package plugin import ( "database/sql" "errors" "fmt" "log/slog" "math" "os" "regexp" "strings" "sync" "time" "gogobee/internal/db" "maunium.net/go/mautrix" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" ) // fmtEuro formats a currency value with thousand separators, e.g. "€12,500". func fmtEuro[T int | int64 | float64](v T) string { n := int64(v) if n < 0 { return "-" + fmtEuro(-n) } s := fmt.Sprintf("%d", n) if len(s) <= 3 { return "€" + s } var out []byte for i, c := range s { if i > 0 && (len(s)-i)%3 == 0 { out = append(out, ',') } out = append(out, byte(c)) } return "€" + string(out) } // gamesRoom returns the configured GAMES_ROOM, or empty if unset. func gamesRoom() id.RoomID { return id.RoomID(os.Getenv("GAMES_ROOM")) } // isGamesRoom checks whether the given room is the games room. // Returns true if no games room is configured (unrestricted). func isGamesRoom(roomID id.RoomID) bool { gr := gamesRoom() return gr == "" || roomID == gr } // --------------------------------------------------------------------------- // Euro config // --------------------------------------------------------------------------- type euroConfig struct { CooldownSeconds int DebtReminder bool StartingCap float64 } func loadEuroConfig() euroConfig { return euroConfig{ CooldownSeconds: envInt("EURO_COOLDOWN_SECONDS", 30), DebtReminder: envOrDefault("EURO_DEBT_REMINDER", "true") == "true", StartingCap: envFloat("EURO_STARTING_CAP", 2500), } } // --------------------------------------------------------------------------- // Combo system — activity streaks that multiply passive euro earning. // // Three independent combo categories: messages, links, images. // Each combo increments with qualifying activity and resets after 10 min idle. // Multiplier: 1 + (combo_step * 2.0) — so step 1 = 3x, step 5 = 11x, etc. // Daily cap per category prevents unbounded farming. // --------------------------------------------------------------------------- const ( comboTimeout = 10 * time.Minute comboMsgCap = 50 comboLinkCap = 10 comboImageCap = 15 comboMultPerStep = 2.0 // +200% per step comboLinkBase = 5.0 comboImageBase = 3.0 ) type comboState struct { Step int LastAt time.Time Today string DayHits int } var urlPattern = regexp.MustCompile(`https?://\S+`) // --------------------------------------------------------------------------- // Euro Plugin // --------------------------------------------------------------------------- type EuroPlugin struct { Base cfg euroConfig cooldowns map[id.UserID]time.Time combos map[id.UserID]map[string]*comboState // category → state mu sync.Mutex } func NewEuroPlugin(client *mautrix.Client) *EuroPlugin { return &EuroPlugin{ Base: NewBase(client), cfg: loadEuroConfig(), cooldowns: make(map[id.UserID]time.Time), combos: make(map[id.UserID]map[string]*comboState), } } func (p *EuroPlugin) Name() string { return "euro" } func (p *EuroPlugin) Version() string { return "1.1.0" } func (p *EuroPlugin) Commands() []CommandDef { return []CommandDef{ {Name: "balance", Description: "Check your euro balance", Usage: "!balance", Category: "Economy"}, {Name: "baltop", Description: "Euro leaderboard", Usage: "!baltop", Category: "Economy"}, {Name: "baltransfer", Description: "Send euros to another player", Usage: "!baltransfer @user €amount", Category: "Economy"}, {Name: "combo", Description: "View your current activity combo streaks", Usage: "!combo", Category: "Economy"}, {Name: "eurogrant", Description: "Grant euros to a user (admin)", Usage: "!eurogrant @user amount [reason]", Category: "Admin", AdminOnly: true}, } } func (p *EuroPlugin) Init() error { return nil } func (p *EuroPlugin) OnReaction(_ ReactionContext) error { return nil } func (p *EuroPlugin) OnMessage(ctx MessageContext) error { // Passive euro earning (all rooms, not just games room) if !ctx.IsCommand { p.awardPassiveEuros(ctx) } switch { case p.IsCommand(ctx.Body, "balance"): return p.handleBalance(ctx) case p.IsCommand(ctx.Body, "baltop"): return p.handleBaltop(ctx) case p.IsCommand(ctx.Body, "baltransfer"): return p.handleTransfer(ctx) case p.IsCommand(ctx.Body, "combo"): return p.handleCombo(ctx) case p.IsCommand(ctx.Body, "eurogrant"): return p.handleGrant(ctx) } return nil } // --------------------------------------------------------------------------- // Passive earning // --------------------------------------------------------------------------- func (p *EuroPlugin) awardPassiveEuros(ctx MessageContext) { p.mu.Lock() last, ok := p.cooldowns[ctx.Sender] now := time.Now() if ok && now.Sub(last) < time.Duration(p.cfg.CooldownSeconds)*time.Second { p.mu.Unlock() return } p.cooldowns[ctx.Sender] = now if len(p.cooldowns) > 1000 { for uid, t := range p.cooldowns { if now.Sub(t) > time.Duration(p.cfg.CooldownSeconds)*time.Second { delete(p.cooldowns, uid) } } } p.mu.Unlock() p.ensureBalance(ctx.Sender) // Message combo — base amount from word count, multiplied by combo. words := len(strings.Fields(ctx.Body)) var baseMsg float64 switch { case words >= 51: baseMsg = 20.00 case words >= 26: baseMsg = 10.00 case words >= 11: baseMsg = 5.00 case words >= 4: baseMsg = 2.50 default: baseMsg = 1.00 } msgMult := p.advanceCombo(ctx.Sender, "message", comboMsgCap) p.credit(ctx.Sender, baseMsg*msgMult, "message") // Link combo — bonus per URL in the message. if urls := urlPattern.FindAllString(ctx.Body, -1); len(urls) > 0 { linkMult := p.advanceCombo(ctx.Sender, "link", comboLinkCap) p.credit(ctx.Sender, comboLinkBase*linkMult*float64(len(urls)), "link_combo") } // Image combo — bonus for image/video/file attachments. if mc := ctx.Event.Content.AsMessage(); mc != nil { if mc.MsgType == event.MsgImage || mc.MsgType == event.MsgVideo || mc.MsgType == event.MsgFile { imgMult := p.advanceCombo(ctx.Sender, "image", comboImageCap) p.credit(ctx.Sender, comboImageBase*imgMult, "image_combo") } } } // advanceCombo increments a user's combo for a category, resets if timed out // or daily cap reached, and returns the current multiplier. func (p *EuroPlugin) advanceCombo(userID id.UserID, category string, dailyCap int) float64 { p.mu.Lock() defer p.mu.Unlock() now := time.Now() today := now.UTC().Format("2006-01-02") userCombos, ok := p.combos[userID] if !ok { userCombos = make(map[string]*comboState) p.combos[userID] = userCombos } cs, ok := userCombos[category] if !ok { cs = &comboState{} userCombos[category] = cs } // Reset on new day. if cs.Today != today { cs.Step = 0 cs.DayHits = 0 cs.Today = today } // Reset if combo timed out. if !cs.LastAt.IsZero() && now.Sub(cs.LastAt) > comboTimeout { cs.Step = 0 } // At daily cap, return base multiplier (no combo bonus). if cs.DayHits >= dailyCap { return 1.0 } cs.DayHits++ cs.Step++ cs.LastAt = now return 1.0 + float64(cs.Step)*comboMultPerStep } // --------------------------------------------------------------------------- // !combo command // --------------------------------------------------------------------------- func (p *EuroPlugin) handleCombo(ctx MessageContext) error { p.mu.Lock() userCombos := p.combos[ctx.Sender] now := time.Now() today := now.UTC().Format("2006-01-02") type info struct { name string step int dayHits int cap int active bool } cats := []info{ {"Messages", 0, 0, comboMsgCap, false}, {"Links", 0, 0, comboLinkCap, false}, {"Images", 0, 0, comboImageCap, false}, } keys := []string{"message", "link", "image"} if userCombos != nil { for i, k := range keys { if cs, ok := userCombos[k]; ok && cs.Today == today { cats[i].step = cs.Step cats[i].dayHits = cs.DayHits cats[i].active = !cs.LastAt.IsZero() && now.Sub(cs.LastAt) < comboTimeout } } } p.mu.Unlock() var sb strings.Builder sb.WriteString("🔥 **Activity Combo**\n\n") for _, c := range cats { mult := 1.0 + float64(c.step)*comboMultPerStep status := "💤 inactive" if c.active { status = fmt.Sprintf("⚡ **%.0fx** multiplier", mult) } else if c.step > 0 { status = "⏳ expired (resets on next activity)" } sb.WriteString(fmt.Sprintf("**%s**: step %d/%d — %s\n", c.name, c.dayHits, c.cap, status)) } sb.WriteString(fmt.Sprintf("\nCombo grows +%.0f%% per step. Resets after %d min idle.", comboMultPerStep*100, int(comboTimeout.Minutes()))) return p.SendReply(ctx.RoomID, ctx.EventID, sb.String()) } // --------------------------------------------------------------------------- // Balance management // --------------------------------------------------------------------------- // ensureBalance creates a balance row if none exists, seeding from corpus. // Uses INSERT OR IGNORE + RowsAffected to avoid duplicate starting_balance logs. func (p *EuroPlugin) ensureBalance(userID id.UserID) { d := db.Get() // Calculate starting balance from corpus character count var totalChars float64 if err := d.QueryRow("SELECT COALESCE(SUM(total_chars), 0) FROM user_stats WHERE user_id = ?", string(userID)).Scan(&totalChars); err != nil { totalChars = 0 } starting := totalChars / 1000.0 if starting > p.cfg.StartingCap { starting = p.cfg.StartingCap } result, err := d.Exec( "INSERT OR IGNORE INTO euro_balances (user_id, balance) VALUES (?, ?)", string(userID), starting, ) if err != nil { slog.Error("euro: failed to create balance", "user", userID, "err", err) return } // Only log transaction if a row was actually inserted (not ignored) affected, _ := result.RowsAffected() if affected > 0 && starting > 0 { p.logTransaction(userID, starting, "starting_balance") } } func (p *EuroPlugin) credit(userID id.UserID, amount float64, reason string) { d := db.Get() _, err := d.Exec( "UPDATE euro_balances SET balance = balance + ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?", amount, string(userID), ) if err != nil { slog.Error("euro: credit failed", "user", userID, "amount", amount, "err", err) return } p.logTransaction(userID, amount, reason) } // Debit subtracts euros atomically. Returns false if this would exceed debt limit. // Uses a conditional UPDATE to prevent race conditions (check-and-act in one statement). func (p *EuroPlugin) Debit(userID id.UserID, amount float64, reason string) bool { if amount <= 0 { slog.Warn("euro: Debit called with non-positive amount", "user", userID, "amount", amount) return false } p.ensureBalance(userID) d := db.Get() debtLimit := envFloat("BLACKJACK_DEBT_LIMIT", 1000) // Atomic: only debit if the result stays within debt limit result, err := d.Exec( `UPDATE euro_balances SET balance = balance - ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND (balance - ?) >= ?`, amount, string(userID), amount, -debtLimit, ) if err != nil { slog.Error("euro: debit failed", "user", userID, "amount", amount, "err", err) return false } affected, _ := result.RowsAffected() if affected == 0 { return false // balance check failed or user doesn't exist } p.logTransaction(userID, -amount, reason) return true } // Credit adds euros (exported for other plugins). func (p *EuroPlugin) Credit(userID id.UserID, amount float64, reason string) { if amount <= 0 { slog.Warn("euro: Credit called with non-positive amount", "user", userID, "amount", amount) return } p.ensureBalance(userID) p.credit(userID, amount, reason) } // GetBalance returns current balance for a user. func (p *EuroPlugin) GetBalance(userID id.UserID) float64 { p.ensureBalance(userID) d := db.Get() var balance float64 if err := d.QueryRow("SELECT balance FROM euro_balances WHERE user_id = ?", string(userID)).Scan(&balance); err != nil { slog.Error("euro: failed to get balance", "user", userID, "err", err) } return balance } // --------------------------------------------------------------------------- // Idempotent money moves // // Every caller of Credit/Debit above is a Matrix message, which arrives exactly // once. Money that moves over a retrying wire — the Pete games escrow poll loop // — has no such guarantee: a claim that succeeds but whose ack is lost gets // retried, and the player pays twice. CreditIdem/DebitIdem take an externalID // (the escrow GUID), do the balance mutation and the transaction log in one // tx, and lean on idx_euro_tx_external so a replay is a no-op rather than a // second debit. // // Anything web-initiated goes through these. Nothing else should. // --------------------------------------------------------------------------- // DebitIdem subtracts euros exactly once for a given externalID. Returns ok=false // only when the debit would breach the debt limit; a replay of an already-applied // externalID returns ok=true without moving money again. balanceAfter is the // balance as of the decision, and is meaningless when ok is false. func (p *EuroPlugin) DebitIdem(userID id.UserID, amount float64, reason, externalID string) (ok bool, balanceAfter float64, err error) { if amount <= 0 { return false, 0, fmt.Errorf("euro: DebitIdem non-positive amount %v", amount) } if externalID == "" { return false, 0, fmt.Errorf("euro: DebitIdem requires an external id") } debtLimit := envFloat("BLACKJACK_DEBT_LIMIT", 1000) return p.applyIdem(userID, -amount, reason, externalID, -debtLimit) } // CreditIdem adds euros exactly once for a given externalID. A replay is a no-op // that still reports ok. func (p *EuroPlugin) CreditIdem(userID id.UserID, amount float64, reason, externalID string) (ok bool, balanceAfter float64, err error) { if amount <= 0 { return false, 0, fmt.Errorf("euro: CreditIdem non-positive amount %v", amount) } if externalID == "" { return false, 0, fmt.Errorf("euro: CreditIdem requires an external id") } // A credit has no floor to breach — math.Inf would do, but the balance can // never land below the current one, so any sentinel below it works. return p.applyIdem(userID, amount, reason, externalID, math.Inf(-1)) } // applyIdem is the shared body: signed delta, applied once, never letting the // balance fall below floor. Balance mutation and transaction log commit together // or not at all. func (p *EuroPlugin) applyIdem(userID id.UserID, delta float64, reason, externalID string, floor float64) (bool, float64, error) { // Outside the tx: db is capped at a single connection, so a nested Exec on // the pool would deadlock against our own open transaction. p.ensureBalance(userID) d := db.Get() tx, err := d.Begin() if err != nil { return false, 0, fmt.Errorf("euro: begin: %w", err) } defer tx.Rollback() //nolint:errcheck // no-op after a successful Commit // Already applied? Report the current balance and move on. var prior int switch err := tx.QueryRow( `SELECT 1 FROM euro_transactions WHERE external_id = ?`, externalID, ).Scan(&prior); { case err == nil: bal, err := txBalance(tx, userID) if err != nil { return false, 0, err } slog.Info("euro: idempotent replay ignored", "user", userID, "external_id", externalID, "reason", reason) return true, bal, nil case errors.Is(err, sql.ErrNoRows): // Not applied yet — carry on. default: return false, 0, fmt.Errorf("euro: idem lookup: %w", err) } res, err := tx.Exec( `UPDATE euro_balances SET balance = balance + ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND (balance + ?) >= ?`, delta, string(userID), delta, floor, ) if err != nil { return false, 0, fmt.Errorf("euro: idem balance update: %w", err) } if affected, _ := res.RowsAffected(); affected == 0 { return false, 0, nil // would breach the floor } if _, err := tx.Exec( `INSERT INTO euro_transactions (user_id, amount, reason, external_id) VALUES (?, ?, ?, ?)`, string(userID), delta, reason, externalID, ); err != nil { // A racing caller won with the same externalID. The unique index is the // backstop the SELECT above can't be: roll back our half-applied delta // and report theirs. if strings.Contains(err.Error(), "UNIQUE constraint failed") { if rbErr := tx.Rollback(); rbErr != nil { return false, 0, fmt.Errorf("euro: rollback after idem race: %w", rbErr) } slog.Info("euro: idempotent race lost, delta rolled back", "user", userID, "external_id", externalID) return true, p.GetBalance(userID), nil } return false, 0, fmt.Errorf("euro: idem tx log: %w", err) } bal, err := txBalance(tx, userID) if err != nil { return false, 0, err } if err := tx.Commit(); err != nil { return false, 0, fmt.Errorf("euro: commit: %w", err) } return true, bal, nil } func txBalance(tx *sql.Tx, userID id.UserID) (float64, error) { var bal float64 if err := tx.QueryRow( `SELECT balance FROM euro_balances WHERE user_id = ?`, string(userID), ).Scan(&bal); err != nil { return 0, fmt.Errorf("euro: read balance: %w", err) } return bal, nil } func (p *EuroPlugin) logTransaction(userID id.UserID, amount float64, reason string) { db.Exec("euro: log transaction", "INSERT INTO euro_transactions (user_id, amount, reason) VALUES (?, ?, ?)", string(userID), amount, reason, ) } // --------------------------------------------------------------------------- // Commands // --------------------------------------------------------------------------- func (p *EuroPlugin) handleBalance(ctx MessageContext) error { p.ensureBalance(ctx.Sender) d := db.Get() var balance float64 if err := d.QueryRow("SELECT balance FROM euro_balances WHERE user_id = ?", string(ctx.Sender)).Scan(&balance); err != nil { return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch balance.") } debtLimit := envFloat("BLACKJACK_DEBT_LIMIT", 1000) var sb strings.Builder sb.WriteString(fmt.Sprintf("💰 **Your Balance:** €%d\n", int(balance))) if balance < 0 { sb.WriteString(fmt.Sprintf("⚠️ You are in debt! (limit: €%d)\n", int(debtLimit))) if balance <= -debtLimit { sb.WriteString("🚫 Betting disabled until you earn your way out of debt.\n") } } // Recent transactions rows, err := d.Query( `SELECT amount, reason, created_at FROM euro_transactions WHERE user_id = ? ORDER BY created_at DESC LIMIT 5`, string(ctx.Sender), ) if err == nil { defer rows.Close() sb.WriteString("\n**Recent transactions:**\n") for rows.Next() { var amount float64 var reason, createdAt string rows.Scan(&amount, &reason, &createdAt) sign := "+" if amount < 0 { sign = "" } sb.WriteString(fmt.Sprintf(" %s€%.0f — %s\n", sign, amount, reason)) } } return p.SendReply(ctx.RoomID, ctx.EventID, sb.String()) } func (p *EuroPlugin) handleBaltop(ctx MessageContext) error { d := db.Get() rows, err := d.Query( `SELECT user_id, balance FROM euro_balances ORDER BY balance DESC LIMIT 10`, ) if err != nil { return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch leaderboard.") } defer rows.Close() var sb strings.Builder sb.WriteString("💰 **Euro Leaderboard**\n\n") rank := 0 for rows.Next() { var userID string var balance float64 rows.Scan(&userID, &balance) rank++ name := p.DisplayName(id.UserID(userID)) medal := "" switch rank { case 1: medal = "🥇" case 2: medal = "🥈" case 3: medal = "🥉" } sb.WriteString(fmt.Sprintf("%s %d. **%s** — €%d\n", medal, rank, name, int(balance))) } if rank == 0 { sb.WriteString("No balances yet.") } return p.SendReply(ctx.RoomID, ctx.EventID, sb.String()) } func (p *EuroPlugin) handleTransfer(ctx MessageContext) error { args := p.GetArgs(ctx.Body, "baltransfer") parts := strings.Fields(args) if len(parts) < 2 { return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!baltransfer @user €amount`") } targetID, ok := p.ResolveUser(parts[0], ctx.RoomID) if !ok { return p.SendReply(ctx.RoomID, ctx.EventID, "Could not resolve user.") } if targetID == ctx.Sender { return p.SendReply(ctx.RoomID, ctx.EventID, "You can't transfer to yourself.") } amountStr := strings.TrimPrefix(parts[1], "€") amount := 0.0 fmt.Sscanf(amountStr, "%f", &amount) if amount < 1 { return p.SendReply(ctx.RoomID, ctx.EventID, "Minimum transfer is €1.") } balance := p.GetBalance(ctx.Sender) if balance < amount { return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Insufficient balance. You have €%d.", int(balance))) } if !p.Debit(ctx.Sender, amount, fmt.Sprintf("transfer to %s", targetID)) { return p.SendReply(ctx.RoomID, ctx.EventID, "Transfer failed.") } p.Credit(targetID, amount, fmt.Sprintf("transfer from %s", ctx.Sender)) senderName := p.DisplayName(ctx.Sender) targetName := p.DisplayName(targetID) return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("💸 **%s** sent €%d to **%s**.", senderName, int(amount), targetName)) } // handleGrant lets an admin credit euros to any user. Logs the transaction // with reason "admin grant by " plus any free-form note the admin // supplied as the third argument. func (p *EuroPlugin) handleGrant(ctx MessageContext) error { if !p.IsAdmin(ctx.Sender) { return nil } args := p.GetArgs(ctx.Body, "eurogrant") parts := strings.Fields(args) if len(parts) < 2 { return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!eurogrant @user amount [reason]`") } targetID, ok := p.ResolveUser(parts[0], ctx.RoomID) if !ok { return p.SendReply(ctx.RoomID, ctx.EventID, "Could not resolve user.") } amountStr := strings.TrimPrefix(parts[1], "€") amount := 0.0 fmt.Sscanf(amountStr, "%f", &amount) if amount < 1 { return p.SendReply(ctx.RoomID, ctx.EventID, "Amount must be at least €1.") } reason := fmt.Sprintf("admin grant by %s", ctx.Sender) if len(parts) > 2 { reason = fmt.Sprintf("%s — %s", reason, strings.Join(parts[2:], " ")) } p.Credit(targetID, amount, reason) newBalance := p.GetBalance(targetID) targetName := p.DisplayName(targetID) return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("✅ Granted €%d to **%s**. New balance: €%d.", int(amount), targetName, int(newBalance))) }