Skip to content

Rich Messages

Rich Messages add block layouts beyond regular text plus MessageEntity[]: headings, lists, documents, button rows, expandable quotations, tables, media collections, and streaming drafts. GramIO supports two rich-formatting lanes plus JSX and raw API surfaces:

  1. The Markdown lane (rich, markdownTable) for the rich-markdown dialect.
  2. The native structured lane (blocks, table) for InputRichBlock* objects.
  3. @gramio/jsx/rich for rich JSX.
  4. Raw bot.api.sendRichMessage() structures for full Bot API control and uploads.

Compose a rich message

ts
import { 
Bot
,
bold
,
format
} from "gramio";
import {
button
,
buttonRow
,
document
,
heading
,
markdownTable
,
paragraph
,
quote
,
rich
,
} from "gramio/rich"; const
bot
= new
Bot
(
process
.
env
.
BOT_TOKEN
as string);
bot
.
command
("report", (
ctx
) =>
ctx
.
send
(
rich
([
heading
(1, "Release report"),
paragraph
(
format
`Status: ${
bold
`ready`}`),
quote
("Details can be expanded", {
expandable
: true,
credit
: "Build system",
}),
document
({
url
: "https://example.com/report.pdf",
caption
: "Full report",
}),
markdownTable
(
[ ["Package", "Version"], ["gramio", "0.15.1"], ], {
compact
: true,
align
: ["left", "right"] },
),
buttonRow
(
[
button
("Open", {
type
: "url",
url
: "https://gramio.dev" }),
button
("Refresh", {
type
: "callback_data",
data
: "refresh-report",
}),
button
("Soon", {
type
: "disabled" }),
], {
align
: "right" },
), ]), ), );

The helpers escape user-controlled strings. Do not concatenate raw rich Markdown/HTML around untrusted input. For a complete string from a trusted serializer, rawRich() is the explicit escape hatch; it keeps Markdown/HTML syntax intact and must never receive user-controlled text.

Native structured tables

Use table() when the message should contain Telegram's native InputRichBlockTable. It can be sent directly, or composed with other structured blocks through blocks([...]). The first row is a header by default; align and valign default to left and top, so the required Telegram cell fields are filled automatically.

ts
import { 
Bot
} from "gramio";
import {
blocks
,
table
} from "gramio/rich";
const
bot
= new
Bot
(
process
.
env
.
BOT_TOKEN
as string);
bot
.
command
("native-report", (
ctx
) =>
ctx
.
send
(
blocks
([
blocks
.
heading
(1, "Release report"),
table
(
[ ["Package", "Version"], ["gramio", "0.15.1"], ], {
bordered
: true,
striped
: true,
compact
: true,
align
: ["left", "right"] },
), ]), ), );

For spans or per-cell formatting, use cell(). When a cell spans rows, omit the covered cells in the following rows; alignment is calculated against the resulting logical column:

ts
import { blocks, table } from "gramio/rich";

const summary = table({
	rows: [
		[
			blocks.cell("Package", { header: true }),
			blocks.cell("Version", { header: true, align: "right" }),
		],
		[blocks.cell("Total", { colSpan: 2, align: "center" })],
	],
	caption: "Release summary",
});

markdownTable() remains available when Markdown output is intentional, especially when composing a legacy rich([...]) message. Do not interpolate a native block into rich — Markdown and structured rich messages are separate send formats. streamRichMessage() is Markdown-chunk based; send native blocks with ctx.send() or ctx.sendRichMessage() instead.

Native DSL

The blocks namespace covers every InputRichBlock and every RichText node from Bot API 10.3. Inline nodes can be nested and mixed with format values; block helpers return a RichBlockNode that can be sent directly or composed in blocks([...]):

ts
import { bold, format } from "gramio";
import { blocks } from "gramio/rich";

const report = blocks([
	blocks.h1("Release report"),
	blocks.paragraph([
		blocks.bold("Status: "),
		format`${bold("ready")}`,
		" — ",
		blocks.url("open changelog", "https://gramio.dev/changelog"),
	]),
	blocks.orderedList([
		blocks.listItem("First step"),
		blocks.listItem("Second step"),
	], { start: 1, type: "1" }),
	blocks.taskList([
		{ content: "Ship", done: true },
		{ content: "Announce" },
	]),
	blocks.details("Diagnostics", [
		blocks.pre("bun test", "sh"),
		blocks.table([
			["Package", "Version"],
			[blocks.cell("gramio"), blocks.cell("0.15.0")],
		], { bordered: true, striped: true }),
	], { open: true }),
	blocks.photo("https://example.com/cover.jpg", {
		caption: "Cover",
		credit: "Build bot",
	}),
	blocks.buttonRow([
		blocks.button("Open", { type: "url", url: "https://gramio.dev" }),
		blocks.button("Refresh", { type: "callback_data", data: "refresh" }),
	]),
]);

bot.command("native-report", (ctx) => ctx.send(report));

Useful aliases include blocks.h1blocks.h6, blocks.hr, blocks.quote, blocks.expandableQuote, blocks.pre, blocks.voice, and blocks.link. Media helpers accept a URL/file id, an uploaded Blob/File, or a complete InputMedia* object. Upload helpers are asynchronous, so await them before passing the file to a native media block:

ts
import { MediaUpload } from "gramio";
import { blocks } from "gramio/rich";

bot.command("upload-cover", async (ctx) => {
	const cover = await MediaUpload.path("./cover.jpg");
	return ctx.send(blocks([blocks.photo(cover, { caption: "Cover" })]));
});

blocks.map() accepts either a { latitude, longitude } location or latitude/longitude numbers. blocks.buttonRow() checks Telegram's 1–8 button limit; native serialization also checks the Bot API limits (500 blocks, 50 media attachments, 20 table columns, 16 nesting levels, and 32,768 UTF-8 text bytes).

For thumbnails and video covers, pass a complete InputMedia* object (for example, MediaInput.video(file, { thumbnail, cover })) as the media argument. Put the visible rich-block caption in the helper's second argument; Telegram ignores caption nested inside InputMedia* in a native rich media block. Uploads are extracted recursively from blockquotes, lists, collages, slideshows, and details.

Migration from @gramio/format 0.11

This release intentionally changes the meaning of table(): it now creates a native Telegram table. Code that used the old Markdown serializer must rename the call to markdownTable(). This is a breaking release (@gramio/format 0.12 and gramio 0.15).

Rich JSX

Use a separate JSX import source so regular formatting JSX and rich JSX stay distinct:

tsx
/** @jsxImportSource @gramio/jsx/rich */

const message = (
	<rich>
		<h1>Release report</h1>
		<blockquote expandable credit="Build system">
			Details can be expanded
		</blockquote>
		<document url="https://example.com/report.pdf" caption="Full report" />
		<table compact align={["left", "right"]}>
			<tr><th>Package</th><th>Version</th></tr>
			<tr><td>gramio</td><td>0.14.0</td></tr>
		</table>
		<button-row align="right">
			<button type="url" url="https://gramio.dev">Open</button>
			<button type="disabled">Soon</button>
		</button-row>
	</rich>
);

Pass message to ctx.send() exactly like a value created by rich().

Upload files inside rich content

@gramio/files recursively finds uploads in rich_message.blocks and rich_message.media, including nested document media, thumbnails, and covers. The middleware rewrites each upload to an attach://… reference.

ts
import { 
Bot
,
MediaUpload
} from "gramio";
const
bot
= new
Bot
(
process
.
env
.
BOT_TOKEN
as string);
bot
.
command
("upload-report", async (
ctx
) => {
const
report
= await
MediaUpload
.
path
("./report.pdf");
return
bot
.
api
.
sendRichMessage
({
chat_id
:
ctx
.
chatId
,
rich_message
: {
html
: '<tg-document src="tg://document?id=report"></tg-document>',
media
: [
{
id
: "report",
media
: {
type
: "document",
media
:
report
},
}, ], }, }); });

You can also put a document directly in rich_message.blocks with an InputRichBlockDocument; recursive uploads are supported there too.

Inline-result rich content (InputRichMessageContent) and editMessageText calls that target an inline_message_id can use only already uploaded file_id values. New Blob/File uploads and explicit media URLs are not supported by Telegram in those API paths.

Stream a draft

Draft IDs are application-defined non-zero integers. Reusing an ID animates the next partial result. A draft is temporary: send the finalized message with sendRichMessage when generation finishes.

ts
import { 
Bot
} from "gramio";
const
bot
= new
Bot
(
process
.
env
.
BOT_TOKEN
as string);
await
bot
.
api
.
sendRichMessageDraft
({
chat_id
: 42,
draft_id
: 1001,
rich_message
: {
markdown
: "## Generating…" },
can_stop
: true,
keep_on_stop
: true,
});

No direct uploads in drafts

Telegram forbids direct file uploads in sendRichMessageDraft. GramIO therefore excludes this method from upload extraction. Reuse an existing file_id; do not pass MediaUpload, Blob, or a new upload URL.

Handle stop-generation updates

When can_stop is enabled and the user presses Stop, Telegram sends stopped_message_generation. It is part of GramIO's default allowed updates; no manual opt-in is needed.

ts
import { 
Bot
} from "gramio";
const
bot
= new
Bot
(
process
.
env
.
BOT_TOKEN
as string);
bot
.
on
("stopped_message_generation", async (
ctx
) => {
console
.
log
(
ctx
.
draftId
,
ctx
.
threadId
,
ctx
.
chatId
,
ctx
.
chatType
);
await
ctx
.
send
("Generation stopped.");
});

ctx.draftId identifies the stopped draft. ctx.threadId is present for a topic, and follow-up sends automatically keep that thread.

See also