From 2721dc55cad0d3630e7473704ba2fbf8e1dfac15 Mon Sep 17 00:00:00 2001 From: Fernando Werneck Date: Thu, 6 Aug 2026 22:20:29 -0300 Subject: [PATCH 1/4] Add block and media support to sendRichMessage InputRichMessage only exposed html/markdown, so rich messages could not be described as blocks nor carry embedded media. Two lower-level defects blocked adding them: - RichBlock/RichText declare their `type` discriminator as a computed property, which Gson does not serialize, and the registered adapters were deserializers only. Every block sent would have gone out without a type. - SendRichMessage inherits isMultipart() == false from AbstractSendRequest, so a block referencing a freshly uploaded file would serialize an attach:// URL with no matching multipart part. editMessageText, which already accepted an InputRichMessage, had the same defect. Adds the InputRichBlock hierarchy (21 blocks plus InputRichBlockListItem), InputRichMessageMedia and the missing InputMediaVoiceNote; makes the rich text/block adapters serialize their discriminator; and collects uploads lazily at send time, so a rich message populated after the request was built is still uploaded. Also adds Kotlin DSL extensions for sendRichMessage and sendRichMessageDraft. ModelTest needed prefab InputMedia values: EqualsVerifier cannot instantiate InputMedia because of its final self-typed field. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012tbomWzUs3YpXUn61Rwg17 --- .../2026-08-06-send-rich-message-design.md | 108 +++++++++ .../model/request/InputMediaVoiceNote.java | 39 ++++ .../request/richmessages/InputRichMessage.kt | 21 +- .../richmessages/InputRichMessageMedia.kt | 8 + .../inputrichblock/InputRichBlock.kt | 5 + .../inputrichblock/InputRichBlockAnchor.kt | 9 + .../inputrichblock/InputRichBlockAnimation.kt | 12 + .../inputrichblock/InputRichBlockAudio.kt | 12 + .../InputRichBlockBlockQuotation.kt | 27 +++ .../inputrichblock/InputRichBlockCollage.kt | 27 +++ .../inputrichblock/InputRichBlockDetails.kt | 29 +++ .../inputrichblock/InputRichBlockDivider.kt | 16 ++ .../inputrichblock/InputRichBlockFooter.kt | 10 + .../inputrichblock/InputRichBlockList.kt | 21 ++ .../inputrichblock/InputRichBlockListItem.kt | 33 +++ .../inputrichblock/InputRichBlockMap.kt | 15 ++ .../InputRichBlockMathematicalExpression.kt | 9 + .../inputrichblock/InputRichBlockParagraph.kt | 10 + .../inputrichblock/InputRichBlockPhoto.kt | 12 + .../InputRichBlockPreformatted.kt | 11 + .../InputRichBlockPullQuotation.kt | 11 + .../InputRichBlockSectionHeading.kt | 11 + .../inputrichblock/InputRichBlockSlideshow.kt | 27 +++ .../inputrichblock/InputRichBlockTable.kt | 36 +++ .../inputrichblock/InputRichBlockThinking.kt | 10 + .../inputrichblock/InputRichBlockVideo.kt | 12 + .../inputrichblock/InputRichBlockVoiceNote.kt | 12 + .../telegrambot/request/EditMessageText.java | 34 +++ .../request/richmessages/SendRichMessage.kt | 26 +++ .../richmessages/SendRichMessageDraft.kt | 26 +++ .../pengrad/telegrambot/utility/BotUtils.java | 2 + .../utility/gson/InputRichBlockSerializer.kt | 19 ++ .../utility/gson/RichBlockTypeAdapter.kt | 13 +- .../utility/gson/RichTextTypeAdapter.kt | 22 +- .../request/SendRichMessageDraftExtension.kt | 17 ++ .../request/SendRichMessageExtension.kt | 24 ++ .../richmessages/RichMessageAttachments.kt | 50 +++++ .../java/com/pengrad/telegrambot/ModelTest.kt | 14 ++ .../telegrambot/RichMessageRequestTest.kt | 208 ++++++++++++++++++ 39 files changed, 1003 insertions(+), 5 deletions(-) create mode 100644 docs/superpowers/specs/2026-08-06-send-rich-message-design.md create mode 100644 library/src/main/java/com/pengrad/telegrambot/model/request/InputMediaVoiceNote.java create mode 100644 library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/InputRichMessageMedia.kt create mode 100644 library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlock.kt create mode 100644 library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockAnchor.kt create mode 100644 library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockAnimation.kt create mode 100644 library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockAudio.kt create mode 100644 library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockBlockQuotation.kt create mode 100644 library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockCollage.kt create mode 100644 library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockDetails.kt create mode 100644 library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockDivider.kt create mode 100644 library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockFooter.kt create mode 100644 library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockList.kt create mode 100644 library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockListItem.kt create mode 100644 library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockMap.kt create mode 100644 library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockMathematicalExpression.kt create mode 100644 library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockParagraph.kt create mode 100644 library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockPhoto.kt create mode 100644 library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockPreformatted.kt create mode 100644 library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockPullQuotation.kt create mode 100644 library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockSectionHeading.kt create mode 100644 library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockSlideshow.kt create mode 100644 library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockTable.kt create mode 100644 library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockThinking.kt create mode 100644 library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockVideo.kt create mode 100644 library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockVoiceNote.kt create mode 100644 library/src/main/java/com/pengrad/telegrambot/utility/gson/InputRichBlockSerializer.kt create mode 100644 library/src/main/java/com/pengrad/telegrambot/utility/kotlin/extension/request/SendRichMessageDraftExtension.kt create mode 100644 library/src/main/java/com/pengrad/telegrambot/utility/kotlin/extension/request/SendRichMessageExtension.kt create mode 100644 library/src/main/java/com/pengrad/telegrambot/utility/richmessages/RichMessageAttachments.kt create mode 100644 library/src/test/java/com/pengrad/telegrambot/RichMessageRequestTest.kt diff --git a/docs/superpowers/specs/2026-08-06-send-rich-message-design.md b/docs/superpowers/specs/2026-08-06-send-rich-message-design.md new file mode 100644 index 000000000..55a00507c --- /dev/null +++ b/docs/superpowers/specs/2026-08-06-send-rich-message-design.md @@ -0,0 +1,108 @@ +# Design: full rich message sending support + +Date: 2026-08-06 +Bot API version: 10.1 + +## Problem + +The library already ships `SendRichMessage`, `SendRichMessageDraft` and `InputRichMessage`, +but `InputRichMessage` only exposes `html`, `markdown`, `is_rtl` and `skip_entity_detection`. +The Bot API also allows a rich message to be described as a list of blocks (`blocks`) and to +carry embedded media (`media`). Neither is representable today, and two lower-level problems +would break any attempt to add them: + +1. **The `type` discriminator is never serialized.** `RichBlock` and `RichText` implementations + declare `override val type: String get() = "..."` — a computed property with no backing + field. Gson serializes fields, so every block sent to Telegram would omit `type`. The + adapters registered in `BotUtils` are `JsonDeserializer` only. +2. **No multipart plumbing.** `SendRichMessage` extends `AbstractSendRequest`, which inherits + `isMultipart() == false`. A block holding a freshly uploaded file would serialize an + `attach://` reference with no corresponding multipart part. + +`InputMediaVoiceNote` is also missing from the library, and `InputRichBlockVoiceNote` requires it. + +## Scope + +Full support for sending rich messages: `blocks`, `media`, file uploads, `sendRichMessage` +and `sendRichMessageDraft`. `editMessageText` is included because it already accepts an +`InputRichMessage` and shares the multipart defect. + +## Design + +### 1. Input block models + +New package `model.request.richmessages.inputrichblock`: + +- `InputRichBlock` — interface exposing `val type: String`, mirroring `RichBlock`. +- 21 concrete blocks: `Paragraph`, `SectionHeading`, `Preformatted`, `Footer`, `Divider`, + `MathematicalExpression`, `Anchor`, `List`, `BlockQuotation`, `PullQuotation`, `Collage`, + `Slideshow`, `Table`, `Details`, `Map`, `Animation`, `Audio`, `Photo`, `Video`, + `VoiceNote`, `Thinking`. +- `InputRichBlockListItem` — note it has no `label` (unlike the received `RichBlockListItem`) + and carries the ordered-list label `type`. + +The `type` string constants are identical to the receive side, so `RichBlockType` is reused +rather than duplicated. Blocks reference the existing received types where the API specifies +them: `RichText`, `RichBlockCaption`, `RichBlockTableCell`, `Location`. + +New `model.request.richmessages.InputRichMessageMedia` (`id` + `InputMedia`) and +`model.request.InputMediaVoiceNote` (Java, matching its `InputMedia` siblings). + +### 2. Serialization + +`RichTextTypeAdapter` and `RichBlockTypeAdapter` additionally implement `JsonSerializer`; +a new `InputRichBlockSerializer` covers the input hierarchy. Each delegates to the concrete +runtime type and injects the discriminator: + +```kotlin +val obj = context.serialize(src, src.javaClass).asJsonObject +obj.addProperty("type", src.type) +``` + +This does not recurse: the adapter is registered against the interface, while +`src.javaClass` resolves to the concrete class's reflective adapter. + +`RichText` needs two special cases, matching how it is parsed: `RichTextPlain` serializes as +a bare JSON string and `RichTextArray` as a JSON array. + +All three are registered in `BotUtils.GSON`. + +### 3. Attachment collection + +`utility.richmessages.RichMessageAttachments.collect(InputRichMessage)` walks `media` and +`blocks` — descending into `list`, `blockquote`, `collage`, `slideshow` and `details` — and +returns the `attach://` name to file mapping gathered from each `InputMedia`. + +`SendRichMessage`, `SendRichMessageDraft` and `EditMessageText` call it lazily and +idempotently from their `isMultipart()` and `getParameters()` overrides. Both are read by +`TelegramBotClient` at send time, so a message mutated after the request was constructed is +still captured — unlike `SendMediaGroup`, which collects in its constructor. + +### 4. `InputRichMessage` + +Gains `blocks: Array?` and `media: Array?` with fluent +setters, keeping the existing `equals`/`hashCode`/`toString` style. + +### 5. Kotlin DSL + +`SendRichMessageExtension.kt` and `SendRichMessageDraftExtension.kt` in +`utility.kotlin.extension.request`, following `SendMessageExtension.kt`. + +### 6. Testing + +Unit tests (no network): + +- every input block serializes with the right `type` and snake_case keys; +- `RichTextPlain` serializes as a string, `RichTextArray` as an array; +- nested blocks serialize recursively; +- attachment collection sets `isMultipart` and emits the `attach://` parts, including for + nested blocks; +- mutating the `InputRichMessage` *after* constructing the request is still picked up. + +Integration coverage in `TelegramBotTest` (requires `TEST_TOKEN`/`CHAT_ID`): send a rich +message with a text block plus an uploaded photo, and a draft carrying a `thinking` block. + +## Out of scope + +Receiving rich messages (already supported) and inline query results carrying +`InputRichMessageContent` beyond what already exists — inline results cannot upload files. diff --git a/library/src/main/java/com/pengrad/telegrambot/model/request/InputMediaVoiceNote.java b/library/src/main/java/com/pengrad/telegrambot/model/request/InputMediaVoiceNote.java new file mode 100644 index 000000000..57e4fbc9f --- /dev/null +++ b/library/src/main/java/com/pengrad/telegrambot/model/request/InputMediaVoiceNote.java @@ -0,0 +1,39 @@ +package com.pengrad.telegrambot.model.request; + +import com.pengrad.telegrambot.request.ContentTypes; + +import java.io.File; +import java.io.Serializable; + +public class InputMediaVoiceNote extends InputMedia implements Serializable { + private final static long serialVersionUID = 0L; + + private Integer duration; + + public InputMediaVoiceNote(String media) { + super("voice_note", media); + } + + public InputMediaVoiceNote(File media) { + super("voice_note", media); + } + + public InputMediaVoiceNote(byte[] media) { + super("voice_note", media); + } + + public InputMediaVoiceNote duration(Integer duration) { + this.duration = duration; + return this; + } + + @Override + public String getDefaultFileName() { + return ContentTypes.VOICE_FILE_NAME; + } + + @Override + public String getDefaultContentType() { + return ContentTypes.VOICE_MIME_TYPE; + } +} diff --git a/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/InputRichMessage.kt b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/InputRichMessage.kt index ec61a5d27..74cb8c728 100644 --- a/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/InputRichMessage.kt +++ b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/InputRichMessage.kt @@ -1,23 +1,33 @@ package com.pengrad.telegrambot.model.request.richmessages +import com.pengrad.telegrambot.model.request.richmessages.inputrichblock.InputRichBlock + class InputRichMessage private constructor( + @get:JvmName("blocks") var blocks: Array?, @get:JvmName("html") var html: String?, @get:JvmName("markdown") var markdown: String?, + @get:JvmName("media") var media: Array?, @get:JvmName("isRtl") var isRtl: Boolean?, @get:JvmName("skipEntityDetection") var skipEntityDetection: Boolean? ) { constructor() : this( + blocks = null, html = null, markdown = null, + media = null, isRtl = null, skipEntityDetection = null ) + fun blocks(vararg blocks: InputRichBlock) = apply { this.blocks = arrayOf(*blocks) } + fun html(html: String) = apply { this.html = html } fun markdown(markdown: String) = apply { this.markdown = markdown } + fun media(vararg media: InputRichMessageMedia) = apply { this.media = arrayOf(*media) } + fun isRtl(isRtl: Boolean) = apply { this.isRtl = isRtl } fun skipEntityDetection(skipEntityDetection: Boolean) = apply { this.skipEntityDetection = skipEntityDetection } @@ -26,20 +36,25 @@ class InputRichMessage private constructor( if (this === other) return true if (javaClass != other?.javaClass) return false other as InputRichMessage - return html == other.html && + return blocks.contentEquals(other.blocks) && + html == other.html && markdown == other.markdown && + media.contentEquals(other.media) && isRtl == other.isRtl && skipEntityDetection == other.skipEntityDetection } override fun hashCode(): Int { - var result = html?.hashCode() ?: 0 + var result = blocks?.contentHashCode() ?: 0 + result = 31 * result + (html?.hashCode() ?: 0) result = 31 * result + (markdown?.hashCode() ?: 0) + result = 31 * result + (media?.contentHashCode() ?: 0) result = 31 * result + (isRtl?.hashCode() ?: 0) result = 31 * result + (skipEntityDetection?.hashCode() ?: 0) return result } override fun toString(): String = - "InputRichMessage(html=$html, markdown=$markdown, isRtl=$isRtl, skipEntityDetection=$skipEntityDetection)" + "InputRichMessage(blocks=${blocks?.contentToString()}, html=$html, markdown=$markdown, " + + "media=${media?.contentToString()}, isRtl=$isRtl, skipEntityDetection=$skipEntityDetection)" } diff --git a/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/InputRichMessageMedia.kt b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/InputRichMessageMedia.kt new file mode 100644 index 000000000..87728d5ef --- /dev/null +++ b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/InputRichMessageMedia.kt @@ -0,0 +1,8 @@ +package com.pengrad.telegrambot.model.request.richmessages + +import com.pengrad.telegrambot.model.request.InputMedia + +data class InputRichMessageMedia( + @get:JvmName("id") val id: String, + @get:JvmName("media") val media: InputMedia<*> +) diff --git a/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlock.kt b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlock.kt new file mode 100644 index 000000000..cd4acd30d --- /dev/null +++ b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlock.kt @@ -0,0 +1,5 @@ +package com.pengrad.telegrambot.model.request.richmessages.inputrichblock + +interface InputRichBlock { + val type: String +} diff --git a/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockAnchor.kt b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockAnchor.kt new file mode 100644 index 000000000..a293a2f9e --- /dev/null +++ b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockAnchor.kt @@ -0,0 +1,9 @@ +package com.pengrad.telegrambot.model.request.richmessages.inputrichblock + +import com.pengrad.telegrambot.model.richmessages.richblock.RichBlockType + +data class InputRichBlockAnchor( + @get:JvmName("name") val name: String +) : InputRichBlock { + override val type: String get() = RichBlockType.ANCHOR +} diff --git a/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockAnimation.kt b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockAnimation.kt new file mode 100644 index 000000000..e7e175154 --- /dev/null +++ b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockAnimation.kt @@ -0,0 +1,12 @@ +package com.pengrad.telegrambot.model.request.richmessages.inputrichblock + +import com.pengrad.telegrambot.model.request.InputMediaAnimation +import com.pengrad.telegrambot.model.richmessages.richblock.RichBlockCaption +import com.pengrad.telegrambot.model.richmessages.richblock.RichBlockType + +data class InputRichBlockAnimation( + @get:JvmName("animation") val animation: InputMediaAnimation, + @get:JvmName("caption") val caption: RichBlockCaption? = null +) : InputRichBlock { + override val type: String get() = RichBlockType.ANIMATION +} diff --git a/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockAudio.kt b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockAudio.kt new file mode 100644 index 000000000..75f011239 --- /dev/null +++ b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockAudio.kt @@ -0,0 +1,12 @@ +package com.pengrad.telegrambot.model.request.richmessages.inputrichblock + +import com.pengrad.telegrambot.model.request.InputMediaAudio +import com.pengrad.telegrambot.model.richmessages.richblock.RichBlockCaption +import com.pengrad.telegrambot.model.richmessages.richblock.RichBlockType + +data class InputRichBlockAudio( + @get:JvmName("audio") val audio: InputMediaAudio, + @get:JvmName("caption") val caption: RichBlockCaption? = null +) : InputRichBlock { + override val type: String get() = RichBlockType.AUDIO +} diff --git a/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockBlockQuotation.kt b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockBlockQuotation.kt new file mode 100644 index 000000000..4e526910f --- /dev/null +++ b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockBlockQuotation.kt @@ -0,0 +1,27 @@ +package com.pengrad.telegrambot.model.request.richmessages.inputrichblock + +import com.pengrad.telegrambot.model.richmessages.richblock.RichBlockType +import com.pengrad.telegrambot.model.richmessages.richtext.RichText + +class InputRichBlockBlockQuotation( + @get:JvmName("blocks") val blocks: Array, + @get:JvmName("credit") val credit: RichText? = null +) : InputRichBlock { + + override val type: String get() = RichBlockType.BLOCKQUOTE + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as InputRichBlockBlockQuotation + return blocks.contentEquals(other.blocks) && credit == other.credit + } + + override fun hashCode(): Int { + var result = blocks.contentHashCode() + result = 31 * result + (credit?.hashCode() ?: 0) + return result + } + + override fun toString(): String = "InputRichBlockBlockQuotation(blocks=${blocks.contentToString()}, credit=$credit)" +} diff --git a/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockCollage.kt b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockCollage.kt new file mode 100644 index 000000000..ad97d1704 --- /dev/null +++ b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockCollage.kt @@ -0,0 +1,27 @@ +package com.pengrad.telegrambot.model.request.richmessages.inputrichblock + +import com.pengrad.telegrambot.model.richmessages.richblock.RichBlockCaption +import com.pengrad.telegrambot.model.richmessages.richblock.RichBlockType + +class InputRichBlockCollage( + @get:JvmName("blocks") val blocks: Array, + @get:JvmName("caption") val caption: RichBlockCaption? = null +) : InputRichBlock { + + override val type: String get() = RichBlockType.COLLAGE + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as InputRichBlockCollage + return blocks.contentEquals(other.blocks) && caption == other.caption + } + + override fun hashCode(): Int { + var result = blocks.contentHashCode() + result = 31 * result + (caption?.hashCode() ?: 0) + return result + } + + override fun toString(): String = "InputRichBlockCollage(blocks=${blocks.contentToString()}, caption=$caption)" +} diff --git a/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockDetails.kt b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockDetails.kt new file mode 100644 index 000000000..0b168e856 --- /dev/null +++ b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockDetails.kt @@ -0,0 +1,29 @@ +package com.pengrad.telegrambot.model.request.richmessages.inputrichblock + +import com.pengrad.telegrambot.model.richmessages.richblock.RichBlockType +import com.pengrad.telegrambot.model.richmessages.richtext.RichText + +class InputRichBlockDetails( + @get:JvmName("summary") val summary: RichText, + @get:JvmName("blocks") val blocks: Array, + @get:JvmName("isOpen") val isOpen: Boolean? = null +) : InputRichBlock { + + override val type: String get() = RichBlockType.DETAILS + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as InputRichBlockDetails + return summary == other.summary && blocks.contentEquals(other.blocks) && isOpen == other.isOpen + } + + override fun hashCode(): Int { + var result = summary.hashCode() + result = 31 * result + blocks.contentHashCode() + result = 31 * result + (isOpen?.hashCode() ?: 0) + return result + } + + override fun toString(): String = "InputRichBlockDetails(summary=$summary, blocks=${blocks.contentToString()}, isOpen=$isOpen)" +} diff --git a/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockDivider.kt b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockDivider.kt new file mode 100644 index 000000000..c74ea9188 --- /dev/null +++ b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockDivider.kt @@ -0,0 +1,16 @@ +package com.pengrad.telegrambot.model.request.richmessages.inputrichblock + +import com.pengrad.telegrambot.model.richmessages.richblock.RichBlockType + +class InputRichBlockDivider : InputRichBlock { + override val type: String get() = RichBlockType.DIVIDER + + override fun equals(other: Any?): Boolean { + if (this === other) return true + return other is InputRichBlockDivider + } + + override fun hashCode(): Int = type.hashCode() + + override fun toString(): String = "InputRichBlockDivider()" +} diff --git a/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockFooter.kt b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockFooter.kt new file mode 100644 index 000000000..5684d3be8 --- /dev/null +++ b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockFooter.kt @@ -0,0 +1,10 @@ +package com.pengrad.telegrambot.model.request.richmessages.inputrichblock + +import com.pengrad.telegrambot.model.richmessages.richblock.RichBlockType +import com.pengrad.telegrambot.model.richmessages.richtext.RichText + +data class InputRichBlockFooter( + @get:JvmName("text") val text: RichText +) : InputRichBlock { + override val type: String get() = RichBlockType.FOOTER +} diff --git a/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockList.kt b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockList.kt new file mode 100644 index 000000000..11295143b --- /dev/null +++ b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockList.kt @@ -0,0 +1,21 @@ +package com.pengrad.telegrambot.model.request.richmessages.inputrichblock + +import com.pengrad.telegrambot.model.richmessages.richblock.RichBlockType + +class InputRichBlockList( + @get:JvmName("items") val items: Array +) : InputRichBlock { + + override val type: String get() = RichBlockType.LIST + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as InputRichBlockList + return items.contentEquals(other.items) + } + + override fun hashCode(): Int = items.contentHashCode() + + override fun toString(): String = "InputRichBlockList(items=${items.contentToString()})" +} diff --git a/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockListItem.kt b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockListItem.kt new file mode 100644 index 000000000..8c5f7a172 --- /dev/null +++ b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockListItem.kt @@ -0,0 +1,33 @@ +package com.pengrad.telegrambot.model.request.richmessages.inputrichblock + +class InputRichBlockListItem( + @get:JvmName("blocks") val blocks: Array, + @get:JvmName("hasCheckbox") val hasCheckbox: Boolean? = null, + @get:JvmName("isChecked") val isChecked: Boolean? = null, + @get:JvmName("value") val value: Int? = null, + @get:JvmName("type") val type: String? = null +) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as InputRichBlockListItem + return blocks.contentEquals(other.blocks) && + hasCheckbox == other.hasCheckbox && + isChecked == other.isChecked && + value == other.value && + type == other.type + } + + override fun hashCode(): Int { + var result = blocks.contentHashCode() + result = 31 * result + (hasCheckbox?.hashCode() ?: 0) + result = 31 * result + (isChecked?.hashCode() ?: 0) + result = 31 * result + (value ?: 0) + result = 31 * result + (type?.hashCode() ?: 0) + return result + } + + override fun toString(): String = + "InputRichBlockListItem(blocks=${blocks.contentToString()}, hasCheckbox=$hasCheckbox, isChecked=$isChecked, value=$value, type=$type)" +} diff --git a/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockMap.kt b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockMap.kt new file mode 100644 index 000000000..a62fc57cf --- /dev/null +++ b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockMap.kt @@ -0,0 +1,15 @@ +package com.pengrad.telegrambot.model.request.richmessages.inputrichblock + +import com.pengrad.telegrambot.model.Location +import com.pengrad.telegrambot.model.richmessages.richblock.RichBlockCaption +import com.pengrad.telegrambot.model.richmessages.richblock.RichBlockType + +data class InputRichBlockMap( + @get:JvmName("location") val location: Location, + @get:JvmName("zoom") val zoom: Int, + @get:JvmName("width") val width: Int, + @get:JvmName("height") val height: Int, + @get:JvmName("caption") val caption: RichBlockCaption? = null +) : InputRichBlock { + override val type: String get() = RichBlockType.MAP +} diff --git a/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockMathematicalExpression.kt b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockMathematicalExpression.kt new file mode 100644 index 000000000..ff6b71b92 --- /dev/null +++ b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockMathematicalExpression.kt @@ -0,0 +1,9 @@ +package com.pengrad.telegrambot.model.request.richmessages.inputrichblock + +import com.pengrad.telegrambot.model.richmessages.richblock.RichBlockType + +data class InputRichBlockMathematicalExpression( + @get:JvmName("expression") val expression: String +) : InputRichBlock { + override val type: String get() = RichBlockType.MATHEMATICAL_EXPRESSION +} diff --git a/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockParagraph.kt b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockParagraph.kt new file mode 100644 index 000000000..81e482482 --- /dev/null +++ b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockParagraph.kt @@ -0,0 +1,10 @@ +package com.pengrad.telegrambot.model.request.richmessages.inputrichblock + +import com.pengrad.telegrambot.model.richmessages.richblock.RichBlockType +import com.pengrad.telegrambot.model.richmessages.richtext.RichText + +data class InputRichBlockParagraph( + @get:JvmName("text") val text: RichText +) : InputRichBlock { + override val type: String get() = RichBlockType.PARAGRAPH +} diff --git a/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockPhoto.kt b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockPhoto.kt new file mode 100644 index 000000000..aa354020b --- /dev/null +++ b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockPhoto.kt @@ -0,0 +1,12 @@ +package com.pengrad.telegrambot.model.request.richmessages.inputrichblock + +import com.pengrad.telegrambot.model.request.InputMediaPhoto +import com.pengrad.telegrambot.model.richmessages.richblock.RichBlockCaption +import com.pengrad.telegrambot.model.richmessages.richblock.RichBlockType + +data class InputRichBlockPhoto( + @get:JvmName("photo") val photo: InputMediaPhoto, + @get:JvmName("caption") val caption: RichBlockCaption? = null +) : InputRichBlock { + override val type: String get() = RichBlockType.PHOTO +} diff --git a/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockPreformatted.kt b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockPreformatted.kt new file mode 100644 index 000000000..d67b0e4f1 --- /dev/null +++ b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockPreformatted.kt @@ -0,0 +1,11 @@ +package com.pengrad.telegrambot.model.request.richmessages.inputrichblock + +import com.pengrad.telegrambot.model.richmessages.richblock.RichBlockType +import com.pengrad.telegrambot.model.richmessages.richtext.RichText + +data class InputRichBlockPreformatted( + @get:JvmName("text") val text: RichText, + @get:JvmName("language") val language: String? = null +) : InputRichBlock { + override val type: String get() = RichBlockType.PRE +} diff --git a/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockPullQuotation.kt b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockPullQuotation.kt new file mode 100644 index 000000000..c1aebf6d4 --- /dev/null +++ b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockPullQuotation.kt @@ -0,0 +1,11 @@ +package com.pengrad.telegrambot.model.request.richmessages.inputrichblock + +import com.pengrad.telegrambot.model.richmessages.richblock.RichBlockType +import com.pengrad.telegrambot.model.richmessages.richtext.RichText + +data class InputRichBlockPullQuotation( + @get:JvmName("text") val text: RichText, + @get:JvmName("credit") val credit: RichText? = null +) : InputRichBlock { + override val type: String get() = RichBlockType.PULLQUOTE +} diff --git a/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockSectionHeading.kt b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockSectionHeading.kt new file mode 100644 index 000000000..d69e2cb50 --- /dev/null +++ b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockSectionHeading.kt @@ -0,0 +1,11 @@ +package com.pengrad.telegrambot.model.request.richmessages.inputrichblock + +import com.pengrad.telegrambot.model.richmessages.richblock.RichBlockType +import com.pengrad.telegrambot.model.richmessages.richtext.RichText + +data class InputRichBlockSectionHeading( + @get:JvmName("text") val text: RichText, + @get:JvmName("size") val size: Int +) : InputRichBlock { + override val type: String get() = RichBlockType.HEADING +} diff --git a/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockSlideshow.kt b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockSlideshow.kt new file mode 100644 index 000000000..e03a100f6 --- /dev/null +++ b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockSlideshow.kt @@ -0,0 +1,27 @@ +package com.pengrad.telegrambot.model.request.richmessages.inputrichblock + +import com.pengrad.telegrambot.model.richmessages.richblock.RichBlockCaption +import com.pengrad.telegrambot.model.richmessages.richblock.RichBlockType + +class InputRichBlockSlideshow( + @get:JvmName("blocks") val blocks: Array, + @get:JvmName("caption") val caption: RichBlockCaption? = null +) : InputRichBlock { + + override val type: String get() = RichBlockType.SLIDESHOW + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as InputRichBlockSlideshow + return blocks.contentEquals(other.blocks) && caption == other.caption + } + + override fun hashCode(): Int { + var result = blocks.contentHashCode() + result = 31 * result + (caption?.hashCode() ?: 0) + return result + } + + override fun toString(): String = "InputRichBlockSlideshow(blocks=${blocks.contentToString()}, caption=$caption)" +} diff --git a/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockTable.kt b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockTable.kt new file mode 100644 index 000000000..81aed7d45 --- /dev/null +++ b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockTable.kt @@ -0,0 +1,36 @@ +package com.pengrad.telegrambot.model.request.richmessages.inputrichblock + +import com.pengrad.telegrambot.model.richmessages.richblock.RichBlockTableCell +import com.pengrad.telegrambot.model.richmessages.richblock.RichBlockType +import com.pengrad.telegrambot.model.richmessages.richtext.RichText + +class InputRichBlockTable( + @get:JvmName("cells") val cells: Array>, + @get:JvmName("isBordered") val isBordered: Boolean? = null, + @get:JvmName("isStriped") val isStriped: Boolean? = null, + @get:JvmName("caption") val caption: RichText? = null +) : InputRichBlock { + + override val type: String get() = RichBlockType.TABLE + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as InputRichBlockTable + return cells.contentDeepEquals(other.cells) && + isBordered == other.isBordered && + isStriped == other.isStriped && + caption == other.caption + } + + override fun hashCode(): Int { + var result = cells.contentDeepHashCode() + result = 31 * result + (isBordered?.hashCode() ?: 0) + result = 31 * result + (isStriped?.hashCode() ?: 0) + result = 31 * result + (caption?.hashCode() ?: 0) + return result + } + + override fun toString(): String = + "InputRichBlockTable(cells=${cells.contentDeepToString()}, isBordered=$isBordered, isStriped=$isStriped, caption=$caption)" +} diff --git a/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockThinking.kt b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockThinking.kt new file mode 100644 index 000000000..bf409c0e2 --- /dev/null +++ b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockThinking.kt @@ -0,0 +1,10 @@ +package com.pengrad.telegrambot.model.request.richmessages.inputrichblock + +import com.pengrad.telegrambot.model.richmessages.richblock.RichBlockType +import com.pengrad.telegrambot.model.richmessages.richtext.RichText + +data class InputRichBlockThinking( + @get:JvmName("text") val text: RichText +) : InputRichBlock { + override val type: String get() = RichBlockType.THINKING +} diff --git a/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockVideo.kt b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockVideo.kt new file mode 100644 index 000000000..be28521b7 --- /dev/null +++ b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockVideo.kt @@ -0,0 +1,12 @@ +package com.pengrad.telegrambot.model.request.richmessages.inputrichblock + +import com.pengrad.telegrambot.model.request.InputMediaVideo +import com.pengrad.telegrambot.model.richmessages.richblock.RichBlockCaption +import com.pengrad.telegrambot.model.richmessages.richblock.RichBlockType + +data class InputRichBlockVideo( + @get:JvmName("video") val video: InputMediaVideo, + @get:JvmName("caption") val caption: RichBlockCaption? = null +) : InputRichBlock { + override val type: String get() = RichBlockType.VIDEO +} diff --git a/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockVoiceNote.kt b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockVoiceNote.kt new file mode 100644 index 000000000..95ac73796 --- /dev/null +++ b/library/src/main/java/com/pengrad/telegrambot/model/request/richmessages/inputrichblock/InputRichBlockVoiceNote.kt @@ -0,0 +1,12 @@ +package com.pengrad.telegrambot.model.request.richmessages.inputrichblock + +import com.pengrad.telegrambot.model.request.InputMediaVoiceNote +import com.pengrad.telegrambot.model.richmessages.richblock.RichBlockCaption +import com.pengrad.telegrambot.model.richmessages.richblock.RichBlockType + +data class InputRichBlockVoiceNote( + @get:JvmName("voiceNote") val voiceNote: InputMediaVoiceNote, + @get:JvmName("caption") val caption: RichBlockCaption? = null +) : InputRichBlock { + override val type: String get() = RichBlockType.VOICE_NOTE +} diff --git a/library/src/main/java/com/pengrad/telegrambot/request/EditMessageText.java b/library/src/main/java/com/pengrad/telegrambot/request/EditMessageText.java index 4a3aeb281..25e32fd50 100644 --- a/library/src/main/java/com/pengrad/telegrambot/request/EditMessageText.java +++ b/library/src/main/java/com/pengrad/telegrambot/request/EditMessageText.java @@ -7,6 +7,9 @@ import com.pengrad.telegrambot.model.request.richmessages.InputRichMessage; import com.pengrad.telegrambot.response.BaseResponse; import com.pengrad.telegrambot.response.SendResponse; +import com.pengrad.telegrambot.utility.richmessages.RichMessageAttachments; + +import java.util.Map; /** * Stas Parshin @@ -14,6 +17,10 @@ */ public class EditMessageText extends BaseRequest { + private InputRichMessage richMessage; + private boolean attachmentsCollected = false; + private boolean isMultipart = false; + public EditMessageText(Object chatId, int messageId, String text) { super(SendResponse.class); add("chat_id", chatId).add("message_id", messageId).add("text", text); @@ -26,11 +33,13 @@ public EditMessageText(String inlineMessageId, String text) { public EditMessageText(Object chatId, int messageId, InputRichMessage richMessage) { super(SendResponse.class); + this.richMessage = richMessage; add("chat_id", chatId).add("message_id", messageId).add("rich_message", richMessage); } public EditMessageText(String inlineMessageId, InputRichMessage richMessage) { super(BaseResponse.class); + this.richMessage = richMessage; add("inline_message_id", inlineMessageId).add("rich_message", richMessage); } @@ -55,7 +64,32 @@ public EditMessageText businessConnectionId(String businessConnectionId) { } public EditMessageText richMessage(InputRichMessage richMessage) { + this.richMessage = richMessage; return add("rich_message", richMessage); } + /** + * Collected on send rather than on construction, so that a rich message populated after + * the request was built is still uploaded. + */ + private void collectAttachments() { + if (attachmentsCollected) return; + attachmentsCollected = true; + Map attachments = RichMessageAttachments.collect(richMessage); + addAll(attachments); + isMultipart = !attachments.isEmpty(); + } + + @Override + public boolean isMultipart() { + collectAttachments(); + return isMultipart; + } + + @Override + public Map getParameters() { + collectAttachments(); + return super.getParameters(); + } + } diff --git a/library/src/main/java/com/pengrad/telegrambot/request/richmessages/SendRichMessage.kt b/library/src/main/java/com/pengrad/telegrambot/request/richmessages/SendRichMessage.kt index 72fa1f9ff..9ad45efc6 100644 --- a/library/src/main/java/com/pengrad/telegrambot/request/richmessages/SendRichMessage.kt +++ b/library/src/main/java/com/pengrad/telegrambot/request/richmessages/SendRichMessage.kt @@ -4,6 +4,7 @@ import com.pengrad.telegrambot.model.request.richmessages.InputRichMessage import com.pengrad.telegrambot.request.AbstractSendRequest import com.pengrad.telegrambot.utility.kotlin.checkDeprecatedConstructorParameters import com.pengrad.telegrambot.utility.kotlin.requestParameter +import com.pengrad.telegrambot.utility.richmessages.RichMessageAttachments @Suppress("unused") class SendRichMessage private constructor( @@ -37,4 +38,29 @@ class SendRichMessage private constructor( } val richMessage: InputRichMessage by requestParameter(richMessage) + + private var attachmentsCollected = false + private var multipart = false + + /** + * Attachments are collected on send rather than on construction, so that a rich message + * populated after the request was built is still uploaded. + */ + private fun collectAttachments() { + if (attachmentsCollected) return + attachmentsCollected = true + val attachments = RichMessageAttachments.collect(richMessage) + attachments.forEach { (name, file) -> addParameter(name, file) } + multipart = attachments.isNotEmpty() + } + + override fun isMultipart(): Boolean { + collectAttachments() + return multipart + } + + override fun getParameters(): MutableMap { + collectAttachments() + return super.getParameters() + } } diff --git a/library/src/main/java/com/pengrad/telegrambot/request/richmessages/SendRichMessageDraft.kt b/library/src/main/java/com/pengrad/telegrambot/request/richmessages/SendRichMessageDraft.kt index 5285729d7..59362a5d8 100644 --- a/library/src/main/java/com/pengrad/telegrambot/request/richmessages/SendRichMessageDraft.kt +++ b/library/src/main/java/com/pengrad/telegrambot/request/richmessages/SendRichMessageDraft.kt @@ -5,6 +5,7 @@ import com.pengrad.telegrambot.request.KBaseRequest import com.pengrad.telegrambot.response.BaseResponse import com.pengrad.telegrambot.utility.kotlin.optionalRequestParameter import com.pengrad.telegrambot.utility.kotlin.requestParameter +import com.pengrad.telegrambot.utility.richmessages.RichMessageAttachments @Suppress("unused") class SendRichMessageDraft( @@ -20,4 +21,29 @@ class SendRichMessageDraft( var messageThreadId: Long? by optionalRequestParameter() fun messageThreadId(messageThreadId: Long) = applySelf { this.messageThreadId = messageThreadId } + + private var attachmentsCollected = false + private var multipart = false + + /** + * Drafts cannot upload new files, but a rich message may still reference already attached + * thumbnails, so the same collection runs here to keep the payload consistent. + */ + private fun collectAttachments() { + if (attachmentsCollected) return + attachmentsCollected = true + val attachments = RichMessageAttachments.collect(richMessage) + attachments.forEach { (name, file) -> addParameter(name, file) } + multipart = attachments.isNotEmpty() + } + + override fun isMultipart(): Boolean { + collectAttachments() + return multipart + } + + override fun getParameters(): MutableMap { + collectAttachments() + return super.getParameters() + } } diff --git a/library/src/main/java/com/pengrad/telegrambot/utility/BotUtils.java b/library/src/main/java/com/pengrad/telegrambot/utility/BotUtils.java index 640786b4e..0ee651d1a 100644 --- a/library/src/main/java/com/pengrad/telegrambot/utility/BotUtils.java +++ b/library/src/main/java/com/pengrad/telegrambot/utility/BotUtils.java @@ -12,6 +12,7 @@ import com.pengrad.telegrambot.model.message.origin.MessageOrigin; import com.pengrad.telegrambot.model.paidmedia.PaidMedia; import com.pengrad.telegrambot.model.reaction.ReactionType; +import com.pengrad.telegrambot.model.request.richmessages.inputrichblock.InputRichBlock; import com.pengrad.telegrambot.model.richmessages.richblock.RichBlock; import com.pengrad.telegrambot.model.richmessages.richtext.RichText; import com.pengrad.telegrambot.model.stars.partner.TransactionPartner; @@ -44,6 +45,7 @@ private BotUtils() {} .registerTypeAdapter(OwnedGift.class, new OwnedGiftTypeAdapter()) .registerTypeAdapter(RichText.class, RichTextTypeAdapter.INSTANCE) .registerTypeAdapter(RichBlock.class, RichBlockTypeAdapter.INSTANCE) + .registerTypeAdapter(InputRichBlock.class, InputRichBlockSerializer.INSTANCE) .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) .create(); diff --git a/library/src/main/java/com/pengrad/telegrambot/utility/gson/InputRichBlockSerializer.kt b/library/src/main/java/com/pengrad/telegrambot/utility/gson/InputRichBlockSerializer.kt new file mode 100644 index 000000000..bb112082a --- /dev/null +++ b/library/src/main/java/com/pengrad/telegrambot/utility/gson/InputRichBlockSerializer.kt @@ -0,0 +1,19 @@ +package com.pengrad.telegrambot.utility.gson + +import com.google.gson.JsonElement +import com.google.gson.JsonSerializationContext +import com.google.gson.JsonSerializer +import com.pengrad.telegrambot.model.request.richmessages.inputrichblock.InputRichBlock +import java.lang.reflect.Type + +/** + * [InputRichBlock] carries its discriminator in a computed property, which Gson does not + * serialize, so it is added explicitly here after delegating to the concrete type. + */ +object InputRichBlockSerializer : JsonSerializer { + + override fun serialize(src: InputRichBlock, typeOfSrc: Type, context: JsonSerializationContext): JsonElement = + context.serialize(src, src.javaClass).asJsonObject.apply { + addProperty("type", src.type) + } +} diff --git a/library/src/main/java/com/pengrad/telegrambot/utility/gson/RichBlockTypeAdapter.kt b/library/src/main/java/com/pengrad/telegrambot/utility/gson/RichBlockTypeAdapter.kt index 53b812639..118a6d687 100644 --- a/library/src/main/java/com/pengrad/telegrambot/utility/gson/RichBlockTypeAdapter.kt +++ b/library/src/main/java/com/pengrad/telegrambot/utility/gson/RichBlockTypeAdapter.kt @@ -4,10 +4,12 @@ import com.google.gson.JsonDeserializationContext import com.google.gson.JsonDeserializer import com.google.gson.JsonElement import com.google.gson.JsonParseException +import com.google.gson.JsonSerializationContext +import com.google.gson.JsonSerializer import com.pengrad.telegrambot.model.richmessages.richblock.* import java.lang.reflect.Type -object RichBlockTypeAdapter : JsonDeserializer { +object RichBlockTypeAdapter : JsonDeserializer, JsonSerializer { private val typeMapping = mapOf( RichBlockType.PARAGRAPH to RichBlockParagraph::class, @@ -41,4 +43,13 @@ object RichBlockTypeAdapter : JsonDeserializer { context.deserialize(obj, it.java) } ?: RichBlockUnknown(discriminator) } + + /** + * The discriminator is a computed property, so Gson leaves it out of the reflective + * output and it has to be added back explicitly. + */ + override fun serialize(src: RichBlock, typeOfSrc: Type, context: JsonSerializationContext): JsonElement = + context.serialize(src, src.javaClass).asJsonObject.apply { + addProperty("type", src.type) + } } diff --git a/library/src/main/java/com/pengrad/telegrambot/utility/gson/RichTextTypeAdapter.kt b/library/src/main/java/com/pengrad/telegrambot/utility/gson/RichTextTypeAdapter.kt index a7fc39756..6b8e2a817 100644 --- a/library/src/main/java/com/pengrad/telegrambot/utility/gson/RichTextTypeAdapter.kt +++ b/library/src/main/java/com/pengrad/telegrambot/utility/gson/RichTextTypeAdapter.kt @@ -1,13 +1,17 @@ package com.pengrad.telegrambot.utility.gson +import com.google.gson.JsonArray import com.google.gson.JsonDeserializationContext import com.google.gson.JsonDeserializer import com.google.gson.JsonElement import com.google.gson.JsonParseException +import com.google.gson.JsonPrimitive +import com.google.gson.JsonSerializationContext +import com.google.gson.JsonSerializer import com.pengrad.telegrambot.model.richmessages.richtext.* import java.lang.reflect.Type -object RichTextTypeAdapter : JsonDeserializer { +object RichTextTypeAdapter : JsonDeserializer, JsonSerializer { private val typeMapping = mapOf( RichTextType.BOLD to RichTextBold::class, @@ -57,4 +61,20 @@ object RichTextTypeAdapter : JsonDeserializer { else -> RichTextUnknown(RichTextType.UNKNOWN) } } + + /** + * Mirrors [deserialize]: plain text is a bare string, an array is a JSON array, and every + * other node is an object whose computed `type` property has to be added back explicitly, + * since Gson only serializes fields. + */ + override fun serialize(src: RichText, typeOfSrc: Type, context: JsonSerializationContext): JsonElement = + when (src) { + is RichTextPlain -> JsonPrimitive(src.text) + is RichTextArray -> JsonArray().apply { + src.elements.forEach { add(context.serialize(it, RichText::class.java)) } + } + else -> context.serialize(src, src.javaClass).asJsonObject.apply { + addProperty("type", src.type) + } + } } diff --git a/library/src/main/java/com/pengrad/telegrambot/utility/kotlin/extension/request/SendRichMessageDraftExtension.kt b/library/src/main/java/com/pengrad/telegrambot/utility/kotlin/extension/request/SendRichMessageDraftExtension.kt new file mode 100644 index 000000000..5633737ba --- /dev/null +++ b/library/src/main/java/com/pengrad/telegrambot/utility/kotlin/extension/request/SendRichMessageDraftExtension.kt @@ -0,0 +1,17 @@ +package com.pengrad.telegrambot.utility.kotlin.extension.request + +import com.pengrad.telegrambot.TelegramAware +import com.pengrad.telegrambot.model.request.richmessages.InputRichMessage +import com.pengrad.telegrambot.request.richmessages.SendRichMessageDraft +import com.pengrad.telegrambot.utility.kotlin.extension.execute + +inline fun TelegramAware.sendRichMessageDraft( + chatId: Long, + draftId: Int, + richMessage: InputRichMessage, + modifier: SendRichMessageDraft.() -> Unit = {} +) = this.execute(SendRichMessageDraft( + chatId = chatId, + draftId = draftId, + richMessage = richMessage +), modifier) diff --git a/library/src/main/java/com/pengrad/telegrambot/utility/kotlin/extension/request/SendRichMessageExtension.kt b/library/src/main/java/com/pengrad/telegrambot/utility/kotlin/extension/request/SendRichMessageExtension.kt new file mode 100644 index 000000000..00607167b --- /dev/null +++ b/library/src/main/java/com/pengrad/telegrambot/utility/kotlin/extension/request/SendRichMessageExtension.kt @@ -0,0 +1,24 @@ +package com.pengrad.telegrambot.utility.kotlin.extension.request + +import com.pengrad.telegrambot.TelegramAware +import com.pengrad.telegrambot.model.request.richmessages.InputRichMessage +import com.pengrad.telegrambot.request.richmessages.SendRichMessage +import com.pengrad.telegrambot.utility.kotlin.extension.execute + +inline fun TelegramAware.sendRichMessage( + chatId: Long, + richMessage: InputRichMessage, + modifier: SendRichMessage.() -> Unit = {} +) = this.execute(SendRichMessage( + chatId = chatId, + richMessage = richMessage +), modifier) + +inline fun TelegramAware.sendRichMessage( + channelUsername: String, + richMessage: InputRichMessage, + modifier: SendRichMessage.() -> Unit = {} +) = this.execute(SendRichMessage( + channelUsername = channelUsername, + richMessage = richMessage +), modifier) diff --git a/library/src/main/java/com/pengrad/telegrambot/utility/richmessages/RichMessageAttachments.kt b/library/src/main/java/com/pengrad/telegrambot/utility/richmessages/RichMessageAttachments.kt new file mode 100644 index 000000000..711d92f6b --- /dev/null +++ b/library/src/main/java/com/pengrad/telegrambot/utility/richmessages/RichMessageAttachments.kt @@ -0,0 +1,50 @@ +package com.pengrad.telegrambot.utility.richmessages + +import com.pengrad.telegrambot.model.request.InputMedia +import com.pengrad.telegrambot.model.request.richmessages.InputRichMessage +import com.pengrad.telegrambot.model.request.richmessages.inputrichblock.* + +/** + * Collects the files that a rich message uploads, keyed by the `attach://` name referenced in + * the serialized payload. A request is multipart exactly when the result is not empty. + */ +object RichMessageAttachments { + + @JvmStatic + fun collect(richMessage: InputRichMessage?): Map { + if (richMessage == null) return emptyMap() + val attachments = LinkedHashMap() + richMessage.media?.forEach { collectMedia(it.media, attachments) } + richMessage.blocks?.forEach { collectBlock(it, attachments) } + return attachments + } + + private fun collectMedia(media: InputMedia<*>, attachments: MutableMap) { + media.attachments?.let { attachments.putAll(it) } + val inputFile = media.inputFile() + val inputFileId = media.inputFileId + if (inputFile != null && inputFileId != null) { + attachments[inputFileId] = inputFile + } + } + + private fun collectBlocks(blocks: Array, attachments: MutableMap) { + blocks.forEach { collectBlock(it, attachments) } + } + + private fun collectBlock(block: InputRichBlock, attachments: MutableMap) { + when (block) { + is InputRichBlockPhoto -> collectMedia(block.photo, attachments) + is InputRichBlockVideo -> collectMedia(block.video, attachments) + is InputRichBlockAudio -> collectMedia(block.audio, attachments) + is InputRichBlockAnimation -> collectMedia(block.animation, attachments) + is InputRichBlockVoiceNote -> collectMedia(block.voiceNote, attachments) + is InputRichBlockCollage -> collectBlocks(block.blocks, attachments) + is InputRichBlockSlideshow -> collectBlocks(block.blocks, attachments) + is InputRichBlockDetails -> collectBlocks(block.blocks, attachments) + is InputRichBlockBlockQuotation -> collectBlocks(block.blocks, attachments) + is InputRichBlockList -> block.items.forEach { collectBlocks(it.blocks, attachments) } + else -> Unit + } + } +} diff --git a/library/src/test/java/com/pengrad/telegrambot/ModelTest.kt b/library/src/test/java/com/pengrad/telegrambot/ModelTest.kt index 51ce8cccc..832b94f98 100644 --- a/library/src/test/java/com/pengrad/telegrambot/ModelTest.kt +++ b/library/src/test/java/com/pengrad/telegrambot/ModelTest.kt @@ -6,6 +6,12 @@ import com.pengrad.telegrambot.model.chatbackground.BackgroundTypeWallpaper import com.pengrad.telegrambot.model.message.MaybeInaccessibleMessage import com.pengrad.telegrambot.model.request.InlineKeyboardButton import com.pengrad.telegrambot.model.request.InlineKeyboardMarkup +import com.pengrad.telegrambot.model.request.InputMedia +import com.pengrad.telegrambot.model.request.InputMediaAnimation +import com.pengrad.telegrambot.model.request.InputMediaAudio +import com.pengrad.telegrambot.model.request.InputMediaPhoto +import com.pengrad.telegrambot.model.request.InputMediaVideo +import com.pengrad.telegrambot.model.request.InputMediaVoiceNote import com.pengrad.telegrambot.model.request.ParseMode import com.pengrad.telegrambot.passport.Credentials import com.pengrad.telegrambot.passport.decrypt.Decrypt @@ -85,6 +91,14 @@ class ModelTest { verifierApi.withPrefabValues(it.javaClass, it.javaClass.getDeclaredConstructor().newInstance(), it) } + // InputMedia holds a final self-typed field that EqualsVerifier cannot instantiate + verifierApi.withPrefabValues(InputMedia::class.java, InputMediaPhoto("red"), InputMediaPhoto("black")) + verifierApi.withPrefabValues(InputMediaAnimation::class.java, InputMediaAnimation("red"), InputMediaAnimation("black")) + verifierApi.withPrefabValues(InputMediaAudio::class.java, InputMediaAudio("red"), InputMediaAudio("black")) + verifierApi.withPrefabValues(InputMediaPhoto::class.java, InputMediaPhoto("red"), InputMediaPhoto("black")) + verifierApi.withPrefabValues(InputMediaVideo::class.java, InputMediaVideo("red"), InputMediaVideo("black")) + verifierApi.withPrefabValues(InputMediaVoiceNote::class.java, InputMediaVoiceNote("red"), InputMediaVoiceNote("black")) + if (c == Message::class.java) { verifierApi.withIgnoredFields("video_chat_started") verifierApi.withIgnoredFields("forum_topic_closed") diff --git a/library/src/test/java/com/pengrad/telegrambot/RichMessageRequestTest.kt b/library/src/test/java/com/pengrad/telegrambot/RichMessageRequestTest.kt new file mode 100644 index 000000000..623624a85 --- /dev/null +++ b/library/src/test/java/com/pengrad/telegrambot/RichMessageRequestTest.kt @@ -0,0 +1,208 @@ +package com.pengrad.telegrambot + +import com.google.gson.JsonParser +import com.pengrad.telegrambot.model.request.InputMediaPhoto +import com.pengrad.telegrambot.model.request.InputMediaVoiceNote +import com.pengrad.telegrambot.model.request.richmessages.InputRichMessage +import com.pengrad.telegrambot.model.request.richmessages.InputRichMessageMedia +import com.pengrad.telegrambot.model.request.richmessages.inputrichblock.* +import com.pengrad.telegrambot.model.richmessages.richblock.RichBlockCaption +import com.pengrad.telegrambot.model.richmessages.richtext.RichTextArray +import com.pengrad.telegrambot.model.richmessages.richtext.RichTextBold +import com.pengrad.telegrambot.model.richmessages.richtext.RichTextPlain +import com.pengrad.telegrambot.request.EditMessageText +import com.pengrad.telegrambot.request.richmessages.SendRichMessage +import com.pengrad.telegrambot.request.richmessages.SendRichMessageDraft +import com.pengrad.telegrambot.utility.BotUtils +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class RichMessageRequestTest { + + private fun json(any: Any) = JsonParser.parseString(BotUtils.toJson(any)).asJsonObject + + /** Serializes the block the way a request does: as an element of [InputRichMessage.blocks]. */ + private fun blockJson(block: InputRichBlock) = + json(InputRichMessage().blocks(block))["blocks"].asJsonArray[0].asJsonObject + + @Test + fun `block carries its type discriminator`() { + val block = blockJson(InputRichBlockParagraph(RichTextPlain("hi"))) + + assertEquals("paragraph", block["type"].asString) + assertEquals("hi", block["text"].asString) + } + + @Test + fun `every block type is serialized with the expected discriminator`() { + val photo = InputMediaPhoto("file_id") + val blocks = listOf( + InputRichBlockParagraph(RichTextPlain("p")) to "paragraph", + InputRichBlockSectionHeading(RichTextPlain("h"), 1) to "heading", + InputRichBlockPreformatted(RichTextPlain("code")) to "pre", + InputRichBlockFooter(RichTextPlain("f")) to "footer", + InputRichBlockDivider() to "divider", + InputRichBlockMathematicalExpression("x^2") to "mathematical_expression", + InputRichBlockAnchor("top") to "anchor", + InputRichBlockList(arrayOf(InputRichBlockListItem(arrayOf(InputRichBlockParagraph(RichTextPlain("i")))))) to "list", + InputRichBlockBlockQuotation(arrayOf(InputRichBlockParagraph(RichTextPlain("q")))) to "blockquote", + InputRichBlockPullQuotation(RichTextPlain("pq")) to "pullquote", + InputRichBlockCollage(arrayOf(InputRichBlockPhoto(photo))) to "collage", + InputRichBlockSlideshow(arrayOf(InputRichBlockPhoto(photo))) to "slideshow", + InputRichBlockTable(arrayOf(emptyArray())) to "table", + InputRichBlockDetails(RichTextPlain("s"), arrayOf(InputRichBlockParagraph(RichTextPlain("d")))) to "details", + InputRichBlockPhoto(photo) to "photo", + InputRichBlockThinking(RichTextPlain("t")) to "thinking", + InputRichBlockVoiceNote(InputMediaVoiceNote("file_id")) to "voice_note" + ) + + blocks.forEach { (block, expectedType) -> + assertEquals(expectedType, blockJson(block)["type"].asString) + } + } + + @Test + fun `field names are converted to snake case`() { + val block = blockJson(InputRichBlockVoiceNote(InputMediaVoiceNote("file_id"))) + + assertEquals("voice_note", block["type"].asString) + assertTrue(block.has("voice_note")) + } + + @Test + fun `rich text is serialized the same way it is parsed`() { + val block = blockJson( + InputRichBlockParagraph( + RichTextArray(arrayOf(RichTextPlain("plain "), RichTextBold(RichTextPlain("bold")))) + ) + ) + + val text = block["text"].asJsonArray + assertEquals("plain ", text[0].asString) + + val bold = text[1].asJsonObject + assertEquals("bold", bold["type"].asString) + assertEquals("bold", bold["text"].asString) + } + + @Test + fun `nested blocks keep their discriminators`() { + val block = blockJson( + InputRichBlockDetails( + summary = RichTextPlain("summary"), + blocks = arrayOf( + InputRichBlockList( + arrayOf( + InputRichBlockListItem( + blocks = arrayOf(InputRichBlockParagraph(RichTextPlain("item"))), + hasCheckbox = true + ) + ) + ) + ) + ) + ) + + assertEquals("details", block["type"].asString) + val list = block["blocks"].asJsonArray[0].asJsonObject + assertEquals("list", list["type"].asString) + val item = list["items"].asJsonArray[0].asJsonObject + assertTrue(item["has_checkbox"].asBoolean) + assertEquals("paragraph", item["blocks"].asJsonArray[0].asJsonObject["type"].asString) + } + + @Test + fun `rich message without uploads is not multipart`() { + val request = SendRichMessage(1L, InputRichMessage().html("hi")) + + assertFalse(request.isMultipart) + assertEquals("hi", json(request.parameters["rich_message"]!!)["html"].asString) + } + + @Test + fun `uploaded block media becomes a multipart part`() { + val photo = InputMediaPhoto(byteArrayOf(1, 2, 3)) + val request = SendRichMessage(1L, InputRichMessage().blocks(InputRichBlockPhoto(photo))) + + assertTrue(request.isMultipart) + assertTrue(request.parameters.containsKey(photo.inputFileId)) + } + + @Test + fun `uploaded media of the message becomes a multipart part`() { + val photo = InputMediaPhoto(byteArrayOf(1, 2, 3)) + val request = SendRichMessage( + 1L, + InputRichMessage() + .html("") + .media(InputRichMessageMedia("pic", photo)) + ) + + assertTrue(request.isMultipart) + assertTrue(request.parameters.containsKey(photo.inputFileId)) + } + + @Test + fun `uploads nested in other blocks are collected`() { + val photo = InputMediaPhoto(byteArrayOf(1, 2, 3)) + val request = SendRichMessage( + 1L, + InputRichMessage().blocks( + InputRichBlockDetails( + summary = RichTextPlain("s"), + blocks = arrayOf( + InputRichBlockCollage( + arrayOf(InputRichBlockPhoto(photo, RichBlockCaption(RichTextPlain("c")))) + ) + ) + ) + ) + ) + + assertTrue(request.isMultipart) + assertTrue(request.parameters.containsKey(photo.inputFileId)) + } + + @Test + fun `uploads added after the request was built are still collected`() { + val photo = InputMediaPhoto(byteArrayOf(1, 2, 3)) + val richMessage = InputRichMessage() + val request = SendRichMessage(1L, richMessage) + + richMessage.blocks(InputRichBlockPhoto(photo)) + + assertTrue(request.isMultipart) + assertTrue(request.parameters.containsKey(photo.inputFileId)) + } + + @Test + fun `drafts collect uploads too`() { + val photo = InputMediaPhoto(byteArrayOf(1, 2, 3)) + val request = SendRichMessageDraft(1L, 7, InputRichMessage().blocks(InputRichBlockPhoto(photo))) + + assertEquals(1L, request.parameters["chat_id"]) + assertEquals(7, request.parameters["draft_id"]) + assertTrue(request.isMultipart) + assertTrue(request.parameters.containsKey(photo.inputFileId)) + } + + @Test + fun `editMessageText collects uploads of its rich message`() { + val photo = InputMediaPhoto(byteArrayOf(1, 2, 3)) + val request = EditMessageText(1L, 2, InputRichMessage().blocks(InputRichBlockPhoto(photo))) + + assertTrue(request.isMultipart) + assertTrue(request.parameters.containsKey(photo.inputFileId)) + } + + @Test + fun `editMessageText without a rich message stays a plain request`() { + val request = EditMessageText(1L, 2, "text") + + assertFalse(request.isMultipart) + assertNull(request.parameters["rich_message"]) + } +} From 77789e838c6d6d9034d0558384f197cea5c25c7b Mon Sep 17 00:00:00 2001 From: Fernando Werneck Date: Thu, 6 Aug 2026 22:27:57 -0300 Subject: [PATCH 2/4] Rescan rich message attachments on every send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The collect-once guard defeated the point of collecting lazily: anything that read isMultipart() or getParameters() before the mutable InputRichMessage was populated — logging, for instance — froze the request as non-multipart, and re-sending a request after adding media kept sending attach:// references with no file parts. RichMessageAttachments.refresh now rescans on every call and drops the parts left over from the previous scan, so replacing a block also removes its stale part. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012tbomWzUs3YpXUn61Rwg17 --- .../telegrambot/request/EditMessageText.java | 16 ++++------ .../request/richmessages/SendRichMessage.kt | 17 +++-------- .../richmessages/SendRichMessageDraft.kt | 17 +++-------- .../richmessages/RichMessageAttachments.kt | 22 ++++++++++++++ .../telegrambot/RichMessageRequestTest.kt | 29 +++++++++++++++++++ 5 files changed, 65 insertions(+), 36 deletions(-) diff --git a/library/src/main/java/com/pengrad/telegrambot/request/EditMessageText.java b/library/src/main/java/com/pengrad/telegrambot/request/EditMessageText.java index 25e32fd50..07928f0ed 100644 --- a/library/src/main/java/com/pengrad/telegrambot/request/EditMessageText.java +++ b/library/src/main/java/com/pengrad/telegrambot/request/EditMessageText.java @@ -9,7 +9,9 @@ import com.pengrad.telegrambot.response.SendResponse; import com.pengrad.telegrambot.utility.richmessages.RichMessageAttachments; +import java.util.LinkedHashSet; import java.util.Map; +import java.util.Set; /** * Stas Parshin @@ -18,8 +20,7 @@ public class EditMessageText extends BaseRequest { private InputRichMessage richMessage; - private boolean attachmentsCollected = false; - private boolean isMultipart = false; + private final Set attachmentNames = new LinkedHashSet<>(); public EditMessageText(Object chatId, int messageId, String text) { super(SendResponse.class); @@ -72,18 +73,13 @@ public EditMessageText richMessage(InputRichMessage richMessage) { * Collected on send rather than on construction, so that a rich message populated after * the request was built is still uploaded. */ - private void collectAttachments() { - if (attachmentsCollected) return; - attachmentsCollected = true; - Map attachments = RichMessageAttachments.collect(richMessage); - addAll(attachments); - isMultipart = !attachments.isEmpty(); + private boolean collectAttachments() { + return RichMessageAttachments.refresh(richMessage, super.getParameters(), attachmentNames); } @Override public boolean isMultipart() { - collectAttachments(); - return isMultipart; + return collectAttachments(); } @Override diff --git a/library/src/main/java/com/pengrad/telegrambot/request/richmessages/SendRichMessage.kt b/library/src/main/java/com/pengrad/telegrambot/request/richmessages/SendRichMessage.kt index 9ad45efc6..013c0d4a8 100644 --- a/library/src/main/java/com/pengrad/telegrambot/request/richmessages/SendRichMessage.kt +++ b/library/src/main/java/com/pengrad/telegrambot/request/richmessages/SendRichMessage.kt @@ -39,25 +39,16 @@ class SendRichMessage private constructor( val richMessage: InputRichMessage by requestParameter(richMessage) - private var attachmentsCollected = false - private var multipart = false + private val attachmentNames = mutableSetOf() /** * Attachments are collected on send rather than on construction, so that a rich message * populated after the request was built is still uploaded. */ - private fun collectAttachments() { - if (attachmentsCollected) return - attachmentsCollected = true - val attachments = RichMessageAttachments.collect(richMessage) - attachments.forEach { (name, file) -> addParameter(name, file) } - multipart = attachments.isNotEmpty() - } + private fun collectAttachments() = + RichMessageAttachments.refresh(richMessage, super.getParameters(), attachmentNames) - override fun isMultipart(): Boolean { - collectAttachments() - return multipart - } + override fun isMultipart(): Boolean = collectAttachments() override fun getParameters(): MutableMap { collectAttachments() diff --git a/library/src/main/java/com/pengrad/telegrambot/request/richmessages/SendRichMessageDraft.kt b/library/src/main/java/com/pengrad/telegrambot/request/richmessages/SendRichMessageDraft.kt index 59362a5d8..61388e446 100644 --- a/library/src/main/java/com/pengrad/telegrambot/request/richmessages/SendRichMessageDraft.kt +++ b/library/src/main/java/com/pengrad/telegrambot/request/richmessages/SendRichMessageDraft.kt @@ -22,25 +22,16 @@ class SendRichMessageDraft( fun messageThreadId(messageThreadId: Long) = applySelf { this.messageThreadId = messageThreadId } - private var attachmentsCollected = false - private var multipart = false + private val attachmentNames = mutableSetOf() /** * Drafts cannot upload new files, but a rich message may still reference already attached * thumbnails, so the same collection runs here to keep the payload consistent. */ - private fun collectAttachments() { - if (attachmentsCollected) return - attachmentsCollected = true - val attachments = RichMessageAttachments.collect(richMessage) - attachments.forEach { (name, file) -> addParameter(name, file) } - multipart = attachments.isNotEmpty() - } + private fun collectAttachments() = + RichMessageAttachments.refresh(richMessage, super.getParameters(), attachmentNames) - override fun isMultipart(): Boolean { - collectAttachments() - return multipart - } + override fun isMultipart(): Boolean = collectAttachments() override fun getParameters(): MutableMap { collectAttachments() diff --git a/library/src/main/java/com/pengrad/telegrambot/utility/richmessages/RichMessageAttachments.kt b/library/src/main/java/com/pengrad/telegrambot/utility/richmessages/RichMessageAttachments.kt index 711d92f6b..9ab94c4a5 100644 --- a/library/src/main/java/com/pengrad/telegrambot/utility/richmessages/RichMessageAttachments.kt +++ b/library/src/main/java/com/pengrad/telegrambot/utility/richmessages/RichMessageAttachments.kt @@ -10,6 +10,28 @@ import com.pengrad.telegrambot.model.request.richmessages.inputrichblock.* */ object RichMessageAttachments { + /** + * Refreshes the attachment parameters of a request, dropping the ones left over from a + * previous scan, and returns whether the request has to be sent as multipart. Rescanning on + * every call keeps a request correct when its rich message is mutated after being built, or + * when the same request is sent more than once. + * + * [collectedNames] is the request's own bookkeeping of what the previous scan added. + */ + @JvmStatic + fun refresh( + richMessage: InputRichMessage?, + parameters: MutableMap, + collectedNames: MutableSet + ): Boolean { + val attachments = collect(richMessage) + collectedNames.forEach { if (!attachments.containsKey(it)) parameters.remove(it) } + collectedNames.clear() + parameters.putAll(attachments) + collectedNames.addAll(attachments.keys) + return attachments.isNotEmpty() + } + @JvmStatic fun collect(richMessage: InputRichMessage?): Map { if (richMessage == null) return emptyMap() diff --git a/library/src/test/java/com/pengrad/telegrambot/RichMessageRequestTest.kt b/library/src/test/java/com/pengrad/telegrambot/RichMessageRequestTest.kt index 623624a85..27b4b9718 100644 --- a/library/src/test/java/com/pengrad/telegrambot/RichMessageRequestTest.kt +++ b/library/src/test/java/com/pengrad/telegrambot/RichMessageRequestTest.kt @@ -178,6 +178,35 @@ class RichMessageRequestTest { assertTrue(request.parameters.containsKey(photo.inputFileId)) } + @Test + fun `uploads are collected even after the request was already inspected`() { + val photo = InputMediaPhoto(byteArrayOf(1, 2, 3)) + val richMessage = InputRichMessage() + val request = SendRichMessage(1L, richMessage) + + assertFalse(request.isMultipart) + richMessage.blocks(InputRichBlockPhoto(photo)) + + assertTrue(request.isMultipart) + assertTrue(request.parameters.containsKey(photo.inputFileId)) + } + + @Test + fun `parts of a replaced block are dropped`() { + val first = InputMediaPhoto(byteArrayOf(1, 2, 3)) + val second = InputMediaPhoto(byteArrayOf(4, 5, 6)) + val richMessage = InputRichMessage().blocks(InputRichBlockPhoto(first)) + val request = SendRichMessage(1L, richMessage) + + assertTrue(request.parameters.containsKey(first.inputFileId)) + + richMessage.blocks(InputRichBlockPhoto(second)) + + assertTrue(request.isMultipart) + assertTrue(request.parameters.containsKey(second.inputFileId)) + assertFalse(request.parameters.containsKey(first.inputFileId)) + } + @Test fun `drafts collect uploads too`() { val photo = InputMediaPhoto(byteArrayOf(1, 2, 3)) From 62fe1c1880d202d0d5726766b76362e085e2cb40 Mon Sep 17 00:00:00 2001 From: Fernando Werneck Date: Thu, 6 Aug 2026 22:34:34 -0300 Subject: [PATCH 3/4] Reject file uploads in rich message drafts sendRichMessageDraft cannot upload new files. Collecting attachments for it made the request multipart and sent attach:// parts that Telegram rejects. There is no legitimate case to collect either: every InputMedia.addAttachment caller takes a File or a byte array, so an already uploaded thumbnail never shows up there. The draft now stays non-multipart and fails fast when its media would need an upload, instead of building a request that cannot succeed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012tbomWzUs3YpXUn61Rwg17 --- .../richmessages/SendRichMessageDraft.kt | 21 ++++++++----------- .../telegrambot/RichMessageRequestTest.kt | 20 +++++++++++++----- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/library/src/main/java/com/pengrad/telegrambot/request/richmessages/SendRichMessageDraft.kt b/library/src/main/java/com/pengrad/telegrambot/request/richmessages/SendRichMessageDraft.kt index 61388e446..cc7f0a0a6 100644 --- a/library/src/main/java/com/pengrad/telegrambot/request/richmessages/SendRichMessageDraft.kt +++ b/library/src/main/java/com/pengrad/telegrambot/request/richmessages/SendRichMessageDraft.kt @@ -22,19 +22,16 @@ class SendRichMessageDraft( fun messageThreadId(messageThreadId: Long) = applySelf { this.messageThreadId = messageThreadId } - private val attachmentNames = mutableSetOf() - /** - * Drafts cannot upload new files, but a rich message may still reference already attached - * thumbnails, so the same collection runs here to keep the payload consistent. + * The draft endpoint cannot upload new files, so the request is never multipart. Media that + * would need an upload — anything built from a [java.io.File] or a byte array, including + * thumbnails and covers — is rejected here instead of being sent as an `attach://` reference + * that Telegram would reject. */ - private fun collectAttachments() = - RichMessageAttachments.refresh(richMessage, super.getParameters(), attachmentNames) - - override fun isMultipart(): Boolean = collectAttachments() - - override fun getParameters(): MutableMap { - collectAttachments() - return super.getParameters() + override fun isMultipart(): Boolean { + require(RichMessageAttachments.collect(richMessage).isEmpty()) { + "sendRichMessageDraft cannot upload new files; reference media by file_id or URL" + } + return false } } diff --git a/library/src/test/java/com/pengrad/telegrambot/RichMessageRequestTest.kt b/library/src/test/java/com/pengrad/telegrambot/RichMessageRequestTest.kt index 27b4b9718..037cc55c7 100644 --- a/library/src/test/java/com/pengrad/telegrambot/RichMessageRequestTest.kt +++ b/library/src/test/java/com/pengrad/telegrambot/RichMessageRequestTest.kt @@ -208,14 +208,24 @@ class RichMessageRequestTest { } @Test - fun `drafts collect uploads too`() { - val photo = InputMediaPhoto(byteArrayOf(1, 2, 3)) - val request = SendRichMessageDraft(1L, 7, InputRichMessage().blocks(InputRichBlockPhoto(photo))) + fun `drafts referencing existing files are plain requests`() { + val request = SendRichMessageDraft( + 1L, + 7, + InputRichMessage().blocks(InputRichBlockPhoto(InputMediaPhoto("file_id"))) + ) assertEquals(1L, request.parameters["chat_id"]) assertEquals(7, request.parameters["draft_id"]) - assertTrue(request.isMultipart) - assertTrue(request.parameters.containsKey(photo.inputFileId)) + assertFalse(request.isMultipart) + } + + @Test(expected = IllegalArgumentException::class) + fun `drafts reject uploads because the endpoint cannot accept them`() { + val photo = InputMediaPhoto(byteArrayOf(1, 2, 3)) + val request = SendRichMessageDraft(1L, 7, InputRichMessage().blocks(InputRichBlockPhoto(photo))) + + request.isMultipart } @Test From 720f9417117d98d9b34f138994fbc8e90bda0178 Mon Sep 17 00:00:00 2001 From: Fernando Werneck Date: Thu, 6 Aug 2026 22:42:32 -0300 Subject: [PATCH 4/4] Reject uploads when editing an inline message The API states, for the rich_message parameter of editMessageText, that "direct upload of new files isn't supported when an inline message is edited". Collecting attachments unconditionally made those edits multipart, producing a request Telegram rejects. EditMessageText now tracks whether it targets an inline message and fails fast on media that would need an upload, as sendRichMessageDraft already does. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012tbomWzUs3YpXUn61Rwg17 --- .../telegrambot/request/EditMessageText.java | 16 ++++++++++++++++ .../telegrambot/RichMessageRequestTest.kt | 18 ++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/library/src/main/java/com/pengrad/telegrambot/request/EditMessageText.java b/library/src/main/java/com/pengrad/telegrambot/request/EditMessageText.java index 07928f0ed..45b1e687b 100644 --- a/library/src/main/java/com/pengrad/telegrambot/request/EditMessageText.java +++ b/library/src/main/java/com/pengrad/telegrambot/request/EditMessageText.java @@ -20,26 +20,31 @@ public class EditMessageText extends BaseRequest { private InputRichMessage richMessage; + private final boolean inlineMessage; private final Set attachmentNames = new LinkedHashSet<>(); public EditMessageText(Object chatId, int messageId, String text) { super(SendResponse.class); + this.inlineMessage = false; add("chat_id", chatId).add("message_id", messageId).add("text", text); } public EditMessageText(String inlineMessageId, String text) { super(BaseResponse.class); + this.inlineMessage = true; add("inline_message_id", inlineMessageId).add("text", text); } public EditMessageText(Object chatId, int messageId, InputRichMessage richMessage) { super(SendResponse.class); + this.inlineMessage = false; this.richMessage = richMessage; add("chat_id", chatId).add("message_id", messageId).add("rich_message", richMessage); } public EditMessageText(String inlineMessageId, InputRichMessage richMessage) { super(BaseResponse.class); + this.inlineMessage = true; this.richMessage = richMessage; add("inline_message_id", inlineMessageId).add("rich_message", richMessage); } @@ -72,8 +77,19 @@ public EditMessageText richMessage(InputRichMessage richMessage) { /** * Collected on send rather than on construction, so that a rich message populated after * the request was built is still uploaded. + * + *

Editing an inline message cannot upload new files, so media that would need an upload + * is rejected instead of being sent as an {@code attach://} reference Telegram would reject. */ private boolean collectAttachments() { + if (inlineMessage) { + if (!RichMessageAttachments.collect(richMessage).isEmpty()) { + throw new IllegalArgumentException( + "editMessageText cannot upload new files when editing an inline message; " + + "reference media by file_id or URL"); + } + return false; + } return RichMessageAttachments.refresh(richMessage, super.getParameters(), attachmentNames); } diff --git a/library/src/test/java/com/pengrad/telegrambot/RichMessageRequestTest.kt b/library/src/test/java/com/pengrad/telegrambot/RichMessageRequestTest.kt index 037cc55c7..1e9105448 100644 --- a/library/src/test/java/com/pengrad/telegrambot/RichMessageRequestTest.kt +++ b/library/src/test/java/com/pengrad/telegrambot/RichMessageRequestTest.kt @@ -237,6 +237,24 @@ class RichMessageRequestTest { assertTrue(request.parameters.containsKey(photo.inputFileId)) } + @Test(expected = IllegalArgumentException::class) + fun `editing an inline message rejects uploads because the endpoint cannot accept them`() { + val photo = InputMediaPhoto(byteArrayOf(1, 2, 3)) + val request = EditMessageText("inline_id", InputRichMessage().blocks(InputRichBlockPhoto(photo))) + + request.isMultipart + } + + @Test + fun `editing an inline message with existing files is a plain request`() { + val request = EditMessageText( + "inline_id", + InputRichMessage().blocks(InputRichBlockPhoto(InputMediaPhoto("file_id"))) + ) + + assertFalse(request.isMultipart) + } + @Test fun `editMessageText without a rich message stays a plain request`() { val request = EditMessageText(1L, 2, "text")