Added polls module.
CI / Formatting (push) Successful in 6s
CI / Linting (push) Successful in 6s
CI / Tests (Python 3.12) (push) Successful in 12s
CI / Tests (Python 3.13) (push) Successful in 12s
CI / Tests (Python 3.14) (push) Successful in 10s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 5s

This commit is contained in:
2026-04-08 12:02:07 -04:00
parent 48e3bf2b07
commit ea91c38736
15 changed files with 2347 additions and 1 deletions
+161
View File
@@ -0,0 +1,161 @@
# Copyright 2026 Logan Fick
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Polls module for Owlbot.
Allows moderators to create interactive polls in chat with live results
via Server-Sent Events. Supports single and multi-select polls, hidden
results, and authentication requirements.
"""
from __future__ import annotations
from markupsafe import escape
from owlbot.api import (
EventContext,
EventType,
ModuleContext,
StreamStartedEvent,
StreamStoppedEvent,
UserJoinedEvent,
on_event,
on_setup,
on_teardown,
)
from .commands import (
cancel_poll_command,
end_poll_command,
handle_bare_vote,
poll_command,
vote_command,
)
from .manager import PollManager, get_manager
from .routes import (
create_page,
create_submit,
events_stream,
mod_cancel,
mod_end,
results_page,
static_create_js,
static_polls_css,
static_polls_js,
vote_page,
vote_submit,
)
__all__ = [
"cancel_poll_command",
"create_page",
"create_submit",
"end_poll_command",
"events_stream",
"handle_bare_vote",
"mod_cancel",
"mod_end",
"on_stream_started",
"on_stream_stopped",
"on_user_joined",
"poll_command",
"results_page",
"setup",
"static_create_js",
"static_polls_css",
"static_polls_js",
"teardown",
"vote_command",
"vote_page",
"vote_submit",
]
@on_setup
async def setup(ctx: ModuleContext) -> None:
"""Initialize the polls module.
:param ctx: Module context with config, storage, and other services.
"""
ctx.state["manager"] = PollManager(ctx)
@on_event(EventType.STREAM_STOPPED)
async def on_stream_stopped(ctx: EventContext[StreamStoppedEvent]) -> None:
"""Begin a grace period when the stream goes offline.
:param ctx: The event context.
"""
get_manager(ctx.module).begin_stream_grace()
@on_event(EventType.STREAM_STARTED)
async def on_stream_started(ctx: EventContext[StreamStartedEvent]) -> None:
"""Cancel the stream grace period if the stream restarts.
:param ctx: The event context.
"""
get_manager(ctx.module).cancel_stream_grace()
@on_event(EventType.USER_JOINED)
async def on_user_joined(ctx: EventContext[UserJoinedEvent]) -> None:
"""Notify a user joining chat that a poll is active.
Sends a private message with the poll question, options, and voting
instructions. Also refreshes the user's token privileges in case their
authentication or moderator status has changed.
:param ctx: The event context.
"""
manager = get_manager(ctx.module)
poll = manager.active_poll
if poll is None:
return
event = ctx.event
manager.refresh_user_status(
event.user.id,
is_moderator=event.user.is_moderator,
is_authenticated=event.user.is_authenticated,
)
escaped_question = escape(poll.question)
option_lines = "".join(
f"<strong>{i}.</strong> {escape(opt)}<br>"
for i, opt in enumerate(poll.options, 1)
)
if poll.allow_multiple:
instructions = "Use <strong>!vote</strong> to get a voting link."
else:
instructions = "Type a number in chat or use <strong>!vote</strong> to vote."
await ctx.owncast_client.send_system_message_to_client(
ctx.event.client_id,
f"<h3>Poll in progress: {escaped_question}</h3>"
f"{option_lines}"
f"<br>{instructions}",
unsanitized=True,
)
@on_teardown
async def teardown(ctx: ModuleContext) -> None:
"""Clean up the polls module.
:param ctx: Module context.
"""
manager = get_manager(ctx)
await manager.teardown()
+284
View File
@@ -0,0 +1,284 @@
# Copyright 2026 Logan Fick
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Chat commands for the polls module."""
from __future__ import annotations
from markupsafe import escape
from owlbot.api import (
ChatEvent,
CommandContext,
EventContext,
EventType,
on_command,
on_event,
)
from .manager import PollError, get_manager
@on_command("poll", requires_moderator=True, cooldown=3)
async def poll_command(ctx: CommandContext) -> None:
"""Start the poll creation flow.
Generates a moderator token and sends the poll creator a private link
to the creation form.
:param ctx: The command context.
"""
manager = get_manager(ctx.module)
try:
mod_token = await manager.begin_creation(ctx.user.id)
except PollError as e:
await ctx.owncast_client.send_system_message_to_client(
ctx.chat_event.client_id, str(e)
)
return
url = ctx.routes.url_for(f"/create/{mod_token}")
await ctx.owncast_client.send_system_message_to_client(
ctx.chat_event.client_id,
f'<a href="{url}"><u>Click here to set up a poll</u></a>.',
unsanitized=True,
)
@on_command("endpoll", requires_moderator=True)
async def end_poll_command(ctx: CommandContext) -> None:
"""End the active poll and announce results.
:param ctx: The command context.
"""
manager = get_manager(ctx.module)
try:
await manager.end()
except PollError as e:
await ctx.owncast_client.send_system_message_to_client(
ctx.chat_event.client_id, str(e)
)
@on_command("cancelpoll", requires_moderator=True)
async def cancel_poll_command(ctx: CommandContext) -> None:
"""Cancel the active poll without announcing results.
:param ctx: The command context.
"""
manager = get_manager(ctx.module)
try:
await manager.cancel()
except PollError as e:
await ctx.owncast_client.send_system_message_to_client(
ctx.chat_event.client_id, str(e)
)
@on_command("vote")
async def vote_command(ctx: CommandContext) -> None:
"""Cast a vote or get a voting link.
Hides the vote message, then either processes a quick numeric vote
(single-select only) or sends a private voting page link.
:param ctx: The command context.
"""
manager = get_manager(ctx.module)
# Hide the vote message to keep votes private and prevent chat spam.
await ctx.owncast_client.set_message_visibility(
[ctx.chat_event.message_id], visible=False
)
poll = manager.active_poll
if poll is None:
await ctx.owncast_client.send_system_message_to_client(
ctx.chat_event.client_id, "There is no active poll."
)
return
user_id = ctx.user.id
token = manager.get_voter_token(
user_id,
is_moderator=ctx.user.is_moderator,
is_authenticated=ctx.user.is_authenticated,
)
vote_url = ctx.routes.url_for(f"/vote/{token.value}")
# Check authentication requirement.
if poll.requires_auth and not ctx.user.is_authenticated:
await ctx.owncast_client.send_system_message_to_client(
ctx.chat_event.client_id,
"This poll requires an authenticated account to vote. "
f'<a href="{vote_url}">'
"<u>Click here to view live results</u></a>.",
unsanitized=True,
)
return
# Check if the user has already voted.
if user_id in poll.votes:
await ctx.owncast_client.send_system_message_to_client(
ctx.chat_event.client_id,
"You have already voted in this poll. "
f'<a href="{vote_url}"><u>Click here to view live results</u></a>.',
unsanitized=True,
)
return
args = ctx.args_list
if args and not poll.allow_multiple:
# Quick vote by number (single-select only).
try:
choice = int(args[0])
except ValueError:
choice = None
if choice is None or choice < 1 or choice > len(poll.options):
await ctx.owncast_client.send_system_message_to_client(
ctx.chat_event.client_id,
f"Invalid option number."
f" Use !vote 1-{len(poll.options)} to vote, or "
f'<a href="{vote_url}">'
"<u>click here to cast your vote</u></a>.",
unsanitized=True,
)
return
# Convert 1-based to 0-based index.
selection = choice - 1
await manager.record_vote(user_id, {selection})
await ctx.owncast_client.send_system_message_to_client(
ctx.chat_event.client_id,
f"Your vote for #{choice} ({escape(poll.options[selection])})"
" has been recorded. "
f'<a href="{vote_url}"><u>Click here to view live results</u></a>.',
unsanitized=True,
)
return
# Send voting page link.
if args and poll.allow_multiple:
message = (
"This poll allows multiple selections,"
" so votes must be cast through the voting page. "
f'<a href="{vote_url}">'
"<u>Click here to cast your vote</u></a>."
)
else:
message = f'<a href="{vote_url}"><u>Click here to cast your vote</u></a>.'
await ctx.owncast_client.send_system_message_to_client(
ctx.chat_event.client_id,
message,
unsanitized=True,
)
@on_event(EventType.CHAT)
async def handle_bare_vote(ctx: EventContext[ChatEvent]) -> None:
"""Allow voting by typing a bare number in chat during active polls.
For single-select polls, records the vote directly. For multi-select
polls, sends the user a voting page link instead. The message is hidden
to keep votes private and prevent chat spam.
:param ctx: The event context.
"""
manager = get_manager(ctx.module)
poll = manager.active_poll
if poll is None:
return
event = ctx.event
# Keep token privilege flags in sync with the user's current status.
manager.refresh_user_status(
event.user.id,
is_moderator=event.user.is_moderator,
is_authenticated=event.user.is_authenticated,
)
text = event.raw_body.strip()
try:
choice = int(text)
except ValueError:
return
# Validate the number is within the option range.
if choice < 1 or choice > len(poll.options):
return
# Hide the message to keep votes private and prevent chat spam.
await ctx.owncast_client.set_message_visibility([event.message_id], visible=False)
ctx.stop_propagation("bare number vote in active poll")
token = manager.get_voter_token(
event.user.id,
is_moderator=event.user.is_moderator,
is_authenticated=event.user.is_authenticated,
)
vote_url = ctx.routes.url_for(f"/vote/{token.value}")
# Check authentication requirement.
if poll.requires_auth and not event.user.is_authenticated:
await ctx.owncast_client.send_system_message_to_client(
event.client_id,
"This poll requires an authenticated account to vote. "
f'<a href="{vote_url}">'
"<u>Click here to view live results</u></a>.",
unsanitized=True,
)
return
# Check if the user has already voted.
if event.user.id in poll.votes:
await ctx.owncast_client.send_system_message_to_client(
event.client_id,
"You have already voted in this poll. "
f'<a href="{vote_url}"><u>Click here to view live results</u></a>.',
unsanitized=True,
)
return
# Multi-select polls can't use bare number voting; send a link instead.
if poll.allow_multiple:
await ctx.owncast_client.send_system_message_to_client(
event.client_id,
"This poll allows multiple selections,"
" so votes must be cast through the voting page. "
f'<a href="{vote_url}">'
"<u>Click here to cast your vote</u></a>.",
unsanitized=True,
)
return
# Record the vote.
selection = choice - 1
await manager.record_vote(event.user.id, {selection})
await ctx.owncast_client.send_system_message_to_client(
event.client_id,
f"Your vote for #{choice} ({escape(poll.options[selection])})"
" has been recorded. "
f'<a href="{vote_url}"><u>Click here to view live results</u></a>.',
unsanitized=True,
)
+696
View File
@@ -0,0 +1,696 @@
# Copyright 2026 Logan Fick
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Poll lifecycle manager for the polls module.
Centralizes all poll state transitions (creation, voting, ending, cancellation)
and SSE broadcasting into a single class, keeping route and command handlers thin.
"""
from __future__ import annotations
import asyncio
import contextlib
import secrets
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from markupsafe import escape
from .types import (
CREATION_TOKEN_TIMEOUT,
MAX_DURATION,
MAX_OPTION_LENGTH,
MAX_OPTIONS,
MAX_QUESTION_LENGTH,
MIN_DURATION,
MIN_OPTIONS,
RESULT_EXPIRY,
STREAM_GRACE_PERIOD,
ActivePoll,
CompletedPoll,
SSEEvent,
Token,
compute_results,
sse_payload,
)
if TYPE_CHECKING:
from aiohttp import web
from owlbot.api import ModuleContext
class PollError(Exception):
"""Raised when a poll operation fails."""
class PollManager:
"""Owns all poll lifecycle operations.
Manages poll creation, voting, SSE broadcasting, ending, and cancellation.
Initialized during module setup and stored in module state.
:param ctx: The module context providing storage, owncast client, routes, etc.
"""
def __init__(self, ctx: ModuleContext) -> None:
"""Initialize the manager with the module context."""
self._ctx = ctx
self._active_poll: ActivePoll | None = None
self._creation_token: Token | None = None
self._creation_expiry_task: asyncio.Task[None] | None = None
self._grace_task: asyncio.Task[None] | None = None
self._last_result: CompletedPoll | None = None
self._result_expiry_task: asyncio.Task[None] | None = None
@property
def active_poll(self) -> ActivePoll | None:
"""The currently active poll, if any."""
return self._active_poll
@property
def creation_token_value(self) -> str | None:
"""The pending token value for poll creation (or None)."""
return self._creation_token.value if self._creation_token else None
@property
def last_result(self) -> CompletedPoll | None:
"""The most recently completed poll result, if any."""
return self._last_result
async def begin_creation(self, creator_id: str) -> str:
"""Begin the poll creation flow by generating a moderator token.
The token expires after :data:`CREATION_TOKEN_TIMEOUT` seconds
if not used.
:param creator_id: The user ID of the poll creator.
:return: The generated moderator token.
:raises PollError: If a poll is already active.
"""
if self._active_poll is not None:
raise PollError("A poll is already active.")
# Cancel any previous token and its expiry task.
self.cancel_creation_token()
self._creation_token = Token(
user_id=creator_id,
is_moderator=True,
is_authenticated=True,
)
async def _expire_creation_token() -> None:
await asyncio.sleep(CREATION_TOKEN_TIMEOUT)
self._creation_token = None
self._creation_expiry_task = None
self._ctx.logger.debug(
"Creation token expired after %ds.",
CREATION_TOKEN_TIMEOUT,
)
self._creation_expiry_task = asyncio.create_task(_expire_creation_token())
return self._creation_token.value
def cancel_creation_token(self) -> None:
"""Cancel any pending creation token and its expiry task."""
if self._creation_expiry_task is not None:
self._creation_expiry_task.cancel()
self._creation_expiry_task = None
self._creation_token = None
def _cancel_result_expiry(self) -> None:
"""Cancel any running result expiry task."""
if self._result_expiry_task is not None:
self._result_expiry_task.cancel()
self._result_expiry_task = None
async def create_poll(
self,
*,
token: str,
question: str,
options: list[str],
hidden: bool,
requires_auth: bool,
allow_multiple: bool,
min_selections: int | None,
max_selections: int | None,
duration: int,
) -> ActivePoll:
"""Create and activate a new poll.
Validates the moderator token, constructs an :class:`ActivePoll`,
starts the auto-end timer task, and announces in chat.
:param token: Moderator token from :meth:`begin_creation`.
:param question: The poll question.
:param options: List of poll option strings.
:param hidden: Whether vote results are hidden until the poll ends.
:param requires_auth: Whether voters must be authenticated.
:param allow_multiple: Whether voters can select multiple options.
:param min_selections: Minimum selections required (multi-select).
:param max_selections: Maximum selections allowed (multi-select).
:param duration: Poll duration in seconds.
:return: The newly created :class:`ActivePoll`.
:raises PollError: If validation fails, the token is invalid,
or a poll is already active.
"""
self._last_result = None
self._cancel_result_expiry()
if self._active_poll is not None:
raise PollError("A poll is already active.")
if self._creation_token is None or not secrets.compare_digest(
self._creation_token.value, token
):
raise PollError("Invalid or expired creation token.")
# Validate inputs.
if not question:
raise PollError("Question is required.")
if len(question) > MAX_QUESTION_LENGTH:
raise PollError(
f"Question must be {MAX_QUESTION_LENGTH} characters or less."
)
# Strip and validate options.
options = [o.strip() for o in options]
if len(options) < MIN_OPTIONS:
raise PollError(f"At least {MIN_OPTIONS} options are required.")
if len(options) > MAX_OPTIONS:
raise PollError(f"Maximum {MAX_OPTIONS} options allowed.")
for i, opt in enumerate(options):
if not opt:
raise PollError(f"Option {i + 1} is empty.")
if len(opt) > MAX_OPTION_LENGTH:
raise PollError(
f"Option {i + 1} exceeds {MAX_OPTION_LENGTH} character limit."
)
if len(set(options)) != len(options):
raise PollError("Duplicate options are not allowed.")
if duration < MIN_DURATION or duration > MAX_DURATION:
raise PollError(
f"Duration must be between {MIN_DURATION} and {MAX_DURATION}s."
)
# Ignore selection limits for single-select polls.
if not allow_multiple:
min_selections = None
max_selections = None
if min_selections is not None and min_selections < 1:
raise PollError("Minimum selections must be at least 1.")
if max_selections is not None and max_selections < 1:
raise PollError("Maximum selections must be at least 1.")
num_options = len(options)
if min_selections is not None and min_selections > num_options:
raise PollError(
f"Minimum selections ({min_selections})"
f" exceeds option count ({num_options})."
)
if max_selections is not None and max_selections > num_options:
raise PollError(
f"Maximum selections ({max_selections})"
f" exceeds option count ({num_options})."
)
if (
min_selections is not None
and max_selections is not None
and min_selections > max_selections
):
raise PollError("Minimum selections cannot exceed maximum selections.")
now = datetime.now(UTC)
poll = ActivePoll(
question=question,
options=options,
hidden=hidden,
requires_auth=requires_auth,
allow_multiple=allow_multiple,
min_selections=min_selections,
max_selections=max_selections,
duration=duration,
created_at=now,
tokens={token: self._creation_token},
)
self._active_poll = poll
# The token object now lives in poll.tokens as a regular token,
# so clearing the creation reference has no effect on it.
self.cancel_creation_token()
# Start auto-end timer task with 60-second reminder.
async def _poll_timer() -> None:
if duration > 60:
await asyncio.sleep(duration - 60)
if poll.allow_multiple:
instructions = "Use <strong>!vote</strong> to get a voting link."
else:
instructions = (
"Type a number in chat or use <strong>!vote</strong> to vote."
)
escaped_question = escape(poll.question)
reminder = (
f"<strong>60 seconds remaining:</strong>"
f" {escaped_question}<br>{instructions}"
)
await self._ctx.owncast_client.send_system_message(
reminder, unsanitized=True
)
await asyncio.sleep(60)
else:
await asyncio.sleep(duration)
await self.end()
poll.timer_task = asyncio.create_task(_poll_timer())
# Announce in chat.
if duration >= 60:
minutes = duration // 60
time_str = f"{minutes} minute{'s' if minutes != 1 else ''}"
else:
time_str = f"{duration} second{'s' if duration != 1 else ''}"
escaped_question = escape(question)
option_lines = "".join(
f"<strong>{i}.</strong> {escape(opt)}<br>"
for i, opt in enumerate(options, 1)
)
if allow_multiple:
instructions = "Use <strong>!vote</strong> to get a voting link."
else:
instructions = (
"Type a number in chat or use <strong>!vote</strong> to vote."
)
announcement = (
f"<h3>Poll: {escaped_question}</h3>"
f"{option_lines}"
f"<br>{instructions} Voting ends in {time_str}."
)
await self._ctx.owncast_client.send_system_message(
announcement, unsanitized=True
)
self._ctx.logger.info(
"Poll created: %s (%d options, %ds)",
question,
len(options),
duration,
)
return poll
def get_voter_token(
self, user_id: str, *, is_moderator: bool, is_authenticated: bool
) -> Token:
"""Get or create a token for the given user.
:param user_id: The user's unique identifier.
:param is_moderator: Whether the user has moderator privileges.
:param is_authenticated: Whether the user has an authenticated Owncast account.
:return: The Token object.
:raises PollError: If no poll is currently active.
"""
poll = self._active_poll
if poll is None:
raise PollError("No active poll.")
# Return existing token if user already has one.
for token in poll.tokens.values():
if token.user_id == user_id:
return token
# Create new token with privilege based on mod status.
token = Token(
user_id=user_id,
is_moderator=is_moderator,
is_authenticated=is_authenticated,
)
poll.tokens[token.value] = token
return token
def refresh_user_status(
self, user_id: str, *, is_moderator: bool, is_authenticated: bool
) -> None:
"""Update the privilege flags on an existing token for the given user.
No-op if there is no active poll or the user has no token.
:param user_id: The user's unique identifier.
:param is_moderator: Current moderator status.
:param is_authenticated: Current authentication status.
"""
poll = self._active_poll
if poll is None:
return
for token in poll.tokens.values():
if token.user_id == user_id:
token.is_moderator = is_moderator
token.is_authenticated = is_authenticated
return
async def record_vote(self, user_id: str, selections: set[int]) -> None:
"""Record a user's vote and broadcast an SSE update.
:param user_id: The voter's unique identifier.
:param selections: Set of selected option indices (0-based).
:raises PollError: If no poll is active or the selections are invalid.
"""
poll = self._active_poll
if poll is None:
raise PollError("No active poll.")
# Check authentication requirement.
if poll.requires_auth:
authenticated = False
for token_obj in poll.tokens.values():
if token_obj.user_id == user_id:
authenticated = token_obj.is_authenticated
break
if not authenticated:
raise PollError("This poll requires an authenticated account to vote.")
if user_id in poll.votes:
raise PollError("You have already voted.")
if not selections:
raise PollError("No options selected.")
num_options = len(poll.options)
for idx in selections:
if idx < 0 or idx >= num_options:
raise PollError(
f"Option index {idx} is out of range (0-{num_options - 1})."
)
if not poll.allow_multiple and len(selections) > 1:
raise PollError("Only one selection is allowed.")
if poll.min_selections is not None and len(selections) < poll.min_selections:
raise PollError(f"At least {poll.min_selections} selections are required.")
if poll.max_selections is not None and len(selections) > poll.max_selections:
raise PollError(f"At most {poll.max_selections} selections are allowed.")
poll.votes[user_id] = selections
# Broadcast SSE update.
results = compute_results(poll.options, poll.votes)
await self._broadcast_sse(
poll,
SSEEvent.TALLY,
{
"options": poll.options,
"counts": results["counts"],
"total_votes": results["total_votes"],
},
)
async def end(self) -> None:
"""End the active poll, store results in memory, and announce.
:raises PollError: If no poll is currently active.
"""
poll = self._active_poll
if poll is None:
raise PollError("No active poll.")
# Clear state synchronously before any await to prevent a concurrent
# end() call (e.g. timer + !endpoll) from passing the guard above.
self._active_poll = None
# Cancel the timer task. When the timer itself calls end(),
# skip entirely: cancelling the current task would poison
# every subsequent await with CancelledError.
if (
poll.timer_task is not None
and poll.timer_task is not asyncio.current_task()
):
poll.timer_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await poll.timer_task
# Compute results and sort options by vote count descending,
# then alphabetically, so the stored data is display-ready.
results = compute_results(poll.options, poll.votes)
counts: list[int] = results["counts"]
sorted_indices = sorted(
range(len(poll.options)),
key=lambda i: (-counts[i], poll.options[i].lower()),
)
sorted_options = [poll.options[i] for i in sorted_indices]
results["counts"] = [counts[i] for i in sorted_indices]
# Store result in memory.
self._last_result = CompletedPoll(
question=poll.question,
options=sorted_options,
results=results,
created_at=poll.created_at,
ended_at=datetime.now(UTC),
)
# Start expiry timer.
self._cancel_result_expiry()
async def _expire_result() -> None:
await asyncio.sleep(RESULT_EXPIRY)
self._last_result = None
self._result_expiry_task = None
self._ctx.logger.debug("Last poll result expired after %ds.", RESULT_EXPIRY)
self._result_expiry_task = asyncio.create_task(_expire_result())
# Announce results in chat.
results_url = self._ctx.routes.url_for("/results")
winners: list[str] = results["winners"]
is_tie: bool = results["is_tie"]
total_votes: int = results["total_votes"]
if total_votes == 0:
outcome = "No votes were cast."
elif is_tie:
tied = ", ".join(escape(w) for w in winners)
outcome = f"It's a tie between: {tied}"
else:
outcome = f"Winner: <strong>{escape(winners[0])}</strong>"
escaped_question = escape(poll.question)
announcement = (
f"<h3>Poll ended: {escaped_question}</h3>"
f"{outcome}<br>"
f"Total votes: {total_votes}<br>"
f'<a href="{escape(results_url)}">'
"<u>Click here to view the results</u></a>."
)
await self._ctx.owncast_client.send_system_message(
announcement, unsanitized=True
)
# Wake up SSE keepalive loops so they exit promptly.
poll.done_event.set()
# Send poll_ended SSE event.
await self._broadcast_sse(
poll,
SSEEvent.POLL_ENDED,
{"results_url": results_url},
)
await self._close_sse_clients(poll)
async def cancel(self) -> None:
"""Cancel the active poll without saving results.
:raises PollError: If no poll is currently active.
"""
poll = self._active_poll
if poll is None:
raise PollError("No active poll.")
# Clear state synchronously before any await to prevent a concurrent
# cancel() call from passing the guard above.
self._active_poll = None
# Cancel the timer task.
if poll.timer_task is not None:
poll.timer_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await poll.timer_task
# Wake up SSE keepalive loops so they exit promptly.
poll.done_event.set()
# Announce cancellation in chat.
await self._ctx.owncast_client.send_system_message(
f"<h3>Poll cancelled: {escape(poll.question)}</h3>",
unsanitized=True,
)
# Send poll_cancelled SSE event.
await self._broadcast_sse(poll, SSEEvent.POLL_CANCELLED, {})
await self._close_sse_clients(poll)
async def register_sse_client(
self, token: str, response: web.StreamResponse
) -> None:
"""Register an SSE client connection, replacing any existing one for the token.
Sends an initial ``tally`` event so the client has current
results without needing them baked into the page.
:param token: The client's token (mod or voter).
:param response: The SSE stream response object.
:raises PollError: If no poll is currently active.
"""
poll = self._active_poll
if poll is None:
raise PollError("No active poll.")
if token not in poll.tokens:
raise PollError("Invalid token.")
poll.sse_clients[token] = response
# Send initial vote counts if this client should see results.
token_obj = poll.tokens[token]
if token_obj.is_moderator or not poll.hidden:
results = compute_results(poll.options, poll.votes)
payload = sse_payload(
SSEEvent.TALLY,
{
"options": poll.options,
"counts": results["counts"],
"total_votes": results["total_votes"],
},
)
await response.write(payload)
def begin_stream_grace(self) -> None:
"""Start the stream-stop grace period.
If the stream does not restart within :data:`STREAM_GRACE_PERIOD`
seconds, any pending creation token is cleared and any active poll
is cancelled.
"""
self.cancel_stream_grace()
async def _grace() -> None:
self._ctx.logger.info(
"Stream stopped. Polls grace period: %ds.",
STREAM_GRACE_PERIOD,
)
await asyncio.sleep(STREAM_GRACE_PERIOD)
self.cancel_creation_token()
if self._active_poll is not None:
await self.cancel()
self._ctx.logger.info(
"Active poll cancelled after stream grace period."
)
self._grace_task = None
self._grace_task = asyncio.create_task(_grace())
def cancel_stream_grace(self) -> None:
"""Cancel any running stream-stop grace period."""
if self._grace_task is not None:
self._grace_task.cancel()
self._grace_task = None
async def teardown(self) -> None:
"""Clean up on module unload.
Cancels grace period, creation token, and any active poll.
"""
self.cancel_stream_grace()
self.cancel_creation_token()
self._cancel_result_expiry()
if self._active_poll is not None:
await self.cancel()
async def _broadcast_sse(
self, poll: ActivePoll, event_type: SSEEvent, data: dict[str, Any]
) -> None:
"""Broadcast an SSE event to connected clients.
For ``tally`` events on hidden polls, only the moderator client
receives the update. All other event types are sent to every client.
:param poll: The active poll.
:param event_type: The SSE event type.
:param data: The event data to serialize.
"""
clients = poll.sse_clients
payload = sse_payload(event_type, data)
# Determine which clients to send to.
if poll.hidden and event_type == SSEEvent.TALLY:
# Only privileged tokens receive vote updates for hidden polls.
targets = {
token_value: client
for token_value, client in clients.items()
if (token_obj := poll.tokens.get(token_value))
and token_obj.is_moderator
}
else:
targets = dict(clients)
if not targets:
return
# Send to all targets concurrently.
tokens = list(targets.keys())
responses = list(targets.values())
results = await asyncio.gather(
*(r.write(payload) for r in responses),
return_exceptions=True,
)
# Remove failed clients.
for token, result in zip(tokens, results, strict=True):
if isinstance(result, BaseException):
self._ctx.logger.debug("Removing disconnected SSE client: %s", token)
clients.pop(token, None)
async def _close_sse_clients(self, poll: ActivePoll) -> None:
"""Close all SSE client connections for a poll.
:param poll: The active poll whose clients should be closed.
"""
for client in poll.sse_clients.values():
with contextlib.suppress(Exception):
await client.write_eof()
poll.sse_clients.clear()
def get_manager(ctx: ModuleContext) -> PollManager:
"""Retrieve the PollManager from the module context's state.
:param ctx: The module context.
:return: The PollManager instance.
:raises RuntimeError: If PollManager has not been initialized.
"""
manager = ctx.state.get("manager")
if not isinstance(manager, PollManager):
raise RuntimeError("PollManager is not initialized.")
return manager
+477
View File
@@ -0,0 +1,477 @@
# Copyright 2026 Logan Fick
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Route handlers for the polls module."""
from __future__ import annotations
import asyncio
import contextlib
import secrets
from http import HTTPStatus
from pathlib import Path
from typing import Any
from aiohttp import web
from owlbot.api import RouteContext, on_route
from .manager import PollError, get_manager
from .types import (
DEFAULT_DURATION,
MAX_DURATION,
MAX_OPTION_LENGTH,
MAX_OPTIONS,
MAX_QUESTION_LENGTH,
MIN_DURATION,
SSEEvent,
sse_payload,
)
_STATIC_DIR = Path(__file__).resolve().parent / "static"
_POLLS_CSS_PATH = _STATIC_DIR / "polls.css"
_POLLS_JS_PATH = _STATIC_DIR / "polls.js"
_CREATE_JS_PATH = _STATIC_DIR / "create.js"
def _error_page(ctx: RouteContext, status: int, message: str) -> web.Response:
"""Render a simple error page.
:param ctx: The route context.
:param status: HTTP status code.
:param message: Error message to display.
:return: HTML error response.
"""
page = ctx.templates.render("error.html", message=message)
return web.Response(status=status, text=page, content_type="text/html")
def _default_form() -> dict[str, Any]:
"""Return default form values for the poll creation template."""
return {
"question": "",
"options": ["", ""],
"duration": DEFAULT_DURATION,
"hidden": False,
"requires_auth": False,
"allow_multiple": False,
"min_selections": "",
"max_selections": "",
}
def _create_form_page(
ctx: RouteContext,
token: str,
*,
form: dict[str, Any],
error: str | None = None,
status: int = 200,
) -> web.Response:
"""Render the poll creation form.
:param ctx: The route context.
:param token: The creation token.
:param form: Form field values to populate.
:param error: Optional error message to display.
:param status: HTTP status code.
:return: HTML response with the creation form.
"""
constraints = {
"min_duration": MIN_DURATION,
"max_duration": MAX_DURATION,
"max_options": MAX_OPTIONS,
"max_question_length": MAX_QUESTION_LENGTH,
"max_option_length": MAX_OPTION_LENGTH,
}
page = ctx.templates.render(
"create.html",
token=token,
form=form,
error=error,
constraints=constraints,
submit_url=ctx.routes.url_for(f"/create/{token}"),
create_js_url=ctx.routes.url_for("/static/create.js"),
)
return web.Response(status=status, text=page, content_type="text/html")
@on_route("/create/{token}", methods=["GET"])
async def create_page(ctx: RouteContext) -> web.Response:
"""Serve the poll creation form.
:param ctx: The route context.
:return: HTML response with the creation form.
"""
token = ctx.match_info["token"]
manager = get_manager(ctx.module)
if manager.active_poll is not None:
return _error_page(ctx, HTTPStatus.CONFLICT, "A poll is already active.")
if manager.creation_token_value is None or not secrets.compare_digest(
manager.creation_token_value, token
):
return _error_page(
ctx, HTTPStatus.FORBIDDEN, "Invalid or expired creation link."
)
return _create_form_page(ctx, token, form=_default_form())
@on_route("/create/{token}", methods=["POST"])
async def create_submit(ctx: RouteContext) -> web.Response:
"""Handle poll creation form submission.
Validates inputs, delegates to the manager to create the poll,
and redirects to the voting page.
:param ctx: The route context.
:return: Redirect to the voting page, or error response.
"""
token = ctx.match_info["token"]
data = await ctx.request.post()
# Parse form inputs.
question = str(data.get("question", "")).strip()
options_raw = str(data.get("options", "")).strip()
options = [o.strip() for o in options_raw.splitlines() if o.strip()]
try:
duration = int(str(data.get("duration", "0")))
except ValueError:
duration = 0
hidden = str(data.get("hidden", "")) == "on"
requires_auth = str(data.get("requires_auth", "")) == "on"
allow_multiple = str(data.get("allow_multiple", "")) == "on"
min_sel_raw = str(data.get("min_selections", "")).strip()
max_sel_raw = str(data.get("max_selections", "")).strip()
min_selections: int | None = None
max_selections: int | None = None
if allow_multiple:
if min_sel_raw:
try:
min_selections = int(min_sel_raw)
except ValueError:
min_selections = None
if max_sel_raw:
try:
max_selections = int(max_sel_raw)
except ValueError:
max_selections = None
form: dict[str, Any] = {
"question": question,
"options": options or ["", ""],
"duration": duration,
"hidden": hidden,
"requires_auth": requires_auth,
"allow_multiple": allow_multiple,
"min_selections": min_sel_raw,
"max_selections": max_sel_raw,
}
manager = get_manager(ctx.module)
try:
await manager.create_poll(
token=token,
question=question,
options=options,
hidden=hidden,
requires_auth=requires_auth,
allow_multiple=allow_multiple,
min_selections=min_selections,
max_selections=max_selections,
duration=duration,
)
except PollError as e:
return _create_form_page(
ctx, token, form=form, error=str(e), status=HTTPStatus.BAD_REQUEST
)
# Redirect to the mod's voting page.
vote_url = ctx.routes.url_for(f"/vote/{token}")
return web.HTTPFound(vote_url)
@on_route("/vote/{token}", methods=["GET"])
async def vote_page(ctx: RouteContext) -> web.Response:
"""Serve the voting page.
Validates the token and renders the voting page with appropriate
visibility and permission controls based on the token's privileges.
:param ctx: The route context.
:return: HTML response with the voting page.
"""
manager = get_manager(ctx.module)
poll = manager.active_poll
if poll is None:
return _error_page(ctx, HTTPStatus.NOT_FOUND, "No active poll.")
token = ctx.match_info["token"]
token_obj = poll.tokens.get(token)
if token_obj is None:
return _error_page(ctx, HTTPStatus.FORBIDDEN, "Invalid or expired voting link.")
has_voted = token_obj.user_id in poll.votes
current_votes = poll.votes.get(token_obj.user_id, set())
page = ctx.templates.render(
"vote.html",
poll=poll,
token=token_obj,
has_voted=has_voted,
current_votes=current_votes,
vote_url=ctx.routes.url_for(f"/vote/{token}"),
polls_css_url=ctx.routes.url_for("/static/polls.css"),
polls_js_url=ctx.routes.url_for("/static/polls.js"),
)
return web.Response(text=page, content_type="text/html")
@on_route("/vote/{token}", methods=["POST"])
async def vote_submit(ctx: RouteContext) -> web.Response:
"""Handle vote form submission.
Validates the token, parses selections, delegates to the manager,
and redirects back to the voting page.
:param ctx: The route context.
:return: Redirect back to the voting page, or error response.
"""
manager = get_manager(ctx.module)
poll = manager.active_poll
if poll is None:
return _error_page(ctx, HTTPStatus.NOT_FOUND, "No active poll.")
token = ctx.match_info["token"]
token_obj = poll.tokens.get(token)
if token_obj is None:
return _error_page(ctx, HTTPStatus.FORBIDDEN, "Invalid voting link.")
if poll.requires_auth and not token_obj.is_authenticated:
return _error_page(
ctx,
HTTPStatus.FORBIDDEN,
"This poll requires an authenticated account to vote.",
)
voter_id = token_obj.user_id
data = await ctx.request.post()
# Parse selections from form checkboxes/radio buttons.
raw_selections = data.getall("option", [])
try:
selections = {int(str(s)) for s in raw_selections}
except ValueError:
return _error_page(ctx, HTTPStatus.BAD_REQUEST, "Invalid selection.")
try:
await manager.record_vote(voter_id, selections)
except PollError as e:
return _error_page(ctx, HTTPStatus.BAD_REQUEST, str(e))
# Redirect back to the voting page.
vote_url = ctx.routes.url_for(f"/vote/{token}")
return web.HTTPFound(vote_url)
@on_route("/vote/{token}/events", streaming=True)
async def events_stream(ctx: RouteContext) -> web.StreamResponse | web.Response:
"""SSE endpoint for live poll updates.
Sends keepalive events every 15 seconds with time remaining.
:param ctx: The route context.
:return: SSE stream response.
"""
manager = get_manager(ctx.module)
poll = manager.active_poll
if poll is None:
return web.Response(status=HTTPStatus.NOT_FOUND, text="No active poll.")
token = ctx.match_info["token"]
if token not in poll.tokens:
return web.Response(status=HTTPStatus.FORBIDDEN, text="Invalid token.")
response = web.StreamResponse(
status=HTTPStatus.OK,
headers={
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
await response.prepare(ctx.request)
await manager.register_sse_client(token, response)
try:
while manager.active_poll is poll:
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(poll.done_event.wait(), timeout=15)
# If the poll ended, exit immediately.
if manager.active_poll is not poll:
break
try:
remaining = poll.time_remaining
payload = sse_payload(SSEEvent.KEEPALIVE, {"time_remaining": remaining})
await response.write(payload)
except Exception:
ctx.logger.debug("SSE keepalive failed for %s...", token[:8])
break
finally:
# Only remove ourselves if we're still the registered client.
# A page reload creates a new SSE connection under the same token,
# so the old handler must not evict the replacement.
if poll.sse_clients.get(token) is response:
poll.sse_clients.pop(token, None)
ctx.logger.debug(
"SSE client disconnected: %s... (%d remaining).",
token[:8],
len(poll.sse_clients),
)
return response
@on_route("/vote/{token}/end", methods=["POST"])
async def mod_end(ctx: RouteContext) -> web.Response:
"""End the poll via the moderator web UI.
:param ctx: The route context.
:return: Redirect to the results page.
"""
manager = get_manager(ctx.module)
poll = manager.active_poll
if poll is None:
return _error_page(ctx, HTTPStatus.NOT_FOUND, "No active poll.")
token = ctx.match_info["token"]
token_obj = poll.tokens.get(token)
if token_obj is None or not token_obj.is_moderator:
return _error_page(
ctx, HTTPStatus.FORBIDDEN, "Only moderators can end the poll."
)
await manager.end()
results_url = ctx.routes.url_for("/results")
return web.HTTPFound(results_url)
@on_route("/vote/{token}/cancel", methods=["POST"])
async def mod_cancel(ctx: RouteContext) -> web.Response:
"""Cancel the poll via the moderator web UI.
:param ctx: The route context.
:return: Redirect response or error page.
"""
manager = get_manager(ctx.module)
poll = manager.active_poll
if poll is None:
return _error_page(ctx, HTTPStatus.NOT_FOUND, "No active poll.")
token = ctx.match_info["token"]
token_obj = poll.tokens.get(token)
if token_obj is None or not token_obj.is_moderator:
return _error_page(
ctx, HTTPStatus.FORBIDDEN, "Only moderators can cancel the poll."
)
await manager.cancel()
return _error_page(ctx, HTTPStatus.OK, "This poll has been cancelled.")
@on_route("/results", methods=["GET"])
async def results_page(ctx: RouteContext) -> web.Response:
"""Serve the results page for the most recently completed poll.
Shows contextual error messages if a poll is active or no
results are available.
:param ctx: The route context.
:return: HTML response with the results page.
"""
manager = get_manager(ctx.module)
if manager.active_poll is not None:
return _error_page(ctx, HTTPStatus.OK, "A poll is currently in progress.")
result = manager.last_result
if result is None:
return _error_page(ctx, HTTPStatus.OK, "No poll has been done recently.")
polls_css_url = ctx.routes.url_for("/static/polls.css")
page = ctx.templates.render(
"results.html",
question=result.question,
options=result.options,
results=result.results,
created_at=result.created_at.isoformat(),
ended_at=result.ended_at.isoformat(),
polls_css_url=polls_css_url,
)
return web.Response(text=page, content_type="text/html")
@on_route("/static/polls.css", methods=["GET"])
async def static_polls_css(ctx: RouteContext) -> web.StreamResponse:
"""Serve the polls stylesheet.
:param ctx: The route context.
:return: The polls.css file with caching headers.
"""
return web.FileResponse(
_POLLS_CSS_PATH,
headers={"Cache-Control": "max-age=86400"},
)
@on_route("/static/polls.js", methods=["GET"])
async def static_polls_js(ctx: RouteContext) -> web.StreamResponse:
"""Serve the polls JavaScript file.
:param ctx: The route context.
:return: The polls.js file with caching headers.
"""
return web.FileResponse(
_POLLS_JS_PATH,
headers={"Cache-Control": "max-age=86400"},
)
@on_route("/static/create.js", methods=["GET"])
async def static_create_js(ctx: RouteContext) -> web.StreamResponse:
"""Serve the create form JavaScript file.
:param ctx: The route context.
:return: The create.js file with caching headers.
"""
return web.FileResponse(
_CREATE_JS_PATH,
headers={"Cache-Control": "max-age=86400"},
)
@@ -0,0 +1,106 @@
// Copyright 2026 Logan Fick
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
(() => {
"use strict";
const config = window.CREATE_CONFIG;
if (!config) return;
const container = document.getElementById('options-container');
const addBtn = document.getElementById('add-option');
const multiCheck = document.getElementById('allow_multiple');
const multiOpts = document.getElementById('multi-select-options');
const form = document.getElementById('poll-form');
const maxOptions = config.maxOptions;
const maxOptionLength = config.maxOptionLength;
const minSel = document.getElementById('min_selections');
const maxSel = document.getElementById('max_selections');
const syncSelectionLimits = () => {
const minVal = parseInt(minSel.value, 10);
const maxVal = parseInt(maxSel.value, 10);
maxSel.min = minVal >= 1 ? minVal : 1;
minSel.max = maxVal >= 1 ? maxVal : minSel.max;
};
const updateSelectionDefaults = () => {
const count = container.querySelectorAll('.option-row').length;
minSel.max = count;
maxSel.max = count;
maxSel.placeholder = count;
if (minSel.value && parseInt(minSel.value, 10) > count) {
minSel.value = count;
}
if (maxSel.value && parseInt(maxSel.value, 10) > count) {
maxSel.value = count;
}
syncSelectionLimits();
};
minSel.addEventListener('input', syncSelectionLimits);
maxSel.addEventListener('input', syncSelectionLimits);
const renumber = () => {
const rows = container.querySelectorAll('.option-row');
for (const [i, row] of [...rows].entries()) {
const num = i + 1;
row.querySelector('.option-number').textContent = num;
const input = row.querySelector('.option-input');
input.name = `option_${num}`;
input.placeholder = `Option ${num}`;
}
addBtn.disabled = rows.length >= maxOptions;
updateSelectionDefaults();
};
addBtn.addEventListener('click', () => {
const rows = container.querySelectorAll('.option-row');
if (rows.length >= maxOptions) return;
const num = rows.length + 1;
const div = document.createElement('div');
div.className = 'input-group mb-2 option-row';
div.innerHTML =
`<span class="input-group-text option-number">${num}</span>` +
`<input type="text" class="form-control option-input" name="option_${num}" required maxlength="${maxOptionLength}" placeholder="Option ${num}">` +
'<button type="button" class="btn btn-outline-danger remove-option">Remove</button>';
container.appendChild(div);
renumber();
});
container.addEventListener('click', (e) => {
if (e.target.classList.contains('remove-option')) {
e.target.closest('.option-row').remove();
renumber();
}
});
multiCheck.addEventListener('change', () => {
if (multiCheck.checked) {
updateSelectionDefaults();
multiOpts.classList.remove('d-none');
} else {
multiOpts.classList.add('d-none');
minSel.value = '';
maxSel.value = '';
}
});
form.addEventListener('submit', () => {
const lines = [...container.querySelectorAll('.option-input')]
.map(input => input.value.trim())
.filter(Boolean);
document.getElementById('options-hidden').value = lines.join('\n');
});
})();
@@ -0,0 +1,58 @@
/*
* Copyright 2026 Logan Fick
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
.option-bar {
position: relative;
background: var(--bs-tertiary-bg);
border-radius: 8px;
overflow: hidden;
min-height: 48px;
display: flex;
align-items: center;
padding: 0.5rem 1rem;
}
.option-bar .bar-fill {
position: absolute;
top: 0;
left: 0;
bottom: 0;
background: rgba(var(--bs-primary-rgb), 0.25);
border-radius: 8px;
transition: width 0.3s ease;
}
.option-bar .bar-fill.winner {
background: rgba(var(--bs-success-rgb), 0.3);
}
.option-bar label,
.option-bar .option-label {
position: relative;
z-index: 1;
flex-grow: 1;
}
.option-bar label {
margin: 0;
cursor: pointer;
}
.option-bar .vote-count {
position: relative;
z-index: 1;
white-space: nowrap;
}
@@ -0,0 +1,185 @@
// Copyright 2026 Logan Fick
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
(() => {
"use strict";
const config = window.POLL_CONFIG;
const tokenInfo = window.TOKEN_INFO;
if (!config || !tokenInfo) return;
const timerEl = document.getElementById("timer");
let remaining = config.timeRemaining;
// --- Countdown timer ---
const formatTime = (seconds) => {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${String(s).padStart(2, "0")}`;
};
const updateTimer = () => {
if (remaining <= 0) {
timerEl.textContent = "0:00";
return;
}
timerEl.textContent = formatTime(remaining);
remaining--;
};
updateTimer();
const timerInterval = setInterval(() => {
updateTimer();
if (remaining < 0) {
clearInterval(timerInterval);
}
}, 1000);
// --- Update vote bars ---
const updateBars = (counts) => {
const total = counts.reduce((a, b) => a + b, 0);
for (const bar of document.querySelectorAll(".option-bar")) {
const idx = Number(bar.dataset.index);
const fill = bar.querySelector(".bar-fill");
const countSpan = bar.querySelector(".vote-count");
const pct = total > 0 ? Math.round((counts[idx] / total) * 100) : 0;
if (fill && counts[idx] !== undefined) {
fill.style.width = `${pct}%`;
}
if (countSpan) {
const countEl = countSpan.querySelector(".count");
const pctEl = countSpan.querySelector(".pct");
if (countEl) countEl.textContent = counts[idx];
if (pctEl) pctEl.textContent = pct;
countSpan.classList.remove("d-none");
}
}
};
// Show vote counts immediately for non-hidden polls so the UI
// doesn't look identical to a hidden poll before SSE delivers data.
if (!config.hidden || tokenInfo.isModerator) {
for (const el of document.querySelectorAll(".vote-count")) {
el.classList.remove("d-none");
}
}
// --- SSE connection ---
const evtSource = new EventSource(tokenInfo.eventsUrl);
const parse = (e) => {
try {
return JSON.parse(e.data);
} catch {
return null;
}
};
evtSource.addEventListener("error", () => {
if (evtSource.readyState === EventSource.CLOSED) {
clearInterval(timerInterval);
const status = document.getElementById("poll-status");
if (status) {
status.innerHTML =
'<div class="alert alert-danger mb-0" role="alert">' +
"Connection lost. Results are not live. Refresh to reconnect.</div>";
}
}
});
evtSource.addEventListener("keepalive", (e) => {
const data = parse(e);
if (data && typeof data.time_remaining === "number") {
remaining = data.time_remaining;
}
});
evtSource.addEventListener("tally", (e) => {
const data = parse(e);
if (data) {
updateBars(data.counts);
}
});
evtSource.addEventListener("poll_ended", (e) => {
const data = parse(e);
clearInterval(timerInterval);
evtSource.close();
if (data?.results_url) {
window.location.href = data.results_url;
}
});
evtSource.addEventListener("poll_cancelled", () => {
clearInterval(timerInterval);
evtSource.close();
const status = document.getElementById("poll-status");
if (status) {
status.innerHTML =
'<div class="alert alert-danger mb-0" role="alert">' +
"This poll has been cancelled.</div>";
}
const form = document.getElementById("vote-form");
if (form) {
for (const input of form.querySelectorAll(
"input, button[type='submit']"
)) {
input.disabled = true;
}
}
});
// --- Multi-select validation ---
const voteForm = document.getElementById("vote-form");
if (voteForm && config.allowMultiple) {
voteForm.addEventListener("submit", (e) => {
const checked = voteForm.querySelectorAll(
'input[name="option"]:checked'
);
const count = checked.length;
if (count === 0) {
e.preventDefault();
alert("Please select at least one option.");
return;
}
if (
config.minSelections !== null &&
count < config.minSelections
) {
e.preventDefault();
alert(
`Please select at least ${config.minSelections} option(s).`
);
return;
}
if (
config.maxSelections !== null &&
count > config.maxSelections
) {
e.preventDefault();
alert(
`Please select at most ${config.maxSelections} option(s).`
);
return;
}
});
}
})();
@@ -0,0 +1,71 @@
{% extends "base.html" %}
{% block title %}Create Poll{% endblock %}
{% block content %}
<h1 class="mb-4">Create Poll</h1>
{% if error %}
<div class="alert alert-danger" role="alert">{{ error }}</div>
{% endif %}
<form method="POST" action="{{ submit_url }}" id="poll-form">
<div class="mb-3">
<label for="question" class="form-label">Question</label>
<input type="text" class="form-control" id="question" name="question" required maxlength="{{ constraints.max_question_length }}" placeholder="What do you want to ask chat?" value="{{ form.question }}">
</div>
<div class="mb-3">
<label class="form-label">Options</label>
<div id="options-container">
{% for opt in form.options %}
<div class="input-group mb-2 option-row">
<span class="input-group-text option-number">{{ loop.index }}</span>
<input type="text" class="form-control option-input" name="option_{{ loop.index }}" required maxlength="{{ constraints.max_option_length }}" placeholder="Option {{ loop.index }}" value="{{ opt }}">
{% if loop.index > 2 %}
<button type="button" class="btn btn-outline-danger remove-option">Remove</button>
{% endif %}
</div>
{% endfor %}
</div>
<button type="button" class="btn btn-sm btn-outline-primary" id="add-option">Add Option</button>
</div>
<div class="mb-3">
<label for="duration" class="form-label">Duration (seconds)</label>
<input type="number" class="form-control" id="duration" name="duration" value="{{ form.duration }}" min="{{ constraints.min_duration }}" max="{{ constraints.max_duration }}">
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="hidden" name="hidden" {% if form.hidden %}checked{% endif %}>
<label class="form-check-label" for="hidden">Hidden until end</label>
<div class="form-text">Vote counts stay hidden from voters until the poll ends.</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="requires_auth" name="requires_auth" {% if form.requires_auth %}checked{% endif %}>
<label class="form-check-label" for="requires_auth">Require authentication</label>
<div class="form-text">Only viewers with an authenticated Owncast account can vote.</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="allow_multiple" name="allow_multiple" {% if form.allow_multiple %}checked{% endif %}>
<label class="form-check-label" for="allow_multiple">Allow multiple selections</label>
<div class="form-text">Voters can select more than one option.</div>
</div>
<div class="mb-3 ms-4 {{ '' if form.allow_multiple else 'd-none' }} d-flex align-items-center gap-2 flex-wrap" id="multi-select-options">
<span>Voters must select between</span>
<input type="number" class="form-control form-control-sm" style="width: 5rem" id="min_selections" name="min_selections" min="1" placeholder="1" value="{{ form.min_selections }}">
<span>and</span>
<input type="number" class="form-control form-control-sm" style="width: 5rem" id="max_selections" name="max_selections" min="1" value="{{ form.max_selections }}">
<span>options.</span>
</div>
<input type="hidden" name="options" id="options-hidden">
<button type="submit" class="btn btn-primary">Create Poll</button>
</form>
{% endblock %}
{% block scripts %}
<script>window.CREATE_CONFIG = {
maxOptions: {{ constraints.max_options | tojson }},
maxOptionLength: {{ constraints.max_option_length | tojson }}
};</script>
<script src="{{ create_js_url }}?v={{ owlbot_version }}"></script>
{% endblock %}
@@ -0,0 +1,6 @@
{% extends "base.html" %}
{% block title %}Error{% endblock %}
{% block content %}
<h1 class="mb-3">Error</h1>
<div class="alert alert-danger" role="alert">{{ message }}</div>
{% endblock %}
@@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block title %}Results: {{ question }}{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ polls_css_url }}?v={{ owlbot_version }}">
{% endblock %}
{% block content %}
<h1 class="mb-3">{{ question }}</h1>
{% set total = results.counts | sum %}
<div class="d-flex flex-column gap-2 mb-4">
{% for option in options %}
{% set count = results.counts[loop.index0] %}
{% set pct = (count / total * 100) | round(0) | int if total > 0 else 0 %}
{% set is_winner = option in results.winners and results.total_votes > 0 %}
<div class="option-bar">
<div class="bar-fill {{ 'winner' if is_winner else 'other' }}" style="width: {{ pct }}%"></div>
<span class="option-label">
{{ option }}
{% if is_winner %}
<span class="badge bg-success ms-1">{{ "Tie" if results.is_tie else "Winner" }}</span>
{% endif %}
</span>
<span class="vote-count">
{{ count }} ({{ pct }}%)
</span>
</div>
{% endfor %}
</div>
<p class="text-body-secondary">Total votes: {{ results.total_votes }}</p>
{% endblock %}
@@ -0,0 +1,83 @@
{% extends "base.html" %}
{% block title %}Vote: {{ poll.question }}{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ polls_css_url }}?v={{ owlbot_version }}">
{% endblock %}
{% block content %}
<h1 class="mb-3">{{ poll.question }}</h1>
<div id="poll-status" class="mb-4">
<p class="text-body-secondary mb-1">Time remaining: <strong id="timer">--:--</strong></p>
{% if poll.hidden %}
{% if token.is_moderator %}
<p class="text-body-secondary mb-0">Results are hidden until this poll ends. As a moderator, you can still see the real-time results.</p>
{% else %}
<p class="text-body-secondary mb-0">Results are hidden until this poll ends.</p>
{% endif %}
{% else %}
<p class="text-body-secondary mb-0">Results are updated in real time.</p>
{% endif %}
</div>
<form method="POST" action="{{ vote_url }}" id="vote-form">
<div class="d-flex flex-column gap-2 mb-4">
{% for option in poll.options %}
<div class="option-bar" data-index="{{ loop.index0 }}">
<div class="bar-fill" style="width: 0%"></div>
<label>
{% if poll.allow_multiple %}
<input type="checkbox" name="option" value="{{ loop.index0 }}" class="form-check-input me-2"
{% if loop.index0 in current_votes %}checked{% endif %}
{% if has_voted or (not token.is_authenticated and poll.requires_auth) %}disabled{% endif %}>
{% else %}
<input type="radio" name="option" value="{{ loop.index0 }}" class="form-check-input me-2" required
{% if loop.index0 in current_votes %}checked{% endif %}
{% if has_voted or (not token.is_authenticated and poll.requires_auth) %}disabled{% endif %}>
{% endif %}
{{ option }}
</label>
<span class="vote-count d-none" data-index="{{ loop.index0 }}">
<span class="count">-</span> (<span class="pct">-</span>%)
</span>
</div>
{% endfor %}
</div>
{% if poll.requires_auth and not token.is_authenticated %}
<div class="alert alert-warning mb-0" role="alert">This poll requires an authenticated account to vote. You are viewing results only.</div>
{% elif not has_voted %}
<button type="submit" class="btn btn-primary me-2">Vote</button>
{% else %}
<p class="text-body-secondary mb-0">You have already voted in this poll.</p>
{% endif %}
</form>
{% if token.is_moderator %}
<hr class="my-4">
<h5>Manage Poll</h5>
<p class="text-body-secondary">As a moderator, you can perform the following actions on this poll:</p>
<div class="d-flex gap-2">
<form method="POST" action="{{ vote_url }}/end">
<button type="submit" class="btn btn-success">End Poll</button>
</form>
<form method="POST" action="{{ vote_url }}/cancel">
<button type="submit" class="btn btn-danger">Cancel Poll</button>
</form>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
window.POLL_CONFIG = {
hidden: {{ poll.hidden | tojson }},
timeRemaining: {{ poll.time_remaining | tojson }},
allowMultiple: {{ poll.allow_multiple | tojson }},
minSelections: {{ poll.min_selections | tojson }},
maxSelections: {{ poll.max_selections | tojson }}
};
window.TOKEN_INFO = {
isModerator: {{ token.is_moderator | tojson }},
eventsUrl: {{ (vote_url + "/events") | tojson }}
};
</script>
<script src="{{ polls_js_url }}?v={{ owlbot_version }}"></script>
{% endblock %}
+184
View File
@@ -0,0 +1,184 @@
# Copyright 2026 Logan Fick
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Typed state containers, constants, and helper functions for the polls module."""
from __future__ import annotations
import asyncio
import secrets
import time
from dataclasses import dataclass, field
from enum import StrEnum
from typing import TYPE_CHECKING, Any, TypedDict
import orjson
if TYPE_CHECKING:
from datetime import datetime
from aiohttp import web
MAX_QUESTION_LENGTH = 200
MAX_OPTION_LENGTH = 50
MIN_OPTIONS = 2
MAX_OPTIONS = 10
DEFAULT_DURATION = 240
MIN_DURATION = 30
MAX_DURATION = 600
CREATION_TOKEN_TIMEOUT = 15 * 60
STREAM_GRACE_PERIOD = 5 * 60
RESULT_EXPIRY = 3600
class SSEEvent(StrEnum):
"""Server-Sent Event types for the polls module."""
KEEPALIVE = "keepalive"
POLL_CANCELLED = "poll_cancelled"
POLL_ENDED = "poll_ended"
TALLY = "tally"
def sse_payload(event_type: SSEEvent, data: dict[str, Any]) -> bytes:
"""Serialize an SSE event into wire format.
:param event_type: The event type name.
:param data: The JSON-serializable event data.
:return: Encoded SSE frame ready for ``StreamResponse.write()``.
"""
return b"event: " + event_type.encode() + b"\ndata: " + orjson.dumps(data) + b"\n\n"
@dataclass(slots=True)
class Token:
"""A token for poll access.
:param user_id: The Owncast user ID this token belongs to.
:param is_moderator: Whether the user has moderator privileges.
:param is_authenticated: Whether the user has an authenticated Owncast account.
:param value: The token string used in URLs (auto-generated if not provided).
"""
user_id: str
is_moderator: bool
is_authenticated: bool
value: str = field(default_factory=lambda: secrets.token_urlsafe(32))
@dataclass(slots=True)
class ActivePoll:
"""An active poll instance.
:param question: The poll question.
:param options: List of poll options.
:param hidden: Whether vote results are hidden until the poll ends.
:param requires_auth: Whether voters must be authenticated.
:param allow_multiple: Whether voters can select multiple options.
:param min_selections: Minimum number of selections required (multi-select).
:param max_selections: Maximum number of selections allowed (multi-select).
:param duration: Poll duration in seconds.
:param created_at: Timestamp when the poll was created.
:param votes: Mapping of user ID to set of selected option indices.
:param sse_clients: Mapping of client token to SSE stream response.
:param timer_task: Asyncio task for the poll timer.
:param tokens: Mapping of token string to Token object.
"""
question: str
options: list[str]
hidden: bool
requires_auth: bool
allow_multiple: bool
min_selections: int | None
max_selections: int | None
duration: int
created_at: datetime
votes: dict[str, set[int]] = field(default_factory=dict)
sse_clients: dict[str, web.StreamResponse] = field(default_factory=dict)
timer_task: asyncio.Task[None] | None = None
tokens: dict[str, Token] = field(default_factory=dict)
done_event: asyncio.Event = field(default_factory=asyncio.Event)
_started_at_mono: float = field(default_factory=time.monotonic)
@property
def time_remaining(self) -> int:
"""Seconds remaining until the poll ends, minimum 0."""
elapsed = time.monotonic() - self._started_at_mono
return max(0, int(self.duration - elapsed))
class PollResults(TypedDict):
"""Computed results of a poll."""
counts: list[int]
winners: list[str]
is_tie: bool
total_votes: int
def compute_results(options: list[str], votes: dict[str, set[int]]) -> PollResults:
"""Compute poll results from votes.
Counts votes per option, finds winners, and detects ties.
:param options: List of poll option strings.
:param votes: Mapping of user ID to set of selected option indices.
:return: Dict with counts, winners, is_tie, and total_votes.
"""
num_options = len(options)
counts = [0] * num_options
for selections in votes.values():
for idx in selections:
counts[idx] += 1
total_votes = len(votes)
max_count = max(counts) if counts else 0
if total_votes == 0 or max_count == 0:
winners = []
is_tie = False
else:
winners = [options[i] for i, c in enumerate(counts) if c == max_count]
is_tie = len(winners) > 1
return {
"counts": counts,
"winners": winners,
"is_tie": is_tie,
"total_votes": total_votes,
}
@dataclass(slots=True)
class CompletedPoll:
"""Snapshot of a completed poll's results.
Stored in memory on PollManager as the most recent result.
Ephemeral — not persisted to disk.
:param question: The poll question.
:param options: Poll options sorted by vote count descending.
:param results: Computed results (counts, winners, tie status).
:param created_at: When the poll was created.
:param ended_at: When the poll ended.
"""
question: str
options: list[str]
results: PollResults
created_at: datetime
ended_at: datetime