use crate::error::{BotError, BotResult};
use crate::types::*;
use std::sync::OnceLock;
use std::time::Duration;
use xbw::all::*;
use xbw::crypt::{
base64::{Base64, Engine},
hex::encode,
md5,
rand::{rng, Rng},
};
const DEFAULT_BASE_URL: &str = "https://ilinkai.weixin.qq.com";
const GETUPDATES_TIMEOUT: Duration = Duration::from_secs(45);
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(15);
const QRCODE_STATUS_TIMEOUT: Duration = Duration::from_secs(60);
static HTTP: OnceLock<Axios> = OnceLock::new();
#[inline]
fn http() -> &'static Axios {
HTTP.get_or_init(|| {
Axios::builder()
.timeout(DEFAULT_TIMEOUT)
.build()
.expect("构建 HTTP 客户端失败")
})
}
#[derive(Debug, Clone)]
pub struct ILinkClient {
bot_token: String,
base_url: String,
}
impl ILinkClient {
pub fn new(bot_token: impl Into<String>, base_url: Option<String>) -> Self {
let bot_token = bot_token.into();
let base_url = base_url.unwrap_or_else(|| DEFAULT_BASE_URL.to_string());
Self {
bot_token,
base_url,
}
}
pub fn set_bot_token(&mut self, token: impl Into<String>) {
self.bot_token = token.into();
}
pub fn bot_token(&self) -> &str {
&self.bot_token
}
pub fn is_authenticated(&self) -> bool {
!self.bot_token.is_empty()
}
pub async fn get_bot_qrcode(&self) -> BotResult<QrCodeResponse> {
let resp = http().get(self.url("ilink/bot/get_bot_qrcode"))
.query(&[("bot_type", "3")])
.headers(Self::make_headers(""))
.send()
.await?
.json::<QrCodeResponse>()
.await?;
Ok(resp)
}
pub async fn get_qrcode_status(&self, qrcode: &str) -> BotResult<QrCodeStatusResponse> {
let resp = http().get(self.url("ilink/bot/get_qrcode_status"))
.query(&[("qrcode", qrcode)])
.headers(Self::make_headers(""))
.timeout(QRCODE_STATUS_TIMEOUT)
.send()
.await?
.json::<QrCodeStatusResponse>()
.await?;
Ok(resp)
}
pub async fn wait_for_login(
&self,
qrcode: &str,
poll_interval: Duration,
max_wait: Duration,
) -> BotResult<(String, String)> {
let start = std::time::Instant::now();
while start.elapsed() < max_wait {
let data = self.get_qrcode_status(qrcode).await?;
match data.status.as_str() {
"confirmed" => {
let token = data.bot_token;
let base = if data.baseurl.is_empty() {
self.base_url.clone()
} else {
data.baseurl
};
return Ok((token, base));
}
"expired" => {
return Err(BotError::other("二维码已过期,请重新登录"));
}
"waiting" | "scanned" => {
}
_ => {
xbw::all::log::warn!("未知的二维码状态: {}", data.status);
}
}
tokio::time::sleep(poll_interval).await;
}
Err(BotError::timeout(format!(
"二维码登录等待超时 ({}s)",
max_wait.as_secs()
)))
}
pub async fn get_updates(&self, cursor: &str) -> BotResult<GetUpdatesResponse> {
let body = GetUpdatesRequest {
get_updates_buf: cursor.to_string(),
base_info: BaseInfo::default(),
};
let resp = http().post(self.url("ilink/bot/getupdates"))
.headers(Self::make_headers(&self.bot_token))
.json(&body)
.timeout(GETUPDATES_TIMEOUT)
.send()
.await?
.json::<GetUpdatesResponse>()
.await?;
Ok(resp)
}
pub async fn send_message(&self, msg: OutboundMessage) -> BotResult<xbw::all::JsonValue> {
let body = SendMessageRequest {
msg,
base_info: BaseInfo::default(),
};
let resp = http().post(self.url("ilink/bot/sendmessage"))
.headers(Self::make_headers(&self.bot_token))
.json(&body)
.send()
.await?
.json::<xbw::all::JsonValue>()
.await?;
Ok(resp)
}
pub async fn send_text(
&self,
to_user_id: &str,
text: &str,
context_token: &str,
) -> BotResult<xbw::all::JsonValue> {
let msg = OutboundMessage {
from_user_id: String::new(),
to_user_id: to_user_id.to_string(),
client_id: Uuid::new_v4().to_string(),
message_type: 2,
message_state: 2,
context_token: context_token.to_string(),
item_list: vec![OutboundContentItem::Text {
text_item: OutboundTextItem {
text: text.to_string(),
},
}],
};
self.send_message(msg).await
}
pub async fn get_upload_url(
&self,
req: GetUploadUrlRequest,
) -> BotResult<GetUploadUrlResponse> {
let resp = http().post(self.url("ilink/bot/getuploadurl"))
.headers(Self::make_headers(&self.bot_token))
.json(&req)
.send()
.await?
.json::<GetUploadUrlResponse>()
.await?;
Ok(resp)
}
pub async fn upload_to_cdn(
&self,
upload_url: &str,
encrypted_data: &[u8],
upload_param: &str,
) -> BotResult<()> {
let resp = http().post(upload_url)
.query(&[("upload_param", upload_param)])
.body(encrypted_data.to_vec())
.send()
.await?;
if !resp.status().is_success() {
return Err(BotError::other(format!(
"CDN 上传失败: HTTP {}",
resp.status()
)));
}
Ok(())
}
pub async fn upload_media(
&self,
file_data: &[u8],
media_type: i64,
to_user_id: &str,
) -> BotResult<UploadedMedia> {
let rawsize = file_data.len() as i64;
let rawfilemd5 = format!("{:x}", md5::compute(file_data));
let mut aes_key_raw = [0u8; 16];
rng().fill_bytes(&mut aes_key_raw);
let aes_key_hex = encode(aes_key_raw);
let encrypted = crate::crypto::aes_ecb_encrypt(file_data, &aes_key_raw)?;
let filesize = encrypted.len() as i64;
let mut filekey_bytes = [0u8; 8];
rng().fill_bytes(&mut filekey_bytes);
let filekey = encode(filekey_bytes);
let upload_req = GetUploadUrlRequest {
filekey,
media_type,
to_user_id: to_user_id.to_string(),
rawsize,
rawfilemd5,
filesize,
aeskey: aes_key_hex.clone(),
no_need_thumb: true,
base_info: BaseInfo::default(),
};
let upload_resp = self.get_upload_url(upload_req).await?;
self.upload_to_cdn(
&upload_resp.upload_full_url,
&encrypted,
&upload_resp.upload_param,
)
.await?;
let aes_key_b64 = Base64.encode(aes_key_hex.as_bytes());
let encrypted_md5 = format!("{:x}", md5::compute(&encrypted));
Ok(UploadedMedia {
encrypt_query_param: upload_resp.upload_param,
aes_key_b64,
filesize,
md5: encrypted_md5,
})
}
pub async fn download_and_decrypt_media(
&self,
cdn_url: &str,
aes_key: &str,
query_param: Option<&str>,
) -> BotResult<Vec<u8>> {
let mut url = cdn_url.to_string();
if let Some(param) = query_param {
if !param.is_empty() {
url.push_str("?encrypt_query_param=");
url.push_str(param);
}
}
let resp = http().get(&url).send().await?;
let encrypted = resp.bytes().await?;
let decrypted = crate::crypto::aes_ecb_decrypt(&encrypted, aes_key)?;
Ok(decrypted)
}
pub async fn send_typing(
&self,
ilink_user_id: &str,
typing_ticket: &str,
status: i64,
) -> BotResult<xbw::all::JsonValue> {
let body = SendTypingRequest {
ilink_user_id: ilink_user_id.to_string(),
typing_ticket: typing_ticket.to_string(),
status,
base_info: BaseInfo::default(),
};
let resp = http().post(self.url("ilink/bot/sendtyping"))
.headers(Self::make_headers(&self.bot_token))
.json(&body)
.send()
.await?
.json::<xbw::all::JsonValue>()
.await?;
Ok(resp)
}
pub async fn get_config(&self) -> BotResult<GetConfigResponse> {
let body = xbw::all::json!({
"base_info": BaseInfo::default()
});
let resp = http().post(self.url("ilink/bot/ilink_bot_getconfig"))
.headers(Self::make_headers(&self.bot_token))
.json(&body)
.send()
.await?
.json::<GetConfigResponse>()
.await?;
Ok(resp)
}
fn url(&self, path: &str) -> String {
format!("{}/{}", self.base_url, path)
}
fn make_headers(bot_token: &str) -> axios_header::HeaderMap {
let mut headers = axios_header::HeaderMap::new();
headers.insert(
axios_header::CONTENT_TYPE,
axios_header::HeaderValue::from_static("application/json; charset=utf-8"),
);
headers.insert(
axios_header::ACCEPT,
axios_header::HeaderValue::from_static("application/json"),
);
if !bot_token.is_empty() {
let bearer = format!("Bearer {}", bot_token);
if let Ok(val) = axios_header::HeaderValue::from_str(&bearer) {
headers.insert(axios_header::AUTHORIZATION, val);
}
}
headers
}
}