diff --git a/services/frontend/shared-types/src/lib.rs b/services/frontend/shared-types/src/lib.rs index 1ca4889..475e67a 100644 --- a/services/frontend/shared-types/src/lib.rs +++ b/services/frontend/shared-types/src/lib.rs @@ -5,3 +5,77 @@ pub mod media; pub mod dashboard; pub mod recording; pub mod ui_state; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_embed_media_string() { + // Simulate what Discord sends: thumbnail as a plain URL string + let json = r#"{"title":"Test","thumbnail":"https://cdn.example.com/image.gif"}"#; + let embed: message::EmbedInfo = serde_json::from_str(json).unwrap(); + assert_eq!(embed.title, Some("Test".into())); + let thumb = embed.thumbnail.unwrap(); + assert_eq!(thumb.url, "https://cdn.example.com/image.gif"); + assert_eq!(thumb.width, None); + assert_eq!(thumb.height, None); + } + + #[test] + fn test_embed_media_object() { + // Standard embed media object + let json = r#"{"thumbnail":{"url":"https://cdn.example.com/img.png","width":128,"height":128}}"#; + let embed: message::EmbedInfo = serde_json::from_str(json).unwrap(); + let thumb = embed.thumbnail.unwrap(); + assert_eq!(thumb.url, "https://cdn.example.com/img.png"); + assert_eq!(thumb.width, Some(128)); + assert_eq!(thumb.height, Some(128)); + } + + #[test] + fn test_embed_media_null() { + let json = r#"{"title":"No Media Here"}"#; + let embed: message::EmbedInfo = serde_json::from_str(json).unwrap(); + assert_eq!(embed.thumbnail, None); + assert_eq!(embed.image, None); + } + + #[test] + fn test_embed_media_both_strings() { + // Discord might send both image and thumbnail as strings + let json = r#"{"image":"https://cdn.example.com/banner.gif","thumbnail":"https://cdn.example.com/thumb.gif"}"#; + let embed: message::EmbedInfo = serde_json::from_str(json).unwrap(); + assert_eq!(embed.image.as_ref().unwrap().url, "https://cdn.example.com/banner.gif"); + assert_eq!(embed.thumbnail.as_ref().unwrap().url, "https://cdn.example.com/thumb.gif"); + } + + #[test] + fn test_full_message_metadata_with_string_thumbnail() { + // Full realistic metadata with string thumbnail (the actual bug) + let json = r#"{ + "stickers": [], + "embeds": [{ + "title": "Meisho Doto Tm Opera O", + "description": null, + "url": "https://klipy.com/gifs/test", + "color": null, + "image": null, + "thumbnail": "https://static.klipy.com/ii/test.webp", + "author": null, + "footer": null, + "fields": [] + }] + }"#; + let meta: message::MessageMetadata = serde_json::from_str(json).unwrap(); + let embeds = meta.embeds.unwrap(); + assert_eq!(embeds.len(), 1); + let embed = &embeds[0]; + assert_eq!(embed.title.as_deref(), Some("Meisho Doto Tm Opera O")); + assert!(embed.image.is_none()); + let thumb = embed.thumbnail.as_ref().unwrap(); + assert_eq!(thumb.url, "https://static.klipy.com/ii/test.webp"); + assert_eq!(thumb.width, None); + assert_eq!(thumb.height, None); + } +} diff --git a/services/frontend/shared-types/src/message.rs b/services/frontend/shared-types/src/message.rs index 3838993..bef0804 100644 --- a/services/frontend/shared-types/src/message.rs +++ b/services/frontend/shared-types/src/message.rs @@ -64,17 +64,42 @@ pub struct AttachmentRef { pub content_type: Option, } +/// Deserialize `EmbedMedia` from either: +/// - `null` → `None` +/// - a JSON string → `Some(EmbedMedia { url: , width: None, height: None })` +/// - a JSON object → standard struct deserialization +fn deser_embed_media<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + let v = Option::::deserialize(d)?; + match v { + None => Ok(None), + Some(serde_json::Value::String(s)) => Ok(Some(EmbedMedia { + url: s, + width: None, + height: None, + })), + Some(obj) => serde_json::from_value(obj).map(Some).map_err(de::Error::custom), + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct EmbedInfo { #[serde(skip_serializing_if = "Option::is_none")] pub title: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + default, + deserialize_with = "deser_embed_media", + skip_serializing_if = "Option::is_none" + )] pub image: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + default, + deserialize_with = "deser_embed_media", + skip_serializing_if = "Option::is_none" + )] pub thumbnail: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, PartialEq)] pub struct EmbedMedia { pub url: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -83,6 +108,28 @@ pub struct EmbedMedia { pub height: Option, } +impl<'de> Deserialize<'de> for EmbedMedia { + fn deserialize>(d: D) -> Result { + // When EmbedMedia appears as a struct member (inside the object branch + // of deser_embed_media), serde calls this directly. We delegate to a + // derived deserializer on the struct fields. + #[derive(serde::Deserialize)] + struct Inner { + url: String, + #[serde(default)] + width: Option, + #[serde(default)] + height: Option, + } + let inner = Inner::deserialize(d)?; + Ok(EmbedMedia { + url: inner.url, + width: inner.width, + height: inner.height, + }) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ChannelRef { pub channel_id: String,