From d972cc5b8d1a9b147e95cb644752eb2d1fa5f21b Mon Sep 17 00:00:00 2001 From: InsanusMokrassar Date: Wed, 26 Aug 2026 15:26:40 +0600 Subject: [PATCH] Fix Bot API 10.3 rich message contracts --- .../tgbotapi/types/rich/InputRichBlocks.kt | 2 +- .../inmo/tgbotapi/types/rich/RichBlocks.kt | 6 +- .../tgbotapi/types/rich/RichMessageButton.kt | 114 +++++++++--------- .../dev/inmo/tgbotapi/types/rich/RichText.kt | 6 + .../tgbotapi/types/rich/RichTextEntities.kt | 26 ++++ .../types/RichMessageSerializationTest.kt | 2 + .../types/rich/RichBlockFormattingTest.kt | 16 ++- .../types/rich/RichTextFormattingTest.kt | 4 +- 8 files changed, 113 insertions(+), 63 deletions(-) diff --git a/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/rich/InputRichBlocks.kt b/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/rich/InputRichBlocks.kt index b5343c2062..7d0d275c53 100644 --- a/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/rich/InputRichBlocks.kt +++ b/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/rich/InputRichBlocks.kt @@ -223,7 +223,7 @@ data class InputRichBlockBlockQuotation( } } -/** A block quotation that is collapsed by default. */ +/** A block quotation that users can expand. */ @Serializable data class InputRichBlockExpandableBlockQuotation( @SerialName(textField) diff --git a/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/rich/RichBlocks.kt b/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/rich/RichBlocks.kt index b6424a208e..f4f84de236 100644 --- a/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/rich/RichBlocks.kt +++ b/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/rich/RichBlocks.kt @@ -305,7 +305,7 @@ data class RichBlockBlockQuotation( } /** - * A block quotation that is collapsed by default. + * A block quotation that users can expand. * * @see RichBlockExpandableBlockQuotation */ @@ -326,9 +326,9 @@ data class RichBlockExpandableBlockQuotation( companion object { const val TYPE = "expandable_blockquote" fun markdown(text: RichText, credit: RichText?): String = - "
${text.markdown}${creditCiteMarkdown(credit)}
" + "
${text.html}${creditCiteHtml(credit)}
" fun html(text: RichText, credit: RichText?): String = - "
${text.html}${creditCiteHtml(credit)}
" + "
${text.html}${creditCiteHtml(credit)}
" } } diff --git a/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/rich/RichMessageButton.kt b/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/rich/RichMessageButton.kt index 4fcaa6dce7..a1e1ac9924 100644 --- a/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/rich/RichMessageButton.kt +++ b/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/rich/RichMessageButton.kt @@ -32,6 +32,9 @@ sealed interface RichMessageButton { val text: RichText val style: RichMessageButtonStyle? + /** Creates rich HTML or Markdown button markup containing [text]. */ + fun toRichMarkup(text: String): String + @Serializable data class Url( @SerialName(textField) override val text: RichText, @@ -39,6 +42,10 @@ sealed interface RichMessageButton { @SerialName(styleField) override val style: RichMessageButtonStyle? = null ) : RichMessageButton { init { validateRichMessageButton(text, style) } + override fun toRichMarkup(text: String): String = richMarkup(text, style) { + attribute("type", "url") + attribute("url", url) + } } @Serializable @@ -48,6 +55,10 @@ sealed interface RichMessageButton { @SerialName(styleField) override val style: RichMessageButtonStyle? = null ) : RichMessageButton { init { validateRichMessageButton(text, style, allowLinkStyle = true) } + override fun toRichMarkup(text: String): String = richMarkup(text, style) { + attribute("type", "callback_data") + attribute("data", callbackData) + } } @Serializable @@ -57,6 +68,10 @@ sealed interface RichMessageButton { @SerialName(styleField) override val style: RichMessageButtonStyle? = null ) : RichMessageButton { init { validateRichMessageButton(text, style) } + override fun toRichMarkup(text: String): String = richMarkup(text, style) { + attribute("type", "web_app") + attribute("url", webApp.url) + } } @Serializable @@ -69,6 +84,13 @@ sealed interface RichMessageButton { validateRichMessageButton(text, style) require(loginUrl.botUsername == null) { "Rich message login URL buttons do not support bot usernames" } } + override fun toRichMarkup(text: String): String = richMarkup(text, style) { + attribute("type", "login_url") + attribute("url", loginUrl.url) + loginUrl.forwardText?.let { attribute("forward-text", it) } + loginUrl.botUsername?.let { attribute("bot-username", it) } + if (loginUrl.requestWriteAccess == true) add("request-write-access") + } } @Serializable @@ -78,6 +100,10 @@ sealed interface RichMessageButton { @SerialName(styleField) override val style: RichMessageButtonStyle? = null ) : RichMessageButton { init { validateRichMessageButton(text, style) } + override fun toRichMarkup(text: String): String = richMarkup(text, style) { + attribute("type", "switch_inline_query") + attribute("query", switchInlineQuery) + } } @Serializable @@ -87,6 +113,10 @@ sealed interface RichMessageButton { @SerialName(styleField) override val style: RichMessageButtonStyle? = null ) : RichMessageButton { init { validateRichMessageButton(text, style) } + override fun toRichMarkup(text: String): String = richMarkup(text, style) { + attribute("type", "switch_inline_query_current_chat") + attribute("query", switchInlineQueryCurrentChat) + } } @Serializable @@ -96,6 +126,14 @@ sealed interface RichMessageButton { @SerialName(styleField) override val style: RichMessageButtonStyle? = null ) : RichMessageButton { init { validateRichMessageButton(text, style) } + override fun toRichMarkup(text: String): String = richMarkup(text, style) { + attribute("type", "switch_inline_query_chosen_chat") + switchInlineQueryChosenChat.query?.let { attribute("query", it) } + if (switchInlineQueryChosenChat.allowUsers) add("allow-user-chats") + if (switchInlineQueryChosenChat.allowBots) add("allow-bot-chats") + if (switchInlineQueryChosenChat.allowGroups) add("allow-group-chats") + if (switchInlineQueryChosenChat.allowChannels) add("allow-channel-chats") + } } @Serializable @@ -105,6 +143,10 @@ sealed interface RichMessageButton { @SerialName(styleField) override val style: RichMessageButtonStyle? = null ) : RichMessageButton { init { validateRichMessageButton(text, style) } + override fun toRichMarkup(text: String): String = richMarkup(text, style) { + attribute("type", "copy_text") + attribute("text", copyText.text) + } } @Serializable @@ -116,6 +158,10 @@ sealed interface RichMessageButton { @SerialName(disabledField) @kotlinx.serialization.EncodeDefault val disabled: DisabledButton = DisabledButton + + override fun toRichMarkup(text: String): String = richMarkup(text, style) { + attribute("type", "disabled") + } } } @@ -124,7 +170,7 @@ private fun validateRichMessageButton( style: RichMessageButtonStyle?, allowLinkStyle: Boolean = false ) { - require(text.isValidRichMessageButtonText()) { + require(text.isValidRichMessageButtonText) { "Rich message button text can contain only plain text, custom emoji and date-time entities" } require(allowLinkStyle || style != RichMessageButtonStyle.Link) { @@ -132,14 +178,6 @@ private fun validateRichMessageButton( } } -private fun RichText.isValidRichMessageButtonText(): Boolean = when (this) { - is RichTextPlain -> true - is RichTextCustomEmoji -> true - is RichTextDateTime -> text.isValidRichMessageButtonText() - is RichTextGroup -> parts.all { it.isValidRichMessageButtonText() } - else -> false -} - /** Closed set of styles supported by [RichMessageButton]. */ @Serializable(RichMessageButtonStyle.Serializer::class) sealed interface RichMessageButtonStyle { @@ -234,57 +272,21 @@ sealed interface RichBlockButtonAlignment { } } -internal fun RichMessageButton.toRichMarkup(text: String): String { +private fun richMarkup( + text: String, + style: RichMessageButtonStyle?, + buildAttributes: MutableList.() -> Unit +): String { val attributes = mutableListOf() - fun attribute(name: String, value: String) { - attributes.add("$name=\"${value.escapeRichMarkupAttribute()}\"") - } - when (this) { - is RichMessageButton.Url -> { - attribute("type", "url") - attribute("url", url) - } - is RichMessageButton.CallbackData -> { - attribute("type", "callback_data") - attribute("data", callbackData) - } - is RichMessageButton.WebApp -> { - attribute("type", "web_app") - attribute("url", webApp.url) - } - is RichMessageButton.LoginUrl -> { - attribute("type", "login_url") - attribute("url", loginUrl.url) - loginUrl.forwardText?.let { attribute("forward-text", it) } - loginUrl.botUsername?.let { attribute("bot-username", it) } - if (loginUrl.requestWriteAccess == true) attributes.add("request-write-access") - } - is RichMessageButton.SwitchInlineQuery -> { - attribute("type", "switch_inline_query") - attribute("query", switchInlineQuery) - } - is RichMessageButton.SwitchInlineQueryCurrentChat -> { - attribute("type", "switch_inline_query_current_chat") - attribute("query", switchInlineQueryCurrentChat) - } - is RichMessageButton.SwitchInlineQueryChosenChat -> { - attribute("type", "switch_inline_query_chosen_chat") - switchInlineQueryChosenChat.query?.let { attribute("query", it) } - if (switchInlineQueryChosenChat.allowUsers) attributes.add("allow-user-chats") - if (switchInlineQueryChosenChat.allowBots) attributes.add("allow-bot-chats") - if (switchInlineQueryChosenChat.allowGroups) attributes.add("allow-group-chats") - if (switchInlineQueryChosenChat.allowChannels) attributes.add("allow-channel-chats") - } - is RichMessageButton.CopyText -> { - attribute("type", "copy_text") - attribute("text", copyText.text) - } - is RichMessageButton.Disabled -> attribute("type", "disabled") - } - style?.let { attribute("style", it.name) } + attributes.buildAttributes() + style?.let { attributes.attribute("style", it.name) } return "$text" } +private fun MutableList.attribute(name: String, value: String) { + add("$name=\"${value.escapeRichMarkupAttribute()}\"") +} + private fun String.escapeRichMarkupAttribute(): String = replace("&", "&") .replace("\"", """) .replace("<", "<") diff --git a/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/rich/RichText.kt b/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/rich/RichText.kt index 62f920ec65..9a7c590609 100644 --- a/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/rich/RichText.kt +++ b/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/rich/RichText.kt @@ -36,6 +36,9 @@ sealed interface RichText { * [Rich HTML style](https://core.telegram.org/bots/api#rich-html-style) representation of this [RichText]. */ val html: String + + /** Whether this text is permitted as [RichMessageButton] text. */ + val isValidRichMessageButtonText: Boolean } /** @@ -48,6 +51,7 @@ data class RichTextPlain( override val rawText: String = text override val markdown: String = markdown(text) override val html: String = html(text) + override val isValidRichMessageButtonText: Boolean = true companion object { fun markdown(text: String): String = text.escapeRichMarkdown() @@ -65,6 +69,7 @@ data class RichTextGroup( override val rawText: String = parts.joinToString(separator = "") { it.rawText } override val markdown: String = markdown(parts) override val html: String = html(parts) + override val isValidRichMessageButtonText: Boolean = parts.all { it.isValidRichMessageButtonText } companion object { fun markdown(parts: List): String = parts.joinToString(separator = "") { it.markdown } @@ -81,6 +86,7 @@ sealed interface RichTextEntity : RichText { override val markdown: String override val html: String + override val isValidRichMessageButtonText: Boolean } object RichTextSerializer : KSerializer { diff --git a/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/rich/RichTextEntities.kt b/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/rich/RichTextEntities.kt index 5681fc223e..572bdfb420 100644 --- a/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/rich/RichTextEntities.kt +++ b/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/rich/RichTextEntities.kt @@ -46,6 +46,7 @@ data class RichTextButton( override val rawText: String = button.text.rawText override val markdown: String = markdown(button) override val html: String = html(button) + override val isValidRichMessageButtonText: Boolean = false companion object { const val TYPE = "button" @@ -71,6 +72,7 @@ data class RichTextBold( override val rawText: String = text.rawText override val markdown: String = markdown(text) override val html: String = html(text) + override val isValidRichMessageButtonText: Boolean = false companion object { const val TYPE = "bold" @@ -96,6 +98,7 @@ data class RichTextItalic( override val rawText: String = text.rawText override val markdown: String = markdown(text) override val html: String = html(text) + override val isValidRichMessageButtonText: Boolean = false companion object { const val TYPE = "italic" @@ -121,6 +124,7 @@ data class RichTextUnderline( override val rawText: String = text.rawText override val markdown: String = markdown(text) override val html: String = html(text) + override val isValidRichMessageButtonText: Boolean = false companion object { const val TYPE = "underline" @@ -146,6 +150,7 @@ data class RichTextStrikethrough( override val rawText: String = text.rawText override val markdown: String = markdown(text) override val html: String = html(text) + override val isValidRichMessageButtonText: Boolean = false companion object { const val TYPE = "strikethrough" @@ -171,6 +176,7 @@ data class RichTextSpoiler( override val rawText: String = text.rawText override val markdown: String = markdown(text) override val html: String = html(text) + override val isValidRichMessageButtonText: Boolean = false companion object { const val TYPE = "spoiler" @@ -196,6 +202,7 @@ data class RichTextSubscript( override val rawText: String = text.rawText override val markdown: String = markdown(text) override val html: String = html(text) + override val isValidRichMessageButtonText: Boolean = false companion object { const val TYPE = "subscript" @@ -221,6 +228,7 @@ data class RichTextSuperscript( override val rawText: String = text.rawText override val markdown: String = markdown(text) override val html: String = html(text) + override val isValidRichMessageButtonText: Boolean = false companion object { const val TYPE = "superscript" @@ -246,6 +254,7 @@ data class RichTextMarked( override val rawText: String = text.rawText override val markdown: String = markdown(text) override val html: String = html(text) + override val isValidRichMessageButtonText: Boolean = false companion object { const val TYPE = "marked" @@ -271,6 +280,7 @@ data class RichTextCode( override val rawText: String = text.rawText override val markdown: String = markdown(text) override val html: String = html(text) + override val isValidRichMessageButtonText: Boolean = false companion object { const val TYPE = "code" @@ -300,6 +310,7 @@ data class RichTextDateTime( override val rawText: String = text.rawText override val markdown: String = markdown(text, unixTime, dateTimeFormat) override val html: String = html(text, unixTime, dateTimeFormat) + override val isValidRichMessageButtonText: Boolean = text.isValidRichMessageButtonText companion object { const val TYPE = "date_time" @@ -329,6 +340,7 @@ data class RichTextTextMention( override val rawText: String = text.rawText override val markdown: String = markdown(text, user) override val html: String = html(text, user) + override val isValidRichMessageButtonText: Boolean = false companion object { const val TYPE = "text_mention" @@ -358,6 +370,7 @@ data class RichTextCustomEmoji( override val rawText: String = alternativeText override val markdown: String = markdown(customEmojiId, alternativeText) override val html: String = html(customEmojiId, alternativeText) + override val isValidRichMessageButtonText: Boolean = true companion object { const val TYPE = "custom_emoji" @@ -385,6 +398,7 @@ data class RichTextMathematicalExpression( override val rawText: String = expression override val markdown: String = markdown(expression) override val html: String = html(expression) + override val isValidRichMessageButtonText: Boolean = false companion object { const val TYPE = "mathematical_expression" @@ -412,6 +426,7 @@ data class RichTextUrl( override val rawText: String = text.rawText override val markdown: String = markdown(text, url) override val html: String = html(text, url) + override val isValidRichMessageButtonText: Boolean = false companion object { const val TYPE = "url" @@ -439,6 +454,7 @@ data class RichTextEmailAddress( override val rawText: String = text.rawText override val markdown: String = markdown(text, emailAddress) override val html: String = html(text, emailAddress) + override val isValidRichMessageButtonText: Boolean = false companion object { const val TYPE = "email_address" @@ -466,6 +482,7 @@ data class RichTextPhoneNumber( override val rawText: String = text.rawText override val markdown: String = markdown(text, phoneNumber) override val html: String = html(text, phoneNumber) + override val isValidRichMessageButtonText: Boolean = false companion object { const val TYPE = "phone_number" @@ -493,6 +510,7 @@ data class RichTextBankCardNumber( override val rawText: String = text.rawText override val markdown: String = markdown(text) override val html: String = html(text) + override val isValidRichMessageButtonText: Boolean = false companion object { const val TYPE = "bank_card_number" @@ -520,6 +538,7 @@ data class RichTextMention( override val rawText: String = text.rawText override val markdown: String = markdown(text) override val html: String = html(text) + override val isValidRichMessageButtonText: Boolean = false companion object { const val TYPE = "mention" @@ -547,6 +566,7 @@ data class RichTextHashtag( override val rawText: String = text.rawText override val markdown: String = markdown(text) override val html: String = html(text) + override val isValidRichMessageButtonText: Boolean = false companion object { const val TYPE = "hashtag" @@ -574,6 +594,7 @@ data class RichTextCashtag( override val rawText: String = text.rawText override val markdown: String = markdown(text) override val html: String = html(text) + override val isValidRichMessageButtonText: Boolean = false companion object { const val TYPE = "cashtag" @@ -601,6 +622,7 @@ data class RichTextBotCommand( override val rawText: String = text.rawText override val markdown: String = markdown(text) override val html: String = html(text) + override val isValidRichMessageButtonText: Boolean = false companion object { const val TYPE = "bot_command" @@ -626,6 +648,7 @@ data class RichTextAnchor( override val rawText: String = "" override val markdown: String = markdown(name) override val html: String = html(name) + override val isValidRichMessageButtonText: Boolean = false companion object { const val TYPE = "anchor" @@ -653,6 +676,7 @@ data class RichTextAnchorLink( override val rawText: String = text.rawText override val markdown: String = markdown(text, anchorName) override val html: String = html(text, anchorName) + override val isValidRichMessageButtonText: Boolean = false companion object { const val TYPE = "anchor_link" @@ -680,6 +704,7 @@ data class RichTextReference( override val rawText: String = text.rawText override val markdown: String = markdown(text, name) override val html: String = html(text, name) + override val isValidRichMessageButtonText: Boolean = false companion object { const val TYPE = "reference" @@ -707,6 +732,7 @@ data class RichTextReferenceLink( override val rawText: String = text.rawText override val markdown: String = markdown(text, referenceName) override val html: String = html(text, referenceName) + override val isValidRichMessageButtonText: Boolean = false companion object { const val TYPE = "reference_link" diff --git a/tgbotapi.core/src/commonTest/kotlin/dev/inmo/tgbotapi/types/RichMessageSerializationTest.kt b/tgbotapi.core/src/commonTest/kotlin/dev/inmo/tgbotapi/types/RichMessageSerializationTest.kt index ac3df7373c..f96f8d46a0 100644 --- a/tgbotapi.core/src/commonTest/kotlin/dev/inmo/tgbotapi/types/RichMessageSerializationTest.kt +++ b/tgbotapi.core/src/commonTest/kotlin/dev/inmo/tgbotapi/types/RichMessageSerializationTest.kt @@ -114,6 +114,7 @@ class RichMessageSerializationTest { ) validTexts.forEach { text -> + assertEquals(true, text.isValidRichMessageButtonText) assertEquals(text, RichMessageButton.Disabled(text).text) } @@ -124,6 +125,7 @@ class RichMessageSerializationTest { RichTextDateTime(RichTextBold(plain), TelegramDate(1L), "wDT") ) invalidTexts.forEach { text -> + assertEquals(false, text.isValidRichMessageButtonText) assertFailsWith { RichMessageButton.Disabled(text) } } 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 index e0b2b8b4cf..4d4adac7eb 100644 --- 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 @@ -1,7 +1,9 @@ package dev.inmo.tgbotapi.types.rich +import dev.inmo.tgbotapi.types.CustomEmojiId import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse class RichBlockFormattingTest { @Test @@ -99,8 +101,18 @@ class RichBlockFormattingTest { @Test fun expandableBlockQuotationAndButtons() { val quotation = RichBlockExpandableBlockQuotation(RichTextPlain("q"), RichTextPlain("credit")) - assertEquals("
qcredit
", quotation.markdown) - assertEquals("
qcredit
", quotation.html) + assertEquals("
qcredit
", quotation.markdown) + assertEquals("
qcredit
", quotation.html) + val customEmojiQuotation = RichBlockExpandableBlockQuotation( + RichTextCustomEmoji(CustomEmojiId("emoji"), "emoji"), + RichTextCustomEmoji(CustomEmojiId("credit"), "credit") + ) + assertEquals( + "
emoji" + + "credit
", + customEmojiQuotation.markdown + ) + assertFalse(customEmojiQuotation.markdown.contains("![")) val buttons = RichBlockButtons(listOf(RichMessageButton.Disabled(RichTextPlain("Disabled"))), RichBlockButtonAlignment.Right) assertEquals("Disabled", buttons.html) } diff --git a/tgbotapi.core/src/commonTest/kotlin/dev/inmo/tgbotapi/types/rich/RichTextFormattingTest.kt b/tgbotapi.core/src/commonTest/kotlin/dev/inmo/tgbotapi/types/rich/RichTextFormattingTest.kt index e48217ca03..05d085036f 100644 --- a/tgbotapi.core/src/commonTest/kotlin/dev/inmo/tgbotapi/types/rich/RichTextFormattingTest.kt +++ b/tgbotapi.core/src/commonTest/kotlin/dev/inmo/tgbotapi/types/rich/RichTextFormattingTest.kt @@ -245,7 +245,9 @@ class RichTextFormattingTest { SwitchInlineQueryChosenChatParameters(rawAttribute, allowUsers = true) ) to "type=\"switch_inline_query_chosen_chat\" query=\"$escapedAttribute\" allow-user-chats", RichMessageButton.CopyText(text, CopyTextButtonData(rawAttribute)) to - "type=\"copy_text\" text=\"$escapedAttribute\"" + "type=\"copy_text\" text=\"$escapedAttribute\"", + RichMessageButton.Disabled(text) to + "type=\"disabled\"" ) buttonsWithExpectedAttributes.forEach { (button, expectedAttributes) ->