REPOSITORY / ScuroNeko/Laniakea

Wiki

KNOWLEDGE REPOSITORY

(doc): Rich Messages wiki page (EN/RU)

(doc): RichAnswer section in MessageContext pages
(doc): sidebar link

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 12:43:18 +03:00
co-authored by Claude Fable 5
parent ae80364ba2
commit b8b0967558
5 changed files with 219 additions and 0 deletions
+13
@@ -105,6 +105,19 @@ English version: [[MessageContext]]
Они покрывают самые частые callback-сценарии без ручного хождения в `tgapi`.
## Rich-сообщения
`RichAnswer(...)` и `RichAnswerKeyboard(...)` отправляют структурированные rich-сообщения Bot API 10.1, собранные из фрагментов `tgfmt`:
```go
ctx.RichAnswer(
tgfmt.H1(tgfmt.NewRich("Отчёт")),
tgfmt.P(tgfmt.NewRich("всё работает").Bold()),
)
```
Полный DSL и приёмная сторона описаны на странице [[Rich-Messages-RU]].
## Drafts и localization
У `MessageContext` есть:
+13
@@ -111,6 +111,19 @@ This is useful for:
- generated summaries
- reports with an action button at the end
## Rich messages
Use `RichAnswer(...)` and `RichAnswerKeyboard(...)` to send structured Bot API 10.1 rich messages built from `tgfmt` fragments:
```go
ctx.RichAnswer(
tgfmt.H1(tgfmt.NewRich("Report")),
tgfmt.P(tgfmt.NewRich("all systems go").Bold()),
)
```
See [[Rich-Messages]] for the full DSL and the receive side.
## Markdown helpers
Use:
+96
@@ -0,0 +1,96 @@
# Rich-сообщения
Rich-сообщения — механизм Bot API 10.1 для сильно структурированного текста: заголовки, абзацы, списки с чекбоксами, таблицы, медиа-коллажи, раскрывающиеся секции, математика и другое. Laniakea поддерживает их целиком: типизированный HTML-DSL для отправки, типизированные wire-структуры для приёма и стриминг черновиков.
## Ключевая асимметрия
Telegram сделал отправку и приём намеренно разными, и Laniakea повторяет это разделение:
- **Отправляется разметка, а не дерево.** Единственный способ отправить rich-сообщение — `tgapi.InputRichMessage` со строкой `HTML` или `Markdown`. API для отправки дерева блоков не существует.
- **Принимается дерево, а не разметка.** Входящее rich-сообщение приходит как `Message.RichMessage` — полностью типизированное дерево узлов `RichBlock` и `RichText`, собранное сервером.
Поэтому в Laniakea:
- **out**-сторона живёт в `tgfmt` (`rich.go`): типизированные HTML-фрагменты и конструкторы с чистыми именами (`P`, `H1`, `Bold`, `Photo`, ...);
- **in**-сторона живёт в `tgapi` (`richtext.go`, `richblock.go`): wire-типы с именами официальных объектов API (`RichBlockParagraph` — это `RichBlockWrap{Tag: "paragraph"}`, `RichTextBold``RichTextWrap{Tag: "bold"}` и т.д.) плюс `UnmarshalRichMessage`.
## Отправка из обработчика
`MessageContext.RichAnswer(...)` принимает фрагменты `tgfmt` и отправляет их через `sendRichMessage`:
```go
import "git.scuroneko.dev/scuroneko/laniakea/tgfmt"
func report(ctx *laniakea.MessageContext, _ struct{}) error {
ctx.RichAnswer(
tgfmt.H1(tgfmt.NewRich("Дневной отчёт")),
tgfmt.P(
tgfmt.NewRich("Статус: "),
tgfmt.NewRich("всё работает").Bold(),
),
tgfmt.Ul(
tgfmt.LiCheckbox(true, tgfmt.NewRich("бэкапы")),
tgfmt.LiCheckbox(false, tgfmt.NewRich("миграция")),
),
)
return nil
}
```
`RichAnswerKeyboard(kb, items...)` прикрепляет inline-клавиатуру.
## DSL в `tgfmt`
Два типа фрагментов превращают невалидную вложенность в ошибку компиляции:
- `tgfmt.Rich` — inline-содержимое (жирный, ссылки, эмодзи, время, математика, ...);
- `tgfmt.RichBlock` — блочное содержимое (заголовки, абзацы, списки, таблицы, медиа, ...).
Блочные конструкторы принимают только `Rich`-аргументы, поэтому таблица внутри абзаца не скомпилируется. На *верхнем уровне* допустимы оба (`RichItem`): соседний inline-контент Telegram сам собирает в абзацы.
Сырой текст попадает в DSL ровно одним способом — `tgfmt.NewRich("...")`, который экранирует HTML. Всё остальное — композиция уже безопасных фрагментов.
Inline-хелперы (методы `Rich`): `Bold`, `Italic`, `Underline`, `Strike`, `Code`, `Mark`, `Sub`, `Sup`, `Spoiler`, `Link`, `Email`, `Phone`, `Mention`, `Anchor`, `AnchorLink`, `Ref`, `Time`, `TimeFormat`, `Math`; свободные функции `Emoji`, `Br`.
Блочные конструкторы: `H1``H6`, `P`, `Pre`, `PreCode`, `Footer`, `Hr`, `AnchorBlock`, `Ul`/`Ol` с `Li`/`LiCheckbox`, `Blockquote`, `Aside`, `Photo`/`Video`/`Audio` (медиа-билдеры с `.Block()`, `.Caption(...)`, `.SetSpoiler()`), `Map`/`MapCaption`, `Collage`/`Slideshow` (+варианты `...Caption`), `Table`/`Row`/`Cell`, `Details`, `MathBlock`.
Чтобы собрать payload без отправки, используйте `tgfmt.RichHTML(items...)` (строка) или `tgfmt.RichMessage(items...)` (готовый `tgapi.InputRichMessage` с включённым `skip_entity_detection`, чтобы сервер не добавлял авто-entities).
## Прямой доступ через `tgapi`
- `API.SendRichMessage(params)` — отправка; `params.RichMessage` — это `InputRichMessage`.
- `API.SendRichMessageDraft(params)` — стриминг частичного сообщения во время генерации. Черновик — эфемерное превью на ~30 секунд с ключом `DraftID` (обновления с тем же ID анимируются); в конце вызовите `SendRichMessage` с полным сообщением. Черновики — единственное место, где встречается блок `thinking`.
- `EditMessageText.RichMessage` — редактирование rich-сообщения на месте (`Text` оставьте пустым).
- `InputRichMessageContent` — rich-контент для результатов inline-запросов (`input_message_content`).
## Приём
`Message.RichMessage` (`*tgapi.RichMessage`) разбирается автоматически. Обходите его type switch-ами:
```go
for _, block := range msg.RichMessage.Blocks {
switch b := block.(type) {
case tgapi.RichBlockWrap: // paragraph, footer, thinking
handleText(b.Text)
case tgapi.RichBlockList:
for _, item := range b.Items {
// item.Label — готовый видимый маркер: "1.", "c.", "vii.", "•"
}
}
}
```
Неизвестные будущие типы узлов с полем `text` сохраняются как `RichTextWrap`/`RichBlockWrap`, а не роняют разбор — парсинг переживает расширения API.
## Подводные камни
- **Медиа — только по URL.** В HTML-режиме `<img>`/`<video>`/`<audio>` принимают только HTTP(S)-URL; `file_id` не работает. Сигнатуры DSL (`Photo(url string)`) делают это явным.
- **Маркеры списков рендерит сервер.** Отправляйте семантику `<ol type start>`/`<li value>` через `OlOpts`/`SetValue`/`SetType`; видимый маркер («c.», «vii.») возвращается уже вычисленным в `RichBlockListItem.Label`. Не генерируйте маркеры сами.
- **Один тег `<video>`/`<audio>` — два типа блоков.** Сервер различает видео/анимацию и аудио/голосовое по расширению URL (`.gif` → animation, `.ogg` → voice note).
- **`skip_entity_detection`** в `tgfmt.RichMessage(...)` по умолчанию `true`; без него сервер добавит авто-entities, и принятое дерево разойдётся с отправленным.
## Что читать дальше
- [[MessageContext]] — остальные вспомогательные методы ответа.
- [[tgapi-Overview]] — низкоуровневый клиент, на котором живут rich-методы.
- [[Drafts]] — поэтапная сборка обычных (не rich) сообщений.
+96
@@ -0,0 +1,96 @@
# Rich Messages
Rich messages are the Bot API 10.1 feature for highly structured text: headings, paragraphs, lists with checkboxes, tables, media collages, expandable sections, math, and more. Laniakea supports them end to end: a typed HTML DSL for sending, typed wire structures for receiving, and streaming drafts.
## The core asymmetry
Telegram made sending and receiving deliberately different, and Laniakea mirrors that split:
- **You send markup, not trees.** The only way to send a rich message is `tgapi.InputRichMessage` with an `HTML` or `Markdown` string. There is no API to submit a block tree.
- **You receive a tree, not markup.** An incoming rich message arrives as `Message.RichMessage` — a fully typed tree of `RichBlock` and `RichText` nodes rendered by the server.
So in Laniakea:
- the **out** side lives in `tgfmt` (`rich.go`): typed HTML fragments and constructors with clean names (`P`, `H1`, `Bold`, `Photo`, ...);
- the **in** side lives in `tgapi` (`richtext.go`, `richblock.go`): wire types named after the official API objects (`RichBlockParagraph` is `RichBlockWrap{Tag: "paragraph"}`, `RichTextBold` is `RichTextWrap{Tag: "bold"}`, and so on) plus `UnmarshalRichMessage`.
## Sending from a handler
`MessageContext.RichAnswer(...)` accepts `tgfmt` fragments and sends them through `sendRichMessage`:
```go
import "git.scuroneko.dev/scuroneko/laniakea/tgfmt"
func report(ctx *laniakea.MessageContext, _ struct{}) error {
ctx.RichAnswer(
tgfmt.H1(tgfmt.NewRich("Daily report")),
tgfmt.P(
tgfmt.NewRich("Status: "),
tgfmt.NewRich("all systems go").Bold(),
),
tgfmt.Ul(
tgfmt.LiCheckbox(true, tgfmt.NewRich("backups")),
tgfmt.LiCheckbox(false, tgfmt.NewRich("migration")),
),
)
return nil
}
```
`RichAnswerKeyboard(kb, items...)` attaches an inline keyboard.
## The `tgfmt` DSL
Two fragment types make invalid nesting a compile error:
- `tgfmt.Rich` — inline content (bold, links, emoji, time, math, ...);
- `tgfmt.RichBlock` — block content (headings, paragraphs, lists, tables, media, ...).
Block constructors accept only `Rich` arguments, so a table inside a paragraph does not compile. At the *top level* both are allowed (`RichItem`): Telegram merges adjacent inline content into paragraphs.
Raw text enters the DSL exactly one way — `tgfmt.NewRich("...")` — which HTML-escapes it. Everything else composes already-safe fragments.
Inline helpers (methods on `Rich`): `Bold`, `Italic`, `Underline`, `Strike`, `Code`, `Mark`, `Sub`, `Sup`, `Spoiler`, `Link`, `Email`, `Phone`, `Mention`, `Anchor`, `AnchorLink`, `Ref`, `Time`, `TimeFormat`, `Math`; free functions `Emoji`, `Br`.
Block constructors: `H1``H6`, `P`, `Pre`, `PreCode`, `Footer`, `Hr`, `AnchorBlock`, `Ul`/`Ol` with `Li`/`LiCheckbox`, `Blockquote`, `Aside`, `Photo`/`Video`/`Audio` (media builders with `.Block()`, `.Caption(...)`, `.SetSpoiler()`), `Map`/`MapCaption`, `Collage`/`Slideshow` (+`...Caption` variants), `Table`/`Row`/`Cell`, `Details`, `MathBlock`.
To build the payload without sending, use `tgfmt.RichHTML(items...)` (plain string) or `tgfmt.RichMessage(items...)` (a ready `tgapi.InputRichMessage` with `skip_entity_detection` enabled so the server does not inject auto-detected entities).
## Direct `tgapi` access
- `API.SendRichMessage(params)` — send; `params.RichMessage` is the `InputRichMessage`.
- `API.SendRichMessageDraft(params)` — stream a partial message while it is being generated. The draft is an ephemeral ~30-second preview keyed by `DraftID` (updates with the same ID animate); finish by calling `SendRichMessage` with the complete message. Drafts are the only place the `thinking` block appears.
- `EditMessageText.RichMessage` — edit a rich message in place (leave `Text` empty).
- `InputRichMessageContent` — rich content for inline query results (`input_message_content`).
## Receiving
`Message.RichMessage` (`*tgapi.RichMessage`) is parsed automatically. Walk it with type switches:
```go
for _, block := range msg.RichMessage.Blocks {
switch b := block.(type) {
case tgapi.RichBlockWrap: // paragraph, footer, thinking
handleText(b.Text)
case tgapi.RichBlockList:
for _, item := range b.Items {
// item.Label is the ready-made visible marker: "1.", "c.", "vii.", "•"
}
}
}
```
Unknown future node types that carry a `text` field are preserved as `RichTextWrap`/`RichBlockWrap` instead of failing, so parsing survives API additions.
## Pitfalls
- **Media is URL-only.** In HTML mode `<img>`/`<video>`/`<audio>` accept only HTTP(S) URLs; `file_id` does not work. The DSL signatures (`Photo(url string)`) make this explicit.
- **List markers are server-rendered.** Send `<ol type start>`/`<li value>` semantics via `OlOpts`/`SetValue`/`SetType`; the visible label ("c.", "vii.") comes back computed in `RichBlockListItem.Label`. Do not generate labels yourself.
- **One `<video>`/`<audio>` tag, two block types.** The server distinguishes video vs animation and audio vs voice note by the URL extension (`.gif` → animation, `.ogg` → voice note).
- **`skip_entity_detection`** defaults to `true` in `tgfmt.RichMessage(...)`; without it the server adds auto-detected entities and the received tree diverges from what you sent.
## Where to go next
- [[MessageContext]] — the rest of the reply helpers.
- [[tgapi-Overview]] — the low-level client the rich methods live on.
- [[Drafts]] — staged assembly of regular (non-rich) messages.
+1
@@ -23,6 +23,7 @@
- [[Inline-Keyboards-and-Payloads]]
- [[Auto-Generated-Commands]]
- [[Drafts]]
- [[Rich-Messages]]
- [[Localization]]
- [[Rate-Limiting]]
- [[tgapi-Overview]]