mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
Initial commit: Freebee Matrix community bot
Full-featured Matrix bot with E2EE via matrix-js-sdk, 32+ plugins, LLM-powered passive classification, XP/leveling, trivia, game releases, anime/movie tracking, and more. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
57
.env.example
Normal file
57
.env.example
Normal file
@@ -0,0 +1,57 @@
|
||||
# Matrix
|
||||
MATRIX_HOMESERVER_URL=https://matrix.example.com
|
||||
MATRIX_ACCESS_TOKEN=syt_your_token_here
|
||||
MATRIX_BOT_USER_ID=@freebee:example.com
|
||||
MATRIX_BOT_PASSWORD=
|
||||
|
||||
# Which rooms the bot actively posts scheduled content to (comma-separated)
|
||||
BOT_ROOMS=!roomid:example.com
|
||||
|
||||
# Admins who can run admin-only commands
|
||||
BOT_ADMIN_USERS=@yourmxid:example.com
|
||||
|
||||
# Runtime
|
||||
BOT_PREFIX=!
|
||||
DATA_DIR=./data
|
||||
LOG_LEVEL=info
|
||||
|
||||
# External APIs
|
||||
RAWG_API_KEY=
|
||||
WORDNIK_API_KEY=
|
||||
CALENDARIFIC_API_KEY=
|
||||
ASIAN_HOLIDAY_COUNTRIES=JP,CN,KR,IN,TH,VN,TW,PH
|
||||
HEBCAL_API_KEY=
|
||||
OPENWEATHER_API_KEY=
|
||||
FINNHUB_API_KEY=
|
||||
BANDSINTOWN_API_KEY=
|
||||
TMDB_API_KEY=
|
||||
LIBRETRANSLATE_URL=
|
||||
|
||||
# Scheduler times (24h UTC)
|
||||
SCHEDULE_HOLIDAYS_HOUR=7
|
||||
SCHEDULE_HOLIDAYS_MINUTE=0
|
||||
SCHEDULE_RELEASES_HOUR=19
|
||||
SCHEDULE_RELEASES_MINUTE=0
|
||||
|
||||
# Feature flags
|
||||
FEATURE_SHADE=false
|
||||
FEATURE_URL_PREVIEW=true
|
||||
|
||||
# Crypto reset (set to true to wipe and re-establish E2EE keys on next startup)
|
||||
CRYPTO_RESET=false
|
||||
|
||||
# LLM passive classifier (requires Ollama — leave blank to disable)
|
||||
OLLAMA_HOST=
|
||||
OLLAMA_MODEL=
|
||||
LLM_SAMPLE_RATE=0.15
|
||||
|
||||
# Rate limits (calls per user per day, 0 = unlimited)
|
||||
RATELIMIT_WEATHER=5
|
||||
RATELIMIT_TRANSLATE=20
|
||||
RATELIMIT_CONCERTS=10
|
||||
|
||||
# Bot default city for concert digest and location-based features
|
||||
BOT_DEFAULT_CITY=
|
||||
|
||||
# Trivia
|
||||
TRIVIA_TIMEOUT_SECONDS=20
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
dist/
|
||||
data/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
node_modules/
|
||||
merge_env.py
|
||||
Twinbee Build Plan.md
|
||||
12
Dockerfile
Normal file
12
Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
COPY tsconfig.json ./
|
||||
COPY src/ ./src/
|
||||
RUN npx tsc
|
||||
|
||||
CMD ["node", "dist/index.js"]
|
||||
9
docker-compose.yml
Normal file
9
docker-compose.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
services:
|
||||
freebee:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
volumes:
|
||||
# CRITICAL: The crypto/ subdirectory contains the bot's device identity.
|
||||
# Losing it means the bot must re-verify in all encrypted rooms.
|
||||
- ./data:/app/data
|
||||
2341
package-lock.json
generated
Normal file
2341
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
package.json
Normal file
34
package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "freebee",
|
||||
"version": "1.0.0",
|
||||
"description": "A full-featured Matrix community bot with E2EE support, plugin architecture, and community engagement features",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsx src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^9.4.3",
|
||||
"chrono-node": "^2.7.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"howlongtobeat": "^1.8.0",
|
||||
"luxon": "^3.4.4",
|
||||
"mathjs": "^15.1.1",
|
||||
"matrix-js-sdk": "^41.1.0-rc.0",
|
||||
"node-html-parser": "^6.1.13",
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^9.0.1",
|
||||
"winston": "^3.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.8",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^20.11.30",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.4.3"
|
||||
}
|
||||
}
|
||||
522
src/db/index.ts
Normal file
522
src/db/index.ts
Normal file
@@ -0,0 +1,522 @@
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
let db: Database.Database;
|
||||
|
||||
export function getDb(): Database.Database {
|
||||
if (!db) throw new Error("Database not initialized. Call initDb() first.");
|
||||
return db;
|
||||
}
|
||||
|
||||
export function initDb(dataDir: string): Database.Database {
|
||||
const dbPath = path.join(dataDir, "freebee.db");
|
||||
db = new Database(dbPath);
|
||||
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.pragma("foreign_keys = ON");
|
||||
|
||||
createTables();
|
||||
runMigrations();
|
||||
seedSchedulerDefaults();
|
||||
|
||||
logger.info(`Database initialized at ${dbPath}`);
|
||||
return db;
|
||||
}
|
||||
|
||||
function createTables(): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
user_id TEXT NOT NULL,
|
||||
room_id TEXT NOT NULL,
|
||||
xp INTEGER NOT NULL DEFAULT 0,
|
||||
level INTEGER NOT NULL DEFAULT 1,
|
||||
rep INTEGER NOT NULL DEFAULT 0,
|
||||
timezone TEXT,
|
||||
PRIMARY KEY (user_id, room_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_stats (
|
||||
user_id TEXT NOT NULL,
|
||||
room_id TEXT NOT NULL,
|
||||
total_messages INTEGER NOT NULL DEFAULT 0,
|
||||
total_words INTEGER NOT NULL DEFAULT 0,
|
||||
total_characters INTEGER NOT NULL DEFAULT 0,
|
||||
total_links INTEGER NOT NULL DEFAULT 0,
|
||||
total_images INTEGER NOT NULL DEFAULT 0,
|
||||
total_questions INTEGER NOT NULL DEFAULT 0,
|
||||
total_exclamations INTEGER NOT NULL DEFAULT 0,
|
||||
total_emojis INTEGER NOT NULL DEFAULT 0,
|
||||
longest_message INTEGER NOT NULL DEFAULT 0,
|
||||
shortest_message INTEGER,
|
||||
avg_words_per_message REAL NOT NULL DEFAULT 0,
|
||||
hourly_distribution TEXT NOT NULL DEFAULT '{}',
|
||||
daily_distribution TEXT NOT NULL DEFAULT '{}',
|
||||
current_streak INTEGER NOT NULL DEFAULT 0,
|
||||
longest_streak INTEGER NOT NULL DEFAULT 0,
|
||||
last_active_date TEXT,
|
||||
PRIMARY KEY (user_id, room_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS xp_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
room_id TEXT NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
granted_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rep_cooldowns (
|
||||
giver_id TEXT NOT NULL,
|
||||
receiver_id TEXT NOT NULL,
|
||||
room_id TEXT NOT NULL,
|
||||
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (giver_id, receiver_id, room_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reminders (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
room_id TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
remind_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
fired INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS daily_activity (
|
||||
user_id TEXT NOT NULL,
|
||||
room_id TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
message_count INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (user_id, room_id, date)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS daily_first (
|
||||
room_id TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
PRIMARY KEY (room_id, date)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wotd_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
room_id TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
word TEXT NOT NULL,
|
||||
definition TEXT,
|
||||
example TEXT,
|
||||
part_of_speech TEXT,
|
||||
posted_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(room_id, date)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wotd_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
room_id TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
xp_awarded INTEGER NOT NULL DEFAULT 0,
|
||||
detected_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id, room_id, date)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS holidays_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
room_id TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
holidays_json TEXT NOT NULL,
|
||||
posted_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(room_id, date)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS releases_cache (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
game_name TEXT NOT NULL,
|
||||
game_slug TEXT,
|
||||
release_date TEXT,
|
||||
platforms TEXT,
|
||||
genres TEXT,
|
||||
rating REAL,
|
||||
data_json TEXT NOT NULL,
|
||||
fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release_watchlist (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
room_id TEXT NOT NULL,
|
||||
game_name TEXT NOT NULL,
|
||||
game_slug TEXT,
|
||||
release_date TEXT,
|
||||
notified_day_before INTEGER NOT NULL DEFAULT 0,
|
||||
notified_day_of INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id, room_id, game_name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS hltb_cache (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
game_name TEXT NOT NULL,
|
||||
search_term TEXT NOT NULL,
|
||||
main_story REAL,
|
||||
main_extra REAL,
|
||||
completionist REAL,
|
||||
data_json TEXT NOT NULL,
|
||||
fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS achievements (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
room_id TEXT NOT NULL,
|
||||
achievement_key TEXT NOT NULL,
|
||||
unlocked_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id, room_id, achievement_key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS quotes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
room_id TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
quoted_by TEXT NOT NULL,
|
||||
event_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS now_playing (
|
||||
user_id TEXT NOT NULL,
|
||||
room_id TEXT NOT NULL,
|
||||
game TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (user_id, room_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS backlog (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
room_id TEXT NOT NULL,
|
||||
game TEXT NOT NULL,
|
||||
completed INTEGER NOT NULL DEFAULT 0,
|
||||
added_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
completed_at TEXT,
|
||||
UNIQUE(user_id, room_id, game)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS predictions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
room_id TEXT NOT NULL,
|
||||
prediction TEXT NOT NULL,
|
||||
outcome TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
resolved_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS keyword_watches (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
room_id TEXT NOT NULL,
|
||||
keyword TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id, room_id, keyword)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scheduler_config (
|
||||
job_name TEXT PRIMARY KEY,
|
||||
hour INTEGER NOT NULL,
|
||||
minute INTEGER NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS shade_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
target_id TEXT,
|
||||
room_id TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
confidence REAL,
|
||||
classified_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS shade_optout (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
opted_out_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Birthdays
|
||||
CREATE TABLE IF NOT EXISTS birthdays (
|
||||
user_id TEXT NOT NULL,
|
||||
room_id TEXT NOT NULL,
|
||||
month INTEGER NOT NULL,
|
||||
day INTEGER NOT NULL,
|
||||
year INTEGER,
|
||||
PRIMARY KEY (user_id, room_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS birthday_fired (
|
||||
user_id TEXT NOT NULL,
|
||||
year INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, year)
|
||||
);
|
||||
|
||||
-- Trivia
|
||||
CREATE TABLE IF NOT EXISTS trivia_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
room_id TEXT NOT NULL,
|
||||
question TEXT NOT NULL,
|
||||
answer TEXT NOT NULL,
|
||||
category TEXT,
|
||||
difficulty TEXT,
|
||||
asked_at INTEGER DEFAULT (unixepoch()),
|
||||
answered_by TEXT,
|
||||
answered_at INTEGER,
|
||||
correct INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS trivia_scores (
|
||||
user_id TEXT NOT NULL,
|
||||
room_id TEXT NOT NULL,
|
||||
total_correct INTEGER DEFAULT 0,
|
||||
total_points INTEGER DEFAULT 0,
|
||||
total_answered INTEGER DEFAULT 0,
|
||||
current_streak INTEGER DEFAULT 0,
|
||||
best_streak INTEGER DEFAULT 0,
|
||||
fastest_ms INTEGER,
|
||||
PRIMARY KEY (user_id, room_id)
|
||||
);
|
||||
|
||||
-- LLM passive classification
|
||||
CREATE TABLE IF NOT EXISTS llm_classifications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
room_id TEXT NOT NULL,
|
||||
event_id TEXT NOT NULL,
|
||||
profanity INTEGER DEFAULT 0,
|
||||
profanity_severity INTEGER DEFAULT 0,
|
||||
insult INTEGER DEFAULT 0,
|
||||
insult_type TEXT,
|
||||
insult_target TEXT,
|
||||
insult_severity INTEGER DEFAULT 0,
|
||||
sentiment TEXT DEFAULT 'neutral',
|
||||
gratitude INTEGER DEFAULT 0,
|
||||
gratitude_toward TEXT,
|
||||
wotd_used INTEGER DEFAULT 0,
|
||||
wotd_correct INTEGER DEFAULT 0,
|
||||
classified_at INTEGER DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS potty_mouth (
|
||||
user_id TEXT NOT NULL,
|
||||
room_id TEXT NOT NULL,
|
||||
total INTEGER DEFAULT 0,
|
||||
severity_1 INTEGER DEFAULT 0,
|
||||
severity_2 INTEGER DEFAULT 0,
|
||||
severity_3 INTEGER DEFAULT 0,
|
||||
PRIMARY KEY (user_id, room_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS insult_log (
|
||||
user_id TEXT NOT NULL,
|
||||
room_id TEXT NOT NULL,
|
||||
total_thrown INTEGER DEFAULT 0,
|
||||
direct_thrown INTEGER DEFAULT 0,
|
||||
indirect_thrown INTEGER DEFAULT 0,
|
||||
times_targeted INTEGER DEFAULT 0,
|
||||
PRIMARY KEY (user_id, room_id)
|
||||
);
|
||||
|
||||
-- Stocks
|
||||
CREATE TABLE IF NOT EXISTS stocks_cache (
|
||||
ticker TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
cached_at INTEGER DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stock_watchlist (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
room_id TEXT NOT NULL,
|
||||
ticker TEXT NOT NULL,
|
||||
created_at INTEGER DEFAULT (unixepoch()),
|
||||
UNIQUE(user_id, room_id, ticker)
|
||||
);
|
||||
|
||||
-- Rate limiting
|
||||
CREATE TABLE IF NOT EXISTS command_usage (
|
||||
user_id TEXT NOT NULL,
|
||||
command TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
count INTEGER DEFAULT 0,
|
||||
PRIMARY KEY (user_id, command, date)
|
||||
);
|
||||
|
||||
-- Concerts
|
||||
CREATE TABLE IF NOT EXISTS concerts_cache (
|
||||
location_key TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
cached_at INTEGER DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS concert_watchlist (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
room_id TEXT NOT NULL,
|
||||
artist TEXT NOT NULL,
|
||||
created_at INTEGER DEFAULT (unixepoch()),
|
||||
UNIQUE(user_id, room_id, artist)
|
||||
);
|
||||
|
||||
-- Anime
|
||||
CREATE TABLE IF NOT EXISTS anime_watchlist (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
room_id TEXT NOT NULL,
|
||||
mal_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
airing_date TEXT,
|
||||
notified INTEGER DEFAULT 0,
|
||||
created_at INTEGER DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS anime_cache (
|
||||
mal_id INTEGER PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
cached_at INTEGER DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
-- Movies/TV
|
||||
CREATE TABLE IF NOT EXISTS movie_watchlist (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
room_id TEXT NOT NULL,
|
||||
tmdb_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
media_type TEXT NOT NULL,
|
||||
release_date TEXT,
|
||||
notified INTEGER DEFAULT 0,
|
||||
created_at INTEGER DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS movie_cache (
|
||||
tmdb_id INTEGER NOT NULL,
|
||||
media_type TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
cached_at INTEGER DEFAULT (unixepoch()),
|
||||
PRIMARY KEY (tmdb_id, media_type)
|
||||
);
|
||||
|
||||
-- Countdowns
|
||||
CREATE TABLE IF NOT EXISTS countdowns (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
room_id TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
target_date TEXT NOT NULL,
|
||||
public INTEGER DEFAULT 1,
|
||||
completed INTEGER DEFAULT 0,
|
||||
created_at INTEGER DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
-- Presence
|
||||
CREATE TABLE IF NOT EXISTS presence (
|
||||
user_id TEXT NOT NULL,
|
||||
room_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'online',
|
||||
status_message TEXT,
|
||||
updated_at INTEGER DEFAULT (unixepoch()),
|
||||
PRIMARY KEY (user_id, room_id)
|
||||
);
|
||||
|
||||
-- Markov corpus
|
||||
CREATE TABLE IF NOT EXISTS markov_corpus (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
room_id TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
added_at INTEGER DEFAULT (unixepoch())
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_markov_user_room ON markov_corpus (user_id, room_id);
|
||||
|
||||
-- Retro game cache (GiantBomb)
|
||||
CREATE TABLE IF NOT EXISTS retro_cache (
|
||||
search_term TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
cached_at INTEGER DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
-- Urban Dictionary cache
|
||||
CREATE TABLE IF NOT EXISTS urban_cache (
|
||||
term TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
cached_at INTEGER DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
-- Room message milestones
|
||||
CREATE TABLE IF NOT EXISTS room_milestones (
|
||||
room_id TEXT PRIMARY KEY,
|
||||
total_messages INTEGER NOT NULL DEFAULT 0,
|
||||
last_milestone INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- Reaction tracking
|
||||
CREATE TABLE IF NOT EXISTS reaction_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
giver_id TEXT NOT NULL,
|
||||
receiver_id TEXT NOT NULL,
|
||||
room_id TEXT NOT NULL,
|
||||
emoji TEXT NOT NULL,
|
||||
event_id TEXT NOT NULL,
|
||||
created_at INTEGER DEFAULT (unixepoch())
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_reaction_room ON reaction_log (room_id);
|
||||
|
||||
-- URL preview cache
|
||||
CREATE TABLE IF NOT EXISTS url_cache (
|
||||
url TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
cached_at INTEGER DEFAULT (unixepoch())
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
function runMigrations(): void {
|
||||
const cols = db
|
||||
.prepare(`PRAGMA table_info(llm_classifications)`)
|
||||
.all() as { name: string }[];
|
||||
if (cols.length > 0) {
|
||||
if (!cols.find((c) => c.name === "sentiment")) {
|
||||
db.exec(`ALTER TABLE llm_classifications ADD COLUMN sentiment TEXT DEFAULT 'neutral'`);
|
||||
logger.info("Migration: added sentiment column to llm_classifications");
|
||||
}
|
||||
if (!cols.find((c) => c.name === "gratitude")) {
|
||||
db.exec(`ALTER TABLE llm_classifications ADD COLUMN gratitude INTEGER DEFAULT 0`);
|
||||
db.exec(`ALTER TABLE llm_classifications ADD COLUMN gratitude_toward TEXT`);
|
||||
logger.info("Migration: added gratitude columns to llm_classifications");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function seedSchedulerDefaults(): void {
|
||||
const insert = db.prepare(`
|
||||
INSERT OR IGNORE INTO scheduler_config (job_name, hour, minute, enabled)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const holidaysHour = parseInt(process.env.SCHEDULE_HOLIDAYS_HOUR ?? "7", 10);
|
||||
const holidaysMinute = parseInt(process.env.SCHEDULE_HOLIDAYS_MINUTE ?? "0", 10);
|
||||
const releasesHour = parseInt(process.env.SCHEDULE_RELEASES_HOUR ?? "19", 10);
|
||||
const releasesMinute = parseInt(process.env.SCHEDULE_RELEASES_MINUTE ?? "0", 10);
|
||||
|
||||
insert.run("holidays", holidaysHour, holidaysMinute, 1);
|
||||
insert.run("releases", releasesHour, releasesMinute, 1);
|
||||
insert.run("wotd", 8, 0, 1);
|
||||
insert.run("birthday_check", 7, 5, 1);
|
||||
insert.run("anime_releases", 19, 30, 1);
|
||||
insert.run("movie_releases", 20, 0, 1);
|
||||
insert.run("concert_digest", 10, 0, 1);
|
||||
}
|
||||
363
src/index.ts
Normal file
363
src/index.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import "dotenv/config";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { BotClient } from "./matrix-client";
|
||||
import { initDb } from "./db";
|
||||
import logger from "./utils/logger";
|
||||
import { loginWithPassword, isTokenValid } from "./utils/auth";
|
||||
import { PluginRegistry } from "./plugins/base";
|
||||
import { XpPlugin } from "./plugins/xp";
|
||||
import { ReputationPlugin } from "./plugins/reputation";
|
||||
import { StatsPlugin } from "./plugins/stats";
|
||||
import { StreaksPlugin } from "./plugins/streaks";
|
||||
import { RemindersPlugin } from "./plugins/reminders";
|
||||
import { UserPlugin } from "./plugins/user";
|
||||
import { FunPlugin } from "./plugins/fun";
|
||||
import { WotdPlugin } from "./plugins/wotd";
|
||||
import { HolidaysPlugin } from "./plugins/holidays";
|
||||
import { GamingPlugin } from "./plugins/gaming";
|
||||
import { DailyScheduler } from "./plugins/daily";
|
||||
import { AchievementsPlugin } from "./plugins/achievements";
|
||||
import { ShadePlugin } from "./plugins/shade";
|
||||
import { RateLimitsPlugin } from "./plugins/ratelimits";
|
||||
import { BirthdayPlugin } from "./plugins/birthday";
|
||||
import { TriviaPlugin } from "./plugins/trivia";
|
||||
import { LlmPassivePlugin } from "./plugins/llm-passive";
|
||||
import { StocksPlugin } from "./plugins/stocks";
|
||||
import { ConcertsPlugin } from "./plugins/concerts";
|
||||
import { AnimePlugin } from "./plugins/anime";
|
||||
import { MoviesPlugin } from "./plugins/movies";
|
||||
import { LookupPlugin } from "./plugins/lookup";
|
||||
import { PresencePlugin } from "./plugins/presence";
|
||||
import { CountdownPlugin } from "./plugins/countdown";
|
||||
import { WelcomePlugin } from "./plugins/welcome";
|
||||
import { MarkovPlugin } from "./plugins/markov";
|
||||
import { UrlsPlugin } from "./plugins/urls";
|
||||
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");
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure we have a valid access token. If the current token is invalid and a
|
||||
* password is configured, log in automatically and persist the new token.
|
||||
*/
|
||||
async function resolveAccessToken(
|
||||
homeserverUrl: string,
|
||||
botUserId: string,
|
||||
currentToken: string | undefined,
|
||||
password: string | undefined,
|
||||
dataDir: string
|
||||
): Promise<string> {
|
||||
const deviceFile = path.join(dataDir, "device.json");
|
||||
const hasDeviceFile = fs.existsSync(deviceFile);
|
||||
|
||||
// If we have a stored device identity AND a valid token, reuse them
|
||||
if (hasDeviceFile && currentToken) {
|
||||
const valid = await isTokenValid(homeserverUrl, currentToken);
|
||||
if (valid) {
|
||||
logger.info("Access token is valid, device identity exists");
|
||||
return currentToken;
|
||||
}
|
||||
logger.warn("Access token is invalid or expired");
|
||||
}
|
||||
|
||||
// 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) {
|
||||
logger.info("No device.json found — fresh crypto store requires a new device via password login");
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
if (!hasDeviceFile) {
|
||||
logger.error(
|
||||
"No device.json found and no MATRIX_BOT_PASSWORD set. " +
|
||||
"A fresh crypto store requires a password login to create a new device. " +
|
||||
"Set MATRIX_BOT_PASSWORD to proceed."
|
||||
);
|
||||
} else {
|
||||
logger.error(
|
||||
"Access token is invalid and no MATRIX_BOT_PASSWORD is set. " +
|
||||
"Provide a valid MATRIX_ACCESS_TOKEN or set MATRIX_BOT_PASSWORD for automatic renewal."
|
||||
);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logger.info("Performing password login to obtain a new access token and device...");
|
||||
|
||||
// Reuse existing device ID only if we have the crypto store to match
|
||||
let existingDeviceId: string | undefined;
|
||||
if (hasDeviceFile) {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(deviceFile, "utf-8"));
|
||||
existingDeviceId = data.deviceId;
|
||||
} catch { /* ignore parse errors */ }
|
||||
}
|
||||
|
||||
const loginResult = await loginWithPassword(homeserverUrl, botUserId, password, existingDeviceId);
|
||||
|
||||
// Save the new device ID
|
||||
fs.writeFileSync(deviceFile, JSON.stringify({ deviceId: loginResult.device_id }), "utf-8");
|
||||
logger.info(`Device identity saved: ${loginResult.device_id}`);
|
||||
|
||||
updateEnvToken(loginResult.access_token);
|
||||
return loginResult.access_token;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const dataDir = process.env.DATA_DIR ?? "./data";
|
||||
const homeserverUrl = process.env.MATRIX_HOMESERVER_URL;
|
||||
const botUserId = process.env.MATRIX_BOT_USER_ID;
|
||||
const botPassword = process.env.MATRIX_BOT_PASSWORD;
|
||||
|
||||
if (!homeserverUrl || !botUserId) {
|
||||
logger.error("Missing required env vars: MATRIX_HOMESERVER_URL, MATRIX_BOT_USER_ID");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!process.env.MATRIX_ACCESS_TOKEN && !botPassword) {
|
||||
logger.error("Provide either MATRIX_ACCESS_TOKEN or MATRIX_BOT_PASSWORD (or both)");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Ensure data directory exists
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
|
||||
// Crypto reset: wipe local crypto store and device identity
|
||||
const resetMarker = path.join(dataDir, ".crypto_reset_needed");
|
||||
const needsCryptoReset = process.env.CRYPTO_RESET === "true" || fs.existsSync(resetMarker);
|
||||
if (needsCryptoReset) {
|
||||
const cryptoDir = path.join(dataDir, "crypto-js");
|
||||
const deviceFile = path.join(dataDir, "device.json");
|
||||
|
||||
// Try to delete the old device from the server
|
||||
let oldDeviceId: string | undefined;
|
||||
if (fs.existsSync(deviceFile)) {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(deviceFile, "utf-8"));
|
||||
oldDeviceId = data.deviceId;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (oldDeviceId && process.env.MATRIX_ACCESS_TOKEN) {
|
||||
try {
|
||||
const res = await fetch(`${homeserverUrl}/_matrix/client/v3/devices/${encodeURIComponent(oldDeviceId)}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${process.env.MATRIX_ACCESS_TOKEN}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ auth: { type: "m.login.password" } }),
|
||||
});
|
||||
if (res.ok || res.status === 401) {
|
||||
logger.info(`Deleted old device ${oldDeviceId} from server`);
|
||||
} else {
|
||||
logger.warn(`Could not delete old device ${oldDeviceId}: ${res.status}`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Could not delete old device ${oldDeviceId}: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (fs.existsSync(cryptoDir)) fs.rmSync(cryptoDir, { recursive: true });
|
||||
if (fs.existsSync(deviceFile)) fs.unlinkSync(deviceFile);
|
||||
if (fs.existsSync(resetMarker)) fs.unlinkSync(resetMarker);
|
||||
logger.info("Crypto reset: wiped crypto store and device.json. Starting with fresh device identity.");
|
||||
}
|
||||
|
||||
// Initialize database
|
||||
initDb(dataDir);
|
||||
|
||||
// Validate / refresh the access token
|
||||
const accessToken = await resolveAccessToken(
|
||||
homeserverUrl,
|
||||
botUserId,
|
||||
process.env.MATRIX_ACCESS_TOKEN,
|
||||
botPassword,
|
||||
dataDir
|
||||
);
|
||||
|
||||
// Create the BotClient (wraps matrix-js-sdk with automatic E2EE)
|
||||
const botClient = BotClient.create({
|
||||
homeserverUrl,
|
||||
accessToken,
|
||||
userId: botUserId,
|
||||
dataDir,
|
||||
});
|
||||
|
||||
// Parse bot rooms
|
||||
const botRooms = (process.env.BOT_ROOMS ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// Instantiate plugins in dependency order
|
||||
const xpPlugin = new XpPlugin(botClient);
|
||||
const repPlugin = new ReputationPlugin(botClient, xpPlugin);
|
||||
const statsPlugin = new StatsPlugin(botClient);
|
||||
const streaksPlugin = new StreaksPlugin(botClient);
|
||||
const remindersPlugin = new RemindersPlugin(botClient);
|
||||
const userPlugin = new UserPlugin(botClient);
|
||||
const funPlugin = new FunPlugin(botClient);
|
||||
const wotdPlugin = new WotdPlugin(botClient, xpPlugin);
|
||||
const holidaysPlugin = new HolidaysPlugin(botClient);
|
||||
const gamingPlugin = new GamingPlugin(botClient);
|
||||
const achievementsPlugin = new AchievementsPlugin(botClient);
|
||||
const rateLimitPlugin = new RateLimitsPlugin(botClient);
|
||||
const birthdayPlugin = new BirthdayPlugin(botClient, xpPlugin);
|
||||
const triviaPlugin = new TriviaPlugin(botClient, xpPlugin);
|
||||
const llmPassivePlugin = new LlmPassivePlugin(botClient, xpPlugin);
|
||||
const stocksPlugin = new StocksPlugin(botClient);
|
||||
const concertsPlugin = new ConcertsPlugin(botClient, rateLimitPlugin);
|
||||
const animePlugin = new AnimePlugin(botClient);
|
||||
const moviesPlugin = new MoviesPlugin(botClient);
|
||||
const lookupPlugin = new LookupPlugin(botClient, rateLimitPlugin);
|
||||
const presencePlugin = new PresencePlugin(botClient);
|
||||
const countdownPlugin = new CountdownPlugin(botClient);
|
||||
const markovPlugin = new MarkovPlugin(botClient);
|
||||
const urlsPlugin = new UrlsPlugin(botClient);
|
||||
const toolsPlugin = new ToolsPlugin(botClient);
|
||||
const reactionsPlugin = new ReactionsPlugin(botClient);
|
||||
const botInfoPlugin = new BotInfoPlugin(botClient);
|
||||
const retroPlugin = new RetroPlugin(botClient);
|
||||
|
||||
const dailyScheduler = new DailyScheduler(
|
||||
botClient,
|
||||
remindersPlugin,
|
||||
wotdPlugin,
|
||||
holidaysPlugin,
|
||||
gamingPlugin,
|
||||
birthdayPlugin,
|
||||
animePlugin,
|
||||
moviesPlugin,
|
||||
concertsPlugin,
|
||||
botRooms
|
||||
);
|
||||
|
||||
// Register plugins with the registry
|
||||
const registry = new PluginRegistry(botUserId);
|
||||
|
||||
// XP first (other plugins depend on it being processed first for passive XP)
|
||||
registry.register(xpPlugin);
|
||||
registry.register(repPlugin);
|
||||
registry.register(statsPlugin);
|
||||
registry.register(streaksPlugin);
|
||||
registry.register(presencePlugin);
|
||||
registry.register(remindersPlugin);
|
||||
registry.register(userPlugin);
|
||||
registry.register(funPlugin);
|
||||
registry.register(wotdPlugin);
|
||||
registry.register(holidaysPlugin);
|
||||
registry.register(gamingPlugin);
|
||||
registry.register(rateLimitPlugin);
|
||||
registry.register(birthdayPlugin);
|
||||
registry.register(triviaPlugin);
|
||||
registry.register(llmPassivePlugin);
|
||||
registry.register(stocksPlugin);
|
||||
registry.register(concertsPlugin);
|
||||
registry.register(animePlugin);
|
||||
registry.register(moviesPlugin);
|
||||
registry.register(lookupPlugin);
|
||||
registry.register(countdownPlugin);
|
||||
registry.register(markovPlugin);
|
||||
registry.register(urlsPlugin);
|
||||
registry.register(toolsPlugin);
|
||||
registry.register(reactionsPlugin);
|
||||
registry.register(botInfoPlugin);
|
||||
registry.register(retroPlugin);
|
||||
registry.register(achievementsPlugin);
|
||||
registry.register(dailyScheduler);
|
||||
|
||||
// Welcome plugin needs registry reference for !help
|
||||
const welcomePlugin = new WelcomePlugin(botClient, xpPlugin, registry);
|
||||
registry.register(welcomePlugin);
|
||||
|
||||
// Register shade plugin only if feature flag is enabled
|
||||
if (process.env.FEATURE_SHADE === "true") {
|
||||
const shadePlugin = new ShadePlugin(botClient);
|
||||
registry.register(shadePlugin);
|
||||
}
|
||||
|
||||
// Wire Matrix events to plugin registry via BotClient
|
||||
botClient.onMessage(async (roomId: string, event: any) => {
|
||||
await registry.dispatch(roomId, event);
|
||||
});
|
||||
|
||||
botClient.onReaction(async (roomId: string, event: any) => {
|
||||
await registry.dispatchReaction(roomId, event);
|
||||
});
|
||||
|
||||
// Start the scheduler
|
||||
dailyScheduler.start();
|
||||
|
||||
// Start the client (initializes crypto + starts syncing)
|
||||
await botClient.start();
|
||||
|
||||
logger.info(`Freebee started as ${botUserId}`);
|
||||
logger.info(`Listening in ${botRooms.length} configured room(s)`);
|
||||
logger.info(`Registered ${registry["plugins"].length} plugin(s)`);
|
||||
logger.info(`E2EE is handled automatically by matrix-js-sdk (Rust crypto)`);
|
||||
|
||||
// Kick-start key exchange in bot rooms (non-blocking).
|
||||
// Sending an encrypted message advertises our device to room members.
|
||||
if (botRooms.length > 0) {
|
||||
(async () => {
|
||||
for (const roomId of botRooms) {
|
||||
try {
|
||||
if (!botClient.isRoomEncrypted(roomId)) continue;
|
||||
if (needsCryptoReset) {
|
||||
await botClient.sendNotice(roomId, "Encryption keys have been refreshed.");
|
||||
} else {
|
||||
await botClient.sendNotice(roomId, "\u200B");
|
||||
}
|
||||
logger.info(`Key exchange ping sent to ${roomId}`);
|
||||
} catch (err) {
|
||||
logger.warn(`Key exchange message failed for ${roomId}: ${err}`);
|
||||
}
|
||||
}
|
||||
})().catch((err) => logger.error(`Key exchange loop failed: ${err}`));
|
||||
}
|
||||
|
||||
// Catch up missed scheduled jobs after sync is ready
|
||||
dailyScheduler.catchUpMissedJobs().catch((err) => {
|
||||
logger.error(`Catch-up failed: ${err}`);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = () => {
|
||||
logger.info("Shutting down...");
|
||||
dailyScheduler.stop();
|
||||
botClient.stop();
|
||||
// Delete device.json so next start creates a fresh device via password login.
|
||||
// The in-memory IndexedDB crypto store doesn't persist, so reusing the same
|
||||
// device ID would cause OTK conflicts with the server.
|
||||
const deviceFile = path.join(dataDir, "device.json");
|
||||
try { if (fs.existsSync(deviceFile)) fs.unlinkSync(deviceFile); } catch { /* ignore */ }
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
logger.error(`Fatal startup error: ${err}`);
|
||||
process.exit(1);
|
||||
});
|
||||
328
src/matrix-client.ts
Normal file
328
src/matrix-client.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Compatibility wrapper around matrix-js-sdk that exposes the same interface
|
||||
* our plugins expect from matrix-bot-sdk. This lets us swap the underlying
|
||||
* SDK without changing any plugin code.
|
||||
*/
|
||||
import * as sdk from "matrix-js-sdk";
|
||||
import { RoomEvent } from "matrix-js-sdk";
|
||||
import type { MatrixEvent } from "matrix-js-sdk";
|
||||
import type { Room } from "matrix-js-sdk";
|
||||
// Suppress matrix-js-sdk's verbose HTTP logging
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
try { (require("matrix-js-sdk/lib/logger").logger as any).setLevel("warn"); } catch { /* ignore */ }
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import logger from "./utils/logger";
|
||||
|
||||
export interface BotClientOptions {
|
||||
homeserverUrl: string;
|
||||
accessToken: string;
|
||||
userId: string;
|
||||
dataDir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps matrix-js-sdk's MatrixClient with the API surface our plugins use.
|
||||
* Crypto is handled automatically by matrix-js-sdk — no manual key management.
|
||||
*/
|
||||
export class BotClient {
|
||||
private client: sdk.MatrixClient;
|
||||
private opts: BotClientOptions;
|
||||
private _userId: string;
|
||||
private started = false;
|
||||
|
||||
// Event callbacks
|
||||
private messageHandlers: ((roomId: string, event: any) => Promise<void>)[] = [];
|
||||
private reactionHandlers: ((roomId: string, event: any) => Promise<void>)[] = [];
|
||||
|
||||
private constructor(opts: BotClientOptions, deviceId: string) {
|
||||
this.opts = opts;
|
||||
this._userId = opts.userId;
|
||||
|
||||
const storeDir = path.join(opts.dataDir, "store");
|
||||
if (!fs.existsSync(storeDir)) fs.mkdirSync(storeDir, { recursive: true });
|
||||
|
||||
this.client = sdk.createClient({
|
||||
baseUrl: opts.homeserverUrl,
|
||||
accessToken: opts.accessToken,
|
||||
userId: opts.userId,
|
||||
deviceId,
|
||||
store: new sdk.MemoryStore(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a BotClient. Reads device ID from device.json (must exist).
|
||||
*/
|
||||
static create(opts: BotClientOptions): BotClient {
|
||||
const deviceFile = path.join(opts.dataDir, "device.json");
|
||||
let deviceId: string | undefined;
|
||||
try {
|
||||
if (fs.existsSync(deviceFile)) {
|
||||
const data = JSON.parse(fs.readFileSync(deviceFile, "utf-8"));
|
||||
deviceId = data.deviceId;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
if (!deviceId) {
|
||||
throw new Error(
|
||||
"No device.json found. The access token must be obtained via password login " +
|
||||
"to create a device identity. Set MATRIX_BOT_PASSWORD."
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`Using device ID: ${deviceId}`);
|
||||
return new BotClient(opts, deviceId);
|
||||
}
|
||||
|
||||
private saveDeviceId(): void {
|
||||
const deviceId = this.client.getDeviceId();
|
||||
if (deviceId) {
|
||||
const deviceFile = path.join(this.opts.dataDir, "device.json");
|
||||
fs.writeFileSync(deviceFile, JSON.stringify({ deviceId }), "utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize crypto and start syncing.
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
// Initialize rust crypto — this handles everything:
|
||||
// key management, session rotation, key gossiping, device tracking
|
||||
const cryptoDir = path.join(this.opts.dataDir, "crypto-js");
|
||||
if (!fs.existsSync(cryptoDir)) fs.mkdirSync(cryptoDir, { recursive: true });
|
||||
|
||||
await this.client.initRustCrypto({
|
||||
cryptoDatabasePrefix: cryptoDir + "/",
|
||||
});
|
||||
|
||||
// Save device ID for persistence across restarts
|
||||
this.saveDeviceId();
|
||||
|
||||
logger.info(`E2EE initialized with device ${this.client.getDeviceId()}`);
|
||||
|
||||
// Wire up event listeners
|
||||
this.client.on(RoomEvent.Timeline, (event: MatrixEvent, room: Room | undefined) => {
|
||||
if (!room) return;
|
||||
const roomId = room.roomId;
|
||||
|
||||
if (event.isBeingDecrypted() || event.getType() === "m.room.encrypted") {
|
||||
// Wait for decryption to complete (or fail)
|
||||
event.once("Event.decrypted" as any, () => {
|
||||
if (event.getType() === "m.room.encrypted") {
|
||||
// Decryption failed — still encrypted
|
||||
logger.warn(
|
||||
`Unable to decrypt event ${event.getId()} from ${event.getSender()} ` +
|
||||
`in ${roomId} (session: ${(event.getContent() as any)?.session_id?.substring(0, 8)}...)`
|
||||
);
|
||||
} else {
|
||||
this.handleTimelineEvent(roomId, event);
|
||||
}
|
||||
});
|
||||
|
||||
// Timeout: if decryption doesn't happen within 10s, the event is likely undecryptable
|
||||
setTimeout(() => {
|
||||
if (event.getType() === "m.room.encrypted") {
|
||||
logger.warn(
|
||||
`Decryption timeout for ${event.getId()} from ${event.getSender()} in ${roomId}`
|
||||
);
|
||||
}
|
||||
}, 10_000);
|
||||
} else {
|
||||
this.handleTimelineEvent(roomId, event);
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-join on invite
|
||||
this.client.on("RoomMember.membership" as any, (_event: any, member: any) => {
|
||||
if (member.membership === "invite" && member.userId === this._userId) {
|
||||
this.client.joinRoom(member.roomId).catch((err: any) => {
|
||||
logger.error(`Failed to auto-join ${member.roomId}: ${err}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Register sync listener BEFORE starting client to avoid race condition
|
||||
const syncReady = new Promise<void>((resolve) => {
|
||||
const onSync = (state: string) => {
|
||||
if (state === "PREPARED" || state === "SYNCING") {
|
||||
logger.info(`Initial sync complete (state: ${state})`);
|
||||
this.started = true;
|
||||
this.client.removeListener("sync" as any, onSync);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
this.client.on("sync" as any, onSync);
|
||||
});
|
||||
|
||||
// Start syncing
|
||||
await this.client.startClient({ initialSyncLimit: 10 });
|
||||
|
||||
// Wait for first sync to complete
|
||||
await syncReady;
|
||||
}
|
||||
|
||||
private handleTimelineEvent(roomId: string, event: MatrixEvent): void {
|
||||
const type = event.getType();
|
||||
const sender = event.getSender();
|
||||
const age = Date.now() - event.getTs();
|
||||
|
||||
logger.debug(`Timeline event: type=${type} sender=${sender} age=${Math.round(age / 1000)}s room=${roomId}`);
|
||||
|
||||
// Skip own messages
|
||||
if (sender === this._userId) return;
|
||||
// Skip non-live events (historical)
|
||||
if (age > 60_000) return;
|
||||
|
||||
const rawEvent = {
|
||||
type: event.getType(),
|
||||
event_id: event.getId(),
|
||||
sender: event.getSender(),
|
||||
room_id: roomId,
|
||||
content: event.getContent(),
|
||||
origin_server_ts: event.getTs(),
|
||||
};
|
||||
|
||||
if (event.getType() === "m.room.message") {
|
||||
for (const handler of this.messageHandlers) {
|
||||
handler(roomId, rawEvent).catch((err) =>
|
||||
logger.error(`Message handler error: ${err}`)
|
||||
);
|
||||
}
|
||||
} else if (event.getType() === "m.reaction") {
|
||||
for (const handler of this.reactionHandlers) {
|
||||
handler(roomId, rawEvent).catch((err) =>
|
||||
logger.error(`Reaction handler error: ${err}`)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Event registration (matching matrix-bot-sdk pattern) ---
|
||||
|
||||
onMessage(handler: (roomId: string, event: any) => Promise<void>): void {
|
||||
this.messageHandlers.push(handler);
|
||||
}
|
||||
|
||||
onReaction(handler: (roomId: string, event: any) => Promise<void>): void {
|
||||
this.reactionHandlers.push(handler);
|
||||
}
|
||||
|
||||
// --- Methods used by plugins (same signatures as matrix-bot-sdk) ---
|
||||
|
||||
async getUserId(): Promise<string> {
|
||||
return this._userId;
|
||||
}
|
||||
|
||||
getDeviceId(): string | null {
|
||||
return this.client.getDeviceId();
|
||||
}
|
||||
|
||||
async sendText(roomId: string, text: string): Promise<string> {
|
||||
const res = await this.client.sendTextMessage(roomId, text);
|
||||
return res.event_id;
|
||||
}
|
||||
|
||||
async sendNotice(roomId: string, text: string): Promise<string> {
|
||||
const res = await this.client.sendNotice(roomId, text);
|
||||
return res.event_id;
|
||||
}
|
||||
|
||||
async sendMessage(roomId: string, content: any): Promise<string> {
|
||||
const res = await this.client.sendMessage(roomId, content);
|
||||
return res.event_id;
|
||||
}
|
||||
|
||||
async sendEvent(roomId: string, eventType: string, content: any): Promise<string> {
|
||||
const res = await this.client.sendEvent(roomId, eventType as any, content);
|
||||
return res.event_id;
|
||||
}
|
||||
|
||||
async getEvent(roomId: string, eventId: string): Promise<any> {
|
||||
return await this.client.fetchRoomEvent(roomId, eventId);
|
||||
}
|
||||
|
||||
async getJoinedRooms(): Promise<string[]> {
|
||||
const res = await this.client.getJoinedRooms();
|
||||
return res.joined_rooms;
|
||||
}
|
||||
|
||||
async getJoinedRoomMembers(roomId: string): Promise<string[]> {
|
||||
const room = this.client.getRoom(roomId);
|
||||
if (!room) return [];
|
||||
return room.getJoinedMembers().map((m) => m.userId);
|
||||
}
|
||||
|
||||
async joinRoom(roomId: string): Promise<void> {
|
||||
await this.client.joinRoom(roomId);
|
||||
}
|
||||
|
||||
async uploadContent(data: Buffer, contentType?: string, filename?: string): Promise<string> {
|
||||
const res = await this.client.uploadContent(data, {
|
||||
type: contentType,
|
||||
name: filename,
|
||||
});
|
||||
return res.content_uri;
|
||||
}
|
||||
|
||||
async sendToDevices(type: string, messages: Record<string, Record<string, any>>): Promise<void> {
|
||||
// matrix-js-sdk expects Map<string, Map<string, object>>
|
||||
const contentMap = new Map<string, Map<string, Record<string, any>>>();
|
||||
for (const [userId, devices] of Object.entries(messages)) {
|
||||
const deviceMap = new Map<string, Record<string, any>>();
|
||||
for (const [deviceId, content] of Object.entries(devices)) {
|
||||
deviceMap.set(deviceId, content);
|
||||
}
|
||||
contentMap.set(userId, deviceMap);
|
||||
}
|
||||
await this.client.sendToDevice(type, contentMap);
|
||||
}
|
||||
|
||||
async getOwnDevices(): Promise<any[]> {
|
||||
const res = await this.client.getDevices();
|
||||
return res.devices;
|
||||
}
|
||||
|
||||
isRoomEncrypted(roomId: string): boolean {
|
||||
return this.client.isRoomEncrypted(roomId);
|
||||
}
|
||||
|
||||
// DM support
|
||||
dms = {
|
||||
getOrCreateDm: async (userId: string): Promise<string> => {
|
||||
// Check for existing DM
|
||||
const rooms = this.client.getRooms();
|
||||
for (const room of rooms) {
|
||||
const members = room.getJoinedMembers();
|
||||
if (members.length === 2 && members.some((m) => m.userId === userId)) {
|
||||
return room.roomId;
|
||||
}
|
||||
}
|
||||
|
||||
// Create new DM
|
||||
const res = await this.client.createRoom({
|
||||
is_direct: true,
|
||||
invite: [userId],
|
||||
preset: "trusted_private_chat" as any,
|
||||
});
|
||||
return res.room_id;
|
||||
},
|
||||
};
|
||||
|
||||
// Crypto accessors for compatibility
|
||||
crypto = {
|
||||
isRoomEncrypted: (roomId: string): boolean => {
|
||||
return this.client.isRoomEncrypted(roomId);
|
||||
},
|
||||
clientDeviceId: "" as string,
|
||||
};
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.client.stopClient();
|
||||
}
|
||||
|
||||
/** Access the underlying matrix-js-sdk client for advanced operations */
|
||||
get raw(): sdk.MatrixClient {
|
||||
return this.client;
|
||||
}
|
||||
}
|
||||
429
src/plugins/achievements.ts
Normal file
429
src/plugins/achievements.ts
Normal file
@@ -0,0 +1,429 @@
|
||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
||||
import { getDb } from "../db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
interface AchievementDef {
|
||||
key: string;
|
||||
name: string;
|
||||
description: string;
|
||||
check: (stats: any, extras: AchievementExtras) => boolean;
|
||||
}
|
||||
|
||||
interface AchievementExtras {
|
||||
userId: string;
|
||||
roomId: string;
|
||||
}
|
||||
|
||||
const ACHIEVEMENTS: AchievementDef[] = [
|
||||
{
|
||||
key: "encyclopedist",
|
||||
name: "Encyclopedist",
|
||||
description: "Write 100,000 total words",
|
||||
check: (s) => s.total_words >= 100_000,
|
||||
},
|
||||
{
|
||||
key: "linkdump",
|
||||
name: "Linkdump",
|
||||
description: "Post 500 links",
|
||||
check: (s) => s.total_links >= 500,
|
||||
},
|
||||
{
|
||||
key: "night_shift",
|
||||
name: "Night Shift",
|
||||
description: "Send 100 messages between 00:00–04:00",
|
||||
check: (s) => {
|
||||
const hourly: Record<string, number> = JSON.parse(s.hourly_distribution || "{}");
|
||||
const nightMsgs = [0, 1, 2, 3].reduce((sum, h) => sum + (hourly[h] ?? 0), 0);
|
||||
return nightMsgs >= 100;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "riddler",
|
||||
name: "Riddler",
|
||||
description: "Ask 500 questions",
|
||||
check: (s) => s.total_questions >= 500,
|
||||
},
|
||||
{
|
||||
key: "show_dont_tell",
|
||||
name: "Show Don't Tell",
|
||||
description: "Post 200 images",
|
||||
check: (s) => s.total_images >= 200,
|
||||
},
|
||||
{
|
||||
key: "hemingway",
|
||||
name: "Hemingway",
|
||||
description: "Send 1,000 messages averaging under 5 words",
|
||||
check: (s) => s.total_messages >= 1000 && s.avg_words_per_message < 5,
|
||||
},
|
||||
{
|
||||
key: "tolstoy",
|
||||
name: "Tolstoy",
|
||||
description: "Send 1,000 messages averaging over 50 words",
|
||||
check: (s) => s.total_messages >= 1000 && s.avg_words_per_message > 50,
|
||||
},
|
||||
{
|
||||
key: "logophile",
|
||||
name: "Logophile",
|
||||
description: "Use a word over 15 characters",
|
||||
// This is checked at message time via parseMessage().hasLongWord
|
||||
// but we also check the flag here from a stored marker
|
||||
check: () => false, // handled specially below
|
||||
},
|
||||
{
|
||||
key: "omnipresent",
|
||||
name: "Omnipresent",
|
||||
description: "Active 30 unique days in a single calendar month",
|
||||
check: (_s, extras) => {
|
||||
const db = getDb();
|
||||
// Check current month
|
||||
const now = new Date();
|
||||
const monthStart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
|
||||
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
const monthEnd = nextMonth.toISOString().slice(0, 10);
|
||||
const row = db
|
||||
.prepare(`SELECT COUNT(DISTINCT date) as days FROM daily_activity WHERE user_id = ? AND room_id = ? AND date >= ? AND date < ?`)
|
||||
.get(extras.userId, extras.roomId, monthStart, monthEnd) as { days: number };
|
||||
return row.days >= 30;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "early_bird_legend",
|
||||
name: "Early Bird Legend",
|
||||
description: "Held First! for 30 days total",
|
||||
check: (_s, extras) => {
|
||||
const db = getDb();
|
||||
const row = db
|
||||
.prepare(`SELECT COUNT(*) as firsts FROM daily_first WHERE user_id = ? AND room_id = ?`)
|
||||
.get(extras.userId, extras.roomId) as { firsts: number };
|
||||
return row.firsts >= 30;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "streak_week",
|
||||
name: "Streak Week",
|
||||
description: "Achieve a 7-day streak",
|
||||
check: (s) => s.longest_streak >= 7,
|
||||
},
|
||||
{
|
||||
key: "streak_month",
|
||||
name: "Streak Month",
|
||||
description: "Achieve a 30-day streak",
|
||||
check: (s) => s.longest_streak >= 30,
|
||||
},
|
||||
{
|
||||
key: "beloved",
|
||||
name: "Beloved",
|
||||
description: "Earn 50 reputation points",
|
||||
check: (_s, extras) => {
|
||||
const db = getDb();
|
||||
const row = db
|
||||
.prepare(`SELECT rep FROM users WHERE user_id = ? AND room_id = ?`)
|
||||
.get(extras.userId, extras.roomId) as { rep: number } | undefined;
|
||||
return (row?.rep ?? 0) >= 50;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "gamer",
|
||||
name: "Gamer",
|
||||
description: "Have 10 items in your backlog",
|
||||
check: (_s, extras) => {
|
||||
const db = getDb();
|
||||
const row = db
|
||||
.prepare(`SELECT COUNT(*) as count FROM backlog WHERE user_id = ? AND room_id = ?`)
|
||||
.get(extras.userId, extras.roomId) as { count: number };
|
||||
return row.count >= 10;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "completionist",
|
||||
name: "Completionist",
|
||||
description: "Complete 10 backlog items",
|
||||
check: (_s, extras) => {
|
||||
const db = getDb();
|
||||
const row = db
|
||||
.prepare(`SELECT COUNT(*) as count FROM backlog WHERE user_id = ? AND room_id = ? AND completed = 1`)
|
||||
.get(extras.userId, extras.roomId) as { count: number };
|
||||
return row.count >= 10;
|
||||
},
|
||||
},
|
||||
// Trivia
|
||||
{
|
||||
key: "trivia_first_blood",
|
||||
name: "First Blood",
|
||||
description: "First correct trivia answer",
|
||||
check: (_s, extras) => {
|
||||
const db = getDb();
|
||||
const row = db.prepare(`SELECT total_correct FROM trivia_scores WHERE user_id = ? AND room_id = ?`)
|
||||
.get(extras.userId, extras.roomId) as { total_correct: number } | undefined;
|
||||
return (row?.total_correct ?? 0) >= 1;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "trivia_century",
|
||||
name: "The Scholar",
|
||||
description: "100 correct trivia answers",
|
||||
check: (_s, extras) => {
|
||||
const db = getDb();
|
||||
const row = db.prepare(`SELECT total_correct FROM trivia_scores WHERE user_id = ? AND room_id = ?`)
|
||||
.get(extras.userId, extras.roomId) as { total_correct: number } | undefined;
|
||||
return (row?.total_correct ?? 0) >= 100;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "trivia_speed_demon",
|
||||
name: "Speed Demon",
|
||||
description: "Correct trivia answer in under 2 seconds",
|
||||
check: (_s, extras) => {
|
||||
const db = getDb();
|
||||
const row = db.prepare(`SELECT fastest_ms FROM trivia_scores WHERE user_id = ? AND room_id = ?`)
|
||||
.get(extras.userId, extras.roomId) as { fastest_ms: number | null } | undefined;
|
||||
return (row?.fastest_ms ?? Infinity) < 2000;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "trivia_on_a_roll",
|
||||
name: "On a Roll",
|
||||
description: "10 correct trivia answers in a row",
|
||||
check: (_s, extras) => {
|
||||
const db = getDb();
|
||||
const row = db.prepare(`SELECT best_streak FROM trivia_scores WHERE user_id = ? AND room_id = ?`)
|
||||
.get(extras.userId, extras.roomId) as { best_streak: number } | undefined;
|
||||
return (row?.best_streak ?? 0) >= 10;
|
||||
},
|
||||
},
|
||||
// Birthday
|
||||
{
|
||||
key: "birthday_celebrated",
|
||||
name: "Birthday Bee",
|
||||
description: "Had your birthday celebrated by Freebee",
|
||||
check: (_s, extras) => {
|
||||
const db = getDb();
|
||||
const row = db.prepare(`SELECT user_id FROM birthday_fired WHERE user_id = ?`)
|
||||
.get(extras.userId) as any;
|
||||
return !!row;
|
||||
},
|
||||
},
|
||||
// LLM passive (only earn if LLM is enabled)
|
||||
{
|
||||
key: "wotd_scholar",
|
||||
name: "Word Nerd",
|
||||
description: "Used the WOTD correctly 10 times",
|
||||
check: (_s, extras) => {
|
||||
const db = getDb();
|
||||
const row = db.prepare(`SELECT COUNT(*) as c FROM wotd_usage WHERE user_id = ? AND room_id = ? AND xp_awarded > 0`)
|
||||
.get(extras.userId, extras.roomId) as { c: number };
|
||||
return row.c >= 10;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "wotd_cheater",
|
||||
name: "Nice Try",
|
||||
description: "Attempted to game the WOTD 5 times",
|
||||
check: (_s, extras) => {
|
||||
const db = getDb();
|
||||
const row = db.prepare(`SELECT COUNT(*) as c FROM wotd_usage WHERE user_id = ? AND room_id = ? AND xp_awarded = 0`)
|
||||
.get(extras.userId, extras.roomId) as { c: number };
|
||||
return row.c >= 5;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "potty_bronze",
|
||||
name: "Needs Soap",
|
||||
description: "50 profanity detections",
|
||||
check: (_s, extras) => {
|
||||
const db = getDb();
|
||||
const row = db.prepare(`SELECT total FROM potty_mouth WHERE user_id = ? AND room_id = ?`)
|
||||
.get(extras.userId, extras.roomId) as { total: number } | undefined;
|
||||
return (row?.total ?? 0) >= 50;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "potty_gold",
|
||||
name: "Sailor Mouth",
|
||||
description: "500 profanity detections",
|
||||
check: (_s, extras) => {
|
||||
const db = getDb();
|
||||
const row = db.prepare(`SELECT total FROM potty_mouth WHERE user_id = ? AND room_id = ?`)
|
||||
.get(extras.userId, extras.roomId) as { total: number } | undefined;
|
||||
return (row?.total ?? 0) >= 500;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "roaster",
|
||||
name: "The Roaster",
|
||||
description: "50 insults thrown",
|
||||
check: (_s, extras) => {
|
||||
const db = getDb();
|
||||
const row = db.prepare(`SELECT total_thrown FROM insult_log WHERE user_id = ? AND room_id = ?`)
|
||||
.get(extras.userId, extras.roomId) as { total_thrown: number } | undefined;
|
||||
return (row?.total_thrown ?? 0) >= 50;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "punching_bag",
|
||||
name: "Punching Bag",
|
||||
description: "Targeted 50 times",
|
||||
check: (_s, extras) => {
|
||||
const db = getDb();
|
||||
const row = db.prepare(`SELECT times_targeted FROM insult_log WHERE user_id = ? AND room_id = ?`)
|
||||
.get(extras.userId, extras.roomId) as { times_targeted: number } | undefined;
|
||||
return (row?.times_targeted ?? 0) >= 50;
|
||||
},
|
||||
},
|
||||
// Social / utility
|
||||
{
|
||||
key: "welcome_wagon",
|
||||
name: "Welcome Wagon",
|
||||
description: "First message ever in this room",
|
||||
check: () => false, // handled by WelcomePlugin directly
|
||||
},
|
||||
{
|
||||
key: "countdown_keeper",
|
||||
name: "Countdown Keeper",
|
||||
description: "5 active countdowns simultaneously",
|
||||
check: (_s, extras) => {
|
||||
const db = getDb();
|
||||
const row = db.prepare(`SELECT COUNT(*) as c FROM countdowns WHERE user_id = ? AND room_id = ? AND completed = 0`)
|
||||
.get(extras.userId, extras.roomId) as { c: number };
|
||||
return row.c >= 5;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "markov_victim",
|
||||
name: "Markov Victim",
|
||||
description: "Had someone run !markov on you",
|
||||
check: () => false, // handled by MarkovPlugin directly
|
||||
},
|
||||
{
|
||||
key: "stonks",
|
||||
name: "Stonks",
|
||||
description: "5 tickers on stock watchlist",
|
||||
check: (_s, extras) => {
|
||||
const db = getDb();
|
||||
const row = db.prepare(`SELECT COUNT(*) as c FROM stock_watchlist WHERE user_id = ? AND room_id = ?`)
|
||||
.get(extras.userId, extras.roomId) as { c: number };
|
||||
return row.c >= 5;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "weeaboo",
|
||||
name: "Certified Weeaboo",
|
||||
description: "10 anime on watchlist",
|
||||
check: (_s, extras) => {
|
||||
const db = getDb();
|
||||
const row = db.prepare(`SELECT COUNT(*) as c FROM anime_watchlist WHERE user_id = ? AND room_id = ?`)
|
||||
.get(extras.userId, extras.roomId) as { c: number };
|
||||
return row.c >= 10;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "cinephile",
|
||||
name: "Cinephile",
|
||||
description: "10 movies/TV shows on watchlist",
|
||||
check: (_s, extras) => {
|
||||
const db = getDb();
|
||||
const row = db.prepare(`SELECT COUNT(*) as c FROM movie_watchlist WHERE user_id = ? AND room_id = ?`)
|
||||
.get(extras.userId, extras.roomId) as { c: number };
|
||||
return row.c >= 10;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "concert_goer",
|
||||
name: "Concert Goer",
|
||||
description: "Watching 5 artists",
|
||||
check: (_s, extras) => {
|
||||
const db = getDb();
|
||||
const row = db.prepare(`SELECT COUNT(*) as c FROM concert_watchlist WHERE user_id = ? AND room_id = ?`)
|
||||
.get(extras.userId, extras.roomId) as { c: number };
|
||||
return row.c >= 5;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export class AchievementsPlugin extends Plugin {
|
||||
constructor(client: IMatrixClient) {
|
||||
super(client);
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "achievements";
|
||||
}
|
||||
|
||||
get commands(): CommandDef[] {
|
||||
return [
|
||||
{ name: "achievements", description: "View unlocked achievements", usage: "!achievements [@user]" },
|
||||
];
|
||||
}
|
||||
|
||||
async onMessage(ctx: MessageContext): Promise<void> {
|
||||
// Passive: evaluate achievements silently
|
||||
this.evaluateAchievements(ctx.sender, ctx.roomId, ctx.body);
|
||||
|
||||
if (this.isCommand(ctx.body, "achievements")) {
|
||||
await this.handleAchievements(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
private evaluateAchievements(userId: string, roomId: string, messageBody: string): void {
|
||||
const db = getDb();
|
||||
const stats = db
|
||||
.prepare(`SELECT * FROM user_stats WHERE user_id = ? AND room_id = ?`)
|
||||
.get(userId, roomId) as any;
|
||||
|
||||
if (!stats) return;
|
||||
|
||||
const extras: AchievementExtras = { userId, roomId };
|
||||
|
||||
for (const achievement of ACHIEVEMENTS) {
|
||||
// Skip if already unlocked
|
||||
const existing = db
|
||||
.prepare(`SELECT id FROM achievements WHERE user_id = ? AND room_id = ? AND achievement_key = ?`)
|
||||
.get(userId, roomId, achievement.key);
|
||||
if (existing) continue;
|
||||
|
||||
let earned = false;
|
||||
|
||||
// Special case: logophile checks message content directly
|
||||
if (achievement.key === "logophile") {
|
||||
const { parseMessage } = require("../utils/parser");
|
||||
const parsed = parseMessage(messageBody);
|
||||
earned = parsed.hasLongWord;
|
||||
} else {
|
||||
earned = achievement.check(stats, extras);
|
||||
}
|
||||
|
||||
if (earned) {
|
||||
db.prepare(`INSERT OR IGNORE INTO achievements (user_id, room_id, achievement_key) VALUES (?, ?, ?)`).run(
|
||||
userId,
|
||||
roomId,
|
||||
achievement.key
|
||||
);
|
||||
logger.debug(`Achievement unlocked: ${userId} earned "${achievement.key}" in ${roomId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAchievements(ctx: MessageContext): Promise<void> {
|
||||
const args = this.getArgs(ctx.body, "achievements");
|
||||
const targetUser = args.startsWith("@") ? args.split(/\s/)[0] : ctx.sender;
|
||||
|
||||
const db = getDb();
|
||||
const unlocked = db
|
||||
.prepare(`SELECT achievement_key, unlocked_at FROM achievements WHERE user_id = ? AND room_id = ? ORDER BY unlocked_at ASC`)
|
||||
.all(targetUser, ctx.roomId) as { achievement_key: string; unlocked_at: string }[];
|
||||
|
||||
const unlockedKeys = new Set(unlocked.map((a) => a.achievement_key));
|
||||
|
||||
const lines = [`Achievements for ${targetUser} (${unlocked.length}/${ACHIEVEMENTS.length}):`];
|
||||
|
||||
for (const def of ACHIEVEMENTS) {
|
||||
if (unlockedKeys.has(def.key)) {
|
||||
lines.push(` [x] ${def.name} — ${def.description}`);
|
||||
} else {
|
||||
lines.push(` [ ] ${def.name} — ${def.description}`);
|
||||
}
|
||||
}
|
||||
|
||||
await this.sendMessage(ctx.roomId, lines.join("\n"));
|
||||
}
|
||||
}
|
||||
429
src/plugins/anime.ts
Normal file
429
src/plugins/anime.ts
Normal file
@@ -0,0 +1,429 @@
|
||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
||||
import { getDb } from "../db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
interface JikanAnime {
|
||||
mal_id: number;
|
||||
title: string;
|
||||
score: number | null;
|
||||
episodes: number | null;
|
||||
status: string | null;
|
||||
synopsis: string | null;
|
||||
genres: { name: string }[];
|
||||
broadcast: { day: string | null; time: string | null; string: string | null } | null;
|
||||
aired: { from: string | null; to: string | null; string: string | null } | null;
|
||||
}
|
||||
|
||||
async function jikanDelay(): Promise<void> {
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
}
|
||||
|
||||
export class AnimePlugin extends Plugin {
|
||||
constructor(client: IMatrixClient) {
|
||||
super(client);
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "anime";
|
||||
}
|
||||
|
||||
get commands(): CommandDef[] {
|
||||
return [
|
||||
{ name: "anime search", description: "Search anime by title", usage: "!anime search <title>" },
|
||||
{ name: "anime watch", description: "Add anime to your watchlist", usage: "!anime watch <title>" },
|
||||
{ name: "anime watching", description: "List your anime watchlist", usage: "!anime watching" },
|
||||
{ name: "anime unwatch", description: "Remove anime from watchlist", usage: "!anime unwatch <title|id>" },
|
||||
{ name: "anime season", description: "Current season top 10 by score", usage: "!anime season" },
|
||||
{ name: "anime upcoming", description: "Next season preview top 10", usage: "!anime upcoming" },
|
||||
{ name: "anime", description: "Get anime details", usage: "!anime <title or MAL ID>" },
|
||||
];
|
||||
}
|
||||
|
||||
async onMessage(ctx: MessageContext): Promise<void> {
|
||||
if (this.isCommand(ctx.body, "anime search ")) {
|
||||
await this.handleSearch(ctx);
|
||||
} else if (this.isCommand(ctx.body, "anime watch ")) {
|
||||
await this.handleWatch(ctx);
|
||||
} else if (this.isCommand(ctx.body, "anime watching")) {
|
||||
await this.handleWatching(ctx);
|
||||
} else if (this.isCommand(ctx.body, "anime unwatch ")) {
|
||||
await this.handleUnwatch(ctx);
|
||||
} else if (this.isCommand(ctx.body, "anime season")) {
|
||||
await this.handleSeason(ctx);
|
||||
} else if (this.isCommand(ctx.body, "anime upcoming")) {
|
||||
await this.handleUpcoming(ctx);
|
||||
} else if (this.isCommand(ctx.body, "anime ")) {
|
||||
await this.handleDetails(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Jikan helpers ──────────────────────────────────────────────────
|
||||
|
||||
private getCachedAnime(malId: number): JikanAnime | null {
|
||||
const db = getDb();
|
||||
const row = db
|
||||
.prepare(`SELECT data FROM anime_cache WHERE mal_id = ? AND cached_at > unixepoch() - 86400`)
|
||||
.get(malId) as { data: string } | undefined;
|
||||
|
||||
if (!row) return null;
|
||||
try {
|
||||
return JSON.parse(row.data) as JikanAnime;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private cacheAnime(anime: JikanAnime): void {
|
||||
const db = getDb();
|
||||
db.prepare(
|
||||
`INSERT INTO anime_cache (mal_id, data, cached_at) VALUES (?, ?, unixepoch())
|
||||
ON CONFLICT(mal_id) DO UPDATE SET data = excluded.data, cached_at = excluded.cached_at`
|
||||
).run(anime.mal_id, JSON.stringify(anime));
|
||||
}
|
||||
|
||||
private async fetchAnimeById(malId: number): Promise<JikanAnime | null> {
|
||||
const cached = this.getCachedAnime(malId);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
await jikanDelay();
|
||||
const res = await fetch(`https://api.jikan.moe/v4/anime/${malId}`);
|
||||
if (!res.ok) return null;
|
||||
|
||||
const json = (await res.json()) as any;
|
||||
const anime = json.data as JikanAnime;
|
||||
if (!anime) return null;
|
||||
|
||||
this.cacheAnime(anime);
|
||||
return anime;
|
||||
} catch (err) {
|
||||
logger.error(`Jikan fetch by ID ${malId} failed: ${err}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async searchAnime(query: string, limit = 3): Promise<JikanAnime[]> {
|
||||
try {
|
||||
await jikanDelay();
|
||||
const url = `https://api.jikan.moe/v4/anime?q=${encodeURIComponent(query)}&limit=${limit}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return [];
|
||||
|
||||
const json = (await res.json()) as any;
|
||||
const results = (json.data ?? []) as JikanAnime[];
|
||||
|
||||
for (const anime of results) {
|
||||
this.cacheAnime(anime);
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (err) {
|
||||
logger.error(`Jikan search failed for "${query}": ${err}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Command handlers ───────────────────────────────────────────────
|
||||
|
||||
private async handleSearch(ctx: MessageContext): Promise<void> {
|
||||
const query = this.getArgs(ctx.body, "anime search").trim();
|
||||
if (!query) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !anime search <title>");
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await this.searchAnime(query, 3);
|
||||
if (results.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `No anime found for "${query}".`);
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = [`Search results for "${query}":`];
|
||||
for (const anime of results) {
|
||||
const score = anime.score != null ? anime.score.toFixed(2) : "N/A";
|
||||
const episodes = anime.episodes != null ? String(anime.episodes) : "?";
|
||||
const status = anime.status ?? "Unknown";
|
||||
lines.push(`[${anime.mal_id}] ${anime.title} — Score: ${score} | Episodes: ${episodes} | Status: ${status}`);
|
||||
}
|
||||
|
||||
await this.sendMessage(ctx.roomId, lines.join("\n"));
|
||||
}
|
||||
|
||||
private async handleDetails(ctx: MessageContext): Promise<void> {
|
||||
const query = this.getArgs(ctx.body, "anime").trim();
|
||||
if (!query) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !anime <title or MAL ID>");
|
||||
return;
|
||||
}
|
||||
|
||||
let anime: JikanAnime | null = null;
|
||||
|
||||
if (/^\d+$/.test(query)) {
|
||||
anime = await this.fetchAnimeById(parseInt(query, 10));
|
||||
} else {
|
||||
const results = await this.searchAnime(query, 1);
|
||||
if (results.length > 0) {
|
||||
anime = results[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (!anime) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `No anime found for "${query}".`);
|
||||
return;
|
||||
}
|
||||
|
||||
const score = anime.score != null ? anime.score.toFixed(2) : "N/A";
|
||||
const episodes = anime.episodes != null ? String(anime.episodes) : "?";
|
||||
const genres = anime.genres.map((g) => g.name).join(", ") || "N/A";
|
||||
let synopsis = anime.synopsis ?? "No synopsis available.";
|
||||
if (synopsis.length > 300) {
|
||||
synopsis = synopsis.slice(0, 297) + "...";
|
||||
}
|
||||
|
||||
const status = anime.status ?? "Unknown";
|
||||
let airingInfo = `Status: ${status}`;
|
||||
if (anime.broadcast?.string) {
|
||||
airingInfo += ` | Broadcast: ${anime.broadcast.string}`;
|
||||
} else if (anime.aired?.string) {
|
||||
airingInfo += ` | Aired: ${anime.aired.string}`;
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`${anime.title} [MAL ${anime.mal_id}]`,
|
||||
`Score: ${score} | Episodes: ${episodes}`,
|
||||
`Genres: ${genres}`,
|
||||
airingInfo,
|
||||
``,
|
||||
synopsis,
|
||||
];
|
||||
|
||||
await this.sendMessage(ctx.roomId, lines.join("\n"));
|
||||
}
|
||||
|
||||
private async handleWatch(ctx: MessageContext): Promise<void> {
|
||||
const query = this.getArgs(ctx.body, "anime watch").trim();
|
||||
if (!query) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !anime watch <title>");
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await this.searchAnime(query, 1);
|
||||
if (results.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `No anime found for "${query}".`);
|
||||
return;
|
||||
}
|
||||
|
||||
const anime = results[0];
|
||||
const db = getDb();
|
||||
|
||||
const existing = db
|
||||
.prepare(`SELECT id FROM anime_watchlist WHERE user_id = ? AND room_id = ? AND mal_id = ?`)
|
||||
.get(ctx.sender, ctx.roomId, anime.mal_id);
|
||||
|
||||
if (existing) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `"${anime.title}" is already on your watchlist.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const airingDate = anime.aired?.from ?? null;
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO anime_watchlist (user_id, room_id, mal_id, title, airing_date) VALUES (?, ?, ?, ?, ?)`
|
||||
).run(ctx.sender, ctx.roomId, anime.mal_id, anime.title, airingDate);
|
||||
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `Added "${anime.title}" to your watchlist.`);
|
||||
}
|
||||
|
||||
private async handleWatching(ctx: MessageContext): Promise<void> {
|
||||
const db = getDb();
|
||||
const rows = db
|
||||
.prepare(`SELECT mal_id, title FROM anime_watchlist WHERE user_id = ? AND room_id = ? ORDER BY created_at DESC`)
|
||||
.all(ctx.sender, ctx.roomId) as { mal_id: number; title: string }[];
|
||||
|
||||
if (rows.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Your anime watchlist is empty.");
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = ["Your anime watchlist:"];
|
||||
for (const row of rows) {
|
||||
const cached = this.getCachedAnime(row.mal_id);
|
||||
const status = cached?.status ?? "Unknown";
|
||||
lines.push(`[${row.mal_id}] ${row.title} — ${status}`);
|
||||
}
|
||||
|
||||
await this.sendMessage(ctx.roomId, lines.join("\n"));
|
||||
}
|
||||
|
||||
private async handleUnwatch(ctx: MessageContext): Promise<void> {
|
||||
const query = this.getArgs(ctx.body, "anime unwatch").trim();
|
||||
if (!query) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !anime unwatch <title|id>");
|
||||
return;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
let result;
|
||||
|
||||
if (/^\d+$/.test(query)) {
|
||||
result = db
|
||||
.prepare(`DELETE FROM anime_watchlist WHERE user_id = ? AND room_id = ? AND mal_id = ?`)
|
||||
.run(ctx.sender, ctx.roomId, parseInt(query, 10));
|
||||
} else {
|
||||
result = db
|
||||
.prepare(`DELETE FROM anime_watchlist WHERE user_id = ? AND room_id = ? AND title LIKE ?`)
|
||||
.run(ctx.sender, ctx.roomId, `%${query}%`);
|
||||
}
|
||||
|
||||
if (result.changes > 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `Removed ${result.changes} anime from your watchlist.`);
|
||||
} else {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `No matching anime found on your watchlist.`);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleSeason(ctx: MessageContext): Promise<void> {
|
||||
try {
|
||||
await jikanDelay();
|
||||
const res = await fetch(`https://api.jikan.moe/v4/seasons/now?limit=10&order_by=score&sort=desc`);
|
||||
if (!res.ok) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to fetch current season data.");
|
||||
return;
|
||||
}
|
||||
|
||||
const json = (await res.json()) as any;
|
||||
const animeList = (json.data ?? []) as JikanAnime[];
|
||||
|
||||
if (animeList.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "No current season anime found.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const anime of animeList) {
|
||||
this.cacheAnime(anime);
|
||||
}
|
||||
|
||||
const lines = ["Current Season — Top 10 by Score:"];
|
||||
for (let i = 0; i < animeList.length; i++) {
|
||||
const anime = animeList[i];
|
||||
const score = anime.score != null ? anime.score.toFixed(2) : "N/A";
|
||||
const episodes = anime.episodes != null ? String(anime.episodes) : "?";
|
||||
lines.push(`${i + 1}. ${anime.title} — Score: ${score} | Episodes: ${episodes}`);
|
||||
}
|
||||
|
||||
await this.sendMessage(ctx.roomId, lines.join("\n"));
|
||||
} catch (err) {
|
||||
logger.error(`Season fetch failed: ${err}`);
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to fetch season data. Try again later.");
|
||||
}
|
||||
}
|
||||
|
||||
private async handleUpcoming(ctx: MessageContext): Promise<void> {
|
||||
try {
|
||||
await jikanDelay();
|
||||
const res = await fetch(`https://api.jikan.moe/v4/seasons/upcoming?limit=10&order_by=members&sort=desc`);
|
||||
if (!res.ok) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to fetch upcoming season data.");
|
||||
return;
|
||||
}
|
||||
|
||||
const json = (await res.json()) as any;
|
||||
const animeList = (json.data ?? []) as JikanAnime[];
|
||||
|
||||
if (animeList.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "No upcoming anime found.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const anime of animeList) {
|
||||
this.cacheAnime(anime);
|
||||
}
|
||||
|
||||
const lines = ["Upcoming Season — Top 10 by Popularity:"];
|
||||
for (let i = 0; i < animeList.length; i++) {
|
||||
const anime = animeList[i];
|
||||
const score = anime.score != null ? anime.score.toFixed(2) : "N/A";
|
||||
const episodes = anime.episodes != null ? String(anime.episodes) : "?";
|
||||
lines.push(`${i + 1}. ${anime.title} — Score: ${score} | Episodes: ${episodes}`);
|
||||
}
|
||||
|
||||
await this.sendMessage(ctx.roomId, lines.join("\n"));
|
||||
} catch (err) {
|
||||
logger.error(`Upcoming fetch failed: ${err}`);
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to fetch upcoming data. Try again later.");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Scheduled daily releases ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Called by DailyScheduler at 19:30 UTC to post today's airing watchlisted anime.
|
||||
*/
|
||||
async postDailyReleases(botRooms: string[]): Promise<void> {
|
||||
const db = getDb();
|
||||
const dayNames = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
const todayName = dayNames[new Date().getUTCDay()];
|
||||
|
||||
// Get all unique mal_ids from watchlist that haven't been notified
|
||||
const watchedIds = db
|
||||
.prepare(`SELECT DISTINCT mal_id FROM anime_watchlist WHERE notified = 0`)
|
||||
.all() as { mal_id: number }[];
|
||||
|
||||
if (watchedIds.length === 0) return;
|
||||
|
||||
const airingToday: JikanAnime[] = [];
|
||||
|
||||
for (const { mal_id } of watchedIds) {
|
||||
const anime = await this.fetchAnimeById(mal_id);
|
||||
if (!anime) continue;
|
||||
|
||||
if (anime.status !== "Currently Airing") continue;
|
||||
|
||||
const broadcastDay = anime.broadcast?.day ?? null;
|
||||
if (broadcastDay && broadcastDay.replace(/s$/, "") === todayName.replace(/s$/, "")) {
|
||||
airingToday.push(anime);
|
||||
}
|
||||
}
|
||||
|
||||
if (airingToday.length === 0) return;
|
||||
|
||||
// Build summary message
|
||||
const lines = ["Today's Airing Anime (from watchlists):"];
|
||||
for (const anime of airingToday) {
|
||||
const time = anime.broadcast?.time ? ` at ${anime.broadcast.time} JST` : "";
|
||||
lines.push(`- ${anime.title}${time}`);
|
||||
}
|
||||
const summary = lines.join("\n");
|
||||
|
||||
// Post to each bot room
|
||||
for (const roomId of botRooms) {
|
||||
try {
|
||||
await this.sendMessage(roomId, summary);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to post anime releases to ${roomId}: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// DM users who have these shows on their watchlist
|
||||
const dmSent = new Set<string>();
|
||||
|
||||
for (const anime of airingToday) {
|
||||
const watchers = db
|
||||
.prepare(`SELECT DISTINCT user_id FROM anime_watchlist WHERE mal_id = ? AND notified = 0`)
|
||||
.all(anime.mal_id) as { user_id: string }[];
|
||||
|
||||
for (const { user_id } of watchers) {
|
||||
const key = `${user_id}:${anime.mal_id}`;
|
||||
if (dmSent.has(key)) continue;
|
||||
dmSent.add(key);
|
||||
|
||||
try {
|
||||
const time = anime.broadcast?.time ? ` at ${anime.broadcast.time} JST` : "";
|
||||
await this.sendDm(user_id, `"${anime.title}" airs today${time}!`);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to DM anime notification to ${user_id}: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
187
src/plugins/base.ts
Normal file
187
src/plugins/base.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import logger from "../utils/logger";
|
||||
|
||||
/** Common client interface — works with both matrix-bot-sdk and our BotClient wrapper */
|
||||
export interface IMatrixClient {
|
||||
getUserId(): Promise<string> | string | null;
|
||||
sendText(roomId: string, text: string): Promise<string>;
|
||||
sendMessage(roomId: string, content: any): Promise<string>;
|
||||
sendEvent(roomId: string, eventType: string, content: any): Promise<string>;
|
||||
sendNotice(roomId: string, text: string): Promise<string>;
|
||||
getEvent(roomId: string, eventId: string): Promise<any>;
|
||||
getJoinedRooms(): Promise<string[]>;
|
||||
getJoinedRoomMembers(roomId: string): Promise<string[]>;
|
||||
uploadContent(data: Buffer, contentType?: string, filename?: string): Promise<string>;
|
||||
dms: { getOrCreateDm(userId: string): Promise<string> };
|
||||
}
|
||||
|
||||
export interface CommandDef {
|
||||
name: string;
|
||||
description: string;
|
||||
usage?: string;
|
||||
adminOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface MessageContext {
|
||||
roomId: string;
|
||||
sender: string;
|
||||
body: string;
|
||||
eventId: string;
|
||||
event: any;
|
||||
}
|
||||
|
||||
export interface ReactionContext {
|
||||
roomId: string;
|
||||
sender: string;
|
||||
eventId: string;
|
||||
reactionKey: string;
|
||||
targetEventId: string;
|
||||
event: any;
|
||||
}
|
||||
|
||||
export abstract class Plugin {
|
||||
protected client: IMatrixClient;
|
||||
protected prefix: string;
|
||||
|
||||
constructor(client: IMatrixClient) {
|
||||
this.client = client;
|
||||
this.prefix = process.env.BOT_PREFIX ?? "!";
|
||||
}
|
||||
|
||||
abstract get name(): string;
|
||||
abstract get commands(): CommandDef[];
|
||||
|
||||
abstract onMessage(ctx: MessageContext): Promise<void>;
|
||||
|
||||
async onReaction(_ctx: ReactionContext): Promise<void> {
|
||||
// Default no-op; plugins override if they care about reactions
|
||||
}
|
||||
|
||||
protected async sendMessage(roomId: string, text: string): Promise<string> {
|
||||
try {
|
||||
return await this.client.sendText(roomId, text);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to send message to ${roomId}: ${err}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
protected async sendHtml(roomId: string, html: string, plain?: string): Promise<string> {
|
||||
try {
|
||||
return await this.client.sendMessage(roomId, {
|
||||
msgtype: "m.text",
|
||||
body: plain ?? html.replace(/<[^>]+>/g, ""),
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: html,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`Failed to send HTML message to ${roomId}: ${err}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
protected async sendReply(roomId: string, eventId: string, text: string): Promise<string> {
|
||||
try {
|
||||
return await this.client.sendMessage(roomId, {
|
||||
msgtype: "m.text",
|
||||
body: text,
|
||||
"m.relates_to": {
|
||||
"m.in_reply_to": { event_id: eventId },
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`Failed to send reply in ${roomId}: ${err}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
protected async sendDm(userId: string, text: string): Promise<void> {
|
||||
try {
|
||||
const dmRoomId = await this.client.dms.getOrCreateDm(userId);
|
||||
await this.client.sendText(dmRoomId, text);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to send DM to ${userId}: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
protected isCommand(body: string, command: string): boolean {
|
||||
return body.startsWith(this.prefix + command);
|
||||
}
|
||||
|
||||
protected getArgs(body: string, command: string): string {
|
||||
return body.slice(this.prefix.length + command.length).trim();
|
||||
}
|
||||
|
||||
protected isAdmin(userId: string): boolean {
|
||||
const admins = (process.env.BOT_ADMIN_USERS ?? "").split(",").map((s) => s.trim());
|
||||
return admins.includes(userId);
|
||||
}
|
||||
}
|
||||
|
||||
export class PluginRegistry {
|
||||
private plugins: Plugin[] = [];
|
||||
private botUserId: string;
|
||||
|
||||
constructor(botUserId: string) {
|
||||
this.botUserId = botUserId;
|
||||
}
|
||||
|
||||
register(plugin: Plugin): void {
|
||||
this.plugins.push(plugin);
|
||||
logger.info(`Registered plugin: ${plugin.name} (${plugin.commands.map((c) => c.name).join(", ") || "passive"})`);
|
||||
}
|
||||
|
||||
getCommands(): { plugin: string; commands: CommandDef[] }[] {
|
||||
return this.plugins
|
||||
.filter((p) => p.commands.length > 0)
|
||||
.map((p) => ({ plugin: p.name, commands: p.commands }));
|
||||
}
|
||||
|
||||
async dispatch(roomId: string, event: any): Promise<void> {
|
||||
if (event.type !== "m.room.message") return;
|
||||
const content = event.content;
|
||||
if (!content || content.msgtype !== "m.text") return;
|
||||
|
||||
const sender: string = event.sender;
|
||||
if (sender === this.botUserId) return;
|
||||
|
||||
const body: string = content.body ?? "";
|
||||
const eventId: string = event.event_id;
|
||||
|
||||
const ctx: MessageContext = { roomId, sender, body, eventId, event };
|
||||
|
||||
for (const plugin of this.plugins) {
|
||||
try {
|
||||
await plugin.onMessage(ctx);
|
||||
} catch (err) {
|
||||
logger.error(`Plugin ${plugin.name} error on message: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async dispatchReaction(roomId: string, event: any): Promise<void> {
|
||||
if (event.type !== "m.reaction") return;
|
||||
|
||||
const sender: string = event.sender;
|
||||
if (sender === this.botUserId) return;
|
||||
|
||||
const relatesTo = event.content?.["m.relates_to"];
|
||||
if (!relatesTo) return;
|
||||
|
||||
const ctx: ReactionContext = {
|
||||
roomId,
|
||||
sender,
|
||||
eventId: event.event_id,
|
||||
reactionKey: relatesTo.key ?? "",
|
||||
targetEventId: relatesTo.event_id ?? "",
|
||||
event,
|
||||
};
|
||||
|
||||
for (const plugin of this.plugins) {
|
||||
try {
|
||||
await plugin.onReaction(ctx);
|
||||
} catch (err) {
|
||||
logger.error(`Plugin ${plugin.name} error on reaction: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
249
src/plugins/birthday.ts
Normal file
249
src/plugins/birthday.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
||||
import { XpPlugin } from "./xp";
|
||||
import { getDb } from "../db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
const MONTH_NAMES = [
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
];
|
||||
|
||||
export class BirthdayPlugin extends Plugin {
|
||||
private xpPlugin: XpPlugin;
|
||||
|
||||
constructor(client: IMatrixClient, xpPlugin: XpPlugin) {
|
||||
super(client);
|
||||
this.xpPlugin = xpPlugin;
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "birthday";
|
||||
}
|
||||
|
||||
get commands(): CommandDef[] {
|
||||
return [
|
||||
{ name: "birthday set", description: "Set your birthday", usage: "!birthday set <month> <day> [year]" },
|
||||
{ name: "birthday remove", description: "Remove your birthday", usage: "!birthday remove" },
|
||||
{ name: "birthday", description: "Show a birthday", usage: "!birthday [@user]" },
|
||||
{ name: "birthdays", description: "Upcoming birthdays in the next 30 days", usage: "!birthdays" },
|
||||
];
|
||||
}
|
||||
|
||||
async onMessage(ctx: MessageContext): Promise<void> {
|
||||
if (this.isCommand(ctx.body, "birthdays")) {
|
||||
await this.handleUpcoming(ctx);
|
||||
} else if (this.isCommand(ctx.body, "birthday set")) {
|
||||
await this.handleSet(ctx);
|
||||
} else if (this.isCommand(ctx.body, "birthday remove")) {
|
||||
await this.handleRemove(ctx);
|
||||
} else if (this.isCommand(ctx.body, "birthday")) {
|
||||
await this.handleShow(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleSet(ctx: MessageContext): Promise<void> {
|
||||
const args = this.getArgs(ctx.body, "birthday set").trim().split(/\s+/);
|
||||
|
||||
if (args.length < 2) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !birthday set <month> <day> [year]\nExample: !birthday set 3 15 1990");
|
||||
return;
|
||||
}
|
||||
|
||||
const month = parseInt(args[0]);
|
||||
const day = parseInt(args[1]);
|
||||
const year = args[2] ? parseInt(args[2]) : null;
|
||||
|
||||
if (isNaN(month) || month < 1 || month > 12) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Month must be between 1 and 12.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNaN(day) || day < 1 || day > 31) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Day must be between 1 and 31.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (year !== null && isNaN(year)) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Invalid year.");
|
||||
return;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
db.prepare(`
|
||||
INSERT INTO birthdays (user_id, room_id, month, day, year)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, room_id) DO UPDATE SET
|
||||
month = excluded.month,
|
||||
day = excluded.day,
|
||||
year = excluded.year
|
||||
`).run(ctx.sender, ctx.roomId, month, day, year);
|
||||
|
||||
const label = `${MONTH_NAMES[month - 1]} ${day}${year ? `, ${year}` : ""}`;
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `Birthday set to ${label}.`);
|
||||
}
|
||||
|
||||
private async handleRemove(ctx: MessageContext): Promise<void> {
|
||||
const db = getDb();
|
||||
const result = db
|
||||
.prepare(`DELETE FROM birthdays WHERE user_id = ? AND room_id = ?`)
|
||||
.run(ctx.sender, ctx.roomId);
|
||||
|
||||
if (result.changes > 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Your birthday has been removed.");
|
||||
} else {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "You don't have a birthday set in this room.");
|
||||
}
|
||||
}
|
||||
|
||||
private async handleShow(ctx: MessageContext): Promise<void> {
|
||||
const args = this.getArgs(ctx.body, "birthday").trim();
|
||||
const target = args.startsWith("@") ? args.split(/\s/)[0] : ctx.sender;
|
||||
|
||||
const db = getDb();
|
||||
const row = db
|
||||
.prepare(`SELECT month, day, year FROM birthdays WHERE user_id = ? AND room_id = ?`)
|
||||
.get(target, ctx.roomId) as { month: number; day: number; year: number | null } | undefined;
|
||||
|
||||
if (!row) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `No birthday set for ${target}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
let message = `${target}'s birthday: ${MONTH_NAMES[row.month - 1]} ${row.day}`;
|
||||
|
||||
// Only show year/age to the user themselves
|
||||
if (ctx.sender === target && row.year) {
|
||||
const now = new Date();
|
||||
const age = this.calculateAge(row.year, row.month, row.day, now);
|
||||
message += `, ${row.year} (age ${age})`;
|
||||
}
|
||||
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, message);
|
||||
}
|
||||
|
||||
private async handleUpcoming(ctx: MessageContext): Promise<void> {
|
||||
const db = getDb();
|
||||
const rows = db
|
||||
.prepare(`SELECT user_id, month, day FROM birthdays WHERE room_id = ?`)
|
||||
.all(ctx.roomId) as { user_id: string; month: number; day: number }[];
|
||||
|
||||
if (rows.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "No birthdays set in this room.");
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
||||
|
||||
interface UpcomingEntry {
|
||||
userId: string;
|
||||
month: number;
|
||||
day: number;
|
||||
daysUntil: number;
|
||||
}
|
||||
|
||||
const upcoming: UpcomingEntry[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
let nextOccurrence = new Date(Date.UTC(today.getUTCFullYear(), row.month - 1, row.day));
|
||||
if (nextOccurrence < today) {
|
||||
nextOccurrence = new Date(Date.UTC(today.getUTCFullYear() + 1, row.month - 1, row.day));
|
||||
}
|
||||
|
||||
const diffMs = nextOccurrence.getTime() - today.getTime();
|
||||
const daysUntil = Math.round(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (daysUntil <= 30) {
|
||||
upcoming.push({ userId: row.user_id, month: row.month, day: row.day, daysUntil });
|
||||
}
|
||||
}
|
||||
|
||||
if (upcoming.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "No birthdays in the next 30 days.");
|
||||
return;
|
||||
}
|
||||
|
||||
upcoming.sort((a, b) => a.daysUntil - b.daysUntil);
|
||||
|
||||
const lines = upcoming.map((entry) => {
|
||||
return `${MONTH_NAMES[entry.month - 1]} ${entry.day} — ${entry.userId}`;
|
||||
});
|
||||
|
||||
await this.sendMessage(ctx.roomId, `Upcoming birthdays (next 30 days):\n${lines.join("\n")}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by DailyScheduler to post birthday announcements.
|
||||
*/
|
||||
async checkAndPost(botRooms: string[]): Promise<void> {
|
||||
const now = new Date();
|
||||
const currentMonth = now.getUTCMonth() + 1;
|
||||
const currentDay = now.getUTCDate();
|
||||
const currentYear = now.getUTCFullYear();
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const matches = db
|
||||
.prepare(`SELECT user_id, room_id, year FROM birthdays WHERE month = ? AND day = ?`)
|
||||
.all(currentMonth, currentDay) as { user_id: string; room_id: string; year: number | null }[];
|
||||
|
||||
if (matches.length === 0) return;
|
||||
|
||||
// Group by user to avoid duplicate DMs
|
||||
const dmSent = new Set<string>();
|
||||
|
||||
for (const match of matches) {
|
||||
// Only post in rooms the bot is active in
|
||||
if (!botRooms.includes(match.room_id)) continue;
|
||||
|
||||
// Check if already fired for this user this year
|
||||
const fired = db
|
||||
.prepare(`SELECT 1 FROM birthday_fired WHERE user_id = ? AND year = ?`)
|
||||
.get(match.user_id, currentYear);
|
||||
|
||||
if (fired) continue;
|
||||
|
||||
// Build announcement
|
||||
let announcement: string;
|
||||
if (match.year) {
|
||||
const age = this.calculateAge(match.year, currentMonth, currentDay, now);
|
||||
announcement = `Happy Birthday ${match.user_id} — turning ${age} today!`;
|
||||
} else {
|
||||
announcement = `Happy Birthday ${match.user_id}!`;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.sendMessage(match.room_id, announcement);
|
||||
this.xpPlugin.grantXp(match.user_id, match.room_id, 100, "birthday");
|
||||
logger.info(`Posted birthday for ${match.user_id} in ${match.room_id}`);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to post birthday for ${match.user_id} in ${match.room_id}: ${err}`);
|
||||
}
|
||||
|
||||
// Send DM once per user
|
||||
if (!dmSent.has(match.user_id)) {
|
||||
dmSent.add(match.user_id);
|
||||
try {
|
||||
await this.sendDm(match.user_id, "Happy Birthday! Hope you have an amazing day!");
|
||||
} catch (err) {
|
||||
logger.error(`Failed to DM birthday user ${match.user_id}: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as fired for this year
|
||||
db.prepare(`INSERT OR IGNORE INTO birthday_fired (user_id, year) VALUES (?, ?)`).run(
|
||||
match.user_id,
|
||||
currentYear
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private calculateAge(birthYear: number, birthMonth: number, birthDay: number, now: Date): number {
|
||||
let age = now.getUTCFullYear() - birthYear;
|
||||
const monthDiff = (now.getUTCMonth() + 1) - birthMonth;
|
||||
if (monthDiff < 0 || (monthDiff === 0 && now.getUTCDate() < birthDay)) {
|
||||
age--;
|
||||
}
|
||||
return age;
|
||||
}
|
||||
}
|
||||
134
src/plugins/botinfo.ts
Normal file
134
src/plugins/botinfo.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
||||
import { getDb } from "../db";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
const LLM_ENABLED = (process.env.OLLAMA_HOST ?? "") !== "" && (process.env.OLLAMA_MODEL ?? "") !== "";
|
||||
const OLLAMA_HOST = (() => {
|
||||
const raw = process.env.OLLAMA_HOST ?? "";
|
||||
return raw && !raw.startsWith("http") ? `http://${raw}` : raw;
|
||||
})();
|
||||
const startTime = Date.now();
|
||||
let messagesProcessed = 0;
|
||||
|
||||
export function incrementMessageCount(): void {
|
||||
messagesProcessed++;
|
||||
}
|
||||
|
||||
export class BotInfoPlugin extends Plugin {
|
||||
constructor(client: IMatrixClient) {
|
||||
super(client);
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "botinfo";
|
||||
}
|
||||
|
||||
get commands(): CommandDef[] {
|
||||
return [
|
||||
{ name: "botinfo", description: "Bot diagnostics", adminOnly: true },
|
||||
];
|
||||
}
|
||||
|
||||
async onMessage(ctx: MessageContext): Promise<void> {
|
||||
incrementMessageCount();
|
||||
|
||||
if (this.isCommand(ctx.body, "botinfo")) {
|
||||
await this.handleBotInfo(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleBotInfo(ctx: MessageContext): Promise<void> {
|
||||
if (!this.isAdmin(ctx.sender)) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Admin only.");
|
||||
return;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const uptimeMs = Date.now() - startTime;
|
||||
const uptime = this.formatUptime(uptimeMs);
|
||||
|
||||
// DB size
|
||||
const dataDir = process.env.DATA_DIR ?? "./data";
|
||||
const dbPath = path.join(dataDir, "freebee.db");
|
||||
let dbSize = "unknown";
|
||||
try {
|
||||
const stats = fs.statSync(dbPath);
|
||||
dbSize = this.formatBytes(stats.size);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Active reminders
|
||||
let activeReminders = 0;
|
||||
try {
|
||||
const row = db.prepare(`SELECT COUNT(*) as count FROM reminders WHERE fired = 0`).get() as { count: number };
|
||||
activeReminders = row.count;
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Total room messages
|
||||
let totalRoomMessages = 0;
|
||||
try {
|
||||
const row = db.prepare(`SELECT SUM(total_messages) as total FROM room_milestones`).get() as { total: number | null };
|
||||
totalRoomMessages = row?.total ?? 0;
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// LLM status
|
||||
let llmStatus = "disabled";
|
||||
if (LLM_ENABLED) {
|
||||
try {
|
||||
const res = await fetch(`${OLLAMA_HOST}/api/tags`, { signal: AbortSignal.timeout(5000) });
|
||||
llmStatus = res.ok ? `running (${process.env.OLLAMA_MODEL})` : `error (HTTP ${res.status})`;
|
||||
} catch {
|
||||
llmStatus = "error (unreachable)";
|
||||
}
|
||||
}
|
||||
|
||||
// Estimated LLM tokens (rough: ~1.3 tokens per word for English)
|
||||
let estimatedTokens = "N/A";
|
||||
if (LLM_ENABLED) {
|
||||
try {
|
||||
const row = db.prepare(`SELECT COUNT(*) as classified FROM llm_classifications`).get() as { classified: number };
|
||||
const wordRow = db.prepare(`SELECT SUM(total_words) as words FROM user_stats`).get() as { words: number | null };
|
||||
const totalWords = wordRow?.words ?? 0;
|
||||
const tokens = Math.round(totalWords * 1.3);
|
||||
estimatedTokens = `~${this.formatNumber(tokens)} tokens (~${this.formatNumber(row.classified)} classifications)`;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const lines = [
|
||||
"Bot Info:",
|
||||
"",
|
||||
`Uptime: ${uptime}`,
|
||||
`Messages processed (session): ${this.formatNumber(messagesProcessed)}`,
|
||||
`Total room messages (all time): ${this.formatNumber(totalRoomMessages)}`,
|
||||
`Database size: ${dbSize}`,
|
||||
`Active reminders: ${activeReminders}`,
|
||||
`LLM classifier: ${llmStatus}`,
|
||||
`LLM tokens processed: ${estimatedTokens}`,
|
||||
];
|
||||
|
||||
await this.sendMessage(ctx.roomId, lines.join("\n"));
|
||||
}
|
||||
|
||||
private formatUptime(ms: number): string {
|
||||
const s = Math.floor(ms / 1000);
|
||||
const days = Math.floor(s / 86400);
|
||||
const hours = Math.floor((s % 86400) / 3600);
|
||||
const minutes = Math.floor((s % 3600) / 60);
|
||||
const parts: string[] = [];
|
||||
if (days > 0) parts.push(`${days}d`);
|
||||
if (hours > 0) parts.push(`${hours}h`);
|
||||
parts.push(`${minutes}m`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
private formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
private formatNumber(n: number): string {
|
||||
return n.toLocaleString("en-US");
|
||||
}
|
||||
}
|
||||
332
src/plugins/concerts.ts
Normal file
332
src/plugins/concerts.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
||||
import { RateLimitsPlugin } from "./ratelimits";
|
||||
import { getDb } from "../db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
interface BandsintownEvent {
|
||||
venue: { name: string; city: string; country: string };
|
||||
datetime: string;
|
||||
offers: any[];
|
||||
lineup: string[];
|
||||
}
|
||||
|
||||
export class ConcertsPlugin extends Plugin {
|
||||
private rateLimitPlugin: RateLimitsPlugin;
|
||||
|
||||
constructor(client: IMatrixClient, rateLimitPlugin: RateLimitsPlugin) {
|
||||
super(client);
|
||||
this.rateLimitPlugin = rateLimitPlugin;
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "concerts";
|
||||
}
|
||||
|
||||
get commands(): CommandDef[] {
|
||||
return [
|
||||
{
|
||||
name: "concerts",
|
||||
description: "Search upcoming concerts or manage your artist watchlist",
|
||||
usage: "!concerts <artist> | !concerts watch <artist> | !concerts watching | !concerts unwatch <artist>",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async onMessage(ctx: MessageContext): Promise<void> {
|
||||
if (!this.isCommand(ctx.body, "concerts")) return;
|
||||
|
||||
const args = this.getArgs(ctx.body, "concerts").trim();
|
||||
|
||||
if (!args) {
|
||||
await this.sendReply(
|
||||
ctx.roomId,
|
||||
ctx.eventId,
|
||||
"Usage: !concerts <artist> | !concerts watch <artist> | !concerts watching | !concerts unwatch <artist>"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.toLowerCase().startsWith("watch ")) {
|
||||
const artist = args.slice(6).trim();
|
||||
if (!artist) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Specify an artist to watch.");
|
||||
return;
|
||||
}
|
||||
await this.handleWatch(ctx, artist);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.toLowerCase() === "watching") {
|
||||
await this.handleWatching(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.toLowerCase().startsWith("unwatch ")) {
|
||||
const artist = args.slice(8).trim();
|
||||
if (!artist) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Specify an artist to unwatch.");
|
||||
return;
|
||||
}
|
||||
await this.handleUnwatch(ctx, artist);
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: search by artist name
|
||||
await this.handleArtistSearch(ctx, args);
|
||||
}
|
||||
|
||||
private getApiKey(): string | undefined {
|
||||
return process.env.BANDSINTOWN_API_KEY;
|
||||
}
|
||||
|
||||
private async handleArtistSearch(ctx: MessageContext, artist: string): Promise<void> {
|
||||
const key = this.getApiKey();
|
||||
if (!key) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Bandsintown API key not configured.");
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = this.rateLimitPlugin.checkLimit(
|
||||
ctx.sender,
|
||||
"concerts",
|
||||
parseInt(process.env.RATELIMIT_CONCERTS ?? "10")
|
||||
);
|
||||
if (!allowed) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "You've reached your daily concerts lookup quota. Try again tomorrow.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const events = await this.fetchArtistEvents(artist, key);
|
||||
|
||||
if (!events || events.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `No upcoming events found for "${artist}".`);
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = events.slice(0, 10).map((evt) => {
|
||||
const lineup = evt.lineup?.join(", ") ?? artist;
|
||||
const venue = evt.venue?.name ?? "Unknown Venue";
|
||||
const city = evt.venue?.city ?? "Unknown City";
|
||||
const date = this.formatDate(evt.datetime);
|
||||
return `${lineup} — ${venue}, ${city} — ${date}`;
|
||||
});
|
||||
|
||||
const header = `Upcoming events for "${artist}" (${Math.min(events.length, 10)} of ${events.length}):`;
|
||||
await this.sendMessage(ctx.roomId, `${header}\n${lines.join("\n")}`);
|
||||
} catch (err) {
|
||||
logger.error(`Concerts artist search failed for "${artist}": ${err}`);
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to fetch concert data. Try again later.");
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchArtistEvents(artist: string, apiKey: string): Promise<BandsintownEvent[]> {
|
||||
const cacheKey = artist.toLowerCase();
|
||||
const db = getDb();
|
||||
|
||||
// Check cache (6 hours = 21600 seconds)
|
||||
const cached = db
|
||||
.prepare(`SELECT data FROM concerts_cache WHERE location_key = ? AND cached_at > unixepoch() - 21600`)
|
||||
.get(cacheKey) as { data: string } | undefined;
|
||||
|
||||
if (cached) {
|
||||
return JSON.parse(cached.data);
|
||||
}
|
||||
|
||||
const url = `https://rest.bandsintown.com/artists/${encodeURIComponent(artist)}/events?app_id=${encodeURIComponent(apiKey)}&date=upcoming`;
|
||||
const res = await fetch(url);
|
||||
|
||||
if (!res.ok) {
|
||||
logger.warn(`Bandsintown API returned ${res.status} for artist "${artist}"`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = (await res.json()) as BandsintownEvent[];
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
db.prepare(
|
||||
`INSERT INTO concerts_cache (location_key, data, cached_at) VALUES (?, ?, unixepoch())
|
||||
ON CONFLICT(location_key) DO UPDATE SET data = excluded.data, cached_at = unixepoch()`
|
||||
).run(cacheKey, JSON.stringify(data));
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
private async handleWatch(ctx: MessageContext, artist: string): Promise<void> {
|
||||
const db = getDb();
|
||||
|
||||
try {
|
||||
db.prepare(
|
||||
`INSERT INTO concert_watchlist (user_id, room_id, artist, created_at) VALUES (?, ?, ?, unixepoch())`
|
||||
).run(ctx.sender, ctx.roomId, artist.toLowerCase());
|
||||
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `Now watching "${artist}" for upcoming concerts.`);
|
||||
} catch (err: any) {
|
||||
if (err?.code === "SQLITE_CONSTRAINT_UNIQUE" || err?.message?.includes("UNIQUE")) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `You're already watching "${artist}".`);
|
||||
} else {
|
||||
logger.error(`Failed to add concert watch for ${ctx.sender}: ${err}`);
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to add artist to watchlist.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleWatching(ctx: MessageContext): Promise<void> {
|
||||
const db = getDb();
|
||||
|
||||
const rows = db
|
||||
.prepare(`SELECT artist FROM concert_watchlist WHERE user_id = ? AND room_id = ? ORDER BY artist`)
|
||||
.all(ctx.sender, ctx.roomId) as { artist: string }[];
|
||||
|
||||
if (rows.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "You're not watching any artists. Use !concerts watch <artist> to add one.");
|
||||
return;
|
||||
}
|
||||
|
||||
const list = rows.map((r) => `- ${r.artist}`).join("\n");
|
||||
await this.sendMessage(ctx.roomId, `Your watched artists:\n${list}`);
|
||||
}
|
||||
|
||||
private async handleUnwatch(ctx: MessageContext, artist: string): Promise<void> {
|
||||
const db = getDb();
|
||||
|
||||
const result = db
|
||||
.prepare(`DELETE FROM concert_watchlist WHERE user_id = ? AND room_id = ? AND artist = ?`)
|
||||
.run(ctx.sender, ctx.roomId, artist.toLowerCase());
|
||||
|
||||
if (result.changes > 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `Stopped watching "${artist}".`);
|
||||
} else {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `You weren't watching "${artist}".`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the scheduler to post a weekly concert digest.
|
||||
* Only posts on Sundays (day 0).
|
||||
*/
|
||||
async postWeeklyDigest(botRooms: string[]): Promise<void> {
|
||||
const today = new Date();
|
||||
if (today.getUTCDay() !== 0) return;
|
||||
|
||||
const key = this.getApiKey();
|
||||
if (!key) {
|
||||
logger.warn("BANDSINTOWN_API_KEY not set, skipping concert digest");
|
||||
return;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Get all unique watched artists across all users
|
||||
const watchedArtists = db
|
||||
.prepare(`SELECT DISTINCT artist FROM concert_watchlist`)
|
||||
.all() as { artist: string }[];
|
||||
|
||||
if (watchedArtists.length === 0) return;
|
||||
|
||||
// Calculate the date range for the next 7 days
|
||||
const now = new Date();
|
||||
const weekFromNow = new Date(now);
|
||||
weekFromNow.setUTCDate(weekFromNow.getUTCDate() + 7);
|
||||
|
||||
const startIso = now.toISOString().slice(0, 10);
|
||||
const endIso = weekFromNow.toISOString().slice(0, 10);
|
||||
|
||||
// Fetch events for each watched artist
|
||||
const upcomingShows: { artist: string; events: BandsintownEvent[] }[] = [];
|
||||
|
||||
for (const { artist } of watchedArtists) {
|
||||
try {
|
||||
const events = await this.fetchArtistEvents(artist, key);
|
||||
const thisWeek = events.filter((evt) => {
|
||||
const eventDate = evt.datetime?.slice(0, 10);
|
||||
return eventDate && eventDate >= startIso && eventDate <= endIso;
|
||||
});
|
||||
|
||||
if (thisWeek.length > 0) {
|
||||
upcomingShows.push({ artist, events: thisWeek });
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Failed to fetch events for watched artist "${artist}": ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (upcomingShows.length === 0) return;
|
||||
|
||||
// Build summary message for bot rooms
|
||||
const lines: string[] = ["Concert Digest — This Week:", ""];
|
||||
for (const { artist, events } of upcomingShows) {
|
||||
for (const evt of events) {
|
||||
const venue = evt.venue?.name ?? "Unknown Venue";
|
||||
const city = evt.venue?.city ?? "Unknown City";
|
||||
const date = this.formatDate(evt.datetime);
|
||||
const lineup = evt.lineup?.join(", ") ?? artist;
|
||||
lines.push(`${lineup} — ${venue}, ${city} — ${date}`);
|
||||
}
|
||||
}
|
||||
|
||||
const message = lines.join("\n");
|
||||
|
||||
for (const roomId of botRooms) {
|
||||
try {
|
||||
await this.sendMessage(roomId, message);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to post concert digest to ${roomId}: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// DM users who are watching artists with shows this week
|
||||
const artistsWithShows = new Set(upcomingShows.map((s) => s.artist));
|
||||
|
||||
const watchers = db
|
||||
.prepare(`SELECT DISTINCT user_id, artist FROM concert_watchlist WHERE artist IN (${[...artistsWithShows].map(() => "?").join(",")})`)
|
||||
.all(...artistsWithShows) as { user_id: string; artist: string }[];
|
||||
|
||||
// Group by user
|
||||
const userArtists = new Map<string, string[]>();
|
||||
for (const { user_id, artist } of watchers) {
|
||||
if (!userArtists.has(user_id)) userArtists.set(user_id, []);
|
||||
userArtists.get(user_id)!.push(artist);
|
||||
}
|
||||
|
||||
for (const [userId, artists] of userArtists) {
|
||||
const dmLines: string[] = ["Concerts this week for your watched artists:", ""];
|
||||
for (const artist of artists) {
|
||||
const showData = upcomingShows.find((s) => s.artist === artist);
|
||||
if (!showData) continue;
|
||||
for (const evt of showData.events) {
|
||||
const venue = evt.venue?.name ?? "Unknown Venue";
|
||||
const city = evt.venue?.city ?? "Unknown City";
|
||||
const date = this.formatDate(evt.datetime);
|
||||
const lineup = evt.lineup?.join(", ") ?? artist;
|
||||
dmLines.push(`${lineup} — ${venue}, ${city} — ${date}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await this.sendDm(userId, dmLines.join("\n"));
|
||||
} catch (err) {
|
||||
logger.error(`Failed to DM concert digest to ${userId}: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private formatDate(datetime: string): string {
|
||||
try {
|
||||
const d = new Date(datetime);
|
||||
return d.toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
} catch {
|
||||
return datetime?.slice(0, 10) ?? "Unknown Date";
|
||||
}
|
||||
}
|
||||
}
|
||||
277
src/plugins/countdown.ts
Normal file
277
src/plugins/countdown.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
||||
import { getDb } from "../db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
const MONTH_NAMES = [
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
];
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const d = new Date(dateStr + "T00:00:00Z");
|
||||
const month = MONTH_NAMES[d.getUTCMonth()];
|
||||
const day = d.getUTCDate();
|
||||
const year = d.getUTCFullYear();
|
||||
return `${month} ${day}, ${year}`;
|
||||
}
|
||||
|
||||
function todayStr(): string {
|
||||
const now = new Date();
|
||||
const y = now.getUTCFullYear();
|
||||
const m = String(now.getUTCMonth() + 1).padStart(2, "0");
|
||||
const d = String(now.getUTCDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
function daysBetween(a: string, b: string): number {
|
||||
const msA = new Date(a + "T00:00:00Z").getTime();
|
||||
const msB = new Date(b + "T00:00:00Z").getTime();
|
||||
return Math.round((msB - msA) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
interface CountdownRow {
|
||||
id: number;
|
||||
user_id: string;
|
||||
room_id: string;
|
||||
label: string;
|
||||
target_date: string;
|
||||
public: number;
|
||||
completed: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export class CountdownPlugin extends Plugin {
|
||||
constructor(client: IMatrixClient) {
|
||||
super(client);
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "countdown";
|
||||
}
|
||||
|
||||
get commands(): CommandDef[] {
|
||||
return [
|
||||
{ name: "countdown add", description: "Add a public countdown", usage: '!countdown add "<label>" <YYYY-MM-DD>' },
|
||||
{ name: "countdown private", description: "Add a private countdown", usage: '!countdown private "<label>" <YYYY-MM-DD>' },
|
||||
{ name: "countdown mine", description: "List your own countdowns" },
|
||||
{ name: "countdown remove", description: "Remove a countdown", usage: "!countdown remove <id>" },
|
||||
{ name: "countdown", description: "List countdowns or show a specific one", usage: "!countdown [id]" },
|
||||
];
|
||||
}
|
||||
|
||||
async onMessage(ctx: MessageContext): Promise<void> {
|
||||
if (this.isCommand(ctx.body, "countdown add")) {
|
||||
await this.handleAdd(ctx, true);
|
||||
} else if (this.isCommand(ctx.body, "countdown private")) {
|
||||
await this.handleAdd(ctx, false);
|
||||
} else if (this.isCommand(ctx.body, "countdown mine")) {
|
||||
await this.handleMine(ctx);
|
||||
} else if (this.isCommand(ctx.body, "countdown remove")) {
|
||||
await this.handleRemove(ctx);
|
||||
} else if (this.isCommand(ctx.body, "countdown")) {
|
||||
const args = this.getArgs(ctx.body, "countdown").trim();
|
||||
if (args && /^\d+$/.test(args)) {
|
||||
await this.handleShow(ctx, parseInt(args));
|
||||
} else if (!args) {
|
||||
await this.handleList(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private parseAddArgs(raw: string): { label: string; date: string } | null {
|
||||
// Expect: "<label>" <YYYY-MM-DD>
|
||||
const match = raw.match(/^"([^"]+)"\s+(\d{4}-\d{2}-\d{2})$/);
|
||||
if (!match) return null;
|
||||
return { label: match[1], date: match[2] };
|
||||
}
|
||||
|
||||
private isValidDate(dateStr: string): boolean {
|
||||
const d = new Date(dateStr + "T00:00:00Z");
|
||||
return !isNaN(d.getTime());
|
||||
}
|
||||
|
||||
private async handleAdd(ctx: MessageContext, isPublic: boolean): Promise<void> {
|
||||
const command = isPublic ? "countdown add" : "countdown private";
|
||||
const args = this.getArgs(ctx.body, command).trim();
|
||||
const parsed = this.parseAddArgs(args);
|
||||
|
||||
if (!parsed) {
|
||||
const usage = isPublic
|
||||
? '!countdown add "<label>" <YYYY-MM-DD>'
|
||||
: '!countdown private "<label>" <YYYY-MM-DD>';
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `Usage: ${usage}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isValidDate(parsed.date)) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Invalid date format. Use YYYY-MM-DD.");
|
||||
return;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const result = db.prepare(
|
||||
`INSERT INTO countdowns (user_id, room_id, label, target_date, public) VALUES (?, ?, ?, ?, ?)`
|
||||
).run(ctx.sender, ctx.roomId, parsed.label, parsed.date, isPublic ? 1 : 0);
|
||||
|
||||
const id = result.lastInsertRowid;
|
||||
const today = todayStr();
|
||||
const days = daysBetween(today, parsed.date);
|
||||
const direction = days >= 0 ? `${days} days away` : `${Math.abs(days)} days ago`;
|
||||
const visibility = isPublic ? "public" : "private";
|
||||
|
||||
await this.sendReply(
|
||||
ctx.roomId,
|
||||
ctx.eventId,
|
||||
`Countdown #${id} added (${visibility}): "${parsed.label}" on ${formatDate(parsed.date)} — ${direction}.`
|
||||
);
|
||||
}
|
||||
|
||||
private async handleRemove(ctx: MessageContext): Promise<void> {
|
||||
const idStr = this.getArgs(ctx.body, "countdown remove").trim();
|
||||
if (!idStr || !/^\d+$/.test(idStr)) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !countdown remove <id>");
|
||||
return;
|
||||
}
|
||||
|
||||
const id = parseInt(idStr);
|
||||
const db = getDb();
|
||||
const result = db.prepare(
|
||||
`DELETE FROM countdowns WHERE id = ? AND user_id = ?`
|
||||
).run(id, ctx.sender);
|
||||
|
||||
if (result.changes > 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `Countdown #${id} removed.`);
|
||||
} else {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `No countdown found with ID ${id} (or it's not yours).`);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleShow(ctx: MessageContext, id: number): Promise<void> {
|
||||
const db = getDb();
|
||||
const row = db.prepare(
|
||||
`SELECT * FROM countdowns WHERE id = ? AND room_id = ? AND completed = 0`
|
||||
).get(id, ctx.roomId) as CountdownRow | undefined;
|
||||
|
||||
if (!row) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `No active countdown found with ID ${id}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only show private countdowns to their owner
|
||||
if (!row.public && row.user_id !== ctx.sender) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `No active countdown found with ID ${id}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const today = todayStr();
|
||||
const days = daysBetween(today, row.target_date);
|
||||
const visibility = row.public ? "public" : "private";
|
||||
|
||||
let detail: string;
|
||||
if (days > 0) {
|
||||
detail = `#${row.id} "${row.label}" — ${days} days (${formatDate(row.target_date)}) [${visibility}]`;
|
||||
} else if (days === 0) {
|
||||
detail = `#${row.id} "${row.label}" — today! (${formatDate(row.target_date)}) [${visibility}]`;
|
||||
} else {
|
||||
detail = `#${row.id} "${row.label}" — happened ${Math.abs(days)} days ago (${formatDate(row.target_date)}) [${visibility}]`;
|
||||
}
|
||||
|
||||
await this.sendMessage(ctx.roomId, detail);
|
||||
}
|
||||
|
||||
private async handleList(ctx: MessageContext): Promise<void> {
|
||||
const db = getDb();
|
||||
const today = todayStr();
|
||||
|
||||
// Fetch all public countdowns for this room + sender's private ones, not yet completed
|
||||
const rows = db.prepare(
|
||||
`SELECT * FROM countdowns
|
||||
WHERE room_id = ? AND completed = 0
|
||||
AND (public = 1 OR user_id = ?)
|
||||
ORDER BY target_date ASC`
|
||||
).all(ctx.roomId, ctx.sender) as CountdownRow[];
|
||||
|
||||
// Cleanup: mark completed any that are past > 7 days
|
||||
const toComplete: number[] = [];
|
||||
const visible: CountdownRow[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const days = daysBetween(today, row.target_date);
|
||||
if (days < 0 && Math.abs(days) > 7) {
|
||||
toComplete.push(row.id);
|
||||
} else {
|
||||
visible.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
if (toComplete.length > 0) {
|
||||
const placeholders = toComplete.map(() => "?").join(",");
|
||||
db.prepare(`UPDATE countdowns SET completed = 1 WHERE id IN (${placeholders})`).run(...toComplete);
|
||||
}
|
||||
|
||||
if (visible.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "No active countdowns.");
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = this.formatCountdownLines(visible, today);
|
||||
await this.sendMessage(ctx.roomId, `Community Countdowns\n\n${lines.join("\n")}`);
|
||||
}
|
||||
|
||||
private async handleMine(ctx: MessageContext): Promise<void> {
|
||||
const db = getDb();
|
||||
const today = todayStr();
|
||||
|
||||
const rows = db.prepare(
|
||||
`SELECT * FROM countdowns
|
||||
WHERE user_id = ? AND room_id = ? AND completed = 0
|
||||
ORDER BY target_date ASC`
|
||||
).all(ctx.sender, ctx.roomId) as CountdownRow[];
|
||||
|
||||
// Cleanup: mark completed any that are past > 7 days
|
||||
const toComplete: number[] = [];
|
||||
const visible: CountdownRow[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const days = daysBetween(today, row.target_date);
|
||||
if (days < 0 && Math.abs(days) > 7) {
|
||||
toComplete.push(row.id);
|
||||
} else {
|
||||
visible.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
if (toComplete.length > 0) {
|
||||
const placeholders = toComplete.map(() => "?").join(",");
|
||||
db.prepare(`UPDATE countdowns SET completed = 1 WHERE id IN (${placeholders})`).run(...toComplete);
|
||||
}
|
||||
|
||||
if (visible.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "You have no active countdowns.");
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = this.formatCountdownLines(visible, today);
|
||||
await this.sendMessage(ctx.roomId, `Your Countdowns\n\n${lines.join("\n")}`);
|
||||
}
|
||||
|
||||
private formatCountdownLines(rows: CountdownRow[], today: string): string[] {
|
||||
return rows.map((row) => {
|
||||
const days = daysBetween(today, row.target_date);
|
||||
const label = row.label;
|
||||
|
||||
if (days > 0) {
|
||||
const pad = " ".repeat(Math.max(0, 30 - label.length));
|
||||
return `${label}${pad} — ${days} days (${formatDate(row.target_date)})`;
|
||||
} else if (days === 0) {
|
||||
const pad = " ".repeat(Math.max(0, 30 - label.length));
|
||||
return `${label}${pad} — today! (${formatDate(row.target_date)})`;
|
||||
} else {
|
||||
const ago = Math.abs(days);
|
||||
const padLabel = `\u2705 ${label}`;
|
||||
const pad = " ".repeat(Math.max(0, 30 - padLabel.length));
|
||||
return `${padLabel}${pad} — happened ${ago} days ago`;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
227
src/plugins/daily.ts
Normal file
227
src/plugins/daily.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
||||
import { getDb } from "../db";
|
||||
import { RemindersPlugin } from "./reminders";
|
||||
import { WotdPlugin } from "./wotd";
|
||||
import { HolidaysPlugin } from "./holidays";
|
||||
import { GamingPlugin } from "./gaming";
|
||||
import { BirthdayPlugin } from "./birthday";
|
||||
import { AnimePlugin } from "./anime";
|
||||
import { MoviesPlugin } from "./movies";
|
||||
import { ConcertsPlugin } from "./concerts";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
interface ScheduledJob {
|
||||
job_name: string;
|
||||
hour: number;
|
||||
minute: number;
|
||||
enabled: number;
|
||||
}
|
||||
|
||||
export class DailyScheduler extends Plugin {
|
||||
private remindersPlugin: RemindersPlugin;
|
||||
private wotdPlugin: WotdPlugin;
|
||||
private holidaysPlugin: HolidaysPlugin;
|
||||
private gamingPlugin: GamingPlugin;
|
||||
private birthdayPlugin: BirthdayPlugin;
|
||||
private animePlugin: AnimePlugin;
|
||||
private moviesPlugin: MoviesPlugin;
|
||||
private concertsPlugin: ConcertsPlugin;
|
||||
private botRooms: string[];
|
||||
private firedToday = new Set<string>();
|
||||
private tickInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private reminderInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(
|
||||
client: IMatrixClient,
|
||||
remindersPlugin: RemindersPlugin,
|
||||
wotdPlugin: WotdPlugin,
|
||||
holidaysPlugin: HolidaysPlugin,
|
||||
gamingPlugin: GamingPlugin,
|
||||
birthdayPlugin: BirthdayPlugin,
|
||||
animePlugin: AnimePlugin,
|
||||
moviesPlugin: MoviesPlugin,
|
||||
concertsPlugin: ConcertsPlugin,
|
||||
botRooms: string[]
|
||||
) {
|
||||
super(client);
|
||||
this.remindersPlugin = remindersPlugin;
|
||||
this.wotdPlugin = wotdPlugin;
|
||||
this.holidaysPlugin = holidaysPlugin;
|
||||
this.gamingPlugin = gamingPlugin;
|
||||
this.birthdayPlugin = birthdayPlugin;
|
||||
this.animePlugin = animePlugin;
|
||||
this.moviesPlugin = moviesPlugin;
|
||||
this.concertsPlugin = concertsPlugin;
|
||||
this.botRooms = botRooms;
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "daily";
|
||||
}
|
||||
|
||||
get commands(): CommandDef[] {
|
||||
return [
|
||||
{ name: "schedule", description: "Adjust scheduled post time (admin)", usage: "!schedule <job> <HH:MM>", adminOnly: true },
|
||||
];
|
||||
}
|
||||
|
||||
start(): void {
|
||||
// Check scheduled jobs every 60 seconds
|
||||
this.tickInterval = setInterval(() => this.tick(), 60_000);
|
||||
|
||||
// Check reminders every 30 seconds
|
||||
this.reminderInterval = setInterval(() => {
|
||||
this.remindersPlugin.checkReminders().catch((err) => {
|
||||
logger.error(`Reminder check failed: ${err}`);
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
logger.info("DailyScheduler started");
|
||||
}
|
||||
|
||||
/**
|
||||
* Run missed jobs. Call this AFTER the Matrix client has fully started
|
||||
* and E2EE is initialized.
|
||||
*/
|
||||
async catchUpMissedJobs(): Promise<void> {
|
||||
// Short delay to ensure crypto is fully ready
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
await this.catchUp();
|
||||
}
|
||||
|
||||
private async catchUp(): Promise<void> {
|
||||
const now = new Date();
|
||||
const currentHour = now.getUTCHours();
|
||||
const currentMinute = now.getUTCMinutes();
|
||||
const today = now.toISOString().slice(0, 10);
|
||||
|
||||
const db = getDb();
|
||||
const jobs = db.prepare(`SELECT * FROM scheduler_config WHERE enabled = 1`).all() as ScheduledJob[];
|
||||
|
||||
for (const job of jobs) {
|
||||
const scheduledMinutes = job.hour * 60 + job.minute;
|
||||
const currentMinutes = currentHour * 60 + currentMinute;
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.tickInterval) clearInterval(this.tickInterval);
|
||||
if (this.reminderInterval) clearInterval(this.reminderInterval);
|
||||
}
|
||||
|
||||
async onMessage(ctx: MessageContext): Promise<void> {
|
||||
if (this.isCommand(ctx.body, "schedule")) {
|
||||
await this.handleSchedule(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
private tick(): void {
|
||||
const now = new Date();
|
||||
const currentHour = now.getUTCHours();
|
||||
const currentMinute = now.getUTCMinutes();
|
||||
const today = now.toISOString().slice(0, 10);
|
||||
|
||||
const db = getDb();
|
||||
const jobs = db.prepare(`SELECT * FROM scheduler_config WHERE enabled = 1`).all() as ScheduledJob[];
|
||||
|
||||
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;
|
||||
|
||||
this.firedToday.add(key);
|
||||
this.runJob(job.job_name).catch((err) => {
|
||||
logger.error(`Scheduled job ${job.job_name} failed: ${err}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up old entries from firedToday (keep only today's)
|
||||
for (const key of this.firedToday) {
|
||||
if (!key.endsWith(today)) {
|
||||
this.firedToday.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async runJob(jobName: string): Promise<void> {
|
||||
logger.info(`Running scheduled job: ${jobName}`);
|
||||
|
||||
switch (jobName) {
|
||||
case "wotd":
|
||||
await this.wotdPlugin.postWotd(this.botRooms);
|
||||
break;
|
||||
case "holidays":
|
||||
await this.holidaysPlugin.postHolidays(this.botRooms);
|
||||
break;
|
||||
case "releases":
|
||||
await this.gamingPlugin.postReleases(this.botRooms);
|
||||
break;
|
||||
case "birthday_check":
|
||||
await this.birthdayPlugin.checkAndPost(this.botRooms);
|
||||
break;
|
||||
case "anime_releases":
|
||||
await this.animePlugin.postDailyReleases(this.botRooms);
|
||||
break;
|
||||
case "movie_releases":
|
||||
await this.moviesPlugin.postDailyReleases(this.botRooms);
|
||||
break;
|
||||
case "concert_digest":
|
||||
await this.concertsPlugin.postWeeklyDigest(this.botRooms);
|
||||
break;
|
||||
default:
|
||||
logger.warn(`Unknown scheduled job: ${jobName}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleSchedule(ctx: MessageContext): Promise<void> {
|
||||
if (!this.isAdmin(ctx.sender)) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "This command is admin-only.");
|
||||
return;
|
||||
}
|
||||
|
||||
const args = this.getArgs(ctx.body, "schedule");
|
||||
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");
|
||||
return;
|
||||
}
|
||||
|
||||
const [, jobName, hourStr, minuteStr] = match;
|
||||
const hour = parseInt(hourStr);
|
||||
const minute = parseInt(minuteStr);
|
||||
|
||||
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Invalid time. Use HH:MM in 24-hour UTC format.");
|
||||
return;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const result = db
|
||||
.prepare(`UPDATE scheduler_config SET hour = ?, minute = ? WHERE job_name = ?`)
|
||||
.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`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear today's fired status so it can re-fire at the new time
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
this.firedToday.delete(`${jobName}:${today}`);
|
||||
|
||||
await this.sendReply(
|
||||
ctx.roomId,
|
||||
ctx.eventId,
|
||||
`Scheduled "${jobName}" updated to ${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")} UTC.`
|
||||
);
|
||||
}
|
||||
}
|
||||
490
src/plugins/fun.ts
Normal file
490
src/plugins/fun.ts
Normal file
@@ -0,0 +1,490 @@
|
||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
||||
import { getDb } from "../db";
|
||||
import { DateTime, IANAZone } from "luxon";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
// City map (shared with user.ts but duplicated here to avoid coupling)
|
||||
const CITY_TIMEZONE_MAP: Record<string, string> = {
|
||||
"new york": "America/New_York", "nyc": "America/New_York",
|
||||
"los angeles": "America/Los_Angeles", "la": "America/Los_Angeles",
|
||||
"chicago": "America/Chicago", "denver": "America/Denver",
|
||||
"london": "Europe/London", "paris": "Europe/Paris",
|
||||
"berlin": "Europe/Berlin", "tokyo": "Asia/Tokyo",
|
||||
"sydney": "Australia/Sydney", "toronto": "America/Toronto",
|
||||
"vancouver": "America/Vancouver", "mumbai": "Asia/Kolkata",
|
||||
"dubai": "Asia/Dubai", "singapore": "Asia/Singapore",
|
||||
"hong kong": "Asia/Hong_Kong", "seoul": "Asia/Seoul",
|
||||
"beijing": "Asia/Shanghai", "moscow": "Europe/Moscow",
|
||||
"istanbul": "Europe/Istanbul", "cairo": "Africa/Cairo",
|
||||
"sao paulo": "America/Sao_Paulo", "mexico city": "America/Mexico_City",
|
||||
"amsterdam": "Europe/Amsterdam", "rome": "Europe/Rome",
|
||||
"madrid": "Europe/Madrid", "bangkok": "Asia/Bangkok",
|
||||
"honolulu": "Pacific/Honolulu", "hawaii": "Pacific/Honolulu",
|
||||
"lisbon": "Europe/Lisbon", "dublin": "Europe/Dublin",
|
||||
"athens": "Europe/Athens", "helsinki": "Europe/Helsinki",
|
||||
"warsaw": "Europe/Warsaw", "prague": "Europe/Prague",
|
||||
"vienna": "Europe/Vienna", "zurich": "Europe/Zurich",
|
||||
"brussels": "Europe/Brussels", "oslo": "Europe/Oslo",
|
||||
"stockholm": "Europe/Stockholm", "copenhagen": "Europe/Copenhagen",
|
||||
"bucharest": "Europe/Bucharest", "budapest": "Europe/Budapest",
|
||||
"jakarta": "Asia/Jakarta", "manila": "Asia/Manila",
|
||||
"taipei": "Asia/Taipei", "shanghai": "Asia/Shanghai",
|
||||
"kolkata": "Asia/Kolkata", "delhi": "Asia/Kolkata",
|
||||
"karachi": "Asia/Karachi", "dhaka": "Asia/Dhaka",
|
||||
"riyadh": "Asia/Riyadh", "tehran": "Asia/Tehran",
|
||||
"johannesburg": "Africa/Johannesburg", "nairobi": "Africa/Nairobi",
|
||||
"lagos": "Africa/Lagos", "casablanca": "Africa/Casablanca",
|
||||
"lima": "America/Lima", "bogota": "America/Bogota",
|
||||
"buenos aires": "America/Argentina/Buenos_Aires",
|
||||
"santiago": "America/Santiago", "anchorage": "America/Anchorage",
|
||||
"auckland": "Pacific/Auckland",
|
||||
};
|
||||
|
||||
/** Try to resolve a city name to an IANA timezone by checking Region/City patterns. */
|
||||
function guessIANAZone(city: string): string | null {
|
||||
// Capitalize words and replace spaces with underscores to match IANA format
|
||||
const formatted = city
|
||||
.split(/[\s-]+/)
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
||||
.join("_");
|
||||
|
||||
const regions = ["Europe", "America", "Asia", "Africa", "Pacific", "Australia", "Atlantic", "Indian", "Arctic"];
|
||||
for (const region of regions) {
|
||||
const candidate = `${region}/${formatted}`;
|
||||
if (IANAZone.isValidZone(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const EIGHT_BALL_RESPONSES = [
|
||||
"It is certain.", "It is decidedly so.", "Without a doubt.",
|
||||
"Yes, definitely.", "You may rely on it.", "As I see it, yes.",
|
||||
"Most likely.", "Outlook good.", "Yes.", "Signs point to yes.",
|
||||
"Reply hazy, try again.", "Ask again later.", "Better not tell you now.",
|
||||
"Cannot predict now.", "Concentrate and ask again.",
|
||||
"Don't count on it.", "My reply is no.", "My sources say no.",
|
||||
"Outlook not so good.", "Very doubtful.",
|
||||
];
|
||||
|
||||
const TWINBEE_FACTS = [
|
||||
"TwinBee first appeared in 1985 as a Konami arcade game.",
|
||||
"TwinBee is a cute 'em up (kawaii shooter) — one of the first of its kind.",
|
||||
"The bell power-up system is TwinBee's signature mechanic — juggle bells to change their color!",
|
||||
"Yellow bells give points, blue bells increase speed, white bells give twin shots, green bells give a shield.",
|
||||
"TwinBee's partner is WinBee, a pink ship piloted by Light's girlfriend Pastel.",
|
||||
"The TwinBee series inspired the Parodius games, Konami's parody shooter line.",
|
||||
"Detana!! TwinBee (1991) is considered the series' masterpiece.",
|
||||
"TwinBee Rainbow Bell Adventure turned the shooter into a platformer.",
|
||||
"TwinBee had an anime series called 'TwinBee Paradise' in the 90s.",
|
||||
"In Pop'n TwinBee, you could punch enemies by getting close — a mechanic unique to the SNES version.",
|
||||
"GwinBee was TwinBee's baby sibling, first appearing in Stinger (1987).",
|
||||
"TwinBee's pilot is named Light, and he's the grandson of Dr. Cinnamon.",
|
||||
"The TwinBee series has over 15 entries, spanning arcade, console, and handheld.",
|
||||
"Parodius (from Parody + Gradius) used TwinBee as a playable character alongside Vic Viper.",
|
||||
];
|
||||
|
||||
export class FunPlugin extends Plugin {
|
||||
constructor(client: IMatrixClient) {
|
||||
super(client);
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "fun";
|
||||
}
|
||||
|
||||
get commands(): CommandDef[] {
|
||||
return [
|
||||
{ name: "roll", description: "Dice roller", usage: "!roll <NdN+M>" },
|
||||
{ name: "8ball", description: "Magic 8-ball", usage: "!8ball <question>" },
|
||||
{ name: "coin", description: "Coin flip" },
|
||||
{ name: "time", description: "World clock", usage: "!time <city|@user>..." },
|
||||
{ name: "hltb", description: "HowLongToBeat lookup", usage: "!hltb <game>" },
|
||||
{ name: "twinbee", description: "Random TwinBee lore" },
|
||||
{ name: "poll", description: "Reaction poll", usage: '!poll "Q" "A" "B" ...' },
|
||||
{ name: "weather", description: "Current weather", usage: "!weather <city|zip[,CC]|@user>" },
|
||||
{ name: "dadjoke", description: "Random dad joke" },
|
||||
{ name: "randomwiki", description: "Random Wikipedia article" },
|
||||
];
|
||||
}
|
||||
|
||||
async onMessage(ctx: MessageContext): Promise<void> {
|
||||
if (this.isCommand(ctx.body, "8ball")) {
|
||||
await this.handle8ball(ctx);
|
||||
} else if (this.isCommand(ctx.body, "roll")) {
|
||||
await this.handleRoll(ctx);
|
||||
} else if (this.isCommand(ctx.body, "coin")) {
|
||||
await this.handleCoin(ctx);
|
||||
} else if (this.isCommand(ctx.body, "time")) {
|
||||
await this.handleTime(ctx);
|
||||
} else if (this.isCommand(ctx.body, "hltb")) {
|
||||
await this.handleHltb(ctx);
|
||||
} else if (this.isCommand(ctx.body, "twinbee")) {
|
||||
await this.handleTwinbee(ctx);
|
||||
} else if (this.isCommand(ctx.body, "weather")) {
|
||||
await this.handleWeather(ctx);
|
||||
} else if (this.isCommand(ctx.body, "poll")) {
|
||||
await this.handlePoll(ctx);
|
||||
} else if (this.isCommand(ctx.body, "dadjoke")) {
|
||||
await this.handleDadJoke(ctx);
|
||||
} else if (this.isCommand(ctx.body, "randomwiki")) {
|
||||
await this.handleRandomWiki(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleRoll(ctx: MessageContext): Promise<void> {
|
||||
const args = this.getArgs(ctx.body, "roll") || "1d6";
|
||||
const match = args.match(/^(\d+)?d(\d+)([+-]\d+)?$/i);
|
||||
|
||||
if (!match) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !roll [N]d<sides>[+/-modifier]\nExamples: !roll d20, !roll 2d6+3");
|
||||
return;
|
||||
}
|
||||
|
||||
const count = Math.min(parseInt(match[1] || "1"), 100);
|
||||
const sides = Math.min(parseInt(match[2]), 1000);
|
||||
const modifier = parseInt(match[3] || "0");
|
||||
|
||||
if (sides < 1) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Dice need at least 1 side!");
|
||||
return;
|
||||
}
|
||||
|
||||
const rolls: number[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
rolls.push(Math.floor(Math.random() * sides) + 1);
|
||||
}
|
||||
|
||||
const sum = rolls.reduce((a, b) => a + b, 0) + modifier;
|
||||
const rollStr = count > 1 ? ` (${rolls.join(", ")})` : "";
|
||||
const modStr = modifier !== 0 ? ` ${modifier > 0 ? "+" : ""}${modifier}` : "";
|
||||
|
||||
await this.sendMessage(ctx.roomId, `${ctx.sender} rolled ${count}d${sides}${modStr}: ${sum}${rollStr}`);
|
||||
}
|
||||
|
||||
private async handle8ball(ctx: MessageContext): Promise<void> {
|
||||
const response = EIGHT_BALL_RESPONSES[Math.floor(Math.random() * EIGHT_BALL_RESPONSES.length)];
|
||||
await this.sendMessage(ctx.roomId, `The Magic 8-Ball says: ${response}`);
|
||||
}
|
||||
|
||||
private async handleCoin(ctx: MessageContext): Promise<void> {
|
||||
const result = Math.random() < 0.5 ? "Heads" : "Tails";
|
||||
await this.sendMessage(ctx.roomId, `${ctx.sender} flipped a coin: ${result}!`);
|
||||
}
|
||||
|
||||
private async handleTime(ctx: MessageContext): Promise<void> {
|
||||
const args = this.getArgs(ctx.body, "time");
|
||||
if (!args) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !time <city|@user> [city2] ...");
|
||||
return;
|
||||
}
|
||||
|
||||
// Split by comma or multiple words that could be cities/@users
|
||||
const queries = args.split(/,\s*/).map((s) => s.trim()).filter(Boolean);
|
||||
const results: string[] = [];
|
||||
|
||||
for (const query of queries) {
|
||||
if (query.startsWith("@")) {
|
||||
// Look up user timezone
|
||||
const db = getDb();
|
||||
const row = db
|
||||
.prepare(`SELECT timezone FROM users WHERE user_id = ? AND room_id = ?`)
|
||||
.get(query, ctx.roomId) as { timezone: string | null } | undefined;
|
||||
|
||||
if (row?.timezone) {
|
||||
const now = DateTime.now().setZone(row.timezone);
|
||||
results.push(`${query}: ${now.toFormat("HH:mm (ZZZZZ)")} [${row.timezone}]`);
|
||||
} else {
|
||||
results.push(`${query}: timezone not set`);
|
||||
}
|
||||
} else {
|
||||
const lower = query.toLowerCase();
|
||||
const tz = CITY_TIMEZONE_MAP[lower] || (IANAZone.isValidZone(query) ? query : null) || guessIANAZone(lower);
|
||||
|
||||
if (tz) {
|
||||
const now = DateTime.now().setZone(tz);
|
||||
results.push(`${query}: ${now.toFormat("HH:mm (ZZZZZ)")} [${tz}]`);
|
||||
} else {
|
||||
results.push(`${query}: unknown timezone`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.sendMessage(ctx.roomId, results.join("\n"));
|
||||
}
|
||||
|
||||
private async handleHltb(ctx: MessageContext): Promise<void> {
|
||||
const game = this.getArgs(ctx.body, "hltb");
|
||||
if (!game) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !hltb <game name>");
|
||||
return;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Check cache (24h TTL)
|
||||
const cached = db
|
||||
.prepare(`SELECT * FROM hltb_cache WHERE search_term = ? AND fetched_at > datetime('now', '-24 hours')`)
|
||||
.get(game.toLowerCase()) as any;
|
||||
|
||||
if (cached) {
|
||||
await this.sendHltbResult(ctx.roomId, cached);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { HowLongToBeatService } = await import("howlongtobeat");
|
||||
const service = new HowLongToBeatService();
|
||||
const results = await service.search(game);
|
||||
|
||||
if (!results || results.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `No HLTB results found for "${game}".`);
|
||||
return;
|
||||
}
|
||||
|
||||
const best = results[0];
|
||||
db.prepare(`
|
||||
INSERT INTO hltb_cache (game_name, search_term, main_story, main_extra, completionist, data_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
best.name,
|
||||
game.toLowerCase(),
|
||||
best.gameplayMain ?? null,
|
||||
best.gameplayMainExtra ?? null,
|
||||
best.gameplayCompletionist ?? null,
|
||||
JSON.stringify(best)
|
||||
);
|
||||
|
||||
await this.sendHltbResult(ctx.roomId, {
|
||||
game_name: best.name,
|
||||
main_story: best.gameplayMain,
|
||||
main_extra: best.gameplayMainExtra,
|
||||
completionist: best.gameplayCompletionist,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`HLTB lookup failed: ${err}`);
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "HLTB lookup failed. Try again later.");
|
||||
}
|
||||
}
|
||||
|
||||
private async sendHltbResult(roomId: string, data: { game_name: string; main_story: number | null; main_extra: number | null; completionist: number | null }): Promise<void> {
|
||||
const formatHours = (h: number | null) => (h ? `${h} hours` : "N/A");
|
||||
await this.sendMessage(
|
||||
roomId,
|
||||
`${data.game_name}:\n Main Story: ${formatHours(data.main_story)}\n Main + Extra: ${formatHours(data.main_extra)}\n Completionist: ${formatHours(data.completionist)}`
|
||||
);
|
||||
}
|
||||
|
||||
private async handleTwinbee(ctx: MessageContext): Promise<void> {
|
||||
const fact = TWINBEE_FACTS[Math.floor(Math.random() * TWINBEE_FACTS.length)];
|
||||
await this.sendMessage(ctx.roomId, fact);
|
||||
}
|
||||
|
||||
private async handleWeather(ctx: MessageContext): Promise<void> {
|
||||
const apiKey = process.env.OPENWEATHER_API_KEY;
|
||||
if (!apiKey) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "OpenWeather API key not configured.");
|
||||
return;
|
||||
}
|
||||
|
||||
const args = this.getArgs(ctx.body, "weather");
|
||||
if (!args) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !weather <city> or !weather @user");
|
||||
return;
|
||||
}
|
||||
|
||||
let query = args;
|
||||
|
||||
// If it's a user mention, look up their timezone and derive a city
|
||||
if (args.startsWith("@")) {
|
||||
const userId = args.split(/\s/)[0];
|
||||
const db = getDb();
|
||||
const row = db
|
||||
.prepare(`SELECT timezone FROM users WHERE user_id = ? AND room_id = ?`)
|
||||
.get(userId, ctx.roomId) as { timezone: string | null } | undefined;
|
||||
|
||||
if (!row?.timezone) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `${userId} hasn't set a timezone. Use !weather <city> instead.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to extract a city name from the IANA zone (e.g., "America/New_York" -> "New York")
|
||||
const parts = row.timezone.split("/");
|
||||
query = parts[parts.length - 1].replace(/_/g, " ");
|
||||
}
|
||||
|
||||
try {
|
||||
// Resolve location via geocoding first for accurate results
|
||||
const location = await this.resolveWeatherLocation(query, apiKey);
|
||||
if (!location) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `Location "${query}" not found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = `https://api.openweathermap.org/data/2.5/weather?lat=${location.lat}&lon=${location.lon}&appid=${encodeURIComponent(apiKey)}&units=metric`;
|
||||
const res = await fetch(url);
|
||||
|
||||
if (!res.ok) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Weather lookup failed. Try again later.");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json() as any;
|
||||
const temp = data.main.temp;
|
||||
const feelsLike = data.main.feels_like;
|
||||
const tempF = (temp * 9) / 5 + 32;
|
||||
const feelsLikeF = (feelsLike * 9) / 5 + 32;
|
||||
const humidity = data.main.humidity;
|
||||
const description = data.weather?.[0]?.description ?? "unknown";
|
||||
const windSpeed = data.wind?.speed ?? 0;
|
||||
|
||||
const lines = [
|
||||
`Weather for ${location.displayName}:`,
|
||||
` ${description.charAt(0).toUpperCase() + description.slice(1)}`,
|
||||
` Temp: ${temp.toFixed(1)}°C / ${tempF.toFixed(1)}°F (feels like ${feelsLike.toFixed(1)}°C / ${feelsLikeF.toFixed(1)}°F)`,
|
||||
` Humidity: ${humidity}% | Wind: ${windSpeed} m/s`,
|
||||
];
|
||||
|
||||
await this.sendMessage(ctx.roomId, lines.join("\n"));
|
||||
} catch (err) {
|
||||
logger.error(`Weather lookup failed: ${err}`);
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Weather lookup failed. Try again later.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a user query to coordinates using OpenWeather Geocoding API.
|
||||
* Handles zip codes (with optional country: "90210" or "90210,US"),
|
||||
* and disambiguates city names by preferring US matches first, then
|
||||
* returning the most relevant result with state/country info.
|
||||
*/
|
||||
private async resolveWeatherLocation(
|
||||
query: string,
|
||||
apiKey: string
|
||||
): Promise<{ lat: number; lon: number; displayName: string } | null> {
|
||||
const trimmed = query.trim();
|
||||
|
||||
// Check if input looks like a zip/postal code (digits, optionally with country suffix)
|
||||
const zipMatch = trimmed.match(/^(\d{3,10})(?:\s*,\s*([A-Za-z]{2}))?$/);
|
||||
if (zipMatch) {
|
||||
const zip = zipMatch[1];
|
||||
const country = zipMatch[2]?.toUpperCase() ?? "US";
|
||||
const url = `https://api.openweathermap.org/geo/1.0/zip?zip=${encodeURIComponent(zip)},${country}&appid=${encodeURIComponent(apiKey)}`;
|
||||
const res = await fetch(url);
|
||||
if (res.ok) {
|
||||
const data = await res.json() as any;
|
||||
if (data.lat != null && data.lon != null) {
|
||||
return {
|
||||
lat: data.lat,
|
||||
lon: data.lon,
|
||||
displayName: `${data.name ?? zip}, ${data.country ?? country}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
// If US zip failed and no country was specified, don't fall through to city search for pure numbers
|
||||
if (!zipMatch[2]) return null;
|
||||
}
|
||||
|
||||
// City name lookup via geocoding API (returns up to 5 results)
|
||||
const geoUrl = `https://api.openweathermap.org/geo/1.0/direct?q=${encodeURIComponent(trimmed)}&limit=5&appid=${encodeURIComponent(apiKey)}`;
|
||||
const geoRes = await fetch(geoUrl);
|
||||
if (!geoRes.ok) return null;
|
||||
|
||||
const results = await geoRes.json() as any[];
|
||||
if (!results || results.length === 0) return null;
|
||||
|
||||
// If there's only one result, use it directly
|
||||
// If multiple, prefer a US match for ambiguous names, otherwise take the first
|
||||
let best = results[0];
|
||||
if (results.length > 1) {
|
||||
const usMatch = results.find((r: any) => r.country === "US");
|
||||
if (usMatch) best = usMatch;
|
||||
}
|
||||
|
||||
const state = best.state ? `, ${best.state}` : "";
|
||||
const country = best.country ? `, ${best.country}` : "";
|
||||
return {
|
||||
lat: best.lat,
|
||||
lon: best.lon,
|
||||
displayName: `${best.name}${state}${country}`,
|
||||
};
|
||||
}
|
||||
|
||||
private async handlePoll(ctx: MessageContext): Promise<void> {
|
||||
const args = this.getArgs(ctx.body, "poll");
|
||||
// Parse quoted strings: !poll "Question" "Option A" "Option B" ...
|
||||
const matches = args.match(/"([^"]+)"/g);
|
||||
|
||||
if (!matches || matches.length < 3) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, 'Usage: !poll "Question?" "Option A" "Option B" ...');
|
||||
return;
|
||||
}
|
||||
|
||||
const question = matches[0].replace(/"/g, "");
|
||||
const options = matches.slice(1).map((m) => m.replace(/"/g, ""));
|
||||
|
||||
if (options.length > 10) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Maximum 10 options allowed.");
|
||||
return;
|
||||
}
|
||||
|
||||
const numberEmojis = ["1\uFE0F\u20E3", "2\uFE0F\u20E3", "3\uFE0F\u20E3", "4\uFE0F\u20E3", "5\uFE0F\u20E3", "6\uFE0F\u20E3", "7\uFE0F\u20E3", "8\uFE0F\u20E3", "9\uFE0F\u20E3", "\uD83D\uDD1F"];
|
||||
|
||||
const lines = [`Poll: ${question}`];
|
||||
options.forEach((opt, i) => {
|
||||
lines.push(`${numberEmojis[i]} ${opt}`);
|
||||
});
|
||||
|
||||
const pollEventId = await this.sendMessage(ctx.roomId, lines.join("\n"));
|
||||
|
||||
// Auto-react with number emojis
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleDadJoke(ctx: MessageContext): Promise<void> {
|
||||
try {
|
||||
const res = await fetch("https://icanhazdadjoke.com/", {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Couldn't fetch a dad joke right now.");
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()) as { joke: string };
|
||||
await this.sendMessage(ctx.roomId, data.joke);
|
||||
} catch (err) {
|
||||
logger.error(`Dad joke fetch failed: ${err}`);
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Dad joke machine broke.");
|
||||
}
|
||||
}
|
||||
|
||||
private async handleRandomWiki(ctx: MessageContext): Promise<void> {
|
||||
try {
|
||||
const res = await fetch("https://en.wikipedia.org/api/rest_v1/page/random/summary");
|
||||
if (!res.ok) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Couldn't fetch a random article.");
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()) as any;
|
||||
const title = data.title ?? "Unknown";
|
||||
let extract = data.extract ?? "";
|
||||
if (extract.length > 500) extract = extract.slice(0, 500) + "...";
|
||||
const pageUrl = data.content_urls?.desktop?.page ?? "";
|
||||
await this.sendMessage(ctx.roomId, `${title}\n\n${extract}\n\nRead more: ${pageUrl}`);
|
||||
} catch (err) {
|
||||
logger.error(`Random wiki fetch failed: ${err}`);
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to fetch a random article.");
|
||||
}
|
||||
}
|
||||
}
|
||||
242
src/plugins/gaming.ts
Normal file
242
src/plugins/gaming.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
||||
import { getDb } from "../db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
export class GamingPlugin extends Plugin {
|
||||
constructor(client: IMatrixClient) {
|
||||
super(client);
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "gaming";
|
||||
}
|
||||
|
||||
get commands(): CommandDef[] {
|
||||
return [
|
||||
{ name: "releases", description: "Upcoming game releases", usage: "!releases [week|month|search <game>]" },
|
||||
];
|
||||
}
|
||||
|
||||
async onMessage(ctx: MessageContext): Promise<void> {
|
||||
if (this.isCommand(ctx.body, "releases")) {
|
||||
await this.handleReleases(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleReleases(ctx: MessageContext): Promise<void> {
|
||||
const args = this.getArgs(ctx.body, "releases").trim();
|
||||
|
||||
if (args.startsWith("search ")) {
|
||||
const query = args.slice(7).trim();
|
||||
await this.handleSearch(ctx, query);
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKey = process.env.RAWG_API_KEY;
|
||||
if (!apiKey) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "RAWG API key not configured.");
|
||||
return;
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const startDate = today.toISOString().slice(0, 10);
|
||||
|
||||
let endDate: string;
|
||||
let label: string;
|
||||
if (args === "month") {
|
||||
const end = new Date(today);
|
||||
end.setDate(end.getDate() + 30);
|
||||
endDate = end.toISOString().slice(0, 10);
|
||||
label = "This Month";
|
||||
} else if (args === "week") {
|
||||
const end = new Date(today);
|
||||
end.setDate(end.getDate() + 7);
|
||||
endDate = end.toISOString().slice(0, 10);
|
||||
label = "This Week";
|
||||
} else {
|
||||
// Default: today
|
||||
endDate = startDate;
|
||||
label = "Today";
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `https://api.rawg.io/api/games?key=${encodeURIComponent(apiKey)}&dates=${startDate},${endDate}&ordering=-rating&page_size=10`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to fetch releases from RAWG.");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json() as any;
|
||||
const games = data.results ?? [];
|
||||
|
||||
if (games.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `No releases found for ${label.toLowerCase()}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = [`Game Releases — ${label}:`];
|
||||
for (const game of games) {
|
||||
const platforms = (game.platforms ?? []).map((p: any) => p.platform?.name).filter(Boolean).join(", ");
|
||||
const hltbInfo = this.getHltbInfo(game.name);
|
||||
let line = `- ${game.name} (${game.released ?? "TBA"})`;
|
||||
if (platforms) line += ` [${platforms}]`;
|
||||
if (hltbInfo) line += ` | ${hltbInfo}`;
|
||||
lines.push(line);
|
||||
}
|
||||
|
||||
await this.sendMessage(ctx.roomId, lines.join("\n"));
|
||||
} catch (err) {
|
||||
logger.error(`Releases fetch failed: ${err}`);
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to fetch releases. Try again later.");
|
||||
}
|
||||
}
|
||||
|
||||
private async handleSearch(ctx: MessageContext, query: string): Promise<void> {
|
||||
if (!query) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !releases search <game name>");
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKey = process.env.RAWG_API_KEY;
|
||||
if (!apiKey) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "RAWG API key not configured.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `https://api.rawg.io/api/games?key=${encodeURIComponent(apiKey)}&search=${encodeURIComponent(query)}&page_size=5`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "RAWG search failed.");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json() as any;
|
||||
const games = data.results ?? [];
|
||||
|
||||
if (games.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `No games found for "${query}".`);
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = [`Search results for "${query}":`];
|
||||
for (const game of games) {
|
||||
const platforms = (game.platforms ?? []).map((p: any) => p.platform?.name).filter(Boolean).join(", ");
|
||||
let line = `- ${game.name} (${game.released ?? "TBA"})`;
|
||||
if (platforms) line += ` [${platforms}]`;
|
||||
if (game.rating) line += ` Rating: ${game.rating}/5`;
|
||||
lines.push(line);
|
||||
}
|
||||
|
||||
await this.sendMessage(ctx.roomId, lines.join("\n"));
|
||||
} catch (err) {
|
||||
logger.error(`RAWG search failed: ${err}`);
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Search failed. Try again later.");
|
||||
}
|
||||
}
|
||||
|
||||
private getHltbInfo(gameName: string): string | null {
|
||||
try {
|
||||
const db = getDb();
|
||||
const row = db
|
||||
.prepare(`SELECT main_story, main_extra FROM hltb_cache WHERE game_name = ? AND fetched_at > datetime('now', '-24 hours') LIMIT 1`)
|
||||
.get(gameName) as { main_story: number | null; main_extra: number | null } | undefined;
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (row.main_story) parts.push(`Main: ${row.main_story}h`);
|
||||
if (row.main_extra) parts.push(`Extra: ${row.main_extra}h`);
|
||||
return parts.length > 0 ? `HLTB ${parts.join(", ")}` : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by DailyScheduler to post the evening releases summary.
|
||||
*/
|
||||
async postReleases(roomIds: string[]): Promise<void> {
|
||||
const apiKey = process.env.RAWG_API_KEY;
|
||||
if (!apiKey) {
|
||||
logger.warn("RAWG_API_KEY not set, skipping releases post");
|
||||
return;
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
try {
|
||||
const url = `https://api.rawg.io/api/games?key=${encodeURIComponent(apiKey)}&dates=${today},${today}&ordering=-rating&page_size=10`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
logger.warn(`RAWG API returned ${res.status}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json() as any;
|
||||
const games = data.results ?? [];
|
||||
|
||||
if (games.length === 0) return;
|
||||
|
||||
const lines = ["Today's Game Releases:"];
|
||||
for (const game of games) {
|
||||
const platforms = (game.platforms ?? []).map((p: any) => p.platform?.name).filter(Boolean).join(", ");
|
||||
const hltbInfo = this.getHltbInfo(game.name);
|
||||
let line = `- ${game.name}`;
|
||||
if (platforms) line += ` [${platforms}]`;
|
||||
if (hltbInfo) line += ` | ${hltbInfo}`;
|
||||
lines.push(line);
|
||||
|
||||
// Cache the release data
|
||||
const db = getDb();
|
||||
db.prepare(`
|
||||
INSERT INTO releases_cache (game_name, game_slug, release_date, platforms, genres, rating, data_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
game.name,
|
||||
game.slug ?? null,
|
||||
game.released ?? null,
|
||||
platforms || null,
|
||||
(game.genres ?? []).map((g: any) => g.name).join(", ") || null,
|
||||
game.rating ?? null,
|
||||
JSON.stringify(game)
|
||||
);
|
||||
}
|
||||
|
||||
const message = lines.join("\n");
|
||||
|
||||
for (const roomId of roomIds) {
|
||||
try {
|
||||
await this.sendMessage(roomId, message);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to post releases to ${roomId}: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check watchlist notifications
|
||||
await this.checkWatchlistNotifications(games);
|
||||
} catch (err) {
|
||||
logger.error(`Releases daily post failed: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async checkWatchlistNotifications(games: any[]): Promise<void> {
|
||||
const db = getDb();
|
||||
|
||||
for (const game of games) {
|
||||
const watchers = db
|
||||
.prepare(`SELECT user_id, room_id FROM release_watchlist WHERE (game_name = ? OR game_slug = ?) AND notified_day_of = 0`)
|
||||
.all(game.name, game.slug ?? "") as { user_id: string; room_id: string }[];
|
||||
|
||||
for (const watcher of watchers) {
|
||||
await this.sendDm(watcher.user_id, `${game.name} releases today!`);
|
||||
db.prepare(`UPDATE release_watchlist SET notified_day_of = 1 WHERE user_id = ? AND room_id = ? AND game_name = ?`).run(
|
||||
watcher.user_id,
|
||||
watcher.room_id,
|
||||
game.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
494
src/plugins/holidays.ts
Normal file
494
src/plugins/holidays.ts
Normal file
@@ -0,0 +1,494 @@
|
||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
||||
import { getDb } from "../db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
// Countries/sources considered "Western" for featured selection
|
||||
const WESTERN_COUNTRIES = new Set(["US", "GB", "CA", "AU", "NZ", "IE"]);
|
||||
|
||||
const ASIAN_COUNTRIES: Record<string, string> = {
|
||||
JP: "Japan",
|
||||
CN: "China",
|
||||
KR: "South Korea",
|
||||
IN: "India",
|
||||
TH: "Thailand",
|
||||
VN: "Vietnam",
|
||||
TW: "Taiwan",
|
||||
PH: "Philippines",
|
||||
};
|
||||
|
||||
function getAsianCountryCodes(): string[] {
|
||||
const override = process.env.ASIAN_HOLIDAY_COUNTRIES;
|
||||
if (override) return override.split(",").map((s) => s.trim().toUpperCase()).filter(Boolean);
|
||||
return Object.keys(ASIAN_COUNTRIES);
|
||||
}
|
||||
|
||||
function countryLabel(code: string): string {
|
||||
return ASIAN_COUNTRIES[code] ?? code;
|
||||
}
|
||||
|
||||
const RANGE_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
interface RangeCacheEntry {
|
||||
results: { date: string; holiday: Holiday }[];
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
interface Holiday {
|
||||
name: string;
|
||||
description?: string;
|
||||
country?: string;
|
||||
type?: string;
|
||||
source: "calendarific" | "hebcal" | "aladhan";
|
||||
}
|
||||
|
||||
export class HolidaysPlugin extends Plugin {
|
||||
private rangeCache = new Map<string, RangeCacheEntry>();
|
||||
|
||||
constructor(client: IMatrixClient) {
|
||||
super(client);
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "holidays";
|
||||
}
|
||||
|
||||
get commands(): CommandDef[] {
|
||||
return [{ name: "holidays", description: "Holidays and observances", usage: "!holidays [week|month]" }];
|
||||
}
|
||||
|
||||
async onMessage(ctx: MessageContext): Promise<void> {
|
||||
if (this.isCommand(ctx.body, "holidays")) {
|
||||
await this.handleHolidays(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleHolidays(ctx: MessageContext): Promise<void> {
|
||||
const args = this.getArgs(ctx.body, "holidays").trim().toLowerCase();
|
||||
|
||||
if (args === "week" || args === "month") {
|
||||
await this.handleHolidaysRange(ctx, args);
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: today — try DB cache first, fall back to live fetch
|
||||
const db = getDb();
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
let holidays: Holiday[];
|
||||
|
||||
const row = db
|
||||
.prepare(`SELECT holidays_json FROM holidays_log WHERE room_id = ? AND date = ?`)
|
||||
.get(ctx.roomId, today) as { holidays_json: string } | undefined;
|
||||
|
||||
if (row) {
|
||||
holidays = JSON.parse(row.holidays_json);
|
||||
} else {
|
||||
// Live fetch from all sources
|
||||
holidays = await this.fetchAllForDate(today);
|
||||
|
||||
// Cache the result so subsequent calls don't re-fetch
|
||||
if (holidays.length > 0) {
|
||||
db.prepare(`INSERT OR IGNORE INTO holidays_log (room_id, date, holidays_json) VALUES (?, ?, ?)`)
|
||||
.run(ctx.roomId, today, JSON.stringify(holidays));
|
||||
}
|
||||
}
|
||||
|
||||
if (holidays.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "No holidays found for today.");
|
||||
return;
|
||||
}
|
||||
|
||||
const asianCodes = new Set(Object.keys(ASIAN_COUNTRIES));
|
||||
const lines = holidays.map((h) => {
|
||||
let src = "";
|
||||
if (h.source === "hebcal") src = " (Jewish)";
|
||||
else if (h.source === "aladhan") src = " (Islamic)";
|
||||
else if (h.country && asianCodes.has(h.country)) src = ` (${countryLabel(h.country)})`;
|
||||
return `- ${h.name}${src}${h.description ? `: ${h.description}` : ""}`;
|
||||
});
|
||||
|
||||
await this.sendMessage(ctx.roomId, `Today's holidays & observances:\n${lines.join("\n")}`);
|
||||
}
|
||||
|
||||
private async handleHolidaysRange(ctx: MessageContext, range: "week" | "month"): Promise<void> {
|
||||
const today = new Date();
|
||||
const startDate = today.toISOString().slice(0, 10);
|
||||
const endDate = new Date(today);
|
||||
endDate.setDate(endDate.getDate() + (range === "week" ? 7 : 30));
|
||||
const endDateStr = endDate.toISOString().slice(0, 10);
|
||||
const label = range === "week" ? "This Week" : "This Month";
|
||||
|
||||
const cacheKey = `${startDate}:${endDateStr}`;
|
||||
const cached = this.rangeCache.get(cacheKey);
|
||||
let allResults: { date: string; holiday: Holiday }[];
|
||||
|
||||
if (cached && Date.now() - cached.fetchedAt < RANGE_CACHE_TTL_MS) {
|
||||
allResults = cached.results;
|
||||
} else {
|
||||
// Fetch from Calendarific (US + Asian countries) and HebCal in parallel
|
||||
const asianCodes = getAsianCountryCodes();
|
||||
const [calendarificHolidays, hebcalHolidays, ...asianRangeResults] = await Promise.all([
|
||||
this.fetchCalendarificRange(startDate, endDateStr),
|
||||
this.fetchHebcalRange(startDate, endDateStr),
|
||||
...asianCodes.map((code) => this.fetchCalendarificRange(startDate, endDateStr, code)),
|
||||
]);
|
||||
|
||||
allResults = [...calendarificHolidays, ...hebcalHolidays, ...asianRangeResults.flat()];
|
||||
this.rangeCache.set(cacheKey, { results: allResults, fetchedAt: Date.now() });
|
||||
}
|
||||
|
||||
const asianCodesSet = new Set(Object.keys(ASIAN_COUNTRIES));
|
||||
const byDate = new Map<string, Holiday[]>();
|
||||
for (const { date, holiday } of allResults) {
|
||||
if (date < startDate || date > endDateStr) continue;
|
||||
if (!byDate.has(date)) byDate.set(date, []);
|
||||
byDate.get(date)!.push(holiday);
|
||||
}
|
||||
|
||||
const sortedDates = [...byDate.keys()].sort();
|
||||
if (sortedDates.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `No holidays found for ${label.toLowerCase()}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const lines: string[] = [`Holidays & Observances — ${label}:`];
|
||||
for (const date of sortedDates) {
|
||||
const dateLabel = new Date(date + "T00:00:00Z").toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
const holidays = byDate.get(date)!;
|
||||
lines.push(`\n${dateLabel}:`);
|
||||
for (const h of holidays) {
|
||||
let src = "";
|
||||
if (h.source === "hebcal") src = " (Jewish)";
|
||||
else if (h.source === "aladhan") src = " (Islamic)";
|
||||
else if (h.country && asianCodesSet.has(h.country)) src = ` (${countryLabel(h.country)})`;
|
||||
lines.push(` - ${h.name}${src}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate if too long for a single message
|
||||
const message = lines.join("\n");
|
||||
if (message.length > 4000) {
|
||||
await this.sendMessage(ctx.roomId, message.slice(0, 3950) + "\n\n... (truncated)");
|
||||
} else {
|
||||
await this.sendMessage(ctx.roomId, message);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchCalendarificRange(startDate: string, endDate: string, country: string = "US"): Promise<{ date: string; holiday: Holiday }[]> {
|
||||
const apiKey = process.env.CALENDARIFIC_API_KEY;
|
||||
if (!apiKey) return [];
|
||||
|
||||
try {
|
||||
// Calendarific supports year+month queries — fetch each month in the range
|
||||
const start = new Date(startDate + "T00:00:00Z");
|
||||
const end = new Date(endDate + "T00:00:00Z");
|
||||
const results: { date: string; holiday: Holiday }[] = [];
|
||||
const fetchedMonths = new Set<string>();
|
||||
|
||||
for (let d = new Date(start); d <= end; d.setUTCMonth(d.getUTCMonth() + 1)) {
|
||||
const monthKey = `${d.getUTCFullYear()}-${d.getUTCMonth() + 1}`;
|
||||
if (fetchedMonths.has(monthKey)) continue;
|
||||
fetchedMonths.add(monthKey);
|
||||
|
||||
const url = `https://calendarific.com/api/v2/holidays?api_key=${encodeURIComponent(apiKey)}&country=${encodeURIComponent(country)}&year=${d.getUTCFullYear()}&month=${d.getUTCMonth() + 1}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) continue;
|
||||
|
||||
const data = await res.json() as any;
|
||||
const holidays = data.response?.holidays ?? [];
|
||||
|
||||
for (const h of holidays) {
|
||||
const hDate = h.date?.iso?.slice(0, 10);
|
||||
if (!hDate) continue;
|
||||
results.push({
|
||||
date: hDate,
|
||||
holiday: {
|
||||
name: h.name,
|
||||
description: h.description,
|
||||
country: h.country?.id ?? country,
|
||||
type: h.type?.[0] ?? "observance",
|
||||
source: "calendarific",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (err) {
|
||||
logger.error(`Calendarific range fetch failed: ${err}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchHebcalRange(startDate: string, endDate: string): Promise<{ date: string; holiday: Holiday }[]> {
|
||||
try {
|
||||
const url = `https://www.hebcal.com/hebcal?cfg=json&v=1&start=${startDate}&end=${endDate}&maj=on&min=on&mod=on&nx=on&ss=on`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return [];
|
||||
|
||||
const data = await res.json() as any;
|
||||
const events = data.items ?? [];
|
||||
|
||||
return events.map((evt: any) => ({
|
||||
date: evt.date?.slice(0, 10) ?? startDate,
|
||||
holiday: {
|
||||
name: evt.title,
|
||||
description: evt.memo ?? undefined,
|
||||
source: "hebcal" as const,
|
||||
},
|
||||
}));
|
||||
} catch (err) {
|
||||
logger.error(`HebCal range fetch failed: ${err}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by DailyScheduler to post the morning holiday summary.
|
||||
*/
|
||||
async postHolidays(roomIds: string[]): Promise<void> {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const db = getDb();
|
||||
|
||||
// Already posted?
|
||||
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);
|
||||
|
||||
if (holidays.length === 0) {
|
||||
logger.info("No holidays found for today, skipping post.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Build date header
|
||||
const gregorianDate = new Date().toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
const dateHeader = [
|
||||
gregorianDate,
|
||||
hebrewDate ? `Hebrew: ${hebrewDate}` : null,
|
||||
hijriDate ? `Hijri: ${hijriDate}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" | ");
|
||||
|
||||
// Select featured holiday (prefer non-Western)
|
||||
const featured = this.selectFeatured(holidays);
|
||||
|
||||
// Build sections
|
||||
const asianCodesSet = new Set(Object.keys(ASIAN_COUNTRIES));
|
||||
const religiousHolidays = holidays.filter(
|
||||
(h) => h.source === "hebcal" || h.source === "aladhan" || h.type === "religious"
|
||||
);
|
||||
const asianHolidays = holidays.filter(
|
||||
(h) => h.source === "calendarific" && h.country && asianCodesSet.has(h.country)
|
||||
);
|
||||
const otherHolidays = holidays.filter(
|
||||
(h) => h.source === "calendarific" && h.type !== "religious" && (!h.country || !asianCodesSet.has(h.country))
|
||||
);
|
||||
|
||||
const lines: string[] = [dateHeader, ""];
|
||||
|
||||
if (featured) {
|
||||
lines.push(`Featured: ${featured.name}${featured.description ? ` — ${featured.description}` : ""}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (religiousHolidays.length > 0) {
|
||||
lines.push("Religious Observances:");
|
||||
for (const h of religiousHolidays) {
|
||||
lines.push(` - ${h.name}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (asianHolidays.length > 0) {
|
||||
lines.push("Asian Holidays:");
|
||||
for (const h of asianHolidays) {
|
||||
lines.push(` - ${h.name} (${countryLabel(h.country!)})`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (otherHolidays.length > 0) {
|
||||
lines.push("Other Observances:");
|
||||
for (const h of otherHolidays) {
|
||||
lines.push(` - ${h.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
const message = lines.join("\n").trim();
|
||||
|
||||
for (const roomId of roomIds) {
|
||||
try {
|
||||
await this.sendMessage(roomId, message);
|
||||
db.prepare(`INSERT OR IGNORE INTO holidays_log (room_id, date, holidays_json) VALUES (?, ?, ?)`).run(
|
||||
roomId,
|
||||
today,
|
||||
JSON.stringify(holidays)
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to post holidays to ${roomId}: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch holidays for a given date from all sources (Calendarific US + Asian, HebCal, Aladhan).
|
||||
*/
|
||||
private async fetchAllForDate(date: string): Promise<Holiday[]>;
|
||||
private async fetchAllForDate(date: string, withCalendarDates: true): Promise<{ holidays: Holiday[]; hebrewDate: string | null; hijriDate: string | null }>;
|
||||
private async fetchAllForDate(date: string, withCalendarDates?: boolean): Promise<Holiday[] | { holidays: Holiday[]; hebrewDate: string | null; hijriDate: string | null }> {
|
||||
const holidays: Holiday[] = [];
|
||||
const asianCodes = getAsianCountryCodes();
|
||||
|
||||
const [calendarific, hebcal, aladhan, ...asianResults] = await Promise.allSettled([
|
||||
this.fetchCalendarific(date),
|
||||
this.fetchHebcal(date),
|
||||
this.fetchAladhan(date),
|
||||
...asianCodes.map((code) => this.fetchCalendarificForCountry(date, code)),
|
||||
]);
|
||||
|
||||
if (calendarific.status === "fulfilled") holidays.push(...calendarific.value);
|
||||
if (hebcal.status === "fulfilled") holidays.push(...hebcal.value);
|
||||
if (aladhan.status === "fulfilled") holidays.push(...aladhan.value);
|
||||
for (const result of asianResults) {
|
||||
if (result.status === "fulfilled") holidays.push(...result.value);
|
||||
}
|
||||
|
||||
if (withCalendarDates) {
|
||||
const hebrewDate = hebcal.status === "fulfilled" ? (hebcal.value as any).__hebrewDate ?? null : null;
|
||||
const hijriDate = aladhan.status === "fulfilled" ? (aladhan.value as any).__hijriDate ?? null : null;
|
||||
return { holidays, hebrewDate, hijriDate };
|
||||
}
|
||||
|
||||
return holidays;
|
||||
}
|
||||
|
||||
private selectFeatured(holidays: Holiday[]): Holiday | null {
|
||||
// Prefer non-Western holidays
|
||||
const nonWestern = holidays.filter(
|
||||
(h) => !h.country || !WESTERN_COUNTRIES.has(h.country)
|
||||
);
|
||||
|
||||
if (nonWestern.length > 0) {
|
||||
return nonWestern[Math.floor(Math.random() * nonWestern.length)];
|
||||
}
|
||||
|
||||
return holidays.length > 0 ? holidays[0] : null;
|
||||
}
|
||||
|
||||
private async fetchCalendarific(date: string): Promise<Holiday[]> {
|
||||
return this.fetchCalendarificForCountry(date, "US");
|
||||
}
|
||||
|
||||
private async fetchCalendarificForCountry(date: string, country: string): Promise<Holiday[]> {
|
||||
const apiKey = process.env.CALENDARIFIC_API_KEY;
|
||||
if (!apiKey) return [];
|
||||
|
||||
try {
|
||||
const [year, month, day] = date.split("-");
|
||||
const url = `https://calendarific.com/api/v2/holidays?api_key=${encodeURIComponent(apiKey)}&country=${encodeURIComponent(country)}&year=${year}&month=${month}&day=${day}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
logger.warn(`Calendarific API (${country}) returned ${res.status}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await res.json() as any;
|
||||
const holidays = data.response?.holidays ?? [];
|
||||
|
||||
return holidays.map((h: any) => ({
|
||||
name: h.name,
|
||||
description: h.description,
|
||||
country: h.country?.id ?? country,
|
||||
type: h.type?.[0] ?? "observance",
|
||||
source: "calendarific" as const,
|
||||
}));
|
||||
} catch (err) {
|
||||
logger.error(`Calendarific fetch (${country}) failed: ${err}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchHebcal(date: string): Promise<Holiday[] & { __hebrewDate?: string }> {
|
||||
try {
|
||||
const [year, month, day] = date.split("-");
|
||||
const url = `https://www.hebcal.com/converter?cfg=json&gy=${year}&gm=${parseInt(month)}&gd=${parseInt(day)}&g2h=1`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
logger.warn(`HebCal API returned ${res.status}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await res.json() as any;
|
||||
|
||||
const result: Holiday[] & { __hebrewDate?: string } = [];
|
||||
result.__hebrewDate = data.hebrew ?? null;
|
||||
|
||||
// Fetch events for this date
|
||||
const eventsUrl = `https://www.hebcal.com/hebcal?cfg=json&v=1&start=${date}&end=${date}&maj=on&min=on&mod=on&nx=on&ss=on`;
|
||||
const eventsRes = await fetch(eventsUrl);
|
||||
if (eventsRes.ok) {
|
||||
const eventsData = await eventsRes.json() as any;
|
||||
const events = eventsData.items ?? [];
|
||||
for (const evt of events) {
|
||||
result.push({
|
||||
name: evt.title,
|
||||
description: evt.memo ?? undefined,
|
||||
source: "hebcal",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
logger.error(`HebCal fetch failed: ${err}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchAladhan(date: string): Promise<Holiday[] & { __hijriDate?: string }> {
|
||||
try {
|
||||
const [year, month, day] = date.split("-");
|
||||
const url = `https://api.aladhan.com/v1/timings/${day}-${month}-${year}?latitude=21.4225&longitude=39.8262`;
|
||||
const res = await fetch(url, { redirect: "follow" });
|
||||
if (!res.ok) {
|
||||
logger.warn(`Aladhan API returned ${res.status}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await res.json() as any;
|
||||
const hijri = data.data?.date?.hijri;
|
||||
|
||||
const result: Holiday[] & { __hijriDate?: string } = [];
|
||||
if (hijri) {
|
||||
result.__hijriDate = `${hijri.day} ${hijri.month?.en} ${hijri.year} AH`;
|
||||
|
||||
// Check for holidays in the response
|
||||
const holidays = hijri.holidays ?? [];
|
||||
for (const name of holidays) {
|
||||
result.push({
|
||||
name,
|
||||
source: "aladhan",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
logger.error(`Aladhan fetch failed: ${err}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
675
src/plugins/llm-passive.ts
Normal file
675
src/plugins/llm-passive.ts
Normal file
@@ -0,0 +1,675 @@
|
||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
||||
import { XpPlugin } from "./xp";
|
||||
import { getDb } from "../db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
const RAW_OLLAMA_HOST = process.env.OLLAMA_HOST ?? "";
|
||||
const OLLAMA_HOST = RAW_OLLAMA_HOST && !RAW_OLLAMA_HOST.startsWith("http") ? `http://${RAW_OLLAMA_HOST}` : RAW_OLLAMA_HOST;
|
||||
const OLLAMA_MODEL = process.env.OLLAMA_MODEL ?? "";
|
||||
const LLM_ENABLED = OLLAMA_HOST !== "" && OLLAMA_MODEL !== "";
|
||||
const NOT_CONFIGURED = "LLM features are not configured.";
|
||||
const WOTD_XP = 50;
|
||||
const REP_XP_BONUS = 5;
|
||||
const REP_COOLDOWN_HOURS = 24;
|
||||
const OLLAMA_TIMEOUT_MS = 30_000;
|
||||
const MAX_QUEUE_SIZE = 50;
|
||||
const OLLAMA_NUM_CTX = 16384;
|
||||
const SAMPLE_RATE = parseFloat(process.env.LLM_SAMPLE_RATE ?? "0.15");
|
||||
|
||||
const PROFANITY_KEYWORDS = [
|
||||
"fuck", "shit", "damn", "hell", "ass", "bitch", "bastard", "crap",
|
||||
"dick", "piss", "cock", "cunt", "twat", "wank", "bollocks", "arse",
|
||||
"motherfucker", "bullshit", "horseshit", "goddamn", "dammit", "asshole",
|
||||
"shitty", "bitchy", "fucked", "fucking", "fucker", "dumbass", "jackass",
|
||||
"dipshit", "wtf", "stfu", "lmfao",
|
||||
];
|
||||
|
||||
type Sentiment = "neutral" | "happy" | "sad" | "angry" | "excited" | "funny" | "love" | "scared";
|
||||
|
||||
interface ClassificationResult {
|
||||
profanity: boolean;
|
||||
profanity_severity: number;
|
||||
insult: boolean;
|
||||
insult_type: "direct" | "indirect" | null;
|
||||
insult_target: string | null;
|
||||
insult_severity: number;
|
||||
sentiment: Sentiment;
|
||||
gratitude: boolean;
|
||||
gratitude_toward: string | null;
|
||||
wotd_used: boolean;
|
||||
wotd_correct: boolean;
|
||||
}
|
||||
|
||||
interface QueueItem {
|
||||
ctx: MessageContext;
|
||||
wotd: { word: string; definition: string | null; example: string | null; part_of_speech: string | null } | null;
|
||||
}
|
||||
|
||||
const BACKOFF_INITIAL_MS = 5_000;
|
||||
const BACKOFF_MAX_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
export class LlmPassivePlugin extends Plugin {
|
||||
private xpPlugin: XpPlugin;
|
||||
private botUserId: string = "";
|
||||
private queue: QueueItem[] = [];
|
||||
private processing = false;
|
||||
private backoffMs = 0;
|
||||
private backoffUntil = 0;
|
||||
private consecutiveFailures = 0;
|
||||
|
||||
constructor(client: IMatrixClient, xpPlugin: XpPlugin) {
|
||||
super(client);
|
||||
this.xpPlugin = xpPlugin;
|
||||
const uid = client.getUserId();
|
||||
if (typeof uid === "string") {
|
||||
this.botUserId = uid;
|
||||
} else if (uid) {
|
||||
(uid as Promise<string>).then((id: string) => { this.botUserId = id; }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "llm-passive";
|
||||
}
|
||||
|
||||
get commands(): CommandDef[] {
|
||||
return [
|
||||
{ name: "potty", description: "Potty mouth stats", usage: "!potty [@user]" },
|
||||
{ name: "pottyboard", description: "Potty mouth leaderboard" },
|
||||
{ name: "insults", description: "Insult stats", usage: "!insults [@user]" },
|
||||
{ name: "insultboard", description: "Most prolific insulters leaderboard" },
|
||||
{ name: "wotd attempts", description: "Who tried today's WOTD", usage: "!wotd attempts" },
|
||||
];
|
||||
}
|
||||
|
||||
async onMessage(ctx: MessageContext): Promise<void> {
|
||||
if (!LLM_ENABLED) {
|
||||
// Still handle commands to return the not-configured message
|
||||
const cmds = ["potty", "pottyboard", "insults", "insultboard"];
|
||||
for (const cmd of cmds) {
|
||||
if (this.isCommand(ctx.body, cmd)) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, NOT_CONFIGURED);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (this.isCommand(ctx.body, "wotd attempts")) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, NOT_CONFIGURED);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle commands
|
||||
if (this.isCommand(ctx.body, "wotd attempts")) {
|
||||
await this.handleWotdAttempts(ctx);
|
||||
return;
|
||||
}
|
||||
if (this.isCommand(ctx.body, "pottyboard")) {
|
||||
await this.handlePottyboard(ctx);
|
||||
return;
|
||||
}
|
||||
if (this.isCommand(ctx.body, "potty")) {
|
||||
await this.handlePotty(ctx);
|
||||
return;
|
||||
}
|
||||
if (this.isCommand(ctx.body, "insultboard")) {
|
||||
await this.handleInsultboard(ctx);
|
||||
return;
|
||||
}
|
||||
if (this.isCommand(ctx.body, "insults")) {
|
||||
await this.handleInsults(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
// Passive classification — pre-filter to avoid sending every message to Ollama
|
||||
// Always classify: keyword matches, @mentions, WOTD usage, non-ASCII text
|
||||
// Otherwise: sample a percentage of remaining messages to catch what keywords miss
|
||||
const bodyLower = ctx.body.toLowerCase();
|
||||
const words = bodyLower.split(/\s+/);
|
||||
|
||||
const wotd = this.getTodaysWotd(ctx.roomId);
|
||||
|
||||
const hasProfanity = words.some((w) => {
|
||||
const cleaned = w.replace(/[^a-z]/g, "");
|
||||
return PROFANITY_KEYWORDS.includes(cleaned);
|
||||
});
|
||||
const hasMention = ctx.body.includes("@");
|
||||
const hasWotd = wotd ? new RegExp(`\\b${wotd.word.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(bodyLower) : false;
|
||||
const hasNonAscii = /[^\x00-\x7F]/.test(ctx.body);
|
||||
const hasThanks = /\b(?:thanks?|thank\s*you|thx|ty|tysm|tyvm|cheers|kudos|props)\b/i.test(ctx.body);
|
||||
|
||||
if (!hasProfanity && !hasMention && !hasWotd && !hasNonAscii && !hasThanks) {
|
||||
// Random sample of remaining messages so the LLM can catch
|
||||
// profanity/insults in any language or phrasing the keyword list misses
|
||||
if (Math.random() >= SAMPLE_RATE) return;
|
||||
}
|
||||
|
||||
// Enqueue for classification (drop if queue is full to avoid unbounded growth)
|
||||
if (this.queue.length >= MAX_QUEUE_SIZE) {
|
||||
logger.warn(`LLM classification queue full (${MAX_QUEUE_SIZE}), dropping message`);
|
||||
return;
|
||||
}
|
||||
this.queue.push({ ctx, wotd });
|
||||
this.processQueue();
|
||||
}
|
||||
|
||||
private getTodaysWotd(roomId: string): { word: string; definition: string | null; example: string | null; part_of_speech: string | null } | null {
|
||||
try {
|
||||
const db = getDb();
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
// Try today first, fall back to the most recent word (covers the gap before the new word is posted)
|
||||
let row = db
|
||||
.prepare(`SELECT word, definition, example, part_of_speech FROM wotd_log WHERE date = ? AND room_id = ? LIMIT 1`)
|
||||
.get(today, roomId) as { word: string; definition: string | null; example: string | null; part_of_speech: string | null } | undefined;
|
||||
if (!row) {
|
||||
row = db
|
||||
.prepare(`SELECT word, definition, example, part_of_speech FROM wotd_log WHERE room_id = ? ORDER BY date DESC LIMIT 1`)
|
||||
.get(roomId) as typeof row;
|
||||
}
|
||||
return row ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async processQueue(): Promise<void> {
|
||||
if (this.processing) return;
|
||||
this.processing = true;
|
||||
|
||||
try {
|
||||
while (this.queue.length > 0) {
|
||||
// If in backoff, wait then retry
|
||||
const now = Date.now();
|
||||
if (this.backoffUntil > now) {
|
||||
const wait = this.backoffUntil - now;
|
||||
logger.info(`LLM backoff: waiting ${Math.ceil(wait / 1000)}s before retry (${this.queue.length} queued)`);
|
||||
await new Promise((r) => setTimeout(r, wait));
|
||||
}
|
||||
|
||||
const item = this.queue.shift()!;
|
||||
try {
|
||||
await this.classify(item);
|
||||
// Success — reset backoff
|
||||
if (this.consecutiveFailures > 0) {
|
||||
logger.info("LLM back online — resuming classification");
|
||||
}
|
||||
this.consecutiveFailures = 0;
|
||||
this.backoffMs = 0;
|
||||
this.backoffUntil = 0;
|
||||
} catch (err) {
|
||||
this.consecutiveFailures++;
|
||||
this.backoffMs = Math.min(
|
||||
this.backoffMs === 0 ? BACKOFF_INITIAL_MS : this.backoffMs * 2,
|
||||
BACKOFF_MAX_MS
|
||||
);
|
||||
this.backoffUntil = Date.now() + this.backoffMs;
|
||||
logger.error(`LLM classification failed (${this.consecutiveFailures}x, next retry in ${this.backoffMs / 1000}s): ${err}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async classify(item: QueueItem): Promise<void> {
|
||||
const { ctx, wotd } = item;
|
||||
|
||||
const wotdWord = wotd?.word ?? "none";
|
||||
const wotdDef = wotd?.definition ?? "N/A";
|
||||
const wotdPos = wotd?.part_of_speech ?? "N/A";
|
||||
const wotdExample = wotd?.example ?? "N/A";
|
||||
|
||||
const prompt = `You are a passive classifier for a private progressive friend group's chat.
|
||||
Affectionate trash talk between friends is normal here — do NOT classify casual banter, questions, or playful teasing as insults.
|
||||
insult should ONLY be true for messages with clear hostile intent to demean or offend someone. Asking questions, using slang, or joking around is NOT an insult.
|
||||
The bot's user ID is ${this.botUserId}. If someone directly insults the bot (e.g. "stupid bot", "you suck"), set insult_target to "${this.botUserId}".
|
||||
Analyze the message and respond ONLY with valid JSON, no other text.
|
||||
|
||||
Today's word of the day: ${wotdWord}
|
||||
Definition: ${wotdDef}
|
||||
Part of speech: ${wotdPos}
|
||||
Example: ${wotdExample}
|
||||
|
||||
Message author: ${ctx.sender}
|
||||
Message: ${ctx.body}
|
||||
|
||||
{"profanity":boolean,"profanity_severity":0|1|2|3,"insult":boolean,"insult_type":"direct"|"indirect"|null,"insult_target":"@mxid or null","insult_severity":0|1|2|3,"sentiment":"neutral"|"happy"|"sad"|"angry"|"excited"|"funny"|"love"|"scared","gratitude":boolean,"gratitude_toward":"@mxid or null","wotd_used":boolean,"wotd_correct":boolean}
|
||||
|
||||
insult_target should be the @mxid of the person being insulted, or null if no specific target. If the bot itself is insulted, use "${this.botUserId}".
|
||||
gratitude is TRUE for genuine thanks/appreciation ("thank you for helping", "thanks for the useful feedback", "ty that worked!"). Sarcastic thanks ("thanks for nothing", "gee thanks for the unhelpful advice") is NOT gratitude. When in doubt, lean toward TRUE if the message tone is positive. gratitude_toward should be the @mxid of the person being thanked, or null if no specific person is mentioned.
|
||||
wotd_used is TRUE only when the word appears as a STANDALONE word in the message (not as a substring of another word). wotd_correct is TRUE only when the standalone word is used correctly per its definition and part of speech. Typing the word out of context, referencing it meta, or awkward forced insertion does NOT qualify.`;
|
||||
|
||||
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 },
|
||||
}),
|
||||
signal: AbortSignal.timeout(OLLAMA_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
logger.warn(`Ollama API returned ${res.status}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { response: string };
|
||||
const result = this.parseClassification(data.response);
|
||||
if (!result) {
|
||||
logger.warn(`Failed to parse Ollama response: ${data.response}`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`LLM classified "${ctx.body.substring(0, 50)}": profanity=${result.profanity}, insult=${result.insult}${result.insult ? ` (target=${result.insult_target}, type=${result.insult_type})` : ""}, sentiment=${result.sentiment}, gratitude=${result.gratitude}${result.gratitude ? ` (toward=${result.gratitude_toward})` : ""}, wotd=${result.wotd_used}`);
|
||||
this.storeResult(ctx, result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to parse the LLM response as a ClassificationResult.
|
||||
* Small models frequently produce malformed JSON — this handles:
|
||||
* - Extra text / markdown fences around the JSON object
|
||||
* - Single quotes instead of double quotes
|
||||
* - Trailing commas before closing braces/brackets
|
||||
* - Unquoted keys
|
||||
* - Boolean strings ("true"/"false") instead of literal booleans
|
||||
* - "none" / "null" strings instead of null
|
||||
* - Missing or extra fields
|
||||
*/
|
||||
private parseClassification(raw: string): ClassificationResult | null {
|
||||
// Step 1: Try direct parse first (fast path)
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return this.normalizeResult(parsed);
|
||||
} catch { /* fall through to repair */ }
|
||||
|
||||
// Step 2: Extract JSON object from surrounding text / markdown fences
|
||||
let cleaned = raw;
|
||||
|
||||
// Strip markdown code fences
|
||||
cleaned = cleaned.replace(/```(?:json)?\s*/gi, "").replace(/```/g, "");
|
||||
|
||||
// Find the outermost { ... } block
|
||||
const firstBrace = cleaned.indexOf("{");
|
||||
const lastBrace = cleaned.lastIndexOf("}");
|
||||
if (firstBrace === -1 || lastBrace === -1 || lastBrace <= firstBrace) return null;
|
||||
cleaned = cleaned.slice(firstBrace, lastBrace + 1);
|
||||
|
||||
// Step 3: Repair common issues
|
||||
// Replace single quotes with double quotes (but not apostrophes inside words)
|
||||
cleaned = cleaned.replace(/'/g, '"');
|
||||
|
||||
// Remove trailing commas before } or ]
|
||||
cleaned = cleaned.replace(/,\s*([}\]])/g, "$1");
|
||||
|
||||
// Quote unquoted keys: word-chars followed by a colon
|
||||
cleaned = cleaned.replace(/(?<=[\s,{])(\w+)\s*:/g, '"$1":');
|
||||
|
||||
// Try parsing again
|
||||
try {
|
||||
const parsed = JSON.parse(cleaned);
|
||||
return this.normalizeResult(parsed);
|
||||
} catch { /* fall through to field-level extraction */ }
|
||||
|
||||
// Step 4: Last resort — regex extraction of individual fields
|
||||
try {
|
||||
const extract = (key: string): string | undefined => {
|
||||
const m = cleaned.match(new RegExp(`"${key}"\\s*:\\s*("(?:[^"\\\\]|\\\\.)*"|[^,}\\]]+)`, "i"));
|
||||
return m?.[1]?.trim();
|
||||
};
|
||||
|
||||
const toBool = (v: string | undefined): boolean => {
|
||||
if (!v) return false;
|
||||
const s = v.replace(/"/g, "").toLowerCase();
|
||||
return s === "true" || s === "1";
|
||||
};
|
||||
|
||||
const toNum = (v: string | undefined): number => {
|
||||
if (!v) return 0;
|
||||
const n = parseInt(v.replace(/"/g, ""), 10);
|
||||
return isNaN(n) ? 0 : n;
|
||||
};
|
||||
|
||||
const toNullableStr = (v: string | undefined): string | null => {
|
||||
if (!v) return null;
|
||||
const s = v.replace(/^"|"$/g, "").trim();
|
||||
if (!s || s === "null" || s === "none" || s === "N/A") return null;
|
||||
return s;
|
||||
};
|
||||
|
||||
const VALID_SENTIMENTS: Sentiment[] = ["neutral", "happy", "sad", "angry", "excited", "funny", "love", "scared"];
|
||||
const toSentiment = (v: string | undefined): Sentiment => {
|
||||
const s = (v ?? "neutral").replace(/^"|"$/g, "").trim().toLowerCase() as Sentiment;
|
||||
return VALID_SENTIMENTS.includes(s) ? s : "neutral";
|
||||
};
|
||||
|
||||
return this.normalizeResult({
|
||||
profanity: toBool(extract("profanity")),
|
||||
profanity_severity: toNum(extract("profanity_severity")),
|
||||
insult: toBool(extract("insult")),
|
||||
insult_type: toNullableStr(extract("insult_type")),
|
||||
insult_target: toNullableStr(extract("insult_target")),
|
||||
insult_severity: toNum(extract("insult_severity")),
|
||||
sentiment: toSentiment(extract("sentiment")),
|
||||
gratitude: toBool(extract("gratitude")),
|
||||
gratitude_toward: toNullableStr(extract("gratitude_toward")),
|
||||
wotd_used: toBool(extract("wotd_used")),
|
||||
wotd_correct: toBool(extract("wotd_correct")),
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce a parsed object into a valid ClassificationResult,
|
||||
* fixing type mismatches (e.g. "true" vs true, "none" vs null).
|
||||
*/
|
||||
private normalizeResult(obj: any): ClassificationResult | null {
|
||||
if (!obj || typeof obj !== "object") return null;
|
||||
|
||||
const toBool = (v: any): boolean => {
|
||||
if (typeof v === "boolean") return v;
|
||||
if (typeof v === "string") return v.toLowerCase() === "true" || v === "1";
|
||||
return !!v;
|
||||
};
|
||||
|
||||
const toSeverity = (v: any): number => {
|
||||
const n = typeof v === "number" ? v : parseInt(String(v), 10);
|
||||
if (isNaN(n) || n < 0) return 0;
|
||||
if (n > 3) return 3;
|
||||
return n;
|
||||
};
|
||||
|
||||
const toNullableStr = (v: any): string | null => {
|
||||
if (v == null) return null;
|
||||
const s = String(v).trim();
|
||||
if (s === "" || s === "null" || s === "none" || s === "N/A") return null;
|
||||
return s;
|
||||
};
|
||||
|
||||
const VALID_SENTIMENTS: Sentiment[] = ["neutral", "happy", "sad", "angry", "excited", "funny", "love", "scared"];
|
||||
const toSentiment = (v: any): Sentiment => {
|
||||
const s = String(v ?? "neutral").toLowerCase().trim() as Sentiment;
|
||||
return VALID_SENTIMENTS.includes(s) ? s : "neutral";
|
||||
};
|
||||
|
||||
const insultType = toNullableStr(obj.insult_type);
|
||||
|
||||
return {
|
||||
profanity: toBool(obj.profanity),
|
||||
profanity_severity: toSeverity(obj.profanity_severity),
|
||||
insult: toBool(obj.insult),
|
||||
insult_type: insultType === "direct" || insultType === "indirect" ? insultType : null,
|
||||
insult_target: toNullableStr(obj.insult_target),
|
||||
insult_severity: toSeverity(obj.insult_severity),
|
||||
sentiment: toSentiment(obj.sentiment),
|
||||
gratitude: toBool(obj.gratitude),
|
||||
gratitude_toward: toNullableStr(obj.gratitude_toward),
|
||||
wotd_used: toBool(obj.wotd_used),
|
||||
wotd_correct: toBool(obj.wotd_correct),
|
||||
};
|
||||
}
|
||||
|
||||
private storeResult(ctx: MessageContext, r: ClassificationResult): void {
|
||||
const db = getDb();
|
||||
|
||||
// Always insert classification
|
||||
db.prepare(`
|
||||
INSERT INTO llm_classifications (user_id, room_id, event_id, profanity, profanity_severity, insult, insult_type, insult_target, insult_severity, sentiment, gratitude, gratitude_toward, wotd_used, wotd_correct)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
ctx.sender, ctx.roomId, ctx.eventId,
|
||||
r.profanity ? 1 : 0, r.profanity_severity,
|
||||
r.insult ? 1 : 0, r.insult_type, r.insult_target, r.insult_severity, r.sentiment,
|
||||
r.gratitude ? 1 : 0, r.gratitude_toward,
|
||||
r.wotd_used ? 1 : 0, r.wotd_correct ? 1 : 0
|
||||
);
|
||||
|
||||
// Profanity tracking
|
||||
if (r.profanity) {
|
||||
const sevCol = `severity_${r.profanity_severity}` as "severity_1" | "severity_2" | "severity_3";
|
||||
if (r.profanity_severity >= 1 && r.profanity_severity <= 3) {
|
||||
db.prepare(`
|
||||
INSERT INTO potty_mouth (user_id, room_id, total, severity_1, severity_2, severity_3)
|
||||
VALUES (?, ?, 1, ?, ?, ?)
|
||||
ON CONFLICT(user_id, room_id) DO UPDATE SET
|
||||
total = total + 1,
|
||||
${sevCol} = ${sevCol} + 1
|
||||
`).run(
|
||||
ctx.sender, ctx.roomId,
|
||||
r.profanity_severity === 1 ? 1 : 0,
|
||||
r.profanity_severity === 2 ? 1 : 0,
|
||||
r.profanity_severity === 3 ? 1 : 0
|
||||
);
|
||||
|
||||
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}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Insult tracking
|
||||
if (r.insult) {
|
||||
const isDirect = r.insult_type === "direct";
|
||||
db.prepare(`
|
||||
INSERT INTO insult_log (user_id, room_id, total_thrown, direct_thrown, indirect_thrown, times_targeted)
|
||||
VALUES (?, ?, 1, ?, ?, 0)
|
||||
ON CONFLICT(user_id, room_id) DO UPDATE SET
|
||||
total_thrown = total_thrown + 1,
|
||||
direct_thrown = direct_thrown + ?,
|
||||
indirect_thrown = indirect_thrown + ?
|
||||
`).run(
|
||||
ctx.sender, ctx.roomId,
|
||||
isDirect ? 1 : 0,
|
||||
isDirect ? 0 : 1,
|
||||
isDirect ? 1 : 0,
|
||||
isDirect ? 0 : 1
|
||||
);
|
||||
|
||||
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}`));
|
||||
|
||||
// Track target
|
||||
if (r.insult_target) {
|
||||
db.prepare(`
|
||||
INSERT INTO insult_log (user_id, room_id, total_thrown, direct_thrown, indirect_thrown, times_targeted)
|
||||
VALUES (?, ?, 0, 0, 0, 1)
|
||||
ON CONFLICT(user_id, room_id) DO UPDATE SET
|
||||
times_targeted = times_targeted + 1
|
||||
`).run(r.insult_target, ctx.roomId);
|
||||
}
|
||||
}
|
||||
|
||||
// WOTD tracking
|
||||
if (r.wotd_used) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const xpAwarded = r.wotd_correct ? WOTD_XP : 0;
|
||||
|
||||
if (r.wotd_correct) {
|
||||
this.xpPlugin.grantXp(ctx.sender, ctx.roomId, WOTD_XP, "wotd_correct");
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO wotd_usage (user_id, room_id, date, xp_awarded)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, room_id, date) DO UPDATE SET
|
||||
xp_awarded = MAX(xp_awarded, ?)
|
||||
`).run(ctx.sender, ctx.roomId, today, xpAwarded, xpAwarded);
|
||||
|
||||
// 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}`));
|
||||
}
|
||||
|
||||
// Gratitude — grant rep when the LLM detects genuine thanks
|
||||
if (r.gratitude && r.gratitude_toward && r.gratitude_toward !== ctx.sender) {
|
||||
const receiverId = r.gratitude_toward;
|
||||
|
||||
// Check 24h cooldown
|
||||
const cooldown = db
|
||||
.prepare(
|
||||
`SELECT granted_at FROM rep_cooldowns
|
||||
WHERE giver_id = ? AND receiver_id = ? AND room_id = ?`
|
||||
)
|
||||
.get(ctx.sender, receiverId, ctx.roomId) as { granted_at: string } | undefined;
|
||||
|
||||
let onCooldown = false;
|
||||
if (cooldown) {
|
||||
const grantedAt = new Date(cooldown.granted_at + "Z").getTime();
|
||||
onCooldown = Date.now() - grantedAt < REP_COOLDOWN_HOURS * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
if (!onCooldown) {
|
||||
db.prepare(
|
||||
`INSERT INTO rep_cooldowns (giver_id, receiver_id, room_id, granted_at)
|
||||
VALUES (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(giver_id, receiver_id, room_id) DO UPDATE SET granted_at = datetime('now')`
|
||||
).run(ctx.sender, receiverId, ctx.roomId);
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO users (user_id, room_id, rep)
|
||||
VALUES (?, ?, 1)
|
||||
ON CONFLICT(user_id, room_id) DO UPDATE SET rep = rep + 1`
|
||||
).run(receiverId, ctx.roomId);
|
||||
|
||||
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}`));
|
||||
|
||||
logger.debug(`LLM: ${ctx.sender} gave rep to ${receiverId} in ${ctx.roomId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Sentiment reaction (only when no other reaction was sent)
|
||||
if (!r.profanity && !r.insult && !r.wotd_used && r.sentiment !== "neutral") {
|
||||
const sentimentEmojis: Record<Sentiment, string[]> = {
|
||||
neutral: [],
|
||||
sad: ["\uD83E\uDEE2", "\uD83D\uDE22", "\uD83E\uDD79", "\uD83D\uDC94", "\uD83E\uDD17"], // 🫢 😢 🥹 💔 🤗
|
||||
happy: ["\uD83D\uDE04", "\uD83C\uDF89", "\uD83D\uDE0A", "\u2728", "\uD83D\uDC4F"], // 😄 🎉 😊 ✨ 👏
|
||||
angry: ["\uD83D\uDE24", "\uD83D\uDCA2", "\uD83D\uDE21"], // 😤 💢 😡
|
||||
excited: ["\uD83D\uDE06", "\uD83D\uDD25", "\uD83C\uDF89", "\uD83D\uDE4C", "\u26A1"], // 😆 🔥 🎉 🙌 ⚡
|
||||
funny: ["\uD83D\uDE02", "\uD83D\uDC80", "\uD83E\uDD23", "\uD83D\uDE06"], // 😂 💀 🤣 😆
|
||||
love: ["\u2764\uFE0F", "\uD83E\uDD70", "\uD83D\uDE0D", "\uD83D\uDC96", "\uD83D\uDC9E"], // ❤️ 🥰 😍 💖 💞
|
||||
scared: ["\uD83D\uDE28", "\uD83D\uDE31", "\uD83D\uDE30"], // 😨 😱 😰
|
||||
};
|
||||
|
||||
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}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Commands ---
|
||||
|
||||
private async handlePotty(ctx: MessageContext): Promise<void> {
|
||||
const args = this.getArgs(ctx.body, "potty");
|
||||
const targetUser = args.startsWith("@") ? args.split(/\s/)[0] : ctx.sender;
|
||||
|
||||
const db = getDb();
|
||||
const row = db
|
||||
.prepare(`SELECT total, severity_1, severity_2, severity_3 FROM potty_mouth WHERE user_id = ? AND room_id = ?`)
|
||||
.get(targetUser, ctx.roomId) as { total: number; severity_1: number; severity_2: number; severity_3: number } | undefined;
|
||||
|
||||
if (!row || row.total === 0) {
|
||||
await this.sendMessage(ctx.roomId, `${targetUser} has a clean mouth. For now.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sendMessage(
|
||||
ctx.roomId,
|
||||
`${targetUser} potty mouth: Total: ${row.total} (mild: ${row.severity_1}, moderate: ${row.severity_2}, scorched: ${row.severity_3})`
|
||||
);
|
||||
}
|
||||
|
||||
private async handlePottyboard(ctx: MessageContext): Promise<void> {
|
||||
const db = getDb();
|
||||
const rows = db
|
||||
.prepare(`SELECT user_id, total, severity_1, severity_2, severity_3 FROM potty_mouth WHERE room_id = ? ORDER BY total DESC LIMIT 10`)
|
||||
.all(ctx.roomId) as { user_id: string; total: number; severity_1: number; severity_2: number; severity_3: number }[];
|
||||
|
||||
if (rows.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "No potty mouth data yet.");
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = rows.map(
|
||||
(r, i) => `${i + 1}. ${r.user_id} — ${r.total} (mild: ${r.severity_1}, moderate: ${r.severity_2}, scorched: ${r.severity_3})`
|
||||
);
|
||||
await this.sendMessage(ctx.roomId, `Potty Mouth Leaderboard:\n${lines.join("\n")}`);
|
||||
}
|
||||
|
||||
private async handleInsults(ctx: MessageContext): Promise<void> {
|
||||
const args = this.getArgs(ctx.body, "insults");
|
||||
const targetUser = args.startsWith("@") ? args.split(/\s/)[0] : ctx.sender;
|
||||
|
||||
const db = getDb();
|
||||
const row = db
|
||||
.prepare(`SELECT total_thrown, direct_thrown, indirect_thrown, times_targeted FROM insult_log WHERE user_id = ? AND room_id = ?`)
|
||||
.get(targetUser, ctx.roomId) as { total_thrown: number; direct_thrown: number; indirect_thrown: number; times_targeted: number } | undefined;
|
||||
|
||||
if (!row) {
|
||||
await this.sendMessage(ctx.roomId, `${targetUser} has no insult data. A saint, apparently.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sendMessage(
|
||||
ctx.roomId,
|
||||
`${targetUser} insults: Thrown: ${row.total_thrown} (direct: ${row.direct_thrown}, indirect: ${row.indirect_thrown}) | Received: ${row.times_targeted} times`
|
||||
);
|
||||
}
|
||||
|
||||
private async handleInsultboard(ctx: MessageContext): Promise<void> {
|
||||
const db = getDb();
|
||||
const rows = db
|
||||
.prepare(`SELECT user_id, total_thrown, direct_thrown, indirect_thrown FROM insult_log WHERE room_id = ? ORDER BY total_thrown DESC LIMIT 10`)
|
||||
.all(ctx.roomId) as { user_id: string; total_thrown: number; direct_thrown: number; indirect_thrown: number }[];
|
||||
|
||||
if (rows.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "No insult data yet.");
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = rows.map(
|
||||
(r, i) => `${i + 1}. ${r.user_id} — ${r.total_thrown} thrown (direct: ${r.direct_thrown}, indirect: ${r.indirect_thrown})`
|
||||
);
|
||||
await this.sendMessage(ctx.roomId, `Insult Leaderboard:\n${lines.join("\n")}`);
|
||||
}
|
||||
|
||||
private async handleWotdAttempts(ctx: MessageContext): Promise<void> {
|
||||
const db = getDb();
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const rows = db
|
||||
.prepare(`SELECT user_id, xp_awarded FROM wotd_usage WHERE room_id = ? AND date = ?`)
|
||||
.all(ctx.roomId, today) as { user_id: string; xp_awarded: number }[];
|
||||
|
||||
if (rows.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "No one has attempted today's WOTD yet.");
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = rows.map(
|
||||
(r) => `${r.user_id} — ${r.xp_awarded > 0 ? "credited" : "no credit"}`
|
||||
);
|
||||
await this.sendMessage(ctx.roomId, `Today's WOTD attempts:\n${lines.join("\n")}`);
|
||||
}
|
||||
}
|
||||
242
src/plugins/lookup.ts
Normal file
242
src/plugins/lookup.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
||||
import { getDb } from "../db";
|
||||
import logger from "../utils/logger";
|
||||
import { RateLimitsPlugin } from "./ratelimits";
|
||||
|
||||
const LANG_CODES = ["pt", "es", "fr", "de", "ja", "ko", "zh", "ar", "ru", "it"];
|
||||
|
||||
export class LookupPlugin extends Plugin {
|
||||
private rateLimitPlugin: RateLimitsPlugin;
|
||||
|
||||
constructor(client: IMatrixClient, rateLimitPlugin: RateLimitsPlugin) {
|
||||
super(client);
|
||||
this.rateLimitPlugin = rateLimitPlugin;
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "lookup";
|
||||
}
|
||||
|
||||
get commands(): CommandDef[] {
|
||||
return [
|
||||
{ name: "wiki", description: "Look up a Wikipedia article", usage: "!wiki <topic>" },
|
||||
{ name: "define", description: "Look up a word definition", usage: "!define <word>" },
|
||||
{ name: "translate", description: "Translate text using LibreTranslate", usage: "!translate [lang] <text>" },
|
||||
{ name: "urban", description: "Urban Dictionary lookup", usage: "!urban <term>" },
|
||||
];
|
||||
}
|
||||
|
||||
async onMessage(ctx: MessageContext): Promise<void> {
|
||||
if (this.isCommand(ctx.body, "wiki")) {
|
||||
await this.handleWiki(ctx);
|
||||
} else if (this.isCommand(ctx.body, "define")) {
|
||||
await this.handleDefine(ctx);
|
||||
} else if (this.isCommand(ctx.body, "translate")) {
|
||||
await this.handleTranslate(ctx);
|
||||
} else if (this.isCommand(ctx.body, "urban")) {
|
||||
await this.handleUrban(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleWiki(ctx: MessageContext): Promise<void> {
|
||||
const topic = this.getArgs(ctx.body, "wiki");
|
||||
if (!topic) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !wiki <topic>");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(topic)}`;
|
||||
const res = await fetch(url);
|
||||
|
||||
if (res.status === 404) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "No Wikipedia article found for that topic.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to fetch Wikipedia article.");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json() as any;
|
||||
const title = data.title ?? topic;
|
||||
let extract = data.extract ?? "";
|
||||
if (extract.length > 500) {
|
||||
extract = extract.slice(0, 500) + "...";
|
||||
}
|
||||
const pageUrl = data.content_urls?.desktop?.page ?? "";
|
||||
|
||||
const reply = `${title}\n\n${extract}\n\nRead more: ${pageUrl}`;
|
||||
await this.sendMessage(ctx.roomId, reply);
|
||||
} catch (err) {
|
||||
logger.error(`Wiki lookup failed: ${err}`);
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "An error occurred while looking up that topic.");
|
||||
}
|
||||
}
|
||||
|
||||
private async handleDefine(ctx: MessageContext): Promise<void> {
|
||||
const word = this.getArgs(ctx.body, "define");
|
||||
if (!word) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !define <word>");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `https://api.dictionaryapi.dev/api/v2/entries/en/${encodeURIComponent(word)}`;
|
||||
const res = await fetch(url);
|
||||
|
||||
if (!res.ok) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "No definition found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json() as any[];
|
||||
if (!data || data.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "No definition found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = data[0];
|
||||
const entryWord = entry.word ?? word;
|
||||
const phonetic = entry.phonetic ?? "";
|
||||
const meaning = entry.meanings?.[0];
|
||||
const partOfSpeech = meaning?.partOfSpeech ?? "";
|
||||
const definition = meaning?.definitions?.[0]?.definition ?? "";
|
||||
const example = meaning?.definitions?.[0]?.example;
|
||||
|
||||
let reply = `${entryWord}${phonetic ? ` (${phonetic})` : ""}\n[${partOfSpeech}] ${definition}`;
|
||||
if (example) {
|
||||
reply += `\nExample: "${example}"`;
|
||||
}
|
||||
|
||||
await this.sendMessage(ctx.roomId, reply);
|
||||
} catch (err) {
|
||||
logger.error(`Define lookup failed: ${err}`);
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "An error occurred while looking up that word.");
|
||||
}
|
||||
}
|
||||
|
||||
private async handleUrban(ctx: MessageContext): Promise<void> {
|
||||
const term = this.getArgs(ctx.body, "urban");
|
||||
if (!term) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !urban <term>");
|
||||
return;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const termLower = term.toLowerCase();
|
||||
const CACHE_TTL = 24 * 60 * 60; // 24 hours
|
||||
|
||||
// Check cache
|
||||
const cached = db.prepare(
|
||||
`SELECT data FROM urban_cache WHERE term = ? AND cached_at > unixepoch() - ?`
|
||||
).get(termLower, CACHE_TTL) as { data: string } | undefined;
|
||||
|
||||
let entry: any;
|
||||
|
||||
if (cached) {
|
||||
entry = JSON.parse(cached.data);
|
||||
} else {
|
||||
try {
|
||||
const url = `https://api.urbandictionary.com/v0/define?term=${encodeURIComponent(term)}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to look up that term.");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { list: any[] };
|
||||
if (!data.list || data.list.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "No Urban Dictionary entry found.");
|
||||
return;
|
||||
}
|
||||
|
||||
entry = data.list[0];
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO urban_cache (term, data) VALUES (?, ?)
|
||||
ON CONFLICT(term) DO UPDATE SET data = excluded.data, cached_at = unixepoch()`
|
||||
).run(termLower, JSON.stringify(entry));
|
||||
} catch (err) {
|
||||
logger.error(`Urban Dictionary lookup failed: ${err}`);
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "An error occurred while looking up that term.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const word = entry.word ?? term;
|
||||
let definition = (entry.definition ?? "").replace(/\[|\]/g, "");
|
||||
if (definition.length > 400) definition = definition.slice(0, 400) + "...";
|
||||
let example = (entry.example ?? "").replace(/\[|\]/g, "");
|
||||
if (example.length > 200) example = example.slice(0, 200) + "...";
|
||||
const thumbsUp = entry.thumbs_up ?? 0;
|
||||
const thumbsDown = entry.thumbs_down ?? 0;
|
||||
|
||||
let reply = `${word}\n\n${definition}`;
|
||||
if (example) reply += `\n\nExample: "${example}"`;
|
||||
reply += `\n\n\uD83D\uDC4D ${thumbsUp} \uD83D\uDC4E ${thumbsDown}`;
|
||||
|
||||
await this.sendMessage(ctx.roomId, reply);
|
||||
}
|
||||
|
||||
private async handleTranslate(ctx: MessageContext): Promise<void> {
|
||||
const LIBRETRANSLATE_URL = process.env.LIBRETRANSLATE_URL;
|
||||
if (!LIBRETRANSLATE_URL) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "LibreTranslate is not configured.");
|
||||
return;
|
||||
}
|
||||
|
||||
const args = this.getArgs(ctx.body, "translate");
|
||||
if (!args) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !translate [lang] <text>");
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = this.rateLimitPlugin.checkLimit(
|
||||
ctx.sender,
|
||||
"translate",
|
||||
parseInt(process.env.RATELIMIT_TRANSLATE ?? "20"),
|
||||
);
|
||||
if (!allowed) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "You have reached your daily translate quota. Try again tomorrow.");
|
||||
return;
|
||||
}
|
||||
|
||||
let targetLang = "en";
|
||||
let text = args;
|
||||
|
||||
const words = args.split(/\s+/);
|
||||
if (words.length > 1 && words[0].length === 2 && LANG_CODES.includes(words[0].toLowerCase())) {
|
||||
targetLang = words[0].toLowerCase();
|
||||
text = words.slice(1).join(" ");
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${LIBRETRANSLATE_URL}/translate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
q: text,
|
||||
source: "auto",
|
||||
target: targetLang,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Translation request failed.");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json() as any;
|
||||
const translatedText = data.translatedText ?? "";
|
||||
const detectedLang = data.detectedLanguage?.language ?? "auto";
|
||||
|
||||
const reply = `[${detectedLang} \u2192 ${targetLang}] ${translatedText}`;
|
||||
await this.sendMessage(ctx.roomId, reply);
|
||||
} catch (err) {
|
||||
logger.error(`Translate failed: ${err}`);
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "An error occurred while translating.");
|
||||
}
|
||||
}
|
||||
}
|
||||
140
src/plugins/markov.ts
Normal file
140
src/plugins/markov.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
||||
import { getDb } from "../db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
export class MarkovPlugin extends Plugin {
|
||||
constructor(client: IMatrixClient) {
|
||||
super(client);
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "markov";
|
||||
}
|
||||
|
||||
get commands(): CommandDef[] {
|
||||
return [
|
||||
{ name: "markov", description: "Generate a Markov chain sentence", usage: "!markov [@user|me]" },
|
||||
];
|
||||
}
|
||||
|
||||
async onMessage(ctx: MessageContext): Promise<void> {
|
||||
// Passive: collect corpus from non-command messages
|
||||
if (!ctx.body.startsWith(this.prefix)) {
|
||||
this.recordMessage(ctx);
|
||||
}
|
||||
|
||||
if (this.isCommand(ctx.body, "markov")) {
|
||||
await this.handleMarkov(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
private recordMessage(ctx: MessageContext): void {
|
||||
const db = getDb();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO markov_corpus (user_id, room_id, message, added_at) VALUES (?, ?, ?, ?)`
|
||||
).run(ctx.sender, ctx.roomId, ctx.body, now);
|
||||
|
||||
const row = db
|
||||
.prepare(`SELECT COUNT(*) as cnt FROM markov_corpus WHERE user_id = ? AND room_id = ?`)
|
||||
.get(ctx.sender, ctx.roomId) as { cnt: number };
|
||||
|
||||
if (row.cnt > 10000) {
|
||||
db.prepare(
|
||||
`DELETE FROM markov_corpus WHERE id IN (
|
||||
SELECT id FROM markov_corpus WHERE user_id = ? AND room_id = ? ORDER BY id ASC LIMIT 1000
|
||||
)`
|
||||
).run(ctx.sender, ctx.roomId);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMarkov(ctx: MessageContext): Promise<void> {
|
||||
const args = this.getArgs(ctx.body, "markov");
|
||||
const db = getDb();
|
||||
|
||||
let messages: { message: string }[];
|
||||
let label: string;
|
||||
|
||||
if (!args) {
|
||||
// Room-wide combined corpus
|
||||
messages = db
|
||||
.prepare(`SELECT message FROM markov_corpus WHERE room_id = ?`)
|
||||
.all(ctx.roomId) as { message: string }[];
|
||||
label = "room";
|
||||
} else if (args === "me") {
|
||||
messages = db
|
||||
.prepare(`SELECT message FROM markov_corpus WHERE user_id = ? AND room_id = ?`)
|
||||
.all(ctx.sender, ctx.roomId) as { message: string }[];
|
||||
label = "user";
|
||||
} else if (args.startsWith("@")) {
|
||||
const targetUser = args.split(/\s/)[0];
|
||||
messages = db
|
||||
.prepare(`SELECT message FROM markov_corpus WHERE user_id = ? AND room_id = ?`)
|
||||
.all(targetUser, ctx.roomId) as { message: string }[];
|
||||
label = "user";
|
||||
} else {
|
||||
// Treat unknown arg as a user mention without @
|
||||
return;
|
||||
}
|
||||
|
||||
if (messages.length < 50) {
|
||||
const reply =
|
||||
label === "room"
|
||||
? "Not enough data in this room yet."
|
||||
: `Not enough data to impersonate ${args === "me" ? ctx.sender : args.split(/\s/)[0]} yet.`;
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const sentence = this.generateSentence(messages);
|
||||
if (!sentence) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Couldn't generate anything. Try again later.");
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sendMessage(ctx.roomId, sentence);
|
||||
}
|
||||
|
||||
private generateSentence(messages: { message: string }[]): string | null {
|
||||
const transitions = new Map<string, string[]>();
|
||||
const starters: string[] = [];
|
||||
|
||||
for (const { message } of messages) {
|
||||
const words = message.split(/\s+/).filter((w) => w.length > 0);
|
||||
if (words.length < 3) continue;
|
||||
|
||||
const starterKey = `${words[0]} ${words[1]}`;
|
||||
starters.push(starterKey);
|
||||
|
||||
for (let i = 0; i < words.length - 2; i++) {
|
||||
const key = `${words[i]} ${words[i + 1]}`;
|
||||
const next = words[i + 2];
|
||||
let list = transitions.get(key);
|
||||
if (!list) {
|
||||
list = [];
|
||||
transitions.set(key, list);
|
||||
}
|
||||
list.push(next);
|
||||
}
|
||||
}
|
||||
|
||||
if (starters.length === 0) return null;
|
||||
|
||||
const startKey = starters[Math.floor(Math.random() * starters.length)];
|
||||
const result = startKey.split(" ");
|
||||
|
||||
for (let i = 0; i < 48; i++) {
|
||||
const key = `${result[result.length - 2]} ${result[result.length - 1]}`;
|
||||
const candidates = transitions.get(key);
|
||||
if (!candidates || candidates.length === 0) break;
|
||||
|
||||
const next = candidates[Math.floor(Math.random() * candidates.length)];
|
||||
result.push(next);
|
||||
|
||||
if (/[.!?]$/.test(next)) break;
|
||||
}
|
||||
|
||||
return result.join(" ");
|
||||
}
|
||||
}
|
||||
451
src/plugins/movies.ts
Normal file
451
src/plugins/movies.ts
Normal file
@@ -0,0 +1,451 @@
|
||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
||||
import { getDb } from "../db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
const CACHE_TTL = 24 * 60 * 60; // 24 hours
|
||||
|
||||
export class MoviesPlugin extends Plugin {
|
||||
constructor(client: IMatrixClient) {
|
||||
super(client);
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "movies";
|
||||
}
|
||||
|
||||
get commands(): CommandDef[] {
|
||||
return [
|
||||
{ name: "movie", description: "Search movies or manage watchlist", usage: "!movie <title> | !movie watch <title> | !movie watching | !movie unwatch <title>" },
|
||||
{ name: "tv", description: "Search TV shows or add to watchlist", usage: "!tv <title> | !tv watch <title>" },
|
||||
{ name: "upcoming", description: "Upcoming theatrical releases", usage: "!upcoming movies [week|month]" },
|
||||
];
|
||||
}
|
||||
|
||||
private get apiKey(): string | undefined {
|
||||
return process.env.TMDB_API_KEY;
|
||||
}
|
||||
|
||||
async onMessage(ctx: MessageContext): Promise<void> {
|
||||
if (this.isCommand(ctx.body, "upcoming movies")) {
|
||||
await this.handleUpcoming(ctx);
|
||||
} else if (this.isCommand(ctx.body, "movie")) {
|
||||
await this.handleMovie(ctx);
|
||||
} else if (this.isCommand(ctx.body, "tv")) {
|
||||
await this.handleTv(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Movie command ---
|
||||
|
||||
private async handleMovie(ctx: MessageContext): Promise<void> {
|
||||
if (!this.apiKey) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "TMDB API key not configured.");
|
||||
return;
|
||||
}
|
||||
|
||||
const args = this.getArgs(ctx.body, "movie");
|
||||
if (!args) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !movie <title> | !movie watch <title> | !movie watching | !movie unwatch <title>");
|
||||
return;
|
||||
}
|
||||
|
||||
if (args === "watching") {
|
||||
await this.handleWatching(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.startsWith("unwatch ")) {
|
||||
const query = args.slice(8).trim();
|
||||
if (!query) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !movie unwatch <title>");
|
||||
return;
|
||||
}
|
||||
await this.handleUnwatch(ctx, query);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.startsWith("watch ")) {
|
||||
const title = args.slice(6).trim();
|
||||
if (!title) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !movie watch <title>");
|
||||
return;
|
||||
}
|
||||
await this.handleWatch(ctx, title, "movie");
|
||||
return;
|
||||
}
|
||||
|
||||
await this.handleMovieSearch(ctx, args);
|
||||
}
|
||||
|
||||
// --- TV command ---
|
||||
|
||||
private async handleTv(ctx: MessageContext): Promise<void> {
|
||||
if (!this.apiKey) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "TMDB API key not configured.");
|
||||
return;
|
||||
}
|
||||
|
||||
const args = this.getArgs(ctx.body, "tv");
|
||||
if (!args) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !tv <title> | !tv watch <title>");
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.startsWith("watch ")) {
|
||||
const title = args.slice(6).trim();
|
||||
if (!title) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !tv watch <title>");
|
||||
return;
|
||||
}
|
||||
await this.handleWatch(ctx, title, "tv");
|
||||
return;
|
||||
}
|
||||
|
||||
await this.handleTvSearch(ctx, args);
|
||||
}
|
||||
|
||||
// --- Movie search ---
|
||||
|
||||
private async handleMovieSearch(ctx: MessageContext, title: string): Promise<void> {
|
||||
try {
|
||||
const url = `https://api.themoviedb.org/3/search/movie?api_key=${this.apiKey}&query=${encodeURIComponent(title)}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to search TMDB.");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json() as any;
|
||||
const results = data.results ?? [];
|
||||
if (results.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `No movies found for "${title}".`);
|
||||
return;
|
||||
}
|
||||
|
||||
const movie = results[0];
|
||||
const details = await this.fetchDetails(movie.id, "movie");
|
||||
if (!details) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to fetch movie details.");
|
||||
return;
|
||||
}
|
||||
|
||||
const year = details.release_date ? details.release_date.slice(0, 4) : "N/A";
|
||||
const rating = details.vote_average != null ? details.vote_average.toFixed(1) : "N/A";
|
||||
const runtime = details.runtime ?? "N/A";
|
||||
const genres = (details.genres ?? []).map((g: any) => g.name).join(", ") || "N/A";
|
||||
const overview = details.overview
|
||||
? details.overview.length > 300
|
||||
? details.overview.slice(0, 300) + "..."
|
||||
: details.overview
|
||||
: "No overview available.";
|
||||
const releaseDate = details.release_date ?? "N/A";
|
||||
|
||||
const output = [
|
||||
`${details.title} (${year}) — ${rating}/10 | ${runtime} min | ${genres}`,
|
||||
overview,
|
||||
`Release: ${releaseDate}`,
|
||||
].join("\n");
|
||||
|
||||
await this.sendMessage(ctx.roomId, output);
|
||||
} catch (err) {
|
||||
logger.error(`Movie search failed: ${err}`);
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "An error occurred while searching.");
|
||||
}
|
||||
}
|
||||
|
||||
// --- TV search ---
|
||||
|
||||
private async handleTvSearch(ctx: MessageContext, title: string): Promise<void> {
|
||||
try {
|
||||
const url = `https://api.themoviedb.org/3/search/tv?api_key=${this.apiKey}&query=${encodeURIComponent(title)}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to search TMDB.");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json() as any;
|
||||
const results = data.results ?? [];
|
||||
if (results.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `No TV shows found for "${title}".`);
|
||||
return;
|
||||
}
|
||||
|
||||
const show = results[0];
|
||||
const details = await this.fetchDetails(show.id, "tv");
|
||||
if (!details) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to fetch TV details.");
|
||||
return;
|
||||
}
|
||||
|
||||
const year = details.first_air_date ? details.first_air_date.slice(0, 4) : "N/A";
|
||||
const rating = details.vote_average != null ? details.vote_average.toFixed(1) : "N/A";
|
||||
const seasons = details.number_of_seasons ?? "N/A";
|
||||
const status = details.status ?? "N/A";
|
||||
const genres = (details.genres ?? []).map((g: any) => g.name).join(", ") || "N/A";
|
||||
const overview = details.overview
|
||||
? details.overview.length > 300
|
||||
? details.overview.slice(0, 300) + "..."
|
||||
: details.overview
|
||||
: "No overview available.";
|
||||
|
||||
const lines = [
|
||||
`${details.name} (${year}) — ${rating}/10 | Seasons: ${seasons} | ${status}`,
|
||||
overview,
|
||||
];
|
||||
|
||||
if (details.next_episode_to_air && details.next_episode_to_air.air_date) {
|
||||
lines.push(`Next episode: ${details.next_episode_to_air.air_date}`);
|
||||
}
|
||||
|
||||
await this.sendMessage(ctx.roomId, lines.join("\n"));
|
||||
} catch (err) {
|
||||
logger.error(`TV search failed: ${err}`);
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "An error occurred while searching.");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Watch (add to watchlist) ---
|
||||
|
||||
private async handleWatch(ctx: MessageContext, title: string, mediaType: "movie" | "tv"): Promise<void> {
|
||||
try {
|
||||
const searchType = mediaType === "movie" ? "movie" : "tv";
|
||||
const url = `https://api.themoviedb.org/3/search/${searchType}?api_key=${this.apiKey}&query=${encodeURIComponent(title)}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to search TMDB.");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json() as any;
|
||||
const results = data.results ?? [];
|
||||
if (results.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `No ${mediaType === "movie" ? "movies" : "TV shows"} found for "${title}".`);
|
||||
return;
|
||||
}
|
||||
|
||||
const item = results[0];
|
||||
const tmdbId = item.id;
|
||||
const itemTitle = mediaType === "movie" ? item.title : item.name;
|
||||
const releaseDate = mediaType === "movie" ? (item.release_date ?? null) : (item.first_air_date ?? null);
|
||||
|
||||
const db = getDb();
|
||||
db.prepare(`
|
||||
INSERT INTO movie_watchlist (user_id, room_id, tmdb_id, title, media_type, release_date, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, unixepoch())
|
||||
`).run(ctx.sender, ctx.roomId, tmdbId, itemTitle, mediaType, releaseDate);
|
||||
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `Added "${itemTitle}" (${mediaType}) to your watchlist.`);
|
||||
} catch (err: any) {
|
||||
if (err?.code === "SQLITE_CONSTRAINT") {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "That title is already on your watchlist.");
|
||||
return;
|
||||
}
|
||||
logger.error(`Watch add failed: ${err}`);
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "An error occurred while adding to watchlist.");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Watching (list watchlist) ---
|
||||
|
||||
private async handleWatching(ctx: MessageContext): Promise<void> {
|
||||
const db = getDb();
|
||||
const rows = db
|
||||
.prepare(`SELECT title, media_type, release_date FROM movie_watchlist WHERE user_id = ? AND room_id = ? ORDER BY media_type ASC, title ASC`)
|
||||
.all(ctx.sender, ctx.roomId) as { title: string; media_type: string; release_date: string | null }[];
|
||||
|
||||
if (rows.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Your watchlist is empty.");
|
||||
return;
|
||||
}
|
||||
|
||||
const movies = rows.filter((r) => r.media_type === "movie");
|
||||
const tvShows = rows.filter((r) => r.media_type === "tv");
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
if (movies.length > 0) {
|
||||
lines.push("Movies:");
|
||||
for (const m of movies) {
|
||||
lines.push(` - ${m.title}${m.release_date ? ` (${m.release_date})` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (tvShows.length > 0) {
|
||||
lines.push("TV Shows:");
|
||||
for (const t of tvShows) {
|
||||
lines.push(` - ${t.title}${t.release_date ? ` (${t.release_date})` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
await this.sendMessage(ctx.roomId, lines.join("\n"));
|
||||
}
|
||||
|
||||
// --- Unwatch ---
|
||||
|
||||
private async handleUnwatch(ctx: MessageContext, query: string): Promise<void> {
|
||||
const db = getDb();
|
||||
const row = db
|
||||
.prepare(`SELECT id, title FROM movie_watchlist WHERE user_id = ? AND room_id = ? AND title LIKE ? LIMIT 1`)
|
||||
.get(ctx.sender, ctx.roomId, `%${query}%`) as { id: number; title: string } | undefined;
|
||||
|
||||
if (!row) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `No watchlist entry matching "${query}" found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
db.prepare(`DELETE FROM movie_watchlist WHERE id = ?`).run(row.id);
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `Removed "${row.title}" from your watchlist.`);
|
||||
}
|
||||
|
||||
// --- Upcoming movies ---
|
||||
|
||||
private async handleUpcoming(ctx: MessageContext): Promise<void> {
|
||||
if (!this.apiKey) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "TMDB API key not configured.");
|
||||
return;
|
||||
}
|
||||
|
||||
const args = this.getArgs(ctx.body, "upcoming movies").trim().toLowerCase();
|
||||
const days = args === "week" ? 7 : 30;
|
||||
|
||||
try {
|
||||
const url = `https://api.themoviedb.org/3/movie/upcoming?api_key=${this.apiKey}®ion=US`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to fetch upcoming movies.");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json() as any;
|
||||
const movies = data.results ?? [];
|
||||
|
||||
const now = new Date();
|
||||
const cutoff = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
|
||||
const todayStr = now.toISOString().slice(0, 10);
|
||||
const cutoffStr = cutoff.toISOString().slice(0, 10);
|
||||
|
||||
const upcoming = movies.filter((m: any) => {
|
||||
if (!m.release_date) return false;
|
||||
return m.release_date >= todayStr && m.release_date <= cutoffStr;
|
||||
});
|
||||
|
||||
if (upcoming.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `No upcoming theatrical releases in the next ${days} days.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = [`Upcoming Movies (next ${days} days):`];
|
||||
for (const m of upcoming) {
|
||||
lines.push(`- ${m.title} (${m.release_date})`);
|
||||
}
|
||||
|
||||
await this.sendMessage(ctx.roomId, lines.join("\n"));
|
||||
} catch (err) {
|
||||
logger.error(`Upcoming movies fetch failed: ${err}`);
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "An error occurred while fetching upcoming movies.");
|
||||
}
|
||||
}
|
||||
|
||||
// --- TMDB details with cache ---
|
||||
|
||||
private async fetchDetails(tmdbId: number, mediaType: string): Promise<any | null> {
|
||||
const cached = this.getCached(tmdbId, mediaType);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const url = `https://api.themoviedb.org/3/${mediaType}/${tmdbId}?api_key=${this.apiKey}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
logger.warn(`TMDB details API returned ${res.status} for ${mediaType}/${tmdbId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
this.setCache(tmdbId, mediaType, data);
|
||||
return data;
|
||||
} catch (err) {
|
||||
logger.error(`TMDB details fetch failed for ${mediaType}/${tmdbId}: ${err}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private getCached(tmdbId: number, mediaType: string): any | null {
|
||||
const db = getDb();
|
||||
const row = db
|
||||
.prepare(`SELECT data, cached_at FROM movie_cache WHERE tmdb_id = ? AND media_type = ?`)
|
||||
.get(tmdbId, mediaType) as { data: string; cached_at: number } | undefined;
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
const age = Math.floor(Date.now() / 1000) - row.cached_at;
|
||||
if (age > CACHE_TTL) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(row.data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private setCache(tmdbId: number, mediaType: string, data: any): void {
|
||||
const db = getDb();
|
||||
db.prepare(`INSERT OR REPLACE INTO movie_cache (tmdb_id, media_type, data, cached_at) VALUES (?, ?, ?, unixepoch())`)
|
||||
.run(tmdbId, mediaType, JSON.stringify(data));
|
||||
}
|
||||
|
||||
// --- Daily releases (called by scheduler at 20:00 UTC) ---
|
||||
|
||||
async postDailyReleases(botRooms: string[]): Promise<void> {
|
||||
const db = getDb();
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const releases = db
|
||||
.prepare(`SELECT DISTINCT tmdb_id, title, media_type FROM movie_watchlist WHERE release_date = ? AND notified = 0`)
|
||||
.all(today) as { tmdb_id: number; title: string; media_type: string }[];
|
||||
|
||||
if (releases.length === 0) return;
|
||||
|
||||
const lines = ["Today's Watchlist Releases:"];
|
||||
for (const r of releases) {
|
||||
const typeLabel = r.media_type === "movie" ? "Movie" : "TV";
|
||||
lines.push(`- [${typeLabel}] ${r.title}`);
|
||||
}
|
||||
const message = lines.join("\n");
|
||||
|
||||
// Post to each bot room
|
||||
for (const roomId of botRooms) {
|
||||
try {
|
||||
await this.sendMessage(roomId, message);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to post daily movie releases to ${roomId}: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// DM users who have these on their watchlist
|
||||
const userEntries = db
|
||||
.prepare(`SELECT id, user_id, title, media_type FROM movie_watchlist WHERE release_date = ? AND notified = 0`)
|
||||
.all(today) as { id: number; user_id: string; title: string; media_type: string }[];
|
||||
|
||||
const userMap = new Map<string, string[]>();
|
||||
for (const entry of userEntries) {
|
||||
const typeLabel = entry.media_type === "movie" ? "Movie" : "TV";
|
||||
const label = `[${typeLabel}] ${entry.title}`;
|
||||
const existing = userMap.get(entry.user_id) ?? [];
|
||||
existing.push(label);
|
||||
userMap.set(entry.user_id, existing);
|
||||
}
|
||||
|
||||
for (const [userId, titles] of userMap) {
|
||||
try {
|
||||
const dmMessage = `Your watchlist releases today:\n${titles.map((t) => `- ${t}`).join("\n")}`;
|
||||
await this.sendDm(userId, dmMessage);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to DM movie releases to ${userId}: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark all as notified
|
||||
db.prepare(`UPDATE movie_watchlist SET notified = 1 WHERE release_date = ? AND notified = 0`).run(today);
|
||||
}
|
||||
}
|
||||
192
src/plugins/presence.ts
Normal file
192
src/plugins/presence.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
||||
import { getDb } from "../db";
|
||||
import { DateTime, IANAZone } from "luxon";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
const STATUS_EMOJI: Record<string, string> = {
|
||||
online: "\u{1F7E2}",
|
||||
away: "\u{1F7E1}",
|
||||
afk: "\u{1F534}",
|
||||
};
|
||||
|
||||
export class PresencePlugin extends Plugin {
|
||||
constructor(client: IMatrixClient) {
|
||||
super(client);
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "presence";
|
||||
}
|
||||
|
||||
get commands(): CommandDef[] {
|
||||
return [
|
||||
{ name: "away", description: "Set status to away", usage: "!away [message]" },
|
||||
{ name: "afk", description: "Set status to AFK", usage: "!afk [message]" },
|
||||
{ name: "back", description: "Set status back to online", usage: "!back" },
|
||||
{ name: "whois", description: "Show user profile card", usage: "!whois @user" },
|
||||
];
|
||||
}
|
||||
|
||||
async onMessage(ctx: MessageContext): Promise<void> {
|
||||
const db = getDb();
|
||||
|
||||
// Passive: auto-clear away/afk on any message
|
||||
db.prepare(
|
||||
`UPDATE presence SET status = 'online', status_message = NULL, updated_at = unixepoch()
|
||||
WHERE user_id = ? AND room_id = ? AND status IN ('away', 'afk')`
|
||||
).run(ctx.sender, ctx.roomId);
|
||||
|
||||
// Passive: always upsert updated_at for last seen
|
||||
db.prepare(
|
||||
`INSERT INTO presence (user_id, room_id, status, updated_at)
|
||||
VALUES (?, ?, 'online', unixepoch())
|
||||
ON CONFLICT(user_id, room_id) DO UPDATE SET updated_at = unixepoch()`
|
||||
).run(ctx.sender, ctx.roomId);
|
||||
|
||||
if (this.isCommand(ctx.body, "away")) {
|
||||
await this.handleAway(ctx);
|
||||
} else if (this.isCommand(ctx.body, "afk")) {
|
||||
await this.handleAfk(ctx);
|
||||
} else if (this.isCommand(ctx.body, "back")) {
|
||||
await this.handleBack(ctx);
|
||||
} else if (this.isCommand(ctx.body, "whois")) {
|
||||
await this.handleWhois(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAway(ctx: MessageContext): Promise<void> {
|
||||
const message = this.getArgs(ctx.body, "away") || null;
|
||||
const db = getDb();
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO presence (user_id, room_id, status, status_message, updated_at)
|
||||
VALUES (?, ?, 'away', ?, unixepoch())
|
||||
ON CONFLICT(user_id, room_id) DO UPDATE SET status = 'away', status_message = excluded.status_message, updated_at = unixepoch()`
|
||||
).run(ctx.sender, ctx.roomId, message);
|
||||
|
||||
const reply = message
|
||||
? `${ctx.sender} is now away: ${message}`
|
||||
: `${ctx.sender} is now away.`;
|
||||
await this.sendMessage(ctx.roomId, reply);
|
||||
}
|
||||
|
||||
private async handleAfk(ctx: MessageContext): Promise<void> {
|
||||
const message = this.getArgs(ctx.body, "afk") || null;
|
||||
const db = getDb();
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO presence (user_id, room_id, status, status_message, updated_at)
|
||||
VALUES (?, ?, 'afk', ?, unixepoch())
|
||||
ON CONFLICT(user_id, room_id) DO UPDATE SET status = 'afk', status_message = excluded.status_message, updated_at = unixepoch()`
|
||||
).run(ctx.sender, ctx.roomId, message);
|
||||
|
||||
const reply = message
|
||||
? `${ctx.sender} is now AFK: ${message}`
|
||||
: `${ctx.sender} is now AFK.`;
|
||||
await this.sendMessage(ctx.roomId, reply);
|
||||
}
|
||||
|
||||
private async handleBack(ctx: MessageContext): Promise<void> {
|
||||
const db = getDb();
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO presence (user_id, room_id, status, status_message, updated_at)
|
||||
VALUES (?, ?, 'online', NULL, unixepoch())
|
||||
ON CONFLICT(user_id, room_id) DO UPDATE SET status = 'online', status_message = NULL, updated_at = unixepoch()`
|
||||
).run(ctx.sender, ctx.roomId);
|
||||
|
||||
await this.sendMessage(ctx.roomId, `${ctx.sender} is back online.`);
|
||||
}
|
||||
|
||||
private async handleWhois(ctx: MessageContext): Promise<void> {
|
||||
const args = this.getArgs(ctx.body, "whois").trim();
|
||||
const targetUser = args.split(/\s/)[0];
|
||||
|
||||
if (!targetUser || !targetUser.startsWith("@")) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !whois @user:server");
|
||||
return;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Presence data
|
||||
const presence = db
|
||||
.prepare(`SELECT status, status_message, updated_at FROM presence WHERE user_id = ? AND room_id = ?`)
|
||||
.get(targetUser, ctx.roomId) as { status: string; status_message: string | null; updated_at: number } | undefined;
|
||||
|
||||
const status = presence?.status ?? "online";
|
||||
const statusMessage = presence?.status_message;
|
||||
const updatedAt = presence?.updated_at;
|
||||
|
||||
// User data
|
||||
const user = db
|
||||
.prepare(`SELECT xp, level, rep, timezone FROM users WHERE user_id = ? AND room_id = ?`)
|
||||
.get(targetUser, ctx.roomId) as { xp: number; level: number; rep: number; timezone: string | null } | undefined;
|
||||
|
||||
// Streak data
|
||||
const stats = db
|
||||
.prepare(`SELECT current_streak FROM user_stats WHERE user_id = ? AND room_id = ?`)
|
||||
.get(targetUser, ctx.roomId) as { current_streak: number } | undefined;
|
||||
|
||||
// Now playing
|
||||
const playing = db
|
||||
.prepare(`SELECT game FROM now_playing WHERE user_id = ? AND room_id = ?`)
|
||||
.get(targetUser, ctx.roomId) as { game: string } | undefined;
|
||||
|
||||
// Build profile card
|
||||
const lines: string[] = [targetUser];
|
||||
|
||||
// Status line
|
||||
const emoji = STATUS_EMOJI[status] ?? STATUS_EMOJI.online;
|
||||
const statusLabel = status.charAt(0).toUpperCase() + status.slice(1);
|
||||
let statusLine = `Status: ${emoji} ${statusLabel}`;
|
||||
if (statusMessage) statusLine += ` (${statusMessage})`;
|
||||
lines.push(statusLine);
|
||||
|
||||
// Last seen
|
||||
if (updatedAt) {
|
||||
lines.push(`Last seen: ${this.formatLastSeen(updatedAt)}`);
|
||||
}
|
||||
|
||||
// Timezone
|
||||
if (user?.timezone) {
|
||||
const zone = IANAZone.create(user.timezone);
|
||||
if (zone.isValid) {
|
||||
const localTime = DateTime.now().setZone(zone).toFormat("HH:mm");
|
||||
lines.push(`Timezone: ${user.timezone} (${localTime} local)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Now playing
|
||||
if (playing?.game) {
|
||||
lines.push(`Now playing: ${playing.game}`);
|
||||
}
|
||||
|
||||
// Rep & Level
|
||||
const rep = user?.rep ?? 0;
|
||||
const level = user?.level ?? 0;
|
||||
lines.push(`Reputation: ${rep} | Level: ${level}`);
|
||||
|
||||
// Streak
|
||||
const streak = stats?.current_streak ?? 0;
|
||||
lines.push(`Streak: ${streak} days`);
|
||||
|
||||
await this.sendMessage(ctx.roomId, lines.join("\n"));
|
||||
}
|
||||
|
||||
private formatLastSeen(updatedAt: number): string {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const diffSeconds = nowSeconds - updatedAt;
|
||||
|
||||
if (diffSeconds < 60) return "just now";
|
||||
|
||||
const diffMinutes = Math.floor(diffSeconds / 60);
|
||||
if (diffMinutes < 60) return `${diffMinutes} minute${diffMinutes !== 1 ? "s" : ""} ago`;
|
||||
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? "s" : ""} ago`;
|
||||
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return `${diffDays} day${diffDays !== 1 ? "s" : ""} ago`;
|
||||
}
|
||||
}
|
||||
78
src/plugins/ratelimits.ts
Normal file
78
src/plugins/ratelimits.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
||||
import { getDb } from "../db";
|
||||
|
||||
export class RateLimitsPlugin extends Plugin {
|
||||
constructor(client: IMatrixClient) {
|
||||
super(client);
|
||||
this.ensureTable();
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "ratelimits";
|
||||
}
|
||||
|
||||
get commands(): CommandDef[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
async onMessage(_ctx: MessageContext): Promise<void> {
|
||||
// No-op: this plugin provides utility methods only
|
||||
}
|
||||
|
||||
private ensureTable(): void {
|
||||
const db = getDb();
|
||||
db.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS command_usage (
|
||||
user_id TEXT,
|
||||
command TEXT,
|
||||
date TEXT,
|
||||
count INTEGER DEFAULT 0,
|
||||
PRIMARY KEY (user_id, command, date)
|
||||
)
|
||||
`).run();
|
||||
}
|
||||
|
||||
private todayKey(): string {
|
||||
const now = new Date();
|
||||
const y = now.getUTCFullYear();
|
||||
const m = String(now.getUTCMonth() + 1).padStart(2, "0");
|
||||
const d = String(now.getUTCDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
private getCount(userId: string, command: string, date: string): number {
|
||||
const db = getDb();
|
||||
const row = db
|
||||
.prepare(`SELECT count FROM command_usage WHERE user_id = ? AND command = ? AND date = ?`)
|
||||
.get(userId, command, date) as { count: number } | undefined;
|
||||
return row?.count ?? 0;
|
||||
}
|
||||
|
||||
checkLimit(userId: string, command: string, dailyMax: number): boolean {
|
||||
if (dailyMax === 0) return true;
|
||||
if (this.isAdmin(userId)) return true;
|
||||
|
||||
const date = this.todayKey();
|
||||
const current = this.getCount(userId, command, date);
|
||||
|
||||
if (current >= dailyMax) return false;
|
||||
|
||||
const db = getDb();
|
||||
db.prepare(`
|
||||
INSERT INTO command_usage (user_id, command, date, count)
|
||||
VALUES (?, ?, ?, 1)
|
||||
ON CONFLICT(user_id, command, date) DO UPDATE SET count = count + 1
|
||||
`).run(userId, command, date);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
remaining(userId: string, command: string, dailyMax: number): number {
|
||||
if (dailyMax === 0) return Infinity;
|
||||
if (this.isAdmin(userId)) return Infinity;
|
||||
|
||||
const date = this.todayKey();
|
||||
const current = this.getCount(userId, command, date);
|
||||
return Math.max(0, dailyMax - current);
|
||||
}
|
||||
}
|
||||
86
src/plugins/reactions.ts
Normal file
86
src/plugins/reactions.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { IMatrixClient, Plugin, CommandDef, MessageContext, ReactionContext } from "./base";
|
||||
import { getDb } from "../db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
export class ReactionsPlugin extends Plugin {
|
||||
constructor(client: IMatrixClient) {
|
||||
super(client);
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "reactions";
|
||||
}
|
||||
|
||||
get commands(): CommandDef[] {
|
||||
return [
|
||||
{ name: "emojiboard", description: "Reaction leaderboard" },
|
||||
];
|
||||
}
|
||||
|
||||
async onMessage(ctx: MessageContext): Promise<void> {
|
||||
if (this.isCommand(ctx.body, "emojiboard")) {
|
||||
await this.handleEmojiboard(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
async onReaction(ctx: ReactionContext): Promise<void> {
|
||||
if (!ctx.reactionKey || !ctx.targetEventId) return;
|
||||
|
||||
try {
|
||||
const targetEvent = await this.client.getEvent(ctx.roomId, ctx.targetEventId);
|
||||
const receiverId: string = targetEvent?.sender;
|
||||
if (!receiverId || receiverId === ctx.sender) return; // Don't track self-reactions
|
||||
|
||||
const db = getDb();
|
||||
db.prepare(
|
||||
`INSERT INTO reaction_log (giver_id, receiver_id, room_id, emoji, event_id)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
).run(ctx.sender, receiverId, ctx.roomId, ctx.reactionKey, ctx.eventId);
|
||||
} catch {
|
||||
// Event may not be found (deleted, redacted, or not yet decrypted) — silently skip
|
||||
}
|
||||
}
|
||||
|
||||
private async handleEmojiboard(ctx: MessageContext): Promise<void> {
|
||||
const db = getDb();
|
||||
|
||||
const topGivers = db.prepare(
|
||||
`SELECT giver_id, COUNT(*) as count FROM reaction_log
|
||||
WHERE room_id = ? GROUP BY giver_id ORDER BY count DESC LIMIT 5`
|
||||
).all(ctx.roomId) as { giver_id: string; count: number }[];
|
||||
|
||||
const topReceivers = db.prepare(
|
||||
`SELECT receiver_id, COUNT(*) as count FROM reaction_log
|
||||
WHERE room_id = ? GROUP BY receiver_id ORDER BY count DESC LIMIT 5`
|
||||
).all(ctx.roomId) as { receiver_id: string; count: number }[];
|
||||
|
||||
const topEmojis = db.prepare(
|
||||
`SELECT emoji, COUNT(*) as count FROM reaction_log
|
||||
WHERE room_id = ? GROUP BY emoji ORDER BY count DESC LIMIT 5`
|
||||
).all(ctx.roomId) as { emoji: string; count: number }[];
|
||||
|
||||
if (topGivers.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "No reaction data yet.");
|
||||
return;
|
||||
}
|
||||
|
||||
const giverLines = topGivers.map((r, i) => `${i + 1}. ${r.giver_id} — ${r.count}`);
|
||||
const receiverLines = topReceivers.map((r, i) => `${i + 1}. ${r.receiver_id} — ${r.count}`);
|
||||
const emojiLines = topEmojis.map((r, i) => `${i + 1}. ${r.emoji} — ${r.count}`);
|
||||
|
||||
const msg = [
|
||||
"Reaction Leaderboard:",
|
||||
"",
|
||||
"Most Reactions Given:",
|
||||
...giverLines,
|
||||
"",
|
||||
"Most Reactions Received:",
|
||||
...receiverLines,
|
||||
"",
|
||||
"Most Used Emoji:",
|
||||
...emojiLines,
|
||||
].join("\n");
|
||||
|
||||
await this.sendMessage(ctx.roomId, msg);
|
||||
}
|
||||
}
|
||||
142
src/plugins/reminders.ts
Normal file
142
src/plugins/reminders.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
||||
import { getDb } from "../db";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import * as chrono from "chrono-node";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
export class RemindersPlugin extends Plugin {
|
||||
constructor(client: IMatrixClient) {
|
||||
super(client);
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "reminders";
|
||||
}
|
||||
|
||||
get commands(): CommandDef[] {
|
||||
return [
|
||||
{ name: "remindme", description: "Set a reminder", usage: '!remindme <time> <message>' },
|
||||
{ name: "reminders", description: "List your pending reminders" },
|
||||
{ name: "unremind", description: "Cancel a reminder", usage: "!unremind <id>" },
|
||||
];
|
||||
}
|
||||
|
||||
async onMessage(ctx: MessageContext): Promise<void> {
|
||||
if (this.isCommand(ctx.body, "remindme")) {
|
||||
await this.handleRemindMe(ctx);
|
||||
} else if (this.isCommand(ctx.body, "unremind")) {
|
||||
await this.handleUnremind(ctx);
|
||||
} else if (this.isCommand(ctx.body, "reminders")) {
|
||||
await this.handleListReminders(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by DailyScheduler every 30 seconds to fire due reminders.
|
||||
*/
|
||||
async checkReminders(): Promise<void> {
|
||||
const db = getDb();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const due = db
|
||||
.prepare(`SELECT * FROM reminders WHERE fired = 0 AND remind_at <= ?`)
|
||||
.all(now) as {
|
||||
id: string;
|
||||
user_id: string;
|
||||
room_id: string;
|
||||
message: string;
|
||||
remind_at: string;
|
||||
}[];
|
||||
|
||||
for (const reminder of due) {
|
||||
try {
|
||||
await this.sendMessage(
|
||||
reminder.room_id,
|
||||
`Reminder for ${reminder.user_id}: ${reminder.message}`
|
||||
);
|
||||
db.prepare(`UPDATE reminders SET fired = 1 WHERE id = ?`).run(reminder.id);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to fire reminder ${reminder.id}: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleRemindMe(ctx: MessageContext): Promise<void> {
|
||||
const args = this.getArgs(ctx.body, "remindme");
|
||||
if (!args) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !remindme <time> <message>\nExample: !remindme in 2 hours check the oven");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get user timezone for parsing context
|
||||
const db = getDb();
|
||||
const userRow = db
|
||||
.prepare(`SELECT timezone FROM users WHERE user_id = ? AND room_id = ?`)
|
||||
.get(ctx.sender, ctx.roomId) as { timezone: string | null } | undefined;
|
||||
|
||||
const refDate = new Date();
|
||||
const parsed = chrono.parse(args, refDate, { forwardDate: true });
|
||||
|
||||
if (parsed.length === 0 || !parsed[0].start) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "I couldn't understand that time. Try something like: in 30 minutes, tomorrow at 3pm, next friday");
|
||||
return;
|
||||
}
|
||||
|
||||
const remindAt = parsed[0].start.date();
|
||||
// Extract message: everything after the parsed time expression
|
||||
const timeText = parsed[0].text;
|
||||
let message = args.slice(args.indexOf(timeText) + timeText.length).trim();
|
||||
if (!message) message = "(no message)";
|
||||
|
||||
const id = uuidv4().slice(0, 8);
|
||||
|
||||
db.prepare(`INSERT INTO reminders (id, user_id, room_id, message, remind_at) VALUES (?, ?, ?, ?, ?)`).run(
|
||||
id,
|
||||
ctx.sender,
|
||||
ctx.roomId,
|
||||
message,
|
||||
remindAt.toISOString()
|
||||
);
|
||||
|
||||
const timeStr = remindAt.toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" });
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `Reminder set for ${timeStr} (UTC). ID: ${id}`);
|
||||
}
|
||||
|
||||
private async handleListReminders(ctx: MessageContext): Promise<void> {
|
||||
const db = getDb();
|
||||
const rows = db
|
||||
.prepare(`SELECT id, message, remind_at FROM reminders WHERE user_id = ? AND room_id = ? AND fired = 0 ORDER BY remind_at ASC`)
|
||||
.all(ctx.sender, ctx.roomId) as { id: string; message: string; remind_at: string }[];
|
||||
|
||||
if (rows.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "You have no pending reminders.");
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = rows.map((r) => {
|
||||
const time = new Date(r.remind_at).toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" });
|
||||
return `[${r.id}] ${time} — ${r.message}`;
|
||||
});
|
||||
|
||||
await this.sendMessage(ctx.roomId, `Your reminders:\n${lines.join("\n")}`);
|
||||
}
|
||||
|
||||
private async handleUnremind(ctx: MessageContext): Promise<void> {
|
||||
const id = this.getArgs(ctx.body, "unremind").trim();
|
||||
if (!id) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !unremind <id>");
|
||||
return;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const result = db
|
||||
.prepare(`DELETE FROM reminders WHERE id = ? AND user_id = ? AND room_id = ? AND fired = 0`)
|
||||
.run(id, ctx.sender, ctx.roomId);
|
||||
|
||||
if (result.changes > 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `Reminder ${id} cancelled.`);
|
||||
} else {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `No pending reminder found with ID ${id}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
125
src/plugins/reputation.ts
Normal file
125
src/plugins/reputation.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
||||
import { XpPlugin } from "./xp";
|
||||
import { getDb } from "../db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
const THANKS_REGEX = /\b(?:thanks?|thank\s*you|thx|ty|tysm|tyvm|cheers|kudos|props)\b.*?(@\S+:\S+)/i;
|
||||
const THANKS_SIMPLE_REGEX = /\b(?:thanks?|thank\s*you|thx|ty|tysm|tyvm|cheers|kudos|props)\b/i;
|
||||
const REP_XP_BONUS = 5;
|
||||
const COOLDOWN_HOURS = 24;
|
||||
const LLM_ENABLED = (process.env.OLLAMA_HOST ?? "") !== "" && (process.env.OLLAMA_MODEL ?? "") !== "";
|
||||
|
||||
export class ReputationPlugin extends Plugin {
|
||||
private xpPlugin: XpPlugin;
|
||||
|
||||
constructor(client: IMatrixClient, xpPlugin: XpPlugin) {
|
||||
super(client);
|
||||
this.xpPlugin = xpPlugin;
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "reputation";
|
||||
}
|
||||
|
||||
get commands(): CommandDef[] {
|
||||
return [
|
||||
{ name: "rep", description: "Show reputation score", usage: "!rep [@user]" },
|
||||
{ name: "repboard", description: "Reputation leaderboard" },
|
||||
];
|
||||
}
|
||||
|
||||
async onMessage(ctx: MessageContext): Promise<void> {
|
||||
// Passive thanks detection (disabled when LLM handles it with sarcasm awareness)
|
||||
if (!LLM_ENABLED) this.detectThanks(ctx);
|
||||
|
||||
if (this.isCommand(ctx.body, "repboard")) {
|
||||
await this.handleRepboard(ctx);
|
||||
} else if (this.isCommand(ctx.body, "rep")) {
|
||||
await this.handleRep(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
private detectThanks(ctx: MessageContext): void {
|
||||
// Try to find a mentioned user in the thanks message
|
||||
const mentionMatch = ctx.body.match(THANKS_REGEX);
|
||||
if (!mentionMatch) return;
|
||||
|
||||
const receiverId = mentionMatch[1];
|
||||
if (receiverId === ctx.sender) return; // Can't thank yourself
|
||||
|
||||
this.grantRep(ctx.sender, receiverId, ctx.roomId, ctx.eventId);
|
||||
}
|
||||
|
||||
private grantRep(giverId: string, receiverId: string, roomId: string, eventId: string): void {
|
||||
const db = getDb();
|
||||
|
||||
// Check 24h cooldown
|
||||
const cooldown = db
|
||||
.prepare(
|
||||
`SELECT granted_at FROM rep_cooldowns
|
||||
WHERE giver_id = ? AND receiver_id = ? AND room_id = ?`
|
||||
)
|
||||
.get(giverId, receiverId, roomId) as { granted_at: string } | undefined;
|
||||
|
||||
if (cooldown) {
|
||||
const grantedAt = new Date(cooldown.granted_at + "Z").getTime();
|
||||
if (Date.now() - grantedAt < COOLDOWN_HOURS * 60 * 60 * 1000) return;
|
||||
}
|
||||
|
||||
// Update or insert cooldown
|
||||
db.prepare(
|
||||
`INSERT INTO rep_cooldowns (giver_id, receiver_id, room_id, granted_at)
|
||||
VALUES (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(giver_id, receiver_id, room_id) DO UPDATE SET granted_at = datetime('now')`
|
||||
).run(giverId, receiverId, roomId);
|
||||
|
||||
// Grant rep
|
||||
db.prepare(
|
||||
`INSERT INTO users (user_id, room_id, rep)
|
||||
VALUES (?, ?, 1)
|
||||
ON CONFLICT(user_id, room_id) DO UPDATE SET rep = rep + 1`
|
||||
).run(receiverId, roomId);
|
||||
|
||||
// Bonus XP
|
||||
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}`));
|
||||
|
||||
logger.debug(`${giverId} gave rep to ${receiverId} in ${roomId}`);
|
||||
}
|
||||
|
||||
private async handleRep(ctx: MessageContext): Promise<void> {
|
||||
const args = this.getArgs(ctx.body, "rep");
|
||||
const targetUser = args.startsWith("@") ? args.split(/\s/)[0] : ctx.sender;
|
||||
|
||||
const db = getDb();
|
||||
const row = db
|
||||
.prepare(`SELECT rep FROM users WHERE user_id = ? AND room_id = ?`)
|
||||
.get(targetUser, ctx.roomId) as { rep: number } | undefined;
|
||||
|
||||
const rep = row?.rep ?? 0;
|
||||
await this.sendMessage(ctx.roomId, `${targetUser} has ${rep} reputation point${rep !== 1 ? "s" : ""}.`);
|
||||
}
|
||||
|
||||
private async handleRepboard(ctx: MessageContext): Promise<void> {
|
||||
const db = getDb();
|
||||
const rows = db
|
||||
.prepare(`SELECT user_id, rep FROM users WHERE room_id = ? AND rep > 0 ORDER BY rep DESC LIMIT 10`)
|
||||
.all(ctx.roomId) as { user_id: string; rep: number }[];
|
||||
|
||||
if (rows.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "No reputation data yet.");
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = rows.map((r, i) => `${i + 1}. ${r.user_id} — ${r.rep} rep`);
|
||||
await this.sendMessage(ctx.roomId, `Reputation Leaderboard:\n${lines.join("\n")}`);
|
||||
}
|
||||
}
|
||||
160
src/plugins/retro.ts
Normal file
160
src/plugins/retro.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
||||
import { getDb } from "../db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
const RAWG_API_KEY = process.env.RAWG_API_KEY ?? "";
|
||||
const ENABLED = RAWG_API_KEY !== "";
|
||||
const CACHE_TTL = 7 * 24 * 60 * 60; // 7 days
|
||||
|
||||
export class RetroPlugin extends Plugin {
|
||||
constructor(client: IMatrixClient) {
|
||||
super(client);
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "retro";
|
||||
}
|
||||
|
||||
get commands(): CommandDef[] {
|
||||
return [
|
||||
{ name: "retro", description: "Retro game lookup", usage: "!retro <game>" },
|
||||
];
|
||||
}
|
||||
|
||||
async onMessage(ctx: MessageContext): Promise<void> {
|
||||
if (this.isCommand(ctx.body, "retro")) {
|
||||
await this.handleRetro(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleRetro(ctx: MessageContext): 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");
|
||||
if (!query) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !retro <game>");
|
||||
return;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const searchKey = query.toLowerCase();
|
||||
|
||||
// Check cache
|
||||
const cached = db.prepare(
|
||||
`SELECT data FROM retro_cache WHERE search_term = ? AND cached_at > unixepoch() - ?`
|
||||
).get(searchKey, CACHE_TTL) as { data: string } | undefined;
|
||||
|
||||
let games: any[];
|
||||
|
||||
if (cached) {
|
||||
games = JSON.parse(cached.data);
|
||||
} else {
|
||||
try {
|
||||
// Search RAWG, sort by relevance
|
||||
const url = `https://api.rawg.io/api/games?key=${RAWG_API_KEY}&search=${encodeURIComponent(query)}&page_size=3&search_precise=true`;
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
|
||||
|
||||
if (!res.ok) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "RAWG API request failed.");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { results: any[] };
|
||||
games = data.results ?? [];
|
||||
|
||||
// Fetch details for the first result to get description and developers
|
||||
if (games.length > 0) {
|
||||
try {
|
||||
const detailRes = await fetch(
|
||||
`https://api.rawg.io/api/games/${games[0].id}?key=${RAWG_API_KEY}`,
|
||||
{ signal: AbortSignal.timeout(10_000) }
|
||||
);
|
||||
if (detailRes.ok) {
|
||||
const detail = (await detailRes.json()) as any;
|
||||
games[0]._detail = {
|
||||
description_raw: detail.description_raw,
|
||||
developers: detail.developers,
|
||||
publishers: detail.publishers,
|
||||
};
|
||||
}
|
||||
} catch { /* detail fetch is best-effort */ }
|
||||
}
|
||||
|
||||
if (games.length > 0) {
|
||||
db.prepare(
|
||||
`INSERT INTO retro_cache (search_term, data) VALUES (?, ?)
|
||||
ON CONFLICT(search_term) DO UPDATE SET data = excluded.data, cached_at = unixepoch()`
|
||||
).run(searchKey, JSON.stringify(games));
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Retro game lookup failed: ${err}`);
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to look up that game.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!games || games.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "No results found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const reply = games.map((g, i) => this.formatGame(g, i === 0)).join("\n\n---\n\n");
|
||||
await this.sendMessage(ctx.roomId, reply);
|
||||
}
|
||||
|
||||
private formatGame(game: any, detailed: boolean): string {
|
||||
const name = game.name ?? "Unknown";
|
||||
|
||||
// Release year
|
||||
let year = "Unknown";
|
||||
if (game.released) {
|
||||
year = game.released.slice(0, 4);
|
||||
}
|
||||
|
||||
// Platforms
|
||||
const platforms = (game.platforms ?? [])
|
||||
.map((p: any) => p.platform?.name)
|
||||
.filter(Boolean)
|
||||
.join(", ") || "Unknown";
|
||||
|
||||
// Genres
|
||||
const genres = (game.genres ?? [])
|
||||
.map((g: any) => g.name)
|
||||
.join(", ");
|
||||
|
||||
// Rating
|
||||
let rating = "";
|
||||
if (game.metacritic) {
|
||||
rating = `Metacritic: ${game.metacritic}`;
|
||||
} else if (game.rating) {
|
||||
rating = `Rating: ${game.rating}/5`;
|
||||
}
|
||||
|
||||
// Developers & publishers (from detail fetch)
|
||||
const developers = (game._detail?.developers ?? [])
|
||||
.map((d: any) => d.name)
|
||||
.join(", ");
|
||||
const publishers = (game._detail?.publishers ?? [])
|
||||
.map((p: any) => p.name)
|
||||
.join(", ");
|
||||
|
||||
let lines = [`${name} (${year})`];
|
||||
lines.push(`Platforms: ${platforms}`);
|
||||
if (developers) lines.push(`Developer: ${developers}`);
|
||||
if (publishers) lines.push(`Publisher: ${publishers}`);
|
||||
if (genres) lines.push(`Genre: ${genres}`);
|
||||
if (rating) lines.push(rating);
|
||||
|
||||
if (detailed && game._detail?.description_raw) {
|
||||
let desc = game._detail.description_raw;
|
||||
if (desc.length > 300) desc = desc.slice(0, 300) + "...";
|
||||
lines.push("");
|
||||
lines.push(desc);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
}
|
||||
33
src/plugins/shade.ts
Normal file
33
src/plugins/shade.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
||||
|
||||
const COMING_SOON = "This feature is coming soon.";
|
||||
|
||||
export class ShadePlugin extends Plugin {
|
||||
constructor(client: IMatrixClient) {
|
||||
super(client);
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "shade";
|
||||
}
|
||||
|
||||
get commands(): CommandDef[] {
|
||||
return [
|
||||
{ name: "shadecheck", description: "(stub) Coming soon", usage: "!shadecheck [@user]" },
|
||||
{ name: "shadeboard", description: "(stub) Coming soon" },
|
||||
{ name: "perpetrators", description: "(stub) Coming soon" },
|
||||
{ name: "receipts", description: "(stub) Coming soon" },
|
||||
{ name: "shadewar", description: "(stub) Coming soon" },
|
||||
];
|
||||
}
|
||||
|
||||
async onMessage(ctx: MessageContext): Promise<void> {
|
||||
const stubCommands = ["shadecheck", "shadeboard", "perpetrators", "receipts", "shadewar"];
|
||||
for (const cmd of stubCommands) {
|
||||
if (this.isCommand(ctx.body, cmd)) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, COMING_SOON);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
228
src/plugins/stats.ts
Normal file
228
src/plugins/stats.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
||||
import { getDb } from "../db";
|
||||
import { parseMessage, deriveArchetype } from "../utils/parser";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
const MILESTONES = [1000, 5000, 10000, 25000, 50000, 100000, 250000, 500000, 1000000];
|
||||
|
||||
export class StatsPlugin extends Plugin {
|
||||
constructor(client: IMatrixClient) {
|
||||
super(client);
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "stats";
|
||||
}
|
||||
|
||||
get commands(): CommandDef[] {
|
||||
return [
|
||||
{ name: "stats", description: "Full message metrics", usage: "!stats [@user]" },
|
||||
{ name: "rankings", description: "Category leaderboard", usage: "!rankings [category] [month]" },
|
||||
{ name: "personality", description: "Derived community archetype", usage: "!personality [@user]" },
|
||||
];
|
||||
}
|
||||
|
||||
async onMessage(ctx: MessageContext): Promise<void> {
|
||||
// Passive: track stats for every message
|
||||
this.trackMessage(ctx);
|
||||
this.checkMilestone(ctx);
|
||||
|
||||
if (this.isCommand(ctx.body, "personality")) {
|
||||
await this.handlePersonality(ctx);
|
||||
} else if (this.isCommand(ctx.body, "stats")) {
|
||||
await this.handleStats(ctx);
|
||||
} else if (this.isCommand(ctx.body, "rankings")) {
|
||||
await this.handleRankings(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
private trackMessage(ctx: MessageContext): void {
|
||||
const parsed = parseMessage(ctx.body);
|
||||
const db = getDb();
|
||||
const now = new Date();
|
||||
const hour = now.getUTCHours();
|
||||
const day = now.getUTCDay();
|
||||
|
||||
// Get existing stats to update distributions
|
||||
const existing = db
|
||||
.prepare(`SELECT hourly_distribution, daily_distribution, total_messages, total_words FROM user_stats WHERE user_id = ? AND room_id = ?`)
|
||||
.get(ctx.sender, ctx.roomId) as
|
||||
| { hourly_distribution: string; daily_distribution: string; total_messages: number; total_words: number }
|
||||
| undefined;
|
||||
|
||||
const hourly: Record<string, number> = JSON.parse(existing?.hourly_distribution ?? "{}");
|
||||
const daily: Record<string, number> = JSON.parse(existing?.daily_distribution ?? "{}");
|
||||
hourly[hour] = (hourly[hour] ?? 0) + 1;
|
||||
daily[day] = (daily[day] ?? 0) + 1;
|
||||
|
||||
const newTotalMessages = (existing?.total_messages ?? 0) + 1;
|
||||
const newTotalWords = (existing?.total_words ?? 0) + parsed.wordCount;
|
||||
const newAvgWords = newTotalWords / newTotalMessages;
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO user_stats (
|
||||
user_id, room_id, total_messages, total_words, total_characters,
|
||||
total_links, total_images, total_questions, total_exclamations,
|
||||
total_emojis, longest_message, shortest_message, avg_words_per_message,
|
||||
hourly_distribution, daily_distribution, last_active_date
|
||||
) VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, date('now'))
|
||||
ON CONFLICT(user_id, room_id) DO UPDATE SET
|
||||
total_messages = total_messages + 1,
|
||||
total_words = total_words + ?,
|
||||
total_characters = total_characters + ?,
|
||||
total_links = total_links + ?,
|
||||
total_images = total_images + ?,
|
||||
total_questions = total_questions + ?,
|
||||
total_exclamations = total_exclamations + ?,
|
||||
total_emojis = total_emojis + ?,
|
||||
longest_message = MAX(longest_message, ?),
|
||||
shortest_message = MIN(COALESCE(shortest_message, ?), ?),
|
||||
avg_words_per_message = ?,
|
||||
hourly_distribution = ?,
|
||||
daily_distribution = ?,
|
||||
last_active_date = date('now')
|
||||
`).run(
|
||||
ctx.sender,
|
||||
ctx.roomId,
|
||||
parsed.wordCount,
|
||||
parsed.charCount,
|
||||
parsed.linkCount,
|
||||
parsed.imageCount,
|
||||
parsed.questionCount,
|
||||
parsed.exclamationCount,
|
||||
parsed.emojiCount,
|
||||
parsed.charCount,
|
||||
parsed.charCount,
|
||||
parsed.wordCount,
|
||||
JSON.stringify(hourly),
|
||||
JSON.stringify(daily),
|
||||
// ON CONFLICT params:
|
||||
parsed.wordCount,
|
||||
parsed.charCount,
|
||||
parsed.linkCount,
|
||||
parsed.imageCount,
|
||||
parsed.questionCount,
|
||||
parsed.exclamationCount,
|
||||
parsed.emojiCount,
|
||||
parsed.charCount,
|
||||
parsed.charCount,
|
||||
parsed.charCount,
|
||||
newAvgWords,
|
||||
JSON.stringify(hourly),
|
||||
JSON.stringify(daily)
|
||||
);
|
||||
}
|
||||
|
||||
private checkMilestone(ctx: MessageContext): void {
|
||||
try {
|
||||
const db = getDb();
|
||||
const row = db
|
||||
.prepare(
|
||||
`INSERT INTO room_milestones (room_id, total_messages, last_milestone)
|
||||
VALUES (?, 1, 0)
|
||||
ON CONFLICT(room_id) DO UPDATE SET total_messages = total_messages + 1
|
||||
RETURNING total_messages, last_milestone`
|
||||
)
|
||||
.get(ctx.roomId) as { total_messages: number; last_milestone: number };
|
||||
|
||||
const nextMilestone = MILESTONES.find((m) => m > row.last_milestone && m <= row.total_messages);
|
||||
if (nextMilestone) {
|
||||
db.prepare(`UPDATE room_milestones SET last_milestone = ? WHERE room_id = ?`)
|
||||
.run(nextMilestone, ctx.roomId);
|
||||
|
||||
const formatted = nextMilestone >= 1000000
|
||||
? `${(nextMilestone / 1000000).toFixed(nextMilestone % 1000000 === 0 ? 0 : 1)}M`
|
||||
: nextMilestone >= 1000
|
||||
? `${(nextMilestone / 1000).toFixed(nextMilestone % 1000 === 0 ? 0 : 1)}k`
|
||||
: String(nextMilestone);
|
||||
|
||||
this.sendMessage(ctx.roomId, `This room just hit ${formatted} messages! Congrats to everyone who contributed to this milestone.`)
|
||||
.catch((err) => logger.error(`Failed to send milestone: ${err}`));
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Milestone check failed: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleStats(ctx: MessageContext): Promise<void> {
|
||||
const args = this.getArgs(ctx.body, "stats");
|
||||
const targetUser = args.startsWith("@") ? args.split(/\s/)[0] : ctx.sender;
|
||||
|
||||
const db = getDb();
|
||||
const row = db
|
||||
.prepare(`SELECT * FROM user_stats WHERE user_id = ? AND room_id = ?`)
|
||||
.get(targetUser, ctx.roomId) as any;
|
||||
|
||||
if (!row) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `No stats found for ${targetUser}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`Stats for ${targetUser}:`,
|
||||
`Messages: ${row.total_messages} | Words: ${row.total_words}`,
|
||||
`Avg words/msg: ${row.avg_words_per_message.toFixed(1)}`,
|
||||
`Links: ${row.total_links} | Images: ${row.total_images}`,
|
||||
`Questions: ${row.total_questions} | Exclamations: ${row.total_exclamations}`,
|
||||
`Emojis: ${row.total_emojis}`,
|
||||
`Longest msg: ${row.longest_message} chars`,
|
||||
`Streak: ${row.current_streak}d (record: ${row.longest_streak}d)`,
|
||||
];
|
||||
|
||||
await this.sendMessage(ctx.roomId, lines.join("\n"));
|
||||
}
|
||||
|
||||
private async handleRankings(ctx: MessageContext): Promise<void> {
|
||||
const args = this.getArgs(ctx.body, "rankings");
|
||||
const parts = args.split(/\s+/);
|
||||
const category = parts[0] || "messages";
|
||||
|
||||
const columnMap: Record<string, string> = {
|
||||
messages: "total_messages",
|
||||
words: "total_words",
|
||||
links: "total_links",
|
||||
images: "total_images",
|
||||
questions: "total_questions",
|
||||
emojis: "total_emojis",
|
||||
streak: "longest_streak",
|
||||
};
|
||||
|
||||
const column = columnMap[category];
|
||||
if (!column) {
|
||||
const valid = Object.keys(columnMap).join(", ");
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `Unknown category. Valid: ${valid}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const rows = db
|
||||
.prepare(`SELECT user_id, ${column} as value FROM user_stats WHERE room_id = ? ORDER BY ${column} DESC LIMIT 10`)
|
||||
.all(ctx.roomId) as { user_id: string; value: number }[];
|
||||
|
||||
if (rows.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "No data yet.");
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = rows.map((r, i) => `${i + 1}. ${r.user_id} — ${r.value}`);
|
||||
await this.sendMessage(ctx.roomId, `Rankings (${category}):\n${lines.join("\n")}`);
|
||||
}
|
||||
|
||||
private async handlePersonality(ctx: MessageContext): Promise<void> {
|
||||
const args = this.getArgs(ctx.body, "personality");
|
||||
const targetUser = args.startsWith("@") ? args.split(/\s/)[0] : ctx.sender;
|
||||
|
||||
const db = getDb();
|
||||
const row = db
|
||||
.prepare(`SELECT total_messages, avg_words_per_message, total_questions, total_links, total_images, total_exclamations, hourly_distribution FROM user_stats WHERE user_id = ? AND room_id = ?`)
|
||||
.get(targetUser, ctx.roomId) as any;
|
||||
|
||||
if (!row) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `No data found for ${targetUser}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const archetype = deriveArchetype(row);
|
||||
await this.sendMessage(ctx.roomId, `${targetUser}'s community archetype: ${archetype}`);
|
||||
}
|
||||
}
|
||||
302
src/plugins/stocks.ts
Normal file
302
src/plugins/stocks.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
||||
import { getDb } from "../db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
const CACHE_TTL_QUOTE = 15 * 60; // 15 minutes
|
||||
const CACHE_TTL_PROFILE = 24 * 60 * 60; // 24 hours
|
||||
const CACHE_TTL_METRICS = 60 * 60; // 1 hour
|
||||
const COOLDOWN_MS = 60_000; // 60 seconds per user
|
||||
|
||||
export class StocksPlugin extends Plugin {
|
||||
private cooldowns: Map<string, number> = new Map();
|
||||
|
||||
constructor(client: IMatrixClient) {
|
||||
super(client);
|
||||
this.initDb();
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "stocks";
|
||||
}
|
||||
|
||||
get commands(): CommandDef[] {
|
||||
return [
|
||||
{ name: "stock", description: "Get stock price info", usage: "!stock <TICKER> [TICKER2] [TICKER3]" },
|
||||
{ name: "stockwatch", description: "Manage your stock watchlist", usage: "!stockwatch <TICKER|list|remove TICKER>" },
|
||||
];
|
||||
}
|
||||
|
||||
private initDb(): void {
|
||||
const db = getDb();
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS stocks_cache (
|
||||
ticker TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
cached_at INTEGER DEFAULT (unixepoch())
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS stock_watchlist (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT,
|
||||
room_id TEXT,
|
||||
ticker TEXT,
|
||||
created_at INTEGER,
|
||||
UNIQUE(user_id, room_id, ticker)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
private get apiKey(): string | undefined {
|
||||
return process.env.FINNHUB_API_KEY;
|
||||
}
|
||||
|
||||
async onMessage(ctx: MessageContext): Promise<void> {
|
||||
if (this.isCommand(ctx.body, "stockwatch")) {
|
||||
await this.handleStockwatch(ctx);
|
||||
} else if (this.isCommand(ctx.body, "stock")) {
|
||||
await this.handleStock(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
private checkCooldown(userId: string): number | null {
|
||||
const last = this.cooldowns.get(userId);
|
||||
if (last) {
|
||||
const elapsed = Date.now() - last;
|
||||
if (elapsed < COOLDOWN_MS) {
|
||||
return Math.ceil((COOLDOWN_MS - elapsed) / 1000);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private setCooldown(userId: string): void {
|
||||
this.cooldowns.set(userId, Date.now());
|
||||
}
|
||||
|
||||
private async handleStock(ctx: MessageContext): Promise<void> {
|
||||
if (!this.apiKey) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Finnhub API key not configured.");
|
||||
return;
|
||||
}
|
||||
|
||||
const args = this.getArgs(ctx.body, "stock");
|
||||
if (!args) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !stock <TICKER> [TICKER2] [TICKER3]");
|
||||
return;
|
||||
}
|
||||
|
||||
const remaining = this.checkCooldown(ctx.sender);
|
||||
if (remaining !== null) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `Please wait ${remaining}s before requesting stock data again.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const tickers = args.split(/\s+/).map((t) => t.toUpperCase());
|
||||
const blocks: string[] = [];
|
||||
|
||||
for (const ticker of tickers) {
|
||||
try {
|
||||
const block = await this.getStockBlock(ticker);
|
||||
blocks.push(block);
|
||||
} catch (err) {
|
||||
logger.error(`Stock lookup failed for ${ticker}: ${err}`);
|
||||
blocks.push(`${ticker} — Failed to retrieve data.`);
|
||||
}
|
||||
}
|
||||
|
||||
this.setCooldown(ctx.sender);
|
||||
await this.sendMessage(ctx.roomId, blocks.join("\n\n"));
|
||||
}
|
||||
|
||||
private async getStockBlock(ticker: string): Promise<string> {
|
||||
const quote = await this.fetchQuote(ticker);
|
||||
if (!quote || quote.c === 0) {
|
||||
return `${ticker} — No data found. Verify the ticker symbol.`;
|
||||
}
|
||||
|
||||
const profile = await this.fetchProfile(ticker);
|
||||
const metrics = await this.fetchMetrics(ticker);
|
||||
|
||||
const change = quote.d ?? 0;
|
||||
const pctChange = quote.dp ?? 0;
|
||||
const arrow = change >= 0 ? "\u25B2" : "\u25BC";
|
||||
const sign = change >= 0 ? "+" : "";
|
||||
|
||||
const header = profile && profile.name
|
||||
? `${ticker} \u2014 ${profile.name}${profile.exchange ? ` (${profile.exchange})` : ""}`
|
||||
: ticker;
|
||||
|
||||
const price = `$${quote.c.toFixed(2)} ${arrow} ${sign}${change.toFixed(2)} (${sign}${pctChange.toFixed(2)}%)`;
|
||||
|
||||
const high52 = metrics?.metric?.["52WeekHigh"];
|
||||
const low52 = metrics?.metric?.["52WeekLow"];
|
||||
const weekRange = high52 != null && low52 != null
|
||||
? `$${low52.toFixed(2)} \u2013 $${high52.toFixed(2)}`
|
||||
: "N/A";
|
||||
|
||||
const ts = quote.t ? new Date(quote.t * 1000) : null;
|
||||
const timeStr = ts
|
||||
? `${ts.getUTCHours().toString().padStart(2, "0")}:${ts.getUTCMinutes().toString().padStart(2, "0")} UTC`
|
||||
: "N/A";
|
||||
|
||||
return [
|
||||
header,
|
||||
`$${quote.c.toFixed(2)} ${arrow} ${sign}${change.toFixed(2)} (${sign}${pctChange.toFixed(2)}%)`,
|
||||
`Volume: N/A | 52W: ${weekRange}`,
|
||||
`Last updated: ${timeStr}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
private getCached(key: string, maxAge: number): any | null {
|
||||
const db = getDb();
|
||||
const row = db
|
||||
.prepare(`SELECT data, cached_at FROM stocks_cache WHERE ticker = ?`)
|
||||
.get(key) as { data: string; cached_at: number } | undefined;
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
const age = Math.floor(Date.now() / 1000) - row.cached_at;
|
||||
if (age > maxAge) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(row.data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private setCache(key: string, data: any): void {
|
||||
const db = getDb();
|
||||
db.prepare(`INSERT OR REPLACE INTO stocks_cache (ticker, data, cached_at) VALUES (?, ?, unixepoch())`)
|
||||
.run(key, JSON.stringify(data));
|
||||
}
|
||||
|
||||
private async fetchQuote(ticker: string): Promise<any> {
|
||||
const cacheKey = `quote:${ticker}`;
|
||||
const cached = this.getCached(cacheKey, CACHE_TTL_QUOTE);
|
||||
if (cached) return cached;
|
||||
|
||||
const url = `https://finnhub.io/api/v1/quote?symbol=${encodeURIComponent(ticker)}&token=${encodeURIComponent(this.apiKey!)}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
logger.warn(`Finnhub quote API returned ${res.status} for ${ticker}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
this.setCache(cacheKey, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
private async fetchProfile(ticker: string): Promise<any> {
|
||||
const cacheKey = `profile:${ticker}`;
|
||||
const cached = this.getCached(cacheKey, CACHE_TTL_PROFILE);
|
||||
if (cached) return cached;
|
||||
|
||||
const url = `https://finnhub.io/api/v1/stock/profile2?symbol=${encodeURIComponent(ticker)}&token=${encodeURIComponent(this.apiKey!)}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
logger.warn(`Finnhub profile API returned ${res.status} for ${ticker}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
this.setCache(cacheKey, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
private async fetchMetrics(ticker: string): Promise<any> {
|
||||
const cacheKey = `metrics:${ticker}`;
|
||||
const cached = this.getCached(cacheKey, CACHE_TTL_METRICS);
|
||||
if (cached) return cached;
|
||||
|
||||
const url = `https://finnhub.io/api/v1/stock/metric?symbol=${encodeURIComponent(ticker)}&metric=all&token=${encodeURIComponent(this.apiKey!)}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
logger.warn(`Finnhub metrics API returned ${res.status} for ${ticker}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
this.setCache(cacheKey, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
private async handleStockwatch(ctx: MessageContext): Promise<void> {
|
||||
if (!this.apiKey) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Finnhub API key not configured.");
|
||||
return;
|
||||
}
|
||||
|
||||
const args = this.getArgs(ctx.body, "stockwatch").trim();
|
||||
|
||||
if (!args) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !stockwatch <TICKER> | !stockwatch list | !stockwatch remove <TICKER>");
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.toLowerCase() === "list") {
|
||||
await this.handleWatchlistList(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.toLowerCase().startsWith("remove")) {
|
||||
const ticker = args.slice(6).trim().toUpperCase();
|
||||
if (!ticker) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !stockwatch remove <TICKER>");
|
||||
return;
|
||||
}
|
||||
await this.handleWatchlistRemove(ctx, ticker);
|
||||
return;
|
||||
}
|
||||
|
||||
const ticker = args.toUpperCase();
|
||||
await this.handleWatchlistAdd(ctx, ticker);
|
||||
}
|
||||
|
||||
private async handleWatchlistAdd(ctx: MessageContext, ticker: string): Promise<void> {
|
||||
const db = getDb();
|
||||
db.prepare(`INSERT OR IGNORE INTO stock_watchlist (user_id, room_id, ticker, created_at) VALUES (?, ?, ?, unixepoch())`)
|
||||
.run(ctx.sender, ctx.roomId, ticker);
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `Added ${ticker} to your watchlist.`);
|
||||
}
|
||||
|
||||
private async handleWatchlistRemove(ctx: MessageContext, ticker: string): Promise<void> {
|
||||
const db = getDb();
|
||||
const result = db
|
||||
.prepare(`DELETE FROM stock_watchlist WHERE user_id = ? AND room_id = ? AND ticker = ?`)
|
||||
.run(ctx.sender, ctx.roomId, ticker);
|
||||
|
||||
if (result.changes > 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `Removed ${ticker} from your watchlist.`);
|
||||
} else {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `${ticker} is not on your watchlist.`);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleWatchlistList(ctx: MessageContext): Promise<void> {
|
||||
const db = getDb();
|
||||
const rows = db
|
||||
.prepare(`SELECT ticker FROM stock_watchlist WHERE user_id = ? AND room_id = ? ORDER BY ticker ASC`)
|
||||
.all(ctx.sender, ctx.roomId) as { ticker: string }[];
|
||||
|
||||
if (rows.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Your watchlist is empty. Use !stockwatch <TICKER> to add one.");
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = rows.map((row) => {
|
||||
const cached = this.getCached(`quote:${row.ticker}`, CACHE_TTL_QUOTE);
|
||||
if (cached && cached.c) {
|
||||
const change = cached.d ?? 0;
|
||||
const sign = change >= 0 ? "+" : "";
|
||||
const arrow = change >= 0 ? "\u25B2" : "\u25BC";
|
||||
return `${row.ticker} $${cached.c.toFixed(2)} ${arrow} ${sign}${change.toFixed(2)}`;
|
||||
}
|
||||
return `${row.ticker} (no data)`;
|
||||
});
|
||||
|
||||
await this.sendMessage(ctx.roomId, `Your watchlist:\n${lines.join("\n")}`);
|
||||
}
|
||||
}
|
||||
127
src/plugins/streaks.ts
Normal file
127
src/plugins/streaks.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
||||
import { getDb } from "../db";
|
||||
|
||||
export class StreaksPlugin extends Plugin {
|
||||
constructor(client: IMatrixClient) {
|
||||
super(client);
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "streaks";
|
||||
}
|
||||
|
||||
get commands(): CommandDef[] {
|
||||
return [
|
||||
{ name: "streak", description: "Current and record streak", usage: "!streak [@user]" },
|
||||
{ name: "firstboard", description: "Early bird leaderboard", usage: "!firstboard [@user|month]" },
|
||||
];
|
||||
}
|
||||
|
||||
async onMessage(ctx: MessageContext): Promise<void> {
|
||||
// Passive: track daily activity and first poster
|
||||
this.trackActivity(ctx);
|
||||
|
||||
if (this.isCommand(ctx.body, "firstboard")) {
|
||||
await this.handleFirstboard(ctx);
|
||||
} else if (this.isCommand(ctx.body, "streak")) {
|
||||
await this.handleStreak(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
private trackActivity(ctx: MessageContext): void {
|
||||
const db = getDb();
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// Track daily activity
|
||||
db.prepare(`
|
||||
INSERT INTO daily_activity (user_id, room_id, date, message_count)
|
||||
VALUES (?, ?, ?, 1)
|
||||
ON CONFLICT(user_id, room_id, date) DO UPDATE SET message_count = message_count + 1
|
||||
`).run(ctx.sender, ctx.roomId, today);
|
||||
|
||||
// Track first poster (INSERT OR IGNORE — first one wins)
|
||||
db.prepare(`
|
||||
INSERT OR IGNORE INTO daily_first (room_id, date, user_id, timestamp)
|
||||
VALUES (?, ?, ?, datetime('now'))
|
||||
`).run(ctx.roomId, today, ctx.sender);
|
||||
|
||||
// Update streak in user_stats
|
||||
this.updateStreak(ctx.sender, ctx.roomId, today);
|
||||
}
|
||||
|
||||
private updateStreak(userId: string, roomId: string, today: string): void {
|
||||
const db = getDb();
|
||||
const stats = db
|
||||
.prepare(`SELECT last_active_date, current_streak, longest_streak FROM user_stats WHERE user_id = ? AND room_id = ?`)
|
||||
.get(userId, roomId) as
|
||||
| { last_active_date: string | null; current_streak: number; longest_streak: number }
|
||||
| undefined;
|
||||
|
||||
if (!stats) return;
|
||||
|
||||
const lastDate = stats.last_active_date;
|
||||
if (lastDate === today) return; // Already counted today
|
||||
|
||||
// Check if yesterday was active
|
||||
const yesterday = new Date();
|
||||
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
|
||||
const yesterdayStr = yesterday.toISOString().slice(0, 10);
|
||||
|
||||
let newStreak: number;
|
||||
if (lastDate === yesterdayStr) {
|
||||
newStreak = stats.current_streak + 1;
|
||||
} else {
|
||||
newStreak = 1;
|
||||
}
|
||||
|
||||
const newLongest = Math.max(stats.longest_streak, newStreak);
|
||||
|
||||
db.prepare(`
|
||||
UPDATE user_stats SET current_streak = ?, longest_streak = ?
|
||||
WHERE user_id = ? AND room_id = ?
|
||||
`).run(newStreak, newLongest, userId, roomId);
|
||||
}
|
||||
|
||||
private async handleStreak(ctx: MessageContext): Promise<void> {
|
||||
const args = this.getArgs(ctx.body, "streak");
|
||||
const targetUser = args.startsWith("@") ? args.split(/\s/)[0] : ctx.sender;
|
||||
|
||||
const db = getDb();
|
||||
const stats = db
|
||||
.prepare(`SELECT current_streak, longest_streak FROM user_stats WHERE user_id = ? AND room_id = ?`)
|
||||
.get(targetUser, ctx.roomId) as { current_streak: number; longest_streak: number } | undefined;
|
||||
|
||||
if (!stats) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `No streak data for ${targetUser}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sendMessage(
|
||||
ctx.roomId,
|
||||
`${targetUser}'s streak: ${stats.current_streak} day${stats.current_streak !== 1 ? "s" : ""} (record: ${stats.longest_streak})`
|
||||
);
|
||||
}
|
||||
|
||||
private async handleFirstboard(ctx: MessageContext): Promise<void> {
|
||||
const db = getDb();
|
||||
|
||||
const rows = db
|
||||
.prepare(`
|
||||
SELECT user_id, COUNT(*) as firsts
|
||||
FROM daily_first
|
||||
WHERE room_id = ?
|
||||
GROUP BY user_id
|
||||
ORDER BY firsts DESC
|
||||
LIMIT 10
|
||||
`)
|
||||
.all(ctx.roomId) as { user_id: string; firsts: number }[];
|
||||
|
||||
if (rows.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "No first-poster data yet.");
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = rows.map((r, i) => `${i + 1}. ${r.user_id} — ${r.firsts} first${r.firsts !== 1 ? "s" : ""}`);
|
||||
await this.sendMessage(ctx.roomId, `Early Bird Leaderboard:\n${lines.join("\n")}`);
|
||||
}
|
||||
}
|
||||
84
src/plugins/tools.ts
Normal file
84
src/plugins/tools.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
||||
import { evaluate } from "mathjs";
|
||||
import QRCode from "qrcode";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
export class ToolsPlugin extends Plugin {
|
||||
constructor(client: IMatrixClient) {
|
||||
super(client);
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "tools";
|
||||
}
|
||||
|
||||
get commands(): CommandDef[] {
|
||||
return [
|
||||
{ name: "calc", description: "Inline calculator", usage: "!calc <expression>" },
|
||||
{ name: "qr", description: "Generate a QR code image", usage: "!qr <text or URL>" },
|
||||
];
|
||||
}
|
||||
|
||||
async onMessage(ctx: MessageContext): Promise<void> {
|
||||
if (this.isCommand(ctx.body, "calc")) {
|
||||
await this.handleCalc(ctx);
|
||||
} else if (this.isCommand(ctx.body, "qr")) {
|
||||
await this.handleQr(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCalc(ctx: MessageContext): Promise<void> {
|
||||
const expr = this.getArgs(ctx.body, "calc");
|
||||
if (!expr) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !calc <expression>");
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize natural language patterns
|
||||
let normalized = expr
|
||||
.replace(/(\d),(\d)/g, "$1$2") // strip thousands commas: 450,000 -> 450000
|
||||
.replace(/(\d+(?:\.\d+)?)\s*%\s*of\s*(\d+(?:\.\d+)?)/gi, "($1 / 100) * $2")
|
||||
.replace(/(\d+(?:\.\d+)?)\s*%/g, "($1 / 100)");
|
||||
|
||||
try {
|
||||
const result = evaluate(normalized);
|
||||
const display = typeof result === "number"
|
||||
? Number.isInteger(result) ? result.toLocaleString("en-US") : parseFloat(result.toFixed(10)).toLocaleString("en-US")
|
||||
: String(result);
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `${expr} = ${display}`);
|
||||
} catch (err) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `Could not evaluate: ${expr}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleQr(ctx: MessageContext): Promise<void> {
|
||||
const text = this.getArgs(ctx.body, "qr");
|
||||
if (!text) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !qr <text or URL>");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const pngBuffer = await QRCode.toBuffer(text, { type: "png", width: 300, margin: 2 });
|
||||
const mxcUrl = await this.client.uploadContent(pngBuffer, "image/png", "qrcode.png");
|
||||
|
||||
await this.client.sendMessage(ctx.roomId, {
|
||||
msgtype: "m.image",
|
||||
body: "qrcode.png",
|
||||
url: mxcUrl,
|
||||
info: {
|
||||
mimetype: "image/png",
|
||||
w: 300,
|
||||
h: 300,
|
||||
size: pngBuffer.length,
|
||||
},
|
||||
"m.relates_to": {
|
||||
"m.in_reply_to": { event_id: ctx.eventId },
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`QR generation failed: ${err}`);
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to generate QR code.");
|
||||
}
|
||||
}
|
||||
}
|
||||
639
src/plugins/trivia.ts
Normal file
639
src/plugins/trivia.ts
Normal file
@@ -0,0 +1,639 @@
|
||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
||||
import { XpPlugin } from "./xp";
|
||||
import { getDb } from "../db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
const TRIVIA_TIMEOUT_SECONDS = parseInt(process.env.TRIVIA_TIMEOUT_SECONDS ?? "20", 10);
|
||||
const FULL_POINTS = 100;
|
||||
const FULL_POINTS_WINDOW_MS = 3000;
|
||||
|
||||
interface OpenTDBQuestion {
|
||||
category: string;
|
||||
type: string;
|
||||
difficulty: string;
|
||||
question: string;
|
||||
correct_answer: string;
|
||||
incorrect_answers: string[];
|
||||
}
|
||||
|
||||
interface ActiveQuestion {
|
||||
sessionId: number;
|
||||
answer: string;
|
||||
answerIndex: number;
|
||||
options: string[];
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
askedAt: number;
|
||||
startedBy: string;
|
||||
threadEventId: string;
|
||||
}
|
||||
|
||||
const CATEGORY_MAP: Record<number, string> = {
|
||||
9: "General Knowledge",
|
||||
10: "Books",
|
||||
11: "Film",
|
||||
12: "Music",
|
||||
13: "Musicals & Theatre",
|
||||
14: "Television",
|
||||
15: "Video Games",
|
||||
16: "Board Games",
|
||||
17: "Science & Nature",
|
||||
18: "Computers",
|
||||
19: "Mathematics",
|
||||
20: "Mythology",
|
||||
21: "Sports",
|
||||
22: "Geography",
|
||||
23: "History",
|
||||
24: "Politics",
|
||||
25: "Art",
|
||||
26: "Celebrities",
|
||||
27: "Animals",
|
||||
28: "Vehicles",
|
||||
29: "Comics",
|
||||
30: "Gadgets",
|
||||
31: "Anime & Manga",
|
||||
32: "Cartoons",
|
||||
};
|
||||
|
||||
function decodeHtml(html: string): string {
|
||||
return html
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function shuffleArray<T>(arr: T[]): T[] {
|
||||
const shuffled = [...arr];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
return shuffled;
|
||||
}
|
||||
|
||||
function calculatePoints(elapsedMs: number): number {
|
||||
if (elapsedMs <= FULL_POINTS_WINDOW_MS) return FULL_POINTS;
|
||||
const timeoutMs = TRIVIA_TIMEOUT_SECONDS * 1000;
|
||||
if (elapsedMs >= timeoutMs) return 0;
|
||||
const decayRange = timeoutMs - FULL_POINTS_WINDOW_MS;
|
||||
const elapsed = elapsedMs - FULL_POINTS_WINDOW_MS;
|
||||
return Math.round(FULL_POINTS * (1 - elapsed / decayRange));
|
||||
}
|
||||
|
||||
function fuzzyMatchCategory(input: string): number | null {
|
||||
const lower = input.toLowerCase();
|
||||
for (const [id, name] of Object.entries(CATEGORY_MAP)) {
|
||||
if (name.toLowerCase().includes(lower) || lower.includes(name.toLowerCase())) {
|
||||
return parseInt(id);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export class TriviaPlugin extends Plugin {
|
||||
private xpPlugin: XpPlugin;
|
||||
private questionCache = new Map<string, OpenTDBQuestion[]>(); // cacheKey -> questions
|
||||
private activeQuestions = new Map<string, ActiveQuestion>();
|
||||
private triviaThreads = new Map<string, { eventId: string; categoryId?: number; difficulty?: string }>(); // roomId -> thread info
|
||||
|
||||
constructor(client: IMatrixClient, xpPlugin: XpPlugin) {
|
||||
super(client);
|
||||
this.xpPlugin = xpPlugin;
|
||||
this.initDb();
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "trivia";
|
||||
}
|
||||
|
||||
get commands(): CommandDef[] {
|
||||
return [
|
||||
{ name: "trivia", description: "Start a trivia question", usage: "!trivia [category] [easy|medium|hard]" },
|
||||
{ name: "trivia stop", description: "Cancel the active question" },
|
||||
{ name: "trivia scores", description: "Trivia leaderboard", usage: "!trivia scores [@user|month]" },
|
||||
{ name: "trivia categories", description: "List available trivia categories" },
|
||||
{ name: "trivia fastest", description: "Top 10 fastest correct answers" },
|
||||
];
|
||||
}
|
||||
|
||||
private initDb(): void {
|
||||
const db = getDb();
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS trivia_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
room_id TEXT,
|
||||
question TEXT,
|
||||
answer TEXT,
|
||||
category TEXT,
|
||||
difficulty TEXT,
|
||||
asked_at INTEGER DEFAULT (unixepoch()),
|
||||
answered_by TEXT,
|
||||
answered_at INTEGER,
|
||||
correct INTEGER DEFAULT 0
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS trivia_scores (
|
||||
user_id TEXT,
|
||||
room_id TEXT,
|
||||
total_correct INTEGER DEFAULT 0,
|
||||
total_points INTEGER DEFAULT 0,
|
||||
total_answered INTEGER DEFAULT 0,
|
||||
current_streak INTEGER DEFAULT 0,
|
||||
best_streak INTEGER DEFAULT 0,
|
||||
fastest_ms INTEGER,
|
||||
PRIMARY KEY(user_id, room_id)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
private cacheKey(roomId: string, categoryId?: number, difficulty?: string): string {
|
||||
return `${roomId}:${categoryId ?? "any"}:${difficulty ?? "any"}`;
|
||||
}
|
||||
|
||||
private async fetchQuestions(roomId: string, categoryId?: number, difficulty?: string): Promise<void> {
|
||||
const key = this.cacheKey(roomId, categoryId, difficulty);
|
||||
try {
|
||||
let url = "https://opentdb.com/api.php?amount=50&type=multiple";
|
||||
if (categoryId) url += `&category=${categoryId}`;
|
||||
if (difficulty) url += `&difficulty=${difficulty}`;
|
||||
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
logger.error(`OpenTDB API returned ${res.status}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { response_code: number; results: OpenTDBQuestion[] };
|
||||
if (data.response_code !== 0 || !data.results.length) {
|
||||
// Try boolean type as fallback
|
||||
let boolUrl = "https://opentdb.com/api.php?amount=50&type=boolean";
|
||||
if (categoryId) boolUrl += `&category=${categoryId}`;
|
||||
if (difficulty) boolUrl += `&difficulty=${difficulty}`;
|
||||
|
||||
const boolRes = await fetch(boolUrl);
|
||||
if (boolRes.ok) {
|
||||
const boolData = (await boolRes.json()) as { response_code: number; results: OpenTDBQuestion[] };
|
||||
if (boolData.response_code === 0) {
|
||||
const cache = this.questionCache.get(key) ?? [];
|
||||
cache.push(...boolData.results);
|
||||
this.questionCache.set(key, cache);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const cache = this.questionCache.get(key) ?? [];
|
||||
cache.push(...data.results);
|
||||
this.questionCache.set(key, cache);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to fetch trivia questions: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
private getMessageThreadId(ctx: MessageContext): string | undefined {
|
||||
const relatesTo = ctx.event.content?.["m.relates_to"];
|
||||
if (relatesTo?.rel_type === "m.thread") {
|
||||
return relatesTo.event_id;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async sendThreadMessage(roomId: string, threadEventId: string, text: string): Promise<string> {
|
||||
return await this.client.sendMessage(roomId, {
|
||||
msgtype: "m.text",
|
||||
body: text,
|
||||
"m.relates_to": {
|
||||
rel_type: "m.thread",
|
||||
event_id: threadEventId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async sendThreadReply(roomId: string, threadEventId: string, replyToEventId: string, text: string): Promise<string> {
|
||||
return await this.client.sendMessage(roomId, {
|
||||
msgtype: "m.text",
|
||||
body: text,
|
||||
"m.relates_to": {
|
||||
rel_type: "m.thread",
|
||||
event_id: threadEventId,
|
||||
"is_falling_back": false,
|
||||
"m.in_reply_to": { event_id: replyToEventId },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async getOrCreateThread(roomId: string, categoryId?: number, difficulty?: string): Promise<string> {
|
||||
const existing = this.triviaThreads.get(roomId);
|
||||
// Reuse existing thread if category matches (or no category specified)
|
||||
if (existing) {
|
||||
const categoryChanged = categoryId !== undefined && categoryId !== existing.categoryId;
|
||||
if (!categoryChanged) return existing.eventId;
|
||||
}
|
||||
|
||||
const catName = categoryId ? CATEGORY_MAP[categoryId] : null;
|
||||
const label = catName ? `Trivia Time: ${catName}!` : "Trivia Time!";
|
||||
const threadRootId = await this.sendMessage(roomId, `${label} Answer trivia questions in this thread.`);
|
||||
this.triviaThreads.set(roomId, { eventId: threadRootId, categoryId, difficulty });
|
||||
return threadRootId;
|
||||
}
|
||||
|
||||
async onMessage(ctx: MessageContext): Promise<void> {
|
||||
const inThreadId = this.getMessageThreadId(ctx);
|
||||
const roomThread = this.triviaThreads.get(ctx.roomId);
|
||||
const roomThreadId = roomThread?.eventId;
|
||||
const isInTriviaThread = !!(inThreadId && inThreadId === roomThreadId);
|
||||
const active = this.activeQuestions.get(ctx.roomId);
|
||||
|
||||
// Check for answer attempts — only accept from within the trivia thread
|
||||
if (active && isInTriviaThread) {
|
||||
const trimmed = ctx.body.trim().toLowerCase();
|
||||
const isMultipleChoice = active.options.length === 4;
|
||||
const validLetters = isMultipleChoice ? ["a", "b", "c", "d"] : ["true", "false"];
|
||||
|
||||
if (validLetters.includes(trimmed)) {
|
||||
await this.handleAnswer(ctx, active, trimmed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.isCommand(ctx.body, "trivia")) return;
|
||||
|
||||
const args = this.getArgs(ctx.body, "trivia").trim();
|
||||
|
||||
// If a thread exists and the user is NOT in it, redirect them
|
||||
// Exception: allow starting a new question from the main room (with optional category/difficulty)
|
||||
const threadOnlySubcommands = ["stop", "scores", "fastest"];
|
||||
const isThreadOnly = threadOnlySubcommands.some((sc) => args === sc || args.startsWith(sc + " "));
|
||||
if (roomThreadId && !isInTriviaThread) {
|
||||
if (isThreadOnly) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Please use the trivia thread for trivia commands and answers.");
|
||||
return;
|
||||
}
|
||||
// Allow categories and starting new questions from the main room
|
||||
}
|
||||
|
||||
if (args === "stop") {
|
||||
await this.handleStop(ctx);
|
||||
} else if (args.startsWith("scores")) {
|
||||
await this.handleScores(ctx, args.slice(6).trim());
|
||||
} else if (args === "categories") {
|
||||
await this.handleCategories(ctx);
|
||||
} else if (args === "fastest") {
|
||||
await this.handleFastest(ctx);
|
||||
} else {
|
||||
await this.handleTrivia(ctx, args);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleTrivia(ctx: MessageContext, args: string): Promise<void> {
|
||||
if (this.activeQuestions.has(ctx.roomId)) {
|
||||
const thread = this.triviaThreads.get(ctx.roomId);
|
||||
if (thread) {
|
||||
await this.sendThreadReply(ctx.roomId, thread.eventId, ctx.eventId, "A question is already active! Answer it first.");
|
||||
} else {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "A question is already active! Answer it first.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse category and difficulty from args
|
||||
let categoryId: number | undefined;
|
||||
let difficulty: string | undefined;
|
||||
const difficulties = ["easy", "medium", "hard"];
|
||||
|
||||
if (args) {
|
||||
const parts = args.split(/\s+/);
|
||||
const diffPart = parts.find((p) => difficulties.includes(p.toLowerCase()));
|
||||
if (diffPart) {
|
||||
difficulty = diffPart.toLowerCase();
|
||||
parts.splice(parts.indexOf(diffPart), 1);
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
const catInput = parts.join(" ");
|
||||
const matched = fuzzyMatchCategory(catInput);
|
||||
if (matched) {
|
||||
categoryId = matched;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no category/difficulty specified and we're in an existing thread, inherit its settings
|
||||
const existingThread = this.triviaThreads.get(ctx.roomId);
|
||||
if (existingThread) {
|
||||
if (categoryId === undefined) categoryId = existingThread.categoryId;
|
||||
if (difficulty === undefined) difficulty = existingThread.difficulty;
|
||||
}
|
||||
|
||||
// Fetch questions if cache is empty for this room + category + difficulty
|
||||
const key = this.cacheKey(ctx.roomId, categoryId, difficulty);
|
||||
const roomCache = this.questionCache.get(key);
|
||||
if (!roomCache || roomCache.length === 0) {
|
||||
await this.fetchQuestions(ctx.roomId, categoryId, difficulty);
|
||||
}
|
||||
|
||||
const questions = this.questionCache.get(key);
|
||||
if (!questions || questions.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to fetch trivia questions. Try again later.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create or get the trivia thread
|
||||
const threadEventId = await this.getOrCreateThread(ctx.roomId, categoryId, difficulty);
|
||||
|
||||
const question = questions.shift()!;
|
||||
const decodedQuestion = decodeHtml(question.question);
|
||||
const correctAnswer = decodeHtml(question.correct_answer);
|
||||
const incorrectAnswers = question.incorrect_answers.map(decodeHtml);
|
||||
|
||||
const isBoolean = question.type === "boolean";
|
||||
let options: string[];
|
||||
let answerIndex: number;
|
||||
let answerLetter: string;
|
||||
|
||||
if (isBoolean) {
|
||||
options = ["True", "False"];
|
||||
answerIndex = correctAnswer === "True" ? 0 : 1;
|
||||
answerLetter = correctAnswer.toLowerCase();
|
||||
} else {
|
||||
options = shuffleArray([correctAnswer, ...incorrectAnswers]);
|
||||
answerIndex = options.indexOf(correctAnswer);
|
||||
answerLetter = ["a", "b", "c", "d"][answerIndex];
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const result = db.prepare(`
|
||||
INSERT INTO trivia_sessions (room_id, question, answer, category, difficulty, asked_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(ctx.roomId, decodedQuestion, correctAnswer, question.category, question.difficulty, now);
|
||||
|
||||
const sessionId = Number(result.lastInsertRowid);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
this.handleTimeout(ctx.roomId, sessionId, correctAnswer);
|
||||
}, TRIVIA_TIMEOUT_SECONDS * 1000);
|
||||
|
||||
this.activeQuestions.set(ctx.roomId, {
|
||||
sessionId,
|
||||
answer: answerLetter,
|
||||
answerIndex,
|
||||
options,
|
||||
timeout,
|
||||
askedAt: Date.now(),
|
||||
startedBy: ctx.sender,
|
||||
threadEventId,
|
||||
});
|
||||
|
||||
const diffLabel = question.difficulty.charAt(0).toUpperCase() + question.difficulty.slice(1);
|
||||
const lines = [`[${question.category}] (${diffLabel})`, decodedQuestion, ""];
|
||||
|
||||
if (isBoolean) {
|
||||
lines.push("True or False?");
|
||||
} else {
|
||||
const labels = ["A", "B", "C", "D"];
|
||||
for (let i = 0; i < options.length; i++) {
|
||||
lines.push(`${labels[i]}) ${options[i]}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("", `You have ${TRIVIA_TIMEOUT_SECONDS} seconds to answer!`);
|
||||
await this.sendThreadMessage(ctx.roomId, threadEventId, lines.join("\n"));
|
||||
}
|
||||
|
||||
private async handleAnswer(ctx: MessageContext, active: ActiveQuestion, answer: string): Promise<void> {
|
||||
const isBoolean = active.options.length === 2;
|
||||
let isCorrect: boolean;
|
||||
|
||||
if (isBoolean) {
|
||||
isCorrect = answer === active.answer;
|
||||
} else {
|
||||
const letterIndex = ["a", "b", "c", "d"].indexOf(answer);
|
||||
isCorrect = letterIndex === active.answerIndex;
|
||||
}
|
||||
|
||||
const elapsedMs = Date.now() - active.askedAt;
|
||||
const db = getDb();
|
||||
|
||||
if (isCorrect) {
|
||||
clearTimeout(active.timeout);
|
||||
this.activeQuestions.delete(ctx.roomId);
|
||||
|
||||
const points = calculatePoints(elapsedMs);
|
||||
const elapsedSec = (elapsedMs / 1000).toFixed(2);
|
||||
|
||||
// Update session
|
||||
db.prepare(`
|
||||
UPDATE trivia_sessions SET answered_by = ?, answered_at = ?, correct = 1
|
||||
WHERE id = ?
|
||||
`).run(ctx.sender, Math.floor(Date.now() / 1000), active.sessionId);
|
||||
|
||||
// Update scores
|
||||
db.prepare(`
|
||||
INSERT INTO trivia_scores (user_id, room_id, total_correct, total_points, total_answered, current_streak, best_streak, fastest_ms)
|
||||
VALUES (?, ?, 1, ?, 1, 1, 1, ?)
|
||||
ON CONFLICT(user_id, room_id) DO UPDATE SET
|
||||
total_correct = total_correct + 1,
|
||||
total_points = total_points + ?,
|
||||
total_answered = total_answered + 1,
|
||||
current_streak = current_streak + 1,
|
||||
best_streak = MAX(best_streak, current_streak + 1),
|
||||
fastest_ms = MIN(COALESCE(fastest_ms, ?), ?)
|
||||
`).run(ctx.sender, ctx.roomId, points, elapsedMs, points, elapsedMs, elapsedMs);
|
||||
|
||||
// Grant XP based on points
|
||||
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}`));
|
||||
|
||||
await this.sendThreadMessage(
|
||||
ctx.roomId,
|
||||
active.threadEventId,
|
||||
`Correct! ${ctx.sender} answered in ${elapsedSec}s for ${points} points!`
|
||||
);
|
||||
} else {
|
||||
// Update total_answered and reset streak
|
||||
db.prepare(`
|
||||
INSERT INTO trivia_scores (user_id, room_id, total_correct, total_points, total_answered, current_streak, best_streak)
|
||||
VALUES (?, ?, 0, 0, 1, 0, 0)
|
||||
ON CONFLICT(user_id, room_id) DO UPDATE SET
|
||||
total_answered = total_answered + 1,
|
||||
current_streak = 0
|
||||
`).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}`));
|
||||
}
|
||||
}
|
||||
|
||||
private handleTimeout(roomId: string, sessionId: number, correctAnswer: string): void {
|
||||
const active = this.activeQuestions.get(roomId);
|
||||
if (!active || active.sessionId !== sessionId) return;
|
||||
|
||||
this.activeQuestions.delete(roomId);
|
||||
|
||||
// Reset streaks for all participants who answered wrong
|
||||
const db = getDb();
|
||||
db.prepare(`
|
||||
UPDATE trivia_scores SET current_streak = 0 WHERE room_id = ?
|
||||
`).run(roomId);
|
||||
|
||||
const threadEventId = active.threadEventId;
|
||||
this.sendThreadMessage(roomId, threadEventId, `Time's up! The correct answer was: ${correctAnswer}`).catch((err) =>
|
||||
logger.error(`Failed to send timeout message: ${err}`)
|
||||
);
|
||||
}
|
||||
|
||||
private async handleStop(ctx: MessageContext): Promise<void> {
|
||||
const active = this.activeQuestions.get(ctx.roomId);
|
||||
const threadEventId = this.triviaThreads.get(ctx.roomId)?.eventId;
|
||||
if (!active) {
|
||||
if (threadEventId) {
|
||||
await this.sendThreadReply(ctx.roomId, threadEventId, ctx.eventId, "No active trivia question to stop.");
|
||||
} else {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "No active trivia question to stop.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isAdmin(ctx.sender) && ctx.sender !== active.startedBy) {
|
||||
await this.sendThreadReply(ctx.roomId, active.threadEventId, ctx.eventId, "Only an admin or the question starter can stop an active question.");
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(active.timeout);
|
||||
this.activeQuestions.delete(ctx.roomId);
|
||||
await this.sendThreadMessage(ctx.roomId, active.threadEventId, "Trivia question cancelled.");
|
||||
}
|
||||
|
||||
private async handleScores(ctx: MessageContext, args: string): Promise<void> {
|
||||
const db = getDb();
|
||||
const threadEventId = this.triviaThreads.get(ctx.roomId)!.eventId;
|
||||
|
||||
if (args === "month") {
|
||||
// Current month scores
|
||||
const now = new Date();
|
||||
const monthStart = Math.floor(new Date(now.getFullYear(), now.getMonth(), 1).getTime() / 1000);
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT answered_by AS user_id,
|
||||
COUNT(*) AS total_correct,
|
||||
SUM(CASE WHEN correct = 1 THEN 1 ELSE 0 END) AS wins
|
||||
FROM trivia_sessions
|
||||
WHERE room_id = ? AND correct = 1 AND asked_at >= ?
|
||||
GROUP BY answered_by
|
||||
ORDER BY wins DESC
|
||||
LIMIT 10
|
||||
`).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.");
|
||||
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")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.startsWith("@")) {
|
||||
// Individual user scores
|
||||
const targetUser = args.split(/\s/)[0];
|
||||
const row = db
|
||||
.prepare(`SELECT * FROM trivia_scores WHERE user_id = ? AND room_id = ?`)
|
||||
.get(targetUser, ctx.roomId) as {
|
||||
total_correct: number;
|
||||
total_points: number;
|
||||
total_answered: number;
|
||||
current_streak: number;
|
||||
best_streak: number;
|
||||
fastest_ms: number | null;
|
||||
} | undefined;
|
||||
|
||||
if (!row) {
|
||||
await this.sendThreadReply(ctx.roomId, threadEventId, ctx.eventId, `No trivia data found for ${targetUser}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const fastestSec = row.fastest_ms != null ? (row.fastest_ms / 1000).toFixed(2) + "s" : "N/A";
|
||||
const accuracy = row.total_answered > 0
|
||||
? ((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}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// General leaderboard
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT user_id, total_correct, total_points, best_streak
|
||||
FROM trivia_scores WHERE room_id = ?
|
||||
ORDER BY total_points DESC LIMIT 10`
|
||||
)
|
||||
.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.");
|
||||
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")}`);
|
||||
}
|
||||
|
||||
private async handleCategories(ctx: MessageContext): Promise<void> {
|
||||
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"));
|
||||
}
|
||||
|
||||
private async handleFastest(ctx: MessageContext): Promise<void> {
|
||||
const threadEventId = this.triviaThreads.get(ctx.roomId)!.eventId;
|
||||
const db = getDb();
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT user_id, fastest_ms
|
||||
FROM trivia_scores WHERE room_id = ? AND fastest_ms IS NOT NULL
|
||||
ORDER BY fastest_ms ASC LIMIT 10`
|
||||
)
|
||||
.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.");
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = 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")}`);
|
||||
}
|
||||
}
|
||||
149
src/plugins/urls.ts
Normal file
149
src/plugins/urls.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
||||
import { getDb } from "../db";
|
||||
import { parse as parseHtml } from "node-html-parser";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
const URL_REGEX = /https?:\/\/[^\s<>]+/g;
|
||||
const MEDIA_EXT = /\.(jpg|jpeg|png|gif|webp|mp4|webm|mp3|wav|pdf|zip|tar|gz)$/i;
|
||||
const CACHE_TTL = 86400; // 24 hours in seconds
|
||||
const FETCH_TIMEOUT = 3000; // 3 seconds
|
||||
const ENABLED = process.env.FEATURE_URL_PREVIEW === "true";
|
||||
|
||||
export class UrlsPlugin extends Plugin {
|
||||
constructor(client: IMatrixClient) {
|
||||
super(client);
|
||||
this.ensureTable();
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "urls";
|
||||
}
|
||||
|
||||
get commands(): CommandDef[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
private ensureTable(): void {
|
||||
try {
|
||||
const db = getDb();
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS url_cache (
|
||||
url TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
cached_at INTEGER
|
||||
)
|
||||
`);
|
||||
} catch (err) {
|
||||
logger.debug(`Failed to create url_cache table: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
async onMessage(ctx: MessageContext): Promise<void> {
|
||||
if (!ENABLED) return;
|
||||
|
||||
try {
|
||||
const matches = ctx.body.match(URL_REGEX);
|
||||
if (!matches || matches.length === 0) return;
|
||||
|
||||
// Only process the first URL to avoid spam
|
||||
const url = matches[0];
|
||||
|
||||
// Skip direct media files
|
||||
if (MEDIA_EXT.test(url)) return;
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Check cache
|
||||
const cached = db
|
||||
.prepare(`SELECT title, description, cached_at FROM url_cache WHERE url = ? AND cached_at > (unixepoch() - ?)`)
|
||||
.get(url, CACHE_TTL) as { title: string | null; description: string | null; cached_at: number } | undefined;
|
||||
|
||||
let title: string | null = null;
|
||||
let description: string | null = null;
|
||||
|
||||
if (cached) {
|
||||
// If title is null, a previous fetch failed — skip
|
||||
if (cached.title === null) return;
|
||||
title = cached.title;
|
||||
description = cached.description;
|
||||
} else {
|
||||
// Fetch the URL
|
||||
const result = await this.fetchUrl(url);
|
||||
if (!result) {
|
||||
// Cache the failure
|
||||
db.prepare(`INSERT OR REPLACE INTO url_cache (url, title, description, cached_at) VALUES (?, NULL, NULL, unixepoch())`).run(url);
|
||||
return;
|
||||
}
|
||||
|
||||
title = result.title;
|
||||
description = result.description;
|
||||
|
||||
// Cache result
|
||||
db.prepare(`INSERT OR REPLACE INTO url_cache (url, title, description, cached_at) VALUES (?, ?, ?, unixepoch())`).run(
|
||||
url,
|
||||
title,
|
||||
description
|
||||
);
|
||||
|
||||
if (!title) return;
|
||||
}
|
||||
|
||||
// Skip if the message body already contains the title text
|
||||
if (title && ctx.body.toLowerCase().includes(title.toLowerCase())) return;
|
||||
|
||||
// Build reply
|
||||
let reply = `\u{1F517} ${title}`;
|
||||
if (description) {
|
||||
const truncated = description.length > 200 ? description.slice(0, 200) + "..." : description;
|
||||
reply += `\n${truncated}`;
|
||||
}
|
||||
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, reply);
|
||||
} catch (err) {
|
||||
logger.debug(`URL preview error: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchUrl(url: string): Promise<{ title: string | null; description: string | null } | null> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"User-Agent": "Freebee Bot/1.0",
|
||||
},
|
||||
redirect: "follow",
|
||||
});
|
||||
|
||||
if (!res.ok) return null;
|
||||
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("text/html")) return null;
|
||||
|
||||
const html = await res.text();
|
||||
const root = parseHtml(html);
|
||||
|
||||
// Extract title: og:title -> <title>
|
||||
const ogTitle = root.querySelector('meta[property="og:title"]')?.getAttribute("content") ?? null;
|
||||
const titleTag = root.querySelector("title")?.text ?? null;
|
||||
const title = ogTitle || titleTag || null;
|
||||
|
||||
// Extract description: og:description -> meta[name="description"]
|
||||
const ogDesc = root.querySelector('meta[property="og:description"]')?.getAttribute("content") ?? null;
|
||||
const metaDesc = root.querySelector('meta[name="description"]')?.getAttribute("content") ?? null;
|
||||
const description = ogDesc || metaDesc || null;
|
||||
|
||||
return { title, description };
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug(`Failed to fetch URL ${url}: ${err}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
473
src/plugins/user.ts
Normal file
473
src/plugins/user.ts
Normal file
@@ -0,0 +1,473 @@
|
||||
import { IMatrixClient, Plugin, CommandDef, MessageContext, ReactionContext } from "./base";
|
||||
import { getDb } from "../db";
|
||||
import { DateTime, IANAZone } from "luxon";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
// Common city → IANA timezone mapping
|
||||
const CITY_TIMEZONE_MAP: Record<string, string> = {
|
||||
"new york": "America/New_York",
|
||||
"nyc": "America/New_York",
|
||||
"los angeles": "America/Los_Angeles",
|
||||
"la": "America/Los_Angeles",
|
||||
"chicago": "America/Chicago",
|
||||
"denver": "America/Denver",
|
||||
"phoenix": "America/Phoenix",
|
||||
"london": "Europe/London",
|
||||
"paris": "Europe/Paris",
|
||||
"berlin": "Europe/Berlin",
|
||||
"tokyo": "Asia/Tokyo",
|
||||
"sydney": "Australia/Sydney",
|
||||
"melbourne": "Australia/Melbourne",
|
||||
"auckland": "Pacific/Auckland",
|
||||
"toronto": "America/Toronto",
|
||||
"vancouver": "America/Vancouver",
|
||||
"mumbai": "Asia/Kolkata",
|
||||
"delhi": "Asia/Kolkata",
|
||||
"dubai": "Asia/Dubai",
|
||||
"singapore": "Asia/Singapore",
|
||||
"hong kong": "Asia/Hong_Kong",
|
||||
"seoul": "Asia/Seoul",
|
||||
"beijing": "Asia/Shanghai",
|
||||
"shanghai": "Asia/Shanghai",
|
||||
"moscow": "Europe/Moscow",
|
||||
"istanbul": "Europe/Istanbul",
|
||||
"cairo": "Africa/Cairo",
|
||||
"johannesburg": "Africa/Johannesburg",
|
||||
"sao paulo": "America/Sao_Paulo",
|
||||
"mexico city": "America/Mexico_City",
|
||||
"amsterdam": "Europe/Amsterdam",
|
||||
"rome": "Europe/Rome",
|
||||
"madrid": "Europe/Madrid",
|
||||
"lisbon": "Europe/Lisbon",
|
||||
"bangkok": "Asia/Bangkok",
|
||||
"jakarta": "Asia/Jakarta",
|
||||
"manila": "Asia/Manila",
|
||||
"kuala lumpur": "Asia/Kuala_Lumpur",
|
||||
"taipei": "Asia/Taipei",
|
||||
"oslo": "Europe/Oslo",
|
||||
"stockholm": "Europe/Stockholm",
|
||||
"helsinki": "Europe/Helsinki",
|
||||
"warsaw": "Europe/Warsaw",
|
||||
"prague": "Europe/Prague",
|
||||
"vienna": "Europe/Vienna",
|
||||
"zurich": "Europe/Zurich",
|
||||
"athens": "Europe/Athens",
|
||||
"honolulu": "Pacific/Honolulu",
|
||||
"anchorage": "America/Anchorage",
|
||||
"hawaii": "Pacific/Honolulu",
|
||||
};
|
||||
|
||||
/** Try to resolve a city name to an IANA timezone by checking Region/City patterns. */
|
||||
function guessIANAZone(city: string): string | null {
|
||||
const formatted = city
|
||||
.split(/[\s-]+/)
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
||||
.join("_");
|
||||
|
||||
const regions = ["Europe", "America", "Asia", "Africa", "Pacific", "Australia", "Atlantic", "Indian", "Arctic"];
|
||||
for (const region of regions) {
|
||||
const candidate = `${region}/${formatted}`;
|
||||
if (IANAZone.isValidZone(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export class UserPlugin extends Plugin {
|
||||
constructor(client: IMatrixClient) {
|
||||
super(client);
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "user";
|
||||
}
|
||||
|
||||
get commands(): CommandDef[] {
|
||||
return [
|
||||
{ name: "settz", description: "Set your timezone", usage: "!settz <city|IANA>" },
|
||||
{ name: "mytz", description: "Show your timezone" },
|
||||
{ name: "timezone list", description: "World clock for everyone in the room" },
|
||||
{ name: "quote", description: "Random starred quote", usage: "!quote [@user]" },
|
||||
{ name: "np", description: "Now playing", usage: "!np [game|@user|list]" },
|
||||
{ name: "backlog", description: "Game backlog", usage: "!backlog [add|list|random|done] [game]" },
|
||||
{ name: "watch", description: "Keyword DM alert", usage: "!watch <keyword>" },
|
||||
{ name: "watching", description: "List your keyword watches" },
|
||||
{ name: "unwatch", description: "Remove a keyword watch", usage: "!unwatch <keyword|id>" },
|
||||
];
|
||||
}
|
||||
|
||||
async onMessage(ctx: MessageContext): Promise<void> {
|
||||
// Passive: check keyword watches
|
||||
this.checkKeywordWatches(ctx);
|
||||
|
||||
if (this.isCommand(ctx.body, "timezone list")) {
|
||||
await this.handleTimezoneList(ctx);
|
||||
} else if (this.isCommand(ctx.body, "settz")) {
|
||||
await this.handleSetTz(ctx);
|
||||
} else if (this.isCommand(ctx.body, "mytz")) {
|
||||
await this.handleMyTz(ctx);
|
||||
} else if (this.isCommand(ctx.body, "quote")) {
|
||||
await this.handleQuote(ctx);
|
||||
} else if (this.isCommand(ctx.body, "np")) {
|
||||
await this.handleNp(ctx);
|
||||
} else if (this.isCommand(ctx.body, "backlog")) {
|
||||
await this.handleBacklog(ctx);
|
||||
} else if (this.isCommand(ctx.body, "unwatch")) {
|
||||
await this.handleUnwatch(ctx);
|
||||
} else if (this.isCommand(ctx.body, "watching")) {
|
||||
await this.handleWatching(ctx);
|
||||
} else if (this.isCommand(ctx.body, "watch")) {
|
||||
await this.handleWatch(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
async onReaction(ctx: ReactionContext): Promise<void> {
|
||||
// Star reactions save quotes
|
||||
if (ctx.reactionKey === "\u2B50" || ctx.reactionKey === "\u2B50\uFE0F") {
|
||||
await this.saveQuote(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
private checkKeywordWatches(ctx: MessageContext): void {
|
||||
const db = getDb();
|
||||
const watches = db
|
||||
.prepare(`SELECT user_id, keyword FROM keyword_watches WHERE room_id = ?`)
|
||||
.all(ctx.roomId) as { user_id: string; keyword: string }[];
|
||||
|
||||
const bodyLower = ctx.body.toLowerCase();
|
||||
|
||||
for (const watch of watches) {
|
||||
if (watch.user_id === ctx.sender) continue; // Don't alert on own messages
|
||||
if (bodyLower.includes(watch.keyword.toLowerCase())) {
|
||||
this.sendDm(
|
||||
watch.user_id,
|
||||
`Keyword alert "${watch.keyword}" triggered by ${ctx.sender} in ${ctx.roomId}:\n${ctx.body.slice(0, 200)}`
|
||||
).catch((err) => logger.error(`Failed keyword DM: ${err}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private resolveTimezone(input: string): string | null {
|
||||
const lower = input.toLowerCase().trim();
|
||||
|
||||
// Check city map first
|
||||
if (CITY_TIMEZONE_MAP[lower]) {
|
||||
return CITY_TIMEZONE_MAP[lower];
|
||||
}
|
||||
|
||||
// Try direct IANA validation
|
||||
if (IANAZone.isValidZone(input.trim())) {
|
||||
return input.trim();
|
||||
}
|
||||
|
||||
// Try guessing from IANA zone database (e.g., "Lisbon" → "Europe/Lisbon")
|
||||
return guessIANAZone(lower);
|
||||
}
|
||||
|
||||
private async handleTimezoneList(ctx: MessageContext): Promise<void> {
|
||||
const db = getDb();
|
||||
const rows = db
|
||||
.prepare(`SELECT user_id, timezone FROM users WHERE room_id = ? AND timezone IS NOT NULL`)
|
||||
.all(ctx.roomId) as { user_id: string; timezone: string }[];
|
||||
|
||||
if (rows.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "No one in this room has set a timezone yet. Use !settz to set yours.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Group users by timezone, sort by UTC offset
|
||||
const groups = new Map<string, string[]>();
|
||||
for (const row of rows) {
|
||||
const list = groups.get(row.timezone) ?? [];
|
||||
list.push(row.user_id);
|
||||
groups.set(row.timezone, list);
|
||||
}
|
||||
|
||||
const entries = [...groups.entries()].map(([tz, users]) => {
|
||||
const dt = DateTime.now().setZone(tz);
|
||||
return { tz, users, offset: dt.offset, time: dt.toFormat("HH:mm (EEE)") };
|
||||
}).sort((a, b) => a.offset - b.offset);
|
||||
|
||||
const lines = entries.map((e) => {
|
||||
const userList = e.users.join(", ");
|
||||
return `${e.time} — ${e.tz}\n ${userList}`;
|
||||
});
|
||||
|
||||
await this.sendMessage(ctx.roomId, `World Clock:\n\n${lines.join("\n\n")}`);
|
||||
}
|
||||
|
||||
private async handleSetTz(ctx: MessageContext): Promise<void> {
|
||||
const args = this.getArgs(ctx.body, "settz");
|
||||
if (!args) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !settz <city or IANA timezone>\nExamples: !settz Tokyo, !settz America/New_York");
|
||||
return;
|
||||
}
|
||||
|
||||
const tz = this.resolveTimezone(args);
|
||||
if (!tz) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `Unknown timezone "${args}". Try a city name or IANA zone (e.g., America/New_York).`);
|
||||
return;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
db.prepare(`
|
||||
INSERT INTO users (user_id, room_id, timezone)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id, room_id) DO UPDATE SET timezone = ?
|
||||
`).run(ctx.sender, ctx.roomId, tz, tz);
|
||||
|
||||
const now = DateTime.now().setZone(tz);
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `Timezone set to ${tz}. Your current time: ${now.toFormat("HH:mm (ZZZZZ)")}`);
|
||||
}
|
||||
|
||||
private async handleMyTz(ctx: MessageContext): Promise<void> {
|
||||
const db = getDb();
|
||||
const row = db
|
||||
.prepare(`SELECT timezone FROM users WHERE user_id = ? AND room_id = ?`)
|
||||
.get(ctx.sender, ctx.roomId) as { timezone: string | null } | undefined;
|
||||
|
||||
if (!row?.timezone) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "You haven't set a timezone yet. Use !settz <city or IANA zone>.");
|
||||
return;
|
||||
}
|
||||
|
||||
const now = DateTime.now().setZone(row.timezone);
|
||||
await this.sendMessage(ctx.roomId, `${ctx.sender}: ${row.timezone} — ${now.toFormat("HH:mm (ZZZZZ)")}`);
|
||||
}
|
||||
|
||||
private async saveQuote(ctx: ReactionContext): Promise<void> {
|
||||
try {
|
||||
const event = await this.client.getEvent(ctx.roomId, ctx.targetEventId);
|
||||
const body = event?.content?.body;
|
||||
if (!body) return;
|
||||
|
||||
const db = getDb();
|
||||
// Avoid duplicate quotes for the same event
|
||||
const existing = db
|
||||
.prepare(`SELECT id FROM quotes WHERE event_id = ? AND room_id = ?`)
|
||||
.get(ctx.targetEventId, ctx.roomId);
|
||||
if (existing) return;
|
||||
|
||||
db.prepare(`INSERT INTO quotes (user_id, room_id, message, quoted_by, event_id) VALUES (?, ?, ?, ?, ?)`).run(
|
||||
event.sender,
|
||||
ctx.roomId,
|
||||
body,
|
||||
ctx.sender,
|
||||
ctx.targetEventId
|
||||
);
|
||||
|
||||
logger.debug(`Quote saved from ${event.sender} by ${ctx.sender}`);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to save quote: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleQuote(ctx: MessageContext): Promise<void> {
|
||||
const args = this.getArgs(ctx.body, "quote");
|
||||
const db = getDb();
|
||||
|
||||
let row: { user_id: string; message: string } | undefined;
|
||||
if (args.startsWith("@")) {
|
||||
const targetUser = args.split(/\s/)[0];
|
||||
row = db
|
||||
.prepare(`SELECT user_id, message FROM quotes WHERE user_id = ? AND room_id = ? ORDER BY RANDOM() LIMIT 1`)
|
||||
.get(targetUser, ctx.roomId) as typeof row;
|
||||
} else {
|
||||
row = db
|
||||
.prepare(`SELECT user_id, message FROM quotes WHERE room_id = ? ORDER BY RANDOM() LIMIT 1`)
|
||||
.get(ctx.roomId) as typeof row;
|
||||
}
|
||||
|
||||
if (!row) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "No quotes found. Star a message with a reaction to save it!");
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sendMessage(ctx.roomId, `"${row.message}" — ${row.user_id}`);
|
||||
}
|
||||
|
||||
private async handleNp(ctx: MessageContext): Promise<void> {
|
||||
const args = this.getArgs(ctx.body, "np");
|
||||
const db = getDb();
|
||||
|
||||
if (!args) {
|
||||
// Show current now-playing
|
||||
const row = db
|
||||
.prepare(`SELECT game FROM now_playing WHERE user_id = ? AND room_id = ?`)
|
||||
.get(ctx.sender, ctx.roomId) as { game: string } | undefined;
|
||||
|
||||
if (!row) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "You're not playing anything. Use !np <game> to set it.");
|
||||
return;
|
||||
}
|
||||
await this.sendMessage(ctx.roomId, `${ctx.sender} is playing: ${row.game}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.startsWith("@")) {
|
||||
const targetUser = args.split(/\s/)[0];
|
||||
const row = db
|
||||
.prepare(`SELECT game FROM now_playing WHERE user_id = ? AND room_id = ?`)
|
||||
.get(targetUser, ctx.roomId) as { game: string } | undefined;
|
||||
|
||||
if (!row) {
|
||||
await this.sendMessage(ctx.roomId, `${targetUser} isn't playing anything.`);
|
||||
return;
|
||||
}
|
||||
await this.sendMessage(ctx.roomId, `${targetUser} is playing: ${row.game}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args === "list") {
|
||||
const rows = db
|
||||
.prepare(`SELECT user_id, game FROM now_playing WHERE room_id = ?`)
|
||||
.all(ctx.roomId) as { user_id: string; game: string }[];
|
||||
|
||||
if (rows.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Nobody is playing anything right now.");
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = rows.map((r) => `${r.user_id}: ${r.game}`);
|
||||
await this.sendMessage(ctx.roomId, `Now Playing:\n${lines.join("\n")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set now playing
|
||||
db.prepare(`
|
||||
INSERT INTO now_playing (user_id, room_id, game)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id, room_id) DO UPDATE SET game = ?, started_at = datetime('now')
|
||||
`).run(ctx.sender, ctx.roomId, args, args);
|
||||
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `Now playing: ${args}`);
|
||||
}
|
||||
|
||||
private async handleBacklog(ctx: MessageContext): Promise<void> {
|
||||
const args = this.getArgs(ctx.body, "backlog");
|
||||
const parts = args.split(/\s+/);
|
||||
const subcommand = parts[0]?.toLowerCase() || "list";
|
||||
const game = parts.slice(1).join(" ");
|
||||
const db = getDb();
|
||||
|
||||
switch (subcommand) {
|
||||
case "add": {
|
||||
if (!game) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !backlog add <game name>");
|
||||
return;
|
||||
}
|
||||
db.prepare(`
|
||||
INSERT OR IGNORE INTO backlog (user_id, room_id, game) VALUES (?, ?, ?)
|
||||
`).run(ctx.sender, ctx.roomId, game);
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `Added "${game}" to your backlog.`);
|
||||
break;
|
||||
}
|
||||
case "done": {
|
||||
if (!game) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !backlog done <game name>");
|
||||
return;
|
||||
}
|
||||
const result = db
|
||||
.prepare(`UPDATE backlog SET completed = 1, completed_at = datetime('now') WHERE user_id = ? AND room_id = ? AND game = ? AND completed = 0`)
|
||||
.run(ctx.sender, ctx.roomId, game);
|
||||
if (result.changes > 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `Marked "${game}" as completed!`);
|
||||
} else {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `"${game}" not found in your active backlog.`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "random": {
|
||||
const row = db
|
||||
.prepare(`SELECT game FROM backlog WHERE user_id = ? AND room_id = ? AND completed = 0 ORDER BY RANDOM() LIMIT 1`)
|
||||
.get(ctx.sender, ctx.roomId) as { game: string } | undefined;
|
||||
if (!row) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Your backlog is empty!");
|
||||
return;
|
||||
}
|
||||
await this.sendMessage(ctx.roomId, `Random pick from your backlog: ${row.game}`);
|
||||
break;
|
||||
}
|
||||
case "list":
|
||||
default: {
|
||||
const rows = db
|
||||
.prepare(`SELECT game, completed FROM backlog WHERE user_id = ? AND room_id = ? ORDER BY completed ASC, added_at DESC`)
|
||||
.all(ctx.sender, ctx.roomId) as { game: string; completed: number }[];
|
||||
|
||||
if (rows.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Your backlog is empty. Use !backlog add <game> to start.");
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = rows.map((r) => `${r.completed ? "[done]" : "[ ]"} ${r.game}`);
|
||||
await this.sendMessage(ctx.roomId, `${ctx.sender}'s backlog:\n${lines.join("\n")}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleWatch(ctx: MessageContext): Promise<void> {
|
||||
const keyword = this.getArgs(ctx.body, "watch").trim();
|
||||
if (!keyword) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !watch <keyword>");
|
||||
return;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
try {
|
||||
db.prepare(`INSERT INTO keyword_watches (user_id, room_id, keyword) VALUES (?, ?, ?)`).run(
|
||||
ctx.sender,
|
||||
ctx.roomId,
|
||||
keyword
|
||||
);
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `Watching for "${keyword}". You'll get a DM when someone mentions it.`);
|
||||
} catch {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `You're already watching for "${keyword}".`);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleWatching(ctx: MessageContext): Promise<void> {
|
||||
const db = getDb();
|
||||
const rows = db
|
||||
.prepare(`SELECT id, keyword FROM keyword_watches WHERE user_id = ? AND room_id = ?`)
|
||||
.all(ctx.sender, ctx.roomId) as { id: number; keyword: string }[];
|
||||
|
||||
if (rows.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "You're not watching any keywords. Use !watch <keyword> to start.");
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = rows.map((r) => `[${r.id}] ${r.keyword}`);
|
||||
await this.sendMessage(ctx.roomId, `Your keyword watches:\n${lines.join("\n")}`);
|
||||
}
|
||||
|
||||
private async handleUnwatch(ctx: MessageContext): Promise<void> {
|
||||
const args = this.getArgs(ctx.body, "unwatch").trim();
|
||||
if (!args) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !unwatch <keyword|id>");
|
||||
return;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
// Try by ID first
|
||||
const idNum = parseInt(args);
|
||||
let result;
|
||||
if (!isNaN(idNum)) {
|
||||
result = db
|
||||
.prepare(`DELETE FROM keyword_watches WHERE id = ? AND user_id = ? AND room_id = ?`)
|
||||
.run(idNum, ctx.sender, ctx.roomId);
|
||||
}
|
||||
|
||||
if (!result || result.changes === 0) {
|
||||
// Try by keyword
|
||||
result = db
|
||||
.prepare(`DELETE FROM keyword_watches WHERE keyword = ? AND user_id = ? AND room_id = ?`)
|
||||
.run(args, ctx.sender, ctx.roomId);
|
||||
}
|
||||
|
||||
if (result.changes > 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `Removed watch for "${args}".`);
|
||||
} else {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `No watch found matching "${args}".`);
|
||||
}
|
||||
}
|
||||
}
|
||||
86
src/plugins/welcome.ts
Normal file
86
src/plugins/welcome.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { IMatrixClient, Plugin, CommandDef, MessageContext, PluginRegistry } from "./base";
|
||||
import { XpPlugin } from "./xp";
|
||||
import { getDb } from "../db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
export class WelcomePlugin extends Plugin {
|
||||
private xpPlugin: XpPlugin;
|
||||
private registry: PluginRegistry;
|
||||
|
||||
constructor(client: IMatrixClient, xpPlugin: XpPlugin, registry: PluginRegistry) {
|
||||
super(client);
|
||||
this.xpPlugin = xpPlugin;
|
||||
this.registry = registry;
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "welcome";
|
||||
}
|
||||
|
||||
get commands(): CommandDef[] {
|
||||
return [{ name: "help", description: "Full command list (sent as DM)" }];
|
||||
}
|
||||
|
||||
async onMessage(ctx: MessageContext): Promise<void> {
|
||||
// Passive: welcome new users before command handling
|
||||
this.handleWelcome(ctx);
|
||||
|
||||
if (this.isCommand(ctx.body, "help")) {
|
||||
await this.handleHelp(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
private handleWelcome(ctx: MessageContext): void {
|
||||
const db = getDb();
|
||||
|
||||
// Check if we've already welcomed this user in ANY room
|
||||
const existing = db
|
||||
.prepare(`SELECT id FROM achievements WHERE user_id = ? AND achievement_key = 'welcome_wagon'`)
|
||||
.get(ctx.sender);
|
||||
|
||||
if (existing) return;
|
||||
|
||||
// New user — insert welcome_wagon achievement (tied to this room for the record)
|
||||
db.prepare(`INSERT OR IGNORE INTO achievements (user_id, room_id, achievement_key) VALUES (?, ?, ?)`).run(
|
||||
ctx.sender,
|
||||
ctx.roomId,
|
||||
"welcome_wagon"
|
||||
);
|
||||
|
||||
// Grant 25 XP
|
||||
this.xpPlugin.grantXp(ctx.sender, ctx.roomId, 25, "welcome");
|
||||
|
||||
// Post welcome message (fire-and-forget)
|
||||
const welcomeText =
|
||||
`Welcome to the community, ${ctx.sender}!\n\n` +
|
||||
`You've got 25 XP to start. Here's a taste of what you can do:\n` +
|
||||
`!rank !hltb <game> !remindme 1h <msg> !trivia !time <city>\n\n` +
|
||||
`Type !help for the full command list (sent as a DM). Have fun!`;
|
||||
|
||||
this.sendMessage(ctx.roomId, welcomeText).catch((err) => {
|
||||
logger.error(`Failed to send welcome message: ${err}`);
|
||||
});
|
||||
|
||||
logger.debug(`Welcomed new user ${ctx.sender} in ${ctx.roomId}`);
|
||||
}
|
||||
|
||||
private async handleHelp(ctx: MessageContext): Promise<void> {
|
||||
const allCommands = this.registry.getCommands();
|
||||
const isAdmin = this.isAdmin(ctx.sender);
|
||||
|
||||
const sections: string[] = [];
|
||||
|
||||
for (const group of allCommands) {
|
||||
const visibleCommands = group.commands.filter((cmd) => isAdmin || !cmd.adminOnly);
|
||||
if (visibleCommands.length === 0) continue;
|
||||
|
||||
const lines = visibleCommands.map((cmd) => ` !${cmd.name} — ${cmd.description}`);
|
||||
sections.push(`[${group.plugin}]\n${lines.join("\n")}`);
|
||||
}
|
||||
|
||||
const helpText = `Freebee Commands\n\n${sections.join("\n\n")}`;
|
||||
|
||||
await this.sendDm(ctx.sender, helpText);
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "Command list sent to your DMs!");
|
||||
}
|
||||
}
|
||||
190
src/plugins/wotd.ts
Normal file
190
src/plugins/wotd.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
||||
import { XpPlugin } from "./xp";
|
||||
import { getDb } from "../db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
const WOTD_BONUS_XP = 25;
|
||||
|
||||
export class WotdPlugin extends Plugin {
|
||||
private xpPlugin: XpPlugin;
|
||||
private todaysWord: string | null = null;
|
||||
|
||||
constructor(client: IMatrixClient, xpPlugin: XpPlugin) {
|
||||
super(client);
|
||||
this.xpPlugin = xpPlugin;
|
||||
this.loadTodaysWord();
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "wotd";
|
||||
}
|
||||
|
||||
get commands(): CommandDef[] {
|
||||
return [
|
||||
{ name: "wotd", description: "Show today's Word of the Day" },
|
||||
];
|
||||
}
|
||||
|
||||
private loadTodaysWord(): void {
|
||||
try {
|
||||
const db = getDb();
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
// Try today first, fall back to the most recent word
|
||||
let row = db
|
||||
.prepare(`SELECT word FROM wotd_log WHERE date = ? LIMIT 1`)
|
||||
.get(today) as { word: string } | undefined;
|
||||
if (!row) {
|
||||
row = db
|
||||
.prepare(`SELECT word FROM wotd_log ORDER BY date DESC LIMIT 1`)
|
||||
.get() as { word: string } | undefined;
|
||||
}
|
||||
this.todaysWord = row?.word?.toLowerCase() ?? null;
|
||||
} catch {
|
||||
this.todaysWord = null;
|
||||
}
|
||||
}
|
||||
|
||||
async onMessage(ctx: MessageContext): Promise<void> {
|
||||
if (this.isCommand(ctx.body, "wotd")) {
|
||||
await this.handleWotd(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
// Passive: check if message contains today's word
|
||||
if (this.todaysWord) {
|
||||
this.checkWordUsage(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleWotd(ctx: MessageContext): Promise<void> {
|
||||
const db = getDb();
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// Try today first, fall back to the most recent word if today's hasn't been posted yet
|
||||
let row = db
|
||||
.prepare(`SELECT word, definition, example, part_of_speech, date FROM wotd_log WHERE date = ? LIMIT 1`)
|
||||
.get(today) as { word: string; definition: string | null; example: string | null; part_of_speech: string | null; date: string } | undefined;
|
||||
|
||||
if (!row) {
|
||||
row = db
|
||||
.prepare(`SELECT word, definition, example, part_of_speech, date FROM wotd_log ORDER BY date DESC LIMIT 1`)
|
||||
.get() as typeof row;
|
||||
}
|
||||
|
||||
if (!row) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "No Word of the Day yet. It will be posted at the scheduled time.");
|
||||
return;
|
||||
}
|
||||
|
||||
const isToday = row.date === today;
|
||||
const lines = [
|
||||
isToday ? `Word of the Day: ${row.word}` : `Most recent Word of the Day: ${row.word} (${row.date})`,
|
||||
row.part_of_speech ? `(${row.part_of_speech})` : "",
|
||||
"",
|
||||
row.definition ?? "No definition available.",
|
||||
row.example ? `\nExample: "${row.example}"` : "",
|
||||
"",
|
||||
isToday
|
||||
? `Use "${row.word}" in a message today for ${WOTD_BONUS_XP} bonus XP!`
|
||||
: `Today's word hasn't been posted yet. Check back after the scheduled time.`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
await this.sendMessage(ctx.roomId, lines);
|
||||
}
|
||||
|
||||
private checkWordUsage(ctx: MessageContext): void {
|
||||
if (!this.todaysWord) return;
|
||||
|
||||
const bodyLower = ctx.body.toLowerCase();
|
||||
if (!bodyLower.includes(this.todaysWord)) return;
|
||||
|
||||
const db = getDb();
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// Check if already awarded today
|
||||
const existing = db
|
||||
.prepare(`SELECT id FROM wotd_usage WHERE user_id = ? AND room_id = ? AND date = ?`)
|
||||
.get(ctx.sender, ctx.roomId, today);
|
||||
if (existing) return;
|
||||
|
||||
// Award bonus XP
|
||||
db.prepare(`INSERT INTO wotd_usage (user_id, room_id, date, xp_awarded) VALUES (?, ?, ?, ?)`).run(
|
||||
ctx.sender,
|
||||
ctx.roomId,
|
||||
today,
|
||||
WOTD_BONUS_XP
|
||||
);
|
||||
|
||||
this.xpPlugin.grantXp(ctx.sender, ctx.roomId, WOTD_BONUS_XP, "wotd_usage");
|
||||
logger.debug(`${ctx.sender} used WOTD "${this.todaysWord}" — awarded ${WOTD_BONUS_XP} XP`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by DailyScheduler to post the word of the day.
|
||||
*/
|
||||
async postWotd(roomIds: string[]): Promise<void> {
|
||||
const apiKey = process.env.WORDNIK_API_KEY;
|
||||
if (!apiKey) {
|
||||
logger.warn("WORDNIK_API_KEY not set, skipping WOTD");
|
||||
return;
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const db = getDb();
|
||||
|
||||
// Check if already posted today (any room)
|
||||
const existing = db.prepare(`SELECT word FROM wotd_log WHERE date = ?`).get(today) as { word: string } | undefined;
|
||||
if (existing) {
|
||||
this.todaysWord = existing.word.toLowerCase();
|
||||
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 API returned ${res.status}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json() as any;
|
||||
const word = data.word;
|
||||
const definitions = data.definitions ?? [];
|
||||
const examples = data.examples ?? [];
|
||||
|
||||
const definition = definitions[0]?.text ?? "No definition available.";
|
||||
const partOfSpeech = definitions[0]?.partOfSpeech ?? "";
|
||||
const example = examples[0]?.text ?? "";
|
||||
|
||||
this.todaysWord = word.toLowerCase();
|
||||
|
||||
const message = [
|
||||
`Word of the Day: ${word}`,
|
||||
partOfSpeech ? `(${partOfSpeech})` : "",
|
||||
"",
|
||||
definition,
|
||||
example ? `\nExample: "${example}"` : "",
|
||||
"",
|
||||
`Use "${word}" in a message today for ${WOTD_BONUS_XP} bonus XP!`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
for (const roomId of roomIds) {
|
||||
try {
|
||||
await this.sendMessage(roomId, message);
|
||||
db.prepare(`
|
||||
INSERT OR IGNORE INTO wotd_log (room_id, date, word, definition, example, part_of_speech)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(roomId, today, word, definition, example || null, partOfSpeech || null);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to post WOTD to ${roomId}: ${err}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`WOTD fetch failed: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
130
src/plugins/xp.ts
Normal file
130
src/plugins/xp.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
||||
import { getDb } from "../db";
|
||||
import { xpToLevel, xpForNextLevel, progressBar } from "../utils/parser";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
const XP_PER_MESSAGE = 10;
|
||||
const XP_COOLDOWN_MS = 30_000;
|
||||
|
||||
export class XpPlugin extends Plugin {
|
||||
private cooldowns = new Map<string, number>();
|
||||
|
||||
constructor(client: IMatrixClient) {
|
||||
super(client);
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "xp";
|
||||
}
|
||||
|
||||
get commands(): CommandDef[] {
|
||||
return [
|
||||
{ name: "rank", description: "Show your XP, level, and progress", usage: "!rank [@user]" },
|
||||
{ name: "leaderboard", description: "Top users by XP", usage: "!leaderboard [n]" },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Public method so other plugins (rep, wotd) can grant bonus XP.
|
||||
*/
|
||||
grantXp(userId: string, roomId: string, amount: number, reason: string): void {
|
||||
const db = getDb();
|
||||
db.prepare(`
|
||||
INSERT INTO users (user_id, room_id, xp, level)
|
||||
VALUES (?, ?, ?, 1)
|
||||
ON CONFLICT(user_id, room_id) DO UPDATE SET
|
||||
xp = xp + ?,
|
||||
level = ?
|
||||
`).run(
|
||||
userId,
|
||||
roomId,
|
||||
amount,
|
||||
amount,
|
||||
// We recalculate level from the new XP total via a subquery-style approach.
|
||||
// Since better-sqlite3 is sync, we do a two-step:
|
||||
0 // placeholder — we fix level right after
|
||||
);
|
||||
|
||||
// Fix level based on actual XP
|
||||
const row = db.prepare(`SELECT xp FROM users WHERE user_id = ? AND room_id = ?`).get(userId, roomId) as
|
||||
| { xp: number }
|
||||
| undefined;
|
||||
if (row) {
|
||||
const newLevel = xpToLevel(row.xp);
|
||||
db.prepare(`UPDATE users SET level = ? WHERE user_id = ? AND room_id = ?`).run(newLevel, userId, roomId);
|
||||
}
|
||||
|
||||
db.prepare(`INSERT INTO xp_log (user_id, room_id, amount, reason) VALUES (?, ?, ?, ?)`).run(
|
||||
userId,
|
||||
roomId,
|
||||
amount,
|
||||
reason
|
||||
);
|
||||
|
||||
logger.debug(`Granted ${amount} XP to ${userId} in ${roomId}: ${reason}`);
|
||||
}
|
||||
|
||||
async onMessage(ctx: MessageContext): Promise<void> {
|
||||
// Passive XP grant with cooldown
|
||||
this.handlePassiveXp(ctx.sender, ctx.roomId);
|
||||
|
||||
// Commands
|
||||
if (this.isCommand(ctx.body, "rank")) {
|
||||
await this.handleRank(ctx);
|
||||
} else if (this.isCommand(ctx.body, "leaderboard")) {
|
||||
await this.handleLeaderboard(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
private handlePassiveXp(userId: string, roomId: string): void {
|
||||
const key = `${userId}:${roomId}`;
|
||||
const now = Date.now();
|
||||
const lastGrant = this.cooldowns.get(key) ?? 0;
|
||||
|
||||
if (now - lastGrant < XP_COOLDOWN_MS) return;
|
||||
|
||||
this.cooldowns.set(key, now);
|
||||
this.grantXp(userId, roomId, XP_PER_MESSAGE, "message");
|
||||
}
|
||||
|
||||
private async handleRank(ctx: MessageContext): Promise<void> {
|
||||
const args = this.getArgs(ctx.body, "rank");
|
||||
const targetUser = args.startsWith("@") ? args.split(/\s/)[0] : ctx.sender;
|
||||
|
||||
const db = getDb();
|
||||
const row = db
|
||||
.prepare(`SELECT xp, level, rep FROM users WHERE user_id = ? AND room_id = ?`)
|
||||
.get(targetUser, ctx.roomId) as { xp: number; level: number; rep: number } | undefined;
|
||||
|
||||
if (!row) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, `No data found for ${targetUser}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const progress = xpForNextLevel(row.xp);
|
||||
const bar = progressBar(progress.current, progress.needed);
|
||||
|
||||
await this.sendMessage(
|
||||
ctx.roomId,
|
||||
`${targetUser}\nLevel ${row.level} | ${row.xp} XP | ${row.rep} rep\n${bar} ${progress.current}/${progress.needed} to next level`
|
||||
);
|
||||
}
|
||||
|
||||
private async handleLeaderboard(ctx: MessageContext): Promise<void> {
|
||||
const args = this.getArgs(ctx.body, "leaderboard");
|
||||
const limit = Math.min(Math.max(parseInt(args) || 10, 1), 25);
|
||||
|
||||
const db = getDb();
|
||||
const rows = db
|
||||
.prepare(`SELECT user_id, xp, level FROM users WHERE room_id = ? ORDER BY xp DESC LIMIT ?`)
|
||||
.all(ctx.roomId, limit) as { user_id: string; xp: number; level: number }[];
|
||||
|
||||
if (rows.length === 0) {
|
||||
await this.sendReply(ctx.roomId, ctx.eventId, "No leaderboard data yet.");
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = rows.map((r, i) => `${i + 1}. ${r.user_id} — Lv${r.level} (${r.xp} XP)`);
|
||||
await this.sendMessage(ctx.roomId, `Leaderboard (Top ${rows.length}):\n${lines.join("\n")}`);
|
||||
}
|
||||
}
|
||||
58
src/utils/auth.ts
Normal file
58
src/utils/auth.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import logger from "./logger";
|
||||
|
||||
interface LoginResponse {
|
||||
access_token: string;
|
||||
device_id: string;
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log in to the homeserver with a password and return a fresh access token.
|
||||
* Reuses the existing device_id when provided so the crypto store stays valid.
|
||||
*/
|
||||
export async function loginWithPassword(
|
||||
homeserverUrl: string,
|
||||
userId: string,
|
||||
password: string,
|
||||
deviceId?: string
|
||||
): Promise<LoginResponse> {
|
||||
const localpart = userId.split(":")[0].replace(/^@/, "");
|
||||
|
||||
const body: Record<string, string> = {
|
||||
type: "m.login.password",
|
||||
user: localpart,
|
||||
password,
|
||||
};
|
||||
if (deviceId) {
|
||||
body.device_id = deviceId;
|
||||
}
|
||||
|
||||
const res = await fetch(`${homeserverUrl}/_matrix/client/v3/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Login failed (${res.status}): ${text}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as LoginResponse;
|
||||
logger.info(`Obtained new access token for device ${data.device_id}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the current access token is still valid.
|
||||
*/
|
||||
export async function isTokenValid(homeserverUrl: string, accessToken: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${homeserverUrl}/_matrix/client/v3/account/whoami`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
24
src/utils/logger.ts
Normal file
24
src/utils/logger.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import winston from "winston";
|
||||
|
||||
const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL ?? "info",
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.printf(({ timestamp, level, message, stack }) => {
|
||||
const msg = `${timestamp} [${level.toUpperCase()}] ${message}`;
|
||||
return stack ? `${msg}\n${stack}` : msg;
|
||||
})
|
||||
),
|
||||
transports: [
|
||||
new winston.transports.Console(),
|
||||
new winston.transports.File({
|
||||
filename: `${process.env.DATA_DIR ?? "./data"}/freebee.log`,
|
||||
maxsize: 5 * 1024 * 1024,
|
||||
maxFiles: 3,
|
||||
tailable: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
export default logger;
|
||||
125
src/utils/parser.ts
Normal file
125
src/utils/parser.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
export interface ParsedMessage {
|
||||
wordCount: number;
|
||||
charCount: number;
|
||||
linkCount: number;
|
||||
imageCount: number;
|
||||
questionCount: number;
|
||||
exclamationCount: number;
|
||||
emojiCount: number;
|
||||
longestWord: string;
|
||||
hasLongWord: boolean; // word > 15 characters
|
||||
}
|
||||
|
||||
const URL_REGEX = /https?:\/\/[^\s]+/gi;
|
||||
const EMOJI_REGEX = /[\p{Emoji_Presentation}\p{Extended_Pictographic}]/gu;
|
||||
const IMAGE_EXTENSIONS = /\.(png|jpg|jpeg|gif|webp|svg|bmp)(\?[^\s]*)?$/i;
|
||||
|
||||
export function parseMessage(body: string): ParsedMessage {
|
||||
const words = body.split(/\s+/).filter((w) => w.length > 0);
|
||||
const links = body.match(URL_REGEX) ?? [];
|
||||
const emojis = body.match(EMOJI_REGEX) ?? [];
|
||||
const questions = (body.match(/\?/g) ?? []).length;
|
||||
const exclamations = (body.match(/!/g) ?? []).length;
|
||||
const images = links.filter((l) => IMAGE_EXTENSIONS.test(l)).length;
|
||||
|
||||
let longestWord = "";
|
||||
for (const word of words) {
|
||||
const cleaned = word.replace(/[^\w]/g, "");
|
||||
if (cleaned.length > longestWord.length) {
|
||||
longestWord = cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
wordCount: words.length,
|
||||
charCount: body.length,
|
||||
linkCount: links.length,
|
||||
imageCount: images,
|
||||
questionCount: questions,
|
||||
exclamationCount: exclamations,
|
||||
emojiCount: emojis.length,
|
||||
longestWord,
|
||||
hasLongWord: longestWord.length > 15,
|
||||
};
|
||||
}
|
||||
|
||||
// XP curve: level N requires N*100 total XP
|
||||
export function levelToXp(level: number): number {
|
||||
return level * 100;
|
||||
}
|
||||
|
||||
export function xpToLevel(xp: number): number {
|
||||
return Math.max(1, Math.floor(xp / 100));
|
||||
}
|
||||
|
||||
export function xpForNextLevel(xp: number): { current: number; needed: number } {
|
||||
const currentLevel = xpToLevel(xp);
|
||||
const nextLevelXp = levelToXp(currentLevel + 1);
|
||||
const currentLevelXp = levelToXp(currentLevel);
|
||||
return {
|
||||
current: xp - currentLevelXp,
|
||||
needed: nextLevelXp - currentLevelXp,
|
||||
};
|
||||
}
|
||||
|
||||
export function progressBar(current: number, total: number, length: number = 10): string {
|
||||
const filled = Math.round((current / total) * length);
|
||||
const empty = length - filled;
|
||||
return "[" + "\u2588".repeat(filled) + "\u2591".repeat(empty) + "]";
|
||||
}
|
||||
|
||||
const ARCHETYPES: { key: string; label: string; check: (s: ArchetypeStats) => boolean }[] = [
|
||||
{ key: "chatterbox", label: "The Chatterbox", check: (s) => s.totalMessages > 1000 && s.avgWords < 10 },
|
||||
{ key: "novelist", label: "The Novelist", check: (s) => s.avgWords > 40 },
|
||||
{ key: "inquisitor", label: "The Inquisitor", check: (s) => s.questionRatio > 0.3 },
|
||||
{ key: "linkmaster", label: "The Linkmaster", check: (s) => s.linkRatio > 0.2 },
|
||||
{ key: "shutterbug", label: "The Shutterbug", check: (s) => s.imageRatio > 0.15 },
|
||||
{ key: "night_owl", label: "The Night Owl", check: (s) => s.nightRatio > 0.4 },
|
||||
{ key: "early_bird", label: "The Early Bird", check: (s) => s.morningRatio > 0.4 },
|
||||
{ key: "enthusiast", label: "The Enthusiast", check: (s) => s.exclamationRatio > 0.3 },
|
||||
{ key: "regular", label: "The Regular", check: () => true },
|
||||
];
|
||||
|
||||
interface ArchetypeStats {
|
||||
totalMessages: number;
|
||||
avgWords: number;
|
||||
questionRatio: number;
|
||||
linkRatio: number;
|
||||
imageRatio: number;
|
||||
nightRatio: number;
|
||||
morningRatio: number;
|
||||
exclamationRatio: number;
|
||||
}
|
||||
|
||||
export function deriveArchetype(stats: {
|
||||
total_messages: number;
|
||||
avg_words_per_message: number;
|
||||
total_questions: number;
|
||||
total_links: number;
|
||||
total_images: number;
|
||||
total_exclamations: number;
|
||||
hourly_distribution: string;
|
||||
}): string {
|
||||
const hourly: Record<string, number> = JSON.parse(stats.hourly_distribution || "{}");
|
||||
const totalFromHourly = Object.values(hourly).reduce((a, b) => a + b, 0) || 1;
|
||||
|
||||
const nightMessages = [0, 1, 2, 3].reduce((sum, h) => sum + (hourly[h] ?? 0), 0);
|
||||
const morningMessages = [5, 6, 7, 8, 9].reduce((sum, h) => sum + (hourly[h] ?? 0), 0);
|
||||
|
||||
const s: ArchetypeStats = {
|
||||
totalMessages: stats.total_messages || 1,
|
||||
avgWords: stats.avg_words_per_message,
|
||||
questionRatio: stats.total_questions / (stats.total_messages || 1),
|
||||
linkRatio: stats.total_links / (stats.total_messages || 1),
|
||||
imageRatio: stats.total_images / (stats.total_messages || 1),
|
||||
nightRatio: nightMessages / totalFromHourly,
|
||||
morningRatio: morningMessages / totalFromHourly,
|
||||
exclamationRatio: stats.total_exclamations / (stats.total_messages || 1),
|
||||
};
|
||||
|
||||
for (const archetype of ARCHETYPES) {
|
||||
if (archetype.check(s)) return archetype.label;
|
||||
}
|
||||
|
||||
return "The Regular";
|
||||
}
|
||||
36
src/workers/llm-classifier.ts
Normal file
36
src/workers/llm-classifier.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* LLM Classifier Worker — STUB
|
||||
*
|
||||
* This worker thread will handle shade classification via Ollama when the feature ships.
|
||||
*
|
||||
* Design notes:
|
||||
* - Runs in a separate Worker thread to avoid blocking the main event loop
|
||||
* - Communicates via MessageChannel (parentPort)
|
||||
* - Receives: { type: "classify", id: string, text: string }
|
||||
* - Returns: { type: "result", id: string, isShade: boolean, confidence: number }
|
||||
* - Uses the model specified by OLLAMA_MODEL env var
|
||||
* - Rate limited to prevent overwhelming the LLM host
|
||||
*
|
||||
* When wiring this up:
|
||||
* 1. ShadePlugin creates a Worker pointing to this file
|
||||
* 2. On each message, ShadePlugin posts a classify job
|
||||
* 3. Worker calls Ollama API and returns the classification
|
||||
* 4. ShadePlugin writes results to shade_log table
|
||||
*/
|
||||
|
||||
// import { parentPort } from "worker_threads";
|
||||
// import { OLLAMA_HOST, OLLAMA_MODEL } from env
|
||||
|
||||
// parentPort?.on("message", async (msg) => {
|
||||
// if (msg.type === "classify") {
|
||||
// const result = await classifyWithOllama(msg.text);
|
||||
// parentPort?.postMessage({
|
||||
// type: "result",
|
||||
// id: msg.id,
|
||||
// isShade: result.isShade,
|
||||
// confidence: result.confidence,
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
|
||||
export {};
|
||||
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "data"]
|
||||
}
|
||||
Reference in New Issue
Block a user