Files
gogobee/internal/plugin/xp.go
prosolis 9c6ded13fa Add forex plugin, stability fixes, and async HTTP dispatch
- Add forex plugin (Frankfurter v2 API) with rate lookups, analysis,
  DM-based alerts, and daily cron poll. Backfills 1 year of history
  on startup for moving averages and buy signal scoring.

- Fix bot hang caused by SQLite lock contention in reminder polling:
  rows cursor was held open while writing to the same DB. Collect
  results first, close cursor, then process. Same fix in milkcarton.

- Add sync retry loop so the bot reconnects after network drops
  instead of silently exiting. StopSync() for clean Ctrl+C shutdown.

- Add panic recovery to all dispatch, syncer, and cron paths.

- Make all HTTP-calling plugin commands async (goroutines) so a slow
  or dead external API cannot block the message dispatch pipeline.
  Affects: lookup, stocks, forex, anime, movies, concerts, gaming,
  retro, wotd, urls, howami.

- Extract DisplayName to Base, add db.Exec helper, convert silent
  error discards across the codebase.

- Fix UNO mercy-kill bug (eliminated bot continues playing), adventure
  DM nag spam, stats column mismatch, per-call regex/replacer allocs.

- Update README: forex commands, Finance section, 47 plugins.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 09:22:02 -07:00

258 lines
6.9 KiB
Go

package plugin
import (
"database/sql"
"fmt"
"log/slog"
"math/rand"
"strings"
"sync"
"time"
"gogobee/internal/db"
"gogobee/internal/util"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
)
// levelUpMessages are Twinbee/Parodius themed congratulations.
var levelUpMessages = []string{
"Power-up collected! %s just reached Level %d! 🔔",
"Twinbee's bell is ringing! %s ascended to Level %d! 🛎️",
"Parodius would be proud — %s hit Level %d! 🐙",
"Stage clear! %s leveled up to Level %d! ⭐",
"Winbee sends sparkles — %s is now Level %d! ✨",
"Bonus round complete! %s reached Level %d! 🎰",
"GwinBee detected a power surge — %s is Level %d! ⚡",
"The Vic Viper salutes %s for reaching Level %d! 🚀",
"Takosuke is dancing! %s just became Level %d! 🎶",
"A shower of bells for %s — Level %d unlocked! 🔔🔔🔔",
"Pastel confirms: %s has broken through to Level %d! 🌈",
"The Shooting Star squadron welcomes %s to Level %d! 💫",
}
// XPPlugin awards XP for messages and tracks levels.
type XPPlugin struct {
Base
mu sync.Mutex
cooldowns map[id.UserID]time.Time
}
// NewXPPlugin creates a new XP plugin.
func NewXPPlugin(client *mautrix.Client) *XPPlugin {
return &XPPlugin{
Base: NewBase(client),
cooldowns: make(map[id.UserID]time.Time),
}
}
func (p *XPPlugin) Name() string { return "xp" }
func (p *XPPlugin) Commands() []CommandDef {
return []CommandDef{
{Name: "rank", Description: "Show your level, XP, and progress", Usage: "!rank [@user]", Category: "Leveling & Stats"},
{Name: "leaderboard", Description: "Show top 10 users by XP", Usage: "!leaderboard", Category: "Leveling & Stats"},
}
}
func (p *XPPlugin) Init() error { return nil }
func (p *XPPlugin) OnReaction(_ ReactionContext) error { return nil }
func (p *XPPlugin) OnMessage(ctx MessageContext) error {
// Handle commands
if p.IsCommand(ctx.Body, "rank") {
return p.handleRank(ctx)
}
if p.IsCommand(ctx.Body, "leaderboard") {
return p.handleLeaderboard(ctx)
}
// Skip XP for commands
if ctx.IsCommand {
return nil
}
// Passive XP with cooldown
p.mu.Lock()
last, ok := p.cooldowns[ctx.Sender]
now := time.Now().UTC()
if ok && now.Sub(last) < 30*time.Second {
p.mu.Unlock()
return nil
}
p.cooldowns[ctx.Sender] = now
// Periodically clean up expired cooldowns to prevent unbounded growth
if len(p.cooldowns) > 1000 {
for uid, t := range p.cooldowns {
if now.Sub(t) > time.Minute {
delete(p.cooldowns, uid)
}
}
}
p.mu.Unlock()
_, leveledUp, newLevel := p.grantXPInternal(ctx.Sender, 10, "message")
if leveledUp {
msg := levelUpMessages[rand.Intn(len(levelUpMessages))]
announcement := fmt.Sprintf(msg, string(ctx.Sender), newLevel)
if err := p.SendMessage(ctx.RoomID, announcement); err != nil {
slog.Error("failed to send level-up announcement", "err", err)
}
}
return nil
}
// GrantXP awards XP to a user from an external plugin and returns (newXP, leveledUp, newLevel).
func (p *XPPlugin) GrantXP(userID id.UserID, amount int, reason string) (int, bool, int) {
return p.grantXPInternal(userID, amount, reason)
}
func (p *XPPlugin) grantXPInternal(userID id.UserID, amount int, reason string) (int, bool, int) {
d := db.Get()
now := time.Now().UTC().Unix()
// Ensure user row exists
_, err := d.Exec(
`INSERT INTO users (user_id, xp, level, last_xp_at) VALUES (?, 0, 0, 0)
ON CONFLICT(user_id) DO NOTHING`, string(userID))
if err != nil {
slog.Error("xp: ensure user", "err", err)
return 0, false, 0
}
// Get current state
var oldXP, oldLevel int
err = d.QueryRow(`SELECT xp, level FROM users WHERE user_id = ?`, string(userID)).Scan(&oldXP, &oldLevel)
if err != nil {
slog.Error("xp: read user", "err", err)
return 0, false, 0
}
newXP := oldXP + amount
newLevel := util.LevelFromXP(newXP)
leveledUp := newLevel > oldLevel
_, err = d.Exec(
`UPDATE users SET xp = ?, level = ?, last_xp_at = ? WHERE user_id = ?`,
newXP, newLevel, now, string(userID))
if err != nil {
slog.Error("xp: update user", "err", err)
return oldXP, false, oldLevel
}
// Log the XP grant
db.Exec("xp: log grant",
`INSERT INTO xp_log (user_id, amount, reason) VALUES (?, ?, ?)`,
string(userID), amount, reason)
return newXP, leveledUp, newLevel
}
func (p *XPPlugin) handleRank(ctx MessageContext) error {
target := ctx.Sender
args := p.GetArgs(ctx.Body, "rank")
if args != "" {
if resolved, ok := p.ResolveUser(args, ctx.RoomID); ok {
target = resolved
}
}
d := db.Get()
var xp, level int
err := d.QueryRow(`SELECT xp, level FROM users WHERE user_id = ?`, string(target)).Scan(&xp, &level)
if err == sql.ErrNoRows {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("%s hasn't earned any XP yet.", string(target)))
}
if err != nil {
slog.Error("xp: rank query", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to look up rank.")
}
nextLevel := level + 1
xpForNext := util.XPForLevel(nextLevel)
xpForCurrent := util.XPForLevel(level)
progress := xp - xpForCurrent
needed := xpForNext - xpForCurrent
bar := util.ProgressBar(progress, needed, 20)
// Get rank position (users with same XP share the same rank)
var rank int
err = d.QueryRow(`SELECT COUNT(DISTINCT xp) + 1 FROM users WHERE xp > ?`, xp).Scan(&rank)
if err != nil {
rank = 0
}
msg := fmt.Sprintf(
"📊 %s — Level %d (Rank #%s)\nXP: %s / %s\n%s",
string(target), level, formatNumber(rank),
formatNumber(xp), formatNumber(xpForNext), bar,
)
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
}
func (p *XPPlugin) handleLeaderboard(ctx MessageContext) error {
members := p.RoomMembers(ctx.RoomID)
d := db.Get()
rows, err := d.Query(`SELECT user_id, xp, level FROM users ORDER BY xp DESC`)
if err != nil {
slog.Error("xp: leaderboard query", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load leaderboard.")
}
defer rows.Close()
var sb strings.Builder
sb.WriteString("🏆 XP Leaderboard — Top 10\n\n")
medals := []string{"🥇", "🥈", "🥉"}
i := 0
for rows.Next() && i < 10 {
var userID string
var xp, level int
if err := rows.Scan(&userID, &xp, &level); 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 — Level %d (%s XP)\n", prefix, userID, level, formatNumber(xp)))
i++
}
if i == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, "No XP data yet.")
}
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
}
// formatNumber adds commas to an integer for display.
func formatNumber(n int) string {
if n < 0 {
return "-" + formatNumber(-n)
}
s := fmt.Sprintf("%d", n)
if len(s) <= 3 {
return s
}
var result strings.Builder
remainder := len(s) % 3
if remainder > 0 {
result.WriteString(s[:remainder])
}
for i := remainder; i < len(s); i += 3 {
if result.Len() > 0 {
result.WriteByte(',')
}
result.WriteString(s[i : i+3])
}
return result.String()
}