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_DM_ON_ACTION=true # DM users when action is taken
|
||||
|
||||
# ---- Miniflux RSS ----
|
||||
MINIFLUX_URL= # Miniflux instance URL (e.g. https://rss.example.com)
|
||||
MINIFLUX_API_KEY= # Miniflux API key
|
||||
MINIFLUX_POLL_INTERVAL=15 # minutes between polls (default 15)
|
||||
MINIFLUX_DEFAULT_ROOM= # default room for feed posts (falls back to BROADCAST_ROOMS)
|
||||
MINIFLUX_MAX_PER_POLL=5 # max entries per feed per poll (default 5)
|
||||
|
||||
# ---- Rate Limits (per user per day) ----
|
||||
RATELIMIT_WEATHER=5
|
||||
RATELIMIT_TRANSLATE=20
|
||||
|
||||
98
README.md
98
README.md
@@ -1,6 +1,6 @@
|
||||
# GogoBee
|
||||
|
||||
Matrix community bot with E2EE, 47 plugins, passive tracking, scheduled posts, and optional LLM features.
|
||||
Matrix community bot with E2EE, 48 plugins, passive tracking, scheduled posts, and optional LLM features.
|
||||
|
||||
Written in Go using [mautrix-go](https://github.com/mautrix/go) for encryption and [modernc.org/sqlite](https://modernc.org/sqlite) for storage.
|
||||
|
||||
@@ -29,7 +29,7 @@ Written in Go using [mautrix-go](https://github.com/mautrix/go) for encryption a
|
||||
|
||||
- **E2EE that actually works** - mautrix-go with goolm (pure Go). Crypto state lives in SQLite so device keys survive restarts. Cross-signing bootstraps on first run — the bot self-verifies its own device.
|
||||
- **No CGo, no system deps** - builds to a single static binary. Cross-compile to whatever you want.
|
||||
- **43 plugins** with dependency injection and ordered registration
|
||||
- **48 plugins** with dependency injection and ordered registration
|
||||
- **Games & economy** - Euro virtual currency, Hangman (collaborative, threaded, tiered scoring), Blackjack (1-4 players, auto-play timeout), UNO (solo vs bot or 2–4 player multiplayer via DMs, with optional No Mercy mode), Texas Hold'em (2-9 players, CFR-trained NPC bot, DM-based gameplay with Ollama coaching tips, 1-hour idle auto-close with 45-min warning), Wordle (daily cooperative, Wordnik-powered, 5-7 letter words, video game themed bonus words with category hints, dupe prevention across last 500 puzzles), Adventure (daily idle RPG via DMs — dungeon, mine, forage, shop, or rest with TwinBee NPC distributing level-scaled rewards, mid-day random events, tier shorthand buying), all with channel restriction
|
||||
- **Moderation system** (optional) - deterministic detection only, no LLM. Word list with leetspeak variation matching, text/image flood, repeated messages, mention flooding, link rate limiting, invite flooding, join/leave cycling. Three-strike ladder (warn → mute → ban). Admin room notifications, DMs over public callouts.
|
||||
- **Passive tracking** - XP, stats, streaks, achievements, markov corpus, keyword alerts, all running silently
|
||||
@@ -128,7 +128,17 @@ Everything is configured through environment variables or a `.env` file.
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `QUOTE_ENCRYPTION_KEY` | Base64-encoded 32-byte AES-256 key for encrypted quote storage. Generate with `openssl rand -base64 32`. If unset, `!quote` is disabled. |
|
||||
| `QUOTE_ENCRYPTION_KEY` | Base64-encoded 32-byte AES-256 key for encrypted quote storage and Markov corpus. Generate with `openssl rand -base64 32`. If unset, `!quote` and `!markov` are disabled. |
|
||||
|
||||
### Miniflux RSS (optional)
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `MINIFLUX_URL` | | Miniflux instance URL, e.g. `https://rss.example.com` |
|
||||
| `MINIFLUX_API_KEY` | | Miniflux API key (Settings → API Keys) |
|
||||
| `MINIFLUX_POLL_INTERVAL` | `15` | Minutes between polls |
|
||||
| `MINIFLUX_DEFAULT_ROOM` | `BROADCAST_ROOMS` | Default room for posts |
|
||||
| `MINIFLUX_MAX_PER_POLL` | `5` | Max entries per feed per poll cycle |
|
||||
|
||||
### Feature Flags
|
||||
|
||||
@@ -604,6 +614,28 @@ Three activity types across 5 tiers of locations (15 total). Higher tiers requir
|
||||
| `!missing post [@user]` | Generate a milk carton poster for the longest-absent (or specified) member |
|
||||
| `!haveyouseenthem @user` | Generate a milk carton missing poster for a user |
|
||||
|
||||
### Markov
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `!markov [@user]` | Generate a sentence in the style of a user (or yourself) |
|
||||
| `!markov me` | Alias for generating from your own corpus |
|
||||
| `!markov @user1 @user2` | Mashup — interleave both users' corpora |
|
||||
| `!impersonate @user` | Alias for `!markov @user` |
|
||||
| `!ghostwrite @user <prompt>` | Seed the Markov chain with a starting phrase |
|
||||
| `!markov stats [@user]` | Corpus size, unique words, top 5 words, earliest date |
|
||||
| `!markov forget` | Delete your own Markov corpus (DM confirmation) |
|
||||
| `!markov forget @user` | Admin-only: clear another user's corpus |
|
||||
| `!markov leaderboard` | Top 10 by corpus size |
|
||||
|
||||
### Emojiboard
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `!emojiboard` | Top 10 most-used reaction emojis in this room |
|
||||
| `!emojiboard @user` | Top emojis given by a specific user |
|
||||
| `!emojiboard received [@user]` | Top emojis received by a user |
|
||||
| `!emojiboard givers <emoji>` | Top 5 users who used a specific emoji most |
|
||||
| `!emojiboard week` | Same as `!emojiboard` but last 7 days only |
|
||||
|
||||
### LLM (requires Ollama)
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
@@ -632,6 +664,18 @@ Three activity types across 5 tiers of locations (15 total). Higher tiers requir
|
||||
| `!mod status` | Show current moderation config |
|
||||
| `!mod test @user` | Simulate next violation without taking action |
|
||||
|
||||
### RSS / Miniflux (requires `MINIFLUX_URL`)
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `!rss feeds` | List all feeds from Miniflux with IDs and categories |
|
||||
| `!rss subscribe <feed_id> [#room]` | Route a feed to this room (admin only) |
|
||||
| `!rss unsubscribe <feed_id>` | Stop routing a feed (admin only) |
|
||||
| `!rss subscriptions` | List active subscriptions for this room |
|
||||
| `!rss latest <feed_id>` | Manually fetch and post the latest entry |
|
||||
| `!rss pause <feed_id>` | Pause feed routing (admin only) |
|
||||
| `!rss resume <feed_id>` | Resume a paused feed (admin only) |
|
||||
| `!rss status` | Show polling status (admin only) |
|
||||
|
||||
### Other
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
@@ -650,8 +694,8 @@ All of these run in the background without any commands:
|
||||
- **Stats** - tracks words, chars, links, images, questions, emojis, and time-of-day patterns
|
||||
- **Streaks** - consecutive days active, first poster of the day
|
||||
- **Reputation** - detects "thanks", "ty", "thx", etc. with 24h cooldown per pair
|
||||
- **Achievements** - 32 of them, checked silently on every message
|
||||
- **Markov chains** - collects messages for `!markov` generation (10k cap per user)
|
||||
- **Achievements** - 80 of them, checked silently on every message
|
||||
- **Markov chains** - collects encrypted messages for `!markov` generation (10k cap per user, 90-day TTL)
|
||||
- **Keyword alerts** - DMs you when someone says your watched keywords
|
||||
- **Presence** - auto-clears away/afk when you send a message
|
||||
- **Room milestones** - announces at 1k, 5k, 10k, 25k, 50k, 100k, 250k, 500k, 1M messages
|
||||
@@ -681,27 +725,47 @@ Uses [robfig/cron](https://github.com/robfig/cron). All times UTC.
|
||||
| 13:00 Wed/Sun | Esteemed | Satirical esteemed community member posts (feature-flagged) |
|
||||
| Every 30s | Reminders | Fires pending reminders |
|
||||
| Hourly | Space groups | Refreshes room membership overlap and group mappings |
|
||||
| 03:00 | Maintenance | Purges stale caches, old rate limits, expired logs; runs SQLite optimize |
|
||||
| 03:00 | Maintenance | Purges stale caches, old rate limits, expired logs, miniflux seen entries; runs SQLite optimize |
|
||||
| 03:30 | Markov TTL | Purges markov corpus entries older than 90 days |
|
||||
| Every N min | Miniflux | Polls Miniflux for new feed entries (configurable, default 15m) |
|
||||
|
||||
---
|
||||
|
||||
## Achievements
|
||||
|
||||
32 achievements:
|
||||
80 achievements across 14 categories:
|
||||
|
||||
**Message Milestones** - first_message, 100_messages, 1000_messages, 10000_messages
|
||||
**Message Milestones** - first_message, 100_messages, 500_messages, 1000_messages, 5000_messages, 10000_messages
|
||||
|
||||
**Time-Based** - night_owl (100 night msgs), early_bird (100 morning msgs)
|
||||
**Time-Based** - night_owl, early_bird, night_dweller, dawn_patrol
|
||||
|
||||
**Content** - wordsmith (avg >8 words), link_collector (50 links), shutterbug (20 images), question_master (100 questions)
|
||||
**Content** - wordsmith, link_collector, link_hoarder, shutterbug, photographer, question_master, emoji_lover, exclaimer
|
||||
|
||||
**Social** - welcome_wagon (first message), rep_magnet (10 rep received)
|
||||
**Social** - welcome_wagon, rep_magnet, rep_star
|
||||
|
||||
**Streaks** - week_streak (7 days), month_streak (30 days)
|
||||
**Streaks** - week_streak, two_week_streak, month_streak, quarter_streak, year_streak
|
||||
|
||||
**Trivia** - trivia_novice (10 correct), trivia_master (100 correct)
|
||||
**Trivia** - trivia_novice, trivia_master, trivia_legend
|
||||
|
||||
**Special** - markov_victim (got markov'd), logophile (used 10 WOTDs)
|
||||
**Economy** - euro_first, euro_1k, euro_10k, euro_broke, euro_comeback, euro_generous
|
||||
|
||||
**Blackjack** - bj_first_win, bj_blackjack, bj_bust, bj_beat_twinbee, bj_100_hands
|
||||
|
||||
**UNO** - uno_first_win, uno_nomercy_win, uno_draw_stack, uno_comeback
|
||||
|
||||
**Texas Hold'em** - holdem_first_win, holdem_royal_flush, holdem_bluff, holdem_beat_npc, holdem_allin_win, holdem_bounty
|
||||
|
||||
**Hangman** - hangman_solve, hangman_first_guess, hangman_submitted, hangman_executioner
|
||||
|
||||
**Wordle** - wordle_solve, wordle_first_guess, wordle_streak_3, wordle_bonus, wordle_closer
|
||||
|
||||
**Adventure** - adv_first, adv_died, adv_revived, adv_streak_7, adv_streak_30, adv_max_level, adv_treasure_cap, adv_grudge_win, adv_party, adv_twinbee_gift
|
||||
|
||||
**Community** - quote_saved, quote_saved_10, missing_poster, tarot_spread
|
||||
|
||||
**WOTD** - wotd_first, wotd_week, wotd_30, logophile
|
||||
|
||||
**Special** - markov_victim, birthday_set, timezone_set, profile_complete
|
||||
|
||||
---
|
||||
|
||||
@@ -768,6 +832,7 @@ All optional. The bot works fine without any of them, you just won't have those
|
||||
| [HowLongToBeat](https://howlongtobeat.com) | Yes, no key | Game completion times |
|
||||
| [LibreTranslate](https://libretranslate.com) | Self-host | Translation |
|
||||
| [Ollama](https://ollama.ai) | Self-host | LLM features (sentiment, tarot, vibes, roasts) |
|
||||
| [Miniflux](https://miniflux.app) | Self-host | RSS feed routing |
|
||||
|
||||
---
|
||||
|
||||
@@ -792,7 +857,7 @@ gogobee/
|
||||
│ ├── crypto/
|
||||
│ │ └── crypto.go # AES-256-GCM encryption, HMAC-SHA256
|
||||
│ ├── db/
|
||||
│ │ └── db.go # SQLite schema (40+ tables), migrations
|
||||
│ │ └── db.go # SQLite schema (80+ tables), migrations
|
||||
│ ├── plugin/
|
||||
│ │ ├── plugin.go # Plugin interface, Base helpers, context types
|
||||
│ │ ├── xp.go # XP & leveling
|
||||
@@ -823,6 +888,7 @@ gogobee/
|
||||
│ │ ├── concerts.go # Concerts
|
||||
│ │ ├── anime.go # Anime
|
||||
│ │ ├── movies.go # Movies/TV
|
||||
│ │ ├── miniflux.go # Miniflux RSS feed routing
|
||||
│ │ ├── botinfo.go # Admin diagnostics
|
||||
│ │ ├── howami.go # LLM roasts
|
||||
│ │ ├── vibe.go # Room vibe, TLDR
|
||||
@@ -886,7 +952,7 @@ gogobee/
|
||||
|
||||
Single SQLite file at `$DATA_DIR/gogobee.db`. Schema auto-creates on first run. WAL mode enabled.
|
||||
|
||||
40+ tables covering users, XP, stats, streaks, reputation, reminders, trivia, achievements, encrypted quotes (AES-256-GCM), backlog, keyword watches, scheduler config, birthdays, horoscopes, LLM classifications, stocks, forex rates/alerts, concerts, anime, movies, countdowns, presence, markov corpus, reaction log, and various caches.
|
||||
80+ tables covering users, XP, stats, streaks, reputation, reminders, trivia, achievements, encrypted quotes (AES-256-GCM), backlog, keyword watches, scheduler config, birthdays, horoscopes, LLM classifications, stocks, forex rates/alerts, concerts, anime, movies, countdowns, presence, encrypted markov corpus, reaction log, miniflux RSS subscriptions, and various caches.
|
||||
|
||||
### Backup
|
||||
|
||||
|
||||
@@ -966,6 +966,23 @@ CREATE TABLE IF NOT EXISTS forex_alerts (
|
||||
PRIMARY KEY (user_id, currency, threshold)
|
||||
);
|
||||
|
||||
-- Miniflux RSS
|
||||
CREATE TABLE IF NOT EXISTS miniflux_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
feed_id INTEGER NOT NULL,
|
||||
room_id TEXT NOT NULL,
|
||||
paused INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE(feed_id, room_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miniflux_seen (
|
||||
feed_id INTEGER NOT NULL,
|
||||
entry_id INTEGER NOT NULL,
|
||||
seen_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (feed_id, entry_id)
|
||||
);
|
||||
|
||||
`
|
||||
|
||||
// SeedSchedulerDefaults inserts default scheduler jobs if they don't exist.
|
||||
|
||||
@@ -449,6 +449,473 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
|
||||
return err == nil && month > 0 && tz != ""
|
||||
},
|
||||
},
|
||||
|
||||
// ── Economy ─────────────────────────────────────────────────────────
|
||||
{
|
||||
ID: "euro_first", Name: "First Paycheck", Description: "The economy has claimed another victim.",
|
||||
Emoji: "💶",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
var count int
|
||||
err := d.QueryRow(
|
||||
`SELECT COUNT(*) FROM euro_transactions WHERE user_id = ? AND amount > 0`,
|
||||
string(u),
|
||||
).Scan(&count)
|
||||
return err == nil && count >= 1
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "euro_1k", Name: "Four Figures", Description: "You are now, technically, a thousandaire.",
|
||||
Emoji: "💰",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
var balance float64
|
||||
err := d.QueryRow(
|
||||
`SELECT balance FROM euro_balances WHERE user_id = ?`,
|
||||
string(u),
|
||||
).Scan(&balance)
|
||||
return err == nil && balance >= 1000
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "euro_10k", Name: "High Roller", Description: "Someone stop this person.",
|
||||
Emoji: "🤑",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
var balance float64
|
||||
err := d.QueryRow(
|
||||
`SELECT balance FROM euro_balances WHERE user_id = ?`,
|
||||
string(u),
|
||||
).Scan(&balance)
|
||||
return err == nil && balance >= 10000
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "euro_broke", Name: "Overdrafted", Description: "Character-building experience.",
|
||||
Emoji: "📉",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
var balance float64
|
||||
err := d.QueryRow(
|
||||
`SELECT balance FROM euro_balances WHERE user_id = ?`,
|
||||
string(u),
|
||||
).Scan(&balance)
|
||||
return err == nil && balance < 0
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "euro_comeback", Name: "Comeback Kid", Description: "Against all odds.",
|
||||
Emoji: "🔄",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by euro plugin when recovering from debt
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "euro_generous", Name: "Philanthropist", Description: "Money is just a social construct anyway.",
|
||||
Emoji: "🎁",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
var total float64
|
||||
err := d.QueryRow(
|
||||
`SELECT COALESCE(SUM(ABS(amount)), 0) FROM euro_transactions WHERE user_id = ? AND reason = 'transfer' AND amount < 0`,
|
||||
string(u),
|
||||
).Scan(&total)
|
||||
return err == nil && total >= 1000
|
||||
},
|
||||
},
|
||||
|
||||
// ── Blackjack ───────────────────────────────────────────────────────
|
||||
{
|
||||
ID: "bj_first_win", Name: "Natural", Description: "The table fears you. Slightly.",
|
||||
Emoji: "🃏",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
var won int
|
||||
err := d.QueryRow(
|
||||
`SELECT games_won FROM blackjack_scores WHERE user_id = ?`,
|
||||
string(u),
|
||||
).Scan(&won)
|
||||
return err == nil && won >= 1
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "bj_blackjack", Name: "Blackjack", Description: "21. As the prophecy foretold.",
|
||||
Emoji: "🎰",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by blackjack plugin on natural 21
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "bj_bust", Name: "Going for It", Description: "Hope is a beautiful thing.",
|
||||
Emoji: "💥",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
var played, won int
|
||||
err := d.QueryRow(
|
||||
`SELECT games_played, games_won FROM blackjack_scores WHERE user_id = ?`,
|
||||
string(u),
|
||||
).Scan(&played, &won)
|
||||
// Busts are approximated as losses (played - won)
|
||||
return err == nil && (played-won) >= 10
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "bj_beat_twinbee", Name: "Gotcha 🐝", Description: "She is furious and will not forget this.",
|
||||
Emoji: "🐝",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by blackjack plugin when beating the bot
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "bj_100_hands", Name: "Card Shark", Description: "Statistically, you should have stopped.",
|
||||
Emoji: "🦈",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
var played int
|
||||
err := d.QueryRow(
|
||||
`SELECT games_played FROM blackjack_scores WHERE user_id = ?`,
|
||||
string(u),
|
||||
).Scan(&played)
|
||||
return err == nil && played >= 100
|
||||
},
|
||||
},
|
||||
|
||||
// ── UNO ─────────────────────────────────────────────────────────────
|
||||
{
|
||||
ID: "uno_first_win", Name: "UNO!", Description: "One card. Victory. Glory.",
|
||||
Emoji: "🟥",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by UNO plugin on first win
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "uno_nomercy_win", Name: "No Mercy", Description: "You are a monster and we respect it.",
|
||||
Emoji: "😈",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by UNO plugin on No Mercy mode win
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "uno_draw_stack", Name: "Stack Overflow", Description: "The pile grows. Your opponents weep.",
|
||||
Emoji: "📚",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by UNO plugin on 3+ draw stacks in one game
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "uno_comeback", Name: "Down to Zero", Description: "The math said no. You said yes.",
|
||||
Emoji: "🔥",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by UNO plugin on winning from 10+ cards
|
||||
return false
|
||||
},
|
||||
},
|
||||
|
||||
// ── Texas Hold'em ───────────────────────────────────────────────────
|
||||
{
|
||||
ID: "holdem_first_win", Name: "Ante Up", Description: "A pot has been claimed.",
|
||||
Emoji: "♠️",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
var won int
|
||||
err := d.QueryRow(
|
||||
`SELECT total_won FROM holdem_scores WHERE user_id = ?`,
|
||||
string(u),
|
||||
).Scan(&won)
|
||||
return err == nil && won > 0
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "holdem_royal_flush", Name: "Royal Flush", Description: "This has never happened before in recorded history.",
|
||||
Emoji: "👑",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by holdem plugin on royal flush
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "holdem_bluff", Name: "Poker Face", Description: "The audacity. Incredible.",
|
||||
Emoji: "😶",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by holdem plugin on high-card-only win
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "holdem_beat_npc", Name: "Outsmarted the Bot", Description: "CFR trained on millions of hands. You trained on vibes.",
|
||||
Emoji: "🤖",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by holdem plugin on beating CFR NPC
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "holdem_allin_win", Name: "All In", Description: "Everything on the line. Everything gained.",
|
||||
Emoji: "💎",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by holdem plugin on all-in win
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "holdem_bounty", Name: "Bounty Hunter", Description: "They had families.",
|
||||
Emoji: "🎯",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by holdem plugin on knocking out a player
|
||||
return false
|
||||
},
|
||||
},
|
||||
|
||||
// ── Hangman ─────────────────────────────────────────────────────────
|
||||
{
|
||||
ID: "hangman_solve", Name: "Lexicographer", Description: "Language. You understand it.",
|
||||
Emoji: "📖",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
var won int
|
||||
err := d.QueryRow(
|
||||
`SELECT games_won FROM hangman_scores WHERE user_id = ?`,
|
||||
string(u),
|
||||
).Scan(&won)
|
||||
return err == nil && won >= 1
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "hangman_first_guess", Name: "Mind Reader", Description: "How.",
|
||||
Emoji: "🔮",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by hangman plugin on first-guess solve
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "hangman_submitted", Name: "Contributor", Description: "Your words. Their suffering.",
|
||||
Emoji: "✏️",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by hangman plugin when a submitted phrase is used
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "hangman_executioner", Name: "Executioner", Description: "You did this.",
|
||||
Emoji: "⚰️",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by hangman plugin on final wrong letter
|
||||
return false
|
||||
},
|
||||
},
|
||||
|
||||
// ── Wordle ──────────────────────────────────────────────────────────
|
||||
{
|
||||
ID: "wordle_solve", Name: "Wordled", Description: "The letters obeyed you.",
|
||||
Emoji: "🟩",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
var solved int
|
||||
err := d.QueryRow(
|
||||
`SELECT puzzles_solved FROM wordle_stats WHERE user_id = ?`,
|
||||
string(u),
|
||||
).Scan(&solved)
|
||||
return err == nil && solved >= 1
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "wordle_first_guess", Name: "Omniscient", Description: "That was statistically impossible.",
|
||||
Emoji: "🧿",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by wordle plugin on 1-guess community solve
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "wordle_streak_3", Name: "Hot Streak", Description: "Momentum.",
|
||||
Emoji: "🔥",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by wordle plugin on 3 consecutive community wins
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "wordle_bonus", Name: "Nerd", Description: "One of us.",
|
||||
Emoji: "🎮",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by wordle plugin on bonus word solve
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "wordle_closer", Name: "Closer", Description: "The community thanks you. Sort of.",
|
||||
Emoji: "🏁",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by wordle plugin on submitting the winning guess
|
||||
return false
|
||||
},
|
||||
},
|
||||
|
||||
// ── Adventure ───────────────────────────────────────────────────────
|
||||
{
|
||||
ID: "adv_first", Name: "Adventurer", Description: "The dungeon awaits. Probably.",
|
||||
Emoji: "⚔️",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
var count int
|
||||
err := d.QueryRow(
|
||||
`SELECT COUNT(*) FROM adventure_activity_log WHERE user_id = ?`,
|
||||
string(u),
|
||||
).Scan(&count)
|
||||
return err == nil && count >= 1
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "adv_died", Name: "Respawning", Description: "This is fine.",
|
||||
Emoji: "💀",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by adventure plugin on death
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "adv_revived", Name: "Second Life", Description: "Someone cared enough.",
|
||||
Emoji: "✨",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by adventure plugin on admin revive
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "adv_streak_7", Name: "Daily Grind", Description: "Committed. Concerningly so.",
|
||||
Emoji: "📅",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
var streak int
|
||||
err := d.QueryRow(
|
||||
`SELECT best_streak FROM adventure_characters WHERE user_id = ?`,
|
||||
string(u),
|
||||
).Scan(&streak)
|
||||
return err == nil && streak >= 7
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "adv_streak_30", Name: "Devoted", Description: "Please go outside.",
|
||||
Emoji: "🗓️",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
var streak int
|
||||
err := d.QueryRow(
|
||||
`SELECT best_streak FROM adventure_characters WHERE user_id = ?`,
|
||||
string(u),
|
||||
).Scan(&streak)
|
||||
return err == nil && streak >= 30
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "adv_max_level", Name: "Legendary", Description: "What's next? Nothing. You've done it.",
|
||||
Emoji: "🏆",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by adventure plugin on reaching max level
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "adv_treasure_cap", Name: "Hoarder", Description: "No more pockets.",
|
||||
Emoji: "💎",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
var count int
|
||||
err := d.QueryRow(
|
||||
`SELECT COUNT(*) FROM adventure_treasures WHERE user_id = ?`,
|
||||
string(u),
|
||||
).Scan(&count)
|
||||
return err == nil && count >= 3
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "adv_grudge_win", Name: "Revenge", Description: "Redemption arc complete.",
|
||||
Emoji: "⚡",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by adventure plugin on grudge location success
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "adv_party", Name: "Party of Two", Description: "Accidental cooperation.",
|
||||
Emoji: "🤝",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by adventure plugin on party bonus
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "adv_twinbee_gift", Name: "Blessed by TwinBee", Description: "The bee has chosen you. Unknown why.",
|
||||
Emoji: "🐝",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by adventure plugin on TwinBee NPC buff
|
||||
return false
|
||||
},
|
||||
},
|
||||
|
||||
// ── Word of the Day ─────────────────────────────────────────────────
|
||||
{
|
||||
ID: "wotd_first", Name: "Logophile Jr.", Description: "25 XP well earned.",
|
||||
Emoji: "📝",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
var count int
|
||||
err := d.QueryRow(
|
||||
`SELECT COUNT(DISTINCT date) FROM wotd_usage WHERE user_id = ? AND count > 0`,
|
||||
string(u),
|
||||
).Scan(&count)
|
||||
return err == nil && count >= 1
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "wotd_week", Name: "Vocabulary Builder", Description: "The dictionary approves.",
|
||||
Emoji: "📚",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
var count int
|
||||
err := d.QueryRow(
|
||||
`SELECT COUNT(DISTINCT date) FROM wotd_usage WHERE user_id = ? AND count > 0`,
|
||||
string(u),
|
||||
).Scan(&count)
|
||||
return err == nil && count >= 7
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "wotd_30", Name: "Wordsmith Elite", Description: "You have transcended mere communication.",
|
||||
Emoji: "🎓",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
var count int
|
||||
err := d.QueryRow(
|
||||
`SELECT COUNT(DISTINCT date) FROM wotd_usage WHERE user_id = ? AND count > 0`,
|
||||
string(u),
|
||||
).Scan(&count)
|
||||
return err == nil && count >= 30
|
||||
},
|
||||
},
|
||||
|
||||
// ── Community & Social ──────────────────────────────────────────────
|
||||
{
|
||||
ID: "quote_saved", Name: "Quotable", Description: "Posterity has noted your words.",
|
||||
Emoji: "💬",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by quote plugin when a quote is saved for the user
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "quote_saved_10", Name: "Memorable", Description: "You say things worth remembering. Allegedly.",
|
||||
Emoji: "🏅",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by quote plugin when 10 quotes are saved for the user
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "missing_poster", Name: "Milk Carton", Description: "Someone noticed you were gone. Eventually.",
|
||||
Emoji: "🥛",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by missing poster plugin
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "tarot_spread", Name: "The Fool's Journey", Description: "Past, present, future. All vibes.",
|
||||
Emoji: "🔮",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by tarot plugin on three-card spread
|
||||
return false
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,6 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
@@ -76,13 +74,12 @@ func (p *AdventurePlugin) Init() error {
|
||||
slog.Info("adventure: rehydrated DM rooms", "count", len(chars))
|
||||
}
|
||||
|
||||
// Catch up on missed jobs (e.g. after a redeploy or SQLite busy failure)
|
||||
dateKey := time.Now().UTC().Format("2006-01-02")
|
||||
if !db.JobCompleted("adventure_midnight", dateKey) {
|
||||
slog.Info("adventure: missed midnight reset detected, running catch-up")
|
||||
// Always reset daily actions at startup — idempotent (WHERE clause
|
||||
// only touches characters whose last_action_date < today). This handles
|
||||
// the case where the old buggy code marked the midnight job as completed
|
||||
// even though the actual reset failed due to SQLite contention.
|
||||
if err := resetAllAdvDailyActions(); err != nil {
|
||||
slog.Error("adventure: catch-up daily reset failed", "err", err)
|
||||
}
|
||||
slog.Error("adventure: startup daily reset failed", "err", err)
|
||||
}
|
||||
// Revive any characters whose DeadUntil has expired
|
||||
p.catchUpRespawns(chars)
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/crypto"
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
@@ -15,20 +20,44 @@ import (
|
||||
// MarkovPlugin collects messages and generates trigram-based text.
|
||||
type MarkovPlugin struct {
|
||||
Base
|
||||
encKey []byte // AES-256 key; nil means disabled
|
||||
enabled bool
|
||||
}
|
||||
|
||||
// NewMarkovPlugin creates a new Markov chain plugin.
|
||||
func NewMarkovPlugin(client *mautrix.Client) *MarkovPlugin {
|
||||
return &MarkovPlugin{
|
||||
p := &MarkovPlugin{
|
||||
Base: NewBase(client),
|
||||
}
|
||||
|
||||
raw := os.Getenv("QUOTE_ENCRYPTION_KEY")
|
||||
if raw == "" {
|
||||
slog.Error("markov: QUOTE_ENCRYPTION_KEY not set — markov collection and commands disabled")
|
||||
return p
|
||||
}
|
||||
|
||||
key, err := crypto.ParseKey(raw)
|
||||
if err != nil {
|
||||
slog.Error("markov: invalid QUOTE_ENCRYPTION_KEY — markov disabled", "err", err)
|
||||
return p
|
||||
}
|
||||
|
||||
p.encKey = key
|
||||
p.enabled = true
|
||||
|
||||
// Migration: purge any unencrypted rows
|
||||
p.purgeUnencryptedRows()
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *MarkovPlugin) Name() string { return "markov" }
|
||||
|
||||
func (p *MarkovPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "markov", Description: "Generate Markov chain text from a user's messages", Usage: "!markov [@user|me]", Category: "Fun & Games"},
|
||||
{Name: "markov", Description: "Generate Markov chain text from a user's messages", Usage: "!markov [@user|me|stats|forget|leaderboard]", Category: "Fun & Games"},
|
||||
{Name: "impersonate", Description: "Alias for !markov @user", Usage: "!impersonate @user", Category: "Fun & Games"},
|
||||
{Name: "ghostwrite", Description: "Seed Markov chain with a starting phrase", Usage: "!ghostwrite @user <prompt>", Category: "Fun & Games"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,9 +66,22 @@ func (p *MarkovPlugin) Init() error { return nil }
|
||||
func (p *MarkovPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *MarkovPlugin) OnMessage(ctx MessageContext) error {
|
||||
if !p.enabled {
|
||||
if p.IsCommand(ctx.Body, "markov") || p.IsCommand(ctx.Body, "impersonate") || p.IsCommand(ctx.Body, "ghostwrite") {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Markov is disabled (encryption key not configured).")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if p.IsCommand(ctx.Body, "markov") {
|
||||
return p.handleMarkov(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "impersonate") {
|
||||
return p.handleImpersonate(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "ghostwrite") {
|
||||
return p.handleGhostwrite(ctx)
|
||||
}
|
||||
|
||||
// Passive: collect non-command messages
|
||||
if !ctx.IsCommand {
|
||||
@@ -49,18 +91,64 @@ func (p *MarkovPlugin) OnMessage(ctx MessageContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// collectMessage stores a message in the markov_corpus, capping at 10,000 per user.
|
||||
// ── Regex for stripping noise from corpus entries ───────────────────────────
|
||||
|
||||
var (
|
||||
matrixEventIDRe = regexp.MustCompile(`\$[A-Za-z0-9_/+=.-]{20,}`)
|
||||
matrixRoomIDRe = regexp.MustCompile(`![A-Za-z0-9_/+=.-]+:[A-Za-z0-9._-]+`)
|
||||
commandPrefixRe = regexp.MustCompile(`^!\S+\s*`)
|
||||
)
|
||||
|
||||
// stripNoise removes Matrix event IDs, room IDs, and !command prefixes from text.
|
||||
func stripNoise(text string) string {
|
||||
text = matrixEventIDRe.ReplaceAllString(text, "")
|
||||
text = matrixRoomIDRe.ReplaceAllString(text, "")
|
||||
text = commandPrefixRe.ReplaceAllString(text, "")
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
// ── Encryption helpers ──────────────────────────────────────────────────────
|
||||
|
||||
func (p *MarkovPlugin) encryptText(plaintext string) (string, error) {
|
||||
ct, err := crypto.Encrypt(p.encKey, []byte(plaintext))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(ct), nil
|
||||
}
|
||||
|
||||
func (p *MarkovPlugin) decryptText(encoded string) (string, error) {
|
||||
ct, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
pt, err := crypto.Decrypt(p.encKey, ct)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(pt), nil
|
||||
}
|
||||
|
||||
// ── Corpus collection ───────────────────────────────────────────────────────
|
||||
|
||||
// collectMessage stores an encrypted message in the markov_corpus, capping at 10,000 per user.
|
||||
func (p *MarkovPlugin) collectMessage(userID id.UserID, text string) {
|
||||
// Skip very short messages
|
||||
if len(strings.Fields(text)) < 3 {
|
||||
cleaned := stripNoise(text)
|
||||
if len(strings.Fields(cleaned)) < 3 {
|
||||
return
|
||||
}
|
||||
|
||||
enc, err := p.encryptText(cleaned)
|
||||
if err != nil {
|
||||
slog.Error("markov: encrypt message", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
|
||||
_, err := d.Exec(
|
||||
_, err = d.Exec(
|
||||
`INSERT INTO markov_corpus (user_id, text) VALUES (?, ?)`,
|
||||
string(userID), text,
|
||||
string(userID), enc,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("markov: insert message", "err", err)
|
||||
@@ -76,74 +164,560 @@ func (p *MarkovPlugin) collectMessage(userID id.UserID, text string) {
|
||||
)
|
||||
}
|
||||
|
||||
func (p *MarkovPlugin) handleMarkov(ctx MessageContext) error {
|
||||
// loadCorpus loads and decrypts all corpus entries for a user. Unencrypted/corrupt rows are skipped.
|
||||
func (p *MarkovPlugin) loadCorpus(userID id.UserID) []string {
|
||||
d := db.Get()
|
||||
args := p.GetArgs(ctx.Body, "markov")
|
||||
|
||||
var targetUser id.UserID
|
||||
|
||||
switch {
|
||||
case args == "":
|
||||
// No argument — pick a random user from the corpus
|
||||
var randomUser string
|
||||
err := d.QueryRow(
|
||||
`SELECT user_id FROM markov_corpus ORDER BY RANDOM() LIMIT 1`,
|
||||
).Scan(&randomUser)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No Markov data available yet.")
|
||||
}
|
||||
targetUser = id.UserID(randomUser)
|
||||
case args == "me":
|
||||
targetUser = ctx.Sender
|
||||
default:
|
||||
// Treat as user ID
|
||||
if resolved, ok := p.ResolveUser(args, ctx.RoomID); ok {
|
||||
targetUser = resolved
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch corpus for the user
|
||||
rows, err := d.Query(
|
||||
`SELECT text FROM markov_corpus WHERE user_id = ?`,
|
||||
string(targetUser),
|
||||
string(userID),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("markov: query corpus", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load Markov data.")
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var texts []string
|
||||
for rows.Next() {
|
||||
var t string
|
||||
if err := rows.Scan(&t); err != nil {
|
||||
var enc string
|
||||
if err := rows.Scan(&enc); err != nil {
|
||||
continue
|
||||
}
|
||||
texts = append(texts, t)
|
||||
pt, err := p.decryptText(enc)
|
||||
if err != nil {
|
||||
// Unencrypted or corrupt row — skip
|
||||
continue
|
||||
}
|
||||
texts = append(texts, pt)
|
||||
}
|
||||
return texts
|
||||
}
|
||||
|
||||
// corpusCount returns the number of corpus entries for a user.
|
||||
func corpusCount(userID id.UserID) int {
|
||||
d := db.Get()
|
||||
var count int
|
||||
_ = d.QueryRow(`SELECT COUNT(*) FROM markov_corpus WHERE user_id = ?`, string(userID)).Scan(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
// ── Migration ───────────────────────────────────────────────────────────────
|
||||
|
||||
// purgeUnencryptedRows detects unencrypted rows (decrypt fails on non-base64) and purges them.
|
||||
func (p *MarkovPlugin) purgeUnencryptedRows() {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`SELECT id, text FROM markov_corpus`)
|
||||
if err != nil {
|
||||
slog.Error("markov: migration scan", "err", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var badIDs []interface{}
|
||||
for rows.Next() {
|
||||
var rowID int64
|
||||
var enc string
|
||||
if err := rows.Scan(&rowID, &enc); err != nil {
|
||||
continue
|
||||
}
|
||||
_, err := p.decryptText(enc)
|
||||
if err != nil {
|
||||
badIDs = append(badIDs, rowID)
|
||||
}
|
||||
}
|
||||
|
||||
if len(texts) < 10 {
|
||||
if len(badIDs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
slog.Warn("markov: purging unencrypted/corrupt rows", "count", len(badIDs))
|
||||
|
||||
// Delete in batches of 100
|
||||
for i := 0; i < len(badIDs); i += 100 {
|
||||
end := i + 100
|
||||
if end > len(badIDs) {
|
||||
end = len(badIDs)
|
||||
}
|
||||
batch := badIDs[i:end]
|
||||
placeholders := strings.Repeat("?,", len(batch))
|
||||
placeholders = placeholders[:len(placeholders)-1]
|
||||
_, err := d.Exec(
|
||||
fmt.Sprintf(`DELETE FROM markov_corpus WHERE id IN (%s)`, placeholders),
|
||||
batch...,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("markov: migration delete batch", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── TTL purge ───────────────────────────────────────────────────────────────
|
||||
|
||||
// MarkovPurgeExpired deletes corpus entries older than 90 days. Intended to be
|
||||
// called from the nightly scheduled jobs in main.go.
|
||||
func MarkovPurgeExpired() {
|
||||
d := db.Get()
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -90).Unix()
|
||||
res, err := d.Exec(`DELETE FROM markov_corpus WHERE created_at < ?`, cutoff)
|
||||
if err != nil {
|
||||
slog.Error("markov: purge expired", "err", err)
|
||||
return
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n > 0 {
|
||||
slog.Info("markov: purged expired entries", "rows", n)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Command handlers ────────────────────────────────────────────────────────
|
||||
|
||||
func (p *MarkovPlugin) handleMarkov(ctx MessageContext) error {
|
||||
args := p.GetArgs(ctx.Body, "markov")
|
||||
|
||||
switch {
|
||||
case args == "":
|
||||
return p.generateForUser(ctx, ctx.Sender, "")
|
||||
case args == "me":
|
||||
return p.generateForUser(ctx, ctx.Sender, "")
|
||||
case strings.HasPrefix(args, "stats"):
|
||||
return p.handleStats(ctx, strings.TrimSpace(strings.TrimPrefix(args, "stats")))
|
||||
case args == "forget":
|
||||
return p.handleForgetSelf(ctx)
|
||||
case strings.HasPrefix(args, "forget "):
|
||||
return p.handleForgetAdmin(ctx, strings.TrimSpace(strings.TrimPrefix(args, "forget ")))
|
||||
case args == "leaderboard":
|
||||
return p.handleLeaderboard(ctx)
|
||||
default:
|
||||
// Could be one user or two users (mashup)
|
||||
return p.handleGenerate(ctx, args)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *MarkovPlugin) handleImpersonate(ctx MessageContext) error {
|
||||
args := p.GetArgs(ctx.Body, "impersonate")
|
||||
if args == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !impersonate @user")
|
||||
}
|
||||
resolved, ok := p.ResolveUser(args, ctx.RoomID)
|
||||
if !ok {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Could not resolve user: %s", args))
|
||||
}
|
||||
return p.generateForUser(ctx, resolved, "")
|
||||
}
|
||||
|
||||
func (p *MarkovPlugin) handleGhostwrite(ctx MessageContext) error {
|
||||
args := p.GetArgs(ctx.Body, "ghostwrite")
|
||||
parts := strings.SplitN(args, " ", 2)
|
||||
if len(parts) < 2 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !ghostwrite @user <prompt>")
|
||||
}
|
||||
|
||||
resolved, ok := p.ResolveUser(parts[0], ctx.RoomID)
|
||||
if !ok {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Could not resolve user: %s", parts[0]))
|
||||
}
|
||||
|
||||
seed := strings.TrimSpace(parts[1])
|
||||
return p.generateForUser(ctx, resolved, seed)
|
||||
}
|
||||
|
||||
// handleGenerate parses one or two user mentions and generates accordingly.
|
||||
func (p *MarkovPlugin) handleGenerate(ctx MessageContext, args string) error {
|
||||
fields := strings.Fields(args)
|
||||
|
||||
if len(fields) == 1 {
|
||||
resolved, ok := p.ResolveUser(fields[0], ctx.RoomID)
|
||||
if !ok {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Could not resolve user: %s", fields[0]))
|
||||
}
|
||||
return p.generateForUser(ctx, resolved, "")
|
||||
}
|
||||
|
||||
if len(fields) == 2 {
|
||||
user1, ok1 := p.ResolveUser(fields[0], ctx.RoomID)
|
||||
user2, ok2 := p.ResolveUser(fields[1], ctx.RoomID)
|
||||
if !ok1 || !ok2 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Could not resolve one or both users.")
|
||||
}
|
||||
return p.generateMashup(ctx, user1, user2)
|
||||
}
|
||||
|
||||
// More than 2 — cap at 2
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Mashup supports at most 2 users.")
|
||||
}
|
||||
|
||||
// generateForUser generates markov text for a single user, optionally seeded.
|
||||
func (p *MarkovPlugin) generateForUser(ctx MessageContext, userID id.UserID, seed string) error {
|
||||
texts := p.loadCorpus(userID)
|
||||
|
||||
if len(texts) < 50 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("Not enough data for %s (need at least 10 messages).", string(targetUser)))
|
||||
fmt.Sprintf("Not enough data for %s (%d messages, need at least 50).",
|
||||
p.DisplayName(userID), len(texts)))
|
||||
}
|
||||
|
||||
// Build trigram model and generate
|
||||
result := generateMarkov(texts, 50)
|
||||
result := p.generateOutput(texts, seed)
|
||||
if result == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to generate Markov text.")
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("[%s]: %s", string(targetUser), result)
|
||||
// Re-roll once if exact corpus match
|
||||
for _, t := range texts {
|
||||
if result == t {
|
||||
result = p.generateOutput(texts, seed)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if result == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to generate Markov text.")
|
||||
}
|
||||
|
||||
name := p.DisplayName(userID)
|
||||
msg := fmt.Sprintf("[%s]: %s", name, result)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||
}
|
||||
|
||||
// generateMashup interleaves corpora of two users and generates.
|
||||
func (p *MarkovPlugin) generateMashup(ctx MessageContext, user1, user2 id.UserID) error {
|
||||
texts1 := p.loadCorpus(user1)
|
||||
texts2 := p.loadCorpus(user2)
|
||||
|
||||
if len(texts1) < 50 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("Not enough data for %s (%d messages, need at least 50).",
|
||||
p.DisplayName(user1), len(texts1)))
|
||||
}
|
||||
if len(texts2) < 50 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("Not enough data for %s (%d messages, need at least 50).",
|
||||
p.DisplayName(user2), len(texts2)))
|
||||
}
|
||||
|
||||
// Interleave corpora
|
||||
combined := make([]string, 0, len(texts1)+len(texts2))
|
||||
i, j := 0, 0
|
||||
for i < len(texts1) || j < len(texts2) {
|
||||
if i < len(texts1) {
|
||||
combined = append(combined, texts1[i])
|
||||
i++
|
||||
}
|
||||
if j < len(texts2) {
|
||||
combined = append(combined, texts2[j])
|
||||
j++
|
||||
}
|
||||
}
|
||||
|
||||
result := p.generateOutput(combined, "")
|
||||
if result == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to generate Markov text.")
|
||||
}
|
||||
|
||||
name1 := p.DisplayName(user1)
|
||||
name2 := p.DisplayName(user2)
|
||||
msg := fmt.Sprintf("[%s × %s]: %s", name1, name2, result)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||
}
|
||||
|
||||
// generateOutput builds a trigram model and generates 1-3 sentences, max 280 chars.
|
||||
// If seed is non-empty, tries to start the chain near the seed words.
|
||||
func (p *MarkovPlugin) generateOutput(texts []string, seed string) string {
|
||||
chain, starters := buildTrigramModel(texts)
|
||||
if len(starters) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var start trigramKey
|
||||
|
||||
if seed != "" {
|
||||
// Try to find a starter that begins with/near the seed
|
||||
seedWords := strings.Fields(strings.ToLower(seed))
|
||||
if len(seedWords) >= 2 {
|
||||
// Look for exact bigram match
|
||||
target := trigramKey{seedWords[0], seedWords[1]}
|
||||
if _, ok := chain[target]; ok {
|
||||
start = target
|
||||
}
|
||||
}
|
||||
if start.w1 == "" && len(seedWords) >= 1 {
|
||||
// Look for a starter beginning with the first seed word
|
||||
seedLower := seedWords[0]
|
||||
var candidates []trigramKey
|
||||
for _, s := range starters {
|
||||
if strings.ToLower(s.w1) == seedLower {
|
||||
candidates = append(candidates, s)
|
||||
}
|
||||
}
|
||||
if len(candidates) > 0 {
|
||||
start = candidates[rand.Intn(len(candidates))]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to random starter
|
||||
if start.w1 == "" {
|
||||
start = starters[rand.Intn(len(starters))]
|
||||
}
|
||||
|
||||
result := []string{start.w1, start.w2}
|
||||
sentences := countSentenceEnds(start.w1) + countSentenceEnds(start.w2)
|
||||
|
||||
for len(result) < 60 {
|
||||
key := trigramKey{result[len(result)-2], result[len(result)-1]}
|
||||
nextWords, ok := chain[key]
|
||||
if !ok || len(nextWords) == 0 {
|
||||
break
|
||||
}
|
||||
next := nextWords[rand.Intn(len(nextWords))]
|
||||
result = append(result, next)
|
||||
|
||||
if endsWithPunctuation(next) {
|
||||
sentences++
|
||||
if sentences >= 3 {
|
||||
break
|
||||
}
|
||||
// After at least 8 words and 1 sentence, chance to stop
|
||||
if len(result) > 8 && rand.Float64() < 0.3 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output := strings.Join(result, " ")
|
||||
|
||||
// Truncate to 280 chars at sentence boundary
|
||||
if len(output) > 280 {
|
||||
output = truncateAtSentence(output, 280)
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
func countSentenceEnds(s string) int {
|
||||
n := 0
|
||||
for _, c := range s {
|
||||
if c == '.' || c == '!' || c == '?' {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// truncateAtSentence truncates text to maxLen, preferring to cut at a sentence boundary.
|
||||
func truncateAtSentence(text string, maxLen int) string {
|
||||
if len(text) <= maxLen {
|
||||
return text
|
||||
}
|
||||
|
||||
sub := text[:maxLen]
|
||||
// Find last sentence-ending punctuation
|
||||
lastEnd := -1
|
||||
for i := len(sub) - 1; i >= 0; i-- {
|
||||
if sub[i] == '.' || sub[i] == '!' || sub[i] == '?' {
|
||||
lastEnd = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if lastEnd > 0 {
|
||||
return sub[:lastEnd+1]
|
||||
}
|
||||
// No sentence boundary — hard truncate at word boundary
|
||||
lastSpace := strings.LastIndex(sub, " ")
|
||||
if lastSpace > 0 {
|
||||
return sub[:lastSpace] + "..."
|
||||
}
|
||||
return sub
|
||||
}
|
||||
|
||||
// ── Stats ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *MarkovPlugin) handleStats(ctx MessageContext, userArg string) error {
|
||||
var targetUser id.UserID
|
||||
if userArg == "" || userArg == "me" {
|
||||
targetUser = ctx.Sender
|
||||
} else {
|
||||
resolved, ok := p.ResolveUser(userArg, ctx.RoomID)
|
||||
if !ok {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Could not resolve user: %s", userArg))
|
||||
}
|
||||
targetUser = resolved
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
|
||||
// Message count
|
||||
var msgCount int
|
||||
_ = d.QueryRow(`SELECT COUNT(*) FROM markov_corpus WHERE user_id = ?`, string(targetUser)).Scan(&msgCount)
|
||||
|
||||
if msgCount == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("%s has no Markov data.", p.DisplayName(targetUser)))
|
||||
}
|
||||
|
||||
// Earliest date
|
||||
var earliest int64
|
||||
_ = d.QueryRow(`SELECT MIN(created_at) FROM markov_corpus WHERE user_id = ?`, string(targetUser)).Scan(&earliest)
|
||||
earliestDate := time.Unix(earliest, 0).UTC().Format("2006-01-02")
|
||||
|
||||
// Decrypt all to compute word stats
|
||||
texts := p.loadCorpus(targetUser)
|
||||
wordFreq := make(map[string]int)
|
||||
totalWords := 0
|
||||
for _, t := range texts {
|
||||
for _, w := range strings.Fields(t) {
|
||||
w = strings.ToLower(w)
|
||||
wordFreq[w]++
|
||||
totalWords++
|
||||
}
|
||||
}
|
||||
|
||||
uniqueWords := len(wordFreq)
|
||||
|
||||
// Top 5 words
|
||||
type wordCount struct {
|
||||
word string
|
||||
count int
|
||||
}
|
||||
var top []wordCount
|
||||
for w, c := range wordFreq {
|
||||
if len(w) < 3 { // skip very short words
|
||||
continue
|
||||
}
|
||||
inserted := false
|
||||
for i, tc := range top {
|
||||
if c > tc.count {
|
||||
top = append(top, wordCount{})
|
||||
copy(top[i+1:], top[i:])
|
||||
top[i] = wordCount{w, c}
|
||||
inserted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !inserted && len(top) < 5 {
|
||||
top = append(top, wordCount{w, c})
|
||||
}
|
||||
if len(top) > 5 {
|
||||
top = top[:5]
|
||||
}
|
||||
}
|
||||
|
||||
name := p.DisplayName(targetUser)
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("📊 Markov Stats — %s\n\n", name))
|
||||
sb.WriteString(fmt.Sprintf("Messages: %d\n", msgCount))
|
||||
sb.WriteString(fmt.Sprintf("Unique words: %d\n", uniqueWords))
|
||||
sb.WriteString(fmt.Sprintf("Total words: %d\n", totalWords))
|
||||
sb.WriteString(fmt.Sprintf("Since: %s\n", earliestDate))
|
||||
|
||||
if len(top) > 0 {
|
||||
sb.WriteString("\nTop words: ")
|
||||
for i, tc := range top {
|
||||
if i > 0 {
|
||||
sb.WriteString(", ")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s (%d)", tc.word, tc.count))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString("\nℹ️ Entries older than 90 days are purged automatically.")
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
// ── Forget ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *MarkovPlugin) handleForgetSelf(ctx MessageContext) error {
|
||||
count := corpusCount(ctx.Sender)
|
||||
if count == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Your Markov corpus is already empty.")
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
_, err := d.Exec(`DELETE FROM markov_corpus WHERE user_id = ?`, string(ctx.Sender))
|
||||
if err != nil {
|
||||
slog.Error("markov: forget self", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to delete your Markov corpus.")
|
||||
}
|
||||
|
||||
_ = p.SendDM(ctx.Sender,
|
||||
fmt.Sprintf("Deleted %d entries from your Markov corpus. This cannot be undone.", count))
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Your Markov corpus has been deleted.")
|
||||
}
|
||||
|
||||
func (p *MarkovPlugin) handleForgetAdmin(ctx MessageContext, userArg string) error {
|
||||
if !p.IsAdmin(ctx.Sender) {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Only admins can delete another user's corpus.")
|
||||
}
|
||||
|
||||
resolved, ok := p.ResolveUser(userArg, ctx.RoomID)
|
||||
if !ok {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Could not resolve user: %s", userArg))
|
||||
}
|
||||
|
||||
count := corpusCount(resolved)
|
||||
if count == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("%s has no Markov data.", p.DisplayName(resolved)))
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
_, err := d.Exec(`DELETE FROM markov_corpus WHERE user_id = ?`, string(resolved))
|
||||
if err != nil {
|
||||
slog.Error("markov: forget admin", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to delete corpus.")
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("Deleted %d Markov entries for %s.", count, p.DisplayName(resolved)))
|
||||
}
|
||||
|
||||
// ── Leaderboard ─────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *MarkovPlugin) handleLeaderboard(ctx MessageContext) error {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(
|
||||
`SELECT user_id, COUNT(*) as cnt FROM markov_corpus
|
||||
GROUP BY user_id ORDER BY cnt DESC LIMIT 10`,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("markov: leaderboard query", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load leaderboard.")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("🏆 Markov Leaderboard — Top 10 by Corpus Size\n\n")
|
||||
|
||||
rank := 0
|
||||
for rows.Next() {
|
||||
var uid string
|
||||
var cnt int
|
||||
if err := rows.Scan(&uid, &cnt); err != nil {
|
||||
continue
|
||||
}
|
||||
rank++
|
||||
name := p.DisplayName(id.UserID(uid))
|
||||
sb.WriteString(fmt.Sprintf("%d. %s — %d messages\n", rank, name, cnt))
|
||||
}
|
||||
|
||||
if rank == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No Markov data available yet.")
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
// ── Trigram model ───────────────────────────────────────────────────────────
|
||||
|
||||
// trigram key
|
||||
type trigramKey struct {
|
||||
w1, w2 string
|
||||
}
|
||||
|
||||
// generateMarkov builds a trigram model from texts and generates output.
|
||||
func generateMarkov(texts []string, maxWords int) string {
|
||||
// buildTrigramModel builds a trigram chain and collects starters from texts.
|
||||
func buildTrigramModel(texts []string) (map[trigramKey][]string, []trigramKey) {
|
||||
chain := make(map[trigramKey][]string)
|
||||
var starters []trigramKey
|
||||
|
||||
@@ -161,6 +735,14 @@ func generateMarkov(texts []string, maxWords int) string {
|
||||
}
|
||||
}
|
||||
|
||||
return chain, starters
|
||||
}
|
||||
|
||||
// generateMarkov builds a trigram model from texts and generates output.
|
||||
// Kept for backward compatibility but delegates to the new model.
|
||||
func generateMarkov(texts []string, maxWords int) string {
|
||||
chain, starters := buildTrigramModel(texts)
|
||||
|
||||
if len(starters) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
744
internal/plugin/miniflux.go
Normal file
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 (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
@@ -29,7 +30,7 @@ func (p *ReactionsPlugin) Name() string { return "reactions" }
|
||||
|
||||
func (p *ReactionsPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "emojiboard", Description: "Top emoji givers, receivers, and most used emojis", Usage: "!emojiboard", Category: "Reactions"},
|
||||
{Name: "emojiboard", Description: "Top emoji givers, receivers, and most used emojis", Usage: "!emojiboard [@user | received [@user] | givers <emoji> | week]", Category: "Reactions"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,80 +73,88 @@ func (p *ReactionsPlugin) resolveEventSender(roomID id.RoomID, eventID id.EventI
|
||||
return evt.Sender, nil
|
||||
}
|
||||
|
||||
// roomDisplayName returns a human-readable room name, falling back to the room ID.
|
||||
func (p *ReactionsPlugin) roomDisplayName(roomID id.RoomID) string {
|
||||
var nameEvt event.RoomNameEventContent
|
||||
err := p.Client.StateEvent(context.Background(), roomID, event.StateRoomName, "", &nameEvt)
|
||||
if err == nil && nameEvt.Name != "" {
|
||||
return nameEvt.Name
|
||||
}
|
||||
return string(roomID)
|
||||
}
|
||||
|
||||
func (p *ReactionsPlugin) handleEmojiboard(ctx MessageContext) error {
|
||||
members := p.RoomMembers(ctx.RoomID)
|
||||
args := strings.TrimSpace(p.GetArgs(ctx.Body, "emojiboard"))
|
||||
lower := strings.ToLower(args)
|
||||
|
||||
switch {
|
||||
case args == "":
|
||||
return p.emojiboardTop(ctx, 0)
|
||||
case lower == "week":
|
||||
return p.emojiboardTop(ctx, 7)
|
||||
case strings.HasPrefix(lower, "received"):
|
||||
rest := args[len("received"):]
|
||||
return p.emojiboardReceived(ctx, strings.TrimSpace(rest))
|
||||
case strings.HasPrefix(lower, "givers "):
|
||||
rest := args[len("givers "):]
|
||||
return p.emojiboardGivers(ctx, strings.TrimSpace(rest))
|
||||
default:
|
||||
// Treat as @user lookup (emojis given by user)
|
||||
return p.emojiboardUser(ctx, args)
|
||||
}
|
||||
}
|
||||
|
||||
// emojiboardTop shows the top 10 most-used reaction emojis in the room.
|
||||
// If days > 0, scopes to that many days.
|
||||
func (p *ReactionsPlugin) emojiboardTop(ctx MessageContext, days int) error {
|
||||
d := db.Get()
|
||||
var sb strings.Builder
|
||||
roomID := string(ctx.RoomID)
|
||||
|
||||
// Top 10 emoji givers
|
||||
sb.WriteString("--- Top 10 Emoji Givers ---\n\n")
|
||||
rows, err := d.Query(
|
||||
`SELECT sender, COUNT(*) as cnt FROM reaction_log GROUP BY sender ORDER BY cnt DESC`,
|
||||
var timeClause string
|
||||
var queryArgs []any
|
||||
queryArgs = append(queryArgs, roomID)
|
||||
if days > 0 {
|
||||
cutoff := time.Now().Unix() - int64(days*86400)
|
||||
timeClause = " AND created_at >= ?"
|
||||
queryArgs = append(queryArgs, cutoff)
|
||||
}
|
||||
|
||||
// Get total reactions
|
||||
var total int
|
||||
row := d.QueryRow(
|
||||
`SELECT COALESCE(COUNT(*), 0) FROM reaction_log WHERE room_id = ?`+timeClause,
|
||||
queryArgs...,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("reactions: givers query", "err", err)
|
||||
if err := row.Scan(&total); err != nil {
|
||||
slog.Error("reactions: emojiboard total query", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.")
|
||||
}
|
||||
giverCount := appendUserBoard(&sb, rows, members)
|
||||
rows.Close()
|
||||
|
||||
// Top 10 emoji receivers
|
||||
sb.WriteString("\n--- Top 10 Emoji Receivers ---\n\n")
|
||||
rows, err = d.Query(
|
||||
`SELECT target_user, COUNT(*) as cnt FROM reaction_log GROUP BY target_user ORDER BY cnt DESC`,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("reactions: receivers query", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.")
|
||||
}
|
||||
receiverCount := appendUserBoard(&sb, rows, members)
|
||||
rows.Close()
|
||||
|
||||
// Top 10 most used emojis (no user filtering needed)
|
||||
sb.WriteString("\n--- Top 10 Most Used Emojis ---\n\n")
|
||||
rows, err = d.Query(
|
||||
`SELECT emoji, COUNT(*) as cnt FROM reaction_log GROUP BY emoji ORDER BY cnt DESC LIMIT 10`,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("reactions: emoji query", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.")
|
||||
}
|
||||
emojiCount := appendEmojiBoard(&sb, rows)
|
||||
rows.Close()
|
||||
|
||||
if giverCount == 0 && receiverCount == 0 && emojiCount == 0 {
|
||||
if total == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No reaction data yet.")
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
// Get top 10 emojis
|
||||
rows, err := d.Query(
|
||||
`SELECT emoji, COUNT(*) as cnt FROM reaction_log WHERE room_id = ?`+timeClause+
|
||||
` GROUP BY emoji ORDER BY cnt DESC LIMIT 10`,
|
||||
queryArgs...,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("reactions: emojiboard query", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// appendUserBoard writes ranked user lines from query rows, filtered by room members.
|
||||
func appendUserBoard(sb *strings.Builder, rows *sql.Rows, members map[id.UserID]bool) int {
|
||||
medals := []string{"🥇", "🥈", "🥉"}
|
||||
i := 0
|
||||
for rows.Next() && i < 10 {
|
||||
var name string
|
||||
var cnt int
|
||||
if err := rows.Scan(&name, &cnt); err != nil {
|
||||
continue
|
||||
}
|
||||
if members != nil && !members[id.UserID(name)] {
|
||||
continue
|
||||
}
|
||||
prefix := fmt.Sprintf("#%d", i+1)
|
||||
if i < len(medals) {
|
||||
prefix = medals[i]
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s %s — %s reactions\n", prefix, name, formatNumber(cnt)))
|
||||
i++
|
||||
}
|
||||
return i
|
||||
}
|
||||
roomName := p.roomDisplayName(ctx.RoomID)
|
||||
var sb strings.Builder
|
||||
|
||||
header := "🏆 Emoji Leaderboard"
|
||||
if days > 0 {
|
||||
header += fmt.Sprintf(" (last %d days)", days)
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s — %s\n\n", header, roomName))
|
||||
|
||||
// appendEmojiBoard writes ranked emoji lines.
|
||||
func appendEmojiBoard(sb *strings.Builder, rows *sql.Rows) int {
|
||||
i := 0
|
||||
for rows.Next() {
|
||||
var emoji string
|
||||
@@ -153,8 +162,181 @@ func appendEmojiBoard(sb *strings.Builder, rows *sql.Rows) int {
|
||||
if err := rows.Scan(&emoji, &cnt); err != nil {
|
||||
continue
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%d. %s — %s times\n", i+1, emoji, formatNumber(cnt)))
|
||||
pct := cnt * 100 / total
|
||||
sb.WriteString(fmt.Sprintf("%d. %s — %s (%d%%)\n", i+1, emoji, formatNumber(cnt), pct))
|
||||
i++
|
||||
}
|
||||
return i
|
||||
|
||||
sb.WriteString(fmt.Sprintf("\nTotal reactions: %s\n", formatNumber(total)))
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
// emojiboardUser shows the top emojis given by a specific user.
|
||||
func (p *ReactionsPlugin) emojiboardUser(ctx MessageContext, userArg string) error {
|
||||
userID, ok := p.ResolveUser(userArg, ctx.RoomID)
|
||||
if !ok {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Could not resolve user: %s", userArg))
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
roomID := string(ctx.RoomID)
|
||||
|
||||
// Total for this user in this room
|
||||
var total int
|
||||
row := d.QueryRow(
|
||||
`SELECT COALESCE(COUNT(*), 0) FROM reaction_log WHERE room_id = ? AND sender = ?`,
|
||||
roomID, string(userID),
|
||||
)
|
||||
if err := row.Scan(&total); err != nil {
|
||||
slog.Error("reactions: emojiboard user total", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.")
|
||||
}
|
||||
|
||||
if total == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("%s has no reactions in this room.", p.DisplayName(userID)))
|
||||
}
|
||||
|
||||
rows, err := d.Query(
|
||||
`SELECT emoji, COUNT(*) as cnt FROM reaction_log WHERE room_id = ? AND sender = ? GROUP BY emoji ORDER BY cnt DESC LIMIT 10`,
|
||||
roomID, string(userID),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("reactions: emojiboard user query", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("🏆 Emojis Given by %s — %s\n\n", p.DisplayName(userID), p.roomDisplayName(ctx.RoomID)))
|
||||
|
||||
i := 0
|
||||
for rows.Next() {
|
||||
var emoji string
|
||||
var cnt int
|
||||
if err := rows.Scan(&emoji, &cnt); err != nil {
|
||||
continue
|
||||
}
|
||||
pct := cnt * 100 / total
|
||||
sb.WriteString(fmt.Sprintf("%d. %s — %s (%d%%)\n", i+1, emoji, formatNumber(cnt), pct))
|
||||
i++
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("\nTotal reactions given: %s\n", formatNumber(total)))
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
// emojiboardReceived shows the top emojis received by a user.
|
||||
func (p *ReactionsPlugin) emojiboardReceived(ctx MessageContext, userArg string) error {
|
||||
// If no user specified, default to the sender
|
||||
var userID id.UserID
|
||||
if userArg == "" {
|
||||
userID = ctx.Sender
|
||||
} else {
|
||||
var ok bool
|
||||
userID, ok = p.ResolveUser(userArg, ctx.RoomID)
|
||||
if !ok {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Could not resolve user: %s", userArg))
|
||||
}
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
roomID := string(ctx.RoomID)
|
||||
|
||||
var total int
|
||||
row := d.QueryRow(
|
||||
`SELECT COALESCE(COUNT(*), 0) FROM reaction_log WHERE room_id = ? AND target_user = ?`,
|
||||
roomID, string(userID),
|
||||
)
|
||||
if err := row.Scan(&total); err != nil {
|
||||
slog.Error("reactions: emojiboard received total", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.")
|
||||
}
|
||||
|
||||
if total == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("%s has received no reactions in this room.", p.DisplayName(userID)))
|
||||
}
|
||||
|
||||
rows, err := d.Query(
|
||||
`SELECT emoji, COUNT(*) as cnt FROM reaction_log WHERE room_id = ? AND target_user = ? GROUP BY emoji ORDER BY cnt DESC LIMIT 10`,
|
||||
roomID, string(userID),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("reactions: emojiboard received query", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("🏆 Emojis Received by %s — %s\n\n", p.DisplayName(userID), p.roomDisplayName(ctx.RoomID)))
|
||||
|
||||
i := 0
|
||||
for rows.Next() {
|
||||
var emoji string
|
||||
var cnt int
|
||||
if err := rows.Scan(&emoji, &cnt); err != nil {
|
||||
continue
|
||||
}
|
||||
pct := cnt * 100 / total
|
||||
sb.WriteString(fmt.Sprintf("%d. %s — %s (%d%%)\n", i+1, emoji, formatNumber(cnt), pct))
|
||||
i++
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("\nTotal reactions received: %s\n", formatNumber(total)))
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
// emojiboardGivers shows the top 5 users who used a specific emoji most.
|
||||
func (p *ReactionsPlugin) emojiboardGivers(ctx MessageContext, emoji string) error {
|
||||
if emoji == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !emojiboard givers <emoji>")
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
roomID := string(ctx.RoomID)
|
||||
|
||||
var total int
|
||||
row := d.QueryRow(
|
||||
`SELECT COALESCE(COUNT(*), 0) FROM reaction_log WHERE room_id = ? AND emoji = ?`,
|
||||
roomID, emoji,
|
||||
)
|
||||
if err := row.Scan(&total); err != nil {
|
||||
slog.Error("reactions: emojiboard givers total", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.")
|
||||
}
|
||||
|
||||
if total == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No one has used %s in this room.", emoji))
|
||||
}
|
||||
|
||||
rows, err := d.Query(
|
||||
`SELECT sender, COUNT(*) as cnt FROM reaction_log WHERE room_id = ? AND emoji = ? GROUP BY sender ORDER BY cnt DESC LIMIT 5`,
|
||||
roomID, emoji,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("reactions: emojiboard givers query", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("🏆 Top %s Givers — %s\n\n", emoji, p.roomDisplayName(ctx.RoomID)))
|
||||
|
||||
i := 0
|
||||
for rows.Next() {
|
||||
var sender string
|
||||
var cnt int
|
||||
if err := rows.Scan(&sender, &cnt); err != nil {
|
||||
continue
|
||||
}
|
||||
pct := cnt * 100 / total
|
||||
sb.WriteString(fmt.Sprintf("%d. %s — %s (%d%%)\n", i+1, p.DisplayName(id.UserID(sender)), formatNumber(cnt), pct))
|
||||
i++
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("\nTotal %s reactions: %s\n", emoji, formatNumber(total)))
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
23
main.go
23
main.go
@@ -132,6 +132,11 @@ func main() {
|
||||
registry.Register(plugin.NewMarkovPlugin(client))
|
||||
registry.Register(plugin.NewURLsPlugin(client))
|
||||
|
||||
// Automation
|
||||
minifluxPlugin := plugin.NewMinifluxPlugin(client)
|
||||
plugin.RegisterMinifluxPlugin(minifluxPlugin)
|
||||
registry.Register(minifluxPlugin)
|
||||
|
||||
// LLM-powered (passive)
|
||||
registry.Register(plugin.NewLLMPassivePlugin(client, xpPlugin))
|
||||
|
||||
@@ -280,7 +285,7 @@ func main() {
|
||||
|
||||
// ---- Set up cron scheduler ----
|
||||
scheduler := cron.New(cron.WithChain(cron.Recover(cronLogger{})))
|
||||
setupScheduledJobs(scheduler, client, wotdPlugin, holidaysPlugin, gamingPlugin, birthdayPlugin, animePlugin, moviesPlugin, concertsPlugin, esteemedPlugin, forexPlugin)
|
||||
setupScheduledJobs(scheduler, client, wotdPlugin, holidaysPlugin, gamingPlugin, birthdayPlugin, animePlugin, moviesPlugin, concertsPlugin, esteemedPlugin, forexPlugin, minifluxPlugin)
|
||||
scheduler.Start()
|
||||
|
||||
// ---- Start syncing ----
|
||||
@@ -332,6 +337,7 @@ func setupScheduledJobs(
|
||||
concerts *plugin.ConcertsPlugin,
|
||||
esteemed *plugin.EsteemPlugin,
|
||||
forex *plugin.ForexPlugin,
|
||||
miniflux *plugin.MinifluxPlugin,
|
||||
) {
|
||||
rooms := getRooms()
|
||||
|
||||
@@ -421,10 +427,25 @@ func setupScheduledJobs(
|
||||
plugin.RefreshSpaceGroups()
|
||||
})
|
||||
|
||||
// Markov corpus TTL purge at 03:30 daily
|
||||
c.AddFunc("30 3 * * *", func() {
|
||||
slog.Info("scheduler: purging expired markov entries")
|
||||
plugin.MarkovPurgeExpired()
|
||||
})
|
||||
|
||||
// Miniflux RSS polling
|
||||
if miniflux != nil {
|
||||
interval := fmt.Sprintf("@every %dm", miniflux.PollInterval())
|
||||
c.AddFunc(interval, func() {
|
||||
plugin.MinifluxPoll(client)
|
||||
})
|
||||
}
|
||||
|
||||
// Database maintenance at 03:00 daily
|
||||
c.AddFunc("0 3 * * *", func() {
|
||||
slog.Info("scheduler: running database maintenance")
|
||||
db.RunMaintenance()
|
||||
plugin.MinifluxPurgeSeen()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user