mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Add level-up announcements, new plugins, scheduler overhaul, and bug fixes
- Level-up announcements with Twinbee/Parodius-themed messages and emoji flair - New plugins: !howami (LLM stat roast), !vibe (room energy), !tldr (conversation summary) - !game command as primary game lookup with !retro as alias - Prefetch system: API data cached at 00:05 UTC for reliable scheduled posts - Nightly maintenance job: prunes old data across 15+ tables - Sentiment aggregation: new sentiment_stats table with backfill migration - Access token moved from .env to device.json for security - Retry logic (sendReact/sendWithRetry) for transient connection failures - Trivia crash fix: optional chaining on threadEventId across all locations - Bug fixes: COALESCE for NULL SUM in howami, unused params in vibe, dead try-catch in fun - License updated from ISC to MIT - README: weather section expanded, prefetch/maintenance documented Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
59
README.md
59
README.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
A full-featured Matrix community bot with end-to-end encryption support, a plugin architecture, silent passive tracking, daily scheduled posts, and a suite of community engagement features.
|
A full-featured Matrix community bot with end-to-end encryption support, a plugin architecture, silent passive tracking, daily scheduled posts, and a suite of community engagement features.
|
||||||
|
|
||||||
Built for the TwinBee/Parodius community. Runs on Node.js with TypeScript, SQLite, and `matrix-js-sdk` with automatic Rust E2EE.
|
Runs on Node.js with TypeScript, SQLite, and `matrix-js-sdk` with automatic Rust E2EE.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -32,11 +32,11 @@ Built for the TwinBee/Parodius community. Runs on Node.js with TypeScript, SQLit
|
|||||||
- **End-to-end encryption** via `matrix-js-sdk` with Rust crypto — automatic key management, session rotation, and key gossiping out of the box
|
- **End-to-end encryption** via `matrix-js-sdk` with Rust crypto — automatic key management, session rotation, and key gossiping out of the box
|
||||||
- **Automatic token renewal** — set `MATRIX_BOT_PASSWORD` and the bot refreshes expired access tokens on its own
|
- **Automatic token renewal** — set `MATRIX_BOT_PASSWORD` and the bot refreshes expired access tokens on its own
|
||||||
- **Crypto reset flow** — set `CRYPTO_RESET=true` to wipe and re-establish E2EE keys in one restart, with automatic server-side device cleanup
|
- **Crypto reset flow** — set `CRYPTO_RESET=true` to wipe and re-establish E2EE keys in one restart, with automatic server-side device cleanup
|
||||||
- **Plugin architecture** — modular design with 32+ independent plugins
|
- **Plugin architecture** — modular design with 35+ independent plugins
|
||||||
- **Silent passive tracking** — XP, streaks, stats, achievements, keyword alerts, Markov corpus, and presence tracked without announcements
|
- **Silent passive tracking** — streaks, stats, achievements, keyword alerts, Markov corpus, and presence tracked without announcements
|
||||||
- **Scheduled daily posts** — Word of the Day, holidays, game releases, birthdays, anime airings, movie releases, and weekly concert digests
|
- **Scheduled daily posts** — Word of the Day, holidays, game releases, birthdays, anime airings, movie releases, and weekly concert digests
|
||||||
- **Multi-calendar holiday support** — Gregorian, Hebrew, Hijri, and Asian calendars (JP, CN, KR, IN, TH, VN, TW, PH) with non-Western holiday featuring
|
- **Multi-calendar holiday support** — Gregorian, Hebrew, Hijri, and Asian calendars (JP, CN, KR, IN, TH, VN, TW, PH) with non-Western holiday featuring
|
||||||
- **XP & leveling system** with cooldown-based anti-farming
|
- **XP & leveling system** with cooldown-based anti-farming and Twinbee/Parodius-themed level-up announcements
|
||||||
- **Reputation system** with natural language thanks detection (LLM-powered sarcasm-aware mode when Ollama is active)
|
- **Reputation system** with natural language thanks detection (LLM-powered sarcasm-aware mode when Ollama is active)
|
||||||
- **32 unlockable achievements** evaluated silently on every message
|
- **32 unlockable achievements** evaluated silently on every message
|
||||||
- **Trivia system** with OpenTDB integration, time-weighted scoring, and threaded conversations
|
- **Trivia system** with OpenTDB integration, time-weighted scoring, and threaded conversations
|
||||||
@@ -46,6 +46,9 @@ Built for the TwinBee/Parodius community. Runs on Node.js with TypeScript, SQLit
|
|||||||
- **Movie & TV tracking** via TMDB with watchlists and release alerts
|
- **Movie & TV tracking** via TMDB with watchlists and release alerts
|
||||||
- **Concert tracking** via Bandsintown with artist watchlists and weekly digests
|
- **Concert tracking** via Bandsintown with artist watchlists and weekly digests
|
||||||
- **LLM-powered passive classifier** (optional, via Ollama) — multilingual profanity, insult, sentiment, gratitude, and WOTD correctness detection with emoji reactions and robust JSON repair
|
- **LLM-powered passive classifier** (optional, via Ollama) — multilingual profanity, insult, sentiment, gratitude, and WOTD correctness detection with emoji reactions and robust JSON repair
|
||||||
|
- **`!howami` roast command** — LLM generates a personalized roast based on your actual stats, achievements, and behavior patterns
|
||||||
|
- **`!vibe` room energy check** — LLM reads recent messages and describes the current chat mood
|
||||||
|
- **`!tldr` conversation summary** — catch up on what you missed with an LLM-generated summary of recent messages
|
||||||
- **URL preview enrichment** — automatic og:tag extraction for shared links
|
- **URL preview enrichment** — automatic og:tag extraction for shared links
|
||||||
- **Markov chain text generation** — per-user trigram models for chaos
|
- **Markov chain text generation** — per-user trigram models for chaos
|
||||||
- **Lookup tools** — Wikipedia, dictionary, and translation (LibreTranslate)
|
- **Lookup tools** — Wikipedia, dictionary, and translation (LibreTranslate)
|
||||||
@@ -53,7 +56,8 @@ Built for the TwinBee/Parodius community. Runs on Node.js with TypeScript, SQLit
|
|||||||
- **Community countdowns** — public and private event countdowns
|
- **Community countdowns** — public and private event countdowns
|
||||||
- **Welcome system** — greets new users with bonus XP and provides `!help` via DM
|
- **Welcome system** — greets new users with bonus XP and provides `!help` via DM
|
||||||
- **Reminder system** with natural language time parsing
|
- **Reminder system** with natural language time parsing
|
||||||
- **Reaction polls**, dice rolling, world clock, weather (with geocoding for ambiguous cities and zip codes), and more
|
- **Weather system** — current conditions via OpenWeatherMap with intelligent geocoding: city names, zip/postal codes (with optional country suffix), `@user` timezone-based lookup, US-preferred disambiguation for ambiguous cities, dual °C/°F display, feels-like temperature, humidity, and wind speed
|
||||||
|
- **Reaction polls**, dice rolling, world clock, and more
|
||||||
- **Inline calculator** — math expressions, percentages, unit conversions via mathjs
|
- **Inline calculator** — math expressions, percentages, unit conversions via mathjs
|
||||||
- **QR code generator** — generates and posts QR code images to the room
|
- **QR code generator** — generates and posts QR code images to the room
|
||||||
- **Reaction leaderboard** — silently tracks emoji reactions, revealed with `!emojiboard`
|
- **Reaction leaderboard** — silently tracks emoji reactions, revealed with `!emojiboard`
|
||||||
@@ -254,7 +258,7 @@ All commands use the configured prefix (default: `!`). Use `!help` to get a full
|
|||||||
| `!rank [@user]` | Show XP, level, and progress bar |
|
| `!rank [@user]` | Show XP, level, and progress bar |
|
||||||
| `!leaderboard [n]` | Top N users by XP (default 10, max 25) |
|
| `!leaderboard [n]` | Top N users by XP (default 10, max 25) |
|
||||||
|
|
||||||
XP is earned passively: **10 XP per message** with a 30-second cooldown to prevent farming. The level curve is linear: level N requires N x 100 total XP.
|
XP is earned passively: **10 XP per message** with a 30-second cooldown to prevent farming. The level curve is linear: level N requires N x 100 total XP. When a user levels up, the bot announces it with a random Twinbee/Parodius-themed power-up message (bell combos, laser modes, shield activations, etc.).
|
||||||
|
|
||||||
### Reputation
|
### Reputation
|
||||||
|
|
||||||
@@ -331,6 +335,16 @@ Reminders are checked every 30 seconds.
|
|||||||
|
|
||||||
Away/AFK status auto-clears silently when the user sends any message.
|
Away/AFK status auto-clears silently when the user sends any message.
|
||||||
|
|
||||||
|
### Weather
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---|---|
|
||||||
|
| `!weather <city>` | Current weather for a city name (e.g., `!weather Tokyo`) |
|
||||||
|
| `!weather <zip[,CC]>` | Weather by zip/postal code — defaults to US, append country code for others (e.g., `!weather 10115,DE`) |
|
||||||
|
| `!weather @user` | Weather for a user's location, derived from their `!settz` timezone |
|
||||||
|
|
||||||
|
Weather uses the OpenWeatherMap Geocoding API to resolve locations before fetching conditions. For ambiguous city names (e.g., "Springfield"), US matches are preferred. Output includes current conditions, temperature in both °C and °F, feels-like temperature, humidity, and wind speed.
|
||||||
|
|
||||||
### Fun
|
### Fun
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
@@ -339,7 +353,6 @@ Away/AFK status auto-clears silently when the user sends any message.
|
|||||||
| `!8ball <question>` | Magic 8-Ball |
|
| `!8ball <question>` | Magic 8-Ball |
|
||||||
| `!coin` | Coin flip |
|
| `!coin` | Coin flip |
|
||||||
| `!time <city\|@user>, ...` | World clock (comma-separated, supports users) |
|
| `!time <city\|@user>, ...` | World clock (comma-separated, supports users) |
|
||||||
| `!weather <city\|zip[,CC]\|@user>` | Current weather with geocoding (temp, humidity, wind) |
|
|
||||||
| `!hltb <game>` | HowLongToBeat lookup (main, extra, completionist) |
|
| `!hltb <game>` | HowLongToBeat lookup (main, extra, completionist) |
|
||||||
| `!twinbee` | Random TwinBee/Parodius lore fact |
|
| `!twinbee` | Random TwinBee/Parodius lore fact |
|
||||||
| `!poll "Q" "A" "B" ...` | Reaction poll (max 10 options, auto-reacts with number emojis) |
|
| `!poll "Q" "A" "B" ...` | Reaction poll (max 10 options, auto-reacts with number emojis) |
|
||||||
@@ -378,11 +391,12 @@ Answer by typing the letter (A/B/C/D) or True/False. First correct answer wins.
|
|||||||
| `!releases month` | Releases in the next 30 days |
|
| `!releases month` | Releases in the next 30 days |
|
||||||
| `!releases search <game>` | Search for a game's release info |
|
| `!releases search <game>` | Search for a game's release info |
|
||||||
|
|
||||||
### Retro Games
|
### Game Lookup
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `!retro <game>` | Look up a classic/retro game (name, year, platforms, developer, genre, metacritic, summary) |
|
| `!game <game>` | Look up any game (name, year, platforms, developer, genre, metacritic, summary) |
|
||||||
|
| `!retro <game>` | Alias for `!game` |
|
||||||
|
|
||||||
Results are cached for 7 days. Uses the same RAWG API key as `!releases`.
|
Results are cached for 7 days. Uses the same RAWG API key as `!releases`.
|
||||||
|
|
||||||
@@ -507,13 +521,18 @@ These commands are only available when `OLLAMA_HOST` and `OLLAMA_MODEL` are conf
|
|||||||
| `!insults [@user]` | Insult stats (thrown/received) |
|
| `!insults [@user]` | Insult stats (thrown/received) |
|
||||||
| `!insultboard` | Most prolific insulters leaderboard |
|
| `!insultboard` | Most prolific insulters leaderboard |
|
||||||
| `!wotd attempts` | Who tried to use today's WOTD and whether they got credit |
|
| `!wotd attempts` | Who tried to use today's WOTD and whether they got credit |
|
||||||
|
| `!howami [@user]` | Get roasted based on your actual stats (XP, messages, profanity, trivia, achievements, sentiment, etc.) |
|
||||||
|
| `!vibe` | LLM reads recent messages and describes the current room energy (5-min cooldown per room) |
|
||||||
|
| `!tldr [n]` | Summarize the last N messages (default: all buffered, max 50) for someone catching up |
|
||||||
|
|
||||||
The classifier also provides emoji reactions as real-time feedback:
|
The classifier also provides emoji reactions as real-time feedback (with automatic retry on connection failures):
|
||||||
- **Profanity**: 🫣 (mild), 😲 (moderate), 🤬 (severe)
|
- **Profanity**: 🫣 (mild), 😲 (moderate), 🤬 (severe)
|
||||||
- **Insults**: 🖕 (bot targeted), 🎯 (direct), 💨 (indirect)
|
- **Insults**: 🖕 (bot targeted), 🎯 (direct), 💨 (indirect)
|
||||||
- **WOTD**: 📖 (correct usage), 🤔 (incorrect)
|
- **WOTD**: 📖 (correct usage), 🤔 (incorrect)
|
||||||
- **Sentiment** (when no other reaction applies): random emoji from pools for happy, sad, angry, excited, funny, love, and scared
|
- **Sentiment** (when no other reaction applies): random emoji from pools for happy, sad, angry, excited, funny, love, and scared
|
||||||
|
|
||||||
|
`!vibe` and `!tldr` share a rolling in-memory buffer of the last 50 text messages per room. The buffer is not persisted — it resets on restart. At least 10 messages are required before either command works.
|
||||||
|
|
||||||
### Achievements
|
### Achievements
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
@@ -543,7 +562,7 @@ These features run silently on every message. The bot never announces passive tr
|
|||||||
|
|
||||||
| Feature | Description |
|
| Feature | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **XP tracking** | Grants 10 XP per message (30s cooldown) |
|
| **XP tracking** | Grants 10 XP per message (30s cooldown); announces level-ups with Twinbee/Parodius-themed messages |
|
||||||
| **Stats tracking** | Counts words, links, images, questions, emojis, message length, hourly/daily distributions |
|
| **Stats tracking** | Counts words, links, images, questions, emojis, message length, hourly/daily distributions |
|
||||||
| **Streak tracking** | Tracks daily activity and consecutive-day streaks |
|
| **Streak tracking** | Tracks daily activity and consecutive-day streaks |
|
||||||
| **First! tracking** | Records who posts the first message each day per room |
|
| **First! tracking** | Records who posts the first message each day per room |
|
||||||
@@ -594,6 +613,12 @@ Posts watchlisted movie and TV releases for today. DMs users when their watchlis
|
|||||||
### Concert Digest (default: 10:00 UTC, Sundays only)
|
### Concert Digest (default: 10:00 UTC, Sundays only)
|
||||||
Posts "This Week in Live Music" with concerts in the next 7 days for watched artists. DMs users watching artists with upcoming shows. Only fires on Sundays.
|
Posts "This Week in Live Music" with concerts in the next 7 days for watched artists. DMs users watching artists with upcoming shows. Only fires on Sundays.
|
||||||
|
|
||||||
|
### Prefetch (default: 00:05 UTC)
|
||||||
|
Fetches API data for Holidays and WOTD ahead of their scheduled post times and caches it in the `daily_prefetch` table. This ensures the actual post jobs have no network dependency and can deliver content even if the APIs are temporarily down. Each source is fetched independently — one failure doesn't block the others.
|
||||||
|
|
||||||
|
### Nightly Maintenance (default: 00:15 UTC)
|
||||||
|
Prunes old data to prevent database bloat. Retention policies: `llm_classifications` (30 days), `xp_log` (30 days), `command_usage` (7 days), `rep_cooldowns` (2 days), `daily_prefetch` (3 days), all cache tables (7–30 days depending on type), and `shade_log` (30 days). Runs after prefetch so fresh data is never pruned.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Achievements
|
## Achievements
|
||||||
@@ -778,7 +803,7 @@ freebee/
|
|||||||
│ ├── wotd.ts # Word of the Day
|
│ ├── wotd.ts # Word of the Day
|
||||||
│ ├── holidays.ts # multi-calendar holidays
|
│ ├── holidays.ts # multi-calendar holidays
|
||||||
│ ├── gaming.ts # game releases (RAWG)
|
│ ├── gaming.ts # game releases (RAWG)
|
||||||
│ ├── daily.ts # DailyScheduler (7 jobs)
|
│ ├── daily.ts # DailyScheduler (9 jobs incl. prefetch & maintenance)
|
||||||
│ ├── achievements.ts # 32 silent achievements
|
│ ├── achievements.ts # 32 silent achievements
|
||||||
│ ├── ratelimits.ts # per-command daily quota middleware
|
│ ├── ratelimits.ts # per-command daily quota middleware
|
||||||
│ ├── birthday.ts # birthday storage & announcements
|
│ ├── birthday.ts # birthday storage & announcements
|
||||||
@@ -797,7 +822,9 @@ freebee/
|
|||||||
│ ├── tools.ts # calc, QR code generator
|
│ ├── tools.ts # calc, QR code generator
|
||||||
│ ├── reactions.ts # reaction tracking, emojiboard
|
│ ├── reactions.ts # reaction tracking, emojiboard
|
||||||
│ ├── botinfo.ts # admin diagnostics
|
│ ├── botinfo.ts # admin diagnostics
|
||||||
│ ├── retro.ts # retro game database (RAWG)
|
│ ├── retro.ts # game lookup (RAWG) — !game and !retro
|
||||||
|
│ ├── howami.ts # LLM-powered stat roasts
|
||||||
|
│ ├── vibe.ts # room vibe check + !tldr summaries
|
||||||
│ └── shade.ts # STUB — feature flagged off
|
│ └── shade.ts # STUB — feature flagged off
|
||||||
└── workers/
|
└── workers/
|
||||||
└── llm-classifier.ts # STUB — superseded by llm-passive.ts
|
└── llm-classifier.ts # STUB — superseded by llm-passive.ts
|
||||||
@@ -817,7 +844,7 @@ Plugins are registered with `PluginRegistry`, which dispatches messages and reac
|
|||||||
|
|
||||||
### Key Design Decisions
|
### Key Design Decisions
|
||||||
|
|
||||||
- **All passive tracking is silent.** The bot never announces that it noticed something (streaks, First!, keyword watches, achievements). Commands are the only reveal mechanism.
|
- **Almost all passive tracking is silent.** The bot never announces streaks, First!, keyword watches, or achievements — commands are the only reveal mechanism. The one exception is **level-ups**, which get a celebratory Twinbee/Parodius-themed announcement.
|
||||||
- **XpPlugin.grantXp() is the single XP entry point** — Birthday, WOTD, Trivia, Welcome, Reputation, and LLM classifier all route through it.
|
- **XpPlugin.grantXp() is the single XP entry point** — Birthday, WOTD, Trivia, Welcome, Reputation, and LLM classifier all route through it.
|
||||||
- **LLM features are fully optional.** Gated by `OLLAMA_HOST` and `OLLAMA_MODEL` — both must be set for classifier to activate. All LLM commands return "not configured" otherwise. The classifier queue is capped at 50 items, requests time out after 30 seconds, a 16k context window is configured for Ollama, and exponential backoff (5s–5min) prevents hammering a down Ollama instance. When active, LLM replaces keyword-based rep granting with sarcasm-aware gratitude detection.
|
- **LLM features are fully optional.** Gated by `OLLAMA_HOST` and `OLLAMA_MODEL` — both must be set for classifier to activate. All LLM commands return "not configured" otherwise. The classifier queue is capped at 50 items, requests time out after 30 seconds, a 16k context window is configured for Ollama, and exponential backoff (5s–5min) prevents hammering a down Ollama instance. When active, LLM replaces keyword-based rep granting with sarcasm-aware gratitude detection.
|
||||||
- **Rate limits are centralized** in `RateLimitsPlugin`. Paid-API commands call `checkLimit()` before requests. Admins bypass all limits.
|
- **Rate limits are centralized** in `RateLimitsPlugin`. Paid-API commands call `checkLimit()` before requests. Admins bypass all limits.
|
||||||
@@ -886,6 +913,8 @@ Freebee uses SQLite via `better-sqlite3` in WAL mode. The database file is at `D
|
|||||||
| `reaction_log` | Emoji reaction tracking (giver, receiver, emoji) |
|
| `reaction_log` | Emoji reaction tracking (giver, receiver, emoji) |
|
||||||
| `retro_cache` | RAWG retro game lookup cache (7d TTL) |
|
| `retro_cache` | RAWG retro game lookup cache (7d TTL) |
|
||||||
| `url_cache` | URL og:tag preview cache (24h TTL) |
|
| `url_cache` | URL og:tag preview cache (24h TTL) |
|
||||||
|
| `sentiment_stats` | Aggregated sentiment counts per (user, room) |
|
||||||
|
| `daily_prefetch` | Cached API data for scheduled posts (holidays, WOTD) |
|
||||||
|
|
||||||
All tables are created automatically on first run.
|
All tables are created automatically on first run.
|
||||||
|
|
||||||
@@ -997,4 +1026,4 @@ If only the crypto store is lost (database intact), delete the old `crypto-js/`
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
ISC
|
MIT
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"name": "freebee",
|
"name": "freebee",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "A full-featured Matrix community bot with E2EE support, plugin architecture, and community engagement features",
|
"description": "A full-featured Matrix community bot with E2EE support, plugin architecture, and community engagement features",
|
||||||
|
"license": "MIT",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
|
|||||||
@@ -474,6 +474,31 @@ function createTables(): void {
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_reaction_room ON reaction_log (room_id);
|
CREATE INDEX IF NOT EXISTS idx_reaction_room ON reaction_log (room_id);
|
||||||
|
|
||||||
|
-- Sentiment aggregates (rolled up from llm_classifications)
|
||||||
|
CREATE TABLE IF NOT EXISTS sentiment_stats (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
neutral INTEGER DEFAULT 0,
|
||||||
|
happy INTEGER DEFAULT 0,
|
||||||
|
sad INTEGER DEFAULT 0,
|
||||||
|
angry INTEGER DEFAULT 0,
|
||||||
|
excited INTEGER DEFAULT 0,
|
||||||
|
funny INTEGER DEFAULT 0,
|
||||||
|
love INTEGER DEFAULT 0,
|
||||||
|
scared INTEGER DEFAULT 0,
|
||||||
|
total_classified INTEGER DEFAULT 0,
|
||||||
|
PRIMARY KEY (user_id, room_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Daily prefetch cache (API data fetched early, posted later)
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_prefetch (
|
||||||
|
job_name TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
fetched_at INTEGER DEFAULT (unixepoch()),
|
||||||
|
PRIMARY KEY (job_name, date)
|
||||||
|
);
|
||||||
|
|
||||||
-- URL preview cache
|
-- URL preview cache
|
||||||
CREATE TABLE IF NOT EXISTS url_cache (
|
CREATE TABLE IF NOT EXISTS url_cache (
|
||||||
url TEXT PRIMARY KEY,
|
url TEXT PRIMARY KEY,
|
||||||
@@ -499,6 +524,27 @@ function runMigrations(): void {
|
|||||||
logger.info("Migration: added gratitude columns to llm_classifications");
|
logger.info("Migration: added gratitude columns to llm_classifications");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backfill sentiment_stats from existing llm_classifications
|
||||||
|
const sentimentCount = db.prepare(`SELECT COUNT(*) as c FROM sentiment_stats`).get() as { c: number };
|
||||||
|
const classCount = db.prepare(`SELECT COUNT(*) as c FROM llm_classifications`).get() as { c: number };
|
||||||
|
if (sentimentCount.c === 0 && classCount.c > 0) {
|
||||||
|
db.exec(`
|
||||||
|
INSERT INTO sentiment_stats (user_id, room_id, neutral, happy, sad, angry, excited, funny, love, scared, total_classified)
|
||||||
|
SELECT user_id, room_id,
|
||||||
|
SUM(CASE WHEN sentiment = 'neutral' THEN 1 ELSE 0 END),
|
||||||
|
SUM(CASE WHEN sentiment = 'happy' THEN 1 ELSE 0 END),
|
||||||
|
SUM(CASE WHEN sentiment = 'sad' THEN 1 ELSE 0 END),
|
||||||
|
SUM(CASE WHEN sentiment = 'angry' THEN 1 ELSE 0 END),
|
||||||
|
SUM(CASE WHEN sentiment = 'excited' THEN 1 ELSE 0 END),
|
||||||
|
SUM(CASE WHEN sentiment = 'funny' THEN 1 ELSE 0 END),
|
||||||
|
SUM(CASE WHEN sentiment = 'love' THEN 1 ELSE 0 END),
|
||||||
|
SUM(CASE WHEN sentiment = 'scared' THEN 1 ELSE 0 END),
|
||||||
|
COUNT(*)
|
||||||
|
FROM llm_classifications GROUP BY user_id, room_id
|
||||||
|
`);
|
||||||
|
logger.info("Migration: backfilled sentiment_stats from llm_classifications");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function seedSchedulerDefaults(): void {
|
function seedSchedulerDefaults(): void {
|
||||||
@@ -512,6 +558,8 @@ function seedSchedulerDefaults(): void {
|
|||||||
const releasesHour = parseInt(process.env.SCHEDULE_RELEASES_HOUR ?? "19", 10);
|
const releasesHour = parseInt(process.env.SCHEDULE_RELEASES_HOUR ?? "19", 10);
|
||||||
const releasesMinute = parseInt(process.env.SCHEDULE_RELEASES_MINUTE ?? "0", 10);
|
const releasesMinute = parseInt(process.env.SCHEDULE_RELEASES_MINUTE ?? "0", 10);
|
||||||
|
|
||||||
|
insert.run("prefetch", 0, 5, 1);
|
||||||
|
insert.run("maintenance", 0, 15, 1);
|
||||||
insert.run("holidays", holidaysHour, holidaysMinute, 1);
|
insert.run("holidays", holidaysHour, holidaysMinute, 1);
|
||||||
insert.run("releases", releasesHour, releasesMinute, 1);
|
insert.run("releases", releasesHour, releasesMinute, 1);
|
||||||
insert.run("wotd", 8, 0, 1);
|
insert.run("wotd", 8, 0, 1);
|
||||||
|
|||||||
53
src/index.ts
53
src/index.ts
@@ -38,23 +38,8 @@ import { ToolsPlugin } from "./plugins/tools";
|
|||||||
import { ReactionsPlugin } from "./plugins/reactions";
|
import { ReactionsPlugin } from "./plugins/reactions";
|
||||||
import { BotInfoPlugin } from "./plugins/botinfo";
|
import { BotInfoPlugin } from "./plugins/botinfo";
|
||||||
import { RetroPlugin } from "./plugins/retro";
|
import { RetroPlugin } from "./plugins/retro";
|
||||||
|
import { HowAmIPlugin } from "./plugins/howami";
|
||||||
/**
|
import { VibePlugin } from "./plugins/vibe";
|
||||||
* Persist a new access token to the .env file so it survives restarts.
|
|
||||||
*/
|
|
||||||
function updateEnvToken(newToken: string): void {
|
|
||||||
const envPath = path.resolve(process.cwd(), ".env");
|
|
||||||
if (!fs.existsSync(envPath)) return;
|
|
||||||
|
|
||||||
let content = fs.readFileSync(envPath, "utf-8");
|
|
||||||
if (content.match(/^MATRIX_ACCESS_TOKEN=.*/m)) {
|
|
||||||
content = content.replace(/^MATRIX_ACCESS_TOKEN=.*/m, `MATRIX_ACCESS_TOKEN=${newToken}`);
|
|
||||||
} else {
|
|
||||||
content += `\nMATRIX_ACCESS_TOKEN=${newToken}\n`;
|
|
||||||
}
|
|
||||||
fs.writeFileSync(envPath, content, "utf-8");
|
|
||||||
logger.info("Updated .env with new access token");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure we have a valid access token. If the current token is invalid and a
|
* Ensure we have a valid access token. If the current token is invalid and a
|
||||||
@@ -70,12 +55,24 @@ async function resolveAccessToken(
|
|||||||
const deviceFile = path.join(dataDir, "device.json");
|
const deviceFile = path.join(dataDir, "device.json");
|
||||||
const hasDeviceFile = fs.existsSync(deviceFile);
|
const hasDeviceFile = fs.existsSync(deviceFile);
|
||||||
|
|
||||||
|
// Try to load the stored token from device.json first
|
||||||
|
let storedToken: string | undefined;
|
||||||
|
if (hasDeviceFile) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(fs.readFileSync(deviceFile, "utf-8"));
|
||||||
|
storedToken = data.accessToken;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer stored token (device.json) over env var
|
||||||
|
const effectiveToken = storedToken ?? currentToken;
|
||||||
|
|
||||||
// If we have a stored device identity AND a valid token, reuse them
|
// If we have a stored device identity AND a valid token, reuse them
|
||||||
if (hasDeviceFile && currentToken) {
|
if (hasDeviceFile && effectiveToken) {
|
||||||
const valid = await isTokenValid(homeserverUrl, currentToken);
|
const valid = await isTokenValid(homeserverUrl, effectiveToken);
|
||||||
if (valid) {
|
if (valid) {
|
||||||
logger.info("Access token is valid, device identity exists");
|
logger.info("Access token is valid, device identity exists");
|
||||||
return currentToken;
|
return effectiveToken;
|
||||||
}
|
}
|
||||||
logger.warn("Access token is invalid or expired");
|
logger.warn("Access token is invalid or expired");
|
||||||
}
|
}
|
||||||
@@ -83,7 +80,7 @@ async function resolveAccessToken(
|
|||||||
// No device.json means fresh crypto store — we MUST do a password login
|
// No device.json means fresh crypto store — we MUST do a password login
|
||||||
// to get a new token + device pair. Reusing an old token's device would
|
// to get a new token + device pair. Reusing an old token's device would
|
||||||
// cause OTK conflicts since the server has keys we don't have locally.
|
// cause OTK conflicts since the server has keys we don't have locally.
|
||||||
if (!hasDeviceFile && currentToken) {
|
if (!hasDeviceFile && effectiveToken) {
|
||||||
logger.info("No device.json found — fresh crypto store requires a new device via password login");
|
logger.info("No device.json found — fresh crypto store requires a new device via password login");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,11 +113,15 @@ async function resolveAccessToken(
|
|||||||
|
|
||||||
const loginResult = await loginWithPassword(homeserverUrl, botUserId, password, existingDeviceId);
|
const loginResult = await loginWithPassword(homeserverUrl, botUserId, password, existingDeviceId);
|
||||||
|
|
||||||
// Save the new device ID
|
// Save the new device ID and access token together
|
||||||
fs.writeFileSync(deviceFile, JSON.stringify({ deviceId: loginResult.device_id }), "utf-8");
|
fs.writeFileSync(
|
||||||
|
deviceFile,
|
||||||
|
JSON.stringify({ deviceId: loginResult.device_id, accessToken: loginResult.access_token }),
|
||||||
|
"utf-8"
|
||||||
|
);
|
||||||
|
fs.chmodSync(deviceFile, 0o600);
|
||||||
logger.info(`Device identity saved: ${loginResult.device_id}`);
|
logger.info(`Device identity saved: ${loginResult.device_id}`);
|
||||||
|
|
||||||
updateEnvToken(loginResult.access_token);
|
|
||||||
return loginResult.access_token;
|
return loginResult.access_token;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,6 +238,8 @@ async function main(): Promise<void> {
|
|||||||
const reactionsPlugin = new ReactionsPlugin(botClient);
|
const reactionsPlugin = new ReactionsPlugin(botClient);
|
||||||
const botInfoPlugin = new BotInfoPlugin(botClient);
|
const botInfoPlugin = new BotInfoPlugin(botClient);
|
||||||
const retroPlugin = new RetroPlugin(botClient);
|
const retroPlugin = new RetroPlugin(botClient);
|
||||||
|
const howAmIPlugin = new HowAmIPlugin(botClient);
|
||||||
|
const vibePlugin = new VibePlugin(botClient);
|
||||||
|
|
||||||
const dailyScheduler = new DailyScheduler(
|
const dailyScheduler = new DailyScheduler(
|
||||||
botClient,
|
botClient,
|
||||||
@@ -282,6 +285,8 @@ async function main(): Promise<void> {
|
|||||||
registry.register(reactionsPlugin);
|
registry.register(reactionsPlugin);
|
||||||
registry.register(botInfoPlugin);
|
registry.register(botInfoPlugin);
|
||||||
registry.register(retroPlugin);
|
registry.register(retroPlugin);
|
||||||
|
registry.register(howAmIPlugin);
|
||||||
|
registry.register(vibePlugin);
|
||||||
registry.register(achievementsPlugin);
|
registry.register(achievementsPlugin);
|
||||||
registry.register(dailyScheduler);
|
registry.register(dailyScheduler);
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,14 @@ export class BotClient {
|
|||||||
const deviceId = this.client.getDeviceId();
|
const deviceId = this.client.getDeviceId();
|
||||||
if (deviceId) {
|
if (deviceId) {
|
||||||
const deviceFile = path.join(this.opts.dataDir, "device.json");
|
const deviceFile = path.join(this.opts.dataDir, "device.json");
|
||||||
fs.writeFileSync(deviceFile, JSON.stringify({ deviceId }), "utf-8");
|
// Preserve existing fields (e.g. accessToken) when updating deviceId
|
||||||
|
let existing: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(deviceFile)) {
|
||||||
|
existing = JSON.parse(fs.readFileSync(deviceFile, "utf-8"));
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
fs.writeFileSync(deviceFile, JSON.stringify({ ...existing, deviceId }), "utf-8");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +108,23 @@ export class BotClient {
|
|||||||
|
|
||||||
logger.info(`E2EE initialized with device ${this.client.getDeviceId()}`);
|
logger.info(`E2EE initialized with device ${this.client.getDeviceId()}`);
|
||||||
|
|
||||||
|
// Bootstrap cross-signing so other clients see this device as verified by its owner
|
||||||
|
try {
|
||||||
|
const crypto = this.client.getCrypto();
|
||||||
|
if (crypto) {
|
||||||
|
await crypto.bootstrapCrossSigning({
|
||||||
|
setupNewCrossSigning: true,
|
||||||
|
authUploadDeviceSigningKeys: async (makeRequest) => {
|
||||||
|
// Authenticate the upload with UIA (empty auth for bot accounts)
|
||||||
|
await makeRequest({ type: "m.login.password", user: this.opts.userId, password: process.env.MATRIX_BOT_PASSWORD });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logger.info("Cross-signing bootstrapped — device is self-verified");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Cross-signing bootstrap failed (non-fatal): ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Wire up event listeners
|
// Wire up event listeners
|
||||||
this.client.on(RoomEvent.Timeline, (event: MatrixEvent, room: Room | undefined) => {
|
this.client.on(RoomEvent.Timeline, (event: MatrixEvent, room: Room | undefined) => {
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
|
|||||||
@@ -103,6 +103,37 @@ export abstract class Plugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a reaction with automatic retry on transient failures.
|
||||||
|
*/
|
||||||
|
protected sendReact(roomId: string, eventId: string, emoji: string): void {
|
||||||
|
this.sendWithRetry(
|
||||||
|
() => this.client.sendEvent(roomId, "m.reaction", {
|
||||||
|
"m.relates_to": { rel_type: "m.annotation", event_id: eventId, key: emoji },
|
||||||
|
}),
|
||||||
|
`react ${emoji} in ${roomId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire-and-forget with retry on connection errors. Retries up to 2 times
|
||||||
|
* with 2s/4s delays. Non-connection errors are not retried.
|
||||||
|
*/
|
||||||
|
protected sendWithRetry(fn: () => Promise<any>, label: string, maxRetries = 2): void {
|
||||||
|
const attempt = (retryCount: number) => {
|
||||||
|
fn().catch((err) => {
|
||||||
|
const isTransient = String(err).includes("ConnectionError") || String(err).includes("fetch failed");
|
||||||
|
if (isTransient && retryCount < maxRetries) {
|
||||||
|
const delay = 2000 * (retryCount + 1);
|
||||||
|
setTimeout(() => attempt(retryCount + 1), delay);
|
||||||
|
} else {
|
||||||
|
logger.error(`Failed to ${label}: ${err}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
attempt(0);
|
||||||
|
}
|
||||||
|
|
||||||
protected isCommand(body: string, command: string): boolean {
|
protected isCommand(body: string, command: string): boolean {
|
||||||
return body.startsWith(this.prefix + command);
|
return body.startsWith(this.prefix + command);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,9 +105,20 @@ export class DailyScheduler extends Plugin {
|
|||||||
// If the scheduled time has already passed today, run it now
|
// If the scheduled time has already passed today, run it now
|
||||||
if (scheduledMinutes <= currentMinutes) {
|
if (scheduledMinutes <= currentMinutes) {
|
||||||
const key = `${job.job_name}:${today}`;
|
const key = `${job.job_name}:${today}`;
|
||||||
this.firedToday.add(key);
|
|
||||||
logger.info(`Catching up missed job: ${job.job_name}`);
|
logger.info(`Catching up missed job: ${job.job_name}`);
|
||||||
await this.runJob(job.job_name);
|
try {
|
||||||
|
await this.runJob(job.job_name);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Catch-up for ${job.job_name} failed: ${err}`);
|
||||||
|
}
|
||||||
|
// Only mark as fired if the job actually produced output.
|
||||||
|
// Jobs that use DB logs (holidays, wotd) check for existing entries,
|
||||||
|
// so leaving firedToday unset lets the tick retry on the next minute.
|
||||||
|
if (this.didJobComplete(job.job_name, today)) {
|
||||||
|
this.firedToday.add(key);
|
||||||
|
} else {
|
||||||
|
logger.warn(`Job ${job.job_name} did not produce output — will retry on next tick`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,10 +146,16 @@ export class DailyScheduler extends Plugin {
|
|||||||
for (const job of jobs) {
|
for (const job of jobs) {
|
||||||
const key = `${job.job_name}:${today}`;
|
const key = `${job.job_name}:${today}`;
|
||||||
if (this.firedToday.has(key)) continue;
|
if (this.firedToday.has(key)) continue;
|
||||||
if (job.hour !== currentHour || job.minute !== currentMinute) continue;
|
// Fire at the scheduled minute, or any minute after if it was missed (e.g. connection blip)
|
||||||
|
const scheduledMinutes = job.hour * 60 + job.minute;
|
||||||
|
const currentMinutes = currentHour * 60 + currentMinute;
|
||||||
|
if (currentMinutes < scheduledMinutes) continue;
|
||||||
|
|
||||||
this.firedToday.add(key);
|
this.runJob(job.job_name).then(() => {
|
||||||
this.runJob(job.job_name).catch((err) => {
|
if (this.didJobComplete(job.job_name, today)) {
|
||||||
|
this.firedToday.add(key);
|
||||||
|
}
|
||||||
|
}).catch((err) => {
|
||||||
logger.error(`Scheduled job ${job.job_name} failed: ${err}`);
|
logger.error(`Scheduled job ${job.job_name} failed: ${err}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -155,6 +172,12 @@ export class DailyScheduler extends Plugin {
|
|||||||
logger.info(`Running scheduled job: ${jobName}`);
|
logger.info(`Running scheduled job: ${jobName}`);
|
||||||
|
|
||||||
switch (jobName) {
|
switch (jobName) {
|
||||||
|
case "prefetch":
|
||||||
|
await this.runPrefetch();
|
||||||
|
break;
|
||||||
|
case "maintenance":
|
||||||
|
this.runMaintenance();
|
||||||
|
break;
|
||||||
case "wotd":
|
case "wotd":
|
||||||
await this.wotdPlugin.postWotd(this.botRooms);
|
await this.wotdPlugin.postWotd(this.botRooms);
|
||||||
break;
|
break;
|
||||||
@@ -181,6 +204,112 @@ export class DailyScheduler extends Plugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefetch API data for holidays and WOTD so the post step is network-free.
|
||||||
|
* Runs early (default 00:05 UTC). Each prefetch is individually error-handled
|
||||||
|
* so one failure doesn't block the others.
|
||||||
|
*/
|
||||||
|
private async runPrefetch(): Promise<void> {
|
||||||
|
const results: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.holidaysPlugin.prefetch();
|
||||||
|
results.push("holidays");
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Holiday prefetch failed: ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.wotdPlugin.prefetch();
|
||||||
|
results.push("wotd");
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`WOTD prefetch failed: ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Prefetch complete: ${results.join(", ") || "none succeeded"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nightly maintenance: prune old data to prevent DB bloat.
|
||||||
|
* Runs after prefetch (default 00:15 UTC).
|
||||||
|
*/
|
||||||
|
private runMaintenance(): void {
|
||||||
|
const db = getDb();
|
||||||
|
let totalDeleted = 0;
|
||||||
|
|
||||||
|
const prune = (label: string, sql: string, ...params: any[]) => {
|
||||||
|
try {
|
||||||
|
const result = db.prepare(sql).run(...params);
|
||||||
|
if (result.changes > 0) {
|
||||||
|
logger.info(`Maintenance: pruned ${result.changes} rows from ${label}`);
|
||||||
|
totalDeleted += result.changes;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Maintenance: failed to prune ${label}: ${err}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// llm_classifications — aggregates already in sentiment_stats, potty_mouth, insult_log
|
||||||
|
prune("llm_classifications", `DELETE FROM llm_classifications WHERE classified_at < unixepoch() - ?`, 30 * 86400);
|
||||||
|
|
||||||
|
// xp_log — audit trail, not queried by any command
|
||||||
|
prune("xp_log", `DELETE FROM xp_log WHERE granted_at < datetime('now', '-30 days')`);
|
||||||
|
|
||||||
|
// command_usage — rate limit tracking, old entries useless
|
||||||
|
prune("command_usage", `DELETE FROM command_usage WHERE date < date('now', '-7 days')`);
|
||||||
|
|
||||||
|
// rep_cooldowns — only 24h cooldown, stale entries accumulate
|
||||||
|
prune("rep_cooldowns", `DELETE FROM rep_cooldowns WHERE granted_at < datetime('now', '-2 days')`);
|
||||||
|
|
||||||
|
// daily_prefetch — only today's matters
|
||||||
|
prune("daily_prefetch", `DELETE FROM daily_prefetch WHERE date < date('now', '-3 days')`);
|
||||||
|
|
||||||
|
// Cache tables — stale entries never deleted by app code
|
||||||
|
prune("url_cache", `DELETE FROM url_cache WHERE cached_at < unixepoch() - ?`, 7 * 86400);
|
||||||
|
prune("retro_cache", `DELETE FROM retro_cache WHERE cached_at < unixepoch() - ?`, 14 * 86400);
|
||||||
|
prune("urban_cache", `DELETE FROM urban_cache WHERE cached_at < unixepoch() - ?`, 7 * 86400);
|
||||||
|
prune("stocks_cache", `DELETE FROM stocks_cache WHERE cached_at < unixepoch() - ?`, 1 * 86400);
|
||||||
|
prune("hltb_cache", `DELETE FROM hltb_cache WHERE fetched_at < datetime('now', '-30 days')`);
|
||||||
|
prune("releases_cache", `DELETE FROM releases_cache WHERE fetched_at < datetime('now', '-30 days')`);
|
||||||
|
prune("anime_cache", `DELETE FROM anime_cache WHERE cached_at < unixepoch() - ?`, 30 * 86400);
|
||||||
|
prune("movie_cache", `DELETE FROM movie_cache WHERE cached_at < unixepoch() - ?`, 7 * 86400);
|
||||||
|
prune("concerts_cache", `DELETE FROM concerts_cache WHERE cached_at < unixepoch() - ?`, 14 * 86400);
|
||||||
|
|
||||||
|
// shade_log — if shade feature is disabled, this is dead weight
|
||||||
|
prune("shade_log", `DELETE FROM shade_log WHERE classified_at < datetime('now', '-30 days')`);
|
||||||
|
|
||||||
|
logger.info(`Maintenance complete: ${totalDeleted} total rows pruned`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a job actually produced output by looking at its DB log.
|
||||||
|
* Jobs without a log table are assumed to have completed.
|
||||||
|
*/
|
||||||
|
private didJobComplete(jobName: string, today: string): boolean {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
switch (jobName) {
|
||||||
|
case "prefetch": {
|
||||||
|
// Prefetch is done if at least one of the caches was populated
|
||||||
|
const row = db.prepare(`SELECT job_name FROM daily_prefetch WHERE date = ? LIMIT 1`).get(today);
|
||||||
|
return !!row;
|
||||||
|
}
|
||||||
|
case "holidays": {
|
||||||
|
const row = db.prepare(`SELECT id FROM holidays_log WHERE date = ? LIMIT 1`).get(today);
|
||||||
|
return !!row;
|
||||||
|
}
|
||||||
|
case "wotd": {
|
||||||
|
const row = db.prepare(`SELECT id FROM wotd_log WHERE date = ? LIMIT 1`).get(today);
|
||||||
|
return !!row;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async handleSchedule(ctx: MessageContext): Promise<void> {
|
private async handleSchedule(ctx: MessageContext): Promise<void> {
|
||||||
if (!this.isAdmin(ctx.sender)) {
|
if (!this.isAdmin(ctx.sender)) {
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "This command is admin-only.");
|
await this.sendReply(ctx.roomId, ctx.eventId, "This command is admin-only.");
|
||||||
@@ -191,7 +320,7 @@ export class DailyScheduler extends Plugin {
|
|||||||
const match = args.match(/^(\w+)\s+(\d{1,2}):(\d{2})$/);
|
const match = args.match(/^(\w+)\s+(\d{1,2}):(\d{2})$/);
|
||||||
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !schedule <job> <HH:MM> (UTC)\nJobs: wotd, holidays, releases, birthday_check, anime_releases, movie_releases, concert_digest");
|
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !schedule <job> <HH:MM> (UTC)\nJobs: prefetch, maintenance, wotd, holidays, releases, birthday_check, anime_releases, movie_releases, concert_digest");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,7 +339,7 @@ export class DailyScheduler extends Plugin {
|
|||||||
.run(hour, minute, jobName);
|
.run(hour, minute, jobName);
|
||||||
|
|
||||||
if (result.changes === 0) {
|
if (result.changes === 0) {
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `Unknown job "${jobName}". Valid jobs: wotd, holidays, releases, birthday_check, anime_releases, movie_releases, concert_digest`);
|
await this.sendReply(ctx.roomId, ctx.eventId, `Unknown job "${jobName}". Valid jobs: prefetch, maintenance, wotd, holidays, releases, birthday_check, anime_releases, movie_releases, concert_digest`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -436,19 +436,9 @@ export class FunPlugin extends Plugin {
|
|||||||
|
|
||||||
const pollEventId = await this.sendMessage(ctx.roomId, lines.join("\n"));
|
const pollEventId = await this.sendMessage(ctx.roomId, lines.join("\n"));
|
||||||
|
|
||||||
// Auto-react with number emojis
|
// Auto-react with number emojis (fire-and-forget with retry)
|
||||||
for (let i = 0; i < options.length; i++) {
|
for (let i = 0; i < options.length; i++) {
|
||||||
try {
|
this.sendReact(ctx.roomId, pollEventId, numberEmojis[i]);
|
||||||
await this.client.sendEvent(ctx.roomId, "m.reaction", {
|
|
||||||
"m.relates_to": {
|
|
||||||
rel_type: "m.annotation",
|
|
||||||
event_id: pollEventId,
|
|
||||||
key: numberEmojis[i],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Failed to add poll reaction: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -248,8 +248,31 @@ export class HolidaysPlugin extends Plugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefetch holiday data from APIs and cache it in the DB.
|
||||||
|
* Called early (e.g. 00:05 UTC) so the post step has no network dependency.
|
||||||
|
*/
|
||||||
|
async prefetch(): Promise<void> {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
// Already prefetched?
|
||||||
|
const existing = db.prepare(`SELECT data FROM daily_prefetch WHERE job_name = 'holidays' AND date = ?`).get(today);
|
||||||
|
if (existing) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { holidays, hebrewDate, hijriDate } = await this.fetchAllForDate(today, true);
|
||||||
|
db.prepare(`INSERT OR REPLACE INTO daily_prefetch (job_name, date, data) VALUES ('holidays', ?, ?)`)
|
||||||
|
.run(today, JSON.stringify({ holidays, hebrewDate, hijriDate }));
|
||||||
|
logger.info(`Prefetched ${holidays.length} holidays for ${today}`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Holiday prefetch failed: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called by DailyScheduler to post the morning holiday summary.
|
* Called by DailyScheduler to post the morning holiday summary.
|
||||||
|
* Reads from prefetch cache first, falls back to live API call.
|
||||||
*/
|
*/
|
||||||
async postHolidays(roomIds: string[]): Promise<void> {
|
async postHolidays(roomIds: string[]): Promise<void> {
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
@@ -259,7 +282,24 @@ export class HolidaysPlugin extends Plugin {
|
|||||||
const existing = db.prepare(`SELECT id FROM holidays_log WHERE date = ? LIMIT 1`).get(today);
|
const existing = db.prepare(`SELECT id FROM holidays_log WHERE date = ? LIMIT 1`).get(today);
|
||||||
if (existing) return;
|
if (existing) return;
|
||||||
|
|
||||||
const { holidays, hebrewDate, hijriDate } = await this.fetchAllForDate(today, true);
|
// Try prefetch cache first
|
||||||
|
let holidays: Holiday[];
|
||||||
|
let hebrewDate: string | null = null;
|
||||||
|
let hijriDate: string | null = null;
|
||||||
|
|
||||||
|
const cached = db.prepare(`SELECT data FROM daily_prefetch WHERE job_name = 'holidays' AND date = ?`).get(today) as { data: string } | undefined;
|
||||||
|
if (cached) {
|
||||||
|
const parsed = JSON.parse(cached.data);
|
||||||
|
holidays = parsed.holidays;
|
||||||
|
hebrewDate = parsed.hebrewDate;
|
||||||
|
hijriDate = parsed.hijriDate;
|
||||||
|
} else {
|
||||||
|
// Fallback to live fetch
|
||||||
|
const result = await this.fetchAllForDate(today, true);
|
||||||
|
holidays = result.holidays;
|
||||||
|
hebrewDate = result.hebrewDate;
|
||||||
|
hijriDate = result.hijriDate;
|
||||||
|
}
|
||||||
|
|
||||||
if (holidays.length === 0) {
|
if (holidays.length === 0) {
|
||||||
logger.info("No holidays found for today, skipping post.");
|
logger.info("No holidays found for today, skipping post.");
|
||||||
|
|||||||
338
src/plugins/howami.ts
Normal file
338
src/plugins/howami.ts
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
||||||
|
import { getDb } from "../db";
|
||||||
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
|
const OLLAMA_HOST = (() => {
|
||||||
|
const raw = process.env.OLLAMA_HOST ?? "";
|
||||||
|
return raw && !raw.startsWith("http") ? `http://${raw}` : raw;
|
||||||
|
})();
|
||||||
|
const OLLAMA_MODEL = process.env.OLLAMA_MODEL ?? "";
|
||||||
|
const LLM_ENABLED = OLLAMA_HOST !== "" && OLLAMA_MODEL !== "";
|
||||||
|
const OLLAMA_TIMEOUT_MS = 45_000;
|
||||||
|
const OLLAMA_NUM_CTX = 16384;
|
||||||
|
|
||||||
|
interface UserProfile {
|
||||||
|
// user_stats
|
||||||
|
totalMessages: number;
|
||||||
|
totalWords: number;
|
||||||
|
totalQuestions: number;
|
||||||
|
totalExclamations: number;
|
||||||
|
totalEmojis: number;
|
||||||
|
totalLinks: number;
|
||||||
|
totalImages: number;
|
||||||
|
avgWordsPerMessage: number;
|
||||||
|
longestMessage: number;
|
||||||
|
currentStreak: number;
|
||||||
|
longestStreak: number;
|
||||||
|
hourlyDistribution: Record<string, number>;
|
||||||
|
dailyDistribution: Record<string, number>;
|
||||||
|
// users
|
||||||
|
xp: number;
|
||||||
|
level: number;
|
||||||
|
rep: number;
|
||||||
|
// potty_mouth
|
||||||
|
profanityTotal: number;
|
||||||
|
profanitySeverity: { mild: number; moderate: number; scorched: number };
|
||||||
|
// insult_log
|
||||||
|
insultsThrown: number;
|
||||||
|
insultsDirect: number;
|
||||||
|
insultsIndirect: number;
|
||||||
|
timesTargeted: number;
|
||||||
|
// trivia
|
||||||
|
triviaCorrect: number;
|
||||||
|
triviaPoints: number;
|
||||||
|
triviaStreak: number;
|
||||||
|
triviaFastestMs: number | null;
|
||||||
|
// achievements
|
||||||
|
achievements: string[];
|
||||||
|
totalAchievements: number;
|
||||||
|
// sentiment breakdown
|
||||||
|
sentiments: Record<string, number>;
|
||||||
|
// wotd
|
||||||
|
wotdCorrect: number;
|
||||||
|
wotdAttempts: number;
|
||||||
|
// activity
|
||||||
|
peakHour: number | null;
|
||||||
|
peakDay: string | null;
|
||||||
|
firstSeenDate: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||||
|
|
||||||
|
export class HowAmIPlugin extends Plugin {
|
||||||
|
constructor(client: IMatrixClient) {
|
||||||
|
super(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
get name() {
|
||||||
|
return "howami";
|
||||||
|
}
|
||||||
|
|
||||||
|
get commands(): CommandDef[] {
|
||||||
|
return [
|
||||||
|
{ name: "howami", description: "Get roasted based on your actual stats", usage: "!howami [@user]" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async onMessage(ctx: MessageContext): Promise<void> {
|
||||||
|
if (!this.isCommand(ctx.body, "howami")) return;
|
||||||
|
await this.handleHowAmI(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleHowAmI(ctx: MessageContext): Promise<void> {
|
||||||
|
if (!LLM_ENABLED) {
|
||||||
|
await this.sendReply(ctx.roomId, ctx.eventId, "LLM features are not configured.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = this.getArgs(ctx.body, "howami");
|
||||||
|
const targetUser = args.startsWith("@") ? args.split(/\s/)[0] : ctx.sender;
|
||||||
|
const isSelf = targetUser === ctx.sender;
|
||||||
|
|
||||||
|
const profile = this.gatherProfile(targetUser, ctx.roomId);
|
||||||
|
if (!profile || profile.totalMessages === 0) {
|
||||||
|
await this.sendReply(ctx.roomId, ctx.eventId, `No data found for ${targetUser}. They need to chat more.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const roast = await this.generateRoast(targetUser, profile, isSelf);
|
||||||
|
if (roast) {
|
||||||
|
await this.sendMessage(ctx.roomId, roast);
|
||||||
|
} else {
|
||||||
|
await this.sendReply(ctx.roomId, ctx.eventId, "The roast machine broke. Try again later.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`howami roast generation failed: ${err}`);
|
||||||
|
await this.sendReply(ctx.roomId, ctx.eventId, "The roast machine broke. Try again later.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private gatherProfile(userId: string, roomId: string): UserProfile | null {
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
// user_stats
|
||||||
|
const stats = db
|
||||||
|
.prepare(`SELECT * FROM user_stats WHERE user_id = ? AND room_id = ?`)
|
||||||
|
.get(userId, roomId) as any;
|
||||||
|
if (!stats) return null;
|
||||||
|
|
||||||
|
// users (xp/level/rep)
|
||||||
|
const user = db
|
||||||
|
.prepare(`SELECT xp, level, rep FROM users WHERE user_id = ? AND room_id = ?`)
|
||||||
|
.get(userId, roomId) as { xp: number; level: number; rep: number } | undefined;
|
||||||
|
|
||||||
|
// potty_mouth
|
||||||
|
const potty = db
|
||||||
|
.prepare(`SELECT total, severity_1, severity_2, severity_3 FROM potty_mouth WHERE user_id = ? AND room_id = ?`)
|
||||||
|
.get(userId, roomId) as { total: number; severity_1: number; severity_2: number; severity_3: number } | undefined;
|
||||||
|
|
||||||
|
// insult_log
|
||||||
|
const insults = db
|
||||||
|
.prepare(`SELECT total_thrown, direct_thrown, indirect_thrown, times_targeted FROM insult_log WHERE user_id = ? AND room_id = ?`)
|
||||||
|
.get(userId, roomId) as { total_thrown: number; direct_thrown: number; indirect_thrown: number; times_targeted: number } | undefined;
|
||||||
|
|
||||||
|
// trivia
|
||||||
|
const trivia = db
|
||||||
|
.prepare(`SELECT total_correct, total_points, best_streak, fastest_ms FROM trivia_scores WHERE user_id = ? AND room_id = ?`)
|
||||||
|
.get(userId, roomId) as { total_correct: number; total_points: number; best_streak: number; fastest_ms: number | null } | undefined;
|
||||||
|
|
||||||
|
// achievements
|
||||||
|
const achievementRows = db
|
||||||
|
.prepare(`SELECT achievement_key FROM achievements WHERE user_id = ? AND room_id = ?`)
|
||||||
|
.all(userId, roomId) as { achievement_key: string }[];
|
||||||
|
|
||||||
|
// sentiment breakdown from aggregate table
|
||||||
|
const sentimentRow = db
|
||||||
|
.prepare(`SELECT * FROM sentiment_stats WHERE user_id = ? AND room_id = ?`)
|
||||||
|
.get(userId, roomId) as { neutral: number; happy: number; sad: number; angry: number; excited: number; funny: number; love: number; scared: number; total_classified: number } | undefined;
|
||||||
|
const sentiments: Record<string, number> = {};
|
||||||
|
if (sentimentRow) {
|
||||||
|
for (const s of ["neutral", "happy", "sad", "angry", "excited", "funny", "love", "scared"] as const) {
|
||||||
|
if (sentimentRow[s] > 0) sentiments[s] = sentimentRow[s];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wotd
|
||||||
|
const wotdRow = db
|
||||||
|
.prepare(`SELECT COUNT(*) as attempts, COALESCE(SUM(CASE WHEN xp_awarded > 0 THEN 1 ELSE 0 END), 0) as correct FROM wotd_usage WHERE user_id = ? AND room_id = ?`)
|
||||||
|
.get(userId, roomId) as { attempts: number; correct: number };
|
||||||
|
|
||||||
|
// first seen
|
||||||
|
const firstSeen = db
|
||||||
|
.prepare(`SELECT MIN(date) as first_date FROM daily_activity WHERE user_id = ? AND room_id = ?`)
|
||||||
|
.get(userId, roomId) as { first_date: string | null };
|
||||||
|
|
||||||
|
// hourly/daily distributions
|
||||||
|
const hourly: Record<string, number> = JSON.parse(stats.hourly_distribution || "{}");
|
||||||
|
const daily: Record<string, number> = JSON.parse(stats.daily_distribution || "{}");
|
||||||
|
|
||||||
|
// peak hour
|
||||||
|
let peakHour: number | null = null;
|
||||||
|
let peakHourCount = 0;
|
||||||
|
for (const [h, count] of Object.entries(hourly)) {
|
||||||
|
if (count > peakHourCount) {
|
||||||
|
peakHour = parseInt(h);
|
||||||
|
peakHourCount = count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// peak day
|
||||||
|
let peakDay: string | null = null;
|
||||||
|
let peakDayCount = 0;
|
||||||
|
for (const [d, count] of Object.entries(daily)) {
|
||||||
|
if (count > peakDayCount) {
|
||||||
|
peakDay = DAY_NAMES[parseInt(d)] ?? null;
|
||||||
|
peakDayCount = count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalMessages: stats.total_messages,
|
||||||
|
totalWords: stats.total_words,
|
||||||
|
totalQuestions: stats.total_questions,
|
||||||
|
totalExclamations: stats.total_exclamations,
|
||||||
|
totalEmojis: stats.total_emojis,
|
||||||
|
totalLinks: stats.total_links,
|
||||||
|
totalImages: stats.total_images,
|
||||||
|
avgWordsPerMessage: stats.avg_words_per_message,
|
||||||
|
longestMessage: stats.longest_message,
|
||||||
|
currentStreak: stats.current_streak,
|
||||||
|
longestStreak: stats.longest_streak,
|
||||||
|
hourlyDistribution: hourly,
|
||||||
|
dailyDistribution: daily,
|
||||||
|
xp: user?.xp ?? 0,
|
||||||
|
level: user?.level ?? 1,
|
||||||
|
rep: user?.rep ?? 0,
|
||||||
|
profanityTotal: potty?.total ?? 0,
|
||||||
|
profanitySeverity: {
|
||||||
|
mild: potty?.severity_1 ?? 0,
|
||||||
|
moderate: potty?.severity_2 ?? 0,
|
||||||
|
scorched: potty?.severity_3 ?? 0,
|
||||||
|
},
|
||||||
|
insultsThrown: insults?.total_thrown ?? 0,
|
||||||
|
insultsDirect: insults?.direct_thrown ?? 0,
|
||||||
|
insultsIndirect: insults?.indirect_thrown ?? 0,
|
||||||
|
timesTargeted: insults?.times_targeted ?? 0,
|
||||||
|
triviaCorrect: trivia?.total_correct ?? 0,
|
||||||
|
triviaPoints: trivia?.total_points ?? 0,
|
||||||
|
triviaStreak: trivia?.best_streak ?? 0,
|
||||||
|
triviaFastestMs: trivia?.fastest_ms ?? null,
|
||||||
|
achievements: achievementRows.map((a) => a.achievement_key),
|
||||||
|
totalAchievements: achievementRows.length,
|
||||||
|
sentiments,
|
||||||
|
wotdCorrect: wotdRow?.correct ?? 0,
|
||||||
|
wotdAttempts: wotdRow?.attempts ?? 0,
|
||||||
|
peakHour,
|
||||||
|
peakDay,
|
||||||
|
firstSeenDate: firstSeen?.first_date ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildStatsSummary(userId: string, p: UserProfile): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
lines.push(`User: ${userId}`);
|
||||||
|
if (p.firstSeenDate) lines.push(`First seen: ${p.firstSeenDate}`);
|
||||||
|
lines.push(`Level ${p.level} | ${p.xp} XP | ${p.rep} reputation`);
|
||||||
|
lines.push(`Messages: ${p.totalMessages} | Words: ${p.totalWords} | Avg words/msg: ${p.avgWordsPerMessage.toFixed(1)}`);
|
||||||
|
|
||||||
|
const questionPct = p.totalMessages > 0 ? ((p.totalQuestions / p.totalMessages) * 100).toFixed(1) : "0";
|
||||||
|
const exclamationPct = p.totalMessages > 0 ? ((p.totalExclamations / p.totalMessages) * 100).toFixed(1) : "0";
|
||||||
|
lines.push(`Questions: ${p.totalQuestions} (${questionPct}% of messages) | Exclamations: ${p.totalExclamations} (${exclamationPct}%)`);
|
||||||
|
|
||||||
|
lines.push(`Emojis: ${p.totalEmojis} | Links: ${p.totalLinks} | Images: ${p.totalImages}`);
|
||||||
|
lines.push(`Longest message: ${p.longestMessage} chars`);
|
||||||
|
lines.push(`Activity streak: ${p.currentStreak}d current, ${p.longestStreak}d record`);
|
||||||
|
|
||||||
|
if (p.peakHour !== null) lines.push(`Most active hour: ${p.peakHour}:00 UTC`);
|
||||||
|
if (p.peakDay) lines.push(`Most active day: ${p.peakDay}`);
|
||||||
|
|
||||||
|
if (p.profanityTotal > 0) {
|
||||||
|
lines.push(`Profanity: ${p.profanityTotal} total (mild: ${p.profanitySeverity.mild}, moderate: ${p.profanitySeverity.moderate}, scorched: ${p.profanitySeverity.scorched})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.insultsThrown > 0 || p.timesTargeted > 0) {
|
||||||
|
lines.push(`Insults thrown: ${p.insultsThrown} (direct: ${p.insultsDirect}, indirect: ${p.insultsIndirect}) | Times targeted: ${p.timesTargeted}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.triviaCorrect > 0) {
|
||||||
|
const fastestStr = p.triviaFastestMs != null ? `${(p.triviaFastestMs / 1000).toFixed(2)}s` : "N/A";
|
||||||
|
lines.push(`Trivia: ${p.triviaCorrect} correct, ${p.triviaPoints} pts, best streak: ${p.triviaStreak}, fastest: ${fastestStr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.wotdAttempts > 0) {
|
||||||
|
lines.push(`WOTD: ${p.wotdCorrect}/${p.wotdAttempts} correct attempts`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sentiment breakdown
|
||||||
|
const totalClassified = Object.values(p.sentiments).reduce((a, b) => a + b, 0);
|
||||||
|
if (totalClassified > 0) {
|
||||||
|
const topSentiments = Object.entries(p.sentiments)
|
||||||
|
.filter(([s]) => s !== "neutral")
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 3)
|
||||||
|
.map(([s, c]) => `${s}: ${c}`);
|
||||||
|
if (topSentiments.length > 0) {
|
||||||
|
lines.push(`Top sentiments: ${topSentiments.join(", ")} (out of ${totalClassified} classified)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.achievements.length > 0) {
|
||||||
|
lines.push(`Achievements (${p.totalAchievements}): ${p.achievements.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateRoast(userId: string, profile: UserProfile, isSelf: boolean): Promise<string | null> {
|
||||||
|
const statsSummary = this.buildStatsSummary(userId, profile);
|
||||||
|
const target = isSelf ? "the user themselves (they asked for it)" : `${userId} (someone else asked about them)`;
|
||||||
|
|
||||||
|
const prompt = `You are Freebee, a snarky but affectionate chat bot in a private friend group. Someone just used !howami, and you need to roast ${target} based on their ACTUAL stats below.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Write a single paragraph roast, 2-4 sentences max
|
||||||
|
- Be witty and specific — reference their actual numbers and patterns
|
||||||
|
- Tone: playful roast between friends, not mean-spirited. Think comedy roast, not bullying
|
||||||
|
- Find the funny angle in their data (night owl? question machine? potty mouth? lurker? emoji addict?)
|
||||||
|
- If they have notable achievements, work those in
|
||||||
|
- If their stats reveal an obvious personality type, call it out
|
||||||
|
- Do NOT use bullet points, headers, or formatting. Just flowing text
|
||||||
|
- Do NOT start with "Well," or "Oh," or greetings. Jump straight into the roast
|
||||||
|
- Keep it under 500 characters
|
||||||
|
|
||||||
|
Stats:
|
||||||
|
${statsSummary}
|
||||||
|
|
||||||
|
Write the roast now. Raw text only, no quotes or markdown.`;
|
||||||
|
|
||||||
|
const res = await fetch(`${OLLAMA_HOST}/api/generate`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: OLLAMA_MODEL,
|
||||||
|
prompt,
|
||||||
|
stream: false,
|
||||||
|
options: { num_ctx: OLLAMA_NUM_CTX, temperature: 0.9 },
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(OLLAMA_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
logger.warn(`Ollama API returned ${res.status} for howami`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as { response: string };
|
||||||
|
let roast = data.response?.trim();
|
||||||
|
if (!roast) return null;
|
||||||
|
|
||||||
|
// Clean up common LLM quirks
|
||||||
|
roast = roast.replace(/^["']|["']$/g, ""); // strip wrapping quotes
|
||||||
|
roast = roast.replace(/^#+\s*/gm, ""); // strip markdown headers
|
||||||
|
roast = roast.replace(/\*\*/g, ""); // strip bold markers
|
||||||
|
|
||||||
|
return roast;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -428,6 +428,19 @@ wotd_used is TRUE only when the word appears as a STANDALONE word in the message
|
|||||||
r.wotd_used ? 1 : 0, r.wotd_correct ? 1 : 0
|
r.wotd_used ? 1 : 0, r.wotd_correct ? 1 : 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Sentiment aggregate
|
||||||
|
const sentCol = r.sentiment as string;
|
||||||
|
const validSentiments = ["neutral", "happy", "sad", "angry", "excited", "funny", "love", "scared"];
|
||||||
|
if (validSentiments.includes(sentCol)) {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO sentiment_stats (user_id, room_id, ${sentCol}, total_classified)
|
||||||
|
VALUES (?, ?, 1, 1)
|
||||||
|
ON CONFLICT(user_id, room_id) DO UPDATE SET
|
||||||
|
${sentCol} = ${sentCol} + 1,
|
||||||
|
total_classified = total_classified + 1
|
||||||
|
`).run(ctx.sender, ctx.roomId);
|
||||||
|
}
|
||||||
|
|
||||||
// Profanity tracking
|
// Profanity tracking
|
||||||
if (r.profanity) {
|
if (r.profanity) {
|
||||||
const sevCol = `severity_${r.profanity_severity}` as "severity_1" | "severity_2" | "severity_3";
|
const sevCol = `severity_${r.profanity_severity}` as "severity_1" | "severity_2" | "severity_3";
|
||||||
@@ -446,9 +459,7 @@ wotd_used is TRUE only when the word appears as a STANDALONE word in the message
|
|||||||
);
|
);
|
||||||
|
|
||||||
const pottyEmoji = ["\uD83E\uDEE3", "\uD83D\uDE32", "\uD83E\uDD2C"][r.profanity_severity - 1]; // 🫣 😲 🤬
|
const pottyEmoji = ["\uD83E\uDEE3", "\uD83D\uDE32", "\uD83E\uDD2C"][r.profanity_severity - 1]; // 🫣 😲 🤬
|
||||||
this.client.sendEvent(ctx.roomId, "m.reaction", {
|
this.sendReact(ctx.roomId, ctx.eventId, pottyEmoji);
|
||||||
"m.relates_to": { rel_type: "m.annotation", event_id: ctx.eventId, key: pottyEmoji },
|
|
||||||
}).catch((err) => logger.error(`Failed to react to profanity: ${err}`));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,9 +483,7 @@ wotd_used is TRUE only when the word appears as a STANDALONE word in the message
|
|||||||
|
|
||||||
const insultEmoji = r.insult_target === this.botUserId ? "\uD83D\uDD95" // 🖕 insulted the bot
|
const insultEmoji = r.insult_target === this.botUserId ? "\uD83D\uDD95" // 🖕 insulted the bot
|
||||||
: r.insult_type === "direct" ? "\uD83C\uDFAF" : "\uD83D\uDCA8"; // 🎯 direct, 💨 indirect
|
: r.insult_type === "direct" ? "\uD83C\uDFAF" : "\uD83D\uDCA8"; // 🎯 direct, 💨 indirect
|
||||||
this.client.sendEvent(ctx.roomId, "m.reaction", {
|
this.sendReact(ctx.roomId, ctx.eventId, insultEmoji);
|
||||||
"m.relates_to": { rel_type: "m.annotation", event_id: ctx.eventId, key: insultEmoji },
|
|
||||||
}).catch((err) => logger.error(`Failed to react to insult: ${err}`));
|
|
||||||
|
|
||||||
// Track target
|
// Track target
|
||||||
if (r.insult_target) {
|
if (r.insult_target) {
|
||||||
@@ -505,13 +514,7 @@ wotd_used is TRUE only when the word appears as a STANDALONE word in the message
|
|||||||
|
|
||||||
// React so the user knows their WOTD attempt was detected
|
// React so the user knows their WOTD attempt was detected
|
||||||
const emoji = r.wotd_correct ? "\uD83D\uDCD6" : "\uD83E\uDD14"; // 📖 if correct, 🤔 if not
|
const emoji = r.wotd_correct ? "\uD83D\uDCD6" : "\uD83E\uDD14"; // 📖 if correct, 🤔 if not
|
||||||
this.client.sendEvent(ctx.roomId, "m.reaction", {
|
this.sendReact(ctx.roomId, ctx.eventId, emoji);
|
||||||
"m.relates_to": {
|
|
||||||
rel_type: "m.annotation",
|
|
||||||
event_id: ctx.eventId,
|
|
||||||
key: emoji,
|
|
||||||
},
|
|
||||||
}).catch((err) => logger.error(`Failed to react to WOTD attempt: ${err}`));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gratitude — grant rep when the LLM detects genuine thanks
|
// Gratitude — grant rep when the LLM detects genuine thanks
|
||||||
@@ -547,9 +550,7 @@ wotd_used is TRUE only when the word appears as a STANDALONE word in the message
|
|||||||
|
|
||||||
this.xpPlugin.grantXp(receiverId, ctx.roomId, REP_XP_BONUS, "reputation");
|
this.xpPlugin.grantXp(receiverId, ctx.roomId, REP_XP_BONUS, "reputation");
|
||||||
|
|
||||||
this.client.sendEvent(ctx.roomId, "m.reaction", {
|
this.sendReact(ctx.roomId, ctx.eventId, "\u2705");
|
||||||
"m.relates_to": { rel_type: "m.annotation", event_id: ctx.eventId, key: "\u2705" },
|
|
||||||
}).catch((err) => logger.error(`Failed to react to gratitude: ${err}`));
|
|
||||||
|
|
||||||
logger.debug(`LLM: ${ctx.sender} gave rep to ${receiverId} in ${ctx.roomId}`);
|
logger.debug(`LLM: ${ctx.sender} gave rep to ${receiverId} in ${ctx.roomId}`);
|
||||||
}
|
}
|
||||||
@@ -571,9 +572,7 @@ wotd_used is TRUE only when the word appears as a STANDALONE word in the message
|
|||||||
const choices = sentimentEmojis[r.sentiment];
|
const choices = sentimentEmojis[r.sentiment];
|
||||||
if (choices.length > 0) {
|
if (choices.length > 0) {
|
||||||
const pick = choices[Math.floor(Math.random() * choices.length)];
|
const pick = choices[Math.floor(Math.random() * choices.length)];
|
||||||
this.client.sendEvent(ctx.roomId, "m.reaction", {
|
this.sendReact(ctx.roomId, ctx.eventId, pick);
|
||||||
"m.relates_to": { rel_type: "m.annotation", event_id: ctx.eventId, key: pick },
|
|
||||||
}).catch((err) => logger.error(`Failed to react to sentiment: ${err}`));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,13 +84,7 @@ export class ReputationPlugin extends Plugin {
|
|||||||
this.xpPlugin.grantXp(receiverId, roomId, REP_XP_BONUS, "reputation");
|
this.xpPlugin.grantXp(receiverId, roomId, REP_XP_BONUS, "reputation");
|
||||||
|
|
||||||
// React with checkmark to acknowledge
|
// React with checkmark to acknowledge
|
||||||
this.client.sendEvent(roomId, "m.reaction", {
|
this.sendReact(roomId, eventId, "\u2705");
|
||||||
"m.relates_to": {
|
|
||||||
rel_type: "m.annotation",
|
|
||||||
event_id: eventId,
|
|
||||||
key: "\u2705",
|
|
||||||
},
|
|
||||||
}).catch((err) => logger.error(`Failed to react: ${err}`));
|
|
||||||
|
|
||||||
logger.debug(`${giverId} gave rep to ${receiverId} in ${roomId}`);
|
logger.debug(`${giverId} gave rep to ${receiverId} in ${roomId}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,25 +17,28 @@ export class RetroPlugin extends Plugin {
|
|||||||
|
|
||||||
get commands(): CommandDef[] {
|
get commands(): CommandDef[] {
|
||||||
return [
|
return [
|
||||||
{ name: "retro", description: "Retro game lookup", usage: "!retro <game>" },
|
{ name: "game", description: "Game lookup via RAWG", usage: "!game <game>" },
|
||||||
|
{ name: "retro", description: "Alias for !game", usage: "!retro <game>" },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
async onMessage(ctx: MessageContext): Promise<void> {
|
async onMessage(ctx: MessageContext): Promise<void> {
|
||||||
if (this.isCommand(ctx.body, "retro")) {
|
if (this.isCommand(ctx.body, "game")) {
|
||||||
await this.handleRetro(ctx);
|
await this.handleGameLookup(ctx, "game");
|
||||||
|
} else if (this.isCommand(ctx.body, "retro")) {
|
||||||
|
await this.handleGameLookup(ctx, "retro");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleRetro(ctx: MessageContext): Promise<void> {
|
private async handleGameLookup(ctx: MessageContext, command: string): Promise<void> {
|
||||||
if (!ENABLED) {
|
if (!ENABLED) {
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "RAWG API is not configured. Set RAWG_API_KEY.");
|
await this.sendReply(ctx.roomId, ctx.eventId, "RAWG API is not configured. Set RAWG_API_KEY.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = this.getArgs(ctx.body, "retro");
|
const query = this.getArgs(ctx.body, command);
|
||||||
if (!query) {
|
if (!query) {
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !retro <game>");
|
await this.sendReply(ctx.roomId, ctx.eventId, `Usage: !${command} <game>`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -446,13 +446,7 @@ export class TriviaPlugin extends Plugin {
|
|||||||
this.xpPlugin.grantXp(ctx.sender, ctx.roomId, points, "trivia correct answer");
|
this.xpPlugin.grantXp(ctx.sender, ctx.roomId, points, "trivia correct answer");
|
||||||
|
|
||||||
// React with checkmark
|
// React with checkmark
|
||||||
this.client.sendEvent(ctx.roomId, "m.reaction", {
|
this.sendReact(ctx.roomId, ctx.eventId, "\u2705");
|
||||||
"m.relates_to": {
|
|
||||||
rel_type: "m.annotation",
|
|
||||||
event_id: ctx.eventId,
|
|
||||||
key: "\u2705",
|
|
||||||
},
|
|
||||||
}).catch((err) => logger.error(`Failed to react: ${err}`));
|
|
||||||
|
|
||||||
await this.sendThreadMessage(
|
await this.sendThreadMessage(
|
||||||
ctx.roomId,
|
ctx.roomId,
|
||||||
@@ -470,13 +464,7 @@ export class TriviaPlugin extends Plugin {
|
|||||||
`).run(ctx.sender, ctx.roomId);
|
`).run(ctx.sender, ctx.roomId);
|
||||||
|
|
||||||
// React with X
|
// React with X
|
||||||
this.client.sendEvent(ctx.roomId, "m.reaction", {
|
this.sendReact(ctx.roomId, ctx.eventId, "\u274C");
|
||||||
"m.relates_to": {
|
|
||||||
rel_type: "m.annotation",
|
|
||||||
event_id: ctx.eventId,
|
|
||||||
key: "\u274C",
|
|
||||||
},
|
|
||||||
}).catch((err) => logger.error(`Failed to react: ${err}`));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -522,7 +510,7 @@ export class TriviaPlugin extends Plugin {
|
|||||||
|
|
||||||
private async handleScores(ctx: MessageContext, args: string): Promise<void> {
|
private async handleScores(ctx: MessageContext, args: string): Promise<void> {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const threadEventId = this.triviaThreads.get(ctx.roomId)!.eventId;
|
const threadEventId = this.triviaThreads.get(ctx.roomId)?.eventId;
|
||||||
|
|
||||||
if (args === "month") {
|
if (args === "month") {
|
||||||
// Current month scores
|
// Current month scores
|
||||||
@@ -541,12 +529,22 @@ export class TriviaPlugin extends Plugin {
|
|||||||
`).all(ctx.roomId, monthStart) as { user_id: string; total_correct: number; wins: number }[];
|
`).all(ctx.roomId, monthStart) as { user_id: string; total_correct: number; wins: number }[];
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
await this.sendThreadReply(ctx.roomId, threadEventId, ctx.eventId, "No trivia scores this month yet.");
|
const msg = "No trivia scores this month yet.";
|
||||||
|
if (threadEventId) {
|
||||||
|
await this.sendThreadReply(ctx.roomId, threadEventId, ctx.eventId, msg);
|
||||||
|
} else {
|
||||||
|
await this.sendReply(ctx.roomId, ctx.eventId, msg);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = rows.map((r, i) => `${i + 1}. ${r.user_id} — ${r.wins} correct`);
|
const lines = rows.map((r, i) => `${i + 1}. ${r.user_id} — ${r.wins} correct`);
|
||||||
await this.sendThreadMessage(ctx.roomId, threadEventId, `Trivia Leaderboard (This Month):\n${lines.join("\n")}`);
|
const text = `Trivia Leaderboard (This Month):\n${lines.join("\n")}`;
|
||||||
|
if (threadEventId) {
|
||||||
|
await this.sendThreadMessage(ctx.roomId, threadEventId, text);
|
||||||
|
} else {
|
||||||
|
await this.sendMessage(ctx.roomId, text);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,7 +563,12 @@ export class TriviaPlugin extends Plugin {
|
|||||||
} | undefined;
|
} | undefined;
|
||||||
|
|
||||||
if (!row) {
|
if (!row) {
|
||||||
await this.sendThreadReply(ctx.roomId, threadEventId, ctx.eventId, `No trivia data found for ${targetUser}.`);
|
const msg = `No trivia data found for ${targetUser}.`;
|
||||||
|
if (threadEventId) {
|
||||||
|
await this.sendThreadReply(ctx.roomId, threadEventId, ctx.eventId, msg);
|
||||||
|
} else {
|
||||||
|
await this.sendReply(ctx.roomId, ctx.eventId, msg);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,15 +577,16 @@ export class TriviaPlugin extends Plugin {
|
|||||||
? ((row.total_correct / row.total_answered) * 100).toFixed(1) + "%"
|
? ((row.total_correct / row.total_answered) * 100).toFixed(1) + "%"
|
||||||
: "N/A";
|
: "N/A";
|
||||||
|
|
||||||
await this.sendThreadMessage(
|
const statsText = `Trivia Stats for ${targetUser}:\n` +
|
||||||
ctx.roomId,
|
`Correct: ${row.total_correct}/${row.total_answered} (${accuracy})\n` +
|
||||||
threadEventId,
|
`Points: ${row.total_points}\n` +
|
||||||
`Trivia Stats for ${targetUser}:\n` +
|
`Streak: ${row.current_streak} (Best: ${row.best_streak})\n` +
|
||||||
`Correct: ${row.total_correct}/${row.total_answered} (${accuracy})\n` +
|
`Fastest: ${fastestSec}`;
|
||||||
`Points: ${row.total_points}\n` +
|
if (threadEventId) {
|
||||||
`Streak: ${row.current_streak} (Best: ${row.best_streak})\n` +
|
await this.sendThreadMessage(ctx.roomId, threadEventId, statsText);
|
||||||
`Fastest: ${fastestSec}`
|
} else {
|
||||||
);
|
await this.sendMessage(ctx.roomId, statsText);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -596,27 +600,42 @@ export class TriviaPlugin extends Plugin {
|
|||||||
.all(ctx.roomId) as { user_id: string; total_correct: number; total_points: number; best_streak: number }[];
|
.all(ctx.roomId) as { user_id: string; total_correct: number; total_points: number; best_streak: number }[];
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
await this.sendThreadReply(ctx.roomId, threadEventId, ctx.eventId, "No trivia scores yet.");
|
const msg = "No trivia scores yet.";
|
||||||
|
if (threadEventId) {
|
||||||
|
await this.sendThreadReply(ctx.roomId, threadEventId, ctx.eventId, msg);
|
||||||
|
} else {
|
||||||
|
await this.sendReply(ctx.roomId, ctx.eventId, msg);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = rows.map(
|
const lines = rows.map(
|
||||||
(r, i) => `${i + 1}. ${r.user_id} — ${r.total_points} pts (${r.total_correct} correct, best streak: ${r.best_streak})`
|
(r, i) => `${i + 1}. ${r.user_id} — ${r.total_points} pts (${r.total_correct} correct, best streak: ${r.best_streak})`
|
||||||
);
|
);
|
||||||
await this.sendThreadMessage(ctx.roomId, threadEventId, `Trivia Leaderboard:\n${lines.join("\n")}`);
|
const text = `Trivia Leaderboard:\n${lines.join("\n")}`;
|
||||||
|
if (threadEventId) {
|
||||||
|
await this.sendThreadMessage(ctx.roomId, threadEventId, text);
|
||||||
|
} else {
|
||||||
|
await this.sendMessage(ctx.roomId, text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleCategories(ctx: MessageContext): Promise<void> {
|
private async handleCategories(ctx: MessageContext): Promise<void> {
|
||||||
const threadEventId = this.triviaThreads.get(ctx.roomId)!.eventId;
|
const threadEventId = this.triviaThreads.get(ctx.roomId)?.eventId;
|
||||||
const lines = ["Available Trivia Categories:"];
|
const lines = ["Available Trivia Categories:"];
|
||||||
for (const [id, name] of Object.entries(CATEGORY_MAP)) {
|
for (const [id, name] of Object.entries(CATEGORY_MAP)) {
|
||||||
lines.push(` ${id}. ${name}`);
|
lines.push(` ${id}. ${name}`);
|
||||||
}
|
}
|
||||||
await this.sendThreadMessage(ctx.roomId, threadEventId, lines.join("\n"));
|
const text = lines.join("\n");
|
||||||
|
if (threadEventId) {
|
||||||
|
await this.sendThreadMessage(ctx.roomId, threadEventId, text);
|
||||||
|
} else {
|
||||||
|
await this.sendMessage(ctx.roomId, text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleFastest(ctx: MessageContext): Promise<void> {
|
private async handleFastest(ctx: MessageContext): Promise<void> {
|
||||||
const threadEventId = this.triviaThreads.get(ctx.roomId)!.eventId;
|
const threadEventId = this.triviaThreads.get(ctx.roomId)?.eventId;
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const rows = db
|
const rows = db
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -627,13 +646,22 @@ export class TriviaPlugin extends Plugin {
|
|||||||
.all(ctx.roomId) as { user_id: string; fastest_ms: number }[];
|
.all(ctx.roomId) as { user_id: string; fastest_ms: number }[];
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
await this.sendThreadReply(ctx.roomId, threadEventId, ctx.eventId, "No fastest answer data yet.");
|
const msg = "No fastest answer data yet.";
|
||||||
|
if (threadEventId) {
|
||||||
|
await this.sendThreadReply(ctx.roomId, threadEventId, ctx.eventId, msg);
|
||||||
|
} else {
|
||||||
|
await this.sendReply(ctx.roomId, ctx.eventId, msg);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = rows.map(
|
const text = `Fastest Trivia Answers:\n${rows.map(
|
||||||
(r, i) => `${i + 1}. ${r.user_id} — ${(r.fastest_ms / 1000).toFixed(2)}s`
|
(r, i) => `${i + 1}. ${r.user_id} — ${(r.fastest_ms / 1000).toFixed(2)}s`
|
||||||
);
|
).join("\n")}`;
|
||||||
await this.sendThreadMessage(ctx.roomId, threadEventId, `Fastest Trivia Answers:\n${lines.join("\n")}`);
|
if (threadEventId) {
|
||||||
|
await this.sendThreadMessage(ctx.roomId, threadEventId, text);
|
||||||
|
} else {
|
||||||
|
await this.sendMessage(ctx.roomId, text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
246
src/plugins/vibe.ts
Normal file
246
src/plugins/vibe.ts
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
||||||
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
|
const OLLAMA_HOST = (() => {
|
||||||
|
const raw = process.env.OLLAMA_HOST ?? "";
|
||||||
|
return raw && !raw.startsWith("http") ? `http://${raw}` : raw;
|
||||||
|
})();
|
||||||
|
const OLLAMA_MODEL = process.env.OLLAMA_MODEL ?? "";
|
||||||
|
const LLM_ENABLED = OLLAMA_HOST !== "" && OLLAMA_MODEL !== "";
|
||||||
|
const OLLAMA_TIMEOUT_MS = 45_000;
|
||||||
|
const OLLAMA_NUM_CTX = 16384;
|
||||||
|
|
||||||
|
const BUFFER_CAP = 50;
|
||||||
|
const MIN_MESSAGES = 10;
|
||||||
|
const COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes per room per command
|
||||||
|
|
||||||
|
interface BufferedMessage {
|
||||||
|
sender: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VibePlugin extends Plugin {
|
||||||
|
private buffers = new Map<string, BufferedMessage[]>();
|
||||||
|
private vibeCooldowns = new Map<string, number>();
|
||||||
|
private tldrCooldowns = new Map<string, number>();
|
||||||
|
|
||||||
|
constructor(client: IMatrixClient) {
|
||||||
|
super(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
get name() {
|
||||||
|
return "vibe";
|
||||||
|
}
|
||||||
|
|
||||||
|
get commands(): CommandDef[] {
|
||||||
|
return [
|
||||||
|
{ name: "vibe", description: "Describe the current room energy", usage: "!vibe" },
|
||||||
|
{ name: "tldr", description: "Summarize recent conversation", usage: "!tldr [n]" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async onMessage(ctx: MessageContext): Promise<void> {
|
||||||
|
// Buffer only plain text messages (skip commands for vibe/tldr)
|
||||||
|
const msgtype = ctx.event?.content?.msgtype;
|
||||||
|
if (msgtype === "m.text" && !this.isCommand(ctx.body, "vibe") && !this.isCommand(ctx.body, "tldr")) {
|
||||||
|
this.pushMessage(ctx.roomId, ctx.sender, ctx.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isCommand(ctx.body, "vibe")) {
|
||||||
|
await this.handleVibe(ctx);
|
||||||
|
} else if (this.isCommand(ctx.body, "tldr")) {
|
||||||
|
await this.handleTldr(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private pushMessage(roomId: string, sender: string, body: string): void {
|
||||||
|
let buf = this.buffers.get(roomId);
|
||||||
|
if (!buf) {
|
||||||
|
buf = [];
|
||||||
|
this.buffers.set(roomId, buf);
|
||||||
|
}
|
||||||
|
buf.push({ sender, body });
|
||||||
|
if (buf.length > BUFFER_CAP) {
|
||||||
|
buf.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkCooldown(cooldowns: Map<string, number>, roomId: string): number | null {
|
||||||
|
const lastUsed = cooldowns.get(roomId) ?? 0;
|
||||||
|
const remaining = lastUsed + COOLDOWN_MS - Date.now();
|
||||||
|
if (remaining > 0) {
|
||||||
|
return Math.ceil(remaining / 1000);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- !vibe ---
|
||||||
|
|
||||||
|
private async handleVibe(ctx: MessageContext): Promise<void> {
|
||||||
|
if (!LLM_ENABLED) {
|
||||||
|
await this.sendReply(ctx.roomId, ctx.eventId, "LLM features are not configured.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cooldownSecs = this.checkCooldown(this.vibeCooldowns, ctx.roomId);
|
||||||
|
if (cooldownSecs) {
|
||||||
|
await this.sendReply(ctx.roomId, ctx.eventId, `Vibe check on cooldown. Try again in ${cooldownSecs}s.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buf = this.buffers.get(ctx.roomId);
|
||||||
|
if (!buf || buf.length < MIN_MESSAGES) {
|
||||||
|
const have = buf?.length ?? 0;
|
||||||
|
await this.sendReply(ctx.roomId, ctx.eventId, `Not enough recent context yet (${have}/${MIN_MESSAGES} messages). Keep chatting.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.vibeCooldowns.set(ctx.roomId, Date.now());
|
||||||
|
|
||||||
|
try {
|
||||||
|
const vibe = await this.generateVibe(buf);
|
||||||
|
if (vibe) {
|
||||||
|
await this.sendMessage(ctx.roomId, vibe);
|
||||||
|
} else {
|
||||||
|
await this.sendReply(ctx.roomId, ctx.eventId, "Couldn't read the room. Try again later.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Vibe generation failed: ${err}`);
|
||||||
|
await this.sendReply(ctx.roomId, ctx.eventId, "Couldn't read the room. Try again later.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateVibe(messages: BufferedMessage[]): Promise<string | null> {
|
||||||
|
const transcript = messages.map((m) => `${m.sender}: ${m.body}`).join("\n");
|
||||||
|
|
||||||
|
const prompt = `You are Freebee, a snarky but affectionate chat bot in a private friend group. Someone just asked you to read the room and describe the current vibe.
|
||||||
|
|
||||||
|
Below is a transcript of the last ${messages.length} messages. Describe the room's current energy in 1-2 sentences. Be creative, funny, and specific to what's actually being discussed. Think of it like a weather report for the chat's mood.
|
||||||
|
|
||||||
|
Examples of the tone we want:
|
||||||
|
- "Chaotic gremlin energy with undertones of unresolved technical debt."
|
||||||
|
- "Three people passionately disagreeing about something none of them will remember tomorrow."
|
||||||
|
- "Suspiciously wholesome. Someone's about to ruin it."
|
||||||
|
- "One person carrying the entire conversation while everyone else lurks in silence."
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- 1-2 sentences max. Short and punchy.
|
||||||
|
- Reference actual topics or dynamics from the transcript, not generic vibes.
|
||||||
|
- Do NOT name specific users. Describe roles/dynamics instead ("someone", "one person", "half the room").
|
||||||
|
- Do NOT use bullet points or formatting. Just flowing text.
|
||||||
|
- Do NOT start with "The vibe is" or "Current vibe:". Just describe it directly.
|
||||||
|
|
||||||
|
Transcript:
|
||||||
|
${transcript}
|
||||||
|
|
||||||
|
Describe the vibe now. Raw text only.`;
|
||||||
|
|
||||||
|
return this.callOllama(prompt, "vibe");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- !tldr ---
|
||||||
|
|
||||||
|
private async handleTldr(ctx: MessageContext): Promise<void> {
|
||||||
|
if (!LLM_ENABLED) {
|
||||||
|
await this.sendReply(ctx.roomId, ctx.eventId, "LLM features are not configured.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cooldownSecs = this.checkCooldown(this.tldrCooldowns, ctx.roomId);
|
||||||
|
if (cooldownSecs) {
|
||||||
|
await this.sendReply(ctx.roomId, ctx.eventId, `TLDR on cooldown. Try again in ${cooldownSecs}s.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = this.getArgs(ctx.body, "tldr").trim();
|
||||||
|
let count = BUFFER_CAP;
|
||||||
|
if (args) {
|
||||||
|
const parsed = parseInt(args, 10);
|
||||||
|
if (!isNaN(parsed) && parsed > 0) {
|
||||||
|
count = Math.min(parsed, BUFFER_CAP);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buf = this.buffers.get(ctx.roomId);
|
||||||
|
if (!buf || buf.length < MIN_MESSAGES) {
|
||||||
|
const have = buf?.length ?? 0;
|
||||||
|
await this.sendReply(ctx.roomId, ctx.eventId, `Not enough recent context yet (${have}/${MIN_MESSAGES} messages). Keep chatting.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = buf.slice(-count);
|
||||||
|
this.tldrCooldowns.set(ctx.roomId, Date.now());
|
||||||
|
|
||||||
|
try {
|
||||||
|
const summary = await this.generateTldr(messages);
|
||||||
|
if (summary) {
|
||||||
|
await this.sendMessage(ctx.roomId, summary);
|
||||||
|
} else {
|
||||||
|
await this.sendReply(ctx.roomId, ctx.eventId, "Couldn't summarize. Try again later.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`TLDR generation failed: ${err}`);
|
||||||
|
await this.sendReply(ctx.roomId, ctx.eventId, "Couldn't summarize. Try again later.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateTldr(messages: BufferedMessage[]): Promise<string | null> {
|
||||||
|
const transcript = messages.map((m) => `${m.sender}: ${m.body}`).join("\n");
|
||||||
|
|
||||||
|
const prompt = `You are Freebee, a helpful chat bot in a private friend group. Someone just asked for a summary of the recent conversation so they can catch up.
|
||||||
|
|
||||||
|
Below is a transcript of the last ${messages.length} messages. Summarize what happened in a concise, easy-to-scan format.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Start with "TLDR:" followed by the summary.
|
||||||
|
- Group by topic/thread if the conversation covered multiple subjects.
|
||||||
|
- Keep each topic to 1 sentence.
|
||||||
|
- Use casual, natural language — not corporate meeting notes.
|
||||||
|
- You MAY reference usernames since this is a summary, not a vibe check.
|
||||||
|
- Keep the entire summary under 500 characters.
|
||||||
|
- No bullet points — use short flowing sentences separated by line breaks.
|
||||||
|
|
||||||
|
Transcript:
|
||||||
|
${transcript}
|
||||||
|
|
||||||
|
Write the summary now. Raw text only.`;
|
||||||
|
|
||||||
|
return this.callOllama(prompt, "tldr");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- shared ---
|
||||||
|
|
||||||
|
private async callOllama(prompt: string, label: string): Promise<string | null> {
|
||||||
|
const res = await fetch(`${OLLAMA_HOST}/api/generate`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: OLLAMA_MODEL,
|
||||||
|
prompt,
|
||||||
|
stream: false,
|
||||||
|
options: { num_ctx: OLLAMA_NUM_CTX, temperature: label === "tldr" ? 0.3 : 0.9 },
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(OLLAMA_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
logger.warn(`Ollama API returned ${res.status} for ${label}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as { response: string };
|
||||||
|
let text = data.response?.trim();
|
||||||
|
if (!text) return null;
|
||||||
|
|
||||||
|
// Clean up LLM quirks
|
||||||
|
text = text.replace(/^["']|["']$/g, "");
|
||||||
|
text = text.replace(/^#+\s*/gm, "");
|
||||||
|
text = text.replace(/\*\*/g, "");
|
||||||
|
|
||||||
|
if (label === "vibe") {
|
||||||
|
text = `Vibe check: ${text}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -121,8 +121,40 @@ export class WotdPlugin extends Plugin {
|
|||||||
logger.debug(`${ctx.sender} used WOTD "${this.todaysWord}" — awarded ${WOTD_BONUS_XP} XP`);
|
logger.debug(`${ctx.sender} used WOTD "${this.todaysWord}" — awarded ${WOTD_BONUS_XP} XP`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefetch WOTD data from API and cache it in the DB.
|
||||||
|
* Called early (e.g. 00:05 UTC) so the post step has no network dependency.
|
||||||
|
*/
|
||||||
|
async prefetch(): Promise<void> {
|
||||||
|
const apiKey = process.env.WORDNIK_API_KEY;
|
||||||
|
if (!apiKey) return;
|
||||||
|
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const existing = db.prepare(`SELECT data FROM daily_prefetch WHERE job_name = 'wotd' AND date = ?`).get(today);
|
||||||
|
if (existing) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `https://api.wordnik.com/v4/words.json/wordOfTheDay?api_key=${encodeURIComponent(apiKey)}`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) {
|
||||||
|
logger.warn(`Wordnik prefetch returned ${res.status}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json() as any;
|
||||||
|
db.prepare(`INSERT OR REPLACE INTO daily_prefetch (job_name, date, data) VALUES ('wotd', ?, ?)`)
|
||||||
|
.run(today, JSON.stringify(data));
|
||||||
|
logger.info(`Prefetched WOTD for ${today}: ${data.word}`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`WOTD prefetch failed: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called by DailyScheduler to post the word of the day.
|
* Called by DailyScheduler to post the word of the day.
|
||||||
|
* Reads from prefetch cache first, falls back to live API call.
|
||||||
*/
|
*/
|
||||||
async postWotd(roomIds: string[]): Promise<void> {
|
async postWotd(roomIds: string[]): Promise<void> {
|
||||||
const apiKey = process.env.WORDNIK_API_KEY;
|
const apiKey = process.env.WORDNIK_API_KEY;
|
||||||
@@ -142,14 +174,21 @@ export class WotdPlugin extends Plugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `https://api.wordnik.com/v4/words.json/wordOfTheDay?api_key=${encodeURIComponent(apiKey)}`;
|
// Try prefetch cache first, fall back to live API
|
||||||
const res = await fetch(url);
|
let data: any;
|
||||||
if (!res.ok) {
|
const cached = db.prepare(`SELECT data FROM daily_prefetch WHERE job_name = 'wotd' AND date = ?`).get(today) as { data: string } | undefined;
|
||||||
logger.warn(`Wordnik API returned ${res.status}`);
|
if (cached) {
|
||||||
return;
|
data = JSON.parse(cached.data);
|
||||||
|
} else {
|
||||||
|
const url = `https://api.wordnik.com/v4/words.json/wordOfTheDay?api_key=${encodeURIComponent(apiKey)}`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) {
|
||||||
|
logger.warn(`Wordnik API returned ${res.status}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
data = await res.json() as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json() as any;
|
|
||||||
const word = data.word;
|
const word = data.word;
|
||||||
const definitions = data.definitions ?? [];
|
const definitions = data.definitions ?? [];
|
||||||
const examples = data.examples ?? [];
|
const examples = data.examples ?? [];
|
||||||
|
|||||||
@@ -6,6 +6,19 @@ import logger from "../utils/logger";
|
|||||||
const XP_PER_MESSAGE = 10;
|
const XP_PER_MESSAGE = 10;
|
||||||
const XP_COOLDOWN_MS = 30_000;
|
const XP_COOLDOWN_MS = 30_000;
|
||||||
|
|
||||||
|
const LEVEL_UP_MESSAGES = [
|
||||||
|
"🔔✨ POWER UP! {user} hit Level {level}! Bell combo complete! ✨🔔",
|
||||||
|
"🌟 {user} grabbed the golden bell — Level {level}! Speed up! 🐝💨",
|
||||||
|
"⚡ Level {level}! {user} is fully powered up! Options attached! 🛸🛸",
|
||||||
|
"🎮 {user} evolved to Level {level}! Twin attack ready! 👊👊",
|
||||||
|
"🏆 STAGE CLEAR! {user} advanced to Level {level}! 🎆🎆🎆",
|
||||||
|
"🔔🔔🔔 {user} collected enough bells for Level {level}! Shield activated! 🛡️",
|
||||||
|
"📢 Level {level} unlocked! {user} picked up the megaphone power-up! 🔊💥",
|
||||||
|
"⚡ {user} just warped to Level {level}! Laser mode engaged! 🔴🔴🔴",
|
||||||
|
"🎰 Bonus round! {user} reached Level {level}! 3-way shot acquired! 🔱",
|
||||||
|
"⚡ {user} powered through to Level {level}! Ripple laser online! 〰️💫",
|
||||||
|
];
|
||||||
|
|
||||||
export class XpPlugin extends Plugin {
|
export class XpPlugin extends Plugin {
|
||||||
private cooldowns = new Map<string, number>();
|
private cooldowns = new Map<string, number>();
|
||||||
|
|
||||||
@@ -29,6 +42,13 @@ export class XpPlugin extends Plugin {
|
|||||||
*/
|
*/
|
||||||
grantXp(userId: string, roomId: string, amount: number, reason: string): void {
|
grantXp(userId: string, roomId: string, amount: number, reason: string): void {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
|
// Grab old level before granting
|
||||||
|
const before = db.prepare(`SELECT level FROM users WHERE user_id = ? AND room_id = ?`).get(userId, roomId) as
|
||||||
|
| { level: number }
|
||||||
|
| undefined;
|
||||||
|
const oldLevel = before?.level ?? 0;
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO users (user_id, room_id, xp, level)
|
INSERT INTO users (user_id, room_id, xp, level)
|
||||||
VALUES (?, ?, ?, 1)
|
VALUES (?, ?, ?, 1)
|
||||||
@@ -49,8 +69,9 @@ export class XpPlugin extends Plugin {
|
|||||||
const row = db.prepare(`SELECT xp FROM users WHERE user_id = ? AND room_id = ?`).get(userId, roomId) as
|
const row = db.prepare(`SELECT xp FROM users WHERE user_id = ? AND room_id = ?`).get(userId, roomId) as
|
||||||
| { xp: number }
|
| { xp: number }
|
||||||
| undefined;
|
| undefined;
|
||||||
|
let newLevel = oldLevel || 1;
|
||||||
if (row) {
|
if (row) {
|
||||||
const newLevel = xpToLevel(row.xp);
|
newLevel = xpToLevel(row.xp);
|
||||||
db.prepare(`UPDATE users SET level = ? WHERE user_id = ? AND room_id = ?`).run(newLevel, userId, roomId);
|
db.prepare(`UPDATE users SET level = ? WHERE user_id = ? AND room_id = ?`).run(newLevel, userId, roomId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +83,15 @@ export class XpPlugin extends Plugin {
|
|||||||
);
|
);
|
||||||
|
|
||||||
logger.debug(`Granted ${amount} XP to ${userId} in ${roomId}: ${reason}`);
|
logger.debug(`Granted ${amount} XP to ${userId} in ${roomId}: ${reason}`);
|
||||||
|
|
||||||
|
// Announce level up
|
||||||
|
if (newLevel > oldLevel && oldLevel > 0) {
|
||||||
|
const template = LEVEL_UP_MESSAGES[Math.floor(Math.random() * LEVEL_UP_MESSAGES.length)];
|
||||||
|
const msg = template.replace("{user}", userId).replace("{level}", String(newLevel));
|
||||||
|
this.sendMessage(roomId, msg).catch((err) => {
|
||||||
|
logger.error(`Failed to announce level up: ${err}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async onMessage(ctx: MessageContext): Promise<void> {
|
async onMessage(ctx: MessageContext): Promise<void> {
|
||||||
|
|||||||
Reference in New Issue
Block a user