Skip to main content

wechat_mp_sdk/api/
customer_service.rs

1//! Customer Service Message API
2//!
3//! Provides APIs for sending customer service messages to users.
4//!
5//! # Overview
6//!
7//! Customer service messages allow Mini Programs to send messages to users
8//! who have interacted with the Mini Program within the last 48 hours.
9//!
10//! # Message Types
11//!
12//! - [`Message::Text`] - Text message
13//! - [`Message::Image`] - Image message
14//! - [`Message::Link`] - Link card message
15//! - [`Message::MiniProgramPage`] - Mini Program page card
16//!
17//! # Example
18//!
19//! ```rust,ignore
20//! use wechat_mp_sdk::api::customer_service::{CustomerServiceApi, Message, TextMessage};
21//! use wechat_mp_sdk::token::TokenManager;
22//!
23//! let api = CustomerServiceApi::new(context);
24//! let message = Message::Text {
25//!     text: TextMessage::new("Hello!")
26//! };
27//! api.send("user_openid", message).await?;
28//! ```
29
30use std::sync::Arc;
31
32use serde::{Deserialize, Serialize};
33
34use super::{WechatApi, WechatContext};
35use crate::error::WechatError;
36use crate::types::AppId;
37
38// ============================================================================
39// Message Types
40// ============================================================================
41
42/// Message types for customer service messages
43#[derive(Debug, Clone, Serialize)]
44#[serde(tag = "msgtype", rename_all = "lowercase")]
45pub enum Message {
46    /// Text message
47    Text { text: TextMessage },
48    /// Image message
49    Image { image: MediaMessage },
50    /// Link card message
51    Link { link: LinkMessage },
52    /// Mini Program page card
53    #[serde(rename = "miniprogrampage")]
54    MiniProgramPage {
55        miniprogrampage: MiniProgramPageMessage,
56    },
57}
58
59/// Text message content
60#[derive(Debug, Clone, Serialize)]
61pub struct TextMessage {
62    /// Message content
63    pub content: String,
64}
65
66impl TextMessage {
67    /// Create a new text message
68    pub fn new(content: impl Into<String>) -> Self {
69        Self {
70            content: content.into(),
71        }
72    }
73}
74
75/// Media message (image) content
76#[derive(Debug, Clone, Serialize)]
77pub struct MediaMessage {
78    /// Media ID from upload API
79    pub media_id: String,
80}
81
82impl MediaMessage {
83    /// Create a new media message
84    pub fn new(media_id: impl Into<String>) -> Self {
85        Self {
86            media_id: media_id.into(),
87        }
88    }
89}
90
91/// Link message content
92#[derive(Debug, Clone, Serialize)]
93pub struct LinkMessage {
94    /// Link title
95    pub title: String,
96    /// Link description
97    pub description: String,
98    /// Link URL
99    pub url: String,
100    /// Thumbnail URL
101    pub thumb_url: String,
102}
103
104impl LinkMessage {
105    /// Create a new link message
106    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/// Mini Program page message content
122#[derive(Debug, Clone, Serialize)]
123pub struct MiniProgramPageMessage {
124    /// Page title
125    pub title: String,
126    /// Mini Program AppID (can be different from current Mini Program)
127    pub appid: AppId,
128    /// Page path
129    pub pagepath: String,
130    /// Thumbnail media ID
131    pub thumb_media_id: String,
132}
133
134impl MiniProgramPageMessage {
135    /// Create a new Mini Program page message
136    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// ============================================================================
152// Request/Response Types
153// ============================================================================
154
155/// Request for sending customer service message
156#[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/// Typing command for customer service
173#[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
185// ============================================================================
186// CustomerServiceApi
187// ============================================================================
188
189/// Customer Service Message API
190///
191/// Provides methods for sending customer service messages to users.
192pub struct CustomerServiceApi {
193    context: Arc<WechatContext>,
194}
195
196impl CustomerServiceApi {
197    /// Create a new CustomerServiceApi instance
198    pub fn new(context: Arc<WechatContext>) -> Self {
199        Self { context }
200    }
201
202    /// Send customer service message
203    ///
204    /// POST /cgi-bin/message/custom/send?access_token=ACCESS_TOKEN
205    ///
206    /// # Arguments
207    /// * `touser` - Recipient's OpenID
208    /// * `message` - Message to send
209    ///
210    /// # Example
211    ///
212    /// ```rust,ignore
213    /// use wechat_mp_sdk::api::customer_service::{CustomerServiceApi, Message, TextMessage};
214    ///
215    /// let api = CustomerServiceApi::new(context);
216    /// let message = Message::Text { text: TextMessage::new("Hello!") };
217    /// api.send("user_openid", message).await?;
218    /// ```
219    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    /// Set typing status for customer service
236    ///
237    /// POST /cgi-bin/message/custom/typing?access_token=ACCESS_TOKEN
238    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// ============================================================================
267// Tests
268// ============================================================================
269
270#[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        // Wire format must use "miniprogrampage" (lowercase), not "MiniProgramPage"
363        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}