umbot
    Preparing search index...

    Creating a platform adapter (Platform Adapter)

    This page is machine-translated from the Russian original. If something reads oddly, the Russian version is the source of truth — open an issue.

    A platform adapter is the bridge between the raw JSON/XML request of an external platform and the unified BotController. Your job: parse the incoming data, fill the controller, process the UI components (buttons, images, sounds) and build the response strictly according to the platform's contract.

    The easiest way to start is the scaffold: npx umbot add platform <Name> in the project root creates src/platforms/<Name>Adapter.ts and a test for it. The scaffold compiles and passes the test right away; the places for the platform API are marked TODO (more in the CLI description).

    The adapter extends the BasePlatformAdapter<TQuery> base class from umbot/plugins (in the framework sources the class is called BasePlatform — BasePlatformAdapter is its public alias in the re-export). For the examples below, import everything you need in one block:

    import { BasePlatformAdapter, TContent } from 'umbot/plugins';
    import { BotController, Text } from 'umbot'; // BotController and Text are exported from the 'umbot' root

    The contract is described by the IPlatformAdapter interface (exported from umbot). The base class covers most of the contract with working implementations: only four members are required, the rest are overridden as needed.

    Contract member Required Default behavior Purpose
    platformName yes 'unknown' The platform identifier: the core registers the adapter in appContext.platforms and matches it with controller.appType
    isPlatformOnQuery(query, headers?) yes (abstract) — "Is this request mine?"
    setQueryData(query, controller) yes (abstract) — Parsing the incoming request and filling the controller
    getContent(controller, stateData?) yes (abstract) — Building the response in the platform format
    isVoice no true Whether the platform is a voice one. A chat platform must set false
    supportedEvents no ['message'] The events the adapter sets in controller.eventType (see "Platform events")
    createApi(controller) no null The controller.api facade (see "The platform API facade")
    signatureName no not set The name of the HTTP header in which the platform passes the webhook signature
    isCorrectQuery(query, headers?, parsedQuery?) no HMAC SHA256 by signatureName and the token Verifying the request; parsedQuery is the body already parsed by the framework
    isSignatureCheckEnabled() no true if both the token and signatureName are set Tells the core whether the webhook is protected: bot.start() uses it to warn about an unprotected entry point
    isSignatureSupported() no true if signatureName is set or isSignatureCheckEnabled is overridden Whether the platform has a webhook signature mechanism at all; platforms without one are left out of the startup warning
    getDeliveryId(query) no not set (no deduplication) The webhook delivery ID for deduplicating retries (see "Deduplicating redeliveries")
    getResponseTimeout() no null (no deadline) How long the platform waits for a response, ms (see "The platform response deadline")
    getUpdates(signal) no not set (polling is unavailable) A single long polling request for new updates (see "Long polling")
    limit no null The requests/sec limit for the rateLimiter middleware
    isLocalStorage / getLocalStorage / setLocalStorage no false / null / empty State storage on the platform side
    getQueryExample(query, userId, count, state) no a generic stub An example platform request for BotTest
    getRatingContext(controller) no calls getContent The response to an application rating request
    send(userId, controllerOrText) no builds a controller and calls getContent Proactive messages via bot.send()
    soundProcessing(controller) no empty Additional speech processing
    init(appContext) no registration in appContext.platforms Adapter initialization

    A minimal chat platform adapter looks like this — each method is covered in detail further in the document:

    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; // a chat platform: the reply goes via the API, not in the webhook body
    limit = 30; // the platform limit, requests/sec (used by the rateLimiter middleware)
    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> {
    // sending the reply via the platform API — see "Building the response"
    await Promise.resolve(controller.text);
    return 'ok';
    }
    }

    The base class's isVoice flag is true — the base implementation targets a voice platform. A messenger must set it to false, otherwise:

    • the core copies controller.text to controller.tts when tts is not set explicitly (for a chat this is needless speech and needless speech synthesis requests);
    • BotTest looks for the reply in the webhook body (response.text / response.tts) instead of controller.text — console testing shows "empty".

    When a request reaches the server, the framework goes through all connected adapters and asks: "Is this request yours?".

    You must implement a method that tells from the headers or the request body whether the request belongs to your platform.

    Example: the WeChat platform sends a specific x-wechat-signature header and XML in the body. Telegram sends the x-telegram-bot-api-secret-token header.

    isPlatformOnQuery(query: unknown, headers?: Record<string, unknown>): boolean {
    const q = query as Record<string, unknown>;
    // 1. Check the headers (the most reliable way)
    if (headers?.['x-wechat-signature']) return true;

    // 2. Fallback: check unique fields in the request body
    return !!(q.xml_msg || q.specific_wechat_field);
    }

    If the platform requires signature (token) verification, override this method. By default BasePlatformAdapter can verify HMAC SHA256, but only if both parameters are set:

    • signatureName — the name of the request header field
    • token — the secret token in the configuration

    If at least one of the parameters is not set, isCorrectQuery() returns true (the check is skipped).

    If the standard check is not enough (for example, the platform uses Ed25519 instead of HMAC SHA256), override isCorrectQuery and implement your own validation logic.

    webhookHandle and webhookEvent pass the raw body as a string as the first argument (the HMAC is computed from it), and as the third — the same body already parsed from JSON (parsedQuery). If the signature is in the request body (like VK's secret), take it from parsedQuery: parsing the body again with JSON.parse on every request costs extra microseconds.

    import { timingSafeEqual } from 'node:crypto';

    // ...inside the adapter class
    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);
    // Constant-time comparison: a regular === leaks through response time how many characters of the secret matched
    return got.length === expected.length && timingSafeEqual(got, expected);
    }

    Note: if you get errors when verifying the signature, make sure that:

    1. The signatureName field is set in the adapter class
    2. The token is registered in appContext.appConfig.tokens[this.platformName].token

    On bot.start() the core goes through the connected adapters and warns in the log if a webhook accepts platform requests without verifying them. It asks the adapter itself — with the isSignatureCheckEnabled() method. The base implementation returns true only when both signatureName and the platform token are set.

    Override the method if authenticity is not verified with a header-based HMAC scheme. VK does this: the secret arrives as a field in the request body, and the platform has no signatureName.

    // An adapter method
    isSignatureCheckEnabled(): boolean {
    // the secret arrives in the request body, not in a header
    return Boolean(this.appContext?.appConfig.tokens[this.platformName]?.secret_key);
    }

    The method answers the question "is the webhook protected in the current configuration" — return the actual state of things.

    The warning is printed only for platforms that have a signature at all: the core asks isSignatureSupported(). BasePlatform answers true if signatureName is set or isSignatureCheckEnabled() is overridden (as in VK), so you need to override it only if the heuristic does not fit. If a platform has no signature by design (like Alice, SmartApp and Marusia), the advice "set a secret" would be impossible to follow — such a webhook has to be protected at other levels (the ipFilter middleware, a secret in the URL path, checks in the business logic).

    Outside the dev mode and without a custom logger, startup warnings are duplicated to stderr (logWarn(msg, meta, { stderr: true })): nobody reads the warn.log file in a container.

    Messengers redeliver a webhook if they did not get a 2xx response in time. Implement getDeliveryId(query), and the core remembers accepted deliveries (an hour, up to 10 000 in process memory) and answers a redelivery with 200 ok without running the logic again. The check happens after isCorrectQuery. If the webhook signature is enabled (isSignatureCheckEnabled() returns true), the key is the ID itself: a request cannot be forged. Without a signature the key is a hash of the request body, so a forged request with a guessed ID will not block the real one.

    A redelivery often arrives while the original request is still being processed (the platform did not wait for the response). Such a redelivery waits for the outcome of the original request, but no longer than 30 seconds:

    • the original was processed (a 2xx or 400 response) — the redelivery gets 200 ok;
    • the original failed with 500 — the redelivery is processed again, the update is not lost;
    • the original did not finish within 30 seconds — the redelivery gets 200 ok, and a warning is written to the log.
    getDeliveryId(query: IMyUpdate): string | null {
    return query.update_id === undefined ? null : String(query.update_id);
    }

    Rules:

    • The ID must be the same for redeliveries of one delivery and different for different events. If the platform gives no ID, build it from the event type, the time and the object identifier (MAX does this: the mid of one message arrives both in message_created and in message_edited).
    • Return null for events whose response carries content (the VK confirmation string, the Viber greeting, a reply in the webhook body for Telegram in the telegram_webhook_reply mode): a redelivery would get an empty ok.
    • Voice platforms (the reply is always the webhook body) do not need this method.

    The core runs one user's requests one after another so that concurrent updates do not overwrite userData. The previous request is awaited for up to 10 seconds. If the platform waits for a response for a limited time, return this deadline in ms: then a request waits for the previous one no longer than half of the remaining time, and after that runs in parallel — otherwise the response would be late. Alice, SmartApp and Marusia return MAX_TIME_REQUEST (2900 ms); messengers need no deadline: the reply goes via the API.

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

    If the platform delivers updates on request, implement getUpdates(signal) — then the bot can be started with bot.startPolling() without a public address. The core calls the method in a loop and processes each update as a webhook request (setQueryData, middleware, commands), but without isCorrectQuery: the update was received from the API with the token.

    • Keep the read position (offset, marker, ts) in the adapter: the next call returns the updates after the ones already returned.
    • The request must finish on signal, otherwise bot.stopPolling() and bot.close() wait for a long request to end. Pass it via request.signal of the built-in Request, not AbortSignal.any([signal, ...]): the signal lives for the whole polling session, and in Node 20 AbortSignal.any() accumulates memory on it with every request.
    • Throw an exception for a temporary error (network, 5xx) — the core retries the call with a pause of 1 to 30 seconds. If polling is impossible (a wrong token, a conflict with a webhook), log the reason and return null: the core stops the loop.
    • In polling mode the reply to the user goes only via the platform API: nobody receives the getContent body.
    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; // longer than the platform holds the request (25 s)
    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: wrong token, polling stopped.');
    return null;
    }
    if (!res.status || !res.data) {
    throw new Error(`HTTP ${res.httpStatus ?? 'no response'}`);
    }
    const updates = res.data;
    if (updates.length) {
    this.#offset = updates[updates.length - 1].id + 1;
    }
    return updates;
    }
    }

    The task: take the raw query and fill the controller fields. Whether the application's business logic works correctly depends on how you fill the controller

    Required fields to fill:

    • controller.userId (string | number) — the unique user ID.
    • controller.userCommand (string) — the command text in lower case (needed for the command lookup).
    • controller.originalUserCommand (string) — the original text as is.
    • controller.messageId (number | string | null) — the message ID (needed to detect the start of a dialog).
    • controller.appType = this.platformName — the platform type: the core finds the adapter in the registry by it.

    Optional but important fields:

    • controller.nlu.setNlu(...) — if the platform sends NLU/intents.
    • controller.userMeta — metadata (for example, whether the user has a screen).
    • controller.payload — additional data (for example, the pressed button).

    The messageId caveat: the start of a dialog is detected strictly by messageId === 0 — the adapter must set 0 for the first message of the dialog (otherwise the welcome intent will not fire).

    setQueryData(query: unknown, controller: BotController): boolean {
    const q = query as Record<string, unknown>;
    if (!q) {
    controller.platformOptions.error = 'Empty request';
    return false;
    }

    controller.requestObject = query; // Keep the original
    controller.appType = this.platformName; // Required: the core looks up the adapter by this field
    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 the platform sends user data
    if (q.user) {
    controller.nlu.setNlu({
    thisUser: { username: (q.user as Record<string, unknown>).name as string },
    });
    }

    return true;
    }

    setQueryData and getContent can return a value right away or a promise (boolean | Promise<boolean>, TContent). Do not declare them async if there is nothing to await inside: every async method creates a promise and an asynchronous frame on every request, and that is a noticeable share of processing time (up to a third in the built-in adapters). The built-in adapters return a promise only where there is a network call:

    setQueryData(query: IMyQuery, controller: BotController): boolean | Promise<boolean> {
    // ...parsing the request...
    const cached = userCache.get(controller.userId);
    if (cached) {
    controller.setThisUser(cached);
    return true; // synchronously: the name is already in the cache
    }
    return this.#loadUser(controller); // a promise — only when an API request is needed
    }

    getContent(controller: BotController): string | Promise<string> {
    // Without an auto-reply there is nothing to send — no promise.
    return controller.skipAutoReply ? 'ok' : this.#send(controller);
    }

    Code that calls adapter methods directly must use await: it works both with a value and with a promise, while .then() works only with a promise.

    Non-text updates — a photo, a voice message, a callback button press, a message edit, a dialog start, a subscription — are routed through a universal event layer. The adapter maps its update type to one of the TEventType values and writes it to controller.eventType, and the application developer writes a handler once for all platforms at once:

    bot.addEvent('photo', async (ctx) => {
    ctx.text = 'Photo received!';
    });

    Event handlers are called before steps and commands. If your adapter does not fill eventType, every request is treated as a regular message ('message'), and the platform drops out of event routing — nothing crashes, so the omission is easy to miss.

    The default value is 'message'. Set the other values where you parse the update type:

    Event When to set it
    message regular text input, including speech recognized by the platform (the default)
    photo, voice, video, document, location, contact, sticker a message with an attachment of the corresponding type
    callback an inline/callback button press
    inline an inline query (the user types "@bot …" in the input field)
    message_edited the user edited a previously sent message
    channel_post a message or a post in a channel
    start the first visit to the bot or skill; put the deep-link payload into controller.payload
    subscribed, unsubscribed subscribing to the bot and unsubscribing from it
    auth account linking completed
    rating the result of an application rating

    The full list is available as the ALL_EVENT_TYPES constant, and a name can be checked with the isEventType(name) function; both are exported from 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) {
    // A button press: the payload becomes a command (see "Callback buttons")
    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';
    }
    }

    The built-in adapters have two ready-made mapping functions that are handy as a reference: pUtils.telegramMessageEvent(message) determines the event from the attachments of a Telegram message (photo, voice, video, document, location, contact, sticker, otherwise message), and pUtils.viberMessageEvent(type) — from Viber's message.type field (picture → photo, file → document and so on).

    The supportedEvents field is the list of events the adapter actually sets. The BasePlatformAdapter default is ['message'], so a platform that can do only text can leave the field alone.

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

    What is important to understand about this field:

    • It does not filter processing. supportedEvents takes no part in request processing: the event is taken from controller.eventType. The field is read only at registration time — bot.addEvent(...) warns the developer about a typo in the event name and about an event that no connected adapter sets. Without the declaration your platform will not break, but users will get a false warning and decide the event is not supported.
    • List only what is real. An event in the list that setQueryData never sets disables a useful warning and hides someone else's typo.
    • A direct IPlatformAdapter implementation (without extending BasePlatformAdapter) may omit the field: in that case the core treats it as ['message'].
    • Events outside TEventType (a platform purchase, a reaction to a message and the like) are not brought into the layer — handle them in action() via controller.requestObject.

    For reference — the lists of the built-in adapters:

    Platform 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
    Alice message, auth
    Marusia message, auth

    The framework works with abstractions (IButtonType, ICardInfo). Platforms require specific formats. Processor functions turn an abstraction into the platform format.

    You need to write a function that takes an array of abstract buttons and returns an object the platform understands. controller.buttons.getButtons(your_processor) calls your function itself and returns the result. The result can be null (an empty button list) — take this into account when building the response.

    // IButtonType is exported from the 'umbot' root
    // 1. Write the processor
    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. Call it inside getContent (the result can be null if there are no buttons)
    const keyboard = controller.buttons.getButtons(myPlatformButtonProcessing);

    A button's payload is an arbitrary value (Record<string, unknown> | string), while platforms accept a string. Serialize it with pUtils.serializePlatformPayload(payload, platformName, appContext): for a non-serializable value the helper returns null and logs a warning instead of breaking the build of the whole keyboard.

    The bot developer arranges buttons into rows with ctx.buttons.row(); in a button this shows up as the options._group group — buttons of the same row share it, and a button without a group has none. If the platform's keyboard is made of rows, do not parse the groups yourself — use pUtils.layoutButtonRows, as the built-in Telegram, VK, MAX and Viber adapters do. The helper puts the buttons of one group into one row, gives a button without a group its own row, and wraps a row longer than the platform limit with a warning in the log:

    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 },
    }));
    // The second argument is the per-row button limit; it can depend on the button types of the row
    return pUtils.layoutButtonRows(items, () => 5, 'MyPlatform', appContext);
    }

    If the platform supports callback buttons (a press arrives as a separate update with a payload), the adapter is responsible for making this press look like a regular command to the business logic. Then the application developer only needs to write:

    bot.addAction('buy', (text, ctx) => {
    ctx.text = 'Placing your order';
    });

    bot.addAction(name, handler) registers the handler as a command with the single slot name, so all that is required from the adapter when parsing a press are three things:

    1. set controller.eventType = 'callback';
    2. put the normalized payload into controller.userCommand — via pUtils.normalizeActionPayload(raw): both the string 'buy' and the JSON {"command":"buy"} turn into buy;
    3. save the parsed payload to controller.payload (pUtils.tryParse(raw)) so that the business logic can read additional fields.

    This is how the built-in Telegram, VK and MAX adapters work. A sample from 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);

    // Technical data of the press goes to the adapter's isolated storage:
    // the API facade takes it from there to answer the press.
    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) is an isolated storage for the request's technical data: the shared controller must not know about the fields of a particular transport, so each adapter keeps them under its own key (usually this.platformName). The API facade reads them from there too — see the section below.

    Important: getImageToken and getSoundToken live in pUtils, which is exported from 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 there is no token yet, upload the image
    if (!image.imageToken && image.imageDir) {
    image.imageToken = await pUtils.getImageToken(
    image.imageDir,
    'my_platform', // the platform name
    controller,
    async (model: ImageTokens) => {
    // 1. Upload the file to the platform API
    const api = new MyPlatformApi(controller.appContext);
    const uploadResult = await api.uploadImage(image.imageDir);

    if (uploadResult?.id) {
    // 2. Save the token to the model
    model.imageToken = uploadResult.id;
    // 3. Save the model to the database (so that it is not uploaded again next time)
    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;
    }

    Important: the callback function (the fourth parameter of getImageToken) is called ONLY on a cache miss, that is, when:

    • The token has not been generated yet (!image.imageToken)
    • The database has no saved token for this file

    If the token already exists and is valid, the callback is not called — the cached value is used. This avoids unnecessary network requests and speeds up the application.

    Like images, sounds use the getSoundToken utility and the SoundTokens model.

    import { pUtils } from 'umbot/plugins';
    import { SoundTokens } from 'umbot';
    // Inside the sound processor:
    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;
    },
    );

    Besides getImageToken/getSoundToken, pUtils (exported from umbot/plugins) has helpers that the built-in adapters use — reuse them in custom ones too:

    Parsing the incoming request:

    • tryParse<T>(raw) — safe parsing of a payload JSON string (null if invalid);
    • normalizeActionPayload(payload) — turns a button payload ('buy', {"command":"buy"} or {"action":"buy"}) into the lowercase string buy for userCommand;
    • hasAnyNluKey(nlu) — checks whether the NLU object has any data at all (media/entities/intents);
    • setThisUserToNlu(controller, thisUser) — fills the thisUser entity (data about the sender) in the controller's NLU;
    • telegramMessageEvent(message) / viberMessageEvent(type) — map the incoming update type to the universal TEventType;
    • getPlatformRequestData<T>(controller, adapterKey) — an isolated storage for the request's technical data under your adapter's key (callbackId, chatId, etc.).

    Building the response:

    • getChatText(text, tts) — the reply text for chat platforms: with an empty text it returns tts without sound markup;
    • getSpeechText(text) — strips voice platform sound markup (#game_win#, pauses, <speaker>) from TTS before sending it to speech synthesis;
    • shouldProcessChatSound(controller, platformName) — whether sound needs processing at all: sounds were added or speech_kit_token is set;
    • defaultSoundProcessing(soundInfo, defaultSounds, defaultEffects?) — the standard substitution of sounds and effects (used by Alice and Marusia);
    • getCorrectButtons(buttons, limit, appContext?) — trims the button array to the platform limit (10 by default; with appContext it logs a truncation warning);
    • serializePlatformPayload(payload, platform, appContext?) — serializes a button payload to a string, returns null with a warning instead of throwing;
    • layoutButtonRows(items, getRowLimit, platform, appContext?) — arranges buttons into keyboard rows by options._group (buttons.row()) respecting the row limit (see "Row layout" above).

    Media tokens: getImageToken and getSoundToken are covered above; cacheMediaToken(model, controller) — writes an already received token to the ImageTokens/SoundTokens model. The cache here is an optimization, not a requirement: without a connected DB adapter the write simply does not happen, and that is not an error.

    The full list with signatures is in the types of src/plugins/platforms/Base/utils.ts (the JSDoc of each helper).

    import { pUtils } from 'umbot/plugins';

    // Inside setQueryData when parsing a button press
    // (normalizeActionPayload lowercases the result itself):
    controller.userCommand = pUtils.normalizeActionPayload(payload);
    controller.originalUserCommand = controller.userCommand;

    controller.api is unified access to the platform's outgoing capabilities right from the business logic: send a photo or a file, answer a button press — without constructing platform Request classes by hand.

    bot.addEvent('callback', async (ctx) => {
    await ctx.api?.answerCallback('Accepted');
    await ctx.api?.sendPhoto('./report.png', { caption: 'Your report' });
    ctx.skipAutoReply = true; // the reply has already been sent manually
    });

    The adapter itself chooses the facade — with the optional createApi(controller) method. The base implementation returns null (the facade is unavailable), so by default controller.api on a custom platform is null, and it is enabled by overriding a single method — no core changes are needed. The facade is lazy: the object is created on the first access to ctx.api, and requests without API calls do not pay for it.

    Voice platforms (Alice, SmartApp, Marusia) do not need a facade: their reply is built as the webhook body, and media are sent via controller.card / controller.sound. Such adapters do not override the method.

    Method Purpose
    sendPhoto(image, params?) Send an image; params.caption is the caption
    sendDocument(file, params?) Send a document or a file
    sendAudio(file, params?) Send audio
    sendVideo(file, params?) Send video
    answerCallback(text, showAlert?) Answer a callback button press (a notification or a snackbar)
    can(method) Whether the platform supports a method — the method names are listed by the TApiMethod type

    The send methods return Promise<Record<string, unknown> | null>: null means sending failed. The IControllerApi, IApiMediaParams interfaces and the TApiMethod type are exported from umbot.

    The facade is a plain object, not a class. Build it with a factory that closes over the controller; take the request's technical data (the press identifier, the chat) from the adapter's storage filled in 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) {
    // Without a recipient we do not send a broken request — an explicit warning in the log
    controller.appContext?.logWarn(
    'controller.api.sendPhoto(): could not determine the recipient.',
    );
    return null;
    }
    // here goes the call to your platform API client
    return { chatId, image, caption: params?.caption ?? '' };
    },
    async sendDocument(): Promise<Record<string, unknown> | null> {
    return null; // the platform cannot do it — honestly return null (and 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(): the current request has no callback identifier — no button was pressed.',
    );
    return null;
    }
    return { callbackId, text };
    },
    can(method: TApiMethod): boolean {
    return (MY_SUPPORTED as readonly string[]).includes(method);
    },
    };
    }

    All that is left is to connect the factory to the adapter — with a single method:

    // An adapter method
    createApi(controller: BotController): IControllerApi | null {
    return makeMyApi(controller);
    }

    The rules the built-in facades follow:

    • can() does not lie. The method returns false where the platform physically cannot perform the operation. For example, the Viber facade answers false for all methods: its Bot API accepts media only by a public URL and with a required size, which the facade does not have.
    • Do not send a request that is known to be broken. If the recipient or the press identifier could not be determined — a warning in the log and null, not an API request with an empty field.
    • An unsupported method returns null rather than throwing. The business logic is cross-platform: the same handler runs both on a platform where the method exists and on one where it does not.

    The makePlatformApi(controller) dispatcher from umbot/plugins builds the facade of a built-in platform by controller.appType — it is kept for manual use; the core does not use it.

    Some platforms (Alice, SmartApp) can store the dialog state on their side. This avoids unnecessary database requests.

    To support this, implement 3 methods:

    1. isLocalStorage(controller) — returns true if the platform supports local storage.
    2. getLocalStorage(controller) — returns the data the platform sent in the request (usually in controller.state).
    3. setLocalStorage(data, controller) — called by the framework when data needs to be saved on the platform side (if the platform does not do this automatically through the response).

    A caveat: in setQueryData you must specify which response field the state goes into by filling controller.platformOptions.stateName (for example, 'session_state' or 'user_state_update').

    The task: build the final response according to the platform contract. The method receives controller (with all the business logic, text, buttons) and stateData (data for the local storage). There are two response paradigms:

    Paradigm A: webhook response (Alice, SmartApp) The platform waits for JSON in the HTTP response body.

    async getContent(controller: BotController, stateData?: Record<string, unknown>): Promise<object> {
    // 1. Build the UI with our processors
    const buttons = controller.buttons.getButtons(myPlatformButtonProcessing);
    const cards = await controller.card.getCards(myPlatformCardProcessing, controller);

    // 2. Build the response
    const response = {
    text: Text.resize(controller.text, 1024), // ALWAYS cut the text to the limits!
    tts: controller.tts,
    buttons: buttons,
    card: cards,
    end_session: controller.isEnd
    };

    // 3. Add the state (if the platform supports it)
    if (controller.platformOptions.stateName && stateData) {
    response[controller.platformOptions.stateName] = stateData;
    }

    return response;
    }

    Paradigm B: API call (Telegram, VK, Max) The platform expects you to send the reply yourself through its API, and the webhook just needs to return 200 OK.

    The controller.skipAutoReply flag is a signal for the core: "the request has already been answered or needs no answer". It is set by the adapter (usually in setQueryData) or by middleware when a request was handled without business logic — for example, an unknown platform event that cannot be answered. getContent only respects the flag: it sees it, does not send a message via the API and returns a neutral webhook body to the core. The core answers the webhook with HTTP 200 in any case — this matters: on 5xx Telegram replays the update forever, and VK disables the server.

    Mark unexpected platform events your adapter cannot handle with skipAutoReply = true in setQueryData (with return true), not return false — the latter leads to HTTP 400.

    async getContent(controller: BotController): Promise<string> {
    // 1. The request needs no auto-reply? Just return a stub for the webhook.
    if (controller.skipAutoReply) {
    return 'ok';
    }

    const api = new MyPlatformApi(controller.appContext);

    // Collect all UI components
    const keyboard = controller.buttons.getButtons(myPlatformButtonProcessing);
    const attachments = await controller.card.getCards(myPlatformCardProcessing, controller);
    const sounds = await controller.sound.getSounds(controller.tts, mySoundProcessing, controller);

    // Pass them to the platform API (the format depends on the platform itself)
    await api.sendMessage(controller.userId, Text.resize(controller.text, 4096), {
    keyboard,
    attachments, // An example for Discord/VK
    audio: sounds // An example
    });

    // 2. Return a stub for the webhook
    return 'ok';
    }

    The value returned from getContent goes into the body of the HTTP response to the webhook. If the platform requires a specific JSON response to the very fact of receiving a webhook (even if you have already sent a message via the API), return that JSON. If the platform accepts any 200 OK status, just return the string 'ok' or an empty object.

    For local testing with BotTest, define the getQueryExample method. It emulates a platform request, letting you check the application before deployment. The base class has a generic stub, but for an adapter you test the method is required: without overriding it, BotTest.simulate() cannot generate a valid payload for your platform.

    Important: the format of the returned object must exactly match the request structure you parse in setQueryData.

    // For testing with BotTest
    getQueryExample(
    query: string,
    userId: string,
    count: number,
    state: Record<string, unknown> | string,
    ): Record<string, unknown> {
    // Return an object in YOUR platform's format
    // The same format is parsed in setQueryData
    return {
    message: {
    sender: { user_id: userId },
    body: {
    text: query,
    seq: count,
    },
    },
    state: state,
    };
    }

    Some platforms send service requests that must be answered with a prepared response without going through the application's business logic:

    • Alice periodically sends ping to check that the skill is available;
    • VK sends confirmation when the webhook is first set up — you need to return the confirmation string;
    • SmartApp may send healthcheck requests.

    To avoid running middleware/commands/action for such requests, set controller.platformOptions.sendInInit in setQueryData — the framework checks this field right after setQueryData and, if it is filled, returns it as the response, skipping all further processing.

    setQueryData(query, controller) {
    // ... regular processing ...

    // Did Yandex send a ping?
    if (query.request.original_utterance === 'ping') {
    controller.platformOptions.sendInInit = {
    version: '1.0',
    response: { text: 'pong' },
    };
    // It is important to return true: with false the core answers the webhook with HTTP 400,
    // and on 4xx/5xx Telegram replays the update forever, while VK disables the server.
    return true;
    }
    return true;
    }

    The sendInInit value format: string | object | null:

    • object — sent in the HTTP response body as JSON (for Alice and SmartApp this is the { version, response, ... } structure).
    • string — sent as plain text (for the VK confirmation).
    • null / undefined — regular processing (the default).

    If the platform has a hard requests-per-second limit (for example, 30 req/sec for Telegram/Max), specify it in the adapter class. The built-in rateLimiter middleware reads the limit value.

    export class MyPlatformAdapter extends BasePlatformAdapter {
    limit = 30; // Tell the framework about the limit
    }

    The limit field by itself limits nothing — you must explicitly connect the rateLimiter middleware:

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

    Only then does the framework use the adapter's limit value to limit the number of requests.

    Voice platforms strictly limit the response time: the framework uses the thresholds WARNING_TIME_REQUEST = 2000 ms (a warning) and MAX_TIME_REQUEST = 2900 ms (an error) — Alice's limit is about 3 seconds, other platforms differ. The check is not automatic: call this._timeLimitLog(controller) in your getContent after building the response. The built-in voice adapters (Alisa, Marusia, SmartApp) do this; chat platform adapters do not call it. Without the call, slow responses do not get into the logs. The thresholds can be overridden in a subclass. And most importantly — do not do heavy synchronous operations inside getContent.

    The time is counted from the mark set by updateTimeStart(controller) (the core calls it before the business logic), and getProcessingTime(controller) returns the elapsed milliseconds. Both methods are public — use them if you compute your own metrics.

    When the business logic sets controller.isSendRating = true, the core builds the response not via getContent but via getRatingContext(controller). The base implementation simply calls getContent, so you need to override the method only for platforms with a special rating request format (SmartApp does this).

    // An adapter method: TContent allows both an object and a promise
    getRatingContext(controller: BotController): Promise<object> {
    return Promise.resolve({
    messageName: 'CALL_RATING',
    payload: { text: controller.text },
    });
    }

    bot.send(userId, textOrController, platform) lets the application message the user first. The core finds the adapter by the platform name and calls its send(userId, controllerOrText) method.

    The base implementation already works: it wraps a string into a controller, sets userId and calls getContent. For a platform that sends the reply via the API (paradigm B) this is enough — nothing special needs to be done. Override the method only if the platform requires a different request for proactive messages or does not support them at all — then return false.

    The built-in platform clients log failures the same way, and the same helpers are available to your adapter (exported from umbot/plugins):

    • getErrorMsg(error, path, url) — a request error message: the source, the URL and the error text;
    • getErrorToken(platform, methodName) — a message saying that no token is set for the platform.
    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;
    }
    }

    Always set a timeout in your own platform HTTP client — a request without a time limit hangs webhook processing. The built-in clients are built on the Request class from umbot, which has a default timeout (details are in the http-client.md document).