first commit
This commit is contained in:
@@ -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)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Токен от @BotFather
|
||||||
|
BOT_TOKEN=123456:AA...
|
||||||
|
|
||||||
|
# ID администраторов через запятую (узнать свой: команда /id)
|
||||||
|
ADMIN_IDS=
|
||||||
|
|
||||||
|
# Сколько минут молчать в чате после того, как владелец ответил вручную
|
||||||
|
HANDOVER_PAUSE_MINUTES=30
|
||||||
|
|
||||||
|
# debug | info | warn | error
|
||||||
|
LOG_LEVEL=info
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
data/
|
||||||
|
*.log
|
||||||
Generated
+123
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(' ') || '—'}`);
|
||||||
@@ -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
@@ -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('Состояние сохранено.');
|
||||||
|
}
|
||||||
@@ -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}")`);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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`);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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' });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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')}` : 'Команд пока нет.');
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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' });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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'));
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
@@ -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 });
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
@@ -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}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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`);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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('Не удалось запустить б�
|
||||||
@@ -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);
|
||||||
@@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 не должен попадать в реестр');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user