Files
meshcore-bot/modules/scheduled_message_cron.py
T
agessaman 035ec963b6 docs: clarify APScheduler cron usage and day-of-week numbering
- Updated `config.ini.example`, `command-reference.md`, and `configuration.md` to clarify the use of APScheduler for cron scheduling, emphasizing the difference in day-of-week numbering compared to Vixie cron.
- Added examples and notes to prevent confusion regarding the interpretation of cron expressions, particularly for users transitioning from Vixie cron conventions.
- Enhanced documentation to guide users in using the correct syntax for scheduling messages, ensuring better understanding and usability.
2026-08-04 20:33:26 -07:00

136 lines
4.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Parse ``[Scheduled_Messages]`` option keys into APScheduler CronTrigger instances,
and option values into ``(channel, message, scope)`` for optional regional flood scope.
Supports (schedule keys):
- Standard 5-field crontab: minute hour day-of-month month day-of-week
- Preset aliases: @yearly, @annually, @monthly, @weekly, @daily, @midnight, @hourly
- Deprecated legacy HHMM (24-hour, no colon) for daily firing at that clock time
Day-of-week uses APScheduler numbering (0=Monday … 6=Sunday), not Vixie cron
(0=Sunday; 7 often allowed). Prefer monsun names. ``@weekly`` expands to
``0 0 * * 0`` (Monday 00:00). See docs/configuration.md and config.ini.example.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
from apscheduler.triggers.cron import CronTrigger
def parse_scheduled_message_value(raw: str) -> tuple[str, str, str | None]:
"""Parse a ``[Scheduled_Messages]`` option value into ``(channel, message, scope)``.
**Legacy (unscoped):** ``channel:body`` — split on the first ``:`` only; ``scope`` is
``None`` (global flood).
**Scoped:** ``channel:#region:body`` — exactly three segments from ``split(':', 2)``
where the middle segment starts with ``#`` after strip. The message body may contain
further colons. Scope must not contain ``:``.
Args:
raw: Config value, e.g. ``Public:Hello`` or ``Public:#sea:Hello: more``.
Returns:
``(channel, message, scope)`` with ``scope`` set only for the scoped form.
Raises:
ValueError: If there is no ``:`` (cannot separate channel from body).
"""
s = (raw or "").strip()
if ":" not in s:
raise ValueError("scheduled message value must be channel:message")
parts = s.split(":", 2)
if len(parts) == 3 and parts[1].strip().startswith("#"):
channel = parts[0].strip()
scope = parts[1].strip()
message = parts[2].strip()
return channel, message, scope
channel, message = s.split(":", 1)
return channel.strip(), message.strip(), None
# Maps @preset (lowercase) -> 5-field crontab (APScheduler does not accept @syntax in from_crontab).
_SPECIAL_PRESET_TO_CRON: dict[str, str] = {
"@yearly": "0 0 1 1 *",
"@annually": "0 0 1 1 *",
"@monthly": "0 0 1 * *",
"@weekly": "0 0 * * 0",
"@daily": "0 0 * * *",
"@midnight": "0 0 * * *",
"@hourly": "0 * * * *",
}
@dataclass(frozen=True)
class ScheduleParseResult:
"""Outcome of parsing a scheduled message key."""
trigger: Optional[CronTrigger]
"""APScheduler trigger, or None if the expression is invalid."""
display_label: str
"""Human-readable schedule for logs and the ``schedule`` command."""
is_deprecated_hhmm: bool
"""True when the legacy HHMM daily form was used."""
def is_valid_legacy_hhmm(time_str: str) -> bool:
"""Return True if ``time_str`` is a valid legacy HHMM clock time (24h)."""
try:
if len(time_str) != 4 or not time_str.isdigit():
return False
hour = int(time_str[:2])
minute = int(time_str[2:])
return 0 <= hour <= 23 and 0 <= minute <= 59
except ValueError:
return False
def parse_schedule_key(
schedule_key: str,
timezone,
) -> ScheduleParseResult:
"""Parse a ``[Scheduled_Messages]`` option name into a :class:`CronTrigger`.
Args:
schedule_key: Raw config option key (e.g. ``0 9 * * *``, ``@daily``, ``0900``).
timezone: ``tzinfo`` or string accepted by APScheduler (same as scheduler).
Returns:
ScheduleParseResult with ``trigger`` set when valid, else ``trigger`` is None
and ``display_label`` still describes what was attempted.
"""
raw = (schedule_key or "").strip()
if not raw:
return ScheduleParseResult(None, "", False)
lowered = raw.lower()
# 1) Deprecated legacy HHMM (must be checked before numeric cron fragments).
if is_valid_legacy_hhmm(raw):
hour = int(raw[:2])
minute = int(raw[2:])
trigger = CronTrigger(hour=hour, minute=minute, timezone=timezone)
display = f"{hour:02d}:{minute:02d}"
return ScheduleParseResult(trigger, display, True)
# 2) @preset aliases
if lowered in _SPECIAL_PRESET_TO_CRON:
cron_expr = _SPECIAL_PRESET_TO_CRON[lowered]
try:
trigger = CronTrigger.from_crontab(cron_expr, timezone=timezone)
except ValueError:
return ScheduleParseResult(None, raw, False)
return ScheduleParseResult(trigger, raw, False)
# 3) Standard 5-field crontab
try:
trigger = CronTrigger.from_crontab(raw, timezone=timezone)
except ValueError:
return ScheduleParseResult(None, raw, False)
return ScheduleParseResult(trigger, raw, False)