Rich messages¶
Bot API 10.1 added rich messages: block-level structure (headings, paragraphs,
code blocks, tables, collapsibles, math) that plain messages cannot express.
send_rich_message is one more Bot method; build its content with
RichMessage plus the inline helpers in moonlygram.rich.
from moonlygram import RichMessage
from moonlygram.rich import bold, link
msg = (
RichMessage()
.heading("Release notes")
.paragraph("Shipped ", bold("rich messages"), ", see ", link("the docs", "https://example.com"))
.code_block("pip install -U moonlygram", language="bash")
)
await bot.send_rich_message(chat_id, html=msg)
Everything is HTML-escaped as it is added, so plain strings are always safe to
pass; use raw() for an HTML fragment you have built yourself.
From Markdown¶
markdown_to_rich converts an existing Markdown string to rich-message HTML:
from moonlygram import markdown_to_rich
await bot.send_rich_message(chat_id, html=markdown_to_rich("# Hi\n\nSome **bold** text."))
Structured blocks¶
Bot API 10.2 adds a third content form. Instead of an HTML or Markdown string,
describe the message as a list of block objects and pass them as blocks. The
block types live in moonlygram.rich.
from moonlygram.rich import (
InputRichBlockList,
InputRichBlockListItem,
InputRichBlockParagraph,
InputRichBlockSectionHeading,
)
await bot.send_rich_message(
chat_id,
blocks=[
InputRichBlockSectionHeading("Release notes", size=1),
InputRichBlockParagraph("Two things shipped:"),
InputRichBlockList(
items=[
InputRichBlockListItem(blocks=[InputRichBlockParagraph("Structured blocks")]),
InputRichBlockListItem(blocks=[InputRichBlockParagraph("Ephemeral messages")]),
]
),
],
)
Pass exactly one of html, markdown, or blocks. When html or markdown
text references a file through a tg://photo?id=, tg://video?id=,
tg://audio?id=, or tg://document?id= link, supply those files with media,
a list of InputRichMessageMedia. Each entry takes an InputFile to upload a
new file, or a file_id / URL string.
Buttons¶
InputRichBlockButtons puts a row of 1-8 buttons in the message body. Each
RichMessageButton sets exactly one action, and style is "primary",
"success", "danger", or "link". A button carrying DisabledButton() is
shown but does nothing.
from moonlygram import DisabledButton
from moonlygram.rich import InputRichBlockButtons, RichMessageButton
InputRichBlockButtons(
[
RichMessageButton("Read more", url="https://example.com", style="link"),
RichMessageButton("Subscribe", callback_data="sub", style="primary"),
RichMessageButton("Sold out", disabled=DisabledButton()),
],
align="center",
)
The same disabled field works on ordinary keyboards through
InlineKeyboardButton, and both InlineKeyboardMarkup and
ReplyKeyboardMarkup accept force_reply to show the reply interface
alongside the keyboard.
Streaming¶
send_rich_message_draft updates an ephemeral draft repeatedly (about a 30s
TTL); send the final version with send_rich_message. In a blocks draft,
InputRichBlockThinking renders a "thinking" placeholder for content that has
not arrived yet; it is valid only in drafts.
Drafts reach private chats only. Sending one to a group is rejected with
TEXTDRAFT_PEER_INVALID, which the Bot API does not document.
Pass can_stop=True to give the reader a control that stops the generation.
Pressing it produces a stopped_message_generation update, which
MessageGenerationStoppedHandler receives; the update names the draft with
draft_id. keep_on_stop=True leaves the text written so far in place.
from moonlygram.ext import MessageGenerationStoppedHandler
async def stopped(update, context):
drafts.cancel(update.stopped_message_generation.draft_id)
app.add_handler(MessageGenerationStoppedHandler(stopped))
Receiving¶
An incoming rich message arrives on Message.rich_message as a
RichMessageContent: is_rtl, plus blocks, a list of RichBlock. Each block
names its variant in type and carries that variant's fields.
async def on_rich(update, context):
rich = update.effective_message.rich_message
if rich is None:
return
for block in rich.blocks:
if block.type == "section_heading":
print(block.size, block.text)
elif block.type == "table":
print(len(block.cells), "rows", "compact" if block.is_compact else "")
Rich text is whatever the payload holds: a plain str, a list mixing strings
and nodes, or a single RichTextNode naming its own type ("bold", "code",
"custom_emoji", and so on). The RichTextValue alias covers all three.
from moonlygram import RichTextNode
def plain(value) -> str:
"""Flatten received rich text down to its characters."""
if isinstance(value, str):
return value
if isinstance(value, list):
return "".join(plain(v) for v in value)
return plain(value.text) if isinstance(value, RichTextNode) else ""
The spec calls these two types RichMessage and RichText. Both names are
already taken here by the send side — the builder and the loose input alias —
so the received classes are RichMessageContent and RichTextNode.
Editing¶
edit_rich_message_text replaces the text of an already-sent message with
rich content. It takes the same html / markdown / blocks / media forms
as send_rich_message:
await bot.edit_rich_message_text(
chat_id=chat_id, message_id=message_id, markdown="All **done**."
)
See the rich API reference for every block and inline helper.