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:
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user