diff --git a/docs/generated/postgres-schema/public.agent_chat_subscriptions.md b/docs/generated/postgres-schema/public.agent_chat_subscriptions.md index b7222da650a..caadb5ce62c 100644 --- a/docs/generated/postgres-schema/public.agent_chat_subscriptions.md +++ b/docs/generated/postgres-schema/public.agent_chat_subscriptions.md @@ -15,7 +15,7 @@ | Name | Type | Definition | | ---- | ---- | ---------- | -| CHK_agent_chat_subscriptions_integrationType | CHECK | CHECK ((("integrationType")::text = ANY ((ARRAY['telegram'::character varying, 'slack'::character varying, 'linear'::character varying, 'discord'::character varying])::text[]))) | +| CHK_agent_chat_subscriptions_integrationType | CHECK | CHECK ((("integrationType")::text = ANY ((ARRAY['telegram'::character varying, 'slack'::character varying, 'linear'::character varying, 'discord'::character varying, 'teams'::character varying])::text[]))) | | FK_e79153bd179c011e779d5016796 | FOREIGN KEY | FOREIGN KEY ("agentId") REFERENCES agents(id) ON DELETE CASCADE | | PK_76598cf91038bee1f3ac94c94bc | PRIMARY KEY | PRIMARY KEY ("agentId", "integrationType", "credentialId", "threadId") | | agent_chat_subscriptions_agentId_not_null | n | NOT NULL "agentId" | diff --git a/docs/generated/sqlite-schema/agent_chat_subscriptions.md b/docs/generated/sqlite-schema/agent_chat_subscriptions.md index 2e7d9739c38..e54e2c0ec21 100644 --- a/docs/generated/sqlite-schema/agent_chat_subscriptions.md +++ b/docs/generated/sqlite-schema/agent_chat_subscriptions.md @@ -6,7 +6,7 @@ Table Definition ```sql -CREATE TABLE "agent_chat_subscriptions" ("agentId" varchar(36) NOT NULL, "integrationType" varchar(64) NOT NULL, "credentialId" varchar(255) NOT NULL, "threadId" varchar(255) NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "CHK_agent_chat_subscriptions_integrationType" CHECK ("integrationType" IN ('telegram', 'slack', 'linear', 'discord')), CONSTRAINT "FK_e79153bd179c011e779d5016796" FOREIGN KEY ("agentId") REFERENCES "agents" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, PRIMARY KEY ("agentId", "integrationType", "credentialId", "threadId")) +CREATE TABLE "agent_chat_subscriptions" ("agentId" varchar(36) NOT NULL, "integrationType" varchar(64) NOT NULL, "credentialId" varchar(255) NOT NULL, "threadId" varchar(255) NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "CHK_agent_chat_subscriptions_integrationType" CHECK ("integrationType" IN ('telegram', 'slack', 'linear', 'discord', 'teams')), CONSTRAINT "FK_e79153bd179c011e779d5016796" FOREIGN KEY ("agentId") REFERENCES "agents" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, PRIMARY KEY ("agentId", "integrationType", "credentialId", "threadId")) ``` @@ -26,7 +26,7 @@ CREATE TABLE "agent_chat_subscriptions" ("agentId" varchar(36) NOT NULL, "integr | Name | Type | Definition | | ---- | ---- | ---------- | -| - | CHECK | CHECK ("integrationType" IN ('telegram', 'slack', 'linear', 'discord')) | +| - | CHECK | CHECK ("integrationType" IN ('telegram', 'slack', 'linear', 'discord', 'teams')) | | - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (agentId) REFERENCES agents (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE | | agentId | PRIMARY KEY | PRIMARY KEY (agentId) | | credentialId | PRIMARY KEY | PRIMARY KEY (credentialId) | diff --git a/packages/@n8n/api-types/src/agents/__tests__/agent-integration.schema.test.ts b/packages/@n8n/api-types/src/agents/__tests__/agent-integration.schema.test.ts index 9b89c367417..6d5ffee2035 100644 --- a/packages/@n8n/api-types/src/agents/__tests__/agent-integration.schema.test.ts +++ b/packages/@n8n/api-types/src/agents/__tests__/agent-integration.schema.test.ts @@ -10,6 +10,31 @@ describe('AgentIntegrationSchema', () => { expect(result.success).toBe(true); }); + it.each(['discord', 'linear', 'teams'])( + 'accepts a %s integration with or without a session idle timeout', + (type) => { + expect(AgentIntegrationSchema.safeParse({ type, credentialId: 'cred-123' }).success).toBe( + true, + ); + expect( + AgentIntegrationSchema.safeParse({ + type, + credentialId: 'cred-123', + settings: { sessionIdleTimeoutMinutes: 30 }, + }).success, + ).toBe(true); + }, + ); + + it('rejects Teams settings it does not define', () => { + const result = AgentIntegrationSchema.safeParse({ + type: 'teams', + credentialId: 'cred-123', + settings: { accessMode: 'public' }, + }); + expect(result.success).toBe(false); + }); + it('accepts an existing Slack integration without messaging settings', () => { const result = AgentIntegrationSchema.safeParse({ type: 'slack', diff --git a/packages/@n8n/api-types/src/agents/agent-integration.schema.ts b/packages/@n8n/api-types/src/agents/agent-integration.schema.ts index 8da7a1f187b..cff4244844a 100644 --- a/packages/@n8n/api-types/src/agents/agent-integration.schema.ts +++ b/packages/@n8n/api-types/src/agents/agent-integration.schema.ts @@ -104,6 +104,9 @@ export type AgentDiscordIntegrationSettings = z.infer; +export const AgentTeamsSettingsSchema = AgentSessionOnlySettingsSchema; +export type AgentTeamsIntegrationSettings = z.infer; + export const AgentIntegrationSettingsSchema = z.union([ AgentTelegramSettingsSchema, AgentSlackSettingsSchema, @@ -128,6 +131,9 @@ const credentialIntegrations = [ createCredIntegrationSchema('discord', AgentDiscordSettingsSchema).extend({ settings: AgentDiscordSettingsSchema.optional(), }), + createCredIntegrationSchema('teams', AgentTeamsSettingsSchema).extend({ + settings: AgentTeamsSettingsSchema.optional(), + }), ] as const; const draftCredentialIntegrations = [ @@ -143,6 +149,9 @@ const draftCredentialIntegrations = [ createDraftCredIntegrationSchema('discord', AgentDiscordSettingsSchema).extend({ settings: AgentDiscordSettingsSchema.optional(), }), + createDraftCredIntegrationSchema('teams', AgentTeamsSettingsSchema).extend({ + settings: AgentTeamsSettingsSchema.optional(), + }), ] as const; export const AgentIntegrationSchema = z.discriminatedUnion('type', credentialIntegrations); diff --git a/packages/@n8n/api-types/src/agents/dto.ts b/packages/@n8n/api-types/src/agents/dto.ts index 2668207bfe9..b9cf4b60e14 100644 --- a/packages/@n8n/api-types/src/agents/dto.ts +++ b/packages/@n8n/api-types/src/agents/dto.ts @@ -44,6 +44,7 @@ export const AGENT_SESSION_ORIGINS = [ 'telegram', 'linear', 'discord', + 'teams', ] as const; export type AgentSessionStatus = (typeof AGENT_SESSION_STATUSES)[number]; diff --git a/packages/@n8n/db/src/migrations/common/1790231058977-AllowTeamsAgentChatSubscriptions.ts b/packages/@n8n/db/src/migrations/common/1790231058977-AllowTeamsAgentChatSubscriptions.ts new file mode 100644 index 00000000000..ecae27b239e --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1790231058977-AllowTeamsAgentChatSubscriptions.ts @@ -0,0 +1,21 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +const TABLE = 'agent_chat_subscriptions'; +const COLUMN = 'integrationType'; +const VALUES_BEFORE = ['telegram', 'slack', 'linear', 'discord']; +const VALUES_AFTER = [...VALUES_BEFORE, 'teams']; + +export class AllowTeamsAgentChatSubscriptions1790231058977 implements ReversibleMigration { + async up({ schemaBuilder }: MigrationContext) { + await schemaBuilder.dropEnumCheck(TABLE, COLUMN, { recreatesOnSqlite: true }); + await schemaBuilder.addEnumCheck(TABLE, COLUMN, VALUES_AFTER, { recreatesOnSqlite: true }); + } + + async down({ escape, runQuery, schemaBuilder }: MigrationContext) { + await runQuery( + `DELETE FROM ${escape.tableName(TABLE)} WHERE ${escape.columnName(COLUMN)} = 'teams'`, + ); + await schemaBuilder.dropEnumCheck(TABLE, COLUMN, { recreatesOnSqlite: true }); + await schemaBuilder.addEnumCheck(TABLE, COLUMN, VALUES_BEFORE, { recreatesOnSqlite: true }); + } +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 35c8edd357b..eb4208c450e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -140,6 +140,7 @@ "@chat-adapter/linear": "catalog:", "@chat-adapter/slack": "catalog:", "@chat-adapter/state-memory": "catalog:", + "@chat-adapter/teams": "catalog:", "@chat-adapter/telegram": "catalog:", "@google-cloud/secret-manager": "5.6.0", "@grpc/grpc-js": "^1.14.3", diff --git a/packages/cli/src/modules/agents/agents.module.ts b/packages/cli/src/modules/agents/agents.module.ts index 64f6b1c1c24..7c0c413f5c7 100644 --- a/packages/cli/src/modules/agents/agents.module.ts +++ b/packages/cli/src/modules/agents/agents.module.ts @@ -87,12 +87,14 @@ export class AgentsModule implements ModuleInterface { ); const { LinearIntegration } = await import('./integrations/platforms/linear-integration.js'); const { DiscordIntegration } = await import('./integrations/platforms/discord-integration.js'); + const { TeamsIntegration } = await import('./integrations/platforms/teams-integration.js'); const { N8nChatIntegration } = await import('./integrations/platforms/n8n-chat-integration.js'); const registry = Container.get(ChatIntegrationRegistry); registry.register(Container.get(SlackIntegration)); registry.register(Container.get(TelegramIntegration)); registry.register(Container.get(LinearIntegration)); registry.register(Container.get(DiscordIntegration)); + registry.register(Container.get(TeamsIntegration)); registry.register(Container.get(N8nChatIntegration)); // Resume Chat and Task services on startup so this main runs what its diff --git a/packages/cli/src/modules/agents/integrations/CHANNEL_INTEGRATION_TESTS.md b/packages/cli/src/modules/agents/integrations/CHANNEL_INTEGRATION_TESTS.md index 77106fcd568..3f4d8c74ee3 100644 --- a/packages/cli/src/modules/agents/integrations/CHANNEL_INTEGRATION_TESTS.md +++ b/packages/cli/src/modules/agents/integrations/CHANNEL_INTEGRATION_TESTS.md @@ -34,6 +34,7 @@ Each platform adapter uses a different HTTP client, so the interception mechanis | Telegram | native `fetch` | replace `globalThis.fetch` | `installFetchStub` (replay-test-helpers) | | Slack | `@slack/web-api` (axios) | `nock` at the HTTP layer | inline in slack `replay-test-context` | | Linear | `@linear/sdk` (GraphQL over fetch) | replace `globalThis.fetch` | `installFetchStub` | +| Teams | `@microsoft/teams.*` (axios) + `jwks-rsa` (node `https`) | `nock` at the HTTP layer | inline in teams `replay-test-context` | Responses are answered from two sources, in order of preference: @@ -55,6 +56,17 @@ so response stubs only need to be valid enough for the real adapter to proceed. reconstructs the streamed text and records it as a synthetic `chat.postMessage` so assertions can treat the reply as one outbound post. `webhookVerifier: () => true` bypasses signature checks (the fixtures carry sanitized signatures); passing `botUserId` skips the `auth.test` lookup. +- **Teams** — the only platform whose inbound requests must carry a **real signed token**. + `createTeamsAdapter` builds a `@microsoft/teams.apps` `App`, which validates every activity's + Bot Framework JWT, and the adapter exposes no bypass: there is no `webhookVerifier` like Slack's, + and `TeamsAdapterConfig` cannot pass the Teams SDK's `skipAuth` through. So the helper generates + an RSA keypair per run, signs an activity token (issuer `https://api.botframework.com`, audience + the app ID, a `serviceurl` claim matching the activity), and serves the matching public JWK from a + nocked `https://login.botframework.com/v1/.well-known/keys`. The adapter's own validation then runs + for real — `sendUnauthenticatedWebhook` asserts it still rejects. Two non-obvious stubs are also + required: the client-credentials token endpoint is **tenant**-scoped, not `botframework.com`-scoped, + and the SDK decodes the access token it gets back, so that stub must return a real JWT rather than + an opaque string. - **Linear** — webhooks are HMAC-signed (`linear-signature`) and timestamp-checked, so the helper refreshes `webhookTimestamp` and signs the body. `@linear/sdk` strictly deserializes typed entities and lazily fetches relationships, so the GraphQL stub returns fully-shaped entities diff --git a/packages/cli/src/modules/agents/integrations/__tests__/adapter-logger.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/adapter-logger.test.ts new file mode 100644 index 00000000000..ae6fe75305c --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/__tests__/adapter-logger.test.ts @@ -0,0 +1,31 @@ +import type { Logger } from '@n8n/backend-common'; +import { mock } from 'vitest-mock-extended'; + +import { createAdapterLogger } from '../adapter-logger'; + +describe('createAdapterLogger', () => { + it('forwards the prefixed message and drops adapter metadata', () => { + const logger = mock(); + + createAdapterLogger(logger, '[TestAdapter]').info('sent a message', { + text: 'secret message body', + signature: 'request-signature', + }); + + expect(logger.info).toHaveBeenCalledWith('[TestAdapter] sent a message'); + }); + + it.each(['debug', 'info', 'warn', 'error'] as const)('forwards %s', (level) => { + const logger = mock(); + + createAdapterLogger(logger, '[TestAdapter]')[level]('hello'); + + expect(logger[level]).toHaveBeenCalledWith('[TestAdapter] hello'); + }); + + it('returns itself as its own child so adapter scoping keeps the prefix', () => { + const adapterLogger = createAdapterLogger(mock(), '[TestAdapter]'); + + expect(adapterLogger.child('scope')).toBe(adapterLogger); + }); +}); diff --git a/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-subscription.constraint.integration.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-subscription.constraint.integration.test.ts new file mode 100644 index 00000000000..e81d218542b --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-subscription.constraint.integration.test.ts @@ -0,0 +1,64 @@ +import { AgentIntegrationSchema } from '@n8n/api-types'; +import { createTeamProject, testDb, testModules } from '@n8n/backend-test-utils'; +import { Container } from '@n8n/di'; + +import { AgentChatSubscriptionRepository } from '../../repositories/agent-chat-subscription.repository'; +import { AgentRepository } from '../../repositories/agent.repository'; + +/** + * `agent_chat_subscriptions.integrationType` is guarded by a CHECK constraint + * that each new channel has to widen with its own migration. Miss it and the + * channel connects, runs, and then fails on the first subscribed message — + * which unit tests never see, because they all run on in-memory state. + * + * The types come from the schema rather than a list here: the mistake this + * guards against is forgetting to add a channel, so a hand-written list would + * be forgotten in the same edit. + */ +const INTEGRATION_TYPES = AgentIntegrationSchema.options.map((option) => option.shape.type.value); + +let agentId: string; + +beforeAll(async () => { + await testModules.loadModules(['agents']); + await testDb.init(); + + const project = await createTeamProject(); + const agent = await Container.get(AgentRepository).save( + Container.get(AgentRepository).create({ + name: 'Subscription constraint agent', + projectId: project.id, + integrations: [], + }), + ); + agentId = agent.id; +}); + +afterAll(async () => { + await testDb.terminate(); +}); + +describe('agent chat subscription integration types', () => { + it('accepts a subscription for every integration type the schema allows', async () => { + const repository = Container.get(AgentChatSubscriptionRepository); + expect(INTEGRATION_TYPES).toContain('teams'); + + for (const integrationType of INTEGRATION_TYPES) { + const scope = { agentId, integrationType, credentialId: `cred-${integrationType}` }; + const threadId = `thread-${integrationType}`; + + await repository.subscribe(scope, threadId); + + await expect(repository.isSubscribed(scope, threadId)).resolves.toBe(true); + } + }); + + // Without this the suite would still pass if a migration dropped the check + // altogether, which would make the test above prove nothing. + it('rejects a type the constraint does not list', async () => { + const repository = Container.get(AgentChatSubscriptionRepository); + const scope = { agentId, integrationType: 'not-a-channel', credentialId: 'cred-x' }; + + await expect(repository.subscribe(scope, 'thread-x')).rejects.toThrow(); + }); +}); diff --git a/packages/cli/src/modules/agents/integrations/__tests__/channel-integration-contract.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/channel-integration-contract.test.ts index fdc3fbd83c7..a4290b40a59 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/channel-integration-contract.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/channel-integration-contract.test.ts @@ -7,6 +7,15 @@ import { createSlackReplayContext, type SlackReplayFixtures, } from './helpers/slack/replay-test-context'; +import { createTeamsReplayContext } from './helpers/teams/replay-test-context'; +import { + dmFollowUp, + dmMessage, + selfMessage, + TEAMS_DM_CONVERSATION_ID, + TEAMS_SERVICE_URL, + TEAMS_USER_ID, +} from './helpers/teams/synthetic-fixtures'; import { createTelegramReplayContext, type TelegramReplayFixtures, @@ -21,6 +30,7 @@ vi.mock('../esm-loader', () => ({ loadTelegramAdapter: async () => await import('@chat-adapter/telegram'), loadSlackAdapter: async () => await import('@chat-adapter/slack'), loadLinearAdapter: async () => await import('@chat-adapter/linear'), + loadTeamsAdapter: async () => await import('@chat-adapter/teams'), })); const slackFixtures = jsonParse( @@ -105,3 +115,49 @@ runSharedChannelIntegrationContract({ }, createContext: async () => await createTelegramReplayContext(telegramFixtures), }); + +// Teams has no recorded session yet (no tenant in CI), so the contract runs +// against hand-built activities driven through the real adapter. +// +// The adapter encodes its thread id as base64url conversation + service URL; +// deriving it from the fixture constants keeps the two from drifting apart. +const teamsThreadId = [ + 'teams', + Buffer.from(TEAMS_DM_CONVERSATION_ID).toString('base64url'), + Buffer.from(TEAMS_SERVICE_URL).toString('base64url'), +].join(':'); + +runSharedChannelIntegrationContract({ + name: 'Microsoft Teams', + fixtures: { mention: dmMessage, followUp: dmFollowUp, selfMessage }, + expected: { + message: 'hello agent', + author: { id: TEAMS_USER_ID, name: 'Alice' }, + followUpMessage: 'follow up', + integrationType: 'teams', + context: { + integrationConnectionId: 'teams:cred-teams', + platform: 'teams', + messageId: 'activity-dm-1', + interactingUserId: TEAMS_USER_ID, + target: { + type: 'thread', + threadId: teamsThreadId, + channelId: teamsThreadId, + }, + }, + resourceId: TEAMS_USER_ID, + firstPost: { + type: 'message', + text: 'Got it', + conversation: { id: TEAMS_DM_CONVERSATION_ID }, + }, + respondPost: { + type: 'message', + text: 'Action response', + conversation: { id: TEAMS_DM_CONVERSATION_ID }, + }, + respondTarget: { threadId: teamsThreadId }, + }, + createContext: async () => await createTeamsReplayContext(), +}); diff --git a/packages/cli/src/modules/agents/integrations/__tests__/credential-fields.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/credential-fields.test.ts new file mode 100644 index 00000000000..ae66caca334 --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/__tests__/credential-fields.test.ts @@ -0,0 +1,37 @@ +import { UserError } from 'n8n-workflow'; + +import { credentialField, requireCredentialField } from '../credential-fields'; + +describe('credentialField', () => { + it('trims a pasted value', () => { + expect(credentialField({ clientId: ' abc ' }, 'clientId')).toBe('abc'); + }); + + it.each([ + ['a missing field', {}], + ['a non-string field', { clientId: 42 }], + ['a whitespace-only field', { clientId: ' ' }], + ['a non-record credential', undefined], + ])('returns an empty string for %s', (_case, credential) => { + expect(credentialField(credential, 'clientId')).toBe(''); + }); +}); + +describe('requireCredentialField', () => { + it('returns the trimmed value', () => { + expect(requireCredentialField({ clientId: ' abc ' }, 'clientId', 'boom')).toBe('abc'); + }); + + it.each([ + ['a missing field', {}], + ['a non-string field', { clientId: 42 }], + ['a whitespace-only field', { clientId: ' ' }], + ])("throws the caller's message for %s", (_case, credential) => { + expect(() => requireCredentialField(credential, 'clientId', 'Needs a client ID.')).toThrow( + UserError, + ); + expect(() => requireCredentialField(credential, 'clientId', 'Needs a client ID.')).toThrow( + 'Needs a client ID.', + ); + }); +}); diff --git a/packages/cli/src/modules/agents/integrations/__tests__/helpers/teams/replay-test-context.ts b/packages/cli/src/modules/agents/integrations/__tests__/helpers/teams/replay-test-context.ts new file mode 100644 index 00000000000..fb44a859bba --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/__tests__/helpers/teams/replay-test-context.ts @@ -0,0 +1,206 @@ +import type { StreamChunk } from '@n8n/agents'; +import type { Logger as BackendLogger } from '@n8n/backend-common'; +import { generateKeyPairSync, randomUUID } from 'crypto'; +import jwt from 'jsonwebtoken'; +import nock from 'nock'; +import { mock } from 'vitest-mock-extended'; + +import type { AgentRepository } from '../../../../repositories/agent.repository'; +import type { ChatInstance } from '../../../chat-integration.service'; +import { ComponentMapper } from '../../../component-mapper'; +import type { IntegrationMessageContext } from '../../../integration-tools'; +import { TeamsIntegration } from '../../../platforms/teams-integration'; +import { + createReplayContextSetup, + type ReplayApiCall, + type ReplayContextSetup, + type ReplayWebhookHandler, + sendJsonWebhook, +} from '../replay-test-helpers'; +import { + TEAMS_APP_ID, + TEAMS_SERVICE_URL, + TEAMS_CLIENT_SECRET, + TEAMS_TENANT_ID, +} from './synthetic-fixtures'; + +/** + * Bot Framework constants the Teams SDK derives from its `PUBLIC` cloud config. + * Pinned here so a stub drifting from the SDK fails loudly instead of silently + * letting an unauthenticated request through. + */ +const JWKS_ORIGIN = 'https://login.botframework.com'; +const JWKS_PATH = '/v1/.well-known/keys'; +const TOKEN_ISSUER = 'https://api.botframework.com'; +const LOGIN_ORIGIN = 'https://login.microsoftonline.com'; + +export interface TeamsReplayContext extends Omit { + chat: ChatInstance; + sendWebhook: (payload: unknown) => Promise; + /** Same payload, no Authorization header — proves the token check is live. */ + sendUnauthenticatedWebhook: (payload: unknown) => Promise; + latestContext: () => IntegrationMessageContext | undefined; + latestThreadId: () => string | undefined; + lastPost: () => ReplayApiCall | undefined; + lastEdit: () => ReplayApiCall | undefined; + lastPostedMessageId: () => string | undefined; +} + +/** + * Signs real RS256 Bot Framework tokens, because the adapter exposes no + * verification bypass. The matching JWK is served from the stubbed JWKS, so the + * adapter's own validation stays under test. + */ +let cachedSigner: ReturnType | undefined; + +/** Reused across contexts: no test needs distinct key material. */ +function createBotFrameworkSigner() { + return (cachedSigner ??= buildBotFrameworkSigner()); +} + +function buildBotFrameworkSigner() { + const keyId = randomUUID(); + const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + const jwk = publicKey.export({ format: 'jwk' }); + + return { + jwks: { keys: [{ ...jwk, kid: keyId, use: 'sig', alg: 'RS256' }] }, + /** + * The Teams SDK decodes the token it gets back from the mint to read its + * claims, so an opaque placeholder string fails. + */ + accessToken: () => + jwt.sign({ aud: TOKEN_ISSUER, iss: TOKEN_ISSUER, appid: TEAMS_APP_ID }, privateKey, { + algorithm: 'RS256', + expiresIn: '1h', + keyid: keyId, + }), + sign: () => + jwt.sign( + { + serviceurl: TEAMS_SERVICE_URL, + aud: TEAMS_APP_ID, + iss: TOKEN_ISSUER, + appid: TEAMS_APP_ID, + sub: TEAMS_APP_ID, + }, + privateKey, + { algorithm: 'RS256', expiresIn: '1h', keyid: keyId }, + ), + }; +} + +function installTeamsApiStub(jwks: object, accessToken: string) { + const apiCalls: ReplayApiCall[] = []; + const serviceUrl = new URL(TEAMS_SERVICE_URL); + + nock(JWKS_ORIGIN).persist().get(JWKS_PATH).reply(200, jwks); + + nock(LOGIN_ORIGIN) + .persist() + .post(/\/[^/]+\/oauth2\/v2\.0\/token/) + .reply(200, { + token_type: 'Bearer', + expires_in: 3599, + ext_expires_in: 3599, + access_token: accessToken, + }); + + // Recorded as `sendActivity` so `lastPost()` reads like the other platforms'. + let nextMessageId = 1000; + const postedMessageIds: string[] = []; + nock(serviceUrl.origin) + .persist() + .post(/\/v3\/conversations\/.+\/activities.*/) + .reply(function (_uri, body) { + apiCalls.push({ + method: 'sendActivity', + body: (typeof body === 'object' && body !== null ? body : {}) as Record, + }); + const id = `message-${nextMessageId++}`; + postedMessageIds.push(id); + return [200, { id }]; + }); + + nock(serviceUrl.origin) + .persist() + .put(/\/v3\/conversations\/.+\/activities\/.+/) + .reply(function (_uri, body) { + apiCalls.push({ + method: 'updateActivity', + body: (typeof body === 'object' && body !== null ? body : {}) as Record, + }); + return [200, { id: 'message-edited' }]; + }); + + return { apiCalls, postedMessageIds, restore: () => nock.cleanAll() }; +} + +export async function createTeamsReplayContext( + options: { stream?: StreamChunk[] } = {}, +): Promise { + const signer = createBotFrameworkSigner(); + const stub = installTeamsApiStub(signer.jwks, signer.accessToken()); + + // Dynamic imports — the chat packages are ESM-only. Production routes through + // esm-loader to dodge the CJS transform; vitest loads ESM natively. + const { createTeamsAdapter } = await import('@chat-adapter/teams'); + const { Chat } = await import('chat'); + const { createMemoryState } = await import('@chat-adapter/state-memory'); + + const adapter = createTeamsAdapter({ + appId: TEAMS_APP_ID, + appPassword: TEAMS_CLIENT_SECRET, + appTenantId: TEAMS_TENANT_ID, + appType: 'SingleTenant', + }); + const chat = new Chat({ + userName: 'n8n-agent-agent-1', + adapters: { teams: adapter } as unknown as Record, + state: createMemoryState(), + }); + + const setup = createReplayContextSetup({ + chat: chat as never, + integrationImpl: new TeamsIntegration(mock(), mock()), + integration: { type: 'teams', credentialId: 'cred-teams', settings: undefined }, + componentMapper: new ComponentMapper(), + stream: options.stream, + }); + + await chat.initialize(); + + const webhooks = chat.webhooks as unknown as Record; + const post = async (payload: unknown, headers: Headers) => + await sendJsonWebhook( + async (request, requestOptions) => await webhooks.teams(request, requestOptions), + 'https://n8n.example.com/rest/projects/project-1/agents/v2/agent-1/webhooks/teams', + payload, + headers, + ); + + const sendWebhook = async (payload: unknown) => { + const headers = new Headers(); + headers.set('authorization', `Bearer ${signer.sign()}`); + return await post(payload, headers); + }; + + const lastCall = (method: string) => + stub.apiCalls.filter((call) => call.method === method).at(-1); + + return { + ...setup, + chat: chat as unknown as ChatInstance, + sendWebhook, + sendUnauthenticatedWebhook: async (payload: unknown) => await post(payload, new Headers()), + latestContext: setup.latestContext, + latestThreadId: setup.latestThreadId, + lastPost: () => lastCall('sendActivity'), + lastEdit: () => lastCall('updateActivity'), + lastPostedMessageId: () => stub.postedMessageIds.at(-1), + shutdown: async () => { + stub.restore(); + await setup.shutdown(); + }, + }; +} diff --git a/packages/cli/src/modules/agents/integrations/__tests__/helpers/teams/synthetic-fixtures.ts b/packages/cli/src/modules/agents/integrations/__tests__/helpers/teams/synthetic-fixtures.ts new file mode 100644 index 00000000000..9f27104a355 --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/__tests__/helpers/teams/synthetic-fixtures.ts @@ -0,0 +1,76 @@ +/** + * Hand-built Bot Framework activities for the Teams channel tests. + * + * Teams has no recorded session yet, so these are written against Microsoft's + * documented activity schema rather than captured from a tenant. They are fed + * to the real `@chat-adapter/teams` adapter, so any shape the adapter rejects + * fails the test rather than passing silently. + */ + +export const TEAMS_APP_ID = '11111111-2222-3333-4444-555555555555'; +export const TEAMS_CLIENT_SECRET = 'test-client-secret'; +export const TEAMS_TENANT_ID = '99999999-8888-7777-6666-555555555555'; +export const TEAMS_SERVICE_URL = 'https://smba.trafficmanager.net/amer'; +export const TEAMS_DM_CONVERSATION_ID = 'a:1dm_conversation_alice'; +export const TEAMS_USER_ID = '29:alice-teams-id'; +const TEAMS_USER_AAD_ID = 'aad-alice'; + +export interface TeamsActivityFixture extends Record { + type: string; + id: string; + serviceUrl: string; + conversation: { id: string; conversationType?: string; tenantId?: string; isGroup?: boolean }; + from: { id: string; name?: string; aadObjectId?: string }; + recipient: { id: string; name?: string }; + channelId: string; + channelData?: Record; +} + +function baseActivity(overrides: Partial = {}): TeamsActivityFixture { + return { + type: 'message', + id: 'activity-1', + timestamp: new Date('2026-01-15T10:00:00.000Z').toISOString(), + serviceUrl: TEAMS_SERVICE_URL, + channelId: 'msteams', + conversation: { + id: TEAMS_DM_CONVERSATION_ID, + conversationType: 'personal', + tenantId: TEAMS_TENANT_ID, + }, + from: { id: TEAMS_USER_ID, name: 'Alice', aadObjectId: TEAMS_USER_AAD_ID }, + recipient: { id: `28:${TEAMS_APP_ID}`, name: 'n8n Agent' }, + channelData: { tenant: { id: TEAMS_TENANT_ID } }, + ...overrides, + }; +} + +/** A first direct message from a user — the Teams equivalent of a mention. */ +export const dmMessage: TeamsActivityFixture = baseActivity({ + id: 'activity-dm-1', + text: 'hello agent', +}); + +export const dmFollowUp: TeamsActivityFixture = baseActivity({ + id: 'activity-dm-2', + text: 'follow up', +}); + +/** A message the bot itself authored — must never trigger the agent. */ +export const selfMessage: TeamsActivityFixture = baseActivity({ + id: 'activity-self-1', + text: 'agent talking to itself', + from: { id: `28:${TEAMS_APP_ID}`, name: 'n8n Agent' }, +}); + +/** + * An Adaptive Card button click. Teams delivers `Action.Submit` as a message + * activity carrying `value` and no text. + */ +export function cardAction(value: Record, replyToId: string) { + return baseActivity({ + id: 'activity-action-1', + replyToId, + value, + }); +} diff --git a/packages/cli/src/modules/agents/integrations/__tests__/teams-integration.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/teams-integration.test.ts new file mode 100644 index 00000000000..f5a2e84b68f --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/__tests__/teams-integration.test.ts @@ -0,0 +1,281 @@ +import type { Logger } from '@n8n/backend-common'; +import { UserError } from 'n8n-workflow'; +import { mock } from 'vitest-mock-extended'; + +import { ConflictError } from '@/errors/response-errors/conflict.error'; + +import type { Agent } from '../../entities/agent.entity'; +import type { AgentRepository } from '../../repositories/agent.repository'; +import type { AgentChatIntegrationContext } from '../agent-chat-integration'; +import { + TEAMS_APP_ID as CLIENT_ID, + TEAMS_CLIENT_SECRET as CLIENT_SECRET, + TEAMS_TENANT_ID as TENANT_ID, +} from './helpers/teams/synthetic-fixtures'; +import { TeamsIntegration } from '../platforms/teams-integration'; + +const createTeamsAdapter = vi.fn(() => ({ name: 'teams' })); + +// The adapter is ESM-only; production reaches it through esm-loader's +// `new Function()` indirection, which cannot run under vitest. A spy stands in +// here so the adapter config this integration builds can be asserted directly. +vi.mock('../esm-loader', () => ({ + loadTeamsAdapter: async () => ({ createTeamsAdapter }), +})); + +const AGENT_ID = 'agent-1'; +const CREDENTIAL_ID = 'cred-teams'; + +function servicePrincipalCredential(overrides: Record = {}) { + return { + authentication: 'clientSecret', + tenantId: TENANT_ID, + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + graphApiBaseUrl: 'https://graph.microsoft.com', + ...overrides, + }; +} + +function connectionContext( + credential: Record = servicePrincipalCredential(), +): AgentChatIntegrationContext { + return { + agentId: AGENT_ID, + projectId: 'project-1', + credentialId: CREDENTIAL_ID, + credential, + integration: { type: 'teams', credentialId: CREDENTIAL_ID, settings: undefined }, + ingressEnabled: true, + webhookUrlFor: (platform: string) => `https://n8n.example.com/webhooks/${platform}`, + }; +} + +describe('TeamsIntegration', () => { + let integration: TeamsIntegration; + let agentRepository: ReturnType>; + + beforeEach(() => { + createTeamsAdapter.mockClear(); + agentRepository = mock(); + agentRepository.findByIntegrationCredential.mockResolvedValue([]); + integration = new TeamsIntegration(mock(), agentRepository); + }); + + describe('platform capabilities', () => { + it('reuses the Entra credential, stays hidden, buffers, and settles in place', () => { + expect(integration).toMatchObject({ + credentialTypes: ['microsoftEntraServicePrincipalApi'], + internal: true, + disableStreaming: true, + // Left at the base-class default: full Adaptive Card payloads fit, so + // no callback store is needed. + needsShortCallbackData: false, + deleteActionMessageBeforeResume: false, + }); + }); + + // Also the base-class default; asserted because Teams receiving only + // webhooks is what makes that default correct here. + it('runs on every main', () => { + expect(integration.requiresLeader()).toBe(false); + }); + }); + + describe('createAdapter', () => { + it('maps the credential onto a single-tenant bot identity', async () => { + await integration.createAdapter(connectionContext()); + + expect(createTeamsAdapter).toHaveBeenCalledWith( + expect.objectContaining({ + appId: CLIENT_ID, + appPassword: CLIENT_SECRET, + appTenantId: TENANT_ID, + appType: 'SingleTenant', + }), + ); + }); + + it('trims whitespace pasted into credential fields', async () => { + await integration.createAdapter( + connectionContext(servicePrincipalCredential({ clientId: ` ${CLIENT_ID} ` })), + ); + + expect(createTeamsAdapter).toHaveBeenCalledWith( + expect.objectContaining({ appId: CLIENT_ID }), + ); + }); + + it('rejects a credential that uses certificate authentication', async () => { + const credential = servicePrincipalCredential({ + authentication: 'certificate', + clientSecret: '', + privateKey: 'pem-key', + certificate: 'pem-cert', + }); + + const rejected = integration.createAdapter(connectionContext(credential)); + await expect(rejected).rejects.toThrow(UserError); + await expect(rejected).rejects.toThrow(/certificate authentication/i); + expect(createTeamsAdapter).not.toHaveBeenCalled(); + }); + + it('pins the Bot Connector host instead of letting an env var choose it', async () => { + await integration.createAdapter(connectionContext()); + + expect(createTeamsAdapter).toHaveBeenCalledWith( + expect.objectContaining({ apiUrl: 'https://smba.trafficmanager.net/teams' }), + ); + }); + + it.each([ + 'https://graph.microsoft.us', + 'https://dod-graph.microsoft.us', + 'https://microsoftgraph.chinacloudapi.cn', + ])('rejects the sovereign cloud %s', async (graphApiBaseUrl) => { + await expect( + integration.createAdapter( + connectionContext(servicePrincipalCredential({ graphApiBaseUrl })), + ), + ).rejects.toThrow(/global Microsoft cloud/); + expect(createTeamsAdapter).not.toHaveBeenCalled(); + }); + + it.each([ + 'tenant/id', + 'tenant id', + 'tenant@id', + 'tenant?id', + 'tenant#id', + // Dot segments pass an alphabet check, then normalize the tenant away. + '.', + '..', + '-', + 'a-', + '-a', + 'a..b', + // A verified domain always has at least two labels. + 'contoso', + ])('rejects the malformed tenant ID %p', async (tenantId) => { + await expect( + integration.createAdapter(connectionContext(servicePrincipalCredential({ tenantId }))), + ).rejects.toThrow(/invalid Directory \(tenant\) ID/); + expect(createTeamsAdapter).not.toHaveBeenCalled(); + }); + + it.each([TENANT_ID, 'contoso.onmicrosoft.com', 'a.b', 'my-tenant.example.com'])( + 'accepts the tenant ID %p', + async (tenantId) => { + await expect( + integration.createAdapter(connectionContext(servicePrincipalCredential({ tenantId }))), + ).resolves.toBeDefined(); + expect(createTeamsAdapter).toHaveBeenCalledWith( + expect.objectContaining({ appTenantId: tenantId }), + ); + }, + ); + + it.each([ + ['clientId', /Application \(client\) ID/], + ['clientSecret', /Client Secret/], + ['tenantId', /Directory \(tenant\) ID/], + ])('rejects a credential with no %s', async (field, message) => { + await expect( + integration.createAdapter(connectionContext(servicePrincipalCredential({ [field]: '' }))), + ).rejects.toThrow(message); + expect(createTeamsAdapter).not.toHaveBeenCalled(); + }); + }); + + describe('onBeforeConnect', () => { + it('rejects a credential another agent already connected', async () => { + agentRepository.findByIntegrationCredential.mockResolvedValue([ + mock({ name: 'Support bot' }), + ]); + + await expect(integration.onBeforeConnect(connectionContext())).rejects.toThrow(ConflictError); + }); + + it('surfaces a broken credential before the channel connects', async () => { + await expect( + integration.onBeforeConnect( + connectionContext(servicePrincipalCredential({ tenantId: '' })), + ), + ).rejects.toThrow(UserError); + }); + }); + + describe('formatActionDecisionMessage', () => { + const user = { userId: 'u-1', userName: 'alice', fullName: 'Alice', isBot: false, isMe: false }; + + it.each([ + [true, '✅ Approved by Alice'], + [false, '🚫 Declined by Alice'], + ])('names the decision when one was resolved: %p', (approved, expected) => { + expect( + integration.formatActionDecisionMessage({ + approved, + raw: {}, + user, + } as Parameters[0]), + ).toBe(expected); + }); + + it.each([ + [{ userId: 'u-1', userName: 'alice', fullName: 'Alice' }, 'Alice'], + [{ userId: 'u-1', userName: 'alice', fullName: '' }, 'alice'], + [{ userId: 'u-1', userName: '', fullName: '' }, 'u-1'], + ])('resolves the responder from %p', (responder, expected) => { + expect( + integration.formatActionDecisionMessage({ + approved: true, + raw: {}, + user: { ...responder, isBot: false, isMe: false }, + } as Parameters[0]), + ).toBe(`✅ Approved by ${expected}`); + }); + + // Without the CallbackStore the resume handler resolves neither the + // decision nor the label, so this generic wording is what a Teams + // approval actually settles to today. + it('falls back to a generic outcome when no decision reaches it', () => { + expect( + integration.formatActionDecisionMessage({ + raw: {}, + user, + } as Parameters[0]), + ).toBe('✅ Action selected by Alice'); + }); + }); + + describe('normalizeComponents', () => { + it('converts select options into individual buttons', () => { + expect( + integration.normalizeComponents([ + { + type: 'select', + label: 'Environment', + options: [ + { label: 'Staging', value: 'staging' }, + { label: 'Production', value: 'production' }, + ], + }, + ]), + ).toEqual([ + { type: 'button', label: 'Staging', value: 'staging' }, + { type: 'button', label: 'Production', value: 'production' }, + ]); + }); + + it('leaves components Adaptive Cards render natively untouched', () => { + const components = [ + { type: 'section' as const, text: 'Deploy summary' }, + { type: 'image' as const, url: 'https://example.com/chart.png', altText: 'Chart' }, + { type: 'divider' as const }, + { type: 'button' as const, label: 'Approve', value: 'true' }, + ]; + + expect(integration.normalizeComponents(components)).toEqual(components); + }); + }); +}); diff --git a/packages/cli/src/modules/agents/integrations/adapter-logger.ts b/packages/cli/src/modules/agents/integrations/adapter-logger.ts new file mode 100644 index 00000000000..02a93f42a12 --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/adapter-logger.ts @@ -0,0 +1,23 @@ +import type { Logger } from '@n8n/backend-common'; +import type { Logger as ChatLogger } from 'chat'; + +/** + * Only the message string is forwarded. Adapter metadata arguments are dropped: + * the Discord adapter was observed passing message text, IDs, request + * signatures and public keys that way, none of which belongs in instance logs. + */ +export function createAdapterLogger(logger: Logger, prefix: string): ChatLogger { + const forward = + (level: 'debug' | 'info' | 'warn' | 'error') => + (message: string, ..._args: unknown[]) => { + logger[level](`${prefix} ${message}`); + }; + const chatLogger: ChatLogger = { + child: () => chatLogger, + debug: forward('debug'), + info: forward('info'), + warn: forward('warn'), + error: forward('error'), + }; + return chatLogger; +} diff --git a/packages/cli/src/modules/agents/integrations/component-mapper.ts b/packages/cli/src/modules/agents/integrations/component-mapper.ts index a95c5340e17..e84e2446cd5 100644 --- a/packages/cli/src/modules/agents/integrations/component-mapper.ts +++ b/packages/cli/src/modules/agents/integrations/component-mapper.ts @@ -75,6 +75,24 @@ interface ComponentRenderContext { makeButton: (label: string, rawValue: string, style?: string) => Promise; } +/** + * Shared by the platforms whose rich cards have no select control. Telegram + * keeps its own override because it also rewrites images. + */ +export function expandSelectsToButtons(components: SuspendComponent[]): SuspendComponent[] { + const normalized: SuspendComponent[] = []; + for (const c of components) { + if (c.type === 'select' || c.type === 'radio_select') { + for (const opt of c.options ?? []) { + normalized.push({ type: 'button', label: opt.label, value: opt.value }); + } + continue; + } + normalized.push(c); + } + return normalized; +} + /** * Converts agent SDK suspend payloads into Chat SDK Card elements. * diff --git a/packages/cli/src/modules/agents/integrations/credential-fields.ts b/packages/cli/src/modules/agents/integrations/credential-fields.ts new file mode 100644 index 00000000000..31cb6515cba --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/credential-fields.ts @@ -0,0 +1,30 @@ +import { isRecord } from '@n8n/utils/is-record'; +import { UserError } from 'n8n-workflow'; + +/** + * Reading a credential field is shared rather than repeated per platform so the + * trimming stays uniform: pasted IDs and secrets routinely carry surrounding + * whitespace, and a channel that forgets to trim fails against the vendor with + * no clue why. + */ +export function credentialField(credential: unknown, field: string): string { + if (!isRecord(credential)) return ''; + const value = credential[field]; + return typeof value === 'string' ? value.trim() : ''; +} + +/** + * The caller supplies the whole message, because a useful one names where in + * the vendor's console the value comes from. + * + * Throws `UserError`: a missing field is the user's to fix, not a bug. + */ +export function requireCredentialField( + credential: unknown, + field: string, + message: string, +): string { + const value = credentialField(credential, field); + if (!value) throw new UserError(message); + return value; +} diff --git a/packages/cli/src/modules/agents/integrations/esm-loader.ts b/packages/cli/src/modules/agents/integrations/esm-loader.ts index 05a20c2f222..b04f956b929 100644 --- a/packages/cli/src/modules/agents/integrations/esm-loader.ts +++ b/packages/cli/src/modules/agents/integrations/esm-loader.ts @@ -40,6 +40,10 @@ export async function loadDiscordAdapter() { return await esmImport('@chat-adapter/discord'); } +export async function loadTeamsAdapter() { + return await esmImport('@chat-adapter/teams'); +} + export async function loadMemoryState() { return await esmImport('@chat-adapter/state-memory'); } diff --git a/packages/cli/src/modules/agents/integrations/platforms/__tests__/teams/synthetic-integration.test.ts b/packages/cli/src/modules/agents/integrations/platforms/__tests__/teams/synthetic-integration.test.ts new file mode 100644 index 00000000000..c44036715b6 --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/platforms/__tests__/teams/synthetic-integration.test.ts @@ -0,0 +1,189 @@ +import { isRecord } from '@n8n/utils/is-record'; + +import { createTeamsReplayContext } from '../../../__tests__/helpers/teams/replay-test-context'; +import { + cardAction, + dmFollowUp, + dmMessage, + selfMessage, + TEAMS_DM_CONVERSATION_ID, + TEAMS_USER_ID, +} from '../../../__tests__/helpers/teams/synthetic-fixtures'; + +// The chat SDK + adapters are ESM-only. Production loads them via esm-loader's +// `new Function()` hack to dodge the CJS transform, which can't run under vitest; +// redirect the loaders to native dynamic imports so the real adapter is used. +vi.mock('../../../esm-loader', () => ({ + loadChatSdk: async () => await import('chat'), + loadMemoryState: async () => await import('@chat-adapter/state-memory'), + loadTeamsAdapter: async () => await import('@chat-adapter/teams'), +})); + +function cardActions(body: unknown): Array> { + const found: Array> = []; + const visit = (node: unknown) => { + if (Array.isArray(node)) return node.forEach(visit); + if (!isRecord(node)) return; + if (node.type === 'Action.Submit') found.push(node); + Object.values(node).forEach(visit); + }; + visit(body); + return found; +} + +describe('Microsoft Teams integration scenarios', () => { + it('rejects an activity that carries no Bot Framework token', async () => { + const ctx = await createTeamsReplayContext(); + try { + const response = await ctx.sendUnauthenticatedWebhook(dmMessage); + + expect(response.status).toBe(401); + expect(ctx.agentExecutor.executeForChatPublished).not.toHaveBeenCalled(); + } finally { + await ctx.shutdown(); + } + }); + + it('routes a Teams direct message to the agent and replies in the same conversation', async () => { + const ctx = await createTeamsReplayContext(); + try { + await expect(ctx.sendWebhook(dmMessage)).resolves.toMatchObject({ status: 200 }); + + expect(ctx.agentExecutor.executeForChatPublished).toHaveBeenCalledTimes(1); + expect(ctx.agentExecutor.executeForChatPublished).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: 'agent-1', + projectId: 'project-1', + message: 'hello agent', + author: { id: TEAMS_USER_ID, name: 'Alice' }, + integrationType: 'teams', + }), + ); + expect(ctx.lastPost()?.body).toMatchObject({ + type: 'message', + text: 'Got it', + conversation: { id: TEAMS_DM_CONVERSATION_ID }, + }); + } finally { + await ctx.shutdown(); + } + }); + + it('routes a follow-up in the same direct message conversation to the same session', async () => { + const ctx = await createTeamsReplayContext(); + try { + await ctx.sendWebhook(dmMessage); + const firstThreadId = ctx.latestThreadId(); + + await ctx.sendWebhook(dmFollowUp); + + expect(ctx.agentExecutor.executeForChatPublished).toHaveBeenCalledTimes(2); + expect(ctx.agentExecutor.executeForChatPublished).toHaveBeenLastCalledWith( + expect.objectContaining({ message: 'follow up' }), + ); + expect(ctx.latestThreadId()).toBe(firstThreadId); + } finally { + await ctx.shutdown(); + } + }); + + it('ignores a message the bot itself authored', async () => { + const ctx = await createTeamsReplayContext(); + try { + await ctx.sendWebhook(selfMessage); + + expect(ctx.agentExecutor.executeForChatPublished).not.toHaveBeenCalled(); + expect(ctx.lastPost()).toBeUndefined(); + } finally { + await ctx.shutdown(); + } + }); + + it('renders a suspended select as individual Adaptive Card buttons', async () => { + const ctx = await createTeamsReplayContext({ + stream: [ + { + type: 'tool-call-suspended', + runId: 'run-select-1', + toolCallId: 'tool-select-1', + toolName: 'select', + suspendPayload: { + type: 'form', + toolName: 'pick_environment', + displayName: 'Pick an environment', + components: [ + { + type: 'select', + label: 'Environment', + options: [ + { label: 'Staging', value: 'staging' }, + { label: 'Production', value: 'production' }, + ], + }, + ], + }, + }, + { type: 'finish', finishReason: 'stop' }, + ], + }); + try { + await ctx.sendWebhook(dmMessage); + + const actions = cardActions(ctx.lastPost()?.body); + expect(actions.map((action) => action.title)).toEqual(['Staging', 'Production']); + } finally { + await ctx.shutdown(); + } + }); + + it('resumes a suspended approval from an Adaptive Card button click', async () => { + const ctx = await createTeamsReplayContext({ + stream: [ + { + type: 'tool-call-suspended', + runId: 'run-card-1', + toolCallId: 'tool-card-1', + toolName: 'approval', + suspendPayload: { + type: 'approval', + toolName: 'send_teams_message', + displayName: 'Send Teams message', + args: { text: 'Continue?' }, + }, + }, + { type: 'finish', finishReason: 'stop' }, + ], + }); + try { + await ctx.sendWebhook(dmMessage); + + const cardPost = ctx.lastPost(); + const cardMessageId = ctx.lastPostedMessageId(); + if (!cardMessageId) throw new Error('Expected the approval card to have been posted'); + const actions = cardActions(cardPost?.body); + const approve = actions[0]; + if (!approve) throw new Error('Expected an Adaptive Card action on the approval card'); + + ctx.nextStream([ + { type: 'text-delta', id: 'resume-text', delta: 'Card handled' }, + { type: 'finish', finishReason: 'stop' }, + ]); + await ctx.sendWebhook(cardAction(approve.data as Record, cardMessageId)); + + expect(ctx.agentExecutor.resumeForChat).toHaveBeenCalledWith( + expect.objectContaining({ + runId: 'run-card-1', + toolCallId: 'tool-card-1', + integrationType: 'teams', + }), + ); + + // Generic wording: no CallbackStore, so no decision reaches the formatter. + expect(ctx.lastEdit()?.body).toMatchObject({ + text: '✅ Action selected by Alice', + }); + } finally { + await ctx.shutdown(); + } + }); +}); diff --git a/packages/cli/src/modules/agents/integrations/platforms/discord-integration.ts b/packages/cli/src/modules/agents/integrations/platforms/discord-integration.ts index c529b9f9839..c075b34fff9 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/discord-integration.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/discord-integration.ts @@ -4,7 +4,7 @@ import { type HttpRequestClient, OutboundHttp } from '@n8n/backend-network'; import { OnLeaderStepdown, OnLeaderTakeover, OnShutdown } from '@n8n/decorators'; import { Service } from '@n8n/di'; import { isRecord } from '@n8n/utils/is-record'; -import type { Logger as ChatLogger, Message, Thread } from 'chat'; +import type { Message, Thread } from 'chat'; import escapeRegExp from 'lodash/escapeRegExp'; import { InstanceSettings } from 'n8n-core'; @@ -27,7 +27,9 @@ import { type WebhookRequestResolution, } from '../agent-chat-integration'; import type { ChatInstance } from '../chat-integration.service'; -import type { SuspendComponent } from '../component-mapper'; +import { createAdapterLogger } from '../adapter-logger'; +import { requireCredentialField } from '../credential-fields'; +import { expandSelectsToButtons, type SuspendComponent } from '../component-mapper'; import { assertCredentialNotClaimed } from '../credential-claim'; import { loadDiscordAdapter } from '../esm-loader'; import type { ReplyExpectation } from '../integration-tools'; @@ -236,7 +238,7 @@ export class DiscordIntegration extends AgentChatIntegration { applicationId, mentionRoleIds: [], apiUrl: DISCORD_API_URL, - logger: this.createAdapterLogger(), + logger: createAdapterLogger(this.logger, '[DiscordAdapter]'), }); this.pendingConnections.set(this.sessionKey(ctx), { @@ -405,21 +407,7 @@ export class DiscordIntegration extends AgentChatIntegration { } normalizeComponents(components: SuspendComponent[]): SuspendComponent[] { - const normalized: SuspendComponent[] = []; - for (const c of components) { - switch (c.type) { - case 'select': - case 'radio_select': - // Discord embeds have no select menu, so offer each option as a button. - for (const opt of c.options ?? []) { - normalized.push({ type: 'button', label: opt.label, value: opt.value }); - } - break; - default: - normalized.push(c); - } - } - return normalized; + return expandSelectsToButtons(components); } private sessionKey(ctx: AgentChatIntegrationContext): string { @@ -478,33 +466,12 @@ export class DiscordIntegration extends AgentChatIntegration { } } - /** - * Adapter-compatible logger that forwards only the message string into n8n. - * Pinned adapter 4.28.1 attaches message text, IDs, signatures, and public - * keys as metadata arguments — never forward those. - */ - private createAdapterLogger(): ChatLogger { - const forward = - (level: 'debug' | 'info' | 'warn' | 'error') => - (message: string, ..._args: unknown[]) => { - this.logger[level](`[DiscordAdapter] ${message}`); - }; - const logger: ChatLogger = { - child: () => logger, - debug: forward('debug'), - info: forward('info'), - warn: forward('warn'), - error: forward('error'), - }; - return logger; - } - // --------------------------------------------------------------------------- // Credential extraction // --------------------------------------------------------------------------- private extractBotToken(credential: Record): string { - return this.requireCredentialField( + return requireCredentialField( credential, 'botToken', 'The Discord credential is missing a Bot Token. Copy it from the Bot section of the Discord Developer Portal.', @@ -518,7 +485,7 @@ export class DiscordIntegration extends AgentChatIntegration { * fields fails the connect before the agent is published. */ private extractPublicKey(credential: Record): string { - return this.requireCredentialField( + return requireCredentialField( credential, 'publicKey', 'The Discord credential is missing a Public Key. Copy it from the application General Information page in the Discord Developer Portal.', @@ -526,23 +493,13 @@ export class DiscordIntegration extends AgentChatIntegration { } private extractApplicationId(credential: Record): string { - return this.requireCredentialField( + return requireCredentialField( credential, 'applicationId', 'The Discord credential is missing an Application ID. Copy it from the application General Information page in the Discord Developer Portal.', ); } - private requireCredentialField( - credential: Record, - field: string, - message: string, - ): string { - const value = credential[field]; - if (typeof value === 'string' && value.trim()) return value.trim(); - throw new Error(message); - } - /** * Approval cards render as embeds, so the original copy is usually the first * embed's description rather than the message content. diff --git a/packages/cli/src/modules/agents/integrations/platforms/teams-integration.ts b/packages/cli/src/modules/agents/integrations/platforms/teams-integration.ts new file mode 100644 index 00000000000..4baf61d2591 --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/platforms/teams-integration.ts @@ -0,0 +1,206 @@ +import type { RichCardComponentType } from '@n8n/api-types'; +import { Logger } from '@n8n/backend-common'; +import { Service } from '@n8n/di'; +import { UserError } from 'n8n-workflow'; + +import { AgentRepository } from '../../repositories/agent.repository'; +import { createAdapterLogger } from '../adapter-logger'; +import { credentialField, requireCredentialField } from '../credential-fields'; +import { + AgentChatIntegration, + type AgentChannelPreconditionContext, + type AgentChatIntegrationContext, + type ActionDecisionMessageParams, +} from '../agent-chat-integration'; +import { expandSelectsToButtons, type SuspendComponent } from '../component-mapper'; +import { assertCredentialNotClaimed } from '../credential-claim'; +import { loadTeamsAdapter } from '../esm-loader'; +import { resolveIntegrationActionDefinitions } from '../integration-tool-definitions'; + +/** Pinned so a stray TEAMS_API_URL env var cannot redirect proactive sends. */ +const TEAMS_API_URL = 'https://smba.trafficmanager.net/teams'; + +const GLOBAL_GRAPH_API_BASE_URL = 'https://graph.microsoft.com'; + +/** + * A tenant ID is a GUID or a verified domain. The value reaches the Teams SDK, + * which interpolates it into a token URL path, so the shape is checked before + * it gets there. + * + * The domain form is matched per label rather than by alphabet: a value of `.` + * or `..` passes an alphabet check, and URL normalization then drops the tenant + * segment entirely. Two labels minimum, each starting and ending alphanumeric. + */ +const TENANT_ID_GUID = /^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$/; +const TENANT_ID_DOMAIN = + /^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$/; + +/** + * Teams sits with Discord rather than Telegram on registration: the messaging + * endpoint is configured once in Azure Bot Service, so there is no API call to + * register or release it and no `onAfterConnect`/`onBeforeDisconnect` hook. + * + * This first slice targets 1:1 direct messages. Group chats and channels are not + * blocked, but they are untested: without RSC permissions Teams only delivers an + * @-mention there, so nothing arrives ambiently. + */ +@Service() +export class TeamsIntegration extends AgentChatIntegration { + readonly type = 'teams'; + + readonly credentialTypes = ['microsoftEntraServicePrincipalApi']; + + readonly displayLabel = 'Microsoft Teams'; + + readonly displayIcon = 'teams'; + + /** Hidden from the catalog and the add-trigger UI until the setup stepper ships. */ + readonly internal = true; + + readonly builderGuidance = { + capabilities: [ + 'Receive Microsoft Teams direct messages as agent triggers.', + 'Respond in the same Microsoft Teams conversation.', + 'Render Adaptive Cards with buttons.', + ], + useIntegrationWhen: [ + 'The agent should be chatted with from Microsoft Teams or act as a Teams bot.', + 'The agent needs to reply to Teams users in the same conversation context.', + ], + useNodeToolWhen: [ + 'Microsoft Teams is only a backend API step and the agent does not need to be connected as a Teams chat surface.', + 'The Teams operation is performed by a non-Agent workflow, or the exact operation is not listed in the Agent integration capabilities.', + ], + }; + + readonly supportedComponents: readonly RichCardComponentType[] = [ + 'section', + 'button', + 'divider', + 'fields', + 'image', + ]; + + readonly actionToolDefinitions = resolveIntegrationActionDefinitions(['respond', 'edit_message']); + + readonly actionToolGuidance = [ + 'For edit_message, pass the messageId returned by a previous Teams action or get_current_message_context. The current Teams conversation is selected automatically.', + ]; + + /** + * Teams acknowledges an Adaptive Card action by editing the card in place, so + * the answered card is settled rather than deleted. + */ + readonly deleteActionMessageBeforeResume = false; + + readonly disableStreaming = true; + + constructor( + private readonly logger: Logger, + private readonly agentRepository: AgentRepository, + ) { + super(); + } + + async createAdapter(ctx: AgentChatIntegrationContext): Promise { + const { appId, appPassword, appTenantId } = this.extractBotCredentials(ctx.credential); + const { createTeamsAdapter } = await loadTeamsAdapter(); + + return createTeamsAdapter({ + appId, + appPassword, + appTenantId, + apiUrl: TEAMS_API_URL, + // The Entra credential always carries a tenant ID, so a bot registered as + // multi-tenant is still driven single-tenant here. Nothing detects that + // mismatch: it surfaces as a token-mint failure on the first message. + appType: 'SingleTenant', + logger: createAdapterLogger(this.logger, '[TeamsAdapter]'), + }); + } + + async assertStartupPreconditions(ctx: AgentChannelPreconditionContext): Promise { + await assertCredentialNotClaimed(this.agentRepository, this.displayLabel, this.type, ctx); + } + + async onBeforeConnect(ctx: AgentChatIntegrationContext): Promise { + await this.assertStartupPreconditions(ctx); + + // Surface a bad credential at connect rather than at the first message. + this.extractBotCredentials(ctx.credential); + } + + /** + * Adaptive Cards do render a select as `Input.ChoiceSet`, but the adapter + * submits one through a `__auto_submit` sentinel that fans every input out as + * its own action event. A suspended tool call expects a single action to + * resume it, so options become individual buttons instead. + */ + normalizeComponents(components: SuspendComponent[]): SuspendComponent[] { + return expandSelectsToButtons(components); + } + + formatActionDecisionMessage({ + approved, + selectedLabel, + user, + }: ActionDecisionMessageParams): string { + const responder = user.fullName || user.userName || user.userId; + if (approved === undefined) { + return `✅ ${selectedLabel || 'Action'} selected by ${responder}`; + } + return approved ? `✅ Approved by ${responder}` : `🚫 Declined by ${responder}`; + } + + /** + * The certificate check is ours to make: that mode stores no `clientSecret`, + * so passing it through would build an adapter with no secret and fail later + * with an opaque authentication error. + */ + private extractBotCredentials(credential: Record): { + appId: string; + appPassword: string; + appTenantId: string; + } { + if (credentialField(credential, 'authentication') === 'certificate') { + throw new UserError( + 'Microsoft Teams channels cannot use certificate authentication. ' + + 'Switch the credential to Client Secret, or create a credential that uses one.', + ); + } + + const graphApiBaseUrl = credentialField(credential, 'graphApiBaseUrl'); + if (graphApiBaseUrl && graphApiBaseUrl.replace(/\/+$/, '') !== GLOBAL_GRAPH_API_BASE_URL) { + throw new UserError( + 'Microsoft Teams channels only reach the global Microsoft cloud. ' + + "Set the credential's Microsoft Graph API base URL back to https://graph.microsoft.com.", + ); + } + + const appTenantId = requireCredentialField( + credential, + 'tenantId', + 'The Microsoft Teams credential is missing a Directory (tenant) ID. Copy it from the app registration overview in the Microsoft Entra admin center.', + ); + if (!TENANT_ID_GUID.test(appTenantId) && !TENANT_ID_DOMAIN.test(appTenantId)) { + throw new UserError( + 'The Microsoft Teams credential has an invalid Directory (tenant) ID. ' + + 'Use the GUID from the app registration overview, or a verified domain such as contoso.onmicrosoft.com.', + ); + } + + return { + appId: requireCredentialField( + credential, + 'clientId', + 'The Microsoft Teams credential is missing an Application (client) ID. Copy it from the app registration overview in the Microsoft Entra admin center.', + ), + appPassword: requireCredentialField( + credential, + 'clientSecret', + 'The Microsoft Teams credential is missing a Client Secret. Create one under Certificates & secrets on the app registration.', + ), + appTenantId, + }; + } +} diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/teams.svg b/packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/teams.svg new file mode 100644 index 00000000000..98d3ab7326b --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/teams.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts index aaeacf15fb7..c5bbf8eb6f5 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts @@ -34,6 +34,7 @@ import StatusNew from './custom/status-new.svg'; import StatusUnknown from './custom/status-unknown.svg'; import StatusWaiting from './custom/status-waiting.svg'; import StatusWarning from './custom/status-warning.svg'; +import Teams from './custom/teams.svg'; import Telegram from './custom/telegram.svg'; import Text from './custom/text.svg'; import Toolbox from './custom/toolbox.svg'; @@ -315,6 +316,7 @@ export const deprecatedIconSet = { linear: Linear, telegram: Telegram, discord: Discord, + teams: Teams, spinner: Spinner, xmark: IconLucideX, mcp: Mcp, @@ -536,6 +538,7 @@ export const updatedIconSet = { linear: Linear, telegram: Telegram, discord: Discord, + teams: Teams, spinner: Spinner, 'node-dirty': NodeDirty, 'node-ellipsis': NodeEllipsis, diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentPreviewMoreMenu.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentPreviewMoreMenu.test.ts index a69eae7223f..35c7ddd70b9 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentPreviewMoreMenu.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentPreviewMoreMenu.test.ts @@ -384,6 +384,7 @@ describe('AgentPreviewMoreMenu', function describeMenu() { ['telegram', 'Telegram', 'telegram'], ['linear', 'Linear', 'linear'], ['discord', 'Discord', 'discord'], + ['teams', 'Teams', 'teams'], ['mcp', 'Mcp', 'mcp'], ['workflow', 'Workflow', 'workflow'], ['webhook', 'Webhook', 'webhook'], diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSessionsListView.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSessionsListView.spec.ts index a1d619a7afd..e6bb2791c21 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSessionsListView.spec.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSessionsListView.spec.ts @@ -429,7 +429,8 @@ describe('AgentSessionsListView', () => { [{ parentThreadId: 'parent-1', source: 'slack' }, 'Sub-agent', 'bot'], [{ source: 'task' }, 'Schedule', 'clock'], [{ taskId: 'task-1', source: 'slack' }, 'Schedule', 'clock'], - [{ source: 'teams' }, 'Teams', 'plug'], + [{ source: 'teams' }, 'Teams', 'teams'], + [{ source: 'future-channel' }, 'Future-channel', 'plug'], [{ source: ' Slack ' }, 'Slack', 'slack'], ] as const)( 'renders origin chip for %j as %s with the %s icon', diff --git a/packages/frontend/editor-ui/src/features/agents/channels/registry.test.ts b/packages/frontend/editor-ui/src/features/agents/channels/registry.test.ts index 32825182eee..61bc8b702a9 100644 --- a/packages/frontend/editor-ui/src/features/agents/channels/registry.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/channels/registry.test.ts @@ -23,6 +23,8 @@ describe('agent channel platform registry', () => { it('narrows registered platform keys', () => { expect(isRegisteredAgentChannelPlatform('slack')).toBe(true); expect(isRegisteredAgentChannelPlatform('future-channel')).toBe(false); + // Teams ships without a setup stepper, so it rides the fallback view. + expect(isRegisteredAgentChannelPlatform('teams')).toBe(false); }); it('derives Slack list metadata from its local runtime state', () => { diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentPreviewMoreMenu.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentPreviewMoreMenu.vue index d85424adfaa..120059976b4 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentPreviewMoreMenu.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentPreviewMoreMenu.vue @@ -132,6 +132,7 @@ const triggerIcon = computed(() => { case 'telegram': case 'linear': case 'discord': + case 'teams': case 'mcp': case 'workflow': case 'webhook': diff --git a/packages/frontend/editor-ui/src/features/agents/views/AgentSessionsListView.vue b/packages/frontend/editor-ui/src/features/agents/views/AgentSessionsListView.vue index 5f4954983e1..7ff638cf752 100644 --- a/packages/frontend/editor-ui/src/features/agents/views/AgentSessionsListView.vue +++ b/packages/frontend/editor-ui/src/features/agents/views/AgentSessionsListView.vue @@ -155,6 +155,7 @@ function originPresentation(thread: AgentExecutionThread): OriginPresentation { case 'telegram': case 'linear': case 'discord': + case 'teams': return { icon: source, label: source.charAt(0).toUpperCase() + source.slice(1) }; case 'chat': case 'n8n_chat': diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd4ed56f833..56daeb49215 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -236,6 +236,9 @@ catalogs: '@chat-adapter/state-memory': specifier: 4.37.0 version: 4.37.0 + '@chat-adapter/teams': + specifier: 4.37.0 + version: 4.37.0 '@chat-adapter/telegram': specifier: 4.37.0 version: 4.37.0 @@ -4399,6 +4402,9 @@ importers: '@chat-adapter/state-memory': specifier: 'catalog:' version: 4.37.0(ai@7.0.74(zod@3.25.76))(supports-color@8.1.1)(zod@3.25.76) + '@chat-adapter/teams': + specifier: 'catalog:' + version: 4.37.0(ai@7.0.74(zod@3.25.76))(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)(zod@3.25.76) '@chat-adapter/telegram': specifier: 'catalog:' version: 4.37.0(ai@7.0.74(zod@3.25.76))(supports-color@8.1.1)(zod@3.25.76) @@ -8749,6 +8755,10 @@ packages: resolution: {integrity: sha512-shSDU7Ioecya+Aob5xliW9IGq1Ui8y4EVSdWGyI1Gbm4Vg61WpP95LuzcY214/wEjSn6w4PZYD4/iVldErHayQ==} engines: {node: '>=0.8.0'} + '@azure/msal-common@16.13.0': + resolution: {integrity: sha512-rOAy0KUcyBbdwVJ+f3uPpthXatFLLZN+/KWAsTLzk1aB23Xl9DRmmXYwSvBFOZyXj4jUQQ5FKxxRkhAFW1fOow==} + engines: {node: '>=0.8.0'} + '@azure/msal-common@16.5.2': resolution: {integrity: sha512-GkDEL6TYo3HgT3UuqakdgE9PZfc1hMki6+Hwgy1uddb/EauvAKfu85vVhuofRSo22D1xTnWt8Ucwfg4vSCVwvA==} engines: {node: '>=0.8.0'} @@ -8761,6 +8771,10 @@ packages: resolution: {integrity: sha512-ObTeMoNPmq19X3z40et9Xvs4ZoWVeJg43PZMRLG5iwVL+2nCtAerG3YTDItqPp1CfXNwmCXBbg8jn1DOx65c3g==} engines: {node: '>=20'} + '@azure/msal-node@5.6.0': + resolution: {integrity: sha512-uFY9NxrWHw8PwZx7gAX6PDn+9vdfS05+levc/kwkx77IkjfaldnQbbcQzzDIZ5Hq5Zdr6/z92oAIoRWKp6MnOA==} + engines: {node: '>=20'} + '@azure/search-documents@12.1.0': resolution: {integrity: sha512-IzD+hfqGqFtXymHXm4RzrZW2MsSH2M7RLmZsKaKVi7SUxbeYTUeX+ALk8gVzkM8ykb7EzlDLWCNErKfAa57rYQ==} engines: {node: '>=18.0.0'} @@ -9559,6 +9573,10 @@ packages: resolution: {integrity: sha512-dI3dk+PUxkL1q5FZ7fn7T9nq9SIBnoovw3moTIl8xwuB4kWRNsvFa5CM7H0fGeaDPeyJPR+PQ+rUKPGKumMKTg==} engines: {node: '>=20'} + '@chat-adapter/teams@4.37.0': + resolution: {integrity: sha512-vlMooVt6jYWd13LLR4dJ+EzU/z1EuXHo+jHCDWrQvsP9syYcllnJEu5Qd6cCma5nA7Ri6OEBGNJhCFWGa+Hctg==} + engines: {node: '>=20'} + '@chat-adapter/telegram@4.37.0': resolution: {integrity: sha512-A/lBDH7QUdGzxn+aBUyfg0Dp3uO9AW04Q2iXd4SwAz1t14wVb/gQ+KeI1QB287thgx+uLfr13f0/5Ss58ET11w==} engines: {node: '>=20'} @@ -11208,6 +11226,30 @@ packages: resolution: {integrity: sha512-m3I5uAwE05orsu3D1AGyisX5KxsgVXB+U4bWOOaX/Z7Ftp/2Cy41qsNhO6LPvSxHBaapyser5dVorF1t5M6tig==} hasBin: true + '@microsoft/teams.api@2.0.16': + resolution: {integrity: sha512-ds2SK6JvAcDL1EfVYWJ1Cmvyytm3CIfdPZS7CxYPvsebhfnMNmvxQlDFuh0Hh0hmjLxIJtycqBddMVulZITs1w==} + engines: {node: '>=20'} + + '@microsoft/teams.apps@2.0.16': + resolution: {integrity: sha512-LwNy2MgP92ZZziGs/8f+17xttjgdzxgP2IYQCwNQ5ENoBFoQewRb53mARxeLEBhKtVfAtDG2yKHAqRuQvvltEg==} + engines: {node: '>=20'} + + '@microsoft/teams.cards@2.0.16': + resolution: {integrity: sha512-hOt+2m6oKI5LdASgsQmGB8yOMs/DTMlypgnQDf9dKWFlDiaZ6fkOy3zuNvVRdT3qZ70EOlq1MlhlT8AZxBbcRg==} + engines: {node: '>=20'} + + '@microsoft/teams.common@2.0.16': + resolution: {integrity: sha512-Z/D0RLTNzkcgGNOdfCzq591UUgtuWIaSu6bbieDxIrLERcu6qI3O6EeeMQLCu/+xkxJrjPJny97CtQEhfLwY8Q==} + engines: {node: '>=20'} + + '@microsoft/teams.graph-endpoints@2.0.16': + resolution: {integrity: sha512-yNqxdr/elYyzL0xcrGF5/muZniFLbel4QIQgGalwyropRzupasBuaWmIgFU4RVOG9i1dlvRAIq3sLj8E2OhVQQ==} + engines: {node: '>=20'} + + '@microsoft/teams.graph@2.0.16': + resolution: {integrity: sha512-tEdVCj76gEdgvxoYkBH3li+mr8Tfe94pp1cSOUMQNwOnsuKYvretKMZfAnt9adJCgGzGH2MYZeDDtgwmOBufGQ==} + engines: {node: '>=20'} + '@microsoft/tsdoc-config@0.17.1': resolution: {integrity: sha512-UtjIFe0C6oYgTnad4q1QP4qXwLhe6tIpNTRStJ2RZEPIkqQPREAwE5spzVxsdn9UaEMUqhh0AqSx3X4nWAKXWw==} @@ -19546,6 +19588,10 @@ packages: jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + jwks-rsa@3.2.2: + resolution: {integrity: sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==} + engines: {node: '>=14'} + jwks-rsa@4.0.1: resolution: {integrity: sha512-poXwUA8S4cP9P5N8tZS3xnUDJH8WmwSGfKK9gIaRPdjLHyJtd9iX/cngX9CUIe0Caof5JhK2EbN7N5lnnaf9NA==} engines: {node: ^20.19.0 || ^22.12.0 || >= 23.0.0} @@ -19553,6 +19599,10 @@ packages: jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + jwt-decode@4.0.0: + resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} + engines: {node: '>=18'} + kafkajs@2.2.4: resolution: {integrity: sha512-j/YeapB1vfPT2iOIUn/vxdyKEuhuY2PxMBvf5JWux6iSaukAccrMtXEY/Lb7OvavDhOWME589bpLrEdnVHjfjA==} engines: {node: '>=14.0.0'} @@ -19995,6 +20045,9 @@ packages: resolution: {integrity: sha512-ERJq3FOzJTxBbFjZ7iDs+NiK4VI9Wz+RdrrAB8dio1oV+YvdPzUEE4QNiT2VD51DkIbCYRUUzCRkssXCHqSnKQ==} engines: {node: 14 || >=16.14} + lru-memoizer@2.3.0: + resolution: {integrity: sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==} + lru-memoizer@3.0.0: resolution: {integrity: sha512-m83w/cYXLdUIboKSPxzPAGfYnk+vqeDYXuoSrQRw1q+yVEd8IXhvMufN8Q5TIPe7e2jyX4SRNrDJI2Skw1yznQ==} @@ -26754,6 +26807,8 @@ snapshots: '@azure/msal-common@15.13.3': {} + '@azure/msal-common@16.13.0': {} + '@azure/msal-common@16.5.2': {} '@azure/msal-node@3.8.4': @@ -26767,6 +26822,11 @@ snapshots: '@azure/msal-common': 16.5.2 jsonwebtoken: 9.0.3 + '@azure/msal-node@5.6.0': + dependencies: + '@azure/msal-common': 16.13.0 + jsonwebtoken: 9.0.3 + '@azure/search-documents@12.1.0(supports-color@8.1.1)': dependencies: '@azure/core-auth': 1.10.1(supports-color@8.1.1) @@ -27834,6 +27894,21 @@ snapshots: - workflow - zod + '@chat-adapter/teams@4.37.0(ai@7.0.74(zod@3.25.76))(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)(zod@3.25.76)': + dependencies: + '@chat-adapter/shared': 4.37.0(ai@7.0.74(zod@3.25.76))(supports-color@8.1.1)(zod@3.25.76) + '@microsoft/teams.api': 2.0.16(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1) + '@microsoft/teams.apps': 2.0.16(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1) + '@microsoft/teams.cards': 2.0.16 + '@microsoft/teams.graph-endpoints': 2.0.16 + chat: 4.37.0(ai@7.0.74(zod@3.25.76))(supports-color@8.1.1)(zod@3.25.76) + transitivePeerDependencies: + - ai + - debug + - supports-color + - workflow + - zod + '@chat-adapter/telegram@4.37.0(ai@7.0.74(zod@3.25.76))(supports-color@8.1.1)(zod@3.25.76)': dependencies: '@chat-adapter/shared': 4.37.0(ai@7.0.74(zod@3.25.76))(supports-color@8.1.1)(zod@3.25.76) @@ -29791,6 +29866,51 @@ snapshots: - '@types/node' optional: true + '@microsoft/teams.api@2.0.16(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)': + dependencies: + '@microsoft/teams.cards': 2.0.16 + '@microsoft/teams.common': 2.0.16(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1) + jwt-decode: 4.0.0 + qs: 6.15.2 + transitivePeerDependencies: + - debug + - supports-color + + '@microsoft/teams.apps@2.0.16(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)': + dependencies: + '@azure/msal-node': 5.6.0 + '@microsoft/teams.api': 2.0.16(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1) + '@microsoft/teams.common': 2.0.16(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1) + '@microsoft/teams.graph': 2.0.16(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1) + axios: 1.18.0(patch_hash=149e256a2a7b632497650b32816716ced972ab02a5ab00fbd8a5158a51722c49)(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1) + cors: 2.8.5 + express: 5.2.1(supports-color@8.1.1) + jsonwebtoken: 9.0.3 + jwks-rsa: 3.2.2(supports-color@8.1.1) + reflect-metadata: 0.2.2 + transitivePeerDependencies: + - debug + - supports-color + + '@microsoft/teams.cards@2.0.16': {} + + '@microsoft/teams.common@2.0.16(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)': + dependencies: + axios: 1.18.0(patch_hash=149e256a2a7b632497650b32816716ced972ab02a5ab00fbd8a5158a51722c49)(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1) + transitivePeerDependencies: + - debug + - supports-color + + '@microsoft/teams.graph-endpoints@2.0.16': {} + + '@microsoft/teams.graph@2.0.16(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)': + dependencies: + '@microsoft/teams.common': 2.0.16(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1) + qs: 6.15.2 + transitivePeerDependencies: + - debug + - supports-color + '@microsoft/tsdoc-config@0.17.1': dependencies: '@microsoft/tsdoc': 0.15.1 @@ -39217,6 +39337,16 @@ snapshots: ecdsa-sig-formatter: 1.0.11 safe-buffer: 5.2.1 + jwks-rsa@3.2.2(supports-color@8.1.1): + dependencies: + '@types/jsonwebtoken': 9.0.10 + debug: 4.4.3(supports-color@8.1.1) + jose: 6.2.9 + limiter: 1.1.5 + lru-memoizer: 2.3.0 + transitivePeerDependencies: + - supports-color + jwks-rsa@4.0.1(supports-color@8.1.1): dependencies: '@types/jsonwebtoken': 9.0.10 @@ -39232,6 +39362,8 @@ snapshots: jwa: 2.0.1 safe-buffer: 5.2.1 + jwt-decode@4.0.0: {} + kafkajs@2.2.4: {} katex@0.18.2: @@ -39656,6 +39788,11 @@ snapshots: lru-cache@9.1.2: {} + lru-memoizer@2.3.0: + dependencies: + lodash.clonedeep: 4.5.0 + lru-cache: 6.0.0 + lru-memoizer@3.0.0: dependencies: lodash.clonedeep: 4.5.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 87c17d7b025..6092d042c61 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -78,6 +78,7 @@ catalog: '@chat-adapter/linear': 4.37.0 '@chat-adapter/slack': 4.37.0 '@chat-adapter/state-memory': 4.37.0 + '@chat-adapter/teams': 4.37.0 '@chat-adapter/telegram': 4.37.0 '@codemirror/autocomplete': 6.20.0 '@codemirror/commands': 6.10.1