Send Telegram Rich Messages via Bot API 10.x sendRichMessage: collapsible expandable blockquotes, details blocks, tables, checklists, code blocks, spoilers, date widgets, dividers, footers — all in ONE message. Contains LIVE-VERIFIED JSON shapes (many differ from the official docs!), a ready Python sender, a probe script, and the troubleshooting table of API error messages. Use when posting changelogs, reports, digests, or any formatted content to Telegram chats/threads/topics. Triggers: rich...
Installs into .claude/skills of the current project.
Are you the author of Telegram Rich Messages?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/youlianvr-telegram-rich-messages)
---
name: telegram-rich-messages
description: >
Send Telegram Rich Messages via Bot API 10.x sendRichMessage: collapsible
expandable blockquotes, details blocks, tables, checklists, code blocks,
spoilers, date widgets, dividers, footers — all in ONE message. Contains
LIVE-VERIFIED JSON shapes (many differ from the official docs!), a ready
Python sender, a probe script, and the troubleshooting table of API error
messages. Use when posting changelogs, reports, digests, or any formatted
content to Telegram chats/threads/topics. Triggers: rich message,
sendRichMessage, свёрнутый блок, collapsible block, expandable blockquote,
TG post, changelog to Telegram, telegram formatting.
metadata:
bot-api: "10.3 (2026-08-24)"
verified: "2026-09-03, live probes against api.telegram.org"
---
# Telegram Rich Messages (Bot API 10.x)
Rich Messages are structured messages built from BLOCKS (not parse_mode
markup). One message can contain text, collapsible sections, tables,
checklists, code blocks, media, and (since 10.3) buttons. Sent via the
`sendRichMessage` method.
## Method
```
POST https://api.telegram.org/bot<TOKEN>/sendRichMessage
{
"chat_id": <id>,
"message_thread_id": <topic_id>, // optional, for forum topics
"rich_message": { "blocks": [ ...blocks ] }
}
```
Requires a Bot API 10.3+ server. If the token's server predates 10.x you
get `Method not found` — fall back to classic `sendMessage` with
`parse_mode: "HTML"` and `<blockquote expandable>…</blockquote>`.
## ⚠️ LIVE-VERIFIED SHAPES (docs diverge — trust these)
Field-name traps found by probing the real API. The official changelog
names classes (e.g. `InputRichBlockExpandableBlockQuotation`) but does NOT
show the JSON wire format; several intuitive shapes FAIL.
### Blocks — supported `type` values (verified OK)
| type | fields | notes |
|---|---|---|
| `paragraph` | `text`: RichText | base text block |
| `expandable_blockquote` | `text`: RichText, `credit`?: RichText | **collapsible** quote, collapsed by default; field is `text` (RichText), NOT `children`/`blocks` |
| `blockquote` | `blocks`: Block[], `credit`?: RichText | regular quote; field is `blocks` (array of blocks), NOT `text` — `text` → `RICH_MESSAGE_EMPTY` |
| `details` | `summary`: RichText, `blocks`: Block[], `is_open`?: true | collapsible section (like `<details>`); content in `blocks` |
| `pre` | `text`: str, `language`?: str | code block. Type is `pre`, NOT `preformatted`/`code_block` |
| `list` | `items`: ListItem[] | see ListItem below; items are objects, NOT strings |
| `table` | `cells`: Cell[][] | 2D array of cells, NO row wrapper object |
| `divider` | — | horizontal rule |
| `footer` | `text`: RichText | small bottom line |
### Unsupported via sendRichMessage (probed, rejected)
`section_heading`, `heading*`, `preformatted`, `pull_quotation`,
`block_quotation` (use `blockquote`), `quotation`. Headings inside blocks:
use `paragraph` with a `bold` run — the client renders message headings
only from user-entered text.
### RichText — the `text` payload
`RichText` = plain **string** OR **array** mixing:
- plain strings,
- run objects: `{"type": "bold"|"italic"|"underline"|"strikethrough"|"marked"|"code"|"spoiler", "text": RichText}`,
- `{"type": "url", "url": "https://…", "text": RichText?}` — link (text label optional),
- `{"type": "date_time", "unix_time": 1788450000}` — date widget (field is `unix_time`, NOT `date_time`).
Gotcha: `{"type": "text", "text": "…"}` as a RUN is INVALID ("Unsupported
rich text type"). Plain text inside arrays must be a raw JSON string.
### ListItem (inside `list.items`)
```json
{
"blocks": [ {"type": "paragraph", "text": "item text"} ],
"has_checkbox": true, // optional; omit for plain bullet
"is_checked": true, // only if has_checkbox
"type": "1", // optional ordered marker: "1"|"a"|"A"|"i"|"I"
"value": 1 // optional number for ordered lists
}
```
Item content lives in `blocks` — a `text` field on the item fails with
"invalid list item type specified".
### TableCell (inside `table.cells[[][]]`)
```json
{"text": RichText?, "is_header": true?, "colspan": 2?, "rowspan": 2?,
"align": "left"|"center"|"right", "valign": "top"|"middle"|"bottom"}
```
`align`/`valign` are required (defaults untested — set them explicitly).
Rows are plain arrays of cells: `cells: [[cell, cell], [cell, cell]]`.
## Minimal working examples
### Changelog with collapsible sections (the common case)
```python
import json, os, urllib.request
TOKEN = os.environ["TELEGRAM_BOT_TOKEN"]
def send(blocks, chat_id, thread_id=None):
p = {"chat_id": chat_id, "rich_message": {"blocks": blocks}}
if thread_id: p["message_thread_id"] = thread_id
req = urllib.request.Request(
f"https://api.telegram.org/bot{TOKEN}/sendRichMessage",
data=json.dumps(p).encode(), headers={"Content-Type": "application/json"})
return json.loads(urllib.request.urlopen(req, timeout=30).read())
blocks = [
{"type": "paragraph", "text": [{"type": "bold", "text": "🚀 Release 1.2.3"}]},
{"type": "paragraph", "text": "Summary line."},
{"type": "expandable_blockquote", "text": [
{"type": "bold", "text": "✨ Features\n"},
"• feature one\n• feature two"]},
{"type": "expandable_blockquote", "text": [
{"type": "bold", "text": "🛠 Fixes\n"},
"• fix one"]},
]
print(send(blocks, chat_id=<chat-id>))
```
### RichText builder helpers (recommended)
```python
def bold(t): return {"type": "bold", "text": t}
def ital(t): return {"type": "italic", "text": t}
def code(t): return {"type": "code", "text": t}
def spoil(t): return {"type": "spoiler", "text": t}
def link(url, label=None):
d = {"type": "url", "url": url}
if label: d["text"] = label
return d
def para(text): return {"type": "paragraph", "text": text}
def expq(text): return {"type": "expandable_blockquote", "text": text} # collapsible
def quote(blocks): return {"type": "blockquote", "blocks": blocks}
def details(summary, blocks, open_=False):
d = {"type": "details", "summary": summary, "blocks": blocks}
if open_: d["is_open"] = True
return d
```
## Editing & fallbacks
- Edit later: `editMessageText` accepts `rich_message` parameter (10.1+).
- Stream drafts: `sendRichMessageDraft` (typing-like streaming of blocks).
- Buttons in-message: `RichBlockButtons` / `RichTextButton` (10.3) — NOT yet
probed; expect the same JSON-shape trial-and-error, probe first.
- Media inside message: `InputRichBlockPhoto` etc. (10.2 `blocks` variants) —
not probed here; same advice.
- If `sendRichMessage` → `Method not found` or "unsupported": fall back to
`sendMessage` with `parse_mode: "HTML"`; `<blockquote expandable>` gives
the collapsible effect (no tables/checklists though).
## Troubleshooting (live-mapped error → cause)
| API error | cause |
|---|---|
| `type "X" is unsupported` | block type not in the supported table (e.g. `preformatted`, `pull_quotation`, `section_heading`) |
| `Unsupported rich text type` | a run object with unknown `type` — usually `{"type":"text",...}` inside a RichText array; use a plain string |
| `RICH_MESSAGE_EMPTY` | block content in wrong field: `blockquote` needs `blocks`, not `text`; `details` needs non-empty `blocks` |
| `RICH_MESSAGE_CONTENT_REQUIRED` | block content in wrong field: `expandable_blockquote` needs `text`, not `blocks`/`children` |
| `invalid list item type specified` | list item objects missing `blocks` or using `text` instead |
| `Can't find field "cells"` | table rows wrapped in objects; `cells` must be a 2D array of cell objects directly |
| `Can't find field "unix_time"` | date run uses `unix_time`, not `date_time`/`timestamp` |
| `Can't parse InputRichBlockListItem: Object expected` | list items as plain strings; must be objects |
| `Method not found` | server/token predates Bot API 10.x — use HTML fallback |
| `Bad Request: message thread not found` | sending to the GENERAL topic with an explicit id: General (even renamed, root id 1) REJECTS `message_thread_id: 1` — omit `message_thread_id` entirely and the message lands in General. For every other topic `message_thread_id` = ROOT MESSAGE id; userbot `list_topics` returns exactly those ids (ids are re-read with `list_topics` immediately before sending — they are not stable across conversations) |
| post lands in the WRONG topic (e.g. a changelog posted twice outside General) | wrong id→topic MAPPING on the sender side, not API misrouting — the API routes exactly to the id given (an id assumed from conversation context belonged to a different topic → duplicate posts). Resolve the topic id via `list_topics` (or the first number in `t.me/<group>/<root>/<msg>` of a message inside the TARGET topic) immediately before sending, and verify actual placement afterwards |
| `deleteMessage` returns ok but message survives | Bot API may report success in forum topics without deleting (2026-09-03: `ok: true` on msg 9396, message stayed). Verify via `forwardMessage`/`getMessages` (both honestly say "not found" vs "found") or userbot `get_message_context`; reliable removal = admin delete from a user account |
| `can't parse InputRichBlock: …` | generic: re-check field names against the tables above |
## Workspace scripts
- `a showcase script of your own` — full showcase of every verified block, sends
to a private chat; run it to see the rendering (or forward its result).
**Token selection:** the workspace `.env` token and the bot-MCP token are
DIFFERENT bots. `TELEGRAM_BOT_TOKEN` in `.env` = `<bot-in-env>`;
`mcp.json → telegram-mcp-bot.env.TELEGRAM_BOT_TOKEN` = `<bot-in-mcp-config>`
(the one added to No AGI Chat). When posting as the group's bot, load the
token from `%USERPROFILE%/.agents/mcp.json`, not from `.env`:
```python
tok = json.load(open(r"%USERPROFILE%/.agents/mcp.json"))["mcpServers"]\
["telegram-mcp-bot"]["env"]["TELEGRAM_BOT_TOKEN"]
```
- ~~`the one-off changelog poster`~~ — removed 2026-09-03 (one-off 0.0.86
changelog poster, recycled); use `tgsend.py` instead.
- `your Telegram sender` — **PRIMARY entry point (2026-09-03):** the general
Telegram sender. ANY content -> NAMED targets from a local targets file you keep
(`<channel-target>`, `<owner-dm-target>`; add new
targets there, never hardcode ids) OR any custom chat id: bare number,
or `c:<id>` channel/group -> -100<id>, `g:<id>` group -> -<id>,
`u:<id>` user. Wraps rich_poster machinery:
markdown (file via `@path`, stdin via `-`, or inline) -> rich message;
`--photo`, `--raw-html`, `--plain`; `--dry-run`; same dedupe cache and
forum guard. Usage:
`python your Telegram sender @changelog.md --to freebuff-channel --footer "..."`
`python your Telegram sender "done" --to owner-dm`
`python your Telegram sender "hi" --to c:<chat-id>`
- ~~`the old probe scripts`~~ — recycled 2026-09-27 (six near-identical session
probes with no caller). To test a new block type without sending anything:
`python your rich-message poster post.md --chat <id> --dry-run`.
- `your rich-message poster` — REUSABLE poster (library layer under tgsend): markdown in -> one rich
message out, `##` sections auto-become collapsible expandable_blockquotes
(`--mode details` / `--mode flat`), inline md (bold/italic/code/spoiler/
strike/underline/links), lists/checklists, GFM tables, fenced code,
quotes, dividers, footer; HTML fallback + forum guard + DUPLICATE-POST
GUARD: sha256(chat, thread/general, mode, blocks-JSON) is remembered in
`a local dedupe cache file` (git-ignored, TTL 72 h) and a re-send
of the same payload is refused with exit 1 (`--force` overrides — e.g.
after manual deletion of the earlier post). Verified live 2026-09-03:
2nd run refused, `--force` sent, test messages cleaned up.
Usage: `python your rich-message poster post.md --chat <id> --thread <root_id>`
(`--dry-run` prints JSON + HTML preview; `-` reads stdin; `--general` is
the explicit opt-in for posting into a forum's General topic, which must
be sent WITHOUT `message_thread_id`).
## Reading Rich Messages back
Bot API `getUpdates` delivers `rich_message` only while unconfirmed; most
MCP wrappers show `[unsupported message type]`. To read rich messages from
history, use MTProto (Telethon): the message object has `.rich_message`
with `.blocks` of `PageBlock*` nodes (`PageBlockParagraph`, `PageBlockList`,
`PageBlockPreformatted`, `PageBlockDetails`, …) and `TextPlain`/`TextConcat`
runs. Workspace session: the host's own session store, outside this repository
(`TELEGRAM_SESSION_STRING`) — see a rendered example of your own for a worked example
of rendering those blocks to markdown.
## Version note
Verified against Bot API 10.3 (2026-08-24) on 2026-09-03. Newer Bot API
versions may add block types or relax field names — re-probe before
trusting this file blindly after a major Telegram announcement.