Initial commit.
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Timers module for Owlbot.
|
||||
|
||||
Allows moderators to create recurring chat messages that fire on a configurable
|
||||
interval with an optional minimum chat line threshold. Supports both simple
|
||||
duration intervals and cron expressions.
|
||||
"""
|
||||
|
||||
from owlbot.api import ModuleContext, on_setup, on_teardown
|
||||
|
||||
from .chat_counter import count_chat_message, handle_visibility_update
|
||||
|
||||
# Re-export decorated handlers so the module loader discovers them.
|
||||
from .commands import (
|
||||
addtimer,
|
||||
deletetimer,
|
||||
disabletimer,
|
||||
enabletimer,
|
||||
listtimers,
|
||||
settimerinterval,
|
||||
settimerlines,
|
||||
settimermessage,
|
||||
)
|
||||
from .routes import timer_list_page
|
||||
from .scheduler import TimerScheduler, clear_scheduler, get_scheduler, set_scheduler
|
||||
|
||||
__all__ = [
|
||||
"addtimer",
|
||||
"count_chat_message",
|
||||
"deletetimer",
|
||||
"disabletimer",
|
||||
"enabletimer",
|
||||
"handle_visibility_update",
|
||||
"listtimers",
|
||||
"settimerinterval",
|
||||
"settimerlines",
|
||||
"settimermessage",
|
||||
"setup",
|
||||
"teardown",
|
||||
"timer_list_page",
|
||||
]
|
||||
|
||||
|
||||
@on_setup
|
||||
async def setup(ctx: ModuleContext) -> None:
|
||||
"""
|
||||
Initialize the timers module.
|
||||
|
||||
Creates the database schema, initializes chat counters for enabled timers,
|
||||
and starts the background scheduler.
|
||||
|
||||
:param ctx: Module context with config, storage, and other services.
|
||||
"""
|
||||
await ctx.storage.execute("""
|
||||
CREATE TABLE IF NOT EXISTS timers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE,
|
||||
message TEXT,
|
||||
interval_value TEXT NOT NULL DEFAULT '15m',
|
||||
interval_type TEXT NOT NULL DEFAULT 'simple',
|
||||
min_chat_lines INTEGER NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_fired_at TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
rows = await ctx.storage.fetch_all(
|
||||
"SELECT id FROM timers WHERE enabled = 1 AND message IS NOT NULL"
|
||||
)
|
||||
timer_ids = [row["id"] for row in rows]
|
||||
|
||||
sched = TimerScheduler()
|
||||
sched.init_counted_ids(timer_ids)
|
||||
ctx.logger.debug("Initialized chat counters for timer IDs: %s", timer_ids)
|
||||
|
||||
count = await ctx.storage.fetch_value("SELECT COUNT(*) FROM timers")
|
||||
ctx.logger.info(f"Loaded {count} timer(s) from database.")
|
||||
|
||||
sched.start(ctx)
|
||||
set_scheduler(sched)
|
||||
|
||||
|
||||
@on_teardown
|
||||
async def teardown(ctx: ModuleContext) -> None:
|
||||
"""
|
||||
Clean up the timers module.
|
||||
|
||||
Stops the background scheduler task.
|
||||
|
||||
:param ctx: Module context.
|
||||
"""
|
||||
await get_scheduler().stop(ctx)
|
||||
clear_scheduler()
|
||||
@@ -0,0 +1,85 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Chat message counting for the timers module.
|
||||
|
||||
Tracks chat messages per timer to support the minimum chat lines threshold.
|
||||
Bot messages and hidden messages are excluded from the count.
|
||||
"""
|
||||
|
||||
from owlbot.api import (
|
||||
ChatEvent,
|
||||
EventContext,
|
||||
EventType,
|
||||
Priority,
|
||||
VisibilityUpdateEvent,
|
||||
on_event,
|
||||
)
|
||||
|
||||
from .scheduler import get_scheduler
|
||||
|
||||
|
||||
@on_event(EventType.CHAT, priority=Priority.LOWEST)
|
||||
async def count_chat_message(ctx: EventContext[ChatEvent]) -> None:
|
||||
"""
|
||||
Count a chat message for all tracked timers.
|
||||
|
||||
Runs at lowest priority so all other CHAT handlers (moderation, etc.)
|
||||
execute first. Skips bot messages and hidden messages.
|
||||
|
||||
:param ctx: The event context with the chat event.
|
||||
"""
|
||||
event = ctx.event
|
||||
|
||||
if event.user.is_bot or not event.is_visible:
|
||||
reason = "bot message" if event.user.is_bot else "hidden message"
|
||||
ctx.logger.debug("Skipping chat count for %s: %s.", event.message_id, reason)
|
||||
return
|
||||
|
||||
counted_ids = get_scheduler().counted_ids
|
||||
ctx.logger.debug(
|
||||
"Counting message %s from %s for %d timer(s).",
|
||||
event.message_id,
|
||||
event.user.display_name,
|
||||
len(counted_ids),
|
||||
)
|
||||
for timer_set in counted_ids.values():
|
||||
timer_set.add(event.message_id)
|
||||
|
||||
|
||||
@on_event(EventType.VISIBILITY_UPDATE, priority=Priority.LOWEST)
|
||||
async def handle_visibility_update(ctx: EventContext[VisibilityUpdateEvent]) -> None:
|
||||
"""
|
||||
Remove hidden messages from chat counts.
|
||||
|
||||
Only handles the hide case. Un-hiding does not re-add messages because
|
||||
we cannot distinguish previously counted user messages from bot messages
|
||||
that were never counted.
|
||||
|
||||
:param ctx: The event context with the visibility update event.
|
||||
"""
|
||||
event = ctx.event
|
||||
|
||||
if event.is_visible:
|
||||
return
|
||||
|
||||
affected = set(event.message_ids)
|
||||
counted_ids = get_scheduler().counted_ids
|
||||
|
||||
ctx.logger.debug(
|
||||
"Removing %d hidden message(s) from chat counts.",
|
||||
len(affected),
|
||||
)
|
||||
for timer_set in counted_ids.values():
|
||||
timer_set -= affected
|
||||
@@ -0,0 +1,391 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Moderator commands for managing timers."""
|
||||
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from owlbot.api import CommandContext, on_command
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import aiosqlite
|
||||
|
||||
from .scheduler import get_scheduler, parse_interval
|
||||
|
||||
# Timer names: must start with a letter or underscore to avoid ambiguity with
|
||||
# numeric timer IDs. Letters, numbers, underscores, max 32 chars.
|
||||
_NAME_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]{0,31}$")
|
||||
|
||||
|
||||
async def _resolve_timer(ctx: CommandContext, identifier: str) -> aiosqlite.Row | None:
|
||||
"""
|
||||
Resolve a timer by numeric ID or name.
|
||||
|
||||
Tries parsing as an integer first, then falls back to a name lookup.
|
||||
|
||||
:param ctx: The command context.
|
||||
:param identifier: A timer ID or name string.
|
||||
:return: The timer row, or None if not found.
|
||||
"""
|
||||
try:
|
||||
timer_id = int(identifier)
|
||||
ctx.logger.debug("Resolving timer by ID: %d", timer_id)
|
||||
row = await ctx.storage.fetch_one(
|
||||
"SELECT * FROM timers WHERE id = ?", (timer_id,)
|
||||
)
|
||||
if row:
|
||||
ctx.logger.debug("Found timer by ID: %s", _timer_display(row))
|
||||
return row
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
ctx.logger.debug("Resolving timer by name: %s", identifier)
|
||||
row = await ctx.storage.fetch_one(
|
||||
"SELECT * FROM timers WHERE name = ?", (identifier.lower(),)
|
||||
)
|
||||
if row:
|
||||
ctx.logger.debug("Found timer by name: %s", _timer_display(row))
|
||||
else:
|
||||
ctx.logger.debug("Timer '%s' not found.", identifier)
|
||||
return row
|
||||
|
||||
|
||||
def _timer_display(row: aiosqlite.Row) -> str:
|
||||
"""
|
||||
Format a timer's display identifier.
|
||||
|
||||
:param row: Timer database row.
|
||||
:return: Display string like "timer_name (#3)" or "timer #3".
|
||||
"""
|
||||
if row["name"]:
|
||||
return f"{row['name']} (#{row['id']})"
|
||||
return f"timer #{row['id']}"
|
||||
|
||||
|
||||
@on_command("addtimer", requires_moderator=True)
|
||||
async def addtimer(ctx: CommandContext) -> None:
|
||||
"""
|
||||
Create a new empty timer with an optional name.
|
||||
|
||||
Usage: !addtimer [name]
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
args = ctx.args_list
|
||||
name = None
|
||||
|
||||
if args:
|
||||
name = args[0].lower()
|
||||
if not _NAME_PATTERN.match(name):
|
||||
await ctx.owncast_client.send_message(
|
||||
"Invalid timer name. Must start with a letter or underscore, "
|
||||
"followed by letters, numbers, or underscores (max 32 characters)."
|
||||
)
|
||||
return
|
||||
|
||||
existing = await ctx.storage.fetch_one(
|
||||
"SELECT id FROM timers WHERE name = ?", (name,)
|
||||
)
|
||||
if existing:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"A timer named '{name}' already exists (#{existing['id']})."
|
||||
)
|
||||
return
|
||||
|
||||
now = datetime.now(UTC).isoformat()
|
||||
cursor = await ctx.storage.execute(
|
||||
"INSERT INTO timers (name, message, interval_value, interval_type, "
|
||||
"min_chat_lines, enabled, created_at, updated_at) "
|
||||
"VALUES (?, NULL, '15m', 'simple', 0, 0, ?, ?)",
|
||||
(name, now, now),
|
||||
)
|
||||
|
||||
timer_id = cursor.lastrowid
|
||||
display = f"{name} (#{timer_id})" if name else f"#{timer_id}"
|
||||
ctx.logger.info(f"Timer {display} created by {ctx.user.display_name}.")
|
||||
await ctx.owncast_client.send_message(f"Timer {display} created.")
|
||||
|
||||
|
||||
@on_command("settimermessage", requires_moderator=True)
|
||||
async def settimermessage(ctx: CommandContext) -> None:
|
||||
"""
|
||||
Set the message text for a timer.
|
||||
|
||||
Usage: !settimermessage <id|name> <message>
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
parts = ctx.args.split(maxsplit=1)
|
||||
if len(parts) < 2:
|
||||
await ctx.owncast_client.send_message(
|
||||
"Usage: !settimermessage <id|name> <message>"
|
||||
)
|
||||
return
|
||||
|
||||
identifier, message = parts
|
||||
if not message.strip():
|
||||
await ctx.owncast_client.send_message(
|
||||
"Usage: !settimermessage <id|name> <message>"
|
||||
)
|
||||
return
|
||||
|
||||
row = await _resolve_timer(ctx, identifier)
|
||||
if not row:
|
||||
await ctx.owncast_client.send_message(f"Timer '{identifier}' not found.")
|
||||
return
|
||||
|
||||
now = datetime.now(UTC).isoformat()
|
||||
await ctx.storage.execute(
|
||||
"UPDATE timers SET message = ?, updated_at = ? WHERE id = ?",
|
||||
(message, now, row["id"]),
|
||||
)
|
||||
|
||||
display = _timer_display(row)
|
||||
ctx.logger.info(f"Timer {display} message updated by {ctx.user.display_name}.")
|
||||
await ctx.owncast_client.send_message(f"Message set for {display}.")
|
||||
if row["enabled"]:
|
||||
get_scheduler().reschedule()
|
||||
|
||||
|
||||
@on_command("settimerinterval", requires_moderator=True)
|
||||
async def settimerinterval(ctx: CommandContext) -> None:
|
||||
"""
|
||||
Set the interval for a timer.
|
||||
|
||||
Accepts simple durations (15m, 1h30m) or cron expressions (*/15 * * * *).
|
||||
|
||||
Usage: !settimerinterval <id|name> <interval>
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
parts = ctx.args.split(maxsplit=1)
|
||||
if len(parts) < 2:
|
||||
await ctx.owncast_client.send_message(
|
||||
"Usage: !settimerinterval <id|name> <interval>"
|
||||
)
|
||||
return
|
||||
|
||||
identifier, interval_str = parts
|
||||
row = await _resolve_timer(ctx, identifier)
|
||||
if not row:
|
||||
await ctx.owncast_client.send_message(f"Timer '{identifier}' not found.")
|
||||
return
|
||||
|
||||
try:
|
||||
interval_type, normalized = parse_interval(interval_str)
|
||||
except ValueError as e:
|
||||
await ctx.owncast_client.send_message(str(e))
|
||||
return
|
||||
|
||||
now = datetime.now(UTC).isoformat()
|
||||
await ctx.storage.execute(
|
||||
"UPDATE timers SET interval_type = ?, interval_value = ?, updated_at = ? "
|
||||
"WHERE id = ?",
|
||||
(interval_type, normalized, now, row["id"]),
|
||||
)
|
||||
|
||||
display = _timer_display(row)
|
||||
ctx.logger.info(
|
||||
f"Timer {display} interval set to {normalized} by {ctx.user.display_name}."
|
||||
)
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Interval for {display} set to {normalized} ({interval_type})."
|
||||
)
|
||||
if row["enabled"]:
|
||||
get_scheduler().reschedule()
|
||||
|
||||
|
||||
@on_command("settimerlines", requires_moderator=True)
|
||||
async def settimerlines(ctx: CommandContext) -> None:
|
||||
"""
|
||||
Set the minimum chat lines between timer firings.
|
||||
|
||||
Usage: !settimerlines <id|name> <count>
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
args = ctx.args_list
|
||||
if len(args) < 2:
|
||||
await ctx.owncast_client.send_message("Usage: !settimerlines <id|name> <count>")
|
||||
return
|
||||
|
||||
identifier = args[0]
|
||||
row = await _resolve_timer(ctx, identifier)
|
||||
if not row:
|
||||
await ctx.owncast_client.send_message(f"Timer '{identifier}' not found.")
|
||||
return
|
||||
|
||||
try:
|
||||
count = int(args[1])
|
||||
except ValueError:
|
||||
await ctx.owncast_client.send_message("Line count must be a number.")
|
||||
return
|
||||
|
||||
if count < 0:
|
||||
await ctx.owncast_client.send_message("Line count cannot be negative.")
|
||||
return
|
||||
|
||||
now = datetime.now(UTC).isoformat()
|
||||
await ctx.storage.execute(
|
||||
"UPDATE timers SET min_chat_lines = ?, updated_at = ? WHERE id = ?",
|
||||
(count, now, row["id"]),
|
||||
)
|
||||
|
||||
display = _timer_display(row)
|
||||
label = f"{count} line(s)" if count > 0 else "disabled"
|
||||
ctx.logger.info(
|
||||
f"Timer {display} min chat lines set to {count} by {ctx.user.display_name}."
|
||||
)
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Minimum chat lines for {display} set to {label}."
|
||||
)
|
||||
if row["enabled"]:
|
||||
get_scheduler().reschedule()
|
||||
|
||||
|
||||
@on_command("enabletimer", requires_moderator=True)
|
||||
async def enabletimer(ctx: CommandContext) -> None:
|
||||
"""
|
||||
Enable a timer.
|
||||
|
||||
Won't enable a timer that has no message set.
|
||||
|
||||
Usage: !enabletimer <id|name>
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
args = ctx.args_list
|
||||
if not args:
|
||||
await ctx.owncast_client.send_message("Usage: !enabletimer <id|name>")
|
||||
return
|
||||
|
||||
identifier = args[0]
|
||||
row = await _resolve_timer(ctx, identifier)
|
||||
if not row:
|
||||
await ctx.owncast_client.send_message(f"Timer '{identifier}' not found.")
|
||||
return
|
||||
|
||||
display = _timer_display(row)
|
||||
|
||||
if row["enabled"]:
|
||||
await ctx.owncast_client.send_message(f"Timer {display} is already enabled.")
|
||||
return
|
||||
|
||||
if not row["message"]:
|
||||
await ctx.owncast_client.send_message(
|
||||
f"Cannot enable {display}: no message set. "
|
||||
f"Use !settimermessage to set one first."
|
||||
)
|
||||
return
|
||||
|
||||
now = datetime.now(UTC).isoformat()
|
||||
await ctx.storage.execute(
|
||||
"UPDATE timers SET enabled = 1, updated_at = ? WHERE id = ?",
|
||||
(now, row["id"]),
|
||||
)
|
||||
|
||||
# Start tracking chat lines for this timer.
|
||||
get_scheduler().counted_ids[row["id"]] = set()
|
||||
ctx.logger.debug("Started chat line tracking for timer %s.", display)
|
||||
|
||||
ctx.logger.info(f"Timer {display} enabled by {ctx.user.display_name}.")
|
||||
await ctx.owncast_client.send_message(f"Timer {display} enabled.")
|
||||
get_scheduler().reschedule()
|
||||
|
||||
|
||||
@on_command("disabletimer", requires_moderator=True)
|
||||
async def disabletimer(ctx: CommandContext) -> None:
|
||||
"""
|
||||
Disable a timer.
|
||||
|
||||
Usage: !disabletimer <id|name>
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
args = ctx.args_list
|
||||
if not args:
|
||||
await ctx.owncast_client.send_message("Usage: !disabletimer <id|name>")
|
||||
return
|
||||
|
||||
identifier = args[0]
|
||||
row = await _resolve_timer(ctx, identifier)
|
||||
if not row:
|
||||
await ctx.owncast_client.send_message(f"Timer '{identifier}' not found.")
|
||||
return
|
||||
|
||||
display = _timer_display(row)
|
||||
|
||||
if not row["enabled"]:
|
||||
await ctx.owncast_client.send_message(f"Timer {display} is already disabled.")
|
||||
return
|
||||
|
||||
now = datetime.now(UTC).isoformat()
|
||||
await ctx.storage.execute(
|
||||
"UPDATE timers SET enabled = 0, updated_at = ? WHERE id = ?",
|
||||
(now, row["id"]),
|
||||
)
|
||||
|
||||
# Stop tracking chat lines for this timer.
|
||||
get_scheduler().counted_ids.pop(row["id"], None)
|
||||
ctx.logger.debug("Stopped chat line tracking for timer %s.", display)
|
||||
|
||||
ctx.logger.info(f"Timer {display} disabled by {ctx.user.display_name}.")
|
||||
await ctx.owncast_client.send_message(f"Timer {display} disabled.")
|
||||
get_scheduler().reschedule()
|
||||
|
||||
|
||||
@on_command("deletetimer", requires_moderator=True)
|
||||
async def deletetimer(ctx: CommandContext) -> None:
|
||||
"""
|
||||
Permanently delete a timer.
|
||||
|
||||
Usage: !deletetimer <id|name>
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
args = ctx.args_list
|
||||
if not args:
|
||||
await ctx.owncast_client.send_message("Usage: !deletetimer <id|name>")
|
||||
return
|
||||
|
||||
identifier = args[0]
|
||||
row = await _resolve_timer(ctx, identifier)
|
||||
if not row:
|
||||
await ctx.owncast_client.send_message(f"Timer '{identifier}' not found.")
|
||||
return
|
||||
|
||||
display = _timer_display(row)
|
||||
await ctx.storage.execute("DELETE FROM timers WHERE id = ?", (row["id"],))
|
||||
|
||||
# Stop tracking chat lines.
|
||||
get_scheduler().counted_ids.pop(row["id"], None)
|
||||
|
||||
ctx.logger.info(f"Timer {display} deleted by {ctx.user.display_name}.")
|
||||
await ctx.owncast_client.send_message(f"Timer {display} deleted.")
|
||||
get_scheduler().reschedule()
|
||||
|
||||
|
||||
@on_command("listtimers", requires_moderator=True, cooldown=15)
|
||||
async def listtimers(ctx: CommandContext) -> None:
|
||||
"""
|
||||
Send the URL to the timer list web page.
|
||||
|
||||
Usage: !listtimers
|
||||
|
||||
:param ctx: The command context.
|
||||
"""
|
||||
url = ctx.routes.url_for("/list")
|
||||
await ctx.owncast_client.send_message(f"Timers: {url}")
|
||||
@@ -0,0 +1,74 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Web routes for the timers module."""
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import jinja2
|
||||
from aiohttp import web
|
||||
|
||||
from owlbot.api import RouteContext, on_route
|
||||
|
||||
_template_dir = Path(__file__).resolve().parent / "templates"
|
||||
_jinja_env = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader(_template_dir),
|
||||
autoescape=True,
|
||||
)
|
||||
|
||||
|
||||
@on_route("/list", methods=["GET"])
|
||||
async def timer_list_page(ctx: RouteContext) -> web.Response:
|
||||
"""
|
||||
Serve an HTML page listing all timers in a table.
|
||||
|
||||
Columns: #, Name, Message, Interval, Min Lines, Status, Last Fired.
|
||||
Accessible at /owlbot/timers/list.
|
||||
|
||||
:param ctx: The route context.
|
||||
:return: HTML response with the timer list table.
|
||||
"""
|
||||
rows = await ctx.storage.fetch_all(
|
||||
"SELECT id, name, message, interval_type, interval_value, "
|
||||
"min_chat_lines, enabled, last_fired_at "
|
||||
"FROM timers ORDER BY id"
|
||||
)
|
||||
|
||||
timers = []
|
||||
for row in rows:
|
||||
last_fired = row["last_fired_at"]
|
||||
if last_fired:
|
||||
last_fired = datetime.fromisoformat(last_fired).strftime(
|
||||
"%Y-%m-%d %H:%M:%S UTC"
|
||||
)
|
||||
else:
|
||||
last_fired = "Never"
|
||||
|
||||
timers.append(
|
||||
{
|
||||
"id": row["id"],
|
||||
"name": row["name"] or "",
|
||||
"message": row["message"] or "(not set)",
|
||||
"interval": f"{row['interval_value']} ({row['interval_type']})",
|
||||
"min_lines": row["min_chat_lines"],
|
||||
"status": "Enabled" if row["enabled"] else "Disabled",
|
||||
"last_fired": last_fired,
|
||||
}
|
||||
)
|
||||
|
||||
template = _jinja_env.get_template("list.html")
|
||||
page = template.render(timers=timers)
|
||||
|
||||
return web.Response(text=page, content_type="text/html")
|
||||
@@ -0,0 +1,419 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Background scheduler and interval parsing for the timers module.
|
||||
|
||||
Manages the scheduler task that sleeps until the next timer is due and fires
|
||||
messages at the right moment. Also provides interval parsing utilities and
|
||||
shared state for chat line counting.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import re
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from enum import StrEnum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from cronsim import CronSim, CronSimError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import aiosqlite
|
||||
|
||||
from owlbot.api import ModuleContext
|
||||
|
||||
|
||||
class IntervalType(StrEnum):
|
||||
"""Supported interval types for timer scheduling."""
|
||||
|
||||
SIMPLE = "simple"
|
||||
CRON = "cron"
|
||||
|
||||
|
||||
# Regex for simple duration strings like "30s", "5m", "1h", "2h30m".
|
||||
_DURATION_PATTERN = re.compile(r"^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$", re.IGNORECASE)
|
||||
|
||||
_MIN_SIMPLE_SECONDS = 30
|
||||
_MIN_CRON_MINUTES = 1
|
||||
|
||||
|
||||
def parse_interval(value: str) -> tuple[IntervalType, str]:
|
||||
"""
|
||||
Parse and validate an interval string.
|
||||
|
||||
Simple durations (no spaces) are parsed with a regex. Cron expressions
|
||||
(contain spaces) are validated with cronsim. Returns the detected type
|
||||
and the normalized value.
|
||||
|
||||
:param value: Raw interval string from the user.
|
||||
:return: Tuple of (interval_type, normalized_value).
|
||||
:raises ValueError: If the value is not a valid interval.
|
||||
"""
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("Interval cannot be empty.")
|
||||
|
||||
if " " in value:
|
||||
return _parse_cron(value)
|
||||
return _parse_simple(value)
|
||||
|
||||
|
||||
def _parse_simple(value: str) -> tuple[IntervalType, str]:
|
||||
"""
|
||||
Parse and validate a simple duration string.
|
||||
|
||||
:param value: Duration string like "30s", "5m", "1h30m".
|
||||
:return: Tuple of (IntervalType.SIMPLE, value).
|
||||
:raises ValueError: If the format is invalid or below minimum.
|
||||
"""
|
||||
match = _DURATION_PATTERN.match(value)
|
||||
if not match or not any(match.groups()):
|
||||
raise ValueError(
|
||||
f"Invalid duration: {value}. Use a format like 30s, 5m, 1h, or 2h30m."
|
||||
)
|
||||
|
||||
hours = int(match.group(1) or 0)
|
||||
minutes = int(match.group(2) or 0)
|
||||
seconds = int(match.group(3) or 0)
|
||||
total = hours * 3600 + minutes * 60 + seconds
|
||||
|
||||
if total < _MIN_SIMPLE_SECONDS:
|
||||
raise ValueError(
|
||||
f"Minimum interval is {_MIN_SIMPLE_SECONDS} seconds. Got {total}s."
|
||||
)
|
||||
|
||||
return (IntervalType.SIMPLE, value.lower())
|
||||
|
||||
|
||||
def _parse_cron(value: str) -> tuple[IntervalType, str]:
|
||||
"""
|
||||
Validate a cron expression.
|
||||
|
||||
:param value: Cron expression string (5 fields).
|
||||
:return: Tuple of (IntervalType.CRON, value).
|
||||
:raises ValueError: If the expression is invalid or fires too frequently.
|
||||
"""
|
||||
try:
|
||||
now = datetime.now(UTC)
|
||||
it = CronSim(value, now)
|
||||
first = next(it)
|
||||
second = next(it)
|
||||
except CronSimError as e:
|
||||
raise ValueError(f"Invalid cron expression: {value}") from e
|
||||
|
||||
gap = (second - first).total_seconds()
|
||||
|
||||
if gap < _MIN_CRON_MINUTES * 60:
|
||||
raise ValueError(
|
||||
f"Cron interval too frequent. Minimum gap is {_MIN_CRON_MINUTES} minute(s)."
|
||||
)
|
||||
|
||||
return (IntervalType.CRON, value)
|
||||
|
||||
|
||||
def _duration_to_seconds(value: str) -> int:
|
||||
"""
|
||||
Convert a validated simple duration string to total seconds.
|
||||
|
||||
:param value: A previously validated duration string.
|
||||
:return: Total seconds.
|
||||
"""
|
||||
match = _DURATION_PATTERN.match(value)
|
||||
if not match:
|
||||
return 0
|
||||
hours = int(match.group(1) or 0)
|
||||
minutes = int(match.group(2) or 0)
|
||||
seconds = int(match.group(3) or 0)
|
||||
return hours * 3600 + minutes * 60 + seconds
|
||||
|
||||
|
||||
def _next_fire_time(row: aiosqlite.Row, now: datetime) -> datetime | None:
|
||||
"""
|
||||
Compute when a timer will next be time-due.
|
||||
|
||||
:param row: Database row with interval_type, interval_value, last_fired_at.
|
||||
:param now: Current UTC time.
|
||||
:return: The datetime when this timer is next due, or None if it can't fire.
|
||||
"""
|
||||
last_fired = row["last_fired_at"]
|
||||
last_fired_dt = datetime.fromisoformat(last_fired) if last_fired else None
|
||||
|
||||
if row["interval_type"] == IntervalType.SIMPLE:
|
||||
interval_secs = _duration_to_seconds(row["interval_value"])
|
||||
if interval_secs <= 0:
|
||||
return None
|
||||
anchor = last_fired_dt if last_fired_dt is not None else now
|
||||
return anchor + timedelta(seconds=interval_secs)
|
||||
|
||||
# Cron timer: wait for the next scheduled tick.
|
||||
try:
|
||||
anchor = last_fired_dt if last_fired_dt is not None else now
|
||||
return next(CronSim(row["interval_value"], anchor))
|
||||
except ValueError, KeyError, CronSimError:
|
||||
return None
|
||||
|
||||
|
||||
def _is_timer_due(row: aiosqlite.Row, now: datetime) -> bool:
|
||||
"""
|
||||
Check whether a timer should fire based on its interval and last fire time.
|
||||
|
||||
:param row: Database row with interval_type, interval_value, last_fired_at.
|
||||
:param now: Current UTC time.
|
||||
:return: True if the timer is due to fire.
|
||||
"""
|
||||
fire_time = _next_fire_time(row, now)
|
||||
if fire_time is None:
|
||||
return False
|
||||
return fire_time <= now
|
||||
|
||||
|
||||
class TimerScheduler:
|
||||
"""Encapsulates the mutable runtime state for the timer scheduler.
|
||||
|
||||
Holds the background task, wake event, and per-timer chat line counters.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
self._counted_ids: dict[int, set[str]] = {}
|
||||
self._wake_event: asyncio.Event | None = None
|
||||
|
||||
@property
|
||||
def counted_ids(self) -> dict[int, set[str]]:
|
||||
"""Per-timer sets of counted message IDs."""
|
||||
return self._counted_ids
|
||||
|
||||
def init_counted_ids(self, timer_ids: list[int]) -> None:
|
||||
"""
|
||||
Initialize empty counter sets for the given timer IDs.
|
||||
|
||||
Called during module setup to prepare tracking for enabled timers.
|
||||
|
||||
:param timer_ids: List of enabled timer IDs to track.
|
||||
"""
|
||||
self._counted_ids = {tid: set() for tid in timer_ids}
|
||||
|
||||
def start(self, ctx: ModuleContext) -> None:
|
||||
"""
|
||||
Start the background scheduler task.
|
||||
|
||||
:param ctx: The module context.
|
||||
"""
|
||||
if self._task is not None:
|
||||
return
|
||||
self._wake_event = asyncio.Event()
|
||||
self._task = asyncio.create_task(self._scheduler_loop(ctx, self._wake_event))
|
||||
ctx.logger.debug("Timer scheduler started.")
|
||||
|
||||
async def stop(self, ctx: ModuleContext) -> None:
|
||||
"""
|
||||
Cancel the background scheduler task and wait for it to exit.
|
||||
|
||||
:param ctx: The module context.
|
||||
"""
|
||||
if self._task is not None:
|
||||
self._task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._task
|
||||
self._task = None
|
||||
self._wake_event = None
|
||||
ctx.logger.info("Timer scheduler stopped.")
|
||||
|
||||
def reschedule(self) -> None:
|
||||
"""
|
||||
Wake the scheduler so it recalculates the next fire time.
|
||||
|
||||
Called by timer management commands when timer state changes.
|
||||
"""
|
||||
if self._wake_event is not None:
|
||||
self._wake_event.set()
|
||||
|
||||
async def _scheduler_loop(self, ctx: ModuleContext, wake: asyncio.Event) -> None:
|
||||
"""
|
||||
Background loop that sleeps until the next timer is due and fires it.
|
||||
|
||||
Uses an asyncio.Event to allow early wake-ups when timer state changes.
|
||||
|
||||
:param ctx: The module context.
|
||||
:param wake: Event used to signal early wake-ups.
|
||||
"""
|
||||
ctx.logger.debug("Scheduler loop running with precise sleep.")
|
||||
while True:
|
||||
delay, display = await _compute_next_delay(ctx)
|
||||
wake.clear()
|
||||
try:
|
||||
if delay is not None:
|
||||
ctx.logger.debug(
|
||||
"Next timer due in %.1fs (timer %s). Sleeping until then.",
|
||||
delay,
|
||||
display,
|
||||
)
|
||||
await asyncio.wait_for(wake.wait(), timeout=delay)
|
||||
ctx.logger.debug(
|
||||
"Woken early by reschedule event. "
|
||||
"Recalculating next fire time.",
|
||||
)
|
||||
else:
|
||||
ctx.logger.debug(
|
||||
"No timers scheduled. Sleeping until woken by a timer change.",
|
||||
)
|
||||
await wake.wait()
|
||||
ctx.logger.debug(
|
||||
"Woken early by reschedule event. "
|
||||
"Recalculating next fire time.",
|
||||
)
|
||||
except TimeoutError:
|
||||
ctx.logger.debug("Sleep finished. Checking for due timers.")
|
||||
try:
|
||||
await self._tick(ctx)
|
||||
except Exception:
|
||||
ctx.logger.exception("Scheduler tick failed.")
|
||||
|
||||
async def _tick(self, ctx: ModuleContext) -> None:
|
||||
"""
|
||||
Single scheduler tick: query enabled timers and fire any that are due.
|
||||
|
||||
:param ctx: The module context.
|
||||
"""
|
||||
rows = await ctx.storage.fetch_all(
|
||||
"SELECT id, name, message, interval_type, interval_value, "
|
||||
"min_chat_lines, last_fired_at "
|
||||
"FROM timers WHERE enabled = 1 AND message IS NOT NULL"
|
||||
)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
ctx.logger.debug("Tick: %d enabled timer(s) to check.", len(rows))
|
||||
|
||||
for row in rows:
|
||||
timer_id = row["id"]
|
||||
display = row["name"] or f"#{timer_id}"
|
||||
|
||||
if not _is_timer_due(row, now):
|
||||
ctx.logger.debug("Timer %s is not due yet.", display)
|
||||
continue
|
||||
|
||||
min_lines = row["min_chat_lines"]
|
||||
if min_lines > 0:
|
||||
counted = len(self._counted_ids.get(timer_id, set()))
|
||||
if counted < min_lines:
|
||||
now_iso = now.isoformat()
|
||||
await ctx.storage.execute(
|
||||
"UPDATE timers SET last_fired_at = ? WHERE id = ?",
|
||||
(now_iso, timer_id),
|
||||
)
|
||||
ctx.logger.info(
|
||||
"Timer %s skipped: chat line threshold not met (%d/%d). "
|
||||
"Schedule advanced.",
|
||||
display,
|
||||
counted,
|
||||
min_lines,
|
||||
)
|
||||
continue
|
||||
|
||||
# Fire the timer. Wrapped in try/except so a single failing timer
|
||||
# (e.g. network error) does not prevent other timers from firing.
|
||||
try:
|
||||
ctx.logger.debug("Firing timer %s.", display)
|
||||
await ctx.owncast_client.send_message(row["message"])
|
||||
|
||||
now_iso = now.isoformat()
|
||||
await ctx.storage.execute(
|
||||
"UPDATE timers SET last_fired_at = ? WHERE id = ?",
|
||||
(now_iso, timer_id),
|
||||
)
|
||||
|
||||
self._counted_ids[timer_id] = set()
|
||||
ctx.logger.info(f"Timer {display} fired.")
|
||||
except Exception:
|
||||
ctx.logger.exception("Failed to fire timer %s.", display)
|
||||
|
||||
|
||||
async def _compute_next_delay(ctx: ModuleContext) -> tuple[float | None, str | None]:
|
||||
"""
|
||||
Query enabled timers and return seconds until the soonest one is time-due.
|
||||
|
||||
:param ctx: The module context.
|
||||
:return: Tuple of (seconds until next timer, display name of that timer),
|
||||
or (None, None) if no timers are scheduled.
|
||||
"""
|
||||
rows = await ctx.storage.fetch_all(
|
||||
"SELECT id, name, interval_type, interval_value, last_fired_at "
|
||||
"FROM timers WHERE enabled = 1 AND message IS NOT NULL"
|
||||
)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
soonest_delay: float | None = None
|
||||
soonest_display: str | None = None
|
||||
|
||||
for row in rows:
|
||||
timer_id = row["id"]
|
||||
display = row["name"] or f"#{timer_id}"
|
||||
|
||||
fire_time = _next_fire_time(row, now)
|
||||
if fire_time is None:
|
||||
continue
|
||||
|
||||
delay = (fire_time - now).total_seconds()
|
||||
ctx.logger.debug(
|
||||
"Timer %s next due at %s (in %.1fs).",
|
||||
display,
|
||||
fire_time,
|
||||
delay,
|
||||
)
|
||||
|
||||
if soonest_delay is None or delay < soonest_delay:
|
||||
soonest_delay = delay
|
||||
soonest_display = display
|
||||
|
||||
if soonest_delay is not None:
|
||||
soonest_delay = max(0.0, soonest_delay)
|
||||
ctx.logger.debug(
|
||||
"Soonest timer is %s in %.1fs.",
|
||||
soonest_display,
|
||||
soonest_delay,
|
||||
)
|
||||
|
||||
return soonest_delay, soonest_display
|
||||
|
||||
|
||||
# Module-level singleton for the scheduler instance.
|
||||
_scheduler: TimerScheduler | None = None
|
||||
|
||||
|
||||
def get_scheduler() -> TimerScheduler:
|
||||
"""
|
||||
Return the active scheduler instance.
|
||||
|
||||
:return: The active TimerScheduler.
|
||||
:raises RuntimeError: If the scheduler has not been initialized.
|
||||
"""
|
||||
if _scheduler is None:
|
||||
raise RuntimeError("TimerScheduler is not initialized.")
|
||||
return _scheduler
|
||||
|
||||
|
||||
def set_scheduler(scheduler: TimerScheduler) -> None:
|
||||
"""
|
||||
Set the active scheduler instance.
|
||||
|
||||
:param scheduler: The TimerScheduler to install.
|
||||
"""
|
||||
global _scheduler
|
||||
_scheduler = scheduler
|
||||
|
||||
|
||||
def clear_scheduler() -> None:
|
||||
"""Clear the active scheduler instance."""
|
||||
global _scheduler
|
||||
_scheduler = None
|
||||
@@ -0,0 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Timers</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; margin: 2rem; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { border: 1px solid #ccc; padding: 0.5rem 0.75rem; text-align: left; }
|
||||
th { background: #f5f5f5; }
|
||||
td:first-child, th:first-child,
|
||||
td:nth-child(4), th:nth-child(4),
|
||||
td:nth-child(5), th:nth-child(5),
|
||||
td:nth-child(6), th:nth-child(6) { text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Timers</h1>
|
||||
{% if timers %}
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>#</th>
|
||||
<th>Name</th>
|
||||
<th>Message</th>
|
||||
<th>Interval</th>
|
||||
<th>Min Lines</th>
|
||||
<th>Status</th>
|
||||
<th>Last Fired</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for timer in timers %}
|
||||
<tr>
|
||||
<td>{{ timer.id }}</td>
|
||||
<td>{{ timer.name }}</td>
|
||||
<td>{{ timer.message }}</td>
|
||||
<td>{{ timer.interval }}</td>
|
||||
<td>{{ timer.min_lines }}</td>
|
||||
<td>{{ timer.status }}</td>
|
||||
<td>{{ timer.last_fired }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p>No timers have been created yet.</p>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user