795 lines
23 KiB
JavaScript
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;
|
|
}
|