1use base64::Engine;
4use rand::Rng;
5use reqwest::Client;
6use serde::{Deserialize, Serialize};
7use serde_json::{json, Value};
8use std::time::Duration;
9use tracing::debug;
10use uuid::Uuid;
11
12use crate::error::{Result, WechatIlinkError};
13use crate::markdown_filter::filter_markdown;
14#[allow(unused_imports)]
15use crate::types::*;
16
17pub const DEFAULT_BASE_URL: &str = "https://ilinkai.weixin.qq.com";
18pub const CDN_BASE_URL: &str = "https://novac2c.cdn.weixin.qq.com/c2c";
19pub const CHANNEL_VERSION: &str = env!("CARGO_PKG_VERSION");
20pub const DEFAULT_BOT_AGENT: &str = "OpenClaw";
21pub const DEFAULT_ILINK_APP_ID: &str = "bot";
22pub const DEFAULT_RATE_LIMIT_RETRY_AFTER: Duration = Duration::from_secs(90);
23
24const RATE_LIMIT_ERRCODE: i32 = -2;
25
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
28pub struct BaseInfo {
29 pub channel_version: String,
30 #[serde(skip_serializing_if = "Option::is_none")]
31 pub bot_agent: Option<String>,
32}
33
34#[derive(Debug, Clone)]
36pub struct ILinkClientOptions {
37 pub bot_agent: Option<String>,
38 pub route_tag: Option<String>,
39 pub ilink_app_id: Option<String>,
40 pub markdown_filter: bool,
41}
42
43impl Default for ILinkClientOptions {
44 fn default() -> Self {
45 Self {
46 bot_agent: None,
47 route_tag: None,
48 ilink_app_id: None,
49 markdown_filter: true,
50 }
51 }
52}
53
54fn build_client_version() -> String {
56 let version = env!("CARGO_PKG_VERSION");
57 let parts: Vec<u32> = version.split('.').filter_map(|p| p.parse().ok()).collect();
58 let major = parts.first().copied().unwrap_or(0) & 0xff;
59 let minor = parts.get(1).copied().unwrap_or(0) & 0xff;
60 let patch = parts.get(2).copied().unwrap_or(0) & 0xff;
61 let num = (major << 16) | (minor << 8) | patch;
62 num.to_string()
63}
64
65pub fn random_wechat_uin() -> String {
67 let mut buf = [0u8; 4];
68 rand::rng().fill_bytes(&mut buf);
69 let val = u32::from_be_bytes(buf);
70 base64::engine::general_purpose::STANDARD.encode(val.to_string())
71}
72
73#[derive(Debug, Deserialize)]
75pub struct QrCodeResponse {
76 pub qrcode: String,
77 pub qrcode_img_content: String,
78}
79
80#[derive(Debug, Deserialize)]
82pub struct QrStatusResponse {
83 pub status: String,
84 pub bot_token: Option<String>,
85 pub ilink_bot_id: Option<String>,
86 pub ilink_user_id: Option<String>,
87 pub baseurl: Option<String>,
88 pub redirect_host: Option<String>,
90}
91
92#[derive(Debug, Deserialize)]
94pub struct GetUpdatesResponse {
95 #[serde(default)]
96 pub ret: i32,
97 #[serde(default)]
98 pub msgs: Vec<WireMessage>,
99 pub sync_buf: Option<String>,
100 #[serde(default)]
101 pub get_updates_buf: String,
102 pub longpolling_timeout_ms: Option<i64>,
103 pub errcode: Option<i32>,
104 pub errmsg: Option<String>,
105}
106
107#[derive(Debug, Deserialize)]
109pub struct GetConfigResponse {
110 pub ret: Option<i32>,
111 pub errmsg: Option<String>,
112 pub typing_ticket: Option<String>,
113}
114
115#[derive(Debug, Deserialize)]
117pub struct NotifyStartResponse {
118 pub ret: Option<i32>,
119 pub errmsg: Option<String>,
120}
121
122#[derive(Debug, Deserialize)]
124pub struct NotifyStopResponse {
125 pub ret: Option<i32>,
126 pub errmsg: Option<String>,
127}
128
129#[derive(Debug)]
131pub struct ILinkClient {
132 http: Client,
133 options: ILinkClientOptions,
134}
135
136fn default_http_client() -> Client {
137 Client::builder()
138 .timeout(Duration::from_secs(45))
139 .build()
140 .unwrap()
141}
142
143impl ILinkClient {
144 pub fn new() -> Self {
145 Self::with_options(ILinkClientOptions::default())
146 }
147
148 pub fn with_options(options: ILinkClientOptions) -> Self {
149 Self::with_http_client_and_options(default_http_client(), options)
150 }
151
152 pub fn with_http_client(http: Client) -> Self {
153 Self::with_http_client_and_options(http, ILinkClientOptions::default())
154 }
155
156 pub fn with_http_client_and_options(http: Client, options: ILinkClientOptions) -> Self {
157 Self { http, options }
158 }
159
160 pub async fn get_qr_code(&self, base_url: &str) -> Result<QrCodeResponse> {
161 self.get_qr_code_with_local_tokens(base_url, &[]).await
162 }
163
164 pub async fn get_qr_code_with_local_tokens(
165 &self,
166 base_url: &str,
167 local_token_list: &[String],
168 ) -> Result<QrCodeResponse> {
169 let url = format!("{}/ilink/bot/get_bot_qrcode?bot_type=3", base_url);
170 let resp = self
171 .apply_common_headers(self.http.post(&url))
172 .json(&json!({ "local_token_list": local_token_list }))
173 .send()
174 .await?;
175 Ok(resp.json().await?)
176 }
177
178 pub async fn poll_qr_status(&self, base_url: &str, qrcode: &str) -> Result<QrStatusResponse> {
179 self.poll_qr_status_with_verify_code(base_url, qrcode, None)
180 .await
181 }
182
183 pub async fn poll_qr_status_with_verify_code(
184 &self,
185 base_url: &str,
186 qrcode: &str,
187 verify_code: Option<&str>,
188 ) -> Result<QrStatusResponse> {
189 let url = format!(
190 "{}{}",
191 base_url,
192 build_qr_status_endpoint(qrcode, verify_code)
193 );
194 let resp = match self
195 .apply_common_headers(self.http.get(&url))
196 .timeout(Duration::from_secs(35))
197 .send()
198 .await
199 {
200 Ok(resp) => resp,
201 Err(err) if err.is_timeout() => return Ok(wait_qr_status_response()),
202 Err(err) => return Err(err.into()),
203 };
204 Ok(resp.json().await?)
205 }
206
207 pub async fn get_updates(
208 &self,
209 base_url: &str,
210 token: &str,
211 cursor: &str,
212 ) -> Result<GetUpdatesResponse> {
213 let body = json!({
214 "get_updates_buf": cursor,
215 "base_info": self.base_info()
216 });
217 let resp = match self
218 .api_post(base_url, "/ilink/bot/getupdates", token, &body, 45)
219 .await
220 {
221 Ok(resp) => resp,
222 Err(WechatIlinkError::Transport(err)) if err.is_timeout() => {
223 return Ok(empty_updates_response(cursor));
224 }
225 Err(err) => return Err(err),
226 };
227 log_raw_getupdates_response(&resp);
228 let result: GetUpdatesResponse = serde_json::from_value(resp)?;
229 if result.ret != 0 || result.errcode.is_some_and(|c| c != 0) {
230 let code = result.errcode.unwrap_or(result.ret);
231 let msg = result
232 .errmsg
233 .unwrap_or_else(|| format!("ret={}", result.ret));
234 return Err(api_error(msg, 200, code));
235 }
236 Ok(result)
237 }
238
239 pub async fn send_message(&self, base_url: &str, token: &str, msg: &Value) -> Result<Value> {
242 let body = json!({
243 "msg": msg,
244 "base_info": self.base_info()
245 });
246 self.api_post(base_url, "/ilink/bot/sendmessage", token, &body, 15)
247 .await
248 }
249
250 pub async fn get_config(
251 &self,
252 base_url: &str,
253 token: &str,
254 user_id: &str,
255 context_token: &str,
256 ) -> Result<GetConfigResponse> {
257 let body = json!({
258 "ilink_user_id": user_id,
259 "context_token": context_token,
260 "base_info": self.base_info()
261 });
262 let resp = self
263 .api_post(base_url, "/ilink/bot/getconfig", token, &body, 15)
264 .await?;
265 Ok(serde_json::from_value(resp)?)
266 }
267
268 pub async fn send_typing(
269 &self,
270 base_url: &str,
271 token: &str,
272 user_id: &str,
273 ticket: &str,
274 status: i32,
275 ) -> Result<()> {
276 let body = json!({
277 "ilink_user_id": user_id,
278 "typing_ticket": ticket,
279 "status": status,
280 "base_info": self.base_info()
281 });
282 self.api_post(base_url, "/ilink/bot/sendtyping", token, &body, 15)
283 .await?;
284 Ok(())
285 }
286
287 pub async fn notify_start(&self, base_url: &str, token: &str) -> Result<NotifyStartResponse> {
288 let body = json!({ "base_info": self.base_info() });
289 let resp = self
290 .api_post(base_url, "/ilink/bot/msg/notifystart", token, &body, 10)
291 .await?;
292 Ok(serde_json::from_value(resp)?)
293 }
294
295 pub async fn notify_stop(&self, base_url: &str, token: &str) -> Result<NotifyStopResponse> {
296 let body = json!({ "base_info": self.base_info() });
297 let resp = self
298 .api_post(base_url, "/ilink/bot/msg/notifystop", token, &body, 10)
299 .await?;
300 Ok(serde_json::from_value(resp)?)
301 }
302
303 fn base_info(&self) -> BaseInfo {
304 BaseInfo {
305 channel_version: CHANNEL_VERSION.to_string(),
306 bot_agent: Some(sanitize_bot_agent(self.options.bot_agent.as_deref())),
307 }
308 }
309
310 fn apply_common_headers(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
311 let app_id = self
312 .options
313 .ilink_app_id
314 .as_deref()
315 .filter(|value| !value.is_empty())
316 .unwrap_or(DEFAULT_ILINK_APP_ID);
317 let request = request
318 .header("iLink-App-Id", app_id)
319 .header("iLink-App-ClientVersion", build_client_version());
320 if let Some(route_tag) = self
321 .options
322 .route_tag
323 .as_deref()
324 .filter(|tag| !tag.is_empty())
325 {
326 request.header("SKRouteTag", route_tag)
327 } else {
328 request
329 }
330 }
331
332 async fn api_post(
333 &self,
334 base_url: &str,
335 endpoint: &str,
336 token: &str,
337 body: &Value,
338 timeout_secs: u64,
339 ) -> Result<Value> {
340 let url = format!("{}{}", base_url, endpoint);
341 let request = self
342 .apply_common_headers(
343 self.http
344 .post(&url)
345 .timeout(Duration::from_secs(timeout_secs))
346 .header("Content-Type", "application/json")
347 .header("AuthorizationType", "ilink_bot_token")
348 .header("Authorization", format!("Bearer {}", token))
349 .header("X-WECHAT-UIN", random_wechat_uin()),
350 )
351 .json(body);
352 let resp = request.send().await?;
353
354 let status = resp.status().as_u16();
355 let text = resp.text().await?;
356 let value: Value = serde_json::from_str(&text).unwrap_or(json!({}));
357
358 if status >= 400 {
359 let code = value["errcode"].as_i64().unwrap_or(0) as i32;
360 return Err(api_error(
361 api_error_message(&value, &text, code),
362 status,
363 code,
364 ));
365 }
366
367 let api_error_code = value["errcode"]
368 .as_i64()
369 .filter(|code| *code != 0)
370 .or_else(|| value["ret"].as_i64().filter(|code| *code != 0));
371 if let Some(code) = api_error_code {
372 let code = code as i32;
373 return Err(api_error(
374 api_error_message(&value, &text, code),
375 status,
376 code,
377 ));
378 }
379
380 Ok(value)
381 }
382}
383
384fn api_error(message: String, http_status: u16, errcode: i32) -> WechatIlinkError {
385 if errcode == RATE_LIMIT_ERRCODE {
386 WechatIlinkError::RateLimited {
387 retry_after: DEFAULT_RATE_LIMIT_RETRY_AFTER,
388 message,
389 http_status,
390 errcode,
391 }
392 } else {
393 WechatIlinkError::Api {
394 message,
395 http_status,
396 errcode,
397 }
398 }
399}
400
401fn api_error_message(value: &Value, raw_body: &str, code: i32) -> String {
402 value["errmsg"]
403 .as_str()
404 .or_else(|| value["message"].as_str())
405 .map(ToString::to_string)
406 .unwrap_or_else(|| {
407 if code != 0 || raw_body.trim().is_empty() || raw_body.trim() == "{}" {
408 format!("ret={code}")
409 } else {
410 raw_body.to_string()
411 }
412 })
413}
414
415fn log_raw_getupdates_response(resp: &Value) {
416 let Some(msgs) = resp.get("msgs").and_then(Value::as_array) else {
417 return;
418 };
419 if msgs.is_empty() {
420 return;
421 }
422 let raw = resp.to_string();
423 let truncated = raw.chars().count() > 24_000;
424 let raw = if truncated {
425 raw.chars().take(24_000).collect::<String>()
426 } else {
427 raw
428 };
429 debug!(
430 target: "wechat_ilink::protocol",
431 msg_count = msgs.len(),
432 bytes = raw.len(),
433 truncated,
434 raw_json = %raw,
435 "raw getupdates response"
436 );
437}
438
439fn sanitize_bot_agent(raw: Option<&str>) -> String {
440 let Some(raw) = raw else {
441 return DEFAULT_BOT_AGENT.to_string();
442 };
443 let raw = raw.trim();
444 if raw.is_empty() {
445 return DEFAULT_BOT_AGENT.to_string();
446 }
447
448 let raw_tokens = raw.split_whitespace().collect::<Vec<_>>();
449 let mut tokens = Vec::new();
450 let mut i = 0;
451 while i < raw_tokens.len() {
452 let token = raw_tokens[i];
453 if token.starts_with('(') && !token.ends_with(')') {
454 let mut comment = token.to_string();
455 while i + 1 < raw_tokens.len() && !comment.ends_with(')') {
456 i += 1;
457 comment.push(' ');
458 comment.push_str(raw_tokens[i]);
459 }
460 tokens.push(comment);
461 } else {
462 tokens.push(token.to_string());
463 }
464 i += 1;
465 }
466
467 let mut accepted = Vec::new();
468 let mut pending_product: Option<String> = None;
469 for token in tokens {
470 if token.starts_with('(') && token.ends_with(')') {
471 let comment = &token[1..token.len() - 1];
472 if let Some(product) = pending_product.take() {
473 if is_bot_agent_comment(comment) {
474 accepted.push(format!("{product} ({comment})"));
475 } else {
476 accepted.push(product);
477 }
478 }
479 continue;
480 }
481 if let Some(product) = pending_product.take() {
482 accepted.push(product);
483 }
484 if is_bot_agent_product(&token) {
485 pending_product = Some(token);
486 }
487 }
488 if let Some(product) = pending_product {
489 accepted.push(product);
490 }
491 if accepted.is_empty() {
492 return DEFAULT_BOT_AGENT.to_string();
493 }
494
495 let mut truncated = Vec::new();
496 let mut len = 0;
497 for token in accepted {
498 let add = if truncated.is_empty() { 0 } else { 1 } + token.len();
499 if len + add > 256 {
500 break;
501 }
502 len += add;
503 truncated.push(token);
504 }
505 if truncated.is_empty() {
506 DEFAULT_BOT_AGENT.to_string()
507 } else {
508 truncated.join(" ")
509 }
510}
511
512fn is_bot_agent_product(token: &str) -> bool {
513 let Some((name, version)) = token.split_once('/') else {
514 return false;
515 };
516 !name.is_empty()
517 && !version.is_empty()
518 && name.len() <= 32
519 && version.len() <= 32
520 && name
521 .chars()
522 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '-'))
523 && version
524 .chars()
525 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '+' | '-'))
526}
527
528fn is_bot_agent_comment(comment: &str) -> bool {
529 !comment.is_empty()
530 && comment.len() <= 64
531 && comment
532 .chars()
533 .all(|ch| ch.is_ascii() && (' '..='~').contains(&ch) && ch != '(' && ch != ')')
534}
535
536fn empty_updates_response(cursor: &str) -> GetUpdatesResponse {
537 GetUpdatesResponse {
538 ret: 0,
539 msgs: Vec::new(),
540 sync_buf: None,
541 get_updates_buf: cursor.to_string(),
542 longpolling_timeout_ms: None,
543 errcode: None,
544 errmsg: None,
545 }
546}
547
548fn build_qr_status_endpoint(qrcode: &str, verify_code: Option<&str>) -> String {
549 let mut endpoint = format!(
550 "/ilink/bot/get_qrcode_status?qrcode={}",
551 urlencoding::encode(qrcode)
552 );
553 if let Some(code) = verify_code.filter(|value| !value.is_empty()) {
554 endpoint.push_str("&verify_code=");
555 endpoint.push_str(&urlencoding::encode(code));
556 }
557 endpoint
558}
559
560fn wait_qr_status_response() -> QrStatusResponse {
561 QrStatusResponse {
562 status: "wait".to_string(),
563 bot_token: None,
564 ilink_bot_id: None,
565 ilink_user_id: None,
566 baseurl: None,
567 redirect_host: None,
568 }
569}
570
571pub fn build_media_message(user_id: &str, context_token: &str, item_list: Vec<Value>) -> Value {
573 build_media_message_with_client_id(
574 user_id,
575 context_token,
576 item_list,
577 &Uuid::new_v4().to_string(),
578 )
579}
580
581pub fn build_media_message_with_client_id(
583 user_id: &str,
584 context_token: &str,
585 item_list: Vec<Value>,
586 client_id: &str,
587) -> Value {
588 let item_list = item_list
589 .into_iter()
590 .map(|mut item| {
591 if let Some(object) = item.as_object_mut() {
592 object
593 .entry("msg_id")
594 .or_insert_with(|| Value::String(client_id.to_string()));
595 }
596 item
597 })
598 .collect::<Vec<_>>();
599 json!({
600 "from_user_id": "",
601 "to_user_id": user_id,
602 "client_id": client_id,
603 "message_type": 2,
604 "message_state": 2,
605 "context_token": context_token,
606 "item_list": item_list
607 })
608}
609
610pub struct GetUploadUrlParams {
612 pub filekey: String,
613 pub media_type: i32,
614 pub to_user_id: String,
615 pub rawsize: usize,
616 pub rawfilemd5: String,
617 pub filesize: usize,
618 pub thumb_rawsize: Option<usize>,
619 pub thumb_rawfilemd5: Option<String>,
620 pub thumb_filesize: Option<usize>,
621 pub no_need_thumb: bool,
622 pub aeskey: String,
623}
624
625#[derive(Debug, Deserialize)]
627pub struct GetUploadUrlResponse {
628 pub upload_param: Option<String>,
629 pub thumb_upload_param: Option<String>,
630 pub upload_full_url: Option<String>,
631}
632
633impl ILinkClient {
634 pub async fn get_upload_url(
636 &self,
637 base_url: &str,
638 token: &str,
639 params: &GetUploadUrlParams,
640 ) -> Result<GetUploadUrlResponse> {
641 let mut body = json!({
642 "filekey": params.filekey,
643 "media_type": params.media_type,
644 "to_user_id": params.to_user_id,
645 "rawsize": params.rawsize,
646 "rawfilemd5": params.rawfilemd5,
647 "filesize": params.filesize,
648 "no_need_thumb": params.no_need_thumb,
649 "aeskey": params.aeskey,
650 "base_info": self.base_info()
651 });
652 if let Some(value) = params.thumb_rawsize {
653 body["thumb_rawsize"] = json!(value);
654 }
655 if let Some(value) = ¶ms.thumb_rawfilemd5 {
656 body["thumb_rawfilemd5"] = json!(value);
657 }
658 if let Some(value) = params.thumb_filesize {
659 body["thumb_filesize"] = json!(value);
660 }
661 let resp = self
662 .api_post(base_url, "/ilink/bot/getuploadurl", token, &body, 15)
663 .await?;
664 Ok(serde_json::from_value(resp)?)
665 }
666
667 pub async fn upload_to_cdn(&self, cdn_url: &str, ciphertext: &[u8]) -> Result<String> {
670 const MAX_RETRIES: u32 = 3;
671 let mut last_err = None;
672
673 for attempt in 1..=MAX_RETRIES {
674 match self
675 .http
676 .post(cdn_url)
677 .header("Content-Type", "application/octet-stream")
678 .body(ciphertext.to_vec())
679 .send()
680 .await
681 {
682 Ok(resp) => {
683 let status = resp.status().as_u16();
684 if status >= 400 && status < 500 {
685 let err_msg = resp
686 .headers()
687 .get("x-error-message")
688 .and_then(|v| v.to_str().ok())
689 .unwrap_or("client error")
690 .to_string();
691 return Err(WechatIlinkError::Media(format!(
692 "CDN upload client error {}: {}",
693 status, err_msg
694 )));
695 }
696 if status != 200 {
697 let err_msg = resp
698 .headers()
699 .get("x-error-message")
700 .and_then(|v| v.to_str().ok())
701 .unwrap_or("server error")
702 .to_string();
703 last_err = Some(WechatIlinkError::Media(format!(
704 "CDN upload server error {}: {}",
705 status, err_msg
706 )));
707 continue;
708 }
709 match resp
710 .headers()
711 .get("x-encrypted-param")
712 .and_then(|v| v.to_str().ok())
713 {
714 Some(param) => return Ok(param.to_string()),
715 None => {
716 last_err = Some(WechatIlinkError::Media(
717 "CDN upload response missing x-encrypted-param header".into(),
718 ));
719 continue;
720 }
721 }
722 }
723 Err(e) => {
724 last_err = Some(WechatIlinkError::Other(format!(
725 "CDN upload network error: {}",
726 e
727 )));
728 if attempt < MAX_RETRIES {
729 continue;
730 }
731 }
732 }
733 }
734 Err(last_err.unwrap_or_else(|| {
735 WechatIlinkError::Media(format!("CDN upload failed after {} attempts", MAX_RETRIES))
736 }))
737 }
738
739 pub fn build_text_message_with_client_id(
741 &self,
742 user_id: &str,
743 context_token: &str,
744 text: &str,
745 client_id: &str,
746 ) -> Value {
747 let text = if self.options.markdown_filter {
748 filter_markdown(text)
749 } else {
750 text.to_string()
751 };
752 build_text_message_payload(user_id, context_token, &text, client_id)
753 }
754}
755
756pub fn build_cdn_upload_url(cdn_base_url: &str, upload_param: &str, filekey: &str) -> String {
758 format!(
759 "{}/upload?encrypted_query_param={}&filekey={}",
760 cdn_base_url,
761 urlencoding::encode(upload_param),
762 urlencoding::encode(filekey)
763 )
764}
765
766pub fn build_text_message(user_id: &str, context_token: &str, text: &str) -> Value {
768 build_text_message_with_client_id(user_id, context_token, text, &Uuid::new_v4().to_string())
769}
770
771pub fn build_text_message_with_client_id(
773 user_id: &str,
774 context_token: &str,
775 text: &str,
776 client_id: &str,
777) -> Value {
778 let text = filter_markdown(text);
779 build_text_message_payload(user_id, context_token, &text, client_id)
780}
781
782fn build_text_message_payload(
783 user_id: &str,
784 context_token: &str,
785 text: &str,
786 client_id: &str,
787) -> Value {
788 json!({
789 "from_user_id": "",
790 "to_user_id": user_id,
791 "client_id": client_id,
792 "message_type": 2,
793 "message_state": 2,
794 "context_token": context_token,
795 "item_list": [{ "type": 1, "msg_id": client_id, "text_item": { "text": text } }]
796 })
797}
798
799#[cfg(test)]
800mod tests {
801 use super::*;
802
803 #[test]
804 fn ilink_client_accepts_caller_provided_reqwest_client() {
805 let http_client = reqwest::Client::new();
806 let client = ILinkClient::with_http_client_and_options(
807 http_client,
808 ILinkClientOptions {
809 bot_agent: Some("Amux/0.1".to_string()),
810 ..ILinkClientOptions::default()
811 },
812 );
813
814 let base_info = client.base_info();
815 assert_eq!(base_info.bot_agent.as_deref(), Some("Amux/0.1"));
816 }
817
818 #[test]
819 fn base_info_preserves_bot_agent_when_configured() {
820 let client = ILinkClient::with_options(ILinkClientOptions {
821 bot_agent: Some("Amux/0.1".to_string()),
822 ..ILinkClientOptions::default()
823 });
824
825 let base_info = client.base_info();
826 assert_eq!(base_info.channel_version, CHANNEL_VERSION);
827 assert_eq!(base_info.bot_agent.as_deref(), Some("Amux/0.1"));
828 }
829
830 #[test]
831 fn base_info_defaults_bot_agent() {
832 let client = ILinkClient::new();
833
834 let base_info = client.base_info();
835 assert_eq!(base_info.bot_agent.as_deref(), Some("OpenClaw"));
836 }
837
838 #[test]
839 fn base_info_sanitizes_bot_agent() {
840 let client = ILinkClient::with_options(ILinkClientOptions {
841 bot_agent: Some(" Amux/0.1\tBad Agent\r\n".to_string()),
842 ..ILinkClientOptions::default()
843 });
844
845 let base_info = client.base_info();
846 assert_eq!(base_info.bot_agent.as_deref(), Some("Amux/0.1"));
847 }
848
849 #[test]
850 fn base_info_preserves_valid_bot_agent_comment() {
851 let client = ILinkClient::with_options(ILinkClientOptions {
852 bot_agent: Some("Amux/0.1 (worker channel) Other/2.0".to_string()),
853 ..ILinkClientOptions::default()
854 });
855
856 let base_info = client.base_info();
857 assert_eq!(
858 base_info.bot_agent.as_deref(),
859 Some("Amux/0.1 (worker channel) Other/2.0")
860 );
861 }
862
863 #[test]
864 fn empty_updates_response_preserves_cursor() {
865 let response = empty_updates_response("cursor-1");
866
867 assert_eq!(response.ret, 0);
868 assert!(response.msgs.is_empty());
869 assert_eq!(response.get_updates_buf, "cursor-1");
870 }
871
872 #[tokio::test]
873 async fn send_message_rejects_nonzero_ret_response() {
874 let (base_url, server) =
875 json_response_server(r#"{"ret":40003,"errmsg":"invalid context_token"}"#);
876 let client = ILinkClient::new();
877
878 let err = client
879 .send_message(&base_url, "token", &json!({"text": "hello"}))
880 .await
881 .expect_err("nonzero ret must be treated as an API error");
882
883 match err {
884 WechatIlinkError::Api {
885 message,
886 http_status,
887 errcode,
888 } => {
889 assert_eq!(message, "invalid context_token");
890 assert_eq!(http_status, 200);
891 assert_eq!(errcode, 40003);
892 }
893 other => panic!("expected API error, got {other:?}"),
894 }
895 server.join().expect("response server should exit");
896 }
897
898 #[tokio::test]
899 async fn send_message_maps_ret_minus_two_to_rate_limited() {
900 let (base_url, server) = json_response_server(r#"{"ret":-2}"#);
901 let client = ILinkClient::new();
902
903 let err = client
904 .send_message(&base_url, "token", &json!({"text": "hello"}))
905 .await
906 .expect_err("ret=-2 must be treated as rate limiting");
907
908 match err {
909 WechatIlinkError::RateLimited {
910 retry_after,
911 message,
912 http_status,
913 errcode,
914 } => {
915 assert_eq!(retry_after, Duration::from_secs(90));
916 assert_eq!(message, "ret=-2");
917 assert_eq!(http_status, 200);
918 assert_eq!(errcode, -2);
919 }
920 other => panic!("expected rate limit error, got {other:?}"),
921 }
922 server.join().expect("response server should exit");
923 }
924
925 #[test]
926 fn qr_status_endpoint_includes_verify_code_when_present() {
927 let endpoint = build_qr_status_endpoint("qr value", Some("12 34"));
928
929 assert_eq!(
930 endpoint,
931 "/ilink/bot/get_qrcode_status?qrcode=qr%20value&verify_code=12%2034"
932 );
933 }
934
935 #[test]
936 fn wait_qr_status_response_is_wait() {
937 let response = wait_qr_status_response();
938
939 assert_eq!(response.status, "wait");
940 assert!(response.bot_token.is_none());
941 }
942
943 #[test]
944 fn common_headers_preserve_route_tag_when_configured() {
945 let client = ILinkClient::with_options(ILinkClientOptions {
946 route_tag: Some("route-a".to_string()),
947 ..ILinkClientOptions::default()
948 });
949
950 let request = client
951 .apply_common_headers(client.http.get("https://example.test"))
952 .build()
953 .expect("request");
954 assert_eq!(
955 request
956 .headers()
957 .get("SKRouteTag")
958 .and_then(|value| value.to_str().ok()),
959 Some("route-a")
960 );
961 }
962
963 #[test]
964 fn common_headers_use_configured_ilink_app_id() {
965 let client = ILinkClient::with_options(ILinkClientOptions {
966 ilink_app_id: Some("custom-app".to_string()),
967 ..ILinkClientOptions::default()
968 });
969
970 let request = client
971 .apply_common_headers(client.http.get("https://example.test"))
972 .build()
973 .expect("request");
974 assert_eq!(
975 request
976 .headers()
977 .get("iLink-App-Id")
978 .and_then(|value| value.to_str().ok()),
979 Some("custom-app")
980 );
981 }
982
983 #[test]
984 fn text_message_builder_can_disable_markdown_filter() {
985 let client = ILinkClient::with_options(ILinkClientOptions {
986 markdown_filter: false,
987 ..ILinkClientOptions::default()
988 });
989
990 let msg = client.build_text_message_with_client_id("user-1", "ctx-1", "# title", "msg-1");
991 assert_eq!(msg["item_list"][0]["text_item"]["text"], "# title");
992 }
993
994 fn json_response_server(body: &'static str) -> (String, std::thread::JoinHandle<()>) {
995 use std::io::{Read, Write};
996 use std::net::TcpListener;
997
998 let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
999 let addr = listener.local_addr().expect("test server addr");
1000 let server = std::thread::spawn(move || {
1001 let (mut stream, _) = listener.accept().expect("accept test request");
1002 stream
1003 .set_read_timeout(Some(Duration::from_secs(2)))
1004 .expect("set read timeout");
1005 let mut buf = [0u8; 4096];
1006 let _ = stream.read(&mut buf);
1007 let response = format!(
1008 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1009 body.len(),
1010 body
1011 );
1012 stream
1013 .write_all(response.as_bytes())
1014 .expect("write test response");
1015 });
1016 (format!("http://{addr}"), server)
1017 }
1018}