420 lines
14 KiB
Python
420 lines
14 KiB
Python
# 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
|