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:
prosolis
2026-03-07 22:48:35 -08:00
commit 69be69e3b0
47 changed files with 13158 additions and 0 deletions

363
src/index.ts Normal file
View 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);
});