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:
prosolis
2026-03-08 11:30:23 -07:00
parent 69be69e3b0
commit 07c8674fe0
17 changed files with 1109 additions and 135 deletions

View File

@@ -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 <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
| Command | Description |
@@ -339,7 +353,6 @@ Away/AFK status auto-clears silently when the user sends any message.
| `!8ball <question>` | Magic 8-Ball |
| `!coin` | Coin flip |
| `!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) |
| `!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 <game>` | Search for a game's release info |
### Retro Games
### Game Lookup
| 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`.
@@ -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 (730 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 (5s5min) 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

View File

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

View File

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

View File

@@ -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<void> {
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<void> {
registry.register(reactionsPlugin);
registry.register(botInfoPlugin);
registry.register(retroPlugin);
registry.register(howAmIPlugin);
registry.register(vibePlugin);
registry.register(achievementsPlugin);
registry.register(dailyScheduler);

View File

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

View File

@@ -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 {
return body.startsWith(this.prefix + command);
}

View File

@@ -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<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> {
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 <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;
}
@@ -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;
}

View File

@@ -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]);
}
}

View File

@@ -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.
* Reads from prefetch cache first, falls back to live API call.
*/
async postHolidays(roomIds: string[]): Promise<void> {
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.");

338
src/plugins/howami.ts Normal file
View 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;
}
}

View File

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

View File

@@ -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}`);
}

View File

@@ -17,25 +17,28 @@ export class RetroPlugin extends Plugin {
get commands(): CommandDef[] {
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> {
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<void> {
private async handleGameLookup(ctx: MessageContext, command: string): Promise<void> {
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 <game>");
await this.sendReply(ctx.roomId, ctx.eventId, `Usage: !${command} <game>`);
return;
}

View File

@@ -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<void> {
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<void> {
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<void> {
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);
}
}
}

246
src/plugins/vibe.ts Normal file
View 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;
}
}

View File

@@ -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<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.
* Reads from prefetch cache first, falls back to live API call.
*/
async postWotd(roomIds: string[]): Promise<void> {
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 ?? [];

View File

@@ -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<string, number>();
@@ -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<void> {