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<()> {
240 let body = json!({
241 "msg": msg,
242 "base_info": self.base_info()
243 });
244 self.api_post(base_url, "/ilink/bot/sendmessage", token, &body, 15)
245 .await?;
246 Ok(())
247 }
248
249 pub async fn get_config(
250 &self,
251 base_url: &str,
252 token: &str,
253 user_id: &str,
254 context_token: &str,
255 ) -> Result<GetConfigResponse> {
256 let body = json!({
257 "ilink_user_id": user_id,
258 "context_token": context_token,
259 "base_info": self.base_info()
260 });
261 let resp = self
262 .api_post(base_url, "/ilink/bot/getconfig", token, &body, 15)
263 .await?;
264 Ok(serde_json::from_value(resp)?)
265 }
266
267 pub async fn send_typing(
268 &self,
269 base_url: &str,
270 token: &str,
271 user_id: &str,
272 ticket: &str,
273 status: i32,
274 ) -> Result<()> {
275 let body = json!({
276 "ilink_user_id": user_id,
277 "typing_ticket": ticket,
278 "status": status,
279 "base_info": self.base_info()
280 });
281 self.api_post(base_url, "/ilink/bot/sendtyping", token, &body, 15)
282 .await?;
283 Ok(())
284 }
285
286 pub async fn notify_start(&self, base_url: &str, token: &str) -> Result<NotifyStartResponse> {
287 let body = json!({ "base_info": self.base_info() });
288 let resp = self
289 .api_post(base_url, "/ilink/bot/msg/notifystart", token, &body, 10)
290 .await?;
291 Ok(serde_json::from_value(resp)?)
292 }
293
294 pub async fn notify_stop(&self, base_url: &str, token: &str) -> Result<NotifyStopResponse> {
295 let body = json!({ "base_info": self.base_info() });
296 let resp = self
297 .api_post(base_url, "/ilink/bot/msg/notifystop", token, &body, 10)
298 .await?;
299 Ok(serde_json::from_value(resp)?)
300 }
301
302 fn base_info(&self) -> BaseInfo {
303 BaseInfo {
304 channel_version: CHANNEL_VERSION.to_string(),
305 bot_agent: Some(sanitize_bot_agent(self.options.bot_agent.as_deref())),
306 }
307 }
308
309 fn apply_common_headers(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
310 let app_id = self
311 .options
312 .ilink_app_id
313 .as_deref()
314 .filter(|value| !value.is_empty())
315 .unwrap_or(DEFAULT_ILINK_APP_ID);
316 let request = request
317 .header("iLink-App-Id", app_id)
318 .header("iLink-App-ClientVersion", build_client_version());
319 if let Some(route_tag) = self
320 .options
321 .route_tag
322 .as_deref()
323 .filter(|tag| !tag.is_empty())
324 {
325 request.header("SKRouteTag", route_tag)
326 } else {
327 request
328 }
329 }
330
331 async fn api_post(
332 &self,
333 base_url: &str,
334 endpoint: &str,
335 token: &str,
336 body: &Value,
337 timeout_secs: u64,
338 ) -> Result<Value> {
339 let url = format!("{}{}", base_url, endpoint);
340 let request = self
341 .apply_common_headers(
342 self.http
343 .post(&url)
344 .timeout(Duration::from_secs(timeout_secs))
345 .header("Content-Type", "application/json")
346 .header("AuthorizationType", "ilink_bot_token")
347 .header("Authorization", format!("Bearer {}", token))
348 .header("X-WECHAT-UIN", random_wechat_uin()),
349 )
350 .json(body);
351 let resp = request.send().await?;
352
353 let status = resp.status().as_u16();
354 let text = resp.text().await?;
355 let value: Value = serde_json::from_str(&text).unwrap_or(json!({}));
356
357 if status >= 400 {
358 let code = value["errcode"].as_i64().unwrap_or(0) as i32;
359 return Err(api_error(
360 api_error_message(&value, &text, code),
361 status,
362 code,
363 ));
364 }
365
366 let api_error_code = value["errcode"]
367 .as_i64()
368 .filter(|code| *code != 0)
369 .or_else(|| value["ret"].as_i64().filter(|code| *code != 0));
370 if let Some(code) = api_error_code {
371 let code = code as i32;
372 return Err(api_error(
373 api_error_message(&value, &text, code),
374 status,
375 code,
376 ));
377 }
378
379 Ok(value)
380 }
381}
382
383fn api_error(message: String, http_status: u16, errcode: i32) -> WechatIlinkError {
384 if errcode == RATE_LIMIT_ERRCODE {
385 WechatIlinkError::RateLimited {
386 retry_after: DEFAULT_RATE_LIMIT_RETRY_AFTER,
387 message,
388 http_status,
389 errcode,
390 }
391 } else {
392 WechatIlinkError::Api {
393 message,
394 http_status,
395 errcode,
396 }
397 }
398}
399
400fn api_error_message(value: &Value, raw_body: &str, code: i32) -> String {
401 value["errmsg"]
402 .as_str()
403 .or_else(|| value["message"].as_str())
404 .map(ToString::to_string)
405 .unwrap_or_else(|| {
406 if code != 0 || raw_body.trim().is_empty() || raw_body.trim() == "{}" {
407 format!("ret={code}")
408 } else {
409 raw_body.to_string()
410 }
411 })
412}
413
414fn log_raw_getupdates_response(resp: &Value) {
415 let Some(msgs) = resp.get("msgs").and_then(Value::as_array) else {
416 return;
417 };
418 if msgs.is_empty() {
419 return;
420 }
421 let raw = resp.to_string();
422 let truncated = raw.chars().count() > 24_000;
423 let raw = if truncated {
424 raw.chars().take(24_000).collect::<String>()
425 } else {
426 raw
427 };
428 debug!(
429 target: "wechat_ilink::protocol",
430 msg_count = msgs.len(),
431 bytes = raw.len(),
432 truncated,
433 raw_json = %raw,
434 "raw getupdates response"
435 );
436}
437
438fn sanitize_bot_agent(raw: Option<&str>) -> String {
439 let Some(raw) = raw else {
440 return DEFAULT_BOT_AGENT.to_string();
441 };
442 let raw = raw.trim();
443 if raw.is_empty() {
444 return DEFAULT_BOT_AGENT.to_string();
445 }
446
447 let raw_tokens = raw.split_whitespace().collect::<Vec<_>>();
448 let mut tokens = Vec::new();
449 let mut i = 0;
450 while i < raw_tokens.len() {
451 let token = raw_tokens[i];
452 if token.starts_with('(') && !token.ends_with(')') {
453 let mut comment = token.to_string();
454 while i + 1 < raw_tokens.len() && !comment.ends_with(')') {
455 i += 1;
456 comment.push(' ');
457 comment.push_str(raw_tokens[i]);
458 }
459 tokens.push(comment);
460 } else {
461 tokens.push(token.to_string());
462 }
463 i += 1;
464 }
465
466 let mut accepted = Vec::new();
467 let mut pending_product: Option<String> = None;
468 for token in tokens {
469 if token.starts_with('(') && token.ends_with(')') {
470 let comment = &token[1..token.len() - 1];
471 if let Some(product) = pending_product.take() {
472 if is_bot_agent_comment(comment) {
473 accepted.push(format!("{product} ({comment})"));
474 } else {
475 accepted.push(product);
476 }
477 }
478 continue;
479 }
480 if let Some(product) = pending_product.take() {
481 accepted.push(product);
482 }
483 if is_bot_agent_product(&token) {
484 pending_product = Some(token);
485 }
486 }
487 if let Some(product) = pending_product {
488 accepted.push(product);
489 }
490 if accepted.is_empty() {
491 return DEFAULT_BOT_AGENT.to_string();
492 }
493
494 let mut truncated = Vec::new();
495 let mut len = 0;
496 for token in accepted {
497 let add = if truncated.is_empty() { 0 } else { 1 } + token.len();
498 if len + add > 256 {
499 break;
500 }
501 len += add;
502 truncated.push(token);
503 }
504 if truncated.is_empty() {
505 DEFAULT_BOT_AGENT.to_string()
506 } else {
507 truncated.join(" ")
508 }
509}
510
511fn is_bot_agent_product(token: &str) -> bool {
512 let Some((name, version)) = token.split_once('/') else {
513 return false;
514 };
515 !name.is_empty()
516 && !version.is_empty()
517 && name.len() <= 32
518 && version.len() <= 32
519 && name
520 .chars()
521 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '-'))
522 && version
523 .chars()
524 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '+' | '-'))
525}
526
527fn is_bot_agent_comment(comment: &str) -> bool {
528 !comment.is_empty()
529 && comment.len() <= 64
530 && comment
531 .chars()
532 .all(|ch| ch.is_ascii() && (' '..='~').contains(&ch) && ch != '(' && ch != ')')
533}
534
535fn empty_updates_response(cursor: &str) -> GetUpdatesResponse {
536 GetUpdatesResponse {
537 ret: 0,
538 msgs: Vec::new(),
539 sync_buf: None,
540 get_updates_buf: cursor.to_string(),
541 longpolling_timeout_ms: None,
542 errcode: None,
543 errmsg: None,
544 }
545}
546
547fn build_qr_status_endpoint(qrcode: &str, verify_code: Option<&str>) -> String {
548 let mut endpoint = format!(
549 "/ilink/bot/get_qrcode_status?qrcode={}",
550 urlencoding::encode(qrcode)
551 );
552 if let Some(code) = verify_code.filter(|value| !value.is_empty()) {
553 endpoint.push_str("&verify_code=");
554 endpoint.push_str(&urlencoding::encode(code));
555 }
556 endpoint
557}
558
559fn wait_qr_status_response() -> QrStatusResponse {
560 QrStatusResponse {
561 status: "wait".to_string(),
562 bot_token: None,
563 ilink_bot_id: None,
564 ilink_user_id: None,
565 baseurl: None,
566 redirect_host: None,
567 }
568}
569
570pub fn build_media_message(user_id: &str, context_token: &str, item_list: Vec<Value>) -> Value {
572 build_media_message_with_client_id(
573 user_id,
574 context_token,
575 item_list,
576 &Uuid::new_v4().to_string(),
577 )
578}
579
580pub fn build_media_message_with_client_id(
582 user_id: &str,
583 context_token: &str,
584 item_list: Vec<Value>,
585 client_id: &str,
586) -> Value {
587 let item_list = item_list
588 .into_iter()
589 .map(|mut item| {
590 if let Some(object) = item.as_object_mut() {
591 object
592 .entry("msg_id")
593 .or_insert_with(|| Value::String(client_id.to_string()));
594 }
595 item
596 })
597 .collect::<Vec<_>>();
598 json!({
599 "from_user_id": "",
600 "to_user_id": user_id,
601 "client_id": client_id,
602 "message_type": 2,
603 "message_state": 2,
604 "context_token": context_token,
605 "item_list": item_list
606 })
607}
608
609pub struct GetUploadUrlParams {
611 pub filekey: String,
612 pub media_type: i32,
613 pub to_user_id: String,
614 pub rawsize: usize,
615 pub rawfilemd5: String,
616 pub filesize: usize,
617 pub thumb_rawsize: Option<usize>,
618 pub thumb_rawfilemd5: Option<String>,
619 pub thumb_filesize: Option<usize>,
620 pub no_need_thumb: bool,
621 pub aeskey: String,
622}
623
624#[derive(Debug, Deserialize)]
626pub struct GetUploadUrlResponse {
627 pub upload_param: Option<String>,
628 pub thumb_upload_param: Option<String>,
629 pub upload_full_url: Option<String>,
630}
631
632impl ILinkClient {
633 pub async fn get_upload_url(
635 &self,
636 base_url: &str,
637 token: &str,
638 params: &GetUploadUrlParams,
639 ) -> Result<GetUploadUrlResponse> {
640 let mut body = json!({
641 "filekey": params.filekey,
642 "media_type": params.media_type,
643 "to_user_id": params.to_user_id,
644 "rawsize": params.rawsize,
645 "rawfilemd5": params.rawfilemd5,
646 "filesize": params.filesize,
647 "no_need_thumb": params.no_need_thumb,
648 "aeskey": params.aeskey,
649 "base_info": self.base_info()
650 });
651 if let Some(value) = params.thumb_rawsize {
652 body["thumb_rawsize"] = json!(value);
653 }
654 if let Some(value) = ¶ms.thumb_rawfilemd5 {
655 body["thumb_rawfilemd5"] = json!(value);
656 }
657 if let Some(value) = params.thumb_filesize {
658 body["thumb_filesize"] = json!(value);
659 }
660 let resp = self
661 .api_post(base_url, "/ilink/bot/getuploadurl", token, &body, 15)
662 .await?;
663 Ok(serde_json::from_value(resp)?)
664 }
665
666 pub async fn upload_to_cdn(&self, cdn_url: &str, ciphertext: &[u8]) -> Result<String> {
669 const MAX_RETRIES: u32 = 3;
670 let mut last_err = None;
671
672 for attempt in 1..=MAX_RETRIES {
673 match self
674 .http
675 .post(cdn_url)
676 .header("Content-Type", "application/octet-stream")
677 .body(ciphertext.to_vec())
678 .send()
679 .await
680 {
681 Ok(resp) => {
682 let status = resp.status().as_u16();
683 if status >= 400 && status < 500 {
684 let err_msg = resp
685 .headers()
686 .get("x-error-message")
687 .and_then(|v| v.to_str().ok())
688 .unwrap_or("client error")
689 .to_string();
690 return Err(WechatIlinkError::Media(format!(
691 "CDN upload client error {}: {}",
692 status, err_msg
693 )));
694 }
695 if status != 200 {
696 let err_msg = resp
697 .headers()
698 .get("x-error-message")
699 .and_then(|v| v.to_str().ok())
700 .unwrap_or("server error")
701 .to_string();
702 last_err = Some(WechatIlinkError::Media(format!(
703 "CDN upload server error {}: {}",
704 status, err_msg
705 )));
706 continue;
707 }
708 match resp
709 .headers()
710 .get("x-encrypted-param")
711 .and_then(|v| v.to_str().ok())
712 {
713 Some(param) => return Ok(param.to_string()),
714 None => {
715 last_err = Some(WechatIlinkError::Media(
716 "CDN upload response missing x-encrypted-param header".into(),
717 ));
718 continue;
719 }
720 }
721 }
722 Err(e) => {
723 last_err = Some(WechatIlinkError::Other(format!(
724 "CDN upload network error: {}",
725 e
726 )));
727 if attempt < MAX_RETRIES {
728 continue;
729 }
730 }
731 }
732 }
733 Err(last_err.unwrap_or_else(|| {
734 WechatIlinkError::Media(format!("CDN upload failed after {} attempts", MAX_RETRIES))
735 }))
736 }
737
738 pub fn build_text_message_with_client_id(
740 &self,
741 user_id: &str,
742 context_token: &str,
743 text: &str,
744 client_id: &str,
745 ) -> Value {
746 let text = if self.options.markdown_filter {
747 filter_markdown(text)
748 } else {
749 text.to_string()
750 };
751 build_text_message_payload(user_id, context_token, &text, client_id)
752 }
753}
754
755pub fn build_cdn_upload_url(cdn_base_url: &str, upload_param: &str, filekey: &str) -> String {
757 format!(
758 "{}/upload?encrypted_query_param={}&filekey={}",
759 cdn_base_url,
760 urlencoding::encode(upload_param),
761 urlencoding::encode(filekey)
762 )
763}
764
765pub fn build_text_message(user_id: &str, context_token: &str, text: &str) -> Value {
767 build_text_message_with_client_id(user_id, context_token, text, &Uuid::new_v4().to_string())
768}
769
770pub fn build_text_message_with_client_id(
772 user_id: &str,
773 context_token: &str,
774 text: &str,
775 client_id: &str,
776) -> Value {
777 let text = filter_markdown(text);
778 build_text_message_payload(user_id, context_token, &text, client_id)
779}
780
781fn build_text_message_payload(
782 user_id: &str,
783 context_token: &str,
784 text: &str,
785 client_id: &str,
786) -> Value {
787 json!({
788 "from_user_id": "",
789 "to_user_id": user_id,
790 "client_id": client_id,
791 "message_type": 2,
792 "message_state": 2,
793 "context_token": context_token,
794 "item_list": [{ "type": 1, "msg_id": client_id, "text_item": { "text": text } }]
795 })
796}
797
798#[cfg(test)]
799mod tests {
800 use super::*;
801
802 #[test]
803 fn ilink_client_accepts_caller_provided_reqwest_client() {
804 let http_client = reqwest::Client::new();
805 let client = ILinkClient::with_http_client_and_options(
806 http_client,
807 ILinkClientOptions {
808 bot_agent: Some("Amux/0.1".to_string()),
809 ..ILinkClientOptions::default()
810 },
811 );
812
813 let base_info = client.base_info();
814 assert_eq!(base_info.bot_agent.as_deref(), Some("Amux/0.1"));
815 }
816
817 #[test]
818 fn base_info_preserves_bot_agent_when_configured() {
819 let client = ILinkClient::with_options(ILinkClientOptions {
820 bot_agent: Some("Amux/0.1".to_string()),
821 ..ILinkClientOptions::default()
822 });
823
824 let base_info = client.base_info();
825 assert_eq!(base_info.channel_version, CHANNEL_VERSION);
826 assert_eq!(base_info.bot_agent.as_deref(), Some("Amux/0.1"));
827 }
828
829 #[test]
830 fn base_info_defaults_bot_agent() {
831 let client = ILinkClient::new();
832
833 let base_info = client.base_info();
834 assert_eq!(base_info.bot_agent.as_deref(), Some("OpenClaw"));
835 }
836
837 #[test]
838 fn base_info_sanitizes_bot_agent() {
839 let client = ILinkClient::with_options(ILinkClientOptions {
840 bot_agent: Some(" Amux/0.1\tBad Agent\r\n".to_string()),
841 ..ILinkClientOptions::default()
842 });
843
844 let base_info = client.base_info();
845 assert_eq!(base_info.bot_agent.as_deref(), Some("Amux/0.1"));
846 }
847
848 #[test]
849 fn base_info_preserves_valid_bot_agent_comment() {
850 let client = ILinkClient::with_options(ILinkClientOptions {
851 bot_agent: Some("Amux/0.1 (worker channel) Other/2.0".to_string()),
852 ..ILinkClientOptions::default()
853 });
854
855 let base_info = client.base_info();
856 assert_eq!(
857 base_info.bot_agent.as_deref(),
858 Some("Amux/0.1 (worker channel) Other/2.0")
859 );
860 }
861
862 #[test]
863 fn empty_updates_response_preserves_cursor() {
864 let response = empty_updates_response("cursor-1");
865
866 assert_eq!(response.ret, 0);
867 assert!(response.msgs.is_empty());
868 assert_eq!(response.get_updates_buf, "cursor-1");
869 }
870
871 #[tokio::test]
872 async fn send_message_rejects_nonzero_ret_response() {
873 let (base_url, server) =
874 json_response_server(r#"{"ret":40003,"errmsg":"invalid context_token"}"#);
875 let client = ILinkClient::new();
876
877 let err = client
878 .send_message(&base_url, "token", &json!({"text": "hello"}))
879 .await
880 .expect_err("nonzero ret must be treated as an API error");
881
882 match err {
883 WechatIlinkError::Api {
884 message,
885 http_status,
886 errcode,
887 } => {
888 assert_eq!(message, "invalid context_token");
889 assert_eq!(http_status, 200);
890 assert_eq!(errcode, 40003);
891 }
892 other => panic!("expected API error, got {other:?}"),
893 }
894 server.join().expect("response server should exit");
895 }
896
897 #[tokio::test]
898 async fn send_message_maps_ret_minus_two_to_rate_limited() {
899 let (base_url, server) = json_response_server(r#"{"ret":-2}"#);
900 let client = ILinkClient::new();
901
902 let err = client
903 .send_message(&base_url, "token", &json!({"text": "hello"}))
904 .await
905 .expect_err("ret=-2 must be treated as rate limiting");
906
907 match err {
908 WechatIlinkError::RateLimited {
909 retry_after,
910 message,
911 http_status,
912 errcode,
913 } => {
914 assert_eq!(retry_after, Duration::from_secs(90));
915 assert_eq!(message, "ret=-2");
916 assert_eq!(http_status, 200);
917 assert_eq!(errcode, -2);
918 }
919 other => panic!("expected rate limit error, got {other:?}"),
920 }
921 server.join().expect("response server should exit");
922 }
923
924 #[test]
925 fn qr_status_endpoint_includes_verify_code_when_present() {
926 let endpoint = build_qr_status_endpoint("qr value", Some("12 34"));
927
928 assert_eq!(
929 endpoint,
930 "/ilink/bot/get_qrcode_status?qrcode=qr%20value&verify_code=12%2034"
931 );
932 }
933
934 #[test]
935 fn wait_qr_status_response_is_wait() {
936 let response = wait_qr_status_response();
937
938 assert_eq!(response.status, "wait");
939 assert!(response.bot_token.is_none());
940 }
941
942 #[test]
943 fn common_headers_preserve_route_tag_when_configured() {
944 let client = ILinkClient::with_options(ILinkClientOptions {
945 route_tag: Some("route-a".to_string()),
946 ..ILinkClientOptions::default()
947 });
948
949 let request = client
950 .apply_common_headers(client.http.get("https://example.test"))
951 .build()
952 .expect("request");
953 assert_eq!(
954 request
955 .headers()
956 .get("SKRouteTag")
957 .and_then(|value| value.to_str().ok()),
958 Some("route-a")
959 );
960 }
961
962 #[test]
963 fn common_headers_use_configured_ilink_app_id() {
964 let client = ILinkClient::with_options(ILinkClientOptions {
965 ilink_app_id: Some("custom-app".to_string()),
966 ..ILinkClientOptions::default()
967 });
968
969 let request = client
970 .apply_common_headers(client.http.get("https://example.test"))
971 .build()
972 .expect("request");
973 assert_eq!(
974 request
975 .headers()
976 .get("iLink-App-Id")
977 .and_then(|value| value.to_str().ok()),
978 Some("custom-app")
979 );
980 }
981
982 #[test]
983 fn text_message_builder_can_disable_markdown_filter() {
984 let client = ILinkClient::with_options(ILinkClientOptions {
985 markdown_filter: false,
986 ..ILinkClientOptions::default()
987 });
988
989 let msg = client.build_text_message_with_client_id("user-1", "ctx-1", "# title", "msg-1");
990 assert_eq!(msg["item_list"][0]["text_item"]["text"], "# title");
991 }
992
993 fn json_response_server(body: &'static str) -> (String, std::thread::JoinHandle<()>) {
994 use std::io::{Read, Write};
995 use std::net::TcpListener;
996
997 let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
998 let addr = listener.local_addr().expect("test server addr");
999 let server = std::thread::spawn(move || {
1000 let (mut stream, _) = listener.accept().expect("accept test request");
1001 stream
1002 .set_read_timeout(Some(Duration::from_secs(2)))
1003 .expect("set read timeout");
1004 let mut buf = [0u8; 4096];
1005 let _ = stream.read(&mut buf);
1006 let response = format!(
1007 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1008 body.len(),
1009 body
1010 );
1011 stream
1012 .write_all(response.as_bytes())
1013 .expect("write test response");
1014 });
1015 (format!("http://{addr}"), server)
1016 }
1017}