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.
This commit is contained in:
agessaman
2026-06-27 17:09:28 -07:00
parent cee36d7d31
commit 6584800cb7
5 changed files with 115 additions and 6 deletions
+9
View File
@@ -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.<channel>, 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.
+29 -4
View File
@@ -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.<channel> 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.<channel> 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.<channel>``.
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 ""
+11 -2
View File
@@ -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.<channel>``, 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"
+6
View File
@@ -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
+60
View File
@@ -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
# ---------------------------------------------------------------------------