Move bots into Fanout & Forwarding

This commit is contained in:
Jack Kingsman
2026-03-05 18:14:03 -08:00
parent 7cd54d14d8
commit 5ecb63fde9
27 changed files with 671 additions and 908 deletions
-18
View File
@@ -1,4 +1,3 @@
import asyncio
import logging
import time
from typing import TYPE_CHECKING
@@ -155,23 +154,6 @@ async def on_contact_message(event: "Event") -> None:
if contact:
await ContactRepository.update_last_contacted(sender_pubkey, received_at)
# Run bot if enabled
from app.bot import run_bot_for_message
asyncio.create_task(
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.
+120
View File
@@ -0,0 +1,120 @@
"""Fanout module wrapping bot execution logic."""
from __future__ import annotations
import asyncio
import logging
from app.fanout.base import FanoutModule
logger = logging.getLogger(__name__)
class BotModule(FanoutModule):
"""Wraps a single bot's code execution and response routing.
Each BotModule represents one bot configuration. It receives decoded
messages via ``on_message``, executes the bot's Python code in a
background task (after a 2-second settle delay), and sends any response
back through the radio.
"""
def __init__(self, config_id: str, config: dict, *, name: str = "Bot") -> None:
super().__init__(config_id, config)
self._name = name
async def on_message(self, data: dict) -> None:
"""Kick off bot execution in a background task so we don't block dispatch."""
asyncio.create_task(self._run_for_message(data))
async def _run_for_message(self, data: dict) -> None:
from app.bot import BOT_EXECUTION_TIMEOUT, execute_bot_code, process_bot_response
code = self.config.get("code", "")
if not code or not code.strip():
return
msg_type = data.get("type", "")
is_dm = msg_type == "PRIV"
# Extract bot parameters from broadcast data
if is_dm:
conversation_key = data.get("conversation_key", "")
sender_key = conversation_key
is_outgoing = data.get("outgoing", False)
message_text = data.get("text", "")
channel_key = None
channel_name = None
# Look up sender name from contacts
from app.repository import ContactRepository
contact = await ContactRepository.get_by_key(conversation_key)
sender_name = contact.name if contact else None
else:
conversation_key = data.get("conversation_key", "")
sender_key = None
is_outgoing = False
sender_name = data.get("sender_name")
channel_key = conversation_key
# Look up channel name
from app.repository import ChannelRepository
channel = await ChannelRepository.get_by_key(conversation_key)
channel_name = channel.name if channel else None
# Strip "sender: " prefix from channel message text
text = data.get("text", "")
if sender_name and text.startswith(f"{sender_name}: "):
message_text = text[len(f"{sender_name}: ") :]
else:
message_text = text
sender_timestamp = data.get("sender_timestamp")
path_value = data.get("path")
# Message model serializes paths as list of dicts; extract first path string
if path_value is None:
paths = data.get("paths")
if paths and isinstance(paths, list) and len(paths) > 0:
path_value = paths[0].get("path") if isinstance(paths[0], dict) else None
# Wait for message to settle (allows retransmissions to be deduped)
await asyncio.sleep(2)
# Execute bot code in thread pool with timeout
from app.bot import _bot_executor, _bot_semaphore
async with _bot_semaphore:
loop = asyncio.get_event_loop()
try:
response = await asyncio.wait_for(
loop.run_in_executor(
_bot_executor,
execute_bot_code,
code,
sender_name,
sender_key,
message_text,
is_dm,
channel_key,
channel_name,
sender_timestamp,
path_value,
is_outgoing,
),
timeout=BOT_EXECUTION_TIMEOUT,
)
except asyncio.TimeoutError:
logger.warning("Bot '%s' execution timed out", self._name)
return
except Exception as e:
logger.warning("Bot '%s' execution error: %s", self._name, e)
return
if response:
await process_bot_response(response, is_dm, sender_key or "", channel_key)
@property
def status(self) -> str:
return "connected"
+14 -1
View File
@@ -17,11 +17,13 @@ def _register_module_types() -> None:
"""Lazily populate the type registry to avoid circular imports."""
if _MODULE_TYPES:
return
from app.fanout.bot import BotModule
from app.fanout.mqtt_community import MqttCommunityModule
from app.fanout.mqtt_private import MqttPrivateModule
_MODULE_TYPES["mqtt_private"] = MqttPrivateModule
_MODULE_TYPES["mqtt_community"] = MqttCommunityModule
_MODULE_TYPES["bot"] = BotModule
def _scope_matches_message(scope: dict, data: dict) -> bool:
@@ -80,13 +82,24 @@ class FanoutManager:
config_blob = cfg["config"]
scope = cfg["scope"]
# Skip bot modules when bots are disabled server-wide
if config_type == "bot":
from app.config import settings as server_settings
if server_settings.disable_bots:
logger.info("Skipping bot module %s (bots disabled by server config)", config_id)
return
cls = _MODULE_TYPES.get(config_type)
if cls is None:
logger.warning("Unknown fanout type %r for config %s, skipping", config_type, config_id)
return
try:
module = cls(config_id, config_blob)
if config_type == "bot":
module = cls(config_id, config_blob, name=cfg.get("name", "Bot"))
else:
module = cls(config_id, config_blob)
await module.start()
self._modules[config_id] = (module, scope)
logger.info(
+65
View File
@@ -289,6 +289,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 36)
applied += 1
# Migration 37: Migrate bots from app_settings to fanout_configs
if version < 37:
logger.info("Applying migration 37: migrate bots to fanout_configs")
await _migrate_037_bots_to_fanout(conn)
await set_version(conn, 37)
applied += 1
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -2149,3 +2156,61 @@ async def _migrate_036_create_fanout_configs(conn: aiosqlite.Connection) -> None
logger.info("Migrated community MQTT settings to fanout_configs")
await conn.commit()
async def _migrate_037_bots_to_fanout(conn: aiosqlite.Connection) -> None:
"""Migrate bots from app_settings.bots JSON to fanout_configs rows."""
import json
import uuid
try:
cursor = await conn.execute("SELECT bots FROM app_settings WHERE id = 1")
row = await cursor.fetchone()
except Exception:
row = None
if row is None:
await conn.commit()
return
bots_json = row["bots"] or "[]"
try:
bots = json.loads(bots_json)
except (json.JSONDecodeError, TypeError):
bots = []
if not bots:
await conn.commit()
return
import time
now = int(time.time())
# Use sort_order starting at 200 to place bots after MQTT configs (0-99)
for i, bot in enumerate(bots):
bot_name = bot.get("name") or f"Bot {i + 1}"
bot_enabled = bool(bot.get("enabled", False))
bot_code = bot.get("code", "")
config_blob = json.dumps({"code": bot_code})
scope = json.dumps({"messages": "all", "raw_packets": "none"})
await conn.execute(
"""
INSERT INTO fanout_configs (id, type, name, enabled, config, scope, sort_order, created_at)
VALUES (?, 'bot', ?, ?, ?, ?, ?, ?)
""",
(
str(uuid.uuid4()),
bot_name,
1 if bot_enabled else 0,
config_blob,
scope,
200 + i,
now,
),
)
logger.info("Migrated bot '%s' to fanout_configs (enabled=%s)", bot_name, bot_enabled)
await conn.commit()
+2 -38
View File
@@ -195,7 +195,7 @@ async def create_message_from_decrypted(
# Use "is not None" to include empty string (direct/0-hop messages)
paths = [MessagePath(path=path or "", received_at=received)] if path is not None else None
# Broadcast new message to connected clients
# Broadcast new message to connected clients (and fanout modules when realtime)
broadcast_event(
"message",
Message(
@@ -212,24 +212,6 @@ async def create_message_from_decrypted(
realtime=trigger_bot,
)
# Run bot if enabled (for incoming channel messages, not historical decryption)
if trigger_bot:
from app.bot import run_bot_for_message
asyncio.create_task(
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
@@ -318,7 +300,7 @@ async def create_dm_message_from_decrypted(
# Build paths array for broadcast
paths = [MessagePath(path=path or "", received_at=received)] if path is not None else None
# Broadcast new message to connected clients
# Broadcast new message to connected clients (and fanout modules when realtime)
broadcast_event(
"message",
Message(
@@ -339,24 +321,6 @@ 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 all real-time DMs, including our own outgoing messages)
if trigger_bot:
from app.bot import run_bot_for_message
asyncio.create_task(
run_bot_for_message(
sender_name=contact.name if contact else None,
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=outgoing,
)
)
return msg_id
+25 -3
View File
@@ -6,13 +6,13 @@ import re
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app.config import settings as server_settings
from app.repository.fanout import FanoutConfigRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/fanout", tags=["fanout"])
# Valid types in Phase 1
_VALID_TYPES = {"mqtt_private", "mqtt_community"}
_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot"}
_IATA_RE = re.compile(r"^[A-Z]{3}$")
@@ -51,11 +51,26 @@ def _validate_mqtt_community_config(config: dict) -> None:
)
def _validate_bot_config(config: dict) -> None:
"""Validate bot config blob (syntax-check the code)."""
code = config.get("code", "")
if not code or not code.strip():
raise HTTPException(status_code=400, detail="Bot code cannot be empty")
try:
compile(code, "<bot_code>", "exec")
except SyntaxError as e:
raise HTTPException(
status_code=400,
detail=f"Bot code has syntax error at line {e.lineno}: {e.msg}",
) from None
def _enforce_scope(config_type: str, scope: dict) -> dict:
"""Enforce type-specific scope constraints. Returns normalized scope."""
if config_type == "mqtt_community":
# Community MQTT always: no messages, all raw packets
return {"messages": "none", "raw_packets": "all"}
if config_type == "bot":
return {"messages": "all", "raw_packets": "none"}
# For mqtt_private, validate scope values
messages = scope.get("messages", "all")
if messages not in ("all", "none") and not isinstance(messages, dict):
@@ -81,6 +96,9 @@ async def create_fanout_config(body: FanoutConfigCreate) -> dict:
detail=f"Invalid type '{body.type}'. Must be one of: {', '.join(sorted(_VALID_TYPES))}",
)
if body.type == "bot" and server_settings.disable_bots:
raise HTTPException(status_code=403, detail="Bot system disabled by server configuration")
# Only validate config when creating as enabled — disabled configs
# are drafts the user hasn't finished configuring yet.
if body.enabled:
@@ -88,6 +106,8 @@ async def create_fanout_config(body: FanoutConfigCreate) -> dict:
_validate_mqtt_private_config(body.config)
elif body.type == "mqtt_community":
_validate_mqtt_community_config(body.config)
elif body.type == "bot":
_validate_bot_config(body.config)
scope = _enforce_scope(body.type, body.scope)
@@ -134,6 +154,8 @@ async def update_fanout_config(config_id: str, body: FanoutConfigUpdate) -> dict
_validate_mqtt_private_config(config_to_validate)
elif existing["type"] == "mqtt_community":
_validate_mqtt_community_config(config_to_validate)
elif existing["type"] == "bot":
_validate_bot_config(config_to_validate)
updated = await FanoutConfigRepository.update(config_id, **kwargs)
if updated is None:
+1 -35
View File
@@ -1,4 +1,3 @@
import asyncio
import logging
import time
@@ -176,25 +175,9 @@ async def send_direct_message(request: SendDirectMessageRequest) -> Message:
)
# Broadcast so all connected clients (not just sender) see the outgoing message immediately.
# Fanout modules (including bots) are triggered via broadcast_event's realtime dispatch.
broadcast_event("message", message.model_dump())
# Trigger bots for outgoing DMs (runs in background, doesn't block response)
from app.bot import run_bot_for_message
asyncio.create_task(
run_bot_for_message(
sender_name=None,
sender_key=db_contact.public_key.lower(),
message_text=request.text,
is_dm=True,
channel_key=None,
channel_name=None,
sender_timestamp=now,
path=None,
is_outgoing=True,
)
)
return message
@@ -335,23 +318,6 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
sender_key=our_public_key,
)
# Trigger bots for outgoing channel messages (runs in background, doesn't block response)
from app.bot import run_bot_for_message
asyncio.create_task(
run_bot_for_message(
sender_name=radio_name or None,
sender_key=None,
message_text=request.text,
is_dm=False,
channel_key=channel_key_upper,
channel_name=db_channel.name,
sender_timestamp=now,
path=None,
is_outgoing=True,
)
)
return message
+2 -37
View File
@@ -2,38 +2,16 @@ import asyncio
import logging
from typing import Literal
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter
from pydantic import BaseModel, Field
from app.config import settings as server_settings
from app.models import AppSettings, BotConfig
from app.models import AppSettings
from app.repository import AppSettingsRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/settings", tags=["settings"])
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)
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 {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,
@@ -56,10 +34,6 @@ class AppSettingsUpdate(BaseModel):
ge=0,
description="Periodic advertisement interval in seconds (0 = disabled, minimum 3600)",
)
bots: list[BotConfig] | None = Field(
default=None,
description="List of bot configurations",
)
flood_scope: str | None = Field(
default=None,
description="Outbound flood scope / region name (empty = disabled)",
@@ -140,15 +114,6 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
logger.info("Updating advert_interval to %d", interval)
kwargs["advert_interval"] = interval
if update.bots is not None:
if server_settings.disable_bots:
raise HTTPException(
status_code=403, detail="Bot system disabled by server configuration"
)
validate_all_bots(update.bots)
logger.info("Updating bots (count=%d)", len(update.bots))
kwargs["bots"] = update.bots
# Block lists
if update.blocked_keys is not None:
kwargs["blocked_keys"] = [k.lower() for k in update.blocked_keys]
+1 -2
View File
@@ -84,9 +84,8 @@ frontend/src/
│ │ ├── settingsConstants.ts # Settings section type, ordering, labels
│ │ ├── SettingsRadioSection.tsx # Name, keys, advert interval, max contacts, radio preset, freq/bw/sf/cr, txPower, lat/lon, reboot
│ │ ├── SettingsLocalSection.tsx # Browser-local settings: theme, local label, reopen last conversation
│ │ ├── SettingsMqttSection.tsx # MQTT broker config, TLS, publish toggles
│ │ ├── SettingsFanoutSection.tsx # Fanout integrations: MQTT, bots, config CRUD
│ │ ├── SettingsDatabaseSection.tsx # DB size, cleanup, auto-decrypt, local label
│ │ ├── SettingsBotSection.tsx # Bot list, code editor, add/delete/reset
│ │ ├── SettingsStatisticsSection.tsx # Read-only mesh network stats
│ │ ├── SettingsAboutSection.tsx # Version, author, license, links
│ │ └── ThemeSelector.tsx # Color theme picker
+5 -18
View File
@@ -13,7 +13,6 @@ import { SettingsRadioSection } from './settings/SettingsRadioSection';
import { SettingsLocalSection } from './settings/SettingsLocalSection';
import { SettingsFanoutSection } from './settings/SettingsFanoutSection';
import { SettingsDatabaseSection } from './settings/SettingsDatabaseSection';
import { SettingsBotSection } from './settings/SettingsBotSection';
import { SettingsStatisticsSection } from './settings/SettingsStatisticsSection';
import { SettingsAboutSection } from './settings/SettingsAboutSection';
@@ -80,7 +79,6 @@ export function SettingsModal(props: SettingsModalProps) {
local: false,
fanout: false,
database: false,
bot: false,
statistics: false,
about: false,
});
@@ -217,26 +215,15 @@ export function SettingsModal(props: SettingsModalProps) {
</section>
)}
{shouldRenderSection('bot') && (
<section className={sectionWrapperClass}>
{renderSectionHeader('bot')}
{isSectionVisible('bot') && appSettings && (
<SettingsBotSection
appSettings={appSettings}
health={health}
isMobileLayout={isMobileLayout}
onSaveAppSettings={onSaveAppSettings}
className={sectionContentClass}
/>
)}
</section>
)}
{shouldRenderSection('fanout') && (
<section className={sectionWrapperClass}>
{renderSectionHeader('fanout')}
{isSectionVisible('fanout') && (
<SettingsFanoutSection health={health} className={sectionContentClass} />
<SettingsFanoutSection
health={health}
onHealthRefresh={onHealthRefresh}
className={sectionContentClass}
/>
)}
</section>
)}
@@ -1,335 +0,0 @@
import { useState, useEffect, lazy, Suspense } from 'react';
import { Label } from '../ui/label';
import { Button } from '../ui/button';
import { Separator } from '../ui/separator';
import { toast } from '../ui/sonner';
import type { AppSettings, AppSettingsUpdate, BotConfig, HealthStatus } from '../../types';
const BotCodeEditor = lazy(() =>
import('../BotCodeEditor').then((m) => ({ default: m.BotCodeEditor }))
);
const DEFAULT_BOT_CODE = `def bot(
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,
is_outgoing: bool = False,
) -> str | list[str] | None:
"""
Process messages and optionally return a reply.
Args:
sender_name: Display name of sender (may be None)
sender_key: 64-char hex public key (None 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)
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!"
return None`;
export function SettingsBotSection({
appSettings,
health,
isMobileLayout,
onSaveAppSettings,
className,
}: {
appSettings: AppSettings;
health: HealthStatus | null;
isMobileLayout: boolean;
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
className?: string;
}) {
const [bots, setBots] = useState<BotConfig[]>([]);
const [expandedBotId, setExpandedBotId] = useState<string | null>(null);
const [editingNameId, setEditingNameId] = useState<string | null>(null);
const [editingNameValue, setEditingNameValue] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setBots(appSettings.bots || []);
}, [appSettings]);
const handleSave = async () => {
setBusy(true);
setError(null);
try {
await onSaveAppSettings({ bots });
toast.success('Bot settings saved');
} catch (err) {
console.error('Failed to save bot settings:', err);
const errorMsg = err instanceof Error ? err.message : 'Failed to save';
setError(errorMsg);
toast.error(errorMsg);
} finally {
setBusy(false);
}
};
const handleAddBot = () => {
const newBot: BotConfig = {
id: crypto.randomUUID(),
name: `Bot ${bots.length + 1}`,
enabled: false,
code: DEFAULT_BOT_CODE,
};
setBots([...bots, newBot]);
setExpandedBotId(newBot.id);
};
const handleDeleteBot = (botId: string) => {
const bot = bots.find((b) => b.id === botId);
if (bot && bot.code.trim() && bot.code !== DEFAULT_BOT_CODE) {
if (!confirm(`Delete "${bot.name}"? This will remove all its code.`)) {
return;
}
}
setBots(bots.filter((b) => b.id !== botId));
if (expandedBotId === botId) {
setExpandedBotId(null);
}
};
const handleToggleBotEnabled = (botId: string) => {
setBots(bots.map((b) => (b.id === botId ? { ...b, enabled: !b.enabled } : b)));
};
const handleBotCodeChange = (botId: string, code: string) => {
setBots(bots.map((b) => (b.id === botId ? { ...b, code } : b)));
};
const handleStartEditingName = (bot: BotConfig) => {
setEditingNameId(bot.id);
setEditingNameValue(bot.name);
};
const handleFinishEditingName = () => {
if (editingNameId && editingNameValue.trim()) {
setBots(
bots.map((b) => (b.id === editingNameId ? { ...b, name: editingNameValue.trim() } : b))
);
}
setEditingNameId(null);
setEditingNameValue('');
};
const handleResetBotCode = (botId: string) => {
setBots(bots.map((b) => (b.id === botId ? { ...b, code: DEFAULT_BOT_CODE } : b)));
};
if (health?.bots_disabled) {
return (
<div className={className}>
<p className="text-sm text-muted-foreground">Bot system disabled by server startup flag.</p>
</div>
);
}
return (
<div className={className}>
<div className="p-3 bg-destructive/10 border border-destructive/30 rounded-md">
<p className="text-sm text-destructive">
<strong>Experimental:</strong> This is an alpha feature and introduces automated message
sending to your radio; unexpected behavior may occur. Use with caution, and please report
any bugs!
</p>
</div>
<div className="p-3 bg-warning/10 border border-warning/30 rounded-md">
<p className="text-sm text-warning">
<strong>Security Warning:</strong> This feature executes arbitrary Python code on the
server. Only run trusted code, and be cautious of arbitrary usage of message parameters.
</p>
</div>
<div className="p-3 bg-warning/10 border border-warning/30 rounded-md">
<p className="text-sm text-warning">
<strong>Don&apos;t wreck the mesh!</strong> Bots process ALL messages, including their
own. Be careful of creating infinite loops!
</p>
</div>
<div className="flex justify-between items-center">
<Label>Bots</Label>
<Button type="button" variant="outline" size="sm" onClick={handleAddBot}>
+ New Bot
</Button>
</div>
{bots.length === 0 ? (
<div className="text-center py-8 border border-dashed border-input rounded-md">
<p className="text-muted-foreground mb-4">No bots configured</p>
<Button type="button" variant="outline" onClick={handleAddBot}>
Create your first bot
</Button>
</div>
) : (
<div className="space-y-2">
{bots.map((bot) => (
<div key={bot.id} className="border border-input rounded-md overflow-hidden">
<button
type="button"
className="flex items-center gap-2 px-3 py-2 bg-muted/50 cursor-pointer hover:bg-muted/80 w-full text-left"
aria-expanded={expandedBotId === bot.id}
onClick={(e) => {
if ((e.target as HTMLElement).closest('input, [data-bot-control]')) return;
setExpandedBotId(expandedBotId === bot.id ? null : bot.id);
}}
>
<span className="text-muted-foreground" aria-hidden="true">
{expandedBotId === bot.id ? '▼' : '▶'}
</span>
{editingNameId === bot.id ? (
<input
type="text"
value={editingNameValue}
onChange={(e) => setEditingNameValue(e.target.value)}
onBlur={handleFinishEditingName}
onKeyDown={(e) => {
if (e.key === 'Enter') handleFinishEditingName();
if (e.key === 'Escape') {
setEditingNameId(null);
setEditingNameValue('');
}
}}
autoFocus
aria-label="Bot name"
className="px-2 py-0.5 text-sm bg-background border border-input rounded flex-1 max-w-[200px]"
onClick={(e) => e.stopPropagation()}
/>
) : (
<span
className="text-sm font-medium flex-1 hover:text-primary cursor-text text-left"
data-bot-control
onClick={(e) => {
e.stopPropagation();
handleStartEditingName(bot);
}}
title="Click to rename"
>
{bot.name}
</span>
)}
<label
className="flex items-center gap-1.5 cursor-pointer"
data-bot-control
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={bot.enabled}
onChange={() => handleToggleBotEnabled(bot.id)}
className="w-4 h-4 rounded border-input accent-primary"
aria-label={`Enable ${bot.name}`}
/>
<span className="text-xs text-muted-foreground">Enabled</span>
</label>
<Button
type="button"
variant="ghost"
size="sm"
data-bot-control
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDeleteBot(bot.id);
}}
title="Delete bot"
aria-label={`Delete ${bot.name}`}
>
<span aria-hidden="true">🗑</span>
</Button>
</button>
{expandedBotId === bot.id && (
<div className="p-3 space-y-3 border-t border-input">
<div className="flex items-center justify-between">
<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.
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleResetBotCode(bot.id)}
>
Reset to Example
</Button>
</div>
<Suspense
fallback={
<div className="h-64 md:h-96 rounded-md border border-input bg-code-editor-bg flex items-center justify-center text-muted-foreground">
Loading editor...
</div>
}
>
<BotCodeEditor
value={bot.code}
onChange={(code) => handleBotCodeChange(bot.id, code)}
id={`bot-code-${bot.id}`}
height={isMobileLayout ? '256px' : '384px'}
/>
</Suspense>
</div>
)}
</div>
))}
</div>
)}
<Separator />
<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 per bot.
</p>
<p>
<strong>Note:</strong> Bots respond to all messages, including your own. For channel
messages, <code>sender_key</code> is <code>None</code>. Multiple enabled bots run
serially, with a two-second delay between messages to prevent repeater collision.
</p>
</div>
{error && (
<div className="text-sm text-destructive" role="alert">
{error}
</div>
)}
<Button onClick={handleSave} disabled={busy} className="w-full">
{busy ? 'Saving...' : 'Save Bot Settings'}
</Button>
</div>
);
}
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, lazy, Suspense } from 'react';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Button } from '../ui/button';
@@ -8,24 +8,68 @@ import { cn } from '@/lib/utils';
import { api } from '../../api';
import type { FanoutConfig, HealthStatus } from '../../types';
const BotCodeEditor = lazy(() =>
import('../BotCodeEditor').then((m) => ({ default: m.BotCodeEditor }))
);
const TYPE_LABELS: Record<string, string> = {
mqtt_private: 'Private MQTT',
mqtt_community: 'Community MQTT',
bot: 'Bot',
};
const TYPE_OPTIONS = [
{ value: 'mqtt_private', label: 'Private MQTT' },
{ value: 'mqtt_community', label: 'Community MQTT' },
{ value: 'bot', label: 'Bot' },
];
const DEFAULT_BOT_CODE = `def bot(
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,
is_outgoing: bool = False,
) -> str | list[str] | None:
"""
Process messages and optionally return a reply.
Args:
sender_name: Display name of sender (may be None)
sender_key: 64-char hex public key (None 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)
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!"
return None`;
function getStatusColor(status: string | undefined) {
if (status === 'connected')
return 'bg-status-connected shadow-[0_0_6px_hsl(var(--status-connected)/0.5)]';
return 'bg-muted-foreground';
}
function getStatusLabel(status: string | undefined) {
if (status === 'connected') return 'Connected';
function getStatusLabel(status: string | undefined, type?: string) {
if (status === 'connected') return type === 'bot' ? 'Active' : 'Connected';
if (status === 'disconnected') return 'Disconnected';
return 'Inactive';
}
@@ -47,6 +91,11 @@ function MqttPrivateConfigEditor({
Forward mesh data to your own MQTT broker for home automation, logging, or alerting.
</p>
<div className="rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs text-warning">
Outgoing messages (DMs and group messages) will be reported to private MQTT brokers in
decrypted/plaintext form.
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="fanout-mqtt-host">Broker Host</Label>
@@ -234,11 +283,88 @@ function MqttCommunityConfigEditor({
);
}
function BotConfigEditor({
config,
onChange,
}: {
config: Record<string, unknown>;
onChange: (config: Record<string, unknown>) => void;
}) {
const code = (config.code as string) || '';
return (
<div className="space-y-3">
<div className="p-3 bg-destructive/10 border border-destructive/30 rounded-md">
<p className="text-sm text-destructive">
<strong>Experimental:</strong> This is an alpha feature and introduces automated message
sending to your radio; unexpected behavior may occur. Use with caution, and please report
any bugs!
</p>
</div>
<div className="p-3 bg-warning/10 border border-warning/30 rounded-md">
<p className="text-sm text-warning">
<strong>Security Warning:</strong> This feature executes arbitrary Python code on the
server. Only run trusted code, and be cautious of arbitrary usage of message parameters.
</p>
</div>
<div className="p-3 bg-warning/10 border border-warning/30 rounded-md">
<p className="text-sm text-warning">
<strong>Don&apos;t wreck the mesh!</strong> Bots process ALL messages, including their
own. Be careful of creating infinite loops!
</p>
</div>
<div className="flex items-center justify-between">
<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.
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onChange({ ...config, code: DEFAULT_BOT_CODE })}
>
Reset to Example
</Button>
</div>
<Suspense
fallback={
<div className="h-64 md:h-96 rounded-md border border-input bg-code-editor-bg flex items-center justify-center text-muted-foreground">
Loading editor...
</div>
}
>
<BotCodeEditor value={code} onChange={(c) => onChange({ ...config, code: c })} />
</Suspense>
<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 per bot.
</p>
<p>
<strong>Note:</strong> Bots respond to all messages, including your own. For channel
messages, <code>sender_key</code> is <code>None</code>. Multiple enabled bots run
serially, with a two-second delay between messages to prevent repeater collision.
</p>
</div>
</div>
);
}
export function SettingsFanoutSection({
health,
onHealthRefresh,
className,
}: {
health: HealthStatus | null;
onHealthRefresh?: () => Promise<void>;
className?: string;
}) {
const [configs, setConfigs] = useState<FanoutConfig[]>([]);
@@ -266,6 +392,7 @@ export function SettingsFanoutSection({
try {
await api.updateFanoutConfig(cfg.id, { enabled: !cfg.enabled });
await loadConfigs();
if (onHealthRefresh) await onHealthRefresh();
toast.success(cfg.enabled ? 'Integration disabled' : 'Integration enabled');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to update');
@@ -332,10 +459,14 @@ export function SettingsFanoutSection({
iata: '',
email: '',
},
bot: {
code: DEFAULT_BOT_CODE,
},
};
const defaultScopes: Record<string, Record<string, unknown>> = {
mqtt_private: { messages: 'all', raw_packets: 'all' },
mqtt_community: { messages: 'none', raw_packets: 'all' },
bot: { messages: 'all', raw_packets: 'none' },
};
try {
@@ -398,6 +529,10 @@ export function SettingsFanoutSection({
<MqttCommunityConfigEditor config={editConfig} onChange={setEditConfig} />
)}
{editingConfig.type === 'bot' && (
<BotConfigEditor config={editConfig} onChange={setEditConfig} />
)}
<Separator />
<div className="flex gap-2">
@@ -416,8 +551,7 @@ export function SettingsFanoutSection({
return (
<div className={cn('space-y-4', className)}>
<div className="rounded-md border border-warning/50 bg-warning/10 px-4 py-3 text-sm text-warning">
MQTT support is an experimental feature in open beta. All publishing uses QoS 0
(at-most-once delivery).
Integrations are an experimental feature in open beta.
</div>
{configs.length === 0 ? (
@@ -453,11 +587,11 @@ export function SettingsFanoutSection({
<div
className={cn('w-2 h-2 rounded-full transition-colors', getStatusColor(status))}
title={getStatusLabel(status)}
title={getStatusLabel(status, cfg.type)}
aria-hidden="true"
/>
<span className="text-xs text-muted-foreground hidden sm:inline">
{cfg.enabled ? getStatusLabel(status) : 'Disabled'}
{cfg.enabled ? getStatusLabel(status, cfg.type) : 'Disabled'}
</span>
<Button
@@ -480,16 +614,18 @@ export function SettingsFanoutSection({
<div className="border border-input rounded-md p-3 space-y-2">
<Label>Select integration type:</Label>
<div className="flex flex-wrap gap-2">
{TYPE_OPTIONS.map((opt) => (
<Button
key={opt.value}
variant={addingType === opt.value ? 'default' : 'outline'}
size="sm"
onClick={() => handleAddCreate(opt.value)}
>
{opt.label}
</Button>
))}
{TYPE_OPTIONS.filter((opt) => opt.value !== 'bot' || !health?.bots_disabled).map(
(opt) => (
<Button
key={opt.value}
variant={addingType === opt.value ? 'default' : 'outline'}
size="sm"
onClick={() => handleAddCreate(opt.value)}
>
{opt.label}
</Button>
)
)}
</div>
<Button variant="ghost" size="sm" onClick={() => setAddingType(null)}>
Cancel
@@ -1,17 +1,9 @@
export type SettingsSection =
| 'radio'
| 'local'
| 'database'
| 'bot'
| 'fanout'
| 'statistics'
| 'about';
export type SettingsSection = 'radio' | 'local' | 'database' | 'fanout' | 'statistics' | 'about';
export const SETTINGS_SECTION_ORDER: SettingsSection[] = [
'radio',
'local',
'database',
'bot',
'fanout',
'statistics',
'about',
@@ -21,7 +13,6 @@ export const SETTINGS_SECTION_LABELS: Record<SettingsSection, string> = {
radio: '📻 Radio',
local: '🖥️ Local Configuration',
database: '🗄️ Database & Messaging',
bot: '🤖 Bots',
fanout: '📤 Fanout & Forwarding',
statistics: '📊 Statistics',
about: 'About',
+2 -3
View File
@@ -236,10 +236,9 @@ describe('SettingsModal', () => {
it('renders selected section from external sidebar nav on desktop mode', async () => {
renderModal({
externalSidebarNav: true,
desktopSection: 'bot',
desktopSection: 'fanout',
});
expect(screen.getByText('No bots configured')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Local Configuration/i })).not.toBeInTheDocument();
expect(screen.queryByLabelText('Preset')).not.toBeInTheDocument();
});
@@ -278,7 +277,7 @@ describe('SettingsModal', () => {
<SettingsModal
open
externalSidebarNav
desktopSection="bot"
desktopSection="fanout"
config={baseConfig}
health={baseHealth}
appSettings={baseSettings}
-1
View File
@@ -255,7 +255,6 @@ export interface AppSettingsUpdate {
auto_decrypt_dm_on_advert?: boolean;
sidebar_sort_order?: 'recent' | 'alpha';
advert_interval?: number;
bots?: BotConfig[];
flood_scope?: string;
blocked_keys?: string[];
blocked_names?: string[];
+2 -1
View File
@@ -29,11 +29,12 @@ def cleanup_test_db_dir():
async def test_db():
"""Create an in-memory test database with schema + migrations."""
from app.repository import channels, contacts, messages, raw_packets, settings
from app.repository import fanout as fanout_repo
db = Database(":memory:")
await db.connect()
submodules = [contacts, channels, messages, raw_packets, settings]
submodules = [contacts, channels, messages, raw_packets, settings, fanout_repo]
originals = [(mod, mod.db) for mod in submodules]
for mod in submodules:
+44 -8
View File
@@ -183,13 +183,6 @@ export function markAllRead(): Promise<{ status: string; timestamp: number }> {
export type Favorite = { type: string; id: string };
export interface BotConfig {
id: string;
name: string;
enabled: boolean;
code: string;
}
export interface AppSettings {
max_radio_contacts: number;
favorites: Favorite[];
@@ -197,7 +190,6 @@ export interface AppSettings {
sidebar_sort_order: string;
last_message_times: Record<string, number>;
preferences_migrated: boolean;
bots: BotConfig[];
advert_interval: number;
}
@@ -212,6 +204,50 @@ export function updateSettings(patch: Partial<AppSettings>): Promise<AppSettings
});
}
// --- Fanout ---
export interface FanoutConfig {
id: string;
type: string;
name: string;
enabled: boolean;
config: Record<string, unknown>;
scope: Record<string, unknown>;
sort_order: number;
created_at: number;
}
export function getFanoutConfigs(): Promise<FanoutConfig[]> {
return fetchJson('/fanout');
}
export function createFanoutConfig(body: {
type: string;
name: string;
config: Record<string, unknown>;
scope?: Record<string, unknown>;
enabled?: boolean;
}): Promise<FanoutConfig> {
return fetchJson('/fanout', {
method: 'POST',
body: JSON.stringify(body),
});
}
export function updateFanoutConfig(
id: string,
patch: Partial<{ name: string; config: Record<string, unknown>; scope: Record<string, unknown>; enabled: boolean }>
): Promise<FanoutConfig> {
return fetchJson(`/fanout/${id}`, {
method: 'PATCH',
body: JSON.stringify(patch),
});
}
export function deleteFanoutConfig(id: string): Promise<{ deleted: boolean }> {
return fetchJson(`/fanout/${id}`, { method: 'DELETE' });
}
// --- Helpers ---
/**
+24 -20
View File
@@ -1,6 +1,12 @@
import { test, expect } from '@playwright/test';
import { ensureFlightlessChannel, getSettings, updateSettings } from '../helpers/api';
import type { BotConfig } from '../helpers/api';
import {
ensureFlightlessChannel,
getFanoutConfigs,
createFanoutConfig,
deleteFanoutConfig,
updateFanoutConfig,
} from '../helpers/api';
import type { FanoutConfig } from '../helpers/api';
const BOT_CODE = `def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path):
if channel_name == "#flightless" and "!e2etest" in message_text.lower():
@@ -8,45 +14,43 @@ const BOT_CODE = `def bot(sender_name, sender_key, message_text, is_dm, channel_
return None`;
test.describe('Bot functionality', () => {
let originalBots: BotConfig[];
let createdBotId: string | null = null;
test.beforeAll(async () => {
await ensureFlightlessChannel();
const settings = await getSettings();
originalBots = settings.bots ?? [];
});
test.afterAll(async () => {
// Restore original bot config
try {
await updateSettings({ bots: originalBots });
} catch {
console.warn('Failed to restore bot config');
// Clean up the bot we created
if (createdBotId) {
try {
await deleteFanoutConfig(createdBotId);
} catch {
console.warn('Failed to delete test bot');
}
}
});
test('create a bot via API, verify it in UI, trigger it, and verify response', async ({
page,
}) => {
// --- Step 1: Create and enable bot via API ---
// CodeMirror is difficult to drive via Playwright (contenteditable, lazy-loaded),
// so we set the bot code via the REST API and verify it through the UI.
const testBot: BotConfig = {
id: crypto.randomUUID(),
// --- Step 1: Create and enable bot via fanout API ---
const bot = await createFanoutConfig({
type: 'bot',
name: 'E2E Test Bot',
config: { code: BOT_CODE },
enabled: true,
code: BOT_CODE,
};
await updateSettings({ bots: [...originalBots, testBot] });
});
createdBotId = bot.id;
// --- Step 2: Verify bot appears in settings UI ---
await page.goto('/');
await expect(page.getByText('Connected')).toBeVisible();
await page.getByText('Settings').click();
await page.getByRole('button', { name: /🤖 Bots/ }).click();
await page.getByRole('button', { name: /Fanout/ }).click();
// The bot name should be visible in the bot list
// The bot name should be visible in the integration list
await expect(page.getByText('E2E Test Bot')).toBeVisible();
// Exit settings page mode
+2 -14
View File
@@ -162,16 +162,10 @@ class TestMessagesEndpoint:
return_value=MagicMock(type=EventType.MSG_SENT, payload={})
)
def _capture_task(coro):
coro.close()
return MagicMock()
radio_manager._meshcore = mock_mc
with (
patch("app.dependencies.radio_manager") as mock_rm,
patch("app.bot.run_bot_for_message", new=AsyncMock()),
patch("app.routers.messages.asyncio.create_task", side_effect=_capture_task),
patch("app.routers.messages.broadcast_event", create=True) as mock_broadcast,
patch("app.routers.messages.broadcast_event") as mock_broadcast,
):
mock_rm.is_connected = True
mock_rm.meshcore = mock_mc
@@ -206,17 +200,11 @@ class TestMessagesEndpoint:
mock_mc.commands.set_channel = AsyncMock(return_value=ok_result)
mock_mc.commands.send_chan_msg = AsyncMock(return_value=ok_result)
def _capture_task(coro):
coro.close()
return MagicMock()
radio_manager._meshcore = mock_mc
with (
patch("app.dependencies.radio_manager") as mock_rm,
patch("app.decoder.calculate_channel_hash", return_value="abcd"),
patch("app.bot.run_bot_for_message", new=AsyncMock()),
patch("app.routers.messages.asyncio.create_task", side_effect=_capture_task),
patch("app.routers.messages.broadcast_event", create=True) as mock_broadcast,
patch("app.routers.messages.broadcast_event") as mock_broadcast,
):
mock_rm.is_connected = True
mock_rm.meshcore = mock_mc
+15 -35
View File
@@ -745,69 +745,49 @@ class TestMultipleBots:
class TestBotCodeValidation:
"""Test bot code syntax validation on save."""
"""Test bot code syntax validation via fanout router."""
def test_valid_code_passes(self):
"""Valid Python code passes validation."""
from app.routers.settings import validate_bot_code
from app.routers.fanout import _validate_bot_config
# Should not raise
validate_bot_code("def bot(): return 'hello'")
_validate_bot_config({"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
from app.routers.fanout import _validate_bot_config
with pytest.raises(HTTPException) as exc_info:
validate_bot_code("def bot(:\n return 'broken'")
_validate_bot_config({"code": "def bot(:\n return 'broken'"})
assert exc_info.value.status_code == 400
assert "syntax error" in exc_info.value.detail.lower()
def test_syntax_error_includes_bot_name(self):
"""Syntax error message includes bot name when provided."""
def test_empty_code_raises(self):
"""Empty code raises HTTPException."""
from fastapi import HTTPException
from app.routers.settings import validate_bot_code
from app.routers.fanout import _validate_bot_config
with pytest.raises(HTTPException) as exc_info:
validate_bot_code("def bot(:\n return 'broken'", bot_name="My Test Bot")
_validate_bot_config({"code": ""})
assert exc_info.value.status_code == 400
assert "My Test Bot" in exc_info.value.detail
assert "empty" 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(" ")
def test_validate_all_bots(self):
"""validate_all_bots validates all bots' code."""
def test_missing_code_raises(self):
"""Missing code key raises HTTPException."""
from fastapi import HTTPException
from app.routers.settings import validate_all_bots
from app.routers.fanout import _validate_bot_config
# Valid bots should pass
valid_bots = [
BotConfig(id="1", name="Bot 1", enabled=True, code="def bot(): return 'hi'"),
BotConfig(id="2", name="Bot 2", enabled=False, code="def bot(): return 'hello'"),
]
validate_all_bots(valid_bots) # Should not raise
# Invalid code should raise with bot name
invalid_bots = [
BotConfig(id="1", name="Good Bot", enabled=True, code="def bot(): return 'hi'"),
BotConfig(id="2", name="Bad Bot", enabled=True, code="def bot(:"),
]
with pytest.raises(HTTPException) as exc_info:
validate_all_bots(invalid_bots)
_validate_bot_config({})
assert "Bad Bot" in exc_info.value.detail
assert exc_info.value.status_code == 400
class TestBotMessageRateLimiting:
+24 -26
View File
@@ -2,7 +2,7 @@
Verifies that when disable_bots=True:
- run_bot_for_message() exits immediately without any work
- PATCH /api/settings with bots returns 403
- POST /api/fanout with type=bot returns 403
- Health endpoint includes bots_disabled=True
"""
@@ -14,8 +14,8 @@ from fastapi import HTTPException
from app.bot import run_bot_for_message
from app.config import Settings
from app.models import BotConfig
from app.routers.fanout import FanoutConfigCreate, create_fanout_config
from app.routers.health import build_health_data
from app.routers.settings import AppSettingsUpdate, update_settings
class TestDisableBotsConfig:
@@ -78,19 +78,20 @@ class TestDisableBotsBotExecution:
mock_exec.assert_called_once()
class TestDisableBotsSettingsEndpoint:
"""Test that bot settings updates are rejected when bots are disabled."""
class TestDisableBotsFanoutEndpoint:
"""Test that bot creation via fanout router is rejected when bots are disabled."""
@pytest.mark.asyncio
async def test_bot_update_returns_403_when_disabled(self, test_db):
"""PATCH /api/settings with bots field returns 403."""
with patch("app.routers.settings.server_settings", MagicMock(disable_bots=True)):
async def test_bot_create_returns_403_when_disabled(self, test_db):
"""POST /api/fanout with type=bot returns 403."""
with patch("app.routers.fanout.server_settings", MagicMock(disable_bots=True)):
with pytest.raises(HTTPException) as exc_info:
await update_settings(
AppSettingsUpdate(
bots=[
BotConfig(id="1", name="Bot", enabled=True, code="def bot(**k): pass")
]
await create_fanout_config(
FanoutConfigCreate(
type="bot",
name="Test Bot",
config={"code": "def bot(**k): pass"},
enabled=False,
)
)
@@ -98,22 +99,19 @@ class TestDisableBotsSettingsEndpoint:
assert "disabled" in exc_info.value.detail.lower()
@pytest.mark.asyncio
async def test_non_bot_update_allowed_when_disabled(self, test_db):
"""Other settings can still be updated when bots are disabled."""
with patch("app.routers.settings.server_settings", MagicMock(disable_bots=True)):
result = await update_settings(AppSettingsUpdate(max_radio_contacts=50))
assert result.max_radio_contacts == 50
@pytest.mark.asyncio
async def test_bot_update_allowed_when_not_disabled(self, test_db):
"""Bot updates work normally when disable_bots is False."""
with patch("app.routers.settings.server_settings", MagicMock(disable_bots=False)):
result = await update_settings(
AppSettingsUpdate(
bots=[BotConfig(id="1", name="Bot", enabled=False, code="def bot(**k): pass")]
async def test_mqtt_create_allowed_when_bots_disabled(self, test_db):
"""Non-bot fanout configs can still be created when bots are disabled."""
with patch("app.routers.fanout.server_settings", MagicMock(disable_bots=True)):
# Create as disabled so fanout_manager.reload_config is not called
result = await create_fanout_config(
FanoutConfigCreate(
type="mqtt_private",
name="Test MQTT",
config={"broker_host": "localhost", "broker_port": 1883},
enabled=False,
)
)
assert len(result.bots) == 1
assert result["type"] == "mqtt_private"
class TestDisableBotsHealthEndpoint:
+6 -49
View File
@@ -5,7 +5,7 @@ delivery confirmation, contact message handling, and event registration.
"""
import time
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import MagicMock, patch
import pytest
@@ -217,43 +217,12 @@ class TestContactMessageCLIFiltering:
messages = await MessageRepository.get_all()
assert len(messages) == 0
@pytest.mark.asyncio
async def test_normal_message_schedules_bot_in_background(self, test_db):
"""Normal messages should schedule bot execution without blocking."""
from app.event_handlers import on_contact_message
def _capture_task(coro):
coro.close()
return MagicMock()
with (
patch("app.event_handlers.broadcast_event"),
patch("app.event_handlers.asyncio.create_task", side_effect=_capture_task) as mock_task,
patch("app.bot.run_bot_for_message", new_callable=AsyncMock) as mock_bot,
):
class MockEvent:
payload = {
"pubkey_prefix": "abc123def456",
"text": "Hello, bot",
"txt_type": 0,
"sender_timestamp": 1700000000,
}
await on_contact_message(MockEvent())
mock_task.assert_called_once()
mock_bot.assert_called_once()
@pytest.mark.asyncio
async def test_normal_message_still_processed(self, test_db):
"""Normal messages (txt_type=0) are still processed normally."""
from app.event_handlers import on_contact_message
with (
patch("app.event_handlers.broadcast_event") as mock_broadcast,
patch("app.bot.run_bot_for_message", new_callable=AsyncMock),
):
with patch("app.event_handlers.broadcast_event") as mock_broadcast:
class MockEvent:
payload = {
@@ -278,10 +247,7 @@ class TestContactMessageCLIFiltering:
"""Broadcast payload should have acked as integer 0, not boolean False."""
from app.event_handlers import on_contact_message
with (
patch("app.event_handlers.broadcast_event") as mock_broadcast,
patch("app.bot.run_bot_for_message", new_callable=AsyncMock),
):
with patch("app.event_handlers.broadcast_event") as mock_broadcast:
class MockEvent:
payload = {
@@ -326,10 +292,7 @@ class TestContactMessageCLIFiltering:
"sender_name",
}
with (
patch("app.event_handlers.broadcast_event") as mock_broadcast,
patch("app.bot.run_bot_for_message", new_callable=AsyncMock),
):
with patch("app.event_handlers.broadcast_event") as mock_broadcast:
class MockEvent:
payload = {
@@ -380,10 +343,7 @@ class TestContactMessageCLIFiltering:
"""Messages without txt_type field are treated as normal (not filtered)."""
from app.event_handlers import on_contact_message
with (
patch("app.event_handlers.broadcast_event"),
patch("app.bot.run_bot_for_message", new_callable=AsyncMock),
):
with patch("app.event_handlers.broadcast_event"):
class MockEvent:
payload = {
@@ -422,10 +382,7 @@ class TestContactMessageCLIFiltering:
}
)
with (
patch("app.event_handlers.broadcast_event") as mock_broadcast,
patch("app.bot.run_bot_for_message", new_callable=AsyncMock),
):
with patch("app.event_handlers.broadcast_event") as mock_broadcast:
class MockEvent:
payload = {
+100
View File
@@ -526,3 +526,103 @@ class TestMigration036:
assert row[0] == 0
finally:
await db.disconnect()
async def _setup_db_with_fanout_table():
"""Create a DB with app_settings + fanout_configs tables for migration 37 tests."""
from app.migrations import _migrate_036_create_fanout_configs
db = Database(":memory:")
await db.connect()
await db.conn.execute(_create_app_settings_table_sql())
await db.conn.execute("INSERT OR IGNORE INTO app_settings (id) VALUES (1)")
await db.conn.commit()
await _migrate_036_create_fanout_configs(db.conn)
return db
class TestMigration037:
@pytest.mark.asyncio
async def test_migration_creates_bot_from_settings(self):
"""Migration should create a fanout_configs row for each bot in app_settings."""
from app.migrations import _migrate_037_bots_to_fanout
db = await _setup_db_with_fanout_table()
try:
bots_json = json.dumps(
[
{
"id": "bot-1",
"name": "EchoBot",
"enabled": True,
"code": "def bot(**k): return 'echo'",
},
{
"id": "bot-2",
"name": "Quiet",
"enabled": False,
"code": "def bot(**k): pass",
},
]
)
await db.conn.execute("UPDATE app_settings SET bots = ? WHERE id = 1", (bots_json,))
await db.conn.commit()
await _migrate_037_bots_to_fanout(db.conn)
cursor = await db.conn.execute(
"SELECT * FROM fanout_configs WHERE type = 'bot' ORDER BY sort_order"
)
rows = await cursor.fetchall()
assert len(rows) == 2
# First bot
assert rows[0]["name"] == "EchoBot"
assert bool(rows[0]["enabled"])
config0 = json.loads(rows[0]["config"])
assert config0["code"] == "def bot(**k): return 'echo'"
scope0 = json.loads(rows[0]["scope"])
assert scope0["messages"] == "all"
assert scope0["raw_packets"] == "none"
assert rows[0]["sort_order"] == 200
# Second bot
assert rows[1]["name"] == "Quiet"
assert not bool(rows[1]["enabled"])
assert rows[1]["sort_order"] == 201
finally:
await db.disconnect()
@pytest.mark.asyncio
async def test_migration_skips_when_no_bots(self):
"""Migration should not create rows when there are no bots."""
from app.migrations import _migrate_037_bots_to_fanout
db = await _setup_db_with_fanout_table()
try:
await _migrate_037_bots_to_fanout(db.conn)
cursor = await db.conn.execute("SELECT COUNT(*) FROM fanout_configs WHERE type = 'bot'")
row = await cursor.fetchone()
assert row[0] == 0
finally:
await db.disconnect()
@pytest.mark.asyncio
async def test_migration_handles_empty_bots_array(self):
"""Migration handles bots=[] gracefully."""
from app.migrations import _migrate_037_bots_to_fanout
db = await _setup_db_with_fanout_table()
try:
await db.conn.execute("UPDATE app_settings SET bots = '[]' WHERE id = 1")
await db.conn.commit()
await _migrate_037_bots_to_fanout(db.conn)
cursor = await db.conn.execute("SELECT COUNT(*) FROM fanout_configs WHERE type = 'bot'")
row = await cursor.fetchone()
assert row[0] == 0
finally:
await db.disconnect()
+26 -26
View File
@@ -100,8 +100,8 @@ class TestMigration001:
# Run migrations
applied = await run_migrations(conn)
assert applied == 36 # All migrations run
assert await get_version(conn) == 36
assert applied == 37 # All migrations run
assert await get_version(conn) == 37
# 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 == 36 # All migrations run
assert applied1 == 37 # All migrations run
assert applied2 == 0 # No migrations on second run
assert await get_version(conn) == 36
assert await get_version(conn) == 37
finally:
await conn.close()
@@ -246,8 +246,8 @@ class TestMigration001:
applied = await run_migrations(conn)
# All migrations applied (version incremented) but no error
assert applied == 36
assert await get_version(conn) == 36
assert applied == 37
assert await get_version(conn) == 37
finally:
await conn.close()
@@ -374,10 +374,10 @@ class TestMigration013:
)
await conn.commit()
# Run migration 13 (plus 14-36 which also run)
# Run migration 13 (plus 14-37 which also run)
applied = await run_migrations(conn)
assert applied == 24
assert await get_version(conn) == 36
assert applied == 25
assert await get_version(conn) == 37
# Verify bots array was created with migrated data
cursor = await conn.execute("SELECT bots FROM app_settings WHERE id = 1")
@@ -497,7 +497,7 @@ class TestMigration018:
assert await cursor.fetchone() is not None
await run_migrations(conn)
assert await get_version(conn) == 36
assert await get_version(conn) == 37
# Verify autoindex is gone
cursor = await conn.execute(
@@ -575,8 +575,8 @@ class TestMigration018:
await conn.commit()
applied = await run_migrations(conn)
assert applied == 19 # Migrations 18-36 run (18+19 skip internally)
assert await get_version(conn) == 36
assert applied == 20 # Migrations 18-37 run (18+19 skip internally)
assert await get_version(conn) == 37
finally:
await conn.close()
@@ -648,7 +648,7 @@ class TestMigration019:
assert await cursor.fetchone() is not None
await run_migrations(conn)
assert await get_version(conn) == 36
assert await get_version(conn) == 37
# Verify autoindex is gone
cursor = await conn.execute(
@@ -714,8 +714,8 @@ class TestMigration020:
assert (await cursor.fetchone())[0] == "delete"
applied = await run_migrations(conn)
assert applied == 17 # Migrations 20-36
assert await get_version(conn) == 36
assert applied == 18 # Migrations 20-37
assert await get_version(conn) == 37
# Verify WAL mode
cursor = await conn.execute("PRAGMA journal_mode")
@@ -745,7 +745,7 @@ class TestMigration020:
await set_version(conn, 20)
applied = await run_migrations(conn)
assert applied == 16 # Migrations 21-36 still run
assert applied == 17 # Migrations 21-37 still run
# Still WAL + INCREMENTAL
cursor = await conn.execute("PRAGMA journal_mode")
@@ -803,8 +803,8 @@ class TestMigration028:
await conn.commit()
applied = await run_migrations(conn)
assert applied == 9
assert await get_version(conn) == 36
assert applied == 10
assert await get_version(conn) == 37
# Verify payload_hash column is now BLOB
cursor = await conn.execute("PRAGMA table_info(raw_packets)")
@@ -873,8 +873,8 @@ class TestMigration028:
await conn.commit()
applied = await run_migrations(conn)
assert applied == 9 # Version still bumped
assert await get_version(conn) == 36
assert applied == 10 # Version still bumped
assert await get_version(conn) == 37
# Verify data unchanged
cursor = await conn.execute("SELECT payload_hash FROM raw_packets")
@@ -923,8 +923,8 @@ class TestMigration032:
await conn.commit()
applied = await run_migrations(conn)
assert applied == 5
assert await get_version(conn) == 36
assert applied == 6
assert await get_version(conn) == 37
# Verify all columns exist with correct defaults
cursor = await conn.execute(
@@ -996,8 +996,8 @@ class TestMigration034:
await conn.commit()
applied = await run_migrations(conn)
assert applied == 3
assert await get_version(conn) == 36
assert applied == 4
assert await get_version(conn) == 37
# Verify column exists with correct default
cursor = await conn.execute("SELECT flood_scope FROM app_settings WHERE id = 1")
@@ -1039,8 +1039,8 @@ class TestMigration033:
await conn.commit()
applied = await run_migrations(conn)
assert applied == 4
assert await get_version(conn) == 36
assert applied == 5
assert await get_version(conn) == 37
cursor = await conn.execute(
"SELECT key, name, is_hashtag, on_radio FROM channels WHERE key = ?",
-76
View File
@@ -509,40 +509,6 @@ class TestAckPipeline:
class TestCreateMessageFromDecrypted:
"""Test the shared message creation function used by both real-time and historical decryption."""
@pytest.mark.asyncio
async def test_schedules_bot_in_background(self, test_db, captured_broadcasts):
"""Bot execution is scheduled and does not block channel message persistence."""
from app.packet_processor import create_message_from_decrypted
packet_id, _ = await RawPacketRepository.create(b"test_packet_bot_channel", 1700000000)
broadcasts, mock_broadcast = captured_broadcasts
def _capture_task(coro):
coro.close()
return MagicMock()
with (
patch("app.packet_processor.broadcast_event", mock_broadcast),
patch(
"app.packet_processor.asyncio.create_task", side_effect=_capture_task
) as mock_task,
patch("app.bot.run_bot_for_message", new_callable=AsyncMock) as mock_bot,
):
msg_id = await create_message_from_decrypted(
packet_id=packet_id,
channel_key="ABC123DEF456",
sender="BotTrigger",
message_text="Hello from channel",
timestamp=1700000000,
received_at=1700000001,
trigger_bot=True,
)
assert msg_id is not None
mock_task.assert_called_once()
mock_bot.assert_called_once()
assert mock_bot.await_count == 0
@pytest.mark.asyncio
async def test_creates_message_and_broadcasts(self, test_db, captured_broadcasts):
"""create_message_from_decrypted creates message and broadcasts correctly."""
@@ -760,48 +726,6 @@ class TestCreateDMMessageFromDecrypted:
FACE12_PUB = "FACE123334789E2B81519AFDBC39A3C9EB7EA3457AD367D3243597A484847E46"
A1B2C3_PUB = "a1b2c3d3ba9f5fa8705b9845fe11cc6f01d1d49caaf4d122ac7121663c5beec7"
@pytest.mark.asyncio
async def test_schedules_bot_in_background(self, test_db, captured_broadcasts):
"""Bot execution is scheduled and does not block DM persistence."""
from app.decoder import DecryptedDirectMessage
from app.packet_processor import create_dm_message_from_decrypted
packet_id, _ = await RawPacketRepository.create(b"test_packet_bot_dm", 1700000000)
decrypted = DecryptedDirectMessage(
timestamp=1700000000,
flags=0,
message="Hello from DM",
dest_hash="fa",
src_hash="a1",
)
broadcasts, mock_broadcast = captured_broadcasts
def _capture_task(coro):
coro.close()
return MagicMock()
with (
patch("app.packet_processor.broadcast_event", mock_broadcast),
patch(
"app.packet_processor.asyncio.create_task", side_effect=_capture_task
) as mock_task,
patch("app.bot.run_bot_for_message", new_callable=AsyncMock) as mock_bot,
):
msg_id = await create_dm_message_from_decrypted(
packet_id=packet_id,
decrypted=decrypted,
their_public_key=self.A1B2C3_PUB,
our_public_key=self.FACE12_PUB,
received_at=1700000001,
outgoing=False,
trigger_bot=True,
)
assert msg_id is not None
mock_task.assert_called_once()
mock_bot.assert_called_once()
assert mock_bot.await_count == 0
@pytest.mark.asyncio
async def test_creates_dm_message_and_broadcasts(self, test_db, captured_broadcasts):
"""create_dm_message_from_decrypted creates message and broadcasts correctly."""
+36 -118
View File
@@ -1,4 +1,4 @@
"""Tests for bot triggering on outgoing messages sent via the messages router."""
"""Tests for outgoing message sending via the messages router."""
import asyncio
import time
@@ -76,77 +76,36 @@ async def _insert_contact(public_key, name="Alice"):
)
class TestOutgoingDMBotTrigger:
"""Test that sending a DM triggers bots with is_outgoing=True."""
class TestOutgoingDMBroadcast:
"""Test that outgoing DMs are broadcast via broadcast_event for fanout dispatch."""
@pytest.mark.asyncio
async def test_send_dm_triggers_bot(self, test_db):
"""Sending a DM creates a background task to run bots."""
async def test_send_dm_broadcasts_outgoing(self, test_db):
"""Sending a DM broadcasts the message with outgoing=True for fanout dispatch."""
mc = _make_mc()
pub_key = "ab" * 32
await _insert_contact(pub_key, "Alice")
broadcasts = []
def capture_broadcast(event_type, data):
broadcasts.append({"type": event_type, "data": data})
with (
patch("app.routers.messages.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch("app.bot.run_bot_for_message", new=AsyncMock()) as mock_bot,
patch("app.routers.messages.broadcast_event", side_effect=capture_broadcast),
):
request = SendDirectMessageRequest(destination=pub_key, text="!lasttime Alice")
await send_direct_message(request)
# Let the background task run
await asyncio.sleep(0)
mock_bot.assert_called_once()
call_kwargs = mock_bot.call_args[1]
assert call_kwargs["message_text"] == "!lasttime Alice"
assert call_kwargs["is_dm"] is True
assert call_kwargs["is_outgoing"] is True
assert call_kwargs["sender_key"] == pub_key
assert call_kwargs["channel_key"] is None
@pytest.mark.asyncio
async def test_send_dm_bot_does_not_block_response(self, test_db):
"""Bot trigger runs in background and doesn't delay the message response."""
mc = _make_mc()
pub_key = "ab" * 32
await _insert_contact(pub_key, "Alice")
# Bot that would take a long time
async def _slow(**kw):
await asyncio.sleep(10)
slow_bot = AsyncMock(side_effect=_slow)
with (
patch("app.routers.messages.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch("app.bot.run_bot_for_message", new=slow_bot),
):
request = SendDirectMessageRequest(destination=pub_key, text="Hello")
# This should return immediately, not wait 10 seconds
message = await send_direct_message(request)
assert message.text == "Hello"
assert message.outgoing is True
@pytest.mark.asyncio
async def test_send_dm_passes_no_sender_name(self, test_db):
"""Outgoing DMs pass sender_name=None (we are the sender)."""
mc = _make_mc()
pub_key = "cd" * 32
await _insert_contact(pub_key, "Bob")
with (
patch("app.routers.messages.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch("app.bot.run_bot_for_message", new=AsyncMock()) as mock_bot,
):
request = SendDirectMessageRequest(destination=pub_key, text="test")
await send_direct_message(request)
await asyncio.sleep(0)
call_kwargs = mock_bot.call_args[1]
assert call_kwargs["sender_name"] is None
msg_broadcasts = [b for b in broadcasts if b["type"] == "message"]
assert len(msg_broadcasts) == 1
data = msg_broadcasts[0]["data"]
assert data["text"] == "!lasttime Alice"
assert data["outgoing"] is True
assert data["type"] == "PRIV"
assert data["conversation_key"] == pub_key
@pytest.mark.asyncio
async def test_send_dm_ambiguous_prefix_returns_409(self, test_db):
@@ -167,77 +126,37 @@ class TestOutgoingDMBotTrigger:
assert "ambiguous" in exc_info.value.detail.lower()
class TestOutgoingChannelBotTrigger:
"""Test that sending a channel message triggers bots with is_outgoing=True."""
class TestOutgoingChannelBroadcast:
"""Test that outgoing channel messages are broadcast via broadcast_event for fanout dispatch."""
@pytest.mark.asyncio
async def test_send_channel_msg_triggers_bot(self, test_db):
"""Sending a channel message creates a background task to run bots."""
async def test_send_channel_msg_broadcasts_outgoing(self, test_db):
"""Sending a channel message broadcasts with outgoing=True for fanout dispatch."""
mc = _make_mc(name="MyNode")
chan_key = "aa" * 16
await ChannelRepository.upsert(key=chan_key, name="#general")
broadcasts = []
def capture_broadcast(event_type, data):
broadcasts.append({"type": event_type, "data": data})
with (
patch("app.routers.messages.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch("app.decoder.calculate_channel_hash", return_value="abcd"),
patch("app.bot.run_bot_for_message", new=AsyncMock()) as mock_bot,
patch("app.routers.messages.broadcast_event", side_effect=capture_broadcast),
):
request = SendChannelMessageRequest(channel_key=chan_key, text="!lasttime5 someone")
await send_channel_message(request)
await asyncio.sleep(0)
mock_bot.assert_called_once()
call_kwargs = mock_bot.call_args[1]
assert call_kwargs["message_text"] == "!lasttime5 someone"
assert call_kwargs["is_dm"] is False
assert call_kwargs["is_outgoing"] is True
assert call_kwargs["channel_key"] == chan_key.upper()
assert call_kwargs["channel_name"] == "#general"
assert call_kwargs["sender_name"] == "MyNode"
assert call_kwargs["sender_key"] is None
@pytest.mark.asyncio
async def test_send_channel_msg_no_radio_name(self, test_db):
"""When radio has no name, sender_name is None."""
mc = _make_mc(name="")
chan_key = "bb" * 16
await ChannelRepository.upsert(key=chan_key, name="#test")
with (
patch("app.routers.messages.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch("app.decoder.calculate_channel_hash", return_value="abcd"),
patch("app.bot.run_bot_for_message", new=AsyncMock()) as mock_bot,
):
request = SendChannelMessageRequest(channel_key=chan_key, text="hello")
await send_channel_message(request)
await asyncio.sleep(0)
call_kwargs = mock_bot.call_args[1]
assert call_kwargs["sender_name"] is None
@pytest.mark.asyncio
async def test_send_channel_msg_bot_does_not_block_response(self, test_db):
"""Bot trigger runs in background and doesn't delay the message response."""
mc = _make_mc(name="MyNode")
chan_key = "cc" * 16
await ChannelRepository.upsert(key=chan_key, name="#slow")
async def _slow(**kw):
await asyncio.sleep(10)
slow_bot = AsyncMock(side_effect=_slow)
with (
patch("app.routers.messages.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch("app.decoder.calculate_channel_hash", return_value="abcd"),
patch("app.bot.run_bot_for_message", new=slow_bot),
):
request = SendChannelMessageRequest(channel_key=chan_key, text="test")
message = await send_channel_message(request)
assert message.outgoing is True
msg_broadcasts = [b for b in broadcasts if b["type"] == "message"]
assert len(msg_broadcasts) == 1
data = msg_broadcasts[0]["data"]
assert data["outgoing"] is True
assert data["type"] == "CHAN"
assert data["conversation_key"] == chan_key.upper()
assert data["sender_name"] == "MyNode"
@pytest.mark.asyncio
async def test_send_channel_msg_response_includes_current_ack_count(self, test_db):
@@ -250,7 +169,7 @@ class TestOutgoingChannelBotTrigger:
patch("app.routers.messages.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch("app.decoder.calculate_channel_hash", return_value="abcd"),
patch("app.bot.run_bot_for_message", new=AsyncMock()),
patch("app.routers.messages.broadcast_event"),
):
request = SendChannelMessageRequest(channel_key=chan_key, text="acked now")
message = await send_channel_message(request)
@@ -277,7 +196,6 @@ class TestOutgoingChannelBotTrigger:
patch("app.routers.messages.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch("app.decoder.calculate_channel_hash", return_value="abcd"),
patch("app.bot.run_bot_for_message", new=AsyncMock()),
patch("app.routers.messages.broadcast_event", side_effect=capture_broadcast),
):
request = SendChannelMessageRequest(channel_key=chan_key, text="hello")
+1 -17
View File
@@ -3,9 +3,8 @@
from unittest.mock import AsyncMock, patch
import pytest
from fastapi import HTTPException
from app.models import AppSettings, BotConfig
from app.models import AppSettings
from app.repository import AppSettingsRepository
from app.routers.settings import (
AppSettingsUpdate,
@@ -53,21 +52,6 @@ class TestUpdateSettings:
assert isinstance(result, AppSettings)
assert result.max_radio_contacts == 200 # default
@pytest.mark.asyncio
async def test_invalid_bot_syntax_returns_400(self):
bad_bot = BotConfig(
id="bot-1",
name="BadBot",
enabled=True,
code="def bot(:\n return 'x'\n",
)
with pytest.raises(HTTPException) as exc:
await update_settings(AppSettingsUpdate(bots=[bad_bot]))
assert exc.value.status_code == 400
assert "syntax error" in exc.value.detail.lower()
@pytest.mark.asyncio
async def test_flood_scope_round_trip(self, test_db):
"""Flood scope should be saved and retrieved correctly."""