umbot
    Preparing search index...

    Middleware in umbot: handling a request before commands

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

    umbot supports telegraf- and vk-io-style middleware — functions that are called before the business logic runs (BotController.action).

    import { T_ALISA } from 'umbot/plugins';

    // Global middleware (for all platforms)
    bot.use(async (ctx, next) => {
    console.log('Request:', ctx.appType);
    await next(); // you must call next() to continue
    });

    // middleware for a specific platform
    bot.use(T_ALISA, async (ctx, next) => {
    // requestObject is the raw platform payload (unknown), a cast is required
    const request = ctx.requestObject as Record<string, unknown> | null;
    const session = request?.session as Record<string, unknown> | undefined;
    if (!session?.user_id) {
    ctx.text = 'Invalid request';
    ctx.isEnd = true;
    // next() is not called → action() will not run
    return;
    }
    await next();
    });
    • Middleware receives the full BotController (with text, isEnd, userData, etc.).
    • If you do not call next(), action() will not be called — that is fine.
    • Middleware runs strictly before the handler (action(), commands, steps): it is not a Koa-style "onion". The order is: first the whole global chain — including the code after await next(), then the platform chain, and only then the handler. Example with a global mw1 (logging before/after next()) and a platform mw2: the order is 1, 4, 2, 3, action() — the code after next() in mw1 runs before the platform chain and before the handler. So after await next() the response (ctx.text, buttons) is not built yet — do not read it there. The full response is available in responseCb of bot.start() / bot.webhookHandle() (example below).
    • Do not get carried away with nested middleware logic: every "before/after next()" branch makes tracing harder. If a middleware is no longer transparent (nested conditions, hidden state), move the logic into a plugin or a command handler.

    npx umbot add middleware <name> creates a scaffold of a middleware factory with options and a test — the file src/middleware/<name>.ts (more in the CLI description).

    bot.use(async (ctx, next) => {
    console.log(`[${ctx.appType}] Request from ${ctx.userId}: ${ctx.userCommand}`);
    await next();
    });

    In middleware the response is not built yet (the handler runs after all middleware). To log the response, use responseCb of the built-in server (webhookHandle has the same third argument):

    bot.start('0.0.0.0', 3000, (_req, res, state) => {
    console.log(`Response ${state.statusCode}:`, state.body);
    state.defaultSend(res, state);
    });
    bot.use(async (ctx, next) => {
    // Let the greeting and help through
    if (ctx.messageId === 0 || ctx.userCommand === 'help') {
    await next();
    return;
    }

    // Check whether the user is authorized
    if (!ctx.userData?.isAuthorized) {
    ctx.text = 'You need to sign in to use this bot.';
    ctx.isEnd = true;
    return; // next() is not called — action() will not run
    }

    await next();
    });
    import { T_TELEGRAM } from 'umbot/plugins';

    // Telegram only
    bot.use(T_TELEGRAM, async (ctx, next) => {
    // ⚠️ payload can be a string or an object — always narrow the type first
    const payload = ctx.payload as { command?: string } | null | undefined;
    if (payload?.command === 'cancel') {
    ctx.text = 'Action cancelled.';
    ctx.isEnd = true;
    return;
    }
    await next();
    });

    The framework ships with built-in middleware that limits the rate of incoming requests (rateLimiter). The limit comes from the platform adapter's limit property: for Telegram, VK, Viber and MAX it is 30 req/sec. Outgoing MAX messages are limited separately by the API client queue to 2 messages per second per dialog. If limit is not set or is 0/null, rateLimiter lets requests through without limits.

    import { rateLimiter } from 'umbot/middleware';

    bot.use(rateLimiter()); // default: queue=100, idle=60s
    // or with custom parameters
    bot.use(rateLimiter(200, 120_000)); // queue=200, idle 2 min
    // a shared limit per platform instead of a per-user limit
    bot.use(rateLimiter(100, 60_000, (ctx) => ctx.appType ?? ''));

    Counter key (getKey, the third parameter). By default the limit is counted per {platform, userId} pair. If the platform has no webhook signature or the secret is not set, userId is chosen by the sender of the request, and by changing it one can bypass the per-user limit. getKey sets your own key (the platform is appended to it): ctx => ctx.appType ?? '' limits the total load per platform. Keep in mind that the limit comes from the adapter's limit: Alice, SmartApp and Marusia do not set it, so the middleware does not limit them.

    What it does:

    • Reads appContext.platforms[platform].limit (TG/VK/Viber/MAX = 30 by default; 0/null — no limits).
    • Keeps a fixed 1-second window per {platform, userId}: the request counter is reset every second.
    • When the limit is exceeded, it queues the request (up to maxQueueSize).
    • Queue overflow → the request is rejected: the middleware sets ctx.platformOptions.rateLimitOverflow = true and throws RateLimitQueueOverflowError (exported from umbot/middleware). The core logs the error and does not run command handling; the platform gets 200, so there is no redelivery. The flag and the error class let you tell an overload rejection from an error in the business logic (for example, in responseCb of bot.start()).
    • destroyRateLimiter() (also from umbot/middleware) stops the cleanup timers and rejects the queued requests of all created limiters — for hot reload and tests; you do not need to call it on a normal process exit, the timers do not keep the process alive.
    • Stores up to 10 000 keys: a new key beyond the limit evicts the least recently active one (with no queue and no ongoing processing). Eviction and cleanup of inactive entries (inactivityTimeout) are O(1) per entry: a stream of requests with new userIds (on Alice and Marusia it is chosen by the sender) does not turn every request into a walk over all keys.
    • All timers are .unref() — they do not block the process from exiting.

    Important: rateLimiter limits incoming requests (from the platform to you), not outgoing API calls (from you to the platform API).


    Checks whether the user is allowed to continue the dialog.

    import { authGuard } from 'umbot/middleware';

    // An allowlist of user IDs
    const ADMIN_IDS = ['12345', '67890'];

    bot.use(
    authGuard((ctx) => ADMIN_IDS.includes(String(ctx.userId)), {
    deniedText: 'This command is available to administrators only',
    }),
    );

    // An asynchronous check — for example, via a database
    bot.use(
    authGuard(async (ctx) => {
    const user = await db.users.findOne({ id: ctx.userId });
    return !!user?.isActive;
    }),
    );

    Behavior:

    • If check returned true, next() is called.
    • If it returned false or check threw an exception, the user gets deniedText and next() is NOT called.
    • Errors in check are logged via appContext.logError but do not break the pipeline.

    Signature: authGuard(check, options?) where check: (ctx) => boolean | Promise<boolean>.


    Assigns a unique requestId to every incoming request — useful for end-to-end log tracing.

    import { requestId } from 'umbot/middleware';

    bot.use(requestId());

    // In other middleware or commands:
    // ⚠️ addCommand requires non-empty slots (except welcome/help — they have defaults).
    // A command with an empty slots array is simply not registered.
    bot.addCommand('debug', ['debug'], (_, ctx) => {
    console.log('request id:', ctx.platformOptions.requestId);
    });

    What it does:

    • Sets ctx.platformOptions.requestId = crypto.randomUUID() (or falls back to timestamp+random on old runtimes).
    • All logs of a single request can now be linked by a shared ID.

    Replies "the service is under maintenance" while check() returns true.

    import { maintenance } from 'umbot/middleware';

    let isDown = false;

    // An admin command can change isDown
    bot.addCommand('admin_maintenance', ['enable maintenance'], (_, ctx) => {
    isDown = true;
    ctx.text = 'Maintenance mode ON';
    });

    bot.use(
    maintenance(() => isDown, {
    message: 'The bot is being updated. Please try again in 5 minutes.',
    }),
    );

    Behavior:

    • If check() returns false, the request proceeds normally.
    • If true, the user gets message and next() is not called.
    • If check() throws, the request is let through (protection against accidentally taking the service down).
    • Both synchronous and asynchronous check functions are supported.

    Filters incoming requests by client IP.

    import { ipFilter } from 'umbot/middleware';

    // Trusted networks only (for example, the platform's IP ranges)
    bot.use(
    ipFilter({
    whitelist: ['203.0.113.0/24', '198.51.100.0/24'],
    deniedText: 'Forbidden',
    rejectWithoutIp: true, // a request with an unknown client IP is rejected too
    }),
    );

    // Or a blacklist (block spam ranges)
    bot.use(
    ipFilter({
    blacklist: ['203.0.113.42', '198.51.100.0/24'],
    }),
    );

    Behavior:

    • Supports both plain IPs ('192.168.1.10') and CIDR ('10.0.0.0/8').
    • IPv6-mapped addresses are normalized to IPv4 automatically (::ffff:127.0.0.1 → 127.0.0.1).
    • The IP is taken from ctx.platformOptions.clientIp. In bot.start()/webhookHandle() the framework fills it from req.socket.remoteAddress; in serverless you have to pass it as the third argument: bot.webhookEvent(body, headers, clientIp). The Yandex Cloud Functions handler generated by the CLI (create from-flow --usecloud) passes event.requestContext.identity.sourceIp.
    • Unknown IP (bot.run()/BotTest, webhookEvent() without clientIp): by default the request is let through without filtering (with a single warning in the log), so the bot does not break in dev/test environments. The rejectWithoutIp: true option rejects such requests — enable it in production together with whitelist, otherwise a forgotten clientIp silently disables the filter. Long polling (bot.startPolling()) has no client IP at all: with rejectWithoutIp: true all updates are rejected, and the filter itself is not needed for polling.
    • Behind a reverse proxy all requests come from the proxy's IP: X-Forwarded-For is not used because the client can forge it. Behind a proxy, restrict access at the proxy level.

    ⚠️ Important: ipFilter does NOT replace real protection with a reverse proxy / firewall. It is an additional layer.


    import { MiddlewareNext, BotController } from 'umbot';

    export function myMiddleware(options: { skip?: boolean } = {}) {
    return async (ctx: BotController, next: MiddlewareNext): Promise<void> => {
    try {
    // your logic BEFORE the request is handled
    } catch (e) {
    ctx.appContext.logError('myMiddleware error', { error: e });
    // Decide: hide the error (continue) or block (return without next)
    }
    await next(); // required so that commands/steps handle the request next
    // your logic AFTER handling (for example, timing, response logging)
    };
    }

    Rules:

    1. Always call next() or deliberately finish the response with ctx.text = ... (without next()).
    2. Always wrap risky operations in try/catch: the core logs an unhandled exception and replies 200 to the platform, but the commands will not run and the user will get no reply.
    3. Middleware is called in registration order (bot.use(mw1); bot.use(mw2); → mw1 first).
    4. If a middleware is platform-specific, use bot.use(T_TELEGRAM, mw).