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) -> 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 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 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 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 *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 async fn fetch_access_token(&self) -> Result<String> {
213 {
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 async fn auth_header(&self) -> Result<String> {
261 let token = self.fetch_access_token().await?;
262 Ok(format!("QQBot {}", token))
263 }
264
265 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 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; Ok(SendResult { msg_id: id })
339 }
340
341 async fn edit_message(&self, _chat_id: &str, _msg_id: &str, _text: &str) -> Result<()> {
342 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 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 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
500const INTENT_C2C: u32 = crate::protocol::INTENT_C2C;
502const INTENT_GROUP_AT_MESSAGE: u32 = crate::protocol::INTENT_GROUP_AT_MESSAGE;
503
504fn 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
527fn 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
550fn 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
585fn 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 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
671fn 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
684fn 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 let mut text = ev.content.trim().to_string();
695
696 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 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"); 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 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 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}