mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 09:13:04 +02:00
Add basic bot functionality
This commit is contained in:
+248
@@ -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)
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user