Files
gogobee/internal/plugin/streaks.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

252 lines
6.2 KiB
Go

package plugin
import (
"database/sql"
"fmt"
"log/slog"
"strings"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
)
// StreaksPlugin tracks daily activity streaks and first-poster records.
type StreaksPlugin struct {
Base
}
// NewStreaksPlugin creates a new streaks plugin.
func NewStreaksPlugin(client *mautrix.Client) *StreaksPlugin {
return &StreaksPlugin{
Base: NewBase(client),
}
}
func (p *StreaksPlugin) Name() string { return "streaks" }
func (p *StreaksPlugin) Commands() []CommandDef {
return []CommandDef{
{Name: "streak", Description: "Show your current and longest activity streak", Usage: "!streak", Category: "Leveling & Stats"},
{Name: "firstboard", Description: "Show top first-posters of the day", Usage: "!firstboard", Category: "Leveling & Stats"},
}
}
func (p *StreaksPlugin) Init() error { return nil }
func (p *StreaksPlugin) OnReaction(_ ReactionContext) error { return nil }
func (p *StreaksPlugin) OnMessage(ctx MessageContext) error {
if p.IsCommand(ctx.Body, "streak") {
return p.handleStreak(ctx)
}
if p.IsCommand(ctx.Body, "firstboard") {
return p.handleFirstboard(ctx)
}
// Skip tracking for bot commands
if ctx.IsCommand {
return nil
}
// Passive: track daily activity and first poster
p.trackDailyActivity(ctx)
p.trackFirstPoster(ctx)
return nil
}
func (p *StreaksPlugin) trackDailyActivity(ctx MessageContext) {
today := time.Now().UTC().Format("2006-01-02")
db.Exec("streaks: track daily activity",
`INSERT INTO daily_activity (user_id, date, message_count) VALUES (?, ?, 1)
ON CONFLICT(user_id, date) DO UPDATE SET message_count = message_count + 1`,
string(ctx.Sender), today,
)
}
func (p *StreaksPlugin) trackFirstPoster(ctx MessageContext) {
today := time.Now().UTC().Format("2006-01-02")
now := time.Now().UTC().Unix()
// INSERT OR IGNORE — only the first poster for this room+date wins
db.Exec("streaks: track first poster",
`INSERT OR IGNORE INTO daily_first (room_id, date, user_id, timestamp) VALUES (?, ?, ?, ?)`,
string(ctx.RoomID), today, string(ctx.Sender), now,
)
}
func (p *StreaksPlugin) handleStreak(ctx MessageContext) error {
target := ctx.Sender
args := p.GetArgs(ctx.Body, "streak")
if args != "" {
if resolved, ok := p.ResolveUser(args, ctx.RoomID); ok {
target = resolved
}
}
d := db.Get()
// Get all active dates for this user, sorted descending
rows, err := d.Query(
`SELECT date FROM daily_activity WHERE user_id = ? ORDER BY date DESC`,
string(target),
)
if err != nil {
slog.Error("streaks: query", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to look up streak.")
}
defer rows.Close()
var dates []string
for rows.Next() {
var date string
if err := rows.Scan(&date); err != nil {
continue
}
dates = append(dates, date)
}
if len(dates) == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("%s has no activity recorded yet.", string(target)))
}
currentStreak, longestStreak := calculateStreaks(dates)
msg := fmt.Sprintf(
"🔥 %s — Streak Report\nCurrent streak: %s days\nLongest streak: %s days\nTotal active days: %s",
string(target),
formatNumber(currentStreak),
formatNumber(longestStreak),
formatNumber(len(dates)),
)
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
}
// calculateStreaks computes current and longest streaks from descending-sorted date strings.
func calculateStreaks(dates []string) (current int, longest int) {
if len(dates) == 0 {
return 0, 0
}
today := time.Now().UTC().Format("2006-01-02")
// Parse first date — current streak only counts if it includes today or yesterday
firstDate, err := time.Parse("2006-01-02", dates[0])
if err != nil {
return 0, 0
}
todayDate, _ := time.Parse("2006-01-02", today)
daysSinceFirst := int(todayDate.Sub(firstDate).Hours() / 24)
if daysSinceFirst > 1 {
// Streak is broken — current streak is 0
// Still calculate longest
current = 0
} else {
// Count current streak
current = 1
for i := 1; i < len(dates); i++ {
prev, err1 := time.Parse("2006-01-02", dates[i-1])
curr, err2 := time.Parse("2006-01-02", dates[i])
if err1 != nil || err2 != nil {
break
}
diff := int(prev.Sub(curr).Hours() / 24)
if diff == 1 {
current++
} else {
break
}
}
}
// Calculate longest streak (walk all dates)
streak := 1
longest = 1
for i := 1; i < len(dates); i++ {
prev, err1 := time.Parse("2006-01-02", dates[i-1])
curr, err2 := time.Parse("2006-01-02", dates[i])
if err1 != nil || err2 != nil {
streak = 1
continue
}
diff := int(prev.Sub(curr).Hours() / 24)
if diff == 1 {
streak++
} else {
streak = 1
}
if streak > longest {
longest = streak
}
}
if current > longest {
longest = current
}
return current, longest
}
func (p *StreaksPlugin) handleFirstboard(ctx MessageContext) error {
members := p.RoomMembers(ctx.RoomID)
d := db.Get()
rows, err := d.Query(
`SELECT user_id, COUNT(*) as wins
FROM daily_first
GROUP BY user_id
ORDER BY wins DESC`,
)
if err != nil {
slog.Error("streaks: firstboard query", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load first-poster board.")
}
defer rows.Close()
var sb strings.Builder
sb.WriteString("🌅 First Poster Board — Top 10\n\n")
medals := []string{"🥇", "🥈", "🥉"}
i := 0
for rows.Next() && i < 10 {
var userID string
var wins int
if err := rows.Scan(&userID, &wins); err != nil {
continue
}
if members != nil && !members[id.UserID(userID)] {
continue
}
prefix := fmt.Sprintf("#%d", i+1)
if i < len(medals) {
prefix = medals[i]
}
sb.WriteString(fmt.Sprintf("%s %s — %s first posts\n", prefix, userID, formatNumber(wins)))
i++
}
if i == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, "No first-poster data yet.")
}
// Also show today's first poster
today := time.Now().UTC().Format("2006-01-02")
var todayFirst sql.NullString
_ = d.QueryRow(
`SELECT user_id FROM daily_first WHERE room_id = ? AND date = ?`,
string(ctx.RoomID), today,
).Scan(&todayFirst)
if todayFirst.Valid {
sb.WriteString(fmt.Sprintf("\nToday's first poster in this room: %s", todayFirst.String))
}
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
}