Add multibot functionality

This commit is contained in:
Jack Kingsman
2026-01-27 17:23:38 -08:00
parent c46128e2cd
commit 530179fde1
18 changed files with 1599 additions and 816 deletions
+45 -44
View File
@@ -21,10 +21,10 @@ from fastapi import HTTPException
logger = logging.getLogger(__name__)
# Limit concurrent bot executions to prevent resource exhaustion
_bot_semaphore = asyncio.Semaphore(3)
_bot_semaphore = asyncio.Semaphore(100)
# Dedicated thread pool for bot execution (separate from default executor)
_bot_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="bot_")
_bot_executor = ThreadPoolExecutor(max_workers=100, thread_name_prefix="bot_")
# Timeout for bot code execution (seconds)
BOT_EXECUTION_TIMEOUT = 10
@@ -225,10 +225,11 @@ async def run_bot_for_message(
is_outgoing: bool = False,
) -> None:
"""
Run the bot for an incoming message if enabled.
Run all enabled bots for an incoming message.
This is the main entry point called by message handlers after
a message is successfully decrypted and stored.
a message is successfully decrypted and stored. Bots run serially,
and errors in one bot don't prevent others from running.
Args:
sender_name: Display name of the sender
@@ -245,23 +246,18 @@ async def run_bot_for_message(
if is_outgoing:
return
# Early check if bot is enabled (will re-check after sleep)
# Early check if any bots are 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")
enabled_bots = [b for b in settings.bots if b.enabled and b.code.strip()]
if not enabled_bots:
return
async with _bot_semaphore:
logger.debug(
"Running bot for message from %s (is_dm=%s)",
"Running %d bot(s) for message from %s (is_dm=%s)",
len(enabled_bots),
sender_name or (sender_key[:12] if sender_key else "unknown"),
is_dm,
)
@@ -269,38 +265,43 @@ async def run_bot_for_message(
# 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)
# Re-check settings after sleep (user may have changed bot config)
settings = await AppSettingsRepository.get()
if not settings.bot_enabled or not settings.bot_code:
logger.debug("Bot disabled during wait, skipping")
enabled_bots = [b for b in settings.bots if b.enabled and b.code.strip()]
if not enabled_bots:
logger.debug("All bots disabled during wait, skipping")
return
# Execute bot code in a dedicated thread pool with timeout
# Run each enabled bot serially
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
for bot in enabled_bots:
logger.debug("Executing bot '%s'", bot.name)
try:
response = await asyncio.wait_for(
loop.run_in_executor(
_bot_executor,
execute_bot_code,
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 '%s' execution timed out after %ds", bot.name, BOT_EXECUTION_TIMEOUT
)
continue # Continue to next bot
except Exception as e:
logger.warning("Bot '%s' execution error: %s", bot.name, e)
continue # Continue to next bot
# Send response if any
if response:
await process_bot_response(response, is_dm, sender_key or "", channel_key)
# Send response if any
if response:
await process_bot_response(response, is_dm, sender_key or "", channel_key)
+78
View File
@@ -121,6 +121,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 12)
applied += 1
# Migration 13: Convert bot_enabled/bot_code to bots JSON array
if version < 13:
logger.info("Applying migration 13: convert to multi-bot format")
await _migrate_013_convert_to_multi_bot(conn)
await set_version(conn, 13)
applied += 1
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -715,3 +722,74 @@ async def _migrate_012_add_bot_settings(conn: aiosqlite.Connection) -> None:
raise
await conn.commit()
async def _migrate_013_convert_to_multi_bot(conn: aiosqlite.Connection) -> None:
"""
Convert single bot_enabled/bot_code to multi-bot format.
Adds a 'bots' TEXT column storing a JSON array of bot configs:
[{"id": "uuid", "name": "Bot 1", "enabled": true, "code": "..."}]
If existing bot_code is non-empty OR bot_enabled is true, migrates
to a single bot named "Bot 1". Otherwise, creates empty array.
Attempts to drop the old bot_enabled and bot_code columns.
"""
import json
import uuid
# Add new bots column
try:
await conn.execute("ALTER TABLE app_settings ADD COLUMN bots TEXT DEFAULT '[]'")
logger.debug("Added bots column to app_settings")
except aiosqlite.OperationalError as e:
if "duplicate column" in str(e).lower():
logger.debug("bots column already exists, skipping")
else:
raise
# Migrate existing bot data
cursor = await conn.execute("SELECT bot_enabled, bot_code FROM app_settings WHERE id = 1")
row = await cursor.fetchone()
if row:
bot_enabled = bool(row[0]) if row[0] is not None else False
bot_code = row[1] or ""
# If there's existing bot data, migrate it
if bot_code.strip() or bot_enabled:
bots = [
{
"id": str(uuid.uuid4()),
"name": "Bot 1",
"enabled": bot_enabled,
"code": bot_code,
}
]
bots_json = json.dumps(bots)
logger.info("Migrating existing bot to multi-bot format: enabled=%s", bot_enabled)
else:
bots_json = "[]"
await conn.execute(
"UPDATE app_settings SET bots = ? WHERE id = 1",
(bots_json,),
)
# Try to drop old columns (SQLite 3.35.0+ only)
for column in ["bot_enabled", "bot_code"]:
try:
await conn.execute(f"ALTER TABLE app_settings DROP COLUMN {column}")
logger.debug("Dropped %s column from app_settings", column)
except aiosqlite.OperationalError as e:
error_msg = str(e).lower()
if "no such column" in error_msg:
logger.debug("app_settings.%s already dropped, skipping", column)
elif "syntax error" in error_msg or "drop column" in error_msg:
# SQLite version doesn't support DROP COLUMN - harmless, column stays
logger.debug("SQLite doesn't support DROP COLUMN, %s column will remain", column)
else:
raise
await conn.commit()
+12 -7
View File
@@ -232,6 +232,15 @@ class Favorite(BaseModel):
id: str = Field(description="Channel key or contact public key")
class BotConfig(BaseModel):
"""Configuration for a single bot."""
id: str = Field(description="UUID for stable identity across renames/reorders")
name: str = Field(description="User-editable name")
enabled: bool = Field(default=False, description="Whether this bot is enabled")
code: str = Field(default="", description="Python code for this bot")
class AppSettings(BaseModel):
"""Application settings stored in the database."""
@@ -266,11 +275,7 @@ 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",
bots: list[BotConfig] = Field(
default_factory=list,
description="List of bot configurations",
)
+31 -13
View File
@@ -7,7 +7,16 @@ from typing import Any, Literal
from app.database import db
from app.decoder import PayloadType, extract_payload, get_packet_payload_type
from app.models import AppSettings, Channel, Contact, Favorite, Message, MessagePath, RawPacket
from app.models import (
AppSettings,
BotConfig,
Channel,
Contact,
Favorite,
Message,
MessagePath,
RawPacket,
)
logger = logging.getLogger(__name__)
@@ -682,7 +691,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, bot_enabled, bot_code
advert_interval, last_advert_time, bots
FROM app_settings WHERE id = 1
"""
)
@@ -718,6 +727,20 @@ class AppSettingsRepository:
)
last_message_times = {}
# Parse bots JSON
bots: list[BotConfig] = []
if row["bots"]:
try:
bots_data = json.loads(row["bots"])
bots = [BotConfig(**b) for b in bots_data]
except (json.JSONDecodeError, TypeError, KeyError) as e:
logger.warning(
"Failed to parse bots JSON, using empty list: %s (data=%r)",
e,
row["bots"][:100] if row["bots"] else None,
)
bots = []
# Validate sidebar_sort_order (fallback to "recent" if invalid)
sort_order = row["sidebar_sort_order"]
if sort_order not in ("recent", "alpha"):
@@ -732,8 +755,7 @@ 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 "",
bots=bots,
)
@staticmethod
@@ -746,8 +768,7 @@ 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,
bots: list[BotConfig] | None = None,
) -> AppSettings:
"""Update app settings. Only provided fields are updated."""
updates = []
@@ -786,13 +807,10 @@ 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 bots is not None:
updates.append("bots = ?")
bots_json = json.dumps([b.model_dump() for b in bots])
params.append(bots_json)
if updates:
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
+16 -17
View File
@@ -4,14 +4,14 @@ from typing import Literal
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app.models import AppSettings
from app.models import AppSettings, BotConfig
from app.repository import AppSettingsRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/settings", tags=["settings"])
def validate_bot_code(code: str) -> None:
def validate_bot_code(code: str, bot_name: str | None = None) -> None:
"""Validate bot code syntax. Raises HTTPException on error."""
if not code or not code.strip():
return # Empty code is valid (disables bot)
@@ -19,12 +19,19 @@ def validate_bot_code(code: str) -> None:
try:
compile(code, "<bot_code>", "exec")
except SyntaxError as e:
name_part = f"'{bot_name}' " if bot_name else ""
raise HTTPException(
status_code=400,
detail=f"Bot code syntax error at line {e.lineno}: {e.msg}",
detail=f"Bot {name_part}has syntax error at line {e.lineno}: {e.msg}",
) from None
def validate_all_bots(bots: list[BotConfig]) -> None:
"""Validate all bots' code syntax. Raises HTTPException on first error."""
for bot in bots:
validate_bot_code(bot.code, bot.name)
class AppSettingsUpdate(BaseModel):
max_radio_contacts: int | None = Field(
default=None,
@@ -45,13 +52,9 @@ class AppSettingsUpdate(BaseModel):
ge=0,
description="Periodic advertisement interval in seconds (0 = disabled)",
)
bot_enabled: bool | None = Field(
bots: list[BotConfig] | 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",
description="List of bot configurations",
)
@@ -116,14 +119,10 @@ 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 update.bots is not None:
validate_all_bots(update.bots)
logger.info("Updating bots (count=%d)", len(update.bots))
kwargs["bots"] = update.bots
if kwargs:
return await AppSettingsRepository.update(**kwargs)