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..e313ce3 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -21,16 +21,14 @@ 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", - })?; - let qrcode_id = qr_resp["data"]["qrcode_id"] - .as_str() - .context(LoginFailedSnafu { - reason: "no qrcode_id", + reason: "no qrcode_img_content 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 { @@ -49,7 +47,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 +62,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", })?;