1use std::sync::Arc;
31
32use serde::{Deserialize, Serialize};
33
34use super::{WechatApi, WechatContext};
35use crate::error::WechatError;
36use crate::types::AppId;
37
38#[derive(Debug, Clone, Serialize)]
44#[serde(tag = "msgtype", rename_all = "lowercase")]
45pub enum Message {
46 Text { text: TextMessage },
48 Image { image: MediaMessage },
50 Link { link: LinkMessage },
52 #[serde(rename = "miniprogrampage")]
54 MiniProgramPage {
55 miniprogrampage: MiniProgramPageMessage,
56 },
57}
58
59#[derive(Debug, Clone, Serialize)]
61pub struct TextMessage {
62 pub content: String,
64}
65
66impl TextMessage {
67 pub fn new(content: impl Into<String>) -> Self {
69 Self {
70 content: content.into(),
71 }
72 }
73}
74
75#[derive(Debug, Clone, Serialize)]
77pub struct MediaMessage {
78 pub media_id: String,
80}
81
82impl MediaMessage {
83 pub fn new(media_id: impl Into<String>) -> Self {
85 Self {
86 media_id: media_id.into(),
87 }
88 }
89}
90
91#[derive(Debug, Clone, Serialize)]
93pub struct LinkMessage {
94 pub title: String,
96 pub description: String,
98 pub url: String,
100 pub thumb_url: String,
102}
103
104impl LinkMessage {
105 pub fn new(
107 title: impl Into<String>,
108 description: impl Into<String>,
109 url: impl Into<String>,
110 thumb_url: impl Into<String>,
111 ) -> Self {
112 Self {
113 title: title.into(),
114 description: description.into(),
115 url: url.into(),
116 thumb_url: thumb_url.into(),
117 }
118 }
119}
120
121#[derive(Debug, Clone, Serialize)]
123pub struct MiniProgramPageMessage {
124 pub title: String,
126 pub appid: AppId,
128 pub pagepath: String,
130 pub thumb_media_id: String,
132}
133
134impl MiniProgramPageMessage {
135 pub fn new(
137 title: impl Into<String>,
138 appid: AppId,
139 pagepath: impl Into<String>,
140 thumb_media_id: impl Into<String>,
141 ) -> Self {
142 Self {
143 title: title.into(),
144 appid,
145 pagepath: pagepath.into(),
146 thumb_media_id: thumb_media_id.into(),
147 }
148 }
149}
150
151#[derive(Debug, Clone, Serialize)]
157struct CustomerServiceMessageRequest {
158 #[serde(rename = "touser")]
159 touser: String,
160 #[serde(flatten)]
161 msgtype: Message,
162}
163
164#[derive(Debug, Clone, Deserialize)]
165struct CustomerServiceMessageResponse {
166 #[serde(default)]
167 errcode: i32,
168 #[serde(default)]
169 errmsg: String,
170}
171
172#[derive(Debug, Clone, Serialize)]
174pub enum TypingCommand {
175 Typing,
176 CancelTyping,
177}
178
179#[derive(Debug, Clone, Serialize)]
180struct SetTypingRequest {
181 touser: String,
182 command: TypingCommand,
183}
184
185pub struct CustomerServiceApi {
193 context: Arc<WechatContext>,
194}
195
196impl CustomerServiceApi {
197 pub fn new(context: Arc<WechatContext>) -> Self {
199 Self { context }
200 }
201
202 pub async fn send(&self, touser: &str, message: Message) -> Result<(), WechatError> {
220 let request = CustomerServiceMessageRequest {
221 touser: touser.to_string(),
222 msgtype: message,
223 };
224
225 let response: CustomerServiceMessageResponse = self
226 .context
227 .authed_post("/cgi-bin/message/custom/send", &request)
228 .await?;
229
230 WechatError::check_api(response.errcode, &response.errmsg)?;
231
232 Ok(())
233 }
234
235 pub async fn set_typing(
239 &self,
240 touser: &str,
241 command: TypingCommand,
242 ) -> Result<(), WechatError> {
243 let request = SetTypingRequest {
244 touser: touser.to_string(),
245 command,
246 };
247 let response: CustomerServiceMessageResponse = self
248 .context
249 .authed_post("/cgi-bin/message/custom/typing", &request)
250 .await?;
251 WechatError::check_api(response.errcode, &response.errmsg)?;
252 Ok(())
253 }
254}
255
256impl WechatApi for CustomerServiceApi {
257 fn api_name(&self) -> &'static str {
258 "customer_service"
259 }
260
261 fn context(&self) -> &WechatContext {
262 &self.context
263 }
264}
265
266#[cfg(test)]
271mod tests {
272 use super::*;
273 use crate::client::WechatClient;
274 use crate::token::TokenManager;
275 use crate::types::{AppId, AppSecret};
276
277 fn create_test_context(base_url: &str) -> Arc<WechatContext> {
278 let appid = AppId::new("wx1234567890abcdef").unwrap();
279 let secret = AppSecret::new("secret1234567890ab").unwrap();
280 let client = Arc::new(
281 WechatClient::builder()
282 .appid(appid)
283 .secret(secret)
284 .base_url(base_url)
285 .build()
286 .unwrap(),
287 );
288 let token_manager = Arc::new(TokenManager::new((*client).clone()));
289 Arc::new(WechatContext::new(client, token_manager))
290 }
291
292 #[test]
293 fn test_text_message() {
294 let msg = TextMessage::new("Hello world");
295 assert_eq!(msg.content, "Hello world");
296 }
297
298 #[test]
299 fn test_media_message() {
300 let msg = MediaMessage::new("media_id_123");
301 assert_eq!(msg.media_id, "media_id_123");
302 }
303
304 #[test]
305 fn test_link_message() {
306 let msg = LinkMessage::new(
307 "Title",
308 "Description",
309 "https://example.com",
310 "https://example.com/thumb.jpg",
311 );
312 assert_eq!(msg.title, "Title");
313 assert_eq!(msg.description, "Description");
314 assert_eq!(msg.url, "https://example.com");
315 assert_eq!(msg.thumb_url, "https://example.com/thumb.jpg");
316 }
317
318 #[test]
319 fn test_miniprogram_page_message() {
320 let appid = AppId::new_unchecked("wx1234567890abcdef");
321 let msg = MiniProgramPageMessage::new(
322 "Title",
323 appid.clone(),
324 "pages/index/index",
325 "thumb_media_id",
326 );
327 assert_eq!(msg.title, "Title");
328 assert_eq!(msg.appid, appid);
329 assert_eq!(msg.pagepath, "pages/index/index");
330 assert_eq!(msg.thumb_media_id, "thumb_media_id");
331 }
332
333 #[test]
334 fn test_message_serialization() {
335 let text_msg = Message::Text {
336 text: TextMessage::new("Hello"),
337 };
338 let json = serde_json::to_string(&text_msg).unwrap();
339 assert!(json.contains("\"msgtype\":\"text\""));
340 assert!(json.contains("\"text\":{\"content\":\"Hello\"}"));
341
342 let image_msg = Message::Image {
343 image: MediaMessage::new("media123"),
344 };
345 let json = serde_json::to_string(&image_msg).unwrap();
346 assert!(json.contains("\"msgtype\":\"image\""));
347 assert!(json.contains("\"image\":{\"media_id\":\"media123\"}"));
348 }
349
350 #[test]
351 fn test_miniprogrampage_serialization_wire_format() {
352 let appid = AppId::new_unchecked("wx1234567890abcdef");
353 let msg = Message::MiniProgramPage {
354 miniprogrampage: MiniProgramPageMessage::new(
355 "Welcome",
356 appid,
357 "pages/index/index",
358 "thumb_media_123",
359 ),
360 };
361 let json = serde_json::to_string(&msg).unwrap();
362 assert!(json.contains("\"msgtype\":\"miniprogrampage\""));
364 assert!(json.contains("\"miniprogrampage\":{"));
365 assert!(json.contains("\"appid\":\"wx1234567890abcdef\""));
366 }
367
368 #[test]
369 fn test_api_name() {
370 let context = create_test_context("http://localhost:0");
371 let api = CustomerServiceApi::new(context);
372 assert_eq!(api.api_name(), "customer_service");
373 }
374
375 #[test]
376 fn test_typing_command_serialization() {
377 let typing = serde_json::to_string(&TypingCommand::Typing).unwrap();
378 assert_eq!(typing, "\"Typing\"");
379 let cancel = serde_json::to_string(&TypingCommand::CancelTyping).unwrap();
380 assert_eq!(cancel, "\"CancelTyping\"");
381 }
382
383 #[tokio::test]
384 async fn test_send_text_message_success() {
385 use wiremock::matchers::{method, path, query_param};
386 use wiremock::{Mock, MockServer, ResponseTemplate};
387
388 let mock_server = MockServer::start().await;
389
390 Mock::given(method("GET"))
391 .and(path("/cgi-bin/token"))
392 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
393 "access_token": "test_token",
394 "expires_in": 7200,
395 "errcode": 0,
396 "errmsg": ""
397 })))
398 .mount(&mock_server)
399 .await;
400
401 Mock::given(method("POST"))
402 .and(path("/cgi-bin/message/custom/send"))
403 .and(query_param("access_token", "test_token"))
404 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
405 "errcode": 0,
406 "errmsg": "ok"
407 })))
408 .mount(&mock_server)
409 .await;
410
411 let context = create_test_context(&mock_server.uri());
412 let api = CustomerServiceApi::new(context);
413
414 let message = Message::Text {
415 text: TextMessage::new("Hello!"),
416 };
417 let result = api.send("test_openid", message).await;
418
419 assert!(result.is_ok());
420 }
421
422 #[tokio::test]
423 async fn test_send_message_api_error() {
424 use wiremock::matchers::{method, path};
425 use wiremock::{Mock, MockServer, ResponseTemplate};
426
427 let mock_server = MockServer::start().await;
428
429 Mock::given(method("GET"))
430 .and(path("/cgi-bin/token"))
431 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
432 "access_token": "test_token",
433 "expires_in": 7200,
434 "errcode": 0,
435 "errmsg": ""
436 })))
437 .mount(&mock_server)
438 .await;
439
440 Mock::given(method("POST"))
441 .and(path("/cgi-bin/message/custom/send"))
442 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
443 "errcode": 40001,
444 "errmsg": "invalid credential"
445 })))
446 .mount(&mock_server)
447 .await;
448
449 let context = create_test_context(&mock_server.uri());
450 let api = CustomerServiceApi::new(context);
451
452 let message = Message::Text {
453 text: TextMessage::new("Hello!"),
454 };
455 let result = api.send("test_openid", message).await;
456
457 assert!(result.is_err());
458 if let Err(WechatError::Api { code, message }) = result {
459 assert_eq!(code, 40001);
460 assert_eq!(message, "invalid credential");
461 } else {
462 panic!("Expected WechatError::Api");
463 }
464 }
465}