1use std::sync::Arc;
18use std::time::{Duration, Instant};
19
20use async_trait::async_trait;
21use futures_util::{SinkExt, StreamExt};
22use robit_agent::error::{AgentError, Result};
23use robit_ai::config::RobitConfig;
24use robit_chatbot::adapter::{
25 ChatMessage, ChatType, MediaAttachment, PlatformAdapter, PlatformCaps, PlatformEvent,
26 SendResult, SenderInfo, UploadResult,
27};
28use tokio::sync::{mpsc, Mutex, RwLock};
29use tokio_util::sync::CancellationToken;
30use tokio_tungstenite::tungstenite::Message;
31use tracing::{debug, info, warn};
32
33use crate::protocol::{
34 event_type, AccessTokenRequest, AccessTokenResponse, GatewayPayload, HelloData, MediaFileInfo,
35 MessageEvent, SendMessageRequest, SendMessageResponse, op,
36};
37
38#[derive(Debug, Clone)]
40pub struct QqConfig {
41 pub app_id: String,
42 pub app_secret: String,
43 pub sandbox: bool,
44}
45
46impl QqConfig {
47 pub fn from_config(config: &RobitConfig) -> std::result::Result<Self, String> {
49 let qq = config
50 .channels
51 .as_ref()
52 .and_then(|c| c.qq_bot.as_ref())
53 .ok_or_else(|| {
54 "QQ Bot config not found. Add [channels.qq_bot] section to config.toml".to_string()
55 })?;
56 Ok(Self {
57 app_id: qq.app_id.clone(),
58 app_secret: qq.app_secret.clone(),
59 sandbox: false,
60 })
61 }
62
63 pub fn gateway_url(&self) -> &str {
65 if self.sandbox {
66 "wss://sandbox.api.sgroup.qq.com/websockets"
67 } else {
68 "wss://api.sgroup.qq.com/websockets"
69 }
70 }
71
72 pub fn api_base_url(&self) -> &str {
74 if self.sandbox {
75 "https://sandbox.api.sgroup.qq.com"
76 } else {
77 "https://api.sgroup.qq.com"
78 }
79 }
80
81 pub fn access_token_url(&self) -> &str {
83 "https://bots.qq.com/app/getAppAccessToken"
84 }
85}
86
87struct CachedToken {
89 token: String,
90 expires_at: Instant,
92}
93
94pub struct QqPlatformAdapter {
96 config: QqConfig,
97 http: reqwest::Client,
99 access_token: RwLock<Option<CachedToken>>,
101 last_seq: Mutex<Option<u64>>,
103 session_id: Mutex<Option<String>>,
105 event_tx: mpsc::Sender<PlatformEvent>,
107 event_rx: Mutex<mpsc::Receiver<PlatformEvent>>,
108 ws_tx: Mutex<Option<futures_util::stream::SplitSink<WebSocket, Message>>>,
112 #[allow(dead_code)]
114 caps: PlatformCaps,
115 heartbeat_interval: RwLock<Duration>,
117 last_inbound_msg_id: Mutex<Option<(String, String)>>, msg_seq: Mutex<u32>,
122}
123
124type WebSocket =
125 tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
126
127impl QqPlatformAdapter {
128 pub async fn connect(config: QqConfig, shutdown: Arc<tokio::sync::Notify>) -> Result<Arc<Self>> {
140 let http = reqwest::Client::new();
141 let caps = PlatformCaps::qq();
142 let (event_tx, event_rx) = mpsc::channel::<PlatformEvent>(256);
143
144 let adapter = Arc::new(Self {
145 config: config.clone(),
146 http,
147 access_token: RwLock::new(None),
148 last_seq: Mutex::new(None),
149 session_id: Mutex::new(None),
150 event_tx,
151 event_rx: Mutex::new(event_rx),
152 ws_tx: Mutex::new(None),
153 caps,
154 heartbeat_interval: RwLock::new(Duration::from_secs(41)),
155 last_inbound_msg_id: Mutex::new(None),
156 msg_seq: Mutex::new(0),
157 });
158
159 let conn_cancel = CancellationToken::new();
160 let tasks = adapter
161 .establish_connection(shutdown.clone(), conn_cancel.clone())
162 .await?;
163
164 let supervisor = Arc::clone(&adapter);
170 tokio::spawn(async move {
171 supervisor_loop(supervisor, tasks, conn_cancel, shutdown).await;
172 });
173
174 Ok(adapter)
175 }
176
177 async fn establish_connection(
184 self: &Arc<Self>,
185 shutdown: Arc<tokio::sync::Notify>,
186 conn_cancel: CancellationToken,
187 ) -> Result<ConnectionTasks> {
188 info!("Connecting to QQ gateway: {}", self.config.gateway_url());
189 let (ws_stream, _response) = tokio_tungstenite::connect_async(self.config.gateway_url())
190 .await
191 .map_err(|e| AgentError::InternalError(format!("WebSocket connect failed: {}", e)))?;
192
193 let (mut write, mut read) = ws_stream.split();
194 write
195 .send(Message::Ping(bytes::Bytes::new()))
196 .await
197 .map_err(|e| AgentError::InternalError(format!("WS ping failed: {}", e)))?;
198
199 let heartbeat_interval = loop {
201 let msg = read
202 .next()
203 .await
204 .ok_or_else(|| AgentError::InternalError("WebSocket closed before Hello".into()))?
205 .map_err(|e| AgentError::InternalError(format!("WS read error: {}", e)))?;
206 if let Message::Text(text) = msg {
207 let payload: GatewayPayload =
208 serde_json::from_str(&text).map_err(|e| {
209 AgentError::InternalError(format!("Invalid Hello JSON: {}", e))
210 })?;
211 if payload.op == op::HELLO {
212 let hello: HelloData = serde_json::from_value(
213 payload.d.ok_or_else(|| AgentError::InternalError("Hello missing d".into()))?,
214 )
215 .map_err(|e| AgentError::InternalError(format!("Invalid Hello data: {}", e)))?;
216 break Duration::from_millis(hello.heartbeat_interval);
217 }
218 }
219 };
220 *self.heartbeat_interval.write().await = heartbeat_interval;
221 info!("QQ heartbeat interval: {:?}", heartbeat_interval);
222
223 let access_token = self.fetch_access_token().await?;
225 let identify =
226 GatewayPayload::identify(&access_token, INTENT_C2C | INTENT_GROUP_AT_MESSAGE);
227 write
228 .send(Message::Text(serde_json::to_string(&identify).unwrap().into()))
229 .await
230 .map_err(|e| AgentError::InternalError(format!("Identify send failed: {}", e)))?;
231
232 *self.ws_tx.lock().await = Some(write);
234
235 let heartbeat = spawn_heartbeat(Arc::clone(self), conn_cancel.clone(), shutdown.clone());
236 let dispatch = spawn_dispatch(Arc::clone(self), read, conn_cancel, shutdown);
237
238 Ok(ConnectionTasks { dispatch, heartbeat })
239 }
240
241 async fn fetch_access_token(&self) -> Result<String> {
243 {
245 let cache = self.access_token.read().await;
246 if let Some(cached) = cache.as_ref() {
247 if cached.expires_at.duration_since(Instant::now())
248 > Duration::from_secs(60)
249 {
250 return Ok(cached.token.clone());
251 }
252 }
253 }
254
255 let req = AccessTokenRequest {
256 app_id: self.config.app_id.clone(),
257 client_secret: self.config.app_secret.clone(),
258 };
259
260 let response = self
261 .http
262 .post(self.config.access_token_url())
263 .json(&req)
264 .send()
265 .await
266 .map_err(|e| AgentError::InternalError(format!("Access token request failed: {}", e)))?;
267
268 let status = response.status();
269 let text = response.text().await.unwrap_or_default();
270
271 if !status.is_success() {
272 return Err(AgentError::InternalError(format!("Access token request failed ({}): {}", status, text)));
273 }
274
275 let resp: AccessTokenResponse = serde_json::from_str(&text)
276 .map_err(|e| AgentError::InternalError(format!("Access token parse failed: {}", e)))?;
277
278 let token = resp.access_token.clone();
279 let expires_in = resp.expires_in.max(60);
280 let cached = CachedToken {
281 token: token.clone(),
282 expires_at: Instant::now() + Duration::from_secs(expires_in),
283 };
284 *self.access_token.write().await = Some(cached);
285 debug!("Fetched QQ access token (expires in {}s)", expires_in);
286 Ok(token)
287 }
288
289 async fn auth_header(&self) -> Result<String> {
291 let token = self.fetch_access_token().await?;
292 Ok(format!("QQBot {}", token))
293 }
294
295 fn record_inbound(&self, chat_id: &str, msg_id: &str) {
297 if let Ok(mut guard) = self.last_inbound_msg_id.try_lock() {
298 *guard = Some((chat_id.to_string(), msg_id.to_string()));
299 }
300 }
301
302 async fn reply_msg_id(&self, chat_id: &str) -> Option<String> {
304 let guard = self.last_inbound_msg_id.lock().await;
305 guard
306 .as_ref()
307 .filter(|(cid, _)| cid == chat_id)
308 .map(|(_, id)| id.clone())
309 }
310
311 async fn next_msg_seq(&self) -> u32 {
312 let mut seq = self.msg_seq.lock().await;
313 *seq = seq.wrapping_add(1);
314 *seq
315 }
316}
317
318#[async_trait]
319impl PlatformAdapter for QqPlatformAdapter {
320 fn capabilities() -> PlatformCaps {
321 PlatformCaps::qq()
322 }
323
324 async fn send_message(&self, chat_id: &str, text: &str) -> Result<SendResult> {
325 let auth = self.auth_header().await?;
326 let (endpoint, is_group) = resolve_send_endpoint(self.config.api_base_url(), chat_id)?;
327
328 let msg_id = self.reply_msg_id(chat_id).await;
329 let msg_seq = self.next_msg_seq().await;
330 let body = SendMessageRequest {
331 content: text.to_string(),
332 msg_type: crate::protocol::msg_type::TEXT,
333 msg_id,
334 msg_seq: Some(msg_seq),
335 media: None,
336 };
337
338 let resp = self
339 .http
340 .post(&endpoint)
341 .header("Authorization", &auth)
342 .json(&body)
343 .send()
344 .await
345 .map_err(|e| AgentError::InternalError(format!("QQ send failed: {}", e)))?;
346
347 let status = resp.status();
348 if !status.is_success() {
349 let text = resp.text().await.unwrap_or_default();
350 warn!("QQ send {} failed ({}): {}", endpoint, status, text);
351 return Err(AgentError::InternalError(format!(
352 "QQ send failed ({})",
353 status
354 )));
355 }
356
357 let parsed: SendMessageResponse = resp
358 .json()
359 .await
360 .map_err(|e| AgentError::InternalError(format!("QQ send response parse: {}", e)))?;
361
362 let id = parsed
363 .id
364 .or(parsed.msg_id)
365 .unwrap_or_else(|| format!("sent-{}", msg_seq));
366 debug!("QQ message sent to {} (id={})", chat_id, id);
367 let _ = is_group; Ok(SendResult { msg_id: id })
369 }
370
371 async fn edit_message(&self, _chat_id: &str, _msg_id: &str, _text: &str) -> Result<()> {
372 self.send_message(_chat_id, _text).await?;
376 Ok(())
377 }
378
379 async fn upload_file(
380 &self,
381 chat_id: &str,
382 file_path: &str,
383 media_type: &str,
384 ) -> Result<UploadResult> {
385 let auth = self.auth_header().await?;
386 let (endpoint, _is_group) =
387 resolve_upload_endpoint(self.config.api_base_url(), chat_id)?;
388
389 let file_type = match media_type {
390 "image" => crate::protocol::file_type::IMAGE,
391 "video" => crate::protocol::file_type::VIDEO,
392 "voice" => crate::protocol::file_type::VOICE,
393 _ => crate::protocol::file_type::FILE,
394 };
395
396 let file_data = tokio::fs::read(file_path)
398 .await
399 .map_err(|e| AgentError::InternalError(format!("Failed to read file {}: {}", file_path, e)))?;
400
401 use base64::Engine;
402 let file_data_b64 = base64::engine::general_purpose::STANDARD.encode(&file_data);
403
404 let body = crate::protocol::UploadMediaRequest {
407 file_type,
408 url: None,
409 file_data: Some(file_data_b64),
410 srv_send_msg: false,
411 };
412
413 let resp = self
414 .http
415 .post(&endpoint)
416 .header("Authorization", &auth)
417 .json(&body)
418 .send()
419 .await
420 .map_err(|e| AgentError::InternalError(format!("QQ upload failed: {}", e)))?;
421
422 let status = resp.status();
423 let resp_body = resp.text().await.unwrap_or_default();
424
425 if !status.is_success() {
426 warn!("QQ upload {} failed ({}): {}", endpoint, status, resp_body);
427 return Err(AgentError::InternalError(format!(
428 "QQ upload failed ({}): {}",
429 status, resp_body
430 )));
431 }
432
433 let parsed: crate::protocol::UploadMediaResponse = serde_json::from_str(&resp_body)
434 .map_err(|e| {
435 AgentError::InternalError(format!(
436 "QQ upload response parse failed: {} (body: {})",
437 e, resp_body
438 ))
439 })?;
440
441 let file_info = parsed.file_info.ok_or_else(|| {
442 AgentError::InternalError(format!(
443 "QQ upload response missing file_info: {}",
444 resp_body
445 ))
446 })?;
447
448 let file_id = parsed
449 .file_uuid
450 .or(parsed.id)
451 .unwrap_or_else(|| file_info.clone());
452
453 debug!("QQ file uploaded: file_info={}, file_id={}", file_info, file_id);
454
455 Ok(UploadResult {
456 file_id,
457 url: file_info,
458 })
459 }
460
461 async fn send_media_message(
462 &self,
463 chat_id: &str,
464 file_url: &str,
465 file_name: &str,
466 media_type: &str,
467 ) -> Result<SendResult> {
468 let auth = self.auth_header().await?;
469 let (endpoint, _is_group) = resolve_send_endpoint(self.config.api_base_url(), chat_id)?;
470
471 let msg_id = self.reply_msg_id(chat_id).await;
472 let msg_seq = self.next_msg_seq().await;
473 let body = SendMessageRequest {
474 content: file_name.to_string(),
475 msg_type: crate::protocol::msg_type::MEDIA,
476 msg_id,
477 msg_seq: Some(msg_seq),
478 media: Some(MediaFileInfo {
479 file_info: file_url.to_string(),
480 }),
481 };
482
483 let resp = self
484 .http
485 .post(&endpoint)
486 .header("Authorization", &auth)
487 .json(&body)
488 .send()
489 .await
490 .map_err(|e| AgentError::InternalError(format!("QQ media send failed: {}", e)))?;
491
492 let status = resp.status();
493 if !status.is_success() {
494 let text = resp.text().await.unwrap_or_default();
495 warn!("QQ media send {} failed ({}): {}", endpoint, status, text);
496 return Err(AgentError::InternalError(format!(
497 "QQ media send failed ({}): {}",
498 status, text
499 )));
500 }
501
502 let parsed: SendMessageResponse = resp
503 .json()
504 .await
505 .map_err(|e| {
506 AgentError::InternalError(format!("QQ media send response parse: {}", e))
507 })?;
508
509 let id = parsed
510 .id
511 .or(parsed.msg_id)
512 .unwrap_or_else(|| format!("media-{}", msg_seq));
513 debug!(
514 "QQ media message sent to {} (id={}, type={})",
515 chat_id, id, media_type
516 );
517 Ok(SendResult { msg_id: id })
518 }
519
520 async fn recv_event(&self) -> Result<PlatformEvent> {
521 self.event_rx
522 .lock()
523 .await
524 .recv()
525 .await
526 .ok_or_else(|| AgentError::InternalError("QQ event channel closed".into()))
527 }
528}
529
530const INTENT_C2C: u32 = crate::protocol::INTENT_C2C;
532const INTENT_GROUP_AT_MESSAGE: u32 = crate::protocol::INTENT_GROUP_AT_MESSAGE;
533
534fn resolve_send_endpoint(base: &str, chat_id: &str) -> Result<(String, bool)> {
539 if let Some(group_id) = chat_id.strip_prefix("group:") {
540 return Ok((
541 format!("{}/v2/groups/{}/messages", base, group_id),
542 true,
543 ));
544 }
545 if let Some(user_id) = chat_id.strip_prefix("private:") {
546 return Ok((
547 format!("{}/v2/users/{}/messages", base, user_id),
548 false,
549 ));
550 }
551 Err(AgentError::InternalError(format!(
552 "Invalid chat_id '{}': expected 'group:{{id}}' or 'private:{{id}}'",
553 chat_id
554 )))
555}
556
557fn resolve_upload_endpoint(base: &str, chat_id: &str) -> Result<(String, bool)> {
562 if let Some(group_id) = chat_id.strip_prefix("group:") {
563 return Ok((
564 format!("{}/v2/groups/{}/files", base, group_id),
565 true,
566 ));
567 }
568 if let Some(user_id) = chat_id.strip_prefix("private:") {
569 return Ok((
570 format!("{}/v2/users/{}/files", base, user_id),
571 false,
572 ));
573 }
574 Err(AgentError::InternalError(format!(
575 "Invalid chat_id '{}': expected 'group:{{id}}' or 'private:{{id}}'",
576 chat_id
577 )))
578}
579
580struct ConnectionTasks {
582 dispatch: tokio::task::JoinHandle<()>,
583 heartbeat: tokio::task::JoinHandle<()>,
584}
585
586#[derive(Clone, Copy)]
589enum FrameAction {
590 Continue,
591 Reconnect,
592}
593
594#[derive(Clone, Copy)]
599enum TaskEnded {
600 Dispatch,
601 Heartbeat,
602}
603
604async fn supervisor_loop(
613 adapter: Arc<QqPlatformAdapter>,
614 mut tasks: ConnectionTasks,
615 mut conn_cancel: CancellationToken,
616 shutdown: Arc<tokio::sync::Notify>,
617) {
618 let mut establish_failures: u32 = 0;
619 loop {
620 let ended = tokio::select! {
629 res = &mut tasks.dispatch => {
630 match res {
631 Ok(()) => debug!("Dispatch task ended"),
632 Err(e) => warn!("Dispatch task panicked: {}", e),
633 }
634 TaskEnded::Dispatch
635 }
636 res = &mut tasks.heartbeat => {
637 match res {
638 Ok(()) => debug!("Heartbeat task ended"),
639 Err(e) => warn!("Heartbeat task panicked: {}", e),
640 }
641 TaskEnded::Heartbeat
642 }
643 _ = shutdown.notified() => {
644 debug!("Supervisor received shutdown signal");
645 conn_cancel.cancel();
646 let _ = tasks.dispatch.await;
649 let _ = tasks.heartbeat.await;
650 return;
651 }
652 };
653
654 conn_cancel.cancel();
657 match ended {
660 TaskEnded::Dispatch => {
661 let _ = tasks.heartbeat.await;
662 }
663 TaskEnded::Heartbeat => {
664 let _ = tasks.dispatch.await;
665 }
666 }
667 *adapter.ws_tx.lock().await = None;
668
669 if sleep_or_shutdown(Duration::from_secs(1), &shutdown).await {
672 return;
673 }
674
675 loop {
677 conn_cancel = CancellationToken::new();
678 match adapter
679 .establish_connection(shutdown.clone(), conn_cancel.clone())
680 .await
681 {
682 Ok(new_tasks) => {
683 establish_failures = 0;
684 info!("Reconnected to QQ gateway");
685 tasks = new_tasks;
686 break; }
688 Err(e) => {
689 establish_failures = establish_failures.saturating_add(1);
690 let backoff = reconnect_backoff(establish_failures);
691 warn!(
692 "Reconnect attempt #{} failed: {}, retrying in {:?}",
693 establish_failures, e, backoff
694 );
695 if sleep_or_shutdown(backoff, &shutdown).await {
696 return;
697 }
698 }
699 }
700 }
701 }
702}
703
704async fn sleep_or_shutdown(dur: Duration, shutdown: &Arc<tokio::sync::Notify>) -> bool {
706 tokio::select! {
707 _ = tokio::time::sleep(dur) => false,
708 _ = shutdown.notified() => true,
709 }
710}
711
712fn reconnect_backoff(failures: u32) -> Duration {
715 let secs = 1u64 << failures.saturating_sub(1).min(5);
716 Duration::from_secs(secs.min(30))
717}
718
719fn spawn_heartbeat(
725 adapter: Arc<QqPlatformAdapter>,
726 conn_cancel: CancellationToken,
727 shutdown: Arc<tokio::sync::Notify>,
728) -> tokio::task::JoinHandle<()> {
729 tokio::spawn(async move {
730 loop {
731 let interval = *adapter.heartbeat_interval.read().await;
732 tokio::select! {
733 _ = conn_cancel.cancelled() => {
734 debug!("Heartbeat task cancelled");
735 return;
736 }
737 _ = shutdown.notified() => {
738 debug!("Heartbeat task received shutdown signal");
739 return;
740 }
741 _ = tokio::time::sleep(interval) => {}
742 }
743 if let Err(e) = send_heartbeat(&adapter).await {
744 warn!("Heartbeat send failed, dropping connection: {}", e);
745 return;
746 }
747 }
748 })
749}
750
751async fn send_heartbeat(adapter: &QqPlatformAdapter) -> std::result::Result<(), String> {
754 let last_seq = *adapter.last_seq.lock().await;
755 let heartbeat = GatewayPayload::heartbeat(last_seq);
756 let payload = serde_json::to_string(&heartbeat).map_err(|e| e.to_string())?;
757 let mut ws_tx = adapter.ws_tx.lock().await;
758 match ws_tx.as_mut() {
759 Some(write) => write
760 .send(Message::Text(payload.into()))
761 .await
762 .map_err(|e| e.to_string()),
763 None => Err("no WS writer (disconnected)".to_string()),
764 }
765}
766
767fn spawn_dispatch(
772 adapter: Arc<QqPlatformAdapter>,
773 mut read: impl futures_util::Stream<
774 Item = std::result::Result<Message, tokio_tungstenite::tungstenite::Error>,
775 > + Unpin
776 + Send
777 + 'static,
778 conn_cancel: CancellationToken,
779 shutdown: Arc<tokio::sync::Notify>,
780) -> tokio::task::JoinHandle<()> {
781 tokio::spawn(async move {
782 loop {
783 tokio::select! {
784 _ = conn_cancel.cancelled() => {
785 debug!("Dispatch task cancelled");
786 return;
787 }
788 _ = shutdown.notified() => {
789 debug!("Dispatch task received shutdown signal");
790 return;
791 }
792 frame = read.next() => {
793 let msg = match frame {
794 Some(Ok(m)) => m,
795 Some(Err(e)) => {
796 warn!("WS read error, dropping connection: {}", e);
797 return;
798 }
799 None => {
800 info!("QQ dispatch stream ended");
801 return;
802 }
803 };
804 match process_frame(&adapter, msg).await {
805 FrameAction::Continue => {}
806 FrameAction::Reconnect => return,
807 }
808 }
809 }
810 }
811 })
812}
813
814async fn process_frame(adapter: &QqPlatformAdapter, msg: Message) -> FrameAction {
817 let text = match msg {
818 Message::Text(t) => t.to_string(),
819 Message::Binary(b) => String::from_utf8_lossy(&b).into_owned(),
820 Message::Close(_) => {
821 info!("QQ WebSocket closed by server");
822 return FrameAction::Reconnect;
823 }
824 _ => return FrameAction::Continue,
825 };
826
827 let payload: GatewayPayload = match serde_json::from_str(&text) {
828 Ok(p) => p,
829 Err(e) => {
830 debug!("Skipping non-JSON WS frame: {}", e);
831 return FrameAction::Continue;
832 }
833 };
834
835 match payload.op {
836 op::HEARTBEAT_ACK => FrameAction::Continue,
837 op::RECONNECT => {
838 warn!("Server requested reconnect");
839 FrameAction::Reconnect
840 }
841 op::INVALID_SESSION => {
842 warn!("Invalid session, will re-identify");
846 *adapter.session_id.lock().await = None;
847 FrameAction::Reconnect
848 }
849 op::DISPATCH => {
850 if let Some(seq) = payload.s {
851 *adapter.last_seq.lock().await = Some(seq);
852 }
853 let event_name = payload.t.as_deref().unwrap_or("");
854 match event_name {
855 event_type::READY => {
856 info!("QQ bot is ready");
857 if let Some(d) = payload.d {
858 if let Some(sid) = d.get("session_id").and_then(|v| v.as_str()) {
859 *adapter.session_id.lock().await = Some(sid.to_string());
860 }
861 }
862 }
863 event_type::RESUMED => {
864 debug!("Session resumed event");
865 }
866 event_type::C2C_MESSAGE_CREATE | event_type::GROUP_AT_MESSAGE_CREATE => {
867 if let Some(d) = payload.d {
868 if let Ok(ev) = serde_json::from_value::<MessageEvent>(d) {
869 if let Some(chat_id) = chat_id_for_event(event_name, &ev) {
871 adapter.record_inbound(&chat_id, &ev.id);
872 }
873 if let Some(platform_ev) = build_platform_event(event_name, &ev) {
874 let _ = adapter.event_tx.send(platform_ev).await;
875 }
876 }
877 }
878 }
879 _ => {
880 debug!("Ignoring dispatch event: {}", event_name);
881 }
882 }
883 FrameAction::Continue
884 }
885 _ => {
886 debug!("Unhandled op {}: {:?}", payload.op, payload.t);
887 FrameAction::Continue
888 }
889 }
890}
891
892fn chat_id_for_event(event_name: &str, ev: &MessageEvent) -> Option<String> {
894 match event_name {
895 event_type::GROUP_AT_MESSAGE_CREATE => {
896 Some(format!("group:{}", ev.group_openid.clone()?))
897 }
898 event_type::C2C_MESSAGE_CREATE => {
899 Some(format!("private:{}", ev.user_id()?))
900 }
901 _ => None,
902 }
903}
904
905fn build_platform_event(event_name: &str, ev: &MessageEvent) -> Option<PlatformEvent> {
907 let chat_id = chat_id_for_event(event_name, ev)?;
908 let chat_type = match event_name {
909 event_type::GROUP_AT_MESSAGE_CREATE => ChatType::Group,
910 event_type::C2C_MESSAGE_CREATE => ChatType::Private,
911 _ => return None,
912 };
913 let user_id = ev.user_id().unwrap_or("unknown").to_string();
914 let mut text = ev.content.trim().to_string();
916
917 let attachments: Vec<MediaAttachment> = ev
919 .attachments
920 .iter()
921 .map(|att| MediaAttachment {
922 content_type: att.content_type.clone().unwrap_or_else(|| "application/octet-stream".into()),
923 url: att.url.clone(),
924 filename: att.filename.clone(),
925 size: att.size,
926 width: att.width,
927 height: att.height,
928 })
929 .collect();
930
931 if !attachments.is_empty() {
933 let descs: Vec<String> = attachments.iter().map(|a| a.describe()).collect();
934 if text.is_empty() {
935 text = descs.join("\n");
936 } else {
937 text = format!("{}\n{}", text, descs.join("\n"));
938 }
939 }
940
941 Some(PlatformEvent::Message(ChatMessage {
942 text,
943 sender: SenderInfo {
944 user_id,
945 chat_id,
946 chat_type,
947 },
948 attachments,
949 }))
950}
951
952#[cfg(test)]
953mod tests {
954 use super::*;
955
956 fn cfg() -> QqConfig {
957 QqConfig {
958 app_id: "id".into(),
959 app_secret: "secret".into(),
960 sandbox: false,
961 }
962 }
963
964 #[test]
965 fn reconnect_backoff_grows_then_caps() {
966 assert_eq!(reconnect_backoff(1), Duration::from_secs(1));
967 assert_eq!(reconnect_backoff(2), Duration::from_secs(2));
968 assert_eq!(reconnect_backoff(3), Duration::from_secs(4));
969 assert_eq!(reconnect_backoff(4), Duration::from_secs(8));
970 assert_eq!(reconnect_backoff(5), Duration::from_secs(16));
971 assert_eq!(reconnect_backoff(6), Duration::from_secs(30));
973 assert_eq!(reconnect_backoff(100), Duration::from_secs(30));
974 }
975
976 #[test]
977 fn resolves_group_send_endpoint() {
978 let (url, is_group) = resolve_send_endpoint("https://api.sgroup.qq.com", "group:abc").unwrap();
979 assert_eq!(url, "https://api.sgroup.qq.com/v2/groups/abc/messages");
980 assert!(is_group);
981 }
982
983 #[test]
984 fn resolves_private_send_endpoint() {
985 let (url, is_group) =
986 resolve_send_endpoint("https://api.sgroup.qq.com", "private:user1").unwrap();
987 assert_eq!(url, "https://api.sgroup.qq.com/v2/users/user1/messages");
988 assert!(!is_group);
989 }
990
991 #[test]
992 fn rejects_invalid_chat_id() {
993 assert!(resolve_send_endpoint("https://x", "bogus").is_err());
994 }
995
996 #[test]
997 fn resolves_group_upload_endpoint() {
998 let (url, is_group) =
999 resolve_upload_endpoint("https://api.sgroup.qq.com", "group:abc").unwrap();
1000 assert_eq!(
1001 url,
1002 "https://api.sgroup.qq.com/v2/groups/abc/files"
1003 );
1004 assert!(is_group);
1005 }
1006
1007 #[test]
1008 fn resolves_private_upload_endpoint() {
1009 let (url, is_group) =
1010 resolve_upload_endpoint("https://api.sgroup.qq.com", "private:user1").unwrap();
1011 assert_eq!(
1012 url,
1013 "https://api.sgroup.qq.com/v2/users/user1/files"
1014 );
1015 assert!(!is_group);
1016 }
1017
1018 #[test]
1019 fn builds_platform_event_for_group() {
1020 let ev = MessageEvent {
1021 id: "m1".into(),
1022 content: " hello".into(),
1023 author: crate::protocol::Author {
1024 user_openid: None,
1025 member_openid: Some("mem1".into()),
1026 },
1027 group_openid: Some("grp1".into()),
1028 attachments: vec![],
1029 };
1030 let pe = build_platform_event(event_type::GROUP_AT_MESSAGE_CREATE, &ev).unwrap();
1031 match pe {
1032 PlatformEvent::Message(m) => {
1033 assert_eq!(m.sender.chat_id, "group:grp1");
1034 assert_eq!(m.sender.chat_type, ChatType::Group);
1035 assert_eq!(m.text, "hello"); assert_eq!(m.sender.user_id, "mem1");
1037 assert!(m.attachments.is_empty());
1038 }
1039 _ => panic!("expected Message"),
1040 }
1041 }
1042
1043 #[test]
1044 fn builds_platform_event_for_c2c() {
1045 let ev = MessageEvent {
1046 id: "m2".into(),
1047 content: "hi".into(),
1048 author: crate::protocol::Author {
1049 user_openid: Some("u1".into()),
1050 member_openid: None,
1051 },
1052 group_openid: None,
1053 attachments: vec![],
1054 };
1055 let pe = build_platform_event(event_type::C2C_MESSAGE_CREATE, &ev).unwrap();
1056 match pe {
1057 PlatformEvent::Message(m) => {
1058 assert_eq!(m.sender.chat_id, "private:u1");
1059 assert_eq!(m.sender.chat_type, ChatType::Private);
1060 }
1061 _ => panic!("expected Message"),
1062 }
1063 }
1064
1065 #[test]
1066 fn builds_platform_event_with_attachments() {
1067 let ev = MessageEvent {
1068 id: "m3".into(),
1069 content: "look".into(),
1070 author: crate::protocol::Author {
1071 user_openid: Some("u2".into()),
1072 member_openid: None,
1073 },
1074 group_openid: None,
1075 attachments: vec![crate::protocol::QqAttachment {
1076 url: "https://cdn.qq.com/img/test.png".into(),
1077 content_type: Some("image/png".into()),
1078 filename: Some("test.png".into()),
1079 size: Some(204800),
1080 width: Some(800),
1081 height: Some(600),
1082 }],
1083 };
1084 let pe = build_platform_event(event_type::C2C_MESSAGE_CREATE, &ev).unwrap();
1085 match pe {
1086 PlatformEvent::Message(m) => {
1087 assert_eq!(m.attachments.len(), 1);
1088 assert_eq!(m.attachments[0].content_type, "image/png");
1089 assert_eq!(m.attachments[0].url, "https://cdn.qq.com/img/test.png");
1090 assert!(m.attachments[0].is_image());
1091 assert!(m.text.contains("用户发送了图片"));
1093 assert!(m.text.contains("test.png"));
1094 }
1095 _ => panic!("expected Message"),
1096 }
1097 }
1098
1099 #[test]
1100 fn from_config_extracts_qq_section() {
1101 let toml_str = r#"
1102 [channels.qq_bot]
1103 app_id = "123"
1104 app_secret = "s"
1105 "#;
1106 let toml_with_providers = format!(
1108 "{}\n[providers.x]\nbase_url = \"https://x\"\napi_key = \"k\"\n[[providers.x.models]]\nid = \"m\"\n",
1109 toml_str
1110 );
1111 let config: RobitConfig = toml::from_str(&toml_with_providers).unwrap();
1112 let qq = QqConfig::from_config(&config).unwrap();
1113 assert_eq!(qq.app_id, "123");
1114 assert_eq!(qq.app_secret, "s");
1115 }
1116
1117 #[test]
1118 fn from_config_errors_when_missing() {
1119 let toml_str = r#"
1120 [providers.x]
1121 base_url = "https://x"
1122 api_key = "k"
1123 [[providers.x.models]]
1124 id = "m"
1125 "#;
1126 let config: RobitConfig = toml::from_str(toml_str).unwrap();
1127 assert!(QqConfig::from_config(&config).is_err());
1128 }
1129
1130 #[test]
1131 fn gateway_and_api_urls() {
1132 let c = cfg();
1133 assert!(c.gateway_url().starts_with("wss://"));
1134 assert!(c.api_base_url().starts_with("https://"));
1135 }
1136}