9 Commits
Author SHA1 Message Date
lili 0bae46e852 add polls documentation 2025-12-14 20:04:36 +01:00
lili a52db9f6ae make polls not work when they're disabled 2025-12-14 19:42:34 +01:00
lili 924c4c547f show polls on dashboard 2025-12-14 19:22:57 +01:00
lili d7fdc1e183 functioning polls 2025-12-12 20:01:09 +01:00
lili bd6f94a976 poll handler fixes: better split, early return 2025-12-10 20:29:07 +01:00
lili 32596ab4e5 add poll db commands to init, fix db poll commands 2025-12-10 19:44:03 +01:00
lili 6f4c6b33f2 argument fix (teardown passes arguments to the function) 2025-12-10 19:01:15 +01:00
lili ecdd399e27 sql fix 2025-12-10 19:01:01 +01:00
lili 3bbfc140ef import fix 2025-12-10 19:00:45 +01:00
11 changed files with 148 additions and 20 deletions
+36
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. 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 ## 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. 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.
@@ -282,6 +292,19 @@ Resets progress on a milestone, regardless of completion status.
```bash ```bash
python -m flask hard-reset-milestone milestone 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
Configuration files should be in the instance folder. For folder installation of tlapbot, Configuration files should be in the instance folder. For folder installation of tlapbot,
that's `instance/` from the root of the Github repository. that's `instance/` from the root of the Github repository.
@@ -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. - `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. - `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. - `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: #### Example config:
An example to show what your config like could look like An example to show what your config like could look like
```python ```python
@@ -334,6 +358,7 @@ REDEEMS={
"inactive": {"price": 100, "type": "note", "info": "Example redeem that is inactive by default", "category": ["inactive"]} "inactive": {"price": 100, "type": "note", "info": "Example redeem that is inactive by default", "category": ["inactive"]}
} }
``` ```
#### File format #### File format
`redeems.py` is a config file with just a `REDEEMS` key, that assigns a dictionary of redeems to it. `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. Each dictionary entry is a redeem, and the dictionary keys are strings that decide the chat command for the redeem.
@@ -350,3 +375,14 @@ Optionally, each redeem can also have `"info"` and `"category"` entries.
If a category from the list is in `ACTIVE_CATEGORIES` from `config.py`, 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 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"]}
}
```
+8 -1
View File
@@ -26,10 +26,15 @@ def create_app(test_config: None = None) -> Flask:
app.config.from_pyfile('redeems.py', silent=True) app.config.from_pyfile('redeems.py', silent=True)
# set up polls if they're enabled # set up polls if they're enabled
if app.config['POLLS']: if app.config['POLLS_ENABLED']:
app.config.from_object('tlapbot.defaults.default_polls') app.config.from_object('tlapbot.defaults.default_polls')
app.config.from_pyfile('polls.py', silent=True) 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. # Make logging work for gunicorn-ran instances of tlapbot.
if app.config['GUNICORN']: if app.config['GUNICORN']:
gunicorn_logger = logging.getLogger('gunicorn.error') gunicorn_logger = logging.getLogger('gunicorn.error')
@@ -62,6 +67,8 @@ def create_app(test_config: None = None) -> Flask:
app.cli.add_command(db.refresh_milestones_command) app.cli.add_command(db.refresh_milestones_command)
app.cli.add_command(db.reset_milestone_command) app.cli.add_command(db.reset_milestone_command)
app.cli.add_command(db.hard_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 # scheduler job for giving points to users
def proxy_job() -> None: def proxy_job() -> None:
+22 -7
View File
@@ -18,7 +18,7 @@ def get_db() -> sqlite3.Connection:
return g.db return g.db
def close_db() -> None: def close_db(*_args) -> None:
db: sqlite3.Connection = g.pop('db', None) db: sqlite3.Connection = g.pop('db', None)
if db is not None: if db is not None:
@@ -92,12 +92,24 @@ def refresh_counters() -> bool:
return 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: def refresh_polls() -> bool:
db = get_db() db = get_db()
try: try:
db.execute("DELETE FROM polls") db.execute("DELETE FROM polls")
db.commit() db.commit()
for poll in current_app.config['POLLS']:
populate_poll_options(db, poll)
db.commit()
except sqlite3.Error as e: except sqlite3.Error as e:
print("Error occurred deleting old counters:", e.args[0]) print("Error occurred deleting old counters:", e.args[0])
return False return False
@@ -105,16 +117,19 @@ def refresh_polls() -> bool:
def reset_poll(poll: str) -> bool: 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: try:
db = get_db() db = get_db()
db.execute( db.execute(
"UPDATE polls SET points = 0 WHERE poll_name = ?", "DELETE FROM polls WHERE poll_name = ?",
(poll,) (poll,)
) )
db.commit() populate_poll_options(db, poll)
return True return True
except sqlite3.Error as e: except sqlite3.Error as e:
current_app.logger.error(f"Error occurred adding a milestone: {e.args[0]}") current_app.logger.error(f"Error occurred resetting poll: {e.args[0]}")
return False return False
@@ -257,8 +272,8 @@ def refresh_polls_command() -> None:
click.echo('Refreshed polls.') click.echo('Refreshed polls.')
@click.command('reset-polls') @click.command('reset-poll')
@click.argument('milestone') @click.argument('poll')
def reset_poll_command(poll: str) -> None: def reset_poll_command(poll: str) -> None:
"""Resets polls progress back to zero.""" """Resets polls progress back to zero."""
if reset_poll(poll): if reset_poll(poll):
+1 -1
View File
@@ -8,4 +8,4 @@ LIST_REDEEMS=False
ACTIVE_CATEGORIES=[] ACTIVE_CATEGORIES=[]
GUNICORN=False GUNICORN=False
PREFIX='!' PREFIX='!'
POLLS=True POLLS_ENABLED=True
+2 -1
View File
@@ -59,7 +59,8 @@ def owncast_webhook() -> Any | None:
if data["eventData"]["user"]["authenticated"]: if data["eventData"]["user"]["authenticated"]:
remove_duplicate_usernames(db, user_id, display_name) remove_duplicate_usernames(db, user_id, display_name)
elif data["eventData"]["rawBody"].startswith(f"{prefix}vote"): elif data["eventData"]["rawBody"].startswith(f"{prefix}vote"):
handle_poll_vote(data["eventData"]["rawBody"], user_id) if current_app.config['POLLS_ENABLED']:
handle_poll_vote(data["eventData"]["rawBody"], user_id)
elif data["eventData"]["rawBody"].startswith(prefix): elif data["eventData"]["rawBody"].startswith(prefix):
handle_redeem(data["eventData"]["rawBody"], user_id) handle_redeem(data["eventData"]["rawBody"], user_id)
return data return data
+6 -3
View File
@@ -6,9 +6,10 @@ from tlapbot.redeems.polls import poll_option_exists, vote_in_poll
def handle_poll_vote(message: str, user_id: str) -> None: def handle_poll_vote(message: str, user_id: str) -> None:
split_message = message[5:].split(maxsplit=1) split_message = message[5:].split(maxsplit=2)
if len(split_message < 3): if len(split_message) < 3:
send_chat("Can't vote for poll, not enough arguments in message.") send_chat("Can't vote for poll, not enough arguments in message.")
return
poll_name = split_message[0] poll_name = split_message[0]
option = split_message[1] option = split_message[1]
poll_points = split_message[2] poll_points = split_message[2]
@@ -24,8 +25,10 @@ def handle_poll_vote(message: str, user_id: str) -> None:
if not user_points: if not user_points:
send_chat(f"Can't vote in {poll_name} poll, failed to read users' points.") send_chat(f"Can't vote in {poll_name} poll, failed to read users' points.")
return return
poll_points = int(poll_points)
if user_points < 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.") 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 if (vote_in_poll(db, poll_name, option, poll_points) and
use_points(db, user_id, poll_points)): use_points(db, user_id, poll_points)):
send_chat(f"{poll_points} donated to {option} in poll {poll_name}") send_chat(f"{poll_points} points donated to {option} in poll {poll_name}")
+1 -1
View File
@@ -2,7 +2,7 @@ from flask import current_app
from sqlite3 import Error, Connection from sqlite3 import Error, Connection
from typing import Tuple, Any from typing import Tuple, Any
from tlapbot.owncast_helpers import use_points from tlapbot.owncast_helpers import use_points
from tlapbot.redeems import is_redeem_active from tlapbot.redeems.redeems import is_redeem_active
# TODO: add a milestone_exists check? # TODO: add a milestone_exists check?
+39 -2
View File
@@ -2,6 +2,43 @@ from flask import current_app
from sqlite3 import Error, Connection 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: def poll_option_exists(db: Connection, poll_name: str, option: str) -> bool | None:
"""Returns None only if error was logged.""" """Returns None only if error was logged."""
try: try:
@@ -28,11 +65,11 @@ def vote_in_poll(db: Connection, poll_name: str, option: str, points: int) -> bo
if poll_option_exists(db, poll_name, option): if poll_option_exists(db, poll_name, option):
try: try:
cursor = db.execute( cursor = db.execute(
"UPDATE polls SET progress = progress + ? WHERE poll_name = ? and option = ?", "UPDATE polls SET points = points + ? WHERE poll_name = ? and option = ?",
(points, poll_name, option) (points, poll_name, option)
) )
db.commit() db.commit()
return True return True
except Error as e: except Error as e:
current_app.logger.error(f"Error occurred updating milestone: {e.args[0]}") current_app.logger.error(f"Error occurred updating poll: {e.args[0]}")
return False return False
+3 -2
View File
@@ -1,6 +1,7 @@
DROP TABLE IF EXISTS counters; DROP TABLE IF EXISTS counters;
DROP TABLE IF EXISTS redeem_queue; DROP TABLE IF EXISTS redeem_queue;
DROP TABLE IF EXISTS milestones; DROP TABLE IF EXISTS milestones;
DROP TABLE IF EXISTS polls;
CREATE TABLE IF NOT EXISTS points ( CREATE TABLE IF NOT EXISTS points (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -34,5 +35,5 @@ CREATE TABLE polls (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
points INTEGER NOT NULL, points INTEGER NOT NULL,
option TEXT NOT NULL, option TEXT NOT NULL,
poll_name TEXT NOT NULL, poll_name TEXT NOT NULL
) );
+26 -1
View File
@@ -23,7 +23,7 @@
<body> <body>
<h3>Redeems Dashboard</h3> <h3>Redeems Dashboard</h3>
{% if (username and users ) %} {% if (username and users) %}
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -47,6 +47,31 @@
{% endif %} {% endif %}
{% if not passive %} {% 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 %} {% if counters %}
<table> <table>
<thead> <thead>
+3
View File
@@ -3,6 +3,7 @@ from tlapbot.db import get_db
from tlapbot.redeems.redeems import 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.counters import all_active_counters
from tlapbot.redeems.milestones import all_active_milestones 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 tlapbot.owncast_helpers import read_all_users_with_username
from datetime import timezone from datetime import timezone
@@ -22,7 +23,9 @@ def dashboard() -> str:
queue=pretty_redeem_queue(db), queue=pretty_redeem_queue(db),
counters=all_active_counters(db), counters=all_active_counters(db),
milestones=all_active_milestones(db), milestones=all_active_milestones(db),
polls=all_active_polls(db),
redeems=all_active_redeems(), redeems=all_active_redeems(),
max_poll_votes=max_poll_votes,
prefix=current_app.config['PREFIX'], prefix=current_app.config['PREFIX'],
passive=current_app.config['PASSIVE'], passive=current_app.config['PASSIVE'],
username=username, username=username,