Răsfoiți Sursa

first commit

allens 3 săptămâni în urmă
comite
d0aca91787
68 a modificat fișierele cu 3843 adăugiri și 0 ștergeri
  1. 66 0
      .gitignore
  2. 9 0
      LICENSE
  3. 204 0
      README.md
  4. 88 0
      init-ocb.sh
  5. 78 0
      ownchatbot/__init__.py
  6. 155 0
      ownchatbot/bot_messages.py
  7. 353 0
      ownchatbot/db.py
  8. 4 0
      ownchatbot/defaults/announce.py
  9. 3 0
      ownchatbot/defaults/categories.py
  10. 13 0
      ownchatbot/defaults/config.py
  11. 6 0
      ownchatbot/defaults/kofi.py
  12. 53 0
      ownchatbot/defaults/rewards.py
  13. 60 0
      ownchatbot/kofi_handlers.py
  14. 53 0
      ownchatbot/owncast_com.py
  15. 353 0
      ownchatbot/reward_handlers.py
  16. 45 0
      ownchatbot/schema.sql
  17. BIN
      ownchatbot/static/img/favicon.ico
  18. BIN
      ownchatbot/static/img/kofi/Ko-fi_COIN.png
  19. BIN
      ownchatbot/static/img/kofi/Ko-fi_CONFETTI.png
  20. BIN
      ownchatbot/static/img/kofi/Ko-fi_FIRE.png
  21. BIN
      ownchatbot/static/img/kofi/Ko-fi_HEART.png
  22. BIN
      ownchatbot/static/img/kofi/Ko-fi_MUSIC.png
  23. BIN
      ownchatbot/static/img/kofi/Ko-fi_RAINBOW.png
  24. BIN
      ownchatbot/static/img/kofi/KofiAndCream_500.png
  25. BIN
      ownchatbot/static/img/kofi/KofiCoffee_500.png
  26. BIN
      ownchatbot/static/img/kofi/KofiHearts_500.png
  27. BIN
      ownchatbot/static/img/kofi/KofiLatte_500.png
  28. BIN
      ownchatbot/static/img/kofi/KofiMug_500.png
  29. BIN
      ownchatbot/static/img/kofi/KofiPour_500.png
  30. BIN
      ownchatbot/static/img/kofi/KofiRainbowHeart_500.png
  31. BIN
      ownchatbot/static/img/kofi/KofiRainbowMug_500.png
  32. BIN
      ownchatbot/static/img/kofi/KofiSplash_500.png
  33. BIN
      ownchatbot/static/img/kofi/kofi_logo.png
  34. BIN
      ownchatbot/static/img/kofi/kofi_symbol.png
  35. BIN
      ownchatbot/static/img/kofi/support_me_on_kofi_badge_beige.png
  36. BIN
      ownchatbot/static/img/milestone.png
  37. BIN
      ownchatbot/static/img/ownchatbotwide.png
  38. BIN
      ownchatbot/static/img/smallrobo.png
  39. BIN
      ownchatbot/static/img/tada.png
  40. 42 0
      ownchatbot/static/mgmtpanel.js
  41. 159 0
      ownchatbot/static/style.css
  42. 42 0
      ownchatbot/static/userpanel.js
  43. 153 0
      ownchatbot/templates/add.html
  44. 131 0
      ownchatbot/templates/edit.html
  45. 32 0
      ownchatbot/templates/edit_account.html
  46. 50 0
      ownchatbot/templates/goals.html
  47. 526 0
      ownchatbot/templates/mgmt.html
  48. 239 0
      ownchatbot/templates/userpanel.html
  49. 44 0
      ownchatbot/templates/votes.html
  50. 2 0
      ownchatbot/update_db.sql
  51. 230 0
      ownchatbot/user_handlers.py
  52. 444 0
      ownchatbot/web_panels.py
  53. 141 0
      ownchatbot/webhooks.py
  54. BIN
      ownchatbotwide.xcf
  55. 29 0
      pyproject.toml
  56. BIN
      screenshots/mgmtmanel07.png
  57. BIN
      screenshots/mgmtpanel01.png
  58. BIN
      screenshots/mgmtpanel02.png
  59. BIN
      screenshots/mgmtpanel03.png
  60. BIN
      screenshots/mgmtpanel04.png
  61. BIN
      screenshots/mgmtpanel05.png
  62. BIN
      screenshots/mgmtpanel06.png
  63. BIN
      screenshots/overlays.png
  64. BIN
      screenshots/userpanel01.png
  65. BIN
      screenshots/userpanel02.png
  66. 14 0
      setup.py
  67. BIN
      smallrobo.xcf
  68. 22 0
      update-db.sh

+ 66 - 0
.gitignore

@@ -0,0 +1,66 @@
+# ---> Python
+# Byte-compiled / optimized / DLL files
+instance/
+.kdev4/
+/*.kdev4
+.git/
+
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+env/
+.venv/
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# PyInstaller
+#  Usually these files are written by a python script from a template
+#  before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*,cover
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+

+ 9 - 0
LICENSE

@@ -0,0 +1,9 @@
+OwnchatBot © 2025 by DeadTOm is licensed under Creative Commons Attribution-ShareAlike 4.0 International
+
+This license requires that reusers give credit to the creator. It allows reusers to distribute, remix, adapt, and build upon the material in any medium or format, even for commercial purposes. If others remix, adapt, or build upon the material, they must license the modified material under identical terms.
+
+BY: Credit must be given to you, the creator.
+SA: Adaptations must be shared under the same terms.
+
+See the License Deed -> https://creativecommons.org/licenses/by-sa/4.0/
+

+ 204 - 0
README.md

@@ -0,0 +1,204 @@
+# OwnchatBot
+![smallrobo](https://www.deadtom.me/~deadtom/img/ownchatbotwide.png)
+
+A chatbot for Owncast, enabling viewers to accumulate points which can be used to redeem stream rewards.
+
+## Table of Contents
+- [Features](#features)
+- [Screenshots](#screenshots)
+- [Roadmap](#roadmap)
+- [Installation](#installation)
+- [Running OwnchatBot](#running-ownchatbot)
+- [Final Configuration](#final-configuration)
+- [Kofi Integration](#kofi-integration)
+- [Overlays](#Overlays)
+- [License](#license)
+- [Attribution](#attribution)
+
+## Features
+* The standard chat redeems most of us are familiar with. Viewer spends points for the redeem, streamer does/says a thing.
+* Voting. The streamer can create votes, and viewers can vote for them.
+  * The current vote tally can be set up as a browser source, to display on-screen.
+* Special redeems that will run commands for things like triggering webhooks and running scripts.
+* Goals that viewers can collectively contribute points to for rewards.
+  * Goal progress can be set up as a browser source in your streaming software, to display on-screen.
+* Cool down timers for redeems and specials.
+* Automated announcements at regular intervals.
+* Web based viewer panel for viewing the reward queue, and points and reward information.
+* Web based management panel.
+ * Configure OwnchatBot settings, Owncast integration and Kofi integration.
+ * Add/edit/remove rewards and reward categories.
+ * Viewer management for manually adjusting viewers' points.
+ * A queue manager, where the streamer can mark rewards fulfilled as they go, or refund rewards if needed.
+* Kofi integration.
+ * Your viewers can get points for donating via kofi.
+ * *Monthly dontation/subscription and shop integration coming next*
+
+## Screenshots
+#### Votes and Goals Overlays
+<a href=https://git.deadtom.me/deadtom/OwnchatBot/src/master/screenshots/overlays.png><img src=https://git.deadtom.me/deadtom/OwnchatBot/raw/master/screenshots/overlays.png width=100></a>
+
+#### Viewer Panel
+<a href=https://git.deadtom.me/deadtom/OwnchatBot/src/master/screenshots/userpanel01.png><img src=https://git.deadtom.me/deadtom/OwnchatBot/raw/master/screenshots/userpanel01.png width=100></a>&nbsp;&nbsp;
+<a href=https://git.deadtom.me/deadtom/OwnchatBot/src/master/screenshots/userpanel02.png><img src=https://git.deadtom.me/deadtom/OwnchatBot/raw/master/screenshots/userpanel02.png width=100></a>
+
+#### Management Panel
+<a href=https://git.deadtom.me/deadtom/OwnchatBot/src/master/screenshots/mgmtpanel01.png><img src=https://git.deadtom.me/deadtom/OwnchatBot/raw/master/screenshots/mgmtpanel01.png width=100></a>&nbsp;&nbsp;
+<a href=https://git.deadtom.me/deadtom/OwnchatBot/src/master/screenshots/mgmtpanel02.png><img src=https://git.deadtom.me/deadtom/OwnchatBot/raw/master/screenshots/mgmtpanel02.png width=100></a>&nbsp;&nbsp;
+<a href=https://git.deadtom.me/deadtom/OwnchatBot/src/master/screenshots/mgmtpanel03.png><img src=https://git.deadtom.me/deadtom/OwnchatBot/raw/master/screenshots/mgmtpanel03.png width=100></a>&nbsp;&nbsp;
+<a href=https://git.deadtom.me/deadtom/OwnchatBot/src/master/screenshots/mgmtpanel04.png><img src=https://git.deadtom.me/deadtom/OwnchatBot/raw/master/screenshots/mgmtpanel04.png width=100></a>&nbsp;&nbsp;
+<a href=https://git.deadtom.me/deadtom/OwnchatBot/src/master/screenshots/mgmtpanel05.png><img src=https://git.deadtom.me/deadtom/OwnchatBot/raw/master/screenshots/mgmtpanel05.png width=100></a>&nbsp;&nbsp;
+<a href=https://git.deadtom.me/deadtom/OwnchatBot/src/master/screenshots/mgmtpanel06.png><img src=https://git.deadtom.me/deadtom/OwnchatBot/raw/master/screenshots/mgmtpanel06.png width=100></a>&nbsp;&nbsp;
+<a href=https://git.deadtom.me/deadtom/OwnchatBot/src/master/screenshots/mgmtpanel07.png><img src=https://git.deadtom.me/deadtom/OwnchatBot/raw/master/screenshots/mgmtpanel07.png width=100></a>&nbsp;&nbsp;
+
+## Roadmap
+*Not necessarily in this order*
+* Kofi monthly subscription benefits
+* Get emojis in OwnchatBot panels working correctly, because my partner insists on it
+* Audible sound when a goal is reached
+* Streak tracker, to track how many consecutive streams a viewer has watched, and reward accordingly
+* Option to only allow authenticated users, followers, or Kofi subscribers to redeem certain rewards.
+* !Timer - Set a timer that will sound an audible alarm. A command only available to the streamer or mods.
+* A "watch" function that watches chat for certain words or phrases, and responds with preset messages.
+
+## Installation
+#### These instructions assume that you have git, and a recent version of python installed.
+1. You can simply download the zip/tar file from the git repo, and decompress it, but I recommend cloning the repo instead. It will make upgrading to future versions much easier.
+    ```bash
+    git clone https://git.deadtom.me/deadtom/OwnchatBot.git
+    ```
+
+2. At the command line, descend into the "OwnchatBot" folder.
+
+3. To start the install, run:
+    ```bash
+    bash init-ocb.sh
+    ```
+4. The last lines of init-ocb's output tell you how to proceed to the management panel, where you can set up Owncast integraion, and other odds and ends.
+
+More detailed instructions for running OwnchatBot are covered below.
+
+## Upgrading
+*Make a backup of your OwnchatBot/instance folder.*
+
+Descend into the OwnchatBot directory. Run:
+  ```bash
+  git pull
+  ```
+If you installed by downloading from the repo, rather than doing a git clone, simply download the new version and extract the file over your existing installation.
+
+### Update the database
+Run:
+  ```bash
+  bash update-db.sh
+  ```
+
+## Running OwnchatBot
+### Running in Flask's native server
+Port 8081 is used here as an example. Be sure to set a correct, usable port number.
+
+Set the FLASK_APP variable:
+```bash
+export FLASK_APP=ownchatbot
+```
+Run the bot in debug mode:
+*For the love of whatever it is that you personally find holy, don't run it this way in production*
+```bash
+env/bin/python -m flask --debug run --host=0.0.0.0 -p 8081
+```
+Run the bot:
+```bash
+env/bin/python -m flask run --host=0.0.0.0 -p 8081
+```  
+### Running in Gunicorn
+Set the FLASK_APP variable:
+```bash
+export FLASK_APP=ownchatbot
+```
+Run OwnchatBot:
+```bash
+env/bin/python -m gunicorn -b 0.0.0.0:8081 -w 1 'ownchatbot:create_app()'
+```
+
+The above command directs all output to the console, but I prefer to have it all dump into a log file.
+You can do this with a small modification to this command:
+```bash
+env/bin/python -m gunicorn --error-logfile ownchatbot.log -b 0.0.0.0:8081 -w 1 'ownchatbot:create_app()'
+```
+
+## Final configuration
+Once OwnchatBot is running, you can start setting up your rewards and other options via the Management Panel. When you finish running init-ocb.sh, it gives you a URL for your management panel. It will look something like this, with a proper, pregenerated auth code attached to it.
+
+http://localhost:8081/mgmt?auth=yourMGMT_AUTHcode
+
+Once you're in the OwnchatBot management panel, bookmark it.
+
+The first thing you'll want to visit is the settings page. Each option is explained in detail. Then you'll need to get it talking to your Owncast instance, and Kofi if you'll be using it, covered below.
+
+### Owncast chat connection
+OwnchatBot accesses Owncast chat room information via a webhook. You'll need to point Owncast at that webhook for things to function.
+1. Go to your Owncast Admin panel -> Integrations -> Webhooks
+2. Click on "Create Webhook"
+3. In the top field, enter OwnchatBot's address and port, with `/chatHook` appended (Example: http://localhost:8081/chatHook), which you'll set up below in the "Running OwnchatBot" section.
+4. Click the "Select All" button.
+5. Click "OK"
+
+### Owncast viewer panel button
+You need to create a button on your Owncast page, so your viewers can access the Rewards Panel to view their points, the rewards queue, and other helpful information. This will only work with an SSL enabled server. If you're running Owncast behind a reverse proxy (which you really should be) then you need to set up another reverse proxy for OwnchatBot. Sorry, you're on your own for how to do that. It's way outside the scope of this document.
+
+1. Go to your Owncast Admin panel -> Integrations -> External Actions.
+2. Click on "Create New Action"
+3. Set the second field to your external OwnchatBot server address, and the port you set up in the "Running OwnchatBot" section.
+4. Set the third field (action title) to whatever you want the button to be named.
+    Example:
+    ```
+    http://external.ownchatbot.url/userpanel
+    Action Title: Stream Rewards
+    ```
+5. Configure the remaining fields/options as desired.
+
+### Kofi Integration
+Integration is accomplished via a webhook triggered by Kofi every time a donation is made.
+
+Paste the following webhook into Kofi -> More -> API -> Webhooks -> Webhook URL.
+  ```
+  https://external.ownchatbot.url/kofiHook
+  ```
+
+Click "Update", but stay on this page.
+
+You need a verification token which Kofi will send when it triggers the webhook.
+Below "Webhook URL", click on "Advanced".
+A pre-generated token will already be there. Copy that token, and paste it into the OCB Management Panel -> Settings -> Kofi Integration -> Verification Token. Then click "Save Changes".
+
+OwnchatBot associates viewer accounts with Kofi accounts using their email address. So in order for viewers to get Kofi benefits in-stream, they must enter their email address in OwnchatBot viewer panel -> OwnchatBot Info -> Kofi. It **must** be the same email address associated with their Kofi account.
+
+If a viewer donates before entering their email address, OwnchatBot creates a temporary entry with their email and donation amount, and then applies it once the viewer enters their email address into the viewer panel. If there is any sort of mix-up here, the streamer can manually connect the viewer's email and account via the management panel.
+
+Email addresses are **ONLY** used for Kofi integration. They are not sent to any other individual or company, and will not be used to create or send mailing lists of any kind.
+
+### Overlays
+To display vote and goal progress on-screen, in your stream, you need to create two browser sources in your streaming software.
+
+Votes: ```http://localhost:8081/votes```
+* Recommended width: 420
+* Recommended height: 150
+* In OBS, check "Refresh browser source when scene becomes active"
+
+Goals ```http://localhost:8081/goals```
+* Recommended width: 610
+* Recommended height: 210
+* In OBS, check "Refresh browser source when scene becomes active"
+
+
+## License
+OwnchatBot © 2025 by [DeadTOm](https://www.deadtom.me) is licensed under [Creative Commons Attribution-ShareAlike 4.0 International](https://creativecommons.org/licenses/by-sa/4.0/).
+
+## Attribution
+The SmallRobo image was created by Mike Carey, mikecarey134@gmail.com.
+I've modified the image, but you can find the original image at [Open Game Art](https://opengameart.org/content/smallrobo).
+At the time of this writing, it is released under a [Creative Commons CC-BY 3](https://creativecommons.org/licenses/by/3.0/) license.
+
+The basic layout of the user and management panels was copied from the [tab bar tutorial on w3schools.com](https://www.w3schools.com/howto/howto_js_tabs.asp).
+
+The food that keeps me alive during all-weekend coding sessions is provided by [Wonder](https://mas.to/@wonderwmn).

+ 88 - 0
init-ocb.sh

@@ -0,0 +1,88 @@
+#!/bin/bash
+#
+
+set -e  #  Exit immediately if any command exits with a non-zero status
+
+check_venv() {  # Check if the venv module is available
+    if python3 -c "import venv" &> /dev/null; then
+        return 0
+    else
+        echo "The 'venv' module is not available. Please ensure you are using Python 3.3 or later."
+        return 1
+    fi
+}
+
+create_venv() {
+    python3 -m venv env
+}
+
+activate_venv() {
+    source env/bin/activate
+}
+
+install_dependencies() {
+    pip install --upgrade pip || { echo "Failed to upgrade pip"; exit 1; }
+    pip install gunicorn || { echo "Failed to install Gunicorn"; exit 1; }
+    pip install -e . || { echo "Failed to install dependencies"; exit 1; }
+}
+
+initialize_db() {  # Create the database. This also populates it with some goals and votes from the default rewards.py
+    export FLASK_APP=ownchatbot
+    if python -m flask init-db; then
+        echo "Database initialized successfully."
+    else
+        echo "Failed to initialize the database. Please check for errors."
+        exit 1  # Exit the script with a non-zero status
+    fi
+}
+
+generate_key() {
+    KEY=$(< /dev/urandom tr -dc 'A-Za-z0-9' | head -c 32)
+    echo "$KEY"
+}
+
+update_config() {  # Generate keys for SECRET_KEY and MGMT_AUTH
+    local key_name="$1"
+    local key_value="$2"
+    local bak_file="instance/config.py.bak"
+
+    # Update the config file
+    if sed -i.bak "s|$key_name = ''|$key_name = '$key_value'|" "instance/config.py"; then
+        echo "Updated $key_name successfully."
+        rm "$bak_file"  # Remove the .bak file if the update was successful
+    else
+        echo "Failed to update $key_name. Backup file remains."
+        exit 1  # Exit the script with a non-zero status
+    fi
+}
+
+if check_venv; then
+    create_venv
+else
+    exit 1
+fi
+
+activate_venv
+install_dependencies
+initialize_db
+deactivate
+
+cp ownchatbot/defaults/*py instance/  # Copy the default config files into the instance folder
+
+SECRET_KEY=$(generate_key)
+update_config "SECRET_KEY" "$SECRET_KEY"
+
+MGMT_AUTH=$(generate_key)
+update_config "MGMT_AUTH" "$MGMT_AUTH"
+
+read -p "Please enter the port number you would like OwnchatBot to listen on: " OCB_PORT
+
+echo "
+You're ready to start OwnchatBot! Run:
+
+env/bin/python -m gunicorn --error-logfile ownchatbot.log -b 0.0.0.0:$OCB_PORT -w 1 'ownchatbot:create_app()'
+
+To configure your bot, go to:
+
+http://localhost:$OCB_PORT/mgmt?auth=$MGMT_AUTH
+"

+ 78 - 0
ownchatbot/__init__.py

@@ -0,0 +1,78 @@
+import os
+import logging
+from flask import Flask
+from ownchatbot.db import get_db
+from ownchatbot.owncast_com import live_now, award_points, send_chat
+from apscheduler.schedulers.background import BackgroundScheduler
+
+current_index = 0
+
+
+def create_app(test_config=None):
+    app = Flask(__name__, instance_relative_config=True)
+
+    try:
+        os.makedirs(app.instance_path)
+    except OSError:
+        pass
+
+    app.config.from_mapping(
+        DATABASE=os.path.join(app.instance_path, 'ownchatbot.sqlite')
+    )
+    app.config.from_object('ownchatbot.defaults.config')  # Read from config files
+    app.config.from_pyfile('config.py', silent=True)
+    app.config.from_object('ownchatbot.defaults.rewards')
+    app.config.from_pyfile('rewards.py', silent=True)
+    app.config.from_object('ownchatbot.defaults.categories')
+    app.config.from_pyfile('categories.py', silent=True)
+    app.config.from_object('ownchatbot.defaults.kofi')
+    app.config.from_pyfile('kofi.py', silent=True)
+    app.config.from_object('ownchatbot.defaults.announce')
+    app.config.from_pyfile('announce.py', silent=True)
+
+    if app.config['GUNICORN']:  # Gunicorn logging integration
+        gunicorn_logger = logging.getLogger('gunicorn.error')
+        app.logger.handlers = gunicorn_logger.handlers
+        app.logger.setLevel(gunicorn_logger.level)
+
+    from . import webhooks  # Set up blueprints
+    from . import web_panels
+    app.register_blueprint(webhooks.ocb)
+    app.register_blueprint(web_panels.ocb)
+
+    from . import db  # Set up cli commands
+    db.init_app(app)
+
+    announcements = app.config['ANNOUNCEMENTS']
+
+    def announce():
+        if app.config['ANNOUNCE_ENABLE']:  # If announcements are enabled
+            global current_index
+            message = announcements[current_index]
+            send_chat(message)
+            current_index = (current_index + 1) % len(announcements)
+        else:
+            app.logger.info(f'Not live, so not sending announcement.')
+                
+    def award_job():
+        with app.app_context():
+            if live_now():  # If stream is live
+                award_points(get_db())
+                
+    def announce_job():
+        with app.app_context():
+            if live_now():  # If stream is live
+                announce()
+
+    jorel_master_of_scheduling = BackgroundScheduler()
+    points_seconds = app.config['POINTS_INTERVAL'] * 60
+    announce_seconds = app.config['ANNOUNCE_INTERVAL'] * 60
+    jorel_master_of_scheduling.add_job(award_job, 'interval', seconds=points_seconds)
+    jorel_master_of_scheduling.add_job(announce_job, 'interval', seconds=announce_seconds)
+    jorel_master_of_scheduling.start()
+
+    return app
+
+
+if __name__ == '__main__':
+    create_app()

+ 155 - 0
ownchatbot/bot_messages.py

@@ -0,0 +1,155 @@
+from flask import current_app
+from ownchatbot.db import get_db, is_cool
+from ownchatbot.owncast_com import send_chat
+from ownchatbot.reward_handlers import run_script, add_to_queue, add_to_vote, add_to_goal, was_goal_reached, goal_reached, is_reward_active, check_vote, all_active_goals, goal_left
+from ownchatbot.user_handlers import spend_points, get_users_points, refund_points, get_all_users_with_user_id
+import os
+
+
+def porps(points):  # Pluralize points based on the number of points
+    if points == 1:
+        num = 'point'
+    else:
+        num = 'points'
+    return f'{points} {num}'
+
+
+def mas(time_diff):  # Convert time difference decimal number to minutes and seconds
+    minutes = int(time_diff)
+    seconds = (time_diff - minutes) * 60
+    seconds = round(seconds)
+    if minutes > 1:
+        mnum = 'minutes'
+    else:
+        mnum = 'minute'
+    if seconds > 1:
+        snum = 'seconds'
+    else:
+        snum = 'second'
+    if minutes == 0:
+        return f'{seconds} {snum}'
+    else:
+        return f'{minutes} {mnum} and {seconds} {snum}'
+
+
+def do_reward(message, user_id):  # Parse the chat command
+    db = get_db()
+    for user in get_all_users_with_user_id(db, user_id):
+        username = user[1]
+    prefix = current_app.config['PREFIX']
+    split_message = message[1:].split(maxsplit=1)
+    reward = split_message[0]
+    if len(split_message) == 1:  # If it's a goal contribution, split the command and the contribution
+        contribution = None
+    else:
+        contribution = split_message[1]
+
+    if reward not in current_app.config['REWARDS']:  # Check if it's a command or a reward
+        send_chat(f'{username}, \"{prefix}{reward}\" is not a chat command or a reward. Check your spelling?')
+        return
+    if not is_reward_active(reward):  # If reward isn't active, say so
+        send_chat(f'Sorry, {username}. \"{prefix}{reward}\" is not currently an active reward.')
+        return
+
+    reward_type = current_app.config['REWARDS'][reward]['type']
+    points = get_users_points(db, user_id)
+
+    if reward_type == 'goal':  # If it's a goal contribution, do the thing
+        if not contribution:
+            send_chat(f'{username}, you didn\'t tell me how many points you want to contribute.')
+            return
+        if goal_reached(db, reward):
+            send_chat(f'{username}, we already completed this goal.')
+            return
+        if int(contribution) > goal_left(db, reward):  # If they're contributing more than they need to
+            current_app.logger.info(f'{username} contributed more than what was needed to reach the target.')
+            contribution = goal_left(db, reward)  # only spend what is needed to reach the goal.
+        if int(contribution) > points:
+            send_chat(f'{username}, you don\'t have that many points.')
+        elif int(contribution) < 0:
+            send_chat(f'{username}, you can\'t contribute negative points.')
+        elif int(contribution) == 0:
+            send_chat(f'{username}, you can\'t contribute zero points.')
+        elif add_to_goal(db, user_id, reward, int(contribution)):
+            send_chat(f'{username} contributed {porps(contribution)} to the \"{prefix}{reward}\" goal.')
+            if was_goal_reached(db, reward):
+                send_chat(f'\"{prefix}{reward}\" target reached! 🎉')
+        else:
+            send_chat(f'Couldn\'t contribute to the \"{prefix}{reward}\" goal for {username}, for some highly technical reason.')
+        return
+
+    price = current_app.config['REWARDS'][reward]['price']  # Do they have enough points?
+    if not points or points < price:
+        send_chat(f'{username}, you don\'t have enough points for \"{prefix}{reward}\".')
+        return
+
+    if reward_type == 'vote':  # If it's a vote, do the thing
+        if check_vote(db, reward, user_id):  # See if viewer already voted
+            send_chat(f'{username}, you already voted for \"{prefix}{reward}\".')
+        else:
+            if add_to_vote(db, reward, user_id) and spend_points(db, user_id, price):
+                send_chat(f'{username} voted for \"{prefix}{reward}\" for {porps(price)}.')
+            else:
+                send_chat(f'Couldn\'t vote for \"{prefix}{reward}\" for {username}, for some highly technical reason.')
+            
+    elif reward_type == 'redeem':  # If it's a redeem, do the thing
+        if is_cool(reward)[0]:  # Is there an active cool down?
+            if (add_to_queue(db, user_id, reward) and
+                    spend_points(db, user_id, price)):
+                send_chat(f'{username} redeemed \"{prefix}{reward}\" for {porps(price)}.')
+            else:
+                send_chat(f'Couldn\'t redeem \"{prefix}{reward}\"for {username}, for some highly technical reason.')
+        else:
+            hot_time = is_cool(reward)[1]
+            send_chat(f'Couldn\'t redeem \"{prefix}{reward}\"for {username}.<br>That reward has {mas(hot_time)} left to cool down.')
+            
+    elif reward_type == 'special':  # If it's a special, do the thing
+        if is_cool(reward)[0]:  # Is there an active cool down?
+            script_cmd = current_app.config['REWARDS'][reward]['cmd']
+            script_ran = run_script(reward, script_cmd)
+            used_points = spend_points(db, user_id, price)
+            if (script_ran and used_points):
+                send_chat(f'{username} redeemed \'{prefix}{reward}\' for {porps(price)}.')
+            else:
+                if refund_points(db, user_id, price):
+                    send_chat(f'Couldn\'t redeem \"{prefix}{reward}\"for {username}, for some highly technical reason.')
+        else:
+            hot_time = is_cool(reward)[1]
+            send_chat(f'Couldn\'t redeem \"{prefix}{reward}\"for {username}.<br>That reward has {mas(hot_time)} left to cool down.')       
+            
+    else:  # If we can't find the reward, say so
+        send_chat(f'\"{prefix}{reward}\", {username}? No such reward.')
+
+
+def help_message():
+    prefix = current_app.config['PREFIX']
+    kofi_settings = current_app.config['KOFI_SETTINGS']
+    kofi_integration = current_app.config['KOFI_INTEGRATION']
+    message = f'You get {current_app.config["POINTS_AWARD"]} points for every {current_app.config["POINTS_INTERVAL"]} seconds you\'re in chat.<br> \
+            You can see your points, the rewards queue, and other helpful information by clicking on the \"Points Rewards\" button.<br><br> \
+            Chat commands:<br> \
+            {prefix}help to see this help message.<br> \
+            {prefix}points to see your points.<br> \
+            {prefix}rewards to see a list of currently active rewards.<br>'
+    if kofi_integration and kofi_settings['tips']:
+        message = f'{message}<br>\
+        Kofi is enabled! Once you\'ve authenticated with Owncast, you\'ll recieve\
+        {kofi_settings["tip_points"]} points for every dollar you tip on Kofi.'
+    send_chat(message)
+
+
+def save_announce(announce_dict):  # Write rewards to announce.py
+    announce_file = os.path.join(current_app.instance_path, 'announce.py')
+    new_announce = f"ANNOUNCEMENTS = {announce_dict['ANNOUNCEMENTS']}  # List of announcements\n\n\
+ANNOUNCE_ENABLE = {announce_dict['ANNOUNCE_ENABLE']}  # Enable announcements\n\
+ANNOUNCE_INTERVAL = {announce_dict['ANNOUNCE_INTERVAL']}  # How long, in minutes, between points announcements"
+    
+    try:
+        with open(announce_file, 'w') as f:
+            f.write(new_announce)
+    except Exception as saerror:
+        current_app.logger.error(f'Couldn\'t save announce.py: {saerror.args[0]}')
+        return False
+    
+    current_app.config.from_pyfile('announce.py', silent=True)
+    return True

+ 353 - 0
ownchatbot/db.py

@@ -0,0 +1,353 @@
+import sqlite3
+from flask import current_app, g
+from ownchatbot.reward_handlers import goal_reached, get_queue
+from flask.cli import with_appcontext
+import click
+from time import time
+import os
+
+
+def rem_from_queue(reward_name):  # Remove a reward from the queue
+    db = get_db()
+
+    try:
+        cursor = db.execute(
+            "DELETE FROM reward_queue WHERE reward = ?", (reward_name,)
+        )
+        db.commit()
+    except sqlite3.Error as rfqerror:
+        print(f'Couldn\'t remove {reward_name} from reward queue: {rfqerror.args[0]}')
+        return False
+    return True
+
+
+def init_app(app):
+    app.teardown_appcontext(close_db)
+    app.cli.add_command(init_db_command)
+    app.cli.add_command(update_db_command)
+    
+
+def get_db():
+    if 'db' not in g:
+        g.db = sqlite3.connect(
+            current_app.config['DATABASE'],
+            detect_types=sqlite3.PARSE_DECLTYPES
+        )
+        g.db.row_factory = sqlite3.Row
+
+    return g.db
+
+
+def close_db(e=None):
+    db = g.pop('db', None)
+
+    if db is not None:
+        db.close()
+
+
+def init_db():  # Setup the database. If it already exists, apply new schema
+    db = get_db()
+
+    with current_app.open_resource('schema.sql') as f:  # Set up database
+        db.executescript(f.read().decode('utf8'))
+
+    reread_votes()
+    reread_goals()
+    if get_queue(db):  # Make sure database is initialized
+        return True
+
+
+def update_db():
+    db = get_db()
+
+    update_db_path = os.path.join(current_app.root_path, 'update_db.sql')  # Check if the update_db.sql file exists
+    if os.path.exists(update_db_path):  # Update it if it does
+        with current_app.open_resource('update_db.sql') as f:
+            db.executescript(f.read().decode('utf8'))
+
+    if get_queue(db):  # Make sure database is initialized
+        return True
+
+
+@click.command('init-db')
+@with_appcontext
+def init_db_command():  # If there is already a database, clear it and do setup
+    if init_db():
+        click.echo('Database initialized.')
+
+
+@click.command('update-db')
+@with_appcontext
+def update_db_command():  # If there is already a database, clear it and do setup
+    if update_db():
+        click.echo('Database initialized.')
+
+
+def clear_reward_queue():  # Completely clear the reward queue
+    db = get_db()
+
+    try:
+        cursor = db.execute(
+            "DELETE FROM reward_queue"
+        )
+        cursor.execute(
+            "UPDATE votes SET count = 0"
+        )
+        db.commit()
+    except sqlite3.Error as serror:
+        print(f'Couldn\'t clear reward queue: {serror.args[0]}')
+        return False
+    return True
+
+
+def clear_fulfilled_rewards():  # Clears only fulfilled rewards from the queue
+    db = get_db()
+
+    try:
+        cursor = db.execute(
+            "DELETE FROM reward_queue WHERE fulfilled = 1"
+        )
+        db.commit()
+    except sqlite3.Error as serror:
+        print(f'Couldn\'t clear fulfilled rewards: {serror.args[0]}')
+        return False
+    return True
+
+
+def rem_all_votes():  # USED TO BE "clear_votes" Clear all votes from the database
+    db = get_db()
+
+    try:
+        db.execute("DELETE FROM votes")
+        db.commit()
+    except sqlite3.Error as cverror:
+        print(f'Couldn\'t clear all votes: {cverror.args[0]}')
+        return False
+    if put_votes(db):
+        return True
+
+
+def rem_vote():  # Remove a single vote from the database
+    db = get_db(vote)
+
+    try:
+        db.execute("DELETE FROM votes WHERE name = ?", ('vote',))
+        db.commit()
+    except sqlite3.Error as rverror:
+        print(f'Couldn\'t remove \"{vote}\" from database: {rverror.args[0]}')
+        return False
+    if put_votes(db):
+        return True
+
+
+def is_cool(reward_name):  # Check if a reward is cooling down.
+    current_time = time()  # In seconds
+    rewards = current_app.config['REWARDS']
+    this_cool_down = rewards[reward_name]['cooldown']
+    if this_cool_down > 0:  # If this reward has a cooldown
+        try:  # Get the cooldown time for it
+            db = get_db()
+            cursor = db.execute(
+                "SELECT created FROM cool_downs WHERE reward = ?", (reward_name,)
+                )
+            current_cds = cursor.fetchall()
+        except sqlite3.Error as icerror:
+            print(f'Couldn\'t get \"{reward_name}\" from database: {icerror.args[0]}')
+        if current_cds:
+            last_time = current_cds[0][0]
+            hot_time = current_time - last_time
+            if hot_time < this_cool_down * 60:  # If it is in the database and still within the cooldown time, return False
+                hot_time = this_cool_down - (hot_time / 60)  # Get remaining minutes in cooldown
+                return False, hot_time  # Return False, and the time left on the cooldown
+            else:  # If it is in the database and the cooldown has expired, update the cooldown time, and return True
+                try:
+                    db.execute(
+                        "UPDATE cool_downs SET created = ? WHERE reward = ?",
+                        (current_time, reward_name)
+                        )
+                    db.commit()
+                    return True, 0
+                except sqlite3.Error as scerror:
+                    print(f'Couldn\'t update \"{reward_name}\"\'s cooldown time in the database: {scerror.args[0]}')
+        else:  # If it is not in the database, add it and return True
+            try:
+                db.execute(
+                        "INSERT INTO cool_downs(created, reward) VALUES(?, ?)",
+                        (current_time, reward_name)
+                    )
+                db.commit()
+                return True, 0
+            except sqlite3.Error as scerror:
+                print(f'Couldn\'t add \"{reward_name}\" to database: {scerror.args[0]}')
+    else:  # If the redeem has no cooldown
+        return True, 0
+
+
+def rem_cool(reward_name):  # Remove a reward from the database
+    try:
+        db = get_db()
+        cursor = db.execute(
+            "DELETE FROM cool_downs WHERE reward = ?", (reward_name,)
+            )
+        current_cds = cursor.fetchall()
+    except sqlite3.Error as icerror:
+        print(f'Couldn\'t remove \"{reward_name}\" from database: {icerror.args[0]}')
+        return False
+    return True
+
+
+def put_votes(db):  # Reread votes from rewards.py, and sync with database
+    info = details['info']
+    for reward, details in current_app.config['REWARDS'].items():
+        if details['type'] == 'vote':
+            try:
+                db.execute(
+                    "INSERT INTO votes(name, count, info) VALUES(?, 0, ?)",
+                    (reward, info)
+                )
+                db.commit()
+            except sqlite3.Error as serror:
+                print(f'Couldn\'t insert \"{vote}\" into database: {serror.args[0]}')
+                return False
+    return True
+
+
+def reread_votes():  # Reread votes from rewards.py, and sync with database
+    db = get_db()
+
+    try:  # Remove votes not in rewards.py
+        cursor = db.execute("SELECT name FROM votes")
+        votes = cursor.fetchall()
+        chopping_block = []
+        for vote in votes:
+            vote = vote[0]
+            if vote not in current_app.config['REWARDS'].keys():
+                chopping_block.append(vote)
+            elif current_app.config['REWARDS'][vote]['type'] != 'vote':
+                chopping_block.append(vote)
+        for vote in chopping_block:
+            cursor.execute("DELETE FROM votes WHERE name = ?", (vote,))
+        db.commit()
+    except sqlite3.Error as serror:
+        print(f'Couldn\'t clear deleted votes from database: {serror.args[0]}')
+        return False
+
+    try:  # Add new votes found in rewards.py
+        for reward, details in current_app.config['REWARDS'].items():
+            if details['type'] == 'vote':  # Check if the vote is already in the database
+                cursor = db.execute(
+                    "SELECT count FROM votes WHERE name = ?",
+                    (reward,)
+                )
+                result = cursor.fetchone()
+                if result is None:  # If it isn't, add it
+                    info = details['info']
+                    if details['type'] == 'vote':
+                        db.execute(
+                            "INSERT INTO votes(name, count, info) VALUES(?, 0, ?)",
+                            (reward, details["info"])
+                        )
+                else:  # If it is, update the info
+                    cursor.execute(
+                        "UPDATE votes SET info = ? WHERE name = ?",
+                        (details['info'], reward)
+                    )
+                db.commit()
+    except sqlite3.Error as serror:
+        print(f'Couldn\'t insert \"{vote}\" into database: {serror.args[0]}')
+        return False
+    return True
+
+
+def reread_goals():  # Reread goals from rewards.py, and sync with database
+    db = get_db()
+
+    try:  # Remove goals not in rewards.py
+        cursor = db.execute("SELECT name FROM goals")
+        goals = cursor.fetchall()
+        chopping_block = []
+        for goal in goals:
+            goal = goal[0]
+            if goal not in current_app.config['REWARDS'].keys():
+                chopping_block.append(goal)
+            elif current_app.config['REWARDS'][goal]['type'] != 'goal':
+                chopping_block.append(goal)
+        for goal in chopping_block:
+            cursor.execute("DELETE FROM goals WHERE name = ?", (goal,))
+        db.commit()
+    except sqlite3.Error as serror:
+        print(f'Couldn\'t clear removed goals from database: {serror.args[0]}')
+        return False
+
+    try:  # Add new goals found in rewards.py
+        for reward, details in current_app.config['REWARDS'].items():
+            if details['type'] == 'goal':  # Check if the goal is already in the database
+                cursor = db.execute(
+                    "SELECT target FROM goals WHERE name = ?",
+                    (reward,)
+                )
+                result = cursor.fetchone()
+                if result is None:  # If it isn't, add it
+                    cursor.execute(
+                        "INSERT INTO goals(name, progress, target, info, complete) VALUES(?, 0, ?, ?, FALSE)",
+                        (reward, details['target'], details['info'])
+                    )
+                elif result != details['target']:  # If it is, update the target
+                    cursor.execute(
+                        "UPDATE goals SET target = ?, info = ? WHERE name = ?",
+                        (details["target"], details["info"], reward)
+                    )
+        db.commit()
+    except sqlite3.Error as serror:
+        print(f'Couldn\'t insert \"{reward}\" into database: {serror.args[0]}')
+        return False
+    return True
+
+
+def reset_goal(goal):  # Set goal progress back to zero
+    if goal not in current_app.config['REWARDS']:  # If it doesn't exist in rewards.py
+        print(f'Couldn\'t reset goal, {goal} not in rewards file.')
+        return False
+    try:
+        db = get_db()
+        target = current_app.config['REWARDS'][goal]['target']
+        info = current_app.config['REWARDS'][goal]['info']
+        cursor = db.cursor()
+        cursor.execute(
+                        "UPDATE goals SET progress = 0, target = ?, complete = FALSE, info = ? WHERE name = ?",
+                        (target, info, goal)
+                    )
+        db.commit()
+        return True
+    except sqlite3.Error as rgerror:
+        current_app.logger.error(f'Couldn\'t reset {goal} in the goals table: {rgerror.args[0]}')
+        return False
+
+
+def reset_vote(vote):
+    if vote not in current_app.config['REWARDS']:  # Check if it exists in rewards.py
+        print(f'Couldn\'t reset vote, {vote} not in rewards file.')
+        return False
+    else:
+        try:
+            db = get_db()
+            cursor = db.cursor()
+            cursor.execute(
+                        "UPDATE votes SET count = 0, voters = '' WHERE name = ?",
+                        (vote,)
+                    )
+            db.commit()
+        except sqlite3.Error as rverror:
+            current_app.logger.error(f'Couldn\'t reset {vote} in the votes table: {rverror.args[0]}')
+            return False
+    return True
+
+
+
+
+
+
+
+
+
+

+ 4 - 0
ownchatbot/defaults/announce.py

@@ -0,0 +1,4 @@
+ANNOUNCEMENTS = ['Thanks for watching my stream!', 'Don\'t forget to change your nickname.']  # List of announcements
+
+ANNOUNCE_ENABLE = False  # Enable announcements
+ANNOUNCE_INTERVAL = 10  # How long, in minutes, between points announcements

+ 3 - 0
ownchatbot/defaults/categories.py

@@ -0,0 +1,3 @@
+ACTIVE_CAT = ['alwaysactive', 'gaming']
+INACTIVE_CAT = []
+ALL_CAT = ACTIVE_CAT + INACTIVE_CAT

+ 13 - 0
ownchatbot/defaults/config.py

@@ -0,0 +1,13 @@
+# Owncast stuff. Get this information from your Owncast admin panel
+ACCESS_TOKEN = ''  # Get from your Owncast Admin panel -> Integrations -> Access Tokens
+OWNCAST_URL = ''  # Either your external Owncast URL, or your local Owncast URL with the proper port number. (Example: http://localhost:8080)
+
+# OwnchatBot Configuration
+POINTS_INTERVAL = 5  # How long, in minutes, between points awards
+POINTS_AWARD = 5  # How many points awarded each interval?
+SECRET_KEY = ''  # Needed for internal flask stuff. Generated during setup. DO NOT CHANGE
+PREFIX = '!'  # Preceeds commands, so OwnchatBot knows what is a command
+MGMT_AUTH = ''  # Needed to access the OwnchatBot management panel. See README.md for more details.
+GUNICORN = True    # Integrate OwnchatBot logging into Gunicorn
+KOFI_TOKEN = ''  # Needed to validate Ko-fi with OCB webhook. Get from Ko-fi Settings -> More -> API -> Webhooks -> Advanced - Verification Token.
+KOFI_INTEGRATION = False  # Integrate OwnchatBot with Ko-fi

+ 6 - 0
ownchatbot/defaults/kofi.py

@@ -0,0 +1,6 @@
+KOFI_SETTINGS = {
+    "tips": True,  # Reward tips with points
+    "tip_points": 100,  # How many points per dollar tipped?
+    "kofi_url": "https://",  # What is the URL of your Kofi page?
+    "kofi_logo": "kofi_symbol.png"  # Which Kofi logo are we using in the viewer panel?
+}

+ 53 - 0
ownchatbot/defaults/rewards.py

@@ -0,0 +1,53 @@
+REWARDS = {
+    "hydrate": {
+        "price": 14,
+        "type": "redeem",
+        "info": "I\'ll have a sip of my drink",
+        "categories": [
+            "alwaysactive"
+        ],
+        "cooldown": 0
+    },
+    "playgame": {
+        "price": 1,
+        "type": "vote",
+        "info": "Should I play a game?",
+        "categories": [
+            "gaming"
+        ],
+        "cooldown": 0
+    },
+    "wearhat": {
+        "target": 200,
+        "type": "goal",
+        "info": "I'll wear a hat.",
+        "categories": [
+            "alwaysactive"
+        ],
+        "cooldown": 0,
+        "milestones": {
+            "milestone1": [
+                "red hat",
+                50
+            ],
+            "milestone2": [
+                "blue hat",
+                100
+            ],
+            "milestone3": [
+                "green hat",
+                150
+            ]
+        }
+    },
+    "doathing": {
+        "price": 100,
+        "type": "special",
+        "cmd": "/path/to/doathing.script",
+        "info": "Executes a command on the host system.",
+        "categories": [
+            "alwaysactive"
+        ],
+        "cooldown": 0
+    }
+}

+ 60 - 0
ownchatbot/kofi_handlers.py

@@ -0,0 +1,60 @@
+from flask import current_app
+from sqlite3 import Error
+from ownchatbot.db import get_db
+from ownchatbot.user_handlers import get_id_by_email, award_chat_points, add_email_to_points, get_all_users_with_user_id
+from ownchatbot.owncast_com import send_chat
+import json
+import os
+
+
+def accept_donation(donation_info, tip_points):
+    db = get_db()
+    is_public = donation_info[0]
+    email = donation_info[2]
+    amount = donation_info[3]
+    amount = int(float(amount))  # Convert from str to int
+    message = donation_info[4]
+    points = amount * tip_points  # Multiply by streamers tip point award
+    ids = get_id_by_email(db, email)
+    if not ids:  # If no id found with that email address
+        if add_email_to_points(db, email, points):  # Create empty account with email and points
+            name = 'Someone'
+            current_app.logger.info(f'No user with email \"{email}\" found in database, created empty account.')
+    else:  # Grant points to the corresponding id
+        for id in ids:
+            if award_chat_points(db, id[0], points):  # Grant points
+                for user in get_all_users_with_user_id(db, id[0]):
+                    name = user[1]
+                current_app.logger.info(f'Granted user id {id[0]} {points} points for their ${amount} donation.')
+    if is_public:
+        message = f'{name} got {points} points for tipping ${amount} on Kofi!'
+        current_app.logger.info(f'Public donation of ${amount} received from {name}')
+    else:
+        message = None
+        current_app.logger.info(f'Private donation of ${amount} received from {name}')
+    if message is not None:  # Only send chat message if it's a public donation
+        send_chat(message)
+
+
+def save_kofi_settings(ksettings_info):  # Write rewards to kofi.py
+    settings_file = os.path.join(current_app.instance_path, 'kofi.py')
+    try:
+        with open(settings_file, 'w') as f:
+            f.write(f'KOFI_SETTINGS = {ksettings_info}')
+        f.close
+        current_app.config.from_pyfile('kofi.py', silent=True)  # Reread kofi.py into the app
+    except Exception as sks_error:
+        current_app.logger.error(f'Couldn\'t save kofi.py: {sks_error.args[0]}')
+        return False
+    
+    return True
+
+
+def kofi_pngs():  # Create a list of all pngs in the kofi img dir
+    png_dir = 'static/img/kofi'
+    png_dir = os.path.join(current_app.root_path, png_dir)
+    png_files = []
+    for file in os.listdir(png_dir):
+        if file.lower().endswith('.png'):
+            png_files.append(file)
+    return png_files

+ 53 - 0
ownchatbot/owncast_com.py

@@ -0,0 +1,53 @@
+from flask import current_app
+import requests
+from ownchatbot.user_handlers import award_chat_points
+
+
+def live_now():  # Check if stream is live
+    owncast_url = current_app.config['OWNCAST_URL']
+    if owncast_url != '':  # If owncast URL is set, award points
+        url = f'{owncast_url}/api/status'
+        try:
+            response = requests.get(url)
+        except requests.exceptions.RequestException as cserror:
+            current_app.logger.error(f'Couldn\'t check if stream is live: {cserror.args[0]}')
+            return False
+        return response.json()['online']
+    else:
+        current_app.logger.error('Owncast url is not set. Can\'t check if stream is live.')
+
+
+def award_points(db):  # Award points to users
+    owncast_url = current_app.config['OWNCAST_URL']
+    access_token = current_app.config['ACCESS_TOKEN']
+    url = f'{owncast_url}/api/integrations/clients'
+    auth_bearer = f'Bearer {access_token}'
+    headers = {'Authorization': auth_bearer}
+    try:
+        response = requests.get(url, headers=headers)
+    except requests.exceptions.RequestException as aperror:
+        current_app.logger.error(f'Couldn\'t get user info: {aperror.args[0]}')
+        return
+    if response.status_code != 200:
+        current_app.logger.error(f'Couldn\'t award points: {response.status_code}.')
+        return
+    unique_users = set(map(lambda user_object: user_object['user']['id'], response.json()))
+    for user_id in unique_users:
+        award_chat_points(db, user_id, current_app.config['POINTS_AWARD'])
+
+
+def send_chat(message):  # Send message to owncast chat
+    owncast_url = current_app.config['OWNCAST_URL']
+    access_token = current_app.config['ACCESS_TOKEN']
+    url = f'{owncast_url}/api/integrations/chat/send'
+    auth_bearer = f'Bearer {access_token}'
+    headers = {'Authorization': auth_bearer}
+    try:
+        response = requests.post(url, headers=headers, json={'body': message})
+    except requests.exceptions.RequestException as scerror:
+        current_app.logger.error(f'Couldn\'t send {message} to Owncast: {scerror.args[0]}')
+        return
+    if response.status_code != 200:
+        current_app.logger.error(f'Couldn\'t send {message} to Owncast: {response.status_code}.')
+        return
+    return response.json()

+ 353 - 0
ownchatbot/reward_handlers.py

@@ -0,0 +1,353 @@
+import os
+from flask import current_app
+from sqlite3 import Error
+from ownchatbot.user_handlers import spend_points
+import subprocess
+import json
+
+
+def sort_key(item):  # Sort rewards by price
+    price = item[1].get('price')
+    return (price is None, price)
+
+
+def check_vote(db, vote_name, user_id):  # Check if user has already voted on this vote
+    try:
+        cursor = db.execute(
+            "SELECT voters FROM votes WHERE name = ?",
+            (vote_name,)
+        )
+        row = cursor.fetchone()
+        if row is None:
+            current_app.logger.warning(f'\"{vote_name}\" not found in vote table.')
+            return False
+        if row[0] == user_id:
+            return True
+    except Error as cverror:
+        current_app.logger.error(f'Couldn\'t check if {user_id} already voted on \"{vote_name}\": {cverror.args[0]}')
+    return False
+
+
+def add_to_vote(db, vote_name, user_id):  # Add a count to a vote
+    try:  # Check if vote exists in the database
+        cursor = db.execute(
+            "SELECT count FROM votes WHERE name = ?",
+            (vote_name,)
+        )
+        vote = cursor.fetchone()
+        if vote is None:
+            current_app.logger.warning(f'{vote_name} not found in vote table.')
+            return False
+        else:  # If vote exists, add a count
+            db.execute(
+                "UPDATE votes SET count = count + 1, voters = ? WHERE name = ?",
+                (user_id, vote_name,)
+            )
+            db.commit()
+            return True
+    except Error as terror:
+        current_app.logger.error(f'Couldn\'t add to \"{vote_name}\" vote: {terror.args[0]}')
+    return False
+
+
+def add_to_queue(db, user_id, reward_name):  # Add a reward to the queue
+    try:
+        db.execute(
+            "INSERT INTO reward_queue(reward, user_id, fulfilled, refunded) VALUES(?, ?, ?, ?)",
+            (reward_name, user_id, 0, 0)
+            )
+        db.commit()
+        return True
+    except Error as qerror:
+        current_app.logger.error(f'Couldn\'t add to reward \"{reward_name}\" for {user_id} queue: {qerror.args[0]}')
+    return False
+
+
+def run_script(reward_name, script_cmd):  # Run a script form a special reward
+    try:
+        subprocess.check_call(script_cmd, shell=True)
+    except Exception as scerror:
+        current_app.logger.error(f'Couldn\'t run script \"{reward_name}\": {scerror.args[0]}')
+        return False
+    return True
+
+
+def add_to_goal(db, user_id, reward_name, points_contributed):  # Add a contribution to a goal
+    try:
+        cursor = db.execute(
+            "SELECT progress, target FROM goals WHERE name = ?",
+            (reward_name,)
+        )
+        row = cursor.fetchone()
+        if row is None:
+            current_app.logger.warning(f'\"{reward_name}\" not found in goal table.')
+            return False
+        progress, target = row
+        if progress + points_contributed > target:
+            points_contributed = target - progress
+            if points_contributed < 0:
+                points_contributed = 0
+        if spend_points(db, user_id, points_contributed):
+            cursor = db.execute(
+                "UPDATE goals SET progress = ? WHERE name = ?",
+                (progress + points_contributed, reward_name)
+            )
+            db.commit()
+            return True
+    except Error as gerror:
+        current_app.logger.error(f'Couldn\'t update goal: {gerror.args[0]}')
+        return False
+
+
+def goal_left(db, reward_name):
+    try:
+        cursor = db.execute(
+            "SELECT progress, target FROM goals WHERE name = ?",
+            (reward_name,)
+        )
+        row = cursor.fetchone()
+        if row is None:
+            current_app.logger.warning(f'{reward_name} not found in Goal table.')
+        else:
+            progress, target = row
+            left = target - progress
+        return left
+    except Error as glerror:
+        current_app.logger.error(f'Couldn\'t check progress for \"{reward_name}\" goal: {glerror.args[0]}')
+
+
+def goal_reached(db, reward_name):  # Set a goal as completed
+    try:
+        cursor = db.execute(
+            "SELECT complete FROM goals WHERE name = ?",
+            (reward_name,)
+        )
+        row = cursor.fetchone()
+        if row is None:
+            current_app.logger.warning(f'{reward_name} not found in goal table.')
+        else:
+            return row[0]
+    except Error as grerror:
+        current_app.logger.error(f'Couldn\'t check if goal was met: {grerror.args[0]}')
+        return False
+
+
+def was_goal_reached(db, reward_name):  # Check if a goal was reached
+    try:
+        cursor = db.execute(
+            "SELECT progress, target FROM goals WHERE name = ?",
+            (reward_name,)
+        )
+        row = cursor.fetchone()
+        if row is None:
+            current_app.logger.warning(f'{reward_name} not found in Goal table.')
+        else:
+            progress, target = row
+            if progress == target:
+                cursor = db.execute(
+                    "UPDATE goals SET complete = TRUE WHERE name = ?",
+                    (reward_name,)
+                )
+                db.commit()
+                return True
+        return False
+    except Error as wgrerror:
+        current_app.logger.error(f'Couldn\'t mark goal met: {wgrerror.args[0]}')
+        return False
+
+
+def all_votes(db):  # Get all the votes
+    try:
+        cursor = db.execute(
+            "SELECT votes.name, votes.count, votes.info FROM votes"
+        )
+        return cursor.fetchall()
+    except Error as aterror:
+        current_app.logger.error(f'Couldn\'t select all votes: {aterror.args[0]}')
+
+
+def refund_reward(db, reward_id):  # Refund a user for a particular reward
+    reward_id = reward_id
+    try:
+        cursor = db.execute(
+            "UPDATE reward_queue SET refunded = 1 WHERE id = ?",
+            (reward_id,)
+        )
+        db.commit()
+    except Error as rferror:
+        current_app.logger.error(f'Couldn\'t refund reward id {reward_id}: {rferror.args[0]}')
+        return False
+
+
+def fulfill_reward(db, reward_id):  # Mark a reward as fulfilled in the database
+    reward_id = reward_id
+    try:
+        cursor = db.execute(
+            "UPDATE reward_queue SET fulfilled = 1 WHERE id = ?",
+            (reward_id,)
+        )
+        db.commit()
+    except Error as frerror:
+        current_app.logger.error(f'Couldn\'t  fulfill reward id {reward_id}: {frerror.args[0]}')
+        return False
+
+
+def all_active_votes(db):  # Get only active votes
+    votes = all_votes(db)
+    all_active_votes = []
+    for name, count, info in votes:
+        if is_reward_active(name):
+            all_active_votes.append((name, count, info))
+    return all_active_votes
+
+
+def all_goals(db):  # Get all the goals
+    try:
+        cursor = db.execute(
+            """SELECT name, progress, target, info FROM goals"""
+        )
+        return cursor.fetchall()
+    except Error as agerror:
+        current_app.logger.error(f'Couldn\'t select all goals: {agerror.args[0]}')
+
+
+def all_active_goals(db):  # Get only active goals
+    goals = all_goals(db)
+    all_active_goals = []
+    for name, progress, target, info in goals:
+        if is_reward_active(name):
+            all_active_goals.append((name, progress, target, info))
+    return all_active_goals
+
+
+def all_active_rewards():  # Get only active rewards
+    rewards = current_app.config['REWARDS']
+    all_active_rewards = {}
+    for reward_name, reward_dict in rewards.items():
+        if reward_dict.get('categories'):  # If reward has empty categories list
+            for category in reward_dict['categories']:  # Compare each category to ACTIVE_CAT list
+                if category in current_app.config['ACTIVE_CAT']:
+                    all_active_rewards[reward_name] = reward_dict
+                    break
+    return all_active_rewards
+
+
+def save_rewards(reward_info):  # Write rewards to rewards.py
+    sorted_rewards = dict(sorted(reward_info.items(), key=sort_key))
+    
+    new_rewards = json.dumps(sorted_rewards, indent=4)
+    rewards_file = os.path.join(current_app.instance_path, 'rewards.py')
+    
+    try:
+        with open(rewards_file, 'w') as f:
+            f.write(f'REWARDS = {new_rewards}')
+    except Exception as srerror:
+        current_app.logger.error(f'Couldn\'t save rewards.py: {srerror.args[0]}')
+        return False
+    
+    return True
+
+
+def save_config(config_dict):  # Write settings to config.py
+    settings_file = os.path.join(current_app.instance_path, 'config.py')
+    secret_key = current_app.config['SECRET_KEY']
+    new_settings = f"# Owncast stuff. Needed to interact with your Owncast instance\n\
+ACCESS_TOKEN = '{config_dict['ACCESS_TOKEN']}'\n\
+OWNCAST_URL = '{config_dict['OWNCAST_URL']}'\n\
+\n\
+# OwnchatBot Configuration \n\
+SECRET_KEY = '{secret_key}'  # Needed for internal Flask stuff. DO NOT DELETE.\n\
+POINTS_INTERVAL = {config_dict['POINTS_INTERVAL']}  # How long, in seconds, between points awards\n\
+POINTS_AWARD = {config_dict['POINTS_AWARD']}  # How many points awarded each interval?\n\
+GUNICORN = {config_dict['GUNICORN']}  # Integrate OwnchatBot logging into Gunicorn\n\
+PREFIX = '{config_dict['PREFIX']}'  # Preceeds commands, so OwnchatBot knows what is a command\n\
+MGMT_AUTH = '{config_dict['MGMT_AUTH']}'  # Needed to access the OwnchatBot management panel. See README.md for more details.\n\
+KOFI_TOKEN = '{config_dict['KOFI_TOKEN']}'  # Needed to validate Ko-fi with OCB webhook. Get from Ko-fi Settings -> More -> API -> Webhooks -> Advanced - Verification Token.\n\
+KOFI_INTEGRATION = {config_dict['KOFI_INTEGRATION']}  # Integrate OwnchatBot with Ko-fi"
+    
+    try:
+        with open(settings_file, 'w') as f:
+            f.write(new_settings)
+        f.close
+    except Exception as scerror:
+        current_app.logger.error(f'Couldn\'t save config.py: {saerror.args[0]}')
+        return False
+    
+    current_app.config.from_pyfile('config.py', silent=True)  # Reread config.py into the app
+    return True
+
+
+def reread_categories():  # Read _CAT varaibles and write to categories.py
+    categories_file = os.path.join(current_app.instance_path, 'categories.py')
+    active_categories = current_app.config['ACTIVE_CAT']
+    inactive_categories = current_app.config['INACTIVE_CAT']
+    try:
+        with open(categories_file, 'r', encoding='utf-8') as f:  # Read categories.py, and set up lines to change
+            category_data = f.readlines()
+            category_data[0] = f'ACTIVE_CAT = {active_categories}\n'
+            category_data[1] = f'INACTIVE_CAT = {inactive_categories}\n'
+        f.close
+
+        with open(categories_file, 'w', encoding='utf-8') as f:  # Write changes to categories.py
+            f.writelines(category_data)
+        f.close
+
+        current_app.config.from_pyfile('categories.py', silent=True)  # Reread categories into the app
+    except Error as rcerror:
+        current_app.logger.error(f'Couldn\'t reread categories: {rcerror.args[0]}')
+
+
+def activate_category(category):  # Move an item from the ACTIVE_CAT list to the INACTIVE_CAT list
+    try:
+        categories_file = os.path.join(current_app.instance_path, 'categories.py')
+        active_categories = current_app.config['ACTIVE_CAT']
+        inactive_categories = current_app.config['INACTIVE_CAT']
+        active_categories.append(category)  # Add to ACTIVE_CAT
+        inactive_categories.remove(category)  # Remove from INACTIVE_CAT
+        reread_categories()
+    except Error as acerror:
+        current_app.logger.error(f'Couldn\'t activate {category}: {acerror.args[0]}')
+
+
+def deactivate_category(category):  # Move an item from the INACTIVE_CAT list to the ACTIVE_CAT list
+    try:
+        categories_file = os.path.join(current_app.instance_path, 'categories.py')
+        active_categories = current_app.config['ACTIVE_CAT']
+        inactive_categories = current_app.config['INACTIVE_CAT']
+        active_categories.remove(category)  # Remove from ACTIVE_CAT
+        inactive_categories.append(category)  # Add to INACTIVE_CAT
+        reread_categories()
+    except Error as dcerror:
+        current_app.logger.error(f'Couldn\'t deactivate {category}: {dcerror.args[0]}')
+
+
+def get_queue(db):  # Get the reward queue and the username
+    try:
+        cursor = db.execute(
+            """SELECT reward_queue.id, reward_queue.created, reward_queue.reward, reward_queue.user_id, reward_queue.fulfilled, reward_queue.refunded, points.name
+            FROM reward_queue
+            INNER JOIN points
+            on reward_queue.user_id = points.id"""
+        )
+        return cursor.fetchall()
+    except Error as gqerror:
+        current_app.logger.error(f'Couldn\'t get queue: {gqerror.args[0]}')
+
+
+def is_reward_active(reward_name):  # Check if reward is active
+    active_categories = current_app.config['ACTIVE_CAT']
+    reward_dict = current_app.config['REWARDS'].get(reward_name, None)
+    try:
+        if reward_dict:
+            if 'categories' in reward_dict:  # Is there a categories key at all?
+                for category in reward_dict['categories']:  # Cycle through categories and compare to active_categories
+                    if category in active_categories:
+                        return True
+                return False
+            elif reward_dict['categories'] == []:  # If categories key is there but empty, return False
+                return False
+            else:
+                return True
+        return None
+    except Error as iraerror:
+        current_app.logger.error(f'Couldn\'t check if {reward_name} is active: {iraerror.args[0]}')

+ 45 - 0
ownchatbot/schema.sql

@@ -0,0 +1,45 @@
+DROP TABLE IF EXISTS goals;
+DROP TABLE IF EXISTS reward_queue;
+DROP TABLE IF EXISTS votes;
+DROP TABLE IF EXISTS cool_downs;
+
+CREATE TABLE IF NOT EXISTS points (
+  id TEXT PRIMARY KEY,
+  name TEXT,
+  points INTEGER,  
+  user_authed BOOLEAN NOT NULL,
+  email TEXT
+);
+
+CREATE TABLE goals (
+  id INTEGER PRIMARY KEY AUTOINCREMENT,
+  name TEXT NOT NULL,
+  progress INTEGER NOT NULL,
+  target INTEGER NOT NULL,
+  complete BOOLEAN NOT NULL,
+  info TEXT
+);
+
+CREATE TABLE votes (
+  id INTEGER PRIMARY KEY AUTOINCREMENT,
+  name TEXT NOT NULL,
+  count INTEGER NOT NULL,
+  voters TEXT,
+  info TEXT
+);
+
+CREATE TABLE reward_queue (
+  id INTEGER PRIMARY KEY AUTOINCREMENT,
+  created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  reward TEXT NOT NULL,
+  user_id TEXT NOT NULL,
+  fulfilled BOOLEAN NOT NULL,
+  refunded BOOLEAN NOT NULL,
+  FOREIGN KEY (user_id) REFERENCES points (id)
+);
+
+CREATE TABLE cool_downs (
+  id INTEGER PRIMARY KEY AUTOINCREMENT,
+  created REAL NOT NULL,
+  reward TEXT NOT NULL
+);

BIN
ownchatbot/static/img/favicon.ico


BIN
ownchatbot/static/img/kofi/Ko-fi_COIN.png


BIN
ownchatbot/static/img/kofi/Ko-fi_CONFETTI.png


BIN
ownchatbot/static/img/kofi/Ko-fi_FIRE.png


BIN
ownchatbot/static/img/kofi/Ko-fi_HEART.png


BIN
ownchatbot/static/img/kofi/Ko-fi_MUSIC.png


BIN
ownchatbot/static/img/kofi/Ko-fi_RAINBOW.png


BIN
ownchatbot/static/img/kofi/KofiAndCream_500.png


BIN
ownchatbot/static/img/kofi/KofiCoffee_500.png


BIN
ownchatbot/static/img/kofi/KofiHearts_500.png


BIN
ownchatbot/static/img/kofi/KofiLatte_500.png


BIN
ownchatbot/static/img/kofi/KofiMug_500.png


BIN
ownchatbot/static/img/kofi/KofiPour_500.png


BIN
ownchatbot/static/img/kofi/KofiRainbowHeart_500.png


BIN
ownchatbot/static/img/kofi/KofiRainbowMug_500.png


BIN
ownchatbot/static/img/kofi/KofiSplash_500.png


BIN
ownchatbot/static/img/kofi/kofi_logo.png


BIN
ownchatbot/static/img/kofi/kofi_symbol.png


BIN
ownchatbot/static/img/kofi/support_me_on_kofi_badge_beige.png


BIN
ownchatbot/static/img/milestone.png


BIN
ownchatbot/static/img/ownchatbotwide.png


BIN
ownchatbot/static/img/smallrobo.png


BIN
ownchatbot/static/img/tada.png


+ 42 - 0
ownchatbot/static/mgmtpanel.js

@@ -0,0 +1,42 @@
+const defaultTab = "queue";
+
+function openTab(event, tabName) {
+    var i, tabcontent, tablinks;
+    tabcontent = document.getElementsByClassName("tabcontent");
+    for (i = 0; i < tabcontent.length; i++) {
+        tabcontent[i].style.display = "none";
+    }
+    tablinks = document.getElementsByClassName("tablinks");
+    for (i = 0; i < tablinks.length; i++) {
+        tablinks[i].className = tablinks[i].className.replace(" active", "");
+    }
+    document.getElementById(tabName).style.display = "block";
+    event.currentTarget.className += " active";
+
+    
+    localStorage.setItem("activeTab", tabName);
+}
+
+function refreshPage() {
+    window.location.reload();
+}
+
+window.onload = function() {
+    var activeTab = localStorage.getItem("activeTab");
+    if (activeTab) {
+        
+        var tablinks = document.getElementsByClassName("tablinks");
+        for (var i = 0; i < tablinks.length; i++) {
+            if (tablinks[i].getAttribute("data-tab") === activeTab) {
+                tablinks[i].click();
+                break;
+            }
+        }
+    } else {
+        
+        var defaultTabLink = document.querySelector(`.tablinks[data-tab="${defaultTab}"]`);
+        if (defaultTabLink) {
+            defaultTabLink.click();
+        }
+    }
+};

+ 159 - 0
ownchatbot/static/style.css

@@ -0,0 +1,159 @@
+html {
+    box-sizing: border-box;
+    font-size: 62.5%
+}
+
+body {
+    width: 900px;
+    background-color: black;
+    color: white;
+    font-family: 'Helvetica', 'Arial', sans-serif;
+    font-size: 1.6em;
+    font-weight: 300;
+    line-height: 1.6;
+    margin: 0 auto; 
+    text-align: center; 
+}
+
+table {
+    border-collapse: collapse;
+    width: 97%; 
+    margin: 10px;
+}
+
+tr {
+    border-bottom: 1px solid gray;
+}
+
+img {
+    height: 50px;
+    width: auto;
+    padding-right: 16px;
+}
+
+.container{
+    margin: 0 auto;
+    max-width: 100.0rem;
+    padding: 0 2.0rem;
+    position: relative;
+    width: 100%
+}
+
+.panel {
+    /* margin: 10px; */
+    background-color: #002e63;
+}
+
+.navbar {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: 5px;
+    background-color: #2a52be;
+}
+
+.tab {
+    overflow: hidden;
+    background-color: #2a52be;
+}
+    
+.tab button {
+    background-color: inherit;
+    color: inherit;
+    float: left;
+    border: none;
+    outline: none;
+    cursor: pointer;
+    padding: 14px 16px;
+    transition: 0.3s;
+    border-radius: 18px;
+}
+   
+    
+.tab button:hover {
+    background-color: #1d2951;
+    color: white;
+}
+    
+.tab button.active {
+    background-color: #003399;
+    color: white;
+}
+    
+.tabcontent {
+    display: none;
+    padding: 6px 12px;
+    border-top: none;
+    background-color: #002e63;
+    text-align: left;
+}
+
+.button {
+    border-radius: 25px;
+    border: none;
+    background-color: inherit;
+    color: white;
+    padding: 4px 16px;
+    text-align: center;
+    text-decoration: none;
+    display: inline-block;
+    font-size: 16px;
+    margin: 4px 2px;
+    transition-duration: 0.4s;
+    cursor: pointer;
+    box-shadow: 0 5px #999;
+}
+
+.button2 {
+    background-color: #002e63;
+    color: inherit;
+    border: 2px solid #008CBA;
+}
+
+.button2:hover {
+    background-color: #008CBA;
+    color: white;
+}
+
+.button2:active {
+    background-color: #008CBA;
+    box-shadow: 0 5px #666;
+    transform: translateY(2px);
+}
+
+a:link {
+    color: white;
+}
+
+a:visited {
+    color: white;
+}
+
+.bar-container {
+        padding:0.01em;
+    }
+
+.bar-blue {
+    color:#fff!important;
+    background-color:#2196F3!important
+}
+    
+.bar-light-grey {
+    color:#000!important;
+    background-color:#f1f1f1!important
+}
+
+.bar-tiny {
+    font-size:10px!important
+}
+    
+.bar-round {
+    border-radius:16px
+}
+
+.milestone-marker {
+    position: absolute;
+    top: -1px;
+    z-index: 1;
+}
+

+ 42 - 0
ownchatbot/static/userpanel.js

@@ -0,0 +1,42 @@
+const defaultTab = "ocbinfo";
+
+function openTab(event, tabName) {
+    var i, tabcontent, tablinks;
+    tabcontent = document.getElementsByClassName("tabcontent");
+    for (i = 0; i < tabcontent.length; i++) {
+        tabcontent[i].style.display = "none";
+    }
+    tablinks = document.getElementsByClassName("tablinks");
+    for (i = 0; i < tablinks.length; i++) {
+        tablinks[i].className = tablinks[i].className.replace(" active", "");
+    }
+    document.getElementById(tabName).style.display = "block";
+    event.currentTarget.className += " active";
+
+    
+    localStorage.setItem("activeTab", tabName);
+}
+
+function refreshPage() {
+    window.location.reload();
+}
+
+window.onload = function() {
+    var activeTab = localStorage.getItem("activeTab");
+    if (activeTab) {
+        
+        var tablinks = document.getElementsByClassName("tablinks");
+        for (var i = 0; i < tablinks.length; i++) {
+            if (tablinks[i].getAttribute("data-tab") === activeTab) {
+                tablinks[i].click();
+                break;
+            }
+        }
+    } else {
+        
+        var defaultTabLink = document.querySelector(`.tablinks[data-tab="${defaultTab}"]`);
+        if (defaultTabLink) {
+            defaultTabLink.click();
+        }
+    }
+};

+ 153 - 0
ownchatbot/templates/add.html

@@ -0,0 +1,153 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    {% if reward_type == "category" %}
+    <title>Create Category</title>
+    {% else %}
+    <title>Create Reward</title>
+    {% endif %}
+    <link rel="icon" type="image/x-icon" href="/static/img/favicon.ico">
+    <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
+</head>
+<body>
+    <div class="panel">
+        <div class="navbar">
+            <p></p>
+            <img src="/static/img/ownchatbotwide.png">
+        </div>
+        <h1>Create a {{ reward_type }}</h1>
+        <form method="POST">
+            <table>
+                <tr>
+                    <td><label for="name">Name:</label></td>
+                    <td>
+                        <input type="text" name="name" required>
+                        {% with messages = get_flashed_messages(with_categories=true) %}
+                            {% if messages %}
+                                {% for category, message in messages %}
+                                    {% if category == 'error' %}
+                                        <span style="color: red;">{{ message }}</span>
+                                    {% endif %}
+                                {% endfor %}
+                            {% endif %}
+                        {% endwith %}
+                    </td>
+                </tr>
+                <input type="hidden" name="type" value="{{ reward_type }}"><br>
+                {% if reward_type != "category" %}
+                    {% if reward_type == "goal" %}
+                        <tr>
+                            <td><label for="target">Target:</label></td>
+                            <td><input type="number" name="target" required></td>                    
+                        </tr>
+                    {% else %}
+                        <tr>
+                            <td><label for="price">Price:</label></td>
+                            <td><input type="number" name="price" required></td>
+                        </tr>
+                    {% endif %}
+                    <tr>
+                        <td><label for="info">Description:</label></td>
+                        <td><textarea name="info" rows="1" cols="50" required></textarea></td>
+                    </tr>
+                    {% if reward_type == "special" %}
+                        <tr>
+                            <td><label for="cmd">Command:</label></td>
+                            <td><input type="text" name="cmd" required></td>
+                        </tr>
+                    {% endif %}
+                    {% if reward_type != "category" %}
+                        {% if reward_type == "goal" or reward_type == "vote" %}
+                            <input type="hidden" name="cooldown" value="0">
+                        {% else %}
+                            <tr>
+                                <td><label for="cooldown">Cool down:</label></td>
+                                <td><input type="number" name="cooldown" maxlength="2" size="2" value="0" required> minutes until this reward can be redeemed again, after each use.</td>
+                            </tr>
+                        {% endif %}
+                    {% endif %}
+                    
+                    {% if reward_type == "goal" %}
+                        <tr>
+                            <td>
+                                Milestones:
+                            </td>
+                            <td>
+                                <table>
+                                    <tr>
+                                        <td>
+                                            <label for="milestone1_desc">Reward:</label>
+                                        </td>
+                                        <td>
+                                            <input type="text" name="milestone1_desc" size="30">
+                                        </td>
+                                        <td>
+                                            <label for="milestone1_points"> at </label>
+                                        </td>
+                                        <td>
+                                            <input type="number" name="milestone1_points" maxlength="4" size="4"> points reached.
+                                        </td>
+                                    </tr>
+                                    <tr>
+                                        <td>
+                                            <label for="milestone2_desc">Reward:</label>
+                                        </td>
+                                        <td>
+                                            <input type="text" name="milestone2_desc" size="30">
+                                        </td>
+                                        <td>
+                                            <label for="milestone2_points"> at </label>
+                                        </td>
+                                        <td>
+                                            <input type="number" name="milestone2_points" maxlength="4" size="4"> points reached.
+                                        </td>
+                                    </tr>
+                                    <tr>
+                                        <td>
+                                            <label for="milestone3_desc">Reward:</label>
+                                        </td>
+                                        <td>
+                                            <input type="text" name="milestone3_desc" size="30">
+                                        </td>
+                                        <td>
+                                            <label for="milestone3_points"> at </label>
+                                        </td>
+                                        <td>
+                                            <input type="number" name="milestone3_points" maxlength="4" size="4"> points reached.
+                                        </td>
+                                    </tr>
+                                </table>
+                            </td>
+                        </tr>
+                    {% endif %}
+                    
+                    <tr>              
+                        <td>Categories</td>
+                        <td>Categories in <span style="color: red;">red</span> are inactive.
+                            <table>
+                                {% for cat in all_cats %}
+                                <tr>
+                                    {% if cat in active_categories %}
+                                    <td><label for="category">{{ cat }}:</label></td>
+                                    {% else %}
+                                    <td><label for="category"><span style="color: red;">{{ cat }}</span>:</label></td>
+                                    {% endif %}
+                                    <td><input type="checkbox" name="category" value="{{ cat }}"></td>
+                                </tr>
+                                {% endfor %}
+                            </table>
+                        </td>
+                    </tr>
+                {% endif %}  
+            </table>
+            <br>Your {{ reward_type }} will be <span style="color: red;">inactive</span>, until you add it to an active category.
+            {% if reward_type == "category" %}
+                <br>By default, new categories are <span style="color: red;">inactive</span>.<br>
+            {% endif %}
+            <br><button class="button button2" type="submit">Create {{ reward_type }}</button><br>
+        </form>
+        <a href="{{ url_for('web_panels.mgmt', auth=session['auth_code']) }}">Cancel</a>
+        <br><br>
+    </div>
+</body>
+</html>

+ 131 - 0
ownchatbot/templates/edit.html

@@ -0,0 +1,131 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <title>Edit {{ reward_data["type"] }}</title>
+    <link rel="icon" type="image/x-icon" href="/static/img/favicon.ico">
+    <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
+</head>
+    <div class="panel">
+    <div class="navbar">
+        <p>Edit {{ reward_data["type"] }} "{{ reward_name }}"</p>
+        <img src="/static/img/ownchatbotwide.png">
+    </div>
+        <body style="text-align: left;">
+            <form method="POST">
+                <table>
+                    {% if reward_data["type"] == "goal" %}
+                    <tr>
+                        <td> <label for="target">Target:</label> </td>
+                        <td> <input type="number" name="target" value="{{ reward_data["target"] }}" required> </td>
+                    </tr>
+                    {% else %}
+                    <tr>
+                        <td> <label for="price">Price:</label> </td>
+                        <td> <input type="number" name="price" value="{{ reward_data["price"] }}" required> </td>
+                    </tr>
+                    {% endif %}
+                        <input type="hidden" name="type" value="{{ reward_data["type"] }}"><br>
+                    <tr>
+                        <td> <label for="info">Info:</label> </td>
+                        <td> <textarea name="info" rows="1" cols="50" required>{{ reward_data["info"] }}</textarea> </td>
+                    </tr>
+                    {% if reward_data["type"] == "special" %}
+                    <tr>
+                        <td> <label for="cmd">Command:</label> </td>
+                        <td> <input type="text" name="cmd" value="{{ reward_data["cmd"] }}" required> </td>
+                    </tr>
+                    {% endif %}
+                    {% if reward_data["type"] != "category"%}
+                        {% if reward_data["type"] == "goal" or reward_data["type"] == "vote" %}
+                            <input type="hidden" name="cooldown" value="0">
+                        {% else %}
+                        <tr>
+                            <td> <label for="cooldown">Cool down:</label> </td>
+                            <td> <input type="number" name="cooldown" maxlength="2" size="2" value="{{ reward_data["cooldown"] }}" required> minutes until this reward can be redeemed again, after each use.</td>
+                        </tr>
+                        {% endif %}
+                    {% endif %}
+                    
+                    {% if reward_data["type"] == "goal" %}
+                        <tr>
+                            <td>
+                                Milestones:
+                            </td>
+                            <td>
+                                <table>
+                                    <tr>
+                                        <td>
+                                            <label for="milestone1_desc">Reward:</label>
+                                        </td>
+                                        <td>
+                                            <input type="text" name="milestone1_desc" size="30" value="{{ reward_data["milestones"]["milestone1"][0] }}">
+                                        </td>
+                                        <td>
+                                            <label for="milestone1_points"> at </label>
+                                        </td>
+                                        <td>
+                                            <input type="number" name="milestone1_points" maxlength="4" size="4" value="{{ reward_data["milestones"]["milestone1"][1] }}"> points reached.
+                                        </td>
+                                    </tr>
+                                    <tr>
+                                        <td>
+                                            <label for="milestone2_desc">Reward:</label>
+                                        </td>
+                                        <td>
+                                            <input type="text" name="milestone2_desc" size="30" value="{{ reward_data["milestones"]["milestone2"][0] }}">
+                                        </td>
+                                        <td>
+                                            <label for="milestone2_points"> at </label>
+                                        </td>
+                                        <td>
+                                            <input type="number" name="milestone2_points" maxlength="4" size="4" value="{{ reward_data["milestones"]["milestone2"][1] }}"> points reached.
+                                        </td>
+                                    </tr>
+                                    <tr>
+                                        <td>
+                                            <label for="milestone3_desc">Reward:</label>
+                                        </td>
+                                        <td>
+                                            <input type="text" name="milestone3_desc" size="30" value="{{ reward_data["milestones"]["milestone3"][0] }}">
+                                        </td>
+                                        <td>
+                                            <label for="milestone3_points"> at </label>
+                                        </td>
+                                        <td>
+                                            <input type="number" name="milestone3_points" maxlength="4" size="4" value="{{ reward_data["milestones"]["milestone3"][1] }}"> points reached.
+                                        </td>
+                                    </tr>
+                                </table>
+                            </td>
+                        </tr>
+                    {% endif %}
+                    
+                    <tr>
+                        <td>Categories</td>
+                        <td>Categories in <span style="color: red;">red</span> are inactive.
+                            <table>
+                                {% for cat in all_cats %}
+                                    <tr>
+                                        {% if cat in active_categories %}
+                                        <td> <label for="category">{{ cat }}:</label> </td>
+                                        {% else %}
+                                        <td> <label for="category"><span style="color: red;">{{ cat }}</span>:</label> </td>
+                                        {% endif %}
+                                        {% if cat in reward_data["categories"] %}
+                                            <td> <input type="checkbox" name="category" value="{{ cat }}" checked> </td>
+                                        {% else %}
+                                            <td> <input type="checkbox" name="category" value="{{ cat }}"> </td>
+                                        {% endif %}
+                                    </tr>
+                                {% endfor %}
+                            </table>
+                        </td>
+                    </tr>
+                </table>
+                <br><button class="button button2" type="submit">Save Changes</button><br>
+            </form>
+            <a href="{{ url_for('web_panels.mgmt', auth=session['auth_code']) }}">Cancel</a>
+            <br><br>
+        </body>
+    </div>
+</html>

+ 32 - 0
ownchatbot/templates/edit_account.html

@@ -0,0 +1,32 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <title>Edit Accountts</title>
+    <link rel="icon" type="image/x-icon" href="/static/img/favicon.ico">
+    <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
+</head>
+    <div class="panel">
+    <div class="navbar">
+        <p>Edit {{ name }}'s Account</p>
+        <img src="/static/img/ownchatbotwide.png">
+    </div>
+        <body>
+            <form method="POST">
+                <table>
+                    <tr style="border-bottom: none;">
+                        <td> <label for="points">Points:</label> </td>
+                        <td> <input type="number" name="newpoints" value="{{ points }}"> </td>
+                        
+                        <td> <label for="email">Email:</label> </td>
+                        <td> <input type="text" name="newemail" value="{{ email }}"  size="40"> </td>
+
+                        <input type="hidden" name="user_id" value="{{ user_id }}">
+                        <input type="hidden" name="name" value="{{ name }}">
+                    </tr>
+                </table>
+                <br><button class="button button2" type="submit">Save</button><br>
+            </form>
+            <a href="{{ url_for('web_panels.mgmt', auth=session['auth_code']) }}">Cancel</a>
+        </body>
+    </div>
+</html>

+ 50 - 0
ownchatbot/templates/goals.html

@@ -0,0 +1,50 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <meta http-equiv="refresh" content="5">
+    <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
+    <title>Active goals</title>
+    <style>
+        body {
+            background-color: transparent;
+            color: white;
+            width: 600px;
+            text-align: left;
+        }
+    </style>
+</head>
+<body style="position: absolute; 
+                            bottom: 0; left: 0;">
+    {% if goals %}
+        <table>
+            <thead>
+                <tr>
+                    <th></th>
+                    <th style="width: 50%"></th>
+                    <th></th>
+                </tr>
+            </thead>
+            <tbody>
+            {% for goal in goals %}
+                <tr>
+                    <td style="text-align: left;"> {{ goal[3] }} </td>
+                    {% set progress = goal[1] / goal[2] * 100 %}
+                    <td><div class="bar-light-grey bar-tiny bar-round">
+                            <div class="bar-round bar-blue" style="text-align: center; width:{{ goal[1] / goal[2] * 100 }}%;">{{ '%0.0f'| format(progress| float) }}%</div>
+                        </div>
+                    </td>
+                    {% if goal[1] == goal[2] %}
+                    <td style="text-align: right;">{{ goal[1] }} / {{ goal[2] }}  <img src=/static/img/tada.png style="width: 24px; height: 24px;"></td>
+                    {% else %}
+                    <td style="text-align: right;">{{ goal[1] }} / {{ goal[2] }}</td>
+                    {% endif %}
+                    
+                </tr>
+            {% endfor %}
+            </tbody>
+        </table>
+    {% endif %}
+</body>
+</html>

+ 526 - 0
ownchatbot/templates/mgmt.html

@@ -0,0 +1,526 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <title>OCB Management Panel</title>
+    <link rel="icon" type="image/x-icon" href="/static/img/favicon.ico">
+    <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
+</head>
+    <script src="/static/mgmtpanel.js"></script>
+    
+    <div class="navbar">
+        <div class="tab">
+            <button class="tablinks" data-tab="queue" onclick="openTab(event, 'queue')">Queue</button>
+            <button class="tablinks" data-tab="accounts" onclick="openTab(event, 'accounts')">Manage Accounts</button>
+            <button class="tablinks" data-tab="managerewards" onclick="openTab(event, 'managerewards')">Rewards</button>
+            <button class="tablinks" data-tab="categories" onclick="openTab(event, 'categories')">Categories</button>
+            <button class="tablinks" data-tab="announcements" onclick="openTab(event, 'announcements')">Announcements</button>
+            <button class="tablinks" data-tab="settings" onclick="openTab(event, 'settings')">Settings</button>
+            {% if kofi_integration %}
+                <button class="tablinks" data-tab="kofi-settings" onclick="openTab(event, 'kofi-settings')">Kofi Settings</button>
+            {% endif %}
+        </div>
+        <img src="/static/img/ownchatbotwide.png">
+    </div>
+    
+    <div id='queue' class="tabcontent">
+        <body>
+            {% if votes %}
+            <table>
+                <thead>
+                    <tr>
+                        <th style="width: 20%;">Active Votes</th>
+                        <th style="width: 40%;">Description</th>
+                        <th style="width: 20%;">Price</th>
+                        <th style="width: 20%;">Current Tally</th>
+                        <th></th>
+                    </tr>
+                </thead>
+                <tbody>
+                {% for vote in votes %}
+                    <tr>
+                        <td> {{ prefix }}{{ vote[0] }} </td>
+                        <td> {{ vote[2] }} </td>
+                        
+                        {% set points_label = 'point' if rewards[vote[0]]["price"] == 1 else 'points' %}
+                        <td> {{ rewards[vote[0]]["price"] }} {{ points_label }}</td>
+                        
+                        {% set votes_label = 'vote' if vote[1] == 1 else 'votes' %}
+                        
+                        <td> {{ vote[1] }} {{ votes_label }}</td>
+                        <td></td>
+                    </tr>
+                {% endfor %}
+                </tbody>
+            </table>
+            {% else %}
+            <h3> You currently have no active votes. </h3>
+            {% endif %}
+            <br>
+            <hr>
+            {% if goals %}
+            <table>
+                <thead>
+                    <tr>
+                        <th style="width: 20%;">Active Goals</th>
+                        <th style="width: 40%;">Description</th>
+                        <th style="width: 25%;">Progress</th>
+                        <th></th>
+                    </tr>
+                </thead>
+                <tbody>
+                {% for goal in goals %}
+                    <tr>
+                        <td> {{ prefix }}{{ goal[0] }} </td>
+                        <td> {{ goal[3] }} </td>
+                        {% set progress = goal[1] / goal[2] * 100 %}
+                        <td>
+                            <div class="bar-light-grey bar-tiny bar-round" style="position: relative;">
+                                <div class="bar-round bar-blue" style="text-align: center; width:{{ progress }}%;">{{ '%0.0f'| format(progress| float) }}%
+                                </div>
+                                {% set milestones = rewards[goal[0]]["milestones"] %}
+                                {% for milestone_key, milestone in milestones.items() %}
+                                    {% if milestones[milestone_key][0] and milestones[milestone_key][1] %}
+                                        {% if milestones[milestone_key][1] < goal[2] %}
+                                            {% set milestone_progress = milestones[milestone_key][1] / goal[2] * 100 %}
+                                                <div class="milestone-marker" style="position: absolute; left: {{ milestone_progress }}%; transform: translateX(-50%);">
+                                                    <img src="/static/img/milestone.png" style="width: 16px; height: 16px;" title="{{ milestones[milestone_key][1] }} points. {{ milestones[milestone_key][0] }}">
+                                                </div>
+                                            {% endif %}
+                                        {% endif %}
+                                    {% endfor %}
+                            </div>
+                        </td>
+                        
+                        {% if goal[1] == goal[2] %}
+                        <td> {{ goal[1] }} / {{ goal[2] }} <img src=/static/img/tada.png style="width: 24px; height: 24px;"></td>
+                        {% else %}
+                        <td> {{ goal[1] }} / {{ goal[2] }} </td>
+                        {% endif %}
+                    </tr>
+                {% endfor %}
+                    <tr style="border-bottom: none;">
+                        <td>
+                        </td>
+                        <td>
+                        </td>
+                        <td style="font-size: small;">
+                            (Hover over flags for details)
+                        </td>
+                    </tr>
+                </tbody>
+            </table>
+            {% else %}
+            <h3> You currently have no active goals. </h3>
+            {% endif %}
+            <hr>
+            <h3>Redeems Queue</h3>
+            {% if queue %}
+            <a href="/mgmt/clearfulfilled"><button class="button button2" onclick="openTab(event, 'queue')">Clear Fullfilled Rewards</button></a>&nbsp;&nbsp;<a href="/mgmt/clearqueue"><button class="button button2" onclick="openTab(event, 'queue')">Clear All Rewards</button></a>
+            <table>
+                <thead>
+                    <tr>
+                        <th>Time</th>
+                        <th>Reward</th>
+                        <th>User</th>
+                        <th></th>
+                    </tr>
+                </thead>
+                <tbody>
+                {% for row in queue %}
+                    <tr>
+                        <td>{{ row[1].replace(tzinfo=utc_timezone).astimezone().strftime("%H:%M") }}</td>
+                        {% if row[4] %}
+                        <td><s>{{ rewards[row[2]]["info"] }}</s></td>
+                        {% else %}
+                        <td>{{ rewards[row[2]]["info"] }}</td>
+                        {% endif %}
+                        <td>{{ row[6] }}</td>
+                        {% if row[4] %}
+                        {% if row[5] %}
+                        <td>Refunded</td>
+                        {% else %}
+                        <td><a href="/mgmt/refund?reward={{ row[2] }}&username={{ row[6] }}&rewarder_id={{ row[3] }}&reward_id={{ row[0] }} "><button class="button button2" onclick="openTab(event, 'queue')">Refund</button></a></td>
+                        {% endif %}
+                        {% else %}
+                        <td><a href="/mgmt/fulfill?reward_id={{ row[0] }}&username={{ row[6] }}"><button class="button button2" onclick="openTab(event, 'queue')">Fulfill</button></a></td>
+                        {% endif %}
+                    </tr>
+                {% endfor %}
+                </tbody>
+            </table>
+            {% else %}
+            <p>There are currently no rewards waiting in the queue.</p>
+            {% endif %}
+        </body>
+    </div>
+
+    <div id='accounts' class="tabcontent">
+
+        <body>
+            <h3>Manage Viewer Accounts</h3>
+            {% if users %}
+            <table>
+                <thead>
+                    <tr>
+                        <th>User</th>
+                        <th>Points balance</th>
+                        <th>Email</th>
+                        <th></th>
+                    </tr>
+                </thead>
+                <tbody>
+                {% for user in users %}
+                    <tr>
+                        <td> {{ user[1] }} </td>
+                        {% set points_label = 'point' if user[2] == 1 else 'points' %}
+                        
+                        <td>{{ user[2] }} {{ points_label }}</td>
+                        {% if user[4] %}
+                            <td>{{ user[4] }}</td>
+                        {% else %}
+                            <td>none</td>
+                        {% endif %}
+                        <td> <a href="/mgmt/edit_account/{{ user[0] }}?name={{ user[1] }}&points={{ user[2] }}&email={{ user[4] }}"><button class="button button2" onclick="openTab(event, 'panel')">Edit</button></a> </td>
+                    </tr>
+                {% endfor %}
+                </tbody>
+            </table>
+            {% endif %}
+        </body>
+    </div>
+
+    <div id='managerewards' class="tabcontent">
+        <h3>Manage Rewards</h3>
+        <body>
+        <div>
+        <h4>Reward Types:</h4>
+        <ul>
+            
+            <li><u>Redeems</u> are standard stream point redeems. They get added to the queue for the streamer to fulfill.</li>
+            <li><u>Specials</u> are redeems, but they run system commands and scripts. This enables you to integrate a lot of other fun things, such as letting your viewers control lighting, activate devices, trigger webhooks, etc. Specials do not go into the queue, because they happen automagically.</li>
+              <ul>
+                <li>Be careful with this one. It does require some more advanced scripting/command line knowledge. Make sure you test your specials before letting viewers use them.</li>
+              </ul>
+            <li><u>Votes</u> are just that, votes. Your viewers can vote on them.</li>
+            <li><u>Goals</u> are rewards that everyone in chat can contribute to. The streamer fulfills the reward when the goal is reached.</li>
+            <li>Add rewards to categories to enable and disable groups of rewards.</li>
+            <li>Rewards with no categories are inactive.</li>
+        </ul>
+
+        </div>
+            <table>
+                <tr>
+                    <td>
+                    </td>
+                    <td>
+                    </td>
+                    <td>
+                    </td>
+                    <td>
+                    </td>
+                </tr>
+                <tr>
+                    <td>
+                        <a href="{{ url_for('web_panels.add', reward_type='redeem') }}"><button class="button button2" onclick="openTab(event, 'managerewards')">Create a Redeem</button></a><br>
+                    </td>
+                    <td>
+                        <a href="{{ url_for('web_panels.add', reward_type='special') }}"><button class="button button2" onclick="openTab(event, 'managerewards')">Create a Special</button></a><br>
+                    </td>
+                    <td>
+                        <a href="{{ url_for('web_panels.add', reward_type='vote') }}"><button class="button button2" onclick="openTab(event, 'managerewards')">Create a Vote</button></a><br>
+                    </td>
+                    <td>
+                        <a href="{{ url_for('web_panels.add', reward_type='goal') }}"><button class="button button2" onclick="openTab(event, 'managerewards')">Create a Goal</button></a><br>
+                    </td>
+                </tr>
+            </table>
+            <br>
+            {% if rewards %}
+            Rewards in <span style="color: red !important;">red</span> are inactive. To activate a reward, add it to an active category.
+            <table>
+                <thead>
+                    <tr>
+                        <th style="width:15%">Name</th>
+                        <th>Target</th>
+                        <th>Price</th>
+                        <th>Info</th>
+                        <th>Cool down</th>
+                        <th>Category</th>
+                        <th style="width:15%">Actions</th>
+                    </tr>
+                </thead>
+                <tbody>
+                {% for reward, reward_info in rewards.items() %}
+                    {% if reward in active_rewards %}
+                    <tr>
+                    {% else %}
+                    <tr style="color: red;">
+                    {% endif %}
+                        <td>{{ prefix }}{{ reward }}</td>
+                        {% if reward_info["type"] == "goal" %}
+                        <td>{{ reward_info["target"] }}</td>
+                        {% else %}
+                        <td>N/A</td>
+                        {% endif %}
+                        {% if reward_info["type"] == "goal" %}
+                        <td>N/A</td>
+                        {% else %}
+                        <td>{{ reward_info["price"] }}</td>
+                        {% endif %}
+                        <td>{{ reward_info["info"] }}</td>
+                        {% if reward_info["cooldown"] > 0 %}
+                        {% set minutes_label = 'minute' if reward_info["cooldown"] == 1 else 'minutes' %}
+                        <td>{{ reward_info["cooldown"] }} {{ minutes_label }}</td>
+                        {% else %}
+                        <td>None</td>
+                        {% endif %}
+                        <td>{{ reward_info["categories"] | join(', ') }}</td>
+                        <td>
+                            <a href="{{ url_for('web_panels.edit', reward_name=reward) }}"><button class="button button2" onclick="openTab(event, 'managerewards')"><span style="color: green;">Edit</span></button></a>&nbsp
+                            <a href="{{ url_for('web_panels.delete', reward_name=reward) }}"><button class="button button2" onclick="openTab(event, 'managerewards')"><span style="color: red;">Delete</span></button></a>&nbsp                            
+                            {% if reward_info["type"] == "goal" or reward_info["type"] == "vote"  %}
+                            <a href="{{ url_for('web_panels.reset', reward_name=reward, reward_type=reward_info["type"]) }}"><button class="button button2" onclick="openTab(event, 'managerewards')"><span style="color: orange;">Reset</span></button></a>
+                            {% endif %}
+                        </td>
+                    </tr>
+                {% endfor %}
+                </tbody>
+            </table>
+            {% endif %}
+        </body>
+    </div>
+
+    <div id='categories' class="tabcontent">
+        <body>
+        <h3>Manage Categories</h3>
+        <div>
+            &nbsp;&nbsp;<a href="{{ url_for('web_panels.add', reward_type='category') }}"><button class="button button2" onclick="openTab(event, 'categories')">Create a new category</button></a><br>
+            <table>
+                <thead>
+                    <tr>
+                        <th style="width: 50%;"><h3>Active Categories</h3></th>
+                        <th style="width: 50%;"><h3>Inactive Categories</h3></th>
+                    </tr>
+                </thead>
+                <tbody>
+                    <tr>
+                        <td>
+                            {% for cat in active_categories %}
+                            &nbsp;&nbsp;{{ cat }} - <a href="/mgmt/deactivate/{{ cat }}"><span style="color: green;">Deactivate</span></a>&nbsp;<a href="/mgmt/delcat/{{ cat }}/active"><span style="color: red;">Delete</span></a><br>
+                            {% endfor %}
+                        </td>
+                        <td>
+                            {% for cat in inactive_categories %}
+                            &nbsp;&nbsp;{{ cat }} - <a href="/mgmt/activate/{{ cat }}"><span style="color: green;">Activate</span></a>&nbsp;<a href="/mgmt/delcat/{{ cat }}/inactive"><span style="color: red;">Delete</span></a><br>
+                            {% endfor %}
+                        </td>
+                    </tr>
+                </tbody>
+            </table>
+        </div>
+        </body>
+    </div>
+
+    <div id='announcements' class="tabcontent">
+        <body>
+            <form method="POST" action="/mgmt/announcements">
+                <table>
+                <h3>Manage Announcements</h3>
+                <table>
+                    <thead>
+                        <tr style="border-bottom: none;">
+                            <th style="width: 20%;"></th>
+                            <th style="width: 50%;"></th>
+                            <th></th>
+                        </tr>
+                    </thead>
+                    <tr>
+                        <td> <label for="announce_enable">Enable:</label> </td>
+                        {% if settings_info[9] %}
+                        <td> <input type="checkbox" name="announce_enable" value="{{ settings_info[9] }}" checked> </td>
+                        {% else %}
+                        <td> <input type="checkbox" name="announce_enable" value="{{ settings_info[9] }}"> </td>
+                        {% endif %}
+                        <td>Enable periodic announcements</td>
+                    </tr>
+                    <tr>
+                        <td> <label for="announce_interval">Interval:</label> </td>
+                        <td> <input type="number" name="announce_interval" value="{{ settings_info[10] }}" size="2" required> minutes</td>
+                        <td>How long between each announcement?</td>
+                    </tr>
+                    <tr>
+                        <td> <label for="announcements">Announcements:</label> </td>
+                        <td>
+                            <textarea name="announcements" rows="5" cols="50">{{ announcements | join('\n') }}</textarea>
+                        </td>
+                        <td>Enter your announcements, one per line.</td>
+                    </tr>
+                </table>
+                <br><button class="button button2" type="submit">Save Changes</button><br>
+            </form>
+            <br><br>
+        </body>
+    </div>
+
+    <div id='settings' class="tabcontent">
+        <body style="text-align: left;">
+            <form method="POST" action="/mgmt/settings">
+                <table>
+                <h3>OwnchatBot Settings</h3>
+                    <thead>
+                        <tr style="border-bottom: none;">
+                            <th style="width: 20%;"></th>
+                            <th></th>
+                            <th style="width: 50%;"></th>
+                        </tr>
+                    </thead>
+                    <tr>
+                        <td> <label for="mgmt_auth">Management Auth Code:</label> </td>
+                        <td style="padding: 5px;"> <input type="text" name="mgmt_auth" value="{{ settings_info[0] }}" size="30" required> </td>
+                        <td>Pregenerated during install. Goes in your management panel URL. Keep it secret. Keep it safe.<br>
+                        http://localhost:5000/mgmt?auth={{ settings_info[0] }}</td>
+                    </tr>
+                    <tr>
+                        <td> <label for="points_interval">Points Interval:</label> </td>
+                        <td> <input type="number" name="points_interval" value="{{ settings_info[1] }}" size="5" required> minutes</td>
+                        <td>How often do you want to award your viewers points?</td>
+                    </tr>
+                    <tr>
+                        <td> <label for="points_award">Points Award:</label> </td>
+                        <td> <input type="number" name="points_award" value="{{ settings_info[2] }}" size="5"  required> points</td>
+                        <td>How many points do you want to award them?</td>
+                    </tr>
+                    <tr>
+                        <td> <label for="gunicorn_logging">Gunicorn Logging:</label> </td>
+                        {% if settings_info[3] %}
+                        <td> <input type="checkbox" name="gunicorn_logging" value="{{ settings_info[3] }}" checked> </td>
+                        {% else %}
+                        <td> <input type="checkbox" name="gunicorn_logging" value="{{ settings_info[3] }}"> </td>
+                        {% endif %}
+                        <td>Enable Gunicorn logging integration.</td>
+                    </tr>
+                    <tr>
+                        <td> <label for="prefix">Chat Command Prefix:</label> </td>
+                        <td> <input type="text" name="prefix" maxlength="1" size="1" value="{{ settings_info[4] }}" required> </td>
+                        <td>Character that preceeds chat commands, so OwnchatBot knows what to look for.<br>Example: "{{ settings_info[4] }}points"</td>
+                    </tr>
+                </table>
+                
+                <h3>Owncast Integration</h3>
+                <table>
+                    <thead>
+                        <tr style="border-bottom: none;">
+                            <th style="width: 20%;"></th>
+                            <th></th>
+                            <th style="width: 50%;"></th>
+                        </tr>
+                    </thead>
+                    <tr>
+                        <td> <label for="access_token">Access Token:</label> </td>
+                        <td style="padding: 5px;"> <input type="text" name="access_token" value="{{ settings_info[5] }}" size="40"> </td>
+                        <td>Create in Owncast Admin panel. Integrations -> Access Tokens</td>
+                    </tr>
+                    <tr>
+                        <td> <label for="owncast_url">Your Owncast URL:</label> </td>
+                        <td> <input type="text" name="owncast_url" value="{{ settings_info[6] }}" size="40"> </td>
+                        <td>The address of your Owncast instance. Can be an internal address, such as "localhost" if that's the case.</td>
+                    </tr>
+                </table>
+                
+                <h3>Kofi Integration</h3>
+                <table>
+                    <thead>
+                        <tr style="border-bottom: none;">
+                            <th style="width: 20%;"></th>
+                            <th></th>
+                            <th style="width: 50%;"></th>
+                        </tr>
+                    </thead>
+                    <tr>
+                        <td> <label for="kofi_integration">Enable:</label> </td>
+                        {% if settings_info[8] %}
+                        <td> <input type="checkbox" name="kofi_integration" value="{{ settings_info[8] }}" checked> </td>
+                        {% else %}
+                        <td> <input type="checkbox" name="kofi_integration" value="{{ settings_info[8] }}"> </td>
+                        {% endif %}
+                        <td>Enable Ko-fi integration.</td>
+                    </tr>
+                    <tr>
+                        <td> <label for="kofi_token">Verification Token:</label> </td>
+                        <td style="padding: 5px;"> <input type="text" name="kofi_token" value="{{ settings_info[7] }}" size="40"> </td>
+                        <td>Get from Kofi -> More -> API -> Webhooks -> Advanced -> Verification Token.</td>
+                    </tr>
+                </table>
+                
+                <br><button class="button button2" type="submit">Save Changes</button><br>
+            </form>
+            <br><br>
+        </body>
+    </div>
+
+    <div id='kofi-settings' class="tabcontent">
+        <body style="text-align: left;">
+            <form method="POST" action="/mgmt/ksettings">
+                <table>
+                <h3>Kofi Settings</h3>
+                    <thead>
+                        <tr style="border-bottom: none;">
+                            <th style="width: 20%;"></th>
+                            <th></th>
+                            <th style="width: 50%;"></th>
+                        </tr>
+                    </thead>
+                    <tr>
+                        <td> <label for="enable_tips">Enable points for tips:</label> </td>
+                        {% if kofi_settings['tips'] %}
+                        <td> <input type="checkbox" name="enable_tips" value="{{ kofi_settings['tips'] }}" checked> </td>
+                        {% else %}
+                        <td> <input type="checkbox" name="enable_tips" value="{{ kofi_settings['tips'] }}"> </td>
+                        {% endif %}
+                        <td>Enable awarding points for tips</td>
+                    </tr>
+                    <tr>
+                        <td> <label for="set_tip_points">Points per dollar:</label> </td>
+                        <td> <input type="number" name="set_tip_points" value="{{ kofi_settings['tip_points'] }}" size="5" required> points</td>
+                        <td>How many points should viewers recieve, for every dollar they tip?</td>
+                    </tr>
+                    <tr>
+                        <td> <label for="kofi_url">Kofi Page:</label> </td>
+                        <td style="padding: 5px;"> <input type="text" name="kofi_url" value="{{ kofi_settings['kofi_url'] }}" size="30"> </td>
+                        <td>What is your Kofi page URL?</td>
+                    </tr>
+                    <tr>
+                        <table>
+                            <tbody>
+                                <tr style="border-bottom: none;">
+                                Which logo would you like to use?
+                                </tr>
+                            {% for kofi_logo in kofi_logos %}
+                                {% if loop.index0 % 8 == 0 %}
+                                    <tr style="border-bottom: none;">
+                                {% endif %}
+                                <td>
+                                    <label for="kofi_logo"><img src="/static/img/kofi/{{ kofi_logo }}"></label>
+                                    {% if kofi_logo == kofi_settings['kofi_logo'] %}
+                                        <input type="radio" name="kofi_logo" value="{{ kofi_logo }}" checked>
+                                    {% else %}
+                                        <input type="radio" name="kofi_logo" value="{{ kofi_logo }}">
+                                    {% endif %}
+                                </td>
+                                {% if loop.index0 % 8 == 7 or loop.last %}
+                                </tr> <!-- Close the row after 8 items or at the end -->
+                                {% endif %}
+                            {% endfor %}
+                            </tbody>
+                        </table>
+                    </tr>
+                </table>
+                <br><button class="button button2" type="submit">Save Changes</button><br>
+            </form>
+            <br><br>
+	    <i>Kofi subscription support coming soon.</i>
+        </body>
+    </div>
+    
+    
+</html>

+ 239 - 0
ownchatbot/templates/userpanel.html

@@ -0,0 +1,239 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <title>OCB - Stream Points and Rewards</title>
+    <link rel="icon" type="image/x-icon" href="/static/img/favicon.ico">
+    <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
+</head>
+    <script src="/static/userpanel.js"></script>
+    
+    <div class="navbar">
+        <div class="tab">
+            <button class="tablinks" data-tab="ocbinfo" onclick="openTab(event, 'ocbinfo')">OwnchatBot Info</button>
+            <button class="tablinks" data-tab="rewards" onclick="openTab(event, 'rewards')">Points and Rewards</button>
+            <button class="tablinks" data-tab="queue" onclick="openTab(event, 'queue')">Redeems Queue</button>
+        </div>
+        <img src="/static/img/ownchatbotwide.png">
+    </div>
+
+    <div id='ocbinfo' class="tabcontent">
+        <h3>OwnchatBot Info</h3>
+        <h4>Reward types</h4>
+        <ul>
+            <li><u>Redeems</u> are standard stream point redeems. They get added to the queue for the streamer to fulfill.</li>
+            <li><u>Votes</u> are just that, votes. You vote on them.</li>
+            <li><u>Goals</u> are rewards that everyone in chat can contribute to. The streamer fulfills the reward when the goal is reached.</li>
+        </ul>
+        <h4>Chat commands</h4>
+        <ul>
+            <li>{{ prefix }}help - Shows the help message.</li>
+            <li>{{ prefix }}points - Shows your current points.</li>
+            <li>{{ prefix }}rewards - Shows you the list of active rewards.</li>
+        </ul>
+        
+        {% if kofi_integration %}
+            {% for user in users %}
+            
+            {% set points_label = 'point' if tip_points == 1 else 'points' %}
+                <h4>Kofi</h4>
+                <div>
+                {% if user['user_authed'] %}
+                    <a href="{{ kofi_settings['kofi_url'] }}/tip" target="new"><img src="/static/img/kofi/{{ kofi_settings['kofi_logo'] }}"></a><br>
+                    {% if kofi_settings['tips'] %}
+                        You are awarded {{ kofi_settings['tip_points'] }} {{ points_label }} for every dollar you tip on Kofi.<br>
+                    {% endif %}
+                    OwnchatBot recognizes your Kofi account by your email address. In order for OwnchatBot to award your tip points, you must enter the email address associated with your Kofi account here.<br><br>
+                    <form method="POST" action="/set_viewer_email">
+                        <label for="new_email">Your Kofi email address:</label>
+                        <input type="text" name="new_email" value="{{ user[4] }}" size="40">
+                        <input type="hidden" name="instance" value="{{ instance }}">
+                        <input type="hidden" name="user_name" value="{{ username }}">
+                        <br>Email addresses are <b>ONLY</b> used for Kofi integration. They are not sent to any other individual or company, and will not be used to create or send mailing lists of any kind.<br>
+                        <input type="hidden" name="user_id" value="{{ user[0] }}"> <button class="button button2" type="submit">Save Email</button>
+                    </form><br>
+                {% else %}
+                    <i>You must authenticate with Owncast to get Kofi rewards.</i><br>
+                {% endif %}
+                </div>
+            {% endfor %}
+        {% endif %}
+
+        <h4>Other stuff</h4>
+        <div>
+            OwnchatBot can be downloaded from <a href=https://git.deadtom.me/deadtom/OwnchatBot>https://git.deadtom.me/deadtom/OwnchatBot</a>.<br>
+            If you are thrilled to death with OwnchatBot, and want to throw a little monetary love his way, <a href=https://ko-fi.com/deadtom>he's on Kofi</a>.<br>
+            OwnchatBot © 2025 by <a href=https://www.deadtom.me>DeadTOm</a> is licensed under <a href=https://creativecommons.org/licenses/by-sa/4.0/>Creative Commons Attribution-ShareAlike 4.0 International</a>.
+        </div>
+	
+    </div>
+
+    
+    <div id='rewards' class="tabcontent">
+        <body>
+            <h3>Points and Rewards</h3>
+            {% for user in users %}
+            
+                {% set points_label = 'point' if points_award == 1 else 'points' %}
+                {% set minutes_label = 'minute' if points_interval == 1 else 'minutes' %}
+
+                &nbsp;&nbsp;{{ user[1] }}, you currently have {{ user[2] }} {{ points_label }}.<br>
+                &nbsp;&nbsp;You are accruing {{ points_award }} {{ points_label }} every {{ points_interval }} {{ minutes_label }}.
+                {% if kofi_integration %}
+                    {% if kofi_settings['tips'] %}
+                        {% set points_label = 'point' if tip_points == 1 else 'points' %}
+                        <br>&nbsp;&nbsp;You can also get {{ kofi_settings['tip_points'] }} {{ points_label }} for every dollar you tip me on Kofi. 🤑
+                    {% endif %}
+                {% endif %}
+            {% endfor %}
+            <h3>Active Votes</h3>
+            {% if votes %}
+                <table>
+                    <thead>
+                        <tr>
+                            <th style="width: 20%;">Name</th>
+                            <th style="width: 40%;">Description</th>
+                            <th style="width: 20%;">Price</th>
+                            <th style="width: 20%;">Current Tally</th>
+                        </tr>
+                    </thead>
+                    <tbody>
+                    {% for vote in votes %}
+                        <tr>
+                            <td> {{ prefix }}{{ vote[0] }} </td>
+                            <td> {{ vote[2] }} </td>
+                            
+                                {% set points_label = 'point' if rewards[vote[0]]["price"] == 1 else 'points' %}
+                                <td> {{ rewards[vote[0]]["price"] }} {{ points_label }}</td>
+                            
+                            {% set votes_label = 'vote' if vote[1] == 1 else 'votes' %}
+                            
+                            <td> {{ vote[1] }} {{ votes_label }}</td>
+                        </tr>
+                    {% endfor %}
+                    </tbody>
+                </table>
+            {% else %}
+                &nbsp;&nbsp;There are currently no active votes
+                <br>
+            {% endif %}
+            <h3>Active Goals</h3>
+            {% if goals %}
+            <table>
+                <thead>
+                    <tr>
+                        <th style="width: 20%;">Name</th>
+                        <th style="width: 40%;">Description</th>
+                        <th style="width: 25%;">Progress</th>
+                        <th></th>
+                    </tr>
+                </thead>
+                <tbody>
+                {% for goal in goals %}
+                    <tr>
+                        <td> {{ prefix }}{{ goal[0] }} </td>
+                        <td> {{ goal[3] }} </td>
+                        {% set progress = goal[1] / goal[2] * 100 %}
+                        <td><div class="bar-light-grey bar-tiny bar-round">
+                            <div class="bar-round bar-blue" style="text-align: center; width:{{ goal[1] / goal[2] * 100 }}%;">{{ '%0.0f'| format(progress| float) }}%</div></td>
+                        {% if goal[1] == goal[2] %}
+                        <td>   {{ goal[1] }} / {{ goal[2] }} <img src=/static/img/tada.png style="width: 24px; height: 24px;"></td>
+                        {% else %}
+                        <td>   {{ goal[1] }} / {{ goal[2] }} </td>
+                        {% endif %}
+                    </tr>
+                {% endfor %}
+                </tbody>
+            </table>
+            {% else %}
+            &nbsp;&nbsp;There are currently no active goals
+            <br>
+            {% endif %}
+        </body>
+        <body>
+            <h3>Active Redeems</h3>
+            {% if rewards %}
+            <table>
+                <thead>
+                    <tr>
+                        <th style="width: 20%;">Name</th>
+                        <th style="width: 40%;">Description</th>
+                        <th>Cool down</th>
+                        <th style="width: 25%;">Price</th>
+                        <th></th>
+                    </tr>
+                </thead>
+                <tbody>
+                {% for reward, reward_info in rewards.items() %}
+                    {% if reward_info["type"] == "redeem" or reward_info["type"] == "special" %}
+                        <tr>
+                            <td>{{ prefix }}{{ reward }}</td>
+                            <td>{{ reward_info["info"] }}</td>
+                            {% if reward_info["cooldown"] > 0 %}
+                            
+                            {% set minutes_label = 'minute' if reward_info["cooldown"] == 1 else 'minutes' %}
+                            
+                            <td>{{ reward_info["cooldown"] }} {{ minutes_label }}</td>
+                            {% else %}
+                            <td>None</td>
+                            {% endif %}
+                            
+                            {% set points_label = 'point' if reward_info["price"] == 1 else 'points' %}
+                            
+                            <td>{{ reward_info["price"] }} {{ points_label }}</td>
+                            <td></td>
+                        </tr>
+                    {% endif %}
+                {% endfor %}
+                </tbody>
+            </table>
+            {% else %}
+            &nbsp;&nbsp;There are currently no active redeems
+            {% endif %}
+            <br><br>
+        </body>
+    </div>
+
+    <div id='queue' class="tabcontent">
+
+        <body>
+            <h3>Queue</h3>
+            {% if queue %}
+            <table>
+                <thead>
+                    <tr>
+                        <th>Time</th>
+                        <th>Name</th>
+                        <th>Description</th>
+                        <th>User</th>
+                        <th></th>
+                    </tr>
+                </thead>
+                <tbody>
+                {% for row in queue %}
+                    {% if not row[4] %}
+                    <tr>
+                        <td>{{ row[1].replace(tzinfo=utc_timezone).astimezone().strftime("%H:%M") }}</td>
+                        <td>{{ prefix }}{{ row[2] }}</td>
+                        <td>{{ all_rewards[row[2]]["info"] }}</td>
+                        {% if row[6] %}
+                        <td>{{ row[6] }}</td>
+                        {% else %}
+                        <td></td>
+                        {% endif %}
+                    </tr>
+                    {% endif %}
+                {% endfor %}
+                </tbody>
+            </table>
+            {% else %}
+            &nbsp;&nbsp;The queue is currently empty
+            {% endif %}
+        </body>
+        <br><br>
+    </div>
+    
+    <script>
+        setTimeout(refreshPage, 30 * 1000);
+    </script>
+
+</html>

+ 44 - 0
ownchatbot/templates/votes.html

@@ -0,0 +1,44 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <meta http-equiv="refresh" content="5">
+    <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
+    <title>Active votes</title>
+    <style>
+        body {
+            background-color: transparent;
+            color: white;
+            width: 400px;
+            position: absolute; 
+                            bottom: 0; left: 0;
+        }
+    </style>
+</head>
+<body>
+    {% if votes %}
+        <table>
+            <thead>
+                <tr>
+                    <th></th>
+                    <th></th>
+                    <th></th>
+                </tr>
+            </thead>
+            <tbody>
+            {% for vote in votes %}
+                <tr>
+                    <td>{{ vote[2] }}</td>
+                    {% if vote[1] == 1 %}
+                    <td>{{ vote[1] }} vote</td>
+                    {% else %}
+                    <td>{{ vote[1] }} votes</td>
+                    {% endif %}
+                </tr>
+            {% endfor %}
+            </tbody>
+        </table>
+    {% endif %}
+</body>
+</html>

+ 2 - 0
ownchatbot/update_db.sql

@@ -0,0 +1,2 @@
+ALTER TABLE points ADD COLUMN email TEXT;
+

+ 230 - 0
ownchatbot/user_handlers.py

@@ -0,0 +1,230 @@
+from flask import current_app
+from sqlite3 import Error
+from re import sub
+import random
+
+
+def get_users_points(db, user_id):  # Look up one user's points by user id
+    try:
+        cursor = db.execute(
+            "SELECT points FROM points WHERE id = ?",
+            (user_id,)
+        )
+        return cursor.fetchone()[0]
+    except Error as guperror:
+        current_app.logger.error(f'Couldn\'t look up points for {user_id}: {guperror.args[0]}')
+
+
+def get_id_by_email(db, email):  # Look up all users' points by username
+    try:
+        cursor = db.execute(
+            "SELECT id, points FROM points WHERE email = ?",
+            (email,)
+        )
+        users = cursor.fetchall()
+        return users
+    except Error as gaubeerror:
+        current_app.logger.error(f'Couldn\'t look up user id for {email} by email: {gaubeerror.args[0]}')
+
+
+def get_all_users_by_name(db, username):  # Look up all users' points by username
+    try:
+        cursor = db.execute(
+            "SELECT * FROM points WHERE name = ?",
+            (username,)
+        )
+        users = cursor.fetchall()
+        return users
+    except Error as gaubnerror:
+        current_app.logger.error(f'Couldn\'t look up points for {username} by username: {gaubnerror.args[0]}')
+
+
+def get_all_users_with_user_id(db, user_id):  # Look up all users' points by user id
+    try:
+        cursor = db.execute(
+            "SELECT * FROM points WHERE id = ?",
+            (user_id,)
+        )
+        users = cursor.fetchall()
+        return users
+    except Error as gauwuierror:
+        current_app.logger.error(f'Couldn\'t look up points for {user_id} by user_id: {gauwuierror.args[0]}')
+
+
+def get_all_users(db):  # Get all users' details from points database
+    try:
+        cursor = db.execute(
+            "SELECT * FROM points"
+        )
+        users = cursor.fetchall()
+        return users
+    except Error as gauerror:
+        current_app.logger.error(f'Couldn\'t get all users\' points: {gauerror.args[0]}')
+
+
+def award_chat_points(db, user_id, points):  # Award points to user by user id
+    try:
+        db.execute(
+            "UPDATE points SET points = points + ? WHERE id = ?",
+            (points, user_id,)
+        )
+        db.commit()
+        return True
+    except Error as acperror:
+        current_app.logger.error(f'Couldn\'t give {points} points to {user_id}: {acperror.args[0]}')
+        return False
+
+
+def adjust_points(db, user_id, points):  # For streamer to manually adjust a user's points
+    try:
+        db.execute(
+            "UPDATE points SET points = ? WHERE id = ?",
+            (points, user_id,)
+        )
+        db.commit()
+        return True
+    except Error as aperror:
+        current_app.logger.error(f'Couldn\'t adjust points for {user_id}: {aperror.args[0]}')
+        return False
+
+
+def delete_user(db, user_id):
+    try:
+        db.execute(
+            "DELETE FROM points WHERE id = ?",
+            (user_id,)
+            )
+        db.commit()
+        return True
+    except Error as du_error:
+        current_app.logger.error(f'Couldn\'t change delete {user_id} from the database: {du_error.args[0]}')
+        return False
+
+
+def change_email(db, user_id, new_email):  # For streamer to manually adjust a user's points
+    try:
+        ids = get_id_by_email(db, new_email)
+        for user in ids:
+            if user[0] != user_id:  # If the found email belongs to a different user
+                if 'temp' in user[0]:  # If the email address belongs to a temp user
+                    existing_points = get_users_points(db, user_id)
+                    new_points = existing_points + user[1]
+                    if adjust_points(db, user_id, new_points):  # Add points to correct user
+                        current_app.logger.info(f'Email was already in the database as a {user[0]}. Added points to {user_id}')
+                        if delete_user(db, user[0]):  # Delete temp user
+                            current_app.logger.info(f'Removed temp user {user_id} from the database.')
+            elif new_email is not None:
+                current_app.logger.error(f'Couldn\'t change email address for {user_id}. {new_email} already belongs to {user[0]}')
+        db.execute(
+            "UPDATE points SET email = ? WHERE id = ?",
+            (new_email, user_id,)
+        )
+        db.commit()
+        return True
+    except Error as ce_error:
+        current_app.logger.error(f'Couldn\'t change email address for {user_id}: {ce_error.args[0]}')
+        return False
+
+
+def spend_points(db, user_id, points):  # A user spends points on a redeem
+    try:
+        db.execute(
+            "UPDATE points SET points = points - ? WHERE id = ?",
+            (points, user_id,)
+        )
+        db.commit()
+        return True
+    except Error as sperror:
+        current_app.logger.error(f'Couldn\'t spend {user_id}\'s {points} points: {sperror.args[0]}')
+        return False
+
+
+def refund_points(db, user_id, points):  # Streamer refunds points for a redeem
+    try:
+        db.execute(
+            "UPDATE points SET points = points + ? WHERE id = ?",
+            (points, user_id,)
+        )
+        db.commit()
+        return True
+    except Error as rerror:
+        current_app.logger.error(f'Couldn\'t refund {points} points for {user_id}: {rerror.args[0]}')
+        return False
+
+
+def user_in_points(db, user_id):  # Check if a user is in the points database
+    try:
+        cursor = db.execute(
+            "SELECT points FROM points WHERE id = ?",
+            (user_id,)
+        )
+        if cursor.fetchone() is None:
+            return False
+        return True
+    except Error as uiperror:
+        current_app.logger.error(f'Couldn\'t for {user_id} in points database: {uiperror.args[0]}')
+
+
+def add_email_to_points(db, email, points):  # Add an anonymous user and points to the database
+    try:
+        id = f'temp{random.randint(10000, 99999)}'  # Create random id with "temp" prepended
+        db.execute(
+            "INSERT INTO points(id, points, user_authed, email) VALUES(?, ?, 0, ?)",
+            (id, points, email)
+        )
+        db.commit()
+        return True
+    except Error as aetperror:
+        current_app.logger.error(f'Couldn\'t add {email} to points database: {aetperror.args[0]}')
+        return False
+
+
+def add_user_to_points(db, user_id, display_name, authed):  # Add a user to the points database
+    try:
+        cursor = db.execute(
+            "SELECT points, name, user_authed FROM points WHERE id = ?",
+            (user_id,)
+        )
+        user = cursor.fetchone()
+        if user is None:  # Add the user if they're not in the database
+            cursor.execute(
+                "INSERT INTO points(id, name, points, user_authed) VALUES(?, ?, 10, ?)",
+                (user_id, display_name, authed)
+            )
+        if user is not None and user[1] is None:  # If their name has changed, change name in the database
+            cursor.execute(
+                "UPDATE points SET name = ?, user_authed = ? WHERE id = ?",
+                (display_name, authed, user_id)
+            )
+        if user is not None and user[2] != authed:  # If they've authenticated, update the database
+            cursor.execute(
+                "UPDATE points SET user_authed = ? WHERE id = ?",
+                (authed, user_id)
+            )
+        db.commit()
+        return True
+    except Error as autperror:
+        current_app.logger.error(f'Couldn\'t add {user_id}/{display_name}/{authed} to database: {autperror.args[0]}')
+        return False
+
+
+def change_name(db, user_id, new_name):  # Change a user name in the points database
+    try:
+        db.execute(
+            "UPDATE points SET name = ? WHERE id = ?",
+            (new_name, user_id)
+        )
+        db.commit()
+    except Error as cnerror:
+        current_app.logger.error(f'Couldn\'t change name to {new_name} for {user_id}: {cnerror.args[0]}')
+
+
+def remove_duplicates(db, user_id, username):  # Remove duplicate usernames
+    try:
+        db.execute(
+            "UPDATE points SET name = NULL WHERE name = ? AND NOT id = ?",
+            (username, user_id)
+        )
+        db.commit()
+    except Error as rderror:
+        current_app.logger.error(f'Couldn\'t remove duplicate username {username} for {user_id}: {rderror.args[0]}')

+ 444 - 0
ownchatbot/web_panels.py

@@ -0,0 +1,444 @@
+from flask import flash, render_template, Blueprint, current_app, redirect, request, url_for, session
+from datetime import timezone
+from ownchatbot.db import get_db, reread_goals, reread_votes, rem_vote, reset_vote, reset_goal, clear_fulfilled_rewards, clear_reward_queue, rem_cool, rem_from_queue
+from ownchatbot.reward_handlers import all_active_votes, all_active_goals, all_active_rewards, get_queue, fulfill_reward, save_rewards, activate_category, deactivate_category, refund_reward, reread_categories, save_config
+from ownchatbot.user_handlers import get_all_users, get_all_users_by_name, refund_points, adjust_points, change_email
+from ownchatbot.bot_messages import save_announce
+import json
+import emoji
+from ownchatbot.kofi_handlers import save_kofi_settings, kofi_pngs
+
+ocb = Blueprint('web_panels', __name__)
+
+
+@ocb.route('/mgmt', methods=['GET'])  # The streamer's management panel
+def mgmt():
+    auth_code = request.args.get('auth')
+    if auth_code == current_app.config['MGMT_AUTH']:
+        session['auth_code'] = auth_code  # Store auth code in session
+    else:
+        return "Not authorized", 403
+    db = get_db()
+    users = get_all_users(db)
+    utc_timezone = timezone.utc
+    rewards = current_app.config['REWARDS']
+    active_rewards = []
+    for each_reward in all_active_rewards():  # Get the name of all active rewards
+        active_rewards.append(each_reward)
+    active_categories = current_app.config['ACTIVE_CAT']
+    inactive_categories = current_app.config['INACTIVE_CAT']
+    all_cats = current_app.config['ALL_CAT']
+    mgmt_auth = current_app.config['MGMT_AUTH']
+    points_interval = current_app.config['POINTS_INTERVAL']
+    points_award = current_app.config['POINTS_AWARD']
+    gunicorn_logging = current_app.config['GUNICORN']
+    prefix = current_app.config['PREFIX']
+    access_token = current_app.config['ACCESS_TOKEN']
+    owncast_url = current_app.config['OWNCAST_URL']
+    kofi_token = current_app.config['KOFI_TOKEN']
+    kofi_integration = current_app.config['KOFI_INTEGRATION']
+    kofi_logos = kofi_pngs()
+    announce_enable = current_app.config['ANNOUNCE_ENABLE']
+    announce_interval = current_app.config['ANNOUNCE_INTERVAL']
+    announcements = current_app.config['ANNOUNCEMENTS']
+    settings_info = [
+        mgmt_auth,
+        points_interval,
+        points_award,
+        gunicorn_logging,
+        prefix,
+        access_token,
+        owncast_url,
+        kofi_token,
+        kofi_integration,
+        announce_enable,
+        announce_interval
+        ]
+    
+    return render_template('mgmt.html',
+                           queue=get_queue(db),
+                           votes=all_active_votes(db),
+                           goals=all_active_goals(db),
+                           rewards=rewards,
+                           active_rewards=active_rewards,
+                           prefix=current_app.config['PREFIX'],
+                           kofi_settings=current_app.config['KOFI_SETTINGS'],
+                           kofi_integration=kofi_integration,
+                           kofi_logos=kofi_logos,
+                           announcements=announcements,
+                           users=users,
+                           utc_timezone=utc_timezone,
+                           active_categories=active_categories,
+                           inactive_categories=inactive_categories,
+                           settings_info=settings_info)
+
+
+@ocb.route('/userpanel', methods=['GET'])  # The viewers panel
+def user_panel():
+    db = get_db()
+    instance = request.args.get('instance')
+    all_rewards = rewards = current_app.config['REWARDS']
+    username = request.args.get('username')
+    points_interval = current_app.config['POINTS_INTERVAL']
+    points_award = current_app.config['POINTS_AWARD']
+    if username is not None:
+        users = get_all_users_by_name(db, username)
+    else:
+        users = []
+    utc_timezone = timezone.utc
+    return render_template('userpanel.html',
+                           queue=get_queue(db),
+                           votes=all_active_votes(db),
+                           goals=all_active_goals(db),
+                           rewards=all_active_rewards(),
+                           all_rewards=all_rewards,
+                           prefix=current_app.config['PREFIX'],
+                           kofi_settings=current_app.config['KOFI_SETTINGS'],
+                           kofi_integration=current_app.config['KOFI_INTEGRATION'],
+                           points_interval=points_interval,
+                           points_award=points_award,
+                           username=username,
+                           users=users,
+                           instance=instance,
+                           utc_timezone=utc_timezone)
+
+
+@ocb.route('/mgmt/fulfill', methods=['GET'])
+def fulfilled():
+    db = get_db()
+    reward_id = request.args.get('reward_id')
+    username = request.args.get('username')
+    fulfill_reward(db, reward_id)
+    return redirect(url_for('web_panels.mgmt', auth=session['auth_code']))
+
+
+@ocb.route('/mgmt/refund', methods=['GET'])
+def refund():
+    db = get_db()
+    reward_id = request.args.get('reward_id')
+    reward = request.args.get('reward')
+    rewards = current_app.config['REWARDS']
+    points = rewards[reward]['price']
+    username = request.args.get('username')
+    user_id = request.args.get('rewarder_id')
+    refund_points(db, user_id, points)  # resets points
+    refund_reward(db, reward_id)  # marks the reward as refunded
+    return redirect(url_for('web_panels.mgmt', auth=session['auth_code']))
+
+
+@ocb.route('/mgmt/edit_account/<user_id>', methods=['GET', 'POST'])  # Streamer manually edit user's account
+def edit_account(user_id):
+    if 'auth_code' not in session:
+        return "Not authorized", 403
+    db = get_db()
+    name = request.args.get('name')
+    points = request.args.get('points')
+    email = request.args.get('email')
+    if request.method == 'POST':
+        user_id = request.form['user_id']
+        name = request.form['name']
+        newpoints = request.form['newpoints']
+        adjust_points(db, user_id, newpoints)
+        newemail = request.form['newemail']
+        if change_email(db, user_id, newemail):
+            if newemail == '':
+                current_app.logger.info(f'Removed {name}\'s email')
+            else:
+                current_app.logger.info(f'Changed {name}\'s email to {newemail}')
+        return redirect(url_for('web_panels.mgmt', auth=session['auth_code']))
+
+    return render_template('edit_account.html',
+                           name=name,
+                           user_id=user_id,
+                           points=points,
+                           email=email)
+
+
+@ocb.route('/mgmt/delete/<reward_name>', methods=['GET', 'POST'])
+def delete(reward_name):
+    del_reward = current_app.config['REWARDS']
+    del_reward.pop(reward_name)
+    if save_rewards(del_reward):
+        if rem_cool(reward_name):
+            rem_from_queue(reward_name)
+            if reread_votes():
+                if reread_goals():
+                    pass
+    return redirect(url_for('web_panels.mgmt', auth=session['auth_code']))
+
+
+@ocb.route('/mgmt/edit/<reward_name>', methods=['GET', 'POST'])
+def edit(reward_name):
+    if 'auth_code' not in session:
+        return "Not authorized", 403
+    active_categories = current_app.config['ACTIVE_CAT']
+    all_the_rewards = current_app.config['REWARDS']
+    reward_data = all_the_rewards[reward_name]
+    all_cats = current_app.config['ALL_CAT']
+    
+    if request.method == 'POST':
+        reward_data['cooldown'] = int(request.form['cooldown'])
+        reward_data['type'] = request.form['type']
+        if reward_data['type'] == 'goal':
+            reward_data['target'] = int(request.form['target'])
+            if "milestones" not in reward_data:  # If using old rewards.py, and no milestones key exists, create one
+                reward_data["milestones"] = {"milestone1": [], "milestone2": [], "milestone3": []}
+            
+            if request.form['milestone1_points'] == '':
+                reward_data['milestones']['milestone1'] = []
+            else:
+                milestone1_points = int(request.form['milestone1_points'])
+                reward_data['milestones']['milestone1'] = [request.form['milestone1_desc'], milestone1_points]
+            
+            if request.form['milestone2_points'] == '':
+                reward_data['milestones']['milestone2'] = []
+            else:
+                milestone2_points = int(request.form['milestone2_points'])
+                reward_data['milestones']['milestone2'] = [request.form['milestone2_desc'], milestone2_points]
+            
+            if request.form['milestone3_points'] == '':
+                reward_data['milestones']['milestone3'] = []
+            else:
+                milestone3_points = int(request.form['milestone3_points'])
+                reward_data['milestones']['milestone3'] = [request.form['milestone3_desc'], milestone3_points]
+        else:
+            reward_data['price'] = int(request.form['price'])
+        reward_data['info'] = emoji.demojize(request.form['info'])
+        if reward_data['type'] == 'special':
+            reward_data['cmd'] = request.form['cmd']
+        reward_data['categories'] = request.form.getlist('category')
+        reward_data['cooldown'] = int(request.form['cooldown'])
+        all_the_rewards[reward_name] = reward_data
+        save_rewards(all_the_rewards)
+        if reward_data['type'] == 'goal':  # Sync goals and votes in the db with rewards.py
+            reread_goals()
+        if reward_data['type'] == 'vote':
+            reread_votes()
+        return redirect(url_for('web_panels.mgmt', auth=session['auth_code']))
+    
+    return render_template('edit.html',
+                           all_cats=all_cats,
+                           reward_name=reward_name,
+                           active_categories=active_categories,
+                           reward_data=reward_data)
+
+
+@ocb.route('/mgmt/settings', methods=['GET', 'POST'])  # OwnchatBot settings panel
+def settings():
+    points_interval = int(request.form['points_interval'])
+    points_award = int(request.form['points_award'])
+    gunicorn_logging = 'gunicorn_logging' in request.form
+    prefix = request.form['prefix']
+    access_token = request.form['access_token']
+    owncast_url = request.form['owncast_url']
+    mgmt_auth = request.form['mgmt_auth']
+    kofi_integration = 'kofi_integration' in request.form
+    kofi_token = request.form['kofi_token']
+    config_dict = {
+        'MGMT_AUTH': mgmt_auth,
+        'POINTS_INTERVAL': points_interval,
+        'POINTS_AWARD': points_award,
+        'GUNICORN': gunicorn_logging,
+        'PREFIX': prefix,
+        'ACCESS_TOKEN': access_token,
+        'OWNCAST_URL': owncast_url,
+        'KOFI_TOKEN': kofi_token,
+        'KOFI_INTEGRATION': kofi_integration
+        }
+    if save_config(config_dict):  # Save new config.py
+        current_app.logger.info('Saved new config.')
+        
+    return redirect(url_for('web_panels.mgmt', auth=session['auth_code']))
+
+
+@ocb.route('/mgmt/announcements', methods=['GET', 'POST'])  # OwnchatBot settings panel
+def announcements():
+    announce_enable = 'announce_enable' in request.form
+    announce_interval = int(request.form['announce_interval'])
+    new_announcements = []
+    new_announcements = request.form['announcements'].strip().split('\n')
+    announce_dict = {
+        'ANNOUNCEMENTS': new_announcements,
+        'ANNOUNCE_ENABLE': announce_enable,
+        'ANNOUNCE_INTERVAL': announce_interval
+        }
+    if save_announce(announce_dict):  # Save new announce.py
+        current_app.logger.info('Saved new announcements.')
+        
+    return redirect(url_for('web_panels.mgmt', auth=session['auth_code']))
+
+
+@ocb.route('/mgmt/ksettings', methods=['GET', 'POST'])  # OwnchatBot settings panel
+def ksettings():
+    kofi_settings_dict = current_app.config['KOFI_SETTINGS']
+    if request.method == 'POST':
+        enable_tips = 'enable_tips' in request.form
+        set_tip_points = request.form['set_tip_points']
+        kofi_url = request.form['kofi_url']
+        kofi_logo = request.form.get('kofi_logo')
+        
+        kofi_settings_dict['tips'] = enable_tips
+        kofi_settings_dict['tip_points'] = int(set_tip_points)
+        kofi_settings_dict['kofi_url'] = kofi_url
+        kofi_settings_dict['kofi_logo'] = kofi_logo
+        if save_kofi_settings(kofi_settings_dict):
+            current_app.logger.info(f'Saved Kofi settings')
+    
+    return redirect(url_for('web_panels.mgmt', auth=session['auth_code']))
+
+
+@ocb.route('/mgmt/add/<reward_type>', methods=['GET', 'POST'])
+def add(reward_type):
+    if 'auth_code' not in session:
+        return "Not authorized", 403
+    all_cats = current_app.config['ALL_CAT']
+    active_categories = current_app.config['ACTIVE_CAT']
+    all_the_rewards = current_app.config['REWARDS']
+    
+    if request.method == 'POST':
+        name = request.form['name']
+        name = name.lower()  # Force the name to all lower case
+        name = emoji.demojize(name)  # Remove any emojis
+        name = name.replace(" ", "")  # Remove any spaces from the name
+        type = request.form['type']
+        
+        if name in all_the_rewards:  # Check for duplicate reward names
+            flash("A reward with this name already exists.", "error")  # Flash an error message
+            return redirect(url_for('web_panels.add', reward_type=reward_type))  # Redirect back to the add page
+        
+        if type != 'category':  # If we're only adding a category, skip all of this
+            cooldown = int(request.form['cooldown'])
+            if type == 'redeem' or type == 'special' or type == 'vote':
+                price = int(request.form['price'])
+            if type == 'goal':
+                target = int(request.form['target'])
+                milestone1_desc = request.form['milestone1_desc']
+                if request.form['milestone1_points'] == '':
+                    milestone1_points = ''
+                else:
+                    milestone1_points = int(request.form['milestone1_points'])
+                milestone2_desc = request.form['milestone2_desc']
+                if request.form['milestone2_points'] == '':
+                    milestone2_points = ''
+                else:
+                    milestone2_points = int(request.form['milestone2_points'])
+                milestone3_desc = request.form['milestone3_desc']
+                if request.form['milestone3_points'] == '':
+                    milestone3_points = ''
+                else:
+                    milestone3_points = int(request.form['milestone3_points'])
+            info = request.form['info']
+            info = emoji.demojize(info)  # Remove any emojis
+            if type == 'special':
+                cmd = request.form['cmd']
+            categories = request.form.getlist('category')
+            if type == 'redeem':
+                if categories == ['']:
+                    all_the_rewards[name] = {'price': price, 'type': type, 'info': info, 'cooldown': cooldown}
+                else:
+                    all_the_rewards[name] = {'price': price, 'type': type, 'info': info, 'categories': categories, 'cooldown': cooldown}
+            if type == 'goal':
+                if categories == ['']:
+                    all_the_rewards[name] = {'target': target, 'type': type, 'info': info, 'cooldown': cooldown}
+                else:
+                    all_the_rewards[name] = {'target': target, 'type': type, 'info': info, 'categories': categories, 'cooldown': cooldown}
+                all_the_rewards[name]["milestones"] = {"milestone1": [], "milestone2": [], "milestone3": []}  # Create empty milestones key
+                if milestone1_points:
+                    all_the_rewards[name]["milestones"]["milestone1"] = [milestone1_desc, milestone1_points]
+                if milestone2_points:
+                    all_the_rewards[name]["milestones"]["milestone2"] = [milestone2_desc, milestone2_points]
+                if milestone3_points:
+                    all_the_rewards[name]["milestones"]["milestone3"] = [milestone3_desc, milestone3_points]
+            if type == 'vote':
+                if categories == ['']:
+                    all_the_rewards[name] = {'price': price, 'type': type, 'info': info}
+                else:
+                    all_the_rewards[name] = {'price': price, 'type': type, 'info': info, 'categories': categories, 'cooldown': cooldown}
+            if type == 'special':
+                if categories == ['']:
+                    all_the_rewards[name] = {'price': price, 'type': type, 'info': info, 'cmd': cmd, 'cooldown': cooldown}
+                else:
+                    all_the_rewards[name] = {'price': price, 'type': type, 'info': info, 'cmd': cmd, 'categories': categories, 'cooldown': cooldown}
+            save_rewards(all_the_rewards)
+            if type == 'goal':  # Remove old goals and votes from the database
+                reread_goals()
+            if type == 'vote':
+                reread_votes()
+        else:  # If we're only adding a category
+            inactive_categories = current_app.config['INACTIVE_CAT']
+            inactive_categories.append(name)  # Add it to the INACTIVE_CAT variable
+            reread_categories()  # Write it to categories.py
+        return redirect(url_for('web_panels.mgmt', auth=session['auth_code']))
+
+    return render_template('add.html',
+                           all_cats=all_cats,
+                           reward_type=reward_type,
+                           active_categories=active_categories)
+
+
+@ocb.route('/set_viewer_email', methods=['GET', 'POST'])
+def set_viewer_email():
+    db = get_db()
+    user_id = request.form['user_id']
+    new_email = request.form['new_email']
+    instance = request.form['instance']
+    user_name = request.form['user_name']
+    if change_email(db, user_id, new_email):
+        current_app.logger.info(f'Changed {user_id}\'s email to {new_email}')
+    return redirect(url_for('web_panels.user_panel', instance=instance, username=user_name))
+
+
+@ocb.route('/mgmt/activate/<category>', methods=['GET', 'POST'])
+def activate(category):
+    activate_category(category)
+    return redirect(url_for('web_panels.mgmt', auth=session['auth_code']))
+
+
+@ocb.route('/mgmt/deactivate/<category>', methods=['GET', 'POST'])
+def deactivate(category):
+    deactivate_category(category)
+    return redirect(url_for('web_panels.mgmt', auth=session['auth_code']))
+
+
+@ocb.route('/mgmt/delcat/<cat_name>/<cat_act>', methods=['GET', 'POST'])
+def delcat(cat_name, cat_act):
+    active_categories = current_app.config['ACTIVE_CAT']
+    inactive_categories = current_app.config['INACTIVE_CAT']
+    if cat_act == 'inactive':
+        inactive_categories.remove(cat_name)
+    else:
+        active_categories.remove(cat_name)
+    reread_categories()
+    current_rewards = current_app.config['REWARDS']
+    for reward, details in current_rewards.items():  # Remove from rewards.py as well
+        if cat_name in details['categories']:
+            details['categories'].remove(cat_name)
+    save_rewards(current_rewards)
+    return redirect(url_for('web_panels.mgmt', auth=session['auth_code']))
+
+
+@ocb.route('/mgmt/reset/<reward_name>/<reward_type>', methods=['GET', 'POST'])  # Reset votes and goals to zero
+def reset(reward_name, reward_type):
+    if reward_type == "goal":
+        reset_goal(reward_name)
+    if reward_type == "vote":
+        reset_vote(reward_name)
+    return redirect(url_for('web_panels.mgmt', auth=session['auth_code']))
+
+
+@ocb.route('/mgmt/rereadvotes', methods=['GET', 'POST'])
+def rereadv():
+    reread_votes()
+    return redirect(url_for('web_panels.mgmt', auth=session['auth_code']))
+
+
+@ocb.route('/mgmt/clearfulfilled', methods=['GET', 'POST'])
+def clearfulfilled():
+    clear_fulfilled_rewards()
+    return redirect(url_for('web_panels.mgmt', auth=session['auth_code']))
+
+
+@ocb.route('/mgmt/clearqueue', methods=['GET', 'POST'])
+def clear_queue():
+    clear_reward_queue()
+    return redirect(url_for('web_panels.mgmt', auth=session['auth_code']))

+ 141 - 0
ownchatbot/webhooks.py

@@ -0,0 +1,141 @@
+from flask import Flask, request, json, Blueprint, current_app, render_template, jsonify, request
+from ownchatbot.db import get_db
+from ownchatbot.owncast_com import send_chat
+from ownchatbot.user_handlers import add_user_to_points, change_name, get_users_points, remove_duplicates
+from ownchatbot.bot_messages import do_reward, help_message
+from ownchatbot.reward_handlers import all_active_goals, all_active_votes
+from ownchatbot.kofi_handlers import accept_donation
+import json
+
+
+ocb = Blueprint('webhooks', __name__)
+
+
+def format(rawjson):  # Make data legible
+    formatted_data = json.dumps(rawjson, indent=4)
+    return formatted_data
+
+
+@ocb.route('/kofiHook', methods=["POST"])
+def kofiHook():
+    current_app.logger.info(f'----------------------------------------------------------------------------')
+    current_app.logger.info(f'Kofi request')
+    if request.content_type == 'application/x-www-form-urlencoded':
+        raw_data = request.form.get('data')  # Get the kofi data
+        if raw_data:
+            raw_data = json.loads(raw_data)
+            is_authed = raw_data['verification_token']
+            if is_authed == current_app.config['KOFI_TOKEN']:
+                type = raw_data['type']
+                is_public = raw_data['is_public']
+                if is_public:
+                    from_name = raw_data['from_name']
+                new_sub = raw_data['is_first_subscription_payment']
+                message = raw_data['message']
+                shop_items = raw_data['shop_items']
+                name = raw_data['from_name']
+                email = raw_data['email']
+                amount = raw_data['amount']
+                sub_payment = raw_data['is_subscription_payment']
+                first_sub = raw_data['is_first_subscription_payment']
+                tier_name = raw_data['tier_name']
+                if type == 'Shop Order':
+                    current_app.logger.info(f'\n{name} purchased {format(shop_items)}\nMessage: {message}\n')
+                if type == 'Donation':
+                    donation_info = [is_public, name, email, amount, message]
+                    tip_points = current_app.config['KOFI_SETTINGS']['tip_points']
+                    accept_donation(donation_info, tip_points)
+                if type == 'Subscription':
+                    if first_sub:
+                        if tier_name:
+                            current_app.logger.info(f'\n{name} <{email}> subscribed as a {tier_name} tier member.')
+                        else:
+                            current_app.logger.info(f'\n{name} <{email}> subscribed.')
+                    else:
+                        if tier_name:
+                            current_app.logger.info(f'\n{name} <{email}> renewed their {tier_name} tier membership.')
+                        else:
+                            current_app.logger.info(f'\n{name} <{email}> renewed their membership.')
+                        
+                return jsonify({'status': 'success'}), 200
+            else:
+                current_app.logger.info(f'Token invalid. Rejecting.')
+                return jsonify({'status': 'unauthorized'}), 401
+
+
+@ocb.route('/chatHook', methods=['POST'])
+def chat_hook():
+    prefix = current_app.config['PREFIX']
+    data = request.json
+    db = get_db()
+
+    if data['type'] in ['CHAT', 'NAME_CHANGED', 'USER_JOINED']:  # Check if the viewer is in the chatbot database
+        user_id = data['eventData']['user']['id']
+        authed = data['eventData']['user']['authenticated']
+        display_name = data['eventData']['user']['displayName']
+        if add_user_to_points(db, user_id, display_name, authed):
+            current_app.logger.debug(f'Added/updated {user_id} database.')
+            
+    if data['type'] == 'USER_JOINED':  # Do username house cleaning when a viewer joins
+        if data['eventData']['user']['authenticated']:
+            remove_duplicates(db, user_id, display_name)
+    elif data['type'] == 'NAME_CHANGE':
+        user_id = data['eventData']['user']['id']
+        new_name = data['eventData']['newName']
+        change_name(db, user_id, new_name)
+        if data['eventData']['user']['authenticated']:
+            remove_duplicates(db, user_id, new_name)
+    elif data['type'] == 'CHAT':  # If a chat message, sort out what command it is
+        user_id = data['eventData']['user']['id']
+        display_name = data['eventData']['user']['displayName']
+        current_app.logger.debug(f'Chat message from {display_name}:')
+        current_app.logger.debug(f'{data["eventData"]["rawBody"]}')
+        
+        lowercase_msg = data['eventData']['rawBody'].lower()  # Convert body to lower case to match reward case
+        if lowercase_msg.startswith(f'{prefix}help'):  # Send the help message
+            help_message()
+            
+        elif lowercase_msg.startswith(f'{prefix}points'):  # Get the viewer's current points
+            points = get_users_points(db, user_id)
+            if points is None:
+                send_chat('Couldn\'t get your points, for some highly technical reason.')
+            else:
+                send_chat(f'{display_name}, you have {points} points.')
+                
+        elif lowercase_msg.startswith(f'{prefix}rewards'):  # Send rewards list
+            if current_app.config['REWARDS']:
+                rewards_msg = f'Currently active rewards:'
+                for reward, details in current_app.config['REWARDS'].items():
+                    if details.get('categories'):
+                        if not (set(details['categories']) & set(current_app.config['ACTIVE_CAT'])):  # If there are no common categories, continue
+                            continue
+                    if 'type' in details and details['type'] == 'goal':
+                        rewards_msg = f'{rewards_msg}<br>* {prefix}{reward} goal at {details["target"]} contributed points.'
+                    else:
+                        rewards_msg = f'{rewards_msg}<br>* {prefix}{reward} for {details["price"]} points.'
+                    if 'info' in details:
+                        rewards_msg = f'{rewards_msg}<br>{details["info"]}'
+                    else:
+                        rewards_msg = f'{rewards_msg}'
+            else:
+                rewards_msg = 'There are currently no active rewards.'
+            send_chat(rewards_msg)
+                    
+        elif lowercase_msg.startswith(f'{prefix}'):  # Send to handle rewards
+            do_reward(lowercase_msg, user_id)
+
+    return data
+
+
+@ocb.route('/goals', methods=['GET'])  # Route for goals overlay
+def goals():
+    db = get_db()
+    return render_template('goals.html',
+                           goals=all_active_goals(db))
+
+
+@ocb.route('/votes', methods=['GET'])  # Route for votes overlay
+def votes():
+    db = get_db()
+    return render_template('votes.html',
+                           votes=all_active_votes(db))

BIN
ownchatbotwide.xcf


+ 29 - 0
pyproject.toml

@@ -0,0 +1,29 @@
+[build-system]
+requires = ["setuptools", "setuptools-scm"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "ownchatbot"
+version = "1.0.0"
+authors = [
+    {name = "DeadTOm", email = "deadtom@deadtom.me"},
+]
+description = "A chatbot for Owncast"
+readme = "README.md"
+requires-python = ">=3.11.2"
+license = "MIT"
+classifiers = [
+    "Programming Language :: Python :: 3",
+    "Operating System :: OS Independent",
+]
+dependencies = [
+        'flask',
+        'requests',
+        'emoji',
+        'apscheduler'
+]
+keywords = ["streaming", "chatbot", "owncast"]
+
+[project.urls]
+Homepage = "https://git.deadtom.me/deadtom/OwnchatBot"
+Issues = "https://git.deadtom.me/issues"

BIN
screenshots/mgmtmanel07.png


BIN
screenshots/mgmtpanel01.png


BIN
screenshots/mgmtpanel02.png


BIN
screenshots/mgmtpanel03.png


BIN
screenshots/mgmtpanel04.png


BIN
screenshots/mgmtpanel05.png


BIN
screenshots/mgmtpanel06.png


BIN
screenshots/overlays.png


BIN
screenshots/userpanel01.png


BIN
screenshots/userpanel02.png


+ 14 - 0
setup.py

@@ -0,0 +1,14 @@
+from setuptools import find_packages, setup
+
+setup(
+    name='ownchatbot',
+    version='1.0.0',
+    packages=find_packages(),
+    include_package_data=True,
+    install_requires=[
+        'flask',
+        'requests',
+        'emoji',
+        'apscheduler'
+    ],
+)

BIN
smallrobo.xcf


+ 22 - 0
update-db.sh

@@ -0,0 +1,22 @@
+#!/bin/bash
+#
+
+set -e  #  Exit immediately if any command exits with a non-zero status
+
+activate_venv() {
+    source env/bin/activate
+}
+
+update_db() {  # Create the database. This also populates it with some goals and votes from the default rewards.py
+    export FLASK_APP=ownchatbot
+    if python -m flask update-db; then
+        echo "Database updated successfully."
+    else
+        echo "Failed to update the database. Please check for errors."
+        exit 1  # Exit the script with a non-zero status
+    fi
+}
+
+activate_venv
+update_db
+deactivate