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:
2026-06-28 17:00:37 -04:00
parent 01c67f9531
commit 000ce85816
16 changed files with 2479 additions and 1 deletions
+38
View File
@@ -3,6 +3,8 @@ import { LoginForm } from './components/LoginForm.tsx';
import { Layout } from './components/Layout.tsx';
import { ChatArea } from './components/ChatArea.tsx';
import { UserSettings } from './components/UserSettings.tsx';
import { BotManager } from './components/BotManager.tsx';
import { CommandManager } from './components/CommandManager.tsx';
import { useAuthStore } from './stores/auth.ts';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -33,6 +35,42 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/bots"
element={
<ProtectedRoute>
<div className="h-full w-full flex flex-col bg-gb-bg">
<header className="flex items-center gap-4 px-4 py-2 border-b border-gb-bg-t bg-gb-bg-s">
<Link to="/" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
[BACK]
</Link>
<span className="text-gb-orange font-mono text-sm">BOT MANAGER</span>
</header>
<div className="flex-1 overflow-hidden">
<BotManager />
</div>
</div>
</ProtectedRoute>
}
/>
<Route
path="/bots/:id/commands"
element={
<ProtectedRoute>
<div className="h-full w-full flex flex-col bg-gb-bg">
<header className="flex items-center gap-4 px-4 py-2 border-b border-gb-bg-t bg-gb-bg-s">
<Link to="/bots" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
[BACK]
</Link>
<span className="text-gb-orange font-mono text-sm">SLASH COMMANDS</span>
</header>
<div className="flex-1 overflow-hidden">
<CommandManager />
</div>
</div>
</ProtectedRoute>
}
/>
<Route
path="/"
element={
+359
View File
@@ -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>
);
}
+224
View File
@@ -0,0 +1,224 @@
import { useEffect, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useBotStore, type Bot, type SlashCommand } from '../stores/bot.ts';
import { useServerStore } from '../stores/server.ts';
export function CommandManager() {
const { id: botId } = useParams<{ id: string }>();
const fetchCommands = useBotStore((s) => s.fetchCommands);
const createCommand = useBotStore((s) => s.createCommand);
const deleteCommand = useBotStore((s) => s.deleteCommand);
const bots = useBotStore((s) => s.bots);
const fetchBots = useBotStore((s) => s.fetchBots);
const error = useBotStore((s) => s.error);
const servers = useServerStore((s) => s.servers);
const fetchServers = useServerStore((s) => s.fetchServers);
const [commands, setCommands] = useState<SlashCommand[]>([]);
const [loading, setLoading] = useState(false);
const [bot, setBot] = useState<Bot | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [cmdName, setCmdName] = useState('');
const [cmdDesc, setCmdDesc] = useState('');
const [cmdServer, setCmdServer] = useState('');
useEffect(() => {
fetchServers();
fetchBots();
}, [fetchServers, fetchBots]);
useEffect(() => {
if (!botId) return;
const found = bots.find((b) => b.id === botId);
if (found) setBot(found);
}, [bots, botId]);
const loadCommands = async () => {
if (!botId) return;
setLoading(true);
const cmds = await fetchCommands(botId);
setCommands(cmds);
setLoading(false);
};
useEffect(() => {
loadCommands();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [botId]);
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
if (!botId || !cmdName.trim() || !cmdServer) return;
try {
const cmd = await createCommand(botId, cmdServer, cmdName.trim(), cmdDesc.trim());
setCommands((prev) => [...prev, cmd]);
setCmdName('');
setCmdDesc('');
setCmdServer('');
setShowAdd(false);
} catch {
// error in store
}
};
const handleDelete = async (cmdId: string) => {
if (!botId) return;
if (!window.confirm('Delete this command?')) return;
try {
await deleteCommand(botId, cmdId);
setCommands((prev) => prev.filter((c) => c.id !== cmdId));
} catch {
// error in store
}
};
const getServerName = (serverId: string) => {
const s = servers.find((sv) => sv.id === serverId);
return s ? s.name : serverId.slice(0, 8);
};
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'}
{'│ === SLASH COMMANDS === │\n'}
{'└──────────────────────────────────┘'}
</pre>
{bot && (
<p className="text-gb-fg-s font-mono text-sm mb-4">
BOT: <span className="text-gb-green">[{bot.name}]</span>
</p>
)}
{error && (
<p className="text-gb-red text-sm font-mono mb-4">ERR: {error}</p>
)}
{/* Add command form */}
<div className="mb-6">
{!showAdd ? (
<button
type="button"
onClick={() => setShowAdd(true)}
className="terminal-button"
>
[ADD COMMAND]
</button>
) : (
<form onSubmit={handleAdd} className="border border-gb-bg-t p-4 space-y-3">
<p className="text-gb-aqua font-mono text-sm">{'>'} NEW SLASH COMMAND</p>
<div>
<label className="block text-gb-fg-f mb-1 font-mono text-xs">SERVER:</label>
<select
value={cmdServer}
onChange={(e) => setCmdServer(e.target.value)}
className="terminal-input w-full"
>
<option value="">-- select server --</option>
{servers.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
<div>
<label className="block text-gb-fg-f mb-1 font-mono text-xs">COMMAND NAME:</label>
<input
type="text"
value={cmdName}
onChange={(e) => setCmdName(e.target.value.replace(/\s/g, '').slice(0, 32))}
maxLength={32}
className="terminal-input w-full"
placeholder="mycommand"
autoFocus
/>
<span className="text-gb-fg-f text-xs font-mono mt-1 block">
/{cmdName || 'command'}
</span>
</div>
<div>
<label className="block text-gb-fg-f mb-1 font-mono text-xs">DESCRIPTION:</label>
<input
type="text"
value={cmdDesc}
onChange={(e) => setCmdDesc(e.target.value.slice(0, 256))}
maxLength={256}
className="terminal-input w-full"
placeholder="what does this command do?"
/>
</div>
<div className="flex gap-2">
<button
type="submit"
className="terminal-button text-xs"
disabled={!cmdName.trim() || !cmdServer}
>
[SAVE]
</button>
<button
type="button"
onClick={() => { setShowAdd(false); setCmdName(''); setCmdDesc(''); setCmdServer(''); }}
className="text-gb-fg-f hover:text-gb-aqua font-mono text-xs"
>
[CANCEL]
</button>
</div>
</form>
)}
</div>
{/* Command list */}
{loading && (
<p className="text-gb-fg-f font-mono text-sm">[loading commands...]</p>
)}
{!loading && commands.length === 0 && (
<p className="text-gb-fg-f font-mono text-sm">[no commands registered]</p>
)}
<div className="space-y-2">
{commands.map((cmd) => (
<div
key={cmd.id}
className="border border-gb-bg-t p-3 flex items-start justify-between gap-3"
>
<div className="min-w-0">
<p className="text-gb-green font-mono text-sm">
/{cmd.name}
</p>
{cmd.description && (
<p className="text-gb-fg-f font-mono text-xs mt-1">{cmd.description}</p>
)}
<p className="text-gb-fg-f font-mono text-xs mt-1">
server: <span className="text-gb-aqua">{getServerName(cmd.server_id)}</span>
</p>
</div>
<button
type="button"
onClick={() => handleDelete(cmd.id)}
className="terminal-button text-xs hover:!text-gb-red shrink-0"
>
[DELETE]
</button>
</div>
))}
</div>
{/* Footer */}
<div className="mt-6 pt-4 border-t border-gb-bg-t flex justify-between">
<Link to="/bots" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
{'<'} [BACK TO BOTS]
</Link>
<Link to="/" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
[CHAT]
</Link>
</div>
</div>
</div>
</div>
);
}
+116
View File
@@ -0,0 +1,116 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useBotStore, type SlashCommand } from '../stores/bot.ts';
interface SlashCommandPopupProps {
serverId: string;
filter: string;
onSelect: (command: SlashCommand) => void;
onClose: () => void;
position?: { bottom: number; left: number };
}
export function SlashCommandPopup({
serverId,
filter,
onSelect,
onClose,
position,
}: SlashCommandPopupProps) {
const fetchServerCommands = useBotStore((s) => s.fetchServerCommands);
const [commands, setCommands] = useState<SlashCommand[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let cancelled = false;
fetchServerCommands(serverId).then((cmds) => {
if (!cancelled) setCommands(cmds);
});
return () => { cancelled = true; };
}, [serverId, fetchServerCommands]);
const filtered = commands
.filter((c) => c.name.toLowerCase().includes(filter.toLowerCase()))
.slice(0, 8);
useEffect(() => {
setSelectedIndex(0);
}, [filter]);
const handleSelect = useCallback(
(cmd: SlashCommand) => {
onSelect(cmd);
},
[onSelect]
);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (filtered.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prev) => Math.min(prev + 1, filtered.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((prev) => Math.max(prev - 1, 0));
} else if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
if (filtered[selectedIndex]) {
handleSelect(filtered[selectedIndex]);
}
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [filtered, selectedIndex, handleSelect, onClose]);
if (filtered.length === 0) return null;
return (
<div
ref={listRef}
className="absolute bg-gb-bg border border-gb-bg-t shadow-lg z-50 min-w-[240px] max-h-[200px] overflow-y-auto"
style={{
bottom: position?.bottom ?? 48,
left: position?.left ?? 16,
}}
>
<div className="px-2 py-1 text-xs text-gb-fg-f font-mono border-b border-gb-bg-t">
SLASH COMMANDS
</div>
{filtered.map((cmd, index) => (
<button
key={cmd.id}
onClick={() => handleSelect(cmd)}
onMouseEnter={() => setSelectedIndex(index)}
className={`
w-full px-2 py-1 text-left text-xs font-mono flex items-center gap-2
transition-colors
${
index === selectedIndex
? 'bg-gb-orange text-gb-bg'
: 'text-gb-fg hover:bg-gb-bg-s'
}
`}
>
<span className="text-gb-green">/</span>
<span className="font-bold">{cmd.name}</span>
{cmd.description && (
<span
className={`truncate ${
index === selectedIndex ? 'text-gb-bg-t' : 'text-gb-fg-f'
}`}
>
{cmd.description}
</span>
)}
</button>
))}
</div>
);
}
+200
View File
@@ -0,0 +1,200 @@
import { create } from 'zustand';
import { api } from '../lib/api.ts';
export interface Bot {
id: string;
name: string;
avatar: string | null;
description: string;
owner_id: string;
created_at: string;
}
export interface SlashCommand {
id: string;
bot_id: string;
server_id: string;
name: string;
description: string;
}
interface BotState {
bots: Bot[];
loading: boolean;
error: string | null;
fetchBots: () => Promise<void>;
createBot: (name: string, description: string) => Promise<Bot & { token: string }>;
updateBot: (id: string, data: { name?: string; description?: string; avatar?: string }) => Promise<Bot>;
deleteBot: (id: string) => Promise<void>;
addToServer: (botId: string, serverId: string) => Promise<void>;
removeFromServer: (botId: string, serverId: string) => Promise<void>;
regenerateToken: (id: string) => Promise<{ token: string }>;
fetchCommands: (botId: string) => Promise<SlashCommand[]>;
createCommand: (botId: string, serverId: string, name: string, description: string) => Promise<SlashCommand>;
deleteCommand: (botId: string, commandId: string) => Promise<void>;
fetchServerCommands: (serverId: string) => Promise<SlashCommand[]>;
}
export const useBotStore = create<BotState>((set) => ({
bots: [],
loading: false,
error: null,
fetchBots: async () => {
set({ loading: true, error: null });
try {
const bots = await api.get<Bot[]>('/bots');
set({ bots, loading: false });
} catch (error) {
set({
loading: false,
error: error instanceof Error ? error.message : 'Failed to fetch bots',
});
}
},
createBot: async (name, description) => {
set({ loading: true, error: null });
try {
const result = await api.post<Bot & { token: string }>('/bots', { name, description });
set((state) => ({
bots: [...state.bots, result],
loading: false,
}));
return result;
} catch (error) {
set({
loading: false,
error: error instanceof Error ? error.message : 'Failed to create bot',
});
throw error;
}
},
updateBot: async (id, data) => {
set({ loading: true, error: null });
try {
const bot = await api.patch<Bot>(`/bots/${id}`, data);
set((state) => ({
bots: state.bots.map((b) => (b.id === id ? bot : b)),
loading: false,
}));
return bot;
} catch (error) {
set({
loading: false,
error: error instanceof Error ? error.message : 'Failed to update bot',
});
throw error;
}
},
deleteBot: async (id) => {
set({ loading: true, error: null });
try {
await api.delete(`/bots/${id}`);
set((state) => ({
bots: state.bots.filter((b) => b.id !== id),
loading: false,
}));
} catch (error) {
set({
loading: false,
error: error instanceof Error ? error.message : 'Failed to delete bot',
});
throw error;
}
},
addToServer: async (botId, serverId) => {
set({ error: null });
try {
await api.post(`/bots/${botId}/servers`, { server_id: serverId });
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to add bot to server',
});
throw error;
}
},
removeFromServer: async (botId, serverId) => {
set({ error: null });
try {
await api.delete(`/bots/${botId}/servers/${serverId}`);
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to remove bot from server',
});
throw error;
}
},
regenerateToken: async (id) => {
set({ loading: true, error: null });
try {
const result = await api.post<{ token: string }>(`/bots/${id}/regenerate-token`);
set({ loading: false });
return result;
} catch (error) {
set({
loading: false,
error: error instanceof Error ? error.message : 'Failed to regenerate token',
});
throw error;
}
},
fetchCommands: async (botId) => {
try {
return await api.get<SlashCommand[]>(`/bots/${botId}/commands`);
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to fetch commands',
});
return [];
}
},
createCommand: async (botId, serverId, name, description) => {
set({ error: null });
try {
const cmd = await api.post<SlashCommand>(`/bots/${botId}/commands`, {
server_id: serverId,
name,
description,
});
return cmd;
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to create command',
});
throw error;
}
},
deleteCommand: async (botId, commandId) => {
set({ error: null });
try {
await api.delete(`/bots/${botId}/commands/${commandId}`);
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to delete command',
});
throw error;
}
},
fetchServerCommands: async (serverId) => {
try {
return await api.get<SlashCommand[]>(`/servers/${serverId}/commands`);
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to fetch server commands',
});
return [];
}
},
}));
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/components/ChannelList.tsx","./src/components/ChatArea.tsx","./src/components/EmojiPicker.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberList.tsx","./src/components/MentionPopup.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/ServerBar.tsx","./src/components/ThemeToggle.tsx","./src/components/TypingIndicator.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/stores/auth.ts","./src/stores/channel.ts","./src/stores/message.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/server.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}
{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/ChannelList.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/EmojiPicker.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberList.tsx","./src/components/MentionPopup.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/ServerBar.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/TypingIndicator.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/message.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/server.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}