@yaebal/keyboard

fluent inline and reply keyboard builders — full button coverage.

install

terminal
pnpm add @yaebal/keyboard

inline keyboard

InlineKeyboard builds an inline_keyboard markup. buttons accumulate into the current row; call .row() to start a new one. call .build() to get the final InlineKeyboardMarkup object to pass as reply_markup.

menu.ts
import { Bot } from "@yaebal/core";
import { InlineKeyboard } from "@yaebal/keyboard";

const bot = new Bot(process.env.BOT_TOKEN!);

bot.command("menu", (ctx) => {
  const kb = new InlineKeyboard()
    .text("ban", "action:ban")
    .text("warn", "action:warn")
    .row()
    .url("view profile", "https://t.me/username")
    .build();

  return ctx.reply("choose an action:", { reply_markup: kb });
});

reply keyboard

Keyboard builds a keyboard markup. same row model as InlineKeyboard. boolean flags like resized() and oneTime() are only included in the output when set to true.

start.ts
import { Keyboard } from "@yaebal/keyboard";

bot.command("start", (ctx) => {
  const kb = new Keyboard()
    .text("yes")
    .text("no")
    .row()
    .requestContact("share phone")
    .resized()
    .oneTime()
    .build();

  return ctx.reply("ready?", { reply_markup: kb });
});

web app, switch inline, login, copy text

inline.ts
const kb = new InlineKeyboard()
  .webApp("open app", "https://yaebal.mom")
  .row()
  .switchInline("share", "my query")
  .row()
  .login("log in", "https://example.com/auth")
  .copyText("copy code", "ABC-123")
  .build();

styling a button

.style() and .icon() apply to the button most recently added — on either builder. call them right after the button they should affect. on inline buttons you can also skip the chaining and pass a third deco argument — either { style, icon } or a "<customEmojiId>:<style>" string shorthand (a bare style or a bare custom-emoji id works too).

style.ts
// style()/icon() modify the button that was just added — call them right after it
const kb = new InlineKeyboard()
  .text("delete", "action:delete")
  .style("danger")
  .text("confirm", "action:confirm")
  .style("success")
  .build();

// or pass a third `deco` argument to decorate the button inline — an object,
// or a "<customEmojiId>:<style>" string shorthand:
const inline = new InlineKeyboard()
  .text("delete", "action:delete", { style: "danger", icon: "5368324170671202286" })
  .text("confirm", "action:confirm", "success")           // bare style
  .text("star", "action:star", "5368324170671202286")     // bare custom-emoji id
  .text("pin", "action:pin", "5368324170671202286:primary") // both
  .build();

request user / chat / managed bot

These reply-keyboard buttons ask the user to pick something and send it back as a service message (users_shared, chat_shared) or, for managed bots, as the managed_bot update and a message with managed_bot_created. every variant takes a requestId you choose — it comes back unchanged so you can match the response to the button that triggered it.

request.ts
import { Keyboard } from "@yaebal/keyboard";

// requestId is echoed back on the *Shared update so you can tell buttons apart —
// it only needs to be unique within this keyboard.
const kb = new Keyboard()
  .requestUsers("pick a user", 1, { max_quantity: 1, user_is_bot: false })
  .row()
  .requestChat("pick a channel", 2, /* isChannel */ true, { request_title: true })
  .row()
  .requestManagedBot("create a bot for me", 3, { suggested_name: "My Shop Bot" })
  .resized()
  .build();

bot.on("message", (ctx) => {
  if (ctx.message.users_shared?.request_id === 1) {
    // ctx.message.users_shared.users
  }
});

// managed bots: created bot info arrives both ways
bot.on("managed_bot", (ctx) => {
  // ctx.bot — the newly created/updated bot's User
});

removing a keyboard / forcing a reply

Keyboard.remove() and Keyboard.forceReply() are static helpers that build the other two reply_markup shapes Telegram supports — they don't need a builder instance.

remove.ts
import { Keyboard } from "@yaebal/keyboard";

// hide whatever reply keyboard is currently shown
await ctx.reply("ok", { reply_markup: Keyboard.remove() });

// open a reply input, as if the user tapped "reply" on this message
await ctx.reply("what's your name?", {
  reply_markup: Keyboard.forceReply({ input_field_placeholder: "your name" }),
});

buttons from dynamic data

add(...buttons) appends raw button objects — pair it with the static, instance-free builders (InlineKeyboard.text/url/webApp, Keyboard.text/requestUsers/requestChat/requestManagedBot) to turn an array into buttons without a hand-rolled loop. columns(n) auto-wraps every n buttons into a new row; call it with no argument to go back to manual .row().

dynamic.ts
const products = await getProducts();

// static, instance-free builders (InlineKeyboard.text/url/webApp,
// Keyboard.text/requestUsers/requestChat/requestManagedBot) return a raw button —
// add() appends any number of them at once.
const kb = new InlineKeyboard()
  .columns(2) // auto-wraps into rows of 2 — no manual .row() bookkeeping
  .add(...products.map((p) => InlineKeyboard.text(p.name, `buy:${p.id}`)))
  .build();

passing the builder directly

InlineKeyboard and Keyboard both implement toJSON() (an alias for build()). Api serializes reply_markup with JSON.stringify, which calls toJSON() automatically — so passing the builder itself works, and .build() is only needed when you want the plain object (e.g. to inspect or store it).

tojson.ts
// InlineKeyboard/Keyboard implement toJSON(), so Api's JSON.stringify(reply_markup)
// picks it up automatically — .build() is optional here
await ctx.reply("pick one", {
  reply_markup: new InlineKeyboard().text("ok", "ok"),
});

api

InlineKeyboard

methodsignaturedescription
text(label: string, data: string) => thisbutton with callback_data
url(label: string, url: string) => thisbutton that opens a URL
webApp(label: string, url: string) => thisbutton that opens a Telegram Web App
login(label: string, url: string, options?: Omit<LoginUrl, "url">) => thisseamless login button (login_url)
switchInline(label: string, query?: string) => thisprompts the user to pick a chat and inserts the query there; query defaults to ""
switchInlineCurrentChat(label: string, query?: string) => thisinserts the query in the current chat instead of prompting
switchInlineChosenChat(label: string, options?: SwitchInlineQueryChosenChat) => thislike switchInline, restricted to chosen chat types
copyText(label: string, text: string) => thiscopies text to the clipboard when pressed
pay(label: string) => thisStars/invoice pay button — must be first button of the first row
game(label: string) => thislaunches the bot's @BotFather game — must be first button of the first row
style(style: "danger" | "success" | "primary") => thisstyles the most recently added button
icon(customEmojiId: string) => thisshows a custom emoji before the label of the most recently added button
row() => thisend the current row; no-op if the row is empty
add(...buttons: InlineKeyboardButton[]) => thisappends raw button objects — combine with the static builders below for dynamic data
columns(columns?: number) => thisauto-wraps into rows of columns; no argument disables it
build() => InlineKeyboardMarkupreturns the finished markup; does not mutate the builder
toJSON() => InlineKeyboardMarkupalias for build(), picked up by JSON.stringify
InlineKeyboard.text(label: string, data: string) => InlineKeyboardButtonstatic — builds a raw button, no instance needed
InlineKeyboard.url(label: string, url: string) => InlineKeyboardButtonstatic — builds a raw button, no instance needed
InlineKeyboard.webApp(label: string, url: string) => InlineKeyboardButtonstatic — builds a raw button, no instance needed

Keyboard

methodsignaturedescription
text(label: string) => thisplain text button
requestContact(label: string) => thisbutton that requests the user's phone number
requestLocation(label: string) => thisbutton that requests the user's location
requestPoll(label: string, type?: "quiz" | "regular") => thisasks the user to compose and send a poll
webApp(label: string, url: string) => thisbutton that opens a Telegram Web App
requestUsers(label: string, requestId: number, options?: Omit<KeyboardButtonRequestUsers, "request_id">) => thisopens a user picker; result comes back as users_shared
requestChat(label: string, requestId: number, isChannel: boolean, options?: Omit<KeyboardButtonRequestChat, "request_id" | "chat_is_channel">) => thisopens a chat picker; result comes back as chat_shared
requestManagedBot(label: string, requestId: number, options?: Omit<KeyboardButtonRequestManagedBot, "request_id">) => thisasks the user to create a bot managed by yours
style(style: "danger" | "success" | "primary") => thisstyles the most recently added button
icon(customEmojiId: string) => thisshows a custom emoji before the label of the most recently added button
row() => thisend the current row; no-op if the row is empty
add(...buttons: KeyboardButton[]) => thisappends raw button objects — combine with the static builders below for dynamic data
columns(columns?: number) => thisauto-wraps into rows of columns; no argument disables it
persistent(value?: boolean) => thisset is_persistent; defaults to true
resized(value?: boolean) => thisset resize_keyboard; defaults to true
oneTime(value?: boolean) => thisset one_time_keyboard; defaults to true
placeholder(text: string) => thisset input_field_placeholder
selective(value?: boolean) => thisset selective; defaults to true
build() => ReplyKeyboardMarkupreturns the finished markup; does not mutate the builder
toJSON() => ReplyKeyboardMarkupalias for build(), picked up by JSON.stringify
Keyboard.text(label: string) => KeyboardButtonstatic — builds a raw button, no instance needed
Keyboard.requestUsers(label: string, requestId: number, options?: Omit<KeyboardButtonRequestUsers, "request_id">) => KeyboardButtonstatic — builds a raw button, no instance needed
Keyboard.requestChat(label: string, requestId: number, isChannel: boolean, options?: Omit<KeyboardButtonRequestChat, "request_id" | "chat_is_channel">) => KeyboardButtonstatic — builds a raw button, no instance needed
Keyboard.requestManagedBot(label: string, requestId: number, options?: Omit<KeyboardButtonRequestManagedBot, "request_id">) => KeyboardButtonstatic — builds a raw button, no instance needed
Keyboard.remove(selective?: boolean) => ReplyKeyboardRemovestatic — hides the current reply keyboard
Keyboard.forceReply(options?: Omit<ForceReply, "force_reply">) => ForceReplystatic — opens a reply input for the message

types

re-exported from @yaebal/types for convenience — no need to depend on it directly just to type a keyboard.

exportdescription
InlineKeyboardMarkupshape returned by InlineKeyboard.build()
InlineKeyboardButtonsingle button in an inline keyboard
ReplyKeyboardMarkupshape returned by Keyboard.build()
KeyboardButtonsingle button in a reply keyboard
ReplyKeyboardRemoveshape returned by Keyboard.remove()
ForceReplyshape returned by Keyboard.forceReply()
KeyboardButtonRequestUsersoptions for requestUsers()
KeyboardButtonRequestChatoptions for requestChat()
KeyboardButtonRequestManagedBotoptions for requestManagedBot()
LoginUrloptions for login()
SwitchInlineQueryChosenChatoptions for switchInlineChosenChat()
.build() always returns a snapshot. mutating the builder after calling .build() does not affect the already-returned markup — the rows are cloned at build time.

a trailing .row() before .build() is safe — an empty in-progress row is not emitted, so you can end every row with .row() without producing a blank final row.

.style() / .icon() throw if called before any button was added — there's nothing yet to style. they still find the last button after a .row() flush (manual or columns()-triggered), so .text(...).row().style(...) works as expected.