mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-29 20:30:00 +08:00
feat(channels): add Zalo channel configuration
This commit is contained in:
@@ -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', {
|
||||
|
||||
@@ -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, ¤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<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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {} }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user