Allow bots to send region scoped messages

This commit is contained in:
jkingsman
2026-06-27 00:36:28 -07:00
parent 4b4dfb0767
commit ce4946351f
8 changed files with 461 additions and 22 deletions
+1
View File
@@ -121,6 +121,7 @@ Wraps bot code execution via `app/fanout/bot_exec.py`. Config blob:
- 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.
- **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)
HTTP webhook delivery. Config blob:
+93 -9
View File
@@ -51,6 +51,59 @@ class BotCallPlan:
keyword_args: tuple[str, ...] = ()
@dataclass(frozen=True)
class BotReply:
"""A structured bot response that may scope its outgoing message(s).
Produced when a bot returns ``{"region": ..., "message": ...}``. The plain
``str``/``list[str]`` return shapes are unaffected and never produce a BotReply.
``flood_scope_override`` carries the per-send region decision for the reply:
- ``None`` → no region specified; use the channel's persisted override
- ``""`` → region explicitly cleared; send unscoped/plain flood
- region → scope the reply send to that region name
"""
messages: list[str]
flood_scope_override: str | None = None
def _coerce_bot_dict_reply(result: dict) -> "BotReply | None":
"""Normalize a ``{"region", "message"}`` bot return into a BotReply, or None.
``message`` may be a string or a list of strings (empties dropped). A missing
``message`` (or no valid messages) yields None. ``region`` is optional: absent
means "use the channel default", ``None`` means "send unscoped", and a string
is normalized to the radio's region form.
"""
from app.region_scope import normalize_region_scope
raw = result.get("message")
if isinstance(raw, str):
messages = [raw] if raw.strip() else []
elif isinstance(raw, list):
messages = [m for m in raw if isinstance(m, str) and m.strip()]
else:
logger.debug("Bot dict response 'message' must be a string or list of strings")
return None
if not messages:
return None
if "region" in result:
region_value = result.get("region")
if region_value is None:
flood_scope_override: str | None = "" # explicit: send unscoped
elif isinstance(region_value, str):
flood_scope_override = normalize_region_scope(region_value)
else:
logger.debug("Bot dict response 'region' must be a string or None")
return None
else:
flood_scope_override = None # no region specified: use channel default
return BotReply(messages=messages, flood_scope_override=flood_scope_override)
def _analyze_bot_signature(bot_func_or_sig) -> BotCallPlan:
"""Validate bot() signature and return a supported call plan."""
try:
@@ -166,7 +219,7 @@ def execute_bot_code(
path_bytes_per_hop: int | None = None,
packet_hash: str | None = None,
region: str | None = None,
) -> str | list[str] | None:
) -> str | list[str] | BotReply | None:
"""
Execute user-provided bot code with message context.
@@ -177,8 +230,12 @@ def execute_bot_code(
`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).
The bot returns either None (no response), a string (single response message),
a list of strings (multiple messages sent in order), or a dict
`{"region": <name|None>, "message": <str|list[str]>}` to scope the reply to a
region for this send only (`None`/empty region = unscoped flood). The dict
form only affects channel replies; `region` is ignored for DM replies.
Legacy bot functions with older signatures are detected via inspect and
called without the newer parameters for backward compatibility.
@@ -315,6 +372,9 @@ def execute_bot_code(
# Validate result
if result is None:
return None
if isinstance(result, dict):
# Structured reply: {"region": ..., "message": str | list[str]}
return _coerce_bot_dict_reply(result)
if isinstance(result, str):
return result if result.strip() else None
if isinstance(result, list):
@@ -331,7 +391,7 @@ def execute_bot_code(
async def process_bot_response(
response: str | list[str],
response: str | list[str] | BotReply,
is_dm: bool,
sender_key: str,
channel_key: str | None,
@@ -346,16 +406,27 @@ async def process_bot_response(
between sends, giving repeaters time to return to listening mode.
Args:
response: The response text to send, or a list of messages to send in order
response: The response to send — a string, a list of messages to send in
order, or a BotReply carrying a per-send region scope
is_dm: Whether the original message was a DM
sender_key: Public key of the original sender (for DM replies)
channel_key: Channel key for channel message replies
"""
# Normalize to list for uniform processing
messages = [response] if isinstance(response, str) else response
# Normalize to (messages, flood_scope_override) for uniform processing.
if isinstance(response, BotReply):
messages = response.messages
flood_scope_override = response.flood_scope_override
elif isinstance(response, str):
messages = [response]
flood_scope_override = None
else:
messages = response
flood_scope_override = None
for message_text in messages:
await _send_single_bot_message(message_text, is_dm, sender_key, channel_key)
await _send_single_bot_message(
message_text, is_dm, sender_key, channel_key, flood_scope_override
)
async def _send_single_bot_message(
@@ -363,6 +434,7 @@ async def _send_single_bot_message(
is_dm: bool,
sender_key: str,
channel_key: str | None,
flood_scope_override: str | None = None,
) -> None:
"""
Send a single bot message with rate limiting.
@@ -372,6 +444,9 @@ async def _send_single_bot_message(
is_dm: Whether the original message was a DM
sender_key: Public key of the original sender (for DM replies)
channel_key: Channel key for channel message replies
flood_scope_override: Per-send region scope for channel replies (None =
channel default, "" = unscoped, region name = scope to that region).
Ignored for DMs, which are not region-scoped.
"""
global _last_bot_send_time
@@ -391,12 +466,21 @@ async def _send_single_bot_message(
try:
if is_dm:
if flood_scope_override is not None:
logger.debug(
"Ignoring bot region scope %r on DM reply (DMs are not region-scoped)",
flood_scope_override,
)
logger.info("Bot sending DM reply to %s", sender_key[:12])
request = SendDirectMessageRequest(destination=sender_key, text=message_text)
await send_direct_message(request)
elif channel_key:
logger.info("Bot sending channel reply to %s", channel_key[:8])
request = SendChannelMessageRequest(channel_key=channel_key, text=message_text)
request = SendChannelMessageRequest(
channel_key=channel_key,
text=message_text,
flood_scope_override=flood_scope_override,
)
await send_channel_message(request)
else:
logger.warning("Cannot send bot response: no destination")
+9
View File
@@ -530,6 +530,15 @@ class SendDirectMessageRequest(SendMessageRequest):
class SendChannelMessageRequest(SendMessageRequest):
channel_key: str = Field(description="Channel key (32-char hex)")
flood_scope_override: str | None = Field(
default=None,
description=(
"Per-send regional flood-scope override. None = use the channel's persisted "
"override (or none); empty string = force unscoped/plain flood; a region name "
"scopes this single send to that region. Takes precedence over the channel's "
"persisted flood_scope_override for this send only."
),
)
class RepeaterLoginRequest(BaseModel):
+8
View File
@@ -13,6 +13,7 @@ from app.models import (
)
from app.repository import AmbiguousPublicKeyPrefixError, AppSettingsRepository, MessageRepository
from app.services.message_send import (
SCOPE_UNSET,
resend_channel_message_record,
send_channel_message_to_channel,
send_direct_message_to_contact,
@@ -154,6 +155,12 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
status_code=400, detail=f"Invalid channel key format: {request.channel_key}"
) from None
# None field = no per-send override (fall back to the channel's persisted
# override). An explicit string (including "" for unscoped) overrides it.
flood_scope_override = (
SCOPE_UNSET if request.flood_scope_override is None else request.flood_scope_override
)
return await send_channel_message_to_channel(
channel=db_channel,
channel_key_upper=request.channel_key.upper(),
@@ -164,6 +171,7 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
error_broadcast_fn=broadcast_error,
now_fn=time.time,
temp_radio_slot=TEMP_RADIO_SLOT,
flood_scope_override=flood_scope_override,
message_repository=MessageRepository,
)
+53 -12
View File
@@ -30,6 +30,20 @@ from app.services.messages import (
logger = logging.getLogger(__name__)
class _ScopeUnset:
"""Sentinel for "no per-send flood-scope override supplied".
Distinguishes "caller did not request a per-send scope, so use the channel's
persisted ``flood_scope_override``" from "caller explicitly requested unscoped
flood (empty string)". A bare ``None``/``""`` cannot express both states.
"""
__slots__ = ()
SCOPE_UNSET = _ScopeUnset()
NO_RADIO_RESPONSE_AFTER_SEND_DETAIL = (
"Send command was issued to the radio, but no response was heard back. "
"The message may or may not have sent successfully."
@@ -135,33 +149,53 @@ async def send_channel_message_with_effective_scope(
radio_manager,
temp_radio_slot: int,
error_broadcast_fn: BroadcastFn,
flood_scope_override: str | _ScopeUnset = SCOPE_UNSET,
app_settings_repository=AppSettingsRepository,
) -> Any:
"""Send a channel message, temporarily overriding flood scope and/or path hash mode."""
override_scope = normalize_region_scope(channel.flood_scope_override)
baseline_scope = ""
"""Send a channel message, temporarily overriding flood scope and/or path hash mode.
if override_scope:
``flood_scope_override`` lets a single send override the channel's persisted
``flood_scope_override``: pass a region name to scope this send, an empty
string to force unscoped/plain flood, or leave it ``SCOPE_UNSET`` to fall
back to the channel's persisted override.
"""
if isinstance(flood_scope_override, _ScopeUnset):
desired_scope = normalize_region_scope(channel.flood_scope_override)
scope_explicit = False
else:
desired_scope = normalize_region_scope(flood_scope_override)
scope_explicit = True
# Fetch the radio's standing scope as the restore target whenever we might
# change it: a non-empty desired scope, or an explicit request to go unscoped.
baseline_scope = ""
if desired_scope or scope_explicit:
settings = await app_settings_repository.get()
baseline_scope = normalize_region_scope(settings.flood_scope)
if override_scope and override_scope != baseline_scope:
# Apply only when the desired scope differs from the radio's baseline. A blank
# desired scope forces unscoped only when explicitly requested; the implicit
# (channel-default) path leaves the radio untouched when no override is set.
apply_scope = desired_scope != baseline_scope and (bool(desired_scope) or scope_explicit)
if apply_scope:
logger.info(
"Temporarily applying channel flood_scope override for %s: %r",
"Temporarily applying flood_scope %s for %s",
desired_scope or "(unscoped)",
channel.name,
override_scope,
)
override_result = await mc.commands.set_flood_scope(override_scope)
override_result = await mc.commands.set_flood_scope(desired_scope)
if override_result is not None and override_result.type == EventType.ERROR:
logger.warning(
"Failed to apply channel flood_scope override for %s: %s",
"Failed to apply flood_scope %r for %s: %s",
desired_scope,
channel.name,
override_result.payload,
)
raise HTTPException(
status_code=422,
detail=(
f"Failed to apply regional override {override_scope!r} before {action_label}: "
f"Failed to apply regional override {desired_scope!r} before {action_label}: "
f"{override_result.payload}"
),
)
@@ -275,7 +309,7 @@ async def send_channel_message_with_effective_scope(
radio_manager.note_channel_slot_used(channel_key)
return send_result
finally:
if override_scope and override_scope != baseline_scope:
if apply_scope:
restored = False
for attempt in range(3):
try:
@@ -763,9 +797,15 @@ async def send_channel_message_to_channel(
error_broadcast_fn: BroadcastFn,
now_fn: NowFn,
temp_radio_slot: int,
flood_scope_override: str | _ScopeUnset = SCOPE_UNSET,
message_repository=MessageRepository,
) -> Any:
"""Send a channel message and persist/broadcast the outgoing row."""
"""Send a channel message and persist/broadcast the outgoing row.
``flood_scope_override`` is forwarded to the scoped send: a region name
scopes this send, an empty string forces unscoped flood, and ``SCOPE_UNSET``
falls back to the channel's persisted override.
"""
sent_at: int | None = None
sender_timestamp: int | None = None
radio_name = ""
@@ -818,6 +858,7 @@ async def send_channel_message_to_channel(
radio_manager=radio_manager,
temp_radio_slot=temp_radio_slot,
error_broadcast_fn=error_broadcast_fn,
flood_scope_override=flood_scope_override,
)
if result is None:
@@ -94,7 +94,10 @@ const DEFAULT_BOT_CODE = `def bot(**kwargs) -> str | list[str] | None:
Returns:
None for no reply, a string for a single reply,
or a list of strings to send multiple messages in order
a list of strings to send multiple messages in order, or a dict
{"region": <name or None>, "message": <str or list[str]>} to scope a
channel reply to a region for that send only (None/"" = unscoped flood;
region is ignored for DM replies).
"""
sender_name = kwargs.get("sender_name")
message_text = kwargs.get("message_text", "")
+219
View File
@@ -497,6 +497,134 @@ def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name,
)
assert result == "ok:Hi"
def test_dict_return_with_region_produces_bot_reply(self):
"""A {"region", "message"} return becomes a BotReply with a normalized scope (#300)."""
from app.fanout.bot_exec import BotReply
code = """
def bot(**kwargs):
return {"region": "EU", "message": "scoped hi"}
"""
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,
)
assert isinstance(result, BotReply)
assert result.messages == ["scoped hi"]
assert result.flood_scope_override == "#EU"
def test_dict_return_with_message_list_drops_empties(self):
"""The dict 'message' may be a list; blank entries are dropped."""
from app.fanout.bot_exec import BotReply
code = """
def bot(**kwargs):
return {"region": "EU", "message": ["a", " ", "b"]}
"""
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,
)
assert isinstance(result, BotReply)
assert result.messages == ["a", "b"]
assert result.flood_scope_override == "#EU"
def test_dict_return_region_none_is_explicit_unscoped(self):
"""region=None means 'send unscoped' (empty override), distinct from 'use default'."""
from app.fanout.bot_exec import BotReply
code = """
def bot(**kwargs):
return {"region": None, "message": "hi"}
"""
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,
)
assert isinstance(result, BotReply)
assert result.flood_scope_override == ""
def test_dict_return_without_region_uses_channel_default(self):
"""A dict with no 'region' key defers to the channel's persisted override (None)."""
from app.fanout.bot_exec import BotReply
code = """
def bot(**kwargs):
return {"message": "hi"}
"""
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,
)
assert isinstance(result, BotReply)
assert result.flood_scope_override is None
def test_dict_return_invalid_message_returns_none(self):
"""A dict whose 'message' is not str/list is rejected (no reply)."""
code = """
def bot(**kwargs):
return {"region": "EU", "message": 123}
"""
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,
)
assert result is None
def test_dict_return_empty_message_returns_none(self):
"""A dict with no usable message text yields no reply."""
code = """
def bot(**kwargs):
return {"region": "EU", "message": " "}
"""
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,
)
assert result is None
def test_channel_message_with_none_sender_key(self):
"""Channel messages correctly pass None for sender_key."""
code = """
@@ -1008,3 +1136,94 @@ class TestBotListResponses:
)
assert sent_messages == ["Just one message"]
class TestBotReplyRouting:
"""A BotReply's region scopes channel replies and is ignored for DMs (#300)."""
@pytest.mark.asyncio
async def test_bot_reply_region_scopes_channel_send(self):
from app.fanout.bot_exec import BotReply
sent = {}
async def mock_send(request):
sent["request"] = request
mock_message = MagicMock()
mock_message.model_dump.return_value = {}
return mock_message
with (
patch("app.fanout.bot_exec.time.monotonic", return_value=100.0),
patch("app.fanout.bot_exec.asyncio.sleep", new_callable=AsyncMock),
patch("app.routers.messages.send_channel_message", side_effect=mock_send),
patch("app.websocket.broadcast_event"),
):
await process_bot_response(
response=BotReply(messages=["scoped hi"], flood_scope_override="#EU"),
is_dm=False,
sender_key="",
channel_key="AABBCCDD",
)
request = sent["request"]
assert request.channel_key == "AABBCCDD"
assert request.text == "scoped hi"
assert request.flood_scope_override == "#EU"
@pytest.mark.asyncio
async def test_bot_reply_all_messages_share_region(self):
from app.fanout.bot_exec import BotReply
overrides = []
async def mock_send(request):
overrides.append(request.flood_scope_override)
mock_message = MagicMock()
mock_message.model_dump.return_value = {}
return mock_message
with (
patch("app.fanout.bot_exec.time.monotonic", return_value=100.0),
patch("app.fanout.bot_exec.asyncio.sleep", new_callable=AsyncMock),
patch("app.routers.messages.send_channel_message", side_effect=mock_send),
patch("app.websocket.broadcast_event"),
):
await process_bot_response(
response=BotReply(messages=["a", "b"], flood_scope_override="#EU"),
is_dm=False,
sender_key="",
channel_key="AABBCCDD",
)
assert overrides == ["#EU", "#EU"]
@pytest.mark.asyncio
async def test_bot_reply_region_ignored_for_dm(self):
from app.fanout.bot_exec import BotReply
sent = {}
async def mock_send(request):
sent["request"] = request
mock_message = MagicMock()
mock_message.model_dump.return_value = {}
return mock_message
with (
patch("app.fanout.bot_exec.time.monotonic", return_value=100.0),
patch("app.fanout.bot_exec.asyncio.sleep", new_callable=AsyncMock),
patch("app.routers.messages.send_direct_message", side_effect=mock_send),
patch("app.websocket.broadcast_event"),
):
await process_bot_response(
response=BotReply(messages=["hi"], flood_scope_override="#EU"),
is_dm=True,
sender_key="a" * 64,
channel_key=None,
)
request = sent["request"]
assert request.text == "hi"
# SendDirectMessageRequest has no scope field; region is simply ignored.
assert not hasattr(request, "flood_scope_override")
+74
View File
@@ -626,6 +626,80 @@ class TestOutgoingChannelBroadcast:
mc.commands.set_flood_scope.assert_not_awaited()
@pytest.mark.asyncio
async def test_send_channel_msg_request_override_beats_channel_override(self, test_db):
"""A per-send flood_scope_override wins over the channel's persisted override."""
mc = _make_mc(name="MyNode")
chan_key = "d0" * 16
await ChannelRepository.upsert(key=chan_key, name="#flightless")
await ChannelRepository.update_flood_scope_override(chan_key, "Esperance")
await AppSettingsRepository.update(flood_scope="Baseline")
with (
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch("app.routers.messages.broadcast_event"),
):
request = SendChannelMessageRequest(
channel_key=chan_key, text="hello", flood_scope_override="Override"
)
await send_channel_message(request)
# Per-send "Override" applied (not the channel's "Esperance"); baseline restored.
assert mc.commands.set_flood_scope.await_args_list == [
call("#Override"),
call("#Baseline"),
]
@pytest.mark.asyncio
async def test_send_channel_msg_request_override_scopes_unscoped_channel(self, test_db):
"""A per-send override scopes a channel that has no persisted override."""
mc = _make_mc(name="MyNode")
chan_key = "d1" * 16
await ChannelRepository.upsert(key=chan_key, name="#plain")
await AppSettingsRepository.update(flood_scope="")
with (
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch("app.routers.messages.broadcast_event"),
):
request = SendChannelMessageRequest(
channel_key=chan_key, text="hello", flood_scope_override="Region"
)
await send_channel_message(request)
# Apply the region, then restore the (empty) baseline.
assert mc.commands.set_flood_scope.await_args_list == [
call("#Region"),
call(""),
]
@pytest.mark.asyncio
async def test_send_channel_msg_explicit_unscoped_override_forces_plain_flood(self, test_db):
"""An explicit empty override forces unscoped flood even over a scoped baseline."""
mc = _make_mc(name="MyNode")
chan_key = "d2" * 16
await ChannelRepository.upsert(key=chan_key, name="#flightless")
await ChannelRepository.update_flood_scope_override(chan_key, "Esperance")
await AppSettingsRepository.update(flood_scope="Baseline")
with (
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch("app.routers.messages.broadcast_event"),
):
request = SendChannelMessageRequest(
channel_key=chan_key, text="hello", flood_scope_override=""
)
await send_channel_message(request)
# Explicit unscoped: set empty scope, then restore the global baseline.
assert mc.commands.set_flood_scope.await_args_list == [
call(""),
call("#Baseline"),
]
@pytest.mark.asyncio
async def test_send_channel_msg_aborts_when_override_apply_fails(self, test_db):
mc = _make_mc(name="MyNode")