feat(scheduler): optional flood scope for scheduled channel messages

Parse channel:#scope:body in [Scheduled_Messages], pass scope to
send_channel_message; extend schedule list and docs.
This commit is contained in:
agessaman committed 2026-05-14 11:32:20 -07:00
1 parent d4eba9ad11
commit 4a0b989f5a
8 files changed
+229 -48

No files matched your search

+18 -8
View File
@@ -68,8 +68,9 @@ class ScheduleCommand(BaseCommand):
scheduled = self._get_scheduled_messages()
if scheduled:
lines.append(f"Scheduled ({len(scheduled)}):")
for sched_display, channel, preview in scheduled:
lines.append(f" {sched_display} #{channel}: {preview}")
for sched_display, channel, preview, scope in scheduled:
scope_part = f" ({scope})" if scope else ""
lines.append(f" {sched_display} #{channel}{scope_part}: {preview}")
else:
lines.append("No scheduled messages configured.")
@@ -80,19 +81,28 @@ class ScheduleCommand(BaseCommand):
return "\n".join(lines)
def _get_scheduled_messages(self) -> list[tuple[str, str, str]]:
"""Return sorted list of (schedule_display, channel, preview) tuples."""
def _get_scheduled_messages(self) -> list[tuple[str, str, str, str | None]]:
"""Return sorted list of (schedule_display, channel, preview, scope) tuples."""
scheduler = getattr(self.bot, "scheduler", None)
if scheduler is None:
return []
scheduled = getattr(scheduler, "scheduled_messages", {})
rows: list[tuple[str, str, str, str]] = []
rows: list[tuple[str, str, str, str, str | None]] = []
for schedule_key, payload in scheduled.items():
if len(payload) == 3:
if len(payload) >= 4:
channel, message, display_label, scope = (
payload[0],
payload[1],
payload[2],
payload[3],
)
elif len(payload) == 3:
channel, message, display_label = payload
scope = None
else:
channel, message = payload[0], payload[1]
scope = None
sk = schedule_key
display_label = (
f"{sk[:2]}:{sk[2:]}"
@@ -107,9 +117,9 @@ class ScheduleCommand(BaseCommand):
preview = (
safe_message if len(safe_message) <= 40 else safe_message[:37] + "..."
)
rows.append((display_label, schedule_key, channel, preview))
rows.append((display_label, schedule_key, channel, preview, scope))
rows.sort(key=lambda r: (r[0].lower(), r[1]))
return [(r[0], r[2], r[3]) for r in rows]
return [(r[0], r[2], r[3], r[4]) for r in rows]
def _get_advert_info(self) -> Optional[str]:
"""Return a one-line advert interval summary, or None if disabled."""
+35 -2
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env python3
"""
Parse [Scheduled_Messages] option keys into APScheduler CronTrigger instances.
Parse ``[Scheduled_Messages]`` option keys into APScheduler CronTrigger instances,
and option values into ``(channel, message, scope)`` for optional regional flood scope.
Supports:
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
@@ -15,6 +16,38 @@ 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 *",
+43 -16
View File
@@ -20,12 +20,15 @@ from apscheduler.triggers.date import DateTrigger
from meshcore.events import EventType
from .maintenance import MaintenanceRunner
from .scheduled_message_cron import is_valid_legacy_hhmm, parse_schedule_key
from .scheduled_message_cron import (
is_valid_legacy_hhmm,
parse_schedule_key,
parse_scheduled_message_value,
)
from .security_utils import validate_external_url
from .utils import decode_escape_sequences, format_keyword_response_with_placeholders, get_config_timezone
class MessageScheduler:
"""Manages scheduled messages and timing"""
@@ -94,25 +97,30 @@ class MessageScheduler:
cron_suggestion,
)
channel, message = message_info.split(':', 1)
channel = channel.strip()
message = decode_escape_sequences(message.strip())
channel, message, scope = parse_scheduled_message_value(message_info)
message = decode_escape_sequences(message)
job_id = "schedmsg_" + hashlib.sha256(
f"{schedule_key}\0{channel}\0{message}".encode()
f"{schedule_key}\0{channel}\0{scope or ''}\0{message}".encode()
).hexdigest()[:24]
self._apscheduler.add_job(
self.send_scheduled_message,
parsed.trigger,
args=[channel, message],
kwargs={"schedule_key": schedule_key},
kwargs={"schedule_key": schedule_key, "scope": scope},
id=job_id,
replace_existing=True,
)
self.scheduled_messages[schedule_key] = (channel, message, parsed.display_label)
self.scheduled_messages[schedule_key] = (
channel,
message,
parsed.display_label,
scope,
)
scope_note = f" scope={scope}" if scope else ""
self.logger.info(
f"Scheduled message: {parsed.display_label} -> {channel}: {message}"
f"Scheduled message: {parsed.display_label} -> {channel}{scope_note}: {message}"
)
except ValueError:
self.logger.warning(f"Invalid scheduled message format: {message_info}")
@@ -234,7 +242,13 @@ class MessageScheduler:
slot = int.from_bytes(digest[:4], "big") / (2**32)
return float(slot * max_s)
def send_scheduled_message(self, channel: str, message: str, schedule_key: str = ""):
def send_scheduled_message(
self,
channel: str,
message: str,
schedule_key: str = "",
scope: str | None = None,
):
"""Send a scheduled message (synchronous wrapper for schedule library)"""
if self.bot.is_radio_zombie:
self.logger.warning("send_scheduled_message suppressed — radio is in zombie state")
@@ -244,7 +258,11 @@ class MessageScheduler:
return
current_time = self.get_current_time()
self.logger.info(f"📅 Sending scheduled message at {current_time.strftime('%H:%M:%S')} to {channel}: {message}")
scope_note = f" [{scope}]" if scope else ""
self.logger.info(
f"📅 Sending scheduled message at {current_time.strftime('%H:%M:%S')} "
f"to {channel}{scope_note}: {message}"
)
import asyncio
@@ -253,8 +271,10 @@ class MessageScheduler:
if hasattr(self.bot, 'main_event_loop') and self.bot.main_event_loop and self.bot.main_event_loop.is_running():
# Schedule coroutine in the running main event loop
future = asyncio.run_coroutine_threadsafe(
self._send_scheduled_message_async(channel, message, schedule_key=schedule_key),
self.bot.main_event_loop
self._send_scheduled_message_async(
channel, message, schedule_key=schedule_key, scope=scope
),
self.bot.main_event_loop,
)
# Wait for completion (with timeout to prevent indefinite blocking)
try:
@@ -270,7 +290,9 @@ class MessageScheduler:
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(
self._send_scheduled_message_async(channel, message, schedule_key=schedule_key)
self._send_scheduled_message_async(
channel, message, schedule_key=schedule_key, scope=scope
)
)
finally:
loop.close()
@@ -432,7 +454,12 @@ class MessageScheduler:
return any(placeholder in message for placeholder in placeholders)
async def _send_scheduled_message_async(
self, channel: str, message: str, *, schedule_key: str = ""
self,
channel: str,
message: str,
*,
schedule_key: str = "",
scope: str | None = None,
):
"""Send a scheduled message (async implementation)"""
stagger = self._scheduled_message_stagger_seconds(schedule_key)
@@ -464,7 +491,7 @@ class MessageScheduler:
send_timeout = self.bot.config.getint('Bot', 'send_timeout_seconds', fallback=30)
await _asyncio.wait_for(
self.bot.command_manager.send_channel_message(
channel, message, skip_user_rate_limit=True
channel, message, skip_user_rate_limit=True, scope=scope
),
timeout=send_timeout,
)