fix(qq): add sandbox mode and passive msg id fallback
This commit is contained in:
parent
bde9d45ead
commit
21e13c8ae5
@ -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.
|
||||
|
||||
@ -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(),
|
||||
)),
|
||||
});
|
||||
}
|
||||
|
||||
@ -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!({
|
||||
|
||||
@ -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!["*"]);
|
||||
}
|
||||
|
||||
|
||||
@ -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?;
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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));
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user