@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.pages.dev", "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.pages.dev", "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.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
thinking (draft-only — see RichMessageDraft)inline marks
bold / italic / underline / strikethrough / spoiler / code / br
link / textMention / anchor / anchorLink / customEmoji
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.
<p>, <h1>–<h6>, <pre><code>, <hr/>, <footer>, <blockquote>, <aside>, <details>, <table>, <tg-collage>, <tg-slideshow>, <tg-map>, <tg-math-block>, <tg-thinking>,
the classic <b>/<i>/<u>/<s>/<code>/<tg-spoiler>/<tg-emoji> set, and tg://user?id=…). a handful (marked, subscript, superscript, dateTime, inline math, reference/referenceLink, table borders) have no documented tag in the schema at all — those are best-effort guesses, flagged in
their doc comments in inline.ts/blocks.ts. verify against the live
"rich message formatting options" docs before depending on the exact spelling in production. where
rich-markdown has no native token for a block at all (footer, pull-quote,
collage/slideshow, map, details, 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. sendRichMessage has no attach:///multipart upload path (unlike sendPhoto) — media blocks take a hosted url, not a local file.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 — .rtl()/.noEntityDetection(), toInputRichMessage()/toJSON() |
sendRichMessage / sendRichMessageDraft | standalone send functions, no plugin required |
rich() | plugin — adds ctx.sendRichMessage / ctx.richMessageDraft |
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.