refactor(core): Decouple agent integration setup (#35589)

This commit is contained in:
yehorkardash
2026-08-10 15:32:55 +02:00
committed by GitHub
parent c1334647a3
commit ec3fbda846
71 changed files with 2860 additions and 3551 deletions
@@ -231,10 +231,6 @@ export class RevertAgentToVersionDto extends Z.class({
versionId: z.string().min(1),
}) {}
export class CreateSlackAgentAppDto extends Z.class({
appConfigurationToken: z.string().min(1),
}) {}
export class TestAgentVectorStoreDto extends Z.class({
vectorStore: AgentVectorStoreConfigSchema,
}) {}
@@ -10,6 +10,7 @@ export * from './agent-skill.schema';
export * from './child-trace';
export * from './inline-agent-config.schema';
export * from './sanitize-agent-json-config';
export * from './slack';
export * from './agent-task.schema';
export * from './dto';
export * from './model-providers';
@@ -0,0 +1,7 @@
import { z } from 'zod';
import { Z } from '../../zod-class';
export class CreateSlackAgentAppDto extends Z.class({
appConfigurationToken: z.string().min(1),
}) {}
@@ -0,0 +1,2 @@
export * from './dto';
export type * from './types';
@@ -0,0 +1,44 @@
export interface CreateSlackAgentAppResponse {
appId: string;
installUrl: string;
}
export interface SlackAgentAppManifest {
display_information: {
name: string;
};
features: {
app_home: {
home_tab_enabled: boolean;
messages_tab_enabled: boolean;
messages_tab_read_only_enabled: boolean;
};
bot_user: {
display_name: string;
always_online: boolean;
};
};
oauth_config: {
redirect_urls?: string[];
scopes: {
bot: string[];
};
};
settings: {
event_subscriptions: {
request_url: string;
bot_events: string[];
};
interactivity: {
is_enabled: boolean;
request_url: string;
};
org_deploy_enabled: boolean;
socket_mode_enabled: boolean;
token_rotation_enabled: boolean;
};
}
export interface SlackAgentAppManifestResponse {
manifest: SlackAgentAppManifest;
}
@@ -46,51 +46,6 @@ export interface AgentIntegrationStatusResponse {
integrations: AgentIntegrationStatusEntry[];
}
export interface CreateSlackAgentAppResponse {
appId: string;
installUrl: string;
}
export interface SlackAgentAppManifest {
display_information: {
name: string;
};
features: {
app_home: {
home_tab_enabled: boolean;
messages_tab_enabled: boolean;
messages_tab_read_only_enabled: boolean;
};
bot_user: {
display_name: string;
always_online: boolean;
};
};
oauth_config: {
redirect_urls?: string[];
scopes: {
bot: string[];
};
};
settings: {
event_subscriptions: {
request_url: string;
bot_events: string[];
};
interactivity: {
is_enabled: boolean;
request_url: string;
};
org_deploy_enabled: boolean;
socket_mode_enabled: boolean;
token_rotation_enabled: boolean;
};
}
export interface SlackAgentAppManifestResponse {
manifest: SlackAgentAppManifest;
}
export interface AgentSkillReference {
path: string;
content: string;
@@ -0,0 +1,149 @@
/* eslint-disable @typescript-eslint/unbound-method -- mock-based tests intentionally reference unbound methods */
import type { AgentIntegrationConfig } from '@n8n/api-types';
import { mock } from 'vitest-mock-extended';
import type { CredentialsService } from '@/credentials/credentials.service';
import { AgentIntegrationManagementService } from '../agent-integration-management.service';
import type { AgentIntegrationPersistenceService } from '../agent-integration-persistence.service';
import type { Agent } from '../entities/agent.entity';
import type {
AgentChatIntegration,
ChatIntegrationRegistry,
} from '../integrations/agent-chat-integration';
import type { ChatIntegrationService } from '../integrations/chat-integration.service';
describe('AgentIntegrationManagementService', () => {
const user = { id: 'user-1' };
const integration = {
type: 'slack',
credentialId: 'credential-1',
} satisfies AgentIntegrationConfig;
const agent = {
id: 'agent-1',
projectId: 'project-1',
activeVersionId: 'version-1',
integrations: [],
} as unknown as Agent;
function makeService() {
const persistenceService = mock<AgentIntegrationPersistenceService>();
const credentialsService = mock<CredentialsService>();
const chatService = mock<ChatIntegrationService>();
const registry = mock<ChatIntegrationRegistry>();
const implementation = mock<AgentChatIntegration>({
type: 'slack',
displayLabel: 'Slack',
credentialTypes: ['slackApi'],
});
registry.require.mockReturnValue(implementation);
return {
service: new AgentIntegrationManagementService(
persistenceService,
credentialsService,
chatService,
registry,
),
persistenceService,
credentialsService,
chatService,
implementation,
};
}
it('persists, connects, and broadcasts for a published integration', async () => {
const { service, persistenceService, credentialsService, chatService, implementation } =
makeService();
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([
{ id: integration.credentialId, type: 'slackApi' },
] as never);
persistenceService.saveCredentialIntegration.mockResolvedValue(agent);
await service.connect({ agent, user: user as never, integration });
expect(implementation.validateConfig).toHaveBeenCalledWith(integration);
expect(chatService.validateBeforeConnect).toHaveBeenCalledWith(
agent.id,
integration,
agent.projectId,
);
expect(persistenceService.saveCredentialIntegration).toHaveBeenCalledWith(agent, integration, {
user,
modifiedBy: 'user',
broadcast: false,
});
expect(chatService.connect).toHaveBeenCalledWith(agent.id, integration, agent.projectId);
expect(chatService.broadcastIntegrationChange).toHaveBeenCalledWith(
agent.id,
integration,
'connect',
);
});
it('persists but does not initialize an unpublished integration', async () => {
const { service, persistenceService, credentialsService, chatService } = makeService();
const draftAgent = { ...agent, activeVersionId: null };
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([
{ id: integration.credentialId, type: 'slackApi' },
] as never);
persistenceService.saveCredentialIntegration.mockResolvedValue(draftAgent);
await service.connect({ agent: draftAgent, user: user as never, integration });
expect(persistenceService.saveCredentialIntegration).toHaveBeenCalledWith(
draftAgent,
integration,
{ user, modifiedBy: 'user', broadcast: false },
);
expect(chatService.validateBeforeConnect).toHaveBeenCalledWith(
draftAgent.id,
integration,
draftAgent.projectId,
);
expect(chatService.connect).not.toHaveBeenCalled();
expect(chatService.broadcastIntegrationChange).not.toHaveBeenCalled();
});
it('does not broadcast when live connection fails', async () => {
const { service, persistenceService, credentialsService, chatService } = makeService();
const connectionError = new Error('Slack connect failed');
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([
{ id: integration.credentialId, type: 'slackApi' },
] as never);
persistenceService.saveCredentialIntegration.mockResolvedValue(agent);
chatService.connect.mockRejectedValue(connectionError);
await expect(service.connect({ agent, user: user as never, integration })).rejects.toBe(
connectionError,
);
expect(persistenceService.saveCredentialIntegration).toHaveBeenCalled();
expect(chatService.connect).toHaveBeenCalled();
expect(chatService.broadcastIntegrationChange).not.toHaveBeenCalled();
});
it('disconnects the runtime channel before removing persistence', async () => {
const { service, persistenceService, chatService } = makeService();
const connectedAgent = { ...agent, integrations: [integration] } as Agent;
persistenceService.removeCredentialIntegration.mockResolvedValue({
...connectedAgent,
integrations: [],
});
await service.disconnect({
agent: connectedAgent,
user: user as never,
type: integration.type,
credentialId: integration.credentialId,
modifiedBy: 'mcp',
});
expect(chatService.disconnectChannel).toHaveBeenCalledWith(agent.id, integration);
expect(persistenceService.removeCredentialIntegration).toHaveBeenCalledWith(
connectedAgent,
integration.type,
integration.credentialId,
{ user, modifiedBy: 'mcp', broadcast: false },
);
});
});
@@ -1,69 +1,42 @@
/* eslint-disable @typescript-eslint/unbound-method -- mock-based tests intentionally reference unbound methods */
import type { AgentIntegrationConfig } from '@n8n/api-types';
import type { Mocked } from 'vitest';
import { mock } from 'vitest-mock-extended';
import type { CredentialsService } from '@/credentials/credentials.service';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import type { AgentIntegrationPersistenceService } from '../agent-integration-persistence.service';
import type { AgentIntegrationManagementService } from '../agent-integration-management.service';
import { AgentIntegrationsController } from '../agent-integrations.controller';
import type { Agent } from '../entities/agent.entity';
import type { ChatIntegrationRegistry } from '../integrations/agent-chat-integration';
import type { ChatIntegrationService } from '../integrations/chat-integration.service';
import type { SlackAppSetupService } from '../integrations/slack-app-setup.service';
import type { AgentRepository } from '../repositories/agent.repository';
import {
expectProjectScopedAgentRoutes,
getRoutesByHandlerName,
} from './test-utils/controller-route-metadata';
const UNAUTHENTICATED_HANDLERS = new Set(['handleWebhook', 'handleSlackAppOAuthCallback']);
const UNAUTHENTICATED_HANDLERS = new Set(['handleWebhook']);
function makeController({
agentIntegrationPersistenceService = mock<AgentIntegrationPersistenceService>(),
credentialsService = mock<CredentialsService>(),
managementService = mock<AgentIntegrationManagementService>(),
chatIntegrationService = mock<ChatIntegrationService>(),
agentRepository = mock<AgentRepository>(),
chatIntegrationRegistry = mock<ChatIntegrationRegistry>(),
slackAppSetupService = mock<SlackAppSetupService>(),
}: {
agentIntegrationPersistenceService?: Mocked<AgentIntegrationPersistenceService>;
credentialsService?: Mocked<CredentialsService>;
managementService?: Mocked<AgentIntegrationManagementService>;
chatIntegrationService?: Mocked<ChatIntegrationService>;
agentRepository?: Mocked<AgentRepository>;
chatIntegrationRegistry?: Mocked<ChatIntegrationRegistry>;
slackAppSetupService?: Mocked<SlackAppSetupService>;
} = {}) {
if (!chatIntegrationRegistry.require.getMockImplementation()) {
chatIntegrationRegistry.require.mockImplementation(
(type: string) =>
({
type,
displayLabel: type,
credentialTypes:
type === 'telegram'
? ['telegramApi']
: type === 'linear'
? ['linearOAuth2Api']
: [`${type}Api`],
}) as never,
);
}
return {
controller: new AgentIntegrationsController(
agentIntegrationPersistenceService,
credentialsService,
managementService,
chatIntegrationService,
agentRepository,
chatIntegrationRegistry,
slackAppSetupService,
),
agentIntegrationPersistenceService,
credentialsService,
managementService,
chatIntegrationService,
agentRepository,
chatIntegrationRegistry,
slackAppSetupService,
};
}
@@ -74,8 +47,6 @@ describe('AgentIntegrationsController route access scopes', () => {
it.each([
['connectIntegration', 'agent:update'],
['createSlackApp', 'agent:update'],
['getSlackAppManifest', 'agent:read'],
['disconnectIntegration', 'agent:update'],
['integrationStatus', 'agent:read'],
])('%s uses %s', (handlerName, scope) => {
@@ -83,667 +54,91 @@ describe('AgentIntegrationsController route access scopes', () => {
});
});
describe('AgentIntegrationsController integration credentials', () => {
it('rejects credentials that are not usable in the agent project', async () => {
const credentialsService = mock<CredentialsService>();
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([
{
id: 'cred-allowed',
name: 'Allowed Slack',
type: 'slackApi',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
scopes: [],
isManaged: false,
isGlobal: false,
isResolvable: true,
currentUserHasAccess: true,
homeProject: null,
sharedWithProjects: [],
},
]);
const chatIntegrationService = mock<ChatIntegrationService>();
const agentRepository = mock<AgentRepository>();
agentRepository.findByIdAndProjectId.mockResolvedValue({
id: 'agent-1',
projectId: 'project-1',
activeVersionId: 'v1',
activeVersion: {},
integrations: [],
} as never);
const { controller } = makeController({
credentialsService,
chatIntegrationService,
agentRepository,
});
await expect(
controller.connectIntegration(
{
params: { projectId: 'project-1' },
user: { id: 'user-1' },
body: { type: 'slack', credentialId: 'cred-outside-project' },
} as never,
undefined as never,
'agent-1',
),
).rejects.toThrow(NotFoundError);
expect(credentialsService.getCredentialsAUserCanUseInAWorkflow).toHaveBeenCalledWith(
{ id: 'user-1' },
{ projectId: 'project-1' },
);
expect(chatIntegrationService.connect).not.toHaveBeenCalled();
});
it('requires Telegram settings when connecting Telegram', async () => {
const { controller, chatIntegrationService } = makeController();
await expect(
controller.connectIntegration(
{
params: { projectId: 'project-1' },
user: { id: 'user-1' },
body: { type: 'telegram', credentialId: 'cred-telegram' },
} as never,
undefined as never,
'agent-1',
),
).rejects.toThrow(BadRequestError);
expect(chatIntegrationService.connect).not.toHaveBeenCalled();
});
it('rejects credentials whose type is not supported by the chat integration', async () => {
const credentialsService = mock<CredentialsService>();
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([
{
id: 'cred-oauth',
name: 'Slack OAuth',
type: 'slackOAuth2Api',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
scopes: [],
isManaged: false,
isGlobal: false,
isResolvable: true,
currentUserHasAccess: true,
homeProject: null,
sharedWithProjects: [],
},
]);
const agentRepository = mock<AgentRepository>();
agentRepository.findByIdAndProjectId.mockResolvedValue({
id: 'agent-1',
projectId: 'project-1',
activeVersionId: 'v1',
activeVersion: {},
integrations: [],
} as never);
const chatIntegrationService = mock<ChatIntegrationService>();
const chatIntegrationRegistry = mock<ChatIntegrationRegistry>();
chatIntegrationRegistry.require.mockReturnValue({
type: 'slack',
credentialTypes: ['slackApi'],
} as never);
const { controller } = makeController({
credentialsService,
chatIntegrationService,
agentRepository,
chatIntegrationRegistry,
});
await expect(
controller.connectIntegration(
{
params: { projectId: 'project-1' },
user: { id: 'user-1' },
body: { type: 'slack', credentialId: 'cred-oauth' },
} as never,
undefined as never,
'agent-1',
),
).rejects.toThrow(BadRequestError);
expect(chatIntegrationService.connect).not.toHaveBeenCalled();
});
it('persists, connects, and broadcasts Telegram settings for a published agent', async () => {
const credentialsService = mock<CredentialsService>();
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([
{
id: 'cred-telegram',
name: 'Telegram Bot',
type: 'telegramApi',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
scopes: [],
isManaged: false,
isGlobal: false,
isResolvable: true,
currentUserHasAccess: true,
homeProject: null,
sharedWithProjects: [],
},
]);
const agentRepository = mock<AgentRepository>();
const agent = {
id: 'agent-1',
projectId: 'project-1',
activeVersionId: 'v1',
activeVersion: {},
integrations: [],
};
agentRepository.findByIdAndProjectId.mockResolvedValue(agent as never);
const chatIntegrationService = mock<ChatIntegrationService>();
const agentIntegrationPersistenceService = mock<AgentIntegrationPersistenceService>();
agentIntegrationPersistenceService.saveCredentialIntegration.mockResolvedValue(agent as never);
const { controller } = makeController({
agentIntegrationPersistenceService,
credentialsService,
chatIntegrationService,
agentRepository,
});
const settings = {
accessMode: 'private' as const,
allowedUsers: ['123'],
};
await expect(
controller.connectIntegration(
{
params: { projectId: 'project-1' },
user: { id: 'user-1' },
body: {
type: 'telegram',
credentialId: 'cred-telegram',
settings,
},
} as never,
undefined as never,
'agent-1',
),
).resolves.toEqual({ status: 'connected' });
describe('AgentIntegrationsController integration management', () => {
const user = { id: 'user-1' };
const agent = {
id: 'agent-1',
projectId: 'project-1',
activeVersionId: 'version-1',
integrations: [],
} as unknown as Agent;
it('delegates a connect and reports connected for a published agent', async () => {
const { controller, managementService, agentRepository } = makeController();
const integration = {
type: 'telegram',
credentialId: 'cred-telegram',
settings,
};
expect(agentIntegrationPersistenceService.saveCredentialIntegration).toHaveBeenCalledWith(
agent,
integration,
{
user: { id: 'user-1' },
modifiedBy: 'user',
broadcast: false,
},
);
expect(chatIntegrationService.connect).toHaveBeenCalledWith(
'agent-1',
integration,
'project-1',
);
expect(chatIntegrationService.broadcastIntegrationChange).toHaveBeenCalledWith(
'agent-1',
integration,
'connect',
);
expect(
agentIntegrationPersistenceService.saveCredentialIntegration.mock.invocationCallOrder[0],
).toBeLessThan(chatIntegrationService.connect.mock.invocationCallOrder[0]);
expect(chatIntegrationService.connect.mock.invocationCallOrder[0]).toBeLessThan(
chatIntegrationService.broadcastIntegrationChange.mock.invocationCallOrder[0],
);
});
it.each([
{ type: 'slack', credentialId: 'cred-slack', credentialType: 'slackApi' },
{ type: 'linear', credentialId: 'cred-linear', credentialType: 'linearOAuth2Api' },
])('persists $type without connecting or publishing an unpublished agent', async (testCase) => {
const credentialsService = mock<CredentialsService>();
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([
{
id: testCase.credentialId,
name: 'Channel credential',
type: testCase.credentialType,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
scopes: [],
isManaged: false,
isGlobal: false,
isResolvable: true,
currentUserHasAccess: true,
homeProject: null,
sharedWithProjects: [],
},
]);
const agentRepository = mock<AgentRepository>();
const agent = {
id: 'agent-1',
projectId: 'project-1',
activeVersionId: null,
activeVersion: null,
integrations: [],
};
const savedAgent = {
...agent,
integrations: [{ type: testCase.type, credentialId: testCase.credentialId }],
};
agentRepository.findByIdAndProjectId.mockResolvedValue(agent as never);
const agentIntegrationPersistenceService = mock<AgentIntegrationPersistenceService>();
agentIntegrationPersistenceService.saveCredentialIntegration.mockResolvedValue(
savedAgent as never,
);
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.connect.mockResolvedValue(undefined);
chatIntegrationService.broadcastIntegrationChange.mockResolvedValue(undefined);
const { controller } = makeController({
agentIntegrationPersistenceService,
credentialsService,
chatIntegrationService,
agentRepository,
});
await expect(
controller.connectIntegration(
{
params: { projectId: 'project-1' },
user: { id: 'user-1' },
body: { type: testCase.type, credentialId: testCase.credentialId },
} as never,
undefined as never,
'agent-1',
),
).resolves.toEqual({ status: 'configured' });
const integration = { type: testCase.type, credentialId: testCase.credentialId };
expect(agentIntegrationPersistenceService.saveCredentialIntegration).toHaveBeenCalledWith(
agent,
integration,
{
user: { id: 'user-1' },
modifiedBy: 'user',
broadcast: false,
},
);
expect(chatIntegrationService.connect).not.toHaveBeenCalled();
expect(chatIntegrationService.broadcastIntegrationChange).not.toHaveBeenCalled();
});
it('does not broadcast when connecting a published agent fails', async () => {
const credentialsService = mock<CredentialsService>();
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([
{
id: 'cred-slack',
name: 'Slack Bot',
type: 'slackApi',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
scopes: [],
isManaged: false,
isGlobal: false,
isResolvable: true,
currentUserHasAccess: true,
homeProject: null,
sharedWithProjects: [],
},
]);
const agentRepository = mock<AgentRepository>();
const agent = {
id: 'agent-1',
projectId: 'project-1',
activeVersionId: 'v1',
activeVersion: {},
integrations: [],
};
const savedAgent = {
...agent,
integrations: [{ type: 'slack', credentialId: 'cred-slack' }],
};
agentRepository.findByIdAndProjectId.mockResolvedValue(agent as never);
const agentIntegrationPersistenceService = mock<AgentIntegrationPersistenceService>();
agentIntegrationPersistenceService.saveCredentialIntegration.mockResolvedValue(
savedAgent as never,
);
const chatIntegrationService = mock<ChatIntegrationService>();
chatIntegrationService.connect.mockRejectedValue(new Error('Slack connect failed'));
const { controller } = makeController({
agentIntegrationPersistenceService,
credentialsService,
chatIntegrationService,
agentRepository,
});
await expect(
controller.connectIntegration(
{
params: { projectId: 'project-1' },
user: { id: 'user-1' },
body: { type: 'slack', credentialId: 'cred-slack' },
} as never,
undefined as never,
'agent-1',
),
).rejects.toThrow('Slack connect failed');
expect(chatIntegrationService.broadcastIntegrationChange).not.toHaveBeenCalled();
});
it('reports complete persisted integrations as configured for an unpublished agent', async () => {
const settings = {
accessMode: 'private' as const,
allowedUsers: ['123'],
};
const agentRepository = mock<AgentRepository>();
agentRepository.findByIdAndProjectId.mockResolvedValue({
id: 'agent-1',
projectId: 'project-1',
activeVersionId: null,
integrations: [
{
type: 'telegram',
credentialId: 'cred-telegram',
settings,
},
],
} as never);
const { controller } = makeController({ agentRepository });
await expect(
controller.integrationStatus(
{ params: { projectId: 'project-1' } } as never,
undefined as never,
'agent-1',
),
).resolves.toEqual({
status: 'configured',
integrations: [
{
type: 'telegram',
credentialId: 'cred-telegram',
settings,
},
],
});
});
it('reports complete persisted integrations as connected for a published agent', async () => {
const agentRepository = mock<AgentRepository>();
agentRepository.findByIdAndProjectId.mockResolvedValue({
id: 'agent-1',
projectId: 'project-1',
activeVersionId: 'v1',
integrations: [{ type: 'linear', credentialId: 'cred-linear' }],
} as never);
const { controller } = makeController({ agentRepository });
await expect(
controller.integrationStatus(
{ params: { projectId: 'project-1' } } as never,
undefined as never,
'agent-1',
),
).resolves.toEqual({
status: 'connected',
integrations: [{ type: 'linear', credentialId: 'cred-linear' }],
});
});
it('reports a draft integration (empty credentialId) as disconnected', async () => {
const agentRepository = mock<AgentRepository>();
agentRepository.findByIdAndProjectId.mockResolvedValue({
id: 'agent-1',
projectId: 'project-1',
activeVersionId: 'v1',
integrations: [{ type: 'slack', credentialId: '' }],
} as never);
const { controller } = makeController({ agentRepository });
await expect(
controller.integrationStatus(
{ params: { projectId: 'project-1' } } as never,
undefined as never,
'agent-1',
),
).resolves.toEqual({ status: 'disconnected', integrations: [] });
});
it('disconnects the channel before removing the persisted integration', async () => {
const agentRepository = mock<AgentRepository>();
const agent = {
id: 'agent-1',
projectId: 'project-1',
integrations: [{ type: 'slack', credentialId: 'cred-slack' }],
};
agentRepository.findByIdAndProjectId.mockResolvedValue(agent as never);
const chatIntegrationService = mock<ChatIntegrationService>();
const agentIntegrationPersistenceService = mock<AgentIntegrationPersistenceService>();
const { controller } = makeController({
agentRepository,
chatIntegrationService,
agentIntegrationPersistenceService,
});
await expect(
controller.disconnectIntegration(
{
params: { projectId: 'project-1' },
user: { id: 'user-1' },
body: { type: 'slack', credentialId: 'cred-slack' },
} as never,
undefined as never,
'agent-1',
{ type: 'slack', credentialId: 'cred-slack' },
),
).resolves.toEqual({ status: 'disconnected' });
expect(chatIntegrationService.disconnectChannel).toHaveBeenCalledWith('agent-1', {
type: 'slack',
credentialId: 'cred-slack',
});
expect(agentIntegrationPersistenceService.removeCredentialIntegration).toHaveBeenCalledWith(
credentialId: 'credential-1',
} satisfies AgentIntegrationConfig;
agentRepository.findByIdAndProjectId.mockResolvedValue(agent);
managementService.connect.mockResolvedValue({ integration, savedAgent: agent });
const result = await controller.connectIntegration(
{
params: { projectId: agent.projectId },
user,
body: integration,
} as never,
undefined as never,
agent.id,
);
expect(managementService.validateConfig).toHaveBeenCalledWith(integration);
expect(managementService.connect).toHaveBeenCalledWith({
agent,
'slack',
'cred-slack',
{ user: { id: 'user-1' }, modifiedBy: 'user', broadcast: false },
);
expect(chatIntegrationService.disconnectChannel.mock.invocationCallOrder[0]).toBeLessThan(
agentIntegrationPersistenceService.removeCredentialIntegration.mock.invocationCallOrder[0],
);
user,
integration,
});
expect(result).toEqual({ status: 'connected' });
});
it('disconnects a draft integration entry with an empty credentialId', async () => {
const agentRepository = mock<AgentRepository>();
const agent = {
id: 'agent-1',
projectId: 'project-1',
integrations: [{ type: 'slack', credentialId: '' }],
};
agentRepository.findByIdAndProjectId.mockResolvedValue(agent as never);
const chatIntegrationService = mock<ChatIntegrationService>();
const agentIntegrationPersistenceService = mock<AgentIntegrationPersistenceService>();
const { controller } = makeController({
agentRepository,
chatIntegrationService,
agentIntegrationPersistenceService,
});
await expect(
controller.disconnectIntegration(
{
params: { projectId: 'project-1' },
user: { id: 'user-1' },
body: { type: 'slack', credentialId: '' },
} as never,
undefined as never,
'agent-1',
{ type: 'slack', credentialId: '' },
),
).resolves.toEqual({ status: 'disconnected' });
expect(chatIntegrationService.disconnectChannel).toHaveBeenCalledWith('agent-1', {
it('reports configured when the saved agent is unpublished', async () => {
const { controller, managementService, agentRepository } = makeController();
const integration = {
type: 'slack',
credentialId: '',
credentialId: 'credential-1',
} satisfies AgentIntegrationConfig;
const draftAgent = { ...agent, activeVersionId: null } as Agent;
agentRepository.findByIdAndProjectId.mockResolvedValue(draftAgent);
managementService.connect.mockResolvedValue({
integration,
savedAgent: draftAgent,
});
expect(agentIntegrationPersistenceService.removeCredentialIntegration).toHaveBeenCalledWith(
const result = await controller.connectIntegration(
{
params: { projectId: agent.projectId },
user,
body: integration,
} as never,
undefined as never,
agent.id,
);
expect(result).toEqual({ status: 'configured' });
});
it('delegates disconnect without platform-specific cleanup', async () => {
const { controller, managementService, agentRepository } = makeController();
agentRepository.findByIdAndProjectId.mockResolvedValue(agent);
managementService.disconnect.mockResolvedValue({ savedAgent: agent });
const result = await controller.disconnectIntegration(
{
params: { projectId: agent.projectId },
user,
} as never,
undefined as never,
agent.id,
{ type: 'slack', credentialId: 'credential-1' },
);
expect(managementService.disconnect).toHaveBeenCalledWith({
agent,
'slack',
'',
{ user: { id: 'user-1' }, modifiedBy: 'user', broadcast: false },
);
});
it('starts Slack app setup with the temporary app configuration token', async () => {
const slackAppSetupService = mock<SlackAppSetupService>();
slackAppSetupService.createApp.mockResolvedValue({
appId: 'A123',
installUrl: 'https://slack.com/oauth/v2/authorize?state=setup-state',
});
const { controller } = makeController({ slackAppSetupService });
await expect(
controller.createSlackApp(
{
params: { projectId: 'project-1' },
user: { id: 'user-1' },
} as never,
undefined as never,
'agent-1',
{ appConfigurationToken: 'xoxe-config' },
),
).resolves.toEqual({
appId: 'A123',
installUrl: 'https://slack.com/oauth/v2/authorize?state=setup-state',
});
expect(slackAppSetupService.createApp).toHaveBeenCalledWith({
projectId: 'project-1',
agentId: 'agent-1',
appConfigurationToken: 'xoxe-config',
user: { id: 'user-1' },
});
});
it('returns the manual Slack app manifest', async () => {
const slackAppSetupService = mock<SlackAppSetupService>();
slackAppSetupService.getManualManifest.mockResolvedValue({
manifest: {
display_information: { name: 'Support Agent' },
features: {
app_home: {
home_tab_enabled: true,
messages_tab_enabled: true,
messages_tab_read_only_enabled: false,
},
bot_user: {
display_name: 'Support Agent',
always_online: true,
},
},
oauth_config: {
scopes: { bot: ['chat:write'] },
},
settings: {
event_subscriptions: {
request_url:
'https://hooks.example/rest/projects/project-1/agents/v2/agent-1/webhooks/slack',
bot_events: ['app_mention'],
},
interactivity: {
is_enabled: true,
request_url:
'https://hooks.example/rest/projects/project-1/agents/v2/agent-1/webhooks/slack',
},
org_deploy_enabled: false,
socket_mode_enabled: false,
token_rotation_enabled: false,
},
},
});
const { controller } = makeController({ slackAppSetupService });
await expect(
controller.getSlackAppManifest(
{ params: { projectId: 'project-1' } } as never,
undefined as never,
'agent-1',
),
).resolves.toEqual({
manifest: expect.objectContaining({
display_information: { name: 'Support Agent' },
oauth_config: {
scopes: { bot: ['chat:write'] },
},
}),
});
expect(slackAppSetupService.getManualManifest).toHaveBeenCalledWith({
projectId: 'project-1',
agentId: 'agent-1',
});
});
it('completes Slack app setup from the OAuth callback and renders the success template', async () => {
const slackAppSetupService = mock<SlackAppSetupService>();
const { controller } = makeController({ slackAppSetupService });
const res = { render: vi.fn() };
await controller.handleSlackAppOAuthCallback(
{
params: { projectId: 'project-1' },
query: { code: 'slack-code', state: 'setup-state' },
} as never,
res as never,
'agent-1',
);
expect(slackAppSetupService.completeInstall).toHaveBeenCalledWith({
projectId: 'project-1',
agentId: 'agent-1',
code: 'slack-code',
state: 'setup-state',
});
expect(res.render).toHaveBeenCalledWith('oauth-callback');
});
it('renders the Slack OAuth error callback when Slack denies setup', async () => {
const slackAppSetupService = mock<SlackAppSetupService>();
const { controller } = makeController({ slackAppSetupService });
const res = { render: vi.fn() };
await controller.handleSlackAppOAuthCallback(
{
params: { projectId: 'project-1' },
query: { error: 'access_denied', error_description: 'User denied install' },
} as never,
res as never,
'agent-1',
);
expect(slackAppSetupService.completeInstall).not.toHaveBeenCalled();
expect(res.render).toHaveBeenCalledWith('oauth-error-callback', {
error: {
message: 'access_denied',
reason: 'User denied install',
},
user,
type: 'slack',
credentialId: 'credential-1',
});
expect(result).toEqual({ status: 'disconnected' });
});
it('returns a platform webhook rejection without looking up a handler', async () => {
@@ -0,0 +1,55 @@
/* eslint-disable @typescript-eslint/unbound-method -- mock-based tests intentionally reference unbound methods */
import { mock } from 'vitest-mock-extended';
import { AgentSlackIntegrationsController } from '../agent-slack-integrations.controller';
import {
expectProjectScopedAgentRoutes,
getRoutesByHandlerName,
} from './test-utils/controller-route-metadata';
import type { SlackManualSetupService } from '../integrations/platforms/slack/slack-manual-setup.service';
const UNAUTHENTICATED_HANDLERS = new Set(['handleSlackAppOAuthCallback']);
describe('AgentSlackIntegrationsController', () => {
expectProjectScopedAgentRoutes(AgentSlackIntegrationsController, UNAUTHENTICATED_HANDLERS);
const routes = getRoutesByHandlerName(AgentSlackIntegrationsController);
it.each([
['createSlackApp', 'agent:update'],
['getSlackAppManifest', 'agent:read'],
])('%s uses %s', (handlerName, scope) => {
expect(routes.get(handlerName)?.accessScope?.scope).toBe(scope);
});
it('keeps the manual Slack route contracts', () => {
expect([...routes.values()].map((route) => route.path).sort()).toEqual([
'/:agentId/integrations/slack/app',
'/:agentId/integrations/slack/manifest',
'/:agentId/integrations/slack/oauth/callback',
]);
});
it('binds the callback state to the route project and agent', async () => {
const manualSetup = mock<SlackManualSetupService>();
const controller = new AgentSlackIntegrationsController(manualSetup);
const response = mock<{ render: (template: string, data?: unknown) => void }>();
await controller.handleSlackAppOAuthCallback(
{
params: { projectId: 'project-1', agentId: 'agent-1' },
query: { code: 'code-1', state: 'state-1' },
} as never,
response as never,
'agent-1',
);
expect(manualSetup.completeInstall).toHaveBeenCalledWith({
projectId: 'project-1',
agentId: 'agent-1',
code: 'code-1',
state: 'state-1',
});
expect(response.render).toHaveBeenCalledWith('oauth-callback');
});
});
@@ -0,0 +1,105 @@
import { AgentIntegrationSchema, type AgentIntegrationConfig } from '@n8n/api-types';
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
import { CredentialsService } from '@/credentials/credentials.service';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { AgentIntegrationPersistenceService } from './agent-integration-persistence.service';
import type { Agent } from './entities/agent.entity';
import { ChatIntegrationRegistry } from './integrations/agent-chat-integration';
import { ChatIntegrationService } from './integrations/chat-integration.service';
@Service()
export class AgentIntegrationManagementService {
constructor(
private readonly persistenceService: AgentIntegrationPersistenceService,
private readonly credentialsService: CredentialsService,
private readonly chatService: ChatIntegrationService,
private readonly registry: ChatIntegrationRegistry,
) {}
async validateConfig(input: unknown): Promise<AgentIntegrationConfig> {
const parsed = await AgentIntegrationSchema.safeParseAsync(input);
if (!parsed.success) throw new BadRequestError(parsed.error.message);
const integration = parsed.data;
this.registry.require(integration.type).validateConfig?.(integration);
return integration;
}
async connect(options: {
agent: Agent;
user: User;
integration: unknown;
modifiedBy?: 'user' | 'mcp';
}): Promise<{ integration: AgentIntegrationConfig; savedAgent: Agent }> {
const integration = await this.validateConfig(options.integration);
const implementation = this.registry.require(integration.type);
const usableCredentials = await this.credentialsService.getCredentialsAUserCanUseInAWorkflow(
options.user,
{ projectId: options.agent.projectId },
);
const credential = usableCredentials.find((item) => item.id === integration.credentialId);
if (!credential) {
throw new NotFoundError(`Credential "${integration.credentialId}" not found`);
}
if (!implementation.credentialTypes.includes(credential.type)) {
throw new BadRequestError(
`${implementation.displayLabel} integrations do not support ${credential.type} credentials`,
);
}
await this.chatService.validateBeforeConnect(
options.agent.id,
integration,
options.agent.projectId,
);
const savedAgent = await this.persistenceService.saveCredentialIntegration(
options.agent,
integration,
{ user: options.user, modifiedBy: options.modifiedBy ?? 'user', broadcast: false },
);
if (savedAgent.activeVersionId === null) return { integration, savedAgent };
await this.chatService.connect(options.agent.id, integration, options.agent.projectId);
await this.chatService.broadcastIntegrationChange(options.agent.id, integration, 'connect');
return { integration, savedAgent };
}
async disconnect(options: {
agent: Agent;
user: User;
type: string;
credentialId: string;
modifiedBy?: 'user' | 'mcp';
}): Promise<{ savedAgent: Agent }> {
const persisted = (options.agent.integrations ?? []).find(
(item) => item.type === options.type && item.credentialId === options.credentialId,
);
const parsed = AgentIntegrationSchema.safeParse({
type: options.type,
credentialId: options.credentialId,
});
const integration = persisted ?? (parsed.success ? parsed.data : undefined);
if (integration) {
await this.chatService.disconnectChannel(options.agent.id, integration);
} else {
await this.chatService.disconnect(options.agent.id, {
type: options.type,
credentialId: options.credentialId,
});
}
const savedAgent = await this.persistenceService.removeCredentialIntegration(
options.agent,
options.type,
options.credentialId,
{ user: options.user, modifiedBy: options.modifiedBy ?? 'user', broadcast: false },
);
return { savedAgent };
}
}
@@ -1,50 +1,29 @@
import {
AgentDisconnectIntegrationDto,
AgentIntegrationSchema,
isDraftIntegration,
type AgentIntegrationStatusResponse,
CreateSlackAgentAppDto,
type CreateSlackAgentAppResponse,
type SlackAgentAppManifestResponse,
} from '@n8n/api-types';
import type { AuthenticatedRequest } from '@n8n/db';
import { Body, Get, Param, Post, ProjectScope, RestController } from '@n8n/decorators';
import type { Request, Response } from 'express';
import { CredentialsService } from '@/credentials/credentials.service';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { AgentIntegrationPersistenceService } from './agent-integration-persistence.service';
import { AgentIntegrationManagementService } from './agent-integration-management.service';
import { ChatIntegrationRegistry } from './integrations/agent-chat-integration';
import { ChatIntegrationService } from './integrations/chat-integration.service';
import { channelIntegrationRecorder } from './integrations/recording/channel-integration-recorder';
import { SlackAppSetupService } from './integrations/slack-app-setup.service';
import { AgentRepository } from './repositories/agent.repository';
@RestController('/projects/:projectId/agents/v2')
export class AgentIntegrationsController {
constructor(
private readonly agentIntegrationPersistenceService: AgentIntegrationPersistenceService,
private readonly credentialsService: CredentialsService,
private readonly integrationManagementService: AgentIntegrationManagementService,
private readonly chatIntegrationService: ChatIntegrationService,
private readonly agentRepository: AgentRepository,
private readonly chatIntegrationRegistry: ChatIntegrationRegistry,
private readonly slackAppSetupService: SlackAppSetupService,
) {}
private async validateIntegration(dto: unknown) {
const integrationParseResult = await AgentIntegrationSchema.safeParseAsync(dto);
if (!integrationParseResult.success) {
throw new BadRequestError(integrationParseResult.error.message);
}
const integration = integrationParseResult.data;
if (integration.type === 'telegram' && !integration.settings) {
throw new BadRequestError('Telegram integration settings are required');
}
return integration;
}
@Post('/:agentId/integrations/connect')
@ProjectScope('agent:update')
async connectIntegration(
@@ -52,112 +31,19 @@ export class AgentIntegrationsController {
_res: Response,
@Param('agentId') agentId: string,
) {
const integration = await this.validateIntegration(req.body);
const { credentialId } = integration;
await this.integrationManagementService.validateConfig(req.body);
const agent = await this.agentRepository.findByIdAndProjectId(agentId, req.params.projectId);
if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`);
const usableCredentials = await this.credentialsService.getCredentialsAUserCanUseInAWorkflow(
req.user,
{ projectId: agent.projectId },
);
const credential = usableCredentials.find((c) => c.id === credentialId);
if (!credential) throw new NotFoundError(`Credential "${credentialId}" not found`);
const integrationImpl = this.chatIntegrationRegistry.require(integration.type);
if (!integrationImpl.credentialTypes.includes(credential.type)) {
throw new BadRequestError(
`${integrationImpl.displayLabel} integrations do not support ${credential.type} credentials`,
);
}
const savedAgent = await this.agentIntegrationPersistenceService.saveCredentialIntegration(
const { savedAgent } = await this.integrationManagementService.connect({
agent,
integration,
{ user: req.user, modifiedBy: 'user', broadcast: false },
);
user: req.user,
integration: req.body,
});
if (savedAgent.activeVersionId === null) return { status: 'configured' };
await this.chatIntegrationService.connect(agentId, integration, agent.projectId);
await this.chatIntegrationService.broadcastIntegrationChange(agentId, integration, 'connect');
return { status: 'connected' };
}
@Post('/:agentId/integrations/slack/app')
@ProjectScope('agent:update')
async createSlackApp(
req: AuthenticatedRequest<{ projectId: string }>,
_res: Response,
@Param('agentId') agentId: string,
@Body payload: CreateSlackAgentAppDto,
): Promise<CreateSlackAgentAppResponse> {
return await this.slackAppSetupService.createApp({
projectId: req.params.projectId,
agentId,
appConfigurationToken: payload.appConfigurationToken,
user: req.user,
});
}
@Get('/:agentId/integrations/slack/manifest')
@ProjectScope('agent:read')
async getSlackAppManifest(
req: AuthenticatedRequest<{ projectId: string }>,
_res: Response,
@Param('agentId') agentId: string,
): Promise<SlackAgentAppManifestResponse> {
return await this.slackAppSetupService.getManualManifest({
projectId: req.params.projectId,
agentId,
});
}
// Slack OAuth callback: do not add @ProjectScope. Authentication happens via
// the one-time setup state generated by the authenticated createSlackApp route.
@Get('/:agentId/integrations/slack/oauth/callback', { skipAuth: true, usesTemplates: true })
async handleSlackAppOAuthCallback(
req: Request<
{ projectId: string; agentId: string },
unknown,
unknown,
{ code?: string; state?: string; error?: string; error_description?: string }
>,
res: Response,
@Param('agentId') agentId: string,
) {
const { code, state, error, error_description: errorDescription } = req.query;
if (error) {
return res.render('oauth-error-callback', {
error: {
message: error,
...(errorDescription ? { reason: errorDescription } : {}),
},
});
}
if (!code || !state) {
return res.render('oauth-error-callback', {
error: { message: 'Insufficient parameters for Slack app setup callback.' },
});
}
try {
await this.slackAppSetupService.completeInstall({
projectId: req.params.projectId,
agentId,
code,
state,
});
return res.render('oauth-callback');
} catch (callbackError) {
const message =
callbackError instanceof Error ? callbackError.message : 'Slack app setup failed';
return res.render('oauth-error-callback', {
error: { message },
});
}
}
@Post('/:agentId/integrations/disconnect')
@ProjectScope('agent:update')
async disconnectIntegration(
@@ -169,24 +55,12 @@ export class AgentIntegrationsController {
const { type, credentialId } = payload;
const agent = await this.agentRepository.findByIdAndProjectId(agentId, req.params.projectId);
if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`);
const persistedIntegration = agent.integrations?.find(
(integration) => integration.type === type && integration.credentialId === credentialId,
);
const parsedIntegration = AgentIntegrationSchema.safeParse({ type, credentialId });
const integration =
persistedIntegration ?? (parsedIntegration.success ? parsedIntegration.data : undefined);
if (integration) {
await this.chatIntegrationService.disconnectChannel(agentId, integration);
} else {
await this.chatIntegrationService.disconnect(agentId, { type, credentialId });
}
await this.agentIntegrationPersistenceService.removeCredentialIntegration(
await this.integrationManagementService.disconnect({
agent,
user: req.user,
type,
credentialId,
{ user: req.user, modifiedBy: 'user', broadcast: false },
);
});
return { status: 'disconnected' };
}
@@ -0,0 +1,89 @@
import {
CreateSlackAgentAppDto,
type CreateSlackAgentAppResponse,
type SlackAgentAppManifestResponse,
} from '@n8n/api-types';
import type { AuthenticatedRequest } from '@n8n/db';
import { Body, Get, Param, Post, ProjectScope, RestController } from '@n8n/decorators';
import type { Request, Response } from 'express';
import { SlackManualSetupService } from './integrations/platforms/slack/slack-manual-setup.service';
@RestController('/projects/:projectId/agents/v2')
export class AgentSlackIntegrationsController {
constructor(private readonly manualSetup: SlackManualSetupService) {}
@Post('/:agentId/integrations/slack/app')
@ProjectScope('agent:update')
async createSlackApp(
req: AuthenticatedRequest<{ projectId: string }>,
_res: Response,
@Param('agentId') agentId: string,
@Body payload: CreateSlackAgentAppDto,
): Promise<CreateSlackAgentAppResponse> {
return await this.manualSetup.createApp({
projectId: req.params.projectId,
agentId,
appConfigurationToken: payload.appConfigurationToken,
user: req.user,
});
}
@Get('/:agentId/integrations/slack/manifest')
@ProjectScope('agent:read')
async getSlackAppManifest(
req: AuthenticatedRequest<{ projectId: string }>,
_res: Response,
@Param('agentId') agentId: string,
): Promise<SlackAgentAppManifestResponse> {
return await this.manualSetup.getManifest({
projectId: req.params.projectId,
agentId,
});
}
// Slack OAuth callback: authentication uses the encrypted one-time state
// generated by the authenticated createSlackApp route.
@Get('/:agentId/integrations/slack/oauth/callback', { skipAuth: true, usesTemplates: true })
async handleSlackAppOAuthCallback(
req: Request<
{ projectId: string; agentId: string },
unknown,
unknown,
{ code?: string; state?: string; error?: string; error_description?: string }
>,
res: Response,
@Param('agentId') agentId: string,
) {
const { code, state, error, error_description: errorDescription } = req.query;
if (error) {
return res.render('oauth-error-callback', {
error: {
message: error,
...(errorDescription ? { reason: errorDescription } : {}),
},
});
}
if (!code || !state) {
return res.render('oauth-error-callback', {
error: { message: 'Insufficient parameters for Slack app setup callback.' },
});
}
try {
await this.manualSetup.completeInstall({
projectId: req.params.projectId,
agentId,
code,
state,
});
return res.render('oauth-callback');
} catch (callbackError) {
const message =
callbackError instanceof Error ? callbackError.message : 'Slack app setup failed';
return res.render('oauth-error-callback', {
error: { message },
});
}
}
}
@@ -19,6 +19,7 @@ export class AgentsModule implements ModuleInterface {
await import('./agent-publish.controller.js');
await import('./agent-chat.controller.js');
await import('./agent-integrations.controller.js');
await import('./agent-slack-integrations.controller.js');
await import('./agent-vector-stores.controller.js');
await import('./agent-tasks.controller.js');
await import('./agent-sandbox.controller.js');
@@ -72,7 +73,9 @@ export class AgentsModule implements ModuleInterface {
// Populate the integration registry with supported chat platforms.
// Adding a new platform is adding one subclass + one register() call.
const { ChatIntegrationRegistry } = await import('./integrations/agent-chat-integration.js');
const { SlackIntegration } = await import('./integrations/platforms/slack-integration.js');
const { SlackIntegration } = await import(
'./integrations/platforms/slack/slack-integration.js'
);
const { TelegramIntegration } = await import(
'./integrations/platforms/telegram-integration.js'
);
@@ -14,7 +14,7 @@ import {
} from '../agent-chat-integration';
import type { ComponentMapper } from '../component-mapper';
import type { IntegrationMessageContextService } from '../integration-message-context.service';
import { SlackIntegration } from '../platforms/slack-integration';
import { SlackIntegration } from '../platforms/slack/slack-integration';
import type { AgentIntegrationConfig } from '@n8n/api-types';
import type { RichCardComponentType } from '@n8n/api-types';
@@ -70,7 +70,7 @@ vi.mock('../esm-loader', () => {
import { ComponentMapper } from '../component-mapper';
import { ChatIntegrationRegistry } from '../agent-chat-integration';
import { SlackIntegration } from '../platforms/slack-integration';
import { SlackIntegration } from '../platforms/slack/slack-integration';
import { Container } from '@n8n/di';
describe('ComponentMapper', () => {
@@ -9,7 +9,7 @@ import type {
getIntegrationToolConnectionDescriptors,
IntegrationMessageContext,
} from '../../../integration-tools';
import { SlackIntegration } from '../../../platforms/slack-integration';
import { SlackIntegration } from '../../../platforms/slack/slack-integration';
import {
createReplayContextSetup,
type MemoryMessageContextStore,
@@ -56,7 +56,7 @@ import {
import { ChatIntegrationActionExecutor } from '../integration-action-executor';
import { getIntegrationToolConnectionDescriptors } from '../integration-tools';
import { LinearIntegration } from '../platforms/linear-integration';
import { SlackIntegration } from '../platforms/slack-integration';
import { SlackIntegration } from '../platforms/slack/slack-integration';
import type { ChatIntegrationService, ChatInstance } from '../chat-integration.service';
import type { AgentIntegrationConfig } from '@n8n/api-types';
import type { RichCardComponentType } from '@n8n/api-types';
@@ -7,7 +7,7 @@ import type { ChatIntegrationService, ChatInstance } from '../chat-integration.s
import { ChatIntegrationContextQueryExecutor } from '../integration-context-query-executor';
import { getIntegrationToolConnectionDescriptors } from '../integration-tools';
import { LinearIntegration } from '../platforms/linear-integration';
import { SlackIntegration } from '../platforms/slack-integration';
import { SlackIntegration } from '../platforms/slack/slack-integration';
import type { AgentIntegrationConfig } from '@n8n/api-types';
const slack: AgentIntegrationConfig = {
@@ -1,472 +0,0 @@
import type { Mock, Mocked } from 'vitest';
import type { HttpRequestClient, OutboundHttp } from '@n8n/backend-network';
import type { User, UserRepository } from '@n8n/db';
import { mock } from 'vitest-mock-extended';
import type { Cipher } from 'n8n-core';
import type { CredentialsService } from '@/credentials/credentials.service';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import type { CacheService } from '@/services/cache/cache.service';
import type { UrlService } from '@/services/url.service';
import type { AgentIntegrationPersistenceService } from '../../agent-integration-persistence.service';
import type { AgentRepository } from '../../repositories/agent.repository';
import type { ChatIntegrationService } from '../chat-integration.service';
import { SlackAppSetupService } from '../slack-app-setup.service';
const agent = {
id: 'agent-1',
projectId: 'project-1',
name: 'Support Agent',
activeVersionId: 'v1',
activeVersion: {},
integrations: [],
};
const unpublishedAgent = {
...agent,
activeVersionId: null,
activeVersion: null,
};
const user = { id: 'user-1' } as User;
function slackResponse(body: Record<string, unknown>) {
return { statusCode: 200, body };
}
function slackAppCreatedResponse() {
return slackResponse({
ok: true,
app_id: 'A123',
credentials: {
client_id: 'C123',
client_secret: 'client-secret',
signing_secret: 'signing-secret',
},
oauth_authorize_url: 'https://slack.com/oauth/v2/authorize?client_id=C123&scope=chat%3Awrite',
});
}
function slackOAuthResponse() {
return slackResponse({
ok: true,
access_token: 'xoxb-installed-token',
token_type: 'bot',
app_id: 'A123',
});
}
function fetchParams(requestMock: Mock, callIndex: number) {
const request = requestMock.mock.calls[callIndex]?.[0] as {
headers?: Record<string, string>;
body: Record<string, string>;
};
// The body is passed as a plain object; OutboundHttp/axios only serializes it as
// form-urlencoded when this content-type is set, so assert the contract here.
expect(request.headers?.['Content-Type']).toBe('application/x-www-form-urlencoded');
return new URLSearchParams(request.body);
}
describe('SlackAppSetupService', () => {
let requestMock: Mock;
let outboundHttp: Mocked<OutboundHttp>;
let cacheStore: Map<string, unknown>;
let cacheService: Mocked<CacheService>;
let cipher: Mocked<Cipher>;
let credentialsService: Mocked<CredentialsService>;
let userRepository: Mocked<UserRepository>;
let agentRepository: Mocked<AgentRepository>;
let agentIntegrationPersistenceService: Mocked<
Pick<AgentIntegrationPersistenceService, 'saveCredentialIntegration'>
>;
let chatIntegrationService: Mocked<ChatIntegrationService>;
let service: SlackAppSetupService;
beforeEach(() => {
const httpClient = mock<HttpRequestClient>();
requestMock = httpClient.request as Mock;
outboundHttp = mock<OutboundHttp>();
outboundHttp.requests.mockReturnValue(httpClient);
cacheStore = new Map<string, unknown>();
cacheService = mock<CacheService>();
cacheService.set.mockImplementation(async (key: string, value: unknown) => {
cacheStore.set(key, value);
});
cacheService.get.mockImplementation(async (key: string) => cacheStore.get(key));
cacheService.delete.mockImplementation(async (key: string) => {
cacheStore.delete(key);
});
cipher = mock<Cipher>();
cipher.encryptV2.mockImplementation(async (data: string | object) => {
const plaintext = typeof data === 'string' ? data : JSON.stringify(data);
return `encrypted:${Buffer.from(plaintext).toString('base64')}`;
});
cipher.decryptV2.mockImplementation(async (data: string) =>
Buffer.from(data.replace(/^encrypted:/, ''), 'base64').toString(),
);
credentialsService = mock<CredentialsService>();
userRepository = mock<UserRepository>();
agentRepository = mock<AgentRepository>();
agentRepository.findByIdAndProjectId.mockResolvedValue(agent as never);
agentIntegrationPersistenceService =
mock<Pick<AgentIntegrationPersistenceService, 'saveCredentialIntegration'>>();
chatIntegrationService = mock<ChatIntegrationService>();
const urlService = mock<UrlService>();
urlService.getWebhookBaseUrl.mockReturnValue('https://hooks.example/');
service = new SlackAppSetupService(
cacheService,
cipher,
credentialsService,
userRepository,
agentRepository,
agentIntegrationPersistenceService as unknown as AgentIntegrationPersistenceService,
chatIntegrationService,
urlService,
outboundHttp,
);
});
async function beginInstall() {
requestMock.mockResolvedValueOnce(slackAppCreatedResponse());
const { installUrl } = await service.createApp({
projectId: 'project-1',
agentId: 'agent-1',
appConfigurationToken: 'xoxe-config',
user,
});
return new URL(installUrl).searchParams.get('state') ?? '';
}
it('creates a Slack app from an agent manifest and returns an install URL with state', async () => {
requestMock.mockResolvedValueOnce(slackAppCreatedResponse());
const result = await service.createApp({
projectId: 'project-1',
agentId: 'agent-1',
appConfigurationToken: 'xoxe-config',
user,
});
expect(requestMock).toHaveBeenCalledWith(
expect.objectContaining({
url: 'https://slack.com/api/apps.manifest.create',
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/x-www-form-urlencoded',
}),
}),
);
const createParams = fetchParams(requestMock, 0);
expect(createParams.get('token')).toBe('xoxe-config');
const manifest = JSON.parse(createParams.get('manifest') ?? '') as {
features: {
app_home: {
home_tab_enabled: boolean;
messages_tab_enabled: boolean;
messages_tab_read_only_enabled: boolean;
};
};
oauth_config: { redirect_urls: string[]; scopes: { bot: string[] } };
settings: {
event_subscriptions: { request_url: string; bot_events: string[] };
interactivity: { is_enabled: boolean; request_url: string };
socket_mode_enabled: boolean;
token_rotation_enabled: boolean;
};
};
const webhookUrl =
'https://hooks.example/rest/projects/project-1/agents/v2/agent-1/webhooks/slack';
const callbackUrl =
'https://hooks.example/rest/projects/project-1/agents/v2/agent-1/integrations/slack/oauth/callback';
expect(manifest.oauth_config.redirect_urls).toEqual([callbackUrl]);
expect(manifest.features.app_home).toEqual({
home_tab_enabled: false,
messages_tab_enabled: true,
messages_tab_read_only_enabled: false,
});
expect(manifest.oauth_config.scopes.bot).toEqual(
expect.arrayContaining(['channels:history', 'groups:history', 'im:history', 'mpim:history']),
);
expect(manifest.settings.event_subscriptions.request_url).toBe(webhookUrl);
expect(manifest.settings.event_subscriptions.bot_events).toEqual([
'app_mention',
'assistant_thread_started',
'assistant_thread_context_changed',
'message.channels',
'message.groups',
'message.im',
'message.mpim',
]);
expect(manifest.settings.interactivity).toEqual({
is_enabled: true,
request_url: webhookUrl,
});
expect(manifest.settings.socket_mode_enabled).toBe(false);
expect(manifest.settings.token_rotation_enabled).toBe(false);
expect(result.appId).toBe('A123');
const installUrl = new URL(result.installUrl);
const state = installUrl.searchParams.get('state');
expect(state).toBeTruthy();
expect(installUrl.searchParams.get('redirect_uri')).toBe(callbackUrl);
expect(cacheService.set).toHaveBeenCalledWith(
`agents:slack-app-setup:${state}`,
expect.stringMatching(/^encrypted:/),
60 * 60 * 1000,
);
const cachedSession = cacheStore.get(`agents:slack-app-setup:${state}`);
expect(cachedSession).not.toEqual(expect.objectContaining({ clientSecret: 'client-secret' }));
expect(String(cachedSession)).not.toContain('client-secret');
expect(String(cachedSession)).not.toContain('signing-secret');
const plaintextSession = JSON.parse(cipher.encryptV2.mock.calls[0]?.[0] as string) as {
agentId: string;
projectId: string;
userId: string;
appId: string;
clientId: string;
clientSecret: string;
signingSecret: string;
redirectUrl: string;
};
expect(plaintextSession).toEqual({
agentId: 'agent-1',
projectId: 'project-1',
userId: 'user-1',
appId: 'A123',
clientId: 'C123',
clientSecret: 'client-secret',
signingSecret: 'signing-secret',
redirectUrl: callbackUrl,
});
expect(credentialsService.createUnmanagedCredential).not.toHaveBeenCalled();
expect(chatIntegrationService.connect).not.toHaveBeenCalled();
});
it('returns the manual Slack app manifest without OAuth redirect URLs', async () => {
const result = await service.getManualManifest({
projectId: 'project-1',
agentId: 'agent-1',
});
expect(result.manifest.display_information.name).toBe('Support Agent');
expect(result.manifest.features.app_home).toEqual({
home_tab_enabled: false,
messages_tab_enabled: true,
messages_tab_read_only_enabled: false,
});
expect(result.manifest.oauth_config).not.toHaveProperty('redirect_urls');
expect(result.manifest.oauth_config.scopes.bot).toContain('chat:write');
expect(result.manifest.settings.event_subscriptions.request_url).toBe(
'https://hooks.example/rest/projects/project-1/agents/v2/agent-1/webhooks/slack',
);
expect(result.manifest.settings.interactivity).toEqual({
is_enabled: true,
request_url: 'https://hooks.example/rest/projects/project-1/agents/v2/agent-1/webhooks/slack',
});
expect(requestMock).not.toHaveBeenCalled();
});
it('saves, connects, and broadcasts a Slack integration for a published agent', async () => {
requestMock.mockResolvedValueOnce(slackAppCreatedResponse()).mockResolvedValueOnce(
slackResponse({
ok: true,
access_token: 'xoxb-installed-token',
token_type: 'bot',
app_id: 'A123',
}),
);
userRepository.findOne.mockResolvedValue(user);
credentialsService.createUnmanagedCredential.mockResolvedValue({ id: 'cred-slack' } as never);
agentIntegrationPersistenceService.saveCredentialIntegration.mockResolvedValue(agent as never);
const { installUrl } = await service.createApp({
projectId: 'project-1',
agentId: 'agent-1',
appConfigurationToken: 'xoxe-config',
user,
});
const state = new URL(installUrl).searchParams.get('state');
expect(state).toBeTruthy();
const encryptedSession = cacheStore.get(`agents:slack-app-setup:${state}`);
await service.completeInstall({
projectId: 'project-1',
agentId: 'agent-1',
code: 'slack-code',
state: state ?? '',
});
const tokenRequest = requestMock.mock.calls[1]?.[0] as {
url: string;
headers: Record<string, string>;
};
expect(tokenRequest.url).toBe('https://slack.com/api/oauth.v2.access');
expect(tokenRequest.headers).toEqual(
expect.objectContaining({
Authorization: `Basic ${Buffer.from('C123:client-secret').toString('base64')}`,
'Content-Type': 'application/x-www-form-urlencoded',
}),
);
const tokenParams = fetchParams(requestMock, 1);
expect(tokenParams.get('code')).toBe('slack-code');
expect(tokenParams.get('redirect_uri')).toBe(
'https://hooks.example/rest/projects/project-1/agents/v2/agent-1/integrations/slack/oauth/callback',
);
expect(userRepository.findOne).toHaveBeenCalledWith({
where: { id: 'user-1' },
relations: ['role'],
});
expect(credentialsService.createUnmanagedCredential).toHaveBeenCalledWith(
{
name: 'Slack - Support Agent',
type: 'slackApi',
data: {
accessToken: 'xoxb-installed-token',
signatureSecret: 'signing-secret',
},
projectId: 'project-1',
},
user,
);
const integration = { type: 'slack', credentialId: 'cred-slack' };
expect(chatIntegrationService.connect).toHaveBeenCalledWith(
'agent-1',
integration,
'project-1',
);
expect(agentIntegrationPersistenceService.saveCredentialIntegration).toHaveBeenCalledWith(
agent,
integration,
{
user,
modifiedBy: 'user',
broadcast: false,
},
);
expect(chatIntegrationService.broadcastIntegrationChange).toHaveBeenCalledWith(
'agent-1',
integration,
'connect',
);
expect(
agentIntegrationPersistenceService.saveCredentialIntegration.mock.invocationCallOrder[0],
).toBeLessThan(chatIntegrationService.connect.mock.invocationCallOrder[0]);
expect(chatIntegrationService.connect.mock.invocationCallOrder[0]).toBeLessThan(
chatIntegrationService.broadcastIntegrationChange.mock.invocationCallOrder[0],
);
expect(cacheService.delete).toHaveBeenCalledWith(`agents:slack-app-setup:${state}`);
expect(cipher.decryptV2).toHaveBeenCalledWith(encryptedSession);
});
it('does not broadcast when connecting a published Slack install fails', async () => {
const state = await beginInstall();
requestMock.mockResolvedValueOnce(slackOAuthResponse());
userRepository.findOne.mockResolvedValue(user);
credentialsService.createUnmanagedCredential.mockResolvedValue({ id: 'cred-slack' } as never);
agentIntegrationPersistenceService.saveCredentialIntegration.mockResolvedValue(agent as never);
const connectError = new Error('Slack connect failed');
chatIntegrationService.connect.mockRejectedValue(connectError);
await expect(
service.completeInstall({
projectId: 'project-1',
agentId: 'agent-1',
code: 'slack-code',
state,
}),
).rejects.toBe(connectError);
expect(agentIntegrationPersistenceService.saveCredentialIntegration).toHaveBeenCalledWith(
agent,
{ type: 'slack', credentialId: 'cred-slack' },
{ user, modifiedBy: 'user', broadcast: false },
);
expect(chatIntegrationService.connect).toHaveBeenCalledWith(
'agent-1',
{ type: 'slack', credentialId: 'cred-slack' },
'project-1',
);
expect(
agentIntegrationPersistenceService.saveCredentialIntegration.mock.invocationCallOrder[0],
).toBeLessThan(chatIntegrationService.connect.mock.invocationCallOrder[0]);
expect(chatIntegrationService.broadcastIntegrationChange).not.toHaveBeenCalled();
});
it('saves without connecting or broadcasting for an unpublished agent', async () => {
agentRepository.findByIdAndProjectId
.mockResolvedValueOnce(agent as never)
.mockResolvedValueOnce(unpublishedAgent as never);
agentIntegrationPersistenceService.saveCredentialIntegration.mockResolvedValue(
unpublishedAgent as never,
);
requestMock.mockResolvedValueOnce(slackAppCreatedResponse()).mockResolvedValueOnce(
slackResponse({
ok: true,
access_token: 'xoxb-installed-token',
token_type: 'bot',
app_id: 'A123',
}),
);
userRepository.findOne.mockResolvedValue(user);
credentialsService.createUnmanagedCredential.mockResolvedValue({ id: 'cred-slack' } as never);
const { installUrl } = await service.createApp({
projectId: 'project-1',
agentId: 'agent-1',
appConfigurationToken: 'xoxe-config',
user,
});
const state = new URL(installUrl).searchParams.get('state') ?? '';
await service.completeInstall({
projectId: 'project-1',
agentId: 'agent-1',
code: 'slack-code',
state,
});
const integration = { type: 'slack', credentialId: 'cred-slack' };
expect(agentIntegrationPersistenceService.saveCredentialIntegration).toHaveBeenCalledWith(
unpublishedAgent,
integration,
{
user,
modifiedBy: 'user',
broadcast: false,
},
);
expect(chatIntegrationService.connect).not.toHaveBeenCalled();
expect(chatIntegrationService.broadcastIntegrationChange).not.toHaveBeenCalled();
});
it('rejects a callback state that does not belong to the requested project and agent', async () => {
requestMock.mockResolvedValueOnce(slackAppCreatedResponse());
const { installUrl } = await service.createApp({
projectId: 'project-1',
agentId: 'agent-1',
appConfigurationToken: 'xoxe-config',
user,
});
const state = new URL(installUrl).searchParams.get('state') ?? '';
await expect(
service.completeInstall({
projectId: 'project-2',
agentId: 'agent-1',
code: 'slack-code',
state,
}),
).rejects.toThrow(BadRequestError);
expect(credentialsService.createUnmanagedCredential).not.toHaveBeenCalled();
expect(chatIntegrationService.connect).not.toHaveBeenCalled();
});
});
@@ -1,5 +1,11 @@
import { SlackIntegration } from '../platforms/slack-integration';
/* eslint-disable @typescript-eslint/unbound-method -- mock-based tests intentionally reference unbound methods */
import { mock } from 'vitest-mock-extended';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import type { AgentRepository } from '../../repositories/agent.repository';
import type { ChatInstance } from '../chat-integration.service';
import { SlackIntegration } from '../platforms/slack/slack-integration';
describe('SlackIntegration', () => {
let integration: SlackIntegration;
@@ -22,6 +28,25 @@ describe('SlackIntegration', () => {
expect(integration.credentialTypes).toEqual(['slackApi']);
});
it('rejects a credential already connected to another agent', async () => {
const agentRepository = mock<AgentRepository>();
agentRepository.findByIntegrationCredential.mockResolvedValue([
{ id: 'other-agent', name: 'Other Agent' },
] as never);
integration = new SlackIntegration(agentRepository);
await expect(
integration.onBeforeConnect({
agentId: 'agent-1',
projectId: 'project-1',
credentialId: 'credential-1',
credential: {},
ingressEnabled: true,
webhookUrlFor: vi.fn(),
}),
).rejects.toThrow(ConflictError);
});
it('extracts the Slack bot user ID for bridge message context', () => {
const chat = {
getAdapter: vi.fn().mockReturnValue({ botUserId: 'U_BOT' }),
@@ -0,0 +1,171 @@
/* eslint-disable @typescript-eslint/unbound-method -- mock-based tests intentionally reference unbound methods */
import type { UserRepository } from '@n8n/db';
import type { Cipher } from 'n8n-core';
import { mock } from 'vitest-mock-extended';
import type { CacheService } from '@/services/cache/cache.service';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { SlackManualSetupService } from '../platforms/slack/slack-manual-setup.service';
import type { SlackMethodsService } from '../platforms/slack/slack-methods.service';
describe('SlackManualSetupService', () => {
function makeService() {
const methods = mock<SlackMethodsService>();
const userRepository = mock<UserRepository>();
const cacheService = mock<CacheService>();
const cipher = mock<Cipher>();
return {
service: new SlackManualSetupService(methods, userRepository, cacheService, cipher),
methods,
userRepository,
cacheService,
cipher,
};
}
it('creates a manual app and stores encrypted callback state', async () => {
const { service, methods, cacheService, cipher } = makeService();
const user = { id: 'user-1' };
const redirectUrl = 'https://n8n.example/callback';
methods.getAgent.mockResolvedValue({ id: 'agent-1', name: 'Support Agent' } as never);
methods.callbackUrl.mockReturnValue(redirectUrl);
methods.buildManifest.mockReturnValue({
display_information: { name: 'Support Agent' },
} as never);
methods.callSlackApi.mockResolvedValue({
ok: true,
app_id: 'app-1',
oauth_authorize_url: 'https://slack.com/oauth/v2/authorize',
credentials: {
client_id: 'client-1',
client_secret: 'secret-1',
signing_secret: 'signing-1',
},
});
methods.childRecord.mockImplementation((record, key) => record[key] as never);
methods.stringProperty.mockImplementation((record, key) => {
const value = record?.[key];
return typeof value === 'string' ? value : undefined;
});
methods.installUrl.mockImplementation((oauthUrl, state, callbackUrl) => {
const installUrl = new URL(oauthUrl);
installUrl.searchParams.set('state', state);
installUrl.searchParams.set('redirect_uri', callbackUrl);
return installUrl.toString();
});
cipher.encryptV2.mockResolvedValue('encrypted-session-without-secrets');
const result = await service.createApp({
projectId: 'project-1',
agentId: 'agent-1',
appConfigurationToken: 'configuration-token',
user: user as never,
});
const installUrl = new URL(result.installUrl);
const state = installUrl.searchParams.get('state');
expect(state).toBeTruthy();
expect(installUrl.searchParams.get('redirect_uri')).toBe(redirectUrl);
expect(methods.buildManifest).toHaveBeenCalledWith('Support Agent', 'project-1', 'agent-1', {
redirectUrl,
});
expect(cacheService.set).toHaveBeenCalledWith(
`agents:slack-app-setup:${state}`,
'encrypted-session-without-secrets',
60 * 60 * 1000,
);
const session = JSON.parse(cipher.encryptV2.mock.calls[0]?.[0] as string) as {
projectId: string;
agentId: string;
userId: string;
appId: string;
clientId: string;
clientSecret: string;
signingSecret: string;
redirectUrl: string;
};
expect(session).toEqual({
projectId: 'project-1',
agentId: 'agent-1',
userId: 'user-1',
appId: 'app-1',
clientId: 'client-1',
clientSecret: 'secret-1',
signingSecret: 'signing-1',
redirectUrl,
});
expect(result.appId).toBe('app-1');
});
it('completes OAuth by atomically consuming state and creating the bot credential', async () => {
const { service, methods, userRepository, cacheService, cipher } = makeService();
const user = { id: 'user-1' };
const agent = {
id: 'agent-1',
projectId: 'project-1',
name: 'Support Agent',
};
const session = {
projectId: 'project-1',
agentId: 'agent-1',
userId: 'user-1',
appId: 'app-1',
clientId: 'client-1',
clientSecret: 'secret-1',
signingSecret: 'signing-1',
redirectUrl: 'https://n8n.example/callback',
};
cacheService.take.mockResolvedValue('encrypted-session');
cipher.decryptV2.mockResolvedValue(JSON.stringify(session));
userRepository.findOne.mockResolvedValue(user as never);
methods.getAgent.mockResolvedValue(agent as never);
methods.callSlackApi.mockResolvedValue({ ok: true, access_token: 'xoxb-token' });
methods.stringProperty.mockReturnValue('xoxb-token');
await service.completeInstall({
projectId: 'project-1',
agentId: 'agent-1',
code: 'oauth-code',
state: 'state-1',
});
expect(cacheService.take).toHaveBeenCalledWith('agents:slack-app-setup:state-1');
expect(cacheService.delete).not.toHaveBeenCalled();
expect(methods.createAndConnectBotCredential).toHaveBeenCalledWith({
agent,
user,
accessToken: 'xoxb-token',
signingSecret: 'signing-1',
});
});
it('rejects callback state for a different project or agent', async () => {
const { service, methods, cacheService, cipher } = makeService();
cacheService.take.mockResolvedValue('encrypted-session');
cipher.decryptV2.mockResolvedValue(
JSON.stringify({
projectId: 'project-1',
agentId: 'agent-1',
userId: 'user-1',
appId: 'app-1',
clientId: 'client-1',
clientSecret: 'secret-1',
signingSecret: 'signing-1',
redirectUrl: 'https://n8n.example/callback',
}),
);
await expect(
service.completeInstall({
projectId: 'project-2',
agentId: 'agent-1',
code: 'oauth-code',
state: 'state-1',
}),
).rejects.toThrow(BadRequestError);
expect(methods.callSlackApi).not.toHaveBeenCalled();
expect(methods.createAndConnectBotCredential).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,106 @@
/* eslint-disable @typescript-eslint/unbound-method -- mock-based tests intentionally reference unbound methods */
import type { OutboundHttp } from '@n8n/backend-network';
import { mock } from 'vitest-mock-extended';
import type { CredentialsService } from '@/credentials/credentials.service';
import type { UrlService } from '@/services/url.service';
import type { AgentIntegrationManagementService } from '../../agent-integration-management.service';
import type { Agent } from '../../entities/agent.entity';
import type { AgentRepository } from '../../repositories/agent.repository';
import { SlackMethodsService } from '../platforms/slack/slack-methods.service';
describe('SlackMethodsService', () => {
function makeService() {
const credentialsService = mock<CredentialsService>();
const managementService = mock<AgentIntegrationManagementService>();
const urlService = mock<UrlService>();
urlService.getWebhookBaseUrl.mockReturnValue('https://hooks.example/');
return {
service: new SlackMethodsService(
credentialsService,
mock<AgentRepository>(),
managementService,
urlService,
mock<OutboundHttp>(),
),
credentialsService,
managementService,
};
}
it('creates a bot credential and delegates published activation to integration management', async () => {
const { service, credentialsService, managementService } = makeService();
const agent = {
id: 'agent-1',
projectId: 'project-1',
name: 'Support Agent',
activeVersionId: 'version-1',
} as unknown as Agent;
const user = { id: 'user-1' };
credentialsService.createUnmanagedCredential.mockResolvedValue({
id: 'credential-1',
} as never);
managementService.connect.mockResolvedValue({
integration: { type: 'slack', credentialId: 'credential-1' },
savedAgent: agent,
});
await service.createAndConnectBotCredential({
agent,
user: user as never,
accessToken: 'xoxb-token',
signingSecret: 'signing-secret',
});
expect(credentialsService.createUnmanagedCredential).toHaveBeenCalledWith(
{
name: 'Slack - Support Agent',
type: 'slackApi',
data: {
accessToken: 'xoxb-token',
signatureSecret: 'signing-secret',
},
projectId: 'project-1',
},
user,
);
expect(managementService.connect).toHaveBeenCalledWith({
agent,
user,
integration: { type: 'slack', credentialId: 'credential-1' },
});
});
it('builds the manual manifest without OAuth redirect URLs', () => {
const { service } = makeService();
const manifest = service.buildManifest('Support Agent', 'project-1', 'agent-1');
expect(manifest.display_information.name).toBe('Support Agent');
expect(manifest.oauth_config).not.toHaveProperty('redirect_urls');
expect(manifest.oauth_config.scopes.bot).toContain('chat:write');
expect(manifest.settings.event_subscriptions.request_url).toBe(
'https://hooks.example/rest/projects/project-1/agents/v2/agent-1/webhooks/slack',
);
expect(manifest.settings.interactivity).toEqual({
is_enabled: true,
request_url: 'https://hooks.example/rest/projects/project-1/agents/v2/agent-1/webhooks/slack',
});
});
it('adds callback state and redirect URL to the Slack install URL', () => {
const { service } = makeService();
const result = new URL(
service.installUrl(
'https://slack.com/oauth/v2/authorize?client_id=client-1',
'state-1',
'https://n8n.example/callback',
),
);
expect(result.searchParams.get('client_id')).toBe('client-1');
expect(result.searchParams.get('state')).toBe('state-1');
expect(result.searchParams.get('redirect_uri')).toBe('https://n8n.example/callback');
});
});
@@ -1,9 +1,8 @@
import { AgentIntegrationConfig, type RichCardComponentType } from '@n8n/api-types';
import { Service } from '@n8n/di';
import type { Thread, Author, Message } from 'chat';
import type { Logger } from 'n8n-workflow';
import { AgentIntegrationConfig } from '@n8n/api-types';
import type { RichCardComponentType } from '@n8n/api-types';
import type { ChatInstance } from './chat-integration.service';
import type { SuspendComponent } from './component-mapper';
import {
@@ -260,6 +259,9 @@ export abstract class AgentChatIntegration {
/** Build the Chat SDK adapter for this platform. */
abstract createAdapter(ctx: AgentChatIntegrationContext): Promise<unknown>;
/** Validate platform-specific configuration before credentials or persistence are touched. */
validateConfig?(integration: AgentIntegrationConfig): void;
/**
* Handle a webhook request that arrives before an integration is connected
* (i.e. before credentials are configured). The canonical case is Slack's
@@ -313,6 +315,12 @@ export abstract class AgentChatIntegration {
*/
onBeforeDisconnect?(ctx: AgentChatIntegrationContext): Promise<void>;
/**
* Prepare a thread created or selected by an outbound send. Platforms can
* use this to receive follow-up messages in that thread.
*/
prepareSentThread?(thread: Thread<unknown, unknown>): Promise<void>;
/**
* Optional hook run on EVERY main once the connection is live, regardless
* of `skipExternalHooks`. Unlike `onAfterConnect`, this is for local runtime
@@ -68,6 +68,7 @@ interface ChatAgentConnection {
interface ConnectOptions {
ingressEnabled?: boolean;
skipExternalHooks?: boolean;
skipBeforeConnect?: boolean;
settings?: AgentIntegrationSettings;
}
@@ -157,6 +158,26 @@ export class ChatIntegrationService {
return type ? this.integrationRegistry.get(type) : undefined;
}
async validateBeforeConnect(
agentId: string,
integration: AgentIntegrationConfig,
projectId: string,
): Promise<void> {
const implementation = this.integrationRegistry.require(integration.type);
implementation.validateConfig?.(integration);
if (!implementation.onBeforeConnect) return;
const credential = await this.decryptCredentialForProject(integration.credentialId, projectId);
await implementation.onBeforeConnect({
agentId,
projectId,
credentialId: integration.credentialId,
credential,
ingressEnabled: true,
webhookUrlFor: (platform) => this.buildWebhookUrl(agentId, projectId, platform),
});
}
/**
* Connect an agent to a chat platform via the Chat SDK.
*
@@ -206,7 +227,12 @@ export class ChatIntegrationService {
// Pre-connect hook — webhook-based platforms use this to detect
// credential conflicts (e.g. a Telegram bot token already in use) and
// abort the connect before we touch any external API.
if (ingressEnabled && integrationImpl.onBeforeConnect && !options.skipExternalHooks) {
if (
ingressEnabled &&
integrationImpl.onBeforeConnect &&
!options.skipExternalHooks &&
!options.skipBeforeConnect
) {
await integrationImpl.onBeforeConnect(ctx);
}
@@ -4,7 +4,7 @@ import { isRecord } from '@n8n/utils/is-record';
import type { Adapter, SentMessage } from 'chat';
import { z } from 'zod';
import { ChatIntegrationRegistry } from './agent-chat-integration';
import { type AgentChatIntegration, ChatIntegrationRegistry } from './agent-chat-integration';
import type { CallbackMetadata } from './callback-store';
import { ChatIntegrationService, type ChatInstance } from './chat-integration.service';
import {
@@ -26,7 +26,6 @@ import type {
IntegrationMessageContext,
IntegrationToolConnectionDescriptor,
} from './integration-tools';
import { subscribeSlackThread } from './platforms/slack-operations';
// The shared wire schema from @n8n/api-types — the same definition the tool
// boundary validates against and the editor-ui renderer parses with.
@@ -260,7 +259,7 @@ export class ChatIntegrationActionExecutor implements IntegrationActionExecutor
}
const thread = chat.thread(threadId);
await maybeSubscribeSlackThread(params.descriptor, thread);
await this.prepareSentThread(params.descriptor, thread);
const sent = await thread.post(await this.toPostable(params.descriptor, input.message, params));
return {
@@ -279,7 +278,7 @@ export class ChatIntegrationActionExecutor implements IntegrationActionExecutor
): Promise<IntegrationActionResult> {
const input = sendDmInputSchema.parse(params.input);
const thread = await chat.openDM(input.userId);
await maybeSubscribeSlackThread(params.descriptor, thread);
await this.prepareSentThread(params.descriptor, thread);
const sent = await thread.post(await this.toPostable(params.descriptor, input.message, params));
return {
@@ -340,7 +339,9 @@ export class ChatIntegrationActionExecutor implements IntegrationActionExecutor
const sent = await channel.post(
await this.toPostable(params.descriptor, input.message, params),
);
await maybeSubscribeSlackSentThread(params.descriptor, chat, sent.threadId);
if (sent.threadId) {
await this.prepareSentThread(params.descriptor, chat.thread(sent.threadId));
}
return {
ok: true,
@@ -399,6 +400,13 @@ export class ChatIntegrationActionExecutor implements IntegrationActionExecutor
metadata,
);
}
private async prepareSentThread(
descriptor: IntegrationToolConnectionDescriptor,
thread: Parameters<NonNullable<AgentChatIntegration['prepareSentThread']>>[0],
): Promise<void> {
await this.integrationRegistry.get(descriptor.integration.type)?.prepareSentThread?.(thread);
}
}
function supportsMessageEditing(adapter: unknown): adapter is Pick<Adapter, 'editMessage'> {
@@ -447,20 +455,3 @@ function buildReactionTarget(
: undefined;
return { type: 'thread', threadId, ...(channelId ? { channelId } : {}) };
}
async function maybeSubscribeSlackThread(
descriptor: IntegrationToolConnectionDescriptor,
thread: { subscribe?: () => Promise<void> },
): Promise<void> {
if (descriptor.integration.type !== 'slack') return;
await subscribeSlackThread(thread);
}
async function maybeSubscribeSlackSentThread(
descriptor: IntegrationToolConnectionDescriptor,
chat: ChatInstance,
threadId: string | undefined,
): Promise<void> {
if (descriptor.integration.type !== 'slack' || !threadId) return;
await subscribeSlackThread(chat.thread(threadId));
}
@@ -4,7 +4,7 @@ import {
slackReplayFixtures,
slackUser,
} from '../../../__tests__/helpers/slack/synthetic-fixtures';
import { SlackIntegration } from '../../slack-integration';
import { SlackIntegration } from '../../slack/slack-integration';
describe('Slack channel integration scenarios', () => {
it('handles Slack URL verification without an active connection', () => {
@@ -8,6 +8,7 @@ import { createHmac } from 'crypto';
import { mock } from 'vitest-mock-extended';
import type { InstanceSettings } from 'n8n-core';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import type { UrlService } from '@/services/url.service';
@@ -98,6 +99,16 @@ describe('TelegramIntegration capabilities', () => {
});
});
describe('TelegramIntegration.validateConfig', () => {
it('requires Telegram settings', () => {
const { integration } = makeIntegration();
expect(() =>
integration.validateConfig({ type: 'telegram', credentialId: 'credential-1' }),
).toThrow(BadRequestError);
});
});
describe('TelegramIntegration.onBeforeConnect', () => {
let agentRepository: Mocked<AgentRepository>;
let integration: TelegramIntegration;
@@ -8,9 +8,9 @@ import type {
BridgeResumeExecutionContext,
BridgeStatusHandle,
PlatformAgentContext,
} from '../agent-chat-integration';
import type { ChatInstance } from '../chat-integration.service';
import type { ReplyExpectation } from '../integration-tools';
} from '../../agent-chat-integration';
import type { ChatInstance } from '../../chat-integration.service';
import type { ReplyExpectation } from '../../integration-tools';
const SLACK_THINKING_STATUS = 'Thinking...';
const SLACK_STATUS_RETRY_DELAY_MS = 750;
@@ -1,6 +1,10 @@
import { Service } from '@n8n/di';
import type { RichCardComponentType } from '@n8n/api-types';
import type { Thread } from 'chat';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import { AgentRepository } from '../../../repositories/agent.repository';
import {
AgentChatIntegration,
type AgentChatIntegrationContext,
@@ -10,15 +14,15 @@ import {
type PlatformAgentContext,
type PlatformContextQueryParams,
type UnauthenticatedWebhookResponse,
} from '../agent-chat-integration';
import type { ChatInstance } from '../chat-integration.service';
import { loadSlackAdapter } from '../esm-loader';
} from '../../agent-chat-integration';
import type { ChatInstance } from '../../chat-integration.service';
import { loadSlackAdapter } from '../../esm-loader';
import {
resolveIntegrationActionDefinitions,
resolveIntegrationContextQueryDefinitions,
} from '../integration-tool-definitions';
import { connectionUnavailable } from '../integration-helpers';
import type { ReplyExpectation } from '../integration-tools';
} from '../../integration-tool-definitions';
import { connectionUnavailable } from '../../integration-helpers';
import type { ReplyExpectation } from '../../integration-tools';
import {
createSlackBridgeExecutionContext,
createSlackResumeExecutionContext,
@@ -26,7 +30,7 @@ import {
getSlackReplyExpectation,
prepareSlackInboundText,
} from './slack-bridge-behavior';
import { executeSlackContextQuery } from './slack-operations';
import { executeSlackContextQuery, subscribeSlackThread } from './slack-operations';
/**
* Slack platform integration.
@@ -35,6 +39,10 @@ import { executeSlackContextQuery } from './slack-operations';
*/
@Service()
export class SlackIntegration extends AgentChatIntegration {
constructor(private readonly agentRepository?: AgentRepository) {
super();
}
readonly type = 'slack';
readonly credentialTypes = ['slackApi'];
@@ -90,6 +98,23 @@ export class SlackIntegration extends AgentChatIntegration {
'do_not_respond',
]);
async onBeforeConnect(ctx: AgentChatIntegrationContext): Promise<void> {
if (!this.agentRepository) return;
const others = await this.agentRepository.findByIntegrationCredential(
this.type,
ctx.credentialId,
ctx.projectId,
ctx.agentId,
);
if (others.length > 0) {
throw new ConflictError(`Slack credential is already connected to agent "${others[0].name}"`);
}
}
async prepareSentThread(thread: Thread<unknown, unknown>): Promise<void> {
await subscribeSlackThread(thread);
}
getPlatformAgentContext(chat: ChatInstance): PlatformAgentContext {
return getSlackPlatformAgentContext(chat);
}
@@ -0,0 +1,194 @@
import { randomBytes } from 'node:crypto';
import type { CreateSlackAgentAppResponse, SlackAgentAppManifestResponse } from '@n8n/api-types';
import type { User } from '@n8n/db';
import { UserRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import { isRecord } from '@n8n/utils/is-record';
import { Cipher } from 'n8n-core';
import { jsonParse } from 'n8n-workflow';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { CacheService } from '@/services/cache/cache.service';
import { SlackMethodsService } from './slack-methods.service';
const SLACK_APP_SETUP_CACHE_PREFIX = 'agents:slack-app-setup:';
const SLACK_APP_SETUP_TTL_MS = 60 * 60 * 1000;
interface CreateSlackAppOptions {
projectId: string;
agentId: string;
appConfigurationToken: string;
user: User;
}
interface GetSlackAppManifestOptions {
projectId: string;
agentId: string;
}
interface CompleteSlackAppInstallOptions {
projectId: string;
agentId: string;
code: string;
state: string;
}
interface SlackAppSetupSession {
projectId: string;
agentId: string;
userId: string;
appId: string;
clientId: string;
clientSecret: string;
signingSecret: string;
redirectUrl: string;
}
function hasSessionShape(value: unknown): value is SlackAppSetupSession {
const keys: Array<keyof SlackAppSetupSession> = [
'projectId',
'agentId',
'userId',
'appId',
'clientId',
'clientSecret',
'signingSecret',
'redirectUrl',
];
return isRecord(value) && keys.every((key) => typeof value[key] === 'string');
}
@Service()
export class SlackManualSetupService {
constructor(
private readonly methods: SlackMethodsService,
private readonly userRepository: UserRepository,
private readonly cacheService: CacheService,
private readonly cipher: Cipher,
) {}
async createApp(options: CreateSlackAppOptions): Promise<CreateSlackAgentAppResponse> {
const appConfigurationToken = options.appConfigurationToken.trim();
if (!appConfigurationToken) {
throw new BadRequestError('Slack app configuration token is required');
}
const agent = await this.methods.getAgent(options.agentId, options.projectId);
const redirectUrl = this.methods.callbackUrl(options.projectId, options.agentId);
const manifest = this.methods.buildManifest(agent.name, options.projectId, options.agentId, {
redirectUrl,
});
const response = await this.methods.callSlackApi('apps.manifest.create', {
token: appConfigurationToken,
manifest: JSON.stringify(manifest),
});
if (response.ok !== true) {
throw this.methods.slackError('create the Slack app', response);
}
const credentials = this.methods.childRecord(response, 'credentials');
const appId = this.methods.stringProperty(response, 'app_id');
const clientId = this.methods.stringProperty(credentials, 'client_id');
const clientSecret = this.methods.stringProperty(credentials, 'client_secret');
const signingSecret = this.methods.stringProperty(credentials, 'signing_secret');
const oauthAuthorizeUrl = this.methods.stringProperty(response, 'oauth_authorize_url');
if (!appId || !clientId || !clientSecret || !signingSecret || !oauthAuthorizeUrl) {
throw new BadRequestError('Slack returned an incomplete app setup response');
}
const state = randomBytes(32).toString('hex');
const setupSession = {
projectId: options.projectId,
agentId: options.agentId,
userId: options.user.id,
appId,
clientId,
clientSecret,
signingSecret,
redirectUrl,
} satisfies SlackAppSetupSession;
await this.cacheService.set(
this.cacheKey(state),
await this.cipher.encryptV2(JSON.stringify(setupSession)),
SLACK_APP_SETUP_TTL_MS,
);
return {
appId,
installUrl: this.methods.installUrl(oauthAuthorizeUrl, state, redirectUrl),
};
}
async getManifest(options: GetSlackAppManifestOptions): Promise<SlackAgentAppManifestResponse> {
const agent = await this.methods.getAgent(options.agentId, options.projectId);
return {
manifest: this.methods.buildManifest(agent.name, options.projectId, options.agentId),
};
}
async completeInstall(options: CompleteSlackAppInstallOptions): Promise<void> {
const session = await this.consumeSession(options.state);
if (session.projectId !== options.projectId || session.agentId !== options.agentId) {
throw new BadRequestError('Slack app setup state does not match this agent');
}
const user = await this.userRepository.findOne({
where: { id: session.userId },
relations: ['role'],
});
if (!user) throw new NotFoundError(`User "${session.userId}" not found`);
const agent = await this.methods.getAgent(session.agentId, session.projectId);
const tokenResponse = await this.methods.callSlackApi(
'oauth.v2.access',
{
code: options.code,
redirect_uri: session.redirectUrl,
},
{
authorization: `Basic ${Buffer.from(`${session.clientId}:${session.clientSecret}`).toString(
'base64',
)}`,
},
);
if (tokenResponse.ok !== true) {
throw this.methods.slackError('finish Slack app installation', tokenResponse);
}
const accessToken = this.methods.stringProperty(tokenResponse, 'access_token');
if (!accessToken?.startsWith('xoxb-')) {
throw new BadRequestError('Slack did not return a Bot User OAuth Token');
}
await this.methods.createAndConnectBotCredential({
agent,
user,
accessToken,
signingSecret: session.signingSecret,
});
}
private async consumeSession(state: string): Promise<SlackAppSetupSession> {
const cached = await this.cacheService.take<unknown>(this.cacheKey(state));
if (typeof cached !== 'string') {
throw new BadRequestError('Slack app setup state has expired or is invalid');
}
try {
const decrypted = await this.cipher.decryptV2(cached);
const session = jsonParse<unknown>(decrypted, { fallbackValue: null });
if (hasSessionShape(session)) return session;
} catch {
// Invalid encrypted state falls through to the shared callback error.
}
throw new BadRequestError('Slack app setup state has expired or is invalid');
}
private cacheKey(state: string): string {
return `${SLACK_APP_SETUP_CACHE_PREFIX}${state}`;
}
}
@@ -0,0 +1,211 @@
import type { AgentIntegrationConfig, SlackAgentAppManifest } from '@n8n/api-types';
import { OutboundHttp } from '@n8n/backend-network';
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
import { isRecord } from '@n8n/utils/is-record';
import { CredentialsService } from '@/credentials/credentials.service';
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 { AgentIntegrationManagementService } from '../../../agent-integration-management.service';
import type { Agent } from '../../../entities/agent.entity';
import { AgentRepository } from '../../../repositories/agent.repository';
const DEFAULT_SLACK_APP_NAME = 'n8n Agent';
const SLACK_CREDENTIAL_TYPE = 'slackApi';
const REQUIRED_BOT_EVENTS = [
'app_mention',
'assistant_thread_started',
'assistant_thread_context_changed',
'message.channels',
'message.groups',
'message.im',
'message.mpim',
] as const;
const REQUIRED_BOT_SCOPES = [
'app_mentions:read',
'assistant:write',
'channels:history',
'channels:join',
'channels:manage',
'channels:read',
'chat:write',
'chat:write.customize',
'files:read',
'files:write',
'groups:history',
'groups:read',
'im:history',
'im:read',
'im:write',
'mpim:history',
'mpim:read',
'mpim:write',
'reactions:write',
'search:read.public',
'users:read',
'users:read.email',
] as const;
@Service()
export class SlackMethodsService {
constructor(
private readonly credentialsService: CredentialsService,
private readonly agentRepository: AgentRepository,
private readonly integrationManagementService: AgentIntegrationManagementService,
private readonly urlService: UrlService,
private readonly outboundHttp: OutboundHttp,
) {}
async callSlackApi(
method: string,
params: Record<string, string>,
headers: Record<string, string> = {},
): Promise<Record<string, unknown>> {
try {
const response = await this.outboundHttp.requests({ ssrf: 'disabled' }).request({
method: 'POST',
url: `https://slack.com/api/${method}`,
headers: {
...headers,
'content-type': 'application/x-www-form-urlencoded',
},
body: params,
returnFullResponse: true,
ignoreHttpStatusErrors: true,
});
const data: unknown = response.body;
return isRecord(data) ? data : { ok: false, error: 'invalid_response' };
} catch {
return { ok: false, error: 'slack_request_failed' };
}
}
slackError(action: string, response: Record<string, unknown>): BadRequestError {
const error = this.stringProperty(response, 'error') ?? 'unknown_error';
return new BadRequestError(`Slack could not ${action}: ${error}`);
}
async getAgent(agentId: string, projectId: string): Promise<Agent> {
const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`);
return agent;
}
buildManifest(
agentName: string,
projectId: string,
agentId: string,
options: { redirectUrl?: string } = {},
): SlackAgentAppManifest {
const slackAppName = this.sanitiseSlackAppName(agentName);
const webhookUrl = this.webhookUrl(projectId, agentId);
return {
display_information: { name: slackAppName },
features: {
app_home: {
home_tab_enabled: false,
messages_tab_enabled: true,
messages_tab_read_only_enabled: false,
},
bot_user: {
display_name: slackAppName,
always_online: true,
},
},
oauth_config: {
...(options.redirectUrl ? { redirect_urls: [options.redirectUrl] } : {}),
scopes: { bot: [...REQUIRED_BOT_SCOPES] },
},
settings: {
event_subscriptions: {
request_url: webhookUrl,
bot_events: [...REQUIRED_BOT_EVENTS],
},
interactivity: {
is_enabled: true,
request_url: webhookUrl,
},
org_deploy_enabled: false,
socket_mode_enabled: false,
token_rotation_enabled: false,
},
};
}
callbackUrl(projectId: string, agentId: string): string {
return `${this.urlService.getWebhookBaseUrl()}rest/projects/${projectId}/agents/v2/${agentId}/integrations/slack/oauth/callback`;
}
installUrl(oauthAuthorizeUrl: string, state: string, redirectUrl: string): string {
try {
const url = new URL(oauthAuthorizeUrl);
url.searchParams.set('state', state);
url.searchParams.set('redirect_uri', redirectUrl);
return url.toString();
} catch {
throw new BadRequestError('Slack returned an invalid installation URL');
}
}
async createAndConnectBotCredential(options: {
agent: Agent;
user: User;
accessToken: string;
signingSecret: string;
}): Promise<string> {
const credential = await this.credentialsService.createUnmanagedCredential(
{
name: this.credentialName(options.agent.name),
type: SLACK_CREDENTIAL_TYPE,
data: {
accessToken: options.accessToken,
signatureSecret: options.signingSecret,
},
projectId: options.agent.projectId,
},
options.user,
);
const integration = {
type: 'slack',
credentialId: credential.id,
} satisfies AgentIntegrationConfig;
await this.integrationManagementService.connect({
agent: options.agent,
user: options.user,
integration,
});
return credential.id;
}
childRecord(record: Record<string, unknown>, key: string): Record<string, unknown> | undefined {
const child = record[key];
return isRecord(child) ? child : undefined;
}
stringProperty(record: Record<string, unknown> | undefined, key: string): string | undefined {
const value = record?.[key];
return typeof value === 'string' ? value : undefined;
}
private webhookUrl(projectId: string, agentId: string): string {
return `${this.urlService.getWebhookBaseUrl()}rest/projects/${projectId}/agents/v2/${agentId}/webhooks/slack`;
}
private credentialName(agentName: string): string {
return `Slack - ${agentName || DEFAULT_SLACK_APP_NAME}`.slice(0, 128);
}
private sanitiseSlackAppName(raw: string): string {
const cleaned = raw
.replace(/[^a-zA-Z0-9 ._-]/g, '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 35);
return cleaned.length > 0 ? cleaned : DEFAULT_SLACK_APP_NAME;
}
}
@@ -1,9 +1,9 @@
import { z } from 'zod';
import { isRecord } from '@n8n/utils/is-record';
import type { ChatInstance } from '../chat-integration.service';
import { stringValue, unsupportedQuery } from '../integration-helpers';
import type { IntegrationContextQuery } from '../integration-tools';
import type { ChatInstance } from '../../chat-integration.service';
import { stringValue, unsupportedQuery } from '../../integration-helpers';
import type { IntegrationContextQuery } from '../../integration-tools';
const PLATFORM = 'slack';
@@ -10,6 +10,7 @@ import { createHmac } from 'crypto';
import { InstanceSettings } from 'n8n-core';
import { UnexpectedError } from 'n8n-workflow';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import { UrlService } from '@/services/url.service';
@@ -155,6 +156,12 @@ export class TelegramIntegration extends AgentChatIntegration {
return createTelegramAdapter({ botToken, mode, secretToken });
}
validateConfig(integration: AgentIntegrationConfig): void {
if (integration.type === this.type && !integration.settings) {
throw new BadRequestError('Telegram integration settings are required');
}
}
/**
* In polling mode the Chat SDK adapter long-polls Telegram, which must be
* done by exactly one main otherwise multiple instances race for the same
@@ -243,7 +250,7 @@ export class TelegramIntegration extends AgentChatIntegration {
async createBridgeExecutionContext(
params: BridgeMessageContextParams,
): Promise<BridgeExecutionContext> {
return createTelegramBridgeExecutionContext(params);
return await Promise.resolve(createTelegramBridgeExecutionContext(params));
}
async createResumeExecutionContext(params: {
@@ -251,7 +258,7 @@ export class TelegramIntegration extends AgentChatIntegration {
logger: BridgeMessageContextParams['logger'];
agentId: string;
}): Promise<BridgeResumeExecutionContext> {
return createTelegramResumeExecutionContext(params);
return await Promise.resolve(createTelegramResumeExecutionContext(params));
}
normalizeComponents(components: SuspendComponent[]): SuspendComponent[] {
@@ -1,418 +0,0 @@
import { randomBytes } from 'node:crypto';
import type {
AgentIntegrationConfig,
CreateSlackAgentAppResponse,
SlackAgentAppManifest,
SlackAgentAppManifestResponse,
} from '@n8n/api-types';
import { OutboundHttp } from '@n8n/backend-network';
import type { User } from '@n8n/db';
import { UserRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import { isRecord } from '@n8n/utils/is-record';
import { Cipher } from 'n8n-core';
import { jsonParse } from 'n8n-workflow';
import { CredentialsService } from '@/credentials/credentials.service';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { CacheService } from '@/services/cache/cache.service';
import { UrlService } from '@/services/url.service';
import { AgentIntegrationPersistenceService } from '../agent-integration-persistence.service';
import type { Agent } from '../entities/agent.entity';
import { AgentRepository } from '../repositories/agent.repository';
import { ChatIntegrationService } from './chat-integration.service';
const SLACK_APP_SETUP_CACHE_PREFIX = 'agents:slack-app-setup:';
const SLACK_APP_SETUP_TTL_MS = 60 * 60 * 1000;
const DEFAULT_SLACK_APP_NAME = 'n8n Agent';
const SLACK_CREDENTIAL_TYPE = 'slackApi';
const REQUIRED_BOT_EVENTS = [
'app_mention',
'assistant_thread_started',
'assistant_thread_context_changed',
'message.channels',
'message.groups',
'message.im',
'message.mpim',
] as const;
const REQUIRED_BOT_SCOPES = [
'app_mentions:read',
'assistant:write',
'channels:history',
'channels:join',
'channels:manage',
'channels:read',
'chat:write',
'chat:write.customize',
'files:read',
'files:write',
'groups:history',
'groups:read',
'im:history',
'im:read',
'im:write',
'mpim:history',
'mpim:read',
'mpim:write',
'reactions:write',
'search:read.public',
'users:read',
'users:read.email',
] as const;
interface CreateSlackAppOptions {
projectId: string;
agentId: string;
appConfigurationToken: string;
user: User;
}
interface GetSlackAppManifestOptions {
projectId: string;
agentId: string;
}
interface CompleteSlackAppInstallOptions {
projectId: string;
agentId: string;
code: string;
state: string;
}
interface SlackAppSetupSession {
projectId: string;
agentId: string;
userId: string;
appId: string;
clientId: string;
clientSecret: string;
signingSecret: string;
redirectUrl: string;
}
function childRecord(
record: Record<string, unknown>,
key: string,
): Record<string, unknown> | undefined {
const child = record[key];
return isRecord(child) ? child : undefined;
}
function stringProperty(
record: Record<string, unknown> | undefined,
key: string,
): string | undefined {
const value = record?.[key];
return typeof value === 'string' ? value : undefined;
}
function hasSessionShape(value: unknown): value is SlackAppSetupSession {
const keys: Array<keyof SlackAppSetupSession> = [
'projectId',
'agentId',
'userId',
'appId',
'clientId',
'clientSecret',
'signingSecret',
'redirectUrl',
];
return isRecord(value) && keys.every((k) => typeof value[k] === 'string');
}
@Service()
export class SlackAppSetupService {
constructor(
private readonly cacheService: CacheService,
private readonly cipher: Cipher,
private readonly credentialsService: CredentialsService,
private readonly userRepository: UserRepository,
private readonly agentRepository: AgentRepository,
private readonly agentIntegrationPersistenceService: AgentIntegrationPersistenceService,
private readonly chatIntegrationService: ChatIntegrationService,
private readonly urlService: UrlService,
private readonly outboundHttp: OutboundHttp,
) {}
async createApp(options: CreateSlackAppOptions): Promise<CreateSlackAgentAppResponse> {
const appConfigurationToken = options.appConfigurationToken.trim();
if (!appConfigurationToken) {
throw new BadRequestError('Slack app configuration token is required');
}
const agent = await this.getAgent(options.agentId, options.projectId);
const redirectUrl = this.callbackUrl(options.projectId, options.agentId);
const manifest = this.buildManifest(agent.name, options.projectId, options.agentId, {
redirectUrl,
});
const response = await this.callSlackApi('apps.manifest.create', {
token: appConfigurationToken,
manifest: JSON.stringify(manifest),
});
if (response.ok !== true) {
throw this.slackError('create the Slack app', response);
}
const credentials = childRecord(response, 'credentials');
const appId = stringProperty(response, 'app_id');
const clientId = stringProperty(credentials, 'client_id');
const clientSecret = stringProperty(credentials, 'client_secret');
const signingSecret = stringProperty(credentials, 'signing_secret');
const oauthAuthorizeUrl = stringProperty(response, 'oauth_authorize_url');
if (!appId || !clientId || !clientSecret || !signingSecret || !oauthAuthorizeUrl) {
throw new BadRequestError('Slack returned an incomplete app setup response');
}
const state = randomBytes(32).toString('hex');
const setupSession = {
projectId: options.projectId,
agentId: options.agentId,
userId: options.user.id,
appId,
clientId,
clientSecret,
signingSecret,
redirectUrl,
} satisfies SlackAppSetupSession;
await this.cacheService.set(
this.cacheKey(state),
await this.cipher.encryptV2(JSON.stringify(setupSession)),
SLACK_APP_SETUP_TTL_MS,
);
return {
appId,
installUrl: this.installUrl(oauthAuthorizeUrl, state, redirectUrl),
};
}
async getManualManifest(
options: GetSlackAppManifestOptions,
): Promise<SlackAgentAppManifestResponse> {
const agent = await this.getAgent(options.agentId, options.projectId);
return {
manifest: this.buildManifest(agent.name, options.projectId, options.agentId),
};
}
async completeInstall(options: CompleteSlackAppInstallOptions): Promise<void> {
const session = await this.consumeSession(options.state);
if (session.projectId !== options.projectId || session.agentId !== options.agentId) {
throw new BadRequestError('Slack app setup state does not match this agent');
}
const user = await this.userRepository.findOne({
where: { id: session.userId },
relations: ['role'],
});
if (!user) {
throw new NotFoundError(`User "${session.userId}" not found`);
}
const agent = await this.getAgent(session.agentId, session.projectId);
const tokenResponse = await this.callSlackApi(
'oauth.v2.access',
{
code: options.code,
redirect_uri: session.redirectUrl,
},
{
Authorization: `Basic ${Buffer.from(`${session.clientId}:${session.clientSecret}`).toString(
'base64',
)}`,
},
);
if (tokenResponse.ok !== true) {
throw this.slackError('finish Slack app installation', tokenResponse);
}
const accessToken = stringProperty(tokenResponse, 'access_token');
if (!accessToken?.startsWith('xoxb-')) {
throw new BadRequestError('Slack did not return a Bot User OAuth Token');
}
const credential = await this.credentialsService.createUnmanagedCredential(
{
name: this.credentialName(agent.name),
type: SLACK_CREDENTIAL_TYPE,
data: {
accessToken,
signatureSecret: session.signingSecret,
},
projectId: session.projectId,
},
user,
);
const integration = {
type: 'slack',
credentialId: credential.id,
} satisfies AgentIntegrationConfig;
const savedAgent = await this.agentIntegrationPersistenceService.saveCredentialIntegration(
agent,
integration,
{
user,
modifiedBy: 'user',
broadcast: false,
},
);
if (savedAgent.activeVersionId === null) return;
await this.chatIntegrationService.connect(session.agentId, integration, session.projectId);
await this.chatIntegrationService.broadcastIntegrationChange(
session.agentId,
integration,
'connect',
);
}
private async getAgent(agentId: string, projectId: string): Promise<Agent> {
const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`);
return agent;
}
private buildManifest(
agentName: string,
projectId: string,
agentId: string,
options: { redirectUrl?: string } = {},
): SlackAgentAppManifest {
const slackAppName = this.sanitiseSlackAppName(agentName);
const webhookUrl = this.webhookUrl(projectId, agentId);
return {
display_information: {
name: slackAppName,
},
features: {
app_home: {
home_tab_enabled: false,
messages_tab_enabled: true,
messages_tab_read_only_enabled: false,
},
bot_user: {
display_name: slackAppName,
always_online: true,
},
},
oauth_config: {
...(options.redirectUrl ? { redirect_urls: [options.redirectUrl] } : {}),
scopes: {
bot: [...REQUIRED_BOT_SCOPES],
},
},
settings: {
event_subscriptions: {
request_url: webhookUrl,
bot_events: [...REQUIRED_BOT_EVENTS],
},
interactivity: {
is_enabled: true,
request_url: webhookUrl,
},
org_deploy_enabled: false,
socket_mode_enabled: false,
token_rotation_enabled: false,
},
};
}
private webhookUrl(projectId: string, agentId: string): string {
return `${this.urlService.getWebhookBaseUrl()}rest/projects/${projectId}/agents/v2/${agentId}/webhooks/slack`;
}
private callbackUrl(projectId: string, agentId: string): string {
return `${this.urlService.getWebhookBaseUrl()}rest/projects/${projectId}/agents/v2/${agentId}/integrations/slack/oauth/callback`;
}
private installUrl(oauthAuthorizeUrl: string, state: string, redirectUrl: string): string {
try {
const url = new URL(oauthAuthorizeUrl);
url.searchParams.set('state', state);
url.searchParams.set('redirect_uri', redirectUrl);
return url.toString();
} catch {
throw new BadRequestError('Slack returned an invalid installation URL');
}
}
private async consumeSession(state: string): Promise<SlackAppSetupSession> {
const key = this.cacheKey(state);
const cached = await this.cacheService.get<unknown>(key);
await this.cacheService.delete(key);
if (typeof cached !== 'string') {
throw new BadRequestError('Slack app setup state has expired or is invalid');
}
try {
const decrypted = await this.cipher.decryptV2(cached);
const session = jsonParse<unknown>(decrypted, { fallbackValue: null });
if (hasSessionShape(session)) {
return session;
}
} catch {}
throw new BadRequestError('Slack app setup state has expired or is invalid');
}
private cacheKey(state: string): string {
return `${SLACK_APP_SETUP_CACHE_PREFIX}${state}`;
}
private credentialName(agentName: string): string {
return `Slack - ${agentName || DEFAULT_SLACK_APP_NAME}`.slice(0, 128);
}
private sanitiseSlackAppName(raw: string): string {
const cleaned = raw
.replace(/[^a-zA-Z0-9 ._-]/g, '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 35);
return cleaned.length > 0 ? cleaned : DEFAULT_SLACK_APP_NAME;
}
private async callSlackApi(
method: string,
params: Record<string, string>,
headers: Record<string, string> = {},
): Promise<Record<string, unknown>> {
try {
const response = await this.outboundHttp
.requests({
ssrf: 'disabled', // the Slack API host is fixed and public
})
.request({
method: 'POST',
url: `https://slack.com/api/${method}`,
headers: {
...headers,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params,
returnFullResponse: true,
ignoreHttpStatusErrors: true, // Status errors are ignored because Slack signals failures in the JSON body
});
const data: unknown = response.body;
if (!isRecord(data)) {
return { ok: false, error: 'invalid_response' };
}
return data;
} catch {
return { ok: false, error: 'slack_request_failed' };
}
}
private slackError(action: string, response: Record<string, unknown>): BadRequestError {
const error = stringProperty(response, 'error') ?? 'unknown_error';
return new BadRequestError(`Slack could not ${action}: ${error}`);
}
}
@@ -30,6 +30,7 @@ import { CredentialsService } from '@/credentials/credentials.service';
import type { EventService } from '@/events/event.service';
import { AgentConfigService } from '@/modules/agents/agent-config.service';
import { AgentCustomToolsService } from '@/modules/agents/agent-custom-tools.service';
import { AgentIntegrationManagementService } from '@/modules/agents/agent-integration-management.service';
import { AgentIntegrationPersistenceService } from '@/modules/agents/agent-integration-persistence.service';
import { AgentModelCatalogService } from '@/modules/agents/agent-model-catalog.service';
import { AgentModificationTelemetryService } from '@/modules/agents/agent-modification-telemetry.service';
@@ -46,8 +47,6 @@ import { AgentValidationService } from '@/modules/agents/agent-validation.servic
import { AgentsService } from '@/modules/agents/agents.service';
import { AttachableWorkflowsService } from '@/modules/agents/attachable-workflows.service';
import type { Agent } from '@/modules/agents/entities/agent.entity';
import { ChatIntegrationRegistry } from '@/modules/agents/integrations/agent-chat-integration';
import { ChatIntegrationService } from '@/modules/agents/integrations/chat-integration.service';
import type { NodeToolAiGatewayService } from '@/modules/agents/json-config/node-tool-ai-gateway.service';
import type { AgentTaskRepository } from '@/modules/agents/repositories/agent-task.repository';
import type { AgentRepository } from '@/modules/agents/repositories/agent.repository';
@@ -134,8 +133,7 @@ describe('McpAgentToolsService', () => {
const agentCustomToolsService = mockInstance(AgentCustomToolsService);
const agentSecureRuntime = mockInstance(AgentSecureRuntime);
const integrationPersistenceService = mockInstance(AgentIntegrationPersistenceService);
const chatIntegrationService = mockInstance(ChatIntegrationService);
const chatIntegrationRegistry = mockInstance(ChatIntegrationRegistry);
const integrationManagementService = mockInstance(AgentIntegrationManagementService);
const mcpRegistryService = mockInstance(McpRegistryService);
const outboundHttp = mockInstance(OutboundHttp);
const urlService = mockInstance(UrlService);
@@ -154,8 +152,7 @@ describe('McpAgentToolsService', () => {
agentCustomToolsService,
agentSecureRuntime,
integrationPersistenceService,
chatIntegrationService,
chatIntegrationRegistry,
integrationManagementService,
mockInstance(AgentModelCatalogService),
mockInstance(AttachableWorkflowsService),
mcpRegistryService,
@@ -1812,19 +1809,13 @@ describe('McpAgentToolsService', () => {
beforeEach(() => {
agentsService.findByIdForUser.mockResolvedValue(agentEntity({ activeVersionId: 'v1' }));
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([
{ id: 'cred-1', type: 'slackApi', name: 'Slack cred' },
] as never);
chatIntegrationRegistry.require.mockReturnValue({
credentialTypes: ['slackApi'],
displayLabel: 'Slack',
} as never);
integrationPersistenceService.saveCredentialIntegration.mockResolvedValue(
agentEntity({
integrationManagementService.connect.mockResolvedValue({
integration: { type: 'slack', credentialId: 'cred-1' },
savedAgent: agentEntity({
activeVersionId: 'v1',
integrations: [{ type: 'slack', credentialId: 'cred-1' }],
}),
);
});
});
it('persists and connects the channel without publishing for a published Agent', async () => {
@@ -1834,11 +1825,12 @@ describe('McpAgentToolsService', () => {
const result = await callTool('update_agent_integration', input);
expect(integrationPersistenceService.saveCredentialIntegration).toHaveBeenCalledWith(
expect.objectContaining({ id: 'agent-1' }),
{ type: 'slack', credentialId: 'cred-1' },
{ user, modifiedBy: 'mcp', broadcast: false },
);
expect(integrationManagementService.connect).toHaveBeenCalledWith({
agent: expect.objectContaining({ id: 'agent-1' }),
user,
integration: { type: 'slack', credentialId: 'cred-1' },
modifiedBy: 'mcp',
});
expect(agentPublishService.publishAgent).not.toHaveBeenCalled();
expect(userHasScopesMock).toHaveBeenCalledWith(user, ['agent:update'], false, {
projectId: 'project-1',
@@ -1846,16 +1838,6 @@ describe('McpAgentToolsService', () => {
expect(userHasScopesMock).not.toHaveBeenCalledWith(user, ['agent:publish'], false, {
projectId: 'project-1',
});
expect(chatIntegrationService.connect).toHaveBeenCalledWith(
'agent-1',
{ type: 'slack', credentialId: 'cred-1' },
'project-1',
);
expect(chatIntegrationService.broadcastIntegrationChange).toHaveBeenCalledWith(
'agent-1',
{ type: 'slack', credentialId: 'cred-1' },
'connect',
);
expect(result.structuredContent).toMatchObject({
ok: true,
configured: true,
@@ -1867,23 +1849,23 @@ describe('McpAgentToolsService', () => {
it('persists without publishing, connecting, or broadcasting for an unpublished Agent', async () => {
agentsService.findByIdForUser.mockResolvedValue(agentEntity({ activeVersionId: null }));
integrationPersistenceService.saveCredentialIntegration.mockResolvedValue(
agentEntity({
integrationManagementService.connect.mockResolvedValue({
integration: { type: 'slack', credentialId: 'cred-1' },
savedAgent: agentEntity({
activeVersionId: null,
integrations: [{ type: 'slack', credentialId: 'cred-1' }],
}),
);
});
const result = await callTool('update_agent_integration', input);
expect(integrationPersistenceService.saveCredentialIntegration).toHaveBeenCalledWith(
expect.objectContaining({ id: 'agent-1' }),
{ type: 'slack', credentialId: 'cred-1' },
{ user, modifiedBy: 'mcp', broadcast: false },
);
expect(integrationManagementService.connect).toHaveBeenCalledWith({
agent: expect.objectContaining({ id: 'agent-1' }),
user,
integration: { type: 'slack', credentialId: 'cred-1' },
modifiedBy: 'mcp',
});
expect(agentPublishService.publishAgent).not.toHaveBeenCalled();
expect(chatIntegrationService.connect).not.toHaveBeenCalled();
expect(chatIntegrationService.broadcastIntegrationChange).not.toHaveBeenCalled();
expect(result.structuredContent).toMatchObject({
ok: true,
configured: true,
@@ -1894,19 +1876,22 @@ describe('McpAgentToolsService', () => {
});
it('requires settings for telegram integrations', async () => {
integrationManagementService.connect.mockRejectedValueOnce(
new Error('Telegram integration settings are required'),
);
const result = await callTool('update_agent_integration', { ...input, type: 'telegram' });
expect(result.isError).toBe(true);
expect(result.structuredContent).toMatchObject({
error: 'Telegram integration settings are required',
});
expect(integrationPersistenceService.saveCredentialIntegration).not.toHaveBeenCalled();
expect(integrationManagementService.connect).toHaveBeenCalled();
});
it('rejects a credential whose type the integration does not support', async () => {
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([
{ id: 'cred-1', type: 'telegramApi', name: 'Telegram cred' },
] as never);
integrationManagementService.connect.mockRejectedValueOnce(
new Error('Slack integrations do not support telegramApi credentials'),
);
const result = await callTool('update_agent_integration', input);
@@ -1914,7 +1899,7 @@ describe('McpAgentToolsService', () => {
expect(result.structuredContent).toMatchObject({
error: 'Slack integrations do not support telegramApi credentials',
});
expect(integrationPersistenceService.saveCredentialIntegration).not.toHaveBeenCalled();
expect(integrationManagementService.connect).toHaveBeenCalled();
});
});
@@ -1928,9 +1913,9 @@ describe('McpAgentToolsService', () => {
};
beforeEach(() => {
integrationPersistenceService.removeCredentialIntegration.mockResolvedValue(
agentEntity({ integrations: [] }),
);
integrationManagementService.disconnect.mockResolvedValue({
savedAgent: agentEntity({ integrations: [] }),
});
});
it('disconnects a persisted integration and removes its record', async () => {
@@ -1939,14 +1924,13 @@ describe('McpAgentToolsService', () => {
const result = await callTool('update_agent_integration', input);
expect(chatIntegrationService.disconnectChannel).toHaveBeenCalledWith('agent-1', persisted);
expect(chatIntegrationService.disconnect).not.toHaveBeenCalled();
expect(integrationPersistenceService.removeCredentialIntegration).toHaveBeenCalledWith(
expect.objectContaining({ id: 'agent-1' }),
'slack',
'cred-1',
{ user, modifiedBy: 'mcp', broadcast: false },
);
expect(integrationManagementService.disconnect).toHaveBeenCalledWith({
agent: expect.objectContaining({ id: 'agent-1' }),
user,
type: 'slack',
credentialId: 'cred-1',
modifiedBy: 'mcp',
});
expect(result.structuredContent).toMatchObject({ ok: true, connected: false });
});
@@ -1955,11 +1939,7 @@ describe('McpAgentToolsService', () => {
const result = await callTool('update_agent_integration', input);
expect(chatIntegrationService.disconnectChannel).toHaveBeenCalledWith('agent-1', {
type: 'slack',
credentialId: 'cred-1',
});
expect(integrationPersistenceService.removeCredentialIntegration).toHaveBeenCalled();
expect(integrationManagementService.disconnect).toHaveBeenCalled();
expect(result.structuredContent).toMatchObject({ ok: true, connected: false });
});
@@ -1968,12 +1948,9 @@ describe('McpAgentToolsService', () => {
await callTool('update_agent_integration', { ...input, type: 'bogus' });
expect(chatIntegrationService.disconnectChannel).not.toHaveBeenCalled();
expect(chatIntegrationService.disconnect).toHaveBeenCalledWith('agent-1', {
type: 'bogus',
credentialId: 'cred-1',
});
expect(integrationPersistenceService.removeCredentialIntegration).toHaveBeenCalled();
expect(integrationManagementService.disconnect).toHaveBeenCalledWith(
expect.objectContaining({ type: 'bogus', credentialId: 'cred-1' }),
);
});
});
@@ -7,7 +7,6 @@ import {
} from '@n8n/ai-utilities/agent-config';
import {
AGENT_MODEL_PROVIDERS,
AgentIntegrationSchema,
AgentJsonConfigBaseSchema,
AgentJsonConfigSchema,
isDraftAgentConfig,
@@ -32,6 +31,7 @@ import { CredentialsService } from '@/credentials/credentials.service';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { AgentConfigService } from '@/modules/agents/agent-config.service';
import { AgentCustomToolsService } from '@/modules/agents/agent-custom-tools.service';
import { AgentIntegrationManagementService } from '@/modules/agents/agent-integration-management.service';
import { AgentIntegrationPersistenceService } from '@/modules/agents/agent-integration-persistence.service';
import { AgentModelCatalogService } from '@/modules/agents/agent-model-catalog.service';
import { AgentPublishService } from '@/modules/agents/agent-publish.service';
@@ -48,8 +48,6 @@ import { AgentValidationService } from '@/modules/agents/agent-validation.servic
import { AgentsService } from '@/modules/agents/agents.service';
import { AttachableWorkflowsService } from '@/modules/agents/attachable-workflows.service';
import type { Agent } from '@/modules/agents/entities/agent.entity';
import { ChatIntegrationRegistry } from '@/modules/agents/integrations/agent-chat-integration';
import { ChatIntegrationService } from '@/modules/agents/integrations/chat-integration.service';
import { composeJsonConfig } from '@/modules/agents/json-config/agent-config-composition';
import { listMcpServerTools } from '@/modules/agents/json-config/mcp-client-factory';
import { sanitizeUnknownAgentCredentials } from '@/modules/agents/json-config/sanitize-unknown-agent-credentials';
@@ -391,8 +389,7 @@ export class McpAgentToolsService {
private readonly agentCustomToolsService: AgentCustomToolsService,
private readonly agentSecureRuntime: AgentSecureRuntime,
private readonly integrationPersistenceService: AgentIntegrationPersistenceService,
private readonly chatIntegrationService: ChatIntegrationService,
private readonly chatIntegrationRegistry: ChatIntegrationRegistry,
private readonly integrationManagementService: AgentIntegrationManagementService,
private readonly agentModelCatalogService: AgentModelCatalogService,
private readonly attachableWorkflowsService: AttachableWorkflowsService,
private readonly mcpRegistryService: McpRegistryService,
@@ -1640,35 +1637,17 @@ export class McpAgentToolsService {
await this.assertScope(user, projectId, 'agent:update');
return input.action === 'disconnect'
? await this.disconnectIntegration(user, input, agent)
: await this.connectIntegration(user, input, agent, projectId);
: await this.connectIntegration(user, input, agent);
}
private async disconnectIntegration(user: User, input: UpdateIntegrationInput, agent: Agent) {
const persisted = (agent.integrations ?? []).find(
(item) => item.type === input.type && item.credentialId === input.credentialId,
);
// Mirrors AgentIntegrationsController.disconnectIntegration: tear down
// the runtime channel even when persistence has no matching record
// (e.g. the integration was removed via a config mutation).
const parsed = AgentIntegrationSchema.safeParse({
const { savedAgent: saved } = await this.integrationManagementService.disconnect({
agent,
user,
type: input.type,
credentialId: input.credentialId,
modifiedBy: 'mcp',
});
const integration = persisted ?? (parsed.success ? parsed.data : undefined);
if (integration) {
await this.chatIntegrationService.disconnectChannel(input.agentId, integration);
} else {
await this.chatIntegrationService.disconnect(input.agentId, {
type: input.type,
credentialId: input.credentialId,
});
}
const saved = await this.integrationPersistenceService.removeCredentialIntegration(
agent,
input.type,
input.credentialId,
{ user, modifiedBy: 'mcp', broadcast: false },
);
return {
ok: true,
agentId: input.agentId,
@@ -1680,39 +1659,18 @@ export class McpAgentToolsService {
};
}
private async connectIntegration(
user: User,
input: UpdateIntegrationInput,
agent: Agent,
projectId: string,
) {
private async connectIntegration(user: User, input: UpdateIntegrationInput, agent: Agent) {
const candidate = {
type: input.type,
credentialId: input.credentialId,
...(input.settings ? { settings: input.settings } : {}),
};
const parsed = AgentIntegrationSchema.safeParse(candidate);
if (!parsed.success) throw new UserError(`Invalid integration: ${parsed.error.message}`);
if (parsed.data.type === 'telegram' && !parsed.data.settings) {
throw new UserError('Telegram integration settings are required');
}
const credential = await this.requireAccessibleCredential(
this.credentialProvider(user, projectId),
input.credentialId,
);
const implementation = this.chatIntegrationRegistry.require(parsed.data.type);
if (!implementation.credentialTypes.includes(credential.type)) {
throw new UserError(
`${implementation.displayLabel} integrations do not support ${credential.type} credentials`,
);
}
const saved = await this.integrationPersistenceService.saveCredentialIntegration(
const { savedAgent: saved } = await this.integrationManagementService.connect({
agent,
parsed.data,
{ user, modifiedBy: 'mcp', broadcast: false },
);
user,
integration: candidate,
modifiedBy: 'mcp',
});
const result = {
ok: true,
agentId: input.agentId,
@@ -1724,12 +1682,6 @@ export class McpAgentToolsService {
};
if (saved.activeVersionId === null) return { ...result, connected: false };
await this.chatIntegrationService.connect(input.agentId, parsed.data, projectId);
await this.chatIntegrationService.broadcastIntegrationChange(
input.agentId,
parsed.data,
'connect',
);
return {
...result,
connected: true,
@@ -202,6 +202,15 @@ for (const backend of ['memory', 'redis'] as const) {
}
});
describe('take', () => {
test('should return a value only once', async () => {
await cacheService.set('single-use', 'value');
await expect(cacheService.take('single-use')).resolves.toBe('value');
await expect(cacheService.take('single-use')).resolves.toBeUndefined();
});
});
describe('delete', () => {
test('should delete a key', async () => {
await cacheService.set('key', 'value');
+22
View File
@@ -23,6 +23,8 @@ type CacheEvents = {
@Service()
export class CacheService extends TypedEmitter<CacheEvents> {
private readonly takingKeys = new Set<string>();
constructor(private readonly globalConfig: GlobalConfig) {
super();
}
@@ -210,6 +212,26 @@ export class CacheService extends TypedEmitter<CacheEvents> {
return fallbackValue;
}
/** Atomically retrieve and delete a primitive value. */
async take<T = unknown>(key: string): Promise<T | undefined> {
if (!this.cache) await this.init();
if (!key) return undefined;
if (this.cache.kind === 'redis') {
return await this.cache.store.getdel<T>(key);
}
if (this.takingKeys.has(key)) return undefined;
this.takingKeys.add(key);
try {
const value = await this.cache.store.get<T>(key);
await this.cache.store.del(key);
return value;
} finally {
this.takingKeys.delete(key);
}
}
/**
* Retrieve a [Redis hash](https://redis.io/docs/data-types/hashes/) under a key.
* If in-memory, the hash is a regular JS object. To retrieve a primitive value
@@ -32,6 +32,7 @@ export type RedisCache = Cache<RedisStore>;
export interface RedisStore extends Store {
readonly isCacheable: (value: unknown) => boolean;
get client(): Redis | Cluster;
getdel<T>(key: string): Promise<T | undefined>;
hget<T>(key: string, field: string): Promise<T | undefined>;
hgetall<T>(key: string): Promise<Record<string, T> | undefined>;
hset(key: string, fieldValueRecord: Record<string, unknown>): Promise<void>;
@@ -57,6 +58,11 @@ function builder(
if (val === undefined || val === null) return undefined;
else return jsonParse<T>(val);
},
async getdel<T>(key: string) {
const val = await redisCache.getdel(key);
if (val === undefined || val === null) return undefined;
return jsonParse<T>(val);
},
async expire(key: string, ttlSeconds: number) {
await redisCache.expire(key, ttlSeconds);
},
@@ -8004,6 +8004,7 @@
"agents.channels.modal.description": "Choose a channel to connect to your agent. Channels allow your agent to receive messages and respond to events.",
"agents.channels.modal.connectDescription": "Connect your {channel} account",
"agents.channels.modal.setupLoadError": "Channel setup couldn't load. Try again.",
"agents.channels.modal.setupPlaceholder": "Setup view for {channel} will go here.",
"agents.channels.modal.editPlaceholder": "Edit view for {channel} will go here.",
"agents.channels.modal.configured": "Configured",
"agents.channels.modal.connected": "Connected",
@@ -16,7 +16,12 @@ const integration = {
function mountItem(configured: boolean, connected: boolean) {
return mount(AgentChannelListItem, {
props: { integration, configured, connected },
props: {
integration,
configured,
connected,
connectAction: { label: 'generic.connect' },
},
global: {
stubs: {
N8nButton: { template: '<button><slot /></button>' },
@@ -43,4 +48,28 @@ describe('AgentChannelListItem', () => {
connected,
);
});
it('renders registry-provided connect action metadata', () => {
const wrapper = mount(AgentChannelListItem, {
props: {
integration,
configured: false,
connected: false,
connectAction: { label: 'Add to Slack', icon: 'plus' },
},
global: {
stubs: {
N8nButton: {
props: ['icon'],
template: '<button :data-icon="icon"><slot /></button>',
},
N8nIcon: { template: '<i />' },
N8nText: { template: '<span><slot /></span>' },
},
},
});
expect(wrapper.get('button').text()).toContain('Add to Slack');
expect(wrapper.get('button').attributes('data-icon')).toBe('plus');
});
});
@@ -1,47 +1,71 @@
import { flushPromises, mount } from '@vue/test-utils';
import { ref } from 'vue';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import AgentChannelModal from '../components/AgentChannelModal.vue';
import AgentChannelModal, { type ChannelView } from '../components/AgentChannelModal.vue';
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({
baseText: (key: string) => key,
}),
const mocks = vi.hoisted(() => ({
connect: vi.fn(),
disconnect: vi.fn(),
fetchStatus: vi.fn(),
beforeSave: vi.fn(),
ensureAgentPersisted: vi.fn(),
}));
const catalog = ref([
{ type: 'slack', label: 'Slack', icon: 'zap' },
{ type: 'linear', label: 'Linear', icon: 'zap' },
{ type: 'telegram', label: 'Telegram', icon: 'zap' },
{
type: 'example',
label: 'Example',
icon: 'zap',
credentialTypes: ['exampleApi'],
},
]);
const integrationSettings = ref({
slack: { accessMode: 'all' },
linear: { accessMode: 'all' },
telegram: { accessMode: 'all' },
});
const statuses = ref<Record<string, 'configured' | 'connected' | 'disconnected'>>({});
const connectedCredentials = ref<Record<string, string>>({});
const selectedCredentials = ref<Record<string, string>>({});
const loadingMap = ref<Record<string, boolean>>({});
const fetchStatusMock = vi.fn().mockResolvedValue(undefined);
const connectMock = vi.fn(
async (channelType: string, credentialId: string): Promise<{ status: string }> => {
connectedCredentials.value[channelType] = credentialId;
return { status: 'connected' };
},
);
const disconnectMock = vi
.fn()
.mockImplementation(async (channelType: string, credentialId: string) => {
if (connectedCredentials.value[channelType] === credentialId) {
delete connectedCredentials.value[channelType];
}
});
const createCredentialMock = vi.fn();
const editCredentialMock = vi.fn();
const setupSlackAppMock = vi.fn().mockResolvedValue(true);
const vueErrorHandlerMock = vi.fn();
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({ baseText: (key: string) => key }),
}));
vi.mock('../channels/registry', () => {
const platformView = {
props: ['modelValue', 'mode', 'isPublished'],
emits: ['update:modelValue', 'connect'],
setup: () => ({
currentSettings: { accessMode: 'all' },
validationError: null,
beforeSave: mocks.beforeSave,
}),
template: `
<div
data-testid="platform-view"
:data-mode="mode"
:data-published="isPublished"
>
<button data-testid="select-credential" @click="$emit('update:modelValue', 'credential-new')" />
<button data-testid="connect-channel" @click="$emit('connect')" />
</div>
`,
};
const platform = {
type: 'example',
setupComponent: platformView,
editComponent: platformView,
getConnectAction: () => ({ label: 'Connect example', icon: 'zap' }),
getConnectedDescription: () => 'Example connected',
};
const runtime = {
loading: { value: false },
load: vi.fn().mockResolvedValue(undefined),
};
return {
agentChannelPlatforms: { example: platform },
getAgentChannelPlatform: () => platform,
createAgentChannelRuntime: () => runtime,
};
});
vi.mock('../composables/useAgentIntegrationsCatalog', () => ({
useAgentIntegrationsCatalog: () => ({
@@ -52,127 +76,79 @@ vi.mock('../composables/useAgentIntegrationsCatalog', () => ({
vi.mock('../composables/useAgentIntegrationStatus', () => ({
useAgentIntegrationStatus: () => ({
fetchStatus: fetchStatusMock,
fetchStatus: mocks.fetchStatus,
connectedCredentials,
integrationSettings,
integrationSettings: ref({ example: { accessMode: 'all' } }),
loadingMap,
errorMessages: ref({}),
errorIsConflict: ref({}),
isConnected: () => false,
isConfigured: (channelType: string) => Boolean(connectedCredentials.value[channelType]),
connect: connectMock,
disconnect: disconnectMock,
isConnected: (type: string) => statuses.value[type] === 'connected',
isConfigured: (type: string) =>
['configured', 'connected'].includes(statuses.value[type] ?? 'disconnected'),
connect: mocks.connect,
disconnect: mocks.disconnect,
}),
}));
vi.mock('../composables/useAgentChannelSetup', () => ({
useAgentChannelSetup: () => ({
channelSetupRef: ref(),
selectedCredentials,
credentialsLoading: ref(false),
credentialPermissions: ref({}),
credentialPermissions: ref({ create: true }),
credentialModalOpen: ref(false),
getChannelCredentialId: (channelType: string | null | undefined) =>
(channelType &&
(selectedCredentials.value[channelType] || connectedCredentials.value[channelType])) ||
'',
getCredentials: (channelType: string) => [
{ id: `${channelType}-credential`, name: `${channelType} credential` },
{ id: `${channelType}-credential-new`, name: `New ${channelType} credential` },
getChannelCredentialId: (type?: string | null) =>
type ? (selectedCredentials.value[type] ?? connectedCredentials.value[type] ?? '') : '',
getCredentials: () => [
{ id: 'credential-old', name: 'Old credential' },
{ id: 'credential-new', name: 'New credential' },
],
loadChannelState: vi.fn().mockImplementation(async () => {
for (const [channelType, credentialId] of Object.entries(connectedCredentials.value)) {
if (!selectedCredentials.value[channelType]) {
selectedCredentials.value[channelType] = credentialId;
}
}
}),
createCredential: createCredentialMock,
editCredential: editCredentialMock,
setupSlackApp: setupSlackAppMock,
loadChannelState: vi.fn().mockResolvedValue(undefined),
createCredential: vi.fn(),
editCredential: vi.fn(),
}),
}));
const channelSetupStub = (testId: string) => ({
props: ['mode', 'loading', 'connectedDescription', 'setupSlackApp'],
emits: ['connect'],
setup: () => ({
currentSettings: { accessMode: 'all' },
validationError: null,
}),
template: `<div
data-testid="${testId}"
:data-mode="mode"
:data-loading="loading"
:data-connected-description="connectedDescription"
>
<button
v-if="setupSlackApp"
data-testid="${testId}-automatic-setup"
@click="setupSlackApp('app-token')"
/>
<button data-testid="${testId}-connect" @click="$emit('connect')" />
</div>`,
});
function mountModal(props: Record<string, unknown>) {
function mountModal(view: ChannelView = 'example_setup', isPublished = false) {
return mount(AgentChannelModal, {
props: {
open: true,
agentId: 'agent-1',
projectId: 'project-1',
isPublished: false,
view: 'linear_setup',
...props,
view,
isPublished,
ensureAgentPersisted: mocks.ensureAgentPersisted,
},
global: {
config: {
errorHandler: vueErrorHandlerMock,
},
stubs: {
// The N8nDialog family's SFCs don't set an explicit `defineOptions({ name })`,
// so Vue infers the component name from the *filename* (Dialog.vue,
// DialogHeader.vue, ...) rather than the `N8n`-prefixed name they're imported
// under -- stubs must be keyed by that inferred name to be picked up.
Dialog: {
props: ['open', 'showCloseButton'],
emits: ['update:open'],
template:
'<div v-if="open"><button v-if="showCloseButton" data-testid="close-dialog" @click="$emit(\'update:open\', false)" /><button data-testid="escape-dialog" @click="$emit(\'update:open\', false)" /><slot /></div>',
'<div v-if="open"><button data-testid="close-dialog" @click="$emit(\'update:open\', false)" /><slot /></div>',
},
DialogHeader: { template: '<div><slot /></div>' },
DialogTitle: { template: '<h3><slot /></h3>' },
DialogFooter: { template: '<div><slot /></div>' },
N8nButton: {
props: ['disabled'],
emits: ['click'],
template: '<button @click="$emit(\'click\')"><slot /></button>',
},
N8nIconButton: {
emits: ['click'],
template: '<button @click="$emit(\'click\')"><slot /></button>',
template: '<button :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
},
N8nIconButton: { template: '<button><slot /></button>' },
N8nIcon: { template: '<i />' },
N8nText: { template: '<span><slot /></span>' },
AgentChannelListItem: {
props: ['configured', 'connected'],
template:
'<li data-testid="channel-list-item" :data-configured="configured" :data-connected="connected" />',
},
AgentChannelSlackSetup: channelSetupStub('slack-setup'),
AgentChannelLinearSetup: channelSetupStub('linear-setup'),
AgentChannelTelegramSetup: channelSetupStub('telegram-setup'),
AgentIntegrationCredentialConnection: {
props: ['integrationType', 'modelValue', 'disabled'],
emits: ['update:modelValue', 'create', 'edit'],
props: ['integration', 'configured', 'connected', 'connectAction'],
emits: ['setup'],
template: `
<div
data-testid="shared-credential-connection"
:data-channel-type="integrationType"
:data-credential-id="modelValue"
<li
data-testid="channel-list-item"
:data-action="connectAction.label"
:data-configured="configured"
:data-connected="connected"
>
<button data-testid="change-credential" :disabled="disabled" @click="$emit('update:modelValue', integrationType + '-credential-new')" />
<button data-testid="edit-credential" @click="$emit('edit')" />
</div>
<button @click="$emit('setup', integration.type)" />
</li>
`,
},
},
@@ -182,359 +158,88 @@ function mountModal(props: Record<string, unknown>) {
describe('AgentChannelModal', () => {
beforeEach(() => {
vi.clearAllMocks();
statuses.value = {};
connectedCredentials.value = {};
selectedCredentials.value = {};
loadingMap.value = {};
vi.clearAllMocks();
});
it('renders the channel list for the list view', () => {
const wrapper = mountModal({ view: 'list' });
expect(wrapper.findAll('[data-testid="channel-list-item"]')).toHaveLength(catalog.value.length);
});
it('does not describe a configured draft channel as connected', async () => {
connectedCredentials.value.linear = 'linear-credential';
const wrapper = mountModal({ view: 'linear_setup' });
await flushPromises();
expect(
wrapper.get('[data-testid="linear-setup"]').attributes('data-connected-description'),
).toBe('');
});
it('renders the per-channel setup view for a setup view', () => {
const wrapper = mountModal({ view: 'linear_setup' });
const linearSetup = wrapper.find('[data-testid="linear-setup"]');
expect(linearSetup.attributes('data-mode')).toBe('setup');
});
it('waits for a pending agent to persist before saving a channel configuration', async () => {
let finishPersisting = () => {};
const persisting = new Promise<void>((resolve) => {
finishPersisting = resolve;
});
selectedCredentials.value.linear = 'linear-credential';
const wrapper = mountModal({
view: 'linear_setup',
ensureAgentPersisted: async () => await persisting,
});
await wrapper.get('[data-testid="linear-setup-connect"]').trigger('click');
await flushPromises();
expect(connectMock).not.toHaveBeenCalled();
finishPersisting();
await flushPromises();
expect(connectMock).toHaveBeenCalledWith('linear', 'linear-credential', {
accessMode: 'all',
});
});
it('waits for a pending agent to persist before starting Slack app setup', async () => {
let finishPersisting = () => {};
const persisting = new Promise<void>((resolve) => {
finishPersisting = resolve;
});
const wrapper = mountModal({
view: 'slack_setup',
ensureAgentPersisted: async () => await persisting,
});
await wrapper.get('[data-testid="slack-setup-automatic-setup"]').trigger('click');
await flushPromises();
expect(setupSlackAppMock).not.toHaveBeenCalled();
finishPersisting();
await flushPromises();
expect(setupSlackAppMock).toHaveBeenCalledWith('app-token', expect.any(Function));
});
it('renders the per-channel edit view for an edit view', () => {
const wrapper = mountModal({ view: 'linear_edit' });
const linearSetup = wrapper.find('[data-testid="linear-setup"]');
expect(linearSetup.attributes('data-mode')).toBe('edit');
});
it.each(['slack', 'linear', 'telegram'])(
'renders the configured credential and edit action for the %s edit view',
async (channelType) => {
connectedCredentials.value[channelType] = `${channelType}-credential`;
const wrapper = mountModal({
view: `${channelType}_edit`,
});
await flushPromises();
const credentialConnection = wrapper.get('[data-testid="shared-credential-connection"]');
expect(credentialConnection.attributes('data-channel-type')).toBe(channelType);
expect(credentialConnection.attributes('data-credential-id')).toBe(
`${channelType}-credential`,
);
await credentialConnection.get('[data-testid="edit-credential"]').trigger('click');
expect(editCredentialMock).toHaveBeenCalledOnce();
},
);
it.each(['slack', 'linear', 'telegram'])(
'removes the exact %s channel binding and closes the modal',
async (channelType) => {
connectedCredentials.value[channelType] = `${channelType}-credential`;
const wrapper = mountModal({
view: `${channelType}_edit`,
});
await flushPromises();
await wrapper.get('[data-testid="agent-channel-remove-channel"]').trigger('click');
await flushPromises();
expect(disconnectMock).toHaveBeenCalledWith(channelType, `${channelType}-credential`);
expect(fetchStatusMock).toHaveBeenCalledWith([channelType]);
expect(connectMock).not.toHaveBeenCalled();
expect(createCredentialMock).not.toHaveBeenCalled();
expect(wrapper.emitted('channel-disconnected')).toEqual([[channelType]]);
expect(wrapper.emitted('agent-changed')).toHaveLength(1);
expect(wrapper.emitted('update:open')).toEqual([[false]]);
},
);
it('keeps the channel listed when another binding of the same type remains', async () => {
connectedCredentials.value.linear = 'linear-credential';
disconnectMock.mockImplementationOnce(async () => {
connectedCredentials.value.linear = 'linear-credential-other';
});
const wrapper = mountModal({
view: 'linear_edit',
});
await flushPromises();
await wrapper.get('[data-testid="agent-channel-remove-channel"]').trigger('click');
await flushPromises();
expect(disconnectMock).toHaveBeenCalledWith('linear', 'linear-credential');
expect(fetchStatusMock).toHaveBeenCalledWith(['linear']);
expect(wrapper.emitted('channel-disconnected')).toBeUndefined();
expect(wrapper.emitted('update:open')).toEqual([[false]]);
});
it('connects a replacement credential before detaching the original binding and refetching status', async () => {
connectedCredentials.value.linear = 'linear-credential';
const wrapper = mountModal({
view: 'linear_edit',
});
await flushPromises();
await wrapper.get('[data-testid="change-credential"]').trigger('click');
await wrapper.get('[data-testid="agent-channel-save-channel-config"]').trigger('click');
await flushPromises();
expect(connectMock).toHaveBeenCalledWith('linear', 'linear-credential-new', {
accessMode: 'all',
});
expect(disconnectMock).toHaveBeenCalledWith('linear', 'linear-credential');
expect(fetchStatusMock).toHaveBeenCalledWith(['linear']);
expect(connectMock.mock.invocationCallOrder[0]).toBeLessThan(
disconnectMock.mock.invocationCallOrder[0],
);
expect(disconnectMock.mock.invocationCallOrder[0]).toBeLessThan(
fetchStatusMock.mock.invocationCallOrder[0],
);
});
it('finishes replacing the original binding if the view changes while connecting', async () => {
connectedCredentials.value.linear = 'linear-credential';
let finishConnect = () => {};
const connectPending = new Promise<void>((resolve) => {
finishConnect = resolve;
});
connectMock.mockImplementationOnce(async (channelType: string, credentialId: string) => {
await connectPending;
connectedCredentials.value[channelType] = credentialId;
mocks.connect.mockImplementation(async (type: string, credentialId: string) => {
statuses.value[type] = 'connected';
connectedCredentials.value[type] = credentialId;
return { status: 'connected' };
});
const wrapper = mountModal({
view: 'linear_edit',
mocks.disconnect.mockImplementation(async (type: string) => {
statuses.value[type] = 'disconnected';
delete connectedCredentials.value[type];
return { status: 'disconnected' };
});
await flushPromises();
await wrapper.get('[data-testid="change-credential"]').trigger('click');
await wrapper.get('[data-testid="agent-channel-save-channel-config"]').trigger('click');
await wrapper.setProps({ view: 'list' });
finishConnect();
await flushPromises();
expect(disconnectMock).toHaveBeenCalledWith('linear', 'linear-credential');
expect(fetchStatusMock).toHaveBeenCalledWith(['linear']);
mocks.fetchStatus.mockResolvedValue(undefined);
mocks.beforeSave.mockResolvedValue(undefined);
mocks.ensureAgentPersisted.mockResolvedValue(undefined);
});
it('prevents the dialog from closing while a replacement is connecting', async () => {
connectedCredentials.value.linear = 'linear-credential';
let finishConnect = () => {};
const connectPending = new Promise<void>((resolve) => {
finishConnect = resolve;
});
connectMock.mockImplementationOnce(async (channelType: string, credentialId: string) => {
loadingMap.value[channelType] = true;
try {
await connectPending;
connectedCredentials.value[channelType] = credentialId;
return { status: 'connected' };
} finally {
loadingMap.value[channelType] = false;
}
});
const wrapper = mountModal({
view: 'linear_edit',
});
it('uses registry metadata and setup rendering without platform checks', async () => {
const list = mountModal('list');
await flushPromises();
expect(list.get('[data-testid="channel-list-item"]').attributes('data-action')).toBe(
'Connect example',
);
await wrapper.get('[data-testid="change-credential"]').trigger('click');
await wrapper.get('[data-testid="agent-channel-save-channel-config"]').trigger('click');
await flushPromises();
expect(wrapper.find('[data-testid="close-dialog"]').exists()).toBe(false);
await wrapper.get('[data-testid="escape-dialog"]').trigger('click');
expect(wrapper.emitted('update:open')).toBeUndefined();
finishConnect();
await flushPromises();
expect(disconnectMock).toHaveBeenCalledWith('linear', 'linear-credential');
expect(wrapper.emitted('update:open')).toEqual([[false]]);
const setup = mountModal();
expect(setup.get('[data-testid="platform-view"]').attributes('data-mode')).toBe('setup');
});
it('resets an unsaved credential change when the edit modal reopens', async () => {
connectedCredentials.value.linear = 'linear-credential';
const wrapper = mountModal({
view: 'linear_edit',
it('presents configured and connected as distinct list states', async () => {
statuses.value.example = 'configured';
const configured = mountModal('list');
await flushPromises();
expect(configured.get('[data-testid="channel-list-item"]').attributes()).toMatchObject({
'data-configured': 'true',
'data-connected': 'false',
});
await flushPromises();
await wrapper.get('[data-testid="change-credential"]').trigger('click');
expect(selectedCredentials.value.linear).toBe('linear-credential-new');
await wrapper.setProps({ open: false });
await wrapper.setProps({ open: true });
await flushPromises();
expect(
wrapper.get('[data-testid="shared-credential-connection"]').attributes('data-credential-id'),
).toBe('linear-credential');
statuses.value.example = 'connected';
await configured.vm.$nextTick();
expect(configured.get('[data-testid="channel-list-item"]').attributes('data-connected')).toBe(
'true',
);
});
it('keeps the original binding and modal open when connecting a replacement credential fails', async () => {
connectedCredentials.value.linear = 'linear-credential';
connectMock.mockRejectedValueOnce(new Error('Failed to connect replacement credential'));
const wrapper = mountModal({
view: 'linear_edit',
});
it('forwards publication state and persists before platform save', async () => {
selectedCredentials.value.example = 'credential-new';
const wrapper = mountModal('example_setup', true);
expect(wrapper.get('[data-testid="platform-view"]').attributes('data-published')).toBe('true');
await wrapper.get('[data-testid="connect-channel"]').trigger('click');
await flushPromises();
await wrapper.get('[data-testid="change-credential"]').trigger('click');
await wrapper.get('[data-testid="agent-channel-save-channel-config"]').trigger('click');
await flushPromises();
expect(connectMock).toHaveBeenCalledWith('linear', 'linear-credential-new', {
expect(mocks.ensureAgentPersisted).toHaveBeenCalledOnce();
expect(mocks.beforeSave).toHaveBeenCalledOnce();
expect(mocks.connect).toHaveBeenCalledWith('example', 'credential-new', {
accessMode: 'all',
});
expect(disconnectMock).not.toHaveBeenCalled();
expect(fetchStatusMock).not.toHaveBeenCalled();
expect(wrapper.emitted('update:open')).toBeUndefined();
expect(vueErrorHandlerMock).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Failed to connect replacement credential' }),
expect.anything(),
expect.any(String),
expect(mocks.ensureAgentPersisted.mock.invocationCallOrder[0]).toBeLessThan(
mocks.connect.mock.invocationCallOrder[0],
);
});
it('locks the replacement credential and retries detaching the original binding', async () => {
connectedCredentials.value.linear = 'linear-credential';
disconnectMock.mockRejectedValueOnce(new Error('Failed to detach original credential'));
const wrapper = mountModal({
view: 'linear_edit',
});
await flushPromises();
await wrapper.get('[data-testid="change-credential"]').trigger('click');
await wrapper.get('[data-testid="agent-channel-save-channel-config"]').trigger('click');
await flushPromises();
expect(connectMock).toHaveBeenCalledOnce();
expect(disconnectMock).toHaveBeenCalledWith('linear', 'linear-credential');
expect(wrapper.get('[data-testid="change-credential"]').attributes('disabled')).toBeDefined();
expect(wrapper.get('[data-testid="linear-setup"]').attributes('data-loading')).toBe('true');
expect(wrapper.get('[data-testid="agent-channel-save-channel-config"]').text()).toBe(
'generic.retry',
);
expect(wrapper.get('[data-testid="agent-channel-credential-replacement-error"]').text()).toBe(
'agents.channels.modal.credentialReplacementError',
);
await wrapper.get('[data-testid="agent-channel-save-channel-config"]').trigger('click');
await flushPromises();
expect(connectMock).toHaveBeenCalledOnce();
expect(disconnectMock).toHaveBeenCalledTimes(2);
expect(disconnectMock).toHaveBeenLastCalledWith('linear', 'linear-credential');
expect(fetchStatusMock).toHaveBeenCalledWith(['linear']);
expect(wrapper.emitted('update:open')).toEqual([[false]]);
});
it('allows the modal to close and reopen after detaching the original binding fails', async () => {
connectedCredentials.value.linear = 'linear-credential';
disconnectMock.mockRejectedValueOnce(new Error('Failed to detach original credential'));
const wrapper = mountModal({
view: 'linear_edit',
});
await flushPromises();
await wrapper.get('[data-testid="change-credential"]').trigger('click');
await wrapper.get('[data-testid="agent-channel-save-channel-config"]').trigger('click');
await flushPromises();
expect(wrapper.get('[data-testid="agent-channel-credential-replacement-error"]').text()).toBe(
'agents.channels.modal.credentialReplacementError',
);
await wrapper.get('[data-testid="close-dialog"]').trigger('click');
expect(wrapper.emitted('update:open')).toEqual([[false]]);
await wrapper.setProps({ open: false });
await wrapper.setProps({ open: true });
await flushPromises();
expect(
wrapper.find('[data-testid="agent-channel-credential-replacement-error"]').exists(),
).toBe(false);
expect(wrapper.find('[data-testid="close-dialog"]').exists()).toBe(true);
expect(wrapper.get('[data-testid="agent-channel-save-channel-config"]').text()).toBe(
'generic.save',
);
});
it('saves settings without disconnecting when the credential is unchanged', async () => {
connectedCredentials.value.telegram = 'telegram-credential';
const wrapper = mountModal({
view: 'telegram_edit',
});
await flushPromises();
await wrapper.get('[data-testid="agent-channel-save-channel-config"]').trigger('click');
await flushPromises();
expect(connectMock).toHaveBeenCalledWith('telegram', 'telegram-credential', {
accessMode: 'all',
});
expect(disconnectMock).not.toHaveBeenCalled();
expect(wrapper.emitted('channel-connected')).toEqual([['telegram']]);
expect(wrapper.emitted('agent-changed')).toHaveLength(1);
expect(wrapper.emitted('update:open')).toEqual([[false]]);
});
it('connects a replacement before disconnecting the original credential', async () => {
statuses.value.example = 'connected';
connectedCredentials.value.example = 'credential-old';
const wrapper = mountModal('example_edit');
await flushPromises();
await wrapper.get('[data-testid="select-credential"]').trigger('click');
await wrapper.get('[data-testid="agent-channel-save-channel-config"]').trigger('click');
await flushPromises();
expect(mocks.connect).toHaveBeenCalledWith('example', 'credential-new', {
accessMode: 'all',
});
expect(mocks.disconnect).toHaveBeenCalledWith('example', 'credential-old');
expect(mocks.connect.mock.invocationCallOrder[0]).toBeLessThan(
mocks.disconnect.mock.invocationCallOrder[0],
);
});
});
@@ -29,7 +29,7 @@ vi.mock('../components/AgentChannelSlackSetupSnapshots.vue', () => ({
},
}));
vi.mock('../composables/useAgentApi', () => ({
vi.mock('../channels/slack/api', () => ({
getSlackAgentAppManifest: vi.fn().mockResolvedValue({ manifest: { display_information: {} } }),
}));
@@ -1,102 +0,0 @@
/* eslint-disable import-x/no-extraneous-dependencies -- test-only pattern */
import { flushPromises, mount } from '@vue/test-utils';
import { describe, expect, it, vi } from 'vitest';
import AgentSlackSettingsForm from '../components/AgentSlackSettingsForm.vue';
const { getSlackAgentAppManifest } = vi.hoisted(() => ({
getSlackAgentAppManifest: vi.fn(),
}));
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({ baseText: (key: string) => key }),
}));
vi.mock('@n8n/stores/useRootStore', () => ({
useRootStore: () => ({
restApiContext: {},
urlBaseWebhook: 'https://hooks.example',
OAuthCallbackUrls: { oauth2: 'https://hooks.example/rest/oauth2-credential/callback' },
}),
}));
vi.mock('../composables/useAgentApi', () => ({
getSlackAgentAppManifest,
}));
describe('AgentSlackSettingsForm', () => {
it('renders the fetched Slack app manifest', async () => {
getSlackAgentAppManifest.mockResolvedValue({
manifest: {
display_information: { name: 'Support Agent' },
oauth_config: {
scopes: {
bot: [
'assistant:write',
'groups:history',
'mpim:history',
'reactions:read',
'reactions:write',
],
},
},
settings: {
event_subscriptions: {
bot_events: [
'assistant_thread_started',
'assistant_thread_context_changed',
'message.channels',
'message.groups',
'message.im',
'message.mpim',
],
},
},
},
});
const wrapper = mount(AgentSlackSettingsForm, {
props: {
agentName: 'Support Agent',
projectId: 'project-1',
agentId: 'agent-1',
},
global: {
stubs: {
N8nButton: { template: '<button><slot name="prefix" /><slot /></button>' },
N8nIcon: { template: '<i />' },
N8nText: { template: '<span><slot /></span>' },
N8nInput: { template: '<input />' },
N8nCollapsiblePanel: { template: '<section><slot /></section>' },
},
},
});
await flushPromises();
const manifest = JSON.parse(wrapper.find('pre').text()) as {
oauth_config: { scopes: { bot: string[] } };
settings: { event_subscriptions: { bot_events: string[] } };
};
expect(getSlackAgentAppManifest).toHaveBeenCalledWith({}, 'project-1', 'agent-1');
expect(manifest.oauth_config.scopes.bot).toEqual(
expect.arrayContaining([
'assistant:write',
'groups:history',
'mpim:history',
'reactions:read',
'reactions:write',
]),
);
expect(manifest.settings.event_subscriptions.bot_events).toEqual(
expect.arrayContaining([
'assistant_thread_started',
'assistant_thread_context_changed',
'message.channels',
'message.groups',
'message.im',
'message.mpim',
]),
);
});
});
@@ -2,29 +2,21 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAgentChannelSetup } from '../composables/useAgentChannelSetup';
const {
fetchAllCredentialsForWorkflowMock,
fetchProjectMock,
projectsStoreMock,
setCredentialsMock,
} = vi.hoisted(() => ({
fetchAllCredentialsForWorkflowMock: vi.fn(),
fetchProjectMock: vi.fn(),
projectsStoreMock: {
const { fetchCredentials, fetchProject, projectsStore } = vi.hoisted(() => ({
fetchCredentials: vi.fn(),
fetchProject: vi.fn(),
projectsStore: {
currentProject: null as { id: string; scopes?: string[] } | null,
personalProject: null as { id: string; scopes?: string[] } | null,
myProjects: [] as Array<{ id: string; scopes?: string[] }>,
fetchProject: vi.fn(),
},
setCredentialsMock: vi.fn(),
}));
vi.mock('../composables/useAgentApi', () => ({
createSlackAgentApp: vi.fn().mockResolvedValue({ installUrl: 'https://slack.test/install' }),
}));
vi.mock('@n8n/stores/useRootStore', () => ({
useRootStore: () => ({ restApiContext: {} }),
vi.mock('@n8n/permissions', () => ({
getResourcePermissions: (scopes?: string[]) => ({
credential: { create: scopes?.includes('credential:create') ?? false },
}),
}));
vi.mock('@/app/stores/ui.store', () => ({
@@ -37,119 +29,90 @@ vi.mock('@/app/stores/ui.store', () => ({
vi.mock('@/features/credentials/credentials.store', () => ({
useCredentialsStore: () => ({
setCredentials: setCredentialsMock,
fetchAllCredentialsForWorkflow: fetchAllCredentialsForWorkflowMock,
setCredentials: vi.fn(),
fetchAllCredentialsForWorkflow: fetchCredentials,
getCredentialTypeByName: vi.fn(),
}),
}));
vi.mock('@/features/collaboration/projects/projects.store', () => ({
useProjectsStore: () => projectsStoreMock,
useProjectsStore: () => projectsStore,
}));
function createChannelSetup() {
return useAgentChannelSetup({
projectId: () => 'artifact-project',
agentId: () => 'agent-1',
currentIntegration: null,
connectedCredentials: {},
fetchStatus: vi.fn().mockResolvedValue(undefined),
isIntegrationConfigured: () => false,
});
}
const integrations = [
{
type: 'example',
label: 'Example',
icon: 'zap',
credentialTypes: ['exampleApi'],
},
];
describe('useAgentChannelSetup', () => {
beforeEach(() => {
vi.clearAllMocks();
projectsStoreMock.currentProject = null;
projectsStoreMock.personalProject = null;
projectsStoreMock.myProjects = [];
projectsStoreMock.fetchProject = fetchProjectMock;
fetchAllCredentialsForWorkflowMock.mockResolvedValue([]);
fetchProjectMock.mockResolvedValue({
id: 'artifact-project',
name: 'Artifact project',
icon: null,
type: 'team',
description: null,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
relations: [],
projectsStore.currentProject = null;
projectsStore.personalProject = null;
projectsStore.myProjects = [];
projectsStore.fetchProject = fetchProject;
fetchProject.mockResolvedValue({
id: 'project-1',
scopes: ['credential:create'],
rolesManaged: false,
});
fetchCredentials.mockResolvedValue([
{ id: 'credential-1', name: 'Example credential', type: 'exampleApi' },
{ id: 'other', name: 'Other credential', type: 'otherApi' },
]);
});
it('loads project permissions, integration credentials, and status generically', async () => {
const fetchStatus = vi.fn().mockResolvedValue(undefined);
const setup = useAgentChannelSetup({
projectId: () => 'project-1',
currentIntegration: () => integrations[0],
connectedCredentials: () => ({ example: 'credential-1' }),
fetchStatus,
});
await setup.loadChannelState(integrations);
expect(fetchProject).toHaveBeenCalledWith('project-1');
expect(fetchStatus).toHaveBeenCalledWith(['example']);
expect(setup.credentialPermissions.value.create).toBe(true);
expect(setup.getCredentials('example')).toEqual([
expect.objectContaining({ id: 'credential-1', name: 'Example credential' }),
]);
expect(setup.selectedCredentials.value.example).toBe('credential-1');
});
it('uses project scopes already available in the store', async () => {
projectsStoreMock.myProjects = [{ id: 'artifact-project', scopes: ['credential:create'] }];
const { credentialPermissions, loadChannelState } = createChannelSetup();
projectsStore.myProjects = [{ id: 'project-1', scopes: ['credential:create'] }];
const setup = useAgentChannelSetup({
projectId: () => 'project-1',
currentIntegration: () => integrations[0],
connectedCredentials: () => ({}),
fetchStatus: vi.fn().mockResolvedValue(undefined),
});
await loadChannelState([]);
await setup.loadChannelState(integrations);
expect(credentialPermissions.value.create).toBe(true);
expect(fetchProjectMock).not.toHaveBeenCalled();
expect(setup.credentialPermissions.value.create).toBe(true);
expect(fetchProject).not.toHaveBeenCalled();
});
it('loads scopes when an artifact project is missing from the store', async () => {
// AGENT-443: artifact mode supplies its project through props rather than the route.
const { credentialPermissions, loadChannelState } = createChannelSetup();
expect(credentialPermissions.value.create).toBe(false);
await loadChannelState([]);
expect(fetchProjectMock).toHaveBeenCalledWith('artifact-project');
expect(credentialPermissions.value.create).toBe(true);
});
it('resolves setupSlackApp successfully when the popup closes while an in-flight poll confirms the configuration', async () => {
vi.useFakeTimers();
class FakeBroadcastChannel {
addEventListener() {}
close() {}
postMessage() {}
}
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel);
const fakePopup = { closed: false, close: vi.fn() };
vi.spyOn(window, 'open').mockReturnValue(fakePopup as unknown as Window);
let resolveFirstPoll!: () => void;
const firstPoll = new Promise<void>((resolve) => {
resolveFirstPoll = resolve;
});
let isConfigured = false;
const fetchStatus = vi.fn().mockImplementation(async () => {
if (fetchStatus.mock.calls.length === 1) {
await firstPoll;
}
it('fails closed when credentials and project permissions cannot load', async () => {
fetchProject.mockRejectedValue(new Error('unavailable'));
fetchCredentials.mockRejectedValue(new Error('unavailable'));
const setup = useAgentChannelSetup({
projectId: () => 'project-1',
currentIntegration: () => integrations[0],
connectedCredentials: () => ({}),
fetchStatus: vi.fn().mockResolvedValue(undefined),
});
const onConnected = vi.fn();
const { setupSlackApp } = useAgentChannelSetup({
projectId: () => 'artifact-project',
agentId: () => 'agent-1',
currentIntegration: null,
connectedCredentials: {},
fetchStatus,
isIntegrationConfigured: () => isConfigured,
});
await setup.loadChannelState(integrations);
const setupPromise = setupSlackApp('token', onConnected);
await vi.advanceTimersByTimeAsync(0);
fakePopup.closed = true;
await vi.advanceTimersByTimeAsync(2000);
isConfigured = true;
resolveFirstPoll();
await expect(setupPromise).resolves.toBe(true);
expect(onConnected).toHaveBeenCalled();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
expect(setup.credentialPermissions.value.create).toBe(false);
expect(setup.getCredentials('example')).toEqual([]);
});
});
@@ -0,0 +1,66 @@
<script setup lang="ts">
import type { Component } from 'vue';
import { computed, ref } from 'vue';
import AgentIntegrationCredentialConnection from '../components/AgentIntegrationCredentialConnection.vue';
import type { AgentChannelViewExpose, AgentChannelViewProps } from './types';
const credentialId = defineModel<string>({ default: '' });
defineProps<AgentChannelViewProps & { detailsComponent: Component }>();
const emit = defineEmits<{
create: [];
edit: [];
}>();
const detailsRef = ref<AgentChannelViewExpose>();
const currentSettings = computed(() => detailsRef.value?.currentSettings);
const validationError = computed(() => detailsRef.value?.validationError ?? null);
defineExpose({ currentSettings, validationError });
</script>
<template>
<div :class="$style.editView">
<AgentIntegrationCredentialConnection
v-model="credentialId"
:integration-type="integration.type"
:integration-label="integration.label"
:credentials="credentials"
:credential-permissions="credentialPermissions"
:credentials-loading="credentialsLoading"
:disabled="credentialReplacementPending || loading"
:loading="loading"
:error-message="errorMessage"
:error-is-conflict="errorIsConflict"
:show-edit-button="!credentialReplacementPending"
@create="emit('create')"
@edit="emit('edit')"
/>
<component
:is="detailsComponent"
ref="detailsRef"
v-model="credentialId"
mode="edit"
:integration="integration"
:credentials="credentials"
:credential-permissions="credentialPermissions"
:credentials-loading="credentialsLoading"
:loading="credentialReplacementPending || loading"
:connected="connected"
:connected-description="connectedDescription"
:saved-settings="savedSettings"
:is-published="isPublished"
:agent-name="agentName"
:project-id="projectId"
:agent-id="agentId"
/>
</div>
</template>
<style module lang="scss">
.editView {
display: flex;
flex-direction: column;
gap: var(--spacing--md);
}
</style>
@@ -0,0 +1,29 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import AgentChannelDiscordSetup from '../../components/AgentChannelDiscordSetup.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 validationError = computed(() => viewRef.value?.validationError ?? null);
defineExpose({ validationError });
</script>
<template>
<AgentChannelStandardEditView
ref="viewRef"
v-bind="$props"
v-model="credentialId"
:details-component="AgentChannelDiscordSetup"
@create="emit('create')"
@edit="emit('edit')"
/>
</template>
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { N8nButton, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { computed } from 'vue';
import AgentIntegrationCredentialConnection from '../../components/AgentIntegrationCredentialConnection.vue';
import type { AgentChannelViewProps } from '../types';
const credentialId = defineModel<string>({ default: '' });
defineProps<AgentChannelViewProps>();
const emit = defineEmits<{
create: [];
edit: [];
connect: [];
}>();
const i18n = useI18n();
const validationError = computed(() => (credentialId.value ? null : 'missing_credential'));
defineExpose({ validationError });
</script>
<template>
<div :class="$style.container">
<AgentIntegrationCredentialConnection
v-model="credentialId"
:integration-type="integration.type"
:integration-label="integration.label"
:credentials="credentials"
:credential-permissions="credentialPermissions"
:credentials-loading="credentialsLoading"
:disabled="loading || credentialReplacementPending"
:loading="loading"
:error-message="errorMessage"
:error-is-conflict="errorIsConflict"
:force-new-credential="forceNewCredential"
:show-edit-button="!credentialReplacementPending"
@create="emit('create')"
@edit="emit('edit')"
/>
<N8nText size="small" color="text-light">
{{
i18n.baseText(
mode === 'setup'
? 'agents.channels.modal.setupPlaceholder'
: 'agents.channels.modal.editPlaceholder',
{ interpolate: { channel: integration.label } },
)
}}
</N8nText>
<N8nButton
v-if="mode === 'setup'"
variant="subtle"
size="medium"
:disabled="Boolean(validationError) || loading"
:loading="loading"
@click="emit('connect')"
>
{{ i18n.baseText('generic.connect') }}
</N8nButton>
</div>
</template>
<style module lang="scss">
.container {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing--sm);
}
</style>
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import AgentChannelStandardEditView from '../AgentChannelStandardEditView.vue';
import type { AgentChannelViewExpose, AgentChannelViewProps } from '../types';
import AgentChannelLinearSetup from './AgentChannelLinearSetup.vue';
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="AgentChannelLinearSetup"
@create="emit('create')"
@edit="emit('edit')"
/>
</template>
@@ -1,14 +1,13 @@
<script setup lang="ts">
import { computed, ref, shallowRef } from 'vue';
import { computed, shallowRef } from 'vue';
import { N8nButton, N8nIconButton, N8nInput, N8nStepper, N8nText } from '@n8n/design-system';
import type { ChatIntegrationDescriptor, AgentIntegrationSettings } from '@n8n/api-types';
import { useI18n } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
import type { PermissionsRecord } from '@n8n/permissions';
import { TIME } from '@/app/constants';
import AgentIntegrationCredentialConnection from './AgentIntegrationCredentialConnection.vue';
import AgentIntegrationSettingsForm from './AgentIntegrationSettingsForm.vue';
import type { AgentCredentialOption } from './AgentCredentialSelect.vue';
import AgentIntegrationCredentialConnection from '../../components/AgentIntegrationCredentialConnection.vue';
import type { AgentCredentialOption } from '../../components/AgentCredentialSelect.vue';
const credentialId = defineModel<string>({ default: '' });
@@ -51,7 +50,6 @@ const emit = defineEmits<{
const i18n = useI18n();
const rootStore = useRootStore();
const copiedField = shallowRef<'oauthCallback' | 'webhook' | null>(null);
const settingsFormRef = ref<InstanceType<typeof AgentIntegrationSettingsForm>>();
const LINEAR_APP_SETUP_URL = 'https://linear.app/settings/api/applications/new';
@@ -86,8 +84,8 @@ const oauthCallbackUrl = computed(
() => (rootStore.OAuthCallbackUrls as { oauth2?: string } | undefined)?.oauth2 ?? '',
);
const currentSettings = computed(() => settingsFormRef.value?.currentSettings);
const validationError = computed(() => settingsFormRef.value?.validationError ?? null);
const currentSettings = computed(() => undefined);
const validationError = computed(() => null);
function urlFor(field: 'oauthCallback' | 'webhook'): string {
return field === 'oauthCallback' ? oauthCallbackUrl.value : webhookUrl.value;
@@ -210,9 +208,9 @@ defineExpose({ credentialId, currentSettings, validationError });
:credentials-loading="credentialsLoading"
:disabled="loading"
:force-new-credential="forceNewCredential"
:class="$style.cred"
@create="emit('create')"
@edit="emit('edit')"
:class="$style.cred"
/>
<N8nButton
variant="subtle"
@@ -323,16 +321,6 @@ defineExpose({ credentialId, currentSettings, validationError });
<N8nText v-else-if="connectedDescription" size="small">{{ connectedDescription }}</N8nText>
</div>
<AgentIntegrationSettingsForm
ref="settingsFormRef"
:type="integration.type"
:disabled="connected || loading"
:connected="connected"
:saved-settings="savedSettings"
:agent-name="agentName"
:project-id="projectId"
:agent-id="agentId"
/>
<N8nText v-if="errorMessage" :class="$style.errorText" size="small">
{{ errorMessage }}
<a
@@ -0,0 +1,38 @@
import { ref } from 'vue';
import type { BaseTextKey } from '@n8n/i18n';
import { describe, expect, it } from 'vitest';
import { getAgentChannelPlatform, isRegisteredAgentChannelPlatform } from './registry';
const text = (key: BaseTextKey) => key;
describe('agent channel platform registry', () => {
it('provides a safe fallback for unknown catalog entries', () => {
const platform = getAgentChannelPlatform('future-channel');
const action = platform.getConnectAction(
{ text },
{ loading: ref(false), load: async () => {} },
);
expect(platform.type).toBe('unknown');
expect(action).toEqual({ label: 'generic.connect' });
expect(platform.setupComponent).toBeDefined();
expect(platform.editComponent).toBeDefined();
});
it('uses manual-only Slack list metadata', () => {
const platform = getAgentChannelPlatform('slack');
const runtime = {
loading: ref(false),
load: async () => {},
};
const action = platform.getConnectAction({ text }, runtime);
expect(action).toEqual({ label: 'generic.connect' });
});
it('narrows registered platform keys without casting', () => {
expect(isRegisteredAgentChannelPlatform('slack')).toBe(true);
expect(isRegisteredAgentChannelPlatform('future-channel')).toBe(false);
});
});
@@ -0,0 +1,76 @@
import { readonly, ref } from 'vue';
import AgentChannelDiscordSetup from '../components/AgentChannelDiscordSetup.vue';
import AgentChannelDiscordEditView from './discord/AgentChannelDiscordEditView.vue';
import AgentChannelFallbackView from './fallback/AgentChannelFallbackView.vue';
import AgentChannelLinearEditView from './linear/AgentChannelLinearEditView.vue';
import AgentChannelLinearSetup from './linear/AgentChannelLinearSetup.vue';
import AgentChannelSlackEditView from './slack/AgentChannelSlackEditView.vue';
import AgentChannelSlackSetupView from './slack/AgentChannelSlackSetupView.vue';
import { useSlackChannelRuntime } from './slack/useSlackChannelRuntime';
import AgentChannelTelegramEditView from './telegram/AgentChannelTelegramEditView.vue';
import AgentChannelTelegramSetup from './telegram/AgentChannelTelegramSetup.vue';
import type {
AgentChannelRuntime,
AgentChannelRuntimeContext,
ChannelPlatformDefinition,
} from './types';
function createDefaultRuntime(): AgentChannelRuntime {
const loading = ref(false);
return { load: async () => {}, loading: readonly(loading) };
}
const fallbackPlatform: ChannelPlatformDefinition = {
type: 'unknown',
setupComponent: AgentChannelFallbackView,
editComponent: AgentChannelFallbackView,
getConnectAction: ({ text }) => ({ label: text('generic.connect') }),
};
const platforms = {
slack: {
type: 'slack',
setupComponent: AgentChannelSlackSetupView,
editComponent: AgentChannelSlackEditView,
createRuntime: useSlackChannelRuntime,
getConnectAction: ({ text }) => ({ label: text('generic.connect') }),
},
linear: {
type: 'linear',
setupComponent: AgentChannelLinearSetup,
editComponent: AgentChannelLinearEditView,
getConnectAction: ({ text }) => ({ label: text('generic.connect') }),
getConnectedDescription: ({ text }) => text('agents.builder.addTrigger.connectedText.linear'),
},
telegram: {
type: 'telegram',
setupComponent: AgentChannelTelegramSetup,
editComponent: AgentChannelTelegramEditView,
getConnectAction: ({ text }) => ({ label: text('generic.connect') }),
getConnectedDescription: ({ text }) => text('agents.builder.addTrigger.connectedText.telegram'),
},
discord: {
type: 'discord',
setupComponent: AgentChannelDiscordSetup,
editComponent: AgentChannelDiscordEditView,
getConnectAction: ({ text }) => ({ label: text('generic.connect') }),
},
} satisfies Record<string, ChannelPlatformDefinition>;
export function isRegisteredAgentChannelPlatform(type: string): type is keyof typeof platforms {
return Object.hasOwn(platforms, type);
}
export function getAgentChannelPlatform(type: string): ChannelPlatformDefinition {
return isRegisteredAgentChannelPlatform(type) ? platforms[type] : fallbackPlatform;
}
export function createAgentChannelRuntime(
platform: ChannelPlatformDefinition,
context: AgentChannelRuntimeContext,
): AgentChannelRuntime {
return platform.createRuntime?.(context) ?? createDefaultRuntime();
}
export const agentChannelPlatforms = Object.freeze(platforms);
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { computed } from 'vue';
import AgentIntegrationCredentialConnection from '../../components/AgentIntegrationCredentialConnection.vue';
import type { AgentChannelViewProps } from '../types';
const credentialId = defineModel<string>({ default: '' });
const props = defineProps<AgentChannelViewProps>();
const emit = defineEmits<{
create: [];
edit: [];
}>();
const loading = computed(() => props.loading || props.runtime.loading.value);
defineExpose({ validationError: null, loading });
</script>
<template>
<AgentIntegrationCredentialConnection
v-model="credentialId"
:integration-type="integration.type"
:integration-label="integration.label"
:credentials="credentials"
:credential-permissions="credentialPermissions"
:credentials-loading="credentialsLoading"
:disabled="credentialReplacementPending || loading"
:loading="loading"
:error-message="errorMessage"
:error-is-conflict="errorIsConflict"
:show-edit-button="!credentialReplacementPending"
@create="emit('create')"
@edit="emit('edit')"
/>
</template>
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import AgentChannelSlackSetup from '../../components/AgentChannelSlackSetup.vue';
import type { AgentChannelViewExpose, AgentChannelViewProps } from '../types';
import { isSlackChannelRuntime } from './useSlackChannelRuntime';
const credentialId = defineModel<string>({ default: '' });
const props = defineProps<AgentChannelViewProps>();
const emit = defineEmits<{
create: [];
edit: [];
connect: [];
connected: [];
}>();
const manualRef = ref<AgentChannelViewExpose>();
const validationError = computed(() => manualRef.value?.validationError ?? null);
const loading = computed(() => props.loading || props.runtime.loading.value);
async function setupApp(token: string) {
if (props.disabled) return false;
if (!isSlackChannelRuntime(props.runtime)) {
throw new Error('Slack channel runtime is unavailable');
}
return await props.runtime.setupApp(token, () => emit('connected'));
}
defineExpose({ validationError, loading });
</script>
<template>
<AgentChannelSlackSetup
ref="manualRef"
v-model="credentialId"
:connected="connected"
:setup-slack-app="setupApp"
:project-id="projectId"
:agent-id="agentId"
:integration="integration"
:credentials="credentials"
:credential-permissions="credentialPermissions"
:credentials-loading="credentialsLoading"
:loading="loading"
:disabled="disabled"
:error-message="errorMessage"
:error-is-conflict="errorIsConflict"
:force-new-credential="forceNewCredential"
:setup-mode="simpleSetup ? 'simple' : 'advanced'"
@create="emit('create')"
@edit="emit('edit')"
@connect="emit('connect')"
/>
</template>
@@ -0,0 +1,23 @@
import type { CreateSlackAgentAppResponse, SlackAgentAppManifestResponse } from '@n8n/api-types';
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/slack`;
export const createSlackAgentApp = async (
context: IRestApiContext,
projectId: string,
agentId: string,
appConfigurationToken: string,
): Promise<CreateSlackAgentAppResponse> =>
await makeRestApiRequest(context, 'POST', `${integrationPath(projectId, agentId)}/app`, {
appConfigurationToken,
});
export const getSlackAgentAppManifest = async (
context: IRestApiContext,
projectId: string,
agentId: string,
): Promise<SlackAgentAppManifestResponse> =>
await makeRestApiRequest(context, 'GET', `${integrationPath(projectId, agentId)}/manifest`);
@@ -0,0 +1,71 @@
import { ref } from 'vue';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useSlackChannelRuntime } from './useSlackChannelRuntime';
const { createSlackAgentApp } = vi.hoisted(() => ({
createSlackAgentApp: vi.fn().mockResolvedValue({ installUrl: 'https://slack.test/install' }),
}));
vi.mock('@n8n/stores/useRootStore', () => ({
useRootStore: () => ({ restApiContext: {} }),
}));
vi.mock('./api', () => ({
createSlackAgentApp,
}));
describe('useSlackChannelRuntime', () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('completes manual setup when a final poll confirms the connection', async () => {
vi.useFakeTimers();
class FakeBroadcastChannel {
addEventListener() {}
close() {}
}
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel);
const popup = { closed: false, close: vi.fn() };
vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window);
let resolveFirstPoll!: () => void;
const firstPoll = new Promise<void>((resolve) => {
resolveFirstPoll = resolve;
});
let isConfigured = false;
const fetchStatus = vi.fn().mockImplementation(async () => {
if (fetchStatus.mock.calls.length === 1) await firstPoll;
});
const ensureAgentPersisted = vi.fn().mockResolvedValue(undefined);
const onConnected = vi.fn();
const runtime = useSlackChannelRuntime({
projectId: ref('project-1'),
agentId: ref('agent-1'),
selectedCredentialId: ref(''),
credentialModalOpen: ref(false),
fetchStatus,
isConnected: () => false,
isConfigured: () => isConfigured,
ensureAgentPersisted,
});
const setupPromise = runtime.setupApp('token', onConnected);
await vi.advanceTimersByTimeAsync(0);
popup.closed = true;
await vi.advanceTimersByTimeAsync(2000);
isConfigured = true;
resolveFirstPoll();
await expect(setupPromise).resolves.toBe(true);
expect(ensureAgentPersisted).toHaveBeenCalledOnce();
expect(createSlackAgentApp).toHaveBeenCalledWith({}, 'project-1', 'agent-1', 'token');
expect(onConnected).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,126 @@
import { useRootStore } from '@n8n/stores/useRootStore';
import { readonly, ref } from 'vue';
import type { AgentChannelRuntime, AgentChannelRuntimeContext } from '../types';
import { createSlackAgentApp } from './api';
const SLACK_APP_SETUP_POLL_INTERVAL_MS = 2000;
const SLACK_APP_SETUP_TIMEOUT_MS = 2 * 60 * 1000;
export interface SlackChannelRuntime extends AgentChannelRuntime {
setupApp: (
appConfigurationToken: string,
onConnected: () => void | Promise<void>,
) => Promise<boolean>;
}
export function isSlackChannelRuntime(
runtime: AgentChannelRuntime,
): runtime is SlackChannelRuntime {
return 'setupApp' in runtime && typeof runtime.setupApp === 'function';
}
export function useSlackChannelRuntime(context: AgentChannelRuntimeContext): SlackChannelRuntime {
const rootStore = useRootStore();
const loading = ref(false);
function openAuthorizationPopup(installUrl: string): Window {
const parsedUrl = new URL(installUrl);
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
throw new Error('Invalid Slack installation URL');
}
const popup = window.open(
parsedUrl.toString(),
'Slack App Authorization',
'scrollbars=no,resizable=yes,status=no,titlebar=no,location=no,toolbar=no,menubar=no,width=500,height=700',
);
if (!popup) throw new Error('Slack authorization popup was blocked');
return popup;
}
async function waitForSetupCompletion(popup: Window): Promise<boolean> {
return await new Promise((resolve) => {
const oauthChannel = new BroadcastChannel('oauth-callback');
let activePoll: Promise<void> | null = null;
let settled = false;
const closePopup = () => {
try {
popup.close();
} catch {}
};
const settle = (success: boolean) => {
if (settled) return;
settled = true;
window.clearInterval(pollInterval);
window.clearTimeout(timeout);
oauthChannel.close();
if (success) closePopup();
resolve(success);
};
const pollStatus = async () => {
if (activePoll || settled) return;
activePoll = (async () => {
try {
await context.fetchStatus(['slack']);
if (context.isConfigured('slack')) settle(true);
} finally {
activePoll = null;
}
})();
await activePoll;
};
const pollInterval = window.setInterval(() => {
if (popup.closed) {
void (activePoll ?? Promise.resolve())
.catch(() => {})
.then(pollStatus)
.finally(() => settle(false));
return;
}
void pollStatus();
}, SLACK_APP_SETUP_POLL_INTERVAL_MS);
const timeout = window.setTimeout(() => settle(false), SLACK_APP_SETUP_TIMEOUT_MS);
oauthChannel.addEventListener('message', (event: MessageEvent) => {
settle(event.data === 'success');
});
void pollStatus();
});
}
async function setupApp(
appConfigurationToken: string,
onConnected: () => void | Promise<void>,
): Promise<boolean> {
loading.value = true;
try {
await context.ensureAgentPersisted?.();
const { installUrl } = await createSlackAgentApp(
rootStore.restApiContext,
context.projectId.value,
context.agentId.value,
appConfigurationToken,
);
const connected = await waitForSetupCompletion(openAuthorizationPopup(installUrl));
if (!connected) throw new Error('Slack app installation was not completed');
await context.fetchStatus(['slack']);
await onConnected();
return true;
} finally {
loading.value = false;
}
}
return {
load: async () => {},
loading: readonly(loading),
setupApp,
};
}
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import AgentChannelStandardEditView from '../AgentChannelStandardEditView.vue';
import type { AgentChannelViewExpose, AgentChannelViewProps } from '../types';
import AgentChannelTelegramSetup from './AgentChannelTelegramSetup.vue';
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="AgentChannelTelegramSetup"
@create="emit('create')"
@edit="emit('edit')"
/>
</template>
@@ -0,0 +1,46 @@
import { createComponentRenderer } from '@/__tests__/render';
import { describe, expect, it, vi } from 'vitest';
import AgentChannelTelegramSetup from './AgentChannelTelegramSetup.vue';
vi.mock('@n8n/i18n', async (importOriginal) => ({
...(await importOriginal()),
useI18n: () => ({
baseText: (key: string) => key,
}),
}));
vi.mock('@n8n/design-system', async (importOriginal) => ({
...(await importOriginal()),
N8nStepper: {
template: `<div><slot :step="{ id: 'connect' }" /></div>`,
},
}));
const renderComponent = createComponentRenderer(AgentChannelTelegramSetup);
describe('AgentChannelTelegramSetup', () => {
it('shows connection errors below the connect button', () => {
const { getByText } = renderComponent({
props: {
mode: 'setup',
modelValue: 'telegram-credential',
integration: {
type: 'telegram',
label: 'Telegram',
icon: 'telegram',
credentialTypes: ['telegramApi'],
},
credentials: [],
credentialPermissions: { create: true },
agentName: 'Agent',
projectId: 'project-id',
agentId: 'agent-id',
errorMessage: 'Telegram credential is already connected to agent "Alex"',
errorIsConflict: true,
},
});
expect(getByText('Telegram credential is already connected to agent "Alex"')).toBeVisible();
});
});
@@ -1,12 +1,17 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { N8nButton, N8nStepper, N8nText } from '@n8n/design-system';
import type { ChatIntegrationDescriptor, AgentIntegrationSettings } from '@n8n/api-types';
import type {
AgentIntegrationSettings,
AgentTelegramIntegrationSettings,
ChatIntegrationDescriptor,
} from '@n8n/api-types';
import { useI18n } from '@n8n/i18n';
import type { PermissionsRecord } from '@n8n/permissions';
import AgentIntegrationCredentialConnection from './AgentIntegrationCredentialConnection.vue';
import AgentIntegrationSettingsForm from './AgentIntegrationSettingsForm.vue';
import type { AgentCredentialOption } from './AgentCredentialSelect.vue';
import { resolveSavedTelegramSettings } from '../../utils/telegramAccessSettings';
import AgentIntegrationCredentialConnection from '../../components/AgentIntegrationCredentialConnection.vue';
import AgentTelegramAccessSettingsForm from '../../components/AgentTelegramAccessSettingsForm.vue';
import type { AgentCredentialOption } from '../../components/AgentCredentialSelect.vue';
const credentialId = defineModel<string>({ default: '' });
@@ -47,8 +52,7 @@ const emit = defineEmits<{
}>();
const i18n = useI18n();
const settingsFormRef = ref<InstanceType<typeof AgentIntegrationSettingsForm>>();
void props;
const settingsFormRef = ref<InstanceType<typeof AgentTelegramAccessSettingsForm>>();
const steps = computed(() => [
{
@@ -74,6 +78,9 @@ const canConnect = computed(
const currentSettings = computed(() => settingsFormRef.value?.currentSettings);
const validationError = computed(() => settingsFormRef.value?.validationError ?? null);
const telegramSavedSettings = computed<AgentTelegramIntegrationSettings | undefined>(() =>
resolveSavedTelegramSettings(props.savedSettings, props.connected),
);
defineExpose({ credentialId, currentSettings, validationError });
</script>
@@ -98,16 +105,11 @@ defineExpose({ credentialId, currentSettings, validationError });
@edit="emit('edit')"
/>
</div>
<AgentIntegrationSettingsForm
<AgentTelegramAccessSettingsForm
v-else-if="step.id === 'access'"
ref="settingsFormRef"
:type="integration.type"
:disabled="connected || loading"
:connected="connected"
:saved-settings="savedSettings"
:agent-name="agentName"
:project-id="projectId"
:agent-id="agentId"
:saved-settings="telegramSavedSettings"
/>
<div v-else-if="step.id === 'connect'" :class="$style.connectStep">
<N8nButton
@@ -120,6 +122,22 @@ defineExpose({ credentialId, currentSettings, validationError });
>
{{ i18n.baseText('agents.builder.addTrigger.connect') }}
</N8nButton>
<N8nText
v-if="errorMessage"
:class="$style.errorText"
size="small"
data-testid="telegram-connect-error"
>
{{ errorMessage }}
<a
v-if="credentialId && !errorIsConflict"
:class="$style.link"
href="#"
@click.prevent="emit('edit')"
>
{{ i18n.baseText('agents.builder.addTrigger.editCredential') }}
</a>
</N8nText>
</div>
</div>
</template>
@@ -139,19 +157,14 @@ defineExpose({ credentialId, currentSettings, validationError });
@edit="emit('edit')"
/>
<N8nText v-else-if="connectedDescription" size="small">{{ connectedDescription }}</N8nText>
<AgentIntegrationSettingsForm
<AgentTelegramAccessSettingsForm
ref="settingsFormRef"
:type="integration.type"
:disabled="loading"
:connected="connected"
:saved-settings="savedSettings"
:agent-name="agentName"
:project-id="projectId"
:agent-id="agentId"
:saved-settings="telegramSavedSettings"
/>
</div>
<N8nText v-if="errorMessage" :class="$style.errorText" size="small">
<N8nText v-if="mode === 'edit' && errorMessage" :class="$style.errorText" size="small">
{{ errorMessage }}
<a
v-if="credentialId && !errorIsConflict"
@@ -0,0 +1,78 @@
import type { AgentIntegrationSettings, ChatIntegrationDescriptor } from '@n8n/api-types';
import type { IconName } from '@n8n/design-system/components/N8nIcon/icons';
import type { BaseTextKey } from '@n8n/i18n';
import type { PermissionsRecord } from '@n8n/permissions';
import type { Component, Ref } from 'vue';
import type { AgentCredentialOption } from '../components/AgentCredentialSelect.vue';
export type AgentChannelMode = 'setup' | 'edit';
export type AgentChannelView = 'list' | `${string}_${AgentChannelMode}`;
export interface AgentChannelConnectAction {
label: string;
icon?: IconName;
}
export interface AgentChannelViewExpose {
currentSettings?: AgentIntegrationSettings;
validationError?: string | null;
loading?: boolean;
beforeSave?: () => Promise<void>;
}
export interface AgentChannelRuntimeContext {
projectId: Ref<string>;
agentId: Ref<string>;
selectedCredentialId: Ref<string>;
credentialModalOpen: Readonly<Ref<boolean>>;
fetchStatus: (integrationTypes: string[]) => Promise<void>;
isConnected: (integrationType: string) => boolean;
isConfigured: (integrationType: string) => boolean;
ensureAgentPersisted?: () => Promise<void>;
}
export interface AgentChannelRuntime {
load: () => Promise<void>;
loading: Readonly<Ref<boolean>>;
}
export interface AgentChannelViewProps {
mode: AgentChannelMode;
integration: ChatIntegrationDescriptor;
modelValue: string;
credentials: AgentCredentialOption[];
credentialPermissions: PermissionsRecord['credential'];
credentialsLoading: boolean;
loading: boolean;
disabled?: boolean;
connected: boolean;
connectedDescription: string;
errorMessage: string;
errorIsConflict: boolean;
savedSettings?: AgentIntegrationSettings;
isPublished: boolean;
agentName: string;
projectId: string;
agentId: string;
forceNewCredential: boolean;
simpleSetup: boolean;
credentialReplacementPending: boolean;
runtime: AgentChannelRuntime;
}
export interface AgentChannelPresentationContext {
text: (key: BaseTextKey) => string;
}
export interface ChannelPlatformDefinition {
type: string;
setupComponent: Component;
editComponent: Component;
createRuntime?: (context: AgentChannelRuntimeContext) => AgentChannelRuntime;
getConnectAction: (
context: AgentChannelPresentationContext,
runtime: AgentChannelRuntime,
) => AgentChannelConnectAction;
getConnectedDescription?: (context: AgentChannelPresentationContext) => string;
}
@@ -1,10 +1,18 @@
<script setup lang="ts">
import { N8nButton, N8nDropdownMenu, N8nIcon, N8nText } from '@n8n/design-system';
import type { DropdownMenuItemProps } from '@n8n/design-system';
import type { IconName } from '@n8n/design-system';
import {
N8nButton,
N8nDropdownMenu,
N8nIcon,
N8nLoading,
N8nText,
updatedIconSet,
type DropdownMenuItemProps,
type IconName,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { ChatIntegrationDescriptor } from '@n8n/api-types';
import { computed } from 'vue';
import type { AgentChannelConnectAction } from '../channels/types';
type ChannelAction = 'edit' | 'disconnect';
@@ -12,6 +20,8 @@ interface Props {
integration: ChatIntegrationDescriptor;
configured: boolean;
connected: boolean;
connectAction: AgentChannelConnectAction;
loading?: boolean;
}
const props = defineProps<Props>();
@@ -39,8 +49,8 @@ const configuredActions = computed<Array<DropdownMenuItemProps<ChannelAction>>>(
return actions;
});
function toIconName(icon: string): IconName {
return icon as IconName;
function isIconName(icon: string): icon is IconName {
return icon in updatedIconSet;
}
function handleConfiguredAction(action: ChannelAction) {
@@ -55,49 +65,71 @@ function handleConfiguredAction(action: ChannelAction) {
<template>
<li :class="$style.channelItem">
<div :class="$style.iconWrapper">
<N8nIcon
:icon="integration.icon ? toIconName(integration.icon) : 'zap'"
:size="28"
:class="$style.channelIcon"
/>
</div>
<div :class="$style.content">
<N8nText :class="$style.name" size="medium" bold color="text-dark">
{{ integration.label }}
</N8nText>
</div>
<template v-if="loading">
<div :class="$style.iconWrapper">
<N8nLoading variant="circle" />
</div>
<div :class="$style.content">
<N8nLoading variant="text" :class="$style.nameSkeleton" />
</div>
<div :class="$style.channelActions">
<N8nLoading variant="rect" :class="$style.buttonSkeleton" />
</div>
</template>
<div :class="$style.channelActions">
<N8nDropdownMenu
v-if="configured"
:items="configuredActions"
placement="bottom-end"
:modal="false"
@select="handleConfiguredAction"
>
<template #trigger>
<N8nButton variant="ghost" size="medium" :class="$style.connectedTrigger">
<div
v-if="connected"
:class="$style.connectedDotContainer"
data-testid="agent-channel-connected-indicator"
>
<span :class="[$style.connectedDot, $style.ping]" />
<span :class="$style.connectedDot" />
</div>
{{
i18n.baseText(
connected ? 'agents.channels.modal.connected' : 'agents.channels.modal.configured',
)
}}
</N8nButton>
</template>
</N8nDropdownMenu>
<N8nButton v-else variant="subtle" size="medium" @click="emit('setup', integration.type)">
{{ i18n.baseText('generic.connect') }}
</N8nButton>
</div>
<template v-else>
<div :class="$style.iconWrapper">
<N8nIcon
:icon="integration.icon && isIconName(integration.icon) ? integration.icon : 'zap'"
:size="28"
:class="$style.channelIcon"
/>
</div>
<div :class="$style.content">
<N8nText :class="$style.name" size="medium" bold color="text-dark">
{{ integration.label }}
</N8nText>
</div>
<div :class="$style.channelActions">
<N8nDropdownMenu
v-if="configured"
:items="configuredActions"
placement="bottom-end"
:modal="false"
@select="handleConfiguredAction"
>
<template #trigger>
<N8nButton variant="ghost" size="medium" :class="$style.connectedTrigger">
<div
v-if="connected"
:class="$style.connectedDotContainer"
data-testid="agent-channel-connected-indicator"
>
<span :class="[$style.connectedDot, $style.ping]" />
<span :class="$style.connectedDot" />
</div>
{{
i18n.baseText(
connected
? 'agents.channels.modal.connected'
: 'agents.channels.modal.configured',
)
}}
</N8nButton>
</template>
</N8nDropdownMenu>
<N8nButton
v-else
variant="subtle"
size="medium"
:icon="connectAction.icon"
@click="emit('setup', integration.type)"
>
{{ connectAction.label }}
</N8nButton>
</div>
</template>
</li>
</template>
@@ -124,6 +156,22 @@ function handleConfiguredAction(action: ChannelAction) {
color: var(--icon-color--strong);
}
.nameSkeleton {
width: 30%;
}
.buttonSkeleton {
height: 32px;
width: 80px;
display: flex;
align-items: center;
justify-content: center;
div {
height: 100%;
}
}
.content {
flex: 1;
min-width: 0;
@@ -1,49 +1,51 @@
<script setup lang="ts">
import {
N8nButton,
N8nIconButton,
N8nDialog,
N8nDialogFooter,
N8nDialogHeader,
N8nDialogTitle,
N8nIcon,
N8nIconButton,
N8nText,
updatedIconSet,
type IconName,
} from '@n8n/design-system';
import type { IconName } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { FocusScope } from 'reka-ui';
import { computed, ref, watch } from 'vue';
import {
agentChannelPlatforms,
createAgentChannelRuntime,
getAgentChannelPlatform,
} from '../channels/registry';
import type {
AgentChannelRuntime,
AgentChannelView,
AgentChannelViewExpose,
} from '../channels/types';
import { useAgentChannelSetup } from '../composables/useAgentChannelSetup';
import { useAgentIntegrationStatus } from '../composables/useAgentIntegrationStatus';
import { useAgentIntegrationsCatalog } from '../composables/useAgentIntegrationsCatalog';
import AgentChannelListItem from './AgentChannelListItem.vue';
import AgentChannelSlackSetup from './AgentChannelSlackSetup.vue';
import AgentChannelLinearSetup from './AgentChannelLinearSetup.vue';
import AgentChannelTelegramSetup from './AgentChannelTelegramSetup.vue';
import AgentChannelDiscordSetup from './AgentChannelDiscordSetup.vue';
import AgentIntegrationCredentialConnection from './AgentIntegrationCredentialConnection.vue';
export type ChannelView =
| 'list'
| 'slack_setup'
| 'slack_edit'
| 'linear_setup'
| 'linear_edit'
| 'telegram_setup'
| 'telegram_edit'
| 'discord_setup'
| 'discord_edit';
export type ChannelView = AgentChannelView;
interface Props {
open: boolean;
agentId: string;
projectId: string;
isPublished: boolean;
view: ChannelView;
isPublished?: boolean;
simpleSetup?: boolean;
ensureAgentPersisted?: () => Promise<void>;
}
const props = defineProps<Props>();
const props = withDefaults(defineProps<Props>(), {
isPublished: false,
simpleSetup: false,
});
const emit = defineEmits<{
'update:open': [value: boolean];
@@ -69,6 +71,7 @@ const {
} = useAgentIntegrationStatus(props.projectId, props.agentId);
const currentView = ref<ChannelView>(props.view);
const viewSession = ref(0);
const credentialIdAtEditOpen = ref('');
const pendingCredentialReplacement = ref<{
channelType: string;
@@ -78,7 +81,8 @@ const pendingCredentialReplacement = ref<{
const credentialReplacementError = ref(false);
function channelTypeFromView(view: ChannelView): string | null {
return view === 'list' ? null : view.split('_')[0];
if (view === 'list') return null;
return view.replace(/_(setup|edit)$/, '');
}
function captureConnectedCredential(channelType: string | null) {
@@ -102,7 +106,6 @@ const currentIntegration = computed(() => {
});
const {
channelSetupRef,
selectedCredentials,
credentialsLoading,
credentialPermissions,
@@ -112,16 +115,50 @@ const {
loadChannelState: loadSharedChannelState,
createCredential,
editCredential,
setupSlackApp: runSlackAppSetup,
} = useAgentChannelSetup({
projectId: () => props.projectId,
agentId: () => props.agentId,
currentIntegration,
connectedCredentials,
fetchStatus,
isIntegrationConfigured,
});
const projectIdRef = computed(() => props.projectId);
const agentIdRef = computed(() => props.agentId);
const runtimes: Record<string, AgentChannelRuntime> = Object.fromEntries(
Object.values(agentChannelPlatforms).map((platform) => [
platform.type,
createAgentChannelRuntime(platform, {
projectId: projectIdRef,
agentId: agentIdRef,
selectedCredentialId: computed(() => getChannelCredentialId(platform.type)),
credentialModalOpen,
fetchStatus,
isConnected: isIntegrationConnected,
isConfigured: isIntegrationConfigured,
ensureAgentPersisted: props.ensureAgentPersisted,
}),
]),
);
const fallbackRuntime = createAgentChannelRuntime(getAgentChannelPlatform('unknown'), {
projectId: projectIdRef,
agentId: agentIdRef,
selectedCredentialId: ref(''),
credentialModalOpen,
fetchStatus,
isConnected: isIntegrationConnected,
isConfigured: isIntegrationConfigured,
ensureAgentPersisted: props.ensureAgentPersisted,
});
const runtimeFor = (type: string): AgentChannelRuntime => runtimes[type] ?? fallbackRuntime;
const currentPlatform = computed(() =>
getAgentChannelPlatform(selectedChannelType.value ?? 'unknown'),
);
const currentRuntime = computed(() => runtimeFor(selectedChannelType.value ?? 'unknown'));
const channelViewRef = ref<AgentChannelViewExpose>();
const listLoading = computed(() =>
Object.values(runtimes).some((runtime) => runtime.loading.value),
);
const hasPendingCredentialReplacement = computed(() => pendingCredentialReplacement.value !== null);
const isCredentialReplacementInProgress = computed(
() => hasPendingCredentialReplacement.value && !credentialReplacementError.value,
@@ -156,21 +193,17 @@ const showFooterActions = computed(() => isEditMode.value && selectedChannelType
const currentChannelCredentialId = computed(() =>
getChannelCredentialId(selectedChannelType.value),
);
const canSaveChannelConfig = computed(() => {
const validationError = channelSetupRef.value?.validationError;
return (
selectedChannelType.value !== null &&
currentChannelCredentialId.value.length > 0 &&
!validationError
!channelViewRef.value?.loading &&
!channelViewRef.value?.validationError
);
});
// Backend integration descriptors ship icon names that may include legacy
// aliases; N8nIcon resolves them at runtime but the static IconName union
// doesn't enumerate them.
function toIconName(icon: string): IconName {
return icon as IconName;
function isIconName(icon: string): icon is IconName {
return icon in updatedIconSet;
}
const headerText = computed(() => {
@@ -198,24 +231,29 @@ function hasError(channelType: string): boolean {
return (errorMessages.value[channelType] ?? '').length > 0;
}
const CONNECTED_TEXT_KEYS = {
telegram: 'agents.builder.addTrigger.connectedText.telegram',
linear: 'agents.builder.addTrigger.connectedText.linear',
} as const;
function integrationConnectedText(channelType: string): string {
if (!isIntegrationConnected(channelType)) return '';
const key = CONNECTED_TEXT_KEYS[channelType as keyof typeof CONNECTED_TEXT_KEYS];
return key ? i18n.baseText(key) : '';
return (
getAgentChannelPlatform(channelType).getConnectedDescription?.({
text: (key) => i18n.baseText(key),
}) ?? ''
);
}
function connectAction(channelType: string) {
return getAgentChannelPlatform(channelType).getConnectAction(
{ text: (key) => i18n.baseText(key) },
runtimeFor(channelType),
);
}
function goToSetup(channelType: string) {
currentView.value = `${channelType}_setup` as ChannelView;
currentView.value = `${channelType}_setup`;
}
function goToEdit(channelType: string) {
prepareChannelEdit(channelType);
currentView.value = `${channelType}_edit` as ChannelView;
currentView.value = `${channelType}_edit`;
}
function goBackToList() {
@@ -273,8 +311,9 @@ async function saveChannelConfig() {
const channelType = selectedChannelType.value;
const credentialId = currentChannelCredentialId.value;
if (!channelType || !credentialId) return;
if (channelSetupRef.value?.validationError) return;
if (channelViewRef.value?.validationError) return;
await props.ensureAgentPersisted?.();
await channelViewRef.value?.beforeSave?.();
const pendingReplacement = pendingCredentialReplacement.value;
if (pendingReplacement?.channelType === channelType) {
selectedCredentials.value[channelType] = pendingReplacement.replacementCredentialId;
@@ -291,7 +330,7 @@ async function saveChannelConfig() {
? credentialIdAtEditOpen.value
: null;
await connect(channelType, credentialId, channelSetupRef.value?.currentSettings);
await connect(channelType, credentialId, channelViewRef.value?.currentSettings);
if (credentialIdToReplace) {
pendingCredentialReplacement.value = {
channelType,
@@ -305,17 +344,17 @@ async function saveChannelConfig() {
closeModal();
}
async function setupSlackApp(appConfigurationToken: string): Promise<boolean> {
await props.ensureAgentPersisted?.();
return await runSlackAppSetup(appConfigurationToken, () => {
emit('channel-connected', 'slack');
emit('agent-changed');
closeModal();
});
function handlePlatformConnected() {
const channelType = selectedChannelType.value;
if (!channelType) return;
emit('channel-connected', channelType);
emit('agent-changed');
closeModal();
}
async function handleDisconnected(channelType: string, credentialId?: string) {
// Draft channel placeholders have no credential, so send '' to remove them by type.
// Draft channels (configured but missing a credential) have no connected
// credential send '' so the backend removes the draft entry by type.
await disconnect(channelType, credentialId ?? connectedCredentials.value[channelType] ?? '');
await fetchStatus([channelType]);
if (!isIntegrationConfigured(channelType)) {
@@ -337,7 +376,10 @@ async function removeCurrentChannel() {
async function loadChannelState() {
const integrations = await ensureLoaded(props.projectId).catch(() => catalog.value ?? []);
await loadSharedChannelState(integrations);
await Promise.all([
loadSharedChannelState(integrations),
...integrations.map(({ type }) => runtimeFor(type).load()),
]);
if (isEditMode.value) {
prepareChannelEdit(selectedChannelType.value);
}
@@ -347,6 +389,7 @@ watch(
() => props.open,
(isOpen) => {
if (isOpen) {
viewSession.value += 1;
void loadChannelState();
currentView.value = props.view;
} else {
@@ -406,8 +449,8 @@ watch(
</N8nIconButton>
<div :class="$style.headerTitle">
<N8nIcon
v-if="currentIntegration?.icon"
:icon="toIconName(currentIntegration.icon)"
v-if="currentIntegration?.icon && isIconName(currentIntegration.icon)"
:icon="currentIntegration.icon"
size="large"
/>
<N8nDialogTitle>{{ headerText }}</N8nDialogTitle>
@@ -426,6 +469,8 @@ watch(
:integration="integration"
:configured="isConfigured(integration.type)"
:connected="isConnected(integration.type)"
:loading="listLoading"
:connect-action="connectAction(integration.type)"
@setup="goToSetup"
@edit="goToEdit"
@disconnect="handleListDisconnect"
@@ -433,31 +478,17 @@ watch(
</ul>
</div>
<div v-else-if="isSetupMode" :key="`setup-${currentView}`" :class="$style.setupView">
<AgentChannelSlackSetup
v-if="selectedChannelType === 'slack'"
ref="channelSetupRef"
v-model="selectedCredentials.slack"
:connected="isConfigured('slack')"
:setup-slack-app="setupSlackApp"
:project-id="projectId"
:agent-id="agentId"
:integration="currentIntegration ?? undefined"
:credentials="getCredentials('slack')"
:credential-permissions="credentialPermissions"
:credentials-loading="credentialsLoading"
:loading="isLoading('slack')"
:error-message="hasError('slack') ? errorMessages.slack : ''"
:error-is-conflict="errorIsConflict.slack"
@create="createCredential"
@edit="editCredential"
@connect="saveChannelConfig"
/>
<AgentChannelLinearSetup
v-else-if="currentIntegration?.type === 'linear'"
ref="channelSetupRef"
<div
v-else-if="currentIntegration"
:key="`${isSetupMode ? 'setup' : 'edit'}-${currentView}`"
:class="isSetupMode ? $style.setupView : $style.editView"
>
<component
:is="isSetupMode ? currentPlatform.setupComponent : currentPlatform.editComponent"
:key="viewSession"
ref="channelViewRef"
v-model="selectedCredentials[currentIntegration.type]"
mode="setup"
:mode="isSetupMode ? 'setup' : 'edit'"
:integration="currentIntegration"
:credentials="getCredentials(currentIntegration.type)"
:credential-permissions="credentialPermissions"
@@ -470,141 +501,27 @@ watch(
"
:error-is-conflict="errorIsConflict[currentIntegration.type]"
:saved-settings="integrationSettings[currentIntegration.type]"
:is-published="isPublished"
:agent-name="agentId"
:project-id="projectId"
:agent-id="agentId"
:force-new-credential="false"
:simple-setup="simpleSetup"
:credential-replacement-pending="hasPendingCredentialReplacement"
:runtime="currentRuntime"
@create="createCredential"
@edit="editCredential"
@connect="saveChannelConfig"
/>
<AgentChannelTelegramSetup
v-else-if="currentIntegration?.type === 'telegram'"
ref="channelSetupRef"
v-model="selectedCredentials[currentIntegration.type]"
mode="setup"
:integration="currentIntegration"
:credentials="getCredentials(currentIntegration.type)"
:credential-permissions="credentialPermissions"
:credentials-loading="credentialsLoading"
:loading="isLoading(currentIntegration.type)"
:connected="isConfigured(currentIntegration.type)"
:connected-description="integrationConnectedText(currentIntegration.type)"
:error-message="
hasError(currentIntegration.type) ? errorMessages[currentIntegration.type] : ''
"
:error-is-conflict="errorIsConflict[currentIntegration.type]"
:saved-settings="integrationSettings[currentIntegration.type]"
:agent-name="agentId"
:project-id="projectId"
:agent-id="agentId"
@create="createCredential"
@edit="editCredential"
@connect="saveChannelConfig"
/>
<AgentChannelDiscordSetup
v-else-if="currentIntegration?.type === 'discord'"
ref="channelSetupRef"
v-model="selectedCredentials[currentIntegration.type]"
mode="setup"
:integration="currentIntegration"
:credentials="getCredentials(currentIntegration.type)"
:credential-permissions="credentialPermissions"
:credentials-loading="credentialsLoading"
:loading="isLoading(currentIntegration.type)"
:connected="isConfigured(currentIntegration.type)"
:error-message="
hasError(currentIntegration.type) ? errorMessages[currentIntegration.type] : ''
"
:error-is-conflict="errorIsConflict[currentIntegration.type]"
:is-published="props.isPublished"
:project-id="projectId"
:agent-id="agentId"
@create="createCredential"
@edit="editCredential"
@connect="saveChannelConfig"
/>
</div>
<div v-else-if="isEditMode" :key="`edit-${currentView}`" :class="$style.editView">
<AgentIntegrationCredentialConnection
v-if="currentIntegration"
v-model="selectedCredentials[currentIntegration.type]"
:integration-type="currentIntegration.type"
:integration-label="currentIntegration.label"
:credentials="getCredentials(currentIntegration.type)"
:credential-permissions="credentialPermissions"
:credentials-loading="credentialsLoading"
:disabled="hasPendingCredentialReplacement || isLoading(currentIntegration.type)"
:loading="isLoading(currentIntegration.type)"
:error-message="
hasError(currentIntegration.type) ? errorMessages[currentIntegration.type] : ''
"
:error-is-conflict="errorIsConflict[currentIntegration.type]"
:show-edit-button="!hasPendingCredentialReplacement"
@create="createCredential"
@edit="editCredential"
@connected="handlePlatformConnected"
/>
<N8nText
v-if="credentialReplacementError"
v-if="isEditMode && credentialReplacementError"
:class="$style.errorText"
size="small"
data-testid="agent-channel-credential-replacement-error"
>
{{ i18n.baseText('agents.channels.modal.credentialReplacementError') }}
</N8nText>
<AgentChannelLinearSetup
v-if="currentIntegration?.type === 'linear'"
ref="channelSetupRef"
v-model="selectedCredentials[currentIntegration.type]"
mode="edit"
:integration="currentIntegration"
:credentials="getCredentials(currentIntegration.type)"
:credential-permissions="credentialPermissions"
:credentials-loading="credentialsLoading"
:loading="hasPendingCredentialReplacement || isLoading(currentIntegration.type)"
:connected="isConfigured(currentIntegration.type)"
:saved-settings="integrationSettings[currentIntegration.type]"
:agent-name="agentId"
:project-id="projectId"
:agent-id="agentId"
/>
<AgentChannelTelegramSetup
v-else-if="currentIntegration?.type === 'telegram'"
ref="channelSetupRef"
v-model="selectedCredentials[currentIntegration.type]"
mode="edit"
:integration="currentIntegration"
:credentials="getCredentials(currentIntegration.type)"
:credential-permissions="credentialPermissions"
:credentials-loading="credentialsLoading"
:loading="hasPendingCredentialReplacement || isLoading(currentIntegration.type)"
:connected="isConfigured(currentIntegration.type)"
:saved-settings="integrationSettings[currentIntegration.type]"
:agent-name="agentId"
:project-id="projectId"
:agent-id="agentId"
/>
<AgentChannelDiscordSetup
v-else-if="currentIntegration?.type === 'discord'"
ref="channelSetupRef"
v-model="selectedCredentials[currentIntegration.type]"
mode="edit"
:integration="currentIntegration"
:credentials="getCredentials(currentIntegration.type)"
:credential-permissions="credentialPermissions"
:credentials-loading="credentialsLoading"
:loading="hasPendingCredentialReplacement || isLoading(currentIntegration.type)"
:connected="isConfigured(currentIntegration.type)"
:project-id="projectId"
:agent-id="agentId"
/>
<N8nText v-else-if="currentIntegration?.type !== 'slack'" size="small" color="text-light">
{{
i18n.baseText('agents.channels.modal.editPlaceholder', {
interpolate: { channel: selectedChannelType ?? '' },
})
}}
</N8nText>
</div>
</Transition>
</div>
@@ -640,7 +557,10 @@ watch(
<N8nButton
variant="solid"
size="medium"
:loading="selectedChannelType ? isLoading(selectedChannelType) : false"
:loading="
(selectedChannelType ? isLoading(selectedChannelType) : false) ||
Boolean(channelViewRef?.loading)
"
:disabled="
!canSaveChannelConfig ||
(selectedChannelType ? isLoading(selectedChannelType) : true)
@@ -14,7 +14,7 @@ import { useI18n } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
import type { ChatIntegrationDescriptor } from '@n8n/api-types';
import type { PermissionsRecord } from '@n8n/permissions';
import { getSlackAgentAppManifest } from '../composables/useAgentApi';
import { getSlackAgentAppManifest } from '../channels/slack/api';
import AgentIntegrationCredentialConnection from './AgentIntegrationCredentialConnection.vue';
import type { AgentCredentialOption } from './AgentCredentialSelect.vue';
@@ -144,7 +144,7 @@ function openChannelModal() {
function openChannelEdit(channelType: string) {
const hasEditableChannelView = catalog.value?.some(({ type }) => type === channelType) ?? false;
channelModalView.value = hasEditableChannelView ? (`${channelType}_edit` as ChannelView) : 'list';
channelModalView.value = hasEditableChannelView ? `${channelType}_edit` : 'list';
channelModalOpen.value = true;
}
@@ -1,77 +0,0 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import type { AgentIntegrationSettings, AgentTelegramIntegrationSettings } from '@n8n/api-types';
import { resolveSavedTelegramSettings } from '../utils/telegramAccessSettings';
import AgentTelegramAccessSettingsForm from './AgentTelegramAccessSettingsForm.vue';
import AgentSlackSettingsForm from './AgentSlackSettingsForm.vue';
const props = withDefaults(
defineProps<{
type: string;
disabled?: boolean;
connected?: boolean;
savedSettings?: AgentIntegrationSettings;
agentName?: string;
projectId?: string;
agentId?: string;
setupSlackApp?: (appConfigurationToken: string) => Promise<boolean>;
}>(),
{
disabled: false,
connected: false,
savedSettings: undefined,
agentName: '',
projectId: '',
agentId: '',
setupSlackApp: undefined,
},
);
const telegramFormRef = ref<InstanceType<typeof AgentTelegramAccessSettingsForm>>();
const telegramSavedSettings = computed<AgentTelegramIntegrationSettings | undefined>(() =>
resolveSavedTelegramSettings(props.savedSettings, props.connected),
);
const currentSettings = computed<AgentIntegrationSettings | undefined>(
() => telegramFormRef.value?.currentSettings,
);
const validationError = computed<string | null>(
() => telegramFormRef.value?.validationError ?? null,
);
const isDirty = computed<boolean>(() => telegramFormRef.value?.isDirty ?? false);
watch(
() => props.type,
() => {
telegramFormRef.value = undefined;
},
);
defineExpose({ currentSettings, validationError, isDirty });
</script>
<template>
<AgentTelegramAccessSettingsForm
v-if="props.type === 'telegram'"
ref="telegramFormRef"
:disabled="disabled"
:saved-settings="telegramSavedSettings"
/>
<AgentSlackSettingsForm
v-else-if="props.type === 'slack'"
:agent-name="agentName"
:project-id="projectId"
:agent-id="agentId"
:connected="connected"
:disabled="disabled"
:setup-slack-app="setupSlackApp"
>
<template #manualConfiguration>
<slot name="manualConfiguration" />
</template>
</AgentSlackSettingsForm>
</template>
@@ -1,313 +0,0 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { N8nButton, N8nCollapsiblePanel, N8nIcon, N8nInput, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
import { getSlackAgentAppManifest } from '../composables/useAgentApi';
const props = defineProps<{
agentName: string;
projectId: string;
agentId: string;
connected?: boolean;
disabled?: boolean;
setupSlackApp?: (appConfigurationToken: string) => Promise<boolean>;
}>();
const i18n = useI18n();
const rootStore = useRootStore();
const manifestCopied = ref(false);
const appConfigurationToken = ref('');
const setupLoading = ref(false);
const setupError = ref(false);
const manualConfigurationOpen = ref(false);
const slackAppManifest = ref('');
const manifestLoading = ref(false);
const manifestError = ref(false);
async function copyManifest() {
if (!slackAppManifest.value) return;
await navigator.clipboard.writeText(slackAppManifest.value);
manifestCopied.value = true;
setTimeout(() => {
manifestCopied.value = false;
}, 2000);
}
async function loadSlackAppManifest() {
manifestLoading.value = true;
manifestError.value = false;
try {
const { manifest } = await getSlackAgentAppManifest(
rootStore.restApiContext,
props.projectId,
props.agentId,
);
slackAppManifest.value = JSON.stringify(manifest, null, 2);
} catch {
slackAppManifest.value = '';
manifestError.value = true;
} finally {
manifestLoading.value = false;
}
}
async function createSlackApp() {
const token = appConfigurationToken.value.trim();
if (!token || !props.setupSlackApp || props.disabled || props.connected) return;
setupLoading.value = true;
setupError.value = false;
try {
const completed = await props.setupSlackApp(token);
if (completed) {
appConfigurationToken.value = '';
}
} catch {
setupError.value = true;
} finally {
setupLoading.value = false;
}
}
watch(
() => [props.projectId, props.agentId, props.connected] as const,
() => {
if (!props.connected) {
void loadSlackAppManifest();
}
},
{ immediate: true },
);
</script>
<template>
<div :class="$style.slackSettings">
<div v-if="!connected" :class="$style.setupSection">
<N8nText size="small" bold>
{{ i18n.baseText('agents.builder.addTrigger.slack.setup.title') }}
</N8nText>
<N8nText :class="$style.setupHint" size="small">
{{ i18n.baseText('agents.builder.addTrigger.slack.setup.hint') }}
<a
href="https://api.slack.com/apps"
target="_blank"
rel="noopener noreferrer"
:class="$style.docsLink"
data-testid="slack-app-configuration-token-link"
>
{{ i18n.baseText('agents.builder.addTrigger.slack.setup.tokenLink') }}
</a>
</N8nText>
<div :class="$style.setupInputRow">
<N8nInput
v-model="appConfigurationToken"
:class="$style.setupInput"
type="password"
size="medium"
:placeholder="i18n.baseText('agents.builder.addTrigger.slack.setup.tokenPlaceholder')"
data-testid="slack-app-configuration-token"
:disabled="disabled || setupLoading"
@keydown.enter.prevent="createSlackApp"
/>
<N8nButton
variant="solid"
size="small"
:loading="setupLoading"
:disabled="!appConfigurationToken.trim() || disabled || setupLoading || !setupSlackApp"
data-testid="slack-create-app"
@click="createSlackApp"
>
<template #prefix>
<N8nIcon icon="plus" size="xsmall" />
</template>
{{ i18n.baseText('agents.builder.addTrigger.slack.setup.button') }}
</N8nButton>
</div>
<N8nText
v-if="setupError"
:class="$style.setupError"
size="small"
data-testid="slack-app-setup-error"
>
{{ i18n.baseText('agents.builder.addTrigger.slack.setup.error') }}
</N8nText>
</div>
<N8nCollapsiblePanel
v-if="!connected"
v-model="manualConfigurationOpen"
:class="$style.manualPanel"
:title="i18n.baseText('agents.builder.addTrigger.slack.manual.title')"
:show-actions-on-hover="false"
:disable-animation="true"
data-testid="slack-manual-configuration"
>
<div :class="$style.manualConfiguration">
<N8nText :class="$style.manualDescription" size="small">
{{ i18n.baseText('agents.builder.addTrigger.slack.manual.description') }}
</N8nText>
<div :class="$style.manifestSection">
<N8nText size="small" bold>
{{ i18n.baseText('agents.builder.addTrigger.slack.manifestTitle') }}
</N8nText>
<N8nText :class="$style.manifestHint" size="small">
{{ i18n.baseText('agents.builder.addTrigger.slack.manifestHint') }}
<a
href="https://docs.slack.dev/app-manifests/configuring-apps-with-app-manifests"
target="_blank"
rel="noopener noreferrer"
:class="$style.docsLink"
>
{{ i18n.baseText('agents.builder.addTrigger.slack.docsCalloutLink') }}
</a>
</N8nText>
<N8nText
v-if="manifestLoading"
:class="$style.manifestHint"
size="small"
data-testid="slack-manifest-loading"
>
{{ i18n.baseText('agents.builder.addTrigger.slack.manifestLoading') }}
</N8nText>
<N8nText
v-else-if="manifestError"
:class="$style.setupError"
size="small"
data-testid="slack-manifest-error"
>
{{ i18n.baseText('agents.builder.addTrigger.slack.manifestError') }}
</N8nText>
<div v-else :class="$style.codeBlock">
<N8nButton
variant="outline"
size="small"
:class="$style.codeBlockCopy"
:disabled="!slackAppManifest"
data-testid="slack-copy-manifest"
@click="copyManifest"
>
<template #prefix>
<N8nIcon :icon="manifestCopied ? 'check' : 'copy'" size="xsmall" />
</template>
{{
manifestCopied
? i18n.baseText('agents.builder.addTrigger.copied')
: i18n.baseText('agents.builder.addTrigger.copy')
}}
</N8nButton>
<pre :class="$style.manifestCode">{{ slackAppManifest }}</pre>
</div>
</div>
<slot name="manualConfiguration" />
</div>
</N8nCollapsiblePanel>
</div>
</template>
<style module lang="scss">
.slackSettings {
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
}
.setupSection {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
padding-bottom: var(--spacing--xs);
}
.manualConfiguration {
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
padding: 0 var(--spacing--xs) var(--spacing--xs);
}
.manualPanel {
background-color: transparent;
}
.manualDescription {
color: var(--color--text--tint-1);
}
.manifestSection {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
}
.setupHint {
color: var(--color--text--tint-1);
}
.setupInputRow {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
flex-wrap: wrap;
}
.setupInput {
flex: 1 1 16rem;
min-width: 0;
}
.setupError {
color: var(--color--danger);
}
.docsCallout {
margin-bottom: var(--spacing--3xs);
}
.docsLink {
color: var(--color--primary);
text-decoration: underline;
}
.manifestHint {
color: var(--color--text--tint-1);
}
.codeBlock {
position: relative;
margin-top: var(--spacing--3xs);
}
/* Sit on top of the rounded container itself rather than inside the scrolling
<pre>, so the button stays put as the user scrolls and never collides with
the scrollbar groove. The right offset clears typical macOS / overlay
scrollbars (~14px) plus our normal inner padding. */
.codeBlockCopy {
position: absolute;
top: var(--spacing--2xs);
right: var(--spacing--lg);
z-index: 1;
}
.manifestCode {
margin: 0;
padding: var(--spacing--xs);
padding-right: calc(var(--spacing--2xl) + var(--spacing--lg));
background-color: var(--color--foreground--tint-2);
border-radius: var(--radius);
font-size: var(--font-size--2xs);
line-height: var(--line-height--xl);
overflow-x: auto;
max-height: 240px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
white-space: pre;
font-family: monospace;
color: var(--color--text);
}
</style>
@@ -14,8 +14,6 @@ import type {
AgentProviderModelsResponse,
AgentVersionListItemDto,
ChatIntegrationDescriptor,
CreateSlackAgentAppResponse,
SlackAgentAppManifestResponse,
VectorStoreTestResult,
} from '@n8n/api-types';
import { getFullApiResponse, makeRestApiRequest } from '@n8n/rest-api-client';
@@ -198,7 +196,7 @@ export const disconnectIntegration = async (
type: string,
credentialId: string,
): Promise<{ status: string }> => {
return await makeRestApiRequest(
return await makeRestApiRequest<{ status: string }>(
context,
'POST',
`/projects/${projectId}/agents/v2/${agentId}/integrations/disconnect`,
@@ -285,49 +283,6 @@ export const runAgentTask = async (
);
};
// Backward-compatible aliases
export const connectSlack = async (
ctx: IRestApiContext,
projectId: string,
agentId: string,
credentialId: string,
) => await connectIntegration(ctx, projectId, agentId, 'slack', credentialId);
export const disconnectSlack = async (
ctx: IRestApiContext,
projectId: string,
agentId: string,
credentialId: string,
) => await disconnectIntegration(ctx, projectId, agentId, 'slack', credentialId);
export const getSlackStatus = getIntegrationStatus;
export const createSlackAgentApp = async (
context: IRestApiContext,
projectId: string,
agentId: string,
appConfigurationToken: string,
): Promise<CreateSlackAgentAppResponse> => {
return await makeRestApiRequest<CreateSlackAgentAppResponse>(
context,
'POST',
`/projects/${projectId}/agents/v2/${agentId}/integrations/slack/app`,
{ appConfigurationToken },
);
};
export const getSlackAgentAppManifest = async (
context: IRestApiContext,
projectId: string,
agentId: string,
): Promise<SlackAgentAppManifestResponse> => {
return await makeRestApiRequest<SlackAgentAppManifestResponse>(
context,
'GET',
`/projects/${projectId}/agents/v2/${agentId}/integrations/slack/manifest`,
);
};
export type ModelInfo = AgentCatalogModel;
export interface ProviderInfo {
@@ -1,6 +1,5 @@
import type { AgentIntegrationSettings, ChatIntegrationDescriptor } from '@n8n/api-types';
import { getResourcePermissions } from '@n8n/permissions';
import { useRootStore } from '@n8n/stores/useRootStore';
import { computed, ref, toValue, watch, type MaybeRefOrGetter } from 'vue';
import { useUIStore } from '@/app/stores/ui.store';
@@ -10,10 +9,6 @@ import { useProjectsStore } from '@/features/collaboration/projects/projects.sto
import type { Project } from '@/features/collaboration/projects/projects.types';
import type { AgentCredentialOption } from '../components/AgentCredentialSelect.vue';
import { createSlackAgentApp } from './useAgentApi';
const SLACK_APP_SETUP_POLL_INTERVAL_MS = 2000;
const SLACK_APP_SETUP_TIMEOUT_MS = 2 * 60 * 1000;
type ChannelSetupComponent = {
credentialId: string;
@@ -23,15 +18,12 @@ type ChannelSetupComponent = {
type UseAgentChannelSetupOptions = {
projectId: MaybeRefOrGetter<string>;
agentId: MaybeRefOrGetter<string>;
currentIntegration: MaybeRefOrGetter<ChatIntegrationDescriptor | null | undefined>;
connectedCredentials: MaybeRefOrGetter<Record<string, string>>;
fetchStatus: (integrationTypes: string[]) => Promise<void>;
isIntegrationConfigured: (type: string) => boolean;
};
export function useAgentChannelSetup(options: UseAgentChannelSetupOptions) {
const rootStore = useRootStore();
const uiStore = useUIStore();
const credentialsStore = useCredentialsStore();
const projectsStore = useProjectsStore();
@@ -46,7 +38,6 @@ export function useAgentChannelSetup(options: UseAgentChannelSetupOptions) {
const fetchedProjectForPermissions = ref<Project | null>(null);
const projectId = computed(() => toValue(options.projectId));
const agentId = computed(() => toValue(options.agentId));
const currentIntegration = computed(() => toValue(options.currentIntegration) ?? null);
const connectedCredentials = computed(() => toValue(options.connectedCredentials));
@@ -171,100 +162,6 @@ export function useAgentChannelSetup(options: UseAgentChannelSetupOptions) {
}
}
function openSlackAppAuthorizationPopup(installUrl: string): Window {
const parsedUrl = new URL(installUrl);
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
throw new Error('Invalid Slack installation URL');
}
const params =
'scrollbars=no,resizable=yes,status=no,titlebar=no,location=no,toolbar=no,menubar=no,width=500,height=700';
const popup = window.open(parsedUrl.toString(), 'Slack App Authorization', params);
if (!popup) {
throw new Error('Slack authorization popup was blocked');
}
return popup;
}
async function waitForSlackAppSetupCompletion(popup: Window): Promise<boolean> {
return await new Promise((resolve) => {
const oauthChannel = new BroadcastChannel('oauth-callback');
let activePoll: Promise<void> | null = null;
let settled = false;
const closePopup = () => {
try {
popup.close();
} catch {}
};
const settle = (success: boolean) => {
if (settled) return;
settled = true;
window.clearInterval(pollInterval);
window.clearTimeout(timeout);
oauthChannel.close();
if (success) closePopup();
resolve(success);
};
const pollStatus = async () => {
if (activePoll || settled) return;
activePoll = (async () => {
try {
await options.fetchStatus(['slack']);
if (options.isIntegrationConfigured('slack')) settle(true);
} finally {
activePoll = null;
}
})();
await activePoll;
};
const pollInterval = window.setInterval(() => {
// User closed the popup — the OAuth flow can't complete anymore. Let any
// in-flight poll finish (it may confirm success), check status once more,
// then give up instead of blocking the UI until the full timeout.
if (popup.closed) {
void (activePoll ?? Promise.resolve())
.catch(() => {})
.then(pollStatus)
.finally(() => settle(false));
return;
}
void pollStatus();
}, SLACK_APP_SETUP_POLL_INTERVAL_MS);
const timeout = window.setTimeout(() => settle(false), SLACK_APP_SETUP_TIMEOUT_MS);
oauthChannel.addEventListener('message', (event: MessageEvent) => {
settle(event.data === 'success');
});
void pollStatus();
});
}
async function setupSlackApp(
appConfigurationToken: string,
onConfigured: () => void | Promise<void>,
): Promise<boolean> {
const { installUrl } = await createSlackAgentApp(
rootStore.restApiContext,
projectId.value,
agentId.value,
appConfigurationToken,
);
const popup = openSlackAppAuthorizationPopup(installUrl);
const configured = await waitForSlackAppSetupCompletion(popup);
if (!configured) {
throw new Error('Slack app installation was not completed');
}
await options.fetchStatus(['slack']);
await onConfigured();
return true;
}
watch(credentialModalOpen, async (isOpen, wasOpen) => {
if (!wasOpen || isOpen) return;
const type = pendingNewCredentialType.value;
@@ -300,6 +197,5 @@ export function useAgentChannelSetup(options: UseAgentChannelSetupOptions) {
loadChannelState,
createCredential,
editCredential,
setupSlackApp,
};
}
@@ -86,6 +86,9 @@ vi.mock('@/features/agents/composables/useAgentIntegrationStatus', () => ({
vi.mock('@/features/agents/composables/useAgentApi', () => ({
getAgent: mocks.getAgent,
}));
vi.mock('@/features/agents/channels/slack/api', () => ({
createSlackAgentApp: mocks.createSlackAgentApp,
}));
@@ -129,7 +132,7 @@ vi.mock('@/features/agents/components/AgentChannelSlackSetup.vue', () => ({
},
}));
vi.mock('@/features/agents/components/AgentChannelLinearSetup.vue', () => ({
vi.mock('@/features/agents/channels/linear/AgentChannelLinearSetup.vue', () => ({
default: {
props: ['connectedDescription'],
template:
@@ -307,6 +310,14 @@ describe('ChannelSetupCard', () => {
expect(wrapper.emitted('resolve')).toBeUndefined();
});
it('renders the safe fallback for an unknown catalog integration', async () => {
const wrapper = mountCard({ integrationType: 'unknown-channel' });
await flushPromises();
expect(wrapper.find('[data-testid="channel-setup-catalog-loading"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="channel-setup-catalog-error"]').exists()).toBe(false);
});
it('shows a loading state until the integration catalog arrives', async () => {
let resolveCatalog: (integrations: ChatIntegrationDescriptor[]) => void = () => {};
mocks.setCatalog([]);
@@ -8,22 +8,31 @@
* `resolve` event that the consumer translates into its own confirm/resolve
* transport call.
*/
import { N8nButton, N8nIcon, N8nLoading, N8nText } from '@n8n/design-system';
import type { IconName } from '@n8n/design-system';
import {
N8nButton,
N8nIcon,
N8nLoading,
N8nText,
updatedIconSet,
type IconName,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { ChatIntegrationDescriptor } from '@n8n/api-types';
import { useRootStore } from '@n8n/stores/useRootStore';
import { computed, ref, watch } from 'vue';
import { agentsEventBus } from '@/features/agents/agents.eventBus';
import {
agentChannelPlatforms,
createAgentChannelRuntime,
getAgentChannelPlatform,
isRegisteredAgentChannelPlatform,
} from '@/features/agents/channels/registry';
import type { AgentChannelRuntime, AgentChannelViewExpose } from '@/features/agents/channels/types';
import { getAgent } from '@/features/agents/composables/useAgentApi';
import { useAgentChannelSetup } from '@/features/agents/composables/useAgentChannelSetup';
import { useAgentIntegrationStatus } from '@/features/agents/composables/useAgentIntegrationStatus';
import { useAgentIntegrationsCatalog } from '@/features/agents/composables/useAgentIntegrationsCatalog';
import AgentChannelDiscordSetup from '@/features/agents/components/AgentChannelDiscordSetup.vue';
import AgentChannelLinearSetup from '@/features/agents/components/AgentChannelLinearSetup.vue';
import AgentChannelSlackSetup from '@/features/agents/components/AgentChannelSlackSetup.vue';
import AgentChannelTelegramSetup from '@/features/agents/components/AgentChannelTelegramSetup.vue';
import type { AgentResource } from '@/features/agents/types';
const props = defineProps<{
@@ -65,42 +74,74 @@ const agent = ref<AgentResource | null>(null);
const catalogLoading = ref(false);
const catalogLoadFailed = ref(false);
const currentIntegration = computed<ChatIntegrationDescriptor | null>(() => {
return catalog.value?.find((integration) => integration.type === props.integrationType) ?? null;
const currentIntegration = computed<ChatIntegrationDescriptor>(() => {
return (
catalog.value?.find((integration) => integration.type === props.integrationType) ?? {
type: props.integrationType,
label: props.integrationType,
icon: 'zap',
credentialTypes: [],
}
);
});
const {
channelSetupRef,
selectedCredentials,
credentialsLoading,
credentialPermissions,
credentialModalOpen,
getChannelCredentialId,
getCredentials,
loadChannelState: loadSharedChannelState,
createCredential,
editCredential,
setupSlackApp: runSlackAppSetup,
} = useAgentChannelSetup({
projectId: () => props.projectId,
agentId: () => props.agentId,
currentIntegration,
connectedCredentials,
fetchStatus,
isIntegrationConfigured,
});
const integrationLabel = computed(() => currentIntegration.value?.label ?? props.integrationType);
const connectedDescriptionKeys = {
telegram: 'agents.builder.addTrigger.connectedText.telegram',
linear: 'agents.builder.addTrigger.connectedText.linear',
} as const;
const projectIdRef = computed(() => props.projectId);
const agentIdRef = computed(() => props.agentId);
const runtimes: Record<string, AgentChannelRuntime> = Object.fromEntries(
Object.values(agentChannelPlatforms).map((platform) => [
platform.type,
createAgentChannelRuntime(platform, {
projectId: projectIdRef,
agentId: agentIdRef,
selectedCredentialId: computed(() => getChannelCredentialId(platform.type)),
credentialModalOpen,
fetchStatus,
isConnected: isIntegrationConnected,
isConfigured: isIntegrationConfigured,
}),
]),
);
const fallbackRuntime = createAgentChannelRuntime(getAgentChannelPlatform('unknown'), {
projectId: projectIdRef,
agentId: agentIdRef,
selectedCredentialId: computed(() => getChannelCredentialId(props.integrationType)),
credentialModalOpen,
fetchStatus,
isConnected: isIntegrationConnected,
isConfigured: isIntegrationConfigured,
});
const currentPlatform = computed(() => getAgentChannelPlatform(props.integrationType));
const currentRuntime = computed(() => runtimes[props.integrationType] ?? fallbackRuntime);
const channelActionInFlight = computed(
() => connectionInFlight.value || currentRuntime.value.loading.value,
);
const channelViewRef = ref<AgentChannelViewExpose>();
const integrationLabel = computed(() => currentIntegration.value.label);
const connectedDescription = computed(() => {
if (!isIntegrationConnected(props.integrationType)) return '';
const key =
connectedDescriptionKeys[props.integrationType as keyof typeof connectedDescriptionKeys];
return key ? i18n.baseText(key) : '';
return (
currentPlatform.value.getConnectedDescription?.({
text: (key) => i18n.baseText(key),
}) ?? ''
);
});
const currentChannelCredentialId = computed(() => getChannelCredentialId(props.integrationType));
@@ -115,8 +156,8 @@ const cardTitle = computed(() =>
}),
);
function toIconName(icon: string): IconName {
return icon as IconName;
function isIconName(icon: string): icon is IconName {
return icon in updatedIconSet;
}
function isBlocked() {
@@ -140,18 +181,19 @@ function notifyAgentUpdated() {
}
function skipSetup() {
if (connectionInFlight.value) return;
if (channelActionInFlight.value) return;
finish(false);
}
async function saveChannelConfig() {
if (isBlocked() || connectionInFlight.value) return;
if (isBlocked() || channelActionInFlight.value) return;
const credentialId = currentChannelCredentialId.value;
if (!credentialId || channelSetupRef.value?.validationError) return;
if (!credentialId || channelViewRef.value?.validationError) return;
connectionInFlight.value = true;
try {
await connect(props.integrationType, credentialId, channelSetupRef.value?.currentSettings);
await channelViewRef.value?.beforeSave?.();
await connect(props.integrationType, credentialId, channelViewRef.value?.currentSettings);
notifyAgentUpdated();
finish(true);
} catch {
@@ -161,17 +203,10 @@ async function saveChannelConfig() {
}
}
async function setupSlackApp(appConfigurationToken: string): Promise<boolean> {
if (isBlocked() || connectionInFlight.value) return false;
connectionInFlight.value = true;
try {
return await runSlackAppSetup(appConfigurationToken, () => {
notifyAgentUpdated();
finish(true);
});
} finally {
connectionInFlight.value = false;
}
function handlePlatformConnected() {
if (isBlocked()) return;
notifyAgentUpdated();
finish(true);
}
async function loadChannelState(forceReload = false) {
@@ -181,7 +216,8 @@ async function loadChannelState(forceReload = false) {
let integrations = await (forceReload
? reloadCatalog(props.projectId)
: ensureLoaded(props.projectId));
const requiresDescriptor = props.integrationType !== 'slack';
const requiresDescriptor =
props.integrationType !== 'slack' && isRegisteredAgentChannelPlatform(props.integrationType);
if (
requiresDescriptor &&
@@ -199,14 +235,12 @@ async function loadChannelState(forceReload = false) {
return;
}
await loadSharedChannelState(integrations);
await Promise.all([loadSharedChannelState(integrations), currentRuntime.value.load()]);
if (requiresDescriptor) {
try {
agent.value = await getAgent(rootStore.restApiContext, props.projectId, props.agentId);
} catch {
agent.value = null;
}
try {
agent.value = await getAgent(rootStore.restApiContext, props.projectId, props.agentId);
} catch {
agent.value = null;
}
} catch {
catalogLoadFailed.value = true;
@@ -226,8 +260,8 @@ watch(
<div :class="$style.card">
<header :class="$style.header">
<N8nIcon
v-if="currentIntegration?.icon"
:icon="toIconName(currentIntegration.icon)"
v-if="isIconName(currentIntegration.icon)"
:icon="currentIntegration.icon"
size="medium"
/>
<N8nText :class="$style.title" size="medium" color="text-dark" bold>
@@ -261,96 +295,35 @@ watch(
</N8nButton>
</div>
<AgentChannelSlackSetup
v-else-if="integrationType === 'slack'"
ref="channelSetupRef"
v-model="selectedCredentials.slack"
:connected="isConfigured"
:setup-slack-app="setupSlackApp"
:project-id="projectId"
:agent-id="agentId"
:integration="currentIntegration ?? undefined"
:credentials="currentCredentials"
:credential-permissions="credentialPermissions"
:credentials-loading="credentialsLoading"
:loading="isLoading"
:error-message="errorMessage"
:error-is-conflict="errorIsConflict.slack"
:force-new-credential="true"
setup-mode="simple"
@create="createCredential"
@edit="editCredential"
@connect="saveChannelConfig"
/>
<AgentChannelLinearSetup
v-else-if="currentIntegration?.type === 'linear'"
ref="channelSetupRef"
v-model="selectedCredentials[currentIntegration.type]"
<component
v-else
:is="currentPlatform.setupComponent"
ref="channelViewRef"
v-model="selectedCredentials[integrationType]"
mode="setup"
:integration="currentIntegration"
:credentials="currentCredentials"
:credential-permissions="credentialPermissions"
:credentials-loading="credentialsLoading"
:loading="isLoading"
:loading="isLoading || connectionInFlight"
:disabled="isBlocked()"
:connected="isConfigured"
:connected-description="connectedDescription"
:error-message="errorMessage"
:error-is-conflict="errorIsConflict[currentIntegration.type]"
:saved-settings="integrationSettings[currentIntegration.type]"
:agent-name="agent?.name ?? agentId"
:project-id="projectId"
:agent-id="agentId"
:force-new-credential="true"
@create="createCredential"
@edit="editCredential"
@connect="saveChannelConfig"
/>
<AgentChannelTelegramSetup
v-else-if="currentIntegration?.type === 'telegram'"
ref="channelSetupRef"
v-model="selectedCredentials[currentIntegration.type]"
mode="setup"
:integration="currentIntegration"
:credentials="currentCredentials"
:credential-permissions="credentialPermissions"
:credentials-loading="credentialsLoading"
:loading="isLoading"
:connected="isConfigured"
:connected-description="connectedDescription"
:error-message="errorMessage"
:error-is-conflict="errorIsConflict[currentIntegration.type]"
:saved-settings="integrationSettings[currentIntegration.type]"
:agent-name="agent?.name ?? agentId"
:project-id="projectId"
:agent-id="agentId"
:force-new-credential="true"
@create="createCredential"
@edit="editCredential"
@connect="saveChannelConfig"
/>
<AgentChannelDiscordSetup
v-else-if="currentIntegration?.type === 'discord'"
ref="channelSetupRef"
v-model="selectedCredentials[currentIntegration.type]"
mode="setup"
:integration="currentIntegration"
:credentials="currentCredentials"
:credential-permissions="credentialPermissions"
:credentials-loading="credentialsLoading"
:loading="isLoading"
:connected="isConfigured"
:error-message="errorMessage"
:error-is-conflict="errorIsConflict[currentIntegration.type]"
:error-is-conflict="errorIsConflict[integrationType]"
:saved-settings="integrationSettings[integrationType]"
:is-published="Boolean(agent?.activeVersionId)"
:agent-name="agent?.name ?? agentId"
:project-id="projectId"
:agent-id="agentId"
:force-new-credential="true"
:simple-setup="true"
:credential-replacement-pending="false"
:runtime="currentRuntime"
@create="createCredential"
@edit="editCredential"
@connect="saveChannelConfig"
@connected="handlePlatformConnected"
/>
</div>
@@ -358,7 +331,7 @@ watch(
<N8nButton
variant="ghost"
size="medium"
:disabled="connectionInFlight"
:disabled="channelActionInFlight"
data-testid="channel-setup-card-skip"
@click="skipSetup"
>