From 430b5aaba7a5a9966204af337b4391302af09669 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Thu, 12 Feb 2026 23:52:49 -0800 Subject: [PATCH] Support incoming/outgoing detection in bots --- app/bot.py | 76 ++++++++++++--- frontend/src/components/SettingsModal.tsx | 8 +- tests/test_bot.py | 110 +++++++++++++++++++--- 3 files changed, 167 insertions(+), 27 deletions(-) diff --git a/app/bot.py b/app/bot.py index 50907bd..4d755d5 100644 --- a/app/bot.py +++ b/app/bot.py @@ -11,6 +11,7 @@ the security implications. """ import asyncio +import inspect import logging import time from concurrent.futures import ThreadPoolExecutor @@ -48,15 +49,19 @@ def execute_bot_code( channel_name: str | None, sender_timestamp: int | None, path: str | None, + is_outgoing: bool = False, ) -> 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)` + `bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, is_outgoing)` that returns either None (no response), a string (single response message), or a list of strings (multiple messages sent in order). + Legacy bot functions with 8 parameters (without is_outgoing) are detected + via inspect and called without the new parameter for backward compatibility. + Args: code: Python code defining the bot function sender_name: Display name of the sender (may be None) @@ -67,6 +72,7 @@ def execute_bot_code( channel_name: Channel name (e.g. "#general" with hash), None for DMs sender_timestamp: Sender's timestamp from the message (may be None) path: Hex-encoded routing path (may be None) + is_outgoing: True if this is our own outgoing message Returns: Response string, list of strings, or None. @@ -95,18 +101,63 @@ def execute_bot_code( bot_func = namespace["bot"] + # Detect whether the bot function accepts is_outgoing (new 9-param signature) + # or uses the legacy 8-param signature, for backward compatibility. + # Three cases: explicit is_outgoing param or 9+ params (positional), + # **kwargs (pass as keyword), or legacy 8-param (omit). + call_style = "legacy" # "positional", "keyword", or "legacy" try: - # Call the bot function with message context - result = bot_func( - sender_name, - sender_key, - message_text, - is_dm, - channel_key, - channel_name, - sender_timestamp, - path, - ) + sig = inspect.signature(bot_func) + params = sig.parameters + non_variadic = [ + p + for p in params.values() + if p.kind not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD) + ] + if "is_outgoing" in params or len(non_variadic) >= 9: + call_style = "positional" + elif any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()): + call_style = "keyword" + except (ValueError, TypeError): + pass + + try: + # Call the bot function with appropriate signature + if call_style == "positional": + result = bot_func( + sender_name, + sender_key, + message_text, + is_dm, + channel_key, + channel_name, + sender_timestamp, + path, + is_outgoing, + ) + elif call_style == "keyword": + result = bot_func( + sender_name, + sender_key, + message_text, + is_dm, + channel_key, + channel_name, + sender_timestamp, + path, + is_outgoing=is_outgoing, + ) + else: + result = bot_func( + sender_name, + sender_key, + message_text, + is_dm, + channel_key, + channel_name, + sender_timestamp, + path, + ) # Validate result if result is None: @@ -281,6 +332,7 @@ async def run_bot_for_message( channel_name, sender_timestamp, path, + is_outgoing, ), timeout=BOT_EXECUTION_TIMEOUT, ) diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 54f835b..072a4e7 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -163,9 +163,10 @@ export function SettingsModal(props: SettingsModalProps) { channel_name: str | None, sender_timestamp: int | None, path: str | None, + is_outgoing: bool = False, ) -> str | list[str] | None: """ - Process incoming messages and optionally return a reply. + Process messages and optionally return a reply. Args: sender_name: Display name of sender (may be None) @@ -176,11 +177,16 @@ export function SettingsModal(props: SettingsModalProps) { channel_name: Channel name with hash (e.g. "#bot"), None for DMs sender_timestamp: Sender's timestamp (unix seconds, may be None) path: Hex-encoded routing path (may be None) + is_outgoing: True if this is our own outgoing message Returns: None for no reply, a string for a single reply, or a list of strings to send multiple messages in order """ + # Don't reply to our own outgoing messages + if is_outgoing: + return None + # Example: Only respond in #bot channel to "!pling" command if channel_name == "#bot" and "!pling" in message_text.lower(): return "[BOT] Plong!" diff --git a/tests/test_bot.py b/tests/test_bot.py index 94fb67c..409827d 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -22,7 +22,7 @@ class TestExecuteBotCode: def test_valid_code_returning_string(self): """Bot code that returns a string works correctly.""" code = """ -def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path): +def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, is_outgoing): return f"Hello, {sender_name}!" """ result = execute_bot_code( @@ -41,7 +41,7 @@ def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, def test_valid_code_returning_none(self): """Bot code that returns None works correctly.""" code = """ -def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path): +def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, is_outgoing): return None """ result = execute_bot_code( @@ -60,7 +60,7 @@ def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, def test_empty_string_response_treated_as_none(self): """Bot returning empty/whitespace string is treated as None.""" code = """ -def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path): +def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, is_outgoing): return " " """ result = execute_bot_code( @@ -135,7 +135,7 @@ bot = "I'm a string, not a function" def test_bot_function_raises_exception(self): """Bot function that raises exception returns None.""" code = """ -def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path): +def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, is_outgoing): raise ValueError("oops!") """ result = execute_bot_code( @@ -154,7 +154,7 @@ def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, def test_bot_returns_non_string(self): """Bot function returning non-string returns None.""" code = """ -def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path): +def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, is_outgoing): return 42 """ result = execute_bot_code( @@ -201,10 +201,9 @@ def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, assert result is None def test_bot_receives_all_parameters(self): - """Bot function receives all expected parameters.""" + """Bot function receives all expected parameters including is_outgoing.""" code = """ -def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path): - # Verify all params are accessible +def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, is_outgoing): parts = [ f"name={sender_name}", f"key={sender_key}", @@ -214,6 +213,7 @@ def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, f"ch_name={channel_name}", f"ts={sender_timestamp}", f"path={path}", + f"outgoing={is_outgoing}", ] return "|".join(parts) """ @@ -227,16 +227,98 @@ def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, channel_name="#test", sender_timestamp=12345, path="001122", + is_outgoing=True, ) assert ( result - == "name=Bob|key=def456|msg=Test|dm=False|ch_key=AABBCCDD|ch_name=#test|ts=12345|path=001122" + == "name=Bob|key=def456|msg=Test|dm=False|ch_key=AABBCCDD|ch_name=#test|ts=12345|path=001122|outgoing=True" ) + def test_is_outgoing_defaults_to_false(self): + """is_outgoing defaults to False when not specified.""" + code = """ +def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, is_outgoing): + return f"outgoing={is_outgoing}" +""" + 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 == "outgoing=False" + + def test_is_outgoing_true_passed_correctly(self): + """is_outgoing=True is passed to bot function.""" + code = """ +def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, is_outgoing): + if is_outgoing: + return None + return "reply" +""" + 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, + is_outgoing=True, + ) + assert result is None + + def test_legacy_8_param_bot_still_works(self): + """Legacy bot with 8 parameters (no is_outgoing) still works.""" + code = """ +def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path): + return f"Hello, {sender_name}!" +""" + 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, + is_outgoing=True, + ) + assert result == "Hello, Alice!" + + def test_legacy_bot_with_kwargs_receives_is_outgoing(self): + """Legacy bot using **kwargs receives is_outgoing.""" + code = """ +def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, **kwargs): + return f"outgoing={kwargs.get('is_outgoing', 'missing')}" +""" + 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, + is_outgoing=True, + ) + assert result == "outgoing=True" + def test_channel_message_with_none_sender_key(self): """Channel messages correctly pass None for sender_key.""" code = """ -def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path): +def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, is_outgoing): if sender_key is None and not is_dm: return "channel message detected" return "unexpected" @@ -257,7 +339,7 @@ def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, 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): +def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, is_outgoing): return ["First message", "Second message", "Third message"] """ result = execute_bot_code( @@ -276,7 +358,7 @@ def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, 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): +def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, is_outgoing): return [] """ result = execute_bot_code( @@ -295,7 +377,7 @@ def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, 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): +def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, is_outgoing): return ["Valid", "", " ", "Also valid", None, 42] """ result = execute_bot_code( @@ -315,7 +397,7 @@ def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, 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): +def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, is_outgoing): return ["", " ", ""] """ result = execute_bot_code(