Refactored polls module into layered architecture with typed domain objects, isolated SSE handling, and comprehensive tests.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 4s
CI / Tests (Python 3.12) (push) Successful in 2m54s
CI / Tests (Python 3.13) (push) Successful in 2m54s
CI / Tests (Python 3.14) (push) Successful in 2m52s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 5s

This commit is contained in:
2026-05-11 11:58:47 -04:00
parent 4918547c7d
commit 06b4a0bb9c
19 changed files with 4172 additions and 1002 deletions
+20 -15
View File
@@ -43,17 +43,16 @@ from .commands import (
vote_command,
)
from .manager import PollManager, get_manager
from .repository import PollRepository
from .routes import (
create_page,
create_submit,
current_page,
events_stream,
mod_cancel,
mod_end,
results_page,
static_create_js,
static_polls_css,
static_polls_js,
vote_page,
static_asset,
vote_submit,
)
@@ -61,6 +60,7 @@ __all__ = [
"cancel_poll_command",
"create_page",
"create_submit",
"current_page",
"end_poll_command",
"events_stream",
"handle_bare_vote",
@@ -72,12 +72,9 @@ __all__ = [
"poll_command",
"results_page",
"setup",
"static_create_js",
"static_polls_css",
"static_polls_js",
"static_asset",
"teardown",
"vote_command",
"vote_page",
"vote_submit",
]
@@ -88,13 +85,17 @@ async def setup(ctx: ModuleContext) -> None:
:param ctx: Module context with config, storage, and other services.
"""
ctx.state["manager"] = PollManager(ctx)
repo = PollRepository()
ctx.state["manager"] = PollManager(ctx, repo)
@on_event(EventType.STREAM_STOPPED)
async def on_stream_stopped(ctx: EventContext[StreamStoppedEvent]) -> None:
"""Begin a grace period when the stream goes offline.
If the stream does not restart before the grace period expires, any active
poll is cancelled.
:param ctx: The event context.
"""
get_manager(ctx.module).begin_stream_grace()
@@ -113,8 +114,8 @@ async def on_stream_started(ctx: EventContext[StreamStartedEvent]) -> None:
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.
Sends a private message with the poll question, options, and a protected
current-poll link.
:param ctx: The event context.
"""
@@ -128,11 +129,14 @@ async def on_user_joined(ctx: EventContext[UserJoinedEvent]) -> None:
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."
selections = manager.get_selections(ctx.event.user)
vote_url = ctx.session_url_for("/current")
if selections is not None:
instructions = (
f'<a href="{vote_url}"><u>Click here to view live results</u></a>.'
)
else:
instructions = "Type a number in chat or use <strong>!vote</strong> to vote."
instructions = f'<a href="{vote_url}"><u>Click here to vote</u></a>.'
await ctx.owncast_client.send_system_message_to_client(
ctx.event.client_id,
@@ -151,3 +155,4 @@ async def teardown(ctx: ModuleContext) -> None:
"""
manager = get_manager(ctx)
await manager.teardown()
ctx.state["manager"] = None
+78 -61
View File
@@ -27,7 +27,41 @@ from owlbot.api import (
on_event,
)
from .manager import PollError, get_manager
from .manager import get_manager
from .types import (
DuplicateVoteError,
NoActivePollError,
PollAuthenticationRequiredError,
PollVoteError,
)
def _vote_error_response(
error: NoActivePollError | PollVoteError,
*,
vote_url: str,
) -> tuple[str, bool]:
"""Return user-facing text and unsanitized flag for a vote failure.
:param error: The vote failure raised by the manager.
:param vote_url: Protected URL for the current poll page.
:return: Message text and whether it contains trusted HTML.
"""
if isinstance(error, NoActivePollError):
return "There is no active poll.", False
if isinstance(error, PollAuthenticationRequiredError):
return (
"This poll requires an authenticated account to vote. "
f'<a href="{vote_url}"><u>Click here to view live results</u></a>.',
True,
)
if isinstance(error, DuplicateVoteError):
return (
"You have already voted in this poll. "
f'<a href="{vote_url}"><u>Click here to view live results</u></a>.',
True,
)
return str(error), False
@on_command("poll", requires_moderator=True, cooldown=3)
@@ -64,9 +98,10 @@ async def end_poll_command(ctx: CommandContext) -> None:
try:
await manager.end()
except PollError as e:
except NoActivePollError:
await ctx.owncast_client.send_system_message_to_client(
ctx.chat_event.client_id, str(e)
ctx.chat_event.client_id,
"There is no active poll.",
)
@@ -80,9 +115,10 @@ async def cancel_poll_command(ctx: CommandContext) -> None:
try:
await manager.cancel()
except PollError as e:
except NoActivePollError:
await ctx.owncast_client.send_system_message_to_client(
ctx.chat_event.client_id, str(e)
ctx.chat_event.client_id,
"There is no active poll.",
)
@@ -110,30 +146,7 @@ async def vote_command(ctx: CommandContext) -> None:
)
return
user_id = ctx.user.id
vote_url = ctx.session_url_for("/vote")
# 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
vote_url = ctx.session_url_for("/current")
args = ctx.args_list
if args and not poll.allow_multiple:
@@ -157,10 +170,22 @@ async def vote_command(ctx: CommandContext) -> None:
# Convert 1-based to 0-based index.
selection = choice - 1
await manager.record_vote(
ctx.user,
{selection},
)
try:
await manager.record_vote(
ctx.user,
{selection},
)
except (
NoActivePollError,
PollVoteError,
) as e:
message, unsanitized = _vote_error_response(e, vote_url=vote_url)
await ctx.owncast_client.send_system_message_to_client(
ctx.chat_event.client_id,
message,
unsanitized=unsanitized,
)
return
await ctx.owncast_client.send_system_message_to_client(
ctx.chat_event.client_id,
f"Your vote for #{choice} ({escape(poll.options[selection])})"
@@ -189,11 +214,12 @@ async def vote_command(ctx: CommandContext) -> None:
@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.
"""Allow voting by typing an in-range bare option number 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.
to keep votes private and prevent chat spam. Non-numeric and out-of-range
messages are ignored.
:param ctx: The event context.
"""
@@ -219,28 +245,7 @@ async def handle_bare_vote(ctx: EventContext[ChatEvent]) -> None:
await ctx.owncast_client.set_message_visibility([event.message_id], visible=False)
ctx.stop_propagation("bare number vote in active poll")
vote_url = ctx.session_url_for("/vote")
# 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
vote_url = ctx.session_url_for("/current")
# Multi-select polls can't use bare number voting; send a link instead.
if poll.allow_multiple:
@@ -257,10 +262,22 @@ async def handle_bare_vote(ctx: EventContext[ChatEvent]) -> None:
# Record the vote.
selection = choice - 1
await manager.record_vote(
event.user,
{selection},
)
try:
await manager.record_vote(
event.user,
{selection},
)
except (
NoActivePollError,
PollVoteError,
) as e:
message, unsanitized = _vote_error_response(e, vote_url=vote_url)
await ctx.owncast_client.send_system_message_to_client(
event.client_id,
message,
unsanitized=unsanitized,
)
return
await ctx.owncast_client.send_system_message_to_client(
event.client_id,
f"Your vote for #{choice} ({escape(poll.options[selection])})"
File diff suppressed because it is too large Load Diff
+126
View File
@@ -0,0 +1,126 @@
# 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.
"""Synchronous in-memory repository for active poll data and recent results."""
from __future__ import annotations
from .types import (
ActivePoll,
CompletedPoll,
DuplicateVoteError,
NoActivePollError,
PollAlreadyActiveError,
)
class PollRepository:
"""Store active poll state and the most recent completed result in memory."""
def __init__(self) -> None:
"""Initialize an empty repository."""
self._active_poll: ActivePoll | None = None
self._last_result: CompletedPoll | None = None
def get_active_poll(self) -> ActivePoll | None:
"""Return the active poll, if one exists.
:return: The active poll, or None if no poll is active.
"""
return self._active_poll
def create_active_poll(self, poll: ActivePoll) -> ActivePoll:
"""Store a new active poll.
:param poll: The poll to activate.
:return: The stored poll.
:raises PollAlreadyActiveError: If a poll is already active.
"""
if self._active_poll is not None:
raise PollAlreadyActiveError
self._active_poll = poll
return poll
def pop_active_poll(self) -> ActivePoll | None:
"""Clear and return the active poll, if one exists.
:return: The previously active poll, or None if no poll was active.
"""
poll = self._active_poll
if poll is None:
return None
self._active_poll = None
return poll
def get_vote(self, user_id: str) -> set[int] | None:
"""Return a copy of a user's selected options for the active poll.
:param user_id: ID of the user whose vote should be read.
:return: Selected option indices, or None if the user has not voted.
:raises NoActivePollError: If no poll is active.
"""
poll = self._require_active_poll()
selections = poll.votes.get(user_id)
return None if selections is None else set(selections)
def set_vote(self, user_id: str, selections: set[int]) -> None:
"""Store a user's selected options for the active poll.
:param user_id: ID of the user casting the vote.
:param selections: Selected option indices.
:raises NoActivePollError: If no poll is active.
:raises DuplicateVoteError: If the user already voted.
"""
poll = self._require_active_poll()
if user_id in poll.votes:
raise DuplicateVoteError(user_id)
poll.votes[user_id] = set(selections)
def list_votes(self) -> dict[str, set[int]]:
"""Return a copy of all user selections for the active poll.
:return: Mapping of user IDs to selected option indices.
:raises NoActivePollError: If no poll is active.
"""
poll = self._require_active_poll()
return {user_id: set(selections) for user_id, selections in poll.votes.items()}
def get_last_result(self) -> CompletedPoll | None:
"""Return the most recent completed result, if one exists.
:return: Most recent completed poll, or None if no result is available.
"""
return self._last_result
def set_last_result(self, result: CompletedPoll) -> None:
"""Store the most recent completed result.
:param result: Completed poll snapshot to expose on the results page.
"""
self._last_result = result
def clear_last_result(self) -> None:
"""Clear the most recent completed result."""
self._last_result = None
def _require_active_poll(self) -> ActivePoll:
"""Return the active poll or raise the domain no-active error.
:return: The currently active poll.
:raises NoActivePollError: If no poll is active.
"""
poll = self._active_poll
if poll is None:
raise NoActivePollError
return poll
+176 -165
View File
@@ -16,8 +16,6 @@
from __future__ import annotations
import asyncio
import contextlib
from http import HTTPStatus
from pathlib import Path
from typing import TYPE_CHECKING, Any
@@ -26,7 +24,7 @@ from aiohttp import web
from owlbot.api import RouteContext, on_route
from .manager import PollError, get_manager
from .manager import get_manager
from .types import (
DEFAULT_DURATION,
MAX_DURATION,
@@ -34,17 +32,31 @@ from .types import (
MAX_OPTIONS,
MAX_QUESTION_LENGTH,
MIN_DURATION,
SSEEvent,
sse_payload,
MIN_OPTIONS,
NoActivePollError,
PollAlreadyActiveError,
PollAuthenticationRequiredError,
PollCreateValidationError,
PollVoteError,
)
if TYPE_CHECKING:
from owlbot.sessions import BrowserSession
from .manager import PollManager
_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"
_STATIC_FILES = {
"polls.css": _POLLS_CSS_PATH,
"polls.js": _POLLS_JS_PATH,
"create.js": _CREATE_JS_PATH,
"shared/config.js": _STATIC_DIR / "shared" / "config.js",
"shared/dom.js": _STATIC_DIR / "shared" / "dom.js",
"shared/poll-events.js": _STATIC_DIR / "shared" / "poll-events.js",
}
def _error_page(ctx: RouteContext, status: int, message: str) -> web.Response:
@@ -60,27 +72,18 @@ def _error_page(ctx: RouteContext, status: int, message: str) -> web.Response:
def _require_session(ctx: RouteContext) -> BrowserSession:
"""Return the browser session for a protected polls route."""
"""Return the browser session for a protected polls route.
:param ctx: The route context.
:return: The resolved browser session.
:raises RuntimeError: If a protected route was called without a session.
"""
session = ctx.session
if session is None:
raise RuntimeError("Protected polls route called without a browser session.")
return session
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,
*,
@@ -99,6 +102,7 @@ def _create_form_page(
constraints = {
"min_duration": MIN_DURATION,
"max_duration": MAX_DURATION,
"min_options": MIN_OPTIONS,
"max_options": MAX_OPTIONS,
"max_question_length": MAX_QUESTION_LENGTH,
"max_option_length": MAX_OPTION_LENGTH,
@@ -114,38 +118,104 @@ def _create_form_page(
return web.Response(status=status, text=page, content_type="text/html")
def _current_poll_page(
ctx: RouteContext,
manager: PollManager,
session: BrowserSession,
*,
error: str | None = None,
status: int = HTTPStatus.OK,
show_current_selections: bool = True,
) -> web.Response:
"""Render the current poll page from the latest active-poll state.
This helper keeps route handlers from holding an ``ActivePoll`` reference
across awaits and then mixing it with fresh manager state. If the poll has
ended before rendering, the caller gets the normal no-active-poll response.
:param ctx: The route context.
:param manager: Poll manager used to read the latest active poll state.
:param session: Browser session for the viewer rendering the page.
:param error: Optional inline voting error to display.
:param status: HTTP status code for the rendered vote page.
:param show_current_selections: Whether to load and show the viewer's
existing selections.
:return: HTML response for the active poll, or a no-active-poll error page.
"""
poll = manager.active_poll
if poll is None:
return _error_page(ctx, HTTPStatus.NOT_FOUND, "No active poll.")
current_selections: frozenset[int] | None = None
if show_current_selections:
try:
current_selections = manager.get_selections(session.user)
except NoActivePollError:
return _error_page(ctx, HTTPStatus.NOT_FOUND, "No active poll.")
has_voted = current_selections is not None
page = ctx.templates.render(
"vote.html",
poll=poll,
viewer_is_authenticated=session.is_authenticated,
viewer_is_moderator=session.is_moderator,
has_voted=has_voted,
current_selections=current_selections or frozenset(),
error=error,
vote_url=ctx.routes.url_for("/current/vote"),
events_url=ctx.routes.url_for("/current/events"),
end_url=ctx.routes.url_for("/current/end"),
cancel_url=ctx.routes.url_for("/current/cancel"),
polls_css_url=ctx.routes.url_for("/static/polls.css"),
polls_js_url=ctx.routes.url_for("/static/polls.js"),
)
return web.Response(status=status, text=page, content_type="text/html")
@on_route("/create", methods=["GET"], requires_moderator=True)
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.
:return: HTML response with the creation form, or an error response if a
poll is already active.
"""
manager = get_manager(ctx.module)
if manager.active_poll is not None:
return _error_page(ctx, HTTPStatus.CONFLICT, "A poll is already active.")
return _create_form_page(ctx, form=_default_form())
return _create_form_page(
ctx,
form={
"question": "",
"options": ["", ""],
"duration": DEFAULT_DURATION,
"hidden": False,
"requires_auth": False,
"allow_multiple": False,
"min_selections": "",
"max_selections": "",
},
)
@on_route("/create", methods=["POST"], requires_moderator=True)
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.
Parses form inputs, delegates normalized fields to the manager, and
redirects to the current poll page.
:param ctx: The route context.
:return: Redirect to the voting page, or error response.
:return: Redirect to the current poll page, or error response.
"""
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()]
options = [str(option).strip() for option in data.getall("options", [])]
try:
duration = int(str(data.get("duration", "0")))
@@ -195,80 +265,51 @@ async def create_submit(ctx: RouteContext) -> web.Response:
max_selections=max_selections,
duration=duration,
)
except PollError as e:
except PollAlreadyActiveError:
return _create_form_page(
ctx,
form=form,
error="A poll is already active.",
status=HTTPStatus.CONFLICT,
)
except PollCreateValidationError as e:
return _create_form_page(
ctx, form=form, error=str(e), status=HTTPStatus.BAD_REQUEST
)
# Redirect to the mod's voting page.
vote_url = ctx.routes.url_for("/vote")
raise web.HTTPFound(vote_url)
# Redirect to the active poll page.
current_url = ctx.routes.url_for("/current")
raise web.HTTPFound(current_url)
@on_route("/vote", methods=["GET"], requires_session=True)
async def vote_page(ctx: RouteContext) -> web.Response:
"""Serve the voting page.
@on_route("/current", methods=["GET"], requires_session=True)
async def current_page(ctx: RouteContext) -> web.Response:
"""Serve the current poll page.
Renders the voting page with appropriate visibility and permission controls
Renders the poll page with appropriate visibility and permission controls
based on the connected viewer's session.
:param ctx: The route context.
:return: HTML response with the voting page.
:return: HTML response with the current poll page, or an error response if
no poll is active.
"""
manager = get_manager(ctx.module)
poll = manager.active_poll
if poll is None:
return _error_page(ctx, HTTPStatus.NOT_FOUND, "No active poll.")
session = _require_session(ctx)
user_id = session.user.id
has_voted = user_id in poll.votes
current_votes = poll.votes.get(user_id, set())
vote_url = ctx.routes.url_for("/vote")
page = ctx.templates.render(
"vote.html",
poll=poll,
viewer_is_authenticated=session.is_authenticated,
viewer_is_moderator=session.is_moderator,
has_voted=has_voted,
current_votes=current_votes,
vote_url=vote_url,
events_url=ctx.routes.url_for("/vote/events"),
end_url=ctx.routes.url_for("/vote/end"),
cancel_url=ctx.routes.url_for("/vote/cancel"),
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")
return _current_poll_page(ctx, manager, session)
@on_route("/vote", methods=["POST"], requires_session=True)
@on_route("/current/vote", methods=["POST"], requires_session=True)
async def vote_submit(ctx: RouteContext) -> web.Response:
"""Handle vote form submission.
Parses selections, delegates to the manager, and redirects back to the
voting page.
current poll page.
:param ctx: The route context.
:return: Redirect back to the voting page, or error response.
:return: Redirect back to the current poll 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.")
session = _require_session(ctx)
if poll.requires_auth and not session.is_authenticated:
return _error_page(
ctx,
HTTPStatus.FORBIDDEN,
"This poll requires an authenticated account to vote.",
)
data = await ctx.request.post()
# Parse selections from form checkboxes/radio buttons.
@@ -276,29 +317,50 @@ async def vote_submit(ctx: RouteContext) -> web.Response:
try:
selections = {int(str(s)) for s in raw_selections}
except ValueError:
return _error_page(ctx, HTTPStatus.BAD_REQUEST, "Invalid selection.")
return _current_poll_page(
ctx,
manager,
session=session,
error="Invalid selection.",
status=HTTPStatus.BAD_REQUEST,
)
try:
await manager.record_vote(
session.user,
selections,
)
except PollError as e:
return _error_page(ctx, HTTPStatus.BAD_REQUEST, str(e))
except NoActivePollError:
return _error_page(ctx, HTTPStatus.NOT_FOUND, "No active poll.")
except PollAuthenticationRequiredError:
return _current_poll_page(
ctx,
manager,
session=session,
show_current_selections=False,
error="This poll requires an authenticated account to vote.",
status=HTTPStatus.FORBIDDEN,
)
except PollVoteError as e:
return _current_poll_page(
ctx,
manager,
session=session,
error=str(e),
status=HTTPStatus.BAD_REQUEST,
)
# Redirect back to the voting page.
vote_url = ctx.routes.url_for("/vote")
raise web.HTTPFound(vote_url)
# Redirect back to the active poll page.
current_url = ctx.routes.url_for("/current")
raise web.HTTPFound(current_url)
@on_route("/vote/events", streaming=True, requires_session=True)
@on_route("/current/events", streaming=True, requires_session=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.
:return: SSE stream response, or a 404 response if no poll is active.
"""
manager = get_manager(ctx.module)
poll = manager.active_poll
@@ -307,7 +369,6 @@ async def events_stream(ctx: RouteContext) -> web.StreamResponse | web.Response:
return web.Response(status=HTTPStatus.NOT_FOUND, text="No active poll.")
session = _require_session(ctx)
user_id = session.user.id
response = web.StreamResponse(
status=HTTPStatus.OK,
@@ -320,73 +381,45 @@ async def events_stream(ctx: RouteContext) -> web.StreamResponse | web.Response:
)
await response.prepare(ctx.request)
await manager.register_sse_client(
session.user,
response=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: # noqa: BLE001 # SSE disconnect; no single exception covers all transport failures
ctx.logger.debug("SSE keepalive failed for %s.", user_id)
break
finally:
# Only remove ourselves if we're still the registered client.
# A page reload creates a new SSE connection for the same user,
# so the old handler must not evict the replacement.
client = poll.sse_clients.get(user_id)
if client is not None and client.response is response:
poll.sse_clients.pop(user_id, None)
ctx.logger.debug(
"SSE client disconnected: %s (%d remaining).",
user_id,
len(poll.sse_clients),
)
await manager.serve_sse_stream(session.user, response)
except NoActivePollError:
ctx.logger.debug("SSE stream ended before client registration.")
return response
@on_route("/vote/end", methods=["POST"], requires_moderator=True)
@on_route("/current/end", methods=["POST"], requires_moderator=True)
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.
:return: Redirect to the results page, or an error response if no poll is
active.
"""
manager = get_manager(ctx.module)
poll = manager.active_poll
if poll is None:
try:
await manager.end()
except NoActivePollError:
return _error_page(ctx, HTTPStatus.NOT_FOUND, "No active poll.")
await manager.end()
results_url = ctx.routes.url_for("/results")
raise web.HTTPFound(results_url)
@on_route("/vote/cancel", methods=["POST"], requires_moderator=True)
@on_route("/current/cancel", methods=["POST"], requires_moderator=True)
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.
:return: Cancellation confirmation or error page.
"""
manager = get_manager(ctx.module)
poll = manager.active_poll
if poll is None:
try:
await manager.cancel()
except NoActivePollError:
return _error_page(ctx, HTTPStatus.NOT_FOUND, "No active poll.")
await manager.cancel()
return _error_page(ctx, HTTPStatus.OK, "This poll has been cancelled.")
@@ -394,8 +427,7 @@ async def mod_cancel(ctx: RouteContext) -> web.Response:
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.
Shows contextual messages if a poll is active or no results are available.
:param ctx: The route context.
:return: HTML response with the results page.
@@ -422,40 +454,19 @@ async def results_page(ctx: RouteContext) -> web.Response:
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: # noqa: ARG001 # required by route handler signature
"""Serve the polls stylesheet.
@on_route("/static/{filename:.+}", methods=["GET"])
async def static_asset(ctx: RouteContext) -> web.StreamResponse | web.Response:
"""Serve allow-listed poll module static assets.
:param ctx: The route context.
:return: The polls.css file with caching headers.
:param ctx: The route context containing the requested filename.
:return: Static file response, or 404 for unknown asset names.
"""
filename = ctx.match_info["filename"]
path = _STATIC_FILES.get(filename)
if path is None:
return web.Response(status=HTTPStatus.NOT_FOUND)
cache_control = "no-cache" if filename.startswith("shared/") else "max-age=86400"
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: # noqa: ARG001 # required by route handler signature
"""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: # noqa: ARG001 # required by route handler signature
"""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"},
path,
headers={"Cache-Control": cache_control},
)
+205
View File
@@ -0,0 +1,205 @@
# 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.
"""Server-Sent Event connection tracking for the polls module."""
from __future__ import annotations
import asyncio
import contextlib
import itertools
from dataclasses import dataclass, field
from enum import StrEnum
from typing import TYPE_CHECKING, Any
import orjson
if TYPE_CHECKING:
from logging import Logger
from aiohttp import web
from owlbot.api import User
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 PollSSEConnection:
"""Runtime metadata for one connected poll event stream.
:param id: Internal connection ID.
:param user_id: Owncast user ID associated with the stream.
:param is_moderator: Whether the connected viewer has moderator privileges.
:param response: Prepared response used for SSE writes.
:param write_lock: Per-connection lock that serializes writes.
"""
id: int
user_id: str
is_moderator: bool
response: web.StreamResponse
write_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
class PollSSERegistry:
"""Maintain live poll SSE connections and serialize writes per client."""
def __init__(self, logger: Logger) -> None:
"""Initialize an empty SSE connection registry.
:param logger: Logger for connection lifecycle diagnostics.
"""
self._logger = logger
self._connection_ids = itertools.count()
self._connections: dict[int, PollSSEConnection] = {}
def __len__(self) -> int:
"""Return the number of currently tracked connections.
:return: Active SSE connection count.
"""
return len(self._connections)
def register(
self,
user: User,
*,
response: web.StreamResponse,
) -> PollSSEConnection:
"""Register an SSE client and return its connection.
:param user: Viewer associated with the event stream.
:param response: Prepared streaming response for the client.
:return: Registered connection metadata.
"""
connection = PollSSEConnection(
id=next(self._connection_ids),
user_id=user.id,
is_moderator=user.is_moderator,
response=response,
)
self._connections[connection.id] = connection
return connection
def unregister(self, connection: PollSSEConnection) -> None:
"""Remove one SSE connection.
:param connection: Connection to remove from the registry.
"""
removed = self._connections.pop(connection.id, None)
if removed is not None:
self._logger.debug(
"SSE client disconnected: %s (%d remaining).",
removed.user_id,
len(self._connections),
)
async def send(
self,
connection: PollSSEConnection,
event_type: SSEEvent,
data: dict[str, Any],
) -> bool:
"""Send one event to one connection, removing it if the write fails.
:param connection: Target SSE connection.
:param event_type: Event type to send.
:param data: JSON-serializable event payload.
:return: True if the event was written successfully.
"""
if self._connections.get(connection.id) is not connection:
return False
try:
await self._write_connection(connection, sse_payload(event_type, data))
except Exception: # noqa: BLE001 # transport failures vary by server/client state
self._connections.pop(connection.id, None)
self._logger.debug(
"Removing disconnected SSE client: %s", connection.user_id
)
return False
return True
async def broadcast(
self,
event_type: SSEEvent,
data: dict[str, Any],
*,
moderators_only: bool = False,
) -> None:
"""Broadcast one event to currently tracked connections.
:param event_type: Event type to send.
:param data: JSON-serializable event payload.
:param moderators_only: Whether to limit delivery to moderator clients.
"""
targets = [
connection
for connection in self._connections.values()
if not moderators_only or connection.is_moderator
]
if not targets:
return
payload = sse_payload(event_type, data)
results = await asyncio.gather(
*(self._write_connection(connection, payload) for connection in targets),
return_exceptions=True,
)
for connection, result in zip(targets, results, strict=True):
if isinstance(result, BaseException):
self._logger.debug(
"Removing disconnected SSE client: %s", connection.user_id
)
self._connections.pop(connection.id, None)
async def close_all(self) -> None:
"""Close and forget every tracked SSE response."""
connections = list(self._connections.values())
for connection in connections:
with contextlib.suppress(Exception):
async with connection.write_lock:
await connection.response.write_eof()
self._connections.clear()
async def _write_connection(
self,
connection: PollSSEConnection,
payload: bytes,
) -> None:
"""Write one encoded SSE frame while holding the connection lock.
:param connection: Target SSE connection.
:param payload: Encoded SSE frame.
"""
async with connection.write_lock:
await connection.response.write(payload)
+189 -79
View File
@@ -12,95 +12,205 @@
// See the License for the specific language governing permissions and
// limitations under the License.
(() => {
"use strict";
import { readJsonConfig } from "./shared/config.js";
import {
createTextElement,
requireElement,
setHidden,
} from "./shared/dom.js";
const config = window.CREATE_CONFIG;
if (!config) return;
class CreatePollForm {
constructor(root, config) {
this.root = root;
this.config = config;
this.form = requireElement(root, "#poll-form", HTMLFormElement);
this.container = requireElement(root, "#options-container", HTMLElement);
this.addButton = requireElement(root, "#add-option", HTMLButtonElement);
this.multiCheckbox = requireElement(
root,
"#allow_multiple",
HTMLInputElement
);
this.multiOptions = requireElement(
root,
"#multi-select-options",
HTMLElement
);
this.minSelectionsInput = requireElement(
root,
"#min_selections",
HTMLInputElement
);
this.maxSelectionsInput = requireElement(
root,
"#max_selections",
HTMLInputElement
);
}
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');
start() {
this.ensureMinimumOptions();
this.renumberOptions();
this.setMultiSelectEnabled(this.multiCheckbox.checked);
this.bindEvents();
}
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;
};
bindEvents() {
this.addButton.addEventListener("click", () => this.addOption());
this.container.addEventListener("click", (event) =>
this.removeOption(event)
);
this.multiCheckbox.addEventListener("change", () =>
this.setMultiSelectEnabled(this.multiCheckbox.checked)
);
this.minSelectionsInput.addEventListener("input", () =>
this.updateSelectionConstraints()
);
this.maxSelectionsInput.addEventListener("input", () =>
this.updateSelectionConstraints()
);
this.form.addEventListener("submit", () =>
this.updateSelectionConstraints()
);
}
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;
optionRows() {
return Array.from(this.container.querySelectorAll(".option-row"));
}
ensureMinimumOptions() {
while (this.optionRows().length < this.config.minOptions) {
this.container.appendChild(this.buildOptionRow());
}
if (maxSel.value && parseInt(maxSel.value, 10) > count) {
maxSel.value = count;
}
addOption() {
if (this.optionRows().length >= this.config.maxOptions) {
return;
}
syncSelectionLimits();
};
this.container.appendChild(this.buildOptionRow());
this.renumberOptions();
}
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}`;
removeOption(event) {
const target = event.target;
if (!(target instanceof Element)) {
return;
}
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();
const button = target.closest(".remove-option");
if (button === null || !this.container.contains(button)) {
return;
}
});
multiCheck.addEventListener('change', () => {
if (multiCheck.checked) {
updateSelectionDefaults();
multiOpts.classList.remove('d-none');
} else {
multiOpts.classList.add('d-none');
minSel.value = '';
maxSel.value = '';
const row = button.closest(".option-row");
if (row === null || this.optionRows().length <= this.config.minOptions) {
return;
}
});
form.addEventListener('submit', () => {
const lines = [...container.querySelectorAll('.option-input')]
.map(input => input.value.trim())
.filter(Boolean);
document.getElementById('options-hidden').value = lines.join('\n');
});
})();
row.remove();
this.renumberOptions();
}
buildOptionRow() {
const row = document.createElement("div");
row.className = "input-group mb-2 option-row";
const number = this.optionRows().length + 1;
const numberLabel = createTextElement(
"span",
"input-group-text option-number",
String(number)
);
const input = document.createElement("input");
input.type = "text";
input.className = "form-control option-input";
input.name = "options";
input.required = true;
input.maxLength = this.config.maxOptionLength;
input.placeholder = `Option ${number}`;
row.append(numberLabel, input);
if (number > this.config.minOptions) {
const removeButton = createTextElement(
"button",
"btn btn-outline-danger remove-option",
"Remove"
);
removeButton.type = "button";
row.appendChild(removeButton);
}
return row;
}
renumberOptions() {
const rows = this.optionRows();
for (const [index, row] of rows.entries()) {
const number = index + 1;
requireElement(row, ".option-number", HTMLElement).textContent =
String(number);
requireElement(row, ".option-input", HTMLInputElement).placeholder =
`Option ${number}`;
}
this.addButton.disabled = rows.length >= this.config.maxOptions;
this.updateSelectionConstraints();
}
setMultiSelectEnabled(enabled) {
this.minSelectionsInput.disabled = !enabled;
this.maxSelectionsInput.disabled = !enabled;
setHidden(this.multiOptions, !enabled);
if (!enabled) {
this.minSelectionsInput.value = "";
this.maxSelectionsInput.value = "";
this.minSelectionsInput.setCustomValidity("");
this.maxSelectionsInput.setCustomValidity("");
return;
}
this.updateSelectionConstraints();
}
updateSelectionConstraints() {
const optionCount = this.optionRows().length;
const minValue = this.parseSelectionValue(this.minSelectionsInput);
const maxValue = this.parseSelectionValue(this.maxSelectionsInput);
this.minSelectionsInput.max = String(optionCount);
this.maxSelectionsInput.max = String(optionCount);
this.maxSelectionsInput.placeholder = String(optionCount);
this.maxSelectionsInput.min =
Number.isInteger(minValue) && minValue >= 1 ? String(minValue) : "1";
this.minSelectionsInput.setCustomValidity("");
this.maxSelectionsInput.setCustomValidity("");
if (
!this.multiCheckbox.checked ||
!Number.isInteger(minValue) ||
!Number.isInteger(maxValue)
) {
return;
}
if (minValue > maxValue) {
this.maxSelectionsInput.setCustomValidity(
"Maximum selections must be at least the minimum."
);
}
}
parseSelectionValue(input) {
if (input.value.trim() === "") {
return null;
}
return Number(input.value);
}
}
const config = readJsonConfig("create-config");
new CreatePollForm(document, config).start();
+170 -149
View File
@@ -12,174 +12,195 @@
// See the License for the specific language governing permissions and
// limitations under the License.
(() => {
"use strict";
import { readJsonConfig } from "./shared/config.js";
import {
disableControls,
requireElement,
showAlert,
} from "./shared/dom.js";
import {
isKeepalivePayload,
isPollEndedPayload,
isTallyPayload,
PollEventStream,
} from "./shared/poll-events.js";
const config = window.POLL_CONFIG;
const viewerInfo = window.VIEWER_INFO;
if (!config || !viewerInfo) return;
function normalizeSeconds(value) {
return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
}
const timerEl = document.getElementById("timer");
let remaining = config.timeRemaining;
function formatTime(seconds) {
const safeSeconds = normalizeSeconds(seconds);
const minutes = Math.floor(safeSeconds / 60);
const remainingSeconds = safeSeconds % 60;
return `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
}
// --- Countdown timer ---
class CountdownTimer {
constructor(element, initialSeconds) {
this.element = element;
this.remaining = normalizeSeconds(initialSeconds);
this.intervalId = null;
}
const formatTime = (seconds) => {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${String(s).padStart(2, "0")}`;
};
start() {
this.render();
this.ensureInterval();
}
const updateTimer = () => {
if (remaining <= 0) {
timerEl.textContent = "0:00";
stop() {
if (this.intervalId === null) {
return;
}
timerEl.textContent = formatTime(remaining);
remaining--;
};
window.clearInterval(this.intervalId);
this.intervalId = null;
}
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 || viewerInfo.isModerator) {
for (const el of document.querySelectorAll(".vote-count")) {
el.classList.remove("d-none");
setRemaining(seconds) {
this.remaining = normalizeSeconds(seconds);
this.render();
if (this.remaining > 0) {
this.ensureInterval();
} else {
this.stop();
}
}
// --- SSE connection ---
const evtSource = new EventSource(viewerInfo.eventsUrl);
const parse = (e) => {
try {
return JSON.parse(e.data);
} catch {
return null;
ensureInterval() {
if (this.intervalId !== null || this.remaining <= 0) {
return;
}
};
this.intervalId = window.setInterval(() => this.tick(), 1000);
}
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>";
tick() {
if (this.remaining <= 0) {
this.stop();
this.render();
return;
}
this.remaining -= 1;
this.render();
if (this.remaining <= 0) {
this.stop();
}
}
render() {
this.element.textContent = formatTime(this.remaining);
}
}
class ResultsBars {
constructor(root) {
this.rows = Array.from(root.querySelectorAll(".option-bar")).map(
(row) => ({
index: Number(row.dataset.index),
fill: row.querySelector(".bar-fill"),
countWrapper: row.querySelector(".vote-count"),
count: row.querySelector(".count"),
percent: row.querySelector(".pct"),
})
);
}
showCounts() {
for (const row of this.rows) {
row.countWrapper?.classList.remove("d-none");
}
}
update(counts) {
const total = counts.reduce((sum, count) => sum + count, 0);
for (const row of this.rows) {
const count = counts[row.index] ?? 0;
const percent = total > 0 ? Math.round((count / total) * 100) : 0;
if (row.fill !== null) {
row.fill.style.width = `${percent}%`;
}
}
});
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;
if (row.count !== null) {
row.count.textContent = String(count);
}
if (row.percent !== null) {
row.percent.textContent = String(percent);
}
row.countWrapper?.classList.remove("d-none");
}
});
}
}
// --- Multi-select validation ---
class VotePage {
constructor(root, config) {
this.root = root;
this.config = config;
this.status = requireElement(root, "#poll-status", HTMLElement);
this.timer = new CountdownTimer(
requireElement(root, "#timer", HTMLElement),
config.timeRemaining
);
this.bars = new ResultsBars(root);
this.stream = null;
}
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;
start() {
this.timer.start();
if (count === 0) {
e.preventDefault();
alert("Please select at least one option.");
return;
}
if (!this.config.hidden || this.config.isModerator) {
this.bars.showCounts();
}
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;
}
this.stream = new PollEventStream(this.config.eventsUrl, {
onClosed: () => this.handleConnectionClosed(),
});
this.stream.on(
"keepalive",
(data) => this.timer.setRemaining(data.time_remaining),
isKeepalivePayload
);
this.stream.on(
"tally",
(data) => this.bars.update(data.counts),
(data) => isTallyPayload(data, this.config.optionCount)
);
this.stream.on(
"poll_ended",
(data) => this.handlePollEnded(data),
isPollEndedPayload
);
this.stream.on("poll_cancelled", () => this.handlePollCancelled());
}
})();
stop() {
this.timer.stop();
if (this.stream !== null) {
this.stream.close();
this.stream = null;
}
}
handleConnectionClosed() {
this.timer.stop();
showAlert(
this.status,
"Connection lost. Results are not live. Refresh to reconnect."
);
}
handlePollEnded(data) {
this.stop();
window.location.href = data.results_url;
}
handlePollCancelled() {
this.stop();
showAlert(this.status, "This poll has been cancelled.");
disableControls(this.root);
}
}
const config = readJsonConfig("poll-config");
new VotePage(document, config).start();
@@ -0,0 +1,26 @@
// 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.
export function readJsonConfig(id) {
const script = document.getElementById(id);
if (!(script instanceof HTMLScriptElement)) {
throw new Error(`Missing JSON config script: #${id}`);
}
try {
return JSON.parse(script.textContent || "{}");
} catch (error) {
throw new Error(`Invalid JSON config script: #${id}`, { cause: error });
}
}
@@ -0,0 +1,52 @@
// 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.
export function requireElement(root, selector, elementType = Element) {
const element = root.querySelector(selector);
if (element === null) {
throw new Error(`Missing required element: ${selector}`);
}
if (!(element instanceof elementType)) {
throw new Error(`Element ${selector} is not a ${elementType.name}`);
}
return element;
}
export function createTextElement(tagName, className, text) {
const element = document.createElement(tagName);
if (className) {
element.className = className;
}
element.textContent = text;
return element;
}
export function setHidden(element, hidden) {
element.classList.toggle("d-none", hidden);
}
export function showAlert(container, message, variant = "danger") {
const alert = document.createElement("div");
alert.className = `alert alert-${variant} mb-0`;
alert.setAttribute("role", "alert");
alert.textContent = message;
container.replaceChildren(alert);
}
export function disableControls(root) {
for (const control of root.querySelectorAll("button, input, select, textarea")) {
control.disabled = true;
}
}
@@ -0,0 +1,75 @@
// 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.
function isRecord(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parseEventData(event) {
try {
return JSON.parse(event.data);
} catch {
return null;
}
}
export function isKeepalivePayload(value) {
return isRecord(value) && Number.isFinite(value.time_remaining);
}
export function isPollEndedPayload(value) {
return (
isRecord(value) &&
typeof value.results_url === "string" &&
value.results_url.length > 0
);
}
export function isTallyPayload(value, optionCount) {
return (
isRecord(value) &&
Array.isArray(value.counts) &&
value.counts.length === optionCount &&
value.counts.every((count) => Number.isInteger(count) && count >= 0)
);
}
export class PollEventStream {
constructor(url, { onClosed } = {}) {
this.source = new EventSource(url);
this.onClosed = onClosed;
this.source.addEventListener("error", () => {
if (this.source.readyState === EventSource.CLOSED) {
this.close();
if (this.onClosed) {
this.onClosed();
}
}
});
}
on(eventName, handler, validator = () => true) {
this.source.addEventListener(eventName, (event) => {
const data = parseEventData(event);
if (validator(data)) {
handler(data);
}
});
}
close() {
this.source.close();
}
}
@@ -17,7 +17,7 @@
{% 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 }}">
<input type="text" class="form-control option-input" name="options" 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 %}
@@ -52,20 +52,22 @@
<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 }}">
<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 }}" {% if not form.allow_multiple %}disabled{% endif %}>
<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 }}">
<input type="number" class="form-control form-control-sm" style="width: 5rem" id="max_selections" name="max_selections" min="1" value="{{ form.max_selections }}" {% if not form.allow_multiple %}disabled{% endif %}>
<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>
<script id="create-config" type="application/json">
{{ {
"minOptions": constraints.min_options,
"maxOptions": constraints.max_options,
"maxOptionLength": constraints.max_option_length
} | tojson }}
</script>
<script type="module" src="{{ create_js_url }}?v={{ owlbot_version }}"></script>
{% endblock %}
@@ -9,15 +9,40 @@
<p class="text-body-secondary mb-1">Time remaining: <strong id="timer">--:--</strong></p>
{% if poll.hidden %}
{% if viewer_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>
<p class="text-body-secondary mb-{{ 1 if poll.allow_multiple else 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>
<p class="text-body-secondary mb-{{ 1 if poll.allow_multiple else 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>
<p class="text-body-secondary mb-{{ 1 if poll.allow_multiple else 0 }}">Results are updated in real time.</p>
{% endif %}
{% if poll.allow_multiple %}
{% set min_count = poll.min_selections or 1 %}
{% set max_count = poll.max_selections %}
{% set min_label = min_count ~ " option" ~ ("s" if min_count != 1 else "") %}
<p class="text-body-secondary mb-0">
{% if max_count is none %}
{% if min_count == 1 %}
Select one or more options.
{% else %}
Select at least {{ min_label }}.
{% endif %}
{% else %}
{% set max_label = max_count ~ " option" ~ ("s" if max_count != 1 else "") %}
{% if min_count == max_count %}
Select exactly {{ min_label }}.
{% else %}
Select {{ min_count }} to {{ max_label }}.
{% endif %}
{% endif %}
</p>
{% endif %}
</div>
{% if error %}
<div class="alert alert-danger" role="alert">{{ error }}</div>
{% endif %}
<form method="POST" action="{{ vote_url }}" id="vote-form">
<div class="d-flex flex-column gap-2 mb-4">
{% for option in poll.options %}
@@ -26,11 +51,11 @@
<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 loop.index0 in current_selections %}checked{% endif %}
{% if has_voted or (not viewer_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 loop.index0 in current_selections %}checked{% endif %}
{% if has_voted or (not viewer_is_authenticated and poll.requires_auth) %}disabled{% endif %}>
{% endif %}
{{ option }}
@@ -66,18 +91,14 @@
{% 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.VIEWER_INFO = {
isModerator: {{ viewer_is_moderator | tojson }},
eventsUrl: {{ events_url | tojson }}
};
<script id="poll-config" type="application/json">
{{ {
"hidden": poll.hidden,
"timeRemaining": poll.time_remaining,
"isModerator": viewer_is_moderator,
"eventsUrl": events_url,
"optionCount": poll.options | length
} | tojson }}
</script>
<script src="{{ polls_js_url }}?v={{ owlbot_version }}"></script>
<script type="module" src="{{ polls_js_url }}?v={{ owlbot_version }}"></script>
{% endblock %}
+110 -55
View File
@@ -16,19 +16,13 @@
from __future__ import annotations
import asyncio
import time
from dataclasses import dataclass, field
from enum import StrEnum
from typing import TYPE_CHECKING, Any, TypedDict
import orjson
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from datetime import datetime
from aiohttp import web
MAX_QUESTION_LENGTH = 200
MAX_OPTION_LENGTH = 50
@@ -42,37 +36,58 @@ 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"
class PollError(Exception):
"""Base class for polls module domain errors."""
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"
class PollStateError(PollError):
"""Raised when an operation is invalid for the current poll state."""
@dataclass(slots=True)
class PollSSEClient:
"""A connected live-results stream for a poll viewer.
class PollAlreadyActiveError(PollStateError):
"""Raised when creating a poll while another one is active."""
:param user_id: Owncast user ID associated with the browser session.
:param is_moderator: Whether this viewer can see hidden live tallies.
:param response: The active SSE response.
"""
def __init__(self) -> None:
"""Initialize with the user-facing active-poll message."""
super().__init__("A poll is already active.")
user_id: str
is_moderator: bool
response: web.StreamResponse
class NoActivePollError(PollStateError):
"""Raised when an operation requires an active poll."""
def __init__(self) -> None:
"""Initialize with the user-facing missing-poll message."""
super().__init__("No active poll.")
class PollCreateValidationError(PollError):
"""Raised when poll creation input is invalid."""
class PollVoteError(PollError):
"""Raised when a vote cannot be accepted."""
class PollVoteValidationError(PollVoteError):
"""Raised when a submitted vote is invalid."""
class PollAuthenticationRequiredError(PollVoteError):
"""Raised when an unauthenticated user tries to vote in an auth-only poll."""
def __init__(self, user_id: str) -> None:
"""Initialize with the denied user ID."""
self.user_id = user_id
super().__init__("This poll requires an authenticated account to vote.")
class DuplicateVoteError(PollVoteError):
"""Raised when the same user tries to vote more than once."""
def __init__(self, user_id: str) -> None:
"""Initialize with the duplicate voter's user ID."""
self.user_id = user_id
super().__init__("You have already voted.")
@dataclass(slots=True)
@@ -81,7 +96,8 @@ class ActivePoll:
:param question: The poll question.
:param options: List of poll options.
:param hidden: Whether vote results are hidden until the poll ends.
:param hidden: Whether live result tallies are hidden from non-moderator
viewers 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).
@@ -89,8 +105,6 @@ class ActivePoll:
: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 user ID to SSE client response metadata.
:param timer_task: Asyncio task for the poll timer.
"""
question: str
@@ -103,23 +117,30 @@ class ActivePoll:
duration: int
created_at: datetime
votes: dict[str, set[int]] = field(default_factory=dict)
sse_clients: dict[str, PollSSEClient] = field(default_factory=dict)
timer_task: asyncio.Task[None] | None = None
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."""
"""Seconds remaining until the poll ends, minimum 0.
:return: Remaining poll duration in whole seconds.
"""
elapsed = time.monotonic() - self._started_at_mono
return max(0, int(self.duration - elapsed))
class PollResults(TypedDict):
"""Computed results of a poll."""
@dataclass(frozen=True, slots=True)
class PollResults:
"""Computed results of a poll.
counts: list[int]
winners: list[str]
:param counts: Vote counts aligned with poll option order.
:param winners: Option labels tied for the highest vote count.
:param is_tie: Whether multiple options share the highest vote count.
:param total_votes: Number of voters represented in the result.
"""
counts: tuple[int, ...]
winners: tuple[str, ...]
is_tie: bool
total_votes: int
@@ -131,7 +152,7 @@ def compute_results(options: list[str], votes: dict[str, set[int]]) -> PollResul
: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.
:return: Immutable results with counts, winners, tie state, and voter total.
"""
num_options = len(options)
counts = [0] * num_options
@@ -144,36 +165,70 @@ def compute_results(options: list[str], votes: dict[str, set[int]]) -> PollResul
max_count = max(counts) if counts else 0
if total_votes == 0 or max_count == 0:
winners = []
winners: list[str] = []
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,
}
return PollResults(
counts=tuple(counts),
winners=tuple(winners),
is_tie=is_tie,
total_votes=total_votes,
)
@dataclass(slots=True)
@dataclass(frozen=True, slots=True)
class CompletedPoll:
"""Snapshot of a completed poll's results.
Stored in memory on PollManager as the most recent result.
Stored in memory as the most recent poll result.
Ephemeral — not persisted to disk.
:param question: The poll question.
:param options: Poll options sorted by vote count descending.
:param options: Poll options sorted by vote count descending, then by label
for ties.
: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]
options: tuple[str, ...]
results: PollResults
created_at: datetime
ended_at: datetime
def complete_poll(poll: ActivePoll, ended_at: datetime) -> CompletedPoll:
"""Build a completed poll snapshot with display-ready sorted results.
:param poll: Active poll to complete.
:param ended_at: Timestamp when the poll ended.
:return: Completed poll snapshot with options sorted by result count, then
by label for ties.
"""
raw_results = compute_results(
poll.options,
{user_id: set(selections) for user_id, selections in poll.votes.items()},
)
counts = raw_results.counts
sorted_indices = sorted(
range(len(poll.options)),
key=lambda i: (-counts[i], poll.options[i].lower()),
)
sorted_options = tuple(poll.options[i] for i in sorted_indices)
return CompletedPoll(
question=poll.question,
options=sorted_options,
results=PollResults(
counts=tuple(counts[i] for i in sorted_indices),
winners=raw_results.winners,
is_tie=raw_results.is_tie,
total_votes=raw_results.total_votes,
),
created_at=poll.created_at,
ended_at=ended_at,
)