mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-29 20:30:00 +08:00
feat(channels): restore WhatsApp config compatibility
This commit is contained in:
@@ -2476,7 +2476,7 @@ export function normalizeMessagingPlatformForm(platform, form = {}) {
|
||||
if (Object.hasOwn(normalized, key)) normalized[key] = csvToStringArray(normalized[key])
|
||||
}
|
||||
|
||||
for (const key of ['mediaMaxMb', 'historyLimit', 'dmHistoryLimit', 'textChunkLimit', 'probeTimeoutMs', 'rateLimitPerMinute', 'httpPort', 'webhookPort', 'feedbackReflectionCooldownMs']) {
|
||||
for (const key of ['mediaMaxMb', 'historyLimit', 'dmHistoryLimit', 'textChunkLimit', 'probeTimeoutMs', 'debounceMs', 'rateLimitPerMinute', 'httpPort', 'webhookPort', 'feedbackReflectionCooldownMs']) {
|
||||
if (!Object.hasOwn(normalized, key)) continue
|
||||
const value = String(normalized[key] || '').trim()
|
||||
if (!value) {
|
||||
@@ -2489,7 +2489,7 @@ export function normalizeMessagingPlatformForm(platform, form = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of ['dangerouslyAllowNameMatching', 'dangerouslyAllowPrivateNetwork', 'dangerouslyAllowInheritedWebhookPath', 'allowInsecureSsl', 'allowBots', 'blockStreaming', 'useManagedIdentity', 'typingIndicator', 'welcomeCard', 'groupWelcomeCard', 'feedbackEnabled', 'feedbackReflection', 'delegatedAuthEnabled', 'ssoEnabled', 'configWrites', 'includeAttachments', 'sendReadReceipts', 'coalesceSameSenderDms']) {
|
||||
for (const key of ['dangerouslyAllowNameMatching', 'dangerouslyAllowPrivateNetwork', 'dangerouslyAllowInheritedWebhookPath', 'allowInsecureSsl', 'allowBots', 'blockStreaming', 'useManagedIdentity', 'typingIndicator', 'welcomeCard', 'groupWelcomeCard', 'feedbackEnabled', 'feedbackReflection', 'delegatedAuthEnabled', 'ssoEnabled', 'configWrites', 'includeAttachments', 'sendReadReceipts', 'coalesceSameSenderDms', 'selfChatMode', 'ackDirect']) {
|
||||
if (Object.hasOwn(normalized, key)) {
|
||||
const value = String(normalized[key] || '').trim()
|
||||
if (!value) {
|
||||
@@ -2705,7 +2705,7 @@ function requiredChannelCredentialFields(platform, form = {}) {
|
||||
}
|
||||
|
||||
function channelDiagnosisCredentialsReady(platform, form = {}) {
|
||||
if (platformStorageKey(platform) === 'zalouser') return true
|
||||
if (['zalouser', 'whatsapp'].includes(platformStorageKey(platform))) return true
|
||||
if (platformStorageKey(platform) === 'msteams') return msteamsCredentialMissingLabels(form).length === 0
|
||||
const requiredFields = requiredChannelCredentialFields(platform, form)
|
||||
if (requiredFields.length) {
|
||||
@@ -2770,7 +2770,7 @@ export function buildOpenClawChannelDiagnosis({
|
||||
.map(group => group.label)
|
||||
const hasAnyCredential = channelRootHasMessagingCredential(form)
|
||||
const anyCredentialOk = anyFields.length ? anyFields.some(([key]) => hasConfiguredMessagingValue(form?.[key])) : false
|
||||
const credentialOk = ['zalouser', 'imessage'].includes(storageKey)
|
||||
const credentialOk = ['zalouser', 'imessage', 'whatsapp'].includes(storageKey)
|
||||
? !!configExists
|
||||
: (requiredFields.length
|
||||
? missing.length === 0
|
||||
@@ -2781,13 +2781,21 @@ export function buildOpenClawChannelDiagnosis({
|
||||
checks.push({
|
||||
id: 'credentials',
|
||||
ok: credentialOk,
|
||||
title: storageKey === 'zalouser' ? '登录/会话配置' : (storageKey === 'imessage' ? '桥接运行配置' : '必要凭证字段'),
|
||||
title: storageKey === 'zalouser'
|
||||
? '登录/会话配置'
|
||||
: (storageKey === 'imessage'
|
||||
? '桥接运行配置'
|
||||
: (storageKey === 'whatsapp' ? '扫码/会话配置' : '必要凭证字段')),
|
||||
detail: storageKey === 'zalouser'
|
||||
? 'Zalo Personal 通过二维码登录保存本地会话;配置已保存后,请按手动命令完成或刷新登录。'
|
||||
: storageKey === 'imessage'
|
||||
? (configExists
|
||||
? 'iMessage 使用本机或远端桥接运行,不需要 Bot Token;已保存基础运行配置。'
|
||||
: '尚未保存 iMessage 渠道配置,请先填写并保存。')
|
||||
: storageKey === 'whatsapp'
|
||||
? (configExists
|
||||
? 'WhatsApp 通过扫码登录保存本地会话,不需要 Bot Token;请使用扫码登录完成设备连接。'
|
||||
: '尚未保存 WhatsApp 渠道配置,请先填写并保存。')
|
||||
: (credentialOk
|
||||
? (requiredFields.length
|
||||
? `已填写 ${requiredFields.map(([, label]) => label).join(' / ')}。`
|
||||
@@ -2917,8 +2925,35 @@ export function buildMessagingPlatformFormValues(platform, saved = {}, options =
|
||||
}
|
||||
|
||||
if (storageKey === 'whatsapp') {
|
||||
putAccessPolicyFormValues(form, saved, { mentionCompat: true })
|
||||
putAccessPolicyFormValues(form, saved)
|
||||
putCsvFormValue(form, saved, 'groupAllowFrom')
|
||||
putBoolFormValue(form, saved, 'enabled')
|
||||
for (const key of ['configWrites', 'sendReadReceipts', 'selfChatMode', 'blockStreaming']) {
|
||||
putBoolFormValue(form, saved, key)
|
||||
}
|
||||
for (const key of ['defaultTo', 'contextVisibility', 'chunkMode', 'reactionLevel', 'replyToMode', 'messagePrefix', 'responsePrefix']) {
|
||||
putStringFormValue(form, saved, key)
|
||||
}
|
||||
for (const key of ['historyLimit', 'dmHistoryLimit', 'mediaMaxMb', 'debounceMs', 'textChunkLimit']) {
|
||||
if (typeof saved[key] === 'number') form[key] = String(saved[key])
|
||||
}
|
||||
if (saved?.ackReaction && typeof saved.ackReaction === 'object') {
|
||||
putStringFormValue(form, saved.ackReaction, 'emoji')
|
||||
if (form.emoji) {
|
||||
form.ackEmoji = form.emoji
|
||||
delete form.emoji
|
||||
}
|
||||
putBoolFormValue(form, saved.ackReaction, 'direct')
|
||||
if (form.direct) {
|
||||
form.ackDirect = form.direct
|
||||
delete form.direct
|
||||
}
|
||||
putStringFormValue(form, saved.ackReaction, 'group')
|
||||
if (form.group) {
|
||||
form.ackGroup = form.group
|
||||
delete form.group
|
||||
}
|
||||
}
|
||||
return form
|
||||
}
|
||||
|
||||
@@ -3653,6 +3688,26 @@ function buildOpenClawMessagingPlatformEntry(platform, form, currentSaved = {})
|
||||
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 === 'whatsapp') {
|
||||
entry.enabled = typeof form.enabled === 'boolean' ? form.enabled : true
|
||||
for (const key of ['defaultTo', 'contextVisibility', 'chunkMode', 'reactionLevel', 'replyToMode', '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
|
||||
for (const key of ['configWrites', 'sendReadReceipts', 'selfChatMode', 'blockStreaming']) {
|
||||
if (typeof form[key] === 'boolean') entry[key] = form[key]
|
||||
}
|
||||
for (const key of ['historyLimit', 'dmHistoryLimit', 'mediaMaxMb', 'debounceMs', 'textChunkLimit']) {
|
||||
if (typeof form[key] === 'number') entry[key] = form[key]
|
||||
}
|
||||
const ackReaction = { ...(currentSaved?.ackReaction && typeof currentSaved.ackReaction === 'object' ? currentSaved.ackReaction : {}) }
|
||||
if (form.ackEmoji) ackReaction.emoji = form.ackEmoji
|
||||
if (typeof form.ackDirect === 'boolean') ackReaction.direct = form.ackDirect
|
||||
if (form.ackGroup) ackReaction.group = form.ackGroup
|
||||
if (Object.keys(ackReaction).length) entry.ackReaction = ackReaction
|
||||
} else if (storageKey === 'signal') {
|
||||
for (const key of ['account', 'cliPath', 'httpUrl', 'httpHost', 'responsePrefix']) {
|
||||
if (form[key]) entry[key] = form[key]
|
||||
@@ -3786,7 +3841,7 @@ export function mergeOpenClawMessagingPlatformConfig(cfg, { platform, form, acco
|
||||
const currentSaved = resolvePlatformConfigEntry(cfg.channels?.[storageKey], platform, normalizedAccountId) || {}
|
||||
const entry = buildOpenClawMessagingPlatformEntry(platform, normalizedForm, currentSaved)
|
||||
applyMessagingPlatformEntry(cfg, storageKey, normalizedAccountId, entry)
|
||||
if (['zalo', 'zalouser', 'line', 'mattermost', 'synology-chat', 'googlechat', 'msteams', 'imessage'].includes(storageKey)) {
|
||||
if (['zalo', 'zalouser', 'line', 'mattermost', 'synology-chat', 'googlechat', 'msteams', 'imessage', 'whatsapp'].includes(storageKey)) {
|
||||
ensureMessagingPluginAllowed(cfg, storageKey)
|
||||
}
|
||||
return { entry, accountId: normalizedAccountId, storageKey }
|
||||
@@ -5256,7 +5311,7 @@ const handlers = {
|
||||
} else {
|
||||
setRootChannelEntry(entry)
|
||||
}
|
||||
} else if (['line', 'mattermost', 'synology-chat', 'googlechat', 'msteams'].includes(storageKey)) {
|
||||
} else if (['line', 'mattermost', 'synology-chat', 'googlechat', 'msteams', 'whatsapp'].includes(storageKey)) {
|
||||
const built = buildOpenClawMessagingPlatformEntry(platform, form, currentSaved)
|
||||
applyMessagingPlatformEntry(cfg, storageKey, normalizedAccountId, built)
|
||||
ensureMessagingPluginAllowed(cfg, storageKey)
|
||||
@@ -5265,7 +5320,7 @@ const handlers = {
|
||||
preserveMessagingCredentialRefs(entry, form, currentSaved)
|
||||
}
|
||||
|
||||
if (platform !== 'qqbot' && platform !== 'feishu' && platform !== 'dingtalk' && platform !== 'dingtalk-connector' && !['line', 'mattermost', 'synology-chat', 'googlechat', 'msteams'].includes(storageKey)) {
|
||||
if (platform !== 'qqbot' && platform !== 'feishu' && platform !== 'dingtalk' && platform !== 'dingtalk-connector' && !['line', 'mattermost', 'synology-chat', 'googlechat', 'msteams', 'whatsapp'].includes(storageKey)) {
|
||||
preserveMessagingCredentialRefs(entry, form, currentSaved)
|
||||
// 合并模式:保留用户通过 CLI 或手动编辑的自定义字段
|
||||
applyMessagingPlatformEntry(cfg, storageKey, normalizedAccountId, entry)
|
||||
@@ -5387,6 +5442,9 @@ const handlers = {
|
||||
if (platform === 'zalouser') {
|
||||
return { valid: true, warnings: ['Zalo Personal 通过二维码登录维护本地会话;请使用 openclaw channels status --probe 检查登录状态'] }
|
||||
}
|
||||
if (platform === 'whatsapp') {
|
||||
return { valid: true, warnings: ['WhatsApp 使用扫码登录维护本地会话,无需在线校验 Bot Token;请通过「启动扫码登录」完成配对。'] }
|
||||
}
|
||||
if (platform === 'discord') {
|
||||
try {
|
||||
const resp = await fetch('https://discord.com/api/v10/users/@me', {
|
||||
|
||||
@@ -334,7 +334,10 @@ fn channel_any_credential_groups(
|
||||
}
|
||||
|
||||
fn channel_diagnosis_credentials_ready(platform: &str, form: &Map<String, Value>) -> bool {
|
||||
if matches!(platform_storage_key(platform), "zalouser" | "imessage") {
|
||||
if matches!(
|
||||
platform_storage_key(platform),
|
||||
"zalouser" | "imessage" | "whatsapp"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if platform_storage_key(platform) == "msteams" {
|
||||
@@ -465,7 +468,7 @@ fn build_openclaw_channel_diagnosis(
|
||||
.iter()
|
||||
.any(|(key, _)| has_configured_messaging_value(form.get(*key)))
|
||||
};
|
||||
let credential_ok = if matches!(storage_key, "zalouser" | "imessage") {
|
||||
let credential_ok = if matches!(storage_key, "zalouser" | "imessage" | "whatsapp") {
|
||||
config_exists
|
||||
} else if !required_fields.is_empty() {
|
||||
missing.is_empty()
|
||||
@@ -485,6 +488,8 @@ fn build_openclaw_channel_diagnosis(
|
||||
"登录/会话配置"
|
||||
} else if storage_key == "imessage" {
|
||||
"桥接运行配置"
|
||||
} else if storage_key == "whatsapp" {
|
||||
"扫码/会话配置"
|
||||
} else {
|
||||
"必要凭证字段"
|
||||
},
|
||||
@@ -496,6 +501,12 @@ fn build_openclaw_channel_diagnosis(
|
||||
} else {
|
||||
"尚未保存 iMessage 渠道配置,请先填写并保存。".to_string()
|
||||
}
|
||||
} else if storage_key == "whatsapp" {
|
||||
if config_exists {
|
||||
"WhatsApp 使用扫码登录保存本地会话,不需要 Bot Token;已保存扫码运行配置。".to_string()
|
||||
} else {
|
||||
"尚未保存 WhatsApp 渠道配置,请先填写并保存,再启动扫码登录。".to_string()
|
||||
}
|
||||
} else if credential_ok {
|
||||
if !required_fields.is_empty() {
|
||||
format!("已填写 {}。", required_labels)
|
||||
@@ -889,6 +900,7 @@ fn normalize_messaging_platform_form(
|
||||
normalize_numeric_form_value(&mut normalized, "dmHistoryLimit");
|
||||
normalize_numeric_form_value(&mut normalized, "textChunkLimit");
|
||||
normalize_numeric_form_value(&mut normalized, "probeTimeoutMs");
|
||||
normalize_numeric_form_value(&mut normalized, "debounceMs");
|
||||
normalize_numeric_form_value(&mut normalized, "rateLimitPerMinute");
|
||||
normalize_numeric_form_value(&mut normalized, "httpPort");
|
||||
normalize_numeric_form_value(&mut normalized, "webhookPort");
|
||||
@@ -911,6 +923,7 @@ fn normalize_messaging_platform_form(
|
||||
"dangerouslyAllowPrivateNetwork",
|
||||
"dangerouslyAllowInheritedWebhookPath",
|
||||
"allowInsecureSsl",
|
||||
"enabled",
|
||||
"allowBots",
|
||||
"blockStreaming",
|
||||
"useManagedIdentity",
|
||||
@@ -925,6 +938,8 @@ fn normalize_messaging_platform_form(
|
||||
"includeAttachments",
|
||||
"sendReadReceipts",
|
||||
"coalesceSameSenderDms",
|
||||
"selfChatMode",
|
||||
"ackDirect",
|
||||
] {
|
||||
if normalized.contains_key(key) {
|
||||
let value = match normalized.get(key) {
|
||||
@@ -1512,7 +1527,50 @@ pub async fn read_platform_config(
|
||||
}
|
||||
"whatsapp" => {
|
||||
insert_access_policy_form_values(&mut form, &saved, false, false);
|
||||
insert_array_as_csv(&mut form, &saved, "groupAllowFrom");
|
||||
insert_bool_as_string(&mut form, &saved, "enabled");
|
||||
for key in [
|
||||
"configWrites",
|
||||
"sendReadReceipts",
|
||||
"selfChatMode",
|
||||
"blockStreaming",
|
||||
] {
|
||||
insert_bool_as_string(&mut form, &saved, key);
|
||||
}
|
||||
for key in [
|
||||
"defaultTo",
|
||||
"contextVisibility",
|
||||
"chunkMode",
|
||||
"reactionLevel",
|
||||
"replyToMode",
|
||||
"messagePrefix",
|
||||
"responsePrefix",
|
||||
] {
|
||||
insert_string_if_present(&mut form, &saved, key);
|
||||
}
|
||||
for key in [
|
||||
"historyLimit",
|
||||
"dmHistoryLimit",
|
||||
"mediaMaxMb",
|
||||
"debounceMs",
|
||||
"textChunkLimit",
|
||||
] {
|
||||
insert_number_as_string(&mut form, &saved, key);
|
||||
}
|
||||
if let Some(ack_reaction) = saved.get("ackReaction") {
|
||||
if let Some(v) = ack_reaction.get("emoji").and_then(|v| v.as_str()) {
|
||||
form.insert("ackEmoji".into(), Value::String(v.into()));
|
||||
}
|
||||
if let Some(v) = ack_reaction.get("direct").and_then(|v| v.as_bool()) {
|
||||
form.insert(
|
||||
"ackDirect".into(),
|
||||
Value::String(if v { "true" } else { "false" }.into()),
|
||||
);
|
||||
}
|
||||
if let Some(v) = ack_reaction.get("group").and_then(|v| v.as_str()) {
|
||||
form.insert("ackGroup".into(), Value::String(v.into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
"signal" => {
|
||||
insert_string_if_present(&mut form, &saved, "account");
|
||||
@@ -2261,6 +2319,18 @@ pub async fn save_messaging_platform(
|
||||
"whatsapp" => {
|
||||
let mut entry = Map::new();
|
||||
entry.insert("enabled".into(), Value::Bool(true));
|
||||
put_bool_value_if_present(&mut entry, "enabled", form_obj.get("enabled"));
|
||||
for key in [
|
||||
"defaultTo",
|
||||
"contextVisibility",
|
||||
"chunkMode",
|
||||
"reactionLevel",
|
||||
"replyToMode",
|
||||
"messagePrefix",
|
||||
"responsePrefix",
|
||||
] {
|
||||
put_string(&mut entry, key, form_string(form_obj, key));
|
||||
}
|
||||
put_string(&mut entry, "dmPolicy", form_string(form_obj, "dmPolicy"));
|
||||
put_string(
|
||||
&mut entry,
|
||||
@@ -2268,13 +2338,50 @@ pub async fn save_messaging_platform(
|
||||
form_string(form_obj, "groupPolicy"),
|
||||
);
|
||||
put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom"));
|
||||
put_bool_from_form(&mut entry, "enabled", &form_string(form_obj, "enabled"));
|
||||
put_array_from_form_value(&mut entry, "groupAllowFrom", form_obj.get("groupAllowFrom"));
|
||||
for key in [
|
||||
"configWrites",
|
||||
"sendReadReceipts",
|
||||
"selfChatMode",
|
||||
"blockStreaming",
|
||||
] {
|
||||
put_bool_value_if_present(&mut entry, key, form_obj.get(key));
|
||||
}
|
||||
for key in [
|
||||
"historyLimit",
|
||||
"dmHistoryLimit",
|
||||
"mediaMaxMb",
|
||||
"debounceMs",
|
||||
"textChunkLimit",
|
||||
] {
|
||||
put_number_value_if_present(&mut entry, key, form_obj.get(key));
|
||||
}
|
||||
let mut ack_reaction = current_saved
|
||||
.get("ackReaction")
|
||||
.and_then(|v| v.as_object())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
put_string(
|
||||
&mut ack_reaction,
|
||||
"emoji",
|
||||
form_string(form_obj, "ackEmoji"),
|
||||
);
|
||||
put_bool_value_if_present(&mut ack_reaction, "direct", form_obj.get("ackDirect"));
|
||||
put_string(
|
||||
&mut ack_reaction,
|
||||
"group",
|
||||
form_string(form_obj, "ackGroup"),
|
||||
);
|
||||
if !ack_reaction.is_empty() {
|
||||
entry.insert("ackReaction".into(), Value::Object(ack_reaction));
|
||||
}
|
||||
merge_channel_entry_for_account(
|
||||
channels_map,
|
||||
&storage_key,
|
||||
account_id.as_deref(),
|
||||
entry,
|
||||
)?;
|
||||
ensure_plugin_allowed(&mut cfg, "whatsapp")?;
|
||||
}
|
||||
"signal" => {
|
||||
let account = form_string(form_obj, "account");
|
||||
@@ -5960,6 +6067,83 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_whatsapp_form_preserves_scan_runtime_fields() {
|
||||
let form = json!({
|
||||
"enabled": "true",
|
||||
"configWrites": "true",
|
||||
"sendReadReceipts": "false",
|
||||
"selfChatMode": "true",
|
||||
"dmPolicy": "allowlist",
|
||||
"allowFrom": "+15551234567, +15557654321",
|
||||
"groupPolicy": "allowlist",
|
||||
"groupAllowFrom": "120363@g.us, 120364@g.us",
|
||||
"debounceMs": "800",
|
||||
"mediaMaxMb": "50",
|
||||
"ackDirect": "true",
|
||||
"ackGroup": "mentions"
|
||||
});
|
||||
let normalized =
|
||||
normalize_messaging_platform_form("whatsapp", form.as_object().expect("object"));
|
||||
|
||||
assert_eq!(
|
||||
normalized.get("enabled").and_then(|v| v.as_bool()),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
normalized.get("configWrites").and_then(|v| v.as_bool()),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
normalized.get("sendReadReceipts").and_then(|v| v.as_bool()),
|
||||
Some(false)
|
||||
);
|
||||
assert_eq!(
|
||||
normalized.get("selfChatMode").and_then(|v| v.as_bool()),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
normalized.get("debounceMs").and_then(|v| v.as_f64()),
|
||||
Some(800.0)
|
||||
);
|
||||
assert_eq!(
|
||||
normalized.get("mediaMaxMb").and_then(|v| v.as_f64()),
|
||||
Some(50.0)
|
||||
);
|
||||
assert_eq!(
|
||||
normalized.get("ackDirect").and_then(|v| v.as_bool()),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
normalized
|
||||
.get("allowFrom")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|items| items.len()),
|
||||
Some(2)
|
||||
);
|
||||
assert_eq!(
|
||||
normalized
|
||||
.get("groupAllowFrom")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|items| items.len()),
|
||||
Some(2)
|
||||
);
|
||||
assert!(channel_diagnosis_credentials_ready("whatsapp", &normalized));
|
||||
let diagnosis =
|
||||
build_openclaw_channel_diagnosis("whatsapp", None, true, true, &normalized, None, None);
|
||||
assert_eq!(
|
||||
diagnosis
|
||||
.get("checks")
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|items| items
|
||||
.iter()
|
||||
.find(|item| item.get("id").and_then(|v| v.as_str()) == Some("credentials")))
|
||||
.and_then(|item| item.get("title"))
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("扫码/会话配置")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_form_readback_preserves_mention_policy_choice() {
|
||||
let saved = json!({
|
||||
|
||||
@@ -364,6 +364,24 @@ export default {
|
||||
gatewayNotConnected: _('Gateway 未连接', 'Gateway not connected', 'Gateway 未連線'),
|
||||
generatingQr: _('正在生成二维码...', 'Generating QR code...', '正在生成二维碼...'),
|
||||
generatingQrShort: _('生成二维码...', 'Generating QR...', '生成二维碼...'),
|
||||
whatsappDesc: _('通过 WhatsApp 个人号扫码登录接入,支持私聊、群组和本地会话运行参数', 'Connect a WhatsApp personal account via QR login, with DM, group, and local session runtime options', '透過 WhatsApp 個人號掃碼登入接入,支援私聊、群組和本地會話執行參數'),
|
||||
whatsappGuide1: _('先安装 <strong>@openclaw/whatsapp</strong> 插件,保存配置时面板会自动尝试启用插件', 'Install the <strong>@openclaw/whatsapp</strong> plugin first; the panel will try to enable it on save', '先安裝 <strong>@openclaw/whatsapp</strong> 外掛,儲存設定時面板會自動嘗試啟用外掛'),
|
||||
whatsappGuide2: _('保存配置后点击「启动扫码登录」,用手机 WhatsApp 扫描二维码完成设备链接', 'After saving config, click "Start QR Login" and scan the QR code with WhatsApp to link the device', '儲存設定後點擊「啟動掃碼登入」,用手機 WhatsApp 掃描 QR code 完成裝置連結'),
|
||||
whatsappGuide3: _('Allow From / Group Allow From 推荐填写手机号或群组 JID,留空则按策略默认处理', 'Use phone numbers or group JIDs for Allow From / Group Allow From; leave empty to use policy defaults', 'Allow From / Group Allow From 建議填寫手機號或群組 JID,留空則按策略預設處理'),
|
||||
whatsappGuide4: _('如扫码入口提示插件未加载,请保存配置、重启 Gateway,并确认插件已在 OpenClaw 中加载', 'If QR login says the plugin is not loaded, save config, restart Gateway, and confirm the plugin is loaded by OpenClaw', '如掃碼入口提示外掛未載入,請儲存設定、重啟 Gateway,並確認外掛已在 OpenClaw 中載入'),
|
||||
whatsappGuideFooter: _('<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">WhatsApp 使用本地扫码会话,不需要 Bot Token;建议使用独立号码并注意账号风控风险。</div>', '<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">WhatsApp uses a local QR-linked session and does not need a Bot Token; a separate number is recommended because account risk may apply.</div>', '<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">WhatsApp 使用本地掃碼會話,不需要 Bot Token;建議使用獨立號碼並注意帳號風控風險。</div>'),
|
||||
whatsappLogin: _('启动扫码登录', 'Start QR Login', '啟動掃碼登入'),
|
||||
whatsappLoginHint: _('通过 Gateway WebSocket 启动 WhatsApp 二维码登录流程', 'Start the WhatsApp QR login flow through Gateway WebSocket', '透過 Gateway WebSocket 啟動 WhatsApp QR code 登入流程'),
|
||||
whatsappSelfChatMode: _('自聊模式', 'Self Chat Mode', '自聊模式'),
|
||||
whatsappSelfChatHint: _('开启后可将自己的 WhatsApp 会话用于本地测试或个人助手场景。', 'Enable this to use your own WhatsApp chat for local testing or personal assistant scenarios.', '開啟後可將自己的 WhatsApp 會話用於本地測試或個人助理場景。'),
|
||||
whatsappAllowFromPh: _('可选,逗号分隔手机号或联系人 JID', 'Optional, comma-separated phone numbers or contact JIDs', '可選,逗號分隔手機號或聯絡人 JID'),
|
||||
whatsappGroupAllowFromPh: _('可选,逗号分隔群组 JID,例如 120363...@g.us', 'Optional, comma-separated group JIDs, e.g. 120363...@g.us', '可選,逗號分隔群組 JID,例如 120363...@g.us'),
|
||||
whatsappDebounceHint: _('合并短时间内连续消息的等待时间,单位毫秒。', 'Delay used to coalesce rapid messages, in milliseconds.', '合併短時間內連續訊息的等待時間,單位毫秒。'),
|
||||
whatsappReadReceipts: _('发送已读回执', 'Send read receipts', '傳送已讀回執'),
|
||||
whatsappConfigWrites: _('允许配置写入', 'Allow config writes', '允許設定寫入'),
|
||||
whatsappAckEmoji: _('确认反应表情', 'Ack reaction emoji', '確認反應表情'),
|
||||
whatsappAckDirect: _('私聊确认反应', 'Ack direct chats', '私聊確認反應'),
|
||||
whatsappAckGroup: _('群组确认反应', 'Ack group chats', '群組確認反應'),
|
||||
whatsappScanQr: _('用手机 WhatsApp 扫描此二维码', 'Scan this QR code with WhatsApp on your phone', '用手機 WhatsApp 掃描此二维碼'),
|
||||
whatsappScanPath: _('WhatsApp → 已连接的设备 → 连接设备', 'WhatsApp → Linked Devices → Link a Device', 'WhatsApp → 已連線的設備 → 連線設備'),
|
||||
waitingScan: _('等待扫码...', 'Waiting for scan...', '等待掃碼...'),
|
||||
|
||||
@@ -395,8 +395,76 @@ const PLATFORM_REGISTRY = {
|
||||
configKey: 'slack',
|
||||
pairingChannel: 'slack',
|
||||
},
|
||||
// WhatsApp 已移除:上游插件运行时未加载,web.login.start 返回 "not available"
|
||||
// 等上游修复后可重新启用
|
||||
whatsapp: {
|
||||
label: 'WhatsApp',
|
||||
iconName: 'message-circle',
|
||||
desc: t('channels.whatsappDesc'),
|
||||
guide: [
|
||||
t('channels.whatsappGuide1'),
|
||||
t('channels.whatsappGuide2'),
|
||||
t('channels.whatsappGuide3'),
|
||||
t('channels.whatsappGuide4'),
|
||||
],
|
||||
guideFooter: t('channels.whatsappGuideFooter'),
|
||||
actions: [
|
||||
{ id: 'login', label: t('channels.whatsappLogin'), hint: t('channels.whatsappLoginHint'), useGatewayLogin: true },
|
||||
],
|
||||
fields: [
|
||||
{ key: 'selfChatMode', label: t('channels.whatsappSelfChatMode'), type: 'select', options: BOOLEAN_OPTIONS, required: false, hint: t('channels.whatsappSelfChatHint') },
|
||||
{ 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.whatsappAllowFromPh'), required: false, hint: t('channels.allowFromHint') },
|
||||
{ key: 'defaultTo', label: 'Default To', placeholder: t('channels.optionalEg', { example: '+15550001111' }), required: false },
|
||||
{ key: 'groupAllowFrom', label: 'Group Allow From', placeholder: t('channels.whatsappGroupAllowFromPh'), required: false, hint: t('channels.groupAllowFromHint') },
|
||||
{ key: 'historyLimit', label: 'History Limit', placeholder: '80', required: false },
|
||||
{ key: 'dmHistoryLimit', label: 'DM History Limit', placeholder: '20', required: false },
|
||||
{ key: 'mediaMaxMb', label: 'Media Max MB', placeholder: '50', required: false },
|
||||
{ key: 'debounceMs', label: 'Debounce MS', placeholder: '800', required: false, hint: t('channels.whatsappDebounceHint') },
|
||||
{ key: 'textChunkLimit', label: 'Text Chunk Limit', placeholder: '1800', required: false },
|
||||
{ key: 'contextVisibility', label: 'Context Visibility', type: 'select', options: [
|
||||
{ value: '', label: t('channels.policyDefault') },
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'allowlist', label: 'Allowlist' },
|
||||
{ value: 'allowlist_quote', label: 'Allowlist + Quote' },
|
||||
], required: false },
|
||||
{ key: 'chunkMode', label: 'Chunk Mode', type: 'select', options: [
|
||||
{ value: '', label: t('channels.policyDefault') },
|
||||
{ value: 'length', label: 'Length' },
|
||||
{ value: 'newline', label: 'Newline' },
|
||||
], required: false },
|
||||
{ key: 'blockStreaming', label: t('channels.signalBlockStreaming'), type: 'select', options: BOOLEAN_OPTIONS, required: false },
|
||||
{ key: 'sendReadReceipts', label: t('channels.whatsappReadReceipts'), type: 'select', options: BOOLEAN_OPTIONS, required: false },
|
||||
{ key: 'configWrites', label: t('channels.whatsappConfigWrites'), type: 'select', options: BOOLEAN_OPTIONS, required: false },
|
||||
{ key: 'reactionLevel', label: 'Reaction Level', type: 'select', options: [
|
||||
{ value: '', label: t('channels.policyDefault') },
|
||||
{ value: 'off', label: t('channels.disable') },
|
||||
{ value: 'ack', label: 'Ack' },
|
||||
{ value: 'minimal', label: 'Minimal' },
|
||||
{ value: 'extensive', label: 'Extensive' },
|
||||
], required: false },
|
||||
{ key: 'replyToMode', label: 'Reply To Mode', type: 'select', options: [
|
||||
{ value: '', label: t('channels.policyDefault') },
|
||||
{ value: 'off', label: t('channels.disable') },
|
||||
{ value: 'first', label: 'First' },
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'batched', label: 'Batched' },
|
||||
], required: false },
|
||||
{ key: 'ackEmoji', label: t('channels.whatsappAckEmoji'), placeholder: t('channels.optionalEg', { example: '✅' }), required: false },
|
||||
{ key: 'ackDirect', label: t('channels.whatsappAckDirect'), type: 'select', options: BOOLEAN_OPTIONS, required: false },
|
||||
{ key: 'ackGroup', label: t('channels.whatsappAckGroup'), type: 'select', options: [
|
||||
{ value: '', label: t('channels.policyDefault') },
|
||||
{ value: 'always', label: 'Always' },
|
||||
{ value: 'mentions', label: 'Mentions' },
|
||||
{ value: 'never', label: 'Never' },
|
||||
], required: false },
|
||||
{ key: 'messagePrefix', label: 'Message Prefix', placeholder: t('channels.optionalEg', { example: '[WA]' }), required: false },
|
||||
{ key: 'responsePrefix', label: 'Response Prefix', placeholder: t('channels.optionalEg', { example: '[AI]' }), required: false },
|
||||
],
|
||||
configKey: 'whatsapp',
|
||||
pairingChannel: 'whatsapp',
|
||||
pluginRequired: '@openclaw/whatsapp@latest',
|
||||
pluginId: 'whatsapp',
|
||||
},
|
||||
weixin: {
|
||||
label: t('channels.weixinLabel'),
|
||||
iconName: 'message-circle',
|
||||
|
||||
@@ -49,6 +49,108 @@ test('渠道保存不会向不支持顶层 requireMention 的平台写入非法
|
||||
assert.equal(Object.hasOwn(form, 'requireMention'), false)
|
||||
})
|
||||
|
||||
test('WhatsApp 渠道保存会写入扫码运行和访问策略字段并启用插件', () => {
|
||||
const cfg = { channels: {} }
|
||||
|
||||
mergeOpenClawMessagingPlatformConfig(cfg, {
|
||||
platform: 'whatsapp',
|
||||
accountId: 'phone-a',
|
||||
form: {
|
||||
enabled: 'true',
|
||||
configWrites: 'true',
|
||||
sendReadReceipts: 'false',
|
||||
selfChatMode: 'true',
|
||||
dmPolicy: 'allowlist',
|
||||
allowFrom: '+15551234567, +15557654321',
|
||||
defaultTo: '+15550001111',
|
||||
groupPolicy: 'allowlist',
|
||||
groupAllowFrom: '120363@g.us, 120364@g.us',
|
||||
contextVisibility: 'allowlist_quote',
|
||||
historyLimit: '80',
|
||||
dmHistoryLimit: '20',
|
||||
mediaMaxMb: '50',
|
||||
debounceMs: '800',
|
||||
textChunkLimit: '1800',
|
||||
chunkMode: 'newline',
|
||||
blockStreaming: 'true',
|
||||
reactionLevel: 'ack',
|
||||
replyToMode: 'first',
|
||||
messagePrefix: '[WA]',
|
||||
responsePrefix: '[AI]',
|
||||
ackEmoji: '✅',
|
||||
ackDirect: 'true',
|
||||
ackGroup: 'mentions',
|
||||
},
|
||||
})
|
||||
|
||||
const root = cfg.channels.whatsapp
|
||||
const account = root.accounts['phone-a']
|
||||
assert.equal(root.defaultAccount, 'phone-a')
|
||||
assert.equal(account.enabled, true)
|
||||
assert.equal(account.configWrites, true)
|
||||
assert.equal(account.sendReadReceipts, false)
|
||||
assert.equal(account.selfChatMode, true)
|
||||
assert.equal(account.dmPolicy, 'allowlist')
|
||||
assert.deepEqual(account.allowFrom, ['+15551234567', '+15557654321'])
|
||||
assert.equal(account.defaultTo, '+15550001111')
|
||||
assert.equal(account.groupPolicy, 'allowlist')
|
||||
assert.deepEqual(account.groupAllowFrom, ['120363@g.us', '120364@g.us'])
|
||||
assert.equal(account.contextVisibility, 'allowlist_quote')
|
||||
assert.equal(account.historyLimit, 80)
|
||||
assert.equal(account.dmHistoryLimit, 20)
|
||||
assert.equal(account.mediaMaxMb, 50)
|
||||
assert.equal(account.debounceMs, 800)
|
||||
assert.equal(account.textChunkLimit, 1800)
|
||||
assert.equal(account.chunkMode, 'newline')
|
||||
assert.equal(account.blockStreaming, true)
|
||||
assert.equal(account.reactionLevel, 'ack')
|
||||
assert.equal(account.replyToMode, 'first')
|
||||
assert.equal(account.messagePrefix, '[WA]')
|
||||
assert.equal(account.responsePrefix, '[AI]')
|
||||
assert.deepEqual(account.ackReaction, { emoji: '✅', direct: true, group: 'mentions' })
|
||||
assert.equal(cfg.plugins.entries.whatsapp.enabled, true)
|
||||
})
|
||||
|
||||
test('WhatsApp 读取会回显扫码运行字段且诊断不要求 Bot Token', () => {
|
||||
const values = buildMessagingPlatformFormValues('whatsapp', {
|
||||
enabled: true,
|
||||
configWrites: true,
|
||||
sendReadReceipts: false,
|
||||
selfChatMode: true,
|
||||
dmPolicy: 'open',
|
||||
allowFrom: ['*'],
|
||||
groupPolicy: 'allowlist',
|
||||
groupAllowFrom: ['120363@g.us'],
|
||||
historyLimit: 50,
|
||||
debounceMs: 800,
|
||||
mediaMaxMb: 50,
|
||||
blockStreaming: true,
|
||||
ackReaction: { emoji: '✅', direct: true, group: 'mentions' },
|
||||
})
|
||||
const diagnosis = buildOpenClawChannelDiagnosis({
|
||||
platform: 'whatsapp',
|
||||
configExists: true,
|
||||
channelEnabled: true,
|
||||
form: values,
|
||||
})
|
||||
|
||||
assert.equal(values.enabled, 'true')
|
||||
assert.equal(values.configWrites, 'true')
|
||||
assert.equal(values.sendReadReceipts, 'false')
|
||||
assert.equal(values.selfChatMode, 'true')
|
||||
assert.equal(values.allowFrom, '*')
|
||||
assert.equal(values.groupAllowFrom, '120363@g.us')
|
||||
assert.equal(values.historyLimit, '50')
|
||||
assert.equal(values.debounceMs, '800')
|
||||
assert.equal(values.mediaMaxMb, '50')
|
||||
assert.equal(values.blockStreaming, 'true')
|
||||
assert.equal(values.ackEmoji, '✅')
|
||||
assert.equal(values.ackDirect, 'true')
|
||||
assert.equal(values.ackGroup, 'mentions')
|
||||
assert.equal(diagnosis.checks.find(item => item.id === 'credentials')?.ok, true)
|
||||
assert.match(diagnosis.checks.find(item => item.id === 'credentials')?.title || '', /扫码|会话/)
|
||||
})
|
||||
|
||||
test('Signal 渠道保存会保留多账号和上游运行字段', () => {
|
||||
const cfg = { channels: {} }
|
||||
|
||||
|
||||
@@ -7,8 +7,15 @@ const channelsPageSource = readFileSync(new URL('../src/pages/channels.js', impo
|
||||
function getRegistryBlock(platformId) {
|
||||
const start = channelsPageSource.indexOf(` ${platformId}: {`)
|
||||
assert.notEqual(start, -1, `未找到 ${platformId} 渠道注册表`)
|
||||
const next = channelsPageSource.indexOf('\n slack: {', start + 1)
|
||||
return channelsPageSource.slice(start, next === -1 ? undefined : next)
|
||||
const braceStart = channelsPageSource.indexOf('{', start)
|
||||
let depth = 0
|
||||
for (let index = braceStart; index < channelsPageSource.length; index += 1) {
|
||||
const char = channelsPageSource[index]
|
||||
if (char === '{') depth += 1
|
||||
if (char === '}') depth -= 1
|
||||
if (depth === 0) return channelsPageSource.slice(start, index + 1)
|
||||
}
|
||||
assert.fail(`未找到 ${platformId} 渠道注册表结束位置`)
|
||||
}
|
||||
|
||||
test('Discord 渠道 UI 会暴露服务器频道 allowlist 配置字段', () => {
|
||||
@@ -39,3 +46,23 @@ test('iMessage 渠道 UI 会暴露桥接运行配置字段', () => {
|
||||
assert.match(imessageBlock, /pluginRequired:\s*'@openclaw\/imessage@latest'/)
|
||||
assert.match(imessageBlock, /pluginId:\s*'imessage'/)
|
||||
})
|
||||
|
||||
test('WhatsApp 渠道 UI 会恢复扫码登录和运行配置入口', () => {
|
||||
const whatsappBlock = getRegistryBlock('whatsapp')
|
||||
|
||||
for (const field of [
|
||||
'selfChatMode',
|
||||
'allowFrom',
|
||||
'groupAllowFrom',
|
||||
'debounceMs',
|
||||
'mediaMaxMb',
|
||||
'sendReadReceipts',
|
||||
'ackEmoji',
|
||||
'ackGroup',
|
||||
]) {
|
||||
assert.match(whatsappBlock, new RegExp(`key:\\s*'${field}'`))
|
||||
}
|
||||
assert.match(whatsappBlock, /id:\s*'login'/)
|
||||
assert.match(whatsappBlock, /pluginRequired:\s*'@openclaw\/whatsapp@latest'/)
|
||||
assert.match(whatsappBlock, /pluginId:\s*'whatsapp'/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user