Add clearer regional scoping for bots. Closes #300.

This commit is contained in:
Jack Kingsman
2026-07-08 17:08:04 -07:00
parent 97b873e991
commit 4385ea5703
6 changed files with 246 additions and 12 deletions
+1 -1
View File
@@ -120,7 +120,7 @@ Wraps bot code execution via `app/fanout/bot_exec.py`. Config blob:
- Executes in a thread pool with timeout and semaphore concurrency control
- Rate-limits outgoing messages for repeater compatibility
- Channel `message_text` passed to bot code is normalized for human readability by stripping a leading `"{sender_name}: "` prefix when it matches the payload sender.
- The `bot(...)` function receives, in order: `sender_name`, `sender_key`, `message_text`, `is_dm`, `channel_key`, `channel_name`, `sender_timestamp`, `path`, then optionally `is_outgoing`, `path_bytes_per_hop`, `packet_hash`. `region` (resolved region name for region-scoped channel messages; `None` otherwise) is delivered **only** to bots that use `**kwargs` or explicitly name the `region` parameter — it is intentionally not added to the positional call styles so existing bot signatures keep binding unchanged. `_analyze_bot_signature` in `bot_exec.py` picks the call style from the bot's actual signature.
- The `bot(...)` function receives, in order: `sender_name`, `sender_key`, `message_text`, `is_dm`, `channel_key`, `channel_name`, `sender_timestamp`, `path`, then optionally `is_outgoing`, `path_bytes_per_hop`, `packet_hash`. Two further kwargs — `region` (resolved region name; `None` for unscoped flood or a transport code matching no known region) and `scoped` (`bool`: whether the message carried a regional flood scope) — are delivered **only** to bots that use `**kwargs` or explicitly name the parameter; they are intentionally not added to the positional call styles so existing bot signatures keep binding unchanged. `scoped` disambiguates a `None` region: `not scoped` = unscoped, `scoped and region is None` = scoped-but-unknown-region, `scoped and region` = that named region. Unlike `region` (channel-only historically), `scoped` is also set for scoped DMs (flood-direct messages can carry a scope), which resolves the DM half of #300. `_analyze_bot_signature` in `bot_exec.py` picks the call style from the bot's actual signature.
- **Return shapes** (`execute_bot_code``process_bot_response`): `None` (no reply), a `str`, a `list[str]` (sent in order), or a `dict` `{"region": <name|None>, "message": <str|list[str]>}`. The dict form (`BotReply`) scopes the reply send to a region **for that send only**: a region name applies it, `None`/empty clears it (unscoped/plain flood), and an absent `region` key falls back to the channel's persisted `flood_scope_override`. Region scoping applies to channel replies only — it is ignored for DM replies (DMs are not region-scoped). Outgoing scope reuses the existing `send_channel_message_with_effective_scope` set-scope/send/restore machinery via a per-send `flood_scope_override` on `SendChannelMessageRequest`. Note the bot can scope to any region name; whether the echo is *labeled* still depends on the operator's `app_settings.known_regions` (that list only drives decode, not transmit).
### webhook (webhook.py)
+6 -2
View File
@@ -137,9 +137,12 @@ class BotModule(FanoutModule):
path_value = paths[0].get("path") if isinstance(paths[0], dict) else None
path_bytes_per_hop = _derive_path_bytes_per_hop(paths, path_value)
packet_hash = data.get("packet_hash")
# Resolved region name for region-scoped channel messages (None for DMs,
# unscoped flood, or when the transport code matched no known region).
# Resolved region name (None for unscoped flood or a transport code that
# matched no known region). `scoped` disambiguates None: a transport code
# was present iff the message carried a regional flood scope. This is set
# for scoped DMs too (flood-direct messages can carry a scope).
region = data.get("region")
scoped = data.get("transport_code") is not None
# Wait for message to settle (allows retransmissions to be deduped)
await asyncio.sleep(2)
@@ -167,6 +170,7 @@ class BotModule(FanoutModule):
path_bytes_per_hop,
packet_hash,
region,
scoped,
),
timeout=BOT_EXECUTION_TIMEOUT,
)
+20 -7
View File
@@ -126,7 +126,7 @@ def _analyze_bot_signature(bot_func_or_sig) -> BotCallPlan:
has_kwargs = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in param_values)
explicit_optional_names = tuple(
name
for name in ("is_outgoing", "path_bytes_per_hop", "packet_hash", "region")
for name in ("is_outgoing", "path_bytes_per_hop", "packet_hash", "region", "scoped")
if name in params
)
unsupported_required_kwonly = [
@@ -134,7 +134,7 @@ def _analyze_bot_signature(bot_func_or_sig) -> BotCallPlan:
for p in param_values
if p.kind == inspect.Parameter.KEYWORD_ONLY
and p.default is inspect.Parameter.empty
and p.name not in {"is_outgoing", "path_bytes_per_hop", "packet_hash", "region"}
and p.name not in {"is_outgoing", "path_bytes_per_hop", "packet_hash", "region", "scoped"}
]
if unsupported_required_kwonly:
raise ValueError(
@@ -164,6 +164,8 @@ def _analyze_bot_signature(bot_func_or_sig) -> BotCallPlan:
keyword_args["packet_hash"] = ""
if has_kwargs or "region" in params:
keyword_args["region"] = None
if has_kwargs or "scoped" in params:
keyword_args["scoped"] = False
candidate_specs.append(("keyword", [], keyword_args))
if not has_kwargs and explicit_optional_names:
@@ -176,6 +178,8 @@ def _analyze_bot_signature(bot_func_or_sig) -> BotCallPlan:
kwargs["packet_hash"] = ""
if has_kwargs or "region" in params:
kwargs["region"] = None
if has_kwargs or "scoped" in params:
kwargs["scoped"] = False
candidate_specs.append(("mixed_keyword", base_args, kwargs))
if has_varargs or positional_capacity >= 11:
@@ -201,7 +205,7 @@ def _analyze_bot_signature(bot_func_or_sig) -> BotCallPlan:
"Supported trailing parameters are: path; path + is_outgoing; "
"path + path_bytes_per_hop; path + is_outgoing + path_bytes_per_hop; "
"path + is_outgoing + path_bytes_per_hop + packet_hash; "
"or use **kwargs for forward compatibility (which also receives region)."
"or use **kwargs for forward compatibility (which also receives region and scoped)."
)
@@ -219,6 +223,7 @@ def execute_bot_code(
path_bytes_per_hop: int | None = None,
packet_hash: str | None = None,
region: str | None = None,
scoped: bool = False,
) -> str | list[str] | BotReply | None:
"""
Execute user-provided bot code with message context.
@@ -227,8 +232,8 @@ def execute_bot_code(
`bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, is_outgoing, path_bytes_per_hop, packet_hash)`
or use named parameters / `**kwargs`.
`region` is only delivered to bots that opt in via `**kwargs` or by naming
the parameter (`region`); the positional call styles are unchanged for
`region` and `scoped` are only delivered to bots that opt in via `**kwargs`
or by naming the parameter; the positional call styles are unchanged for
backward compatibility.
The bot returns either None (no response), a string (single response message),
@@ -253,8 +258,14 @@ def execute_bot_code(
is_outgoing: True if this is our own outgoing message
path_bytes_per_hop: Number of bytes per routing hop (1, 2, or 3), if known
packet_hash: MeshCore packet hash (first 16 hex chars of SHA256, uppercase), if known
region: Resolved region name for a region-scoped channel message, or None
for DMs, unscoped flood, or a transport code matching no known region
scoped: True if the message carried a regional flood scope, False for
plain/unscoped flood. Check this first — it is the meaningful signal.
Set for scoped DMs too (flood-direct messages can carry a scope).
region: Only meaningful when scoped is True. When scoped is False, region
is always None and should be ignored. When scoped is True, region is
the decoded region name, or None if the scope matched none of your
known_regions (i.e. scoped, but region unrecognized). region is never
enough on its own to tell "unscoped" from "unrecognized" — use scoped.
Returns:
Response string, list of strings, or None.
@@ -356,6 +367,8 @@ def execute_bot_code(
keyword_args["packet_hash"] = packet_hash
if "region" in call_plan.keyword_args:
keyword_args["region"] = region
if "scoped" in call_plan.keyword_args:
keyword_args["scoped"] = scoped
result = bot_func(**keyword_args)
else:
result = bot_func(