1use std::sync::Arc;
10
11use openlark_core::config::Config;
12#[cfg(any(feature = "im", feature = "contact"))]
13use openlark_core::error::business_error;
14#[cfg(feature = "im")]
15use openlark_core::validate_required;
16#[cfg(any(feature = "im", feature = "contact"))]
17use openlark_core::{SDKResult, error::validation_error};
18
19#[cfg(feature = "contact")]
20use crate::contact::contact::v3::user::{
21 create::UserResponse,
22 get::GetUserRequest,
23 models::{DepartmentIdType, UserIdType as ContactUserIdType},
24};
25#[cfg(feature = "contact")]
26use crate::contact::contact_search::old::default::v1::user::SearchUserRequest;
27#[cfg(feature = "im")]
28use crate::im::v1::message::{
29 create::{CreateMessageBody, CreateMessageRequest},
30 models::ReceiveIdType,
31 reply::{ReplyMessageBody, ReplyMessageRequest},
32};
33#[cfg(feature = "im")]
34use crate::im::v1::thread::forward::{ForwardThreadBody, ForwardThreadRequest};
35#[cfg(feature = "im")]
36use crate::im::v1::{
37 chat::{get::GetChatRequest, search::SearchChatsRequest},
38 message::models::UserIdType as ImUserIdType,
39};
40#[cfg(feature = "im")]
41use crate::im::v1::{
42 file::{
43 create::{CreateFileBody, CreateFileRequest},
44 models::CreateFileResponse,
45 },
46 image::{
47 create::CreateImageRequest,
48 models::{CreateImageResponse, ImageType},
49 },
50};
51
52#[cfg(feature = "im")]
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct MessageRecipient {
59 pub receive_id: String,
61 pub receive_id_type: ReceiveIdType,
63}
64
65#[cfg(feature = "im")]
66impl MessageRecipient {
67 pub fn new(receive_id: impl Into<String>, receive_id_type: ReceiveIdType) -> Self {
69 Self {
70 receive_id: receive_id.into(),
71 receive_id_type,
72 }
73 }
74
75 pub fn open_id(receive_id: impl Into<String>) -> Self {
77 Self::new(receive_id, ReceiveIdType::OpenId)
78 }
79
80 pub fn user_id(receive_id: impl Into<String>) -> Self {
82 Self::new(receive_id, ReceiveIdType::UserId)
83 }
84
85 pub fn email(receive_id: impl Into<String>) -> Self {
87 Self::new(receive_id, ReceiveIdType::Email)
88 }
89
90 pub fn chat_id(receive_id: impl Into<String>) -> Self {
92 Self::new(receive_id, ReceiveIdType::ChatId)
93 }
94}
95
96#[cfg(feature = "im")]
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct PostMessage {
102 pub locale: String,
104 pub title: String,
106 pub text: String,
108}
109
110#[cfg(feature = "im")]
111impl PostMessage {
112 pub fn zh_cn(title: impl Into<String>, text: impl Into<String>) -> Self {
114 Self {
115 locale: "zh_cn".to_string(),
116 title: title.into(),
117 text: text.into(),
118 }
119 }
120
121 fn into_content(self) -> SDKResult<String> {
122 let title = self.title.trim().to_string();
123 let text = self.text.trim().to_string();
124 if title.is_empty() {
125 return Err(validation_error("title", "title 不能为空"));
126 }
127 if text.is_empty() {
128 return Err(validation_error("text", "text 不能为空"));
129 }
130
131 Ok(serde_json::json!({
132 "post": {
133 self.locale: {
134 "title": title,
135 "content": [[{"tag": "text", "text": text}]]
136 }
137 }
138 })
139 .to_string())
140 }
141}
142
143#[cfg(feature = "im")]
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct ReplyTarget {
149 pub message_id: String,
151 pub reply_in_thread: bool,
153}
154
155#[cfg(feature = "im")]
156impl ReplyTarget {
157 pub fn direct(message_id: impl Into<String>) -> Self {
159 Self {
160 message_id: message_id.into(),
161 reply_in_thread: false,
162 }
163 }
164
165 pub fn in_thread(message_id: impl Into<String>) -> Self {
167 Self {
168 message_id: message_id.into(),
169 reply_in_thread: true,
170 }
171 }
172}
173
174#[cfg(feature = "im")]
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct MediaImageUpload {
181 pub image_type: ImageType,
183 pub file_name: Option<String>,
185 pub bytes: Vec<u8>,
187}
188
189#[cfg(feature = "im")]
190impl MediaImageUpload {
191 pub fn new(bytes: Vec<u8>) -> Self {
193 Self {
194 image_type: ImageType::Message,
195 file_name: None,
196 bytes,
197 }
198 }
199
200 pub fn avatar(mut self) -> Self {
202 self.image_type = ImageType::Avatar;
203 self
204 }
205
206 pub fn file_name(mut self, file_name: impl Into<String>) -> Self {
208 self.file_name = Some(file_name.into());
209 self
210 }
211}
212
213#[cfg(feature = "im")]
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct MediaFileUpload {
219 pub file_name: String,
221 pub file_type: String,
223 pub duration: Option<i32>,
225 pub bytes: Vec<u8>,
227}
228
229#[cfg(feature = "im")]
230impl MediaFileUpload {
231 pub fn new(file_name: impl Into<String>, bytes: Vec<u8>) -> Self {
233 let file_name = file_name.into();
234 let file_type = infer_file_type(&file_name);
235 Self {
236 file_name,
237 file_type,
238 duration: None,
239 bytes,
240 }
241 }
242
243 pub fn file_type(mut self, file_type: impl Into<String>) -> Self {
245 self.file_type = file_type.into();
246 self
247 }
248
249 pub fn duration(mut self, duration: i32) -> Self {
251 self.duration = Some(duration);
252 self
253 }
254}
255
256#[cfg(feature = "contact")]
260#[derive(Debug, Clone, serde::Deserialize, PartialEq, Eq)]
261pub struct UserLookupItem {
262 pub name: String,
264 pub open_id: String,
266 #[serde(default)]
268 pub user_id: Option<String>,
269 #[serde(default)]
271 pub department_ids: Vec<String>,
272}
273
274#[cfg(feature = "im")]
278#[derive(Debug, Clone, serde::Deserialize, PartialEq, Eq)]
279pub struct ChatLookupItem {
280 pub chat_id: String,
282 pub name: String,
284 #[serde(default)]
286 pub description: Option<String>,
287 #[serde(default)]
289 pub owner_id: Option<String>,
290 #[serde(default)]
292 pub owner_id_type: Option<String>,
293 #[serde(default)]
295 pub external: bool,
296 #[serde(default)]
298 pub tenant_key: Option<String>,
299 #[serde(default)]
301 pub chat_status: Option<String>,
302}
303
304#[cfg(feature = "contact")]
305#[derive(Debug, Clone, serde::Deserialize)]
306struct UserLookupResponse {
307 #[serde(default)]
308 has_more: bool,
309 #[serde(default)]
310 page_token: Option<String>,
311 #[serde(default)]
312 users: Vec<UserLookupItem>,
313}
314
315#[cfg(feature = "im")]
316#[derive(Debug, Clone, serde::Deserialize)]
317struct ChatLookupResponse {
318 #[serde(default)]
319 has_more: bool,
320 #[serde(default)]
321 page_token: Option<String>,
322 #[serde(default)]
323 items: Vec<ChatLookupItem>,
324}
325
326#[derive(Debug, Clone)]
328pub struct CommunicationClient {
329 config: Arc<Config>,
330
331 #[cfg(feature = "im")]
332 pub im: ImClient,
334
335 #[cfg(feature = "contact")]
336 pub contact: ContactClient,
338
339 #[cfg(feature = "moments")]
340 pub moments: MomentsClient,
342}
343
344impl CommunicationClient {
345 pub fn new(config: Config) -> Self {
347 let config = Arc::new(config);
348 Self {
349 config: config.clone(),
350 #[cfg(feature = "im")]
351 im: ImClient::new(config.clone()),
352 #[cfg(feature = "contact")]
353 contact: ContactClient::new(config.clone()),
354 #[cfg(feature = "moments")]
355 moments: MomentsClient::new(config),
356 }
357 }
358
359 pub fn config(&self) -> &Config {
361 &self.config
362 }
363}
364
365#[cfg(feature = "im")]
366#[derive(Debug, Clone)]
368pub struct ImClient {
369 config: Arc<Config>,
370}
371
372#[cfg(feature = "im")]
373impl ImClient {
374 fn new(config: Arc<Config>) -> Self {
375 Self { config }
376 }
377
378 pub fn config(&self) -> &Config {
380 &self.config
381 }
382
383 pub async fn send_text(
385 &self,
386 recipient: MessageRecipient,
387 text: impl Into<String>,
388 ) -> SDKResult<serde_json::Value> {
389 let body = Self::build_text_body(recipient, text.into())?;
390 Self::create_message_request(self.config.clone(), body.receive_id_type())
391 .execute(body.into())
392 .await
393 }
394
395 pub async fn send_post(
397 &self,
398 recipient: MessageRecipient,
399 post: PostMessage,
400 ) -> SDKResult<serde_json::Value> {
401 let body = Self::build_post_body(recipient, post)?;
402 Self::create_message_request(self.config.clone(), body.receive_id_type())
403 .execute(body.into())
404 .await
405 }
406
407 pub async fn reply_text(
409 &self,
410 target: ReplyTarget,
411 text: impl Into<String>,
412 ) -> SDKResult<serde_json::Value> {
413 let body = Self::build_reply_text_body(target, text.into())?;
414 Self::create_reply_request(self.config.clone(), body.message_id())
415 .execute(body.into())
416 .await
417 }
418
419 pub async fn reply_post(
421 &self,
422 target: ReplyTarget,
423 post: PostMessage,
424 ) -> SDKResult<serde_json::Value> {
425 let body = Self::build_reply_post_body(target, post)?;
426 Self::create_reply_request(self.config.clone(), body.message_id())
427 .execute(body.into())
428 .await
429 }
430
431 pub async fn forward_thread(
433 &self,
434 thread_id: impl Into<String>,
435 recipient: MessageRecipient,
436 ) -> SDKResult<serde_json::Value> {
437 let request = ForwardThreadRequest::new(self.config.as_ref().clone())
438 .thread_id(thread_id)
439 .receive_id_type(recipient.receive_id_type);
440 request
441 .execute(ForwardThreadBody::new(recipient.receive_id))
442 .await
443 }
444
445 pub async fn upload_image(&self, upload: MediaImageUpload) -> SDKResult<CreateImageResponse> {
447 if upload.bytes.is_empty() {
448 return Err(validation_error("image", "image 不能为空"));
449 }
450 let mut request =
451 CreateImageRequest::new(self.config.as_ref().clone()).image_type(upload.image_type);
452 if let Some(file_name) = upload.file_name {
453 request = request.file_name(file_name);
454 }
455 request.execute(upload.bytes).await
456 }
457
458 pub async fn upload_file(&self, upload: MediaFileUpload) -> SDKResult<CreateFileResponse> {
460 let mut body = CreateFileBody::new(upload.file_type, upload.file_name);
461 if let Some(duration) = upload.duration {
462 body = body.duration(duration);
463 }
464 CreateFileRequest::new(self.config.as_ref().clone())
465 .execute(body, upload.bytes)
466 .await
467 }
468
469 pub async fn send_image(
471 &self,
472 recipient: MessageRecipient,
473 image_key: impl Into<String>,
474 ) -> SDKResult<serde_json::Value> {
475 let image_key = image_key.into();
476 if image_key.trim().is_empty() {
477 return Err(validation_error("image_key", "image_key 不能为空"));
478 }
479 let body = Self::build_media_body(
480 recipient,
481 "image",
482 serde_json::json!({ "image_key": image_key }).to_string(),
483 )?;
484 Self::create_message_request(self.config.clone(), body.receive_id_type())
485 .execute(body.into())
486 .await
487 }
488
489 pub async fn send_file(
491 &self,
492 recipient: MessageRecipient,
493 file_key: impl Into<String>,
494 ) -> SDKResult<serde_json::Value> {
495 let file_key = file_key.into();
496 if file_key.trim().is_empty() {
497 return Err(validation_error("file_key", "file_key 不能为空"));
498 }
499 let body = Self::build_media_body(
500 recipient,
501 "file",
502 serde_json::json!({ "file_key": file_key }).to_string(),
503 )?;
504 Self::create_message_request(self.config.clone(), body.receive_id_type())
505 .execute(body.into())
506 .await
507 }
508
509 pub async fn search_chats_all(&self, query: impl AsRef<str>) -> SDKResult<Vec<ChatLookupItem>> {
511 let query = query.as_ref().trim().to_string();
512 if query.is_empty() {
513 return Err(validation_error("query", "query 不能为空"));
514 }
515
516 let mut items = Vec::new();
517 let mut page_token: Option<String> = None;
518
519 loop {
520 let mut request = SearchChatsRequest::new(self.config.as_ref().clone())
521 .query(query.clone())
522 .user_id_type(ImUserIdType::OpenId)
523 .page_size(100);
524 if let Some(token) = &page_token {
525 request = request.page_token(token.clone());
526 }
527
528 let response: ChatLookupResponse = serde_json::from_value(request.execute().await?)
529 .map_err(|e| validation_error("chat_lookup_response", e.to_string().as_str()))?;
530 items.extend(response.items);
531
532 if !response.has_more {
533 break;
534 }
535 page_token = response.page_token;
536 }
537
538 Ok(items)
539 }
540
541 pub async fn find_chat_by_name(&self, name: &str) -> SDKResult<ChatLookupItem> {
543 let items = self.search_chats_all(name).await?;
544 find_unique_chat_by_name(&items, name)
545 }
546
547 pub async fn get_chat_info(&self, chat_id: impl Into<String>) -> SDKResult<serde_json::Value> {
549 GetChatRequest::new(self.config.as_ref().clone())
550 .chat_id(chat_id)
551 .user_id_type(ImUserIdType::OpenId)
552 .execute()
553 .await
554 }
555
556 fn create_message_request(
557 config: Arc<Config>,
558 receive_id_type: ReceiveIdType,
559 ) -> CreateMessageRequest {
560 CreateMessageRequest::new(config.as_ref().clone()).receive_id_type(receive_id_type)
561 }
562
563 fn create_reply_request(config: Arc<Config>, message_id: String) -> ReplyMessageRequest {
564 ReplyMessageRequest::new(config.as_ref().clone()).message_id(message_id)
565 }
566
567 fn build_text_body(recipient: MessageRecipient, text: String) -> SDKResult<HelperMessageBody> {
568 let text = text.trim().to_string();
569 if text.is_empty() {
570 return Err(validation_error("text", "text 不能为空"));
571 }
572
573 Ok(HelperMessageBody::new(
574 recipient,
575 "text",
576 serde_json::json!({ "text": text }).to_string(),
577 ))
578 }
579
580 fn build_post_body(
581 recipient: MessageRecipient,
582 post: PostMessage,
583 ) -> SDKResult<HelperMessageBody> {
584 Ok(HelperMessageBody::new(
585 recipient,
586 "post",
587 post.into_content()?,
588 ))
589 }
590
591 fn build_media_body(
592 recipient: MessageRecipient,
593 msg_type: &str,
594 content: String,
595 ) -> SDKResult<HelperMessageBody> {
596 validate_required!(content, "content 不能为空");
597 Ok(HelperMessageBody::new(recipient, msg_type, content))
598 }
599
600 fn build_reply_text_body(target: ReplyTarget, text: String) -> SDKResult<HelperReplyBody> {
601 let text = text.trim().to_string();
602 if text.is_empty() {
603 return Err(validation_error("text", "text 不能为空"));
604 }
605
606 Ok(HelperReplyBody::new(
607 target,
608 "text",
609 serde_json::json!({ "text": text }).to_string(),
610 ))
611 }
612
613 fn build_reply_post_body(target: ReplyTarget, post: PostMessage) -> SDKResult<HelperReplyBody> {
614 Ok(HelperReplyBody::new(target, "post", post.into_content()?))
615 }
616}
617
618#[cfg(feature = "im")]
619#[derive(Debug, Clone)]
620struct HelperMessageBody {
621 body: CreateMessageBody,
622 receive_id_type: ReceiveIdType,
623}
624
625#[cfg(feature = "im")]
626impl HelperMessageBody {
627 fn new(recipient: MessageRecipient, msg_type: &str, content: String) -> Self {
628 Self {
629 receive_id_type: recipient.receive_id_type,
630 body: CreateMessageBody {
631 receive_id: recipient.receive_id,
632 msg_type: msg_type.to_string(),
633 content,
634 uuid: None,
635 },
636 }
637 }
638
639 fn receive_id_type(&self) -> ReceiveIdType {
640 self.receive_id_type
641 }
642}
643
644#[cfg(feature = "im")]
645impl From<HelperMessageBody> for CreateMessageBody {
646 fn from(value: HelperMessageBody) -> Self {
647 value.body
648 }
649}
650
651#[cfg(feature = "im")]
652#[derive(Debug, Clone)]
653struct HelperReplyBody {
654 body: ReplyMessageBody,
655 message_id: String,
656}
657
658#[cfg(feature = "im")]
659impl HelperReplyBody {
660 fn new(target: ReplyTarget, msg_type: &str, content: String) -> Self {
661 Self {
662 message_id: target.message_id,
663 body: ReplyMessageBody {
664 content,
665 msg_type: msg_type.to_string(),
666 reply_in_thread: Some(target.reply_in_thread),
667 uuid: None,
668 },
669 }
670 }
671
672 fn message_id(&self) -> String {
673 self.message_id.clone()
674 }
675}
676
677#[cfg(feature = "im")]
678impl From<HelperReplyBody> for ReplyMessageBody {
679 fn from(value: HelperReplyBody) -> Self {
680 value.body
681 }
682}
683
684#[cfg(feature = "im")]
685fn infer_file_type(file_name: &str) -> String {
686 std::path::Path::new(file_name)
687 .extension()
688 .and_then(|ext| ext.to_str())
689 .map(|ext| ext.to_ascii_lowercase())
690 .filter(|ext| !ext.is_empty())
691 .unwrap_or_else(|| "stream".to_string())
692}
693
694#[cfg(feature = "contact")]
695fn find_unique_user_by_name(users: &[UserLookupItem], name: &str) -> SDKResult<UserLookupItem> {
696 let name = name.trim();
697 if name.is_empty() {
698 return Err(validation_error("name", "name 不能为空"));
699 }
700
701 let mut matches = users.iter().filter(|user| user.name == name).cloned();
702
703 let first = matches
704 .next()
705 .ok_or_else(|| business_error(format!("未找到用户: {name}")))?;
706 if matches.next().is_some() {
707 return Err(business_error(format!(
708 "找到多个同名用户,请缩小范围: {name}"
709 )));
710 }
711 Ok(first)
712}
713
714#[cfg(feature = "im")]
715fn find_unique_chat_by_name(chats: &[ChatLookupItem], name: &str) -> SDKResult<ChatLookupItem> {
716 let name = name.trim();
717 if name.is_empty() {
718 return Err(validation_error("name", "name 不能为空"));
719 }
720
721 let mut matches = chats.iter().filter(|chat| chat.name == name).cloned();
722
723 let first = matches
724 .next()
725 .ok_or_else(|| business_error(format!("未找到群聊: {name}")))?;
726 if matches.next().is_some() {
727 return Err(business_error(format!(
728 "找到多个同名群聊,请缩小范围: {name}"
729 )));
730 }
731 Ok(first)
732}
733
734#[cfg(feature = "contact")]
735#[derive(Debug, Clone)]
737pub struct ContactClient {
738 config: Arc<Config>,
739}
740
741#[cfg(feature = "contact")]
742impl ContactClient {
743 fn new(config: Arc<Config>) -> Self {
744 Self { config }
745 }
746
747 pub fn config(&self) -> &Config {
749 &self.config
750 }
751
752 pub async fn search_users_all(&self, query: impl AsRef<str>) -> SDKResult<Vec<UserLookupItem>> {
754 let query = query.as_ref().trim().to_string();
755 if query.is_empty() {
756 return Err(validation_error("query", "query 不能为空"));
757 }
758
759 let mut users = Vec::new();
760 let mut page_token: Option<String> = None;
761
762 loop {
763 let mut request = SearchUserRequest::new(self.config.as_ref().clone())
764 .query(query.clone())
765 .page_size(100);
766 if let Some(token) = &page_token {
767 request = request.page_token(token.clone());
768 }
769
770 let response: UserLookupResponse = serde_json::from_value(request.execute().await?)
771 .map_err(|e| validation_error("user_lookup_response", e.to_string().as_str()))?;
772 users.extend(response.users);
773
774 if !response.has_more {
775 break;
776 }
777 page_token = response.page_token;
778 }
779
780 Ok(users)
781 }
782
783 pub async fn find_user_by_name(&self, name: &str) -> SDKResult<UserLookupItem> {
785 let users = self.search_users_all(name).await?;
786 find_unique_user_by_name(&users, name)
787 }
788
789 pub async fn get_user_by_open_id(&self, open_id: impl Into<String>) -> SDKResult<UserResponse> {
791 GetUserRequest::new(self.config.as_ref().clone())
792 .user_id(open_id)
793 .user_id_type(ContactUserIdType::OpenId)
794 .department_id_type(DepartmentIdType::OpenDepartmentId)
795 .execute()
796 .await
797 }
798}
799
800#[cfg(feature = "moments")]
801#[derive(Debug, Clone)]
803pub struct MomentsClient {
804 config: Arc<Config>,
805}
806
807#[cfg(feature = "moments")]
808impl MomentsClient {
809 fn new(config: Arc<Config>) -> Self {
810 Self { config }
811 }
812
813 pub fn config(&self) -> &Config {
815 &self.config
816 }
817}
818
819#[cfg(test)]
820#[allow(unused_imports)]
821mod tests {
822 use super::*;
823
824 fn create_test_config() -> Config {
825 Config::builder()
826 .app_id("test_app")
827 .app_secret("test_secret")
828 .build()
829 }
830
831 #[test]
832 fn test_communication_client_creation() {
833 let config = create_test_config();
834 let client = CommunicationClient::new(config);
835 assert_eq!(client.config().app_id(), "test_app");
836 }
837
838 #[test]
839 fn test_communication_client_debug() {
840 let config = create_test_config();
841 let client = CommunicationClient::new(config);
842 let debug_str = format!("{client:?}");
843 assert!(debug_str.contains("CommunicationClient"));
844 }
845
846 #[test]
847 fn test_communication_client_clone() {
848 let config = create_test_config();
849 let client = CommunicationClient::new(config);
850 let cloned = client.clone();
851 assert_eq!(cloned.config().app_id(), "test_app");
852 }
853
854 #[cfg(feature = "im")]
855 #[test]
856 fn test_im_client_config() {
857 let config = create_test_config();
858 let client = CommunicationClient::new(config);
859 assert_eq!(client.im.config().app_id(), "test_app");
860 }
861
862 #[cfg(feature = "im")]
863 #[test]
864 fn test_message_recipient_constructors() {
865 assert_eq!(
866 MessageRecipient::open_id("ou_xxx"),
867 MessageRecipient::new("ou_xxx", ReceiveIdType::OpenId)
868 );
869 assert_eq!(
870 MessageRecipient::chat_id("oc_xxx"),
871 MessageRecipient::new("oc_xxx", ReceiveIdType::ChatId)
872 );
873 }
874
875 #[cfg(feature = "im")]
876 #[test]
877 fn test_post_message_serialization() {
878 let content = PostMessage::zh_cn("周报", "本周已完成 3 项任务")
879 .into_content()
880 .expect("post content should serialize");
881
882 let value: serde_json::Value =
883 serde_json::from_str(&content).expect("content should be valid json");
884 assert_eq!(value["post"]["zh_cn"]["title"], "周报");
885 assert_eq!(
886 value["post"]["zh_cn"]["content"][0][0]["text"],
887 "本周已完成 3 项任务"
888 );
889 }
890
891 #[cfg(feature = "im")]
892 #[test]
893 fn test_reply_target_constructors() {
894 assert_eq!(
895 ReplyTarget::direct("om_xxx"),
896 ReplyTarget {
897 message_id: "om_xxx".to_string(),
898 reply_in_thread: false,
899 }
900 );
901 assert_eq!(
902 ReplyTarget::in_thread("om_xxx"),
903 ReplyTarget {
904 message_id: "om_xxx".to_string(),
905 reply_in_thread: true,
906 }
907 );
908 }
909
910 #[cfg(feature = "im")]
911 #[test]
912 fn test_media_image_upload_defaults() {
913 let upload = MediaImageUpload::new(vec![1, 2, 3]).file_name("image.png");
914 assert_eq!(upload.image_type, ImageType::Message);
915 assert_eq!(upload.file_name.as_deref(), Some("image.png"));
916 assert_eq!(upload.bytes, vec![1, 2, 3]);
917 }
918
919 #[cfg(feature = "im")]
920 #[test]
921 fn test_media_file_upload_infers_type() {
922 let upload = MediaFileUpload::new("report.pdf", vec![1, 2, 3]).duration(15);
923 assert_eq!(upload.file_type, "pdf");
924 assert_eq!(upload.file_name, "report.pdf");
925 assert_eq!(upload.duration, Some(15));
926 }
927
928 #[cfg(feature = "im")]
929 #[test]
930 fn test_build_text_message_body() {
931 let body = ImClient::build_text_body(MessageRecipient::open_id("ou_xxx"), "hello".into())
932 .expect("text body should build");
933 let request_body: CreateMessageBody = body.into();
934 assert_eq!(request_body.msg_type, "text");
935 assert_eq!(request_body.receive_id, "ou_xxx");
936 assert_eq!(request_body.content, r#"{"text":"hello"}"#);
937 }
938
939 #[cfg(feature = "im")]
940 #[test]
941 fn test_build_post_message_body() {
942 let body = ImClient::build_post_body(
943 MessageRecipient::chat_id("oc_xxx"),
944 PostMessage::zh_cn("项目播报", "今天完成发布"),
945 )
946 .expect("post body should build");
947 let request_body: CreateMessageBody = body.into();
948 let value: serde_json::Value =
949 serde_json::from_str(&request_body.content).expect("content should be valid json");
950
951 assert_eq!(request_body.msg_type, "post");
952 assert_eq!(request_body.receive_id, "oc_xxx");
953 assert_eq!(value["post"]["zh_cn"]["title"], "项目播报");
954 }
955
956 #[cfg(feature = "im")]
957 #[test]
958 fn test_build_media_message_body_for_image() {
959 let body = ImClient::build_media_body(
960 MessageRecipient::open_id("ou_xxx"),
961 "image",
962 serde_json::json!({ "image_key": "img_xxx" }).to_string(),
963 )
964 .expect("image body should build");
965 let request_body: CreateMessageBody = body.into();
966 assert_eq!(request_body.msg_type, "image");
967 assert_eq!(request_body.content, r#"{"image_key":"img_xxx"}"#);
968 }
969
970 #[cfg(feature = "im")]
971 #[test]
972 fn test_build_media_message_body_for_file() {
973 let body = ImClient::build_media_body(
974 MessageRecipient::chat_id("oc_xxx"),
975 "file",
976 serde_json::json!({ "file_key": "file_xxx" }).to_string(),
977 )
978 .expect("file body should build");
979 let request_body: CreateMessageBody = body.into();
980 assert_eq!(request_body.msg_type, "file");
981 assert_eq!(request_body.receive_id, "oc_xxx");
982 assert_eq!(request_body.content, r#"{"file_key":"file_xxx"}"#);
983 }
984
985 #[cfg(feature = "im")]
986 #[test]
987 fn test_build_reply_text_message_body() {
988 let body = ImClient::build_reply_text_body(ReplyTarget::direct("om_xxx"), "收到".into())
989 .expect("reply text body should build");
990 let request_body: ReplyMessageBody = body.into();
991 assert_eq!(request_body.msg_type, "text");
992 assert_eq!(request_body.reply_in_thread, Some(false));
993 assert_eq!(request_body.content, r#"{"text":"收到"}"#);
994 }
995
996 #[cfg(feature = "im")]
997 #[test]
998 fn test_build_reply_post_message_body() {
999 let body = ImClient::build_reply_post_body(
1000 ReplyTarget::in_thread("om_xxx"),
1001 PostMessage::zh_cn("进展", "线程内同步"),
1002 )
1003 .expect("reply post body should build");
1004 let request_body: ReplyMessageBody = body.into();
1005 let value: serde_json::Value =
1006 serde_json::from_str(&request_body.content).expect("content should be valid json");
1007
1008 assert_eq!(request_body.msg_type, "post");
1009 assert_eq!(request_body.reply_in_thread, Some(true));
1010 assert_eq!(value["post"]["zh_cn"]["title"], "进展");
1011 }
1012
1013 #[cfg(feature = "im")]
1014 #[tokio::test]
1015 async fn test_send_image_rejects_empty_key() {
1016 let client = CommunicationClient::new(create_test_config());
1017 let error = client
1018 .im
1019 .send_image(MessageRecipient::open_id("ou_xxx"), "")
1020 .await
1021 .expect_err("empty image_key should fail");
1022 assert!(error.to_string().contains("image_key"));
1023 }
1024
1025 #[cfg(feature = "im")]
1026 #[tokio::test]
1027 async fn test_send_file_rejects_empty_key() {
1028 let client = CommunicationClient::new(create_test_config());
1029 let error = client
1030 .im
1031 .send_file(MessageRecipient::chat_id("oc_xxx"), "")
1032 .await
1033 .expect_err("empty file_key should fail");
1034 assert!(error.to_string().contains("file_key"));
1035 }
1036
1037 #[cfg(feature = "im")]
1038 #[tokio::test]
1039 async fn test_upload_image_rejects_empty_bytes() {
1040 let client = CommunicationClient::new(create_test_config());
1041 let error = client
1042 .im
1043 .upload_image(MediaImageUpload::new(Vec::new()))
1044 .await
1045 .expect_err("empty image bytes should fail");
1046 assert!(error.to_string().contains("image"));
1047 }
1048
1049 #[cfg(feature = "contact")]
1050 #[test]
1051 fn test_user_lookup_response_deserializes() {
1052 let response: UserLookupResponse = serde_json::from_value(serde_json::json!({
1053 "has_more": true,
1054 "page_token": "token_1",
1055 "users": [
1056 {
1057 "name": "zhangsan",
1058 "open_id": "ou_xxx",
1059 "user_id": "u_xxx",
1060 "department_ids": ["od_1"]
1061 }
1062 ]
1063 }))
1064 .expect("user lookup response should deserialize");
1065
1066 assert!(response.has_more);
1067 assert_eq!(response.page_token.as_deref(), Some("token_1"));
1068 assert_eq!(response.users[0].name, "zhangsan");
1069 assert_eq!(response.users[0].open_id, "ou_xxx");
1070 }
1071
1072 #[cfg(feature = "contact")]
1073 #[test]
1074 fn test_find_unique_user_by_name_rejects_duplicates() {
1075 let users = vec![
1076 UserLookupItem {
1077 name: "zhangsan".to_string(),
1078 open_id: "ou_1".to_string(),
1079 user_id: None,
1080 department_ids: vec![],
1081 },
1082 UserLookupItem {
1083 name: "zhangsan".to_string(),
1084 open_id: "ou_2".to_string(),
1085 user_id: None,
1086 department_ids: vec![],
1087 },
1088 ];
1089
1090 let error =
1091 find_unique_user_by_name(&users, "zhangsan").expect_err("duplicate user should fail");
1092 assert!(error.to_string().contains("多个同名用户"));
1093 }
1094
1095 #[cfg(feature = "im")]
1096 #[test]
1097 fn test_chat_lookup_response_deserializes() {
1098 let response: ChatLookupResponse = serde_json::from_value(serde_json::json!({
1099 "has_more": false,
1100 "items": [
1101 {
1102 "chat_id": "oc_xxx",
1103 "name": "项目群",
1104 "description": "研发群",
1105 "owner_id": "ou_owner",
1106 "owner_id_type": "open_id",
1107 "external": false,
1108 "tenant_key": "tenant_key",
1109 "chat_status": "normal"
1110 }
1111 ]
1112 }))
1113 .expect("chat lookup response should deserialize");
1114
1115 assert!(!response.has_more);
1116 assert_eq!(response.items[0].chat_id, "oc_xxx");
1117 assert_eq!(response.items[0].name, "项目群");
1118 }
1119
1120 #[cfg(feature = "im")]
1121 #[test]
1122 fn test_find_unique_chat_by_name_rejects_duplicates() {
1123 let chats = vec![
1124 ChatLookupItem {
1125 chat_id: "oc_1".to_string(),
1126 name: "项目群".to_string(),
1127 description: None,
1128 owner_id: None,
1129 owner_id_type: None,
1130 external: false,
1131 tenant_key: None,
1132 chat_status: None,
1133 },
1134 ChatLookupItem {
1135 chat_id: "oc_2".to_string(),
1136 name: "项目群".to_string(),
1137 description: None,
1138 owner_id: None,
1139 owner_id_type: None,
1140 external: false,
1141 tenant_key: None,
1142 chat_status: None,
1143 },
1144 ];
1145
1146 let error =
1147 find_unique_chat_by_name(&chats, "项目群").expect_err("duplicate chat should fail");
1148 assert!(error.to_string().contains("多个同名群聊"));
1149 }
1150
1151 #[cfg(feature = "contact")]
1152 #[test]
1153 fn test_contact_client_config() {
1154 let config = create_test_config();
1155 let client = CommunicationClient::new(config);
1156 assert_eq!(client.contact.config().app_id(), "test_app");
1157 }
1158}