messenger-bot-adapters
v1.0.4
Published
Platform-agnostic bot adapters for MAX, Telegram and VK
Readme
messenger-bot-adapters
Единый интерфейс для ботов в Telegram, MAX и VK. Пишете логику один раз — адаптер прозрачно транслирует вызовы в API нужного мессенджера.
Установка
npm install messenger-bot-adaptersSDK платформы загружается лениво — только при создании нужного адаптера. Установите SDK только той платформы, которую используете:
| Платформа | Доп. зависимость |
|---|---|
| Telegram | не нужна — работает через REST API |
| MAX | npm install @maxhub/max-bot-api (>= 0.2.2) |
| VK | npm install vk-io (>= 4.10.1) |
Быстрый старт
import { TelegramBotAdapter } from 'messenger-bot-adapters';
const adapter = new TelegramBotAdapter(process.env.BOT_TOKEN!);
adapter.onMessage((msg) => {
adapter.sendMessage(msg.chatId, `Привет! Вы написали: ${msg.text}`);
});
await adapter.startPolling();Создание адаптера
import {
TelegramBotAdapter,
MaxBotAdapter,
VkBotAdapter,
} from 'messenger-bot-adapters';
import type { IBotAdapter } from 'messenger-bot-adapters';
function createAdapter(): IBotAdapter {
const token = process.env.BOT_TOKEN!;
const webhookSecret = process.env.BOT_WEBHOOK_SECRET;
switch (process.env.MESSENGER_PLATFORM) {
case 'telegram':
return new TelegramBotAdapter(token, { webhookSecret });
case 'vk':
return new VkBotAdapter(token, { webhookSecret });
default:
return new MaxBotAdapter(token, { webhookSecret });
}
}Параметры конструкторов
Все три адаптера принимают одинаковый набор параметров:
| Параметр | Тип | Обязательный | Описание |
|---|---|---|---|
| token | string | да | Токен бота |
| options.webhookSecret | string | нет | Секрет для проверки подписи входящих вебхуков |
TelegramBotAdapter принимает дополнительный параметр:
| Параметр | Тип | Обязательный | Описание |
|---|---|---|---|
| options.getMessages | (chatId: number, opts?: GetMessagesOptions) => ServiceMessage[] | нет | Кастомный источник истории сообщений для getMessages(). Если не передан, метод возвращает []. |
Обработчики событий
onMessage
Вызывается при каждом входящем сообщении от пользователя или бота.
adapter.onMessage((msg) => {
if (msg.isBot) return; // игнорируем сообщения от самого бота
console.log(msg.chatId, msg.text);
if (msg.attachments?.length) {
for (const att of msg.attachments) {
console.log(att.type, att.filename, att.size);
}
}
if (msg.contact) {
console.log(msg.contact.name, msg.contact.phone);
}
});ServiceMessage
| Поле | Тип | Описание |
|---|---|---|
| messageId | string | Идентификатор сообщения |
| chatId | number | Идентификатор чата |
| text | string \| undefined | Текст сообщения |
| contact | { name: string; phone: string } \| undefined | Контакт, если пользователь поделился им. Заполняется в Telegram и MAX; в VK всегда undefined. |
| attachments | ServiceAttachment[] \| undefined | Вложения (фото, видео, документы, аудио) |
| timestamp | number | Время сообщения (unix ms) |
| senderName | string \| undefined | Имя отправителя. В VK не заполняется. |
| isBot | boolean \| undefined | true, если сообщение отправлено самим ботом |
| agentId | string \| undefined | Идентификатор агента поддержки, приславшего сообщение |
ServiceAttachment
| Поле | Тип | Описание |
|---|---|---|
| type | 'image' \| 'video' \| 'audio' \| 'file' | Тип вложения |
| payload | Record<string, unknown> | Платформенный объект вложения в исходном виде |
| filename | string \| undefined | Имя файла |
| size | number \| undefined | Размер в байтах |
onStart
Вызывается при старте бота пользователем (команда /start или переход по deeplink).
Deeplink-ссылки с токеном:
| Платформа | Формат ссылки |
|---|---|
| Telegram | https://t.me/mybot?start=TOKEN |
| MAX | https://max.ru/join/mybot?payload=token%3DTOKEN |
| VK | https://vk.com/mygroup?ref=TOKEN |
adapter.onStart(async (event, token) => {
if (token) {
// Пользователь перешёл по deeplink — token содержит переданное значение
const result = await verifyToken(token, event.userId, event.chatId);
if (result.ok) {
await adapter.sendMessage(event.chatId, 'Вы успешно подписались на уведомления.');
} else {
await adapter.sendMessage(event.chatId, 'Ошибка при подписке.');
}
} else {
// Обычный /start без токена
await adapter.sendMessage(event.chatId, 'Добро пожаловать!');
}
});Параметры обработчика
| Параметр | Тип | Описание |
|---|---|---|
| event.chatId | number | Идентификатор чата |
| event.userId | number | Идентификатор пользователя |
| token | string \| null | Значение из deeplink-параметра; null при обычном /start |
onCallback
Вызывается при нажатии inline-кнопки (созданной через options.buttons в sendMessage).
adapter.onCallback(async (event) => {
if (event.data === 'yes') {
await adapter.sendMessage(event.chatId, 'Вы выбрали «Да»');
}
});CallbackEvent
| Поле | Тип | Описание |
|---|---|---|
| chatId | number | Идентификатор чата |
| userId | number | Идентификатор пользователя, нажавшего кнопку |
| data | string | Значение поля data кнопки |
Методы
sendMessage
sendMessage(chatId: number, text: string, options?: SendOptions): Promise<ServiceMessage>| Параметр | Тип | Обязательный | Описание |
|---|---|---|---|
| chatId | number | да | Идентификатор чата |
| text | string | да | Текст сообщения |
| options | SendOptions | нет | Дополнительные параметры |
SendOptions
| Поле | Тип | Описание |
|---|---|---|
| format | 'html' | HTML-разметка текста. Поддерживается в Telegram и MAX; в VK игнорируется. |
| buttons | Array<Array<{ text: string; data: string }>> | Inline-кнопки. Внешний массив — строки кнопок, внутренний — кнопки в строке. Нажатие попадает в onCallback. |
| replyKeyboard | Array<Array<string \| { label: string; requestContact: true }>> | Клавиатура быстрых ответов под полем ввода. Строка — текстовая кнопка, объект с requestContact: true — кнопка запроса контакта (в VK работает как текстовая). |
| removeReplyKeyboard | boolean | Убрать ранее показанную клавиатуру быстрых ответов. В MAX не поддерживается. |
| links | Array<Array<{ text: string; url: string }>> | Ссылки под сообщением. Та же структура строк, что и у buttons. |
| attachments | ServiceAttachment[] | Вложения для отправки. Получите объект через uploadFile, затем передайте сюда. |
Примеры
// Простой текст
await adapter.sendMessage(chatId, 'Привет!');
// HTML
await adapter.sendMessage(chatId, '<b>Жирный</b> и <i>курсив</i>', { format: 'html' });
// Inline-кнопки
await adapter.sendMessage(chatId, 'Продолжить поиск?', {
buttons: [
[{ text: 'Да, ищем дальше', data: 'search_again' }],
[{ text: 'Нет, выйти', data: 'exit' }],
],
});
// Несколько кнопок в одной строке
await adapter.sendMessage(chatId, 'Выберите предмет:', {
buttons: [[
{ text: 'Математика', data: 'subject:math' },
{ text: 'Физика', data: 'subject:physics' },
]],
});
// Клавиатура быстрых ответов
await adapter.sendMessage(chatId, 'Главное меню:', {
replyKeyboard: [
['Найти репетитора'],
['Настройки', 'Помощь'],
],
});
// Запрос контакта
await adapter.sendMessage(chatId, 'Поделитесь контактом:', {
replyKeyboard: [[{ label: 'Поделиться контактом', requestContact: true }]],
});
// Убрать клавиатуру
await adapter.sendMessage(chatId, 'Готово.', { removeReplyKeyboard: true });
// Ссылка под сообщением
await adapter.sendMessage(chatId, 'Заявка создана:', {
links: [[{ text: 'Личный кабинет', url: 'https://example.com/client/home' }]],
});
// Отправка файла
const attachment = await adapter.uploadFile(buffer, 'application/pdf', 'document.pdf', chatId);
await adapter.sendMessage(chatId, 'Держите документ:', { attachments: [attachment] });uploadFile
Загружает файл на серверы платформы и возвращает объект вложения для использования в sendMessage.
uploadFile(file: Buffer, mimetype: string, filename: string, chatId?: number): Promise<ServiceAttachment>| Параметр | Тип | Обязательный | Описание |
|---|---|---|---|
| file | Buffer | да | Содержимое файла |
| mimetype | string | да | MIME-тип файла, например image/jpeg, application/pdf |
| filename | string | да | Имя файла с расширением |
| chatId | number | VK: да, остальные: нет | Идентификатор чата, к которому привязывается загрузка. В VK обязателен — без него загрузка завершится ошибкой. |
getMessages
Возвращает историю сообщений чата.
getMessages(chatId: number, options?: GetMessagesOptions): Promise<ServiceMessage[]>| Параметр | Тип | Обязательный | Описание |
|---|---|---|---|
| chatId | number | да | Идентификатор чата |
| options | GetMessagesOptions | нет | Параметры выборки |
GetMessagesOptions
| Поле | Тип | Описание | Поддержка |
|---|---|---|---|
| from | number | Начало диапазона (unix ms) | MAX |
| to | number | Конец диапазона (unix ms) | MAX |
| count | number | Максимальное количество сообщений | MAX, VK (по умолчанию 200) |
Для Telegram
getMessagesделегируется вoptions.getMessages, переданный в конструктор. Если параметр не передан, метод всегда возвращает[].
// Последние 50 сообщений
const messages = await adapter.getMessages(chatId, { count: 50 });
// Сообщения в диапазоне времени
const messages = await adapter.getMessages(chatId, { from: startTs, to: endTs });registerCommands
Регистрирует команды бота в меню платформы.
registerCommands(commands: Array<{ name: string; description: string }>): Promise<void>| Параметр | Тип | Описание |
|---|---|---|
| commands[].name | string | Имя команды без / |
| commands[].description | string | Описание команды, видимое пользователю |
В Telegram и MAX команда
startдобавляется автоматически. В VK метод является no-op — команды не регистрируются.
await adapter.registerCommands([
{ name: 'help', description: 'Справка' },
{ name: 'settings', description: 'Настройки' },
]);setWebhook
Регистрирует URL вебхука в платформе.
setWebhook(url: string, secret?: string): Promise<void>| Параметр | Тип | Обязательный | Описание |
|---|---|---|---|
| url | string | да | Публичный HTTPS-URL, на который платформа будет отправлять обновления |
| secret | string | нет | Секрет для подписи запросов. Должен совпадать с webhookSecret, переданным в конструктор. |
deleteWebhook
Удаляет зарегистрированный вебхук. Вызывайте перед startPolling, чтобы сбросить ранее установленный вебхук.
deleteWebhook(): Promise<void>startPolling
Запускает получение обновлений через long-polling. Используйте в разработке; в продакшне предпочтителен вебхук.
startPolling(): Promise<void>processWebhook
Обрабатывает входящий POST-запрос от платформы: проверяет подпись, запускает обработчики событий и возвращает строку-ответ.
processWebhook(body: unknown, headers: Record<string, string | string[] | undefined>): string | false| Параметр | Тип | Описание |
|---|---|---|
| body | unknown | Разобранное тело запроса |
| headers | Record<string, string \| string[] \| undefined> | Заголовки запроса |
Возвращаемое значение
| Значение | Когда | Что делать |
|---|---|---|
| 'ok' | Telegram, MAX — любое валидное обновление | Вернуть 200 OK с телом 'ok' |
| 'ok' | VK — любое событие кроме confirmation | Вернуть 200 OK с телом 'ok' |
| confirmationCode | VK — событие confirmation при регистрации вебхука | Вернуть 200 OK с этой строкой в теле; VK убедится, что URL принадлежит вам |
| false | Подпись не прошла проверку (любая платформа) | Вернуть 401 Unauthorized |
Код подтверждения VK получается автоматически при вызове setWebhook и кешируется внутри адаптера — вручную его передавать не нужно.
app.post('/webhook', (req, res) => {
const result = adapter.processWebhook(req.body, req.headers);
if (result === false) {
res.status(401).json({ ok: false, error: 'Unauthorized' });
return;
}
res.status(200).send(result);
});Webhook и Polling
Polling (разработка)
await adapter.registerCommands([{ name: 'help', description: 'Справка' }]);
try {
await adapter.deleteWebhook();
} catch (err) {
console.warn('deleteWebhook failed:', err);
}
await adapter.startPolling();Webhook (продакшн)
import express from 'express';
await adapter.registerCommands([{ name: 'help', description: 'Справка' }]);
await adapter.setWebhook('https://my-service.example.com/webhook', process.env.BOT_WEBHOOK_SECRET);
const app = express();
app.use(express.json());
app.post('/webhook', (req, res) => {
const result = adapter.processWebhook(req.body, req.headers);
if (result === false) {
res.status(401).json({ ok: false, error: 'Unauthorized' });
return;
}
res.status(200).send(result);
});
app.listen(3000);Обработка ошибок
Все методы могут бросить BotApiError при ошибках платформенного API.
Поля BotApiError
| Поле | Тип | Описание |
|---|---|---|
| code | BotApiErrorCode | Нормализованный код ошибки |
| platformCode | string \| undefined | Оригинальный код ошибки платформы: HTTP-статус у Telegram, код из тела ответа у MAX, числовой код ошибки VK API у VK |
| message | string | Текст ошибки |
BotApiErrorCode
| Код | Описание |
|---|---|
| bot_blocked | Пользователь остановил или заблокировал бота |
| chat_not_found | Чат не найден |
| unsupported_feature | Платформа не поддерживает запрошенную функцию |
| unknown_error | Неожиданная ошибка платформы |
import { BotApiError } from 'messenger-bot-adapters';
try {
await adapter.sendMessage(chatId, 'Уведомление');
} catch (err) {
if (err instanceof BotApiError) {
switch (err.code) {
case 'bot_blocked':
await markChatAsInactive(chatId);
break;
case 'chat_not_found':
await removeChat(chatId);
break;
case 'unknown_error':
console.error('Bot API error:', err.message, 'platformCode:', err.platformCode);
break;
}
} else {
throw err;
}
}Своя реализация адаптера
Чтобы добавить поддержку нового мессенджера, унаследуйтесь от BaseAdapter:
import { BaseAdapter } from 'messenger-bot-adapters';
import type {
ServiceMessage, ServiceAttachment, SendOptions,
GetMessagesOptions, BotStartedEvent, CallbackEvent,
} from 'messenger-bot-adapters';
export class MyAdapter extends BaseAdapter {
onMessage(handler: (msg: ServiceMessage) => void): void { /* ... */ }
onStart(handler: (event: BotStartedEvent, token: string | null) => void): void { /* ... */ }
onCallback(handler: (event: CallbackEvent) => void): void { /* ... */ }
async sendMessage(chatId: number, text: string, options?: SendOptions): Promise<ServiceMessage> { /* ... */ }
async getMessages(chatId: number, options?: GetMessagesOptions): Promise<ServiceMessage[]> { /* ... */ }
async uploadFile(file: Buffer, mimetype: string, filename: string, chatId?: number): Promise<ServiceAttachment> { /* ... */ }
async registerCommands(commands: Array<{ name: string; description: string }>): Promise<void> { /* ... */ }
async setWebhook(url: string, secret?: string): Promise<void> { /* ... */ }
async deleteWebhook(): Promise<void> { /* ... */ }
async startPolling(): Promise<void> { /* ... */ }
protected verifyWebhookSignature(
headers: Record<string, string | string[] | undefined>,
body?: unknown,
): boolean { /* ... */ }
protected handleWebhookUpdate(body: unknown): void { /* ... */ }
}BaseAdapter предоставляет готовую реализацию processWebhook и утилиту extractToken(payload) для разбора deeplink-токенов.
Различия платформ
| | Telegram | MAX | VK |
|---|---|---|---|
| msg.senderName | first_name + last_name | имя из профиля | не заполняется |
| msg.contact | при шаринге контакта | при шаринге контакта (из VCF) | никогда не заполняется |
| replyKeyboard | нативная | эмулируется inline callback-кнопками | нативная |
| requestContact | нативный шаринг | нативный шаринг | текстовая кнопка |
| removeReplyKeyboard | ✅ | — | ✅ |
| format: 'html' | ✅ | ✅ | игнорируется |
| registerCommands | регистрирует start + переданные | регистрирует start + переданные | no-op |
| getMessages | через options.getMessages; [] если не передан | платформенный API (from, to, count) | платформенный API (только count, по умолчанию 200) |
| uploadFile — chatId | необязателен | необязателен | обязателен |
| Проверка подписи webhook | заголовок x-telegram-bot-api-secret-token | заголовок x-max-bot-api-secret | поле body.secret |
| deeplink-токен в onStart | параметр /start TOKEN | поле payload события bot_started | referralValue (ref в ссылке) |
Требования
- Node.js >= 20
- TypeScript >= 5 (опционально, но рекомендуется)
Лицензия
MIT
