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
This commit is contained in:
elonf 2026-02-17 10:22:23 +08:00 committed by Chummy
parent d94d7baa14
commit ed71bce447
5 changed files with 659 additions and 5 deletions

View file

@ -7,6 +7,7 @@ pub mod irc;
pub mod lark; pub mod lark;
pub mod matrix; pub mod matrix;
pub mod signal; pub mod signal;
pub mod qq;
pub mod slack; pub mod slack;
pub mod telegram; pub mod telegram;
pub mod traits; pub mod traits;
@ -21,6 +22,7 @@ pub use irc::IrcChannel;
pub use lark::LarkChannel; pub use lark::LarkChannel;
pub use matrix::MatrixChannel; pub use matrix::MatrixChannel;
pub use signal::SignalChannel; pub use signal::SignalChannel;
pub use qq::QQChannel;
pub use slack::SlackChannel; pub use slack::SlackChannel;
pub use telegram::TelegramChannel; pub use telegram::TelegramChannel;
pub use traits::Channel; 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()), ("IRC", config.channels_config.irc.is_some()),
("Lark", config.channels_config.lark.is_some()), ("Lark", config.channels_config.lark.is_some()),
("DingTalk", config.channels_config.dingtalk.is_some()), ("DingTalk", config.channels_config.dingtalk.is_some()),
("QQ", config.channels_config.qq.is_some()),
] { ] {
println!(" {} {name}", if configured { "" } else { "" }); 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() { if channels.is_empty() {
println!("No real-time channels configured. Run `zeroclaw onboard` first."); println!("No real-time channels configured. Run `zeroclaw onboard` first.");
return Ok(()); 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() { if channels.is_empty() {
println!("No channels configured. Run `zeroclaw onboard` to set up channels."); println!("No channels configured. Run `zeroclaw onboard` to set up channels.");
return Ok(()); return Ok(());

512
src/channels/qq.rs Normal file
View file

@ -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<String>,
client: reqwest::Client,
/// Cached access token + expiry timestamp.
token_cache: Arc<RwLock<Option<(String, u64)>>>,
/// Message deduplication set.
dedup: Arc<RwLock<HashSet<String>>>,
}
impl QQChannel {
pub fn new(app_id: String, app_secret: String, allowed_users: Vec<String>) -> 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::<u64>().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<String> {
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<String> {
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<String> = 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<ChannelMessage>) -> 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::<serde_json::Value>().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::<u64>().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"]);
}
}

View file

@ -1283,6 +1283,7 @@ pub struct ChannelsConfig {
pub irc: Option<IrcConfig>, pub irc: Option<IrcConfig>,
pub lark: Option<LarkConfig>, pub lark: Option<LarkConfig>,
pub dingtalk: Option<DingTalkConfig>, pub dingtalk: Option<DingTalkConfig>,
pub qq: Option<QQConfig>,
} }
impl Default for ChannelsConfig { impl Default for ChannelsConfig {
@ -1301,6 +1302,7 @@ impl Default for ChannelsConfig {
irc: None, irc: None,
lark: None, lark: None,
dingtalk: None, dingtalk: None,
qq: None,
} }
} }
} }
@ -1632,6 +1634,18 @@ pub struct DingTalkConfig {
pub allowed_users: Vec<String>, pub allowed_users: Vec<String>,
} }
/// 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<String>,
}
// ── Config impl ────────────────────────────────────────────────── // ── Config impl ──────────────────────────────────────────────────
impl Default for Config { impl Default for Config {
@ -2173,6 +2187,7 @@ default_temperature = 0.7
irc: None, irc: None,
lark: None, lark: None,
dingtalk: None, dingtalk: None,
qq: None,
}, },
memory: MemoryConfig::default(), memory: MemoryConfig::default(),
tunnel: TunnelConfig::default(), tunnel: TunnelConfig::default(),
@ -2587,6 +2602,7 @@ tool_dispatcher = "xml"
irc: None, irc: None,
lark: None, lark: None,
dingtalk: None, dingtalk: None,
qq: None,
}; };
let toml_str = toml::to_string_pretty(&c).unwrap(); let toml_str = toml::to_string_pretty(&c).unwrap();
let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap(); let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
@ -2748,6 +2764,7 @@ channel_id = "C123"
irc: None, irc: None,
lark: None, lark: None,
dingtalk: None, dingtalk: None,
qq: None,
}; };
let toml_str = toml::to_string_pretty(&c).unwrap(); let toml_str = toml::to_string_pretty(&c).unwrap();
let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap(); let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();

View file

@ -143,6 +143,18 @@ pub fn all_integrations() -> Vec<IntegrationEntry> {
} }
}, },
}, },
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 ─────────────────────────────────────────── // ── AI Models ───────────────────────────────────────────
IntegrationEntry { IntegrationEntry {
name: "OpenRouter", name: "OpenRouter",

View file

@ -1,4 +1,4 @@
use crate::config::schema::{DingTalkConfig, IrcConfig, WhatsAppConfig}; use crate::config::schema::{DingTalkConfig, IrcConfig, QQConfig, WhatsAppConfig};
use crate::config::{ use crate::config::{
AutonomyConfig, BrowserConfig, ChannelsConfig, ComposioConfig, Config, DiscordConfig, AutonomyConfig, BrowserConfig, ChannelsConfig, ComposioConfig, Config, DiscordConfig,
HeartbeatConfig, IMessageConfig, MatrixConfig, MemoryConfig, ObservabilityConfig, HeartbeatConfig, IMessageConfig, MatrixConfig, MemoryConfig, ObservabilityConfig,
@ -158,7 +158,8 @@ pub fn run_wizard() -> Result<Config> {
|| config.channels_config.imessage.is_some() || config.channels_config.imessage.is_some()
|| config.channels_config.matrix.is_some() || config.channels_config.matrix.is_some()
|| config.channels_config.email.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() { if has_channels && config.api_key.is_some() {
let launch: bool = Confirm::new() let launch: bool = Confirm::new()
@ -215,7 +216,8 @@ pub fn run_channels_repair_wizard() -> Result<Config> {
|| config.channels_config.imessage.is_some() || config.channels_config.imessage.is_some()
|| config.channels_config.matrix.is_some() || config.channels_config.matrix.is_some()
|| config.channels_config.email.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() { if has_channels && config.api_key.is_some() {
let launch: bool = Confirm::new() let launch: bool = Confirm::new()
@ -2427,6 +2429,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
irc: None, irc: None,
lark: None, lark: None,
dingtalk: None, dingtalk: None,
qq: None,
}; };
loop { loop {
@ -2503,13 +2506,21 @@ fn setup_channels() -> Result<ChannelsConfig> {
"— DingTalk Stream Mode" "— DingTalk Stream Mode"
} }
), ),
format!(
"QQ Official {}",
if config.qq.is_some() {
"✅ connected"
} else {
"— Tencent QQ Bot"
}
),
"Done — finish setup".to_string(), "Done — finish setup".to_string(),
]; ];
let choice = Select::new() let choice = Select::new()
.with_prompt(" Connect a channel (or Done to continue)") .with_prompt(" Connect a channel (or Done to continue)")
.items(&options) .items(&options)
.default(9) .default(10)
.interact()?; .interact()?;
match choice { match choice {
@ -3291,6 +3302,82 @@ fn setup_channels() -> Result<ChannelsConfig> {
allowed_users, 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<String> = 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 _ => break, // Done
} }
println!(); println!();
@ -3328,6 +3415,9 @@ fn setup_channels() -> Result<ChannelsConfig> {
if config.dingtalk.is_some() { if config.dingtalk.is_some() {
active.push("DingTalk"); active.push("DingTalk");
} }
if config.qq.is_some() {
active.push("QQ");
}
println!( println!(
" {} Channels: {}", " {} Channels: {}",
@ -3779,7 +3869,8 @@ fn print_summary(config: &Config) {
|| config.channels_config.imessage.is_some() || config.channels_config.imessage.is_some()
|| config.channels_config.matrix.is_some() || config.channels_config.matrix.is_some()
|| config.channels_config.email.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!();
println!( println!(