mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-05-19 05:45:41 +00:00
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:
+5
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+180
-57
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user