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
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:
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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 %}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user