# 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,
REMINDER_THRESHOLD,
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 reminder before expiry.
async def _poll_timer() -> None:
if duration > REMINDER_THRESHOLD:
await asyncio.sleep(duration - REMINDER_THRESHOLD)
if poll.allow_multiple:
instructions = "Use !vote to get a voting link."
else:
instructions = (
"Type a number in chat or use !vote to vote."
)
escaped_question = escape(poll.question)
reminder = (
f"{REMINDER_THRESHOLD} seconds remaining:"
f" {escaped_question}
{instructions}"
)
await self._ctx.owncast_client.send_system_message(
reminder, unsanitized=True
)
await asyncio.sleep(REMINDER_THRESHOLD)
else:
await asyncio.sleep(duration)
await self.end()
poll.timer_task = asyncio.create_task(_poll_timer())
# Announce in chat.
if duration >= REMINDER_THRESHOLD:
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"{i}. {escape(opt)}
"
for i, opt in enumerate(options, 1)
)
if allow_multiple:
instructions = "Use !vote to get a voting link."
else:
instructions = (
"Type a number in chat or use !vote to vote."
)
announcement = (
f"