From 6584800cb701dec24446a25a70a63e975df912ec Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 27 Jun 2026 17:09:28 -0700 Subject: [PATCH] feat(command_manager, models): enhance flood scope handling for channels - Introduced per-channel flood scope configuration in `config.ini.example`, allowing for more granular control over message routing. - Updated `CommandManager` to normalize channel names and resolve flood scopes based on channel-specific settings, improving message delivery accuracy. - Enhanced `MeshMessage` to consider channel-specific flood scopes when determining the effective outgoing flood scope. - Added unit tests to verify the new flood scope resolution logic and ensure correct behavior across various scenarios. --- config.ini.example | 9 ++++ modules/command_manager.py | 33 ++++++++++++-- modules/models.py | 13 +++++- tests/test_command_manager.py | 6 +++ tests/unit/test_flood_scope_resolve.py | 60 ++++++++++++++++++++++++++ 5 files changed, 115 insertions(+), 6 deletions(-) diff --git a/config.ini.example b/config.ini.example index 2add3e7..53b6aef 100644 --- a/config.ini.example +++ b/config.ini.example @@ -284,6 +284,15 @@ max_response_hops = 7 # You can use "region" or "#region" (the # is added if missing). # outgoing_flood_scope_override = #west +# Optional per-channel outgoing flood scope defaults. +# Keys are case-insensitive and may include or omit the leading # in the channel name. +# Values may be region, #region, *, 0, None, or empty for global flood. +# Precedence: explicit send scope, mirrored reply scope, plugin/service flood_scope, +# per-channel flood_scope., outgoing_flood_scope_override, then global flood. +# flood_scope.general = #west +# flood_scope.weather = #sea +# flood_scope.#local = * + [Banned_Users] # List of banned sender names (comma-separated). Matching is prefix (starts-with): # "Awful Username" also matches "Awful Username 🍆". No bot responses in channels or DMs. diff --git a/modules/command_manager.py b/modules/command_manager.py index 2e90650..64f7d1d 100644 --- a/modules/command_manager.py +++ b/modules/command_manager.py @@ -165,12 +165,19 @@ class CommandManager: @staticmethod def _normalize_scope_name(scope: str) -> str: """Return scope with '#' prepended if it is a non-global named region without one.""" - if scope in ("", "*", "0", "None"): + if scope in ("", "*", "0", "None") or scope.lower() == "none": + if scope.lower() == "none": + return "None" return scope if not scope.startswith("#"): return "#" + scope return scope + @staticmethod + def _normalize_channel_name_for_scope_config(channel: str) -> str: + """Normalize channel names for [Channels] flood_scope. lookups.""" + return channel.strip().removeprefix("#").lower() + def _outgoing_flood_scope_override(self) -> str: """[Channels] outgoing_flood_scope_override when set, else empty string.""" if self.bot.config.has_section("Channels") and self.bot.config.has_option( @@ -179,18 +186,33 @@ class CommandManager: return (self.bot.config.get("Channels", "outgoing_flood_scope_override") or "").strip() return "" + def _channel_flood_scope(self, channel: str | None) -> str | None: + """Return [Channels] flood_scope. when configured, including global markers.""" + if not channel or not self.bot.config.has_section("Channels"): + return None + channel_key = self._normalize_channel_name_for_scope_config(channel) + for key, value in self.bot.config.items("Channels"): + if not key.startswith("flood_scope."): + continue + configured_channel = key[len("flood_scope."):] + if self._normalize_channel_name_for_scope_config(configured_channel) == channel_key: + return self._normalize_scope_name((value or "").strip()) + return None + def resolve_channel_send_scope( self, *, scope: str | None = None, message: MeshMessage | None = None, config_section: str | None = None, + channel: str | None = None, ) -> str | None: """Resolve explicit regional scope before send_channel_message applies override. Precedence: explicit ``scope`` arg → ``message.reply_scope`` (mirror incoming) → - ``flood_scope`` in ``config_section``. Returns ``None`` when unset so - ``send_channel_message`` falls back to ``outgoing_flood_scope_override``. + ``flood_scope`` in ``config_section`` → per-channel ``flood_scope.``. + Returns ``None`` when unset so ``send_channel_message`` falls back to + ``outgoing_flood_scope_override``. """ if scope is not None: return scope @@ -200,6 +222,9 @@ class CommandManager: raw = (self.bot.config.get(config_section, "flood_scope", fallback="") or "").strip() if raw: return self._normalize_scope_name(raw) + channel_scope = self._channel_flood_scope(channel or (message.channel if message else None)) + if channel_scope is not None: + return channel_scope return None def _should_queue_command(self, command: BaseCommand, message: MeshMessage) -> tuple[bool, float]: @@ -1167,7 +1192,7 @@ class CommandManager: # Don't fail the send if transmission tracking fails # Optional flood scope (region): set before send, restore after - resolved = self.resolve_channel_send_scope(scope=scope) + resolved = self.resolve_channel_send_scope(scope=scope, channel=channel) scope_to_use = ( resolved if resolved is not None else self._outgoing_flood_scope_override() ) or "" diff --git a/modules/models.py b/modules/models.py index 5e2630d..c4c49cc 100644 --- a/modules/models.py +++ b/modules/models.py @@ -35,13 +35,22 @@ class MeshMessage: def effective_outgoing_flood_scope(self, bot: Any) -> str: """Resolve outbound flood scope the same way as ``CommandManager.send_channel_message``. - For channel replies: ``reply_scope`` when set, else ``[Channels] outgoing_flood_scope_override``. + For channel replies: ``reply_scope`` when set, else per-channel + ``[Channels] flood_scope.``, else ``[Channels] outgoing_flood_scope_override``. Empty string means global flood. DMs return ``""`` (not applicable). """ if self.is_dm: return "" if self.reply_scope is not None: return (self.reply_scope or "").strip() + if self.channel and bot.config.has_section("Channels"): + channel_key = self.channel.strip().removeprefix("#").lower() + for key, value in bot.config.items("Channels"): + if not key.startswith("flood_scope."): + continue + configured_channel = key[len("flood_scope."):].strip().removeprefix("#").lower() + if configured_channel == channel_key: + return (value or "").strip() scope_cfg = "" if bot.config.has_section("Channels") and bot.config.has_option( "Channels", "outgoing_flood_scope_override" @@ -52,4 +61,4 @@ class MeshMessage: @staticmethod def is_global_flood_scope(scope: str) -> bool: """Match ``send_channel_message`` global markers (before ``_normalize_scope_name``).""" - return scope in ("", "*", "0", "None") + return scope in ("", "*", "0", "None") or scope.lower() == "none" diff --git a/tests/test_command_manager.py b/tests/test_command_manager.py index 9340e39..5dc3e6e 100644 --- a/tests/test_command_manager.py +++ b/tests/test_command_manager.py @@ -884,6 +884,12 @@ class TestGetMaxMessageLength: msg = MeshMessage(content="x", channel="general", is_dm=False) assert mgr.get_max_message_length(msg) == 137 + def test_channel_flood_scope_reduces_budget_by_10_bytes(self): + mgr = self._make_manager(bot_name="LongBotName") + mgr.bot.config.set("Channels", "flood_scope.weather", "#sea") + msg = MeshMessage(content="x", channel="#Weather", is_dm=False) + assert mgr.get_max_message_length(msg) == 137 + def test_parity_with_base_command_get_max_message_length(self): """CommandManager must mirror BaseCommand byte budgets (PR #128).""" from tests.commands.test_base_command import _TestCommand diff --git a/tests/unit/test_flood_scope_resolve.py b/tests/unit/test_flood_scope_resolve.py index 55dd04d..62bafe5 100644 --- a/tests/unit/test_flood_scope_resolve.py +++ b/tests/unit/test_flood_scope_resolve.py @@ -54,11 +54,38 @@ class TestResolveChannelSendScope: cm = _command_manager(_make_config(outgoing_flood_scope_override="#west")) assert cm.resolve_channel_send_scope(scope=None) is None + def test_channel_flood_scope_normalizes_channel_and_scope(self): + config = _make_config() + config.set("Channels", "flood_scope.weather", "sea") + cm = _command_manager(config) + + assert cm.resolve_channel_send_scope(channel="#Weather") == "#sea" + + def test_channel_global_marker_overrides_global_fallback(self): + config = _make_config(outgoing_flood_scope_override="#west") + config.set("Channels", "flood_scope.weather", "*") + cm = _command_manager(config) + + assert cm.resolve_channel_send_scope(channel="weather") == "*" + def test_precedence_explicit_over_message(self): cm = _command_manager(_make_config()) msg = MeshMessage(content="x", channel="general", is_dm=False, reply_scope="#east") assert cm.resolve_channel_send_scope(scope="#west", message=msg) == "#west" + def test_precedence_reply_and_config_section_over_channel(self): + config = _make_config() + config.set("Channels", "flood_scope.weather", "#channel") + config.add_section("Weather_Service") + config.set("Weather_Service", "flood_scope", "#plugin") + cm = _command_manager(config) + msg = MeshMessage(content="x", channel="weather", is_dm=False, reply_scope="#reply") + + assert cm.resolve_channel_send_scope(message=msg, config_section="Weather_Service") == "#reply" + assert cm.resolve_channel_send_scope( + config_section="Weather_Service", channel="weather" + ) == "#plugin" + class _StubService(BaseServicePlugin): config_section = "Weather_Service" @@ -123,6 +150,39 @@ async def test_send_channel_message_applies_override_when_resolve_returns_none() assert set_flood_scope.await_args_list[0].args[0] == "#west" +@pytest.mark.asyncio +async def test_send_channel_message_applies_channel_scope(): + config = _make_config(outgoing_flood_scope_override="#west") + config.set("Channels", "flood_scope.weather", "sea") + bot = MagicMock() + bot.config = config + bot.logger = Mock() + bot.connected = True + bot.is_radio_zombie = False + bot.is_radio_offline = False + bot.channel_manager.get_channel_number.return_value = 2 + + set_flood_scope = AsyncMock(return_value=MagicMock(type="OK")) + send_chan_msg = AsyncMock(return_value=MagicMock(type="OK", payload={})) + bot.meshcore = MagicMock() + bot.meshcore.commands.set_flood_scope = set_flood_scope + bot.meshcore.commands.send_chan_msg = send_chan_msg + + cm = object.__new__(CommandManager) + cm.bot = bot + cm.logger = bot.logger + cm.flood_scope_allow_global = False + cm.flood_scope_keys = {} + cm._check_rate_limits = AsyncMock(return_value=(True, None)) + cm._handle_send_result = MagicMock(return_value=True) + cm._is_no_event_received = MagicMock(return_value=False) + + await cm.send_channel_message("#Weather", "hi", scope=None) + + set_flood_scope.assert_awaited() + assert set_flood_scope.await_args_list[0].args[0] == "#sea" + + # --------------------------------------------------------------------------- # Shared helpers for Path-F and Path-G tests # ---------------------------------------------------------------------------