mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-08-29 15:18:34 +00:00
Adds a Schedule page that lists every [Scheduled_Messages] entry with its
next run time and supports add, edit and delete. Writes go to config.ini
and queue a config reload, so schedules change without restarting the
bot, which was the actual request.
It edits the config section the bot already reads rather than
introducing a database table. reload_config() already re-runs
setup_scheduled_messages() with rollback, so there is nothing to keep in
sync and the schedule command lists exactly what the page shows.
Validation runs through the same parsers the scheduler uses, so the UI
cannot accept a schedule the bot would later reject. The builder
composes cron from plain-language options and previews the next five
runs; entries the bot cannot run are listed as "Not scheduled" with the
reason instead of being hidden, since a typo that silences a message is
what an operator most needs to see. The 15-minute floor for {cmd:...}
messages is enforced at save time too.
Also relabels the radio Disconnect button to "Stop Bot" behind a
confirmation (#240). It was never a radio-only disconnect: the main loop
runs while self.connected is true, so disconnecting exits the process.
That surprised an operator running under tmux with nothing to restart
it. disconnect_radio()'s docstring now says so as well.
205 lines
7.9 KiB
Python
205 lines
7.9 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
|
|
|
|
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
|
|
) -> 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.
|
|
|
|
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)
|
|
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), 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,
|
|
}
|
|
if parsed.is_deprecated_hhmm:
|
|
result["warning"] = (
|
|
f"{raw} is the deprecated HHMM form and will stop working in a future "
|
|
"release. Use 5-field cron instead."
|
|
)
|
|
|
|
# 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) -> str:
|
|
"""Build the config value for an entry, matching parse_scheduled_message_value."""
|
|
channel = (channel or "").strip()
|
|
message = (message or "").strip()
|
|
scope = (scope or "").strip()
|
|
if scope:
|
|
if not scope.startswith("#"):
|
|
scope = f"#{scope}"
|
|
return f"{channel}:{scope}:{message}"
|
|
return f"{channel}:{message}"
|
|
|
|
|
|
def validate_entry(channel: str, message: str, scope: str | None) -> str | None:
|
|
"""Return an error string for an unusable entry, or None when it is fine."""
|
|
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": "",
|
|
}
|
|
try:
|
|
channel, message, scope = parse_scheduled_message_value(raw_value)
|
|
entry.update(channel=channel, message=message, scope=scope)
|
|
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))
|
|
entries.append(entry)
|
|
return entries
|