fix(config): clarify flood scope configuration and logging behavior

- Updated `config.ini.example` and `configuration.md` to enhance clarity on the `outgoing_flood_scope_override` and `flood_scopes` settings, detailing their behavior and interactions.
- Improved logging in `CommandManager` to provide better insights into scope resolution and potential issues during message sending.
- Added new methods in `MessageHandler` for improved RF data correlation, ensuring eligibility checks for flood scope matching.
- Enhanced unit tests to cover new behaviors and ensure robust handling of flood scope configurations.
This commit is contained in:
agessaman
2026-05-17 14:38:47 -07:00
parent 41fe46babb
commit cb54ca6a45
7 changed files with 645 additions and 73 deletions
+5 -3
View File
@@ -251,15 +251,17 @@ max_response_hops = 7
prefix_bytes = 1 prefix_bytes = 1
# List of region scopes (comma-separated) that the bot will reply to when messages arrive on those scopes. # 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 # Example: flood_scopes = *,#west,#east
# Use * to allow replies to unscoped FLOOD messages. # Use * to allow replies to unscoped FLOOD messages.
# Leave blank or commented to use the default (all). # Leave blank or commented to use the default (all).
# flood_scopes = #region, #otherregion # flood_scopes = #region, #otherregion
# Outgoing flood scope override (optional) # Outgoing flood scope override (optional)
# Set this to a region (e.g. #west) to always use that scope for all outgoing channel messages. # Fixed scope for proactive sends and when reply_scope is unset (webhooks, scheduled messages).
# Leave commented or empty to let the bot automatically match the incoming messages region. # 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). # You can use "region" or "#region" (the # is added if missing).
# outgoing_flood_scope_override = #west # outgoing_flood_scope_override = #west
+6 -6
View File
@@ -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. - **`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 commands section also forces that command to be DM-only; see `config.ini.example` for examples (e.g. `[Joke_Command]`). - **`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 commands 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). - **`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. - **`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). Regular (unscoped) FLOOD messages are blocked unless `*` is included in the list. Leave empty or omit to accept all messages regardless of 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 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 ### outgoing_flood_scope_override vs flood_scopes
@@ -45,8 +45,8 @@ These two options are independent and serve different purposes:
| Option | Controls | | Option | Controls |
|--------|----------| |--------|----------|
| `outgoing_flood_scope_override` | What scope the bot *sends replies with* (fixed outbound override; omit for auto-mirror) | | `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 scope mirroring) | | `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):** **Example — auto-mirror incoming scope (default, no override needed):**
```ini ```ini
@@ -60,11 +60,11 @@ flood_scopes = #west, #east, *
``` ```
Same as above, but `*` opts in to also accepting regular (unscoped) FLOOD messages. 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 ```ini
outgoing_flood_scope_override = #west 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:** **Example — fixed outbound scope, restricted to a matching inbound scope:**
```ini ```ini
+46 -6
View File
@@ -1174,12 +1174,43 @@ class CommandManager:
scope_is_global = scope_to_use in ("", "*", "0", "None") scope_is_global = scope_to_use in ("", "*", "0", "None")
if not scope_is_global: if not scope_is_global:
scope_to_use = self._normalize_scope_name(scope_to_use) scope_to_use = self._normalize_scope_name(scope_to_use)
override_cfg = self._outgoing_flood_scope_override()
if scope_is_global: 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: else:
self.logger.debug("Outbound channel flood scope: %s (set_flood_scope)", scope_to_use) scope_source = "explicit argument" if scope is not None else (
if not scope_is_global and hasattr(self.bot.meshcore.commands, "set_flood_scope"): "outgoing_flood_scope_override"
await self.bot.meshcore.commands.set_flood_scope(scope_to_use) 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})" target = f"{channel} (channel {channel_num})"
# Retry on no_event_received: max 2 extra attempts, 2s apart # Retry on no_event_received: max 2 extra attempts, 2s apart
@@ -1192,7 +1223,11 @@ class CommandManager:
) )
finally: finally:
if not scope_is_global and hasattr(self.bot.meshcore.commands, "set_flood_scope"): 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: if self._is_no_event_received(result) and _attempt < _max_retries:
self.logger.warning( self.logger.warning(
@@ -1202,7 +1237,12 @@ class CommandManager:
await asyncio.sleep(2) await asyncio.sleep(2)
# Re-apply scope for next attempt # Re-apply scope for next attempt
if not scope_is_global and hasattr(self.bot.meshcore.commands, "set_flood_scope"): 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 continue
break break
+180 -57
View File
@@ -212,6 +212,40 @@ class MessageHandler:
return 0 return 0
return rt 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: def _is_old_cached_message(self, timestamp: Any) -> bool:
"""Check if a message timestamp indicates it's from before bot connection. """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.sort(key=lambda x: x.get("timestamp", 0), reverse=True)
self.recent_rf_data = self.recent_rf_data[: self._max_rf_cache_size] 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 14)."""
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( 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: ) -> dict[str, Any] | None:
"""Find recent RF data for SNR/RSSI and packet decoding with improved correlation """Find recent RF data for SNR/RSSI and packet decoding with improved correlation
@@ -1367,6 +1455,10 @@ class MessageHandler:
correlation_key: Can be either: correlation_key: Can be either:
- packet_prefix (from raw_hex[:32]) for RF data correlation - packet_prefix (from raw_hex[:32]) for RF data correlation
- pubkey_prefix (from message payload) for message 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 import time
@@ -1383,21 +1475,30 @@ class MessageHandler:
self.logger.debug(f"No recent RF data found within {max_age_seconds}s window") self.logger.debug(f"No recent RF data found within {max_age_seconds}s window")
return None 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) # Strategy 1: Try exact packet prefix match first (for RF data correlation)
if correlation_key: if correlation_key:
for data in recent_data: for data in recent_data:
rf_packet_prefix = data.get("packet_prefix", "") or "" rf_packet_prefix = data.get("packet_prefix", "") or ""
if rf_packet_prefix == correlation_key: if rf_packet_prefix == correlation_key:
self.logger.debug(f"Found exact packet prefix match: {rf_packet_prefix}") accepted = _accept(data)
return 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) # Strategy 2: Try pubkey prefix match (for message correlation)
if correlation_key: if correlation_key:
for data in recent_data: for data in recent_data:
rf_pubkey_prefix = data.get("pubkey_prefix", "") or "" rf_pubkey_prefix = data.get("pubkey_prefix", "") or ""
if rf_pubkey_prefix == correlation_key: if rf_pubkey_prefix == correlation_key:
self.logger.debug(f"Found exact pubkey prefix match: {rf_pubkey_prefix}") accepted = _accept(data)
return data if accepted:
self.logger.debug(f"Found exact pubkey prefix match: {rf_pubkey_prefix}")
return accepted
# Strategy 3: Try partial packet prefix matches # Strategy 3: Try partial packet prefix matches
if correlation_key: if correlation_key:
@@ -1406,16 +1507,37 @@ class MessageHandler:
# Check for partial match (at least 16 characters) # Check for partial match (at least 16 characters)
min_length = min(len(rf_packet_prefix), len(correlation_key), 16) 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: if rf_packet_prefix[:min_length] == correlation_key[:min_length] and min_length >= 16:
self.logger.debug( accepted = _accept(data)
f"Found partial packet prefix match: {rf_packet_prefix[:16]}... matches {correlation_key[:16]}..." if accepted:
) self.logger.debug(
return data 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) # Strategy 4: Use most recent data (fallback for timing issues)
if recent_data: 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") 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 most_recent
return None return None
@@ -2028,42 +2150,28 @@ class MessageHandler:
f"Processing channel message from packet prefix: {message_packet_prefix}, pubkey: {message_pubkey}" f"Processing channel message from packet prefix: {message_packet_prefix}, pubkey: {message_pubkey}"
) )
# Enhanced RF data correlation with multiple strategies extended_timeout = self.rf_data_timeout * 2
recent_rf_data = None recent_rf_data = await self._correlate_channel_message_rf_data(
message_packet_prefix,
# Strategy 1: Try immediate correlation using packet prefix message_pubkey,
if message_packet_prefix: payload,
recent_rf_data = self.find_recent_rf_data(message_packet_prefix) scope_eligible_only=False,
elif message_pubkey: extended_timeout=extended_timeout,
# Fallback to pubkey correlation )
recent_rf_data = self.find_recent_rf_data(message_pubkey) scope_rf_data = await self._correlate_channel_message_rf_data(
message_packet_prefix,
# Strategy 2: If no immediate match and enhanced correlation is enabled, store message and wait briefly message_pubkey,
if not recent_rf_data and self.enhanced_correlation: payload,
import time scope_eligible_only=True,
extended_timeout=extended_timeout,
correlation_key = message_packet_prefix or message_pubkey )
message_id = f"{correlation_key}_{int(time.time() * 1000)}" if scope_rf_data and scope_rf_data is not recent_rf_data:
self.store_message_for_correlation(message_id, payload) self.logger.debug(
"Using separate scope-eligible RF correlation (path/SNR source differs)"
# 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)
packet_info: dict[str, Any] | None = None 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"): if recent_rf_data and recent_rf_data.get("raw_hex"):
raw_hex = recent_rf_data["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]}...") 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) hops = payload.get("path_len", 255)
path_string = None path_string = None
# Scope matching: if the RF data is a TC_FLOOD, check whether its transport if scope_rf_data and scope_rf_data.get("raw_hex"):
# code matches any configured flood_scopes entry. If so, the reply should # Decode the full inner MeshCore packet (header + path + ciphertext).
# use the same scope so it reaches the same scoped network segment. # 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 reply_scope: str | None = None
if recent_rf_data: cmd_mgr = getattr(self.bot, "command_manager", None)
scope_keys = getattr(getattr(self.bot, "command_manager", None), "flood_scope_keys", {}) scope_keys = getattr(cmd_mgr, "flood_scope_keys", {})
if scope_rf_data and scope_keys:
reply_scope = self._resolve_reply_scope_from_rf_data( 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 # Allowlist enforcement: when flood_scopes is configured, only reply to
# messages whose scope matched an entry. Unscoped FLOOD is allowed only # messages whose scope matched an entry. Unscoped FLOOD is allowed only
# when '*' (or equivalent) is explicitly listed. # 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: if scope_keys and reply_scope is None:
allow_global = getattr(cmd_mgr, "flood_scope_allow_global", False) allow_global = getattr(cmd_mgr, "flood_scope_allow_global", False)
rt_for_check = self._effective_route_type_int(recent_rf_data, packet_info) if scope_rf_data and self._is_rf_data_scope_eligible(
if rt_for_check == 0: scope_rf_data, scope_packet_info
):
self.logger.info("Ignoring TC_FLOOD: scope not in flood_scopes allowlist") self.logger.info("Ignoring TC_FLOOD: scope not in flood_scopes allowlist")
return return
elif not allow_global: if not allow_global:
self.logger.debug("Ignoring FLOOD: unscoped messages not permitted (add '*' to flood_scopes)") 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 return
# Get the full public key from contacts if available # Get the full public key from contacts if available
@@ -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"
+190 -1
View File
@@ -1,7 +1,7 @@
"""Unit tests for outbound flood scope resolution.""" """Unit tests for outbound flood scope resolution."""
import configparser import configparser
from unittest.mock import AsyncMock, MagicMock, Mock from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest import pytest
@@ -121,3 +121,192 @@ async def test_send_channel_message_applies_override_when_resolve_returns_none()
set_flood_scope.assert_awaited() set_flood_scope.assert_awaited()
assert set_flood_scope.await_args_list[0].args[0] == "#west" 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
+39
View File
@@ -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 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(): def test_scope_fields_from_packet_info_enum_route_type():
class _EnumVal: class _EnumVal:
def __init__(self, value: int): def __init__(self, value: int):