feat(channels): add iMessage config compatibility

This commit is contained in:
晴天
2026-05-23 07:31:06 +08:00
parent 6c947a1fec
commit 8d745e7543
6 changed files with 405 additions and 11 deletions

View File

@@ -2438,7 +2438,7 @@ export function normalizeMessagingPlatformForm(platform, form = {}) {
if (!Object.hasOwn(normalized, 'allowFrom') && Object.hasOwn(normalized, 'allowedUsers')) {
normalized.allowFrom = normalized.allowedUsers
}
const needsAccessDefaults = ['telegram', 'discord', 'feishu', 'slack', 'signal', 'msteams', 'whatsapp', 'zalo', 'zalouser', 'line', 'mattermost', 'googlechat'].includes(storageKey)
const needsAccessDefaults = ['telegram', 'discord', 'feishu', 'slack', 'signal', 'msteams', 'whatsapp', 'zalo', 'zalouser', 'line', 'mattermost', 'googlechat', 'imessage'].includes(storageKey)
const hasDmField = Object.hasOwn(normalized, 'dmPolicy') || needsAccessDefaults
const hasGroupField = Object.hasOwn(normalized, 'groupPolicy') || needsAccessDefaults
@@ -2472,11 +2472,11 @@ export function normalizeMessagingPlatformForm(platform, form = {}) {
normalized.allowedUserIds = csvToStringArray(normalized.allowedUserIds)
}
for (const key of ['promptStarters', 'delegatedAuthScopes']) {
for (const key of ['promptStarters', 'delegatedAuthScopes', 'attachmentRoots', 'remoteAttachmentRoots']) {
if (Object.hasOwn(normalized, key)) normalized[key] = csvToStringArray(normalized[key])
}
for (const key of ['mediaMaxMb', 'historyLimit', 'dmHistoryLimit', 'textChunkLimit', 'rateLimitPerMinute', 'httpPort', 'webhookPort', 'feedbackReflectionCooldownMs']) {
for (const key of ['mediaMaxMb', 'historyLimit', 'dmHistoryLimit', 'textChunkLimit', 'probeTimeoutMs', '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']) {
for (const key of ['dangerouslyAllowNameMatching', 'dangerouslyAllowPrivateNetwork', 'dangerouslyAllowInheritedWebhookPath', 'allowInsecureSsl', 'allowBots', 'blockStreaming', 'useManagedIdentity', 'typingIndicator', 'welcomeCard', 'groupWelcomeCard', 'feedbackEnabled', 'feedbackReflection', 'delegatedAuthEnabled', 'ssoEnabled', 'configWrites', 'includeAttachments', 'sendReadReceipts', 'coalesceSameSenderDms']) {
if (Object.hasOwn(normalized, key)) {
const value = String(normalized[key] || '').trim()
if (!value) {
@@ -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 = storageKey === 'zalouser'
const credentialOk = ['zalouser', 'imessage'].includes(storageKey)
? !!configExists
: (requiredFields.length
? missing.length === 0
@@ -2781,9 +2781,13 @@ export function buildOpenClawChannelDiagnosis({
checks.push({
id: 'credentials',
ok: credentialOk,
title: storageKey === 'zalouser' ? '登录/会话配置' : '必要凭证字段',
title: storageKey === 'zalouser' ? '登录/会话配置' : (storageKey === 'imessage' ? '桥接运行配置' : '必要凭证字段'),
detail: storageKey === 'zalouser'
? 'Zalo Personal 通过二维码登录保存本地会话;配置已保存后,请按手动命令完成或刷新登录。'
: storageKey === 'imessage'
? (configExists
? 'iMessage 使用本机或远端桥接运行,不需要 Bot Token已保存基础运行配置。'
: '尚未保存 iMessage 渠道配置,请先填写并保存。')
: (credentialOk
? (requiredFields.length
? `已填写 ${requiredFields.map(([, label]) => label).join(' / ')}`
@@ -3662,6 +3666,22 @@ function buildOpenClawMessagingPlatformEntry(platform, form, currentSaved = {})
for (const key of ['historyLimit', 'dmHistoryLimit', 'textChunkLimit', 'mediaMaxMb']) {
if (typeof form[key] === 'number') entry[key] = form[key]
}
} else if (storageKey === 'imessage') {
for (const key of ['cliPath', 'dbPath', 'remoteHost', 'service', 'region', 'defaultTo', 'contextVisibility', 'chunkMode', 'reactionNotifications', 'responsePrefix']) {
if (form[key]) entry[key] = form[key]
}
entry.dmPolicy = form.dmPolicy
entry.groupPolicy = form.groupPolicy
if (Array.isArray(form.allowFrom) && form.allowFrom.length) entry.allowFrom = form.allowFrom
if (Array.isArray(form.groupAllowFrom) && form.groupAllowFrom.length) entry.groupAllowFrom = form.groupAllowFrom
if (Array.isArray(form.attachmentRoots) && form.attachmentRoots.length) entry.attachmentRoots = form.attachmentRoots
if (Array.isArray(form.remoteAttachmentRoots) && form.remoteAttachmentRoots.length) entry.remoteAttachmentRoots = form.remoteAttachmentRoots
for (const key of ['configWrites', 'includeAttachments', 'blockStreaming', 'sendReadReceipts', 'coalesceSameSenderDms']) {
if (typeof form[key] === 'boolean') entry[key] = form[key]
}
for (const key of ['historyLimit', 'dmHistoryLimit', 'mediaMaxMb', 'probeTimeoutMs', 'textChunkLimit']) {
if (typeof form[key] === 'number') entry[key] = form[key]
}
} else if (storageKey === 'msteams') {
for (const key of ['appId', 'appPassword', 'tenantId', 'authType', 'certificatePath', 'certificateThumbprint', 'managedIdentityClientId', 'replyStyle', 'sharePointSiteId', 'responsePrefix']) {
if (form[key]) entry[key] = form[key]
@@ -3766,7 +3786,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'].includes(storageKey)) {
if (['zalo', 'zalouser', 'line', 'mattermost', 'synology-chat', 'googlechat', 'msteams', 'imessage'].includes(storageKey)) {
ensureMessagingPluginAllowed(cfg, storageKey)
}
return { entry, accountId: normalizedAccountId, storageKey }

View File

@@ -334,7 +334,7 @@ fn channel_any_credential_groups(
}
fn channel_diagnosis_credentials_ready(platform: &str, form: &Map<String, Value>) -> bool {
if platform_storage_key(platform) == "zalouser" {
if matches!(platform_storage_key(platform), "zalouser" | "imessage") {
return true;
}
if platform_storage_key(platform) == "msteams" {
@@ -465,7 +465,7 @@ fn build_openclaw_channel_diagnosis(
.iter()
.any(|(key, _)| has_configured_messaging_value(form.get(*key)))
};
let credential_ok = if storage_key == "zalouser" {
let credential_ok = if matches!(storage_key, "zalouser" | "imessage") {
config_exists
} else if !required_fields.is_empty() {
missing.is_empty()
@@ -481,9 +481,21 @@ fn build_openclaw_channel_diagnosis(
checks.push(json!({
"id": "credentials",
"ok": credential_ok,
"title": if storage_key == "zalouser" { "登录/会话配置" } else { "必要凭证字段" },
"title": if storage_key == "zalouser" {
"登录/会话配置"
} else if storage_key == "imessage" {
"桥接运行配置"
} else {
"必要凭证字段"
},
"detail": if storage_key == "zalouser" {
"Zalo Personal 通过二维码登录保存本地会话;配置已保存后,请按手动命令完成或刷新登录。".to_string()
} else if storage_key == "imessage" {
if config_exists {
"iMessage 使用本机或远端桥接运行,不需要 Bot Token已保存基础运行配置。".to_string()
} else {
"尚未保存 iMessage 渠道配置,请先填写并保存。".to_string()
}
} else if credential_ok {
if !required_fields.is_empty() {
format!("已填写 {}", required_labels)
@@ -703,6 +715,16 @@ fn put_number_from_form(entry: &mut Map<String, Value>, key: &str, raw: &str) {
}
}
fn put_number_value_if_present(entry: &mut Map<String, Value>, key: &str, value: Option<&Value>) {
if let Some(number) = value.and_then(|v| v.as_f64()) {
if let Some(json_number) = serde_json::Number::from_f64(number) {
entry.insert(key.into(), Value::Number(json_number));
}
return;
}
put_number_from_form(entry, key, &value.and_then(|v| v.as_str()).unwrap_or(""));
}
fn normalize_numeric_form_value(map: &mut Map<String, Value>, key: &str) {
let Some(value) = map.get(key).cloned() else {
return;
@@ -801,6 +823,7 @@ fn normalize_messaging_platform_form(
| "line"
| "mattermost"
| "googlechat"
| "imessage"
);
let has_dm_field = normalized.contains_key("dmPolicy") || needs_access_defaults;
let has_group_field = normalized.contains_key("groupPolicy") || needs_access_defaults;
@@ -865,12 +888,18 @@ fn normalize_messaging_platform_form(
normalize_numeric_form_value(&mut normalized, "historyLimit");
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, "rateLimitPerMinute");
normalize_numeric_form_value(&mut normalized, "httpPort");
normalize_numeric_form_value(&mut normalized, "webhookPort");
normalize_numeric_form_value(&mut normalized, "feedbackReflectionCooldownMs");
for key in ["promptStarters", "delegatedAuthScopes"] {
for key in [
"promptStarters",
"delegatedAuthScopes",
"attachmentRoots",
"remoteAttachmentRoots",
] {
if normalized.contains_key(key) {
let items = json_array_from_csv_value(normalized.get(key));
normalized.insert(key.into(), Value::Array(items));
@@ -892,6 +921,10 @@ fn normalize_messaging_platform_form(
"feedbackReflection",
"delegatedAuthEnabled",
"ssoEnabled",
"configWrites",
"includeAttachments",
"sendReadReceipts",
"coalesceSameSenderDms",
] {
if normalized.contains_key(key) {
let value = match normalized.get(key) {
@@ -1500,6 +1533,44 @@ pub async fn read_platform_config(
insert_number_as_string(&mut form, &saved, key);
}
}
"imessage" => {
for key in [
"cliPath",
"dbPath",
"remoteHost",
"service",
"region",
"defaultTo",
"contextVisibility",
"chunkMode",
"reactionNotifications",
"responsePrefix",
] {
insert_string_if_present(&mut form, &saved, key);
}
insert_access_policy_form_values(&mut form, &saved, false, false);
insert_array_as_csv(&mut form, &saved, "groupAllowFrom");
insert_array_as_csv(&mut form, &saved, "attachmentRoots");
insert_array_as_csv(&mut form, &saved, "remoteAttachmentRoots");
for key in [
"configWrites",
"includeAttachments",
"blockStreaming",
"sendReadReceipts",
"coalesceSameSenderDms",
] {
insert_bool_as_string(&mut form, &saved, key);
}
for key in [
"historyLimit",
"dmHistoryLimit",
"mediaMaxMb",
"probeTimeoutMs",
"textChunkLimit",
] {
insert_number_as_string(&mut form, &saved, key);
}
}
"matrix" => {
insert_string_if_present(&mut form, &saved, "homeserver");
insert_secret_aware_form_value(&mut form, &saved, "accessToken");
@@ -2248,6 +2319,67 @@ pub async fn save_messaging_platform(
entry,
)?;
}
"imessage" => {
let mut entry = Map::new();
entry.insert("enabled".into(), Value::Bool(true));
for key in [
"cliPath",
"dbPath",
"remoteHost",
"service",
"region",
"defaultTo",
"contextVisibility",
"chunkMode",
"reactionNotifications",
"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,
"groupPolicy",
form_string(form_obj, "groupPolicy"),
);
put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom"));
put_array_from_form_value(&mut entry, "groupAllowFrom", form_obj.get("groupAllowFrom"));
put_array_from_form_value(
&mut entry,
"attachmentRoots",
form_obj.get("attachmentRoots"),
);
put_array_from_form_value(
&mut entry,
"remoteAttachmentRoots",
form_obj.get("remoteAttachmentRoots"),
);
for key in [
"configWrites",
"includeAttachments",
"blockStreaming",
"sendReadReceipts",
"coalesceSameSenderDms",
] {
put_bool_value_if_present(&mut entry, key, form_obj.get(key));
}
for key in [
"historyLimit",
"dmHistoryLimit",
"mediaMaxMb",
"probeTimeoutMs",
"textChunkLimit",
] {
put_number_value_if_present(&mut entry, key, form_obj.get(key));
}
merge_channel_entry_for_account(
channels_map,
&storage_key,
account_id.as_deref(),
entry,
)?;
ensure_plugin_allowed(&mut cfg, "imessage")?;
}
"matrix" => {
let homeserver = form_string(form_obj, "homeserver");
let access_token = form_string(form_obj, "accessToken");
@@ -2825,6 +2957,10 @@ pub async fn verify_bot_token(platform: String, form: Value) -> Result<Value, St
"matrix" => verify_matrix(&client, form_obj).await,
"signal" => verify_signal(&client, form_obj).await,
"msteams" => verify_msteams(&client, form_obj).await,
"imessage" => Ok(json!({
"valid": true,
"warnings": ["iMessage 使用本机或远端桥接运行,无需在线校验 Bot Token请通过 Gateway 日志确认桥接进程状态"]
})),
"whatsapp" => Ok(json!({
"valid": true,
"warnings": ["WhatsApp 使用扫码登录,无需在线校验凭证;请通过「启动扫码登录」完成配对"]
@@ -5768,6 +5904,62 @@ mod tests {
);
}
#[test]
fn normalize_imessage_form_preserves_bridge_runtime_fields() {
let form = json!({
"dmPolicy": "allowlist",
"allowFrom": "+15551234567, +15557654321",
"groupPolicy": "allowlist",
"groupAllowFrom": "chat-guid-1, chat-guid-2",
"probeTimeoutMs": "5000",
"attachmentRoots": "/Users/me/Downloads, /tmp/imessage",
"includeAttachments": "true",
"sendReadReceipts": "false"
});
let normalized =
normalize_messaging_platform_form("imessage", form.as_object().expect("object"));
assert_eq!(
normalized.get("dmPolicy").and_then(|v| v.as_str()),
Some("allowlist")
);
assert_eq!(
normalized.get("probeTimeoutMs").and_then(|v| v.as_f64()),
Some(5000.0)
);
assert_eq!(
normalized
.get("includeAttachments")
.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("attachmentRoots")
.and_then(|v| v.as_array())
.map(|items| items.len()),
Some(2)
);
assert!(channel_diagnosis_credentials_ready("imessage", &normalized));
let diagnosis =
build_openclaw_channel_diagnosis("imessage", 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

@@ -234,6 +234,21 @@ export default {
signalAllowFromPh: _('可选,逗号分隔', 'Optional, comma-separated', '可選,逗號分隔'),
signalGroupAllowFromPh: _('可选,逗号分隔群组 ID', 'Optional, comma-separated group IDs', '可選,逗號分隔群組 ID'),
signalBlockStreaming: _('阻止流式分块', 'Block streaming chunks', '阻止串流分塊'),
imessageDesc: _('接入 iMessage / SMS 桥接,支持本机或远端 Mac 运行', 'Connect iMessage / SMS bridge from a local or remote Mac', '接入 iMessage / SMS 橋接,支援本機或遠端 Mac 執行'),
imessageGuide1: _('在可访问 Messages 数据库的 macOS 设备上安装 iMessage 桥接插件', 'Install the iMessage bridge plugin on a macOS device that can access the Messages database', '在可存取 Messages 資料庫的 macOS 裝置上安裝 iMessage 橋接外掛'),
imessageGuide2: _('确认桥接 CLI 可读取 chat.db并能按需发送 iMessage 或 SMS', 'Confirm the bridge CLI can read chat.db and send iMessage or SMS when needed', '確認橋接 CLI 可讀取 chat.db並能按需傳送 iMessage 或 SMS'),
imessageGuide3: _('按需填写远端主机、白名单、附件目录和历史窗口', 'Fill remote host, allowlists, attachment roots, and history windows as needed', '按需填寫遠端主機、白名單、附件目錄和歷史視窗'),
imessageGuide4: _('保存后重启或重载 Gateway再通过渠道诊断查看桥接状态', 'Save, restart or reload Gateway, then check bridge status in channel diagnostics', '儲存後重啟或重載 Gateway再透過頻道診斷查看橋接狀態'),
imessageGuideFooter: _('<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">iMessage 不需要 Bot Token运行状态取决于本机或远端 Mac 的桥接进程权限</div>', '<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">iMessage does not need a Bot Token; runtime health depends on bridge process permissions on the local or remote Mac</div>', '<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">iMessage 不需要 Bot Token執行狀態取決於本機或遠端 Mac 的橋接程序權限</div>'),
imessageCliPathPh: _('可选,默认从 PATH 查找桥接 CLI', 'Optional, defaults to PATH lookup for the bridge CLI', '可選,預設從 PATH 尋找橋接 CLI'),
imessageAllowFromPh: _('可选,逗号分隔手机号、邮箱或联系人 ID', 'Optional, comma-separated phone numbers, emails, or contact IDs', '可選逗號分隔手機號、Email 或聯絡人 ID'),
imessageGroupAllowFromPh: _('可选,逗号分隔群聊 GUID', 'Optional, comma-separated group chat GUIDs', '可選,逗號分隔群聊 GUID'),
imessageIncludeAttachments: _('包含附件', 'Include attachments', '包含附件'),
imessageAttachmentRootsPh: _('可选,逗号分隔本机附件目录', 'Optional, comma-separated local attachment directories', '可選,逗號分隔本機附件目錄'),
imessageRemoteAttachmentRootsPh: _('可选,逗号分隔远端附件目录', 'Optional, comma-separated remote attachment directories', '可選,逗號分隔遠端附件目錄'),
imessageReadReceipts: _('发送已读回执', 'Send read receipts', '傳送已讀回執'),
imessageReactionOwn: _('仅自己相关', 'Own only', '僅自己相關'),
imessageCoalesceDms: _('合并同发送者私信', 'Coalesce same-sender DMs', '合併同傳送者私訊'),
matrixDesc: _('接入 Matrix 协议Element 等客户端)', 'Connect via Matrix protocol (Element and other clients)', '接入 Matrix 协議Element 等用戶端)', 'Matrix プロトコルに接続'),
matrixGuide1: _('在 Matrix 服务器上注册 Bot 账号', 'Register a Bot account on a Matrix server', '在 Matrix 伺服器上註冊 Bot 账號'),
matrixGuide2: _('获取 <strong>Access Token</strong>(或使用用户名密码)', 'Get an <strong>Access Token</strong> (or use username & password)', '取得 <strong>Access Token</strong>(或使用使用者名密碼)'),
@@ -244,6 +259,7 @@ export default {
matrixAllowFromPh: _('可选,逗号分隔用户 ID', 'Optional, comma-separated user IDs', '可選,逗號分隔使用者 ID'),
matrixAuthRequired: _('Matrix 需要填写 Access Token或填写 User ID + Password', 'Matrix requires an Access Token, or User ID + Password'),
groupAllGroups: _('所有群组', 'All groups', '所有群組'),
groupAllChats: _('所有聊天', 'All chats', '所有聊天'),
groupAllRooms: _('所有房间', 'All rooms', '所有房間'),
groupAllTeams: _('所有团队', 'All teams', '所有團队'),
groupMentionBot: _('仅 @机器人时', 'Only when @bot', '僅 @機器人時'),

View File

@@ -492,6 +492,56 @@ const PLATFORM_REGISTRY = {
],
configKey: 'signal',
},
imessage: {
label: 'iMessage',
iconName: 'message-circle',
desc: t('channels.imessageDesc'),
guide: [
t('channels.imessageGuide1'),
t('channels.imessageGuide2'),
t('channels.imessageGuide3'),
t('channels.imessageGuide4'),
],
guideFooter: t('channels.imessageGuideFooter'),
fields: [
{ key: 'cliPath', label: 'Bridge CLI Path', placeholder: t('channels.imessageCliPathPh'), required: false },
{ key: 'dbPath', label: 'Messages DB Path', placeholder: '~/Library/Messages/chat.db', required: false },
{ key: 'remoteHost', label: 'Remote Host', placeholder: t('channels.optionalEg', { example: 'mac-mini.local' }), required: false },
{ key: 'service', label: 'Service', type: 'select', options: [
{ value: '', label: t('channels.policyDefault') },
{ value: 'imessage', label: 'iMessage' },
{ value: 'sms', label: 'SMS' },
{ value: 'auto', label: 'Auto' },
], required: false },
{ key: 'region', label: 'Region', placeholder: t('channels.optionalEg', { example: 'US' }), required: false },
{ key: 'dmPolicy', label: t('channels.dmPolicy'), type: 'select', options: DM_POLICY_OPTIONS, required: false },
{ key: 'groupPolicy', label: t('channels.groupPolicy'), type: 'select', options: GROUP_POLICY_OPTIONS(t('channels.groupAllChats')), required: false },
{ key: 'allowFrom', label: 'Allow From', placeholder: t('channels.imessageAllowFromPh'), 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.imessageGroupAllowFromPh'), 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: '25', required: false },
{ key: 'probeTimeoutMs', label: 'Probe Timeout MS', placeholder: '5000', required: false },
{ key: 'textChunkLimit', label: 'Text Chunk Limit', placeholder: '1800', required: false },
{ key: 'includeAttachments', label: t('channels.imessageIncludeAttachments'), type: 'select', options: BOOLEAN_OPTIONS, required: false },
{ key: 'attachmentRoots', label: 'Attachment Roots', placeholder: t('channels.imessageAttachmentRootsPh'), required: false },
{ key: 'remoteAttachmentRoots', label: 'Remote Attachment Roots', placeholder: t('channels.imessageRemoteAttachmentRootsPh'), required: false },
{ key: 'blockStreaming', label: t('channels.signalBlockStreaming'), type: 'select', options: BOOLEAN_OPTIONS, required: false },
{ key: 'sendReadReceipts', label: t('channels.imessageReadReceipts'), type: 'select', options: BOOLEAN_OPTIONS, required: false },
{ key: 'reactionNotifications', label: 'Reaction Notifications', type: 'select', options: [
{ value: '', label: t('channels.policyDefault') },
{ value: 'off', label: t('channels.disable') },
{ value: 'all', label: t('channels.enable') },
{ value: 'own', label: t('channels.imessageReactionOwn') },
], required: false },
{ key: 'coalesceSameSenderDms', label: t('channels.imessageCoalesceDms'), type: 'select', options: BOOLEAN_OPTIONS, required: false },
{ key: 'responsePrefix', label: 'Response Prefix', placeholder: t('channels.optionalEg', { example: '[iMessage]' }), required: false },
],
configKey: 'imessage',
pluginRequired: '@openclaw/imessage@latest',
pluginId: 'imessage',
},
matrix: {
label: 'Matrix',
iconName: 'globe',

View File

@@ -920,6 +920,100 @@ test('Microsoft Teams 诊断会按认证模式检查动态必填凭证', () => {
assert.equal(managedIdentity.checks.find(item => item.id === 'credentials')?.ok, true)
})
test('iMessage 渠道保存会写入桥接运行字段并启用插件', () => {
const cfg = { channels: {} }
mergeOpenClawMessagingPlatformConfig(cfg, {
platform: 'imessage',
form: {
cliPath: '/usr/local/bin/imsg',
dbPath: '~/Library/Messages/chat.db',
remoteHost: 'mac-mini.local',
service: 'auto',
region: 'US',
dmPolicy: 'allowlist',
allowFrom: '+15551234567, +15557654321',
defaultTo: '+15550001111',
groupPolicy: 'allowlist',
groupAllowFrom: 'chat-guid-1, chat-guid-2',
historyLimit: '80',
dmHistoryLimit: '20',
mediaMaxMb: '25',
probeTimeoutMs: '5000',
textChunkLimit: '1800',
includeAttachments: 'true',
attachmentRoots: '/Users/me/Downloads, /tmp/imessage',
remoteAttachmentRoots: '/mnt/messages',
sendReadReceipts: 'false',
coalesceSameSenderDms: 'true',
reactionNotifications: 'own',
responsePrefix: '[iMessage]',
},
})
const entry = cfg.channels.imessage
assert.equal(entry.cliPath, '/usr/local/bin/imsg')
assert.equal(entry.dbPath, '~/Library/Messages/chat.db')
assert.equal(entry.remoteHost, 'mac-mini.local')
assert.equal(entry.service, 'auto')
assert.equal(entry.region, 'US')
assert.equal(entry.dmPolicy, 'allowlist')
assert.deepEqual(entry.allowFrom, ['+15551234567', '+15557654321'])
assert.equal(entry.defaultTo, '+15550001111')
assert.equal(entry.groupPolicy, 'allowlist')
assert.deepEqual(entry.groupAllowFrom, ['chat-guid-1', 'chat-guid-2'])
assert.equal(entry.historyLimit, 80)
assert.equal(entry.dmHistoryLimit, 20)
assert.equal(entry.mediaMaxMb, 25)
assert.equal(entry.probeTimeoutMs, 5000)
assert.equal(entry.textChunkLimit, 1800)
assert.equal(entry.includeAttachments, true)
assert.deepEqual(entry.attachmentRoots, ['/Users/me/Downloads', '/tmp/imessage'])
assert.deepEqual(entry.remoteAttachmentRoots, ['/mnt/messages'])
assert.equal(entry.sendReadReceipts, false)
assert.equal(entry.coalesceSameSenderDms, true)
assert.equal(entry.reactionNotifications, 'own')
assert.equal(entry.responsePrefix, '[iMessage]')
assert.equal(cfg.plugins.entries.imessage.enabled, true)
})
test('iMessage 读取会回显桥接字段且诊断不要求 Bot Token', () => {
const values = buildMessagingPlatformFormValues('imessage', {
cliPath: '/usr/local/bin/imsg',
dbPath: '~/Library/Messages/chat.db',
remoteHost: 'mac-mini.local',
service: 'sms',
dmPolicy: 'open',
allowFrom: ['*'],
groupPolicy: 'allowlist',
groupAllowFrom: ['chat-guid-1'],
includeAttachments: true,
attachmentRoots: ['/Users/me/Downloads'],
sendReadReceipts: false,
historyLimit: 50,
responsePrefix: '[iMessage]',
})
const diagnosis = buildOpenClawChannelDiagnosis({
platform: 'imessage',
configExists: true,
channelEnabled: true,
form: values,
})
assert.equal(values.cliPath, '/usr/local/bin/imsg')
assert.equal(values.service, 'sms')
assert.equal(values.dmPolicy, 'open')
assert.equal(values.allowFrom, '*')
assert.equal(values.groupAllowFrom, 'chat-guid-1')
assert.equal(values.includeAttachments, 'true')
assert.equal(values.attachmentRoots, '/Users/me/Downloads')
assert.equal(values.sendReadReceipts, 'false')
assert.equal(values.historyLimit, '50')
assert.equal(values.responsePrefix, '[iMessage]')
assert.equal(diagnosis.checks.find(item => item.id === 'credentials')?.ok, true)
assert.match(diagnosis.checks.find(item => item.id === 'credentials')?.title || '', /桥接/)
})
test('Discord 渠道保存会保留运行时需要的 applicationId', () => {
const cfg = { channels: {} }

View File

@@ -17,3 +17,25 @@ test('Discord 渠道 UI 会暴露服务器频道 allowlist 配置字段', () =>
assert.match(discordBlock, /key:\s*'guildId'/)
assert.match(discordBlock, /key:\s*'channelId'/)
})
test('iMessage 渠道 UI 会暴露桥接运行配置字段', () => {
const imessageBlock = getRegistryBlock('imessage')
for (const field of [
'cliPath',
'dbPath',
'remoteHost',
'service',
'allowFrom',
'groupAllowFrom',
'probeTimeoutMs',
'attachmentRoots',
'remoteAttachmentRoots',
'sendReadReceipts',
'coalesceSameSenderDms',
]) {
assert.match(imessageBlock, new RegExp(`key:\\s*'${field}'`))
}
assert.match(imessageBlock, /pluginRequired:\s*'@openclaw\/imessage@latest'/)
assert.match(imessageBlock, /pluginId:\s*'imessage'/)
})