Support multiple outgoing messages from the bot

This commit is contained in:
Jack Kingsman
2026-01-27 12:21:00 -08:00
parent e2b4d7b8fe
commit cf1107f736
7 changed files with 242 additions and 18 deletions

View File

@@ -48,13 +48,14 @@ def execute_bot_code(
channel_name: str | None,
sender_timestamp: int | None,
path: str | None,
) -> str | None:
) -> str | list[str] | None:
"""
Execute user-provided bot code with message context.
The code should define a function:
`bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path)`
that returns either None (no response) or a string (response message).
that returns either None (no response), a string (single response message),
or a list of strings (multiple messages sent in order).
Args:
code: Python code defining the bot function
@@ -68,7 +69,7 @@ def execute_bot_code(
path: Hex-encoded routing path (may be None)
Returns:
Response string if the bot returns one, None otherwise.
Response string, list of strings, or None.
Note: This executes arbitrary code. Only use with trusted input.
"""
@@ -112,8 +113,12 @@ def execute_bot_code(
return None
if isinstance(result, str):
return result if result.strip() else None
if isinstance(result, list):
# Filter to non-empty strings only
valid_messages = [msg for msg in result if isinstance(msg, str) and msg.strip()]
return valid_messages if valid_messages else None
logger.debug("Bot function returned non-string: %s", type(result))
logger.debug("Bot function returned unsupported type: %s", type(result))
return None
except Exception as e:
@@ -122,13 +127,13 @@ def execute_bot_code(
async def process_bot_response(
response: str,
response: str | list[str],
is_dm: bool,
sender_key: str,
channel_key: str | None,
) -> None:
"""
Send the bot's response message using the existing message sending endpoints.
Send the bot's response message(s) using the existing message sending endpoints.
For DMs, sends a direct message back to the sender.
For channel messages, sends to the same channel.
@@ -137,7 +142,29 @@ async def process_bot_response(
between sends, giving repeaters time to return to listening mode.
Args:
response: The response text to send
response: The response text to send, or a list of messages to send in order
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
for message_text in messages:
await _send_single_bot_message(message_text, is_dm, sender_key, channel_key)
async def _send_single_bot_message(
message_text: str,
is_dm: bool,
sender_key: str,
channel_key: str | None,
) -> None:
"""
Send a single bot message with rate limiting.
Args:
message_text: The message text to send
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
@@ -162,13 +189,13 @@ async def process_bot_response(
try:
if is_dm:
logger.info("Bot sending DM reply to %s", sender_key[:12])
request = SendDirectMessageRequest(destination=sender_key, text=response)
request = SendDirectMessageRequest(destination=sender_key, text=message_text)
message = await send_direct_message(request)
# Broadcast to WebSocket (endpoint returns to HTTP caller, bot needs explicit broadcast)
broadcast_event("message", message.model_dump())
elif channel_key:
logger.info("Bot sending channel reply to %s", channel_key[:8])
request = SendChannelMessageRequest(channel_key=channel_key, text=response)
request = SendChannelMessageRequest(channel_key=channel_key, text=message_text)
message = await send_channel_message(request)
# Broadcast to WebSocket
broadcast_event("message", message.model_dump())

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -13,7 +13,7 @@
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<script type="module" crossorigin src="/assets/index-COA8MjNX.js"></script>
<script type="module" crossorigin src="/assets/index-B3-iYEDN.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-H2C92sGV.css">
</head>
<body>

View File

@@ -108,13 +108,22 @@ export function SettingsModal({
const [advertInterval, setAdvertInterval] = useState('0');
// Bot state
const DEFAULT_BOT_CODE = `def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path):
const DEFAULT_BOT_CODE = `def bot(
sender_name: str | None,
sender_key: str | None,
message_text: str,
is_dm: bool,
channel_key: str | None,
channel_name: str | None,
sender_timestamp: int | None,
path: str | None,
) -> str | list[str] | None:
"""
Process incoming messages and optionally return a reply.
Args:
sender_name: Display name of sender (may be None)
sender_key: 64-char hex public key (empty for channel msgs)
sender_key: 64-char hex public key (None for channel msgs)
message_text: The message content
is_dm: True for direct messages, False for channel
channel_key: 32-char hex key for channels, None for DMs
@@ -123,7 +132,8 @@ export function SettingsModal({
path: Hex-encoded routing path (may be None)
Returns:
None for no reply, or a string to send as reply
None for no reply, a string for a single reply,
or a list of strings to send multiple messages in order
"""
# Example: Only respond in #bot channel to "!pling" command
if channel_name == "#bot" and "!pling" in message_text.lower():

View File

@@ -253,6 +253,83 @@ def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name,
)
assert result == "channel message detected"
def test_bot_returns_list_of_strings(self):
"""Bot function returning list of strings works correctly."""
code = """
def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path):
return ["First message", "Second message", "Third message"]
"""
result = execute_bot_code(
code=code,
sender_name="Alice",
sender_key="abc123",
message_text="Hi",
is_dm=True,
channel_key=None,
channel_name=None,
sender_timestamp=None,
path=None,
)
assert result == ["First message", "Second message", "Third message"]
def test_bot_returns_empty_list(self):
"""Bot function returning empty list is treated as None."""
code = """
def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path):
return []
"""
result = execute_bot_code(
code=code,
sender_name="Alice",
sender_key="abc123",
message_text="Hi",
is_dm=True,
channel_key=None,
channel_name=None,
sender_timestamp=None,
path=None,
)
assert result is None
def test_bot_returns_list_with_empty_strings_filtered(self):
"""Bot function returning list filters out empty/whitespace strings."""
code = """
def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path):
return ["Valid", "", " ", "Also valid", None, 42]
"""
result = execute_bot_code(
code=code,
sender_name="Alice",
sender_key="abc123",
message_text="Hi",
is_dm=True,
channel_key=None,
channel_name=None,
sender_timestamp=None,
path=None,
)
# Only valid non-empty strings should remain
assert result == ["Valid", "Also valid"]
def test_bot_returns_list_all_empty_treated_as_none(self):
"""Bot function returning list of all empty strings is treated as None."""
code = """
def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path):
return ["", " ", ""]
"""
result = execute_bot_code(
code=code,
sender_name="Alice",
sender_key="abc123",
message_text="Hi",
is_dm=True,
channel_key=None,
channel_name=None,
sender_timestamp=None,
path=None,
)
assert result is None
class TestRunBotForMessage:
"""Test the main bot entry point."""
@@ -608,3 +685,103 @@ class TestBotMessageRateLimiting:
wait_time = mock_sleep.call_args[0][0]
assert abs(wait_time - 1.0) < 0.01
mock_send.assert_called_once()
class TestBotListResponses:
"""Test bot functionality for list responses."""
@pytest.fixture(autouse=True)
def reset_rate_limit_state(self):
"""Reset rate limiting state between tests."""
bot_module._last_bot_send_time = 0.0
yield
bot_module._last_bot_send_time = 0.0
@pytest.mark.asyncio
async def test_list_response_sends_multiple_messages(self):
"""List response should send multiple messages in order."""
sent_messages = []
async def mock_send(request):
sent_messages.append(request.text)
mock_message = MagicMock()
mock_message.model_dump.return_value = {}
return mock_message
with (
patch("app.bot.time.monotonic", return_value=100.0),
patch("app.bot.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=["First", "Second", "Third"],
is_dm=True,
sender_key="a" * 64,
channel_key=None,
)
assert sent_messages == ["First", "Second", "Third"]
@pytest.mark.asyncio
async def test_list_response_rate_limited_between_messages(self):
"""Each message in a list should be rate limited."""
sleep_calls = []
time_counter = [100.0]
def mock_monotonic():
return time_counter[0]
async def mock_sleep(duration):
sleep_calls.append(duration)
time_counter[0] += duration
async def mock_send(request):
mock_message = MagicMock()
mock_message.model_dump.return_value = {}
return mock_message
with (
patch("app.bot.time.monotonic", side_effect=mock_monotonic),
patch("app.bot.asyncio.sleep", side_effect=mock_sleep),
patch("app.routers.messages.send_direct_message", side_effect=mock_send),
patch("app.websocket.broadcast_event"),
):
await process_bot_response(
response=["First", "Second", "Third"],
is_dm=True,
sender_key="a" * 64,
channel_key=None,
)
# Should have waited between messages (after first send)
# First message: no wait, Second: wait 2s, Third: wait 2s
assert len(sleep_calls) == 2
assert all(abs(w - BOT_MESSAGE_SPACING) < 0.01 for w in sleep_calls)
@pytest.mark.asyncio
async def test_string_response_still_works(self):
"""Single string response should still work after list support added."""
sent_messages = []
async def mock_send(request):
sent_messages.append(request.text)
mock_message = MagicMock()
mock_message.model_dump.return_value = {}
return mock_message
with (
patch("app.bot.time.monotonic", return_value=100.0),
patch("app.bot.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="Just one message",
is_dm=True,
sender_key="a" * 64,
channel_key=None,
)
assert sent_messages == ["Just one message"]