mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-06-05 15:21:24 +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
|
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 message’s 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
|
||||||
|
|
||||||
|
|||||||
@@ -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 command’s 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 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).
|
- **`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
|
||||||
|
|||||||
@@ -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
@@ -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 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(
|
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"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user