From 07c8674fe0a42341a30bc43e42bd1a526ab73d71 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sun, 8 Mar 2026 11:30:23 -0700 Subject: [PATCH] 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 --- README.md | 59 +++++-- package.json | 1 + src/db/index.ts | 48 ++++++ src/index.ts | 53 +++--- src/matrix-client.ts | 26 ++- src/plugins/base.ts | 31 ++++ src/plugins/daily.ts | 143 +++++++++++++++- src/plugins/fun.ts | 14 +- src/plugins/holidays.ts | 42 ++++- src/plugins/howami.ts | 338 +++++++++++++++++++++++++++++++++++++ src/plugins/llm-passive.ts | 37 ++-- src/plugins/reputation.ts | 8 +- src/plugins/retro.ts | 15 +- src/plugins/trivia.ts | 100 +++++++---- src/plugins/vibe.ts | 246 +++++++++++++++++++++++++++ src/plugins/wotd.ts | 51 +++++- src/plugins/xp.ts | 32 +++- 17 files changed, 1109 insertions(+), 135 deletions(-) create mode 100644 src/plugins/howami.ts create mode 100644 src/plugins/vibe.ts diff --git a/README.md b/README.md index 7f3844b..114901e 100644 --- a/README.md +++ b/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. -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 - **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 -- **Plugin architecture** — modular design with 32+ independent plugins -- **Silent passive tracking** — XP, streaks, stats, achievements, keyword alerts, Markov corpus, and presence tracked without announcements +- **Plugin architecture** — modular design with 35+ independent plugins +- **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 - **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) - **32 unlockable achievements** evaluated silently on every message - **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 - **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 +- **`!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 - **Markov chain text generation** — per-user trigram models for chaos - **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 - **Welcome system** — greets new users with bonus XP and provides `!help` via DM - **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 - **QR code generator** — generates and posts QR code images to the room - **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 | | `!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 @@ -331,6 +335,16 @@ Reminders are checked every 30 seconds. Away/AFK status auto-clears silently when the user sends any message. +### Weather + +| Command | Description | +|---|---| +| `!weather ` | Current weather for a city name (e.g., `!weather Tokyo`) | +| `!weather ` | 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 | Command | Description | @@ -339,7 +353,6 @@ Away/AFK status auto-clears silently when the user sends any message. | `!8ball ` | Magic 8-Ball | | `!coin` | Coin flip | | `!time , ...` | World clock (comma-separated, supports users) | -| `!weather ` | Current weather with geocoding (temp, humidity, wind) | | `!hltb ` | HowLongToBeat lookup (main, extra, completionist) | | `!twinbee` | Random TwinBee/Parodius lore fact | | `!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 search ` | Search for a game's release info | -### Retro Games +### Game Lookup | Command | Description | |---|---| -| `!retro ` | Look up a classic/retro game (name, year, platforms, developer, genre, metacritic, summary) | +| `!game ` | Look up any game (name, year, platforms, developer, genre, metacritic, summary) | +| `!retro ` | Alias for `!game` | 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) | | `!insultboard` | Most prolific insulters leaderboard | | `!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) - **Insults**: 🖕 (bot targeted), 🎯 (direct), 💨 (indirect) - **WOTD**: 📖 (correct usage), 🤔 (incorrect) - **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 | Command | Description | @@ -543,7 +562,7 @@ These features run silently on every message. The bot never announces passive tr | 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 | | **Streak tracking** | Tracks daily activity and consecutive-day streaks | | **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) 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 @@ -778,7 +803,7 @@ freebee/ │ ├── wotd.ts # Word of the Day │ ├── holidays.ts # multi-calendar holidays │ ├── gaming.ts # game releases (RAWG) - │ ├── daily.ts # DailyScheduler (7 jobs) + │ ├── daily.ts # DailyScheduler (9 jobs incl. prefetch & maintenance) │ ├── achievements.ts # 32 silent achievements │ ├── ratelimits.ts # per-command daily quota middleware │ ├── birthday.ts # birthday storage & announcements @@ -797,7 +822,9 @@ freebee/ │ ├── tools.ts # calc, QR code generator │ ├── reactions.ts # reaction tracking, emojiboard │ ├── 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 └── workers/ └── 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 -- **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. - **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. @@ -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) | | `retro_cache` | RAWG retro game lookup cache (7d 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. @@ -997,4 +1026,4 @@ If only the crypto store is lost (database intact), delete the old `crypto-js/` ## License -ISC +MIT diff --git a/package.json b/package.json index 8622795..60dfdf9 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "freebee", "version": "1.0.0", "description": "A full-featured Matrix community bot with E2EE support, plugin architecture, and community engagement features", + "license": "MIT", "main": "dist/index.js", "scripts": { "build": "tsc", diff --git a/src/db/index.ts b/src/db/index.ts index 70b41ca..1df7a71 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -474,6 +474,31 @@ function createTables(): void { ); 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 CREATE TABLE IF NOT EXISTS url_cache ( url TEXT PRIMARY KEY, @@ -499,6 +524,27 @@ function runMigrations(): void { 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 { @@ -512,6 +558,8 @@ function seedSchedulerDefaults(): void { const releasesHour = parseInt(process.env.SCHEDULE_RELEASES_HOUR ?? "19", 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("releases", releasesHour, releasesMinute, 1); insert.run("wotd", 8, 0, 1); diff --git a/src/index.ts b/src/index.ts index 5297e4d..1f4e05c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,23 +38,8 @@ import { ToolsPlugin } from "./plugins/tools"; import { ReactionsPlugin } from "./plugins/reactions"; import { BotInfoPlugin } from "./plugins/botinfo"; import { RetroPlugin } from "./plugins/retro"; - -/** - * 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"); -} +import { HowAmIPlugin } from "./plugins/howami"; +import { VibePlugin } from "./plugins/vibe"; /** * 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 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 (hasDeviceFile && currentToken) { - const valid = await isTokenValid(homeserverUrl, currentToken); + if (hasDeviceFile && effectiveToken) { + const valid = await isTokenValid(homeserverUrl, effectiveToken); if (valid) { logger.info("Access token is valid, device identity exists"); - return currentToken; + return effectiveToken; } 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 // 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. - if (!hasDeviceFile && currentToken) { + if (!hasDeviceFile && effectiveToken) { 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); - // Save the new device ID - fs.writeFileSync(deviceFile, JSON.stringify({ deviceId: loginResult.device_id }), "utf-8"); + // Save the new device ID and access token together + 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}`); - updateEnvToken(loginResult.access_token); return loginResult.access_token; } @@ -237,6 +238,8 @@ async function main(): Promise { const reactionsPlugin = new ReactionsPlugin(botClient); const botInfoPlugin = new BotInfoPlugin(botClient); const retroPlugin = new RetroPlugin(botClient); + const howAmIPlugin = new HowAmIPlugin(botClient); + const vibePlugin = new VibePlugin(botClient); const dailyScheduler = new DailyScheduler( botClient, @@ -282,6 +285,8 @@ async function main(): Promise { registry.register(reactionsPlugin); registry.register(botInfoPlugin); registry.register(retroPlugin); + registry.register(howAmIPlugin); + registry.register(vibePlugin); registry.register(achievementsPlugin); registry.register(dailyScheduler); diff --git a/src/matrix-client.ts b/src/matrix-client.ts index 2d7067f..3a0adcc 100644 --- a/src/matrix-client.ts +++ b/src/matrix-client.ts @@ -79,7 +79,14 @@ export class BotClient { const deviceId = this.client.getDeviceId(); if (deviceId) { 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 = {}; + 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()}`); + // 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 this.client.on(RoomEvent.Timeline, (event: MatrixEvent, room: Room | undefined) => { if (!room) return; diff --git a/src/plugins/base.ts b/src/plugins/base.ts index f0496fa..830232f 100644 --- a/src/plugins/base.ts +++ b/src/plugins/base.ts @@ -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, 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 { return body.startsWith(this.prefix + command); } diff --git a/src/plugins/daily.ts b/src/plugins/daily.ts index 9e51c39..2fbd6dc 100644 --- a/src/plugins/daily.ts +++ b/src/plugins/daily.ts @@ -105,9 +105,20 @@ export class DailyScheduler extends Plugin { // If the scheduled time has already passed today, run it now if (scheduledMinutes <= currentMinutes) { const key = `${job.job_name}:${today}`; - this.firedToday.add(key); 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) { const key = `${job.job_name}:${today}`; 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).catch((err) => { + this.runJob(job.job_name).then(() => { + if (this.didJobComplete(job.job_name, today)) { + this.firedToday.add(key); + } + }).catch((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}`); switch (jobName) { + case "prefetch": + await this.runPrefetch(); + break; + case "maintenance": + this.runMaintenance(); + break; case "wotd": await this.wotdPlugin.postWotd(this.botRooms); 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 { + 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 { if (!this.isAdmin(ctx.sender)) { 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})$/); if (!match) { - await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !schedule (UTC)\nJobs: wotd, holidays, releases, birthday_check, anime_releases, movie_releases, concert_digest"); + await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !schedule (UTC)\nJobs: prefetch, maintenance, wotd, holidays, releases, birthday_check, anime_releases, movie_releases, concert_digest"); return; } @@ -210,7 +339,7 @@ export class DailyScheduler extends Plugin { .run(hour, minute, jobName); 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; } diff --git a/src/plugins/fun.ts b/src/plugins/fun.ts index 335c6ff..5b5f102 100644 --- a/src/plugins/fun.ts +++ b/src/plugins/fun.ts @@ -436,19 +436,9 @@ export class FunPlugin extends Plugin { 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++) { - try { - 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}`); - } + this.sendReact(ctx.roomId, pollEventId, numberEmojis[i]); } } diff --git a/src/plugins/holidays.ts b/src/plugins/holidays.ts index 8cd4903..a8b1729 100644 --- a/src/plugins/holidays.ts +++ b/src/plugins/holidays.ts @@ -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 { + 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. + * Reads from prefetch cache first, falls back to live API call. */ async postHolidays(roomIds: string[]): Promise { 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); 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) { logger.info("No holidays found for today, skipping post."); diff --git a/src/plugins/howami.ts b/src/plugins/howami.ts new file mode 100644 index 0000000..8b89044 --- /dev/null +++ b/src/plugins/howami.ts @@ -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; + dailyDistribution: Record; + // 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; + // 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 { + if (!this.isCommand(ctx.body, "howami")) return; + await this.handleHowAmI(ctx); + } + + private async handleHowAmI(ctx: MessageContext): Promise { + 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 = {}; + 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 = JSON.parse(stats.hourly_distribution || "{}"); + const daily: Record = 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 { + 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; + } +} diff --git a/src/plugins/llm-passive.ts b/src/plugins/llm-passive.ts index 1a7af6a..f82873a 100644 --- a/src/plugins/llm-passive.ts +++ b/src/plugins/llm-passive.ts @@ -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 ); + // 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 if (r.profanity) { 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]; // 🫣 😲 🤬 - this.client.sendEvent(ctx.roomId, "m.reaction", { - "m.relates_to": { rel_type: "m.annotation", event_id: ctx.eventId, key: pottyEmoji }, - }).catch((err) => logger.error(`Failed to react to profanity: ${err}`)); + this.sendReact(ctx.roomId, ctx.eventId, pottyEmoji); } } @@ -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 : r.insult_type === "direct" ? "\uD83C\uDFAF" : "\uD83D\uDCA8"; // 🎯 direct, 💨 indirect - this.client.sendEvent(ctx.roomId, "m.reaction", { - "m.relates_to": { rel_type: "m.annotation", event_id: ctx.eventId, key: insultEmoji }, - }).catch((err) => logger.error(`Failed to react to insult: ${err}`)); + this.sendReact(ctx.roomId, ctx.eventId, insultEmoji); // Track 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 const emoji = r.wotd_correct ? "\uD83D\uDCD6" : "\uD83E\uDD14"; // 📖 if correct, 🤔 if not - this.client.sendEvent(ctx.roomId, "m.reaction", { - "m.relates_to": { - rel_type: "m.annotation", - event_id: ctx.eventId, - key: emoji, - }, - }).catch((err) => logger.error(`Failed to react to WOTD attempt: ${err}`)); + this.sendReact(ctx.roomId, ctx.eventId, emoji); } // 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.client.sendEvent(ctx.roomId, "m.reaction", { - "m.relates_to": { rel_type: "m.annotation", event_id: ctx.eventId, key: "\u2705" }, - }).catch((err) => logger.error(`Failed to react to gratitude: ${err}`)); + this.sendReact(ctx.roomId, ctx.eventId, "\u2705"); 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]; if (choices.length > 0) { const pick = choices[Math.floor(Math.random() * choices.length)]; - this.client.sendEvent(ctx.roomId, "m.reaction", { - "m.relates_to": { rel_type: "m.annotation", event_id: ctx.eventId, key: pick }, - }).catch((err) => logger.error(`Failed to react to sentiment: ${err}`)); + this.sendReact(ctx.roomId, ctx.eventId, pick); } } } diff --git a/src/plugins/reputation.ts b/src/plugins/reputation.ts index 243f530..757f177 100644 --- a/src/plugins/reputation.ts +++ b/src/plugins/reputation.ts @@ -84,13 +84,7 @@ export class ReputationPlugin extends Plugin { this.xpPlugin.grantXp(receiverId, roomId, REP_XP_BONUS, "reputation"); // React with checkmark to acknowledge - this.client.sendEvent(roomId, "m.reaction", { - "m.relates_to": { - rel_type: "m.annotation", - event_id: eventId, - key: "\u2705", - }, - }).catch((err) => logger.error(`Failed to react: ${err}`)); + this.sendReact(roomId, eventId, "\u2705"); logger.debug(`${giverId} gave rep to ${receiverId} in ${roomId}`); } diff --git a/src/plugins/retro.ts b/src/plugins/retro.ts index 8a2959a..8f661df 100644 --- a/src/plugins/retro.ts +++ b/src/plugins/retro.ts @@ -17,25 +17,28 @@ export class RetroPlugin extends Plugin { get commands(): CommandDef[] { return [ - { name: "retro", description: "Retro game lookup", usage: "!retro " }, + { name: "game", description: "Game lookup via RAWG", usage: "!game " }, + { name: "retro", description: "Alias for !game", usage: "!retro " }, ]; } async onMessage(ctx: MessageContext): Promise { - if (this.isCommand(ctx.body, "retro")) { - await this.handleRetro(ctx); + if (this.isCommand(ctx.body, "game")) { + await this.handleGameLookup(ctx, "game"); + } else if (this.isCommand(ctx.body, "retro")) { + await this.handleGameLookup(ctx, "retro"); } } - private async handleRetro(ctx: MessageContext): Promise { + private async handleGameLookup(ctx: MessageContext, command: string): Promise { if (!ENABLED) { await this.sendReply(ctx.roomId, ctx.eventId, "RAWG API is not configured. Set RAWG_API_KEY."); return; } - const query = this.getArgs(ctx.body, "retro"); + const query = this.getArgs(ctx.body, command); if (!query) { - await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !retro "); + await this.sendReply(ctx.roomId, ctx.eventId, `Usage: !${command} `); return; } diff --git a/src/plugins/trivia.ts b/src/plugins/trivia.ts index 5d1557d..00a62e6 100644 --- a/src/plugins/trivia.ts +++ b/src/plugins/trivia.ts @@ -446,13 +446,7 @@ export class TriviaPlugin extends Plugin { this.xpPlugin.grantXp(ctx.sender, ctx.roomId, points, "trivia correct answer"); // React with checkmark - this.client.sendEvent(ctx.roomId, "m.reaction", { - "m.relates_to": { - rel_type: "m.annotation", - event_id: ctx.eventId, - key: "\u2705", - }, - }).catch((err) => logger.error(`Failed to react: ${err}`)); + this.sendReact(ctx.roomId, ctx.eventId, "\u2705"); await this.sendThreadMessage( ctx.roomId, @@ -470,13 +464,7 @@ export class TriviaPlugin extends Plugin { `).run(ctx.sender, ctx.roomId); // React with X - this.client.sendEvent(ctx.roomId, "m.reaction", { - "m.relates_to": { - rel_type: "m.annotation", - event_id: ctx.eventId, - key: "\u274C", - }, - }).catch((err) => logger.error(`Failed to react: ${err}`)); + this.sendReact(ctx.roomId, ctx.eventId, "\u274C"); } } @@ -522,7 +510,7 @@ export class TriviaPlugin extends Plugin { private async handleScores(ctx: MessageContext, args: string): Promise { const db = getDb(); - const threadEventId = this.triviaThreads.get(ctx.roomId)!.eventId; + const threadEventId = this.triviaThreads.get(ctx.roomId)?.eventId; if (args === "month") { // 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 }[]; 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; } 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; } @@ -565,7 +563,12 @@ export class TriviaPlugin extends Plugin { } | undefined; 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; } @@ -574,15 +577,16 @@ export class TriviaPlugin extends Plugin { ? ((row.total_correct / row.total_answered) * 100).toFixed(1) + "%" : "N/A"; - await this.sendThreadMessage( - ctx.roomId, - threadEventId, - `Trivia Stats for ${targetUser}:\n` + - `Correct: ${row.total_correct}/${row.total_answered} (${accuracy})\n` + - `Points: ${row.total_points}\n` + - `Streak: ${row.current_streak} (Best: ${row.best_streak})\n` + - `Fastest: ${fastestSec}` - ); + const statsText = `Trivia Stats for ${targetUser}:\n` + + `Correct: ${row.total_correct}/${row.total_answered} (${accuracy})\n` + + `Points: ${row.total_points}\n` + + `Streak: ${row.current_streak} (Best: ${row.best_streak})\n` + + `Fastest: ${fastestSec}`; + if (threadEventId) { + await this.sendThreadMessage(ctx.roomId, threadEventId, statsText); + } else { + await this.sendMessage(ctx.roomId, statsText); + } 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 }[]; 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; } const lines = rows.map( (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 { - const threadEventId = this.triviaThreads.get(ctx.roomId)!.eventId; + const threadEventId = this.triviaThreads.get(ctx.roomId)?.eventId; const lines = ["Available Trivia Categories:"]; for (const [id, name] of Object.entries(CATEGORY_MAP)) { 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 { - const threadEventId = this.triviaThreads.get(ctx.roomId)!.eventId; + const threadEventId = this.triviaThreads.get(ctx.roomId)?.eventId; const db = getDb(); const rows = db .prepare( @@ -627,13 +646,22 @@ export class TriviaPlugin extends Plugin { .all(ctx.roomId) as { user_id: string; fastest_ms: number }[]; 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; } - 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` - ); - await this.sendThreadMessage(ctx.roomId, threadEventId, `Fastest Trivia Answers:\n${lines.join("\n")}`); + ).join("\n")}`; + if (threadEventId) { + await this.sendThreadMessage(ctx.roomId, threadEventId, text); + } else { + await this.sendMessage(ctx.roomId, text); + } } } diff --git a/src/plugins/vibe.ts b/src/plugins/vibe.ts new file mode 100644 index 0000000..fbd7783 --- /dev/null +++ b/src/plugins/vibe.ts @@ -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(); + private vibeCooldowns = new Map(); + private tldrCooldowns = new Map(); + + 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 { + // 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, 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 { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/src/plugins/wotd.ts b/src/plugins/wotd.ts index 26b6d90..d98815c 100644 --- a/src/plugins/wotd.ts +++ b/src/plugins/wotd.ts @@ -121,8 +121,40 @@ export class WotdPlugin extends Plugin { 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 { + 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. + * Reads from prefetch cache first, falls back to live API call. */ async postWotd(roomIds: string[]): Promise { const apiKey = process.env.WORDNIK_API_KEY; @@ -142,14 +174,21 @@ export class WotdPlugin extends Plugin { } 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 API returned ${res.status}`); - return; + // Try prefetch cache first, fall back to live API + let data: any; + const cached = db.prepare(`SELECT data FROM daily_prefetch WHERE job_name = 'wotd' AND date = ?`).get(today) as { data: string } | undefined; + if (cached) { + 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 definitions = data.definitions ?? []; const examples = data.examples ?? []; diff --git a/src/plugins/xp.ts b/src/plugins/xp.ts index 4f46990..f1f9eb8 100644 --- a/src/plugins/xp.ts +++ b/src/plugins/xp.ts @@ -6,6 +6,19 @@ import logger from "../utils/logger"; const XP_PER_MESSAGE = 10; 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 { private cooldowns = new Map(); @@ -29,6 +42,13 @@ export class XpPlugin extends Plugin { */ grantXp(userId: string, roomId: string, amount: number, reason: string): void { 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(` INSERT INTO users (user_id, room_id, xp, level) 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 | { xp: number } | undefined; + let newLevel = oldLevel || 1; 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); } @@ -62,6 +83,15 @@ export class XpPlugin extends Plugin { ); 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 {