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
2026-05-14 11:32:20 -07:00
parent d4eba9ad11
commit 4a0b989f5a
8 changed files with 229 additions and 48 deletions
+7 -1
View File
@@ -434,7 +434,13 @@ category.funfact = fun
#category.fortune = fun
[Scheduled_Messages]
# Scheduled message format: <schedule> = channel:message
# Scheduled message format:
# <schedule> = channel:message
# <schedule> = channel:#flood_scope:message (regional flood — middle field must start with #)
# The scoped form uses split(':', 2): the message body may contain additional colons.
# Examples:
# 0 18 * * * = Public:Hello! ...
# 0 18 * * * = Public:#sea:Hello! ... (same channel text, sent with #sea flood scope)
#
# <schedule> is one of:
# - 5-field crontab (minute hour day-of-month month day-of-week), e.g.
+4
View File
@@ -146,3 +146,7 @@ Some configuration can be reloaded without restarting the bot using the **`reloa
## Pausing channel responses (remote)
Admins can DM **`channelpause`** or **`channelresume`** (see `[Admin_ACL]` in `config.ini`) to stop or resume bot reactions on **public channels** only—greeter, keywords, and commands on channels are skipped; DMs still work. The setting is **in memory only** (back to responding on channels after restart). Scheduled channel posts from the scheduler are **not** blocked by this toggle.
## Scheduled messages (`[Scheduled_Messages]`)
Each entry is `<schedule_key> = <value>` where the value is normally **`channel:message`** (first colon separates channel from body). For **regional flood scope** on that send only, use **`channel:#scope:message`**: the middle segment must start with `#` (same convention as `flood_scopes` / `outgoing_flood_scope_override`). The message body may contain more colons. Omit the middle field for classic global flood. See `config.ini.example` under `[Scheduled_Messages]` for examples. The **`schedule`** command lists each job with `(#scope)` when set.
+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,
)
+20 -10
View File
@@ -87,22 +87,22 @@ class TestBuildResponseEmpty:
class TestBuildResponseWithSchedules:
def test_shows_scheduled_count(self, bot):
bot.scheduler.scheduled_messages = {
"0900": ("general", "Good morning!", "09:00"),
"1800": ("general", "Good evening!", "18:00"),
"0900": ("general", "Good morning!", "09:00", None),
"1800": ("general", "Good evening!", "18:00", None),
}
cmd = make_cmd(bot)
response = cmd._build_response()
assert "Scheduled (2)" in response
def test_formats_time_as_hhmm(self, bot):
bot.scheduler.scheduled_messages = {"0930": ("general", "Hello", "09:30")}
bot.scheduler.scheduled_messages = {"0930": ("general", "Hello", "09:30", None)}
cmd = make_cmd(bot)
response = cmd._build_response()
assert "09:30" in response
def test_shows_channel_and_message(self, bot):
bot.scheduler.scheduled_messages = {
"1200": ("alerts", "Noon check-in", "12:00"),
"1200": ("alerts", "Noon check-in", "12:00", None),
}
cmd = make_cmd(bot)
response = cmd._build_response()
@@ -111,7 +111,7 @@ class TestBuildResponseWithSchedules:
def test_long_message_is_truncated(self, bot):
long_msg = "A" * 100
bot.scheduler.scheduled_messages = {"0800": ("general", long_msg, "08:00")}
bot.scheduler.scheduled_messages = {"0800": ("general", long_msg, "08:00", None)}
cmd = make_cmd(bot)
response = cmd._build_response()
# Preview should be ≤43 chars (40 + "...")
@@ -124,9 +124,9 @@ class TestBuildResponseWithSchedules:
def test_entries_sorted_by_time(self, bot):
bot.scheduler.scheduled_messages = {
"1800": ("general", "Evening", "18:00"),
"0600": ("general", "Morning", "06:00"),
"1200": ("general", "Noon", "12:00"),
"1800": ("general", "Evening", "18:00", None),
"0600": ("general", "Morning", "06:00", None),
"1200": ("general", "Noon", "12:00", None),
}
cmd = make_cmd(bot)
response = cmd._build_response()
@@ -136,16 +136,26 @@ class TestBuildResponseWithSchedules:
def test_shows_cron_schedule_string(self, bot):
bot.scheduler.scheduled_messages = {
"0 9 * * *": ("general", "Cron hello", "0 9 * * *"),
"0 9 * * *": ("general", "Cron hello", "0 9 * * *", None),
}
cmd = make_cmd(bot)
response = cmd._build_response()
assert "0 9 * * *" in response
assert "Cron hello" in response
def test_shows_flood_scope_when_set(self, bot):
bot.scheduler.scheduled_messages = {
"0 18 * * *": ("Public", "Hello mesh", "0 18 * * *", "#sea"),
}
cmd = make_cmd(bot)
response = cmd._build_response()
assert "(#sea)" in response
assert "#Public" in response
assert "Hello mesh" in response
def test_shows_at_preset_label(self, bot):
bot.scheduler.scheduled_messages = {
"@hourly": ("general", "tick", "@hourly"),
"@hourly": ("general", "tick", "@hourly", None),
}
cmd = make_cmd(bot)
response = cmd._build_response()
+48
View File
@@ -0,0 +1,48 @@
"""Tests for scheduled_message_cron value parsing."""
import pytest
from modules.scheduled_message_cron import parse_scheduled_message_value
class TestParseScheduledMessageValue:
def test_legacy_channel_body(self):
ch, msg, scope = parse_scheduled_message_value("Public:Hello!")
assert ch == "Public"
assert msg == "Hello!"
assert scope is None
def test_scoped_channel_scope_body(self):
ch, msg, scope = parse_scheduled_message_value("Public:#sea:Hello!")
assert ch == "Public"
assert scope == "#sea"
assert msg == "Hello!"
def test_scoped_body_may_contain_colons(self):
ch, msg, scope = parse_scheduled_message_value("Public:#sea:Hello: with colons")
assert ch == "Public"
assert scope == "#sea"
assert msg == "Hello: with colons"
def test_three_parts_middle_not_hash_is_legacy(self):
ch, msg, scope = parse_scheduled_message_value("Public:Hello: world")
assert ch == "Public"
assert msg == "Hello: world"
assert scope is None
def test_strips_whitespace(self):
ch, msg, scope = parse_scheduled_message_value(" gen : #w : hi ")
assert ch == "gen"
assert scope == "#w"
assert msg == "hi"
def test_no_colon_raises(self):
with pytest.raises(ValueError):
parse_scheduled_message_value("nocolon")
def test_only_channel_hash_scope_two_segments_legacy(self):
"""Two segments after split(':',2) — middle does not get # form; legacy parse."""
ch, msg, scope = parse_scheduled_message_value("Public:#sea")
assert ch == "Public"
assert msg == "#sea"
assert scope is None
+54 -11
View File
@@ -140,7 +140,8 @@ class TestSetupScheduledMessages:
scheduler.bot.config.set("Scheduled_Messages", "0900", "general: Good morning!")
self._setup_and_call(scheduler)
assert "0900" in scheduler.scheduled_messages
channel, message, label = scheduler.scheduled_messages["0900"]
channel, message, label, scope = scheduler.scheduled_messages["0900"]
assert scope is None
assert channel == "general"
assert "Good morning!" in message
assert label == "09:00"
@@ -163,7 +164,8 @@ class TestSetupScheduledMessages:
)
self._setup_and_call(scheduler)
assert "0 9 * * *" in scheduler.scheduled_messages
ch, msg, label = scheduler.scheduled_messages["0 9 * * *"]
ch, msg, label, _scope = scheduler.scheduled_messages["0 9 * * *"]
assert _scope is None
assert ch == "general"
assert "Morning cron" in msg
assert label == "0 9 * * *"
@@ -171,7 +173,27 @@ class TestSetupScheduledMessages:
msg_jobs = [
j for j in scheduler._apscheduler.get_jobs() if j.id.startswith("schedmsg_")
]
assert msg_jobs[0].kwargs == {"schedule_key": "0 9 * * *"}
assert msg_jobs[0].kwargs == {"schedule_key": "0 9 * * *", "scope": None}
self._teardown(scheduler)
def test_scoped_flood_message_stores_scope_and_job_kwargs(self, scheduler):
scheduler.bot.config.add_section("Scheduled_Messages")
scheduler.bot.config.set(
"Scheduled_Messages",
"0 18 * * *",
"Public:#sea:Hello! Come send test messages.",
)
self._setup_and_call(scheduler)
assert "0 18 * * *" in scheduler.scheduled_messages
ch, msg, label, scope = scheduler.scheduled_messages["0 18 * * *"]
assert ch == "Public"
assert scope == "#sea"
assert msg.startswith("Hello!")
msg_jobs = [
j for j in scheduler._apscheduler.get_jobs() if j.id.startswith("schedmsg_")
]
assert len(msg_jobs) == 1
assert msg_jobs[0].kwargs == {"schedule_key": "0 18 * * *", "scope": "#sea"}
self._teardown(scheduler)
def test_at_weekly_preset_registered(self, scheduler):
@@ -236,7 +258,8 @@ class TestSetupScheduledMessages:
scheduler.bot.config.add_section("Scheduled_Messages")
scheduler.bot.config.set("Scheduled_Messages", "1000", r"general: Line1\nLine2")
self._setup_and_call(scheduler)
_, message, label = scheduler.scheduled_messages["1000"]
_, message, label, _sc = scheduler.scheduled_messages["1000"]
assert _sc is None
assert "\n" in message
assert label == "10:00"
self._teardown(scheduler)
@@ -987,7 +1010,7 @@ class TestSendScheduledMessageAsync:
scheduler.bot.command_manager.send_channel_message = AsyncMock()
asyncio.run(scheduler._send_scheduled_message_async("general", "Hello world"))
scheduler.bot.command_manager.send_channel_message.assert_called_once_with(
"general", "Hello world", skip_user_rate_limit=True
"general", "Hello world", skip_user_rate_limit=True, scope=None
)
def test_with_placeholder_calls_get_mesh_info_and_formats(self):
@@ -1024,7 +1047,7 @@ class TestSendScheduledMessageAsync:
)
mock_fmt.assert_called_once()
scheduler.bot.command_manager.send_channel_message.assert_called_once_with(
"general", "Contacts: 42", skip_user_rate_limit=True
"general", "Contacts: 42", skip_user_rate_limit=True, scope=None
)
def test_get_mesh_info_exception_sends_message_as_is(self):
@@ -1042,7 +1065,7 @@ class TestSendScheduledMessageAsync:
)
)
scheduler.bot.command_manager.send_channel_message.assert_called_once_with(
"general", "Active: {total_contacts}", skip_user_rate_limit=True
"general", "Active: {total_contacts}", skip_user_rate_limit=True, scope=None
)
def test_format_placeholder_exception_sends_message_as_is(self):
@@ -1064,7 +1087,19 @@ class TestSendScheduledMessageAsync:
)
)
scheduler.bot.command_manager.send_channel_message.assert_called_once_with(
"alerts", "Count: {total_contacts}", skip_user_rate_limit=True
"alerts", "Count: {total_contacts}", skip_user_rate_limit=True, scope=None
)
def test_passes_scope_to_send_channel_message(self):
scheduler = _make_scheduler()
scheduler.bot.command_manager.send_channel_message = AsyncMock()
asyncio.run(
scheduler._send_scheduled_message_async(
"Public", "Hi", scope="#sea"
)
)
scheduler.bot.command_manager.send_channel_message.assert_called_once_with(
"Public", "Hi", skip_user_rate_limit=True, scope="#sea"
)
def test_stagger_invokes_sleep_when_configured(self):
@@ -1140,7 +1175,13 @@ class TestSendScheduledMessageWrapper:
mock_loop = Mock()
async def _fake_send(channel: str, message: str, *, schedule_key: str = "") -> None:
async def _fake_send(
channel: str,
message: str,
*,
schedule_key: str = "",
scope: str | None = None,
) -> None:
return None
def _run_until_complete(coro):
@@ -1156,7 +1197,9 @@ class TestSendScheduledMessageWrapper:
mock_loop.run_until_complete.assert_called_once()
mock_loop.close.assert_called_once()
mock_send.assert_called_once_with("general", "test message", schedule_key="")
mock_send.assert_called_once_with(
"general", "test message", schedule_key="", scope=None
)
def test_suppressed_when_radio_zombie(self):
scheduler = _make_scheduler()
@@ -1574,7 +1617,7 @@ class TestSendScheduledMessageAsyncTimeout:
asyncio.run(sched._send_scheduled_message_async("#general", "hello"))
sched.bot.command_manager.send_channel_message.assert_awaited_once_with(
"#general", "hello", skip_user_rate_limit=True
"#general", "hello", skip_user_rate_limit=True, scope=None
)
def test_timeout_raises_asyncio_timeout_error(self, mock_logger):