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>
This commit is contained in:
prosolis
2026-03-26 09:22:02 -07:00
parent c7c1b76589
commit 9c6ded13fa
68 changed files with 5784 additions and 724 deletions

View File

@@ -37,6 +37,7 @@ func (p *StatsPlugin) Commands() []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"},
}
}
@@ -55,6 +56,9 @@ func (p *StatsPlugin) OnMessage(ctx MessageContext) error {
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 {
@@ -316,3 +320,238 @@ func (p *StatsPlugin) handlePersonality(ctx MessageContext) error {
)
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)
sb.WriteString(fmt.Sprintf("⭐ **Level %d** · %s XP · 💰 €%.0f · 🏅 %d achievements\n\n",
level, formatNumber(xp), balance, achievementCount))
// ── 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
msgStats := util.MessageStats{Words: totalWords, Links: totalLinks, Emojis: totalEmojis, Questions: totalQuestions}
archetype := util.DeriveArchetype(msgStats, totalMsg)
sb.WriteString(fmt.Sprintf(" Personality: **%s**\n", archetype.Name))
}
// ── 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 combatLv, miningLv, forageLv, combatXP, miningXP, forageXP int
var alive bool
var streak, bestStreak int
err = d.QueryRow(
`SELECT combat_level, mining_skill, foraging_skill,
combat_xp, mining_xp, foraging_xp,
alive, current_streak, best_streak
FROM adventure_characters WHERE user_id = ?`, uid,
).Scan(&combatLv, &miningLv, &forageLv, &combatXP, &miningXP, &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(" Combat Lv.%d (%d XP) · Mining Lv.%d (%d XP) · Forage Lv.%d (%d XP)\n",
combatLv, combatXP, miningLv, miningXP, 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: %d\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 int
err = d.QueryRow(
`SELECT puzzles_played, puzzles_solved, total_guesses
FROM wordle_stats WHERE user_id = ?`, uid,
).Scan(&wPlayed, &wSolved, &wGuesses)
if err == nil && wPlayed > 0 {
avg := float64(wGuesses) / float64(wPlayed)
sb.WriteString(fmt.Sprintf(" 🟩 Wordle: %d/%d solved · %.1f avg guesses\n",
wSolved, wPlayed, avg))
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)
}
sb.WriteString(fmt.Sprintf(" 🧠 Trivia: %d/%d correct%s\n",
tCorrect, tCorrect+tWrong, 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 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)
unoTotal := unoSingleTotal + unoMultiTotal
unoWins := unoSingleWins + unoMultiWins
if unoTotal > 0 {
sb.WriteString(fmt.Sprintf(" 🎴 UNO: %d/%d W/L\n",
unoWins, unoTotal-unoWins))
hasGames = true
}
// Bot defeats
var botDefeats int
_ = d.QueryRow(`SELECT COALESCE(SUM(losses), 0) FROM bot_defeats WHERE user_id = ?`, uid).Scan(&botDefeats)
if botDefeats > 0 {
sb.WriteString(fmt.Sprintf(" 🤖 Lost to TwinBee: %d times\n", botDefeats))
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())
}