update examples for ktgbotapi 37.0.0

This commit is contained in:
2026-08-30 17:44:15 +06:00
parent ce48393895
commit 447de0c3ce
19 changed files with 603 additions and 138 deletions

View File

@@ -3,11 +3,13 @@
RichMessagesBot is a long-polling showcase of Telegram rich messages. It sends
rich content from HTML, Markdown, and the typed `InputRichMessageBlocks` DSL;
streams drafts; edits rich text; handles incoming rich messages; and supplies
rich content from inline and guest queries.
rich content from inline and guest queries. Bot API 10.3 coverage includes rich
buttons, compact tables, expandable quotations, document blocks, document
references, direct document uploads, and stoppable drafts.
## Commands
The bot installs seven commands in Telegram's default command menu. Three
The bot installs eight commands in Telegram's default command menu. Three
additional handlers can be invoked by typing their commands manually.
| Command | In menu | Demonstration |
@@ -18,21 +20,24 @@ additional handlers can be invoked by typing their commands manually.
| `/rich_markdown_blocks` | No | Sends and logs the full fixture as a typed block tree, including first-class media blocks, captions, collages, and slideshows. |
| `/rich_markdown_medialess_blocks` | No | Sends and logs the same typed block tree without its media section. |
| `/rich_blocks` | Yes | Sends a smaller, directly constructed block tree with headings, formatted paragraphs, ordered and unordered checkbox lists, a divider, preformatted Kotlin, and a quotation. |
| `/rich_draft` | Yes | Streams three Markdown revisions under draft ID `1`, one second apart, then sends a normal final rich message. |
| `/rich_blocks_draft` | Yes | Streams two draft-only `thinking()` blocks under draft ID `2`, one second apart, then sends a normal typed-block answer. |
| `/rich_10_3` | Yes | Sends an inline `RichTextButton`, three aligned `InputRichBlockButtons` rows with URL, callback, inline-query, copy-text, and disabled actions, a compact table, an expandable quotation, and a document block. |
| `/rich_draft` | Yes | In a private chat, streams three Markdown revisions with `canStop` and `keepOnStop`, one second apart, then sends a normal final rich message unless generation is stopped. |
| `/rich_blocks_draft` | Yes | In a private chat, streams two draft-only `thinking()` blocks with `canStop` and `keepOnStop`, then sends a normal typed-block answer unless generation is stopped. |
| `/rich_edit` | Yes | Sends a Markdown rich message, waits two seconds, and replaces its rich content with `EditChatMessageRichText`. |
| `/wait_rich` | Yes | Prompts for a rich message, waits for the next matching content, and reports its block count. |
The two draft examples finalize by sending a new normal rich message; they do not
turn the draft itself into the final message. They use distinct fixed IDs (`1`
and `2`); concurrent runs of the same command in one chat reuse that command's
ID.
The two draft examples allocate a unique draft ID and subscribe to
`waitMessageGenerationStopped` before sending the first revision. They finalize
by sending a new normal rich message only if no matching stop update arrives;
when stopped, they leave the retained draft untouched and log the event.
## Other triggers
| Trigger | Behavior |
| --- | --- |
| Any photo | Reuses the received Telegram file ID without downloading it. The bot first sends HTML whose `tg://photo?id=userphoto` reference is resolved by `InputRichMessageMedia`, then sends the same photo as a typed `photo()` block. |
| Any document | Reuses the received file ID through a `tg://document?id=userdocument` HTML reference, then downloads the file and sends it again as a multipart upload nested in a typed `document()` block. Large documents are therefore held in memory. |
| A `rich_10_3_callback` rich button | Answers the callback with a notification confirming that the rich-message button was received. |
| Any incoming rich message | Logs right-to-left state and every parsed block, replies with the block count, and resends the rich message with `createResend`. The `onlyRichMessageContentMessages` flow also logs its block count. |
| Any inline query | Returns uncached HTML and Markdown articles whose selected messages use `InputRichMessageContent`. |
| A text guest request containing `/rich_guest` | Returns one inline article containing the full Markdown fixture. This is a substring check, not a registered bot command. Non-text guest requests and text without that exact case-sensitive substring are ignored by this handler. |
@@ -50,9 +55,9 @@ Telegram file ID and assigning an alias for a `tg://photo?id=...` reference.
The source also shows the two library shapes used for rich media: an
`InputRichMessageMedia` mapping for markup references and first-class photo,
video, audio, voice-note, animation, collage, and slideshow blocks. The library
can collect multipart files nested in a rich-message tree as `attach://` uploads,
although this example's running handlers use URLs or an existing file ID.
video, audio, voice-note, animation, document, collage, and slideshow blocks.
The document trigger demonstrates the library collecting a multipart file nested
in a rich-message tree as an `attach://` upload.
## Telegram setup and permissions
@@ -60,7 +65,7 @@ although this example's running handlers use URLs or an existing file ID.
control.
2. Start a private chat with the bot, or add it to a chat and allow it to send
messages and media.
3. To test ordinary photo and rich-message triggers in a group, ensure Telegram
3. To test ordinary photo, document, and rich-message triggers in a group, ensure Telegram
delivers non-command messages to the bot, for example by disabling Group
Privacy Mode or making the bot an administrator.
4. Enable Inline Mode in BotFather to exercise the inline-query results.

View File

@@ -5,14 +5,18 @@ import dev.inmo.kslog.common.setDefaultKSLog
import dev.inmo.micro_utils.coroutines.subscribeLoggingDropExceptions
import dev.inmo.tgbotapi.extensions.api.answers.answer
import dev.inmo.tgbotapi.extensions.api.bot.setMyCommands
import dev.inmo.tgbotapi.extensions.api.files.downloadFile
import dev.inmo.tgbotapi.extensions.api.send.reply
import dev.inmo.tgbotapi.extensions.api.send.sendRichMessage
import dev.inmo.tgbotapi.extensions.api.send.sendRichMessageDraft
import dev.inmo.tgbotapi.extensions.behaviour_builder.expectations.waitMessageGenerationStopped
import dev.inmo.tgbotapi.extensions.behaviour_builder.expectations.waitRichMessage
import dev.inmo.tgbotapi.extensions.behaviour_builder.telegramBotWithBehaviourAndLongPolling
import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onBaseInlineQuery
import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onCommand
import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onDocument
import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onGuestRequestMessage
import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onMessageDataCallbackQuery
import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onPhoto
import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onRichMessage
import dev.inmo.tgbotapi.extensions.utils.baseSentMessageUpdateOrNull
@@ -21,16 +25,19 @@ import dev.inmo.tgbotapi.extensions.utils.onlyRichMessageContentMessages
import dev.inmo.tgbotapi.extensions.utils.withContentOrNull
import dev.inmo.tgbotapi.requests.edit.text.EditChatMessageRichText
import dev.inmo.tgbotapi.requests.abstracts.InputFile
import dev.inmo.tgbotapi.requests.abstracts.asMultipartFile
import dev.inmo.tgbotapi.types.BotCommand
import dev.inmo.tgbotapi.types.CustomEmojiId
import dev.inmo.tgbotapi.types.InlineQueries.InlineQueryResult.InlineQueryResultArticle
import dev.inmo.tgbotapi.types.InlineQueries.InputMessageContent.InputRichMessageContent
import dev.inmo.tgbotapi.types.InlineQueryId
import dev.inmo.tgbotapi.types.TelegramDate
import dev.inmo.tgbotapi.types.chat.PrivateChat
import dev.inmo.tgbotapi.types.message.content.TextContent
import dev.inmo.tgbotapi.types.message.textsources.BotCommandTextSource
import dev.inmo.tgbotapi.types.media.TelegramMediaAnimation
import dev.inmo.tgbotapi.types.media.TelegramMediaAudio
import dev.inmo.tgbotapi.types.media.TelegramMediaDocument
import dev.inmo.tgbotapi.types.media.TelegramMediaPhoto
import dev.inmo.tgbotapi.types.media.TelegramMediaVideo
import dev.inmo.tgbotapi.types.media.TelegramMediaVoiceNote
@@ -40,19 +47,31 @@ import dev.inmo.tgbotapi.types.rich.InputRichMessageHTML
import dev.inmo.tgbotapi.types.rich.InputRichMessageMarkdown
import dev.inmo.tgbotapi.types.rich.InputRichMessageMedia
import dev.inmo.tgbotapi.types.rich.RichBlockCaption
import dev.inmo.tgbotapi.types.rich.RichBlockButtonAlignment
import dev.inmo.tgbotapi.types.rich.RichBlockTableCellAlign
import dev.inmo.tgbotapi.types.rich.RichBlockTableCellVAlign
import dev.inmo.tgbotapi.types.rich.RichMessageButton
import dev.inmo.tgbotapi.types.rich.RichMessageButtonStyle
import dev.inmo.tgbotapi.types.rich.RichTextPlain
import dev.inmo.tgbotapi.types.rich.buildRichText
import dev.inmo.tgbotapi.types.buttons.InlineKeyboardButtons.CopyTextButtonData
import dev.inmo.tgbotapi.types.buttons.InlineKeyboardButtons.SwitchInlineQueryChosenChat
import dev.inmo.tgbotapi.types.toChatId
import dev.inmo.tgbotapi.utils.DraftIdAllocator
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.withTimeoutOrNull
private val richDraftIds = DraftIdAllocator()
/**
* Runs a long-polling showcase of the rich-message APIs introduced in Telegram Bot API 10.1 and 10.2.
* Runs a long-polling showcase of the rich-message APIs introduced in Telegram Bot API 10.1 through 10.3.
*
* Outgoing [dev.inmo.tgbotapi.types.rich.InputRichMessage] values use one of three representations:
* [InputRichMessageHTML], [InputRichMessageMarkdown], or a typed [InputRichMessageBlocks] tree of
@@ -60,7 +79,9 @@ import kotlinx.coroutines.flow.mapNotNull
* [sendRichMessageDraft] revisions sharing a draft ID (including draft-only `thinking()` blocks), and edits
* through [EditChatMessageRichText]. Media is shown both as [InputRichMessageMedia] references such as
* `tg://photo?id=...` and as typed blocks; [dev.inmo.tgbotapi.requests.send.SendRichMessage] also turns
* multipart files inside an input tree into `attach://` uploads.
* multipart files inside an input tree into `attach://` uploads. Bot API 10.3 button rows, inline rich-text buttons,
* compact tables, expandable quotations and document blocks are demonstrated by `/rich_10_3` and the document
* trigger.
*
* Incoming [dev.inmo.tgbotapi.types.message.content.RichMessageContent] and user-selected content covers
* [onRichMessage], [waitRichMessage], [onlyRichMessageContentMessages], photo reuse, and
@@ -692,20 +713,40 @@ suspend fun main(vararg args: String) {
// sendRichMessageDraft: stream partial rich messages sharing one draftId, then finalize
// with a full sendRichMessage. Emulates streaming of an AI-generated reply.
onCommand("rich_draft") {
val chatId = it.chat.id.toChatId()
val draftId = 1L
onCommand("rich_draft", initialFilter = { it.chat is PrivateChat }) { origin ->
val chatId = origin.chat.id.toChatId()
val draftId = richDraftIds.allocate()
val stoppedUpdate = async(start = CoroutineStart.UNDISPATCHED) {
waitMessageGenerationStopped()
.filter { it.chat.id == origin.chat.id && it.draftId == draftId }
.first()
}
val parts = listOf(
"Thinking",
"Thinking about *rich* messages",
"Thinking about *rich* messages and how to _stream_ them"
)
parts.forEach { part ->
sendRichMessageDraft(chatId, draftId, InputRichMessageMarkdown(part))
delay(1000)
try {
parts.forEach { part ->
sendRichMessageDraft(
chatId,
draftId.long,
InputRichMessageMarkdown(part),
canStop = true,
keepOnStop = true,
)
val stopped = withTimeoutOrNull(1000L) { stoppedUpdate.await() }
if (stopped != null) {
println("Stopped rich draft ${stopped.draftId.long} in ${stopped.chat.id}")
return@onCommand
}
}
// Finalize only if the user did not stop generation; a normal message removes a retained draft.
sendRichMessage(chatId, InputRichMessageMarkdown("Done! Here is the *final* rich message."))
} finally {
stoppedUpdate.cancel()
richDraftIds.free(draftId)
}
// finalize the streamed draft with the real message
sendRichMessage(chatId, InputRichMessageMarkdown("Done! Here is the *final* rich message."))
}
// EditChatMessageRichText: send a rich message, then edit it with new rich content
@@ -772,29 +813,148 @@ suspend fun main(vararg args: String) {
)
}
// sendRichMessageDraft with blocks: the thinking() block is only valid inside a draft and is used
// to stream a model's reasoning before the finalized rich message is sent via sendRichMessage.
onCommand("rich_blocks_draft") {
val chatId = it.chat.id.toChatId()
val draftId = 2L
listOf("Analyzing your request", "Composing a structured answer").forEach { step ->
sendRichMessageDraft(
chatId,
draftId,
InputRichMessageBlocks { thinking(step) }
)
delay(1000)
}
// finalize the streamed draft with the real (non-thinking) blocks
// === Bot API 10.3 additions: buttons, compact tables, expandable quotes and documents ===
onCommand("rich_10_3") {
val callbackButton = RichMessageButton.CallbackData(
RichTextPlain("Callback"),
callbackData = "rich_10_3_callback",
style = RichMessageButtonStyle.Link,
)
sendRichMessage(
chatId,
it.chat.id,
InputRichMessageBlocks {
heading("Answer", level = 2)
paragraph("Here is the finalized, structured reply.")
h1("Bot API 10.3 rich blocks")
paragraph {
plain("A RichTextButton can live inline with text: ")
button(
RichMessageButton.CopyText(
RichTextPlain("copy 42"),
CopyTextButtonData("42"),
style = RichMessageButtonStyle.Primary,
)
)
}
expandableBlockQuotation(credit = RichTextPlain("Expandable quotation credit")) {
plain("This quotation starts collapsed and can be expanded by the reader. ")
dateTime("Bot API 10.3", TelegramDate(1787518800L), "d MMMM yyyy")
}
table(
isBordered = true,
isStriped = true,
isCompact = true,
caption = RichTextPlain("A compact table"),
) {
row {
headerCell(RichBlockTableCellAlign.Left, RichBlockTableCellVAlign.Middle) { plain("Feature") }
headerCell(RichBlockTableCellAlign.Right, RichBlockTableCellVAlign.Middle) { plain("Version") }
}
row {
cell(RichBlockTableCellAlign.Left, RichBlockTableCellVAlign.Middle) { plain("Compact tables") }
cell(RichBlockTableCellAlign.Right, RichBlockTableCellVAlign.Middle) { plain("10.3") }
}
}
buttons(
listOf(
RichMessageButton.Url(
RichTextPlain("Telegram"),
"https://telegram.org",
RichMessageButtonStyle.Primary,
),
callbackButton,
),
align = RichBlockButtonAlignment.Left,
)
buttons(
listOf(
RichMessageButton.SwitchInlineQuery(
RichTextPlain("Choose chat"),
"rich 10.3",
RichMessageButtonStyle.Success,
),
RichMessageButton.SwitchInlineQueryCurrentChat(
RichTextPlain("Current chat"),
"rich 10.3",
),
RichMessageButton.SwitchInlineQueryChosenChat(
RichTextPlain("Groups only"),
SwitchInlineQueryChosenChat(
query = "rich 10.3",
allowGroups = true,
),
),
),
align = RichBlockButtonAlignment.Center,
)
buttons(
listOf(
RichMessageButton.CopyText(
RichTextPlain("Copy value"),
CopyTextButtonData("Bot API 10.3"),
),
RichMessageButton.Disabled(
RichTextPlain("Disabled"),
RichMessageButtonStyle.Danger,
),
),
align = RichBlockButtonAlignment.Right,
)
document(
TelegramMediaDocument(
InputFile.fromUrl("https://telegram.org/example/document.pdf")
),
RichBlockCaption(RichTextPlain("A general-file document block")),
)
}
)
}
onMessageDataCallbackQuery(Regex("rich_10_3_callback")) { query ->
answer(query, "Rich-message callback received")
}
// sendRichMessageDraft with blocks: the thinking() block is only valid inside a draft and is used
// to stream a model's reasoning before the finalized rich message is sent via sendRichMessage.
onCommand("rich_blocks_draft", initialFilter = { it.chat is PrivateChat }) { origin ->
val chatId = origin.chat.id.toChatId()
val draftId = richDraftIds.allocate()
val stoppedUpdate = async(start = CoroutineStart.UNDISPATCHED) {
waitMessageGenerationStopped()
.filter { it.chat.id == origin.chat.id && it.draftId == draftId }
.first()
}
try {
listOf("Analyzing your request", "Composing a structured answer").forEach { step ->
sendRichMessageDraft(
chatId,
draftId.long,
InputRichMessageBlocks { thinking(step) },
canStop = true,
keepOnStop = true,
)
val stopped = withTimeoutOrNull(1000L) { stoppedUpdate.await() }
if (stopped != null) {
println("Stopped rich block draft ${stopped.draftId.long} in ${stopped.chat.id}")
return@onCommand
}
}
// Finalize only if the user did not stop generation; a normal message removes a retained draft.
sendRichMessage(
chatId,
InputRichMessageBlocks {
heading("Answer", level = 2)
paragraph("Here is the finalized, structured reply.")
}
)
} finally {
stoppedUpdate.cancel()
richDraftIds.free(draftId)
}
}
// Rich message media: send me a photo and it gets embedded into a rich message two ways.
onPhoto { message ->
// reuse the received file by its fileId (no upload). To upload a brand-new file instead,
@@ -828,6 +988,40 @@ suspend fun main(vararg args: String) {
)
}
// A received document demonstrates both tg://document?id= references and a direct multipart upload.
onDocument { message ->
val reusedDocument = TelegramMediaDocument(message.content.media.fileId)
sendRichMessage(
message.chat.id,
InputRichMessageHTML(
"""
<h2>Your document, referenced from HTML</h2>
<tg-document src="tg://document?id=userdocument"></tg-document>
""".trimIndent(),
media = listOf(
InputRichMessageMedia(id = "userdocument", media = reusedDocument)
)
)
)
val uploadedDocument = TelegramMediaDocument(
downloadFile(message.content).asMultipartFile(
message.content.media.fileName ?: "document.bin"
)
)
sendRichMessage(
message.chat.id,
InputRichMessageBlocks {
h2("Your document, uploaded as a new file")
paragraph("SendRichMessage collects the MultipartFile nested in this document block.")
document(
uploadedDocument,
RichBlockCaption(RichTextPlain("Direct attach:// document upload")),
)
}
)
}
// waitRichMessage expectation: wait for the user to send a rich message
onCommand("wait_rich") {
reply(it, "Send me a rich message now")
@@ -913,6 +1107,7 @@ suspend fun main(vararg args: String) {
BotCommand("rich_html", "Send a rich message described with HTML"),
BotCommand("rich_markdown", "Send a rich message described with Markdown"),
BotCommand("rich_blocks", "Send a rich message built from the InputRichBlocks DSL"),
BotCommand("rich_10_3", "Show Bot API 10.3 rich blocks and buttons"),
BotCommand("rich_draft", "Stream a rich message draft, then finalize it"),
BotCommand("rich_blocks_draft", "Stream a blocks draft with thinking(), then finalize it"),
BotCommand("rich_edit", "Send a rich message and edit it with new rich content"),