Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions docs/superpowers/specs/2026-08-06-send-rich-message-design.md
Original file line number Diff line number Diff line change
@@ -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<InputRichBlock>?` and `media: Array<InputRichMessageMedia>?` 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.
Original file line number Diff line number Diff line change
@@ -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<InputMediaVoiceNote> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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<InputRichBlock>?,
@get:JvmName("html") var html: String?,
@get:JvmName("markdown") var markdown: String?,
@get:JvmName("media") var media: Array<InputRichMessageMedia>?,
@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 }
Expand All @@ -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)"
}
Original file line number Diff line number Diff line change
@@ -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<*>
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.pengrad.telegrambot.model.request.richmessages.inputrichblock

interface InputRichBlock {
val type: String
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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<InputRichBlock>,
@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)"
}
Original file line number Diff line number Diff line change
@@ -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<InputRichBlock>,
@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)"
}
Original file line number Diff line number Diff line change
@@ -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<InputRichBlock>,
@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)"
}
Original file line number Diff line number Diff line change
@@ -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()"
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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<InputRichBlockListItem>
) : 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()})"
}
Loading