Переписать проект в личный MTProto-юзербот
Смена архитектуры с бизнес-бота на Bot API (команды со слешем) на личный юзербот на GramJS/MTProto с управлением через компаньон-бота. Возможности: - antidelete — кэш входящих в личке, восстановление удалённых «у всех» - onetime — перехват «просмотр один раз» фото/видео до истечения ссылки - локальное хранилище node:sqlite + шифрование AES-256-GCM, чистка по сроку - компаньон-бот (grammY): инлайн-панель, лента с пагинацией, выдача медиа, доступ только владельцу по OWNER_ID Устройство: - core/ — crypto, db, media, loader, registry, logger - userbot/ — MTProto-клиент из строки сессии, подписка на события - modules/ — фичи как модули с хуками setup/onMessage/onEdited/onDeleted - bot/ — компаньон-бот (единственный UI) - scripts/login.js — генерация строки сессии Удалён старый код Bot API (commands/, handlers/, middlewares/, dispatcher, store). Обновлены README и .env.example. Hcrgram/ (сторонний референс) добавлен в .gitignore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+66
-63
@@ -1,84 +1,87 @@
|
||||
export const SCOPES = ['all', 'private', 'business'];
|
||||
import { logger } from './logger.js';
|
||||
|
||||
/** 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 = [];
|
||||
/**
|
||||
* Реестр модулей юзербота. Модуль — это файл в src/modules, который экспортирует
|
||||
* по умолчанию объект вида:
|
||||
*
|
||||
* export default {
|
||||
* name: 'antidelete',
|
||||
* description: 'Ловит удалённые сообщения',
|
||||
* setup(ctx) { ... }, // однократная инициализация
|
||||
* onMessage(message, ctx) { ... }, // новое входящее (личные чаты)
|
||||
* onDeleted(event, ctx) { ... }, // событие удаления
|
||||
* onEdited(message, ctx) { ... }, // правка сообщения
|
||||
* panel: { title, id, render, ... }, // раздел в компаньон-боте (опц.)
|
||||
* }
|
||||
*
|
||||
* Обработчики опциональны — модуль объявляет только то, что ему нужно.
|
||||
*/
|
||||
export class ModuleRegistry {
|
||||
#modules = [];
|
||||
#byName = new Map();
|
||||
|
||||
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);
|
||||
const module = normalize(definition, source);
|
||||
if (this.#byName.has(module.name)) {
|
||||
throw new Error(`Модуль "${module.name}" уже зарегистрирован (${source}).`);
|
||||
}
|
||||
|
||||
this.#commands.push(command);
|
||||
return command;
|
||||
this.#byName.set(module.name, module);
|
||||
this.#modules.push(module);
|
||||
return module;
|
||||
}
|
||||
|
||||
resolve(trigger) {
|
||||
return this.#triggers.get(trigger.toLowerCase());
|
||||
}
|
||||
|
||||
/** Все команды в порядке регистрации. */
|
||||
list() {
|
||||
return [...this.#commands];
|
||||
return [...this.#modules];
|
||||
}
|
||||
|
||||
/** Что показываем в /help. */
|
||||
visible() {
|
||||
return this.#commands.filter((cmd) => !cmd.hidden);
|
||||
get(name) {
|
||||
return this.#byName.get(name);
|
||||
}
|
||||
|
||||
/** Что уходит в setMyCommands: без скрытых, админских и нелатинских алиасов. */
|
||||
forTelegram() {
|
||||
return this.#commands
|
||||
.filter((cmd) => !cmd.hidden && !cmd.adminOnly && TELEGRAM_COMMAND.test(cmd.command))
|
||||
.map((cmd) => ({ command: cmd.command, description: cmd.description }));
|
||||
/** Модули, у которых объявлен нужный хук — чтобы не дёргать пустышки. */
|
||||
withHook(hook) {
|
||||
return this.#modules.filter((m) => typeof m[hook] === 'function');
|
||||
}
|
||||
|
||||
panels() {
|
||||
return this.#modules.filter((m) => m.panel).map((m) => ({ ...m.panel, module: m.name }));
|
||||
}
|
||||
}
|
||||
|
||||
function normalize(definition, source) {
|
||||
if (!definition || typeof definition !== 'object') {
|
||||
throw new Error(`${source}: файл команды должен экспортировать объект по умолчанию.`);
|
||||
function normalize(def, source) {
|
||||
if (!def || typeof def !== '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 (typeof def.name !== 'string' || !def.name.trim()) {
|
||||
throw new Error(`${source}: у модуля не задано поле "name".`);
|
||||
}
|
||||
if (command.startsWith('/')) {
|
||||
throw new Error(`${source}: имя команды указывается без слэша — "${command.slice(1)}", а не "${command}".`);
|
||||
for (const hook of ['setup', 'onMessage', 'onDeleted', 'onEdited']) {
|
||||
if (def[hook] !== undefined && typeof def[hook] !== 'function') {
|
||||
throw new Error(`${source}: "${hook}" должен быть функцией.`);
|
||||
}
|
||||
}
|
||||
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,
|
||||
name: def.name,
|
||||
description: def.description ?? '',
|
||||
setup: def.setup,
|
||||
onMessage: def.onMessage,
|
||||
onDeleted: def.onDeleted,
|
||||
onEdited: def.onEdited,
|
||||
panel: def.panel,
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Прогоняет хук по всем модулям, где он объявлен. Ошибка одного модуля
|
||||
* не роняет остальные — логируем и идём дальше.
|
||||
*/
|
||||
export async function runHook(registry, hook, payload, ctx) {
|
||||
for (const module of registry.withHook(hook)) {
|
||||
try {
|
||||
await module[hook](payload, ctx);
|
||||
} catch (err) {
|
||||
logger.error(`Модуль "${module.name}", хук ${hook}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user