Files
gogobee/internal/plugin/adventure_scheduler.go
prosolis 9c6ded13fa 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>
2026-03-26 09:22:02 -07:00

316 lines
8.7 KiB
Go

package plugin
import (
"fmt"
"log/slog"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// ── Morning DM Ticker ────────────────────────────────────────────────────────
func (p *AdventurePlugin) morningTicker() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for range ticker.C {
now := time.Now().UTC()
if now.Hour() != p.morningHour || now.Minute() != 0 {
continue
}
dateKey := now.Format("2006-01-02")
jobName := "adventure_morning"
if db.JobCompleted(jobName, dateKey) {
continue
}
slog.Info("adventure: sending morning DMs")
p.sendMorningDMs()
db.MarkJobCompleted(jobName, dateKey)
}
}
func (p *AdventurePlugin) sendMorningDMs() {
chars, err := loadAllAdvCharacters()
if err != nil {
slog.Error("adventure: failed to load characters for morning DMs", "err", err)
return
}
now := time.Now().UTC()
for _, char := range chars {
char := char
// Check if dead and ready to respawn
if !char.Alive && char.DeadUntil != nil && now.After(*char.DeadUntil) {
// Revive
char.Alive = true
char.DeadUntil = nil
if err := saveAdvCharacter(&char); err != nil {
slog.Error("adventure: failed to revive character", "user", char.UserID, "err", err)
continue
}
// Send respawn DM
text := renderAdvRespawnDM(&char)
if err := p.SendDM(char.UserID, text); err != nil {
slog.Error("adventure: failed to send respawn DM", "user", char.UserID, "err", err)
}
}
// If still dead, send death status
if !char.Alive {
text := renderAdvDeathStatusDM(&char)
if err := p.SendDM(char.UserID, text); err != nil {
slog.Error("adventure: failed to send death status DM", "user", char.UserID, "err", err)
}
continue
}
// If already acted today, skip
if char.ActionTakenToday {
continue
}
// Send morning DM with choices
equip, err := loadAdvEquipment(char.UserID)
if err != nil {
slog.Error("adventure: failed to load equipment for morning DM", "user", char.UserID, "err", err)
continue
}
treasures, _ := loadAdvTreasureBonuses(char.UserID)
buffs, _ := loadAdvActiveBuffs(char.UserID)
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
balance := p.euro.GetBalance(char.UserID)
text := renderAdvMorningDM(&char, equip, balance, bonuses)
if err := p.SendDM(char.UserID, text); err != nil {
slog.Error("adventure: failed to send morning DM", "user", char.UserID, "err", err)
continue
}
// Register DM room for reply routing
p.registerDMRoom(char.UserID)
}
}
// ── Evening Summary Ticker ───────────────────────────────────────────────────
func (p *AdventurePlugin) summaryTicker() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for range ticker.C {
now := time.Now().UTC()
if now.Hour() != p.summaryHour || now.Minute() != 0 {
continue
}
dateKey := now.Format("2006-01-02")
jobName := "adventure_summary"
if db.JobCompleted(jobName, dateKey) {
continue
}
slog.Info("adventure: posting daily summary")
p.postDailySummary()
db.MarkJobCompleted(jobName, dateKey)
}
}
func (p *AdventurePlugin) postDailySummary() {
gr := gamesRoom()
if gr == "" {
return
}
// Run TwinBee daily action
tbResult := p.runTwinBeeDaily()
tbRewards := p.distributeTwinBeeRewards(tbResult)
// Load all characters and today's logs
chars, err := loadAllAdvCharacters()
if err != nil {
slog.Error("adventure: failed to load characters for summary", "err", err)
return
}
todayLogs, _ := loadAdvTodayLogs()
logMap := make(map[id.UserID]*AdvDayLog)
for i := range todayLogs {
logMap[todayLogs[i].UserID] = &todayLogs[i]
}
// Build player summaries
var players []AdvPlayerDaySummary
for _, c := range chars {
ps := AdvPlayerDaySummary{
DisplayName: c.DisplayName,
CombatLevel: c.CombatLevel,
MiningSkill: c.MiningSkill,
ForagingSkill: c.ForagingSkill,
}
if !c.Alive {
ps.IsDead = true
if c.DeadUntil != nil {
ps.DeadUntil = c.DeadUntil.Format("15:04") + " UTC"
}
// Check if they died today
if log, ok := logMap[c.UserID]; ok {
ps.Activity = log.ActivityType
ps.Location = log.Location
ps.Outcome = log.Outcome
ps.LootValue = log.LootValue
ps.SummaryLine = advSummaryOneLiner(c.UserID, AdvActivityType(log.ActivityType), AdvOutcomeType(log.Outcome), log.LootValue, log.Location)
}
players = append(players, ps)
continue
}
if !c.ActionTakenToday {
ps.IsResting = true
if len(SummaryResting) > 0 {
ps.SummaryLine = SummaryResting[time.Now().Nanosecond()%len(SummaryResting)]
}
players = append(players, ps)
continue
}
// Active player with today's log
if log, ok := logMap[c.UserID]; ok {
ps.Activity = log.ActivityType
ps.Location = log.Location
ps.Outcome = log.Outcome
ps.LootValue = log.LootValue
ps.SummaryLine = advSummaryOneLiner(c.UserID, AdvActivityType(log.ActivityType), AdvOutcomeType(log.Outcome), log.LootValue, log.Location)
}
players = append(players, ps)
}
// Check party bonuses and add to summary
for i := range players {
if players[i].Location != "" && !players[i].IsDead && !players[i].IsResting {
for j := i + 1; j < len(players); j++ {
if players[j].Location == players[i].Location && !players[j].IsDead && !players[j].IsResting {
players[i].SummaryLine += fmt.Sprintf(" (Party bonus with %s!)", players[j].DisplayName)
players[j].SummaryLine += fmt.Sprintf(" (Party bonus with %s!)", players[i].DisplayName)
}
}
}
}
date := time.Now().UTC().Format("2006-01-02")
summary := renderAdvDailySummary(date, tbResult, tbRewards, players)
if err := p.SendMessage(id.RoomID(gr), summary); err != nil {
slog.Error("adventure: failed to post daily summary", "err", err)
}
}
// ── Midnight Reset Ticker ────────────────────────────────────────────────────
func (p *AdventurePlugin) midnightTicker() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for range ticker.C {
now := time.Now().UTC()
if now.Hour() != 0 || now.Minute() != 0 {
continue
}
dateKey := now.Format("2006-01-02")
jobName := "adventure_midnight"
if db.JobCompleted(jobName, dateKey) {
continue
}
slog.Info("adventure: midnight reset")
p.midnightReset()
db.MarkJobCompleted(jobName, dateKey)
}
}
func (p *AdventurePlugin) midnightReset() {
// Send idle shame DMs to players who didn't act
chars, err := loadAllAdvCharacters()
if err != nil {
slog.Error("adventure: midnight reset failed to load chars", "err", err)
return
}
today := time.Now().UTC().Format("2006-01-02")
for _, char := range chars {
if !char.Alive {
continue
}
if !char.ActionTakenToday {
// Idle shame DM
text := renderAdvIdleShameDM(&char)
if err := p.SendDM(char.UserID, text); err != nil {
slog.Error("adventure: failed to send idle shame DM", "user", char.UserID, "err", err)
}
// Reset streak
if char.CurrentStreak > 0 {
char.CurrentStreak = 0
_ = saveAdvCharacter(&char)
}
} else {
// Update streak — LastActionDate was set at action time
yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02")
if char.LastActionDate == yesterday || char.LastActionDate == today {
char.CurrentStreak++
} else {
// Gap in activity — start fresh
char.CurrentStreak = 1
}
if char.CurrentStreak > char.BestStreak {
char.BestStreak = char.CurrentStreak
}
_ = saveAdvCharacter(&char)
}
}
// Reset all daily actions
if err := resetAllAdvDailyActions(); err != nil {
slog.Error("adventure: failed to reset daily actions", "err", err)
}
// Prune expired buffs
if err := pruneAdvExpiredBuffs(); err != nil {
slog.Error("adventure: failed to prune expired buffs", "err", err)
}
// Clear flavor history to prevent unbounded memory growth.
// Entries are only used for dedup within a day, so clearing at midnight is fine.
advClearFlavorHistory()
// Clear DM reminder dedup — entries are date-keyed so stale after midnight.
p.dmRemindedDate.Range(func(key, _ any) bool {
p.dmRemindedDate.Delete(key)
return true
})
}
// ── Helper ───────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) registerDMRoom(userID id.UserID) {
room, err := p.GetDMRoom(userID)
if err != nil {
return
}
p.mu.Lock()
p.dmToPlayer[room] = userID
p.mu.Unlock()
}