Initial commit.

This commit is contained in:
2026-02-14 15:20:52 -05:00
commit 067b7c5a0a
48 changed files with 12169 additions and 0 deletions
+108
View File
@@ -0,0 +1,108 @@
# 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.
"""Timers module for Owlbot.
Allows moderators to create recurring chat messages that fire on a configurable
interval with an optional minimum chat line threshold. Supports both simple
duration intervals and cron expressions.
"""
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.
from .commands import (
addtimer,
deletetimer,
disabletimer,
enabletimer,
listtimers,
settimerinterval,
settimerlines,
settimermessage,
)
from .routes import timer_list_page
from .scheduler import TimerScheduler, clear_scheduler, get_scheduler, set_scheduler
__all__ = [
"addtimer",
"count_chat_message",
"deletetimer",
"disabletimer",
"enabletimer",
"handle_visibility_update",
"listtimers",
"settimerinterval",
"settimerlines",
"settimermessage",
"setup",
"teardown",
"timer_list_page",
]
@on_setup
async def setup(ctx: ModuleContext) -> None:
"""
Initialize the timers module.
Creates the database schema, initializes chat counters for enabled timers,
and starts the background scheduler.
:param ctx: Module context with config, storage, and other services.
"""
await ctx.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
)
""")
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)
set_scheduler(sched)
@on_teardown
async def teardown(ctx: ModuleContext) -> None:
"""
Clean up the timers module.
Stops the background scheduler task.
:param ctx: Module context.
"""
await get_scheduler().stop(ctx)
clear_scheduler()
@@ -0,0 +1,85 @@
# 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 message counting 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 (
ChatEvent,
EventContext,
EventType,
Priority,
VisibilityUpdateEvent,
on_event,
)
from .scheduler import get_scheduler
@on_event(EventType.CHAT, priority=Priority.LOWEST)
async def count_chat_message(ctx: EventContext[ChatEvent]) -> None:
"""
Count a chat message for all tracked timers.
Runs at lowest priority so all other CHAT handlers (moderation, etc.)
execute first. Skips bot messages and hidden messages.
:param ctx: The event context with the chat event.
"""
event = ctx.event
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
counted_ids = get_scheduler().counted_ids
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)
async def handle_visibility_update(ctx: EventContext[VisibilityUpdateEvent]) -> None:
"""
Remove hidden messages from chat counts.
Only handles the hide case. Un-hiding does not re-add messages because
we cannot distinguish previously counted user messages from bot messages
that were never counted.
:param ctx: The event context with the visibility update event.
"""
event = ctx.event
if event.is_visible:
return
affected = set(event.message_ids)
counted_ids = get_scheduler().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
+391
View File
@@ -0,0 +1,391 @@
# 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.
"""Moderator commands for managing timers."""
import re
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from owlbot.api import CommandContext, on_command
if TYPE_CHECKING:
import aiosqlite
from .scheduler import get_scheduler, parse_interval
# 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}$")
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)
async def addtimer(ctx: CommandContext) -> None:
"""
Create a new empty timer with an optional name.
Usage: !addtimer [name]
:param ctx: The command context.
"""
args = ctx.args_list
name = None
if args:
name = args[0].lower()
if not _NAME_PATTERN.match(name):
await ctx.owncast_client.send_message(
"Invalid timer name. Must start with a letter or underscore, "
"followed by letters, numbers, or underscores (max 32 characters)."
)
return
existing = await ctx.storage.fetch_one(
"SELECT id FROM timers WHERE name = ?", (name,)
)
if existing:
await ctx.owncast_client.send_message(
f"A timer named '{name}' already exists (#{existing['id']})."
)
return
now = datetime.now(UTC).isoformat()
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)
async def settimermessage(ctx: CommandContext) -> None:
"""
Set the message text for a timer.
Usage: !settimermessage <id|name> <message>
:param ctx: The command context.
"""
parts = ctx.args.split(maxsplit=1)
if len(parts) < 2:
await ctx.owncast_client.send_message(
"Usage: !settimermessage <id|name> <message>"
)
return
identifier, message = parts
if not message.strip():
await ctx.owncast_client.send_message(
"Usage: !settimermessage <id|name> <message>"
)
return
row = await _resolve_timer(ctx, identifier)
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().reschedule()
@on_command("settimerinterval", requires_moderator=True)
async def settimerinterval(ctx: CommandContext) -> None:
"""
Set the interval for a timer.
Accepts simple durations (15m, 1h30m) or cron expressions (*/15 * * * *).
Usage: !settimerinterval <id|name> <interval>
:param ctx: The command context.
"""
parts = ctx.args.split(maxsplit=1)
if len(parts) < 2:
await ctx.owncast_client.send_message(
"Usage: !settimerinterval <id|name> <interval>"
)
return
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:
interval_type, normalized = parse_interval(interval_str)
except ValueError as e:
await ctx.owncast_client.send_message(str(e))
return
now = datetime.now(UTC).isoformat()
await ctx.storage.execute(
"UPDATE timers SET interval_type = ?, interval_value = ?, updated_at = ? "
"WHERE id = ?",
(interval_type, normalized, now, row["id"]),
)
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(
f"Interval for {display} set to {normalized} ({interval_type})."
)
if row["enabled"]:
get_scheduler().reschedule()
@on_command("settimerlines", requires_moderator=True)
async def settimerlines(ctx: CommandContext) -> None:
"""
Set the minimum chat lines between timer firings.
Usage: !settimerlines <id|name> <count>
:param ctx: The command context.
"""
args = ctx.args_list
if len(args) < 2:
await ctx.owncast_client.send_message("Usage: !settimerlines <id|name> <count>")
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:
count = int(args[1])
except ValueError:
await ctx.owncast_client.send_message("Line count must be a number.")
return
if count < 0:
await ctx.owncast_client.send_message("Line count cannot be negative.")
return
now = datetime.now(UTC).isoformat()
await ctx.storage.execute(
"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(
f"Minimum chat lines for {display} set to {label}."
)
if row["enabled"]:
get_scheduler().reschedule()
@on_command("enabletimer", requires_moderator=True)
async def enabletimer(ctx: CommandContext) -> None:
"""
Enable a timer.
Won't enable a timer that has no message set.
Usage: !enabletimer <id|name>
:param ctx: The command context.
"""
args = ctx.args_list
if not args:
await ctx.owncast_client.send_message("Usage: !enabletimer <id|name>")
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
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(
f"Cannot enable {display}: no message set. "
f"Use !settimermessage to set one first."
)
return
now = datetime.now(UTC).isoformat()
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().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().reschedule()
@on_command("disabletimer", requires_moderator=True)
async def disabletimer(ctx: CommandContext) -> None:
"""
Disable a timer.
Usage: !disabletimer <id|name>
:param ctx: The command context.
"""
args = ctx.args_list
if not args:
await ctx.owncast_client.send_message("Usage: !disabletimer <id|name>")
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
display = _timer_display(row)
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"]),
)
# Stop tracking chat lines for this timer.
get_scheduler().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().reschedule()
@on_command("deletetimer", requires_moderator=True)
async def deletetimer(ctx: CommandContext) -> None:
"""
Permanently delete a timer.
Usage: !deletetimer <id|name>
:param ctx: The command context.
"""
args = ctx.args_list
if not args:
await ctx.owncast_client.send_message("Usage: !deletetimer <id|name>")
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
display = _timer_display(row)
await ctx.storage.execute("DELETE FROM timers WHERE id = ?", (row["id"],))
# Stop tracking chat lines.
get_scheduler().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().reschedule()
@on_command("listtimers", requires_moderator=True, cooldown=15)
async def listtimers(ctx: CommandContext) -> None:
"""
Send the URL to the timer list web page.
Usage: !listtimers
:param ctx: The command context.
"""
url = ctx.routes.url_for("/list")
await ctx.owncast_client.send_message(f"Timers: {url}")
+74
View File
@@ -0,0 +1,74 @@
# 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.
"""Web routes for the timers module."""
from datetime import datetime
from pathlib import Path
import jinja2
from aiohttp import web
from owlbot.api import RouteContext, on_route
_template_dir = Path(__file__).resolve().parent / "templates"
_jinja_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(_template_dir),
autoescape=True,
)
@on_route("/list", methods=["GET"])
async def timer_list_page(ctx: RouteContext) -> web.Response:
"""
Serve an HTML page listing all timers in a table.
Columns: #, Name, Message, Interval, Min Lines, Status, Last Fired.
Accessible at /owlbot/timers/list.
:param ctx: The route context.
:return: HTML response with the timer list table.
"""
rows = await ctx.storage.fetch_all(
"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,
}
)
template = _jinja_env.get_template("list.html")
page = template.render(timers=timers)
return web.Response(text=page, content_type="text/html")
+419
View File
@@ -0,0 +1,419 @@
# 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.
"""
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
if row["interval_type"] == IntervalType.SIMPLE:
interval_secs = _duration_to_seconds(row["interval_value"])
if interval_secs <= 0:
return None
anchor = last_fired_dt if last_fired_dt is not None else now
return anchor + timedelta(seconds=interval_secs)
# Cron timer: wait for the next scheduled tick.
try:
anchor = last_fired_dt if last_fired_dt is not None else now
return next(CronSim(row["interval_value"], anchor))
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:
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
# Module-level singleton for the scheduler instance.
_scheduler: TimerScheduler | None = None
def get_scheduler() -> TimerScheduler:
"""
Return the active scheduler instance.
:return: The active TimerScheduler.
:raises RuntimeError: If the scheduler has not been initialized.
"""
if _scheduler is None:
raise RuntimeError("TimerScheduler is not initialized.")
return _scheduler
def set_scheduler(scheduler: TimerScheduler) -> None:
"""
Set the active scheduler instance.
:param scheduler: The TimerScheduler to install.
"""
global _scheduler
_scheduler = scheduler
def clear_scheduler() -> None:
"""Clear the active scheduler instance."""
global _scheduler
_scheduler = None
@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Timers</title>
<style>
body { font-family: sans-serif; margin: 2rem; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ccc; padding: 0.5rem 0.75rem; text-align: left; }
th { background: #f5f5f5; }
td:first-child, th:first-child,
td:nth-child(4), th:nth-child(4),
td:nth-child(5), th:nth-child(5),
td:nth-child(6), th:nth-child(6) { text-align: center; }
</style>
</head>
<body>
<h1>Timers</h1>
{% if timers %}
<table>
<thead><tr>
<th>#</th>
<th>Name</th>
<th>Message</th>
<th>Interval</th>
<th>Min Lines</th>
<th>Status</th>
<th>Last Fired</th>
</tr></thead>
<tbody>
{% for timer in timers %}
<tr>
<td>{{ timer.id }}</td>
<td>{{ timer.name }}</td>
<td>{{ timer.message }}</td>
<td>{{ timer.interval }}</td>
<td>{{ timer.min_lines }}</td>
<td>{{ timer.status }}</td>
<td>{{ timer.last_fired }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No timers have been created yet.</p>
{% endif %}
</body>
</html>