From 4ca1f9473657e0ff0534322e552d9f7b4aaec5f2 Mon Sep 17 00:00:00 2001 From: crrow Date: Mon, 23 Mar 2026 12:03:03 +0900 Subject: [PATCH 1/4] fix(login): adapt to v2 login API with form-encoded endpoints (#11) Login endpoints now use form encoding + ret/err_msg error fields instead of JSON + errcode/errmsg. Response field paths changed (qrcode_img_content, qrcode, top-level status/credentials). Maintains backward compatibility with v1 nested data format. Closes #11 --- src/api.rs | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++--- src/bot.rs | 21 ++++++++++++++------ 2 files changed, 69 insertions(+), 9 deletions(-) diff --git a/src/api.rs b/src/api.rs index 49d3475..8d491d2 100644 --- a/src/api.rs +++ b/src/api.rs @@ -100,17 +100,68 @@ impl WeixinApiClient { Ok(resp) } + /// Sends a form-encoded POST request and checks the `ret` error field. + /// + /// Login endpoints use form encoding + `ret`/`err_msg` instead of JSON + + /// `errcode`/`errmsg` used by messaging endpoints. + async fn post_form(&self, path: &str, params: &[(&str, &str)]) -> Result { + self.post_form_with_timeout(path, params, Duration::from_secs(30)) + .await + } + + /// Same as [`post_form`](Self::post_form) but with a custom timeout. + async fn post_form_with_timeout( + &self, + path: &str, + params: &[(&str, &str)], + timeout: Duration, + ) -> Result { + let url = format!("{}/{}", self.base_url, path); + let resp = self + .client + .post(&url) + .headers(self.headers()) + .form(params) + .timeout(timeout) + .send() + .await + .context(HttpSnafu)? + .json::() + .await + .context(HttpSnafu)?; + + if let Some(ret) = resp.get("ret").and_then(Value::as_i64) + && ret != 0 + { + let msg = resp + .get("err_msg") + .and_then(|v| v.as_str()) + .unwrap_or("unknown error") + .to_string(); + return Err(ApiSnafu { + code: ret, + message: msg, + } + .build()); + } + Ok(resp) + } + /// Requests a new login QR code from the API. pub async fn fetch_qr_code(&self) -> Result { - self.post("ilink/bot/get_bot_qrcode", &serde_json::json!({})) + self.post_form("ilink/bot/get_bot_qrcode", &[("bot_type", "3")]) .await } /// Polls the current scan status for the given `qrcode_id`. + /// + /// Uses a longer timeout than the default because this endpoint + /// long-polls until the user scans the QR code. pub async fn get_qr_code_status(&self, qrcode_id: &str) -> Result { - self.post( + self.post_form_with_timeout( "ilink/bot/get_qrcode_status", - &serde_json::json!({ "qrcode_id": qrcode_id }), + &[("qrcode", qrcode_id), ("bot_type", "3")], + Duration::from_secs(60), ) .await } diff --git a/src/bot.rs b/src/bot.rs index 175c1f8..5ee2ba2 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -21,15 +21,15 @@ pub async fn login(options: LoginOptions) -> Result { let client = WeixinApiClient::new(base_url, "", None); let qr_resp = client.fetch_qr_code().await?; - let qrcode_url = qr_resp["data"]["qrcode_url"] + let qrcode_url = qr_resp["qrcode_img_content"] .as_str() .context(LoginFailedSnafu { - reason: "no qrcode_url", + reason: "no qrcode_img_content in response", })?; - let qrcode_id = qr_resp["data"]["qrcode_id"] + let qrcode_id = qr_resp["qrcode"] .as_str() .context(LoginFailedSnafu { - reason: "no qrcode_id", + reason: "no qrcode in response", })?; let qr = qrcode::QrCode::new(qrcode_url.as_bytes()).map_err(|e| { @@ -49,7 +49,11 @@ pub async fn login(options: LoginOptions) -> Result { loop { tokio::time::sleep(std::time::Duration::from_secs(2)).await; let status_resp = client.get_qr_code_status(qrcode_id).await?; - let status = status_resp["data"]["status"].as_str().unwrap_or("unknown"); + // Try top-level field first (v2 API), fall back to nested data.status (v1) + let status = status_resp["status"] + .as_str() + .or_else(|| status_resp["data"]["status"].as_str()) + .unwrap_or("unknown"); match status { "wait" => {} @@ -60,7 +64,12 @@ pub async fn login(options: LoginOptions) -> Result { return Err(QrCodeExpiredSnafu.build()); } "confirmed" => { - let data = &status_resp["data"]; + // v2 API returns credentials at top level; v1 nests under data + let data = if status_resp.get("bot_token").is_some() { + &status_resp + } else { + &status_resp["data"] + }; let token = data["bot_token"].as_str().context(LoginFailedSnafu { reason: "no bot_token", })?; From ae9679fea088074a602980b9e7b001a82d267aeb Mon Sep 17 00:00:00 2001 From: crrow Date: Mon, 23 Mar 2026 12:06:53 +0900 Subject: [PATCH 2/4] style(login): apply rustfmt formatting (#11) Closes #11 --- src/api.rs | 12 +++++++----- src/bot.rs | 12 +++++------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/api.rs b/src/api.rs index 8d491d2..7d1bf09 100644 --- a/src/api.rs +++ b/src/api.rs @@ -13,9 +13,9 @@ const SESSION_EXPIRED_ERRCODE: i64 = -14; /// Handles authentication headers, request signing, and automatic /// session-expiry detection on every response. pub struct WeixinApiClient { - client: Client, - base_url: String, - token: String, + client: Client, + base_url: String, + token: String, route_tag: Option, } @@ -32,7 +32,9 @@ impl WeixinApiClient { } /// Replaces the bearer token used for subsequent requests. - pub fn set_token(&mut self, token: &str) { self.token = token.to_string(); } + pub fn set_token(&mut self, token: &str) { + self.token = token.to_string(); + } fn headers(&self) -> reqwest::header::HeaderMap { use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; @@ -139,7 +141,7 @@ impl WeixinApiClient { .unwrap_or("unknown error") .to_string(); return Err(ApiSnafu { - code: ret, + code: ret, message: msg, } .build()); diff --git a/src/bot.rs b/src/bot.rs index 5ee2ba2..af3ce61 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -26,11 +26,9 @@ pub async fn login(options: LoginOptions) -> Result { .context(LoginFailedSnafu { reason: "no qrcode_img_content in response", })?; - let qrcode_id = qr_resp["qrcode"] - .as_str() - .context(LoginFailedSnafu { - reason: "no qrcode in response", - })?; + let qrcode_id = qr_resp["qrcode"].as_str().context(LoginFailedSnafu { + reason: "no qrcode in response", + })?; let qr = qrcode::QrCode::new(qrcode_url.as_bytes()).map_err(|e| { LoginFailedSnafu { @@ -85,10 +83,10 @@ pub async fn login(options: LoginOptions) -> Result { .to_string(); let account_data = storage::AccountData { - token: token.to_string(), + token: token.to_string(), saved_at: chrono::Utc::now().to_rfc3339(), base_url: base.to_string(), - user_id: user_id.to_string(), + user_id: user_id.to_string(), }; storage::save_account_data(&account_id, &account_data)?; From f3c1ec51cc088f6963f1777027efaf020608afe4 Mon Sep 17 00:00:00 2001 From: crrow Date: Mon, 23 Mar 2026 12:14:36 +0900 Subject: [PATCH 3/4] style(login): align struct fields for nightly rustfmt (#11) Closes #11 --- src/api.rs | 2 +- src/bot.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/api.rs b/src/api.rs index 7d1bf09..8c96caa 100644 --- a/src/api.rs +++ b/src/api.rs @@ -141,7 +141,7 @@ impl WeixinApiClient { .unwrap_or("unknown error") .to_string(); return Err(ApiSnafu { - code: ret, + code: ret, message: msg, } .build()); diff --git a/src/bot.rs b/src/bot.rs index af3ce61..e313ce3 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -83,10 +83,10 @@ pub async fn login(options: LoginOptions) -> Result { .to_string(); let account_data = storage::AccountData { - token: token.to_string(), + token: token.to_string(), saved_at: chrono::Utc::now().to_rfc3339(), base_url: base.to_string(), - user_id: user_id.to_string(), + user_id: user_id.to_string(), }; storage::save_account_data(&account_id, &account_data)?; From c5325837962c0dfb3259b67d75f77146076c874d Mon Sep 17 00:00:00 2001 From: crrow Date: Mon, 23 Mar 2026 12:19:38 +0900 Subject: [PATCH 4/4] style(api): restore nightly rustfmt field alignment (#11) Closes #11 --- src/api.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/api.rs b/src/api.rs index 8c96caa..8d491d2 100644 --- a/src/api.rs +++ b/src/api.rs @@ -13,9 +13,9 @@ const SESSION_EXPIRED_ERRCODE: i64 = -14; /// Handles authentication headers, request signing, and automatic /// session-expiry detection on every response. pub struct WeixinApiClient { - client: Client, - base_url: String, - token: String, + client: Client, + base_url: String, + token: String, route_tag: Option, } @@ -32,9 +32,7 @@ impl WeixinApiClient { } /// Replaces the bearer token used for subsequent requests. - pub fn set_token(&mut self, token: &str) { - self.token = token.to_string(); - } + pub fn set_token(&mut self, token: &str) { self.token = token.to_string(); } fn headers(&self) -> reqwest::header::HeaderMap { use reqwest::header::{HeaderMap, HeaderName, HeaderValue};