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
+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>
);
}