diff --git a/app/event_handlers.py b/app/event_handlers.py
index 84dceb3..e0cd6e6 100644
--- a/app/event_handlers.py
+++ b/app/event_handlers.py
@@ -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.
diff --git a/app/fanout/bot.py b/app/fanout/bot.py
new file mode 100644
index 0000000..715b9ad
--- /dev/null
+++ b/app/fanout/bot.py
@@ -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"
diff --git a/app/fanout/manager.py b/app/fanout/manager.py
index 06a070f..bb5c38f 100644
--- a/app/fanout/manager.py
+++ b/app/fanout/manager.py
@@ -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(
diff --git a/app/migrations.py b/app/migrations.py
index 65168db..28805b1 100644
--- a/app/migrations.py
+++ b/app/migrations.py
@@ -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()
diff --git a/app/packet_processor.py b/app/packet_processor.py
index 1264345..cbe49d1 100644
--- a/app/packet_processor.py
+++ b/app/packet_processor.py
@@ -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
diff --git a/app/routers/fanout.py b/app/routers/fanout.py
index 4aa5c6b..3bf4bdc 100644
--- a/app/routers/fanout.py
+++ b/app/routers/fanout.py
@@ -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, "", "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:
diff --git a/app/routers/messages.py b/app/routers/messages.py
index 757b0ba..e8e1354 100644
--- a/app/routers/messages.py
+++ b/app/routers/messages.py
@@ -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
diff --git a/app/routers/settings.py b/app/routers/settings.py
index 56cf6de..7f220a3 100644
--- a/app/routers/settings.py
+++ b/app/routers/settings.py
@@ -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, "", "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]
diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md
index 61bcc5e..407e803 100644
--- a/frontend/AGENTS.md
+++ b/frontend/AGENTS.md
@@ -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
diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx
index 174b907..3cbb851 100644
--- a/frontend/src/components/SettingsModal.tsx
+++ b/frontend/src/components/SettingsModal.tsx
@@ -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) {
)}
- {shouldRenderSection('bot') && (
-
- {renderSectionHeader('bot')}
- {isSectionVisible('bot') && appSettings && (
-
- )}
-
- )}
-
{shouldRenderSection('fanout') && (
{renderSectionHeader('fanout')}
{isSectionVisible('fanout') && (
-
+
)}
)}
diff --git a/frontend/src/components/settings/SettingsBotSection.tsx b/frontend/src/components/settings/SettingsBotSection.tsx
deleted file mode 100644
index c69b270..0000000
--- a/frontend/src/components/settings/SettingsBotSection.tsx
+++ /dev/null
@@ -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;
- className?: string;
-}) {
- const [bots, setBots] = useState([]);
- const [expandedBotId, setExpandedBotId] = useState(null);
- const [editingNameId, setEditingNameId] = useState(null);
- const [editingNameValue, setEditingNameValue] = useState('');
-
- const [busy, setBusy] = useState(false);
- const [error, setError] = useState(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 (
-
-
Bot system disabled by server startup flag.
-
- );
- }
-
- return (
-
-
-
- Experimental: 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!
-
-
-
-
-
- Security Warning: This feature executes arbitrary Python code on the
- server. Only run trusted code, and be cautious of arbitrary usage of message parameters.
-
-
-
-
-
- Don't wreck the mesh! Bots process ALL messages, including their
- own. Be careful of creating infinite loops!
-
-
-
-
-
-
-
-
- {bots.length === 0 ? (
-
-
No bots configured
-
-
- ) : (
-
- {bots.map((bot) => (
-
-
-
- {expandedBotId === bot.id && (
-
-
-
- Define a bot() function that
- receives message data and optionally returns a reply.
-
- handleResetBotCode(bot.id)}
- >
- Reset to Example
-
-
- Available: Standard Python libraries and any modules installed in the
- server environment.
-
-
- Limits: 10 second timeout per bot.
-
-
- Note: Bots respond to all messages, including your own. For channel
- messages, sender_key is None. Multiple enabled bots run
- serially, with a two-second delay between messages to prevent repeater collision.
-
-
-
- {error && (
-
- {error}
-
- )}
-
-
- {busy ? 'Saving...' : 'Save Bot Settings'}
-
-
- );
-}
diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx
index e804ac6..7d350dd 100644
--- a/frontend/src/components/settings/SettingsFanoutSection.tsx
+++ b/frontend/src/components/settings/SettingsFanoutSection.tsx
@@ -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 = {
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.
+
+ Outgoing messages (DMs and group messages) will be reported to private MQTT brokers in
+ decrypted/plaintext form.
+
+ Experimental: 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!
+
+
+
+
+
+ Security Warning: This feature executes arbitrary Python code on the
+ server. Only run trusted code, and be cautious of arbitrary usage of message parameters.
+
+
+
+
+
+ Don't wreck the mesh! Bots process ALL messages, including their
+ own. Be careful of creating infinite loops!
+
+
+
+
+
+ Define a bot() function that receives
+ message data and optionally returns a reply.
+
+ onChange({ ...config, code: DEFAULT_BOT_CODE })}
+ >
+ Reset to Example
+
+
+ Available: Standard Python libraries and any modules installed in the
+ server environment.
+
+
+ Limits: 10 second timeout per bot.
+
+
+ Note: Bots respond to all messages, including your own. For channel
+ messages, sender_key is None. Multiple enabled bots run
+ serially, with a two-second delay between messages to prevent repeater collision.
+
@@ -416,8 +551,7 @@ export function SettingsFanoutSection({
return (
- 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.