feat(bots): built-in bot runner + steamfree from UI

- BotRunner: server-side goroutine manager for built-in bot types
- steamfree bot embedded in server (polls Steam API, posts free games)
- bot_type + config JSONB columns on bots table
- Create/Update/Delete handlers manage runner lifecycle
- GET /bots/types returns registered bot types
- BotManager: type selector dropdown + config fields on create
- No SSH needed: create a 'Steam Free Games' bot from /bots/manage
This commit is contained in:
2026-07-15 14:14:02 -04:00
parent c839e67c47
commit 4b32655e67
8 changed files with 488 additions and 30 deletions
+64 -2
View File
@@ -3,6 +3,17 @@ import { Link } from 'react-router-dom';
import { useBotStore, type Bot } from '../stores/bot.ts';
import { useServerStore } from '../stores/server.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);
@@ -20,6 +31,8 @@ export function BotManager() {
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 [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const [editDesc, setEditDesc] = useState('');
@@ -37,10 +50,19 @@ export function BotManager() {
e.preventDefault();
if (!createName.trim()) return;
try {
const result = await createBot(createName.trim(), createDesc.trim());
// 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({});
setShowCreate(false);
} catch {
// error handled in store
@@ -102,6 +124,8 @@ export function BotManager() {
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">
@@ -180,13 +204,48 @@ export function BotManager() {
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' ? (
<input
type="text"
value={createConfig[field.key] || ''}
onChange={(e) => setCreateConfig((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder="channel ID (from URL)"
className="terminal-input w-full"
/>
) : (
<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(''); }}
onClick={() => { setShowCreate(false); setCreateName(''); setCreateDesc(''); setCreateType(''); setCreateConfig({}); }}
className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm"
>
[CANCEL]
@@ -255,6 +314,9 @@ export function BotManager() {
/>
)}
[{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">
+23 -4
View File
@@ -6,6 +6,8 @@ export interface Bot {
name: string;
avatar: string | null;
description: string;
bot_type: string;
config: Record<string, unknown>;
owner_id: string;
created_at: string;
}
@@ -31,13 +33,15 @@ export interface StoreBot {
interface BotState {
bots: Bot[];
storeBots: StoreBot[];
botTypes: string[];
loading: boolean;
error: string | null;
fetchBots: () => Promise<void>;
fetchStoreBots: () => Promise<void>;
createBot: (name: string, description: string) => Promise<Bot & { token: string }>;
updateBot: (id: string, data: { name?: string; description?: string; avatar?: string }) => Promise<Bot>;
fetchBotTypes: () => Promise<void>;
createBot: (name: string, description: string, botType?: string, config?: Record<string, unknown>) => Promise<Bot & { token: string }>;
updateBot: (id: string, data: { name?: string; description?: string; avatar?: string; bot_type?: string; config?: Record<string, unknown> }) => Promise<Bot>;
deleteBot: (id: string) => Promise<void>;
addToServer: (botId: string, serverId: string) => Promise<void>;
removeFromServer: (botId: string, serverId: string) => Promise<void>;
@@ -52,6 +56,7 @@ interface BotState {
export const useBotStore = create<BotState>((set) => ({
bots: [],
storeBots: [],
botTypes: [],
loading: false,
error: null,
@@ -81,10 +86,24 @@ export const useBotStore = create<BotState>((set) => ({
}
},
createBot: async (name, description) => {
fetchBotTypes: async () => {
try {
const types = await api.get<string[]>('/bots/types');
set({ botTypes: types });
} catch {
// silent
}
},
createBot: async (name, description, botType, config) => {
set({ loading: true, error: null });
try {
const result = await api.post<Bot & { token: string }>('/bots', { name, description });
const result = await api.post<Bot & { token: string }>('/bots', {
name,
description,
bot_type: botType || '',
config: config || {},
});
set((state) => ({
bots: [...state.bots, result],
loading: false,