aibot 1.0.1

WeChat iLink Bot API — full async Rust implementation
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
//! # iLink Bot HTTP API 客户端
//!
//! 封装所有 iLink 协议端点的异步 HTTP 调用。
//!
//! ## 端点列表
//!
//! | 方法 | 路径 | 用途 |
//! |------|------|------|
//! | GET | `/ilink/bot/get_bot_qrcode` | 获取登录二维码 |
//! | GET | `/ilink/bot/get_qrcode_status` | 轮询扫码状态 |
//! | POST | `/ilink/bot/getupdates` | 长轮询接收消息 |
//! | POST | `/ilink/bot/sendmessage` | 发送消息 |
//! | POST | `/ilink/bot/getuploadurl` | 获取媒体上传地址 |
//! | POST | `/ilink/bot/sendtyping` | 发送"正在输入"状态 |
//! | POST | `/ilink/bot/ilink_bot_getconfig` | 获取 Bot 配置 |
//!
//! ## 请求流程
//!
//! ```text
//!   ┌──────────┐  get_bot_qrcode  ┌──────────┐
//!   │  扫码登录  │ ──────────────→ │ 获取二维码  │
//!   └──────────┘                  └──────────┘
//!        │                              │
//!        ▼                              ▼
//!   ┌──────────┐  wait_for_login   ┌──────────┐
//!   │ 微信扫码  │ ←────────────── │ 轮询状态   │
//!   └──────────┘                  └──────────┘
//!        │                              │
//!        ▼                              ▼
//!   ┌──────────┐                  ┌──────────┐
//!   │ 确认登录  │ ──────────────→ │ 获得 Token │
//!   └──────────┘                  └──────────┘
//!        │
//!        ▼
//!   ┌──────────┐  get_updates    ┌──────────┐
//!   │ 消息循环   │ ←───────────── │ 长轮询    │
//!   └──────────┘                  └──────────┘
//! ```

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},
};
/// iLink API 默认基础地址。
const DEFAULT_BASE_URL: &str = "https://ilinkai.weixin.qq.com";

/// 长轮询超时(服务端最长等待约 35 秒,客户端设为 45 秒)。
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);

/// 共享的 HTTP 客户端(`Axios` = `Axios`)。
///
/// 不再在 `ILinkClient` 中保存客户端实例,直接通过
/// `http().get(...)` / `http().post(...)` 发起请求。`Axios`
/// 克隆代价极低且共享底层连接池,因此共享一个实例即可。
static HTTP: OnceLock<Axios> = OnceLock::new();

/// 获取(首次调用时初始化)共享的 HTTP 客户端。
#[inline]
fn http() -> &'static Axios {
    HTTP.get_or_init(|| {
        Axios::builder()
            .timeout(DEFAULT_TIMEOUT)
            .build()
            .expect("构建 HTTP 客户端失败")
    })
}

/// iLink Bot API 的异步 HTTP 客户端。
///
/// 封装了所有 iLink 协议端点,提供类型安全的请求/响应接口。
///
/// # 使用示例
///
/// ```rust,no_run
/// use wechat_ilink_bot::ILinkClient;
///
/// # #[tokio::main]
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let client = ILinkClient::new("my_bot_token", None);
/// let updates = client.get_updates("").await?;
/// for msg in updates.msgs {
///     println!("来自: {}", msg.from_user_id);
/// }
/// # Ok(())
/// # }
/// ```
#[derive(Debug, Clone)]
pub struct ILinkClient {
    /// Bearer Token(扫码登录后获取)。
    bot_token: String,
    /// API 基础地址。
    base_url: String,
}

impl ILinkClient {
    /// 创建一个新的 iLink 客户端。
    ///
    /// ## 参数
    ///
    /// * `bot_token` — Bearer Token(如果还没 Token 可传空字符串,随后触发扫码登录)。
    /// * `base_url` — API 基础地址(`None` 则使用 `https://ilinkai.weixin.qq.com`)。
    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,
        }
    }

    /// 更新 Bot Token(扫码登录后调用)。
    pub fn set_bot_token(&mut self, token: impl Into<String>) {
        self.bot_token = token.into();
    }

    /// 获取当前 Bot Token。
    pub fn bot_token(&self) -> &str {
        &self.bot_token
    }

    /// 是否已经获取到 Token(已认证)。
    pub fn is_authenticated(&self) -> bool {
        !self.bot_token.is_empty()
    }

    // ========================================================================
    // Auth — 扫码登录
    // ========================================================================

    /// 获取登录二维码。
    ///
    /// 返回包含二维码标识(用于轮询)和 Base64 编码的 PNG 图片的响应。
    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)
    }

    /// 轮询二维码扫码状态。
    ///
    /// ## 状态说明
    ///
    /// | 状态 | 含义 |
    /// |------|------|
    /// | `"waiting"` | 等待用户扫码 |
    /// | `"scanned"` | 已扫码,等待确认 |
    /// | `"confirmed"` | 已确认,登录成功(此时 `bot_token` 字段有值) |
    /// | `"expired"` | 二维码已过期 |
    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)
    }

    /// 阻塞等待用户扫码确认,直到超时。
    ///
    /// ## 参数
    ///
    /// * `qrcode` — 二维码标识(来自 `get_bot_qrcode` 的响应)。
    /// * `poll_interval` — 轮询间隔(建议 2 秒)。
    /// * `max_wait` — 最大等待时间(建议 300 秒 = 5 分钟)。
    ///
    /// ## 返回值
    ///
    /// 成功时返回 `(bot_token, base_url)` 元组。
    ///
    /// ## 错误
    ///
    /// - 二维码过期 → [`BotError::Other`]
    /// - 等待超时 → [`BotError::Timeout`]
    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()
        )))
    }

    // ========================================================================
    // Messaging — 消息收发
    // ========================================================================

    /// 长轮询接收消息。
    ///
    /// 服务端会维持 HTTP 连接等待消息到达(最长约 35 秒)。
    /// 首次调用时 `cursor` 传空字符串,后续使用上次返回的 `get_updates_buf`。
    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)
    }

    /// 向微信用户发送消息。
    ///
    /// 接收一个 [`OutboundMessage`] 结构体,支持文本/图片/文件/语音等类型。
    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)
    }

    /// 快捷发送纯文本消息。
    ///
    /// ## 参数
    ///
    /// * `to_user_id` — 目标用户 ID。
    /// * `text` — 消息文本内容。
    /// * `context_token` — 上下文令牌(来自入站消息)。
    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
    }

    // ========================================================================
    // Media — 媒体上传
    // ========================================================================

    /// 获取媒体文件上传地址。
    ///
    /// 在上传媒体前需要先调用此接口获取 CDN 上传地址和参数。
    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)
    }

    /// 上传加密后的媒体文件到微信 CDN。
    ///
    /// ## 参数
    ///
    /// * `upload_url` — CDN 上传地址(来自 `get_upload_url`)。
    /// * `encrypted_data` — AES 加密后的文件数据。
    /// * `upload_param` — 上传参数(来自 `get_upload_url`)。
    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(())
    }

    /// 完整流程:加密并上传媒体文件。
    ///
    /// 此方法自动完成:
    ///
    /// 1. 生成随机 AES 密钥和文件标识
    /// 2. 计算原始文件 MD5 和大小
    /// 3. 使用 AES-128-ECB 加密文件
    /// 4. 获取 CDN 上传地址
    /// 5. 上传加密文件到 CDN
    /// 6. 返回构建消息体所需的参数
    ///
    /// ## 参数
    ///
    /// * `file_data` — 原始文件字节数据。
    /// * `media_type` — 媒体类型(3=图片, 4=文件, 5=语音)。
    /// * `to_user_id` — 接收方用户 ID。
    ///
    /// ## 返回值
    ///
    /// 返回 [`UploadedMedia`] 结构体,可用于构建 [`OutboundMediaItem`]。
    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));

        // 生成 16 字节随机 AES 密钥
        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;

        // 文件标识:16 位随机十六进制字符串(8 字节)
        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?;

        // 上传到 CDN
        self.upload_to_cdn(
            &upload_resp.upload_full_url,
            &encrypted,
            &upload_resp.upload_param,
        )
        .await?;

        // 构建消息所需的 AES 密钥格式:Base64(hex_string)
        let aes_key_b64 = Base64.encode(aes_key_hex.as_bytes());

        // 加密后的文件 MD5
        let encrypted_md5 = format!("{:x}", md5::compute(&encrypted));

        Ok(UploadedMedia {
            encrypt_query_param: upload_resp.upload_param,
            aes_key_b64,
            filesize,
            md5: encrypted_md5,
        })
    }

    // ========================================================================
    // Media — 媒体下载
    // ========================================================================

    /// 从 CDN 下载并解密媒体文件。
    ///
    /// ## 参数
    ///
    /// * `cdn_url` — 媒体 CDN 下载地址(来自消息体中的 `cdn_image_url` 等)。
    /// * `aes_key` — AES 密钥字符串(支持多种编码格式)。
    /// * `query_param` — 可选的加密查询参数(文件/语音类型需要)。
    ///
    /// ## 返回值
    ///
    /// 解密后的原始文件数据。
    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)
    }

    // ========================================================================
    // Typing — 正在输入状态
    // ========================================================================

    /// 发送"正在输入..."状态指示器。
    ///
    /// 需要先通过 `get_config` 获取 `typing_ticket`。
    ///
    /// ## 参数
    ///
    /// * `ilink_user_id` — 目标用户 ID。
    /// * `typing_ticket` — 打字状态票据(来自 `get_config`)。
    /// * `status` — 状态值(1 = 正在输入, 0 = 停止输入)。
    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)
    }

    // ========================================================================
    // Config — 配置
    // ========================================================================

    /// 获取 Bot 配置信息(包括打字状态票据等)。
    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)
    }

    // ========================================================================
    // 内部辅助方法
    // ========================================================================

    /// 拼接完整的 API URL。
    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
    }
}