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

429
src/plugins/anime.ts Normal file
View 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}`);
}
}
}
}
}