commit e92e0df6449982e8b504f534ac906418cf3753b2 Author: ares Date: Sun Aug 9 16:53:38 2026 +0300 first commit diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..d470b2f --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(npm test *)", + "Bash(BOT_TOKEN=\"123456:AAFAKE_TOKEN_FOR_SMOKE_TEST\" LOG_LEVEL=debug timeout 25 node src/index.js)" + ] + } +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c58dacf --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Токен от @BotFather +BOT_TOKEN=123456:AA... + +# ID администраторов через запятую (узнать свой: команда /id) +ADMIN_IDS= + +# Сколько минут молчать в чате после того, как владелец ответил вручную +HANDOVER_PAUSE_MINUTES=30 + +# debug | info | warn | error +LOG_LEVEL=info diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..40f5df4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.env +data/ +*.log diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f8549eb --- /dev/null +++ b/package-lock.json @@ -0,0 +1,123 @@ +{ + "name": "telegrambusiness", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "telegrambusiness", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "grammy": "^1.45.1" + } + }, + "node_modules/@grammyjs/types": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-4.0.0.tgz", + "integrity": "sha512-Z8lDLTvOlo12e5Vnly/vQh3JC9ppaitS1dGZ3w068gNitOd/y8tTSiib+Xm38aBGbtYGmUZwOc6afYrLs2CSTg==", + "license": "MIT" + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/grammy": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/grammy/-/grammy-1.45.1.tgz", + "integrity": "sha512-Y4VL/hqJMZZxwlUr5ZgM68CFu2iIeEkNLR1cY3+Ww68CIvWARDsoXFix7+31rmyC0+7L85ZI+Pq3E5JSG5+nLQ==", + "license": "MIT", + "dependencies": { + "@grammyjs/types": "4.0.0", + "abort-controller": "^3.0.0", + "debug": "^4.4.3", + "node-fetch": "^2.7.0" + }, + "engines": { + "node": "^12.20.0 || >=14.13.1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9f72780 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "telegrambusiness", + "version": "1.0.0", + "description": "Telegram bot: modular commands + Business Mode support", + "type": "module", + "main": "src/index.js", + "engines": { + "node": ">=22.9.0" + }, + "scripts": { + "start": "node --env-file-if-exists=.env src/index.js", + "dev": "node --watch --env-file-if-exists=.env src/index.js", + "check": "node scripts/check.js", + "test": "node --test \"test/**/*.test.js\"", + "sync": "node --env-file-if-exists=.env scripts/sync-commands.js" + }, + "dependencies": { + "grammy": "^1.45.1" + } +} diff --git a/scripts/check.js b/scripts/check.js new file mode 100644 index 0000000..0ba67c2 --- /dev/null +++ b/scripts/check.js @@ -0,0 +1,20 @@ +/** + * Проверка структуры без обращения к Telegram: грузит все команды, + * ловит конфликты триггеров и печатает таблицу. Токен не нужен. + * + * npm run check + */ +import { loadCommands } from '../src/core/loader.js'; + +const registry = await loadCommands(); +const rows = registry.list().map((cmd) => ({ + команда: `/${cmd.command}`, + алиасы: cmd.aliases.join(', ') || '—', + scope: cmd.scope, + admin: cmd.adminOnly ? 'да' : '', + hidden: cmd.hidden ? 'да' : '', + файл: cmd.source, +})); + +console.table(rows); +console.log(`В меню Telegram уйдёт: ${registry.forTelegram().map((c) => `/${c.command}`).join(' ') || '—'}`); diff --git a/scripts/sync-commands.js b/scripts/sync-commands.js new file mode 100644 index 0000000..8a63e12 --- /dev/null +++ b/scripts/sync-commands.js @@ -0,0 +1,18 @@ +/** + * Отправляет список команд в Telegram, не поднимая бота. + * + * npm run sync + */ +import { Bot } from 'grammy'; + +import { assertConfig, config } from '../src/config.js'; +import { loadCommands } from '../src/core/loader.js'; + +assertConfig(); + +const registry = await loadCommands(); +const bot = new Bot(config.token); +const commands = registry.forTelegram(); + +await bot.api.setMyCommands(commands); +console.log(`Готово. Отправлено команд: ${commands.length}`); diff --git a/src/bot.js b/src/bot.js new file mode 100644 index 0000000..88f925d --- /dev/null +++ b/src/bot.js @@ -0,0 +1,63 @@ +import { Bot, GrammyError, HttpError } from 'grammy'; + +import { assertConfig, config } from './config.js'; +import { createDispatcher } from './core/dispatcher.js'; +import { loadCommands } from './core/loader.js'; +import { logger } from './core/logger.js'; +import { connections, handovers } from './core/store.js'; +import { registerConnectionHandlers } from './handlers/connection.js'; +import { fallback } from './handlers/fallback.js'; +import { businessContext } from './middlewares/business.js'; +import { handover } from './middlewares/handover.js'; + +/** Без явного allowed_updates Telegram не пришлёт business-апдейты. */ +export const ALLOWED_UPDATES = [ + 'message', + 'edited_message', + 'callback_query', + 'business_connection', + 'business_message', + 'edited_business_message', + 'deleted_business_messages', +]; + +export async function createBot() { + assertConfig(); + await Promise.all([connections.load(), handovers.load()]); + + const registry = await loadCommands(); + const bot = new Bot(config.token); + + bot.catch(({ error, ctx }) => { + const where = `update ${ctx?.update?.update_id}`; + if (error instanceof GrammyError) { + logger.error(`${where}: Telegram отклонил запрос — ${error.description}`); + } else if (error instanceof HttpError) { + logger.error(`${where}: сеть недоступна`, error); + } else { + logger.error(`${where}: необработанная ошибка`, error); + } + }); + + bot.use(businessContext()); + + // Подключение/отключение бота к бизнес-аккаунту — до пайплайна сообщений. + registerConnectionHandlers(bot); + + bot.on(['message', 'business_message'], handover(), createDispatcher(registry), fallback()); + + return { bot, registry }; +} + +export async function syncCommands(bot, registry) { + const commands = registry.forTelegram(); + await bot.api.setMyCommands(commands); + logger.info(`Меню команд обновлено: ${commands.map((c) => `/${c.command}`).join(' ') || '—'}`); +} + +export async function shutdown(bot) { + logger.info('Останавливаюсь…'); + await bot.stop(); + await Promise.all([connections.flush(), handovers.flush()]); + logger.info('Состояние сохранено.'); +} diff --git a/src/commands/_template.js b/src/commands/_template.js new file mode 100644 index 0000000..3beeeab --- /dev/null +++ b/src/commands/_template.js @@ -0,0 +1,26 @@ +/** + * Шаблон команды. Файлы, чьё имя начинается с `_`, загрузчик пропускает, + * поэтому этот файл можно скопировать и переименовать — например, в price.js. + * + * Поля: + * command — имя без слэша, обязательное + * aliases — дополнительные триггеры (можно кириллицей) + * description — строка для /help и setMyCommands + * scope — 'all' | 'private' (только личка с ботом) | 'business' (только бизнес-чаты) + * adminOnly — доступно владельцу бизнес-аккаунта и ID из ADMIN_IDS + * hidden — не показывать в /help и не отправлять в setMyCommands + * handler — (ctx, args, { registry, rest }) => Promise + */ +export default { + command: 'template', + aliases: [], + description: 'Пример команды', + scope: 'all', + adminOnly: false, + hidden: true, + + async handler(ctx, args, { rest }) { + // args — массив слов после команды, rest — та же строка целиком. + await ctx.reply(args.length ? `Аргументы: ${args.join(', ')}` : `Команда без аргументов (rest: "${rest}")`); + }, +}; diff --git a/src/commands/business/pause.js b/src/commands/business/pause.js new file mode 100644 index 0000000..267fc08 --- /dev/null +++ b/src/commands/business/pause.js @@ -0,0 +1,28 @@ +import { config } from '../../config.js'; + +export default { + command: 'pause', + aliases: ['пауза'], + description: 'Остановить автоответы в этом чате: /pause [минуты | off]', + scope: 'business', + adminOnly: true, + + async handler(ctx, args) { + const [arg] = args; + + if (arg === 'off' || arg === 'выкл') { + ctx.business.resume(); + await ctx.reply('Автоответы снова включены в этом чате.'); + return; + } + + const minutes = arg ? Number(arg) : config.handoverPauseMs / 60_000; + if (!Number.isFinite(minutes) || minutes <= 0) { + await ctx.reply('Нужно число минут или "off". Например: /pause 60'); + return; + } + + ctx.business.pause(minutes * 60_000); + await ctx.reply(`Молчу в этом чате ${minutes} мин. Вернуть раньше — /pause off`); + }, +}; diff --git a/src/commands/business/status.js b/src/commands/business/status.js new file mode 100644 index 0000000..607f752 --- /dev/null +++ b/src/commands/business/status.js @@ -0,0 +1,40 @@ +import { connections } from '../../core/store.js'; + +const RIGHTS_TO_SHOW = [ + ['can_reply', 'отвечать'], + ['can_read_messages', 'читать переписку'], + ['can_delete_outgoing_messages', 'удалять свои сообщения'], + ['can_edit_name', 'менять имя'], + ['can_view_gifts_and_stars', 'видеть подарки и звёзды'], + ['can_manage_stories', 'управлять историями'], +]; + +export default { + command: 'status', + description: 'Состояние бизнес-подключений', + scope: 'all', + adminOnly: true, + + async handler(ctx) { + const all = connections.entries(); + if (!all.length) { + await ctx.reply( + 'Активных подключений нет.\n\nВключите Business Mode у бота в @BotFather, затем добавьте бота в Settings → Telegram Business → Chatbots.', + ); + return; + } + + const blocks = all.map(([id, record]) => { + const rights = RIGHTS_TO_SHOW.filter(([key]) => record.rights?.[key]).map(([, label]) => label); + + return [ + `Подключение ${id}`, + `владелец: ${record.ownerId}${record.ownerUsername ? ` (@${record.ownerUsername})` : ''}`, + `активно: ${record.isEnabled ? 'да' : 'нет'}`, + `права: ${rights.length ? rights.join(', ') : 'не переданы'}`, + ].join('\n'); + }); + + await ctx.reply(blocks.join('\n\n'), { parse_mode: 'HTML' }); + }, +}; diff --git a/src/commands/help.js b/src/commands/help.js new file mode 100644 index 0000000..4821c5c --- /dev/null +++ b/src/commands/help.js @@ -0,0 +1,23 @@ +import { isOperator } from '../middlewares/business.js'; + +export default { + command: 'help', + aliases: ['помощь', 'справка'], + description: 'Список команд', + scope: 'all', + + async handler(ctx, args, { registry }) { + const operator = isOperator(ctx); + + // Админские команды показываем только тем, кто может их вызвать. + const available = registry.visible().filter((cmd) => operator || !cmd.adminOnly); + + const lines = available.map((cmd) => { + const aliases = cmd.aliases.length ? ` (${cmd.aliases.map((a) => `/${a}`).join(', ')})` : ''; + const mark = cmd.adminOnly ? ' 🔒' : ''; + return `/${cmd.command}${aliases} — ${cmd.description}${mark}`; + }); + + await ctx.reply(lines.length ? `Команды:\n${lines.join('\n')}` : 'Команд пока нет.'); + }, +}; diff --git a/src/commands/id.js b/src/commands/id.js new file mode 100644 index 0000000..9022b9f --- /dev/null +++ b/src/commands/id.js @@ -0,0 +1,23 @@ +export default { + command: 'id', + aliases: ['whoami'], + description: 'Показать ID пользователя и чата', + scope: 'all', + + async handler(ctx) { + const lines = [ + `Ваш ID: ${ctx.from?.id}`, + `Чат: ${ctx.chat?.id} (${ctx.chat?.type})`, + ]; + + if (ctx.business) { + lines.push( + `Подключение: ${ctx.business.connectionId}`, + `Владелец: ${ctx.business.ownerId ?? 'неизвестен'}`, + `Вы владелец: ${ctx.business.isOwner ? 'да' : 'нет'}`, + ); + } + + await ctx.reply(lines.join('\n'), { parse_mode: 'HTML' }); + }, +}; diff --git a/src/commands/ping.js b/src/commands/ping.js new file mode 100644 index 0000000..d734b47 --- /dev/null +++ b/src/commands/ping.js @@ -0,0 +1,17 @@ +export default { + command: 'ping', + description: 'Проверка живости и задержки апдейта', + scope: 'all', + + async handler(ctx) { + // Telegram отдаёт date в секундах, поэтому точность лага — около секунды. + const sentAt = (ctx.msg?.date ?? 0) * 1000; + const lagMs = sentAt ? Date.now() - sentAt : null; + + const lines = ['pong']; + if (lagMs !== null) lines.push(`задержка апдейта: ~${lagMs} мс`); + lines.push(`uptime: ${Math.round(process.uptime())} с`); + + await ctx.reply(lines.join('\n')); + }, +}; diff --git a/src/commands/start.js b/src/commands/start.js new file mode 100644 index 0000000..18a0bd9 --- /dev/null +++ b/src/commands/start.js @@ -0,0 +1,28 @@ +export default { + command: 'start', + aliases: ['старт'], + description: 'Приветствие и краткая справка', + scope: 'all', + + async handler(ctx, args, { registry }) { + const name = ctx.from?.first_name ?? 'там'; + + // В бизнес-чате бот пишет от имени владельца, поэтому текст другой: + // клиент не должен видеть, что общается с ботом-помощником. + if (ctx.business) { + await ctx.reply( + `Здравствуйте, ${name}! Спасибо за обращение — уже смотрю ваш вопрос и скоро вернусь с ответом.`, + ); + return; + } + + const commands = registry + .visible() + .map((cmd) => `/${cmd.command} — ${cmd.description}`) + .join('\n'); + + await ctx.reply( + `Привет, ${name}. Я бот-помощник для Telegram Business.\n\nДоступные команды:\n${commands}`, + ); + }, +}; diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..885a3d7 --- /dev/null +++ b/src/config.js @@ -0,0 +1,26 @@ +const parseIds = (raw) => + (raw ?? '') + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + .map(Number) + .filter((id) => Number.isInteger(id)); + +export const config = { + token: process.env.BOT_TOKEN ?? '', + adminIds: parseIds(process.env.ADMIN_IDS), + handoverPauseMs: Number(process.env.HANDOVER_PAUSE_MINUTES ?? 30) * 60_000, + logLevel: process.env.LOG_LEVEL ?? 'info', + dataDir: new URL('../data/', import.meta.url), +}; + +export function assertConfig() { + if (!config.token) { + throw new Error('BOT_TOKEN не задан. Скопируйте .env.example в .env и вставьте токен от @BotFather.'); + } + if (!Number.isFinite(config.handoverPauseMs) || config.handoverPauseMs < 0) { + throw new Error('HANDOVER_PAUSE_MINUTES должен быть неотрицательным числом.'); + } +} + +export const isAdmin = (userId) => config.adminIds.includes(userId); diff --git a/src/core/dispatcher.js b/src/core/dispatcher.js new file mode 100644 index 0000000..97db10e --- /dev/null +++ b/src/core/dispatcher.js @@ -0,0 +1,68 @@ +import { isAdmin } from '../config.js'; +import { logger } from './logger.js'; + +/** + * Разбирает команду из ctx.msg — то есть и из `message`, и из `business_message`. + * Родной bot.command() в grammY смотрит только на message/channel_post, + * поэтому в бизнес-чатах он молчит. + */ +export function parseCommand(ctx) { + const msg = ctx.msg; + const text = msg?.text ?? msg?.caption; + if (!text || !text.startsWith('/')) return null; + + const entities = msg.entities ?? msg.caption_entities ?? []; + const entity = entities.find((e) => e.type === 'bot_command' && e.offset === 0); + + // Латинские команды Telegram размечает сам. Для алиасов вроде "/старт" + // разметки не будет, поэтому режем текст руками. + const rawLength = entity?.length ?? (text.split(/\s/, 1)[0]?.length ?? 0); + const raw = text.slice(1, rawLength); + if (!raw) return null; + + const [name, target] = raw.split('@'); + if (target && target.toLowerCase() !== ctx.me?.username?.toLowerCase()) return null; + + const rest = text.slice(rawLength).trim(); + return { + name: name.toLowerCase(), + rest, + args: rest.length ? rest.split(/\s+/) : [], + }; +} + +const inScope = (scope, ctx) => { + if (scope === 'all') return true; + if (scope === 'business') return Boolean(ctx.businessConnectionId); + return !ctx.businessConnectionId && ctx.chat?.type === 'private'; +}; + +/** + * @param {import('./registry.js').CommandRegistry} registry + */ +export function createDispatcher(registry) { + return async (ctx, next) => { + // На правках сообщений команды не выполняем, иначе один /start сработает дважды. + if (ctx.editedMessage || ctx.editedBusinessMessage) return next(); + + const parsed = parseCommand(ctx); + if (!parsed) return next(); + + const command = registry.resolve(parsed.name); + if (!command) return next(); + + if (!inScope(command.scope, ctx)) { + logger.debug(`/${parsed.name}: вне scope "${command.scope}", пропускаем`); + return next(); + } + + const allowed = !command.adminOnly || isAdmin(ctx.from?.id) || ctx.business?.isOwner === true; + if (!allowed) { + logger.warn(`/${parsed.name}: отказано пользователю ${ctx.from?.id}`); + return; + } + + logger.info(`/${command.command} от ${ctx.from?.id}${ctx.businessConnectionId ? ' (business)' : ''}`); + await command.handler(ctx, parsed.args, { registry, rest: parsed.rest }); + }; +} diff --git a/src/core/loader.js b/src/core/loader.js new file mode 100644 index 0000000..b73b7d9 --- /dev/null +++ b/src/core/loader.js @@ -0,0 +1,32 @@ +import { readdir } from 'node:fs/promises'; +import { join, relative, sep } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { logger } from './logger.js'; +import { CommandRegistry } from './registry.js'; + +const COMMANDS_DIR = fileURLToPath(new URL('../commands/', import.meta.url)); + +/** + * Забирает все *.js из src/commands (включая подпапки) и складывает в реестр. + * Файлы, начинающиеся с `_`, игнорируются — удобно для черновиков и хелперов. + */ +export async function loadCommands(dir = COMMANDS_DIR) { + const registry = new CommandRegistry(); + const entries = await readdir(dir, { recursive: true, withFileTypes: true }); + + const files = entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.js') && !entry.name.startsWith('_')) + .map((entry) => join(entry.parentPath ?? entry.path, entry.name)) + .sort(); + + for (const file of files) { + const source = relative(dir, file).split(sep).join('/'); + const module = await import(pathToFileURL(file).href); + registry.register(module.default, source); + logger.debug(`команда загружена: ${source}`); + } + + logger.info(`Загружено команд: ${registry.list().length}`); + return registry; +} diff --git a/src/core/logger.js b/src/core/logger.js new file mode 100644 index 0000000..2df60bf --- /dev/null +++ b/src/core/logger.js @@ -0,0 +1,16 @@ +import { config } from '../config.js'; + +const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 }; +const threshold = LEVELS[config.logLevel] ?? LEVELS.info; + +const write = (level, stream, args) => { + if (LEVELS[level] < threshold) return; + stream(`[${new Date().toISOString()}] ${level.toUpperCase()}`, ...args); +}; + +export const logger = { + debug: (...args) => write('debug', console.debug, args), + info: (...args) => write('info', console.info, args), + warn: (...args) => write('warn', console.warn, args), + error: (...args) => write('error', console.error, args), +}; diff --git a/src/core/registry.js b/src/core/registry.js new file mode 100644 index 0000000..9a20a9d --- /dev/null +++ b/src/core/registry.js @@ -0,0 +1,84 @@ +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} триггер (имя или алиас) -> команда */ + #triggers = new Map(); + /** @type {object[]} */ + #commands = []; + + register(definition, source = '') { + 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, + }; +} diff --git a/src/core/store.js b/src/core/store.js new file mode 100644 index 0000000..fcd4415 --- /dev/null +++ b/src/core/store.js @@ -0,0 +1,83 @@ +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +import { config } from '../config.js'; +import { logger } from './logger.js'; + +/** + * Простейшее JSON-хранилище: держим состояние в памяти, на диск пишем + * дебаунсом через временный файл. Для продакшена меняется на Redis/Postgres, + * интерфейс (get/set/delete) при этом остаётся тем же. + */ +export class JsonStore { + #path; + #data = new Map(); + #flushTimer = null; + #writing = Promise.resolve(); + + constructor(fileName) { + this.#path = fileURLToPath(new URL(fileName, config.dataDir)); + } + + async load() { + try { + const raw = await readFile(this.#path, 'utf8'); + this.#data = new Map(Object.entries(JSON.parse(raw))); + logger.debug(`store ${this.#path}: загружено записей — ${this.#data.size}`); + } catch (err) { + if (err.code !== 'ENOENT') throw err; + logger.debug(`store ${this.#path}: файла нет, начинаем с пустого состояния`); + } + return this; + } + + get(key) { + return this.#data.get(String(key)); + } + + has(key) { + return this.#data.has(String(key)); + } + + entries() { + return [...this.#data.entries()]; + } + + set(key, value) { + this.#data.set(String(key), value); + this.#scheduleFlush(); + return value; + } + + delete(key) { + const existed = this.#data.delete(String(key)); + if (existed) this.#scheduleFlush(); + return existed; + } + + #scheduleFlush() { + if (this.#flushTimer) return; + this.#flushTimer = setTimeout(() => { + this.#flushTimer = null; + this.flush().catch((err) => logger.error('store: не удалось записать файл', err)); + }, 300); + this.#flushTimer.unref?.(); + } + + async flush() { + this.#writing = this.#writing.then(async () => { + const payload = JSON.stringify(Object.fromEntries(this.#data), null, 2); + const tmp = `${this.#path}.tmp`; + await mkdir(fileURLToPath(config.dataDir), { recursive: true }); + await writeFile(tmp, payload, 'utf8'); + await rename(tmp, this.#path); + }); + return this.#writing; + } +} + +/** business_connection_id -> { ownerId, userChatId, isEnabled, rights, updatedAt } */ +export const connections = new JsonStore('connections.json'); + +/** `${connectionId}:${chatId}` -> timestamp, до которого автоответы выключены */ +export const handovers = new JsonStore('handovers.json'); diff --git a/src/handlers/connection.js b/src/handlers/connection.js new file mode 100644 index 0000000..261fc58 --- /dev/null +++ b/src/handlers/connection.js @@ -0,0 +1,33 @@ +import { logger } from '../core/logger.js'; +import { connections } from '../core/store.js'; +import { saveConnection } from '../middlewares/business.js'; + +/** + * business_connection приходит, когда владелец подключает бота, меняет права + * или отключает его. Здесь единственное место, где мы узнаём connection.id, — + * без него нельзя отправить ни одного сообщения, поэтому пишем его в хранилище. + */ +export function registerConnectionHandlers(bot) { + bot.on('business_connection', async (ctx) => { + const connection = ctx.businessConnection; + + if (connection.is_enabled) { + saveConnection(connection); + logger.info(`Бот подключён к аккаунту ${connection.user.id} (${connection.id})`); + + // user_chat_id — личный чат с владельцем, туда пишем без business_connection_id. + await ctx.api.sendMessage( + connection.user_chat_id, + 'Бот подключён к вашему бизнес-аккаунту. Проверить права — /status, остановить автоответы в конкретном чате — /pause.', + ).catch((err) => logger.warn('Не удалось написать владельцу', err)); + return; + } + + connections.delete(connection.id); + logger.info(`Подключение ${connection.id} отключено владельцем ${connection.user.id}`); + }); + + bot.on('deleted_business_messages', (ctx) => { + logger.debug(`Удалено сообщений: ${ctx.deletedBusinessMessages.message_ids.length}`); + }); +} diff --git a/src/handlers/fallback.js b/src/handlers/fallback.js new file mode 100644 index 0000000..83978b3 --- /dev/null +++ b/src/handlers/fallback.js @@ -0,0 +1,32 @@ +import { logger } from '../core/logger.js'; + +/** + * Всё, что не команда. Сюда же вешается автоответчик, FAQ или переход в LLM. + * Вызывается последним в цепочке, поэтому next() уже не нужен. + */ +export function fallback() { + return async (ctx) => { + const text = ctx.msg?.text ?? ctx.msg?.caption ?? ''; + + if (ctx.business) { + logger.debug(`Бизнес-чат ${ctx.chat?.id}: сообщение без команды`); + + // Отметку «прочитано» ставим только если владелец выдал право читать чат. + if (ctx.business.rights?.can_read_messages && ctx.msgId) { + await ctx.api + .readBusinessMessage(ctx.business.connectionId, ctx.chat.id, ctx.msgId) + .catch((err) => logger.debug('readBusinessMessage не сработал', err.description ?? err)); + } + + await ctx.reply('Принял ваше сообщение, скоро ответим.'); + return; + } + + if (!text.startsWith('/')) { + await ctx.reply('Не понял. Посмотрите /help — там список команд.'); + return; + } + + await ctx.reply(`Команда ${text.split(/\s/)[0]} не найдена. Список — /help`); + }; +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..a11ca06 --- /dev/null +++ b/src/index.js @@ -0,0 +1,34 @@ +import { GrammyError } from 'grammy'; + +import { ALLOWED_UPDATES, createBot, shutdown, syncCommands } from './bot.js'; +import { logger } from './core/logger.js'; + +const { bot, registry } = await createBot(); + +for (const signal of ['SIGINT', 'SIGTERM']) { + process.once(signal, () => { + shutdown(bot).then( + () => process.exit(0), + (err) => { + logger.error('Ошибка при остановке', err); + process.exit(1); + }, + ); + }); +} + +try { + await bot.start({ + allowed_updates: ALLOWED_UPDATES, + // Первый апдейт приходит уже после getMe, поэтому меню обновляем здесь. + onStart: async ({ username }) => { + await syncCommands(bot, registry).catch((err) => logger.warn('setMyCommands не прошёл', err)); + logger.info(`@${username} запущен. Апдейты: ${ALLOWED_UPDATES.join(', ')}`); + }, + }); +} catch (err) { + // bot.catch() ловит ошибки апдейтов, но не падения на старте. + if (err instanceof GrammyError && err.error_code === 401) { + logger.error('Telegram не принял токен (401). Проверьте BOT_TOKEN в .env.'); + } else { + logger.error('Не удалось запустить б \ No newline at end of file diff --git a/src/middlewares/business.js b/src/middlewares/business.js new file mode 100644 index 0000000..0d03aa3 --- /dev/null +++ b/src/middlewares/business.js @@ -0,0 +1,76 @@ +import { isAdmin } from '../config.js'; +import { logger } from '../core/logger.js'; +import { connections, handovers } from '../core/store.js'; + +export const pauseKey = (connectionId, chatId) => `${connectionId}:${chatId}`; + +export const isPaused = (connectionId, chatId) => { + const until = handovers.get(pauseKey(connectionId, chatId)); + if (!until) return false; + if (until > Date.now()) return true; + handovers.delete(pauseKey(connectionId, chatId)); + return false; +}; + +export function saveConnection(connection) { + return connections.set(connection.id, { + ownerId: connection.user.id, + ownerUsername: connection.user.username ?? null, + userChatId: connection.user_chat_id, + isEnabled: connection.is_enabled ?? true, + rights: connection.rights ?? null, + updatedAt: Date.now(), + }); +} + +/** + * Кладёт в ctx.business всё, что нужно обработчикам: кто владелец аккаунта, + * пишет ли он сам прямо сейчас, какие права выданы боту. + * + * Данные о подключении приходят один раз в апдейте business_connection, + * поэтому храним их и при промахе кэша дотягиваем через getBusinessConnection. + */ +export function businessContext() { + return async (ctx, next) => { + const connectionId = ctx.businessConnectionId; + if (!connectionId) { + ctx.business = null; + return next(); + } + + let record = connections.get(connectionId); + if (!record) { + try { + record = saveConnection(await ctx.api.getBusinessConnection(connectionId)); + logger.info(`Подключение ${connectionId} восстановлено через getBusinessConnection`); + } catch (err) { + logger.error(`Не удалось получить подключение ${connectionId}`, err); + } + } + + const chatId = ctx.chat?.id; + ctx.business = { + connectionId, + ownerId: record?.ownerId ?? null, + userChatId: record?.userChatId ?? null, + rights: record?.rights ?? null, + isEnabled: record?.isEnabled ?? true, + canReply: record?.rights?.can_reply ?? true, + isOwner: Boolean(record?.ownerId && ctx.from?.id === record.ownerId), + isPaused: chatId != null ? isPaused(connectionId, chatId) : false, + pause(ms) { + if (chatId == null) return; + handovers.set(pauseKey(connectionId, chatId), Date.now() + ms); + }, + resume() { + if (chatId == null) return; + handovers.delete(pauseKey(connectionId, chatId)); + }, + }; + + return next(); + }; +} + +/** Владелец аккаунта или админ из ADMIN_IDS. */ +export const isOperator = (ctx) => ctx.business?.isOwner === true || isAdmin(ctx.from?.id); diff --git a/src/middlewares/handover.js b/src/middlewares/handover.js new file mode 100644 index 0000000..ab97426 --- /dev/null +++ b/src/middlewares/handover.js @@ -0,0 +1,39 @@ +import { config } from '../config.js'; +import { parseCommand } from '../core/dispatcher.js'; +import { logger } from '../core/logger.js'; + +/** + * Передача диалога человеку. + * + * В бизнес-чате апдейт business_message приходит и на сообщения клиента, + * и на собственные сообщения владельца. Если владелец ответил вручную — + * бот замолкает в этом чате на HANDOVER_PAUSE_MINUTES, чтобы клиент + * не получал два ответа сразу. Команды владельца при этом продолжают работать. + */ +export function handover() { + return async (ctx, next) => { + const business = ctx.business; + if (!business) return next(); + + const isCommand = Boolean(parseCommand(ctx)); + + if (business.isOwner) { + if (isCommand) return next(); + business.pause(config.handoverPauseMs); + logger.info(`Владелец отвечает сам в чате ${ctx.chat?.id} — пауза на ${config.handoverPauseMs / 60_000} мин`); + return; + } + + if (business.isPaused) { + logger.debug(`Чат ${ctx.chat?.id} на паузе — молчим`); + return; + } + + if (!business.canReply) { + logger.warn(`Нет права can_reply в подключении ${business.connectionId}`); + return; + } + + return next(); + }; +} diff --git a/test/dispatcher.test.js b/test/dispatcher.test.js new file mode 100644 index 0000000..d489713 --- /dev/null +++ b/test/dispatcher.test.js @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { createDispatcher, parseCommand } from '../src/core/dispatcher.js'; +import { CommandRegistry } from '../src/core/registry.js'; + +/** Telegram сам размечает только латинские команды — здесь это воспроизводим. */ +const entities = (text) => { + const match = /^\/[a-zA-Z0-9_@]+/.exec(text); + return match ? [{ type: 'bot_command', offset: 0, length: match[0].length }] : []; +}; + +const ctxFor = (text, extra = {}) => ({ + me: { username: 'MyBot' }, + from: { id: 1 }, + chat: { id: 1, type: 'private' }, + msg: { text, entities: entities(text), date: 0 }, + business: null, + ...extra, +}); + +describe('parseCommand', () => { + it('разбирает команду без аргументов', () => { + assert.deepEqual(parseCommand(ctxFor('/start')), { name: 'start', rest: '', args: [] }); + }); + + it('отделяет аргументы от имени', () => { + const parsed = parseCommand(ctxFor('/pause 60 now')); + assert.equal(parsed.name, 'pause'); + assert.deepEqual(parsed.args, ['60', 'now']); + assert.equal(parsed.rest, '60 now'); + }); + + it('понимает кириллический алиас без bot_command entity', () => { + assert.equal(parseCommand(ctxFor('/старт')).name, 'старт'); + }); + + it('принимает /cmd@своего_бота', () => { + assert.equal(parseCommand(ctxFor('/ping@mybot')).name, 'ping'); + }); + + it('игнорирует /cmd@чужого_бота', () => { + assert.equal(parseCommand(ctxFor('/ping@OtherBot')), null); + }); + + it('игнорирует обычный текст и команду не в начале строки', () => { + assert.equal(parseCommand(ctxFor('привет')), null); + assert.equal(parseCommand(ctxFor('см. /help')), null); + }); + + it('читает команду из подписи к медиа', () => { + const ctx = { me: { username: 'MyBot' }, msg: { caption: '/start', caption_entities: entities('/start') } }; + assert.equal(parseCommand(ctx).name, 'start'); + }); +}); + +describe('createDispatcher', () => { + const build = (definition) => { + const registry = new CommandRegistry(); + const calls = []; + registry.register({ handler: (ctx, args) => calls.push(args), ...definition }, 'test.js'); + return { dispatch: createDispatcher(registry), calls }; + }; + + const noop = async () => {}; + + it('вызывает обработчик и передаёт аргументы', async () => { + const { dispatch, calls } = build({ command: 'echo' }); + await dispatch(ctxFor('/echo раз два'), noop); + assert.deepEqual(calls, [['раз', 'два']]); + }); + + it('срабатывает на алиас', async () => { + const { dispatch, calls } = build({ command: 'help', aliases: ['помощь'] }); + await dispatch(ctxFor('/помощь'), noop); + assert.equal(calls.length, 1); + }); + + it('пропускает дальше неизвестную команду', async () => { + const { dispatch, calls } = build({ command: 'help' }); + let nexted = false; + await dispatch(ctxFor('/nope'), async () => { + nexted = true; + }); + assert.equal(calls.length, 0); + assert.ok(nexted); + }); + + it('не выполняет команду повторно при редактировании сообщения', async () => { + const { dispatch, calls } = build({ command: 'start' }); + await dispatch({ ...ctxFor('/start'), editedMessage: { text: '/start' } }, noop); + assert.equal(calls.length, 0); + }); + + it('scope business не работает в личке с ботом', async () => { + const { dispatch, calls } = build({ command: 'pause', scope: 'business', adminOnly: false }); + await dispatch(ctxFor('/pause'), noop); + assert.equal(calls.length, 0); + }); + + it('scope private не работает в бизнес-чате', async () => { + const { dispatch, calls } = build({ command: 'start', scope: 'private' }); + await dispatch(ctxFor('/start', { businessConnectionId: 'conn-1' }), noop); + assert.equal(calls.length, 0); + }); + + it('adminOnly пускает владельца бизнес-аккаунта и блокирует клиента', async () => { + const owner = build({ command: 'pause', scope: 'business', adminOnly: true }); + await owner.dispatch( + ctxFor('/pause', { businessConnectionId: 'conn-1', business: { isOwner: true } }), + noop, + ); + assert.equal(owner.calls.length, 1); + + const client = build({ command: 'pause', scope: 'business', adminOnly: true }); + await client.dispatch( + ctxFor('/pause', { businessConnectionId: 'conn-1', business: { isOwner: false } }), + noop, + ); + assert.equal(client.calls.length, 0); + }); +}); diff --git a/test/registry.test.js b/test/registry.test.js new file mode 100644 index 0000000..f91b267 --- /dev/null +++ b/test/registry.test.js @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { loadCommands } from '../src/core/loader.js'; +import { CommandRegistry } from '../src/core/registry.js'; + +const valid = { command: 'demo', handler: async () => {} }; + +describe('CommandRegistry', () => { + it('ругается на дубли имён и алиасов', () => { + const registry = new CommandRegistry(); + registry.register(valid, 'a.js'); + assert.throws(() => registry.register(valid, 'b.js'), /Конфликт триггеров/); + assert.throws(() => registry.register({ ...valid, command: 'other', aliases: ['demo'] }, 'c.js'), /Конфликт/); + }); + + it('проверяет обязательные поля', () => { + const registry = new CommandRegistry(); + assert.throws(() => registry.register({ handler: () => {} }, 'x.js'), /не задано поле "command"/); + assert.throws(() => registry.register({ command: 'a' }, 'x.js'), /"handler" должно быть функцией/); + assert.throws(() => registry.register({ ...valid, command: '/demo' }, 'x.js'), /без слэша/); + assert.throws(() => registry.register({ ...valid, scope: 'group' }, 'x.js'), /неизвестный scope/); + assert.throws(() => registry.register(null, 'x.js'), /объект по умолчанию/); + }); + + it('в меню Telegram не попадают скрытые, админские и нелатинские', () => { + const registry = new CommandRegistry(); + registry.register({ ...valid, description: 'ок' }, 'a.js'); + registry.register({ ...valid, command: 'secret', hidden: true }, 'b.js'); + registry.register({ ...valid, command: 'root', adminOnly: true }, 'c.js'); + registry.register({ ...valid, command: 'привет' }, 'd.js'); + + assert.deepEqual(registry.forTelegram(), [{ command: 'demo', description: 'ок' }]); + }); +}); + +describe('loadCommands', () => { + it('поднимает все команды проекта и пропускает файлы с _', async () => { + const registry = await loadCommands(); + const names = registry.list().map((cmd) => cmd.command); + + assert.ok(names.includes('start')); + assert.ok(names.includes('pause'), 'команды из подпапок тоже должны грузиться'); + assert.ok(!names.includes('template'), '_template.js не должен попадать в реестр'); + }); +});