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
+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();
};
}