fix: critical bugs in group chat, dashboard race, and QQBot save

- Hermes group chat: restore active profile after multi-profile send
- Hermes group chat: ignore hermes-run-* events until run_id is known
- Dashboard: skip self-heal write when loadSeq is superseded
- QQBot: merge account entries and preserve SecretRef/custom fields

Co-authored-by: 晴天 <1186258278@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-06-04 11:10:59 +00:00
parent 38934fe754
commit 187a3fce5b
3 changed files with 132 additions and 26 deletions

View File

@@ -1556,12 +1556,18 @@ pub async fn read_platform_config(
return Ok(json!({ "exists": false }));
}
// 写入表单字段(前端 UI 用 clientSecret
if let Some(v) = app_id_val {
form.insert("appId".into(), Value::String(v.into()));
// 写入表单字段(前端 UI 用 clientSecretSecretRef 显示占位并保留原始对象
insert_secret_aware_form_value(&mut form, qqbot_val, "appId");
insert_secret_aware_form_value(&mut form, qqbot_val, "clientSecret");
if !form.contains_key("appId") {
if let Some(v) = app_id_val {
form.insert("appId".into(), Value::String(v.into()));
}
}
if let Some(v) = client_secret_val {
form.insert("clientSecret".into(), Value::String(v.into()));
if !form.contains_key("clientSecret") {
if let Some(v) = client_secret_val {
form.insert("clientSecret".into(), Value::String(v.into()));
}
}
// 旧格式迁移:仅有 token 字符串时,折叠为 accounts.* 下的 appId + clientSecret + token与官方 CLI 结构一致)
@@ -2481,20 +2487,12 @@ pub async fn save_messaging_platform(
.trim()
.to_string();
if app_id.is_empty() {
return Err("AppID 不能为空".into());
}
if client_secret.is_empty() {
return Err("ClientSecret 不能为空".into());
}
// 与 `openclaw channels add --channel qqbot --token "AppID:Secret"` 一致:凭证写在 accounts.<id> 下,并保留组合 token
// 与 `openclaw channels add --channel qqbot --token "AppID:Secret"` 一致:凭证写在 accounts.<id> 下
let acct_key = account_id
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or(QQBOT_DEFAULT_ACCOUNT_ID);
let token_combo = format!("{}:{}", app_id, client_secret);
let qqbot_node = channels_map
.entry("qqbot")
@@ -2507,14 +2505,36 @@ pub async fn save_messaging_platform(
qqbot_obj.remove("appSecret");
qqbot_obj.remove("token");
let accounts = qqbot_obj.entry("accounts").or_insert_with(|| json!({}));
let accounts_obj = accounts.as_object_mut().ok_or("accounts 格式错误")?;
let mut entry = Map::new();
entry.insert("appId".into(), Value::String(app_id));
entry.insert("clientSecret".into(), Value::String(client_secret));
entry.insert("token".into(), Value::String(token_combo));
if !app_id.is_empty() {
entry.insert("appId".into(), Value::String(app_id));
}
if !client_secret.is_empty() {
entry.insert("clientSecret".into(), Value::String(client_secret));
}
entry.insert("enabled".into(), Value::Bool(true));
accounts_obj.insert(acct_key.to_string(), Value::Object(entry));
preserve_messaging_credential_refs(&mut entry, form_obj, &current_saved);
if !has_configured_messaging_value(entry.get("appId")) {
return Err("AppID 不能为空".into());
}
if !has_configured_messaging_value(entry.get("clientSecret")) {
return Err("ClientSecret 不能为空".into());
}
// 明文凭证时写入组合 tokenSecretRef 等场景保留已有 token
if let (Some(Value::String(aid)), Some(Value::String(sec))) = (
entry.get("appId"),
entry.get("clientSecret"),
) {
entry.insert("token".into(), Value::String(format!("{}:{}", aid, sec)));
} else if let Some(token) = current_saved.get("token") {
if has_configured_messaging_value(Some(token)) {
entry.insert("token".into(), token.clone());
}
}
merge_account_channel_entry(channels_map, "qqbot", acct_key, entry)?;
ensure_openclaw_qqbot_plugin(&mut cfg)?;
ensure_chat_completions_enabled(&mut cfg)?;
@@ -7664,4 +7684,79 @@ mod tests {
assert!(value_has_messaging_credential(&account));
}
#[test]
fn qqbot_account_merge_preserves_cli_custom_fields() {
let mut channels_map = Map::new();
channels_map.insert(
"qqbot".into(),
json!({
"enabled": true,
"accounts": {
"mybot": {
"appId": "aid",
"clientSecret": "sec",
"token": "aid:sec",
"enabled": true,
"dmPolicy": "pairing",
"groupPolicy": "allowlist"
}
}
}),
);
let current = channels_map
.get("qqbot")
.and_then(|v| v.get("accounts"))
.and_then(|a| a.get("mybot"))
.cloned()
.unwrap_or(Value::Null);
let mut entry = Map::new();
entry.insert("appId".into(), Value::String("aid".into()));
entry.insert("clientSecret".into(), Value::String("sec".into()));
entry.insert("enabled".into(), Value::Bool(true));
entry.insert("token".into(), Value::String("aid:sec".into()));
preserve_messaging_credential_refs(&mut entry, &Map::new(), &current);
merge_account_channel_entry(&mut channels_map, "qqbot", "mybot", entry).expect("merge");
let saved = channels_map
.get("qqbot")
.and_then(|v| v.get("accounts"))
.and_then(|a| a.get("mybot"))
.expect("account");
assert_eq!(saved.get("dmPolicy").and_then(|v| v.as_str()), Some("pairing"));
assert_eq!(
saved.get("groupPolicy").and_then(|v| v.as_str()),
Some("allowlist")
);
}
#[test]
fn qqbot_save_preserves_unchanged_client_secret_secret_ref() {
let current = json!({
"appId": "aid",
"clientSecret": {
"source": "env",
"provider": "default",
"id": "QQBOT_CLIENT_SECRET"
},
"token": "aid:placeholder"
});
let form = json!({
"appId": "aid",
"clientSecret": "SecretRef(env:default:QQBOT_CLIENT_SECRET)"
});
let mut entry = Map::new();
entry.insert("appId".into(), Value::String("aid".into()));
entry.insert(
"clientSecret".into(),
Value::String("SecretRef(env:default:QQBOT_CLIENT_SECRET)".into()),
);
preserve_messaging_credential_refs(
&mut entry,
form.as_object().expect("object"),
&current,
);
assert_eq!(entry.get("clientSecret"), current.get("clientSecret"));
}
}

View File

@@ -58,27 +58,32 @@ async function runHermesAgentAndWaitFinal(input) {
cleanup()
reject(err)
}
const matchesRun = (rid) => !runId || !rid || rid === runId
// Ignore events from other runs until we know our run_id (prevents cross-talk with /h/chat).
const matchesHermesRun = (rid) => {
if (!rid) return false
if (!runId) return false
return rid === runId
}
;(async () => {
try {
unsubs.push(await safeTauriListen('hermes-run-started', (e) => {
if (!runId && e?.payload?.run_id) runId = e.payload.run_id
}))
unsubs.push(await safeTauriListen('hermes-run-delta', (e) => {
if (!matchesRun(e?.payload?.run_id)) return
if (!matchesHermesRun(e?.payload?.run_id)) return
accumulated += e?.payload?.delta || ''
}))
unsubs.push(await safeTauriListen('hermes-run-done', (e) => {
if (!matchesRun(e?.payload?.run_id)) return
if (!matchesHermesRun(e?.payload?.run_id)) return
const out = (e?.payload?.output || accumulated || '').trim()
finish(out)
}))
unsubs.push(await safeTauriListen('hermes-run-error', (e) => {
if (!matchesRun(e?.payload?.run_id)) return
if (!matchesHermesRun(e?.payload?.run_id)) return
fail(new Error(e?.payload?.error || 'unknown error'))
}))
unsubs.push(await safeTauriListen('hermes-run-cancelled', (e) => {
if (!matchesRun(e?.payload?.run_id)) return
if (!matchesHermesRun(e?.payload?.run_id)) return
finish(accumulated.trim() || '(cancelled)')
}))
@@ -303,11 +308,13 @@ export function render() {
// 每个 profile run 完后切到下一个。
// 这是个 trade-off — 真正的并发需要后端改造支持 per-call profile。
let activeProfile = null
let initialProfile = null
try {
// 记下当前 active profile 用于最后还原
const curResp = await api.hermesProfilesList().catch(() => null)
const curArr = Array.isArray(curResp) ? curResp : (curResp?.profiles || [])
activeProfile = curResp?.active || curArr.find(p => p.active)?.name || 'default'
initialProfile = curResp?.active || curArr.find(p => p.active)?.name || 'default'
activeProfile = initialProfile
} catch {}
for (let i = 0; i < targets.length; i++) {
@@ -333,6 +340,9 @@ export function render() {
}
// 还原 active profile如果改了— 静默尝试
if (initialProfile && activeProfile && activeProfile !== initialProfile) {
try { await api.hermesProfileUse(initialProfile) } catch { /* best-effort */ }
}
sending = false
draw()
}

View File

@@ -292,6 +292,7 @@ async function _loadDashboardDataInner(page, fullRefresh, loadSeq) {
patched = true
}
if (patched) {
if (loadSeq !== _dashboardLoadSeq || !page.isConnected) return
config = freshConfig
api.writeOpenclawConfig(freshConfig).catch(() => {})
}