Enable bot responses to ourselves take 2

This commit is contained in:
Jack Kingsman
2026-01-29 12:32:36 -08:00
parent 225c892847
commit cc12997672
2 changed files with 237 additions and 2 deletions

View File

@@ -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

196
tests/test_send_messages.py Normal file
View File

@@ -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