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
+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[];