From ed71bce447f39485c0c4d227a235ab5f4a8f9586 Mon Sep 17 00:00:00 2001 From: elonf Date: Tue, 17 Feb 2026 10:22:23 +0800 Subject: [PATCH] feat(channels): add QQ Official channel via Tencent Bot SDK Implement QQ Official messaging channel using OAuth2 authentication with Discord-like WebSocket gateway protocol for events. - Add QQChannel with send/listen/health_check support - Add QQConfig (app_id, app_secret, allowed_users) - OAuth2 token refresh and WebSocket heartbeat management - Message deduplication with capacity-based eviction - Support both C2C (private) and group AT messages - Integrate with onboard wizard, integrations registry, and channel list/doctor commands - Include unit tests for user allowlist, deduplication, and config --- src/channels/mod.rs | 22 ++ src/channels/qq.rs | 512 +++++++++++++++++++++++++++++++++++ src/config/schema.rs | 17 ++ src/integrations/registry.rs | 12 + src/onboard/wizard.rs | 101 ++++++- 5 files changed, 659 insertions(+), 5 deletions(-) create mode 100644 src/channels/qq.rs diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 7a291e5..651bc47 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -7,6 +7,7 @@ pub mod irc; pub mod lark; pub mod matrix; pub mod signal; +pub mod qq; pub mod slack; pub mod telegram; pub mod traits; @@ -21,6 +22,7 @@ pub use irc::IrcChannel; pub use lark::LarkChannel; pub use matrix::MatrixChannel; pub use signal::SignalChannel; +pub use qq::QQChannel; pub use slack::SlackChannel; pub use telegram::TelegramChannel; pub use traits::Channel; @@ -719,6 +721,7 @@ pub fn handle_command(command: crate::ChannelCommands, config: &Config) -> Resul ("IRC", config.channels_config.irc.is_some()), ("Lark", config.channels_config.lark.is_some()), ("DingTalk", config.channels_config.dingtalk.is_some()), + ("QQ", config.channels_config.qq.is_some()), ] { println!(" {} {name}", if configured { "✅" } else { "❌" }); } @@ -881,6 +884,17 @@ pub async fn doctor_channels(config: Config) -> Result<()> { )); } + if let Some(ref qq) = config.channels_config.qq { + channels.push(( + "QQ", + Arc::new(QQChannel::new( + qq.app_id.clone(), + qq.app_secret.clone(), + qq.allowed_users.clone(), + )), + )); + } + if channels.is_empty() { println!("No real-time channels configured. Run `zeroclaw onboard` first."); return Ok(()); @@ -1160,6 +1174,14 @@ pub async fn start_channels(config: Config) -> Result<()> { ))); } + if let Some(ref qq) = config.channels_config.qq { + channels.push(Arc::new(QQChannel::new( + qq.app_id.clone(), + qq.app_secret.clone(), + qq.allowed_users.clone(), + ))); + } + if channels.is_empty() { println!("No channels configured. Run `zeroclaw onboard` to set up channels."); return Ok(()); diff --git a/src/channels/qq.rs b/src/channels/qq.rs new file mode 100644 index 0000000..78012c6 --- /dev/null +++ b/src/channels/qq.rs @@ -0,0 +1,512 @@ +use super::traits::{Channel, ChannelMessage}; +use async_trait::async_trait; +use futures_util::{SinkExt, StreamExt}; +use serde_json::json; +use std::collections::HashSet; +use std::sync::Arc; +use tokio::sync::RwLock; +use tokio_tungstenite::tungstenite::Message; +use uuid::Uuid; + +const QQ_API_BASE: &str = "https://api.sgroup.qq.com"; +const QQ_AUTH_URL: &str = "https://bots.qq.com/app/getAppAccessToken"; + +/// Deduplication set capacity — evict oldest half when full. +const DEDUP_CAPACITY: usize = 10_000; + +/// QQ Official Bot channel — uses Tencent's official QQ Bot API with +/// OAuth2 authentication and a Discord-like WebSocket gateway protocol. +pub struct QQChannel { + app_id: String, + app_secret: String, + allowed_users: Vec, + client: reqwest::Client, + /// Cached access token + expiry timestamp. + token_cache: Arc>>, + /// Message deduplication set. + dedup: Arc>>, +} + +impl QQChannel { + pub fn new(app_id: String, app_secret: String, allowed_users: Vec) -> Self { + Self { + app_id, + app_secret, + allowed_users, + client: reqwest::Client::new(), + token_cache: Arc::new(RwLock::new(None)), + dedup: Arc::new(RwLock::new(HashSet::new())), + } + } + + fn is_user_allowed(&self, user_id: &str) -> bool { + self.allowed_users.iter().any(|u| u == "*" || u == user_id) + } + + /// Fetch an access token from QQ's OAuth2 endpoint. + async fn fetch_access_token(&self) -> anyhow::Result<(String, u64)> { + let body = json!({ + "appId": self.app_id, + "clientSecret": self.app_secret, + }); + + let resp = self.client.post(QQ_AUTH_URL).json(&body).send().await?; + + if !resp.status().is_success() { + let status = resp.status(); + let err = resp.text().await.unwrap_or_default(); + anyhow::bail!("QQ token request failed ({status}): {err}"); + } + + let data: serde_json::Value = resp.json().await?; + let token = data + .get("access_token") + .and_then(|t| t.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing access_token in QQ response"))? + .to_string(); + + let expires_in = data + .get("expires_in") + .and_then(|e| e.as_str()) + .and_then(|e| e.parse::().ok()) + .unwrap_or(7200); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + // Expire 60 seconds early to avoid edge cases + let expiry = now + expires_in.saturating_sub(60); + + Ok((token, expiry)) + } + + /// Get a valid access token, refreshing if expired. + async fn get_token(&self) -> anyhow::Result { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + { + let cache = self.token_cache.read().await; + if let Some((ref token, expiry)) = *cache { + if now < expiry { + return Ok(token.clone()); + } + } + } + + let (token, expiry) = self.fetch_access_token().await?; + { + let mut cache = self.token_cache.write().await; + *cache = Some((token.clone(), expiry)); + } + Ok(token) + } + + /// Get the WebSocket gateway URL. + async fn get_gateway_url(&self, token: &str) -> anyhow::Result { + let resp = self + .client + .get(format!("{QQ_API_BASE}/gateway")) + .header("Authorization", format!("QQBot {token}")) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let err = resp.text().await.unwrap_or_default(); + anyhow::bail!("QQ gateway request failed ({status}): {err}"); + } + + let data: serde_json::Value = resp.json().await?; + let url = data + .get("url") + .and_then(|u| u.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing gateway URL in QQ response"))? + .to_string(); + + Ok(url) + } + + /// Check and insert message ID for deduplication. + async fn is_duplicate(&self, msg_id: &str) -> bool { + if msg_id.is_empty() { + return false; + } + + let mut dedup = self.dedup.write().await; + + if dedup.contains(msg_id) { + return true; + } + + // Evict oldest half when at capacity + if dedup.len() >= DEDUP_CAPACITY { + let to_remove: Vec = dedup.iter().take(DEDUP_CAPACITY / 2).cloned().collect(); + for key in to_remove { + dedup.remove(&key); + } + } + + dedup.insert(msg_id.to_string()); + false + } +} + +#[async_trait] +impl Channel for QQChannel { + fn name(&self) -> &str { + "qq" + } + + async fn send(&self, message: &str, recipient: &str) -> anyhow::Result<()> { + let token = self.get_token().await?; + + // Determine if this is a group or private message based on recipient format + // Format: "user:{openid}" or "group:{group_openid}" + let (url, body) = if let Some(group_id) = recipient.strip_prefix("group:") { + ( + format!("{QQ_API_BASE}/v2/groups/{group_id}/messages"), + json!({ + "content": message, + "msg_type": 0, + }), + ) + } else { + let user_id = recipient.strip_prefix("user:").unwrap_or(recipient); + ( + format!("{QQ_API_BASE}/v2/users/{user_id}/messages"), + json!({ + "content": message, + "msg_type": 0, + }), + ) + }; + + let resp = self + .client + .post(&url) + .header("Authorization", format!("QQBot {token}")) + .json(&body) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let err = resp.text().await.unwrap_or_default(); + anyhow::bail!("QQ send message failed ({status}): {err}"); + } + + Ok(()) + } + + #[allow(clippy::too_many_lines)] + async fn listen(&self, tx: tokio::sync::mpsc::Sender) -> anyhow::Result<()> { + tracing::info!("QQ: authenticating..."); + let token = self.get_token().await?; + + tracing::info!("QQ: fetching gateway URL..."); + let gw_url = self.get_gateway_url(&token).await?; + + tracing::info!("QQ: connecting to gateway WebSocket..."); + let (ws_stream, _) = tokio_tungstenite::connect_async(&gw_url).await?; + let (mut write, mut read) = ws_stream.split(); + + // Read Hello (opcode 10) + let hello = read + .next() + .await + .ok_or(anyhow::anyhow!("QQ: no hello frame"))??; + let hello_data: serde_json::Value = serde_json::from_str(&hello.to_string())?; + let heartbeat_interval = hello_data + .get("d") + .and_then(|d| d.get("heartbeat_interval")) + .and_then(serde_json::Value::as_u64) + .unwrap_or(41250); + + // Send Identify (opcode 2) + // Intents: PUBLIC_GUILD_MESSAGES (1<<30) | C2C_MESSAGE_CREATE & GROUP_AT_MESSAGE_CREATE (1<<25) + let intents: u64 = (1 << 25) | (1 << 30); + let identify = json!({ + "op": 2, + "d": { + "token": format!("QQBot {token}"), + "intents": intents, + "properties": { + "os": "linux", + "browser": "zeroclaw", + "device": "zeroclaw", + } + } + }); + write.send(Message::Text(identify.to_string())).await?; + + tracing::info!("QQ: connected and identified"); + + let mut sequence: i64 = -1; + + // Spawn heartbeat timer + let (hb_tx, mut hb_rx) = tokio::sync::mpsc::channel::<()>(1); + let hb_interval = heartbeat_interval; + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_millis(hb_interval)); + loop { + interval.tick().await; + if hb_tx.send(()).await.is_err() { + break; + } + } + }); + + // Spawn token refresh task + let token_cache = Arc::clone(&self.token_cache); + let app_id = self.app_id.clone(); + let app_secret = self.app_secret.clone(); + let client = self.client.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(6000)); // ~100 min + loop { + interval.tick().await; + let body = json!({ + "appId": app_id, + "clientSecret": app_secret, + }); + if let Ok(resp) = client.post(QQ_AUTH_URL).json(&body).send().await { + if let Ok(data) = resp.json::().await { + if let Some(new_token) = data.get("access_token").and_then(|t| t.as_str()) { + let expires_in = data + .get("expires_in") + .and_then(|e| e.as_str()) + .and_then(|e| e.parse::().ok()) + .unwrap_or(7200); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let mut cache = token_cache.write().await; + *cache = + Some((new_token.to_string(), now + expires_in.saturating_sub(60))); + tracing::debug!("QQ: token refreshed"); + } + } + } + } + }); + + loop { + tokio::select! { + _ = hb_rx.recv() => { + let d = if sequence >= 0 { json!(sequence) } else { json!(null) }; + let hb = json!({"op": 1, "d": d}); + if write.send(Message::Text(hb.to_string())).await.is_err() { + break; + } + } + msg = read.next() => { + let msg = match msg { + Some(Ok(Message::Text(t))) => t, + Some(Ok(Message::Close(_))) | None => break, + _ => continue, + }; + + let event: serde_json::Value = match serde_json::from_str(&msg) { + Ok(e) => e, + Err(_) => continue, + }; + + // Track sequence number + if let Some(s) = event.get("s").and_then(serde_json::Value::as_i64) { + sequence = s; + } + + let op = event.get("op").and_then(serde_json::Value::as_u64).unwrap_or(0); + + match op { + // Server requests immediate heartbeat + 1 => { + let d = if sequence >= 0 { json!(sequence) } else { json!(null) }; + let hb = json!({"op": 1, "d": d}); + if write.send(Message::Text(hb.to_string())).await.is_err() { + break; + } + continue; + } + // Reconnect + 7 => { + tracing::warn!("QQ: received Reconnect (op 7)"); + break; + } + // Invalid Session + 9 => { + tracing::warn!("QQ: received Invalid Session (op 9)"); + break; + } + _ => {} + } + + // Only process dispatch events (op 0) + if op != 0 { + continue; + } + + let event_type = event.get("t").and_then(|t| t.as_str()).unwrap_or(""); + let d = match event.get("d") { + Some(d) => d, + None => continue, + }; + + match event_type { + "C2C_MESSAGE_CREATE" => { + let msg_id = d.get("id").and_then(|i| i.as_str()).unwrap_or(""); + if self.is_duplicate(msg_id).await { + continue; + } + + let content = d.get("content").and_then(|c| c.as_str()).unwrap_or("").trim(); + if content.is_empty() { + continue; + } + + let author_id = d.get("author").and_then(|a| a.get("id")).and_then(|i| i.as_str()).unwrap_or("unknown"); + // For QQ, user_openid is the identifier + let user_openid = d.get("author").and_then(|a| a.get("user_openid")).and_then(|u| u.as_str()).unwrap_or(author_id); + + if !self.is_user_allowed(user_openid) { + tracing::warn!("QQ: ignoring C2C message from unauthorized user: {user_openid}"); + continue; + } + + let chat_id = format!("user:{user_openid}"); + + let channel_msg = ChannelMessage { + id: Uuid::new_v4().to_string(), + sender: user_openid.to_string(), + content: content.to_string(), + channel: "qq".to_string(), + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + }; + + // Override the channel message chat_id via sender field + let mut msg = channel_msg; + msg.sender = chat_id; + + if tx.send(msg).await.is_err() { + tracing::warn!("QQ: message channel closed"); + break; + } + } + "GROUP_AT_MESSAGE_CREATE" => { + let msg_id = d.get("id").and_then(|i| i.as_str()).unwrap_or(""); + if self.is_duplicate(msg_id).await { + continue; + } + + let content = d.get("content").and_then(|c| c.as_str()).unwrap_or("").trim(); + if content.is_empty() { + continue; + } + + let author_id = d.get("author").and_then(|a| a.get("member_openid")).and_then(|m| m.as_str()).unwrap_or("unknown"); + + if !self.is_user_allowed(author_id) { + tracing::warn!("QQ: ignoring group message from unauthorized user: {author_id}"); + continue; + } + + let group_openid = d.get("group_openid").and_then(|g| g.as_str()).unwrap_or("unknown"); + let chat_id = format!("group:{group_openid}"); + + let channel_msg = ChannelMessage { + id: Uuid::new_v4().to_string(), + sender: chat_id, + content: content.to_string(), + channel: "qq".to_string(), + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + }; + + if tx.send(channel_msg).await.is_err() { + tracing::warn!("QQ: message channel closed"); + break; + } + } + _ => {} + } + } + } + } + + anyhow::bail!("QQ WebSocket connection closed") + } + + async fn health_check(&self) -> bool { + self.fetch_access_token().await.is_ok() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_name() { + let ch = QQChannel::new("id".into(), "secret".into(), vec![]); + assert_eq!(ch.name(), "qq"); + } + + #[test] + fn test_user_allowed_wildcard() { + let ch = QQChannel::new("id".into(), "secret".into(), vec!["*".into()]); + assert!(ch.is_user_allowed("anyone")); + } + + #[test] + fn test_user_allowed_specific() { + let ch = QQChannel::new("id".into(), "secret".into(), vec!["user123".into()]); + assert!(ch.is_user_allowed("user123")); + assert!(!ch.is_user_allowed("other")); + } + + #[test] + fn test_user_denied_empty() { + let ch = QQChannel::new("id".into(), "secret".into(), vec![]); + assert!(!ch.is_user_allowed("anyone")); + } + + #[tokio::test] + async fn test_dedup() { + let ch = QQChannel::new("id".into(), "secret".into(), vec![]); + assert!(!ch.is_duplicate("msg1").await); + assert!(ch.is_duplicate("msg1").await); + assert!(!ch.is_duplicate("msg2").await); + } + + #[tokio::test] + async fn test_dedup_empty_id() { + let ch = QQChannel::new("id".into(), "secret".into(), vec![]); + // Empty IDs should never be considered duplicates + assert!(!ch.is_duplicate("").await); + assert!(!ch.is_duplicate("").await); + } + + #[test] + fn test_config_serde() { + let toml_str = r#" +app_id = "12345" +app_secret = "secret_abc" +allowed_users = ["user1"] +"#; + let config: crate::config::schema::QQConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.app_id, "12345"); + assert_eq!(config.app_secret, "secret_abc"); + assert_eq!(config.allowed_users, vec!["user1"]); + } +} diff --git a/src/config/schema.rs b/src/config/schema.rs index c90573c..2c2af1b 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -1283,6 +1283,7 @@ pub struct ChannelsConfig { pub irc: Option, pub lark: Option, pub dingtalk: Option, + pub qq: Option, } impl Default for ChannelsConfig { @@ -1301,6 +1302,7 @@ impl Default for ChannelsConfig { irc: None, lark: None, dingtalk: None, + qq: None, } } } @@ -1632,6 +1634,18 @@ pub struct DingTalkConfig { pub allowed_users: Vec, } +/// QQ Official Bot configuration (Tencent QQ Bot SDK) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QQConfig { + /// App ID from QQ Bot developer console + pub app_id: String, + /// App Secret from QQ Bot developer console + pub app_secret: String, + /// Allowed user IDs. Empty = deny all, "*" = allow all + #[serde(default)] + pub allowed_users: Vec, +} + // ── Config impl ────────────────────────────────────────────────── impl Default for Config { @@ -2173,6 +2187,7 @@ default_temperature = 0.7 irc: None, lark: None, dingtalk: None, + qq: None, }, memory: MemoryConfig::default(), tunnel: TunnelConfig::default(), @@ -2587,6 +2602,7 @@ tool_dispatcher = "xml" irc: None, lark: None, dingtalk: None, + qq: None, }; let toml_str = toml::to_string_pretty(&c).unwrap(); let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap(); @@ -2748,6 +2764,7 @@ channel_id = "C123" irc: None, lark: None, dingtalk: None, + qq: None, }; let toml_str = toml::to_string_pretty(&c).unwrap(); let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap(); diff --git a/src/integrations/registry.rs b/src/integrations/registry.rs index 3933950..ac1ee7b 100644 --- a/src/integrations/registry.rs +++ b/src/integrations/registry.rs @@ -143,6 +143,18 @@ pub fn all_integrations() -> Vec { } }, }, + IntegrationEntry { + name: "QQ Official", + description: "Tencent QQ Bot SDK", + category: IntegrationCategory::Chat, + status_fn: |c| { + if c.channels_config.qq.is_some() { + IntegrationStatus::Active + } else { + IntegrationStatus::Available + } + }, + }, // ── AI Models ─────────────────────────────────────────── IntegrationEntry { name: "OpenRouter", diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index b9ed634..c28f00d 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -1,4 +1,4 @@ -use crate::config::schema::{DingTalkConfig, IrcConfig, WhatsAppConfig}; +use crate::config::schema::{DingTalkConfig, IrcConfig, QQConfig, WhatsAppConfig}; use crate::config::{ AutonomyConfig, BrowserConfig, ChannelsConfig, ComposioConfig, Config, DiscordConfig, HeartbeatConfig, IMessageConfig, MatrixConfig, MemoryConfig, ObservabilityConfig, @@ -158,7 +158,8 @@ pub fn run_wizard() -> Result { || config.channels_config.imessage.is_some() || config.channels_config.matrix.is_some() || config.channels_config.email.is_some() - || config.channels_config.dingtalk.is_some(); + || config.channels_config.dingtalk.is_some() + || config.channels_config.qq.is_some(); if has_channels && config.api_key.is_some() { let launch: bool = Confirm::new() @@ -215,7 +216,8 @@ pub fn run_channels_repair_wizard() -> Result { || config.channels_config.imessage.is_some() || config.channels_config.matrix.is_some() || config.channels_config.email.is_some() - || config.channels_config.dingtalk.is_some(); + || config.channels_config.dingtalk.is_some() + || config.channels_config.qq.is_some(); if has_channels && config.api_key.is_some() { let launch: bool = Confirm::new() @@ -2427,6 +2429,7 @@ fn setup_channels() -> Result { irc: None, lark: None, dingtalk: None, + qq: None, }; loop { @@ -2503,13 +2506,21 @@ fn setup_channels() -> Result { "— DingTalk Stream Mode" } ), + format!( + "QQ Official {}", + if config.qq.is_some() { + "✅ connected" + } else { + "— Tencent QQ Bot" + } + ), "Done — finish setup".to_string(), ]; let choice = Select::new() .with_prompt(" Connect a channel (or Done to continue)") .items(&options) - .default(9) + .default(10) .interact()?; match choice { @@ -3291,6 +3302,82 @@ fn setup_channels() -> Result { allowed_users, }); } + 9 => { + // ── QQ Official ── + println!(); + println!( + " {} {}", + style("QQ Official Setup").white().bold(), + style("— Tencent QQ Bot SDK").dim() + ); + print_bullet("1. Go to QQ Bot developer console (q.qq.com)"); + print_bullet("2. Create a bot application"); + print_bullet("3. Copy the App ID and App Secret"); + println!(); + + let app_id: String = Input::new().with_prompt(" App ID").interact_text()?; + + if app_id.trim().is_empty() { + println!(" {} Skipped", style("→").dim()); + continue; + } + + let app_secret: String = + Input::new().with_prompt(" App Secret").interact_text()?; + + // Test connection + print!(" {} Testing connection... ", style("⏳").dim()); + let client = reqwest::blocking::Client::new(); + let body = serde_json::json!({ + "appId": app_id, + "clientSecret": app_secret, + }); + match client + .post("https://bots.qq.com/app/getAppAccessToken") + .json(&body) + .send() + { + Ok(resp) if resp.status().is_success() => { + let data: serde_json::Value = resp.json().unwrap_or_default(); + if data.get("access_token").is_some() { + println!( + "\r {} QQ Bot credentials verified ", + style("✅").green().bold() + ); + } else { + println!( + "\r {} Auth error — check your credentials", + style("❌").red().bold() + ); + continue; + } + } + _ => { + println!( + "\r {} Connection failed — check your credentials", + style("❌").red().bold() + ); + continue; + } + } + + let users_str: String = Input::new() + .with_prompt(" Allowed user IDs (comma-separated, '*' for all)") + .allow_empty(true) + .interact_text()?; + + let allowed_users: Vec = users_str + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + + config.qq = Some(QQConfig { + app_id, + app_secret, + allowed_users, + }); + } _ => break, // Done } println!(); @@ -3328,6 +3415,9 @@ fn setup_channels() -> Result { if config.dingtalk.is_some() { active.push("DingTalk"); } + if config.qq.is_some() { + active.push("QQ"); + } println!( " {} Channels: {}", @@ -3779,7 +3869,8 @@ fn print_summary(config: &Config) { || config.channels_config.imessage.is_some() || config.channels_config.matrix.is_some() || config.channels_config.email.is_some() - || config.channels_config.dingtalk.is_some(); + || config.channels_config.dingtalk.is_some() + || config.channels_config.qq.is_some(); println!(); println!(