diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 7737c9f..72e098e 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -2498,7 +2498,7 @@ export function normalizeMessagingPlatformForm(platform, form = {}) { normalized.allowedUserIds = csvToStringArray(normalized.allowedUserIds) } - for (const key of ['promptStarters', 'delegatedAuthScopes', 'attachmentRoots', 'remoteAttachmentRoots', 'toolsAllow', 'allowedRoles', 'relays', 'channels', 'groups', 'mentionPatterns']) { + for (const key of ['promptStarters', 'delegatedAuthScopes', 'attachmentRoots', 'remoteAttachmentRoots', 'toolsAllow', 'allowedRoles', 'relays', 'channels', 'groups', 'mentionPatterns', 'groupChannels', 'dmAllowlist', 'groupInviteAllowlist', 'defaultAuthorizedShips']) { if (Object.hasOwn(normalized, key)) normalized[key] = csvToStringArray(normalized[key]) } @@ -2515,7 +2515,7 @@ export function normalizeMessagingPlatformForm(platform, form = {}) { } } - for (const key of ['dangerouslyAllowNameMatching', 'dangerouslyAllowPrivateNetwork', 'dangerouslyAllowInheritedWebhookPath', 'allowInsecureSsl', 'enabled', 'allowBots', 'blockStreaming', 'useManagedIdentity', 'typingIndicator', 'welcomeCard', 'groupWelcomeCard', 'feedbackEnabled', 'feedbackReflection', 'delegatedAuthEnabled', 'ssoEnabled', 'configWrites', 'includeAttachments', 'sendReadReceipts', 'coalesceSameSenderDms', 'selfChatMode', 'ackDirect', 'senderIsOwner', 'requireMention', 'tls', 'nickservEnabled', 'nickservRegister']) { + for (const key of ['dangerouslyAllowNameMatching', 'dangerouslyAllowPrivateNetwork', 'dangerouslyAllowInheritedWebhookPath', 'allowInsecureSsl', 'enabled', 'allowBots', 'blockStreaming', 'useManagedIdentity', 'typingIndicator', 'welcomeCard', 'groupWelcomeCard', 'feedbackEnabled', 'feedbackReflection', 'delegatedAuthEnabled', 'ssoEnabled', 'configWrites', 'includeAttachments', 'sendReadReceipts', 'coalesceSameSenderDms', 'selfChatMode', 'ackDirect', 'senderIsOwner', 'requireMention', 'tls', 'nickservEnabled', 'nickservRegister', 'autoDiscoverChannels', 'showModelSignature', 'autoAcceptDmInvites', 'autoAcceptGroupInvites']) { if (Object.hasOwn(normalized, key)) { const value = typeof normalized[key] === 'boolean' ? String(normalized[key]) @@ -2645,6 +2645,7 @@ const MESSAGING_CREDENTIAL_FIELDS = [ 'botToken', 'channelAccessToken', 'channelSecret', + 'code', 'clientId', 'clientSecret', 'refreshToken', @@ -2744,6 +2745,7 @@ const CHANNEL_DIAG_REQUIRED_FIELDS = { 'nextcloud-talk': [['baseUrl', 'Base URL']], nostr: [['privateKey', 'Private Key']], irc: [['host', 'Host'], ['nick', 'Nick']], + tlon: [['ship', 'Ship'], ['url', 'URL'], ['code', 'Code']], twitch: [['username', 'Username'], ['accessToken', 'Access Token'], ['clientId', 'Client ID'], ['channel', 'Channel']], signal: [['account', 'Signal 账号']], } @@ -3211,6 +3213,26 @@ export function buildMessagingPlatformFormValues(platform, saved = {}, options = return form } + if (storageKey === 'tlon') { + const shared = options.channelRoot && typeof options.channelRoot === 'object' + ? { ...options.channelRoot, ...saved } + : saved + if (options.channelRoot?.network && !saved.network) shared.network = options.channelRoot.network + for (const key of ['name', 'ship', 'url', 'code', 'responsePrefix', 'ownerShip']) { + putSecretAwareFormValue(form, shared, key) + } + putBoolFormValue(form, shared, 'enabled') + putBoolFormValue(form, shared?.network, 'dangerouslyAllowPrivateNetwork') + putCsvFormValue(form, shared, 'groupChannels') + putCsvFormValue(form, shared, 'dmAllowlist') + putCsvFormValue(form, shared, 'groupInviteAllowlist') + putCsvFormValue(form, shared, 'defaultAuthorizedShips') + for (const key of ['autoDiscoverChannels', 'showModelSignature', 'autoAcceptDmInvites', 'autoAcceptGroupInvites']) { + putBoolFormValue(form, shared, key) + } + return form + } + if (storageKey === 'synology-chat') { for (const key of ['token', 'incomingUrl', 'nasHost', 'webhookPath', 'botName']) { putSecretAwareFormValue(form, saved, key) @@ -3660,6 +3682,7 @@ function secretAwareAccountDisplayValue(value) { function resolvePlatformConfigEntry(channelRoot, platform, accountId) { if (!channelRoot || typeof channelRoot !== 'object') return null const accountKey = typeof accountId === 'string' ? accountId.trim() : '' + if (platformStorageKey(platform) === 'tlon' && accountKey === QQBOT_DEFAULT_ACCOUNT_ID) return channelRoot if (accountKey) return channelRoot.accounts?.[accountKey] || channelRoot if (platformStorageKey(platform) === 'qqbot' && !channelHasQqbotCredentials(channelRoot)) { return channelRoot.accounts?.[QQBOT_DEFAULT_ACCOUNT_ID] || channelRoot @@ -3674,7 +3697,7 @@ export function listPlatformAccounts(channelRoot) { return Object.entries(channelRoot.accounts) .map(([accountId, value]) => { const entry = { accountId } - const displayId = ['appId', 'clientId', 'account', 'nick'] + const displayId = ['appId', 'clientId', 'account', 'nick', 'ship'] .map(key => secretAwareAccountDisplayValue(value?.[key])) .find(Boolean) if (displayId) entry.appId = displayId @@ -4056,6 +4079,24 @@ function buildOpenClawMessagingPlatformEntry(platform, form, currentSaved = {}) if (typeof form.nickservRegister === 'boolean') nickserv.register = form.nickservRegister if (form.nickservRegisterEmail) nickserv.registerEmail = form.nickservRegisterEmail if (Object.keys(nickserv).length) entry.nickserv = nickserv + } else if (storageKey === 'tlon') { + entry.enabled = typeof form.enabled === 'boolean' ? form.enabled : true + for (const key of ['name', 'ship', 'url', 'responsePrefix', 'ownerShip']) { + if (form[key]) entry[key] = form[key] + } + const code = resolveMessagingCredentialFormValueForSave({ form, current: currentSaved, formKey: 'code' }) + if (code === undefined) delete entry.code + else entry.code = code + if (Array.isArray(form.groupChannels) && form.groupChannels.length) entry.groupChannels = form.groupChannels + if (Array.isArray(form.dmAllowlist) && form.dmAllowlist.length) entry.dmAllowlist = form.dmAllowlist + if (Array.isArray(form.groupInviteAllowlist) && form.groupInviteAllowlist.length) entry.groupInviteAllowlist = form.groupInviteAllowlist + if (Array.isArray(form.defaultAuthorizedShips) && form.defaultAuthorizedShips.length) entry.defaultAuthorizedShips = form.defaultAuthorizedShips + for (const key of ['autoDiscoverChannels', 'showModelSignature', 'autoAcceptDmInvites', 'autoAcceptGroupInvites']) { + if (typeof form[key] === 'boolean') entry[key] = form[key] + } + if (typeof form.dangerouslyAllowPrivateNetwork === 'boolean') { + entry.network = { ...(currentSaved?.network || {}), dangerouslyAllowPrivateNetwork: form.dangerouslyAllowPrivateNetwork } + } } else if (storageKey === 'synology-chat') { for (const key of ['token', 'incomingUrl', 'nasHost', 'webhookPath', 'botName']) { if (form[key]) entry[key] = form[key] @@ -4096,8 +4137,11 @@ export function mergeOpenClawMessagingPlatformConfig(cfg, { platform, form, acco const normalizedAccountId = typeof accountId === 'string' ? accountId.trim() : '' const currentSaved = resolvePlatformConfigEntry(cfg.channels?.[storageKey], platform, normalizedAccountId) || {} const entry = buildOpenClawMessagingPlatformEntry(platform, normalizedForm, currentSaved) - applyMessagingPlatformEntry(cfg, storageKey, storageKey === 'nostr' ? '' : normalizedAccountId, entry) - if (['zalo', 'zalouser', 'line', 'mattermost', 'clickclack', 'nextcloud-talk', 'twitch', 'nostr', 'irc', 'synology-chat', 'googlechat', 'msteams', 'imessage', 'whatsapp'].includes(storageKey)) { + const targetAccountId = storageKey === 'nostr' || (storageKey === 'tlon' && normalizedAccountId === QQBOT_DEFAULT_ACCOUNT_ID) + ? '' + : normalizedAccountId + applyMessagingPlatformEntry(cfg, storageKey, targetAccountId, entry) + if (['zalo', 'zalouser', 'line', 'mattermost', 'clickclack', 'nextcloud-talk', 'twitch', 'nostr', 'irc', 'tlon', 'synology-chat', 'googlechat', 'msteams', 'imessage', 'whatsapp'].includes(storageKey)) { ensureMessagingPluginAllowed(cfg, storageKey) } return { entry, accountId: normalizedAccountId, storageKey } @@ -5567,16 +5611,19 @@ const handlers = { } else { setRootChannelEntry(entry) } - } else if (['line', 'mattermost', 'clickclack', 'nextcloud-talk', 'twitch', 'nostr', 'irc', 'synology-chat', 'googlechat', 'msteams', 'whatsapp'].includes(storageKey)) { + } else if (['line', 'mattermost', 'clickclack', 'nextcloud-talk', 'twitch', 'nostr', 'irc', 'tlon', 'synology-chat', 'googlechat', 'msteams', 'whatsapp'].includes(storageKey)) { const built = buildOpenClawMessagingPlatformEntry(platform, form, currentSaved) - applyMessagingPlatformEntry(cfg, storageKey, storageKey === 'nostr' ? '' : normalizedAccountId, built) + const targetAccountId = storageKey === 'nostr' || (storageKey === 'tlon' && normalizedAccountId === QQBOT_DEFAULT_ACCOUNT_ID) + ? '' + : normalizedAccountId + applyMessagingPlatformEntry(cfg, storageKey, targetAccountId, built) ensureMessagingPluginAllowed(cfg, storageKey) } else { Object.assign(entry, form) preserveMessagingCredentialRefs(entry, form, currentSaved) } - if (platform !== 'qqbot' && platform !== 'feishu' && platform !== 'dingtalk' && platform !== 'dingtalk-connector' && !['line', 'mattermost', 'clickclack', 'nextcloud-talk', 'twitch', 'nostr', 'irc', 'synology-chat', 'googlechat', 'msteams', 'whatsapp'].includes(storageKey)) { + if (platform !== 'qqbot' && platform !== 'feishu' && platform !== 'dingtalk' && platform !== 'dingtalk-connector' && !['line', 'mattermost', 'clickclack', 'nextcloud-talk', 'twitch', 'nostr', 'irc', 'tlon', 'synology-chat', 'googlechat', 'msteams', 'whatsapp'].includes(storageKey)) { preserveMessagingCredentialRefs(entry, form, currentSaved) // 合并模式:保留用户通过 CLI 或手动编辑的自定义字段 applyMessagingPlatformEntry(cfg, storageKey, normalizedAccountId, entry) @@ -5716,6 +5763,9 @@ const handlers = { if (platform === 'irc') { return { valid: true, warnings: ['IRC 面板已完成基础字段校验;实际连通性请通过 Gateway 启动日志或 openclaw channels status --probe 验证。'] } } + if (platform === 'tlon') { + return { valid: true, warnings: ['Tlon 面板已完成基础字段校验;实际连通性请通过 Gateway 启动日志或 openclaw channels status --probe 验证。'] } + } 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 ca52ea5..e6fb0d7 100644 --- a/src-tauri/src/commands/messaging.rs +++ b/src-tauri/src/commands/messaging.rs @@ -197,6 +197,7 @@ fn preserve_messaging_credential_refs( "botToken", "channelAccessToken", "channelSecret", + "code", "clientId", "clientSecret", "refreshToken", @@ -286,6 +287,7 @@ fn channel_root_has_messaging_credential(root: &Map) -> bool { "botToken", "channelAccessToken", "channelSecret", + "code", "clientId", "clientSecret", "refreshToken", @@ -332,6 +334,7 @@ fn required_channel_credential_fields( "nextcloud-talk" => vec![("baseUrl", "Base URL")], "nostr" => vec![("privateKey", "Private Key")], "irc" => vec![("host", "Host"), ("nick", "Nick")], + "tlon" => vec![("ship", "Ship"), ("url", "URL"), ("code", "Code")], "twitch" => vec![ ("username", "Username"), ("accessToken", "Access Token"), @@ -1081,6 +1084,10 @@ fn normalize_messaging_platform_form( "channels", "groups", "mentionPatterns", + "groupChannels", + "dmAllowlist", + "groupInviteAllowlist", + "defaultAuthorizedShips", ] { if normalized.contains_key(key) { let items = json_array_from_csv_value(normalized.get(key)); @@ -1115,6 +1122,10 @@ fn normalize_messaging_platform_form( "tls", "nickservEnabled", "nickservRegister", + "autoDiscoverChannels", + "showModelSignature", + "autoAcceptDmInvites", + "autoAcceptGroupInvites", ] { if normalized.contains_key(key) { let value = match normalized.get(key) { @@ -1409,6 +1420,9 @@ fn resolve_platform_config_entry( let root = channel_root?; let account = account_id.map(str::trim).filter(|s| !s.is_empty()); if let Some(acct) = account { + if platform_storage_key(platform) == "tlon" && acct == QQBOT_DEFAULT_ACCOUNT_ID { + return Some(root.clone()); + } if let Some(value) = root.get("accounts").and_then(|a| a.get(acct)) { return Some(value.clone()); } @@ -2094,6 +2108,41 @@ pub async fn read_platform_config( } } } + "tlon" => { + let mut shared = channel_root + .and_then(|root| root.as_object()) + .cloned() + .unwrap_or_default(); + if let Some(saved_obj) = saved.as_object() { + for (key, value) in saved_obj { + shared.insert(key.clone(), value.clone()); + } + } + let shared = Value::Object(shared); + for key in ["name", "ship", "url", "code", "responsePrefix", "ownerShip"] { + insert_secret_aware_form_value(&mut form, &shared, key); + } + insert_bool_as_string(&mut form, &shared, "enabled"); + if let Some(network) = shared.get("network") { + insert_bool_as_string(&mut form, network, "dangerouslyAllowPrivateNetwork"); + } + for key in [ + "groupChannels", + "dmAllowlist", + "groupInviteAllowlist", + "defaultAuthorizedShips", + ] { + insert_array_as_csv(&mut form, &shared, key); + } + for key in [ + "autoDiscoverChannels", + "showModelSignature", + "autoAcceptDmInvites", + "autoAcceptGroupInvites", + ] { + insert_bool_as_string(&mut form, &shared, key); + } + } "synology-chat" => { for key in ["token", "incomingUrl", "nasHost", "webhookPath", "botName"] { insert_secret_aware_form_value(&mut form, &saved, key); @@ -3445,6 +3494,75 @@ pub async fn save_messaging_platform( )?; ensure_plugin_allowed(&mut cfg, "irc")?; } + "tlon" => { + let ship = form_string(form_obj, "ship"); + let url = form_string(form_obj, "url"); + let code = form_string(form_obj, "code"); + if ship.is_empty() { + return Err("Tlon Ship 不能为空".into()); + } + if url.is_empty() { + return Err("Tlon URL 不能为空".into()); + } + if code.is_empty() && !has_configured_messaging_value(form_obj.get("code")) { + return Err("Tlon Code 不能为空".into()); + } + + 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 ["name", "ship", "url", "responsePrefix", "ownerShip"] { + put_string(&mut entry, key, form_string(form_obj, key)); + } + match resolve_messaging_credential_value_for_save(form_obj, ¤t_saved, "code") { + Some(value) => { + entry.insert("code".into(), value); + } + None => { + entry.remove("code"); + } + } + for key in [ + "groupChannels", + "dmAllowlist", + "groupInviteAllowlist", + "defaultAuthorizedShips", + ] { + put_array_from_form_value(&mut entry, key, form_obj.get(key)); + } + for key in [ + "autoDiscoverChannels", + "showModelSignature", + "autoAcceptDmInvites", + "autoAcceptGroupInvites", + ] { + put_bool_value_if_present(&mut entry, key, form_obj.get(key)); + } + if form_obj.contains_key("dangerouslyAllowPrivateNetwork") { + let mut network = current_saved + .get("network") + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_default(); + put_bool_value_if_present( + &mut network, + "dangerouslyAllowPrivateNetwork", + form_obj.get("dangerouslyAllowPrivateNetwork"), + ); + if !network.is_empty() { + entry.insert("network".into(), Value::Object(network)); + } + } + preserve_messaging_credential_refs(&mut entry, form_obj, ¤t_saved); + let target_account_id = + if account_id.as_deref().map(str::trim) == Some(QQBOT_DEFAULT_ACCOUNT_ID) { + None + } else { + account_id.as_deref() + }; + merge_channel_entry_for_account(channels_map, &storage_key, target_account_id, entry)?; + ensure_plugin_allowed(&mut cfg, "tlon")?; + } "synology-chat" => { let token = form_string(form_obj, "token"); let incoming_url = form_string(form_obj, "incomingUrl"); @@ -3762,6 +3880,10 @@ pub async fn verify_bot_token(platform: String, form: Value) -> Result Ok(json!({ + "valid": true, + "warnings": ["Tlon 面板已完成基础字段校验;实际连通性请通过 Gateway 启动日志或 openclaw channels status --probe 验证"] + })), _ => Ok(json!({ "valid": true, "warnings": ["该平台暂不支持在线校验"] @@ -4686,6 +4808,7 @@ pub async fn list_configured_platforms() -> Result { .or_else(|| account_display_value(acct_val, "clientId")) .or_else(|| account_display_value(acct_val, "account")) .or_else(|| account_display_value(acct_val, "nick")) + .or_else(|| account_display_value(acct_val, "ship")) { entry["appId"] = Value::String(display_id); } @@ -7296,6 +7419,139 @@ mod tests { .contains("IRC 面板已完成基础字段校验")); } + #[test] + fn normalize_tlon_form_preserves_ship_login_and_invite_fields() { + let form = json!({ + "enabled": "true", + "name": "Main Ship", + "ship": "~sampel-palnet", + "url": "https://urbit.example.com", + "code": "lidlut-tabwed-pillex-ridrup", + "dangerouslyAllowPrivateNetwork": "true", + "groupChannels": "chat/~host-ship/general, chat/~host-ship/support", + "dmAllowlist": "zod, ~nec", + "groupInviteAllowlist": "~bus", + "autoDiscoverChannels": "true", + "showModelSignature": "false", + "responsePrefix": "[Tlon]", + "autoAcceptDmInvites": "true", + "autoAcceptGroupInvites": "false", + "ownerShip": "~sampel-palnet", + "defaultAuthorizedShips": "~zod, ~nec" + }); + let normalized = + normalize_messaging_platform_form("tlon", form.as_object().expect("object")); + + assert_eq!( + normalized.get("enabled").and_then(|v| v.as_bool()), + Some(true) + ); + assert_eq!( + normalized + .get("dangerouslyAllowPrivateNetwork") + .and_then(|v| v.as_bool()), + Some(true) + ); + assert_eq!( + normalized + .get("groupChannels") + .and_then(|v| v.as_array()) + .map(|items| items.len()), + Some(2) + ); + assert_eq!( + normalized + .get("dmAllowlist") + .and_then(|v| v.as_array()) + .map(|items| items.len()), + Some(2) + ); + assert_eq!( + normalized + .get("groupInviteAllowlist") + .and_then(|v| v.as_array()) + .map(|items| items.len()), + Some(1) + ); + assert_eq!( + normalized + .get("defaultAuthorizedShips") + .and_then(|v| v.as_array()) + .map(|items| items.len()), + Some(2) + ); + assert_eq!( + normalized + .get("autoDiscoverChannels") + .and_then(|v| v.as_bool()), + Some(true) + ); + assert_eq!( + normalized + .get("showModelSignature") + .and_then(|v| v.as_bool()), + Some(false) + ); + assert_eq!( + normalized + .get("autoAcceptDmInvites") + .and_then(|v| v.as_bool()), + Some(true) + ); + assert_eq!( + normalized + .get("autoAcceptGroupInvites") + .and_then(|v| v.as_bool()), + Some(false) + ); + assert!(channel_diagnosis_credentials_ready("tlon", &normalized)); + + let missing = normalize_messaging_platform_form( + "tlon", + json!({ + "ship": "~sampel-palnet", + "url": "https://urbit.example.com" + }) + .as_object() + .expect("object"), + ); + assert!(!channel_diagnosis_credentials_ready("tlon", &missing)); + let diagnosis = + build_openclaw_channel_diagnosis("tlon", None, true, true, &missing, None, None); + assert!(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("detail")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .contains("Code")); + } + + #[test] + fn verify_tlon_token_returns_probe_guidance_warning() { + let result = tauri::async_runtime::block_on(verify_bot_token( + "tlon".to_string(), + json!({ + "ship": "~sampel-palnet", + "url": "https://urbit.example.com", + "code": "lidlut-tabwed-pillex-ridrup" + }), + )) + .expect("verify result"); + + assert_eq!(result.get("valid").and_then(|v| v.as_bool()), Some(true)); + assert!(result + .get("warnings") + .and_then(|v| v.as_array()) + .and_then(|items| items.first()) + .and_then(|v| v.as_str()) + .unwrap_or("") + .contains("Tlon 面板已完成基础字段校验")); + } + #[test] fn channel_form_readback_preserves_mention_policy_choice() { let saved = json!({ diff --git a/src/lib/channel-labels.js b/src/lib/channel-labels.js index dab0981..5ddcb8d 100644 --- a/src/lib/channel-labels.js +++ b/src/lib/channel-labels.js @@ -18,6 +18,7 @@ export const CHANNEL_LABELS = { imessage: 'iMessage', line: 'LINE', nostr: 'Nostr', + tlon: 'Tlon', mattermost: 'Mattermost', clickclack: 'ClickClack', 'nextcloud-talk': 'Nextcloud Talk', diff --git a/src/locales/modules/channels.js b/src/locales/modules/channels.js index eb1b037..3f3c4b5 100644 --- a/src/locales/modules/channels.js +++ b/src/locales/modules/channels.js @@ -190,6 +190,28 @@ export default { nostrDefaultAccountHint: _('上游当前通过根节点配置生成隐式账号,通常保持 default 即可。', 'Upstream currently derives an implicit account from the root config; default is usually enough.'), nostrProfileAboutPh: _('可选,展示在 Nostr Profile 中的机器人介绍', 'Optional bot introduction shown in the Nostr profile'), nostrProfileUrlHint: _('上游 Profile URL 字段要求使用 https:// 地址。', 'Upstream profile URL fields require https:// URLs.'), + tlonDesc: _('接入 Tlon / Urbit 消息网络,支持 Ship 登录、群组频道、私信白名单和邀请控制', 'Connect Tlon / Urbit messaging with ship login, group channels, DM allowlists, and invite controls'), + tlonGuide1: _('准备可访问的 Tlon Ship,例如 ~sampel-palnet,并确认 Gateway 网络可以访问该 Ship 的 URL', 'Prepare a reachable Tlon ship, for example ~sampel-palnet, and make sure Gateway can access its URL'), + tlonGuide2: _('填写 Ship、URL 和登录 Code;这是上游 Tlon 插件的最小可运行配置', 'Fill Ship, URL, and login Code; these are the minimum fields required by the upstream Tlon plugin'), + tlonGuide3: _('按需填写 Group Channels、DM Allowlist 与 Group Invite Allowlist,限制机器人可响应和可接受邀请的范围', 'Set Group Channels, DM Allowlist, and Group Invite Allowlist as needed to limit where the bot can respond and accept invites'), + tlonGuide4: _('保存后面板会启用 Tlon 插件并重载 Gateway;真实连通性以 Gateway 日志或 channels status --probe 为准', 'After saving, the panel enables the Tlon plugin and reloads Gateway; verify connectivity through Gateway logs or channels status --probe'), + tlonGuideFooter: _('
Tlon 最小配置需要 Ship、URL 与 Code;命名账号会写入 channels.tlon.accounts,默认账号写入 channels.tlon 根节点。
', '
Tlon minimally requires Ship, URL, and Code; named accounts are written to channels.tlon.accounts, while the default account is written to the channels.tlon root.
'), + tlonShipHint: _('Ship 名通常以 ~ 开头,例如 ~sampel-palnet。', 'Ship names usually start with ~, for example ~sampel-palnet.'), + tlonUrlHint: _('Tlon 实例 URL,建议使用 https://。如为内网地址,需确认 Gateway 可访问。', 'Tlon instance URL; https:// is recommended. For private addresses, make sure Gateway can reach it.'), + tlonCodeHint: _('Tlon 登录码;生产环境建议使用 SecretRef,保持 SecretRef 占位不变即可保留引用。', 'Tlon login code; prefer SecretRef in production. Keep the SecretRef placeholder unchanged to preserve the reference.'), + tlonPrivateNetworkHint: _('仅在 Tlon URL 是可信内网地址时开启;该开关会写入 network.dangerouslyAllowPrivateNetwork。', 'Enable only for trusted private Tlon URLs; this writes network.dangerouslyAllowPrivateNetwork.'), + tlonGroupChannelsHint: _('群组频道 Nest,多个值用逗号分隔,例如 chat/~host-ship/general。', 'Group channel nests separated by commas, for example chat/~host-ship/general.'), + tlonDmAllowlistHint: _('允许发起私信的 Ship 列表,多个值用逗号分隔。', 'Ships allowed to start DMs, separated by commas.'), + tlonGroupInviteAllowlistHint: _('允许邀请机器人加入群组的 Ship 列表,留空表示按上游默认处理。', 'Ships allowed to invite the bot to groups; leave empty to use upstream defaults.'), + tlonAutoDiscoverChannels: _('自动发现频道', 'Auto-discover channels'), + tlonAutoDiscoverChannelsHint: _('开启后上游会尝试发现 Ship 可用频道;大型 Ship 上建议先显式填写 Group Channels。', 'When enabled, upstream tries to discover available channels; for large ships, explicitly setting Group Channels first is recommended.'), + tlonShowModelSignature: _('显示模型签名', 'Show model signature'), + tlonAutoAcceptDmInvites: _('自动接受私信邀请', 'Auto-accept DM invites'), + tlonAutoAcceptDmInvitesHint: _('通常仅配合 DM Allowlist 使用,避免接受未知 Ship。', 'Usually use this with DM Allowlist to avoid accepting unknown ships.'), + tlonAutoAcceptGroupInvites: _('自动接受群组邀请', 'Auto-accept group invites'), + tlonAutoAcceptGroupInvitesHint: _('建议同时配置 Group Invite Allowlist,避免恶意群组邀请。', 'Configure Group Invite Allowlist as well to avoid malicious group invites.'), + tlonOwnerShipHint: _('用于审批请求的 Owner Ship,可填写当前 Ship 或专门的管理 Ship。', 'Owner ship that receives approval requests; use the current ship or a dedicated management ship.'), + tlonDefaultAuthorizedShipsHint: _('默认授权 Ship 列表,多个值用逗号分隔。', 'Default authorized ships separated by commas.'), ircDesc: _('接入 IRC 网络,支持服务器账号、NickServ 登录、频道白名单和提及策略', 'Connect IRC networks with server accounts, NickServ login, channel allowlists, and mention policy'), ircGuide1: _('准备 IRC 服务器地址,例如 irc.libera.chat,并确认 Gateway 网络可以连接', 'Prepare the IRC server host, for example irc.libera.chat, and make sure Gateway can connect'), ircGuide2: _('填写机器人 Nick;如服务器需要 SASL 或密码,可填写 Server Password 或文件路径', 'Fill the bot Nick; if the server requires SASL or a password, provide Server Password or a file path'), diff --git a/src/pages/channels.js b/src/pages/channels.js index f4e84d1..5c1cd10 100644 --- a/src/pages/channels.js +++ b/src/pages/channels.js @@ -418,6 +418,39 @@ const PLATFORM_REGISTRY = { pluginRequired: '@openclaw/nostr@latest', pluginId: 'nostr', }, + tlon: { + label: 'Tlon', + iconName: 'globe', + desc: t('channels.tlonDesc'), + guide: [ + t('channels.tlonGuide1'), + t('channels.tlonGuide2'), + t('channels.tlonGuide3'), + t('channels.tlonGuide4'), + ], + guideFooter: t('channels.tlonGuideFooter'), + fields: [ + { key: 'name', label: t('channels.accountName'), placeholder: t('channels.optionalEg', { example: 'main-ship' }), required: false }, + { key: 'ship', label: 'Ship', placeholder: '~sampel-palnet', required: true, hint: t('channels.tlonShipHint') }, + { key: 'url', label: 'URL', placeholder: 'https://urbit.example.com', required: true, hint: t('channels.tlonUrlHint') }, + { key: 'code', label: 'Code', placeholder: 'lidlut-tabwed-pillex-ridrup', secret: true, required: true, hint: t('channels.tlonCodeHint') }, + { key: 'dangerouslyAllowPrivateNetwork', label: t('channels.mattermostPrivateNetwork'), type: 'select', options: BOOLEAN_OPTIONS, required: false, hint: t('channels.tlonPrivateNetworkHint') }, + { key: 'groupChannels', label: 'Group Channels', placeholder: 'chat/~host-ship/general, chat/~host-ship/support', required: false, hint: t('channels.tlonGroupChannelsHint') }, + { key: 'dmAllowlist', label: 'DM Allowlist', placeholder: '~zod, ~nec', required: false, hint: t('channels.tlonDmAllowlistHint') }, + { key: 'groupInviteAllowlist', label: 'Group Invite Allowlist', placeholder: '~zod, ~nec', required: false, hint: t('channels.tlonGroupInviteAllowlistHint') }, + { key: 'autoDiscoverChannels', label: t('channels.tlonAutoDiscoverChannels'), type: 'select', options: BOOLEAN_OPTIONS, required: false, hint: t('channels.tlonAutoDiscoverChannelsHint') }, + { key: 'showModelSignature', label: t('channels.tlonShowModelSignature'), type: 'select', options: BOOLEAN_OPTIONS, required: false }, + { key: 'responsePrefix', label: 'Response Prefix', placeholder: t('channels.optionalEg', { example: '[Tlon]' }), required: false }, + { key: 'autoAcceptDmInvites', label: t('channels.tlonAutoAcceptDmInvites'), type: 'select', options: BOOLEAN_OPTIONS, required: false, hint: t('channels.tlonAutoAcceptDmInvitesHint') }, + { key: 'autoAcceptGroupInvites', label: t('channels.tlonAutoAcceptGroupInvites'), type: 'select', options: BOOLEAN_OPTIONS, required: false, hint: t('channels.tlonAutoAcceptGroupInvitesHint') }, + { key: 'ownerShip', label: 'Owner Ship', placeholder: '~sampel-palnet', required: false, hint: t('channels.tlonOwnerShipHint') }, + { key: 'defaultAuthorizedShips', label: 'Default Authorized Ships', placeholder: '~zod, ~nec', required: false, hint: t('channels.tlonDefaultAuthorizedShipsHint') }, + ], + configKey: 'tlon', + pairingChannel: 'tlon', + pluginRequired: '@openclaw/tlon@latest', + pluginId: 'tlon', + }, irc: { label: 'IRC', iconName: 'hash', @@ -993,7 +1026,7 @@ function applyRouteIntent(page, state) { // ── 已配置平台渲染 ── // ── 多账号支持的平台:与 OpenClaw 的 accounts/defaultAccount 配置模型保持一致 ── -const MULTI_INSTANCE_PLATFORMS = ['telegram', 'discord', 'slack', 'feishu', 'dingtalk', 'dingtalk-connector', 'qqbot', 'zalo', 'zalouser', 'line', 'mattermost', 'clickclack', 'nextcloud-talk', 'twitch', 'irc', 'synology-chat', 'googlechat', 'signal'] +const MULTI_INSTANCE_PLATFORMS = ['telegram', 'discord', 'slack', 'feishu', 'dingtalk', 'dingtalk-connector', 'qqbot', 'zalo', 'zalouser', 'line', 'mattermost', 'clickclack', 'nextcloud-talk', 'twitch', 'tlon', 'irc', 'synology-chat', 'googlechat', 'signal'] function supportsMessagingMultiAccount(pid) { return MULTI_INSTANCE_PLATFORMS.includes(pid) diff --git a/src/style/components.css b/src/style/components.css index 04fe822..3744b75 100644 --- a/src/style/components.css +++ b/src/style/components.css @@ -1149,6 +1149,18 @@ mark { /* === 移动端响应式 === */ @media (max-width: 768px) { + .modal .btn { + min-height: 44px; + justify-content: center; + } + .modal .btn-sm, + .modal .btn-xs { + min-height: 40px; + padding: var(--space-sm) var(--space-md); + } + .modal-actions { + flex-wrap: wrap; + } .stat-cards { grid-template-columns: repeat(2, 1fr); gap: var(--space-sm); diff --git a/tests/channel-config-normalization.test.js b/tests/channel-config-normalization.test.js index 0e1810c..8e4dea0 100644 --- a/tests/channel-config-normalization.test.js +++ b/tests/channel-config-normalization.test.js @@ -689,6 +689,145 @@ test('IRC 读取和诊断会回显服务器、NickServ 与频道字段', () => { assert.equal(ready.checks.find(item => item.id === 'credentials')?.ok, true) }) +test('Tlon 默认账号保存会写入上游根节点配置并启用插件', () => { + const cfg = { channels: {} } + + mergeOpenClawMessagingPlatformConfig(cfg, { + platform: 'tlon', + accountId: 'default', + form: { + enabled: 'true', + name: 'Main Ship', + ship: '~sampel-palnet', + url: 'https://urbit.example.com', + code: 'lidlut-tabwed-pillex-ridrup', + dangerouslyAllowPrivateNetwork: 'true', + groupChannels: 'chat/~host-ship/general, chat/~host-ship/support', + dmAllowlist: 'zod, ~nec', + groupInviteAllowlist: '~bus', + autoDiscoverChannels: 'true', + showModelSignature: 'false', + responsePrefix: '[Tlon]', + autoAcceptDmInvites: 'true', + autoAcceptGroupInvites: 'false', + ownerShip: '~sampel-palnet', + defaultAuthorizedShips: '~zod, ~nec', + }, + }) + + const root = cfg.channels.tlon + assert.equal(root.enabled, true) + assert.equal(root.name, 'Main Ship') + assert.equal(root.ship, '~sampel-palnet') + assert.equal(root.url, 'https://urbit.example.com') + assert.equal(root.code, 'lidlut-tabwed-pillex-ridrup') + assert.deepEqual(root.network, { dangerouslyAllowPrivateNetwork: true }) + assert.deepEqual(root.groupChannels, ['chat/~host-ship/general', 'chat/~host-ship/support']) + assert.deepEqual(root.dmAllowlist, ['zod', '~nec']) + assert.deepEqual(root.groupInviteAllowlist, ['~bus']) + assert.equal(root.autoDiscoverChannels, true) + assert.equal(root.showModelSignature, false) + assert.equal(root.responsePrefix, '[Tlon]') + assert.equal(root.autoAcceptDmInvites, true) + assert.equal(root.autoAcceptGroupInvites, false) + assert.equal(root.ownerShip, '~sampel-palnet') + assert.deepEqual(root.defaultAuthorizedShips, ['~zod', '~nec']) + assert.equal(Object.hasOwn(root, 'accounts'), false) + assert.equal(cfg.plugins.entries.tlon.enabled, true) +}) + +test('Tlon 命名账号保存会写入 accounts 并保留根节点共享字段', () => { + const cfg = { + channels: { + tlon: { + enabled: true, + defaultAuthorizedShips: ['~zod'], + }, + }, + } + + mergeOpenClawMessagingPlatformConfig(cfg, { + platform: 'tlon', + accountId: 'support', + form: { + enabled: 'true', + ship: '~support-palnet', + url: 'https://support.example.com', + code: 'fodwyt-ragful-sivnys-nivlup', + groupChannels: 'chat/~host-ship/support', + dmAllowlist: '~zod', + autoDiscoverChannels: 'false', + ownerShip: '~support-palnet', + }, + }) + + const root = cfg.channels.tlon + const account = root.accounts.support + assert.deepEqual(root.defaultAuthorizedShips, ['~zod']) + assert.equal(account.enabled, true) + assert.equal(account.ship, '~support-palnet') + assert.equal(account.url, 'https://support.example.com') + assert.equal(account.code, 'fodwyt-ragful-sivnys-nivlup') + assert.deepEqual(account.groupChannels, ['chat/~host-ship/support']) + assert.deepEqual(account.dmAllowlist, ['~zod']) + assert.equal(account.autoDiscoverChannels, false) + assert.equal(account.ownerShip, '~support-palnet') + assert.equal(cfg.plugins.entries.tlon.enabled, true) +}) + +test('Tlon 读取和诊断会回显 Ship、URL、登录码和安全配置', () => { + const values = buildMessagingPlatformFormValues('tlon', { + enabled: true, + name: 'Main Ship', + ship: '~sampel-palnet', + url: 'https://urbit.example.com', + code: 'lidlut-tabwed-pillex-ridrup', + network: { dangerouslyAllowPrivateNetwork: true }, + groupChannels: ['chat/~host-ship/general', 'chat/~host-ship/support'], + dmAllowlist: ['~zod', '~nec'], + groupInviteAllowlist: ['~bus'], + autoDiscoverChannels: true, + showModelSignature: false, + responsePrefix: '[Tlon]', + autoAcceptDmInvites: true, + autoAcceptGroupInvites: false, + ownerShip: '~sampel-palnet', + defaultAuthorizedShips: ['~zod', '~nec'], + }) + const missingCode = buildOpenClawChannelDiagnosis({ + platform: 'tlon', + configExists: true, + channelEnabled: true, + form: { ship: '~sampel-palnet', url: 'https://urbit.example.com' }, + }) + const ready = buildOpenClawChannelDiagnosis({ + platform: 'tlon', + configExists: true, + channelEnabled: true, + form: values, + }) + + assert.equal(values.enabled, 'true') + assert.equal(values.name, 'Main Ship') + assert.equal(values.ship, '~sampel-palnet') + assert.equal(values.url, 'https://urbit.example.com') + assert.equal(values.code, 'lidlut-tabwed-pillex-ridrup') + assert.equal(values.dangerouslyAllowPrivateNetwork, 'true') + assert.equal(values.groupChannels, 'chat/~host-ship/general, chat/~host-ship/support') + assert.equal(values.dmAllowlist, '~zod, ~nec') + assert.equal(values.groupInviteAllowlist, '~bus') + assert.equal(values.autoDiscoverChannels, 'true') + assert.equal(values.showModelSignature, 'false') + assert.equal(values.responsePrefix, '[Tlon]') + assert.equal(values.autoAcceptDmInvites, 'true') + assert.equal(values.autoAcceptGroupInvites, 'false') + assert.equal(values.ownerShip, '~sampel-palnet') + assert.equal(values.defaultAuthorizedShips, '~zod, ~nec') + assert.equal(missingCode.checks.find(item => item.id === 'credentials')?.ok, false) + assert.match(missingCode.checks.find(item => item.id === 'credentials')?.detail || '', /Code/) + assert.equal(ready.checks.find(item => item.id === 'credentials')?.ok, true) +}) + test('Signal 渠道保存会保留多账号和上游运行字段', () => { const cfg = { channels: {} } @@ -943,6 +1082,20 @@ test('渠道账号列表会使用 IRC Nick 作为安全展示标识', () => { ]) }) +test('渠道账号列表会使用 Tlon Ship 作为安全展示标识', () => { + const accounts = listPlatformAccounts({ + accounts: { + support: { + ship: '~support-palnet', + }, + }, + }) + + assert.deepEqual(accounts, [ + { accountId: 'support', appId: '~support-palnet' }, + ]) +}) + test('渠道保存时 clientId 未改动 SecretRef 占位会保留原始引用', () => { const secretRef = { source: 'env', provider: 'default', id: 'DINGTALK_CLIENT_ID' } const value = resolveMessagingCredentialValueForSave({ diff --git a/tests/channel-ui-registry.test.js b/tests/channel-ui-registry.test.js index baa8408..ac925b5 100644 --- a/tests/channel-ui-registry.test.js +++ b/tests/channel-ui-registry.test.js @@ -197,3 +197,28 @@ test('IRC 渠道 UI 会暴露服务器、NickServ 与频道访问配置字段', assert.match(ircBlock, /pluginRequired:\s*'@openclaw\/irc@latest'/) assert.match(ircBlock, /pluginId:\s*'irc'/) }) + +test('Tlon 渠道 UI 会暴露 Urbit 登录、频道和邀请安全配置字段', () => { + const tlonBlock = getRegistryBlock('tlon') + + for (const field of [ + 'ship', + 'url', + 'code', + 'dangerouslyAllowPrivateNetwork', + 'groupChannels', + 'dmAllowlist', + 'groupInviteAllowlist', + 'autoDiscoverChannels', + 'showModelSignature', + 'responsePrefix', + 'autoAcceptDmInvites', + 'autoAcceptGroupInvites', + 'ownerShip', + 'defaultAuthorizedShips', + ]) { + assert.match(tlonBlock, new RegExp(`key:\\s*'${field}'`)) + } + assert.match(tlonBlock, /pluginRequired:\s*'@openclaw\/tlon@latest'/) + assert.match(tlonBlock, /pluginId:\s*'tlon'/) +})