The bot that refused to /start: reading Telegram's errors literally

debug log showing a Telegram 400 entity-parse error, a missing image path, and a deprecation warning, with the parse error stamped ROOT CAUSE

The setup is deliberately small: a Node.js bot on node-telegram-bot-api, developed in WSL/Ubuntu, running in polling mode, destined for a VPS under PM2 later. On /start it should do EXACTLY three things — send an image from the project’s assets/ directory, attach a formatted caption containing a t.me invite link, and render three inline buttons pointing at that same link.

Three things. And on the first real test, the terminal produced three different complaints at once. This post is about the order you read them in — because two of them were real, one was noise, and the instinct to fix the loudest one first would have wasted the afternoon.

The failure surface

Here is what came back on the first /start:

400 Bad Request: can't parse entities:
Can't find end of the entity starting at byte offset 50
❌ Image not found:
/home/devx/sampleproject/assets/Generated_Image_90h62.jpg
[node-telegram-bot-api] DeprecationWarning:
In the future, content-type of files you send will default to
"application/octet-stream".

Three messages, three severities, one debugging rule: separate what stopped the request from what merely printed. A 400 from the Telegram API is a rejection — the request reached Telegram and Telegram refused it. A local “not found” log is a precondition failure — the request never left the machine. A DeprecationWarning is neither — it is the library talking about its own future. Only the first two are bugs, and they are different bugs on different layers.

Error one — can’t parse entities at byte offset 50

The caption was being sent with:

parse_mode: "Markdown"

and it contained a Telegram invite link of this shape:

https://t.me/+aB3_xYzExample

Read the error literally, because it is more precise than it looks. “Entities” are Telegram’s formatting spans — bold, italic, links. “Can’t find end of the entity starting at byte offset 50” means the parser found an opening formatting marker at byte 50 of the caption and reached end-of-string without a matching close.

Count 50 bytes into the caption and you land inside the URL — on the underscore.

That’s the whole bug. In Telegram’s legacy Markdown grammar, _ opens an italic span. Invite links generated by Telegram routinely contain _ (and +, and other characters Markdown treats as syntax). The parser hit the underscore in the URL, opened an italic entity, never found a closing _, and rejected the entire message. The URL was valid. The caption was valid. The grammar was wrong for the payload.

This is a recurring class of bug worth naming: data that is legal in one layer is syntax in another. The same failure shape shows up as SQL injection, as broken shell quoting, as YAML strings that begin with *. Here it was benign — a promo caption, not a security boundary — but the diagnosis discipline is identical: find the byte the parser named, look at what character lives there, and ask what that character means to the parser rather than to you.

The fix: switch grammars, or opt out entirely

Two clean exits.

Exit one — HTML mode. Telegram’s HTML parse mode treats only <, >, and & as syntax, so underscores in URLs pass through untouched:

parse_mode: "HTML"
<b>Launch offer</b>

Code:
<a href="https://t.me/+aB3_xYzExample">https://t.me/+aB3_xYzExample</a>

Exit two — no parse mode at all. If the caption needs no formatting, omit parse_mode entirely. No grammar, no entities, no parser to offend. This is the smaller fix and the one to prefer when bold text is decoration rather than requirement.

The bot took exit one, because the bold heading in the caption was wanted. Either way, the principle is the same: don’t escape your way around a hostile grammar when you can choose a grammar that isn’t hostile.

Error two — the image that wasn’t there

❌ Image not found:
/home/devx/sampleproject/assets/Generated_Image_90h62.jpg

This error is honest and complete: that exact path does not resolve. On Linux — which includes WSL — “exact” is doing real work in that sentence, because the filesystem is case-sensitive and extension-literal. generated_image_90h62.jpg, Generated_Image_90h62.JPG, and Generated_Image_90h62.png are four different files, three of which don’t exist.

The diagnosis is two commands, not a rewrite:

cd /home/devx/sampleproject
ls assets/
ls -lh assets/Generated_Image_90h62.jpg

The first shows what is actually in the directory — real name, real casing, real extension. The second confirms whether the exact path the code constructs exists. If the file is sitting in the project root instead of assets/, the fix is a move, not a code change:

mkdir -p assets
mv Generated_Image_90h62.jpg assets/

And one trap specific to generated images: the extension the code expects must match the extension the file actually has, not the one the filename claims. An image exported as PNG but named .jpg will pass the existence check and can still fail downstream. file assets/Generated_Image_90h62.jpg settles the question in one line.

The path itself was built the portable way — path.join(__dirname, "assets", ...) rather than a hardcoded string — which is why the error message could print the full absolute path it tried. That’s worth keeping even in a throwaway bot: an error that names the exact path it attempted is an error you can diagnose with ls.

The red herring — a deprecation warning in the blast radius

[node-telegram-bot-api] DeprecationWarning:
In the future, content-type of files you send will default to
"application/octet-stream".

This printed adjacent to the failures, and adjacency is exactly how warnings get promoted to suspects. It caused nothing. It is the library announcing a future change to default content-type handling for uploads — informational, not causal. The 400 was caused by entity parsing; the missing image was caused by the missing image.

The discipline: a warning that appears near a failure is evidence only of proximity. Before touching anything a warning mentions, confirm the actual failure has its own named cause. Here both failures did, and the warning got correctly filed under “later, maybe never.”

Designing the handler to degrade, not die

The structural fix outlasts both bugs: the /start handler was rebuilt so that no single missing asset or rejected upload kills the process. The logic, in order:

  1. Check the image exists before attempting to send it.
  2. If it exists, send the photo with caption and buttons.
  3. If it is missing — or Telegram rejects the photo for any reason — fall back to sending the same caption and buttons as a plain text message.
  4. Log polling errors instead of letting them throw.
try {
  if (!fs.existsSync(IMAGE_PATH)) {
    console.error(`❌ Image not found: ${IMAGE_PATH}`);
    throw new Error("Image file missing");
  }

  await bot.sendPhoto(chatId, IMAGE_PATH, {
    caption: caption.trim(),
    parse_mode: "HTML",
    reply_markup: buttons,
  });
} catch (err) {
  console.error("❌ sendPhoto failed:", err.message);

  await bot.sendMessage(chatId, caption.trim(), {
    parse_mode: "HTML",
    reply_markup: buttons,
    disable_web_page_preview: true,
  });
}
bot.on("polling_error", (error) => {
  console.error("Polling error:", error.message);
});

Two design notes on this shape.

First, the existence check throws into the catch rather than returning early. That’s deliberate: it routes the missing-file case and the Telegram-rejection case through the same fallback, so there is exactly one degraded path to test instead of two. The user still gets the caption and all three buttons — the image is the only casualty.

Second, disable_web_page_preview: true on the fallback matters more than it looks. Without it, the text-mode message would render Telegram’s own link preview card under the caption — a half-broken imitation of the image the bot failed to send. The fallback should look intentional, not like an error state leaking into the chat.

One honest caveat on the current setup: the bot token lives directly in index.js by explicit choice — no .env, no config layer. That’s workable for a single-file bot on a private machine, with one hard rule attached: the file never touches a public repository, and a leaked token gets regenerated through BotFather immediately. Convenience was chosen with its cost priced in, not ignored.

Quick reference

Symptom Layer Root cause Fix
400 can't parse entities with byte offset Telegram API Markdown-significant character (_) inside a URL parse_mode: "HTML" or drop parse_mode
Image not found: with full path Local filesystem Exact path miss — casing, extension, or location ls assets/, match name exactly, mv into place
Photo passes checks but still fails Telegram API Extension doesn’t match real file type file <path> to verify actual format
DeprecationWarning about content-type Library Future default change — informational Nothing; not causal
Bot crashes when asset missing Handler design No fallback path existsSync + try/catch → text-message fallback
Unhandled polling exceptions Runtime No polling_error listener Attach listener, log and continue