Skip to main content

wx_bot_sdk/api/
mod.rs

1pub mod config_cache;
2pub mod session_guard;
3
4pub use config_cache::{CachedConfig, WeixinConfigManager};
5pub use session_guard::{SESSION_EXPIRED_ERRCODE, get_remaining_pause_ms, pause_session};
6
7use serde::{Deserialize, Serialize};
8use serde_json::json;
9
10use crate::util::random_uint32_base64;
11
12pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>;
13
14const APP_ID: &str = "bot";
15const PACKAGE_VERSION: &str = env!("CARGO_PKG_VERSION");
16const DEFAULT_LONG_POLL_TIMEOUT_MS: u64 = 35_000;
17const DEFAULT_API_TIMEOUT_MS: u64 = 15_000;
18const DEFAULT_CONFIG_TIMEOUT_MS: u64 = 10_000;
19
20#[derive(Clone, Debug, Default, Serialize, Deserialize)]
21pub struct BaseInfo {
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub channel_version: Option<String>,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub bot_agent: Option<String>,
26}
27
28#[repr(u8)]
29#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
30pub enum UploadMediaType {
31    Image = 1,
32    Video = 2,
33    File = 3,
34    Voice = 4,
35}
36
37#[repr(u8)]
38#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
39pub enum MessageType {
40    None = 0,
41    User = 1,
42    Bot = 2,
43}
44
45#[repr(u8)]
46#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
47pub enum MessageItemType {
48    None = 0,
49    Text = 1,
50    Image = 2,
51    Voice = 3,
52    File = 4,
53    Video = 5,
54}
55
56#[repr(u8)]
57#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
58pub enum MessageState {
59    New = 0,
60    Generating = 1,
61    Finish = 2,
62}
63
64#[repr(u8)]
65#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
66pub enum TypingStatus {
67    Typing = 1,
68    Cancel = 2,
69}
70
71#[derive(Clone, Debug, Default, Serialize, Deserialize)]
72pub struct TextItem {
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub text: Option<String>,
75}
76
77#[derive(Clone, Debug, Default, Serialize, Deserialize)]
78pub struct CdnMedia {
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub encrypt_query_param: Option<String>,
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub aes_key: Option<String>,
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub encrypt_type: Option<i32>,
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub full_url: Option<String>,
87}
88
89#[derive(Clone, Debug, Default, Serialize, Deserialize)]
90pub struct ImageItem {
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub media: Option<CdnMedia>,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub thumb_media: Option<CdnMedia>,
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub aeskey: Option<String>,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub url: Option<String>,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub mid_size: Option<usize>,
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub thumb_size: Option<usize>,
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub thumb_height: Option<usize>,
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub thumb_width: Option<usize>,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub hd_size: Option<usize>,
109}
110
111#[derive(Clone, Debug, Default, Serialize, Deserialize)]
112pub struct VoiceItem {
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub media: Option<CdnMedia>,
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub encode_type: Option<i32>,
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub bits_per_sample: Option<i32>,
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub sample_rate: Option<i32>,
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub playtime: Option<i32>,
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub text: Option<String>,
125}
126
127#[derive(Clone, Debug, Default, Serialize, Deserialize)]
128pub struct FileItem {
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub media: Option<CdnMedia>,
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub file_name: Option<String>,
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub md5: Option<String>,
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub len: Option<String>,
137}
138
139#[derive(Clone, Debug, Default, Serialize, Deserialize)]
140pub struct VideoItem {
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub media: Option<CdnMedia>,
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub video_size: Option<usize>,
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub play_length: Option<i32>,
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub video_md5: Option<String>,
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub thumb_media: Option<CdnMedia>,
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub thumb_size: Option<usize>,
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub thumb_height: Option<usize>,
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub thumb_width: Option<usize>,
157}
158
159#[derive(Clone, Debug, Default, Serialize, Deserialize)]
160pub struct RefMessage {
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub message_item: Option<Box<MessageItem>>,
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub title: Option<String>,
165}
166
167#[derive(Clone, Debug, Default, Serialize, Deserialize)]
168pub struct MessageItem {
169    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
170    pub item_type: Option<i32>,
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub create_time_ms: Option<i64>,
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub update_time_ms: Option<i64>,
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub is_completed: Option<bool>,
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub msg_id: Option<String>,
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub ref_msg: Option<RefMessage>,
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub text_item: Option<TextItem>,
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub image_item: Option<ImageItem>,
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub voice_item: Option<VoiceItem>,
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub file_item: Option<FileItem>,
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub video_item: Option<VideoItem>,
191}
192
193#[derive(Clone, Debug, Default, Serialize, Deserialize)]
194pub struct WeixinMessage {
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub seq: Option<i64>,
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub message_id: Option<i64>,
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub from_user_id: Option<String>,
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub to_user_id: Option<String>,
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub client_id: Option<String>,
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub create_time_ms: Option<i64>,
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub update_time_ms: Option<i64>,
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub delete_time_ms: Option<i64>,
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub session_id: Option<String>,
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub group_id: Option<String>,
215    #[serde(skip_serializing_if = "Option::is_none")]
216    pub message_type: Option<i32>,
217    #[serde(skip_serializing_if = "Option::is_none")]
218    pub message_state: Option<i32>,
219    #[serde(skip_serializing_if = "Option::is_none")]
220    pub item_list: Option<Vec<MessageItem>>,
221    #[serde(skip_serializing_if = "Option::is_none")]
222    pub context_token: Option<String>,
223}
224
225#[derive(Clone, Debug, Default, Serialize, Deserialize)]
226pub struct GetUpdatesReq {
227    #[serde(skip_serializing_if = "Option::is_none")]
228    pub get_updates_buf: Option<String>,
229}
230
231#[derive(Clone, Debug, Default, Serialize, Deserialize)]
232pub struct GetUpdatesResp {
233    pub ret: Option<i32>,
234    pub errcode: Option<i32>,
235    pub errmsg: Option<String>,
236    pub msgs: Option<Vec<WeixinMessage>>,
237    pub get_updates_buf: Option<String>,
238    pub longpolling_timeout_ms: Option<u64>,
239}
240
241#[derive(Clone, Debug, Default, Serialize, Deserialize)]
242pub struct SendMessageReq {
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub msg: Option<WeixinMessage>,
245}
246
247#[derive(Clone, Debug, Default, Serialize, Deserialize)]
248pub struct GetUploadUrlReq {
249    pub filekey: Option<String>,
250    pub media_type: Option<i32>,
251    pub to_user_id: Option<String>,
252    pub rawsize: Option<usize>,
253    pub rawfilemd5: Option<String>,
254    pub filesize: Option<usize>,
255    pub thumb_rawsize: Option<usize>,
256    pub thumb_rawfilemd5: Option<String>,
257    pub thumb_filesize: Option<usize>,
258    pub no_need_thumb: Option<bool>,
259    pub aeskey: Option<String>,
260}
261
262#[derive(Clone, Debug, Default, Serialize, Deserialize)]
263pub struct GetUploadUrlResp {
264    pub upload_param: Option<String>,
265    pub thumb_upload_param: Option<String>,
266    pub upload_full_url: Option<String>,
267}
268
269#[derive(Clone, Debug, Default, Serialize, Deserialize)]
270pub struct SendTypingReq {
271    pub ilink_user_id: Option<String>,
272    pub typing_ticket: Option<String>,
273    pub status: Option<i32>,
274}
275
276#[derive(Clone, Debug, Default, Serialize, Deserialize)]
277pub struct SendTypingResp {
278    pub ret: Option<i32>,
279    pub errmsg: Option<String>,
280}
281#[derive(Clone, Debug, Default, Serialize, Deserialize)]
282pub struct GetConfigResp {
283    pub ret: Option<i32>,
284    pub errmsg: Option<String>,
285    pub typing_ticket: Option<String>,
286}
287#[derive(Clone, Debug, Default, Serialize, Deserialize)]
288pub struct NotifyStopResp {
289    pub ret: Option<i32>,
290    pub errmsg: Option<String>,
291}
292#[derive(Clone, Debug, Default, Serialize, Deserialize)]
293pub struct NotifyStartResp {
294    pub ret: Option<i32>,
295    pub errmsg: Option<String>,
296}
297
298#[derive(Clone, Debug)]
299pub struct WeixinApiOptions {
300    pub base_url: String,
301    pub token: Option<String>,
302    pub timeout_ms: Option<u64>,
303    pub long_poll_timeout_ms: Option<u64>,
304}
305
306impl WeixinApiOptions {
307    pub fn new(base_url: impl Into<String>, token: Option<String>) -> Self {
308        Self {
309            base_url: base_url.into(),
310            token,
311            timeout_ms: None,
312            long_poll_timeout_ms: None,
313        }
314    }
315}
316
317fn encode_version(version: &str) -> u32 {
318    let parts: Vec<u32> = version.split('.').map(|p| p.parse().unwrap_or(0)).collect();
319    ((parts.first().copied().unwrap_or(0) & 0xff) << 16)
320        | ((parts.get(1).copied().unwrap_or(0) & 0xff) << 8)
321        | (parts.get(2).copied().unwrap_or(0) & 0xff)
322}
323
324pub fn build_base_info() -> BaseInfo {
325    BaseInfo {
326        channel_version: Some(PACKAGE_VERSION.to_string()),
327        bot_agent: Some(format!("weixin-bot-sdk/{PACKAGE_VERSION}")),
328    }
329}
330
331pub fn sanitize_bot_agent(raw: &str) -> String {
332    let trimmed = raw.trim();
333    let cleaned: String = trimmed
334        .chars()
335        .filter(|c| c.is_ascii() && !c.is_control())
336        .take(256)
337        .collect();
338    if cleaned.is_empty() {
339        format!("weixin-bot-sdk/{PACKAGE_VERSION}")
340    } else {
341        cleaned
342    }
343}
344
345fn common_headers(
346    req: reqwest::RequestBuilder,
347    token: Option<&str>,
348    json: bool,
349) -> reqwest::RequestBuilder {
350    let mut b = req.header("iLink-App-Id", APP_ID).header(
351        "iLink-App-ClientVersion",
352        encode_version(PACKAGE_VERSION).to_string(),
353    );
354    if json {
355        b = b
356            .header("Content-Type", "application/json")
357            .header("AuthorizationType", "ilink_bot_token")
358            .header("X-WECHAT-UIN", random_uint32_base64());
359    }
360    if let Some(t) = token.map(str::trim).filter(|t| !t.is_empty()) {
361        b = b.header("Authorization", format!("Bearer {t}"));
362    }
363    b
364}
365
366fn endpoint_url(base_url: &str, endpoint: &str) -> Result<String> {
367    let base = if base_url.ends_with('/') {
368        base_url.to_string()
369    } else {
370        format!("{base_url}/")
371    };
372    Ok(url::Url::parse(&base)?.join(endpoint)?.to_string())
373}
374
375pub async fn api_post_fetch(
376    base_url: &str,
377    endpoint: &str,
378    body: String,
379    token: Option<&str>,
380    timeout_ms: Option<u64>,
381    label: &str,
382) -> Result<String> {
383    let client = reqwest::Client::new();
384    let url = endpoint_url(base_url, endpoint)?;
385    let res = common_headers(
386        client.post(url).timeout(std::time::Duration::from_millis(
387            timeout_ms.unwrap_or(DEFAULT_API_TIMEOUT_MS),
388        )),
389        token,
390        true,
391    )
392    .body(body)
393    .send()
394    .await?;
395    let status = res.status();
396    let text = res.text().await?;
397    if !status.is_success() {
398        return Err(format!("{label} HTTP {status}: {text}").into());
399    }
400    Ok(text)
401}
402
403pub async fn api_get_fetch(
404    base_url: &str,
405    endpoint: &str,
406    timeout_ms: Option<u64>,
407    label: &str,
408) -> Result<String> {
409    let client = reqwest::Client::new();
410    let url = endpoint_url(base_url, endpoint)?;
411    let res = common_headers(
412        client.get(url).timeout(std::time::Duration::from_millis(
413            timeout_ms.unwrap_or(DEFAULT_API_TIMEOUT_MS),
414        )),
415        None,
416        false,
417    )
418    .send()
419    .await?;
420    let status = res.status();
421    let text = res.text().await?;
422    if !status.is_success() {
423        return Err(format!("{label} HTTP {status}: {text}").into());
424    }
425    Ok(text)
426}
427
428fn is_request_timeout(err: &(dyn std::error::Error + Send + Sync + 'static)) -> bool {
429    err.downcast_ref::<reqwest::Error>()
430        .map(|err| err.is_timeout())
431        .unwrap_or(false)
432}
433
434pub async fn get_updates(params: GetUpdatesReq, opts: &WeixinApiOptions) -> Result<GetUpdatesResp> {
435    let get_updates_buf = params.get_updates_buf.unwrap_or_default();
436    let timeout = opts
437        .long_poll_timeout_ms
438        .or(opts.timeout_ms)
439        .unwrap_or(DEFAULT_LONG_POLL_TIMEOUT_MS);
440    let raw = match api_post_fetch(
441        &opts.base_url,
442        "ilink/bot/getupdates",
443        json!({"get_updates_buf": get_updates_buf, "base_info": build_base_info()}).to_string(),
444        opts.token.as_deref(),
445        Some(timeout),
446        "getUpdates",
447    )
448    .await
449    {
450        Ok(raw) => raw,
451        Err(err) if is_request_timeout(err.as_ref()) => {
452            return Ok(GetUpdatesResp {
453                ret: Some(0),
454                errcode: None,
455                errmsg: None,
456                msgs: Some(Vec::new()),
457                get_updates_buf: Some(get_updates_buf),
458                longpolling_timeout_ms: None,
459            });
460        }
461        Err(err) => return Err(err),
462    };
463    Ok(serde_json::from_str(&raw)?)
464}
465
466pub async fn get_upload_url(
467    params: GetUploadUrlReq,
468    opts: &WeixinApiOptions,
469) -> Result<GetUploadUrlResp> {
470    let mut v = serde_json::to_value(params)?;
471    v.as_object_mut()
472        .unwrap()
473        .insert("base_info".into(), serde_json::to_value(build_base_info())?);
474    let raw = api_post_fetch(
475        &opts.base_url,
476        "ilink/bot/getuploadurl",
477        v.to_string(),
478        opts.token.as_deref(),
479        opts.timeout_ms,
480        "getUploadUrl",
481    )
482    .await?;
483    Ok(serde_json::from_str(&raw)?)
484}
485
486pub async fn send_message(params: SendMessageReq, opts: &WeixinApiOptions) -> Result<()> {
487    let mut v = serde_json::to_value(params)?;
488    v.as_object_mut()
489        .unwrap()
490        .insert("base_info".into(), serde_json::to_value(build_base_info())?);
491    api_post_fetch(
492        &opts.base_url,
493        "ilink/bot/sendmessage",
494        v.to_string(),
495        opts.token.as_deref(),
496        opts.timeout_ms,
497        "sendMessage",
498    )
499    .await?;
500    Ok(())
501}
502
503pub async fn get_config(
504    opts: &WeixinApiOptions,
505    ilink_user_id: &str,
506    context_token: Option<&str>,
507) -> Result<GetConfigResp> {
508    let raw = api_post_fetch(
509        &opts.base_url,
510        "ilink/bot/getconfig",
511        json!({
512            "ilink_user_id": ilink_user_id,
513            "context_token": context_token,
514            "base_info": build_base_info(),
515        })
516        .to_string(),
517        opts.token.as_deref(),
518        Some(DEFAULT_CONFIG_TIMEOUT_MS),
519        "getConfig",
520    )
521    .await?;
522    Ok(serde_json::from_str(&raw)?)
523}
524
525pub async fn send_typing(opts: &WeixinApiOptions, body: SendTypingReq) -> Result<SendTypingResp> {
526    let mut v = serde_json::to_value(body)?;
527    v.as_object_mut()
528        .unwrap()
529        .insert("base_info".into(), serde_json::to_value(build_base_info())?);
530    let raw = api_post_fetch(
531        &opts.base_url,
532        "ilink/bot/sendtyping",
533        v.to_string(),
534        opts.token.as_deref(),
535        Some(DEFAULT_CONFIG_TIMEOUT_MS),
536        "sendTyping",
537    )
538    .await?;
539    Ok(serde_json::from_str(&raw)?)
540}
541
542pub async fn notify_start(opts: &WeixinApiOptions) -> Result<NotifyStartResp> {
543    let raw = api_post_fetch(
544        &opts.base_url,
545        "ilink/bot/msg/notifystart",
546        json!({"base_info": build_base_info()}).to_string(),
547        opts.token.as_deref(),
548        Some(DEFAULT_CONFIG_TIMEOUT_MS),
549        "notifyStart",
550    )
551    .await?;
552    Ok(serde_json::from_str(&raw)?)
553}
554
555pub async fn notify_stop(opts: &WeixinApiOptions) -> Result<NotifyStopResp> {
556    let raw = api_post_fetch(
557        &opts.base_url,
558        "ilink/bot/msg/notifystop",
559        json!({"base_info": build_base_info()}).to_string(),
560        opts.token.as_deref(),
561        Some(DEFAULT_CONFIG_TIMEOUT_MS),
562        "notifyStop",
563    )
564    .await?;
565    Ok(serde_json::from_str(&raw)?)
566}