Skip to main content

wechat_ilink/
bot.rs

1//! Main WechatIlinkClient.
2
3use 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/// Lifecycle events emitted by the WeChat iLink client.
29#[derive(Debug, Clone)]
30pub enum WechatEvent {
31    /// A new context token was observed from an incoming wire message.
32    ContextObserved(WechatContext),
33    /// An incoming parsed message.
34    Message(IncomingMessage),
35    /// The polling cursor advanced after processing a batch of messages.
36    CursorAdvanced { account_key: String, cursor: String },
37    /// The current auth session has expired and re-login is required.
38    AuthSessionExpired { account_key: String },
39    /// The SDK observed that user interaction would refresh WeChat state.
40    ///
41    /// The SDK does not send a reminder. Applications decide whether to notify
42    /// users and what wording/channel to use.
43    UserInteractionRequested {
44        account_key: String,
45        user_id: Option<String>,
46        reason: UserInteractionReason,
47    },
48}
49
50/// Message ids produced by a send operation.
51#[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/// Why the SDK suggests asking a WeChat user to interact.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum UserInteractionReason {
86    /// A stored context is nearing expiry.
87    ContextExpiring {
88        observed_at: SystemTime,
89        expires_at: SystemTime,
90        remind_before: Duration,
91    },
92    /// Proactive outbound sends are close to the bot-wide iLink rate limit.
93    OutboundRateLimitApproaching {
94        sent_count: usize,
95        window: Duration,
96        threshold: usize,
97    },
98}
99
100/// A stream of WeChat lifecycle events.
101pub 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/// Events emitted during QR login.
126#[derive(Debug)]
127pub enum LoginQrEvent {
128    /// A new QR code should be displayed or rendered by the application.
129    QrCode { content: String },
130    /// Login status changed.
131    StatusChanged { status: String },
132    /// WeChat requires a verification code before continuing.
133    NeedVerifyCode {
134        prompt: String,
135        responder: VerifyCodeResponder,
136    },
137    /// Login completed and credentials were installed into the client.
138    Confirmed { credentials: Credentials },
139}
140
141/// One-shot responder for [`LoginQrEvent::NeedVerifyCode`].
142#[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
158/// A stream of QR login events.
159pub 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/// Rate-limit and context-refresh policy for iLink requests.
184#[derive(Debug, Clone)]
185pub struct WechatRateLimitOptions {
186    /// Delay used before retrying `ret=-2` / `errcode=-2`.
187    pub retry_after: Duration,
188    /// Number of retries after the initial rate-limited attempt.
189    pub retry_attempts: usize,
190    /// Rolling account window used to request proactive user interaction.
191    pub interaction_window: Duration,
192    /// Emit interaction event at this send count.
193    pub interaction_threshold: usize,
194    /// Expected context lifetime after a user WeChat message.
195    pub context_ttl: Duration,
196    /// Emit context-expiring interaction event this long before expiry.
197    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
226/// Builder for [`WechatIlinkClient`].
227pub 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
342/// WechatIlinkClient is the main entry point for the WeChat iLink protocol.
343pub 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    /// Create a default client instance.
358    pub fn new() -> Self {
359        Self::builder().build()
360    }
361
362    /// Create a client builder.
363    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    /// Maximum number of QR code refresh attempts before giving up.
402    const MAX_QR_REFRESH: u32 = 3;
403    /// Fixed API base URL for QR code requests.
404    const FIXED_QR_BASE_URL: &'static str = "https://ilinkai.weixin.qq.com";
405
406    /// Install externally loaded credentials into this client.
407    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    /// Return the currently installed credentials, if any.
413    pub async fn credentials(&self) -> Option<Credentials> {
414        self.credentials.read().await.clone()
415    }
416
417    /// Start QR login and return a stream of login events.
418    ///
419    /// Applications display [`LoginQrEvent::QrCode`], optionally answer
420    /// [`LoginQrEvent::NeedVerifyCode`], and persist credentials from
421    /// [`LoginQrEvent::Confirmed`].
422    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                            &current_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    /// Start polling from an externally persisted cursor and stream resulting events.
571    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    /// Emit an event to active streams.
615    fn emit_event(&self, event: WechatEvent) {
616        let _ = self.event_tx.send(event);
617    }
618
619    /// Reply to an incoming message using its observed context.
620    ///
621    /// Returns an error if the incoming message has no context (e.g. the
622    /// context_token was empty in the wire message).
623    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    /// Send text using an explicit [`WechatContext`].
632    ///
633    /// The caller is responsible for supplying a valid context. This is the
634    /// preferred way to send a reply when you already hold the context from an
635    /// [`IncomingMessage`].
636    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    /// Send media content using an explicit [`WechatContext`].
646    ///
647    /// The caller is responsible for supplying a valid context.
648    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    /// Show "typing..." indicator using an explicit [`WechatContext`].
658    ///
659    /// The caller is responsible for supplying a valid context.
660    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    /// Reply with media content using the incoming message's observed context.
679    ///
680    /// Returns an error if the incoming message has no context.
681    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    /// Download media from an incoming message.
694    /// Returns None if the message has no media. Priority: image > file > video > voice.
695    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    /// Download and decrypt a raw CDN media reference.
747    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    /// Upload data to WeChat CDN without sending a message.
756    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    /// Stop the bot.
1033    pub async fn stop(&self) {
1034        *self.stopped.write().await = true;
1035    }
1036
1037    // --- internal media ---
1038
1039    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, &params).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    // --- internal text ---
1216
1217    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            // Always keep client_id (stable local id). Also keep any server-assigned
1239            // ids from the response so quote payloads that reference wire message_id
1240            // (numeric) can resolve.
1241            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
1328/// Content to send via reply_media / send_media_with_context.
1329pub 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
1417/// Collect local client_id plus any server-assigned ids present in sendmessage JSON.
1418///
1419/// WeChat quote payloads often reference the **server** numeric `message_id`, while
1420/// outbound builders only know the caller-supplied `client_id` (UUID). Binding both
1421/// is required for quote routing.
1422pub(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    // Known / likely response shapes (API may evolve; keep extraction defensive).
1439    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    // Shallow object scan for any *message_id / *msg_id keys.
1454    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    // Lightweight timestamp string without a chrono dependency.
1523    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}