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

@@ -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

View File

@@ -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 24 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 <prompt>` | 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 <emoji>` | 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 <feed_id> [#room]` | Route a feed to this room (admin only) |
| `!rss unsubscribe <feed_id>` | Stop routing a feed (admin only) |
| `!rss subscriptions` | List active subscriptions for this room |
| `!rss latest <feed_id>` | Manually fetch and post the latest entry |
| `!rss pause <feed_id>` | Pause feed routing (admin only) |
| `!rss resume <feed_id>` | 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

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())
}

23
main.go
View File

@@ -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()
})
}