Add incoming region to kwargs

This commit is contained in:
jkingsman
2026-06-26 21:26:58 -07:00
parent 14cd399f70
commit 4b4dfb0767
7 changed files with 267 additions and 3 deletions
+1
View File
@@ -120,6 +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.
### webhook (webhook.py)
HTTP webhook delivery. Config blob:
+4
View File
@@ -137,6 +137,9 @@ 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).
region = data.get("region")
# Wait for message to settle (allows retransmissions to be deduped)
await asyncio.sleep(2)
@@ -163,6 +166,7 @@ class BotModule(FanoutModule):
is_outgoing,
path_bytes_per_hop,
packet_hash,
region,
),
timeout=BOT_EXECUTION_TIMEOUT,
)
+18 -3
View File
@@ -72,14 +72,16 @@ def _analyze_bot_signature(bot_func_or_sig) -> BotCallPlan:
has_varargs = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in param_values)
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") if name in params
name
for name in ("is_outgoing", "path_bytes_per_hop", "packet_hash", "region")
if name in params
)
unsupported_required_kwonly = [
p.name
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"}
and p.name not in {"is_outgoing", "path_bytes_per_hop", "packet_hash", "region"}
]
if unsupported_required_kwonly:
raise ValueError(
@@ -107,6 +109,8 @@ def _analyze_bot_signature(bot_func_or_sig) -> BotCallPlan:
keyword_args["path_bytes_per_hop"] = 1
if has_kwargs or "packet_hash" in params:
keyword_args["packet_hash"] = ""
if has_kwargs or "region" in params:
keyword_args["region"] = None
candidate_specs.append(("keyword", [], keyword_args))
if not has_kwargs and explicit_optional_names:
@@ -117,6 +121,8 @@ def _analyze_bot_signature(bot_func_or_sig) -> BotCallPlan:
kwargs["path_bytes_per_hop"] = 1
if has_kwargs or "packet_hash" in params:
kwargs["packet_hash"] = ""
if has_kwargs or "region" in params:
kwargs["region"] = None
candidate_specs.append(("mixed_keyword", base_args, kwargs))
if has_varargs or positional_capacity >= 11:
@@ -142,7 +148,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."
"or use **kwargs for forward compatibility (which also receives region)."
)
@@ -159,6 +165,7 @@ def execute_bot_code(
is_outgoing: bool = False,
path_bytes_per_hop: int | None = None,
packet_hash: str | None = None,
region: str | None = None,
) -> str | list[str] | None:
"""
Execute user-provided bot code with message context.
@@ -166,6 +173,10 @@ def execute_bot_code(
The code should define a function:
`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
backward compatibility.
that returns either None (no response), a string (single response message),
or a list of strings (multiple messages sent in order).
@@ -185,6 +196,8 @@ 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
Returns:
Response string, list of strings, or None.
@@ -284,6 +297,8 @@ def execute_bot_code(
keyword_args["path_bytes_per_hop"] = path_bytes_per_hop
if "packet_hash" in call_plan.keyword_args:
keyword_args["packet_hash"] = packet_hash
if "region" in call_plan.keyword_args:
keyword_args["region"] = region
result = bot_func(**keyword_args)
else:
result = bot_func(
@@ -89,6 +89,8 @@ const DEFAULT_BOT_CODE = `def bot(**kwargs) -> str | list[str] | None:
path: Hex-encoded routing path (may be None)
is_outgoing: True if this is our own outgoing message
path_bytes_per_hop: Bytes per hop in path (1, 2, or 3) when known
region: Resolved region name for a region-scoped channel
message (None for DMs, unscoped flood, or no match)
Returns:
None for no reply, a string for a single reply,
+100
View File
@@ -397,6 +397,106 @@ def bot(**kwargs):
)
assert result == "Alice|Hi|True|2"
def test_kwargs_bot_receives_region(self):
"""Bots using **kwargs receive the region of a scoped channel message (#300)."""
code = """
def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, **kwargs):
return f"region={kwargs.get('region', 'missing')}"
"""
result = execute_bot_code(
code=code,
sender_name="Someone",
sender_key=None,
message_text="Hi",
is_dm=False,
channel_key="AABBCCDD",
channel_name="#general",
sender_timestamp=None,
path=None,
region="EU",
)
assert result == "region=EU"
def test_named_region_param_bot_receives_region(self):
"""Bots may opt into region by naming the parameter (with a default)."""
code = """
def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, region=None):
return f"region={region}"
"""
result = execute_bot_code(
code=code,
sender_name="Someone",
sender_key=None,
message_text="Hi",
is_dm=False,
channel_key="AABBCCDD",
channel_name="#general",
sender_timestamp=None,
path=None,
region="US",
)
assert result == "region=US"
def test_required_keyword_only_region_param_is_supported(self):
"""A required keyword-only `region` parameter is accepted (allow-set parity)."""
code = """
def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, *, region):
return f"region={region}"
"""
result = execute_bot_code(
code=code,
sender_name="Someone",
sender_key=None,
message_text="Hi",
is_dm=False,
channel_key="AABBCCDD",
channel_name="#general",
sender_timestamp=None,
path=None,
region="EU",
)
assert result == "region=EU"
def test_kwargs_bot_receives_none_region_for_unscoped_message(self):
"""region is delivered as None (not absent) for DMs / unscoped flood."""
code = """
def bot(**kwargs):
return f"region={kwargs.get('region', 'missing')}"
"""
result = execute_bot_code(
code=code,
sender_name="Alice",
sender_key="abc123",
message_text="Hi",
is_dm=True,
channel_key=None,
channel_name=None,
sender_timestamp=None,
path=None,
)
assert result == "region=None"
def test_legacy_positional_bot_unaffected_by_region(self):
"""Historical positional bots keep binding unchanged; region is never passed positionally."""
code = """
def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, is_outgoing):
return f"ok:{message_text}"
"""
result = execute_bot_code(
code=code,
sender_name="Alice",
sender_key="abc123",
message_text="Hi",
is_dm=True,
channel_key=None,
channel_name=None,
sender_timestamp=None,
path=None,
is_outgoing=False,
region="EU",
)
assert result == "ok:Hi"
def test_channel_message_with_none_sender_key(self):
"""Channel messages correctly pass None for sender_key."""
code = """
+103
View File
@@ -38,6 +38,7 @@ class TestBotModuleParameterExtraction:
is_outgoing,
path_bytes_per_hop,
packet_hash,
region,
):
captured["is_outgoing"] = is_outgoing
captured["is_dm"] = is_dm
@@ -88,6 +89,7 @@ class TestBotModuleParameterExtraction:
is_outgoing,
path_bytes_per_hop,
packet_hash,
region,
):
captured["is_outgoing"] = is_outgoing
return None
@@ -135,6 +137,7 @@ class TestBotModuleParameterExtraction:
is_outgoing,
path_bytes_per_hop,
packet_hash,
region,
):
captured["path"] = path
captured["path_bytes_per_hop"] = path_bytes_per_hop
@@ -164,6 +167,103 @@ class TestBotModuleParameterExtraction:
assert captured["path"] == "aabbccdd"
assert captured["path_bytes_per_hop"] == 2
@pytest.mark.asyncio
async def test_region_extracted_from_payload(self):
"""A channel message's resolved region is pulled from data and forwarded (#300)."""
from app.fanout.bot import BotModule
captured = {}
def fake_execute(
code,
sender_name,
sender_key,
message_text,
is_dm,
channel_key,
channel_name,
sender_timestamp,
path,
is_outgoing,
path_bytes_per_hop,
packet_hash,
region,
):
captured["region"] = region
return None
mod = BotModule("test", {"code": "def bot(**k): pass"}, name="Test")
with (
patch("app.fanout.bot_exec.execute_bot_code", side_effect=fake_execute),
patch(
"app.fanout.bot_exec._bot_semaphore",
MagicMock(__aenter__=AsyncMock(), __aexit__=AsyncMock()),
),
patch("app.fanout.bot.asyncio.sleep", new_callable=AsyncMock),
patch("app.repository.ChannelRepository") as mock_chan,
):
mock_chan.get_by_key = AsyncMock(return_value=MagicMock(name="#test"))
await mod._run_for_message(
{
"type": "CHAN",
"conversation_key": "ch1",
"text": "Alice: hello",
"sender_name": "Alice",
"channel_name": "#test",
"region": "EU",
}
)
assert captured["region"] == "EU"
@pytest.mark.asyncio
async def test_region_absent_defaults_to_none(self):
"""A message without a region key forwards region=None to bot execution."""
from app.fanout.bot import BotModule
captured = {}
def fake_execute(
code,
sender_name,
sender_key,
message_text,
is_dm,
channel_key,
channel_name,
sender_timestamp,
path,
is_outgoing,
path_bytes_per_hop,
packet_hash,
region,
):
captured["region"] = region
return None
mod = BotModule("test", {"code": "def bot(**k): pass"}, name="Test")
with (
patch("app.fanout.bot_exec.execute_bot_code", side_effect=fake_execute),
patch(
"app.fanout.bot_exec._bot_semaphore",
MagicMock(__aenter__=AsyncMock(), __aexit__=AsyncMock()),
),
patch("app.fanout.bot.asyncio.sleep", new_callable=AsyncMock),
patch("app.repository.ContactRepository") as mock_contact,
):
mock_contact.get_by_key = AsyncMock(return_value=MagicMock(name="Alice"))
await mod._run_for_message(
{
"type": "PRIV",
"conversation_key": "pk1",
"text": "hello",
}
)
assert captured["region"] is None
@pytest.mark.asyncio
async def test_channel_sender_prefix_stripped(self):
"""Channel message text has 'SenderName: ' prefix stripped."""
@@ -184,6 +284,7 @@ class TestBotModuleParameterExtraction:
is_outgoing,
path_bytes_per_hop,
packet_hash,
region,
):
captured["message_text"] = message_text
captured["sender_name"] = sender_name
@@ -233,6 +334,7 @@ class TestBotModuleParameterExtraction:
is_outgoing,
path_bytes_per_hop,
packet_hash,
region,
):
captured["channel_name"] = channel_name
return None
@@ -281,6 +383,7 @@ class TestBotModuleParameterExtraction:
is_outgoing,
path_bytes_per_hop,
packet_hash,
region,
):
captured["sender_name"] = sender_name
captured["sender_key"] = sender_key
+39
View File
@@ -1749,6 +1749,45 @@ class TestBotModuleLifecycle:
assert mod._active is False
assert len(mod._tasks) == 0
@pytest.mark.asyncio
async def test_channel_message_region_reaches_bot_kwargs(self):
"""A scoped channel message's resolved region flows to bot **kwargs (#300)."""
from unittest.mock import AsyncMock, patch
from app.fanout.bot import BotModule
mod = BotModule(
"bot1",
{"code": "def bot(**k): return f\"region={k.get('region')}\""},
name="Test Bot",
)
mod._active = True
captured: dict = {}
async def capture(response, is_dm, sender_key, channel_key):
captured["response"] = response
with (
patch("app.fanout.bot.asyncio.sleep", new_callable=AsyncMock),
patch("app.fanout.bot_exec.process_bot_response", side_effect=capture),
):
await mod.on_message(
{
"type": "CHAN",
"conversation_key": "AABBCCDD",
"text": "Alice: hi",
"sender_name": "Alice",
"channel_name": "#general",
"outgoing": False,
"region": "EU",
}
)
if mod._tasks:
await asyncio.gather(*mod._tasks, return_exceptions=True)
assert captured.get("response") == "region=EU"
# ---------------------------------------------------------------------------
# Manager restart failure tests