Files
dumpsterChat/web/src/components/BotManager.tsx
T
hobokenchicken eb5f38de1c fix(bots): channel picker dropdown instead of ID text input
Server selector + channel dropdown for built-in bot config.
No more asking users to paste UUIDs.
2026-07-15 15:02:39 -04:00

449 lines
18 KiB
TypeScript

import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { useBotStore, type Bot } from '../stores/bot.ts';
import { useServerStore } from '../stores/server.ts';
import { useChannelStore } from '../stores/channel.ts';
// ponytail: config fields per bot type, add new types here
const BOT_TYPE_CONFIGS: Record<string, { label: string; fields: { key: string; label: string; type: 'text' | 'number' | 'channel'; placeholder: string }[] }> = {
steamfree: {
label: 'Steam Free Games',
fields: [
{ key: 'channel_id', label: 'CHANNEL', type: 'channel', placeholder: 'select channel' },
{ key: 'poll_minutes', label: 'POLL INTERVAL (min)', type: 'number', placeholder: '30' },
],
},
};
export function BotManager() {
const bots = useBotStore((s) => s.bots);
const loading = useBotStore((s) => s.loading);
const error = useBotStore((s) => s.error);
const fetchBots = useBotStore((s) => s.fetchBots);
const createBot = useBotStore((s) => s.createBot);
const updateBot = useBotStore((s) => s.updateBot);
const deleteBot = useBotStore((s) => s.deleteBot);
const addToServer = useBotStore((s) => s.addToServer);
const regenerateToken = useBotStore((s) => s.regenerateToken);
const servers = useServerStore((s) => s.servers);
const fetchServers = useServerStore((s) => s.fetchServers);
const channelsByServer = useChannelStore((s) => s.channelsByServer);
const fetchChannels = useChannelStore((s) => s.fetchChannels);
const [showCreate, setShowCreate] = useState(false);
const [createName, setCreateName] = useState('');
const [createDesc, setCreateDesc] = useState('');
const [createType, setCreateType] = useState('');
const [createConfig, setCreateConfig] = useState<Record<string, string>>({});
const [channelServerId, setChannelServerId] = useState('');
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const [editDesc, setEditDesc] = useState('');
const [tokenDisplay, setTokenDisplay] = useState<{ botId: string; token: string } | null>(null);
const [addToServerBotId, setAddToServerBotId] = useState<string | null>(null);
const [selectedServer, setSelectedServer] = useState('');
const [copied, setCopied] = useState(false);
useEffect(() => {
fetchBots();
fetchServers();
}, [fetchBots, fetchServers]);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!createName.trim()) return;
try {
// Build config object with proper types
const cfg: Record<string, unknown> = {};
for (const [k, v] of Object.entries(createConfig)) {
if (v === '') continue;
const fieldDef = BOT_TYPE_CONFIGS[createType]?.fields.find((f) => f.key === k);
cfg[k] = fieldDef?.type === 'number' ? parseInt(v, 10) : v;
}
const result = await createBot(createName.trim(), createDesc.trim(), createType || undefined, Object.keys(cfg).length ? cfg : undefined);
setTokenDisplay({ botId: result.id, token: result.token });
setCreateName('');
setCreateDesc('');
setCreateType('');
setCreateConfig({});
setChannelServerId('');
setShowCreate(false);
} catch {
// error handled in store
}
};
const handleUpdate = async (e: React.FormEvent) => {
e.preventDefault();
if (!editingId) return;
try {
await updateBot(editingId, { name: editName.trim(), description: editDesc.trim() });
setEditingId(null);
} catch {
// error handled in store
}
};
const handleDelete = async (id: string, name: string) => {
if (!window.confirm(`Delete bot "${name}"? This cannot be undone.`)) return;
try {
await deleteBot(id);
} catch {
// error handled in store
}
};
const handleRegenerate = async (id: string) => {
if (!window.confirm('Regenerate token? The old token will be invalidated.')) return;
try {
const result = await regenerateToken(id);
setTokenDisplay({ botId: id, token: result.token });
} catch {
// error handled in store
}
};
const handleAddToServer = async () => {
if (!addToServerBotId || !selectedServer) return;
try {
await addToServer(addToServerBotId, selectedServer);
setAddToServerBotId(null);
setSelectedServer('');
} catch {
// error handled in store
}
};
const handleCopyToken = () => {
if (tokenDisplay) {
navigator.clipboard.writeText(tokenDisplay.token);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const startEdit = (bot: Bot) => {
setEditingId(bot.id);
setEditName(bot.name);
setEditDesc(bot.description);
};
const typeConfig = createType ? BOT_TYPE_CONFIGS[createType] : null;
return (
<div className="h-full w-full bg-gb-bg p-4 md:p-8 overflow-y-auto">
<div className="max-w-3xl mx-auto">
<div className="border border-gb-bg-t p-6">
<pre className="text-gb-orange font-mono text-center mb-6">
{'┌──────────────────────────────────┐\n'}
{'│ === BOT MANAGER === │\n'}
{'└──────────────────────────────────┘'}
</pre>
{error && (
<p className="text-gb-red text-sm font-mono mb-4">ERR: {error}</p>
)}
{/* Token display overlay */}
{tokenDisplay && (
<div className="border border-gb-green bg-gb-bg-s p-4 mb-4">
<p className="text-gb-green text-sm font-mono mb-2">
{'>'} TOKEN GENERATED copy it now, it won't be shown again:
</p>
<div className="flex items-center gap-2">
<code className="text-gb-fg bg-gb-bg px-2 py-1 border border-gb-bg-t flex-1 text-xs break-all">
{tokenDisplay.token}
</code>
<button
type="button"
onClick={handleCopyToken}
className="terminal-button text-xs shrink-0"
>
{copied ? '[COPIED]' : '[COPY]'}
</button>
</div>
<button
type="button"
onClick={() => setTokenDisplay(null)}
className="text-gb-fg-f hover:text-gb-aqua font-mono text-xs mt-2"
>
[DISMISS]
</button>
</div>
)}
{/* Create bot form */}
<div className="mb-6">
{!showCreate ? (
<button
type="button"
onClick={() => setShowCreate(true)}
className="terminal-button"
>
[CREATE BOT]
</button>
) : (
<form onSubmit={handleCreate} className="border border-gb-bg-t p-4 space-y-3">
<p className="text-gb-aqua font-mono text-sm">{'>'} NEW BOT</p>
<div>
<label className="block text-gb-fg-f mb-1 font-mono text-xs">NAME:</label>
<input
type="text"
value={createName}
onChange={(e) => setCreateName(e.target.value.slice(0, 32))}
maxLength={32}
className="terminal-input w-full"
placeholder="my-cool-bot"
autoFocus
/>
</div>
<div>
<label className="block text-gb-fg-f mb-1 font-mono text-xs">DESCRIPTION:</label>
<input
type="text"
value={createDesc}
onChange={(e) => setCreateDesc(e.target.value.slice(0, 256))}
maxLength={256}
className="terminal-input w-full"
placeholder="what does this bot do?"
/>
</div>
<div>
<label className="block text-gb-fg-f mb-1 font-mono text-xs">TYPE:</label>
<select
value={createType}
onChange={(e) => { setCreateType(e.target.value); setCreateConfig({}); }}
className="terminal-input w-full"
>
<option value="">External (connects via WebSocket)</option>
{Object.entries(BOT_TYPE_CONFIGS).map(([key, cfg]) => (
<option key={key} value={key}>{cfg.label}</option>
))}
</select>
</div>
{typeConfig && typeConfig.fields.map((field) => (
<div key={field.key}>
<label className="block text-gb-fg-f mb-1 font-mono text-xs">{field.label}:</label>
{field.type === 'channel' ? (
<div className="space-y-1">
<select
value={channelServerId}
onChange={(e) => {
setChannelServerId(e.target.value);
setCreateConfig((prev) => ({ ...prev, [field.key]: '' }));
if (e.target.value) fetchChannels(e.target.value);
}}
className="terminal-input w-full"
>
<option value="">-- select server first --</option>
{servers.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
<select
value={createConfig[field.key] || ''}
onChange={(e) => setCreateConfig((prev) => ({ ...prev, [field.key]: e.target.value }))}
className="terminal-input w-full"
disabled={!channelServerId}
>
<option value="">-- select channel --</option>
{(channelsByServer[channelServerId] || [])
.filter((c) => c.type === 'text')
.map((c) => (
<option key={c.id} value={c.id}>#{c.name}</option>
))}
</select>
</div>
) : (
<input
type={field.type}
value={createConfig[field.key] || ''}
onChange={(e) => setCreateConfig((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.placeholder}
className="terminal-input w-full"
/>
)}
</div>
))}
<div className="flex gap-2">
<button type="submit" className="terminal-button" disabled={loading || !createName.trim()}>
{loading ? '[CREATING...]' : '[SAVE]'}
</button>
<button
type="button"
onClick={() => { setShowCreate(false); setCreateName(''); setCreateDesc(''); setCreateType(''); setCreateConfig({}); setChannelServerId(''); }}
className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm"
>
[CANCEL]
</button>
</div>
</form>
)}
</div>
{/* Bot list */}
{loading && bots.length === 0 && (
<p className="text-gb-fg-f font-mono text-sm">[loading bots...]</p>
)}
{!loading && bots.length === 0 && (
<p className="text-gb-fg-f font-mono text-sm">[no bots created yet]</p>
)}
<div className="space-y-3">
{bots.map((bot) => (
<div key={bot.id} className="border border-gb-bg-t p-4">
{editingId === bot.id ? (
<form onSubmit={handleUpdate} className="space-y-3">
<div>
<label className="block text-gb-fg-f mb-1 font-mono text-xs">NAME:</label>
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value.slice(0, 32))}
maxLength={32}
className="terminal-input w-full"
/>
</div>
<div>
<label className="block text-gb-fg-f mb-1 font-mono text-xs">DESCRIPTION:</label>
<input
type="text"
value={editDesc}
onChange={(e) => setEditDesc(e.target.value.slice(0, 256))}
maxLength={256}
className="terminal-input w-full"
/>
</div>
<div className="flex gap-2">
<button type="submit" className="terminal-button text-xs" disabled={loading}>
{loading ? '[SAVING...]' : '[SAVE]'}
</button>
<button
type="button"
onClick={() => setEditingId(null)}
className="text-gb-fg-f hover:text-gb-aqua font-mono text-xs"
>
[CANCEL]
</button>
</div>
</form>
) : (
<>
<div className="flex items-start justify-between gap-2 mb-2">
<div className="min-w-0">
<p className="text-gb-green font-mono text-sm truncate">
{bot.avatar && (
<img
src={bot.avatar}
alt=""
className="inline w-5 h-5 mr-1 align-middle border border-gb-bg-t"
/>
)}
[{bot.name}]
{bot.bot_type && (
<span className="text-gb-aqua text-xs ml-2">({BOT_TYPE_CONFIGS[bot.bot_type]?.label || bot.bot_type})</span>
)}
</p>
{bot.description && (
<p className="text-gb-fg-f font-mono text-xs mt-1 truncate">
{bot.description}
</p>
)}
</div>
<span className="text-gb-fg-f font-mono text-xs shrink-0">
ID:{bot.id.slice(0, 8)}
</span>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => startEdit(bot)}
className="terminal-button text-xs"
>
[EDIT]
</button>
<button
type="button"
onClick={() => handleDelete(bot.id, bot.name)}
className="terminal-button text-xs hover:!text-gb-red"
>
[DELETE]
</button>
<button
type="button"
onClick={() => { setAddToServerBotId(bot.id); setSelectedServer(''); }}
className="terminal-button text-xs"
>
[ADD TO SERVER]
</button>
<button
type="button"
onClick={() => handleRegenerate(bot.id)}
className="terminal-button text-xs"
>
[REGENERATE TOKEN]
</button>
<Link
to={`/bots/${bot.id}/commands`}
className="terminal-button text-xs"
>
[COMMANDS]
</Link>
</div>
</>
)}
</div>
))}
</div>
{/* Add to server modal */}
{addToServerBotId && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="border border-gb-bg-t bg-gb-bg p-6 max-w-md w-full mx-4">
<p className="text-gb-orange font-mono text-sm mb-4">
{'>'} ADD BOT TO SERVER
</p>
<select
value={selectedServer}
onChange={(e) => setSelectedServer(e.target.value)}
className="terminal-input w-full mb-4"
>
<option value="">-- select server --</option>
{servers.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
<div className="flex gap-2">
<button
type="button"
onClick={handleAddToServer}
className="terminal-button text-xs"
disabled={!selectedServer}
>
[ADD]
</button>
<button
type="button"
onClick={() => { setAddToServerBotId(null); setSelectedServer(''); }}
className="text-gb-fg-f hover:text-gb-aqua font-mono text-xs"
>
[CANCEL]
</button>
</div>
</div>
</div>
)}
{/* Footer */}
<div className="mt-6 pt-4 border-t border-gb-bg-t">
<Link to="/bots" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
{'<'} [BACK TO STORE]
</Link>
</div>
</div>
</div>
</div>
);
}