mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-31 05:53:04 +02:00
Allow bots to send region scoped messages
This commit is contained in:
@@ -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
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user