first commit

This commit is contained in:
2026-08-09 16:53:38 +03:00
commit e92e0df644
28 changed files with 1143 additions and 0 deletions
+8
View File
@@ -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)"
]
}
}
+11
View File
@@ -0,0 +1,11 @@
# Токен от @BotFather
BOT_TOKEN=123456:AA...
# ID администраторов через запятую (узнать свой: команда /id)
ADMIN_IDS=
# Сколько минут молчать в чате после того, как владелец ответил вручную
HANDOVER_PAUSE_MINUTES=30
# debug | info | warn | error
LOG_LEVEL=info
+4
View File
@@ -0,0 +1,4 @@
node_modules/
.env
data/
*.log
+123
View File
@@ -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"
}
}
}
}
+20
View File
@@ -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"
}
}
+20
View File
@@ -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(' ') || '—'}`);
+18
View File
@@ -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}`);
+63
View File
@@ -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('Состояние сохранено.');
}
+26
View File
@@ -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<void>
*/
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}")`);
},
};
+28
View File
@@ -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`);
},
};
+40
View File
@@ -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 [
`<b>Подключение</b> <code>${id}</code>`,
`владелец: <code>${record.ownerId}</code>${record.ownerUsername ? ` (@${record.ownerUsername})` : ''}`,
`активно: ${record.isEnabled ? 'да' : 'нет'}`,
`права: ${rights.length ? rights.join(', ') : 'не переданы'}`,
].join('\n');
});
await ctx.reply(blocks.join('\n\n'), { parse_mode: 'HTML' });
},
};
+23
View File
@@ -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')}` : 'Команд пока нет.');
},
};
+23
View File
@@ -0,0 +1,23 @@
export default {
command: 'id',
aliases: ['whoami'],
description: 'Показать ID пользователя и чата',
scope: 'all',
async handler(ctx) {
const lines = [
`Ваш ID: <code>${ctx.from?.id}</code>`,
`Чат: <code>${ctx.chat?.id}</code> (${ctx.chat?.type})`,
];
if (ctx.business) {
lines.push(
`Подключение: <code>${ctx.business.connectionId}</code>`,
`Владелец: <code>${ctx.business.ownerId ?? 'неизвестен'}</code>`,
`Вы владелец: ${ctx.business.isOwner ? 'да' : 'нет'}`,
);
}
await ctx.reply(lines.join('\n'), { parse_mode: 'HTML' });
},
};
+17
View File
@@ -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'));
},
};
+28
View File
@@ -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}`,
);
},
};
+26
View File
@@ -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);
+68
View File
@@ -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 });
};
}
+32
View File
@@ -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;
}
+16
View File
@@ -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),
};
+84
View File
@@ -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<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,
};
}
+83
View File
@@ -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');
+33
View File
@@ -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}`);
});
}
+32
View File
@@ -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`);
};
}
+34
View File
@@ -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('Не удалось запустить б
+76
View File
@@ -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);
+39
View File
@@ -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();
};
}
+122
View File
@@ -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);
});
});
+46
View File
@@ -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 не должен попадать в реестр');
});
});