Compare commits

...

30 Commits

Author SHA1 Message Date
0bae46e852 add polls documentation 2025-12-14 20:04:36 +01:00
a52db9f6ae make polls not work when they're disabled 2025-12-14 19:42:34 +01:00
924c4c547f show polls on dashboard 2025-12-14 19:22:57 +01:00
d7fdc1e183 functioning polls 2025-12-12 20:01:09 +01:00
bd6f94a976 poll handler fixes: better split, early return 2025-12-10 20:29:07 +01:00
32596ab4e5 add poll db commands to init, fix db poll commands 2025-12-10 19:44:03 +01:00
6f4c6b33f2 argument fix (teardown passes arguments to the function) 2025-12-10 19:01:15 +01:00
ecdd399e27 sql fix 2025-12-10 19:01:01 +01:00
3bbfc140ef import fix 2025-12-10 19:00:45 +01:00
b8fb30a2ae add TODO 2025-03-10 21:08:05 +01:00
70285958d1 WIP: add polls 2025-03-10 21:06:40 +01:00
9592e2daf0 move defaults to defaults folder 2025-03-10 19:52:55 +01:00
d0ff884dec split redeems.py into a folder 2025-03-10 19:36:15 +01:00
56cb94e1a0 remove the complete column from milestones table 2025-03-07 23:11:42 +01:00
4e363876e7 add None points handling to redeems_handler 2024-10-06 15:23:48 +02:00
20e3d6d4b2 add types to dashboard 2024-10-06 15:23:38 +02:00
b3cbe42450 add types to owncast_webhooks 2024-10-06 15:23:30 +02:00
68c585405d expand types and comments in helpers 2024-10-06 15:23:18 +02:00
e8d8c265a7 add types to db 2024-10-06 15:23:02 +02:00
a274ddb89f add types to init 2024-10-06 15:22:34 +02:00
22fcea8a96 add types to requests 2024-10-06 15:22:25 +02:00
0d5d82a23b fix type alias, remove unused functions 2024-10-06 15:21:57 +02:00
50403381e0 fix is_redeem_from_config_active type hints 2024-10-06 11:18:43 +02:00
6758829634 typo fix 2024-10-06 11:08:50 +02:00
9f89a2a892 rename tlapbot types :( 2024-10-06 11:08:17 +02:00
c8d2865383 redeems.py type signatures, add_to_redeem_queue improvement, redeem alias
need to test slight change
2024-10-06 11:03:56 +02:00
09de99234c add type signature to handle_redeem 2024-10-06 11:02:05 +02:00
fe75fb7acc type signature for send_help 2024-10-05 22:09:13 +02:00
477151f423 typo fix 2024-10-05 22:08:47 +02:00
c22af69b2a type signatures in owncast_helpers
+ typo fix
2024-10-05 22:08:19 +02:00
23 changed files with 590 additions and 306 deletions

View File

@ -97,6 +97,16 @@ Milestone redeems can be used as long-term community challenges, to start stream
challenges, decide new games to play, etc.
#### Polls
Poll redeems are viewer votes, choices or answers. A poll can have multiple options,
and viewers choose which option they're voting for. The dashboard then shows all options, their number of points (=number of votes), and a progress bar indicating which poll option is leading.
Poll redeems can be used to decide what to do next, what game to play, or just a fun viewer interaction where people vote if they like pineapple on pizza.
There is no "limit" or official closure of poll redeems.
Polls only work if `POLLS_ENABLED` is `True` in the config.
## License & Contributions
Tlapbot as it currently is does not come with a license. If you're a content creator, streamer, vtuber, etc. I'll be happy to give you permission to use Tlapbot, or make changes that'd fit your stream.
@ -122,7 +132,7 @@ as a package in editable more.
python -m flask init-db
```
5. Create an `instance/config.py` file and fill it in as needed.
Default values are included in `tlapbot/default_config.py`, and values in
Default values are included in `tlapbot/defaults/default_config.py`, and values in
`config.py` overwrite them. (The database also lives in the instance folder
by default.)
@ -133,7 +143,7 @@ by default.)
OWNCAST_INSTANCE_URL # default points to localhost owncast on default port
```
6. OPTIONAL: Create an `instance/redeems.py` file and add your custom redeems.
If you don't add a redeems file, the bot will initialize the default redeems from `tlapbot/default_redeems.py`.
If you don't add a redeems file, the bot will initialize the default redeems from `tlapbot/defaults/default_redeems.py`.
More details on how to write the config and redeems files are written later in the readme.
7. If you've added any new counters or milestones, run `refresh-counters` or `refresh-milestones` commands to initialize them into the database.
@ -282,6 +292,19 @@ Resets progress on a milestone, regardless of completion status.
```bash
python -m flask hard-reset-milestone milestone
```
#### refresh-polls
Resets progress on all polls, removes old polls not in config, adds new polls from config.
```bash
python -m flask refresh-polls
```
#### reset-poll
Resets progress on a single poll. (Can be used to add a single poll from config while not resetting the other polls.)
```bash
python -m flask reset-poll poll-name
```
## Configuration files
Configuration files should be in the instance folder. For folder installation of tlapbot,
that's `instance/` from the root of the Github repository.
@ -299,7 +322,7 @@ Including these values is mandatory if you want tlapbot to work.
- `OWNCAST_ACCESS_TOKEN` is the owncast access token that owncast will use to get list of users in chat. Generate one in your owncast instance.
- `OWNCAST_INSTANCE_URL` is the full URL of your owncast instance, like `"http://MyTlapbotServer.example"`
#### Optional
Including these values will overwrite their defaults from `/tlapbot/default_config.py`.
Including these values will overwrite their defaults from `/tlapbot/defaults/default_config.py`.
- `POINTS_CYCLE_TIME` decides how often channel points are given to users in chat,
in seconds.
- `POINTS_AMOUNT_GIVEN` decides how many channel points users receive.
@ -309,6 +332,7 @@ This makes the !help output quite long, so it's `False` by default.
- `GUNICORN` if `True`, sets logging to use gunicorn's logger. Only set this to True if you're using Gunicorn to run tlapbot.
- `ACTIVE_CATEGORIES` can be an empty list `[]`, or a list of strings of activated categories (i.e. `["chatting", "singing"]`). Redeems with a category included in the list will be active, redeems from other categories will not be active. Redeems with no category are always active.
- `PREFIX` is a *single character string* that decides what character gets used as a prefix for tlapbot commands. (i.e. you can use `?` instead of `!`). Symbols are recommended. Prefix cannot be longer than one character.
- `POLLS_ENABLED` decides if polls are enabled. `True` by default. You can disable polls by setting it to `False`.
#### Example config:
An example to show what your config like could look like
```python
@ -320,7 +344,7 @@ LIST_REDEEMS=True
ACTIVE_CATEGORIES=["gaming"]
```
### redeems.py
`redeems.py` is a file where you define all your custom redeems. Tlapbot will work without it, but it will load a few default, generic redeems from `tlapbot/default_redeems.py`.
`redeems.py` is a file where you define all your custom redeems. Tlapbot will work without it, but it will load a few default, generic redeems from `tlapbot/defaults/default_redeems.py`.
(`redeems.py` should be in the instance folder: `instance/redeems.py` for folder install.)
#### `default_redeems.py`:
@ -334,6 +358,7 @@ REDEEMS={
"inactive": {"price": 100, "type": "note", "info": "Example redeem that is inactive by default", "category": ["inactive"]}
}
```
#### File format
`redeems.py` is a config file with just a `REDEEMS` key, that assigns a dictionary of redeems to it.
Each dictionary entry is a redeem, and the dictionary keys are strings that decide the chat command for the redeem.
@ -349,4 +374,15 @@ Optionally, each redeem can also have `"info"` and `"category"` entries.
- `"category"` is an optional list of strings, the categories the redeem is in.
If a category from the list is in `ACTIVE_CATEGORIES` from `config.py`,
then the redeem will be active. It will not be active if none of the categories
are in `ACTIVE_CATEGORIES`. Redeems with no category are always active.
are in `ACTIVE_CATEGORIES`. Redeems with no category are always active.
### polls.py
`polls.py` is where you define your custom polls. Tlapbot will work without it, and you don't need to supply your file if you turned off polls.
(`polls.py` should be in the instance folder: `instance/polls.py` for folder install.)
```python
POLLS={
"favourite_food2": {"info": "Vote for your favourite food.", "options": ["pizza", "soup", "curry", "schnitzel"]},
"test_other_thing": {"info": "thing", "options": ["no", "yes"]}
}
```

View File

@ -6,7 +6,7 @@ from tlapbot.db import get_db
from tlapbot.owncast_requests import is_stream_live, give_points_to_chat
def create_app(test_config=None):
def create_app(test_config: None = None) -> Flask:
app = Flask(__name__, instance_relative_config=True)
# ensure the instance folder exists
@ -20,11 +20,21 @@ def create_app(test_config=None):
app.config.from_mapping(
DATABASE=os.path.join(app.instance_path, "tlapbot.sqlite")
)
app.config.from_object('tlapbot.default_config')
app.config.from_object('tlapbot.default_redeems')
app.config.from_object('tlapbot.defaults.default_config')
app.config.from_object('tlapbot.defaults.default_redeems')
app.config.from_pyfile('config.py', silent=True)
app.config.from_pyfile('redeems.py', silent=True)
# set up polls if they're enabled
if app.config['POLLS_ENABLED']:
app.config.from_object('tlapbot.defaults.default_polls')
app.config.from_pyfile('polls.py', silent=True)
for poll in app.config['POLLS']:
if ' ' in poll:
app.logger.warning(f"Poll '{poll}' has spaces in its name.")
app.logger.warning("Poll with spaces are impossible to redeem.")
# Make logging work for gunicorn-ran instances of tlapbot.
if app.config['GUNICORN']:
gunicorn_logger = logging.getLogger('gunicorn.error')
@ -57,9 +67,11 @@ def create_app(test_config=None):
app.cli.add_command(db.refresh_milestones_command)
app.cli.add_command(db.reset_milestone_command)
app.cli.add_command(db.hard_reset_milestone_command)
app.cli.add_command(db.refresh_polls_command)
app.cli.add_command(db.reset_poll_command)
# scheduler job for giving points to users
def proxy_job():
def proxy_job() -> None:
with app.app_context():
if is_stream_live():
app.logger.info("Stream is LIVE. Giving points to chat.")

View File

@ -1,13 +1,13 @@
import sqlite3
import click
from flask import current_app, g
from flask import current_app, g, Flask
from flask.cli import with_appcontext
from tlapbot.redeems import milestone_complete
from tlapbot.redeems.milestones import milestone_complete
def get_db():
def get_db() -> sqlite3.Connection:
if 'db' not in g:
g.db = sqlite3.connect(
current_app.config['DATABASE'],
@ -18,14 +18,14 @@ def get_db():
return g.db
def close_db(e=None):
db = g.pop('db', None)
def close_db(*_args) -> None:
db: sqlite3.Connection = g.pop('db', None)
if db is not None:
db.close()
def insert_counters(db):
def insert_counters(db: sqlite3.Connection) -> bool:
for redeem, redeem_info in current_app.config['REDEEMS'].items():
if redeem_info["type"] == "counter":
try:
@ -40,17 +40,30 @@ def insert_counters(db):
return True
def init_db():
def insert_polls(db: sqlite3.Connection) -> bool:
for poll, poll_info in current_app.config['POLLS'].items():
for option in poll_info["options"]:
try:
db.execute(
"INSERT INTO polls(poll_name, option, points) VALUES(?, ?, 0)",
(poll, option)
)
except sqlite3.Error as e:
print(f"Failed inserting poll option {poll} {option} to db: {e.args[0]}")
return False
return True
def init_db() -> bool:
db = get_db()
with current_app.open_resource('schema.sql') as f:
db.executescript(f.read().decode('utf8'))
if insert_counters(db):
return True
return insert_counters(db)
def clear_redeem_queue():
def clear_redeem_queue() -> bool:
db = get_db()
try:
@ -62,25 +75,65 @@ def clear_redeem_queue():
)
db.commit()
except sqlite3.Error as e:
print("Error occured deleting redeem queue:", e.args[0])
print("Error occurred deleting redeem queue:", e.args[0])
return False
return True
def refresh_counters():
def refresh_counters() -> bool:
db = get_db()
try:
db.execute("DELETE FROM counters")
db.commit()
except sqlite3.Error as e:
print("Error occured deleting old counters:", e.args[0])
print("Error occurred deleting old counters:", e.args[0])
return False
if insert_counters(db):
return insert_counters(db)
def populate_poll_options(db: sqlite3.Connection, poll: str) -> bool:
for option in current_app.config['POLLS'][poll]['options']:
db.execute(
"INSERT INTO polls(poll_name, points, option) VALUES(?, ?, ?)",
(poll, 0, option)
)
db.commit()
return True
def refresh_polls() -> bool:
db = get_db()
try:
db.execute("DELETE FROM polls")
db.commit()
for poll in current_app.config['POLLS']:
populate_poll_options(db, poll)
db.commit()
except sqlite3.Error as e:
print("Error occurred deleting old counters:", e.args[0])
return False
return insert_polls(db)
def reset_poll(poll: str) -> bool:
if poll not in current_app.config['POLLS']:
print(f"Failed resetting poll, {poll} not in polls file.")
return False
try:
db = get_db()
db.execute(
"DELETE FROM polls WHERE poll_name = ?",
(poll,)
)
populate_poll_options(db, poll)
return True
except sqlite3.Error as e:
current_app.logger.error(f"Error occurred resetting poll: {e.args[0]}")
return False
def refresh_milestones():
def refresh_milestones() -> bool:
db = get_db()
# delete old milestones
try:
@ -111,7 +164,7 @@ def refresh_milestones():
result = cursor.fetchone()
if result is None:
cursor.execute(
"INSERT INTO milestones(name, progress, goal, complete) VALUES(?, 0, ?, FALSE)",
"INSERT INTO milestones(name, progress, goal) VALUES(?, 0, ?)",
(redeem, redeem_info['goal'])
)
# update existing milestone to new goal
@ -127,7 +180,7 @@ def refresh_milestones():
return True
def reset_milestone(milestone):
def reset_milestone(milestone: str) -> bool:
if milestone not in current_app.config['REDEEMS']:
print(f"Failed resetting milestone, {milestone} not in redeems file.")
return False
@ -138,19 +191,19 @@ def reset_milestone(milestone):
(milestone,)
)
db.execute(
"INSERT INTO milestones(name, progress, goal, complete) VALUES(?, ?, ?, FALSE)",
"INSERT INTO milestones(name, progress, goal) VALUES(?, ?, ?)",
(milestone, 0, current_app.config['REDEEMS'][milestone]['goal'])
)
db.commit()
return True
except sqlite3.Error as e:
current_app.logger.error(f"Error occured adding a milestone: {e.args[0]}")
current_app.logger.error(f"Error occurred adding a milestone: {e.args[0]}")
return False
@click.command('init-db')
@with_appcontext
def init_db_command():
def init_db_command() -> None:
"""Clear the existing data and create new tables."""
if init_db():
click.echo('Initialized the database.')
@ -158,7 +211,7 @@ def init_db_command():
@click.command('clear-queue')
@with_appcontext
def clear_queue_command():
def clear_queue_command() -> None:
"""Remove all redeems from the redeem queue."""
if clear_redeem_queue():
click.echo('Cleared redeem queue.')
@ -166,7 +219,7 @@ def clear_queue_command():
@click.command('refresh-counters')
@with_appcontext
def refresh_counters_command():
def refresh_counters_command() -> None:
"""Refresh counters from current config file.
(Remove old ones, add new ones.)"""
if refresh_counters():
@ -175,7 +228,7 @@ def refresh_counters_command():
@click.command('clear-refresh')
@with_appcontext
def refresh_and_clear_command():
def refresh_and_clear_command() -> None:
"""Refresh counters and clear queue."""
if refresh_counters() and clear_redeem_queue():
click.echo('Counters refreshed and queue cleared.')
@ -183,7 +236,7 @@ def refresh_and_clear_command():
@click.command('refresh-milestones')
@with_appcontext
def refresh_milestones_command():
def refresh_milestones_command() -> None:
"""Initialize all milestones from the redeems file,
delete milestones not in redeem file."""
if refresh_milestones():
@ -192,7 +245,7 @@ def refresh_milestones_command():
@click.command('reset-milestone')
@click.argument('milestone')
def reset_milestone_command(milestone):
def reset_milestone_command(milestone: str) -> None:
"""Resets a completed milestone back to zero."""
if milestone_complete(get_db(), milestone):
if reset_milestone(milestone):
@ -204,12 +257,31 @@ def reset_milestone_command(milestone):
@click.command('hard-reset-milestone')
@click.argument('milestone')
def hard_reset_milestone_command(milestone):
def hard_reset_milestone_command(milestone: str) -> None:
"""Resets any milestone back to zero."""
if reset_milestone(milestone):
click.echo(f"Hard reset milestone {milestone}.")
def init_app(app):
@click.command('refresh-polls')
@with_appcontext
def refresh_polls_command() -> None:
"""Initialize all polls from the polls file,
delete polls not in polls file."""
if refresh_polls():
click.echo('Refreshed polls.')
@click.command('reset-poll')
@click.argument('poll')
def reset_poll_command(poll: str) -> None:
"""Resets polls progress back to zero."""
if reset_poll(poll):
click.echo(f"Reset poll {poll}.")
else:
click.echo(f"Could not reset poll {poll}.")
def init_app(app: Flask) -> None:
app.teardown_appcontext(close_db)
app.cli.add_command(init_db_command)

View File

View File

@ -7,4 +7,5 @@ PASSIVE=False
LIST_REDEEMS=False
ACTIVE_CATEGORIES=[]
GUNICORN=False
PREFIX='!'
PREFIX='!'
POLLS_ENABLED=True

View File

@ -0,0 +1,3 @@
POLLS={
"favourite_food": {"info": "Vote for your favourite food.", "options": ["pizza", "soup", "curry", "schnitzel"]}
}

View File

@ -2,7 +2,7 @@ from flask import current_app
from tlapbot.owncast_requests import send_chat
def send_help():
def send_help() -> None:
message = []
message.append("Tlapbot gives you points for being in chat, and then allows you to spend those points. <br>")
message.append(f"People connected to chat receive {current_app.config['POINTS_AMOUNT_GIVEN']} points every {current_app.config['POINTS_CYCLE_TIME']} seconds. <br>")

View File

@ -1,11 +1,12 @@
from flask import current_app
from sqlite3 import Error
from sqlite3 import Error, Connection
from re import sub
from typing import Tuple
# # # db stuff # # #
def read_users_points(db, user_id):
"""Errors out if user doesn't exist."""
def read_users_points(db: Connection, user_id: str) -> int | None:
"""Returns None and logs error in case of error, or if user doesn't exist."""
try:
cursor = db.execute(
"SELECT points FROM points WHERE id = ?",
@ -13,11 +14,12 @@ def read_users_points(db, user_id):
)
return cursor.fetchone()[0]
except Error as e:
current_app.logger.error(f"Error occured reading points: {e.args[0]}")
current_app.logger.error(f"To user: {user_id}")
current_app.logger.error(f"Error occurred reading points: {e.args[0]}")
current_app.logger.error(f"Of user: {user_id}")
def read_all_users_with_username(db, username):
def read_all_users_with_username(db: Connection, username: str) -> list[Tuple[str, int]] | None:
"""Returns None only if Error was logged."""
try:
cursor = db.execute(
"SELECT name, points FROM points WHERE name = ?",
@ -26,11 +28,11 @@ def read_all_users_with_username(db, username):
users = cursor.fetchall()
return users
except Error as e:
current_app.logger.error(f"Error occured reading points by username: {e.args[0]}")
current_app.logger.error(f"To everyone with username: {username}")
current_app.logger.error(f"Error occurred reading points by username: {e.args[0]}")
current_app.logger.error(f"Of everyone with username: {username}")
def give_points_to_user(db, user_id, points):
def give_points_to_user(db: Connection, user_id: str, points: int) -> None:
try:
db.execute(
"UPDATE points SET points = points + ? WHERE id = ?",
@ -38,11 +40,11 @@ def give_points_to_user(db, user_id, points):
)
db.commit()
except Error as e:
current_app.logger.error(f"Error occured giving points: {e.args[0]}")
current_app.logger.error(f"Error occurred giving points: {e.args[0]}")
current_app.logger.error(f"To user: {user_id} amount of points: {points}")
def use_points(db, user_id, points):
def use_points(db: Connection, user_id: str, points: int) -> bool:
try:
db.execute(
"UPDATE points SET points = points - ? WHERE id = ?",
@ -51,12 +53,13 @@ def use_points(db, user_id, points):
db.commit()
return True
except Error as e:
current_app.logger.error(f"Error occured using points: {e.args[0]}")
current_app.logger.error(f"Error occurred using points: {e.args[0]}")
current_app.logger.error(f"From user: {user_id} amount of points: {points}")
return False
def user_exists(db, user_id):
def user_exists(db: Connection, user_id: str) -> bool | None:
"""Returns None only if an error was logged."""
try:
cursor = db.execute(
"SELECT points FROM points WHERE id = ?",
@ -66,11 +69,11 @@ def user_exists(db, user_id):
return False
return True
except Error as e:
current_app.logger.error(f"Error occured checking if user exists: {e.args[0]}")
current_app.logger.error(f"Error occurred checking if user exists: {e.args[0]}")
current_app.logger.error(f"To user: {user_id}")
def add_user_to_database(db, user_id, display_name):
def add_user_to_database(db: Connection, user_id: str, display_name: str) -> None:
""" Adds a new user to the database. Does nothing if user is already in."""
try:
cursor = db.execute(
@ -92,11 +95,11 @@ def add_user_to_database(db, user_id, display_name):
)
db.commit()
except Error as e:
current_app.logger.error(f"Error occured adding user to db: {e.args[0]}")
current_app.logger.error(f"Error occurred adding user to db: {e.args[0]}")
current_app.logger.error(f"To user id: {user_id}, with display name: {display_name}")
def change_display_name(db, user_id, new_name):
def change_display_name(db: Connection, user_id: str, new_name: str) -> None:
try:
db.execute(
"UPDATE points SET name = ? WHERE id = ?",
@ -104,11 +107,11 @@ def change_display_name(db, user_id, new_name):
)
db.commit()
except Error as e:
current_app.logger.error(f"Error occured changing display name: {e.args[0]}")
current_app.logger.error(f"Error occurred changing display name: {e.args[0]}")
current_app.logger.error(f"To user id: {user_id}, with display name: {new_name}")
def remove_duplicate_usernames(db, user_id, username):
def remove_duplicate_usernames(db: Connection, user_id: str, username: str) -> None:
try:
db.execute(
"""UPDATE points
@ -118,12 +121,12 @@ def remove_duplicate_usernames(db, user_id, username):
)
db.commit()
except Error as e:
current_app.logger.error(f"Error occured removing duplicate usernames: {e.args[0]}")
current_app.logger.error(f"Error occurred removing duplicate usernames: {e.args[0]}")
# # # misc. stuff # # #
# This is now unused since rawBody attribute of the webhook now returns cleaned-up emotes.
def remove_emoji(message):
def remove_emoji(message: str) -> str:
return sub(
r'<img class="emoji" alt="(:.*?:)" title=":.*?:" src="/img/emoji/.*?">',
r'\1',

View File

@ -1,28 +1,30 @@
import requests
from flask import current_app
from tlapbot.owncast_helpers import give_points_to_user
from sqlite3 import Connection
from typing import Any
def is_stream_live():
def is_stream_live() -> bool:
url = current_app.config['OWNCAST_INSTANCE_URL'] + '/api/status'
try:
r = requests.get(url)
except requests.exceptions.RequestException as e:
current_app.logger.error(f"Error occured checking if stream is live: {e.args[0]}")
current_app.logger.error(f"Error occurred checking if stream is live: {e.args[0]}")
return False
return r.json()["online"]
def give_points_to_chat(db):
def give_points_to_chat(db: Connection) -> None:
url = current_app.config['OWNCAST_INSTANCE_URL'] + '/api/integrations/clients'
headers = {"Authorization": "Bearer " + current_app.config['OWNCAST_ACCESS_TOKEN']}
try:
r = requests.get(url, headers=headers)
except requests.exceptions.RequestException as e:
current_app.logger.error(f"Error occured getting users to give points to: {e.args[0]}")
current_app.logger.error(f"Error occurred getting users to give points to: {e.args[0]}")
return
if r.status_code != 200:
current_app.logger.error(f"Error occured when giving points: Response code not 200.")
current_app.logger.error(f"Error occurred when giving points: Response code not 200.")
current_app.logger.error(f"Response code received: {r.status_code}.")
current_app.logger.error(f"Check owncast instance url and access key.")
return
@ -31,16 +33,16 @@ def give_points_to_chat(db):
give_points_to_user(db, user_id, current_app.config['POINTS_AMOUNT_GIVEN'])
def send_chat(message):
def send_chat(message: str) -> Any:
url = current_app.config['OWNCAST_INSTANCE_URL'] + '/api/integrations/chat/send'
headers = {"Authorization": "Bearer " + current_app.config['OWNCAST_ACCESS_TOKEN']}
try:
r = requests.post(url, headers=headers, json={"body": message})
except requests.exceptions.RequestException as e:
current_app.logger.error(f"Error occured sending chat message: {e.args[0]}")
current_app.logger.error(f"Error occurred sending chat message: {e.args[0]}")
return
if r.status_code != 200:
current_app.logger.error(f"Error occured when sending chat: Response code not 200.")
current_app.logger.error(f"Error occurred when sending chat: Response code not 200.")
current_app.logger.error(f"Response code received: {r.status_code}.")
current_app.logger.error(f"Check owncast instance url and access key.")
return

View File

@ -1,19 +1,25 @@
from flask import Flask, request, json, Blueprint, current_app
from sqlite3 import Connection
from typing import Any
from tlapbot.db import get_db
from tlapbot.owncast_requests import send_chat
from tlapbot.owncast_helpers import (add_user_to_database, change_display_name,
read_users_points, remove_duplicate_usernames)
from tlapbot.help_message import send_help
from tlapbot.redeems_handler import handle_redeem
from tlapbot.poll_handler import handle_poll_vote
bp = Blueprint('owncast_webhooks', __name__)
@bp.route('/owncastWebhook', methods=['POST'])
def owncast_webhook():
data = request.json
db = get_db()
def owncast_webhook() -> Any | None:
"""Reads webhook json -- adds new users, removes duplicate usernames,
handles name changes and chat messages with commands.
Returns the 'data' json from the request."""
data: Any | None = request.json
db: Connection = get_db()
# Make sure user is in db before doing anything else.
if data["type"] in ["CHAT", "NAME_CHANGED", "USER_JOINED"]:
@ -52,6 +58,9 @@ def owncast_webhook():
change_display_name(db, user_id, display_name)
if data["eventData"]["user"]["authenticated"]:
remove_duplicate_usernames(db, user_id, display_name)
elif data["eventData"]["rawBody"].startswith(f"{prefix}vote"):
if current_app.config['POLLS_ENABLED']:
handle_poll_vote(data["eventData"]["rawBody"], user_id)
elif data["eventData"]["rawBody"].startswith(prefix):
handle_redeem(data["eventData"]["rawBody"], user_id)
return data

34
tlapbot/poll_handler.py Normal file
View File

@ -0,0 +1,34 @@
from sqlite3 import Connection
from tlapbot.db import get_db
from tlapbot.owncast_requests import send_chat
from tlapbot.owncast_helpers import read_users_points, use_points
from tlapbot.redeems.polls import poll_option_exists, vote_in_poll
def handle_poll_vote(message: str, user_id: str) -> None:
split_message = message[5:].split(maxsplit=2)
if len(split_message) < 3:
send_chat("Can't vote for poll, not enough arguments in message.")
return
poll_name = split_message[0]
option = split_message[1]
poll_points = split_message[2]
db: Connection = get_db()
if not poll_option_exists(db, poll_name, option):
send_chat("Can't vote for poll, poll and option combination not found.")
return
if not poll_points.isdigit():
send_chat(f"Can't vote in {poll_name} poll, donation amount is not a number.")
return
user_points = read_users_points(db, user_id)
if not user_points:
send_chat(f"Can't vote in {poll_name} poll, failed to read users' points.")
return
poll_points = int(poll_points)
if user_points < poll_points:
send_chat(f"Can't vote in {poll_name} poll, you're voting with more points than you have.")
return
if (vote_in_poll(db, poll_name, option, poll_points) and
use_points(db, user_id, poll_points)):
send_chat(f"{poll_points} points donated to {option} in poll {poll_name}")

View File

@ -1,227 +0,0 @@
from flask import current_app
from sqlite3 import Error
from tlapbot.owncast_helpers import use_points
def counter_exists(db, counter_name):
try:
cursor = db.execute(
"SELECT count FROM counters WHERE name = ?",
(counter_name,)
)
counter = cursor.fetchone()
if counter is None:
current_app.logger.warning("Counter not found in database.")
current_app.logger.warning("Maybe you forgot to run the refresh-counters CLI command "
"after you added a new counter to the config?")
return False
return True
except Error as e:
current_app.logger.error(f"Error occured checking if counter exists: {e.args[0]}")
current_app.logger.error(f"For counter: {counter_name}")
def add_to_counter(db, counter_name):
if counter_exists(db, counter_name):
try:
db.execute(
"UPDATE counters SET count = count + 1 WHERE name = ?",
(counter_name,)
)
db.commit()
return True
except Error as e:
current_app.logger.error(f"Error occured adding to counter: {e.args[0]}")
current_app.logger.error(f"To counter: {counter_name}")
return False
def add_to_redeem_queue(db, user_id, redeem_name, note=None):
try:
db.execute(
"INSERT INTO redeem_queue(redeem, redeemer_id, note) VALUES(?, ?, ?)",
(redeem_name, user_id, note)
)
db.commit()
return True
except Error as e:
current_app.logger.error(f"Error occured adding to redeem queue: {e.args[0]}")
current_app.logger.error(f"To user: {user_id} with redeem: {redeem_name} with note: {note}")
return False
def add_to_milestone(db, user_id, redeem_name, points_donated):
try:
cursor = db.execute(
"SELECT progress, goal FROM milestones WHERE name = ?",
(redeem_name,)
)
row = cursor.fetchone()
if row is None:
current_app.logger.warning("Milestone not found in database.")
current_app.logger.warning("Maybe you forgot to run the refresh-milestones CLI command "
"after you added a new milestone to the config?")
return False
progress, goal = row
if progress + points_donated > goal:
points_donated = goal - progress
if points_donated < 0:
points_donated = 0
if use_points(db, user_id, points_donated):
cursor = db.execute(
"UPDATE milestones SET progress = ? WHERE name = ?",
(progress + points_donated, redeem_name)
)
db.commit()
return True
except Error as e:
current_app.logger.error(f"Error occured updating milestone: {e.args[0]}")
return False
def milestone_complete(db, redeem_name):
try:
cursor = db.execute(
"SELECT complete FROM milestones WHERE name = ?",
(redeem_name,)
)
row = cursor.fetchone()
if row is None:
current_app.logger.warning("Milestone not found in database.")
current_app.logger.warning("Maybe you forgot to run the refresh-milestones CLI command "
"after you added a new milestone to the config?")
else:
return row[0]
except Error as e:
current_app.logger.error(f"Error occured checking if milestone is complete: {e.args[0]}")
def check_apply_milestone_completion(db, redeem_name):
try:
cursor = db.execute(
"SELECT progress, goal FROM milestones WHERE name = ?",
(redeem_name,)
)
row = cursor.fetchone()
if row is None:
current_app.logger.warning("Milestone not found in database.")
current_app.logger.warning("Maybe you forgot to run the refresh-milestones CLI command "
"after you added a new milestone to the config?")
else:
progress, goal = row
if progress == goal:
cursor = db.execute(
"UPDATE milestones SET complete = TRUE WHERE name = ?",
(redeem_name,)
)
db.commit()
return True
return False
except Error as e:
current_app.logger.error(f"Error occured applying milestone completion: {e.args[0]}")
return False
def all_milestones(db):
try:
cursor = db.execute(
"""SELECT name, progress, goal FROM milestones"""
)
return cursor.fetchall()
except Error as e:
current_app.logger.error(f"Error occured selecting all milestones: {e.args[0]}")
def all_counters(db):
try:
cursor = db.execute(
"""SELECT counters.name, counters.count FROM counters"""
)
return cursor.fetchall()
except Error as e:
current_app.logger.error(f"Error occured selecting all counters: {e.args[0]}")
def all_active_counters(db):
counters = all_counters(db)
all_active_counters = []
for name, count in counters:
if is_redeem_active(name):
all_active_counters.append((name, count))
return all_active_counters
def all_active_milestones(db):
milestones = all_milestones(db)
all_active_milestones = []
for name, progress, goal in milestones:
if is_redeem_active(name):
all_active_milestones.append((name, progress, goal))
return all_active_milestones
def all_active_redeems():
redeems = current_app.config['REDEEMS']
all_active_redeems = {}
for redeem_name, redeem_dict in redeems.items():
if redeem_dict.get('category', None):
for category in redeem_dict['category']:
if category in current_app.config['ACTIVE_CATEGORIES']:
all_active_redeems[redeem_name] = redeem_dict
break
else:
all_active_redeems[redeem_name] = redeem_dict
return all_active_redeems
def pretty_redeem_queue(db):
try:
cursor = db.execute(
"""SELECT redeem_queue.created, redeem_queue.redeem, redeem_queue.note, points.name
FROM redeem_queue
INNER JOIN points
on redeem_queue.redeemer_id = points.id"""
)
return cursor.fetchall()
except Error as e:
current_app.logger.error(f"Error occured selecting pretty redeem queue: {e.args[0]}")
def whole_redeem_queue(db):
try:
cursor = db.execute(
"SELECT * from redeem_queue"
)
return cursor.fetchall()
except Error as e:
current_app.logger.error(f"Error occured selecting redeem queue: {e.args[0]}")
def is_redeem_active(redeem_name):
"""Checks if redeem is active. Pulls the redeem by name from config."""
active_categories = current_app.config['ACTIVE_CATEGORIES']
redeem_dict = current_app.config['REDEEMS'].get(redeem_name, None)
if redeem_dict:
if "category" in redeem_dict:
for category in redeem_dict["category"]:
if category in active_categories:
return True
return False
else:
return True
return None # redeem does not exist, unknown active state
def is_redeem_from_config_active(redeem, active_categories):
"""Checks if redeem is active. `redeem` is a whole key:value pair from redeems config."""
if "category" in redeem[1] and redeem[1]["category"]:
for category in redeem[1]["category"]:
if category in active_categories:
return True
return False
return True
def remove_inactive_redeems(redeems, active_categories):
return dict(filter(lambda redeem: is_redeem_from_config_active(redeem, active_categories),
redeems.items()))

View File

View File

@ -0,0 +1,63 @@
from flask import current_app
from sqlite3 import Error, Connection
from typing import Tuple
from tlapbot.redeems.redeems import is_redeem_active
def counter_exists(db: Connection, counter_name: str) -> bool | None:
"""Returns None only if error was logged."""
try:
cursor = db.execute(
"SELECT count FROM counters WHERE name = ?",
(counter_name,)
)
counter = cursor.fetchone()
if counter is None:
current_app.logger.warning("Counter not found in database.")
current_app.logger.warning("Maybe you forgot to run the refresh-counters CLI command "
"after you added a new counter to the config?")
return False
return True
except Error as e:
current_app.logger.error(f"Error occurred checking if counter exists: {e.args[0]}")
current_app.logger.error(f"For counter: {counter_name}")
def add_to_counter(db: Connection, counter_name: str) -> bool:
if counter_exists(db, counter_name):
try:
db.execute(
"UPDATE counters SET count = count + 1 WHERE name = ?",
(counter_name,)
)
db.commit()
return True
except Error as e:
current_app.logger.error(f"Error occurred adding to counter: {e.args[0]}")
current_app.logger.error(f"To counter: {counter_name}")
return False
def all_counters(db: Connection) -> list[Tuple[str, int]] | None:
"""Returns list of all (even inactive) counters and their current value.
Returns None only if error was logged."""
try:
cursor = db.execute(
"""SELECT name, count FROM counters"""
)
return cursor.fetchall()
except Error as e:
current_app.logger.error(f"Error occurred selecting all counters: {e.args[0]}")
def all_active_counters(db: Connection) -> list[Tuple[str, int]] | None:
"""Returns list of all active counters, and their current value.
Returns None if error was logged."""
counters = all_counters(db)
if counters is not None:
all_active_counters = []
for name, count in counters:
if is_redeem_active(name):
all_active_counters.append((name, count))
return all_active_counters

View File

@ -0,0 +1,80 @@
from flask import current_app
from sqlite3 import Error, Connection
from typing import Tuple, Any
from tlapbot.owncast_helpers import use_points
from tlapbot.redeems.redeems import is_redeem_active
# TODO: add a milestone_exists check?
def add_to_milestone(db: Connection, user_id: str, redeem_name: str, points_donated: int) -> bool:
try:
cursor = db.execute(
"SELECT progress, goal FROM milestones WHERE name = ?",
(redeem_name,)
)
row = cursor.fetchone()
if row is None:
current_app.logger.warning("Milestone not found in database.")
current_app.logger.warning("Maybe you forgot to run the refresh-milestones CLI command "
"after you added a new milestone to the config?")
return False
progress, goal = row
if progress + points_donated > goal:
points_donated = goal - progress
if points_donated < 0:
points_donated = 0
if use_points(db, user_id, points_donated):
cursor = db.execute(
"UPDATE milestones SET progress = ? WHERE name = ?",
(progress + points_donated, redeem_name)
)
db.commit()
return True
except Error as e:
current_app.logger.error(f"Error occurred updating milestone: {e.args[0]}")
return False
def milestone_complete(db: Connection, redeem_name: str) -> bool | None:
"""Returns None only if error was logged."""
try:
cursor = db.execute(
"SELECT progress, goal FROM milestones WHERE name = ?",
(redeem_name,)
)
row = cursor.fetchone()
if row is None:
current_app.logger.warning("Milestone not found in database.")
current_app.logger.warning("Maybe you forgot to run the refresh-milestones CLI command "
"after you added a new milestone to the config?")
else:
progress, goal = row
if progress == goal:
return True
return False
except Error as e:
current_app.logger.error(f"Error occurred checking if milestone is complete: {e.args[0]}")
def all_milestones(db: Connection) -> list[Tuple[str, int, int]] | None:
"""Returns list of all (even inactive) milestones, their progress and their goal.
Returns None only if error was logged."""
try:
cursor = db.execute(
"""SELECT name, progress, goal FROM milestones"""
)
return cursor.fetchall()
except Error as e:
current_app.logger.error(f"Error occurred selecting all milestones: {e.args[0]}")
def all_active_milestones(db: Connection) -> list[Tuple[str, int, int]] | None:
"""Returns list of all active milestones, their progress and their goal.
Returns None only if error was logged."""
milestones = all_milestones(db)
if milestones is not None:
all_active_milestones = []
for name, progress, goal in milestones:
if is_redeem_active(name):
all_active_milestones.append((name, progress, goal))
return all_active_milestones

75
tlapbot/redeems/polls.py Normal file
View File

@ -0,0 +1,75 @@
from flask import current_app
from sqlite3 import Error, Connection
def get_poll_votes(db: Connection, poll_name: str) -> dict[str, int] | None:
"""Get all poll votes and point values for a given `poll_name`"""
try:
options = {}
cursor = db.execute(
"SELECT option, points from polls WHERE poll_name = ?",
(poll_name,)
)
for row in cursor:
options[row[0]] = row[1]
return options
except Error as e:
current_app.logger.error(f"Error occurred getting poll votes: {e.args[0]}")
current_app.logger.error(f"For poll {poll_name}")
def all_active_polls(db: Connection) -> dict[str, dict[str, int]] | None:
"""Returns a dict where the keys are poll names, values is a dict of options and points.
Returns None if polls aren't enabled."""
if not current_app.config['POLLS_ENABLED']:
return None
polls = current_app.config['POLLS']
poll_votes = {}
for poll_name in polls.keys():
poll_votes[poll_name] = get_poll_votes(db, poll_name)
return poll_votes
def max_poll_votes(poll_dict: dict[str, int]) -> int:
"""Returns the maximum number of poll votes/points. Input is the output of `get_poll_votes`"""
highest_points = 0
for _, points in poll_dict.items():
if points > highest_points:
highest_points = points
return highest_points
def poll_option_exists(db: Connection, poll_name: str, option: str) -> bool | None:
"""Returns None only if error was logged."""
try:
cursor = db.execute(
"SELECT poll_name, option FROM polls WHERE poll_name = ? and option = ?",
(poll_name, option)
)
counter = cursor.fetchone()
if counter is None:
current_app.logger.warning("Poll and option combination not found in database.")
current_app.logger.warning("Maybe you forgot to run the refresh-polls CLI command "
"after you added a new poll to the config?")
return False
return True
except Error as e:
current_app.logger.error(f"Error occurred checking if poll exists: {e.args[0]}")
current_app.logger.error(f"For poll&option: {poll_name}:{option}")
def vote_in_poll(db: Connection, poll_name: str, option: str, points: int) -> bool | None:
if points <= 0:
current_app.logger.warning(f"Vote for {poll_name}:{option} was not a positive integer.")
return
if poll_option_exists(db, poll_name, option):
try:
cursor = db.execute(
"UPDATE polls SET points = points + ? WHERE poll_name = ? and option = ?",
(points, poll_name, option)
)
db.commit()
return True
except Error as e:
current_app.logger.error(f"Error occurred updating poll: {e.args[0]}")
return False

View File

@ -0,0 +1,74 @@
from flask import current_app
from sqlite3 import Error, Connection
from typing import Tuple, Any
from tlapbot.tlapbot_types import Redeems
# TODO: test if the new default works
def add_to_redeem_queue(db: Connection, user_id: str, redeem_name: str, note: str="") -> bool:
try:
db.execute(
"INSERT INTO redeem_queue(redeem, redeemer_id, note) VALUES(?, ?, ?)",
(redeem_name, user_id, note)
)
db.commit()
return True
except Error as e:
current_app.logger.error(f"Error occurred adding to redeem queue: {e.args[0]}")
current_app.logger.error(f"To user: {user_id} with redeem: {redeem_name} with note: {note}")
return False
def all_active_redeems() -> Redeems:
"""Returns list of all active redeems."""
redeems = current_app.config['REDEEMS']
all_active_redeems = {}
for redeem_name, redeem_dict in redeems.items():
if redeem_dict.get('category', None):
for category in redeem_dict['category']:
if category in current_app.config['ACTIVE_CATEGORIES']:
all_active_redeems[redeem_name] = redeem_dict
break
else:
all_active_redeems[redeem_name] = redeem_dict
return all_active_redeems
def pretty_redeem_queue(db: Connection) -> list[Tuple[str, str, str, str]] | None:
"""Returns a 'pretty' redeem queue, with name of the redeemer joined instead of ID.
Returns None only if error was logged."""
try:
cursor = db.execute(
"""SELECT redeem_queue.created, redeem_queue.redeem, redeem_queue.note, points.name
FROM redeem_queue
INNER JOIN points
on redeem_queue.redeemer_id = points.id"""
)
return cursor.fetchall()
except Error as e:
current_app.logger.error(f"Error occurred selecting pretty redeem queue: {e.args[0]}")
def whole_redeem_queue(db: Connection) -> list[Any] | None:
"""Returns None if error was logged."""
try:
cursor = db.execute(
"SELECT * from redeem_queue" #TODO: specify columns to fetch
)
return cursor.fetchall()
except Error as e:
current_app.logger.error(f"Error occurred selecting redeem queue: {e.args[0]}")
def is_redeem_active(redeem_name: str) -> bool | None:
"""Checks if redeem is active. Pulls the redeem by name from config.
Returns None if the redeem doesn't exist."""
active_categories = current_app.config['ACTIVE_CATEGORIES']
redeem_dict = current_app.config['REDEEMS'].get(redeem_name, None)
if redeem_dict:
if redeem_dict.get('category', None):
for category in redeem_dict["category"]:
if category in active_categories:
return True
return False
else:
return True
return None # redeem does not exist, unknown active state

View File

@ -1,12 +1,13 @@
from flask import current_app
from tlapbot.db import get_db
from tlapbot.owncast_requests import send_chat
from tlapbot.redeems import (add_to_redeem_queue, add_to_counter, add_to_milestone,
check_apply_milestone_completion, milestone_complete, is_redeem_active)
from tlapbot.redeems.redeems import add_to_redeem_queue, is_redeem_active
from tlapbot.redeems.counters import add_to_counter
from tlapbot.redeems.milestones import add_to_milestone, milestone_complete
from tlapbot.owncast_helpers import use_points, read_users_points
def handle_redeem(message, user_id):
def handle_redeem(message: str, user_id: str) -> None:
split_message = message[1:].split(maxsplit=1)
redeem = split_message[0]
if len(split_message) == 1:
@ -25,6 +26,11 @@ def handle_redeem(message, user_id):
redeem_type = current_app.config['REDEEMS'][redeem]["type"]
points = read_users_points(db, user_id)
if points is None:
send_chat(f"Can't redeem {redeem}, failed to read users' points.")
return
# handle milestone first because it doesn't have a price
if redeem_type == "milestone":
if milestone_complete(db, redeem):
@ -39,7 +45,7 @@ def handle_redeem(message, user_id):
send_chat(f"Can't donate zero points.")
elif add_to_milestone(db, user_id, redeem, int(note)):
send_chat(f"Succesfully donated to {redeem} milestone!")
if check_apply_milestone_completion(db, redeem):
if milestone_complete(db, redeem):
send_chat(f"Milestone goal {redeem} complete!")
else:
send_chat(f"Redeeming milestone {redeem} failed.")

View File

@ -1,6 +1,7 @@
DROP TABLE IF EXISTS counters;
DROP TABLE IF EXISTS redeem_queue;
DROP TABLE IF EXISTS milestones;
DROP TABLE IF EXISTS polls;
CREATE TABLE IF NOT EXISTS points (
id TEXT PRIMARY KEY,
@ -12,8 +13,7 @@ CREATE TABLE milestones (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
progress INTEGER NOT NULL,
goal INTEGER NOT NULL,
complete BOOLEAN NOT NULL
goal INTEGER NOT NULL
);
CREATE TABLE counters (
@ -29,4 +29,11 @@ CREATE TABLE redeem_queue (
redeemer_id TEXT NOT NULL,
note TEXT,
FOREIGN KEY (redeemer_id) REFERENCES points (id)
);
CREATE TABLE polls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
points INTEGER NOT NULL,
option TEXT NOT NULL,
poll_name TEXT NOT NULL
);

View File

@ -23,7 +23,7 @@
<body>
<h3>Redeems Dashboard</h3>
{% if (username and users ) %}
{% if (username and users) %}
<table>
<thead>
<tr>
@ -47,6 +47,31 @@
{% endif %}
{% if not passive %}
{% if polls %}
<table>
<thead>
<tr>
<th colspan="2">Active polls</th>
<th colspan="2">Points</th>
</tr>
</thead>
<tbody>
{% for poll_name, poll_dict in polls.items() %}
{% for option in poll_dict.keys() %}
{% set max_vote = max_poll_votes(poll_dict) %}
<tr>
<td> {{ poll_name }} </td>
<td> {{ option }} </td>
<td> {{ poll_dict[option] }} </td>
<td> <progress id="file" max={{ max_vote }} value={{ poll_dict[option] }}></progress></td>
</tr>
{% endfor %}
{% endfor %}
</tbody>
</table>
{% endif %}
{% if counters %}
<table>
<thead>

View File

@ -1,6 +1,9 @@
from flask import render_template, Blueprint, request, current_app
from tlapbot.db import get_db
from tlapbot.redeems import all_active_counters, all_active_milestones, all_active_redeems, pretty_redeem_queue
from tlapbot.redeems.redeems import all_active_redeems, pretty_redeem_queue
from tlapbot.redeems.counters import all_active_counters
from tlapbot.redeems.milestones import all_active_milestones
from tlapbot.redeems.polls import all_active_polls, max_poll_votes
from tlapbot.owncast_helpers import read_all_users_with_username
from datetime import timezone
@ -8,7 +11,7 @@ bp = Blueprint('redeem_dashboard', __name__)
@bp.route('/dashboard', methods=['GET'])
def dashboard():
def dashboard() -> str:
db = get_db()
username = request.args.get("username")
if username is not None:
@ -20,7 +23,9 @@ def dashboard():
queue=pretty_redeem_queue(db),
counters=all_active_counters(db),
milestones=all_active_milestones(db),
polls=all_active_polls(db),
redeems=all_active_redeems(),
max_poll_votes=max_poll_votes,
prefix=current_app.config['PREFIX'],
passive=current_app.config['PASSIVE'],
username=username,

4
tlapbot/tlapbot_types.py Normal file
View File

@ -0,0 +1,4 @@
from typing import Any, TypeAlias
Redeems: TypeAlias = dict[str, dict[str, Any]]
# at the moment the Any could be specialized to str | int | list[str]