Skip to main content

wechat_mp_sdk/api/
subscribe.rs

1//! Subscribe Message API
2//!
3//! Provides APIs for sending subscribe messages and managing templates.
4//!
5//! ## Overview
6//!
7//! Subscribe messages are special messages that users must opt-in to receive.
8//! They are commonly used for notifications like order status, reminders, etc.
9//!
10//! ## Usage
11//!
12//! ```ignore
13//! use wechat_mp_sdk::api::subscribe::{SubscribeApi, SubscribeMessageOptions, SubscribeMessageData, SubscribeMessageValue};
14//!
15//! // Create the API instance
16//! let subscribe_api = SubscribeApi::new(context);
17//!
18//! // Send a subscribe message
19//! let mut data = SubscribeMessageData::new();
20//! data.insert("thing1".to_string(), SubscribeMessageValue::new("Order #123"));
21//! data.insert("time2".to_string(), SubscribeMessageValue::new("2024-01-01 12:00"));
22//!
23//! let options = SubscribeMessageOptions {
24//!     touser: "user_openid".to_string(),
25//!     template_id: "template_id".to_string(),
26//!     data,
27//!     page: Some("pages/index/index".to_string()),
28//!     miniprogram_state: None,
29//!     lang: None,
30//! };
31//!
32//! subscribe_api.send(options).await?;
33//! ```
34
35use std::collections::HashMap;
36use std::sync::Arc;
37
38use serde::{Deserialize, Serialize};
39
40use super::{WechatApi, WechatContext};
41use crate::error::WechatError;
42use crate::types::OpenId;
43
44/// Subscribe message data (key-value pairs)
45pub type SubscribeMessageData = HashMap<String, SubscribeMessageValue>;
46
47/// Value for subscribe message field
48#[derive(Debug, Clone, Serialize)]
49pub struct SubscribeMessageValue {
50    pub value: String,
51}
52
53impl SubscribeMessageValue {
54    /// Create a new subscribe message value
55    pub fn new(value: impl Into<String>) -> Self {
56        Self {
57            value: value.into(),
58        }
59    }
60}
61
62/// Request for sending subscribe message
63#[derive(Debug, Clone, Serialize)]
64struct SubscribeMessageRequest {
65    #[serde(rename = "touser")]
66    touser: OpenId,
67    #[serde(rename = "template_id")]
68    template_id: String,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    page: Option<String>,
71    data: SubscribeMessageData,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    miniprogram_state: Option<MiniProgramState>,
74    #[serde(skip_serializing_if = "Option::is_none")]
75    lang: Option<Lang>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
79#[serde(rename_all = "lowercase")]
80pub enum MiniProgramState {
81    Developer,
82    Trial,
83    Formal,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
87pub enum Lang {
88    #[serde(rename = "zh_CN")]
89    ZhCN,
90    #[serde(rename = "en_US")]
91    EnUS,
92    #[serde(rename = "zh_HK")]
93    ZhHK,
94    #[serde(rename = "zh_TW")]
95    ZhTW,
96}
97
98/// Options for sending subscribe messages
99#[derive(Debug, Clone)]
100pub struct SubscribeMessageOptions {
101    /// Recipient's OpenID
102    pub touser: OpenId,
103    /// Template ID
104    pub template_id: String,
105    /// Template data
106    pub data: SubscribeMessageData,
107    /// Page to navigate to (optional)
108    pub page: Option<String>,
109    /// Mini program state: "developer", "trial", or "formal" (optional)
110    pub miniprogram_state: Option<MiniProgramState>,
111    /// Language: "zh_CN", "en_US", "zh_HK", "zh_TW" (optional)
112    pub lang: Option<Lang>,
113}
114
115/// Response from subscribe message API
116#[derive(Debug, Clone, Deserialize)]
117struct SubscribeMessageResponse {
118    #[serde(default)]
119    errcode: i32,
120    #[serde(default)]
121    errmsg: String,
122}
123
124/// Template info
125#[non_exhaustive]
126#[derive(Debug, Clone, Deserialize)]
127pub struct TemplateInfo {
128    /// Private template ID
129    #[serde(rename = "priTmplId")]
130    pub private_template_id: String,
131    /// Template title
132    pub title: String,
133    /// Template content
134    pub content: String,
135    /// Example content (optional)
136    #[serde(default)]
137    pub example: Option<String>,
138    /// Template type
139    #[serde(rename = "type")]
140    pub template_type: i32,
141}
142
143/// Response from get template list
144#[non_exhaustive]
145#[derive(Debug, Clone, Deserialize)]
146pub struct TemplateListResponse {
147    pub data: Vec<TemplateInfo>,
148    #[serde(default)]
149    errcode: i32,
150    #[serde(default)]
151    errmsg: String,
152}
153
154/// Request for add template
155#[derive(Debug, Clone, Serialize)]
156struct AddTemplateRequest {
157    tid: String,
158    #[serde(skip_serializing_if = "Option::is_none")]
159    kid_list: Option<Vec<i32>>,
160    #[serde(skip_serializing_if = "Option::is_none")]
161    scene_desc: Option<String>,
162}
163
164/// Response from add template
165#[non_exhaustive]
166#[derive(Debug, Clone, Deserialize)]
167pub struct AddTemplateResponse {
168    #[serde(rename = "priTmplId")]
169    pub private_template_id: String,
170    #[serde(default)]
171    errcode: i32,
172    #[serde(default)]
173    errmsg: String,
174}
175
176/// Category info
177#[non_exhaustive]
178#[derive(Debug, Clone, Deserialize)]
179pub struct CategoryInfo {
180    /// Category ID
181    pub id: i32,
182    /// Category name
183    pub name: String,
184}
185
186/// Response from get category
187#[non_exhaustive]
188#[derive(Debug, Clone, Deserialize)]
189pub struct CategoryListResponse {
190    pub data: Vec<CategoryInfo>,
191    #[serde(default)]
192    errcode: i32,
193    #[serde(default)]
194    errmsg: String,
195}
196
197#[non_exhaustive]
198#[derive(Debug, Clone, Deserialize, Serialize)]
199pub struct PubTemplateKeywordInfo {
200    #[serde(default)]
201    pub kid: i32,
202    #[serde(default)]
203    pub name: String,
204    #[serde(default)]
205    pub rule: String,
206}
207
208#[non_exhaustive]
209#[derive(Debug, Clone, Deserialize, Serialize)]
210pub struct PubTemplateKeywordResponse {
211    #[serde(default)]
212    pub data: Vec<PubTemplateKeywordInfo>,
213    #[serde(default)]
214    pub(crate) errcode: i32,
215    #[serde(default)]
216    pub(crate) errmsg: String,
217}
218
219#[non_exhaustive]
220#[derive(Debug, Clone, Deserialize, Serialize)]
221pub struct PubTemplateTitleInfo {
222    #[serde(default)]
223    pub tid: String,
224    #[serde(default)]
225    pub title: String,
226    #[serde(default)]
227    pub r#type: i32,
228    #[serde(default)]
229    pub category_id: i32,
230}
231
232#[non_exhaustive]
233#[derive(Debug, Clone, Deserialize, Serialize)]
234pub struct PubTemplateTitleListResponse {
235    #[serde(default)]
236    pub data: Vec<PubTemplateTitleInfo>,
237    #[serde(default)]
238    pub(crate) errcode: i32,
239    #[serde(default)]
240    pub(crate) errmsg: String,
241}
242
243#[non_exhaustive]
244#[derive(Debug, Clone, Serialize)]
245pub struct UserNotifyRequest {
246    #[serde(flatten)]
247    pub payload: HashMap<String, serde_json::Value>,
248}
249
250#[non_exhaustive]
251#[derive(Debug, Clone, Serialize)]
252pub struct UserNotifyExtRequest {
253    #[serde(flatten)]
254    pub payload: HashMap<String, serde_json::Value>,
255}
256
257#[non_exhaustive]
258#[derive(Debug, Clone, Serialize)]
259pub struct GetUserNotifyRequest {
260    #[serde(flatten)]
261    pub payload: HashMap<String, serde_json::Value>,
262}
263
264#[non_exhaustive]
265#[derive(Debug, Clone, Deserialize, Serialize)]
266pub struct UserNotifyResponse {
267    #[serde(default)]
268    pub(crate) errcode: i32,
269    #[serde(default)]
270    pub(crate) errmsg: String,
271    #[serde(flatten)]
272    pub extra: HashMap<String, serde_json::Value>,
273}
274
275/// Subscribe Message API
276///
277/// Provides methods for sending subscribe messages and managing templates.
278pub struct SubscribeApi {
279    context: Arc<WechatContext>,
280}
281
282impl SubscribeApi {
283    /// Create a new SubscribeApi instance
284    pub fn new(context: Arc<WechatContext>) -> Self {
285        Self { context }
286    }
287
288    /// Send subscribe message
289    ///
290    /// POST /cgi-bin/message/subscribe/send?access_token=ACCESS_TOKEN
291    ///
292    /// # Arguments
293    /// * `options` - Subscribe message options
294    ///
295    /// # Example
296    ///
297    /// ```ignore
298    /// use wechat_mp_sdk::api::subscribe::{
299    ///     SubscribeApi, SubscribeMessageOptions, SubscribeMessageData, SubscribeMessageValue
300    /// };
301    ///
302    /// let mut data = SubscribeMessageData::new();
303    /// data.insert("thing1".to_string(), SubscribeMessageValue::new("Order #123"));
304    ///
305    /// let options = SubscribeMessageOptions {
306    ///     touser: "user_openid".to_string(),
307    ///     template_id: "template_id".to_string(),
308    ///     data,
309    ///     page: Some("pages/index/index".to_string()),
310    ///     miniprogram_state: None,
311    ///     lang: None,
312    /// };
313    ///
314    /// subscribe_api.send(options).await?;
315    /// ```
316    pub async fn send(&self, options: SubscribeMessageOptions) -> Result<(), WechatError> {
317        let request = SubscribeMessageRequest {
318            touser: options.touser,
319            template_id: options.template_id,
320            page: options.page,
321            data: options.data,
322            miniprogram_state: options.miniprogram_state,
323            lang: options.lang,
324        };
325
326        let response: SubscribeMessageResponse = self
327            .context
328            .authed_post("/cgi-bin/message/subscribe/send", &request)
329            .await?;
330
331        WechatError::check_api(response.errcode, &response.errmsg)?;
332
333        Ok(())
334    }
335
336    /// Add template from template library
337    ///
338    /// POST /wxaapi/newtmpl/addtemplate?access_token=ACCESS_TOKEN
339    ///
340    /// # Arguments
341    /// * `tid` - Template library ID
342    /// * `kid_list` - Keyword ID list (optional)
343    /// * `scene_desc` - Scene description (optional)
344    ///
345    /// # Returns
346    /// The private template ID
347    ///
348    /// # Example
349    ///
350    /// ```ignore
351    /// let pri_tmpl_id = subscribe_api.add_template("tid123", Some(vec![1, 2, 3]), Some("payment notification")).await?;
352    /// println!("Template ID: {}", pri_tmpl_id);
353    /// ```
354    pub async fn add_template(
355        &self,
356        tid: &str,
357        kid_list: Option<Vec<i32>>,
358        scene_desc: Option<&str>,
359    ) -> Result<String, WechatError> {
360        let request = AddTemplateRequest {
361            tid: tid.to_string(),
362            kid_list,
363            scene_desc: scene_desc.map(|s| s.to_string()),
364        };
365
366        let response: AddTemplateResponse = self
367            .context
368            .authed_post("/wxaapi/newtmpl/addtemplate", &request)
369            .await?;
370
371        WechatError::check_api(response.errcode, &response.errmsg)?;
372
373        Ok(response.private_template_id)
374    }
375
376    /// Get template list
377    ///
378    /// GET /wxaapi/newtmpl/gettemplate?access_token=ACCESS_TOKEN
379    ///
380    /// # Returns
381    /// List of templates
382    ///
383    /// # Example
384    ///
385    /// ```ignore
386    /// let templates = subscribe_api.get_template_list().await?;
387    /// for tmpl in templates {
388    ///     println!("Template: {} - {}", tmpl.private_template_id, tmpl.title);
389    /// }
390    /// ```
391    pub async fn get_template_list(&self) -> Result<Vec<TemplateInfo>, WechatError> {
392        let response: TemplateListResponse = self
393            .context
394            .authed_get("/wxaapi/newtmpl/gettemplate", &[])
395            .await?;
396
397        WechatError::check_api(response.errcode, &response.errmsg)?;
398
399        Ok(response.data)
400    }
401
402    /// Delete template
403    ///
404    /// POST /wxaapi/newtmpl/deltemplate?access_token=ACCESS_TOKEN
405    ///
406    /// # Arguments
407    /// * `pri_tmpl_id` - Private template ID to delete
408    ///
409    /// # Example
410    ///
411    /// ```ignore
412    /// subscribe_api.delete_template("pri_tmpl_id_123").await?;
413    /// ```
414    pub async fn delete_template(&self, pri_tmpl_id: &str) -> Result<(), WechatError> {
415        #[derive(Serialize)]
416        struct Request {
417            #[serde(rename = "pri_tmpl_id")]
418            pri_tmpl_id: String,
419        }
420
421        let response: SubscribeMessageResponse = self
422            .context
423            .authed_post(
424                "/wxaapi/newtmpl/deltemplate",
425                &Request {
426                    pri_tmpl_id: pri_tmpl_id.to_string(),
427                },
428            )
429            .await?;
430
431        WechatError::check_api(response.errcode, &response.errmsg)?;
432
433        Ok(())
434    }
435
436    /// Get category list
437    ///
438    /// GET /wxaapi/newtmpl/getcategory?access_token=ACCESS_TOKEN
439    ///
440    /// # Returns
441    /// List of categories available for templates
442    ///
443    /// # Example
444    ///
445    /// ```ignore
446    /// let categories = subscribe_api.get_category().await?;
447    /// for cat in categories {
448    ///     println!("Category: {} - {}", cat.id, cat.name);
449    /// }
450    /// ```
451    pub async fn get_category(&self) -> Result<Vec<CategoryInfo>, WechatError> {
452        let response: CategoryListResponse = self
453            .context
454            .authed_get("/wxaapi/newtmpl/getcategory", &[])
455            .await?;
456
457        WechatError::check_api(response.errcode, &response.errmsg)?;
458
459        Ok(response.data)
460    }
461
462    pub async fn get_pub_template_keywords_by_id(
463        &self,
464        tid: &str,
465    ) -> Result<PubTemplateKeywordResponse, WechatError> {
466        let response: PubTemplateKeywordResponse = self
467            .context
468            .authed_get("/wxaapi/newtmpl/getpubtemplatekeywords", &[("tid", tid)])
469            .await?;
470
471        WechatError::check_api(response.errcode, &response.errmsg)?;
472
473        Ok(response)
474    }
475
476    pub async fn get_pub_template_title_list(
477        &self,
478        ids: &[i32],
479        start: i32,
480        limit: i32,
481    ) -> Result<PubTemplateTitleListResponse, WechatError> {
482        let ids_text = ids
483            .iter()
484            .map(std::string::ToString::to_string)
485            .collect::<Vec<String>>()
486            .join(",");
487        let start_text = start.to_string();
488        let limit_text = limit.to_string();
489        let response: PubTemplateTitleListResponse = self
490            .context
491            .authed_get(
492                "/wxaapi/newtmpl/getpubtemplatetitles",
493                &[
494                    ("ids", ids_text.as_str()),
495                    ("start", start_text.as_str()),
496                    ("limit", limit_text.as_str()),
497                ],
498            )
499            .await?;
500
501        WechatError::check_api(response.errcode, &response.errmsg)?;
502
503        Ok(response)
504    }
505
506    pub async fn set_user_notify(
507        &self,
508        request: &UserNotifyRequest,
509    ) -> Result<UserNotifyResponse, WechatError> {
510        self.post_user_notify("/cgi-bin/message/update_template_card", request)
511            .await
512    }
513
514    pub async fn set_user_notify_ext(
515        &self,
516        request: &UserNotifyExtRequest,
517    ) -> Result<UserNotifyResponse, WechatError> {
518        self.post_user_notify("/cgi-bin/message/update_template_card_ext", request)
519            .await
520    }
521
522    pub async fn get_user_notify(
523        &self,
524        request: &GetUserNotifyRequest,
525    ) -> Result<UserNotifyResponse, WechatError> {
526        self.post_user_notify("/cgi-bin/message/get_template_card", request)
527            .await
528    }
529
530    async fn post_user_notify<B: Serialize>(
531        &self,
532        endpoint: &str,
533        body: &B,
534    ) -> Result<UserNotifyResponse, WechatError> {
535        let response: UserNotifyResponse = self.context.authed_post(endpoint, body).await?;
536
537        WechatError::check_api(response.errcode, &response.errmsg)?;
538
539        Ok(response)
540    }
541}
542
543impl WechatApi for SubscribeApi {
544    fn context(&self) -> &WechatContext {
545        &self.context
546    }
547
548    fn api_name(&self) -> &'static str {
549        "subscribe"
550    }
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556    use crate::client::WechatClient;
557    use crate::token::TokenManager;
558    use crate::types::{AppId, AppSecret};
559
560    fn create_test_context(base_url: &str) -> Arc<WechatContext> {
561        let appid = AppId::new("wx1234567890abcdef").unwrap();
562        let secret = AppSecret::new("secret1234567890ab").unwrap();
563        let client = Arc::new(
564            WechatClient::builder()
565                .appid(appid)
566                .secret(secret)
567                .base_url(base_url)
568                .build()
569                .unwrap(),
570        );
571        let token_manager = Arc::new(TokenManager::new((*client).clone()));
572        Arc::new(WechatContext::new(client, token_manager))
573    }
574
575    #[test]
576    fn test_subscribe_message_value() {
577        let value = SubscribeMessageValue::new("test value");
578        assert_eq!(value.value, "test value");
579    }
580
581    #[test]
582    fn test_subscribe_message_data() {
583        let mut data = SubscribeMessageData::new();
584        data.insert(
585            "thing1".to_string(),
586            SubscribeMessageValue::new("Order #123"),
587        );
588        data.insert(
589            "time2".to_string(),
590            SubscribeMessageValue::new("2024-01-01"),
591        );
592
593        assert_eq!(data.len(), 2);
594        assert_eq!(data.get("thing1").unwrap().value, "Order #123");
595    }
596
597    #[test]
598    fn test_subscribe_message_options() {
599        let mut data = SubscribeMessageData::new();
600        data.insert("key1".to_string(), SubscribeMessageValue::new("value1"));
601
602        let options = SubscribeMessageOptions {
603            touser: OpenId::new("o6_bmjrPTlm6_2sgVt7hMZOPfL2M").unwrap(),
604            template_id: "template_id_456".to_string(),
605            data,
606            page: Some("pages/index/index".to_string()),
607            miniprogram_state: Some(MiniProgramState::Developer),
608            lang: Some(Lang::ZhCN),
609        };
610
611        assert_eq!(options.touser.as_str(), "o6_bmjrPTlm6_2sgVt7hMZOPfL2M");
612        assert_eq!(options.template_id, "template_id_456");
613        assert_eq!(options.page, Some("pages/index/index".to_string()));
614    }
615
616    #[test]
617    fn test_pub_template_keywords_response_parse() {
618        let json = r#"{
619            "data": [{"kid": 1, "name": "thing1", "rule": "20个以内字符"}],
620            "errcode": 0,
621            "errmsg": "ok"
622        }"#;
623
624        let response: PubTemplateKeywordResponse = serde_json::from_str(json).unwrap();
625        assert_eq!(response.data.len(), 1);
626        assert_eq!(response.data[0].kid, 1);
627    }
628
629    #[test]
630    fn test_user_notify_response_parse() {
631        let json = r#"{"errcode": 0, "errmsg": "ok", "status": "success"}"#;
632
633        let response: UserNotifyResponse = serde_json::from_str(json).unwrap();
634        assert_eq!(response.errcode, 0);
635        assert_eq!(response.extra.get("status").unwrap(), "success");
636    }
637
638    #[tokio::test]
639    async fn test_send_success() {
640        use wiremock::matchers::{body_json, method, path, query_param};
641        use wiremock::{Mock, MockServer, ResponseTemplate};
642
643        let mock_server = MockServer::start().await;
644
645        Mock::given(method("POST"))
646            .and(path("/cgi-bin/message/subscribe/send"))
647            .and(query_param("access_token", "test_token"))
648            .and(body_json(serde_json::json!({
649                "touser": "o6_bmjrPTlm6_2sgVt7hMZOPfL2M",
650                "template_id": "template_id_456",
651                "data": {
652                    "thing1": {"value": "Order #123"}
653                }
654            })))
655            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
656                "errcode": 0,
657                "errmsg": "ok"
658            })))
659            .mount(&mock_server)
660            .await;
661
662        Mock::given(method("GET"))
663            .and(path("/cgi-bin/token"))
664            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
665                "access_token": "test_token",
666                "expires_in": 7200,
667                "errcode": 0,
668                "errmsg": ""
669            })))
670            .mount(&mock_server)
671            .await;
672
673        let context = create_test_context(&mock_server.uri());
674        let subscribe_api = SubscribeApi::new(context);
675
676        let mut data = SubscribeMessageData::new();
677        data.insert(
678            "thing1".to_string(),
679            SubscribeMessageValue::new("Order #123"),
680        );
681
682        let options = SubscribeMessageOptions {
683            touser: OpenId::new("o6_bmjrPTlm6_2sgVt7hMZOPfL2M").unwrap(),
684            template_id: "template_id_456".to_string(),
685            data,
686            page: None,
687            miniprogram_state: None,
688            lang: None,
689        };
690
691        let result = subscribe_api.send(options).await;
692        assert!(result.is_ok());
693    }
694}