85 lines
2.9 KiB
JavaScript
85 lines
2.9 KiB
JavaScript
|
|
export const SCOPES = ['all', 'private', 'business'];
|
|||
|
|
|
|||
|
|
/** Telegram принимает в setMyCommands только [a-z0-9_]{1,32} */
|
|||
|
|
const TELEGRAM_COMMAND = /^[a-z0-9_]{1,32}$/;
|
|||
|
|
|
|||
|
|
export class CommandRegistry {
|
|||
|
|
/** @type {Map<string, object>} триггер (имя или алиас) -> команда */
|
|||
|
|
#triggers = new Map();
|
|||
|
|
/** @type {object[]} */
|
|||
|
|
#commands = [];
|
|||
|
|
|
|||
|
|
register(definition, source = '<inline>') {
|
|||
|
|
const command = normalize(definition, source);
|
|||
|
|
|
|||
|
|
for (const trigger of [command.command, ...command.aliases]) {
|
|||
|
|
const taken = this.#triggers.get(trigger);
|
|||
|
|
if (taken) {
|
|||
|
|
throw new Error(
|
|||
|
|
`Конфликт триггеров: "${trigger}" из ${source} уже занят командой "${taken.command}" (${taken.source}).`,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
this.#triggers.set(trigger, command);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
this.#commands.push(command);
|
|||
|
|
return command;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
resolve(trigger) {
|
|||
|
|
return this.#triggers.get(trigger.toLowerCase());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Все команды в порядке регистрации. */
|
|||
|
|
list() {
|
|||
|
|
return [...this.#commands];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Что показываем в /help. */
|
|||
|
|
visible() {
|
|||
|
|
return this.#commands.filter((cmd) => !cmd.hidden);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Что уходит в setMyCommands: без скрытых, админских и нелатинских алиасов. */
|
|||
|
|
forTelegram() {
|
|||
|
|
return this.#commands
|
|||
|
|
.filter((cmd) => !cmd.hidden && !cmd.adminOnly && TELEGRAM_COMMAND.test(cmd.command))
|
|||
|
|
.map((cmd) => ({ command: cmd.command, description: cmd.description }));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function normalize(definition, source) {
|
|||
|
|
if (!definition || typeof definition !== 'object') {
|
|||
|
|
throw new Error(`${source}: файл команды должен экспортировать объект по умолчанию.`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { command, aliases = [], description = '', scope = 'all', adminOnly = false, hidden = false, handler } = definition;
|
|||
|
|
|
|||
|
|
if (typeof command !== 'string' || !command.trim()) {
|
|||
|
|
throw new Error(`${source}: не задано поле "command".`);
|
|||
|
|
}
|
|||
|
|
if (command.startsWith('/')) {
|
|||
|
|
throw new Error(`${source}: имя команды указывается без слэша — "${command.slice(1)}", а не "${command}".`);
|
|||
|
|
}
|
|||
|
|
if (typeof handler !== 'function') {
|
|||
|
|
throw new Error(`${source}: поле "handler" должно быть функцией.`);
|
|||
|
|
}
|
|||
|
|
if (!SCOPES.includes(scope)) {
|
|||
|
|
throw new Error(`${source}: неизвестный scope "${scope}". Допустимо: ${SCOPES.join(', ')}.`);
|
|||
|
|
}
|
|||
|
|
if (!Array.isArray(aliases) || aliases.some((alias) => typeof alias !== 'string')) {
|
|||
|
|
throw new Error(`${source}: "aliases" должен быть массивом строк.`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
command: command.toLowerCase(),
|
|||
|
|
aliases: aliases.map((alias) => alias.toLowerCase()),
|
|||
|
|
description,
|
|||
|
|
scope,
|
|||
|
|
adminOnly: Boolean(adminOnly),
|
|||
|
|
hidden: Boolean(hidden),
|
|||
|
|
handler,
|
|||
|
|
source,
|
|||
|
|
};
|
|||
|
|
}
|