Add markov, emojiboard, achievements, miniflux plugins and adventure catch-up fix

New plugins: encrypted Markov chain generation (9 commands), emojiboard
reaction stats (5 views), Miniflux RSS feed integration (8 commands),
and 48 new achievements across 9 categories. Adventure startup now
unconditionally resets daily actions and respawns expired deaths to
handle missed resets from SQLite contention. README updated with all
new features.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-03-27 11:55:47 -07:00
parent 1b423b1b16
commit 81e6cecad9
9 changed files with 2219 additions and 136 deletions

View File

@@ -966,6 +966,23 @@ CREATE TABLE IF NOT EXISTS forex_alerts (
PRIMARY KEY (user_id, currency, threshold)
);
-- Miniflux RSS
CREATE TABLE IF NOT EXISTS miniflux_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
feed_id INTEGER NOT NULL,
room_id TEXT NOT NULL,
paused INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
UNIQUE(feed_id, room_id)
);
CREATE TABLE IF NOT EXISTS miniflux_seen (
feed_id INTEGER NOT NULL,
entry_id INTEGER NOT NULL,
seen_at INTEGER NOT NULL,
PRIMARY KEY (feed_id, entry_id)
);
`
// SeedSchedulerDefaults inserts default scheduler jobs if they don't exist.

View File

@@ -449,6 +449,473 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
return err == nil && month > 0 && tz != ""
},
},
// ── Economy ─────────────────────────────────────────────────────────
{
ID: "euro_first", Name: "First Paycheck", Description: "The economy has claimed another victim.",
Emoji: "💶",
Check: func(d *sql.DB, u id.UserID) bool {
var count int
err := d.QueryRow(
`SELECT COUNT(*) FROM euro_transactions WHERE user_id = ? AND amount > 0`,
string(u),
).Scan(&count)
return err == nil && count >= 1
},
},
{
ID: "euro_1k", Name: "Four Figures", Description: "You are now, technically, a thousandaire.",
Emoji: "💰",
Check: func(d *sql.DB, u id.UserID) bool {
var balance float64
err := d.QueryRow(
`SELECT balance FROM euro_balances WHERE user_id = ?`,
string(u),
).Scan(&balance)
return err == nil && balance >= 1000
},
},
{
ID: "euro_10k", Name: "High Roller", Description: "Someone stop this person.",
Emoji: "🤑",
Check: func(d *sql.DB, u id.UserID) bool {
var balance float64
err := d.QueryRow(
`SELECT balance FROM euro_balances WHERE user_id = ?`,
string(u),
).Scan(&balance)
return err == nil && balance >= 10000
},
},
{
ID: "euro_broke", Name: "Overdrafted", Description: "Character-building experience.",
Emoji: "📉",
Check: func(d *sql.DB, u id.UserID) bool {
var balance float64
err := d.QueryRow(
`SELECT balance FROM euro_balances WHERE user_id = ?`,
string(u),
).Scan(&balance)
return err == nil && balance < 0
},
},
{
ID: "euro_comeback", Name: "Comeback Kid", Description: "Against all odds.",
Emoji: "🔄",
Check: func(d *sql.DB, u id.UserID) bool {
// Granted by euro plugin when recovering from debt
return false
},
},
{
ID: "euro_generous", Name: "Philanthropist", Description: "Money is just a social construct anyway.",
Emoji: "🎁",
Check: func(d *sql.DB, u id.UserID) bool {
var total float64
err := d.QueryRow(
`SELECT COALESCE(SUM(ABS(amount)), 0) FROM euro_transactions WHERE user_id = ? AND reason = 'transfer' AND amount < 0`,
string(u),
).Scan(&total)
return err == nil && total >= 1000
},
},
// ── Blackjack ───────────────────────────────────────────────────────
{
ID: "bj_first_win", Name: "Natural", Description: "The table fears you. Slightly.",
Emoji: "🃏",
Check: func(d *sql.DB, u id.UserID) bool {
var won int
err := d.QueryRow(
`SELECT games_won FROM blackjack_scores WHERE user_id = ?`,
string(u),
).Scan(&won)
return err == nil && won >= 1
},
},
{
ID: "bj_blackjack", Name: "Blackjack", Description: "21. As the prophecy foretold.",
Emoji: "🎰",
Check: func(d *sql.DB, u id.UserID) bool {
// Granted by blackjack plugin on natural 21
return false
},
},
{
ID: "bj_bust", Name: "Going for It", Description: "Hope is a beautiful thing.",
Emoji: "💥",
Check: func(d *sql.DB, u id.UserID) bool {
var played, won int
err := d.QueryRow(
`SELECT games_played, games_won FROM blackjack_scores WHERE user_id = ?`,
string(u),
).Scan(&played, &won)
// Busts are approximated as losses (played - won)
return err == nil && (played-won) >= 10
},
},
{
ID: "bj_beat_twinbee", Name: "Gotcha 🐝", Description: "She is furious and will not forget this.",
Emoji: "🐝",
Check: func(d *sql.DB, u id.UserID) bool {
// Granted by blackjack plugin when beating the bot
return false
},
},
{
ID: "bj_100_hands", Name: "Card Shark", Description: "Statistically, you should have stopped.",
Emoji: "🦈",
Check: func(d *sql.DB, u id.UserID) bool {
var played int
err := d.QueryRow(
`SELECT games_played FROM blackjack_scores WHERE user_id = ?`,
string(u),
).Scan(&played)
return err == nil && played >= 100
},
},
// ── UNO ─────────────────────────────────────────────────────────────
{
ID: "uno_first_win", Name: "UNO!", Description: "One card. Victory. Glory.",
Emoji: "🟥",
Check: func(d *sql.DB, u id.UserID) bool {
// Granted by UNO plugin on first win
return false
},
},
{
ID: "uno_nomercy_win", Name: "No Mercy", Description: "You are a monster and we respect it.",
Emoji: "😈",
Check: func(d *sql.DB, u id.UserID) bool {
// Granted by UNO plugin on No Mercy mode win
return false
},
},
{
ID: "uno_draw_stack", Name: "Stack Overflow", Description: "The pile grows. Your opponents weep.",
Emoji: "📚",
Check: func(d *sql.DB, u id.UserID) bool {
// Granted by UNO plugin on 3+ draw stacks in one game
return false
},
},
{
ID: "uno_comeback", Name: "Down to Zero", Description: "The math said no. You said yes.",
Emoji: "🔥",
Check: func(d *sql.DB, u id.UserID) bool {
// Granted by UNO plugin on winning from 10+ cards
return false
},
},
// ── Texas Hold'em ───────────────────────────────────────────────────
{
ID: "holdem_first_win", Name: "Ante Up", Description: "A pot has been claimed.",
Emoji: "♠️",
Check: func(d *sql.DB, u id.UserID) bool {
var won int
err := d.QueryRow(
`SELECT total_won FROM holdem_scores WHERE user_id = ?`,
string(u),
).Scan(&won)
return err == nil && won > 0
},
},
{
ID: "holdem_royal_flush", Name: "Royal Flush", Description: "This has never happened before in recorded history.",
Emoji: "👑",
Check: func(d *sql.DB, u id.UserID) bool {
// Granted by holdem plugin on royal flush
return false
},
},
{
ID: "holdem_bluff", Name: "Poker Face", Description: "The audacity. Incredible.",
Emoji: "😶",
Check: func(d *sql.DB, u id.UserID) bool {
// Granted by holdem plugin on high-card-only win
return false
},
},
{
ID: "holdem_beat_npc", Name: "Outsmarted the Bot", Description: "CFR trained on millions of hands. You trained on vibes.",
Emoji: "🤖",
Check: func(d *sql.DB, u id.UserID) bool {
// Granted by holdem plugin on beating CFR NPC
return false
},
},
{
ID: "holdem_allin_win", Name: "All In", Description: "Everything on the line. Everything gained.",
Emoji: "💎",
Check: func(d *sql.DB, u id.UserID) bool {
// Granted by holdem plugin on all-in win
return false
},
},
{
ID: "holdem_bounty", Name: "Bounty Hunter", Description: "They had families.",
Emoji: "🎯",
Check: func(d *sql.DB, u id.UserID) bool {
// Granted by holdem plugin on knocking out a player
return false
},
},
// ── Hangman ─────────────────────────────────────────────────────────
{
ID: "hangman_solve", Name: "Lexicographer", Description: "Language. You understand it.",
Emoji: "📖",
Check: func(d *sql.DB, u id.UserID) bool {
var won int
err := d.QueryRow(
`SELECT games_won FROM hangman_scores WHERE user_id = ?`,
string(u),
).Scan(&won)
return err == nil && won >= 1
},
},
{
ID: "hangman_first_guess", Name: "Mind Reader", Description: "How.",
Emoji: "🔮",
Check: func(d *sql.DB, u id.UserID) bool {
// Granted by hangman plugin on first-guess solve
return false
},
},
{
ID: "hangman_submitted", Name: "Contributor", Description: "Your words. Their suffering.",
Emoji: "✏️",
Check: func(d *sql.DB, u id.UserID) bool {
// Granted by hangman plugin when a submitted phrase is used
return false
},
},
{
ID: "hangman_executioner", Name: "Executioner", Description: "You did this.",
Emoji: "⚰️",
Check: func(d *sql.DB, u id.UserID) bool {
// Granted by hangman plugin on final wrong letter
return false
},
},
// ── Wordle ──────────────────────────────────────────────────────────
{
ID: "wordle_solve", Name: "Wordled", Description: "The letters obeyed you.",
Emoji: "🟩",
Check: func(d *sql.DB, u id.UserID) bool {
var solved int
err := d.QueryRow(
`SELECT puzzles_solved FROM wordle_stats WHERE user_id = ?`,
string(u),
).Scan(&solved)
return err == nil && solved >= 1
},
},
{
ID: "wordle_first_guess", Name: "Omniscient", Description: "That was statistically impossible.",
Emoji: "🧿",
Check: func(d *sql.DB, u id.UserID) bool {
// Granted by wordle plugin on 1-guess community solve
return false
},
},
{
ID: "wordle_streak_3", Name: "Hot Streak", Description: "Momentum.",
Emoji: "🔥",
Check: func(d *sql.DB, u id.UserID) bool {
// Granted by wordle plugin on 3 consecutive community wins
return false
},
},
{
ID: "wordle_bonus", Name: "Nerd", Description: "One of us.",
Emoji: "🎮",
Check: func(d *sql.DB, u id.UserID) bool {
// Granted by wordle plugin on bonus word solve
return false
},
},
{
ID: "wordle_closer", Name: "Closer", Description: "The community thanks you. Sort of.",
Emoji: "🏁",
Check: func(d *sql.DB, u id.UserID) bool {
// Granted by wordle plugin on submitting the winning guess
return false
},
},
// ── Adventure ───────────────────────────────────────────────────────
{
ID: "adv_first", Name: "Adventurer", Description: "The dungeon awaits. Probably.",
Emoji: "⚔️",
Check: func(d *sql.DB, u id.UserID) bool {
var count int
err := d.QueryRow(
`SELECT COUNT(*) FROM adventure_activity_log WHERE user_id = ?`,
string(u),
).Scan(&count)
return err == nil && count >= 1
},
},
{
ID: "adv_died", Name: "Respawning", Description: "This is fine.",
Emoji: "💀",
Check: func(d *sql.DB, u id.UserID) bool {
// Granted by adventure plugin on death
return false
},
},
{
ID: "adv_revived", Name: "Second Life", Description: "Someone cared enough.",
Emoji: "✨",
Check: func(d *sql.DB, u id.UserID) bool {
// Granted by adventure plugin on admin revive
return false
},
},
{
ID: "adv_streak_7", Name: "Daily Grind", Description: "Committed. Concerningly so.",
Emoji: "📅",
Check: func(d *sql.DB, u id.UserID) bool {
var streak int
err := d.QueryRow(
`SELECT best_streak FROM adventure_characters WHERE user_id = ?`,
string(u),
).Scan(&streak)
return err == nil && streak >= 7
},
},
{
ID: "adv_streak_30", Name: "Devoted", Description: "Please go outside.",
Emoji: "🗓️",
Check: func(d *sql.DB, u id.UserID) bool {
var streak int
err := d.QueryRow(
`SELECT best_streak FROM adventure_characters WHERE user_id = ?`,
string(u),
).Scan(&streak)
return err == nil && streak >= 30
},
},
{
ID: "adv_max_level", Name: "Legendary", Description: "What's next? Nothing. You've done it.",
Emoji: "🏆",
Check: func(d *sql.DB, u id.UserID) bool {
// Granted by adventure plugin on reaching max level
return false
},
},
{
ID: "adv_treasure_cap", Name: "Hoarder", Description: "No more pockets.",
Emoji: "💎",
Check: func(d *sql.DB, u id.UserID) bool {
var count int
err := d.QueryRow(
`SELECT COUNT(*) FROM adventure_treasures WHERE user_id = ?`,
string(u),
).Scan(&count)
return err == nil && count >= 3
},
},
{
ID: "adv_grudge_win", Name: "Revenge", Description: "Redemption arc complete.",
Emoji: "⚡",
Check: func(d *sql.DB, u id.UserID) bool {
// Granted by adventure plugin on grudge location success
return false
},
},
{
ID: "adv_party", Name: "Party of Two", Description: "Accidental cooperation.",
Emoji: "🤝",
Check: func(d *sql.DB, u id.UserID) bool {
// Granted by adventure plugin on party bonus
return false
},
},
{
ID: "adv_twinbee_gift", Name: "Blessed by TwinBee", Description: "The bee has chosen you. Unknown why.",
Emoji: "🐝",
Check: func(d *sql.DB, u id.UserID) bool {
// Granted by adventure plugin on TwinBee NPC buff
return false
},
},
// ── Word of the Day ─────────────────────────────────────────────────
{
ID: "wotd_first", Name: "Logophile Jr.", Description: "25 XP well earned.",
Emoji: "📝",
Check: func(d *sql.DB, u id.UserID) bool {
var count int
err := d.QueryRow(
`SELECT COUNT(DISTINCT date) FROM wotd_usage WHERE user_id = ? AND count > 0`,
string(u),
).Scan(&count)
return err == nil && count >= 1
},
},
{
ID: "wotd_week", Name: "Vocabulary Builder", Description: "The dictionary approves.",
Emoji: "📚",
Check: func(d *sql.DB, u id.UserID) bool {
var count int
err := d.QueryRow(
`SELECT COUNT(DISTINCT date) FROM wotd_usage WHERE user_id = ? AND count > 0`,
string(u),
).Scan(&count)
return err == nil && count >= 7
},
},
{
ID: "wotd_30", Name: "Wordsmith Elite", Description: "You have transcended mere communication.",
Emoji: "🎓",
Check: func(d *sql.DB, u id.UserID) bool {
var count int
err := d.QueryRow(
`SELECT COUNT(DISTINCT date) FROM wotd_usage WHERE user_id = ? AND count > 0`,
string(u),
).Scan(&count)
return err == nil && count >= 30
},
},
// ── Community & Social ──────────────────────────────────────────────
{
ID: "quote_saved", Name: "Quotable", Description: "Posterity has noted your words.",
Emoji: "💬",
Check: func(d *sql.DB, u id.UserID) bool {
// Granted by quote plugin when a quote is saved for the user
return false
},
},
{
ID: "quote_saved_10", Name: "Memorable", Description: "You say things worth remembering. Allegedly.",
Emoji: "🏅",
Check: func(d *sql.DB, u id.UserID) bool {
// Granted by quote plugin when 10 quotes are saved for the user
return false
},
},
{
ID: "missing_poster", Name: "Milk Carton", Description: "Someone noticed you were gone. Eventually.",
Emoji: "🥛",
Check: func(d *sql.DB, u id.UserID) bool {
// Granted by missing poster plugin
return false
},
},
{
ID: "tarot_spread", Name: "The Fool's Journey", Description: "Past, present, future. All vibes.",
Emoji: "🔮",
Check: func(d *sql.DB, u id.UserID) bool {
// Granted by tarot plugin on three-card spread
return false
},
},
}
}

View File

@@ -9,8 +9,6 @@ import (
"sync"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
)
@@ -76,13 +74,12 @@ func (p *AdventurePlugin) Init() error {
slog.Info("adventure: rehydrated DM rooms", "count", len(chars))
}
// Catch up on missed jobs (e.g. after a redeploy or SQLite busy failure)
dateKey := time.Now().UTC().Format("2006-01-02")
if !db.JobCompleted("adventure_midnight", dateKey) {
slog.Info("adventure: missed midnight reset detected, running catch-up")
if err := resetAllAdvDailyActions(); err != nil {
slog.Error("adventure: catch-up daily reset failed", "err", err)
}
// Always reset daily actions at startup — idempotent (WHERE clause
// only touches characters whose last_action_date < today). This handles
// the case where the old buggy code marked the midnight job as completed
// even though the actual reset failed due to SQLite contention.
if err := resetAllAdvDailyActions(); err != nil {
slog.Error("adventure: startup daily reset failed", "err", err)
}
// Revive any characters whose DeadUntil has expired
p.catchUpRespawns(chars)

View File

@@ -1,11 +1,16 @@
package plugin
import (
"encoding/base64"
"fmt"
"log/slog"
"math/rand"
"os"
"regexp"
"strings"
"time"
"gogobee/internal/crypto"
"gogobee/internal/db"
"maunium.net/go/mautrix"
@@ -15,20 +20,44 @@ import (
// MarkovPlugin collects messages and generates trigram-based text.
type MarkovPlugin struct {
Base
encKey []byte // AES-256 key; nil means disabled
enabled bool
}
// NewMarkovPlugin creates a new Markov chain plugin.
func NewMarkovPlugin(client *mautrix.Client) *MarkovPlugin {
return &MarkovPlugin{
p := &MarkovPlugin{
Base: NewBase(client),
}
raw := os.Getenv("QUOTE_ENCRYPTION_KEY")
if raw == "" {
slog.Error("markov: QUOTE_ENCRYPTION_KEY not set — markov collection and commands disabled")
return p
}
key, err := crypto.ParseKey(raw)
if err != nil {
slog.Error("markov: invalid QUOTE_ENCRYPTION_KEY — markov disabled", "err", err)
return p
}
p.encKey = key
p.enabled = true
// Migration: purge any unencrypted rows
p.purgeUnencryptedRows()
return p
}
func (p *MarkovPlugin) Name() string { return "markov" }
func (p *MarkovPlugin) Commands() []CommandDef {
return []CommandDef{
{Name: "markov", Description: "Generate Markov chain text from a user's messages", Usage: "!markov [@user|me]", Category: "Fun & Games"},
{Name: "markov", Description: "Generate Markov chain text from a user's messages", Usage: "!markov [@user|me|stats|forget|leaderboard]", Category: "Fun & Games"},
{Name: "impersonate", Description: "Alias for !markov @user", Usage: "!impersonate @user", Category: "Fun & Games"},
{Name: "ghostwrite", Description: "Seed Markov chain with a starting phrase", Usage: "!ghostwrite @user <prompt>", Category: "Fun & Games"},
}
}
@@ -37,9 +66,22 @@ func (p *MarkovPlugin) Init() error { return nil }
func (p *MarkovPlugin) OnReaction(_ ReactionContext) error { return nil }
func (p *MarkovPlugin) OnMessage(ctx MessageContext) error {
if !p.enabled {
if p.IsCommand(ctx.Body, "markov") || p.IsCommand(ctx.Body, "impersonate") || p.IsCommand(ctx.Body, "ghostwrite") {
return p.SendReply(ctx.RoomID, ctx.EventID, "Markov is disabled (encryption key not configured).")
}
return nil
}
if p.IsCommand(ctx.Body, "markov") {
return p.handleMarkov(ctx)
}
if p.IsCommand(ctx.Body, "impersonate") {
return p.handleImpersonate(ctx)
}
if p.IsCommand(ctx.Body, "ghostwrite") {
return p.handleGhostwrite(ctx)
}
// Passive: collect non-command messages
if !ctx.IsCommand {
@@ -49,18 +91,64 @@ func (p *MarkovPlugin) OnMessage(ctx MessageContext) error {
return nil
}
// collectMessage stores a message in the markov_corpus, capping at 10,000 per user.
// ── Regex for stripping noise from corpus entries ───────────────────────────
var (
matrixEventIDRe = regexp.MustCompile(`\$[A-Za-z0-9_/+=.-]{20,}`)
matrixRoomIDRe = regexp.MustCompile(`![A-Za-z0-9_/+=.-]+:[A-Za-z0-9._-]+`)
commandPrefixRe = regexp.MustCompile(`^!\S+\s*`)
)
// stripNoise removes Matrix event IDs, room IDs, and !command prefixes from text.
func stripNoise(text string) string {
text = matrixEventIDRe.ReplaceAllString(text, "")
text = matrixRoomIDRe.ReplaceAllString(text, "")
text = commandPrefixRe.ReplaceAllString(text, "")
return strings.TrimSpace(text)
}
// ── Encryption helpers ──────────────────────────────────────────────────────
func (p *MarkovPlugin) encryptText(plaintext string) (string, error) {
ct, err := crypto.Encrypt(p.encKey, []byte(plaintext))
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(ct), nil
}
func (p *MarkovPlugin) decryptText(encoded string) (string, error) {
ct, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", err
}
pt, err := crypto.Decrypt(p.encKey, ct)
if err != nil {
return "", err
}
return string(pt), nil
}
// ── Corpus collection ───────────────────────────────────────────────────────
// collectMessage stores an encrypted message in the markov_corpus, capping at 10,000 per user.
func (p *MarkovPlugin) collectMessage(userID id.UserID, text string) {
// Skip very short messages
if len(strings.Fields(text)) < 3 {
cleaned := stripNoise(text)
if len(strings.Fields(cleaned)) < 3 {
return
}
enc, err := p.encryptText(cleaned)
if err != nil {
slog.Error("markov: encrypt message", "err", err)
return
}
d := db.Get()
_, err := d.Exec(
_, err = d.Exec(
`INSERT INTO markov_corpus (user_id, text) VALUES (?, ?)`,
string(userID), text,
string(userID), enc,
)
if err != nil {
slog.Error("markov: insert message", "err", err)
@@ -76,74 +164,560 @@ func (p *MarkovPlugin) collectMessage(userID id.UserID, text string) {
)
}
func (p *MarkovPlugin) handleMarkov(ctx MessageContext) error {
// loadCorpus loads and decrypts all corpus entries for a user. Unencrypted/corrupt rows are skipped.
func (p *MarkovPlugin) loadCorpus(userID id.UserID) []string {
d := db.Get()
args := p.GetArgs(ctx.Body, "markov")
var targetUser id.UserID
switch {
case args == "":
// No argument — pick a random user from the corpus
var randomUser string
err := d.QueryRow(
`SELECT user_id FROM markov_corpus ORDER BY RANDOM() LIMIT 1`,
).Scan(&randomUser)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "No Markov data available yet.")
}
targetUser = id.UserID(randomUser)
case args == "me":
targetUser = ctx.Sender
default:
// Treat as user ID
if resolved, ok := p.ResolveUser(args, ctx.RoomID); ok {
targetUser = resolved
}
}
// Fetch corpus for the user
rows, err := d.Query(
`SELECT text FROM markov_corpus WHERE user_id = ?`,
string(targetUser),
string(userID),
)
if err != nil {
slog.Error("markov: query corpus", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load Markov data.")
return nil
}
defer rows.Close()
var texts []string
for rows.Next() {
var t string
if err := rows.Scan(&t); err != nil {
var enc string
if err := rows.Scan(&enc); err != nil {
continue
}
texts = append(texts, t)
pt, err := p.decryptText(enc)
if err != nil {
// Unencrypted or corrupt row — skip
continue
}
texts = append(texts, pt)
}
return texts
}
// corpusCount returns the number of corpus entries for a user.
func corpusCount(userID id.UserID) int {
d := db.Get()
var count int
_ = d.QueryRow(`SELECT COUNT(*) FROM markov_corpus WHERE user_id = ?`, string(userID)).Scan(&count)
return count
}
// ── Migration ───────────────────────────────────────────────────────────────
// purgeUnencryptedRows detects unencrypted rows (decrypt fails on non-base64) and purges them.
func (p *MarkovPlugin) purgeUnencryptedRows() {
d := db.Get()
rows, err := d.Query(`SELECT id, text FROM markov_corpus`)
if err != nil {
slog.Error("markov: migration scan", "err", err)
return
}
defer rows.Close()
var badIDs []interface{}
for rows.Next() {
var rowID int64
var enc string
if err := rows.Scan(&rowID, &enc); err != nil {
continue
}
_, err := p.decryptText(enc)
if err != nil {
badIDs = append(badIDs, rowID)
}
}
if len(texts) < 10 {
if len(badIDs) == 0 {
return
}
slog.Warn("markov: purging unencrypted/corrupt rows", "count", len(badIDs))
// Delete in batches of 100
for i := 0; i < len(badIDs); i += 100 {
end := i + 100
if end > len(badIDs) {
end = len(badIDs)
}
batch := badIDs[i:end]
placeholders := strings.Repeat("?,", len(batch))
placeholders = placeholders[:len(placeholders)-1]
_, err := d.Exec(
fmt.Sprintf(`DELETE FROM markov_corpus WHERE id IN (%s)`, placeholders),
batch...,
)
if err != nil {
slog.Error("markov: migration delete batch", "err", err)
}
}
}
// ── TTL purge ───────────────────────────────────────────────────────────────
// MarkovPurgeExpired deletes corpus entries older than 90 days. Intended to be
// called from the nightly scheduled jobs in main.go.
func MarkovPurgeExpired() {
d := db.Get()
cutoff := time.Now().UTC().AddDate(0, 0, -90).Unix()
res, err := d.Exec(`DELETE FROM markov_corpus WHERE created_at < ?`, cutoff)
if err != nil {
slog.Error("markov: purge expired", "err", err)
return
}
n, _ := res.RowsAffected()
if n > 0 {
slog.Info("markov: purged expired entries", "rows", n)
}
}
// ── Command handlers ────────────────────────────────────────────────────────
func (p *MarkovPlugin) handleMarkov(ctx MessageContext) error {
args := p.GetArgs(ctx.Body, "markov")
switch {
case args == "":
return p.generateForUser(ctx, ctx.Sender, "")
case args == "me":
return p.generateForUser(ctx, ctx.Sender, "")
case strings.HasPrefix(args, "stats"):
return p.handleStats(ctx, strings.TrimSpace(strings.TrimPrefix(args, "stats")))
case args == "forget":
return p.handleForgetSelf(ctx)
case strings.HasPrefix(args, "forget "):
return p.handleForgetAdmin(ctx, strings.TrimSpace(strings.TrimPrefix(args, "forget ")))
case args == "leaderboard":
return p.handleLeaderboard(ctx)
default:
// Could be one user or two users (mashup)
return p.handleGenerate(ctx, args)
}
}
func (p *MarkovPlugin) handleImpersonate(ctx MessageContext) error {
args := p.GetArgs(ctx.Body, "impersonate")
if args == "" {
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !impersonate @user")
}
resolved, ok := p.ResolveUser(args, ctx.RoomID)
if !ok {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Could not resolve user: %s", args))
}
return p.generateForUser(ctx, resolved, "")
}
func (p *MarkovPlugin) handleGhostwrite(ctx MessageContext) error {
args := p.GetArgs(ctx.Body, "ghostwrite")
parts := strings.SplitN(args, " ", 2)
if len(parts) < 2 {
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !ghostwrite @user <prompt>")
}
resolved, ok := p.ResolveUser(parts[0], ctx.RoomID)
if !ok {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Could not resolve user: %s", parts[0]))
}
seed := strings.TrimSpace(parts[1])
return p.generateForUser(ctx, resolved, seed)
}
// handleGenerate parses one or two user mentions and generates accordingly.
func (p *MarkovPlugin) handleGenerate(ctx MessageContext, args string) error {
fields := strings.Fields(args)
if len(fields) == 1 {
resolved, ok := p.ResolveUser(fields[0], ctx.RoomID)
if !ok {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Could not resolve user: %s", fields[0]))
}
return p.generateForUser(ctx, resolved, "")
}
if len(fields) == 2 {
user1, ok1 := p.ResolveUser(fields[0], ctx.RoomID)
user2, ok2 := p.ResolveUser(fields[1], ctx.RoomID)
if !ok1 || !ok2 {
return p.SendReply(ctx.RoomID, ctx.EventID, "Could not resolve one or both users.")
}
return p.generateMashup(ctx, user1, user2)
}
// More than 2 — cap at 2
return p.SendReply(ctx.RoomID, ctx.EventID, "Mashup supports at most 2 users.")
}
// generateForUser generates markov text for a single user, optionally seeded.
func (p *MarkovPlugin) generateForUser(ctx MessageContext, userID id.UserID, seed string) error {
texts := p.loadCorpus(userID)
if len(texts) < 50 {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("Not enough data for %s (need at least 10 messages).", string(targetUser)))
fmt.Sprintf("Not enough data for %s (%d messages, need at least 50).",
p.DisplayName(userID), len(texts)))
}
// Build trigram model and generate
result := generateMarkov(texts, 50)
result := p.generateOutput(texts, seed)
if result == "" {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to generate Markov text.")
}
msg := fmt.Sprintf("[%s]: %s", string(targetUser), result)
// Re-roll once if exact corpus match
for _, t := range texts {
if result == t {
result = p.generateOutput(texts, seed)
break
}
}
if result == "" {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to generate Markov text.")
}
name := p.DisplayName(userID)
msg := fmt.Sprintf("[%s]: %s", name, result)
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
}
// generateMashup interleaves corpora of two users and generates.
func (p *MarkovPlugin) generateMashup(ctx MessageContext, user1, user2 id.UserID) error {
texts1 := p.loadCorpus(user1)
texts2 := p.loadCorpus(user2)
if len(texts1) < 50 {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("Not enough data for %s (%d messages, need at least 50).",
p.DisplayName(user1), len(texts1)))
}
if len(texts2) < 50 {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("Not enough data for %s (%d messages, need at least 50).",
p.DisplayName(user2), len(texts2)))
}
// Interleave corpora
combined := make([]string, 0, len(texts1)+len(texts2))
i, j := 0, 0
for i < len(texts1) || j < len(texts2) {
if i < len(texts1) {
combined = append(combined, texts1[i])
i++
}
if j < len(texts2) {
combined = append(combined, texts2[j])
j++
}
}
result := p.generateOutput(combined, "")
if result == "" {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to generate Markov text.")
}
name1 := p.DisplayName(user1)
name2 := p.DisplayName(user2)
msg := fmt.Sprintf("[%s × %s]: %s", name1, name2, result)
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
}
// generateOutput builds a trigram model and generates 1-3 sentences, max 280 chars.
// If seed is non-empty, tries to start the chain near the seed words.
func (p *MarkovPlugin) generateOutput(texts []string, seed string) string {
chain, starters := buildTrigramModel(texts)
if len(starters) == 0 {
return ""
}
var start trigramKey
if seed != "" {
// Try to find a starter that begins with/near the seed
seedWords := strings.Fields(strings.ToLower(seed))
if len(seedWords) >= 2 {
// Look for exact bigram match
target := trigramKey{seedWords[0], seedWords[1]}
if _, ok := chain[target]; ok {
start = target
}
}
if start.w1 == "" && len(seedWords) >= 1 {
// Look for a starter beginning with the first seed word
seedLower := seedWords[0]
var candidates []trigramKey
for _, s := range starters {
if strings.ToLower(s.w1) == seedLower {
candidates = append(candidates, s)
}
}
if len(candidates) > 0 {
start = candidates[rand.Intn(len(candidates))]
}
}
}
// Fallback to random starter
if start.w1 == "" {
start = starters[rand.Intn(len(starters))]
}
result := []string{start.w1, start.w2}
sentences := countSentenceEnds(start.w1) + countSentenceEnds(start.w2)
for len(result) < 60 {
key := trigramKey{result[len(result)-2], result[len(result)-1]}
nextWords, ok := chain[key]
if !ok || len(nextWords) == 0 {
break
}
next := nextWords[rand.Intn(len(nextWords))]
result = append(result, next)
if endsWithPunctuation(next) {
sentences++
if sentences >= 3 {
break
}
// After at least 8 words and 1 sentence, chance to stop
if len(result) > 8 && rand.Float64() < 0.3 {
break
}
}
}
output := strings.Join(result, " ")
// Truncate to 280 chars at sentence boundary
if len(output) > 280 {
output = truncateAtSentence(output, 280)
}
return output
}
func countSentenceEnds(s string) int {
n := 0
for _, c := range s {
if c == '.' || c == '!' || c == '?' {
n++
}
}
return n
}
// truncateAtSentence truncates text to maxLen, preferring to cut at a sentence boundary.
func truncateAtSentence(text string, maxLen int) string {
if len(text) <= maxLen {
return text
}
sub := text[:maxLen]
// Find last sentence-ending punctuation
lastEnd := -1
for i := len(sub) - 1; i >= 0; i-- {
if sub[i] == '.' || sub[i] == '!' || sub[i] == '?' {
lastEnd = i
break
}
}
if lastEnd > 0 {
return sub[:lastEnd+1]
}
// No sentence boundary — hard truncate at word boundary
lastSpace := strings.LastIndex(sub, " ")
if lastSpace > 0 {
return sub[:lastSpace] + "..."
}
return sub
}
// ── Stats ───────────────────────────────────────────────────────────────────
func (p *MarkovPlugin) handleStats(ctx MessageContext, userArg string) error {
var targetUser id.UserID
if userArg == "" || userArg == "me" {
targetUser = ctx.Sender
} else {
resolved, ok := p.ResolveUser(userArg, ctx.RoomID)
if !ok {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Could not resolve user: %s", userArg))
}
targetUser = resolved
}
d := db.Get()
// Message count
var msgCount int
_ = d.QueryRow(`SELECT COUNT(*) FROM markov_corpus WHERE user_id = ?`, string(targetUser)).Scan(&msgCount)
if msgCount == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("%s has no Markov data.", p.DisplayName(targetUser)))
}
// Earliest date
var earliest int64
_ = d.QueryRow(`SELECT MIN(created_at) FROM markov_corpus WHERE user_id = ?`, string(targetUser)).Scan(&earliest)
earliestDate := time.Unix(earliest, 0).UTC().Format("2006-01-02")
// Decrypt all to compute word stats
texts := p.loadCorpus(targetUser)
wordFreq := make(map[string]int)
totalWords := 0
for _, t := range texts {
for _, w := range strings.Fields(t) {
w = strings.ToLower(w)
wordFreq[w]++
totalWords++
}
}
uniqueWords := len(wordFreq)
// Top 5 words
type wordCount struct {
word string
count int
}
var top []wordCount
for w, c := range wordFreq {
if len(w) < 3 { // skip very short words
continue
}
inserted := false
for i, tc := range top {
if c > tc.count {
top = append(top, wordCount{})
copy(top[i+1:], top[i:])
top[i] = wordCount{w, c}
inserted = true
break
}
}
if !inserted && len(top) < 5 {
top = append(top, wordCount{w, c})
}
if len(top) > 5 {
top = top[:5]
}
}
name := p.DisplayName(targetUser)
var sb strings.Builder
sb.WriteString(fmt.Sprintf("📊 Markov Stats — %s\n\n", name))
sb.WriteString(fmt.Sprintf("Messages: %d\n", msgCount))
sb.WriteString(fmt.Sprintf("Unique words: %d\n", uniqueWords))
sb.WriteString(fmt.Sprintf("Total words: %d\n", totalWords))
sb.WriteString(fmt.Sprintf("Since: %s\n", earliestDate))
if len(top) > 0 {
sb.WriteString("\nTop words: ")
for i, tc := range top {
if i > 0 {
sb.WriteString(", ")
}
sb.WriteString(fmt.Sprintf("%s (%d)", tc.word, tc.count))
}
sb.WriteString("\n")
}
sb.WriteString("\n Entries older than 90 days are purged automatically.")
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
}
// ── Forget ──────────────────────────────────────────────────────────────────
func (p *MarkovPlugin) handleForgetSelf(ctx MessageContext) error {
count := corpusCount(ctx.Sender)
if count == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, "Your Markov corpus is already empty.")
}
d := db.Get()
_, err := d.Exec(`DELETE FROM markov_corpus WHERE user_id = ?`, string(ctx.Sender))
if err != nil {
slog.Error("markov: forget self", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to delete your Markov corpus.")
}
_ = p.SendDM(ctx.Sender,
fmt.Sprintf("Deleted %d entries from your Markov corpus. This cannot be undone.", count))
return p.SendReply(ctx.RoomID, ctx.EventID, "Your Markov corpus has been deleted.")
}
func (p *MarkovPlugin) handleForgetAdmin(ctx MessageContext, userArg string) error {
if !p.IsAdmin(ctx.Sender) {
return p.SendReply(ctx.RoomID, ctx.EventID, "Only admins can delete another user's corpus.")
}
resolved, ok := p.ResolveUser(userArg, ctx.RoomID)
if !ok {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Could not resolve user: %s", userArg))
}
count := corpusCount(resolved)
if count == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("%s has no Markov data.", p.DisplayName(resolved)))
}
d := db.Get()
_, err := d.Exec(`DELETE FROM markov_corpus WHERE user_id = ?`, string(resolved))
if err != nil {
slog.Error("markov: forget admin", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to delete corpus.")
}
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("Deleted %d Markov entries for %s.", count, p.DisplayName(resolved)))
}
// ── Leaderboard ─────────────────────────────────────────────────────────────
func (p *MarkovPlugin) handleLeaderboard(ctx MessageContext) error {
d := db.Get()
rows, err := d.Query(
`SELECT user_id, COUNT(*) as cnt FROM markov_corpus
GROUP BY user_id ORDER BY cnt DESC LIMIT 10`,
)
if err != nil {
slog.Error("markov: leaderboard query", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load leaderboard.")
}
defer rows.Close()
var sb strings.Builder
sb.WriteString("🏆 Markov Leaderboard — Top 10 by Corpus Size\n\n")
rank := 0
for rows.Next() {
var uid string
var cnt int
if err := rows.Scan(&uid, &cnt); err != nil {
continue
}
rank++
name := p.DisplayName(id.UserID(uid))
sb.WriteString(fmt.Sprintf("%d. %s — %d messages\n", rank, name, cnt))
}
if rank == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, "No Markov data available yet.")
}
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
}
// ── Trigram model ───────────────────────────────────────────────────────────
// trigram key
type trigramKey struct {
w1, w2 string
}
// generateMarkov builds a trigram model from texts and generates output.
func generateMarkov(texts []string, maxWords int) string {
// buildTrigramModel builds a trigram chain and collects starters from texts.
func buildTrigramModel(texts []string) (map[trigramKey][]string, []trigramKey) {
chain := make(map[trigramKey][]string)
var starters []trigramKey
@@ -161,6 +735,14 @@ func generateMarkov(texts []string, maxWords int) string {
}
}
return chain, starters
}
// generateMarkov builds a trigram model from texts and generates output.
// Kept for backward compatibility but delegates to the new model.
func generateMarkov(texts []string, maxWords int) string {
chain, starters := buildTrigramModel(texts)
if len(starters) == 0 {
return ""
}

744
internal/plugin/miniflux.go Normal file
View File

@@ -0,0 +1,744 @@
package plugin
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
)
// MinifluxPlugin polls a self-hosted Miniflux RSS instance for new feed entries
// and routes them to configured Matrix rooms.
type MinifluxPlugin struct {
Base
httpClient *http.Client
mu sync.Mutex
enabled bool
baseURL string
apiKey string
pollInterval int // minutes
defaultRoom string
maxPerPoll int
feedFailCounts map[int64]int // per-feed consecutive failure counter
pollingDisabled bool // set true on 401
}
func NewMinifluxPlugin(client *mautrix.Client) *MinifluxPlugin {
return &MinifluxPlugin{
Base: NewBase(client),
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
func (p *MinifluxPlugin) Name() string { return "miniflux" }
func (p *MinifluxPlugin) Commands() []CommandDef {
return []CommandDef{
{Name: "rss", Description: "Miniflux RSS feed routing", Usage: "!rss feeds · !rss subscribe <feed_id> [#room] · !rss unsubscribe <feed_id> · !rss subscriptions · !rss latest <feed_id> · !rss pause <feed_id> · !rss resume <feed_id> · !rss status", Category: "Automation"},
}
}
func (p *MinifluxPlugin) Init() error {
p.baseURL = strings.TrimRight(os.Getenv("MINIFLUX_URL"), "/")
p.apiKey = os.Getenv("MINIFLUX_API_KEY")
if p.baseURL == "" || p.apiKey == "" {
slog.Info("miniflux: disabled (MINIFLUX_URL or MINIFLUX_API_KEY not set)")
return nil
}
p.pollInterval = 15
if v := os.Getenv("MINIFLUX_POLL_INTERVAL"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
p.pollInterval = n
}
}
p.defaultRoom = os.Getenv("MINIFLUX_DEFAULT_ROOM")
if p.defaultRoom == "" {
p.defaultRoom = os.Getenv("BROADCAST_ROOMS")
if idx := strings.Index(p.defaultRoom, ","); idx > 0 {
p.defaultRoom = p.defaultRoom[:idx]
}
}
p.defaultRoom = strings.TrimSpace(p.defaultRoom)
p.maxPerPoll = 5
if v := os.Getenv("MINIFLUX_MAX_PER_POLL"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
p.maxPerPoll = n
}
}
p.feedFailCounts = make(map[int64]int)
p.enabled = true
slog.Info("miniflux: initialized", "url", p.baseURL, "poll_interval", p.pollInterval, "max_per_poll", p.maxPerPoll)
return nil
}
func (p *MinifluxPlugin) OnReaction(_ ReactionContext) error { return nil }
// PollInterval returns the configured polling interval in minutes.
func (p *MinifluxPlugin) PollInterval() int { return p.pollInterval }
func (p *MinifluxPlugin) OnMessage(ctx MessageContext) error {
if !p.IsCommand(ctx.Body, "rss") {
return nil
}
if !p.enabled {
return p.SendReply(ctx.RoomID, ctx.EventID, "RSS feed routing is disabled (MINIFLUX_URL not configured).")
}
args := strings.TrimSpace(p.GetArgs(ctx.Body, "rss"))
if args == "" {
return p.SendReply(ctx.RoomID, ctx.EventID,
"Usage: `!rss feeds` · `!rss subscribe <feed_id> [#room]` · `!rss unsubscribe <feed_id>` · `!rss subscriptions` · `!rss latest <feed_id>` · `!rss pause <feed_id>` · `!rss resume <feed_id>` · `!rss status`")
}
parts := strings.Fields(args)
sub := strings.ToLower(parts[0])
switch sub {
case "feeds":
go func() {
if err := p.cmdFeeds(ctx); err != nil {
slog.Error("miniflux: feeds error", "err", err)
}
}()
return nil
case "subscribe":
if !p.IsAdmin(ctx.Sender) {
return p.SendReply(ctx.RoomID, ctx.EventID, "Only admins can manage subscriptions.")
}
return p.cmdSubscribe(ctx, parts[1:])
case "unsubscribe":
if !p.IsAdmin(ctx.Sender) {
return p.SendReply(ctx.RoomID, ctx.EventID, "Only admins can manage subscriptions.")
}
return p.cmdUnsubscribe(ctx, parts[1:])
case "subscriptions":
return p.cmdSubscriptions(ctx)
case "latest":
go func() {
if err := p.cmdLatest(ctx, parts[1:]); err != nil {
slog.Error("miniflux: latest error", "err", err)
}
}()
return nil
case "pause":
if !p.IsAdmin(ctx.Sender) {
return p.SendReply(ctx.RoomID, ctx.EventID, "Only admins can manage subscriptions.")
}
return p.cmdPause(ctx, parts[1:])
case "resume":
if !p.IsAdmin(ctx.Sender) {
return p.SendReply(ctx.RoomID, ctx.EventID, "Only admins can manage subscriptions.")
}
return p.cmdResume(ctx, parts[1:])
case "status":
if !p.IsAdmin(ctx.Sender) {
return p.SendReply(ctx.RoomID, ctx.EventID, "Only admins can view polling status.")
}
return p.cmdStatus(ctx)
default:
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Unknown subcommand `%s`. Try `!rss` for usage.", parts[0]))
}
}
// ── Miniflux API Types ──────────────────────────────────────────────────────
// errMinifluxUnauthorized is returned on 401 to allow reliable type checking.
var errMinifluxUnauthorized = fmt.Errorf("miniflux: 401 unauthorized")
type minifluxFeed struct {
ID int64 `json:"id"`
Title string `json:"title"`
SiteURL string `json:"site_url"`
Category struct {
Title string `json:"title"`
} `json:"category"`
}
type minifluxEntry struct {
ID int64 `json:"id"`
FeedID int64 `json:"feed_id"`
Title string `json:"title"`
URL string `json:"url"`
Content string `json:"content"`
Author string `json:"author"`
PublishedAt string `json:"published_at"`
Feed struct {
Title string `json:"title"`
} `json:"feed"`
}
type minifluxFeedsResponse []minifluxFeed
type minifluxEntriesResponse struct {
Total int `json:"total"`
Entries []minifluxEntry `json:"entries"`
}
// ── API Helpers ─────────────────────────────────────────────────────────────
func (p *MinifluxPlugin) minifluxGet(endpoint string, result interface{}) error {
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
url := p.baseURL + endpoint
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return err
}
req.Header.Set("X-Auth-Token", p.apiKey)
resp, err := p.httpClient.Do(req)
if err != nil {
return fmt.Errorf("miniflux API error: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return errMinifluxUnauthorized
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("miniflux API returned %d", resp.StatusCode)
}
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
return fmt.Errorf("miniflux decode error: %w", err)
}
return nil
}
func (p *MinifluxPlugin) fetchFeeds() ([]minifluxFeed, error) {
var feeds minifluxFeedsResponse
if err := p.minifluxGet("/v1/feeds", &feeds); err != nil {
return nil, err
}
return feeds, nil
}
func (p *MinifluxPlugin) fetchEntries(feedID int64, limit int) ([]minifluxEntry, error) {
var resp minifluxEntriesResponse
endpoint := fmt.Sprintf("/v1/feeds/%d/entries?status=unread&limit=%d&order=published_at&direction=asc", feedID, limit)
if err := p.minifluxGet(endpoint, &resp); err != nil {
return nil, err
}
return resp.Entries, nil
}
// ── Command Handlers ────────────────────────────────────────────────────────
func (p *MinifluxPlugin) cmdFeeds(ctx MessageContext) error {
feeds, err := p.fetchFeeds()
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Could not fetch feeds: %v", err))
}
if len(feeds) == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, "No feeds found in Miniflux.")
}
var sb strings.Builder
sb.WriteString("**Miniflux Feeds**\n\n")
for _, f := range feeds {
cat := f.Category.Title
if cat == "" {
cat = "Uncategorized"
}
sb.WriteString(fmt.Sprintf("**%d** — %s _%s_\n", f.ID, f.Title, cat))
}
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
}
func (p *MinifluxPlugin) cmdSubscribe(ctx MessageContext, args []string) error {
if len(args) < 1 {
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!rss subscribe <feed_id> [#room]`")
}
feedID, err := strconv.ParseInt(args[0], 10, 64)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Invalid feed ID `%s`.", args[0]))
}
roomID := string(ctx.RoomID)
if len(args) >= 2 {
roomID = args[1]
}
now := time.Now().Unix()
d := db.Get()
_, err = d.Exec(
`INSERT INTO miniflux_subscriptions (feed_id, room_id, paused, created_at) VALUES (?, ?, 0, ?)
ON CONFLICT(feed_id, room_id) DO UPDATE SET paused = 0`,
feedID, roomID, now,
)
if err != nil {
slog.Error("miniflux: subscribe insert", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to save subscription.")
}
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Subscribed feed **%d** to room `%s`.", feedID, roomID))
}
func (p *MinifluxPlugin) cmdUnsubscribe(ctx MessageContext, args []string) error {
if len(args) < 1 {
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!rss unsubscribe <feed_id>`")
}
feedID, err := strconv.ParseInt(args[0], 10, 64)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Invalid feed ID `%s`.", args[0]))
}
res, err := db.Get().Exec(
`DELETE FROM miniflux_subscriptions WHERE feed_id = ? AND room_id = ?`,
feedID, string(ctx.RoomID),
)
if err != nil {
slog.Error("miniflux: unsubscribe delete", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to remove subscription.")
}
n, _ := res.RowsAffected()
if n == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No subscription found for feed **%d** in this room.", feedID))
}
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Unsubscribed feed **%d** from this room.", feedID))
}
func (p *MinifluxPlugin) cmdSubscriptions(ctx MessageContext) error {
d := db.Get()
rows, err := d.Query(
`SELECT feed_id, room_id, paused, created_at FROM miniflux_subscriptions WHERE room_id = ?`,
string(ctx.RoomID),
)
if err != nil {
slog.Error("miniflux: query subscriptions", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to query subscriptions.")
}
defer rows.Close()
var sb strings.Builder
sb.WriteString("**RSS Subscriptions for this room**\n\n")
count := 0
for rows.Next() {
var feedID int64
var roomID string
var paused int
var createdAt int64
if err := rows.Scan(&feedID, &roomID, &paused, &createdAt); err != nil {
continue
}
status := "active"
if paused == 1 {
status = "paused"
}
t := time.Unix(createdAt, 0).UTC().Format("Jan 2, 2006")
sb.WriteString(fmt.Sprintf("Feed **%d** — %s _(since %s)_\n", feedID, status, t))
count++
}
if count == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, "No RSS subscriptions for this room. Use `!rss subscribe <feed_id>` to add one.")
}
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
}
func (p *MinifluxPlugin) cmdLatest(ctx MessageContext, args []string) error {
if len(args) < 1 {
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!rss latest <feed_id>`")
}
feedID, err := strconv.ParseInt(args[0], 10, 64)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Invalid feed ID `%s`.", args[0]))
}
entries, err := p.fetchEntries(feedID, 1)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Could not fetch entries: %v", err))
}
if len(entries) == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, "No unread entries for this feed.")
}
msg := formatEntry(entries[0])
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
}
func (p *MinifluxPlugin) cmdPause(ctx MessageContext, args []string) error {
if len(args) < 1 {
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!rss pause <feed_id>`")
}
feedID, err := strconv.ParseInt(args[0], 10, 64)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Invalid feed ID `%s`.", args[0]))
}
res, err := db.Get().Exec(
`UPDATE miniflux_subscriptions SET paused = 1 WHERE feed_id = ? AND room_id = ?`,
feedID, string(ctx.RoomID),
)
if err != nil {
slog.Error("miniflux: pause update", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to pause subscription.")
}
n, _ := res.RowsAffected()
if n == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No subscription found for feed **%d** in this room.", feedID))
}
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Paused feed **%d** in this room.", feedID))
}
func (p *MinifluxPlugin) cmdResume(ctx MessageContext, args []string) error {
if len(args) < 1 {
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!rss resume <feed_id>`")
}
feedID, err := strconv.ParseInt(args[0], 10, 64)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Invalid feed ID `%s`.", args[0]))
}
res, err := db.Get().Exec(
`UPDATE miniflux_subscriptions SET paused = 0 WHERE feed_id = ? AND room_id = ?`,
feedID, string(ctx.RoomID),
)
if err != nil {
slog.Error("miniflux: resume update", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to resume subscription.")
}
n, _ := res.RowsAffected()
if n == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No subscription found for feed **%d** in this room.", feedID))
}
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Resumed feed **%d** in this room.", feedID))
}
func (p *MinifluxPlugin) cmdStatus(ctx MessageContext) error {
p.mu.Lock()
disabled := p.pollingDisabled
totalFailing := 0
for _, v := range p.feedFailCounts {
if v > 0 {
totalFailing++
}
}
p.mu.Unlock()
status := "active"
if disabled {
status = "DISABLED (401 unauthorized)"
} else if totalFailing > 0 {
status = fmt.Sprintf("degraded (%d feeds with errors)", totalFailing)
}
// Count subscriptions
var total, active, paused int
d := db.Get()
_ = d.QueryRow(`SELECT COUNT(*) FROM miniflux_subscriptions`).Scan(&total)
_ = d.QueryRow(`SELECT COUNT(*) FROM miniflux_subscriptions WHERE paused = 0`).Scan(&active)
_ = d.QueryRow(`SELECT COUNT(*) FROM miniflux_subscriptions WHERE paused = 1`).Scan(&paused)
var seenCount int
_ = d.QueryRow(`SELECT COUNT(*) FROM miniflux_seen`).Scan(&seenCount)
msg := fmt.Sprintf("**RSS Polling Status**\n\n"+
"Status: **%s**\n"+
"Poll interval: %d minutes\n"+
"Max per poll: %d\n"+
"Subscriptions: %d total (%d active, %d paused)\n"+
"Seen entries tracked: %d",
status, p.pollInterval, p.maxPerPoll, total, active, paused, seenCount)
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
}
// ── Entry Formatting ────────────────────────────────────────────────────────
func formatEntry(e minifluxEntry) string {
feedName := e.Feed.Title
if feedName == "" {
feedName = "RSS"
}
summary := stripHTMLTags(e.Content)
summary = truncateAtWordBoundary(summary, 280)
var sb strings.Builder
sb.WriteString(fmt.Sprintf("📰 %s\n\n", feedName))
sb.WriteString(fmt.Sprintf("**%s**\n", e.Title))
sb.WriteString(e.URL + "\n\n")
if summary != "" {
sb.WriteString(fmt.Sprintf("> %s\n\n", summary))
}
// Build attribution line
var attr []string
if e.Author != "" {
attr = append(attr, e.Author)
}
if e.PublishedAt != "" {
if t, err := time.Parse(time.RFC3339, e.PublishedAt); err == nil {
attr = append(attr, timeAgo(t))
}
}
if len(attr) > 0 {
sb.WriteString("— " + strings.Join(attr, " · "))
}
return sb.String()
}
// truncateAtWordBoundary truncates s to at most maxLen characters, breaking at
// a word boundary, and appends an ellipsis if truncated.
func truncateAtWordBoundary(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
// Find last space before maxLen
truncated := s[:maxLen]
if idx := strings.LastIndex(truncated, " "); idx > 0 {
truncated = truncated[:idx]
}
return truncated + "…"
}
// timeAgo returns a human-readable relative time string.
func timeAgo(t time.Time) string {
d := time.Since(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
m := int(d.Minutes())
if m == 1 {
return "1m ago"
}
return fmt.Sprintf("%dm ago", m)
case d < 24*time.Hour:
h := int(d.Hours())
if h == 1 {
return "1h ago"
}
return fmt.Sprintf("%dh ago", h)
default:
days := int(d.Hours() / 24)
if days == 1 {
return "1d ago"
}
return fmt.Sprintf("%dd ago", days)
}
}
// ── Polling (called by scheduler) ───────────────────────────────────────────
// MinifluxPoll runs one poll cycle: fetches new entries for all active
// subscriptions and posts them to the configured rooms. Safe to call from
// the scheduler goroutine.
func MinifluxPoll(client *mautrix.Client) {
p := findMinifluxPlugin(client)
if p == nil {
return
}
p.poll()
}
// minifluxPluginInstance caches the singleton so the scheduler can find it.
var (
minifluxPluginInstance *MinifluxPlugin
minifluxPluginInstanceMu sync.Mutex
)
// RegisterMinifluxPlugin stores the plugin instance for scheduler access.
func RegisterMinifluxPlugin(p *MinifluxPlugin) {
minifluxPluginInstanceMu.Lock()
minifluxPluginInstance = p
minifluxPluginInstanceMu.Unlock()
}
func findMinifluxPlugin(_ *mautrix.Client) *MinifluxPlugin {
minifluxPluginInstanceMu.Lock()
defer minifluxPluginInstanceMu.Unlock()
return minifluxPluginInstance
}
func (p *MinifluxPlugin) poll() {
if !p.enabled {
return
}
p.mu.Lock()
if p.pollingDisabled {
p.mu.Unlock()
return
}
p.mu.Unlock()
// Get all active (non-paused) subscriptions
d := db.Get()
rows, err := d.Query(`SELECT DISTINCT feed_id, room_id FROM miniflux_subscriptions WHERE paused = 0`)
if err != nil {
slog.Error("miniflux: poll query subscriptions", "err", err)
return
}
type sub struct {
feedID int64
roomID string
}
var subs []sub
for rows.Next() {
var s sub
if err := rows.Scan(&s.feedID, &s.roomID); err != nil {
continue
}
subs = append(subs, s)
}
rows.Close() // close explicitly before HTTP work
if len(subs) == 0 {
return
}
// Group subscriptions by feed ID
feedRooms := make(map[int64][]string)
for _, s := range subs {
feedRooms[s.feedID] = append(feedRooms[s.feedID], s.roomID)
}
for feedID, rooms := range feedRooms {
entries, err := p.fetchEntries(feedID, p.maxPerPoll)
if err != nil {
p.handlePollError(feedID, err)
slog.Error("miniflux: poll fetch entries", "feed_id", feedID, "err", err)
continue
}
// Reset consecutive failures for this feed on success
p.mu.Lock()
delete(p.feedFailCounts, feedID)
p.mu.Unlock()
for _, entry := range entries {
// Check if already seen
if p.isEntrySeen(feedID, entry.ID) {
continue
}
// Check if this is a first-time subscription and entry is older than 24h
if p.isEntryTooOld(entry) {
p.markEntrySeen(feedID, entry.ID)
continue
}
// Post to all subscribed rooms
msg := formatEntry(entry)
for _, roomID := range rooms {
if err := p.SendMessage(id.RoomID(roomID), msg); err != nil {
slog.Error("miniflux: post entry", "feed_id", feedID, "entry_id", entry.ID, "room", roomID, "err", err)
}
}
p.markEntrySeen(feedID, entry.ID)
}
}
}
func (p *MinifluxPlugin) handlePollError(feedID int64, err error) {
p.mu.Lock()
defer p.mu.Unlock()
// On 401, disable polling immediately and alert admins
if errors.Is(err, errMinifluxUnauthorized) {
p.pollingDisabled = true
slog.Error("miniflux: 401 unauthorized, disabling all polling")
go p.alertAdmins("RSS polling has been **disabled** due to a 401 Unauthorized response from Miniflux. Check MINIFLUX_API_KEY.")
return
}
p.feedFailCounts[feedID]++
if p.feedFailCounts[feedID] == 5 {
slog.Error("miniflux: 5 consecutive poll failures for feed", "feed_id", feedID)
go p.alertAdmins(fmt.Sprintf("RSS feed **%d** has failed **5 consecutive polls**. Latest error: %v", feedID, err))
}
}
func (p *MinifluxPlugin) alertAdmins(msg string) {
admins := os.Getenv("ADMIN_USERS")
if admins == "" {
return
}
for _, a := range strings.Split(admins, ",") {
userID := id.UserID(strings.TrimSpace(a))
if userID == "" {
continue
}
if err := p.SendDM(userID, msg); err != nil {
slog.Error("miniflux: failed to DM admin", "admin", userID, "err", err)
}
}
}
func (p *MinifluxPlugin) isEntrySeen(feedID, entryID int64) bool {
var count int
err := db.Get().QueryRow(
`SELECT COUNT(*) FROM miniflux_seen WHERE feed_id = ? AND entry_id = ?`,
feedID, entryID,
).Scan(&count)
return err == nil && count > 0
}
func (p *MinifluxPlugin) markEntrySeen(feedID, entryID int64) {
db.Exec("miniflux mark seen",
`INSERT OR IGNORE INTO miniflux_seen (feed_id, entry_id, seen_at) VALUES (?, ?, ?)`,
feedID, entryID, time.Now().Unix(),
)
}
// isEntryTooOld returns true if the entry was published more than 24h ago.
// Used to skip old entries on first subscription.
func (p *MinifluxPlugin) isEntryTooOld(entry minifluxEntry) bool {
if entry.PublishedAt == "" {
return false
}
t, err := time.Parse(time.RFC3339, entry.PublishedAt)
if err != nil {
return false
}
return time.Since(t) > 24*time.Hour
}
// ── Maintenance ─────────────────────────────────────────────────────────────
// MinifluxPurgeSeen removes miniflux_seen entries older than 7 days.
// Intended to be called from the maintenance scheduler.
func MinifluxPurgeSeen() {
cutoff := time.Now().Add(-7 * 24 * time.Hour).Unix()
res, err := db.Get().Exec(`DELETE FROM miniflux_seen WHERE seen_at < ?`, cutoff)
if err != nil {
slog.Error("miniflux: purge seen", "err", err)
return
}
n, _ := res.RowsAffected()
if n > 0 {
slog.Info("miniflux: purged seen entries", "rows", n)
}
}

View File

@@ -2,14 +2,15 @@ package plugin
import (
"context"
"database/sql"
"fmt"
"log/slog"
"strings"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
@@ -29,7 +30,7 @@ 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"},
{Name: "emojiboard", Description: "Top emoji givers, receivers, and most used emojis", Usage: "!emojiboard [@user | received [@user] | givers <emoji> | week]", Category: "Reactions"},
}
}
@@ -72,80 +73,88 @@ func (p *ReactionsPlugin) resolveEventSender(roomID id.RoomID, eventID id.EventI
return evt.Sender, nil
}
// roomDisplayName returns a human-readable room name, falling back to the room ID.
func (p *ReactionsPlugin) roomDisplayName(roomID id.RoomID) string {
var nameEvt event.RoomNameEventContent
err := p.Client.StateEvent(context.Background(), roomID, event.StateRoomName, "", &nameEvt)
if err == nil && nameEvt.Name != "" {
return nameEvt.Name
}
return string(roomID)
}
func (p *ReactionsPlugin) handleEmojiboard(ctx MessageContext) error {
members := p.RoomMembers(ctx.RoomID)
args := strings.TrimSpace(p.GetArgs(ctx.Body, "emojiboard"))
lower := strings.ToLower(args)
switch {
case args == "":
return p.emojiboardTop(ctx, 0)
case lower == "week":
return p.emojiboardTop(ctx, 7)
case strings.HasPrefix(lower, "received"):
rest := args[len("received"):]
return p.emojiboardReceived(ctx, strings.TrimSpace(rest))
case strings.HasPrefix(lower, "givers "):
rest := args[len("givers "):]
return p.emojiboardGivers(ctx, strings.TrimSpace(rest))
default:
// Treat as @user lookup (emojis given by user)
return p.emojiboardUser(ctx, args)
}
}
// emojiboardTop shows the top 10 most-used reaction emojis in the room.
// If days > 0, scopes to that many days.
func (p *ReactionsPlugin) emojiboardTop(ctx MessageContext, days int) error {
d := db.Get()
var sb strings.Builder
roomID := string(ctx.RoomID)
// 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`,
var timeClause string
var queryArgs []any
queryArgs = append(queryArgs, roomID)
if days > 0 {
cutoff := time.Now().Unix() - int64(days*86400)
timeClause = " AND created_at >= ?"
queryArgs = append(queryArgs, cutoff)
}
// Get total reactions
var total int
row := d.QueryRow(
`SELECT COALESCE(COUNT(*), 0) FROM reaction_log WHERE room_id = ?`+timeClause,
queryArgs...,
)
if err != nil {
slog.Error("reactions: givers query", "err", err)
if err := row.Scan(&total); err != nil {
slog.Error("reactions: emojiboard total 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 {
if total == 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++
// Get top 10 emojis
rows, err := d.Query(
`SELECT emoji, COUNT(*) as cnt FROM reaction_log WHERE room_id = ?`+timeClause+
` GROUP BY emoji ORDER BY cnt DESC LIMIT 10`,
queryArgs...,
)
if err != nil {
slog.Error("reactions: emojiboard query", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.")
}
return i
}
defer rows.Close()
roomName := p.roomDisplayName(ctx.RoomID)
var sb strings.Builder
header := "🏆 Emoji Leaderboard"
if days > 0 {
header += fmt.Sprintf(" (last %d days)", days)
}
sb.WriteString(fmt.Sprintf("%s — %s\n\n", header, roomName))
// appendEmojiBoard writes ranked emoji lines.
func appendEmojiBoard(sb *strings.Builder, rows *sql.Rows) int {
i := 0
for rows.Next() {
var emoji string
@@ -153,8 +162,181 @@ func appendEmojiBoard(sb *strings.Builder, rows *sql.Rows) int {
if err := rows.Scan(&emoji, &cnt); err != nil {
continue
}
sb.WriteString(fmt.Sprintf("%d. %s — %s times\n", i+1, emoji, formatNumber(cnt)))
pct := cnt * 100 / total
sb.WriteString(fmt.Sprintf("%d. %s — %s (%d%%)\n", i+1, emoji, formatNumber(cnt), pct))
i++
}
return i
sb.WriteString(fmt.Sprintf("\nTotal reactions: %s\n", formatNumber(total)))
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
}
// emojiboardUser shows the top emojis given by a specific user.
func (p *ReactionsPlugin) emojiboardUser(ctx MessageContext, userArg string) error {
userID, ok := p.ResolveUser(userArg, ctx.RoomID)
if !ok {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Could not resolve user: %s", userArg))
}
d := db.Get()
roomID := string(ctx.RoomID)
// Total for this user in this room
var total int
row := d.QueryRow(
`SELECT COALESCE(COUNT(*), 0) FROM reaction_log WHERE room_id = ? AND sender = ?`,
roomID, string(userID),
)
if err := row.Scan(&total); err != nil {
slog.Error("reactions: emojiboard user total", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.")
}
if total == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("%s has no reactions in this room.", p.DisplayName(userID)))
}
rows, err := d.Query(
`SELECT emoji, COUNT(*) as cnt FROM reaction_log WHERE room_id = ? AND sender = ? GROUP BY emoji ORDER BY cnt DESC LIMIT 10`,
roomID, string(userID),
)
if err != nil {
slog.Error("reactions: emojiboard user query", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.")
}
defer rows.Close()
var sb strings.Builder
sb.WriteString(fmt.Sprintf("🏆 Emojis Given by %s — %s\n\n", p.DisplayName(userID), p.roomDisplayName(ctx.RoomID)))
i := 0
for rows.Next() {
var emoji string
var cnt int
if err := rows.Scan(&emoji, &cnt); err != nil {
continue
}
pct := cnt * 100 / total
sb.WriteString(fmt.Sprintf("%d. %s — %s (%d%%)\n", i+1, emoji, formatNumber(cnt), pct))
i++
}
sb.WriteString(fmt.Sprintf("\nTotal reactions given: %s\n", formatNumber(total)))
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
}
// emojiboardReceived shows the top emojis received by a user.
func (p *ReactionsPlugin) emojiboardReceived(ctx MessageContext, userArg string) error {
// If no user specified, default to the sender
var userID id.UserID
if userArg == "" {
userID = ctx.Sender
} else {
var ok bool
userID, ok = p.ResolveUser(userArg, ctx.RoomID)
if !ok {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Could not resolve user: %s", userArg))
}
}
d := db.Get()
roomID := string(ctx.RoomID)
var total int
row := d.QueryRow(
`SELECT COALESCE(COUNT(*), 0) FROM reaction_log WHERE room_id = ? AND target_user = ?`,
roomID, string(userID),
)
if err := row.Scan(&total); err != nil {
slog.Error("reactions: emojiboard received total", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.")
}
if total == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("%s has received no reactions in this room.", p.DisplayName(userID)))
}
rows, err := d.Query(
`SELECT emoji, COUNT(*) as cnt FROM reaction_log WHERE room_id = ? AND target_user = ? GROUP BY emoji ORDER BY cnt DESC LIMIT 10`,
roomID, string(userID),
)
if err != nil {
slog.Error("reactions: emojiboard received query", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.")
}
defer rows.Close()
var sb strings.Builder
sb.WriteString(fmt.Sprintf("🏆 Emojis Received by %s — %s\n\n", p.DisplayName(userID), p.roomDisplayName(ctx.RoomID)))
i := 0
for rows.Next() {
var emoji string
var cnt int
if err := rows.Scan(&emoji, &cnt); err != nil {
continue
}
pct := cnt * 100 / total
sb.WriteString(fmt.Sprintf("%d. %s — %s (%d%%)\n", i+1, emoji, formatNumber(cnt), pct))
i++
}
sb.WriteString(fmt.Sprintf("\nTotal reactions received: %s\n", formatNumber(total)))
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
}
// emojiboardGivers shows the top 5 users who used a specific emoji most.
func (p *ReactionsPlugin) emojiboardGivers(ctx MessageContext, emoji string) error {
if emoji == "" {
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !emojiboard givers <emoji>")
}
d := db.Get()
roomID := string(ctx.RoomID)
var total int
row := d.QueryRow(
`SELECT COALESCE(COUNT(*), 0) FROM reaction_log WHERE room_id = ? AND emoji = ?`,
roomID, emoji,
)
if err := row.Scan(&total); err != nil {
slog.Error("reactions: emojiboard givers total", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.")
}
if total == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No one has used %s in this room.", emoji))
}
rows, err := d.Query(
`SELECT sender, COUNT(*) as cnt FROM reaction_log WHERE room_id = ? AND emoji = ? GROUP BY sender ORDER BY cnt DESC LIMIT 5`,
roomID, emoji,
)
if err != nil {
slog.Error("reactions: emojiboard givers query", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.")
}
defer rows.Close()
var sb strings.Builder
sb.WriteString(fmt.Sprintf("🏆 Top %s Givers — %s\n\n", emoji, p.roomDisplayName(ctx.RoomID)))
i := 0
for rows.Next() {
var sender string
var cnt int
if err := rows.Scan(&sender, &cnt); err != nil {
continue
}
pct := cnt * 100 / total
sb.WriteString(fmt.Sprintf("%d. %s — %s (%d%%)\n", i+1, p.DisplayName(id.UserID(sender)), formatNumber(cnt), pct))
i++
}
sb.WriteString(fmt.Sprintf("\nTotal %s reactions: %s\n", emoji, formatNumber(total)))
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
}