10 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
lili b8fb30a2ae add TODO 2025-03-10 21:08:05 +01:00
11 changed files with 149 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.
#### 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.
@@ -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.
@@ -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
@@ -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.
@@ -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`,
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.
### 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)
# 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_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')
@@ -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.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() -> None:
+22 -7
View File
@@ -18,7 +18,7 @@ def get_db() -> sqlite3.Connection:
return g.db
def close_db() -> None:
def close_db(*_args) -> None:
db: sqlite3.Connection = g.pop('db', None)
if db is not None:
@@ -92,12 +92,24 @@ def refresh_counters() -> bool:
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
@@ -105,16 +117,19 @@ def refresh_polls() -> 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:
db = get_db()
db.execute(
"UPDATE polls SET points = 0 WHERE poll_name = ?",
"DELETE FROM polls WHERE poll_name = ?",
(poll,)
)
db.commit()
populate_poll_options(db, poll)
return True
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
@@ -257,8 +272,8 @@ def refresh_polls_command() -> None:
click.echo('Refreshed polls.')
@click.command('reset-polls')
@click.argument('milestone')
@click.command('reset-poll')
@click.argument('poll')
def reset_poll_command(poll: str) -> None:
"""Resets polls progress back to zero."""
if reset_poll(poll):
+1 -1
View File
@@ -8,4 +8,4 @@ LIST_REDEEMS=False
ACTIVE_CATEGORIES=[]
GUNICORN=False
PREFIX='!'
POLLS=True
POLLS_ENABLED=True
+1
View File
@@ -59,6 +59,7 @@ def owncast_webhook() -> Any | None:
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)
+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:
split_message = message[5:].split(maxsplit=1)
if len(split_message < 3):
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]
@@ -24,8 +25,10 @@ def handle_poll_vote(message: str, user_id: str) -> None:
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} donated to {option} in poll {poll_name}")
send_chat(f"{poll_points} points donated to {option} in poll {poll_name}")
+2 -1
View File
@@ -2,9 +2,10 @@ 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 import is_redeem_active
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(
+39 -2
View File
@@ -2,6 +2,43 @@ 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:
@@ -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):
try:
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)
)
db.commit()
return True
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
+3 -2
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,
@@ -34,5 +35,5 @@ CREATE TABLE polls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
points INTEGER 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>
<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>
+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.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
@@ -22,7 +23,9 @@ def dashboard() -> str:
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,