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_tungstenite::tungstenite::Message;
30use tracing::{debug, info, warn};
31
32use crate::protocol::{
33    event_type, AccessTokenRequest, AccessTokenResponse, GatewayPayload, HelloData, MediaFileInfo,
34    MessageEvent, SendMessageRequest, SendMessageResponse, op,
35};
36
37/// QQ Bot configuration parsed from `[channels.qq_bot]`.
38#[derive(Debug, Clone)]
39pub struct QqConfig {
40    pub app_id: String,
41    pub app_secret: String,
42    pub sandbox: bool,
43}
44
45impl QqConfig {
46    /// Extract QQ Bot config from the loaded `RobitConfig`.
47    pub fn from_config(config: &RobitConfig) -> std::result::Result<Self, String> {
48        let qq = config
49            .channels
50            .as_ref()
51            .and_then(|c| c.qq_bot.as_ref())
52            .ok_or_else(|| {
53                "QQ Bot config not found. Add [channels.qq_bot] section to config.toml".to_string()
54            })?;
55        Ok(Self {
56            app_id: qq.app_id.clone(),
57            app_secret: qq.app_secret.clone(),
58            sandbox: false,
59        })
60    }
61
62    /// WebSocket gateway URL.
63    pub fn gateway_url(&self) -> &str {
64        if self.sandbox {
65            "wss://sandbox.api.sgroup.qq.com/websockets"
66        } else {
67            "wss://api.sgroup.qq.com/websockets"
68        }
69    }
70
71    /// HTTP API base URL.
72    pub fn api_base_url(&self) -> &str {
73        if self.sandbox {
74            "https://sandbox.api.sgroup.qq.com"
75        } else {
76            "https://api.sgroup.qq.com"
77        }
78    }
79
80    /// App access token endpoint.
81    pub fn access_token_url(&self) -> &str {
82        "https://bots.qq.com/app/getAppAccessToken"
83    }
84}
85
86/// A cached access token with its expiry.
87struct CachedToken {
88    token: String,
89    /// Instant at which the token expires.
90    expires_at: Instant,
91}
92
93/// QQ Official Bot platform adapter.
94pub struct QqPlatformAdapter {
95    config: QqConfig,
96    /// HTTP client for sending messages and fetching access tokens.
97    http: reqwest::Client,
98    /// Cached app access token (refreshed as needed).
99    access_token: RwLock<Option<CachedToken>>,
100    /// Last sequence number received (for heartbeats / resume).
101    last_seq: Mutex<Option<u64>>,
102    /// Session ID from the Ready event (for resume).
103    session_id: Mutex<Option<String>>,
104    /// Inbound event channel: dispatch/heartbeat tasks push, recv_event pops.
105    event_tx: mpsc::Sender<PlatformEvent>,
106    event_rx: Mutex<mpsc::Receiver<PlatformEvent>>,
107    /// Outbound WebSocket writes (shared between send_message-via-WS and the
108    /// heartbeat task). Currently send_message uses HTTP, so this is owned by
109    /// the dispatch/heartbeat tasks.
110    ws_tx: Mutex<Option<futures_util::stream::SplitSink<WebSocket, Message>>>,
111    /// Platform capabilities (kept for diagnostics; capabilities() is static).
112    #[allow(dead_code)]
113    caps: PlatformCaps,
114    /// Heartbeat interval (from the Hello event).
115    heartbeat_interval: RwLock<Duration>,
116    /// Tracks the last received message ID per chat, so passive replies can
117    /// reference it (QQ requires `msg_id` within 5 min of the original).
118    last_inbound_msg_id: Mutex<Option<(String, String)>>, // (chat_id, msg_id)
119    /// Monotonic counter for `msg_seq` per reply.
120    msg_seq: Mutex<u32>,
121}
122
123type WebSocket =
124    tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
125
126impl QqPlatformAdapter {
127    /// Connect to the QQ gateway and start the heartbeat + dispatch tasks.
128    ///
129    /// This is the constructor used in place of `PlatformAdapter::connect` when
130    /// we already own the config (avoids the `Self::Config` indirection).
131    /// `shutdown` is notified on Ctrl+C to let background tasks exit gracefully.
132    pub async fn connect(config: QqConfig, shutdown: Arc<tokio::sync::Notify>) -> Result<Arc<Self>> {
133        let http = reqwest::Client::new();
134        let caps = PlatformCaps::qq();
135        let (event_tx, event_rx) = mpsc::channel::<PlatformEvent>(256);
136
137        let adapter = Arc::new(Self {
138            config: config.clone(),
139            http,
140            access_token: RwLock::new(None),
141            last_seq: Mutex::new(None),
142            session_id: Mutex::new(None),
143            event_tx,
144            event_rx: Mutex::new(event_rx),
145            ws_tx: Mutex::new(None),
146            caps,
147            heartbeat_interval: RwLock::new(Duration::from_secs(41)),
148            last_inbound_msg_id: Mutex::new(None),
149            msg_seq: Mutex::new(0),
150        });
151
152        adapter.establish_connection(shutdown).await?;
153        Ok(adapter)
154    }
155
156    /// Open the WebSocket, complete the Hello → Identify handshake, and spawn
157    /// the heartbeat + dispatch background tasks. The `shutdown` notify is
158    /// checked by both tasks so they exit gracefully on Ctrl+C.
159    async fn establish_connection(
160        self: &Arc<Self>,
161        shutdown: Arc<tokio::sync::Notify>,
162    ) -> Result<()> {
163        info!("Connecting to QQ gateway: {}", self.config.gateway_url());
164        let (ws_stream, _response) = tokio_tungstenite::connect_async(self.config.gateway_url())
165            .await
166            .map_err(|e| AgentError::InternalError(format!("WebSocket connect failed: {}", e)))?;
167
168        let (mut write, mut read) = ws_stream.split();
169        write
170            .send(Message::Ping(bytes::Bytes::new()))
171            .await
172            .map_err(|e| AgentError::InternalError(format!("WS ping failed: {}", e)))?;
173
174        // 1. Wait for Hello (op=10) to learn the heartbeat interval.
175        let heartbeat_interval = loop {
176            let msg = read
177                .next()
178                .await
179                .ok_or_else(|| AgentError::InternalError("WebSocket closed before Hello".into()))?
180                .map_err(|e| AgentError::InternalError(format!("WS read error: {}", e)))?;
181            if let Message::Text(text) = msg {
182                let payload: GatewayPayload =
183                    serde_json::from_str(&text).map_err(|e| {
184                        AgentError::InternalError(format!("Invalid Hello JSON: {}", e))
185                    })?;
186                if payload.op == op::HELLO {
187                    let hello: HelloData = serde_json::from_value(
188                        payload.d.ok_or_else(|| AgentError::InternalError("Hello missing d".into()))?,
189                    )
190                    .map_err(|e| AgentError::InternalError(format!("Invalid Hello data: {}", e)))?;
191                    break Duration::from_millis(hello.heartbeat_interval);
192                }
193            }
194        };
195        *self.heartbeat_interval.write().await = heartbeat_interval;
196        info!("QQ heartbeat interval: {:?}", heartbeat_interval);
197
198        // 2. Fetch an app access token and send Identify (op=2).
199        let access_token = self.fetch_access_token().await?;
200        let identify =
201            GatewayPayload::identify(&access_token, INTENT_C2C | INTENT_GROUP_AT_MESSAGE);
202        write
203            .send(Message::Text(serde_json::to_string(&identify).unwrap().into()))
204            .await
205            .map_err(|e| AgentError::InternalError(format!("Identify send failed: {}", e)))?;
206
207        // 3. Store the write half and spawn the heartbeat + dispatch tasks.
208        *self.ws_tx.lock().await = Some(write);
209
210        spawn_heartbeat(Arc::clone(self), shutdown.clone());
211        spawn_dispatch(Arc::clone(self), read, shutdown);
212
213        Ok(())
214    }
215
216    /// Fetch (and cache) an app access token, returning a fresh one.
217    async fn fetch_access_token(&self) -> Result<String> {
218        // Return cached if still valid (with a 60s safety margin).
219        {
220            let cache = self.access_token.read().await;
221            if let Some(cached) = cache.as_ref() {
222                if cached.expires_at.duration_since(Instant::now())
223                    > Duration::from_secs(60)
224                {
225                    return Ok(cached.token.clone());
226                }
227            }
228        }
229
230        let req = AccessTokenRequest {
231            app_id: self.config.app_id.clone(),
232            client_secret: self.config.app_secret.clone(),
233        };
234
235        let response = self
236            .http
237            .post(self.config.access_token_url())
238            .json(&req)
239            .send()
240            .await
241            .map_err(|e| AgentError::InternalError(format!("Access token request failed: {}", e)))?;
242
243        let status = response.status();
244        let text = response.text().await.unwrap_or_default();
245
246        if !status.is_success() {
247            return Err(AgentError::InternalError(format!("Access token request failed ({}): {}", status, text)));
248        }
249
250        let resp: AccessTokenResponse = serde_json::from_str(&text)
251            .map_err(|e| AgentError::InternalError(format!("Access token parse failed: {}", e)))?;
252
253        let token = resp.access_token.clone();
254        let expires_in = resp.expires_in.max(60);
255        let cached = CachedToken {
256            token: token.clone(),
257            expires_at: Instant::now() + Duration::from_secs(expires_in),
258        };
259        *self.access_token.write().await = Some(cached);
260        debug!("Fetched QQ access token (expires in {}s)", expires_in);
261        Ok(token)
262    }
263
264    /// Build the Authorization header value (`QQBot {token}`).
265    async fn auth_header(&self) -> Result<String> {
266        let token = self.fetch_access_token().await?;
267        Ok(format!("QQBot {}", token))
268    }
269
270    /// Record the inbound message ID for a chat (so a later reply can reference it).
271    fn record_inbound(&self, chat_id: &str, msg_id: &str) {
272        if let Ok(mut guard) = self.last_inbound_msg_id.try_lock() {
273            *guard = Some((chat_id.to_string(), msg_id.to_string()));
274        }
275    }
276
277    /// The inbound message ID to reference for a reply to `chat_id`, if any.
278    async fn reply_msg_id(&self, chat_id: &str) -> Option<String> {
279        let guard = self.last_inbound_msg_id.lock().await;
280        guard
281            .as_ref()
282            .filter(|(cid, _)| cid == chat_id)
283            .map(|(_, id)| id.clone())
284    }
285
286    async fn next_msg_seq(&self) -> u32 {
287        let mut seq = self.msg_seq.lock().await;
288        *seq = seq.wrapping_add(1);
289        *seq
290    }
291}
292
293#[async_trait]
294impl PlatformAdapter for QqPlatformAdapter {
295    fn capabilities() -> PlatformCaps {
296        PlatformCaps::qq()
297    }
298
299    async fn send_message(&self, chat_id: &str, text: &str) -> Result<SendResult> {
300        let auth = self.auth_header().await?;
301        let (endpoint, is_group) = resolve_send_endpoint(self.config.api_base_url(), chat_id)?;
302
303        let msg_id = self.reply_msg_id(chat_id).await;
304        let msg_seq = self.next_msg_seq().await;
305        let body = SendMessageRequest {
306            content: text.to_string(),
307            msg_type: crate::protocol::msg_type::TEXT,
308            msg_id,
309            msg_seq: Some(msg_seq),
310            media: None,
311        };
312
313        let resp = self
314            .http
315            .post(&endpoint)
316            .header("Authorization", &auth)
317            .json(&body)
318            .send()
319            .await
320            .map_err(|e| AgentError::InternalError(format!("QQ send failed: {}", e)))?;
321
322        let status = resp.status();
323        if !status.is_success() {
324            let text = resp.text().await.unwrap_or_default();
325            warn!("QQ send {} failed ({}): {}", endpoint, status, text);
326            return Err(AgentError::InternalError(format!(
327                "QQ send failed ({})",
328                status
329            )));
330        }
331
332        let parsed: SendMessageResponse = resp
333            .json()
334            .await
335            .map_err(|e| AgentError::InternalError(format!("QQ send response parse: {}", e)))?;
336
337        let id = parsed
338            .id
339            .or(parsed.msg_id)
340            .unwrap_or_else(|| format!("sent-{}", msg_seq));
341        debug!("QQ message sent to {} (id={})", chat_id, id);
342        let _ = is_group; // currently unused beyond endpoint selection
343        Ok(SendResult { msg_id: id })
344    }
345
346    async fn edit_message(&self, _chat_id: &str, _msg_id: &str, _text: &str) -> Result<()> {
347        // QQ's passive-reply model doesn't support editing a sent message in
348        // place (each reply is a new message referencing a msg_id). Fall back
349        // to a fresh send so edit-based streaming degrades gracefully.
350        self.send_message(_chat_id, _text).await?;
351        Ok(())
352    }
353
354    async fn upload_file(
355        &self,
356        chat_id: &str,
357        file_path: &str,
358        media_type: &str,
359    ) -> Result<UploadResult> {
360        let auth = self.auth_header().await?;
361        let (endpoint, _is_group) =
362            resolve_upload_endpoint(self.config.api_base_url(), chat_id)?;
363
364        let file_type = match media_type {
365            "image" => crate::protocol::file_type::IMAGE,
366            "video" => crate::protocol::file_type::VIDEO,
367            "voice" => crate::protocol::file_type::VOICE,
368            _ => crate::protocol::file_type::FILE,
369        };
370
371        // Read file from disk and encode as base64.
372        let file_data = tokio::fs::read(file_path)
373            .await
374            .map_err(|e| AgentError::InternalError(format!("Failed to read file {}: {}", file_path, e)))?;
375
376        use base64::Engine;
377        let file_data_b64 = base64::engine::general_purpose::STANDARD.encode(&file_data);
378
379        // QQ upload API uses JSON body with base64-encoded file_data.
380        // srv_send_msg = false → returns file_info for later use (recommended).
381        let body = crate::protocol::UploadMediaRequest {
382            file_type,
383            url: None,
384            file_data: Some(file_data_b64),
385            srv_send_msg: false,
386        };
387
388        let resp = self
389            .http
390            .post(&endpoint)
391            .header("Authorization", &auth)
392            .json(&body)
393            .send()
394            .await
395            .map_err(|e| AgentError::InternalError(format!("QQ upload failed: {}", e)))?;
396
397        let status = resp.status();
398        let resp_body = resp.text().await.unwrap_or_default();
399
400        if !status.is_success() {
401            warn!("QQ upload {} failed ({}): {}", endpoint, status, resp_body);
402            return Err(AgentError::InternalError(format!(
403                "QQ upload failed ({}): {}",
404                status, resp_body
405            )));
406        }
407
408        let parsed: crate::protocol::UploadMediaResponse = serde_json::from_str(&resp_body)
409            .map_err(|e| {
410                AgentError::InternalError(format!(
411                    "QQ upload response parse failed: {} (body: {})",
412                    e, resp_body
413                ))
414            })?;
415
416        let file_info = parsed.file_info.ok_or_else(|| {
417            AgentError::InternalError(format!(
418                "QQ upload response missing file_info: {}",
419                resp_body
420            ))
421        })?;
422
423        let file_id = parsed
424            .file_uuid
425            .or(parsed.id)
426            .unwrap_or_else(|| file_info.clone());
427
428        debug!("QQ file uploaded: file_info={}, file_id={}", file_info, file_id);
429
430        Ok(UploadResult {
431            file_id,
432            url: file_info,
433        })
434    }
435
436    async fn send_media_message(
437        &self,
438        chat_id: &str,
439        file_url: &str,
440        file_name: &str,
441        media_type: &str,
442    ) -> Result<SendResult> {
443        let auth = self.auth_header().await?;
444        let (endpoint, _is_group) = resolve_send_endpoint(self.config.api_base_url(), chat_id)?;
445
446        let msg_id = self.reply_msg_id(chat_id).await;
447        let msg_seq = self.next_msg_seq().await;
448        let body = SendMessageRequest {
449            content: file_name.to_string(),
450            msg_type: crate::protocol::msg_type::MEDIA,
451            msg_id,
452            msg_seq: Some(msg_seq),
453            media: Some(MediaFileInfo {
454                file_info: file_url.to_string(),
455            }),
456        };
457
458        let resp = self
459            .http
460            .post(&endpoint)
461            .header("Authorization", &auth)
462            .json(&body)
463            .send()
464            .await
465            .map_err(|e| AgentError::InternalError(format!("QQ media send failed: {}", e)))?;
466
467        let status = resp.status();
468        if !status.is_success() {
469            let text = resp.text().await.unwrap_or_default();
470            warn!("QQ media send {} failed ({}): {}", endpoint, status, text);
471            return Err(AgentError::InternalError(format!(
472                "QQ media send failed ({}): {}",
473                status, text
474            )));
475        }
476
477        let parsed: SendMessageResponse = resp
478            .json()
479            .await
480            .map_err(|e| {
481                AgentError::InternalError(format!("QQ media send response parse: {}", e))
482            })?;
483
484        let id = parsed
485            .id
486            .or(parsed.msg_id)
487            .unwrap_or_else(|| format!("media-{}", msg_seq));
488        debug!(
489            "QQ media message sent to {} (id={}, type={})",
490            chat_id, id, media_type
491        );
492        Ok(SendResult { msg_id: id })
493    }
494
495    async fn recv_event(&self) -> Result<PlatformEvent> {
496        self.event_rx
497            .lock()
498            .await
499            .recv()
500            .await
501            .ok_or_else(|| AgentError::InternalError("QQ event channel closed".into()))
502    }
503}
504
505/// Intent bitmask for C2C + group @-messages.
506const INTENT_C2C: u32 = crate::protocol::INTENT_C2C;
507const INTENT_GROUP_AT_MESSAGE: u32 = crate::protocol::INTENT_GROUP_AT_MESSAGE;
508
509/// Resolve the HTTP send endpoint for a chat_id.
510///
511/// `group:{openid}` → `/v2/groups/{openid}/messages`
512/// `private:{openid}` → `/v2/users/{openid}/messages`
513fn resolve_send_endpoint(base: &str, chat_id: &str) -> Result<(String, bool)> {
514    if let Some(group_id) = chat_id.strip_prefix("group:") {
515        return Ok((
516            format!("{}/v2/groups/{}/messages", base, group_id),
517            true,
518        ));
519    }
520    if let Some(user_id) = chat_id.strip_prefix("private:") {
521        return Ok((
522            format!("{}/v2/users/{}/messages", base, user_id),
523            false,
524        ));
525    }
526    Err(AgentError::InternalError(format!(
527        "Invalid chat_id '{}': expected 'group:{{id}}' or 'private:{{id}}'",
528        chat_id
529    )))
530}
531
532/// Resolve the HTTP upload endpoint for a chat_id.
533///
534/// `group:{openid}` → `/v2/groups/{openid}/files`
535/// `private:{openid}` → `/v2/users/{openid}/files`
536fn resolve_upload_endpoint(base: &str, chat_id: &str) -> Result<(String, bool)> {
537    if let Some(group_id) = chat_id.strip_prefix("group:") {
538        return Ok((
539            format!("{}/v2/groups/{}/files", base, group_id),
540            true,
541        ));
542    }
543    if let Some(user_id) = chat_id.strip_prefix("private:") {
544        return Ok((
545            format!("{}/v2/users/{}/files", base, user_id),
546            false,
547        ));
548    }
549    Err(AgentError::InternalError(format!(
550        "Invalid chat_id '{}': expected 'group:{{id}}' or 'private:{{id}}'",
551        chat_id
552    )))
553}
554
555/// Spawn the periodic heartbeat task. Checks `shutdown` so it exits
556/// gracefully on Ctrl+C.
557fn spawn_heartbeat(adapter: Arc<QqPlatformAdapter>, shutdown: Arc<tokio::sync::Notify>) {
558    tokio::spawn(async move {
559        loop {
560            let interval = *adapter.heartbeat_interval.read().await;
561            tokio::select! {
562                _ = tokio::time::sleep(interval) => {}
563                _ = shutdown.notified() => {
564                    debug!("Heartbeat task received shutdown signal");
565                    return;
566                }
567            }
568
569            let last_seq = *adapter.last_seq.lock().await;
570            let heartbeat = GatewayPayload::heartbeat(last_seq);
571            let payload = match serde_json::to_string(&heartbeat) {
572                Ok(p) => p,
573                Err(e) => {
574                    warn!("Failed to serialize heartbeat: {}", e);
575                    continue;
576                }
577            };
578            let mut ws_tx = adapter.ws_tx.lock().await;
579            if let Some(write) = ws_tx.as_mut() {
580                if let Err(e) = write.send(Message::Text(payload.into())).await {
581                    warn!("Heartbeat send failed: {}", e);
582                    let _ = adapter
583                        .event_tx
584                        .send(PlatformEvent::Disconnected)
585                        .await;
586                    return;
587                }
588                debug!("Heartbeat sent (seq={:?})", last_seq);
589            } else {
590                warn!("Heartbeat: no WS writer (disconnected)");
591                return;
592            }
593        }
594    });
595}
596
597/// Spawn the dispatch task: reads WS frames, converts dispatch events to
598/// [`PlatformEvent`], and forwards them to the event channel. Checks
599/// `shutdown` so it exits gracefully on Ctrl+C.
600fn spawn_dispatch(
601    adapter: Arc<QqPlatformAdapter>,
602    mut read: impl futures_util::Stream<Item = std::result::Result<Message, tokio_tungstenite::tungstenite::Error>> + Unpin + Send + 'static,
603    shutdown: Arc<tokio::sync::Notify>,
604) {
605    tokio::spawn(async move {
606        loop {
607            tokio::select! {
608                frame = read.next() => {
609                    let msg = match frame {
610                        Some(Ok(m)) => m,
611                        Some(Err(e)) => {
612                            warn!("WS read error: {}", e);
613                            let _ = adapter.event_tx.send(PlatformEvent::Disconnected).await;
614                            return;
615                        }
616                        None => {
617                            info!("QQ dispatch stream ended");
618                            let _ = adapter.event_tx.send(PlatformEvent::Disconnected).await;
619                            return;
620                        }
621                    };
622                    let text = match msg {
623                        Message::Text(t) => t.to_string(),
624                        Message::Binary(b) => String::from_utf8_lossy(&b).into_owned(),
625                        Message::Close(_) => {
626                            info!("QQ WebSocket closed by server");
627                            let _ = adapter.event_tx.send(PlatformEvent::Disconnected).await;
628                            return;
629                        }
630                        _ => continue,
631                    };
632
633                    let payload: GatewayPayload = match serde_json::from_str(&text) {
634                        Ok(p) => p,
635                        Err(e) => {
636                            debug!("Skipping non-JSON WS frame: {}", e);
637                            continue;
638                        }
639                    };
640
641                    match payload.op {
642                        op::HEARTBEAT_ACK => {
643                            debug!("Heartbeat ACK");
644                        }
645                        op::RECONNECT => {
646                            warn!("Server requested reconnect");
647                            let _ = adapter.event_tx.send(PlatformEvent::Disconnected).await;
648                            return;
649                        }
650                        op::DISPATCH => {
651                            if let Some(seq) = payload.s {
652                                *adapter.last_seq.lock().await = Some(seq);
653                            }
654                            let event_name = payload.t.as_deref().unwrap_or("");
655                            match event_name {
656                                event_type::READY => {
657                                    info!("QQ bot is ready");
658                                    if let Some(d) = payload.d {
659                                        if let Some(sid) = d.get("session_id").and_then(|v| v.as_str()) {
660                                            *adapter.session_id.lock().await = Some(sid.to_string());
661                                        }
662                                    }
663                                }
664                                event_type::C2C_MESSAGE_CREATE
665                                | event_type::GROUP_AT_MESSAGE_CREATE => {
666                                    if let Some(d) = payload.d {
667                                        if let Ok(ev) = serde_json::from_value::<MessageEvent>(d) {
668                                            // Record the inbound msg_id for replies, then forward.
669                                            if let Some(chat_id) = chat_id_for_event(event_name, &ev) {
670                                                adapter.record_inbound(&chat_id, &ev.id);
671                                            }
672                                            if let Some(platform_ev) =
673                                                build_platform_event(event_name, &ev)
674                                            {
675                                                let _ = adapter.event_tx.send(platform_ev).await;
676                                            }
677                                        }
678                                    }
679                                }
680                                _ => {
681                                    debug!("Ignoring dispatch event: {}", event_name);
682                                }
683                            }
684                        }
685                        _ => {
686                            debug!("Unhandled op {}: {:?}", payload.op, payload.t);
687                        }
688                    }
689                } // end of frame match
690                _ = shutdown.notified() => {
691                    debug!("Dispatch task received shutdown signal");
692                    return;
693                }
694            } // end of tokio::select!
695        } // end of loop
696        // Unreachable: loop exits via return in all branches above.
697    });
698}
699
700/// Compute the platform `chat_id` for a QQ message event.
701fn chat_id_for_event(event_name: &str, ev: &MessageEvent) -> Option<String> {
702    match event_name {
703        event_type::GROUP_AT_MESSAGE_CREATE => {
704            Some(format!("group:{}", ev.group_openid.clone()?))
705        }
706        event_type::C2C_MESSAGE_CREATE => {
707            Some(format!("private:{}", ev.user_id()?))
708        }
709        _ => None,
710    }
711}
712
713/// Convert a QQ message event into a platform-agnostic [`PlatformEvent::Message`].
714fn build_platform_event(event_name: &str, ev: &MessageEvent) -> Option<PlatformEvent> {
715    let chat_id = chat_id_for_event(event_name, ev)?;
716    let chat_type = match event_name {
717        event_type::GROUP_AT_MESSAGE_CREATE => ChatType::Group,
718        event_type::C2C_MESSAGE_CREATE => ChatType::Private,
719        _ => return None,
720    };
721    let user_id = ev.user_id().unwrap_or("unknown").to_string();
722    // QQ group @-message content typically has a leading space from the @mention.
723    let mut text = ev.content.trim().to_string();
724
725    // Convert QQ attachments to platform-agnostic media attachments.
726    let attachments: Vec<MediaAttachment> = ev
727        .attachments
728        .iter()
729        .map(|att| MediaAttachment {
730            content_type: att.content_type.clone().unwrap_or_else(|| "application/octet-stream".into()),
731            url: att.url.clone(),
732            filename: att.filename.clone(),
733            size: att.size,
734            width: att.width,
735            height: att.height,
736        })
737        .collect();
738
739    // Append attachment descriptions to the text so the LLM knows about them.
740    if !attachments.is_empty() {
741        let descs: Vec<String> = attachments.iter().map(|a| a.describe()).collect();
742        if text.is_empty() {
743            text = descs.join("\n");
744        } else {
745            text = format!("{}\n{}", text, descs.join("\n"));
746        }
747    }
748
749    Some(PlatformEvent::Message(ChatMessage {
750        text,
751        sender: SenderInfo {
752            user_id,
753            chat_id,
754            chat_type,
755        },
756        attachments,
757    }))
758}
759
760#[cfg(test)]
761mod tests {
762    use super::*;
763
764    fn cfg() -> QqConfig {
765        QqConfig {
766            app_id: "id".into(),
767            app_secret: "secret".into(),
768            sandbox: false,
769        }
770    }
771
772    #[test]
773    fn resolves_group_send_endpoint() {
774        let (url, is_group) = resolve_send_endpoint("https://api.sgroup.qq.com", "group:abc").unwrap();
775        assert_eq!(url, "https://api.sgroup.qq.com/v2/groups/abc/messages");
776        assert!(is_group);
777    }
778
779    #[test]
780    fn resolves_private_send_endpoint() {
781        let (url, is_group) =
782            resolve_send_endpoint("https://api.sgroup.qq.com", "private:user1").unwrap();
783        assert_eq!(url, "https://api.sgroup.qq.com/v2/users/user1/messages");
784        assert!(!is_group);
785    }
786
787    #[test]
788    fn rejects_invalid_chat_id() {
789        assert!(resolve_send_endpoint("https://x", "bogus").is_err());
790    }
791
792    #[test]
793    fn resolves_group_upload_endpoint() {
794        let (url, is_group) =
795            resolve_upload_endpoint("https://api.sgroup.qq.com", "group:abc").unwrap();
796        assert_eq!(
797            url,
798            "https://api.sgroup.qq.com/v2/groups/abc/files"
799        );
800        assert!(is_group);
801    }
802
803    #[test]
804    fn resolves_private_upload_endpoint() {
805        let (url, is_group) =
806            resolve_upload_endpoint("https://api.sgroup.qq.com", "private:user1").unwrap();
807        assert_eq!(
808            url,
809            "https://api.sgroup.qq.com/v2/users/user1/files"
810        );
811        assert!(!is_group);
812    }
813
814    #[test]
815    fn builds_platform_event_for_group() {
816        let ev = MessageEvent {
817            id: "m1".into(),
818            content: " hello".into(),
819            author: crate::protocol::Author {
820                user_openid: None,
821                member_openid: Some("mem1".into()),
822            },
823            group_openid: Some("grp1".into()),
824            attachments: vec![],
825        };
826        let pe = build_platform_event(event_type::GROUP_AT_MESSAGE_CREATE, &ev).unwrap();
827        match pe {
828            PlatformEvent::Message(m) => {
829                assert_eq!(m.sender.chat_id, "group:grp1");
830                assert_eq!(m.sender.chat_type, ChatType::Group);
831                assert_eq!(m.text, "hello"); // leading space trimmed
832                assert_eq!(m.sender.user_id, "mem1");
833                assert!(m.attachments.is_empty());
834            }
835            _ => panic!("expected Message"),
836        }
837    }
838
839    #[test]
840    fn builds_platform_event_for_c2c() {
841        let ev = MessageEvent {
842            id: "m2".into(),
843            content: "hi".into(),
844            author: crate::protocol::Author {
845                user_openid: Some("u1".into()),
846                member_openid: None,
847            },
848            group_openid: None,
849            attachments: vec![],
850        };
851        let pe = build_platform_event(event_type::C2C_MESSAGE_CREATE, &ev).unwrap();
852        match pe {
853            PlatformEvent::Message(m) => {
854                assert_eq!(m.sender.chat_id, "private:u1");
855                assert_eq!(m.sender.chat_type, ChatType::Private);
856            }
857            _ => panic!("expected Message"),
858        }
859    }
860
861    #[test]
862    fn builds_platform_event_with_attachments() {
863        let ev = MessageEvent {
864            id: "m3".into(),
865            content: "look".into(),
866            author: crate::protocol::Author {
867                user_openid: Some("u2".into()),
868                member_openid: None,
869            },
870            group_openid: None,
871            attachments: vec![crate::protocol::QqAttachment {
872                url: "https://cdn.qq.com/img/test.png".into(),
873                content_type: Some("image/png".into()),
874                filename: Some("test.png".into()),
875                size: Some(204800),
876                width: Some(800),
877                height: Some(600),
878            }],
879        };
880        let pe = build_platform_event(event_type::C2C_MESSAGE_CREATE, &ev).unwrap();
881        match pe {
882            PlatformEvent::Message(m) => {
883                assert_eq!(m.attachments.len(), 1);
884                assert_eq!(m.attachments[0].content_type, "image/png");
885                assert_eq!(m.attachments[0].url, "https://cdn.qq.com/img/test.png");
886                assert!(m.attachments[0].is_image());
887                // Text should include attachment description.
888                assert!(m.text.contains("用户发送了图片"));
889                assert!(m.text.contains("test.png"));
890            }
891            _ => panic!("expected Message"),
892        }
893    }
894
895    #[test]
896    fn from_config_extracts_qq_section() {
897        let toml_str = r#"
898            [channels.qq_bot]
899            app_id = "123"
900            app_secret = "s"
901        "#;
902        // RobitConfig requires a non-empty `providers` map, so add a minimal one.
903        let toml_with_providers = format!(
904            "{}\n[providers.x]\nbase_url = \"https://x\"\napi_key = \"k\"\n[[providers.x.models]]\nid = \"m\"\n",
905            toml_str
906        );
907        let config: RobitConfig = toml::from_str(&toml_with_providers).unwrap();
908        let qq = QqConfig::from_config(&config).unwrap();
909        assert_eq!(qq.app_id, "123");
910        assert_eq!(qq.app_secret, "s");
911    }
912
913    #[test]
914    fn from_config_errors_when_missing() {
915        let toml_str = r#"
916            [providers.x]
917            base_url = "https://x"
918            api_key = "k"
919            [[providers.x.models]]
920            id = "m"
921        "#;
922        let config: RobitConfig = toml::from_str(toml_str).unwrap();
923        assert!(QqConfig::from_config(&config).is_err());
924    }
925
926    #[test]
927    fn gateway_and_api_urls() {
928        let c = cfg();
929        assert!(c.gateway_url().starts_with("wss://"));
930        assert!(c.api_base_url().starts_with("https://"));
931    }
932}