diff --git a/config.ini.example b/config.ini.example index 49ecd74..e6ec8d9 100644 --- a/config.ini.example +++ b/config.ini.example @@ -434,7 +434,13 @@ category.funfact = fun #category.fortune = fun [Scheduled_Messages] -# Scheduled message format: = channel:message +# Scheduled message format: +# = channel:message +# = 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) # # is one of: # - 5-field crontab (minute hour day-of-month month day-of-week), e.g. diff --git a/docs/configuration.md b/docs/configuration.md index 57c31f5..f9eb7c1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 ` = ` 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. diff --git a/modules/commands/schedule_command.py b/modules/commands/schedule_command.py index 38971b2..b45b95f 100644 --- a/modules/commands/schedule_command.py +++ b/modules/commands/schedule_command.py @@ -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.""" diff --git a/modules/scheduled_message_cron.py b/modules/scheduled_message_cron.py index e40c607..49e0281 100644 --- a/modules/scheduled_message_cron.py +++ b/modules/scheduled_message_cron.py @@ -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 *", diff --git a/modules/scheduler.py b/modules/scheduler.py index 1913fc3..c1be95f 100644 --- a/modules/scheduler.py +++ b/modules/scheduler.py @@ -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, ) diff --git a/tests/test_schedule_command.py b/tests/test_schedule_command.py index d631c1d..c2ddd92 100644 --- a/tests/test_schedule_command.py +++ b/tests/test_schedule_command.py @@ -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() diff --git a/tests/test_scheduled_message_cron.py b/tests/test_scheduled_message_cron.py new file mode 100644 index 0000000..0bb95f1 --- /dev/null +++ b/tests/test_scheduled_message_cron.py @@ -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 diff --git a/tests/test_scheduler_logic.py b/tests/test_scheduler_logic.py index a4699d9..896ac93 100644 --- a/tests/test_scheduler_logic.py +++ b/tests/test_scheduler_logic.py @@ -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):