troubleshooting
symptom-driven fixes for the telegram failures users hit most often in real bots.
error text → cause → fix
search this table for the exact wording Telegram (or Node) gave you:
| error text | cause | fix |
|---|---|---|
message is not modified | editing a message with identical text/markup | skip the edit, or diff before calling editText/editMessageText |
can't parse entities: … | malformed HTML/Markdown passed straight to parse_mode | build the text with html`…`/md`…` from @yaebal/fmt instead of raw markup strings |
query is too old and response timeout expired | answered a callback query more than ~15s after it arrived | call answerCallbackQuery() first, before any awaits that can be slow |
QUERY_ID_INVALID | reused or already-answered callback_query id | answer each callback query exactly once |
chat not found | wrong/stale chat_id, or the bot was never in that chat | verify the id; a user must have started the bot or share a chat with it first |
bot was blocked by the user | 403 on send — the user blocked the bot | catch and drop this recipient; don't retry it in a broadcast |
not enough rights to … | the bot lacks the specific admin permission for that action | check getChatMember for the bot itself before attempting the action |
message to delete not found | deleting a message already deleted or older than 48h | treat as success — the end state (message gone) is already true |
bot does not start
| symptom | likely cause | fix |
|---|---|---|
401 unauthorized | missing, empty, or wrong token | validate BOT_TOKEN before constructing the bot |
404 not found on every method | wrong apiRoot or token copied with whitespace | trim the token and check custom bot api server url |
| process exits immediately | bot.start() rejected during startup (bad token, no network) and nothing caught it | use top-level await bot.start() in esm so the startup error surfaces with a stack trace |
import { createBot } from "yaebal";
const token = process.env.BOT_TOKEN;
if (!token) {
throw new Error("BOT_TOKEN is missing");
}
const bot = createBot(token);ERR_MODULE_NOT_FOUND / esm import errors
yaebal is "type": "module". Node's ESM resolver does not add extensions for you —
a local import needs the .js specifier that the compiled output will actually have,
even while editing the .ts source.
export const loadUser = async (id: number) => ({ id });// @ts-expect-error — ERR_MODULE_NOT_FOUND at runtime: node's esm resolver
// does not add extensions for you, so this fails to resolve even though
// users.ts sits right next to this file.
import { loadUser } from "./users";
// ✅ write .js even though the source file is users.ts — nodenext rewrites
// nothing at compile time; this is what actually ends up on disk.
import { loadUser as loadUserFixed } from "./users.js";
await loadUserFixed(1);polling receives no updates
telegram allows either long polling or webhooks for a token, not both. a common failure is 409 conflict: terminated by other getUpdates request: another process is polling the
same token, or a webhook is still registered.
import { createBot } from "yaebal";
const bot = createBot(process.env.BOT_TOKEN!);
// if polling gets 409 conflict, make sure no webhook is set
await bot.api.call("deleteWebhook", { drop_pending_updates: true });
// then start exactly one polling process
await bot.start();| symptom | fix |
|---|---|
409 conflict | stop old containers, local dev processes, and duplicate bot.start() calls |
| webhook is set | call deleteWebhook or switch the app to webhook mode |
| groups only deliver commands | botfather privacy mode is enabled; disable it only if the bot must read all group messages |
chat_member / reactions never arrive | pass explicit allowedUpdates |
import { Bot } from "@yaebal/core";
const token = process.env.BOT_TOKEN!;
const bot = new Bot(token, {
allowedUpdates: [
"message",
"callback_query",
"chat_member",
"message_reaction",
"chat_join_request",
],
});webhook does not fire
use getWebhookInfo first. telegram tells you the registered url, pending update count,
and the last delivery error.
import { createBot } from "yaebal";
import type { WebhookInfo } from "@yaebal/types";
const bot = createBot(process.env.BOT_TOKEN!);
const info = await bot.api.call<WebhookInfo>("getWebhookInfo");
console.log(info.url, info.last_error_message);| symptom | fix |
|---|---|
| telegram never reaches localhost | use a public https url or a tunnel for local dev |
401 in your logs | the secretToken passed to setWebhook does not match the handler |
405 | telegram must post to the exact route that mounts the webhook callback |
413 | request body exceeded the built-in 1 mib guard; real updates should be tiny |
see webhooks and @yaebal/web.
callback button spinner hangs
telegram clients show a loading spinner until the bot answers the callback query. answer it at the top of the handler, then edit or send messages.
import { InlineKeyboard, createBot } from "yaebal";
const bot = createBot(process.env.BOT_TOKEN!);
bot.command("start", (ctx) =>
ctx.reply("deploy to production?", {
reply_markup: new InlineKeyboard().text("confirm", "confirm:deploy").build(),
}),
);
bot.callbackQuery(/^confirm:/, async (ctx) => {
await ctx.answerCallbackQuery(); // answer first so the client spinner stops
await ctx.reply("confirmed");
});
bot.start();formatting is broken
| symptom | fix |
|---|---|
literal <b> or markdown appears in chat | use html/md from @yaebal/fmt or entity builders from core, not raw parse_mode strings |
| user input breaks formatting | interpolate into html/md templates so user text is escaped as literal text |
| entities disappear after string concatenation | keep values as yaebal format results until the final ctx.send/ctx.reply |
media upload fails
use media.path for local files, media.buffer for in-memory bytes, media.url for public urls, and raw strings for telegram file_ids.
import { createBot, media } from "yaebal";
const bot = createBot(process.env.BOT_TOKEN!);
bot.command("send", async (ctx) => {
await ctx.sendPhoto(media.url("https://example.com/cat.jpg"));
await ctx.sendDocument(media.path("./report.pdf"));
await ctx.sendPhoto("AgACAgIAAx..."); // existing file_id also works
});media.path() needs a runtime file
reader and is not available on cloudflare workers. use media.url() or media.buffer() on edge.session or plugin fields are missing
in yaebal, plugin context fields exist downstream of the .install() call. install
plugins before handlers that use them, and encode plugin dependencies in the plugin type when you
write your own.
| problem | fix |
|---|---|
ctx.session is not typed | install session() before the handler and keep the returned bot/composer value in the chain |
| scene plugin cannot read session | install session before scenes/onboarding-style stateful plugins |
| plugin works at runtime but not in typescript | make it a Plugin<In, Out> and return the augmented composer |
types look too weak
use createBot() from the yaebal meta package for rich
generated runtime contexts. a bare new Bot(token) from @yaebal/core intentionally exposes the small base context unless you provide a custom context factory.
bot feels slow / updates pile up
sequential long polling processes one update at a time by design — a slow handler (a database call, an outbound http request) blocks everything behind it in the same chat and others besides.
| symptom | fix |
|---|---|
| updates visibly queue up under load | @yaebal/runner — concurrent polling with per-chat ordering preserved |
| outbound sends get 429'd | @yaebal/throttle — buckets outgoing calls under Telegram's own limits |
| calls fail transiently (5xx, network blips) | @yaebal/again — retry with backoff instead of failing the update |
still stuck
- search the generated bot api reference for the exact method.
- turn the failing behavior into a test with @yaebal/test.
- check production patterns in production.