use anyhow::{anyhow, Context, Result};
use reqwest::Client;
use std::time::Duration;
use tokio::sync::mpsc::UnboundedSender;
use tracing::{info, warn};
use crate::error::HubError;
use super::types::*;
fn upstream_parse_err(e: impl std::fmt::Display) -> anyhow::Error {
anyhow::Error::new(HubError::UpstreamParse(e.to_string()))
}
#[derive(Clone, serde::Serialize)]
#[serde(tag = "kind")]
pub enum QrLoginUiEvent {
#[serde(rename = "ready")]
Ready { image: String, link: String },
#[serde(rename = "status")]
Status { message: String },
#[serde(rename = "done")]
Done,
#[serde(rename = "expired")]
Expired,
}
pub struct LoginClient {
client: Client,
base_url: String,
}
impl LoginClient {
pub fn new(base_url: Option<String>) -> Result<Self> {
let client = Client::builder()
.timeout(Duration::from_secs(120))
.build()
.context("failed to build HTTP client")?;
Ok(Self {
client,
base_url: base_url.unwrap_or_else(|| ILINK_BASE_URL.to_string()),
})
}
pub async fn login_with_qr(&self) -> Result<String> {
self.login_with_qr_ui(None).await
}
pub async fn login_with_qr_ui(
&self,
ui: Option<UnboundedSender<QrLoginUiEvent>>,
) -> Result<String> {
info!("Starting iLink QR login");
let qr_resp = self.get_qrcode().await?;
let key = qr_resp
.qrcode
.ok_or_else(|| anyhow!("no qrcode key in response"))?;
let qr_url = qr_resp
.qrcode_img_content
.ok_or_else(|| anyhow!("no qrcode URL in response"))?;
if let Some(ref tx) = ui {
let image = crate::client::pairing::encode_qr_svg_data_uri(&qr_url)?;
let _ = tx.send(QrLoginUiEvent::Ready {
image,
link: qr_url.clone(),
});
} else {
println!("\n╔══════════════════════════════════════╗");
println!("║ WeChat ClawBot Login ║");
println!("╚══════════════════════════════════════╝");
println!();
crate::client::pairing::render_qr_terminal(&qr_url)?;
println!();
println!("Scan the QR code with WeChat to log in.");
println!("QR URL: {}", qr_url);
println!();
}
let out = self.poll_qrcode_status(&key, ui.as_ref()).await;
if let Some(tx) = &ui {
if out.is_ok() {
let _ = tx.send(QrLoginUiEvent::Done);
}
}
out
}
async fn get_qrcode(&self) -> Result<GetQrcodeResponse> {
let url = format!("{}/ilink/bot/get_bot_qrcode?bot_type=3", self.base_url);
let resp = self
.client
.get(&url)
.send()
.await?
.json::<GetQrcodeResponse>()
.await
.map_err(upstream_parse_err)?;
if resp.ret != 0 {
return Err(anyhow!(
"get_bot_qrcode failed: {}",
resp.errmsg.as_deref().unwrap_or("unknown")
));
}
Ok(resp)
}
#[cfg(test)]
fn with_base_url(base_url: String) -> Result<Self> {
let client = Client::builder()
.timeout(Duration::from_secs(120))
.build()
.context("failed to build HTTP client")?;
Ok(Self { client, base_url })
}
async fn poll_qrcode_status(
&self,
key: &str,
ui: Option<&UnboundedSender<QrLoginUiEvent>>,
) -> Result<String> {
let url = format!(
"{}/ilink/bot/get_qrcode_status?qrcode={}",
self.base_url, key
);
let mut attempts = 0u32;
const MAX_ATTEMPTS: u32 = 60;
loop {
if attempts >= MAX_ATTEMPTS {
if let Some(tx) = ui {
let _ = tx.send(QrLoginUiEvent::Expired);
}
return Err(anyhow!(
"QR login timed out after {} attempts",
MAX_ATTEMPTS
));
}
attempts += 1;
tokio::time::sleep(Duration::from_secs(1)).await;
let resp = match self.client.get(&url).send().await {
Err(e) => {
warn!(error = %e, "network error polling qrcode status, retrying");
tokio::time::sleep(Duration::from_secs(2)).await;
continue;
}
Ok(r) => match r.json::<QrcodeStatusResponse>().await {
Ok(r) => r,
Err(e) => {
warn!(error = %e, "error parsing qrcode status response, retrying");
continue;
}
},
};
match resp.status.as_deref() {
Some("wait") | None => {}
Some("scaned") | Some("scanned") => {
info!("QR code scanned, waiting for confirmation...");
if let Some(tx) = ui {
let _ = tx.send(QrLoginUiEvent::Status {
message: "已在手机上扫码,请在微信里确认登录".into(),
});
}
}
Some("confirmed") => {
if let Some(token) = resp.bot_token {
info!("QR login successful");
return Ok(token);
}
return Err(anyhow!("login confirmed but no bot_token in response"));
}
Some("expired") => {
if let Some(tx) = ui {
let _ = tx.send(QrLoginUiEvent::Expired);
}
return Err(anyhow!("QR code expired, please run login again"));
}
Some(status) => {
if resp.ret != 0 {
return Err(anyhow!(
"qrcode status error: {}",
resp.errmsg.as_deref().unwrap_or(status)
));
}
warn!(status, "unknown qrcode status, continuing to poll");
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use mockito::Server;
fn make_client(base_url: String) -> LoginClient {
LoginClient::with_base_url(base_url).expect("test client")
}
#[test]
fn upstream_parse_err_wraps_as_hub_error_upstream_parse() {
let err = upstream_parse_err("json parse failed");
let hub_err = err
.downcast::<HubError>()
.expect("must downcast to HubError");
assert!(
matches!(hub_err, HubError::UpstreamParse(_)),
"must be HubError::UpstreamParse, got: {hub_err:?}"
);
}
#[tokio::test]
async fn get_qrcode_non_zero_ret_returns_err() {
let mut server = Server::new_async().await;
let _m = server
.mock("GET", "/ilink/bot/get_bot_qrcode?bot_type=3")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"ret":1,"errmsg":"auth failed","qrcode":null,"qrcode_img_content":null}"#,
)
.create_async()
.await;
let client = make_client(server.url());
let result = client.get_qrcode().await;
assert!(result.is_err(), "non-zero ret must return Err");
let msg = format!("{}", result.unwrap_err());
assert!(
msg.contains("get_bot_qrcode failed"),
"error message must mention get_bot_qrcode, got: {msg}"
);
}
#[tokio::test]
async fn get_qrcode_zero_ret_returns_ok() {
let mut server = Server::new_async().await;
let _m = server
.mock("GET", "/ilink/bot/get_bot_qrcode?bot_type=3")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"ret":0,"qrcode":"test-key-123","qrcode_img_content":"https://example.com/qr.png"}"#,
)
.create_async()
.await;
let client = make_client(server.url());
let result = client.get_qrcode().await;
assert!(result.is_ok(), "zero ret must return Ok, got: {result:?}");
let resp = result.unwrap();
assert_eq!(resp.qrcode.as_deref(), Some("test-key-123"));
}
#[tokio::test]
async fn poll_qrcode_status_confirmed_with_token_returns_ok() {
let mut server = Server::new_async().await;
let _m = server
.mock("GET", "/ilink/bot/get_qrcode_status?qrcode=test-key")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"ret":0,"status":"confirmed","bot_token":"my-bot-token-xyz","baseurl":null,"ilink_bot_id":null,"ilink_user_id":null}"#,
)
.create_async()
.await;
let client = make_client(server.url());
let result = client.poll_qrcode_status("test-key", None).await;
assert!(
result.is_ok(),
"confirmed status must return Ok, got: {result:?}"
);
assert_eq!(result.unwrap(), "my-bot-token-xyz");
}
#[tokio::test]
async fn poll_qrcode_status_confirmed_without_token_returns_err() {
let mut server = Server::new_async().await;
let _m = server
.mock("GET", "/ilink/bot/get_qrcode_status?qrcode=no-token-key")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"ret":0,"status":"confirmed","bot_token":null,"baseurl":null,"ilink_bot_id":null,"ilink_user_id":null}"#,
)
.create_async()
.await;
let client = make_client(server.url());
let result = client.poll_qrcode_status("no-token-key", None).await;
assert!(
result.is_err(),
"confirmed without bot_token must return Err"
);
}
#[tokio::test]
async fn poll_qrcode_status_expired_returns_err() {
let mut server = Server::new_async().await;
let _m = server
.mock("GET", "/ilink/bot/get_qrcode_status?qrcode=expired-key")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"ret":0,"status":"expired","bot_token":null,"baseurl":null,"ilink_bot_id":null,"ilink_user_id":null}"#,
)
.create_async()
.await;
let client = make_client(server.url());
let result = client.poll_qrcode_status("expired-key", None).await;
assert!(result.is_err(), "expired status must return Err");
let msg = format!("{}", result.unwrap_err());
assert!(
msg.contains("expired"),
"error must mention 'expired', got: {msg}"
);
}
#[tokio::test]
async fn poll_qrcode_status_unknown_status_nonzero_ret_returns_err() {
let mut server = Server::new_async().await;
let _m = server
.mock("GET", "/ilink/bot/get_qrcode_status?qrcode=err-key")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"ret":5,"status":"unknown_status","errmsg":"something went wrong","bot_token":null,"baseurl":null,"ilink_bot_id":null,"ilink_user_id":null}"#,
)
.create_async()
.await;
let client = make_client(server.url());
let result = client.poll_qrcode_status("err-key", None).await;
assert!(
result.is_err(),
"non-zero ret with unknown status must return Err"
);
}
#[tokio::test]
async fn login_with_qr_ui_returns_the_confirmed_bot_token() {
let mut server = Server::new_async().await;
let _m_qr = server
.mock("GET", "/ilink/bot/get_bot_qrcode?bot_type=3")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"ret":0,"qrcode":"qr-key-test","qrcode_img_content":"https://wx.qq.com/qr.png"}"#,
)
.create_async()
.await;
let _m_status = server
.mock("GET", "/ilink/bot/get_qrcode_status?qrcode=qr-key-test")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"ret":0,"status":"confirmed","bot_token":"token-from-qr-login","baseurl":null,"ilink_bot_id":null,"ilink_user_id":null}"#,
)
.create_async()
.await;
let client = make_client(server.url());
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel::<QrLoginUiEvent>();
let result = client.login_with_qr_ui(Some(tx)).await;
assert!(result.is_ok(), "expected Ok but got: {result:?}");
let token = result.unwrap();
assert_eq!(
token, "token-from-qr-login",
"login_with_qr_ui must return the confirmed bot_token, got: {token:?}"
);
}
#[tokio::test]
async fn get_qrcode_zero_ret_returns_non_empty_qrcode_key() {
let mut server = Server::new_async().await;
let _m = server
.mock("GET", "/ilink/bot/get_bot_qrcode?bot_type=3")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"ret":0,"qrcode":"my-qr-key-9999","qrcode_img_content":"https://example.com/qr.png"}"#,
)
.create_async()
.await;
let client = make_client(server.url());
let result = client.get_qrcode().await;
assert!(result.is_ok(), "zero ret must be Ok, got: {result:?}");
let resp = result.unwrap();
let qrcode = resp.qrcode.as_deref().unwrap_or("");
assert_eq!(
qrcode, "my-qr-key-9999",
"qrcode must match the mocked value"
);
}
#[tokio::test]
async fn poll_qrcode_status_returns_exact_token_not_placeholder() {
let mut server = Server::new_async().await;
let _m = server
.mock(
"GET",
"/ilink/bot/get_qrcode_status?qrcode=exact-token-key",
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"ret":0,"status":"confirmed","bot_token":"precise-bot-token-xyz","baseurl":null,"ilink_bot_id":null,"ilink_user_id":null}"#,
)
.create_async()
.await;
let client = make_client(server.url());
let result = client.poll_qrcode_status("exact-token-key", None).await;
assert!(result.is_ok(), "expected Ok, got: {result:?}");
let token = result.unwrap();
assert!(
!token.is_empty() && token != "xyzzy",
"token must be non-empty and non-xyzzy, got: {token:?}"
);
assert_eq!(token, "precise-bot-token-xyz");
}
#[tokio::test]
async fn login_with_qr_returns_the_confirmed_bot_token() {
let mut server = Server::new_async().await;
let _m_qr = server
.mock("GET", "/ilink/bot/get_bot_qrcode?bot_type=3")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"ret":0,"qrcode":"qr-key-wrapper","qrcode_img_content":"https://wx.qq.com/qr.png"}"#,
)
.create_async()
.await;
let _m_status = server
.mock("GET", "/ilink/bot/get_qrcode_status?qrcode=qr-key-wrapper")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"ret":0,"status":"confirmed","bot_token":"token-via-login-with-qr","baseurl":null,"ilink_bot_id":null,"ilink_user_id":null}"#,
)
.create_async()
.await;
let client = make_client(server.url());
let result = client.login_with_qr().await;
assert!(result.is_ok(), "expected Ok but got: {result:?}");
assert_eq!(result.unwrap(), "token-via-login-with-qr");
}
#[tokio::test]
async fn poll_qrcode_status_unknown_status_nonzero_ret_message() {
let mut server = Server::new_async().await;
let _m = server
.mock("GET", "/ilink/bot/get_qrcode_status?qrcode=err-msg-key")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"ret":5,"status":"unknown_status","errmsg":"something went wrong","bot_token":null,"baseurl":null,"ilink_bot_id":null,"ilink_user_id":null}"#,
)
.create_async()
.await;
let client = make_client(server.url());
let err = client
.poll_qrcode_status("err-msg-key", None)
.await
.expect_err("non-zero ret with unknown status must return Err");
let msg = format!("{err}");
assert!(
msg.contains("qrcode status error") && msg.contains("something went wrong"),
"must fail on ret check with errmsg, not via timeout/network; got: {msg}"
);
}
}