mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -112,6 +112,13 @@ MOD_NEW_MEMBER_FLOOD_MULTIPLIER=0.5
|
|||||||
MOD_ADMIN_ROOM= # dedicated admin room for mod notifications
|
MOD_ADMIN_ROOM= # dedicated admin room for mod notifications
|
||||||
MOD_DM_ON_ACTION=true # DM users when action is taken
|
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) ----
|
# ---- Rate Limits (per user per day) ----
|
||||||
RATELIMIT_WEATHER=5
|
RATELIMIT_WEATHER=5
|
||||||
RATELIMIT_TRANSLATE=20
|
RATELIMIT_TRANSLATE=20
|
||||||
|
|||||||
98
README.md
98
README.md
@@ -1,6 +1,6 @@
|
|||||||
# GogoBee
|
# 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.
|
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.
|
- **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.
|
- **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
|
- **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.
|
- **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
|
- **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 |
|
| 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
|
### 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 |
|
| `!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 |
|
| `!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)
|
### LLM (requires Ollama)
|
||||||
| Command | Description |
|
| 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 status` | Show current moderation config |
|
||||||
| `!mod test @user` | Simulate next violation without taking action |
|
| `!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
|
### Other
|
||||||
| Command | Description |
|
| 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
|
- **Stats** - tracks words, chars, links, images, questions, emojis, and time-of-day patterns
|
||||||
- **Streaks** - consecutive days active, first poster of the day
|
- **Streaks** - consecutive days active, first poster of the day
|
||||||
- **Reputation** - detects "thanks", "ty", "thx", etc. with 24h cooldown per pair
|
- **Reputation** - detects "thanks", "ty", "thx", etc. with 24h cooldown per pair
|
||||||
- **Achievements** - 32 of them, checked silently on every message
|
- **Achievements** - 80 of them, checked silently on every message
|
||||||
- **Markov chains** - collects messages for `!markov` generation (10k cap per user)
|
- **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
|
- **Keyword alerts** - DMs you when someone says your watched keywords
|
||||||
- **Presence** - auto-clears away/afk when you send a message
|
- **Presence** - auto-clears away/afk when you send a message
|
||||||
- **Room milestones** - announces at 1k, 5k, 10k, 25k, 50k, 100k, 250k, 500k, 1M messages
|
- **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) |
|
| 13:00 Wed/Sun | Esteemed | Satirical esteemed community member posts (feature-flagged) |
|
||||||
| Every 30s | Reminders | Fires pending reminders |
|
| Every 30s | Reminders | Fires pending reminders |
|
||||||
| Hourly | Space groups | Refreshes room membership overlap and group mappings |
|
| 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
|
## 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 |
|
| [HowLongToBeat](https://howlongtobeat.com) | Yes, no key | Game completion times |
|
||||||
| [LibreTranslate](https://libretranslate.com) | Self-host | Translation |
|
| [LibreTranslate](https://libretranslate.com) | Self-host | Translation |
|
||||||
| [Ollama](https://ollama.ai) | Self-host | LLM features (sentiment, tarot, vibes, roasts) |
|
| [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/
|
||||||
│ │ └── crypto.go # AES-256-GCM encryption, HMAC-SHA256
|
│ │ └── crypto.go # AES-256-GCM encryption, HMAC-SHA256
|
||||||
│ ├── db/
|
│ ├── db/
|
||||||
│ │ └── db.go # SQLite schema (40+ tables), migrations
|
│ │ └── db.go # SQLite schema (80+ tables), migrations
|
||||||
│ ├── plugin/
|
│ ├── plugin/
|
||||||
│ │ ├── plugin.go # Plugin interface, Base helpers, context types
|
│ │ ├── plugin.go # Plugin interface, Base helpers, context types
|
||||||
│ │ ├── xp.go # XP & leveling
|
│ │ ├── xp.go # XP & leveling
|
||||||
@@ -823,6 +888,7 @@ gogobee/
|
|||||||
│ │ ├── concerts.go # Concerts
|
│ │ ├── concerts.go # Concerts
|
||||||
│ │ ├── anime.go # Anime
|
│ │ ├── anime.go # Anime
|
||||||
│ │ ├── movies.go # Movies/TV
|
│ │ ├── movies.go # Movies/TV
|
||||||
|
│ │ ├── miniflux.go # Miniflux RSS feed routing
|
||||||
│ │ ├── botinfo.go # Admin diagnostics
|
│ │ ├── botinfo.go # Admin diagnostics
|
||||||
│ │ ├── howami.go # LLM roasts
|
│ │ ├── howami.go # LLM roasts
|
||||||
│ │ ├── vibe.go # Room vibe, TLDR
|
│ │ ├── 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.
|
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
|
### Backup
|
||||||
|
|
||||||
|
|||||||
@@ -966,6 +966,23 @@ CREATE TABLE IF NOT EXISTS forex_alerts (
|
|||||||
PRIMARY KEY (user_id, currency, threshold)
|
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.
|
// SeedSchedulerDefaults inserts default scheduler jobs if they don't exist.
|
||||||
|
|||||||
@@ -449,6 +449,473 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
|
|||||||
return err == nil && month > 0 && tz != ""
|
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
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gogobee/internal/db"
|
|
||||||
|
|
||||||
"maunium.net/go/mautrix"
|
"maunium.net/go/mautrix"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
)
|
)
|
||||||
@@ -76,13 +74,12 @@ func (p *AdventurePlugin) Init() error {
|
|||||||
slog.Info("adventure: rehydrated DM rooms", "count", len(chars))
|
slog.Info("adventure: rehydrated DM rooms", "count", len(chars))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Catch up on missed jobs (e.g. after a redeploy or SQLite busy failure)
|
// Always reset daily actions at startup — idempotent (WHERE clause
|
||||||
dateKey := time.Now().UTC().Format("2006-01-02")
|
// only touches characters whose last_action_date < today). This handles
|
||||||
if !db.JobCompleted("adventure_midnight", dateKey) {
|
// the case where the old buggy code marked the midnight job as completed
|
||||||
slog.Info("adventure: missed midnight reset detected, running catch-up")
|
// even though the actual reset failed due to SQLite contention.
|
||||||
if err := resetAllAdvDailyActions(); err != nil {
|
if err := resetAllAdvDailyActions(); err != nil {
|
||||||
slog.Error("adventure: catch-up daily reset failed", "err", err)
|
slog.Error("adventure: startup daily reset failed", "err", err)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Revive any characters whose DeadUntil has expired
|
// Revive any characters whose DeadUntil has expired
|
||||||
p.catchUpRespawns(chars)
|
p.catchUpRespawns(chars)
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/crypto"
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
|
|
||||||
"maunium.net/go/mautrix"
|
"maunium.net/go/mautrix"
|
||||||
@@ -15,20 +20,44 @@ import (
|
|||||||
// MarkovPlugin collects messages and generates trigram-based text.
|
// MarkovPlugin collects messages and generates trigram-based text.
|
||||||
type MarkovPlugin struct {
|
type MarkovPlugin struct {
|
||||||
Base
|
Base
|
||||||
|
encKey []byte // AES-256 key; nil means disabled
|
||||||
|
enabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMarkovPlugin creates a new Markov chain plugin.
|
// NewMarkovPlugin creates a new Markov chain plugin.
|
||||||
func NewMarkovPlugin(client *mautrix.Client) *MarkovPlugin {
|
func NewMarkovPlugin(client *mautrix.Client) *MarkovPlugin {
|
||||||
return &MarkovPlugin{
|
p := &MarkovPlugin{
|
||||||
Base: NewBase(client),
|
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) Name() string { return "markov" }
|
||||||
|
|
||||||
func (p *MarkovPlugin) Commands() []CommandDef {
|
func (p *MarkovPlugin) Commands() []CommandDef {
|
||||||
return []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) OnReaction(_ ReactionContext) error { return nil }
|
||||||
|
|
||||||
func (p *MarkovPlugin) OnMessage(ctx MessageContext) error {
|
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") {
|
if p.IsCommand(ctx.Body, "markov") {
|
||||||
return p.handleMarkov(ctx)
|
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
|
// Passive: collect non-command messages
|
||||||
if !ctx.IsCommand {
|
if !ctx.IsCommand {
|
||||||
@@ -49,18 +91,64 @@ func (p *MarkovPlugin) OnMessage(ctx MessageContext) error {
|
|||||||
return nil
|
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) {
|
func (p *MarkovPlugin) collectMessage(userID id.UserID, text string) {
|
||||||
// Skip very short messages
|
cleaned := stripNoise(text)
|
||||||
if len(strings.Fields(text)) < 3 {
|
if len(strings.Fields(cleaned)) < 3 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
enc, err := p.encryptText(cleaned)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("markov: encrypt message", "err", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
d := db.Get()
|
d := db.Get()
|
||||||
|
|
||||||
_, err := d.Exec(
|
_, err = d.Exec(
|
||||||
`INSERT INTO markov_corpus (user_id, text) VALUES (?, ?)`,
|
`INSERT INTO markov_corpus (user_id, text) VALUES (?, ?)`,
|
||||||
string(userID), text,
|
string(userID), enc,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("markov: insert message", "err", err)
|
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()
|
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(
|
rows, err := d.Query(
|
||||||
`SELECT text FROM markov_corpus WHERE user_id = ?`,
|
`SELECT text FROM markov_corpus WHERE user_id = ?`,
|
||||||
string(targetUser),
|
string(userID),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("markov: query corpus", "err", err)
|
slog.Error("markov: query corpus", "err", err)
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load Markov data.")
|
return nil
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var texts []string
|
var texts []string
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var t string
|
var enc string
|
||||||
if err := rows.Scan(&t); err != nil {
|
if err := rows.Scan(&enc); err != nil {
|
||||||
continue
|
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,
|
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 := p.generateOutput(texts, seed)
|
||||||
result := generateMarkov(texts, 50)
|
|
||||||
if result == "" {
|
if result == "" {
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to generate Markov text.")
|
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)
|
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
|
// trigram key
|
||||||
type trigramKey struct {
|
type trigramKey struct {
|
||||||
w1, w2 string
|
w1, w2 string
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateMarkov builds a trigram model from texts and generates output.
|
// buildTrigramModel builds a trigram chain and collects starters from texts.
|
||||||
func generateMarkov(texts []string, maxWords int) string {
|
func buildTrigramModel(texts []string) (map[trigramKey][]string, []trigramKey) {
|
||||||
chain := make(map[trigramKey][]string)
|
chain := make(map[trigramKey][]string)
|
||||||
var starters []trigramKey
|
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 {
|
if len(starters) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
744
internal/plugin/miniflux.go
Normal file
744
internal/plugin/miniflux.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,14 +2,15 @@ package plugin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
|
|
||||||
"maunium.net/go/mautrix"
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/event"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ func (p *ReactionsPlugin) Name() string { return "reactions" }
|
|||||||
|
|
||||||
func (p *ReactionsPlugin) Commands() []CommandDef {
|
func (p *ReactionsPlugin) Commands() []CommandDef {
|
||||||
return []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
|
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 {
|
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()
|
d := db.Get()
|
||||||
var sb strings.Builder
|
roomID := string(ctx.RoomID)
|
||||||
|
|
||||||
// Top 10 emoji givers
|
var timeClause string
|
||||||
sb.WriteString("--- Top 10 Emoji Givers ---\n\n")
|
var queryArgs []any
|
||||||
rows, err := d.Query(
|
queryArgs = append(queryArgs, roomID)
|
||||||
`SELECT sender, COUNT(*) as cnt FROM reaction_log GROUP BY sender ORDER BY cnt DESC`,
|
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 {
|
if err := row.Scan(&total); err != nil {
|
||||||
slog.Error("reactions: givers query", "err", err)
|
slog.Error("reactions: emojiboard total query", "err", err)
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.")
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.")
|
||||||
}
|
}
|
||||||
giverCount := appendUserBoard(&sb, rows, members)
|
|
||||||
rows.Close()
|
|
||||||
|
|
||||||
// Top 10 emoji receivers
|
if total == 0 {
|
||||||
sb.WriteString("\n--- Top 10 Emoji Receivers ---\n\n")
|
|
||||||
rows, err = d.Query(
|
|
||||||
`SELECT target_user, COUNT(*) as cnt FROM reaction_log GROUP BY target_user ORDER BY cnt DESC`,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("reactions: receivers query", "err", err)
|
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.")
|
|
||||||
}
|
|
||||||
receiverCount := appendUserBoard(&sb, rows, members)
|
|
||||||
rows.Close()
|
|
||||||
|
|
||||||
// Top 10 most used emojis (no user filtering needed)
|
|
||||||
sb.WriteString("\n--- Top 10 Most Used Emojis ---\n\n")
|
|
||||||
rows, err = d.Query(
|
|
||||||
`SELECT emoji, COUNT(*) as cnt FROM reaction_log GROUP BY emoji ORDER BY cnt DESC LIMIT 10`,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("reactions: emoji query", "err", err)
|
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.")
|
|
||||||
}
|
|
||||||
emojiCount := appendEmojiBoard(&sb, rows)
|
|
||||||
rows.Close()
|
|
||||||
|
|
||||||
if giverCount == 0 && receiverCount == 0 && emojiCount == 0 {
|
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No reaction data yet.")
|
return p.SendReply(ctx.RoomID, ctx.EventID, "No reaction data yet.")
|
||||||
}
|
}
|
||||||
|
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
// Get top 10 emojis
|
||||||
}
|
rows, err := d.Query(
|
||||||
|
`SELECT emoji, COUNT(*) as cnt FROM reaction_log WHERE room_id = ?`+timeClause+
|
||||||
// appendUserBoard writes ranked user lines from query rows, filtered by room members.
|
` GROUP BY emoji ORDER BY cnt DESC LIMIT 10`,
|
||||||
func appendUserBoard(sb *strings.Builder, rows *sql.Rows, members map[id.UserID]bool) int {
|
queryArgs...,
|
||||||
medals := []string{"🥇", "🥈", "🥉"}
|
)
|
||||||
i := 0
|
if err != nil {
|
||||||
for rows.Next() && i < 10 {
|
slog.Error("reactions: emojiboard query", "err", err)
|
||||||
var name string
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.")
|
||||||
var cnt int
|
|
||||||
if err := rows.Scan(&name, &cnt); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if members != nil && !members[id.UserID(name)] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
prefix := fmt.Sprintf("#%d", i+1)
|
|
||||||
if i < len(medals) {
|
|
||||||
prefix = medals[i]
|
|
||||||
}
|
|
||||||
sb.WriteString(fmt.Sprintf("%s %s — %s reactions\n", prefix, name, formatNumber(cnt)))
|
|
||||||
i++
|
|
||||||
}
|
}
|
||||||
return i
|
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
|
i := 0
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var emoji string
|
var emoji string
|
||||||
@@ -153,8 +162,181 @@ func appendEmojiBoard(sb *strings.Builder, rows *sql.Rows) int {
|
|||||||
if err := rows.Scan(&emoji, &cnt); err != nil {
|
if err := rows.Scan(&emoji, &cnt); err != nil {
|
||||||
continue
|
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++
|
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
23
main.go
@@ -132,6 +132,11 @@ func main() {
|
|||||||
registry.Register(plugin.NewMarkovPlugin(client))
|
registry.Register(plugin.NewMarkovPlugin(client))
|
||||||
registry.Register(plugin.NewURLsPlugin(client))
|
registry.Register(plugin.NewURLsPlugin(client))
|
||||||
|
|
||||||
|
// Automation
|
||||||
|
minifluxPlugin := plugin.NewMinifluxPlugin(client)
|
||||||
|
plugin.RegisterMinifluxPlugin(minifluxPlugin)
|
||||||
|
registry.Register(minifluxPlugin)
|
||||||
|
|
||||||
// LLM-powered (passive)
|
// LLM-powered (passive)
|
||||||
registry.Register(plugin.NewLLMPassivePlugin(client, xpPlugin))
|
registry.Register(plugin.NewLLMPassivePlugin(client, xpPlugin))
|
||||||
|
|
||||||
@@ -280,7 +285,7 @@ func main() {
|
|||||||
|
|
||||||
// ---- Set up cron scheduler ----
|
// ---- Set up cron scheduler ----
|
||||||
scheduler := cron.New(cron.WithChain(cron.Recover(cronLogger{})))
|
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()
|
scheduler.Start()
|
||||||
|
|
||||||
// ---- Start syncing ----
|
// ---- Start syncing ----
|
||||||
@@ -332,6 +337,7 @@ func setupScheduledJobs(
|
|||||||
concerts *plugin.ConcertsPlugin,
|
concerts *plugin.ConcertsPlugin,
|
||||||
esteemed *plugin.EsteemPlugin,
|
esteemed *plugin.EsteemPlugin,
|
||||||
forex *plugin.ForexPlugin,
|
forex *plugin.ForexPlugin,
|
||||||
|
miniflux *plugin.MinifluxPlugin,
|
||||||
) {
|
) {
|
||||||
rooms := getRooms()
|
rooms := getRooms()
|
||||||
|
|
||||||
@@ -421,10 +427,25 @@ func setupScheduledJobs(
|
|||||||
plugin.RefreshSpaceGroups()
|
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
|
// Database maintenance at 03:00 daily
|
||||||
c.AddFunc("0 3 * * *", func() {
|
c.AddFunc("0 3 * * *", func() {
|
||||||
slog.Info("scheduler: running database maintenance")
|
slog.Info("scheduler: running database maintenance")
|
||||||
db.RunMaintenance()
|
db.RunMaintenance()
|
||||||
|
plugin.MinifluxPurgeSeen()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user