mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 01:03:34 +02:00
Allow bots to send region scoped messages
This commit is contained in:
@@ -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