Lumi/src/services/command-router.js
2026-07-19 15:33:20 +02:00

373 lines
12 KiB
JavaScript

const { db } = require("./db");
const { incrementCommands } = require("./stats");
const {
buildCommandContext,
runAdvancedCommand,
normalizeCommandResult
} = require("./commands");
const { normalizeRandomReplies, selectRandomReply } = require("./command-random");
const { findConditionalReply, normalizeConditionalReplies } = require("./command-conditional");
const { beginCommandUse, commandKey, customCommandKey } = require("./command-policies");
const { getEnabledPlatformIds, normalizePlatformSelection } = require("./platforms");
const placeholders = require("./placeholders");
const { createLogger } = require("./logger");
const commandLog = createLogger("core:commands", { category: "command" });
function createCommandRouter({ settings }) {
const commandMap = new Map();
const pluginCommands = new Map();
const registry = new Map();
function clearCommands(pluginId) {
const existing = pluginCommands.get(pluginId) || [];
for (const entry of existing) {
const handlers = commandMap.get(entry.trigger) || [];
const nextHandlers = handlers.filter((handler) => handler !== entry.handler);
if (nextHandlers.length) {
commandMap.set(entry.trigger, nextHandlers);
} else {
commandMap.delete(entry.trigger);
}
}
pluginCommands.delete(pluginId);
registry.delete(pluginId);
}
function registerCommands(pluginId, commands = []) {
if (!pluginId) {
throw new Error("Plugin id is required to register commands.");
}
clearCommands(pluginId);
const entries = [];
const registered = [];
for (const command of commands) {
const triggers = (command.triggers || [])
.map((trigger) => trigger.toLowerCase())
.filter(Boolean);
const handler = buildHandler(pluginId, command, triggers);
for (const trigger of triggers) {
const list = commandMap.get(trigger) || [];
list.push(handler);
commandMap.set(trigger, list);
entries.push({ trigger, handler });
}
registered.push({
key: handler.commandKey,
pluginId,
id: handler.commandId,
triggers,
platforms: handler.platforms,
description: String(command.description || ""),
defaultPolicy: handler.defaultPolicy
});
}
pluginCommands.set(pluginId, entries);
registry.set(pluginId, registered);
}
function buildHandler(pluginId, command, triggers) {
const handler = async (ctx) => {
return await command.handler(ctx);
};
handler.commandId = command.id || triggers[0] || null;
handler.commandKey = commandKey(pluginId, handler.commandId);
handler.platforms = Array.isArray(command.platforms) ? command.platforms : [];
handler.defaultPolicy = command.defaultPolicy || {};
return handler;
}
function listCommands() {
return Array.from(registry.values()).flat().map((entry) => ({ ...entry, triggers: [...entry.triggers], platforms: [...entry.platforms] }));
}
async function handleMessage({ platform, raw, user, platformUser, reply, meta }) {
const prefix = settings.getSetting("command_prefix", "!");
if (!raw.startsWith(prefix)) {
return false;
}
const rawCommand = raw.slice(prefix.length).trim();
if (!rawCommand) {
return false;
}
const parts = rawCommand.split(/\s+/);
const trigger = parts[0].toLowerCase();
const args = parts.slice(1);
const argsText = args.join(" ");
const ctx = {
platform,
trigger,
raw,
args,
argsText,
user: {
id: user.id,
username: user.internal_username || user.username,
platformId: platformUser.id,
displayName: platformUser.displayName || platformUser.username,
tag: platformUser.tag
},
platformUser,
meta,
reply
};
const customHandled = await handleCustomCommand({
trigger,
platform,
ctx,
raw,
reply
});
if (customHandled) {
incrementCommands(user.id);
commandLog.debug("Custom command completed", {
trigger,
platform,
user_id: user.id
}, { event: "custom_command_completed" });
return true;
}
const handlers = commandMap.get(trigger) || [];
for (const handler of handlers) {
if (handler.platforms.length && !handler.platforms.includes(ctx.platform)) continue;
let lease;
try {
lease = await beginCommandUse({
commandKey: handler.commandKey,
ctx,
defaultPolicy: handler.defaultPolicy
});
} catch (error) {
commandLog.error("Command policy check failed", { command_id: handler.commandId, trigger, platform, user_id: user.id, error }, { event: "command_policy_failed" });
await safeReply(reply, "This command's access settings could not be checked.");
return true;
}
if (!lease.allowed) {
await safeReply(reply, lease.message);
return true;
}
try {
const result = await handler(ctx);
if (typeof result === "string" && result) {
await safeReply(reply, result);
await lease.commit();
recordCommandUsage(handler.commandId);
incrementCommands(user.id);
commandLog.debug("Command completed", {
command_id: handler.commandId,
trigger,
platform,
user_id: user.id
}, { event: "command_completed" });
return true;
}
if (result === true) {
await lease.commit();
recordCommandUsage(handler.commandId);
incrementCommands(user.id);
commandLog.debug("Command completed", {
command_id: handler.commandId,
trigger,
platform,
user_id: user.id
}, { event: "command_completed" });
return true;
}
await lease.rollback("Command handler did not accept the invocation.");
} catch (error) {
await lease.rollback(error?.message || "Command handler failed.");
commandLog.error("Command handler failed", {
command_id: handler.commandId,
trigger,
platform,
user_id: user.id,
error
}, { event: "command_failed" });
await safeReply(reply, "Command failed to execute.");
return true;
}
}
return false;
}
return {
registerCommands,
clearCommands,
handleMessage,
listCommands
};
}
async function handleCustomCommand({ trigger, platform, ctx, raw, reply }) {
const row = db
.prepare(
"SELECT id, response, mode, language, code, platform, random_replies_json, rng_enabled, rng_min, rng_max, conditional_replies_json, conditional_fuzzy FROM custom_commands WHERE trigger = ? AND enabled = 1"
)
.get(trigger);
if (!row) {
return false;
}
const enabledPlatforms = getEnabledPlatformIds();
const allowedPlatforms = normalizePlatformSelection(row.platform, enabledPlatforms);
if (!allowedPlatforms.includes(platform)) {
return false;
}
let lease;
try {
lease = await beginCommandUse({ commandKey: customCommandKey(row.id), ctx });
} catch (error) {
commandLog.error("Custom command policy check failed", { trigger, platform, user_id: ctx.user.id, error }, { event: "command_policy_failed" });
await safeReply(reply, "This command's access settings could not be checked.");
return true;
}
if (!lease.allowed) {
await safeReply(reply, lease.message);
return true;
}
try {
if (row.mode === "advanced" && row.code) {
const messageInfo = buildMessageInfo(ctx, raw);
const commandCtx = buildCommandContext({
platform,
user: {
id: ctx.user.id,
platformId: ctx.user.platformId,
username: ctx.user.username,
displayName: ctx.user.displayName,
tag: ctx.user.tag
},
message: messageInfo,
args: ctx.args,
argsText: ctx.argsText
});
const result = await runAdvancedCommand(
{ code: row.code, language: row.language },
commandCtx
);
const output = normalizeCommandResult(result);
if (output) {
await safeReply(reply, output);
} else {
await safeReply(reply, "Command ran but returned no output.");
}
} else if (row.mode === "random") {
const selected = selectRandomReply({
replies: normalizeRandomReplies(row.random_replies_json),
rngEnabled: Boolean(row.rng_enabled),
rngMin: row.rng_min,
rngMax: row.rng_max
});
const rendered = await placeholders.renderTemplate({
fieldId: "core.custom_commands.static_response",
template: selected.reply.text,
outputAudience: "user",
user: {
id: ctx.user.id,
username: ctx.user.username,
isAdmin: false,
isMod: false
},
runtimeContext: { ctx, user: ctx.user, platform, runtime: true, command: { rng: selected.rng } }
});
await safeReply(reply, rendered.rendered);
} else if (row.mode === "conditional") {
const replies = normalizeConditionalReplies(row.conditional_replies_json);
const found = findConditionalReply(replies, ctx.argsText, { fuzzy: Boolean(row.conditional_fuzzy) });
let template = found.match?.response || "";
if (!ctx.argsText) {
template = row.response || `Available options: ${replies.map((entry) => entry.keyword).join(", ")}`;
} else if (!found.match) {
template = `Unknown option. Available options: ${replies.map((entry) => entry.keyword).join(", ")}`;
}
const rendered = await renderStaticResponse(template, ctx, platform, {
conditional: { requested: ctx.argsText, keyword: found.match?.keyword || null, fuzzy: found.fuzzy }
});
await safeReply(reply, rendered);
} else {
await safeReply(reply, await renderStaticResponse(row.response, ctx, platform));
}
recordCommandUsage(`custom:${trigger}`);
await lease.commit();
return true;
} catch (error) {
await lease.rollback(error?.message || "Custom command failed.");
commandLog.error("Custom command failed", {
trigger,
platform,
user_id: ctx.user.id,
error
}, { event: "custom_command_failed" });
await safeReply(reply, "Command failed to execute.");
return true;
}
}
async function renderStaticResponse(template, ctx, platform, extraRuntime = {}) {
const rendered = await placeholders.renderTemplate({
fieldId: "core.custom_commands.static_response",
template,
outputAudience: "user",
user: {
id: ctx.user.id,
username: ctx.user.username,
isAdmin: false,
isMod: false
},
runtimeContext: { ctx, user: ctx.user, platform, runtime: true, ...extraRuntime }
});
return rendered.rendered;
}
function buildMessageInfo(ctx, raw) {
if (ctx.platform === "discord" && ctx.meta?.message) {
const message = ctx.meta.message;
return {
id: message.id,
content: raw,
channelId: message.channelId,
guildId: message.guildId
};
}
if (ctx.platform === "twitch") {
return {
channel: ctx.meta?.channel,
content: raw
};
}
if (ctx.platform === "youtube") {
return {
liveChatId: ctx.meta?.liveChatId,
messageId: ctx.meta?.messageId,
channelId: ctx.meta?.author?.channelId,
content: raw
};
}
return { content: raw };
}
async function safeReply(reply, content) {
try {
await reply(content);
} catch (error) {
commandLog.error("Command reply failed", error, { event: "command_reply_failed" });
}
}
function recordCommandUsage(commandId) {
if (!commandId) {
return;
}
const now = Date.now();
db.prepare(
"INSERT INTO command_usage (command_id, count, updated_at) VALUES (?, 1, ?) " +
"ON CONFLICT(command_id) DO UPDATE SET count = count + 1, updated_at = excluded.updated_at"
).run(commandId, now);
}
module.exports = {
createCommandRouter
};