Адаптер платформы — это мост между сырым JSON/XML запросом от внешней платформы и унифицированным контроллером
BotController. Ваша задача: распарсить входящие данные, наполнить контроллер, обработать UI-компоненты (кнопки,
картинки, звуки) и сформировать ответ строго по контракту конкретной платформы.
Адаптер наследуется от базового класса BasePlatformAdapter<TQuery> из umbot/plugins (в исходниках фреймворка
класс называется BasePlatform — BasePlatformAdapter это его публичный алиас при реэкспорте). Для примеров ниже
подключите всё необходимое одним блоком:
import { BasePlatformAdapter, TContent } from 'umbot/plugins';
import { BotController, Text } from 'umbot'; // BotController и Text экспортируются из корня 'umbot'
Когда на сервер приходит запрос, фреймворк перебирает все подключенные адаптеры и спрашивает: «Это твой запрос?».
Вы должны реализовать метод, который по заголовкам или по телу запроса понимает, относится ли он к вашей платформе.
Пример: Платформа WeChat отправляет специфичный заголовок x-wechat-signature и XML в теле. Telegram отправляет заголовок x-telegram-bot-api-secret-token.
isPlatformOnQuery(query: unknown, headers?: Record<string, unknown>): boolean {
const q = query as Record<string, unknown>;
// 1. Проверяем заголовки (самый надежный способ)
if (headers?.['x-wechat-signature']) return true;
// 2. Фолбэк: проверяем уникальные поля в теле запроса
return !!(q.xml_msg || q.specific_wechat_field);
}
Если платформа требует проверки подписи (токена), переопределяйте этот метод. По умолчанию BasePlatformAdapter умеет
проверять HMAC SHA256, но только если оба параметра заданы:
signatureName — имя поля в заголовке запросаtoken — секретный токен в конфигурацииЕсли хотя бы один из параметров не задан, isCorrectQuery() вернет true (проверка будет пропущена).
Если стандартной проверки недостаточно (например, платформа использует Ed25519 вместо HMAC SHA256), переопределите метод
isCorrectQuery и реализуйте свою логику валидации.
Примечание: Если вы получаете ошибки при проверке подписи, убедитесь, что:
signatureName установлено в классе адаптераappContext.appConfig.tokens[this.platformName].tokenЗадача: Взять сырой query и заполнить поля controller. От того, как вы заполните контроллер, зависит корректная
работа бизнес-логики приложения
Обязательные поля для заполнения:
controller.userId (string | number) — уникальный ID пользователя.controller.userCommand (string) — текст команды в нижнем регистре (нужно для поиска команд).controller.originalUserCommand (string) — оригинальный текст как есть.controller.messageId (number | string | null) — ID сообщения (нужно для определения начала диалога).controller.appType = this.platformName — тип платформы: по нему ядро находит адаптер в реестре.Опциональные, но важные поля:
controller.nlu.setNlu(...) — если платформа присылает NLU/интенты.controller.userMeta — метаданные (например, есть ли у юзера экран).controller.payload — дополнительные данные (например, нажатая кнопка).Нюанс messageId: начало диалога определяется строго по messageId === 0 — адаптер обязан выставлять 0 для первого сообщения диалога (иначе welcome-интент не сработает).
setQueryData(query: unknown, controller: BotController): boolean {
const q = query as Record<string, unknown>;
if (!q) {
controller.platformOptions.error = 'Пустой запрос';
return false;
}
controller.requestObject = query; // Сохраняем оригинал
controller.appType = this.platformName; // Обязательно: ядро ищет адаптер по этому полю
controller.userId = q.user_id as string | number;
controller.userCommand = ((q.text as string) || '').toLowerCase().trim();
controller.originalUserCommand = (q.text as string) || '';
controller.messageId = q.message_id as string | number;
// Если платформа присылает данные о юзере
if (q.user) {
controller.nlu.setNlu({
thisUser: { username: (q.user as Record<string, unknown>).name as string },
});
}
return true;
}
Фреймворк оперирует абстракциями (IButtonType, ICardInfo). Платформы требуют специфичные форматы. Чтобы превратить
абстракцию в формат платформы, используются функции-процессоры.
Вам нужно написать функцию, которая принимает массив абстрактных кнопок и возвращает объект, понятный платформе.
Метод controller.buttons.getButtons(ваш_процессор) сам вызовет вашу функцию и отдаст результат.
Результат может быть null (пустой список кнопок) — учитывайте это при формировании ответа.
// IButtonType экспортируется из корня 'umbot'
// 1. Пишем процессор
function myPlatformButtonProcessing(buttons: IButtonType[]): MyPlatformKeyboard {
return {
inline_keyboard: buttons.map((btn) => ({
text: btn.title,
callback_data: btn.payload ? JSON.stringify(btn.payload) : btn.title,
})),
};
}
// 2. Вызываем внутри getContent (результат может быть null, если кнопок нет)
const keyboard = controller.buttons.getButtons(myPlatformButtonProcessing);
Важно: getImageToken и getSoundToken находятся в pUtils, который экспортируется из umbot/plugins:
import { pUtils } from 'umbot/plugins';
import { ImageTokens, BotController } from 'umbot';
import { MyPlatformApi } from './MyPlatformApi';
async function myPlatformCardProcessing(cardInfo: ICardInfo, controller: BotController) {
const elements = [];
for (const image of cardInfo.images) {
// Если токена еще нет, загружаем его
if (!image.imageToken && image.imageDir) {
image.imageToken = await pUtils.getImageToken(
image.imageDir,
'my_platform', // имя платформы
controller,
async (model: ImageTokens) => {
// 1. Загружаем файл в API платформы
const api = new MyPlatformApi(controller.appContext);
const uploadResult = await api.uploadImage(image.imageDir);
if (uploadResult?.id) {
// 2. Сохраняем токен в модель
model.imageToken = uploadResult.id;
// 3. Сохраняем модель в БД (чтобы в следующий раз не грузить заново)
if (await model.save(true)) {
return model.imageToken;
}
}
return null;
},
);
}
if (image.imageToken) {
elements.push({
type: 'image',
photo_id: image.imageToken,
title: image.title,
description: image.desc,
});
}
}
return elements;
}
Важно: Callback функция (четвертый параметр getImageToken) вызывается ТОЛЬКО при cache miss, то есть когда:
!image.imageToken)Если токен уже существует и валиден, callback не вызывается - используется кэшированное значение. Это позволяет избежать лишних сетевых запросов и ускорить работу приложения.
Аналогично изображениям, используется утилита getSoundToken и модель SoundTokens.
import { pUtils } from 'umbot/plugins';
import { SoundTokens } from 'umbot';
// Внутри процессора звуков:
const audioToken = await pUtils.getSoundToken(
path,
'my_platform',
controller,
async (model: SoundTokens) => {
const api = new MyPlatformApi(controller.appContext);
const res = await api.uploadAudio(path);
if (res?.id) {
model.soundToken = res.id;
if (await model.save(true)) return model.soundToken;
}
return null;
},
);
pUtils)Помимо getImageToken/getSoundToken, в pUtils (экспорт из umbot/plugins) есть хелперы, которые
используют встроенные адаптеры — их стоит переиспользовать и в кастомных:
getChatText(text, tts) — текст ответа для чат-платформ: при пустом text возвращает tts без разметки звуков;tryParse<T>(raw) — безопасный разбор JSON-строки payload (null при невалидном);normalizeActionPayload(payload) — приводит payload кнопки ('buy' или {"command":"buy"}) к строке buy для userCommand;hasAnyNluKey(nlu) — проверяет, есть ли в объекте NLU хоть какие-то данные (медиа/сущности/интенты);setThisUserToNlu(controller, thisUser) — заполняет сущность thisUser (данные об отправителе) в NLU контроллера;telegramMessageEvent(message) / viberMessageEvent(type) — сопоставляют тип входящего апдейта с универсальным TEventType;getCorrectButtons(buttons, limit) — обрезает массив кнопок до лимита платформы (дефолт 10).Полный список с сигнатурами — в типах src/plugins/platforms/Base/utils.ts (JSDoc каждого хелпера).
import { pUtils } from 'umbot/plugins';
// Внутри setQueryData при разборе апдейта:
controller.userCommand = pUtils.normalizeActionPayload(payload).toLowerCase();
Некоторые платформы (Алиса, SmartApp) умеют хранить состояние диалога на своей стороне. Это позволяет не делать лишних запросов в БД.
Чтобы поддержать это, нужно реализовать 3 метода:
isLocalStorage(controller) — возвращает true, если платформа поддерживает локальное хранилище.getLocalStorage(controller) — возвращает данные, которые платформа прислала в запросе (обычно лежат в
controller.state).setLocalStorage(data, controller) — вызывается фреймворком, если нужно сохранить данные на стороне платформы (если
платформа не делает это автоматически через ответ).Нюанс: В setQueryData вы должны указать, в какое поле ответа класть стейт, заполнив
controller.platformOptions.stateName (например, 'session_state' или 'user_state_update').
Задача: Собрать финальный ответ согласно контракту платформы.
Метод принимает controller (со всей бизнес-логикой, текстом, кнопками) и stateData (данные для локального
хранилища).
Здесь есть две парадигмы ответов:
Парадигма А: Webhook-Response (Алиса, SmartApp) Платформа ждет JSON в теле HTTP-ответа.
async getContent(controller: BotController, stateData?: Record<string, unknown>): Promise<object> {
// 1. Собираем UI через наши процессоры
const buttons = controller.buttons.getButtons(myPlatformButtonProcessing);
const cards = await controller.card.getCards(myPlatformCardProcessing, controller);
// 2. Формируем ответ
const response = {
text: Text.resize(controller.text, 1024), // ОБЯЗАТЕЛЬНО режьте текст по лимитам!
tts: controller.tts,
buttons: buttons,
card: cards,
end_session: controller.isEnd
};
// 3. Добавляем состояние (если платформа его поддерживает)
if (controller.platformOptions.stateName && stateData) {
response[controller.platformOptions.stateName] = stateData;
}
return response;
}
Парадигма Б: API-Call (Telegram, VK, Max) Платформа ждет, что вы сами отправите ответ через её API, а вебхуку нужно просто вернуть 200 OK.
Флаг controller.skipAutoReply — это сигнал для ядра: «запрос уже отвечен или не требует ответа».
Его выставляет адаптер (обычно в setQueryData) или middleware, когда запрос обработан без бизнес-логики —
например, неизвестное событие платформы, на которое нельзя ответить. getContent лишь учитывает флаг:
видит его и не отправляет сообщение через API, а возвращает ядру нейтральное тело для вебхука.
Ядро в любом случае отвечает на вебхук HTTP 200 — это важно: на 5xx Telegram реплеит апдейт бесконечно,
а VK отключает сервер.
Неожиданные события платформы, которые ваш адаптер не может обработать, помечайте именно skipAutoReply = true
в setQueryData (с return true), а не return false — второй вариант приведет к HTTP 400.
async getContent(controller: BotController): Promise<string> {
// 1. Запрос не требует автоответа? Просто возвращаем заглушку для вебхука.
if (controller.skipAutoReply) {
return 'ok';
}
const api = new MyPlatformApi(controller.appContext);
// Собираем все UI-компоненты
const keyboard = controller.buttons.getButtons(myPlatformButtonProcessing);
const attachments = await controller.card.getCards(myPlatformCardProcessing, controller);
const sounds = await controller.sound.getSounds(controller.tts, mySoundProcessing, controller);
// Передаем их в API платформы (формат зависит от самой платформы)
await api.sendMessage(controller.userId, Text.resize(controller.text, 4096), {
keyboard,
attachments, // Пример для Discord/VK
audio: sounds // Пример
});
// 2. Возвращаем заглушку для вебхука
return 'ok';
}
Возвращаемое значение из getContent пойдет в тело HTTP-ответа на вебхук. Если платформа требует специфичный JSON-ответ на сам факт получения вебхука (даже если вы уже отправили сообщение через API) — верните этот JSON. Если платформа принимает любой статус 200 OK — просто верните строку 'ok' или пустой объект.
Для локального тестирования через BotTest определите метод getQueryExample.
Этот метод эмулирует запрос от платформы, позволяя проверить работу приложения до деплоя.
В базовом классе есть generic-заглушка, но для тестируемого адаптера метод обязателен:
без переопределения BotTest.simulate() не сможет сгенерировать валидный payload вашей платформы.
Важно: Формат возвращаемого объекта должен точно соответствовать структуре запроса,
которую вы парсите в setQueryData.
// Для тестирования через BotTest
getQueryExample(
query: string,
userId: string,
count: number,
state: Record<string, unknown> | string,
): Record<string, unknown> {
// Возвращаем объект в формате ВАШЕЙ платформы
// Этот же формат будет парситься в setQueryData
return {
message: {
sender: { user_id: userId },
body: {
text: query,
seq: count,
},
},
state: state,
};
}
sendInInit)Некоторые платформы присылают служебные запросы, на которые нужно ответить заготовленным ответом, не проходя бизнес-логику приложения:
ping для проверки доступности навыка;confirmation — нужно
вернуть строку-подтверждение;Чтобы не запускать middleware/commands/action для таких запросов, в setQueryData
установите controller.platformOptions.sendInInit — фреймворк проверит это поле
сразу после setQueryData и, если оно заполнено, вернёт его как ответ,
пропустив всю дальнейшую обработку.
setQueryData(query, controller) {
// ... обычная обработка ...
// Яндекс прислал ping?
if (query.request.original_utterance === 'ping') {
controller.platformOptions.sendInInit = {
version: '1.0',
response: { text: 'pong' },
};
// Важно вернуть true: при false ядро ответит на вебхук HTTP 400,
// а Telegram по 4xx/5xx бесконечно реплеит апдейт, VK — отключает сервер.
return true;
}
return true;
}
Формат значения sendInInit: string | object | null:
Если у платформы есть жесткий лимит запросов в секунду (например, 30 req/sec у Telegram/Max), укажите это в классе
адаптера. Значение limit читает встроенный middleware rateLimiter.
export class MyPlatformAdapter extends BasePlatformAdapter {
limit = 30; // Сообщаем фреймворку о лимите
}
Само по себе поле limit ничего не ограничивает — необходимо явно подключить middleware rateLimiter:
import { Bot } from 'umbot';
import { rateLimiter } from 'umbot/middleware';
import { TelegramAdapter } from 'umbot/plugins';
const bot = new Bot();
bot.use(new TelegramAdapter('YOUR_TOKEN'));
bot.use(rateLimiter());
Только после этого фреймворк будет использовать значение limit из адаптера для ограничения количества запросов.
Голосовые платформы жестко ограничивают время ответа: фреймворк ориентируется на пороги WARNING_TIME_REQUEST = 2000 мс
(предупреждение) и MAX_TIME_REQUEST = 2900 мс (ошибка) — у Алисы лимит около 3 секунд, у других платформ он отличается.
Проверка не выполняется автоматически: в своём getContent вызовите this._timeLimitLog(controller) после формирования
ответа. Так поступают встроенные голосовые адаптеры (Alisa, Marusia, SmartApp); адаптеры чат-платформ его не вызывают.
Без вызова медленные ответы не попадут в логи. Пороги можно переопределить
в наследнике. И главное — не делайте тяжелых синхронных операций внутри getContent.