mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
- 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>
181 lines
5.0 KiB
Go
181 lines
5.0 KiB
Go
package plugin
|
|
|
|
import (
|
|
"fmt"
|
|
"math/rand/v2"
|
|
"strings"
|
|
|
|
"maunium.net/go/mautrix"
|
|
"maunium.net/go/mautrix/id"
|
|
|
|
"gogobee/internal/db"
|
|
)
|
|
|
|
// FlipPlugin handles !flip (coin flip) restricted to the games room.
|
|
type FlipPlugin struct {
|
|
Base
|
|
}
|
|
|
|
func NewFlipPlugin(client *mautrix.Client) *FlipPlugin {
|
|
return &FlipPlugin{Base: NewBase(client)}
|
|
}
|
|
|
|
func (p *FlipPlugin) Name() string { return "flip" }
|
|
|
|
func (p *FlipPlugin) Commands() []CommandDef {
|
|
return []CommandDef{
|
|
{Name: "flip", Description: "Flip a coin", Usage: "!flip", Category: "Games"},
|
|
{Name: "games", Description: "List available games", Usage: "!games", Category: "Games"},
|
|
{Name: "twinbeeboard", Description: "GogoBee's victory record", Usage: "!twinbeeboard", Category: "Games"},
|
|
}
|
|
}
|
|
|
|
func (p *FlipPlugin) Init() error { return nil }
|
|
func (p *FlipPlugin) OnReaction(_ ReactionContext) error { return nil }
|
|
|
|
func (p *FlipPlugin) OnMessage(ctx MessageContext) error {
|
|
switch {
|
|
case p.IsCommand(ctx.Body, "flip"):
|
|
if !isGamesRoom(ctx.RoomID) {
|
|
gr := gamesRoom()
|
|
if gr != "" {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Games are only available in the games channel!")
|
|
}
|
|
}
|
|
return p.handleFlip(ctx)
|
|
case p.IsCommand(ctx.Body, "games"):
|
|
return p.handleGames(ctx)
|
|
case p.IsCommand(ctx.Body, "twinbeeboard"):
|
|
return p.handleLoseboard(ctx)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *FlipPlugin) handleFlip(ctx MessageContext) error {
|
|
if rand.IntN(2) == 0 {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "🪙 **Heads!**")
|
|
}
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "🪙 **Tails!**")
|
|
}
|
|
|
|
func (p *FlipPlugin) handleGames(ctx MessageContext) error {
|
|
gr := gamesRoom()
|
|
roomNote := ""
|
|
if gr != "" {
|
|
roomNote = "\n\nAll games are played in the games channel."
|
|
}
|
|
|
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
|
"🎮 **Available Games**\n\n"+
|
|
"**!flip** — Coin flip\n"+
|
|
"**!hangman start** — Collaborative Hangman\n"+
|
|
"**!blackjack €amount** — Blackjack (1-4 players vs dealer)\n"+
|
|
"**!uno €amount** — UNO (solo or multiplayer, classic or No Mercy)\n"+
|
|
"**!holdem join** — Texas Hold'em poker (2-9 players)\n"+
|
|
"**!wordle <word>** — Daily cooperative Wordle\n"+
|
|
"**!trivia** — Trivia questions\n\n"+
|
|
"**Economy:**\n"+
|
|
"**!balance** — Check your euros\n"+
|
|
"**!baltop** — Euro leaderboard\n"+
|
|
"**!baltransfer @user €amount** — Send euros\n"+
|
|
"**!hangboard** — Hangman leaderboard\n"+
|
|
"**!bjboard** — Blackjack leaderboard\n"+
|
|
"**!twinbeeboard** — Bot defeat leaderboard"+
|
|
roomNote)
|
|
}
|
|
|
|
func (p *FlipPlugin) handleLoseboard(ctx MessageContext) error {
|
|
d := db.Get()
|
|
rows, err := d.Query(
|
|
`SELECT user_id, game, losses FROM bot_defeats
|
|
WHERE losses > 0
|
|
ORDER BY losses DESC LIMIT 15`)
|
|
if err != nil {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load twinbeeboard.")
|
|
}
|
|
defer rows.Close()
|
|
|
|
// Aggregate total losses per user and track per-game breakdown
|
|
type userStats struct {
|
|
total int
|
|
breakdown map[string]int
|
|
}
|
|
users := make(map[string]*userStats)
|
|
var order []string
|
|
|
|
for rows.Next() {
|
|
var uid, game string
|
|
var losses int
|
|
if err := rows.Scan(&uid, &game, &losses); err != nil {
|
|
continue
|
|
}
|
|
if _, ok := users[uid]; !ok {
|
|
users[uid] = &userStats{breakdown: make(map[string]int)}
|
|
order = append(order, uid)
|
|
}
|
|
users[uid].total += losses
|
|
users[uid].breakdown[game] = losses
|
|
}
|
|
|
|
if len(order) == 0 {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "🐝 No victories yet. GogoBee is patient.")
|
|
}
|
|
|
|
// Sort by total losses descending
|
|
for i := 0; i < len(order); i++ {
|
|
for j := i + 1; j < len(order); j++ {
|
|
if users[order[j]].total > users[order[i]].total {
|
|
order[i], order[j] = order[j], order[i]
|
|
}
|
|
}
|
|
}
|
|
if len(order) > 10 {
|
|
order = order[:10]
|
|
}
|
|
|
|
gameLabels := map[string]string{
|
|
"blackjack": "BJ",
|
|
"uno": "UNO",
|
|
"uno_multi": "UNO MP",
|
|
}
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString("🐝 **GogoBee's Trophy Wall**\n\n")
|
|
for i, uid := range order {
|
|
name := p.DisplayName(id.UserID(uid))
|
|
stats := users[uid]
|
|
var parts []string
|
|
for game, count := range stats.breakdown {
|
|
label := gameLabels[game]
|
|
if label == "" {
|
|
label = game
|
|
}
|
|
parts = append(parts, fmt.Sprintf("%s: %d", label, count))
|
|
}
|
|
sb.WriteString(fmt.Sprintf("%d. **%s** — %d losses (%s)\n",
|
|
i+1, name, stats.total, strings.Join(parts, ", ")))
|
|
}
|
|
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
|
}
|
|
|
|
// recordBotDefeat increments the bot defeat counter for a user in a specific game.
|
|
// This is a package-level function so all game plugins can call it.
|
|
func recordBotDefeat(userID id.UserID, game string) {
|
|
db.Exec("flip: record bot defeat",
|
|
`INSERT INTO bot_defeats (user_id, game, losses)
|
|
VALUES (?, ?, 1)
|
|
ON CONFLICT(user_id, game) DO UPDATE SET
|
|
losses = losses + 1`,
|
|
string(userID), game,
|
|
)
|
|
}
|
|
|
|
|
|
|
|
// redirectToGamesRoom returns the room ID for games-restricted redirect.
|
|
func redirectToGamesRoom(sender id.UserID) string {
|
|
_ = sender
|
|
return "Games are only available in the games channel!"
|
|
}
|