Skip to main content

wechat_ilink/
types.rs

1use hex;
2use md5::{Digest, Md5};
3use serde::{Deserialize, Serialize};
4use serde_repr::{Deserialize_repr, Serialize_repr};
5use std::time::SystemTime;
6
7/// Message sender type.
8/// Uses serde_repr for integer (de)serialization: JSON `1` ↔ `MessageType::User`.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize_repr, Deserialize_repr)]
10#[repr(i32)]
11pub enum MessageType {
12    None = 0,
13    User = 1,
14    Bot = 2,
15}
16
17impl Default for MessageType {
18    fn default() -> Self {
19        Self::None
20    }
21}
22
23/// Message delivery state.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize_repr, Deserialize_repr)]
25#[repr(i32)]
26pub enum MessageState {
27    New = 0,
28    Generating = 1,
29    Finish = 2,
30}
31
32impl Default for MessageState {
33    fn default() -> Self {
34        Self::New
35    }
36}
37
38/// Content type of a message item.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize_repr, Deserialize_repr)]
40#[repr(i32)]
41pub enum MessageItemType {
42    None = 0,
43    Text = 1,
44    Image = 2,
45    Voice = 3,
46    File = 4,
47    Video = 5,
48}
49
50impl Default for MessageItemType {
51    fn default() -> Self {
52        Self::None
53    }
54}
55
56/// Media type for upload requests.
57#[derive(Debug, Clone, Copy)]
58#[repr(i32)]
59pub enum MediaType {
60    Image = 1,
61    Video = 2,
62    File = 3,
63    Voice = 4,
64}
65
66/// CDN media reference.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct CDNMedia {
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub encrypt_query_param: Option<String>,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub aes_key: Option<String>,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub encrypt_type: Option<i32>,
75    /// Complete download URL returned by server; when set, use directly.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub full_url: Option<String>,
78}
79
80/// Text content.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct TextItem {
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub text: Option<String>,
85}
86
87/// Image content.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct ImageItem {
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub media: Option<CDNMedia>,
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub thumb_media: Option<CDNMedia>,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub aeskey: Option<String>,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub url: Option<String>,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub mid_size: Option<i64>,
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub thumb_size: Option<i64>,
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub thumb_width: Option<i32>,
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub thumb_height: Option<i32>,
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub hd_size: Option<i64>,
108}
109
110/// Voice content.
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct VoiceItem {
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub media: Option<CDNMedia>,
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub encode_type: Option<i32>,
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub bits_per_sample: Option<i32>,
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub sample_rate: Option<i32>,
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub text: Option<String>,
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub playtime: Option<i32>,
125}
126
127/// File content.
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct FileItem {
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub media: Option<CDNMedia>,
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub file_name: Option<String>,
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub md5: Option<String>,
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub len: Option<String>,
138}
139
140/// Video content.
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct VideoItem {
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub media: Option<CDNMedia>,
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub video_size: Option<i64>,
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub play_length: Option<i32>,
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub video_md5: Option<String>,
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub thumb_media: Option<CDNMedia>,
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub thumb_size: Option<i64>,
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub thumb_height: Option<i32>,
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub thumb_width: Option<i32>,
159}
160
161/// Referenced/quoted message.
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct RefMessage {
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub msg_id: Option<String>,
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub message_id: Option<i64>,
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub client_id: Option<String>,
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub title: Option<String>,
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub message_item: Option<Box<WireMessageItem>>,
174}
175
176/// A single content item in a message.
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct WireMessageItem {
179    #[serde(rename = "type")]
180    #[serde(default)]
181    pub item_type: MessageItemType,
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub create_time_ms: Option<i64>,
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub update_time_ms: Option<i64>,
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub is_completed: Option<bool>,
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub msg_id: Option<String>,
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub text_item: Option<TextItem>,
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub image_item: Option<ImageItem>,
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub voice_item: Option<VoiceItem>,
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub file_item: Option<FileItem>,
198    #[serde(skip_serializing_if = "Option::is_none")]
199    pub video_item: Option<VideoItem>,
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub ref_msg: Option<RefMessage>,
202}
203
204/// Raw wire message from the iLink API.
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct WireMessage {
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub seq: Option<i64>,
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub message_id: Option<i64>,
211    #[serde(default)]
212    pub from_user_id: String,
213    #[serde(default)]
214    pub to_user_id: String,
215    #[serde(default)]
216    pub client_id: String,
217    #[serde(default)]
218    pub create_time_ms: i64,
219    #[serde(skip_serializing_if = "Option::is_none")]
220    pub update_time_ms: Option<i64>,
221    #[serde(skip_serializing_if = "Option::is_none")]
222    pub delete_time_ms: Option<i64>,
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub session_id: Option<String>,
225    #[serde(skip_serializing_if = "Option::is_none")]
226    pub group_id: Option<String>,
227    #[serde(default)]
228    pub message_type: MessageType,
229    #[serde(default)]
230    pub message_state: MessageState,
231    #[serde(default)]
232    pub context_token: String,
233    #[serde(default)]
234    pub item_list: Vec<WireMessageItem>,
235}
236
237/// Context information observed from an incoming message.
238///
239/// Carries the context token, user identity, and the account key that
240/// was used to observe it. The [`Debug`] impl redacts the context token
241/// to avoid leaking it into logs. Serialization includes the raw token;
242/// do not log serialized contexts.
243#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
244pub struct WechatContext {
245    /// Account key used when this context was observed.
246    pub account_key: String,
247    /// WeChat user ID from whom this context was observed.
248    pub user_id: String,
249    /// Opaque context token for reply/send operations.
250    pub context_token: String,
251    /// Unix timestamp in milliseconds when the context was observed.
252    pub observed_at_unix_ms: i64,
253    /// Message ID of the message that produced this context, if any.
254    pub source_message_id: Option<String>,
255}
256
257impl std::fmt::Debug for WechatContext {
258    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259        f.debug_struct("WechatContext")
260            .field("account_key", &self.account_key)
261            .field("user_id", &self.user_id)
262            .field("context_token", &"<redacted>")
263            .field("observed_at_unix_ms", &self.observed_at_unix_ms)
264            .field("source_message_id", &self.source_message_id)
265            .finish()
266    }
267}
268
269impl WechatContext {
270    /// Returns a stable fingerprint of the context token.
271    ///
272    /// Two contexts with the same token always produce the same fingerprint,
273    /// regardless of other fields. Useful for detecting token rotation without
274    /// comparing the raw token.
275    pub fn token_fingerprint(&self) -> String {
276        let digest = Md5::digest(self.context_token.as_bytes());
277        format!("md5:{}", hex::encode(digest))
278    }
279
280    /// Returns the observation time as a [`SystemTime`].
281    pub fn observed_at(&self) -> SystemTime {
282        std::time::UNIX_EPOCH + std::time::Duration::from_millis(self.observed_at_unix_ms as u64)
283    }
284}
285
286/// Parsed incoming message — user-friendly.
287#[derive(Debug, Clone)]
288pub struct IncomingMessage {
289    pub message_id: Option<String>,
290    pub wire_message_id: Option<i64>,
291    pub client_id: String,
292    pub user_id: String,
293    pub text: String,
294    pub content_type: ContentType,
295    pub timestamp: SystemTime,
296    pub images: Vec<ImageContent>,
297    pub voices: Vec<VoiceContent>,
298    pub files: Vec<FileContent>,
299    pub videos: Vec<VideoContent>,
300    pub quoted: Option<QuotedMessage>,
301    pub raw: WireMessage,
302    pub(crate) context_token: String,
303    /// Context observed from this message, including the account key.
304    pub context: Option<WechatContext>,
305}
306
307impl IncomingMessage {
308    /// Opaque reply token bound to this message.
309    ///
310    /// Prefer the full [`context`](Self::context) with
311    /// [`WechatIlinkClient::send_text_with_context`](crate::WechatIlinkClient::send_text_with_context)
312    /// or [`WechatIlinkClient::reply`](crate::WechatIlinkClient::reply), which uses it automatically.
313    /// Use this getter only when constructing low-level protocol payloads with
314    /// [`protocol::build_text_message`](crate::protocol::build_text_message) /
315    /// [`protocol::build_media_message`](crate::protocol::build_media_message)
316    /// for [`ILinkClient::send_message`](crate::protocol::ILinkClient::send_message).
317    pub fn context_token(&self) -> &str {
318        &self.context_token
319    }
320
321    /// Parse a raw [`WireMessage`] into a user-friendly [`IncomingMessage`],
322    /// associating the context with the given account key.
323    ///
324    /// Prefer this over [`from_wire`](Self::from_wire) when the account key
325    /// is known, so that [`context`](Self::context) carries correct
326    /// [`WechatContext::account_key`] information.
327    ///
328    /// Returns `None` if the wire message is not a user-originated message
329    /// (e.g. it was sent by the bot itself).
330    pub fn from_wire_for_account(wire: &WireMessage, account_key: &str) -> Option<Self> {
331        Self::from_wire_inner(wire, account_key)
332    }
333
334    /// Parse a raw [`WireMessage`] into a user-friendly [`IncomingMessage`].
335    ///
336    /// Uses a fallback account key derived from the wire message. When the
337    /// account key is known, prefer [`from_wire_for_account`](Self::from_wire_for_account).
338    ///
339    /// Returns `None` if the wire message is not a user-originated message
340    /// (e.g. it was sent by the bot itself).
341    ///
342    /// This is the stable entry point for consumers who drive
343    /// [`ILinkClient::get_updates`](crate::protocol::ILinkClient::get_updates)
344    /// themselves instead of using [`WechatIlinkClient`](crate::WechatIlinkClient)'s
345    /// dispatcher.
346    pub fn from_wire(wire: &WireMessage) -> Option<Self> {
347        let fallback_account_key = if wire.to_user_id.is_empty() {
348            wire.from_user_id.as_str()
349        } else {
350            wire.to_user_id.as_str()
351        };
352        Self::from_wire_inner(wire, fallback_account_key)
353    }
354
355    fn from_wire_inner(wire: &WireMessage, account_key: &str) -> Option<Self> {
356        if wire.message_type != MessageType::User {
357            return None;
358        }
359
360        let source_message_id = current_message_id(wire);
361
362        let mut msg = IncomingMessage {
363            message_id: source_message_id.clone(),
364            wire_message_id: wire.message_id,
365            client_id: wire.client_id.clone(),
366            user_id: wire.from_user_id.clone(),
367            text: extract_text(&wire.item_list),
368            content_type: detect_type(&wire.item_list),
369            timestamp: std::time::UNIX_EPOCH
370                + std::time::Duration::from_millis(wire.create_time_ms as u64),
371            images: Vec::new(),
372            voices: Vec::new(),
373            files: Vec::new(),
374            videos: Vec::new(),
375            quoted: None,
376            raw: wire.clone(),
377            context_token: wire.context_token.clone(),
378            context: (!wire.context_token.is_empty()).then(|| WechatContext {
379                account_key: account_key.to_string(),
380                user_id: wire.from_user_id.clone(),
381                context_token: wire.context_token.clone(),
382                observed_at_unix_ms: wire.create_time_ms,
383                source_message_id: source_message_id.clone(),
384            }),
385        };
386
387        for item in &wire.item_list {
388            if let Some(ref img) = item.image_item {
389                msg.images.push(ImageContent {
390                    media: img.media.clone(),
391                    thumb_media: img.thumb_media.clone(),
392                    aes_key: img.aeskey.clone(),
393                    url: img.url.clone(),
394                    width: img.thumb_width,
395                    height: img.thumb_height,
396                });
397            }
398            if let Some(ref voice) = item.voice_item {
399                msg.voices.push(VoiceContent {
400                    media: voice.media.clone(),
401                    text: voice.text.clone(),
402                    duration_ms: voice.playtime,
403                    encode_type: voice.encode_type,
404                });
405            }
406            if let Some(ref file) = item.file_item {
407                msg.files.push(FileContent {
408                    media: file.media.clone(),
409                    file_name: file.file_name.clone(),
410                    md5: file.md5.clone(),
411                    size: file.len.as_ref().and_then(|s| s.parse().ok()),
412                });
413            }
414            if let Some(ref video) = item.video_item {
415                msg.videos.push(VideoContent {
416                    media: video.media.clone(),
417                    thumb_media: video.thumb_media.clone(),
418                    duration_ms: video.play_length,
419                });
420            }
421            if let Some(ref refm) = item.ref_msg {
422                msg.quoted = Some(QuotedMessage {
423                    message_id: quoted_message_id(refm),
424                    title: refm.title.clone(),
425                    text: refm
426                        .message_item
427                        .as_ref()
428                        .and_then(|i| i.text_item.as_ref())
429                        .and_then(|t| t.text.clone()),
430                });
431            }
432        }
433
434        Some(msg)
435    }
436}
437
438fn quoted_message_id(refm: &RefMessage) -> Option<String> {
439    refm.message_item
440        .as_ref()
441        .and_then(|item| item.msg_id.clone())
442        .or_else(|| refm.msg_id.clone())
443        .or_else(|| refm.message_id.map(|id| id.to_string()))
444        .or_else(|| refm.client_id.clone())
445}
446
447fn current_message_id(wire: &WireMessage) -> Option<String> {
448    wire.item_list
449        .iter()
450        .find_map(|item| item.msg_id.clone())
451        .or_else(|| wire.message_id.map(|id| id.to_string()))
452        .or_else(|| (!wire.client_id.is_empty()).then(|| wire.client_id.clone()))
453}
454
455fn detect_type(items: &[WireMessageItem]) -> ContentType {
456    items
457        .first()
458        .map_or(ContentType::Text, |item| match item.item_type {
459            MessageItemType::Image => ContentType::Image,
460            MessageItemType::Voice => ContentType::Voice,
461            MessageItemType::File => ContentType::File,
462            MessageItemType::Video => ContentType::Video,
463            _ => ContentType::Text,
464        })
465}
466
467fn extract_text(items: &[WireMessageItem]) -> String {
468    items
469        .iter()
470        .filter_map(|item| match item.item_type {
471            MessageItemType::None => None,
472            MessageItemType::Text => item.text_item.as_ref().and_then(|t| t.text.clone()),
473            MessageItemType::Image => Some(
474                item.image_item
475                    .as_ref()
476                    .and_then(|i| i.url.clone())
477                    .unwrap_or_else(|| "[image]".to_string()),
478            ),
479            MessageItemType::Voice => Some(
480                item.voice_item
481                    .as_ref()
482                    .and_then(|v| v.text.clone())
483                    .unwrap_or_else(|| "[voice]".to_string()),
484            ),
485            MessageItemType::File => Some(
486                item.file_item
487                    .as_ref()
488                    .and_then(|f| f.file_name.clone())
489                    .unwrap_or_else(|| "[file]".to_string()),
490            ),
491            MessageItemType::Video => Some("[video]".to_string()),
492        })
493        .collect::<Vec<_>>()
494        .join("\n")
495}
496
497/// Content type of an incoming message.
498#[derive(Debug, Clone, Copy, PartialEq, Eq)]
499pub enum ContentType {
500    Text,
501    Image,
502    Voice,
503    File,
504    Video,
505}
506
507#[derive(Debug, Clone)]
508pub struct ImageContent {
509    pub media: Option<CDNMedia>,
510    pub thumb_media: Option<CDNMedia>,
511    pub aes_key: Option<String>,
512    pub url: Option<String>,
513    pub width: Option<i32>,
514    pub height: Option<i32>,
515}
516
517#[derive(Debug, Clone)]
518pub struct VoiceContent {
519    pub media: Option<CDNMedia>,
520    pub text: Option<String>,
521    pub duration_ms: Option<i32>,
522    pub encode_type: Option<i32>,
523}
524
525#[derive(Debug, Clone)]
526pub struct FileContent {
527    pub media: Option<CDNMedia>,
528    pub file_name: Option<String>,
529    pub md5: Option<String>,
530    pub size: Option<i64>,
531}
532
533#[derive(Debug, Clone)]
534pub struct VideoContent {
535    pub media: Option<CDNMedia>,
536    pub thumb_media: Option<CDNMedia>,
537    pub duration_ms: Option<i32>,
538}
539
540#[derive(Debug, Clone)]
541pub struct QuotedMessage {
542    pub message_id: Option<String>,
543    pub title: Option<String>,
544    pub text: Option<String>,
545}
546
547/// Result of downloading media from a message.
548#[derive(Debug, Clone)]
549pub struct DownloadedMedia {
550    pub data: Vec<u8>,
551    /// "image", "file", "video", "voice"
552    pub media_type: String,
553    pub file_name: Option<String>,
554    pub format: Option<String>,
555}
556
557/// Result of uploading media to CDN.
558#[derive(Debug, Clone)]
559pub struct UploadResult {
560    pub media: CDNMedia,
561    pub aes_key: [u8; 16],
562    pub encrypted_file_size: usize,
563}
564
565/// Stored login credentials.
566#[derive(Debug, Clone, Serialize, Deserialize)]
567pub struct Credentials {
568    pub token: String,
569    #[serde(rename = "baseUrl")]
570    pub base_url: String,
571    #[serde(rename = "accountId")]
572    pub account_id: String,
573    #[serde(rename = "userId")]
574    pub user_id: String,
575    #[serde(skip_serializing_if = "Option::is_none")]
576    pub saved_at: Option<String>,
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582
583    macro_rules! wire_message {
584        ($($field:tt)*) => {
585            WireMessage {
586                seq: None,
587                message_id: None,
588                update_time_ms: None,
589                delete_time_ms: None,
590                session_id: None,
591                group_id: None,
592                $($field)*
593            }
594        };
595    }
596
597    macro_rules! wire_item {
598        ($($field:tt)*) => {
599            WireMessageItem {
600                create_time_ms: None,
601                update_time_ms: None,
602                is_completed: None,
603                msg_id: None,
604                $($field)*
605            }
606        };
607    }
608
609    #[test]
610    fn message_type_values() {
611        assert_eq!(MessageType::None as i32, 0);
612        assert_eq!(MessageType::User as i32, 1);
613        assert_eq!(MessageType::Bot as i32, 2);
614    }
615
616    #[test]
617    fn message_state_values() {
618        assert_eq!(MessageState::New as i32, 0);
619        assert_eq!(MessageState::Generating as i32, 1);
620        assert_eq!(MessageState::Finish as i32, 2);
621    }
622
623    #[test]
624    fn message_item_type_values() {
625        assert_eq!(MessageItemType::None as i32, 0);
626        assert_eq!(MessageItemType::Text as i32, 1);
627        assert_eq!(MessageItemType::Image as i32, 2);
628        assert_eq!(MessageItemType::Voice as i32, 3);
629        assert_eq!(MessageItemType::File as i32, 4);
630        assert_eq!(MessageItemType::Video as i32, 5);
631    }
632
633    #[test]
634    fn wire_message_json_round_trip() {
635        let wire = wire_message! {
636            from_user_id: "user1".to_string(),
637            to_user_id: "bot1".to_string(),
638            client_id: "c1".to_string(),
639            create_time_ms: 1700000000000,
640            message_type: MessageType::User,
641            message_state: MessageState::Finish,
642            context_token: "ctx".to_string(),
643            item_list: vec![wire_item! {
644                item_type: MessageItemType::Text,
645                text_item: Some(TextItem {
646                    text: Some("hello".to_string()),
647                }),
648                image_item: None,
649                voice_item: None,
650                file_item: None,
651                video_item: None,
652                ref_msg: None,
653            }],
654        };
655        let json = serde_json::to_string(&wire).unwrap();
656        let decoded: WireMessage = serde_json::from_str(&json).unwrap();
657        assert_eq!(decoded.from_user_id, "user1");
658        assert_eq!(decoded.message_type, MessageType::User);
659        assert_eq!(decoded.item_list.len(), 1);
660        assert_eq!(
661            decoded.item_list[0]
662                .text_item
663                .as_ref()
664                .unwrap()
665                .text
666                .as_deref(),
667            Some("hello")
668        );
669    }
670
671    #[test]
672    fn wire_message_accepts_missing_optional_typescript_fields() {
673        let wire: WireMessage = serde_json::from_value(serde_json::json!({}))
674            .expect("wire message with optional fields");
675
676        assert_eq!(wire.from_user_id, "");
677        assert_eq!(wire.to_user_id, "");
678        assert_eq!(wire.client_id, "");
679        assert_eq!(wire.create_time_ms, 0);
680        assert_eq!(wire.message_type, MessageType::None);
681        assert_eq!(wire.message_state, MessageState::New);
682        assert_eq!(wire.context_token, "");
683        assert!(wire.item_list.is_empty());
684    }
685
686    #[test]
687    fn wire_message_item_defaults_missing_type_to_none() {
688        let item: WireMessageItem =
689            serde_json::from_value(serde_json::json!({ "text_item": { "text": "hello" } }))
690                .expect("message item with optional type");
691
692        assert_eq!(item.item_type, MessageItemType::None);
693        assert_eq!(
694            item.text_item
695                .as_ref()
696                .and_then(|text| text.text.as_deref()),
697            Some("hello")
698        );
699    }
700
701    #[test]
702    fn credentials_json_camel_case() {
703        let creds = Credentials {
704            token: "tok".to_string(),
705            base_url: "https://api.example.com".to_string(),
706            account_id: "acc1".to_string(),
707            user_id: "uid1".to_string(),
708            saved_at: Some("2024-01-01T00:00:00Z".to_string()),
709        };
710        let json = serde_json::to_string(&creds).unwrap();
711        assert!(json.contains("\"baseUrl\""), "expected camelCase baseUrl");
712        assert!(
713            json.contains("\"accountId\""),
714            "expected camelCase accountId"
715        );
716        assert!(json.contains("\"userId\""), "expected camelCase userId");
717
718        let decoded: Credentials = serde_json::from_str(&json).unwrap();
719        assert_eq!(decoded.token, "tok");
720        assert_eq!(decoded.base_url, "https://api.example.com");
721    }
722
723    #[test]
724    fn credentials_omits_none_saved_at() {
725        let creds = Credentials {
726            token: "tok".to_string(),
727            base_url: "https://api.example.com".to_string(),
728            account_id: "acc1".to_string(),
729            user_id: "uid1".to_string(),
730            saved_at: None,
731        };
732        let json = serde_json::to_string(&creds).unwrap();
733        assert!(!json.contains("saved_at"), "should omit None saved_at");
734    }
735
736    #[test]
737    fn cdn_media_json() {
738        let media = CDNMedia {
739            encrypt_query_param: Some("param=abc".to_string()),
740            aes_key: Some("key123".to_string()),
741            encrypt_type: Some(1),
742            full_url: None,
743        };
744        let json = serde_json::to_string(&media).unwrap();
745        let decoded: CDNMedia = serde_json::from_str(&json).unwrap();
746        assert_eq!(decoded.encrypt_query_param.as_deref(), Some("param=abc"));
747        assert_eq!(decoded.aes_key.as_deref(), Some("key123"));
748        assert_eq!(decoded.encrypt_type, Some(1));
749    }
750
751    #[test]
752    fn wire_message_with_image() {
753        let wire = wire_message! {
754            from_user_id: "user1".to_string(),
755            to_user_id: "bot1".to_string(),
756            client_id: "c1".to_string(),
757            create_time_ms: 1700000000000,
758            message_type: MessageType::User,
759            message_state: MessageState::Finish,
760            context_token: "ctx".to_string(),
761            item_list: vec![wire_item! {
762                item_type: MessageItemType::Image,
763                text_item: None,
764                image_item: Some(ImageItem {
765                    media: None,
766                    thumb_media: None,
767                    aeskey: Some("key".to_string()),
768                    url: Some("http://img.jpg".to_string()),
769                    mid_size: Some(1024),
770                    thumb_size: None,
771                    thumb_width: Some(100),
772                    thumb_height: Some(200),
773                    hd_size: None,
774                }),
775                voice_item: None,
776                file_item: None,
777                video_item: None,
778                ref_msg: None,
779            }],
780        };
781        let json = serde_json::to_string(&wire).unwrap();
782        let decoded: WireMessage = serde_json::from_str(&json).unwrap();
783        let img = decoded.item_list[0].image_item.as_ref().unwrap();
784        assert_eq!(img.url, Some("http://img.jpg".to_string()));
785        assert_eq!(img.thumb_width, Some(100));
786    }
787
788    #[test]
789    fn content_type_equality() {
790        assert_eq!(ContentType::Text, ContentType::Text);
791        assert_ne!(ContentType::Text, ContentType::Image);
792    }
793
794    #[test]
795    fn detect_type_text() {
796        let items = vec![wire_item! {
797            item_type: MessageItemType::Text,
798            text_item: Some(TextItem {
799                text: Some("hi".to_string()),
800            }),
801            image_item: None,
802            voice_item: None,
803            file_item: None,
804            video_item: None,
805            ref_msg: None,
806        }];
807        assert_eq!(detect_type(&items), ContentType::Text);
808    }
809
810    #[test]
811    fn detect_type_image() {
812        let items = vec![wire_item! {
813            item_type: MessageItemType::Image,
814            text_item: None,
815            image_item: Some(ImageItem {
816                media: None,
817                thumb_media: None,
818                aeskey: None,
819                url: Some("http://img".to_string()),
820                mid_size: None,
821                thumb_size: None,
822                thumb_width: None,
823                thumb_height: None,
824                hd_size: None,
825            }),
826            voice_item: None,
827            file_item: None,
828            video_item: None,
829            ref_msg: None,
830        }];
831        assert_eq!(detect_type(&items), ContentType::Image);
832    }
833
834    #[test]
835    fn detect_type_empty() {
836        assert_eq!(detect_type(&[]), ContentType::Text);
837    }
838
839    #[test]
840    fn extract_text_single() {
841        let items = vec![wire_item! {
842            item_type: MessageItemType::Text,
843            text_item: Some(TextItem {
844                text: Some("hello world".to_string()),
845            }),
846            image_item: None,
847            voice_item: None,
848            file_item: None,
849            video_item: None,
850            ref_msg: None,
851        }];
852        assert_eq!(extract_text(&items), "hello world");
853    }
854
855    #[test]
856    fn extract_text_multi() {
857        let items = vec![
858            wire_item! {
859                item_type: MessageItemType::Text,
860                text_item: Some(TextItem {
861                    text: Some("line1".to_string()),
862                }),
863                image_item: None,
864                voice_item: None,
865                file_item: None,
866                video_item: None,
867                ref_msg: None,
868            },
869            wire_item! {
870                item_type: MessageItemType::Text,
871                text_item: Some(TextItem {
872                    text: Some("line2".to_string()),
873                }),
874                image_item: None,
875                voice_item: None,
876                file_item: None,
877                video_item: None,
878                ref_msg: None,
879            },
880        ];
881        assert_eq!(extract_text(&items), "line1\nline2");
882    }
883
884    #[test]
885    fn extract_text_image_url() {
886        let items = vec![wire_item! {
887            item_type: MessageItemType::Image,
888            text_item: None,
889            image_item: Some(ImageItem {
890                media: None,
891                thumb_media: None,
892                aeskey: None,
893                url: Some("http://img.jpg".to_string()),
894                mid_size: None,
895                thumb_size: None,
896                thumb_width: None,
897                thumb_height: None,
898                hd_size: None,
899            }),
900            voice_item: None,
901            file_item: None,
902            video_item: None,
903            ref_msg: None,
904        }];
905        assert_eq!(extract_text(&items), "http://img.jpg");
906    }
907
908    #[test]
909    fn extract_text_image_placeholder() {
910        let items = vec![wire_item! {
911            item_type: MessageItemType::Image,
912            text_item: None,
913            image_item: Some(ImageItem {
914                media: None,
915                thumb_media: None,
916                aeskey: None,
917                url: None,
918                mid_size: None,
919                thumb_size: None,
920                thumb_width: None,
921                thumb_height: None,
922                hd_size: None,
923            }),
924            voice_item: None,
925            file_item: None,
926            video_item: None,
927            ref_msg: None,
928        }];
929        assert_eq!(extract_text(&items), "[image]");
930    }
931
932    #[test]
933    fn extract_text_voice_with_text() {
934        let items = vec![wire_item! {
935            item_type: MessageItemType::Voice,
936            text_item: None,
937            image_item: None,
938            voice_item: Some(VoiceItem {
939                media: None,
940                encode_type: None,
941                bits_per_sample: None,
942                sample_rate: None,
943                text: Some("hello".to_string()),
944                playtime: None,
945            }),
946            file_item: None,
947            video_item: None,
948            ref_msg: None,
949        }];
950        assert_eq!(extract_text(&items), "hello");
951    }
952
953    #[test]
954    fn extract_text_file_name() {
955        let items = vec![wire_item! {
956            item_type: MessageItemType::File,
957            text_item: None,
958            image_item: None,
959            voice_item: None,
960            file_item: Some(FileItem {
961                media: None,
962                file_name: Some("doc.pdf".to_string()),
963                md5: None,
964                len: None,
965            }),
966            video_item: None,
967            ref_msg: None,
968        }];
969        assert_eq!(extract_text(&items), "doc.pdf");
970    }
971
972    #[test]
973    fn extract_text_video() {
974        let items = vec![wire_item! {
975            item_type: MessageItemType::Video,
976            text_item: None,
977            image_item: None,
978            voice_item: None,
979            file_item: None,
980            video_item: Some(VideoItem {
981                media: None,
982                video_size: None,
983                play_length: None,
984                video_md5: None,
985                thumb_media: None,
986                thumb_size: None,
987                thumb_height: None,
988                thumb_width: None,
989            }),
990            ref_msg: None,
991        }];
992        assert_eq!(extract_text(&items), "[video]");
993    }
994
995    #[test]
996    fn from_wire_user_text() {
997        let wire = wire_message! {
998            from_user_id: "user123".to_string(),
999            to_user_id: "bot456".to_string(),
1000            client_id: "c1".to_string(),
1001            create_time_ms: 1700000000000,
1002            message_type: MessageType::User,
1003            message_state: MessageState::Finish,
1004            context_token: "ctx-abc".to_string(),
1005            item_list: vec![wire_item! {
1006                item_type: MessageItemType::Text,
1007                text_item: Some(TextItem {
1008                    text: Some("hello".to_string()),
1009                }),
1010                image_item: None,
1011                voice_item: None,
1012                file_item: None,
1013                video_item: None,
1014                ref_msg: None,
1015            }],
1016        };
1017        let msg = IncomingMessage::from_wire(&wire).unwrap();
1018        assert_eq!(msg.user_id, "user123");
1019        assert_eq!(msg.text, "hello");
1020        assert_eq!(msg.content_type, ContentType::Text);
1021        assert_eq!(msg.context_token(), "ctx-abc");
1022    }
1023
1024    #[test]
1025    fn from_wire_skips_bot() {
1026        let wire = wire_message! {
1027            from_user_id: "bot456".to_string(),
1028            to_user_id: "user123".to_string(),
1029            client_id: "c1".to_string(),
1030            create_time_ms: 1700000000000,
1031            message_type: MessageType::Bot,
1032            message_state: MessageState::Finish,
1033            context_token: "ctx".to_string(),
1034            item_list: vec![wire_item! {
1035                item_type: MessageItemType::Text,
1036                text_item: Some(TextItem {
1037                    text: Some("reply".to_string()),
1038                }),
1039                image_item: None,
1040                voice_item: None,
1041                file_item: None,
1042                video_item: None,
1043                ref_msg: None,
1044            }],
1045        };
1046        assert!(IncomingMessage::from_wire(&wire).is_none());
1047    }
1048
1049    #[test]
1050    fn from_wire_with_image() {
1051        let wire = wire_message! {
1052            from_user_id: "user123".to_string(),
1053            to_user_id: "bot456".to_string(),
1054            client_id: "c1".to_string(),
1055            create_time_ms: 1700000000000,
1056            message_type: MessageType::User,
1057            message_state: MessageState::Finish,
1058            context_token: "ctx".to_string(),
1059            item_list: vec![wire_item! {
1060                item_type: MessageItemType::Image,
1061                text_item: None,
1062                image_item: Some(ImageItem {
1063                    media: None,
1064                    thumb_media: None,
1065                    aeskey: Some("key".to_string()),
1066                    url: Some("http://img.jpg".to_string()),
1067                    mid_size: None,
1068                    thumb_size: None,
1069                    thumb_width: Some(100),
1070                    thumb_height: Some(200),
1071                    hd_size: None,
1072                }),
1073                voice_item: None,
1074                file_item: None,
1075                video_item: None,
1076                ref_msg: None,
1077            }],
1078        };
1079        let msg = IncomingMessage::from_wire(&wire).unwrap();
1080        assert_eq!(msg.images.len(), 1);
1081        assert_eq!(msg.images[0].url, Some("http://img.jpg".to_string()));
1082        assert_eq!(msg.images[0].width, Some(100));
1083        assert_eq!(msg.images[0].height, Some(200));
1084    }
1085
1086    #[test]
1087    fn from_wire_with_quoted() {
1088        let wire = wire_message! {
1089            from_user_id: "user123".to_string(),
1090            to_user_id: "bot456".to_string(),
1091            client_id: "c1".to_string(),
1092            create_time_ms: 1700000000000,
1093            message_type: MessageType::User,
1094            message_state: MessageState::Finish,
1095            context_token: "ctx".to_string(),
1096            item_list: vec![wire_item! {
1097                item_type: MessageItemType::Text,
1098                text_item: Some(TextItem {
1099                    text: Some("replying".to_string()),
1100                }),
1101                image_item: None,
1102                voice_item: None,
1103                file_item: None,
1104                video_item: None,
1105                ref_msg: Some(RefMessage {
1106                    msg_id: None,
1107                    message_id: None,
1108                    client_id: None,
1109                    title: Some("Original".to_string()),
1110                    message_item: Some(Box::new(wire_item! {
1111                        item_type: MessageItemType::Text,
1112                        text_item: Some(TextItem {
1113                            text: Some("original text".to_string()),
1114                        }),
1115                        image_item: None,
1116                        voice_item: None,
1117                        file_item: None,
1118                        video_item: None,
1119                        ref_msg: None,
1120                    })),
1121                }),
1122            }],
1123        };
1124        let msg = IncomingMessage::from_wire(&wire).unwrap();
1125        let quoted = msg.quoted.as_ref().unwrap();
1126        assert_eq!(quoted.title, Some("Original".to_string()));
1127        assert_eq!(quoted.text.as_deref(), Some("original text"));
1128    }
1129}