Add Bot API 10.3 Rich Messages support

This commit is contained in:
InsanusMokrassar committed 2026-08-25 14:02:46 +06:00
1 parent efd4667ffa
commit 2e69bc6954
22 files changed
+1526 -22

No files matched your search

@@ -609,6 +609,8 @@ const val isCheckedField = "is_checked"
const val isOpenField = "is_open"
const val isBorderedField = "is_bordered"
const val isStripedField = "is_striped"
const val isCompactField = "is_compact"
const val buttonsField = "buttons"
const val certificateField = "certificate"
const val questionField = "question"
const val questionEntitiesField = "question_entities"
@@ -57,7 +57,7 @@ data class TelegramMediaDocument internal constructor(
override val thumb: InputFile? = null,
@SerialName(disableContentTypeDetectionField)
val disableContentTypeDetection: Boolean? = null
) : TelegramFreeMedia, DocumentMediaGroupMemberTelegramMedia, ThumbedTelegramMedia, InputPollMedia {
) : TelegramFreeMedia, DocumentMediaGroupMemberTelegramMedia, RichMessageMemberTelegramMedia, ThumbedTelegramMedia, InputPollMedia {
@EncodeDefault
override val type: String = TYPE
override val textSources: TextSourcesList? by lazy {
@@ -54,14 +54,17 @@ object InputRichBlockSerializer : JsonContentPolymorphicSerializer<InputRichBloc
InputRichBlockAnchor.TYPE -> InputRichBlockAnchor.serializer()
InputRichBlockList.TYPE -> InputRichBlockList.serializer()
InputRichBlockBlockQuotation.TYPE -> InputRichBlockBlockQuotation.serializer()
InputRichBlockExpandableBlockQuotation.TYPE -> InputRichBlockExpandableBlockQuotation.serializer()
InputRichBlockPullQuotation.TYPE -> InputRichBlockPullQuotation.serializer()
InputRichBlockCollage.TYPE -> InputRichBlockCollage.serializer()
InputRichBlockSlideshow.TYPE -> InputRichBlockSlideshow.serializer()
InputRichBlockTable.TYPE -> InputRichBlockTable.serializer()
InputRichBlockDetails.TYPE -> InputRichBlockDetails.serializer()
InputRichBlockMap.TYPE -> InputRichBlockMap.serializer()
InputRichBlockButtons.TYPE -> InputRichBlockButtons.serializer()
InputRichBlockAnimation.TYPE -> InputRichBlockAnimation.serializer()
InputRichBlockAudio.TYPE -> InputRichBlockAudio.serializer()
InputRichBlockDocument.TYPE -> InputRichBlockDocument.serializer()
InputRichBlockPhoto.TYPE -> InputRichBlockPhoto.serializer()
InputRichBlockVideo.TYPE -> InputRichBlockVideo.serializer()
InputRichBlockVoiceNote.TYPE -> InputRichBlockVoiceNote.serializer()
@@ -1,14 +1,18 @@
package dev.inmo.tgbotapi.types.rich
import dev.inmo.tgbotapi.types.animationField
import dev.inmo.tgbotapi.types.alignField
import dev.inmo.tgbotapi.types.audioField
import dev.inmo.tgbotapi.types.blocksField
import dev.inmo.tgbotapi.types.captionField
import dev.inmo.tgbotapi.types.cellsField
import dev.inmo.tgbotapi.types.buttonsField
import dev.inmo.tgbotapi.types.creditField
import dev.inmo.tgbotapi.types.documentField
import dev.inmo.tgbotapi.types.expressionField
import dev.inmo.tgbotapi.types.heightField
import dev.inmo.tgbotapi.types.isBorderedField
import dev.inmo.tgbotapi.types.isCompactField
import dev.inmo.tgbotapi.types.isOpenField
import dev.inmo.tgbotapi.types.isStripedField
import dev.inmo.tgbotapi.types.itemsField
@@ -18,6 +22,7 @@ import dev.inmo.tgbotapi.types.locationField
import dev.inmo.tgbotapi.types.media.TelegramMedia
import dev.inmo.tgbotapi.types.media.TelegramMediaAnimation
import dev.inmo.tgbotapi.types.media.TelegramMediaAudio
import dev.inmo.tgbotapi.types.media.TelegramMediaDocument
import dev.inmo.tgbotapi.types.media.TelegramMediaPhoto
import dev.inmo.tgbotapi.types.media.TelegramMediaVideo
import dev.inmo.tgbotapi.types.media.TelegramMediaVoiceNote
@@ -218,6 +223,23 @@ data class InputRichBlockBlockQuotation(
}
}
/** A block quotation that is collapsed by default. */
@Serializable
data class InputRichBlockExpandableBlockQuotation(
@SerialName(textField)
val text: RichText,
@SerialName(creditField)
val credit: RichText? = null
) : InputRichBlock {
@EncodeDefault
@SerialName(typeField)
override val type: String = TYPE
companion object {
const val TYPE = "expandable_blockquote"
}
}
/**
* A quotation with centered text.
*
@@ -295,7 +317,9 @@ data class InputRichBlockTable(
@SerialName(isStripedField)
val isStriped: Boolean? = null,
@SerialName(captionField)
val caption: RichText? = null
val caption: RichText? = null,
@SerialName(isCompactField)
val isCompact: Boolean? = null
) : InputRichBlock {
@EncodeDefault
@SerialName(typeField)
@@ -306,6 +330,27 @@ data class InputRichBlockTable(
}
}
/** A row of rich message buttons. */
@Serializable
data class InputRichBlockButtons(
@SerialName(buttonsField)
val buttons: List<RichMessageButton>,
@SerialName(alignField)
val align: RichBlockButtonAlignment? = null
) : InputRichBlock {
@EncodeDefault
@SerialName(typeField)
override val type: String = TYPE
init {
require(buttons.size in 1..8) { "InputRichBlockButtons requires from 1 to 8 buttons" }
}
companion object {
const val TYPE = "buttons"
}
}
/**
* An expandable block for details disclosure.
*
@@ -407,6 +452,25 @@ data class InputRichBlockAudio(
}
}
/** A block with a general file. */
@Serializable
data class InputRichBlockDocument(
@SerialName(documentField)
val document: TelegramMediaDocument,
@SerialName(captionField)
override val caption: RichBlockCaption? = null
) : InputRichBlockMedia {
@EncodeDefault
@SerialName(typeField)
override val type: String = TYPE
override val media: TelegramMedia
get() = document
companion object {
const val TYPE = "document"
}
}
/**
* A block with a photo.
*
@@ -3,6 +3,7 @@ package dev.inmo.tgbotapi.types.rich
import dev.inmo.tgbotapi.types.location.StaticLocation
import dev.inmo.tgbotapi.types.media.TelegramMediaAnimation
import dev.inmo.tgbotapi.types.media.TelegramMediaAudio
import dev.inmo.tgbotapi.types.media.TelegramMediaDocument
import dev.inmo.tgbotapi.types.media.TelegramMediaPhoto
import dev.inmo.tgbotapi.types.media.TelegramMediaVideo
import dev.inmo.tgbotapi.types.media.TelegramMediaVoiceNote
@@ -148,6 +149,9 @@ class InputRichBlocksBuilder {
fun blockQuotation(credit: RichText? = null, block: InputRichBlocksBuilder.() -> Unit) =
add(InputRichBlockBlockQuotation(buildInputRichBlocks(block), credit))
fun expandableBlockQuotation(credit: RichText? = null, block: RichTextBuilder.() -> Unit) =
add(InputRichBlockExpandableBlockQuotation(buildRichText(block), credit))
fun pullQuotation(credit: RichText? = null, block: RichTextBuilder.() -> Unit) =
add(InputRichBlockPullQuotation(buildRichText(block), credit))
@@ -166,6 +170,9 @@ class InputRichBlocksBuilder {
fun audio(audio: TelegramMediaAudio, caption: RichBlockCaption? = null) = add(InputRichBlockAudio(audio, caption))
fun document(document: TelegramMediaDocument, caption: RichBlockCaption? = null) =
add(InputRichBlockDocument(document, caption))
fun voiceNote(voiceNote: TelegramMediaVoiceNote, caption: RichBlockCaption? = null) =
add(InputRichBlockVoiceNote(voiceNote, caption))
@@ -179,8 +186,12 @@ class InputRichBlocksBuilder {
isBordered: Boolean? = null,
isStriped: Boolean? = null,
caption: RichText? = null,
isCompact: Boolean? = null,
block: InputRichBlockTableBuilder.() -> Unit
) = add(InputRichBlockTable(InputRichBlockTableBuilder().apply(block).build(), isBordered, isStriped, caption))
) = add(InputRichBlockTable(InputRichBlockTableBuilder().apply(block).build(), isBordered, isStriped, caption, isCompact))
fun buttons(buttons: List<RichMessageButton>, align: RichBlockButtonAlignment? = null) =
add(InputRichBlockButtons(buttons, align))
fun map(
location: StaticLocation,
@@ -10,7 +10,7 @@ private val inputRichMessageMediaIdRegex = Regex("[A-Za-z0-9_-]{1,64}")
/**
* Describes a media referenced from [InputRichMessage.html]/[InputRichMessage.markdown] via `tg://photo?id=`,
* `tg://video?id=` and `tg://audio?id=` links.
* `tg://video?id=`, `tg://document?id=` and `tg://audio?id=` links.
*
* @see <a href="https://core.telegram.org/bots/api#inputrichmessagemedia">InputRichMessageMedia</a>
*/
@@ -77,14 +77,17 @@ object RichBlockSerializer : JsonContentPolymorphicSerializer<RichBlock>(RichBlo
RichBlockAnchor.TYPE -> RichBlockAnchor.serializer()
RichBlockList.TYPE -> RichBlockList.serializer()
RichBlockBlockQuotation.TYPE -> RichBlockBlockQuotation.serializer()
RichBlockExpandableBlockQuotation.TYPE -> RichBlockExpandableBlockQuotation.serializer()
RichBlockPullQuotation.TYPE -> RichBlockPullQuotation.serializer()
RichBlockCollage.TYPE -> RichBlockCollage.serializer()
RichBlockSlideshow.TYPE -> RichBlockSlideshow.serializer()
RichBlockTable.TYPE -> RichBlockTable.serializer()
RichBlockDetails.TYPE -> RichBlockDetails.serializer()
RichBlockMap.TYPE -> RichBlockMap.serializer()
RichBlockButtons.TYPE -> RichBlockButtons.serializer()
RichBlockAnimation.TYPE -> RichBlockAnimation.serializer()
RichBlockAudio.TYPE -> RichBlockAudio.serializer()
RichBlockDocument.TYPE -> RichBlockDocument.serializer()
RichBlockPhoto.TYPE -> RichBlockPhoto.serializer()
RichBlockVideo.TYPE -> RichBlockVideo.serializer()
RichBlockVoiceNote.TYPE -> RichBlockVoiceNote.serializer()
@@ -1,14 +1,18 @@
package dev.inmo.tgbotapi.types.rich
import dev.inmo.tgbotapi.types.animationField
import dev.inmo.tgbotapi.types.alignField
import dev.inmo.tgbotapi.types.audioField
import dev.inmo.tgbotapi.types.blocksField
import dev.inmo.tgbotapi.types.captionField
import dev.inmo.tgbotapi.types.cellsField
import dev.inmo.tgbotapi.types.buttonsField
import dev.inmo.tgbotapi.types.creditField
import dev.inmo.tgbotapi.types.documentField
import dev.inmo.tgbotapi.types.expressionField
import dev.inmo.tgbotapi.types.files.AnimationFile
import dev.inmo.tgbotapi.types.files.AudioFile
import dev.inmo.tgbotapi.types.files.DocumentFile
import dev.inmo.tgbotapi.types.files.PhotoFile
import dev.inmo.tgbotapi.types.files.TelegramMediaFile
import dev.inmo.tgbotapi.types.files.VideoFile
@@ -16,6 +20,7 @@ import dev.inmo.tgbotapi.types.files.VoiceFile
import dev.inmo.tgbotapi.types.hasSpoilerField
import dev.inmo.tgbotapi.types.heightField
import dev.inmo.tgbotapi.types.isBorderedField
import dev.inmo.tgbotapi.types.isCompactField
import dev.inmo.tgbotapi.types.isOpenField
import dev.inmo.tgbotapi.types.isStripedField
import dev.inmo.tgbotapi.types.itemsField
@@ -299,6 +304,34 @@ data class RichBlockBlockQuotation(
}
}
/**
* A block quotation that is collapsed by default.
*
* @see <a href="https://core.telegram.org/bots/api#richblockexpandableblockquotation">RichBlockExpandableBlockQuotation</a>
*/
@Serializable
data class RichBlockExpandableBlockQuotation(
@SerialName(textField)
val text: RichText,
@SerialName(creditField)
val credit: RichText? = null
) : RichBlock {
@EncodeDefault
@SerialName(typeField)
override val type: String = TYPE
override val markdown: String = markdown(text, credit)
override val html: String = html(text, credit)
companion object {
const val TYPE = "expandable_blockquote"
fun markdown(text: RichText, credit: RichText?): String =
"<blockquote collapsed>${text.markdown}${creditCiteMarkdown(credit)}</blockquote>"
fun html(text: RichText, credit: RichText?): String =
"<blockquote collapsed>${text.html}${creditCiteHtml(credit)}</blockquote>"
}
}
/**
* A quotation with centered text.
*
@@ -395,14 +428,16 @@ data class RichBlockTable(
@SerialName(isStripedField)
val isStriped: Boolean? = null,
@SerialName(captionField)
val caption: RichText? = null
val caption: RichText? = null,
@SerialName(isCompactField)
val isCompact: Boolean? = null
) : RichBlock {
@EncodeDefault
@SerialName(typeField)
override val type: String = TYPE
override val markdown: String = markdown(cells)
override val html: String = html(cells, isBordered, isStriped, caption)
override val html: String = html(cells, isBordered, isStriped, isCompact, caption)
companion object {
const val TYPE = "table"
@@ -424,10 +459,11 @@ data class RichBlockTable(
return lines.joinToString(separator = "\n")
}
fun html(cells: List<List<RichBlockTableCell>>, isBordered: Boolean?, isStriped: Boolean?, caption: RichText?): String {
fun html(cells: List<List<RichBlockTableCell>>, isBordered: Boolean?, isStriped: Boolean?, isCompact: Boolean?, caption: RichText?): String {
val attributes = buildString {
if (isBordered == true) append(" bordered")
if (isStriped == true) append(" striped")
if (isCompact == true) append(" compact")
}
val captionPart = caption?.let { "<caption>${it.html}</caption>" } ?: ""
val rows = cells.joinToString(separator = "") { row ->
@@ -445,6 +481,32 @@ data class RichBlockTable(
}
return "<table$attributes>$captionPart$rows</table>"
}
fun html(cells: List<List<RichBlockTableCell>>, isBordered: Boolean?, isStriped: Boolean?, caption: RichText?): String =
html(cells, isBordered, isStriped, null, caption)
}
}
/** A row of rich message buttons. */
@Serializable
data class RichBlockButtons(
@SerialName(buttonsField)
val buttons: List<RichMessageButton>,
@SerialName(alignField)
val align: RichBlockButtonAlignment? = null
) : RichBlock {
@EncodeDefault
@SerialName(typeField)
override val type: String = TYPE
override val markdown: String = markdown(buttons, align)
override val html: String = html(buttons, align)
companion object {
const val TYPE = "buttons"
fun markdown(buttons: List<RichMessageButton>, align: RichBlockButtonAlignment?): String =
html(buttons, align)
fun html(buttons: List<RichMessageButton>, align: RichBlockButtonAlignment?): String =
"<tg-button-row${align?.let { " align=\"${it.name}\"" } ?: ""}>${buttons.joinToString("") { it.toRichMarkup(it.text.html) }}</tg-button-row>"
}
}
@@ -588,6 +650,31 @@ data class RichBlockAudio(
}
}
/** A block with a general file. */
@Serializable
data class RichBlockDocument(
@SerialName(documentField)
val document: DocumentFile,
@SerialName(captionField)
override val caption: RichBlockCaption? = null
) : RichBlockMedia {
@EncodeDefault
@SerialName(typeField)
override val type: String = TYPE
override val media: TelegramMediaFile
get() = document
override val markdown: String = markdown(document, caption)
override val html: String = html(document, caption)
companion object {
const val TYPE = "document"
fun markdown(document: DocumentFile, caption: RichBlockCaption?): String =
richMediaMarkdown("tg://document?id=${document.fileId.fileId}", caption)
fun html(document: DocumentFile, caption: RichBlockCaption?): String =
richMediaHtml("tg-document", "tg://document?id=${document.fileId.fileId}", spoiler = false, selfClosing = false, caption = caption)
}
}
/**
* A block with a photo.
*
@@ -0,0 +1,291 @@
package dev.inmo.tgbotapi.types.rich
import dev.inmo.tgbotapi.types.LoginURL
import dev.inmo.tgbotapi.types.*
import dev.inmo.tgbotapi.types.buttons.InlineKeyboardButtons.CopyTextButtonData
import dev.inmo.tgbotapi.types.buttons.InlineKeyboardButtons.DisabledButton
import dev.inmo.tgbotapi.types.buttons.InlineKeyboardButtons.SwitchInlineQueryChosenChat as SwitchInlineQueryChosenChatParameters
import dev.inmo.tgbotapi.types.webapps.WebAppInfo
import dev.inmo.tgbotapi.utils.internal.ClassCastsIncluded
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonEncoder
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
/**
* A button in a rich message. Every subtype contains exactly one Bot API button action. [text] may contain only plain
* text, custom emoji and date-time entities. [LoginUrl] does not support [LoginURL.botUsername].
*
* @see <a href="https://core.telegram.org/bots/api#richmessagebutton">RichMessageButton</a>
*/
@Serializable(RichMessageButtonSerializer::class)
@ClassCastsIncluded
sealed interface RichMessageButton {
val text: RichText
val style: RichMessageButtonStyle?
@Serializable
data class Url(
@SerialName(textField) override val text: RichText,
@SerialName(urlField) val url: String,
@SerialName(styleField) override val style: RichMessageButtonStyle? = null
) : RichMessageButton {
init { validateRichMessageButton(text, style) }
}
@Serializable
data class CallbackData(
@SerialName(textField) override val text: RichText,
@SerialName(callbackDataField) val callbackData: String,
@SerialName(styleField) override val style: RichMessageButtonStyle? = null
) : RichMessageButton {
init { validateRichMessageButton(text, style, allowLinkStyle = true) }
}
@Serializable
data class WebApp(
@SerialName(textField) override val text: RichText,
@SerialName(webAppField) val webApp: WebAppInfo,
@SerialName(styleField) override val style: RichMessageButtonStyle? = null
) : RichMessageButton {
init { validateRichMessageButton(text, style) }
}
@Serializable
data class LoginUrl(
@SerialName(textField) override val text: RichText,
@SerialName(loginUrlField) val loginUrl: LoginURL,
@SerialName(styleField) override val style: RichMessageButtonStyle? = null
) : RichMessageButton {
init {
validateRichMessageButton(text, style)
require(loginUrl.botUsername == null) { "Rich message login URL buttons do not support bot usernames" }
}
}
@Serializable
data class SwitchInlineQuery(
@SerialName(textField) override val text: RichText,
@SerialName(switchInlineQueryField) val switchInlineQuery: String,
@SerialName(styleField) override val style: RichMessageButtonStyle? = null
) : RichMessageButton {
init { validateRichMessageButton(text, style) }
}
@Serializable
data class SwitchInlineQueryCurrentChat(
@SerialName(textField) override val text: RichText,
@SerialName(switchInlineQueryCurrentChatField) val switchInlineQueryCurrentChat: String,
@SerialName(styleField) override val style: RichMessageButtonStyle? = null
) : RichMessageButton {
init { validateRichMessageButton(text, style) }
}
@Serializable
data class SwitchInlineQueryChosenChat(
@SerialName(textField) override val text: RichText,
@SerialName(switchInlineQueryChosenChatField) val switchInlineQueryChosenChat: SwitchInlineQueryChosenChatParameters,
@SerialName(styleField) override val style: RichMessageButtonStyle? = null
) : RichMessageButton {
init { validateRichMessageButton(text, style) }
}
@Serializable
data class CopyText(
@SerialName(textField) override val text: RichText,
@SerialName(copyTextField) val copyText: CopyTextButtonData,
@SerialName(styleField) override val style: RichMessageButtonStyle? = null
) : RichMessageButton {
init { validateRichMessageButton(text, style) }
}
@Serializable
data class Disabled(
@SerialName(textField) override val text: RichText,
@SerialName(styleField) override val style: RichMessageButtonStyle? = null
) : RichMessageButton {
init { validateRichMessageButton(text, style) }
@SerialName(disabledField)
@kotlinx.serialization.EncodeDefault
val disabled: DisabledButton = DisabledButton
}
}
private fun validateRichMessageButton(
text: RichText,
style: RichMessageButtonStyle?,
allowLinkStyle: Boolean = false
) {
require(text.isValidRichMessageButtonText()) {
"Rich message button text can contain only plain text, custom emoji and date-time entities"
}
require(allowLinkStyle || style != RichMessageButtonStyle.Link) {
"The link style is allowed only for callback buttons"
}
}
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 {
val name: String
@Serializable(Serializer::class)
data object Danger : RichMessageButtonStyle { override val name = "danger" }
@Serializable(Serializer::class)
data object Success : RichMessageButtonStyle { override val name = "success" }
@Serializable(Serializer::class)
data object Primary : RichMessageButtonStyle { override val name = "primary" }
@Serializable(Serializer::class)
data object Link : RichMessageButtonStyle { override val name = "link" }
object Serializer : KSerializer<RichMessageButtonStyle> {
override val descriptor: SerialDescriptor = String.serializer().descriptor
override fun serialize(encoder: Encoder, value: RichMessageButtonStyle) = encoder.encodeString(value.name)
override fun deserialize(decoder: Decoder): RichMessageButtonStyle = when (val name = decoder.decodeString()) {
Danger.name -> Danger
Success.name -> Success
Primary.name -> Primary
Link.name -> Link
else -> throw SerializationException("Unknown RichMessageButtonStyle: $name")
}
}
}
object RichMessageButtonSerializer : KSerializer<RichMessageButton> {
override val descriptor: SerialDescriptor = JsonObject.serializer().descriptor
private val actionFields = setOf(
urlField, callbackDataField, webAppField, loginUrlField, switchInlineQueryField,
switchInlineQueryCurrentChatField, switchInlineQueryChosenChatField, copyTextField, disabledField
)
override fun deserialize(decoder: Decoder): RichMessageButton {
val input = decoder as? JsonDecoder ?: throw SerializationException("RichMessageButton requires JSON")
val element = input.decodeJsonElement().jsonObject
val actions = element.keys.intersect(actionFields)
if (actions.size != 1) throw SerializationException("RichMessageButton requires exactly one action, got $actions")
return when (actions.single()) {
urlField -> input.json.decodeFromJsonElement(RichMessageButton.Url.serializer(), element)
callbackDataField -> input.json.decodeFromJsonElement(RichMessageButton.CallbackData.serializer(), element)
webAppField -> input.json.decodeFromJsonElement(RichMessageButton.WebApp.serializer(), element)
loginUrlField -> input.json.decodeFromJsonElement(RichMessageButton.LoginUrl.serializer(), element)
switchInlineQueryField -> input.json.decodeFromJsonElement(RichMessageButton.SwitchInlineQuery.serializer(), element)
switchInlineQueryCurrentChatField -> input.json.decodeFromJsonElement(RichMessageButton.SwitchInlineQueryCurrentChat.serializer(), element)
switchInlineQueryChosenChatField -> input.json.decodeFromJsonElement(RichMessageButton.SwitchInlineQueryChosenChat.serializer(), element)
copyTextField -> input.json.decodeFromJsonElement(RichMessageButton.CopyText.serializer(), element)
disabledField -> input.json.decodeFromJsonElement(RichMessageButton.Disabled.serializer(), element)
else -> error("Unreachable RichMessageButton action")
}
}
override fun serialize(encoder: Encoder, value: RichMessageButton) {
val output = encoder as? JsonEncoder ?: throw SerializationException("RichMessageButton requires JSON")
val element = when (value) {
is RichMessageButton.Url -> output.json.encodeToJsonElement(RichMessageButton.Url.serializer(), value)
is RichMessageButton.CallbackData -> output.json.encodeToJsonElement(RichMessageButton.CallbackData.serializer(), value)
is RichMessageButton.WebApp -> output.json.encodeToJsonElement(RichMessageButton.WebApp.serializer(), value)
is RichMessageButton.LoginUrl -> output.json.encodeToJsonElement(RichMessageButton.LoginUrl.serializer(), value)
is RichMessageButton.SwitchInlineQuery -> output.json.encodeToJsonElement(RichMessageButton.SwitchInlineQuery.serializer(), value)
is RichMessageButton.SwitchInlineQueryCurrentChat -> output.json.encodeToJsonElement(RichMessageButton.SwitchInlineQueryCurrentChat.serializer(), value)
is RichMessageButton.SwitchInlineQueryChosenChat -> output.json.encodeToJsonElement(RichMessageButton.SwitchInlineQueryChosenChat.serializer(), value)
is RichMessageButton.CopyText -> output.json.encodeToJsonElement(RichMessageButton.CopyText.serializer(), value)
is RichMessageButton.Disabled -> output.json.encodeToJsonElement(RichMessageButton.Disabled.serializer(), value)
}
output.encodeJsonElement(element)
}
}
/** Horizontal alignment of [RichBlockButtons]. */
@Serializable(RichBlockButtonAlignment.Serializer::class)
sealed interface RichBlockButtonAlignment {
val name: String
@Serializable(Serializer::class)
data object Left : RichBlockButtonAlignment { override val name = "left" }
@Serializable(Serializer::class)
data object Center : RichBlockButtonAlignment { override val name = "center" }
@Serializable(Serializer::class)
data object Right : RichBlockButtonAlignment { override val name = "right" }
object Serializer : KSerializer<RichBlockButtonAlignment> {
override val descriptor: SerialDescriptor = String.serializer().descriptor
override fun serialize(encoder: Encoder, value: RichBlockButtonAlignment) = encoder.encodeString(value.name)
override fun deserialize(decoder: Decoder): RichBlockButtonAlignment = when (val name = decoder.decodeString()) {
Left.name -> Left
Center.name -> Center
Right.name -> Right
else -> throw SerializationException("Unknown RichBlockButtonAlignment: $name")
}
}
}
internal fun RichMessageButton.toRichMarkup(text: String): 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) }
return "<tg-button ${attributes.joinToString(" ")}>$text</tg-button>"
}
private fun String.escapeRichMarkupAttribute(): String = replace("&", "&amp;")
.replace("\"", "&quot;")
.replace("<", "&lt;")
.replace(">", "&gt;")
@@ -133,6 +133,7 @@ object RichTextEntitySerializer : JsonContentPolymorphicSerializer<RichTextEntit
RichTextHashtag.TYPE -> RichTextHashtag.serializer()
RichTextCashtag.TYPE -> RichTextCashtag.serializer()
RichTextBotCommand.TYPE -> RichTextBotCommand.serializer()
RichTextButton.TYPE -> RichTextButton.serializer()
RichTextAnchor.TYPE -> RichTextAnchor.serializer()
RichTextAnchorLink.TYPE -> RichTextAnchorLink.serializer()
RichTextReference.TYPE -> RichTextReference.serializer()
@@ -98,6 +98,8 @@ class RichTextBuilder {
fun botCommand(botCommand: String, block: RichTextBuilder.() -> Unit) =
add(RichTextBotCommand(buildRichText(block), botCommand))
fun button(button: RichMessageButton) = add(RichTextButton(button))
fun anchor(name: String) = add(RichTextAnchor(name))
fun anchorLink(text: String, anchorName: String) = add(RichTextAnchorLink(RichTextPlain(text), anchorName))
@@ -4,6 +4,7 @@ import dev.inmo.tgbotapi.types.CustomEmojiId
import dev.inmo.tgbotapi.types.alternativeTextField
import dev.inmo.tgbotapi.types.anchorNameField
import dev.inmo.tgbotapi.types.bankCardNumberField
import dev.inmo.tgbotapi.types.buttonField
import dev.inmo.tgbotapi.types.botCommandFullField
import dev.inmo.tgbotapi.types.cashtagField
import dev.inmo.tgbotapi.types.chat.User
@@ -28,6 +29,31 @@ import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* A [RichMessageButton] embedded in [RichText].
*
* @see <a href="https://core.telegram.org/bots/api#richtextbutton">RichTextButton</a>
*/
@Serializable
data class RichTextButton(
@SerialName(buttonField)
val button: RichMessageButton
) : RichTextEntity {
@EncodeDefault
@SerialName(typeField)
override val type: String = TYPE
override val rawText: String = button.text.rawText
override val markdown: String = markdown(button)
override val html: String = html(button)
companion object {
const val TYPE = "button"
fun markdown(button: RichMessageButton): String = button.toRichMarkup(button.text.markdown)
fun html(button: RichMessageButton): String = button.toRichMarkup(button.text.html)
}
}
/**
* A bold [RichTextEntity].
*
@@ -48,14 +48,14 @@ fun String.escapeMarkdownV2PreAndCode() = escapeMarkdownV2(markdownV2PreAndCodeE
fun String.escapeMarkdownV2Common() = escapeMarkdownV2(markdownV2CommonEscapes)
fun String.toHtml(): String = replace(
"&",
"&amp;"
).replace(
"<",
"&lt;"
).replace(
">",
"&gt;"
).replace(
"&",
"&amp;"
)
fun String.splitForText() = chunked(textLength.last)
@@ -2,8 +2,12 @@ package dev.inmo.tgbotapi.types
import dev.inmo.tgbotapi.types.rich.*
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFails
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
class RichMessageSerializationTest {
@@ -68,4 +72,95 @@ class RichMessageSerializationTest {
val decoded = json.decodeFromString(RichTextInfo.serializer(), encoded)
assertEquals(message, decoded)
}
@Test
fun serializesRichMessageButtonsWithFlatActionShape() {
val button: RichMessageButton = RichMessageButton.CallbackData(
text = RichTextPlain("Press"),
callbackData = "callback",
style = RichMessageButtonStyle.Link
)
val encoded = json.encodeToString(RichMessageButtonSerializer, button)
val element = json.parseToJsonElement(encoded).jsonObject
assertEquals("callback", element["callback_data"]?.jsonPrimitive?.content)
assertTrue("url" !in element)
assertEquals(button, json.decodeFromString(RichMessageButtonSerializer, encoded))
assertEquals(
RichMessageButton.Disabled(RichTextPlain("Disabled")),
json.decodeFromString(RichMessageButtonSerializer, "{\"text\":\"Disabled\",\"disabled\":{}}")
)
}
@Test
fun rejectsRichMessageButtonsWithZeroOrMultipleActions() {
kotlin.test.assertFailsWith<kotlinx.serialization.SerializationException> {
json.decodeFromString(RichMessageButtonSerializer, "{\"text\":\"none\"}")
}
kotlin.test.assertFailsWith<kotlinx.serialization.SerializationException> {
json.decodeFromString(RichMessageButtonSerializer, "{\"text\":\"multiple\",\"url\":\"https://t.me\",\"disabled\":{}}")
}
}
@Test
fun validatesRichMessageButtonTextContract() {
val plain = RichTextPlain("plain")
val customEmoji = RichTextCustomEmoji(CustomEmojiId("emoji"), "emoji")
val dateTime = RichTextDateTime(RichTextPlain("date"), TelegramDate(1L), "wDT")
val validTexts = listOf(
plain,
customEmoji,
dateTime,
RichTextGroup(listOf(plain, customEmoji, dateTime, RichTextGroup(listOf(plain))))
)
validTexts.forEach { text ->
assertEquals(text, RichMessageButton.Disabled(text).text)
}
val invalidTexts = listOf(
RichTextBold(plain),
RichTextUrl(plain, "https://example.org"),
RichTextGroup(listOf(plain, RichTextBold(plain))),
RichTextDateTime(RichTextBold(plain), TelegramDate(1L), "wDT")
)
invalidTexts.forEach { text ->
assertFailsWith<IllegalArgumentException> { RichMessageButton.Disabled(text) }
}
assertFails {
json.decodeFromString(
RichMessageButtonSerializer,
"{\"text\":{\"type\":\"bold\",\"text\":\"invalid\"},\"disabled\":{}}"
)
}
}
@Test
fun rejectsBotUsernameInRichMessageLoginUrlButtons() {
assertFailsWith<IllegalArgumentException> {
RichMessageButton.LoginUrl(
RichTextPlain("Login"),
LoginURL("https://example.org", botUsername = "example_bot")
)
}
assertFails {
json.decodeFromString(
RichMessageButtonSerializer,
"{\"text\":\"Login\",\"login_url\":{\"url\":\"https://example.org\",\"bot_username\":\"example_bot\"}}"
)
}
}
@Test
fun decodesNewRichTextAndBlockVariants() {
val text = json.decodeFromString(RichTextSerializer, "{\"type\":\"button\",\"button\":{\"text\":\"Press\",\"disabled\":{}}}")
assertEquals(RichTextButton(RichMessageButton.Disabled(RichTextPlain("Press"))), text)
val blocks = json.decodeFromString(
kotlinx.serialization.builtins.ListSerializer(RichBlockSerializer),
"[{\"type\":\"buttons\",\"buttons\":[{\"text\":\"Press\",\"disabled\":{}}],\"align\":\"center\"},{\"type\":\"expandable_blockquote\",\"text\":\"quote\"}]"
)
assertEquals(RichBlockButtons(listOf(RichMessageButton.Disabled(RichTextPlain("Press"))), RichBlockButtonAlignment.Center), blocks[0])
assertEquals(RichBlockExpandableBlockQuotation(RichTextPlain("quote")), blocks[1])
}
}
@@ -2,6 +2,7 @@ package dev.inmo.tgbotapi.types.rich
import dev.inmo.tgbotapi.requests.abstracts.FileId
import dev.inmo.tgbotapi.types.media.TelegramMediaPhoto
import dev.inmo.tgbotapi.types.media.TelegramMediaDocument
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -125,4 +126,19 @@ class InputRichBlocksDslTest {
blocks
)
}
@Test
fun buildsButtonsExpandableQuotationAndDocument() {
val document = TelegramMediaDocument(FileId("document_file_id"))
val blocks = buildInputRichBlocks {
expandableBlockQuotation { plain("quote") }
buttons(listOf(RichMessageButton.Disabled(RichTextPlain("Disabled"))), RichBlockButtonAlignment.Left)
document(document)
table(isCompact = true) { }
}
assertEquals(InputRichBlockExpandableBlockQuotation(RichTextPlain("quote")), blocks[0])
assertEquals(InputRichBlockButtons(listOf(RichMessageButton.Disabled(RichTextPlain("Disabled"))), RichBlockButtonAlignment.Left), blocks[1])
assertEquals(InputRichBlockDocument(document), blocks[2])
assertEquals(true, (blocks[3] as InputRichBlockTable).isCompact)
}
}
@@ -1,7 +1,9 @@
package dev.inmo.tgbotapi.types.rich
import dev.inmo.tgbotapi.requests.abstracts.FileId
import dev.inmo.tgbotapi.requests.abstracts.asMultipartFile
import dev.inmo.tgbotapi.types.media.TelegramMediaPhoto
import dev.inmo.tgbotapi.types.media.TelegramMediaDocument
import dev.inmo.tgbotapi.types.media.TelegramMediaVoiceNote
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.SerializationException
@@ -73,6 +75,29 @@ class InputRichMessageSerializationTest {
assertEquals(true, cell["is_header"]?.jsonPrimitive?.boolean)
}
@Test
fun encodesCompactTablesButtonsAndDocuments() {
val document = TelegramMediaDocument("content".encodeToByteArray().asMultipartFile("document.txt"))
val message = InputRichMessageBlocks(
blocks = listOf(
InputRichBlockTable(emptyList(), isCompact = true),
InputRichBlockButtons(listOf(RichMessageButton.Disabled(RichTextPlain("Disabled")))),
InputRichBlockDocument(document)
)
)
val blocks = json.encodeToJsonElement(InputRichMessage.serializer(), message).jsonObject["blocks"]!!.jsonArray
assertEquals(true, blocks[0].jsonObject["is_compact"]?.jsonPrimitive?.boolean)
assertEquals("buttons", blocks[1].jsonObject["type"]?.jsonPrimitive?.content)
assertEquals("document", blocks[2].jsonObject["type"]?.jsonPrimitive?.content)
assertEquals(document.file, message.multipartFiles.values.single())
}
@Test
fun limitsInputButtonRowsToOneThroughEightButtons() {
assertFailsWith<IllegalArgumentException> { InputRichBlockButtons(emptyList()) }
assertFailsWith<IllegalArgumentException> { InputRichBlockButtons(List(9) { RichMessageButton.Disabled(RichTextPlain("x")) }) }
}
@Test
fun serializesTableCellVariantsWithSharedFlatShape() {
val header: RichBlockTableCell = RichBlockTableCell.Header(RichTextPlain("h"), align = RichBlockTableCellAlign.Left, valign = RichBlockTableCellVAlign.Top)
@@ -170,6 +195,17 @@ class InputRichMessageSerializationTest {
assertEquals("photo_file_id", mediaJson["media"]!!.jsonObject["media"]?.jsonPrimitive?.content)
}
@Test
fun encodesDocumentMediaReferences() {
val message = InputRichMessageMarkdown(
markdown = "See [document](tg://document?id=doc)",
media = listOf(InputRichMessageMedia("doc", TelegramMediaDocument(FileId("document_file_id"))))
)
val mediaJson = json.encodeToJsonElement(InputRichMessage.serializer(), message).jsonObject["media"]!!.jsonArray.single().jsonObject
assertEquals("document", mediaJson["media"]!!.jsonObject["type"]?.jsonPrimitive?.content)
assertEquals("document_file_id", mediaJson["media"]!!.jsonObject["media"]?.jsonPrimitive?.content)
}
@Test
fun roundTripsListOfInputRichBlocks() {
val blocks: List<InputRichBlock> = listOf(
@@ -96,6 +96,15 @@ class RichBlockFormattingTest {
assertEquals("<blockquote><p>q</p></blockquote>", block.html)
}
@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)
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)
}
@Test
fun details() {
val block = RichBlockDetails(RichTextPlain("sum"), listOf(RichBlockParagraph(RichTextPlain("body"))), isOpen = true)
@@ -2,9 +2,13 @@ package dev.inmo.tgbotapi.types.rich
import dev.inmo.tgbotapi.types.ChatId
import dev.inmo.tgbotapi.types.CustomEmojiId
import dev.inmo.tgbotapi.types.LoginURL
import dev.inmo.tgbotapi.types.RawChatId
import dev.inmo.tgbotapi.types.TelegramDate
import dev.inmo.tgbotapi.types.buttons.InlineKeyboardButtons.CopyTextButtonData
import dev.inmo.tgbotapi.types.buttons.InlineKeyboardButtons.SwitchInlineQueryChosenChat as SwitchInlineQueryChosenChatParameters
import dev.inmo.tgbotapi.types.chat.CommonUser
import dev.inmo.tgbotapi.types.webapps.WebAppInfo
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -206,7 +210,49 @@ class RichTextFormattingTest {
@Test
fun plainHtmlEscapesAngleBrackets() {
assertEquals("a&amp;lt;b", RichTextPlain("a<b").html)
assertEquals("a&lt;b", RichTextPlain("a<b").html)
}
@Test
fun buttonHtmlEscapesContentExactlyOnce() {
val button = RichTextButton(RichMessageButton.Disabled(RichTextPlain("<&>")))
assertEquals("<tg-button type=\"disabled\">&lt;&amp;&gt;</tg-button>", button.html)
}
@Test
fun buttonAttributesEscapeRichMarkupMetacharacters() {
val rawAttribute = "a&\"<>"
val escapedAttribute = "a&amp;&quot;&lt;&gt;"
val text = RichTextPlain("Button")
val buttonsWithExpectedAttributes = listOf(
RichMessageButton.Url(text, rawAttribute) to
"type=\"url\" url=\"$escapedAttribute\"",
RichMessageButton.CallbackData(text, rawAttribute) to
"type=\"callback_data\" data=\"$escapedAttribute\"",
RichMessageButton.WebApp(text, WebAppInfo(rawAttribute)) to
"type=\"web_app\" url=\"$escapedAttribute\"",
RichMessageButton.LoginUrl(
text,
LoginURL(rawAttribute, rawAttribute, requestWriteAccess = true)
) to "type=\"login_url\" url=\"$escapedAttribute\" forward-text=\"$escapedAttribute\" " +
"request-write-access",
RichMessageButton.SwitchInlineQuery(text, rawAttribute) to
"type=\"switch_inline_query\" query=\"$escapedAttribute\"",
RichMessageButton.SwitchInlineQueryCurrentChat(text, rawAttribute) to
"type=\"switch_inline_query_current_chat\" query=\"$escapedAttribute\"",
RichMessageButton.SwitchInlineQueryChosenChat(
text,
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\""
)
buttonsWithExpectedAttributes.forEach { (button, expectedAttributes) ->
val expectedMarkup = "<tg-button $expectedAttributes>Button</tg-button>"
assertEquals(expectedMarkup, RichTextButton(button).html)
assertEquals(expectedMarkup, RichTextButton(button).markdown)
}
}
@Test