Phase 4: Bots & Extensibility
Backend: - internal/bot/auth.go: bot token generation and verification - internal/bot/handlers.go: bot CRUD (create, list, get, update, delete, server management, token regen) - internal/bot/commands.go: slash command registration and management - internal/webhook/handlers.go: webhook CRUD and execution endpoint - internal/webhook/token.go: webhook token generation - internal/db/db.go: bots, bot_servers, slash_commands, webhooks tables - internal/gateway/events.go: BOT_JOIN, BOT_LEAVE event constants - cmd/server/main.go: wired bot, webhook, invite routes Frontend: - stores/bot.ts: Zustand store for bot management - BotManager.tsx: bot list, create, edit, delete, add to server, token display - CommandManager.tsx: slash command CRUD per bot - SlashCommandPopup.tsx: / command autocomplete popup - App.tsx: /bots and /bots/:id/commands routes Examples: - examples/modbot/: auto-delete banned words, /kick, /ban, /purge commands - examples/welcome/: welcome message on member join
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
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';
|
||||
|
||||
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 [showCreate, setShowCreate] = useState(false);
|
||||
const [createName, setCreateName] = useState('');
|
||||
const [createDesc, setCreateDesc] = 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 {
|
||||
const result = await createBot(createName.trim(), createDesc.trim());
|
||||
setTokenDisplay({ botId: result.id, token: result.token });
|
||||
setCreateName('');
|
||||
setCreateDesc('');
|
||||
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);
|
||||
};
|
||||
|
||||
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 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(''); }}
|
||||
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}]
|
||||
</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="/" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
|
||||
{'<'} [BACK TO CHAT]
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user