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
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:
@@ -21,8 +21,6 @@ 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,
|
||||
@@ -34,8 +32,15 @@ from .commands import (
|
||||
settimerlines,
|
||||
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 .scheduler import TimerScheduler
|
||||
|
||||
__all__ = [
|
||||
"addtimer",
|
||||
@@ -43,6 +48,8 @@ __all__ = [
|
||||
"deletetimer",
|
||||
"disabletimer",
|
||||
"enabletimer",
|
||||
"handle_stream_started",
|
||||
"handle_stream_stopped",
|
||||
"handle_visibility_update",
|
||||
"listtimers",
|
||||
"settimerinterval",
|
||||
@@ -58,51 +65,40 @@ __all__ = [
|
||||
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.
|
||||
Creates the database schema, instantiates the TimerManager, and starts
|
||||
timers if the stream is currently live.
|
||||
|
||||
: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
|
||||
repo = TimerRepository(ctx.storage)
|
||||
await repo.setup()
|
||||
manager = TimerManager(ctx, repo)
|
||||
ctx.state["manager"] = manager
|
||||
|
||||
try:
|
||||
status = await ctx.owncast_client.get_status()
|
||||
if status.get("online", False):
|
||||
await manager.start_all()
|
||||
ctx.logger.info("Stream is live. Timers started.")
|
||||
else:
|
||||
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
|
||||
async def teardown(ctx: ModuleContext) -> None:
|
||||
"""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.
|
||||
"""
|
||||
scheduler: TimerScheduler | None = ctx.state.get("scheduler")
|
||||
if scheduler is not None:
|
||||
await scheduler.stop(ctx)
|
||||
ctx.state["scheduler"] = None
|
||||
manager: TimerManager | None = ctx.state.get("manager")
|
||||
if manager is not None:
|
||||
manager.cancel_offline_stop()
|
||||
await manager.stop_all()
|
||||
ctx.state["manager"] = None
|
||||
|
||||
@@ -14,65 +14,19 @@
|
||||
|
||||
"""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
|
||||
|
||||
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']}"
|
||||
from .manager import get_manager
|
||||
from .types import (
|
||||
InvalidTimerNameError,
|
||||
NegativeLineCountError,
|
||||
TimerAlreadyDisabledError,
|
||||
TimerAlreadyEnabledError,
|
||||
TimerMessageRequiredError,
|
||||
TimerNameTakenError,
|
||||
TimerNotFoundError,
|
||||
parse_interval,
|
||||
)
|
||||
|
||||
|
||||
@on_command("addtimer", requires_moderator=True)
|
||||
@@ -84,38 +38,24 @@ async def addtimer(ctx: CommandContext) -> None:
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
args = ctx.args_list
|
||||
name = None
|
||||
name = args[0].lower() if args else 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,)
|
||||
try:
|
||||
info = await get_manager(ctx.module).create_timer(name)
|
||||
except InvalidTimerNameError:
|
||||
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)."
|
||||
)
|
||||
if existing:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"A timer named '{name}' already exists (#{existing['id']})."
|
||||
)
|
||||
return
|
||||
return
|
||||
except TimerNameTakenError as e:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"A timer named '{e.name}' already exists (#{e.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.")
|
||||
await ctx.owncast_client.send_message(f"Timer {info.display} created.")
|
||||
|
||||
|
||||
@on_command("settimermessage", requires_moderator=True)
|
||||
@@ -127,35 +67,21 @@ async def settimermessage(ctx: CommandContext) -> None:
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
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(
|
||||
"Usage: !settimermessage <id|name> <message>"
|
||||
)
|
||||
return
|
||||
|
||||
identifier, message = parts
|
||||
if not message.strip():
|
||||
await ctx.owncast_client.send_message(
|
||||
"Usage: !settimermessage <id|name> <message>"
|
||||
)
|
||||
|
||||
try:
|
||||
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
|
||||
|
||||
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(ctx.module).reschedule()
|
||||
await ctx.owncast_client.send_message(f"Message set for {info.display}.")
|
||||
|
||||
|
||||
@on_command("settimerinterval", requires_moderator=True)
|
||||
@@ -176,10 +102,6 @@ async def settimerinterval(ctx: CommandContext) -> None:
|
||||
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)
|
||||
@@ -187,22 +109,18 @@ async def settimerinterval(ctx: CommandContext) -> None:
|
||||
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"]),
|
||||
)
|
||||
try:
|
||||
info = await get_manager(ctx.module).set_interval(
|
||||
identifier, interval_type, normalized
|
||||
)
|
||||
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(
|
||||
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)
|
||||
@@ -218,46 +136,32 @@ async def settimerlines(ctx: CommandContext) -> None:
|
||||
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:
|
||||
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.")
|
||||
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}."
|
||||
)
|
||||
lines = info.min_chat_lines
|
||||
label = f"{lines} line(s)" if lines > 0 else "disabled"
|
||||
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)
|
||||
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.
|
||||
@@ -267,38 +171,24 @@ async def enabletimer(ctx: CommandContext) -> None:
|
||||
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.")
|
||||
try:
|
||||
info = await get_manager(ctx.module).enable_timer(args[0])
|
||||
except TimerNotFoundError as e:
|
||||
await ctx.owncast_client.send_message(f"Timer '{e.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"]:
|
||||
except TimerAlreadyEnabledError as e:
|
||||
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."
|
||||
)
|
||||
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(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()
|
||||
await ctx.owncast_client.send_message(f"Timer {info.display} enabled.")
|
||||
|
||||
|
||||
@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>")
|
||||
return
|
||||
|
||||
identifier = args[0]
|
||||
row = await _resolve_timer(ctx, identifier)
|
||||
if not row:
|
||||
await ctx.owncast_client.send_message(f"Timer '{identifier}' not found.")
|
||||
try:
|
||||
info = await get_manager(ctx.module).disable_timer(args[0])
|
||||
except TimerNotFoundError as e:
|
||||
await ctx.owncast_client.send_message(f"Timer '{e.identifier}' not found.")
|
||||
return
|
||||
except TimerAlreadyDisabledError as e:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Timer {e.info.display} is already disabled."
|
||||
)
|
||||
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(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()
|
||||
await ctx.owncast_client.send_message(f"Timer {info.display} disabled.")
|
||||
|
||||
|
||||
@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>")
|
||||
return
|
||||
|
||||
identifier = args[0]
|
||||
row = await _resolve_timer(ctx, identifier)
|
||||
if not row:
|
||||
await ctx.owncast_client.send_message(f"Timer '{identifier}' not found.")
|
||||
try:
|
||||
info = await get_manager(ctx.module).delete_timer(args[0])
|
||||
except TimerNotFoundError as e:
|
||||
await ctx.owncast_client.send_message(f"Timer '{e.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(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()
|
||||
await ctx.owncast_client.send_message(f"Timer {info.display} deleted.")
|
||||
|
||||
|
||||
@on_command("listtimers", requires_moderator=True, cooldown=15)
|
||||
|
||||
+39
-27
@@ -12,27 +12,57 @@
|
||||
# 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.
|
||||
"""
|
||||
"""Event handlers for the timers module."""
|
||||
|
||||
from owlbot.api import (
|
||||
ChatEvent,
|
||||
EventContext,
|
||||
EventType,
|
||||
Priority,
|
||||
StreamStartedEvent,
|
||||
StreamStoppedEvent,
|
||||
VisibilityUpdateEvent,
|
||||
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)
|
||||
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.)
|
||||
execute first. Skips bot messages and hidden messages.
|
||||
@@ -42,19 +72,9 @@ async def count_chat_message(ctx: EventContext[ChatEvent]) -> None:
|
||||
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(ctx.module).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)
|
||||
get_manager(ctx.module).count_message(event.message_id)
|
||||
|
||||
|
||||
@on_event(EventType.VISIBILITY_UPDATE, priority=Priority.LOWEST)
|
||||
@@ -72,12 +92,4 @@ async def handle_visibility_update(ctx: EventContext[VisibilityUpdateEvent]) ->
|
||||
if event.is_visible:
|
||||
return
|
||||
|
||||
affected = 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
|
||||
get_manager(ctx.module).remove_messages(set(event.message_ids))
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -14,12 +14,12 @@
|
||||
|
||||
"""Web routes for the timers module."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from owlbot.api import RouteContext, on_route
|
||||
|
||||
from .manager import get_manager
|
||||
|
||||
|
||||
@on_route("/list", methods=["GET"])
|
||||
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.
|
||||
: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,
|
||||
}
|
||||
)
|
||||
|
||||
timers = await get_manager(ctx.module).list_timers()
|
||||
page = ctx.templates.render("list.html", timers=timers)
|
||||
return web.Response(text=page, content_type="text/html")
|
||||
|
||||
@@ -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 %}
|
||||
<tr>
|
||||
<td class="text-center">{{ timer.id }}</td>
|
||||
<td>{{ timer.name }}</td>
|
||||
<td>{{ timer.message }}</td>
|
||||
<td class="text-center">{{ timer.interval }}</td>
|
||||
<td class="text-center">{{ timer.min_lines }}</td>
|
||||
<td class="text-center">{{ timer.status }}</td>
|
||||
<td>{{ timer.last_fired }}</td>
|
||||
<td>{{ timer.name or "" }}</td>
|
||||
<td>{{ timer.message or "(not set)" }}</td>
|
||||
<td class="text-center">{{ timer.interval_value }} ({{ timer.interval_type }})</td>
|
||||
<td class="text-center">{{ timer.min_chat_lines }}</td>
|
||||
<td class="text-center">{% if timer.enabled %}Enabled{% else %}Disabled{% endif %}</td>
|
||||
<td>{% if timer.last_fired_at %}{{ timer.last_fired_at[:19] | replace("T", " ") }} UTC{% else %}Never{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user