1use hex;
2use md5::{Digest, Md5};
3use serde::{Deserialize, Serialize};
4use serde_repr::{Deserialize_repr, Serialize_repr};
5use std::time::SystemTime;
6
7#[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#[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#[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#[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#[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 #[serde(skip_serializing_if = "Option::is_none")]
77 pub full_url: Option<String>,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct TextItem {
83 #[serde(skip_serializing_if = "Option::is_none")]
84 pub text: Option<String>,
85}
86
87#[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#[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#[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#[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#[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#[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#[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#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
244pub struct WechatContext {
245 pub account_key: String,
247 pub user_id: String,
249 pub context_token: String,
251 pub observed_at_unix_ms: i64,
253 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 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 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#[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 pub context: Option<WechatContext>,
305}
306
307impl IncomingMessage {
308 pub fn context_token(&self) -> &str {
318 &self.context_token
319 }
320
321 pub fn from_wire_for_account(wire: &WireMessage, account_key: &str) -> Option<Self> {
331 Self::from_wire_inner(wire, account_key)
332 }
333
334 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#[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#[derive(Debug, Clone)]
549pub struct DownloadedMedia {
550 pub data: Vec<u8>,
551 pub media_type: String,
553 pub file_name: Option<String>,
554 pub format: Option<String>,
555}
556
557#[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#[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}