feat(channels): improve Microsoft Teams config compatibility

This commit is contained in:
晴天
2026-05-23 06:22:23 +08:00
parent 49be118c5f
commit f188bb85f7
5 changed files with 564 additions and 41 deletions

View File

@@ -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: ['该平台暂不支持在线校验'] }
},

View File

@@ -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<String, Value>) -> 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<String, Value>) -> 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<String, Value>
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, &current_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!(

View File

@@ -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: _('<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">需要 Azure AD 应用注册和 Teams 管理员权限</div>', '<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">需要 Azure AD 应用注册和 Teams 管理员权限</div>', '<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">需要 Azure AD 應用註冊和 Teams 管理員權限</div>'),
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: _('安装 <a href="https://github.com/AsamK/signal-cli" target="_blank" rel="noopener">signal-cli</a> 并注册/链接账号', '安装 <a href="https://github.com/AsamK/signal-cli" target="_blank" rel="noopener">signal-cli</a> 并注册/链接账号', '安裝 <a href="https://github.com/AsamK/signal-cli" target="_blank" rel="noopener">signal-cli</a> 並註冊/連結账號'),
signalGuide2: _('确保 signal-cli 可正常收发消息', 'Ensure signal-cli can send and receive messages', '確保 signal-cli 可正常收發訊息'),

View File

@@ -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 `
<div class="form-group">
<label class="form-label">${labelWithHelp(f.label)}${f.required ? ' *' : ''}</label>
<label class="form-label">${labelWithHelp(f.label)}<span class="required-marker" data-required-for="${escapeAttr(f.key)}">${fieldRequired ? ' *' : ''}</span></label>
<select class="form-input" name="${f.key}" data-name="${f.key}">
${f.options.map(o => `<option value="${o.value}" ${val === o.value ? 'selected' : ''}>${o.label}</option>`).join('')}
</select>
@@ -2239,7 +2262,7 @@ async function openConfigDialog(pid, page, state, accountId) {
if (f.multiline) {
return `
<div class="form-group">
<label class="form-label">${labelWithHelp(f.label)}${f.required ? ' *' : ''}</label>
<label class="form-label">${labelWithHelp(f.label)}<span class="required-marker" data-required-for="${escapeAttr(f.key)}">${fieldRequired ? ' *' : ''}</span></label>
<textarea class="form-input" name="${f.key}" rows="5" placeholder="${escapeAttr(f.placeholder || '')}" ${i === 0 ? 'autofocus' : ''} style="width:100%;min-height:112px;resize:vertical;font-family:var(--font-mono);line-height:1.5">${escapeAttr(val)}</textarea>
${fieldHint ? `<div class="form-hint">${fieldHint}</div>` : ''}
</div>
@@ -2247,7 +2270,7 @@ async function openConfigDialog(pid, page, state, accountId) {
}
return `
<div class="form-group">
<label class="form-label">${labelWithHelp(f.label)}${f.required ? ' *' : ''}</label>
<label class="form-label">${labelWithHelp(f.label)}<span class="required-marker" data-required-for="${escapeAttr(f.key)}">${fieldRequired ? ' *' : ''}</span></label>
<div style="display:flex;gap:8px">
<input class="form-input" name="${f.key}" type="${f.secret ? 'password' : 'text'}"
value="${escapeAttr(val)}" placeholder="${escapeAttr(f.placeholder || '')}"
@@ -2379,6 +2402,19 @@ async function openConfigDialog(pid, page, state, accountId) {
return obj
}
const updateRequiredMarkers = () => {
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')

View File

@@ -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: {} }