diff --git a/scripts/dev-api.js b/scripts/dev-api.js index e6921e9..b4e8169 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -2472,7 +2472,11 @@ export function normalizeMessagingPlatformForm(platform, form = {}) { normalized.allowedUserIds = csvToStringArray(normalized.allowedUserIds) } - for (const key of ['mediaMaxMb', 'historyLimit', 'dmHistoryLimit', 'textChunkLimit', 'rateLimitPerMinute', 'httpPort']) { + for (const key of ['promptStarters', 'delegatedAuthScopes']) { + if (Object.hasOwn(normalized, key)) normalized[key] = csvToStringArray(normalized[key]) + } + + for (const key of ['mediaMaxMb', 'historyLimit', 'dmHistoryLimit', 'textChunkLimit', 'rateLimitPerMinute', 'httpPort', 'webhookPort', 'feedbackReflectionCooldownMs']) { if (!Object.hasOwn(normalized, key)) continue const value = String(normalized[key] || '').trim() if (!value) { @@ -2485,7 +2489,7 @@ export function normalizeMessagingPlatformForm(platform, form = {}) { } } - for (const key of ['dangerouslyAllowNameMatching', 'dangerouslyAllowPrivateNetwork', 'dangerouslyAllowInheritedWebhookPath', 'allowInsecureSsl', 'allowBots', 'blockStreaming']) { + for (const key of ['dangerouslyAllowNameMatching', 'dangerouslyAllowPrivateNetwork', 'dangerouslyAllowInheritedWebhookPath', 'allowInsecureSsl', 'allowBots', 'blockStreaming', 'useManagedIdentity', 'typingIndicator', 'welcomeCard', 'groupWelcomeCard', 'feedbackEnabled', 'feedbackReflection', 'delegatedAuthEnabled', 'ssoEnabled']) { if (Object.hasOwn(normalized, key)) { const value = String(normalized[key] || '').trim() if (!value) { @@ -2612,6 +2616,33 @@ function hasConfiguredMessagingValue(value) { return value !== undefined && value !== null } +function isEnabledFormFlag(value) { + if (typeof value === 'boolean') return value + if (typeof value === 'number') return value !== 0 + if (typeof value === 'string') { + return ['true', '1', 'yes', 'on', 'enabled'].includes(value.trim().toLowerCase()) + } + return false +} + +function msteamsCredentialMissingLabels(form = {}) { + const missing = [] + if (!hasConfiguredMessagingValue(form?.appId)) missing.push('App ID') + if (missing.length) return missing + + if (hasConfiguredMessagingValue(form?.appPassword)) return [] + if (isEnabledFormFlag(form?.useManagedIdentity)) return [] + + const authType = String(form?.authType || '').trim().toLowerCase() + const hasFederatedCredential = hasConfiguredMessagingValue(form?.certificatePath) || hasConfiguredMessagingValue(form?.certificateThumbprint) + if (authType === 'federated' && hasFederatedCredential) return [] + + if (authType === 'federated') { + return ['Certificate Path / Certificate Thumbprint / Managed Identity / App Password'] + } + return ['App Password'] +} + function channelRootHasMessagingCredential(root) { if (!root || typeof root !== 'object' || Array.isArray(root)) return false return MESSAGING_CREDENTIAL_FIELDS.some(key => hasConfiguredMessagingValue(root[key])) @@ -2649,7 +2680,6 @@ const CHANNEL_DIAG_REQUIRED_FIELDS = { feishu: [['appId', 'App ID'], ['appSecret', 'App Secret']], 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']], 'synology-chat': [['token', 'Token'], ['incomingUrl', 'Incoming URL']], signal: [['account', 'Signal 账号']], @@ -2668,11 +2698,15 @@ function requiredChannelCredentialFields(platform, form = {}) { if (form.accessToken) return [['accessToken', 'Access Token']] return [['homeserver', 'Homeserver'], ['userId', 'User ID'], ['password', 'Password']] } + if (storageKey === 'msteams') { + return msteamsCredentialMissingLabels(form).map(label => [label === 'App ID' ? 'appId' : '__msteamsAuth', label]) + } return CHANNEL_DIAG_REQUIRED_FIELDS[storageKey] || [] } function channelDiagnosisCredentialsReady(platform, form = {}) { if (platformStorageKey(platform) === 'zalouser') return true + if (platformStorageKey(platform) === 'msteams') return msteamsCredentialMissingLabels(form).length === 0 const requiredFields = requiredChannelCredentialFields(platform, form) if (requiredFields.length) { return requiredFields.every(([key]) => hasConfiguredMessagingValue(form?.[key])) @@ -2726,9 +2760,11 @@ 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 missing = storageKey === 'msteams' + ? msteamsCredentialMissingLabels(form) + : 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) @@ -2907,11 +2943,45 @@ export function buildMessagingPlatformFormValues(platform, saved = {}, options = } if (storageKey === 'msteams') { - for (const key of ['appId', 'appPassword', 'tenantId', 'botEndpoint', 'webhookPath']) { + for (const key of ['appId', 'appPassword', 'tenantId', 'authType', 'certificatePath', 'certificateThumbprint', 'managedIdentityClientId', 'botEndpoint', 'replyStyle', 'sharePointSiteId', 'responsePrefix', 'ssoConnectionName']) { putSecretAwareFormValue(form, saved, key) } - putAccessPolicyFormValues(form, saved) + putStringFormValue(form, saved?.webhook, 'path') + if (form.path) { + form.webhookPath = form.path + delete form.path + } + if (typeof saved?.webhook?.port === 'number') form.webhookPort = String(saved.webhook.port) + putAccessPolicyFormValues(form, saved, { mentionCompat: true }) + putCsvFormValue(form, saved, 'groupAllowFrom') putBoolFormValue(form, saved, 'requireMention') + for (const key of ['useManagedIdentity', 'blockStreaming', 'typingIndicator', 'welcomeCard', 'groupWelcomeCard', 'feedbackEnabled', 'feedbackReflection']) { + putBoolFormValue(form, saved, key) + } + for (const key of ['historyLimit', 'dmHistoryLimit', 'textChunkLimit', 'mediaMaxMb', 'feedbackReflectionCooldownMs']) { + if (typeof saved[key] === 'number') form[key] = String(saved[key]) + } + putCsvFormValue(form, saved, 'promptStarters') + putBoolFormValue(form, saved?.delegatedAuth, 'enabled') + if (form.enabled) { + form.delegatedAuthEnabled = form.enabled + delete form.enabled + } + putCsvFormValue(form, saved?.delegatedAuth, 'scopes') + if (form.scopes) { + form.delegatedAuthScopes = form.scopes + delete form.scopes + } + putBoolFormValue(form, saved?.sso, 'enabled') + if (form.enabled) { + form.ssoEnabled = form.enabled + delete form.enabled + } + putStringFormValue(form, saved?.sso, 'connectionName') + if (form.connectionName) { + form.ssoConnectionName = form.connectionName + delete form.connectionName + } return form } @@ -3512,6 +3582,33 @@ 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 === 'msteams') { + for (const key of ['appId', 'appPassword', 'tenantId', 'authType', 'certificatePath', 'certificateThumbprint', 'managedIdentityClientId', 'replyStyle', 'sharePointSiteId', 'responsePrefix']) { + if (form[key]) entry[key] = form[key] + } + const webhook = { ...(currentSaved?.webhook && typeof currentSaved.webhook === 'object' ? currentSaved.webhook : {}) } + if (typeof form.webhookPort === 'number') webhook.port = form.webhookPort + if (form.webhookPath) webhook.path = form.webhookPath + if (Object.keys(webhook).length) entry.webhook = webhook + entry.dmPolicy = form.dmPolicy + entry.groupPolicy = form.groupPolicy + if (Array.isArray(form.allowFrom) && form.allowFrom.length) entry.allowFrom = form.allowFrom + if (Array.isArray(form.groupAllowFrom) && form.groupAllowFrom.length) entry.groupAllowFrom = form.groupAllowFrom + for (const key of ['useManagedIdentity', 'requireMention', 'blockStreaming', 'typingIndicator', 'welcomeCard', 'groupWelcomeCard', 'feedbackEnabled', 'feedbackReflection']) { + if (typeof form[key] === 'boolean') entry[key] = form[key] + } + for (const key of ['historyLimit', 'dmHistoryLimit', 'textChunkLimit', 'mediaMaxMb', 'feedbackReflectionCooldownMs']) { + if (typeof form[key] === 'number') entry[key] = form[key] + } + if (Array.isArray(form.promptStarters) && form.promptStarters.length) entry.promptStarters = form.promptStarters + const delegatedAuth = { ...(currentSaved?.delegatedAuth && typeof currentSaved.delegatedAuth === 'object' ? currentSaved.delegatedAuth : {}) } + if (typeof form.delegatedAuthEnabled === 'boolean') delegatedAuth.enabled = form.delegatedAuthEnabled + if (Array.isArray(form.delegatedAuthScopes) && form.delegatedAuthScopes.length) delegatedAuth.scopes = form.delegatedAuthScopes + if (Object.keys(delegatedAuth).length) entry.delegatedAuth = delegatedAuth + const sso = { ...(currentSaved?.sso && typeof currentSaved.sso === 'object' ? currentSaved.sso : {}) } + if (typeof form.ssoEnabled === 'boolean') sso.enabled = form.ssoEnabled + if (form.ssoConnectionName) sso.connectionName = form.ssoConnectionName + if (Object.keys(sso).length) entry.sso = sso } else if (storageKey === 'zalouser') { for (const key of ['profile', 'messagePrefix', 'responsePrefix']) { if (form[key]) entry[key] = form[key] @@ -3589,7 +3686,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'].includes(storageKey)) { + if (['zalo', 'zalouser', 'line', 'mattermost', 'synology-chat', 'googlechat', 'msteams'].includes(storageKey)) { ensureMessagingPluginAllowed(cfg, storageKey) } return { entry, accountId: normalizedAccountId, storageKey } @@ -5059,7 +5156,7 @@ const handlers = { } else { setRootChannelEntry(entry) } - } else if (['line', 'mattermost', 'synology-chat', 'googlechat'].includes(storageKey)) { + } else if (['line', 'mattermost', 'synology-chat', 'googlechat', 'msteams'].includes(storageKey)) { const built = buildOpenClawMessagingPlatformEntry(platform, form, currentSaved) applyMessagingPlatformEntry(cfg, storageKey, normalizedAccountId, built) ensureMessagingPluginAllowed(cfg, storageKey) @@ -5068,7 +5165,7 @@ const handlers = { preserveMessagingCredentialRefs(entry, form, currentSaved) } - if (platform !== 'qqbot' && platform !== 'feishu' && platform !== 'dingtalk' && platform !== 'dingtalk-connector' && !['line', 'mattermost', 'synology-chat', 'googlechat'].includes(storageKey)) { + if (platform !== 'qqbot' && platform !== 'feishu' && platform !== 'dingtalk' && platform !== 'dingtalk-connector' && !['line', 'mattermost', 'synology-chat', 'googlechat', 'msteams'].includes(storageKey)) { preserveMessagingCredentialRefs(entry, form, currentSaved) // 合并模式:保留用户通过 CLI 或手动编辑的自定义字段 applyMessagingPlatformEntry(cfg, storageKey, normalizedAccountId, entry) @@ -5204,6 +5301,39 @@ const handlers = { return { valid: false, errors: [`Discord API 连接失败: ${e.message}`] } } } + if (platform === 'msteams') { + const missing = msteamsCredentialMissingLabels(form) + if (missing.length) return { valid: false, errors: [`缺少 ${missing.join(' / ')}`] } + if (!hasConfiguredMessagingValue(form.appPassword)) { + return { + valid: true, + warnings: ['当前 Teams 认证模式不使用 Client Secret;面板已完成结构校验,实际连通性请通过 Gateway 启动日志或 openclaw channels status --probe 验证。'], + details: [`App ID: ${String(form.appId || '').trim()}`], + } + } + const tenantId = String(form.tenantId || 'botframework.com').trim() || 'botframework.com' + try { + const body = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: String(form.appId || '').trim(), + client_secret: String(form.appPassword || '').trim(), + scope: 'https://api.botframework.com/.default', + }) + const resp = await fetch(`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + signal: AbortSignal.timeout(15000), + }) + const result = await resp.json() + if (result.access_token) { + return { valid: true, errors: [], details: [`App ID: ${form.appId}`, `Tenant: ${tenantId}`, `Token 有效期: ${result.expires_in || 0}s`] } + } + return { valid: false, errors: [result.error_description || result.error || '凭证无效,请检查 App ID 和 App Password'] } + } catch (e) { + return { valid: false, errors: [`Azure AD 连接失败: ${e.message}`] } + } + } return { valid: true, warnings: ['该平台暂不支持在线校验'] } }, diff --git a/src-tauri/src/commands/messaging.rs b/src-tauri/src/commands/messaging.rs index fe78e7d..19afdab 100644 --- a/src-tauri/src/commands/messaging.rs +++ b/src-tauri/src/commands/messaging.rs @@ -179,6 +179,41 @@ fn has_configured_messaging_value(value: Option<&Value>) -> bool { } } +fn is_enabled_form_flag(value: Option<&Value>) -> bool { + match value { + Some(Value::Bool(v)) => *v, + Some(Value::Number(v)) => v.as_i64().map(|n| n != 0).unwrap_or(false), + Some(Value::String(raw)) => matches!( + raw.trim().to_ascii_lowercase().as_str(), + "true" | "1" | "yes" | "on" | "enabled" + ), + _ => false, + } +} + +fn msteams_credential_missing_labels(form: &Map) -> Vec<&'static str> { + if !has_configured_messaging_value(form.get("appId")) { + return vec!["App ID"]; + } + if has_configured_messaging_value(form.get("appPassword")) { + return vec![]; + } + if is_enabled_form_flag(form.get("useManagedIdentity")) { + return vec![]; + } + + let auth_type = form_string(form, "authType").to_ascii_lowercase(); + let has_federated_credential = has_configured_messaging_value(form.get("certificatePath")) + || has_configured_messaging_value(form.get("certificateThumbprint")); + if auth_type == "federated" && has_federated_credential { + return vec![]; + } + if auth_type == "federated" { + return vec!["Certificate Path / Certificate Thumbprint / Managed Identity / App Password"]; + } + vec!["App Password"] +} + fn channel_root_has_messaging_credential(root: &Map) -> bool { [ "accessToken", @@ -223,7 +258,6 @@ fn required_channel_credential_fields( "discord" => vec![("token", "Bot Token")], "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")], "synology-chat" => vec![("token", "Token"), ("incomingUrl", "Incoming URL")], "signal" => vec![("account", "Signal 账号")], @@ -249,6 +283,16 @@ fn required_channel_credential_fields( ] } } + "msteams" => msteams_credential_missing_labels(form) + .into_iter() + .map(|label| { + if label == "App ID" { + ("appId", "App ID") + } else { + ("__msteamsAuth", label) + } + }) + .collect(), _ => vec![], } } @@ -293,6 +337,9 @@ fn channel_diagnosis_credentials_ready(platform: &str, form: &Map if platform_storage_key(platform) == "zalouser" { return true; } + if platform_storage_key(platform) == "msteams" { + return msteams_credential_missing_labels(form).is_empty(); + } let required_fields = required_channel_credential_fields(platform, form); if !required_fields.is_empty() { return required_fields @@ -393,11 +440,15 @@ 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: Vec<&str> = if storage_key == "msteams" { + msteams_credential_missing_labels(form) + } else { + 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)| { @@ -816,6 +867,15 @@ fn normalize_messaging_platform_form( normalize_numeric_form_value(&mut normalized, "textChunkLimit"); 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"] { + if normalized.contains_key(key) { + let items = json_array_from_csv_value(normalized.get(key)); + normalized.insert(key.into(), Value::Array(items)); + } + } for key in [ "dangerouslyAllowNameMatching", @@ -824,6 +884,14 @@ fn normalize_messaging_platform_form( "allowInsecureSsl", "allowBots", "blockStreaming", + "useManagedIdentity", + "typingIndicator", + "welcomeCard", + "groupWelcomeCard", + "feedbackEnabled", + "feedbackReflection", + "delegatedAuthEnabled", + "ssoEnabled", ] { if normalized.contains_key(key) { let value = match normalized.get(key) { @@ -1451,11 +1519,75 @@ pub async fn read_platform_config( "msteams" => { insert_secret_aware_form_value(&mut form, &saved, "appId"); insert_secret_aware_form_value(&mut form, &saved, "appPassword"); - insert_string_if_present(&mut form, &saved, "tenantId"); - insert_string_if_present(&mut form, &saved, "botEndpoint"); - insert_string_if_present(&mut form, &saved, "webhookPath"); + for key in [ + "tenantId", + "authType", + "certificatePath", + "certificateThumbprint", + "managedIdentityClientId", + "botEndpoint", + "replyStyle", + "sharePointSiteId", + "responsePrefix", + ] { + insert_string_if_present(&mut form, &saved, key); + } + if let Some(webhook) = saved.get("webhook") { + insert_string_if_present(&mut form, webhook, "path"); + if let Some(v) = form.remove("path") { + form.insert("webhookPath".into(), v); + } + insert_number_as_string(&mut form, webhook, "port"); + if let Some(v) = form.remove("port") { + form.insert("webhookPort".into(), v); + } + } else { + insert_string_if_present(&mut form, &saved, "webhookPath"); + } 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, "requireMention"); + for key in [ + "useManagedIdentity", + "blockStreaming", + "typingIndicator", + "welcomeCard", + "groupWelcomeCard", + "feedbackEnabled", + "feedbackReflection", + ] { + insert_bool_as_string(&mut form, &saved, key); + } + for key in [ + "historyLimit", + "dmHistoryLimit", + "textChunkLimit", + "mediaMaxMb", + "feedbackReflectionCooldownMs", + ] { + insert_number_as_string(&mut form, &saved, key); + } + insert_array_as_csv(&mut form, &saved, "promptStarters"); + if let Some(delegated_auth) = saved.get("delegatedAuth") { + insert_bool_as_string(&mut form, delegated_auth, "enabled"); + if let Some(v) = form.remove("enabled") { + form.insert("delegatedAuthEnabled".into(), v); + } + insert_array_as_csv(&mut form, delegated_auth, "scopes"); + if let Some(v) = form.remove("scopes") { + form.insert("delegatedAuthScopes".into(), v); + } + } + if let Some(sso) = saved.get("sso") { + insert_bool_as_string(&mut form, sso, "enabled"); + if let Some(v) = form.remove("enabled") { + form.insert("ssoEnabled".into(), v); + } + insert_string_if_present(&mut form, sso, "connectionName"); + if let Some(v) = form.remove("connectionName") { + form.insert("ssoConnectionName".into(), v); + } + } } "line" => { for key in [ @@ -2156,33 +2288,99 @@ pub async fn save_messaging_platform( "msteams" => { let app_id = form_string(form_obj, "appId"); let app_password = form_string(form_obj, "appPassword"); - if app_id.is_empty() || app_password.is_empty() { - return Err("App ID 和 App Password 不能为空".into()); + let missing_credentials = msteams_credential_missing_labels(form_obj); + if !missing_credentials.is_empty() { + return Err(format!("缺少 {}", missing_credentials.join(" / "))); } let mut entry = Map::new(); entry.insert("enabled".into(), Value::Bool(true)); put_string(&mut entry, "appId", app_id); put_string(&mut entry, "appPassword", app_password); - put_string(&mut entry, "tenantId", form_string(form_obj, "tenantId")); - put_string( - &mut entry, - "botEndpoint", - form_string(form_obj, "botEndpoint"), - ); - put_string( - &mut entry, - "webhookPath", - form_string(form_obj, "webhookPath"), - ); + for key in [ + "tenantId", + "authType", + "certificatePath", + "certificateThumbprint", + "managedIdentityClientId", + "replyStyle", + "sharePointSiteId", + "responsePrefix", + ] { + put_string(&mut entry, key, form_string(form_obj, key)); + } + let mut webhook = current_saved + .get("webhook") + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_default(); + put_number_from_form(&mut webhook, "port", &form_string(form_obj, "webhookPort")); + put_string(&mut webhook, "path", form_string(form_obj, "webhookPath")); + if !webhook.is_empty() { + entry.insert("webhook".into(), Value::Object(webhook)); + } 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")); + for key in [ + "useManagedIdentity", + "requireMention", + "blockStreaming", + "typingIndicator", + "welcomeCard", + "groupWelcomeCard", + "feedbackEnabled", + "feedbackReflection", + ] { + put_bool_value_if_present(&mut entry, key, form_obj.get(key)); + } + for key in [ + "historyLimit", + "dmHistoryLimit", + "textChunkLimit", + "mediaMaxMb", + "feedbackReflectionCooldownMs", + ] { + put_number_from_form(&mut entry, key, &form_string(form_obj, key)); + } + put_array_from_form_value(&mut entry, "promptStarters", form_obj.get("promptStarters")); + let mut delegated_auth = current_saved + .get("delegatedAuth") + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_default(); + put_bool_value_if_present( + &mut delegated_auth, + "enabled", + form_obj.get("delegatedAuthEnabled"), + ); + put_array_from_form_value( + &mut delegated_auth, + "scopes", + form_obj.get("delegatedAuthScopes"), + ); + if !delegated_auth.is_empty() { + entry.insert("delegatedAuth".into(), Value::Object(delegated_auth)); + } + let mut sso = current_saved + .get("sso") + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_default(); + put_bool_value_if_present(&mut sso, "enabled", form_obj.get("ssoEnabled")); + put_string( + &mut sso, + "connectionName", + form_string(form_obj, "ssoConnectionName"), + ); + if !sso.is_empty() { + entry.insert("sso".into(), Value::Object(sso)); + } preserve_messaging_credential_refs(&mut entry, form_obj, ¤t_saved); merge_channel_entry_for_account( channels_map, @@ -4000,8 +4198,18 @@ async fn verify_msteams( if app_id.is_empty() { return Ok(json!({ "valid": false, "errors": ["App ID 不能为空"] })); } + let missing_credentials = msteams_credential_missing_labels(form); + if !missing_credentials.is_empty() { + return Ok( + json!({ "valid": false, "errors": [format!("缺少 {}", missing_credentials.join(" / "))] }), + ); + } if app_password.is_empty() { - return Ok(json!({ "valid": false, "errors": ["App Password 不能为空"] })); + return Ok(json!({ + "valid": true, + "warnings": ["当前 Teams 认证模式不使用 Client Secret;面板已完成结构校验,实际连通性请通过 Gateway 启动日志或 openclaw channels status --probe 验证。"], + "details": [format!("App ID: {}", app_id)] + })); } let token_url = format!( diff --git a/src/locales/modules/channels.js b/src/locales/modules/channels.js index e13548e..1c89b61 100644 --- a/src/locales/modules/channels.js +++ b/src/locales/modules/channels.js @@ -216,8 +216,12 @@ export default { msteamsGuide3: _('在 Teams 中安装自定义 App', 'Install the custom App in Teams', '在 Teams 中安裝自定義 App'), msteamsGuide4: _('配置消息端点 URL', 'Configure the messaging endpoint URL', '設定訊息端点 URL'), msteamsGuideFooter: _('
需要 Azure AD 应用注册和 Teams 管理员权限
', '
需要 Azure AD 应用注册和 Teams 管理员权限
', '
需要 Azure AD 應用註冊和 Teams 管理員權限
'), + msteamsAppPasswordHint: _('Client Secret 模式必填;Federated / Managed Identity 可留空', 'Required for Client Secret mode; leave empty for Federated / Managed Identity', 'Client Secret 模式必填;Federated / Managed Identity 可留空'), msteamsTenantIdPh: _('可选,单租户时填写', 'Optional, fill for single-tenant', '可選,單租戶時填写'), msteamsAllowFromPh: _('可选,逗号分隔', 'Optional, comma-separated', '可選,逗號分隔'), + msteamsGroupAllowFromPh: _('可选,逗号分隔 Team 或 Channel ID', 'Optional, comma-separated Team or Channel IDs', '可選,逗號分隔 Team 或 Channel ID'), + msteamsPromptStartersPh: _('可选,逗号分隔,例如 help, status', 'Optional, comma-separated, e.g. help, status', '可選,逗號分隔,例如 help, status'), + msteamsDelegatedScopesPh: _('可选,逗号分隔 Microsoft Graph scopes', 'Optional, comma-separated Microsoft Graph scopes', '可選,逗號分隔 Microsoft Graph scopes'), signalDesc: _('接入 Signal Messenger', 'Connect to Signal Messenger', '', 'Signal に接続'), signalGuide1: _('安装 signal-cli 并注册/链接账号', '安装 signal-cli 并注册/链接账号', '安裝 signal-cli 並註冊/連結账號'), signalGuide2: _('确保 signal-cli 可正常收发消息', 'Ensure signal-cli can send and receive messages', '確保 signal-cli 可正常收發訊息'), diff --git a/src/pages/channels.js b/src/pages/channels.js index 6c454c4..f853612 100644 --- a/src/pages/channels.js +++ b/src/pages/channels.js @@ -430,13 +430,34 @@ const PLATFORM_REGISTRY = { guideFooter: t('channels.msteamsGuideFooter'), fields: [ { key: 'appId', label: 'App ID', placeholder: 'Azure AD Application ID', required: true }, - { key: 'appPassword', label: 'App Password', placeholder: 'Azure AD Client Secret', secret: true, required: true }, + { key: 'appPassword', label: 'App Password', placeholder: 'Azure AD Client Secret', secret: true, required: (form) => form.authType !== 'federated' && form.useManagedIdentity !== 'true', hint: t('channels.msteamsAppPasswordHint') }, { key: 'tenantId', label: 'Tenant ID', placeholder: t('channels.msteamsTenantIdPh'), required: false }, - { key: 'botEndpoint', label: 'Bot Endpoint', placeholder: 'https://example.com/api/teams/messages', required: false }, - { key: 'webhookPath', label: 'Webhook Path', placeholder: '/msteams/messages', required: false }, + { key: 'authType', label: 'Auth Type', type: 'select', options: [{ value: '', label: t('channels.policyDefault') }, { value: 'secret', label: 'Client Secret' }, { value: 'federated', label: 'Federated / Certificate' }], required: false }, + { key: 'certificatePath', label: 'Certificate Path', placeholder: t('channels.optionalEg', { example: '/run/secrets/teams.pem' }), required: false }, + { key: 'certificateThumbprint', label: 'Certificate Thumbprint', placeholder: t('channels.optionalEg', { example: 'ABCD1234' }), required: false }, + { key: 'useManagedIdentity', label: 'Managed Identity', type: 'select', options: BOOLEAN_OPTIONS, required: false }, + { key: 'managedIdentityClientId', label: 'Managed Identity Client ID', placeholder: t('channels.optionalEg', { example: '00000000-0000-0000-0000-000000000000' }), required: false }, + { key: 'webhookPort', label: 'Webhook Port', placeholder: '3978', required: false }, + { key: 'webhookPath', label: 'Webhook Path', placeholder: '/api/teams/messages', 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.groupAllTeams'), { mention: true }), required: false }, { key: 'allowFrom', label: 'Allow From', placeholder: t('channels.msteamsAllowFromPh'), required: false }, + { key: 'groupAllowFrom', label: 'Group Allow From', placeholder: t('channels.msteamsGroupAllowFromPh'), 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: 'textChunkLimit', label: 'Text Chunk Limit', placeholder: '1800', required: false }, + { key: 'mediaMaxMb', label: 'Media Max MB', placeholder: '100', required: false }, + { key: 'blockStreaming', label: t('channels.signalBlockStreaming'), type: 'select', options: BOOLEAN_OPTIONS, required: false }, + { key: 'typingIndicator', label: 'Typing Indicator', type: 'select', options: BOOLEAN_OPTIONS, required: false }, + { key: 'replyStyle', label: 'Reply Style', type: 'select', options: [{ value: '', label: t('channels.policyDefault') }, { value: 'thread', label: 'Thread' }, { value: 'top-level', label: 'Top-level' }], required: false }, + { key: 'sharePointSiteId', label: 'SharePoint Site ID', placeholder: t('channels.optionalEg', { example: 'contoso.sharepoint.com,guid1,guid2' }), required: false }, + { key: 'responsePrefix', label: 'Response Prefix', placeholder: t('channels.optionalEg', { example: '[Teams]' }), required: false }, + { key: 'welcomeCard', label: 'Welcome Card', type: 'select', options: BOOLEAN_OPTIONS, required: false }, + { key: 'promptStarters', label: 'Prompt Starters', placeholder: t('channels.msteamsPromptStartersPh'), required: false }, + { key: 'delegatedAuthEnabled', label: 'Delegated Auth', type: 'select', options: BOOLEAN_OPTIONS, required: false }, + { key: 'delegatedAuthScopes', label: 'Delegated Auth Scopes', placeholder: t('channels.msteamsDelegatedScopesPh'), required: false }, + { key: 'ssoEnabled', label: 'SSO', type: 'select', options: BOOLEAN_OPTIONS, required: false }, + { key: 'ssoConnectionName', label: 'SSO Connection Name', placeholder: t('channels.optionalEg', { example: 'teams-oauth' }), required: false }, ], configKey: 'msteams', pluginRequired: '@openclaw/msteams@latest', @@ -2204,6 +2225,7 @@ async function openConfigDialog(pid, page, state, accountId) { ` const isFieldRequired = (field, form) => { + if (typeof field.required === 'function') return field.required(form || {}) if (field.required) return true if (!field.requiredWhen) return false return Object.entries(field.requiredWhen).every(([k, expected]) => (form[k] || '') === expected) @@ -2221,6 +2243,7 @@ async function openConfigDialog(pid, page, state, accountId) { const fieldsHtml = reg.fields.map((f, i) => { const val = existing[f.key] || '' const secretRefLocked = existing.__secretRefs?.[f.key] + const fieldRequired = isFieldRequired(f, existing) const fieldHint = [ f.hint, secretRefLocked ? t('channels.secretRefPreserveHint') : '', @@ -2228,7 +2251,7 @@ async function openConfigDialog(pid, page, state, accountId) { if (f.type === 'select' && f.options) { return `
- + @@ -2239,7 +2262,7 @@ async function openConfigDialog(pid, page, state, accountId) { if (f.multiline) { return `
- + ${fieldHint ? `
${fieldHint}
` : ''}
@@ -2247,7 +2270,7 @@ async function openConfigDialog(pid, page, state, accountId) { } return `
- +
{ + const form = collectForm() + reg.fields.forEach(f => { + const marker = modal.querySelector(`.required-marker[data-required-for="${f.key}"]`) + if (marker) marker.textContent = isFieldRequired(f, form) ? ' *' : '' + }) + } + modal.querySelectorAll('input[name], select[name], textarea[name]').forEach(el => { + el.addEventListener('input', updateRequiredMarkers) + el.addEventListener('change', updateRequiredMarkers) + }) + updateRequiredMarkers() + // 校验按钮 const btnVerify = modal.querySelector('#btn-verify') const btnSave = modal.querySelector('#btn-save') diff --git a/tests/channel-config-normalization.test.js b/tests/channel-config-normalization.test.js index 5420eac..aa62468 100644 --- a/tests/channel-config-normalization.test.js +++ b/tests/channel-config-normalization.test.js @@ -775,6 +775,151 @@ test('Google Chat 诊断要求 service account 文件或内联 JSON 其中一项 assert.equal(ready.checks.find(item => item.id === 'credentials')?.ok, true) }) +test('Microsoft Teams 渠道保存会写入新版认证和运行字段', () => { + const cfg = { channels: {} } + + mergeOpenClawMessagingPlatformConfig(cfg, { + platform: 'msteams', + form: { + appId: 'teams-app-id', + appPassword: 'teams-secret', + tenantId: 'tenant-1', + authType: 'federated', + certificatePath: '/run/secrets/teams.pem', + certificateThumbprint: 'thumbprint-1', + useManagedIdentity: 'true', + managedIdentityClientId: 'identity-client-id', + webhookPort: '3978', + webhookPath: '/api/teams/messages', + dmPolicy: 'allowlist', + allowFrom: 'user-a, user-b', + groupPolicy: 'mentioned', + groupAllowFrom: 'team-1, team-2', + textChunkLimit: '1800', + historyLimit: '80', + dmHistoryLimit: '20', + mediaMaxMb: '100', + blockStreaming: 'true', + typingIndicator: 'true', + replyStyle: 'thread', + sharePointSiteId: 'contoso.sharepoint.com,guid1,guid2', + responsePrefix: '[Teams]', + welcomeCard: 'true', + promptStarters: 'help, status', + delegatedAuthEnabled: 'true', + delegatedAuthScopes: 'User.Read, offline_access', + ssoEnabled: 'true', + ssoConnectionName: 'teams-oauth', + }, + }) + + const entry = cfg.channels.msteams + assert.equal(entry.appId, 'teams-app-id') + assert.equal(entry.appPassword, 'teams-secret') + assert.equal(entry.tenantId, 'tenant-1') + assert.equal(entry.authType, 'federated') + assert.equal(entry.certificatePath, '/run/secrets/teams.pem') + assert.equal(entry.certificateThumbprint, 'thumbprint-1') + assert.equal(entry.useManagedIdentity, true) + assert.equal(entry.managedIdentityClientId, 'identity-client-id') + assert.deepEqual(entry.webhook, { port: 3978, path: '/api/teams/messages' }) + assert.equal(entry.dmPolicy, 'allowlist') + assert.deepEqual(entry.allowFrom, ['user-a', 'user-b']) + assert.equal(entry.groupPolicy, 'open') + assert.equal(entry.requireMention, true) + assert.deepEqual(entry.groupAllowFrom, ['team-1', 'team-2']) + assert.equal(entry.textChunkLimit, 1800) + assert.equal(entry.historyLimit, 80) + assert.equal(entry.dmHistoryLimit, 20) + assert.equal(entry.mediaMaxMb, 100) + assert.equal(entry.blockStreaming, true) + assert.equal(entry.typingIndicator, true) + assert.equal(entry.replyStyle, 'thread') + assert.equal(entry.sharePointSiteId, 'contoso.sharepoint.com,guid1,guid2') + assert.equal(entry.responsePrefix, '[Teams]') + assert.equal(entry.welcomeCard, true) + assert.deepEqual(entry.promptStarters, ['help', 'status']) + assert.deepEqual(entry.delegatedAuth, { enabled: true, scopes: ['User.Read', 'offline_access'] }) + assert.deepEqual(entry.sso, { enabled: true, connectionName: 'teams-oauth' }) + assert.equal(cfg.plugins.entries.msteams.enabled, true) +}) + +test('Microsoft Teams 渠道读取会回显嵌套 webhook 和运行字段', () => { + const values = buildMessagingPlatformFormValues('msteams', { + appId: 'teams-app-id', + appPassword: 'teams-secret', + authType: 'federated', + webhook: { port: 3978, path: '/api/teams/messages' }, + dmPolicy: 'allowlist', + allowFrom: ['user-a', 'user-b'], + groupPolicy: 'open', + requireMention: true, + groupAllowFrom: ['team-1', 'team-2'], + textChunkLimit: 1800, + mediaMaxMb: 100, + blockStreaming: true, + typingIndicator: true, + welcomeCard: true, + promptStarters: ['help', 'status'], + delegatedAuth: { enabled: true, scopes: ['User.Read', 'offline_access'] }, + sso: { enabled: true, connectionName: 'teams-oauth' }, + }) + + assert.equal(values.appId, 'teams-app-id') + assert.equal(values.appPassword, 'teams-secret') + assert.equal(values.authType, 'federated') + assert.equal(values.webhookPort, '3978') + assert.equal(values.webhookPath, '/api/teams/messages') + assert.equal(values.groupPolicy, 'mentioned') + assert.equal(values.groupAllowFrom, 'team-1, team-2') + assert.equal(values.textChunkLimit, '1800') + assert.equal(values.mediaMaxMb, '100') + assert.equal(values.blockStreaming, 'true') + assert.equal(values.typingIndicator, 'true') + assert.equal(values.welcomeCard, 'true') + assert.equal(values.promptStarters, 'help, status') + assert.equal(values.delegatedAuthEnabled, 'true') + assert.equal(values.delegatedAuthScopes, 'User.Read, offline_access') + assert.equal(values.ssoEnabled, 'true') + assert.equal(values.ssoConnectionName, 'teams-oauth') +}) + +test('Microsoft Teams 诊断会按认证模式检查动态必填凭证', () => { + const missingSecret = buildOpenClawChannelDiagnosis({ + platform: 'msteams', + configExists: true, + channelEnabled: true, + form: { + appId: 'teams-app-id', + authType: 'secret', + }, + }) + const federatedCert = buildOpenClawChannelDiagnosis({ + platform: 'msteams', + configExists: true, + channelEnabled: true, + form: { + appId: 'teams-app-id', + authType: 'federated', + certificatePath: '/run/secrets/teams.pem', + }, + }) + const managedIdentity = buildOpenClawChannelDiagnosis({ + platform: 'msteams', + configExists: true, + channelEnabled: true, + form: { + appId: 'teams-app-id', + useManagedIdentity: 'true', + }, + }) + + assert.equal(missingSecret.checks.find(item => item.id === 'credentials')?.ok, false) + assert.match(missingSecret.checks.find(item => item.id === 'credentials')?.detail || '', /App Password/) + assert.equal(federatedCert.checks.find(item => item.id === 'credentials')?.ok, true) + assert.equal(managedIdentity.checks.find(item => item.id === 'credentials')?.ok, true) +}) + test('Discord 渠道保存会保留运行时需要的 applicationId', () => { const cfg = { channels: {} }