use std::time::Duration;
use crate::config::WeixinConfig;
use crate::error::{Error, Result};
use crate::types::{
CHANNEL_VERSION, DEFAULT_CONFIG_TIMEOUT_MS, GetConfigRequest, GetConfigResponse,
GetUpdatesRequest, GetUpdatesResponse, GetUploadUrlRequest, GetUploadUrlResponse, ILINK_APP_ID,
SendMessageRequest, SendTypingRequest, build_base_info,
};
use crate::util::redact;
fn build_client_version(version: &str) -> u32 {
let parts: Vec<u32> = version.split('.').filter_map(|p| p.parse().ok()).collect();
let major = parts.first().copied().unwrap_or(0) & 0xff;
let minor = parts.get(1).copied().unwrap_or(0) & 0xff;
let patch = parts.get(2).copied().unwrap_or(0) & 0xff;
(major << 16) | (minor << 8) | patch
}
fn random_wechat_uin() -> String {
use base64::Engine;
use rand::Rng;
let n: u32 = rand::rng().random();
base64::engine::general_purpose::STANDARD.encode(n.to_string().as_bytes())
}
fn ensure_trailing_slash(url: &str) -> String {
if url.ends_with('/') {
url.to_owned()
} else {
format!("{url}/")
}
}
pub(crate) struct HttpApiClient {
base_url: String,
token: String,
route_tag: Option<u32>,
api_timeout: Duration,
http: reqwest::Client,
}
impl HttpApiClient {
pub fn new(config: &WeixinConfig) -> Self {
Self {
base_url: ensure_trailing_slash(&config.base_url),
token: config.token.clone(),
route_tag: config.route_tag,
api_timeout: config.api_timeout,
http: reqwest::Client::new(),
}
}
fn common_headers(&self) -> Vec<(&'static str, String)> {
let mut h = vec![
("iLink-App-Id", ILINK_APP_ID.to_owned()),
(
"iLink-App-ClientVersion",
build_client_version(CHANNEL_VERSION).to_string(),
),
];
if let Some(tag) = self.route_tag {
h.push(("SKRouteTag", tag.to_string()));
}
h
}
fn post_headers(&self) -> Vec<(&'static str, String)> {
let mut h = vec![
("Content-Type", "application/json".to_owned()),
("AuthorizationType", "ilink_bot_token".to_owned()),
("X-WECHAT-UIN", random_wechat_uin()),
];
if !self.token.is_empty() {
h.push(("Authorization", format!("Bearer {}", self.token.trim())));
}
h.extend(self.common_headers());
h
}
async fn post_json<T: serde::de::DeserializeOwned>(
&self,
endpoint: &str,
body: &impl serde::Serialize,
timeout: Duration,
) -> Result<T> {
let url = format!("{}{endpoint}", self.base_url);
let body_str = serde_json::to_string(body)?;
tracing::debug!(
url = redact::redact_url(&url),
body = redact::redact_body_default(&body_str),
"POST"
);
let mut builder = self.http.post(&url).timeout(timeout).body(body_str);
for (k, v) in self.post_headers() {
builder = builder.header(k, v);
}
let response = builder.send().await?;
let status = response.status();
let raw = response.text().await?;
tracing::debug!(
status = %status,
body = redact::redact_body_default(&raw),
"response"
);
if !status.is_success() {
return Err(Error::Api {
errcode: i32::from(status.as_u16()),
errmsg: raw,
});
}
Ok(serde_json::from_str(&raw)?)
}
pub async fn get_updates(
&self,
request: &GetUpdatesRequest,
timeout: Duration,
) -> Result<GetUpdatesResponse> {
let url = format!("{}ilink/bot/getupdates", self.base_url);
let body_str = serde_json::to_string(request)?;
let mut builder = self.http.post(&url).timeout(timeout).body(body_str);
for (k, v) in self.post_headers() {
builder = builder.header(k, v);
}
match builder.send().await {
Ok(response) => {
let raw = response.text().await?;
Ok(serde_json::from_str(&raw)?)
}
Err(e) if e.is_timeout() => {
tracing::debug!("getUpdates: client-side timeout, returning empty response");
Ok(GetUpdatesResponse {
ret: Some(0),
msgs: Some(Vec::new()),
get_updates_buf: Some(request.get_updates_buf.clone()),
..Default::default()
})
}
Err(e) => Err(e.into()),
}
}
pub async fn send_message(&self, request: &SendMessageRequest) -> Result<()> {
let _: serde_json::Value = self
.post_json("ilink/bot/sendmessage", request, self.api_timeout)
.await?;
Ok(())
}
pub async fn get_upload_url(
&self,
request: &GetUploadUrlRequest,
) -> Result<GetUploadUrlResponse> {
self.post_json("ilink/bot/getuploadurl", request, self.api_timeout)
.await
}
pub async fn get_config(
&self,
user_id: &str,
context_token: Option<&str>,
) -> Result<GetConfigResponse> {
let body = GetConfigRequest {
ilink_user_id: user_id.to_owned(),
context_token: context_token.map(String::from),
base_info: build_base_info(),
};
self.post_json(
"ilink/bot/getconfig",
&body,
Duration::from_millis(DEFAULT_CONFIG_TIMEOUT_MS),
)
.await
}
pub async fn send_typing(&self, request: &SendTypingRequest) -> Result<()> {
let _: serde_json::Value = self
.post_json(
"ilink/bot/sendtyping",
request,
Duration::from_millis(DEFAULT_CONFIG_TIMEOUT_MS),
)
.await?;
Ok(())
}
pub async fn api_get(&self, endpoint: &str, timeout: Duration) -> Result<String> {
let url = format!("{}{endpoint}", self.base_url);
tracing::debug!(url = redact::redact_url(&url), "GET");
let mut builder = self.http.get(&url).timeout(timeout);
for (k, v) in self.common_headers() {
builder = builder.header(k, v);
}
let response = builder.send().await?;
let status = response.status();
let raw = response.text().await?;
if !status.is_success() {
return Err(Error::Api {
errcode: i32::from(status.as_u16()),
errmsg: raw,
});
}
Ok(raw)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_client_version_encoding() {
assert_eq!(build_client_version("2.1.1"), (2 << 16) | (1 << 8) | 1);
assert_eq!(build_client_version("1.0.0"), 1 << 16);
assert_eq!(build_client_version("0.0.1"), 1);
assert_eq!(build_client_version(""), 0);
}
#[test]
fn ensure_trailing_slash_adds() {
assert_eq!(
ensure_trailing_slash("https://example.com"),
"https://example.com/"
);
}
#[test]
fn ensure_trailing_slash_noop() {
assert_eq!(
ensure_trailing_slash("https://example.com/"),
"https://example.com/"
);
}
#[test]
fn random_wechat_uin_format() {
use base64::Engine;
let uin = random_wechat_uin();
let decoded = base64::engine::general_purpose::STANDARD
.decode(&uin)
.unwrap();
let s = std::str::from_utf8(&decoded).unwrap();
assert!(s.parse::<u32>().is_ok());
}
}