The bot that refused to /start: reading Telegram's errors literally
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:
- Check the image exists before attempting to send it.
- If it exists, send the photo with caption and buttons.
- 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.
- 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 |