forked from iarv/Remote-Terminal-for-MeshCore
Support multiple outgoing messages from the bot
This commit is contained in:
45
app/bot.py
45
app/bot.py
@@ -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
1
frontend/dist/assets/index-B3-iYEDN.js.map
vendored
Normal file
1
frontend/dist/assets/index-B3-iYEDN.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/index-COA8MjNX.js.map
vendored
1
frontend/dist/assets/index-COA8MjNX.js.map
vendored
File diff suppressed because one or more lines are too long
2
frontend/dist/index.html
vendored
2
frontend/dist/index.html
vendored
@@ -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>
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user