瀏覽代碼

first commit

deadtom 6 天之前
當前提交
c03e717ecf

+ 67 - 0
.gitignore

@@ -0,0 +1,67 @@
+# ---> Python
+# Byte-compiled / optimized / DLL files
+instance/
+.kdev4/
+/*.kdev4
+.git/
+screenshots/
+
+__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/
+

+ 165 - 0
README.md

@@ -0,0 +1,165 @@
+# 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)
+- [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.
+* Web based viewer panel for viewing the reward queue, and points and reward information.
+* Web based management panel.
+ * Configure OwnchatBot and Owncast integration settings.
+ * Add/edit/remove rewards and reward categories.
+ * Points management for manually adjusting viewers' points.
+ * A queue manager, where the streamer can mark rewards fulfilled as they go, or refund rewards if needed.
+
+## 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;
+
+## Roadmap
+* Get emojis in OwnchatBot panels working correctly, because my partner insists on it
+* Order reward list by price
+* Audible sound when goal is reached
+* Option to only allow authenticated users and/or followers to redeem.
+* !Timer - Set a timer that will sound an audible alarm. A command only available to the streamer or mods.
+* A system where OCB generates a code, which can be entered into the user panel to get points.
+  * The idea is to enable streamers to use services like Kofi or Patreon, which have an API, so users can purchase stream points, subscribe for monthly perks, or whatever else people might come up with.
+* A "watch" function that watches chat for certain words or phrases, and responds with preset messages.
+* Automated system messages at regular intervals.
+
+## 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.
+
+## 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:
+```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, 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 user 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 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://localhost:8081/userpanel
+    Action Title: Points Rewards
+    ```
+5. Configure the remaining fields/options as desired.
+
+Happy streaming!
+
+
+### 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
+* Check "Refresh browser source when scene becomes active"
+
+Goals ```http://localhost:8081/goals```
+* Recommended width: 610
+* Recommended height: 210
+* 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 [Wondrwmn](https://wondrwmn.deadtom.me).

+ 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
+"

+ 54 - 0
ownchatbot/__init__.py

@@ -0,0 +1,54 @@
+import os
+import logging
+from flask import Flask
+from ownchatbot.db import get_db
+from ownchatbot.owncast_com import live_now, award_points
+from apscheduler.schedulers.background import BackgroundScheduler
+
+
+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)
+
+    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 db init cli command
+    db.init_app(app)
+    
+    def award_job():
+        with app.app_context():
+            if live_now():
+                award_points(get_db())
+
+    banker = BackgroundScheduler()
+    seconds = app.config['POINTS_INTERVAL'] * 60
+    banker.add_job(award_job, 'interval', seconds=seconds)
+    banker.start()
+
+    return app
+
+
+if __name__ == '__main__':
+    create_app()

+ 128 - 0
ownchatbot/bot_messages.py

@@ -0,0 +1,128 @@
+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
+
+
+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 int(contribution) > goal_left(db, reward):  # If they're contributing more than they need to, 
+            contribution = goal_left(db, reward)       # only spend what is needed to reach the goal.
+        if goal_reached(db, reward):
+            send_chat(f'{username}, we already completed this goal.')
+        elif not contribution:
+            send_chat(f'{username}, you didn\'t tell me how many points you want to contribute.')
+        elif int(contribution) > points:
+            send_chat(f'{username}, you don\'t have enough 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:
+                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']
+    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>'
+    send_chat(message)

+ 330 - 0
ownchatbot/db.py

@@ -0,0 +1,330 @@
+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
+
+
+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)
+    
+
+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
+
+
+@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.')
+
+
+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
+
+
+
+
+
+
+
+
+
+

+ 3 - 0
ownchatbot/defaults/categories.py

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

+ 11 - 0
ownchatbot/defaults/config.py

@@ -0,0 +1,11 @@
+# Owncast stuff. Get this information from your Owncast admin panel
+ACCESS_TOKEN = ''  # Get from your Owncast Admin panel -> Integrations -> Access Tokens
+OWNCAST_URL = 'https://your.owncast.site'  # 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

+ 39 - 0
ownchatbot/defaults/rewards.py

@@ -0,0 +1,39 @@
+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
+    },
+    "doathing": {
+        "price": 100,
+        "type": "special",
+        "cmd": "/path/to/doathing.script",
+        "info": "Executes a command on the host system.",
+        "categories": [
+            "alwaysactive"
+        ],
+        "cooldown": 0
+    }
+}

+ 50 - 0
ownchatbot/owncast_com.py

@@ -0,0 +1,50 @@
+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']
+    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']
+
+
+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()

+ 336 - 0
ownchatbot/reward_handlers.py

@@ -0,0 +1,336 @@
+import os
+from flask import current_app
+from sqlite3 import Error
+from ownchatbot.user_handlers import spend_points
+import subprocess
+import json
+
+
+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]}')
+
+
+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
+    new_rewards = json.dumps(reward_info, 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}')
+        f.close
+    except Exception as srerror:
+        current_app.logger.error(f'Couldn\'t save rewards.py: {serror.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"
+    
+    with open(settings_file, 'w') as f:
+        f.write(new_settings)
+    f.close
+    current_app.config.from_pyfile('config.py', silent=True)  # Reread config.py into the app
+
+
+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]}')

+ 44 - 0
ownchatbot/schema.sql

@@ -0,0 +1,44 @@
+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
+);
+
+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
+);

二進制
ownchatbot/static/img/favicon.ico


二進制
ownchatbot/static/img/ownchatbotwide.png


二進制
ownchatbot/static/img/smallrobo.png


二進制
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();
+        }
+    }
+};

+ 152 - 0
ownchatbot/static/style.css

@@ -0,0 +1,152 @@
+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
+    }

+ 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();
+        }
+    }
+};

+ 85 - 0
ownchatbot/templates/add.html

@@ -0,0 +1,85 @@
+<!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>
+    <div class="panel">
+    <div class="navbar">
+        <p></p>
+        <img src="/static/img/ownchatbotwide.png">
+    </div>
+        <body style="text-align: left;">
+            <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> </td>
+                    </tr>
+                        <input type="hidden" name="type" value="{{ reward_type }}"><br>
+                    {% if reward_type != "category" %}
+                    {% if reward_type == "goal" %}
+                        <td> <label for="target">Target:</label> </td>
+                        <td> <input type="number" name="target" required> </td>                    
+                    {% 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 %}
+                    <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>
+        </body>
+    </div>
+</html>

+ 29 - 0
ownchatbot/templates/adjust.html

@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <title>Adjust Points</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>Adjust {{ name }}'s Points</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 }}" required> </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 Points</button><br>
+            </form>
+            <a href="{{ url_for('web_panels.mgmt', auth=session['auth_code']) }}">Cancel</a>
+        </body>
+    </div>
+</html>

+ 77 - 0
ownchatbot/templates/edit.html

@@ -0,0 +1,77 @@
+<!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"] }}</p>
+        <img src="/static/img/ownchatbotwide.png">
+    </div>
+        <body style="text-align: left;">
+            <h1>"{{ reward_name }}"</h1>
+            <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_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 %}
+                    <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>

+ 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>

+ 360 - 0
ownchatbot/templates/mgmt.html

@@ -0,0 +1,360 @@
+<!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="balances" onclick="openTab(event, 'balances')">Points Balances</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="settings" onclick="openTab(event, 'settings')">Settings</button>
+        </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">
+                            <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 %}
+            <h3> You currently have no active goals. </h3>
+            {% endif %}
+            <br>
+            <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='balances' class="tabcontent">
+
+        <body>
+            <h3>Viewer points balances</h3>
+            {% if users %}
+            <table>
+                <thead>
+                    <tr>
+                        <th>User</th>
+                        <th>Points balance</th>
+                        <th></th>
+                    </tr>
+                </thead>
+                <tbody>
+                {% for user in users %}
+                    <tr>
+                        <td> {{ user[0] }} </td>
+                        {% set points_label = 'point' if user[1] == 1 else 'points' %}
+                        
+                        <td>{{ user[1] }} {{ points_label }}</td>
+                        <td> <a href="/mgmt/adjust/{{ user[2] }}?name={{ user[0] }}&points={{ user[1] }}"><button class="button button2" onclick="openTab(event, 'panel')">Adjust Points</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>Type</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["type"] }}</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 %}
+                        <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='settings' class="tabcontent">
+        <body style="text-align: left;">
+            <form method="POST" 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="textr" 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="30"  required> </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="30" required> </td>
+                        <td>The address of your Owncast instance. Can be an internal address, such as "localhost" if that's the case.</td>
+                    </tr>
+                </table>
+                <br><button class="button button2" type="submit">Save Changes</button><br>
+            </form>
+            <br><br>
+        </body>
+    </div>
+</html>

+ 191 - 0
ownchatbot/templates/userpanel.html

@@ -0,0 +1,191 @@
+<!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>
+    </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[0] }}, you currently have {{ user[1] }} {{ points_label }}.<br>
+            &nbsp;&nbsp;You are accruing {{ points_award }} {{ points_label }} every {{ points_interval }} {{ minutes_label }}.
+            {% endfor %}
+            <h3>Active Votes</h3>
+            <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>
+            <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>

+ 158 - 0
ownchatbot/user_handlers.py

@@ -0,0 +1,158 @@
+from flask import current_app
+from sqlite3 import Error
+from re import sub
+
+
+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_all_users_by_name(db, username):  # Look up all users' points by username
+    try:
+        cursor = db.execute(
+            "SELECT name, points 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 id, name, points 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 name, points, id 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()
+    except Error as acperror:
+        current_app.logger.error(f'Couldn\'t give {points} points to {user_id}: {acperror.args[0]}')
+
+
+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()
+    except Error as aperror:
+        current_app.logger.error(f'Couldn\'t adjust points for {user_id}: {aperror.args[0]}')
+
+
+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_user_to_points(db, user_id, display_name):  # Add a user to the points database
+    try:
+        cursor = db.execute(
+            "SELECT points, name FROM points WHERE id = ?",
+            (user_id,)
+        )
+        user = cursor.fetchone()
+        if user is None:
+            cursor.execute(
+                "INSERT INTO points(id, name, points, user_authed) VALUES(?, ?, 10, 0)",
+                (user_id, display_name)
+            )
+        if user is not None and user[1] is None:
+            cursor.execute(
+                """UPDATE points
+                SET name = ?
+                WHERE id = ?""",
+                (display_name, user_id)
+            )
+        db.commit()
+    except Error as autperror:
+        current_app.logger.error(f'Couldn\'t add {user_id}/{display_name} to points database: {autperror.args[0]}')
+
+
+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]}')

+ 307 - 0
ownchatbot/web_panels.py

@@ -0,0 +1,307 @@
+from flask import 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
+import json
+import emoji
+
+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  # Handle invalid auth code
+    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']
+    settings_info = [mgmt_auth, points_interval, points_award, gunicorn_logging, prefix, access_token, owncast_url]
+    
+    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'],
+                           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()
+    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'],
+                           points_interval=points_interval,
+                           points_award=points_award,
+                           username=username,
+                           users=users,
+                           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/adjust/<user_id>', methods=['GET', 'POST'])  # Streamer manually adjusts user's points
+def adjust(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')
+    if request.method == 'POST':
+        user_id = request.form['user_id']
+        name = request.form['name']
+        newpoints = request.form['newpoints']
+        adjust_points(db, user_id, newpoints)
+        return redirect(url_for('web_panels.mgmt', auth=session['auth_code']))
+
+    return render_template('adjust.html',
+                           name=name,
+                           user_id=user_id,
+                           points=points)
+
+
+@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'])
+        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']
+    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
+        }
+    save_config(config_dict)
+    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, because they cause UnicodeEncodeErrors
+        name = name.replace(" ", "")  # Remove any spaces from the name
+        type = request.form['type']
+        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'])
+            info = request.form['info']
+            info = emoji.demojize(info)  # Remove any emojis, because they cause UnicodeEncodeErrors
+            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}
+            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('/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']))

+ 85 - 0
ownchatbot/webhooks.py

@@ -0,0 +1,85 @@
+from flask import Flask, request, json, Blueprint, current_app, render_template
+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
+
+
+ocb = Blueprint('webhooks', __name__)
+
+
+@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']
+        display_name = data['eventData']['user']['displayName']
+        add_user_to_points(db, user_id, display_name)
+
+    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))

二進制
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"
+authors = [
+    {name = "Allen Seelye   ", 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"

+ 14 - 0
setup.py

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

二進制
smallrobo.xcf