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

Implement markdown and html for RichTextEntity inheritors

Add Rich Markdown style and Rich HTML style serialization for every
RichTextEntity subtype, following the Bot API rich formatting spec
(https://core.telegram.org/bots/api#rich-markdown-style and #rich-html-style).

New RichTextFormatting.kt provides:
* String.escapeRichMarkdown() escaping the rich-markdown special characters;
* RichText.source - plain unformatted text of any RichText;
* RichText.markdown / RichText.html - recursive dispatch over RichTextPlain,
  RichTextGroup and RichTextEntity so inner texts render correctly.

Each of the 25 entity types now overrides markdown and html. Auto-detected
entities (mention, hashtag, cashtag, bank card, bot command) emit their visible
text; the rest wrap inner text in the corresponding markers/tags.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 17:02:03 +06:00
parent 1091dbdb5e
commit 4cfc054526
4 changed files with 271 additions and 0 deletions

View File

@@ -42,6 +42,9 @@ data class RichTextGroup(
@Serializable(RichTextEntitySerializer::class)
sealed interface RichTextEntity : RichText {
val type: String
val markdown: String
val html: String
}
object RichTextSerializer : KSerializer<RichText> {

View File

@@ -12,6 +12,7 @@ import dev.inmo.tgbotapi.types.dateTimeFormatField
import dev.inmo.tgbotapi.types.emailAddressField
import dev.inmo.tgbotapi.types.expressionField
import dev.inmo.tgbotapi.types.hashtagField
import dev.inmo.tgbotapi.types.internalUserLinkBeginning
import dev.inmo.tgbotapi.types.nameField
import dev.inmo.tgbotapi.types.phoneNumberField
import dev.inmo.tgbotapi.types.referenceNameField
@@ -21,6 +22,7 @@ import dev.inmo.tgbotapi.types.unixTimeField
import dev.inmo.tgbotapi.types.urlField
import dev.inmo.tgbotapi.types.userField
import dev.inmo.tgbotapi.types.usernameField
import dev.inmo.tgbotapi.utils.extensions.toHtml
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -39,6 +41,11 @@ data class RichTextBold(
@SerialName(typeField)
override val type: String = TYPE
override val markdown: String
get() = "**${text.markdown}**"
override val html: String
get() = "<b>${text.html}</b>"
companion object {
const val TYPE = "bold"
}
@@ -58,6 +65,11 @@ data class RichTextItalic(
@SerialName(typeField)
override val type: String = TYPE
override val markdown: String
get() = "*${text.markdown}*"
override val html: String
get() = "<i>${text.html}</i>"
companion object {
const val TYPE = "italic"
}
@@ -77,6 +89,11 @@ data class RichTextUnderline(
@SerialName(typeField)
override val type: String = TYPE
override val markdown: String
get() = "<u>${text.markdown}</u>"
override val html: String
get() = "<u>${text.html}</u>"
companion object {
const val TYPE = "underline"
}
@@ -96,6 +113,11 @@ data class RichTextStrikethrough(
@SerialName(typeField)
override val type: String = TYPE
override val markdown: String
get() = "~~${text.markdown}~~"
override val html: String
get() = "<s>${text.html}</s>"
companion object {
const val TYPE = "strikethrough"
}
@@ -115,6 +137,11 @@ data class RichTextSpoiler(
@SerialName(typeField)
override val type: String = TYPE
override val markdown: String
get() = "||${text.markdown}||"
override val html: String
get() = "<tg-spoiler>${text.html}</tg-spoiler>"
companion object {
const val TYPE = "spoiler"
}
@@ -134,6 +161,11 @@ data class RichTextSubscript(
@SerialName(typeField)
override val type: String = TYPE
override val markdown: String
get() = "<sub>${text.markdown}</sub>"
override val html: String
get() = "<sub>${text.html}</sub>"
companion object {
const val TYPE = "subscript"
}
@@ -153,6 +185,11 @@ data class RichTextSuperscript(
@SerialName(typeField)
override val type: String = TYPE
override val markdown: String
get() = "<sup>${text.markdown}</sup>"
override val html: String
get() = "<sup>${text.html}</sup>"
companion object {
const val TYPE = "superscript"
}
@@ -172,6 +209,11 @@ data class RichTextMarked(
@SerialName(typeField)
override val type: String = TYPE
override val markdown: String
get() = "==${text.markdown}=="
override val html: String
get() = "<mark>${text.html}</mark>"
companion object {
const val TYPE = "marked"
}
@@ -191,6 +233,11 @@ data class RichTextCode(
@SerialName(typeField)
override val type: String = TYPE
override val markdown: String
get() = "`${text.source}`"
override val html: String
get() = "<code>${text.html}</code>"
companion object {
const val TYPE = "code"
}
@@ -214,6 +261,11 @@ data class RichTextDateTime(
@SerialName(typeField)
override val type: String = TYPE
override val markdown: String
get() = "![${text.markdown}](tg://time?unix=$unixTime&format=$dateTimeFormat)"
override val html: String
get() = "<tg-time unix=\"$unixTime\" format=\"$dateTimeFormat\">${text.html}</tg-time>"
companion object {
const val TYPE = "date_time"
}
@@ -235,6 +287,11 @@ data class RichTextTextMention(
@SerialName(typeField)
override val type: String = TYPE
override val markdown: String
get() = "[${text.markdown}]($internalUserLinkBeginning${user.id.chatId.long})"
override val html: String
get() = "<a href=\"$internalUserLinkBeginning${user.id.chatId.long}\">${text.html}</a>"
companion object {
const val TYPE = "text_mention"
}
@@ -256,6 +313,11 @@ data class RichTextCustomEmoji(
@SerialName(typeField)
override val type: String = TYPE
override val markdown: String
get() = "![${alternativeText.escapeRichMarkdown()}](tg://emoji?id=${customEmojiId.string})"
override val html: String
get() = "<tg-emoji emoji-id=\"${customEmojiId.string}\">${alternativeText.toHtml()}</tg-emoji>"
companion object {
const val TYPE = "custom_emoji"
}
@@ -275,6 +337,11 @@ data class RichTextMathematicalExpression(
@SerialName(typeField)
override val type: String = TYPE
override val markdown: String
get() = "\$$expression\$"
override val html: String
get() = "<tg-math>$expression</tg-math>"
companion object {
const val TYPE = "mathematical_expression"
}
@@ -296,6 +363,11 @@ data class RichTextUrl(
@SerialName(typeField)
override val type: String = TYPE
override val markdown: String
get() = "[${text.markdown}]($url)"
override val html: String
get() = "<a href=\"$url\">${text.html}</a>"
companion object {
const val TYPE = "url"
}
@@ -317,6 +389,11 @@ data class RichTextEmailAddress(
@SerialName(typeField)
override val type: String = TYPE
override val markdown: String
get() = "[${text.markdown}](mailto:$emailAddress)"
override val html: String
get() = "<a href=\"mailto:$emailAddress\">${text.html}</a>"
companion object {
const val TYPE = "email_address"
}
@@ -338,6 +415,11 @@ data class RichTextPhoneNumber(
@SerialName(typeField)
override val type: String = TYPE
override val markdown: String
get() = "[${text.markdown}](tel:$phoneNumber)"
override val html: String
get() = "<a href=\"tel:$phoneNumber\">${text.html}</a>"
companion object {
const val TYPE = "phone_number"
}
@@ -359,6 +441,11 @@ data class RichTextBankCardNumber(
@SerialName(typeField)
override val type: String = TYPE
override val markdown: String
get() = text.markdown
override val html: String
get() = text.html
companion object {
const val TYPE = "bank_card_number"
}
@@ -380,6 +467,11 @@ data class RichTextMention(
@SerialName(typeField)
override val type: String = TYPE
override val markdown: String
get() = text.markdown
override val html: String
get() = text.html
companion object {
const val TYPE = "mention"
}
@@ -401,6 +493,11 @@ data class RichTextHashtag(
@SerialName(typeField)
override val type: String = TYPE
override val markdown: String
get() = text.markdown
override val html: String
get() = text.html
companion object {
const val TYPE = "hashtag"
}
@@ -422,6 +519,11 @@ data class RichTextCashtag(
@SerialName(typeField)
override val type: String = TYPE
override val markdown: String
get() = text.markdown
override val html: String
get() = text.html
companion object {
const val TYPE = "cashtag"
}
@@ -443,6 +545,11 @@ data class RichTextBotCommand(
@SerialName(typeField)
override val type: String = TYPE
override val markdown: String
get() = text.markdown
override val html: String
get() = text.html
companion object {
const val TYPE = "bot_command"
}
@@ -462,6 +569,11 @@ data class RichTextAnchor(
@SerialName(typeField)
override val type: String = TYPE
override val markdown: String
get() = "<a name=\"$name\"></a>"
override val html: String
get() = "<a name=\"$name\"></a>"
companion object {
const val TYPE = "anchor"
}
@@ -483,6 +595,11 @@ data class RichTextAnchorLink(
@SerialName(typeField)
override val type: String = TYPE
override val markdown: String
get() = "[${text.markdown}](#$anchorName)"
override val html: String
get() = "<a href=\"#$anchorName\">${text.html}</a>"
companion object {
const val TYPE = "anchor_link"
}
@@ -504,6 +621,11 @@ data class RichTextReference(
@SerialName(typeField)
override val type: String = TYPE
override val markdown: String
get() = "<tg-reference name=\"$name\">${text.markdown}</tg-reference>"
override val html: String
get() = "<tg-reference name=\"$name\">${text.html}</tg-reference>"
companion object {
const val TYPE = "reference"
}
@@ -525,6 +647,11 @@ data class RichTextReferenceLink(
@SerialName(typeField)
override val type: String = TYPE
override val markdown: String
get() = "[${text.markdown}](#$referenceName)"
override val html: String
get() = "<a href=\"#$referenceName\">${text.html}</a>"
companion object {
const val TYPE = "reference_link"
}

View File

@@ -0,0 +1,82 @@
package dev.inmo.tgbotapi.types.rich
import dev.inmo.tgbotapi.types.internalUserLinkBeginning
import dev.inmo.tgbotapi.utils.extensions.toHtml
/**
* Characters which have a special meaning in the
* [Rich Markdown style](https://core.telegram.org/bots/api#rich-markdown-style) and must be escaped with a backslash
* to be represented literally.
*/
private val richMarkdownSpecialCharacters = setOf(
'\\', '`', '*', '_', '~', '|', '[', ']', '(', ')', '<', '>', '#', '=', '!', '$'
)
/**
* Escapes all the [richMarkdownSpecialCharacters] of the receiver with a backslash so that the resulting string is
* represented literally in the [Rich Markdown style](https://core.telegram.org/bots/api#rich-markdown-style).
*/
fun String.escapeRichMarkdown(): String = buildString {
for (character in this@escapeRichMarkdown) {
if (character in richMarkdownSpecialCharacters) {
append('\\')
}
append(character)
}
}
/**
* Plain (unformatted) text of this [RichText]. For [RichTextEntity]s without an inner [RichText] it falls back to the
* most meaningful textual representation: alternative text for custom emojis, the expression for mathematical
* expressions and an empty string for anchors.
*/
val RichText.source: String
get() = when (this) {
is RichTextPlain -> text
is RichTextGroup -> parts.joinToString(separator = "") { it.source }
is RichTextCustomEmoji -> alternativeText
is RichTextMathematicalExpression -> expression
is RichTextAnchor -> ""
is RichTextBold -> text.source
is RichTextItalic -> text.source
is RichTextUnderline -> text.source
is RichTextStrikethrough -> text.source
is RichTextSpoiler -> text.source
is RichTextSubscript -> text.source
is RichTextSuperscript -> text.source
is RichTextMarked -> text.source
is RichTextCode -> text.source
is RichTextDateTime -> text.source
is RichTextTextMention -> text.source
is RichTextUrl -> text.source
is RichTextEmailAddress -> text.source
is RichTextPhoneNumber -> text.source
is RichTextBankCardNumber -> text.source
is RichTextMention -> text.source
is RichTextHashtag -> text.source
is RichTextCashtag -> text.source
is RichTextBotCommand -> text.source
is RichTextAnchorLink -> text.source
is RichTextReference -> text.source
is RichTextReferenceLink -> text.source
}
/**
* [Rich Markdown style](https://core.telegram.org/bots/api#rich-markdown-style) representation of this [RichText].
*/
val RichText.markdown: String
get() = when (this) {
is RichTextPlain -> text.escapeRichMarkdown()
is RichTextGroup -> parts.joinToString(separator = "") { it.markdown }
is RichTextEntity -> markdown
}
/**
* [Rich HTML style](https://core.telegram.org/bots/api#rich-html-style) representation of this [RichText].
*/
val RichText.html: String
get() = when (this) {
is RichTextPlain -> text.toHtml()
is RichTextGroup -> parts.joinToString(separator = "") { it.html }
is RichTextEntity -> html
}