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, db to read. type BotFunc func(ctx context.Context, db *sql.DB, 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() // Built-in bots pick a channel in config but users often skip "Add to Server". // Resolve channel_id → server and ensure bot_servers so send() doesn't no-op. r.ensureBotServerFromConfig(botID, config) 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, r.db, config, send) r.logger.Info("bot stopped", "bot_id", botID, "type", botType) }() } // ensureBotServerFromConfig joins the bot to the server that owns config.channel_id. func (r *Runner) ensureBotServerFromConfig(botID string, config json.RawMessage) { var cfg struct { ChannelID string `json:"channel_id"` } if err := json.Unmarshal(config, &cfg); err != nil || cfg.ChannelID == "" { return } serverID, err := r.hub.ServerIDForChannel(context.Background(), cfg.ChannelID) if err != nil || serverID == "" { r.logger.Warn("bot start: channel not found for auto-join", "bot_id", botID, "channel_id", cfg.ChannelID, "error", err) return } var ownerID string if err := r.db.QueryRowContext(context.Background(), `SELECT owner_id FROM bots WHERE id = $1`, botID, ).Scan(&ownerID); err != nil { return } if _, err := r.db.ExecContext(context.Background(), ` INSERT INTO bot_servers (bot_id, server_id, added_by) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING `, botID, serverID, ownerID); err != nil { r.logger.Warn("bot start: auto-join server failed", "bot_id", botID, "server_id", serverID, "error", err) return } r.logger.Info("bot auto-joined server", "bot_id", botID, "server_id", serverID) } // 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 }