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: