From ce4946351f20ca61ed85991c5e18a15be817822d Mon Sep 17 00:00:00 2001 From: jkingsman Date: Sat, 27 Jun 2026 00:36:28 -0700 Subject: [PATCH] Allow bots to send region scoped messages --- app/fanout/AGENTS_fanout.md | 1 + app/fanout/bot_exec.py | 102 +++++++- app/models.py | 9 + app/routers/messages.py | 8 + app/services/message_send.py | 65 +++++- .../settings/SettingsFanoutSection.tsx | 5 +- tests/test_bot.py | 219 ++++++++++++++++++ tests/test_send_messages.py | 74 ++++++ 8 files changed, 461 insertions(+), 22 deletions(-) diff --git a/app/fanout/AGENTS_fanout.md b/app/fanout/AGENTS_fanout.md index a4995b0..17370d0 100644 --- a/app/fanout/AGENTS_fanout.md +++ b/app/fanout/AGENTS_fanout.md @@ -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": , "message": }`. 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: diff --git a/app/fanout/bot_exec.py b/app/fanout/bot_exec.py index 66faf52..454b14d 100644 --- a/app/fanout/bot_exec.py +++ b/app/fanout/bot_exec.py @@ -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": , "message": }` 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") diff --git a/app/models.py b/app/models.py index 4376777..a2315c3 100644 --- a/app/models.py +++ b/app/models.py @@ -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): diff --git a/app/routers/messages.py b/app/routers/messages.py index 99b6fd9..6b3e957 100644 --- a/app/routers/messages.py +++ b/app/routers/messages.py @@ -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, ) diff --git a/app/services/message_send.py b/app/services/message_send.py index d274a66..68d3543 100644 --- a/app/services/message_send.py +++ b/app/services/message_send.py @@ -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: diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx index 93b2c2e..9cef069 100644 --- a/frontend/src/components/settings/SettingsFanoutSection.tsx +++ b/frontend/src/components/settings/SettingsFanoutSection.tsx @@ -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": , "message": } 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", "") diff --git a/tests/test_bot.py b/tests/test_bot.py index 19562c9..64681b5 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -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") diff --git a/tests/test_send_messages.py b/tests/test_send_messages.py index 8c8162f..150782f 100644 --- a/tests/test_send_messages.py +++ b/tests/test_send_messages.py @@ -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")