REPOSITORY / ScuroNeko/Laniakea
Wiki
(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>
@@ -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` есть:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]]
|
||||
|
||||
Reference in New Issue
Block a user