fix(fe): handle Discord embed thumbnail/image as plain URL string in serde deserialization
The Discord embed metadata sometimes returns thumbnail/image fields as
plain URL strings (e.g., "thumbnail":"https://...") instead of the
expected object format ({"url":"...","width":...,"height":...}).
Since serde deserializes the entire PageResult<MessageRecord> in one call,
even one message with a string-format embed field caused the entire response
to fail, making the message section appear empty despite the API returning
valid data.
Added a custom deserializer deser_embed_media that accepts null, a string
(interpreted as URL), or an object (standard struct deserialization).
Applied to both image and thumbnail fields in EmbedInfo with #[serde(default)]
so missing fields don't error. Includes 5 unit tests covering all formats.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,17 +64,42 @@ pub struct AttachmentRef {
|
||||
pub content_type: Option<String>,
|
||||
}
|
||||
|
||||
/// Deserialize `EmbedMedia` from either:
|
||||
/// - `null` → `None`
|
||||
/// - a JSON string → `Some(EmbedMedia { url: <string>, width: None, height: None })`
|
||||
/// - a JSON object → standard struct deserialization
|
||||
fn deser_embed_media<'de, D: Deserializer<'de>>(d: D) -> Result<Option<EmbedMedia>, D::Error> {
|
||||
let v = Option::<serde_json::Value>::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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "deser_embed_media",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub image: Option<EmbedMedia>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "deser_embed_media",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub thumbnail: Option<EmbedMedia>,
|
||||
}
|
||||
|
||||
#[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<u32>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for EmbedMedia {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
// 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<u32>,
|
||||
#[serde(default)]
|
||||
height: Option<u32>,
|
||||
}
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user