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