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 bot_token: String,
45 pub sandbox: bool,
46}
47
48impl QqConfig {
49 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 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 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 pub fn access_token_url(&self) -> &str {
86 "https://bots.qq.com/app/getAppAccessToken"
87 }
88}
89
90struct CachedToken {
92 token: String,
93 expires_at: Instant,
95}
96
97pub struct QqPlatformAdapter {
99 config: QqConfig,
100 http: reqwest::Client,
102 access_token: RwLock<Option<CachedToken>>,
104 last_seq: Mutex<Option<u64>>,
106 session_id: Mutex<Option<String>>,
108 event_tx: mpsc::Sender<PlatformEvent>,
110 event_rx: Mutex<mpsc::Receiver<PlatformEvent>>,
111 ws_tx: Mutex<Option<futures_util::stream::SplitSink<WebSocket, Message>>>,
115 #[allow(dead_code)]
117 caps: PlatformCaps,
118 heartbeat_interval: RwLock<Duration>,
120 last_inbound_msg_id: Mutex<Option<(String, String)>>, msg_seq: Mutex<u32>,
125}
126
127type WebSocket =
128 tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
129
130impl QqPlatformAdapter {
131 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 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 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 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 *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 async fn fetch_access_token(&self) -> Result<String> {
217 {
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 async fn auth_header(&self) -> Result<String> {
265 let token = self.fetch_access_token().await?;
266 Ok(format!("QQBot {}", token))
267 }
268
269 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 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; Ok(SendResult { msg_id: id })
343 }
344
345 async fn edit_message(&self, _chat_id: &str, _msg_id: &str, _text: &str) -> Result<()> {
346 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 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 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
504const INTENT_C2C: u32 = crate::protocol::INTENT_C2C;
506const INTENT_GROUP_AT_MESSAGE: u32 = crate::protocol::INTENT_GROUP_AT_MESSAGE;
507
508fn 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
531fn 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
554fn 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
589fn 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 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
675fn 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
688fn 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 let mut text = ev.content.trim().to_string();
699
700 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 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"); 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 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 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}