mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-08-28 23:00:41 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user