932 lines
28 KiB
JavaScript
932 lines
28 KiB
JavaScript
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} <user>`,
|
|
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;
|
|
}
|