mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(core): Add the Microsoft Teams agent channel (no-changelog) (#38686)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
adf1eb245e
commit
f9982ef182
|
|
@ -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" |
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<summary><strong>Table Definition</strong></summary>
|
||||
|
||||
```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"))
|
||||
```
|
||||
|
||||
</details>
|
||||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -104,6 +104,9 @@ export type AgentDiscordIntegrationSettings = z.infer<typeof AgentDiscordSetting
|
|||
export const AgentLinearSettingsSchema = AgentSessionOnlySettingsSchema;
|
||||
export type AgentLinearIntegrationSettings = z.infer<typeof AgentLinearSettingsSchema>;
|
||||
|
||||
export const AgentTeamsSettingsSchema = AgentSessionOnlySettingsSchema;
|
||||
export type AgentTeamsIntegrationSettings = z.infer<typeof AgentTeamsSettingsSchema>;
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export const AGENT_SESSION_ORIGINS = [
|
|||
'telegram',
|
||||
'linear',
|
||||
'discord',
|
||||
'teams',
|
||||
] as const;
|
||||
|
||||
export type AgentSessionStatus = (typeof AGENT_SESSION_STATUSES)[number];
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<Logger>();
|
||||
|
||||
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<Logger>();
|
||||
|
||||
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<Logger>(), '[TestAdapter]');
|
||||
|
||||
expect(adapterLogger.child('scope')).toBe(adapterLogger);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<SlackReplayFixtures>(
|
||||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<ReplayContextSetup, 'chat'> {
|
||||
chat: ChatInstance;
|
||||
sendWebhook: (payload: unknown) => Promise<Response>;
|
||||
/** Same payload, no Authorization header — proves the token check is live. */
|
||||
sendUnauthenticatedWebhook: (payload: unknown) => Promise<Response>;
|
||||
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<typeof buildBotFrameworkSigner> | 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<string, unknown>,
|
||||
});
|
||||
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<string, unknown>,
|
||||
});
|
||||
return [200, { id: 'message-edited' }];
|
||||
});
|
||||
|
||||
return { apiCalls, postedMessageIds, restore: () => nock.cleanAll() };
|
||||
}
|
||||
|
||||
export async function createTeamsReplayContext(
|
||||
options: { stream?: StreamChunk[] } = {},
|
||||
): Promise<TeamsReplayContext> {
|
||||
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<string, never>,
|
||||
state: createMemoryState(),
|
||||
});
|
||||
|
||||
const setup = createReplayContextSetup({
|
||||
chat: chat as never,
|
||||
integrationImpl: new TeamsIntegration(mock<BackendLogger>(), mock<AgentRepository>()),
|
||||
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<string, ReplayWebhookHandler>;
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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<string, unknown> {
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
function baseActivity(overrides: Partial<TeamsActivityFixture> = {}): 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<string, unknown>, replyToId: string) {
|
||||
return baseActivity({
|
||||
id: 'activity-action-1',
|
||||
replyToId,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
|
@ -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<string, unknown> = {}) {
|
||||
return {
|
||||
authentication: 'clientSecret',
|
||||
tenantId: TENANT_ID,
|
||||
clientId: CLIENT_ID,
|
||||
clientSecret: CLIENT_SECRET,
|
||||
graphApiBaseUrl: 'https://graph.microsoft.com',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function connectionContext(
|
||||
credential: Record<string, unknown> = 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<typeof mock<AgentRepository>>;
|
||||
|
||||
beforeEach(() => {
|
||||
createTeamsAdapter.mockClear();
|
||||
agentRepository = mock<AgentRepository>();
|
||||
agentRepository.findByIntegrationCredential.mockResolvedValue([]);
|
||||
integration = new TeamsIntegration(mock<Logger>(), 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<Agent>({ 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<typeof integration.formatActionDecisionMessage>[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<typeof integration.formatActionDecisionMessage>[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<typeof integration.formatActionDecisionMessage>[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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -75,6 +75,24 @@ interface ComponentRenderContext {
|
|||
makeButton: (label: string, rawValue: string, style?: string) => Promise<unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -40,6 +40,10 @@ export async function loadDiscordAdapter() {
|
|||
return await esmImport<typeof import('@chat-adapter/discord')>('@chat-adapter/discord');
|
||||
}
|
||||
|
||||
export async function loadTeamsAdapter() {
|
||||
return await esmImport<typeof import('@chat-adapter/teams')>('@chat-adapter/teams');
|
||||
}
|
||||
|
||||
export async function loadMemoryState() {
|
||||
return await esmImport<typeof import('@chat-adapter/state-memory')>('@chat-adapter/state-memory');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Record<string, unknown>> {
|
||||
const found: Array<Record<string, unknown>> = [];
|
||||
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<string, unknown>, 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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, unknown>): 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, unknown>): 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, unknown>): 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<string, unknown>,
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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<unknown> {
|
||||
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<void> {
|
||||
await assertCredentialNotClaimed(this.agentRepository, this.displayLabel, this.type, ctx);
|
||||
}
|
||||
|
||||
async onBeforeConnect(ctx: AgentChatIntegrationContext): Promise<void> {
|
||||
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<string, unknown>): {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2381.4 2354.5"><path fill="#5558af" d="M2015.6 899.2c19.5 19.5 42.5 35 67.9 45.8 53 22.2 112.7 22.2 165.8 0 51.2-21.8 92-62.5 113.7-113.7 22.2-53 22.2-112.7 0-165.8-21.8-51.2-62.5-92-113.7-113.7-53-22.2-112.7-22.2-165.8 0-51.2 21.8-92 62.5-113.7 113.7-22.2 53-22.2 112.7 0 165.8 10.8 25.3 26.4 48.4 45.8 67.9m-62.4 197.8v642.1h107c36.8-.2 73.4-3.6 109.5-10.4 36.3-6.4 71.3-18.6 103.7-36.2 30.6-16.6 57-40 77.3-68.2 21.3-31.3 32-68.6 30.5-106.5V1097zm-346.8-269.2c28.4.2 56.6-5.5 82.8-16.7 51.2-21.8 91.9-62.5 113.6-113.7 22.2-53 22.2-112.7-.1-165.8-21.8-51.2-62.5-92-113.7-113.7-26.2-11.2-54.4-16.9-82.9-16.7-28.3-.2-56.3 5.5-82.3 16.7-19.4 8.3-25.5 19.1-52.2 32.1v329c26.8 13.1 32.8 23.8 52.2 32.1 26.1 11.3 54.2 16.9 82.6 16.7m-134.8 1081.1c26.8 5.8 36.4 10.3 55.4 12.9 20.8 3 41.8 4.5 62.8 4.6 32.4-.2 64.8-3.6 96.5-10.4 32.3-6.5 63.3-18.6 91.5-35.7 27.7-17 51-40.2 68.2-67.7 19-32.1 28.3-69.1 26.9-106.4v-743h-401.3zM0 2113.7l1391.3 240.8V0L0 240.8z"/><path fill="#fff" d="m1016.7 722.4-642.1 39.1v148.1l240.8-9.7v686.7l160.5 9.4V893.6l240.8-10.7z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -132,6 +132,7 @@ const triggerIcon = computed<IconName>(() => {
|
|||
case 'telegram':
|
||||
case 'linear':
|
||||
case 'discord':
|
||||
case 'teams':
|
||||
case 'mcp':
|
||||
case 'workflow':
|
||||
case 'webhook':
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
137
pnpm-lock.yaml
137
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user