From fe719aa1e361aaee0067d1a792b3baa4bd7e9ce2 Mon Sep 17 00:00:00 2001 From: "luoquan.wl" Date: Fri, 21 Aug 2026 17:17:50 +0800 Subject: [PATCH] feat(channels): support Microsoft Teams configuration --- README.md | 4 +- client/src/App.tsx | 61 +++++++++++++++++++++++++------ client/src/channelBinding.test.ts | 12 ++++++ client/src/channelBinding.ts | 11 ++++++ client/src/forwardApi.test.ts | 33 +++++++++++++++++ client/src/forwardApi.ts | 14 ++++++- docs/forward-overview.md | 2 +- 7 files changed, 121 insertions(+), 16 deletions(-) create mode 100644 client/src/channelBinding.test.ts create mode 100644 client/src/channelBinding.ts diff --git a/README.md b/README.md index d9530e7..991f5d4 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,9 @@ Forward Quickstart 是一个用于体验 Qoder Cloud Agents Forward API 的示 - **会话历史与用量**:查看历史 Session、事件历史、执行状态和会话时长统计。 - **定时任务**:创建、编辑、暂停、恢复、归档和手动执行 Schedule。 - **批量任务**(仅开发者模式):基于 JSONL 输入文件创建异步批量任务,平台闲时时段自动调度执行。支持表单构建(选模板逐行输入,自动生成 JSONL)和文件上传两种创建方式,前端预校验(JSON 解析/必填字段/custom_id 唯一/≤10000 行)并自动补全缺失的 identity_id;创建时可选添加「自定义标签」(逐项键值对输入,对应 API metadata 字段,仅用于标识查找不影响执行),绑定模板下拉展示模板当前使用的模型;列表页展示状态徽章与分段进度条,支持状态筛选与游标分页,已完成/已取消的任务支持「再次执行」(复用原输入文件与参数新建任务);详情弹窗展示状态时间线、7 维任务计数与标签芯片,非终态自动轮询(5s 起步退避至 30s);支持取消(二次确认)与终态后下载 output.jsonl / error.jsonl。 -- **IM 渠道**:按环境展示并创建微信、企业微信、钉钉、飞书、Lark 和 Slack 渠道,支持扫码绑定或手动密钥配置,并对齐各渠道数量上限;创建渠道时显式传入 `identity_resolution` 以确保渠道授权后消息路由正确生效。 +- **IM 渠道**:按环境展示并创建微信、企业微信、钉钉、飞书、Lark、Slack 和 Microsoft Teams 渠道,支持扫码绑定或手动密钥配置,并对齐各渠道数量上限;Teams 使用 App ID、Tenant ID 和 Client Secret 配置;创建渠道时显式传入 `identity_resolution` 以确保渠道授权后消息路由正确生效。 + + Teams 需在 Microsoft Teams Developer Portal 将 Messaging Endpoint 配置为 `https://api.qoder.com/channels/teams/messages`。如部署使用独立 Channel Gateway,可在前端构建时通过 `VITE_TEAMS_CALLBACK_URL` 覆盖该地址。 - **个人记忆查看**:基于 Template 生效配置读取关联 Memory Store,并查看记忆条目内容。 本 Quickstart 聚焦客户可直接复用的 Forward 主流程,不包含控制台的身份管理、会话诊断和会话范围能力。 diff --git a/client/src/App.tsx b/client/src/App.tsx index 30aeaa6..2e1f879 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -57,6 +57,7 @@ import { deleteChannel, buildChannelCredentials, channelTypesForEnvironment, + getTeamsCallbackUrl, CHANNEL_MAX_COUNTS, waitForChannelBinding, listManagedAgents, @@ -84,6 +85,7 @@ import { isImageFile } from './imageUtils'; import { BatchPanel } from './batchPanel'; import { UsagePanel } from './usagePanel'; import { VoiceEntryButton } from './voice/VoiceEntryButton'; +import { channelBindingModes } from './channelBinding'; import { VoiceSessionView } from './voice/VoiceSessionView'; import { useVoiceAvailability } from './voice/useVoiceAvailability'; import { isVoiceSession } from './voice/voiceSession'; @@ -163,15 +165,29 @@ const CHANNEL_TYPES: Array<{ value: ChannelType; label: string; icon: string; qr { value: 'feishu', label: '飞书', icon: '🐦', qrSupport: true, manualSupport: true }, { value: 'lark', label: 'Lark', icon: '🪽', qrSupport: false, manualSupport: true }, { value: 'slack', label: 'Slack', icon: '💠', qrSupport: false, manualSupport: true }, + { value: 'teams', label: 'Teams', icon: '🟦', qrSupport: false, manualSupport: true }, ]; function channelCredentialLabels(channelType: ChannelType): { key: string; secret: string } { if (channelType === 'feishu' || channelType === 'lark') return { key: 'App ID', secret: 'App Secret' }; if (channelType === 'dingtalk') return { key: 'Client ID', secret: 'Client Secret' }; if (channelType === 'slack') return { key: 'App Token', secret: 'Bot Token' }; + if (channelType === 'teams') return { key: 'App ID', secret: 'Client Secret' }; return { key: 'Bot ID', secret: 'Secret' }; } +function TeamsCallbackGuidance() { + return ( +
+
+ 请先在 Microsoft Teams Developer Portal 中配置 Messaging Endpoint: + 前往配置 → +
+ {getTeamsCallbackUrl()} +
+ ); +} + const MODEL_DISPLAY_NAMES: Record = { ultimate: '旗舰版', performance: '高性能版', @@ -2316,6 +2332,7 @@ export default function App() { const [createdChannelId, setCreatedChannelId] = useState(null); const [chanAppKey, setChanAppKey] = useState(''); const [chanAppSecret, setChanAppSecret] = useState(''); + const [chanTenantId, setChanTenantId] = useState(''); const [chanShowTools, setChanShowTools] = useState(false); const [chanShowThinking, setChanShowThinking] = useState(false); const [editingChannelItem, setEditingChannelItem] = useState(null); @@ -2920,7 +2937,7 @@ export default function App() { const handleSaveCredentials = useCallback(async () => { if (!ctx || !identity) return; - if (!chanAppKey.trim() || !chanAppSecret.trim()) return; + if (!chanAppKey.trim() || !chanAppSecret.trim() || (chanType === 'teams' && !chanTenantId.trim())) return; if (channels.filter((channel) => channel.channel_type === chanType).length >= CHANNEL_MAX_COUNTS[chanType]) { setError(`${CHANNEL_TYPES.find((item) => item.value === chanType)?.label || chanType}渠道最多可创建 ${CHANNEL_MAX_COUNTS[chanType]} 个`); return; @@ -2928,7 +2945,7 @@ export default function App() { setLoading(true); setError(''); try { - const credentials = buildChannelCredentials(chanType, chanAppKey.trim(), chanAppSecret.trim()); + const credentials = buildChannelCredentials(chanType, chanAppKey.trim(), chanAppSecret.trim(), chanTenantId.trim()); // Create channel with credentials in one call (channel doesn't exist yet for manual mode) await createChannel(ctx, { identity_id: identity.id, @@ -2948,12 +2965,13 @@ export default function App() { setChanName(''); setChanAppKey(''); setChanAppSecret(''); + setChanTenantId(''); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setLoading(false); } - }, [channels, ctx, identity, chanTemplateId, chanType, chanName, chanAppKey, chanAppSecret, chanShowTools, chanShowThinking, loadChannels]); + }, [channels, ctx, identity, chanTemplateId, chanType, chanName, chanAppKey, chanAppSecret, chanTenantId, chanShowTools, chanShowThinking, loadChannels]); const handleRebindChannel = useCallback(async (channel: ForwardChannel) => { if (!ctx) return; @@ -3040,7 +3058,7 @@ export default function App() { }; // If manual mode and credentials provided, include them if (chanMode === 'manual' && chanAppKey.trim()) { - const credentials = buildChannelCredentials(editingChannelItem.channel_type, chanAppKey.trim(), chanAppSecret.trim()); + const credentials = buildChannelCredentials(editingChannelItem.channel_type, chanAppKey.trim(), chanAppSecret.trim(), chanTenantId.trim()); updatePayload.channel_config = { ...editingChannelItem.channel_config, credentials, @@ -3052,13 +3070,14 @@ export default function App() { setQrSession(null); setChanAppKey(''); setChanAppSecret(''); + setChanTenantId(''); await loadChannels(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setLoading(false); } - }, [ctx, editingChannelItem, chanMode, chanAppKey, chanAppSecret, loadChannels, stopQrPolling]); + }, [ctx, editingChannelItem, chanMode, chanAppKey, chanAppSecret, chanTenantId, loadChannels, stopQrPolling]); const loadTemplateResourceOptions = useCallback(async () => { await Promise.all(TEMPLATE_RESOURCE_TYPES.map((type) => loadRegisteredResources(type))); @@ -4722,7 +4741,7 @@ export default function App() {
- +
1 ? 'grid-cols-2' : 'grid-cols-1'}`}> + {chanTypeInfo && channelBindingModes(chanTypeInfo).map((mode) => mode === 'qr' ? ( + + ) : ( + + ))}
{/* QR scan sub-section */} @@ -6968,6 +6990,7 @@ export default function App() { const { key: keyLabel, secret: secretLabel } = channelCredentialLabels(editingChannelItem.channel_type); return (
+ {editingChannelItem.channel_type === 'teams' && }
+ {editingChannelItem.channel_type === 'teams' && ( + + )}
); })()} @@ -7167,6 +7196,7 @@ export default function App() { setQrSession(null); setChanAppKey(''); setChanAppSecret(''); + setChanTenantId(''); setCreatedChannelId(null); setChannelStep('config'); }} title={channelStep === 'config' ? '添加 IM 渠道' : '绑定渠道'}> @@ -7348,6 +7378,7 @@ export default function App() { {/* Manual credentials section */} {chanMode === 'manual' && (
+ {chanType === 'teams' && }
+ {chanType === 'teams' && ( + + )}
)} @@ -7381,7 +7418,7 @@ export default function App() { {(() => { const isQr = chanMode === 'qr' && CHANNEL_TYPES.find((c) => c.value === chanType)?.qrSupport; const qrConfirmed = isQr && qrSession?.status === 'confirmed' && !qrBindingIssue && !qrVerifying; - const manualReady = chanMode === 'manual' && chanAppKey.trim() && chanAppSecret.trim(); + const manualReady = chanMode === 'manual' && chanAppKey.trim() && chanAppSecret.trim() && (chanType !== 'teams' || chanTenantId.trim()); const isDisabled = isQr ? !qrConfirmed : (!manualReady || loading); const btnText = loading ? '保存中...' : isQr ? (qrVerifying ? '确认绑定状态中...' : qrBindingIssue ? '绑定未完成' : qrConfirmed ? '保存' : '等待扫码确认...') diff --git a/client/src/channelBinding.test.ts b/client/src/channelBinding.test.ts new file mode 100644 index 0000000..a2ce165 --- /dev/null +++ b/client/src/channelBinding.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from 'vitest'; +import { channelBindingModes } from './channelBinding'; + +describe('channelBindingModes', () => { + test('offers only manual configuration when QR binding is unsupported', () => { + expect(channelBindingModes({ qrSupport: false, manualSupport: true })).toEqual(['manual']); + }); + + test('offers both supported binding modes', () => { + expect(channelBindingModes({ qrSupport: true, manualSupport: true })).toEqual(['qr', 'manual']); + }); +}); diff --git a/client/src/channelBinding.ts b/client/src/channelBinding.ts new file mode 100644 index 0000000..68ec0f2 --- /dev/null +++ b/client/src/channelBinding.ts @@ -0,0 +1,11 @@ +export type ChannelBindingMode = 'qr' | 'manual'; + +export function channelBindingModes(capabilities: { + qrSupport: boolean; + manualSupport: boolean; +}): ChannelBindingMode[] { + const modes: ChannelBindingMode[] = []; + if (capabilities.qrSupport) modes.push('qr'); + if (capabilities.manualSupport) modes.push('manual'); + return modes; +} diff --git a/client/src/forwardApi.test.ts b/client/src/forwardApi.test.ts index 8e4f775..4392514 100644 --- a/client/src/forwardApi.test.ts +++ b/client/src/forwardApi.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { buildChannelCredentials, + channelTypesForEnvironment, deleteCloudEnvironment, deleteCloudSkill, deleteCloudVault, @@ -8,6 +9,7 @@ import { downloadCloudFile, getCloudFile, getMemoryEntry, + getTeamsCallbackUrl, listResourceCatalog, listEvents, listMemoryEntries, @@ -316,6 +318,14 @@ describe('buildChannelCredentials', () => { expect(buildChannelCredentials('slack', 'xapp-token', 'xoxb-token')).toEqual({ app_token: 'xapp-token', bot_token: 'xoxb-token' }); }); + test('maps Teams app, tenant, and secret credentials', () => { + expect(buildChannelCredentials('teams', 'app-id', 'client-secret', 'tenant-id')).toEqual({ + app_id: 'app-id', + tenant_id: 'tenant-id', + client_secret: 'client-secret', + }); + }); + test('maps fields for dingtalk', () => { expect(buildChannelCredentials('dingtalk', 'ding-key', 'ding-secret')).toEqual({ client_id: 'ding-key', client_secret: 'ding-secret' }); }); @@ -329,6 +339,29 @@ describe('buildChannelCredentials', () => { }); }); +describe('channelTypesForEnvironment', () => { + test('offers Teams only in the global environment', () => { + expect(channelTypesForEnvironment('global-prod')).toContain('teams'); + expect(channelTypesForEnvironment('cn-prod')).not.toContain('teams'); + }); +}); + +describe('getTeamsCallbackUrl', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + test('uses the Global production endpoint by default', () => { + vi.stubEnv('VITE_TEAMS_CALLBACK_URL', ''); + expect(getTeamsCallbackUrl()).toBe('https://api.qoder.com/channels/teams/messages'); + }); + + test('uses the configured endpoint override', () => { + vi.stubEnv('VITE_TEAMS_CALLBACK_URL', 'https://gateway.example.com/channels/teams/messages'); + expect(getTeamsCallbackUrl()).toBe('https://gateway.example.com/channels/teams/messages'); + }); +}); + describe('waitForChannelBinding', () => { const ctx: ForwardContext = { pat: 'pat_test', environment: 'cn-prod' }; diff --git a/client/src/forwardApi.ts b/client/src/forwardApi.ts index 0ecccca..80d7812 100644 --- a/client/src/forwardApi.ts +++ b/client/src/forwardApi.ts @@ -1341,7 +1341,7 @@ function normalizeMemoryEntry(entry: T): T { // ─── Channels (Forward API) ──────────────────────────────────────── -export type ChannelType = 'wechat' | 'wecom' | 'feishu' | 'lark' | 'dingtalk' | 'slack'; +export type ChannelType = 'wechat' | 'wecom' | 'feishu' | 'lark' | 'dingtalk' | 'slack' | 'teams'; export type BindingStatus = 'unbound' | 'bound' | 'expired'; export type IdentityResolutionMode = 'fixed' | 'pairing'; @@ -1441,6 +1441,7 @@ export function buildChannelCredentials( channelType: ChannelType, key: string, secret: string, + tenantId?: string, ): Record { switch (channelType) { case 'feishu': @@ -1450,6 +1451,8 @@ export function buildChannelCredentials( return { app_token: key, bot_token: secret }; case 'dingtalk': return { client_id: key, client_secret: secret }; + case 'teams': + return { app_id: key, tenant_id: tenantId ?? '', client_secret: secret }; case 'wecom': return { bot_id: key, secret }; default: @@ -1465,11 +1468,18 @@ export const CHANNEL_MAX_COUNTS: Record = { feishu: 5, lark: 5, slack: 5, + teams: 5, }; +const DEFAULT_TEAMS_CALLBACK_URL = 'https://api.qoder.com/channels/teams/messages'; + +export function getTeamsCallbackUrl(): string { + return import.meta.env.VITE_TEAMS_CALLBACK_URL?.trim() || DEFAULT_TEAMS_CALLBACK_URL; +} + export function channelTypesForEnvironment(environment: ForwardApiEnvironment): ChannelType[] { return environment === 'global-prod' - ? ['wechat', 'wecom', 'dingtalk', 'feishu', 'lark', 'slack'] + ? ['wechat', 'wecom', 'dingtalk', 'feishu', 'lark', 'slack', 'teams'] : ['wechat', 'wecom', 'dingtalk', 'feishu']; } diff --git a/docs/forward-overview.md b/docs/forward-overview.md index 582d4fe..42682c1 100644 --- a/docs/forward-overview.md +++ b/docs/forward-overview.md @@ -78,7 +78,7 @@ Forward Quickstart 覆盖了 Forward Mode 的主链路,也包含部分配套 C | Sessions | 创建 Session,发送用户消息,查询历史 Session,查询事件历史,归档 Session,取消当前 Turn。 | | SSE Events | 实时接收 Agent 消息、状态变化、思考内容、工具调用、工具结果和增量流式文本。 | | Schedules | 查询、创建、编辑、暂停、恢复、归档和手动执行 Schedule,并查询 Schedule Run。 | -| Channels | 创建、查询、更新和删除 IM 渠道,支持微信、企业微信、钉钉、飞书;支持扫码绑定和手动凭据配置。 | +| Channels | 创建、查询、更新和删除 IM 渠道,支持微信、企业微信、钉钉、飞书,以及 Global 环境的 Lark、Slack 和 Microsoft Teams;支持扫码绑定和手动凭据配置。 | | Memory | 基于 Template 生效配置读取 Memory Store,查询记忆条目并展示内容。 | | Usage | 基于 Session 列表展示会话数量、执行状态和时长统计。 |