rust_wechat_work/message/
robot.rs

1use crate::error::WorkError;
2use reqwest::{multipart, Client};
3use rust_wechat_codegen::ServerResponse;
4use rust_wechat_core::client::ClientTrait;
5use rust_wechat_core::utils::{base64, md5};
6use rust_wechat_core::WechatError;
7use serde::ser::{SerializeSeq, SerializeStruct};
8use serde::{Deserialize, Serialize, Serializer};
9use serde_repr::{Deserialize_repr, Serialize_repr};
10
11const SEND_MESSAGE_URL: &str = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send";
12const UPLOAD_MEDIA_URL: &str = "https://qyapi.weixin.qq.com/cgi-bin/webhook/upload_media";
13
14/// 表示机器人消息的通用响应
15#[derive(Debug, Serialize, Deserialize, ServerResponse)]
16#[sr(flatten)]
17pub struct SendRobotMessage {
18    #[serde(rename = "type")]
19    pub media_type: Option<String>, // For media upload
20    pub media_id: Option<String>,   // For media upload
21    pub created_at: Option<String>, // For media upload
22}
23
24#[derive(Debug)]
25pub enum MentionTarget {
26    All,
27    List(Vec<String>),
28}
29
30impl Serialize for MentionTarget {
31    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
32    where
33        S: Serializer,
34    {
35        match self {
36            MentionTarget::All => {
37                let mut seq = serializer.serialize_seq(Some(1))?;
38                seq.serialize_element(vec!["@all".to_string()].as_slice())?;
39                seq.end()
40            }
41            MentionTarget::List(v) => {
42                let mut seq = serializer.serialize_seq(Some(v.len()))?;
43                seq.serialize_element(v)?;
44                seq.end()
45            }
46        }
47    }
48}
49
50/// 文本消息
51#[derive(Debug, Serialize)]
52pub struct TextMessage {
53    pub content: String,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub mentioned_list: Option<MentionTarget>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub mentioned_mobile_list: Option<MentionTarget>,
58}
59
60/// Markdown消息
61#[derive(Debug, Serialize)]
62pub struct MarkdownMessage {
63    pub content: String,
64}
65
66/// 图片消息
67#[derive(Debug, Serialize)]
68pub struct ImageMessage {
69    base64: String,
70    md5: String,
71}
72
73impl ImageMessage {
74    pub fn new(path: &str) -> rust_wechat_core::Result<Self> {
75        let base64 = base64(path)?;
76        let md5 = md5(&base64)?;
77        Ok(ImageMessage { base64, md5 })
78    }
79}
80
81/// 图文消息文章
82#[derive(Debug, Serialize)]
83pub struct NewsArticle {
84    pub title: String,
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub description: Option<String>,
87    pub url: String,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub picurl: Option<String>,
90}
91
92/// 图文消息
93#[derive(Debug, Serialize)]
94pub struct NewsMessage {
95    pub articles: Vec<NewsArticle>,
96}
97
98/// 文件消息
99#[derive(Debug, Serialize)]
100pub struct FileMessage {
101    pub media_id: String,
102}
103
104/// 语音消息
105#[derive(Debug, Serialize)]
106pub struct VoiceMessage {
107    pub media_id: String,
108}
109
110// --- Start of TemplateCard additions ---
111#[derive(Debug, Serialize_repr, Deserialize_repr, Clone)]
112#[repr(u8)]
113pub enum DescColor {
114    Gray = 0,
115    Blue = 1,
116    Red = 2,
117    Green = 3,
118}
119
120/// 卡片来源,可为空
121#[derive(Debug, Serialize, Deserialize, Clone, Default)]
122pub struct CardSource {
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub icon_url: Option<String>,
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub desc: Option<String>,
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub desc_color: Option<DescColor>,
129}
130
131/// 主要内容,如何展示主要内容由 `emphasis_content` 和 `main_title` 控制
132#[derive(Debug, Serialize, Deserialize, Clone)]
133pub struct MainTitle {
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub title: Option<String>,
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub desc: Option<String>,
138}
139
140/// 关键数据样式
141#[derive(Debug, Serialize, Deserialize, Clone)]
142pub struct EmphasisContent {
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub title: Option<String>,
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub desc: Option<String>,
147}
148#[derive(Debug, Serialize, Deserialize, Clone)]
149pub enum QuotaTarget {
150    None,
151    Url(String),
152    Weapp { appid: String, pagepath: String },
153}
154
155/// 引用文献样式,建议不与 `main_title` 同时设置
156#[derive(Debug, Clone)]
157pub struct QuoteArea {
158    pub target: QuotaTarget,
159    pub title: Option<String>,
160    pub quote_text: Option<String>,
161}
162
163impl Serialize for QuoteArea {
164    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
165    where
166        S: Serializer,
167    {
168        let mut s = serializer.serialize_struct("QuoteArea", 4)?;
169        s.serialize_field("title", &self.title)?;
170        s.serialize_field("quote_text", &self.quote_text)?;
171        match &self.target {
172            QuotaTarget::None => {
173                s.serialize_field("type", &0)?;
174            }
175            QuotaTarget::Url(url) => {
176                s.serialize_field("type", &1)?;
177                s.serialize_field("url", url)?;
178            }
179            QuotaTarget::Weapp { appid, pagepath } => {
180                s.serialize_field("type", &2)?;
181                s.serialize_field("appid", appid)?;
182                s.serialize_field("pagepath", pagepath)?;
183            }
184        }
185        s.end()
186    }
187}
188
189#[derive(Debug, Clone)]
190pub enum ContentType {
191    Url {
192        value: Option<String>,
193        url: String,
194    },
195    Media {
196        media_id: String,
197        media_type: String,
198    },
199    Member {
200        value: Option<String>,
201        user_id: String,
202    },
203}
204
205#[derive(Debug, Clone)]
206pub struct HorizontalContent {
207    pub title: Option<String>,
208    pub content: ContentType,
209}
210
211impl Serialize for HorizontalContent {
212    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
213    where
214        S: Serializer,
215    {
216        let mut state = serializer.serialize_struct("HorizontalContent", 2)?;
217        state.serialize_field("keyname", &self.title)?;
218        match &self.content {
219            ContentType::Url { value, url } => {
220                state.serialize_field("type", &1)?;
221                state.serialize_field("url", url)?;
222                state.serialize_field("value", value)?;
223            }
224            ContentType::Media {
225                media_id,
226                media_type,
227            } => {
228                state.serialize_field("type", &2)?;
229                state.serialize_field("media_id", media_id)?;
230                state.serialize_field("value", media_type)?;
231            }
232            ContentType::Member { value, user_id } => {
233                state.serialize_field("type", &3)?;
234                state.serialize_field("value", value)?;
235                state.serialize_field("userid", user_id)?;
236            }
237        }
238        state.end()
239    }
240}
241
242#[derive(Debug, Serialize, Deserialize, Clone)]
243pub enum JumpTarget {
244    None,
245    Url(String),
246    Weapp { appid: String, pagepath: String },
247}
248
249/// 跳转指引样式的列表,该字段可为空数组,但有数据的话需确认对应字段是否必填,列表长度不超过3
250#[derive(Debug, Clone)]
251pub struct Jump {
252    pub title: String,
253    pub r#type: JumpTarget,
254}
255impl Serialize for Jump {
256    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
257    where
258        S: Serializer,
259    {
260        let mut state = serializer.serialize_struct("Jump", 2)?;
261        state.serialize_field("title", &self.title)?;
262        match &self.r#type {
263            JumpTarget::None => state.serialize_field("type", &0)?,
264            JumpTarget::Url(url) => {
265                state.serialize_field("type", &1)?;
266                state.serialize_field("url", url)?;
267            }
268            JumpTarget::Weapp { appid, pagepath } => {
269                state.serialize_field("type", &2)?;
270                state.serialize_field("appid", appid)?;
271                state.serialize_field("pagepath", pagepath)?;
272            }
273        }
274        state.end()
275    }
276}
277
278#[derive(Debug, Clone)]
279pub enum CardAction {
280    Url(String),
281    Weapp { appid: String, pagepath: String },
282}
283
284impl Serialize for CardAction {
285    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
286    where
287        S: Serializer,
288    {
289        let mut state = serializer.serialize_struct("CardAction", 2)?;
290        match &self {
291            CardAction::Url(url) => {
292                state.serialize_field("type", &1)?;
293                state.serialize_field("url", url)?;
294            }
295            CardAction::Weapp { appid, pagepath } => {
296                state.serialize_field("type", &2)?;
297                state.serialize_field("appid", appid)?;
298                state.serialize_field("pagepath", pagepath)?;
299            }
300        }
301        state.end()
302    }
303}
304
305/// 图片样式
306#[derive(Debug, Serialize, Deserialize, Clone)]
307pub struct CardImage {
308    pub url: String,
309    #[serde(skip_serializing_if = "Option::is_none")]
310    pub aspect_ratio: Option<f64>,
311}
312
313/// 垂直内容,卡片二级垂直内容,该字段可为空数组,但有数据的话需确认对应字段是否必填,列表长度不超过4
314#[derive(Debug, Serialize, Deserialize, Clone)]
315pub struct VerticalContent {
316    pub title: String, // 最多4个字
317    #[serde(skip_serializing_if = "Option::is_none")]
318    pub desc: Option<String>, // Desc内容,2行,最多26个字
319}
320
321/// 图文展示,图片在左,文字在右
322#[derive(Debug, Clone)]
323pub struct ImageTextArea {
324    pub r#type: JumpTarget, // 0-普通文本, 1-url
325    pub title: Option<String>,
326    pub desc: Option<String>,
327    pub image_url: String,
328}
329
330impl Serialize for ImageTextArea {
331    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
332    where
333        S: Serializer,
334    {
335        let mut state = serializer.serialize_struct("ImageTextArea", 3)?;
336        state.serialize_field("title", &self.title)?;
337        state.serialize_field("desc", &self.desc)?;
338        state.serialize_field("image_url", &self.image_url)?;
339        match &self.r#type {
340            JumpTarget::None => state.serialize_field("type", &0)?,
341            JumpTarget::Url(url) => {
342                state.serialize_field("type", &1)?;
343                state.serialize_field("url", url)?;
344            }
345            JumpTarget::Weapp { appid, pagepath } => {
346                state.serialize_field("type", &2)?;
347                state.serialize_field("appid", appid)?;
348                state.serialize_field("pagepath", pagepath)?;
349            }
350        }
351        state.end()
352    }
353}
354
355#[derive(Debug, Serialize, Clone)]
356#[serde(untagged, rename_all = "snake_case")]
357pub enum NoticeCard {
358    TextNotice {
359        #[serde(skip_serializing_if = "Option::is_none")]
360        emphasis_content: Option<EmphasisContent>,
361        #[serde(skip_serializing_if = "Option::is_none")]
362        sub_title_text: Option<String>,
363    },
364    NewsNotice {
365        #[serde(skip_serializing_if = "Option::is_none")]
366        card_image: Option<CardImage>,
367        #[serde(skip_serializing_if = "Option::is_none")]
368        image_text_area: Option<ImageTextArea>,
369        #[serde(skip_serializing_if = "Option::is_none")]
370        vertical_content_list: Option<Vec<VerticalContent>>,
371    },
372}
373
374/// 模板卡片消息定义
375#[derive(Debug, Serialize, Clone)]
376pub struct TemplateCardMessage {
377    #[serde(flatten)]
378    pub card: NoticeCard,
379    #[serde(skip_serializing_if = "Option::is_none")]
380    pub source: Option<CardSource>,
381    #[serde(skip_serializing_if = "Option::is_none")]
382    pub main_title: Option<MainTitle>,
383    #[serde(skip_serializing_if = "Option::is_none")]
384    pub quote_area: Option<QuoteArea>,
385    #[serde(skip_serializing_if = "Option::is_none")]
386    pub horizontal_content_list: Option<Vec<HorizontalContent>>,
387    #[serde(skip_serializing_if = "Option::is_none")]
388    pub jump_list: Option<Vec<Jump>>,
389    #[serde(skip_serializing_if = "Option::is_none")]
390    pub card_action: Option<CardAction>,
391}
392// --- End of TemplateCard additions ---
393
394/// 机器人消息体
395#[derive(Debug, Serialize)]
396#[serde(tag = "msgtype")]
397pub enum RobotMessage {
398    #[serde(rename = "text")]
399    Text(TextMessage),
400    #[serde(rename = "markdown")]
401    Markdown(MarkdownMessage),
402    #[serde(rename = "image")]
403    Image(ImageMessage),
404    #[serde(rename = "news")]
405    News(NewsMessage),
406    #[serde(rename = "file")]
407    File(FileMessage),
408    #[serde(rename = "voice")]
409    Voice(VoiceMessage),
410    #[serde(rename = "template_card")]
411    TemplateCard(TemplateCardMessage),
412}
413#[derive(Debug, Serialize)]
414#[serde(rename_all = "snake_case")]
415pub enum FileType {
416    Voice,
417    File,
418}
419
420/// 企业微信群机器人客户端
421#[derive(Clone)]
422pub struct RobotClient {
423    pub key: String,
424    pub client: Client,
425}
426
427impl ClientTrait for RobotClient {
428    async fn access_token(&self) -> rust_wechat_core::Result<String> {
429        Err(WechatError::UnsupportedCommand("access_token".to_string()))
430    }
431
432    async fn refresh_access_token(&mut self) -> rust_wechat_core::Result<()> {
433        Err(WechatError::UnsupportedCommand(
434            "refresh_access_token".to_string(),
435        ))
436    }
437
438    fn http_client(&self) -> &Client {
439        &self.client
440    }
441}
442impl RobotClient {
443    /// 创建一个新的机器人客户端实例
444    ///
445    /// # Arguments
446    ///
447    /// * `webhook_key` - 机器人的 Webhook Key (从 webhook_url 中提取,例如: 693a91f6-7xxx-4bc4-97a0-0ec2sifa5aaa)
448    pub fn new(key: &str) -> Self {
449        RobotClient {
450            key: key.to_string(),
451            client: reqwest::Client::new(),
452        }
453    }
454
455    /// 发送消息到群机器人
456    pub async fn send_message(&self, message: &RobotMessage) -> crate::Result<()> {
457        let url = self.url_with_query(SEND_MESSAGE_URL, &[("key", &self.key)])?;
458        let response: SendRobotMessageResponse = self.json(url, message).await?;
459        Ok(response.ignore()?)
460    }
461
462    /// 上传文件到企业微信,用于发送文件或语音消息 <mcreference link="https://developer.work.weixin.qq.com/document/path/99110#%E6%96%87%E4%BB%B6%E4%B8%8A%E4%BC%A0%E6%8E%A5%E5%8F%A3" index="0">0</mcreference>
463    ///
464    /// # Arguments
465    ///
466    /// * `file_path` - 本地文件的路径
467    /// * `file_type` - 文件类型,根据企业微信文档,此处应为 "file" (文件), "voice" (语音), "image" (图片) <mcreference link="https://developer.work.weixin.qq.com/document/path/99110#%E6%96%87%E4%BB%B6%E4%B8%8A%E4%BC%A0%E6%8E%A5%E5%8F%A3" index="0">0</mcreference>
468    pub async fn upload_media(
469        &self,
470        file_path: &str,
471        file_type: FileType,
472    ) -> crate::Result<SendRobotMessage> {
473        let url = self.url_with_query(UPLOAD_MEDIA_URL, &[("key", &self.key)])?;
474
475        let file_bytes = tokio::fs::read(file_path).await?;
476        let file_name = std::path::Path::new(file_path)
477            .file_name()
478            .and_then(|n| n.to_str())
479            .unwrap_or("unknown_file")
480            .to_string();
481
482        let part = multipart::Part::bytes(file_bytes)
483            .file_name(file_name)
484            .mime_str("application/octet-stream")?;
485
486        let form = multipart::Form::new().part("media", part);
487
488        let response: SendRobotMessageResponse =
489            self.upload_with(url, &[("type", file_type)], form).await?;
490
491        Ok(response.data()?)
492    }
493
494    /// 发送文件消息 (通过先上传文件获取media_id)
495    pub async fn send_file_message(&self, file_path: &str) -> crate::Result<()> {
496        let upload_response = self.upload_media(file_path, FileType::File).await?;
497        if let Some(media_id) = upload_response.media_id {
498            let file_message = FileMessage { media_id };
499            self.send_message(&RobotMessage::File(file_message)).await
500        } else {
501            Err(WorkError::CommonError(
502                "Failed to get media_id from upload response".to_string(),
503            ))
504        }
505    }
506}