@yaebal/keyboard
fluent inline and reply keyboard builders — full button coverage.
install
pnpm add @yaebal/keyboardinline 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.
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.
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
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()/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.
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.
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().
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).
// 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
| method | signature | description |
|---|---|---|
text | (label: string, data: string) => this | button with callback_data |
url | (label: string, url: string) => this | button that opens a URL |
webApp | (label: string, url: string) => this | button that opens a Telegram Web App |
login | (label: string, url: string, options?: Omit<LoginUrl, "url">) => this | seamless login button (login_url) |
switchInline | (label: string, query?: string) => this | prompts the user to pick a chat and inserts the query there; query defaults to "" |
switchInlineCurrentChat | (label: string, query?: string) => this | inserts the query in the current chat instead of prompting |
switchInlineChosenChat | (label: string, options?: SwitchInlineQueryChosenChat) => this | like switchInline, restricted to chosen chat types |
copyText | (label: string, text: string) => this | copies text to the clipboard when pressed |
pay | (label: string) => this | Stars/invoice pay button — must be first button of the first row |
game | (label: string) => this | launches the bot's @BotFather game — must be first button of the first row |
style | (style: "danger" | "success" | "primary") => this | styles the most recently added button |
icon | (customEmojiId: string) => this | shows a custom emoji before the label of the most recently added button |
row | () => this | end the current row; no-op if the row is empty |
add | (...buttons: InlineKeyboardButton[]) => this | appends raw button objects — combine with the static builders below for dynamic data |
columns | (columns?: number) => this | auto-wraps into rows of columns; no argument disables it |
build | () => InlineKeyboardMarkup | returns the finished markup; does not mutate the builder |
toJSON | () => InlineKeyboardMarkup | alias for build(), picked up by JSON.stringify |
InlineKeyboard.text | (label: string, data: string) => InlineKeyboardButton | static — builds a raw button, no instance needed |
InlineKeyboard.url | (label: string, url: string) => InlineKeyboardButton | static — builds a raw button, no instance needed |
InlineKeyboard.webApp | (label: string, url: string) => InlineKeyboardButton | static — builds a raw button, no instance needed |
Keyboard
| method | signature | description |
|---|---|---|
text | (label: string) => this | plain text button |
requestContact | (label: string) => this | button that requests the user's phone number |
requestLocation | (label: string) => this | button that requests the user's location |
requestPoll | (label: string, type?: "quiz" | "regular") => this | asks the user to compose and send a poll |
webApp | (label: string, url: string) => this | button that opens a Telegram Web App |
requestUsers | (label: string, requestId: number, options?: Omit<KeyboardButtonRequestUsers, "request_id">) => this | opens a user picker; result comes back as users_shared |
requestChat | (label: string, requestId: number, isChannel: boolean, options?: Omit<KeyboardButtonRequestChat, "request_id" | "chat_is_channel">) => this | opens a chat picker; result comes back as chat_shared |
requestManagedBot | (label: string, requestId: number, options?: Omit<KeyboardButtonRequestManagedBot, "request_id">) => this | asks the user to create a bot managed by yours |
style | (style: "danger" | "success" | "primary") => this | styles the most recently added button |
icon | (customEmojiId: string) => this | shows a custom emoji before the label of the most recently added button |
row | () => this | end the current row; no-op if the row is empty |
add | (...buttons: KeyboardButton[]) => this | appends raw button objects — combine with the static builders below for dynamic data |
columns | (columns?: number) => this | auto-wraps into rows of columns; no argument disables it |
persistent | (value?: boolean) => this | set is_persistent; defaults to true |
resized | (value?: boolean) => this | set resize_keyboard; defaults to true |
oneTime | (value?: boolean) => this | set one_time_keyboard; defaults to true |
placeholder | (text: string) => this | set input_field_placeholder |
selective | (value?: boolean) => this | set selective; defaults to true |
build | () => ReplyKeyboardMarkup | returns the finished markup; does not mutate the builder |
toJSON | () => ReplyKeyboardMarkup | alias for build(), picked up by JSON.stringify |
Keyboard.text | (label: string) => KeyboardButton | static — builds a raw button, no instance needed |
Keyboard.requestUsers | (label: string, requestId: number, options?: Omit<KeyboardButtonRequestUsers, "request_id">) => KeyboardButton | static — builds a raw button, no instance needed |
Keyboard.requestChat | (label: string, requestId: number, isChannel: boolean, options?: Omit<KeyboardButtonRequestChat, "request_id" | "chat_is_channel">) => KeyboardButton | static — builds a raw button, no instance needed |
Keyboard.requestManagedBot | (label: string, requestId: number, options?: Omit<KeyboardButtonRequestManagedBot, "request_id">) => KeyboardButton | static — builds a raw button, no instance needed |
Keyboard.remove | (selective?: boolean) => ReplyKeyboardRemove | static — hides the current reply keyboard |
Keyboard.forceReply | (options?: Omit<ForceReply, "force_reply">) => ForceReply | static — 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.
| export | description |
|---|---|
InlineKeyboardMarkup | shape returned by InlineKeyboard.build() |
InlineKeyboardButton | single button in an inline keyboard |
ReplyKeyboardMarkup | shape returned by Keyboard.build() |
KeyboardButton | single button in a reply keyboard |
ReplyKeyboardRemove | shape returned by Keyboard.remove() |
ForceReply | shape returned by Keyboard.forceReply() |
KeyboardButtonRequestUsers | options for requestUsers() |
KeyboardButtonRequestChat | options for requestChat() |
KeyboardButtonRequestManagedBot | options for requestManagedBot() |
LoginUrl | options for login() |
SwitchInlineQueryChosenChat | options 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.