mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 09:13:04 +02:00
Move bots into Fanout & Forwarding
This commit is contained in:
@@ -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'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'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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user