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