mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-09-17 05:04:19 +00:00
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.
268 lines
9.8 KiB
Python
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
|