1
0
mirror of https://github.com/InsanusMokrassar/TelegramBotAPI.git synced 2026-08-19 15:46:31 +00:00

Add Bot API 10.2 Rich Messages support

Input-side rich messages: InputRichMessage.blocks/media fields, 21
InputRichBlock* block types + InputRichBlockSerializer, InputRichBlockListItem,
InputRichMessageMedia, TelegramMediaVoiceNote + RichMessageMemberTelegramMedia
marker on Animation/Audio/Photo/Video, DSL builders, and multipart upload for
SendRichMessage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-18 19:16:07 +06:00
parent 0b3a5bb0af
commit 16be206b0f
21 changed files with 2311 additions and 21 deletions

View File

@@ -1,5 +1,8 @@
package dev.inmo.tgbotapi.requests.send
import dev.inmo.tgbotapi.requests.abstracts.MultipartFile
import dev.inmo.tgbotapi.requests.abstracts.MultipartRequest
import dev.inmo.tgbotapi.requests.abstracts.SimpleRequest
import dev.inmo.tgbotapi.requests.send.abstracts.ReplyingMarkupSendMessageRequest
import dev.inmo.tgbotapi.requests.send.abstracts.SendContentMessageRequest
import dev.inmo.tgbotapi.types.*
@@ -10,6 +13,7 @@ import dev.inmo.tgbotapi.types.message.abstracts.ChatContentMessage
import dev.inmo.tgbotapi.types.message.abstracts.TelegramBotAPIMessageDeserializationStrategyClass
import dev.inmo.tgbotapi.types.message.content.RichMessageContent
import dev.inmo.tgbotapi.types.rich.InputRichMessage
import dev.inmo.tgbotapi.types.rich.multipartFiles
import kotlinx.serialization.DeserializationStrategy
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.ExperimentalSerializationApi
@@ -56,10 +60,14 @@ data class SendRichMessage(
@SerialName(replyMarkupField)
override val replyMarkup: KeyboardMarkup? = null
) : SendContentMessageRequest<ChatContentMessage<RichMessageContent>>,
ReplyingMarkupSendMessageRequest<ChatContentMessage<RichMessageContent>> {
ReplyingMarkupSendMessageRequest<ChatContentMessage<RichMessageContent>>,
MultipartRequest.Common<ChatContentMessage<RichMessageContent>> {
override fun method(): String = "sendRichMessage"
override val resultDeserializer: DeserializationStrategy<ChatContentMessage<RichMessageContent>>
get() = RichMessageContentMessageResultDeserializer
override val requestSerializer: SerializationStrategy<*>
get() = serializer()
override val data: SimpleRequest<ChatContentMessage<RichMessageContent>>
get() = this
override val mediaMap: Map<String, MultipartFile> by lazy { richMessage.multipartFiles }
}

View File

@@ -8,6 +8,7 @@ import dev.inmo.tgbotapi.types.draftIdField
import dev.inmo.tgbotapi.types.messageThreadIdField
import dev.inmo.tgbotapi.types.richMessageField
import dev.inmo.tgbotapi.types.rich.InputRichMessage
import dev.inmo.tgbotapi.types.rich.multipartFiles
import dev.inmo.tgbotapi.utils.serializers.UnitFromBooleanSerializer
import kotlinx.serialization.DeserializationStrategy
import kotlinx.serialization.SerialName
@@ -39,6 +40,9 @@ data class SendRichMessageDraft(
require(draftId != 0L) {
"draftId of SendRichMessageDraft must be non-zero"
}
require(richMessage.multipartFiles.isEmpty()) {
"sendRichMessageDraft does not support direct upload of new files"
}
}
override val requestSerializer: SerializationStrategy<*>

View File

@@ -0,0 +1,7 @@
package dev.inmo.tgbotapi.types.media
import kotlinx.serialization.Serializable
@Suppress("SERIALIZER_TYPE_INCOMPATIBLE")
@Serializable(TelegramMediaSerializer::class)
sealed interface RichMessageMemberTelegramMedia : TelegramMedia

View File

@@ -67,7 +67,7 @@ data class TelegramMediaAnimation internal constructor(
override val height: Int? = null,
override val duration: Long? = null,
override val thumb: InputFile? = null
) : TelegramFreeMedia, SizedTelegramMedia, DuratedTelegramMedia, ThumbedTelegramMedia, TextedOutput, SpoilerableTelegramMedia, WithCustomizableCaptionTelegramMedia, InputPollMedia, InputPollOptionMedia {
) : TelegramFreeMedia, RichMessageMemberTelegramMedia, SizedTelegramMedia, DuratedTelegramMedia, ThumbedTelegramMedia, TextedOutput, SpoilerableTelegramMedia, WithCustomizableCaptionTelegramMedia, InputPollMedia, InputPollOptionMedia {
@EncodeDefault
override val type: String = TYPE
override val textSources: TextSourcesList? by lazy {

View File

@@ -51,7 +51,7 @@ data class TelegramMediaAudio internal constructor(
override val performer: String? = null,
override val title: String? = null,
override val thumb: InputFile? = null
) : TelegramFreeMedia, AudioMediaGroupMemberTelegramMedia, DuratedTelegramMedia, ThumbedTelegramMedia, TitledTelegramMedia,
) : TelegramFreeMedia, RichMessageMemberTelegramMedia, AudioMediaGroupMemberTelegramMedia, DuratedTelegramMedia, ThumbedTelegramMedia, TitledTelegramMedia,
Performerable, InputPollMedia {
@EncodeDefault
override val type: String = TYPE

View File

@@ -44,7 +44,7 @@ data class TelegramMediaPhoto internal constructor(
override val spoilered: Boolean = false,
@SerialName(showCaptionAboveMediaField)
override val showCaptionAboveMedia: Boolean = false,
) : TelegramFreeMedia, VisualMediaGroupMemberTelegramMedia, InputPollMedia, InputPollOptionMedia {
) : TelegramFreeMedia, RichMessageMemberTelegramMedia, VisualMediaGroupMemberTelegramMedia, InputPollMedia, InputPollOptionMedia {
@EncodeDefault
override val type: String = TYPE
override val textSources: TextSourcesList? by lazy {

View File

@@ -25,6 +25,7 @@ object TelegramMediaSerializer : KSerializer<TelegramMedia> {
is TelegramPaidMediaPhoto -> TelegramPaidMediaPhoto.serializer().serialize(encoder, value)
is TelegramPaidMediaLivePhoto -> TelegramPaidMediaLivePhoto.serializer().serialize(encoder, value)
is TelegramMediaSticker -> TelegramMediaSticker.serializer().serialize(encoder, value)
is TelegramMediaVoiceNote -> TelegramMediaVoiceNote.serializer().serialize(encoder, value)
}
}

View File

@@ -92,6 +92,7 @@ data class TelegramMediaVideo internal constructor (
@SerialName(supportsStreamingField)
override val supportsStreaming: Boolean = false,
) : TelegramFreeMedia,
RichMessageMemberTelegramMedia,
SizedTelegramMedia,
DuratedTelegramMedia,
ThumbedTelegramMedia,

View File

@@ -0,0 +1,79 @@
package dev.inmo.tgbotapi.types.media
import dev.inmo.tgbotapi.abstracts.TextedOutput
import dev.inmo.tgbotapi.requests.abstracts.InputFile
import dev.inmo.tgbotapi.requests.abstracts.fileIdToSend
import dev.inmo.tgbotapi.types.*
import dev.inmo.tgbotapi.types.message.textsources.TextSourcesList
import dev.inmo.tgbotapi.types.message.ParseMode
import dev.inmo.tgbotapi.types.message.parseModeField
import dev.inmo.tgbotapi.types.files.VoiceFile
import dev.inmo.tgbotapi.types.message.*
import dev.inmo.tgbotapi.types.message.RawMessageEntity
import dev.inmo.tgbotapi.types.message.toRawMessageEntities
import dev.inmo.tgbotapi.utils.extensions.makeString
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
fun TelegramMediaVoiceNote(
file: InputFile,
entities: TextSourcesList,
duration: Long? = null
) = TelegramMediaVoiceNote(
file, entities.makeString(), null, entities.toRawMessageEntities(), duration
)
fun TelegramMediaVoiceNote(
file: InputFile,
text: String? = null,
parseMode: ParseMode? = null,
duration: Long? = null
) = TelegramMediaVoiceNote(
file, text, parseMode, null, duration
)
@ConsistentCopyVisibility
@Serializable
data class TelegramMediaVoiceNote internal constructor(
override val file: InputFile,
@SerialName(captionField)
override val text: String? = null,
@SerialName(parseModeField)
override val parseMode: ParseMode? = null,
@SerialName(captionEntitiesField)
private val rawEntities: List<RawMessageEntity>? = null,
override val duration: Long? = null
) : RichMessageMemberTelegramMedia, DuratedTelegramMedia, TextedOutput {
@EncodeDefault
override val type: String = TYPE
override val textSources: TextSourcesList? by lazy {
rawEntities ?.asTextSources(text ?: return@lazy null)
}
@SerialName(mediaField)
override val media: String
init { media = file.fileIdToSend } // crutch until js compiling will be fixed
companion object {
const val TYPE = "voice_note"
}
}
fun VoiceFile.toTelegramMediaVoiceNote(
text: String? = null,
parseMode: ParseMode? = null
): TelegramMediaVoiceNote = TelegramMediaVoiceNote(
fileId,
text,
parseMode,
duration
)
fun VoiceFile.toTelegramMediaVoiceNote(
textSources: TextSourcesList = emptyList()
): TelegramMediaVoiceNote = TelegramMediaVoiceNote(
fileId,
textSources,
duration
)

View File

@@ -0,0 +1,72 @@
package dev.inmo.tgbotapi.types.rich
import dev.inmo.tgbotapi.types.media.TelegramMedia
import dev.inmo.tgbotapi.types.typeField
import dev.inmo.tgbotapi.utils.internal.ClassCastsIncluded
import kotlinx.serialization.DeserializationStrategy
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonContentPolymorphicSerializer
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
/**
* Represents a block in a rich formatted message to be sent.
*
* @see <a href="https://core.telegram.org/bots/api#inputrichblock">InputRichBlock</a>
*/
@Serializable(InputRichBlockSerializer::class)
@ClassCastsIncluded
sealed interface InputRichBlock {
val type: String
}
@Serializable(InputRichBlockSerializer::class)
sealed interface InputRichBlockMedia : InputRichBlock {
val media: TelegramMedia
val caption: RichBlockCaption?
}
/**
* The nested [InputRichBlock]s directly contained by this block, or an empty list for leaf blocks. Container blocks
* ([InputRichBlockList] via its [InputRichBlockListItem.blocks], [InputRichBlockBlockQuotation], [InputRichBlockCollage],
* [InputRichBlockSlideshow] and [InputRichBlockDetails]) expose their children here.
*/
val InputRichBlock.subBlocks: List<InputRichBlock>
get() = when (this) {
is InputRichBlockList -> items.flatMap { it.blocks }
is InputRichBlockBlockQuotation -> blocks
is InputRichBlockCollage -> blocks
is InputRichBlockSlideshow -> blocks
is InputRichBlockDetails -> blocks
else -> emptyList()
}
object InputRichBlockSerializer : JsonContentPolymorphicSerializer<InputRichBlock>(InputRichBlock::class) {
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<InputRichBlock> {
return when (val type = element.jsonObject[typeField]?.jsonPrimitive?.content) {
InputRichBlockParagraph.TYPE -> InputRichBlockParagraph.serializer()
InputRichBlockSectionHeading.TYPE -> InputRichBlockSectionHeading.serializer()
InputRichBlockPreformatted.TYPE -> InputRichBlockPreformatted.serializer()
InputRichBlockFooter.TYPE -> InputRichBlockFooter.serializer()
InputRichBlockDivider.TYPE -> InputRichBlockDivider.serializer()
InputRichBlockMathematicalExpression.TYPE -> InputRichBlockMathematicalExpression.serializer()
InputRichBlockAnchor.TYPE -> InputRichBlockAnchor.serializer()
InputRichBlockList.TYPE -> InputRichBlockList.serializer()
InputRichBlockBlockQuotation.TYPE -> InputRichBlockBlockQuotation.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()
InputRichBlockAnimation.TYPE -> InputRichBlockAnimation.serializer()
InputRichBlockAudio.TYPE -> InputRichBlockAudio.serializer()
InputRichBlockPhoto.TYPE -> InputRichBlockPhoto.serializer()
InputRichBlockVideo.TYPE -> InputRichBlockVideo.serializer()
InputRichBlockVoiceNote.TYPE -> InputRichBlockVoiceNote.serializer()
InputRichBlockThinking.TYPE -> InputRichBlockThinking.serializer()
else -> error("Unknown InputRichBlock type: $type")
}
}
}

View File

@@ -0,0 +1,31 @@
package dev.inmo.tgbotapi.types.rich
import dev.inmo.tgbotapi.types.blocksField
import dev.inmo.tgbotapi.types.hasCheckboxField
import dev.inmo.tgbotapi.types.isCheckedField
import dev.inmo.tgbotapi.types.typeField
import dev.inmo.tgbotapi.types.valueField
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* An item of an [InputRichBlockList].
*
* @see <a href="https://core.telegram.org/bots/api#inputrichblocklistitem">InputRichBlockListItem</a>
*/
@Serializable
data class InputRichBlockListItem(
@SerialName(blocksField)
val blocks: List<InputRichBlock>,
@SerialName(hasCheckboxField)
val hasCheckbox: Boolean? = null,
@SerialName(isCheckedField)
val isChecked: Boolean? = null,
@SerialName(valueField)
val value: Int? = null,
/**
* For ordered lists, the type of the item label; must be one of "a", "A", "i", "I" or "1".
*/
@SerialName(typeField)
val labelType: String? = null
)

View File

@@ -0,0 +1,499 @@
package dev.inmo.tgbotapi.types.rich
import dev.inmo.tgbotapi.types.animationField
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.creditField
import dev.inmo.tgbotapi.types.expressionField
import dev.inmo.tgbotapi.types.heightField
import dev.inmo.tgbotapi.types.isBorderedField
import dev.inmo.tgbotapi.types.isOpenField
import dev.inmo.tgbotapi.types.isStripedField
import dev.inmo.tgbotapi.types.itemsField
import dev.inmo.tgbotapi.types.languageField
import dev.inmo.tgbotapi.types.location.StaticLocation
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.TelegramMediaPhoto
import dev.inmo.tgbotapi.types.media.TelegramMediaVideo
import dev.inmo.tgbotapi.types.media.TelegramMediaVoiceNote
import dev.inmo.tgbotapi.types.nameField
import dev.inmo.tgbotapi.types.photoField
import dev.inmo.tgbotapi.types.sizeField
import dev.inmo.tgbotapi.types.summaryField
import dev.inmo.tgbotapi.types.textField
import dev.inmo.tgbotapi.types.typeField
import dev.inmo.tgbotapi.types.videoField
import dev.inmo.tgbotapi.types.voiceNoteField
import dev.inmo.tgbotapi.types.widthField
import dev.inmo.tgbotapi.types.zoomField
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* A text paragraph.
*
* @see <a href="https://core.telegram.org/bots/api#inputrichblockparagraph">InputRichBlockParagraph</a>
*/
@Serializable
data class InputRichBlockParagraph(
@SerialName(textField)
val text: RichText
) : InputRichBlock {
@EncodeDefault
@SerialName(typeField)
override val type: String = TYPE
companion object {
const val TYPE = "paragraph"
}
}
/**
* A section heading.
*
* @see <a href="https://core.telegram.org/bots/api#inputrichblocksectionheading">InputRichBlockSectionHeading</a>
*/
@Serializable
data class InputRichBlockSectionHeading(
@SerialName(textField)
val text: RichText,
/**
* Relative size of the text font; 1-6, 1 is the largest, 6 is the smallest.
*/
@SerialName(sizeField)
val level: Int
) : InputRichBlock {
@EncodeDefault
@SerialName(typeField)
override val type: String = TYPE
companion object {
const val TYPE = "heading"
}
}
/**
* A preformatted text block.
*
* @see <a href="https://core.telegram.org/bots/api#inputrichblockpreformatted">InputRichBlockPreformatted</a>
*/
@Serializable
data class InputRichBlockPreformatted(
@SerialName(textField)
val text: RichText,
@SerialName(languageField)
val language: String? = null
) : InputRichBlock {
@EncodeDefault
@SerialName(typeField)
override val type: String = TYPE
companion object {
const val TYPE = "pre"
}
}
/**
* A footer.
*
* @see <a href="https://core.telegram.org/bots/api#inputrichblockfooter">InputRichBlockFooter</a>
*/
@Serializable
data class InputRichBlockFooter(
@SerialName(textField)
val text: RichText
) : InputRichBlock {
@EncodeDefault
@SerialName(typeField)
override val type: String = TYPE
companion object {
const val TYPE = "footer"
}
}
/**
* A divider.
*
* @see <a href="https://core.telegram.org/bots/api#inputrichblockdivider">InputRichBlockDivider</a>
*/
@Serializable
class InputRichBlockDivider : InputRichBlock {
@EncodeDefault
@SerialName(typeField)
override val type: String = TYPE
override fun equals(other: Any?): Boolean = other is InputRichBlockDivider
override fun hashCode(): Int = TYPE.hashCode()
override fun toString(): String = "InputRichBlockDivider"
companion object {
const val TYPE = "divider"
}
}
/**
* A block with a mathematical expression in LaTeX format.
*
* @see <a href="https://core.telegram.org/bots/api#inputrichblockmathematicalexpression">InputRichBlockMathematicalExpression</a>
*/
@Serializable
data class InputRichBlockMathematicalExpression(
@SerialName(expressionField)
val expression: String
) : InputRichBlock {
@EncodeDefault
@SerialName(typeField)
override val type: String = TYPE
companion object {
const val TYPE = "mathematical_expression"
}
}
/**
* A block with an anchor.
*
* @see <a href="https://core.telegram.org/bots/api#inputrichblockanchor">InputRichBlockAnchor</a>
*/
@Serializable
data class InputRichBlockAnchor(
@SerialName(nameField)
val name: String
) : InputRichBlock {
@EncodeDefault
@SerialName(typeField)
override val type: String = TYPE
companion object {
const val TYPE = "anchor"
}
}
/**
* A list of blocks.
*
* @see <a href="https://core.telegram.org/bots/api#inputrichblocklist">InputRichBlockList</a>
*/
@Serializable
data class InputRichBlockList(
@SerialName(itemsField)
val items: List<InputRichBlockListItem>
) : InputRichBlock {
@EncodeDefault
@SerialName(typeField)
override val type: String = TYPE
companion object {
const val TYPE = "list"
}
}
/**
* A block quotation.
*
* @see <a href="https://core.telegram.org/bots/api#inputrichblockblockquotation">InputRichBlockBlockQuotation</a>
*/
@Serializable
data class InputRichBlockBlockQuotation(
@SerialName(blocksField)
val blocks: List<InputRichBlock>,
@SerialName(creditField)
val credit: RichText? = null
) : InputRichBlock {
@EncodeDefault
@SerialName(typeField)
override val type: String = TYPE
companion object {
const val TYPE = "blockquote"
}
}
/**
* A quotation with centered text.
*
* @see <a href="https://core.telegram.org/bots/api#inputrichblockpullquotation">InputRichBlockPullQuotation</a>
*/
@Serializable
data class InputRichBlockPullQuotation(
@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 = "pullquote"
}
}
/**
* A collage.
*
* @see <a href="https://core.telegram.org/bots/api#inputrichblockcollage">InputRichBlockCollage</a>
*/
@Serializable
data class InputRichBlockCollage(
@SerialName(blocksField)
val blocks: List<InputRichBlock>,
@SerialName(captionField)
val caption: RichBlockCaption? = null
) : InputRichBlock {
@EncodeDefault
@SerialName(typeField)
override val type: String = TYPE
companion object {
const val TYPE = "collage"
}
}
/**
* A slideshow.
*
* @see <a href="https://core.telegram.org/bots/api#inputrichblockslideshow">InputRichBlockSlideshow</a>
*/
@Serializable
data class InputRichBlockSlideshow(
@SerialName(blocksField)
val blocks: List<InputRichBlock>,
@SerialName(captionField)
val caption: RichBlockCaption? = null
) : InputRichBlock {
@EncodeDefault
@SerialName(typeField)
override val type: String = TYPE
companion object {
const val TYPE = "slideshow"
}
}
/**
* A table.
*
* @see <a href="https://core.telegram.org/bots/api#inputrichblocktable">InputRichBlockTable</a>
*/
@Serializable
data class InputRichBlockTable(
@SerialName(cellsField)
val cells: List<List<RichBlockTableCell>>,
@SerialName(isBorderedField)
val isBordered: Boolean? = null,
@SerialName(isStripedField)
val isStriped: Boolean? = null,
@SerialName(captionField)
val caption: RichText? = null
) : InputRichBlock {
@EncodeDefault
@SerialName(typeField)
override val type: String = TYPE
companion object {
const val TYPE = "table"
}
}
/**
* An expandable block for details disclosure.
*
* @see <a href="https://core.telegram.org/bots/api#inputrichblockdetails">InputRichBlockDetails</a>
*/
@Serializable
data class InputRichBlockDetails(
@SerialName(summaryField)
val summary: RichText,
@SerialName(blocksField)
val blocks: List<InputRichBlock>,
@SerialName(isOpenField)
val isOpen: Boolean? = null
) : InputRichBlock {
@EncodeDefault
@SerialName(typeField)
override val type: String = TYPE
companion object {
const val TYPE = "details"
}
}
/**
* A block with a map.
*
* @see <a href="https://core.telegram.org/bots/api#inputrichblockmap">InputRichBlockMap</a>
*/
@Serializable
data class InputRichBlockMap(
@SerialName(locationField)
val location: StaticLocation,
/**
* Map zoom level; 0-24.
*/
@SerialName(zoomField)
val zoom: Int,
@SerialName(widthField)
val width: Int,
@SerialName(heightField)
val height: Int,
@SerialName(captionField)
val caption: RichBlockCaption? = null
) : InputRichBlock {
@EncodeDefault
@SerialName(typeField)
override val type: String = TYPE
companion object {
const val TYPE = "map"
}
}
/**
* A block with an animation.
*
* @see <a href="https://core.telegram.org/bots/api#inputrichblockanimation">InputRichBlockAnimation</a>
*/
@Serializable
data class InputRichBlockAnimation(
@SerialName(animationField)
val animation: TelegramMediaAnimation,
@SerialName(captionField)
override val caption: RichBlockCaption? = null
) : InputRichBlockMedia {
@EncodeDefault
@SerialName(typeField)
override val type: String = TYPE
override val media: TelegramMedia
get() = animation
companion object {
const val TYPE = "animation"
}
}
/**
* A block with a music file.
*
* @see <a href="https://core.telegram.org/bots/api#inputrichblockaudio">InputRichBlockAudio</a>
*/
@Serializable
data class InputRichBlockAudio(
@SerialName(audioField)
val audio: TelegramMediaAudio,
@SerialName(captionField)
override val caption: RichBlockCaption? = null
) : InputRichBlockMedia {
@EncodeDefault
@SerialName(typeField)
override val type: String = TYPE
override val media: TelegramMedia
get() = audio
companion object {
const val TYPE = "audio"
}
}
/**
* A block with a photo.
*
* @see <a href="https://core.telegram.org/bots/api#inputrichblockphoto">InputRichBlockPhoto</a>
*/
@Serializable
data class InputRichBlockPhoto(
@SerialName(photoField)
val photo: TelegramMediaPhoto,
@SerialName(captionField)
override val caption: RichBlockCaption? = null
) : InputRichBlockMedia {
@EncodeDefault
@SerialName(typeField)
override val type: String = TYPE
override val media: TelegramMedia
get() = photo
companion object {
const val TYPE = "photo"
}
}
/**
* A block with a video.
*
* @see <a href="https://core.telegram.org/bots/api#inputrichblockvideo">InputRichBlockVideo</a>
*/
@Serializable
data class InputRichBlockVideo(
@SerialName(videoField)
val video: TelegramMediaVideo,
@SerialName(captionField)
override val caption: RichBlockCaption? = null
) : InputRichBlockMedia {
@EncodeDefault
@SerialName(typeField)
override val type: String = TYPE
override val media: TelegramMedia
get() = video
companion object {
const val TYPE = "video"
}
}
/**
* A block with a voice note.
*
* @see <a href="https://core.telegram.org/bots/api#inputrichblockvoicenote">InputRichBlockVoiceNote</a>
*/
@Serializable
data class InputRichBlockVoiceNote(
@SerialName(voiceNoteField)
val voiceNote: TelegramMediaVoiceNote,
@SerialName(captionField)
override val caption: RichBlockCaption? = null
) : InputRichBlockMedia {
@EncodeDefault
@SerialName(typeField)
override val type: String = TYPE
override val media: TelegramMedia
get() = voiceNote
companion object {
const val TYPE = "voice_note"
}
}
/**
* A block with a "Thinking…" placeholder. May be used only in [dev.inmo.tgbotapi.requests.send.SendRichMessageDraft].
*
* @see <a href="https://core.telegram.org/bots/api#inputrichblockthinking">InputRichBlockThinking</a>
*/
@Serializable
data class InputRichBlockThinking(
@SerialName(textField)
val text: RichText
) : InputRichBlock {
@EncodeDefault
@SerialName(typeField)
override val type: String = TYPE
companion object {
const val TYPE = "thinking"
}
}

View File

@@ -0,0 +1,129 @@
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.TelegramMediaPhoto
import dev.inmo.tgbotapi.types.media.TelegramMediaVideo
import dev.inmo.tgbotapi.types.media.TelegramMediaVoiceNote
/**
* Builder of [InputRichBlockListItem]s used inside [InputRichBlocksBuilder.list].
*/
@RichTextDsl
class InputRichBlockListBuilder {
private val items = mutableListOf<InputRichBlockListItem>()
fun item(
hasCheckbox: Boolean? = null,
isChecked: Boolean? = null,
value: Int? = null,
labelType: String? = null,
block: InputRichBlocksBuilder.() -> Unit
) {
items.add(InputRichBlockListItem(buildInputRichBlocks(block), hasCheckbox, isChecked, value, labelType))
}
fun item(text: String) {
items.add(InputRichBlockListItem(listOf(InputRichBlockParagraph(RichTextPlain(text)))))
}
fun build(): List<InputRichBlockListItem> = items.toList()
}
/**
* Builder of a [List] of [InputRichBlock]s - the root of the rich message input DSL. Text-bearing and container blocks
* have their own DSL functions; media blocks (photo, video, animation, audio, voice note, collage, slideshow, table,
* map) can be appended with the matching convenience function or with [add] / unary plus.
*/
@RichTextDsl
class InputRichBlocksBuilder {
private val blocks = mutableListOf<InputRichBlock>()
/** Appends an already built [InputRichBlock]. */
fun add(block: InputRichBlock) {
blocks.add(block)
}
/** Appends an already built [InputRichBlock]. */
operator fun InputRichBlock.unaryPlus() = add(this)
fun paragraph(text: String) = add(InputRichBlockParagraph(RichTextPlain(text)))
fun paragraph(block: RichTextBuilder.() -> Unit) = add(InputRichBlockParagraph(buildRichText(block)))
fun heading(text: String, level: Int) = add(InputRichBlockSectionHeading(RichTextPlain(text), level))
fun heading(level: Int, block: RichTextBuilder.() -> Unit) = add(InputRichBlockSectionHeading(buildRichText(block), level))
fun preformatted(text: String, language: String? = null) = add(InputRichBlockPreformatted(RichTextPlain(text), language))
fun footer(text: String) = add(InputRichBlockFooter(RichTextPlain(text)))
fun footer(block: RichTextBuilder.() -> Unit) = add(InputRichBlockFooter(buildRichText(block)))
fun divider() = add(InputRichBlockDivider())
fun mathematicalExpression(expression: String) = add(InputRichBlockMathematicalExpression(expression))
fun anchor(name: String) = add(InputRichBlockAnchor(name))
fun thinking(text: String) = add(InputRichBlockThinking(RichTextPlain(text)))
fun thinking(block: RichTextBuilder.() -> Unit) = add(InputRichBlockThinking(buildRichText(block)))
fun list(block: InputRichBlockListBuilder.() -> Unit) = add(InputRichBlockList(InputRichBlockListBuilder().apply(block).build()))
fun blockQuotation(credit: RichText? = null, block: InputRichBlocksBuilder.() -> Unit) =
add(InputRichBlockBlockQuotation(buildInputRichBlocks(block), credit))
fun pullQuotation(credit: RichText? = null, block: RichTextBuilder.() -> Unit) =
add(InputRichBlockPullQuotation(buildRichText(block), credit))
fun details(summary: RichText, isOpen: Boolean? = null, block: InputRichBlocksBuilder.() -> Unit) =
add(InputRichBlockDetails(summary, buildInputRichBlocks(block), isOpen))
fun details(summary: String, isOpen: Boolean? = null, block: InputRichBlocksBuilder.() -> Unit) =
details(RichTextPlain(summary), isOpen, block)
fun photo(photo: TelegramMediaPhoto, caption: RichBlockCaption? = null) = add(InputRichBlockPhoto(photo, caption))
fun video(video: TelegramMediaVideo, caption: RichBlockCaption? = null) = add(InputRichBlockVideo(video, caption))
fun animation(animation: TelegramMediaAnimation, caption: RichBlockCaption? = null) =
add(InputRichBlockAnimation(animation, caption))
fun audio(audio: TelegramMediaAudio, caption: RichBlockCaption? = null) = add(InputRichBlockAudio(audio, caption))
fun voiceNote(voiceNote: TelegramMediaVoiceNote, caption: RichBlockCaption? = null) =
add(InputRichBlockVoiceNote(voiceNote, caption))
fun collage(caption: RichBlockCaption? = null, block: InputRichBlocksBuilder.() -> Unit) =
add(InputRichBlockCollage(buildInputRichBlocks(block), caption))
fun slideshow(caption: RichBlockCaption? = null, block: InputRichBlocksBuilder.() -> Unit) =
add(InputRichBlockSlideshow(buildInputRichBlocks(block), caption))
fun table(
cells: List<List<RichBlockTableCell>>,
isBordered: Boolean? = null,
isStriped: Boolean? = null,
caption: RichText? = null
) = add(InputRichBlockTable(cells, isBordered, isStriped, caption))
fun map(
location: StaticLocation,
zoom: Int,
width: Int,
height: Int,
caption: RichBlockCaption? = null
) = add(InputRichBlockMap(location, zoom, width, height, caption))
fun build(): List<InputRichBlock> = blocks.toList()
}
/** Builds a [List] of [InputRichBlock]s using the [InputRichBlocksBuilder] DSL. */
fun buildInputRichBlocks(block: InputRichBlocksBuilder.() -> Unit): List<InputRichBlock> = InputRichBlocksBuilder().apply(block).build()
/** Builds an [InputRichMessage] with `blocks` built using the [InputRichBlocksBuilder] DSL. */
fun InputRichMessageBlocks(
isRtl: Boolean? = null,
skipEntityDetection: Boolean? = null,
block: InputRichBlocksBuilder.() -> Unit
): InputRichMessage = InputRichMessageBlocks(buildInputRichBlocks(block), isRtl, skipEntityDetection)

View File

@@ -1,15 +1,21 @@
package dev.inmo.tgbotapi.types.rich
import dev.inmo.tgbotapi.requests.abstracts.MultipartFile
import dev.inmo.tgbotapi.types.blocksField
import dev.inmo.tgbotapi.types.htmlField
import dev.inmo.tgbotapi.types.isRtlField
import dev.inmo.tgbotapi.types.markdownField
import dev.inmo.tgbotapi.types.media.CoveredTelegramMedia
import dev.inmo.tgbotapi.types.media.TelegramMedia
import dev.inmo.tgbotapi.types.media.ThumbedTelegramMedia
import dev.inmo.tgbotapi.types.mediaField
import dev.inmo.tgbotapi.types.skipEntityDetectionField
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Describes a rich message to be sent. Exactly one of the fields [html] or [markdown] must be used. Use the
* [InputRichMessageHTML] and [InputRichMessageMarkdown] factories to build an instance.
* Describes a rich message to be sent. Exactly one of the fields [html], [markdown] or [blocks] must be used. Use the
* [InputRichMessageHTML], [InputRichMessageMarkdown] and [InputRichMessageBlocks] factories to build an instance.
*
* @see <a href="https://core.telegram.org/bots/api#inputrichmessage">InputRichMessage</a>
*/
@@ -20,14 +26,21 @@ data class InputRichMessage internal constructor(
val html: String? = null,
@SerialName(markdownField)
val markdown: String? = null,
@SerialName(blocksField)
val blocks: List<InputRichBlock>? = null,
@SerialName(mediaField)
val media: List<InputRichMessageMedia>? = null,
@SerialName(isRtlField)
val isRtl: Boolean? = null,
@SerialName(skipEntityDetectionField)
val skipEntityDetection: Boolean? = null
) {
init {
require((html == null) != (markdown == null)) {
"Exactly one of the fields html or markdown must be used in InputRichMessage"
require(listOfNotNull(html, markdown, blocks).size == 1) {
"Exactly one of the fields html, markdown or blocks must be used in InputRichMessage"
}
require(media == null || blocks == null) {
"The field media of InputRichMessage can be used only together with html or markdown, not blocks"
}
}
}
@@ -40,10 +53,13 @@ data class InputRichMessage internal constructor(
fun InputRichMessageHTML(
html: String,
isRtl: Boolean? = null,
skipEntityDetection: Boolean? = null
skipEntityDetection: Boolean? = null,
media: List<InputRichMessageMedia>? = null
): InputRichMessage = InputRichMessage(
html = html,
markdown = null,
blocks = null,
media = media,
isRtl = isRtl,
skipEntityDetection = skipEntityDetection
)
@@ -56,10 +72,61 @@ fun InputRichMessageHTML(
fun InputRichMessageMarkdown(
markdown: String,
isRtl: Boolean? = null,
skipEntityDetection: Boolean? = null
skipEntityDetection: Boolean? = null,
media: List<InputRichMessageMedia>? = null
): InputRichMessage = InputRichMessage(
html = null,
markdown = markdown,
blocks = null,
media = media,
isRtl = isRtl,
skipEntityDetection = skipEntityDetection
)
/**
* Creates an [InputRichMessage] with the content described using a list of [InputRichBlock]s.
*
* @see <a href="https://core.telegram.org/bots/api#inputrichmessage">InputRichMessage</a>
*/
fun InputRichMessageBlocks(
blocks: List<InputRichBlock>,
isRtl: Boolean? = null,
skipEntityDetection: Boolean? = null
): InputRichMessage = InputRichMessage(
html = null,
markdown = null,
blocks = blocks,
media = null,
isRtl = isRtl,
skipEntityDetection = skipEntityDetection
)
/**
* Files ([MultipartFile]) referenced by this [InputRichMessage]: the standalone [InputRichMessage.media] entries and any
* media block nested (recursively) in [InputRichMessage.blocks], keyed by [MultipartFile.fileId].
*/
internal val InputRichMessage.multipartFiles: Map<String, MultipartFile>
get() {
val mediaFiles = media.orEmpty().flatMap { it.media.multipartFiles() }
val blockFiles = blocks.orEmpty().flatMap { it.multipartFiles() }
return (mediaFiles + blockFiles).associateBy { it.fileId }
}
private fun TelegramMedia.multipartFiles(): List<MultipartFile> = listOfNotNull(
file as? MultipartFile,
if (this is ThumbedTelegramMedia) {
thumb as? MultipartFile
} else {
null
},
if (this is CoveredTelegramMedia) {
cover as? MultipartFile
} else {
null
}
)
private fun InputRichBlock.multipartFiles(): List<MultipartFile> {
val ownFiles = (this as? InputRichBlockMedia) ?.media ?.multipartFiles().orEmpty()
return ownFiles + subBlocks.flatMap { it.multipartFiles() }
}

View File

@@ -0,0 +1,29 @@
package dev.inmo.tgbotapi.types.rich
import dev.inmo.tgbotapi.types.idField
import dev.inmo.tgbotapi.types.media.RichMessageMemberTelegramMedia
import dev.inmo.tgbotapi.types.mediaField
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
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.
*
* @see <a href="https://core.telegram.org/bots/api#inputrichmessagemedia">InputRichMessageMedia</a>
*/
@Serializable
data class InputRichMessageMedia(
@SerialName(idField)
val id: String,
@SerialName(mediaField)
val media: RichMessageMemberTelegramMedia
) {
init {
require(id.matches(inputRichMessageMediaIdRegex)) {
"id of InputRichMessageMedia must be 1-64 characters long and contain only latin letters, digits, underscores and hyphens, but was \"$id\""
}
}
}

View File

@@ -0,0 +1,59 @@
package dev.inmo.tgbotapi.types.rich
import dev.inmo.tgbotapi.requests.abstracts.FileId
import dev.inmo.tgbotapi.types.media.TelegramMediaPhoto
import kotlin.test.Test
import kotlin.test.assertEquals
class InputRichBlocksDslTest {
@Test
fun buildsTextAndContainerBlocks() {
val blocks = buildInputRichBlocks {
heading("Title", 1)
paragraph {
plain("Hello ")
bold("world")
}
divider()
list {
item("first")
item(labelType = "1") { paragraph("second") }
}
blockQuotation {
paragraph("quoted")
}
}
assertEquals(5, blocks.size)
assertEquals(InputRichBlockSectionHeading(RichTextPlain("Title"), 1), blocks[0])
assertEquals(
InputRichBlockParagraph(RichTextGroup(listOf(RichTextPlain("Hello "), RichTextBold(RichTextPlain("world"))))),
blocks[1]
)
assertEquals(InputRichBlockDivider(), blocks[2])
val list = blocks[3] as InputRichBlockList
assertEquals(2, list.items.size)
assertEquals(InputRichBlockListItem(listOf(InputRichBlockParagraph(RichTextPlain("first")))), list.items[0])
assertEquals("1", list.items[1].labelType)
assertEquals(InputRichBlockBlockQuotation(listOf(InputRichBlockParagraph(RichTextPlain("quoted")))), blocks[4])
}
@Test
fun buildsMediaBlocks() {
val photo = TelegramMediaPhoto(FileId("photo_file_id"))
val blocks = buildInputRichBlocks {
photo(photo)
}
assertEquals(listOf(InputRichBlockPhoto(photo)), blocks)
}
@Test
fun buildsInputRichMessageBlocks() {
val message = InputRichMessageBlocks(isRtl = true) {
paragraph("p")
}
assertEquals(InputRichMessageBlocks(listOf(InputRichBlockParagraph(RichTextPlain("p"))), true), message)
}
}

View File

@@ -0,0 +1,176 @@
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.TelegramMediaVoiceNote
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.int
import kotlinx.serialization.json.boolean
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
class InputRichMessageSerializationTest {
private val json = Json { encodeDefaults = true }
@Test
fun encodesTextAndContainerBlocks() {
val message = InputRichMessageBlocks(
blocks = listOf(
InputRichBlockParagraph(RichTextPlain("Hello")),
InputRichBlockSectionHeading(RichTextPlain("Title"), 2),
InputRichBlockDivider(),
InputRichBlockList(
listOf(
InputRichBlockListItem(
listOf(InputRichBlockParagraph(RichTextPlain("first"))),
labelType = "1"
)
)
)
)
)
val blocksJson = json.encodeToJsonElement(InputRichMessage.serializer(), message).jsonObject["blocks"]!!.jsonArray
val paragraph = blocksJson[0].jsonObject
assertEquals("paragraph", paragraph["type"]?.jsonPrimitive?.content)
val heading = blocksJson[1].jsonObject
assertEquals("heading", heading["type"]?.jsonPrimitive?.content)
assertEquals(2, heading["size"]?.jsonPrimitive?.int)
val divider = blocksJson[2].jsonObject
assertEquals("divider", divider["type"]?.jsonPrimitive?.content)
assertEquals(1, divider.size)
val list = blocksJson[3].jsonObject
assertEquals("list", list["type"]?.jsonPrimitive?.content)
val item = list["items"]!!.jsonArray[0].jsonObject
assertTrue("label" !in item)
assertEquals("1", item["type"]?.jsonPrimitive?.content)
}
@Test
fun encodesTableReusingReceivedCellType() {
val table = InputRichBlockTable(
cells = listOf(
listOf(RichBlockTableCell(RichTextPlain("h1"), isHeader = true, align = "left", valign = "top"))
)
)
val message = InputRichMessageBlocks(blocks = listOf(table))
val tableJson = json.encodeToJsonElement(InputRichMessage.serializer(), message).jsonObject["blocks"]!!.jsonArray[0].jsonObject
val cell = tableJson["cells"]!!.jsonArray[0].jsonArray[0].jsonObject
assertEquals("left", cell["align"]?.jsonPrimitive?.content)
assertEquals(true, cell["is_header"]?.jsonPrimitive?.boolean)
}
@Test
fun encodesPhotoBlockMedia() {
val photoBlock = InputRichBlockPhoto(TelegramMediaPhoto(FileId("photo_file_id")))
val message = InputRichMessageBlocks(blocks = listOf(photoBlock))
val blockJson = json.encodeToJsonElement(InputRichMessage.serializer(), message).jsonObject["blocks"]!!.jsonArray[0].jsonObject
assertEquals("photo", blockJson["type"]?.jsonPrimitive?.content)
val photoJson = blockJson["photo"]!!.jsonObject
assertEquals("photo", photoJson["type"]?.jsonPrimitive?.content)
assertEquals("photo_file_id", photoJson["media"]?.jsonPrimitive?.content)
}
@Test
fun encodesVoiceNoteBlock() {
val voiceBlock = InputRichBlockVoiceNote(TelegramMediaVoiceNote(FileId("voice_file_id"), duration = 12))
val message = InputRichMessageBlocks(blocks = listOf(voiceBlock))
val blockJson = json.encodeToJsonElement(InputRichMessage.serializer(), message).jsonObject["blocks"]!!.jsonArray[0].jsonObject
assertEquals("voice_note", blockJson["type"]?.jsonPrimitive?.content)
val voiceJson = blockJson["voice_note"]!!.jsonObject
assertEquals("voice_note", voiceJson["type"]?.jsonPrimitive?.content)
assertEquals("voice_file_id", voiceJson["media"]?.jsonPrimitive?.content)
assertEquals(12, voiceJson["duration"]?.jsonPrimitive?.int)
}
@Test
fun encodesMarkdownWithMedia() {
val message = InputRichMessageMarkdown(
markdown = "See ![photo](tg://photo?id=abc)",
media = listOf(InputRichMessageMedia("abc", TelegramMediaPhoto(FileId("photo_file_id"))))
)
val element = json.encodeToJsonElement(InputRichMessage.serializer(), message).jsonObject
assertEquals("See ![photo](tg://photo?id=abc)", element["markdown"]?.jsonPrimitive?.content)
val mediaJson = element["media"]!!.jsonArray[0].jsonObject
assertEquals("abc", mediaJson["id"]?.jsonPrimitive?.content)
assertEquals("photo_file_id", mediaJson["media"]!!.jsonObject["media"]?.jsonPrimitive?.content)
}
@Test
fun roundTripsListOfInputRichBlocks() {
val blocks: List<InputRichBlock> = listOf(
InputRichBlockParagraph(RichTextGroup(listOf(RichTextPlain("a "), RichTextBold(RichTextPlain("b"))))),
InputRichBlockDivider(),
InputRichBlockList(
listOf(
InputRichBlockListItem(
listOf(InputRichBlockParagraph(RichTextPlain("item"))),
hasCheckbox = true,
isChecked = true
)
)
)
)
val serializer = ListSerializer(InputRichBlockSerializer)
val encoded = json.encodeToString(serializer, blocks)
val decoded = json.decodeFromString(serializer, encoded)
assertEquals(blocks, decoded)
}
@Test
fun requiresExactlyOneOfHtmlMarkdownOrBlocks() {
assertFailsWith<IllegalArgumentException> {
InputRichMessage(html = null, markdown = null, blocks = null)
}
assertFailsWith<IllegalArgumentException> {
InputRichMessage(html = "<b>hi</b>", markdown = "**hi**", blocks = null)
}
assertFailsWith<IllegalArgumentException> {
InputRichMessage(
html = "<b>hi</b>",
markdown = null,
blocks = listOf(InputRichBlockParagraph(RichTextPlain("hi")))
)
}
}
@Test
fun requiresMediaOnlyWithoutBlocks() {
assertFailsWith<IllegalArgumentException> {
InputRichMessage(
html = null,
markdown = null,
blocks = listOf(InputRichBlockParagraph(RichTextPlain("hi"))),
media = listOf(InputRichMessageMedia("abc", TelegramMediaPhoto(FileId("photo_file_id"))))
)
}
}
@Test
fun requiresValidMediaId() {
assertFailsWith<IllegalArgumentException> {
InputRichMessageMedia("", TelegramMediaPhoto(FileId("photo_file_id")))
}
assertFailsWith<IllegalArgumentException> {
InputRichMessageMedia("has space", TelegramMediaPhoto(FileId("photo_file_id")))
}
assertFailsWith<IllegalArgumentException> {
InputRichMessageMedia("x".repeat(65), TelegramMediaPhoto(FileId("photo_file_id")))
}
}
}