diff --git a/.env.example b/.env.example index 0a77139..8dc69be 100644 --- a/.env.example +++ b/.env.example @@ -112,6 +112,13 @@ MOD_NEW_MEMBER_FLOOD_MULTIPLIER=0.5 MOD_ADMIN_ROOM= # dedicated admin room for mod notifications MOD_DM_ON_ACTION=true # DM users when action is taken +# ---- Miniflux RSS ---- +MINIFLUX_URL= # Miniflux instance URL (e.g. https://rss.example.com) +MINIFLUX_API_KEY= # Miniflux API key +MINIFLUX_POLL_INTERVAL=15 # minutes between polls (default 15) +MINIFLUX_DEFAULT_ROOM= # default room for feed posts (falls back to BROADCAST_ROOMS) +MINIFLUX_MAX_PER_POLL=5 # max entries per feed per poll (default 5) + # ---- Rate Limits (per user per day) ---- RATELIMIT_WEATHER=5 RATELIMIT_TRANSLATE=20 diff --git a/README.md b/README.md index 87e109c..640aeb6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # GogoBee -Matrix community bot with E2EE, 47 plugins, passive tracking, scheduled posts, and optional LLM features. +Matrix community bot with E2EE, 48 plugins, passive tracking, scheduled posts, and optional LLM features. Written in Go using [mautrix-go](https://github.com/mautrix/go) for encryption and [modernc.org/sqlite](https://modernc.org/sqlite) for storage. @@ -29,7 +29,7 @@ Written in Go using [mautrix-go](https://github.com/mautrix/go) for encryption a - **E2EE that actually works** - mautrix-go with goolm (pure Go). Crypto state lives in SQLite so device keys survive restarts. Cross-signing bootstraps on first run — the bot self-verifies its own device. - **No CGo, no system deps** - builds to a single static binary. Cross-compile to whatever you want. -- **43 plugins** with dependency injection and ordered registration +- **48 plugins** with dependency injection and ordered registration - **Games & economy** - Euro virtual currency, Hangman (collaborative, threaded, tiered scoring), Blackjack (1-4 players, auto-play timeout), UNO (solo vs bot or 2–4 player multiplayer via DMs, with optional No Mercy mode), Texas Hold'em (2-9 players, CFR-trained NPC bot, DM-based gameplay with Ollama coaching tips, 1-hour idle auto-close with 45-min warning), Wordle (daily cooperative, Wordnik-powered, 5-7 letter words, video game themed bonus words with category hints, dupe prevention across last 500 puzzles), Adventure (daily idle RPG via DMs — dungeon, mine, forage, shop, or rest with TwinBee NPC distributing level-scaled rewards, mid-day random events, tier shorthand buying), all with channel restriction - **Moderation system** (optional) - deterministic detection only, no LLM. Word list with leetspeak variation matching, text/image flood, repeated messages, mention flooding, link rate limiting, invite flooding, join/leave cycling. Three-strike ladder (warn → mute → ban). Admin room notifications, DMs over public callouts. - **Passive tracking** - XP, stats, streaks, achievements, markov corpus, keyword alerts, all running silently @@ -128,7 +128,17 @@ Everything is configured through environment variables or a `.env` file. | Variable | Description | |----------|-------------| -| `QUOTE_ENCRYPTION_KEY` | Base64-encoded 32-byte AES-256 key for encrypted quote storage. Generate with `openssl rand -base64 32`. If unset, `!quote` is disabled. | +| `QUOTE_ENCRYPTION_KEY` | Base64-encoded 32-byte AES-256 key for encrypted quote storage and Markov corpus. Generate with `openssl rand -base64 32`. If unset, `!quote` and `!markov` are disabled. | + +### Miniflux RSS (optional) + +| Variable | Default | Description | +|----------|---------|-------------| +| `MINIFLUX_URL` | | Miniflux instance URL, e.g. `https://rss.example.com` | +| `MINIFLUX_API_KEY` | | Miniflux API key (Settings → API Keys) | +| `MINIFLUX_POLL_INTERVAL` | `15` | Minutes between polls | +| `MINIFLUX_DEFAULT_ROOM` | `BROADCAST_ROOMS` | Default room for posts | +| `MINIFLUX_MAX_PER_POLL` | `5` | Max entries per feed per poll cycle | ### Feature Flags @@ -604,6 +614,28 @@ Three activity types across 5 tiers of locations (15 total). Higher tiers requir | `!missing post [@user]` | Generate a milk carton poster for the longest-absent (or specified) member | | `!haveyouseenthem @user` | Generate a milk carton missing poster for a user | +### Markov +| Command | Description | +|---------|-------------| +| `!markov [@user]` | Generate a sentence in the style of a user (or yourself) | +| `!markov me` | Alias for generating from your own corpus | +| `!markov @user1 @user2` | Mashup — interleave both users' corpora | +| `!impersonate @user` | Alias for `!markov @user` | +| `!ghostwrite @user ` | Seed the Markov chain with a starting phrase | +| `!markov stats [@user]` | Corpus size, unique words, top 5 words, earliest date | +| `!markov forget` | Delete your own Markov corpus (DM confirmation) | +| `!markov forget @user` | Admin-only: clear another user's corpus | +| `!markov leaderboard` | Top 10 by corpus size | + +### Emojiboard +| Command | Description | +|---------|-------------| +| `!emojiboard` | Top 10 most-used reaction emojis in this room | +| `!emojiboard @user` | Top emojis given by a specific user | +| `!emojiboard received [@user]` | Top emojis received by a user | +| `!emojiboard givers ` | Top 5 users who used a specific emoji most | +| `!emojiboard week` | Same as `!emojiboard` but last 7 days only | + ### LLM (requires Ollama) | Command | Description | |---------|-------------| @@ -632,6 +664,18 @@ Three activity types across 5 tiers of locations (15 total). Higher tiers requir | `!mod status` | Show current moderation config | | `!mod test @user` | Simulate next violation without taking action | +### RSS / Miniflux (requires `MINIFLUX_URL`) +| Command | Description | +|---------|-------------| +| `!rss feeds` | List all feeds from Miniflux with IDs and categories | +| `!rss subscribe [#room]` | Route a feed to this room (admin only) | +| `!rss unsubscribe ` | Stop routing a feed (admin only) | +| `!rss subscriptions` | List active subscriptions for this room | +| `!rss latest ` | Manually fetch and post the latest entry | +| `!rss pause ` | Pause feed routing (admin only) | +| `!rss resume ` | Resume a paused feed (admin only) | +| `!rss status` | Show polling status (admin only) | + ### Other | Command | Description | |---------|-------------| @@ -650,8 +694,8 @@ All of these run in the background without any commands: - **Stats** - tracks words, chars, links, images, questions, emojis, and time-of-day patterns - **Streaks** - consecutive days active, first poster of the day - **Reputation** - detects "thanks", "ty", "thx", etc. with 24h cooldown per pair -- **Achievements** - 32 of them, checked silently on every message -- **Markov chains** - collects messages for `!markov` generation (10k cap per user) +- **Achievements** - 80 of them, checked silently on every message +- **Markov chains** - collects encrypted messages for `!markov` generation (10k cap per user, 90-day TTL) - **Keyword alerts** - DMs you when someone says your watched keywords - **Presence** - auto-clears away/afk when you send a message - **Room milestones** - announces at 1k, 5k, 10k, 25k, 50k, 100k, 250k, 500k, 1M messages @@ -681,27 +725,47 @@ Uses [robfig/cron](https://github.com/robfig/cron). All times UTC. | 13:00 Wed/Sun | Esteemed | Satirical esteemed community member posts (feature-flagged) | | Every 30s | Reminders | Fires pending reminders | | Hourly | Space groups | Refreshes room membership overlap and group mappings | -| 03:00 | Maintenance | Purges stale caches, old rate limits, expired logs; runs SQLite optimize | +| 03:00 | Maintenance | Purges stale caches, old rate limits, expired logs, miniflux seen entries; runs SQLite optimize | +| 03:30 | Markov TTL | Purges markov corpus entries older than 90 days | +| Every N min | Miniflux | Polls Miniflux for new feed entries (configurable, default 15m) | --- ## Achievements -32 achievements: +80 achievements across 14 categories: -**Message Milestones** - first_message, 100_messages, 1000_messages, 10000_messages +**Message Milestones** - first_message, 100_messages, 500_messages, 1000_messages, 5000_messages, 10000_messages -**Time-Based** - night_owl (100 night msgs), early_bird (100 morning msgs) +**Time-Based** - night_owl, early_bird, night_dweller, dawn_patrol -**Content** - wordsmith (avg >8 words), link_collector (50 links), shutterbug (20 images), question_master (100 questions) +**Content** - wordsmith, link_collector, link_hoarder, shutterbug, photographer, question_master, emoji_lover, exclaimer -**Social** - welcome_wagon (first message), rep_magnet (10 rep received) +**Social** - welcome_wagon, rep_magnet, rep_star -**Streaks** - week_streak (7 days), month_streak (30 days) +**Streaks** - week_streak, two_week_streak, month_streak, quarter_streak, year_streak -**Trivia** - trivia_novice (10 correct), trivia_master (100 correct) +**Trivia** - trivia_novice, trivia_master, trivia_legend -**Special** - markov_victim (got markov'd), logophile (used 10 WOTDs) +**Economy** - euro_first, euro_1k, euro_10k, euro_broke, euro_comeback, euro_generous + +**Blackjack** - bj_first_win, bj_blackjack, bj_bust, bj_beat_twinbee, bj_100_hands + +**UNO** - uno_first_win, uno_nomercy_win, uno_draw_stack, uno_comeback + +**Texas Hold'em** - holdem_first_win, holdem_royal_flush, holdem_bluff, holdem_beat_npc, holdem_allin_win, holdem_bounty + +**Hangman** - hangman_solve, hangman_first_guess, hangman_submitted, hangman_executioner + +**Wordle** - wordle_solve, wordle_first_guess, wordle_streak_3, wordle_bonus, wordle_closer + +**Adventure** - adv_first, adv_died, adv_revived, adv_streak_7, adv_streak_30, adv_max_level, adv_treasure_cap, adv_grudge_win, adv_party, adv_twinbee_gift + +**Community** - quote_saved, quote_saved_10, missing_poster, tarot_spread + +**WOTD** - wotd_first, wotd_week, wotd_30, logophile + +**Special** - markov_victim, birthday_set, timezone_set, profile_complete --- @@ -768,6 +832,7 @@ All optional. The bot works fine without any of them, you just won't have those | [HowLongToBeat](https://howlongtobeat.com) | Yes, no key | Game completion times | | [LibreTranslate](https://libretranslate.com) | Self-host | Translation | | [Ollama](https://ollama.ai) | Self-host | LLM features (sentiment, tarot, vibes, roasts) | +| [Miniflux](https://miniflux.app) | Self-host | RSS feed routing | --- @@ -792,7 +857,7 @@ gogobee/ │ ├── crypto/ │ │ └── crypto.go # AES-256-GCM encryption, HMAC-SHA256 │ ├── db/ -│ │ └── db.go # SQLite schema (40+ tables), migrations +│ │ └── db.go # SQLite schema (80+ tables), migrations │ ├── plugin/ │ │ ├── plugin.go # Plugin interface, Base helpers, context types │ │ ├── xp.go # XP & leveling @@ -823,6 +888,7 @@ gogobee/ │ │ ├── concerts.go # Concerts │ │ ├── anime.go # Anime │ │ ├── movies.go # Movies/TV +│ │ ├── miniflux.go # Miniflux RSS feed routing │ │ ├── botinfo.go # Admin diagnostics │ │ ├── howami.go # LLM roasts │ │ ├── vibe.go # Room vibe, TLDR @@ -886,7 +952,7 @@ gogobee/ Single SQLite file at `$DATA_DIR/gogobee.db`. Schema auto-creates on first run. WAL mode enabled. -40+ tables covering users, XP, stats, streaks, reputation, reminders, trivia, achievements, encrypted quotes (AES-256-GCM), backlog, keyword watches, scheduler config, birthdays, horoscopes, LLM classifications, stocks, forex rates/alerts, concerts, anime, movies, countdowns, presence, markov corpus, reaction log, and various caches. +80+ tables covering users, XP, stats, streaks, reputation, reminders, trivia, achievements, encrypted quotes (AES-256-GCM), backlog, keyword watches, scheduler config, birthdays, horoscopes, LLM classifications, stocks, forex rates/alerts, concerts, anime, movies, countdowns, presence, encrypted markov corpus, reaction log, miniflux RSS subscriptions, and various caches. ### Backup diff --git a/internal/db/db.go b/internal/db/db.go index 3a26449..1550514 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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. diff --git a/internal/plugin/achievements.go b/internal/plugin/achievements.go index e5a7026..b256a1b 100644 --- a/internal/plugin/achievements.go +++ b/internal/plugin/achievements.go @@ -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 + }, + }, } } diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 1474ede..be1f70b 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -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) diff --git a/internal/plugin/markov.go b/internal/plugin/markov.go index c8b3e2b..180a679 100644 --- a/internal/plugin/markov.go +++ b/internal/plugin/markov.go @@ -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 ", 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 ") + } + + 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 "" } diff --git a/internal/plugin/miniflux.go b/internal/plugin/miniflux.go new file mode 100644 index 0000000..264b07d --- /dev/null +++ b/internal/plugin/miniflux.go @@ -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 [#room] · !rss unsubscribe · !rss subscriptions · !rss latest · !rss pause · !rss resume · !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 [#room]` · `!rss unsubscribe ` · `!rss subscriptions` · `!rss latest ` · `!rss pause ` · `!rss resume ` · `!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 [#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 `") + } + + 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 ` 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 `") + } + + 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 `") + } + + 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 `") + } + + 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) + } +} diff --git a/internal/plugin/reactions.go b/internal/plugin/reactions.go index 9209b0b..d09ea4b 100644 --- a/internal/plugin/reactions.go +++ b/internal/plugin/reactions.go @@ -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 | 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 ") + } + + 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()) } diff --git a/main.go b/main.go index 2ee2510..8d72c59 100644 --- a/main.go +++ b/main.go @@ -132,6 +132,11 @@ func main() { registry.Register(plugin.NewMarkovPlugin(client)) registry.Register(plugin.NewURLsPlugin(client)) + // Automation + minifluxPlugin := plugin.NewMinifluxPlugin(client) + plugin.RegisterMinifluxPlugin(minifluxPlugin) + registry.Register(minifluxPlugin) + // LLM-powered (passive) registry.Register(plugin.NewLLMPassivePlugin(client, xpPlugin)) @@ -280,7 +285,7 @@ func main() { // ---- Set up cron scheduler ---- scheduler := cron.New(cron.WithChain(cron.Recover(cronLogger{}))) - setupScheduledJobs(scheduler, client, wotdPlugin, holidaysPlugin, gamingPlugin, birthdayPlugin, animePlugin, moviesPlugin, concertsPlugin, esteemedPlugin, forexPlugin) + setupScheduledJobs(scheduler, client, wotdPlugin, holidaysPlugin, gamingPlugin, birthdayPlugin, animePlugin, moviesPlugin, concertsPlugin, esteemedPlugin, forexPlugin, minifluxPlugin) scheduler.Start() // ---- Start syncing ---- @@ -332,6 +337,7 @@ func setupScheduledJobs( concerts *plugin.ConcertsPlugin, esteemed *plugin.EsteemPlugin, forex *plugin.ForexPlugin, + miniflux *plugin.MinifluxPlugin, ) { rooms := getRooms() @@ -421,10 +427,25 @@ func setupScheduledJobs( plugin.RefreshSpaceGroups() }) + // Markov corpus TTL purge at 03:30 daily + c.AddFunc("30 3 * * *", func() { + slog.Info("scheduler: purging expired markov entries") + plugin.MarkovPurgeExpired() + }) + + // Miniflux RSS polling + if miniflux != nil { + interval := fmt.Sprintf("@every %dm", miniflux.PollInterval()) + c.AddFunc(interval, func() { + plugin.MinifluxPoll(client) + }) + } + // Database maintenance at 03:00 daily c.AddFunc("0 3 * * *", func() { slog.Info("scheduler: running database maintenance") db.RunMaintenance() + plugin.MinifluxPurgeSeen() }) }