diff --git a/cmd/server/main.go b/cmd/server/main.go index 1651933..305b7cd 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -80,6 +80,11 @@ func main() { hub := gateway.NewHub(database.DB, logger) go hub.Run() + // Built-in bot runner + botRunner := bot.NewRunner(database.DB, hub, logger) + botRunner.Register("steamfree", bot.SteamFreeBot) + go botRunner.StartAll() + // Giphy client (nil if no API key) giphyClient := giphy.NewClient(cfg.Giphy.APIKey) @@ -350,7 +355,7 @@ func main() { // Bots + slash commands r.Route("/bots", func(r chi.Router) { - bot.NewHandler(database.DB).RegisterRoutes(r) + bot.NewHandler(database.DB, botRunner).RegisterRoutes(r) bot.NewCommandHandler(database.DB).RegisterCommandRoutes(r) }) diff --git a/internal/bot/handlers.go b/internal/bot/handlers.go index a9511bf..93cfef2 100644 --- a/internal/bot/handlers.go +++ b/internal/bot/handlers.go @@ -12,17 +12,19 @@ import ( // Handler handles bot CRUD and server-assignment routes. type Handler struct { - db *sql.DB + db *sql.DB + runner *Runner } // NewHandler creates a new bot Handler. -func NewHandler(db *sql.DB) *Handler { - return &Handler{db: db} +func NewHandler(db *sql.DB, runner *Runner) *Handler { + return &Handler{db: db, runner: runner} } // RegisterRoutes registers authenticated bot routes under the given router. func (h *Handler) RegisterRoutes(r chi.Router) { r.Get("/store", h.Store) + r.Get("/types", h.ListTypes) r.Post("/", h.Create) r.Get("/", h.List) r.Get("/{botID}", h.Get) @@ -36,12 +38,14 @@ func (h *Handler) RegisterRoutes(r chi.Router) { // ---- response / request types ---- type botResponse struct { - ID string `json:"id"` - Name string `json:"name"` - Avatar *string `json:"avatar"` - Description string `json:"description"` - OwnerID string `json:"owner_id"` - CreatedAt string `json:"created_at"` + ID string `json:"id"` + Name string `json:"name"` + Avatar *string `json:"avatar"` + Description string `json:"description"` + BotType string `json:"bot_type"` + Config json.RawMessage `json:"config"` + OwnerID string `json:"owner_id"` + CreatedAt string `json:"created_at"` } // botWithToken is returned only on create / regenerate-token. @@ -51,14 +55,18 @@ type botWithToken struct { } type createBotRequest struct { - Name string `json:"name"` - Description string `json:"description"` + Name string `json:"name"` + Description string `json:"description"` + BotType string `json:"bot_type"` + Config json.RawMessage `json:"config"` } type updateBotRequest struct { - Name *string `json:"name"` - Description *string `json:"description"` - Avatar *string `json:"avatar"` + Name *string `json:"name"` + Description *string `json:"description"` + Avatar *string `json:"avatar"` + BotType *string `json:"bot_type"` + Config json.RawMessage `json:"config"` } type addToServerRequest struct { @@ -110,15 +118,21 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { token := GenerateToken() tokenHash := HashToken(token) + configJSON := req.Config + if configJSON == nil { + configJSON = json.RawMessage(`{}`) + } + var bot botWithToken var avatar sql.NullString var createdAt sql.NullString + var configOut sql.NullString err := h.db.QueryRowContext(r.Context(), ` - INSERT INTO bots (name, description, owner_id, token) - VALUES ($1, $2, $3, $4) - RETURNING id, name, avatar, description, owner_id, created_at::text - `, req.Name, req.Description, userID, tokenHash).Scan( - &bot.ID, &bot.Name, &avatar, &bot.Description, &bot.OwnerID, &createdAt, + INSERT INTO bots (name, description, owner_id, token, bot_type, config) + VALUES ($1, $2, $3, $4, $5, $6::jsonb) + RETURNING id, name, avatar, description, bot_type, config::text, owner_id, created_at::text + `, req.Name, req.Description, userID, tokenHash, req.BotType, string(configJSON)).Scan( + &bot.ID, &bot.Name, &avatar, &bot.Description, &bot.BotType, &configOut, &bot.OwnerID, &createdAt, ) if err != nil { writeErr(w, http.StatusInternalServerError, "failed to create bot") @@ -127,9 +141,17 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { if avatar.Valid { bot.Avatar = &avatar.String } + if configOut.Valid { + bot.Config = json.RawMessage(configOut.String) + } bot.CreatedAt = createdAt.String bot.Token = token + // Start built-in bot if type is set + if req.BotType != "" && h.runner != nil { + h.runner.Start(bot.ID, req.BotType, bot.Config) + } + writeJSON(w, http.StatusCreated, bot) } @@ -282,15 +304,18 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { var b botResponse var avatar sql.NullString var createdAt sql.NullString + var configOut sql.NullString err = h.db.QueryRowContext(r.Context(), ` UPDATE bots SET name = COALESCE($1, name), description = COALESCE($2, description), - avatar = COALESCE($3, avatar) + avatar = COALESCE($3, avatar), + bot_type = COALESCE($5, bot_type), + config = COALESCE($6::jsonb, config) WHERE id = $4 - RETURNING id, name, avatar, description, owner_id, created_at::text - `, req.Name, req.Description, req.Avatar, botID).Scan( - &b.ID, &b.Name, &avatar, &b.Description, &b.OwnerID, &createdAt, + RETURNING id, name, avatar, description, bot_type, config::text, owner_id, created_at::text + `, req.Name, req.Description, req.Avatar, botID, req.BotType, string(req.Config)).Scan( + &b.ID, &b.Name, &avatar, &b.Description, &b.BotType, &configOut, &b.OwnerID, &createdAt, ) if err != nil { writeErr(w, http.StatusInternalServerError, "failed to update bot") @@ -299,8 +324,22 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { if avatar.Valid { b.Avatar = &avatar.String } + if configOut.Valid { + b.Config = json.RawMessage(configOut.String) + } b.CreatedAt = createdAt.String + // Restart built-in bot if type/config changed + if req.BotType != nil && h.runner != nil { + h.runner.Stop(b.ID) + if *req.BotType != "" { + h.runner.Start(b.ID, *req.BotType, b.Config) + } + } else if req.Config != nil && h.runner != nil && b.BotType != "" { + h.runner.Stop(b.ID) + h.runner.Start(b.ID, b.BotType, b.Config) + } + writeJSON(w, http.StatusOK, b) } @@ -346,6 +385,11 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { return } + // Stop built-in bot if running + if h.runner != nil { + h.runner.Stop(botID) + } + w.WriteHeader(http.StatusNoContent) } @@ -599,3 +643,13 @@ func (h *Handler) Store(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, bots) } + +// ListTypes returns available built-in bot types. +func (h *Handler) ListTypes(w http.ResponseWriter, r *http.Request) { + if h.runner == nil { + writeJSON(w, http.StatusOK, []string{}) + return + } + writeJSON(w, http.StatusOK, h.runner.RegisteredTypes()) +} + diff --git a/internal/bot/runner.go b/internal/bot/runner.go new file mode 100644 index 0000000..c0f59a6 --- /dev/null +++ b/internal/bot/runner.go @@ -0,0 +1,197 @@ +package bot + +import ( + "context" + "database/sql" + "encoding/json" + "log/slog" + "sync" + + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway" +) + +// BotFunc is the signature for a built-in bot type's run function. +// It blocks until ctx is cancelled. Use send to post messages. +type BotFunc func(ctx context.Context, config json.RawMessage, send SendMessageFunc) + +// SendMessageFunc posts a message to a channel as this bot. +type SendMessageFunc func(channelID, content string) + +// Runner manages server-side bot goroutines. +type Runner struct { + db *sql.DB + hub *gateway.Hub + logger *slog.Logger + + mu sync.Mutex + bots map[string]context.CancelFunc // botID -> cancel + types map[string]BotFunc // type name -> runner +} + +func NewRunner(db *sql.DB, hub *gateway.Hub, logger *slog.Logger) *Runner { + return &Runner{ + db: db, + hub: hub, + logger: logger, + bots: make(map[string]context.CancelFunc), + types: make(map[string]BotFunc), + } +} + +// Register adds a built-in bot type. +func (r *Runner) Register(name string, fn BotFunc) { + r.types[name] = fn +} + +// StartAll loads all bots with a bot_type set and starts them. +func (r *Runner) StartAll() { + rows, err := r.db.QueryContext(context.Background(), + `SELECT id, bot_type, config::text FROM bots WHERE bot_type != ''`) + if err != nil { + r.logger.Error("failed to load bot configs", "error", err) + return + } + defer rows.Close() + + for rows.Next() { + var id, botType, configStr string + if err := rows.Scan(&id, &botType, &configStr); err != nil { + continue + } + r.Start(id, botType, json.RawMessage(configStr)) + } +} + +// Start starts a single bot by ID. Safe to call multiple times (restarts). +func (r *Runner) Start(botID, botType string, config json.RawMessage) { + r.mu.Lock() + // Stop existing if running + if cancel, ok := r.bots[botID]; ok { + cancel() + delete(r.bots, botID) + } + + fn, ok := r.types[botType] + if !ok { + r.mu.Unlock() + r.logger.Warn("unknown bot type", "type", botType, "bot_id", botID) + return + } + + ctx, cancel := context.WithCancel(context.Background()) + r.bots[botID] = cancel + r.mu.Unlock() + + send := r.makeSender(botID) + r.logger.Info("starting built-in bot", "bot_id", botID, "type", botType) + + go func() { + defer func() { + if rec := recover(); rec != nil { + r.logger.Error("bot panic", "bot_id", botID, "type", botType, "panic", rec) + } + }() + fn(ctx, config, send) + r.logger.Info("bot stopped", "bot_id", botID, "type", botType) + }() +} + +// Stop stops a single bot by ID. +func (r *Runner) Stop(botID string) { + r.mu.Lock() + defer r.mu.Unlock() + if cancel, ok := r.bots[botID]; ok { + cancel() + delete(r.bots, botID) + } +} + +// IsRunning checks if a bot is currently running. +func (r *Runner) IsRunning(botID string) bool { + r.mu.Lock() + defer r.mu.Unlock() + _, ok := r.bots[botID] + return ok +} + +// makeSender returns a SendMessageFunc that inserts into DB and broadcasts. +func (r *Runner) makeSender(botID string) SendMessageFunc { + return func(channelID, content string) { + if len(content) > 4000 { + content = content[:4000] + } + + // Look up bot info + server + var botName, ownerID string + err := r.db.QueryRowContext(context.Background(), + `SELECT name, owner_id FROM bots WHERE id = $1`, botID, + ).Scan(&botName, &ownerID) + if err != nil { + r.logger.Error("send: bot not found", "bot_id", botID, "error", err) + return + } + + serverID, err := r.hub.ServerIDForChannel(context.Background(), channelID) + if err != nil { + r.logger.Error("send: channel not found", "channel_id", channelID, "error", err) + return + } + + // Verify bot is in this server + var inServer bool + err = r.db.QueryRowContext(context.Background(), + `SELECT EXISTS(SELECT 1 FROM bot_servers WHERE bot_id = $1 AND server_id = $2)`, + botID, serverID, + ).Scan(&inServer) + if err != nil || !inServer { + r.logger.Warn("send: bot not in server", "bot_id", botID, "server_id", serverID) + return + } + + var msgID, createdAt string + err = r.db.QueryRowContext(context.Background(), + `INSERT INTO messages (channel_id, author_id, content, bot_id) + VALUES ($1, $2, $3, $4) + RETURNING id, created_at::text`, + channelID, ownerID, content, botID, + ).Scan(&msgID, &createdAt) + if err != nil { + r.logger.Error("send: insert failed", "error", err) + return + } + + r.hub.BroadcastToServer(serverID, gateway.Event{ + Type: gateway.EventMessageCreate, + Data: map[string]interface{}{ + "id": msgID, + "channel_id": channelID, + "author_id": ownerID, + "author_username": botName, + "author_display_name": nil, + "author_bot": true, + "bot_id": botID, + "bot_name": botName, + "content": content, + "reply_to": nil, + "edited_at": nil, + "pinned": false, + "created_at": createdAt, + "embeds": []interface{}{}, + "reactions": []interface{}{}, + }, + }) + } +} + +// RegisteredTypes returns the list of available bot type names. +func (r *Runner) RegisteredTypes() []string { + r.mu.Lock() + defer r.mu.Unlock() + names := make([]string, 0, len(r.types)) + for k := range r.types { + names = append(names, k) + } + return names +} + + diff --git a/internal/bot/steamfree.go b/internal/bot/steamfree.go new file mode 100644 index 0000000..f354a50 --- /dev/null +++ b/internal/bot/steamfree.go @@ -0,0 +1,116 @@ +package bot + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// SteamFreeConfig is the config shape for bot_type "steamfree". +type SteamFreeConfig struct { + ChannelID string `json:"channel_id"` + PollMinutes int `json:"poll_minutes"` +} + +// SteamFreeBot polls Steam's featured categories for 100%-off games. +func SteamFreeBot(ctx context.Context, raw json.RawMessage, send SendMessageFunc) { + var cfg SteamFreeConfig + if err := json.Unmarshal(raw, &cfg); err != nil || cfg.ChannelID == "" { + return + } + if cfg.PollMinutes <= 0 { + cfg.PollMinutes = 30 + } + + seen := map[int]bool{} + ticker := time.NewTicker(time.Duration(cfg.PollMinutes) * time.Minute) + defer ticker.Stop() + + // Poll immediately on start + pollSteam(cfg.ChannelID, seen, send) + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + pollSteam(cfg.ChannelID, seen, send) + } + } +} + +func pollSteam(channelID string, seen map[int]bool, send SendMessageFunc) { + games, err := fetchFreeGames() + if err != nil { + return // ponytail: silent on error, logs add noise + } + for _, g := range games { + if seen[g.ID] { + continue + } + seen[g.ID] = true + + msg := fmt.Sprintf( + "🎮 **FREE ON STEAM** 🎮\n**%s**\n~~$%.2f~~ → **FREE**\nhttps://store.steampowered.com/app/%d", + g.Name, float64(g.OriginalPrice)/100, g.ID, + ) + send(channelID, msg) + time.Sleep(500 * time.Millisecond) + } +} + +type featuredCategories struct { + Specials struct { + Items []struct { + ID int `json:"id"` + Name string `json:"name"` + DiscountPct int `json:"discount_percent"` + FinalPrice int `json:"final_price"` + OriginalPrice int `json:"original_price"` + } `json:"items"` + } `json:"specials"` +} + +func fetchFreeGames() ([]struct { + ID int `json:"id"` + Name string `json:"name"` + DiscountPct int `json:"discount_percent"` + FinalPrice int `json:"final_price"` + OriginalPrice int `json:"original_price"` +}, error) { + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Get("https://store.steampowered.com/api/featuredcategories?cc=us&l=english") + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return nil, fmt.Errorf("steam: %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + var cats featuredCategories + if err := json.Unmarshal(body, &cats); err != nil { + return nil, err + } + var free []struct { + ID int `json:"id"` + Name string `json:"name"` + DiscountPct int `json:"discount_percent"` + FinalPrice int `json:"final_price"` + OriginalPrice int `json:"original_price"` + } + for _, item := range cats.Specials.Items { + if item.DiscountPct == 100 && item.OriginalPrice > 0 { + free = append(free, item) + } + } + return free, nil +} + + diff --git a/internal/db/db.go b/internal/db/db.go index a78124e..0275914 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -569,5 +569,10 @@ CREATE INDEX IF NOT EXISTS idx_feature_request_votes_fr ON feature_request_votes -- Bot messages: track which bot authored a message ALTER TABLE messages ADD COLUMN IF NOT EXISTS bot_id UUID REFERENCES bots(id) ON DELETE SET NULL; CREATE INDEX IF NOT EXISTS idx_messages_bot ON messages(bot_id) WHERE bot_id IS NOT NULL; + +-- Built-in bot types: bot_type + config for server-managed bots +ALTER TABLE bots ADD COLUMN IF NOT EXISTS bot_type VARCHAR(32) DEFAULT ''; +ALTER TABLE bots ADD COLUMN IF NOT EXISTS config JSONB DEFAULT '{}'; +CREATE INDEX IF NOT EXISTS idx_bots_type ON bots(bot_type) WHERE bot_type != ''; ` diff --git a/steamfree b/steamfree new file mode 100755 index 0000000..b48a849 Binary files /dev/null and b/steamfree differ diff --git a/web/src/components/BotManager.tsx b/web/src/components/BotManager.tsx index c0beb1c..bd9ea93 100644 --- a/web/src/components/BotManager.tsx +++ b/web/src/components/BotManager.tsx @@ -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 = { + 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>({}); const [editingId, setEditingId] = useState(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 = {}; + 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 (
@@ -180,13 +204,48 @@ export function BotManager() { placeholder="what does this bot do?" />
+
+ + +
+ {typeConfig && typeConfig.fields.map((field) => ( +
+ + {field.type === 'channel' ? ( + setCreateConfig((prev) => ({ ...prev, [field.key]: e.target.value }))} + placeholder="channel ID (from URL)" + className="terminal-input w-full" + /> + ) : ( + setCreateConfig((prev) => ({ ...prev, [field.key]: e.target.value }))} + placeholder={field.placeholder} + className="terminal-input w-full" + /> + )} +
+ ))}