Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## Unreleased

* fix: serialize the `type` of the media embedded in rich message blocks

## 0.52.0 (2026-08-28)

* feat: Bot API 10.3 - [#336](https://github.com/ayrat555/frankenstein/pull/336)
Expand Down
172 changes: 172 additions & 0 deletions src/rich_message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -630,38 +630,123 @@ pub struct InputRichBlockButtons {
pub align: Option<String>,
}

/// Serializers adding the `type` discriminator that the Bot API requires on the media
/// objects embedded in rich blocks.
///
/// [`InputRichMessageMediaKind`] gets the discriminator from its enum tag, but blocks like
/// [`InputRichBlockVideo`] hold the concrete media struct, so it has to be added here.
mod tagged_media {
use serde::{Serialize, Serializer};

use crate::input_media::{
InputMediaAnimation, InputMediaAudio, InputMediaDocument, InputMediaPhoto, InputMediaVideo,
InputMediaVoiceNote,
};

#[derive(Serialize)]
struct Tagged<'a, T> {
#[serde(rename = "type")]
type_field: &'static str,
#[serde(flatten)]
media: &'a T,
}

pub fn animation<S: Serializer>(
media: &InputMediaAnimation,
serializer: S,
) -> Result<S::Ok, S::Error> {
Tagged {
type_field: "animation",
media,
}
.serialize(serializer)
}

pub fn audio<S: Serializer>(media: &InputMediaAudio, serializer: S) -> Result<S::Ok, S::Error> {
Tagged {
type_field: "audio",
media,
}
.serialize(serializer)
}

pub fn document<S: Serializer>(
media: &InputMediaDocument,
serializer: S,
) -> Result<S::Ok, S::Error> {
Tagged {
type_field: "document",
media,
}
.serialize(serializer)
}

pub fn photo<S: Serializer>(media: &InputMediaPhoto, serializer: S) -> Result<S::Ok, S::Error> {
Tagged {
type_field: "photo",
media,
}
.serialize(serializer)
}

pub fn video<S: Serializer>(media: &InputMediaVideo, serializer: S) -> Result<S::Ok, S::Error> {
Tagged {
type_field: "video",
media,
}
.serialize(serializer)
}

pub fn voice_note<S: Serializer>(
media: &InputMediaVoiceNote,
serializer: S,
) -> Result<S::Ok, S::Error> {
Tagged {
type_field: "voice_note",
media,
}
.serialize(serializer)
}
}

#[apply(apistruct!)]
pub struct InputRichBlockAnimation {
#[serde(serialize_with = "tagged_media::animation")]
pub animation: InputMediaAnimation,
pub caption: Option<RichBlockCaption>,
}

#[apply(apistruct!)]
pub struct InputRichBlockAudio {
#[serde(serialize_with = "tagged_media::audio")]
pub audio: InputMediaAudio,
pub caption: Option<RichBlockCaption>,
}

#[apply(apistruct!)]
pub struct InputRichBlockDocument {
#[serde(serialize_with = "tagged_media::document")]
pub document: InputMediaDocument,
pub caption: Option<RichBlockCaption>,
}

#[apply(apistruct!)]
pub struct InputRichBlockPhoto {
#[serde(serialize_with = "tagged_media::photo")]
pub photo: InputMediaPhoto,
pub caption: Option<RichBlockCaption>,
}

#[apply(apistruct!)]
pub struct InputRichBlockVideo {
#[serde(serialize_with = "tagged_media::video")]
pub video: InputMediaVideo,
pub caption: Option<RichBlockCaption>,
}

#[apply(apistruct!)]
pub struct InputRichBlockVoiceNote {
#[serde(serialize_with = "tagged_media::voice_note")]
pub voice_note: InputMediaVoiceNote,
pub caption: Option<RichBlockCaption>,
}
Expand Down Expand Up @@ -895,3 +980,90 @@ mod input_file_replacement {
}
}
}

#[cfg(test)]
mod tests {
use super::{
InputRichBlock, InputRichBlockAnimation, InputRichBlockAudio, InputRichBlockDocument,
InputRichBlockPhoto, InputRichBlockVideo, InputRichBlockVoiceNote, InputRichMessage,
RichBlockCaption, RichText,
};
use crate::input_media::{
InputMediaAnimation, InputMediaAudio, InputMediaDocument, InputMediaPhoto,
InputMediaVideo, InputMediaVoiceNote,
};
use crate::test_json::assert_json_str;

#[test]
fn block_media_is_serialized_with_its_type() {
let message = InputRichMessage::builder()
.blocks(vec![
InputRichBlock::Animation(
InputRichBlockAnimation::builder()
.animation(
InputMediaAnimation::builder()
.media("https://example.com/animation.gif".to_string())
.build(),
)
.build(),
),
InputRichBlock::Audio(
InputRichBlockAudio::builder()
.audio(
InputMediaAudio::builder()
.media("https://example.com/audio.mp3".to_string())
.build(),
)
.build(),
),
InputRichBlock::Document(
InputRichBlockDocument::builder()
.document(
InputMediaDocument::builder()
.media("https://example.com/document.pdf".to_string())
.build(),
)
.build(),
),
InputRichBlock::Photo(
InputRichBlockPhoto::builder()
.photo(
InputMediaPhoto::builder()
.media("https://example.com/photo.jpg".to_string())
.build(),
)
.caption(
RichBlockCaption::builder()
.text(RichText::from("A photo"))
.build(),
)
.build(),
),
InputRichBlock::Video(
InputRichBlockVideo::builder()
.video(
InputMediaVideo::builder()
.media("https://example.com/video.mp4".to_string())
.supports_streaming(true)
.build(),
)
.build(),
),
InputRichBlock::VoiceNote(
InputRichBlockVoiceNote::builder()
.voice_note(
InputMediaVoiceNote::builder()
.media("https://example.com/voice.ogg".to_string())
.build(),
)
.build(),
),
])
.build();

assert_json_str(
&message,
r#"{"blocks":[{"type":"animation","animation":{"type":"animation","media":"https://example.com/animation.gif"}},{"type":"audio","audio":{"type":"audio","media":"https://example.com/audio.mp3"}},{"type":"document","document":{"type":"document","media":"https://example.com/document.pdf"}},{"type":"photo","photo":{"type":"photo","media":"https://example.com/photo.jpg"},"caption":{"text":"A photo"}},{"type":"video","video":{"type":"video","media":"https://example.com/video.mp4","supports_streaming":true}},{"type":"voice_note","voice_note":{"type":"voice_note","media":"https://example.com/voice.ogg"}}]}"#,
);
}
}
Loading