diff --git a/config.ini.example b/config.ini.example index df131e5..efe2d6f 100644 --- a/config.ini.example +++ b/config.ini.example @@ -251,15 +251,17 @@ max_response_hops = 7 prefix_bytes = 1 # List of region scopes (comma-separated) that the bot will reply to when messages arrive on those scopes. -# Responses will be sent with the same scope hash. +# Replies mirror scope via RF correlation (TC_FLOOD / GRP_TXT); stale ADVERT/other packets are not used for scope. +# Responses will be sent with the same scope hash when correlation succeeds. # Example: flood_scopes = *,#west,#east # Use * to allow replies to unscoped FLOOD messages. # Leave blank or commented to use the default (all). # flood_scopes = #region, #otherregion # Outgoing flood scope override (optional) -# Set this to a region (e.g. #west) to always use that scope for all outgoing channel messages. -# Leave commented or empty to let the bot automatically match the incoming message’s region. +# Fixed scope for proactive sends and when reply_scope is unset (webhooks, scheduled messages). +# Replies with matched reply_scope still use that scope; this does not override per-message mirror. +# Leave commented or empty to rely on auto-mirror from flood_scopes correlation only. # You can use "region" or "#region" (the # is added if missing). # outgoing_flood_scope_override = #west diff --git a/docs/configuration.md b/docs/configuration.md index f9eb7c1..debef8d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -36,8 +36,8 @@ If you rely on config-file-only workflows, restart the bot after changing `[Logg - **`respond_to_dms`** – If `true`, the bot responds to direct messages; if `false`, it ignores DMs. - **`channel_keywords`** – Optional. When set (comma-separated command/keyword names), only those triggers are answered **in channels**; DMs always get all triggers. Use this to reduce channel traffic by making heavy triggers (e.g. `wx`, `satpass`, `joke`) DM-only. Leave empty or omit to allow all triggers in monitored channels. Per-command **`channels = `** (empty) in a command’s section also forces that command to be DM-only; see `config.ini.example` for examples (e.g. `[Joke_Command]`). - **`max_response_hops`** - Default: 64 (code fallback); 7 in the shipped config templates. The bot will ignore messages that have traveled more than this number of hops. A value at or below 10 is recommended — in most meshes, anything higher is almost never an intentional message meant to trigger this bot, so lowering it keeps the bot from amplifying long flood traffic (#161). -- **`outgoing_flood_scope_override`** – Optional. Overrides the scope the bot uses for all outbound channel message sends. When **not set** (default), the bot automatically mirrors the scope of each incoming TC_FLOOD message: a reply to a `#west`-scoped message is sent with `#west` scope, and a reply to a plain (unscoped) FLOOD message is sent as classic global flood. When **set** to a region name like `#west`, the bot always uses that fixed scope for every outbound send, ignoring the incoming message's scope. -- **`flood_scopes`** – Optional. Comma-separated list of named scopes the bot will **accept and reply to**. When set, this acts as an allowlist: only TC_FLOOD messages matching one of these scopes receive a reply, and the reply is sent using the same scope as the incoming message (auto-mirror). Regular (unscoped) FLOOD messages are blocked unless `*` is included in the list. Leave empty or omit to accept all messages regardless of scope. +- **`outgoing_flood_scope_override`** – Optional. Fixed regional scope for outbound channel sends when no per-message scope is passed to `send_channel_message`. When **not set** (default), replies use **`reply_scope`** from inbound TC_FLOOD correlation (auto-mirror). When **set** (e.g. `#west`), that scope is used for proactive sends (webhooks, scheduled messages, feeds) and whenever `reply_scope` is unset. It does **not** override an explicit `reply_scope` on a reply. Unscoped FLOOD uses global flood unless this override or `reply_scope` applies. +- **`flood_scopes`** – Optional. Comma-separated list of named scopes the bot will **accept and reply to**. When set, this acts as an allowlist: only TC_FLOOD messages matching one of these scopes receive a reply, and the reply is sent using the same scope as the incoming message (auto-mirror via `reply_scope`). Regular (unscoped) FLOOD messages are blocked unless `*` is included in the list. Leave empty or omit to accept all messages regardless of scope. **Auto-mirror requires correct RF correlation** (TC_FLOOD / GRP_TXT in the RF cache); if correlation fails, the bot will not use a stale ADVERT or other packet for scope and may ignore the message when `*` is not listed. ### outgoing_flood_scope_override vs flood_scopes @@ -45,8 +45,8 @@ These two options are independent and serve different purposes: | Option | Controls | |--------|----------| -| `outgoing_flood_scope_override` | What scope the bot *sends replies with* (fixed outbound override; omit for auto-mirror) | -| `flood_scopes` | Which incoming scopes the bot *accepts* (allowlist + per-message scope mirroring) | +| `outgoing_flood_scope_override` | Default/fallback outbound scope when `reply_scope` is unset (proactive sends); does not override `reply_scope` on replies | +| `flood_scopes` | Which incoming scopes the bot *accepts* (allowlist + per-message `reply_scope` from RF correlation) | **Example — auto-mirror incoming scope (default, no override needed):** ```ini @@ -60,11 +60,11 @@ flood_scopes = #west, #east, * ``` Same as above, but `*` opts in to also accepting regular (unscoped) FLOOD messages. -**Example — fixed outbound scope regardless of incoming scope:** +**Example — fixed outbound scope for proactive sends and when mirror fails:** ```ini outgoing_flood_scope_override = #west ``` -The bot always sends replies using the `#west` scope. All incoming messages (scoped or not) are accepted. +Channel replies still prefer `reply_scope` from correlated TC_FLOOD when present. Override applies when `reply_scope` is unset (webhooks, scheduled jobs, or failed RF scope correlation). **Example — fixed outbound scope, restricted to a matching inbound scope:** ```ini diff --git a/modules/command_manager.py b/modules/command_manager.py index ba03f32..2e90650 100644 --- a/modules/command_manager.py +++ b/modules/command_manager.py @@ -1174,12 +1174,43 @@ class CommandManager: scope_is_global = scope_to_use in ("", "*", "0", "None") if not scope_is_global: scope_to_use = self._normalize_scope_name(scope_to_use) + override_cfg = self._outgoing_flood_scope_override() if scope_is_global: - self.logger.debug("Outbound channel flood scope: global (no set_flood_scope)") + if override_cfg: + self.logger.warning( + "Outbound channel flood scope: global (no set_flood_scope); " + "outgoing_flood_scope_override=%r was not applied " + "(explicit scope=%r)", + override_cfg, + scope, + ) + else: + self.logger.debug("Outbound channel flood scope: global (no set_flood_scope)") else: - self.logger.debug("Outbound channel flood scope: %s (set_flood_scope)", scope_to_use) - if not scope_is_global and hasattr(self.bot.meshcore.commands, "set_flood_scope"): - await self.bot.meshcore.commands.set_flood_scope(scope_to_use) + scope_source = "explicit argument" if scope is not None else ( + "outgoing_flood_scope_override" + if resolved is None and override_cfg + else "reply_scope or config" + ) + self.logger.info( + "Outbound channel flood scope: %s (%s; set_flood_scope)", + scope_to_use, + scope_source, + ) + if not scope_is_global and not hasattr(self.bot.meshcore.commands, "set_flood_scope"): + self.logger.warning( + "Regional flood scope %r requested but meshcore.commands.set_flood_scope " + "is unavailable; channel message will use device default (often global flood)", + scope_to_use, + ) + elif not scope_is_global: + _scope_result = await self.bot.meshcore.commands.set_flood_scope(scope_to_use) + if _scope_result is None or getattr(_scope_result, "type", None) == "ERROR": + self.logger.warning( + "set_flood_scope(%s) failed (result=%s); " + "message will be sent with current firmware scope", + scope_to_use, _scope_result, + ) target = f"{channel} (channel {channel_num})" # Retry on no_event_received: max 2 extra attempts, 2s apart @@ -1192,7 +1223,11 @@ class CommandManager: ) finally: if not scope_is_global and hasattr(self.bot.meshcore.commands, "set_flood_scope"): - await self.bot.meshcore.commands.set_flood_scope("*") + _restore_result = await self.bot.meshcore.commands.set_flood_scope("*") + if _restore_result is None or getattr(_restore_result, "type", None) == "ERROR": + self.logger.warning( + "set_flood_scope('*') restore failed (result=%s)", _restore_result + ) if self._is_no_event_received(result) and _attempt < _max_retries: self.logger.warning( @@ -1202,7 +1237,12 @@ class CommandManager: await asyncio.sleep(2) # Re-apply scope for next attempt if not scope_is_global and hasattr(self.bot.meshcore.commands, "set_flood_scope"): - await self.bot.meshcore.commands.set_flood_scope(scope_to_use) + _scope_result = await self.bot.meshcore.commands.set_flood_scope(scope_to_use) + if _scope_result is None or getattr(_scope_result, "type", None) == "ERROR": + self.logger.warning( + "set_flood_scope(%s) failed on retry re-apply (result=%s)", + scope_to_use, _scope_result, + ) continue break diff --git a/modules/message_handler.py b/modules/message_handler.py index aeda0fd..a198d3e 100644 --- a/modules/message_handler.py +++ b/modules/message_handler.py @@ -212,6 +212,40 @@ class MessageHandler: return 0 return rt + @staticmethod + def _grp_txt_payload_type_int() -> int: + """Payload type used for channel text on TC_FLOOD (GRP_TXT).""" + return int(PayloadType.GRP_TXT.value) + + def _is_rf_data_scope_eligible( + self, + rf_data: dict[str, Any] | None, + packet_info: dict[str, Any] | None = None, + ) -> bool: + """True when RF row has fields needed for TC_FLOOD regional scope HMAC matching.""" + if not rf_data: + return False + rt = rf_data.get("route_type_int") + tc_code1 = rf_data.get("transport_code1") + payload_type = rf_data.get("payload_type_int") + scope_payload_hex = rf_data.get("scope_payload_hex") or "" + + dec_rt, dec_tc, dec_pt, dec_hex = self._scope_fields_from_packet_info(packet_info) + if dec_rt == 0: + rt = 0 + if dec_tc is not None: + tc_code1 = dec_tc + if dec_pt is not None: + payload_type = dec_pt + if dec_hex: + scope_payload_hex = dec_hex + + if rt != int(RouteType.TRANSPORT_FLOOD.value): + return False + if tc_code1 is None or payload_type is None or not scope_payload_hex: + return False + return int(payload_type) == self._grp_txt_payload_type_int() + def _is_old_cached_message(self, timestamp: Any) -> bool: """Check if a message timestamp indicates it's from before bot connection. @@ -1358,8 +1392,62 @@ class MessageHandler: self.recent_rf_data.sort(key=lambda x: x.get("timestamp", 0), reverse=True) self.recent_rf_data = self.recent_rf_data[: self._max_rf_cache_size] + async def _correlate_channel_message_rf_data( + self, + message_packet_prefix: str | None, + message_pubkey: str, + payload: dict[str, Any], + *, + scope_eligible_only: bool, + extended_timeout: float, + ) -> dict[str, Any] | None: + """Correlate a channel message with cached RF log rows (strategies 1–4).""" + recent_rf_data: dict[str, Any] | None = None + + if message_packet_prefix: + recent_rf_data = self.find_recent_rf_data( + message_packet_prefix, scope_eligible_only=scope_eligible_only + ) + elif message_pubkey: + recent_rf_data = self.find_recent_rf_data( + message_pubkey, scope_eligible_only=scope_eligible_only + ) + + if not recent_rf_data and self.enhanced_correlation and not scope_eligible_only: + correlation_key = message_packet_prefix or message_pubkey + message_id = f"{correlation_key}_{int(time.time() * 1000)}" + self.store_message_for_correlation(message_id, payload) + await asyncio.sleep(0.1) + recent_rf_data = self.correlate_message_with_rf_data(message_id) + + if not recent_rf_data: + if message_packet_prefix: + recent_rf_data = self.find_recent_rf_data( + message_packet_prefix, + max_age_seconds=extended_timeout, + scope_eligible_only=scope_eligible_only, + ) + elif message_pubkey: + recent_rf_data = self.find_recent_rf_data( + message_pubkey, + max_age_seconds=extended_timeout, + scope_eligible_only=scope_eligible_only, + ) + + if not recent_rf_data: + recent_rf_data = self.find_recent_rf_data( + max_age_seconds=extended_timeout, + scope_eligible_only=scope_eligible_only, + ) + + return recent_rf_data + def find_recent_rf_data( - self, correlation_key: str | None = None, max_age_seconds: float | None = None + self, + correlation_key: str | None = None, + max_age_seconds: float | None = None, + *, + scope_eligible_only: bool = False, ) -> dict[str, Any] | None: """Find recent RF data for SNR/RSSI and packet decoding with improved correlation @@ -1367,6 +1455,10 @@ class MessageHandler: correlation_key: Can be either: - packet_prefix (from raw_hex[:32]) for RF data correlation - pubkey_prefix (from message payload) for message correlation + max_age_seconds: Maximum age of RF cache entries to consider. + scope_eligible_only: When True, only return TC_FLOOD / GRP_TXT rows suitable + for flood_scopes HMAC matching. Strategy 4 (most-recent fallback) skips + unrelated packets such as ADVERT. """ import time @@ -1383,21 +1475,30 @@ class MessageHandler: self.logger.debug(f"No recent RF data found within {max_age_seconds}s window") return None + def _accept(data: dict[str, Any]) -> dict[str, Any] | None: + if scope_eligible_only and not self._is_rf_data_scope_eligible(data): + return None + return data + # Strategy 1: Try exact packet prefix match first (for RF data correlation) if correlation_key: for data in recent_data: rf_packet_prefix = data.get("packet_prefix", "") or "" if rf_packet_prefix == correlation_key: - self.logger.debug(f"Found exact packet prefix match: {rf_packet_prefix}") - return data + accepted = _accept(data) + if accepted: + self.logger.debug(f"Found exact packet prefix match: {rf_packet_prefix}") + return accepted # Strategy 2: Try pubkey prefix match (for message correlation) if correlation_key: for data in recent_data: rf_pubkey_prefix = data.get("pubkey_prefix", "") or "" if rf_pubkey_prefix == correlation_key: - self.logger.debug(f"Found exact pubkey prefix match: {rf_pubkey_prefix}") - return data + accepted = _accept(data) + if accepted: + self.logger.debug(f"Found exact pubkey prefix match: {rf_pubkey_prefix}") + return accepted # Strategy 3: Try partial packet prefix matches if correlation_key: @@ -1406,16 +1507,37 @@ class MessageHandler: # Check for partial match (at least 16 characters) min_length = min(len(rf_packet_prefix), len(correlation_key), 16) if rf_packet_prefix[:min_length] == correlation_key[:min_length] and min_length >= 16: - self.logger.debug( - f"Found partial packet prefix match: {rf_packet_prefix[:16]}... matches {correlation_key[:16]}..." - ) - return data + accepted = _accept(data) + if accepted: + self.logger.debug( + f"Found partial packet prefix match: {rf_packet_prefix[:16]}... " + f"matches {correlation_key[:16]}..." + ) + return accepted # Strategy 4: Use most recent data (fallback for timing issues) if recent_data: - most_recent = max(recent_data, key=lambda x: x["timestamp"]) + candidates = recent_data + if scope_eligible_only: + candidates = [d for d in recent_data if self._is_rf_data_scope_eligible(d)] + if not candidates: + self.logger.debug( + "No scope-eligible RF data in cache for fallback " + "(need TC_FLOOD GRP_TXT with transport code)" + ) + return None + most_recent = max(candidates, key=lambda x: x["timestamp"]) packet_prefix = most_recent.get("packet_prefix", "unknown") - self.logger.debug(f"Using most recent RF data (fallback): {packet_prefix} at {most_recent['timestamp']}") + if scope_eligible_only: + self.logger.debug( + "Using most recent scope-eligible RF data (fallback): %s at %s", + packet_prefix, + most_recent["timestamp"], + ) + else: + self.logger.debug( + f"Using most recent RF data (fallback): {packet_prefix} at {most_recent['timestamp']}" + ) return most_recent return None @@ -2028,42 +2150,28 @@ class MessageHandler: f"Processing channel message from packet prefix: {message_packet_prefix}, pubkey: {message_pubkey}" ) - # Enhanced RF data correlation with multiple strategies - recent_rf_data = None - - # Strategy 1: Try immediate correlation using packet prefix - if message_packet_prefix: - recent_rf_data = self.find_recent_rf_data(message_packet_prefix) - elif message_pubkey: - # Fallback to pubkey correlation - recent_rf_data = self.find_recent_rf_data(message_pubkey) - - # Strategy 2: If no immediate match and enhanced correlation is enabled, store message and wait briefly - if not recent_rf_data and self.enhanced_correlation: - import time - - correlation_key = message_packet_prefix or message_pubkey - message_id = f"{correlation_key}_{int(time.time() * 1000)}" - self.store_message_for_correlation(message_id, payload) - - # Wait a short time for RF data to arrive (non-blocking) - await asyncio.sleep(0.1) # 100ms wait - recent_rf_data = self.correlate_message_with_rf_data(message_id) - - # Strategy 3: Try with extended timeout if still no match - if not recent_rf_data: - extended_timeout = self.rf_data_timeout * 2 # Double the normal timeout - if message_packet_prefix: - recent_rf_data = self.find_recent_rf_data(message_packet_prefix, max_age_seconds=extended_timeout) - elif message_pubkey: - recent_rf_data = self.find_recent_rf_data(message_pubkey, max_age_seconds=extended_timeout) - - # Strategy 4: Use most recent RF data as last resort - if not recent_rf_data: - extended_timeout = self.rf_data_timeout * 2 # Double the normal timeout - recent_rf_data = self.find_recent_rf_data(max_age_seconds=extended_timeout) + extended_timeout = self.rf_data_timeout * 2 + recent_rf_data = await self._correlate_channel_message_rf_data( + message_packet_prefix, + message_pubkey, + payload, + scope_eligible_only=False, + extended_timeout=extended_timeout, + ) + scope_rf_data = await self._correlate_channel_message_rf_data( + message_packet_prefix, + message_pubkey, + payload, + scope_eligible_only=True, + extended_timeout=extended_timeout, + ) + if scope_rf_data and scope_rf_data is not recent_rf_data: + self.logger.debug( + "Using separate scope-eligible RF correlation (path/SNR source differs)" + ) packet_info: dict[str, Any] | None = None + scope_packet_info: dict[str, Any] | None = None if recent_rf_data and recent_rf_data.get("raw_hex"): raw_hex = recent_rf_data["raw_hex"] self.logger.info(f"🔍 FOUND RF DATA: {len(raw_hex)} chars, starts with: {raw_hex[:32]}...") @@ -2136,29 +2244,44 @@ class MessageHandler: hops = payload.get("path_len", 255) path_string = None - # Scope matching: if the RF data is a TC_FLOOD, check whether its transport - # code matches any configured flood_scopes entry. If so, the reply should - # use the same scope so it reaches the same scoped network segment. + if scope_rf_data and scope_rf_data.get("raw_hex"): + # Decode the full inner MeshCore packet (header + path + ciphertext). + # scope_payload_hex is ciphertext-only for HMAC; do not pass it to decode_meshcore_packet. + inner_packet_hex = scope_rf_data.get("payload") + if inner_packet_hex: + scope_packet_info = self.decode_meshcore_packet(inner_packet_hex) + if scope_rf_data.get("packet_hash") and scope_packet_info: + scope_packet_info["packet_hash"] = scope_rf_data["packet_hash"] + + # Scope matching: use scope-eligible RF only (never a stale ADVERT fallback). reply_scope: str | None = None - if recent_rf_data: - scope_keys = getattr(getattr(self.bot, "command_manager", None), "flood_scope_keys", {}) + cmd_mgr = getattr(self.bot, "command_manager", None) + scope_keys = getattr(cmd_mgr, "flood_scope_keys", {}) + if scope_rf_data and scope_keys: reply_scope = self._resolve_reply_scope_from_rf_data( - recent_rf_data, packet_info, scope_keys + scope_rf_data, scope_packet_info, scope_keys ) # Allowlist enforcement: when flood_scopes is configured, only reply to # messages whose scope matched an entry. Unscoped FLOOD is allowed only # when '*' (or equivalent) is explicitly listed. - cmd_mgr = getattr(self.bot, "command_manager", None) - scope_keys = getattr(cmd_mgr, "flood_scope_keys", {}) if scope_keys and reply_scope is None: allow_global = getattr(cmd_mgr, "flood_scope_allow_global", False) - rt_for_check = self._effective_route_type_int(recent_rf_data, packet_info) - if rt_for_check == 0: + if scope_rf_data and self._is_rf_data_scope_eligible( + scope_rf_data, scope_packet_info + ): self.logger.info("Ignoring TC_FLOOD: scope not in flood_scopes allowlist") return - elif not allow_global: - self.logger.debug("Ignoring FLOOD: unscoped messages not permitted (add '*' to flood_scopes)") + if not allow_global: + if scope_rf_data is None: + self.logger.info( + "Ignoring channel message: no TC_FLOOD RF correlation for " + "flood_scopes allowlist (avoid replying on wrong scope)" + ) + else: + self.logger.debug( + "Ignoring FLOOD: unscoped messages not permitted (add '*' to flood_scopes)" + ) return # Get the full public key from contacts if available diff --git a/tests/integration/test_rf_scope_correlation.py b/tests/integration/test_rf_scope_correlation.py new file mode 100644 index 0000000..6eb132f --- /dev/null +++ b/tests/integration/test_rf_scope_correlation.py @@ -0,0 +1,179 @@ +"""Regression tests for TC_FLOOD scope RF correlation (snoco bench captures).""" + +import time +from hashlib import sha256 +from unittest.mock import Mock + +import pytest + +from modules.command_manager import CommandManager +from modules.message_handler import MessageHandler +from tests.integration.test_flood_scope_reply import make_transport_code + +def _fresh_rf(entry: dict) -> dict: + """Copy RF fixture with a current timestamp so find_recent_rf_data age filter passes.""" + out = dict(entry) + out["timestamp"] = time.time() + return out + + +SNOCO_PING_RF = { + "timestamp": 0, + "packet_prefix": "33ec149748000000ca37f40824e44f7c", + "pubkey_prefix": None, + "snr": 12.75, + "rssi": -20, + "raw_hex": ( + "33ec149748000000ca37f40824e44f7c4a819746c46d811faa5745753b17ade613e3a648adf8cbf7c1b03b" + ), + "payload": "149748000000ca37f40824e44f7c4a819746c46d811faa5745753b17ade613e3a648adf8cbf7c1b03b", + "route_type_int": 0, + "transport_code1": 18583, + "payload_type_int": 5, + "scope_payload_hex": ( + "ca37f40824e44f7c4a819746c46d811faa5745753b17ade613e3a648adf8cbf7c1b03b" + ), +} + +SNOCO_PING_REPLY_HEX = ( + "14337a000000cac8cd8b8e7c8b96d9fc79040f518a4a1c24b536e7f27a6ad4fcd500d6376d3e182e01" +) + +SNOCO_WX_TRIGGER_HEX = ( + "149fad0000020901ca4941e45582364d4849dbb657e3ca1b2b0627b8dc2dc271869b8a503608d92d64ceb5" +) + +SNOCO_WX_REPLY_HEX = ( + "15040901217aca42f8b41fecd26a6c994258382397075a008badd92461435d93823e58e02e8ff23665bf169365a21" + "accb3b3afe36b66176357d77c699f0d5f3be52d1e84dd333d23e381eeebc110ca6d2caa5eac1d11a20cf92093ee0fda693df9639a911e4" + "c0ef0486c828e24a351a29f3bc27fd8778968ce62dfe8d275e70fd8f9893dc18773ea519bda9e7c51da9b95451d8812756bbc07" +) + +STALE_ADVERT_RF = { + "timestamp": 0, + "packet_prefix": "1ce11104c77ee01e72dcb234ee207ce1", + "pubkey_prefix": None, + "snr": 7.0, + "rssi": -31, + "raw_hex": ( + "1ce11104c77ee01e72dcb234ee207ce10f6bc1ce6b21773ffc077c44b15a7d9f48c1793b12a527198a910b6a65806c520a5583733" + "f9c4091b912c35c21f01d8a41d7a2f4f341401077248ed0354f65de7ac8594ab243238c5e10ecc641c3e18dee632b4371eef9a3f3e8" + "ed09923d2cd50204fbb9f8224953512d494d532d5270747222" + ), + "route_type_int": 1, + "transport_code1": None, + "payload_type_int": 4, + "scope_payload_hex": "", +} + + +def _scope_key(name: str) -> bytes: + return sha256(name.encode()).digest()[:16] + + +@pytest.fixture +def mh() -> MessageHandler: + handler = object.__new__(MessageHandler) + handler.logger = Mock() + handler.rf_data_timeout = 15.0 + handler.enhanced_correlation = False + handler.recent_rf_data = [] + handler.pending_messages = {} + return handler + + +class TestScopeEligibleRfData: + def test_snoco_ping_rf_is_scope_eligible(self, mh: MessageHandler): + assert mh._is_rf_data_scope_eligible(SNOCO_PING_RF) is True + + def test_stale_advert_rf_is_not_scope_eligible(self, mh: MessageHandler): + assert mh._is_rf_data_scope_eligible(STALE_ADVERT_RF) is False + + def test_find_recent_scope_fallback_skips_advert(self, mh: MessageHandler): + advert = _fresh_rf(STALE_ADVERT_RF) + ping = _fresh_rf(SNOCO_PING_RF) + mh.recent_rf_data = [advert, ping] + result = mh.find_recent_rf_data(scope_eligible_only=True) + assert result is ping + + def test_find_recent_scope_fallback_returns_none_without_tc_flood(self, mh: MessageHandler): + mh.recent_rf_data = [_fresh_rf(STALE_ADVERT_RF)] + assert mh.find_recent_rf_data(scope_eligible_only=True) is None + + +class TestSnocoPingScopeMatch: + def test_ping_rf_resolves_reply_scope_snoco(self, mh: MessageHandler): + scope_keys = {"#snoco": _scope_key("#snoco")} + packet_info = mh.decode_meshcore_packet(SNOCO_PING_RF["payload"]) + reply_scope = mh._resolve_reply_scope_from_rf_data( + SNOCO_PING_RF, packet_info, scope_keys + ) + assert reply_scope == "#snoco" + + def test_raw_wrapper_decode_does_not_invalidate_scope_eligibility(self, mh: MessageHandler): + """Decoding RF raw_hex without inner packet must not poison allowlist checks.""" + bad_packet_info = mh.decode_meshcore_packet(SNOCO_PING_RF["raw_hex"]) + assert bad_packet_info is not None + assert bad_packet_info.get("route_type_name") != "TRANSPORT_FLOOD" + assert mh._is_rf_data_scope_eligible(SNOCO_PING_RF, bad_packet_info) is True + + def test_scope_payload_hex_is_not_a_decodable_packet(self, mh: MessageHandler): + assert mh.decode_meshcore_packet(SNOCO_PING_RF["scope_payload_hex"]) is None + + def test_ping_reply_on_air_is_transport_flood_snoco(self, mh: MessageHandler): + info = mh.decode_meshcore_packet(SNOCO_PING_REPLY_HEX) + assert info is not None + assert info.get("route_type_name") == "TRANSPORT_FLOOD" + tc1 = (info.get("transport_codes") or {}).get("code1") + payload = bytes.fromhex(info.get("payload_hex") or "") + assert MessageHandler._match_scope(tc1, 5, payload, {"#snoco": _scope_key("#snoco")}) == "#snoco" + + def test_wx_trigger_hex_is_scoped_snoco(self, mh: MessageHandler): + info = mh.decode_meshcore_packet(SNOCO_WX_TRIGGER_HEX) + assert info.get("route_type_name") == "TRANSPORT_FLOOD" + tc1 = (info.get("transport_codes") or {}).get("code1") + payload = bytes.fromhex(info.get("payload_hex") or "") + assert tc1 == make_transport_code("#snoco", 5, payload) + + def test_wx_reply_hex_is_global_flood(self, mh: MessageHandler): + info = mh.decode_meshcore_packet(SNOCO_WX_REPLY_HEX) + assert info.get("route_type_name") == "FLOOD" + assert not info.get("has_transport_codes") + + +class TestStaleAdvertScopeCorrelation: + """Wx failure mode: ADVERT is most recent but scope lookup must use TC_FLOOD row.""" + + def test_scope_correlation_finds_ping_not_advert(self, mh: MessageHandler): + ping = _fresh_rf(SNOCO_PING_RF) + advert = _fresh_rf(STALE_ADVERT_RF) + advert["timestamp"] = ping["timestamp"] + 1.0 + mh.recent_rf_data = [ping, advert] + path_rf = mh.find_recent_rf_data() + scope_rf = mh.find_recent_rf_data(scope_eligible_only=True) + assert path_rf is advert + assert scope_rf is ping + + def test_resolve_scope_from_advert_returns_none(self, mh: MessageHandler): + scope_keys = {"#snoco": _scope_key("#snoco")} + packet_info = mh.decode_meshcore_packet(STALE_ADVERT_RF["raw_hex"]) + assert ( + mh._resolve_reply_scope_from_rf_data(STALE_ADVERT_RF, packet_info, scope_keys) + is None + ) + + +class TestCommandManagerOutboundScopeLogging: + def test_warning_when_global_despite_override(self): + cm = object.__new__(CommandManager) + cm.bot = Mock() + cm.bot.config = Mock() + cm.bot.config.has_section = Mock(return_value=True) + cm.bot.config.has_option = Mock(return_value=True) + cm.bot.config.get = Mock( + side_effect=lambda section, key, fallback=None: ( + "#snoco" if key == "outgoing_flood_scope_override" else fallback + ) + ) + cm.logger = Mock() + assert cm._outgoing_flood_scope_override() == "#snoco" diff --git a/tests/unit/test_flood_scope_resolve.py b/tests/unit/test_flood_scope_resolve.py index ad3301a..55dd04d 100644 --- a/tests/unit/test_flood_scope_resolve.py +++ b/tests/unit/test_flood_scope_resolve.py @@ -1,7 +1,7 @@ """Unit tests for outbound flood scope resolution.""" import configparser -from unittest.mock import AsyncMock, MagicMock, Mock +from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest @@ -121,3 +121,192 @@ async def test_send_channel_message_applies_override_when_resolve_returns_none() set_flood_scope.assert_awaited() assert set_flood_scope.await_args_list[0].args[0] == "#west" + + +# --------------------------------------------------------------------------- +# Shared helpers for Path-F and Path-G tests +# --------------------------------------------------------------------------- + +def _make_scoped_cm(scope_str: str = "west"): + """CommandManager wired for send_channel_message unit tests with a regional scope.""" + config = _make_config(outgoing_flood_scope_override=scope_str) + 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 = 1 + bot.meshcore = MagicMock() + + 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) + return cm, bot + + +def _ok_result(): + r = MagicMock() + r.type = "OK" + r.payload = {} + return r + + +def _scope_error_result(): + r = MagicMock() + r.type = "ERROR" + r.payload = {} + return r + + +def _no_event_result(): + """Result that the real _is_no_event_received treats as a retry trigger.""" + from meshcore import EventType + r = MagicMock() + r.type = EventType.ERROR + r.payload = {"reason": "no_event_received"} + return r + + +# --------------------------------------------------------------------------- +# Path F: firmware rejects SET_FLOOD_SCOPE +# --------------------------------------------------------------------------- + +class TestSetFloodScopeResultHandling: + """set_flood_scope result checking — warning logged on ERROR/None, send still proceeds.""" + + @pytest.mark.asyncio + async def test_error_result_logs_warning_and_still_sends(self): + """set_flood_scope returning type=ERROR: warning logged, send_chan_msg still called.""" + cm, bot = _make_scoped_cm("west") + bot.meshcore.commands.set_flood_scope = AsyncMock(return_value=_scope_error_result()) + bot.meshcore.commands.send_chan_msg = AsyncMock(return_value=_ok_result()) + cm._is_no_event_received = MagicMock(return_value=False) + + result = await cm.send_channel_message("general", "hi", scope=None) + + assert result is True + bot.meshcore.commands.send_chan_msg.assert_awaited_once() + warning_calls = str(bot.logger.warning.call_args_list) + assert "set_flood_scope" in warning_calls + assert "#west" in warning_calls + + @pytest.mark.asyncio + async def test_none_result_logs_warning_and_still_sends(self): + """set_flood_scope returning None: warning logged, send_chan_msg still called.""" + cm, bot = _make_scoped_cm("west") + bot.meshcore.commands.set_flood_scope = AsyncMock(return_value=None) + bot.meshcore.commands.send_chan_msg = AsyncMock(return_value=_ok_result()) + cm._is_no_event_received = MagicMock(return_value=False) + + result = await cm.send_channel_message("general", "hi", scope=None) + + assert result is True + bot.meshcore.commands.send_chan_msg.assert_awaited_once() + assert any( + "set_flood_scope" in str(c.args) + for c in bot.logger.warning.call_args_list + ) + + @pytest.mark.asyncio + async def test_restore_failure_logs_warning(self): + """set_flood_scope('*') restore returning ERROR: warning logged.""" + cm, bot = _make_scoped_cm("west") + # Pre-send succeeds; restore-to-global fails + bot.meshcore.commands.set_flood_scope = AsyncMock( + side_effect=[_ok_result(), _scope_error_result()] + ) + bot.meshcore.commands.send_chan_msg = AsyncMock(return_value=_ok_result()) + cm._is_no_event_received = MagicMock(return_value=False) + + await cm.send_channel_message("general", "hi", scope=None) + + assert any( + "restore" in str(c.args) + for c in bot.logger.warning.call_args_list + ) + + @pytest.mark.asyncio + async def test_ok_result_no_scope_failure_warning(self): + """set_flood_scope returning OK: no scope-failure warning logged.""" + cm, bot = _make_scoped_cm("west") + bot.meshcore.commands.set_flood_scope = AsyncMock(return_value=_ok_result()) + bot.meshcore.commands.send_chan_msg = AsyncMock(return_value=_ok_result()) + cm._is_no_event_received = MagicMock(return_value=False) + + await cm.send_channel_message("general", "hi", scope=None) + + assert not any( + "set_flood_scope" in str(c.args) and "failed" in str(c.args) + for c in bot.logger.warning.call_args_list + ) + + +# --------------------------------------------------------------------------- +# Path G: retry re-applies scope +# --------------------------------------------------------------------------- + +class TestRetryReappliesScope: + """set_flood_scope(scope) is re-applied before each retry attempt.""" + + @pytest.mark.asyncio + async def test_scope_call_sequence_on_retry(self): + """no_event_received on attempt 0: full set_flood_scope call sequence is correct. + + Expected: set(scope) → send[fail] → set(*) → set(scope) → send[ok] → set(*) + """ + cm, bot = _make_scoped_cm("west") + bot.meshcore.commands.set_flood_scope = AsyncMock(return_value=_ok_result()) + bot.meshcore.commands.send_chan_msg = AsyncMock( + side_effect=[_no_event_result(), _ok_result()] + ) + + with patch("modules.command_manager.asyncio.sleep", new_callable=AsyncMock): + result = await cm.send_channel_message("general", "hi", scope=None) + + assert result is True + calls = [c.args[0] for c in bot.meshcore.commands.set_flood_scope.await_args_list] + assert calls == ["#west", "*", "#west", "*"] + + @pytest.mark.asyncio + async def test_retry_reapply_failure_logs_warning(self): + """set_flood_scope fails on retry re-apply: 'retry re-apply' warning logged.""" + cm, bot = _make_scoped_cm("west") + # pre-send OK, restore OK, re-apply ERROR, second restore OK + bot.meshcore.commands.set_flood_scope = AsyncMock( + side_effect=[_ok_result(), _ok_result(), _scope_error_result(), _ok_result()] + ) + bot.meshcore.commands.send_chan_msg = AsyncMock( + side_effect=[_no_event_result(), _ok_result()] + ) + + with patch("modules.command_manager.asyncio.sleep", new_callable=AsyncMock): + await cm.send_channel_message("general", "hi", scope=None) + + assert any( + "retry re-apply" in str(c.args) + for c in bot.logger.warning.call_args_list + ) + + @pytest.mark.asyncio + async def test_all_retries_exhaust_scope_still_restored(self): + """All 3 attempts fail: set_flood_scope('*') still called after each attempt.""" + cm, bot = _make_scoped_cm("west") + bot.meshcore.commands.set_flood_scope = AsyncMock(return_value=_ok_result()) + bot.meshcore.commands.send_chan_msg = AsyncMock(return_value=_no_event_result()) + cm._handle_send_result = MagicMock(return_value=False) + + with patch("modules.command_manager.asyncio.sleep", new_callable=AsyncMock): + result = await cm.send_channel_message("general", "hi", scope=None) + + assert result is False + restore_calls = [ + c.args[0] for c in bot.meshcore.commands.set_flood_scope.await_args_list + if c.args[0] == "*" + ] + # One restore per attempt (3 attempts total) + assert len(restore_calls) == 3 diff --git a/tests/unit/test_scope_matching.py b/tests/unit/test_scope_matching.py index 29b3e74..1aa8667 100644 --- a/tests/unit/test_scope_matching.py +++ b/tests/unit/test_scope_matching.py @@ -128,6 +128,45 @@ def test_effective_route_type_prefers_decode_tc_flood(): assert mh._effective_route_type_int(recent_rf_data, packet_info) == 0 +def test_scope_eligible_ignores_bad_decode_when_not_tc_flood(): + """RF-wrapper decode must not overwrite cache scope fields (wrong payload_type/path).""" + scope_name = "#snoco" + tc = make_transport_code(scope_name, PAYLOAD_TYPE, PAYLOAD) + rf_data = { + "route_type_int": 0, + "transport_code1": tc, + "payload_type_int": PAYLOAD_TYPE, + "scope_payload_hex": PAYLOAD.hex(), + } + bad_packet_info = { + "route_type": 3, + "transport_codes": {"code1": 5356}, + "payload_type": 12, + "payload_hex": "0000" + PAYLOAD.hex(), + } + mh = _make_handler_for_scope_resolve() + assert mh._is_rf_data_scope_eligible(rf_data, bad_packet_info) is True + + +def test_scope_eligible_uses_decode_when_tc_flood(): + scope_name = "#snoco" + tc = make_transport_code(scope_name, PAYLOAD_TYPE, PAYLOAD) + rf_data = { + "route_type_int": 1, + "transport_code1": None, + "payload_type_int": 4, + "scope_payload_hex": "", + } + good_packet_info = { + "route_type": 0, + "transport_codes": {"code1": tc}, + "payload_type": PAYLOAD_TYPE, + "payload_hex": PAYLOAD.hex(), + } + mh = _make_handler_for_scope_resolve() + assert mh._is_rf_data_scope_eligible(rf_data, good_packet_info) is True + + def test_scope_fields_from_packet_info_enum_route_type(): class _EnumVal: def __init__(self, value: int):