diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 26ae9c6..0e123d3 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -2429,7 +2429,7 @@ function putWildcardAllowFromWhenOpen(entry, previousAllowFrom) { } function platformSupportsTopLevelRequireMention(platform) { - return ['feishu', 'slack', 'msteams'].includes(platformStorageKey(platform)) + return ['feishu', 'slack', 'msteams', 'mattermost'].includes(platformStorageKey(platform)) } export function normalizeMessagingPlatformForm(platform, form = {}) { @@ -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'].includes(storageKey) + const needsAccessDefaults = ['telegram', 'discord', 'feishu', 'slack', 'signal', 'msteams', 'whatsapp', 'zalo', 'zalouser', 'line', 'mattermost'].includes(storageKey) const hasDmField = Object.hasOwn(normalized, 'dmPolicy') || needsAccessDefaults const hasGroupField = Object.hasOwn(normalized, 'groupPolicy') || needsAccessDefaults @@ -2481,12 +2481,14 @@ export function normalizeMessagingPlatformForm(platform, form = {}) { } } - if (storageKey === 'zalouser' && Object.hasOwn(normalized, 'dangerouslyAllowNameMatching')) { - const value = String(normalized.dangerouslyAllowNameMatching || '').trim() - if (!value) { - delete normalized.dangerouslyAllowNameMatching - } else { - normalized.dangerouslyAllowNameMatching = value === 'true' + for (const key of ['dangerouslyAllowNameMatching', 'dangerouslyAllowPrivateNetwork']) { + if (Object.hasOwn(normalized, key)) { + const value = String(normalized[key] || '').trim() + if (!value) { + delete normalized[key] + } else { + normalized[key] = value === 'true' + } } } @@ -2583,11 +2585,14 @@ const MESSAGING_CREDENTIAL_FIELDS = [ 'appSecret', 'appToken', 'botToken', + 'channelAccessToken', + 'channelSecret', 'clientId', 'clientSecret', 'gatewayPassword', 'gatewayToken', 'password', + 'secretFile', 'signingSecret', 'token', 'tokenFile', @@ -2613,6 +2618,17 @@ function channelAnyCredentialFields(platform) { return [] } +function channelAnyCredentialGroups(platform) { + const storageKey = platformStorageKey(platform) + if (storageKey === 'line') { + return [ + { label: 'Channel Access Token 或 Token File', fields: [['channelAccessToken', 'Channel Access Token'], ['tokenFile', 'Token File']] }, + { label: 'Channel Secret 或 Secret File', fields: [['channelSecret', 'Channel Secret'], ['secretFile', 'Secret File']] }, + ] + } + return [] +} + const CHANNEL_DIAG_REQUIRED_FIELDS = { telegram: [['botToken', 'Bot Token']], discord: [['token', 'Bot Token']], @@ -2620,6 +2636,7 @@ const CHANNEL_DIAG_REQUIRED_FIELDS = { dingtalk: [['clientId', 'Client ID'], ['clientSecret', 'Client Secret']], 'dingtalk-connector': [['clientId', 'Client ID'], ['clientSecret', 'Client Secret']], msteams: [['appId', 'App ID'], ['appPassword', 'App Password']], + mattermost: [['botToken', 'Bot Token'], ['baseUrl', 'Base URL']], signal: [['account', 'Signal 账号']], } @@ -2645,6 +2662,10 @@ function channelDiagnosisCredentialsReady(platform, form = {}) { if (requiredFields.length) { return requiredFields.every(([key]) => hasConfiguredMessagingValue(form?.[key])) } + const anyGroups = channelAnyCredentialGroups(platform) + if (anyGroups.length) { + return anyGroups.every(group => group.fields.some(([key]) => hasConfiguredMessagingValue(form?.[key]))) + } const anyFields = channelAnyCredentialFields(platform) if (anyFields.length) { return anyFields.some(([key]) => hasConfiguredMessagingValue(form?.[key])) @@ -2689,14 +2710,22 @@ export function buildOpenClawChannelDiagnosis({ const requiredFields = requiredChannelCredentialFields(storageKey, form) const anyFields = channelAnyCredentialFields(storageKey) + const anyGroups = channelAnyCredentialGroups(storageKey) const missing = requiredFields .filter(([key]) => !hasConfiguredMessagingValue(form?.[key])) .map(([, label]) => label) + const missingGroups = anyGroups + .filter(group => !group.fields.some(([key]) => hasConfiguredMessagingValue(form?.[key]))) + .map(group => group.label) const hasAnyCredential = channelRootHasMessagingCredential(form) const anyCredentialOk = anyFields.length ? anyFields.some(([key]) => hasConfiguredMessagingValue(form?.[key])) : false const credentialOk = storageKey === 'zalouser' ? !!configExists - : (requiredFields.length ? missing.length === 0 : (anyFields.length ? anyCredentialOk : hasAnyCredential)) + : (requiredFields.length + ? missing.length === 0 + : (anyGroups.length + ? missingGroups.length === 0 + : (anyFields.length ? anyCredentialOk : hasAnyCredential))) const anyLabels = anyFields.map(([, label]) => label).join(' / ') checks.push({ id: 'credentials', @@ -2707,10 +2736,14 @@ export function buildOpenClawChannelDiagnosis({ : (credentialOk ? (requiredFields.length ? `已填写 ${requiredFields.map(([, label]) => label).join(' / ')}。` - : (anyFields.length ? `已填写 ${anyLabels} 其中一项。` : '已检测到可用凭证字段。')) + : (anyGroups.length + ? `已填写 ${anyGroups.map(group => group.label).join(';')}。` + : (anyFields.length ? `已填写 ${anyLabels} 其中一项。` : '已检测到可用凭证字段。'))) : (missing.length ? `缺少 ${missing.join(' / ')},请补齐后保存。` - : (anyFields.length ? `缺少 ${anyLabels},至少填写一项后保存。` : '未检测到可用凭证字段,请检查渠道配置。'))), + : (missingGroups.length + ? `缺少 ${missingGroups.join(';')},请补齐后保存。` + : (anyFields.length ? `缺少 ${anyLabels},至少填写一项后保存。` : '未检测到可用凭证字段,请检查渠道配置。')))), }) if (verifyError) { @@ -2862,6 +2895,29 @@ export function buildMessagingPlatformFormValues(platform, saved = {}, options = return form } + if (storageKey === 'line') { + for (const key of ['channelAccessToken', 'tokenFile', 'channelSecret', 'secretFile', 'webhookPath', 'responsePrefix']) { + putSecretAwareFormValue(form, saved, key) + } + putAccessPolicyFormValues(form, saved) + putCsvFormValue(form, saved, 'groupAllowFrom') + if (typeof saved.mediaMaxMb === 'number') form.mediaMaxMb = String(saved.mediaMaxMb) + return form + } + + if (storageKey === 'mattermost') { + for (const key of ['botToken', 'baseUrl', 'name', 'replyToMode', 'responsePrefix']) { + putSecretAwareFormValue(form, saved, key) + } + putAccessPolicyFormValues(form, saved, { mentionCompat: true }) + putCsvFormValue(form, saved, 'groupAllowFrom') + putBoolFormValue(form, saved, 'dangerouslyAllowNameMatching') + putBoolFormValue(form, saved?.network, 'dangerouslyAllowPrivateNetwork') + putStringFormValue(form, saved?.commands, 'callbackPath') + putStringFormValue(form, saved?.commands, 'callbackUrl') + return form + } + for (const [key, value] of Object.entries(saved)) { if (key === 'enabled' || key === 'accounts') continue if (typeof value === 'string') form[key] = value @@ -3334,6 +3390,19 @@ function applyMessagingPlatformEntry(cfg, storageKey, accountId, entry) { } } +function ensureMessagingPluginAllowed(cfg, pluginId) { + if (!pluginId || !pluginId.trim()) return + const pid = pluginId.trim() + if (!cfg.plugins || typeof cfg.plugins !== 'object' || Array.isArray(cfg.plugins)) cfg.plugins = {} + if (!cfg.plugins.entries || typeof cfg.plugins.entries !== 'object' || Array.isArray(cfg.plugins.entries)) cfg.plugins.entries = {} + if (!Array.isArray(cfg.plugins.allow)) cfg.plugins.allow = [] + if (!cfg.plugins.allow.includes(pid)) cfg.plugins.allow.push(pid) + if (!cfg.plugins.entries[pid] || typeof cfg.plugins.entries[pid] !== 'object' || Array.isArray(cfg.plugins.entries[pid])) { + cfg.plugins.entries[pid] = {} + } + cfg.plugins.entries[pid].enabled = true +} + function buildOpenClawMessagingPlatformEntry(platform, form, currentSaved = {}) { const entry = { enabled: true } const storageKey = platformStorageKey(platform) @@ -3384,6 +3453,32 @@ function buildOpenClawMessagingPlatformEntry(platform, form, currentSaved = {}) if (Array.isArray(form.groupAllowFrom) && form.groupAllowFrom.length) entry.groupAllowFrom = form.groupAllowFrom if (typeof form.historyLimit === 'number') entry.historyLimit = form.historyLimit if (typeof form.dangerouslyAllowNameMatching === 'boolean') entry.dangerouslyAllowNameMatching = form.dangerouslyAllowNameMatching + } else if (storageKey === 'line') { + for (const key of ['channelAccessToken', 'tokenFile', 'channelSecret', 'secretFile', 'webhookPath', '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 (typeof form.mediaMaxMb === 'number') entry.mediaMaxMb = form.mediaMaxMb + } else if (storageKey === 'mattermost') { + for (const key of ['botToken', 'baseUrl', 'name', 'replyToMode', 'responsePrefix']) { + if (form[key]) entry[key] = form[key] + } + entry.dmPolicy = form.dmPolicy + entry.groupPolicy = form.groupPolicy + if (Object.hasOwn(form, 'requireMention')) entry.requireMention = !!form.requireMention + 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.dangerouslyAllowNameMatching === 'boolean') entry.dangerouslyAllowNameMatching = form.dangerouslyAllowNameMatching + if (typeof form.dangerouslyAllowPrivateNetwork === 'boolean') { + entry.network = { ...(currentSaved?.network || {}), dangerouslyAllowPrivateNetwork: form.dangerouslyAllowPrivateNetwork } + } + const commands = {} + if (form.callbackPath) commands.callbackPath = form.callbackPath + if (form.callbackUrl) commands.callbackUrl = form.callbackUrl + if (Object.keys(commands).length) entry.commands = { ...(currentSaved?.commands || {}), ...commands } } else { Object.assign(entry, form) } @@ -4866,12 +4961,16 @@ const handlers = { } else { setRootChannelEntry(entry) } + } else if (platform === 'line' || platform === 'mattermost') { + const built = buildOpenClawMessagingPlatformEntry(platform, form, currentSaved) + applyMessagingPlatformEntry(cfg, storageKey, normalizedAccountId, built) + ensureMessagingPluginAllowed(cfg, platform) } else { Object.assign(entry, form) preserveMessagingCredentialRefs(entry, form, currentSaved) } - if (platform !== 'qqbot' && platform !== 'feishu' && platform !== 'dingtalk' && platform !== 'dingtalk-connector') { + if (platform !== 'qqbot' && platform !== 'feishu' && platform !== 'dingtalk' && platform !== 'dingtalk-connector' && platform !== 'line' && platform !== 'mattermost') { preserveMessagingCredentialRefs(entry, form, currentSaved) // 合并模式:保留用户通过 CLI 或手动编辑的自定义字段 applyMessagingPlatformEntry(cfg, storageKey, normalizedAccountId, entry) diff --git a/src-tauri/src/commands/messaging.rs b/src-tauri/src/commands/messaging.rs index 2406b44..4f72631 100644 --- a/src-tauri/src/commands/messaging.rs +++ b/src-tauri/src/commands/messaging.rs @@ -140,11 +140,14 @@ fn preserve_messaging_credential_refs( "appSecret", "appToken", "botToken", + "channelAccessToken", + "channelSecret", "clientId", "clientSecret", "gatewayPassword", "gatewayToken", "password", + "secretFile", "signingSecret", "token", "tokenFile", @@ -181,11 +184,14 @@ fn channel_root_has_messaging_credential(root: &Map) -> bool { "appSecret", "appToken", "botToken", + "channelAccessToken", + "channelSecret", "clientId", "clientSecret", "gatewayPassword", "gatewayToken", "password", + "secretFile", "signingSecret", "token", "tokenFile", @@ -212,6 +218,7 @@ fn required_channel_credential_fields( "feishu" => vec![("appId", "App ID"), ("appSecret", "App Secret")], "dingtalk-connector" => vec![("clientId", "Client ID"), ("clientSecret", "Client Secret")], "msteams" => vec![("appId", "App ID"), ("appPassword", "App Password")], + "mattermost" => vec![("botToken", "Bot Token"), ("baseUrl", "Base URL")], "signal" => vec![("account", "Signal 账号")], "slack" => { let mode = form_string(form, "mode"); @@ -246,6 +253,30 @@ fn channel_any_credential_fields(platform: &str) -> Vec<(&'static str, &'static } } +fn channel_any_credential_groups( + platform: &str, +) -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> { + match platform_storage_key(platform) { + "line" => vec![ + ( + "Channel Access Token 或 Token File", + vec![ + ("channelAccessToken", "Channel Access Token"), + ("tokenFile", "Token File"), + ], + ), + ( + "Channel Secret 或 Secret File", + vec![ + ("channelSecret", "Channel Secret"), + ("secretFile", "Secret File"), + ], + ), + ], + _ => vec![], + } +} + fn channel_diagnosis_credentials_ready(platform: &str, form: &Map) -> bool { if platform_storage_key(platform) == "zalouser" { return true; @@ -256,6 +287,14 @@ fn channel_diagnosis_credentials_ready(platform: &str, form: &Map .iter() .all(|(key, _)| has_configured_messaging_value(form.get(*key))); } + let any_groups = channel_any_credential_groups(platform); + if !any_groups.is_empty() { + return any_groups.iter().all(|(_, fields)| { + fields + .iter() + .any(|(key, _)| has_configured_messaging_value(form.get(*key))) + }); + } let any_fields = channel_any_credential_fields(platform); if !any_fields.is_empty() { return any_fields @@ -341,11 +380,21 @@ fn build_openclaw_channel_diagnosis( let required_fields = required_channel_credential_fields(storage_key, form); let any_fields = channel_any_credential_fields(storage_key); + let any_groups = channel_any_credential_groups(storage_key); let missing: Vec<&str> = required_fields .iter() .filter(|(key, _)| !has_configured_messaging_value(form.get(*key))) .map(|(_, label)| *label) .collect(); + let missing_groups: Vec<&str> = any_groups + .iter() + .filter(|(_, fields)| { + !fields + .iter() + .any(|(key, _)| has_configured_messaging_value(form.get(*key))) + }) + .map(|(label, _)| *label) + .collect(); let any_credential_ok = if any_fields.is_empty() { false } else { @@ -357,6 +406,8 @@ fn build_openclaw_channel_diagnosis( config_exists } else if !required_fields.is_empty() { missing.is_empty() + } else if !any_groups.is_empty() { + missing_groups.is_empty() } else if !any_fields.is_empty() { any_credential_ok } else { @@ -373,6 +424,15 @@ fn build_openclaw_channel_diagnosis( } else if credential_ok { if !required_fields.is_empty() { format!("已填写 {}。", required_labels) + } else if !any_groups.is_empty() { + format!( + "已填写 {}。", + any_groups + .iter() + .map(|(label, _)| *label) + .collect::>() + .join(";") + ) } else if !any_fields.is_empty() { format!("已填写 {} 其中一项。", any_labels) } else { @@ -380,6 +440,8 @@ fn build_openclaw_channel_diagnosis( } } else if !missing.is_empty() { format!("缺少 {},请补齐后保存。", missing.join(" / ")) + } else if !missing_groups.is_empty() { + format!("缺少 {},请补齐后保存。", missing_groups.join(";")) } else if !any_fields.is_empty() { format!("缺少 {},至少填写一项后保存。", any_labels) } else { @@ -639,7 +701,7 @@ fn normalize_group_policy_value(raw: Option<&Value>, fallback: &str) -> String { fn platform_supports_top_level_require_mention(platform: &str) -> bool { matches!( platform_storage_key(platform), - "feishu" | "slack" | "msteams" + "feishu" | "slack" | "msteams" | "mattermost" ) } @@ -667,6 +729,8 @@ fn normalize_messaging_platform_form( | "whatsapp" | "zalo" | "zalouser" + | "line" + | "mattermost" ); let has_dm_field = normalized.contains_key("dmPolicy") || needs_access_defaults; let has_group_field = normalized.contains_key("groupPolicy") || needs_access_defaults; @@ -725,23 +789,28 @@ fn normalize_messaging_platform_form( normalize_numeric_form_value(&mut normalized, "mediaMaxMb"); normalize_numeric_form_value(&mut normalized, "historyLimit"); - if storage_key == "zalouser" && normalized.contains_key("dangerouslyAllowNameMatching") { - let value = match normalized.get("dangerouslyAllowNameMatching") { - Some(Value::Bool(v)) => Some(*v), - Some(Value::String(raw)) => { - let trimmed = raw.trim(); - if trimmed.is_empty() { - None - } else { - Some(bool_from_form_value(trimmed).unwrap_or(false)) + for key in [ + "dangerouslyAllowNameMatching", + "dangerouslyAllowPrivateNetwork", + ] { + if normalized.contains_key(key) { + let value = match normalized.get(key) { + Some(Value::Bool(v)) => Some(*v), + Some(Value::String(raw)) => { + let trimmed = raw.trim(); + if trimmed.is_empty() { + None + } else { + Some(bool_from_form_value(trimmed).unwrap_or(false)) + } } + _ => None, + }; + if let Some(v) = value { + normalized.insert(key.into(), Value::Bool(v)); + } else { + normalized.remove(key); } - _ => None, - }; - if let Some(v) = value { - normalized.insert("dangerouslyAllowNameMatching".into(), Value::Bool(v)); - } else { - normalized.remove("dangerouslyAllowNameMatching"); } } @@ -1345,6 +1414,44 @@ pub async fn read_platform_config( insert_access_policy_form_values(&mut form, &saved, false, true); insert_bool_as_string(&mut form, &saved, "requireMention"); } + "line" => { + for key in [ + "channelAccessToken", + "tokenFile", + "channelSecret", + "secretFile", + "webhookPath", + "responsePrefix", + ] { + insert_secret_aware_form_value(&mut form, &saved, key); + } + insert_access_policy_form_values(&mut form, &saved, false, false); + insert_array_as_csv(&mut form, &saved, "groupAllowFrom"); + if let Some(v) = saved.get("mediaMaxMb").and_then(|v| v.as_i64()) { + form.insert("mediaMaxMb".into(), Value::String(v.to_string())); + } + } + "mattermost" => { + for key in [ + "botToken", + "baseUrl", + "name", + "replyToMode", + "responsePrefix", + ] { + insert_secret_aware_form_value(&mut form, &saved, key); + } + insert_access_policy_form_values(&mut form, &saved, false, true); + insert_array_as_csv(&mut form, &saved, "groupAllowFrom"); + insert_bool_as_string(&mut form, &saved, "dangerouslyAllowNameMatching"); + if let Some(network) = saved.get("network") { + insert_bool_as_string(&mut form, network, "dangerouslyAllowPrivateNetwork"); + } + if let Some(commands) = saved.get("commands") { + insert_string_if_present(&mut form, commands, "callbackPath"); + insert_string_if_present(&mut form, commands, "callbackUrl"); + } + } _ => { if saved.is_null() { return Ok(json!({ "exists": false })); @@ -1973,6 +2080,152 @@ pub async fn save_messaging_platform( )?; ensure_plugin_allowed(&mut cfg, "msteams")?; } + "line" => { + let channel_access_token = form_string(form_obj, "channelAccessToken"); + let token_file = form_string(form_obj, "tokenFile"); + let channel_secret = form_string(form_obj, "channelSecret"); + let secret_file = form_string(form_obj, "secretFile"); + if channel_access_token.is_empty() && token_file.is_empty() { + return Err("Channel Access Token 或 Token File 至少填写一项".into()); + } + if channel_secret.is_empty() && secret_file.is_empty() { + return Err("Channel Secret 或 Secret File 至少填写一项".into()); + } + + let mut entry = Map::new(); + entry.insert("enabled".into(), Value::Bool(true)); + put_string(&mut entry, "channelAccessToken", channel_access_token); + put_string(&mut entry, "tokenFile", token_file); + put_string(&mut entry, "channelSecret", channel_secret); + put_string(&mut entry, "secretFile", secret_file); + put_string( + &mut entry, + "webhookPath", + form_string(form_obj, "webhookPath"), + ); + put_string( + &mut entry, + "responsePrefix", + form_string(form_obj, "responsePrefix"), + ); + 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")); + if let Some(value) = form_obj.get("mediaMaxMb").and_then(|v| v.as_f64()) { + if let Some(number) = serde_json::Number::from_f64(value) { + entry.insert("mediaMaxMb".into(), Value::Number(number)); + } + } else { + put_number_from_form( + &mut entry, + "mediaMaxMb", + &form_string(form_obj, "mediaMaxMb"), + ); + } + preserve_messaging_credential_refs(&mut entry, form_obj, ¤t_saved); + merge_channel_entry_for_account( + channels_map, + &storage_key, + account_id.as_deref(), + entry, + )?; + ensure_plugin_allowed(&mut cfg, "line")?; + } + "mattermost" => { + let bot_token = form_string(form_obj, "botToken"); + let base_url = form_string(form_obj, "baseUrl"); + if bot_token.is_empty() { + return Err("Mattermost Bot Token 不能为空".into()); + } + if base_url.is_empty() { + return Err("Mattermost Base URL 不能为空".into()); + } + + let mut entry = Map::new(); + entry.insert("enabled".into(), Value::Bool(true)); + put_string(&mut entry, "botToken", bot_token); + put_string(&mut entry, "baseUrl", base_url); + put_string(&mut entry, "name", form_string(form_obj, "name")); + put_string( + &mut entry, + "replyToMode", + form_string(form_obj, "replyToMode"), + ); + put_string( + &mut entry, + "responsePrefix", + form_string(form_obj, "responsePrefix"), + ); + put_string(&mut entry, "dmPolicy", form_string(form_obj, "dmPolicy")); + put_string( + &mut entry, + "groupPolicy", + form_string(form_obj, "groupPolicy"), + ); + put_bool_value_if_present(&mut entry, "requireMention", form_obj.get("requireMention")); + 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_bool_value_if_present( + &mut entry, + "dangerouslyAllowNameMatching", + form_obj.get("dangerouslyAllowNameMatching"), + ); + + if form_obj.contains_key("dangerouslyAllowPrivateNetwork") { + let mut network = current_saved + .get("network") + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_default(); + match form_obj.get("dangerouslyAllowPrivateNetwork") { + Some(Value::Bool(v)) => { + network.insert("dangerouslyAllowPrivateNetwork".into(), Value::Bool(*v)); + } + Some(Value::String(raw)) => { + if let Some(v) = bool_from_form_value(raw) { + network.insert("dangerouslyAllowPrivateNetwork".into(), Value::Bool(v)); + } + } + _ => {} + } + if !network.is_empty() { + entry.insert("network".into(), Value::Object(network)); + } + } + + let mut commands = current_saved + .get("commands") + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_default(); + put_string( + &mut commands, + "callbackPath", + form_string(form_obj, "callbackPath"), + ); + put_string( + &mut commands, + "callbackUrl", + form_string(form_obj, "callbackUrl"), + ); + if !commands.is_empty() { + entry.insert("commands".into(), Value::Object(commands)); + } + + preserve_messaging_credential_refs(&mut entry, form_obj, ¤t_saved); + merge_channel_entry_for_account( + channels_map, + &storage_key, + account_id.as_deref(), + entry, + )?; + ensure_plugin_allowed(&mut cfg, "mattermost")?; + } _ => { // 通用平台:直接保存表单字段 let mut entry = Map::new(); diff --git a/src/locales/modules/channels.js b/src/locales/modules/channels.js index 47ab6cf..5b95714 100644 --- a/src/locales/modules/channels.js +++ b/src/locales/modules/channels.js @@ -91,6 +91,43 @@ export default { zalouserAllowFromHint: _('推荐使用 Zalo 用户 ID;只有确认安全时才开启名称匹配。', 'Zalo user IDs are recommended; enable name matching only when you understand the risk.'), zalouserGroupAllowFromPh: _('可选,逗号分隔群组 ID 或精确群名', 'Optional, comma-separated group IDs or exact group names'), zalouserManualLoginHint: _('插件安装并保存配置后,在终端运行此命令完成二维码登录。多账号请追加 --account 账号标识。', 'After installing the plugin and saving config, run this command in your terminal to complete QR login. For multi-account setup, append --account account-id.'), + lineDesc: _('接入 LINE Messaging API,支持私聊、群组和 webhook 回调', 'Connect LINE Messaging API with DM, group, and webhook support'), + lineGuide1: _('前往 LINE Developers Console 创建或打开 Messaging API Channel', 'Open LINE Developers Console and create or open a Messaging API Channel'), + lineGuide2: _('在「Messaging API」页获取 Channel Access Token,在「Basic settings」页获取 Channel Secret', 'Get Channel Access Token from Messaging API and Channel Secret from Basic settings'), + lineGuide3: _('如果凭证由外部文件或 SecretRef 管理,可分别填写 Token File 与 Secret File', 'If credentials are managed by files or SecretRef, use Token File and Secret File instead'), + lineGuide4: _('配置 Webhook URL 指向 Gateway 暴露的 LINE webhook path,并在 LINE 控制台启用 Webhook', 'Set the Webhook URL to the Gateway LINE webhook path and enable Webhook in LINE Console'), + lineGuide5: _('设置私信/群组策略与允许列表,保存后面板会安装插件并重载 Gateway', 'Set DM/group policy and allowlists; after saving, the panel installs the plugin and reloads Gateway'), + lineGuideFooter: _('
LINE 至少需要 Channel Access Token 或 Token File 其中一项,并且需要 Channel Secret 或 Secret File 其中一项。
', '
LINE requires either Channel Access Token or Token File, and either Channel Secret or Secret File.
'), + lineAccessTokenPh: _('LINE Channel Access Token', 'LINE Channel Access Token'), + lineAccessTokenHint: _('与 Token File 二选一;如果当前值来自 SecretRef,保持占位不变即可保留引用。', 'Use either this or Token File; keep the SecretRef placeholder unchanged to preserve the reference.'), + lineTokenFilePh: _('可选,例如 /etc/openclaw/line-token.txt', 'Optional, e.g. /etc/openclaw/line-token.txt'), + lineTokenFileHint: _('当 Channel Access Token 由文件管理时填写;在线校验只会直接校验表单 Token。', 'Use this when Channel Access Token is file-managed; online verification only checks the form token directly.'), + lineChannelSecretPh: _('LINE Channel Secret', 'LINE Channel Secret'), + lineChannelSecretHint: _('与 Secret File 二选一,用于校验 LINE webhook 签名。', 'Use either this or Secret File; used to verify LINE webhook signatures.'), + lineSecretFilePh: _('可选,例如 /etc/openclaw/line-secret.txt', 'Optional, e.g. /etc/openclaw/line-secret.txt'), + lineSecretFileHint: _('当 Channel Secret 由文件管理时填写。', 'Use this when Channel Secret is file-managed.'), + lineAllowFromPh: _('可选,逗号分隔 LINE 用户 ID', 'Optional, comma-separated LINE user IDs'), + lineAllowFromHint: _('LINE 用户 ID 通常以 U 开头;留空表示按策略默认处理。', 'LINE user IDs usually start with U; leave empty to use policy defaults.'), + lineGroupAllowFromPh: _('可选,逗号分隔群组或聊天室 ID', 'Optional, comma-separated group or room IDs'), + lineTokenOrFile: _('Channel Access Token 或 Token File', 'Channel Access Token or Token File'), + lineSecretOrFile: _('Channel Secret 或 Secret File', 'Channel Secret or Secret File'), + mattermostDesc: _('接入自托管 Mattermost,支持私信、频道、slash command 和交互按钮', 'Connect self-hosted Mattermost with DMs, channels, slash commands, and interactions'), + mattermostGuide1: _('在 Mattermost 中创建 Bot Account,并复制 Personal Access Token 作为 Bot Token', 'Create a Bot Account in Mattermost and copy its Personal Access Token as Bot Token'), + mattermostGuide2: _('填写 Mattermost 站点 Base URL,例如 https://mattermost.example.com', 'Fill the Mattermost site Base URL, for example https://mattermost.example.com'), + mattermostGuide3: _('如启用 slash command,配置 Callback Path / URL,并确保 Mattermost 服务端能访问 Gateway', 'If slash commands are enabled, configure Callback Path / URL and ensure the Mattermost server can reach Gateway'), + mattermostGuide4: _('自托管内网地址需要显式开启 Private Network 开关,避免误连不受信任地址', 'For self-hosted private-network URLs, explicitly enable Private Network to avoid unsafe internal access'), + mattermostGuide5: _('设置私信/频道策略与允许列表,保存后面板会安装插件并重载 Gateway', 'Set DM/channel policy and allowlists; after saving, the panel installs the plugin and reloads Gateway'), + mattermostGuideFooter: _('
Mattermost 最小配置需要 Bot Token 与 Base URL。
', '
Mattermost minimally requires Bot Token and Base URL.
'), + mattermostBotTokenPh: _('Mattermost Bot Personal Access Token', 'Mattermost Bot Personal Access Token'), + mattermostBaseUrlHint: _('填写站点根地址,不要包含 /api/v4;末尾斜杠会在运行时自动归一化。', 'Use the site root URL without /api/v4; trailing slash is normalized at runtime.'), + mattermostAllowFromPh: _('可选,逗号分隔用户名或用户 ID', 'Optional, comma-separated usernames or user IDs'), + mattermostAllowFromHint: _('用户名可带 @;选择“允许所有私信”时会自动加入 *。', 'Usernames may include @; choosing Allow all DMs automatically adds *.'), + mattermostGroupAllowFromPh: _('可选,逗号分隔频道 ID 或频道名称', 'Optional, comma-separated channel IDs or channel names'), + mattermostCallbackPathHint: _('用于 slash command/交互回调的 Gateway HTTP path。', 'Gateway HTTP path for slash command and interaction callbacks.'), + mattermostNameMatching: _('允许名称匹配', 'Allow Name Matching'), + mattermostNameMatchingHint: _('关闭时优先使用稳定 ID,避免同名用户或频道误匹配。', 'When disabled, prefer stable IDs to avoid wrong matches with duplicate users or channels.'), + mattermostPrivateNetwork: _('允许内网地址', 'Allow Private Network'), + mattermostPrivateNetworkHint: _('仅在 Mattermost 部署于可信内网时开启。', 'Enable only when Mattermost is deployed on a trusted private network.'), discordDesc: _('接入 Discord Bot,支持服务器频道和私信', 'Connect a Discord Bot, supports server channels and DMs', '接入 Discord Bot,支援伺服器頻道和私信', 'Discord Bot に接続'), discordGuide1: _('前往 Discord Developer Portal 创建 Application', '前往 Discord Developer Portal 创建 Application', '前往 Discord Developer Portal 建立 Application'), discordGuide2: _('在 Bot 页面点击「Reset Token」获取 Bot Token', 'Click "Reset Token" on the Bot page to get the Bot Token', '在 Bot 頁面点擊「Reset Token」取得 Bot Token'), @@ -127,6 +164,7 @@ export default { groupDisabled: _('禁用群组', 'Disable groups', '停用群組'), allowFromPh: _('可选,逗号分隔用户/频道 ID', 'Optional, comma-separated user/channel IDs', '可選,逗號分隔使用者/頻道 ID'), allowFromHint: _('限制允许的用户或频道 ID,留空不限制', 'Restrict to specific user or channel IDs; leave empty for no restriction', '限制允許的使用者或頻道 ID,留空不限制'), + accountName: _('账号名称', 'Account Name'), weixinLabel: _('微信', 'WeChat'), weixinDesc: _('通过 openclaw-weixin 插件接入个人微信', 'Connect personal WeChat via the openclaw-weixin plugin', '通過 openclaw-weixin 外掛接入個人微信'), weixinGuide1: _('本功能基于 openclaw-weixin 插件', 'This feature is powered by the openclaw-weixin plugin', '本功能基於 openclaw-weixin 外掛'), @@ -171,7 +209,7 @@ export default { groupAllRooms: _('所有房间', 'All rooms', '所有房間'), groupAllTeams: _('所有团队', 'All teams', '所有團队'), groupMentionBot: _('仅 @机器人时', 'Only when @bot', '僅 @機器人時'), - optionalEg: _('可选,如', 'Optional, e.g.', '可選,如'), + optionalEg: _('可选,如 {example}', 'Optional, e.g. {example}', '可選,如 {example}'), editAccountLabel: _('编辑 {id}', 'Edit {id}', '編輯 {id}'), bound: _('已绑定', 'Bound', '已綁定'), notBoundAgent: _('未绑定 Agent', 'No Agent bound', '未綁定 Agent'), diff --git a/src/pages/channels.js b/src/pages/channels.js index 59d88de..40481fa 100644 --- a/src/pages/channels.js +++ b/src/pages/channels.js @@ -195,6 +195,78 @@ const PLATFORM_REGISTRY = { pluginRequired: '@openclaw/zalouser@latest', pluginId: 'zalouser', }, + line: { + label: 'LINE', + iconName: 'message-circle', + desc: t('channels.lineDesc'), + guide: [ + t('channels.lineGuide1'), + t('channels.lineGuide2'), + t('channels.lineGuide3'), + t('channels.lineGuide4'), + t('channels.lineGuide5'), + ], + guideFooter: t('channels.lineGuideFooter'), + fields: [ + { key: 'channelAccessToken', label: 'Channel Access Token', placeholder: t('channels.lineAccessTokenPh'), secret: true, required: false, hint: t('channels.lineAccessTokenHint') }, + { key: 'tokenFile', label: 'Token File', placeholder: t('channels.lineTokenFilePh'), required: false, hint: t('channels.lineTokenFileHint') }, + { key: 'channelSecret', label: 'Channel Secret', placeholder: t('channels.lineChannelSecretPh'), secret: true, required: false, hint: t('channels.lineChannelSecretHint') }, + { key: 'secretFile', label: 'Secret File', placeholder: t('channels.lineSecretFilePh'), required: false, hint: t('channels.lineSecretFileHint') }, + { key: 'webhookPath', label: 'Webhook Path', placeholder: '/line/webhook', 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.groupAllGroups')), required: false }, + { key: 'allowFrom', label: 'Allow From', placeholder: t('channels.lineAllowFromPh'), required: false, hint: t('channels.lineAllowFromHint') }, + { key: 'groupAllowFrom', label: 'Group Allow From', placeholder: t('channels.lineGroupAllowFromPh'), required: false, hint: t('channels.groupAllowFromHint') }, + { key: 'mediaMaxMb', label: 'Media Max MB', placeholder: '50', required: false }, + { key: 'responsePrefix', label: 'Response Prefix', placeholder: t('channels.optionalEg', { example: '[AI]' }), required: false }, + ], + requiredAny: [ + { keys: ['channelAccessToken', 'tokenFile'], label: t('channels.lineTokenOrFile') }, + { keys: ['channelSecret', 'secretFile'], label: t('channels.lineSecretOrFile') }, + ], + configKey: 'line', + pairingChannel: 'line', + pluginRequired: '@openclaw/line@latest', + pluginId: 'line', + }, + mattermost: { + label: 'Mattermost', + iconName: 'message-square', + desc: t('channels.mattermostDesc'), + guide: [ + t('channels.mattermostGuide1'), + t('channels.mattermostGuide2'), + t('channels.mattermostGuide3'), + t('channels.mattermostGuide4'), + t('channels.mattermostGuide5'), + ], + guideFooter: t('channels.mattermostGuideFooter'), + fields: [ + { key: 'botToken', label: 'Bot Token', placeholder: t('channels.mattermostBotTokenPh'), secret: true, required: true }, + { key: 'baseUrl', label: 'Base URL', placeholder: 'https://mattermost.example.com', required: true, hint: t('channels.mattermostBaseUrlHint') }, + { key: 'name', label: t('channels.accountName'), placeholder: t('channels.optionalEg', { example: 'ops' }), 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.groupAllChannels'), { mention: true }), required: false }, + { key: 'allowFrom', label: 'Allow From', placeholder: t('channels.mattermostAllowFromPh'), required: false, hint: t('channels.mattermostAllowFromHint') }, + { key: 'groupAllowFrom', label: 'Group Allow From', placeholder: t('channels.mattermostGroupAllowFromPh'), required: false, hint: t('channels.groupAllowFromHint') }, + { key: 'replyToMode', label: 'Reply To Mode', type: 'select', required: false, options: [ + { value: '', label: t('channels.policyDefault') }, + { value: 'off', label: t('channels.disable') }, + { value: 'first', label: 'First' }, + { value: 'all', label: 'All' }, + { value: 'batched', label: 'Batched' }, + ] }, + { key: 'callbackPath', label: 'Slash Callback Path', placeholder: '/api/channels/mattermost/command', required: false, hint: t('channels.mattermostCallbackPathHint') }, + { key: 'callbackUrl', label: 'Slash Callback URL', placeholder: 'https://panel.example.com/api/channels/mattermost/command', required: false }, + { key: 'dangerouslyAllowNameMatching', label: t('channels.mattermostNameMatching'), type: 'select', options: BOOLEAN_OPTIONS, required: false, hint: t('channels.mattermostNameMatchingHint') }, + { key: 'dangerouslyAllowPrivateNetwork', label: t('channels.mattermostPrivateNetwork'), type: 'select', options: BOOLEAN_OPTIONS, required: false, hint: t('channels.mattermostPrivateNetworkHint') }, + { key: 'responsePrefix', label: 'Response Prefix', placeholder: t('channels.optionalEg', { example: '[AI]' }), required: false }, + ], + configKey: 'mattermost', + pairingChannel: 'mattermost', + pluginRequired: '@openclaw/mattermost@latest', + pluginId: 'mattermost', + }, discord: { label: 'Discord', iconName: 'hash', @@ -505,7 +577,7 @@ function applyRouteIntent(page, state) { // ── 已配置平台渲染 ── // ── 多账号支持的平台:与 OpenClaw 的 accounts/defaultAccount 配置模型保持一致 ── -const MULTI_INSTANCE_PLATFORMS = ['telegram', 'discord', 'slack', 'feishu', 'dingtalk', 'dingtalk-connector', 'qqbot', 'zalo', 'zalouser'] +const MULTI_INSTANCE_PLATFORMS = ['telegram', 'discord', 'slack', 'feishu', 'dingtalk', 'dingtalk-connector', 'qqbot', 'zalo', 'zalouser', 'line', 'mattermost'] function supportsMessagingMultiAccount(pid) { return MULTI_INSTANCE_PLATFORMS.includes(pid) diff --git a/tests/channel-config-normalization.test.js b/tests/channel-config-normalization.test.js index 3399190..30a8f70 100644 --- a/tests/channel-config-normalization.test.js +++ b/tests/channel-config-normalization.test.js @@ -460,6 +460,115 @@ test('Zalo Personal 保存和诊断按二维码会话型渠道处理', () => { assert.equal(result.checks.find(item => item.id === 'credentials')?.title, '登录/会话配置') }) +test('LINE 渠道保存会写入双凭证组合并支持多账号', () => { + const cfg = { channels: {} } + + mergeOpenClawMessagingPlatformConfig(cfg, { + platform: 'line', + accountId: 'jp', + form: { + tokenFile: '/run/secrets/line-token', + secretFile: '/run/secrets/line-secret', + allowFrom: 'U123, U456', + groupAllowFrom: 'C123', + groupPolicy: 'open', + mediaMaxMb: '25', + webhookPath: '/line/webhook', + }, + }) + + const account = cfg.channels.line.accounts.jp + assert.equal(cfg.channels.line.defaultAccount, 'jp') + assert.equal(account.tokenFile, '/run/secrets/line-token') + assert.equal(account.secretFile, '/run/secrets/line-secret') + assert.deepEqual(account.allowFrom, ['U123', 'U456']) + assert.deepEqual(account.groupAllowFrom, ['C123']) + assert.equal(account.groupPolicy, 'open') + assert.equal(account.mediaMaxMb, 25) + assert.equal(account.webhookPath, '/line/webhook') +}) + +test('LINE 诊断要求 token 与 secret 两组凭证各满足一项', () => { + const ready = buildOpenClawChannelDiagnosis({ + platform: 'line', + configExists: true, + channelEnabled: true, + form: { + channelAccessToken: 'line-token', + secretFile: '/run/secrets/line-secret', + }, + }) + const missingSecret = buildOpenClawChannelDiagnosis({ + platform: 'line', + configExists: true, + channelEnabled: true, + form: { + channelAccessToken: 'line-token', + }, + }) + + assert.equal(ready.checks.find(item => item.id === 'credentials')?.ok, true) + assert.equal(missingSecret.checks.find(item => item.id === 'credentials')?.ok, false) + assert.match(missingSecret.checks.find(item => item.id === 'credentials')?.detail || '', /Channel Secret.*Secret File/) +}) + +test('Mattermost 渠道保存会写入嵌套命令和网络配置', () => { + const cfg = { channels: {} } + + mergeOpenClawMessagingPlatformConfig(cfg, { + platform: 'mattermost', + accountId: 'ops', + form: { + botToken: 'mattermost-token', + baseUrl: 'https://mattermost.example.com/', + groupPolicy: 'mentioned', + allowFrom: '@alice, bob', + groupAllowFrom: 'town-square', + callbackPath: '/api/channels/mattermost/ops', + callbackUrl: 'https://panel.example.com/api/channels/mattermost/ops', + dangerouslyAllowNameMatching: 'true', + dangerouslyAllowPrivateNetwork: 'true', + replyToMode: 'all', + }, + }) + + const account = cfg.channels.mattermost.accounts.ops + assert.equal(cfg.channels.mattermost.defaultAccount, 'ops') + assert.equal(account.botToken, 'mattermost-token') + assert.equal(account.baseUrl, 'https://mattermost.example.com/') + assert.equal(account.groupPolicy, 'open') + assert.equal(account.requireMention, true) + assert.deepEqual(account.allowFrom, ['@alice', 'bob']) + assert.deepEqual(account.groupAllowFrom, ['town-square']) + assert.equal(account.commands.callbackPath, '/api/channels/mattermost/ops') + assert.equal(account.commands.callbackUrl, 'https://panel.example.com/api/channels/mattermost/ops') + assert.equal(account.network.dangerouslyAllowPrivateNetwork, true) + assert.equal(account.dangerouslyAllowNameMatching, true) + assert.equal(account.replyToMode, 'all') +}) + +test('Mattermost 诊断要求 Bot Token 和 Base URL', () => { + const missingBaseUrl = buildOpenClawChannelDiagnosis({ + platform: 'mattermost', + configExists: true, + channelEnabled: true, + form: { botToken: 'mattermost-token' }, + }) + const ready = buildOpenClawChannelDiagnosis({ + platform: 'mattermost', + configExists: true, + channelEnabled: true, + form: { + botToken: 'mattermost-token', + baseUrl: 'https://mattermost.example.com', + }, + }) + + assert.equal(missingBaseUrl.checks.find(item => item.id === 'credentials')?.ok, false) + assert.match(missingBaseUrl.checks.find(item => item.id === 'credentials')?.detail || '', /Base URL/) + assert.equal(ready.checks.find(item => item.id === 'credentials')?.ok, true) +}) + test('Discord 渠道保存会保留运行时需要的 applicationId', () => { const cfg = { channels: {} }