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

161 lines
4.5 KiB
Go

package plugin
import (
"context"
"database/sql"
"fmt"
"log/slog"
"strings"
"gogobee/internal/db"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
)
// ReactionsPlugin logs reactions and provides emoji usage statistics.
type ReactionsPlugin struct {
Base
}
// NewReactionsPlugin creates a new reactions plugin.
func NewReactionsPlugin(client *mautrix.Client) *ReactionsPlugin {
return &ReactionsPlugin{
Base: NewBase(client),
}
}
func (p *ReactionsPlugin) Name() string { return "reactions" }
func (p *ReactionsPlugin) Commands() []CommandDef {
return []CommandDef{
{Name: "emojiboard", Description: "Top emoji givers, receivers, and most used emojis", Usage: "!emojiboard", Category: "Reactions"},
}
}
func (p *ReactionsPlugin) Init() error { return nil }
func (p *ReactionsPlugin) OnMessage(ctx MessageContext) error {
if p.IsCommand(ctx.Body, "emojiboard") {
return p.handleEmojiboard(ctx)
}
return nil
}
func (p *ReactionsPlugin) OnReaction(ctx ReactionContext) error {
// Resolve the target event's sender via Matrix API
targetUser, err := p.resolveEventSender(ctx.RoomID, ctx.TargetEvent)
if err != nil {
slog.Error("reactions: resolve target event sender", "err", err, "event", ctx.TargetEvent)
return nil // Don't fail the whole handler
}
// Don't log self-reactions
if ctx.Sender == targetUser {
return nil
}
db.Exec("reactions: log reaction",
`INSERT INTO reaction_log (room_id, event_id, sender, target_user, emoji) VALUES (?, ?, ?, ?, ?)`,
string(ctx.RoomID), string(ctx.TargetEvent), string(ctx.Sender), string(targetUser), ctx.Emoji,
)
return nil
}
// resolveEventSender fetches an event from the Matrix API to determine its sender.
func (p *ReactionsPlugin) resolveEventSender(roomID id.RoomID, eventID id.EventID) (id.UserID, error) {
evt, err := p.Client.GetEvent(context.Background(), roomID, eventID)
if err != nil {
return "", fmt.Errorf("get event %s: %w", eventID, err)
}
return evt.Sender, nil
}
func (p *ReactionsPlugin) handleEmojiboard(ctx MessageContext) error {
members := p.RoomMembers(ctx.RoomID)
d := db.Get()
var sb strings.Builder
// Top 10 emoji givers
sb.WriteString("--- Top 10 Emoji Givers ---\n\n")
rows, err := d.Query(
`SELECT sender, COUNT(*) as cnt FROM reaction_log GROUP BY sender ORDER BY cnt DESC`,
)
if err != nil {
slog.Error("reactions: givers query", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.")
}
giverCount := appendUserBoard(&sb, rows, members)
rows.Close()
// Top 10 emoji receivers
sb.WriteString("\n--- Top 10 Emoji Receivers ---\n\n")
rows, err = d.Query(
`SELECT target_user, COUNT(*) as cnt FROM reaction_log GROUP BY target_user ORDER BY cnt DESC`,
)
if err != nil {
slog.Error("reactions: receivers query", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.")
}
receiverCount := appendUserBoard(&sb, rows, members)
rows.Close()
// Top 10 most used emojis (no user filtering needed)
sb.WriteString("\n--- Top 10 Most Used Emojis ---\n\n")
rows, err = d.Query(
`SELECT emoji, COUNT(*) as cnt FROM reaction_log GROUP BY emoji ORDER BY cnt DESC LIMIT 10`,
)
if err != nil {
slog.Error("reactions: emoji query", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.")
}
emojiCount := appendEmojiBoard(&sb, rows)
rows.Close()
if giverCount == 0 && receiverCount == 0 && emojiCount == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, "No reaction data yet.")
}
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
}
// appendUserBoard writes ranked user lines from query rows, filtered by room members.
func appendUserBoard(sb *strings.Builder, rows *sql.Rows, members map[id.UserID]bool) int {
medals := []string{"🥇", "🥈", "🥉"}
i := 0
for rows.Next() && i < 10 {
var name string
var cnt int
if err := rows.Scan(&name, &cnt); err != nil {
continue
}
if members != nil && !members[id.UserID(name)] {
continue
}
prefix := fmt.Sprintf("#%d", i+1)
if i < len(medals) {
prefix = medals[i]
}
sb.WriteString(fmt.Sprintf("%s %s — %s reactions\n", prefix, name, formatNumber(cnt)))
i++
}
return i
}
// appendEmojiBoard writes ranked emoji lines.
func appendEmojiBoard(sb *strings.Builder, rows *sql.Rows) int {
i := 0
for rows.Next() {
var emoji string
var cnt int
if err := rows.Scan(&emoji, &cnt); err != nil {
continue
}
sb.WriteString(fmt.Sprintf("%d. %s — %s times\n", i+1, emoji, formatNumber(cnt)))
i++
}
return i
}