Files
meshcore-bot/modules/scheduled_message_admin.py
T
Adam Gessaman abd39acfa3 feat(scheduler): add positional day-of-month and date bounds
Standard 5-field cron cannot express "the 4th Tuesday" or "the last
Friday" — patterns recurring nets need — so operators were hand-rolling
day-of-month lists that drift across months of different lengths.

APScheduler's day field already understands these ("last fri", "4th
tue"); the only obstacle is the space, which collides with crontab's
field separator. Accept "-" or "_" in its place and restore it before
handing the field over:

    0 19 last-fri * *          last Friday of the month
    0 19 4th-tue * *           fourth Tuesday
    0 19 1st-tue,3rd-tue * *   first and third Tuesday

Neither separator is ambiguous — crontab range endpoints are digits, so
"last-fri" cannot read as a range. Positional expressions are valid only
in day-of-month; "0 19 * * last-fri" is rejected.

Also adds optional start=/end= date bounds, which limit a schedule to a
date range. They live on the option value rather than the key, ahead of
the channel and keyed with "=", so they never collide with the ":"
separating channel from message, and a body mentioning "start=" is not
misread as a bound. Either may be omitted, order does not matter, and
the end date is inclusive of that whole day. A schedule with no runs
left is skipped at startup with a log line saying why.

The web viewer gains start/end pickers, shows a bounded entry's window
and marks an exhausted one Finished. Bounds round-trip through the edit
cycle, so editing an entry cannot silently drop them.

Expressions without a positional escape are passed to APScheduler
untouched: a 6000-expression sweep over valid crontab forms shows no
behavioural change.
2026-08-29 13:34:05 -07:00

268 lines
9.8 KiB
Python

#!/usr/bin/env python3
"""Read/validate/compose ``[Scheduled_Messages]`` entries for the web viewer.
The bot's schedules live in ``config.ini`` and are applied by
:meth:`modules.scheduler.MessageScheduler.setup_scheduled_messages`, which
``reload_config()`` re-runs — so edits take effect without a restart and there is
no second store to keep in sync.
Everything here validates through the same parsers the scheduler uses
(:mod:`modules.scheduled_message_cron`), so the UI can never accept a schedule the
bot would then reject at startup.
"""
from __future__ import annotations
import configparser
import datetime
from typing import Any
from .scheduled_message_cron import (
parse_schedule_key,
parse_scheduled_message_value,
split_schedule_bounds,
)
SECTION = "Scheduled_Messages"
# Mirrors MessageScheduler.MIN_COMMAND_PLACEHOLDER_INTERVAL_SECONDS. Imported lazily
# in _min_interval_floor() to avoid importing the scheduler (and APScheduler's
# background machinery) into the web viewer process just for a constant.
_COMMAND_PLACEHOLDER = "{cmd:"
def _min_interval_floor() -> int:
from .scheduler import MessageScheduler
return int(MessageScheduler.MIN_COMMAND_PLACEHOLDER_INTERVAL_SECONDS)
def _min_fire_interval_seconds(trigger: Any, tz: Any, samples: int = 12) -> float | None:
"""Smallest gap between consecutive firings, measured the way the scheduler does."""
from .scheduler import MessageScheduler
return MessageScheduler._min_fire_interval_seconds(trigger, tz, samples)
def next_run_times(trigger: Any, tz: Any, count: int = 5) -> list[str]:
"""Next *count* firing times as ISO strings, for previewing a schedule."""
if trigger is None:
return []
out: list[str] = []
previous = trigger.get_next_fire_time(None, datetime.datetime.now(tz))
while previous is not None and len(out) < count:
out.append(previous.isoformat())
previous = trigger.get_next_fire_time(
previous, previous + datetime.timedelta(microseconds=1)
)
return out
def describe_schedule(
schedule: str,
tz: Any,
message: str = "",
count: int = 5,
start: str | None = None,
end: str | None = None,
) -> dict[str, Any]:
"""Validate a schedule key and describe when it would fire.
Args:
schedule: The raw key an operator typed, e.g. ``0 6,12,18 * * *`` or ``@daily``.
tz: Timezone the bot schedules in.
message: Message body, only needed to apply the ``{cmd:...}`` airtime floor.
count: How many upcoming runs to return.
start: Optional ISO date the schedule starts on (from the entry's value).
end: Optional ISO date it runs through, inclusive.
Returns:
``valid``, a human ``label``, ``next_runs``, ``interval_seconds`` (tightest gap),
``deprecated`` for the legacy HHMM form, and ``error`` when unusable.
"""
raw = (schedule or "").strip()
if not raw:
return {"valid": False, "error": "Schedule is required", "next_runs": []}
try:
parsed = parse_schedule_key(raw, tz, start, end)
except Exception as exc: # noqa: BLE001 - any parser error is just an invalid schedule
return {"valid": False, "error": f"Could not parse schedule: {exc}", "next_runs": []}
if parsed.trigger is None:
return {
"valid": False,
"error": (
"Not a valid schedule. Use 5-field cron (minute hour day-of-month "
"month day-of-week), a positional day-of-month such as last-fri or "
"4th-tue, or a preset like @daily or @hourly."
),
"next_runs": [],
}
interval = _min_fire_interval_seconds(parsed.trigger, tz)
result: dict[str, Any] = {
"valid": True,
"label": parsed.display_label,
"next_runs": next_run_times(parsed.trigger, tz, count),
"interval_seconds": interval,
"deprecated": bool(parsed.is_deprecated_hhmm),
"error": None,
}
warnings: list[str] = []
if parsed.is_deprecated_hhmm:
warnings.append(
f"{raw} is the deprecated HHMM form and will stop working in a future "
"release. Use 5-field cron instead."
)
if (start or end) and not result["next_runs"]:
# Well-formed, just outside its window -- distinct from a malformed schedule.
result["finished"] = True
warnings.append(
f"This schedule has no runs left: it is bounded to "
f"{start or 'any date'} .. {end or 'any date'}."
)
if warnings:
result["warning"] = " ".join(warnings)
# Same floor the scheduler enforces at startup, applied here so the UI refuses it
# up front rather than letting it be saved and silently dropped on reload.
if _COMMAND_PLACEHOLDER in (message or ""):
floor = _min_interval_floor()
if interval is not None and interval < floor:
result["valid"] = False
result["error"] = (
f"A message using {{cmd:...}} may not run more often than every "
f"{floor // 60} minutes; this fires every "
f"{_humanize_seconds(interval)}. Each run costs airtime."
)
return result
def _humanize_seconds(seconds: float) -> str:
seconds = int(seconds)
if seconds % 86400 == 0:
days = seconds // 86400
return f"{days} day{'s' if days != 1 else ''}"
if seconds % 3600 == 0:
hours = seconds // 3600
return f"{hours} hour{'s' if hours != 1 else ''}"
if seconds % 60 == 0:
minutes = seconds // 60
return f"{minutes} minute{'s' if minutes != 1 else ''}"
return f"{seconds} seconds"
def compose_value(
channel: str,
message: str,
scope: str | None = None,
start: str | None = None,
end: str | None = None,
) -> str:
"""Build the config value for an entry, matching parse_scheduled_message_value.
Date bounds lead, so they cannot be confused with a message body, and are read
back off by :func:`~modules.scheduled_message_cron.split_schedule_bounds`.
"""
channel = (channel or "").strip()
message = (message or "").strip()
scope = (scope or "").strip()
starts_on = (start or "").strip()
ends_on = (end or "").strip()
prefix = ""
if starts_on:
prefix += f"start={starts_on} "
if ends_on:
prefix += f"end={ends_on} "
if scope:
if not scope.startswith("#"):
scope = f"#{scope}"
return f"{prefix}{channel}:{scope}:{message}"
return f"{prefix}{channel}:{message}"
def validate_entry(
channel: str,
message: str,
scope: str | None,
start: str | None = None,
end: str | None = None,
) -> str | None:
"""Return an error string for an unusable entry, or None when it is fine."""
starts_on = (start or "").strip()
ends_on = (end or "").strip()
for label, value in (("Start date", starts_on), ("End date", ends_on)):
if not value:
continue
try:
datetime.date.fromisoformat(value)
except ValueError:
return f"{label} must be an ISO date (YYYY-MM-DD)"
if starts_on and ends_on and ends_on < starts_on:
return "End date is before the start date"
if not (channel or "").strip():
return "Channel is required"
if not (message or "").strip():
return "Message is required"
if ":" in (channel or ""):
return "Channel cannot contain ':'"
if scope and ":" in scope:
return "Scope cannot contain ':'"
# The INI writer rejects these outright; catching them here gives a better message.
for field, value in (("channel", channel), ("message", message), ("scope", scope or "")):
if "\n" in value or "\r" in value:
return f"The {field} cannot contain line breaks (use \\n for a mesh line break)"
return None
def read_entries(config_path: str, tz: Any) -> list[dict[str, Any]]:
"""Read every ``[Scheduled_Messages]`` entry from disk, described for the UI.
Reads the file rather than a cached ConfigParser so the list reflects what was
just written. Malformed rows are returned with an ``error`` instead of being
hidden, since a row the bot is skipping is exactly what an operator needs to see.
"""
# Default optionxform (lower-casing) on purpose: the bot reads this section with a
# plain ConfigParser, so the UI must show the same keys the scheduler registers.
# ini_writer matches keys case-insensitively, so edits and deletes still line up.
parser = configparser.ConfigParser(interpolation=None)
try:
parser.read(config_path, encoding="utf-8")
except (OSError, configparser.Error):
return []
if not parser.has_section(SECTION):
return []
entries: list[dict[str, Any]] = []
for schedule, raw_value in parser.items(SECTION):
entry: dict[str, Any] = {
"schedule": schedule,
"raw_value": raw_value,
"channel": "",
"scope": None,
"message": "",
"start": None,
"end": None,
}
try:
start, end, rest = split_schedule_bounds(raw_value)
channel, message, scope = parse_scheduled_message_value(rest)
entry.update(
channel=channel, message=message, scope=scope, start=start, end=end
)
except ValueError as exc:
# Keep the same shape as a described entry so callers never have to
# special-case a malformed row to find out it is not running.
entry["valid"] = False
entry["error"] = f"Malformed value: {exc}"
entry["next_runs"] = []
entries.append(entry)
continue
entry.update(
describe_schedule(schedule, tz, message=message, start=start, end=end)
)
entries.append(entry)
return entries