From 13bd4478f6aac8a8fbf210cdcdc4859b869c7b69 Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Wed, 15 Jul 2026 20:17:23 -0400 Subject: [PATCH] fix(bots): auto-join server from channel_id so built-ins can post MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: makeSender requires bot_servers membership, but create flow never auto-added bots when users only picked a channel. - Start() resolves config.channel_id → server and upserts bot_servers - Confess cursor uses (created_at,id) so deletes don't stall polling --- internal/bot/confess.go | 75 +++++++++++++++++++++++++++-------------- internal/bot/runner.go | 34 +++++++++++++++++++ 2 files changed, 84 insertions(+), 25 deletions(-) diff --git a/internal/bot/confess.go b/internal/bot/confess.go index fe7fb42..08d15b3 100644 --- a/internal/bot/confess.go +++ b/internal/bot/confess.go @@ -21,7 +21,11 @@ func ConfessBot(ctx context.Context, db *sql.DB, raw json.RawMessage, send SendM return } - lastID := "" + // Cursor is (created_at, id) in memory so deletes don't break polling. + var lastAt time.Time + var lastID string + seeded := false + ticker := time.NewTicker(10 * time.Second) defer ticker.Stop() @@ -30,50 +34,71 @@ func ConfessBot(ctx context.Context, db *sql.DB, raw json.RawMessage, send SendM case <-ctx.Done(): return case <-ticker.C: - confessPoll(db, cfg.ChannelID, &lastID, send) + confessPoll(db, cfg.ChannelID, &seeded, &lastAt, &lastID, send) } } } -func confessPoll(db *sql.DB, targetChannel string, lastID *string, send SendMessageFunc) { - q := `SELECT id, channel_id, content FROM messages - WHERE content ILIKE '/confess%' AND bot_id IS NULL - ORDER BY created_at ASC LIMIT 10` - if *lastID != "" { - q = `SELECT id, channel_id, content FROM messages - WHERE content ILIKE '/confess%' AND bot_id IS NULL AND created_at::text > (SELECT created_at::text FROM messages WHERE id = $1) - ORDER BY created_at ASC LIMIT 10` +func confessPoll(db *sql.DB, targetChannel string, seeded *bool, lastAt *time.Time, lastID *string, send SendMessageFunc) { + if !*seeded { + var at sql.NullTime + var id sql.NullString + _ = db.QueryRowContext(context.Background(), + `SELECT created_at, id::text FROM messages ORDER BY created_at DESC, id DESC LIMIT 1`, + ).Scan(&at, &id) + if at.Valid { + *lastAt = at.Time + } + if id.Valid { + *lastID = id.String + } + *seeded = true + return } - var rows *sql.Rows - var err error - if *lastID != "" { - rows, err = db.QueryContext(context.Background(), q, *lastID) - } else { - rows, err = db.QueryContext(context.Background(), q) - } + rows, err := db.QueryContext(context.Background(), ` + SELECT m.id::text, m.created_at, m.content + FROM messages m + WHERE m.bot_id IS NULL + AND lower(m.content) LIKE '/confess%' + AND (m.created_at, m.id) > ($1::timestamptz, $2::uuid) + ORDER BY m.created_at ASC, m.id ASC + LIMIT 20 + `, *lastAt, nullUUID(*lastID)) if err != nil { return } defer rows.Close() for rows.Next() { - var id, channelID, content string - if err := rows.Scan(&id, &channelID, &content); err != nil { + var id string + var createdAt time.Time + var content string + if err := rows.Scan(&id, &createdAt, &content); err != nil { continue } + *lastAt = createdAt *lastID = id - // Strip "/confess" prefix - text := strings.TrimSpace(strings.TrimPrefix(content, "/confess")) + rest := content + if len(rest) >= 8 && strings.EqualFold(rest[:8], "/confess") { + rest = rest[8:] + } + text := strings.TrimSpace(rest) if text == "" { continue } - // Delete the original message - db.ExecContext(context.Background(), `DELETE FROM messages WHERE id = $1`, id) - - // Repost anonymously + // Delete original so author isn't exposed in chat history. + _, _ = db.ExecContext(context.Background(), `DELETE FROM messages WHERE id = $1::uuid`, id) send(targetChannel, fmt.Sprintf("🕵️ **anonymous confession:** %s", text)) } } + +// nullUUID returns a zero UUID when empty so the first post-seed poll still works. +func nullUUID(id string) string { + if id == "" { + return "00000000-0000-0000-0000-000000000000" + } + return id +} diff --git a/internal/bot/runner.go b/internal/bot/runner.go index 04cfebe..ff54efd 100644 --- a/internal/bot/runner.go +++ b/internal/bot/runner.go @@ -82,6 +82,10 @@ func (r *Runner) Start(botID, botType string, config json.RawMessage) { 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) @@ -96,6 +100,36 @@ func (r *Runner) Start(botID, botType string, config json.RawMessage) { }() } +// 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()