fix(qq): add sandbox mode and passive msg id fallback

This commit is contained in:
argenis de la rosa 2026-02-26 22:43:26 -05:00 committed by Argenis
parent bde9d45ead
commit 21e13c8ae5
8 changed files with 126 additions and 14 deletions

View File

@ -459,11 +459,13 @@ app_id = "qq-app-id"
app_secret = "qq-app-secret"
allowed_users = ["*"]
receive_mode = "webhook" # webhook (default) or websocket (legacy fallback)
environment = "production" # production (default) or sandbox
```
Notes:
- `webhook` mode is now the default and serves inbound callbacks at `POST /qq`.
- Set `environment = "sandbox"` to target `https://sandbox.api.sgroup.qq.com` for unpublished bot testing.
- QQ validation challenge payloads (`op = 13`) are auto-signed using `app_secret`.
- `X-Bot-Appid` is checked when present and must match `app_id`.
- Set `receive_mode = "websocket"` to keep the legacy gateway WS receive path.

View File

@ -4537,10 +4537,11 @@ fn collect_configured_channels(
} else {
channels.push(ConfiguredChannel {
display_name: "QQ",
channel: Arc::new(QQChannel::new(
channel: Arc::new(QQChannel::new_with_environment(
qq.app_id.clone(),
qq.app_secret.clone(),
qq.allowed_users.clone(),
qq.environment.clone(),
)),
});
}

View File

@ -1,4 +1,5 @@
use super::traits::{Channel, ChannelMessage, SendMessage};
use crate::config::schema::QQEnvironment;
use async_trait::async_trait;
use futures_util::{SinkExt, StreamExt};
use ring::signature::Ed25519KeyPair;
@ -11,6 +12,7 @@ use tokio_tungstenite::tungstenite::Message;
use uuid::Uuid;
const QQ_API_BASE: &str = "https://api.sgroup.qq.com";
const QQ_SANDBOX_API_BASE: &str = "https://sandbox.api.sgroup.qq.com";
const QQ_AUTH_URL: &str = "https://bots.qq.com/app/getAppAccessToken";
fn ensure_https(url: &str) -> anyhow::Result<()> {
@ -147,6 +149,14 @@ fn build_channel_message(
}
}
fn extract_message_id(payload: &serde_json::Value) -> &str {
payload
.get("id")
.and_then(Value::as_str)
.or_else(|| payload.get("msg_id").and_then(Value::as_str))
.unwrap_or("")
}
fn qq_seed_from_secret(secret: &str) -> Option<[u8; 32]> {
let bytes = secret.as_bytes();
if bytes.is_empty() {
@ -203,11 +213,11 @@ fn build_media_message_body(file_info: &str, msg_id: Option<&str>, msg_seq: u64)
Value::Object(body)
}
fn resolve_send_endpoints(recipient: &str) -> (String, String) {
fn resolve_send_endpoints(api_base: &str, recipient: &str) -> (String, String) {
if let Some(group_id) = recipient.strip_prefix("group:") {
(
format!("{QQ_API_BASE}/v2/groups/{group_id}/messages"),
format!("{QQ_API_BASE}/v2/groups/{group_id}/files"),
format!("{api_base}/v2/groups/{group_id}/messages"),
format!("{api_base}/v2/groups/{group_id}/files"),
)
} else {
let raw_uid = recipient.strip_prefix("user:").unwrap_or(recipient);
@ -216,8 +226,8 @@ fn resolve_send_endpoints(recipient: &str) -> (String, String) {
.filter(|c| c.is_alphanumeric() || *c == '_')
.collect();
(
format!("{QQ_API_BASE}/v2/users/{user_id}/messages"),
format!("{QQ_API_BASE}/v2/users/{user_id}/files"),
format!("{api_base}/v2/users/{user_id}/messages"),
format!("{api_base}/v2/users/{user_id}/files"),
)
}
}
@ -230,6 +240,7 @@ const DEDUP_CAPACITY: usize = 10_000;
pub struct QQChannel {
app_id: String,
app_secret: String,
environment: QQEnvironment,
allowed_users: Vec<String>,
/// Cached access token + expiry timestamp.
token_cache: Arc<RwLock<Option<(String, u64)>>>,
@ -239,9 +250,19 @@ pub struct QQChannel {
impl QQChannel {
pub fn new(app_id: String, app_secret: String, allowed_users: Vec<String>) -> Self {
Self::new_with_environment(app_id, app_secret, allowed_users, QQEnvironment::Production)
}
pub fn new_with_environment(
app_id: String,
app_secret: String,
allowed_users: Vec<String>,
environment: QQEnvironment,
) -> Self {
Self {
app_id,
app_secret,
environment,
allowed_users,
token_cache: Arc::new(RwLock::new(None)),
dedup: Arc::new(RwLock::new(HashSet::new())),
@ -256,6 +277,13 @@ impl QQChannel {
&self.app_id
}
fn api_base(&self) -> &'static str {
match self.environment {
QQEnvironment::Production => QQ_API_BASE,
QQEnvironment::Sandbox => QQ_SANDBOX_API_BASE,
}
}
fn is_user_allowed(&self, user_id: &str) -> bool {
self.allowed_users.iter().any(|u| u == "*" || u == user_id)
}
@ -267,7 +295,7 @@ impl QQChannel {
) -> Option<ChannelMessage> {
match event_type {
"C2C_MESSAGE_CREATE" => {
let msg_id = payload.get("id").and_then(Value::as_str).unwrap_or("");
let msg_id = extract_message_id(payload);
if self.is_duplicate(msg_id).await {
return None;
}
@ -295,7 +323,7 @@ impl QQChannel {
Some(build_channel_message(user_openid, chat_id, content, msg_id))
}
"GROUP_AT_MESSAGE_CREATE" => {
let msg_id = payload.get("id").and_then(Value::as_str).unwrap_or("");
let msg_id = extract_message_id(payload);
if self.is_duplicate(msg_id).await {
return None;
}
@ -316,6 +344,7 @@ impl QQChannel {
let group_openid = payload
.get("group_openid")
.and_then(Value::as_str)
.or_else(|| payload.get("group_id").and_then(Value::as_str))
.unwrap_or("unknown");
let chat_id = format!("group:{group_openid}");
Some(build_channel_message(author_id, chat_id, content, msg_id))
@ -524,7 +553,7 @@ impl QQChannel {
async fn get_gateway_url(&self, token: &str) -> anyhow::Result<String> {
let resp = self
.http_client()
.get(format!("{QQ_API_BASE}/gateway"))
.get(format!("{}/gateway", self.api_base()))
.header("Authorization", format!("QQBot {token}"))
.send()
.await?;
@ -579,7 +608,7 @@ impl Channel for QQChannel {
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
let token = self.get_token().await?;
let (message_url, files_url) = resolve_send_endpoints(&message.recipient);
let (message_url, files_url) = resolve_send_endpoints(self.api_base(), &message.recipient);
let passive_msg_id = message
.thread_ts
@ -824,6 +853,34 @@ allowed_users = ["user1"]
config.receive_mode,
crate::config::schema::QQReceiveMode::Webhook
);
assert_eq!(
config.environment,
crate::config::schema::QQEnvironment::Production
);
}
#[test]
fn test_resolve_send_endpoints_respects_selected_api_base() {
let (group_messages, group_files) =
resolve_send_endpoints(QQ_SANDBOX_API_BASE, "group:12345");
assert_eq!(
group_messages,
"https://sandbox.api.sgroup.qq.com/v2/groups/12345/messages"
);
assert_eq!(
group_files,
"https://sandbox.api.sgroup.qq.com/v2/groups/12345/files"
);
let (user_messages, user_files) = resolve_send_endpoints(QQ_API_BASE, "user:abc_123");
assert_eq!(
user_messages,
"https://api.sgroup.qq.com/v2/users/abc_123/messages"
);
assert_eq!(
user_files,
"https://api.sgroup.qq.com/v2/users/abc_123/files"
);
}
#[test]
@ -897,6 +954,27 @@ allowed_users = ["user1"]
assert!(second.is_empty());
}
#[tokio::test]
async fn test_parse_webhook_payload_supports_msg_id_fallback_for_passive_reply() {
let ch = QQChannel::new("id".into(), "secret".into(), vec!["user_open_1".into()]);
let payload = json!({
"op": 0,
"t": "C2C_MESSAGE_CREATE",
"d": {
"msg_id": "msg-fallback-1",
"content": "hello webhook",
"author": {
"id": "author-1",
"user_openid": "user_open_1"
}
}
});
let messages = ch.parse_webhook_payload(&payload).await;
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].thread_ts.as_deref(), Some("msg-fallback-1"));
}
#[test]
fn test_compose_message_content_text_only() {
let payload = json!({

View File

@ -4879,6 +4879,15 @@ pub enum QQReceiveMode {
Webhook,
}
/// QQ API environment.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum QQEnvironment {
#[default]
Production,
Sandbox,
}
/// QQ Official Bot configuration (Tencent QQ Bot SDK)
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct QQConfig {
@ -4892,6 +4901,9 @@ pub struct QQConfig {
/// Event receive mode: "webhook" (default) or "websocket".
#[serde(default)]
pub receive_mode: QQReceiveMode,
/// API environment: "production" (default) or "sandbox".
#[serde(default)]
pub environment: QQEnvironment,
}
impl ChannelConfig for QQConfig {
@ -10382,6 +10394,7 @@ default_model = "legacy-model"
let json = r#"{"app_id":"123","app_secret":"secret"}"#;
let parsed: QQConfig = serde_json::from_str(json).unwrap();
assert_eq!(parsed.receive_mode, QQReceiveMode::Webhook);
assert_eq!(parsed.environment, QQEnvironment::Production);
assert!(parsed.allowed_users.is_empty());
}
@ -10392,10 +10405,12 @@ default_model = "legacy-model"
app_secret: "secret".into(),
allowed_users: vec!["*".into()],
receive_mode: QQReceiveMode::Websocket,
environment: QQEnvironment::Sandbox,
};
let toml_str = toml::to_string(&qc).unwrap();
let parsed: QQConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.receive_mode, QQReceiveMode::Websocket);
assert_eq!(parsed.environment, QQEnvironment::Sandbox);
assert_eq!(parsed.allowed_users, vec!["*"]);
}

View File

@ -374,10 +374,11 @@ pub(crate) async fn deliver_announcement(
.qq
.as_ref()
.ok_or_else(|| anyhow::anyhow!("qq channel not configured"))?;
let channel = QQChannel::new(
let channel = QQChannel::new_with_environment(
qq.app_id.clone(),
qq.app_secret.clone(),
qq.allowed_users.clone(),
qq.environment.clone(),
);
channel.send(&SendMessage::new(output, target)).await?;
}

View File

@ -503,6 +503,7 @@ mod tests {
app_secret: "app-secret".into(),
allowed_users: vec!["*".into()],
receive_mode: crate::config::schema::QQReceiveMode::Websocket,
environment: crate::config::schema::QQEnvironment::Production,
});
assert!(has_supervised_channels(&config));
}

View File

@ -516,10 +516,11 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
// QQ channel (if configured)
let qq_channel: Option<Arc<QQChannel>> = config.channels_config.qq.as_ref().map(|qq_cfg| {
Arc::new(QQChannel::new(
Arc::new(QQChannel::new_with_environment(
qq_cfg.app_id.clone(),
qq_cfg.app_secret.clone(),
qq_cfg.allowed_users.clone(),
qq_cfg.environment.clone(),
))
});
let qq_webhook_enabled = config

View File

@ -1,7 +1,7 @@
use crate::config::schema::{
default_nostr_relays, DingTalkConfig, IrcConfig, LarkReceiveMode, LinqConfig,
NextcloudTalkConfig, NostrConfig, QQConfig, QQReceiveMode, SignalConfig, StreamMode,
WhatsAppConfig,
NextcloudTalkConfig, NostrConfig, QQConfig, QQEnvironment, QQReceiveMode, SignalConfig,
StreamMode, WhatsAppConfig,
};
use crate::config::{
AutonomyConfig, BrowserConfig, ChannelsConfig, ComposioConfig, Config, DiscordConfig,
@ -5104,11 +5104,23 @@ fn setup_channels() -> Result<ChannelsConfig> {
QQReceiveMode::Websocket
};
let environment_choice = Select::new()
.with_prompt(" API environment")
.items(["Production", "Sandbox (for unpublished bot testing)"])
.default(0)
.interact()?;
let environment = if environment_choice == 0 {
QQEnvironment::Production
} else {
QQEnvironment::Sandbox
};
config.qq = Some(QQConfig {
app_id,
app_secret,
allowed_users,
receive_mode,
environment,
});
}
ChannelMenuChoice::LarkFeishu => {
@ -7978,6 +7990,7 @@ mod tests {
app_secret: "app-secret".into(),
allowed_users: vec!["*".into()],
receive_mode: crate::config::schema::QQReceiveMode::Websocket,
environment: crate::config::schema::QQEnvironment::Production,
});
assert!(has_launchable_channels(&channels));