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#[derive(Debug, Serialize, Deserialize, ServerResponse)]
16#[sr(flatten)]
17pub struct SendRobotMessage {
18 #[serde(rename = "type")]
19 pub media_type: Option<String>, pub media_id: Option<String>, pub created_at: Option<String>, }
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#[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#[derive(Debug, Serialize)]
62pub struct MarkdownMessage {
63 pub content: String,
64}
65
66#[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#[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#[derive(Debug, Serialize)]
94pub struct NewsMessage {
95 pub articles: Vec<NewsArticle>,
96}
97
98#[derive(Debug, Serialize)]
100pub struct FileMessage {
101 pub media_id: String,
102}
103
104#[derive(Debug, Serialize)]
106pub struct VoiceMessage {
107 pub media_id: String,
108}
109
110#[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#[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#[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#[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#[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#[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#[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#[derive(Debug, Serialize, Deserialize, Clone)]
315pub struct VerticalContent {
316 pub title: String, #[serde(skip_serializing_if = "Option::is_none")]
318 pub desc: Option<String>, }
320
321#[derive(Debug, Clone)]
323pub struct ImageTextArea {
324 pub r#type: JumpTarget, 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#[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#[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#[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 pub fn new(key: &str) -> Self {
449 RobotClient {
450 key: key.to_string(),
451 client: reqwest::Client::new(),
452 }
453 }
454
455 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 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 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}