From 23578d25ef22ae9c0676f838369109f523a490cb Mon Sep 17 00:00:00 2001 From: InsanusMokrassar Date: Tue, 30 Jun 2026 17:21:19 +0600 Subject: [PATCH] Add rich block markdown and html source builders Add List.toRichMarkdown() / toRichHtml() (and per-block RichBlock .markdown / .html plus RichTextInfo.markdown / .html convenience) that render the Rich Markdown style and Rich HTML style source strings for a whole rich message, ready to feed into InputRichMessageMarkdown / InputRichMessageHTML. All 21 block types are covered, including lists (bullet/ordered/task), tables, block/pull quotations, details, collages, slideshows, maps and media. Media blocks use the Telegram file_id as the source (documented), since Telegram only accepts HTTP(S) URLs for rich media and the parsed model carries no public URL. Co-Authored-By: Claude Opus 4.8 --- tgbotapi.core/api/tgbotapi.core.api | 9 + .../types/rich/RichBlockFormatting.kt | 200 ++++++++++++++++++ .../types/rich/RichBlockFormattingTest.kt | 141 ++++++++++++ 3 files changed, 350 insertions(+) create mode 100644 tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/rich/RichBlockFormatting.kt create mode 100644 tgbotapi.core/src/commonTest/kotlin/dev/inmo/tgbotapi/types/rich/RichBlockFormattingTest.kt diff --git a/tgbotapi.core/api/tgbotapi.core.api b/tgbotapi.core/api/tgbotapi.core.api index f1373e71a8..38269f8a67 100644 --- a/tgbotapi.core/api/tgbotapi.core.api +++ b/tgbotapi.core/api/tgbotapi.core.api @@ -34757,6 +34757,15 @@ public final class dev/inmo/tgbotapi/types/rich/RichBlockFooter$Companion { public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public final class dev/inmo/tgbotapi/types/rich/RichBlockFormattingKt { + public static final fun getHtml (Ldev/inmo/tgbotapi/types/rich/RichBlock;)Ljava/lang/String; + public static final fun getHtml (Ldev/inmo/tgbotapi/types/rich/RichTextInfo;)Ljava/lang/String; + public static final fun getMarkdown (Ldev/inmo/tgbotapi/types/rich/RichBlock;)Ljava/lang/String; + public static final fun getMarkdown (Ldev/inmo/tgbotapi/types/rich/RichTextInfo;)Ljava/lang/String; + public static final fun toRichHtml (Ljava/util/List;)Ljava/lang/String; + public static final fun toRichMarkdown (Ljava/util/List;)Ljava/lang/String; +} + public final class dev/inmo/tgbotapi/types/rich/RichBlockList : dev/inmo/tgbotapi/types/rich/RichBlock { public static final field Companion Ldev/inmo/tgbotapi/types/rich/RichBlockList$Companion; public static final field TYPE Ljava/lang/String; diff --git a/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/rich/RichBlockFormatting.kt b/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/rich/RichBlockFormatting.kt new file mode 100644 index 0000000000..2511d90d5f --- /dev/null +++ b/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/rich/RichBlockFormatting.kt @@ -0,0 +1,200 @@ +package dev.inmo.tgbotapi.types.rich + +/** + * Builds the [Rich Markdown style](https://core.telegram.org/bots/api#rich-markdown-style) source string for this list + * of [RichBlock]s. The resulting string may be passed to [InputRichMessageMarkdown]. + * + * Media blocks ([RichBlockPhoto], [RichBlockVideo], [RichBlockAudio], [RichBlockVoiceNote] and [RichBlockAnimation]) are + * rendered using the Telegram file_id as the media source. Telegram only accepts HTTP(S) URLs for rich media, so for + * those blocks the output is a faithful structural representation rather than a directly sendable message. + */ +fun List.toRichMarkdown(): String = joinToString(separator = "\n\n") { it.markdown } + +/** + * Builds the [Rich HTML style](https://core.telegram.org/bots/api#rich-html-style) source string for this list of + * [RichBlock]s. The resulting string may be passed to [InputRichMessageHTML]. See [toRichMarkdown] for the media note. + */ +fun List.toRichHtml(): String = joinToString(separator = "\n") { it.html } + +/** + * [Rich Markdown style](https://core.telegram.org/bots/api#rich-markdown-style) source of all the [RichTextInfo.blocks]. + */ +val RichTextInfo.markdown: String + get() = blocks.toRichMarkdown() + +/** + * [Rich HTML style](https://core.telegram.org/bots/api#rich-html-style) source of all the [RichTextInfo.blocks]. + */ +val RichTextInfo.html: String + get() = blocks.toRichHtml() + +/** + * [Rich Markdown style](https://core.telegram.org/bots/api#rich-markdown-style) source of this single [RichBlock]. + */ +val RichBlock.markdown: String + get() = when (this) { + is RichBlockParagraph -> text.markdown + is RichBlockSectionHeading -> "#".repeat(size) + " " + text.markdown + is RichBlockPreformatted -> "```" + (language ?: "") + "\n" + text.source + "\n```" + is RichBlockFooter -> "
${text.markdown}
" + is RichBlockDivider -> "---" + is RichBlockMathematicalExpression -> "\$\$" + expression + "\$\$" + is RichBlockAnchor -> "" + is RichBlockList -> richBlockListMarkdown(this) + is RichBlockBlockQuotation -> richBlockQuotationMarkdown(blocks, credit) + is RichBlockPullQuotation -> "" + is RichBlockCollage -> richMediaContainerMarkdown("tg-collage", blocks, caption) + is RichBlockSlideshow -> richMediaContainerMarkdown("tg-slideshow", blocks, caption) + is RichBlockTable -> richBlockTableMarkdown(this) + is RichBlockDetails -> "${summary.markdown}\n\n${blocks.toRichMarkdown()}\n\n" + is RichBlockMap -> richBlockMapMarkdown(this) + is RichBlockAnimation -> richMediaMarkdown(animation.fileId.fileId, caption) + is RichBlockAudio -> richMediaMarkdown(audio.fileId.fileId, caption) + is RichBlockPhoto -> richMediaMarkdown(photo.fileId.fileId, caption) + is RichBlockVideo -> richMediaMarkdown(video.fileId.fileId, caption) + is RichBlockVoiceNote -> richMediaMarkdown(voiceNote.fileId.fileId, caption) + is RichBlockThinking -> "${text.markdown}" + } + +/** + * [Rich HTML style](https://core.telegram.org/bots/api#rich-html-style) source of this single [RichBlock]. + */ +val RichBlock.html: String + get() = when (this) { + is RichBlockParagraph -> "

${text.html}

" + is RichBlockSectionHeading -> "${text.html}" + is RichBlockPreformatted -> language?.let { "
${text.html}
" } ?: "
${text.html}
" + is RichBlockFooter -> "
${text.html}
" + is RichBlockDivider -> "
" + is RichBlockMathematicalExpression -> "$expression" + is RichBlockAnchor -> "" + is RichBlockList -> richBlockListHtml(this) + is RichBlockBlockQuotation -> "
${blocks.toRichHtml()}${creditCiteHtml(credit)}
" + is RichBlockPullQuotation -> "" + is RichBlockCollage -> richMediaContainerHtml("tg-collage", blocks, caption) + is RichBlockSlideshow -> richMediaContainerHtml("tg-slideshow", blocks, caption) + is RichBlockTable -> richBlockTableHtml(this) + is RichBlockDetails -> "${summary.html}${blocks.toRichHtml()}" + is RichBlockMap -> richBlockMapHtml(this) + is RichBlockAnimation -> richMediaHtml("video", animation.fileId.fileId, hasSpoiler == true, selfClosing = false, caption = caption) + is RichBlockAudio -> richMediaHtml("audio", audio.fileId.fileId, spoiler = false, selfClosing = false, caption = caption) + is RichBlockPhoto -> richMediaHtml("img", photo.fileId.fileId, hasSpoiler == true, selfClosing = true, caption = caption) + is RichBlockVideo -> richMediaHtml("video", video.fileId.fileId, hasSpoiler == true, selfClosing = false, caption = caption) + is RichBlockVoiceNote -> richMediaHtml("audio", voiceNote.fileId.fileId, spoiler = false, selfClosing = false, caption = caption) + is RichBlockThinking -> "${text.html}" + } + +private fun richOpenAttribute(isOpen: Boolean?): String = if (isOpen == true) " open" else "" + +private fun creditCiteMarkdown(credit: RichText?): String = credit?.let { "${it.markdown}" } ?: "" + +private fun creditCiteHtml(credit: RichText?): String = credit?.let { "${it.html}" } ?: "" + +private fun richBlockListMarkdown(list: RichBlockList): String = + list.items.mapIndexed { index, item -> + val marker = when { + item.hasCheckbox == true -> if (item.isChecked == true) "- [x] " else "- [ ] " + item.labelType != null -> "${item.value ?: (index + 1)}. " + else -> "- " + } + item.blocks.toRichMarkdown().lineSequence().mapIndexed { lineIndex, line -> + if (lineIndex == 0) "$marker$line" else " $line" + }.joinToString(separator = "\n") + }.joinToString(separator = "\n") + +private fun richBlockListHtml(list: RichBlockList): String { + val ordered = list.items.any { it.labelType != null } + val tag = if (ordered) "ol" else "ul" + val items = list.items.joinToString(separator = "") { item -> + val attributes = buildString { + item.value?.let { append(" value=\"$it\"") } + item.labelType?.let { append(" type=\"$it\"") } + } + val checkbox = if (item.hasCheckbox == true) { + "" + } else { + "" + } + "$checkbox${item.blocks.toRichHtml()}" + } + return "<$tag>$items" +} + +private fun richBlockQuotationMarkdown(blocks: List, credit: RichText?): String { + val quoted = blocks.toRichMarkdown().lineSequence().joinToString(separator = "\n") { line -> + if (line.isEmpty()) ">" else "> $line" + } + return quoted + (credit?.let { "\n> ${creditCiteMarkdown(it)}" } ?: "") +} + +private fun richMediaContainerMarkdown(tag: String, blocks: List, caption: RichBlockCaption?): String { + val media = blocks.joinToString(separator = "\n") { it.markdown } + val captionPart = caption?.let { "\n
${it.text.markdown}${creditCiteMarkdown(it.credit)}
" } ?: "" + return "<$tag>\n\n$media$captionPart\n\n" +} + +private fun richMediaContainerHtml(tag: String, blocks: List, caption: RichBlockCaption?): String { + val media = blocks.joinToString(separator = "") { it.html } + val captionPart = caption?.let { "
${it.text.html}${creditCiteHtml(it.credit)}
" } ?: "" + return "<$tag>$media$captionPart" +} + +private fun richMediaMarkdown(source: String, caption: RichBlockCaption?): String = + caption?.let { "![](" + source + " \"" + it.text.source + "\")" } ?: "![]($source)" + +private fun richMediaHtml(tag: String, source: String, spoiler: Boolean, selfClosing: Boolean, caption: RichBlockCaption?): String { + val spoilerAttribute = if (spoiler) " tg-spoiler" else "" + val element = if (selfClosing) "<$tag src=\"$source\"$spoilerAttribute/>" else "<$tag src=\"$source\"$spoilerAttribute>" + return caption?.let { "
$element
${it.text.html}${creditCiteHtml(it.credit)}
" } ?: element +} + +private fun richBlockMapMarkdown(map: RichBlockMap): String { + val element = "" + return map.caption?.let { "
$element
${it.text.markdown}${creditCiteMarkdown(it.credit)}
" } ?: element +} + +private fun richBlockMapHtml(map: RichBlockMap): String { + val element = "" + return map.caption?.let { "
$element
${it.text.html}${creditCiteHtml(it.credit)}
" } ?: element +} + +private fun richBlockTableMarkdown(table: RichBlockTable): String { + if (table.cells.isEmpty()) return "" + fun renderRow(row: List): String = + row.joinToString(separator = " | ", prefix = "| ", postfix = " |") { it.text?.markdown ?: "" } + fun alignment(cell: RichBlockTableCell): String = when (cell.align) { + "left" -> ":---" + "center" -> ":--:" + "right" -> "---:" + else -> "---" + } + val header = table.cells.first() + val lines = mutableListOf( + renderRow(header), + header.joinToString(separator = " | ", prefix = "| ", postfix = " |") { alignment(it) } + ) + table.cells.drop(1).forEach { lines.add(renderRow(it)) } + return lines.joinToString(separator = "\n") +} + +private fun richBlockTableHtml(table: RichBlockTable): String { + val attributes = buildString { + if (table.isBordered == true) append(" bordered") + if (table.isStriped == true) append(" striped") + } + val caption = table.caption?.let { "${it.html}" } ?: "" + val rows = table.cells.joinToString(separator = "") { row -> + val cells = row.joinToString(separator = "") { cell -> + val tag = if (cell.isHeader == true) "th" else "td" + val cellAttributes = buildString { + cell.colspan?.let { append(" colspan=\"$it\"") } + cell.rowspan?.let { append(" rowspan=\"$it\"") } + append(" align=\"${cell.align}\"") + append(" valign=\"${cell.valign}\"") + } + "<$tag$cellAttributes>${cell.text?.html ?: ""}" + } + "$cells" + } + return "$caption$rows" +} diff --git a/tgbotapi.core/src/commonTest/kotlin/dev/inmo/tgbotapi/types/rich/RichBlockFormattingTest.kt b/tgbotapi.core/src/commonTest/kotlin/dev/inmo/tgbotapi/types/rich/RichBlockFormattingTest.kt new file mode 100644 index 0000000000..95dea255e6 --- /dev/null +++ b/tgbotapi.core/src/commonTest/kotlin/dev/inmo/tgbotapi/types/rich/RichBlockFormattingTest.kt @@ -0,0 +1,141 @@ +package dev.inmo.tgbotapi.types.rich + +import kotlin.test.Test +import kotlin.test.assertEquals + +class RichBlockFormattingTest { + @Test + fun paragraph() { + val block = RichBlockParagraph(RichTextPlain("Hello")) + assertEquals("Hello", block.markdown) + assertEquals("

Hello

", block.html) + } + + @Test + fun heading() { + val block = RichBlockSectionHeading(RichTextPlain("Title"), 2) + assertEquals("## Title", block.markdown) + assertEquals("

Title

", block.html) + } + + @Test + fun divider() { + val block = RichBlockDivider() + assertEquals("---", block.markdown) + assertEquals("
", block.html) + } + + @Test + fun footer() { + val block = RichBlockFooter(RichTextPlain("f")) + assertEquals("
f
", block.markdown) + assertEquals("
f
", block.html) + } + + @Test + fun preformatted() { + val withLanguage = RichBlockPreformatted(RichTextPlain("code"), "kotlin") + assertEquals("```kotlin\ncode\n```", withLanguage.markdown) + assertEquals("
code
", withLanguage.html) + + val withoutLanguage = RichBlockPreformatted(RichTextPlain("c")) + assertEquals("```\nc\n```", withoutLanguage.markdown) + assertEquals("
c
", withoutLanguage.html) + } + + @Test + fun mathematicalExpression() { + val block = RichBlockMathematicalExpression("E=mc^2") + assertEquals("\$\$E=mc^2\$\$", block.markdown) + assertEquals("E=mc^2", block.html) + } + + @Test + fun anchor() { + val block = RichBlockAnchor("top") + assertEquals("", block.markdown) + assertEquals("", block.html) + } + + @Test + fun bulletList() { + val block = RichBlockList(listOf(RichBlockListItem("-", listOf(RichBlockParagraph(RichTextPlain("one")))))) + assertEquals("- one", block.markdown) + assertEquals("
  • one

", block.html) + } + + @Test + fun orderedList() { + val block = RichBlockList( + listOf(RichBlockListItem("1", listOf(RichBlockParagraph(RichTextPlain("one"))), labelType = "1")) + ) + assertEquals("1. one", block.markdown) + assertEquals("
  1. one

", block.html) + } + + @Test + fun taskList() { + val block = RichBlockList( + listOf( + RichBlockListItem( + "x", + listOf(RichBlockParagraph(RichTextPlain("done"))), + hasCheckbox = true, + isChecked = true + ) + ) + ) + assertEquals("- [x] done", block.markdown) + assertEquals("
  • done

", block.html) + } + + @Test + fun blockQuotation() { + val block = RichBlockBlockQuotation(listOf(RichBlockParagraph(RichTextPlain("q")))) + assertEquals("> q", block.markdown) + assertEquals("

q

", block.html) + } + + @Test + fun details() { + val block = RichBlockDetails(RichTextPlain("sum"), listOf(RichBlockParagraph(RichTextPlain("body"))), isOpen = true) + assertEquals("
sum\n\nbody\n\n
", block.markdown) + assertEquals("
sum

body

", block.html) + } + + @Test + fun table() { + val block = RichBlockTable( + listOf( + listOf( + RichBlockTableCell(text = RichTextPlain("H1"), isHeader = true, align = "left", valign = "top"), + RichBlockTableCell(text = RichTextPlain("H2"), isHeader = true, align = "center", valign = "top") + ), + listOf( + RichBlockTableCell(text = RichTextPlain("a"), align = "left", valign = "top"), + RichBlockTableCell(text = RichTextPlain("b"), align = "center", valign = "top") + ) + ) + ) + assertEquals("| H1 | H2 |\n| :--- | :--: |\n| a | b |", block.markdown) + assertEquals( + "" + + "
H1H2
ab
", + block.html + ) + } + + @Test + fun listOfBlocksJoinsBlocks() { + val blocks = listOf(RichBlockParagraph(RichTextPlain("a")), RichBlockDivider()) + assertEquals("a\n\n---", blocks.toRichMarkdown()) + assertEquals("

a

\n
", blocks.toRichHtml()) + } + + @Test + fun richTextInfoDelegatesToBlocks() { + val info = RichTextInfo(listOf(RichBlockParagraph(RichTextPlain("p")), RichBlockDivider())) + assertEquals("p\n\n---", info.markdown) + assertEquals("

p

\n
", info.html) + } +}