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