Add automatic bot functionality

Add basic bot functionality
This commit is contained in:
Jack Kingsman
2026-01-26 21:46:26 -08:00
committed by GitHub
22 changed files with 1570 additions and 561 deletions
+248
View File
@@ -0,0 +1,248 @@
"""
Bot execution module for automatic message responses.
This module provides functionality for executing user-defined Python code
in response to incoming messages. The user's code can process message data
and optionally return a response string.
SECURITY WARNING: This executes arbitrary Python code provided by the user.
It should only be enabled on trusted systems where the user understands
the security implications.
"""
import asyncio
import logging
from concurrent.futures import ThreadPoolExecutor
from typing import Any
from fastapi import HTTPException
logger = logging.getLogger(__name__)
# Limit concurrent bot executions to prevent resource exhaustion
_bot_semaphore = asyncio.Semaphore(3)
# Dedicated thread pool for bot execution (separate from default executor)
_bot_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="bot_")
# Timeout for bot code execution (seconds)
BOT_EXECUTION_TIMEOUT = 10
def execute_bot_code(
code: str,
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 | 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).
Args:
code: Python code defining the bot function
sender_name: Display name of the sender (may be None)
sender_key: 64-char hex public key of sender for DMs, None for channel messages
message_text: The message content
is_dm: True for direct messages, False for channel messages
channel_key: 32-char hex channel key for channel messages, None for DMs
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)
Returns:
Response string if the bot returns one, None otherwise.
Note: This executes arbitrary code. Only use with trusted input.
"""
if not code or not code.strip():
return None
# Build execution namespace with allowed imports
namespace: dict[str, Any] = {
"__builtins__": __builtins__,
}
try:
# Execute the user's code to define the bot function
exec(code, namespace)
except Exception as e:
logger.warning("Bot code compilation failed: %s", e)
return None
# Check if bot function was defined
if "bot" not in namespace or not callable(namespace["bot"]):
logger.debug("Bot code does not define a callable 'bot' function")
return None
bot_func = namespace["bot"]
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,
)
# Validate result
if result is None:
return None
if isinstance(result, str):
return result if result.strip() else None
logger.debug("Bot function returned non-string: %s", type(result))
return None
except Exception as e:
logger.warning("Bot function execution failed: %s", e)
return None
async def process_bot_response(
response: str,
is_dm: bool,
sender_key: str,
channel_key: str | None,
) -> None:
"""
Send the bot's response message using the existing message sending endpoints.
For DMs, sends a direct message back to the sender.
For channel messages, sends to the same channel.
Args:
response: The response 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
"""
from app.models import SendChannelMessageRequest, SendDirectMessageRequest
from app.routers.messages import send_channel_message, send_direct_message
from app.websocket import broadcast_event
try:
if is_dm:
logger.info("Bot sending DM reply to %s", sender_key[:12])
request = SendDirectMessageRequest(destination=sender_key, text=response)
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)
message = await send_channel_message(request)
# Broadcast to WebSocket
broadcast_event("message", message.model_dump())
else:
logger.warning("Cannot send bot response: no destination")
except HTTPException as e:
logger.error("Bot failed to send response: %s", e.detail)
except Exception as e:
logger.error("Bot failed to send response: %s", e)
async def run_bot_for_message(
sender_name: str | None,
sender_key: str | None,
message_text: str,
is_dm: bool,
channel_key: str | None,
channel_name: str | None = None,
sender_timestamp: int | None = None,
path: str | None = None,
is_outgoing: bool = False,
) -> None:
"""
Run the bot for an incoming message if enabled.
This is the main entry point called by message handlers after
a message is successfully decrypted and stored.
Args:
sender_name: Display name of the sender
sender_key: 64-char hex public key of sender (DMs only, None for channels)
message_text: The message content
is_dm: True for direct messages, False for channel messages
channel_key: Channel key for channel messages
channel_name: Channel name (e.g. "#general"), None for DMs
sender_timestamp: Sender's timestamp from the message
path: Hex-encoded routing path
is_outgoing: Whether this is our own outgoing message (skip bot)
"""
# Don't respond to our own outgoing messages
if is_outgoing:
return
# Early check if bot is enabled (will re-check after sleep)
from app.repository import AppSettingsRepository
settings = await AppSettingsRepository.get()
if not settings.bot_enabled or not settings.bot_code:
return
# Try to acquire semaphore (limit concurrent bot executions)
if not _bot_semaphore.locked():
pass # Semaphore available
else:
logger.debug("Bot execution queue full, skipping message")
return
async with _bot_semaphore:
logger.debug(
"Running bot for message from %s (is_dm=%s)",
sender_name or (sender_key[:12] if sender_key else "unknown"),
is_dm,
)
# Wait for the initiating message's retransmissions to propagate through the mesh
await asyncio.sleep(2)
# Re-check settings after sleep (user may have disabled bot)
settings = await AppSettingsRepository.get()
if not settings.bot_enabled or not settings.bot_code:
logger.debug("Bot disabled during wait, skipping")
return
# Execute bot code in a dedicated thread pool with timeout
loop = asyncio.get_event_loop()
try:
response = await asyncio.wait_for(
loop.run_in_executor(
_bot_executor,
execute_bot_code,
settings.bot_code,
sender_name,
sender_key,
message_text,
is_dm,
channel_key,
channel_name,
sender_timestamp,
path,
),
timeout=BOT_EXECUTION_TIMEOUT,
)
except asyncio.TimeoutError:
logger.warning("Bot execution timed out after %ds", BOT_EXECUTION_TIMEOUT)
return
except Exception as e:
logger.warning("Bot execution error: %s", e)
return
# Send response if any
if response:
await process_bot_response(response, is_dm, sender_key or "", channel_key)
+15
View File
@@ -118,6 +118,21 @@ async def on_contact_message(event: "Event") -> None:
if contact:
await ContactRepository.update_last_contacted(contact.public_key, received_at)
# Run bot if enabled (for non-CLI messages)
from app.bot import run_bot_for_message
await run_bot_for_message(
sender_name=contact.name if contact else None,
sender_key=sender_pubkey,
message_text=payload.get("text", ""),
is_dm=True,
channel_key=None,
channel_name=None,
sender_timestamp=payload.get("sender_timestamp"),
path=payload.get("path"),
is_outgoing=False,
)
async def on_rx_log_data(event: "Event") -> None:
"""Store raw RF packet data and process via centralized packet processor.
+35
View File
@@ -114,6 +114,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 11)
applied += 1
# Migration 12: Add bot_enabled and bot_code columns to app_settings
if version < 12:
logger.info("Applying migration 12: add bot settings columns")
await _migrate_012_add_bot_settings(conn)
await set_version(conn, 12)
applied += 1
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -680,3 +687,31 @@ async def _migrate_011_add_last_advert_time(conn: aiosqlite.Connection) -> None:
raise
await conn.commit()
async def _migrate_012_add_bot_settings(conn: aiosqlite.Connection) -> None:
"""
Add bot_enabled and bot_code columns to app_settings table.
This enables user-defined Python code to be executed when messages are received,
allowing for custom bot responses.
"""
try:
await conn.execute("ALTER TABLE app_settings ADD COLUMN bot_enabled INTEGER DEFAULT 0")
logger.debug("Added bot_enabled column to app_settings")
except aiosqlite.OperationalError as e:
if "duplicate column" in str(e).lower():
logger.debug("bot_enabled column already exists, skipping")
else:
raise
try:
await conn.execute("ALTER TABLE app_settings ADD COLUMN bot_code TEXT DEFAULT ''")
logger.debug("Added bot_code column to app_settings")
except aiosqlite.OperationalError as e:
if "duplicate column" in str(e).lower():
logger.debug("bot_code column already exists, skipping")
else:
raise
await conn.commit()
+8
View File
@@ -263,3 +263,11 @@ class AppSettings(BaseModel):
default=0,
description="Unix timestamp of last advertisement sent (0 = never)",
)
bot_enabled: bool = Field(
default=False,
description="Whether the message bot is enabled",
)
bot_code: str = Field(
default="",
description="Python code for the message bot function",
)
+46 -2
View File
@@ -47,6 +47,8 @@ async def create_message_from_decrypted(
timestamp: int,
received_at: int | None = None,
path: str | None = None,
channel_name: str | None = None,
trigger_bot: bool = True,
) -> int | None:
"""Create a message record from decrypted channel packet content.
@@ -56,11 +58,13 @@ async def create_message_from_decrypted(
Args:
packet_id: ID of the raw packet being processed
channel_key: Hex string channel key
channel_name: Channel name (e.g. "#general"), for bot context
sender: Sender name (will be prefixed to message) or None
message_text: The decrypted message content
timestamp: Sender timestamp from the packet
received_at: When the packet was received (defaults to now)
path: Hex-encoded routing path (None for historical decryption)
path: Hex-encoded routing path
trigger_bot: Whether to trigger bot response (False for historical decryption)
Returns the message ID if created, None if duplicate.
"""
@@ -162,6 +166,22 @@ async def create_message_from_decrypted(
},
)
# Run bot if enabled (for incoming channel messages, not historical decryption)
if trigger_bot:
from app.bot import run_bot_for_message
await run_bot_for_message(
sender_name=sender,
sender_key=None, # Channel messages don't have a sender public key
message_text=message_text,
is_dm=False,
channel_key=channel_key_normalized,
channel_name=channel_name,
sender_timestamp=timestamp,
path=path,
is_outgoing=False,
)
return msg_id
@@ -173,6 +193,7 @@ async def create_dm_message_from_decrypted(
received_at: int | None = None,
path: str | None = None,
outgoing: bool = False,
trigger_bot: bool = True,
) -> int | None:
"""Create a message record from decrypted direct message packet content.
@@ -185,8 +206,9 @@ async def create_dm_message_from_decrypted(
their_public_key: The contact's full 64-char public key (conversation_key)
our_public_key: Our public key (to determine direction), or None
received_at: When the packet was received (defaults to now)
path: Hex-encoded routing path (None for historical decryption)
path: Hex-encoded routing path
outgoing: Whether this is an outgoing message (we sent it)
trigger_bot: Whether to trigger bot response (False for historical decryption)
Returns the message ID if created, None if duplicate.
"""
@@ -289,6 +311,26 @@ async def create_dm_message_from_decrypted(
# Update contact's last_contacted timestamp (for sorting)
await ContactRepository.update_last_contacted(conversation_key, received)
# Run bot if enabled (for incoming DMs only, not historical decryption or outgoing)
if trigger_bot and not outgoing:
from app.bot import run_bot_for_message
# Get contact name for the bot
contact = await ContactRepository.get_by_key(their_public_key)
sender_name = contact.name if contact else None
await run_bot_for_message(
sender_name=sender_name,
sender_key=their_public_key,
message_text=decrypted.message,
is_dm=True,
channel_key=None,
channel_name=None,
sender_timestamp=decrypted.timestamp,
path=path,
is_outgoing=False,
)
return msg_id
@@ -341,6 +383,7 @@ async def run_historical_dm_decryption(
received_at=packet_timestamp,
path=path_hex,
outgoing=outgoing,
trigger_bot=False, # Historical decryption should not trigger bot
)
if msg_id is not None:
@@ -535,6 +578,7 @@ async def _process_group_text(
msg_id = await create_message_from_decrypted(
packet_id=packet_id,
channel_key=channel.key,
channel_name=channel.name,
sender=decrypted.sender,
message_text=decrypted.message,
timestamp=decrypted.timestamp,
+13 -1
View File
@@ -682,7 +682,7 @@ class AppSettingsRepository:
"""
SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
sidebar_sort_order, last_message_times, preferences_migrated,
advert_interval, last_advert_time
advert_interval, last_advert_time, bot_enabled, bot_code
FROM app_settings WHERE id = 1
"""
)
@@ -732,6 +732,8 @@ class AppSettingsRepository:
preferences_migrated=bool(row["preferences_migrated"]),
advert_interval=row["advert_interval"] or 0,
last_advert_time=row["last_advert_time"] or 0,
bot_enabled=bool(row["bot_enabled"]),
bot_code=row["bot_code"] or "",
)
@staticmethod
@@ -744,6 +746,8 @@ class AppSettingsRepository:
preferences_migrated: bool | None = None,
advert_interval: int | None = None,
last_advert_time: int | None = None,
bot_enabled: bool | None = None,
bot_code: str | None = None,
) -> AppSettings:
"""Update app settings. Only provided fields are updated."""
updates = []
@@ -782,6 +786,14 @@ class AppSettingsRepository:
updates.append("last_advert_time = ?")
params.append(last_advert_time)
if bot_enabled is not None:
updates.append("bot_enabled = ?")
params.append(1 if bot_enabled else 0)
if bot_code is not None:
updates.append("bot_code = ?")
params.append(bot_code)
if updates:
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
await db.conn.execute(query, params)
+2
View File
@@ -63,11 +63,13 @@ async def _run_historical_channel_decryption(
msg_id = await create_message_from_decrypted(
packet_id=packet_id,
channel_key=channel_key_hex,
channel_name=display_name,
sender=result.sender,
message_text=result.message,
timestamp=result.timestamp,
received_at=packet_timestamp,
path=path_hex,
trigger_bot=False, # Historical decryption should not trigger bot
)
if msg_id is not None:
+32 -1
View File
@@ -1,7 +1,7 @@
import logging
from typing import Literal
from fastapi import APIRouter
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app.models import AppSettings
@@ -11,6 +11,20 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/settings", tags=["settings"])
def validate_bot_code(code: str) -> None:
"""Validate bot code syntax. Raises HTTPException on error."""
if not code or not code.strip():
return # Empty code is valid (disables bot)
try:
compile(code, "<bot_code>", "exec")
except SyntaxError as e:
raise HTTPException(
status_code=400,
detail=f"Bot code syntax error at line {e.lineno}: {e.msg}",
) from None
class AppSettingsUpdate(BaseModel):
max_radio_contacts: int | None = Field(
default=None,
@@ -31,6 +45,14 @@ class AppSettingsUpdate(BaseModel):
ge=0,
description="Periodic advertisement interval in seconds (0 = disabled)",
)
bot_enabled: bool | None = Field(
default=None,
description="Whether the message bot is enabled",
)
bot_code: str | None = Field(
default=None,
description="Python code for the message bot function",
)
class FavoriteRequest(BaseModel):
@@ -94,6 +116,15 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
logger.info("Updating advert_interval to %d", update.advert_interval)
kwargs["advert_interval"] = update.advert_interval
if update.bot_enabled is not None:
logger.info("Updating bot_enabled to %s", update.bot_enabled)
kwargs["bot_enabled"] = update.bot_enabled
if update.bot_code is not None:
validate_bot_code(update.bot_code)
logger.info("Updating bot_code (length=%d)", len(update.bot_code))
kwargs["bot_code"] = update.bot_code
if kwargs:
return await AppSettingsRepository.update(**kwargs)
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
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
+2 -2
View File
@@ -13,8 +13,8 @@
<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-DsqXtGVx.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cxcg9Mr6.css">
<script type="module" crossorigin src="/assets/index-DpuVn1aK.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B-EWrA2Q.css">
</head>
<body>
<div id="root"></div>
+30 -2
View File
@@ -1,12 +1,12 @@
{
"name": "remoteterm-meshcore-frontend",
"version": "1.4.1",
"version": "1.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "remoteterm-meshcore-frontend",
"version": "1.4.1",
"version": "1.5.0",
"dependencies": {
"@michaelhart/meshcore-decoder": "^0.2.7",
"@radix-ui/react-checkbox": "^1.3.3",
@@ -15,6 +15,7 @@
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@types/prismjs": "^1.26.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"d3-force": "^3.0.0",
@@ -22,9 +23,11 @@
"lucide-react": "^0.562.0",
"meshcore-hashtag-cracker": "^1.6.0",
"nosleep.js": "^0.12.0",
"prismjs": "^1.30.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-leaflet": "^4.2.1",
"react-simple-code-editor": "^0.14.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7"
@@ -1521,6 +1524,12 @@
"undici-types": "~7.16.0"
}
},
"node_modules/@types/prismjs": {
"version": "1.26.5",
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz",
"integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==",
"license": "MIT"
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"devOptional": true,
@@ -3953,6 +3962,15 @@
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/prismjs": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"dev": true,
@@ -4071,6 +4089,16 @@
}
}
},
"node_modules/react-simple-code-editor": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/react-simple-code-editor/-/react-simple-code-editor-0.14.1.tgz",
"integrity": "sha512-BR5DtNRy+AswWJECyA17qhUDvrrCZ6zXOCfkQY5zSmb96BVUbpVAv03WpcjcwtCwiLbIANx3gebHOcXYn1EHow==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"license": "MIT",
+3
View File
@@ -22,6 +22,7 @@
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@types/prismjs": "^1.26.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"d3-force": "^3.0.0",
@@ -29,9 +30,11 @@
"lucide-react": "^0.562.0",
"meshcore-hashtag-cracker": "^1.6.0",
"nosleep.js": "^0.12.0",
"prismjs": "^1.30.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-leaflet": "^4.2.1",
"react-simple-code-editor": "^0.14.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7"
+136 -3
View File
@@ -1,4 +1,8 @@
import { useState, useEffect, useMemo } from 'react';
import Editor from 'react-simple-code-editor';
import { highlight, languages } from 'prismjs';
import 'prismjs/components/prism-python';
import 'prismjs/themes/prism-tomorrow.css';
import type {
AppSettings,
AppSettingsUpdate,
@@ -73,7 +77,7 @@ export function SettingsModal({
onRefreshAppSettings,
}: SettingsModalProps) {
// Tab state
type SettingsTab = 'radio' | 'identity' | 'serial' | 'database' | 'advertise';
type SettingsTab = 'radio' | 'identity' | 'serial' | 'database' | 'advertise' | 'bot';
const [activeTab, setActiveTab] = useState<SettingsTab>('radio');
// Radio config state
@@ -103,6 +107,31 @@ export function SettingsModal({
// Advertisement interval state
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):
"""
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)
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
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)
Returns:
None for no reply, or a string to send as reply
"""
# Example: Only respond in #bot channel to "!pling" command
if channel_name == "#bot" and "!pling" in message_text.lower():
return "[BOT] Plong!"
return None`;
const [botEnabled, setBotEnabled] = useState(false);
const [botCode, setBotCode] = useState(DEFAULT_BOT_CODE);
useEffect(() => {
if (config) {
setName(config.name);
@@ -121,6 +150,11 @@ export function SettingsModal({
setMaxRadioContacts(String(appSettings.max_radio_contacts));
setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert);
setAdvertInterval(String(appSettings.advert_interval));
setBotEnabled(appSettings.bot_enabled);
// Only overwrite bot code if user has saved custom code
if (appSettings.bot_code) {
setBotCode(appSettings.bot_code);
}
}
}, [appSettings]);
@@ -354,9 +388,25 @@ export function SettingsModal({
}
};
const handleSaveBotSettings = async () => {
setLoading(true);
setError('');
try {
await onSaveAppSettings({ bot_enabled: botEnabled, bot_code: botCode });
toast.success('Bot settings saved');
} catch (err) {
console.error('Failed to save bot settings:', err);
setError(err instanceof Error ? err.message : 'Failed to save');
toast.error('Failed to save settings');
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
<DialogContent className="sm:max-w-[50vw] sm:min-w-[500px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Radio & Settings</DialogTitle>
<DialogDescription className="sr-only">
@@ -366,6 +416,7 @@ export function SettingsModal({
{activeTab === 'serial' && 'View serial port connection and configure contact sync'}
{activeTab === 'database' && 'View database statistics and clean up old packets'}
{activeTab === 'advertise' && 'Send a flood advertisement to announce your presence'}
{activeTab === 'bot' && 'Configure automatic message bot with Python code'}
</DialogDescription>
</DialogHeader>
@@ -377,12 +428,13 @@ export function SettingsModal({
onValueChange={(v) => setActiveTab(v as SettingsTab)}
className="w-full"
>
<TabsList className="grid w-full grid-cols-5">
<TabsList className="grid w-full grid-cols-6">
<TabsTrigger value="radio">Radio</TabsTrigger>
<TabsTrigger value="identity">Identity</TabsTrigger>
<TabsTrigger value="serial">Serial</TabsTrigger>
<TabsTrigger value="database">Database</TabsTrigger>
<TabsTrigger value="advertise">Advertise</TabsTrigger>
<TabsTrigger value="bot">Bot</TabsTrigger>
</TabsList>
{/* Radio Config Tab */}
@@ -747,6 +799,87 @@ export function SettingsModal({
)}
</div>
</TabsContent>
{/* Bot Tab */}
<TabsContent value="bot" className="space-y-4 mt-4">
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-md">
<p className="text-sm text-yellow-500">
<strong>Security Warning:</strong> This feature executes arbitrary Python code on
the server. Only enable if you understand the security implications.
</p>
</div>
<div className="space-y-3">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={botEnabled}
onChange={(e) => setBotEnabled(e.target.checked)}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm font-medium">Enable Message Bot</span>
</label>
</div>
<Separator />
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="bot-code">Bot Code (Python)</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setBotCode(DEFAULT_BOT_CODE)}
disabled={!botEnabled}
>
Reset to Example
</Button>
</div>
<p className="text-xs text-muted-foreground">
Define a <code className="bg-muted px-1 rounded">bot()</code> function that
receives message data and optionally returns a reply string.
</p>
<div
className={`rounded-md border border-input bg-[#2d2d2d] overflow-auto h-64 ${!botEnabled ? 'opacity-50 pointer-events-none' : ''}`}
>
<Editor
value={botCode}
onValueChange={setBotCode}
highlight={(code) => highlight(code, languages.python, 'python')}
padding={12}
style={{
fontFamily:
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
fontSize: 13,
minHeight: '100%',
}}
textareaId="bot-code"
disabled={!botEnabled}
/>
</div>
</div>
<div className="text-xs text-muted-foreground space-y-1">
<p>
<strong>Available:</strong> Standard Python libraries and any modules installed in
the server environment.
</p>
<p>
<strong>Limits:</strong> 10 second timeout, max 3 concurrent executions.
</p>
<p>
<strong>Note:</strong> Bot only responds to incoming messages, not your own. For
channel messages, <code>sender_key</code> is <code>None</code>.
</p>
</div>
{error && <div className="text-sm text-destructive">{error}</div>}
<Button onClick={handleSaveBotSettings} disabled={loading} className="w-full">
{loading ? 'Saving...' : 'Save Bot Settings'}
</Button>
</TabsContent>
</Tabs>
)}
</DialogContent>
+4
View File
@@ -134,6 +134,8 @@ export interface AppSettings {
last_message_times: Record<string, number>;
preferences_migrated: boolean;
advert_interval: number;
bot_enabled: boolean;
bot_code: string;
}
export interface AppSettingsUpdate {
@@ -141,6 +143,8 @@ export interface AppSettingsUpdate {
auto_decrypt_dm_on_advert?: boolean;
sidebar_sort_order?: 'recent' | 'alpha';
advert_interval?: number;
bot_enabled?: boolean;
bot_code?: string;
}
export interface MigratePreferencesRequest {
+383
View File
@@ -0,0 +1,383 @@
"""Tests for the bot execution module."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.bot import (
_bot_semaphore,
execute_bot_code,
run_bot_for_message,
)
class TestExecuteBotCode:
"""Test bot code execution."""
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):
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,
)
assert result == "Hello, Alice!"
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):
return None
"""
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_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):
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_code_with_syntax_error(self):
"""Bot code with syntax error returns None."""
code = """
def bot(sender_name:
return "broken"
"""
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_code_without_bot_function(self):
"""Code that doesn't define 'bot' function returns None."""
code = """
def my_function():
return "hello"
"""
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_not_callable(self):
"""Code where 'bot' is not callable returns None."""
code = """
bot = "I'm a string, not a function"
"""
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_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):
raise ValueError("oops!")
"""
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_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):
return 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,
)
assert result is None
def test_empty_code_returns_none(self):
"""Empty bot code returns None."""
result = execute_bot_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_whitespace_only_code_returns_none(self):
"""Whitespace-only bot code returns None."""
result = execute_bot_code(
code=" \n\t ",
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_receives_all_parameters(self):
"""Bot function receives all expected parameters."""
code = """
def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path):
# Verify all params are accessible
parts = [
f"name={sender_name}",
f"key={sender_key}",
f"msg={message_text}",
f"dm={is_dm}",
f"ch_key={channel_key}",
f"ch_name={channel_name}",
f"ts={sender_timestamp}",
f"path={path}",
]
return "|".join(parts)
"""
result = execute_bot_code(
code=code,
sender_name="Bob",
sender_key="def456",
message_text="Test",
is_dm=False,
channel_key="AABBCCDD",
channel_name="#test",
sender_timestamp=12345,
path="001122",
)
assert (
result
== "name=Bob|key=def456|msg=Test|dm=False|ch_key=AABBCCDD|ch_name=#test|ts=12345|path=001122"
)
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):
if sender_key is None and not is_dm:
return "channel message detected"
return "unexpected"
"""
result = execute_bot_code(
code=code,
sender_name="Someone",
sender_key=None, # Channel messages don't have sender key
message_text="Test",
is_dm=False,
channel_key="AABBCCDD",
channel_name="#general",
sender_timestamp=None,
path=None,
)
assert result == "channel message detected"
class TestRunBotForMessage:
"""Test the main bot entry point."""
@pytest.fixture(autouse=True)
def reset_semaphore(self):
"""Reset semaphore state between tests."""
# Ensure semaphore is fully released
while _bot_semaphore.locked():
_bot_semaphore.release()
yield
@pytest.mark.asyncio
async def test_skips_outgoing_messages(self):
"""Bot is not triggered for outgoing messages."""
with patch("app.repository.AppSettingsRepository") as mock_repo:
await run_bot_for_message(
sender_name="Me",
sender_key="abc123",
message_text="Hello",
is_dm=True,
channel_key=None,
is_outgoing=True,
)
# Should not even check settings
mock_repo.get.assert_not_called()
@pytest.mark.asyncio
async def test_skips_when_bot_disabled(self):
"""Bot is not triggered when disabled in settings."""
with patch("app.repository.AppSettingsRepository") as mock_repo:
mock_settings = MagicMock()
mock_settings.bot_enabled = False
mock_settings.bot_code = "def bot(): pass"
mock_repo.get = AsyncMock(return_value=mock_settings)
with patch("app.bot.execute_bot_code") as mock_exec:
await run_bot_for_message(
sender_name="Alice",
sender_key="abc123",
message_text="Hello",
is_dm=True,
channel_key=None,
)
mock_exec.assert_not_called()
@pytest.mark.asyncio
async def test_skips_when_bot_code_empty(self):
"""Bot is not triggered when code is empty."""
with patch("app.repository.AppSettingsRepository") as mock_repo:
mock_settings = MagicMock()
mock_settings.bot_enabled = True
mock_settings.bot_code = ""
mock_repo.get = AsyncMock(return_value=mock_settings)
with patch("app.bot.execute_bot_code") as mock_exec:
await run_bot_for_message(
sender_name="Alice",
sender_key="abc123",
message_text="Hello",
is_dm=True,
channel_key=None,
)
mock_exec.assert_not_called()
@pytest.mark.asyncio
async def test_rechecks_settings_after_sleep(self):
"""Settings are re-checked after 2 second sleep."""
with patch("app.repository.AppSettingsRepository") as mock_repo:
# First call: bot enabled
# Second call (after sleep): bot disabled
mock_settings_enabled = MagicMock()
mock_settings_enabled.bot_enabled = True
mock_settings_enabled.bot_code = "def bot(): return 'hi'"
mock_settings_disabled = MagicMock()
mock_settings_disabled.bot_enabled = False
mock_settings_disabled.bot_code = "def bot(): return 'hi'"
mock_repo.get = AsyncMock(side_effect=[mock_settings_enabled, mock_settings_disabled])
with (
patch("app.bot.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
patch("app.bot.execute_bot_code") as mock_exec,
):
await run_bot_for_message(
sender_name="Alice",
sender_key="abc123",
message_text="Hello",
is_dm=True,
channel_key=None,
)
# Should have slept
mock_sleep.assert_called_once_with(2)
# Should NOT have executed bot (disabled after sleep)
mock_exec.assert_not_called()
class TestBotCodeValidation:
"""Test bot code syntax validation on save."""
def test_valid_code_passes(self):
"""Valid Python code passes validation."""
from app.routers.settings import validate_bot_code
# Should not raise
validate_bot_code("def bot(): return 'hello'")
def test_syntax_error_raises(self):
"""Syntax error in code raises HTTPException."""
from fastapi import HTTPException
from app.routers.settings import validate_bot_code
with pytest.raises(HTTPException) as exc_info:
validate_bot_code("def bot(:\n return 'broken'")
assert exc_info.value.status_code == 400
assert "syntax error" in exc_info.value.detail.lower()
def test_empty_code_passes(self):
"""Empty code passes validation (disables bot)."""
from app.routers.settings import validate_bot_code
# Should not raise
validate_bot_code("")
validate_bot_code(" ")
+3
View File
@@ -192,6 +192,7 @@ class TestContactMessageCLIFiltering:
patch("app.event_handlers.MessageRepository") as mock_repo,
patch("app.event_handlers.ContactRepository") as mock_contact_repo,
patch("app.event_handlers.broadcast_event") as mock_broadcast,
patch("app.bot.run_bot_for_message", new_callable=AsyncMock),
):
mock_repo.create = AsyncMock(return_value=42)
mock_contact_repo.get_by_key_prefix = AsyncMock(return_value=None)
@@ -220,6 +221,7 @@ class TestContactMessageCLIFiltering:
patch("app.event_handlers.MessageRepository") as mock_repo,
patch("app.event_handlers.ContactRepository") as mock_contact_repo,
patch("app.event_handlers.broadcast_event") as mock_broadcast,
patch("app.bot.run_bot_for_message", new_callable=AsyncMock),
):
mock_repo.create = AsyncMock(return_value=42)
mock_contact_repo.get_by_key_prefix = AsyncMock(return_value=None)
@@ -254,6 +256,7 @@ class TestContactMessageCLIFiltering:
patch("app.event_handlers.MessageRepository") as mock_repo,
patch("app.event_handlers.ContactRepository") as mock_contact_repo,
patch("app.event_handlers.broadcast_event"),
patch("app.bot.run_bot_for_message", new_callable=AsyncMock),
):
mock_repo.create = AsyncMock(return_value=42)
mock_contact_repo.get_by_key_prefix = AsyncMock(return_value=None)
+6 -6
View File
@@ -100,8 +100,8 @@ class TestMigration001:
# Run migrations
applied = await run_migrations(conn)
assert applied == 11 # All 11 migrations run
assert await get_version(conn) == 11
assert applied == 12 # All 11 migrations run
assert await get_version(conn) == 12
# Verify columns exist by inserting and selecting
await conn.execute(
@@ -183,9 +183,9 @@ class TestMigration001:
applied1 = await run_migrations(conn)
applied2 = await run_migrations(conn)
assert applied1 == 11 # All 11 migrations run
assert applied1 == 12 # All 11 migrations run
assert applied2 == 0 # No migrations on second run
assert await get_version(conn) == 11
assert await get_version(conn) == 12
finally:
await conn.close()
@@ -246,8 +246,8 @@ class TestMigration001:
applied = await run_migrations(conn)
# All 11 migrations applied (version incremented) but no error
assert applied == 11
assert await get_version(conn) == 11
assert applied == 12
assert await get_version(conn) == 12
finally:
await conn.close()