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