From 780b1bdde530f6afe07dfe0c9337645a5cd65c13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Sat, 23 May 2026 04:38:49 +0800 Subject: [PATCH] feat(channels): add Zalo channel configuration --- scripts/dev-api.js | 109 ++++++- src-tauri/src/commands/messaging.rs | 330 +++++++++++++++++++-- src/lib/channel-labels.js | 2 + src/locales/modules/channels.js | 31 ++ src/pages/channels.js | 92 +++++- tests/channel-config-normalization.test.js | 95 ++++++ 6 files changed, 623 insertions(+), 36 deletions(-) diff --git a/scripts/dev-api.js b/scripts/dev-api.js index c7318cc..26ae9c6 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -2438,7 +2438,7 @@ export function normalizeMessagingPlatformForm(platform, form = {}) { if (!Object.hasOwn(normalized, 'allowFrom') && Object.hasOwn(normalized, 'allowedUsers')) { normalized.allowFrom = normalized.allowedUsers } - const needsAccessDefaults = ['telegram', 'discord', 'feishu', 'slack', 'signal', 'msteams', 'whatsapp'].includes(storageKey) + const needsAccessDefaults = ['telegram', 'discord', 'feishu', 'slack', 'signal', 'msteams', 'whatsapp', 'zalo', 'zalouser'].includes(storageKey) const hasDmField = Object.hasOwn(normalized, 'dmPolicy') || needsAccessDefaults const hasGroupField = Object.hasOwn(normalized, 'groupPolicy') || needsAccessDefaults @@ -2464,6 +2464,32 @@ export function normalizeMessagingPlatformForm(platform, form = {}) { } } + if (Object.hasOwn(normalized, 'groupAllowFrom')) { + normalized.groupAllowFrom = csvToStringArray(normalized.groupAllowFrom) + } + + for (const key of ['mediaMaxMb', 'historyLimit']) { + if (!Object.hasOwn(normalized, key)) continue + const value = String(normalized[key] || '').trim() + if (!value) { + delete normalized[key] + continue + } + const numberValue = Number(value) + if (Number.isFinite(numberValue) && numberValue >= 0) { + normalized[key] = numberValue + } + } + + if (storageKey === 'zalouser' && Object.hasOwn(normalized, 'dangerouslyAllowNameMatching')) { + const value = String(normalized.dangerouslyAllowNameMatching || '').trim() + if (!value) { + delete normalized.dangerouslyAllowNameMatching + } else { + normalized.dangerouslyAllowNameMatching = value === 'true' + } + } + if (storageKey === 'feishu') { normalized.domain = String(normalized.domain || '').trim() || 'feishu' normalized.connectionMode = normalized.connectionMode || 'websocket' @@ -2565,6 +2591,7 @@ const MESSAGING_CREDENTIAL_FIELDS = [ 'signingSecret', 'token', 'tokenFile', + 'webhookSecret', ] function hasConfiguredMessagingValue(value) { @@ -2578,6 +2605,14 @@ function channelRootHasMessagingCredential(root) { return MESSAGING_CREDENTIAL_FIELDS.some(key => hasConfiguredMessagingValue(root[key])) } +function channelAnyCredentialFields(platform) { + const storageKey = platformStorageKey(platform) + if (storageKey === 'zalo') { + return [['botToken', 'Bot Token'], ['tokenFile', 'Token File']] + } + return [] +} + const CHANNEL_DIAG_REQUIRED_FIELDS = { telegram: [['botToken', 'Bot Token']], discord: [['token', 'Bot Token']], @@ -2605,10 +2640,15 @@ function requiredChannelCredentialFields(platform, form = {}) { } function channelDiagnosisCredentialsReady(platform, form = {}) { + if (platformStorageKey(platform) === 'zalouser') return true const requiredFields = requiredChannelCredentialFields(platform, form) if (requiredFields.length) { return requiredFields.every(([key]) => hasConfiguredMessagingValue(form?.[key])) } + const anyFields = channelAnyCredentialFields(platform) + if (anyFields.length) { + return anyFields.some(([key]) => hasConfiguredMessagingValue(form?.[key])) + } return channelRootHasMessagingCredential(form) } @@ -2648,22 +2688,29 @@ export function buildOpenClawChannelDiagnosis({ }) const requiredFields = requiredChannelCredentialFields(storageKey, form) + const anyFields = channelAnyCredentialFields(storageKey) const missing = requiredFields .filter(([key]) => !hasConfiguredMessagingValue(form?.[key])) .map(([, label]) => label) const hasAnyCredential = channelRootHasMessagingCredential(form) - const credentialOk = requiredFields.length ? missing.length === 0 : hasAnyCredential + const anyCredentialOk = anyFields.length ? anyFields.some(([key]) => hasConfiguredMessagingValue(form?.[key])) : false + const credentialOk = storageKey === 'zalouser' + ? !!configExists + : (requiredFields.length ? missing.length === 0 : (anyFields.length ? anyCredentialOk : hasAnyCredential)) + const anyLabels = anyFields.map(([, label]) => label).join(' / ') checks.push({ id: 'credentials', ok: credentialOk, - title: '必要凭证字段', - detail: credentialOk - ? (requiredFields.length - ? `已填写 ${requiredFields.map(([, label]) => label).join(' / ')}。` - : '已检测到可用凭证字段。') - : (missing.length - ? `缺少 ${missing.join(' / ')},请补齐后保存。` - : '未检测到可用凭证字段,请检查渠道配置。'), + title: storageKey === 'zalouser' ? '登录/会话配置' : '必要凭证字段', + detail: storageKey === 'zalouser' + ? 'Zalo Personal 通过二维码登录保存本地会话;配置已保存后,请按手动命令完成或刷新登录。' + : (credentialOk + ? (requiredFields.length + ? `已填写 ${requiredFields.map(([, label]) => label).join(' / ')}。` + : (anyFields.length ? `已填写 ${anyLabels} 其中一项。` : '已检测到可用凭证字段。')) + : (missing.length + ? `缺少 ${missing.join(' / ')},请补齐后保存。` + : (anyFields.length ? `缺少 ${anyLabels},至少填写一项后保存。` : '未检测到可用凭证字段,请检查渠道配置。'))), }) if (verifyError) { @@ -2824,6 +2871,8 @@ export function buildMessagingPlatformFormValues(platform, saved = {}, options = if (csv) form[key] = csv } else if (typeof value === 'boolean') { form[key] = value ? 'true' : 'false' + } else if (typeof value === 'number') { + form[key] = String(value) } } return form @@ -3316,6 +3365,25 @@ function buildOpenClawMessagingPlatformEntry(platform, form, currentSaved = {}) entry.reactionNotifications = form.reactionNotifications entry.typingIndicator = form.typingIndicator entry.resolveSenderNames = form.resolveSenderNames + } else if (storageKey === 'zalo') { + for (const key of ['botToken', 'tokenFile', 'webhookUrl', 'webhookSecret', 'webhookPath', 'proxy', 'responsePrefix']) { + if (form[key]) entry[key] = form[key] + } + entry.dmPolicy = form.dmPolicy + entry.groupPolicy = form.groupPolicy + if (Array.isArray(form.allowFrom) && form.allowFrom.length) entry.allowFrom = form.allowFrom + if (Array.isArray(form.groupAllowFrom) && form.groupAllowFrom.length) entry.groupAllowFrom = form.groupAllowFrom + if (typeof form.mediaMaxMb === 'number') entry.mediaMaxMb = form.mediaMaxMb + } else if (storageKey === 'zalouser') { + for (const key of ['profile', 'messagePrefix', 'responsePrefix']) { + if (form[key]) entry[key] = form[key] + } + entry.dmPolicy = form.dmPolicy + entry.groupPolicy = form.groupPolicy + if (Array.isArray(form.allowFrom) && form.allowFrom.length) entry.allowFrom = form.allowFrom + if (Array.isArray(form.groupAllowFrom) && form.groupAllowFrom.length) entry.groupAllowFrom = form.groupAllowFrom + if (typeof form.historyLimit === 'number') entry.historyLimit = form.historyLimit + if (typeof form.dangerouslyAllowNameMatching === 'boolean') entry.dangerouslyAllowNameMatching = form.dangerouslyAllowNameMatching } else { Object.assign(entry, form) } @@ -4904,6 +4972,27 @@ const handlers = { return { valid: false, errors: [`Telegram API 连接失败: ${e.message}`] } } } + if (platform === 'zalo') { + if (form.botToken) { + try { + const resp = await fetch(`https://bot-api.zaloplatforms.com/bot${form.botToken}/getMe`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal: AbortSignal.timeout(15000), + }) + const body = await resp.json() + if (body.ok) return { valid: true, errors: [], details: ['Zalo Bot Token 已通过 getMe 校验'] } + return { valid: false, errors: [body.description || body.message || 'Zalo Bot Token 无效'] } + } catch (e) { + return { valid: false, errors: [`Zalo API 连接失败: ${e.message}`] } + } + } + if (form.tokenFile) return { valid: true, warnings: ['已配置 Token File;Web 模式不会读取外部文件做在线校验'] } + return { valid: false, errors: ['请填写 Bot Token 或 Token File'] } + } + if (platform === 'zalouser') { + return { valid: true, warnings: ['Zalo Personal 通过二维码登录维护本地会话;请使用 openclaw channels status --probe 检查登录状态'] } + } if (platform === 'discord') { try { const resp = await fetch('https://discord.com/api/v10/users/@me', { diff --git a/src-tauri/src/commands/messaging.rs b/src-tauri/src/commands/messaging.rs index 952df67..2406b44 100644 --- a/src-tauri/src/commands/messaging.rs +++ b/src-tauri/src/commands/messaging.rs @@ -148,6 +148,7 @@ fn preserve_messaging_credential_refs( "signingSecret", "token", "tokenFile", + "webhookSecret", ] { if !form_obj.contains_key(key) { continue; @@ -188,6 +189,7 @@ fn channel_root_has_messaging_credential(root: &Map) -> bool { "signingSecret", "token", "tokenFile", + "webhookSecret", ] .iter() .any(|key| has_configured_messaging_value(root.get(*key))) @@ -237,14 +239,38 @@ fn required_channel_credential_fields( } } -fn channel_diagnosis_credentials_ready(platform: &str, form: &Map) -> bool { - let required_fields = required_channel_credential_fields(platform, form); - if required_fields.is_empty() { - return channel_root_has_messaging_credential(form); +fn channel_any_credential_fields(platform: &str) -> Vec<(&'static str, &'static str)> { + match platform_storage_key(platform) { + "zalo" => vec![("botToken", "Bot Token"), ("tokenFile", "Token File")], + _ => vec![], } - required_fields +} + +fn channel_diagnosis_credentials_ready(platform: &str, form: &Map) -> bool { + if platform_storage_key(platform) == "zalouser" { + return true; + } + let required_fields = required_channel_credential_fields(platform, form); + if !required_fields.is_empty() { + return required_fields + .iter() + .all(|(key, _)| has_configured_messaging_value(form.get(*key))); + } + let any_fields = channel_any_credential_fields(platform); + if !any_fields.is_empty() { + return any_fields + .iter() + .any(|(key, _)| has_configured_messaging_value(form.get(*key))); + } + channel_root_has_messaging_credential(form) +} + +fn credential_labels(fields: &[(&'static str, &'static str)]) -> String { + fields .iter() - .all(|(key, _)| has_configured_messaging_value(form.get(*key))) + .map(|(_, label)| *label) + .collect::>() + .join(" / ") } fn json_string_list(value: Option<&Value>) -> Vec { @@ -314,35 +340,50 @@ fn build_openclaw_channel_diagnosis( })); let required_fields = required_channel_credential_fields(storage_key, form); + let any_fields = channel_any_credential_fields(storage_key); let missing: Vec<&str> = required_fields .iter() .filter(|(key, _)| !has_configured_messaging_value(form.get(*key))) .map(|(_, label)| *label) .collect(); - let credential_ok = if required_fields.is_empty() { - channel_root_has_messaging_credential(form) + let any_credential_ok = if any_fields.is_empty() { + false } else { - missing.is_empty() + any_fields + .iter() + .any(|(key, _)| has_configured_messaging_value(form.get(*key))) }; - let required_labels = required_fields - .iter() - .map(|(_, label)| *label) - .collect::>() - .join(" / "); + let credential_ok = if storage_key == "zalouser" { + config_exists + } else if !required_fields.is_empty() { + missing.is_empty() + } else if !any_fields.is_empty() { + any_credential_ok + } else { + channel_root_has_messaging_credential(form) + }; + let required_labels = credential_labels(&required_fields); + let any_labels = credential_labels(&any_fields); checks.push(json!({ "id": "credentials", "ok": credential_ok, - "title": "必要凭证字段", - "detail": if credential_ok { - if required_fields.is_empty() { - "已检测到可用凭证字段。".to_string() - } else { + "title": if storage_key == "zalouser" { "登录/会话配置" } else { "必要凭证字段" }, + "detail": if storage_key == "zalouser" { + "Zalo Personal 通过二维码登录保存本地会话;配置已保存后,请按手动命令完成或刷新登录。".to_string() + } else if credential_ok { + if !required_fields.is_empty() { format!("已填写 {}。", required_labels) + } else if !any_fields.is_empty() { + format!("已填写 {} 其中一项。", any_labels) + } else { + "已检测到可用凭证字段。".to_string() } - } else if missing.is_empty() { - "未检测到可用凭证字段,请检查渠道配置。".to_string() - } else { + } else if !missing.is_empty() { format!("缺少 {},请补齐后保存。", missing.join(" / ")) + } else if !any_fields.is_empty() { + format!("缺少 {},至少填写一项后保存。", any_labels) + } else { + "未检测到可用凭证字段,请检查渠道配置。".to_string() } })); @@ -519,6 +560,42 @@ fn put_bool_from_form(entry: &mut Map, key: &str, raw: &str) { } } +fn put_number_from_form(entry: &mut Map, key: &str, raw: &str) { + let value = raw.trim(); + if value.is_empty() { + return; + } + if let Ok(number) = value.parse::() { + if let Some(json_number) = serde_json::Number::from_f64(number) { + entry.insert(key.into(), Value::Number(json_number)); + } + } +} + +fn normalize_numeric_form_value(map: &mut Map, key: &str) { + let Some(value) = map.get(key).cloned() else { + return; + }; + match value { + Value::String(raw) => { + let trimmed = raw.trim(); + if trimmed.is_empty() { + map.remove(key); + return; + } + if let Ok(number) = trimmed.parse::() { + if let Some(json_number) = serde_json::Number::from_f64(number) { + map.insert(key.into(), Value::Number(json_number)); + } + } + } + Value::Null => { + map.remove(key); + } + _ => {} + } +} + fn put_bool_value_if_present(entry: &mut Map, key: &str, value: Option<&Value>) { match value { Some(Value::Bool(v)) => { @@ -581,7 +658,15 @@ fn normalize_messaging_platform_form( let needs_access_defaults = matches!( storage_key, - "telegram" | "discord" | "feishu" | "slack" | "signal" | "msteams" | "whatsapp" + "telegram" + | "discord" + | "feishu" + | "slack" + | "signal" + | "msteams" + | "whatsapp" + | "zalo" + | "zalouser" ); let has_dm_field = normalized.contains_key("dmPolicy") || needs_access_defaults; let has_group_field = normalized.contains_key("groupPolicy") || needs_access_defaults; @@ -632,6 +717,34 @@ fn normalize_messaging_platform_form( } } + if normalized.contains_key("groupAllowFrom") { + let items = json_array_from_csv_value(normalized.get("groupAllowFrom")); + normalized.insert("groupAllowFrom".into(), Value::Array(items)); + } + + normalize_numeric_form_value(&mut normalized, "mediaMaxMb"); + normalize_numeric_form_value(&mut normalized, "historyLimit"); + + if storage_key == "zalouser" && normalized.contains_key("dangerouslyAllowNameMatching") { + let value = match normalized.get("dangerouslyAllowNameMatching") { + Some(Value::Bool(v)) => Some(*v), + Some(Value::String(raw)) => { + let trimmed = raw.trim(); + if trimmed.is_empty() { + None + } else { + Some(bool_from_form_value(trimmed).unwrap_or(false)) + } + } + _ => None, + }; + if let Some(v) = value { + normalized.insert("dangerouslyAllowNameMatching".into(), Value::Bool(v)); + } else { + normalized.remove("dangerouslyAllowNameMatching"); + } + } + if storage_key == "feishu" { let domain = normalized .get("domain") @@ -1253,6 +1366,8 @@ pub async fn read_platform_config( k.clone(), Value::String(if b { "true" } else { "false" }.into()), ); + } else if v.is_number() { + form.insert(k.clone(), Value::String(v.to_string())); } } } @@ -1391,6 +1506,112 @@ pub async fn save_messaging_platform( entry, )?; } + "zalo" => { + let bot_token = form_string(form_obj, "botToken"); + let token_file = form_string(form_obj, "tokenFile"); + if bot_token.is_empty() && token_file.is_empty() { + return Err("Bot Token 或 Token File 至少填写一项".into()); + } + + let mut entry = Map::new(); + entry.insert("enabled".into(), Value::Bool(true)); + put_string(&mut entry, "botToken", bot_token); + put_string(&mut entry, "tokenFile", token_file); + put_string( + &mut entry, + "webhookUrl", + form_string(form_obj, "webhookUrl"), + ); + put_string( + &mut entry, + "webhookSecret", + form_string(form_obj, "webhookSecret"), + ); + put_string( + &mut entry, + "webhookPath", + form_string(form_obj, "webhookPath"), + ); + put_string(&mut entry, "proxy", form_string(form_obj, "proxy")); + put_string( + &mut entry, + "responsePrefix", + form_string(form_obj, "responsePrefix"), + ); + put_string(&mut entry, "dmPolicy", form_string(form_obj, "dmPolicy")); + put_string( + &mut entry, + "groupPolicy", + form_string(form_obj, "groupPolicy"), + ); + put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom")); + put_array_from_form_value(&mut entry, "groupAllowFrom", form_obj.get("groupAllowFrom")); + if let Some(value) = form_obj.get("mediaMaxMb").and_then(|v| v.as_f64()) { + if let Some(number) = serde_json::Number::from_f64(value) { + entry.insert("mediaMaxMb".into(), Value::Number(number)); + } + } else { + put_number_from_form( + &mut entry, + "mediaMaxMb", + &form_string(form_obj, "mediaMaxMb"), + ); + } + preserve_messaging_credential_refs(&mut entry, form_obj, ¤t_saved); + merge_channel_entry_for_account( + channels_map, + &storage_key, + account_id.as_deref(), + entry, + )?; + ensure_plugin_allowed(&mut cfg, "zalo")?; + } + "zalouser" => { + let mut entry = Map::new(); + entry.insert("enabled".into(), Value::Bool(true)); + put_string(&mut entry, "profile", form_string(form_obj, "profile")); + put_string( + &mut entry, + "messagePrefix", + form_string(form_obj, "messagePrefix"), + ); + put_string( + &mut entry, + "responsePrefix", + form_string(form_obj, "responsePrefix"), + ); + put_string(&mut entry, "dmPolicy", form_string(form_obj, "dmPolicy")); + put_string( + &mut entry, + "groupPolicy", + form_string(form_obj, "groupPolicy"), + ); + put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom")); + put_array_from_form_value(&mut entry, "groupAllowFrom", form_obj.get("groupAllowFrom")); + put_bool_value_if_present( + &mut entry, + "dangerouslyAllowNameMatching", + form_obj.get("dangerouslyAllowNameMatching"), + ); + if let Some(value) = form_obj.get("historyLimit").and_then(|v| v.as_f64()) { + if let Some(number) = serde_json::Number::from_f64(value) { + entry.insert("historyLimit".into(), Value::Number(number)); + } + } else { + put_number_from_form( + &mut entry, + "historyLimit", + &form_string(form_obj, "historyLimit"), + ); + } + merge_channel_entry_for_account( + channels_map, + &storage_key, + account_id.as_deref(), + entry, + )?; + ensure_plugin_allowed(&mut cfg, "zalouser")?; + } "qqbot" => { let app_id = form_obj .get("appId") @@ -1891,6 +2112,11 @@ pub async fn verify_bot_token(platform: String, form: Value) -> Result verify_feishu(&client, form_obj).await, "dingtalk" | "dingtalk-connector" => verify_dingtalk(&client, form_obj).await, "slack" => verify_slack(&client, form_obj).await, + "zalo" => verify_zalo(&client, form_obj).await, + "zalouser" => Ok(json!({ + "valid": true, + "warnings": ["Zalo Personal 通过二维码登录维护本地会话;请使用 openclaw channels status --probe 检查登录状态"] + })), "matrix" => verify_matrix(&client, form_obj).await, "signal" => verify_signal(&client, form_obj).await, "msteams" => verify_msteams(&client, form_obj).await, @@ -4486,6 +4712,64 @@ async fn verify_telegram( } } +// ── Zalo Bot 凭证校验 ───────────────────────────────────── + +async fn verify_zalo(client: &reqwest::Client, form: &Map) -> Result { + let bot_token = form + .get("botToken") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); + let token_file = form + .get("tokenFile") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); + + if bot_token.is_empty() { + if token_file.is_empty() { + return Ok(json!({ "valid": false, "errors": ["请填写 Bot Token 或 Token File"] })); + } + return Ok(json!({ + "valid": true, + "warnings": ["已配置 Token File;桌面端不会读取外部文件做在线校验"] + })); + } + + let resp = client + .post(format!( + "https://bot-api.zaloplatforms.com/bot{}/getMe", + bot_token + )) + .header("Content-Type", "application/json") + .send() + .await + .map_err(|e| format!("Zalo API 连接失败: {}", e))?; + + let body: Value = resp + .json() + .await + .map_err(|e| format!("解析响应失败: {}", e))?; + + if body.get("ok").and_then(|v| v.as_bool()) == Some(true) { + Ok(json!({ + "valid": true, + "errors": [], + "details": ["Zalo Bot Token 已通过 getMe 校验"] + })) + } else { + let msg = body + .get("description") + .or_else(|| body.get("message")) + .and_then(|v| v.as_str()) + .unwrap_or("Zalo Bot Token 无效"); + Ok(json!({ + "valid": false, + "errors": [msg] + })) + } +} + // ── 飞书凭证校验 ────────────────────────────────────── async fn verify_feishu( diff --git a/src/lib/channel-labels.js b/src/lib/channel-labels.js index 3494269..b28df41 100644 --- a/src/lib/channel-labels.js +++ b/src/lib/channel-labels.js @@ -8,6 +8,8 @@ export const CHANNEL_LABELS = { discord: 'Discord', slack: 'Slack', whatsapp: 'WhatsApp', + zalo: 'Zalo', + zalouser: 'Zalo Personal', msteams: 'Microsoft Teams', signal: 'Signal', matrix: 'Matrix', diff --git a/src/locales/modules/channels.js b/src/locales/modules/channels.js index ad18805..47ab6cf 100644 --- a/src/locales/modules/channels.js +++ b/src/locales/modules/channels.js @@ -60,6 +60,37 @@ export default { telegramGuide3: _('复制 BotFather 返回的 Bot Token', 'Copy the Bot Token returned by BotFather', '複製 BotFather 返回的 Bot Token'), telegramGuide4: _('填入下方凭证并保存', 'Fill in credentials below and save', '填入下方憑證並儲存'), telegramGuideFooter: _('
需要公网可达的服务器或使用 polling 模式
', '
需要公网可达的服务器或使用 polling 模式
', '
需要公網可達的伺服器或使用 polling 模式
'), + zaloDesc: _('接入 Zalo Bot API,适合越南用户场景的私聊和群组消息', 'Connect Zalo Bot API for DM and group messaging in Vietnam-focused scenarios', '接入 Zalo Bot API,適合越南使用者場景的私聊和群組訊息'), + zaloGuide1: _('前往 Zalo Bot Platform 创建或打开机器人', 'Open Zalo Bot Platform and create or open your bot', '前往 Zalo Bot Platform 建立或開啟機器人'), + zaloGuide2: _('获取 Bot Token;如果使用 SecretRef 或外部文件,也可以填写 Token File', 'Get the Bot Token; if you use SecretRef or an external file, fill Token File instead', '取得 Bot Token;如果使用 SecretRef 或外部檔案,也可以填寫 Token File'), + zaloGuide3: _('如使用 webhook 模式,填写 Webhook URL / Secret / Path;否则可先留空使用默认接收方式', 'For webhook mode, fill Webhook URL / Secret / Path; otherwise leave them empty for the default receive mode', '如使用 webhook 模式,填寫 Webhook URL / Secret / Path;否則可先留空使用預設接收方式'), + zaloGuide4: _('设置私信策略、群组策略和允许列表,保存后面板会安装插件并重载 Gateway', 'Set DM policy, group policy, and allowlists; after saving, the panel installs the plugin and reloads Gateway', '設定私信策略、群組策略和允許列表,儲存後面板會安裝外掛並重載 Gateway'), + zaloGuide5: _('如果收不到消息,请确认机器人已在 Zalo 侧启用并查看 Gateway 日志', 'If messages do not arrive, confirm the bot is enabled on Zalo and inspect Gateway logs', '如果收不到訊息,請確認機器人已在 Zalo 側啟用並查看 Gateway 日誌'), + zaloGuideFooter: _('
Zalo Bot 至少需要 Bot Token 或 Token File 其中一项。
', '
Zalo Bot requires either Bot Token or Token File.
', '
Zalo Bot 至少需要 Bot Token 或 Token File 其中一項。
'), + zaloBotTokenPh: _('Zalo Bot Token', 'Zalo Bot Token'), + zaloBotTokenHint: _('与 Token File 二选一;如果当前值来自 SecretRef,保持占位不变即可保留引用。', 'Use either this or Token File; keep the SecretRef placeholder unchanged to preserve the reference.'), + zaloTokenFilePh: _('可选,例如 /etc/openclaw/zalo-token.txt', 'Optional, e.g. /etc/openclaw/zalo-token.txt'), + zaloTokenFileHint: _('当 token 由文件管理时填写;在线校验只会直接校验 Bot Token,不会读取外部文件。', 'Use this when the token is file-managed; online verification only checks Bot Token directly and will not read external files.'), + zaloWebhookSecretPh: _('至少 8 位的 Webhook Secret', 'Webhook Secret with at least 8 characters'), + zaloAllowFromPh: _('可选,逗号分隔 Zalo 用户 ID', 'Optional, comma-separated Zalo user IDs'), + zaloAllowFromHint: _('Zalo 官方 Bot 推荐使用数字用户 ID;留空表示按策略默认处理。', 'Numeric user IDs are recommended for Zalo Bot; leave empty to use policy defaults.'), + zaloGroupAllowFromPh: _('可选,逗号分隔群组或会话 ID', 'Optional, comma-separated group or thread IDs'), + groupAllowFromHint: _('限制允许的群组或会话 ID,留空不限制。', 'Restrict allowed group or thread IDs; leave empty for no restriction.'), + zaloTokenOrFile: _('Bot Token 或 Token File', 'Bot Token or Token File'), + zalouserDesc: _('通过二维码登录接入 Zalo 个人账号,支持私聊和群组', 'Connect a Zalo personal account via QR login, with DM and group support', '透過 QR code 登入接入 Zalo 個人帳號,支援私聊和群組'), + zalouserGuide1: _('先安装 @openclaw/zalouser 插件,保存配置时面板会自动尝试安装', 'Install the @openclaw/zalouser plugin first; the panel will try to install it on save', '先安裝 @openclaw/zalouser 外掛,儲存設定時面板會自動嘗試安裝'), + zalouserGuide2: _('保存后在终端执行 openclaw channels login --channel zalouser 并用 Zalo 手机端扫码', 'After saving, run openclaw channels login --channel zalouser and scan with the Zalo mobile app', '儲存後在終端執行 openclaw channels login --channel zalouser 並用 Zalo 手機端掃碼'), + zalouserGuide3: _('多账号时填写账号标识,并在命令后追加 --account 账号标识', 'For multi-account setup, fill Account Identifier and add --account account-id to the command', '多帳號時填寫帳號標識,並在命令後追加 --account 帳號標識'), + zalouserGuide4: _('Allow From / Group Allow From 推荐使用数字 ID;名称匹配存在误配风险,默认关闭', 'Numeric IDs are recommended for Allow From / Group Allow From; name matching is risky and disabled by default', 'Allow From / Group Allow From 推薦使用數字 ID;名稱匹配存在誤配風險,預設關閉'), + zalouserGuide5: _('如登录状态异常,可执行 openclaw channels logout --channel zalouser 后重新登录', 'If login state is unhealthy, run openclaw channels logout --channel zalouser and log in again', '如登入狀態異常,可執行 openclaw channels logout --channel zalouser 後重新登入'), + zalouserGuideFooter: _('
Zalo Personal 是非官方个人号自动化集成,存在账号风控风险,请谨慎使用。
', '
Zalo Personal is an unofficial personal-account automation integration and may carry account risk. Use carefully.
', '
Zalo Personal 是非官方個人號自動化整合,存在帳號風控風險,請謹慎使用。
'), + zalouserProfileHint: _('默认可留空;多账号建议与账号标识一致,例如 work。', 'Leave empty for default; for multi-account setup, match the account identifier, e.g. work.'), + zalouserNameMatching: _('允许名称匹配', 'Allow Name Matching'), + zalouserNameMatchingHint: _('关闭时仅使用稳定 ID 匹配,避免同名联系人或群组误匹配。', 'When disabled, only stable IDs are matched to avoid wrong matches with duplicate names.'), + zalouserAllowFromPh: _('可选,逗号分隔用户 ID 或精确名称', 'Optional, comma-separated user IDs or exact names'), + zalouserAllowFromHint: _('推荐使用 Zalo 用户 ID;只有确认安全时才开启名称匹配。', 'Zalo user IDs are recommended; enable name matching only when you understand the risk.'), + zalouserGroupAllowFromPh: _('可选,逗号分隔群组 ID 或精确群名', 'Optional, comma-separated group IDs or exact group names'), + zalouserManualLoginHint: _('插件安装并保存配置后,在终端运行此命令完成二维码登录。多账号请追加 --account 账号标识。', 'After installing the plugin and saving config, run this command in your terminal to complete QR login. For multi-account setup, append --account account-id.'), discordDesc: _('接入 Discord Bot,支持服务器频道和私信', 'Connect a Discord Bot, supports server channels and DMs', '接入 Discord Bot,支援伺服器頻道和私信', 'Discord Bot に接続'), discordGuide1: _('前往 Discord Developer Portal 创建 Application', '前往 Discord Developer Portal 创建 Application', '前往 Discord Developer Portal 建立 Application'), discordGuide2: _('在 Bot 页面点击「Reset Token」获取 Bot Token', 'Click "Reset Token" on the Bot page to get the Bot Token', '在 Bot 頁面点擊「Reset Token」取得 Bot Token'), diff --git a/src/pages/channels.js b/src/pages/channels.js index 886093c..59d88de 100644 --- a/src/pages/channels.js +++ b/src/pages/channels.js @@ -36,6 +36,12 @@ const GROUP_POLICY_OPTIONS = (allLabel, { mention = false } = {}) => [ { value: 'disabled', label: t('channels.groupDisabled') }, ] +const BOOLEAN_OPTIONS = [ + { value: '', label: t('channels.policyDefault') }, + { value: 'true', label: t('channels.enable') }, + { value: 'false', label: t('channels.disable') }, +] + const PLATFORM_REGISTRY = { qqbot: { label: t('channels.qqbotLabel'), @@ -130,6 +136,65 @@ const PLATFORM_REGISTRY = { configKey: 'telegram', pairingChannel: 'telegram', }, + zalo: { + label: 'Zalo', + iconName: 'send', + desc: t('channels.zaloDesc'), + guide: [ + t('channels.zaloGuide1'), + t('channels.zaloGuide2'), + t('channels.zaloGuide3'), + t('channels.zaloGuide4'), + t('channels.zaloGuide5'), + ], + guideFooter: t('channels.zaloGuideFooter'), + fields: [ + { key: 'botToken', label: 'Bot Token', placeholder: t('channels.zaloBotTokenPh'), secret: true, required: false, hint: t('channels.zaloBotTokenHint') }, + { key: 'tokenFile', label: 'Token File', placeholder: t('channels.zaloTokenFilePh'), required: false, hint: t('channels.zaloTokenFileHint') }, + { key: 'webhookUrl', label: 'Webhook URL', placeholder: 'https://example.com/zalo-webhook', required: false }, + { key: 'webhookSecret', label: 'Webhook Secret', placeholder: t('channels.zaloWebhookSecretPh'), secret: true, required: false }, + { key: 'webhookPath', label: 'Webhook Path', placeholder: '/zalo-webhook', required: false }, + { key: 'dmPolicy', label: t('channels.dmPolicy'), type: 'select', options: DM_POLICY_OPTIONS, required: false }, + { key: 'groupPolicy', label: t('channels.groupPolicy'), type: 'select', options: GROUP_POLICY_OPTIONS(t('channels.groupAllGroups')), required: false }, + { key: 'allowFrom', label: 'Allow From', placeholder: t('channels.zaloAllowFromPh'), required: false, hint: t('channels.zaloAllowFromHint') }, + { key: 'groupAllowFrom', label: 'Group Allow From', placeholder: t('channels.zaloGroupAllowFromPh'), required: false, hint: t('channels.groupAllowFromHint') }, + { key: 'mediaMaxMb', label: 'Media Max MB', placeholder: '50', required: false }, + { key: 'proxy', label: 'Proxy', placeholder: 'http://127.0.0.1:7890', required: false }, + { key: 'responsePrefix', label: 'Response Prefix', placeholder: t('channels.optionalEg', { example: '[AI]' }), required: false }, + ], + requiredAny: [{ keys: ['botToken', 'tokenFile'], label: t('channels.zaloTokenOrFile') }], + configKey: 'zalo', + pairingChannel: 'zalo', + pluginRequired: '@openclaw/zalo@latest', + pluginId: 'zalo', + }, + zalouser: { + label: 'Zalo Personal', + iconName: 'message-circle', + desc: t('channels.zalouserDesc'), + guide: [ + t('channels.zalouserGuide1'), + t('channels.zalouserGuide2'), + t('channels.zalouserGuide3'), + t('channels.zalouserGuide4'), + t('channels.zalouserGuide5'), + ], + guideFooter: t('channels.zalouserGuideFooter'), + fields: [ + { key: 'profile', label: 'Profile', placeholder: 'default', required: false, hint: t('channels.zalouserProfileHint') }, + { key: 'dangerouslyAllowNameMatching', label: t('channels.zalouserNameMatching'), type: 'select', options: BOOLEAN_OPTIONS, required: false, hint: t('channels.zalouserNameMatchingHint') }, + { key: 'dmPolicy', label: t('channels.dmPolicy'), type: 'select', options: DM_POLICY_OPTIONS, required: false }, + { key: 'groupPolicy', label: t('channels.groupPolicy'), type: 'select', options: GROUP_POLICY_OPTIONS(t('channels.groupAllGroups')), required: false }, + { key: 'allowFrom', label: 'Allow From', placeholder: t('channels.zalouserAllowFromPh'), required: false, hint: t('channels.zalouserAllowFromHint') }, + { key: 'groupAllowFrom', label: 'Group Allow From', placeholder: t('channels.zalouserGroupAllowFromPh'), required: false, hint: t('channels.groupAllowFromHint') }, + { key: 'historyLimit', label: 'History Limit', placeholder: '20', required: false }, + { key: 'messagePrefix', label: 'Message Prefix', placeholder: t('channels.optionalEg', { example: '[Zalo]' }), required: false }, + { key: 'responsePrefix', label: 'Response Prefix', placeholder: t('channels.optionalEg', { example: '[AI]' }), required: false }, + ], + configKey: 'zalouser', + pluginRequired: '@openclaw/zalouser@latest', + pluginId: 'zalouser', + }, discord: { label: 'Discord', iconName: 'hash', @@ -440,7 +505,7 @@ function applyRouteIntent(page, state) { // ── 已配置平台渲染 ── // ── 多账号支持的平台:与 OpenClaw 的 accounts/defaultAccount 配置模型保持一致 ── -const MULTI_INSTANCE_PLATFORMS = ['telegram', 'discord', 'slack', 'feishu', 'dingtalk', 'dingtalk-connector', 'qqbot'] +const MULTI_INSTANCE_PLATFORMS = ['telegram', 'discord', 'slack', 'feishu', 'dingtalk', 'dingtalk-connector', 'qqbot', 'zalo', 'zalouser'] function supportsMessagingMultiAccount(pid) { return MULTI_INSTANCE_PLATFORMS.includes(pid) @@ -1334,16 +1399,25 @@ function getManualCommandSpecs(pid, reg) { ] } - if (!['qqbot', 'feishu', 'dingtalk'].includes(pid) || !reg.pluginRequired) { + if (!reg.pluginRequired) { return [] } - return [{ + const commands = [{ id: 'install', title: t('channels.manualInstallCommand'), hint: t('channels.manualInstallHint', { platform: reg.label }), command: `openclaw plugins install ${reg.pluginRequired}`, }] + if (pid === 'zalouser') { + commands.push({ + id: 'login', + title: t('channels.manualLoginCommand'), + hint: t('channels.zalouserManualLoginHint'), + command: 'openclaw channels login --channel zalouser', + }) + } + return commands } function buildManualCommandPanel(commandSpecs) { @@ -2298,6 +2372,12 @@ async function openConfigDialog(pid, page, state, accountId) { return } } + for (const group of reg.requiredAny || []) { + if (!group.keys.some(key => form[key])) { + toast(t('channels.pleaseFill', { field: group.label }), 'warning') + return + } + } btnVerify.disabled = true btnVerify.textContent = t('channels.verifying') resultEl.innerHTML = '' @@ -2334,6 +2414,12 @@ async function openConfigDialog(pid, page, state, accountId) { return } } + for (const group of reg.requiredAny || []) { + if (!group.keys.some(key => form[key])) { + toast(t('channels.pleaseFill', { field: group.label }), 'warning') + return + } + } if (pid === 'matrix' && !form.accessToken && !(form.userId && form.password)) { toast(t('channels.matrixAuthRequired'), 'warning') return diff --git a/tests/channel-config-normalization.test.js b/tests/channel-config-normalization.test.js index efa90a9..3399190 100644 --- a/tests/channel-config-normalization.test.js +++ b/tests/channel-config-normalization.test.js @@ -365,6 +365,101 @@ test('通用渠道诊断会识别钉钉 Client ID 和 Client Secret', () => { assert.equal(result.checks.find(item => item.id === 'online_verify')?.ok, true) }) +test('Zalo 渠道保存会补齐策略并保留 Bot Token 或 Token File', () => { + const tokenForm = normalizeMessagingPlatformForm('zalo', { + botToken: 'zalo-token', + groupAllowFrom: 'group-1, group-2', + mediaMaxMb: '25', + }) + const tokenFileForm = normalizeMessagingPlatformForm('zalo', { + tokenFile: '/run/secrets/zalo-token', + }) + + assert.equal(tokenForm.botToken, 'zalo-token') + assert.equal(tokenForm.dmPolicy, 'pairing') + assert.equal(tokenForm.groupPolicy, 'allowlist') + assert.deepEqual(tokenForm.groupAllowFrom, ['group-1', 'group-2']) + assert.equal(tokenForm.mediaMaxMb, 25) + assert.equal(tokenFileForm.tokenFile, '/run/secrets/zalo-token') +}) + +test('OpenClaw 渠道保存会写入 Zalo 多账号配置', () => { + const cfg = { channels: {} } + + mergeOpenClawMessagingPlatformConfig(cfg, { + platform: 'zalo', + accountId: 'vn', + form: { + botToken: 'zalo-token', + groupAllowFrom: 'thread-1', + mediaMaxMb: '30', + }, + }) + + assert.equal(cfg.channels.zalo.defaultAccount, 'vn') + assert.equal(cfg.channels.zalo.accounts.vn.botToken, 'zalo-token') + assert.deepEqual(cfg.channels.zalo.accounts.vn.groupAllowFrom, ['thread-1']) + assert.equal(cfg.channels.zalo.accounts.vn.mediaMaxMb, 30) +}) + +test('Zalo 诊断接受 Bot Token 或 Token File 二选一', () => { + const tokenResult = buildOpenClawChannelDiagnosis({ + platform: 'zalo', + configExists: true, + channelEnabled: true, + form: { botToken: 'zalo-token' }, + }) + const fileResult = buildOpenClawChannelDiagnosis({ + platform: 'zalo', + configExists: true, + channelEnabled: true, + form: { tokenFile: '/run/secrets/zalo-token' }, + }) + const missingResult = buildOpenClawChannelDiagnosis({ + platform: 'zalo', + configExists: true, + channelEnabled: true, + form: {}, + }) + + assert.equal(tokenResult.checks.find(item => item.id === 'credentials')?.ok, true) + assert.equal(fileResult.checks.find(item => item.id === 'credentials')?.ok, true) + assert.equal(missingResult.checks.find(item => item.id === 'credentials')?.ok, false) + assert.match(missingResult.checks.find(item => item.id === 'credentials')?.detail || '', /Bot Token.*Token File/) +}) + +test('Zalo Personal 保存和诊断按二维码会话型渠道处理', () => { + const form = normalizeMessagingPlatformForm('zalouser', { + profile: 'work', + dangerouslyAllowNameMatching: 'true', + allowFrom: '12345, Alice', + groupAllowFrom: 'group-1', + historyLimit: '12', + }) + const cfg = { channels: {} } + + mergeOpenClawMessagingPlatformConfig(cfg, { + platform: 'zalouser', + accountId: 'work', + form, + }) + const result = buildOpenClawChannelDiagnosis({ + platform: 'zalouser', + configExists: true, + channelEnabled: true, + form: buildMessagingPlatformFormValues('zalouser', cfg.channels.zalouser.accounts.work), + }) + + assert.equal(cfg.channels.zalouser.defaultAccount, 'work') + assert.equal(cfg.channels.zalouser.accounts.work.profile, 'work') + assert.equal(cfg.channels.zalouser.accounts.work.dangerouslyAllowNameMatching, true) + assert.deepEqual(cfg.channels.zalouser.accounts.work.allowFrom, ['12345', 'Alice']) + assert.deepEqual(cfg.channels.zalouser.accounts.work.groupAllowFrom, ['group-1']) + assert.equal(cfg.channels.zalouser.accounts.work.historyLimit, 12) + assert.equal(result.checks.find(item => item.id === 'credentials')?.ok, true) + assert.equal(result.checks.find(item => item.id === 'credentials')?.title, '登录/会话配置') +}) + test('Discord 渠道保存会保留运行时需要的 applicationId', () => { const cfg = { channels: {} }