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