Lumi/plugins/quotes/index.js
2026-05-30 20:37:42 +02:00

795 lines
23 KiB
JavaScript

const path = require("path");
const PLUGIN_ID = "quotes";
let cachedAppToken = null;
let cachedAppTokenExpiry = 0;
module.exports = {
id: PLUGIN_ID,
init({ web, db, settings, commandRouter }) {
ensureTables(db);
registerQuoteCommands({ db, settings, commandRouter });
const router = web.createRouter();
router.get("/", (req, res) => {
const user = req.session.user || null;
const isMod = Boolean(user?.isAdmin || user?.isMod);
if (!isMod) {
return res.status(403).render("error", {
title: "Access denied",
message: "You do not have access to that page."
});
}
const quotes = listQuotes(db, { includeHidden: true, includeArchived: true });
const editId = parseInt(req.query.edit, 10);
const editingQuote = Number.isFinite(editId)
? getQuoteById(db, editId, { includeHidden: true, includeArchived: true })
: null;
res.render(path.join(__dirname, "views", "quotes.ejs"), {
title: "Quotes",
quotes,
editingQuote,
formatDateTime,
formatDateInput
});
});
router.post("/quotes/create", (req, res) => {
const user = req.session.user || null;
const isMod = Boolean(user?.isAdmin || user?.isMod);
if (!isMod) {
return res.status(403).render("error", {
title: "Access denied",
message: "You do not have access to that page."
});
}
const quoteText = (req.body.quote_text || "").trim();
if (!quoteText) {
req.session.flash = { type: "error", message: "Quote text is required." };
return res.redirect(`/plugins/${PLUGIN_ID}`);
}
const quoteDatetime = parseDateInput(req.body.quote_datetime) || Date.now();
const quoter = (req.body.quoter || user?.username || "Unknown").trim();
const quoterUserId = resolveUserIdByUsername(db, quoter);
const gameName = (req.body.game_name || "").trim();
const hidden = req.body.hidden === "on";
const archived = req.body.archived === "on";
const now = Date.now();
addQuote(db, {
quoteText,
quoter: quoter || "Unknown",
quoterUserId,
gameName: gameName || null,
quoteDatetime,
editedBy: user?.username || "system",
editedLast: now,
hidden,
archived
});
req.session.flash = { type: "success", message: "Quote added." };
res.redirect(`/plugins/${PLUGIN_ID}`);
});
router.post("/quotes/:id/update", (req, res) => {
const user = req.session.user || null;
const isMod = Boolean(user?.isAdmin || user?.isMod);
if (!isMod) {
return res.status(403).render("error", {
title: "Access denied",
message: "You do not have access to that page."
});
}
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id)) {
req.session.flash = { type: "error", message: "Invalid quote id." };
return res.redirect(`/plugins/${PLUGIN_ID}`);
}
const quoteText = (req.body.quote_text || "").trim();
if (!quoteText) {
req.session.flash = { type: "error", message: "Quote text is required." };
return res.redirect(`/plugins/${PLUGIN_ID}?edit=${id}`);
}
const quoteDatetime = parseDateInput(req.body.quote_datetime);
const quoter = (req.body.quoter || "").trim();
const quoterUserId = resolveUserIdByUsername(db, quoter);
const gameName = (req.body.game_name || "").trim();
const hidden = req.body.hidden === "on";
const archived = req.body.archived === "on";
updateQuote(db, id, {
quoteText,
quoter: quoter || "Unknown",
quoterUserId,
gameName: gameName || null,
quoteDatetime,
hidden,
archived,
editedBy: user?.username || "system",
editedLast: Date.now()
});
req.session.flash = { type: "success", message: "Quote updated." };
res.redirect(`/plugins/${PLUGIN_ID}`);
});
router.post("/quotes/:id/hide", (req, res) => {
const user = req.session.user || null;
const isMod = Boolean(user?.isAdmin || user?.isMod);
if (!isMod) {
return res.status(403).render("error", {
title: "Access denied",
message: "You do not have access to that page."
});
}
setQuoteHidden(db, req.params.id, true, user?.username || "system");
res.redirect(`/plugins/${PLUGIN_ID}`);
});
router.post("/quotes/:id/unhide", (req, res) => {
const user = req.session.user || null;
const isMod = Boolean(user?.isAdmin || user?.isMod);
if (!isMod) {
return res.status(403).render("error", {
title: "Access denied",
message: "You do not have access to that page."
});
}
setQuoteHidden(db, req.params.id, false, user?.username || "system");
res.redirect(`/plugins/${PLUGIN_ID}`);
});
router.post("/quotes/:id/archive", (req, res) => {
const user = req.session.user || null;
const isMod = Boolean(user?.isAdmin || user?.isMod);
if (!isMod) {
return res.status(403).render("error", {
title: "Access denied",
message: "You do not have access to that page."
});
}
setQuoteArchived(db, req.params.id, true, user?.username || "system");
res.redirect(`/plugins/${PLUGIN_ID}`);
});
router.post("/quotes/:id/restore", (req, res) => {
const user = req.session.user || null;
const isMod = Boolean(user?.isAdmin || user?.isMod);
if (!isMod) {
return res.status(403).render("error", {
title: "Access denied",
message: "You do not have access to that page."
});
}
setQuoteArchived(db, req.params.id, false, user?.username || "system");
res.redirect(`/plugins/${PLUGIN_ID}`);
});
router.get("/api/quotes", (req, res) => {
const user = req.session.user || null;
const isMod = Boolean(user?.isAdmin || user?.isMod);
if (!isMod) {
return res.status(403).json({ error: "Access denied." });
}
const quotes = listQuotes(db, { includeHidden: true, includeArchived: false });
res.json({ quotes });
});
router.get("/api/quotes/:id", (req, res) => {
const user = req.session.user || null;
const isMod = Boolean(user?.isAdmin || user?.isMod);
if (!isMod) {
return res.status(403).json({ error: "Access denied." });
}
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id)) {
return res.status(400).json({ error: "Invalid quote id." });
}
const quote = getQuoteById(db, id, { includeHidden: true, includeArchived: false });
if (!quote) {
return res.status(404).json({ error: "Quote not found." });
}
res.json({ quote });
});
web.mount(`/plugins/${PLUGIN_ID}`, router, {
label: "Quotes",
role: "mod",
section: "plugins"
});
}
};
function ensureTables(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS quotes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
quote_text TEXT NOT NULL,
quoter TEXT NOT NULL,
quoter_user_id TEXT,
game_name TEXT,
quote_datetime INTEGER NOT NULL,
edited_by TEXT,
edited_last INTEGER,
hidden INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS quotes_archived_idx ON quotes (archived);
CREATE INDEX IF NOT EXISTS quotes_hidden_idx ON quotes (hidden);
CREATE INDEX IF NOT EXISTS quotes_quote_datetime_idx ON quotes (quote_datetime);
`);
const columns = db
.prepare("PRAGMA table_info(quotes)")
.all()
.map((column) => column.name);
if (!columns.includes("quoter_user_id")) {
db.exec("ALTER TABLE quotes ADD COLUMN quoter_user_id TEXT");
}
db.exec("CREATE INDEX IF NOT EXISTS quotes_quoter_user_id_idx ON quotes (quoter_user_id)");
try {
db.prepare(
"UPDATE quotes SET quoter_user_id = (" +
"SELECT id FROM user_profiles WHERE internal_username = quotes.quoter COLLATE NOCASE LIMIT 1" +
") WHERE quoter_user_id IS NULL"
).run();
} catch {
// ignore backfill errors
}
}
function registerQuoteCommands({ db, settings, commandRouter }) {
if (!commandRouter) {
return null;
}
const platforms = ["discord", "twitch", "youtube"];
commandRouter.registerCommands(PLUGIN_ID, [
{
id: "quote",
triggers: ["quote"],
platforms,
handler: async (ctx) => await handleQuoteCommand({ ctx, db, settings })
}
]);
return null;
}
async function handleQuoteCommand({ ctx, db, settings }) {
const prefix = settings.getSetting("command_prefix", "!");
const subcommand = (ctx.args[0] || "").toLowerCase();
const role = getRoleFlags(ctx, settings);
if (!subcommand) {
await ctx.reply(
`Usage: ${prefix}quote <id|random|search|add|remove> | ${prefix}quote random`
);
return true;
}
if (subcommand === "add") {
if (!role.isAdmin && !role.isMod) {
await ctx.reply("You do not have permission to add quotes.");
return true;
}
const quoteText = ctx.args.slice(1).join(" ").trim();
if (!quoteText) {
await ctx.reply(`Usage: ${prefix}quote add <quote text>`);
return true;
}
const gameName = await resolveGameName(ctx, settings);
const now = Date.now();
const quoter = ctx.user.displayName || ctx.user.username || "Unknown";
const editor = ctx.user.username || quoter;
const id = addQuote(db, {
quoteText,
quoter,
quoterUserId: ctx.user.id,
gameName,
quoteDatetime: now,
editedBy: editor,
editedLast: now,
hidden: false,
archived: false
});
await ctx.reply(`Quote #${id} added.`);
return true;
}
if (subcommand === "search") {
const searchText = ctx.args.slice(1).join(" ").trim();
if (!searchText) {
await ctx.reply(`Usage: ${prefix}quote search <text>`);
return true;
}
const match = searchQuotes(db, searchText);
if (!match) {
await ctx.reply("No matching quotes found.");
return true;
}
await replyWithQuote(ctx, match);
return true;
}
if (subcommand === "remove" || subcommand === "delete") {
if (!role.isAdmin && !role.isMod) {
await ctx.reply("You do not have permission to remove quotes.");
return true;
}
const id = parseInt(ctx.args[1], 10);
if (!Number.isFinite(id)) {
await ctx.reply(`Usage: ${prefix}quote remove <id>`);
return true;
}
const removed = setQuoteArchived(db, id, true, ctx.user.username);
if (!removed) {
await ctx.reply("Quote not found.");
return true;
}
await ctx.reply(`Quote #${id} archived.`);
return true;
}
if (subcommand === "random") {
const quote = getRandomQuote(db);
if (!quote) {
await ctx.reply("No quotes available yet.");
return true;
}
await replyWithQuote(ctx, quote);
return true;
}
if (/^\d+$/.test(subcommand)) {
const id = parseInt(subcommand, 10);
const quote = getQuoteById(db, id, { includeHidden: false, includeArchived: false });
if (!quote) {
await ctx.reply("Quote not found.");
return true;
}
await replyWithQuote(ctx, quote);
return true;
}
await ctx.reply(
`Usage: ${prefix}quote <id|random|search|add|remove> | ${prefix}quote random`
);
return true;
}
function getRoleFlags(ctx, settings) {
if (ctx.platform === "discord") {
const roles = ctx.meta?.message?.member?.roles?.cache;
if (!roles) {
return { isAdmin: false, isMod: false };
}
const adminIds = parseList(settings.getSetting("discord_admin_role_id"));
const modIds = parseList(settings.getSetting("discord_mod_role_id"));
const roleIds = Array.from(roles.keys());
const isAdmin = roleIds.some((roleId) => adminIds.includes(roleId));
const isMod = roleIds.some((roleId) => modIds.includes(roleId));
return { isAdmin, isMod };
}
if (ctx.platform === "twitch") {
const badges = ctx.meta?.tags?.badges || {};
const isAdmin = Boolean(badges.broadcaster);
const isMod = Boolean(ctx.meta?.tags?.mod || badges.moderator);
return { isAdmin, isMod };
}
if (ctx.platform === "youtube") {
const author = ctx.meta?.author || {};
const isAdmin = Boolean(author.isChatOwner);
const isMod = Boolean(author.isChatModerator);
return { isAdmin, isMod };
}
return { isAdmin: false, isMod: false };
}
function parseList(value) {
return (value || "")
.toString()
.split(/[,\s]+/)
.map((item) => item.trim())
.filter(Boolean);
}
function resolveUserIdByUsername(db, username) {
const desired = (username || "").trim();
if (!desired) {
return null;
}
const row = db
.prepare("SELECT id FROM user_profiles WHERE internal_username = ? LIMIT 1")
.get(desired);
return row?.id || null;
}
function listQuotes(db, { includeHidden = true, includeArchived = true } = {}) {
const where = [];
if (!includeHidden) {
where.push("hidden = 0");
}
if (!includeArchived) {
where.push("archived = 0");
}
const clause = where.length ? `WHERE ${where.join(" AND ")}` : "";
return db
.prepare(
"SELECT id, quote_text, quoter, quoter_user_id, game_name, quote_datetime, edited_by, edited_last, hidden, archived " +
`FROM quotes ${clause} ORDER BY quote_datetime DESC, id DESC`
)
.all()
.map(normalizeQuoteRow);
}
function getQuoteById(db, id, { includeHidden, includeArchived }) {
const where = ["id = ?"];
const params = [id];
if (!includeHidden) {
where.push("hidden = 0");
}
if (!includeArchived) {
where.push("archived = 0");
}
const row = db
.prepare(
`SELECT id, quote_text, quoter, quoter_user_id, game_name, quote_datetime, edited_by, edited_last, hidden, archived FROM quotes WHERE ${where.join(
" AND "
)} LIMIT 1`
)
.get(...params);
return row ? normalizeQuoteRow(row) : null;
}
function getRandomQuote(db) {
const row = db
.prepare(
"SELECT id, quote_text, quoter, quoter_user_id, game_name, quote_datetime, edited_by, edited_last, hidden, archived " +
"FROM quotes WHERE hidden = 0 AND archived = 0 ORDER BY RANDOM() LIMIT 1"
)
.get();
return row ? normalizeQuoteRow(row) : null;
}
function addQuote(db, {
quoteText,
quoter,
quoterUserId,
gameName,
quoteDatetime,
editedBy,
editedLast,
hidden,
archived
}) {
const result = db
.prepare(
"INSERT INTO quotes (quote_text, quoter, quoter_user_id, game_name, quote_datetime, edited_by, edited_last, hidden, archived) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
)
.run(
quoteText,
quoter,
quoterUserId || null,
gameName || null,
quoteDatetime || Date.now(),
editedBy || null,
editedLast || null,
hidden ? 1 : 0,
archived ? 1 : 0
);
return result.lastInsertRowid;
}
function updateQuote(db, id, {
quoteText,
quoter,
quoterUserId,
gameName,
quoteDatetime,
hidden,
archived,
editedBy,
editedLast
}) {
let effectiveDatetime = quoteDatetime;
let effectiveQuoterUserId = quoterUserId;
if (!effectiveDatetime) {
const existing = db
.prepare("SELECT quote_datetime, quoter_user_id FROM quotes WHERE id = ?")
.get(id);
effectiveDatetime = existing?.quote_datetime || Date.now();
if (effectiveQuoterUserId === undefined) {
effectiveQuoterUserId = existing?.quoter_user_id || null;
}
} else if (effectiveQuoterUserId === undefined) {
const existing = db
.prepare("SELECT quoter_user_id FROM quotes WHERE id = ?")
.get(id);
effectiveQuoterUserId = existing?.quoter_user_id || null;
}
const updates = [
quoteText,
quoter,
effectiveQuoterUserId || null,
gameName || null,
effectiveDatetime,
editedBy || null,
editedLast || null,
hidden ? 1 : 0,
archived ? 1 : 0,
id
];
db.prepare(
"UPDATE quotes SET quote_text = ?, quoter = ?, quoter_user_id = ?, game_name = ?, quote_datetime = ?, edited_by = ?, edited_last = ?, hidden = ?, archived = ? WHERE id = ?"
).run(...updates);
}
function setQuoteHidden(db, id, hidden, editor) {
const parsed = parseInt(id, 10);
if (!Number.isFinite(parsed)) {
return false;
}
const result = db
.prepare(
"UPDATE quotes SET hidden = ?, edited_by = ?, edited_last = ? WHERE id = ?"
)
.run(hidden ? 1 : 0, editor || null, Date.now(), parsed);
return result.changes > 0;
}
function setQuoteArchived(db, id, archived, editor) {
const parsed = parseInt(id, 10);
if (!Number.isFinite(parsed)) {
return false;
}
const result = db
.prepare(
"UPDATE quotes SET archived = ?, edited_by = ?, edited_last = ? WHERE id = ?"
)
.run(archived ? 1 : 0, editor || null, Date.now(), parsed);
return result.changes > 0;
}
function searchQuotes(db, searchText, { includeHidden = false } = {}) {
const term = (searchText || "").trim().toLowerCase();
if (!term) {
return null;
}
const tokens = term.split(/\s+/).filter(Boolean);
if (!tokens.length) {
return null;
}
const where = ["archived = 0"];
if (!includeHidden) {
where.push("hidden = 0");
}
const rows = db
.prepare(
"SELECT id, quote_text, quoter, quoter_user_id, game_name, quote_datetime, edited_by, edited_last, hidden, archived " +
`FROM quotes WHERE ${where.join(" AND ")}`
)
.all()
.map(normalizeQuoteRow);
let best = null;
let bestScore = 0;
for (const row of rows) {
const hayText = (row.quote_text || "").toLowerCase();
const hayGame = (row.game_name || "").toLowerCase();
const hayQuoter = (row.quoter || "").toLowerCase();
const hayId = row.id.toString();
const hayDate = buildSearchDate(row.quote_datetime);
let score = 0;
let matchesAll = true;
for (const token of tokens) {
const matchesText = hayText.includes(token);
const matchesGame = hayGame.includes(token);
const matchesQuoter = hayQuoter.includes(token);
const matchesId = hayId.includes(token);
const matchesDate = hayDate.includes(token);
if (!matchesText && !matchesGame && !matchesQuoter && !matchesId && !matchesDate) {
matchesAll = false;
break;
}
if (matchesText) score += 5;
if (matchesGame) score += 4;
if (matchesQuoter) score += 2;
if (matchesId) score += 3;
if (matchesDate) score += 1;
}
if (!matchesAll) {
continue;
}
if (hayText.includes(term)) {
score += 6;
}
if (score > bestScore) {
bestScore = score;
best = row;
} else if (score === bestScore && best && row.quote_datetime > best.quote_datetime) {
best = row;
}
}
return best;
}
async function replyWithQuote(ctx, quote) {
if (ctx.platform === "discord") {
const embed = buildQuoteEmbed(quote);
await ctx.reply({ embeds: [embed] });
return;
}
await ctx.reply(buildQuoteText(quote));
}
function buildQuoteText(quote) {
const dateLabel = formatDateLabel(quote.quote_datetime);
const quoter = quote.quoter || "Unknown";
return `#${quote.id} "${quote.quote_text}" - quoted by ${quoter} ${dateLabel}`;
}
function buildQuoteEmbed(quote) {
const fields = [
{ name: "Quoted by", value: quote.quoter || "Unknown", inline: true }
];
if (quote.game_name) {
fields.push({ name: "Game", value: quote.game_name, inline: true });
}
fields.push({
name: "Date",
value: formatDateTime(quote.quote_datetime),
inline: true
});
return {
title: `Quote #${quote.id}`,
description: `"${quote.quote_text}"`,
fields,
timestamp: new Date(quote.quote_datetime).toISOString()
};
}
function normalizeQuoteRow(row) {
return {
id: row.id,
quote_text: row.quote_text,
quoter: row.quoter,
quoter_user_id: row.quoter_user_id,
game_name: row.game_name,
quote_datetime: row.quote_datetime,
edited_by: row.edited_by,
edited_last: row.edited_last,
hidden: Boolean(row.hidden),
archived: Boolean(row.archived)
};
}
function formatDateTime(timestamp) {
if (!timestamp) {
return "Unknown";
}
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) {
return "Unknown";
}
return new Intl.DateTimeFormat("en-US", {
dateStyle: "medium",
timeStyle: "short"
}).format(date);
}
function formatDateLabel(timestamp) {
if (!timestamp) {
return "";
}
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) {
return "";
}
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric"
}).format(date);
}
function formatDateInput(timestamp) {
if (!timestamp) {
return "";
}
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) {
return "";
}
const pad = (value) => value.toString().padStart(2, "0");
const yyyy = date.getFullYear();
const mm = pad(date.getMonth() + 1);
const dd = pad(date.getDate());
const hh = pad(date.getHours());
const min = pad(date.getMinutes());
return `${yyyy}-${mm}-${dd}T${hh}:${min}`;
}
function parseDateInput(value) {
if (!value) {
return null;
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return null;
}
return parsed.getTime();
}
function buildSearchDate(timestamp) {
if (!timestamp) {
return "";
}
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) {
return "";
}
return `${date.toISOString().slice(0, 10)} ${formatDateLabel(timestamp)}`.toLowerCase();
}
async function resolveGameName(ctx, settings) {
if (ctx.platform !== "twitch") {
return null;
}
const roomId = ctx.meta?.tags?.["room-id"] || ctx.meta?.tags?.roomId;
if (!roomId) {
return null;
}
const clientId = settings.getSetting("twitch_client_id");
const clientSecret = settings.getSetting("twitch_client_secret");
if (!clientId || !clientSecret) {
return null;
}
const token = await getTwitchAppToken(clientId, clientSecret);
if (!token) {
return null;
}
const response = await fetch(
`https://api.twitch.tv/helix/channels?broadcaster_id=${encodeURIComponent(roomId)}`,
{
headers: {
"Client-Id": clientId,
Authorization: `Bearer ${token}`
}
}
);
if (!response.ok) {
return null;
}
const data = await response.json();
const channel = data.data?.[0];
return channel?.game_name || null;
}
async function getTwitchAppToken(clientId, clientSecret) {
const now = Date.now();
if (cachedAppToken && now < cachedAppTokenExpiry) {
return cachedAppToken;
}
const url =
"https://id.twitch.tv/oauth2/token" +
`?client_id=${encodeURIComponent(clientId)}` +
`&client_secret=${encodeURIComponent(clientSecret)}` +
"&grant_type=client_credentials";
const response = await fetch(url, { method: "POST" });
if (!response.ok) {
return null;
}
const data = await response.json();
if (!data.access_token || !data.expires_in) {
return null;
}
cachedAppToken = data.access_token;
cachedAppTokenExpiry = now + (data.expires_in - 60) * 1000;
return cachedAppToken;
}