package plugin import ( "database/sql" "fmt" "log/slog" "strings" "time" "gogobee/internal/db" "gogobee/internal/util" "maunium.net/go/mautrix" "maunium.net/go/mautrix/id" ) var milestoneThresholds = []int{ 1_000, 5_000, 10_000, 25_000, 50_000, 100_000, 250_000, 500_000, 1_000_000, } // StatsPlugin passively tracks message statistics and provides query commands. type StatsPlugin struct { Base } // NewStatsPlugin creates a new stats plugin. func NewStatsPlugin(client *mautrix.Client) *StatsPlugin { return &StatsPlugin{ Base: NewBase(client), } } func (p *StatsPlugin) Name() string { return "stats" } func (p *StatsPlugin) Version() string { return "1.1.0" } func (p *StatsPlugin) Commands() []CommandDef { return []CommandDef{ {Name: "stats", Description: "Show message statistics for a user", Usage: "!stats [@user]", Category: "Leveling & Stats"}, {Name: "rankings", Description: "Show rankings for a stat category", Usage: "!rankings [words|links|questions|emojis]", Category: "Leveling & Stats"}, {Name: "personality", Description: "Show your chat personality archetype", Usage: "!personality", Category: "Leveling & Stats"}, {Name: "superstatsexplusalpha", Description: "Everything we know about you", Usage: "!superstatsexplusalpha [@user]", Category: "Leveling & Stats"}, } } func (p *StatsPlugin) Init() error { return nil } func (p *StatsPlugin) OnReaction(_ ReactionContext) error { return nil } func (p *StatsPlugin) OnMessage(ctx MessageContext) error { // Handle commands first if p.IsCommand(ctx.Body, "stats") { return p.handleStats(ctx) } if p.IsCommand(ctx.Body, "rankings") { return p.handleRankings(ctx) } if p.IsCommand(ctx.Body, "personality") { return p.handlePersonality(ctx) } if p.IsCommand(ctx.Body, "superstatsexplusalpha") { return p.handleSuperStats(ctx) } // Skip tracking for bot commands if ctx.IsCommand { return nil } // Passive: track message stats return p.trackMessage(ctx) } func (p *StatsPlugin) trackMessage(ctx MessageContext) error { stats := util.ParseMessage(ctx.Body) d := db.Get() now := time.Now().UTC() // Determine time-of-day buckets hour := now.Hour() nightIncr := 0 morningIncr := 0 if hour >= 0 && hour < 6 { nightIncr = 1 } else if hour >= 6 && hour < 12 { morningIncr = 1 } _, err := d.Exec( `INSERT INTO user_stats (user_id, total_messages, total_words, total_chars, total_links, total_images, total_questions, total_exclamations, total_emojis, night_messages, morning_messages, updated_at) VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET total_messages = total_messages + 1, total_words = total_words + ?, total_chars = total_chars + ?, total_links = total_links + ?, total_images = total_images + ?, total_questions = total_questions + ?, total_exclamations = total_exclamations + ?, total_emojis = total_emojis + ?, night_messages = night_messages + ?, morning_messages = morning_messages + ?, updated_at = ?`, string(ctx.Sender), stats.Words, stats.Chars, stats.Links, stats.Images, stats.Questions, stats.Exclamations, stats.Emojis, nightIncr, morningIncr, now.Unix(), // ON CONFLICT values stats.Words, stats.Chars, stats.Links, stats.Images, stats.Questions, stats.Exclamations, stats.Emojis, nightIncr, morningIncr, now.Unix(), ) if err != nil { slog.Error("stats: track message", "err", err) return nil // Don't fail the message pipeline } // Room milestone tracking p.checkRoomMilestone(ctx) return nil } func (p *StatsPlugin) checkRoomMilestone(ctx MessageContext) { d := db.Get() // Upsert room message count _, err := d.Exec( `INSERT INTO room_milestones (room_id, total_messages, last_milestone) VALUES (?, 1, 0) ON CONFLICT(room_id) DO UPDATE SET total_messages = total_messages + 1`, string(ctx.RoomID), ) if err != nil { slog.Error("stats: room milestone upsert", "err", err) return } var totalMessages, lastMilestone int err = d.QueryRow( `SELECT total_messages, last_milestone FROM room_milestones WHERE room_id = ?`, string(ctx.RoomID), ).Scan(&totalMessages, &lastMilestone) if err != nil { slog.Error("stats: room milestone read", "err", err) return } // Check if we crossed a new milestone for _, threshold := range milestoneThresholds { if totalMessages >= threshold && lastMilestone < threshold { _, err = d.Exec( `UPDATE room_milestones SET last_milestone = ? WHERE room_id = ?`, threshold, string(ctx.RoomID), ) if err != nil { slog.Error("stats: update milestone", "err", err) return } msg := fmt.Sprintf("๐ŸŽ‰ This room just hit %s messages! Keep the conversation going!", formatNumber(threshold)) if err := p.SendMessage(ctx.RoomID, msg); err != nil { slog.Error("stats: milestone announcement", "err", err) } return // Only announce one milestone at a time } } } func (p *StatsPlugin) handleStats(ctx MessageContext) error { target := ctx.Sender args := p.GetArgs(ctx.Body, "stats") if args != "" { if resolved, ok := p.ResolveUser(args, ctx.RoomID); ok { target = resolved } } d := db.Get() var totalMsg, totalWords, totalChars, totalLinks, totalImages int var totalQuestions, totalExclamations, totalEmojis, nightMsg, morningMsg int err := d.QueryRow( `SELECT total_messages, total_words, total_chars, total_links, total_images, total_questions, total_exclamations, total_emojis, night_messages, morning_messages FROM user_stats WHERE user_id = ?`, string(target), ).Scan(&totalMsg, &totalWords, &totalChars, &totalLinks, &totalImages, &totalQuestions, &totalExclamations, &totalEmojis, &nightMsg, &morningMsg) if err == sql.ErrNoRows { return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No stats found for %s.", string(target))) } if err != nil { slog.Error("stats: query", "err", err) return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to look up stats.") } avgWords := 0 if totalMsg > 0 { avgWords = totalWords / totalMsg } var taxPaid int _ = d.QueryRow(`SELECT total_paid FROM tax_ledger WHERE user_id = ?`, string(target)).Scan(&taxPaid) msg := fmt.Sprintf( "๐Ÿ“Š Stats for %s\n"+ "Messages: %s | Words: %s | Chars: %s\n"+ "Links: %s | Images: %s | Emojis: %s\n"+ "Questions: %s | Exclamations: %s\n"+ "Night msgs (00-06): %s | Morning msgs (06-12): %s\n"+ "Avg words/msg: %d", string(target), formatNumber(totalMsg), formatNumber(totalWords), formatNumber(totalChars), formatNumber(totalLinks), formatNumber(totalImages), formatNumber(totalEmojis), formatNumber(totalQuestions), formatNumber(totalExclamations), formatNumber(nightMsg), formatNumber(morningMsg), avgWords, ) if taxPaid > 0 { msg += fmt.Sprintf("\n๐Ÿ›๏ธ Community tax paid: %s", fmtEuro(taxPaid)) } return p.SendReply(ctx.RoomID, ctx.EventID, msg) } func (p *StatsPlugin) handleRankings(ctx MessageContext) error { args := strings.ToLower(strings.TrimSpace(p.GetArgs(ctx.Body, "rankings"))) column := "total_words" label := "Words" switch args { case "links": column = "total_links" label = "Links" case "questions": column = "total_questions" label = "Questions" case "emojis": column = "total_emojis" label = "Emojis" case "words", "": column = "total_words" label = "Words" default: return p.SendReply(ctx.RoomID, ctx.EventID, "Valid categories: words, links, questions, emojis") } members := p.RoomMembers(ctx.RoomID) d := db.Get() // Using fmt.Sprintf for column name is safe here since we control the value above query := fmt.Sprintf( `SELECT user_id, %s FROM user_stats WHERE %s > 0 ORDER BY %s DESC`, column, column, column, ) rows, err := d.Query(query) if err != nil { slog.Error("stats: rankings query", "err", err) return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load rankings.") } defer rows.Close() var sb strings.Builder sb.WriteString(fmt.Sprintf("๐Ÿ“ˆ %s Rankings โ€” Top 10\n\n", label)) medals := []string{"๐Ÿฅ‡", "๐Ÿฅˆ", "๐Ÿฅ‰"} i := 0 for rows.Next() && i < 10 { var userID string var val int if err := rows.Scan(&userID, &val); err != nil { continue } if members != nil && !members[id.UserID(userID)] { continue } prefix := fmt.Sprintf("#%d", i+1) if i < len(medals) { prefix = medals[i] } sb.WriteString(fmt.Sprintf("%s %s โ€” %s %s\n", prefix, userID, formatNumber(val), strings.ToLower(label))) i++ } if i == 0 { return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No %s data yet.", strings.ToLower(label))) } return p.SendReply(ctx.RoomID, ctx.EventID, sb.String()) } func (p *StatsPlugin) handlePersonality(ctx MessageContext) error { archetypes := GetUserArchetypesLimited(string(ctx.Sender)) var totalMsg int db.Get().QueryRow(`SELECT total_messages FROM user_stats WHERE user_id = ?`, string(ctx.Sender)).Scan(&totalMsg) if totalMsg == 0 { return p.SendReply(ctx.RoomID, ctx.EventID, "Not enough data to determine your personality yet. Keep chatting!") } msg := fmt.Sprintf("๐Ÿง  %s, your chat personality:\n\n%s\n\nBased on %s messages analyzed.", string(ctx.Sender), FormatArchetypesFull(archetypes), formatNumber(totalMsg)) return p.SendReply(ctx.RoomID, ctx.EventID, msg) } func (p *StatsPlugin) handleSuperStats(ctx MessageContext) error { target := ctx.Sender args := p.GetArgs(ctx.Body, "superstatsexplusalpha") if args != "" { if resolved, ok := p.ResolveUser(args, ctx.RoomID); ok { target = resolved } } d := db.Get() uid := string(target) displayName := p.DisplayName(target) var sb strings.Builder sb.WriteString(fmt.Sprintf("๐Ÿ“‹ **SUPER STATS EX+ฮฑ โ€” %s**\n", displayName)) sb.WriteString("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•\n\n") // โ”€โ”€ Level & Economy โ”€โ”€ var xp, level int var balance float64 _ = d.QueryRow(`SELECT xp, level FROM users WHERE user_id = ?`, uid).Scan(&xp, &level) _ = d.QueryRow(`SELECT balance FROM euro_balances WHERE user_id = ?`, uid).Scan(&balance) var achievementCount int _ = d.QueryRow(`SELECT COUNT(*) FROM achievements WHERE user_id = ?`, uid).Scan(&achievementCount) var taxPaid int _ = d.QueryRow(`SELECT total_paid FROM tax_ledger WHERE user_id = ?`, uid).Scan(&taxPaid) taxLine := "" if taxPaid > 0 { taxLine = fmt.Sprintf(" ยท ๐Ÿ›๏ธ %s tax paid", fmtEuro(taxPaid)) } sb.WriteString(fmt.Sprintf("โญ **Level %d** ยท %s XP ยท ๐Ÿ’ฐ โ‚ฌ%.0f ยท ๐Ÿ… %d achievements%s\n\n", level, formatNumber(xp), balance, achievementCount, taxLine)) // โ”€โ”€ Chat Stats โ”€โ”€ var totalMsg, totalWords, totalLinks, totalEmojis, totalQuestions int err := d.QueryRow( `SELECT total_messages, total_words, total_links, total_emojis, total_questions FROM user_stats WHERE user_id = ?`, uid, ).Scan(&totalMsg, &totalWords, &totalLinks, &totalEmojis, &totalQuestions) if err == nil && totalMsg > 0 { sb.WriteString(fmt.Sprintf("๐Ÿ’ฌ **Chat:** %s msgs ยท %s words ยท %s links ยท %s emoji ยท %s questions\n", formatNumber(totalMsg), formatNumber(totalWords), formatNumber(totalLinks), formatNumber(totalEmojis), formatNumber(totalQuestions))) // Personality (from cached archetypes) archetypes := GetUserArchetypesLimited(uid) sb.WriteString(fmt.Sprintf(" Personality: **%s**\n", FormatArchetypeNames(archetypes))) } // โ”€โ”€ Sentiment โ”€โ”€ var positive, negative, neutral int err = d.QueryRow( `SELECT COALESCE(positive,0), COALESCE(negative,0), COALESCE(neutral,0) FROM sentiment_stats WHERE user_id = ?`, uid, ).Scan(&positive, &negative, &neutral) if err == nil { total := positive + negative + neutral if total > 0 { sb.WriteString(fmt.Sprintf(" Sentiment: +%d / -%d / ~%d", positive, negative, neutral)) pct := float64(positive) / float64(total) * 100 if pct >= 60 { sb.WriteString(" ๐Ÿ˜Š") } else if float64(negative)/float64(total)*100 >= 40 { sb.WriteString(" ๐Ÿ˜ค") } sb.WriteString("\n") } } // โ”€โ”€ Potty Mouth โ”€โ”€ var mild, moderate, scorching int err = d.QueryRow( `SELECT COALESCE(mild,0), COALESCE(moderate,0), COALESCE(scorching,0) FROM potty_mouth WHERE user_id = ?`, uid, ).Scan(&mild, &moderate, &scorching) if err == nil && (mild+moderate+scorching) > 0 { sb.WriteString(fmt.Sprintf(" Potty mouth: ๐Ÿคฌ %d mild ยท %d moderate ยท %d scorching\n", mild, moderate, scorching)) } sb.WriteString("\n") // โ”€โ”€ Adventure โ”€โ”€ var miningLv, fishingLv, forageLv, miningXP, fishingXP, forageXP int var alive bool var streak, bestStreak int err = d.QueryRow( `SELECT mining_skill, fishing_skill, foraging_skill, mining_xp, fishing_xp, foraging_xp, alive, current_streak, best_streak FROM player_meta WHERE user_id = ?`, uid, ).Scan(&miningLv, &fishingLv, &forageLv, &miningXP, &fishingXP, &forageXP, &alive, &streak, &bestStreak) if err == nil { status := "Alive" if !alive { status = "๐Ÿ’€ Dead" } sb.WriteString(fmt.Sprintf("โš”๏ธ **Adventure:** %s\n", status)) sb.WriteString(fmt.Sprintf(" Mining Lv.%d (%d XP) ยท Fishing Lv.%d (%d XP) ยท Forage Lv.%d (%d XP)\n", miningLv, miningXP, fishingLv, fishingXP, forageLv, forageXP)) if streak > 0 || bestStreak > 0 { sb.WriteString(fmt.Sprintf(" ๐Ÿ”ฅ Streak: %d days (best: %d)\n", streak, bestStreak)) } // Equipment score (use canonical scoring function) equip, eqErr := loadAdvEquipment(id.UserID(uid)) if eqErr == nil && len(equip) > 0 { sb.WriteString(fmt.Sprintf(" Equipment score: %.1f\n", advEquipmentScore(equip))) } sb.WriteString("\n") } // โ”€โ”€ Game Records โ”€โ”€ sb.WriteString("๐ŸŽฎ **Games:**\n") hasGames := false // Holdem var hPlayed int var hWon, hLost, hBiggest int64 err = d.QueryRow( `SELECT hands_played, total_won, total_lost, biggest_pot FROM holdem_scores WHERE user_id = ?`, uid, ).Scan(&hPlayed, &hWon, &hLost, &hBiggest) if err == nil && hPlayed > 0 { net := hWon - hLost sign := "+" if net < 0 { sign = "" } sb.WriteString(fmt.Sprintf(" ๐Ÿƒ Holdem: %d hands ยท %sโ‚ฌ%d net ยท biggest pot โ‚ฌ%d\n", hPlayed, sign, net, hBiggest)) hasGames = true } // Blackjack var bjPlayed, bjWon int var bjEarned float64 err = d.QueryRow( `SELECT games_played, games_won, total_earned FROM blackjack_scores WHERE user_id = ?`, uid, ).Scan(&bjPlayed, &bjWon, &bjEarned) if err == nil && bjPlayed > 0 { sb.WriteString(fmt.Sprintf(" ๐Ÿ‚ก Blackjack: %d/%d W/L ยท โ‚ฌ%.0f earned\n", bjWon, bjPlayed-bjWon, bjEarned)) hasGames = true } // Hangman var hmPlayed, hmWon int var hmEarned float64 err = d.QueryRow( `SELECT games_played, games_won, total_earned FROM hangman_scores WHERE user_id = ?`, uid, ).Scan(&hmPlayed, &hmWon, &hmEarned) if err == nil && hmPlayed > 0 { sb.WriteString(fmt.Sprintf(" ๐Ÿ“ Hangman: %d/%d W/L ยท โ‚ฌ%.0f earned\n", hmWon, hmPlayed-hmWon, hmEarned)) hasGames = true } // Wordle var wPlayed, wSolved, wGuesses, wEarned int err = d.QueryRow( `SELECT puzzles_played, puzzles_solved, total_guesses, total_earned FROM wordle_stats WHERE user_id = ?`, uid, ).Scan(&wPlayed, &wSolved, &wGuesses, &wEarned) if err == nil && wPlayed > 0 { avg := float64(wGuesses) / float64(wPlayed) sb.WriteString(fmt.Sprintf(" ๐ŸŸฉ Wordle: %d/%d solved ยท %.1f avg guesses ยท โ‚ฌ%d earned\n", wSolved, wPlayed, avg, wEarned)) hasGames = true } // Trivia var tCorrect, tWrong int var tFastest int64 err = d.QueryRow( `SELECT COALESCE(SUM(correct),0), COALESCE(SUM(wrong),0), COALESCE(MIN(fastest_ms),0) FROM trivia_scores WHERE user_id = ?`, uid, ).Scan(&tCorrect, &tWrong, &tFastest) if (tCorrect + tWrong) > 0 { fastStr := "" if tFastest > 0 { fastStr = fmt.Sprintf(" ยท fastest: %.1fs", float64(tFastest)/1000) } pct := float64(tCorrect) / float64(tCorrect+tWrong) * 100 sb.WriteString(fmt.Sprintf(" ๐Ÿง  Trivia: %d/%d correct (%.0f%%)%s\n", tCorrect, tCorrect+tWrong, pct, fastStr)) hasGames = true } // UNO (single + multi combined) var unoSingleWins, unoSingleTotal int _ = d.QueryRow( `SELECT COUNT(*), COALESCE(SUM(CASE WHEN result = 'win' THEN 1 ELSE 0 END), 0) FROM uno_games WHERE player_id = ?`, uid, ).Scan(&unoSingleTotal, &unoSingleWins) var unoSingleEarned float64 _ = d.QueryRow( `SELECT COALESCE(SUM(CASE WHEN result IN ('player_win','sudden_death_player') THEN (pot_before - pot_after) - wager ELSE -wager END), 0) FROM uno_games WHERE player_id = ?`, uid, ).Scan(&unoSingleEarned) var unoMultiWins, unoMultiTotal int _ = d.QueryRow( `SELECT COUNT(*), COALESCE(SUM(CASE WHEN winner_id = ? THEN 1 ELSE 0 END), 0) FROM uno_multi_games WHERE player_ids LIKE ?`, uid, "%"+uid+"%", ).Scan(&unoMultiTotal, &unoMultiWins) var unoMultiEarned float64 _ = d.QueryRow( `SELECT COALESCE(SUM(CASE WHEN winner_id = ? THEN pot_total - ante ELSE -ante END), 0) FROM uno_multi_games WHERE player_ids LIKE ?`, uid, "%"+uid+"%", ).Scan(&unoMultiEarned) unoTotal := unoSingleTotal + unoMultiTotal unoWins := unoSingleWins + unoMultiWins unoEarned := unoSingleEarned + unoMultiEarned if unoTotal > 0 { sign := "+" if unoEarned < 0 { sign = "" } sb.WriteString(fmt.Sprintf(" ๐ŸŽด UNO: %d/%d W/L ยท %sโ‚ฌ%.0f net\n", unoWins, unoTotal-unoWins, sign, unoEarned)) hasGames = true } if !hasGames { sb.WriteString(" No game records yet.\n") } // โ”€โ”€ Commands โ”€โ”€ var cmdCount int _ = d.QueryRow(`SELECT COALESCE(SUM(count), 0) FROM command_usage WHERE user_id = ?`, uid).Scan(&cmdCount) if cmdCount > 0 { sb.WriteString(fmt.Sprintf("\nโŒจ๏ธ %s commands used\n", formatNumber(cmdCount))) } // โ”€โ”€ Account Age โ”€โ”€ var createdAt time.Time err = d.QueryRow(`SELECT created_at FROM users WHERE user_id = ?`, uid).Scan(&createdAt) if err == nil { days := int(time.Since(createdAt).Hours() / 24) sb.WriteString(fmt.Sprintf("๐Ÿ“… Member for %d days\n", days)) } return p.SendMessage(ctx.RoomID, sb.String()) }