Framework

June 22, 2026 ยท View on GitHub

The framework layer adds a command router, conversations, sessions, a webhook receiver, flood-control retries, and structured logging/metrics. It is fully additive and opt-in: if you register no command, hears, or conversation, dispatch is unchanged and your existing on_* handlers run as before.

Command Router

Register handlers by command name. Handlers receive a context, not a raw message.

local api = require('telegram-bot-lua').configure(os.getenv('BOT_TOKEN'))

api.command('start', function(ctx)
    ctx.reply('Welcome!')
end)

-- guarded command: the handler only runs if guard(ctx) returns truthy
api.command('ban', {
    guard = function(ctx) return api.is_user_group_admin(ctx.chat_id, ctx.from.id) end,
    on_denied = function(ctx) ctx.reply('Admins only.') end
}, function(ctx)
    -- ctx.args is the parsed argument list, ctx.args_str the raw remainder
    ctx.reply('Banning ' .. (ctx.args[1] or '?'))
end)

-- match free text with a lua pattern or a function(text)
api.hears('^hello', function(ctx) ctx.reply('Hi there!') end)
api.hears(function(text) return text:find('bye') end, function(ctx) ctx.reply('Bye!') end)

-- fallback for a /command with no registered handler
api.on_command_not_found(function(ctx)
    ctx.reply('Unknown command: ' .. ctx.command)
end)

api.run({ timeout = 60 })

Dispatch order per update is: pending conversation, then commands, then hears. The first match consumes the update; otherwise the on_* handlers run.

Context

api.build_context(update) turns a raw update into a ctx. The router builds it for you, but you can call it directly when handling updates yourself.

Fields:

FieldDescription
ctx.updatethe raw update
ctx.update_typee.g. 'message', 'callback_query', 'inline_query'
ctx.payloadthe update payload for that type
ctx.messagethe message (for message-like updates, or a callback's message)
ctx.chat / ctx.chat_idthe chat and its id
ctx.fromthe sender (User)
ctx.sessionthe session table for this chat/user (if sessions are loaded)

Inside command/hears handlers, ctx.command, ctx.args, ctx.args_str, and ctx.match are also set.

Methods:

ctx.reply(text, opts)              -- send_message to ctx.chat_id
ctx.reply_with_photo(photo, opts)  -- send_photo to ctx.chat_id
ctx.answer(opts)                   -- answer_callback_query (callback updates only)
local ctx = api.build_context(update)
if ctx.update_type == 'callback_query' then
    ctx.answer({ text = 'Got it' })
end

Sessions

A per-chat/user store keyed by chat_id:user_id. The default backend is in-memory; swap it for a Redis- or DB-backed one.

-- the session for an update (also exposed as ctx.session)
local s = api.session.get(update)
s.count = (s.count or 0) + 1

api.session.set(update, { count = 0 })   -- replace the whole value
api.session.clear(update)                -- drop it

Backends are tables with get(self, key), set(self, key, value), and clear(self, key). api.session.memory() builds the default in-memory backend; api.session.use(backend) installs a replacement.

api.session.use(api.session.memory())   -- the default

-- a custom backend (e.g. backed by the redis adapter)
api.session.use({
    get   = function(_, key) return load_from_redis(key) or {} end,
    set   = function(_, key, value) save_to_redis(key, value) end,
    clear = function(_, key) delete_from_redis(key) end
})

Conversations

A conversation is a function(ctx) that can call ctx.wait_for() to suspend until the user's next message or callback arrives. It works in both sync and async (api.run()) modes.

api.conversation('signup', function(ctx)
    ctx.reply('What is your name?')
    local name = ctx.wait_for().message.text

    ctx.reply('And your age?')
    local age = ctx.wait_for().message.text

    ctx.session.name = name
    ctx.session.age = age
    ctx.reply('Thanks, ' .. name .. '!')
end)

api.command('signup', function(ctx)
    api.enter('signup', ctx.update)   -- start the wizard for this user
end)

api.run({ timeout = 60 })

api.enter(fn_or_name, update) starts a conversation: pass either a registered name or a function(ctx) directly. Each ctx.wait_for() returns the ctx of the next update from the same chat/user.

Webhook Receiver

Two entry points; both verify the x-telegram-bot-api-secret-token when a secret is configured.

Bring your own server

api.webhook.process(body, opts) verifies the secret and dispatches an update you already received over your own HTTP stack. body may be a raw JSON string or a decoded table.

-- inside your HTTP handler
local ok, reason = api.webhook.process(request_body, {
    secret_token    = os.getenv('WEBHOOK_SECRET'),         -- the secret set with setWebhook
    received_secret = headers['x-telegram-bot-api-secret-token']
})
-- ok is false + reason on a bad secret or invalid payload

Turnkey server

api.webhook.serve(opts) runs a minimal copas HTTP server that accepts Telegram webhooks.

api.webhook.serve({
    host = '0.0.0.0',
    port = 8443,
    path = '/bot',                       -- optional path prefix to require
    secret_token = os.getenv('WEBHOOK_SECRET')
})

It only accepts POST, returns 200 OK on success, and never lets a handler error take down the server. Pass { no_loop = true } to register the server without entering copas.loop() (when you run your own loop).

Retries / Flood Control

Every request runs under a retry policy. Telegram's 429 retry_after is honoured automatically, and transient connection failures back off with bounded exponential delay; a normal 4xx API error is returned immediately (not retried).

api.retry = {
    enabled = true,
    max_attempts = 3,
    base_delay = 1,    -- seconds
    max_delay = 30     -- cap for exponential backoff
}

api.retry.enabled = false   -- opt out entirely

Logging & Metrics

Structured logging through a configurable sink with level filtering, plus simple named counters.

api.log.level = 'debug'   -- 'debug' | 'info' | 'warn' | 'error' (default 'info')

api.log.debug('verbose detail')
api.log.info('update processed')
api.log.warn('slow response')
api.log.error('request failed', err)

-- route logs elsewhere (default prints "[level] message")
function api.log.sink(level, message)
    write_to_file(level, message)
end
api.metrics.incr('updates')        -- +1
api.metrics.incr('requests', 5)    -- +5
api.metrics.get('updates')         -- read one counter (0 if unset)
api.metrics.all()                  -- a shallow copy of all counters
api.metrics.reset()                -- zero everything