BMH Bot SDK · Telegram
Build Telegram bots in code and Visual Flow.
A typed SDK that keeps code and canvas synchronized, while still exposing every Telegram Bot API method when you need lower-level control.
Move between Code and Flow without maintaining two different bot definitions.
Use concise helpers or call any documented Bot API method directly.
Use Web Standard requests, responses, FormData, and AbortSignal locally.
Start
Quick start
Create a command, reply to a user, and add an inline button.The editor already resolves @bmhien/bot. For local development, install the public package with npm install @bmhien/bot. The SDK repository also accepts direct installs from its main branch.
import { Bot } from "@bmhien/bot";
const bot = new Bot();
bot.command("start", async (ctx) => {
await ctx.reply("Welcome to my bot 👋");
await ctx.telegram.sendButtons("Choose an action", [
{ text: "Continue", data: "continue" },
]);
});
bot.callback("continue", async (ctx) => {
await ctx.telegram.answerCallback("Done");
});
export default bot;- 1Create the bot
Declare one
Botinstance and export it as the default value. - 2Register handlers
Use
command,callback, oronfor Telegram updates. - 3Save and inspect the flow
Supported statements appear as nodes immediately; diagnostics stay attached to their source block.
Start
Project structure
New projects begin with three editable files. Folders are optional and fully configurable.Use any layout you want
Move files or create folders from Explorer, then point entrypoint and visual_flow at the correct file. Resolvable relative imports are rewritten when a module or folder moves. Generated declarations live under the hidden, read-only .bmh directory.
{
"framework": "@bmhien/bot",
"version": 1,
"compatibility_date": "2026-08-25",
"platform": "telegram",
"entrypoint": "main.ts",
"visual_flow": "main.ts",
"runtime": { "delivery": "webhook" },
"project": {
"include": ["**/*"],
"exclude": ["node_modules/**", "dist/**"]
}
}entrypointFile used to start the bot.
visual_flowSource file synchronized with the canvas. It may be the same as the entrypoint.
project.includeGlob patterns for editable project files.
project.excludePatterns ignored by the project and editor.
Build
Events & commands
Register explicit handlers. A Telegram update contains at most one update payload.bot.command(name, handler)Runs for a slash command. A leading slash is optional and bot mentions are normalized.
bot.callback(data, handler)Runs for inline keyboard callback data. Use an empty string as a catch-all.
bot.on(updateType, handler)Runs for any of the 27 documented Telegram update types.
bot.command("help", (ctx) => ctx.reply("How can I help?"));
bot.callback("confirm", async (ctx) => {
await ctx.telegram.answerCallback("Confirmed");
await ctx.telegram.editMessage("✅ Confirmed");
});
bot.on("chat_member", async (ctx) => {
await ctx.reply("Membership changed");
});Poll answers, inline queries, shipping queries, pre-checkout queries, paid-media purchases, and managed-bot updates identify an actor but do not guarantee a chat. Use answerInlineQuery(), answerShippingQuery(), or answerPreCheckoutQuery() for their matching query lifecycle instead of ctx.reply().
Build
Context & templates
Handlers receive one normalized context so common code does not depend on raw Telegram payload shapes.ctx.updateThe original typed Telegram update.
ctx.updateTypeThe update field that was present, such as message or callback_query.
ctx.textNormalized message text, caption, query, callback data, or poll question.
ctx.chatIdAvailable only when Telegram supplies a real destination chat.
ctx.userIdThe actor ID. It is intentionally not treated as a chat ID.
ctx.messageIdCurrent message identifier when the update includes a message.
ctx.ephemeralMessageIdCurrent per-receiver ephemeral message identifier, when supplied by Telegram.
ctx.draftIdThe stopped draft identifier supplied by stopped_message_generation.
ctx.businessConnectionIdCurrent Telegram Business connection, inherited automatically by checklist helpers.
ctx.callbackQueryIdRequired by answerCallback().
ctx.inlineQueryIdIdentifier used automatically by answerInlineQuery().
ctx.shippingQueryIdIdentifier used automatically by answerShippingQuery().
ctx.preCheckoutQueryIdIdentifier used automatically by answerPreCheckoutQuery().
ctx.guestQueryIdIdentifier used automatically by answerGuestQuery().
ctx.successfulPaymentNormalized completed invoice with payload, amount, recurring state, and durable charge IDs.
ctx.messageThreadIdCurrent forum topic thread, used automatically by topic lifecycle helpers.
ctx.telegramTelegram helpers and the complete typed method escape hatch.
Canvas template values
Visual node fields can reference normalized values. Unknown or unavailable values render as an empty string.
{{update.type}}{{update.id}}{{message.text}}{{message.id}}{{message.ephemeral_id}}{{message.thread_id}}{{user.id}}{{user.first_name}}{{user.username}}{{chat.id}}{{chat.title}}{{callback.data}}{{poll.id}}{{business.connection_id}}{{payment.payload}}{{payment.currency}}{{payment.total_amount}}{{payment.telegram_charge_id}}{{payment.provider_charge_id}}{{payment.subscription_expiration_date}}{{inline.query_id}}{{shipping.query_id}}{{payment.pre_checkout_query_id}}{{guest.query_id}}{{draft.id}}Named action results
HTTP requests, generic Telegram API calls, forum-topic creation, and Business checklist actions can save their returned value for every later block on the same trigger path. The same syntax works inside a reusable defineFlowFunction() module.
const topic = await ctx.telegram.createForumTopic("Support");
await ctx.telegram.closeForumTopic(
"{{result.topic.message_thread_id}}",
);
const lookup = await ctx.http.request("https://api.example.com/user", {
method: "GET",
});
await ctx.reply("Status {{result.lookup.status}}: {{result.lookup.body.name}}");Each matching trigger receives an isolated result scope. HTTP captures expose status, ok, and body; Telegram captures expose the returned Bot API object directly. createForumTopic() also returns a typed TelegramForumTopic in local SDK code. Names are safe identifiers up to 32 characters. Captures are limited to 64 KB, five nested levels, 100 fields, and 4,096 characters per template value.
Core SDK
Reusable flow functions
Compose a large bot from named project modules without losing Code ↔ Flow synchronization.import { defineFlowFunction } from "@bmhien/bot";
export const welcome = defineFlowFunction(
"Welcome sequence",
async (ctx) => {
await ctx.telegram.sendChatAction("typing");
await ctx.reply("Welcome to BMH");
},
);import { Bot } from "@bmhien/bot";
import { welcome } from "./flows/welcome";
const bot = new Bot();
bot.command("start", async (ctx) => {
await ctx.run(welcome);
});
export default bot;Every valid named export appears as a draggable block with its module and function name.
Explorer rewrites resolvable static and dynamic relative imports when files or folders move.
Cycles, unresolved exports, more than 20 nested levels, and more than 200 compiled steps are rejected.
Chat, callback, query-answer, exactly-once, and terminal-action conflicts inside a function surface on its canvas node.
The synchronized subset discovers export const name = defineFlowFunction(...). Imports may pass through an index.ts barrel using a named alias or export *; generated code preserves that public import while cycles and ambiguous exports are rejected. Unrestricted application code can remain in other files and use the complete SDK normally.
Core SDK
Telegram client
Use the low-level client when running locally or when a specialist Bot API method does not need a dedicated helper.Public exports
BotRegisters commands, update listeners, and callback-query handlers.
TelegramClientSends validated JSON or multipart requests to the Telegram Bot API.
createTelegramWebhookHandlerTurns a bot and client into a Web Standard request handler.
runTelegramPollingRuns cancellable long polling with offset and retry handling.
dispatchTelegramUpdateNormalizes and dispatches one already-parsed update.
createTelegramContextBuilds the normalized context used by handlers.
defineFlowFunctionDeclares a reusable project function that runs through ctx.run().
TELEGRAM_METHODSReadonly catalog of all 185 recognized method names.
TELEGRAM_UPDATE_TYPESReadonly catalog of all 27 recognized update types.
TELEGRAM_CHAT_PERMISSION_FIELDSReadonly whitelist for current Telegram chat permission keys.
TELEGRAM_ADMINISTRATOR_RIGHT_FIELDSReadonly whitelist for current administrator-right keys.
Constructor and request options
const telegram = new TelegramClient(token, {
apiRoot: "https://api.telegram.org", // optional
fetch: globalThis.fetch, // optional
});
const me = await telegram.call("getMe", {}, {
signal: abortController.signal,
});call(method, params, options?)Sends JSON and returns Telegram's unwrapped result.
upload(method, formData, options?)Sends multipart data without overriding the generated boundary.
getFileUrl(fileId)Resolves a downloadable file URL after calling getFile.
Treat the value returned by getFileUrl() as a credential: do not persist it, log it, or send it to an untrusted client.
Build
Telegram helpers
Common operations use concise methods and map cleanly to visual nodes.ctx.reply(text)Send a text message to the current chat.
sendButtons(text, buttons)Send one inline keyboard row per button.
sendPhoto / sendDocumentSend media by file ID or supported URL with an optional caption.
sendAudio / sendVideo / sendAnimationSend common media formats.
sendVoice / sendVideoNoteSend Telegram voice and video-note messages.
sendLocation / sendVenue / sendContactSend structured location or contact data.
sendPoll / sendDiceCreate a poll or send a Telegram dice animation.
sendMessageDraftStream a temporary partial response in a private chat, with an optional Stop button.
editEphemeralMessageText / editEphemeralRichMessageReplace per-receiver ephemeral text or structured rich content.
editEphemeralMessageMedia / editEphemeralMessageCaptionReplace ephemeral media or update its caption.
editEphemeralMessageReplyMarkup / deleteEphemeralMessageReplace/remove buttons or delete the current ephemeral message.
sendChecklist / editChecklistCreate or replace a validated Telegram Business checklist with 1–30 tasks.
getAvailableGifts / sendGiftRead the gift catalog and send a validated gift to one user or channel.
giftPremiumSubscriptionGift exactly 3, 6, or 12 months of Telegram Premium at the matching Stars price.
getUserGifts / getChatGiftsList owned gifts for the current or explicitly selected user or chat.
getBusinessAccountGifts / getBusinessAccountStarBalanceRead typed gift inventory and Stars balance for the current Business connection.
convertGiftToStars / upgradeGift / transferGiftManage the lifecycle and ownership of a Business account's gifts.
editMessage / deleteMessageModify or remove the current/configured message.
deleteMessagesDelete 1-100 unique message IDs with one validated request.
setReactionSet an emoji reaction on a message.
forwardMessage / copyMessageBring a source message into the current chat.
pinMessage / unpinMessageManage pinned messages in the current chat.
unpinAllMessagesClear every pinned message in the current chat.
banMember / unbanMember / restrictMemberModerate a member with validated IDs, permissions, and expiry timestamps.
promoteMember / setAdministratorTitleManage administrator rights and custom titles.
setMemberTag / setDefaultPermissionsManage regular member tags and chat-wide defaults.
approveJoinRequest / declineJoinRequestResolve a pending request exactly once on a compatible trigger path.
answerInlineQueryReturn 0-50 validated results with caching, pagination, and an optional results button.
answerShippingQueryReturn shipping options when accepted, or a required customer-facing error when rejected.
answerPreCheckoutQueryApprove checkout or reject it with a required error message.
answerGuestQueryReturn one inline result as the guest-mode reply.
sendInvoiceSend a validated Stars or provider-backed invoice to the current chat.
refundStarPaymentRefund a Stars charge, defaulting to the current completed payment context.
editStarSubscriptionCancel or restore renewal using the current completed payment context.
createForumTopic / editForumTopicCreate, rename, recolor, or remove the custom icon of a forum topic.
closeForumTopic / reopenForumTopicChange the current or explicitly selected topic lifecycle.
unpinAllForumTopicMessagesClear pinned messages inside the current topic.
deleteForumTopicDelete a topic and all messages inside it.
leaveChatLeave the current chat and terminate the active flow path.
sendChatActionShow typing, upload, recording, or location activity.
Call any Bot API method
await ctx.telegram.call("banChatMember", {
chat_id: -1001234567890,
user_id: 123456789,
revoke_messages: true,
});In hosted flows, delivery lifecycle methods—getUpdates, setWebhook, deleteWebhook, logOut, and close—are reserved for the runtime.
Core SDK
Common recipes
Small, complete patterns for the workflows most bots need first.Inline button and callback acknowledgement
Always answer a callback query promptly so Telegram can dismiss the loading indicator.
bot.command("confirm", (ctx) =>
ctx.telegram.sendButtons("Continue?", [
{ text: "Yes", data: "confirm:yes" },
])
);
bot.callback("confirm:yes", async (ctx) => {
await ctx.telegram.answerCallback("Saved");
await ctx.telegram.editMessage("✅ Confirmed");
});Chat action around slow work
bot.on("message", async (ctx) => {
await ctx.telegram.sendChatAction("typing");
const result = await createAnswer(ctx.text ?? "");
await ctx.reply(result);
});Stream generated output
A message draft is a temporary private-chat preview. Reuse the same non-zero draft ID as output changes, then send the completed message so it persists.
bot.on("message", async (ctx) => {
await ctx.telegram.sendMessageDraft(1, "Thinking…", {
canStop: true,
keepOnStop: true,
});
const answer = await createAnswer(ctx.text ?? "");
await ctx.reply(answer); // persists the final output
});
bot.on("stopped_message_generation", async (ctx) => {
await cancelGeneration(ctx.draftId);
});They are available only in private chats and are not durable messages. Always call ctx.reply() or sendMessage with the final text.
Edit and delete ephemeral messages
Telegram ephemeral messages use a receiver ID and a separate ephemeral_message_id. The SDK inherits both from the current update, or accepts an explicit target saved from an earlier result.
bot.on("message", async (ctx) => {
await ctx.telegram.editEphemeralMessageText("Updated");
await ctx.telegram.editEphemeralRichMessage({ markdown: "**Ready**" });
await ctx.telegram.editEphemeralMessageMedia({
type: "photo",
media: "https://example.com/result.jpg",
caption: "Result",
});
await ctx.telegram.editEphemeralMessageCaption("Final caption");
await ctx.telegram.editEphemeralMessageReplyMarkup(); // removes buttons
await ctx.telegram.deleteEphemeralMessage();
});Targets must use positive IDs. Text and caption limits, InputMedia types, entity/parse-mode exclusivity, callback-data bytes, exact button actions, and Telegram's ban on login_url for ephemeral keyboards are enforced locally.
Telegram Business checklist
Business-message updates expose their connection automatically. A named return value lets the next block edit the exact message that was created.
bot.on("business_message", async (ctx) => {
const checklistMessage = await ctx.telegram.sendChecklist({
title: "Launch tasks",
tasks: [
{ id: 1, text: "Review" },
{ id: 2, text: "Ship" },
],
othersCanMarkTasksAsDone: true,
});
await ctx.telegram.editChecklist({
title: "Updated tasks",
tasks: [{ id: 1, text: "Shipped" }],
}, { messageId: "{{result.checklistMessage.message_id}}" });
});Titles support 1–255 characters. Each checklist requires 1–30 tasks with unique positive IDs and 1–100 characters of text. The helper requires a Telegram Business connection ID, defaulting to ctx.businessConnectionId.
Telegram Gifts and Business Stars
Gift catalog, balance, and owned-gift list helpers return typed data. Give a result a name in Visual Flow—or assign it in code—to use it in later blocks and imported flow functions.
bot.on("business_message", async (ctx) => {
const catalog = await ctx.telegram.getAvailableGifts();
await ctx.telegram.sendGift(catalog.gifts[0].id);
await ctx.telegram.giftPremiumSubscription(3);
const balance = await ctx.telegram.getBusinessAccountStarBalance();
const owned = await ctx.telegram.getBusinessAccountGifts({ limit: 20 });
await ctx.telegram.upgradeGift(owned.gifts[0].owned_gift_id!, {
keepOriginalDetails: true,
});
});Send and Premium helpers default to ctx.userId; user/chat lists default to their current IDs; Business settings, balance, transfer, list, conversion, upgrade, and gift transfer default to ctx.businessConnectionId. Every target may be explicit for out-of-band work.
The SDK rejects conflicting targets and filters, unsupported formatted-text entities, invalid 1–100 page sizes, invalid Stars amounts, and Premium durations outside 3/6/12 months before delivery. Conversion, upgrade, and transfer can still fail when Telegram reports an incompatible gift state or insufficient business rights.
Inline query results
bot.on("inline_query", async (ctx) => {
await ctx.telegram.answerInlineQuery([
{
type: "article",
id: "help",
title: "Help",
input_message_content: { message_text: "How can I help?" },
},
], { cacheTime: 60, isPersonal: true });
});Validated moderation helpers
await ctx.telegram.banMember(ctx.userId!, {
revokeMessages: true,
});
await ctx.telegram.promoteMember(ctx.userId!, {
can_manage_chat: true,
can_delete_messages: true,
});Telegram Stars payment lifecycle
bot.command("buy", (ctx) =>
ctx.telegram.sendInvoice({
title: "Premium access",
description: "Unlock premium access",
payload: "premium-order",
currency: "XTR",
prices: [{ label: "Premium", amount: 100 }],
})
);
bot.on("pre_checkout_query", (ctx) =>
ctx.telegram.answerPreCheckoutQuery(true)
);
bot.on("successful_payment", async (ctx) => {
await deliverPurchase(ctx.successfulPayment!.invoice_payload);
});pre_checkout_query validates the order; it does not prove payment. Deliver goods only from successful_payment. This semantic SDK event is nested inside Telegram's raw message update, so use message in allowedUpdates.
Forum topic lifecycle
await ctx.telegram.createForumTopic("Support", {
iconColor: 7322096,
});
// These default to ctx.messageThreadId.
await ctx.telegram.editForumTopic({ name: "Help desk" });
await ctx.telegram.closeForumTopic();
await ctx.telegram.reopenForumTopic();
await ctx.telegram.unpinAllForumTopicMessages();deleteForumTopic() removes the topic together with every message inside it. Use an explicit thread ID when the current update does not belong to the intended topic.
ctx.userId identifies who caused an update; ctx.chatId identifies where a chat action can be sent. They are not interchangeable.
Build
Visual Flow
The canvas is a view of your configured source file, not a second copy of the program.trigger.messageaction.conditionaction.send_messageClick a node to reveal its source range. Move the code cursor to highlight the matching node.
Node errors survive view changes and refreshes for the same source revision.
The graph blocks cycles, duplicate triggers, chatless actions, mismatched or repeated query answers, terminal-action children, and overlapping paths that would execute twice.
Saving code preserves node positions. Moving nodes changes layout without rewriting source.
The synchronized file accepts literal handler arguments and supported SDK statements. Put unrestricted application code in other project files.
Run locally
Webhook
Create a framework-neutral handler that consumes and returns Web Standard objects.import {
Bot,
TelegramClient,
createTelegramWebhookHandler,
} from "@bmhien/bot";
const bot = new Bot();
const telegram = new TelegramClient(
process.env.TELEGRAM_BOT_TOKEN!,
);
bot.on("message", (ctx) =>
ctx.reply(`Received: ${ctx.text ?? ""}`)
);
export const handleTelegram = createTelegramWebhookHandler(
bot,
telegram,
{
secretToken: process.env.TELEGRAM_WEBHOOK_SECRET,
parseMode: "HTML",
},
);GET /webhookReturns 405 Method Not Allowed.
Invalid secretReturns 401 Unauthorized before parsing the body.
Malformed updateReturns 400 without dispatching a handler.
Future update typeReturns 200 safely so Telegram does not retry forever.
Handler throwsThe request fails, allowing Telegram to retry delivery.
Run locally
Long polling
Receive updates locally with safe offsets, retry metadata, and graceful cancellation.import { Bot, TelegramClient, runTelegramPolling } from "@bmhien/bot";
const bot = new Bot();
const telegram = new TelegramClient(process.env.TELEGRAM_BOT_TOKEN!);
const controller = new AbortController();
bot.command("start", (ctx) => ctx.reply("Running locally"));
await runTelegramPolling(bot, telegram, {
signal: controller.signal,
offset: 0,
timeout: 30,
limit: 100,
allowedUpdates: ["message", "callback_query"],
onError: console.error,
});- Offsets advance only after successful dispatch.
- Duplicate and out-of-order update IDs are skipped.
- Telegram
retry_aftercontrols the retry delay. - Aborting the signal cancels an in-flight request immediately.
timeoutandlimitare clamped to Telegram's accepted ranges.
Delivery
Security
Protect the token, authenticate webhook delivery, and keep sensitive values out of logs and browser code.Anyone holding the bot token controls the bot. Store it only in environment variables or a dedicated secret store and rotate it through BotFather if it is exposed.
Set a random secretToken and configure the same value with Telegram. The handler verifies the request header before parsing the body.
Use allowedUpdates for polling—and the equivalent webhook setting—to receive only events your bot handles.
Never log tokens, environment values, request endpoints containing tokens, or download URLs produced by getFileUrl().
Webhook verification contract
HTTP methodOnly POST is accepted; other methods return 405.
Secret formatMust contain 1–256 letters, digits, underscores, or hyphens.
Secret headerCompared against X-Telegram-Bot-Api-Secret-Token before JSON parsing.
Update identifierupdate_id must be a non-negative safe integer.
Unknown future updateA valid but unsupported payload is acknowledged safely instead of entering a retry loop.
Telegram does not deliver getUpdates results while a webhook is configured. Remove the webhook before starting local polling.
Run locally
Files & uploads
Use JSON for existing file IDs and multipart FormData for new file bytes.const form = new FormData();
form.set("chat_id", "123456789");
form.set("document", new Blob([bytes]), "report.pdf");
await telegram.upload("sendDocument", form);
const downloadUrl = await telegram.getFileUrl(fileId);The client leaves multipart headers unset so the Fetch implementation can add the correct boundary.
Reference
Bot API methods
The generic client recognizes every method documented in Telegram Bot API 10.3.No methods match this filter.
Reference
Update types
Pass any update type below tobot.on(type, handler).bot.on("successful_payment", handler) runs when a raw message contains Telegram's completed-payment object. It is an SDK event, not a value accepted by Telegram's allowed_updates.
Reference
Errors & limits
Validate early, preserve Telegram retry metadata, and never place the bot token in an error message.try {
await telegram.call("sendMessage", {
chat_id: 123456789,
text: "Hello",
});
} catch (error) {
if (error instanceof TelegramClientError) {
console.error(error.errorCode, error.retryAfter, error.migrateToChatId);
}
}Read bot tokens and webhook secrets from environment variables. The SDK sanitizes transport errors, but your own logs must avoid printing request URLs or environment values.