Skip to main content

wechat_ilink/
protocol.rs

1//! Raw iLink Bot API HTTP calls.
2
3use 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/// Common request metadata attached to every CGI request.
27#[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/// Low-level iLink client options.
35#[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
54/// Build iLink-App-ClientVersion from the crate version (0x00MMNNPP).
55fn 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
65/// Generate the X-WECHAT-UIN header value.
66pub 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/// QR code response.
74#[derive(Debug, Deserialize)]
75pub struct QrCodeResponse {
76    pub qrcode: String,
77    pub qrcode_img_content: String,
78}
79
80/// QR status response.
81#[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    /// New host to redirect polling to when status is "scaned_but_redirect".
89    pub redirect_host: Option<String>,
90}
91
92/// Get updates response.
93#[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/// Get config response.
108#[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/// Notify start response.
116#[derive(Debug, Deserialize)]
117pub struct NotifyStartResponse {
118    pub ret: Option<i32>,
119    pub errmsg: Option<String>,
120}
121
122/// Notify stop response.
123#[derive(Debug, Deserialize)]
124pub struct NotifyStopResponse {
125    pub ret: Option<i32>,
126    pub errmsg: Option<String>,
127}
128
129/// Low-level iLink API client.
130#[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
570/// Build a media message payload.
571pub 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
580/// Build a media message payload with a caller-supplied client id.
581pub 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
609/// GetUploadUrl request parameters.
610pub 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/// GetUploadUrl response.
625#[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    /// Get a pre-signed CDN upload URL.
634    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) = &params.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    /// Upload encrypted bytes to CDN with retry (up to 3 attempts).
667    /// Returns the download encrypted_query_param from the x-encrypted-param header.
668    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    /// Build a text message payload with this client's text normalization options.
739    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
755/// Build a CDN upload URL from params.
756pub 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
765/// Build a text message payload.
766pub 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
770/// Build a text message payload with a caller-supplied client id.
771pub 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}