Skip to content
junioraww edited this page Nov 16, 2025 · 1 revision

Keygram

Everything you need to know about the library:

  1. How handlers (event processors) work
  2. How keyboards work
  3. How to use states

Handlers

A Handler (or Middleware) processes requests from Telegram, and Telegram, in turn, forwards user requests to us.

The difference between a Handler and Middleware is that a Handler processes a specific event (for example, the user sends text), while Middleware processes everything at once (e.g., for logging).

Telegram Bot API provides the Update class as a universal object for all requests. When a user sends a message, Telegram sends our bot a JSON request like:

{
  "update_id": 12345,
  "message": {
    ...
  } 
}

Inside message is everything a message can contain. For example, if the user sends plain text, message looks like this:

{
  "message_id": 12345,
  "chat": {
    "id": 2281337
  },
  "from": {
    "id": 666228,
    "first_name": "Maxim"
  },
  "text": "Hello!"
}

If we want the bot to respond to text messages, we write:

import { TelegramBot } from 'keygram'

const bot = new TelegramBot("token_from_@botfather")

bot.on('message:text', ctx => {
  return ctx.reply("Hello world!")
})

bot.startPolling()

This is the simplest possible program.

Here, bot.on(...) adds a Handler, and bot.startPolling() starts the processing loop.

After bot.startPolling() the bot begins actively “polling” Telegram servers to check for new messages.

Since when opening a bot in Telegram clients we are first asked to send /start, let’s handle that message specially:

bot.text('/start', ctx => {
  return ctx.reply("Welcome!")
})

bot.on('message:text', ctx => {
  return ctx.reply("Got it!")
})

Now, when sending /start, our primitive bot will respond differently.

Note that this library does not have a next() argument like many JS Telegram libraries that rely on Middleware. By default, all added handlers continue execution — i.e., they act like next(). To stop execution inside a Handler, you must return a truthy value. In the example above, we return ctx.reply(), which is always truthy (not undefined, 0, null, etc.).

All available Update parameters can be found here. All available Message parameters can be found here.

For convenience, some Message parameters can be used without specifying message:

bot.on("video", handleVideo)
bot.on("new_chat_member", handleNewbie)
// BUT:
bot.on("poll", handlePoll) // <-- Works differently

According to the Telegram Bot API docs, Poll is an Update-level object that we receive when new votes are cast in polls sent by the bot.

If you want to listen specifically for messages with polls (not votes), use: bot.on("message:poll", handlePoll)

Panel & Keyboard

When bots first appeared, everyone used commands. Now, all messengers have keyboards, and we can use them to build scenarios.

import { Panel, Keyboard } from 'keygram'

const sendClick = ctx => {
  ctx.reply("Click!")
}

const startKeyboard = Panel().Callback("Press me", sendClick)

This is how you create a keyboard attached to a message. To send it:

ctx.reply("Message with keyboard", startKeyboard)
ctx.reply("Message with object", { keyboard: startKeyboard })
ctx.reply({ text: "Message", keyboard: startKeyboard })

All three options are handled correctly.

To send a keyboard attached to the bottom of the screen instead of the message, use Keyboard() instead of Panel().

How does it work under the hood?

When the keyboard (Panel()) is initialized and buttons with Callback() are added, the library saves the functions and writes their names into callback_data. When the user presses a button, the bot extracts the function name from callback_data and executes it.

By default, to partially prevent forged requests, each callback_data contains a crypto-signature (generated from the function name, arguments, and bot token). However, this does not prevent repeated requests, so validation is still required.

States

When we need to press a button and then enter text, scenario management becomes complicated. The library includes StateManager to manage states and block certain Handlers for certain users (during interactions).

But first, we must configure State persistence between bot restarts:

// [EXAMPLE – Do NOT use in production!]
import { createClient } from "redis"

const redis = await createClient()

bot.state.save = async (ctx, new_state) => {
  await redis.set('user:' + ctx.from.id, JSON.stringify(new_state))
  // TODO: handle errors!
}

bot.state.load = async (ctx) => {
  return JSON.parse(await redis.get('user:' + ctx.from.id) || '{}')
}

// bot.state.setMaxSize(100)
// bot.state.setUnloadAfter(60)

This saves and loads states to/from a database. Here’s an example of using states:

function onInput(ctx) {
  if (!ctx.text) return ctx.reply("Enter text!")
  ctx.reply("Saved! You entered: " + ctx.text)
  ctx.state = { input: ctx.text }
}

bot.on('/input', async ctx => {
  ctx.state = { allow: [] }
  ctx.input(ctx)
})

bot.on('/show', async ctx => {
  const state = ctx.state || { input: null }
  ctx.reply("Saved value: " + state.input)
})

bot.register(onInput) // Don’t forget, so input continues working after restart!

When we set ctx.state = { allow: [] }, we block all Handlers that could interfere with the input process (await ctx.input()). For example, we ignore /show, repeated /input, button presses, etc.

Since state loading may be asynchronous (and in production — must be), synchronous get/set of ctx.state may cause race conditions. So it's better to use await bot.setState(...) — but for simplicity, this example is synchronous.

To allow pressing a button with a function like cancelInput, we do:

function cancelInput(ctx) {
  ctx.reset() // Same as ctx.state = {}
  ctx.answer("Canceled!")
}

const cancelKeyboard = Keyboard().Callback("Cancel", cancelInput)

function onInput(ctx) {
  if (!ctx.text) return ctx.reply("Enter text!")
  ctx.reply("Saved! You entered: " + ctx.text)
  ctx.state = { input: ctx.text }
}

bot.on('/input', async ctx => {
  ctx.state = { allow: 'cancelInput' }
  ctx.reply("Enter text", cancelKeyboard)
  ctx.input(onInput)
})

bot.register(onInput, cancelInput)

ctx.input() adds an input: [function_name] parameter to State, so input can continue after a bot restart.

The function can be simplified:

bot.on('/input', async ctx => {
  ctx.reply("Enter text", cancelKeyboard)
  ctx.input(onInput, { allow: 'cancelInput' }) // The second argument merges into ctx.state
})

Clone this wiki locally