@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

terminal
pnpm add @yaebal/rich

why 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:

dual-dialect.ts
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:

template.ts
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:

from-data.ts
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.

flags.ts
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:

bot.ts
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.

stream.ts
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.

read.ts
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

block builders
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

inline builders
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 1111

tables 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:

tables.ts
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.

tag confidence varies. most tags are confirmed straight from telegram's schema (<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

exportwhat
html / mdtagged templates — same builders, either dialect, auto-escaped interpolation
documentoptions-object form: assemble blocks into a RichDocument with an explicit dialect
RichDocumentthe sendable result — .rtl()/.noEntityDetection(), toInputRichMessage()/toJSON()
sendRichMessage / sendRichMessageDraftstandalone send functions, no plugin required
rich()plugin — adds ctx.sendRichMessage / ctx.richMessageDraft
RichMessageDraftthe draft/streaming session class (rewrite / write / send / cancel)
RichNode / isRichNode / makeNodethe node contract, for writing your own dual-dialect builder
escapeMarkdown / escapeMarkdownUrlthe raw markdown escapers the builders use internally
isParagraph, isTable, isCustomEmoji, …one type guard per RichBlock/RichText variant
richTextToPlainText / richBlockToPlainText / richMessageToPlainTextflatten 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.