Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 54 additions & 3 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Value> {
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<Value> {
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::<Value>()
.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<Value> {
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<Value> {
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
}
Expand Down
25 changes: 16 additions & 9 deletions src/bot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,14 @@ pub async fn login(options: LoginOptions) -> Result<String> {
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 {
Expand All @@ -49,7 +47,11 @@ pub async fn login(options: LoginOptions) -> Result<String> {
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" => {}
Expand All @@ -60,7 +62,12 @@ pub async fn login(options: LoginOptions) -> Result<String> {
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",
})?;
Expand Down