1
0
mirror of https://github.com/InsanusMokrassar/TelegramBotAPI.git synced 2026-09-02 06:09:19 +00:00

Fix Bot API 10.3 rich message contracts

This commit is contained in:
2026-08-26 15:26:40 +06:00
parent 61c4e7af17
commit d972cc5b8d
8 changed files with 113 additions and 63 deletions

View File

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

View File

@@ -305,7 +305,7 @@ data class RichBlockBlockQuotation(
}
/**
* A block quotation that is collapsed by default.
* A block quotation that users can expand.
*
* @see <a href="https://core.telegram.org/bots/api#richblockexpandableblockquotation">RichBlockExpandableBlockQuotation</a>
*/
@@ -326,9 +326,9 @@ data class RichBlockExpandableBlockQuotation(
companion object {
const val TYPE = "expandable_blockquote"
fun markdown(text: RichText, credit: RichText?): String =
"<blockquote collapsed>${text.markdown}${creditCiteMarkdown(credit)}</blockquote>"
"<blockquote expandable>${text.html}${creditCiteHtml(credit)}</blockquote>"
fun html(text: RichText, credit: RichText?): String =
"<blockquote collapsed>${text.html}${creditCiteHtml(credit)}</blockquote>"
"<blockquote expandable>${text.html}${creditCiteHtml(credit)}</blockquote>"
}
}

View File

@@ -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<String>.() -> Unit
): String {
val attributes = mutableListOf<String>()
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 "<tg-button ${attributes.joinToString(" ")}>$text</tg-button>"
}
private fun MutableList<String>.attribute(name: String, value: String) {
add("$name=\"${value.escapeRichMarkupAttribute()}\"")
}
private fun String.escapeRichMarkupAttribute(): String = replace("&", "&amp;")
.replace("\"", "&quot;")
.replace("<", "&lt;")

View File

@@ -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<RichText>): 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<RichText> {

View File

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

View File

@@ -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<IllegalArgumentException> { RichMessageButton.Disabled(text) }
}

View File

@@ -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("<blockquote collapsed>q<cite>credit</cite></blockquote>", quotation.markdown)
assertEquals("<blockquote collapsed>q<cite>credit</cite></blockquote>", quotation.html)
assertEquals("<blockquote expandable>q<cite>credit</cite></blockquote>", quotation.markdown)
assertEquals("<blockquote expandable>q<cite>credit</cite></blockquote>", quotation.html)
val customEmojiQuotation = RichBlockExpandableBlockQuotation(
RichTextCustomEmoji(CustomEmojiId("emoji"), "emoji"),
RichTextCustomEmoji(CustomEmojiId("credit"), "credit")
)
assertEquals(
"<blockquote expandable><tg-emoji emoji-id=\"emoji\">emoji</tg-emoji>" +
"<cite><tg-emoji emoji-id=\"credit\">credit</tg-emoji></cite></blockquote>",
customEmojiQuotation.markdown
)
assertFalse(customEmojiQuotation.markdown.contains("!["))
val buttons = RichBlockButtons(listOf(RichMessageButton.Disabled(RichTextPlain("Disabled"))), RichBlockButtonAlignment.Right)
assertEquals("<tg-button-row align=\"right\"><tg-button type=\"disabled\">Disabled</tg-button></tg-button-row>", buttons.html)
}

View File

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