diff --git a/scripts/dev-api.js b/scripts/dev-api.js index e570cca..0a5fb67 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -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', { diff --git a/src-tauri/src/commands/messaging.rs b/src-tauri/src/commands/messaging.rs index 05257f7..ace20f7 100644 --- a/src-tauri/src/commands/messaging.rs +++ b/src-tauri/src/commands/messaging.rs @@ -334,7 +334,10 @@ fn channel_any_credential_groups( } fn channel_diagnosis_credentials_ready(platform: &str, form: &Map) -> 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!({ diff --git a/src/locales/modules/channels.js b/src/locales/modules/channels.js index bafd977..77e6cdb 100644 --- a/src/locales/modules/channels.js +++ b/src/locales/modules/channels.js @@ -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: _('先安装 @openclaw/whatsapp 插件,保存配置时面板会自动尝试启用插件', 'Install the @openclaw/whatsapp plugin first; the panel will try to enable it on save', '先安裝 @openclaw/whatsapp 外掛,儲存設定時面板會自動嘗試啟用外掛'), + 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: _('
WhatsApp 使用本地扫码会话,不需要 Bot Token;建议使用独立号码并注意账号风控风险。
', '
WhatsApp uses a local QR-linked session and does not need a Bot Token; a separate number is recommended because account risk may apply.
', '
WhatsApp 使用本地掃碼會話,不需要 Bot Token;建議使用獨立號碼並注意帳號風控風險。
'), + 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...', '等待掃碼...'), diff --git a/src/pages/channels.js b/src/pages/channels.js index 0ebc18d..e0f07aa 100644 --- a/src/pages/channels.js +++ b/src/pages/channels.js @@ -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', diff --git a/tests/channel-config-normalization.test.js b/tests/channel-config-normalization.test.js index 5929f09..2c40891 100644 --- a/tests/channel-config-normalization.test.js +++ b/tests/channel-config-normalization.test.js @@ -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: {} } diff --git a/tests/channel-ui-registry.test.js b/tests/channel-ui-registry.test.js index ad41ee8..4db060b 100644 --- a/tests/channel-ui-registry.test.js +++ b/tests/channel-ui-registry.test.js @@ -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'/) +})