ooodc 597881f72e feat: Implement WeChatBot SDK with error handling and message protocol
- Add WeChatBotError enum for error handling with various error types.
- Create a Result type alias for easier error management.
- Implement ILinkClient for low-level API interactions including QR code generation, message sending, and updates retrieval.
- Define message types and structures for handling incoming messages and media content.
- Add tests for error handling and message parsing to ensure reliability.

Co-authored-by: Copilot <copilot@github.com>
2026-05-06 14:18:47 +08:00

139 lines
3.9 KiB
Rust

//! Low-level CDN client for direct media download.
//!
//! [`CdnClient`] is a primitive layer that can be used independently of
//! [`WeChatBot`](crate::WeChatBot), e.g. when you drive `get_updates` yourself
//! via [`ILinkClient`](crate::protocol::ILinkClient) and only need decryption
//! for a specific attachment.
//!
//! Modeled after [`teloxide_core::Bot`]: wraps a [`reqwest::Client`] so
//! connection pool / TLS session / DNS cache are reused across calls, and is
//! cheap to [`Clone`].
use reqwest::Client;
use std::time::Duration;
use crate::crypto;
use crate::error::{Result, WeChatBotError};
use crate::protocol::CDN_BASE_URL;
use crate::types::CDNMedia;
/// HTTP client for WeChat CDN media endpoints.
///
/// Cheap to [`Clone`] — shares the underlying [`reqwest::Client`], which uses
/// an `Arc` internally.
///
/// # Example
///
/// ```no_run
/// use wechatbot::{CdnClient, CDNMedia};
///
/// # async fn demo(media: CDNMedia) -> Result<(), Box<dyn std::error::Error>> {
/// let cdn = CdnClient::new();
/// let bytes = cdn.download(&media, None).await?;
/// # Ok(())
/// # }
/// ```
#[derive(Debug, Clone)]
pub struct CdnClient {
http: Client,
base_url: String,
}
impl Default for CdnClient {
fn default() -> Self {
Self::new()
}
}
impl CdnClient {
/// Create a [`CdnClient`] with a fresh internal [`reqwest::Client`].
pub fn new() -> Self {
Self::with_client(Client::new())
}
/// Create a [`CdnClient`] that reuses an existing [`reqwest::Client`].
///
/// Useful when the caller already maintains a shared HTTP client with
/// custom proxy / TLS / timeout configuration.
pub fn with_client(http: Client) -> Self {
Self {
http,
base_url: CDN_BASE_URL.to_string(),
}
}
/// Override the CDN base URL (defaults to [`CDN_BASE_URL`]).
///
/// Primarily intended for tests and regional endpoints.
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = base_url.into();
self
}
/// Download and AES-decrypt a CDN media object.
///
/// `aes_key_override` is used when the decryption key is attached to the
/// message metadata (e.g. [`ImageContent::aes_key`](crate::ImageContent::aes_key))
/// rather than embedded in the media's own `aes_key` field.
pub async fn download(
&self,
media: &CDNMedia,
aes_key_override: Option<&str>,
) -> Result<Vec<u8>> {
let download_url = format!(
"{}/download?encrypted_query_param={}",
self.base_url,
urlencoding::encode(&media.encrypt_query_param)
);
let resp = self
.http
.get(&download_url)
.timeout(Duration::from_secs(60))
.send()
.await?;
if !resp.status().is_success() {
return Err(WeChatBotError::Media(format!(
"CDN download failed: HTTP {}",
resp.status()
)));
}
let ciphertext = resp.bytes().await?.to_vec();
let key_source = aes_key_override.unwrap_or(&media.aes_key);
if key_source.is_empty() {
return Err(WeChatBotError::Media("no AES key available".into()));
}
let aes_key = crypto::decode_aes_key(key_source)?;
crypto::decrypt_aes_ecb(&ciphertext, &aes_key)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_and_new_equivalent() {
let a = CdnClient::default();
let b = CdnClient::new();
assert_eq!(a.base_url, b.base_url);
}
#[test]
fn with_base_url_overrides() {
let c = CdnClient::new().with_base_url("https://example.test/cdn");
assert_eq!(c.base_url, "https://example.test/cdn");
}
#[test]
fn clone_is_cheap_and_preserves_config() {
let c = CdnClient::new().with_base_url("https://x.y/z");
let cloned = c.clone();
assert_eq!(c.base_url, cloned.base_url);
}
}