Skip to main content

openlark_communication/common/
chain.rs

1//! openlark-communication 链式调用入口(meta 风格,偏"模块入口")
2//!
3//! 说明:
4//! - 本文件放在 `common/` 下,避免被 strict API 校验脚本计入"额外实现文件"。
5//! - communication 模块 API 规模较大(IM/Contact/Moments 等)。为避免为大量 API 手写方法,
6//!   这里先提供"bizTag 级入口 + Config 透传"。
7//! - 具体 API 调用仍使用各 `*RequestBuilder/*Request` 的 `new(config)` / `execute(...)`。
8
9use 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/// 消息接收者 helper。
53///
54/// 统一接收者 ID 与 `receive_id_type`,避免文本/卡片等消息 helper
55/// 每次都分别传两个参数。
56#[cfg(feature = "im")]
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct MessageRecipient {
59    /// 接收者 ID。
60    pub receive_id: String,
61    /// 接收者 ID 类型。
62    pub receive_id_type: ReceiveIdType,
63}
64
65#[cfg(feature = "im")]
66impl MessageRecipient {
67    /// 使用指定接收者 ID 和类型创建 helper。
68    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    /// 创建使用 `open_id` 的接收者。
76    pub fn open_id(receive_id: impl Into<String>) -> Self {
77        Self::new(receive_id, ReceiveIdType::OpenId)
78    }
79
80    /// 创建使用 `user_id` 的接收者。
81    pub fn user_id(receive_id: impl Into<String>) -> Self {
82        Self::new(receive_id, ReceiveIdType::UserId)
83    }
84
85    /// 创建使用邮箱的接收者。
86    pub fn email(receive_id: impl Into<String>) -> Self {
87        Self::new(receive_id, ReceiveIdType::Email)
88    }
89
90    /// 创建使用群 ID 的接收者。
91    pub fn chat_id(receive_id: impl Into<String>) -> Self {
92        Self::new(receive_id, ReceiveIdType::ChatId)
93    }
94}
95
96/// 富文本(post)消息 helper。
97///
98/// 当前只覆盖最常见的“标题 + 单段文本”结构,不试图抽象完整消息 DSL。
99#[cfg(feature = "im")]
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct PostMessage {
102    /// 语言区域。
103    pub locale: String,
104    /// 标题。
105    pub title: String,
106    /// 文本内容。
107    pub text: String,
108}
109
110#[cfg(feature = "im")]
111impl PostMessage {
112    /// 创建中文富文本消息 helper。
113    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/// 回复上下文 helper。
144///
145/// 统一回复目标消息与是否在话题内回复的上下文参数。
146#[cfg(feature = "im")]
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct ReplyTarget {
149    /// 目标消息 ID。
150    pub message_id: String,
151    /// 是否以话题形式回复。
152    pub reply_in_thread: bool,
153}
154
155#[cfg(feature = "im")]
156impl ReplyTarget {
157    /// 创建普通回复目标。
158    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    /// 创建话题内回复目标。
166    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/// 图片上传 helper。
175///
176/// 默认按消息图片上传,调用方只需传入二进制内容;
177/// 如有需要,可额外补充文件名或切换为头像上传。
178#[cfg(feature = "im")]
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct MediaImageUpload {
181    /// 图片用途类型。
182    pub image_type: ImageType,
183    /// 文件名。
184    pub file_name: Option<String>,
185    /// 图片二进制内容。
186    pub bytes: Vec<u8>,
187}
188
189#[cfg(feature = "im")]
190impl MediaImageUpload {
191    /// 使用图片字节创建上传 helper。
192    pub fn new(bytes: Vec<u8>) -> Self {
193        Self {
194            image_type: ImageType::Message,
195            file_name: None,
196            bytes,
197        }
198    }
199
200    /// 将图片类型切换为头像。
201    pub fn avatar(mut self) -> Self {
202        self.image_type = ImageType::Avatar;
203        self
204    }
205
206    /// 设置上传文件名。
207    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/// 文件上传 helper。
214///
215/// 默认根据文件名后缀推断 `file_type`,必要时允许调用方显式覆盖。
216#[cfg(feature = "im")]
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct MediaFileUpload {
219    /// 文件名。
220    pub file_name: String,
221    /// 文件类型。
222    pub file_type: String,
223    /// 时长,通常用于音视频。
224    pub duration: Option<i32>,
225    /// 文件二进制内容。
226    pub bytes: Vec<u8>,
227}
228
229#[cfg(feature = "im")]
230impl MediaFileUpload {
231    /// 使用文件名和内容创建上传 helper。
232    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    /// 显式覆盖文件类型。
244    pub fn file_type(mut self, file_type: impl Into<String>) -> Self {
245        self.file_type = file_type.into();
246        self
247    }
248
249    /// 设置媒体时长。
250    pub fn duration(mut self, duration: i32) -> Self {
251        self.duration = Some(duration);
252        self
253    }
254}
255
256/// 用户查找 helper。
257///
258/// 统一承载搜索用户接口里最常用的字段,避免调用方直接处理原始 JSON。
259#[cfg(feature = "contact")]
260#[derive(Debug, Clone, serde::Deserialize, PartialEq, Eq)]
261pub struct UserLookupItem {
262    /// 用户名称。
263    pub name: String,
264    /// 用户 open_id。
265    pub open_id: String,
266    /// 用户 user_id。
267    #[serde(default)]
268    pub user_id: Option<String>,
269    /// 用户所在部门 ID 列表。
270    #[serde(default)]
271    pub department_ids: Vec<String>,
272}
273
274/// 群查找 helper。
275///
276/// 统一承载群搜索结果里最常用的字段。
277#[cfg(feature = "im")]
278#[derive(Debug, Clone, serde::Deserialize, PartialEq, Eq)]
279pub struct ChatLookupItem {
280    /// 群 ID。
281    pub chat_id: String,
282    /// 群名称。
283    pub name: String,
284    /// 群描述。
285    #[serde(default)]
286    pub description: Option<String>,
287    /// 群主 ID。
288    #[serde(default)]
289    pub owner_id: Option<String>,
290    /// 群主 ID 类型。
291    #[serde(default)]
292    pub owner_id_type: Option<String>,
293    /// 是否外部群。
294    #[serde(default)]
295    pub external: bool,
296    /// 租户 key。
297    #[serde(default)]
298    pub tenant_key: Option<String>,
299    /// 群状态。
300    #[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/// Communication 链式入口:`communication.im` / `communication.contact` / `communication.moments`
327#[derive(Debug, Clone)]
328pub struct CommunicationClient {
329    config: Arc<Config>,
330
331    #[cfg(feature = "im")]
332    /// IM helper 入口。
333    pub im: ImClient,
334
335    #[cfg(feature = "contact")]
336    /// 通讯录 helper 入口。
337    pub contact: ContactClient,
338
339    #[cfg(feature = "moments")]
340    /// Moments helper 入口。
341    pub moments: MomentsClient,
342}
343
344impl CommunicationClient {
345    /// 使用配置创建 communication 链式入口。
346    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    /// 返回底层共享配置。
360    pub fn config(&self) -> &Config {
361        &self.config
362    }
363}
364
365#[cfg(feature = "im")]
366/// IM 链式 helper 入口。
367#[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    /// 返回底层共享配置。
379    pub fn config(&self) -> &Config {
380        &self.config
381    }
382
383    /// 发送文本消息 helper。
384    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    /// 发送富文本(post)消息 helper。
396    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    /// 回复文本消息 helper。
408    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    /// 回复富文本(post)消息 helper。
420    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    /// 转发话题 helper。
432    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    /// 上传消息图片 helper。
446    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    /// 上传消息文件 helper。
459    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    /// 发送图片消息 helper。
470    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    /// 发送文件消息 helper。
490    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    /// 搜索可见群聊并自动处理分页。
510    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    /// 按群名称唯一查找单个群聊。
542    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    /// 通过 chat_id 获取群详情。
548    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/// 通讯录链式 helper 入口。
736#[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    /// 返回底层共享配置。
748    pub fn config(&self) -> &Config {
749        &self.config
750    }
751
752    /// 搜索用户并自动处理分页。
753    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    /// 按用户名唯一查找单个用户。
784    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    /// 通过 open_id 获取用户详情。
790    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/// Moments 链式 helper 入口。
802#[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    /// 返回底层共享配置。
814    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}