mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
feat: Guide the Microsoft Teams channel setup from n8n (no-changelog) (#38804)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f2a10ff3ed
commit
edf112cd92
|
|
@ -153,4 +153,42 @@ describe('AgentIntegrationSchema', () => {
|
|||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Teams availability settings', () => {
|
||||
const parse = (settings: Record<string, unknown>) =>
|
||||
AgentIntegrationSchema.safeParse({ type: 'teams', credentialId: 'cred-123', settings });
|
||||
|
||||
it.each([
|
||||
['nothing beyond the shared shape', {}],
|
||||
['team channels', { teamChannels: true }],
|
||||
['group chats', { groupChats: true }],
|
||||
['a read permission with its scope', { teamChannels: true, readAllChannelMessages: true }],
|
||||
[
|
||||
'both reads with both scopes',
|
||||
{
|
||||
teamChannels: true,
|
||||
groupChats: true,
|
||||
readAllChannelMessages: true,
|
||||
readAllGroupMessages: true,
|
||||
},
|
||||
],
|
||||
])('accepts %s', (_label, settings) => {
|
||||
expect(parse(settings).success).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['reading channels without team channels', { readAllChannelMessages: true }],
|
||||
['reading group chats without group chats', { readAllGroupMessages: true }],
|
||||
[
|
||||
'reading channels while team channels are off',
|
||||
{ teamChannels: false, readAllChannelMessages: true },
|
||||
],
|
||||
])('rejects %s', (_label, settings) => {
|
||||
expect(parse(settings).success).toBe(false);
|
||||
});
|
||||
|
||||
it('still rejects an unknown setting', () => {
|
||||
expect(parse({ somethingElse: true }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -104,7 +104,64 @@ export type AgentDiscordIntegrationSettings = z.infer<typeof AgentDiscordSetting
|
|||
export const AgentLinearSettingsSchema = AgentSessionOnlySettingsSchema;
|
||||
export type AgentLinearIntegrationSettings = z.infer<typeof AgentLinearSettingsSchema>;
|
||||
|
||||
export const AgentTeamsSettingsSchema = AgentSessionOnlySettingsSchema;
|
||||
/**
|
||||
* Where the Teams app may be used, and how much it may read there.
|
||||
*
|
||||
* These are manifest fields, not runtime switches: they become the bot's
|
||||
* `scopes` and its resource-specific permissions, so changing one means a new
|
||||
* app package. Direct chat is not listed because it is always on — it is what
|
||||
* makes the connection testable.
|
||||
*
|
||||
* Both reads default off. Without them Teams delivers only @mentions in a
|
||||
* shared conversation; with them every message arrives.
|
||||
*/
|
||||
/** Teams rejects a longer short name or short description outright. */
|
||||
export const TEAMS_DISPLAY_NAME_MAX = 30;
|
||||
export const TEAMS_DESCRIPTION_MAX = 80;
|
||||
|
||||
export const AgentTeamsSettingsSchema = z
|
||||
.object({
|
||||
sessionIdleTimeoutMinutes,
|
||||
/**
|
||||
* How the app appears in Teams. Both fall back to the agent's own name
|
||||
* when unset, so a first setup needs neither.
|
||||
*/
|
||||
displayName: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(TEAMS_DISPLAY_NAME_MAX)
|
||||
// Teams strips control and formatting characters, so a name made only of
|
||||
// those would be stored as an override that never appears.
|
||||
.refine((value) => /[^\p{Cc}\p{Cf}\s]/u.test(value), {
|
||||
message: 'Enter a name with at least one visible character',
|
||||
})
|
||||
.optional(),
|
||||
description: z.string().trim().min(1).max(TEAMS_DESCRIPTION_MAX).optional(),
|
||||
teamChannels: z.boolean().optional(),
|
||||
groupChats: z.boolean().optional(),
|
||||
readAllChannelMessages: z.boolean().optional(),
|
||||
readAllGroupMessages: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
// A read permission without its surface would sit in the manifest doing
|
||||
// nothing, and would read to the user as if it were in effect.
|
||||
if (value.readAllChannelMessages && !value.teamChannels) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['readAllChannelMessages'],
|
||||
message: 'Turn on team channels before reading all channel messages',
|
||||
});
|
||||
}
|
||||
if (value.readAllGroupMessages && !value.groupChats) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['readAllGroupMessages'],
|
||||
message: 'Turn on group and meeting chats before reading all group messages',
|
||||
});
|
||||
}
|
||||
});
|
||||
export type AgentTeamsIntegrationSettings = z.infer<typeof AgentTeamsSettingsSchema>;
|
||||
|
||||
export const AgentIntegrationSettingsSchema = z.union([
|
||||
|
|
@ -112,6 +169,9 @@ export const AgentIntegrationSettingsSchema = z.union([
|
|||
AgentSlackSettingsSchema,
|
||||
AgentDiscordSettingsSchema,
|
||||
AgentLinearSettingsSchema,
|
||||
// Teams was left out while it aliased the shared shape, when the entry was a
|
||||
// no-op. Its settings now differ, so leaving it out would reject them.
|
||||
AgentTeamsSettingsSchema,
|
||||
z.undefined(),
|
||||
]);
|
||||
export type AgentIntegrationSettings = z.infer<typeof AgentIntegrationSettingsSchema>;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
MAX_AGENT_CHAT_ATTACHMENT_MIMETYPE_LENGTH,
|
||||
MAX_AGENT_CHAT_ATTACHMENTS_PER_MESSAGE,
|
||||
} from './agent-chat-attachments.constants';
|
||||
import { AgentApprovalSchema } from './agent-integration.schema';
|
||||
import { AgentApprovalSchema, AgentTeamsSettingsSchema } from './agent-integration.schema';
|
||||
import { AgentVectorStoreConfigSchema, AgentJsonConfigSchema } from './agent-json-config.schema';
|
||||
import { agentSkillSchema, agentSkillShape } from './agent-skill.schema';
|
||||
import { agentTaskSchema } from './agent-task.schema';
|
||||
|
|
@ -288,6 +288,16 @@ export class AgentConnectIntegrationDto extends Z.class({
|
|||
approval: AgentApprovalSchema.optional(),
|
||||
}) {}
|
||||
|
||||
/**
|
||||
* The package is downloaded in the setup before the channel is connected, so
|
||||
* the settings it must reflect exist only in the open form. Without them the
|
||||
* first zip would ship the defaults whatever the user chose.
|
||||
*/
|
||||
export class AgentTeamsPackageDto extends Z.class({
|
||||
credentialId: z.string().min(1).optional(),
|
||||
settings: AgentTeamsSettingsSchema.optional(),
|
||||
}) {}
|
||||
|
||||
export class AgentDisconnectIntegrationDto extends Z.class({
|
||||
type: z.string().min(1),
|
||||
// Empty string targets a draft integration entry (`credentialId: ''`).
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export * from './inline-agent-config.schema';
|
|||
export * from './sanitize-agent-json-config';
|
||||
export * from './sanitize-agent-tool-name';
|
||||
export * from './slack';
|
||||
export type * from './teams';
|
||||
export * from './agent-task.schema';
|
||||
export * from './dto';
|
||||
export * from './model-providers';
|
||||
|
|
|
|||
1
packages/@n8n/api-types/src/agents/teams/index.ts
Normal file
1
packages/@n8n/api-types/src/agents/teams/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export type * from './types';
|
||||
88
packages/@n8n/api-types/src/agents/teams/types.ts
Normal file
88
packages/@n8n/api-types/src/agents/teams/types.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
/**
|
||||
* The subset of the Teams app manifest this channel emits.
|
||||
*
|
||||
* Deliberately narrower than the published schema: every field here is one we
|
||||
* set, so a field Teams rejects is a compile error rather than a failed upload.
|
||||
*/
|
||||
export interface TeamsAgentAppManifest {
|
||||
$schema: string;
|
||||
manifestVersion: string;
|
||||
version: string;
|
||||
id: string;
|
||||
packageName: string;
|
||||
developer: {
|
||||
name: string;
|
||||
websiteUrl: string;
|
||||
privacyUrl: string;
|
||||
termsOfUseUrl: string;
|
||||
};
|
||||
name: {
|
||||
short: string;
|
||||
full: string;
|
||||
};
|
||||
description: {
|
||||
short: string;
|
||||
full: string;
|
||||
};
|
||||
icons: {
|
||||
color: string;
|
||||
outline: string;
|
||||
};
|
||||
accentColor: string;
|
||||
bots: Array<{
|
||||
botId: string;
|
||||
scopes: string[];
|
||||
isNotificationOnly: boolean;
|
||||
supportsFiles: boolean;
|
||||
}>;
|
||||
permissions: string[];
|
||||
validDomains: string[];
|
||||
/** Required alongside resource-specific permissions; omitted without them. */
|
||||
webApplicationInfo?: {
|
||||
id: string;
|
||||
};
|
||||
/** Present only when a read-all permission is turned on. */
|
||||
authorization?: {
|
||||
permissions: {
|
||||
resourceSpecific: Array<{
|
||||
name: string;
|
||||
type: 'Application';
|
||||
}>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface TeamsAgentSetupState {
|
||||
/** The URL Azure Bot Service posts activities to. */
|
||||
messagingEndpointUrl: string;
|
||||
/** Present once a credential is connected; the manifest needs its client ID. */
|
||||
botId: string | null;
|
||||
/** Null until a credential is connected, because the template needs the client ID. */
|
||||
deployToAzureUrl: string | null;
|
||||
/**
|
||||
* Name of the agent already using the selected credential, if any. Microsoft
|
||||
* binds one Azure bot to one app registration, so a second deployment with
|
||||
* the same credential is refused by the portal and both agents would in any
|
||||
* case share one messaging endpoint.
|
||||
*/
|
||||
credentialClaimedBy: string | null;
|
||||
/**
|
||||
* What the manifest uses when the settings override neither. Computed here so
|
||||
* the fields show the values Teams will actually display, rather than the
|
||||
* frontend guessing at the same derivation.
|
||||
*/
|
||||
defaultDisplayName: string;
|
||||
defaultDescription: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of checking that a credential can actually reach Microsoft, before the
|
||||
* channel is connected. A wrong client secret otherwise surfaces only when the
|
||||
* first message fails.
|
||||
*/
|
||||
export type TeamsCredentialCheck =
|
||||
| { status: 'ok' }
|
||||
| {
|
||||
status: 'failed';
|
||||
reason: 'certificate' | 'incomplete' | 'rejected' | 'unreachable' | 'cloud';
|
||||
};
|
||||
|
|
@ -105,6 +105,8 @@
|
|||
"@types/xml2js": "catalog:",
|
||||
"@types/yargs-parser": "21.0.0",
|
||||
"@vvo/tzdb": "^6.141.0",
|
||||
"ajv": "^8.18.0",
|
||||
"ajv-draft-04": "^1.0.0",
|
||||
"axios": "catalog:",
|
||||
"concurrently": "^8.2.0",
|
||||
"esbuild": "catalog:",
|
||||
|
|
|
|||
|
|
@ -136,35 +136,46 @@ function bundleSpec(sourcePath, distPath) {
|
|||
}
|
||||
|
||||
function copyAgentIntegrationAssets() {
|
||||
const sourceDir = path.resolve(
|
||||
// tsc emits no non-TS files, so every platform's assets are copied here.
|
||||
// Discovered rather than listed: a platform that adds an assets directory
|
||||
// otherwise works in dev, where they are read from src, and ships without
|
||||
// them.
|
||||
const platformsRoot = path.resolve(
|
||||
ROOT_DIR,
|
||||
'src',
|
||||
'modules',
|
||||
'agents',
|
||||
'integrations',
|
||||
'platforms',
|
||||
'slack',
|
||||
'assets',
|
||||
);
|
||||
const destinationDir = path.resolve(
|
||||
ROOT_DIR,
|
||||
'dist',
|
||||
'modules',
|
||||
'agents',
|
||||
'integrations',
|
||||
'platforms',
|
||||
'slack',
|
||||
'assets',
|
||||
);
|
||||
const sourceDirs = glob.sync('*/assets', {
|
||||
cwd: platformsRoot,
|
||||
onlyDirectories: true,
|
||||
absolute: false,
|
||||
});
|
||||
|
||||
if (!existsSync(sourceDir)) {
|
||||
throw new Error(`Agent integration assets directory not found: ${sourceDir}`);
|
||||
if (sourceDirs.length === 0) {
|
||||
throw new Error(`No agent integration assets directories found under: ${platformsRoot}`);
|
||||
}
|
||||
shell.rm('-rf', destinationDir);
|
||||
shell.mkdir('-p', path.dirname(destinationDir));
|
||||
shell.cp('-R', sourceDir, destinationDir);
|
||||
if (!existsSync(destinationDir)) {
|
||||
throw new Error(`Failed to copy agent integration assets to: ${destinationDir}`);
|
||||
|
||||
for (const relativeDir of sourceDirs) {
|
||||
const sourceDir = path.resolve(platformsRoot, relativeDir);
|
||||
const destinationDir = path.resolve(
|
||||
ROOT_DIR,
|
||||
'dist',
|
||||
'modules',
|
||||
'agents',
|
||||
'integrations',
|
||||
'platforms',
|
||||
relativeDir,
|
||||
);
|
||||
|
||||
shell.rm('-rf', destinationDir);
|
||||
shell.mkdir('-p', path.dirname(destinationDir));
|
||||
shell.cp('-R', sourceDir, destinationDir);
|
||||
if (!existsSync(destinationDir)) {
|
||||
throw new Error(`Failed to copy agent integration assets to: ${destinationDir}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,110 @@
|
|||
/* eslint-disable @typescript-eslint/unbound-method -- mock-based tests intentionally reference unbound methods */
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { AgentTeamsIntegrationsController } from '../agent-teams-integrations.controller';
|
||||
import {
|
||||
expectProjectScopedAgentRoutes,
|
||||
getRoutesByHandlerName,
|
||||
} from './test-utils/controller-route-metadata';
|
||||
import type { TeamsCredentialCheckService } from '../integrations/platforms/teams/teams-credential-check.service';
|
||||
import type { TeamsSetupService } from '../integrations/platforms/teams/teams-setup.service';
|
||||
|
||||
/** Reached by the Azure portal and the browser's save dialog, not by the app. */
|
||||
const UNAUTHENTICATED_HANDLERS = new Set(['getArmTemplate', 'armTemplatePreflight']);
|
||||
|
||||
const TEMPLATE = { $schema: 'https://schema.management.azure.com/…', resources: [] };
|
||||
|
||||
describe('AgentTeamsIntegrationsController', () => {
|
||||
expectProjectScopedAgentRoutes(AgentTeamsIntegrationsController, UNAUTHENTICATED_HANDLERS);
|
||||
|
||||
const routes = getRoutesByHandlerName(AgentTeamsIntegrationsController);
|
||||
|
||||
// All three hand back or act on a credential the caller names, so none of
|
||||
// them is a read of the agent alone.
|
||||
it.each([
|
||||
['getSetupState', 'agent:update'],
|
||||
['downloadPackage', 'agent:update'],
|
||||
['checkCredential', 'agent:update'],
|
||||
])('%s uses %s', (handlerName, scope) => {
|
||||
expect(routes.get(handlerName)?.accessScope?.scope).toBe(scope);
|
||||
});
|
||||
|
||||
describe('the ARM template route', () => {
|
||||
const buildController = () => {
|
||||
const setupService = mock<TeamsSetupService>();
|
||||
setupService.buildArmTemplate.mockResolvedValue(TEMPLATE);
|
||||
return {
|
||||
setupService,
|
||||
controller: new AgentTeamsIntegrationsController(
|
||||
setupService,
|
||||
mock<TeamsCredentialCheckService>(),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const request = () =>
|
||||
mock<Request<{ projectId: string }>>({
|
||||
params: { projectId: 'project-1' },
|
||||
query: { token: 'a-token', credentialId: 'cred-1' },
|
||||
});
|
||||
|
||||
it('serves the template at the root, not wrapped in a data envelope', async () => {
|
||||
const { controller } = buildController();
|
||||
const res = mock<Response>();
|
||||
|
||||
await controller.getArmTemplate(request(), res, 'agent-1');
|
||||
|
||||
// The portal answers "this is not a valid template" for anything else,
|
||||
// because the ARM schema has to be the top-level object.
|
||||
expect(res.send).toHaveBeenCalledWith(JSON.stringify(TEMPLATE));
|
||||
});
|
||||
|
||||
it('serves it as JSON', async () => {
|
||||
const { controller } = buildController();
|
||||
const res = mock<Response>();
|
||||
|
||||
await controller.getArmTemplate(request(), res, 'agent-1');
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'application/json');
|
||||
});
|
||||
|
||||
// Azure fetches this from the user's browser, so without CORS it reports
|
||||
// the template as unreachable. Declared on the route rather than set in
|
||||
// the handler, so a request that never reaches the handler still carries
|
||||
// the headers.
|
||||
it.each(['getArmTemplate', 'armTemplatePreflight'])('declares CORS on %s', (handlerName) => {
|
||||
const cors = routes.get(handlerName)?.cors;
|
||||
|
||||
expect(cors).toMatchObject({ allowedOrigins: ['*'], allowedMethods: ['get', 'options'] });
|
||||
});
|
||||
|
||||
it('rate limits the route, which anyone can reach', () => {
|
||||
expect(routes.get('getArmTemplate')?.ipRateLimit).toBeDefined();
|
||||
});
|
||||
|
||||
it('answers a preflight without touching the agent', async () => {
|
||||
const { controller, setupService } = buildController();
|
||||
const res = mock<Response>();
|
||||
res.status.mockReturnValue(res);
|
||||
|
||||
controller.armTemplatePreflight(mock<Request>(), res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(204);
|
||||
expect(setupService.buildArmTemplate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes the signed token and credential through, so neither can be assumed', async () => {
|
||||
const { controller, setupService } = buildController();
|
||||
|
||||
await controller.getArmTemplate(request(), mock<Response>(), 'agent-1');
|
||||
|
||||
expect(setupService.buildArmTemplate).toHaveBeenCalledWith({
|
||||
projectId: 'project-1',
|
||||
agentId: 'agent-1',
|
||||
token: 'a-token',
|
||||
credentialId: 'cred-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
import { AgentTeamsPackageDto } from '@n8n/api-types';
|
||||
import type { TeamsAgentSetupState, TeamsCredentialCheck } from '@n8n/api-types';
|
||||
import { Time } from '@n8n/constants';
|
||||
import type { AuthenticatedRequest } from '@n8n/db';
|
||||
import type { CorsOptions } from '@n8n/decorators';
|
||||
import { Body, Get, Options, Param, Post, ProjectScope, RestController } from '@n8n/decorators';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { TeamsCredentialCheckService } from './integrations/platforms/teams/teams-credential-check.service';
|
||||
import { TeamsSetupService } from './integrations/platforms/teams/teams-setup.service';
|
||||
|
||||
/**
|
||||
* The portal is reached through several hosts (national clouds, and its own
|
||||
* preview host), and an allow-list that misses one fails as "template
|
||||
* unreachable" with nothing naming the origin. The signed token is what
|
||||
* authorises this route; the origin is not relied on for it.
|
||||
*/
|
||||
const TEMPLATE_CORS: Partial<CorsOptions> = {
|
||||
allowedOrigins: ['*'],
|
||||
allowedMethods: ['get', 'options'],
|
||||
allowedHeaders: ['Content-Type'],
|
||||
};
|
||||
|
||||
@RestController('/projects/:projectId/agents/v2')
|
||||
export class AgentTeamsIntegrationsController {
|
||||
constructor(
|
||||
private readonly setupService: TeamsSetupService,
|
||||
private readonly credentialCheckService: TeamsCredentialCheckService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Proves the credential can reach Microsoft before the channel is connected.
|
||||
* Connecting a credential that cannot mint a token leaves a channel that
|
||||
* looks connected and fails on the first message.
|
||||
*/
|
||||
@Post('/:agentId/integrations/teams/check/:credentialId')
|
||||
@ProjectScope('agent:update')
|
||||
async checkCredential(
|
||||
req: AuthenticatedRequest<{ projectId: string }>,
|
||||
_res: Response,
|
||||
@Param('credentialId') credentialId: string,
|
||||
): Promise<TeamsCredentialCheck> {
|
||||
return await this.credentialCheckService.check(req.user, req.params.projectId, credentialId);
|
||||
}
|
||||
|
||||
@Get('/:agentId/integrations/teams/setup')
|
||||
@ProjectScope('agent:update')
|
||||
async getSetupState(
|
||||
req: AuthenticatedRequest<{ projectId: string }, {}, {}, { credentialId?: string }>,
|
||||
_res: Response,
|
||||
@Param('agentId') agentId: string,
|
||||
): Promise<TeamsAgentSetupState> {
|
||||
const credentialId = req.query.credentialId;
|
||||
return await this.setupService.getSetupState(
|
||||
req.user,
|
||||
{ projectId: req.params.projectId, agentId },
|
||||
typeof credentialId === 'string' ? credentialId : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A POST because the manifest is built from the settings in the open form,
|
||||
* which are not stored until the channel is connected.
|
||||
*/
|
||||
@Post('/:agentId/integrations/teams/package')
|
||||
@ProjectScope('agent:update')
|
||||
async downloadPackage(
|
||||
req: AuthenticatedRequest<{ projectId: string }>,
|
||||
res: Response,
|
||||
@Param('agentId') agentId: string,
|
||||
@Body payload: AgentTeamsPackageDto,
|
||||
): Promise<void> {
|
||||
const archive = await this.setupService.buildPackage(
|
||||
req.user,
|
||||
{ projectId: req.params.projectId, agentId },
|
||||
payload.credentialId,
|
||||
payload.settings,
|
||||
);
|
||||
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="n8n-agent-teams-app.zip"');
|
||||
res.send(archive);
|
||||
}
|
||||
|
||||
@Options('/:agentId/integrations/teams/arm-template', { skipAuth: true, cors: TEMPLATE_CORS })
|
||||
armTemplatePreflight(_req: Request, res: Response): void {
|
||||
res.status(204).send();
|
||||
}
|
||||
|
||||
/**
|
||||
* Carries no n8n session, so it authorises on the signed token in the query
|
||||
* string instead. Rate limited because it is reachable by anyone and does a
|
||||
* database read once a token verifies.
|
||||
*
|
||||
* Written straight to the response rather than returned: the REST layer wraps
|
||||
* a returned value in `{ data: ... }`, and the portal rejects that with
|
||||
* "this is not a valid template" because the ARM schema has to be at the root.
|
||||
*/
|
||||
@Get('/:agentId/integrations/teams/arm-template', {
|
||||
skipAuth: true,
|
||||
cors: TEMPLATE_CORS,
|
||||
ipRateLimit: { limit: 60, windowMs: Time.minutes.toMilliseconds },
|
||||
})
|
||||
async getArmTemplate(
|
||||
req: Request<{ projectId: string }>,
|
||||
res: Response,
|
||||
@Param('agentId') agentId: string,
|
||||
): Promise<void> {
|
||||
const { token, credentialId } = req.query;
|
||||
const template = await this.setupService.buildArmTemplate({
|
||||
projectId: req.params.projectId,
|
||||
agentId,
|
||||
token: typeof token === 'string' ? token : '',
|
||||
credentialId: typeof credentialId === 'string' ? credentialId : '',
|
||||
});
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.send(JSON.stringify(template));
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ export class AgentsModule implements ModuleInterface {
|
|||
await import('./agent-chat.controller.js');
|
||||
await import('./agent-integrations.controller.js');
|
||||
await import('./agent-slack-integrations.controller.js');
|
||||
await import('./agent-teams-integrations.controller.js');
|
||||
await import('./agent-vector-stores.controller.js');
|
||||
await import('./agent-tasks.controller.js');
|
||||
await import('./agent-sandbox.controller.js');
|
||||
|
|
@ -87,7 +88,9 @@ 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 { TeamsIntegration } = await import(
|
||||
'./integrations/platforms/teams/teams-integration.js'
|
||||
);
|
||||
const { N8nChatIntegration } = await import('./integrations/platforms/n8n-chat-integration.js');
|
||||
const registry = Container.get(ChatIntegrationRegistry);
|
||||
registry.register(Container.get(SlackIntegration));
|
||||
|
|
|
|||
|
|
@ -74,6 +74,41 @@ so response stubs only need to be valid enough for the real adapter to proceed.
|
|||
by id). Linear's "mention" is an **agent-session** event, not a comment — see the contract note
|
||||
below.
|
||||
|
||||
#### Teams setup, outside the adapter
|
||||
|
||||
The Teams **setup** path is tested separately from the channel, because none of
|
||||
it goes through the adapter:
|
||||
|
||||
- `teams-manifest.service.test.ts` validates the generated manifest against
|
||||
Microsoft's published schema, vendored at
|
||||
`platforms/teams/__tests__/fixtures/MicrosoftTeams.schema.v1.16.json` so the
|
||||
test needs no network. The schema is **draft-04**, hence `ajv-draft-04`. Icon
|
||||
checks decode the bundled PNGs with `node:zlib` rather than adding a decoder.
|
||||
- `teams-credential-check.service.test.ts` asserts the shape of the token
|
||||
request — the tenant-scoped URL, the Bot Framework scope, the
|
||||
client-credentials grant — and that the reply carries an access token. The
|
||||
token itself is opaque in the test and is not validated. A 200 carrying no
|
||||
token counts as a failure.
|
||||
- **`[TeamsAdapter] Failed to fetch user info from Graph API` is expected.** The
|
||||
adapter calls `GET /users/{aadObjectId}` with the bot's app-only token, and a
|
||||
fresh Entra app registration has no Graph permissions. It warns, caches a
|
||||
negative result so it does not retry per message, and returns null.
|
||||
|
||||
The only thing lost is `message.author.email`. The author's name and id come
|
||||
from the activity payload, so routing, sessions and replies are unaffected.
|
||||
|
||||
Curing it needs `User.Read.All` granted on the **Entra app registration**, with
|
||||
admin consent. The Teams manifest cannot grant it: manifest permissions are
|
||||
resource-specific, scoped to one team or chat and consented by its owner, while
|
||||
this is a tenant-wide directory read. `User.ReadBasic.All` would be the lighter
|
||||
ask but is delegated-only, so app-only has no smaller option. We do not ask for
|
||||
it — tenant-wide directory read is a poor trade for one optional field.
|
||||
|
||||
- The bundled icons live in `platforms/teams/assets/`. `copyAgentIntegrationAssets`
|
||||
in `packages/cli/scripts/build.mjs` discovers every `platforms/*/assets`
|
||||
directory; a platform that hardcodes itself out of that list works in dev,
|
||||
where assets are read from `src`, and ships without them.
|
||||
|
||||
## Test Layout
|
||||
|
||||
```text
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { CredentialsEntity } from '@n8n/db';
|
||||
|
||||
import type { CredentialsService } from '@/credentials/credentials.service';
|
||||
|
||||
import { AgentCredentialLookupService } from '../agent-credential-lookup.service';
|
||||
|
||||
const PROJECT_ID = 'project-1';
|
||||
const CREDENTIAL_ID = 'cred-1';
|
||||
const TYPE = 'microsoftEntraServicePrincipalApi';
|
||||
|
||||
describe('AgentCredentialLookupService', () => {
|
||||
let credentialsService: ReturnType<typeof mock<CredentialsService>>;
|
||||
let service: AgentCredentialLookupService;
|
||||
|
||||
beforeEach(() => {
|
||||
credentialsService = mock<CredentialsService>();
|
||||
credentialsService.findAllCredentialIdsForProject.mockResolvedValue([]);
|
||||
credentialsService.findAllGlobalCredentialIds.mockResolvedValue([]);
|
||||
credentialsService.decrypt.mockResolvedValue({ clientId: 'a-client' });
|
||||
service = new AgentCredentialLookupService(credentialsService);
|
||||
});
|
||||
|
||||
it('finds a credential belonging to the project', async () => {
|
||||
const wanted = mock<CredentialsEntity>({ id: CREDENTIAL_ID, type: TYPE });
|
||||
credentialsService.findAllCredentialIdsForProject.mockResolvedValue([
|
||||
mock<CredentialsEntity>({ id: 'another-cred', type: TYPE }),
|
||||
wanted,
|
||||
]);
|
||||
|
||||
expect(await service.decryptForProject(PROJECT_ID, CREDENTIAL_ID, TYPE)).toEqual({
|
||||
clientId: 'a-client',
|
||||
});
|
||||
// Asserted by identity: decrypt is stubbed, so a wrong pick would still
|
||||
// return the expected object.
|
||||
expect(credentialsService.decrypt).toHaveBeenCalledWith(wanted, true);
|
||||
});
|
||||
|
||||
it('finds a credential shared globally rather than through the project', async () => {
|
||||
const wanted = mock<CredentialsEntity>({ id: CREDENTIAL_ID, type: TYPE });
|
||||
credentialsService.findAllGlobalCredentialIds.mockResolvedValue([
|
||||
mock<CredentialsEntity>({ id: 'another-cred', type: TYPE }),
|
||||
wanted,
|
||||
]);
|
||||
|
||||
expect(await service.decryptForProject(PROJECT_ID, CREDENTIAL_ID, TYPE)).toEqual({
|
||||
clientId: 'a-client',
|
||||
});
|
||||
expect(credentialsService.decrypt).toHaveBeenCalledWith(wanted, true);
|
||||
});
|
||||
|
||||
it('leaves the global credentials unread when the project already has it', async () => {
|
||||
credentialsService.findAllCredentialIdsForProject.mockResolvedValue([
|
||||
mock<CredentialsEntity>({ id: CREDENTIAL_ID, type: TYPE }),
|
||||
]);
|
||||
|
||||
await service.decryptForProject(PROJECT_ID, CREDENTIAL_ID, TYPE);
|
||||
|
||||
// The global query reads every global credential, data column included.
|
||||
expect(credentialsService.findAllGlobalCredentialIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a credential of another type', async () => {
|
||||
credentialsService.findAllCredentialIdsForProject.mockResolvedValue([
|
||||
mock<CredentialsEntity>({ id: CREDENTIAL_ID, type: 'slackApi' }),
|
||||
]);
|
||||
|
||||
expect(await service.decryptForProject(PROJECT_ID, CREDENTIAL_ID, TYPE)).toBeNull();
|
||||
expect(credentialsService.decrypt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a credential visible to neither', async () => {
|
||||
expect(await service.decryptForProject(PROJECT_ID, CREDENTIAL_ID, TYPE)).toBeNull();
|
||||
expect(credentialsService.decrypt).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -9,7 +9,7 @@ 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 { TeamsIntegration } from '../../../platforms/teams/teams-integration';
|
||||
import {
|
||||
createReplayContextSetup,
|
||||
type ReplayApiCall,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
TEAMS_CLIENT_SECRET as CLIENT_SECRET,
|
||||
TEAMS_TENANT_ID as TENANT_ID,
|
||||
} from './helpers/teams/synthetic-fixtures';
|
||||
import { TeamsIntegration } from '../platforms/teams-integration';
|
||||
import { TeamsIntegration } from '../platforms/teams/teams-integration';
|
||||
|
||||
const createTeamsAdapter = vi.fn(() => ({ name: 'teams' }));
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
import type { User } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
|
||||
import { CredentialsService } from '@/credentials/credentials.service';
|
||||
|
||||
/**
|
||||
* Resolves a credential through the agent's project rather than a signed-in
|
||||
* user, because the routes that need it run without one.
|
||||
*
|
||||
* Shared so that a setup step and the check that gates it cannot disagree about
|
||||
* which credentials a project may read. They did: a globally shared credential
|
||||
* that setup accepted was reported as missing by the check.
|
||||
*
|
||||
* `ChatIntegrationService.decryptCredentialForProject` still resolves the same
|
||||
* way for the running channel and has not been moved here, so this is not yet
|
||||
* the only copy.
|
||||
*/
|
||||
@Service()
|
||||
export class AgentCredentialLookupService {
|
||||
constructor(private readonly credentialsService: CredentialsService) {}
|
||||
|
||||
/**
|
||||
* Returns the decrypted credential, or null when the user may not use it in
|
||||
* this project, or it is not of the expected type.
|
||||
*
|
||||
* The filter is the one connecting a channel uses, so a route that reads a
|
||||
* credential and the route that connects it agree on who may name it.
|
||||
*/
|
||||
async decryptForUser(user: User, projectId: string, credentialId: string, expectedType: string) {
|
||||
const usable = await this.credentialsService.getCredentialsAUserCanUseInAWorkflow(user, {
|
||||
projectId,
|
||||
});
|
||||
if (!usable.some((item) => item.id === credentialId)) return null;
|
||||
return await this.decryptForProject(projectId, credentialId, expectedType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the decrypted credential, or null when it is not visible to the
|
||||
* project or is not of the expected type.
|
||||
*
|
||||
* No user is consulted, so this is only for a route that has none and is
|
||||
* authorised another way. Anything reached by a signed-in caller should use
|
||||
* `decryptForUser`.
|
||||
*/
|
||||
async decryptForProject(projectId: string, credentialId: string, expectedType: string) {
|
||||
const projectCredentials =
|
||||
await this.credentialsService.findAllCredentialIdsForProject(projectId);
|
||||
// Global credentials are only consulted on a miss: the query reads every
|
||||
// global credential, data column included.
|
||||
const credential =
|
||||
projectCredentials.find((item) => item.id === credentialId) ??
|
||||
(await this.credentialsService.findAllGlobalCredentialIds(true)).find(
|
||||
(item) => item.id === credentialId,
|
||||
);
|
||||
|
||||
if (!credential || credential.type !== expectedType) return null;
|
||||
return await this.credentialsService.decrypt(credential, true);
|
||||
}
|
||||
}
|
||||
|
|
@ -105,3 +105,66 @@ export function hasUpdateIssueField(input: {
|
|||
input.parentId !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero-width joiner and the variation selectors are formatting characters that
|
||||
* hold an emoji sequence together, so removing them breaks the glyph. Checked
|
||||
* by code point rather than a character class, which cannot hold a combining
|
||||
* character without becoming misleading.
|
||||
*/
|
||||
function isEmojiGlue(character: string): boolean {
|
||||
const point = character.codePointAt(0) ?? 0;
|
||||
return point === 0x200d || (point >= 0xfe00 && point <= 0xfe0f);
|
||||
}
|
||||
|
||||
/** Whitespace that is also a control character, so it becomes a space first. */
|
||||
const WHITESPACE_CONTROLS = /[\t\n\v\f\r\u0085\u2028\u2029]/gu;
|
||||
|
||||
const FORMATTING = /[\p{Cc}\p{Cf}]/gu;
|
||||
|
||||
const GRAPHEMES = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
||||
|
||||
/**
|
||||
* Reduces a user-chosen name to something safe to put in a vendor app listing:
|
||||
* no stray control characters, no runs of whitespace, and within the vendor's
|
||||
* length cap.
|
||||
*
|
||||
* Accented and non-Latin names survive. Slack keeps its own stricter rule,
|
||||
* because Slack's app names are restricted where Teams' are not.
|
||||
*
|
||||
* `maxLength` counts code points, which is what a JSON Schema `maxLength`
|
||||
* counts, but the cut lands on a grapheme boundary, so a flag or a joined emoji
|
||||
* is never left half-written.
|
||||
*/
|
||||
export function sanitiseAppName(raw: string, maxLength: number, fallback: string): string {
|
||||
const cleaned = raw
|
||||
.replace(WHITESPACE_CONTROLS, ' ')
|
||||
.replace(FORMATTING, (character) => (isEmojiGlue(character) ? character : ''))
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
const out = truncateToCodePoints(cleaned, maxLength);
|
||||
return out.length > 0 ? out : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cuts to a budget of `max` code points -- what a JSON Schema `maxLength`
|
||||
* counts -- but only on a grapheme boundary, so a flag or a joined emoji is
|
||||
* never left half-written.
|
||||
*
|
||||
* Every cap on a vendor field goes through here. Capping twice with two rules
|
||||
* is how a value that one step kept whole gets halved by the next.
|
||||
*/
|
||||
export function truncateToCodePoints(value: string, max: number): string {
|
||||
if (Array.from(value).length <= max) return value.trim();
|
||||
|
||||
let out = '';
|
||||
let points = 0;
|
||||
for (const { segment } of GRAPHEMES.segment(value)) {
|
||||
const size = Array.from(segment).length;
|
||||
if (points + size > max) break;
|
||||
out += segment;
|
||||
points += size;
|
||||
}
|
||||
return out.trim();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,904 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-04/schema#",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
},
|
||||
"manifestVersion": {
|
||||
"type": "string",
|
||||
"description": "The version of the schema this manifest is using. This schema version supports extending Teams apps to other parts of the Microsoft 365 ecosystem. More info at https://aka.ms/extendteamsapps.",
|
||||
"const": "1.16"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "The version of the app. Changes to your manifest should cause a version change. This version string must follow the semver standard (http://semver.org).",
|
||||
"maxLength": 256
|
||||
},
|
||||
"id": {
|
||||
"$ref": "#/definitions/guid",
|
||||
"description": "A unique identifier for this app. This id must be a GUID."
|
||||
},
|
||||
"packageName": {
|
||||
"type": "string",
|
||||
"description": "A unique identifier for this app in reverse domain notation. E.g: com.example.myapp",
|
||||
"maxLength": 64
|
||||
},
|
||||
"localizationInfo": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"defaultLanguageTag": {
|
||||
"$ref": "#/definitions/languageTag",
|
||||
"description": "The language tag of the strings in this top level manifest file.",
|
||||
"default": "en-us"
|
||||
},
|
||||
"additionalLanguages": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"languageTag": {
|
||||
"$ref": "#/definitions/languageTag",
|
||||
"description": "The language tag of the strings in the provided file."
|
||||
},
|
||||
"file": {
|
||||
"$ref": "#/definitions/relativePath",
|
||||
"description": "A relative file path to a the .json file containing the translated strings."
|
||||
}
|
||||
},
|
||||
"required": ["languageTag", "file"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["defaultLanguageTag"]
|
||||
},
|
||||
"developer": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The display name for the developer.",
|
||||
"maxLength": 32
|
||||
},
|
||||
"mpnId": {
|
||||
"type": "string",
|
||||
"description": "The Microsoft Partner Network ID that identifies the partner organization building the app. This field is not required, and should only be used if you are already part of the Microsoft Partner Network. More info at https://aka.ms/partner",
|
||||
"maxLength": 10
|
||||
},
|
||||
"websiteUrl": {
|
||||
"$ref": "#/definitions/httpsUrl",
|
||||
"description": "The url to the page that provides support information for the app."
|
||||
},
|
||||
"privacyUrl": {
|
||||
"$ref": "#/definitions/httpsUrl",
|
||||
"description": "The url to the page that provides privacy information for the app."
|
||||
},
|
||||
"termsOfUseUrl": {
|
||||
"$ref": "#/definitions/httpsUrl",
|
||||
"description": "The url to the page that provides the terms of use for the app."
|
||||
}
|
||||
},
|
||||
"required": ["name", "websiteUrl", "privacyUrl", "termsOfUseUrl"]
|
||||
},
|
||||
"name": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"short": {
|
||||
"type": "string",
|
||||
"description": "A short display name for the app.",
|
||||
"maxLength": 30
|
||||
},
|
||||
"full": {
|
||||
"type": "string",
|
||||
"description": "The full name of the app, used if the full app name exceeds 30 characters.",
|
||||
"maxLength": 100
|
||||
}
|
||||
},
|
||||
"required": ["short", "full"]
|
||||
},
|
||||
"description": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"short": {
|
||||
"type": "string",
|
||||
"description": "A short description of the app used when space is limited. Maximum length is 80 characters.",
|
||||
"maxLength": 80
|
||||
},
|
||||
"full": {
|
||||
"type": "string",
|
||||
"description": "The full description of the app. Maximum length is 4000 characters.",
|
||||
"maxLength": 4000
|
||||
}
|
||||
},
|
||||
"required": ["short", "full"]
|
||||
},
|
||||
"icons": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"outline": {
|
||||
"$ref": "#/definitions/relativePath",
|
||||
"description": "A relative file path to a transparent PNG outline icon. The border color needs to be white. Size 32x32."
|
||||
},
|
||||
"color": {
|
||||
"$ref": "#/definitions/relativePath",
|
||||
"description": "A relative file path to a full color PNG icon. Size 192x192."
|
||||
}
|
||||
},
|
||||
"required": ["outline", "color"]
|
||||
},
|
||||
"accentColor": {
|
||||
"$ref": "#/definitions/hexColor",
|
||||
"description": "A color to use in conjunction with the icon. The value must be a valid HTML color code starting with '#', for example `#4464ee`."
|
||||
},
|
||||
"configurableTabs": {
|
||||
"type": "array",
|
||||
"description": "These are tabs users can optionally add to their channels and 1:1 or group chats and require extra configuration before they are added. Configurable tabs are not supported in the personal scope. Currently only one configurable tab per app is supported.",
|
||||
"maxItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"configurationUrl": {
|
||||
"$ref": "#/definitions/httpsUrl",
|
||||
"description": "The url to use when configuring the tab."
|
||||
},
|
||||
"canUpdateConfiguration": {
|
||||
"type": "boolean",
|
||||
"description": "A value indicating whether an instance of the tab's configuration can be updated by the user after creation.",
|
||||
"default": true
|
||||
},
|
||||
"scopes": {
|
||||
"type": "array",
|
||||
"description": "Specifies whether the tab offers an experience in the context of a channel in a team, in a 1:1 or group chat, or in an experience scoped to an individual user alone. These options are non-exclusive. Currently, configurable tabs are only supported in the teams and groupchats scopes.",
|
||||
"maxItems": 2,
|
||||
"items": {
|
||||
"enum": ["team", "groupChat", "groupchat"]
|
||||
}
|
||||
},
|
||||
"meetingSurfaces": {
|
||||
"type": "array",
|
||||
"description": "The set of meetingSurfaceItem scopes that a tab belong to",
|
||||
"maxItems": 2,
|
||||
"items": {
|
||||
"enum": ["sidePanel", "stage"]
|
||||
}
|
||||
},
|
||||
"context": {
|
||||
"type": "array",
|
||||
"description": "The set of contextItem scopes that a tab belong to",
|
||||
"maxItems": 8,
|
||||
"items": {
|
||||
"enum": [
|
||||
"personalTab",
|
||||
"channelTab",
|
||||
"privateChatTab",
|
||||
"meetingChatTab",
|
||||
"meetingDetailsTab",
|
||||
"meetingSidePanel",
|
||||
"meetingStage",
|
||||
"callingSidePanel"
|
||||
]
|
||||
}
|
||||
},
|
||||
"sharePointPreviewImage": {
|
||||
"$ref": "#/definitions/relativePath",
|
||||
"description": "A relative file path to a tab preview image for use in SharePoint. Size 1024x768."
|
||||
},
|
||||
"supportedSharePointHosts": {
|
||||
"type": "array",
|
||||
"description": "Defines how your tab will be made available in SharePoint.",
|
||||
"maxItems": 2,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"enum": ["sharePointFullPage", "sharePointWebPart"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["configurationUrl", "scopes"]
|
||||
}
|
||||
},
|
||||
"staticTabs": {
|
||||
"type": "array",
|
||||
"description": "A set of tabs that may be 'pinned' by default, without the user adding them manually. Static tabs declared in personal scope are always pinned to the app's personal experience. Static tabs do not currently support the 'teams' scope.",
|
||||
"maxItems": 16,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"entityId": {
|
||||
"type": "string",
|
||||
"description": "A unique identifier for the entity which the tab displays.",
|
||||
"maxLength": 64
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The display name of the tab.",
|
||||
"maxLength": 128
|
||||
},
|
||||
"contentUrl": {
|
||||
"$ref": "#/definitions/httpsUrl",
|
||||
"description": "The url which points to the entity UI to be displayed in the canvas."
|
||||
},
|
||||
"contentBotId": {
|
||||
"$ref": "#/definitions/guid",
|
||||
"description": "The Microsoft App ID specified for the bot in the Bot Framework portal (https://dev.botframework.com/bots)"
|
||||
},
|
||||
"websiteUrl": {
|
||||
"$ref": "#/definitions/httpsUrl",
|
||||
"description": "The url to point at if a user opts to view in a browser."
|
||||
},
|
||||
"searchUrl": {
|
||||
"$ref": "#/definitions/httpsUrl",
|
||||
"description": "The url to direct a user's search queries."
|
||||
},
|
||||
"scopes": {
|
||||
"type": "array",
|
||||
"description": "Specifies whether the tab offers an experience in the context of a channel in a team, or an experience scoped to an individual user alone. These options are non-exclusive. Currently static tabs are only supported in the 'personal' scope.",
|
||||
"maxItems": 3,
|
||||
"items": {
|
||||
"enum": ["team", "personal", "groupChat", "groupchat"]
|
||||
}
|
||||
},
|
||||
"context": {
|
||||
"type": "array",
|
||||
"description": "The set of contextItem scopes that a tab belong to",
|
||||
"maxItems": 8,
|
||||
"items": {
|
||||
"enum": [
|
||||
"personalTab",
|
||||
"channelTab",
|
||||
"privateChatTab",
|
||||
"meetingChatTab",
|
||||
"meetingDetailsTab",
|
||||
"meetingSidePanel",
|
||||
"meetingStage",
|
||||
"teamLevelApp"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["entityId", "scopes"]
|
||||
}
|
||||
},
|
||||
"bots": {
|
||||
"type": "array",
|
||||
"description": "The set of bots for this app. Currently only one bot per app is supported.",
|
||||
"maxItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"botId": {
|
||||
"$ref": "#/definitions/guid",
|
||||
"description": "The Microsoft App ID specified for the bot in the Bot Framework portal (https://dev.botframework.com/bots)"
|
||||
},
|
||||
"needsChannelSelector": {
|
||||
"type": "boolean",
|
||||
"description": "This value describes whether or not the bot utilizes a user hint to add the bot to a specific channel.",
|
||||
"default": false
|
||||
},
|
||||
"isNotificationOnly": {
|
||||
"type": "boolean",
|
||||
"description": "A value indicating whether or not the bot is a one-way notification only bot, as opposed to a conversational bot.",
|
||||
"default": false
|
||||
},
|
||||
"supportsFiles": {
|
||||
"type": "boolean",
|
||||
"description": "A value indicating whether the bot supports uploading/downloading of files.",
|
||||
"default": false
|
||||
},
|
||||
"supportsCalling": {
|
||||
"type": "boolean",
|
||||
"description": "A value indicating whether the bot supports audio calling.",
|
||||
"default": false
|
||||
},
|
||||
"supportsVideo": {
|
||||
"type": "boolean",
|
||||
"description": "A value indicating whether the bot supports video calling.",
|
||||
"default": false
|
||||
},
|
||||
"scopes": {
|
||||
"type": "array",
|
||||
"description": "Specifies whether the bot offers an experience in the context of a channel in a team, in a 1:1 or group chat, or in an experience scoped to an individual user alone. These options are non-exclusive.",
|
||||
"maxItems": 3,
|
||||
"items": {
|
||||
"enum": ["team", "personal", "groupChat", "groupchat"]
|
||||
}
|
||||
},
|
||||
"commandLists": {
|
||||
"type": "array",
|
||||
"maxItems": 3,
|
||||
"description": "The list of commands that the bot supplies, including their usage, description, and the scope for which the commands are valid. A separate command list should be used for each scope.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"scopes": {
|
||||
"type": "array",
|
||||
"description": "Specifies the scopes for which the command list is valid",
|
||||
"maxItems": 3,
|
||||
"items": {
|
||||
"enum": ["team", "personal", "groupChat", "groupchat"]
|
||||
}
|
||||
},
|
||||
"commands": {
|
||||
"type": "array",
|
||||
"maxItems": 10,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "The bot command name",
|
||||
"maxLength": 32
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "A simple text description or an example of the command syntax and its arguments.",
|
||||
"maxLength": 128
|
||||
}
|
||||
},
|
||||
"required": ["title", "description"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["scopes", "commands"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["botId", "scopes"]
|
||||
}
|
||||
},
|
||||
"connectors": {
|
||||
"type": "array",
|
||||
"description": "The set of Office365 connectors for this app. Currently only one connector per app is supported.",
|
||||
"maxItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"connectorId": {
|
||||
"type": "string",
|
||||
"description": "A unique identifier for the connector which matches its ID in the Connectors Developer Portal.",
|
||||
"maxLength": 64
|
||||
},
|
||||
"configurationUrl": {
|
||||
"$ref": "#/definitions/httpsUrl",
|
||||
"description": "The url to use for configuring the connector using the inline configuration experience."
|
||||
},
|
||||
"scopes": {
|
||||
"type": "array",
|
||||
"description": "Specifies whether the connector offers an experience in the context of a channel in a team, or an experience scoped to an individual user alone. Currently, only the team scope is supported.",
|
||||
"maxItems": 1,
|
||||
"items": {
|
||||
"enum": ["team"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["connectorId", "scopes"]
|
||||
}
|
||||
},
|
||||
"subscriptionOffer": {
|
||||
"type": "object",
|
||||
"description": "Subscription offer associated with this app.",
|
||||
"properties": {
|
||||
"offerId": {
|
||||
"type": "string",
|
||||
"description": "A unique identifier for the Commercial Marketplace Software as a Service Offer.",
|
||||
"maxLength": 2048
|
||||
}
|
||||
},
|
||||
"required": ["offerId"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"composeExtensions": {
|
||||
"type": "array",
|
||||
"description": "The set of compose extensions for this app. Currently only one compose extension per app is supported.",
|
||||
"maxItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"botId": {
|
||||
"$ref": "#/definitions/guid",
|
||||
"description": "The Microsoft App ID specified for the bot powering the compose extension in the Bot Framework portal (https://dev.botframework.com/bots)"
|
||||
},
|
||||
"canUpdateConfiguration": {
|
||||
"type": "boolean",
|
||||
"description": "A value indicating whether the configuration of a compose extension can be updated by the user.",
|
||||
"default": false
|
||||
},
|
||||
"commands": {
|
||||
"type": "array",
|
||||
"maxItems": 10,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Id of the command.",
|
||||
"maxLength": 64
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["query", "action"],
|
||||
"description": "Type of the command",
|
||||
"default": "query"
|
||||
},
|
||||
"context": {
|
||||
"type": "array",
|
||||
"maxItems": 3,
|
||||
"items": {
|
||||
"enum": ["compose", "commandBox", "message"]
|
||||
},
|
||||
"description": "Context where the command would apply",
|
||||
"default": ["compose", "commandBox"]
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Title of the command.",
|
||||
"maxLength": 32
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Description of the command.",
|
||||
"maxLength": 128
|
||||
},
|
||||
"initialRun": {
|
||||
"type": "boolean",
|
||||
"description": "A boolean value that indicates if the command should be run once initially with no parameter.",
|
||||
"default": false
|
||||
},
|
||||
"fetchTask": {
|
||||
"type": "boolean",
|
||||
"description": "A boolean value that indicates if it should fetch task module dynamically",
|
||||
"default": false
|
||||
},
|
||||
"parameters": {
|
||||
"type": "array",
|
||||
"maxItems": 5,
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the parameter.",
|
||||
"maxLength": 64
|
||||
},
|
||||
"inputType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"text",
|
||||
"textarea",
|
||||
"number",
|
||||
"date",
|
||||
"time",
|
||||
"toggle",
|
||||
"choiceset"
|
||||
],
|
||||
"description": "Type of the parameter",
|
||||
"default": "text"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Title of the parameter.",
|
||||
"maxLength": 32
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Description of the parameter.",
|
||||
"maxLength": 128
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"description": "Initial value for the parameter",
|
||||
"maxLength": 512
|
||||
},
|
||||
"choices": {
|
||||
"type": "array",
|
||||
"maxItems": 10,
|
||||
"description": "The choice options for the parameter",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Title of the choice",
|
||||
"maxLength": 128
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"description": "Value of the choice",
|
||||
"maxLength": 512
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": ["title", "value"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["name", "title"]
|
||||
}
|
||||
},
|
||||
"taskInfo": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Initial dialog title",
|
||||
"maxLength": 64
|
||||
},
|
||||
"width": {
|
||||
"$ref": "#/definitions/taskInfoDimension",
|
||||
"description": "Dialog width - either a number in pixels or default layout such as 'large', 'medium', or 'small'"
|
||||
},
|
||||
"height": {
|
||||
"$ref": "#/definitions/taskInfoDimension",
|
||||
"description": "Dialog height - either a number in pixels or default layout such as 'large', 'medium', or 'small'"
|
||||
},
|
||||
"url": {
|
||||
"$ref": "#/definitions/httpsUrl",
|
||||
"description": "Initial webview URL"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["id", "title"]
|
||||
}
|
||||
},
|
||||
"messageHandlers": {
|
||||
"type": "array",
|
||||
"maxItems": 5,
|
||||
"description": "A list of handlers that allow apps to be invoked when certain conditions are met",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["link"],
|
||||
"description": "Type of the message handler"
|
||||
},
|
||||
"value": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"domains": {
|
||||
"type": "array",
|
||||
"description": "A list of domains that the link message handler can register for, and when they are matched the app will be invoked",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"maxLength": 2048
|
||||
}
|
||||
},
|
||||
"supportsAnonymizedPayloads": {
|
||||
"type": "boolean",
|
||||
"description": "A boolean that indicates whether the app's link message handler supports anonymous invoke flow.",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["type", "value"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["botId", "commands"]
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"type": "array",
|
||||
"description": "Specifies the permissions the app requests from users.",
|
||||
"maxItems": 2,
|
||||
"items": {
|
||||
"enum": ["identity", "messageTeamMembers"]
|
||||
}
|
||||
},
|
||||
"devicePermissions": {
|
||||
"type": "array",
|
||||
"description": "Specify the native features on a user's device that your app may request access to.",
|
||||
"maxItems": 5,
|
||||
"items": {
|
||||
"enum": ["geolocation", "media", "notifications", "midi", "openExternal"]
|
||||
}
|
||||
},
|
||||
"validDomains": {
|
||||
"type": "array",
|
||||
"description": "A list of valid domains from which the tabs expect to load any content. Domain listings can include wildcards, for example `*.example.com`. If your tab configuration or content UI needs to navigate to any other domain besides the one use for tab configuration, that domain must be specified here.",
|
||||
"maxItems": 16,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"maxLength": 2048
|
||||
}
|
||||
},
|
||||
"webApplicationInfo": {
|
||||
"type": "object",
|
||||
"description": "Specify your AAD App ID and Graph information to help users seamlessly sign into your AAD app.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/guid",
|
||||
"description": "AAD application id of the app. This id must be a GUID."
|
||||
},
|
||||
"resource": {
|
||||
"type": "string",
|
||||
"description": "Resource url of app for acquiring auth token for SSO.",
|
||||
"maxLength": 2048
|
||||
}
|
||||
},
|
||||
"required": ["id"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"graphConnector": {
|
||||
"type": "object",
|
||||
"description": "Specify the app's Graph connector configuration. If this is present then webApplicationInfo.id must also be specified.",
|
||||
"properties": {
|
||||
"notificationUrl": {
|
||||
"$ref": "#/definitions/httpsUrl",
|
||||
"description": "The url where Graph-connector notifications for the application should be sent."
|
||||
}
|
||||
},
|
||||
"required": ["notificationUrl"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"showLoadingIndicator": {
|
||||
"type": "boolean",
|
||||
"description": "A value indicating whether or not show loading indicator when app/tab is loading",
|
||||
"default": false
|
||||
},
|
||||
"isFullScreen": {
|
||||
"type": "boolean",
|
||||
"description": "A value indicating whether a personal app is rendered without a tab header-bar",
|
||||
"default": false
|
||||
},
|
||||
"activities": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"activityTypes": {
|
||||
"type": "array",
|
||||
"description": "Specify the types of activites that your app can post to a users activity feed",
|
||||
"maxItems": 128,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"maxLength": 32
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
},
|
||||
"templateText": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
}
|
||||
},
|
||||
"required": ["type", "description", "templateText"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"configurableProperties": {
|
||||
"type": "array",
|
||||
"description": "A list of tenant configured properties for an app",
|
||||
"maxItems": 9,
|
||||
"items": {
|
||||
"enum": [
|
||||
"name",
|
||||
"shortDescription",
|
||||
"longDescription",
|
||||
"smallImageUrl",
|
||||
"largeImageUrl",
|
||||
"accentColor",
|
||||
"developerUrl",
|
||||
"privacyUrl",
|
||||
"termsOfUseUrl"
|
||||
]
|
||||
}
|
||||
},
|
||||
"supportedChannelTypes": {
|
||||
"type": "array",
|
||||
"description": "List of 'non-standard' channel types that the app supports. Note: Channels of standard type are supported by default if the app supports team scope.",
|
||||
"maxItems": 2,
|
||||
"items": {
|
||||
"enum": ["sharedChannels", "privateChannels"]
|
||||
}
|
||||
},
|
||||
"defaultBlockUntilAdminAction": {
|
||||
"type": "boolean",
|
||||
"description": "A value indicating whether an app is blocked by default until admin allows it",
|
||||
"default": false
|
||||
},
|
||||
"publisherDocsUrl": {
|
||||
"$ref": "#/definitions/httpsUrl",
|
||||
"description": "The url to the page that provides additional app information for the admins"
|
||||
},
|
||||
"defaultInstallScope": {
|
||||
"type": "string",
|
||||
"enum": ["personal", "team", "groupChat", "groupchat", "meetings"],
|
||||
"description": "The install scope defined for this app by default. This will be the option displayed on the button when a user tries to add the app"
|
||||
},
|
||||
"defaultGroupCapability": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"team": {
|
||||
"type": "string",
|
||||
"enum": ["tab", "bot", "connector"],
|
||||
"description": "When the install scope selected is Team, this field specifies the default capability available"
|
||||
},
|
||||
"groupchat": {
|
||||
"type": "string",
|
||||
"enum": ["tab", "bot", "connector"],
|
||||
"description": "When the install scope selected is GroupChat, this field specifies the default capability available"
|
||||
},
|
||||
"meetings": {
|
||||
"type": "string",
|
||||
"enum": ["tab", "bot", "connector"],
|
||||
"description": "When the install scope selected is Meetings, this field specifies the default capability available"
|
||||
}
|
||||
},
|
||||
"description": "When a group install scope is selected, this will define the default capability when the user installs the app",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"meetingExtensionDefinition": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scenes": {
|
||||
"description": "Meeting supported scenes.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/guid",
|
||||
"description": "A unique identifier for this scene. This id must be a GUID."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Scene name.",
|
||||
"maxLength": 128
|
||||
},
|
||||
"file": {
|
||||
"$ref": "#/definitions/relativePath",
|
||||
"description": "A relative file path to a scene metadata json file."
|
||||
},
|
||||
"preview": {
|
||||
"$ref": "#/definitions/relativePath",
|
||||
"description": "A relative file path to a scene PNG preview icon."
|
||||
},
|
||||
"maxAudience": {
|
||||
"type": "integer",
|
||||
"description": "Maximum audiences supported in scene.",
|
||||
"maximum": 50
|
||||
},
|
||||
"seatsReservedForOrganizersOrPresenters": {
|
||||
"type": "integer",
|
||||
"description": "Number of seats reserved for organizers or presenters.",
|
||||
"maximum": 50
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"file",
|
||||
"preview",
|
||||
"maxAudience",
|
||||
"seatsReservedForOrganizersOrPresenters"
|
||||
]
|
||||
},
|
||||
"maxItems": 5,
|
||||
"type": "array",
|
||||
"uniqueItems": true
|
||||
},
|
||||
"supportsStreaming": {
|
||||
"type": "boolean",
|
||||
"description": "A boolean value indicating whether this app can stream the meeting's audio video content to an RTMP endpoint.",
|
||||
"default": false
|
||||
},
|
||||
"supportsAnonymousGuestUsers": {
|
||||
"type": "boolean",
|
||||
"description": "A boolean value indicating whether this app allows management by anonymous users.",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"description": "Specify meeting extension definition.",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"authorization": {
|
||||
"type": "object",
|
||||
"description": "Specify and consolidates authorization related information for the App.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"permissions": {
|
||||
"type": "object",
|
||||
"description": "List of permissions that the app needs to function.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"resourceSpecific": {
|
||||
"description": "Permissions that guard data access on a resource instance level.",
|
||||
"maxItems": 16,
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The name of the resource-specific permission.",
|
||||
"maxLength": 128
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["Application", "Delegated"],
|
||||
"description": "The type of the resource-specific permission."
|
||||
}
|
||||
},
|
||||
"required": ["name", "type"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"manifestVersion",
|
||||
"version",
|
||||
"id",
|
||||
"developer",
|
||||
"name",
|
||||
"description",
|
||||
"icons",
|
||||
"accentColor"
|
||||
],
|
||||
"definitions": {
|
||||
"relativePath": {
|
||||
"type": "string",
|
||||
"maxLength": 2048
|
||||
},
|
||||
"httpsUrl": {
|
||||
"type": "string",
|
||||
"maxLength": 2048,
|
||||
"pattern": "^[Hh][Tt][Tt][Pp][Ss]?://"
|
||||
},
|
||||
"semver": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^([0-9]|[1-9]+[0-9]*)\\.([0-9]|[1-9]+[0-9]*)\\.([0-9]|[1-9]+[0-9]*)$"
|
||||
},
|
||||
"hexColor": {
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9a-fA-F]{6}$"
|
||||
},
|
||||
"guid": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$"
|
||||
},
|
||||
"languageTag": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9]{1,8}(-[A-Za-z0-9]{1,8}){0,2}$"
|
||||
},
|
||||
"taskInfoDimension": {
|
||||
"type": "string",
|
||||
"pattern": "^((([0-9]*\\.)?[0-9]+)|[lL][aA][rR][gG][eE]|[mM][eE][dD][iI][uU][mM]|[sS][mM][aA][lL][lL])$",
|
||||
"maxLength": 16
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
import { mock } from 'vitest-mock-extended';
|
||||
import type { GlobalConfig } from '@n8n/config';
|
||||
import type { InstanceSettings } from 'n8n-core';
|
||||
|
||||
import { JwtService } from '@/services/jwt.service';
|
||||
import type { UrlService } from '@/services/url.service';
|
||||
|
||||
import { TeamsArmTemplateService } from '../teams-arm-template.service';
|
||||
|
||||
const PROJECT_ID = 'project-1';
|
||||
const AGENT_ID = 'agent-1';
|
||||
const CREDENTIAL_ID = 'cred-1';
|
||||
|
||||
const jwtService = new JwtService(
|
||||
mock<InstanceSettings>({ encryptionKey: 'test-encryption-key' }),
|
||||
mock<GlobalConfig>({ userManagement: { jwtSecret: 'test-jwt-secret' } }),
|
||||
);
|
||||
const urlService = mock<UrlService>();
|
||||
urlService.getWebhookBaseUrl.mockReturnValue('https://n8n.example.com/');
|
||||
|
||||
const options = {
|
||||
agentName: 'Support Bot',
|
||||
agentId: AGENT_ID,
|
||||
msaAppId: '11111111-2222-3333-4444-555555555555',
|
||||
msaAppTenantId: '99999999-8888-7777-6666-555555555555',
|
||||
messagingEndpoint:
|
||||
'https://n8n.example.com/rest/projects/project-1/agents/v2/agent-1/webhooks/teams',
|
||||
};
|
||||
|
||||
describe('TeamsArmTemplateService', () => {
|
||||
const service = new TeamsArmTemplateService(jwtService, urlService);
|
||||
|
||||
const resourcesOf = (template: Record<string, unknown>) =>
|
||||
template.resources as Array<Record<string, unknown>>;
|
||||
const parametersOf = (template: Record<string, unknown>) =>
|
||||
template.parameters as Record<string, { defaultValue: unknown }>;
|
||||
|
||||
describe('buildTemplate', () => {
|
||||
it('labels the bot with the name chosen for Teams when there is one', () => {
|
||||
const [bot] = resourcesOf(service.buildTemplate({ ...options, displayName: 'Helpdesk' }));
|
||||
|
||||
expect((bot.properties as { displayName: string }).displayName).toBe('Helpdesk');
|
||||
});
|
||||
|
||||
it('falls back to the agent name when no Teams name is set', () => {
|
||||
const [bot] = resourcesOf(service.buildTemplate(options));
|
||||
|
||||
expect((bot.properties as { displayName: string }).displayName).toBe('Support Bot');
|
||||
});
|
||||
|
||||
it('keeps the resource name on the agent, so a Teams rename cannot move it', () => {
|
||||
const withName = parametersOf(service.buildTemplate({ ...options, displayName: 'Helpdesk' }));
|
||||
const without = parametersOf(service.buildTemplate(options));
|
||||
|
||||
// The resource name cannot change once the bot exists: a second
|
||||
// deployment under a new name creates a second bot instead.
|
||||
expect(withName.botName.defaultValue).toBe(without.botName.defaultValue);
|
||||
});
|
||||
|
||||
it('refuses a plain-http endpoint, which Azure will not accept', () => {
|
||||
expect(() =>
|
||||
service.buildTemplate({ ...options, messagingEndpoint: 'http://n8n.example.com/hook' }),
|
||||
).toThrow(/HTTPS/);
|
||||
});
|
||||
|
||||
it('creates the bot resource and enables the Teams channel', () => {
|
||||
const types = resourcesOf(service.buildTemplate(options)).map((r) => r.type);
|
||||
|
||||
expect(types).toEqual([
|
||||
'Microsoft.BotService/botServices',
|
||||
'Microsoft.BotService/botServices/channels',
|
||||
]);
|
||||
});
|
||||
|
||||
it('enables the Teams channel rather than only declaring it', () => {
|
||||
const channel = resourcesOf(service.buildTemplate(options))[1];
|
||||
|
||||
expect(channel.properties).toEqual({
|
||||
channelName: 'MsTeamsChannel',
|
||||
properties: { isEnabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('creates the channel only after the bot exists', () => {
|
||||
const channel = resourcesOf(service.buildTemplate(options))[1];
|
||||
|
||||
expect(channel.dependsOn).toEqual([
|
||||
"[resourceId('Microsoft.BotService/botServices', parameters('botName'))]",
|
||||
]);
|
||||
});
|
||||
|
||||
it('pre-fills the values the user would otherwise copy, because the portal reads defaults', () => {
|
||||
const parameters = parametersOf(service.buildTemplate(options));
|
||||
|
||||
expect(parameters.msaAppId.defaultValue).toBe(options.msaAppId);
|
||||
expect(parameters.msaAppTenantId.defaultValue).toBe(options.msaAppTenantId);
|
||||
expect(parameters.messagingEndpoint.defaultValue).toBe(options.messagingEndpoint);
|
||||
});
|
||||
|
||||
it('registers the bot single-tenant, matching the credential', () => {
|
||||
const bot = resourcesOf(service.buildTemplate(options))[0];
|
||||
|
||||
expect((bot.properties as Record<string, unknown>).msaAppType).toBe('SingleTenant');
|
||||
});
|
||||
|
||||
it('names the bot after the agent, with a digest because Azure names are global', () => {
|
||||
const mine = parametersOf(service.buildTemplate(options)).botName.defaultValue;
|
||||
const other = parametersOf(service.buildTemplate({ ...options, agentId: 'agent-2' })).botName
|
||||
.defaultValue;
|
||||
|
||||
expect(mine).toMatch(/^support-bot-[0-9a-f]{8}$/);
|
||||
expect(other).not.toBe(mine);
|
||||
});
|
||||
|
||||
it('falls back to a usable name when the agent name cannot start one', () => {
|
||||
const name = parametersOf(service.buildTemplate({ ...options, agentName: '123 🎉' })).botName
|
||||
.defaultValue;
|
||||
|
||||
expect(name).toMatch(/^n8n-agent-[0-9a-f]{8}$/);
|
||||
});
|
||||
|
||||
it('omits the Entra defaults when unknown, so the portal marks them required', () => {
|
||||
const parameters = parametersOf(
|
||||
service.buildTemplate({ ...options, msaAppId: '', msaAppTenantId: '' }),
|
||||
);
|
||||
|
||||
// An empty default looks filled in, and the deployment then fails at the
|
||||
// end with "Microsoft App ID is required".
|
||||
expect(parameters.msaAppId).not.toHaveProperty('defaultValue');
|
||||
expect(parameters.msaAppTenantId).not.toHaveProperty('defaultValue');
|
||||
expect(parameters.messagingEndpoint.defaultValue).toBe(options.messagingEndpoint);
|
||||
});
|
||||
|
||||
it('carries no credential secret', () => {
|
||||
expect(JSON.stringify(service.buildTemplate(options))).not.toMatch(/secret/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDeployUrl', () => {
|
||||
it('points the Azure portal at the template URL', () => {
|
||||
const url = service.buildDeployUrl(PROJECT_ID, AGENT_ID, CREDENTIAL_ID);
|
||||
|
||||
expect(url.startsWith('https://portal.azure.com/#create/Microsoft.Template/uri/')).toBe(true);
|
||||
const encoded = url.slice('https://portal.azure.com/#create/Microsoft.Template/uri/'.length);
|
||||
expect(decodeURIComponent(encoded)).toContain(
|
||||
'/rest/projects/project-1/agents/v2/agent-1/integrations/teams/arm-template?token=',
|
||||
);
|
||||
});
|
||||
|
||||
it('carries no credential secret in the URL', () => {
|
||||
expect(service.buildDeployUrl(PROJECT_ID, AGENT_ID, CREDENTIAL_ID)).not.toMatch(/secret/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyToken', () => {
|
||||
const tokenFrom = (url: string) => new URL(url).searchParams.get('token') ?? '';
|
||||
|
||||
it('accepts the token it just issued', () => {
|
||||
const token = tokenFrom(service.buildTemplateUrl(PROJECT_ID, AGENT_ID, CREDENTIAL_ID));
|
||||
|
||||
expect(service.verifyToken(PROJECT_ID, AGENT_ID, CREDENTIAL_ID, token)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an expired token', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const token = tokenFrom(service.buildTemplateUrl(PROJECT_ID, AGENT_ID, CREDENTIAL_ID));
|
||||
expect(service.verifyToken(PROJECT_ID, AGENT_ID, CREDENTIAL_ID, token)).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(16 * 60 * 1000);
|
||||
|
||||
expect(service.verifyToken(PROJECT_ID, AGENT_ID, CREDENTIAL_ID, token)).toBe(false);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a tampered signature', () => {
|
||||
const [header, payload, signature] = tokenFrom(
|
||||
service.buildTemplateUrl(PROJECT_ID, AGENT_ID, CREDENTIAL_ID),
|
||||
).split('.');
|
||||
const flipped = signature.startsWith('a')
|
||||
? `b${signature.slice(1)}`
|
||||
: `a${signature.slice(1)}`;
|
||||
|
||||
expect(
|
||||
service.verifyToken(PROJECT_ID, AGENT_ID, CREDENTIAL_ID, `${header}.${payload}.${flipped}`),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a token whose expiry was pushed out', () => {
|
||||
const [header, payload, signature] = tokenFrom(
|
||||
service.buildTemplateUrl(PROJECT_ID, AGENT_ID, CREDENTIAL_ID),
|
||||
).split('.');
|
||||
const claims = JSON.parse(Buffer.from(payload, 'base64url').toString());
|
||||
const extended = Buffer.from(
|
||||
JSON.stringify({ ...claims, exp: claims.exp + 86_400 }),
|
||||
).toString('base64url');
|
||||
|
||||
// The expiry is inside the signed payload, so pushing it out invalidates
|
||||
// the signature that still travels with it.
|
||||
expect(
|
||||
service.verifyToken(
|
||||
PROJECT_ID,
|
||||
AGENT_ID,
|
||||
CREDENTIAL_ID,
|
||||
`${header}.${extended}.${signature}`,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an n8n token minted for anything other than this route', () => {
|
||||
// The instance signs tokens for other purposes with the same secret.
|
||||
const token = jwtService.sign(
|
||||
{ projectId: PROJECT_ID, agentId: AGENT_ID, credentialId: CREDENTIAL_ID },
|
||||
{ expiresIn: '15m' },
|
||||
);
|
||||
|
||||
expect(service.verifyToken(PROJECT_ID, AGENT_ID, CREDENTIAL_ID, token)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a token minted for a different agent', () => {
|
||||
const token = tokenFrom(service.buildTemplateUrl(PROJECT_ID, 'other-agent', CREDENTIAL_ID));
|
||||
|
||||
expect(service.verifyToken(PROJECT_ID, AGENT_ID, CREDENTIAL_ID, token)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a token minted for a different credential, so it cannot be swapped', () => {
|
||||
const token = tokenFrom(service.buildTemplateUrl(PROJECT_ID, AGENT_ID, 'other-cred'));
|
||||
|
||||
expect(service.verifyToken(PROJECT_ID, AGENT_ID, CREDENTIAL_ID, token)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a token minted for a different project', () => {
|
||||
const token = tokenFrom(service.buildTemplateUrl('other-project', AGENT_ID, CREDENTIAL_ID));
|
||||
|
||||
expect(service.verifyToken(PROJECT_ID, AGENT_ID, CREDENTIAL_ID, token)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['', 'empty'],
|
||||
['nonsense', 'malformed'],
|
||||
['123.456.', 'signature-less'],
|
||||
])('rejects a %s token (%s)', (token) => {
|
||||
expect(service.verifyToken(PROJECT_ID, AGENT_ID, CREDENTIAL_ID, token)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
import { mock } from 'vitest-mock-extended';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { User } from '@n8n/db';
|
||||
import type { OutboundHttp } from '@n8n/backend-network';
|
||||
|
||||
import type { CredentialsService } from '@/credentials/credentials.service';
|
||||
|
||||
import { AgentCredentialLookupService } from '../../../agent-credential-lookup.service';
|
||||
import { TeamsCredentialCheckService } from '../teams-credential-check.service';
|
||||
|
||||
const PROJECT_ID = 'project-1';
|
||||
const CREDENTIAL_ID = 'cred-1';
|
||||
const CLIENT_ID = '11111111-2222-3333-4444-555555555555';
|
||||
const TENANT_ID = '99999999-8888-7777-6666-555555555555';
|
||||
|
||||
const user = mock<User>({ id: 'user-1' });
|
||||
|
||||
const workingCredential = {
|
||||
tenantId: TENANT_ID,
|
||||
clientId: CLIENT_ID,
|
||||
clientSecret: 'a-secret',
|
||||
};
|
||||
|
||||
describe('TeamsCredentialCheckService', () => {
|
||||
let credentialsService: ReturnType<typeof mock<CredentialsService>>;
|
||||
let request: ReturnType<typeof vi.fn>;
|
||||
let service: TeamsCredentialCheckService;
|
||||
|
||||
const withCredential = (
|
||||
data: Record<string, string>,
|
||||
type = 'microsoftEntraServicePrincipalApi',
|
||||
) => {
|
||||
credentialsService.findAllCredentialIdsForProject.mockResolvedValue([
|
||||
mock({ id: CREDENTIAL_ID, type }),
|
||||
]);
|
||||
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([
|
||||
mock({ id: CREDENTIAL_ID }),
|
||||
]);
|
||||
credentialsService.decrypt.mockResolvedValue(data);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
credentialsService = mock<CredentialsService>();
|
||||
credentialsService.findAllCredentialIdsForProject.mockResolvedValue([]);
|
||||
credentialsService.findAllGlobalCredentialIds.mockResolvedValue([]);
|
||||
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([]);
|
||||
request = vi.fn();
|
||||
const outboundHttp = mock<OutboundHttp>();
|
||||
outboundHttp.requests.mockReturnValue(mock({ request }) as never);
|
||||
service = new TeamsCredentialCheckService(
|
||||
new AgentCredentialLookupService(credentialsService),
|
||||
outboundHttp,
|
||||
mock<Logger>(),
|
||||
);
|
||||
});
|
||||
|
||||
it('passes when Microsoft issues a token', async () => {
|
||||
withCredential(workingCredential);
|
||||
request.mockResolvedValue({ statusCode: 200, body: { access_token: 'a-token' } });
|
||||
|
||||
expect(await service.check(user, PROJECT_ID, CREDENTIAL_ID)).toEqual({ status: 'ok' });
|
||||
});
|
||||
|
||||
it('asks for a Bot Framework token, which is what the channel needs', async () => {
|
||||
withCredential(workingCredential);
|
||||
request.mockResolvedValue({ statusCode: 200, body: { access_token: 'a-token' } });
|
||||
|
||||
await service.check(user, PROJECT_ID, CREDENTIAL_ID);
|
||||
|
||||
const [options] = request.mock.calls[0] as [{ url: string; body: string }];
|
||||
expect(options.url).toContain(TENANT_ID);
|
||||
expect(options.body).toContain('scope=https%3A%2F%2Fapi.botframework.com%2F.default');
|
||||
expect(options.body).toContain('grant_type=client_credentials');
|
||||
});
|
||||
|
||||
it('fails when Microsoft refuses the details', async () => {
|
||||
withCredential(workingCredential);
|
||||
request.mockResolvedValue({ statusCode: 401, body: { error: 'invalid_client' } });
|
||||
|
||||
expect(await service.check(user, PROJECT_ID, CREDENTIAL_ID)).toEqual({
|
||||
status: 'failed',
|
||||
reason: 'rejected',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails when a 200 carries no token, rather than reporting success', async () => {
|
||||
withCredential(workingCredential);
|
||||
request.mockResolvedValue({ statusCode: 200, body: {} });
|
||||
|
||||
expect(await service.check(user, PROJECT_ID, CREDENTIAL_ID)).toMatchObject({
|
||||
status: 'failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('reports being unable to reach Microsoft separately from a refusal', async () => {
|
||||
withCredential(workingCredential);
|
||||
request.mockRejectedValue(new Error('ENOTFOUND'));
|
||||
|
||||
expect(await service.check(user, PROJECT_ID, CREDENTIAL_ID)).toEqual({
|
||||
status: 'failed',
|
||||
reason: 'unreachable',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects certificate mode without asking Microsoft, since it stores no secret', async () => {
|
||||
withCredential({ ...workingCredential, authentication: 'certificate' });
|
||||
|
||||
expect(await service.check(user, PROJECT_ID, CREDENTIAL_ID)).toEqual({
|
||||
status: 'failed',
|
||||
reason: 'certificate',
|
||||
});
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['tenant', { clientId: CLIENT_ID, clientSecret: 'a-secret' }],
|
||||
['client ID', { tenantId: TENANT_ID, clientSecret: 'a-secret' }],
|
||||
['client secret', { tenantId: TENANT_ID, clientId: CLIENT_ID }],
|
||||
])('reports a credential missing its %s without asking Microsoft', async (_label, data) => {
|
||||
withCredential(data);
|
||||
|
||||
expect(await service.check(user, PROJECT_ID, CREDENTIAL_ID)).toEqual({
|
||||
status: 'failed',
|
||||
reason: 'incomplete',
|
||||
});
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a credential of the wrong type', async () => {
|
||||
withCredential(workingCredential, 'slackApi');
|
||||
|
||||
expect(await service.check(user, PROJECT_ID, CREDENTIAL_ID)).toEqual({
|
||||
status: 'failed',
|
||||
reason: 'incomplete',
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a credential outside the project', async () => {
|
||||
expect(await service.check(user, PROJECT_ID, CREDENTIAL_ID)).toEqual({
|
||||
status: 'failed',
|
||||
reason: 'incomplete',
|
||||
});
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a non-global Microsoft cloud, which the channel refuses too', async () => {
|
||||
withCredential({ ...workingCredential, graphApiBaseUrl: 'https://graph.microsoft.us/' });
|
||||
|
||||
expect(await service.check(user, PROJECT_ID, CREDENTIAL_ID)).toEqual({
|
||||
status: 'failed',
|
||||
reason: 'cloud',
|
||||
});
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts the global cloud spelled with a trailing slash', async () => {
|
||||
withCredential({ ...workingCredential, graphApiBaseUrl: 'https://graph.microsoft.com/' });
|
||||
request.mockResolvedValue({ statusCode: 200, body: { access_token: 'a-token' } });
|
||||
|
||||
expect(await service.check(user, PROJECT_ID, CREDENTIAL_ID)).toEqual({ status: 'ok' });
|
||||
});
|
||||
|
||||
it('trims the credential fields, as the channel does before using them', async () => {
|
||||
withCredential({
|
||||
tenantId: ` ${TENANT_ID} `,
|
||||
clientId: ` ${CLIENT_ID} `,
|
||||
clientSecret: ' a-secret ',
|
||||
});
|
||||
request.mockResolvedValue({ statusCode: 200, body: { access_token: 'a-token' } });
|
||||
|
||||
expect(await service.check(user, PROJECT_ID, CREDENTIAL_ID)).toEqual({ status: 'ok' });
|
||||
|
||||
const [options] = request.mock.calls[0] as [{ url: string; body: string }];
|
||||
expect(options.url).toContain(TENANT_ID);
|
||||
expect(options.body).toContain(`client_id=${CLIENT_ID}`);
|
||||
});
|
||||
|
||||
it('bounds the wait, so a stalled Microsoft does not hang the step', async () => {
|
||||
withCredential(workingCredential);
|
||||
request.mockResolvedValue({ statusCode: 200, body: { access_token: 'a-token' } });
|
||||
|
||||
await service.check(user, PROJECT_ID, CREDENTIAL_ID);
|
||||
|
||||
const [options] = request.mock.calls[0] as [{ timeout?: number }];
|
||||
expect(options.timeout).toBeGreaterThan(0);
|
||||
expect(options.timeout).toBeLessThanOrEqual(60_000);
|
||||
});
|
||||
|
||||
it('refuses a credential the caller may not use, without asking Microsoft', async () => {
|
||||
withCredential(workingCredential);
|
||||
// Visible to the project, but not shared with this user.
|
||||
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([]);
|
||||
|
||||
expect(await service.check(user, PROJECT_ID, CREDENTIAL_ID)).toEqual({
|
||||
status: 'failed',
|
||||
reason: 'incomplete',
|
||||
});
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
expect(credentialsService.decrypt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never returns the client secret', async () => {
|
||||
withCredential(workingCredential);
|
||||
request.mockResolvedValue({ statusCode: 200, body: { access_token: 'a-token' } });
|
||||
|
||||
expect(JSON.stringify(await service.check(user, PROJECT_ID, CREDENTIAL_ID))).not.toContain(
|
||||
'a-secret',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,430 @@
|
|||
import Ajv from 'ajv-draft-04';
|
||||
import { unzipSync } from 'fflate';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { inflateSync } from 'node:zlib';
|
||||
|
||||
import { TeamsManifestService } from '../teams-manifest.service';
|
||||
|
||||
interface Rgba {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
a: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal reader for the 8-bit RGBA, non-interlaced PNGs this service bundles.
|
||||
* Written out rather than pulling in a decoder and its types for one assertion.
|
||||
*/
|
||||
function readRgbaPixels(png: Buffer): Rgba[] {
|
||||
const width = png.readUInt32BE(16);
|
||||
const height = png.readUInt32BE(20);
|
||||
expect([png.readUInt8(24), png.readUInt8(25), png.readUInt8(28)]).toEqual([8, 6, 0]);
|
||||
|
||||
const idat: Buffer[] = [];
|
||||
for (let offset = 8; offset + 8 <= png.length; ) {
|
||||
const length = png.readUInt32BE(offset);
|
||||
const type = png.toString('ascii', offset + 4, offset + 8);
|
||||
if (type === 'IDAT') idat.push(png.subarray(offset + 8, offset + 8 + length));
|
||||
offset += length + 12;
|
||||
}
|
||||
|
||||
const raw = inflateSync(Buffer.concat(idat));
|
||||
const bpp = 4;
|
||||
const stride = width * bpp;
|
||||
const out = Buffer.alloc(height * stride);
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
const filter = raw[y * (stride + 1)];
|
||||
const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
|
||||
for (let x = 0; x < stride; x++) {
|
||||
const left = x >= bpp ? out[y * stride + x - bpp] : 0;
|
||||
const up = y > 0 ? out[(y - 1) * stride + x] : 0;
|
||||
const upLeft = y > 0 && x >= bpp ? out[(y - 1) * stride + x - bpp] : 0;
|
||||
let value = line[x];
|
||||
if (filter === 1) value += left;
|
||||
else if (filter === 2) value += up;
|
||||
else if (filter === 3) value += Math.floor((left + up) / 2);
|
||||
else if (filter === 4) {
|
||||
const p = left + up - upLeft;
|
||||
const [dL, dU, dUL] = [Math.abs(p - left), Math.abs(p - up), Math.abs(p - upLeft)];
|
||||
value += dL <= dU && dL <= dUL ? left : dU <= dUL ? up : upLeft;
|
||||
}
|
||||
out[y * stride + x] = value & 0xff;
|
||||
}
|
||||
}
|
||||
|
||||
const pixels: Rgba[] = [];
|
||||
for (let i = 0; i < out.length; i += bpp) {
|
||||
pixels.push({ r: out[i], g: out[i + 1], b: out[i + 2], a: out[i + 3] });
|
||||
}
|
||||
return pixels;
|
||||
}
|
||||
|
||||
const schema: object = JSON.parse(
|
||||
readFileSync(join(__dirname, 'fixtures', 'MicrosoftTeams.schema.v1.16.json'), 'utf8'),
|
||||
);
|
||||
|
||||
const BOT_ID = '11111111-2222-3333-4444-555555555555';
|
||||
|
||||
const options = (
|
||||
overrides: Partial<Parameters<TeamsManifestService['buildManifest']>[0]> = {},
|
||||
) => ({
|
||||
agentName: 'Support Bot',
|
||||
agentId: 'agent-abc',
|
||||
botId: BOT_ID,
|
||||
versionAt: new Date('2026-09-15T10:00:00.000Z'),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('TeamsManifestService', () => {
|
||||
const service = new TeamsManifestService();
|
||||
|
||||
describe('buildManifest', () => {
|
||||
it('produces a manifest that satisfies the published Teams schema', () => {
|
||||
const ajv = new Ajv({ strict: false });
|
||||
const validate = ajv.compile(schema);
|
||||
|
||||
const valid = validate(service.buildManifest(options()));
|
||||
|
||||
expect(validate.errors).toBeNull();
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the credential client ID as the bot ID', () => {
|
||||
expect(service.buildManifest(options()).bots[0].botId).toBe(BOT_ID);
|
||||
});
|
||||
|
||||
it('scopes the bot to direct messages when nothing else is turned on', () => {
|
||||
expect(service.buildManifest(options()).bots[0].scopes).toEqual(['personal']);
|
||||
});
|
||||
|
||||
describe('availability', () => {
|
||||
it.each([
|
||||
['team channels', { teamChannels: true }, ['personal', 'team']],
|
||||
['group chats', { groupChats: true }, ['personal', 'groupChat']],
|
||||
['both', { teamChannels: true, groupChats: true }, ['personal', 'team', 'groupChat']],
|
||||
])('adds the %s scope', (_label, availability, expected) => {
|
||||
expect(service.buildManifest(options({ settings: availability })).bots[0].scopes).toEqual(
|
||||
expected,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps direct chat on whatever else is turned off', () => {
|
||||
expect(
|
||||
service.buildManifest(options({ settings: { teamChannels: false } })).bots[0].scopes,
|
||||
).toEqual(['personal']);
|
||||
});
|
||||
|
||||
it('emits no read permissions by default', () => {
|
||||
const manifest = service.buildManifest(options());
|
||||
|
||||
expect(manifest.authorization).toBeUndefined();
|
||||
expect(manifest.webApplicationInfo).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'channel messages',
|
||||
{ teamChannels: true, readAllChannelMessages: true },
|
||||
'ChannelMessage.Read.Group',
|
||||
],
|
||||
[
|
||||
'group messages',
|
||||
{ groupChats: true, readAllGroupMessages: true },
|
||||
'ChatMessage.Read.Chat',
|
||||
],
|
||||
])('asks to read all %s', (_label, availability, permission) => {
|
||||
const manifest = service.buildManifest(options({ settings: availability }));
|
||||
|
||||
expect(manifest.authorization?.permissions.resourceSpecific).toEqual([
|
||||
{ name: permission, type: 'Application' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('pairs a read permission with webApplicationInfo, which Teams requires', () => {
|
||||
const manifest = service.buildManifest(
|
||||
options({ settings: { teamChannels: true, readAllChannelMessages: true } }),
|
||||
);
|
||||
|
||||
expect(manifest.webApplicationInfo).toEqual({ id: BOT_ID });
|
||||
});
|
||||
|
||||
it('drops a read permission whose surface is off, so it cannot read as in effect', () => {
|
||||
const manifest = service.buildManifest(
|
||||
options({ settings: { teamChannels: false, readAllChannelMessages: true } }),
|
||||
);
|
||||
|
||||
expect(manifest.authorization).toBeUndefined();
|
||||
expect(manifest.bots[0].scopes).toEqual(['personal']);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['nothing', {}],
|
||||
['every scope', { teamChannels: true, groupChats: true }],
|
||||
[
|
||||
'every scope and read permission',
|
||||
{
|
||||
teamChannels: true,
|
||||
groupChats: true,
|
||||
readAllChannelMessages: true,
|
||||
readAllGroupMessages: true,
|
||||
},
|
||||
],
|
||||
])('still satisfies the published schema with %s turned on', (_label, availability) => {
|
||||
const ajv = new Ajv({ strict: false });
|
||||
|
||||
expect(
|
||||
ajv.validate(schema, service.buildManifest(options({ settings: availability }))),
|
||||
).toBe(true);
|
||||
expect(ajv.errors).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('derives a stable GUID id for an agent, and a different one per agent', () => {
|
||||
const first = service.buildManifest(options()).id;
|
||||
const second = service.buildManifest(options()).id;
|
||||
const other = service.buildManifest(options({ agentId: 'agent-xyz' })).id;
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(other).not.toBe(first);
|
||||
expect(first).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
|
||||
});
|
||||
|
||||
it('raises the version when the agent changes, so Teams accepts the re-upload', () => {
|
||||
const earlier = service.buildManifest(options()).version;
|
||||
const later = service.buildManifest(
|
||||
options({ versionAt: new Date('2026-09-15T10:00:05.000Z') }),
|
||||
).version;
|
||||
|
||||
expect(later).not.toBe(earlier);
|
||||
expect([earlier, later].sort()[1]).toBe(later);
|
||||
});
|
||||
|
||||
it('separates two stamps inside the same second', () => {
|
||||
const at = new Date('2026-09-15T10:00:00.000Z');
|
||||
const barelyLater = new Date('2026-09-15T10:00:00.010Z');
|
||||
|
||||
expect(service.buildManifest(options({ versionAt: at })).version).not.toBe(
|
||||
service.buildManifest(options({ versionAt: barelyLater })).version,
|
||||
);
|
||||
});
|
||||
|
||||
it('rises when only the settings changed, so Teams applies the re-upload', () => {
|
||||
const at = new Date('2026-09-15T10:00:00.000Z');
|
||||
const later = new Date('2026-09-15T10:00:05.000Z');
|
||||
|
||||
const before = service.buildManifest(options({ versionAt: at })).version;
|
||||
const after = service.buildManifest(
|
||||
options({ versionAt: later, settings: { teamChannels: true } }),
|
||||
).version;
|
||||
|
||||
expect(after).not.toBe(before);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['1.0.0', new Date('1970-01-01T00:00:00.000Z')],
|
||||
['1.20711.36000000', new Date('2026-09-15T10:00:00.000Z')],
|
||||
])('formats the version as %s', (expected, updatedAt) => {
|
||||
expect(service.buildManifest(options({ versionAt: updatedAt })).version).toBe(expected);
|
||||
});
|
||||
|
||||
it('never emits a version with a major below 1', () => {
|
||||
expect(service.buildManifest(options({ versionAt: new Date(0) })).version).toMatch(
|
||||
/^[1-9]\d*\.\d+\.\d+$/,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['short name', 30, (m: ReturnType<TeamsManifestService['buildManifest']>) => m.name.short],
|
||||
['full name', 100, (m: ReturnType<TeamsManifestService['buildManifest']>) => m.name.full],
|
||||
[
|
||||
'short description',
|
||||
80,
|
||||
(m: ReturnType<TeamsManifestService['buildManifest']>) => m.description.short,
|
||||
],
|
||||
[
|
||||
'full description',
|
||||
4000,
|
||||
(m: ReturnType<TeamsManifestService['buildManifest']>) => m.description.full,
|
||||
],
|
||||
[
|
||||
'developer name',
|
||||
32,
|
||||
(m: ReturnType<TeamsManifestService['buildManifest']>) => m.developer.name,
|
||||
],
|
||||
])('keeps the %s within Teams limits', (_label, limit, read) => {
|
||||
const manifest = service.buildManifest(options({ agentName: 'A'.repeat(500) }));
|
||||
|
||||
expect(read(manifest).length).toBeGreaterThan(0);
|
||||
expect(read(manifest).length).toBeLessThanOrEqual(limit);
|
||||
});
|
||||
|
||||
describe('app identity', () => {
|
||||
it('uses the agent name when nothing overrides it', () => {
|
||||
expect(service.buildManifest(options()).name.short).toBe('Support Bot');
|
||||
});
|
||||
|
||||
it('prefers the name chosen for Teams', () => {
|
||||
const manifest = service.buildManifest(
|
||||
options({ settings: { displayName: 'Support desk' } }),
|
||||
);
|
||||
|
||||
expect(manifest.name.short).toBe('Support desk');
|
||||
expect(manifest.description.short).toContain('Support desk');
|
||||
});
|
||||
|
||||
it('prefers the description chosen for Teams', () => {
|
||||
const manifest = service.buildManifest(
|
||||
options({ settings: { description: 'Answers questions about orders' } }),
|
||||
);
|
||||
|
||||
expect(manifest.description.short).toBe('Answers questions about orders');
|
||||
});
|
||||
|
||||
it('reports the same defaults the manifest falls back to', () => {
|
||||
const defaults = service.defaultIdentity('Support Bot');
|
||||
const manifest = service.buildManifest(options());
|
||||
|
||||
// The setup shows these before anything is saved, so they have to be
|
||||
// what the manifest would actually use.
|
||||
expect(defaults.displayName).toBe(manifest.name.short);
|
||||
expect(defaults.description).toBe(manifest.description.short);
|
||||
});
|
||||
|
||||
it('sanitises and truncates the defaults too', () => {
|
||||
const defaults = service.defaultIdentity('A'.repeat(200));
|
||||
|
||||
expect(defaults.displayName.length).toBeLessThanOrEqual(30);
|
||||
expect(defaults.description.length).toBeLessThanOrEqual(80);
|
||||
});
|
||||
|
||||
it('still truncates an override to the Teams limits', () => {
|
||||
const manifest = service.buildManifest(
|
||||
options({ settings: { displayName: 'A'.repeat(200) } }),
|
||||
);
|
||||
|
||||
expect(manifest.name.short.length).toBeLessThanOrEqual(30);
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to a default name when the agent name has no usable characters', () => {
|
||||
// Zero-width and control characters only.
|
||||
expect(service.buildManifest(options({ agentName: '\u200b\u0007 \u200e' })).name.short).toBe(
|
||||
'n8n Agent',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an accented name', 'Müller Bot'],
|
||||
['a non-Latin name', 'サポートボット'],
|
||||
['an emoji name', '🎉 Party Bot'],
|
||||
])('keeps %s intact, which Teams accepts', (_label, agentName) => {
|
||||
expect(service.buildManifest(options({ agentName })).name.short).toBe(agentName);
|
||||
});
|
||||
|
||||
it('keeps a word break that was written as a newline', () => {
|
||||
expect(service.buildManifest(options({ agentName: 'Support\nBot' })).name.short).toBe(
|
||||
'Support Bot',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a joined emoji whole, rather than splitting it into its parts', () => {
|
||||
const joined = '👩💻 Bot';
|
||||
|
||||
expect(service.buildManifest(options({ agentName: joined })).name.short).toBe(joined);
|
||||
});
|
||||
|
||||
it('leaves every character whole when the cap lands mid-flag', () => {
|
||||
// The leading letter shifts the boundary, so a cut counted in code
|
||||
// points would end on half a flag. Asserting the property rather than
|
||||
// a fixed string: an exact-value test passes whenever the arithmetic
|
||||
// happens to line up.
|
||||
const graphemes = (value: string) =>
|
||||
[...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(value)].map(
|
||||
(entry) => entry.segment,
|
||||
);
|
||||
|
||||
const name = 'A' + '🇺🇸'.repeat(20);
|
||||
const short = service.buildManifest(options({ agentName: name })).name.short;
|
||||
|
||||
// The cut has to land on a boundary of the input, so the output is a
|
||||
// whole-grapheme prefix of it. Half a flag is itself a valid grapheme, so
|
||||
// counting graphemes in the output alone proves nothing.
|
||||
expect(graphemes(name).slice(0, graphemes(short).length).join('')).toBe(short);
|
||||
expect(Array.from(short).length).toBeLessThanOrEqual(30);
|
||||
});
|
||||
|
||||
it('does not cut a flag in half at the length cap', () => {
|
||||
// Each flag is one grapheme of two code points, so an odd cap has to
|
||||
// stop short rather than leave half a flag.
|
||||
const short = service.buildManifest(options({ agentName: '🇺🇸'.repeat(20) })).name.short;
|
||||
|
||||
expect(short).toBe('🇺🇸'.repeat(15));
|
||||
expect(Array.from(short)).toHaveLength(30);
|
||||
});
|
||||
|
||||
it('does not cut an emoji in half at the length cap', () => {
|
||||
const name = '🎉'.repeat(40);
|
||||
|
||||
const short = service.buildManifest(options({ agentName: name })).name.short;
|
||||
|
||||
expect([...short]).toHaveLength(30);
|
||||
expect(short).toBe('🎉'.repeat(30));
|
||||
});
|
||||
|
||||
it('serves every developer URL over HTTPS', () => {
|
||||
const { developer } = service.buildManifest(options());
|
||||
|
||||
for (const url of [developer.websiteUrl, developer.privacyUrl, developer.termsOfUseUrl]) {
|
||||
expect(url).toMatch(/^https:\/\//);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPackage', () => {
|
||||
it('produces a flat zip holding exactly the three expected files', async () => {
|
||||
const entries = unzipSync(await service.buildPackage(options()));
|
||||
|
||||
expect(Object.keys(entries).sort()).toEqual(['color.png', 'manifest.json', 'outline.png']);
|
||||
// A nested folder is what makes the Teams upload fail.
|
||||
expect(Object.keys(entries).some((name) => name.includes('/'))).toBe(false);
|
||||
});
|
||||
|
||||
it('writes the same manifest into the zip that buildManifest returns', async () => {
|
||||
const entries = unzipSync(await service.buildPackage(options()));
|
||||
|
||||
expect(JSON.parse(Buffer.from(entries['manifest.json']).toString('utf8'))).toEqual(
|
||||
service.buildManifest(options()),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['color.png', 192],
|
||||
['outline.png', 32],
|
||||
])('bundles %s at %ipx square', async (name, size) => {
|
||||
const entries = unzipSync(await service.buildPackage(options()));
|
||||
const png = Buffer.from(entries[name]);
|
||||
|
||||
// PNG IHDR: width and height are big-endian uint32 at bytes 16 and 20.
|
||||
expect(png.subarray(0, 8)).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
|
||||
expect(png.readUInt32BE(16)).toBe(size);
|
||||
expect(png.readUInt32BE(20)).toBe(size);
|
||||
});
|
||||
|
||||
it('bundles an outline icon made only of white and transparent pixels', async () => {
|
||||
const entries = unzipSync(await service.buildPackage(options()));
|
||||
const pixels = readRgbaPixels(Buffer.from(entries['outline.png']));
|
||||
|
||||
// Teams rejects a coloured outline icon with InvalidOutlineIconTransparency.
|
||||
const coloured = pixels.filter(
|
||||
({ r, g, b, a }) => a > 0 && (r !== 255 || g !== 255 || b !== 255),
|
||||
);
|
||||
|
||||
expect(coloured).toEqual([]);
|
||||
expect(pixels.some(({ a }) => a === 255)).toBe(true);
|
||||
expect(pixels.some(({ a }) => a === 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,449 @@
|
|||
import { mock } from 'vitest-mock-extended';
|
||||
import { unzipSync } from 'fflate';
|
||||
import type { GlobalConfig } from '@n8n/config';
|
||||
import type { User } from '@n8n/db';
|
||||
import type { InstanceSettings } from 'n8n-core';
|
||||
|
||||
import { JwtService } from '@/services/jwt.service';
|
||||
|
||||
import type { CredentialsService } from '@/credentials/credentials.service';
|
||||
import type { UrlService } from '@/services/url.service';
|
||||
|
||||
import type { Agent } from '../../../../entities/agent.entity';
|
||||
import { AgentCredentialLookupService } from '../../../agent-credential-lookup.service';
|
||||
import type { AgentRepository } from '../../../../repositories/agent.repository';
|
||||
import { TeamsArmTemplateService } from '../teams-arm-template.service';
|
||||
import { TeamsManifestService } from '../teams-manifest.service';
|
||||
import { TeamsSetupService } from '../teams-setup.service';
|
||||
|
||||
const PROJECT_ID = 'project-1';
|
||||
const AGENT_ID = 'agent-1';
|
||||
const CREDENTIAL_ID = 'cred-1';
|
||||
const CLIENT_ID = '11111111-2222-3333-4444-555555555555';
|
||||
const TENANT_ID = '99999999-8888-7777-6666-555555555555';
|
||||
|
||||
const user = mock<User>({ id: 'user-1' });
|
||||
|
||||
const jwtService = new JwtService(
|
||||
mock<InstanceSettings>({ encryptionKey: 'test-encryption-key' }),
|
||||
mock<GlobalConfig>({ userManagement: { jwtSecret: 'test-jwt-secret' } }),
|
||||
);
|
||||
|
||||
describe('TeamsSetupService', () => {
|
||||
let agentRepository: ReturnType<typeof mock<AgentRepository>>;
|
||||
let credentialsService: ReturnType<typeof mock<CredentialsService>>;
|
||||
let urlService: ReturnType<typeof mock<UrlService>>;
|
||||
let armTemplateService: TeamsArmTemplateService;
|
||||
let service: TeamsSetupService;
|
||||
|
||||
/**
|
||||
* `integrations` is assigned rather than passed to `mock`, which proxies
|
||||
* nested objects: the integration's `settings` would come back as mock
|
||||
* functions, so an absent `displayName` would read as present.
|
||||
*/
|
||||
const agentWith = (integrations: Agent['integrations']) =>
|
||||
Object.assign(
|
||||
mock<Agent>({
|
||||
id: AGENT_ID,
|
||||
projectId: PROJECT_ID,
|
||||
name: 'Support Bot',
|
||||
updatedAt: new Date('2026-09-15T10:00:00.000Z'),
|
||||
}),
|
||||
{ integrations },
|
||||
);
|
||||
|
||||
const connectTeamsCredential = (type = 'microsoftEntraServicePrincipalApi') => {
|
||||
agentRepository.findByIdAndProjectId.mockResolvedValue(
|
||||
agentWith([{ type: 'teams', credentialId: CREDENTIAL_ID, settings: {} }]),
|
||||
);
|
||||
credentialsService.findAllCredentialIdsForProject.mockResolvedValue([
|
||||
mock({ id: CREDENTIAL_ID, type }),
|
||||
]);
|
||||
credentialsService.decrypt.mockResolvedValue({
|
||||
clientId: CLIENT_ID,
|
||||
tenantId: TENANT_ID,
|
||||
clientSecret: 'super-secret',
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
agentRepository = mock<AgentRepository>();
|
||||
credentialsService = mock<CredentialsService>();
|
||||
urlService = mock<UrlService>();
|
||||
urlService.getWebhookBaseUrl.mockReturnValue('https://n8n.example.com/');
|
||||
credentialsService.findAllCredentialIdsForProject.mockResolvedValue([]);
|
||||
credentialsService.findAllGlobalCredentialIds.mockResolvedValue([]);
|
||||
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([
|
||||
mock({ id: CREDENTIAL_ID }),
|
||||
]);
|
||||
agentRepository.findByIdAndProjectId.mockResolvedValue(agentWith([]));
|
||||
agentRepository.findByIntegrationCredentialAnyProject.mockResolvedValue([]);
|
||||
|
||||
armTemplateService = new TeamsArmTemplateService(jwtService, urlService);
|
||||
service = new TeamsSetupService(
|
||||
agentRepository,
|
||||
new AgentCredentialLookupService(credentialsService),
|
||||
new TeamsManifestService(),
|
||||
armTemplateService,
|
||||
urlService,
|
||||
);
|
||||
});
|
||||
|
||||
describe('getSetupState', () => {
|
||||
it('always shows the messaging endpoint URL, credential or not', async () => {
|
||||
const state = await service.getSetupState(user, { projectId: PROJECT_ID, agentId: AGENT_ID });
|
||||
|
||||
expect(state.messagingEndpointUrl).toBe(
|
||||
'https://n8n.example.com/rest/projects/project-1/agents/v2/agent-1/webhooks/teams',
|
||||
);
|
||||
});
|
||||
|
||||
it('withholds the deployment until a credential supplies the Entra IDs', async () => {
|
||||
const state = await service.getSetupState(user, { projectId: PROJECT_ID, agentId: AGENT_ID });
|
||||
|
||||
expect(state.botId).toBeNull();
|
||||
expect(state.deployToAzureUrl).toBeNull();
|
||||
});
|
||||
|
||||
it('pre-fills the deployment from a credential that is picked but not yet connected', async () => {
|
||||
credentialsService.findAllCredentialIdsForProject.mockResolvedValue([
|
||||
mock({ id: CREDENTIAL_ID, type: 'microsoftEntraServicePrincipalApi' }),
|
||||
]);
|
||||
credentialsService.decrypt.mockResolvedValue({
|
||||
clientId: CLIENT_ID,
|
||||
tenantId: TENANT_ID,
|
||||
clientSecret: 'super-secret',
|
||||
});
|
||||
|
||||
const state = await service.getSetupState(
|
||||
user,
|
||||
{
|
||||
projectId: PROJECT_ID,
|
||||
agentId: AGENT_ID,
|
||||
},
|
||||
CREDENTIAL_ID,
|
||||
);
|
||||
|
||||
expect(state.botId).toBe(CLIENT_ID);
|
||||
expect(state.deployToAzureUrl).toContain('portal.azure.com');
|
||||
});
|
||||
|
||||
it('reports the bot once a credential is connected', async () => {
|
||||
connectTeamsCredential();
|
||||
|
||||
const state = await service.getSetupState(user, { projectId: PROJECT_ID, agentId: AGENT_ID });
|
||||
|
||||
expect(state.botId).toBe(CLIENT_ID);
|
||||
});
|
||||
|
||||
it('ignores a connected credential of the wrong type', async () => {
|
||||
connectTeamsCredential('slackApi');
|
||||
|
||||
const state = await service.getSetupState(user, { projectId: PROJECT_ID, agentId: AGENT_ID });
|
||||
|
||||
expect(state.botId).toBeNull();
|
||||
});
|
||||
|
||||
it('never leaks the client secret', async () => {
|
||||
connectTeamsCredential();
|
||||
|
||||
const state = await service.getSetupState(user, { projectId: PROJECT_ID, agentId: AGENT_ID });
|
||||
|
||||
expect(JSON.stringify(state)).not.toContain('super-secret');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a credential another agent already uses', () => {
|
||||
const claimedByOtherAgent = () => {
|
||||
agentRepository.findByIntegrationCredentialAnyProject.mockResolvedValue([
|
||||
mock<Agent>({ name: 'Sales Bot' }),
|
||||
]);
|
||||
};
|
||||
|
||||
it('names the agent holding it', async () => {
|
||||
connectTeamsCredential();
|
||||
claimedByOtherAgent();
|
||||
|
||||
const state = await service.getSetupState(
|
||||
user,
|
||||
{
|
||||
projectId: PROJECT_ID,
|
||||
agentId: AGENT_ID,
|
||||
},
|
||||
CREDENTIAL_ID,
|
||||
);
|
||||
|
||||
expect(state.credentialClaimedBy).toBe('Sales Bot');
|
||||
});
|
||||
|
||||
it('withholds the deployment, which the portal would refuse', async () => {
|
||||
connectTeamsCredential();
|
||||
claimedByOtherAgent();
|
||||
|
||||
const state = await service.getSetupState(
|
||||
user,
|
||||
{
|
||||
projectId: PROJECT_ID,
|
||||
agentId: AGENT_ID,
|
||||
},
|
||||
CREDENTIAL_ID,
|
||||
);
|
||||
|
||||
expect(state.deployToAzureUrl).toBeNull();
|
||||
});
|
||||
|
||||
it("looks beyond the agent's own project, because the clash is at Microsoft", async () => {
|
||||
connectTeamsCredential();
|
||||
|
||||
await service.getSetupState(
|
||||
user,
|
||||
{ projectId: PROJECT_ID, agentId: AGENT_ID },
|
||||
CREDENTIAL_ID,
|
||||
);
|
||||
|
||||
expect(agentRepository.findByIntegrationCredentialAnyProject).toHaveBeenCalledWith(
|
||||
'teams',
|
||||
CREDENTIAL_ID,
|
||||
AGENT_ID,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to build the package, which would name the other agent's bot", async () => {
|
||||
connectTeamsCredential();
|
||||
claimedByOtherAgent();
|
||||
|
||||
await expect(
|
||||
service.buildPackage(user, { projectId: PROJECT_ID, agentId: AGENT_ID }, CREDENTIAL_ID),
|
||||
).rejects.toThrow(/already backs the Teams channel/);
|
||||
});
|
||||
|
||||
it('leaves the deployment alone when nothing else uses the credential', async () => {
|
||||
connectTeamsCredential();
|
||||
|
||||
const state = await service.getSetupState(
|
||||
user,
|
||||
{
|
||||
projectId: PROJECT_ID,
|
||||
agentId: AGENT_ID,
|
||||
},
|
||||
CREDENTIAL_ID,
|
||||
);
|
||||
|
||||
expect(state.credentialClaimedBy).toBeNull();
|
||||
expect(state.deployToAzureUrl).toContain('portal.azure.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPackage', () => {
|
||||
it('refuses to build a package before a credential supplies the client ID', async () => {
|
||||
await expect(
|
||||
service.buildPackage(user, { projectId: PROJECT_ID, agentId: AGENT_ID }),
|
||||
).rejects.toThrow(/Add the credential/);
|
||||
});
|
||||
|
||||
it('builds a package from a credential that is picked but not yet connected', async () => {
|
||||
credentialsService.findAllCredentialIdsForProject.mockResolvedValue([
|
||||
mock({ id: CREDENTIAL_ID, type: 'microsoftEntraServicePrincipalApi' }),
|
||||
]);
|
||||
credentialsService.decrypt.mockResolvedValue({
|
||||
clientId: CLIENT_ID,
|
||||
tenantId: TENANT_ID,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.buildPackage(user, { projectId: PROJECT_ID, agentId: AGENT_ID }, CREDENTIAL_ID),
|
||||
).resolves.toBeInstanceOf(Buffer);
|
||||
});
|
||||
|
||||
it('builds a package once a credential is connected', async () => {
|
||||
connectTeamsCredential();
|
||||
|
||||
await expect(
|
||||
service.buildPackage(user, { projectId: PROJECT_ID, agentId: AGENT_ID }),
|
||||
).resolves.toBeInstanceOf(Buffer);
|
||||
});
|
||||
|
||||
it('refuses a client ID that is not a GUID, which Teams rejects at upload', async () => {
|
||||
agentRepository.findByIdAndProjectId.mockResolvedValue(agentWith([]));
|
||||
credentialsService.findAllCredentialIdsForProject.mockResolvedValue([
|
||||
mock({ id: CREDENTIAL_ID, type: 'microsoftEntraServicePrincipalApi' }),
|
||||
]);
|
||||
credentialsService.decrypt.mockResolvedValue({
|
||||
clientId: 'not-a-guid',
|
||||
tenantId: TENANT_ID,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.buildPackage(user, { projectId: PROJECT_ID, agentId: AGENT_ID }, CREDENTIAL_ID),
|
||||
).rejects.toThrow(/not a GUID/);
|
||||
});
|
||||
|
||||
const manifestOf = (archive: Buffer) =>
|
||||
JSON.parse(Buffer.from(unzipSync(new Uint8Array(archive))['manifest.json']).toString());
|
||||
|
||||
it('builds from the settings it is given, which are not stored during setup', async () => {
|
||||
connectTeamsCredential();
|
||||
|
||||
const archive = await service.buildPackage(
|
||||
user,
|
||||
{ projectId: PROJECT_ID, agentId: AGENT_ID },
|
||||
CREDENTIAL_ID,
|
||||
{ teamChannels: true, groupChats: true, displayName: 'Helpdesk' },
|
||||
);
|
||||
|
||||
const manifest = manifestOf(archive);
|
||||
expect(manifest.bots[0].scopes).toEqual(['personal', 'team', 'groupChat']);
|
||||
expect(manifest.name.short).toBe('Helpdesk');
|
||||
});
|
||||
|
||||
it('raises the version when settings are supplied, so Teams applies the package', async () => {
|
||||
connectTeamsCredential();
|
||||
|
||||
const stored = manifestOf(
|
||||
await service.buildPackage(user, { projectId: PROJECT_ID, agentId: AGENT_ID }),
|
||||
).version;
|
||||
const chosen = manifestOf(
|
||||
await service.buildPackage(
|
||||
user,
|
||||
{ projectId: PROJECT_ID, agentId: AGENT_ID },
|
||||
CREDENTIAL_ID,
|
||||
{
|
||||
teamChannels: true,
|
||||
},
|
||||
),
|
||||
).version;
|
||||
|
||||
// The agent's own timestamp does not move when unsaved settings change,
|
||||
// and Teams ignores a package whose version it has already seen.
|
||||
expect(chosen).not.toBe(stored);
|
||||
});
|
||||
|
||||
it('falls back to the stored settings when none are given', async () => {
|
||||
agentRepository.findByIdAndProjectId.mockResolvedValue(
|
||||
agentWith([{ type: 'teams', credentialId: CREDENTIAL_ID, settings: { groupChats: true } }]),
|
||||
);
|
||||
credentialsService.findAllCredentialIdsForProject.mockResolvedValue([
|
||||
mock({ id: CREDENTIAL_ID, type: 'microsoftEntraServicePrincipalApi' }),
|
||||
]);
|
||||
credentialsService.decrypt.mockResolvedValue({ clientId: CLIENT_ID, tenantId: TENANT_ID });
|
||||
|
||||
const archive = await service.buildPackage(user, {
|
||||
projectId: PROJECT_ID,
|
||||
agentId: AGENT_ID,
|
||||
});
|
||||
|
||||
expect(manifestOf(archive).bots[0].scopes).toEqual(['personal', 'groupChat']);
|
||||
});
|
||||
});
|
||||
|
||||
it('withholds the deployment when the credential carries no Entra IDs', async () => {
|
||||
agentRepository.findByIdAndProjectId.mockResolvedValue(agentWith([]));
|
||||
credentialsService.findAllCredentialIdsForProject.mockResolvedValue([
|
||||
mock({ id: CREDENTIAL_ID, type: 'microsoftEntraServicePrincipalApi' }),
|
||||
]);
|
||||
credentialsService.decrypt.mockResolvedValue({ clientId: '', tenantId: '' });
|
||||
|
||||
const state = await service.getSetupState(
|
||||
user,
|
||||
{
|
||||
projectId: PROJECT_ID,
|
||||
agentId: AGENT_ID,
|
||||
},
|
||||
CREDENTIAL_ID,
|
||||
);
|
||||
|
||||
// The portal would fetch the template and be told the credential is
|
||||
// incomplete, which reads as an n8n outage.
|
||||
expect(state.deployToAzureUrl).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses to build a package on a credential the caller may not use', async () => {
|
||||
connectTeamsCredential();
|
||||
// Present in the project, but not shared with this user.
|
||||
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
service.buildPackage(user, { projectId: PROJECT_ID, agentId: AGENT_ID }, CREDENTIAL_ID),
|
||||
).rejects.toThrow(/Add the credential/);
|
||||
});
|
||||
|
||||
it('hides the bot ID of a credential the caller may not use', async () => {
|
||||
connectTeamsCredential();
|
||||
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([]);
|
||||
|
||||
const state = await service.getSetupState(
|
||||
user,
|
||||
{ projectId: PROJECT_ID, agentId: AGENT_ID },
|
||||
CREDENTIAL_ID,
|
||||
);
|
||||
|
||||
// The client ID is credential content, and the deploy link would serve
|
||||
// it plus the tenant ID to an unauthenticated fetch.
|
||||
expect(state.botId).toBeNull();
|
||||
expect(state.deployToAzureUrl).toBeNull();
|
||||
});
|
||||
|
||||
describe('buildArmTemplate', () => {
|
||||
const validToken = () =>
|
||||
new URL(
|
||||
armTemplateService.buildTemplateUrl(PROJECT_ID, AGENT_ID, CREDENTIAL_ID),
|
||||
).searchParams.get('token') ?? '';
|
||||
|
||||
it('rejects a request without a usable token', async () => {
|
||||
connectTeamsCredential();
|
||||
|
||||
await expect(
|
||||
service.buildArmTemplate({
|
||||
projectId: PROJECT_ID,
|
||||
agentId: AGENT_ID,
|
||||
credentialId: CREDENTIAL_ID,
|
||||
token: 'nope',
|
||||
}),
|
||||
).rejects.toThrow(/expired/);
|
||||
});
|
||||
|
||||
it('does not read the agent at all when the token is bad', async () => {
|
||||
await expect(
|
||||
service.buildArmTemplate({
|
||||
projectId: PROJECT_ID,
|
||||
agentId: AGENT_ID,
|
||||
credentialId: CREDENTIAL_ID,
|
||||
token: 'nope',
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(agentRepository.findByIdAndProjectId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns a template carrying the credential values for a valid token', async () => {
|
||||
connectTeamsCredential();
|
||||
|
||||
const template = await service.buildArmTemplate({
|
||||
projectId: PROJECT_ID,
|
||||
agentId: AGENT_ID,
|
||||
credentialId: CREDENTIAL_ID,
|
||||
token: validToken(),
|
||||
});
|
||||
|
||||
const parameters = template.parameters as Record<string, { defaultValue: unknown }>;
|
||||
expect(parameters.msaAppId.defaultValue).toBe(CLIENT_ID);
|
||||
expect(parameters.msaAppTenantId.defaultValue).toBe(TENANT_ID);
|
||||
expect(parameters.messagingEndpoint.defaultValue).toBe(
|
||||
'https://n8n.example.com/rest/projects/project-1/agents/v2/agent-1/webhooks/teams',
|
||||
);
|
||||
});
|
||||
|
||||
it('never puts the client secret in the template', async () => {
|
||||
connectTeamsCredential();
|
||||
|
||||
const template = await service.buildArmTemplate({
|
||||
projectId: PROJECT_ID,
|
||||
agentId: AGENT_ID,
|
||||
credentialId: CREDENTIAL_ID,
|
||||
token: validToken(),
|
||||
});
|
||||
|
||||
expect(JSON.stringify(template)).not.toContain('super-secret');
|
||||
});
|
||||
});
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 566 B |
|
|
@ -0,0 +1,205 @@
|
|||
import { Service } from '@n8n/di';
|
||||
import { UserError } from 'n8n-workflow';
|
||||
import { createHmac } from 'node:crypto';
|
||||
|
||||
import { JwtService } from '@/services/jwt.service';
|
||||
import { UrlService } from '@/services/url.service';
|
||||
|
||||
import { sanitiseAppName } from '../../integration-helpers';
|
||||
|
||||
/**
|
||||
* The Azure portal fetches the template itself, so the link stays usable only
|
||||
* long enough to click it.
|
||||
*/
|
||||
const TOKEN_TTL = '15m';
|
||||
|
||||
/** Scopes the token to this route, so no other n8n-signed token is accepted. */
|
||||
const TOKEN_SUBJECT = 'teams-arm-template';
|
||||
|
||||
interface TeamsArmTokenClaims {
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
credentialId: string;
|
||||
}
|
||||
|
||||
const BOT_API_VERSION = '2022-09-15';
|
||||
|
||||
/** Azure caps the bot's display name; the fallback matches the manifest's. */
|
||||
const BOT_DISPLAY_NAME_MAX = 42;
|
||||
const DEFAULT_BOT_DISPLAY_NAME = 'n8n Agent';
|
||||
|
||||
export interface TeamsArmTemplateOptions {
|
||||
/**
|
||||
* Names the Azure resource, which is why it is the agent's own name and not
|
||||
* the Teams display name: the resource name cannot change once the bot
|
||||
* exists, or a second deployment creates a second bot.
|
||||
*/
|
||||
agentName: string;
|
||||
agentId: string;
|
||||
/**
|
||||
* What the bot is called in the Azure portal. Follows the name chosen for
|
||||
* Teams when there is one, so the two listings agree.
|
||||
*/
|
||||
displayName?: string;
|
||||
/** Application (client) ID of the Entra app backing the bot. */
|
||||
msaAppId: string;
|
||||
/** Directory (tenant) ID, so the bot is registered single-tenant. */
|
||||
msaAppTenantId: string;
|
||||
messagingEndpoint: string;
|
||||
}
|
||||
|
||||
@Service()
|
||||
export class TeamsArmTemplateService {
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly urlService: UrlService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Every value is a parameter default, because the portal pre-fills the blade
|
||||
* from defaults and offers no way to pass values in the URL.
|
||||
*/
|
||||
buildTemplate(options: TeamsArmTemplateOptions): Record<string, unknown> {
|
||||
// Azure Bot Service refuses a plain-http endpoint, and the deployment fails
|
||||
// with a message that names neither n8n nor the setting behind it.
|
||||
if (!options.messagingEndpoint.startsWith('https://')) {
|
||||
throw new UserError(
|
||||
"The Teams bot needs an HTTPS messaging endpoint. Set N8N_WEBHOOK_URL to this instance's public HTTPS URL.",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
$schema: 'https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#',
|
||||
contentVersion: '1.0.0.0',
|
||||
parameters: {
|
||||
botName: {
|
||||
type: 'string',
|
||||
defaultValue: this.buildBotName(options.agentName, options.agentId),
|
||||
metadata: { description: 'Name of the Azure Bot resource. Must be globally unique.' },
|
||||
},
|
||||
// An empty default looks filled in and fails validation only at the end
|
||||
// of the deployment; omitting it makes the portal mark the field
|
||||
// required up front.
|
||||
msaAppId: {
|
||||
type: 'string',
|
||||
...(options.msaAppId ? { defaultValue: options.msaAppId } : {}),
|
||||
metadata: {
|
||||
description:
|
||||
'Application (client) ID of your Microsoft Entra app registration. Copy it from the app registration overview page.',
|
||||
},
|
||||
},
|
||||
msaAppTenantId: {
|
||||
type: 'string',
|
||||
...(options.msaAppTenantId ? { defaultValue: options.msaAppTenantId } : {}),
|
||||
metadata: {
|
||||
description:
|
||||
'Directory (tenant) ID of your Microsoft Entra app registration, on the same overview page.',
|
||||
},
|
||||
},
|
||||
messagingEndpoint: {
|
||||
type: 'string',
|
||||
defaultValue: options.messagingEndpoint,
|
||||
metadata: { description: 'Where Teams delivers messages. Filled in by n8n.' },
|
||||
},
|
||||
// Bot registrations are not regional.
|
||||
location: { type: 'string', defaultValue: 'global' },
|
||||
sku: { type: 'string', defaultValue: 'F0', allowedValues: ['F0', 'S1'] },
|
||||
},
|
||||
resources: [
|
||||
{
|
||||
type: 'Microsoft.BotService/botServices',
|
||||
apiVersion: BOT_API_VERSION,
|
||||
name: "[parameters('botName')]",
|
||||
location: "[parameters('location')]",
|
||||
sku: { name: "[parameters('sku')]" },
|
||||
kind: 'azurebot',
|
||||
properties: {
|
||||
displayName: sanitiseAppName(
|
||||
options.displayName ?? options.agentName,
|
||||
BOT_DISPLAY_NAME_MAX,
|
||||
DEFAULT_BOT_DISPLAY_NAME,
|
||||
),
|
||||
endpoint: "[parameters('messagingEndpoint')]",
|
||||
msaAppId: "[parameters('msaAppId')]",
|
||||
msaAppType: 'SingleTenant',
|
||||
msaAppTenantId: "[parameters('msaAppTenantId')]",
|
||||
},
|
||||
},
|
||||
{
|
||||
// Without this the bot exists but Teams cannot reach it, which is
|
||||
// the half of the setup a user is most likely to miss.
|
||||
type: 'Microsoft.BotService/botServices/channels',
|
||||
apiVersion: BOT_API_VERSION,
|
||||
name: "[concat(parameters('botName'), '/MsTeamsChannel')]",
|
||||
location: "[parameters('location')]",
|
||||
dependsOn: ["[resourceId('Microsoft.BotService/botServices', parameters('botName'))]"],
|
||||
properties: {
|
||||
channelName: 'MsTeamsChannel',
|
||||
properties: { isEnabled: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
outputs: {
|
||||
botName: { type: 'string', value: "[parameters('botName')]" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
buildDeployUrl(projectId: string, agentId: string, credentialId: string): string {
|
||||
return `https://portal.azure.com/#create/Microsoft.Template/uri/${encodeURIComponent(
|
||||
this.buildTemplateUrl(projectId, agentId, credentialId),
|
||||
)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The credential is named in the URL because the deployment happens before
|
||||
* the channel is connected, so the agent holds no credential to read it from.
|
||||
*/
|
||||
buildTemplateUrl(projectId: string, agentId: string, credentialId: string): string {
|
||||
const token = this.signToken(projectId, agentId, credentialId);
|
||||
const query = new URLSearchParams({ token, credentialId });
|
||||
return `${this.urlService.getWebhookBaseUrl()}rest/projects/${projectId}/agents/v2/${agentId}/integrations/teams/arm-template?${query.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The portal fetches the template with no n8n session, so the token is the
|
||||
* whole authorisation. Every value the template is built from is a claim, so
|
||||
* a token cannot be reused for another agent or another credential.
|
||||
*/
|
||||
verifyToken(projectId: string, agentId: string, credentialId: string, token: string): boolean {
|
||||
try {
|
||||
const claims = this.jwtService.verify<TeamsArmTokenClaims>(token, { subject: TOKEN_SUBJECT });
|
||||
return (
|
||||
claims.projectId === projectId &&
|
||||
claims.agentId === agentId &&
|
||||
claims.credentialId === credentialId
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private signToken(projectId: string, agentId: string, credentialId: string): string {
|
||||
return this.jwtService.sign(
|
||||
{ projectId, agentId, credentialId },
|
||||
{ subject: TOKEN_SUBJECT, expiresIn: TOKEN_TTL },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A digest is appended because an Azure Bot resource name is globally unique,
|
||||
* so the bare agent name would collide across tenants.
|
||||
*/
|
||||
private buildBotName(agentName: string, agentId: string): string {
|
||||
const suffix = createHmac('sha256', 'teams-bot-name').update(agentId).digest('hex').slice(0, 8);
|
||||
const slug = agentName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
// Azure allows 64 characters; leave room for the digest and separator.
|
||||
.slice(0, 40)
|
||||
.replace(/-+$/, '');
|
||||
// An Azure Bot name must start with a letter.
|
||||
return /^[a-z]/.test(slug) ? `${slug}-${suffix}` : `n8n-agent-${suffix}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import type { TeamsCredentialCheck } from '@n8n/api-types';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { OutboundHttp } from '@n8n/backend-network';
|
||||
import type { User } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { AgentCredentialLookupService } from '../../agent-credential-lookup.service';
|
||||
import { stringProperty } from '../../integration-helpers';
|
||||
|
||||
const TEAMS_CREDENTIAL_TYPE = 'microsoftEntraServicePrincipalApi';
|
||||
|
||||
/** The audience the Bot Framework connector issues tokens for. */
|
||||
const BOT_FRAMEWORK_SCOPE = 'https://api.botframework.com/.default';
|
||||
|
||||
/** The only cloud the Teams channel reaches, matching what the channel enforces. */
|
||||
const GLOBAL_GRAPH_API_BASE_URL = 'https://graph.microsoft.com';
|
||||
|
||||
/**
|
||||
* The check gates a button, so a Microsoft connection that opens and then
|
||||
* stalls has to give up long before the instance-wide request timeout.
|
||||
*/
|
||||
const CHECK_TIMEOUT_MS = 30_000;
|
||||
|
||||
@Service()
|
||||
export class TeamsCredentialCheckService {
|
||||
constructor(
|
||||
private readonly credentialLookup: AgentCredentialLookupService,
|
||||
private readonly outboundHttp: OutboundHttp,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Mints a token exactly as the channel will, so the check fails for the same
|
||||
* reasons the channel would: a wrong secret, a wrong tenant, a wrong client
|
||||
* ID. Anything short of a real token is not evidence the channel will work.
|
||||
*/
|
||||
async check(user: User, projectId: string, credentialId: string): Promise<TeamsCredentialCheck> {
|
||||
// Scoped to the caller, not just the project: this mints a real token, and
|
||||
// naming a credential is not the same as being allowed to use it.
|
||||
const data = await this.credentialLookup.decryptForUser(
|
||||
user,
|
||||
projectId,
|
||||
credentialId,
|
||||
TEAMS_CREDENTIAL_TYPE,
|
||||
);
|
||||
if (!data) return { status: 'failed', reason: 'incomplete' };
|
||||
|
||||
// The channel rejects certificate mode outright: it stores no client
|
||||
// secret, so there is nothing to mint a token with.
|
||||
if (stringProperty(data, 'authentication') === 'certificate') {
|
||||
return { status: 'failed', reason: 'certificate' };
|
||||
}
|
||||
|
||||
// The channel refuses a non-global cloud, so accepting one here would let
|
||||
// the setup pass on a credential the first message rejects.
|
||||
const graphApiBaseUrl = stringProperty(data, 'graphApiBaseUrl');
|
||||
if (graphApiBaseUrl && graphApiBaseUrl.replace(/\/+$/, '') !== GLOBAL_GRAPH_API_BASE_URL) {
|
||||
return { status: 'failed', reason: 'cloud' };
|
||||
}
|
||||
|
||||
// Trimmed the way the channel trims them, so the check does not refuse a
|
||||
// credential that would actually work.
|
||||
const tenantId = stringProperty(data, 'tenantId')?.trim();
|
||||
const clientId = stringProperty(data, 'clientId')?.trim();
|
||||
const clientSecret = stringProperty(data, 'clientSecret')?.trim();
|
||||
if (!tenantId || !clientId || !clientSecret) {
|
||||
return { status: 'failed', reason: 'incomplete' };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.outboundHttp
|
||||
// Fixed public vendor host, not user-controllable.
|
||||
.requests({ useDefaultSsrfPolicy: 'unsafe' })
|
||||
.request({
|
||||
method: 'POST',
|
||||
url: `https://login.microsoftonline.com/${encodeURIComponent(tenantId)}/oauth2/v2.0/token`,
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
scope: BOT_FRAMEWORK_SCOPE,
|
||||
}).toString(),
|
||||
returnFullResponse: true,
|
||||
ignoreHttpStatusErrors: true,
|
||||
timeout: CHECK_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
const body: unknown = response.body;
|
||||
const token = stringProperty(body, 'access_token');
|
||||
if (response.statusCode === 200 && token) return { status: 'ok' };
|
||||
|
||||
this.logger.debug('[TeamsCredentialCheck] Microsoft refused the credential', {
|
||||
statusCode: response.statusCode,
|
||||
});
|
||||
return { status: 'failed', reason: 'rejected' };
|
||||
} catch (error) {
|
||||
this.logger.debug('[TeamsCredentialCheck] Could not reach Microsoft', { error });
|
||||
return { status: 'failed', reason: 'unreachable' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,19 +3,19 @@ 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 { 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';
|
||||
} 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';
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
import {
|
||||
TEAMS_DESCRIPTION_MAX,
|
||||
TEAMS_DISPLAY_NAME_MAX,
|
||||
type AgentTeamsIntegrationSettings,
|
||||
type TeamsAgentAppManifest,
|
||||
} from '@n8n/api-types';
|
||||
import { Service } from '@n8n/di';
|
||||
import { zipSync } from 'fflate';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { v5 as uuidv5 } from 'uuid';
|
||||
|
||||
import { sanitiseAppName, truncateToCodePoints } from '../../integration-helpers';
|
||||
|
||||
const MANIFEST_VERSION = '1.16';
|
||||
const MANIFEST_SCHEMA = `https://developer.microsoft.com/json-schemas/teams/v${MANIFEST_VERSION}/MicrosoftTeams.schema.json`;
|
||||
|
||||
/** Matches the colour of the bundled icons. */
|
||||
const ACCENT_COLOR = '#EA4B71';
|
||||
|
||||
const DEFAULT_APP_NAME = 'n8n Agent';
|
||||
|
||||
/**
|
||||
* Fixed namespace for the manifest id, so the GUID for an agent never changes.
|
||||
* Teams keys an installed app on this id: a new one installs a second app
|
||||
* instead of updating the first.
|
||||
*/
|
||||
const MANIFEST_ID_NAMESPACE = 'b6b3f2a4-1c5e-4d9a-9f3b-7e2c8a1d4f60';
|
||||
|
||||
/** Teams rejects anything longer, naming the field. */
|
||||
const LIMITS = {
|
||||
// The same caps the settings schema enforces, so a value it accepts is never
|
||||
// truncated on its way into the manifest.
|
||||
shortName: TEAMS_DISPLAY_NAME_MAX,
|
||||
shortDescription: TEAMS_DESCRIPTION_MAX,
|
||||
fullName: 100,
|
||||
fullDescription: 4000,
|
||||
developerName: 32,
|
||||
} as const;
|
||||
|
||||
export interface BuildTeamsManifestOptions {
|
||||
agentName: string;
|
||||
agentId: string;
|
||||
/** Application (client) ID of the Entra app backing the bot. */
|
||||
botId: string;
|
||||
/**
|
||||
* Drives the manifest version, so a re-upload is an update. It has to move
|
||||
* whenever anything else in the manifest does: Teams ignores a package whose
|
||||
* version it has already seen, so an unchanged version silently discards the
|
||||
* new scopes or name.
|
||||
*/
|
||||
versionAt: Date;
|
||||
/**
|
||||
* Where the app may be used and how it appears. Absent means direct chat
|
||||
* only, with the name and description falling back to the agent's.
|
||||
*/
|
||||
settings?: AgentTeamsIntegrationSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Teams delivers only @mentions in a shared conversation unless the app asks to
|
||||
* read everything there. Each name is paired with the scope it depends on, so a
|
||||
* permission cannot be emitted without its surface.
|
||||
*/
|
||||
const READ_PERMISSIONS = [
|
||||
{
|
||||
setting: 'readAllChannelMessages',
|
||||
requires: 'teamChannels',
|
||||
name: 'ChannelMessage.Read.Group',
|
||||
},
|
||||
{
|
||||
setting: 'readAllGroupMessages',
|
||||
requires: 'groupChats',
|
||||
name: 'ChatMessage.Read.Chat',
|
||||
},
|
||||
] as const;
|
||||
|
||||
function describeApp(appName: string): string {
|
||||
return `Chat with ${appName}, an agent powered by n8n.`;
|
||||
}
|
||||
|
||||
@Service()
|
||||
export class TeamsManifestService {
|
||||
buildManifest(options: BuildTeamsManifestOptions): TeamsAgentAppManifest {
|
||||
const appName = sanitiseAppName(
|
||||
options.settings?.displayName ?? options.agentName,
|
||||
LIMITS.fullName,
|
||||
DEFAULT_APP_NAME,
|
||||
);
|
||||
// Falls back to describing the chosen name, not the agent's, so a renamed
|
||||
// app does not describe itself as something else.
|
||||
const shortDescription = options.settings?.description ?? describeApp(appName);
|
||||
return {
|
||||
$schema: MANIFEST_SCHEMA,
|
||||
manifestVersion: MANIFEST_VERSION,
|
||||
version: this.buildVersion(options.versionAt),
|
||||
id: this.buildManifestId(options.agentId),
|
||||
packageName: `io.n8n.agent.${this.buildManifestId(options.agentId)}`,
|
||||
developer: {
|
||||
name: this.truncate('n8n', LIMITS.developerName),
|
||||
websiteUrl: 'https://n8n.io',
|
||||
privacyUrl: 'https://n8n.io/legal/privacy',
|
||||
termsOfUseUrl: 'https://n8n.io/legal/terms',
|
||||
},
|
||||
name: {
|
||||
short: this.truncate(appName, LIMITS.shortName),
|
||||
full: this.truncate(appName, LIMITS.fullName),
|
||||
},
|
||||
description: {
|
||||
short: this.truncate(shortDescription, LIMITS.shortDescription),
|
||||
full: this.truncate(shortDescription, LIMITS.fullDescription),
|
||||
},
|
||||
icons: {
|
||||
color: 'color.png',
|
||||
outline: 'outline.png',
|
||||
},
|
||||
accentColor: ACCENT_COLOR,
|
||||
bots: [
|
||||
{
|
||||
botId: options.botId,
|
||||
scopes: this.buildScopes(options.settings),
|
||||
isNotificationOnly: false,
|
||||
supportsFiles: false,
|
||||
},
|
||||
],
|
||||
permissions: ['identity', 'messageTeamMembers'],
|
||||
validDomains: [],
|
||||
...this.buildReadPermissions(options.botId, options.settings),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A flat zip. A nested folder is the single most common reason the Teams
|
||||
* upload fails, so entries are added by name and never by directory.
|
||||
*/
|
||||
async buildPackage(options: BuildTeamsManifestOptions): Promise<Buffer> {
|
||||
const manifest = this.buildManifest(options);
|
||||
const [color, outline] = await Promise.all([
|
||||
readFile(join(__dirname, 'assets', 'color.png')),
|
||||
readFile(join(__dirname, 'assets', 'outline.png')),
|
||||
]);
|
||||
|
||||
return Buffer.from(
|
||||
zipSync({
|
||||
'manifest.json': Buffer.from(JSON.stringify(manifest, null, 2), 'utf8'),
|
||||
'color.png': color,
|
||||
'outline.png': outline,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Direct chat is always on: it is what makes the connection testable. */
|
||||
private buildScopes(availability: BuildTeamsManifestOptions['settings']): string[] {
|
||||
return [
|
||||
'personal',
|
||||
...(availability?.teamChannels ? ['team'] : []),
|
||||
...(availability?.groupChats ? ['groupChat'] : []),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* `webApplicationInfo` is required alongside resource-specific permissions,
|
||||
* so both appear together or neither does.
|
||||
*/
|
||||
private buildReadPermissions(
|
||||
botId: string,
|
||||
availability: BuildTeamsManifestOptions['settings'],
|
||||
): Partial<TeamsAgentAppManifest> {
|
||||
const resourceSpecific = READ_PERMISSIONS.filter(
|
||||
({ setting, requires }) => availability?.[setting] && availability?.[requires],
|
||||
).map(({ name }) => ({ name, type: 'Application' as const }));
|
||||
|
||||
if (resourceSpecific.length === 0) return {};
|
||||
return {
|
||||
webApplicationInfo: { id: botId },
|
||||
authorization: { permissions: { resourceSpecific } },
|
||||
};
|
||||
}
|
||||
|
||||
/** What the manifest falls back to, so the setup can show it before saving. */
|
||||
defaultIdentity(agentName: string): { displayName: string; description: string } {
|
||||
const appName = sanitiseAppName(agentName, LIMITS.fullName, DEFAULT_APP_NAME);
|
||||
return {
|
||||
displayName: this.truncate(appName, LIMITS.shortName),
|
||||
description: this.truncate(describeApp(appName), LIMITS.shortDescription),
|
||||
};
|
||||
}
|
||||
|
||||
/** Stable per agent, so a re-download updates the same Teams app. */
|
||||
buildManifestId(agentId: string): string {
|
||||
return uuidv5(agentId, MANIFEST_ID_NAMESPACE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Teams only accepts an update whose version is higher than the installed
|
||||
* one, so the version tracks the agent rather than being a constant. Major
|
||||
* stays 1 because Teams rejects a leading 0.
|
||||
*/
|
||||
private buildVersion(updatedAt: Date): string {
|
||||
const millis = Math.max(0, updatedAt.getTime());
|
||||
const days = Math.floor(millis / 86_400_000);
|
||||
// Milliseconds, not seconds: two downloads a moment apart would otherwise
|
||||
// carry the same version, and Teams ignores a version it has already seen.
|
||||
const millisIntoDay = millis % 86_400_000;
|
||||
return `1.${days}.${millisIntoDay}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The same cut `sanitiseAppName` makes, so the shorter cap cannot halve an
|
||||
* emoji the longer one kept whole.
|
||||
*/
|
||||
private truncate(value: string, max: number): string {
|
||||
return truncateToCodePoints(value, max);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
import type { AgentTeamsIntegrationSettings, TeamsAgentSetupState } from '@n8n/api-types';
|
||||
import type { User } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { UrlService } from '@/services/url.service';
|
||||
|
||||
import { TeamsArmTemplateService } from './teams-arm-template.service';
|
||||
import { TeamsManifestService } from './teams-manifest.service';
|
||||
import type { Agent } from '../../../entities/agent.entity';
|
||||
import { AgentRepository } from '../../../repositories/agent.repository';
|
||||
import { AgentCredentialLookupService } from '../../agent-credential-lookup.service';
|
||||
import { stringProperty } from '../../integration-helpers';
|
||||
|
||||
const TEAMS_CREDENTIAL_TYPE = 'microsoftEntraServicePrincipalApi';
|
||||
|
||||
const GUID = /^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$/;
|
||||
|
||||
interface AgentScope {
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
interface BotIdentity {
|
||||
clientId: string;
|
||||
tenantId: string;
|
||||
}
|
||||
|
||||
@Service()
|
||||
export class TeamsSetupService {
|
||||
constructor(
|
||||
private readonly agentRepository: AgentRepository,
|
||||
private readonly credentialLookup: AgentCredentialLookupService,
|
||||
private readonly manifestService: TeamsManifestService,
|
||||
private readonly armTemplateService: TeamsArmTemplateService,
|
||||
private readonly urlService: UrlService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* `selectedCredentialId` is the one picked in the setup but not yet connected
|
||||
* to the agent, which is what the deployment is pre-filled from: the agent
|
||||
* holds no credential until the very last step.
|
||||
*/
|
||||
async getSetupState(
|
||||
user: User,
|
||||
scope: AgentScope,
|
||||
selectedCredentialId?: string,
|
||||
): Promise<TeamsAgentSetupState> {
|
||||
const agent = await this.getAgent(scope);
|
||||
const credentialId = selectedCredentialId ?? this.connectedCredentialId(agent);
|
||||
const [identity, claimedBy] = await Promise.all([
|
||||
credentialId ? this.readIdentity(agent.projectId, credentialId, user) : null,
|
||||
credentialId ? this.credentialClaimedBy(agent.id, credentialId) : null,
|
||||
]);
|
||||
|
||||
return {
|
||||
messagingEndpointUrl: this.messagingEndpointUrl(scope),
|
||||
botId: identity?.clientId ?? null,
|
||||
// Withheld while the credential is taken: the deployment would fail in
|
||||
// the portal, and the user would read Azure's wording for an n8n-side
|
||||
// choice they can still change here.
|
||||
deployToAzureUrl:
|
||||
credentialId && identity && !claimedBy
|
||||
? this.armTemplateService.buildDeployUrl(scope.projectId, scope.agentId, credentialId)
|
||||
: null,
|
||||
credentialClaimedBy: claimedBy,
|
||||
...this.defaultIdentity(agent.name),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Built from the selected credential, not only the connected one: connecting
|
||||
* closes the setup, so waiting for it would put the download behind a round
|
||||
* trip through the edit view.
|
||||
*/
|
||||
async buildPackage(
|
||||
user: User,
|
||||
scope: AgentScope,
|
||||
selectedCredentialId?: string,
|
||||
selectedSettings?: AgentTeamsIntegrationSettings,
|
||||
): Promise<Buffer> {
|
||||
const agent = await this.getAgent(scope);
|
||||
const credentialId = selectedCredentialId ?? this.connectedCredentialId(agent);
|
||||
const identity = credentialId
|
||||
? await this.readIdentity(agent.projectId, credentialId, user)
|
||||
: null;
|
||||
if (!credentialId || !identity) {
|
||||
throw new BadRequestError('Add the credential before downloading the app package.');
|
||||
}
|
||||
|
||||
// Teams rejects a package whose botId is not a GUID, and it says so only
|
||||
// at upload, by which point the cause is three steps behind.
|
||||
if (!GUID.test(identity.clientId)) {
|
||||
throw new BadRequestError(
|
||||
"This credential's Application (client) ID is not a GUID, which Teams requires.",
|
||||
);
|
||||
}
|
||||
|
||||
// The manifest names this credential's bot, so a credential another agent
|
||||
// holds would build that agent's app under this agent's name.
|
||||
const claimedBy = await this.credentialClaimedBy(agent.id, credentialId);
|
||||
if (claimedBy) {
|
||||
throw new BadRequestError(
|
||||
`This credential already backs the Teams channel of "${claimedBy}". Pick a different one.`,
|
||||
);
|
||||
}
|
||||
|
||||
return await this.manifestService.buildPackage({
|
||||
agentName: agent.name,
|
||||
agentId: agent.id,
|
||||
botId: identity.clientId,
|
||||
// Settings supplied by the caller are not stored yet, so the agent's own
|
||||
// timestamp does not move with them. Stamping now keeps the version
|
||||
// rising, which is the only way Teams applies the package.
|
||||
versionAt: selectedSettings
|
||||
? new Date(Math.max(Date.now(), agent.updatedAt.getTime()))
|
||||
: agent.updatedAt,
|
||||
// The open form wins over what is stored: during setup nothing is stored
|
||||
// yet, and in the settings view the fields sit above this button.
|
||||
settings: selectedSettings ?? this.teamsSettingsOf(agent),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reached by the Azure portal, which carries no n8n session, so the signed
|
||||
* token is the whole authorisation check.
|
||||
*/
|
||||
async buildArmTemplate(
|
||||
scope: AgentScope & { token: string; credentialId: string },
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (
|
||||
!this.armTemplateService.verifyToken(
|
||||
scope.projectId,
|
||||
scope.agentId,
|
||||
scope.credentialId,
|
||||
scope.token,
|
||||
)
|
||||
) {
|
||||
throw new NotFoundError('This deployment link has expired. Open the setup steps again.');
|
||||
}
|
||||
|
||||
const agent = await this.getAgent(scope);
|
||||
const identity = await this.readIdentity(agent.projectId, scope.credentialId);
|
||||
if (!identity) {
|
||||
throw new BadRequestError('This credential is missing its tenant or client ID.');
|
||||
}
|
||||
|
||||
return this.armTemplateService.buildTemplate({
|
||||
agentName: agent.name,
|
||||
agentId: agent.id,
|
||||
displayName: this.teamsSettingsOf(agent)?.displayName,
|
||||
msaAppId: identity.clientId,
|
||||
msaAppTenantId: identity.tenantId,
|
||||
messagingEndpoint: this.messagingEndpointUrl(scope),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Looked up across projects, because the clash is at Microsoft rather than in
|
||||
* n8n. Connecting is still refused only within the project, by the shared
|
||||
* precondition every channel uses.
|
||||
*/
|
||||
private async credentialClaimedBy(agentId: string, credentialId: string): Promise<string | null> {
|
||||
const others = await this.agentRepository.findByIntegrationCredentialAnyProject(
|
||||
'teams',
|
||||
credentialId,
|
||||
agentId,
|
||||
);
|
||||
return others[0]?.name ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's settings live on the connected integration, because they are what
|
||||
* the user chose for this agent's Teams app rather than for the agent itself.
|
||||
*/
|
||||
private teamsSettingsOf(agent: Agent): AgentTeamsIntegrationSettings | undefined {
|
||||
return agent.integrations?.find((item) => item.type === 'teams')?.settings;
|
||||
}
|
||||
|
||||
private defaultIdentity(agentName: string) {
|
||||
const { displayName, description } = this.manifestService.defaultIdentity(agentName);
|
||||
return { defaultDisplayName: displayName, defaultDescription: description };
|
||||
}
|
||||
|
||||
private async getAgent(scope: AgentScope): Promise<Agent> {
|
||||
const agent = await this.agentRepository.findByIdAndProjectId(scope.agentId, scope.projectId);
|
||||
if (!agent) throw new NotFoundError(`Agent "${scope.agentId}" not found`);
|
||||
return agent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolved through the agent's project rather than a signed-in user, because
|
||||
* the template route has none. The client ID this returns is not a secret: it
|
||||
* ships inside the manifest the user downloads.
|
||||
*/
|
||||
private connectedCredentialId(agent: Agent): string | undefined {
|
||||
return agent.integrations?.find((item) => item.type === 'teams')?.credentialId;
|
||||
}
|
||||
|
||||
/**
|
||||
* `user` is absent only on the ARM template route, which has no session and
|
||||
* is authorised by a signed token instead -- and that token is minted only
|
||||
* after a user-scoped read has already allowed the credential.
|
||||
*/
|
||||
private async readIdentity(
|
||||
projectId: string,
|
||||
credentialId: string,
|
||||
user?: User,
|
||||
): Promise<BotIdentity | null> {
|
||||
const data = user
|
||||
? await this.credentialLookup.decryptForUser(
|
||||
user,
|
||||
projectId,
|
||||
credentialId,
|
||||
TEAMS_CREDENTIAL_TYPE,
|
||||
)
|
||||
: await this.credentialLookup.decryptForProject(
|
||||
projectId,
|
||||
credentialId,
|
||||
TEAMS_CREDENTIAL_TYPE,
|
||||
);
|
||||
const clientId = stringProperty(data, 'clientId');
|
||||
const tenantId = stringProperty(data, 'tenantId');
|
||||
return clientId && tenantId ? { clientId, tenantId } : null;
|
||||
}
|
||||
|
||||
private messagingEndpointUrl(scope: AgentScope): string {
|
||||
// getWebhookBaseUrl returns the URL with a trailing slash.
|
||||
return `${this.urlService.getWebhookBaseUrl()}rest/projects/${scope.projectId}/agents/v2/${scope.agentId}/webhooks/teams`;
|
||||
}
|
||||
}
|
||||
|
|
@ -352,6 +352,31 @@ export class AgentRepository extends Repository<Agent> {
|
|||
return new Set(rows.map((row) => row.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds agents whose `integrations` JSON column contains an entry matching the
|
||||
* given `type` + `credentialId`, anywhere on the instance, excluding
|
||||
* `excludeAgentId`.
|
||||
*
|
||||
* Instance-wide, unlike `findByIntegrationCredential`: a vendor app such as an
|
||||
* Entra or Slack registration is bound to one bot at the vendor, so an agent
|
||||
* in another project breaks a setup just as surely as one in this project.
|
||||
*
|
||||
* Reads only the columns the predicate and the caller need, so an instance
|
||||
* with large agent configurations does not transfer and parse all of them.
|
||||
*/
|
||||
async findByIntegrationCredentialAnyProject(
|
||||
type: string,
|
||||
credentialId: string,
|
||||
excludeAgentId: string,
|
||||
): Promise<Array<Pick<Agent, 'id' | 'name' | 'integrations'>>> {
|
||||
const agents = await this.find({ select: ['id', 'name', 'integrations'] });
|
||||
return agents.filter(
|
||||
(agent) =>
|
||||
agent.id !== excludeAgentId &&
|
||||
(agent.integrations ?? []).some((i) => i.type === type && i.credentialId === credentialId),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds agents within a project whose `integrations` JSON column contains an
|
||||
* entry matching the given `type` + `credentialId`, excluding `excludeAgentId`.
|
||||
|
|
|
|||
|
|
@ -5,6 +5,37 @@ import Switch from './Switch.vue';
|
|||
|
||||
describe('components/N8nSwitch', () => {
|
||||
describe('rendering', () => {
|
||||
it('keeps the label the caller forwarded, rather than the generic fallback', () => {
|
||||
const wrapper = render(Switch, { attrs: { 'aria-label': 'Team channels' } });
|
||||
|
||||
expect(wrapper.getByRole('switch')).toHaveAttribute('aria-label', 'Team channels');
|
||||
});
|
||||
|
||||
it('falls back to a generic label only when the caller gives neither', () => {
|
||||
const wrapper = render(Switch);
|
||||
|
||||
expect(wrapper.getByRole('switch')).toHaveAttribute('aria-label', 'Toggle');
|
||||
});
|
||||
|
||||
it('follows a forwarded label that changes', async () => {
|
||||
const wrapper = render(Switch, { attrs: { 'aria-label': 'Team channels' } });
|
||||
|
||||
await wrapper.rerender({ 'aria-label': 'Group chats' });
|
||||
|
||||
// `attrs` is not reactive, so a cached lookup would still say the first.
|
||||
expect(wrapper.getByRole('switch')).toHaveAttribute('aria-label', 'Group chats');
|
||||
});
|
||||
|
||||
it('prefers its own label prop over a forwarded one', () => {
|
||||
const wrapper = render(Switch, {
|
||||
props: { label: 'Visible label' },
|
||||
attrs: { 'aria-label': 'Forwarded' },
|
||||
});
|
||||
|
||||
// The visible label is already associated through aria-labelledby.
|
||||
expect(wrapper.getByRole('switch')).not.toHaveAttribute('aria-label');
|
||||
});
|
||||
|
||||
it('should forward attributes without Vue warnings', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
|
|
|
|||
|
|
@ -8750,6 +8750,47 @@
|
|||
"agents.channels.discord.setup.interactionsEndpoint.description": "Discord delivers button clicks to this URL. Messages reach your agent over a separate connection, so without it the agent can still chat, but approval buttons will not work.",
|
||||
"agents.channels.discord.setup.interactionsEndpoint.hint": "Save it as the Interactions Endpoint URL on the application's General Information page after the agent is connected and published.",
|
||||
"agents.channels.setup.publishNotice": "Publish this agent before adding the Interactions Endpoint URL in Discord.",
|
||||
"agents.channels.teams.messagingEndpointUrl.label": "Messaging endpoint URL",
|
||||
"agents.channels.teams.setup.createBot.title": "Create an Azure Bot",
|
||||
"agents.channels.teams.setup.createBot.description": "In the Azure portal, create a single-tenant Azure Bot with this endpoint, then turn on the Microsoft Teams channel. Already have a bot? Paste the endpoint into its Configuration page instead.",
|
||||
"agents.channels.teams.setup.createBot.button": "Deploy to Azure",
|
||||
"agents.channels.teams.setup.createBot.portalButton": "Open Azure portal",
|
||||
"agents.channels.teams.setup.createBot.hint": "The deployment fills in the endpoint and turns the Teams channel on. Paste your app registration's Application and Directory IDs into it, then pick a subscription and resource group.",
|
||||
"agents.channels.teams.setup.createBot.prerequisites": "You need a work or school account with an Azure subscription, and permission to register an app in Microsoft Entra. If either is missing, ask an administrator to create the bot and send you its Application and Directory IDs.",
|
||||
"agents.channels.teams.setup.connectBot.title": "Connect the bot",
|
||||
"agents.channels.teams.setup.connectBot.description": "In your bot's Azure page, open Test in Web Chat and send any message. We'll pick the bot up from it.",
|
||||
"agents.channels.teams.setup.connectBot.listening": "Listening for a message from your bot…",
|
||||
"agents.channels.teams.setup.connectBot.slow": "Taking long? Check the endpoint was saved and the Teams channel is on, or enter the values yourself.",
|
||||
"agents.channels.teams.setup.connectBot.manualLink": "enter the values yourself",
|
||||
"agents.channels.teams.setup.connectBot.expired": "We stopped listening. Start again when your bot is ready.",
|
||||
"agents.channels.teams.setup.connectBot.found": "Found your bot. Add its client secret to finish.",
|
||||
"agents.channels.teams.setup.connectBot.foundExisting": "Found your bot, and a credential that already has its details.",
|
||||
"agents.channels.teams.setup.connectBot.clientIdLabel": "Application (client) ID",
|
||||
"agents.channels.teams.setup.connectBot.tenantIdLabel": "Directory (tenant) ID",
|
||||
"agents.channels.teams.setup.connectBot.tenantMissing": "This message carried no tenant, which Web Chat does not send. Add your Directory (tenant) ID to the credential.",
|
||||
"agents.channels.teams.setup.availability.title": "Choose where it's available",
|
||||
"agents.channels.teams.setup.availability.description": "Set which conversations the agent can join. Changing these later means downloading and uploading the app again.",
|
||||
"agents.channels.teams.setup.availability.directChat": "Direct chat",
|
||||
"agents.channels.teams.setup.availability.directChatHint": "Always on, so we can confirm the connection works.",
|
||||
"agents.channels.teams.setup.availability.teamChannels": "Team channels",
|
||||
"agents.channels.teams.setup.availability.teamChannelsHint": "Members can add and @mention the agent in a channel.",
|
||||
"agents.channels.teams.setup.availability.groupChats": "Group and meeting chats",
|
||||
"agents.channels.teams.setup.availability.groupChatsHint": "The agent can join multi-person and meeting conversations.",
|
||||
"agents.channels.teams.setup.availability.readingTitle": "What can it see?",
|
||||
"agents.channels.teams.setup.availability.readAllChannelMessages": "Read all channel messages",
|
||||
"agents.channels.teams.setup.availability.readAllChannelMessagesHint": "Otherwise it only sees messages that @mention it. Turn on team channels first.",
|
||||
"agents.channels.teams.setup.availability.readAllGroupMessages": "Read all group-chat messages",
|
||||
"agents.channels.teams.setup.availability.readAllGroupMessagesHint": "Otherwise it only sees messages that @mention it. Turn on group and meeting chats first.",
|
||||
"agents.channels.teams.setup.availability.untested": "Team channels and group chats are not covered by tests yet. Direct chat is.",
|
||||
"agents.channels.teams.setup.install.title": "Install for me",
|
||||
"agents.channels.teams.setup.install.description": "Upload the app package in Teams: Apps, then Manage your apps, then Upload an app.",
|
||||
"agents.channels.teams.setup.install.button": "Download app package",
|
||||
"agents.channels.teams.setup.install.needsBot": "Connect the bot first. The package needs its Application (client) ID.",
|
||||
"agents.channels.teams.setup.install.hint": "Upload the zip exactly as downloaded. If the option is missing, a Teams administrator must allow custom app uploads for your organisation.",
|
||||
"agents.channels.teams.setup.install.connectButton": "Connect",
|
||||
"agents.channels.teams.setup.install.needsCredential": "Add the credential in step 2 before connecting.",
|
||||
"agents.channels.teams.setup.install.downloadFailed": "Could not download the app package. Try again.",
|
||||
"agents.channels.teams.setup.publishNotice": "Publish this agent before you message it in Teams.",
|
||||
"agents.channels.telegram.setup.credential.title": "Add credential",
|
||||
"agents.channels.telegram.setup.access.title": "Set access and restrictions",
|
||||
"agents.channels.telegram.setup.connect.title": "Connect Telegram",
|
||||
|
|
@ -8978,5 +9019,37 @@
|
|||
"promotions.modal.incoming.paused.title": "Apply paused",
|
||||
"promotions.modal.incoming.paused.blocked": "Some credentials or variables are not set up on this instance yet. Nothing was changed.",
|
||||
"promotions.modal.incoming.paused.source-changed": "The source changed since this preview. Refresh and review the changes again.",
|
||||
"promotions.applied.projectRemoved": "Apply removed this project from the instance."
|
||||
"promotions.applied.projectRemoved": "Apply removed this project from the instance.",
|
||||
"agents.channels.teams.setup.connectBot.openBotHint": "Your bot is called {botName}. Open it, then choose Test in Web Chat.",
|
||||
"agents.channels.teams.setup.install.checking": "Checking the credential can reach Microsoft…",
|
||||
"agents.channels.teams.setup.install.verified": "The credential works. Microsoft issued a token for this bot.",
|
||||
"agents.channels.teams.setup.install.failed.certificate": "This credential uses certificate authentication, which Teams channels cannot use. Switch it to Client Secret.",
|
||||
"agents.channels.teams.setup.install.failed.incomplete": "This credential is missing its tenant ID, client ID or client secret.",
|
||||
"agents.channels.teams.setup.install.failed.rejected": "Microsoft rejected these details. Check the client secret has not expired, and that the tenant and client IDs match the app registration.",
|
||||
"agents.channels.teams.setup.install.failed.unreachable": "Could not reach Microsoft to check the credential. Check this instance has outbound internet access, then try again.",
|
||||
"agents.channels.teams.setup.install.recheck": "Check again",
|
||||
"agents.channels.teams.setup.createBot.entraButton": "Register an app in Entra",
|
||||
"agents.channels.teams.setup.connectBot.openBotButton": "Open Azure Bot Services",
|
||||
"agents.channels.teams.setup.createCredential.title": "Register the app and add the credential",
|
||||
"agents.channels.teams.setup.createCredential.description": "Register an app in Microsoft Entra, add a client secret, then put its Directory (tenant) ID, Application (client) ID and secret into a credential here. Everything after this is built from it.",
|
||||
"agents.channels.teams.setup.createCredential.button": "Register an app in Microsoft Entra",
|
||||
"agents.channels.teams.setup.createCredential.prerequisites": "The three values come from a Microsoft Entra app registration. If you do not have one, create it first — you need a work or school account with permission to register apps, and an Azure subscription for the next step.",
|
||||
"agents.channels.teams.setup.createBot.needsCredential": "Add the credential first. The deployment needs its Application and Directory IDs.",
|
||||
"agents.channels.teams.setup.connectBot.mismatch": "A bot reached n8n, but it is not the one this credential describes. Check the endpoint is on the right bot.",
|
||||
"agents.channels.teams.setup.connectBot.skipped": "Check skipped. You can still finish, but nothing has confirmed the endpoint yet.",
|
||||
"agents.channels.teams.setup.createBot.existingBot": "Already have an Azure bot?",
|
||||
"agents.channels.teams.setup.createBot.existingBotHint": "Paste this into your bot's Configuration page as the messaging endpoint, and turn on the Microsoft Teams channel.",
|
||||
"agents.channels.teams.setup.install.openChat": "Open the chat in Teams",
|
||||
"agents.channels.teams.setup.availability.whereTitle": "Where can people use the agent?",
|
||||
"agents.channels.teams.setup.availability.readingSummaryNone": "Only messages addressed to it",
|
||||
"agents.channels.teams.setup.availability.readingNote": "Team owners approve read access when they install the app.",
|
||||
"agents.channels.teams.settings.updateNotice": "Changes here update the app in Teams. Download the package again and upload it to apply them.",
|
||||
"agents.channels.teams.settings.displayName": "Agent name in Teams",
|
||||
"agents.channels.teams.settings.displayNamePlaceholder": "Uses the agent's name",
|
||||
"agents.channels.teams.settings.description": "Description",
|
||||
"agents.channels.teams.settings.descriptionPlaceholder": "What this agent helps with",
|
||||
"agents.channels.teams.settings.descriptionDefault": "Chat with {name}, an agent powered by n8n.",
|
||||
"agents.channels.teams.setup.createCredential.claimed": "{agent} already uses this credential for Teams. Microsoft allows one bot for each app registration, so pick a different credential or register another app in Entra.",
|
||||
"agents.channels.teams.setup.createBot.needsFreeCredential": "Pick a credential no other agent uses before deploying.",
|
||||
"agents.channels.teams.setup.install.failed.cloud": "Teams channels only reach the global Microsoft cloud. Set the credential's Microsoft Graph API base URL back to https://graph.microsoft.com."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,8 +23,9 @@ 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);
|
||||
// Registered for its stepper. The channel itself stays hidden from the
|
||||
// catalog through `internal` on the backend, which is a separate switch.
|
||||
expect(isRegisteredAgentChannelPlatform('teams')).toBe(true);
|
||||
});
|
||||
|
||||
it('derives Slack list metadata from its local runtime state', () => {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import AgentChannelSlackRemoveConfirmation from './slack/AgentChannelSlackRemove
|
|||
import AgentChannelSlackSetupKindSelector from './slack/AgentChannelSlackSetupKindSelector.vue';
|
||||
import AgentChannelSlackSetupView from './slack/AgentChannelSlackSetupView.vue';
|
||||
import { isSlackChannelRuntime, useSlackChannelRuntime } from './slack/useSlackChannelRuntime';
|
||||
import AgentChannelTeamsEditView from './teams/AgentChannelTeamsEditView.vue';
|
||||
import AgentChannelTeamsSetup from './teams/AgentChannelTeamsSetup.vue';
|
||||
import AgentChannelTelegramEditView from './telegram/AgentChannelTelegramEditView.vue';
|
||||
import AgentChannelTelegramSetup from './telegram/AgentChannelTelegramSetup.vue';
|
||||
import type {
|
||||
|
|
@ -106,6 +108,12 @@ const platforms = {
|
|||
editComponent: AgentChannelDiscordEditView,
|
||||
getConnectAction: ({ text }) => ({ label: text('generic.connect') }),
|
||||
},
|
||||
teams: {
|
||||
type: 'teams',
|
||||
setupComponent: AgentChannelTeamsSetup,
|
||||
editComponent: AgentChannelTeamsEditView,
|
||||
getConnectAction: ({ text }) => ({ label: text('generic.connect') }),
|
||||
},
|
||||
} satisfies Record<string, AgentChannelPlatform>;
|
||||
|
||||
export function isRegisteredAgentChannelPlatform(type: string): type is keyof typeof platforms {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,251 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Where the Teams app may be used and how much it may read there.
|
||||
*
|
||||
* Shared by the setup stepper and the channel settings, because these are
|
||||
* manifest fields: changing one produces a new app package either way.
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
import { N8nCollapsiblePanel, N8nIcon, N8nSwitch2, N8nText } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
|
||||
export interface TeamsAvailability {
|
||||
teamChannels: boolean;
|
||||
groupChats: boolean;
|
||||
readAllChannelMessages: boolean;
|
||||
readAllGroupMessages: boolean;
|
||||
}
|
||||
|
||||
const value = defineModel<TeamsAvailability>({ required: true });
|
||||
|
||||
const props = withDefaults(defineProps<{ startCollapsed?: boolean }>(), {
|
||||
startCollapsed: false,
|
||||
});
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
// Local, because `startCollapsed` is only where the panels begin: bound
|
||||
// straight to the panel, the open state it emits has nothing to apply it.
|
||||
const whereOpen = ref(!props.startCollapsed);
|
||||
const readingOpen = ref(!props.startCollapsed);
|
||||
|
||||
function set(patch: Partial<TeamsAvailability>) {
|
||||
const next = { ...value.value, ...patch };
|
||||
// A read permission without its surface is rejected by the backend schema, so
|
||||
// it cannot be left on when its scope goes off.
|
||||
if (!next.teamChannels) next.readAllChannelMessages = false;
|
||||
if (!next.groupChats) next.readAllGroupMessages = false;
|
||||
value.value = next;
|
||||
}
|
||||
|
||||
/** Collapsed panels still have to say what they are set to. */
|
||||
const whereSummary = computed(() =>
|
||||
[
|
||||
i18n.baseText('agents.channels.teams.setup.availability.directChat'),
|
||||
...(value.value.teamChannels
|
||||
? [i18n.baseText('agents.channels.teams.setup.availability.teamChannels')]
|
||||
: []),
|
||||
...(value.value.groupChats
|
||||
? [i18n.baseText('agents.channels.teams.setup.availability.groupChats')]
|
||||
: []),
|
||||
].join(', '),
|
||||
);
|
||||
|
||||
const readingSummary = computed(() => {
|
||||
const reads = [
|
||||
...(value.value.readAllChannelMessages
|
||||
? [i18n.baseText('agents.channels.teams.setup.availability.readAllChannelMessages')]
|
||||
: []),
|
||||
...(value.value.readAllGroupMessages
|
||||
? [i18n.baseText('agents.channels.teams.setup.availability.readAllGroupMessages')]
|
||||
: []),
|
||||
];
|
||||
return reads.length > 0
|
||||
? reads.join(', ')
|
||||
: i18n.baseText('agents.channels.teams.setup.availability.readingSummaryNone');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.panels">
|
||||
<N8nCollapsiblePanel v-model="whereOpen" :class="$style.panel">
|
||||
<template #title>
|
||||
<span :class="$style.panelTitle">
|
||||
<N8nText size="small" bold>
|
||||
{{ i18n.baseText('agents.channels.teams.setup.availability.whereTitle') }}
|
||||
</N8nText>
|
||||
<N8nText size="small" :class="$style.hint" data-testid="teams-where-summary">
|
||||
{{ whereSummary }}
|
||||
</N8nText>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div :class="$style.panelBody">
|
||||
<div :class="$style.row" data-testid="teams-scope-direct">
|
||||
<div :class="$style.rowText">
|
||||
<N8nText size="small">
|
||||
{{ i18n.baseText('agents.channels.teams.setup.availability.directChat') }}
|
||||
</N8nText>
|
||||
<N8nText size="small" :class="$style.hint">
|
||||
{{ i18n.baseText('agents.channels.teams.setup.availability.directChatHint') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<!-- Fixed, so a tick rather than a control that cannot move. -->
|
||||
<N8nIcon icon="check" size="small" :class="$style.fixed" />
|
||||
</div>
|
||||
|
||||
<div :class="$style.row">
|
||||
<div :class="$style.rowText">
|
||||
<N8nText size="small">
|
||||
{{ i18n.baseText('agents.channels.teams.setup.availability.teamChannels') }}
|
||||
</N8nText>
|
||||
<N8nText size="small" :class="$style.hint">
|
||||
{{ i18n.baseText('agents.channels.teams.setup.availability.teamChannelsHint') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nSwitch2
|
||||
:model-value="value.teamChannels"
|
||||
:aria-label="i18n.baseText('agents.channels.teams.setup.availability.teamChannels')"
|
||||
data-testid="teams-scope-channels"
|
||||
@update:model-value="set({ teamChannels: $event })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="$style.row">
|
||||
<div :class="$style.rowText">
|
||||
<N8nText size="small">
|
||||
{{ i18n.baseText('agents.channels.teams.setup.availability.groupChats') }}
|
||||
</N8nText>
|
||||
<N8nText size="small" :class="$style.hint">
|
||||
{{ i18n.baseText('agents.channels.teams.setup.availability.groupChatsHint') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nSwitch2
|
||||
:model-value="value.groupChats"
|
||||
:aria-label="i18n.baseText('agents.channels.teams.setup.availability.groupChats')"
|
||||
data-testid="teams-scope-groups"
|
||||
@update:model-value="set({ groupChats: $event })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</N8nCollapsiblePanel>
|
||||
|
||||
<N8nCollapsiblePanel v-model="readingOpen" :class="$style.panel">
|
||||
<template #title>
|
||||
<span :class="$style.panelTitle">
|
||||
<N8nText size="small" bold>
|
||||
{{ i18n.baseText('agents.channels.teams.setup.availability.readingTitle') }}
|
||||
</N8nText>
|
||||
<N8nText size="small" :class="$style.hint" data-testid="teams-reading-summary">
|
||||
{{ readingSummary }}
|
||||
</N8nText>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div :class="$style.panelBody">
|
||||
<N8nText size="small" :class="$style.hint">
|
||||
{{ i18n.baseText('agents.channels.teams.setup.availability.readingNote') }}
|
||||
</N8nText>
|
||||
|
||||
<div :class="$style.row">
|
||||
<div :class="$style.rowText">
|
||||
<N8nText size="small" :class="{ [$style.hint]: !value.teamChannels }">
|
||||
{{ i18n.baseText('agents.channels.teams.setup.availability.readAllChannelMessages') }}
|
||||
</N8nText>
|
||||
<N8nText size="small" :class="$style.hint">
|
||||
{{
|
||||
i18n.baseText('agents.channels.teams.setup.availability.readAllChannelMessagesHint')
|
||||
}}
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nSwitch2
|
||||
:model-value="value.readAllChannelMessages"
|
||||
:disabled="!value.teamChannels"
|
||||
:aria-label="
|
||||
i18n.baseText('agents.channels.teams.setup.availability.readAllChannelMessages')
|
||||
"
|
||||
data-testid="teams-read-channels"
|
||||
@update:model-value="set({ readAllChannelMessages: $event })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="$style.row">
|
||||
<div :class="$style.rowText">
|
||||
<N8nText size="small" :class="{ [$style.hint]: !value.groupChats }">
|
||||
{{ i18n.baseText('agents.channels.teams.setup.availability.readAllGroupMessages') }}
|
||||
</N8nText>
|
||||
<N8nText size="small" :class="$style.hint">
|
||||
{{
|
||||
i18n.baseText('agents.channels.teams.setup.availability.readAllGroupMessagesHint')
|
||||
}}
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nSwitch2
|
||||
:model-value="value.readAllGroupMessages"
|
||||
:disabled="!value.groupChats"
|
||||
:aria-label="
|
||||
i18n.baseText('agents.channels.teams.setup.availability.readAllGroupMessages')
|
||||
"
|
||||
data-testid="teams-read-groups"
|
||||
@update:model-value="set({ readAllGroupMessages: $event })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</N8nCollapsiblePanel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.panels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--2xs);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.panel {
|
||||
width: 100%;
|
||||
border: var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.panelTitle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--5xs);
|
||||
text-align: left;
|
||||
/* Air between the chevron and the heading. */
|
||||
padding-left: var(--spacing--3xs);
|
||||
}
|
||||
|
||||
/* One left edge for every row and note, matching the header's own padding. */
|
||||
.panelBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 var(--spacing--xs) var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing--sm);
|
||||
padding: var(--spacing--2xs) 0;
|
||||
}
|
||||
|
||||
.rowText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--5xs);
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-color--subtler);
|
||||
}
|
||||
|
||||
.fixed {
|
||||
color: var(--text-color--subtler);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import AgentChannelTeamsSetup from './AgentChannelTeamsSetup.vue';
|
||||
import AgentChannelStandardEditView from '../AgentChannelStandardEditView.vue';
|
||||
import type { AgentChannelViewExpose, AgentChannelViewProps } from '../types';
|
||||
|
||||
const credentialId = defineModel<string>({ default: '' });
|
||||
defineProps<AgentChannelViewProps>();
|
||||
const emit = defineEmits<{
|
||||
create: [];
|
||||
edit: [];
|
||||
}>();
|
||||
const viewRef = ref<AgentChannelViewExpose>();
|
||||
const currentSettings = computed(() => viewRef.value?.currentSettings);
|
||||
const validationError = computed(() => viewRef.value?.validationError ?? null);
|
||||
|
||||
defineExpose({ currentSettings, validationError });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AgentChannelStandardEditView
|
||||
ref="viewRef"
|
||||
v-bind="$props"
|
||||
v-model="credentialId"
|
||||
:details-component="AgentChannelTeamsSetup"
|
||||
@create="emit('create')"
|
||||
@edit="emit('edit')"
|
||||
/>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,634 @@
|
|||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { configure, fireEvent, waitFor } from '@testing-library/vue';
|
||||
|
||||
import AgentChannelTeamsSetup from './AgentChannelTeamsSetup.vue';
|
||||
import type { TeamsCredentialCheck } from '@n8n/api-types';
|
||||
|
||||
import { checkTeamsCredential, fetchTeamsAppPackage, getTeamsSetupState } from './api';
|
||||
|
||||
vi.mock('@n8n/i18n', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
useI18n: () => ({
|
||||
// Keeps interpolated values assertable, since the key stands in for the copy.
|
||||
baseText: (key: string, options?: { interpolate?: Record<string, string> }) =>
|
||||
options?.interpolate ? `${key} ${Object.values(options.interpolate).join(' ')}` : key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('file-saver', () => ({
|
||||
saveAs: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./api', () => ({
|
||||
checkTeamsCredential: vi.fn(),
|
||||
getTeamsSetupState: vi.fn(),
|
||||
fetchTeamsAppPackage: vi.fn(),
|
||||
}));
|
||||
|
||||
// The shared default is `data-test-id`; these components use `data-testid`.
|
||||
configure({ testIdAttribute: 'data-testid' });
|
||||
|
||||
// N8nStepper renders every step's slot, so one render covers the whole stepper.
|
||||
const ENDPOINT = 'https://n8n.example.com/rest/projects/p/agents/v2/a/webhooks/teams';
|
||||
const DEPLOY_URL = 'https://portal.azure.com/#create/Microsoft.Template/uri/encoded';
|
||||
const DEFAULT_NAME = 'Support Bot';
|
||||
const DEFAULT_DESCRIPTION = 'Chat with Support Bot, an agent powered by n8n.';
|
||||
const CLIENT_ID = '11111111-2222-3333-4444-555555555555';
|
||||
|
||||
const renderComponent = createComponentRenderer(AgentChannelTeamsSetup);
|
||||
|
||||
// N8nSwitch2 is a Reka UI switch: a button with aria-checked, not an input.
|
||||
const checkedSwitch = (el: HTMLElement) => el.getAttribute('aria-checked') === 'true';
|
||||
|
||||
const props = (overrides: Record<string, unknown> = {}) => ({
|
||||
mode: 'setup' as const,
|
||||
modelValue: '',
|
||||
integration: {
|
||||
type: 'teams',
|
||||
label: 'Microsoft Teams',
|
||||
icon: 'teams',
|
||||
credentialTypes: ['microsoftEntraServicePrincipalApi'],
|
||||
},
|
||||
credentials: [],
|
||||
credentialPermissions: { create: true },
|
||||
agentName: 'Agent',
|
||||
projectId: 'p',
|
||||
agentId: 'a',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('AgentChannelTeamsSetup', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
createTestingPinia({ stubActions: false });
|
||||
vi.mocked(getTeamsSetupState).mockResolvedValue({
|
||||
messagingEndpointUrl: ENDPOINT,
|
||||
botId: null,
|
||||
deployToAzureUrl: DEPLOY_URL,
|
||||
credentialClaimedBy: null,
|
||||
defaultDisplayName: DEFAULT_NAME,
|
||||
defaultDescription: DEFAULT_DESCRIPTION,
|
||||
});
|
||||
vi.mocked(fetchTeamsAppPackage).mockResolvedValue(new Blob(['zip']));
|
||||
vi.mocked(checkTeamsCredential).mockResolvedValue({ status: 'ok' });
|
||||
});
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('step 1, the credential', () => {
|
||||
it('leads with the Entra registration the rest is built from', async () => {
|
||||
const { getByTestId } = renderComponent({ props: props() });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-entra-register-link')).toBeVisible());
|
||||
});
|
||||
|
||||
it('says up front what account and permissions are needed', async () => {
|
||||
const { getByTestId } = renderComponent({ props: props() });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-create-bot-prerequisites')).toBeVisible());
|
||||
});
|
||||
|
||||
it('comes before the deployment, which cannot be filled in without it', async () => {
|
||||
const { container } = renderComponent({ props: props() });
|
||||
|
||||
await waitFor(() => expect(container.textContent).toContain('setup.createCredential.title'));
|
||||
const text = container.textContent ?? '';
|
||||
expect(text.indexOf('setup.createCredential.title')).toBeLessThan(
|
||||
text.indexOf('setup.createBot.title'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('step 2, deploy the bot', () => {
|
||||
it('keeps the endpoint URL out of the way, since the deployment sets it', async () => {
|
||||
const { getByTestId, container } = renderComponent({ props: props() });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-show-endpoint')).toBeVisible());
|
||||
expect(container.querySelector('#teams-messaging-endpoint-url')).toBeNull();
|
||||
});
|
||||
|
||||
it('reveals the endpoint URL for someone wiring up a bot they already have', async () => {
|
||||
const { getByTestId, container } = renderComponent({ props: props() });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-show-endpoint')).toBeVisible());
|
||||
await fireEvent.click(getByTestId('teams-show-endpoint'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('#teams-messaging-endpoint-url')).toHaveValue(ENDPOINT);
|
||||
});
|
||||
});
|
||||
|
||||
it('withholds the deployment until a credential supplies the Entra IDs', async () => {
|
||||
vi.mocked(getTeamsSetupState).mockResolvedValue({
|
||||
messagingEndpointUrl: ENDPOINT,
|
||||
botId: null,
|
||||
deployToAzureUrl: null,
|
||||
credentialClaimedBy: null,
|
||||
defaultDisplayName: DEFAULT_NAME,
|
||||
defaultDescription: DEFAULT_DESCRIPTION,
|
||||
});
|
||||
|
||||
const { getByTestId, queryByTestId } = renderComponent({ props: props() });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-deploy-blocked')).toBeVisible());
|
||||
expect(queryByTestId('teams-deploy-to-azure')).toBeNull();
|
||||
});
|
||||
|
||||
it('offers the deployment once the credential is picked', async () => {
|
||||
const { getByTestId } = renderComponent({ props: props({ modelValue: 'cred-1' }) });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('teams-deploy-to-azure')).toHaveAttribute('href', DEPLOY_URL);
|
||||
});
|
||||
});
|
||||
|
||||
it('rebuilds the deployment when the credential changes, since it is baked in', async () => {
|
||||
const { rerender } = renderComponent({ props: props() });
|
||||
await waitFor(() => expect(getTeamsSetupState).toHaveBeenCalled());
|
||||
vi.mocked(getTeamsSetupState).mockClear();
|
||||
|
||||
await rerender(props({ modelValue: 'cred-2' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getTeamsSetupState).toHaveBeenCalledWith(expect.anything(), 'p', 'a', 'cred-2'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('step 3, availability', () => {
|
||||
it('shows direct chat as fixed, with no control to change it', async () => {
|
||||
const { getByTestId } = renderComponent({ props: props() });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-scope-direct')).toBeVisible());
|
||||
expect(getByTestId('teams-scope-direct').querySelector('[role="switch"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('starts with everything else off', async () => {
|
||||
const { getByTestId } = renderComponent({ props: props() });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-scope-channels')).toBeVisible());
|
||||
expect(checkedSwitch(getByTestId('teams-scope-channels'))).toBe(false);
|
||||
expect(checkedSwitch(getByTestId('teams-scope-groups'))).toBe(false);
|
||||
});
|
||||
|
||||
it('summarises each panel, so a collapsed one still says what it is set to', async () => {
|
||||
const { getByTestId } = renderComponent({ props: props() });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-where-summary')).toBeVisible());
|
||||
expect(getByTestId('teams-where-summary').textContent).toContain(
|
||||
'setup.availability.directChat',
|
||||
);
|
||||
expect(getByTestId('teams-reading-summary').textContent).toContain(
|
||||
'setup.availability.readingSummaryNone',
|
||||
);
|
||||
|
||||
await fireEvent.click(getByTestId('teams-scope-channels'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByTestId('teams-where-summary').textContent).toContain(
|
||||
'setup.availability.teamChannels',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('collapses an open panel when its header is clicked', async () => {
|
||||
const { getByTestId } = renderComponent({ props: props() });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-scope-channels')).toBeVisible());
|
||||
await fireEvent.click(getByTestId('teams-where-summary'));
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-scope-channels')).not.toBeVisible());
|
||||
});
|
||||
|
||||
it('keeps a read permission locked until its surface is on', async () => {
|
||||
const { getByTestId } = renderComponent({ props: props() });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-read-channels')).toBeVisible());
|
||||
expect(getByTestId('teams-read-channels')).toBeDisabled();
|
||||
|
||||
await fireEvent.click(getByTestId('teams-scope-channels'));
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-read-channels')).not.toBeDisabled());
|
||||
});
|
||||
|
||||
it('clears a read permission when its surface is turned back off', async () => {
|
||||
const { getByTestId } = renderComponent({ props: props() });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-scope-channels')).toBeVisible());
|
||||
await fireEvent.click(getByTestId('teams-scope-channels'));
|
||||
await waitFor(() => expect(getByTestId('teams-read-channels')).not.toBeDisabled());
|
||||
await fireEvent.click(getByTestId('teams-read-channels'));
|
||||
await waitFor(() => expect(checkedSwitch(getByTestId('teams-read-channels'))).toBe(true));
|
||||
|
||||
await fireEvent.click(getByTestId('teams-scope-channels'));
|
||||
|
||||
await waitFor(() => expect(checkedSwitch(getByTestId('teams-read-channels'))).toBe(false));
|
||||
});
|
||||
|
||||
it('restores saved settings', async () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
props: props({ savedSettings: { teamChannels: true, readAllChannelMessages: true } }),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(checkedSwitch(getByTestId('teams-scope-channels'))).toBe(true));
|
||||
expect(checkedSwitch(getByTestId('teams-read-channels'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('step 4, install', () => {
|
||||
it('withholds the package until a credential is picked', async () => {
|
||||
const { getByTestId, queryByTestId } = renderComponent({ props: props() });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-package-blocked')).toBeVisible());
|
||||
expect(queryByTestId('teams-download-package')).toBeNull();
|
||||
});
|
||||
|
||||
it('fetches the package rather than linking to it, so the session survives', async () => {
|
||||
vi.mocked(getTeamsSetupState).mockResolvedValue({
|
||||
messagingEndpointUrl: ENDPOINT,
|
||||
botId: CLIENT_ID,
|
||||
deployToAzureUrl: DEPLOY_URL,
|
||||
credentialClaimedBy: null,
|
||||
defaultDisplayName: DEFAULT_NAME,
|
||||
defaultDescription: DEFAULT_DESCRIPTION,
|
||||
});
|
||||
|
||||
const { getByTestId } = renderComponent({ props: props({ connected: true }) });
|
||||
await waitFor(() => expect(getByTestId('teams-download-package')).toBeVisible());
|
||||
expect(getByTestId('teams-download-package')).not.toHaveAttribute('href');
|
||||
|
||||
await fireEvent.click(getByTestId('teams-download-package'));
|
||||
|
||||
await waitFor(() => expect(fetchTeamsAppPackage).toHaveBeenCalled());
|
||||
expect(vi.mocked(fetchTeamsAppPackage).mock.calls[0]?.slice(1, 3)).toEqual(['p', 'a']);
|
||||
});
|
||||
|
||||
it('reports a failed download instead of silently doing nothing', async () => {
|
||||
vi.mocked(getTeamsSetupState).mockResolvedValue({
|
||||
messagingEndpointUrl: ENDPOINT,
|
||||
botId: CLIENT_ID,
|
||||
deployToAzureUrl: DEPLOY_URL,
|
||||
credentialClaimedBy: null,
|
||||
defaultDisplayName: DEFAULT_NAME,
|
||||
defaultDescription: DEFAULT_DESCRIPTION,
|
||||
});
|
||||
vi.mocked(fetchTeamsAppPackage).mockRejectedValue(new Error('401'));
|
||||
|
||||
const { getByTestId } = renderComponent({ props: props({ connected: true }) });
|
||||
await waitFor(() => expect(getByTestId('teams-download-package')).toBeVisible());
|
||||
await fireEvent.click(getByTestId('teams-download-package'));
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-download-error')).toBeVisible());
|
||||
});
|
||||
|
||||
it('keeps connecting locked until the credential reaches Microsoft', async () => {
|
||||
vi.mocked(checkTeamsCredential).mockResolvedValue({ status: 'failed', reason: 'rejected' });
|
||||
|
||||
const { getByTestId } = renderComponent({ props: props({ modelValue: 'cred-1' }) });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-credential-problem')).toBeVisible());
|
||||
expect(getByTestId('teams-connect')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('unlocks saving once the credential checks out, without announcing it', async () => {
|
||||
const { getByTestId, queryByTestId } = renderComponent({
|
||||
props: props({ modelValue: 'cred-1' }),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-connect')).not.toBeDisabled());
|
||||
// An enabled button says it already; a success line would only repeat it.
|
||||
expect(queryByTestId('teams-credential-problem')).toBeNull();
|
||||
expect(queryByTestId('teams-connect-blocked')).toBeNull();
|
||||
});
|
||||
|
||||
it('offers a retry when the check failed', async () => {
|
||||
vi.mocked(checkTeamsCredential).mockResolvedValue({
|
||||
status: 'failed',
|
||||
reason: 'unreachable',
|
||||
});
|
||||
|
||||
const { getByTestId } = renderComponent({ props: props({ modelValue: 'cred-1' }) });
|
||||
await waitFor(() => expect(getByTestId('teams-credential-recheck')).toBeVisible());
|
||||
|
||||
vi.mocked(checkTeamsCredential).mockClear();
|
||||
await fireEvent.click(getByTestId('teams-credential-recheck'));
|
||||
|
||||
await waitFor(() => expect(checkTeamsCredential).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('keeps connecting for last, because the modal closes on it', async () => {
|
||||
const { getByTestId, emitted } = renderComponent({ props: props({ modelValue: 'cred-1' }) });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-connect')).toBeVisible());
|
||||
await fireEvent.click(getByTestId('teams-connect'));
|
||||
|
||||
expect(emitted().connect).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('settings', () => {
|
||||
const settingsProps = (overrides: Record<string, unknown> = {}) =>
|
||||
props({ mode: 'edit', connected: true, modelValue: 'cred-1', ...overrides });
|
||||
|
||||
it('says that changes here mean downloading the package again', async () => {
|
||||
const { getByTestId } = renderComponent({ props: settingsProps() });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-update-notice')).toBeVisible());
|
||||
});
|
||||
|
||||
it('lets the app name and description be changed after setup', async () => {
|
||||
const { getByTestId } = renderComponent({ props: settingsProps() });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-display-name')).toBeVisible());
|
||||
expect(getByTestId('teams-description')).toBeVisible();
|
||||
});
|
||||
|
||||
it('starts the availability panels collapsed, summarised', async () => {
|
||||
const { getByTestId } = renderComponent({ props: settingsProps() });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-where-summary')).toBeVisible());
|
||||
// Rendered but folded away, so settings opens on the summary rather than
|
||||
// on five controls.
|
||||
expect(getByTestId('teams-scope-channels')).not.toBeVisible();
|
||||
expect(getByTestId('teams-reading-summary')).toBeVisible();
|
||||
});
|
||||
|
||||
it('opens a collapsed panel when its header is clicked', async () => {
|
||||
const { getByTestId } = renderComponent({ props: settingsProps() });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-where-summary')).toBeVisible());
|
||||
await fireEvent.click(getByTestId('teams-where-summary'));
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-scope-channels')).toBeVisible());
|
||||
});
|
||||
|
||||
it('shows what the manifest would use as a placeholder, not as a value', async () => {
|
||||
const { getByTestId } = renderComponent({ props: settingsProps() });
|
||||
|
||||
const name = () => getByTestId('teams-display-name').querySelector('input');
|
||||
await waitFor(() => expect(name()).toHaveAttribute('placeholder', DEFAULT_NAME));
|
||||
|
||||
// Filling them in would save them as overrides, and the Teams app would
|
||||
// then keep the old name after the agent is renamed.
|
||||
expect(name()).toHaveValue('');
|
||||
expect(getByTestId('teams-description').querySelector('input')).toHaveAttribute(
|
||||
'placeholder',
|
||||
DEFAULT_DESCRIPTION,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a saved override rather than replacing it with the default', async () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
props: settingsProps({ savedSettings: { displayName: 'Helpdesk' } }),
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByTestId('teams-display-name').querySelector('input')).toHaveValue('Helpdesk'),
|
||||
);
|
||||
// The description had no override, so it keeps following the agent.
|
||||
expect(getByTestId('teams-description').querySelector('input')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('restores saved settings, including the app identity', async () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
props: settingsProps({
|
||||
savedSettings: {
|
||||
displayName: 'Support',
|
||||
description: 'Answers questions',
|
||||
teamChannels: true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByTestId('teams-display-name').querySelector('input')).toHaveValue('Support'),
|
||||
);
|
||||
expect(getByTestId('teams-description').querySelector('input')).toHaveValue(
|
||||
'Answers questions',
|
||||
);
|
||||
// Mounted while collapsed, so its state is readable without expanding.
|
||||
expect(checkedSwitch(getByTestId('teams-scope-channels'))).toBe(true);
|
||||
});
|
||||
|
||||
it('offers the package again, so a change can be applied', async () => {
|
||||
vi.mocked(getTeamsSetupState).mockResolvedValue({
|
||||
messagingEndpointUrl: ENDPOINT,
|
||||
botId: CLIENT_ID,
|
||||
deployToAzureUrl: DEPLOY_URL,
|
||||
credentialClaimedBy: null,
|
||||
defaultDisplayName: DEFAULT_NAME,
|
||||
defaultDescription: DEFAULT_DESCRIPTION,
|
||||
});
|
||||
|
||||
const { getByTestId } = renderComponent({ props: settingsProps() });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-download-package')).toBeVisible());
|
||||
});
|
||||
|
||||
it('keeps the endpoint URL tucked away here too', async () => {
|
||||
const { getByTestId, container } = renderComponent({ props: settingsProps() });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-show-endpoint')).toBeVisible());
|
||||
expect(container.querySelector('#teams-messaging-endpoint-url')).toBeNull();
|
||||
|
||||
await fireEvent.click(getByTestId('teams-show-endpoint'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(container.querySelector('#teams-messaging-endpoint-url')).toHaveValue(ENDPOINT),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not spend a Microsoft token request on a check nothing here reads', async () => {
|
||||
renderComponent({ props: settingsProps() });
|
||||
|
||||
await waitFor(() => expect(getTeamsSetupState).toHaveBeenCalled());
|
||||
expect(checkTeamsCredential).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows nothing from the setup steps', async () => {
|
||||
const { queryByTestId } = renderComponent({ props: settingsProps() });
|
||||
|
||||
await waitFor(() => expect(queryByTestId('teams-deploy-to-azure')).toBeNull());
|
||||
expect(queryByTestId('teams-entra-register-link')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('downloads the package with the availability chosen in the stepper, not the stored one', async () => {
|
||||
vi.mocked(getTeamsSetupState).mockResolvedValue({
|
||||
messagingEndpointUrl: ENDPOINT,
|
||||
botId: CLIENT_ID,
|
||||
deployToAzureUrl: DEPLOY_URL,
|
||||
credentialClaimedBy: null,
|
||||
defaultDisplayName: DEFAULT_NAME,
|
||||
defaultDescription: DEFAULT_DESCRIPTION,
|
||||
});
|
||||
|
||||
const { getByTestId } = renderComponent({ props: props({ modelValue: 'cred-1' }) });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-download-package')).toBeVisible());
|
||||
await fireEvent.click(getByTestId('teams-scope-channels'));
|
||||
await fireEvent.click(getByTestId('teams-download-package'));
|
||||
|
||||
// Nothing is stored until connect, which happens after this download.
|
||||
await waitFor(() =>
|
||||
expect(fetchTeamsAppPackage).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'p',
|
||||
'a',
|
||||
'cred-1',
|
||||
expect.objectContaining({ teamChannels: true }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
describe('a credential another agent already uses', () => {
|
||||
const claimed = () =>
|
||||
vi.mocked(getTeamsSetupState).mockResolvedValue({
|
||||
messagingEndpointUrl: ENDPOINT,
|
||||
botId: CLIENT_ID,
|
||||
// The server withholds the deployment for the same reason.
|
||||
deployToAzureUrl: null,
|
||||
credentialClaimedBy: 'Sales Bot',
|
||||
defaultDisplayName: DEFAULT_NAME,
|
||||
defaultDescription: DEFAULT_DESCRIPTION,
|
||||
});
|
||||
|
||||
it('says so on the step where the credential is chosen', async () => {
|
||||
claimed();
|
||||
|
||||
const { getByTestId } = renderComponent({ props: props({ modelValue: 'cred-1' }) });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByTestId('teams-credential-claimed')).toHaveTextContent('Sales Bot'),
|
||||
);
|
||||
});
|
||||
|
||||
it('offers neither the deployment nor the package', async () => {
|
||||
claimed();
|
||||
|
||||
const { getByTestId, queryByTestId } = renderComponent({
|
||||
props: props({ modelValue: 'cred-1' }),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-credential-claimed')).toBeVisible());
|
||||
expect(queryByTestId('teams-deploy-to-azure')).toBeNull();
|
||||
expect(queryByTestId('teams-download-package')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps connect disabled even though the credential itself works', async () => {
|
||||
claimed();
|
||||
|
||||
const { getByTestId } = renderComponent({ props: props({ modelValue: 'cred-1' }) });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-credential-claimed')).toBeVisible());
|
||||
expect(getByTestId('teams-connect')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the selected credential changes', () => {
|
||||
it('drops the previous verification, so Save cannot use it', async () => {
|
||||
let release: ((value: TeamsCredentialCheck) => void) | undefined;
|
||||
vi.mocked(checkTeamsCredential)
|
||||
.mockResolvedValueOnce({ status: 'ok' })
|
||||
.mockImplementationOnce(async () => await new Promise((resolve) => (release = resolve)));
|
||||
|
||||
const { getByTestId, rerender } = renderComponent({
|
||||
props: props({ modelValue: 'cred-1' }),
|
||||
});
|
||||
await waitFor(() => expect(getByTestId('teams-connect')).toBeEnabled());
|
||||
|
||||
await rerender(props({ modelValue: 'cred-2' }));
|
||||
|
||||
// The second check has not answered yet, so nothing is verified.
|
||||
await waitFor(() => expect(getByTestId('teams-connect')).toBeDisabled());
|
||||
release?.({ status: 'ok' });
|
||||
});
|
||||
|
||||
it('ignores an answer for the credential that is no longer selected', async () => {
|
||||
let releaseFirst: ((value: TeamsCredentialCheck) => void) | undefined;
|
||||
vi.mocked(checkTeamsCredential)
|
||||
.mockImplementationOnce(
|
||||
async () => await new Promise((resolve) => (releaseFirst = resolve)),
|
||||
)
|
||||
.mockResolvedValueOnce({ status: 'failed', reason: 'rejected' });
|
||||
|
||||
const { getByTestId, rerender } = renderComponent({
|
||||
props: props({ modelValue: 'cred-1' }),
|
||||
});
|
||||
await rerender(props({ modelValue: 'cred-2' }));
|
||||
await waitFor(() => expect(getByTestId('teams-credential-problem')).toBeVisible());
|
||||
|
||||
// The first credential's answer lands last and must be discarded.
|
||||
releaseFirst?.({ status: 'ok' });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-connect')).toBeDisabled());
|
||||
});
|
||||
});
|
||||
|
||||
it('adopts saved settings that arrive after the view is rendered', async () => {
|
||||
const { getByTestId, rerender } = renderComponent({ props: props({ mode: 'edit' }) });
|
||||
|
||||
// The panels start collapsed here, so the switch is present but not shown.
|
||||
await waitFor(() => expect(getByTestId('teams-scope-channels')).toBeInTheDocument());
|
||||
expect(checkedSwitch(getByTestId('teams-scope-channels'))).toBe(false);
|
||||
|
||||
await rerender(props({ mode: 'edit', savedSettings: { teamChannels: true } }));
|
||||
|
||||
await waitFor(() => expect(checkedSwitch(getByTestId('teams-scope-channels'))).toBe(true));
|
||||
});
|
||||
|
||||
it('keeps an edit when saved settings arrive afterwards', async () => {
|
||||
const { getByTestId, rerender } = renderComponent({ props: props({ mode: 'edit' }) });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-scope-groups')).toBeInTheDocument());
|
||||
await fireEvent.click(getByTestId('teams-scope-groups'));
|
||||
|
||||
await rerender(props({ mode: 'edit', savedSettings: { teamChannels: true } }));
|
||||
|
||||
// The late arrival must not undo what the user just turned on.
|
||||
await waitFor(() => expect(checkedSwitch(getByTestId('teams-scope-groups'))).toBe(true));
|
||||
});
|
||||
|
||||
it('adopts saved values for the fields the user has not touched', async () => {
|
||||
const { getByTestId, rerender } = renderComponent({ props: props({ mode: 'edit' }) });
|
||||
|
||||
const name = () => getByTestId('teams-display-name').querySelector('input');
|
||||
await waitFor(() => expect(name()).toBeInTheDocument());
|
||||
await fireEvent.update(name() as HTMLInputElement, 'My own name');
|
||||
|
||||
await rerender(
|
||||
props({
|
||||
mode: 'edit',
|
||||
savedSettings: { displayName: 'Saved name', teamChannels: true },
|
||||
}),
|
||||
);
|
||||
|
||||
// Editing the name must not stop the availability adopting what arrived.
|
||||
await waitFor(() => expect(checkedSwitch(getByTestId('teams-scope-channels'))).toBe(true));
|
||||
expect(name()).toHaveValue('My own name');
|
||||
});
|
||||
|
||||
it('asks for the setup state once on open, not once per trigger that wants it', async () => {
|
||||
renderComponent({ props: props() });
|
||||
|
||||
await waitFor(() => expect(getTeamsSetupState).toHaveBeenCalled());
|
||||
expect(getTeamsSetupState).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('still shows the endpoint URL when the setup state cannot be loaded', async () => {
|
||||
vi.mocked(getTeamsSetupState).mockRejectedValue(new Error('offline'));
|
||||
|
||||
const { getByTestId, container } = renderComponent({ props: props() });
|
||||
|
||||
await waitFor(() => expect(getByTestId('teams-show-endpoint')).toBeVisible());
|
||||
await fireEvent.click(getByTestId('teams-show-endpoint'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('#teams-messaging-endpoint-url')).toHaveValue(
|
||||
'http://localhost:5678/rest/projects/p/agents/v2/a/webhooks/teams',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,632 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { saveAs } from 'file-saver';
|
||||
import { N8nButton, N8nCopyInput, N8nInput, N8nStepper, N8nText } from '@n8n/design-system';
|
||||
import { TEAMS_DESCRIPTION_MAX, TEAMS_DISPLAY_NAME_MAX } from '@n8n/api-types';
|
||||
import type {
|
||||
AgentTeamsIntegrationSettings,
|
||||
ChatIntegrationDescriptor,
|
||||
TeamsAgentSetupState,
|
||||
TeamsCredentialCheck,
|
||||
} from '@n8n/api-types';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import type { PermissionsRecord } from '@n8n/permissions';
|
||||
import AgentIntegrationCredentialConnection from '../../components/AgentIntegrationCredentialConnection.vue';
|
||||
import type { AgentCredentialOption } from '../../components/AgentCredentialSelect.vue';
|
||||
import AgentChannelTeamsAvailability, {
|
||||
type TeamsAvailability,
|
||||
} from './AgentChannelTeamsAvailability.vue';
|
||||
import { checkTeamsCredential, fetchTeamsAppPackage, getTeamsSetupState } from './api';
|
||||
|
||||
const credentialId = defineModel<string>({ default: '' });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
mode: 'setup' | 'edit';
|
||||
integration: ChatIntegrationDescriptor;
|
||||
credentials: AgentCredentialOption[];
|
||||
credentialPermissions: PermissionsRecord['credential'];
|
||||
credentialsLoading?: boolean;
|
||||
loading?: boolean;
|
||||
connected?: boolean;
|
||||
isPublished?: boolean;
|
||||
errorMessage?: string;
|
||||
errorIsConflict?: boolean;
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
forceNewCredential?: boolean;
|
||||
savedSettings?: AgentTeamsIntegrationSettings;
|
||||
}>(),
|
||||
{
|
||||
credentialsLoading: false,
|
||||
loading: false,
|
||||
connected: false,
|
||||
isPublished: true,
|
||||
errorMessage: '',
|
||||
errorIsConflict: false,
|
||||
forceNewCredential: false,
|
||||
savedSettings: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
create: [];
|
||||
edit: [];
|
||||
connect: [];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const rootStore = useRootStore();
|
||||
|
||||
const ENTRA_APP_REGISTRATION_URL =
|
||||
'https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/CreateApplicationBlade';
|
||||
|
||||
const setupState = ref<TeamsAgentSetupState | null>(null);
|
||||
const showEndpoint = ref(false);
|
||||
|
||||
const availability = ref<TeamsAvailability>({
|
||||
teamChannels: props.savedSettings?.teamChannels ?? false,
|
||||
groupChats: props.savedSettings?.groupChats ?? false,
|
||||
readAllChannelMessages: props.savedSettings?.readAllChannelMessages ?? false,
|
||||
readAllGroupMessages: props.savedSettings?.readAllGroupMessages ?? false,
|
||||
});
|
||||
|
||||
const displayName = ref(props.savedSettings?.displayName ?? '');
|
||||
const description = ref(props.savedSettings?.description ?? '');
|
||||
|
||||
/**
|
||||
* Shown as placeholders rather than written into the fields. Filling them in
|
||||
* would save them as overrides on the first connect, and the Teams app would
|
||||
* then keep the agent's old name after a rename.
|
||||
*/
|
||||
const defaultDisplayName = computed(() => setupState.value?.defaultDisplayName ?? '');
|
||||
const defaultDescription = computed(() => setupState.value?.defaultDescription ?? '');
|
||||
|
||||
const messagingEndpointUrl = computed(() => {
|
||||
if (setupState.value) return setupState.value.messagingEndpointUrl;
|
||||
// The same shape the backend builds, so the URL still shows when the setup
|
||||
// request fails -- which is when someone most needs to paste it by hand.
|
||||
const base = rootStore.urlBaseWebhook.replace(/\/$/, '');
|
||||
return `${base}/rest/projects/${props.projectId}/agents/v2/${props.agentId}/webhooks/teams`;
|
||||
});
|
||||
|
||||
/**
|
||||
* Surfaced here rather than at connect, which is three steps later: by then the
|
||||
* user has already spent an Azure deployment that the portal refuses, and reads
|
||||
* Azure's wording for a choice they could still change on this step.
|
||||
*/
|
||||
const credentialClaimedBy = computed(() => setupState.value?.credentialClaimedBy ?? null);
|
||||
|
||||
const downloading = ref(false);
|
||||
const downloadError = ref('');
|
||||
// The manifest needs the bot's client ID, which the picked credential supplies
|
||||
// before it is connected, so the step does not wait on connecting.
|
||||
const canDownloadPackage = computed(
|
||||
() => Boolean(setupState.value?.botId) && !credentialClaimedBy.value,
|
||||
);
|
||||
|
||||
const credentialCheck = ref<TeamsCredentialCheck | null>(null);
|
||||
const checking = ref(false);
|
||||
|
||||
/**
|
||||
* Both requests answer for the credential that was selected when they were
|
||||
* sent. Without this, switching credentials quickly lets an earlier answer
|
||||
* land last and describe the wrong one -- as a verified credential, or as a
|
||||
* deployment link for the credential no longer selected.
|
||||
*/
|
||||
let latestRequest = 0;
|
||||
|
||||
/**
|
||||
* Tracked per field, so an edit to one does not stop the others adopting
|
||||
* settings that arrive afterwards.
|
||||
*/
|
||||
const touched = ref(new Set<'availability' | 'displayName' | 'description'>());
|
||||
|
||||
function editAvailability(value: TeamsAvailability) {
|
||||
availability.value = value;
|
||||
touched.value.add('availability');
|
||||
}
|
||||
|
||||
function editDisplayName(value: string) {
|
||||
displayName.value = value;
|
||||
touched.value.add('displayName');
|
||||
}
|
||||
|
||||
function editDescription(value: string) {
|
||||
description.value = value;
|
||||
touched.value.add('description');
|
||||
}
|
||||
|
||||
/**
|
||||
* Saving is gated on the credential actually reaching Microsoft. Without this
|
||||
* the channel connects on a wrong secret and fails on the first message, long
|
||||
* after the setup said it succeeded.
|
||||
*/
|
||||
const credentialVerified = computed(() => credentialCheck.value?.status === 'ok');
|
||||
const credentialProblem = computed(() =>
|
||||
credentialCheck.value?.status === 'failed' ? credentialCheck.value.reason : null,
|
||||
);
|
||||
|
||||
async function runCredentialCheck(request = ++latestRequest) {
|
||||
const id = credentialId.value;
|
||||
// A result for the credential just replaced says nothing about this one, so
|
||||
// it goes before the new answer arrives rather than after.
|
||||
credentialCheck.value = null;
|
||||
// Only the setup step reads the result, and the check costs a token request
|
||||
// to Microsoft, so the settings view does not pay for it.
|
||||
if (props.mode !== 'setup' || !id) return;
|
||||
|
||||
checking.value = true;
|
||||
try {
|
||||
const result = await checkTeamsCredential(
|
||||
rootStore.restApiContext,
|
||||
props.projectId,
|
||||
props.agentId,
|
||||
id,
|
||||
);
|
||||
if (request === latestRequest) credentialCheck.value = result;
|
||||
} catch {
|
||||
if (request === latestRequest)
|
||||
credentialCheck.value = { status: 'failed', reason: 'unreachable' };
|
||||
} finally {
|
||||
if (request === latestRequest) checking.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadPackage() {
|
||||
downloading.value = true;
|
||||
downloadError.value = '';
|
||||
try {
|
||||
const blob = await fetchTeamsAppPackage(
|
||||
rootStore.restApiContext,
|
||||
props.projectId,
|
||||
props.agentId,
|
||||
credentialId.value || undefined,
|
||||
currentSettings.value,
|
||||
);
|
||||
saveAs(blob, 'n8n-agent-teams-app.zip');
|
||||
} catch {
|
||||
downloadError.value = i18n.baseText('agents.channels.teams.setup.install.downloadFailed');
|
||||
} finally {
|
||||
downloading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSetupState(request = ++latestRequest) {
|
||||
if (!props.projectId || !props.agentId) return;
|
||||
try {
|
||||
const state = await getTeamsSetupState(
|
||||
rootStore.restApiContext,
|
||||
props.projectId,
|
||||
props.agentId,
|
||||
credentialId.value || undefined,
|
||||
);
|
||||
if (request === latestRequest) setupState.value = state;
|
||||
} catch {
|
||||
if (request === latestRequest) setupState.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.connected,
|
||||
() => loadSetupState(),
|
||||
);
|
||||
|
||||
watch(
|
||||
credentialId,
|
||||
async () => {
|
||||
const request = ++latestRequest;
|
||||
await Promise.all([runCredentialCheck(request), loadSetupState(request)]);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
/**
|
||||
* Re-synced rather than read once, so settings that arrive after this mounts
|
||||
* are not overwritten by the empty defaults the refs started with. Edits
|
||||
* already made here win: only an untouched field follows the saved value.
|
||||
*/
|
||||
watch(
|
||||
() => props.savedSettings,
|
||||
(saved) => {
|
||||
if (!saved) return;
|
||||
if (!touched.value.has('availability')) {
|
||||
availability.value = {
|
||||
teamChannels: saved.teamChannels ?? false,
|
||||
groupChats: saved.groupChats ?? false,
|
||||
readAllChannelMessages: saved.readAllChannelMessages ?? false,
|
||||
readAllGroupMessages: saved.readAllGroupMessages ?? false,
|
||||
};
|
||||
}
|
||||
if (!touched.value.has('displayName')) displayName.value = saved.displayName ?? '';
|
||||
if (!touched.value.has('description')) description.value = saved.description ?? '';
|
||||
},
|
||||
);
|
||||
|
||||
const steps = computed(() => [
|
||||
{
|
||||
id: 'create-credential',
|
||||
title: i18n.baseText('agents.channels.teams.setup.createCredential.title'),
|
||||
description: i18n.baseText('agents.channels.teams.setup.createCredential.description'),
|
||||
},
|
||||
{
|
||||
id: 'create-bot',
|
||||
title: i18n.baseText('agents.channels.teams.setup.createBot.title'),
|
||||
description: i18n.baseText('agents.channels.teams.setup.createBot.description'),
|
||||
},
|
||||
{
|
||||
id: 'availability',
|
||||
title: i18n.baseText('agents.channels.teams.setup.availability.title'),
|
||||
description: i18n.baseText('agents.channels.teams.setup.availability.description'),
|
||||
},
|
||||
{
|
||||
id: 'install',
|
||||
title: i18n.baseText('agents.channels.teams.setup.install.title'),
|
||||
description: i18n.baseText('agents.channels.teams.setup.install.description'),
|
||||
},
|
||||
]);
|
||||
|
||||
/**
|
||||
* Built on the saved settings, because connecting replaces the settings object
|
||||
* wholesale: a field this form does not render would otherwise be dropped the
|
||||
* first time someone saves from here.
|
||||
*
|
||||
* Empty strings are absent rather than values: the schema requires a non-empty
|
||||
* string when the field is present, and both fall back server-side.
|
||||
*/
|
||||
const currentSettings = computed(() => {
|
||||
// The identity keys are dropped from the base: an empty field means "fall
|
||||
// back to the agent", and the saved value would otherwise reinstate itself.
|
||||
const {
|
||||
displayName: _saved,
|
||||
description: _savedDescription,
|
||||
...rest
|
||||
} = props.savedSettings ?? {};
|
||||
return {
|
||||
...rest,
|
||||
...availability.value,
|
||||
...(displayName.value.trim() ? { displayName: displayName.value.trim() } : {}),
|
||||
...(description.value.trim() ? { description: description.value.trim() } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
defineExpose({ credentialId, validationError: null, currentSettings });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.teamsSetup">
|
||||
<N8nStepper v-if="mode === 'setup'" :steps="steps">
|
||||
<template #default="{ step }">
|
||||
<div :class="$style.stepContent">
|
||||
<div v-if="step.id === 'create-credential'" :class="$style.stepStack">
|
||||
<AgentIntegrationCredentialConnection
|
||||
v-if="!connected"
|
||||
v-model="credentialId"
|
||||
:integration-type="integration.type"
|
||||
:integration-label="integration.label"
|
||||
:credentials="credentials"
|
||||
:credential-permissions="credentialPermissions"
|
||||
:credentials-loading="credentialsLoading"
|
||||
:disabled="loading"
|
||||
:loading="loading"
|
||||
:error-message="errorMessage"
|
||||
:error-is-conflict="errorIsConflict"
|
||||
:force-new-credential="forceNewCredential"
|
||||
@create="emit('create')"
|
||||
@edit="emit('edit')"
|
||||
/>
|
||||
|
||||
<N8nText
|
||||
v-if="credentialClaimedBy"
|
||||
size="small"
|
||||
:class="$style.error"
|
||||
data-testid="teams-credential-claimed"
|
||||
>
|
||||
{{
|
||||
i18n.baseText('agents.channels.teams.setup.createCredential.claimed', {
|
||||
interpolate: { agent: credentialClaimedBy },
|
||||
})
|
||||
}}
|
||||
</N8nText>
|
||||
|
||||
<N8nText :class="$style.hint" size="small" data-testid="teams-create-bot-prerequisites">
|
||||
{{ i18n.baseText('agents.channels.teams.setup.createCredential.prerequisites') }}
|
||||
<a
|
||||
:href="ENTRA_APP_REGISTRATION_URL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="teams-entra-register-link"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.teams.setup.createCredential.button') }}
|
||||
</a>
|
||||
</N8nText>
|
||||
</div>
|
||||
|
||||
<div v-else-if="step.id === 'create-bot'" :class="$style.stepStack">
|
||||
<N8nButton
|
||||
v-if="setupState?.deployToAzureUrl"
|
||||
:href="setupState.deployToAzureUrl"
|
||||
target="_blank"
|
||||
variant="subtle"
|
||||
size="medium"
|
||||
data-testid="teams-deploy-to-azure"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.teams.setup.createBot.button') }}
|
||||
</N8nButton>
|
||||
<N8nText v-else :class="$style.hint" size="small" data-testid="teams-deploy-blocked">
|
||||
{{
|
||||
credentialClaimedBy
|
||||
? i18n.baseText('agents.channels.teams.setup.createBot.needsFreeCredential')
|
||||
: i18n.baseText('agents.channels.teams.setup.createBot.needsCredential')
|
||||
}}
|
||||
</N8nText>
|
||||
|
||||
<N8nText :class="$style.hint" size="small">
|
||||
{{ i18n.baseText('agents.channels.teams.setup.createBot.hint') }}
|
||||
</N8nText>
|
||||
|
||||
<!-- The deployment sets the endpoint, so only someone wiring up an
|
||||
existing bot needs to see it. -->
|
||||
<N8nButton
|
||||
v-if="!showEndpoint"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
data-testid="teams-show-endpoint"
|
||||
@click="showEndpoint = true"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.teams.setup.createBot.existingBot') }}
|
||||
</N8nButton>
|
||||
<div v-else :class="$style.field" data-testid="teams-endpoint-field">
|
||||
<label for="teams-messaging-endpoint-url">
|
||||
<N8nText size="small" bold>
|
||||
{{ i18n.baseText('agents.channels.teams.messagingEndpointUrl.label') }}
|
||||
</N8nText>
|
||||
</label>
|
||||
<N8nCopyInput
|
||||
id="teams-messaging-endpoint-url"
|
||||
:value="messagingEndpointUrl"
|
||||
size="large"
|
||||
:class="$style.urlInput"
|
||||
:copy-label="i18n.baseText('agents.builder.addTrigger.copy')"
|
||||
:copied-label="i18n.baseText('agents.builder.addTrigger.copied')"
|
||||
/>
|
||||
<N8nText :class="$style.hint" size="small">
|
||||
{{ i18n.baseText('agents.channels.teams.setup.createBot.existingBotHint') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="step.id === 'availability'" :class="$style.stepStack">
|
||||
<AgentChannelTeamsAvailability
|
||||
:model-value="availability"
|
||||
@update:model-value="editAvailability"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="step.id === 'install'" :class="$style.stepStack">
|
||||
<N8nButton
|
||||
v-if="canDownloadPackage"
|
||||
variant="subtle"
|
||||
size="medium"
|
||||
icon="download"
|
||||
:loading="downloading"
|
||||
data-testid="teams-download-package"
|
||||
@click="downloadPackage"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.teams.setup.install.button') }}
|
||||
</N8nButton>
|
||||
<N8nText v-else size="small" :class="$style.hint" data-testid="teams-package-blocked">
|
||||
{{ i18n.baseText('agents.channels.teams.setup.install.needsBot') }}
|
||||
</N8nText>
|
||||
|
||||
<N8nText
|
||||
v-if="downloadError"
|
||||
size="small"
|
||||
:class="$style.error"
|
||||
data-testid="teams-download-error"
|
||||
>
|
||||
{{ downloadError }}
|
||||
</N8nText>
|
||||
|
||||
<N8nText :class="$style.hint" size="small">
|
||||
{{ i18n.baseText('agents.channels.teams.setup.install.hint') }}
|
||||
</N8nText>
|
||||
|
||||
<!-- Last, because the modal closes on it: the package has to be
|
||||
downloaded by then. -->
|
||||
<template v-if="!connected">
|
||||
<N8nText
|
||||
v-if="checking"
|
||||
:class="$style.hint"
|
||||
size="small"
|
||||
data-testid="teams-credential-checking"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.teams.setup.install.checking') }}
|
||||
</N8nText>
|
||||
<template v-else-if="credentialProblem">
|
||||
<N8nText size="small" :class="$style.error" data-testid="teams-credential-problem">
|
||||
{{
|
||||
i18n.baseText(`agents.channels.teams.setup.install.failed.${credentialProblem}`)
|
||||
}}
|
||||
</N8nText>
|
||||
<N8nButton
|
||||
variant="ghost"
|
||||
size="small"
|
||||
data-testid="teams-credential-recheck"
|
||||
@click="runCredentialCheck()"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.teams.setup.install.recheck') }}
|
||||
</N8nButton>
|
||||
</template>
|
||||
<!-- Only when nothing is picked; a verified credential says nothing. -->
|
||||
<N8nText
|
||||
v-else-if="!credentialId"
|
||||
:class="$style.hint"
|
||||
size="small"
|
||||
data-testid="teams-connect-blocked"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.teams.setup.install.needsCredential') }}
|
||||
</N8nText>
|
||||
|
||||
<N8nButton
|
||||
variant="solid"
|
||||
size="medium"
|
||||
:disabled="!credentialVerified || Boolean(credentialClaimedBy) || loading"
|
||||
:loading="loading"
|
||||
data-testid="teams-connect"
|
||||
@click="emit('connect')"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.teams.setup.install.connectButton') }}
|
||||
</N8nButton>
|
||||
</template>
|
||||
<N8nText
|
||||
v-if="connected && !isPublished"
|
||||
:class="$style.hint"
|
||||
size="small"
|
||||
data-testid="teams-publish-notice"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.teams.setup.publishNotice') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</N8nStepper>
|
||||
|
||||
<div v-else :class="$style.formContent">
|
||||
<N8nText size="small" :class="$style.hint" data-testid="teams-update-notice">
|
||||
{{ i18n.baseText('agents.channels.teams.settings.updateNotice') }}
|
||||
</N8nText>
|
||||
|
||||
<div :class="$style.field" data-testid="teams-display-name">
|
||||
<label for="teams-display-name">
|
||||
<N8nText size="small" bold>
|
||||
{{ i18n.baseText('agents.channels.teams.settings.displayName') }}
|
||||
</N8nText>
|
||||
</label>
|
||||
<N8nInput
|
||||
id="teams-display-name"
|
||||
:model-value="displayName"
|
||||
@update:model-value="editDisplayName"
|
||||
size="large"
|
||||
:maxlength="TEAMS_DISPLAY_NAME_MAX"
|
||||
:placeholder="defaultDisplayName"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="$style.field" data-testid="teams-description">
|
||||
<label for="teams-description">
|
||||
<N8nText size="small" bold>
|
||||
{{ i18n.baseText('agents.channels.teams.settings.description') }}
|
||||
</N8nText>
|
||||
</label>
|
||||
<N8nInput
|
||||
id="teams-description"
|
||||
:model-value="description"
|
||||
@update:model-value="editDescription"
|
||||
size="large"
|
||||
:maxlength="TEAMS_DESCRIPTION_MAX"
|
||||
:placeholder="defaultDescription"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AgentChannelTeamsAvailability
|
||||
:model-value="availability"
|
||||
start-collapsed
|
||||
@update:model-value="editAvailability"
|
||||
/>
|
||||
|
||||
<N8nButton
|
||||
v-if="canDownloadPackage"
|
||||
variant="subtle"
|
||||
size="small"
|
||||
icon="download"
|
||||
:loading="downloading"
|
||||
data-testid="teams-download-package"
|
||||
@click="downloadPackage"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.teams.setup.install.button') }}
|
||||
</N8nButton>
|
||||
<N8nText v-if="downloadError" size="small" :class="$style.error">
|
||||
{{ downloadError }}
|
||||
</N8nText>
|
||||
|
||||
<N8nButton
|
||||
v-if="!showEndpoint"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
data-testid="teams-show-endpoint"
|
||||
@click="showEndpoint = true"
|
||||
>
|
||||
{{ i18n.baseText('agents.channels.teams.setup.createBot.existingBot') }}
|
||||
</N8nButton>
|
||||
<div v-else :class="$style.field" data-testid="teams-endpoint-field">
|
||||
<label for="teams-messaging-endpoint-url">
|
||||
<N8nText size="small" bold>
|
||||
{{ i18n.baseText('agents.channels.teams.messagingEndpointUrl.label') }}
|
||||
</N8nText>
|
||||
</label>
|
||||
<N8nCopyInput
|
||||
id="teams-messaging-endpoint-url"
|
||||
:value="messagingEndpointUrl"
|
||||
size="large"
|
||||
:class="$style.urlInput"
|
||||
:copy-label="i18n.baseText('agents.builder.addTrigger.copy')"
|
||||
:copied-label="i18n.baseText('agents.builder.addTrigger.copied')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.teamsSetup,
|
||||
.formContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.stepContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--sm);
|
||||
padding-top: var(--spacing--xs);
|
||||
}
|
||||
|
||||
.stepStack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing--sm);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--3xs);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-color--subtler);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--color--danger);
|
||||
}
|
||||
|
||||
.urlInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.urlInput input {
|
||||
font-family: monospace;
|
||||
font-size: var(--font-size--2xs);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import type {
|
||||
AgentTeamsIntegrationSettings,
|
||||
TeamsAgentSetupState,
|
||||
TeamsCredentialCheck,
|
||||
} from '@n8n/api-types';
|
||||
import { getBrowserId } from '@n8n/constants';
|
||||
import type { IRestApiContext } from '@n8n/rest-api-client';
|
||||
import { makeRestApiRequest } from '@n8n/rest-api-client';
|
||||
|
||||
const integrationPath = (projectId: string, agentId: string) =>
|
||||
`/projects/${projectId}/agents/v2/${agentId}/integrations/teams`;
|
||||
|
||||
/** `credentialId` is the one picked in the setup, before it is on the agent. */
|
||||
export const getTeamsSetupState = async (
|
||||
context: IRestApiContext,
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
credentialId?: string,
|
||||
): Promise<TeamsAgentSetupState> =>
|
||||
await makeRestApiRequest(
|
||||
context,
|
||||
'GET',
|
||||
`${integrationPath(projectId, agentId)}/setup`,
|
||||
credentialId ? { credentialId } : undefined,
|
||||
);
|
||||
|
||||
export const checkTeamsCredential = async (
|
||||
context: IRestApiContext,
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
credentialId: string,
|
||||
): Promise<TeamsCredentialCheck> =>
|
||||
await makeRestApiRequest(
|
||||
context,
|
||||
'POST',
|
||||
`${integrationPath(projectId, agentId)}/check/${credentialId}`,
|
||||
);
|
||||
|
||||
/**
|
||||
* Fetched rather than linked to. The session cookie is bound to a `browser-id`
|
||||
* header, which a plain navigation cannot send: the request comes back 401 and
|
||||
* the app treats that as a dead session and signs the user out.
|
||||
*
|
||||
* `settings` are the ones in the open form rather than the stored ones, because
|
||||
* the package is downloaded before the channel is connected.
|
||||
*/
|
||||
export const fetchTeamsAppPackage = async (
|
||||
context: IRestApiContext,
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
credentialId?: string,
|
||||
settings?: AgentTeamsIntegrationSettings,
|
||||
): Promise<Blob> => {
|
||||
const response = await fetch(`${context.baseUrl}${integrationPath(projectId, agentId)}/package`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'browser-id': getBrowserId(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credentialId, settings }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Could not download the Teams app package (${response.status})`);
|
||||
}
|
||||
return await response.blob();
|
||||
};
|
||||
|
|
@ -4952,6 +4952,12 @@ importers:
|
|||
'@vvo/tzdb':
|
||||
specifier: ^6.141.0
|
||||
version: 6.141.0
|
||||
ajv:
|
||||
specifier: 8.20.0
|
||||
version: 8.20.0
|
||||
ajv-draft-04:
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.0(ajv@8.20.0)
|
||||
axios:
|
||||
specifier: 1.18.0
|
||||
version: 1.18.0(patch_hash=149e256a2a7b632497650b32816716ced972ab02a5ab00fbd8a5158a51722c49)(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user