@yaebal/rich
sendRichMessage / sendRichMessageDraft — telegram's block-tree message
format. one dual-dialect builder set, a draft/streaming session that owns the 30s ttl, and full
read-side coverage of everything telegram can hand back on message.rich_message.
install
pnpm add @yaebal/richwhy this isn't in @yaebal/fmt
@yaebal/fmt parses classic parse_mode/entities
— a flat { text, entities } pair. a rich message is a different model entirely: a
block-tree document (paragraphs, headings, tables, lists, collages, slideshows, a
collapsible <details>, even a <tg-thinking> placeholder for
a streaming answer). you write extended html (or markdown) once, telegram parses it server-side,
and the same tree comes back on message.rich_message. different wire format, different
tag vocabulary, its own streaming protocol — hence its own package.
one builder, two dialects
telegram accepts a rich message as either InputRichMessage.html or .markdown. most rich-message libraries pick one dialect to build for and bolt the
other on as a parallel, hand-duplicated set of functions. @yaebal/rich doesn't: every
builder — bold, paragraph, table, list, all
~40 of them — returns a RichNode that doesn't know its own output format yet. it
renders itself only when it lands inside a template:
import { html, md, heading, paragraph, bold, link, sendRichMessage } from "@yaebal/rich";
const title = "release notes";
const body = [
heading(1, title),
paragraph("yaebal ", bold("0.1"), " is out — see ", link("https://yaebal.mom", "the docs"), "."),
];
await sendRichMessage(ctx.api, ctx.chat.id, html(body)); // <h1>…</h1><p>…</p>
await sendRichMessage(ctx.api, ctx.chat.id, md(body)); // # …\n\n…there is no md.bold/md.paragraph shadow api to learn, nothing to keep in
sync, and nothing that silently drifts between dialects — bold(...) is bold(...) everywhere. html/md are tagged templates first:
const doc = html`
${heading(1, title)}
${paragraph("yaebal ", bold("0.1"), " is out — see ", link("https://yaebal.mom", "the docs"), ".")}
`;
await sendRichMessage(ctx.api, ctx.chat.id, doc);literal template text passes through unchanged; only ${…} interpolation is
touched — a string is dialect-escaped (so user input can never inject formatting), a builder node
renders itself into the template's dialect, a nested document inlines as-is if the dialects match
(and throws RichError if they don't), an array concatenates, and null/undefined/false vanish so cond && bold("x") composes cleanly. multi-line templates are dedented (common
leading indentation stripped). html/md also accept a plain string
(passed through as-is, no escaping/dedent — for already-formatted content) or an array of blocks —
the form for composing from data instead of prose:
html([heading(1, title), list(items.map((i) => paragraph(i.text)))]);document(blocks, { dialect, rtl, skipEntityDetection }) is the options-object
equivalent, for when the dialect is a runtime variable rather than a call-site choice.
RichDocument — the sendable result
html/md/document() all return a RichDocument: a
rendered string plus the InputRichMessage flags, settable fluently.
const doc = html`${paragraph("right-to-left, no auto-linking")}`
.rtl()
.noEntityDetection();
await sendRichMessage(ctx.api, ctx.chat.id, doc);sendRichMessage/sendRichMessageDraft/RichMessageDraft all
accept a RichDocument, a raw InputRichMessage, or a plain html string
interchangeably — toJSON() also delegates to toInputRichMessage(), so a RichDocument serializes correctly even nested inside a hand-built payload.
or install the plugin for ctx.send-flavored ergonomics:
import { Bot } from "@yaebal/core";
import { rich, html, paragraph } from "@yaebal/rich";
const bot = new Bot(token)
.install(rich())
.command("hi", (ctx) => ctx.sendRichMessage(html`${paragraph("hello!")}`));streaming a draft
sendRichMessageDraft streams a partial answer to a private chat — but the draft is ephemeral: telegram drops it 30 seconds after the last push, and it never turns
into a real message on its own. RichMessageDraft is the part of this package worth
the price of admission: it re-pushes the latest draft on a timer so a slow generator (e.g. an llm
stream) doesn't lose it between chunks, and it refuses to push after you close it.
two ways to grow a draft: rewrite() replaces the whole thing — right for a token
stream, where every chunk is a longer version of the same paragraph. write() appends to it (plain string concatenation) — right for tacking on a block
after content that's already there, without re-supplying it.
import { thinking, html, paragraph, divider, footer } from "@yaebal/rich";
bot.command("ask", async (ctx) => {
const draft = ctx.richMessageDraft(1); // draft_id, non-zero, per message
await draft.rewrite(html`${thinking("thinking…")}`); // draft-only block
let text = "";
for await (const chunk of streamAnswer(ctx.text)) {
text += chunk;
await draft.rewrite(html`${paragraph(text)}`); // full replace — same growing paragraph
}
await draft.write(html`${divider()}${footer("streamed")}`); // append, no need to re-supply `text`
// required — a draft never persists on its own. send() with no argument
// auto-assembles from the rewrite()/write() calls above.
await draft.send();
});send() with no argument auto-assembles from the accumulated rewrite()/write() calls — pass an explicit override when the persisted
message should differ from the last draft snapshot. call draft.cancel() instead of send() to abandon a draft without persisting anything — it expires within 30s
regardless.ctx.richMessageDraft(id, { canStop: true }) shows the user a stop button on the
draft (bot api 10.3). a press arrives as a stopped_message_generation update — stop
generating there and send() or cancel(). keepOnStop: true keeps the draft visible after the press.
llm output
models write github-flavored markdown out of habit. RICH_MARKDOWN_PROMPT is a
system prompt listing exactly what telegram's rich parser renders — and the html it doesn't —
so prepend it whenever a model's answer becomes a rich message, e.g. with @yaebal/ai's rich: true.
import { ai } from "@yaebal/ai";
import { RICH_MARKDOWN_PROMPT } from "@yaebal/rich";
bot.install(
ai({ model, rich: true, system: `${RICH_MARKDOWN_PROMPT}\n\nyou are a concise assistant.` }),
);buttons
bot api 10.3 puts buttons inside the message body: button() sits right in the text, buttons() lays out a row. every RichMessageButton type is covered
(callbackData, url, webApp, loginUrl, switchInlineQuery*, copyText, disabled), with style "success" | "danger" | "primary" | "link" (link is
callback-only). the label holds plain text, customEmoji() and dateTime().
import { button, buttons, customEmoji, html, paragraph } from "@yaebal/rich";
await ctx.sendRichMessage(html`
${paragraph("deploy is ready ", button("details", { callbackData: "deploy:details" }, { style: "link" }))}
${buttons([
button([customEmoji("5368324170671202286", "✅"), " ship"], { callbackData: "deploy:ship" }, { style: "success" }),
button("cancel", { callbackData: "deploy:cancel" }, { style: "danger" }),
], { align: "center" })}
`);
// callback buttons arrive as an ordinary callback_query
bot.callbackQuery("deploy:ship", (ctx) => ctx.answerCallbackQuery({ text: "shipping" }));reading
every RichBlock/RichText variant has a matching isX type
guard, and richMessageToPlainText / richBlockToPlainText / richTextToPlainText flatten the whole tree (or one node) to plain characters — useful
for search indices, logs, or notification previews.
import { richMessageToPlainText, isTable, isPhoto } from "@yaebal/rich";
bot.on("message:rich_message", (ctx) => {
const plain = richMessageToPlainText(ctx.message.rich_message);
const tables = ctx.message.rich_message.blocks.filter(isTable);
});coverage
every one of telegram's ~50 Rich* types is covered on both sides — a builder (or
documented auto-detection) and a type guard + plain-text branch for every block and inline mark,
in both dialects at once.
blocks
paragraph / heading / h1…h6 / preformatted / footer / divider
mathBlock / anchorBlock
blockquote / pullquote / details / list / item / table / cell / join
collage / slideshow / map / image / video / audio / file
buttons (a row of button()s, bot api 10.3)
thinking (draft-only — see RichMessageDraft)inline marks
bold / italic / underline / strikethrough / spoiler / code / br
link / textMention / anchor / anchorLink / customEmoji / button
marked / subscript / superscript / dateTime / math / reference / referenceLink
// no builder needed — auto-detected from plain text unless .noEntityDetection():
@username #hashtag $CASHTAG /bot_command
https://url name@email.com +1 555 0100 4111 1111 1111 1111tables and lists carry their full field set
cell() and item() aren't afterthoughts bolted onto a plain-array api —
they're first-class RichNodes that also carry the options table()/list() need to do the right thing per dialect:
table(
[
[cell("day", { header: true }), cell("count", { header: true, align: "right" })],
[cell("mon"), cell(128, { align: "right", colspan: 2 })],
],
{ bordered: true, caption: "week" },
);
// html: a full <table> — colspan/rowspan/valign, per-cell <th>/<td>
// markdown: a gfm table — header-first, alignment kept, spans dropped (no gfm equivalent)list() accepts bare values directly (auto-wrapped in a plain item) or explicit item()s for checkboxes and ordered-list numbering overrides (value, type) — no separate wrapper step required either way.
<tg-time unix format> for dateTime, <tg-math>, <tg-reference name> with <a href="#…"> links, <tg-map lat long zoom/>, a tg-spoiler attribute on media, <mark>/<sub>/<sup>, and the bot api 10.3 <tg-button>/<tg-button-row>, <blockquote expandable>, <tg-document> and bordered/striped/compact table attributes. where
rich-markdown has no native token for a block at all (footer, pull-quote,
collage/slideshow, map, details, buttons, expandable quotes, underline, subscript, superscript), the raw html tag is
embedded as-is in the markdown output too — telegram's markdown parser accepts embedded html
blocks as long as they're blank-line-separated, which the block builders already handle. media blocks take a hosted url or a
tg://…?id= link to an explicit attachment.
normal sends support multipart uploads through nested media.* sources,
including tg://document?id=. draft pushes require previously uploaded files.when using canStop, stop updates must run concurrently with generation and
bypass chat queues. see the concurrent ai runner setup or use concurrent webhooks. close the draft and cancel the provider in the stop handler. keepOnStop keeps the preview only temporarily; send() persists it.
attachments and structured drafts
RichDocument.media(...entries) attaches files referenced in html or markdown. document(blocks, { media }) is the options-object equivalent. attachment ids
must be unique and match [A-Za-z0-9_-]{1,64}. normal sends accept new
uploads through media.path, media.buffer, media.stream and media.text, urls or existing file ids.
import { media } from "@yaebal/core";
import { html, file, sendRichMessage } from "@yaebal/rich";
const report = html([file("tg://document?id=report")]).media({
id: "report",
media: { type: "document", media: media.text("hello", "report.txt") },
});
await sendRichMessage(ctx.api, ctx.chat.id, report);
const draft = ctx.richMessageDraft(7);
await draft.rewrite({ blocks: [{ type: "paragraph", text: ["hello ", { type: "bold", text: "world" }] }] });
await draft.write({ blocks: [{ type: "anchor", name: "end" }] });
await draft.send();all send helpers accept raw InputRichMessage, including structured blocks. drafts preserve blocks and explicit media through keep-alive pushes
and finalization. rewrite replaces the content; write appends
blocks to blocks or text in the same dialect, preserving flags and merging attachment ids.
an appended entry replaces the previous attachment with the same id.
drafts reject new uploads and explicit media urls before sending. use existing file ids
or media.fileId while streaming, then optionally upload files with a final draft.send(override). a rich input must specify exactly one of html, markdown or blocks.
api
| export | what |
|---|---|
html / md | tagged templates — same builders, either dialect, auto-escaped interpolation |
document | options-object form: assemble blocks into a RichDocument with an explicit dialect |
RichDocument | the sendable result — .media(...entries), .rtl()/.noEntityDetection(), toInputRichMessage()/toJSON() |
sendRichMessage / sendRichMessageDraft | standalone send functions, no plugin required |
rich() | plugin — adds ctx.sendRichMessage / ctx.richMessageDraft |
button / buttons | in-message buttons (bot api 10.3) — inline, or a row block |
RichMessageDraft | the draft/streaming session class (rewrite / write / send / cancel) |
RichNode / isRichNode / makeNode | the node contract, for writing your own dual-dialect builder |
escapeMarkdown / escapeMarkdownUrl | the raw markdown escapers the builders use internally |
isParagraph, isTable, isCustomEmoji, … | one type guard per RichBlock/RichText variant |
richTextToPlainText / richBlockToPlainText / richMessageToPlainText | flatten to plain text |
plus the full generated type surface (RichMessage, RichBlock, RichText, and every RichBlock*/RichText* interface)
re-exported from @yaebal/types for convenience.
example
a runnable bot covering all of the above lives at examples/rich-messages.