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
+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")