Rewrite from TypeScript to Go

Complete rewrite of the Freebee Matrix bot as GogoBee using mautrix-go.

- E2EE with goolm (pure Go, no CGo/libolm) and cross-signing bootstrap
- 35+ plugins with dependency injection and ordered registration
- SQLite storage via modernc.org/sqlite (no CGo)
- Scheduler via robfig/cron for WOTD, holidays, birthdays, releases, etc.
- Optional LLM integration (Ollama) for sentiment, profanity, roasts, vibes
- Threaded trivia, three-tier profanity tracking, WOTD usage verification
- Multi-country holiday support with deduplication
- Quadratic XP curve, configurable bot display name, encrypted DMs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-03-08 18:25:18 -07:00
parent 07c8674fe0
commit ddb196aaad
91 changed files with 12865 additions and 13909 deletions

257
internal/plugin/xp.go Normal file
View File

@@ -0,0 +1,257 @@
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 != "" {
// Trim optional @ prefix and whitespace
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
if cleaned != "" {
target = id.UserID(cleaned)
}
}
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
var rank int
err = d.QueryRow(`SELECT COUNT(*) + 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 {
d := db.Get()
rows, err := d.Query(`SELECT user_id, xp, level FROM users ORDER BY xp DESC LIMIT 10`)
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() {
var userID string
var xp, level int
if err := rows.Scan(&userID, &xp, &level); err != nil {
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()
}