feat(channels): restore WhatsApp config compatibility

This commit is contained in:
晴天
2026-05-23 07:51:16 +08:00
parent 8d745e7543
commit d933177ec3
6 changed files with 473 additions and 16 deletions

View File

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

View File

@@ -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!({

View File

@@ -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...', '等待掃碼...'),

View File

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

View File

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

View File

@@ -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'/)
})