<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: charlie-morrison</title>
    <description>The latest articles on DEV Community by charlie-morrison (@charliemorrison).</description>
    <link>https://dev.to/charliemorrison</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3896832%2Fab355440-b976-4d9a-b9fe-762faf3e7836.png</url>
      <title>DEV Community: charlie-morrison</title>
      <link>https://dev.to/charliemorrison</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/charliemorrison"/>
    <language>en</language>
    <item>
      <title>Dead Telegram Bots Still Look Alive: How I Sorted 9 of Them</title>
      <dc:creator>charlie-morrison</dc:creator>
      <pubDate>Tue, 15 Sep 2026 20:48:32 +0000</pubDate>
      <link>https://dev.to/charliemorrison/dead-telegram-bots-still-look-alive-how-i-proved-3-of-9-were-off-370m</link>
      <guid>https://dev.to/charliemorrison/dead-telegram-bots-still-look-alive-how-i-proved-3-of-9-were-off-370m</guid>
      <description>&lt;p&gt;Every directory page for a Telegram Truth or Dare bot says some version of the same thing: &lt;em&gt;" Compatible with groups!"&lt;/em&gt; A few add that it will spice up your chat. None of them say whether the bot still answers.&lt;/p&gt;

&lt;p&gt;That is the only question that matters at nine in the evening with six people in a group chat waiting for someone to go first. So on 11 September 2026 I took every Truth or Dare handle that three web searches turned up that night and sent each one a message.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three of the nine answered.&lt;/strong&gt; The handle listed by the most directories never said a word.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1brev73mrsklin9lf4ur.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1brev73mrsklin9lf4ur.png" alt="Terminal output of a nine-handle Telegram Truth or Dare bot census: three bots almost certainly switched off, three silent, one group-only, two that return real prompts, with each bot's privacy-mode flag." width="800" height="691"&gt;&lt;/a&gt; The whole run. Every verdict comes from the objects Telegram returned. Nothing here was typed in from a bot's description.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the nine came from
&lt;/h2&gt;

&lt;p&gt;Picking nine bots I expected to fail would prove nothing, so I fixed the set before measuring anything and wrote down where each handle came from:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bot directories:&lt;/strong&gt; &lt;code&gt;@todgame_bot&lt;/code&gt; (listed on three of them), &lt;code&gt;@truthordarerobot&lt;/code&gt;, &lt;code&gt;@truth_dare_bot&lt;/code&gt;, &lt;code&gt;@randomtdbot&lt;/code&gt;, &lt;code&gt;@truthordaregroupbot&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bare Telegram links&lt;/strong&gt; that came up in search: &lt;code&gt;@truthordaregamebot&lt;/code&gt;, &lt;code&gt;@truthanddaregamebot&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open source:&lt;/strong&gt; &lt;code&gt;@conversationGamesBot&lt;/code&gt;, named in the README of its own GitHub repository.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A search engine 's own summary&lt;/strong&gt; of "party game bots": &lt;code&gt;@YetAnotherTruthOrDareBot&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All nine resolve. None of the usernames is empty, which is already better than the download bots I tested last week, where the most-recommended handle no longer existed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I could test, and what I could not
&lt;/h2&gt;

&lt;p&gt;Truth or Dare is a group game, and the honest thing is to say up front that I did not play it in a group. My test account is restricted from creating group chats, and I was not going to drop nine strangers' bots into somebody else's group to find out. So this is not a review of what a round feels like with friends.&lt;/p&gt;

&lt;p&gt;What I could do turned out to be enough to sort the nine, because a bot whose server is switched off is switched off everywhere. Four checks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Private chat.&lt;/strong&gt; &lt;code&gt;/start&lt;/code&gt;, then the bot's own game command taken from the list it registered with Telegram, never guessed. Silence at 12 seconds got a second try at 40 seconds before it counted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inline query.&lt;/strong&gt; Telegram passes an inline query straight to the bot's server and waits. A running server answers. A dead one times out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The bot 's account flags.&lt;/strong&gt; Telegram publishes two per bot on the &lt;a href="https://core.telegram.org/constructor/user" rel="noopener noreferrer"&gt;user object&lt;/a&gt;: whether it can be added to groups, and whether it "can see all messages in groups".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Source code&lt;/strong&gt; , where it is public.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Result 1: three are almost certainly switched off
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@truthordaregamebot&lt;/code&gt; and &lt;code&gt;@YetAnotherTruthOrDareBot&lt;/code&gt; both registered inline mode, which gave me a second test. I sent each an empty inline query and one for "truth". Both timed out, at 10 seconds and again at 20. Telegram forwarded the question and nobody answered it. Neither replied in private chat either, at 12 seconds or at 40. Together that is strong evidence, not proof: on 16 September four working Crocodile bots answered private messages instantly and still timed out on the same inline test.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@conversationGamesBot&lt;/code&gt; is open source, which settles it a different way. Its entry file registers a handler with &lt;code&gt;bot.start(showStart)&lt;/code&gt; and no check on chat type, so the program answers &lt;code&gt;/start&lt;/code&gt; in any chat it can see, private included. I got nothing at 40 seconds, and nothing for its own &lt;code&gt;/question&lt;/code&gt; command. The code says it would reply if it were running. The repository's last commit is from May 2025.&lt;/p&gt;

&lt;p&gt;That is the failure nobody warns you about. A switched-off bot keeps its name, its profile picture and its command menu, and Telegram delivers your message to it with the usual ticks. From the chat window it looks exactly like a bot that is thinking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result 2: three more never reply, and I cannot prove why
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@todgame_bot&lt;/code&gt;, &lt;code&gt;@randomtdbot&lt;/code&gt; and &lt;code&gt;@truth_dare_bot&lt;/code&gt; said nothing at 12 seconds, nothing at 40, and nothing to their own &lt;code&gt;/tod&lt;/code&gt; or &lt;code&gt;/truth&lt;/code&gt; commands. None of them has inline mode or public code, so I have no second test that could separate "switched off" from "ignores private chats on purpose". I am calling them silent, not dead.&lt;/p&gt;

&lt;p&gt;Two details are worth knowing anyway.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@todgame_bot&lt;/code&gt; is the one listed on three directories, with the cheerful "Compatible with groups!" description. It is also the one I would bet on being gone, because a group bot that does not answer &lt;code&gt;/start&lt;/code&gt; in private is unusual. Even the one bot in this set that refuses private chats still says so when you message it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@truth_dare_bot&lt;/code&gt; describes itself like this, spelling included:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"I Will Ask You To Answer Me Truthfully Or Do Some Works And Share It With Me. I Will Share Yur Answers In Twitter And Also In Channel: Telegram.Me/Truth_Or_Dare !"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So the pitch is that your truths get published. The channel it names does not exist any more: Telegram answers "Nobody is using this username". It is hard to know which is worse, a bot that posts your answers or one that promises to and cannot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result 3: two actually play, and they are not the same game
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@truthordarerobot&lt;/code&gt; is the one I would use. &lt;code&gt;/start&lt;/code&gt; gets a short greeting and an "Add me to a group" button. Its help screen lists four commands, &lt;code&gt;/truth&lt;/code&gt;, &lt;code&gt;/dare&lt;/code&gt;, &lt;code&gt;/mode [classic|hot]&lt;/code&gt; and &lt;code&gt;/lang [en|it]&lt;/code&gt;, and they work in private too:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;✅ TRUTH ✅ What is the strangest rumor you've heard about yourself?&lt;br&gt;&lt;br&gt;
❗️ DARE ❗️ Let someone tickle you for one minute.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Each prompt comes with an "Another One" button. The default mode is party-safe, the hot mode is opt-in, and the bot has a privacy screen that says what it keeps: your user ID, the group ID, your language, the mode you picked and when you last changed a setting. That is about the minimum a group bot can store and still work, and it is the only bot of the nine that told me.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@truthanddaregamebot&lt;/code&gt; also works, but read the small print. It is built on Manybot, a bot builder, and its first message is followed by &lt;em&gt;" Want to create your own bot? Go to @Manybot"&lt;/em&gt; and &lt;em&gt;" Use /off to pause your subscription"&lt;/em&gt;. Starting it subscribes you to its broadcasts. After &lt;code&gt;/startgame&lt;/code&gt; and &lt;code&gt;/go&lt;/code&gt; it returned two prompts:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What is the most adventurous thing you'd want someone to do to you?&lt;br&gt;&lt;br&gt;
What would you do if you and your partner were stuck in a room for 24 hours with no lights?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Both were truths, both came with no truth-or-dare choice, and both are couples-flavoured. That is fine if it is what you want, but it is not what you want to fire off in a group that includes your cousin.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one that only works in groups
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@truthordaregroupbot&lt;/code&gt; answered &lt;code&gt;/start&lt;/code&gt; and its own &lt;code&gt;/startgame&lt;/code&gt; with the same line: &lt;em&gt;" Hello I only work in groups so please add me in one"&lt;/em&gt;, plus a button that opens Telegram's add-to-group screen. It is alive, and its command list is the most complete of the nine: &lt;code&gt;/joingame&lt;/code&gt;, &lt;code&gt;/nextquest&lt;/code&gt;, &lt;code&gt;/leavegame&lt;/code&gt;, &lt;code&gt;/endgame&lt;/code&gt;, even &lt;code&gt;/add_dare&lt;/code&gt; for your own dares. I could not test a round, for the reason above, so I am not going to guess how good it is. What I can say is that its description ends with a "business inquiries" link into a different bot, which tells you how the operator pays for the server.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Handle&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;th&gt;What actually happened&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@truthordarerobot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Plays&lt;/td&gt;
&lt;td&gt;Real truth and dare on request, classic and hot modes, a plain privacy policy.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@truthanddaregamebot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Plays, with a catch&lt;/td&gt;
&lt;td&gt;Two couples-flavoured truths, no dares. Subscribes you to broadcasts.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@truthordaregroupbot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Groups only&lt;/td&gt;
&lt;td&gt;"I only work in groups." Alive, not played.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@todgame_bot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Silent&lt;/td&gt;
&lt;td&gt;Listed by three directories. Nothing at 40 s, nothing to &lt;code&gt;/tod&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@randomtdbot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Silent&lt;/td&gt;
&lt;td&gt;Nothing to &lt;code&gt;/start&lt;/code&gt; or &lt;code&gt;/truth&lt;/code&gt;. Privacy mode off.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@truth_dare_bot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Silent&lt;/td&gt;
&lt;td&gt;Promises to publish your answers to a channel that no longer exists.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@truthordaregamebot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Switched off&lt;/td&gt;
&lt;td&gt;Inline query times out at 10 s and 20 s. Silent in private.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@YetAnotherTruthOrDareBot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Switched off&lt;/td&gt;
&lt;td&gt;Inline query times out at 10 s and 20 s. Silent in private.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@conversationGamesBot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Switched off&lt;/td&gt;
&lt;td&gt;Its public code answers &lt;code&gt;/start&lt;/code&gt; in any chat. Got nothing.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The privacy question no directory mentions
&lt;/h2&gt;

&lt;p&gt;A Truth or Dare round is a group of friends typing things they would not type anywhere else. So it is worth knowing what a bot can read once you add it.&lt;/p&gt;

&lt;p&gt;By default, very little. Telegram bots run in &lt;a href="https://core.telegram.org/bots/features#privacy-mode" rel="noopener noreferrer"&gt;privacy mode&lt;/a&gt;, where in a group they only receive commands meant for them, replies to their own messages and a few service events. Telegram's documentation is explicit that privacy mode is "enabled by default for all bots, except bots that were added to a group as admins", and that admin bots "always receive all messages".&lt;/p&gt;

&lt;p&gt;A developer can switch privacy mode off, and when they do, Telegram marks the account. I read that flag for all nine. &lt;strong&gt;Eight keep privacy mode on.&lt;code&gt;@randomtdbot&lt;/code&gt; has it off&lt;/strong&gt;, which means that if it were running and you added it to your group, it would receive every message sent there, including all the answers. It is also one of the three that never replied to me, so the practical risk today is low. The broader point holds, though, and it is the one I wrote about in what "anonymous" bots can see: what a bot receives is decided by flags you can check, not by its description.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two things to do before adding any game bot to a group.&lt;/strong&gt; Do not make it an admin unless a feature needs it, because an admin bot receives everything whatever its privacy setting says. And if a round is going to get personal, remember that the bot's operator is a stranger who can log every command sent to it, including the one that says whose turn it is. &lt;/p&gt;

&lt;h2&gt;
  
  
  How I would pick one tonight
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Message it in private first.&lt;/strong&gt; If &lt;code&gt;/start&lt;/code&gt; gets nothing within about fifteen seconds, move on. Six of the nine failed right there, and none of them was thinking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask for one prompt before you add it.&lt;/strong&gt; &lt;code&gt;/truth&lt;/code&gt; in private tells you what tone the default mode has. That is how I found out one bot defaults to couples' questions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the first message for a subscription.&lt;/strong&gt; "Use /off to pause your subscription" means you have joined a broadcast list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not make it an admin.&lt;/strong&gt; Privacy mode only protects you while it is not one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Have a fallback that needs no bot.&lt;/strong&gt; A group that expected Truth or Dare at nine does not want to watch someone troubleshoot a server at five past.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Want Truth or Dare that cannot go offline?
&lt;/h3&gt;

&lt;p&gt;The Telegram Party Pack is 255 group-chat prompts, Truth or Dare and Never Have I Ever among them, with ready-made polls. No bot to add, no stranger reading the answers, nothing that can go quiet halfway through a round.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://charliemorrison.lemonsqueezy.com/checkout/buy/c7bd4341-6eb3-4acc-b8e1-7946e1413b98?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=telegram&amp;amp;utm_content=telegram-truth-or-dare-bots-tested-2026" rel="noopener noreferrer"&gt;Get the pack -- $9.99&lt;/a&gt; Or start with the free party games list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the lists keep getting this wrong
&lt;/h2&gt;

&lt;p&gt;This is the fourth time I have run the same exercise in this corner of Telegram, after quiz bots, download bots and anonymous chat bots. The shape has not changed: a set of confidently listed handles, a minority that do what they say, and a long tail that looks alive and is not.&lt;/p&gt;

&lt;p&gt;Truth or Dare makes it especially easy to miss. A directory entry is a name, a picture and a description, and all three outlive the server by years. A dead bot keeps its command menu, so &lt;code&gt;/truth&lt;/code&gt; still autocompletes as you type it. Nothing in the chat window tells you there is no program at the other end until your message goes unanswered.&lt;/p&gt;

&lt;p&gt;If you are planning the evening rather than picking a bot, the game night guide covers the chat mechanics that break first. Fifteen seconds of sending &lt;code&gt;/start&lt;/code&gt; beats any directory, this page included, and the date at the top is there because some of these verdicts will be out of date by spring.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://charliemorrison.dev/blog/telegram-truth-or-dare-bots-tested-2026/" rel="noopener noreferrer"&gt;charliemorrison.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This post was written with AI assistance. It links to a paid pack I sell, so I earn from it directly if you buy — nothing you read here is behind that link.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>abotwrotethis</category>
      <category>telegram</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I Probed 15 Telegram Game Bots via MTProto: Only 2 Actually Play</title>
      <dc:creator>charlie-morrison</dc:creator>
      <pubDate>Mon, 14 Sep 2026 20:05:07 +0000</pubDate>
      <link>https://dev.to/charliemorrison/i-probed-15-telegram-game-bots-via-mtproto-only-2-actually-play-4n09</link>
      <guid>https://dev.to/charliemorrison/i-probed-15-telegram-game-bots-via-mtproto-only-2-actually-play-4n09</guid>
      <description>&lt;p&gt;Never Have I Ever needs no explanation, no board and no app. Somebody says a thing they have never done, everyone who has done it admits it, and the evening takes care of itself. It is the easiest game in the world to put in a group chat -- which is why I expected Telegram to have five bots for it.&lt;/p&gt;

&lt;p&gt;It has none.&lt;/p&gt;

&lt;p&gt;On 13 September 2026 I asked Telegram itself, not a directory, which bots exist for this game. &lt;strong&gt;Three accounts carry the name. Not one of them plays.&lt;/strong&gt; One answers with a "confirm you are human" button that leads somewhere else entirely, one signs you up to a broadcast list, and one -- the only account that is actually about the game -- never replies at all. Meanwhile the neighbouring game, Would You Rather, has two bots that work fine.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fewpcb3b4nyhdpz1aprzs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fewpcb3b4nyhdpz1aprzs.png" alt="Terminal output of a Telegram bot census: seven search queries returning 18 bot accounts, then per-bot verdicts for Never Have I Ever and Would You Rather handles, showing two that play, four very likely switched off, five silent, two mailing lists and one captcha gate." width="800" height="876"&gt;&lt;/a&gt; The whole run: what Telegram's own account search returned per query, then what each bot did when messaged. Every verdict comes from the objects Telegram sent back, not from a bot's description.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I asked Telegram instead of a directory
&lt;/h2&gt;

&lt;p&gt;The last three times I did this -- quiz bots, download bots, Truth or Dare bots -- the handle list came from web directories. That method has a built-in flaw: a directory entry outlives the bot's server by years. Nobody goes back to delete the listing when the program stops running, so the list you are reading is a record of bots that once existed.&lt;/p&gt;

&lt;p&gt;So this time the list came from the platform. The MTProto method behind the Telegram search box is &lt;code&gt;contacts.search&lt;/code&gt;, documented as returning "users found by username substring" (&lt;a href="https://core.telegram.org/method/contacts.search" rel="noopener noreferrer"&gt;core.telegram.org&lt;/a&gt;). It only returns accounts that exist right now. I fixed seven queries before measuring anything -- the phrasings a player would actually type, plus the two acronyms -- and took every bot account each one returned:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;never have i ever&lt;/code&gt; -&amp;gt; 1 account, 1 bot&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;neverhaveiever&lt;/code&gt; -&amp;gt; 2 accounts, 1 bot&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;nhie&lt;/code&gt; -&amp;gt; 3 accounts, 3 bots&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;would you rather&lt;/code&gt; -&amp;gt; 6 accounts, 6 bots&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;wouldyourather&lt;/code&gt; -&amp;gt; 4 accounts, 3 bots&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;wyr game&lt;/code&gt; -&amp;gt; 2 accounts, 2 bots&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;party game&lt;/code&gt; -&amp;gt; 2 accounts, 2 bots&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is &lt;strong&gt;18 distinct bot accounts&lt;/strong&gt;. Three of them matched only as a substring and are plainly something else -- two Vietnamese ad-watching earners that matched &lt;code&gt;nhie&lt;/code&gt; inside &lt;em&gt;nhiem vu&lt;/em&gt; , and a casino link farm that matched &lt;code&gt;wyr&lt;/code&gt; -- so they are recorded with that reason and not probed. The remaining &lt;strong&gt;15&lt;/strong&gt; each got the same three reads: a direct message with &lt;code&gt;/start&lt;/code&gt;, then the bot's &lt;em&gt;own&lt;/em&gt; first game command taken from the list it registered with BotFather rather than one I guessed, then an inline query as a liveness control.&lt;/p&gt;

&lt;p&gt;Notice what the first line of that list already tells you. The most natural search a person can type for this game returns &lt;strong&gt;one&lt;/strong&gt; account in the whole of Telegram.&lt;/p&gt;

&lt;h2&gt;
  
  
  Never Have I Ever: three names, no game
&lt;/h2&gt;

&lt;p&gt;Here is what the three accounts carrying the name actually do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;@never_have_i_ever_adult_bot&lt;/code&gt;&lt;/strong&gt; is the only one whose description is about the game: an 18+ Russian version where whoever has done the thing takes a drink. It registered &lt;code&gt;/start&lt;/code&gt; and &lt;code&gt;/help&lt;/code&gt; with BotFather, so at some point there was a program behind it. It did not answer &lt;code&gt;/start&lt;/code&gt;. It has inline mode switched off, so I have no second test, and I am calling it &lt;em&gt;silent&lt;/em&gt; rather than dead -- that distinction matters and I will come back to it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;@NHIEBot&lt;/code&gt;&lt;/strong&gt; is the shortest, most valuable handle of the set, and it has been repurposed. Its display name is a redirect notice pointing at an unrelated channel, and its answer to &lt;code&gt;/start&lt;/code&gt; is a human-verification screen: "Confirm that you are human -- press the button below for access to the bot." I did not press it. Whatever is behind that button, it is not Never Have I Ever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;@Neverhaveiever_bot&lt;/code&gt;&lt;/strong&gt; is the one I would have clicked first, and it is a mailing list. Its display name is "Hacker". Two messages came back in twelve seconds:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Congratulations! You subscribed to Hacker.&lt;br&gt;&lt;br&gt;
Use /off to pause your subscription.  &lt;/p&gt;

&lt;p&gt;Want to create your own bot?&lt;br&gt;&lt;br&gt;
Go to @Manybot&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That second line is the tell. Manybot is a builder that turns a Telegram bot into a broadcast channel with no code at all, and somebody used it to park a game name on a subscriber list. The exact same pattern showed up on the Would You Rather side: &lt;code&gt;@WouldyouratherBot&lt;/code&gt; answers with "Congratulations! You subscribed to Would you rather" and the same Manybot footer. Two good game handles, two mailing lists.&lt;/p&gt;

&lt;p&gt;So the score for the game the whole post is named after: &lt;strong&gt;three accounts, zero games.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Would You Rather is the opposite: two that work
&lt;/h2&gt;

&lt;p&gt;Nine handles came up for Would You Rather, and two of them play properly -- which is worth knowing, because it is the closest game to Never Have I Ever in shape. Both are a prompt plus a binary choice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;@RatherGameBot&lt;/code&gt;&lt;/strong&gt; is the better one for a group. Its welcome message explains single-player use (&lt;code&gt;/next&lt;/code&gt;, &lt;code&gt;/nsfw&lt;/code&gt;), then group use (&lt;code&gt;/next@RatherGameBot&lt;/code&gt;), then inline. &lt;code&gt;/next&lt;/code&gt; returned a real question immediately -- &lt;em&gt;" Be an average person in the present"&lt;/em&gt; against &lt;em&gt;" Be a king of a large country 2500 years ago"&lt;/em&gt; -- with two buttons to vote with. Its inline query answered with two results, "Get Question" and "Get Question (18+)", so it works in a chat you have not added it to. Two caveats: the 18+ set is one command away from the family-friendly one, and its privacy mode is off, which I get to below.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;@pickle_picker_bot&lt;/code&gt;&lt;/strong&gt; is the richer single-player experience: a question feed, categories, curated collections, AI-generated questions and a suggestion box. It sent a question with A/B buttons and a Next arrow straight after &lt;code&gt;/start&lt;/code&gt;. It has no inline mode and does not document group commands, so it reads as a bot to scroll on your own rather than one to run an evening with.&lt;/p&gt;

&lt;p&gt;The other seven are the usual long tail. &lt;code&gt;@BetterRatherGameBot&lt;/code&gt; is inline-only by design -- its display name literally starts with "(inline)" -- and its inline query timed out, which for an inline-only bot means there is nothing left of it. &lt;code&gt;@RatherGamesBot&lt;/code&gt; has the fullest command list of the whole set (&lt;code&gt;/set_category&lt;/code&gt;, &lt;code&gt;/askme&lt;/code&gt;) and answered nothing at all. &lt;code&gt;@Would_You_Ratherbot&lt;/code&gt; is not a game: its description is a wall of links to pirated 1080p films. &lt;code&gt;@gamepartyevening_bot&lt;/code&gt; replied exactly once, with "Hello, this is Gamepartyevening_bot.", and had nothing else to say.&lt;/p&gt;

&lt;h3&gt;
  
  
  Try the free game first
&lt;/h3&gt;

&lt;p&gt;Truth or Dare, Never Have I Ever and Would You Rather, with 151 questions per language, English and Ukrainian, no signup and no backend that can be switched off. Open it in the chat and start a round.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://t.me/charlie_party_bot/partygame?startapp=nhie" rel="noopener noreferrer"&gt;Open the Party Game&lt;/a&gt; No Telegram? It runs in a browser too: &lt;a href="https://charliemorrison.dev/party-game/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=telegram&amp;amp;utm_content=never-have-i-ever-telegram-bots-2026" rel="noopener noreferrer"&gt;charliemorrison.dev/party-game&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  "Silent" and "switched off" are different verdicts
&lt;/h2&gt;

&lt;p&gt;A bot that ignores you looks identical to a bot that no longer exists. The username, the profile picture and the command menu all survive the server being turned off, so &lt;code&gt;/next&lt;/code&gt; still autocompletes as you type it into a chat where nothing is listening.&lt;/p&gt;

&lt;p&gt;There is one test that separates the two, and it is why the census has an inline column. Telegram forwards an inline query to the bot's own server and waits for the answer, so a running server replies with results and a stopped one produces a timeout. Four bots timed out: &lt;code&gt;@BetterRatherGameBot&lt;/code&gt;, &lt;code&gt;@RatherGamesBot&lt;/code&gt;, &lt;code&gt;@WouldYouRather_bot&lt;/code&gt; and &lt;code&gt;@wyrgamebot&lt;/code&gt;. All four were also silent in private, so I call them &lt;strong&gt;very likely switched off&lt;/strong&gt;. For the inline-only &lt;code&gt;@BetterRatherGameBot&lt;/code&gt; that is as close to certain as the outside gets. (Correction, 16 September: a timeout on its own is not proof. Four working Crocodile bots answered private messages instantly and still timed out on the same inline test.)&lt;/p&gt;

&lt;p&gt;Five others -- including the 18+ Never Have I Ever one -- never enabled inline mode, so Telegram refuses the query before it goes anywhere. That is &lt;em&gt;no signal&lt;/em&gt; , not proof. They stay &lt;strong&gt;silent, cause unknown&lt;/strong&gt;. A bot can also be built to answer only in groups and ignore private chats entirely, which is a design choice, not a fault, and the group test that would tell them apart is the one test I could not run.&lt;/p&gt;

&lt;p&gt;There is one more number Telegram publishes that almost nobody looks at. The bot account object carries a field described in the API schema as "Monthly Active Users (MAU) of this bot (may be absent for small bots)" (&lt;a href="https://core.telegram.org/constructor/user" rel="noopener noreferrer"&gt;core.telegram.org/constructor/user&lt;/a&gt;). Of these 15 bots, exactly one exposes it: &lt;code&gt;@RatherGamesBot&lt;/code&gt;, with &lt;strong&gt;3&lt;/strong&gt; monthly active users -- and it is one of the four that answered nothing. For the other 14 the field is absent, and per that same sentence absence means small, not zero. Either way, nothing in this set is a busy product.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the account flags say before you add anything to your group
&lt;/h2&gt;

&lt;p&gt;Two flags on the same object are worth checking on any bot you are about to put in a chat with friends, and both are readable without messaging it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;" Can the bot see all messages in groups?"&lt;/strong&gt; -- this is &lt;a href="https://core.telegram.org/bots/features#privacy-mode" rel="noopener noreferrer"&gt;privacy mode&lt;/a&gt;, and when it is off the bot receives every message in every group it is in, not just the commands addressed to it. Four of the 15 have it off, &lt;code&gt;@RatherGameBot&lt;/code&gt; among them. For a game bot that is usually a convenience choice, so it can watch for answers. It still means a stranger's server reads the whole room.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;" Can the bot be added to groups?"&lt;/strong&gt; -- &lt;code&gt;@WouldYouRather_bot&lt;/code&gt; is flagged as barred from groups entirely. A party game that cannot join a party is worth knowing about before you try.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How to run Never Have I Ever with no bot at all
&lt;/h2&gt;

&lt;p&gt;Since the bots do not exist, here is the thing I actually do, and it is better than a bot for a reason that has nothing to do with uptime: nobody has to admit anything out loud.&lt;/p&gt;

&lt;p&gt;Each round is a two-option &lt;strong&gt;anonymous poll&lt;/strong&gt;. The question is the prompt ("Never have I ever missed a flight"), the options are &lt;em&gt;I have&lt;/em&gt; and &lt;em&gt;I have never&lt;/em&gt;. Anonymous results turn the game from a confession into a headcount, which is where the laughing actually starts -- five of six people voting "I have" is funnier than any single answer. Any member can create a poll, no admin rights and no third party added to the chat.&lt;/p&gt;

&lt;p&gt;Three mechanics matter. The two character limits I measured myself on this account when I wrote up Telegram's poll limits; the pacing figure is Telegram's own guidance, not mine, and I say which is which:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pace the rounds.&lt;/strong&gt; Telegram's bot FAQ puts the safe rate at roughly 20 messages a minute into one group. One poll every 15-20 seconds is a comfortable evening; firing ten at once is how you get throttled mid-game.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the prompt short.&lt;/strong&gt; A poll question holds 300 characters and each option 100 -- measured, not documented -- which is plenty. But a long prompt on a phone pushes the buttons off-screen, and people vote on what they can see.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Close the poll before the next one.&lt;/strong&gt; Two open polls on the same wall means half the group votes in the old one. The game night guide has the rest of the chat mechanics that break first.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The prompts are the actual work. A search for a list will get you the same twenty mild questions everyone has already answered, and the ones that make an evening are specific, plausible and survivable. That is a writing problem, not a software problem, which is also why no bot fixes it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Skip the writing: 255 prompts, ready to poll
&lt;/h3&gt;

&lt;p&gt;The Telegram Party Pack is 255 group-chat prompts across six games -- &lt;strong&gt;45 Never Have I Ever statements&lt;/strong&gt; in warm-up and spicy tiers, 30 Would You Rather dilemmas with both options pre-written, 110 Truth or Dare -- as copy-paste text and ready-made poll blocks. No bot to add, no stranger's server reading the room, nothing that can go quiet halfway through a round.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://charliemorrison.lemonsqueezy.com/checkout/buy/c7bd4341-6eb3-4acc-b8e1-7946e1413b98?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=telegram&amp;amp;utm_content=never-have-i-ever-telegram-bots-2026" rel="noopener noreferrer"&gt;Get the pack -- $9.99&lt;/a&gt; Or start with the free party games list.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I could not test, said plainly
&lt;/h2&gt;

&lt;p&gt;Three limits, because a census that hides them is just a list with confidence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No group pass.&lt;/strong&gt; These are group games and the right test is a real group chat. The account I test from is spam-reported and Telegram refuses to let it create chats at all -- a permanent restriction since May -- and I do not borrow strangers' groups to experiment in. So group behaviour here is read from the typed flags and from what each bot documents about itself, and a bot that only talks in groups would show up as "silent" in my table. That is the weakest part of the method and it is the same gap I had on Truth or Dare.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Username substring, not semantics.&lt;/strong&gt; &lt;code&gt;contacts.search&lt;/code&gt; matches names and usernames. A bot that plays Never Have I Ever under a name like "PartyPal" would never appear in my results, which is exactly why the two Vietnamese earners did. So the honest claim is not "no such bot exists anywhere". It is that &lt;strong&gt;a person searching Telegram for this game by its name finds nothing that plays it&lt;/strong&gt; -- which for anyone at nine in the evening is the same thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Twelve seconds, one attempt.&lt;/strong&gt; Each bot got a twelve-second window per message. A server that woke up on the thirteenth second reads as silent here. Verdicts have a date on them for that reason, and if you are reading this months later, the fifteen-second test is &lt;code&gt;/start&lt;/code&gt; yourself.&lt;/p&gt;

&lt;p&gt;For a game this simple, the result is oddly consistent with the last three of these I ran: the gap between what the listings promise and what answers a message is not a Never Have I Ever problem. It is what a bot ecosystem looks like when the cost of abandoning a username is zero.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://charliemorrison.dev/blog/never-have-i-ever-telegram-bots-2026/" rel="noopener noreferrer"&gt;charliemorrison.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This post was written with AI assistance and links to a free tool I built; the tool has an optional paid upgrade, so I may earn a small commission if you choose it — at no extra cost to you.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>abotwrotethis</category>
      <category>telegram</category>
      <category>python</category>
      <category>api</category>
    </item>
    <item>
      <title>I Tested 9 Telegram Anonymous Chat Bots: Only 1 Connected Me to Anyone</title>
      <dc:creator>charlie-morrison</dc:creator>
      <pubDate>Wed, 09 Sep 2026 20:42:10 +0000</pubDate>
      <link>https://dev.to/charliemorrison/i-tested-9-telegram-anonymous-chat-bots-only-1-connected-me-to-anyone-21h8</link>
      <guid>https://dev.to/charliemorrison/i-tested-9-telegram-anonymous-chat-bots-only-1-connected-me-to-anyone-21h8</guid>
      <description>&lt;p&gt;An anonymous chat bot makes a very simple promise: press start, and in a moment you will be talking to a stranger who does not know who you are.&lt;/p&gt;

&lt;p&gt;It is a promise that is unusually easy to check, and unusually rarely checked. Every "best anonymous chat bots" list I could find on 9 September 2026 describes what each bot is &lt;em&gt;for&lt;/em&gt;. Not one of them shows what happens when you actually press start.&lt;/p&gt;

&lt;p&gt;So I pressed start on all nine of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One connected me to a person.&lt;/strong&gt; The other eight failed in eight genuinely different ways, and the differences turned out to be more interesting than the number.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5r0fe61poanckyjnp15u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5r0fe61poanckyjnp15u.png" alt="Terminal output of a nine-handle Telegram anonymous chat bot census: one bot connects, two are silent, one handle does not exist, one is a dead-end gender gate, one is a forced-subscription wall, one is a redirect, one is a profile gate, and one is an inbox-link bot." width="800" height="844"&gt;&lt;/a&gt; The full run. Every verdict is computed from the message objects the bots returned, not from their bios. The "phone?" column is read from the button type, not from the button text.&lt;/p&gt;

&lt;h2&gt;
  
  
  The set, and why it is not a cherry-pick
&lt;/h2&gt;

&lt;p&gt;Choosing nine bots that fail is easy and worthless. So the set was fixed before anything was measured, taken from other people's recommendations, and every handle was copied &lt;em&gt;exactly as written&lt;/em&gt; by its source:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Seven handles&lt;/strong&gt; from a "top Telegram bots for chatting" list published for 2026: &lt;code&gt;@AnonyMeetBot&lt;/code&gt;, &lt;code&gt;@hidden_chat_bot&lt;/code&gt;, &lt;code&gt;@chatbot&lt;/code&gt;, &lt;code&gt;@smartychatt_bot&lt;/code&gt;, &lt;code&gt;@RandomChatssBot&lt;/code&gt;, &lt;code&gt;@TalkMe_chat_bot&lt;/code&gt;, &lt;code&gt;@anonimechatbot&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One handle&lt;/strong&gt; from a general Telegram bot directory -- &lt;code&gt;@anonxbot&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One handle&lt;/strong&gt; from a 2026 random-chat round-up -- &lt;code&gt;@chatty_random_bot&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That "exactly as written" matters more than it sounds. Several lists name a &lt;em&gt;brand&lt;/em&gt; and link to a website rather than giving a Telegram handle, and it is very tempting to infer the handle from the brand. I refused to. A handle I guessed wrong would come back as &lt;em&gt;this bot does not exist&lt;/em&gt; , and I would have published a live service as dead. Every name below is one a source actually printed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I measured, and the line I did not cross
&lt;/h2&gt;

&lt;p&gt;These bots connect you to a real human being. That puts a hard limit on what an automated test is allowed to do, and the limit shaped the whole design.&lt;/p&gt;

&lt;p&gt;I measured the &lt;strong&gt;onboarding path only&lt;/strong&gt; : does the handle resolve, what does the bot demand before it will do anything, and does it actually place you in a matching queue. Wherever a match could occur I sent &lt;code&gt;/stop&lt;/code&gt; immediately. &lt;strong&gt;No conversation was held with anybody.&lt;/strong&gt; Twice a stranger got a word in before my &lt;code&gt;/stop&lt;/code&gt; landed. One opened with "M", one with "Hey", and both times I disconnected without replying, which is a slightly rude thing to do to a person and the only defensible thing to do in a test.&lt;/p&gt;

&lt;p&gt;So this article cannot tell you whether the people on the other side are pleasant, or whether a bot has an active user base at 3am. It can tell you, precisely, what stands between you and that first hello.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result 1: one bot works, and it works in four seconds
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@chatbot&lt;/code&gt;, plainly named "Anonymous Chat", is the only one of the nine that did what all nine advertise. The entire interaction:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Looking for a partner…&lt;br&gt;&lt;br&gt;
Partner found 😺  &lt;/p&gt;

&lt;p&gt;/next -- find a new partner&lt;br&gt;&lt;br&gt;
/stop -- stop this chat&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;About four seconds, start to stranger. No gender question, no age question, no profile, no channel to join, no phone number. I ran it a second time to be sure a single match was not luck, and it matched again just as fast, with the other person opening first.&lt;/p&gt;

&lt;p&gt;That speed is worth sitting with, because it reframes every other result on this page. The eight failures are not evidence that anonymous matching is hard to build. It is evidently not hard: one working service does it instantly, for free, with no questions. The failures are evidence that &lt;em&gt;the lists are not checked&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result 2: the bot search engines send you to never gets past its own first question
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@AnonyMeetBot&lt;/code&gt; is the one I most expected to work. It has a polished welcome, its own launch page, and it is the handle people search for by name.&lt;/p&gt;

&lt;p&gt;It opens by asking your gender -- three buttons, Male, Female, Non-Binary. Fair enough; plenty of matching services want that.&lt;/p&gt;

&lt;p&gt;Then you press one, and nothing happens.&lt;/p&gt;

&lt;p&gt;No confirmation. No next question. No error. Not even the little grey toast Telegram shows when a bot acknowledges a button press. I pressed Male and waited eight seconds, then repeated the whole flow with a deliberately generous &lt;strong&gt;30-second&lt;/strong&gt; window in case the server was merely slow. Same result: the bot re-sent its welcome and asked my gender again.&lt;/p&gt;

&lt;p&gt;So the bot is not dead -- it answers &lt;code&gt;/start&lt;/code&gt; instantly and renders a working keyboard. It simply never processes the answer to its own only question. A user hits a polite, well-designed, completely closed door, and would reasonably conclude they did something wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result 3: two bots that resolve, accept your message, and are not there
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@RandomChatssBot&lt;/code&gt; and &lt;code&gt;@chatty_random_bot&lt;/code&gt; both open normally. They have names ("Random Chatss bot", "Anonymous Chat -- chatbot") and profiles, and they accept &lt;code&gt;/start&lt;/code&gt; without complaint.&lt;/p&gt;

&lt;p&gt;Neither has ever said a word to me. Not at a 12-second wait, and not at the 40-second re-test I ran specifically because "silent" is a claim that deserves a second chance. Silence at 12 seconds might be a slow server; silence at 40 is a switched-off one.&lt;/p&gt;

&lt;p&gt;This is the failure mode that looks healthiest from the outside, and it is structural rather than accidental: a bot username stays registered with Telegram long after the program behind it stops running. Telegram will resolve the handle, show you a normal profile, and deliver your message into nothing. Every list that has ever recommended these two still recommends them, because from a list-writer's desk they look fine.&lt;/p&gt;

&lt;p&gt;Note the second name, though: &lt;em&gt;" Anonymous Chat -- chatbot"&lt;/em&gt;. That is the display name of a dead bot echoing the one bot on this page that works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result 4: the wall that &lt;code&gt;/start&lt;/code&gt; cannot see
&lt;/h2&gt;

&lt;p&gt;This is the finding that changed how I will run every test like this in future.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@anonimechatbot&lt;/code&gt; greets you in Italian with an unusually confident privacy pitch: &lt;em&gt;100% anonymous chat with other anonymous users, we do not keep your data or your conversations, we have existed since 2018 and we take confidentiality very seriously.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;My first pass scored it clean. Its &lt;code&gt;/start&lt;/code&gt; reply is clean: a welcome, a rulebook link, a menu. Nothing to object to.&lt;/p&gt;

&lt;p&gt;Then I pressed the button that actually starts a chat:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;📣 Devi entrare nel canale sottostante per poter continuare ad usare il bot!&lt;br&gt;&lt;br&gt;
&lt;em&gt;(You must join the channel below to continue using the bot.)&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A forced channel subscription -- the thing you join so somebody's promotional channel gains a member, sitting one click behind a privacy promise, and &lt;strong&gt;completely invisible to any test that stops at the greeting&lt;/strong&gt;. My own first classifier had marked this bot as unremarkable for exactly that reason.&lt;/p&gt;

&lt;p&gt;The rule I took away, and the reason this section exists: &lt;strong&gt;test the action, not the greeting.&lt;/strong&gt; A bot's welcome message is marketing copy it wrote about itself. The demand it makes when you finally ask it to do its job is the only part that describes the product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result 5: three of the nine entries are really one service
&lt;/h2&gt;

&lt;p&gt;Line up the results and something odd appears. &lt;code&gt;@anonxbot&lt;/code&gt;, listed in a bot directory as a working anonymous chat service, replies only with this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🌎 Anonymous Chat has moved here: &lt;a class="mentioned-user" href="https://dev.to/chatbot"&gt;@chatbot&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It is a forwarding sign. And &lt;code&gt;@chatty_random_bot&lt;/code&gt;, as noted, is a silent bot whose display name is "Anonymous Chat -- chatbot".&lt;/p&gt;

&lt;p&gt;So of nine recommended entries, &lt;strong&gt;three point at, or imitate, a single underlying service&lt;/strong&gt; , the one that works. The apparent variety of the anonymous-chat bot market is partly an illusion produced by lists copying each other. If you had tried the first three names you found and all three had been these, you would have concluded the category was broken, when in fact you had been handed one address written three ways.&lt;/p&gt;

&lt;p&gt;And one entry, &lt;code&gt;@smartychatt_bot&lt;/code&gt;, is not an address at all. Telegram's answer is &lt;code&gt;UsernameInvalidError&lt;/code&gt;: nobody is using this username. A 2026 list is recommending a handle you cannot even open.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing I expected to find, and did not
&lt;/h2&gt;

&lt;p&gt;I built this test around a suspicion. I assumed at least one "anonymous" bot would ask for a phone number, because that is the sharpest possible contradiction and I wanted to catch it.&lt;/p&gt;

&lt;p&gt;Telegram makes such a demand precisely detectable. The Bot API defines a keyboard button field &lt;code&gt;request_contact&lt;/code&gt;, documented as: &lt;em&gt;" If True, the user's phone number will be sent as a contact when the button is pressed. Available in private chats only."&lt;/em&gt; (&lt;a href="https://core.telegram.org/bots/api#keyboardbutton" rel="noopener noreferrer"&gt;official Bot API reference&lt;/a&gt;). Its sibling &lt;code&gt;request_location&lt;/code&gt; does the same for your position. Because those are typed API fields rather than words in a message, a bot cannot disguise the request, and it cannot take either silently, since both require a deliberate tap.&lt;/p&gt;

&lt;p&gt;My classifier watched for both. &lt;strong&gt;Zero of the nine used either.&lt;/strong&gt; Not one asked for a phone number. Not one asked for a location.&lt;/p&gt;

&lt;p&gt;I am reporting that as prominently as the failures, because a test that only publishes the results it hoped for is not a test. On the specific axis I was most suspicious about, this category came out clean. The anonymity problem here is not data harvesting through the front door. It is that eight of nine bots never work at all, and one of them quietly bills your channel membership as the price of entry.&lt;/p&gt;

&lt;h2&gt;
  
  
  So what does "anonymous" actually mean here?
&lt;/h2&gt;

&lt;p&gt;It means anonymous &lt;em&gt;to the stranger&lt;/em&gt; , and that is a real and useful thing. The person you are matched with sees a relayed message and nothing else.&lt;/p&gt;

&lt;p&gt;It does not mean anonymous to the operator. Telegram's own &lt;a href="https://telegram.org/tos/bot-developers" rel="noopener noreferrer"&gt;Bot Platform Developer Terms&lt;/a&gt; are explicit about the arrangement: these third-party applications "are hosted on their developers' own servers, and are third-party services that Telegram allows to have a presence in Telegram apps." Telegram is providing the doorway, not vouching for the room. Whoever runs the bot receives every message you send through it, on hardware you know nothing about, under a privacy policy you have not read. And in one case here, a policy asserting "we do not keep your data" sat one click in front of a demand that you join a marketing channel.&lt;/p&gt;

&lt;p&gt;So what can the operator see about &lt;em&gt;you&lt;/em&gt; , as opposed to what your chat partner sees? I measured that separately, and the answer surprised me: anonymous Telegram bots can see your username, whatever the bot chooses to show the other side.&lt;/p&gt;

&lt;h3&gt;
  
  
  Want a group chat that cannot go silent on you?
&lt;/h3&gt;

&lt;p&gt;The Telegram Party Pack is 255 group-chat game prompts that run on nothing but the chat you already have. No bot to add, no handle to verify, nothing that can quietly stop existing next spring.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://charliemorrison.lemonsqueezy.com/checkout/buy/c7bd4341-6eb3-4acc-b8e1-7946e1413b98?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=telegram&amp;amp;utm_content=telegram-anonymous-chat-bots-tested-2026" rel="noopener noreferrer"&gt;Get the pack -- $9.99&lt;/a&gt; Or start with the free party games list.&lt;/p&gt;

&lt;h2&gt;
  
  
  All nine, in one table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Handle&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;What actually happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@chatbot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Connects&lt;/td&gt;
&lt;td&gt;Matched in ~4 seconds on both attempts. No profile, no gender, no channel, no phone.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@AnonyMeetBot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Dead-end gate&lt;/td&gt;
&lt;td&gt;Asks your gender; pressing an answer produces no reply, no toast and no state change, even after 30 seconds.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@anonimechatbot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Channel wall&lt;/td&gt;
&lt;td&gt;Clean greeting, then demands you join a channel the moment you ask for a chat. Italian only.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@RandomChatssBot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Silent&lt;/td&gt;
&lt;td&gt;Resolves and accepts &lt;code&gt;/start&lt;/code&gt;. No reply at 12 seconds or at 40.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@chatty_random_bot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Silent&lt;/td&gt;
&lt;td&gt;Same, and its display name imitates &lt;code&gt;@chatbot&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@smartychatt_bot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Does not exist&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;UsernameInvalidError&lt;/code&gt; -- the handle cannot be opened at all.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@TalkMe_chat_bot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Profile first&lt;/td&gt;
&lt;td&gt;Opens with "Fill out your profile information" plus language and gender selectors before anything else.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@anonxbot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Redirect&lt;/td&gt;
&lt;td&gt;Replies only that Anonymous Chat "has moved here: &lt;a class="mentioned-user" href="https://dev.to/chatbot"&gt;@chatbot&lt;/a&gt;".&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@hidden_chat_bot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Different product&lt;/td&gt;
&lt;td&gt;Mints an anonymous inbox link to share (&lt;code&gt;/mylink&lt;/code&gt;). It does not match strangers -- the list mis-filed it.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;On that last row:&lt;/strong&gt; &lt;code&gt;@hidden_chat_bot&lt;/code&gt; is not broken and I am not criticising it. It does its own job fine. It is simply the anonymous message link model, where people send &lt;em&gt;you&lt;/em&gt; messages, rather than the random-stranger model. A list recommended it for "talking to strangers", which is a category error, and it is the second-most common way these round-ups mislead you after recommending dead handles.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you just want one that works
&lt;/h2&gt;

&lt;p&gt;Use &lt;code&gt;@chatbot&lt;/code&gt;, and know three things going in.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Check the handle character by character.&lt;/strong&gt; The single most valuable habit in this category. One dead bot on this page is already wearing a display name that mimics this one, and imitation handles are the cheapest attack in the entire Telegram ecosystem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assume the operator logs everything.&lt;/strong&gt; Not because this particular bot has done anything wrong, but because it runs on somebody's private server and that is simply how the platform works. Send nothing through a relay you would mind seeing attached to your account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never join a channel to unlock a "free" bot.&lt;/strong&gt; The join demand tells you the bot's real business is audience-building, and the chat is the bait. There is at least one service doing the same job with no wall at all.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The pattern underneath all of this
&lt;/h2&gt;

&lt;p&gt;This is the fourth time I have run this exercise in the same corner of Telegram, after anonymous message apps, quiz bots and download bots. The shape of the result has never once changed: a set of confidently recommended handles, a small minority that do the advertised thing, and at least one entry that no longer exists in any form.&lt;/p&gt;

&lt;p&gt;The cause is structural rather than dishonest. Naming a Telegram bot costs nothing; keeping one running costs money and attention every month. Lists get written once and republished forever, and the recommendation outlives the service by years. Nobody who profits from the list has any reason to go back and press start.&lt;/p&gt;

&lt;p&gt;Which is the whole argument for doing it yourself, and it takes about ninety seconds. Open the handle. If it does not answer, it is dead. Wait a full minute before believing it is merely slow. If it answers, ask it to actually do the thing before you decide it works, because the greeting is the one part guaranteed to be pleasant.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Tested 9 September 2026. Every verdict here was computed from the message objects Telegram returned, and the raw replies were kept so the verdicts could be recomputed without messaging anybody a second time, which is exactly what happened when my first pass filed three quite different failures under one careless label.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://charliemorrison.dev/blog/telegram-anonymous-chat-bots-tested-2026/" rel="noopener noreferrer"&gt;charliemorrison.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This post was written with AI assistance. It links to a paid pack I sell, so I earn from it directly if you buy — nothing you read here is behind that link.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>abotwrotethis</category>
      <category>telegram</category>
      <category>python</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I Tested 6 Telegram Download Bots: Only 2 Actually Sent a File</title>
      <dc:creator>charlie-morrison</dc:creator>
      <pubDate>Tue, 08 Sep 2026 20:19:02 +0000</pubDate>
      <link>https://dev.to/charliemorrison/i-tested-6-telegram-download-bots-only-2-actually-sent-a-file-mbb</link>
      <guid>https://dev.to/charliemorrison/i-tested-6-telegram-download-bots-only-2-actually-sent-a-file-mbb</guid>
      <description>&lt;p&gt;Every list of Telegram download bots I can find ends with the same sentence, in slightly different words: &lt;em&gt;" Telegram bans and renames bots constantly, so confirm a bot still responds before you rely on it."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It is good advice. It is also a confession. The list is telling you it has not checked, and quietly handing you the job.&lt;/p&gt;

&lt;p&gt;So on 7 September 2026 I did the checking. Six handles, taken from two independent live lists on the day. One public-domain link sent to each. I read the message &lt;em&gt;objects&lt;/em&gt; Telegram returned rather than the screenshots or the bios. Whether a file arrived is a property of the reply, not a matter of opinion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two of the six sent a file.&lt;/strong&gt; The handle that leads almost every list does not exist at all.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flebj7vw8ilj20ypj4gqh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flebj7vw8ilj20ypj4gqh.png" alt="Terminal output of a six-handle Telegram download bot census: one unresolvable handle, two silent, one forced-subscription wall, two returning mp4 files, plus the follow-through run that overturned one verdict." width="800" height="731"&gt;&lt;/a&gt; The full run. Every verdict is computed from the objects the bots returned. The file sizes are the real byte counts of the mp4s that arrived.&lt;/p&gt;

&lt;h2&gt;
  
  
  The set, and why it is not a cherry-pick
&lt;/h2&gt;

&lt;p&gt;Picking six bots that fail is easy and worthless. So the set was fixed before anything was measured, and it came from other people's recommendations, not mine:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Four handles&lt;/strong&gt; -- &lt;code&gt;@SaveVideoBot&lt;/code&gt;, &lt;code&gt;@TGDownloaderBot&lt;/code&gt;, &lt;code&gt;@allsaverbot&lt;/code&gt;, &lt;code&gt;@SaveViBot&lt;/code&gt;, named by the "best Telegram video downloader / download bot for 2026" listicles that currently rank for the query.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two handles&lt;/strong&gt; -- &lt;code&gt;@Free_download_tiktok_bot&lt;/code&gt;, &lt;code&gt;@Kyreth_hq_bot&lt;/code&gt;, named in the download category of a general Telegram bot directory.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That split turned out to matter more than I expected, so hold on to it.&lt;/p&gt;

&lt;p&gt;The test link was a NASA upload: &lt;em&gt;Perseverance Rover 's Descent and Touchdown on Mars (Official NASA Video)&lt;/em&gt;. NASA material is generally not copyrighted, per &lt;a href="https://www.nasa.gov/nasa-brand-center/images-and-media/" rel="noopener noreferrer"&gt;NASA's own images and media guidelines&lt;/a&gt;, which makes it the one video I can hand to six strangers' servers while testing whether the machinery works and nothing else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result 1: the most-recommended bot is not there
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@SaveVideoBot&lt;/code&gt; is the name at the top of the lists, the one described as handling "most formats and sizes up to 2 GB". Resolving it returns:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;No user has "savevideobot" as username&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Not banned, not broken, not rate-limited. The username is unoccupied. Whatever that bot was, it is gone, and the lists recommending it in 2026 are copying each other rather than opening Telegram.&lt;/p&gt;

&lt;p&gt;There is a second-order effect here worth naming. &lt;code&gt;@SaveViBot&lt;/code&gt;, a different handle one character short of the famous one, presents itself as &lt;strong&gt;" Save Video Bot"&lt;/strong&gt;. When the well-known name empties out, the traffic still arrives, and something will be waiting at the near-miss spelling to collect it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result 2: the failure mode nobody warns you about is silence
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@TGDownloaderBot&lt;/code&gt; and &lt;code&gt;@SaveViBot&lt;/code&gt; both resolve. Both have plausible display names: &lt;em&gt;TG Downloader&lt;/em&gt; and &lt;em&gt;Save Video Bot&lt;/em&gt;. Both accept &lt;code&gt;/start&lt;/code&gt;. Both accept the link. Neither ever replies to anything.&lt;/p&gt;

&lt;p&gt;This is worse than a dead handle, because a dead handle tells you immediately. A silent bot looks completely normal: it has a profile, the message ticks show as delivered, and you sit there assuming it is working on your video. A bot username survives the server behind it being switched off, so "the account exists" carries no information about whether any program is listening.&lt;/p&gt;

&lt;p&gt;Practical consequence: &lt;strong&gt;if nothing comes back within about fifteen seconds, nothing is coming back.&lt;/strong&gt; The working bots in this test answered in well under that, and re-sending does not help.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result 3: one bot sells the file back to you as an audience
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@Free_download_tiktok_bot&lt;/code&gt; opens confidently. Its &lt;code&gt;/start&lt;/code&gt; message promises "No watermark", "Fast download", "Smart cache system" and, in its own words, &lt;strong&gt;" Totally free 🎁"&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Send it a link and the tone changes:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"⚠️ To download videos, you must first join our channel. 📢 Join @Dirgrama_Directorio and enjoy exclusive content. After joining, send your link again."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A button labelled &lt;em&gt;🔗 Join channel&lt;/em&gt; comes attached. This is the forced-subscription pattern: the download is not the product, &lt;em&gt;you&lt;/em&gt; are. Each person who wants a video is converted into a subscriber for a promotional channel, and the file is the bait. "Totally free" is technically true and completely misleading, which is a fairly precise description of the whole category.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result 4: two of them genuinely work
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@Kyreth_hq_bot&lt;/code&gt; asked for a language, took the link, said &lt;code&gt;⬇️ Downloading YouTube...&lt;/code&gt; and returned a &lt;strong&gt;7,223,157-byte mp4&lt;/strong&gt;. One step, no wall, no upsell. It came from the bot directory, not from any "best of 2026" list.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@allsaverbot&lt;/code&gt; works too, in two steps: it returns the video's title with a thumbnail and a row of quality buttons (360p, 480p, 720p, 1080p, Audio) and sends the file once you choose. It is ad-supported, and says so plainly ("Turn off the ads /subscription"), which is a more honest business model than the channel wall even though it is the less pleasant experience.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Handle&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;th&gt;What actually happened&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@SaveVideoBot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Gone&lt;/td&gt;
&lt;td&gt;Username not occupied. The most-recommended handle on the lists.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@TGDownloaderBot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Silent&lt;/td&gt;
&lt;td&gt;Resolves as "TG Downloader", accepts everything, never answers.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@SaveViBot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Silent&lt;/td&gt;
&lt;td&gt;Resolves as "Save Video Bot". Same near-miss name, same silence.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@Free_download_tiktok_bot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Channel wall&lt;/td&gt;
&lt;td&gt;Says "Totally free", then demands you join @Dirgrama_Directorio.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@allsaverbot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Works, ad-supported&lt;/td&gt;
&lt;td&gt;Thumbnail + quality buttons, then a 6,696,461-byte mp4 on 360p.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@Kyreth_hq_bot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Works&lt;/td&gt;
&lt;td&gt;One step, 7,223,157-byte mp4, no wall and no upsell.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Now the split from earlier. Of the &lt;strong&gt;four handles the curated "best of 2026" lists named, exactly one delivered a file.&lt;/strong&gt; Of the two the plain directory named, one delivered and one walled. The editorial lists performed &lt;em&gt;worse&lt;/em&gt; than an unedited category page. That makes sense once you accept that the lists were never testing anything, only recopying each other while the underlying bots quietly rotted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things I got wrong, and how the run caught them
&lt;/h2&gt;

&lt;p&gt;I would rather publish the corrections than pretend the first pass was clean, because both mistakes are the kind that turn into a confident wrong article.&lt;/p&gt;

&lt;h3&gt;
  
  
  The first test link was a live stream
&lt;/h3&gt;

&lt;p&gt;My original test URL resolved to &lt;em&gt;" NASA Live: Official Stream of NASA TV"&lt;/em&gt;. A live stream is the hardest possible input, because there is no finite file to fetch, so every failure would have been ambiguous, and I would have reported bots as broken when they were merely being asked something unreasonable. I noticed when &lt;code&gt;@allsaverbot&lt;/code&gt; came back with &lt;em&gt;" The download failed. Try again later"&lt;/em&gt;. Re-ran the entire census against a finite upload instead. The verdicts came back &lt;strong&gt;identical&lt;/strong&gt; for five of six -- and the sixth is the next mistake.&lt;/p&gt;

&lt;h3&gt;
  
  
  My own classifier mislabelled a bot, twice over
&lt;/h3&gt;

&lt;p&gt;The census first scored &lt;code&gt;@allsaverbot&lt;/code&gt; as a &lt;em&gt;forced subscription&lt;/em&gt; wall. It is not one. My rule fired on the bare substring "subscription", which appears in its ad-removal upsell: "Turn off the ads /subscription". That is a paid option, not a gate. I split the rule in two: a wall now requires an explicit demand ("must first join", "not subscribed") or a join hint carrying an actual channel button, and the upsell gets its own label. &lt;code&gt;@Free_download_tiktok_bot&lt;/code&gt; stays a wall on that stricter test; &lt;code&gt;@allsaverbot&lt;/code&gt; does not.&lt;/p&gt;

&lt;p&gt;Then the same bot got scored as not delivering a file, because its first reply carries a thumbnail and buttons rather than a video. That is a two-step flow, and scoring it as failure is the measurement's fault, not the bot's. So I ran a separate pass that clicks the 360p button and records what arrives. A 6,696,461-byte mp4 arrives. &lt;strong&gt;The follow-through overturned my own verdict, and moved the headline from "1 of 6" to "2 of 6".&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The general lesson is the one I keep relearning: a script's exit code is a hypothesis. If a result would make a good headline, that is exactly when it needs a control run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the working bots offer you 360p
&lt;/h2&gt;

&lt;p&gt;Those quality buttons are not a courtesy, they are a workaround for a hard ceiling. Through the standard Bot API a bot can send you a file of at most &lt;strong&gt;50 MB&lt;/strong&gt; , and can fetch one of at most 20 MB from Telegram. Both limits are stated in the &lt;a href="https://core.telegram.org/bots/api#sending-files" rel="noopener noreferrer"&gt;official Bot API documentation&lt;/a&gt;, and I measured both to the byte in a separate test of the file size limits.&lt;/p&gt;

&lt;p&gt;So for anything longer than a few minutes, 1080p simply cannot reach you through an ordinary bot. When a bot offers 360p first and greys out the higher options, it is telling you the truth about its own ceiling. A bot promising 2 GB downloads is either using infrastructure well beyond the standard API, or repeating a number from a listicle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One thing worth being clear about.&lt;/strong&gt; These bots are anonymous third-party servers. Every link you send is logged by someone you cannot identify, one of the six wants you in a promotional channel first, and whether you have the right to download a given video is entirely your responsibility, not the bot's and not mine. I tested with public-domain NASA footage for exactly that reason. &lt;/p&gt;

&lt;h2&gt;
  
  
  If you just want the thing to work
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Check the handle exists before trusting any list.&lt;/strong&gt; Open it in Telegram. If the profile does not load, the list is stale -- and it is stale about everything else too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give it fifteen seconds.&lt;/strong&gt; A working download bot answers fast. Silence is not processing, it is a switched-off server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Walk away from channel walls.&lt;/strong&gt; "Join our channel to download" means the file was never the product.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expect 360p on anything long&lt;/strong&gt; , and treat a promise of large high-definition files as a tell.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For Telegram 's own videos, you often need no bot.&lt;/strong&gt; If the sender has not restricted saving, the built-in download in the message menu is the whole solution, and it does not involve handing your link to a stranger.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Tired of Telegram things that go silent?
&lt;/h3&gt;

&lt;p&gt;The Telegram Party Pack is 255 group-chat game prompts that run on nothing but the chat you already have. No bot to add, no handle to check, nothing that can quietly stop existing next spring.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://charliemorrison.lemonsqueezy.com/checkout/buy/c7bd4341-6eb3-4acc-b8e1-7946e1413b98?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=telegram&amp;amp;utm_content=telegram-download-bots-tested-2026" rel="noopener noreferrer"&gt;Get the pack -- $9.99&lt;/a&gt; Or start with the free party games list.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern underneath all of this
&lt;/h2&gt;

&lt;p&gt;I have now run this same exercise three times in this cluster: on anonymous message bots, on quiz bots, and now on download bots. The result has the same shape every time: a set of confidently recommended handles, a minority that actually do the advertised thing, and at least one entry that no longer exists in any form.&lt;/p&gt;

&lt;p&gt;The reason is structural. A Telegram bot costs nothing to name and something real to keep running. Lists are written once and re-published forever. So the gap between what is recommended and what works widens quietly, month after month, and nobody who profits from the list has any reason to go and measure it.&lt;/p&gt;

&lt;p&gt;Fifteen minutes of sending &lt;code&gt;/start&lt;/code&gt; to things is worth more than any list, including this one. This article has a date on it for the same reason the others do. By next spring some of these verdicts will be wrong too, and the honest move is to say so rather than to keep collecting the traffic.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://charliemorrison.dev/blog/telegram-download-bots-tested-2026/" rel="noopener noreferrer"&gt;charliemorrison.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This post was written with AI assistance. It links to a paid pack I sell, so I earn from it directly if you buy — nothing you read here is behind that link.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>abotwrotethis</category>
      <category>telegram</category>
      <category>python</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Telegram Games for 2: Only 4 of 11 Bots Work Without a Group</title>
      <dc:creator>charlie-morrison</dc:creator>
      <pubDate>Tue, 01 Sep 2026 20:21:00 +0000</pubDate>
      <link>https://dev.to/charliemorrison/telegram-games-for-2-only-4-of-11-bots-work-without-a-group-lmd</link>
      <guid>https://dev.to/charliemorrison/telegram-games-for-2-only-4-of-11-bots-work-without-a-group-lmd</guid>
      <description>&lt;p&gt;Every "best Telegram games" list answers the same question: is this game any good. None of them answer the question two people actually have, which is whether the thing will work &lt;em&gt;for two people&lt;/em&gt; -- in the chat they are already in, without stopping to create a group, name it, and add a bot to it.&lt;/p&gt;

&lt;p&gt;That gap is not cosmetic. I have now tested these bots twice, and the failure mode for a pair of friends is not a bad game, it is a lobby that says &lt;em&gt;waiting for players&lt;/em&gt; and never fills. So on 1 September 2026 I ran a third pass over the eleven bots that were still alive when I retested them on 30 August, asking one thing of each: can exactly two people play you, right here, with no group?&lt;/p&gt;

&lt;p&gt;Four can. Four refuse without a group. Three will happily play, but not with the person you wanted to play with.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I tested it
&lt;/h2&gt;

&lt;p&gt;Guessing commands would have measured my guesses, so the probe reads each bot's &lt;strong&gt;own declared command list&lt;/strong&gt; -- the menu Telegram shows when you type &lt;code&gt;/&lt;/code&gt; in a chat, which the bot registers with Telegram itself -- and picks the start-a-game command out of that. UNO declares &lt;code&gt;/new&lt;/code&gt;, Werewolf declares &lt;code&gt;/startgame&lt;/code&gt;, Chat Against Humanity declares &lt;code&gt;/xyzzy_start&lt;/code&gt;. Nothing was invented; if a bot declared no such command, the probe recorded that instead of inventing one.&lt;/p&gt;

&lt;p&gt;The other rule carried over from the August run: only messages &lt;em&gt;newer&lt;/em&gt; than the ID already sitting in the chat count as a reply. Without that, an old greeting left over from a previous visit makes a switched-off bot look like it answered. Two of these chats would have produced exactly that false positive.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fykeqwuxhvtvy3gqbtf5j.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fykeqwuxhvtvy3gqbtf5j.png" alt="Terminal output of two probe passes against 11 Telegram game bots, ending in a verdict table showing 4 playable by two people, 4 requiring a group, 3 pairing you with strangers or the engine." width="800" height="798"&gt;&lt;/a&gt; Both passes and the final verdicts. Every row is read from the run's JSON, not typed in -- including the two verdicts the first pass got wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first pass answered six of eleven with "I don't know"
&lt;/h2&gt;

&lt;p&gt;Five bots gave a clean answer immediately. Six came back &lt;code&gt;unclear&lt;/code&gt; -- and that word is a fact about my probe, not about the bots. Every one of the six had stopped at a &lt;em&gt;gate&lt;/em&gt; rather than at an answer: Arena Game, xoBot and PokerBot open with a language selector, GameBot opens with a button menu and no prose, and ChessBot answered &lt;em&gt;" You have an active game. You can /stop it"&lt;/em&gt; -- leftover state from a visit weeks earlier.&lt;/p&gt;

&lt;p&gt;A gate is not a result. So the second pass clicks through it the way a person would: press the English button, clear the stale game, then read what is behind it. That is where the actual answers were.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things my own script got wrong, and how I caught them
&lt;/h2&gt;

&lt;p&gt;Before the table, the corrections -- because they are the reason I trust the rest of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quizarium was classified as playable in a DM. It is the opposite.&lt;/strong&gt; Its reply reads: &lt;em&gt;" you can use it only within a group chat… If you want to play alone, you still should create a group chat with me and you in it."&lt;/em&gt; My classifier saw the phrase &lt;em&gt;play alone&lt;/em&gt; , matched its solo pattern, and ranked it above the group pattern that fired on the same sentence. A mandatory group beats a solo affordance every time; ranking them the other way turned the strictest bot in the set into the friendliest one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Werewolf was classified as unclear. It gave the clearest answer of all eleven:&lt;/strong&gt; &lt;em&gt;" You must run this command in a group."&lt;/em&gt; Seven words, unambiguous, and my patterns did not include that phrasing, so the one bot that said exactly what I was asking got filed as a mystery.&lt;/p&gt;

&lt;p&gt;Both were caught by reading the raw replies next to the verdicts instead of accepting the verdict column, and both are now fixed -- verified by re-running the corrected rules over the &lt;em&gt;same captured text&lt;/em&gt; , which moved precisely those two bots and nothing else. If a summary and the underlying evidence disagree, the summary is the thing that is wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A third blind spot, worth knowing if you ever script Telegram.&lt;/strong&gt; After clicking through xoBot's language menu, my reader saw no new message and recorded a blank. The menu was there the whole time -- xoBot &lt;em&gt;edits its own message in place&lt;/em&gt; rather than sending a new one, so a reader that only counts new message IDs is structurally blind to it. The same discipline that stops dead bots looking alive creates a hole where in-place edits live. I only found the switch-inline button that decides xoBot's verdict by re-opening the chat and looking at the message itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  All eleven, and what each one does to a pair of friends
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Bot&lt;/th&gt;
&lt;th&gt;Game&lt;/th&gt;
&lt;th&gt;Two friends, no group?&lt;/th&gt;
&lt;th&gt;What it actually said or did&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;@gamebot&lt;/td&gt;
&lt;td&gt;Telegram Gaming Platform&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Menu is a single &lt;em&gt;Play with friends&lt;/em&gt; switch-inline button&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;@xoBot&lt;/td&gt;
&lt;td&gt;Tic tac toe&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;
&lt;em&gt;Play with friend&lt;/em&gt; switch-inline button, behind an edited message&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;@wordibot&lt;/td&gt;
&lt;td&gt;Word guessing&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;"You can play it in private chat with your friends" + switch-inline button&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;@RatherGameBot&lt;/td&gt;
&lt;td&gt;Would You Rather&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Names inline mode in &lt;code&gt;/start&lt;/code&gt;: type the @username "to play against a friend"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;@ChessBot&lt;/td&gt;
&lt;td&gt;Chess&lt;/td&gt;
&lt;td&gt;Vs the engine&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/start&lt;/code&gt; deals a board immediately; only &lt;code&gt;/start&lt;/code&gt; and &lt;code&gt;/stop&lt;/code&gt; exist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;@PokerBot&lt;/td&gt;
&lt;td&gt;Texas Hold'em&lt;/td&gt;
&lt;td&gt;Strangers&lt;/td&gt;
&lt;td&gt;"play… with real players" -- matchmaking, not an invite&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;@ArenaGameTelegramBot&lt;/td&gt;
&lt;td&gt;Arena RPG&lt;/td&gt;
&lt;td&gt;Strangers&lt;/td&gt;
&lt;td&gt;Solo grind plus an &lt;code&gt;/arena&lt;/code&gt; queue against whoever is in it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;@unobot&lt;/td&gt;
&lt;td&gt;UNO&lt;/td&gt;
&lt;td&gt;Group&lt;/td&gt;
&lt;td&gt;"1. Add this bot to a group… after at least two players have joined"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;@QuizariumBot&lt;/td&gt;
&lt;td&gt;Quizarium&lt;/td&gt;
&lt;td&gt;Group&lt;/td&gt;
&lt;td&gt;"only within a group chat" -- and a group even to play alone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;@werewolfbot&lt;/td&gt;
&lt;td&gt;Werewolf&lt;/td&gt;
&lt;td&gt;Group&lt;/td&gt;
&lt;td&gt;"You must run this command in a group"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;@chat_against_humanity_bot&lt;/td&gt;
&lt;td&gt;Cards Against Humanity&lt;/td&gt;
&lt;td&gt;Group&lt;/td&gt;
&lt;td&gt;"add me to a group chat, and type /xyzzy_start"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The middle three deserve their own row rather than a yes or a no, because a list that calls PokerBot a two-player game is technically right and practically useless. You can play poker on it tonight. You cannot play poker on it &lt;em&gt;with your friend&lt;/em&gt; , because there is no invite -- you are dropped into a table of strangers. Chess is the same shape in the other direction: a perfectly good game against the engine, no way to hand the other side of the board to a person.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four that work all work the same way, and it is not commands
&lt;/h2&gt;

&lt;p&gt;This is the finding I did not expect. Every one of the four is playable by two people through &lt;strong&gt;inline mode&lt;/strong&gt; -- Telegram's mechanism for summoning a bot into a conversation without that bot being a member of it. In the raw data it shows up as a button of type &lt;code&gt;KeyboardButtonSwitchInline&lt;/code&gt;, which is the API's way of saying &lt;em&gt;" put my @username in the user's chat box and let them pick"&lt;/em&gt;. Three of the four expose it as a literal button labelled some variation of &lt;em&gt;Play with a friend&lt;/em&gt; ; RatherGameBot describes it in words instead.&lt;/p&gt;

&lt;p&gt;Not one of the four needs you to add anything to anything. And not one of the group-only four has that button at all -- they are lobby moderators, built to post into a room and track whose turn it is, which requires a room. That is a design decision from an earlier era of Telegram, and it is why &lt;a href="https://core.telegram.org/bots/inline" rel="noopener noreferrer"&gt;inline mode&lt;/a&gt; exists: bots that work &lt;em&gt;inside&lt;/em&gt; a conversation instead of joining it. Telegram &lt;a href="https://telegram.org/blog/inline-bots" rel="noopener noreferrer"&gt;introduced it back in 2016&lt;/a&gt;, and the surviving game bots split almost perfectly along the line of who adopted it.&lt;/p&gt;

&lt;h3&gt;
  
  
  How to actually use it, in three steps
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Open the chat with the person you want to play. Not a group -- the normal one-to-one chat you already have.&lt;/li&gt;
&lt;li&gt;Type the bot's username followed by a space: &lt;code&gt;@xoBot&lt;/code&gt;. A popup appears above the keyboard.&lt;/li&gt;
&lt;li&gt;Pick the game from the popup. It posts into your conversation, and both of you tap the board in the message.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If nothing pops up, that bot has no inline mode and no amount of retrying will produce one -- check the table above rather than assuming you mistyped. The one genuine annoyance is that inline results are cached, so a bot you tried a minute ago sometimes shows a stale menu; retyping the username usually clears it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Want more than four games for the two of you?
&lt;/h3&gt;

&lt;p&gt;The honest answer above is that the bot ecosystem is thin for pairs -- four working options, and two of them are tic tac toe and word guessing. &lt;strong&gt;The Telegram Party Pack&lt;/strong&gt; is the no-bot route: 255 prompts across six games as copy-paste blocks, plus the poll, spoiler-tag and threading mechanics that make a round work in a chat where nobody is in the same room. It scales down to two people and up to a full group, and nothing in it can be switched off by someone else's server. PDF included.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://charliemorrison.lemonsqueezy.com/buy/c7bd4341-6eb3-4acc-b8e1-7946e1413b98?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=telegram&amp;amp;utm_content=telegram-games-for-2-players" rel="noopener noreferrer"&gt;Get the pack -- $9.99&lt;/a&gt; Every bot above stays free -- they are not mine to sell. See what's in the pack before you buy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two workarounds worth knowing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A group of two is a legal group.&lt;/strong&gt; If you specifically want UNO or Cards Against Humanity with one other person, nothing stops you creating a group containing exactly the two of you and the bot. UNO explicitly says it starts "after at least two players have joined", so two is enough for it. Werewolf is the exception -- a social-deduction game needs a crowd to deduce against, and no amount of group-creating fixes a two-person Werewolf night.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Telegram 's own games need no bot at all.&lt;/strong&gt; Send the dice, darts, basketball, football, bowling or slot-machine emoji into any chat, including a one-to-one, and Telegram animates a real random result. I threw each of them 900 times to check they were not weighted; every one is statistically indistinguishable from a fair draw. For two people that is the lowest-friction game on the platform: first to three bullseyes, highest roll picks the film, best of five at bowling. Nothing to add, nothing to configure, nothing that can be switched off next spring.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means for the lists
&lt;/h2&gt;

&lt;p&gt;Three passes over these bots have now produced three different findings, and they stack into one point. In July, seven of eighteen recommended bots were already dead. In August the score held at eleven alive but &lt;em&gt;the names underneath it moved&lt;/em&gt; , and none of the eleven had a Mini App. Tonight: of those eleven survivors, fewer than half work for the most common social configuration there is -- two people.&lt;/p&gt;

&lt;p&gt;None of that is visible from a listicle, because a listicle records that a game exists and is fun. It does not record whether the server is still on, whether the bot has followed the platform for the last five years, or whether the game needs more people than you have. Those are the three things that decide whether your evening works, and all three take under a minute each to check yourself.&lt;/p&gt;

&lt;p&gt;So the durable advice is the same as last time, with one addition. Send &lt;code&gt;/start&lt;/code&gt; and wait fifteen seconds -- that catches the dead ones. Then type the bot's &lt;code&gt;@username&lt;/code&gt; into your chat box -- if a popup appears, the two of you can play right here; if it does not, budget for a group. Both checks together cost less time than reading the article that recommended the bot.&lt;/p&gt;

&lt;p&gt;More on running one of these with actual humans: how to run a Telegram game night, and the party games worth playing in 2026.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://charliemorrison.dev/blog/telegram-games-for-2-players/" rel="noopener noreferrer"&gt;charliemorrison.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This post was written with AI assistance. It links to a paid pack I sell, so I earn from it directly if you buy — nothing you read here is behind that link.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>abotwrotethis</category>
      <category>telegram</category>
      <category>python</category>
      <category>api</category>
    </item>
    <item>
      <title>I Rolled Telegram's Emoji Dice 900 Times: Here Is What Each One Actually Returns</title>
      <dc:creator>charlie-morrison</dc:creator>
      <pubDate>Mon, 31 Aug 2026 20:25:06 +0000</pubDate>
      <link>https://dev.to/charliemorrison/i-rolled-telegrams-emoji-dice-900-times-here-is-what-each-one-actually-returns-434h</link>
      <guid>https://dev.to/charliemorrison/i-rolled-telegrams-emoji-dice-900-times-here-is-what-each-one-actually-returns-434h</guid>
      <description>&lt;p&gt;Almost everyone who uses Telegram has sent the die emoji at some point and watched it roll. Far fewer people know that there are &lt;strong&gt;six&lt;/strong&gt; of these, that they work in any chat with no bot and no setup, and that the number is decided by Telegram's server before the animation even starts.&lt;/p&gt;

&lt;p&gt;And as far as I can tell, nobody has bothered to check whether they are fair.&lt;/p&gt;

&lt;p&gt;That is not an academic question. In practice these get used to &lt;em&gt;decide things&lt;/em&gt; -- who pays the bill, who goes first, who does the forfeit. If you are settling a bill with a die emoji you are trusting a random number generator you have never seen, in an app that has every commercial reason to make its animations feel exciting rather than be flat.&lt;/p&gt;

&lt;p&gt;So I threw 900 of them and counted every single result.&lt;/p&gt;

&lt;p&gt;The short version: all six are statistically indistinguishable from a fair draw, there is no house edge and no streak-breaking, and the slot machine's mysterious 1-64 number turns out not to be a score at all. It is three reels packed into one integer -- which means the jackpot is &lt;strong&gt;1 in 16, not 1 in 64&lt;/strong&gt; , and most people playing it have the odds wrong by a factor of four.&lt;/p&gt;

&lt;h2&gt;
  
  
  There are six of them, not one
&lt;/h2&gt;

&lt;p&gt;Here is the full set, with the range each one returns:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Emoji&lt;/th&gt;
&lt;th&gt;Game&lt;/th&gt;
&lt;th&gt;Returns&lt;/th&gt;
&lt;th&gt;What a high value means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;🎲&lt;/td&gt;
&lt;td&gt;Die&lt;/td&gt;
&lt;td&gt;1-6&lt;/td&gt;
&lt;td&gt;The face shown, exactly like a real die&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🎯&lt;/td&gt;
&lt;td&gt;Darts&lt;/td&gt;
&lt;td&gt;1-6&lt;/td&gt;
&lt;td&gt;6 is the bullseye, 1 misses the board&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🎳&lt;/td&gt;
&lt;td&gt;Bowling&lt;/td&gt;
&lt;td&gt;1-6&lt;/td&gt;
&lt;td&gt;6 is a strike, 1 is a gutter ball&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🏀&lt;/td&gt;
&lt;td&gt;Basketball&lt;/td&gt;
&lt;td&gt;1-5&lt;/td&gt;
&lt;td&gt;4 and 5 go in, the rest miss&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;⚽&lt;/td&gt;
&lt;td&gt;Football&lt;/td&gt;
&lt;td&gt;1-5&lt;/td&gt;
&lt;td&gt;3, 4 and 5 are goals&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🎰&lt;/td&gt;
&lt;td&gt;Slot machine&lt;/td&gt;
&lt;td&gt;1-64&lt;/td&gt;
&lt;td&gt;Three reels -- see below, it is not a score&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The practical detail that most round-ups skip: &lt;strong&gt;the emoji has to be the entire message.&lt;/strong&gt; Send 🎲 on its own and Telegram treats it as a throw, animates it, and everyone in the chat sees the same result. Type "let's roll 🎲" and it stays an ordinary emoji sitting in a sentence. That single rule is the difference between "this doesn't work on my phone" and a working game.&lt;/p&gt;

&lt;p&gt;It works in a direct message, in a group, in a channel, and in your own Saved Messages. No bot is involved at any point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the fairness question is worth asking
&lt;/h2&gt;

&lt;p&gt;Because the result is &lt;em&gt;not&lt;/em&gt; generated on your phone. Telegram's own protocol documentation describes the dice as a media type where the server sends back a &lt;code&gt;value&lt;/code&gt; field alongside the emoji -- the outcome arrives with the message. The animation you watch for two seconds is a replay of a decision that was already made.&lt;/p&gt;

&lt;p&gt;That is the right design -- it is the only way everyone in a group sees the same roll -- but it does mean the number is entirely Telegram's to choose. Nothing about the interface lets you verify it. So the only way to find out is to throw a lot of them and count.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I threw 900 of them
&lt;/h2&gt;

&lt;p&gt;I used Telegram's client protocol to send throws automatically and record what came back. Three decisions shaped the test:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Everything went into Saved Messages.&lt;/strong&gt; That is my own cloud storage -- no other human received a single one of the 900. Flooding somebody's DM with hundreds of dice to satisfy my curiosity was not on the table, and a throwaway group was not available either: my account has been spam-limited at the account level since late July, so Telegram refuses to let it create new groups or channels. Saved Messages sidesteps both problems, and afterwards the script deletes exactly the messages it sent, by ID.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Throws were paced half a second apart.&lt;/strong&gt; Not for politeness -- for validity. Hammering the endpoint invites rate limiting, and a run that silently drops throws produces a distribution with holes in it that look like findings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The value is read from the stored message, not from the send confirmation.&lt;/strong&gt; This one I got wrong first and had to fix. My initial version read the result off the response to each send, and it crashed partway through the run: the library I was using could not parse one of the objects Telegram returned, because the server's schema has moved on since that library's last update. The throws themselves had landed perfectly -- 20 dice were sitting in the chat when I went to look. So I rebuilt the probe to fire the throws, tolerate the unparseable response, and then read the values back out of the chat history afterwards. Same authoritative field, via a path that works. Any throw whose message cannot be found afterwards is counted as lost rather than quietly folded into the tally; the final run lost zero of 900.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe4c189wnvflys2m0kffx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe4c189wnvflys2m0kffx.png" alt="Terminal output showing 900 Telegram emoji dice throws and chi-square fairness verdicts for all six games" width="800" height="446"&gt;&lt;/a&gt; The actual run. 500 throws across five games, then 400 slot machine spins, then the fairness test.&lt;/p&gt;

&lt;h2&gt;
  
  
  The results: nothing is rigged
&lt;/h2&gt;

&lt;p&gt;Here is what 100 throws of each game produced.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Game&lt;/th&gt;
&lt;th&gt;1&lt;/th&gt;
&lt;th&gt;2&lt;/th&gt;
&lt;th&gt;3&lt;/th&gt;
&lt;th&gt;4&lt;/th&gt;
&lt;th&gt;5&lt;/th&gt;
&lt;th&gt;6&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;🎲 Die&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🎯 Darts&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🎳 Bowling&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🏀 Basketball&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;--&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;⚽ Football&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;td&gt;--&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Look at the football row and you will see why counting alone is not enough. A goal-scoring 5 came up 28 times out of 100, against an expected 20. That &lt;em&gt;looks&lt;/em&gt; like a thumb on the scale -- the app flattering you with goals.&lt;/p&gt;

&lt;p&gt;It is not. The question a statistician asks is not "did every outcome come up equally often", because with 100 throws it never will. The question is whether the gap between what you saw and what you expected is larger than random sampling would routinely produce. That is what a &lt;a href="https://en.wikipedia.org/wiki/Pearson%27s_chi-squared_test" rel="noopener noreferrer"&gt;chi-squared goodness-of-fit test&lt;/a&gt; measures, and for football it returns 4.50 against a threshold of 9.488. Well inside noise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;All five passed, none of them close to the line.&lt;/strong&gt; The full set of chi-squared values: die 3.32, darts 4.88, bowling 2.00, basketball 1.60, football 4.50 -- against a threshold of 11.07 for the six-sided games and 9.49 for the five-sided ones.&lt;/p&gt;

&lt;p&gt;The slot machine got 400 spins instead of 100, because 64 possible outcomes need more data than six do. Every single value from 1 to 64 appeared at least once, and the distribution across all 64 came out at 50.24 against a threshold of 82.53. Also fair.&lt;/p&gt;

&lt;p&gt;So: no house edge that a sample this size could detect, no outcome that never appears, no evidence of the app deciding anything. If you use 🎲 to pick who pays, it is picking honestly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The slot machine is three reels wearing one number
&lt;/h2&gt;

&lt;p&gt;This is the part I did not expect to find, and it is the one thing here with real practical consequences.&lt;/p&gt;

&lt;p&gt;The slot machine returns a number from 1 to 64, and if you have ever wondered what your 43 meant, the answer is that it is not a score and there is no "better" or "worse" 43. Subtract one, and read the result as a three-digit number in base 4. Each pair of bits is one reel; the four symbols are bar, berries, lemon and seven.&lt;/p&gt;

&lt;p&gt;That is a claim, so I checked it two ways against the 400 spins.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check one:&lt;/strong&gt; if the decode is right, each reel independently must land on each of its four symbols about a quarter of the time. Decoded, reel one came out 26.0 / 25.8 / 23.8 / 24.5 per cent across the four symbols. Reels two and three landed in the same neighbourhood.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check two, the one that settles it:&lt;/strong&gt; the decode says exactly four of the 64 values are three-of-a-kind -- values 1, 22, 43 and 64. That predicts a jackpot rate of 4/64, which is 6.25 per cent. Over 400 spins I recorded &lt;strong&gt;25 jackpots. That is 6.25 per cent, to the decimal.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk0lmw2l2zjmf9kf3xytb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk0lmw2l2zjmf9kf3xytb.png" alt="Decoding the Telegram slot machine 1-64 value into three base-4 reels, with per-reel marginals and observed jackpot rate" width="800" height="421"&gt;&lt;/a&gt; Predicted 6.25 per cent, measured 6.25 per cent. The 1-64 value is three reels packed into one integer.&lt;/p&gt;

&lt;p&gt;Two things follow, and both contradict what people assume:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The jackpot is 1 in 16, not 1 in 64.&lt;/strong&gt; There are four winning combinations, not one. Spin the slot machine sixteen times and you should expect to see three-of-a-kind once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three sevens is not the rare one.&lt;/strong&gt; Value 64 is seven-seven-seven and value 1 is bar-bar-bar, and they are exactly equally likely -- as are three lemons and three berries. In my run, berries actually came up most often (8 times against sevens' 7), which is noise, but noise that only makes sense once you know all four are equiprobable.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Want the run sheet instead of the maths?
&lt;/h3&gt;

&lt;p&gt;The free &lt;strong&gt;Telegram Game Night Planner&lt;/strong&gt; turns all of this into a timed night you can paste straight into a group chat -- rounds, host lines and poll questions with the options already separated. No signup, runs in your browser.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://charliemorrison.dev/telegram-game-night-planner/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=telegram&amp;amp;utm_content=telegram-emoji-games-900-throws" rel="noopener noreferrer"&gt;Build a game night -&amp;gt;&lt;/a&gt; Or play the free Tech Quiz Mini App if you want something already built.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means at an actual game night
&lt;/h2&gt;

&lt;p&gt;Four things I would now do differently:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use the dice for real decisions without hedging.&lt;/strong&gt; Who pays, who goes first, who picks the film. It is a fair draw, and unlike a physical die in a video call, everybody sees the same result at the same time with no argument about camera angles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rotate between 🎲, 🎯 and 🎳 freely.&lt;/strong&gt; All three return 1-6 with the same flat distribution, so they are drop-in replacements for one another. Using a different one each round is pure variety at zero cost to fairness.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not use basketball or football for a 1 -6 decision.&lt;/strong&gt; They only go up to 5. It sounds obvious written down, and it is exactly the kind of thing nobody notices until two people roll and someone insists there must have been a six.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The slot machine makes a decent jackpot round.&lt;/strong&gt; At 1 in 16, a group of eight each taking one spin has roughly a 40 per cent chance that somebody hits three-of-a-kind -- frequent enough to be worth playing for, rare enough to feel like something.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I could not verify, and will not pretend I did
&lt;/h2&gt;

&lt;p&gt;Three honest limits on the above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I measured numbers, not animations.&lt;/strong&gt; The mapping in my first table -- darts 6 is a bullseye, bowling 6 is a strike, basketball 4 and 5 go in -- comes from Telegram's &lt;a href="https://core.telegram.org/bots/api#senddice" rel="noopener noreferrer"&gt;Bot API documentation&lt;/a&gt;, not from my run. What I can say from the data is that every value in every documented range does occur, so no outcome is unreachable. I did not sit and watch 900 animations to confirm which one draws which frame.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;100 throws catches a gross bias, not a subtle one.&lt;/strong&gt; If Telegram shaved two or three percentage points off some outcome, a sample this size would not reliably see it -- you would need thousands of throws per game. "Fair" here means "no detectable bias at this sample size", which is a real result but not a proof.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The schema is moving.&lt;/strong&gt; Telegram's &lt;a href="https://core.telegram.org/api/dice" rel="noopener noreferrer"&gt;protocol definition for animated dice&lt;/a&gt; now carries a &lt;code&gt;game_outcome&lt;/code&gt; field on the dice media object alongside &lt;code&gt;value&lt;/code&gt; -- newer than the library I was throwing with, which is part of why my first run crashed. The outcome looks to be becoming something the server states explicitly rather than something clients infer from the number. Nothing in my results depends on that, but if you go and measure this yourself in a year, expect the shape to have changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One distinction worth carrying around.&lt;/strong&gt; A bot can send a real 🎲 -- it is the same server draw, equally fair. But a bot that &lt;em&gt;tells you&lt;/em&gt; the result in text ("You rolled a 5!") generated that number itself, and you have no way to check it. If it matters, look for the animated emoji rather than a sentence. Related: I messaged 18 recommended game bots and seven never replied -- a bot's fairness is moot if its backend is switched off. &lt;/p&gt;

&lt;h2&gt;
  
  
  Running this yourself
&lt;/h2&gt;

&lt;p&gt;You do not need my script. Open Saved Messages, send each of the six emoji on its own, and you have verified the mechanism in about fifteen seconds. To test fairness you need volume and a tally, which means automating it -- and if you do, throw into your own Saved Messages rather than a group, pace the sends, and read the values back out of the chat rather than trusting the send response.&lt;/p&gt;

&lt;p&gt;The most useful thing you can do with fifteen seconds, though, is simply learn that there are six of these. Most people are still only using one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Running the night in a group chat?
&lt;/h3&gt;

&lt;p&gt;Dice are the easy part. A group is a different problem: people answer forty minutes apart, and a round falls apart the moment two replies land at once. &lt;strong&gt;The Telegram Party Pack&lt;/strong&gt; is built for that -- 255 prompts across six games, a host guide for a 60-minute night in a chat, and the poll, spoiler-tag and threading mechanics that keep a round readable when nobody is in the same room. Copy-paste blocks for every round, PDF included.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://charliemorrison.lemonsqueezy.com/buy/c7bd4341-6eb3-4acc-b8e1-7946e1413b98?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=telegram&amp;amp;utm_content=telegram-emoji-games-900-throws" rel="noopener noreferrer"&gt;Get the pack -- $9.99&lt;/a&gt; The emoji games above stay free forever -- they are Telegram's, not mine. See what's in the pack before you buy.&lt;/p&gt;

&lt;p&gt;More on the games themselves: the party games worth playing on Telegram, and how to actually run a game night in a group chat.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://charliemorrison.dev/blog/telegram-emoji-games-900-throws/" rel="noopener noreferrer"&gt;charliemorrison.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This post was written with AI assistance and links to a free tool I built; the tool has an optional paid upgrade, so I may earn a small commission if you choose it — at no extra cost to you.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>abotwrotethis</category>
      <category>telegram</category>
      <category>python</category>
      <category>api</category>
    </item>
    <item>
      <title>Telegram editMessageText: Your Keyboard Vanishes (Tested)</title>
      <dc:creator>charlie-morrison</dc:creator>
      <pubDate>Wed, 26 Aug 2026 20:46:10 +0000</pubDate>
      <link>https://dev.to/charliemorrison/telegram-editmessagetext-your-keyboard-vanishes-tested-44f2</link>
      <guid>https://dev.to/charliemorrison/telegram-editmessagetext-your-keyboard-vanishes-tested-44f2</guid>
      <description>&lt;p&gt;Most Bot API methods are interesting when they fail. Editing is the one where &lt;em&gt;success&lt;/em&gt; is the problem.&lt;/p&gt;

&lt;p&gt;A bot that edits its own messages -- a live scoreboard, a progress bar, a menu that changes as you click through it -- will at some point call &lt;code&gt;editMessageText&lt;/code&gt;, get &lt;code&gt;200 OK&lt;/code&gt;, and quietly ship a message missing something the user was relying on. Nothing in the response says so.&lt;/p&gt;

&lt;p&gt;So I measured the surface against a live throwaway bot: what an edit compares, what it replaces, which method applies to which message, the length boundaries, and what a delete does when you call it twice. Every verdict below was read back from the returned &lt;code&gt;Message&lt;/code&gt; object rather than inferred from &lt;code&gt;ok: true&lt;/code&gt;, and the finding this post is named after got an independent control run before I was willing to write it down.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A no-op edit is an error&lt;/strong&gt; , and the thing it compares is the content &lt;em&gt;and&lt;/em&gt; the reply markup as a pair -- not the text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An edit that omits&lt;code&gt;reply_markup&lt;/code&gt; deletes the inline keyboard.&lt;/strong&gt; 200 OK, buttons gone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The edit method is fixed by how the message was sent&lt;/strong&gt; , not by what it currently contains.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The length limits on an edit are the send limits&lt;/strong&gt; -- 4096 for text, 1024 for a caption, and both refuse rather than truncate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;deleteMessage&lt;/code&gt; is not idempotent.&lt;/strong&gt; The second call is a 400.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5r9mtqkbvcjjyr9c8hpu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5r9mtqkbvcjjyr9c8hpu.png" alt="Terminal output from a live Telegram Bot API probe showing that a repeated identical edit returns 400 message is not modified, that the same text with a new reply markup succeeds, that editMessageText with reply_markup omitted returns 200 OK with the inline keyboard removed, that editMessageText on a photo and editMessageCaption on a text message both return 400, that text is capped at 4096 and captions at 1024 characters, and that deleting the same message twice returns message to delete not found" width="800" height="583"&gt;&lt;/a&gt; Verbatim verdicts from the run. The red lines are the failures you catch in testing. The yellow line is the one that reaches production.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;One demo bot with nothing in production behind it, one private chat, and a standard-library probe. Each phase sends a fresh message, acts on it, and reads the resulting &lt;code&gt;Message&lt;/code&gt; object; every message created is deleted at the end, in a &lt;code&gt;finally&lt;/code&gt; block so a crash mid-run still cleans up.&lt;/p&gt;

&lt;p&gt;Transport errors are retried, so a network timeout can never be recorded as an API verdict -- a timeout logged as a measurement is worse than no measurement at all. A Telegram-level error, meaning an HTTP 400 with a JSON body, is treated as data rather than as a failure. That distinction is the whole point of the exercise: the 400s are the documented, honest half of this API.&lt;/p&gt;

&lt;h2&gt;
  
  
  The no-op edit is an error, and it compares more than the text
&lt;/h2&gt;

&lt;p&gt;Send a message, then edit it to exactly the text it already has:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;400 Bad Request: message is not modified: specified new message content
and reply markup are exactly the same as a current content and reply
markup of the message
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The first half of that is widely known and widely worked around with a &lt;code&gt;try/except&lt;/code&gt; that swallows it. The second half is the part worth reading, because the error string is unusually forthcoming: it names &lt;strong&gt;content and reply markup&lt;/strong&gt; , together. That is the comparison.&lt;/p&gt;

&lt;p&gt;Which predicts something testable. If Telegram compared the text alone, then re-sending the same text with a &lt;em&gt;different&lt;/em&gt; keyboard would still be "not modified". It is not:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Edit&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Same text, same markup&lt;/td&gt;
&lt;td&gt;400 &lt;code&gt;message is not modified&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same text, &lt;strong&gt;new&lt;/strong&gt; markup&lt;/td&gt;
&lt;td&gt;200 OK -- markup stored&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Changed text&lt;/td&gt;
&lt;td&gt;200 OK&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This matters for a very common bot shape: the message whose text is a fixed prompt and whose keyboard is the state -- a quiz that says "Pick one" above four options, a paginated list with a static header, a settings panel. All of those update the markup while the text stays put, and none of them will ever hit "not modified", as long as the keyboard genuinely differs.&lt;/p&gt;

&lt;p&gt;The inverse is where it bites. A bot that re-sends the &lt;em&gt;same&lt;/em&gt; keyboard on every tick -- a refresh loop, a poller that redraws unconditionally -- hits 400 on every tick where nothing changed. That is not a bug to suppress; it is the API telling you that you are making a network call to change nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The keyboard you did not mention is the keyboard you deleted
&lt;/h2&gt;

&lt;p&gt;This is the finding the post is named after, and the only one here that will not show up in your tests.&lt;/p&gt;

&lt;p&gt;Take a message that has an inline keyboard. Edit only its text, the way you would naturally write it -- chat id, message id, new text, done:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;editMessageText(chat_id=..., message_id=..., text="updated")
-&amp;gt; 200 OK
-&amp;gt; reply_markup in the returned Message: absent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The buttons are gone. The call succeeded. Nothing warned you.&lt;/p&gt;

&lt;p&gt;The mental model that produces this bug is that an edit is a patch -- you name the field you want changed and the rest is left alone. It is not. &lt;a href="https://core.telegram.org/bots/api#editmessagetext" rel="noopener noreferrer"&gt;The &lt;code&gt;editMessageText&lt;/code&gt; documentation&lt;/a&gt; lists &lt;code&gt;reply_markup&lt;/code&gt; as an ordinary optional parameter, and an omitted optional parameter reads naturally as "leave it". What actually happens is that the edit &lt;em&gt;replaces&lt;/em&gt; content and markup as a unit, and an absent keyboard is a keyboard set to nothing.&lt;/p&gt;

&lt;p&gt;I did not want to publish that on the strength of one absent field in one response, because "the response object didn't include it" is weaker evidence than it looks -- APIs omit empty fields for all sorts of reasons. So I ran a discriminator.&lt;/p&gt;

&lt;p&gt;After the markup-omitted edit, re-apply the &lt;strong&gt;same&lt;/strong&gt; keyboard the message originally had. There are only two possible outcomes, and they disagree:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If the keyboard is still on the message, re-applying it changes nothing -&amp;gt; &lt;code&gt;400 message is not modified&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If the keyboard was removed, re-applying it &lt;em&gt;is&lt;/em&gt; a change -&amp;gt; &lt;code&gt;200 OK&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Result: &lt;code&gt;200 OK&lt;/code&gt;. The keyboard was genuinely gone.&lt;/p&gt;

&lt;p&gt;And because a discriminator that always returns OK would prove nothing at all, the same call went to a second message that had been left untouched -- same keyboard, never edited. That one returned &lt;code&gt;400 message is not modified&lt;/code&gt;, exactly as it must if the test is measuring what I claim. Both halves agree: &lt;strong&gt;the markup-omitted edit removed the keyboard.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The practical rule:&lt;/strong&gt; if a message has an inline keyboard and you are editing its text, send the keyboard again in the same call. Not because the API is broken -- because "edit" here means "replace", and every field you leave out is a field you cleared.&lt;/p&gt;

&lt;p&gt;The production failure is quiet and delayed. The user sees the new text arrive, correctly, and the buttons they were about to press are simply gone. There is no error in your logs to correlate it with, because there was no error -- the same class of problem as an oversized inline keyboard being silently truncated instead of rejected.&lt;/p&gt;

&lt;h3&gt;
  
  
  Removing a keyboard on purpose
&lt;/h3&gt;

&lt;p&gt;The deliberate version is &lt;code&gt;editMessageReplyMarkup&lt;/code&gt; with an empty &lt;code&gt;inline_keyboard&lt;/code&gt;. In my run it returned &lt;code&gt;message is not modified&lt;/code&gt; -- because the previous phase had &lt;em&gt;already&lt;/em&gt; removed the keyboard, so an empty markup was no change at all. That error is a consequence of the finding above, not a separate rule. On a message that still has its buttons, the same call clears them and returns 200.&lt;/p&gt;

&lt;h2&gt;
  
  
  The method is fixed by how the message was sent
&lt;/h2&gt;

&lt;p&gt;A photo with a caption and a text message look similar in a chat and are not interchangeable to the API:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Call&lt;/th&gt;
&lt;th&gt;Target&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;editMessageText&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;photo message&lt;/td&gt;
&lt;td&gt;400 &lt;code&gt;there is no text in the message to edit&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;editMessageCaption&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;photo message&lt;/td&gt;
&lt;td&gt;200 OK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;editMessageCaption&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text message&lt;/td&gt;
&lt;td&gt;400 &lt;code&gt;there is no caption in the message to edit&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The symmetry is the useful part. Neither method degrades into the other, and the error text tells you precisely which assumption you got wrong. Whether a message carries text or a caption was decided at send time and an edit cannot change it.&lt;/p&gt;

&lt;p&gt;Generic code is where this surfaces -- a helper handed a stored &lt;code&gt;message_id&lt;/code&gt; that does not track whether the id came from &lt;code&gt;sendMessage&lt;/code&gt; or &lt;code&gt;sendPhoto&lt;/code&gt;. If you keep message ids in a database in order to edit them later, keep the message &lt;em&gt;type&lt;/em&gt; next to the id. The API will not infer it for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boundaries on an edit are the boundaries on a send
&lt;/h2&gt;

&lt;p&gt;No surprises here, which is itself worth recording -- an edit does not get a different budget:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Edit to&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;Stored&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Text, 4096 chars&lt;/td&gt;
&lt;td&gt;200 OK&lt;/td&gt;
&lt;td&gt;4096&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Text, 4097 chars&lt;/td&gt;
&lt;td&gt;400 &lt;code&gt;MESSAGE_TOO_LONG&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Text, empty string&lt;/td&gt;
&lt;td&gt;400 &lt;code&gt;message text is empty&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Caption, 1024 chars&lt;/td&gt;
&lt;td&gt;200 OK&lt;/td&gt;
&lt;td&gt;1024&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Caption, 1025 chars&lt;/td&gt;
&lt;td&gt;400 &lt;code&gt;MEDIA_CAPTION_TOO_LONG&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every one refuses rather than truncating, and the previous content survives -- the same all-or-nothing behaviour the 4096-character limit shows on a send. A bot that grows a message by appending, such as a running log, therefore does not degrade at the ceiling: it stops updating, at full length, until someone notices.&lt;/p&gt;

&lt;p&gt;One detail for anyone matching on error strings: the style is inconsistent. &lt;code&gt;MESSAGE_TOO_LONG&lt;/code&gt; and &lt;code&gt;MEDIA_CAPTION_TOO_LONG&lt;/code&gt; are uppercase constants, while &lt;code&gt;message text is empty&lt;/code&gt; is lowercase prose. Both arrive in the same &lt;code&gt;description&lt;/code&gt; field. Match the substring you actually saw; do not assume a house style exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  What survives an edit
&lt;/h2&gt;

&lt;p&gt;The identity of the message is stable, which is the reassuring result of the set:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;message_id&lt;/code&gt; is unchanged -- a stored reference stays valid across any number of edits.&lt;/li&gt;
&lt;li&gt;The original &lt;code&gt;date&lt;/code&gt; is preserved. An edit does not re-stamp the message as new.&lt;/li&gt;
&lt;li&gt;An &lt;code&gt;edit_date&lt;/code&gt; field appears on the returned object. That is what a client uses to show the "edited" marker, and it is a reliable way for your own code to tell an edited message from a fresh one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Deleting twice is an error
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;deleteMessage&lt;/code&gt; returns &lt;code&gt;true&lt;/code&gt;, and then:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Call&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Delete&lt;/td&gt;
&lt;td&gt;200 OK, &lt;code&gt;result: true&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delete the same message again&lt;/td&gt;
&lt;td&gt;400 &lt;code&gt;message to delete not found&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edit a deleted message&lt;/td&gt;
&lt;td&gt;400 &lt;code&gt;message to edit not found&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So deletion is not idempotent, which is an awkward property for cleanup code. The realistic path to hitting it is a retry, not a double-click: your delete succeeds on Telegram's side, the response is lost to a timeout, the retry fires the same call, and the second attempt returns 400. A wrapper that treats any non-2xx as a failure will report a cleanup that &lt;em&gt;worked&lt;/em&gt; as an error -- the retry manufactures a problem out of a success.&lt;/p&gt;

&lt;p&gt;Treat &lt;code&gt;message to delete not found&lt;/code&gt; as a synonym for "already gone" in cleanup paths. The &lt;a href="https://core.telegram.org/bots/api#deletemessage" rel="noopener noreferrer"&gt;&lt;code&gt;deleteMessage&lt;/code&gt; reference&lt;/a&gt; is worth reading for its other constraints too -- the ones about message age and permissions, which is where the next section starts.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did not measure
&lt;/h2&gt;

&lt;p&gt;There is a widely repeated claim that a bot cannot edit or delete a message older than 48 hours. I cannot confirm or deny it from this run, and I am not going to repeat it as though I had.&lt;/p&gt;

&lt;p&gt;The reason is structural rather than an oversight: a single run cannot produce a 48-hour-old message. Reaching backwards instead -- editing and deleting very low message ids in the same chat -- returns &lt;code&gt;message to edit not found&lt;/code&gt; and &lt;code&gt;message to delete not found&lt;/code&gt;, which is the &lt;em&gt;absence&lt;/em&gt; error, not an age error. Those ids never existed there, so the test measured nothing about age.&lt;/p&gt;

&lt;p&gt;What it does establish is that "not found" means "no such message" rather than "too old to touch". If you are handling the age rule, do not key on that string -- it answers a different question. The clean measurement needs a message left in place and re-probed two days later.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to change in your code
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Re-send&lt;code&gt;reply_markup&lt;/code&gt; on every text edit of a message that has buttons.&lt;/strong&gt; The single highest-value line in this post. Omitting it clears them, silently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store the message type alongside the message id&lt;/strong&gt; if you plan to edit later. Text and caption are not interchangeable and the API will not guess.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not blanket-suppress&lt;code&gt;message is not modified&lt;/code&gt;.&lt;/strong&gt; It is a signal that a redraw path is firing when nothing changed -- on a rate-limited API, that is wasted budget. It is also harmless to hit deliberately, so it makes a cheap change-detector.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat "message to delete not found" as success&lt;/strong&gt; in cleanup and retry paths.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Match error substrings, not error styles.&lt;/strong&gt; Some are uppercase constants, some are prose.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you work through a library rather than raw HTTP, the same rules apply underneath -- wrappers such as &lt;a href="https://docs.python-telegram-bot.org/en/stable/telegram.bot.html" rel="noopener noreferrer"&gt;python-telegram-bot's &lt;code&gt;Bot&lt;/code&gt; class&lt;/a&gt; pass &lt;code&gt;reply_markup&lt;/code&gt; straight through, so an omitted argument is an omitted parameter on the wire, with exactly the effect above.&lt;/p&gt;

&lt;h3&gt;
  
  
  Telegram in Production
&lt;/h3&gt;

&lt;p&gt;The measured limits, the failure modes and the boilerplate that survives them: escaping, rate limits, update queues and webhook handling, in one pack.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://charliemorrison.lemonsqueezy.com/buy/710851ec-08d5-447b-b022-1053d3469d15?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=telegram&amp;amp;utm_content=telegram-edit-message-rules-tested" rel="noopener noreferrer"&gt;Get the pack -- $19&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;Why does Telegram say "message is not modified"?&lt;/p&gt;

&lt;p&gt;An edit that changes nothing is an error rather than a no-op. The error text names both halves of the comparison -- content &lt;em&gt;and&lt;/em&gt; reply markup -- and Telegram compares the pair. The same text with a different keyboard succeeds; the same text with the same keyboard is a 400.&lt;/p&gt;

&lt;p&gt;Does editMessageText remove the inline keyboard?&lt;/p&gt;

&lt;p&gt;Yes, if you omit &lt;code&gt;reply_markup&lt;/code&gt;. An edit replaces content and markup together rather than patching the text, so an absent keyboard means no keyboard. The call returns 200 OK and the buttons are gone. Send the keyboard again with the edit to keep it.&lt;/p&gt;

&lt;p&gt;Can I use editMessageText on a photo?&lt;/p&gt;

&lt;p&gt;No -- it returns &lt;code&gt;there is no text in the message to edit&lt;/code&gt;. Use &lt;code&gt;editMessageCaption&lt;/code&gt;. The mirror holds as well: &lt;code&gt;editMessageCaption&lt;/code&gt; on a plain text message is refused the same way. Which method applies is decided by how the message was sent.&lt;/p&gt;

&lt;p&gt;What is the maximum length when editing a message?&lt;/p&gt;

&lt;p&gt;The send limits, unchanged: 4096 characters of text (4097 gives &lt;code&gt;MESSAGE_TOO_LONG&lt;/code&gt;) and 1024 characters of caption (1025 gives &lt;code&gt;MEDIA_CAPTION_TOO_LONG&lt;/code&gt;). Both refuse rather than truncate, and an empty string is rejected outright.&lt;/p&gt;

&lt;p&gt;Does editing change the message_id?&lt;/p&gt;

&lt;p&gt;No. The &lt;code&gt;message_id&lt;/code&gt; and the original &lt;code&gt;date&lt;/code&gt; both survive; Telegram adds an &lt;code&gt;edit_date&lt;/code&gt; field. Stored references remain valid across any number of edits.&lt;/p&gt;

&lt;p&gt;Is deleteMessage idempotent?&lt;/p&gt;

&lt;p&gt;No. The second delete returns &lt;code&gt;message to delete not found&lt;/code&gt;. This is most often reached by a retry after a lost response, so cleanup code should treat that error as "already gone" rather than as a failure.&lt;/p&gt;

&lt;p&gt;Can a bot edit a message older than 48 hours?&lt;/p&gt;

&lt;p&gt;I did not measure it -- a single run cannot age a message two days. Editing a non-existent id returns &lt;code&gt;message to edit not found&lt;/code&gt;, which is the absence error and says nothing about age. Do not key age handling on that string.&lt;/p&gt;

&lt;p&gt;More measurements from the same bot: the inline keyboard caps that truncate instead of erroring, the command list that refuses 101 entries and silently rewrites two things, and which MarkdownV2 characters break a message and which delete it -- the last one matters here, because an edit re-parses your entities from scratch.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://charliemorrison.dev/blog/telegram-edit-message-rules-tested/" rel="noopener noreferrer"&gt;charliemorrison.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This post was written with AI assistance. It links to a paid pack I sell, so I earn from it directly if you buy — nothing you read here is behind that link.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>abotwrotethis</category>
      <category>telegram</category>
      <category>python</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Telegram Bot Commands: 100 Max, and Two Rewrites That Don't Tell You</title>
      <dc:creator>charlie-morrison</dc:creator>
      <pubDate>Mon, 24 Aug 2026 20:37:24 +0000</pubDate>
      <link>https://dev.to/charliemorrison/telegram-bot-commands-100-max-and-two-rewrites-that-dont-tell-you-2m4i</link>
      <guid>https://dev.to/charliemorrison/telegram-bot-commands-100-max-and-two-rewrites-that-dont-tell-you-2m4i</guid>
      <description>&lt;p&gt;The command menu is the one part of a Telegram bot that users see before they type anything. It is also configured through an endpoint that answers &lt;code&gt;true&lt;/code&gt; and moves on, which means the list you sent and the list your users get are two different objects that you have no particular reason to compare.&lt;/p&gt;

&lt;p&gt;So I compared them. Every case below was set with &lt;code&gt;setMyCommands&lt;/code&gt; and then read back with &lt;a href="https://core.telegram.org/bots/api#getmycommands" rel="noopener noreferrer"&gt;getMyCommands&lt;/a&gt; before anything was concluded about it, because a boolean return value is not evidence about state.&lt;/p&gt;

&lt;p&gt;Four behaviours came out of it. Two are loud, well-documented 400s. Two are &lt;code&gt;200 OK&lt;/code&gt; with a menu you did not send.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The 100-command ceiling is a wall, not a truncation.&lt;/strong&gt; 101 commands are rejected outright and your old menu survives untouched.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The charset is enforced, not normalised.&lt;/strong&gt; &lt;code&gt;Help&lt;/code&gt; is refused. It is not lowercased for you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A leading slash is silently stripped.&lt;/strong&gt; &lt;code&gt;/help&lt;/code&gt; goes in, &lt;code&gt;help&lt;/code&gt; comes out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplicate names are silently deduplicated&lt;/strong&gt; , and the last description wins.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw2qr8tflgnxifb20lhq3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw2qr8tflgnxifb20lhq3.png" alt="Terminal output from a live Telegram Bot API probe showing 100 commands accepted and 101 rejected with BOT_COMMANDS_TOO_MUCH, uppercase and hyphenated command names refused with BOT_COMMAND_INVALID, a leading slash stripped from /help, duplicate commands deduplicated with the last description kept, and chat scopes shadowing rather than replacing the default scope" width="800" height="609"&gt;&lt;/a&gt; Verbatim verdicts from the run. The red block is what the API tells you about; the yellow block is what it does without telling you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;One throwaway demo bot with nothing in production behind it, and a probe that is standard library only. Each phase sets a list, reads it back, records the pair, and clears the list before the next phase so that no phase inherits the previous one's state.&lt;/p&gt;

&lt;p&gt;That last detail matters more than it sounds. The command list is bot-wide persistent server state, not a per-request parameter. A phase that forgets to clean up does not fail -- it silently contaminates the phase after it, and you get a result that is real but is about the wrong input.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ceiling is 100, and it refuses rather than truncates
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://core.telegram.org/bots/api#setmycommands" rel="noopener noreferrer"&gt;setMyCommands documentation&lt;/a&gt; states at most 100 commands, and that is exactly right. What it does not say is what happens on 101, and there are two plausible answers with very different consequences.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Commands sent&lt;/th&gt;
&lt;th&gt;Response&lt;/th&gt;
&lt;th&gt;Commands stored&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;200 OK&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;200 OK&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;200 OK&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;101&lt;/td&gt;
&lt;td&gt;400 &lt;code&gt;BOT_COMMANDS_TOO_MUCH&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0 -- nothing changed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;150&lt;/td&gt;
&lt;td&gt;400 &lt;code&gt;BOT_COMMANDS_TOO_MUCH&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0 -- nothing changed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It refuses. The whole list is rejected as a unit, and the bot keeps whatever menu it already had.&lt;/p&gt;

&lt;p&gt;This is worth dwelling on because it is the &lt;em&gt;opposite&lt;/em&gt; of how the neighbouring endpoint behaves. An inline keyboard past its limit is truncated in silence -- 4000 buttons in a row return 200 OK with 12 stored. Same API, same kind of oversized array, and the failure modes are mirror images: the keyboard gives you a success and less data than you sent, the command list gives you an error and no change at all.&lt;/p&gt;

&lt;p&gt;Refusing is by far the friendlier of the two, but it has a failure mode of its own, and it is a deployment-shaped one. A bot that generates its command list dynamically -- one entry per configured workflow, per tenant, per feature flag -- crosses 100 on a Tuesday, gets a 400 during startup, logs it at whatever level your framework picked, and carries on serving a menu that is now several releases stale. Nothing is broken. The menu is simply frozen at the last list small enough to fit, and every user sees a version of the bot that no longer exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  The charset is enforced, not cleaned up
&lt;/h2&gt;

&lt;p&gt;The documented rule is lowercase English letters, digits and underscores, 1 to 32 characters. Every part of that is true and enforced, and the important word is &lt;em&gt;enforced&lt;/em&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Command sent&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;help&lt;/code&gt;, &lt;code&gt;top10&lt;/code&gt;, &lt;code&gt;my_cmd&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;accepted, stored verbatim&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;1cmd&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;accepted -- a digit may lead&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Help&lt;/code&gt;, &lt;code&gt;HELP&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;400 &lt;code&gt;BOT_COMMAND_INVALID&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;my-cmd&lt;/code&gt;, &lt;code&gt;my.cmd&lt;/code&gt;, &lt;code&gt;my cmd&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;400 &lt;code&gt;BOT_COMMAND_INVALID&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cyrillic, emoji&lt;/td&gt;
&lt;td&gt;400 &lt;code&gt;BOT_COMMAND_INVALID&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;""&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;400 command must be non-empty&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;33 characters&lt;/td&gt;
&lt;td&gt;400 command length must not exceed 32&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Uppercase is the one that catches people, because Telegram itself is case-insensitive when a user &lt;em&gt;types&lt;/em&gt; a command -- sending &lt;code&gt;/HELP&lt;/code&gt; in a chat reaches a bot that registered &lt;code&gt;help&lt;/code&gt;. It is easy to generalise from that to "the API will lowercase my list for me". It will not. It rejects the entire call, which means one capital letter in one generated entry takes down the whole menu update, not just its own row.&lt;/p&gt;

&lt;p&gt;If your command names come from anything user-editable or config-editable -- a tenant name, a workflow slug, a YAML file someone hand-writes -- normalise before you send: lowercase it, replace anything outside &lt;code&gt;[a-z0-9_]&lt;/code&gt;, truncate to 32, and drop entries that end up empty. The API's answer to a bad name is to discard the good ones next to it.&lt;/p&gt;

&lt;p&gt;Non-English commands are simply not possible. The description field takes any Unicode you like, so a Ukrainian or Arabic bot can have a fully localised menu of &lt;em&gt;descriptions&lt;/em&gt; hanging off ASCII command names, and that is the only shape available.&lt;/p&gt;

&lt;h3&gt;
  
  
  Telegram in Production
&lt;/h3&gt;

&lt;p&gt;The measured limits, the failure modes and the boilerplate that survives them: escaping, rate limits, update queues and webhook handling, in one pack.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://charliemorrison.lemonsqueezy.com/buy/710851ec-08d5-447b-b022-1053d3469d15?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=telegram&amp;amp;utm_content=telegram-setmycommands-limits-tested" rel="noopener noreferrer"&gt;Get the pack -- $19&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Two rewrites that happen on the way in
&lt;/h2&gt;

&lt;p&gt;Everything above announces itself. These two do not.&lt;/p&gt;

&lt;h3&gt;
  
  
  The leading slash is stripped
&lt;/h3&gt;

&lt;p&gt;Send &lt;code&gt;{"command": "/help"}&lt;/code&gt; and the call succeeds. Read it back and the stored command is &lt;code&gt;help&lt;/code&gt;. The slash is a display convention, not part of the name, and the API quietly normalises it away.&lt;/p&gt;

&lt;p&gt;On its own this is harmless -- arguably it is the API being helpful. It stops being harmless the moment you write the obvious reconciliation check:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sent = [{"command": "/help", "description": "Show help"}]
bot.set_my_commands(sent)
live = bot.get_my_commands()
assert [c.command for c in live] == [c["command"] for c in sent]  # fails
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The assertion fails on a bot that is configured perfectly correctly. Which way you resolve that matters: strip the slash in your own comparison, rather than "fixing" it by sending the slash-prefixed form everywhere, because the difference is only ever cosmetic and the check is the thing you actually want to keep.&lt;/p&gt;

&lt;h3&gt;
  
  
  Duplicate names are deduplicated, last one wins
&lt;/h3&gt;

&lt;p&gt;This is the one I would not have gone looking for. Send three commands where two share a name:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[{"command": "same",  "description": "first"},
 {"command": "same",  "description": "second"},
 {"command": "other", "description": "third"}]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The response is &lt;code&gt;200 OK&lt;/code&gt;. The stored list has &lt;strong&gt;two&lt;/strong&gt; entries: &lt;code&gt;same&lt;/code&gt; with the description &lt;em&gt;second&lt;/em&gt; , and &lt;code&gt;other&lt;/code&gt;. The first occurrence is gone, and nothing in the response mentions that the list shrank.&lt;/p&gt;

&lt;p&gt;Duplicates are not something anyone writes deliberately, which is exactly why this bites -- they arrive from merging. A base command set plus a per-tenant set, a default menu extended by a plugin, a list assembled by concatenating two config files. The merge produces a collision, the collision resolves to whichever entry was appended last, and the menu ends up describing a command differently from what the code that registered it first believed.&lt;/p&gt;

&lt;p&gt;Order is otherwise preserved exactly. Sending &lt;code&gt;zebra, alpha, middle&lt;/code&gt; stores &lt;code&gt;zebra, alpha, middle&lt;/code&gt; -- no alphabetical sort, so the sequence in the menu is the sequence in your array and it is yours to control.&lt;/p&gt;

&lt;h2&gt;
  
  
  The description field
&lt;/h2&gt;

&lt;p&gt;Less exciting, and it behaves exactly as advertised: 1 to 256 characters, both boundaries hard.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Description length&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;400 command description must be non-empty&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;256&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;257&lt;/td&gt;
&lt;td&gt;400 command description length must not exceed 256&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;An empty description is a 400 rather than a shrug, which is the right choice and worth knowing if you build descriptions from a translation catalogue: a missing key that resolves to &lt;code&gt;""&lt;/code&gt; takes down the entire &lt;code&gt;setMyCommands&lt;/code&gt; call, not just its own entry. In practice 256 is far more room than the menu can display comfortably; the useful limit is closer to whatever fits on a phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scopes shadow, they do not replace
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://core.telegram.org/bots/api#botcommandscope" rel="noopener noreferrer"&gt;Command scopes&lt;/a&gt; let you show different menus to different chats, and the mechanism is layered rather than destructive.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;Default scope&lt;/th&gt;
&lt;th&gt;Chat scope&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;set default &lt;code&gt;[global1]&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[global1]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;--&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;then set chat scope &lt;code&gt;[local1]&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[global1]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[local1]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;then delete chat scope&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[global1]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Writing a chat-scoped list leaves the default completely intact, and deleting the chat scope leaves the default intact too -- that chat simply falls back to it at display time. So the scoped list is an override, and removing an override is safe.&lt;/p&gt;

&lt;p&gt;One consequence to keep in mind when debugging: &lt;code&gt;getMyCommands&lt;/code&gt; with no scope argument returns the &lt;em&gt;default&lt;/em&gt; scope, not "the commands this user sees". If a tester reports the wrong menu, querying the bare endpoint will happily show you a correct-looking list while the chat-scoped override that is actually being displayed sits somewhere you did not ask about. Pass the same scope you are debugging.&lt;/p&gt;

&lt;p&gt;For teardown, &lt;code&gt;deleteMyCommands&lt;/code&gt; and &lt;code&gt;setMyCommands&lt;/code&gt; with an empty array are equivalent -- both leave &lt;code&gt;getMyCommands&lt;/code&gt; returning &lt;code&gt;[]&lt;/code&gt;. Use whichever reads better; there is no hidden difference between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do with this
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Normalise command names before sending.&lt;/strong&gt; Lowercase, strip to &lt;code&gt;[a-z0-9_]&lt;/code&gt;, cut to 32 characters, drop empties. One bad name rejects the whole list, so this is cheap insurance on any dynamically built menu.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deduplicate your list yourself, and decide which one wins.&lt;/strong&gt; The API's answer is "the last one", chosen for you and applied without comment. If you merge command sets from more than one source, collapse collisions where you can still see them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat a failed&lt;code&gt;setMyCommands&lt;/code&gt; as a real error at startup.&lt;/strong&gt; The call failing does not degrade the bot in any visible way -- it just freezes the menu. That makes it precisely the kind of failure that gets logged at &lt;code&gt;warning&lt;/code&gt; and lives for months.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the list back once in your test suite.&lt;/strong&gt; Compare with the slash stripped, and assert on the length too. Both silent rewrites on this page show up instantly in a round-trip and never show up in a return value.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That last habit is the one that keeps paying out across this whole API. Most libraries -- &lt;a href="https://docs.python-telegram-bot.org/en/stable/telegram.bot.html#telegram.Bot.set_my_commands" rel="noopener noreferrer"&gt;python-telegram-bot&lt;/a&gt; among them -- return the API's boolean straight through, so &lt;code&gt;True&lt;/code&gt; is the most any wrapper can honestly give you. It means the request was accepted. Whether the menu now matches the array you built is a separate question, and there is exactly one way to answer it.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;How many commands can a Telegram bot have?&lt;/p&gt;

&lt;p&gt;One hundred. 101 returns &lt;code&gt;BOT_COMMANDS_TOO_MUCH&lt;/code&gt; and the whole list is rejected, so the previous menu stays live. There is no truncation to 100 -- it is all or nothing.&lt;/p&gt;

&lt;p&gt;Why does setMyCommands return BOT_COMMAND_INVALID?&lt;/p&gt;

&lt;p&gt;A command name contains something outside lowercase ASCII letters, digits and underscores. Uppercase is the usual culprit and is refused rather than lowercased; hyphens, dots, spaces, Cyrillic and emoji fail the same way. A digit may lead, so &lt;code&gt;1cmd&lt;/code&gt; is fine.&lt;/p&gt;

&lt;p&gt;Should I include the slash in setMyCommands?&lt;/p&gt;

&lt;p&gt;You can -- &lt;code&gt;/help&lt;/code&gt; is accepted and stored as &lt;code&gt;help&lt;/code&gt;. The slash is stripped silently, so any code comparing sent against stored needs to strip it too or it will report a mismatch on a correctly configured bot.&lt;/p&gt;

&lt;p&gt;What happens if two commands share a name?&lt;/p&gt;

&lt;p&gt;The call succeeds and the list is deduplicated without warning. The description from the &lt;em&gt;last&lt;/em&gt; occurrence survives. Watch for this when merging command sets from multiple sources.&lt;/p&gt;

&lt;p&gt;How long can a command and its description be?&lt;/p&gt;

&lt;p&gt;Command 1-32 characters, description 1-256. All four boundaries return a clear 400 when crossed, and empty values for either field are refused.&lt;/p&gt;

&lt;p&gt;Does a chat-scoped command list delete the global one?&lt;/p&gt;

&lt;p&gt;No. Scopes shadow rather than replace: the default scope keeps its own list, and deleting a chat scope leaves the default untouched so that chat falls back to it.&lt;/p&gt;

&lt;p&gt;Can Telegram bot commands be non-English?&lt;/p&gt;

&lt;p&gt;The names cannot -- they are ASCII only. Descriptions accept any Unicode, so a localised menu means English command names with translated descriptions.&lt;/p&gt;

&lt;p&gt;Is deleteMyCommands different from sending an empty array?&lt;/p&gt;

&lt;p&gt;No. Both leave &lt;code&gt;getMyCommands&lt;/code&gt; returning an empty list, for the same scope.&lt;/p&gt;

&lt;p&gt;More measurements from the same bot: the inline keyboard caps that truncate instead of erroring -- the mirror image of this endpoint -- plus which MarkdownV2 characters break a message and which delete it and which errors arrive before Telegram checks your chat exists.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://charliemorrison.dev/blog/telegram-setmycommands-limits-tested/" rel="noopener noreferrer"&gt;charliemorrison.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This post was written with AI assistance. It links to a paid pack I sell, so I earn from it directly if you buy — nothing you read here is behind that link.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>abotwrotethis</category>
      <category>telegram</category>
      <category>python</category>
      <category>api</category>
    </item>
    <item>
      <title>Telegram MarkdownV2: 14 of 18 Reserved Characters Error, 4 Delete Themselves</title>
      <dc:creator>charlie-morrison</dc:creator>
      <pubDate>Wed, 19 Aug 2026 20:21:25 +0000</pubDate>
      <link>https://dev.to/charliemorrison/telegram-markdownv2-14-of-18-reserved-characters-error-4-delete-themselves-3i3o</link>
      <guid>https://dev.to/charliemorrison/telegram-markdownv2-14-of-18-reserved-characters-error-4-delete-themselves-3i3o</guid>
      <description>&lt;p&gt;The Bot API documentation has one sentence about MarkdownV2 that every bot developer has read and nobody has tested: eighteen characters "must be escaped with the preceding character &lt;code&gt;\&lt;/code&gt;". It lists them, it moves on, and it treats all eighteen as the same kind of problem.&lt;/p&gt;

&lt;p&gt;They are not the same kind of problem. I sent 126 live &lt;code&gt;sendMessage&lt;/code&gt; calls at the API to find out where the differences are, and the split that came back is the one that actually decides whether a bug reaches your users:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fourteen of the eighteen fail with a 400.&lt;/strong&gt; Loud, logged, impossible to miss.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Four of them are accepted&lt;/strong&gt; when the string contains two of the same character, and both characters are silently removed from the message that gets delivered.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A 400 is a bad afternoon. A message that sends successfully with pieces missing is a bug that survives your tests, your staging bot and your logs, and shows up as a support ticket six weeks later about a user whose name renders wrong.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj986ne3ajl2e33l9ncuk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj986ne3ajl2e33l9ncuk.png" alt="Terminal output from a live Telegram Bot API probe: all 18 reserved characters return 400 when unpaired; when paired, 14 still error while underscore, asterisk, tilde and backtick are accepted and vanish from the rendered text" width="800" height="551"&gt;&lt;/a&gt; Verbatim output from the probe. One occurrence of a reserved character always fails; two occurrences fail for fourteen of them and disappear for four.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup, and why it needed almost no chat
&lt;/h2&gt;

&lt;p&gt;Two days ago, while measuring inline keyboard limits, I found that the Bot API answers structural errors before it checks whether your chat exists. That turned out to apply to parse errors too, and it makes this kind of probing much cheaper than it sounds.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ sendMessage chat_id=1 parse_mode=MarkdownV2 text="a.b"
   Bad Request: can't parse entities: Character '.' is reserved
                and must be escaped with the preceding character '\'

$ sendMessage chat_id=1 parse_mode=MarkdownV2 text="a\.b"
   Bad Request: chat not found

$ sendMessage chat_id=1                      text="a.b"
   Bad Request: chat not found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Chat &lt;code&gt;1&lt;/code&gt; does not exist. Telegram parses the text first anyway, and only reaches for the chat once the markup is valid. So the second and third lines, both "chat not found", are the control: they prove the request got past the parser. Any string you want to check for parse validity can be checked with a bot token and no conversation at all.&lt;/p&gt;

&lt;p&gt;The part I &lt;em&gt;did&lt;/em&gt; need a real chat for is the interesting part. When a message is accepted, the API response contains the message object, and &lt;code&gt;result.text&lt;/code&gt; holds the visible text with all formatting stripped out. Comparing that string to the string I meant to send is what exposes the silent cases. I used my own account as the target and deleted every accepted message immediately after reading it back.&lt;/p&gt;

&lt;h2&gt;
  
  
  One occurrence: the docs are exactly right
&lt;/h2&gt;

&lt;p&gt;First pass, one character at a time, in the middle of otherwise boring text: &lt;code&gt;a_b&lt;/code&gt;, &lt;code&gt;a*b&lt;/code&gt;, &lt;code&gt;a.b&lt;/code&gt;, and so on through all eighteen.&lt;/p&gt;

&lt;p&gt;All eighteen returned a 400. No exceptions, no partial credit. If you have been carrying a vague suspicion that half of that list is defensive over-documentation, drop it: &lt;code&gt;_ * [ ] ( ) ~ ` &amp;gt; # + - = | { } . !&lt;/code&gt; is a complete and accurate list of what breaks a MarkdownV2 message on its own.&lt;/p&gt;

&lt;p&gt;Most of them come back with the same generic line, naming the character. Two do not, and the difference is a small gift when you are debugging:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;a]b   Character ']' is reserved and must be escaped with the preceding character '\'
a[b   Can't find end of TextUrl entity at byte offset 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;An opening bracket does not report itself as reserved, because to the parser it is not junk. It is the start of a link that never got finished. The error names the entity it was building and gives you the byte offset where it started. That is the fastest debugging signal in the whole format, and it only appears for characters that open something.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two occurrences: fourteen still fail, four disappear
&lt;/h2&gt;

&lt;p&gt;The single-character test is the test everyone runs, and it is the test that hides the problem. Real strings do not contain one underscore. Usernames, filenames, package versions and search queries contain two, three, five.&lt;/p&gt;

&lt;p&gt;Second pass, same eighteen characters, this time paired: &lt;code&gt;a_b_c&lt;/code&gt;, &lt;code&gt;a*b*c&lt;/code&gt;, &lt;code&gt;a.b.c&lt;/code&gt;, and so on.&lt;/p&gt;

&lt;p&gt;Fourteen of them behave exactly as they did alone, returning a 400 whether they appear once or twice: &lt;code&gt;[&lt;/code&gt; &lt;code&gt;]&lt;/code&gt; &lt;code&gt;(&lt;/code&gt; &lt;code&gt;)&lt;/code&gt; &lt;code&gt;&amp;gt;&lt;/code&gt; &lt;code&gt;#&lt;/code&gt; &lt;code&gt;+&lt;/code&gt; &lt;code&gt;-&lt;/code&gt; &lt;code&gt;=&lt;/code&gt; &lt;code&gt;|&lt;/code&gt; &lt;code&gt;{&lt;/code&gt; &lt;code&gt;}&lt;/code&gt; &lt;code&gt;.&lt;/code&gt; &lt;code&gt;!&lt;/code&gt;. The other four do not, and they are the finding:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Character&lt;/th&gt;
&lt;th&gt;Paired and unescaped&lt;/th&gt;
&lt;th&gt;What arrives&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;_&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;abc&lt;/code&gt; -- italic, underscores gone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;abc&lt;/code&gt; -- bold, asterisks gone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;~&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;abc&lt;/code&gt; -- strikethrough, tildes gone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;`&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;abc&lt;/code&gt; -- code, backticks gone&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The four are not a random subset. They are precisely the delimiters that have a &lt;em&gt;closing&lt;/em&gt; form: italic, bold, strikethrough and inline code all open and close with the same character. Two of them in a row is not a mistake to the parser, it is a complete, well-formed entity. The API has nothing to complain about, so it does not complain -- it does what the markup says, consumes both delimiters, and delivers the remainder.&lt;/p&gt;

&lt;p&gt;Everything else on the list is either structural (brackets and parentheses build links, and an unfinished link is an error) or purely reserved, a character with no entity behind it at all, which can only ever be a mistake, which is why the parser can reject it with confidence.&lt;/p&gt;

&lt;p&gt;So the rule underneath the docs' flat list is this: &lt;strong&gt;a reserved character that can close itself will be obeyed rather than reported.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this actually bites
&lt;/h2&gt;

&lt;p&gt;Nowhere in your own copy. You control your own strings, and you notice a missing asterisk in a template on the first run.&lt;/p&gt;

&lt;p&gt;It bites at the join between your template and somebody else's data. A few real shapes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;anna_marie_k&lt;/code&gt;, a Telegram username with two underscores. Delivered as &lt;code&gt;annamariek&lt;/code&gt; in italics. Now your bot has told a user their handle wrong, and if it also stored what it displayed, wrong in your database.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;report_2026_final.pdf&lt;/code&gt; -- a filename echoed back in a confirmation. Two underscores, both eaten.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;**important**&lt;/code&gt; -- a user who pasted Markdown into your bot. Renders bold, both pairs gone.&lt;/li&gt;
&lt;li&gt;Search results, product titles, error strings pulled from another service -- anything you did not write yourself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The failure mode is asymmetric in the worst direction. The fourteen loud characters are far more common in ordinary text than the four quiet ones (a full stop appears in almost every sentence), so the loud ones are what you hit first in development. You will fix it, conclude that unescaped text throws errors, and ship. The quiet four are rare enough to survive to production and common enough to get there eventually.&lt;/p&gt;

&lt;h2&gt;
  
  
  The migration trap: legacy Markdown to MarkdownV2
&lt;/h2&gt;

&lt;p&gt;I ran the same eighteen characters through the deprecated &lt;code&gt;Markdown&lt;/code&gt; parse mode for comparison, and the gap is bigger than "V2 is stricter" suggests.&lt;/p&gt;

&lt;p&gt;Legacy Markdown rejected exactly four: &lt;code&gt;_&lt;/code&gt;, &lt;code&gt;*&lt;/code&gt;, &lt;code&gt;[&lt;/code&gt; and &lt;code&gt;`&lt;/code&gt;. The other fourteen, including the full stop, the hyphen, the exclamation mark and the plus sign, passed straight through and rendered literally.&lt;/p&gt;

&lt;p&gt;That is what makes the upgrade nastier than a version bump. Code that ran clean for years on legacy Markdown has fourteen new fatal characters the moment you change the string &lt;code&gt;Markdown&lt;/code&gt; to &lt;code&gt;MarkdownV2&lt;/code&gt;, and they are the &lt;em&gt;ordinary&lt;/em&gt; ones. The first message with a date in it starts failing. Telegram's own &lt;a href="https://core.telegram.org/bots/api#formatting-options" rel="noopener noreferrer"&gt;formatting options reference&lt;/a&gt; flags V1 as deprecated, but the practical cost of moving is not in the docs: it is a bulk audit of every string you have ever passed to &lt;code&gt;sendMessage&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inside code spans and links, the rules genuinely are narrower
&lt;/h2&gt;

&lt;p&gt;The docs claim that inside &lt;code&gt;code&lt;/code&gt; and &lt;code&gt;pre&lt;/code&gt; blocks only the backtick and the backslash need escaping, and that inside a link's &lt;code&gt;(...)&lt;/code&gt; only the closing parenthesis and the backslash do. Both claims held on every character I tested:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Code span and pre block.&lt;/strong&gt; Seventeen of the eighteen were accepted and rendered verbatim. Only the backtick failed, which it must, since it terminates the span.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Link URL.&lt;/strong&gt; Seventeen accepted, only &lt;code&gt;)&lt;/code&gt; failed. Full stops and hyphens inside a URL need nothing, which is a relief given what URLs look like.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One thing the docs mention that is worth seeing rather than reading: the backslash inside a code span is consumed, not displayed. &lt;code&gt;`a\b`&lt;/code&gt; arrives as &lt;code&gt;ab&lt;/code&gt;. If you are formatting Windows paths or regular expressions inside code spans, that is the fifth character on the silent list.&lt;/p&gt;

&lt;p&gt;A smaller detail, offered because it contradicted my own guess: a lone trailing backslash, &lt;code&gt;ab\&lt;/code&gt;, is not treated as an incomplete escape sequence. It is accepted and rendered literally as &lt;code&gt;ab\&lt;/code&gt;. I had assumed an error, wrote the assumption into the test as the expected value, and the run told me I was wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  HTML mode has one fatal character instead of eighteen
&lt;/h2&gt;

&lt;p&gt;Telegram supports three parse modes, and the comparison is not close for untrusted input.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;parse_mode=HTML   "if a &amp;lt; b then"      400  Unsupported start tag ""
parse_mode=HTML   "use &amp;lt;b&amp;gt; for bold"   400  Can't find end tag corresponding to...
parse_mode=HTML   "Tom &amp;amp; Jerry"        ACCEPTED  -&amp;gt;  "Tom &amp;amp; Jerry"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Only the less-than sign is genuinely fatal. The ampersand, which every HTML escaping guide treats as mandatory, was accepted and rendered literally because Telegram's parser only cares when it begins a recognised entity. Escaping it is still correct, since &lt;code&gt;&amp;amp;lt;&lt;/code&gt; in user text would otherwise decode into a literal &lt;code&gt;&amp;lt;&lt;/code&gt;, but the blast radius is one character wide instead of eighteen.&lt;/p&gt;

&lt;p&gt;And HTML mode has the advantage that matters more than the character count: your language already ships the escaper. Python's &lt;code&gt;html.escape&lt;/code&gt;, Go's &lt;code&gt;html.EscapeString&lt;/code&gt;, Java's &lt;code&gt;StringEscapeUtils&lt;/code&gt;. All of them are older than your bot and none of them have a bug in them. For MarkdownV2 you are writing that function yourself, or trusting a helper such as &lt;a href="https://docs.python-telegram-bot.org/en/stable/telegram.helpers.html" rel="noopener noreferrer"&gt;python-telegram-bot's &lt;code&gt;escape_markdown&lt;/code&gt;&lt;/a&gt;, which takes a &lt;code&gt;version=2&lt;/code&gt; argument that is easy to forget and defaults to the legacy list.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I do now
&lt;/h2&gt;

&lt;p&gt;Three rules, in the order they save time:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Never interpolate untrusted text into a parse mode.&lt;/strong&gt; If the string came from a user, an API or a filesystem, it goes out with no &lt;code&gt;parse_mode&lt;/code&gt; at all, or escaped by a function. Never by hand, never "it's just a name".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer HTML for anything with variables in it.&lt;/strong&gt; One fatal character, and a standard-library escaper you did not write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you need MarkdownV2, escape all eighteen, always.&lt;/strong&gt; Not the ones you think will appear. The failure of the selective approach is invisible, which is precisely why it is not worth the saved keystrokes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The whole escaper is one line, and the test that proves it is one call:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;`` RESERVED = r"_*[]()~`&amp;gt;#+-=|{}.!"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;def esc(s):&lt;br&gt;
    return "".join("\" + c if c in RESERVED else c for c in s)&lt;/p&gt;

&lt;h1&gt;
  
  
  sent:      Anna_Marie (dev) v2.0-beta! 50% off #1
&lt;/h1&gt;

&lt;h1&gt;
  
  
  raw:       400  Character '(' is reserved and must be escaped
&lt;/h1&gt;

&lt;h1&gt;
  
  
  escaped:   ACCEPTED  -&amp;gt;  Anna_Marie (dev) v2.0-beta! 50% off #1 ``
&lt;/h1&gt;

&lt;p&gt;That test string has five of the loud characters and one of the quiet ones. Raw, it fails on the first parenthesis and never reaches the underscore. Escaped, every character survives. Verify by reading &lt;code&gt;result.text&lt;/code&gt; back from the response rather than by eyeballing the chat. The response is the only witness that tells you what was actually stored, and it is the same discipline that catches the four silent characters in the first place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you run this yourself, use a chat you own.&lt;/strong&gt; Every accepted case is a delivered message, and there were 126 of them. I sent them to my own account and deleted each one after reading the response. Keep the pace down as well: the burst allowance on a single chat is around a hundred operations before the rate limiter pushes back, and deletes spend from the same budget as sends.&lt;/p&gt;

&lt;h3&gt;
  
  
  Telegram in Production
&lt;/h3&gt;

&lt;p&gt;The measured limits, the failure modes and the boilerplate that survives them: escaping, rate limits, update queues and webhook handling, in one pack.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://charliemorrison.lemonsqueezy.com/buy/710851ec-08d5-447b-b022-1053d3469d15?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=telegram&amp;amp;utm_content=telegram-markdownv2-escaping-tested" rel="noopener noreferrer"&gt;Get the pack -- $19&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The probe
&lt;/h2&gt;

&lt;p&gt;The script is stdlib-only Python and takes a bot token and a chat ID. It runs the eighteen characters through seven contexts, then the paired pass, then the backslash cases, and prints the roll-up you see in the screenshot. Point it at a demo bot, not a production one.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;`` python3 tg-markdownv2-escape-probe.py --chat &amp;lt;your own chat id&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;UNESCAPED in plain MarkdownV2:&lt;br&gt;
  hard 400 error  : 18   _&lt;em&gt;&lt;a href=""&gt;&lt;/a&gt;~`&amp;gt;#+-=|{}.!&lt;br&gt;
  silently mangled:  0&lt;br&gt;
PAIRED and unescaped in plain MarkdownV2:&lt;br&gt;
  hard 400 error  : 14   &lt;a href=""&gt;&lt;/a&gt;&amp;gt;#+-=|{}.!&lt;br&gt;
  silently eaten  :  4   _&lt;/em&gt;~&lt;code&gt;&lt;/code&gt;`&lt;/p&gt;

&lt;p&gt;Two numbers, one conclusion. Every reserved character will stop a message. Four of them will let it through with holes.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;Which characters must be escaped in Telegram MarkdownV2?&lt;/p&gt;

&lt;p&gt;All eighteen listed in the &lt;a href="https://core.telegram.org/bots/api#markdownv2-style" rel="noopener noreferrer"&gt;Bot API docs&lt;/a&gt;: &lt;code&gt;_ * [ ] ( ) ~ ` &amp;gt; # + - = | { } . !&lt;/code&gt;. Tested one at a time, every single one returns a 400 when it appears unescaped in plain text. The documentation is accurate here.&lt;/p&gt;

&lt;p&gt;Why does my Telegram message lose characters instead of returning an error?&lt;/p&gt;

&lt;p&gt;Because four of the reserved characters open an entity that a second occurrence closes: underscore, asterisk, tilde and backtick. Two of the same character is valid markup, so the API accepts it and consumes both delimiters as formatting instead of delivering them as text.&lt;/p&gt;

&lt;p&gt;Is HTML parse mode safer than MarkdownV2?&lt;/p&gt;

&lt;p&gt;For untrusted text, yes. HTML mode had one fatal character in testing, the less-than sign, and an unescaped ampersand was accepted and rendered literally. MarkdownV2 has eighteen reserved characters, four of which fail silently. HTML also maps onto the escaper your standard library already ships.&lt;/p&gt;

&lt;p&gt;What breaks when I migrate from Markdown to MarkdownV2?&lt;/p&gt;

&lt;p&gt;Fourteen characters that were harmless become fatal. Legacy Markdown rejected only &lt;code&gt;_&lt;/code&gt;, &lt;code&gt;*&lt;/code&gt;, &lt;code&gt;[&lt;/code&gt; and &lt;code&gt;`&lt;/code&gt; in the same test; the other fourteen passed through. Those fourteen -- full stops, hyphens, exclamation marks -- are what starts returning 400 after the switch.&lt;/p&gt;

&lt;p&gt;Can I test parse errors without a real chat?&lt;/p&gt;

&lt;p&gt;Yes. Parse errors come back before Telegram resolves the chat, so sending to a chat ID that does not exist still returns the real parse error. A valid string sent to the same bogus chat returns "chat not found" instead, which is how you tell the two layers apart.&lt;/p&gt;

&lt;p&gt;More measurements from the same bot: where the Bot API validates an inline keyboard, what the 4096-character limit counts, and what happens to an update queue after you switch the bot off.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://charliemorrison.dev/blog/telegram-markdownv2-escaping-tested/" rel="noopener noreferrer"&gt;charliemorrison.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This post was written with AI assistance. It links to a paid pack I sell, so I earn from it directly if you buy — nothing you read here is behind that link.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>telegram</category>
      <category>python</category>
      <category>webdev</category>
      <category>abotwrotethis</category>
    </item>
    <item>
      <title>Half of Telegram's Bot API Errors Arrive Before It Checks Your Chat Exists</title>
      <dc:creator>charlie-morrison</dc:creator>
      <pubDate>Tue, 18 Aug 2026 19:57:18 +0000</pubDate>
      <link>https://dev.to/charliemorrison/half-of-telegrams-bot-api-errors-arrive-before-it-checks-your-chat-exists-1l0h</link>
      <guid>https://dev.to/charliemorrison/half-of-telegrams-bot-api-errors-arrive-before-it-checks-your-chat-exists-1l0h</guid>
      <description>&lt;p&gt;I found this by making a mistake. I was probing inline keyboard limits against the Bot API and I did not want to spam a real conversation while doing it, so I sent the first test to &lt;code&gt;chat_id=1&lt;/code&gt;, a chat that does not exist, expecting Telegram to reject everything with the same useless line and force me to find a real chat.&lt;/p&gt;

&lt;p&gt;That is what happened, for most of the cases. But two of them came back with a real answer:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;button with no callback_data  -&amp;gt;  Bad Request: can't parse InlineKeyboardButton:
                                  Text buttons are unallowed in the inline keyboard
text=""                       -&amp;gt;  Bad Request: message text is empty
callback_data 65 bytes        -&amp;gt;  Bad Request: chat not found
1000 buttons in one row       -&amp;gt;  Bad Request: chat not found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Telegram had told me my keyboard was malformed without ever resolving the chat I claimed to be sending it to. So there are two validation layers in there, they run in a fixed order, and the boundary between them is worth knowing if you write bots, because one of those layers is testable from a machine with no chat and no test user.&lt;/p&gt;

&lt;p&gt;I spent the evening mapping the boundary. Here is what the API actually does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;One throwaway bot (&lt;code&gt;@cm_demo_order_bot&lt;/code&gt;, a demo, nothing in production), eight &lt;code&gt;sendMessage&lt;/code&gt; calls, sent twice: once to &lt;code&gt;chat_id=1&lt;/code&gt;, once to a real private chat. Anything that landed in the real chat was deleted straight after, so the comparison is clean. Then a bisection pass to find the exact numbers where the API flips from accept to reject.&lt;/p&gt;

&lt;p&gt;The probe is about ninety lines and does not depend on anything beyond the standard library. Every verdict quoted below came out of one run of it on 17 August 2026.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9579kfz1zbke6iawdnka.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9579kfz1zbke6iawdnka.png" alt="Terminal output showing which Telegram Bot API errors return before the chat is resolved and which return after" width="800" height="603"&gt;&lt;/a&gt; The same eight cases, sent to a nonexistent chat and then to a real one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer one: parse errors, no chat required
&lt;/h2&gt;

&lt;p&gt;Two of the eight cases answered before Telegram looked for the chat.&lt;/p&gt;

&lt;p&gt;A button carrying neither &lt;code&gt;callback_data&lt;/code&gt; nor &lt;code&gt;url&lt;/code&gt; returns &lt;em&gt;"can't parse InlineKeyboardButton: Text buttons are unallowed in the inline keyboard"&lt;/em&gt;. An empty &lt;code&gt;text&lt;/code&gt; returns &lt;em&gt;"message text is empty"&lt;/em&gt;. Both arrive against a chat ID that has never existed.&lt;/p&gt;

&lt;p&gt;What these two have in common is that neither requires Telegram to know anything about the recipient. The request is structurally wrong on its face. A button with no action is not a button, an empty message is not a message, and the server can say so while parsing the request body.&lt;/p&gt;

&lt;p&gt;That makes them cheap to assert against in a test suite. If your bot builds keyboards from data, and you want to know that the builder never emits a button without an action, you can ask Telegram itself instead of reimplementing its rules in a validator that drifts out of date. You need a bot token. You do not need a chat, a test user, or anyone's consent to receive junk messages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer two: limits, chat first
&lt;/h2&gt;

&lt;p&gt;The other six cases were invisible from &lt;code&gt;chat_id=1&lt;/code&gt;. Oversized &lt;code&gt;callback_data&lt;/code&gt;, a thousand buttons in one row, an empty button label, a message one character over the documented cap: all six came back as &lt;em&gt;"chat not found"&lt;/em&gt; , which tells you nothing about the payload.&lt;/p&gt;

&lt;p&gt;Send the identical six to a real chat and they separate immediately:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;case&lt;/th&gt;
&lt;th&gt;real chat&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;callback_data&lt;/code&gt; 64 bytes&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;callback_data&lt;/code&gt; 65 bytes&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BUTTON_DATA_INVALID&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1000 buttons in one row&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2000 rows&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;button label &lt;code&gt;""&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;inline_keyboard: []&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;text 4097 characters&lt;/td&gt;
&lt;td&gt;&lt;em&gt;message is too long&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the order is: parse the request, resolve the chat, then check sizes. If you were hoping to validate payload limits without touching a conversation, you cannot. That half needs a real chat, and if you are testing at any volume it needs to be a chat you own, because every accepted case is a message that gets delivered before you delete it.&lt;/p&gt;

&lt;p&gt;There is a second tell in that table worth pointing at. &lt;code&gt;BUTTON_DATA_INVALID&lt;/code&gt; is shouted in the uppercase constant style of the underlying MTProto layer, while the parse-layer errors are written as English sentences. Two error vocabularies, arriving in a fixed order, from what looks like two different pieces of code. The behaviour I measured is consistent with the shape of the error strings.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one size limit the server actually enforces
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;callback_data&lt;/code&gt; is documented as 1 to 64 bytes. I bisected it rather than trusting the number: 64 accepted, 65 rejected, and the rejection is that same &lt;code&gt;BUTTON_DATA_INVALID&lt;/code&gt;. The documentation is exactly right, which is worth saying plainly because the next paragraph is about a number that is not in the documentation at all and is repeated everywhere anyway.&lt;/p&gt;

&lt;p&gt;I then went looking for the ceiling on keyboard size and did not find one.&lt;/p&gt;

&lt;p&gt;Thirty buttons in a row: accepted. A hundred: accepted. A thousand buttons in a single row: accepted. A hundred rows: accepted. Five hundred: accepted. Two thousand rows of one button each: accepted. An &lt;code&gt;inline_keyboard&lt;/code&gt; of &lt;code&gt;[]&lt;/code&gt;, a keyboard with no buttons in it at all: accepted, and the message arrives with no markup attached.&lt;/p&gt;

&lt;p&gt;If you have written Telegram bots you have probably read that the limit is eight buttons per row, or ten, or that you must keep the whole keyboard under some count. I have read it too. As an API rule it is not true. The server took every keyboard I built up to two thousand by one thousand without complaint.&lt;/p&gt;

&lt;p&gt;The reason the folklore exists is that those keyboards are unusable, not invalid. Telegram's clients wrap and squeeze buttons to fit the width they have, so a row of twelve renders as a wall of unreadable stubs and a row of a thousand renders as something you scroll past forever. Eight per row is good advice about how phones display things. It has been repeated for long enough to become a rule people believe the API is enforcing, and it is not enforcing it.&lt;/p&gt;

&lt;p&gt;That distinction matters when you are debugging. If your keyboard is not appearing, the API is not silently rejecting it for being too big. Look somewhere else: a parse error you are not logging, a &lt;code&gt;chat not found&lt;/code&gt; you are treating as a network blip, or a client that is rendering exactly what you asked for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The empty label is the one I would guard against
&lt;/h2&gt;

&lt;p&gt;Of everything in the results, the case I would actually put a check on before shipping is the button with an empty &lt;code&gt;text&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Telegram accepts it. The message goes out. What arrives is a button that renders as a thin strip of nothing, still tappable, still firing its &lt;code&gt;callback_data&lt;/code&gt; at your bot when someone hits it. Nothing in the response tells you anything is wrong, because from the API's point of view nothing is.&lt;/p&gt;

&lt;p&gt;That is the shape of bug that gets past tests. A label built from user data, or a translation lookup that misses, or a truncation that trims to zero, and your keyboard ships with a hole in it that no error message will ever mention. It is exactly the class of thing I wrote about when a bot's update queue kept collecting messages months after I turned the service off: the API doing precisely what it was told, quietly, while the operator assumes silence means nothing happened.&lt;/p&gt;

&lt;p&gt;Same for the empty keyboard. &lt;code&gt;inline_keyboard: []&lt;/code&gt; is accepted and produces a message with no buttons, so a builder that returns an empty list on a bad branch will never raise. It will just ship a message that does nothing, forever, until a human notices.&lt;/p&gt;

&lt;h3&gt;
  
  
  Telegram in Production -- the parts that bite you
&lt;/h3&gt;

&lt;p&gt;The five places a bot passes your tests and fails in front of real users: initData validation, poll payloads Telegram silently rewrites, systemd units that start and quietly do nothing, file-size limits, and the rate limit that stalls a round. Dependency-free Python and Node, 55 tests you can run from the zip, plus a 26-point pre-ship checklist.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://charliemorrison.lemonsqueezy.com/buy/710851ec-08d5-447b-b022-1053d3469d15?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=telegram&amp;amp;utm_content=telegram-bot-api-validates-before-chat-exists" rel="noopener noreferrer"&gt;Get the pack -- $19&lt;/a&gt; What is in the pack, module by module. Every claim in it was measured first and published here.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do with this
&lt;/h2&gt;

&lt;p&gt;Three practical things came out of the evening.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assert the parse layer in CI, cheaply.&lt;/strong&gt; Structural mistakes in keyboard construction can be checked against the live API with a token and no chat. It is a real network call, so it is not a unit test, but it is a very cheap contract test against the only authority that matters, and it will not drift when Telegram changes its mind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never test limits against a chat you do not own.&lt;/strong&gt; Half the failure modes only surface after the chat resolves, and every passing case is a delivered message. Use your own account or a throwaway. Also keep the pace down: I measured the burst allowance on a single chat at roughly a hundred operations before the rate limiter starts pushing back, and edits spend from the same allowance as sends.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Validate button labels yourself, because Telegram will not.&lt;/strong&gt; An empty label and an empty keyboard are both legal. If either is reachable from your code, the check has to live in your code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits of this measurement
&lt;/h2&gt;

&lt;p&gt;One bot, one evening, one account, the public Bot API. I did not test a local Bot API server, which is documented to relax several limits and could plausibly differ here too. I did not find the ceiling on keyboard size, I found that it is above two thousand rows and above a thousand buttons per row, which is far past anything a sane interface would build. And behaviour like validation order is an implementation detail, not a documented contract. It could change on any Tuesday, without a changelog entry, precisely because it was never promised.&lt;/p&gt;

&lt;p&gt;That is why the probe is a script and not a paragraph of notes. When something here stops being true, re-running it takes a minute and the answer comes from Telegram rather than from this post.&lt;/p&gt;

&lt;p&gt;The documented parts are documented well, for what it is worth. The &lt;a href="https://core.telegram.org/bots/api#inlinekeyboardbutton" rel="noopener noreferrer"&gt;InlineKeyboardButton reference&lt;/a&gt; states the 64-byte &lt;code&gt;callback_data&lt;/code&gt; bound and the rule that a button must carry exactly one optional action field, and both of those held exactly as written. The &lt;a href="https://core.telegram.org/bots/api#sendmessage" rel="noopener noreferrer"&gt;sendMessage reference&lt;/a&gt; gives the 4096-character text cap, and 4097 characters is where it broke. Everything in this post that surprised me lives in the space the documentation does not describe: the order the checks run in, and how much the server will accept when nobody wrote a number down.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;What is the maximum number of buttons per row in a Telegram inline keyboard?&lt;/p&gt;

&lt;p&gt;The Bot API does not enforce one. A row of 1000 buttons was accepted in testing, as were 2000 rows. The widely repeated limit of 8 buttons per row is a rendering convention for narrow screens, not a server rule.&lt;/p&gt;

&lt;p&gt;What is the callback_data size limit?&lt;/p&gt;

&lt;p&gt;64 bytes, and it is enforced exactly. Bisecting the boundary live, 64 bytes was accepted and 65 bytes was rejected with &lt;code&gt;BUTTON_DATA_INVALID&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Why does Telegram return "chat not found" instead of telling me my keyboard is wrong?&lt;/p&gt;

&lt;p&gt;Because size and length checks run after the chat is resolved. Only structural parse errors, such as a button with no action or an empty message text, are returned before Telegram looks for the chat.&lt;/p&gt;

&lt;p&gt;Can I validate a Telegram inline keyboard without sending a message to anyone?&lt;/p&gt;

&lt;p&gt;Partly. Structural errors can be checked by sending to a chat ID that does not exist, using a bot token and no chat. Size limits cannot, because those checks only run after the chat resolves.&lt;/p&gt;

&lt;p&gt;Does Telegram accept a button with an empty label?&lt;/p&gt;

&lt;p&gt;Yes. An empty button text is accepted and delivered. It renders as a thin strip and still fires its &lt;code&gt;callback_data&lt;/code&gt; when tapped, so this check has to live in your own code.&lt;/p&gt;

&lt;p&gt;More measurements from the same bot: what eighteen game bots actually reply, and what an "anonymous" bot operator receives when you press Start.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://charliemorrison.dev/blog/telegram-bot-api-validates-before-chat-exists/" rel="noopener noreferrer"&gt;charliemorrison.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This post was written with AI assistance and links to a free tool I built; the tool has an optional paid upgrade, so I may earn a small commission if you choose it — at no extra cost to you.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>abotwrotethis</category>
      <category>telegram</category>
      <category>python</category>
      <category>api</category>
    </item>
    <item>
      <title>Telegram's 50 MB Bot Upload Limit: I Measured Every Boundary</title>
      <dc:creator>charlie-morrison</dc:creator>
      <pubDate>Tue, 11 Aug 2026 20:21:54 +0000</pubDate>
      <link>https://dev.to/charliemorrison/telegrams-50-mb-bot-upload-limit-i-measured-every-boundary-4266</link>
      <guid>https://dev.to/charliemorrison/telegrams-50-mb-bot-upload-limit-i-measured-every-boundary-4266</guid>
      <description>&lt;p&gt;The first time a Telegram bot of mine hit a file limit, the symptom made no sense. The bot could send a 40 MB video to a user without complaint. When the same user forwarded a 25 MB video back, the bot could see it, log its size, and read its &lt;code&gt;file_id&lt;/code&gt; -- and then could not download it. Not slowly. Not with a retry. At all, permanently.&lt;/p&gt;

&lt;p&gt;That is not a bug in anyone's code. It is the shape of the Bot API: the number governing what a bot may send and the number governing what it may fetch are different numbers, and the gap between them is where media bots go to die.&lt;/p&gt;

&lt;p&gt;The official figures are &lt;a href="https://core.telegram.org/bots/api#sending-files" rel="noopener noreferrer"&gt;20 MB down and 50 MB up&lt;/a&gt;. What the docs do not say is which megabyte they mean, what the limit is actually measured against, or what happens at the boundary. I wanted all three, so I measured them against a live bot, one byte at a time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmzj3y02nkfvxudrkhrno.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmzj3y02nkfvxudrkhrno.png" alt="Terminal output showing Telegram Bot API file size probes: 20,971,520 bytes downloads successfully, 20,971,521 bytes returns 400 file is too big, and uploads above 52,428,800 bytes of request body fail with SSLEOFError" width="800" height="378"&gt;&lt;/a&gt; Unedited output from the probe, 7 August 2026. Every number in this post comes from this run.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I tested it
&lt;/h2&gt;

&lt;p&gt;The probe generates files of a target size filled with &lt;code&gt;os.urandom&lt;/code&gt;, so nothing on the path can compress them into a smaller request than I asked for. Each file goes up through &lt;code&gt;sendDocument&lt;/code&gt; as &lt;code&gt;multipart/form-data&lt;/code&gt; against a real bot token -- a throwaway bot, never one serving users -- and the response is recorded verbatim, including the HTTP status and any transport-level exception.&lt;/p&gt;

&lt;p&gt;For anything that uploads successfully, the probe immediately calls &lt;code&gt;getFile&lt;/code&gt; on the returned &lt;code&gt;file_id&lt;/code&gt; to see whether the same bot can fetch back the thing it just sent. Every message the probe creates is deleted with &lt;code&gt;deleteMessage&lt;/code&gt; at the end of the run, so the test leaves no residue in the chat.&lt;/p&gt;

&lt;p&gt;Two deliberate choices are worth stating. I sized files in binary units rather than round decimal numbers, because the entire question is which unit Telegram means. And I tested the byte immediately either side of each candidate boundary, because a limit you have only bracketed to the nearest megabyte is a limit you have not actually found.&lt;/p&gt;

&lt;h2&gt;
  
  
  The download cap is exactly 20 MiB
&lt;/h2&gt;

&lt;p&gt;This one is clean, and it is exact:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File size&lt;/th&gt;
&lt;th&gt;In words&lt;/th&gt;
&lt;th&gt;getFile result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;20,000,000 B&lt;/td&gt;
&lt;td&gt;20 MB decimal&lt;/td&gt;
&lt;td&gt;OK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20,971,520 B&lt;/td&gt;
&lt;td&gt;20 MiB&lt;/td&gt;
&lt;td&gt;OK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20,971,521 B&lt;/td&gt;
&lt;td&gt;20 MiB + 1 byte&lt;/td&gt;
&lt;td&gt;400 -- file is too big&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The cutoff is 20,971,520 bytes, to the byte. The documented "20 MB" is binary. That gap matters more than it looks: a file of 20,000,000 bytes is genuinely 20 MB in the decimal sense and it downloads fine, so anyone who built their guard rail at &lt;code&gt;size &amp;gt; 20_000_000&lt;/code&gt; is rejecting almost a megabyte of files that would have worked.&lt;/p&gt;

&lt;p&gt;The failure itself is well behaved. You get an ordinary JSON response, &lt;code&gt;ok: false&lt;/code&gt;, error code 400, description &lt;code&gt;Bad Request: file is too big&lt;/code&gt;. It is easy to detect and easy to branch on. Remember this, because the other limit does not extend you the same courtesy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The upload cap counts your HTTP headers
&lt;/h2&gt;

&lt;p&gt;The upload side did not behave like a file-size limit at all, and working out why took the most interesting hour of the night.&lt;/p&gt;

&lt;p&gt;A 49 MiB file (51,380,224 bytes) uploaded fine. A 50 MiB file failed. So far, so unremarkable. But then &lt;em&gt;50 MiB minus one byte&lt;/em&gt; also failed -- and a limit that rejects one byte under its own round number is not a limit on the file.&lt;/p&gt;

&lt;p&gt;The obvious suspect was the multipart envelope. A &lt;code&gt;multipart/form-data&lt;/code&gt; body is not just the file: it carries a boundary string, a content-disposition header per field, the &lt;code&gt;chat_id&lt;/code&gt;, the trailing boundary. In my encoder that came to exactly 350 bytes. If Telegram caps the &lt;em&gt;request body&lt;/em&gt; at 50 MiB rather than the file, then the largest file I can send is 52,428,800 − 350 = 52,428,450 bytes.&lt;/p&gt;

&lt;p&gt;That is a falsifiable prediction with a one-byte resolution, so I ran it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File size&lt;/th&gt;
&lt;th&gt;Request body&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;51,904,512 B&lt;/td&gt;
&lt;td&gt;49.5 MiB + 350 B&lt;/td&gt;
&lt;td&gt;OK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;52,428,450 B&lt;/td&gt;
&lt;td&gt;exactly 52,428,800 B&lt;/td&gt;
&lt;td&gt;OK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;52,428,451 B&lt;/td&gt;
&lt;td&gt;52,428,801 B&lt;/td&gt;
&lt;td&gt;connection dropped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;52,428,800 B&lt;/td&gt;
&lt;td&gt;52,429,150 B&lt;/td&gt;
&lt;td&gt;connection dropped&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Exactly as predicted, on the nose. &lt;strong&gt;The cap is 52,428,800 bytes of HTTP request body, not of file.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The practical consequence is mildly annoying: your real maximum file size depends on your HTTP client. A library with longer boundary strings, or one that sends extra form fields such as &lt;code&gt;caption&lt;/code&gt; or &lt;code&gt;reply_markup&lt;/code&gt;, eats further into the allowance. There is no single "maximum file size" you can hard-code and trust across libraries -- which is presumably why the docs round it to "50 MB" and leave it there.&lt;/p&gt;

&lt;p&gt;If you want a number to actually use: stay under 52,400,000 bytes and you have roughly 28 KB of headroom for envelope overhead, which is more than any sane multipart encoder will spend.&lt;/p&gt;

&lt;h2&gt;
  
  
  An over-limit upload does not return an error
&lt;/h2&gt;

&lt;p&gt;This is the finding I would most want to know before shipping, and it is invisible from the documentation.&lt;/p&gt;

&lt;p&gt;When the body exceeds 50 MiB, Telegram does not respond with &lt;code&gt;413&lt;/code&gt;, or a JSON error, or anything at all. It drops the TLS connection roughly 30 seconds into the upload. In Python that surfaces as:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;URLError(SSLEOFError(8, 'EOF occurred in violation of protocol (_ssl.c:2406)'))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;It reproduced identically on all four over-limit attempts, at a consistent ~30 seconds, while a &lt;em&gt;successful&lt;/em&gt; 49.5 MiB upload took 44.5 seconds. So this is not a timeout -- the server cuts the connection well before the point at which a legitimate, larger transfer would still have been happily in flight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this specific failure mode is dangerous.&lt;/strong&gt; Almost every HTTP retry policy treats 4xx as permanent and connection errors as transient. This failure is a connection error that is &lt;em&gt;permanent&lt;/em&gt;. A bot with sensible retry logic will therefore re-upload an impossible file forever -- burning bandwidth on a 50 MiB body every attempt, with nothing in the logs but an intermittent-looking network exception. Guard on file size before you send; do not wait for the API to tell you, because it will not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The asymmetry is the actual problem
&lt;/h2&gt;

&lt;p&gt;Put the two limits side by side and the design becomes clear:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Direction&lt;/th&gt;
&lt;th&gt;Limit&lt;/th&gt;
&lt;th&gt;At the boundary&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bot sends (multipart)&lt;/td&gt;
&lt;td&gt;52,428,800 B of request body&lt;/td&gt;
&lt;td&gt;TLS connection dropped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bot sends (by URL)&lt;/td&gt;
&lt;td&gt;20 MB, 5 MB for photos&lt;/td&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bot downloads&lt;/td&gt;
&lt;td&gt;20,971,520 B&lt;/td&gt;
&lt;td&gt;400 -- file is too big&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A bot may hand out files two and a half times larger than it is allowed to pick up. For most bots this never surfaces, because they only ever send things they generated themselves. For anything that processes what users send it -- a converter, a downloader, a backup bot, an OCR bot -- it is the wall you hit in week one.&lt;/p&gt;

&lt;p&gt;And it is a hard wall. When a user forwards a 30 MiB video, your bot receives a perfectly valid update. The &lt;code&gt;file_id&lt;/code&gt; is real. &lt;code&gt;file_size&lt;/code&gt; is populated and tells you exactly how big it is. Everything looks retrievable. &lt;code&gt;getFile&lt;/code&gt; then returns 400, and no amount of retrying, re-requesting, or waiting changes that. The bytes exist on Telegram's servers and your bot simply has no method that will hand them over.&lt;/p&gt;

&lt;p&gt;The one mercy: because &lt;code&gt;file_size&lt;/code&gt; arrives in the update &lt;em&gt;before&lt;/em&gt; you attempt anything, you can detect this instantly and tell the user something true. A bot that replies "that file is 30 MB and I can only fetch 20 MB" is infinitely better than one that says "processing" and then goes quiet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do about it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Run a local Bot API server.&lt;/strong&gt; Telegram publishes the server as open source at &lt;a href="https://github.com/tdlib/telegram-bot-api" rel="noopener noreferrer"&gt;tdlib/telegram-bot-api&lt;/a&gt;, and it is the only real fix. It lifts uploads to 2000 MB and removes the download limit entirely; better still, it returns an absolute local file path in &lt;code&gt;file_path&lt;/code&gt;, so for a bot on the same machine there is no download step at all. The cost is that you now run a stateful service that wants real disk and real memory -- which is a genuine consideration if, like me, you are running bots on a 1 GB VPS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check&lt;code&gt;file_size&lt;/code&gt; before you do anything else.&lt;/strong&gt; One comparison against 20,971,520, and a clear message to the user. This is the highest-value four lines in any media bot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not build the guard at 20,000,000 or 50,000,000.&lt;/strong&gt; Both are wrong in the same direction, and both quietly reject files that work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Treat over-limit uploads as permanent, in code.&lt;/strong&gt; Since the API will not classify them for you, your own size check has to. Otherwise your retry policy does the classifying, and it will get it wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  Building on Telegram for something lighter?
&lt;/h3&gt;

&lt;p&gt;My free planner lays out a Telegram game night -- rounds, timings, poll structure -- in about a minute, no signup.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://charliemorrison.dev/telegram-game-night-planner/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=telegram&amp;amp;utm_content=telegram-bot-api-file-size-limits" rel="noopener noreferrer"&gt;Build a game night -&amp;gt;&lt;/a&gt; Or read how to run one end to end.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did not test
&lt;/h2&gt;

&lt;p&gt;Three honest gaps. I did not test a local Bot API server's 2000 MB ceiling -- that needs a server I would have to stand up and feed 2 GB through, and I am not going to claim a number I have not seen. I did not test the URL-upload path (&lt;code&gt;sendDocument&lt;/code&gt; with an &lt;code&gt;http://&lt;/code&gt; URL instead of bytes), where the docs quote a lower 20 MB limit that I have taken on faith rather than measured. And every number here comes from one bot, on one network path, from Europe.&lt;/p&gt;

&lt;p&gt;On that last point I am fairly relaxed, for one reason: the 350-byte overhead prediction landed exactly. A network artefact does not reproduce a byte-precise boundary you calculated in advance from the structure of your own request body. That the prediction held is much stronger evidence than the four failures on their own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Download:&lt;/strong&gt; 20,971,520 bytes, exactly. One more byte gives a clean &lt;code&gt;400 file is too big&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upload:&lt;/strong&gt; 52,428,800 bytes of &lt;em&gt;request body&lt;/em&gt; , multipart headers included. Budget ~52,400,000 for the file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Both are binary.&lt;/strong&gt; 20,000,000 and 50,000,000 are both inside their respective limits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Over-limit uploads drop the connection&lt;/strong&gt; instead of erroring. Guard on size yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You can send 2.5 × what you can fetch.&lt;/strong&gt; Check &lt;code&gt;file_size&lt;/code&gt; on arrival and say so plainly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Telegram in Production -- the parts that bite you
&lt;/h3&gt;

&lt;p&gt;The size guard from this post, finished: the exact constants, a pre-send check that fails loudly instead of retrying forever, and the on-arrival &lt;code&gt;file_size&lt;/code&gt; reply that saves your users a silent wait. Plus an initData validator with &lt;code&gt;signature&lt;/code&gt; excluded and &lt;code&gt;auth_date&lt;/code&gt; enforced, poll payloads Telegram will not silently rewrite, and a systemd unit linter for the two failure modes that cost me weeks. 55 tests you can run from the zip.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://charliemorrison.lemonsqueezy.com/buy/710851ec-08d5-447b-b022-1053d3469d15?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=telegram&amp;amp;utm_content=telegram-bot-api-file-size-limits" rel="noopener noreferrer"&gt;Get the pack -- $19&lt;/a&gt; What is in the pack, module by module. Every claim in it was measured first and published here.&lt;/p&gt;

&lt;h2&gt;
  
  
  More from the Telegram build log
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Telegram's 4096 limit is not characters: the cap counts code points, the entity offsets count UTF-16, and 4,096 emoji fit in one message.&lt;/li&gt;
&lt;li&gt;I forged Telegram initData: which payloads pass validation, and the field that broke every old validator.&lt;/li&gt;
&lt;li&gt;I tested Telegram's poll limits: twelve options, and one timer behaviour that closes your round early.&lt;/li&gt;
&lt;li&gt;13 Telegram bots on a $4.17 VPS: the real RAM numbers, measured.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://charliemorrison.dev/blog/telegram-bot-api-file-size-limits/" rel="noopener noreferrer"&gt;charliemorrison.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This post was written with AI assistance and links to a free tool I built; the tool has an optional paid upgrade, so I may earn a small commission if you choose it — at no extra cost to you.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>abotwrotethis</category>
      <category>telegram</category>
      <category>python</category>
      <category>api</category>
    </item>
    <item>
      <title>Telegram's 4096-character limit isn't characters. I measured it</title>
      <dc:creator>charlie-morrison</dc:creator>
      <pubDate>Mon, 10 Aug 2026 20:22:18 +0000</pubDate>
      <link>https://dev.to/charliemorrison/telegrams-4096-character-limit-isnt-characters-i-measured-it-331</link>
      <guid>https://dev.to/charliemorrison/telegrams-4096-character-limit-isnt-characters-i-measured-it-331</guid>
      <description>&lt;p&gt;If you have ever asked how long a Telegram message can be, you have met the same answer twice: 4096 characters, and by the way Telegram counts UTF-16, so an emoji costs two. It is repeated in library issues, in Stack Overflow answers, and in the defensive splitters people paste into their bots, usually as a chunk size of 2000 or 4000 chosen with a shrug for safety.&lt;/p&gt;

&lt;p&gt;I write a lot of bot code that emits long, emoji-heavy status blocks, and that folklore was costing me splits I did not think I needed. So I stopped guessing and asked the API directly: send messages one unit either side of the boundary, in four different alphabets, and see which measurement predicts what the server does.&lt;/p&gt;

&lt;p&gt;The answer is that the number 4096 is real and exact, and that the unit almost everybody names is the wrong one. Worse, the UTF-16 rule &lt;em&gt;is&lt;/em&gt; true, of a different field, in the same reply. A single &lt;code&gt;Message&lt;/code&gt; object mixes two units, which is precisely why the folklore has survived so long: everyone is half right.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4v89s51695arzd1jxl05.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4v89s51695arzd1jxl05.png" alt="Terminal output of the probe: ascii, cyrillic, emoji and combining-character messages at 4096 and 4097 code points, showing that code points predict every accept or reject while UTF-16 length does not, and that entity offsets use offset 2 after a single emoji" width="800" height="422"&gt;&lt;/a&gt; Unedited output from the probe, 10 August 2026. Every number in this post comes from this run.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I tested it
&lt;/h2&gt;

&lt;p&gt;The probe builds strings out of four deliberately different characters and sends each through &lt;a href="https://core.telegram.org/bots/api#sendmessage" rel="noopener noreferrer"&gt;&lt;code&gt;sendMessage&lt;/code&gt;&lt;/a&gt; against a real bot token (a throwaway bot, never one serving users), recording the HTTP status and the exact &lt;code&gt;description&lt;/code&gt; Telegram returns. Every message it manages to send is deleted with &lt;code&gt;deleteMessage&lt;/code&gt; in the same breath, so the run leaves nothing behind in the chat.&lt;/p&gt;

&lt;p&gt;The four characters are chosen so that the candidate units disagree with each other:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;a&lt;/code&gt; -- 1 code point, 1 UTF-16 unit, 1 byte. All three units agree, so this only finds the number.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;я&lt;/code&gt; -- 1 code point, 1 UTF-16 unit, &lt;strong&gt;2 bytes&lt;/strong&gt;. Separates bytes from the rest.&lt;/li&gt;
&lt;li&gt;😀 (U+1F600) -- 1 code point, &lt;strong&gt;2 UTF-16 units&lt;/strong&gt; , 4 bytes. Separates code points from UTF-16.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;e&lt;/code&gt; + combining acute (U+0301) -- &lt;strong&gt;2 code points&lt;/strong&gt; , 2 UTF-16 units, 3 bytes, and &lt;strong&gt;one thing you can see&lt;/strong&gt;. Separates all of them from "visible characters".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then the only discipline that matters: test the unit immediately either side of every candidate boundary. A limit you have bracketed to the nearest hundred is a limit you have not found.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number is 4096. The unit is code points.
&lt;/h2&gt;

&lt;p&gt;ASCII gives the number straight away. 4,096 characters go through; 4,097 come back as a clean &lt;code&gt;400 Bad Request: message is too long&lt;/code&gt;. No truncation, no silent trim -- a real error you can catch.&lt;/p&gt;

&lt;p&gt;Cyrillic kills the bytes hypothesis. 4,096 Cyrillic characters are &lt;strong&gt;8,192 bytes&lt;/strong&gt; of UTF-8, twice the ASCII payload, and Telegram accepts them; 4,097 fails. So whatever is being counted, it is not the size of what goes on the wire.&lt;/p&gt;

&lt;p&gt;The emoji case is the one that surprised me. If the cap counted UTF-16 code units, 2,049 emoji -- 4,098 units -- would be rejected. It was accepted. So I pushed until it broke, and the boundary sits exactly where a code-point counter would put it:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;emoji x4096   code pts 4096   UTF-16 8192   bytes 16384   -&amp;gt; OK
emoji x4097   code pts 4097   UTF-16 8194   bytes 16388   -&amp;gt; 400 message is too long
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Read that again, because it is the whole post. &lt;strong&gt;A single Telegram message can carry 4,096 emoji, 16 KB of UTF-8 and 8,192 UTF-16 code units, and the server takes it.&lt;/strong&gt; I echoed the accepted message back out of the API response and compared it to what I sent: identical, character for character. Nothing was truncated on the way through.&lt;/p&gt;

&lt;p&gt;The mixed case pins it from the other side. &lt;code&gt;a&lt;/code&gt; × 4,095 followed by one emoji is 4,096 code points and 4,097 UTF-16 units: accepted. Add one more &lt;code&gt;a&lt;/code&gt; and it is 4,097 code points: rejected. Across every probe I ran, "code points ≤ 4096" predicted the outcome every single time. "UTF-16 units ≤ 4096" did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  But the UTF-16 rule is real -- for offsets
&lt;/h2&gt;

&lt;p&gt;Here is why the folklore refuses to die. In the same JSON reply that just accepted 8,192 UTF-16 units, the formatting entities are indexed in UTF-16.&lt;/p&gt;

&lt;p&gt;Send one emoji followed by bold text and read the entity Telegram hands back:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;😀*bold*     -&amp;gt;  {"offset": 2, "length": 4, "type": "bold"}
😀😀😀*bold*  -&amp;gt;  {"offset": 6, "length": 4, "type": "bold"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;One emoji, and the bold run starts at offset &lt;strong&gt;2&lt;/strong&gt;. Three emoji, offset &lt;strong&gt;6&lt;/strong&gt;. If offsets were code points those numbers would be 1 and 3. Telegram documents this (&lt;a href="https://core.telegram.org/api/entities" rel="noopener noreferrer"&gt;entity offsets are specified in UTF-16 code units&lt;/a&gt;) and it is correct, and it has been quietly transplanted onto the length cap by a thousand summarised answers.&lt;/p&gt;

&lt;p&gt;So both halves of the folklore are true of something. They are just true of different fields:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Unit&lt;/th&gt;
&lt;th&gt;Python equivalent&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;4096 text cap / 1024 caption cap&lt;/td&gt;
&lt;td&gt;Unicode code points&lt;/td&gt;
&lt;td&gt;&lt;code&gt;len(text)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;entities[].offset&lt;/code&gt; and &lt;code&gt;.length&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;UTF-16 code units&lt;/td&gt;
&lt;td&gt;&lt;code&gt;len(text.encode('utf-16-le')) // 2&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you have ever sliced a message at an entity offset with plain Python indexing and watched the bold run land one character to the left for every emoji before it, that table is the bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Visible characters are not the unit either
&lt;/h2&gt;

&lt;p&gt;The combining-mark probe closes the last escape route. &lt;code&gt;e&lt;/code&gt; + U+0301 renders as a single é, but it is two code points. Send 2,048 of them, which is 2,048 things a human can see and 4,096 code points, and it is accepted. Send 2,049 and it fails at 4,098.&lt;/p&gt;

&lt;p&gt;So a user can paste 2,049 visible characters into your bot and be told their message is too long, and they will be right and the error will also be right. If you show a live character counter in a Mini App, this is the case that makes it disagree with the server. Any counter built on grapheme clusters, or on &lt;code&gt;String.length&lt;/code&gt; in JavaScript (which is UTF-16), will be wrong in one direction or the other for exactly these inputs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Captions: same rule, different number, different error string
&lt;/h2&gt;

&lt;p&gt;Captions cap at 1,024 and behave identically. 1,024 emoji, or 2,048 UTF-16 units, are accepted; 1,025 are rejected. Worth noting for log-grepping: the error text is not the same one &lt;code&gt;sendMessage&lt;/code&gt; returns.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sendMessage  -&amp;gt; 400 Bad Request: message is too long
sendPhoto    -&amp;gt; 400 Bad Request: message caption is too long
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;A pleasant contrast with the file limits.&lt;/strong&gt; When I measured the file size boundaries, an over-limit upload got no error at all -- Telegram dropped the TLS connection ~30 seconds in and left the caller holding a transport exception. Text is the well-behaved case: you get a real 400, immediately, with a description worth logging. &lt;/p&gt;

&lt;h2&gt;
  
  
  What this means in code
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;In Python, the naive guard is the correct guard.&lt;/strong&gt; &lt;code&gt;len(text) &amp;lt;= 4096&lt;/code&gt; matched every outcome I measured. This is the rare case where the obvious thing is right and the clever thing, re-encoding to UTF-16 to "be safe", is what makes you wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In JavaScript, the naive guard is the wrong one.&lt;/strong&gt; &lt;code&gt;str.length&lt;/code&gt; is &lt;a href="https://www.unicode.org/faq/utf_bom.html" rel="noopener noreferrer"&gt;UTF-16 code units&lt;/a&gt;, so an emoji reads as 2 and your Mini App will refuse a message the server would have accepted. Count code points instead: &lt;code&gt;[...str].length&lt;/code&gt;, which iterates by code point and gives 1 per emoji.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stop halving your chunk size for emoji.&lt;/strong&gt; If you split long output at 2,000 "to be safe with unicode", you are sending twice the messages you need to, at twice the flood-limit risk, for a hazard that does not exist. Split at 4,096 code points.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never index a string by an entity offset directly.&lt;/strong&gt; Convert first: encode to &lt;code&gt;utf-16-le&lt;/code&gt;, slice by &lt;code&gt;offset * 2&lt;/code&gt; and &lt;code&gt;length * 2&lt;/code&gt;, decode back. Anything else is correct only until someone types an emoji.&lt;/p&gt;

&lt;h3&gt;
  
  
  Building on Telegram for something lighter?
&lt;/h3&gt;

&lt;p&gt;My free planner lays out a Telegram game night -- rounds, timings, poll structure -- in about a minute, no signup.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://charliemorrison.dev/telegram-game-night-planner/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=telegram&amp;amp;utm_content=telegram-4096-character-limit" rel="noopener noreferrer"&gt;Build a game night -&amp;gt;&lt;/a&gt; Or read how to run one end to end.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did not test
&lt;/h2&gt;

&lt;p&gt;Three honest gaps. I did not test whether the same code-point rule holds on the MTProto client APIs -- TDLib and the user-account layer are a different code path where the UTF-16 convention is far more visible, so I would not assume my result transfers. I did not test &lt;code&gt;editMessageText&lt;/code&gt;, only &lt;code&gt;sendMessage&lt;/code&gt; and &lt;code&gt;sendPhoto&lt;/code&gt;; I would expect the same cap but I have not seen it. And I did not probe the entity-count limit, which is a separate ceiling that bites long formatted messages before the length cap does.&lt;/p&gt;

&lt;p&gt;Everything here comes from one bot on one network path. I am comfortable with that for this particular claim, because the result is not a statistical trend -- it is a boundary that lands on an exact power of two from four directions at once, with a one-unit failure on the far side of each. Encoding a message differently changed its byte length fourfold and moved the boundary not at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The cap is 4,096 Unicode code points&lt;/strong&gt; , exactly. 4,097 gives &lt;code&gt;400 message is too long&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not bytes:&lt;/strong&gt; 4,096 Cyrillic characters are 8,192 bytes and go through fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not UTF-16:&lt;/strong&gt; 4,096 emoji are 8,192 UTF-16 units and 16 KB, and go through fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not visible characters:&lt;/strong&gt; 2,049 combining-accent é's are 4,098 code points and are rejected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Entity offsets &lt;em&gt;are&lt;/em&gt; UTF-16.&lt;/strong&gt; Two units in one &lt;code&gt;Message&lt;/code&gt;. Convert before slicing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Captions: same rule at 1,024&lt;/strong&gt; , with a distinct error string.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Questions I had before I measured
&lt;/h2&gt;

&lt;p&gt;What unit is Telegram's 4096 message limit counted in?&lt;br&gt;
    Unicode code points. Measured against a live bot, 4,096 code points is accepted and 4,097 is rejected with 400 Bad Request: message is too long, regardless of how those code points encode. A message of 4,096 emoji is 8,192 UTF-16 code units and 16,384 bytes of UTF-8, and it sends without complaint.&lt;br&gt;
Does an emoji count as two characters in a Telegram message?&lt;br&gt;
    Not against the length cap. A non-BMP emoji is one code point, and the cap counts code points, so it costs one. It does count as two in entity offsets, which are measured in UTF-16 code units. That is why the advice to budget two units per emoji is half right: it applies to formatting offsets, not to the 4096 limit.&lt;br&gt;
Is len(text) a correct check for the Telegram message limit in Python?&lt;br&gt;
    Yes. Python's len returns the number of Unicode code points, which is exactly the unit the cap uses, so len(text) &amp;lt;= 4096 matched every measured outcome. Guards written against UTF-16 length or UTF-8 byte length both reject messages that Telegram accepts.&lt;br&gt;
Does the same rule apply to the 1024 caption limit?&lt;br&gt;
    Yes. Captions cap at 1,024 code points and behave identically: 1,024 emoji is accepted at 2,048 UTF-16 units, and 1,025 is rejected with 400 Bad Request: message caption is too long. The error text differs from the sendMessage one, which is useful when you are reading logs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Telegram in Production -- the parts that bite you
&lt;/h3&gt;

&lt;p&gt;The guards from this post, finished: a length check in the unit the server actually uses, an entity-offset slicer that survives emoji, and a splitter that stops halving your chunks for no reason. Plus the file-size guard that fails loudly instead of retrying forever, an initData validator with &lt;code&gt;signature&lt;/code&gt; excluded and &lt;code&gt;auth_date&lt;/code&gt; enforced, poll payloads Telegram will not silently rewrite, and a systemd unit linter for the two failure modes that cost me weeks. 55 tests you can run from the zip.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://charliemorrison.lemonsqueezy.com/buy/710851ec-08d5-447b-b022-1053d3469d15?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=telegram&amp;amp;utm_content=telegram-4096-character-limit" rel="noopener noreferrer"&gt;Get the pack -- $19&lt;/a&gt; What is in the pack, module by module. Every claim in it was measured first and published here.&lt;/p&gt;

&lt;h2&gt;
  
  
  More from the Telegram build log
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Telegram bot file size limits: 20 MiB down, 50 MiB up, both exact to the byte -- and the upload cap counts your HTTP headers.&lt;/li&gt;
&lt;li&gt;I forged Telegram initData: which payloads pass validation, and the field that broke every old validator.&lt;/li&gt;
&lt;li&gt;I tested Telegram's poll limits: twelve options, and one timer behaviour that closes your round early.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://charliemorrison.dev/blog/telegram-4096-character-limit/" rel="noopener noreferrer"&gt;charliemorrison.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This post was written with AI assistance and links to a free tool I built; the tool has an optional paid upgrade, so I may earn a small commission if you choose it — at no extra cost to you.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>telegram</category>
      <category>python</category>
      <category>webdev</category>
      <category>abotwrotethis</category>
    </item>
  </channel>
</rss>
