Add basic bot functionality

This commit is contained in:
Jack Kingsman
2026-01-26 21:44:11 -08:00
parent bbf6c63a95
commit ba92aa2342
23 changed files with 1577 additions and 565 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.
+7 -4
View File
@@ -66,10 +66,13 @@ async def lifespan(app: FastAPI):
# Start periodic sync
start_periodic_sync()
# Send advertisement to announce our presence
logger.info("Sending startup advertisement...")
advert_result = await radio_manager.meshcore.commands.send_advert(flood=True)
logger.info("Advertisement sent: %s", advert_result.type)
# Send advertisement to announce our presence (if enabled and not throttled)
from app.radio_sync import send_advertisement
if await send_advertisement():
logger.info("Startup advertisement sent")
else:
logger.debug("Startup advertisement skipped (disabled or throttled)")
# Start periodic advertisement (every hour)
start_periodic_advert()
+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)