CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 14s
CI / Tests (Python 3.13) (push) Successful in 14s
CI / Tests (Python 3.14) (push) Successful in 11s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 5s
460 lines
17 KiB
Python
460 lines
17 KiB
Python
# 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.
|
|
|
|
"""Timer task management and stream-aware lifecycle for the timers module.
|
|
|
|
Provides the Timer class (per-timer async fire loop) and TimerManager (collection
|
|
management, business rules, and stream-aware lifecycle). All persistence is
|
|
delegated to TimerRepository.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
from datetime import UTC, datetime, timedelta
|
|
from typing import TYPE_CHECKING
|
|
|
|
from cronsim import CronSim, CronSimError
|
|
|
|
from .types import (
|
|
IntervalType,
|
|
NegativeLineCountError,
|
|
TimerAlreadyDisabledError,
|
|
TimerAlreadyEnabledError,
|
|
TimerMessageRequiredError,
|
|
duration_to_seconds,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from owlbot.api import ModuleContext
|
|
|
|
from .repository import TimerRepository
|
|
from .types import TimerInfo
|
|
|
|
|
|
class Timer:
|
|
"""Encapsulates a single timer's async task, chat counter, and fire loop."""
|
|
|
|
def __init__(
|
|
self, info: TimerInfo, ctx: ModuleContext, repo: TimerRepository
|
|
) -> None:
|
|
"""Initialize a timer with no running task.
|
|
|
|
:param info: Snapshot of the timer from the database.
|
|
:param ctx: The module context.
|
|
:param repo: The timer repository for persistence.
|
|
:raises TimerMessageRequiredError: If the timer has no message set.
|
|
:raises ValueError: If the interval is invalid.
|
|
"""
|
|
if info.message is None:
|
|
raise TimerMessageRequiredError(info)
|
|
|
|
if info.interval_type == IntervalType.SIMPLE:
|
|
if duration_to_seconds(info.interval_value) <= 0:
|
|
raise ValueError(
|
|
f"Timer {info.display} has an invalid simple interval."
|
|
)
|
|
else:
|
|
try:
|
|
next(CronSim(info.interval_value, datetime.now(UTC)))
|
|
except (ValueError, KeyError, CronSimError):
|
|
raise ValueError(
|
|
f"Timer {info.display} has an invalid cron expression."
|
|
) from None
|
|
|
|
self._info = info
|
|
self._message: str = info.message
|
|
self._ctx = ctx
|
|
self._repo = repo
|
|
self._task: asyncio.Task[None] | None = None
|
|
self._counted_messages: set[str] = set()
|
|
|
|
@property
|
|
def running(self) -> bool:
|
|
"""Whether the fire loop task is currently running."""
|
|
return self._task is not None and not self._task.done()
|
|
|
|
def start(self) -> None:
|
|
"""Spawn the fire loop as a background task.
|
|
|
|
No-op if already running.
|
|
"""
|
|
if self.running:
|
|
return
|
|
self._task = asyncio.create_task(
|
|
self._fire_loop(), name=f"Timers Module - Timer #{self._info.id} loop"
|
|
)
|
|
|
|
async def stop(self) -> None:
|
|
"""Cancel the fire loop task and clear the chat counter."""
|
|
if self._task is not None:
|
|
self._task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await self._task
|
|
self._task = None
|
|
self._counted_messages.clear()
|
|
|
|
def count_message(self, message_id: str) -> None:
|
|
"""Count a chat message toward this timer's threshold.
|
|
|
|
No-op if the timer has no minimum chat line requirement.
|
|
|
|
:param message_id: The message ID to count.
|
|
"""
|
|
if self._info.min_chat_lines > 0:
|
|
self._counted_messages.add(message_id)
|
|
|
|
def remove_messages(self, message_ids: set[str]) -> None:
|
|
"""Remove message IDs from this timer's counter.
|
|
|
|
No-op if the timer has no minimum chat line requirement.
|
|
|
|
:param message_ids: Set of message IDs to remove.
|
|
"""
|
|
if self._info.min_chat_lines > 0:
|
|
self._counted_messages.difference_update(message_ids)
|
|
|
|
async def _fire_loop(self) -> None:
|
|
"""Background loop: sleep until due, check threshold, fire or skip.
|
|
|
|
All timer configuration is read from the TimerInfo snapshot set at
|
|
construction. The only database interaction is persisting last_fired_at
|
|
so the schedule survives restarts. Config changes are handled by
|
|
TimerManager restarting the Timer with a fresh snapshot.
|
|
"""
|
|
ctx = self._ctx
|
|
info = self._info
|
|
display = info.display
|
|
message = self._message
|
|
last_fired_at: datetime | None = (
|
|
datetime.fromisoformat(info.last_fired_at) if info.last_fired_at else None
|
|
)
|
|
|
|
if info.interval_type == IntervalType.SIMPLE:
|
|
interval_secs = duration_to_seconds(info.interval_value)
|
|
cron_iter: CronSim | None = None
|
|
else:
|
|
interval_secs = 0
|
|
cron_iter = CronSim(info.interval_value, last_fired_at or datetime.now(UTC))
|
|
|
|
ctx.logger.debug("Timer %s fire loop started.", display)
|
|
while True:
|
|
now = datetime.now(UTC)
|
|
|
|
# Compute next fire time from the interval.
|
|
if last_fired_at is None:
|
|
fire_time = now
|
|
elif cron_iter is not None:
|
|
fire_time = next(cron_iter)
|
|
else:
|
|
fire_time = last_fired_at + timedelta(seconds=interval_secs)
|
|
|
|
delay = max(0.0, (fire_time - now).total_seconds())
|
|
|
|
if delay > 0:
|
|
ctx.logger.debug("Timer %s sleeping for %.1fs.", display, delay)
|
|
await asyncio.sleep(delay)
|
|
|
|
now = datetime.now(UTC)
|
|
|
|
# Check min_chat_lines threshold.
|
|
if info.min_chat_lines > 0:
|
|
counted = len(self._counted_messages)
|
|
if counted < info.min_chat_lines:
|
|
last_fired_at = now
|
|
await self._repo.update_last_fired_at(info.id, now.isoformat())
|
|
ctx.logger.info(
|
|
"Timer %s skipped: chat threshold not met (%d/%d). "
|
|
"Schedule advanced.",
|
|
display,
|
|
counted,
|
|
info.min_chat_lines,
|
|
)
|
|
continue
|
|
|
|
# Fire the timer.
|
|
try:
|
|
ctx.logger.debug("Firing timer %s.", display)
|
|
await ctx.owncast_client.send_message(message)
|
|
self._counted_messages.clear()
|
|
ctx.logger.info("Timer %s fired.", display)
|
|
except Exception:
|
|
ctx.logger.exception("Failed to fire timer %s.", display)
|
|
|
|
# Always advance the schedule so a persistent error doesn't
|
|
# cause a tight retry loop.
|
|
last_fired_at = now
|
|
await self._repo.update_last_fired_at(info.id, now.isoformat())
|
|
|
|
|
|
class TimerManager:
|
|
"""Manages the collection of Timer instances and stream-aware lifecycle.
|
|
|
|
Business rules (enable/disable guards, message requirements) live here.
|
|
All persistence is delegated to the TimerRepository.
|
|
"""
|
|
|
|
OFFLINE_STOP_DELAY: float = 300.0
|
|
|
|
def __init__(self, ctx: ModuleContext, repo: TimerRepository) -> None:
|
|
"""Initialize an empty manager.
|
|
|
|
:param ctx: The module context.
|
|
:param repo: The timer repository for persistence.
|
|
"""
|
|
self._ctx = ctx
|
|
self._repo = repo
|
|
self._timers: dict[int, Timer] = {}
|
|
self._offline_stop_task: asyncio.Task[None] | None = None
|
|
|
|
def start_timer(self, info: TimerInfo) -> None:
|
|
"""Create a Timer and start its fire loop.
|
|
|
|
No-op if the timer already has a running task.
|
|
|
|
:param info: Snapshot of the timer.
|
|
"""
|
|
if info.id in self._timers:
|
|
return
|
|
timer = Timer(info, self._ctx, self._repo)
|
|
timer.start()
|
|
self._timers[info.id] = timer
|
|
self._ctx.logger.debug("Started timer task for %s.", info.display)
|
|
|
|
async def stop_timer(self, timer_id: int) -> None:
|
|
"""Cancel a timer's task and remove it from the collection.
|
|
|
|
No-op if the timer has no running task.
|
|
|
|
:param timer_id: Database ID of the timer.
|
|
"""
|
|
timer = self._timers.pop(timer_id, None)
|
|
if timer is not None:
|
|
await timer.stop()
|
|
self._ctx.logger.debug("Stopped timer task for #%d.", timer_id)
|
|
|
|
async def restart_timer(self, info: TimerInfo) -> None:
|
|
"""Stop and re-start a timer with fresh state.
|
|
|
|
No-op if the timer has no running task.
|
|
|
|
:param info: Snapshot of the timer.
|
|
"""
|
|
if info.id not in self._timers:
|
|
return
|
|
await self.stop_timer(info.id)
|
|
self.start_timer(info)
|
|
|
|
async def start_all(self) -> None:
|
|
"""Start tasks for all enabled timers that have a message set.
|
|
|
|
Timers that already have a running task are skipped.
|
|
"""
|
|
infos = await self._repo.list_startable()
|
|
for info in infos:
|
|
self.start_timer(info)
|
|
self._ctx.logger.info("Started %d timer task(s).", len(infos))
|
|
|
|
async def stop_all(self) -> None:
|
|
"""Cancel all running timer tasks."""
|
|
timers = list(self._timers.values())
|
|
self._timers.clear()
|
|
await asyncio.gather(*(t.stop() for t in timers))
|
|
|
|
async def create_timer(self, name: str | None) -> TimerInfo:
|
|
"""Insert a new disabled timer and return its info.
|
|
|
|
:param name: Optional timer name, or None for unnamed.
|
|
:return: Snapshot of the newly created timer.
|
|
:raises InvalidTimerNameError: If the name format is invalid.
|
|
:raises TimerNameTakenError: If the name is already in use.
|
|
"""
|
|
info = await self._repo.create(name)
|
|
self._ctx.logger.debug("Created timer #%d.", info.id)
|
|
return info
|
|
|
|
async def delete_timer(self, identifier: str) -> TimerInfo:
|
|
"""Delete a timer from the database and stop it if running.
|
|
|
|
:param identifier: Timer ID or name string.
|
|
:return: Snapshot of the deleted timer.
|
|
:raises TimerNotFoundError: If no timer matches the identifier.
|
|
"""
|
|
info = await self._repo.get(identifier)
|
|
await self.stop_timer(info.id)
|
|
await self._repo.delete(info.id)
|
|
self._ctx.logger.debug("Deleted timer #%d.", info.id)
|
|
return info
|
|
|
|
async def set_message(self, identifier: str, message: str) -> TimerInfo:
|
|
"""Update a timer's message and restart it if running.
|
|
|
|
:param identifier: Timer ID or name string.
|
|
:param message: The new message text.
|
|
:return: Snapshot of the updated timer.
|
|
:raises TimerNotFoundError: If no timer matches the identifier.
|
|
"""
|
|
info = await self._repo.get(identifier)
|
|
updated = await self._repo.update_message(info.id, message.strip())
|
|
self._ctx.logger.debug("Updated message for timer #%d.", info.id)
|
|
await self.restart_timer(updated)
|
|
return updated
|
|
|
|
async def set_interval(
|
|
self, identifier: str, interval_type: IntervalType, interval_value: str
|
|
) -> TimerInfo:
|
|
"""Update a timer's interval and restart it if running.
|
|
|
|
:param identifier: Timer ID or name string.
|
|
:param interval_type: The interval type.
|
|
:param interval_value: The interval value string.
|
|
:return: Snapshot of the updated timer.
|
|
:raises TimerNotFoundError: If no timer matches the identifier.
|
|
"""
|
|
info = await self._repo.get(identifier)
|
|
updated = await self._repo.update_interval(
|
|
info.id, interval_type, interval_value
|
|
)
|
|
self._ctx.logger.debug(
|
|
"Updated interval for timer #%d to %s (%s).",
|
|
info.id,
|
|
interval_value,
|
|
interval_type,
|
|
)
|
|
await self.restart_timer(updated)
|
|
return updated
|
|
|
|
async def set_min_chat_lines(self, identifier: str, count: int) -> TimerInfo:
|
|
"""Update a timer's minimum chat line threshold and restart if running.
|
|
|
|
:param identifier: Timer ID or name string.
|
|
:param count: Minimum number of chat lines between firings.
|
|
:return: Snapshot of the updated timer.
|
|
:raises NegativeLineCountError: If the count is negative.
|
|
:raises TimerNotFoundError: If no timer matches the identifier.
|
|
"""
|
|
if count < 0:
|
|
raise NegativeLineCountError
|
|
|
|
info = await self._repo.get(identifier)
|
|
updated = await self._repo.update_min_lines(info.id, count)
|
|
self._ctx.logger.debug(
|
|
"Updated min chat lines for timer #%d to %d.", info.id, count
|
|
)
|
|
await self.restart_timer(updated)
|
|
return updated
|
|
|
|
async def enable_timer(self, identifier: str) -> TimerInfo:
|
|
"""Enable a timer in the database and start it.
|
|
|
|
:param identifier: Timer ID or name string.
|
|
:return: Snapshot of the enabled timer.
|
|
:raises TimerNotFoundError: If no timer matches the identifier.
|
|
:raises TimerAlreadyEnabledError: If the timer is already enabled.
|
|
:raises TimerMessageRequiredError: If the timer has no message set.
|
|
"""
|
|
info = await self._repo.get(identifier)
|
|
|
|
if info.enabled:
|
|
raise TimerAlreadyEnabledError(info)
|
|
|
|
if not info.message:
|
|
raise TimerMessageRequiredError(info)
|
|
|
|
enabled_info = await self._repo.update_enabled(info.id, enabled=True)
|
|
self._ctx.logger.debug("Enabled timer #%d.", info.id)
|
|
self.start_timer(enabled_info)
|
|
return enabled_info
|
|
|
|
async def disable_timer(self, identifier: str) -> TimerInfo:
|
|
"""Disable a timer in the database and stop it.
|
|
|
|
:param identifier: Timer ID or name string.
|
|
:return: Snapshot of the disabled timer.
|
|
:raises TimerNotFoundError: If no timer matches the identifier.
|
|
:raises TimerAlreadyDisabledError: If the timer is already disabled.
|
|
"""
|
|
info = await self._repo.get(identifier)
|
|
|
|
if not info.enabled:
|
|
raise TimerAlreadyDisabledError(info)
|
|
|
|
await self.stop_timer(info.id)
|
|
disabled_info = await self._repo.update_enabled(info.id, enabled=False)
|
|
self._ctx.logger.debug("Disabled timer #%d.", info.id)
|
|
return disabled_info
|
|
|
|
async def list_timers(self) -> list[TimerInfo]:
|
|
"""Return all timers ordered by ID.
|
|
|
|
:return: List of TimerInfo snapshots.
|
|
"""
|
|
return await self._repo.list_all()
|
|
|
|
def count_message(self, message_id: str) -> None:
|
|
"""Count a chat message toward all active timers' thresholds.
|
|
|
|
:param message_id: The message ID to count.
|
|
"""
|
|
for timer in self._timers.values():
|
|
timer.count_message(message_id)
|
|
|
|
def remove_messages(self, message_ids: set[str]) -> None:
|
|
"""Remove message IDs from all active timers' counters.
|
|
|
|
Used when messages are hidden by moderation.
|
|
|
|
:param message_ids: Set of message IDs to remove.
|
|
"""
|
|
for timer in self._timers.values():
|
|
timer.remove_messages(message_ids)
|
|
|
|
def schedule_offline_stop(self) -> None:
|
|
"""Schedule all timers to stop after the offline grace period.
|
|
|
|
Cancels any previously scheduled offline stop first.
|
|
"""
|
|
self.cancel_offline_stop()
|
|
self._offline_stop_task = asyncio.create_task(
|
|
self._delayed_offline_stop(),
|
|
name="Timers Module - Offline grace period timer",
|
|
)
|
|
|
|
def cancel_offline_stop(self) -> None:
|
|
"""Cancel a pending offline stop if one is scheduled."""
|
|
if self._offline_stop_task is not None:
|
|
self._offline_stop_task.cancel()
|
|
self._offline_stop_task = None
|
|
|
|
async def _delayed_offline_stop(self) -> None:
|
|
"""Wait for OFFLINE_STOP_DELAY seconds, then stop all timers."""
|
|
await asyncio.sleep(self.OFFLINE_STOP_DELAY)
|
|
await self.stop_all()
|
|
self._offline_stop_task = None
|
|
self._ctx.logger.info("Timers stopped after stream offline.")
|
|
|
|
|
|
def get_manager(ctx: ModuleContext) -> TimerManager:
|
|
"""Return the TimerManager stored in the module context's state.
|
|
|
|
:param ctx: The module context.
|
|
:return: The active TimerManager.
|
|
:raises RuntimeError: If the manager has not been initialized.
|
|
"""
|
|
manager = ctx.state.get("manager")
|
|
if not isinstance(manager, TimerManager):
|
|
raise RuntimeError("TimerManager is not initialized.") # noqa: TRY004 # state error, not a type error
|
|
return manager
|