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

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;
}