Refactored timers module into layered architecture with per-timer async tasks and stream-aware lifecycle.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 13s
CI / Tests (Python 3.13) (push) Successful in 13s
CI / Tests (Python 3.14) (push) Successful in 10s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 5s

This commit is contained in:
2026-04-09 20:39:25 -04:00
parent c4217b6ed8
commit 4fa7050f0a
10 changed files with 2089 additions and 702 deletions
+34 -38
View File
@@ -21,8 +21,6 @@ duration intervals and cron expressions.
from owlbot.api import ModuleContext, on_setup, on_teardown from owlbot.api import ModuleContext, on_setup, on_teardown
from .chat_counter import count_chat_message, handle_visibility_update
# Re-export decorated handlers so the module loader discovers them. # Re-export decorated handlers so the module loader discovers them.
from .commands import ( from .commands import (
addtimer, addtimer,
@@ -34,8 +32,15 @@ from .commands import (
settimerlines, settimerlines,
settimermessage, settimermessage,
) )
from .events import (
count_chat_message,
handle_stream_started,
handle_stream_stopped,
handle_visibility_update,
)
from .manager import TimerManager
from .repository import TimerRepository
from .routes import timer_list_page from .routes import timer_list_page
from .scheduler import TimerScheduler
__all__ = [ __all__ = [
"addtimer", "addtimer",
@@ -43,6 +48,8 @@ __all__ = [
"deletetimer", "deletetimer",
"disabletimer", "disabletimer",
"enabletimer", "enabletimer",
"handle_stream_started",
"handle_stream_stopped",
"handle_visibility_update", "handle_visibility_update",
"listtimers", "listtimers",
"settimerinterval", "settimerinterval",
@@ -58,51 +65,40 @@ __all__ = [
async def setup(ctx: ModuleContext) -> None: async def setup(ctx: ModuleContext) -> None:
"""Initialize the timers module. """Initialize the timers module.
Creates the database schema, initializes chat counters for enabled timers, Creates the database schema, instantiates the TimerManager, and starts
and starts the background scheduler. timers if the stream is currently live.
:param ctx: Module context with config, storage, and other services. :param ctx: Module context with config, storage, and other services.
""" """
await ctx.storage.execute(""" repo = TimerRepository(ctx.storage)
CREATE TABLE IF NOT EXISTS timers ( await repo.setup()
id INTEGER PRIMARY KEY AUTOINCREMENT, manager = TimerManager(ctx, repo)
name TEXT UNIQUE, ctx.state["manager"] = manager
message TEXT,
interval_value TEXT NOT NULL DEFAULT '15m', try:
interval_type TEXT NOT NULL DEFAULT 'simple', status = await ctx.owncast_client.get_status()
min_chat_lines INTEGER NOT NULL DEFAULT 0, if status.get("online", False):
enabled INTEGER NOT NULL DEFAULT 0, await manager.start_all()
created_at TEXT NOT NULL, ctx.logger.info("Stream is live. Timers started.")
updated_at TEXT NOT NULL, else:
last_fired_at TEXT ctx.logger.info("Stream is offline. Timers will start on stream start.")
except Exception:
ctx.logger.warning(
"Could not check stream status. Timers will start on stream start.",
exc_info=True,
) )
""")
rows = await ctx.storage.fetch_all(
"SELECT id FROM timers WHERE enabled = 1 AND message IS NOT NULL"
)
timer_ids = [row["id"] for row in rows]
sched = TimerScheduler()
sched.init_counted_ids(timer_ids)
ctx.logger.debug("Initialized chat counters for timer IDs: %s", timer_ids)
count = await ctx.storage.fetch_value("SELECT COUNT(*) FROM timers")
ctx.logger.info(f"Loaded {count} timer(s) from database.")
sched.start(ctx)
ctx.state["scheduler"] = sched
@on_teardown @on_teardown
async def teardown(ctx: ModuleContext) -> None: async def teardown(ctx: ModuleContext) -> None:
"""Clean up the timers module. """Clean up the timers module.
Stops the background scheduler task. Cancels any pending offline stop and stops all running timer tasks.
:param ctx: Module context. :param ctx: Module context.
""" """
scheduler: TimerScheduler | None = ctx.state.get("scheduler") manager: TimerManager | None = ctx.state.get("manager")
if scheduler is not None: if manager is not None:
await scheduler.stop(ctx) manager.cancel_offline_stop()
ctx.state["scheduler"] = None await manager.stop_all()
ctx.state["manager"] = None
+70 -201
View File
@@ -14,65 +14,19 @@
"""Moderator commands for managing timers.""" """Moderator commands for managing timers."""
from __future__ import annotations
import re
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from owlbot.api import CommandContext, on_command from owlbot.api import CommandContext, on_command
if TYPE_CHECKING: from .manager import get_manager
import aiosqlite from .types import (
InvalidTimerNameError,
from .scheduler import get_scheduler, parse_interval NegativeLineCountError,
TimerAlreadyDisabledError,
# Timer names: must start with a letter or underscore to avoid ambiguity with TimerAlreadyEnabledError,
# numeric timer IDs. Letters, numbers, underscores, max 32 chars. TimerMessageRequiredError,
_NAME_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]{0,31}$") TimerNameTakenError,
TimerNotFoundError,
parse_interval,
async def _resolve_timer(ctx: CommandContext, identifier: str) -> aiosqlite.Row | None: )
"""Resolve a timer by numeric ID or name.
Tries parsing as an integer first, then falls back to a name lookup.
:param ctx: The command context.
:param identifier: A timer ID or name string.
:return: The timer row, or None if not found.
"""
try:
timer_id = int(identifier)
ctx.logger.debug("Resolving timer by ID: %d", timer_id)
row = await ctx.storage.fetch_one(
"SELECT * FROM timers WHERE id = ?", (timer_id,)
)
if row:
ctx.logger.debug("Found timer by ID: %s", _timer_display(row))
return row
except ValueError:
pass
ctx.logger.debug("Resolving timer by name: %s", identifier)
row = await ctx.storage.fetch_one(
"SELECT * FROM timers WHERE name = ?", (identifier.lower(),)
)
if row:
ctx.logger.debug("Found timer by name: %s", _timer_display(row))
else:
ctx.logger.debug("Timer '%s' not found.", identifier)
return row
def _timer_display(row: aiosqlite.Row) -> str:
"""Format a timer's display identifier.
:param row: Timer database row.
:return: Display string like "timer_name (#3)" or "timer #3".
"""
if row["name"]:
return f"{row['name']} (#{row['id']})"
return f"timer #{row['id']}"
@on_command("addtimer", requires_moderator=True) @on_command("addtimer", requires_moderator=True)
@@ -84,38 +38,24 @@ async def addtimer(ctx: CommandContext) -> None:
:param ctx: The command context. :param ctx: The command context.
""" """
args = ctx.args_list args = ctx.args_list
name = None name = args[0].lower() if args else None
if args: try:
name = args[0].lower() info = await get_manager(ctx.module).create_timer(name)
if not _NAME_PATTERN.match(name): except InvalidTimerNameError:
await ctx.owncast_client.send_message( await ctx.owncast_client.send_message(
"Invalid timer name. Must start with a letter or underscore, " "Invalid timer name. Must start with a letter or underscore, "
"followed by letters, numbers, or underscores (max 32 characters)." "followed by letters, numbers, or underscores "
"(max 32 characters)."
) )
return return
except TimerNameTakenError as e:
existing = await ctx.storage.fetch_one(
"SELECT id FROM timers WHERE name = ?", (name,)
)
if existing:
await ctx.owncast_client.send_message( await ctx.owncast_client.send_message(
f"A timer named '{name}' already exists (#{existing['id']})." f"A timer named '{e.name}' already exists (#{e.existing_id})."
) )
return return
now = datetime.now(UTC).isoformat() await ctx.owncast_client.send_message(f"Timer {info.display} created.")
cursor = await ctx.storage.execute(
"INSERT INTO timers (name, message, interval_value, interval_type, "
"min_chat_lines, enabled, created_at, updated_at) "
"VALUES (?, NULL, '15m', 'simple', 0, 0, ?, ?)",
(name, now, now),
)
timer_id = cursor.lastrowid
display = f"{name} (#{timer_id})" if name else f"#{timer_id}"
ctx.logger.info(f"Timer {display} created by {ctx.user.display_name}.")
await ctx.owncast_client.send_message(f"Timer {display} created.")
@on_command("settimermessage", requires_moderator=True) @on_command("settimermessage", requires_moderator=True)
@@ -127,35 +67,21 @@ async def settimermessage(ctx: CommandContext) -> None:
:param ctx: The command context. :param ctx: The command context.
""" """
parts = ctx.args.split(maxsplit=1) parts = ctx.args.split(maxsplit=1)
if len(parts) < 2: if len(parts) < 2 or not parts[1].strip():
await ctx.owncast_client.send_message( await ctx.owncast_client.send_message(
"Usage: !settimermessage <id|name> <message>" "Usage: !settimermessage <id|name> <message>"
) )
return return
identifier, message = parts identifier, message = parts
if not message.strip():
await ctx.owncast_client.send_message( try:
"Usage: !settimermessage <id|name> <message>" info = await get_manager(ctx.module).set_message(identifier, message)
) except TimerNotFoundError as e:
await ctx.owncast_client.send_message(f"Timer '{e.identifier}' not found.")
return return
row = await _resolve_timer(ctx, identifier) await ctx.owncast_client.send_message(f"Message set for {info.display}.")
if not row:
await ctx.owncast_client.send_message(f"Timer '{identifier}' not found.")
return
now = datetime.now(UTC).isoformat()
await ctx.storage.execute(
"UPDATE timers SET message = ?, updated_at = ? WHERE id = ?",
(message, now, row["id"]),
)
display = _timer_display(row)
ctx.logger.info(f"Timer {display} message updated by {ctx.user.display_name}.")
await ctx.owncast_client.send_message(f"Message set for {display}.")
if row["enabled"]:
get_scheduler(ctx.module).reschedule()
@on_command("settimerinterval", requires_moderator=True) @on_command("settimerinterval", requires_moderator=True)
@@ -176,10 +102,6 @@ async def settimerinterval(ctx: CommandContext) -> None:
return return
identifier, interval_str = parts identifier, interval_str = parts
row = await _resolve_timer(ctx, identifier)
if not row:
await ctx.owncast_client.send_message(f"Timer '{identifier}' not found.")
return
try: try:
interval_type, normalized = parse_interval(interval_str) interval_type, normalized = parse_interval(interval_str)
@@ -187,22 +109,18 @@ async def settimerinterval(ctx: CommandContext) -> None:
await ctx.owncast_client.send_message(str(e)) await ctx.owncast_client.send_message(str(e))
return return
now = datetime.now(UTC).isoformat() try:
await ctx.storage.execute( info = await get_manager(ctx.module).set_interval(
"UPDATE timers SET interval_type = ?, interval_value = ?, updated_at = ? " identifier, interval_type, normalized
"WHERE id = ?",
(interval_type, normalized, now, row["id"]),
) )
except TimerNotFoundError as e:
await ctx.owncast_client.send_message(f"Timer '{e.identifier}' not found.")
return
display = _timer_display(row)
ctx.logger.info(
f"Timer {display} interval set to {normalized} by {ctx.user.display_name}."
)
await ctx.owncast_client.send_message( await ctx.owncast_client.send_message(
f"Interval for {display} set to {normalized} ({interval_type})." f"Interval for {info.display} set to "
f"{info.interval_value} ({info.interval_type})."
) )
if row["enabled"]:
get_scheduler(ctx.module).reschedule()
@on_command("settimerlines", requires_moderator=True) @on_command("settimerlines", requires_moderator=True)
@@ -218,46 +136,32 @@ async def settimerlines(ctx: CommandContext) -> None:
await ctx.owncast_client.send_message("Usage: !settimerlines <id|name> <count>") await ctx.owncast_client.send_message("Usage: !settimerlines <id|name> <count>")
return return
identifier = args[0]
row = await _resolve_timer(ctx, identifier)
if not row:
await ctx.owncast_client.send_message(f"Timer '{identifier}' not found.")
return
try: try:
count = int(args[1]) count = int(args[1])
except ValueError: except ValueError:
await ctx.owncast_client.send_message("Line count must be a number.") await ctx.owncast_client.send_message("Line count must be a number.")
return return
if count < 0: try:
info = await get_manager(ctx.module).set_min_chat_lines(args[0], count)
except TimerNotFoundError as e:
await ctx.owncast_client.send_message(f"Timer '{e.identifier}' not found.")
return
except NegativeLineCountError:
await ctx.owncast_client.send_message("Line count cannot be negative.") await ctx.owncast_client.send_message("Line count cannot be negative.")
return return
now = datetime.now(UTC).isoformat() lines = info.min_chat_lines
await ctx.storage.execute( label = f"{lines} line(s)" if lines > 0 else "disabled"
"UPDATE timers SET min_chat_lines = ?, updated_at = ? WHERE id = ?",
(count, now, row["id"]),
)
display = _timer_display(row)
label = f"{count} line(s)" if count > 0 else "disabled"
ctx.logger.info(
f"Timer {display} min chat lines set to {count} by {ctx.user.display_name}."
)
await ctx.owncast_client.send_message( await ctx.owncast_client.send_message(
f"Minimum chat lines for {display} set to {label}." f"Minimum chat lines for {info.display} set to {label}."
) )
if row["enabled"]:
get_scheduler(ctx.module).reschedule()
@on_command("enabletimer", requires_moderator=True) @on_command("enabletimer", requires_moderator=True)
async def enabletimer(ctx: CommandContext) -> None: async def enabletimer(ctx: CommandContext) -> None:
"""Enable a timer. """Enable a timer.
Won't enable a timer that has no message set.
Usage: !enabletimer <id|name> Usage: !enabletimer <id|name>
:param ctx: The command context. :param ctx: The command context.
@@ -267,38 +171,24 @@ async def enabletimer(ctx: CommandContext) -> None:
await ctx.owncast_client.send_message("Usage: !enabletimer <id|name>") await ctx.owncast_client.send_message("Usage: !enabletimer <id|name>")
return return
identifier = args[0] try:
row = await _resolve_timer(ctx, identifier) info = await get_manager(ctx.module).enable_timer(args[0])
if not row: except TimerNotFoundError as e:
await ctx.owncast_client.send_message(f"Timer '{identifier}' not found.") await ctx.owncast_client.send_message(f"Timer '{e.identifier}' not found.")
return return
except TimerAlreadyEnabledError as e:
display = _timer_display(row)
if row["enabled"]:
await ctx.owncast_client.send_message(f"Timer {display} is already enabled.")
return
if not row["message"]:
await ctx.owncast_client.send_message( await ctx.owncast_client.send_message(
f"Cannot enable {display}: no message set. " f"Timer {e.info.display} is already enabled."
)
return
except TimerMessageRequiredError as e:
await ctx.owncast_client.send_message(
f"Cannot enable {e.info.display}: no message set. "
f"Use !settimermessage to set one first." f"Use !settimermessage to set one first."
) )
return return
now = datetime.now(UTC).isoformat() await ctx.owncast_client.send_message(f"Timer {info.display} enabled.")
await ctx.storage.execute(
"UPDATE timers SET enabled = 1, updated_at = ? WHERE id = ?",
(now, row["id"]),
)
# Start tracking chat lines for this timer.
get_scheduler(ctx.module).counted_ids[row["id"]] = set()
ctx.logger.debug("Started chat line tracking for timer %s.", display)
ctx.logger.info(f"Timer {display} enabled by {ctx.user.display_name}.")
await ctx.owncast_client.send_message(f"Timer {display} enabled.")
get_scheduler(ctx.module).reschedule()
@on_command("disabletimer", requires_moderator=True) @on_command("disabletimer", requires_moderator=True)
@@ -314,31 +204,18 @@ async def disabletimer(ctx: CommandContext) -> None:
await ctx.owncast_client.send_message("Usage: !disabletimer <id|name>") await ctx.owncast_client.send_message("Usage: !disabletimer <id|name>")
return return
identifier = args[0] try:
row = await _resolve_timer(ctx, identifier) info = await get_manager(ctx.module).disable_timer(args[0])
if not row: except TimerNotFoundError as e:
await ctx.owncast_client.send_message(f"Timer '{identifier}' not found.") await ctx.owncast_client.send_message(f"Timer '{e.identifier}' not found.")
return return
except TimerAlreadyDisabledError as e:
display = _timer_display(row) await ctx.owncast_client.send_message(
f"Timer {e.info.display} is already disabled."
if not row["enabled"]:
await ctx.owncast_client.send_message(f"Timer {display} is already disabled.")
return
now = datetime.now(UTC).isoformat()
await ctx.storage.execute(
"UPDATE timers SET enabled = 0, updated_at = ? WHERE id = ?",
(now, row["id"]),
) )
return
# Stop tracking chat lines for this timer. await ctx.owncast_client.send_message(f"Timer {info.display} disabled.")
get_scheduler(ctx.module).counted_ids.pop(row["id"], None)
ctx.logger.debug("Stopped chat line tracking for timer %s.", display)
ctx.logger.info(f"Timer {display} disabled by {ctx.user.display_name}.")
await ctx.owncast_client.send_message(f"Timer {display} disabled.")
get_scheduler(ctx.module).reschedule()
@on_command("deletetimer", requires_moderator=True) @on_command("deletetimer", requires_moderator=True)
@@ -354,21 +231,13 @@ async def deletetimer(ctx: CommandContext) -> None:
await ctx.owncast_client.send_message("Usage: !deletetimer <id|name>") await ctx.owncast_client.send_message("Usage: !deletetimer <id|name>")
return return
identifier = args[0] try:
row = await _resolve_timer(ctx, identifier) info = await get_manager(ctx.module).delete_timer(args[0])
if not row: except TimerNotFoundError as e:
await ctx.owncast_client.send_message(f"Timer '{identifier}' not found.") await ctx.owncast_client.send_message(f"Timer '{e.identifier}' not found.")
return return
display = _timer_display(row) await ctx.owncast_client.send_message(f"Timer {info.display} deleted.")
await ctx.storage.execute("DELETE FROM timers WHERE id = ?", (row["id"],))
# Stop tracking chat lines.
get_scheduler(ctx.module).counted_ids.pop(row["id"], None)
ctx.logger.info(f"Timer {display} deleted by {ctx.user.display_name}.")
await ctx.owncast_client.send_message(f"Timer {display} deleted.")
get_scheduler(ctx.module).reschedule()
@on_command("listtimers", requires_moderator=True, cooldown=15) @on_command("listtimers", requires_moderator=True, cooldown=15)
@@ -12,27 +12,57 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""Chat message counting for the timers module. """Event handlers for the timers module."""
Tracks chat messages per timer to support the minimum chat lines threshold.
Bot messages and hidden messages are excluded from the count.
"""
from owlbot.api import ( from owlbot.api import (
ChatEvent, ChatEvent,
EventContext, EventContext,
EventType, EventType,
Priority, Priority,
StreamStartedEvent,
StreamStoppedEvent,
VisibilityUpdateEvent, VisibilityUpdateEvent,
on_event, on_event,
) )
from .scheduler import get_scheduler from .manager import get_manager
@on_event(EventType.STREAM_STARTED)
async def handle_stream_started(ctx: EventContext[StreamStartedEvent]) -> None:
"""Start timers when the stream goes live.
Cancels any pending offline stop (stream returned within grace period)
and starts all enabled timers.
:param ctx: The event context.
"""
manager = get_manager(ctx.module)
manager.cancel_offline_stop()
await manager.start_all()
ctx.logger.info("Stream started. Timers activated.")
@on_event(EventType.STREAM_STOPPED)
async def handle_stream_stopped(ctx: EventContext[StreamStoppedEvent]) -> None:
"""Schedule timers to stop after a grace period.
If the stream returns within the grace period, the stop is cancelled
by handle_stream_started.
:param ctx: The event context.
"""
manager = get_manager(ctx.module)
manager.schedule_offline_stop()
ctx.logger.info(
"Stream stopped. Timers will stop in %.0f seconds.",
manager.OFFLINE_STOP_DELAY,
)
@on_event(EventType.CHAT, priority=Priority.LOWEST) @on_event(EventType.CHAT, priority=Priority.LOWEST)
async def count_chat_message(ctx: EventContext[ChatEvent]) -> None: async def count_chat_message(ctx: EventContext[ChatEvent]) -> None:
"""Count a chat message for all tracked timers. """Count a chat message for all active timers.
Runs at lowest priority so all other CHAT handlers (moderation, etc.) Runs at lowest priority so all other CHAT handlers (moderation, etc.)
execute first. Skips bot messages and hidden messages. execute first. Skips bot messages and hidden messages.
@@ -42,19 +72,9 @@ async def count_chat_message(ctx: EventContext[ChatEvent]) -> None:
event = ctx.event event = ctx.event
if event.user.is_bot or not event.is_visible: if event.user.is_bot or not event.is_visible:
reason = "bot message" if event.user.is_bot else "hidden message"
ctx.logger.debug("Skipping chat count for %s: %s.", event.message_id, reason)
return return
counted_ids = get_scheduler(ctx.module).counted_ids get_manager(ctx.module).count_message(event.message_id)
ctx.logger.debug(
"Counting message %s from %s for %d timer(s).",
event.message_id,
event.user.display_name,
len(counted_ids),
)
for timer_set in counted_ids.values():
timer_set.add(event.message_id)
@on_event(EventType.VISIBILITY_UPDATE, priority=Priority.LOWEST) @on_event(EventType.VISIBILITY_UPDATE, priority=Priority.LOWEST)
@@ -72,12 +92,4 @@ async def handle_visibility_update(ctx: EventContext[VisibilityUpdateEvent]) ->
if event.is_visible: if event.is_visible:
return return
affected = set(event.message_ids) get_manager(ctx.module).remove_messages(set(event.message_ids))
counted_ids = get_scheduler(ctx.module).counted_ids
ctx.logger.debug(
"Removing %d hidden message(s) from chat counts.",
len(affected),
)
for timer_set in counted_ids.values():
timer_set -= affected
+459
View File
@@ -0,0 +1,459 @@
# 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.")
return manager
+219
View File
@@ -0,0 +1,219 @@
# 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.
"""Persistence layer for timer records."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from .types import (
NAME_PATTERN,
IntervalType,
InvalidTimerNameError,
TimerInfo,
TimerNameTakenError,
TimerNotFoundError,
)
if TYPE_CHECKING:
from owlbot.api.storage import ModuleStorage
class TimerRepository:
"""Handles all database operations for timer records."""
def __init__(self, storage: ModuleStorage) -> None:
"""Initialize with a module storage instance.
:param storage: The module's storage backend.
"""
self._storage = storage
async def setup(self) -> None:
"""Create the timers table if it does not exist."""
await self._storage.execute("""
CREATE TABLE IF NOT EXISTS timers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE,
message TEXT,
interval_value TEXT NOT NULL DEFAULT '15m',
interval_type TEXT NOT NULL DEFAULT 'simple',
min_chat_lines INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_fired_at TEXT
)
""")
async def get(self, identifier: str) -> TimerInfo:
"""Resolve a timer by numeric ID or name.
Tries parsing as an integer first, then falls back to a name lookup.
:param identifier: A timer ID or name string.
:return: The resolved TimerInfo.
:raises TimerNotFoundError: If no timer matches the identifier.
"""
try:
timer_id = int(identifier)
row = await self._storage.fetch_one(
"SELECT * FROM timers WHERE id = ?", (timer_id,)
)
if row:
return TimerInfo.from_row(row)
except ValueError:
pass
row = await self._storage.fetch_one(
"SELECT * FROM timers WHERE name = ?", (identifier.lower(),)
)
if row is None:
raise TimerNotFoundError(identifier)
return TimerInfo.from_row(row)
async def create(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.
"""
if name is not None:
if not NAME_PATTERN.match(name):
raise InvalidTimerNameError(name)
existing = await self._storage.fetch_one(
"SELECT id FROM timers WHERE name = ?", (name,)
)
if existing:
raise TimerNameTakenError(name, existing["id"])
now = datetime.now(UTC).isoformat()
row = await self._storage.fetch_one(
"INSERT INTO timers (name, message, interval_value, interval_type, "
"min_chat_lines, enabled, created_at, updated_at) "
"VALUES (?, NULL, '15m', 'simple', 0, 0, ?, ?) RETURNING *",
(name, now, now),
)
if row is None:
raise RuntimeError("INSERT RETURNING did not produce a row")
return TimerInfo.from_row(row)
async def delete(self, timer_id: int) -> None:
"""Delete a timer record from the database.
:param timer_id: Database ID of the timer.
"""
await self._storage.execute("DELETE FROM timers WHERE id = ?", (timer_id,))
async def update_message(self, timer_id: int, message: str) -> TimerInfo:
"""Update a timer's message text.
:param timer_id: Database ID of the timer.
:param message: The new message text.
:return: Snapshot of the updated timer.
"""
now = datetime.now(UTC).isoformat()
row = await self._storage.fetch_one(
"UPDATE timers SET message = ?, updated_at = ? WHERE id = ? RETURNING *",
(message, now, timer_id),
)
if row is None:
raise TimerNotFoundError(timer_id)
return TimerInfo.from_row(row)
async def update_interval(
self, timer_id: int, interval_type: IntervalType, interval_value: str
) -> TimerInfo:
"""Update a timer's interval type and value.
:param timer_id: Database ID of the timer.
:param interval_type: The interval type.
:param interval_value: The interval value string.
:return: Snapshot of the updated timer.
"""
now = datetime.now(UTC).isoformat()
row = await self._storage.fetch_one(
"UPDATE timers SET interval_type = ?, interval_value = ?, "
"updated_at = ? WHERE id = ? RETURNING *",
(interval_type, interval_value, now, timer_id),
)
if row is None:
raise TimerNotFoundError(timer_id)
return TimerInfo.from_row(row)
async def update_min_lines(self, timer_id: int, count: int) -> TimerInfo:
"""Update a timer's minimum chat line threshold.
:param timer_id: Database ID of the timer.
:param count: Minimum number of chat lines between firings.
:return: Snapshot of the updated timer.
"""
now = datetime.now(UTC).isoformat()
row = await self._storage.fetch_one(
"UPDATE timers SET min_chat_lines = ?, updated_at = ? "
"WHERE id = ? RETURNING *",
(count, now, timer_id),
)
if row is None:
raise TimerNotFoundError(timer_id)
return TimerInfo.from_row(row)
async def update_enabled(self, timer_id: int, *, enabled: bool) -> TimerInfo:
"""Update a timer's enabled state.
:param timer_id: Database ID of the timer.
:param enabled: Whether the timer should be enabled.
:return: Snapshot of the updated timer.
"""
now = datetime.now(UTC).isoformat()
row = await self._storage.fetch_one(
"UPDATE timers SET enabled = ?, updated_at = ? WHERE id = ? RETURNING *",
(int(enabled), now, timer_id),
)
if row is None:
raise TimerNotFoundError(timer_id)
return TimerInfo.from_row(row)
async def update_last_fired_at(self, timer_id: int, timestamp: str) -> None:
"""Persist the last fired timestamp for a timer.
:param timer_id: Database ID of the timer.
:param timestamp: ISO-format timestamp string.
"""
await self._storage.execute(
"UPDATE timers SET last_fired_at = ? WHERE id = ?",
(timestamp, timer_id),
)
async def list_all(self) -> list[TimerInfo]:
"""Return all timers ordered by ID.
:return: List of TimerInfo snapshots.
"""
rows = await self._storage.fetch_all("SELECT * FROM timers ORDER BY id")
return [TimerInfo.from_row(row) for row in rows]
async def list_startable(self) -> list[TimerInfo]:
"""Return all enabled timers that have a message set.
:return: List of TimerInfo snapshots for startable timers.
"""
rows = await self._storage.fetch_all(
"SELECT * FROM timers WHERE enabled = 1 AND message IS NOT NULL"
)
return [TimerInfo.from_row(row) for row in rows]
+3 -30
View File
@@ -14,12 +14,12 @@
"""Web routes for the timers module.""" """Web routes for the timers module."""
from datetime import datetime
from aiohttp import web from aiohttp import web
from owlbot.api import RouteContext, on_route from owlbot.api import RouteContext, on_route
from .manager import get_manager
@on_route("/list", methods=["GET"]) @on_route("/list", methods=["GET"])
async def timer_list_page(ctx: RouteContext) -> web.Response: async def timer_list_page(ctx: RouteContext) -> web.Response:
@@ -31,33 +31,6 @@ async def timer_list_page(ctx: RouteContext) -> web.Response:
:param ctx: The route context. :param ctx: The route context.
:return: HTML response with the timer list table. :return: HTML response with the timer list table.
""" """
rows = await ctx.storage.fetch_all( timers = await get_manager(ctx.module).list_timers()
"SELECT id, name, message, interval_type, interval_value, "
"min_chat_lines, enabled, last_fired_at "
"FROM timers ORDER BY id"
)
timers = []
for row in rows:
last_fired = row["last_fired_at"]
if last_fired:
last_fired = datetime.fromisoformat(last_fired).strftime(
"%Y-%m-%d %H:%M:%S UTC"
)
else:
last_fired = "Never"
timers.append(
{
"id": row["id"],
"name": row["name"] or "",
"message": row["message"] or "(not set)",
"interval": f"{row['interval_value']} ({row['interval_type']})",
"min_lines": row["min_chat_lines"],
"status": "Enabled" if row["enabled"] else "Disabled",
"last_fired": last_fired,
}
)
page = ctx.templates.render("list.html", timers=timers) page = ctx.templates.render("list.html", timers=timers)
return web.Response(text=page, content_type="text/html") return web.Response(text=page, content_type="text/html")
-392
View File
@@ -1,392 +0,0 @@
# 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.
"""Background scheduler and interval parsing for the timers module.
Manages the scheduler task that sleeps until the next timer is due and fires
messages at the right moment. Also provides interval parsing utilities and
shared state for chat line counting.
"""
from __future__ import annotations
import asyncio
import contextlib
import re
from datetime import UTC, datetime, timedelta
from enum import StrEnum
from typing import TYPE_CHECKING
from cronsim import CronSim, CronSimError
if TYPE_CHECKING:
import aiosqlite
from owlbot.api import ModuleContext
class IntervalType(StrEnum):
"""Supported interval types for timer scheduling."""
SIMPLE = "simple"
CRON = "cron"
# Regex for simple duration strings like "30s", "5m", "1h", "2h30m".
_DURATION_PATTERN = re.compile(r"^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$", re.IGNORECASE)
_MIN_SIMPLE_SECONDS = 30
_MIN_CRON_MINUTES = 1
def parse_interval(value: str) -> tuple[IntervalType, str]:
"""Parse and validate an interval string.
Simple durations (no spaces) are parsed with a regex. Cron expressions
(contain spaces) are validated with cronsim. Returns the detected type
and the normalized value.
:param value: Raw interval string from the user.
:return: Tuple of (interval_type, normalized_value).
:raises ValueError: If the value is not a valid interval.
"""
value = value.strip()
if not value:
raise ValueError("Interval cannot be empty.")
if " " in value:
return _parse_cron(value)
return _parse_simple(value)
def _parse_simple(value: str) -> tuple[IntervalType, str]:
"""Parse and validate a simple duration string.
:param value: Duration string like "30s", "5m", "1h30m".
:return: Tuple of (IntervalType.SIMPLE, value).
:raises ValueError: If the format is invalid or below minimum.
"""
match = _DURATION_PATTERN.match(value)
if not match or not any(match.groups()):
raise ValueError(
f"Invalid duration: {value}. Use a format like 30s, 5m, 1h, or 2h30m."
)
hours = int(match.group(1) or 0)
minutes = int(match.group(2) or 0)
seconds = int(match.group(3) or 0)
total = hours * 3600 + minutes * 60 + seconds
if total < _MIN_SIMPLE_SECONDS:
raise ValueError(
f"Minimum interval is {_MIN_SIMPLE_SECONDS} seconds. Got {total}s."
)
return (IntervalType.SIMPLE, value.lower())
def _parse_cron(value: str) -> tuple[IntervalType, str]:
"""Validate a cron expression.
:param value: Cron expression string (5 fields).
:return: Tuple of (IntervalType.CRON, value).
:raises ValueError: If the expression is invalid or fires too frequently.
"""
try:
now = datetime.now(UTC)
it = CronSim(value, now)
first = next(it)
second = next(it)
except CronSimError as e:
raise ValueError(f"Invalid cron expression: {value}") from e
gap = (second - first).total_seconds()
if gap < _MIN_CRON_MINUTES * 60:
raise ValueError(
f"Cron interval too frequent. Minimum gap is {_MIN_CRON_MINUTES} minute(s)."
)
return (IntervalType.CRON, value)
def _duration_to_seconds(value: str) -> int:
"""Convert a validated simple duration string to total seconds.
:param value: A previously validated duration string.
:return: Total seconds.
"""
match = _DURATION_PATTERN.match(value)
if not match:
return 0
hours = int(match.group(1) or 0)
minutes = int(match.group(2) or 0)
seconds = int(match.group(3) or 0)
return hours * 3600 + minutes * 60 + seconds
def _next_fire_time(row: aiosqlite.Row, now: datetime) -> datetime | None:
"""Compute when a timer will next be time-due.
:param row: Database row with interval_type, interval_value, last_fired_at.
:param now: Current UTC time.
:return: The datetime when this timer is next due, or None if it can't fire.
"""
last_fired = row["last_fired_at"]
last_fired_dt = datetime.fromisoformat(last_fired) if last_fired else None
# Never fired before: immediately due.
if last_fired_dt is None:
return now
if row["interval_type"] == IntervalType.SIMPLE:
interval_secs = _duration_to_seconds(row["interval_value"])
if interval_secs <= 0:
return None
return last_fired_dt + timedelta(seconds=interval_secs)
# Cron timer: wait for the next scheduled tick.
try:
return next(CronSim(row["interval_value"], last_fired_dt))
except (ValueError, KeyError, CronSimError):
return None
def _is_timer_due(row: aiosqlite.Row, now: datetime) -> bool:
"""Check whether a timer should fire based on its interval and last fire time.
:param row: Database row with interval_type, interval_value, last_fired_at.
:param now: Current UTC time.
:return: True if the timer is due to fire.
"""
fire_time = _next_fire_time(row, now)
if fire_time is None:
return False
return fire_time <= now
class TimerScheduler:
"""Encapsulates the mutable runtime state for the timer scheduler.
Holds the background task, wake event, and per-timer chat line counters.
"""
def __init__(self) -> None:
"""Initialize an empty scheduler with no running task."""
self._task: asyncio.Task[None] | None = None
self._counted_ids: dict[int, set[str]] = {}
self._wake_event: asyncio.Event | None = None
@property
def counted_ids(self) -> dict[int, set[str]]:
"""Per-timer sets of counted message IDs."""
return self._counted_ids
def init_counted_ids(self, timer_ids: list[int]) -> None:
"""Initialize empty counter sets for the given timer IDs.
Called during module setup to prepare tracking for enabled timers.
:param timer_ids: List of enabled timer IDs to track.
"""
self._counted_ids = {tid: set() for tid in timer_ids}
def start(self, ctx: ModuleContext) -> None:
"""Start the background scheduler task.
:param ctx: The module context.
"""
if self._task is not None:
return
self._wake_event = asyncio.Event()
self._task = asyncio.create_task(self._scheduler_loop(ctx, self._wake_event))
ctx.logger.debug("Timer scheduler started.")
async def stop(self, ctx: ModuleContext) -> None:
"""Cancel the background scheduler task and wait for it to exit.
:param ctx: The module context.
"""
if self._task is not None:
self._task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._task
self._task = None
self._wake_event = None
ctx.logger.info("Timer scheduler stopped.")
def reschedule(self) -> None:
"""Wake the scheduler so it recalculates the next fire time.
Called by timer management commands when timer state changes.
"""
if self._wake_event is not None:
self._wake_event.set()
async def _scheduler_loop(self, ctx: ModuleContext, wake: asyncio.Event) -> None:
"""Background loop that sleeps until the next timer is due and fires it.
Uses an asyncio.Event to allow early wake-ups when timer state changes.
:param ctx: The module context.
:param wake: Event used to signal early wake-ups.
"""
ctx.logger.debug("Scheduler loop running with precise sleep.")
while True:
delay, display = await _compute_next_delay(ctx)
wake.clear()
try:
if delay is not None:
ctx.logger.debug(
"Next timer due in %.1fs (timer %s). Sleeping until then.",
delay,
display,
)
await asyncio.wait_for(wake.wait(), timeout=delay)
ctx.logger.debug(
"Woken early by reschedule event. "
"Recalculating next fire time.",
)
else:
ctx.logger.debug(
"No timers scheduled. Sleeping until woken by a timer change.",
)
await wake.wait()
ctx.logger.debug(
"Woken early by reschedule event. "
"Recalculating next fire time.",
)
except TimeoutError:
ctx.logger.debug("Sleep finished. Checking for due timers.")
try:
await self._tick(ctx)
except Exception:
ctx.logger.exception("Scheduler tick failed.")
async def _tick(self, ctx: ModuleContext) -> None:
"""Single scheduler tick: query enabled timers and fire any that are due.
:param ctx: The module context.
"""
rows = await ctx.storage.fetch_all(
"SELECT id, name, message, interval_type, interval_value, "
"min_chat_lines, last_fired_at "
"FROM timers WHERE enabled = 1 AND message IS NOT NULL"
)
now = datetime.now(UTC)
ctx.logger.debug("Tick: %d enabled timer(s) to check.", len(rows))
for row in rows:
timer_id = row["id"]
display = row["name"] or f"#{timer_id}"
if not _is_timer_due(row, now):
ctx.logger.debug("Timer %s is not due yet.", display)
continue
min_lines = row["min_chat_lines"]
if min_lines > 0:
counted = len(self._counted_ids.get(timer_id, set()))
if counted < min_lines:
now_iso = now.isoformat()
await ctx.storage.execute(
"UPDATE timers SET last_fired_at = ? WHERE id = ?",
(now_iso, timer_id),
)
ctx.logger.info(
"Timer %s skipped: chat line threshold not met (%d/%d). "
"Schedule advanced.",
display,
counted,
min_lines,
)
continue
# Fire the timer. Wrapped in try/except so a single failing timer
# (e.g. network error) does not prevent other timers from firing.
try:
ctx.logger.debug("Firing timer %s.", display)
await ctx.owncast_client.send_message(row["message"])
now_iso = now.isoformat()
await ctx.storage.execute(
"UPDATE timers SET last_fired_at = ? WHERE id = ?",
(now_iso, timer_id),
)
self._counted_ids[timer_id] = set()
ctx.logger.info(f"Timer {display} fired.")
except Exception:
ctx.logger.exception("Failed to fire timer %s.", display)
async def _compute_next_delay(ctx: ModuleContext) -> tuple[float | None, str | None]:
"""Query enabled timers and return seconds until the soonest one is time-due.
:param ctx: The module context.
:return: Tuple of (seconds until next timer, display name of that timer),
or (None, None) if no timers are scheduled.
"""
rows = await ctx.storage.fetch_all(
"SELECT id, name, interval_type, interval_value, last_fired_at "
"FROM timers WHERE enabled = 1 AND message IS NOT NULL"
)
now = datetime.now(UTC)
soonest_delay: float | None = None
soonest_display: str | None = None
for row in rows:
timer_id = row["id"]
display = row["name"] or f"#{timer_id}"
fire_time = _next_fire_time(row, now)
if fire_time is None:
continue
delay = (fire_time - now).total_seconds()
ctx.logger.debug(
"Timer %s next due at %s (in %.1fs).",
display,
fire_time,
delay,
)
if soonest_delay is None or delay < soonest_delay:
soonest_delay = delay
soonest_display = display
if soonest_delay is not None:
soonest_delay = max(0.0, soonest_delay)
ctx.logger.debug(
"Soonest timer is %s in %.1fs.",
soonest_display,
soonest_delay,
)
return soonest_delay, soonest_display
def get_scheduler(ctx: ModuleContext) -> TimerScheduler:
"""Return the scheduler stored in the module context's state.
:param ctx: The module context.
:return: The active TimerScheduler.
:raises RuntimeError: If the scheduler has not been initialized.
"""
scheduler = ctx.state.get("scheduler")
if not isinstance(scheduler, TimerScheduler):
raise RuntimeError("TimerScheduler is not initialized.")
return scheduler
@@ -19,12 +19,12 @@
{% for timer in timers %} {% for timer in timers %}
<tr> <tr>
<td class="text-center">{{ timer.id }}</td> <td class="text-center">{{ timer.id }}</td>
<td>{{ timer.name }}</td> <td>{{ timer.name or "" }}</td>
<td>{{ timer.message }}</td> <td>{{ timer.message or "(not set)" }}</td>
<td class="text-center">{{ timer.interval }}</td> <td class="text-center">{{ timer.interval_value }} ({{ timer.interval_type }})</td>
<td class="text-center">{{ timer.min_lines }}</td> <td class="text-center">{{ timer.min_chat_lines }}</td>
<td class="text-center">{{ timer.status }}</td> <td class="text-center">{% if timer.enabled %}Enabled{% else %}Disabled{% endif %}</td>
<td>{{ timer.last_fired }}</td> <td>{% if timer.last_fired_at %}{{ timer.last_fired_at[:19] | replace("T", " ") }} UTC{% else %}Never{% endif %}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
+248
View File
@@ -0,0 +1,248 @@
# 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.
"""Data containers, constants, and pure helper functions for the timers module."""
from __future__ import annotations
import dataclasses
import re
from datetime import UTC, datetime
from enum import StrEnum
from typing import TYPE_CHECKING
from cronsim import CronSim, CronSimError
if TYPE_CHECKING:
import aiosqlite
class IntervalType(StrEnum):
"""Supported interval types for timer scheduling."""
SIMPLE = "simple"
CRON = "cron"
@dataclasses.dataclass(frozen=True, slots=True)
class TimerInfo:
"""Complete snapshot of a timer, returned by manager operations."""
id: int
name: str | None
message: str | None
interval_type: IntervalType
interval_value: str
min_chat_lines: int
enabled: bool
created_at: str
updated_at: str
last_fired_at: str | None
@property
def display(self) -> str:
"""Format as a human-readable identifier."""
if self.name:
return f"{self.name} (#{self.id})"
return f"timer #{self.id}"
@classmethod
def from_row(cls, row: aiosqlite.Row) -> TimerInfo:
"""Build a TimerInfo from a database row.
:param row: A row from the timers table (must include all columns).
:return: A frozen TimerInfo snapshot.
"""
return cls(
id=row["id"],
name=row["name"],
message=row["message"],
interval_type=IntervalType(row["interval_type"]),
interval_value=row["interval_value"],
min_chat_lines=row["min_chat_lines"],
enabled=bool(row["enabled"]),
created_at=row["created_at"],
updated_at=row["updated_at"],
last_fired_at=row["last_fired_at"],
)
class TimerError(Exception):
"""Base class for timer domain errors."""
class TimerNotFoundError(TimerError):
"""No timer matches the given identifier."""
def __init__(self, identifier: str | int) -> None:
"""Initialize with the identifier that was looked up.
:param identifier: The ID or name that was looked up.
"""
self.identifier = identifier
super().__init__(f"timer not found: {identifier}")
class InvalidTimerNameError(TimerError):
"""Timer name does not match the required format."""
def __init__(self, name: str) -> None:
"""Initialize with the invalid name.
:param name: The invalid name.
"""
self.name = name
super().__init__(f"invalid timer name: {name}")
class TimerNameTakenError(TimerError):
"""A timer with this name already exists."""
def __init__(self, name: str, existing_id: int) -> None:
"""Initialize with the conflicting name and existing timer ID.
:param name: The conflicting name.
:param existing_id: Database ID of the existing timer.
"""
self.name = name
self.existing_id = existing_id
super().__init__(f"timer name taken: {name} (#{existing_id})")
class TimerAlreadyEnabledError(TimerError):
"""Timer is already enabled."""
def __init__(self, info: TimerInfo) -> None:
"""Initialize with the timer info.
:param info: The timer that is already enabled.
"""
self.info = info
super().__init__(f"already enabled: {info.display}")
class TimerAlreadyDisabledError(TimerError):
"""Timer is already disabled."""
def __init__(self, info: TimerInfo) -> None:
"""Initialize with the timer info.
:param info: The timer that is already disabled.
"""
self.info = info
super().__init__(f"already disabled: {info.display}")
class TimerMessageRequiredError(TimerError):
"""Timer cannot be enabled without a message."""
def __init__(self, info: TimerInfo) -> None:
"""Initialize with the timer info.
:param info: The timer that has no message set.
"""
self.info = info
super().__init__(f"no message set: {info.display}")
class NegativeLineCountError(TimerError):
"""Line count cannot be negative."""
# Timer names: must start with a letter or underscore to avoid ambiguity with
# numeric timer IDs. Letters, numbers, underscores, max 32 chars.
NAME_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]{0,31}$")
# Regex for simple duration strings like "30s", "5m", "1h", "2h30m".
_DURATION_PATTERN = re.compile(r"^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$", re.IGNORECASE)
_MIN_SIMPLE_SECONDS = 60
def parse_interval(value: str) -> tuple[IntervalType, str]:
"""Parse and validate an interval string.
Simple durations (no spaces) are parsed with a regex. Cron expressions
(contain spaces) are validated with cronsim. Returns the detected type
and the normalized value.
:param value: Raw interval string from the user.
:return: Tuple of (interval_type, normalized_value).
:raises ValueError: If the value is not a valid interval.
"""
value = value.strip()
if not value:
raise ValueError("Interval cannot be empty.")
if " " in value:
return _parse_cron(value)
return _parse_simple(value)
def _parse_simple(value: str) -> tuple[IntervalType, str]:
"""Parse and validate a simple duration string.
:param value: Duration string like "30s", "5m", "1h30m".
:return: Tuple of (IntervalType.SIMPLE, value).
:raises ValueError: If the format is invalid or below minimum.
"""
match = _DURATION_PATTERN.match(value)
if not match or not any(match.groups()):
raise ValueError(
f"Invalid duration: {value}. Use a format like 30s, 5m, 1h, or 2h30m."
)
hours = int(match.group(1) or 0)
minutes = int(match.group(2) or 0)
seconds = int(match.group(3) or 0)
total = hours * 3600 + minutes * 60 + seconds
if total < _MIN_SIMPLE_SECONDS:
raise ValueError(
f"Minimum interval is {_MIN_SIMPLE_SECONDS} seconds. Got {total}s."
)
return (IntervalType.SIMPLE, value.lower())
def _parse_cron(value: str) -> tuple[IntervalType, str]:
"""Validate a cron expression.
:param value: Cron expression string (5 fields).
:return: Tuple of (IntervalType.CRON, value).
:raises ValueError: If the expression is invalid.
"""
try:
now = datetime.now(UTC)
it = CronSim(value, now)
next(it)
except CronSimError as e:
raise ValueError(f"Invalid cron expression: {value}") from e
return (IntervalType.CRON, value)
def duration_to_seconds(value: str) -> int:
"""Convert a validated simple duration string to total seconds.
:param value: A previously validated duration string.
:return: Total seconds.
"""
match = _DURATION_PATTERN.match(value)
if not match:
return 0
hours = int(match.group(1) or 0)
minutes = int(match.group(2) or 0)
seconds = int(match.group(3) or 0)
return hours * 3600 + minutes * 60 + seconds
+1003
View File
File diff suppressed because it is too large Load Diff