umbot
    Preparing search index...

    Создание адаптера платформы (Platform Adapter)

    Адаптер платформы — это мост между сырым JSON/XML запросом от внешней платформы и унифицированным контроллером BotController. Ваша задача: распарсить входящие данные, наполнить контроллер, обработать UI-компоненты (кнопки, картинки, звуки) и сформировать ответ строго по контракту конкретной платформы.

    Начать удобнее с каркаса: npx umbot add platform <Name> в корне проекта создаёт src/platforms/<Name>Adapter.ts и тест к нему. Каркас компилируется и проходит тест сразу, места под API платформы отмечены TODO (подробнее — в описании CLI).

    Адаптер наследуется от базового класса BasePlatformAdapter<TQuery> из umbot/plugins (в исходниках фреймворка класс называется BasePlatform — BasePlatformAdapter это его публичный алиас при реэкспорте). Для примеров ниже подключите всё необходимое одним блоком:

    import { BasePlatformAdapter, TContent } from 'umbot/plugins';
    import { BotController, Text } from 'umbot'; // BotController и Text экспортируются из корня 'umbot'

    Контракт описан интерфейсом IPlatformAdapter (экспортируется из umbot). Базовый класс закрывает большую часть контракта рабочими реализациями: обязательных членов всего четыре, остальные переопределяются по мере надобности.

    Член контракта Обязателен Поведение по умолчанию Назначение
    platformName да 'unknown' Идентификатор платформы: ядро регистрирует адаптер в appContext.platforms и сопоставляет с controller.appType
    isPlatformOnQuery(query, headers?) да (абстрактный) — «Этот запрос мой?»
    setQueryData(query, controller) да (абстрактный) — Разбор входящего запроса и заполнение контроллера
    getContent(controller, stateData?) да (абстрактный) — Сборка ответа в формате платформы
    isVoice нет true Голосовая ли платформа. Чат-платформа обязана выставить false
    supportedEvents нет ['message'] События, которые адаптер выставляет в controller.eventType (см. «События платформы»)
    createApi(controller) нет null Фасад controller.api (см. «API-фасад платформы»)
    signatureName нет не задан Имя HTTP-заголовка, в котором платформа передаёт подпись вебхука
    isCorrectQuery(query, headers?, parsedQuery?) нет HMAC SHA256 по signatureName и токену Проверка подлинности запроса; parsedQuery — тело, уже разобранное фреймворком
    isSignatureCheckEnabled() нет true, если заданы токен и signatureName Сообщает ядру, защищён ли вебхук: по нему bot.start() предупреждает о незащищённой точке входа
    isSignatureSupported() нет true, если задан signatureName или переопределён isSignatureCheckEnabled Есть ли у платформы механизм подписи вебхука; платформы без него не попадают в предупреждение при старте
    getDeliveryId(query) нет не задан (дедупликации нет) ID доставки вебхука для дедупликации повторов (см. «Дедупликация повторных доставок»)
    getResponseTimeout() нет null (срока нет) Сколько платформа ждёт ответа, мс (см. «Срок ответа платформы»)
    getUpdates(signal) нет не задан (polling недоступен) Один запрос long polling за новыми обновлениями (см. «Long polling»)
    limit нет null Лимит запросов/сек для middleware rateLimiter
    isLocalStorage / getLocalStorage / setLocalStorage нет false / null / пусто Хранилище состояния на стороне платформы
    getQueryExample(query, userId, count, state) нет generic-заглушка Пример запроса платформы для BotTest
    getRatingContext(controller) нет вызывает getContent Ответ на запрос оценки приложения
    send(userId, controllerOrText) нет собирает контроллер и вызывает getContent Активные рассылки через bot.send()
    soundProcessing(controller) нет пусто Дополнительная обработка озвучки
    init(appContext) нет регистрация в appContext.platforms Инициализация адаптера

    Минимальный каркас адаптера чат-платформы выглядит так — дальше по документу каждый метод разбирается подробно:

    import { BasePlatformAdapter } from 'umbot/plugins';
    import { BotController, TEventType } from 'umbot';

    interface IMyQuery {
    update_id?: number;
    user_id?: string;
    text?: string;
    }

    export class MyPlatformAdapter extends BasePlatformAdapter<IMyQuery> {
    platformName = 'my_platform';
    isVoice = false; // чат-платформа: ответ уходит через API, а не телом вебхука
    limit = 30; // лимит платформы, запросов/сек (использует middleware rateLimiter)
    supportedEvents: readonly TEventType[] = ['message', 'photo', 'callback'];

    isPlatformOnQuery(query: IMyQuery): boolean {
    return typeof query?.update_id === 'number';
    }

    setQueryData(query: IMyQuery, controller: BotController): boolean {
    controller.appType = this.platformName;
    controller.userId = query.user_id ?? '';
    controller.originalUserCommand = query.text ?? '';
    controller.userCommand = controller.originalUserCommand.toLowerCase().trim();
    return true;
    }

    async getContent(controller: BotController): Promise<string> {
    // отправка ответа через API платформы — см. «Формирование ответа»
    await Promise.resolve(controller.text);
    return 'ok';
    }
    }

    Флаг isVoice базового класса равен true — базовая реализация рассчитана на голосовую платформу. Мессенджеру обязательно выставьте false, иначе:

    • ядро будет копировать controller.text в controller.tts, когда tts не задан явно (для чата это лишняя озвучка и лишние запросы к синтезу речи);
    • BotTest станет искать ответ в теле вебхука (response.text / response.tts) вместо controller.text — консольное тестирование покажет «пусто».

    Когда на сервер приходит запрос, фреймворк перебирает все подключенные адаптеры и спрашивает: «Это твой запрос?».

    Вы должны реализовать метод, который по заголовкам или по телу запроса понимает, относится ли он к вашей платформе.

    Пример: Платформа 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 и реализуйте свою логику валидации.

    webhookHandle и webhookEvent передают первым аргументом сырое тело строкой (от него считается HMAC), а третьим — то же тело, уже разобранное из JSON (parsedQuery). Если подпись лежит в теле запроса (как секрет у VK), берите её из parsedQuery: повторный JSON.parse тела на каждом запросе — лишние микросекунды.

    import { timingSafeEqual } from 'node:crypto';

    // ...внутри класса адаптера
    isCorrectQuery(query: string | IMyQuery, headers?: Record<string, unknown>, parsedQuery?: unknown): boolean {
    const body = (parsedQuery ?? (typeof query === 'string' ? JSON.parse(query) : query)) as IMyQuery;
    const got = Buffer.from(String(body.secret ?? ''));
    const expected = Buffer.from(this.secret);
    // Сравнение за постоянное время: обычное === по времени ответа выдаёт, сколько символов секрета совпало
    return got.length === expected.length && timingSafeEqual(got, expected);
    }

    Примечание: Если вы получаете ошибки при проверке подписи, убедитесь, что:

    1. Поле signatureName установлено в классе адаптера
    2. Токен зарегистрирован в appContext.appConfig.tokens[this.platformName].token

    При bot.start() ядро обходит подключённые адаптеры и предупреждает в лог, если вебхук принимает запросы платформы без проверки подлинности. Спрашивает оно сам адаптер — методом isSignatureCheckEnabled(). Базовая реализация возвращает true, только когда заданы и signatureName, и токен платформы.

    Переопределяйте метод, если подлинность проверяется не HMAC-схемой по заголовку. Так делает VK: секрет приходит полем в теле запроса, signatureName у платформы нет.

    // Метод адаптера
    isSignatureCheckEnabled(): boolean {
    // секрет приходит в теле запроса, а не в заголовке
    return Boolean(this.appContext?.appConfig.tokens[this.platformName]?.secret_key);
    }

    Метод отвечает на вопрос «защищён ли вебхук в текущей конфигурации» — возвращайте реальное положение дел.

    Предупреждение выводится только для платформ, у которых подпись вообще есть: ядро спрашивает isSignatureSupported(). BasePlatform отвечает true, если задан signatureName или переопределён isSignatureCheckEnabled() (как у VK), поэтому переопределять его нужно, только если эвристика не подходит. Если у платформы подписи нет по построению (как у Алисы, SmartApp и Маруси), совет «задайте секрет» был бы невыполним — защищать такой вебхук нужно на других уровнях (middleware ipFilter, секрет в пути URL, проверки в бизнес-логике).

    Вне режима dev и без своего логгера предупреждения при старте дублируются в stderr (logWarn(msg, meta, { stderr: true })): файл warn.log в контейнере никто не читает.

    Мессенджеры повторяют доставку вебхука, если не получили ответ 2xx вовремя. Реализуйте getDeliveryId(query), и ядро будет помнить принятые доставки (час, до 10 000 в памяти процесса) и отвечать на повтор 200 ok без повторного запуска логики. Проверка идёт после isCorrectQuery. Если подпись вебхука включена (isSignatureCheckEnabled() возвращает true), ключ — сам ID: подделать запрос нельзя. Без подписи ключ — хэш тела запроса, поэтому поддельный запрос с угаданным ID не заблокирует настоящий.

    Повтор часто приходит, пока исходный запрос ещё обрабатывается (платформа не дождалась ответа). Такой повтор ждёт исхода исходного запроса, но не дольше 30 секунд:

    • исходный обработан (ответ 2xx или 400) — повтор получает 200 ok;
    • исходный упал с 500 — повтор обрабатывается заново, апдейт не теряется;
    • исходный не завершился за 30 секунд — повтор получает 200 ok, в лог пишется предупреждение.
    getDeliveryId(query: IMyUpdate): string | null {
    return query.update_id === undefined ? null : String(query.update_id);
    }

    Правила:

    • ID должен быть одинаковым у повторов одной доставки и разным у разных событий. Если платформа не даёт ID, соберите его из типа события, времени и идентификатора объекта (так делает MAX: mid одного сообщения приходит и в message_created, и в message_edited).
    • Возвращайте null для событий, ответ на которые несёт содержимое (строка подтверждения VK, приветствие Viber, ответ телом вебхука у Telegram в режиме telegram_webhook_reply): повтор получит пустой ok.
    • Голосовым платформам (ответ всегда телом вебхука) метод не нужен.

    Ядро выполняет запросы одного пользователя по очереди, чтобы параллельные апдейты не затирали userData. Предыдущий запрос ждётся до 10 секунд. Если платформа ждёт ответ ограниченное время, верните этот срок в мс: тогда запрос ждёт предыдущий не дольше половины оставшегося срока, а потом выполняется параллельно — иначе ответ опоздал бы. Алиса, SmartApp и Маруся возвращают MAX_TIME_REQUEST (2900 мс), мессенджерам срок не нужен: ответ уходит через API.

    class MyVoiceAdapter extends BasePlatform {
    getResponseTimeout(): number | null {
    return this.MAX_TIME_REQUEST;
    }
    }

    Если платформа отдаёт обновления по запросу, реализуйте getUpdates(signal) — тогда бота можно запустить bot.startPolling() без публичного адреса. Ядро вызывает метод в цикле и обрабатывает каждое обновление как запрос вебхука (setQueryData, middleware, команды), но без isCorrectQuery: обновление получено от API по токену.

    • Позицию чтения (offset, marker, ts) храните в адаптере: следующий вызов возвращает обновления после отданных.
    • Запрос обязан завершаться по signal, иначе bot.stopPolling() и bot.close() ждут окончания долгого запроса. Передавайте его через request.signal встроенного Request, а не AbortSignal.any([signal, ...]): сигнал живёт весь сеанс polling, и в Node 20 AbortSignal.any() копит на нём память с каждым запросом.
    • Временную ошибку (сеть, 5xx) бросайте исключением — ядро повторит вызов с паузой от 1 до 30 секунд. Если polling невозможен (неверный токен, конфликт с вебхуком), запишите причину в лог и верните null: ядро остановит цикл.
    • Ответ пользователю в режиме polling уходит только через API платформы: тело getContent никто не получит.
    class MyAdapter extends BasePlatform {
    #offset = 0;

    async getUpdates(signal: AbortSignal): Promise<unknown[] | null> {
    const request = new Request(this.appContext as AppContext);
    request.maxTimeQuery = 35_000; // дольше, чем платформа держит запрос (25 с)
    request.signal = signal;
    const res = await request.send<{ id: number }[]>(
    `https://api.example.com/updates?offset=${this.#offset}&timeout=25`,
    );
    if (res.httpStatus === 401) {
    this.appContext?.logError('MyAdapter: неверный токен, polling остановлен.');
    return null;
    }
    if (!res.status || !res.data) {
    throw new Error(`HTTP ${res.httpStatus ?? 'нет ответа'}`);
    }
    const updates = res.data;
    if (updates.length) {
    this.#offset = updates[updates.length - 1].id + 1;
    }
    return updates;
    }
    }

    Задача: Взять сырой 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;
    }

    setQueryData и getContent могут вернуть значение сразу или промис (boolean | Promise<boolean>, TContent). Не объявляйте их async, если внутри нечего ждать: каждый async-метод на каждом запросе создаёт промис и асинхронный кадр, а это заметная доля времени обработки (у встроенных адаптеров — до трети). Встроенные адаптеры отдают промис только там, где есть сетевой вызов:

    setQueryData(query: IMyQuery, controller: BotController): boolean | Promise<boolean> {
    // ...разбор запроса...
    const cached = userCache.get(controller.userId);
    if (cached) {
    controller.setThisUser(cached);
    return true; // синхронно: имя уже есть в кэше
    }
    return this.#loadUser(controller); // промис — только когда нужен запрос к API
    }

    getContent(controller: BotController): string | Promise<string> {
    // Без автоответа отвечать нечем — без промиса.
    return controller.skipAutoReply ? 'ok' : this.#send(controller);
    }

    Код, который вызывает методы адаптера напрямую, должен использовать await: он работает и со значением, и с промисом, а .then() — только с промисом.

    Не-текстовые апдейты — фото, голосовое, нажатие callback-кнопки, редактирование сообщения, старт диалога, подписка — роутятся через универсальный событийный слой. Адаптер приводит свой тип апдейта к одному из значений TEventType и записывает его в controller.eventType, а разработчик приложения пишет обработчик один раз для всех платформ сразу:

    bot.addEvent('photo', async (ctx) => {
    ctx.text = 'Фото получено!';
    });

    Обработчики событий вызываются до шагов и команд. Если ваш адаптер не заполняет eventType, любой запрос считается обычным сообщением ('message'), и платформа выпадает из событийного роутинга — при этом ничего не падает, поэтому пропуск легко не заметить.

    Значение по умолчанию — 'message'. Остальные значения выставляйте там, где разобрали тип апдейта:

    Событие Когда выставлять
    message обычный текстовый ввод, в том числе распознанная платформой речь (дефолт)
    photo, voice, video, document, location, contact, sticker сообщение с вложением соответствующего типа
    callback нажатие inline/callback-кнопки
    inline inline-запрос (пользователь печатает «@бот …» в поле ввода)
    message_edited пользователь отредактировал ранее отправленное сообщение
    channel_post сообщение или пост в канале
    start первый вход в бота или навык; deep-link payload кладите в controller.payload
    subscribed, unsubscribed подписка на бота и отписка от него
    auth завершение привязки аккаунта
    rating результат оценки приложения

    Полный перечень доступен константой ALL_EVENT_TYPES, проверка имени — функцией isEventType(name); и то, и другое экспортируется из umbot.

    import { BasePlatformAdapter, pUtils } from 'umbot/plugins';
    import { BotController, TEventType } from 'umbot';

    interface IMyQuery {
    update_id?: number;
    user_id?: string;
    text?: string;
    photo?: { file_id: string };
    callback?: { payload?: unknown };
    }

    export class MyPlatformAdapter extends BasePlatformAdapter<IMyQuery> {
    platformName = 'my_platform';
    isVoice = false;
    supportedEvents: readonly TEventType[] = ['message', 'photo', 'callback'];

    isPlatformOnQuery(query: IMyQuery): boolean {
    return typeof query?.update_id === 'number';
    }

    setQueryData(query: IMyQuery, controller: BotController): boolean {
    controller.requestObject = query;
    controller.appType = this.platformName;
    controller.userId = query.user_id ?? '';

    if (query.callback) {
    // Нажатие кнопки: payload становится командой (см. «Callback-кнопки»)
    controller.eventType = 'callback';
    controller.payload = query.callback.payload as Record<string, unknown>;
    controller.userCommand = pUtils.normalizeActionPayload(query.callback.payload);
    controller.originalUserCommand = controller.userCommand;
    return true;
    }

    if (query.photo) {
    controller.eventType = 'photo';
    }

    controller.originalUserCommand = query.text ?? '';
    controller.userCommand = controller.originalUserCommand.toLowerCase().trim();
    return true;
    }

    async getContent(controller: BotController): Promise<string> {
    await Promise.resolve(controller.text);
    return 'ok';
    }
    }

    Во встроенных адаптерах есть две готовые функции-сопоставления, их удобно взять за образец: pUtils.telegramMessageEvent(message) определяет событие по вложениям сообщения Telegram (photo, voice, video, document, location, contact, sticker, иначе message), а pUtils.viberMessageEvent(type) — по полю message.type у Viber (picture → photo, file → document и так далее).

    Поле supportedEvents — перечень событий, которые адаптер реально выставляет. Базовое значение BasePlatformAdapter — ['message'], поэтому платформе, умеющей только текст, поле можно не трогать.

    supportedEvents: readonly TEventType[] = ['message', 'photo', 'callback'];
    

    Что важно понимать про это поле:

    • Оно не фильтрует обработку. При обработке запроса supportedEvents не участвует: событие берётся из controller.eventType. Поле читается только в момент регистрации — bot.addEvent(...) предупреждает разработчика об опечатке в имени события и о событии, которого не выставляет ни один подключённый адаптер. Без объявления ваша платформа не сломается, но пользователи получат ложное предупреждение и решат, что событие не поддерживается.
    • Перечисляйте только реальность. Событие в списке, которое setQueryData никогда не выставляет, отключит полезное предупреждение и спрячет чужую опечатку.
    • Прямая реализация IPlatformAdapter (без наследования от BasePlatformAdapter) может поле не объявлять: ядро в этом случае считает его равным ['message'].
    • События вне TEventType (платформенная покупка, реакция на сообщение и подобное) в слой не заводятся — обрабатывайте их в action() по controller.requestObject.

    Для справки — перечни встроенных адаптеров:

    Платформа supportedEvents
    Telegram message, photo, voice, video, document, location, contact, sticker, callback, inline, message_edited, channel_post
    Viber message, photo, video, document, contact, location, sticker, start, subscribed, unsubscribed
    MAX message, callback, start, message_edited
    VK message, callback
    SmartApp message, start, rating
    Алиса message, auth
    Маруся message, auth

    Фреймворк оперирует абстракциями (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);

    payload кнопки — произвольное значение (Record<string, unknown> | string), а платформы принимают строку. Сериализуйте его через pUtils.serializePlatformPayload(payload, platformName, appContext): при несериализуемом значении хелпер вернёт null и напишет в лог предупреждение вместо того, чтобы уронить сборку всей клавиатуры.

    Разработчик бота раскладывает кнопки по рядам через ctx.buttons.row(); в кнопке это видно как группа options._group — у кнопок одного ряда она одинаковая, у кнопки без группы её нет. Если у платформы клавиатура из рядов, не разбирайте группы сами — используйте pUtils.layoutButtonRows, как встроенные адаптеры Telegram, VK, MAX и Viber. Хелпер собирает кнопки одной группы в один ряд, кнопке без группы отдаёт отдельную строку, а ряд длиннее лимита платформы переносит с предупреждением в лог:

    import { pUtils } from 'umbot/plugins';

    function myPlatformButtonProcessing(buttons: IButtonType[], appContext?: AppContext): MyButton[][] {
    const items: pUtils.IButtonRowItem<MyButton>[] = buttons.map((btn) => ({
    group: btn.options?._group,
    item: { text: btn.title },
    }));
    // Второй аргумент — лимит кнопок в ряду; может зависеть от типов кнопок ряда
    return pUtils.layoutButtonRows(items, () => 5, 'MyPlatform', appContext);
    }

    Если платформа умеет callback-кнопки (нажатие приходит отдельным апдейтом с payload), адаптер отвечает за то, чтобы это нажатие выглядело для бизнес-логики как обычная команда. Тогда разработчику приложения достаточно написать:

    bot.addAction('buy', (text, ctx) => {
    ctx.text = 'Оформляю заказ';
    });

    bot.addAction(name, handler) регистрирует обработчик как команду с единственным слотом name, поэтому всё, что требуется от адаптера при разборе нажатия, — три вещи:

    1. выставить controller.eventType = 'callback';
    2. положить в controller.userCommand нормализованный payload — через pUtils.normalizeActionPayload(raw): и строка 'buy', и JSON {"command":"buy"} превращаются в buy;
    3. сохранить разобранный payload в controller.payload (pUtils.tryParse(raw)), чтобы бизнес-логика могла прочитать дополнительные поля.

    Так устроены встроенные адаптеры Telegram, VK и MAX. Образец из Telegram/Adapter.ts:

    import { pUtils } from 'umbot/plugins';
    import { BotController } from 'umbot';

    interface IMyCallback {
    id: string;
    data?: string;
    chat_id?: number;
    }

    function setCallbackQuery(callback: IMyCallback, controller: BotController): void {
    controller.eventType = 'callback';
    controller.userCommand = pUtils.normalizeActionPayload(callback.data);
    controller.originalUserCommand = callback.data || '';
    controller.payload = pUtils.tryParse(callback.data);

    // Технические данные нажатия — в изолированное хранилище адаптера:
    // из него их возьмёт API-фасад, чтобы ответить на нажатие.
    const data = pUtils.getPlatformRequestData<{ callbackId?: string; chatId?: number }>(
    controller,
    'my_platform',
    );
    data.callbackId = callback.id;
    if (callback.chat_id !== undefined) {
    data.chatId = callback.chat_id;
    }
    }

    pUtils.getPlatformRequestData(controller, adapterKey) — изолированное хранилище технических данных запроса: общий контроллер не должен знать о полях конкретного транспорта, поэтому каждый адаптер держит их под собственным ключом (обычно this.platformName). Оттуда же их читает API-фасад — см. раздел ниже.

    Важно: 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;
    },
    );

    Помимо getImageToken/getSoundToken, в pUtils (экспорт из umbot/plugins) есть хелперы, которые используют встроенные адаптеры — их стоит переиспользовать и в кастомных:

    Разбор входящего запроса:

    • tryParse<T>(raw) — безопасный разбор JSON-строки payload (null при невалидном);
    • normalizeActionPayload(payload) — приводит payload кнопки ('buy', {"command":"buy"} или {"action":"buy"}) к строке buy в нижнем регистре для userCommand;
    • hasAnyNluKey(nlu) — проверяет, есть ли в объекте NLU хоть какие-то данные (медиа/сущности/интенты);
    • setThisUserToNlu(controller, thisUser) — заполняет сущность thisUser (данные об отправителе) в NLU контроллера;
    • telegramMessageEvent(message) / viberMessageEvent(type) — сопоставляют тип входящего апдейта с универсальным TEventType;
    • getPlatformRequestData<T>(controller, adapterKey) — изолированное хранилище технических данных запроса под ключом вашего адаптера (callbackId, chatId и т.п.).

    Сборка ответа:

    • getChatText(text, tts) — текст ответа для чат-платформ: при пустом text возвращает tts без разметки звуков;
    • getSpeechText(text) — чистит TTS от звуковой разметки голосовых платформ (#game_win#, паузы, <speaker>) перед отправкой в синтез речи;
    • shouldProcessChatSound(controller, platformName) — нужно ли вообще обрабатывать звук: есть добавленные звуки либо задан speech_kit_token;
    • defaultSoundProcessing(soundInfo, defaultSounds, defaultEffects?) — стандартная подстановка звуков и эффектов (используют Алиса и Маруся);
    • getCorrectButtons(buttons, limit, appContext?) — обрезает массив кнопок до лимита платформы (дефолт 10; с appContext пишет предупреждение об усечении);
    • serializePlatformPayload(payload, platform, appContext?) — сериализует payload кнопки в строку, возвращает null с предупреждением вместо исключения;
    • layoutButtonRows(items, getRowLimit, platform, appContext?) — раскладывает кнопки по рядам клавиатуры по options._group (buttons.row()) с учётом лимита ряда (см. «Раскладка по рядам» выше).

    Медиа-токены: getImageToken и getSoundToken разобраны выше; cacheMediaToken(model, controller) — записывает уже полученный токен в модель ImageTokens/SoundTokens. Кэш здесь — оптимизация, а не условие работы: без подключённого DB-адаптера запись просто не происходит, и это не ошибка.

    Полный список с сигнатурами — в типах src/plugins/platforms/Base/utils.ts (JSDoc каждого хелпера).

    import { pUtils } from 'umbot/plugins';

    // Внутри setQueryData при разборе нажатия кнопки
    // (normalizeActionPayload сам приводит результат к нижнему регистру):
    controller.userCommand = pUtils.normalizeActionPayload(payload);
    controller.originalUserCommand = controller.userCommand;

    controller.api — унифицированный доступ к исходящим возможностям платформы прямо из бизнес-логики: отправить фото или файл, ответить на нажатие кнопки, не конструируя вручную платформенные Request-классы.

    bot.addEvent('callback', async (ctx) => {
    await ctx.api?.answerCallback('Принято');
    await ctx.api?.sendPhoto('./report.png', { caption: 'Ваш отчёт' });
    ctx.skipAutoReply = true; // ответ уже отправлен вручную
    });

    Фасад выбирает сам адаптер — опциональным методом createApi(controller). Базовая реализация возвращает null (фасад недоступен), поэтому по умолчанию controller.api на кастомной платформе равен null, и включается он переопределением одного метода — правки ядра не нужны. Фасад ленивый: объект создаётся при первом обращении к ctx.api, запросы без API-вызовов за него не платят.

    Голосовым платформам (Алиса, SmartApp, Маруся) фасад не нужен: их ответ формируется телом вебхука, а медиа отправляются через controller.card / controller.sound. Такие адаптеры метод не переопределяют.

    Метод Назначение
    sendPhoto(image, params?) Отправить изображение; params.caption — подпись
    sendDocument(file, params?) Отправить документ или файл
    sendAudio(file, params?) Отправить аудио
    sendVideo(file, params?) Отправить видео
    answerCallback(text, showAlert?) Ответить на нажатие callback-кнопки (уведомление или snackbar)
    can(method) Поддерживает ли платформа метод — имена методов перечислены типом TApiMethod

    Методы отправки возвращают Promise<Record<string, unknown> | null>: null — отправить не удалось. Интерфейсы IControllerApi, IApiMediaParams и тип TApiMethod экспортируются из umbot.

    Фасад — обычный объект, а не класс. Собирайте его фабрикой, которая замыкается на контроллер; технические данные запроса (идентификатор нажатия, чат) берите из хранилища адаптера, заполненного в setQueryData.

    import { pUtils } from 'umbot/plugins';
    import { BotController, IApiMediaParams, IControllerApi, TApiMethod } from 'umbot';

    const MY_SUPPORTED: readonly TApiMethod[] = ['sendPhoto', 'answerCallback'];

    interface IMyApiData extends Record<string, unknown> {
    callbackId?: string;
    chatId?: number;
    }

    export function makeMyApi(controller: BotController): IControllerApi {
    const data = (): IMyApiData =>
    pUtils.getPlatformRequestData<IMyApiData>(controller, 'my_platform');
    const recipient = (): string | number | null => data().chatId ?? controller.userId;

    return {
    async sendPhoto(
    image: string,
    params?: IApiMediaParams,
    ): Promise<Record<string, unknown> | null> {
    const chatId = recipient();
    if (!chatId) {
    // Без адресата не отправляем битый запрос — явный warn в лог
    controller.appContext?.logWarn(
    'controller.api.sendPhoto(): не удалось определить адресата.',
    );
    return null;
    }
    // здесь — вызов вашего API-клиента платформы
    return { chatId, image, caption: params?.caption ?? '' };
    },
    async sendDocument(): Promise<Record<string, unknown> | null> {
    return null; // платформа не умеет — честно отдаём null (и can() === false)
    },
    async sendAudio(): Promise<Record<string, unknown> | null> {
    return null;
    },
    async sendVideo(): Promise<Record<string, unknown> | null> {
    return null;
    },
    async answerCallback(text: string): Promise<Record<string, unknown> | null> {
    const callbackId = data().callbackId;
    if (!callbackId) {
    controller.appContext?.logWarn(
    'controller.api.answerCallback(): у текущего запроса нет callback-идентификатора — кнопка не была нажата.',
    );
    return null;
    }
    return { callbackId, text };
    },
    can(method: TApiMethod): boolean {
    return (MY_SUPPORTED as readonly string[]).includes(method);
    },
    };
    }

    Остаётся подключить фабрику к адаптеру — одним методом:

    // Метод адаптера
    createApi(controller: BotController): IControllerApi | null {
    return makeMyApi(controller);
    }

    Правила, которые соблюдают встроенные фасады:

    • can() не врёт. Метод возвращает false там, где платформа физически не умеет операцию. Так, фасад Viber отвечает false на все методы: его Bot API принимает медиа только по публичному URL и с обязательным size, которых у фасада нет.
    • Не отправлять заведомо битый запрос. Не удалось определить адресата или идентификатор нажатия — предупреждение в лог и null, а не запрос в API с пустым полем.
    • Неподдерживаемый метод возвращает null, а не бросает исключение. Бизнес-логика кросс-платформенная: один и тот же обработчик выполняется и на платформе, где метод есть, и там, где его нет.

    Диспетчер makePlatformApi(controller) из umbot/plugins собирает фасад встроенной платформы по controller.appType — он оставлен для ручного использования, ядро им не пользуется.

    Некоторые платформы (Алиса, SmartApp) умеют хранить состояние диалога на своей стороне. Это позволяет не делать лишних запросов в БД.

    Чтобы поддержать это, нужно реализовать 3 метода:

    1. isLocalStorage(controller) — возвращает true, если платформа поддерживает локальное хранилище.
    2. getLocalStorage(controller) — возвращает данные, которые платформа прислала в запросе (обычно лежат в controller.state).
    3. 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,
    };
    }

    Некоторые платформы присылают служебные запросы, на которые нужно ответить заготовленным ответом, не проходя бизнес-логику приложения:

    • Алиса периодически шлёт ping для проверки доступности навыка;
    • VK при первичной настройке вебхука присылает confirmation — нужно вернуть строку-подтверждение;
    • SmartApp может присылать healthcheck-запросы.

    Чтобы не запускать 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:

    • object — будет отправлен в тело HTTP-ответа как JSON (для Алисы, SmartApp — это структура { version, response, ... }).
    • string — будет отправлен как plain text (для VK confirmation).
    • null / undefined — обычная обработка (по умолчанию).

    Если у платформы есть жесткий лимит запросов в секунду (например, 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.

    Время считается от метки, которую ставит updateTimeStart(controller) (ядро вызывает его перед бизнес-логикой), а getProcessingTime(controller) возвращает прошедшие миллисекунды. Оба метода публичные — пользуйтесь ими, если считаете собственные метрики.

    Когда бизнес-логика выставила controller.isSendRating = true, ядро собирает ответ не через getContent, а через getRatingContext(controller). Базовая реализация просто вызывает getContent, поэтому переопределять метод нужно только платформам со специальным форматом запроса оценки (так делает SmartApp).

    // Метод адаптера: TContent допускает и объект, и промис
    getRatingContext(controller: BotController): Promise<object> {
    return Promise.resolve({
    messageName: 'CALL_RATING',
    payload: { text: controller.text },
    });
    }

    bot.send(userId, текстИлиКонтроллер, platform) позволяет приложению написать пользователю первым. Ядро находит адаптер по имени платформы и вызывает его метод send(userId, controllerOrText).

    Базовая реализация уже рабочая: строку она оборачивает в контроллер, подставляет userId и вызывает getContent. Для платформы, которая отправляет ответ через API (парадигма Б), этого достаточно — специально ничего делать не нужно. Переопределяйте метод, только если платформа требует другого запроса для проактивных сообщений или не поддерживает их вовсе — тогда верните false.

    Клиенты встроенных платформ логируют сбои одинаково, и те же хелперы доступны вашему адаптеру (экспорт из umbot/plugins):

    • getErrorMsg(error, path, url) — сообщение об ошибке запроса: источник, URL и текст ошибки;
    • getErrorToken(platform, methodName) — сообщение о том, что для платформы не задан токен.
    import { getErrorMsg, getErrorToken } from 'umbot/plugins';
    import { AppContext } from 'umbot';

    async function callMyApi(appContext: AppContext, url: string): Promise<unknown | null> {
    const token = appContext.appConfig.tokens['my_platform']?.token;
    if (!token) {
    appContext.logError(getErrorToken('my_platform', 'callMyApi'));
    return null;
    }
    try {
    const response = await fetch(url, { headers: { Authorization: token } });
    return await response.json();
    } catch (error) {
    appContext.logError(getErrorMsg(error as Error, 'MyPlatformRequest', url));
    return null;
    }
    }

    В собственном HTTP-клиенте платформы всегда задавайте таймаут — запрос без ограничения времени подвешивает обработку вебхука. Встроенные клиенты построены на классе Request из umbot, у которого таймаут задан по умолчанию (подробности — в документе http-client.md).