1use futures_core::Stream;
4use futures_util::StreamExt;
5use std::collections::{HashMap, VecDeque};
6use std::future::Future;
7use std::path::Path;
8use std::pin::Pin;
9use std::sync::Arc;
10use std::task::{Context, Poll};
11use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
12use tokio::sync::{broadcast, oneshot, Mutex, Notify, RwLock};
13use tokio::time::sleep;
14use tracing::{error, info, warn};
15
16use crate::cdn::CdnClient;
17use crate::crypto;
18use crate::error::{Result, WechatIlinkError};
19use crate::protocol::{self, ILinkClient, ILinkClientOptions};
20use crate::types::*;
21use md5::{Digest, Md5};
22use rand::Rng;
23use serde_json::{json, Value};
24use uuid::Uuid;
25
26const EVENT_CHANNEL_CAPACITY: usize = 256;
27
28#[derive(Debug, Clone)]
30pub enum WechatEvent {
31 ContextObserved(WechatContext),
33 Message(IncomingMessage),
35 CursorAdvanced { account_key: String, cursor: String },
37 AuthSessionExpired { account_key: String },
39 UserInteractionRequested {
44 account_key: String,
45 user_id: Option<String>,
46 reason: UserInteractionReason,
47 },
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct SendReceipt {
53 pub message_ids: Vec<String>,
54 pub visible_texts: Vec<String>,
55}
56
57impl SendReceipt {
58 pub fn last_message_id(&self) -> Option<&str> {
59 self.message_ids.last().map(String::as_str)
60 }
61
62 #[allow(dead_code)]
63 fn single(message_id: String) -> Self {
64 Self {
65 message_ids: vec![message_id],
66 visible_texts: Vec::new(),
67 }
68 }
69
70 fn empty() -> Self {
71 Self {
72 message_ids: Vec::new(),
73 visible_texts: Vec::new(),
74 }
75 }
76
77 fn append(&mut self, mut other: SendReceipt) {
78 self.message_ids.append(&mut other.message_ids);
79 self.visible_texts.append(&mut other.visible_texts);
80 }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum UserInteractionReason {
86 ContextExpiring {
88 observed_at: SystemTime,
89 expires_at: SystemTime,
90 remind_before: Duration,
91 },
92 OutboundRateLimitApproaching {
94 sent_count: usize,
95 window: Duration,
96 threshold: usize,
97 },
98}
99
100pub struct WechatEventStream<'a> {
102 inner: Pin<Box<dyn Stream<Item = Result<WechatEvent>> + Send + 'a>>,
103}
104
105impl<'a> WechatEventStream<'a> {
106 fn new(stream: impl Stream<Item = Result<WechatEvent>> + Send + 'a) -> Self {
107 Self {
108 inner: Box::pin(stream),
109 }
110 }
111
112 pub async fn next(&mut self) -> Option<Result<WechatEvent>> {
113 self.inner.next().await
114 }
115}
116
117impl Stream for WechatEventStream<'_> {
118 type Item = Result<WechatEvent>;
119
120 fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
121 self.inner.as_mut().poll_next(cx)
122 }
123}
124
125#[derive(Debug)]
127pub enum LoginQrEvent {
128 QrCode { content: String },
130 StatusChanged { status: String },
132 NeedVerifyCode {
134 prompt: String,
135 responder: VerifyCodeResponder,
136 },
137 Confirmed { credentials: Credentials },
139}
140
141#[derive(Debug)]
143pub struct VerifyCodeResponder {
144 tx: oneshot::Sender<Option<String>>,
145}
146
147impl VerifyCodeResponder {
148 pub fn send(self, code: impl Into<String>) -> std::result::Result<(), Option<String>> {
149 let code = Some(code.into());
150 self.tx.send(code)
151 }
152
153 pub fn cancel(self) -> std::result::Result<(), Option<String>> {
154 self.tx.send(None)
155 }
156}
157
158pub struct LoginQrStream<'a> {
160 inner: Pin<Box<dyn Stream<Item = Result<LoginQrEvent>> + Send + 'a>>,
161}
162
163impl<'a> LoginQrStream<'a> {
164 fn new(stream: impl Stream<Item = Result<LoginQrEvent>> + Send + 'a) -> Self {
165 Self {
166 inner: Box::pin(stream),
167 }
168 }
169
170 pub async fn next(&mut self) -> Option<Result<LoginQrEvent>> {
171 self.inner.next().await
172 }
173}
174
175impl Stream for LoginQrStream<'_> {
176 type Item = Result<LoginQrEvent>;
177
178 fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
179 self.inner.as_mut().poll_next(cx)
180 }
181}
182
183#[derive(Debug, Clone)]
185pub struct WechatRateLimitOptions {
186 pub retry_after: Duration,
188 pub retry_attempts: usize,
190 pub interaction_window: Duration,
192 pub interaction_threshold: usize,
194 pub context_ttl: Duration,
196 pub context_remind_before: Duration,
198}
199
200impl Default for WechatRateLimitOptions {
201 fn default() -> Self {
202 Self {
203 retry_after: protocol::DEFAULT_RATE_LIMIT_RETRY_AFTER,
204 retry_attempts: 5,
205 interaction_window: Duration::from_secs(5 * 60),
206 interaction_threshold: 6,
207 context_ttl: Duration::from_secs(24 * 60 * 60),
208 context_remind_before: Duration::from_secs(30 * 60),
209 }
210 }
211}
212
213#[derive(Default)]
214struct AccountRateLimitState {
215 sent_at: VecDeque<Instant>,
216 next_allowed_at: Option<Instant>,
217}
218
219struct ContextRefreshState {
220 user_id: String,
221 observed_at: SystemTime,
222 expires_at: SystemTime,
223 reminded: bool,
224}
225
226pub struct WechatIlinkClientBuilder {
228 base_url: Option<String>,
229 bot_agent: Option<String>,
230 ilink_app_id: Option<String>,
231 route_tag: Option<String>,
232 markdown_filter: bool,
233 rate_limit: WechatRateLimitOptions,
234 credentials: Option<Credentials>,
235 http_client: Option<reqwest::Client>,
236}
237
238impl Default for WechatIlinkClientBuilder {
239 fn default() -> Self {
240 Self {
241 base_url: None,
242 bot_agent: None,
243 ilink_app_id: None,
244 route_tag: None,
245 markdown_filter: true,
246 rate_limit: WechatRateLimitOptions::default(),
247 credentials: None,
248 http_client: None,
249 }
250 }
251}
252
253impl WechatIlinkClientBuilder {
254 pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
255 self.base_url = Some(base_url.into());
256 self
257 }
258
259 pub fn bot_agent(mut self, bot_agent: impl Into<String>) -> Self {
260 self.bot_agent = Some(bot_agent.into());
261 self
262 }
263
264 pub fn ilink_app_id(mut self, ilink_app_id: impl Into<String>) -> Self {
265 self.ilink_app_id = Some(ilink_app_id.into());
266 self
267 }
268
269 pub fn route_tag(mut self, route_tag: impl Into<String>) -> Self {
270 self.route_tag = Some(route_tag.into());
271 self
272 }
273
274 pub fn markdown_filter(mut self, enabled: bool) -> Self {
275 self.markdown_filter = enabled;
276 self
277 }
278
279 pub fn rate_limit_options(mut self, options: WechatRateLimitOptions) -> Self {
280 self.rate_limit = options;
281 self
282 }
283
284 pub fn rate_limit_retry_after(mut self, retry_after: Duration) -> Self {
285 self.rate_limit.retry_after = if retry_after.is_zero() {
286 protocol::DEFAULT_RATE_LIMIT_RETRY_AFTER
287 } else {
288 retry_after
289 };
290 self
291 }
292
293 pub fn rate_limit_retry_attempts(mut self, retry_attempts: usize) -> Self {
294 self.rate_limit.retry_attempts = retry_attempts;
295 self
296 }
297
298 pub fn rate_limit_max_retries(mut self, max_retries: usize) -> Self {
299 self.rate_limit.retry_attempts = max_retries;
300 self
301 }
302
303 pub fn context_ttl(mut self, ttl: Duration) -> Self {
304 if !ttl.is_zero() {
305 self.rate_limit.context_ttl = ttl;
306 }
307 self
308 }
309
310 pub fn context_expiry_remind_before(mut self, remind_before: Duration) -> Self {
311 self.rate_limit.context_remind_before = remind_before;
312 self
313 }
314
315 pub fn rate_limit_interaction_window(mut self, window: Duration) -> Self {
316 if !window.is_zero() {
317 self.rate_limit.interaction_window = window;
318 }
319 self
320 }
321
322 pub fn rate_limit_interaction_threshold(mut self, threshold: usize) -> Self {
323 self.rate_limit.interaction_threshold = threshold;
324 self
325 }
326
327 pub fn credentials(mut self, credentials: Credentials) -> Self {
328 self.credentials = Some(credentials);
329 self
330 }
331
332 pub fn http_client(mut self, http_client: reqwest::Client) -> Self {
333 self.http_client = Some(http_client);
334 self
335 }
336
337 pub fn build(self) -> WechatIlinkClient {
338 WechatIlinkClient::from_builder(self)
339 }
340}
341
342pub struct WechatIlinkClient {
344 client: Arc<ILinkClient>,
345 cdn: CdnClient,
346 credentials: RwLock<Option<Credentials>>,
347 event_tx: broadcast::Sender<WechatEvent>,
348 rate_limit_states: Mutex<HashMap<String, AccountRateLimitState>>,
349 context_refresh_states: Mutex<HashMap<(String, String), ContextRefreshState>>,
350 rate_limit_notify: Notify,
351 base_url: RwLock<String>,
352 stopped: RwLock<bool>,
353 rate_limit: WechatRateLimitOptions,
354}
355
356impl WechatIlinkClient {
357 pub fn new() -> Self {
359 Self::builder().build()
360 }
361
362 pub fn builder() -> WechatIlinkClientBuilder {
364 WechatIlinkClientBuilder::default()
365 }
366
367 fn from_builder(builder: WechatIlinkClientBuilder) -> Self {
368 let base_url = builder
369 .credentials
370 .as_ref()
371 .map(|creds| creds.base_url.clone())
372 .or(builder.base_url)
373 .unwrap_or_else(|| protocol::DEFAULT_BASE_URL.to_string());
374 let options = ILinkClientOptions {
375 bot_agent: builder.bot_agent,
376 route_tag: builder.route_tag,
377 ilink_app_id: builder.ilink_app_id,
378 markdown_filter: builder.markdown_filter,
379 };
380 let (client, cdn) = match builder.http_client {
381 Some(http_client) => (
382 ILinkClient::with_http_client_and_options(http_client.clone(), options),
383 CdnClient::with_client(http_client),
384 ),
385 None => (ILinkClient::with_options(options), CdnClient::new()),
386 };
387 Self {
388 client: Arc::new(client),
389 cdn,
390 credentials: RwLock::new(builder.credentials),
391 event_tx: broadcast::channel(EVENT_CHANNEL_CAPACITY).0,
392 rate_limit_states: Mutex::new(HashMap::new()),
393 context_refresh_states: Mutex::new(HashMap::new()),
394 rate_limit_notify: Notify::new(),
395 base_url: RwLock::new(base_url),
396 stopped: RwLock::new(false),
397 rate_limit: builder.rate_limit,
398 }
399 }
400
401 const MAX_QR_REFRESH: u32 = 3;
403 const FIXED_QR_BASE_URL: &'static str = "https://ilinkai.weixin.qq.com";
405
406 pub async fn set_credentials(&self, creds: Credentials) {
408 *self.base_url.write().await = creds.base_url.clone();
409 *self.credentials.write().await = Some(creds);
410 }
411
412 pub async fn credentials(&self) -> Option<Credentials> {
414 self.credentials.read().await.clone()
415 }
416
417 pub fn login_qr(&self) -> LoginQrStream<'_> {
423 LoginQrStream::new(async_stream::stream! {
424 let base_url = self.base_url.read().await.clone();
425 let mut qr_refresh_count = 0u32;
426 loop {
427 qr_refresh_count += 1;
428 if qr_refresh_count > Self::MAX_QR_REFRESH {
429 yield Err(WechatIlinkError::Auth(format!(
430 "QR code expired {} times — login aborted",
431 Self::MAX_QR_REFRESH
432 )));
433 return;
434 }
435
436 let qr = match self.client.get_qr_code(Self::FIXED_QR_BASE_URL).await {
437 Ok(qr) => qr,
438 Err(err) => {
439 yield Err(err);
440 return;
441 }
442 };
443 yield Ok(LoginQrEvent::QrCode {
444 content: qr.qrcode_img_content.clone(),
445 });
446
447 let mut last_status = String::new();
448 let mut current_poll_base_url = Self::FIXED_QR_BASE_URL.to_string();
449 let mut pending_verify_code: Option<String> = None;
450 loop {
451 let status = match self
452 .client
453 .poll_qr_status_with_verify_code(
454 ¤t_poll_base_url,
455 &qr.qrcode,
456 pending_verify_code.as_deref(),
457 )
458 .await
459 {
460 Ok(status) => status,
461 Err(err) => {
462 yield Err(err);
463 return;
464 }
465 };
466
467 if status.status != last_status {
468 last_status = status.status.clone();
469 match status.status.as_str() {
470 "scaned" => info!("QR scanned — confirm in WeChat"),
471 "expired" => warn!("QR expired — requesting new one"),
472 "confirmed" => info!("Login confirmed"),
473 "need_verifycode" => info!("QR verification code required"),
474 "verify_code_blocked" => warn!("QR verification code blocked"),
475 "binded_redirect" => warn!("QR already bound"),
476 _ => {}
477 }
478 yield Ok(LoginQrEvent::StatusChanged {
479 status: status.status.clone(),
480 });
481 }
482
483 if status.status == "wait" {
484 sleep(Duration::from_secs(1)).await;
485 continue;
486 }
487
488 if status.status == "need_verifycode" {
489 let prompt = if pending_verify_code.is_some() {
490 "QR verification code mismatch"
491 } else {
492 "QR verification code required"
493 };
494 let (tx, rx) = oneshot::channel();
495 yield Ok(LoginQrEvent::NeedVerifyCode {
496 prompt: prompt.to_string(),
497 responder: VerifyCodeResponder { tx },
498 });
499 let code = match rx.await {
500 Ok(Some(value)) => value.trim().to_string(),
501 _ => String::new(),
502 };
503 if code.is_empty() {
504 yield Err(WechatIlinkError::Auth(
505 "QR verification code was not provided".into(),
506 ));
507 return;
508 }
509 pending_verify_code = Some(code);
510 continue;
511 }
512
513 if status.status == "verify_code_blocked" {
514 break;
515 }
516
517 if status.status == "binded_redirect" {
518 yield Err(WechatIlinkError::Auth(
519 "QR login is already bound to this app".into(),
520 ));
521 return;
522 }
523
524 if status.status == "confirmed" {
525 let token = match status.bot_token {
526 Some(token) => token,
527 None => {
528 yield Err(WechatIlinkError::Auth("missing bot_token".into()));
529 return;
530 }
531 };
532 let creds = Credentials {
533 token,
534 base_url: status.baseurl.unwrap_or_else(|| base_url.clone()),
535 account_id: status.ilink_bot_id.unwrap_or_default(),
536 user_id: status.ilink_user_id.unwrap_or_default(),
537 saved_at: Some(chrono_now()),
538 };
539 *self.credentials.write().await = Some(creds.clone());
540 *self.base_url.write().await = creds.base_url.clone();
541 yield Ok(LoginQrEvent::Confirmed { credentials: creds });
542 return;
543 }
544
545 if status.status == "scaned_but_redirect" {
546 if let Some(ref host) = status.redirect_host {
547 current_poll_base_url = format!("https://{}", host);
548 info!("IDC redirect, switching polling host to {}", host);
549 } else {
550 warn!("Received scaned_but_redirect but redirect_host is missing");
551 }
552 sleep(Duration::from_secs(2)).await;
553 continue;
554 }
555
556 if status.status == "scaned" && pending_verify_code.is_some() {
557 pending_verify_code = None;
558 }
559
560 if status.status == "expired" {
561 break;
562 }
563
564 sleep(Duration::from_secs(2)).await;
565 }
566 }
567 })
568 }
569
570 pub fn events_from_cursor(
572 self: Arc<Self>,
573 cursor: Option<String>,
574 ) -> WechatEventStream<'static> {
575 let mut rx = self.event_tx.subscribe();
576 let runner = Arc::clone(&self);
577 let mut task =
578 tokio::spawn(async move { runner.run_loop(cursor.unwrap_or_default()).await });
579 let abort_on_drop = AbortOnDrop(task.abort_handle());
580 WechatEventStream::new(async_stream::stream! {
581 let _abort_on_drop = abort_on_drop;
582 loop {
583 tokio::select! {
584 biased;
585 result = &mut task => {
586 match result {
587 Ok(Ok(())) => return,
588 Ok(Err(err)) => {
589 yield Err(err);
590 return;
591 }
592 Err(err) => {
593 yield Err(WechatIlinkError::Other(format!(
594 "wechat poll task failed: {err}"
595 )));
596 return;
597 }
598 }
599 }
600 event = rx.recv() => match event {
601 Ok(event) => yield Ok(event),
602 Err(broadcast::error::RecvError::Lagged(skipped)) => {
603 yield Err(WechatIlinkError::Other(format!(
604 "wechat event stream lagged by {skipped} events"
605 )));
606 }
607 Err(broadcast::error::RecvError::Closed) => return,
608 }
609 }
610 }
611 })
612 }
613
614 fn emit_event(&self, event: WechatEvent) {
616 let _ = self.event_tx.send(event);
617 }
618
619 pub async fn reply(&self, msg: &IncomingMessage, text: &str) -> Result<SendReceipt> {
624 let context = msg
625 .context
626 .as_ref()
627 .ok_or_else(|| WechatIlinkError::NoContext(msg.user_id.clone()))?;
628 self.send_text_with_context(context, text).await
629 }
630
631 pub async fn send_text_with_context(
637 &self,
638 context: &WechatContext,
639 text: &str,
640 ) -> Result<SendReceipt> {
641 self.send_text(&context.user_id, text, &context.context_token)
642 .await
643 }
644
645 pub async fn send_media_with_context(
649 &self,
650 context: &WechatContext,
651 content: SendContent,
652 ) -> Result<SendReceipt> {
653 self.send_content(&context.user_id, &context.context_token, content)
654 .await
655 }
656
657 pub async fn send_typing_with_context(&self, context: &WechatContext) -> Result<()> {
661 let (base_url, token) = self.get_auth().await?;
662 let config = self
663 .retry_rate_limited(|| {
664 self.client
665 .get_config(&base_url, &token, &context.user_id, &context.context_token)
666 })
667 .await?;
668 if let Some(ticket) = config.typing_ticket {
669 self.retry_rate_limited(|| {
670 self.client
671 .send_typing(&base_url, &token, &context.user_id, &ticket, 1)
672 })
673 .await?;
674 }
675 Ok(())
676 }
677
678 pub async fn reply_media(
682 &self,
683 msg: &IncomingMessage,
684 content: SendContent,
685 ) -> Result<SendReceipt> {
686 let context = msg
687 .context
688 .as_ref()
689 .ok_or_else(|| WechatIlinkError::NoContext(msg.user_id.clone()))?;
690 self.send_media_with_context(context, content).await
691 }
692
693 pub async fn download(&self, msg: &IncomingMessage) -> Result<Option<DownloadedMedia>> {
696 if let Some(img) = msg.images.first() {
697 if let Some(ref media) = img.media {
698 let data = self.cdn.download(media, img.aes_key.as_deref()).await?;
699 return Ok(Some(DownloadedMedia {
700 data,
701 media_type: "image".into(),
702 file_name: None,
703 format: None,
704 }));
705 }
706 }
707 if let Some(file) = msg.files.first() {
708 if let Some(ref media) = file.media {
709 require_download_aes_key("file", media)?;
710 let data = self.cdn.download(media, None).await?;
711 return Ok(Some(DownloadedMedia {
712 data,
713 media_type: "file".into(),
714 file_name: Some(file.file_name.clone().unwrap_or_else(|| "file.bin".into())),
715 format: None,
716 }));
717 }
718 }
719 if let Some(video) = msg.videos.first() {
720 if let Some(ref media) = video.media {
721 require_download_aes_key("video", media)?;
722 let data = self.cdn.download(media, None).await?;
723 return Ok(Some(DownloadedMedia {
724 data,
725 media_type: "video".into(),
726 file_name: None,
727 format: None,
728 }));
729 }
730 }
731 if let Some(voice) = msg.voices.first() {
732 if let Some(ref media) = voice.media {
733 require_download_aes_key("voice", media)?;
734 let data = self.cdn.download(media, None).await?;
735 return Ok(Some(DownloadedMedia {
736 data,
737 media_type: "voice".into(),
738 file_name: None,
739 format: Some("silk".into()),
740 }));
741 }
742 }
743 Ok(None)
744 }
745
746 pub async fn download_raw(
748 &self,
749 media: &CDNMedia,
750 aeskey_override: Option<&str>,
751 ) -> Result<Vec<u8>> {
752 self.cdn.download(media, aeskey_override).await
753 }
754
755 pub async fn upload(
757 &self,
758 data: &[u8],
759 user_id: &str,
760 media_type: i32,
761 ) -> Result<UploadResult> {
762 let (base_url, token) = self.get_auth().await?;
763 self.cdn_upload(&base_url, &token, data, user_id, media_type)
764 .await
765 }
766
767 async fn run_loop(&self, mut cursor: String) -> Result<()> {
768 *self.stopped.write().await = false;
769 info!("Long-poll loop started");
770 let mut retry_delay = Duration::from_secs(1);
771
772 if let Ok((base_url, token)) = self.get_auth().await {
773 match self.client.notify_start(&base_url, &token).await {
774 Ok(resp) if resp.ret.is_some_and(|ret| ret != 0) => warn!(
775 "notify_start returned ret={:?} errmsg={:?}",
776 resp.ret, resp.errmsg
777 ),
778 Ok(_) => {}
779 Err(err) => warn!("notify_start failed: {}", err),
780 }
781 }
782
783 loop {
784 if *self.stopped.read().await {
785 break;
786 }
787
788 let (base_url, token) = self.get_auth().await?;
789
790 match self.client.get_updates(&base_url, &token, &cursor).await {
791 Ok(updates) => {
792 let account_key = self.account_key().await;
793 let new_cursor = updates.get_updates_buf.clone();
794 let mut cursor_changed = false;
795 if !new_cursor.is_empty() && cursor.as_str() != new_cursor.as_str() {
796 cursor_changed = true;
797 cursor = new_cursor.clone();
798 }
799 retry_delay = Duration::from_secs(1);
800
801 for wire in &updates.msgs {
802 if let Some(incoming) =
803 IncomingMessage::from_wire_for_account(wire, &account_key)
804 {
805 self.reset_account_rate_limit(&account_key).await;
806 if let Some(context) = incoming.context.clone() {
807 self.observe_context_for_interaction(&context).await;
808 self.emit_event(WechatEvent::ContextObserved(context));
809 }
810 self.emit_event(WechatEvent::Message(incoming.clone()));
811 }
812 }
813 self.emit_due_context_interaction_requests().await;
814 if cursor_changed {
815 self.emit_event(WechatEvent::CursorAdvanced {
816 account_key: account_key.clone(),
817 cursor: new_cursor.clone(),
818 });
819 }
820 }
821 Err(e) if e.is_session_expired() => {
822 warn!("Session expired — re-login required");
823 let account_key = self.account_key().await;
824 self.emit_event(WechatEvent::AuthSessionExpired {
825 account_key: account_key.clone(),
826 });
827 cursor.clear();
828 break;
829 }
830 Err(e) if e.is_rate_limited() => {
831 self.report_error(&e);
832 sleep(self.rate_limit.retry_after).await;
833 retry_delay = Duration::from_secs(1);
834 continue;
835 }
836 Err(e) => {
837 self.report_error(&e);
838 sleep(retry_delay).await;
839 retry_delay = std::cmp::min(retry_delay * 2, Duration::from_secs(10));
840 continue;
841 }
842 }
843 }
844
845 info!("Long-poll loop stopped");
846 if let Ok((base_url, token)) = self.get_auth().await {
847 match self.client.notify_stop(&base_url, &token).await {
848 Ok(resp) if resp.ret.is_some_and(|ret| ret != 0) => warn!(
849 "notify_stop returned ret={:?} errmsg={:?}",
850 resp.ret, resp.errmsg
851 ),
852 Ok(_) => {}
853 Err(err) => warn!("notify_stop failed: {}", err),
854 }
855 }
856 Ok(())
857 }
858
859 async fn retry_rate_limited<F, Fut, T>(&self, mut operation: F) -> Result<T>
860 where
861 F: FnMut() -> Fut,
862 Fut: Future<Output = Result<T>>,
863 {
864 let account_key = self.account_key().await;
865 if let Some(wait) = self.active_rate_limit_backoff(&account_key).await {
866 return Err(rate_limited_error(wait));
867 }
868
869 let mut retries = 0usize;
870 loop {
871 match operation().await {
872 Ok(value) => return Ok(value),
873 Err(err) if err.is_rate_limited() => {
874 let err = with_rate_limit_retry_after(err, self.rate_limit.retry_after);
875 self.defer_account_rate_limit(&account_key, self.rate_limit.retry_after)
876 .await;
877 if retries >= self.rate_limit.retry_attempts {
878 return Err(err);
879 }
880 retries += 1;
881 self.wait_for_rate_limit_reset_or_timeout(
882 &account_key,
883 self.rate_limit.retry_after,
884 )
885 .await;
886 }
887 Err(err) => return Err(err),
888 }
889 }
890 }
891
892 async fn active_rate_limit_backoff(&self, account_key: &str) -> Option<Duration> {
893 if account_key.is_empty() {
894 return None;
895 }
896 let mut states = self.rate_limit_states.lock().await;
897 let state = states.entry(account_key.to_string()).or_default();
898 match state.next_allowed_at {
899 Some(next_allowed_at) => match next_allowed_at.checked_duration_since(Instant::now()) {
900 Some(wait) if !wait.is_zero() => Some(wait),
901 _ => {
902 state.next_allowed_at = None;
903 None
904 }
905 },
906 None => None,
907 }
908 }
909
910 async fn wait_for_rate_limit_reset_or_timeout(&self, account_key: &str, wait: Duration) {
911 if account_key.is_empty() || wait.is_zero() {
912 return;
913 }
914 let _ = tokio::time::timeout(wait, self.rate_limit_notify.notified()).await;
915 }
916
917 async fn defer_account_rate_limit(&self, account_key: &str, retry_after: Duration) {
918 if account_key.is_empty() {
919 return;
920 }
921 let next_allowed_at = Instant::now() + retry_after;
922 let mut states = self.rate_limit_states.lock().await;
923 let state = states.entry(account_key.to_string()).or_default();
924 if state
925 .next_allowed_at
926 .map_or(true, |current| current < next_allowed_at)
927 {
928 state.next_allowed_at = Some(next_allowed_at);
929 }
930 }
931
932 async fn reset_account_rate_limit(&self, account_key: &str) {
933 if account_key.is_empty() {
934 return;
935 }
936 let mut states = self.rate_limit_states.lock().await;
937 let state = states.entry(account_key.to_string()).or_default();
938 state.sent_at.clear();
939 state.next_allowed_at = None;
940 drop(states);
941 self.rate_limit_notify.notify_waiters();
942 }
943
944 async fn observe_context_for_interaction(&self, context: &WechatContext) {
945 let observed_at = SystemTime::now();
946 let expires_at = observed_at + self.rate_limit.context_ttl;
947 self.context_refresh_states.lock().await.insert(
948 (context.account_key.clone(), context.user_id.clone()),
949 ContextRefreshState {
950 user_id: context.user_id.clone(),
951 observed_at,
952 expires_at,
953 reminded: false,
954 },
955 );
956 }
957
958 async fn emit_due_context_interaction_requests(&self) {
959 if self.rate_limit.context_ttl.is_zero() {
960 return;
961 }
962 let now = SystemTime::now();
963 let mut events = Vec::new();
964 {
965 let mut contexts = self.context_refresh_states.lock().await;
966 contexts.retain(|_, state| !(state.reminded && now > state.expires_at));
967 for ((account_key, _), state) in contexts.iter_mut() {
968 if state.reminded {
969 continue;
970 }
971 let remind_at = state
972 .expires_at
973 .checked_sub(self.rate_limit.context_remind_before)
974 .unwrap_or(state.observed_at);
975 if now >= remind_at {
976 state.reminded = true;
977 events.push(WechatEvent::UserInteractionRequested {
978 account_key: account_key.clone(),
979 user_id: Some(state.user_id.clone()),
980 reason: UserInteractionReason::ContextExpiring {
981 observed_at: state.observed_at,
982 expires_at: state.expires_at,
983 remind_before: self.rate_limit.context_remind_before,
984 },
985 });
986 }
987 }
988 }
989 for event in events {
990 self.emit_event(event);
991 }
992 }
993
994 async fn record_successful_message_send(&self) {
995 let account_key = self.account_key().await;
996 if account_key.is_empty() || self.rate_limit.interaction_threshold == 0 {
997 return;
998 }
999
1000 let now = Instant::now();
1001 let mut event = None;
1002 {
1003 let mut states = self.rate_limit_states.lock().await;
1004 let state = states.entry(account_key.clone()).or_default();
1005 while let Some(sent_at) = state.sent_at.front().copied() {
1006 if now.duration_since(sent_at) > self.rate_limit.interaction_window {
1007 state.sent_at.pop_front();
1008 } else {
1009 break;
1010 }
1011 }
1012 state.sent_at.push_back(now);
1013 let sent_count = state.sent_at.len();
1014 if sent_count == self.rate_limit.interaction_threshold {
1015 event = Some(WechatEvent::UserInteractionRequested {
1016 account_key,
1017 user_id: None,
1018 reason: UserInteractionReason::OutboundRateLimitApproaching {
1019 sent_count,
1020 window: self.rate_limit.interaction_window,
1021 threshold: self.rate_limit.interaction_threshold,
1022 },
1023 });
1024 }
1025 }
1026
1027 if let Some(event) = event {
1028 self.emit_event(event);
1029 }
1030 }
1031
1032 pub async fn stop(&self) {
1034 *self.stopped.write().await = true;
1035 }
1036
1037 fn send_content<'a>(
1040 &'a self,
1041 user_id: &'a str,
1042 context_token: &'a str,
1043 content: SendContent,
1044 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<SendReceipt>> + Send + 'a>> {
1045 Box::pin(async move {
1046 let (base_url, token) = self.get_auth().await?;
1047 match content {
1048 SendContent::Text(text) => self.send_text(user_id, &text, context_token).await,
1049 SendContent::Image { data, caption } => {
1050 let result = self
1051 .cdn_upload(&base_url, &token, &data, user_id, 1)
1052 .await?;
1053 let mut receipt = SendReceipt::empty();
1054 if let Some(cap) = caption {
1055 receipt.append(self.send_text(user_id, &cap, context_token).await?);
1056 }
1057 let item = json!({"type": 2, "image_item": {
1058 "media": cdn_media_json(&result.media),
1059 "mid_size": result.encrypted_file_size,
1060 }});
1061 receipt.append(
1062 self.send_media_item(&base_url, &token, user_id, context_token, item)
1063 .await?,
1064 );
1065 Ok(receipt)
1066 }
1067 SendContent::Video { data, caption } => {
1068 let result = self
1069 .cdn_upload(&base_url, &token, &data, user_id, 2)
1070 .await?;
1071 let mut receipt = SendReceipt::empty();
1072 if let Some(cap) = caption {
1073 receipt.append(self.send_text(user_id, &cap, context_token).await?);
1074 }
1075 let item = json!({"type": 5, "video_item": {
1076 "media": cdn_media_json(&result.media),
1077 "video_size": result.encrypted_file_size,
1078 }});
1079 receipt.append(
1080 self.send_media_item(&base_url, &token, user_id, context_token, item)
1081 .await?,
1082 );
1083 Ok(receipt)
1084 }
1085 SendContent::File {
1086 data,
1087 file_name,
1088 caption,
1089 } => {
1090 let cat = categorize_by_extension(&file_name);
1091 match cat {
1092 "image" => {
1093 self.send_content(
1094 user_id,
1095 context_token,
1096 SendContent::Image { data, caption },
1097 )
1098 .await
1099 }
1100 "video" => {
1101 self.send_content(
1102 user_id,
1103 context_token,
1104 SendContent::Video { data, caption },
1105 )
1106 .await
1107 }
1108 _ => {
1109 let mut receipt = SendReceipt::empty();
1110 if let Some(cap) = caption {
1111 receipt.append(self.send_text(user_id, &cap, context_token).await?);
1112 }
1113 let data_len = data.len();
1114 let result = self
1115 .cdn_upload(&base_url, &token, &data, user_id, 3)
1116 .await?;
1117 let item = json!({"type": 4, "file_item": {
1118 "media": cdn_media_json(&result.media),
1119 "file_name": file_name,
1120 "len": data_len.to_string(),
1121 }});
1122 receipt.append(
1123 self.send_media_item(
1124 &base_url,
1125 &token,
1126 user_id,
1127 context_token,
1128 item,
1129 )
1130 .await?,
1131 );
1132 Ok(receipt)
1133 }
1134 }
1135 }
1136 }
1137 })
1138 }
1139
1140 async fn send_media_item(
1141 &self,
1142 base_url: &str,
1143 token: &str,
1144 user_id: &str,
1145 context_token: &str,
1146 item: serde_json::Value,
1147 ) -> Result<SendReceipt> {
1148 let client_id = Uuid::new_v4().to_string();
1149 let msg = protocol::build_media_message_with_client_id(
1150 user_id,
1151 context_token,
1152 vec![item],
1153 &client_id,
1154 );
1155 let resp = self
1156 .retry_rate_limited(|| self.client.send_message(base_url, token, &msg))
1157 .await?;
1158 self.record_successful_message_send().await;
1159 Ok(SendReceipt {
1160 message_ids: collect_send_message_ids(&client_id, &resp),
1161 visible_texts: Vec::new(),
1162 })
1163 }
1164
1165 async fn cdn_upload(
1166 &self,
1167 base_url: &str,
1168 token: &str,
1169 data: &[u8],
1170 user_id: &str,
1171 media_type: i32,
1172 ) -> Result<UploadResult> {
1173 let aes_key = crypto::generate_aes_key();
1174 let ciphertext = crypto::encrypt_aes_ecb(data, &aes_key);
1175
1176 let mut filekey_buf = [0u8; 16];
1177 rand::rng().fill_bytes(&mut filekey_buf);
1178 let filekey = hex::encode(filekey_buf);
1179
1180 let raw_md5 = hex::encode(Md5::digest(data));
1181
1182 let params = protocol::GetUploadUrlParams {
1183 filekey: filekey.clone(),
1184 media_type,
1185 to_user_id: user_id.to_string(),
1186 rawsize: data.len(),
1187 rawfilemd5: raw_md5,
1188 filesize: ciphertext.len(),
1189 thumb_rawsize: None,
1190 thumb_rawfilemd5: None,
1191 thumb_filesize: None,
1192 no_need_thumb: true,
1193 aeskey: crypto::encode_aes_key_hex(&aes_key),
1194 };
1195
1196 let upload_resp = self.client.get_upload_url(base_url, token, ¶ms).await?;
1197 let upload_url = resolve_cdn_upload_url(&upload_resp, &filekey)?;
1198
1199 let encrypted_file_size = ciphertext.len();
1200
1201 let encrypt_query_param = self.client.upload_to_cdn(&upload_url, &ciphertext).await?;
1202
1203 Ok(UploadResult {
1204 media: CDNMedia {
1205 encrypt_query_param: Some(encrypt_query_param),
1206 aes_key: Some(crypto::encode_aes_key_base64(&aes_key)),
1207 encrypt_type: Some(1),
1208 full_url: None,
1209 },
1210 aes_key,
1211 encrypted_file_size,
1212 })
1213 }
1214
1215 async fn send_text(
1218 &self,
1219 user_id: &str,
1220 text: &str,
1221 context_token: &str,
1222 ) -> Result<SendReceipt> {
1223 let (base_url, token) = self.get_auth().await?;
1224 let mut message_ids = Vec::new();
1225 let mut visible_texts = Vec::new();
1226 for chunk in chunk_text(text, 4000) {
1227 let client_id = Uuid::new_v4().to_string();
1228 let msg = self.client.build_text_message_with_client_id(
1229 user_id,
1230 context_token,
1231 &chunk,
1232 &client_id,
1233 );
1234 let resp = self
1235 .retry_rate_limited(|| self.client.send_message(&base_url, &token, &msg))
1236 .await?;
1237 self.record_successful_message_send().await;
1238 for id in collect_send_message_ids(&client_id, &resp) {
1242 if !message_ids.iter().any(|existing| existing == &id) {
1243 message_ids.push(id);
1244 }
1245 }
1246 visible_texts.push(chunk);
1247 }
1248 Ok(SendReceipt {
1249 message_ids,
1250 visible_texts,
1251 })
1252 }
1253
1254 async fn get_auth(&self) -> Result<(String, String)> {
1255 let creds = self.credentials.read().await;
1256 let creds = creds
1257 .as_ref()
1258 .ok_or_else(|| WechatIlinkError::Auth("not logged in".into()))?;
1259 Ok((creds.base_url.clone(), creds.token.clone()))
1260 }
1261
1262 async fn account_key(&self) -> String {
1263 let creds = self.credentials.read().await;
1264 match creds.as_ref() {
1265 Some(c) => context_account_key(c).to_string(),
1266 None => String::new(),
1267 }
1268 }
1269
1270 fn report_error(&self, err: &WechatIlinkError) {
1271 error!("{}", err);
1272 }
1273}
1274
1275#[cfg(test)]
1276fn event_stream_from_receiver(
1277 mut rx: broadcast::Receiver<WechatEvent>,
1278) -> WechatEventStream<'static> {
1279 WechatEventStream::new(async_stream::stream! {
1280 loop {
1281 match rx.recv().await {
1282 Ok(event) => yield Ok(event),
1283 Err(broadcast::error::RecvError::Lagged(skipped)) => {
1284 yield Err(WechatIlinkError::Other(format!(
1285 "wechat event stream lagged by {skipped} events"
1286 )));
1287 }
1288 Err(broadcast::error::RecvError::Closed) => return,
1289 }
1290 }
1291 })
1292}
1293
1294struct AbortOnDrop(tokio::task::AbortHandle);
1295
1296impl Drop for AbortOnDrop {
1297 fn drop(&mut self) {
1298 self.0.abort();
1299 }
1300}
1301
1302fn rate_limited_error(retry_after: Duration) -> WechatIlinkError {
1303 WechatIlinkError::RateLimited {
1304 retry_after,
1305 message: "ret=-2".to_string(),
1306 http_status: 200,
1307 errcode: -2,
1308 }
1309}
1310
1311fn with_rate_limit_retry_after(err: WechatIlinkError, retry_after: Duration) -> WechatIlinkError {
1312 match err {
1313 WechatIlinkError::RateLimited {
1314 message,
1315 http_status,
1316 errcode,
1317 ..
1318 } => WechatIlinkError::RateLimited {
1319 retry_after,
1320 message,
1321 http_status,
1322 errcode,
1323 },
1324 other => other,
1325 }
1326}
1327
1328pub enum SendContent {
1330 Text(String),
1331 Image {
1332 data: Vec<u8>,
1333 caption: Option<String>,
1334 },
1335 Video {
1336 data: Vec<u8>,
1337 caption: Option<String>,
1338 },
1339 File {
1340 data: Vec<u8>,
1341 file_name: String,
1342 caption: Option<String>,
1343 },
1344}
1345
1346fn cdn_media_json(media: &CDNMedia) -> serde_json::Value {
1347 let mut v = json!({});
1348 if let Some(param) = &media.encrypt_query_param {
1349 v["encrypt_query_param"] = json!(param);
1350 }
1351 if let Some(key) = &media.aes_key {
1352 v["aes_key"] = json!(key);
1353 }
1354 if let Some(et) = media.encrypt_type {
1355 v["encrypt_type"] = json!(et);
1356 }
1357 if let Some(url) = &media.full_url {
1358 v["full_url"] = json!(url);
1359 }
1360 v
1361}
1362
1363fn resolve_cdn_upload_url(
1364 response: &protocol::GetUploadUrlResponse,
1365 filekey: &str,
1366) -> Result<String> {
1367 if let Some(upload_full_url) = response
1368 .upload_full_url
1369 .as_deref()
1370 .filter(|value| !value.is_empty())
1371 {
1372 return Ok(upload_full_url.to_string());
1373 }
1374 if let Some(upload_param) = response
1375 .upload_param
1376 .as_deref()
1377 .filter(|value| !value.is_empty())
1378 {
1379 return Ok(protocol::build_cdn_upload_url(
1380 protocol::CDN_BASE_URL,
1381 upload_param,
1382 filekey,
1383 ));
1384 }
1385 Err(WechatIlinkError::Media(
1386 "getuploadurl did not return upload_full_url or upload_param".into(),
1387 ))
1388}
1389
1390fn require_download_aes_key(media_type: &str, media: &CDNMedia) -> Result<()> {
1391 if media
1392 .aes_key
1393 .as_deref()
1394 .is_some_and(|value| !value.is_empty())
1395 {
1396 return Ok(());
1397 }
1398 Err(WechatIlinkError::Media(format!(
1399 "{} CDN media missing AES key",
1400 media_type
1401 )))
1402}
1403
1404fn categorize_by_extension(filename: &str) -> &'static str {
1405 let ext = Path::new(filename)
1406 .extension()
1407 .and_then(|e| e.to_str())
1408 .unwrap_or("")
1409 .to_lowercase();
1410 match ext.as_str() {
1411 "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "svg" => "image",
1412 "mp4" | "mov" | "webm" | "mkv" | "avi" => "video",
1413 _ => "file",
1414 }
1415}
1416
1417pub(crate) fn collect_send_message_ids(client_id: &str, resp: &Value) -> Vec<String> {
1423 let mut ids = vec![client_id.to_string()];
1424 let mut push = |s: String| {
1425 if !s.is_empty() && !ids.iter().any(|existing| existing == &s) {
1426 ids.push(s);
1427 }
1428 };
1429 let mut consider = |v: &Value| {
1430 if let Some(n) = v.as_i64() {
1431 push(n.to_string());
1432 } else if let Some(u) = v.as_u64() {
1433 push(u.to_string());
1434 } else if let Some(s) = v.as_str() {
1435 push(s.to_string());
1436 }
1437 };
1438 for path in [
1440 "/message_id",
1441 "/msg_id",
1442 "/msg/message_id",
1443 "/msg/msg_id",
1444 "/data/message_id",
1445 "/data/msg_id",
1446 "/result/message_id",
1447 "/result/msg_id",
1448 ] {
1449 if let Some(v) = resp.pointer(path) {
1450 consider(v);
1451 }
1452 }
1453 if let Some(obj) = resp.as_object() {
1455 for (k, v) in obj {
1456 let key = k.to_ascii_lowercase();
1457 if key.contains("message_id") || key == "msg_id" || key.ends_with("_msg_id") {
1458 consider(v);
1459 }
1460 }
1461 }
1462 ids
1463}
1464
1465fn chunk_text(text: &str, limit: usize) -> Vec<String> {
1466 if text.len() <= limit {
1467 return vec![text.to_string()];
1468 }
1469 let mut chunks = Vec::new();
1470 let mut remaining = text;
1471 while !remaining.is_empty() {
1472 if remaining.len() <= limit {
1473 chunks.push(remaining.to_string());
1474 break;
1475 }
1476 let window_end = char_boundary_at_or_before(remaining, limit);
1477 let window = &remaining[..window_end];
1478 let cut = window
1479 .rfind("\n\n")
1480 .filter(|&i| i > window_end * 3 / 10)
1481 .map(|i| i + 2)
1482 .or_else(|| {
1483 window
1484 .rfind('\n')
1485 .filter(|&i| i > window_end * 3 / 10)
1486 .map(|i| i + 1)
1487 })
1488 .or_else(|| {
1489 window
1490 .rfind(' ')
1491 .filter(|&i| i > window_end * 3 / 10)
1492 .map(|i| i + 1)
1493 })
1494 .unwrap_or(window_end);
1495 chunks.push(remaining[..cut].to_string());
1496 remaining = &remaining[cut..];
1497 }
1498 if chunks.is_empty() {
1499 vec![String::new()]
1500 } else {
1501 chunks
1502 }
1503}
1504
1505fn char_boundary_at_or_before(text: &str, limit: usize) -> usize {
1506 let mut end = limit.min(text.len());
1507 while !text.is_char_boundary(end) {
1508 end -= 1;
1509 }
1510 end
1511}
1512
1513fn context_account_key(creds: &Credentials) -> &str {
1514 if !creds.account_id.is_empty() {
1515 &creds.account_id
1516 } else {
1517 &creds.user_id
1518 }
1519}
1520
1521fn chrono_now() -> String {
1522 let dur = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
1524 format!("{}Z", dur.as_secs())
1525}
1526
1527#[cfg(test)]
1528mod tests {
1529 use super::*;
1530
1531 #[test]
1532 fn builder_accepts_caller_provided_reqwest_client() {
1533 let http_client = reqwest::Client::new();
1534 let _client = WechatIlinkClient::builder()
1535 .http_client(http_client)
1536 .build();
1537 }
1538
1539 #[test]
1540 fn chunk_text_short() {
1541 let chunks = chunk_text("hello", 100);
1542 assert_eq!(chunks, vec!["hello"]);
1543 }
1544
1545 #[test]
1546 fn chunk_text_empty() {
1547 let chunks = chunk_text("", 100);
1548 assert_eq!(chunks, vec![""]);
1549 }
1550
1551 #[test]
1552 fn chunk_text_splits_on_paragraph() {
1553 let text = "aaaa\n\nbbbb";
1554 let chunks = chunk_text(text, 7);
1555 assert_eq!(chunks, vec!["aaaa\n\n", "bbbb"]);
1556 }
1557
1558 #[test]
1559 fn chunk_text_splits_on_newline() {
1560 let text = "aaaa\nbbbb";
1561 let chunks = chunk_text(text, 7);
1562 assert_eq!(chunks, vec!["aaaa\n", "bbbb"]);
1563 }
1564
1565 #[test]
1566 fn chunk_text_exact_limit() {
1567 let text = "abcdef";
1568 let chunks = chunk_text(text, 6);
1569 assert_eq!(chunks, vec!["abcdef"]);
1570 }
1571
1572 #[test]
1573 fn chunk_text_does_not_split_utf8_char_boundary() {
1574 let mut text = "a".repeat(3999);
1575 text.push('启');
1576 text.push_str("tail");
1577
1578 let chunks = chunk_text(&text, 4000);
1579
1580 assert_eq!(chunks.concat(), text);
1581 assert!(chunks.iter().all(|chunk| chunk.len() <= 4000));
1582 }
1583
1584 #[test]
1585 fn categorize_image_extensions() {
1586 assert_eq!(categorize_by_extension("photo.png"), "image");
1587 assert_eq!(categorize_by_extension("photo.JPG"), "image");
1588 assert_eq!(categorize_by_extension("anim.gif"), "image");
1589 assert_eq!(categorize_by_extension("pic.webp"), "image");
1590 }
1591
1592 #[test]
1593 fn categorize_video_extensions() {
1594 assert_eq!(categorize_by_extension("clip.mp4"), "video");
1595 assert_eq!(categorize_by_extension("clip.MOV"), "video");
1596 assert_eq!(categorize_by_extension("movie.webm"), "video");
1597 }
1598
1599 #[test]
1600 fn categorize_file_extensions() {
1601 assert_eq!(categorize_by_extension("report.pdf"), "file");
1602 assert_eq!(categorize_by_extension("data.csv"), "file");
1603 assert_eq!(categorize_by_extension("noext"), "file");
1604 }
1605
1606 #[test]
1607 fn cdn_media_json_with_encrypt_type() {
1608 let media = CDNMedia {
1609 encrypt_query_param: Some("param=1".to_string()),
1610 aes_key: Some("key123".to_string()),
1611 encrypt_type: Some(1),
1612 full_url: None,
1613 };
1614 let j = cdn_media_json(&media);
1615 assert_eq!(j["encrypt_query_param"], "param=1");
1616 assert_eq!(j["aes_key"], "key123");
1617 assert_eq!(j["encrypt_type"], 1);
1618 }
1619
1620 #[test]
1621 fn cdn_media_json_without_encrypt_type() {
1622 let media = CDNMedia {
1623 encrypt_query_param: Some("p".to_string()),
1624 aes_key: Some("k".to_string()),
1625 encrypt_type: None,
1626 full_url: None,
1627 };
1628 let j = cdn_media_json(&media);
1629 assert!(j.get("encrypt_type").is_none());
1630 }
1631
1632 #[test]
1633 fn upload_url_prefers_full_url() {
1634 let response = protocol::GetUploadUrlResponse {
1635 upload_param: Some("param".to_string()),
1636 thumb_upload_param: None,
1637 upload_full_url: Some("https://cdn.example/upload".to_string()),
1638 };
1639
1640 let upload_url = resolve_cdn_upload_url(&response, "file-key").unwrap();
1641 assert_eq!(upload_url, "https://cdn.example/upload");
1642 }
1643
1644 #[test]
1645 fn upload_url_falls_back_to_upload_param() {
1646 let response = protocol::GetUploadUrlResponse {
1647 upload_param: Some("param value".to_string()),
1648 thumb_upload_param: None,
1649 upload_full_url: None,
1650 };
1651
1652 let upload_url = resolve_cdn_upload_url(&response, "file key").unwrap();
1653 assert_eq!(
1654 upload_url,
1655 format!(
1656 "{}/upload?encrypted_query_param=param%20value&filekey=file%20key",
1657 protocol::CDN_BASE_URL
1658 )
1659 );
1660 }
1661
1662 #[test]
1663 fn upload_url_requires_full_url_or_upload_param() {
1664 let response = protocol::GetUploadUrlResponse {
1665 upload_param: None,
1666 thumb_upload_param: None,
1667 upload_full_url: None,
1668 };
1669
1670 let err = resolve_cdn_upload_url(&response, "file-key").unwrap_err();
1671 assert!(matches!(err, WechatIlinkError::Media(_)));
1672 }
1673
1674 #[test]
1675 fn non_image_download_requires_media_aes_key() {
1676 let media = CDNMedia {
1677 encrypt_query_param: Some("param".to_string()),
1678 aes_key: None,
1679 encrypt_type: Some(1),
1680 full_url: None,
1681 };
1682
1683 let err = require_download_aes_key("voice", &media).unwrap_err();
1684 assert!(matches!(err, WechatIlinkError::Media(_)));
1685 }
1686
1687 #[test]
1688 fn non_image_download_accepts_media_aes_key() {
1689 let media = CDNMedia {
1690 encrypt_query_param: Some("param".to_string()),
1691 aes_key: Some("key".to_string()),
1692 encrypt_type: Some(1),
1693 full_url: None,
1694 };
1695
1696 require_download_aes_key("file", &media).unwrap();
1697 }
1698
1699 #[tokio::test]
1700 async fn events_from_cursor_reports_missing_auth_without_storing_cursor() {
1701 let client = Arc::new(WechatIlinkClient::new());
1702 let mut events = client.events_from_cursor(Some("external-cursor".to_string()));
1703
1704 let err = events
1705 .next()
1706 .await
1707 .expect("stream item")
1708 .expect_err("missing auth should be surfaced through stream");
1709
1710 assert!(matches!(err, WechatIlinkError::Auth(_)));
1711 }
1712
1713 #[test]
1714 fn rate_limit_builder_defaults_and_overrides_are_available() {
1715 let default_options = WechatRateLimitOptions::default();
1716 assert_eq!(default_options.retry_after, Duration::from_secs(90));
1717 assert_eq!(default_options.retry_attempts, 5);
1718 assert_eq!(default_options.interaction_window, Duration::from_secs(300));
1719 assert_eq!(default_options.interaction_threshold, 6);
1720 assert_eq!(
1721 default_options.context_ttl,
1722 Duration::from_secs(24 * 60 * 60)
1723 );
1724
1725 let client = WechatIlinkClient::builder()
1726 .rate_limit_retry_after(Duration::from_secs(60))
1727 .rate_limit_max_retries(2)
1728 .rate_limit_interaction_window(Duration::from_secs(120))
1729 .rate_limit_interaction_threshold(4)
1730 .context_ttl(Duration::from_secs(3600))
1731 .context_expiry_remind_before(Duration::from_secs(300))
1732 .build();
1733
1734 assert_eq!(client.rate_limit.retry_after, Duration::from_secs(60));
1735 assert_eq!(client.rate_limit.retry_attempts, 2);
1736 assert_eq!(
1737 client.rate_limit.interaction_window,
1738 Duration::from_secs(120)
1739 );
1740 assert_eq!(client.rate_limit.interaction_threshold, 4);
1741 assert_eq!(client.rate_limit.context_ttl, Duration::from_secs(3600));
1742 assert_eq!(
1743 client.rate_limit.context_remind_before,
1744 Duration::from_secs(300)
1745 );
1746 }
1747
1748 #[tokio::test]
1749 async fn outbound_threshold_emits_interaction_event_and_incoming_resets_window() {
1750 let client = WechatIlinkClient::builder()
1751 .rate_limit_interaction_threshold(3)
1752 .rate_limit_interaction_window(Duration::from_secs(300))
1753 .build();
1754 client.set_credentials(test_credentials()).await;
1755 let mut events = event_stream_from_receiver(client.event_tx.subscribe());
1756
1757 client.record_successful_message_send().await;
1758 client.record_successful_message_send().await;
1759 assert!(
1760 tokio::time::timeout(Duration::from_millis(5), events.next())
1761 .await
1762 .is_err()
1763 );
1764 client.record_successful_message_send().await;
1765 assert_rate_limit_event(events.next().await, "account-1", 3, 3);
1766 client.record_successful_message_send().await;
1767 assert!(
1768 tokio::time::timeout(Duration::from_millis(5), events.next())
1769 .await
1770 .is_err()
1771 );
1772
1773 client.reset_account_rate_limit("account-1").await;
1774 client.record_successful_message_send().await;
1775 client.record_successful_message_send().await;
1776 client.record_successful_message_send().await;
1777 assert_rate_limit_event(events.next().await, "account-1", 3, 3);
1778 }
1779
1780 #[tokio::test]
1781 async fn context_expiry_emits_user_interaction_event_and_new_context_resets_it() {
1782 let client = WechatIlinkClient::builder()
1783 .context_ttl(Duration::from_millis(5))
1784 .context_expiry_remind_before(Duration::from_millis(5))
1785 .build();
1786 let mut events = event_stream_from_receiver(client.event_tx.subscribe());
1787
1788 let context = WechatContext {
1789 account_key: "account-1".to_string(),
1790 user_id: "user-1".to_string(),
1791 context_token: "ctx-1".to_string(),
1792 observed_at_unix_ms: 1,
1793 source_message_id: Some("msg-1".to_string()),
1794 };
1795 client.observe_context_for_interaction(&context).await;
1796 tokio::time::sleep(Duration::from_millis(10)).await;
1797 client.emit_due_context_interaction_requests().await;
1798 assert_context_expiring_event(events.next().await, "account-1", "user-1");
1799
1800 client.observe_context_for_interaction(&context).await;
1801 tokio::time::sleep(Duration::from_millis(10)).await;
1802 client.emit_due_context_interaction_requests().await;
1803 assert_context_expiring_event(events.next().await, "account-1", "user-1");
1804 }
1805
1806 #[tokio::test]
1807 async fn retry_rate_limited_uses_configured_retry_count() {
1808 use std::sync::atomic::{AtomicUsize, Ordering};
1809
1810 let client = WechatIlinkClient::builder()
1811 .rate_limit_retry_after(Duration::from_millis(1))
1812 .rate_limit_max_retries(2)
1813 .build();
1814 client.set_credentials(test_credentials()).await;
1815 let attempts = Arc::new(AtomicUsize::new(0));
1816 let attempts_for_operation = Arc::clone(&attempts);
1817
1818 let err = client
1819 .retry_rate_limited(move || {
1820 let attempts_for_operation = Arc::clone(&attempts_for_operation);
1821 async move {
1822 attempts_for_operation.fetch_add(1, Ordering::SeqCst);
1823 Err::<(), _>(rate_limited_error(Duration::from_millis(1)))
1824 }
1825 })
1826 .await
1827 .expect_err("rate limit should remain after configured retries");
1828
1829 assert!(err.is_rate_limited());
1830 assert_eq!(attempts.load(Ordering::SeqCst), 3);
1831 }
1832
1833 #[tokio::test]
1834 async fn active_rate_limit_backoff_returns_error_without_queueing_new_request() {
1835 let client = WechatIlinkClient::builder()
1836 .rate_limit_retry_after(Duration::from_secs(90))
1837 .build();
1838 client.set_credentials(test_credentials()).await;
1839 client
1840 .defer_account_rate_limit("account-1", Duration::from_secs(90))
1841 .await;
1842
1843 let mut called = false;
1844 let err = client
1845 .retry_rate_limited(|| {
1846 called = true;
1847 async { Ok::<_, WechatIlinkError>(()) }
1848 })
1849 .await
1850 .expect_err("active backoff should return immediately");
1851
1852 assert!(!called);
1853 assert!(err.is_rate_limited());
1854 }
1855
1856 #[tokio::test]
1857 async fn event_stream_accepts_context_observed_and_cursor_events() {
1858 let client = WechatIlinkClient::new();
1859 let mut events = event_stream_from_receiver(client.event_tx.subscribe());
1860
1861 client.emit_event(WechatEvent::ContextObserved(WechatContext {
1862 account_key: "account-1".to_string(),
1863 user_id: "user-1".to_string(),
1864 context_token: "ctx-1".to_string(),
1865 observed_at_unix_ms: 1,
1866 source_message_id: Some("msg-1".to_string()),
1867 }));
1868 client.emit_event(WechatEvent::CursorAdvanced {
1869 account_key: "account-1".to_string(),
1870 cursor: "cursor-2".to_string(),
1871 });
1872
1873 assert_eq!(format_event(events.next().await), "context:user-1");
1874 assert_eq!(format_event(events.next().await), "cursor:cursor-2");
1875 }
1876
1877 fn assert_rate_limit_event(
1878 event: Option<Result<WechatEvent>>,
1879 expected_account: &str,
1880 expected_sent_count: usize,
1881 expected_threshold: usize,
1882 ) {
1883 match event.expect("event").expect("ok event") {
1884 WechatEvent::UserInteractionRequested {
1885 account_key,
1886 reason:
1887 UserInteractionReason::OutboundRateLimitApproaching {
1888 sent_count,
1889 threshold,
1890 ..
1891 },
1892 ..
1893 } => {
1894 assert_eq!(account_key, expected_account);
1895 assert_eq!(sent_count, expected_sent_count);
1896 assert_eq!(threshold, expected_threshold);
1897 }
1898 other => panic!("expected rate-limit interaction event, got {other:?}"),
1899 }
1900 }
1901
1902 fn assert_context_expiring_event(
1903 event: Option<Result<WechatEvent>>,
1904 expected_account: &str,
1905 expected_user: &str,
1906 ) {
1907 match event.expect("event").expect("ok event") {
1908 WechatEvent::UserInteractionRequested {
1909 account_key,
1910 user_id,
1911 reason: UserInteractionReason::ContextExpiring { .. },
1912 } => {
1913 assert_eq!(account_key, expected_account);
1914 assert_eq!(user_id.as_deref(), Some(expected_user));
1915 }
1916 other => panic!("expected context-expiring interaction event, got {other:?}"),
1917 }
1918 }
1919
1920 fn format_event(event: Option<Result<WechatEvent>>) -> String {
1921 match event.expect("event").expect("ok event") {
1922 WechatEvent::ContextObserved(ctx) => format!("context:{}", ctx.user_id),
1923 WechatEvent::CursorAdvanced { cursor, .. } => format!("cursor:{cursor}"),
1924 WechatEvent::Message(msg) => format!("message:{}", msg.user_id),
1925 WechatEvent::AuthSessionExpired { account_key } => format!("auth:{account_key}"),
1926 WechatEvent::UserInteractionRequested { reason, .. } => match reason {
1927 UserInteractionReason::OutboundRateLimitApproaching { sent_count, .. } => {
1928 format!("rate-limit:{sent_count}")
1929 }
1930 UserInteractionReason::ContextExpiring { .. } => "context-expiring".into(),
1931 },
1932 }
1933 }
1934
1935 fn test_credentials() -> Credentials {
1936 Credentials {
1937 token: "token-1".into(),
1938 base_url: "https://example.invalid".into(),
1939 account_id: "account-1".into(),
1940 user_id: "bot-1".into(),
1941 saved_at: None,
1942 }
1943 }
1944
1945 #[tokio::test]
1946 async fn credentials_are_supplied_by_caller() {
1947 let client = WechatIlinkClient::new();
1948 client.set_credentials(test_credentials()).await;
1949
1950 let creds = client.credentials().await.unwrap();
1951 assert_eq!(creds.account_id, "account-1");
1952 }
1953
1954 #[test]
1955 fn collect_send_message_ids_keeps_client_and_server_ids() {
1956 let resp = serde_json::json!({
1957 "ret": 0,
1958 "message_id": 7481615214657239688i64,
1959 });
1960 let ids = collect_send_message_ids("5c70a44d-7548-4afd-88fc-8320c7d3aab1", &resp);
1961 assert!(ids.contains(&"5c70a44d-7548-4afd-88fc-8320c7d3aab1".to_string()));
1962 assert!(ids.contains(&"7481615214657239688".to_string()));
1963 }
1964}