diff --git a/cmd/server/main.go b/cmd/server/main.go index 305b7cd..c69e23b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -83,6 +83,8 @@ func main() { // Built-in bot runner botRunner := bot.NewRunner(database.DB, hub, logger) botRunner.Register("steamfree", bot.SteamFreeBot) + botRunner.Register("confess", bot.ConfessBot) + botRunner.Register("leaderboard", bot.LeaderboardBot) go botRunner.StartAll() // Giphy client (nil if no API key) diff --git a/internal/bot/confess.go b/internal/bot/confess.go new file mode 100644 index 0000000..fe7fb42 --- /dev/null +++ b/internal/bot/confess.go @@ -0,0 +1,79 @@ +package bot + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "strings" + "time" +) + +// ConfessConfig: "channel_id" is where anonymous confessions land. +type ConfessConfig struct { + ChannelID string `json:"channel_id"` +} + +// ConfessBot watches for /confess messages, deletes the original, reposts anonymously. +func ConfessBot(ctx context.Context, db *sql.DB, raw json.RawMessage, send SendMessageFunc) { + var cfg ConfessConfig + if err := json.Unmarshal(raw, &cfg); err != nil || cfg.ChannelID == "" { + return + } + + lastID := "" + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + confessPoll(db, cfg.ChannelID, &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` + } + + 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) + } + if err != nil { + return + } + defer rows.Close() + + for rows.Next() { + var id, channelID, content string + if err := rows.Scan(&id, &channelID, &content); err != nil { + continue + } + *lastID = id + + // Strip "/confess" prefix + text := strings.TrimSpace(strings.TrimPrefix(content, "/confess")) + if text == "" { + continue + } + + // Delete the original message + db.ExecContext(context.Background(), `DELETE FROM messages WHERE id = $1`, id) + + // Repost anonymously + send(targetChannel, fmt.Sprintf("🕵️ **anonymous confession:** %s", text)) + } +} diff --git a/internal/bot/leaderboard.go b/internal/bot/leaderboard.go new file mode 100644 index 0000000..c3db598 --- /dev/null +++ b/internal/bot/leaderboard.go @@ -0,0 +1,85 @@ +package bot + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "strings" + "time" +) + +// LeaderboardConfig: "channel_id" is where the daily recap lands. +type LeaderboardConfig struct { + ChannelID string `json:"channel_id"` +} + +// LeaderboardBot posts a daily shitpost recap. +func LeaderboardBot(ctx context.Context, db *sql.DB, raw json.RawMessage, send SendMessageFunc) { + var cfg LeaderboardConfig + if err := json.Unmarshal(raw, &cfg); err != nil || cfg.ChannelID == "" { + return + } + + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + + // Post immediately on start, then daily + postLeaderboard(db, cfg.ChannelID, send) + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + postLeaderboard(db, cfg.ChannelID, send) + } + } +} + +func postLeaderboard(db *sql.DB, channelID string, send SendMessageFunc) { + // ponytail: global lock on count. per-user aggregates if throughput matters. + since := time.Now().Add(-24 * time.Hour) + + rows, err := db.QueryContext(context.Background(), ` + SELECT u.username, COUNT(*) AS msg_count + FROM messages m + JOIN users u ON m.author_id = u.id + WHERE m.created_at > $1 AND m.bot_id IS NULL + GROUP BY u.username + ORDER BY msg_count DESC + LIMIT 10 + `, since) + if err != nil { + return + } + defer rows.Close() + + var b strings.Builder + b.WriteString("🏆 **24H SHITPOST LEADERBOARD** 🏆\n") + + rank := 1 + for rows.Next() { + var username string + var count int + if err := rows.Scan(&username, &count); err != nil { + continue + } + medal := "" + switch rank { + case 1: + medal = "🥇" + case 2: + medal = "🥈" + case 3: + medal = "🥉" + } + fmt.Fprintf(&b, "%s #%d **%s** — %d msgs\n", medal, rank, username, count) + rank++ + } + if rank == 1 { + b.WriteString("*crickets*\n") + } + + send(channelID, strings.TrimSpace(b.String())) +} diff --git a/internal/bot/runner.go b/internal/bot/runner.go index c0f59a6..04cfebe 100644 --- a/internal/bot/runner.go +++ b/internal/bot/runner.go @@ -11,8 +11,8 @@ import ( ) // 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) +// 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) @@ -91,7 +91,7 @@ func (r *Runner) Start(botID, botType string, config json.RawMessage) { r.logger.Error("bot panic", "bot_id", botID, "type", botType, "panic", rec) } }() - fn(ctx, config, send) + fn(ctx, r.db, config, send) r.logger.Info("bot stopped", "bot_id", botID, "type", botType) }() } diff --git a/internal/bot/steamfree.go b/internal/bot/steamfree.go index f354a50..671de16 100644 --- a/internal/bot/steamfree.go +++ b/internal/bot/steamfree.go @@ -2,6 +2,7 @@ package bot import ( "context" + "database/sql" "encoding/json" "fmt" "io" @@ -16,7 +17,7 @@ type SteamFreeConfig struct { } // SteamFreeBot polls Steam's featured categories for 100%-off games. -func SteamFreeBot(ctx context.Context, raw json.RawMessage, send SendMessageFunc) { +func SteamFreeBot(ctx context.Context, _ *sql.DB, raw json.RawMessage, send SendMessageFunc) { var cfg SteamFreeConfig if err := json.Unmarshal(raw, &cfg); err != nil || cfg.ChannelID == "" { return diff --git a/web/src/components/BotManager.tsx b/web/src/components/BotManager.tsx index 5e6a787..561cf71 100644 --- a/web/src/components/BotManager.tsx +++ b/web/src/components/BotManager.tsx @@ -13,6 +13,18 @@ const BOT_TYPE_CONFIGS: Record