const fs = require("fs"); const path = require("path"); const DEFAULT_ACTIONS = [ { id: "hug", verb: "hugs", past: "hugged" }, { id: "bonk", verb: "bonks", past: "bonked" }, { id: "comfort", verb: "comforts", past: "comforted" }, { id: "pat", verb: "pats", past: "patted" }, { id: "cuddle", verb: "cuddles", past: "cuddled" }, { id: "boop", verb: "boops", past: "booped" }, { id: "highfive", verb: "high-fives", past: "high-fived", aliases: ["high-five", "hf"] }, { id: "snuggle", verb: "snuggles", past: "snuggled" }, { id: "cheer", verb: "cheers for", past: "cheered for" }, { id: "headpat", verb: "headpats", past: "headpatted", aliases: ["head-pat"] }, { id: "support", verb: "supports", past: "supported" }, { id: "encourage", verb: "encourages", past: "encouraged" }, { id: "stalk", verb: "stalks", past: "stalked", category: "yandere" }, { id: "kidnap", verb: "kidnaps", past: "kidnapped", category: "yandere" }, { id: "stab", verb: "stabs", past: "stabbed", category: "yandere" }, { id: "claim", verb: "claims", past: "claimed", category: "yandere" } ]; const PLUGIN_ID = "expression-interaction"; let cachedConfig = null; let cachedConfigAt = 0; let cachedAppToken = null; let cachedAppTokenExpiry = 0; let refreshCommands = null; let pluginMeta = { dir: __dirname, name: "Expression Interaction" }; module.exports = { id: PLUGIN_ID, init({ web, settings, db, commandRouter, plugin }) { ensureTables(db); ensureDefaultActions(db); pluginMeta = { dir: plugin?.dir || __dirname, name: plugin?.name || "Expression Interaction" }; writeCommandsManifest(getExpressionConfig(db)); refreshCommands = registerExpressionCommands({ commandRouter, settings, db }); const router = web.createRouter(); router.get("/", (req, res) => { const config = getExpressionConfig(db); const user = req.session.user || null; res.render(path.join(__dirname, "views", "expression.ejs"), { title: "Expression Interaction", actions: config.actions, platforms: config.platforms, conflicts: config.conflicts, stats: user ? getUserStats(db, user.id) : null, globalStats: getGlobalStats(db), isAdmin: Boolean(user?.isAdmin) }); }); router.post("/settings", (req, res) => { if (!req.session.user || !req.session.user.isAdmin) { return res.status(403).render("error", { title: "Access denied", message: "You do not have access to that page." }); } savePlatformSettings(db, req.body); if (refreshCommands) { refreshCommands(); } else { writeCommandsManifest(getExpressionConfig(db)); } req.session.flash = { type: "success", message: "Expression settings updated." }; res.redirect("/plugins/expression-interaction"); }); router.post("/actions/create", (req, res) => { if (!req.session.user || !req.session.user.isAdmin) { return res.status(403).render("error", { title: "Access denied", message: "You do not have access to that page." }); } const result = createExpressionAction(db, req.body); if (!result.ok) { req.session.flash = { type: "error", message: result.message }; return res.redirect("/plugins/expression-interaction"); } if (refreshCommands) { refreshCommands(); } else { writeCommandsManifest(getExpressionConfig(db)); } req.session.flash = { type: "success", message: "Expression added." }; res.redirect("/plugins/expression-interaction"); }); router.post("/actions/:id/update", (req, res) => { if (!req.session.user || !req.session.user.isAdmin) { return res.status(403).render("error", { title: "Access denied", message: "You do not have access to that page." }); } const result = updateExpressionAction(db, req.params.id, req.body); if (!result.ok) { req.session.flash = { type: "error", message: result.message }; return res.redirect("/plugins/expression-interaction"); } if (refreshCommands) { refreshCommands(); } else { writeCommandsManifest(getExpressionConfig(db)); } req.session.flash = { type: "success", message: "Expression updated." }; res.redirect("/plugins/expression-interaction"); }); router.post("/actions/:id/toggle", (req, res) => { if (!req.session.user || !req.session.user.isAdmin) { return res.status(403).render("error", { title: "Access denied", message: "You do not have access to that page." }); } toggleExpressionAction(db, req.params.id); invalidateConfigCache(); if (refreshCommands) { refreshCommands(); } else { writeCommandsManifest(getExpressionConfig(db)); } res.redirect("/plugins/expression-interaction"); }); router.post("/actions/:id/archive", (req, res) => { if (!req.session.user || !req.session.user.isAdmin) { return res.status(403).render("error", { title: "Access denied", message: "You do not have access to that page." }); } setExpressionActionArchived(db, req.params.id, true); invalidateConfigCache(); if (refreshCommands) { refreshCommands(); } else { writeCommandsManifest(getExpressionConfig(db)); } res.redirect("/plugins/expression-interaction"); }); router.post("/actions/:id/restore", (req, res) => { if (!req.session.user || !req.session.user.isAdmin) { return res.status(403).render("error", { title: "Access denied", message: "You do not have access to that page." }); } setExpressionActionArchived(db, req.params.id, false); invalidateConfigCache(); if (refreshCommands) { refreshCommands(); } else { writeCommandsManifest(getExpressionConfig(db)); } res.redirect("/plugins/expression-interaction"); }); web.mount("/plugins/expression-interaction", router, { label: "Expression Interaction", role: "public", section: "plugins" }); } }; function ensureTables(db) { db.exec(` CREATE TABLE IF NOT EXISTS expression_actions ( id TEXT PRIMARY KEY, command TEXT NOT NULL, verb TEXT, past TEXT, aliases TEXT, enabled INTEGER NOT NULL DEFAULT 1, archived INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS expression_interactions ( id INTEGER PRIMARY KEY AUTOINCREMENT, action TEXT NOT NULL, platform TEXT NOT NULL, actor_user_id TEXT NOT NULL, target_user_id TEXT NOT NULL, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS expression_pair_stats ( action TEXT NOT NULL, actor_user_id TEXT NOT NULL, target_user_id TEXT NOT NULL, count INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (action, actor_user_id, target_user_id) ); CREATE TABLE IF NOT EXISTS expression_user_stats ( action TEXT NOT NULL, user_id TEXT NOT NULL, given_count INTEGER NOT NULL DEFAULT 0, received_count INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (action, user_id) ); `); } function parseBoolean(value, fallback) { if (value === undefined || value === null || value === "") { return fallback; } if (typeof value === "boolean") { return value; } const normalized = value.toString().toLowerCase(); return ["1", "true", "yes", "on"].includes(normalized); } function getPluginSettings(db) { const rows = db .prepare("SELECT key, value FROM plugin_settings WHERE plugin_id = ?") .all(PLUGIN_ID); return rows.reduce((acc, row) => { acc[row.key] = row.value; return acc; }, {}); } function setPluginSetting(db, key, value) { db.prepare( "INSERT INTO plugin_settings (plugin_id, key, value, updated_at) VALUES (?, ?, ?, ?) " + "ON CONFLICT(plugin_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at" ).run(PLUGIN_ID, key, value, Date.now()); } function normalizeCommandName(name, fallback) { const raw = (name || fallback || "").trim().replace(/^!+/, ""); if (!raw) { return (fallback || "").toLowerCase(); } return raw.toLowerCase().replace(/\s+/g, "-"); } function normalizeActionId(name) { const raw = (name || "").trim().replace(/^!+/, "").toLowerCase(); if (!raw) { return ""; } return raw .replace(/[^a-z0-9-_]+/g, "-") .replace(/-+/g, "-") .replace(/^-+|-+$/g, ""); } function conjugateVerb(name) { const word = name.toLowerCase(); if (word.endsWith("y") && !/[aeiou]y$/.test(word)) { return `${word.slice(0, -1)}ies`; } if (/(s|x|z|ch|sh)$/.test(word)) { return `${word}es`; } return `${word}s`; } function conjugatePast(name) { const word = name.toLowerCase(); if (word.endsWith("e")) { return `${word}d`; } if (word.endsWith("y") && !/[aeiou]y$/.test(word)) { return `${word.slice(0, -1)}ied`; } return `${word}ed`; } function parseList(value) { return (value || "") .toString() .split(/[,\s]+/) .map((item) => item.trim()) .filter(Boolean); } function parseAliasList(value) { if (value === undefined || value === null || value === "") { return []; } if (Array.isArray(value)) { return value.map((item) => item.toString()); } try { const parsed = JSON.parse(value); if (Array.isArray(parsed)) { return parsed.map((item) => item.toString()); } } catch { // ignore invalid JSON } return parseList(value); } function normalizeAliasList(list, command) { const seen = new Set(); const normalized = []; for (const entry of list || []) { const alias = normalizeCommandName(entry, ""); if (!alias || alias === command || seen.has(alias)) { continue; } seen.add(alias); normalized.push(alias); } return normalized; } function ensureDefaultActions(db) { const existing = db .prepare("SELECT COUNT(*) AS count FROM expression_actions") .get(); const rows = existing?.count || 0; const settings = getPluginSettings(db); const now = Date.now(); const insert = db.prepare( "INSERT INTO expression_actions (id, command, verb, past, aliases, enabled, archived, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)" ); const addAction = (action) => { const storedName = settings[`action_${action.id}_name`] || action.id; const command = normalizeCommandName(storedName, action.id); const enabled = parseBoolean( settings[`action_${action.id}_enabled`], true ); const useDefaultAliases = command === normalizeCommandName(action.id, action.id); const aliases = useDefaultAliases ? normalizeAliasList(action.aliases || [], command) : []; insert.run( action.id, command, action.verb || "", action.past || "", JSON.stringify(aliases), enabled ? 1 : 0, now, now ); }; if (!rows) { DEFAULT_ACTIONS.forEach(addAction); return; } for (const action of DEFAULT_ACTIONS) { const existingAction = db .prepare("SELECT id FROM expression_actions WHERE id = ?") .get(action.id); if (!existingAction) { addAction(action); } } } function getExpressionActions(db) { const rows = db .prepare( "SELECT id, command, verb, past, aliases, enabled, archived, created_at, updated_at " + "FROM expression_actions ORDER BY created_at, id" ) .all(); return rows.map((row) => { const command = normalizeCommandName(row.command, row.id); const aliasList = normalizeAliasList(parseAliasList(row.aliases), command); const verbOverride = (row.verb || "").toString().trim(); const pastOverride = (row.past || "").toString().trim(); const verb = verbOverride || conjugateVerb(command); const past = pastOverride || conjugatePast(command); return { id: row.id, command, verb, past, verbOverride, pastOverride, aliases: aliasList, enabled: Boolean(row.enabled), archived: Boolean(row.archived), createdAt: row.created_at, updatedAt: row.updated_at }; }); } function getExpressionConfig(db) { const now = Date.now(); if (cachedConfig && now - cachedConfigAt < 5000) { return cachedConfig; } const settings = getPluginSettings(db); const platforms = { discord: parseBoolean(settings.platform_discord, true), twitch: parseBoolean(settings.platform_twitch, true) }; const conflicts = new Set(); const actionByTrigger = new Map(); const actions = getExpressionActions(db); actions .filter((action) => action.enabled && !action.archived) .forEach((action) => { const triggers = new Set([action.command, ...(action.aliases || [])]); for (const trigger of triggers) { if (actionByTrigger.has(trigger)) { conflicts.add(trigger); continue; } actionByTrigger.set(trigger, action); } }); cachedConfig = { platforms, actions, actionByTrigger, conflicts: Array.from(conflicts) }; cachedConfigAt = now; return cachedConfig; } function invalidateConfigCache() { cachedConfig = null; cachedConfigAt = 0; } function savePlatformSettings(db, body) { const platformDiscord = body.platform_discord === "on"; const platformTwitch = body.platform_twitch === "on"; setPluginSetting(db, "platform_discord", platformDiscord ? "1" : "0"); setPluginSetting(db, "platform_twitch", platformTwitch ? "1" : "0"); invalidateConfigCache(); } function createExpressionAction(db, body) { const rawId = (body.action_id || "").trim(); const rawCommand = (body.action_command || "").trim(); const id = normalizeActionId(rawId || rawCommand); if (!id) { return { ok: false, message: "Action id is required." }; } const existing = db .prepare("SELECT id FROM expression_actions WHERE id = ?") .get(id); if (existing) { return { ok: false, message: "That action id already exists." }; } const command = normalizeCommandName(rawCommand || id, id); if (!command) { return { ok: false, message: "Command name is required." }; } const verb = (body.action_verb || "").toString().trim(); const past = (body.action_past || "").toString().trim(); const aliases = normalizeAliasList( parseList(body.action_aliases || ""), command ); const enabled = body.action_enabled === "on"; const now = Date.now(); db.prepare( "INSERT INTO expression_actions (id, command, verb, past, aliases, enabled, archived, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)" ).run( id, command, verb, past, JSON.stringify(aliases), enabled ? 1 : 0, now, now ); invalidateConfigCache(); return { ok: true }; } function updateExpressionAction(db, id, body) { const existing = db .prepare("SELECT id FROM expression_actions WHERE id = ?") .get(id); if (!existing) { return { ok: false, message: "Expression not found." }; } const rawCommand = (body.action_command || "").trim(); const command = normalizeCommandName(rawCommand || id, id); if (!command) { return { ok: false, message: "Command name is required." }; } const verb = (body.action_verb || "").toString().trim(); const past = (body.action_past || "").toString().trim(); const aliases = normalizeAliasList( parseList(body.action_aliases || ""), command ); const enabled = body.action_enabled === "on"; const now = Date.now(); db.prepare( "UPDATE expression_actions SET command = ?, verb = ?, past = ?, aliases = ?, enabled = ?, updated_at = ? WHERE id = ?" ).run( command, verb, past, JSON.stringify(aliases), enabled ? 1 : 0, now, id ); invalidateConfigCache(); return { ok: true }; } function toggleExpressionAction(db, id) { const row = db .prepare("SELECT enabled FROM expression_actions WHERE id = ?") .get(id); if (!row) { return; } const next = row.enabled ? 0 : 1; db.prepare( "UPDATE expression_actions SET enabled = ?, updated_at = ? WHERE id = ?" ).run(next, Date.now(), id); } function setExpressionActionArchived(db, id, archived) { db.prepare( "UPDATE expression_actions SET archived = ?, updated_at = ? WHERE id = ?" ).run(archived ? 1 : 0, Date.now(), id); } function getUserStats(db, userId) { const rows = db .prepare( "SELECT action, given_count, received_count FROM expression_user_stats WHERE user_id = ?" ) .all(userId); const totals = rows.reduce( (acc, row) => { acc.given += row.given_count; acc.received += row.received_count; return acc; }, { given: 0, received: 0 } ); const byAction = rows.reduce((acc, row) => { acc[row.action] = row; return acc; }, {}); return { totals, byAction }; } function getGlobalStats(db) { const total = db .prepare("SELECT COUNT(*) AS count FROM expression_interactions") .get(); const byAction = db .prepare( "SELECT action, COUNT(*) AS count FROM expression_interactions GROUP BY action ORDER BY count DESC" ) .all(); return { total: total?.count || 0, byAction }; } function registerExpressionCommands({ commandRouter, settings, db }) { if (!commandRouter) { return null; } const rebuild = () => { const config = getExpressionConfig(db); const platforms = []; if (config.platforms.discord) { platforms.push("discord"); } if (config.platforms.twitch) { platforms.push("twitch"); } if (!platforms.length) { writeCommandsManifest(config); commandRouter.registerCommands(PLUGIN_ID, []); return; } const commands = config.actions .filter((action) => action.enabled && !action.archived) .map((action) => { const triggers = new Set([action.command, ...(action.aliases || [])]); const filtered = Array.from(triggers).filter((trigger) => { const mapped = config.actionByTrigger.get(trigger); return mapped && mapped.id === action.id; }); if (!filtered.length) { return null; } return { triggers: filtered, platforms, handler: async (ctx) => { return await handleExpressionCommand({ ctx, actionId: action.id, settings, db }); } }; }) .filter(Boolean); commandRouter.registerCommands(PLUGIN_ID, commands); writeCommandsManifest(config); }; rebuild(); return rebuild; } function writeCommandsManifest(config) { if (!pluginMeta?.dir) { return; } const toTitle = (value) => (value || "").replace(/(^|\s|-)(\w)/g, (_m, sep, char) => `${sep || ""}${char.toUpperCase()}` ); const commands = (config.actions || []) .filter((action) => action.enabled && !action.archived) .map((action) => ({ id: action.id, trigger: action.command, usage: `${action.command} `, name: toTitle(action.command), description: `Send a ${action.command} to another user.`, level: "public", platforms: ["discord", "twitch"], aliases: action.aliases || [] })); const manifest = { pluginId: PLUGIN_ID, pluginName: pluginMeta?.name || "Expression Interaction", platformKeys: { discord: "platform_discord", twitch: "platform_twitch" }, commands }; try { const target = path.join(pluginMeta.dir, "cmds.json"); fs.writeFileSync(target, JSON.stringify(manifest, null, 2), "utf8"); } catch (error) { console.error("Failed to write expression command manifest", error); } } async function handleExpressionCommand({ ctx, actionId, settings, db }) { const { ensureUserForIdentity } = require("../../src/services/users"); const config = getExpressionConfig(db); if (!config.platforms[ctx.platform]) { return false; } const action = config.actions.find((item) => item.id === actionId); if (!action || !action.enabled || action.archived) { return false; } const prefix = settings.getSetting("command_prefix", "!"); const targetToken = ctx.args[0]; if (!targetToken) { const usageTarget = ctx.platform === "discord" ? "@username" : "username"; await ctx.reply(`Usage: ${prefix}${action.command} ${usageTarget}`); return true; } if (ctx.platform === "discord") { const message = ctx.meta?.message; const targetInfo = await resolveDiscordTarget( message, targetToken, ensureUserForIdentity ); if (!targetInfo) { await ctx.reply("I couldn't find that user. Try mentioning them."); return true; } const stats = recordInteraction( db, action.id, "discord", ctx.user.id, targetInfo.profile.id ); const response = buildResponse({ action, actorLabel: `<@${ctx.platformUser.id}>`, targetLabel: targetInfo.label, actorName: ctx.user.username, targetName: targetInfo.profile.internal_username, stats }); await ctx.reply(response); return true; } if (ctx.platform === "twitch") { const targetLogin = targetToken.replace(/^@/, "").trim(); if (!targetLogin) { await ctx.reply(`Usage: ${prefix}${action.command} username`); return true; } const targetResolved = await resolveTwitchTarget( targetLogin, settings, ensureUserForIdentity ); const stats = recordInteraction( db, action.id, "twitch", ctx.user.id, targetResolved.profile.id ); const response = buildResponse({ action, actorLabel: `@${ctx.platformUser.username || ctx.platformUser.displayName}`, targetLabel: targetResolved.label, actorName: ctx.user.username, targetName: targetResolved.profile.internal_username, stats }); await ctx.reply(response); return true; } return false; } async function resolveDiscordTarget(message, token, ensureUserForIdentity) { if (message?.mentions?.users?.first) { const mention = message.mentions.users.first(); if (mention) { const display = mention.globalName || mention.username || mention.tag || mention.id; const profile = ensureUserForIdentity({ provider: "discord", providerUserId: mention.id, displayName: display }); return { profile, label: `<@${mention.id}>` }; } } const idMatch = token.match(/^<@!?(\d+)>$/) || token.match(/^(\d{15,})$/); if (idMatch && message?.client?.users?.fetch) { const id = idMatch[1]; const user = await message.client.users.fetch(id).catch(() => null); if (user) { const display = user.globalName || user.username || user.tag || user.id; const profile = ensureUserForIdentity({ provider: "discord", providerUserId: user.id, displayName: display }); return { profile, label: `<@${user.id}>` }; } } const name = token.replace(/^@/, "").trim(); if (!name) { return null; } const profile = ensureUserForIdentity({ provider: "discord_name", providerUserId: name.toLowerCase(), displayName: name, fallbackName: name }); return { profile, label: name }; } function recordInteraction(db, action, platform, actorUserId, targetUserId) { const now = Date.now(); db.prepare( "INSERT INTO expression_interactions (action, platform, actor_user_id, target_user_id, created_at) VALUES (?, ?, ?, ?, ?)" ).run(action, platform, actorUserId, targetUserId, now); db.prepare( "INSERT INTO expression_pair_stats (action, actor_user_id, target_user_id, count) VALUES (?, ?, ?, 1) " + "ON CONFLICT(action, actor_user_id, target_user_id) DO UPDATE SET count = count + 1" ).run(action, actorUserId, targetUserId); db.prepare( "INSERT INTO expression_user_stats (action, user_id, given_count, received_count) VALUES (?, ?, 1, 0) " + "ON CONFLICT(action, user_id) DO UPDATE SET given_count = given_count + 1" ).run(action, actorUserId); db.prepare( "INSERT INTO expression_user_stats (action, user_id, given_count, received_count) VALUES (?, ?, 0, 1) " + "ON CONFLICT(action, user_id) DO UPDATE SET received_count = received_count + 1" ).run(action, targetUserId); const pair = db .prepare( "SELECT count FROM expression_pair_stats WHERE action = ? AND actor_user_id = ? AND target_user_id = ?" ) .get(action, actorUserId, targetUserId); const actorTotals = db .prepare( "SELECT given_count FROM expression_user_stats WHERE action = ? AND user_id = ?" ) .get(action, actorUserId); const targetTotals = db .prepare( "SELECT received_count FROM expression_user_stats WHERE action = ? AND user_id = ?" ) .get(action, targetUserId); const globalTotals = db .prepare("SELECT COUNT(*) AS count FROM expression_interactions WHERE action = ?") .get(action); return { pairCount: pair?.count || 1, actorTotal: actorTotals?.given_count || 1, targetTotal: targetTotals?.received_count || 1, globalTotal: globalTotals?.count || 1 }; } function buildResponse({ action, actorLabel, targetLabel, actorName, targetName, stats }) { const main = `${actorLabel} ${action.verb} ${targetLabel}.`; const options = [ `${actorName} has ${action.past} ${targetName} ${stats.pairCount} times.`, `${actorName} has ${action.past} ${stats.actorTotal} times total.`, `${targetName} has been ${action.past} ${stats.targetTotal} times.`, `This action has been used ${stats.globalTotal} times.` ]; const detail = options[Math.floor(Math.random() * options.length)]; return `${main} ${detail}`; } async function resolveTwitchTarget(login, settings, ensureUserForIdentity) { const cleaned = login.toLowerCase(); const resolved = await fetchTwitchUser(cleaned, settings); if (resolved) { const profile = ensureUserForIdentity({ provider: "twitch", providerUserId: resolved.id, displayName: resolved.display_name }); return { profile, label: `@${resolved.login || cleaned}` }; } const profile = ensureUserForIdentity({ provider: "twitch_login", providerUserId: cleaned, displayName: cleaned, fallbackName: cleaned }); return { profile, label: `@${cleaned}` }; } async function fetchTwitchUser(login, settings) { 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/users?login=${encodeURIComponent(login)}`, { headers: { "Client-Id": clientId, Authorization: `Bearer ${token}` } } ); if (!response.ok) { return null; } const data = await response.json(); return data.data?.[0] || 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; }