Skip to main content

robit_qq/
platform.rs

1//! QQ Official Bot platform adapter.
2//!
3//! Implements [`robit_chatbot::PlatformAdapter`] for the QQ Official Bot API:
4//!
5//! - **Access token**: obtained via the OAuth2 `getAppAccessToken` endpoint
6//!   (app_id + app_secret), refreshed before expiry, and used for both the
7//!   WebSocket Identify and HTTP message sends.
8//! - **WebSocket gateway**: connects, sends Identify (`op=2`), then runs a
9//!   heartbeat task and a dispatch task. Dispatch converts
10//!   `C2C_MESSAGE_CREATE` / `GROUP_AT_MESSAGE_CREATE` events into
11//!   [`PlatformEvent::Message`].
12//! - **HTTP send**: POSTs text to the group or user messages endpoint.
13//!
14//! `chat_id` encoding: `"group:{group_openid}"` for group chats,
15//! `"private:{user_openid}"` for C2C chats.
16
17use std::sync::Arc;
18use std::time::{Duration, Instant};
19
20use async_trait::async_trait;
21use futures_util::{SinkExt, StreamExt};
22use robit_agent::error::{AgentError, Result};
23use robit_ai::config::RobitConfig;
24use robit_chatbot::adapter::{
25    ChatMessage, ChatType, MediaAttachment, PlatformAdapter, PlatformCaps, PlatformEvent,
26    SendResult, SenderInfo, UploadResult,
27};
28use tokio::sync::{mpsc, Mutex, RwLock};
29use tokio_util::sync::CancellationToken;
30use tokio_tungstenite::tungstenite::Message;
31use tracing::{debug, info, warn};
32
33use crate::protocol::{
34    event_type, AccessTokenRequest, AccessTokenResponse, GatewayPayload, HelloData, MediaFileInfo,
35    MessageEvent, SendMessageRequest, SendMessageResponse, op,
36};
37
38/// QQ Bot configuration parsed from `[channels.qq_bot]`.
39#[derive(Debug, Clone)]
40pub struct QqConfig {
41    pub app_id: String,
42    pub app_secret: String,
43    pub sandbox: bool,
44}
45
46impl QqConfig {
47    /// Extract QQ Bot config from the loaded `RobitConfig`.
48    pub fn from_config(config: &RobitConfig) -> std::result::Result<Self, String> {
49        let qq = config
50            .channels
51            .as_ref()
52            .and_then(|c| c.qq_bot.as_ref())
53            .ok_or_else(|| {
54                "QQ Bot config not found. Add [channels.qq_bot] section to config.toml".to_string()
55            })?;
56        Ok(Self {
57            app_id: qq.app_id.clone(),
58            app_secret: qq.app_secret.clone(),
59            sandbox: false,
60        })
61    }
62
63    /// WebSocket gateway URL.
64    pub fn gateway_url(&self) -> &str {
65        if self.sandbox {
66            "wss://sandbox.api.sgroup.qq.com/websockets"
67        } else {
68            "wss://api.sgroup.qq.com/websockets"
69        }
70    }
71
72    /// HTTP API base URL.
73    pub fn api_base_url(&self) -> &str {
74        if self.sandbox {
75            "https://sandbox.api.sgroup.qq.com"
76        } else {
77            "https://api.sgroup.qq.com"
78        }
79    }
80
81    /// App access token endpoint.
82    pub fn access_token_url(&self) -> &str {
83        "https://bots.qq.com/app/getAppAccessToken"
84    }
85}
86
87/// A cached access token with its expiry.
88struct CachedToken {
89    token: String,
90    /// Instant at which the token expires.
91    expires_at: Instant,
92}
93
94/// QQ Official Bot platform adapter.
95pub struct QqPlatformAdapter {
96    config: QqConfig,
97    /// HTTP client for sending messages and fetching access tokens.
98    http: reqwest::Client,
99    /// Cached app access token (refreshed as needed).
100    access_token: RwLock<Option<CachedToken>>,
101    /// Last sequence number received (for heartbeats / resume).
102    last_seq: Mutex<Option<u64>>,
103    /// Session ID from the Ready event (for resume).
104    session_id: Mutex<Option<String>>,
105    /// Inbound event channel: dispatch/heartbeat tasks push, recv_event pops.
106    event_tx: mpsc::Sender<PlatformEvent>,
107    event_rx: Mutex<mpsc::Receiver<PlatformEvent>>,
108    /// Outbound WebSocket writes (shared between send_message-via-WS and the
109    /// heartbeat task). Currently send_message uses HTTP, so this is owned by
110    /// the dispatch/heartbeat tasks.
111    ws_tx: Mutex<Option<futures_util::stream::SplitSink<WebSocket, Message>>>,
112    /// Platform capabilities (kept for diagnostics; capabilities() is static).
113    #[allow(dead_code)]
114    caps: PlatformCaps,
115    /// Heartbeat interval (from the Hello event).
116    heartbeat_interval: RwLock<Duration>,
117    /// Tracks the last received message ID per chat, so passive replies can
118    /// reference it (QQ requires `msg_id` within 5 min of the original).
119    last_inbound_msg_id: Mutex<Option<(String, String)>>, // (chat_id, msg_id)
120    /// Monotonic counter for `msg_seq` per reply.
121    msg_seq: Mutex<u32>,
122}
123
124type WebSocket =
125    tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
126
127impl QqPlatformAdapter {
128    /// Connect to the QQ gateway and start the heartbeat + dispatch tasks.
129    ///
130    /// This is the constructor used in place of `PlatformAdapter::connect` when
131    /// we already own the config (avoids the `Self::Config` indirection).
132    /// `shutdown` is notified on Ctrl+C to let background tasks exit gracefully.
133    ///
134    /// The first connection is established synchronously so bad config (wrong
135    /// app_id/secret, unreachable gateway) surfaces immediately. A background
136    /// supervisor then owns the connection lifecycle and transparently
137    /// reconnects on gateway rotations (roughly every ~30 min) without
138    /// dropping the adapter or the agents held by the `ChatbotManager`.
139    pub async fn connect(config: QqConfig, shutdown: Arc<tokio::sync::Notify>) -> Result<Arc<Self>> {
140        let http = reqwest::Client::new();
141        let caps = PlatformCaps::qq();
142        let (event_tx, event_rx) = mpsc::channel::<PlatformEvent>(256);
143
144        let adapter = Arc::new(Self {
145            config: config.clone(),
146            http,
147            access_token: RwLock::new(None),
148            last_seq: Mutex::new(None),
149            session_id: Mutex::new(None),
150            event_tx,
151            event_rx: Mutex::new(event_rx),
152            ws_tx: Mutex::new(None),
153            caps,
154            heartbeat_interval: RwLock::new(Duration::from_secs(41)),
155            last_inbound_msg_id: Mutex::new(None),
156            msg_seq: Mutex::new(0),
157        });
158
159        let conn_cancel = CancellationToken::new();
160        let tasks = adapter
161            .establish_connection(shutdown.clone(), conn_cancel.clone())
162            .await?;
163
164        // Supervisor owns all subsequent reconnects. As long as it can
165        // re-establish the WebSocket, the adapter - and the agents held by the
166        // ChatbotManager - survive across QQ gateway rotations, which
167        // previously killed the whole process along with every in-flight async
168        // task (e.g. image generation).
169        let supervisor = Arc::clone(&adapter);
170        tokio::spawn(async move {
171            supervisor_loop(supervisor, tasks, conn_cancel, shutdown).await;
172        });
173
174        Ok(adapter)
175    }
176
177    /// Open the WebSocket, complete the Hello → Identify handshake, and spawn
178    /// the heartbeat + dispatch background tasks. `conn_cancel` lets the
179    /// supervisor stop both tasks when it tears the connection down for a
180    /// reconnect; `shutdown` lets them exit on Ctrl+C. Neither task emits
181    /// `Disconnected` - the supervisor owns reconnect, so the manager/agents
182    /// stay alive across gateway rotations.
183    async fn establish_connection(
184        self: &Arc<Self>,
185        shutdown: Arc<tokio::sync::Notify>,
186        conn_cancel: CancellationToken,
187    ) -> Result<ConnectionTasks> {
188        info!("Connecting to QQ gateway: {}", self.config.gateway_url());
189        let (ws_stream, _response) = tokio_tungstenite::connect_async(self.config.gateway_url())
190            .await
191            .map_err(|e| AgentError::InternalError(format!("WebSocket connect failed: {}", e)))?;
192
193        let (mut write, mut read) = ws_stream.split();
194        write
195            .send(Message::Ping(bytes::Bytes::new()))
196            .await
197            .map_err(|e| AgentError::InternalError(format!("WS ping failed: {}", e)))?;
198
199        // 1. Wait for Hello (op=10) to learn the heartbeat interval.
200        let heartbeat_interval = loop {
201            let msg = read
202                .next()
203                .await
204                .ok_or_else(|| AgentError::InternalError("WebSocket closed before Hello".into()))?
205                .map_err(|e| AgentError::InternalError(format!("WS read error: {}", e)))?;
206            if let Message::Text(text) = msg {
207                let payload: GatewayPayload =
208                    serde_json::from_str(&text).map_err(|e| {
209                        AgentError::InternalError(format!("Invalid Hello JSON: {}", e))
210                    })?;
211                if payload.op == op::HELLO {
212                    let hello: HelloData = serde_json::from_value(
213                        payload.d.ok_or_else(|| AgentError::InternalError("Hello missing d".into()))?,
214                    )
215                    .map_err(|e| AgentError::InternalError(format!("Invalid Hello data: {}", e)))?;
216                    break Duration::from_millis(hello.heartbeat_interval);
217                }
218            }
219        };
220        *self.heartbeat_interval.write().await = heartbeat_interval;
221        info!("QQ heartbeat interval: {:?}", heartbeat_interval);
222
223        // 2. Fetch an app access token and send Identify (op=2).
224        let access_token = self.fetch_access_token().await?;
225        let identify =
226            GatewayPayload::identify(&access_token, INTENT_C2C | INTENT_GROUP_AT_MESSAGE);
227        write
228            .send(Message::Text(serde_json::to_string(&identify).unwrap().into()))
229            .await
230            .map_err(|e| AgentError::InternalError(format!("Identify send failed: {}", e)))?;
231
232        // 3. Store the write half and spawn the heartbeat + dispatch tasks.
233        *self.ws_tx.lock().await = Some(write);
234
235        let heartbeat = spawn_heartbeat(Arc::clone(self), conn_cancel.clone(), shutdown.clone());
236        let dispatch = spawn_dispatch(Arc::clone(self), read, conn_cancel, shutdown);
237
238        Ok(ConnectionTasks { dispatch, heartbeat })
239    }
240
241    /// Fetch (and cache) an app access token, returning a fresh one.
242    async fn fetch_access_token(&self) -> Result<String> {
243        // Return cached if still valid (with a 60s safety margin).
244        {
245            let cache = self.access_token.read().await;
246            if let Some(cached) = cache.as_ref() {
247                if cached.expires_at.duration_since(Instant::now())
248                    > Duration::from_secs(60)
249                {
250                    return Ok(cached.token.clone());
251                }
252            }
253        }
254
255        let req = AccessTokenRequest {
256            app_id: self.config.app_id.clone(),
257            client_secret: self.config.app_secret.clone(),
258        };
259
260        let response = self
261            .http
262            .post(self.config.access_token_url())
263            .json(&req)
264            .send()
265            .await
266            .map_err(|e| AgentError::InternalError(format!("Access token request failed: {}", e)))?;
267
268        let status = response.status();
269        let text = response.text().await.unwrap_or_default();
270
271        if !status.is_success() {
272            return Err(AgentError::InternalError(format!("Access token request failed ({}): {}", status, text)));
273        }
274
275        let resp: AccessTokenResponse = serde_json::from_str(&text)
276            .map_err(|e| AgentError::InternalError(format!("Access token parse failed: {}", e)))?;
277
278        let token = resp.access_token.clone();
279        let expires_in = resp.expires_in.max(60);
280        let cached = CachedToken {
281            token: token.clone(),
282            expires_at: Instant::now() + Duration::from_secs(expires_in),
283        };
284        *self.access_token.write().await = Some(cached);
285        debug!("Fetched QQ access token (expires in {}s)", expires_in);
286        Ok(token)
287    }
288
289    /// Build the Authorization header value (`QQBot {token}`).
290    async fn auth_header(&self) -> Result<String> {
291        let token = self.fetch_access_token().await?;
292        Ok(format!("QQBot {}", token))
293    }
294
295    /// Record the inbound message ID for a chat (so a later reply can reference it).
296    fn record_inbound(&self, chat_id: &str, msg_id: &str) {
297        if let Ok(mut guard) = self.last_inbound_msg_id.try_lock() {
298            *guard = Some((chat_id.to_string(), msg_id.to_string()));
299        }
300    }
301
302    /// The inbound message ID to reference for a reply to `chat_id`, if any.
303    async fn reply_msg_id(&self, chat_id: &str) -> Option<String> {
304        let guard = self.last_inbound_msg_id.lock().await;
305        guard
306            .as_ref()
307            .filter(|(cid, _)| cid == chat_id)
308            .map(|(_, id)| id.clone())
309    }
310
311    async fn next_msg_seq(&self) -> u32 {
312        let mut seq = self.msg_seq.lock().await;
313        *seq = seq.wrapping_add(1);
314        *seq
315    }
316}
317
318#[async_trait]
319impl PlatformAdapter for QqPlatformAdapter {
320    fn capabilities() -> PlatformCaps {
321        PlatformCaps::qq()
322    }
323
324    async fn send_message(&self, chat_id: &str, text: &str) -> Result<SendResult> {
325        let auth = self.auth_header().await?;
326        let (endpoint, is_group) = resolve_send_endpoint(self.config.api_base_url(), chat_id)?;
327
328        let msg_id = self.reply_msg_id(chat_id).await;
329        let msg_seq = self.next_msg_seq().await;
330        let body = SendMessageRequest {
331            content: text.to_string(),
332            msg_type: crate::protocol::msg_type::TEXT,
333            msg_id,
334            msg_seq: Some(msg_seq),
335            media: None,
336        };
337
338        let resp = self
339            .http
340            .post(&endpoint)
341            .header("Authorization", &auth)
342            .json(&body)
343            .send()
344            .await
345            .map_err(|e| AgentError::InternalError(format!("QQ send failed: {}", e)))?;
346
347        let status = resp.status();
348        if !status.is_success() {
349            let text = resp.text().await.unwrap_or_default();
350            warn!("QQ send {} failed ({}): {}", endpoint, status, text);
351            return Err(AgentError::InternalError(format!(
352                "QQ send failed ({})",
353                status
354            )));
355        }
356
357        let parsed: SendMessageResponse = resp
358            .json()
359            .await
360            .map_err(|e| AgentError::InternalError(format!("QQ send response parse: {}", e)))?;
361
362        let id = parsed
363            .id
364            .or(parsed.msg_id)
365            .unwrap_or_else(|| format!("sent-{}", msg_seq));
366        debug!("QQ message sent to {} (id={})", chat_id, id);
367        let _ = is_group; // currently unused beyond endpoint selection
368        Ok(SendResult { msg_id: id })
369    }
370
371    async fn edit_message(&self, _chat_id: &str, _msg_id: &str, _text: &str) -> Result<()> {
372        // QQ's passive-reply model doesn't support editing a sent message in
373        // place (each reply is a new message referencing a msg_id). Fall back
374        // to a fresh send so edit-based streaming degrades gracefully.
375        self.send_message(_chat_id, _text).await?;
376        Ok(())
377    }
378
379    async fn upload_file(
380        &self,
381        chat_id: &str,
382        file_path: &str,
383        media_type: &str,
384    ) -> Result<UploadResult> {
385        let auth = self.auth_header().await?;
386        let (endpoint, _is_group) =
387            resolve_upload_endpoint(self.config.api_base_url(), chat_id)?;
388
389        let file_type = match media_type {
390            "image" => crate::protocol::file_type::IMAGE,
391            "video" => crate::protocol::file_type::VIDEO,
392            "voice" => crate::protocol::file_type::VOICE,
393            _ => crate::protocol::file_type::FILE,
394        };
395
396        // Read file from disk and encode as base64.
397        let file_data = tokio::fs::read(file_path)
398            .await
399            .map_err(|e| AgentError::InternalError(format!("Failed to read file {}: {}", file_path, e)))?;
400
401        use base64::Engine;
402        let file_data_b64 = base64::engine::general_purpose::STANDARD.encode(&file_data);
403
404        // QQ upload API uses JSON body with base64-encoded file_data.
405        // srv_send_msg = false → returns file_info for later use (recommended).
406        let body = crate::protocol::UploadMediaRequest {
407            file_type,
408            url: None,
409            file_data: Some(file_data_b64),
410            srv_send_msg: false,
411        };
412
413        let resp = self
414            .http
415            .post(&endpoint)
416            .header("Authorization", &auth)
417            .json(&body)
418            .send()
419            .await
420            .map_err(|e| AgentError::InternalError(format!("QQ upload failed: {}", e)))?;
421
422        let status = resp.status();
423        let resp_body = resp.text().await.unwrap_or_default();
424
425        if !status.is_success() {
426            warn!("QQ upload {} failed ({}): {}", endpoint, status, resp_body);
427            return Err(AgentError::InternalError(format!(
428                "QQ upload failed ({}): {}",
429                status, resp_body
430            )));
431        }
432
433        let parsed: crate::protocol::UploadMediaResponse = serde_json::from_str(&resp_body)
434            .map_err(|e| {
435                AgentError::InternalError(format!(
436                    "QQ upload response parse failed: {} (body: {})",
437                    e, resp_body
438                ))
439            })?;
440
441        let file_info = parsed.file_info.ok_or_else(|| {
442            AgentError::InternalError(format!(
443                "QQ upload response missing file_info: {}",
444                resp_body
445            ))
446        })?;
447
448        let file_id = parsed
449            .file_uuid
450            .or(parsed.id)
451            .unwrap_or_else(|| file_info.clone());
452
453        debug!("QQ file uploaded: file_info={}, file_id={}", file_info, file_id);
454
455        Ok(UploadResult {
456            file_id,
457            url: file_info,
458        })
459    }
460
461    async fn send_media_message(
462        &self,
463        chat_id: &str,
464        file_url: &str,
465        file_name: &str,
466        media_type: &str,
467    ) -> Result<SendResult> {
468        let auth = self.auth_header().await?;
469        let (endpoint, _is_group) = resolve_send_endpoint(self.config.api_base_url(), chat_id)?;
470
471        let msg_id = self.reply_msg_id(chat_id).await;
472        let msg_seq = self.next_msg_seq().await;
473        let body = SendMessageRequest {
474            content: file_name.to_string(),
475            msg_type: crate::protocol::msg_type::MEDIA,
476            msg_id,
477            msg_seq: Some(msg_seq),
478            media: Some(MediaFileInfo {
479                file_info: file_url.to_string(),
480            }),
481        };
482
483        let resp = self
484            .http
485            .post(&endpoint)
486            .header("Authorization", &auth)
487            .json(&body)
488            .send()
489            .await
490            .map_err(|e| AgentError::InternalError(format!("QQ media send failed: {}", e)))?;
491
492        let status = resp.status();
493        if !status.is_success() {
494            let text = resp.text().await.unwrap_or_default();
495            warn!("QQ media send {} failed ({}): {}", endpoint, status, text);
496            return Err(AgentError::InternalError(format!(
497                "QQ media send failed ({}): {}",
498                status, text
499            )));
500        }
501
502        let parsed: SendMessageResponse = resp
503            .json()
504            .await
505            .map_err(|e| {
506                AgentError::InternalError(format!("QQ media send response parse: {}", e))
507            })?;
508
509        let id = parsed
510            .id
511            .or(parsed.msg_id)
512            .unwrap_or_else(|| format!("media-{}", msg_seq));
513        debug!(
514            "QQ media message sent to {} (id={}, type={})",
515            chat_id, id, media_type
516        );
517        Ok(SendResult { msg_id: id })
518    }
519
520    async fn recv_event(&self) -> Result<PlatformEvent> {
521        self.event_rx
522            .lock()
523            .await
524            .recv()
525            .await
526            .ok_or_else(|| AgentError::InternalError("QQ event channel closed".into()))
527    }
528}
529
530/// Intent bitmask for C2C + group @-messages.
531const INTENT_C2C: u32 = crate::protocol::INTENT_C2C;
532const INTENT_GROUP_AT_MESSAGE: u32 = crate::protocol::INTENT_GROUP_AT_MESSAGE;
533
534/// Resolve the HTTP send endpoint for a chat_id.
535///
536/// `group:{openid}` → `/v2/groups/{openid}/messages`
537/// `private:{openid}` → `/v2/users/{openid}/messages`
538fn resolve_send_endpoint(base: &str, chat_id: &str) -> Result<(String, bool)> {
539    if let Some(group_id) = chat_id.strip_prefix("group:") {
540        return Ok((
541            format!("{}/v2/groups/{}/messages", base, group_id),
542            true,
543        ));
544    }
545    if let Some(user_id) = chat_id.strip_prefix("private:") {
546        return Ok((
547            format!("{}/v2/users/{}/messages", base, user_id),
548            false,
549        ));
550    }
551    Err(AgentError::InternalError(format!(
552        "Invalid chat_id '{}': expected 'group:{{id}}' or 'private:{{id}}'",
553        chat_id
554    )))
555}
556
557/// Resolve the HTTP upload endpoint for a chat_id.
558///
559/// `group:{openid}` → `/v2/groups/{openid}/files`
560/// `private:{openid}` → `/v2/users/{openid}/files`
561fn resolve_upload_endpoint(base: &str, chat_id: &str) -> Result<(String, bool)> {
562    if let Some(group_id) = chat_id.strip_prefix("group:") {
563        return Ok((
564            format!("{}/v2/groups/{}/files", base, group_id),
565            true,
566        ));
567    }
568    if let Some(user_id) = chat_id.strip_prefix("private:") {
569        return Ok((
570            format!("{}/v2/users/{}/files", base, user_id),
571            false,
572        ));
573    }
574    Err(AgentError::InternalError(format!(
575        "Invalid chat_id '{}': expected 'group:{{id}}' or 'private:{{id}}'",
576        chat_id
577    )))
578}
579
580/// Bookkeeping for the two background tasks of one WebSocket connection.
581struct ConnectionTasks {
582    dispatch: tokio::task::JoinHandle<()>,
583    heartbeat: tokio::task::JoinHandle<()>,
584}
585
586/// Action returned by frame processing: keep going, or drop the connection
587/// (the supervisor will reconnect).
588#[derive(Clone, Copy)]
589enum FrameAction {
590    Continue,
591    Reconnect,
592}
593
594/// Which connection task ended first in the supervisor's `select!`. The
595/// `select!` consumes the JoinHandle of whichever task fires, so the
596/// supervisor must await only the *other* task afterwards - re-polling the
597/// consumed handle panics ("JoinHandle polled after completion").
598#[derive(Clone, Copy)]
599enum TaskEnded {
600    Dispatch,
601    Heartbeat,
602}
603
604/// Background reconnect supervisor: owns the connection lifecycle after the
605/// initial [`QqPlatformAdapter::connect`]. When either the dispatch or
606/// heartbeat task of the current connection ends (routine QQ gateway rotation
607/// every ~30 min, network blip, send/read failure, server-requested
608/// reconnect), it cancels the other task and re-establishes the WebSocket
609/// with bounded backoff. The adapter - and the agents held by the
610/// `ChatbotManager` - stay alive across reconnects; only `shutdown` (Ctrl+C)
611/// stops the supervisor.
612async fn supervisor_loop(
613    adapter: Arc<QqPlatformAdapter>,
614    mut tasks: ConnectionTasks,
615    mut conn_cancel: CancellationToken,
616    shutdown: Arc<tokio::sync::Notify>,
617) {
618    let mut establish_failures: u32 = 0;
619    loop {
620        // Wait for either connection task to end, or for shutdown. When one
621        // task ends the connection is considered lost (the other is likely
622        // already failing or about to). `select!` consumes the JoinHandle of
623        // whichever task fires, so below we await only the *other* one:
624        // re-polling the consumed handle panics ("JoinHandle polled after
625        // completion"), which used to silently kill the supervisor (the panic
626        // went to stderr, not the tracing log) and left QQ offline after every
627        // ~30-min gateway rotation.
628        let ended = tokio::select! {
629            res = &mut tasks.dispatch => {
630                match res {
631                    Ok(()) => debug!("Dispatch task ended"),
632                    Err(e) => warn!("Dispatch task panicked: {}", e),
633                }
634                TaskEnded::Dispatch
635            }
636            res = &mut tasks.heartbeat => {
637                match res {
638                    Ok(()) => debug!("Heartbeat task ended"),
639                    Err(e) => warn!("Heartbeat task panicked: {}", e),
640                }
641                TaskEnded::Heartbeat
642            }
643            _ = shutdown.notified() => {
644                debug!("Supervisor received shutdown signal");
645                conn_cancel.cancel();
646                // Neither handle was consumed (the shutdown branch fired), so
647                // awaiting both is safe.
648                let _ = tasks.dispatch.await;
649                let _ = tasks.heartbeat.await;
650                return;
651            }
652        };
653
654        // A task ended: stop the other, release the stale writer so no
655        // heartbeats are sent on the dead socket while we reconnect.
656        conn_cancel.cancel();
657        // Await only the task that did NOT fire above - the fired JoinHandle
658        // was already consumed by `select!`.
659        match ended {
660            TaskEnded::Dispatch => {
661                let _ = tasks.heartbeat.await;
662            }
663            TaskEnded::Heartbeat => {
664                let _ = tasks.dispatch.await;
665            }
666        }
667        *adapter.ws_tx.lock().await = None;
668
669        // Brief pause before reconnecting (avoids a hot loop if the gateway
670        // drops us the instant we connect).
671        if sleep_or_shutdown(Duration::from_secs(1), &shutdown).await {
672            return;
673        }
674
675        // Re-establish, retrying with backoff until success or shutdown.
676        loop {
677            conn_cancel = CancellationToken::new();
678            match adapter
679                .establish_connection(shutdown.clone(), conn_cancel.clone())
680                .await
681            {
682                Ok(new_tasks) => {
683                    establish_failures = 0;
684                    info!("Reconnected to QQ gateway");
685                    tasks = new_tasks;
686                    break; // back to outer loop: wait for this connection
687                }
688                Err(e) => {
689                    establish_failures = establish_failures.saturating_add(1);
690                    let backoff = reconnect_backoff(establish_failures);
691                    warn!(
692                        "Reconnect attempt #{} failed: {}, retrying in {:?}",
693                        establish_failures, e, backoff
694                    );
695                    if sleep_or_shutdown(backoff, &shutdown).await {
696                        return;
697                    }
698                }
699            }
700        }
701    }
702}
703
704/// Sleep for `dur`, returning early (`true`) if `shutdown` is notified.
705async fn sleep_or_shutdown(dur: Duration, shutdown: &Arc<tokio::sync::Notify>) -> bool {
706    tokio::select! {
707        _ = tokio::time::sleep(dur) => false,
708        _ = shutdown.notified() => true,
709    }
710}
711
712/// Exponential backoff after `failures` consecutive establish failures:
713/// 1s, 2s, 4s, 8s, 16s, 30s, 30s, ...
714fn reconnect_backoff(failures: u32) -> Duration {
715    let secs = 1u64 << failures.saturating_sub(1).min(5);
716    Duration::from_secs(secs.min(30))
717}
718
719/// Spawn the periodic heartbeat task. Exits (handing control back to the
720/// supervisor) on `conn_cancel`, `shutdown`, or a heartbeat send failure
721/// (which means the connection is dead). Unlike the old version it does NOT
722/// emit `Disconnected` - the supervisor detects the task ending and
723/// reconnects, so the manager/agents are never torn down.
724fn spawn_heartbeat(
725    adapter: Arc<QqPlatformAdapter>,
726    conn_cancel: CancellationToken,
727    shutdown: Arc<tokio::sync::Notify>,
728) -> tokio::task::JoinHandle<()> {
729    tokio::spawn(async move {
730        loop {
731            let interval = *adapter.heartbeat_interval.read().await;
732            tokio::select! {
733                _ = conn_cancel.cancelled() => {
734                    debug!("Heartbeat task cancelled");
735                    return;
736                }
737                _ = shutdown.notified() => {
738                    debug!("Heartbeat task received shutdown signal");
739                    return;
740                }
741                _ = tokio::time::sleep(interval) => {}
742            }
743            if let Err(e) = send_heartbeat(&adapter).await {
744                warn!("Heartbeat send failed, dropping connection: {}", e);
745                return;
746            }
747        }
748    })
749}
750
751/// Send a heartbeat (op=1) carrying the last sequence number. Returns `Err`
752/// if the send fails or there is no writer (connection is dead).
753async fn send_heartbeat(adapter: &QqPlatformAdapter) -> std::result::Result<(), String> {
754    let last_seq = *adapter.last_seq.lock().await;
755    let heartbeat = GatewayPayload::heartbeat(last_seq);
756    let payload = serde_json::to_string(&heartbeat).map_err(|e| e.to_string())?;
757    let mut ws_tx = adapter.ws_tx.lock().await;
758    match ws_tx.as_mut() {
759        Some(write) => write
760            .send(Message::Text(payload.into()))
761            .await
762            .map_err(|e| e.to_string()),
763        None => Err("no WS writer (disconnected)".to_string()),
764    }
765}
766
767/// Spawn the dispatch task: reads WS frames, converts dispatch events to
768/// [`PlatformEvent`], and forwards them to the event channel. Exits (handing
769/// control back to the supervisor) on `conn_cancel`, `shutdown`, a WS read
770/// error/close, or a server-requested reconnect. Does NOT emit `Disconnected`.
771fn spawn_dispatch(
772    adapter: Arc<QqPlatformAdapter>,
773    mut read: impl futures_util::Stream<
774        Item = std::result::Result<Message, tokio_tungstenite::tungstenite::Error>,
775    > + Unpin
776    + Send
777    + 'static,
778    conn_cancel: CancellationToken,
779    shutdown: Arc<tokio::sync::Notify>,
780) -> tokio::task::JoinHandle<()> {
781    tokio::spawn(async move {
782        loop {
783            tokio::select! {
784                _ = conn_cancel.cancelled() => {
785                    debug!("Dispatch task cancelled");
786                    return;
787                }
788                _ = shutdown.notified() => {
789                    debug!("Dispatch task received shutdown signal");
790                    return;
791                }
792                frame = read.next() => {
793                    let msg = match frame {
794                        Some(Ok(m)) => m,
795                        Some(Err(e)) => {
796                            warn!("WS read error, dropping connection: {}", e);
797                            return;
798                        }
799                        None => {
800                            info!("QQ dispatch stream ended");
801                            return;
802                        }
803                    };
804                    match process_frame(&adapter, msg).await {
805                        FrameAction::Continue => {}
806                        FrameAction::Reconnect => return,
807                    }
808                }
809            }
810        }
811    })
812}
813
814/// Process a single WebSocket frame. Returns whether the connection should be
815/// dropped so the supervisor can reconnect.
816async fn process_frame(adapter: &QqPlatformAdapter, msg: Message) -> FrameAction {
817    let text = match msg {
818        Message::Text(t) => t.to_string(),
819        Message::Binary(b) => String::from_utf8_lossy(&b).into_owned(),
820        Message::Close(_) => {
821            info!("QQ WebSocket closed by server");
822            return FrameAction::Reconnect;
823        }
824        _ => return FrameAction::Continue,
825    };
826
827    let payload: GatewayPayload = match serde_json::from_str(&text) {
828        Ok(p) => p,
829        Err(e) => {
830            debug!("Skipping non-JSON WS frame: {}", e);
831            return FrameAction::Continue;
832        }
833    };
834
835    match payload.op {
836        op::HEARTBEAT_ACK => FrameAction::Continue,
837        op::RECONNECT => {
838            warn!("Server requested reconnect");
839            FrameAction::Reconnect
840        }
841        op::INVALID_SESSION => {
842            // Session no longer valid: clear it so the next establish does a
843            // fresh Identify. (We always Identify today; this keeps session_id
844            // honest for a future Resume path.)
845            warn!("Invalid session, will re-identify");
846            *adapter.session_id.lock().await = None;
847            FrameAction::Reconnect
848        }
849        op::DISPATCH => {
850            if let Some(seq) = payload.s {
851                *adapter.last_seq.lock().await = Some(seq);
852            }
853            let event_name = payload.t.as_deref().unwrap_or("");
854            match event_name {
855                event_type::READY => {
856                    info!("QQ bot is ready");
857                    if let Some(d) = payload.d {
858                        if let Some(sid) = d.get("session_id").and_then(|v| v.as_str()) {
859                            *adapter.session_id.lock().await = Some(sid.to_string());
860                        }
861                    }
862                }
863                event_type::RESUMED => {
864                    debug!("Session resumed event");
865                }
866                event_type::C2C_MESSAGE_CREATE | event_type::GROUP_AT_MESSAGE_CREATE => {
867                    if let Some(d) = payload.d {
868                        if let Ok(ev) = serde_json::from_value::<MessageEvent>(d) {
869                            // Record the inbound msg_id for replies, then forward.
870                            if let Some(chat_id) = chat_id_for_event(event_name, &ev) {
871                                adapter.record_inbound(&chat_id, &ev.id);
872                            }
873                            if let Some(platform_ev) = build_platform_event(event_name, &ev) {
874                                let _ = adapter.event_tx.send(platform_ev).await;
875                            }
876                        }
877                    }
878                }
879                _ => {
880                    debug!("Ignoring dispatch event: {}", event_name);
881                }
882            }
883            FrameAction::Continue
884        }
885        _ => {
886            debug!("Unhandled op {}: {:?}", payload.op, payload.t);
887            FrameAction::Continue
888        }
889    }
890}
891
892/// Compute the platform `chat_id` for a QQ message event.
893fn chat_id_for_event(event_name: &str, ev: &MessageEvent) -> Option<String> {
894    match event_name {
895        event_type::GROUP_AT_MESSAGE_CREATE => {
896            Some(format!("group:{}", ev.group_openid.clone()?))
897        }
898        event_type::C2C_MESSAGE_CREATE => {
899            Some(format!("private:{}", ev.user_id()?))
900        }
901        _ => None,
902    }
903}
904
905/// Convert a QQ message event into a platform-agnostic [`PlatformEvent::Message`].
906fn build_platform_event(event_name: &str, ev: &MessageEvent) -> Option<PlatformEvent> {
907    let chat_id = chat_id_for_event(event_name, ev)?;
908    let chat_type = match event_name {
909        event_type::GROUP_AT_MESSAGE_CREATE => ChatType::Group,
910        event_type::C2C_MESSAGE_CREATE => ChatType::Private,
911        _ => return None,
912    };
913    let user_id = ev.user_id().unwrap_or("unknown").to_string();
914    // QQ group @-message content typically has a leading space from the @mention.
915    let mut text = ev.content.trim().to_string();
916
917    // Convert QQ attachments to platform-agnostic media attachments.
918    let attachments: Vec<MediaAttachment> = ev
919        .attachments
920        .iter()
921        .map(|att| MediaAttachment {
922            content_type: att.content_type.clone().unwrap_or_else(|| "application/octet-stream".into()),
923            url: att.url.clone(),
924            filename: att.filename.clone(),
925            size: att.size,
926            width: att.width,
927            height: att.height,
928        })
929        .collect();
930
931    // Append attachment descriptions to the text so the LLM knows about them.
932    if !attachments.is_empty() {
933        let descs: Vec<String> = attachments.iter().map(|a| a.describe()).collect();
934        if text.is_empty() {
935            text = descs.join("\n");
936        } else {
937            text = format!("{}\n{}", text, descs.join("\n"));
938        }
939    }
940
941    Some(PlatformEvent::Message(ChatMessage {
942        text,
943        sender: SenderInfo {
944            user_id,
945            chat_id,
946            chat_type,
947        },
948        attachments,
949    }))
950}
951
952#[cfg(test)]
953mod tests {
954    use super::*;
955
956    fn cfg() -> QqConfig {
957        QqConfig {
958            app_id: "id".into(),
959            app_secret: "secret".into(),
960            sandbox: false,
961        }
962    }
963
964    #[test]
965    fn reconnect_backoff_grows_then_caps() {
966        assert_eq!(reconnect_backoff(1), Duration::from_secs(1));
967        assert_eq!(reconnect_backoff(2), Duration::from_secs(2));
968        assert_eq!(reconnect_backoff(3), Duration::from_secs(4));
969        assert_eq!(reconnect_backoff(4), Duration::from_secs(8));
970        assert_eq!(reconnect_backoff(5), Duration::from_secs(16));
971        // 1 << 5 = 32s, capped at 30s.
972        assert_eq!(reconnect_backoff(6), Duration::from_secs(30));
973        assert_eq!(reconnect_backoff(100), Duration::from_secs(30));
974    }
975
976    #[test]
977    fn resolves_group_send_endpoint() {
978        let (url, is_group) = resolve_send_endpoint("https://api.sgroup.qq.com", "group:abc").unwrap();
979        assert_eq!(url, "https://api.sgroup.qq.com/v2/groups/abc/messages");
980        assert!(is_group);
981    }
982
983    #[test]
984    fn resolves_private_send_endpoint() {
985        let (url, is_group) =
986            resolve_send_endpoint("https://api.sgroup.qq.com", "private:user1").unwrap();
987        assert_eq!(url, "https://api.sgroup.qq.com/v2/users/user1/messages");
988        assert!(!is_group);
989    }
990
991    #[test]
992    fn rejects_invalid_chat_id() {
993        assert!(resolve_send_endpoint("https://x", "bogus").is_err());
994    }
995
996    #[test]
997    fn resolves_group_upload_endpoint() {
998        let (url, is_group) =
999            resolve_upload_endpoint("https://api.sgroup.qq.com", "group:abc").unwrap();
1000        assert_eq!(
1001            url,
1002            "https://api.sgroup.qq.com/v2/groups/abc/files"
1003        );
1004        assert!(is_group);
1005    }
1006
1007    #[test]
1008    fn resolves_private_upload_endpoint() {
1009        let (url, is_group) =
1010            resolve_upload_endpoint("https://api.sgroup.qq.com", "private:user1").unwrap();
1011        assert_eq!(
1012            url,
1013            "https://api.sgroup.qq.com/v2/users/user1/files"
1014        );
1015        assert!(!is_group);
1016    }
1017
1018    #[test]
1019    fn builds_platform_event_for_group() {
1020        let ev = MessageEvent {
1021            id: "m1".into(),
1022            content: " hello".into(),
1023            author: crate::protocol::Author {
1024                user_openid: None,
1025                member_openid: Some("mem1".into()),
1026            },
1027            group_openid: Some("grp1".into()),
1028            attachments: vec![],
1029        };
1030        let pe = build_platform_event(event_type::GROUP_AT_MESSAGE_CREATE, &ev).unwrap();
1031        match pe {
1032            PlatformEvent::Message(m) => {
1033                assert_eq!(m.sender.chat_id, "group:grp1");
1034                assert_eq!(m.sender.chat_type, ChatType::Group);
1035                assert_eq!(m.text, "hello"); // leading space trimmed
1036                assert_eq!(m.sender.user_id, "mem1");
1037                assert!(m.attachments.is_empty());
1038            }
1039            _ => panic!("expected Message"),
1040        }
1041    }
1042
1043    #[test]
1044    fn builds_platform_event_for_c2c() {
1045        let ev = MessageEvent {
1046            id: "m2".into(),
1047            content: "hi".into(),
1048            author: crate::protocol::Author {
1049                user_openid: Some("u1".into()),
1050                member_openid: None,
1051            },
1052            group_openid: None,
1053            attachments: vec![],
1054        };
1055        let pe = build_platform_event(event_type::C2C_MESSAGE_CREATE, &ev).unwrap();
1056        match pe {
1057            PlatformEvent::Message(m) => {
1058                assert_eq!(m.sender.chat_id, "private:u1");
1059                assert_eq!(m.sender.chat_type, ChatType::Private);
1060            }
1061            _ => panic!("expected Message"),
1062        }
1063    }
1064
1065    #[test]
1066    fn builds_platform_event_with_attachments() {
1067        let ev = MessageEvent {
1068            id: "m3".into(),
1069            content: "look".into(),
1070            author: crate::protocol::Author {
1071                user_openid: Some("u2".into()),
1072                member_openid: None,
1073            },
1074            group_openid: None,
1075            attachments: vec![crate::protocol::QqAttachment {
1076                url: "https://cdn.qq.com/img/test.png".into(),
1077                content_type: Some("image/png".into()),
1078                filename: Some("test.png".into()),
1079                size: Some(204800),
1080                width: Some(800),
1081                height: Some(600),
1082            }],
1083        };
1084        let pe = build_platform_event(event_type::C2C_MESSAGE_CREATE, &ev).unwrap();
1085        match pe {
1086            PlatformEvent::Message(m) => {
1087                assert_eq!(m.attachments.len(), 1);
1088                assert_eq!(m.attachments[0].content_type, "image/png");
1089                assert_eq!(m.attachments[0].url, "https://cdn.qq.com/img/test.png");
1090                assert!(m.attachments[0].is_image());
1091                // Text should include attachment description.
1092                assert!(m.text.contains("用户发送了图片"));
1093                assert!(m.text.contains("test.png"));
1094            }
1095            _ => panic!("expected Message"),
1096        }
1097    }
1098
1099    #[test]
1100    fn from_config_extracts_qq_section() {
1101        let toml_str = r#"
1102            [channels.qq_bot]
1103            app_id = "123"
1104            app_secret = "s"
1105        "#;
1106        // RobitConfig requires a non-empty `providers` map, so add a minimal one.
1107        let toml_with_providers = format!(
1108            "{}\n[providers.x]\nbase_url = \"https://x\"\napi_key = \"k\"\n[[providers.x.models]]\nid = \"m\"\n",
1109            toml_str
1110        );
1111        let config: RobitConfig = toml::from_str(&toml_with_providers).unwrap();
1112        let qq = QqConfig::from_config(&config).unwrap();
1113        assert_eq!(qq.app_id, "123");
1114        assert_eq!(qq.app_secret, "s");
1115    }
1116
1117    #[test]
1118    fn from_config_errors_when_missing() {
1119        let toml_str = r#"
1120            [providers.x]
1121            base_url = "https://x"
1122            api_key = "k"
1123            [[providers.x.models]]
1124            id = "m"
1125        "#;
1126        let config: RobitConfig = toml::from_str(toml_str).unwrap();
1127        assert!(QqConfig::from_config(&config).is_err());
1128    }
1129
1130    #[test]
1131    fn gateway_and_api_urls() {
1132        let c = cfg();
1133        assert!(c.gateway_url().starts_with("wss://"));
1134        assert!(c.api_base_url().starts_with("https://"));
1135    }
1136}