-
-
+
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 (
);
})()}
@@ -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' && (
)}
@@ -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 列表展示会话数量、执行状态和时长统计。 |