mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
New features: - !tarot and !tarotspread commands with LLM-powered readings (zodiac-aware) - !quote system with AES-256-GCM encryption at rest, reply-to-save, search, and quoteboard - Shared crypto utility (internal/crypto) for AES-256-GCM encrypt/decrypt/HMAC - ResolveUser now matches display names via room membership as fallback - !vibe and !tldr show bot uptime when insufficient messages buffered Bug fixes: - Fix duplicate quotes table schema that would crash quotewall at runtime - Fix biased random selection in quote retrieval (use rand.Intn) - Fix XP rank calculation to handle tied scores with COUNT(DISTINCT xp) - Scope all leaderboards to room members (repboard, pottyboard, insultboard, firstboard, rankings, emojiboard) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
261 lines
7.0 KiB
Go
261 lines
7.0 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
|
|
_, err = d.Exec(
|
|
`INSERT INTO xp_log (user_id, amount, reason) VALUES (?, ?, ?)`,
|
|
string(userID), amount, reason)
|
|
if err != nil {
|
|
slog.Error("xp: log grant", "err", err)
|
|
}
|
|
|
|
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()
|
|
}
|