1
0
mirror of https://github.com/InsanusMokrassar/TelegramBotAPI.git synced 2026-07-03 16:46:06 +00:00

Add rich block markdown and html source builders

Add List<RichBlock>.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 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 17:21:19 +06:00
parent b5238320a5
commit 23578d25ef
3 changed files with 350 additions and 0 deletions

View File

@@ -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;

View File

@@ -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<RichBlock>.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<RichBlock>.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 -> "<footer>${text.markdown}</footer>"
is RichBlockDivider -> "---"
is RichBlockMathematicalExpression -> "\$\$" + expression + "\$\$"
is RichBlockAnchor -> "<a name=\"$name\"></a>"
is RichBlockList -> richBlockListMarkdown(this)
is RichBlockBlockQuotation -> richBlockQuotationMarkdown(blocks, credit)
is RichBlockPullQuotation -> "<aside>${text.markdown}${creditCiteMarkdown(credit)}</aside>"
is RichBlockCollage -> richMediaContainerMarkdown("tg-collage", blocks, caption)
is RichBlockSlideshow -> richMediaContainerMarkdown("tg-slideshow", blocks, caption)
is RichBlockTable -> richBlockTableMarkdown(this)
is RichBlockDetails -> "<details${richOpenAttribute(isOpen)}><summary>${summary.markdown}</summary>\n\n${blocks.toRichMarkdown()}\n\n</details>"
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 -> "<tg-thinking>${text.markdown}</tg-thinking>"
}
/**
* [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 -> "<p>${text.html}</p>"
is RichBlockSectionHeading -> "<h$size>${text.html}</h$size>"
is RichBlockPreformatted -> language?.let { "<pre><code class=\"language-$it\">${text.html}</code></pre>" } ?: "<pre>${text.html}</pre>"
is RichBlockFooter -> "<footer>${text.html}</footer>"
is RichBlockDivider -> "<hr/>"
is RichBlockMathematicalExpression -> "<tg-math-block>$expression</tg-math-block>"
is RichBlockAnchor -> "<a name=\"$name\"></a>"
is RichBlockList -> richBlockListHtml(this)
is RichBlockBlockQuotation -> "<blockquote>${blocks.toRichHtml()}${creditCiteHtml(credit)}</blockquote>"
is RichBlockPullQuotation -> "<aside>${text.html}${creditCiteHtml(credit)}</aside>"
is RichBlockCollage -> richMediaContainerHtml("tg-collage", blocks, caption)
is RichBlockSlideshow -> richMediaContainerHtml("tg-slideshow", blocks, caption)
is RichBlockTable -> richBlockTableHtml(this)
is RichBlockDetails -> "<details${richOpenAttribute(isOpen)}><summary>${summary.html}</summary>${blocks.toRichHtml()}</details>"
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 -> "<tg-thinking>${text.html}</tg-thinking>"
}
private fun richOpenAttribute(isOpen: Boolean?): String = if (isOpen == true) " open" else ""
private fun creditCiteMarkdown(credit: RichText?): String = credit?.let { "<cite>${it.markdown}</cite>" } ?: ""
private fun creditCiteHtml(credit: RichText?): String = credit?.let { "<cite>${it.html}</cite>" } ?: ""
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) {
"<input type=\"checkbox\"${if (item.isChecked == true) " checked" else ""}>"
} else {
""
}
"<li$attributes>$checkbox${item.blocks.toRichHtml()}</li>"
}
return "<$tag>$items</$tag>"
}
private fun richBlockQuotationMarkdown(blocks: List<RichBlock>, 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<RichBlock>, caption: RichBlockCaption?): String {
val media = blocks.joinToString(separator = "\n") { it.markdown }
val captionPart = caption?.let { "\n<figcaption>${it.text.markdown}${creditCiteMarkdown(it.credit)}</figcaption>" } ?: ""
return "<$tag>\n\n$media$captionPart\n\n</$tag>"
}
private fun richMediaContainerHtml(tag: String, blocks: List<RichBlock>, caption: RichBlockCaption?): String {
val media = blocks.joinToString(separator = "") { it.html }
val captionPart = caption?.let { "<figcaption>${it.text.html}${creditCiteHtml(it.credit)}</figcaption>" } ?: ""
return "<$tag>$media$captionPart</$tag>"
}
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></$tag>"
return caption?.let { "<figure>$element<figcaption>${it.text.html}${creditCiteHtml(it.credit)}</figcaption></figure>" } ?: element
}
private fun richBlockMapMarkdown(map: RichBlockMap): String {
val element = "<tg-map lat=\"${map.location.latitude}\" long=\"${map.location.longitude}\" zoom=\"${map.zoom}\"/>"
return map.caption?.let { "<figure>$element<figcaption>${it.text.markdown}${creditCiteMarkdown(it.credit)}</figcaption></figure>" } ?: element
}
private fun richBlockMapHtml(map: RichBlockMap): String {
val element = "<tg-map lat=\"${map.location.latitude}\" long=\"${map.location.longitude}\" zoom=\"${map.zoom}\"/>"
return map.caption?.let { "<figure>$element<figcaption>${it.text.html}${creditCiteHtml(it.credit)}</figcaption></figure>" } ?: element
}
private fun richBlockTableMarkdown(table: RichBlockTable): String {
if (table.cells.isEmpty()) return ""
fun renderRow(row: List<RichBlockTableCell>): 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 { "<caption>${it.html}</caption>" } ?: ""
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 ?: ""}</$tag>"
}
"<tr>$cells</tr>"
}
return "<table$attributes>$caption$rows</table>"
}

View File

@@ -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("<p>Hello</p>", block.html)
}
@Test
fun heading() {
val block = RichBlockSectionHeading(RichTextPlain("Title"), 2)
assertEquals("## Title", block.markdown)
assertEquals("<h2>Title</h2>", block.html)
}
@Test
fun divider() {
val block = RichBlockDivider()
assertEquals("---", block.markdown)
assertEquals("<hr/>", block.html)
}
@Test
fun footer() {
val block = RichBlockFooter(RichTextPlain("f"))
assertEquals("<footer>f</footer>", block.markdown)
assertEquals("<footer>f</footer>", block.html)
}
@Test
fun preformatted() {
val withLanguage = RichBlockPreformatted(RichTextPlain("code"), "kotlin")
assertEquals("```kotlin\ncode\n```", withLanguage.markdown)
assertEquals("<pre><code class=\"language-kotlin\">code</code></pre>", withLanguage.html)
val withoutLanguage = RichBlockPreformatted(RichTextPlain("c"))
assertEquals("```\nc\n```", withoutLanguage.markdown)
assertEquals("<pre>c</pre>", withoutLanguage.html)
}
@Test
fun mathematicalExpression() {
val block = RichBlockMathematicalExpression("E=mc^2")
assertEquals("\$\$E=mc^2\$\$", block.markdown)
assertEquals("<tg-math-block>E=mc^2</tg-math-block>", block.html)
}
@Test
fun anchor() {
val block = RichBlockAnchor("top")
assertEquals("<a name=\"top\"></a>", block.markdown)
assertEquals("<a name=\"top\"></a>", block.html)
}
@Test
fun bulletList() {
val block = RichBlockList(listOf(RichBlockListItem("-", listOf(RichBlockParagraph(RichTextPlain("one"))))))
assertEquals("- one", block.markdown)
assertEquals("<ul><li><p>one</p></li></ul>", block.html)
}
@Test
fun orderedList() {
val block = RichBlockList(
listOf(RichBlockListItem("1", listOf(RichBlockParagraph(RichTextPlain("one"))), labelType = "1"))
)
assertEquals("1. one", block.markdown)
assertEquals("<ol><li type=\"1\"><p>one</p></li></ol>", 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("<ul><li><input type=\"checkbox\" checked><p>done</p></li></ul>", block.html)
}
@Test
fun blockQuotation() {
val block = RichBlockBlockQuotation(listOf(RichBlockParagraph(RichTextPlain("q"))))
assertEquals("> q", block.markdown)
assertEquals("<blockquote><p>q</p></blockquote>", block.html)
}
@Test
fun details() {
val block = RichBlockDetails(RichTextPlain("sum"), listOf(RichBlockParagraph(RichTextPlain("body"))), isOpen = true)
assertEquals("<details open><summary>sum</summary>\n\nbody\n\n</details>", block.markdown)
assertEquals("<details open><summary>sum</summary><p>body</p></details>", 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(
"<table><tr><th align=\"left\" valign=\"top\">H1</th><th align=\"center\" valign=\"top\">H2</th></tr>" +
"<tr><td align=\"left\" valign=\"top\">a</td><td align=\"center\" valign=\"top\">b</td></tr></table>",
block.html
)
}
@Test
fun listOfBlocksJoinsBlocks() {
val blocks = listOf(RichBlockParagraph(RichTextPlain("a")), RichBlockDivider())
assertEquals("a\n\n---", blocks.toRichMarkdown())
assertEquals("<p>a</p>\n<hr/>", blocks.toRichHtml())
}
@Test
fun richTextInfoDelegatesToBlocks() {
val info = RichTextInfo(listOf(RichBlockParagraph(RichTextPlain("p")), RichBlockDivider()))
assertEquals("p\n\n---", info.markdown)
assertEquals("<p>p</p>\n<hr/>", info.html)
}
}