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

@@ -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')