@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.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:

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

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.

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.

llm.ts
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().

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

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 / file
buttons             (a row of button()s, bot api 10.3)
thinking            (draft-only — see RichMessageDraft)

inline marks

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

every tag follows telegram's "rich html style" / "rich markdown style" docs, including the less common ones: <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.

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

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 — .media(...entries), .rtl()/.noEntityDetection(), toInputRichMessage()/toJSON()
sendRichMessage / sendRichMessageDraftstandalone send functions, no plugin required
rich()plugin — adds ctx.sendRichMessage / ctx.richMessageDraft
button / buttonsin-message buttons (bot api 10.3) — inline, or a row block
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.