diff --git a/app/fanout/AGENTS_fanout.md b/app/fanout/AGENTS_fanout.md index d8d7074..a4995b0 100644 --- a/app/fanout/AGENTS_fanout.md +++ b/app/fanout/AGENTS_fanout.md @@ -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: diff --git a/app/fanout/bot.py b/app/fanout/bot.py index bb22412..e4e63bd 100644 --- a/app/fanout/bot.py +++ b/app/fanout/bot.py @@ -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, ) diff --git a/app/fanout/bot_exec.py b/app/fanout/bot_exec.py index edf93d0..66faf52 100644 --- a/app/fanout/bot_exec.py +++ b/app/fanout/bot_exec.py @@ -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( diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx index b48b870..93b2c2e 100644 --- a/frontend/src/components/settings/SettingsFanoutSection.tsx +++ b/frontend/src/components/settings/SettingsFanoutSection.tsx @@ -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, diff --git a/tests/test_bot.py b/tests/test_bot.py index e627b0a..19562c9 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -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 = """ diff --git a/tests/test_fanout_hitlist.py b/tests/test_fanout_hitlist.py index 722b047..1b446d0 100644 --- a/tests/test_fanout_hitlist.py +++ b/tests/test_fanout_hitlist.py @@ -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 diff --git a/tests/test_fanout_integration.py b/tests/test_fanout_integration.py index c9d9c29..c07b846 100644 --- a/tests/test_fanout_integration.py +++ b/tests/test_fanout_integration.py @@ -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