feat(channels): add Zalo channel configuration

This commit is contained in:
晴天
2026-05-23 04:38:49 +08:00
parent 067389d65f
commit 780b1bdde5
6 changed files with 623 additions and 36 deletions

View File

@@ -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 FileWeb 模式不会读取外部文件做在线校验'] }
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', {

View File

@@ -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<String, Value>) -> 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<String, Value>) -> 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<String, Value>) -> 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::<Vec<_>>()
.join(" / ")
}
fn json_string_list(value: Option<&Value>) -> Vec<String> {
@@ -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::<Vec<_>>()
.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<String, Value>, key: &str, raw: &str) {
}
}
fn put_number_from_form(entry: &mut Map<String, Value>, key: &str, raw: &str) {
let value = raw.trim();
if value.is_empty() {
return;
}
if let Ok(number) = value.parse::<f64>() {
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<String, Value>, 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::<f64>() {
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<String, Value>, 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, &current_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<Value, St
"feishu" => 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<String, Value>) -> Result<Value, String> {
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(

View File

@@ -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',

View File

@@ -60,6 +60,37 @@ export default {
telegramGuide3: _('复制 BotFather 返回的 <strong>Bot Token</strong>', 'Copy the <strong>Bot Token</strong> returned by BotFather', '複製 BotFather 返回的 <strong>Bot Token</strong>'),
telegramGuide4: _('填入下方凭证并保存', 'Fill in credentials below and save', '填入下方憑證並儲存'),
telegramGuideFooter: _('<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">需要公网可达的服务器或使用 polling 模式</div>', '<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">需要公网可达的服务器或使用 polling 模式</div>', '<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">需要公網可達的伺服器或使用 polling 模式</div>'),
zaloDesc: _('接入 Zalo Bot API适合越南用户场景的私聊和群组消息', 'Connect Zalo Bot API for DM and group messaging in Vietnam-focused scenarios', '接入 Zalo Bot API適合越南使用者場景的私聊和群組訊息'),
zaloGuide1: _('前往 <a href="https://bot.zaloplatforms.com" target="_blank" rel="noopener">Zalo Bot Platform</a> 创建或打开机器人', 'Open <a href="https://bot.zaloplatforms.com" target="_blank" rel="noopener">Zalo Bot Platform</a> and create or open your bot', '前往 <a href="https://bot.zaloplatforms.com" target="_blank" rel="noopener">Zalo Bot Platform</a> 建立或開啟機器人'),
zaloGuide2: _('获取 <strong>Bot Token</strong>;如果使用 SecretRef 或外部文件,也可以填写 Token File', 'Get the <strong>Bot Token</strong>; if you use SecretRef or an external file, fill Token File instead', '取得 <strong>Bot Token</strong>;如果使用 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: _('<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">Zalo Bot 至少需要 Bot Token 或 Token File 其中一项。</div>', '<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">Zalo Bot requires either Bot Token or Token File.</div>', '<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">Zalo Bot 至少需要 Bot Token 或 Token File 其中一項。</div>'),
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: _('先安装 <strong>@openclaw/zalouser</strong> 插件,保存配置时面板会自动尝试安装', 'Install the <strong>@openclaw/zalouser</strong> plugin first; the panel will try to install it on save', '先安裝 <strong>@openclaw/zalouser</strong> 外掛,儲存設定時面板會自動嘗試安裝'),
zalouserGuide2: _('保存后在终端执行 <code>openclaw channels login --channel zalouser</code> 并用 Zalo 手机端扫码', 'After saving, run <code>openclaw channels login --channel zalouser</code> and scan with the Zalo mobile app', '儲存後在終端執行 <code>openclaw channels login --channel zalouser</code> 並用 Zalo 手機端掃碼'),
zalouserGuide3: _('多账号时填写账号标识,并在命令后追加 <code>--account 账号标识</code>', 'For multi-account setup, fill Account Identifier and add <code>--account account-id</code> to the command', '多帳號時填寫帳號標識,並在命令後追加 <code>--account 帳號標識</code>'),
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: _('如登录状态异常,可执行 <code>openclaw channels logout --channel zalouser</code> 后重新登录', 'If login state is unhealthy, run <code>openclaw channels logout --channel zalouser</code> and log in again', '如登入狀態異常,可執行 <code>openclaw channels logout --channel zalouser</code> 後重新登入'),
zalouserGuideFooter: _('<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">Zalo Personal 是非官方个人号自动化集成,存在账号风控风险,请谨慎使用。</div>', '<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">Zalo Personal is an unofficial personal-account automation integration and may carry account risk. Use carefully.</div>', '<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">Zalo Personal 是非官方個人號自動化整合,存在帳號風控風險,請謹慎使用。</div>'),
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: _('前往 <a href="https://discord.com/developers/applications" target="_blank" rel="noopener">Discord Developer Portal</a> 创建 Application', '前往 <a href="https://discord.com/developers/applications" target="_blank" rel="noopener">Discord Developer Portal</a> 创建 Application', '前往 <a href="https://discord.com/developers/applications" target="_blank" rel="noopener">Discord Developer Portal</a> 建立 Application'),
discordGuide2: _('在 Bot 页面点击「Reset Token」获取 <strong>Bot Token</strong>', 'Click "Reset Token" on the Bot page to get the <strong>Bot Token</strong>', '在 Bot 頁面点擊「Reset Token」取得 <strong>Bot Token</strong>'),

View File

@@ -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

View File

@@ -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: {} }