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}