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 { handleKeyboardActivate } from '../../utils/a11y'; 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) => (
{ if ((e.target as HTMLElement).closest('input, button')) return; setExpandedBotId(expandedBotId === bot.id ? null : bot.id); }} > {editingNameId === bot.id ? ( setEditingNameValue(e.target.value)} onBlur={handleFinishEditingName} onKeyDown={(e) => { if (e.key === 'Enter') handleFinishEditingName(); if (e.key === 'Escape') { setEditingNameId(null); setEditingNameValue(''); } }} autoFocus className="px-2 py-0.5 text-sm bg-background border border-input rounded flex-1 max-w-[200px]" onClick={(e) => e.stopPropagation()} /> ) : ( { e.stopPropagation(); handleStartEditingName(bot); }} title="Click to rename" > {bot.name} )}
{expandedBotId === bot.id && (

Define a bot() function that receives message data and optionally returns a reply.

Loading editor...
} > handleBotCodeChange(bot.id, code)} id={`bot-code-${bot.id}`} height={isMobileLayout ? '256px' : '384px'} />
)}
))}
)}

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}
)} ); }