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

67
main.go
View File

@@ -8,6 +8,7 @@ import (
"os/signal"
"strings"
"syscall"
"time"
"gogobee/internal/bot"
"gogobee/internal/db"
@@ -99,6 +100,8 @@ func main() {
registry.Register(plugin.NewLookupPlugin(client, ratePlugin))
registry.Register(plugin.NewCountdownPlugin(client))
registry.Register(plugin.NewStocksPlugin(client))
forexPlugin := plugin.NewForexPlugin(client)
registry.Register(forexPlugin)
concertsPlugin := plugin.NewConcertsPlugin(client, ratePlugin)
registry.Register(concertsPlugin)
animePlugin := plugin.NewAnimePlugin(client)
@@ -115,7 +118,7 @@ func main() {
registry.Register(plugin.NewUnoPlugin(client, euroPlugin))
registry.Register(plugin.NewHoldemPlugin(client, euroPlugin))
registry.Register(plugin.NewAdventurePlugin(client, euroPlugin))
wordlePlugin := plugin.NewWordlePlugin(client)
wordlePlugin := plugin.NewWordlePlugin(client, euroPlugin)
registry.Register(wordlePlugin)
// Community
@@ -172,6 +175,12 @@ func main() {
// Auto-join on invite + moderation member tracking
syncer.OnEventType(event.StateMember, func(ctx context.Context, evt *event.Event) {
defer func() {
if rec := recover(); rec != nil {
slog.Error("member event handler panic", "panic", rec, "room", evt.RoomID)
}
}()
mem := evt.Content.AsMember()
if mem == nil {
return
@@ -197,6 +206,13 @@ func main() {
// Message handler
syncer.OnEventType(event.EventMessage, func(ctx context.Context, evt *event.Event) {
defer func() {
if rec := recover(); rec != nil {
slog.Error("message event handler panic", "panic", rec,
"sender", evt.Sender, "room", evt.RoomID)
}
}()
// Skip own messages
if evt.Sender == client.UserID {
return
@@ -234,6 +250,13 @@ func main() {
// Reaction handler
syncer.OnEventType(event.EventReaction, func(ctx context.Context, evt *event.Event) {
defer func() {
if rec := recover(); rec != nil {
slog.Error("reaction event handler panic", "panic", rec,
"sender", evt.Sender, "room", evt.RoomID)
}
}()
if evt.Sender == client.UserID {
return
}
@@ -256,8 +279,8 @@ func main() {
})
// ---- Set up cron scheduler ----
scheduler := cron.New()
setupScheduledJobs(scheduler, client, wotdPlugin, holidaysPlugin, gamingPlugin, birthdayPlugin, animePlugin, moviesPlugin, concertsPlugin, esteemedPlugin)
scheduler := cron.New(cron.WithChain(cron.Recover(cronLogger{})))
setupScheduledJobs(scheduler, client, wotdPlugin, holidaysPlugin, gamingPlugin, birthdayPlugin, animePlugin, moviesPlugin, concertsPlugin, esteemedPlugin, forexPlugin)
scheduler.Start()
// ---- Start syncing ----
@@ -272,11 +295,26 @@ func main() {
sig := <-sigCh
slog.Info("shutting down", "signal", sig)
scheduler.Stop()
client.StopSync()
cancel()
}()
if err := client.SyncWithContext(ctx); err != nil {
slog.Error("sync stopped", "err", err)
syncLoop:
for {
err := client.SyncWithContext(ctx)
if ctx.Err() != nil {
break // shutdown requested
}
if err != nil {
slog.Error("sync stopped, restarting in 5s", "err", err)
} else {
slog.Warn("sync returned without error, restarting in 5s")
}
select {
case <-time.After(5 * time.Second):
case <-ctx.Done():
break syncLoop
}
}
slog.Info("GogoBee stopped")
@@ -293,6 +331,7 @@ func setupScheduledJobs(
movies *plugin.MoviesPlugin,
concerts *plugin.ConcertsPlugin,
esteemed *plugin.EsteemPlugin,
forex *plugin.ForexPlugin,
) {
rooms := getRooms()
@@ -370,6 +409,12 @@ func setupScheduledJobs(
esteemed.PostWeekly()
})
// Forex daily poll at 17:01 UTC (ECB publishes ~16:00 CET)
c.AddFunc("1 17 * * *", func() {
slog.Info("scheduler: forex daily poll")
forex.DailyPoll()
})
// Space groups refresh every hour
c.AddFunc("0 * * * *", func() {
slog.Info("scheduler: refreshing space groups")
@@ -461,3 +506,15 @@ func envOr(key, fallback string) string {
}
return v
}
// cronLogger adapts slog to the cron.Logger interface for panic recovery logging.
type cronLogger struct{}
func (cronLogger) Info(msg string, keysAndValues ...interface{}) {
slog.Info(msg, keysAndValues...)
}
func (cronLogger) Error(err error, msg string, keysAndValues ...interface{}) {
args := append([]interface{}{"err", err}, keysAndValues...)
slog.Error(msg, args...)
}