From f870d0e67fcaa3c1522e30d768fee78941999c66 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Thu, 29 Jan 2026 12:32:36 -0800 Subject: [PATCH] Enable bot responses to ourselves take 2 --- app/routers/messages.py | 43 +++++++- tests/test_send_messages.py | 196 ++++++++++++++++++++++++++++++++++++ 2 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 tests/test_send_messages.py diff --git a/app/routers/messages.py b/app/routers/messages.py index d311c23..0b52af1 100644 --- a/app/routers/messages.py +++ b/app/routers/messages.py @@ -1,3 +1,4 @@ +import asyncio import logging import time @@ -115,7 +116,7 @@ async def send_direct_message(request: SendDirectMessageRequest) -> Message: track_pending_ack(ack_code, message_id, suggested_timeout) logger.debug("Tracking ACK %s for message %d", ack_code, message_id) - return Message( + message = Message( id=message_id, type="PRIV", conversation_key=db_contact.public_key, @@ -126,6 +127,25 @@ async def send_direct_message(request: SendDirectMessageRequest) -> Message: acked=0, ) + # Trigger bots for outgoing DMs (runs in background, doesn't block response) + from app.bot import run_bot_for_message + + asyncio.create_task( + run_bot_for_message( + sender_name=None, + sender_key=db_contact.public_key, + message_text=request.text, + is_dm=True, + channel_key=None, + channel_name=None, + sender_timestamp=now, + path=None, + is_outgoing=True, + ) + ) + + return message + # Temporary radio slot used for sending channel messages TEMP_RADIO_SLOT = 0 @@ -213,7 +233,7 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message: detail="Failed to store outgoing message - unexpected duplicate", ) - return Message( + message = Message( id=message_id, type="CHAN", conversation_key=channel_key_upper, @@ -223,3 +243,22 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message: outgoing=True, acked=0, ) + + # Trigger bots for outgoing channel messages (runs in background, doesn't block response) + from app.bot import run_bot_for_message + + asyncio.create_task( + run_bot_for_message( + sender_name=radio_name or None, + sender_key=None, + message_text=request.text, + is_dm=False, + channel_key=channel_key_upper, + channel_name=db_channel.name, + sender_timestamp=now, + path=None, + is_outgoing=True, + ) + ) + + return message diff --git a/tests/test_send_messages.py b/tests/test_send_messages.py new file mode 100644 index 0000000..e5649e2 --- /dev/null +++ b/tests/test_send_messages.py @@ -0,0 +1,196 @@ +"""Tests for bot triggering on outgoing messages sent via the messages router.""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from meshcore import EventType + +from app.models import Channel, Contact, SendChannelMessageRequest, SendDirectMessageRequest +from app.routers.messages import send_channel_message, send_direct_message + + +def _make_radio_result(payload=None): + """Create a mock radio command result.""" + result = MagicMock() + result.type = EventType.MSG_SENT + result.payload = payload or {} + return result + + +def _make_mc(name="TestNode"): + """Create a mock MeshCore connection.""" + mc = MagicMock() + mc.self_info = {"name": name} + mc.commands = MagicMock() + mc.commands.send_msg = AsyncMock(return_value=_make_radio_result()) + mc.commands.send_chan_msg = AsyncMock(return_value=_make_radio_result()) + mc.commands.add_contact = AsyncMock(return_value=_make_radio_result()) + mc.commands.set_channel = AsyncMock(return_value=_make_radio_result()) + mc.get_contact_by_key_prefix = MagicMock(return_value=None) + return mc + + +class TestOutgoingDMBotTrigger: + """Test that sending a DM triggers bots with is_outgoing=True.""" + + @pytest.mark.asyncio + async def test_send_dm_triggers_bot(self): + """Sending a DM creates a background task to run bots.""" + mc = _make_mc() + db_contact = Contact(public_key="ab" * 32, name="Alice") + + with ( + patch("app.routers.messages.require_connected", return_value=mc), + patch( + "app.repository.ContactRepository.get_by_key_or_prefix", + new=AsyncMock(return_value=db_contact), + ), + patch("app.repository.ContactRepository.update_last_contacted", new=AsyncMock()), + patch("app.repository.MessageRepository.create", new=AsyncMock(return_value=1)), + patch("app.bot.run_bot_for_message", new=AsyncMock()) as mock_bot, + ): + request = SendDirectMessageRequest( + destination=db_contact.public_key, text="!lasttime Alice" + ) + await send_direct_message(request) + + # Let the background task run + await asyncio.sleep(0) + + mock_bot.assert_called_once() + call_kwargs = mock_bot.call_args[1] + assert call_kwargs["message_text"] == "!lasttime Alice" + assert call_kwargs["is_dm"] is True + assert call_kwargs["is_outgoing"] is True + assert call_kwargs["sender_key"] == db_contact.public_key + assert call_kwargs["channel_key"] is None + + @pytest.mark.asyncio + async def test_send_dm_bot_does_not_block_response(self): + """Bot trigger runs in background and doesn't delay the message response.""" + mc = _make_mc() + db_contact = Contact(public_key="ab" * 32, name="Alice") + + # Bot that would take a long time + slow_bot = AsyncMock(side_effect=lambda **kw: asyncio.sleep(10)) + + with ( + patch("app.routers.messages.require_connected", return_value=mc), + patch( + "app.repository.ContactRepository.get_by_key_or_prefix", + new=AsyncMock(return_value=db_contact), + ), + patch("app.repository.ContactRepository.update_last_contacted", new=AsyncMock()), + patch("app.repository.MessageRepository.create", new=AsyncMock(return_value=1)), + patch("app.bot.run_bot_for_message", new=slow_bot), + ): + request = SendDirectMessageRequest(destination=db_contact.public_key, text="Hello") + # This should return immediately, not wait 10 seconds + message = await send_direct_message(request) + assert message.text == "Hello" + assert message.outgoing is True + + @pytest.mark.asyncio + async def test_send_dm_passes_no_sender_name(self): + """Outgoing DMs pass sender_name=None (we are the sender).""" + mc = _make_mc() + db_contact = Contact(public_key="cd" * 32, name="Bob") + + with ( + patch("app.routers.messages.require_connected", return_value=mc), + patch( + "app.repository.ContactRepository.get_by_key_or_prefix", + new=AsyncMock(return_value=db_contact), + ), + patch("app.repository.ContactRepository.update_last_contacted", new=AsyncMock()), + patch("app.repository.MessageRepository.create", new=AsyncMock(return_value=1)), + patch("app.bot.run_bot_for_message", new=AsyncMock()) as mock_bot, + ): + request = SendDirectMessageRequest(destination=db_contact.public_key, text="test") + await send_direct_message(request) + await asyncio.sleep(0) + + call_kwargs = mock_bot.call_args[1] + assert call_kwargs["sender_name"] is None + + +class TestOutgoingChannelBotTrigger: + """Test that sending a channel message triggers bots with is_outgoing=True.""" + + @pytest.mark.asyncio + async def test_send_channel_msg_triggers_bot(self): + """Sending a channel message creates a background task to run bots.""" + mc = _make_mc(name="MyNode") + db_channel = Channel(key="aa" * 16, name="#general") + + with ( + patch("app.routers.messages.require_connected", return_value=mc), + patch( + "app.repository.ChannelRepository.get_by_key", + new=AsyncMock(return_value=db_channel), + ), + patch("app.repository.MessageRepository.create", new=AsyncMock(return_value=1)), + patch("app.decoder.calculate_channel_hash", return_value="abcd"), + patch("app.bot.run_bot_for_message", new=AsyncMock()) as mock_bot, + ): + request = SendChannelMessageRequest( + channel_key=db_channel.key, text="!lasttime5 someone" + ) + await send_channel_message(request) + await asyncio.sleep(0) + + mock_bot.assert_called_once() + call_kwargs = mock_bot.call_args[1] + assert call_kwargs["message_text"] == "!lasttime5 someone" + assert call_kwargs["is_dm"] is False + assert call_kwargs["is_outgoing"] is True + assert call_kwargs["channel_key"] == db_channel.key.upper() + assert call_kwargs["channel_name"] == "#general" + assert call_kwargs["sender_name"] == "MyNode" + assert call_kwargs["sender_key"] is None + + @pytest.mark.asyncio + async def test_send_channel_msg_no_radio_name(self): + """When radio has no name, sender_name is None.""" + mc = _make_mc(name="") + db_channel = Channel(key="bb" * 16, name="#test") + + with ( + patch("app.routers.messages.require_connected", return_value=mc), + patch( + "app.repository.ChannelRepository.get_by_key", + new=AsyncMock(return_value=db_channel), + ), + patch("app.repository.MessageRepository.create", new=AsyncMock(return_value=1)), + patch("app.decoder.calculate_channel_hash", return_value="abcd"), + patch("app.bot.run_bot_for_message", new=AsyncMock()) as mock_bot, + ): + request = SendChannelMessageRequest(channel_key=db_channel.key, text="hello") + await send_channel_message(request) + await asyncio.sleep(0) + + call_kwargs = mock_bot.call_args[1] + assert call_kwargs["sender_name"] is None + + @pytest.mark.asyncio + async def test_send_channel_msg_bot_does_not_block_response(self): + """Bot trigger runs in background and doesn't delay the message response.""" + mc = _make_mc(name="MyNode") + db_channel = Channel(key="cc" * 16, name="#slow") + + slow_bot = AsyncMock(side_effect=lambda **kw: asyncio.sleep(10)) + + with ( + patch("app.routers.messages.require_connected", return_value=mc), + patch( + "app.repository.ChannelRepository.get_by_key", + new=AsyncMock(return_value=db_channel), + ), + patch("app.repository.MessageRepository.create", new=AsyncMock(return_value=1)), + patch("app.decoder.calculate_channel_hash", return_value="abcd"), + patch("app.bot.run_bot_for_message", new=slow_bot), + ): + request = SendChannelMessageRequest(channel_key=db_channel.key, text="test") + message = await send_channel_message(request) + assert message.outgoing is True