diff --git a/packages/@n8n/api-types/src/agents/dto.ts b/packages/@n8n/api-types/src/agents/dto.ts index fe3c6a2fa41..954107e285d 100644 --- a/packages/@n8n/api-types/src/agents/dto.ts +++ b/packages/@n8n/api-types/src/agents/dto.ts @@ -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, }) {} diff --git a/packages/@n8n/api-types/src/agents/index.ts b/packages/@n8n/api-types/src/agents/index.ts index 24d1fb4a42e..ccbdad686f2 100644 --- a/packages/@n8n/api-types/src/agents/index.ts +++ b/packages/@n8n/api-types/src/agents/index.ts @@ -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'; diff --git a/packages/@n8n/api-types/src/agents/slack/dto.ts b/packages/@n8n/api-types/src/agents/slack/dto.ts new file mode 100644 index 00000000000..36ba9cb7a6d --- /dev/null +++ b/packages/@n8n/api-types/src/agents/slack/dto.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +import { Z } from '../../zod-class'; + +export class CreateSlackAgentAppDto extends Z.class({ + appConfigurationToken: z.string().min(1), +}) {} diff --git a/packages/@n8n/api-types/src/agents/slack/index.ts b/packages/@n8n/api-types/src/agents/slack/index.ts new file mode 100644 index 00000000000..986213b43af --- /dev/null +++ b/packages/@n8n/api-types/src/agents/slack/index.ts @@ -0,0 +1,2 @@ +export * from './dto'; +export type * from './types'; diff --git a/packages/@n8n/api-types/src/agents/slack/types.ts b/packages/@n8n/api-types/src/agents/slack/types.ts new file mode 100644 index 00000000000..a7b8a3ef246 --- /dev/null +++ b/packages/@n8n/api-types/src/agents/slack/types.ts @@ -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; +} diff --git a/packages/@n8n/api-types/src/agents/types.ts b/packages/@n8n/api-types/src/agents/types.ts index 95797cbd4cb..28a9cd60b40 100644 --- a/packages/@n8n/api-types/src/agents/types.ts +++ b/packages/@n8n/api-types/src/agents/types.ts @@ -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; diff --git a/packages/cli/src/modules/agents/__tests__/agent-integration-management.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-integration-management.service.test.ts new file mode 100644 index 00000000000..2dbfc05efb4 --- /dev/null +++ b/packages/cli/src/modules/agents/__tests__/agent-integration-management.service.test.ts @@ -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(); + const credentialsService = mock(); + const chatService = mock(); + const registry = mock(); + const implementation = mock({ + 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 }, + ); + }); +}); diff --git a/packages/cli/src/modules/agents/__tests__/agent-integrations.controller.test.ts b/packages/cli/src/modules/agents/__tests__/agent-integrations.controller.test.ts index 50a7db558ae..812de1d51e0 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-integrations.controller.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-integrations.controller.test.ts @@ -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(), - credentialsService = mock(), + managementService = mock(), chatIntegrationService = mock(), agentRepository = mock(), chatIntegrationRegistry = mock(), - slackAppSetupService = mock(), }: { - agentIntegrationPersistenceService?: Mocked; - credentialsService?: Mocked; + managementService?: Mocked; chatIntegrationService?: Mocked; agentRepository?: Mocked; chatIntegrationRegistry?: Mocked; - slackAppSetupService?: Mocked; } = {}) { - 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.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(); - const agentRepository = mock(); - 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.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.findByIdAndProjectId.mockResolvedValue({ - id: 'agent-1', - projectId: 'project-1', - activeVersionId: 'v1', - activeVersion: {}, - integrations: [], - } as never); - - const chatIntegrationService = mock(); - const chatIntegrationRegistry = mock(); - 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.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(); - const agent = { - id: 'agent-1', - projectId: 'project-1', - activeVersionId: 'v1', - activeVersion: {}, - integrations: [], - }; - agentRepository.findByIdAndProjectId.mockResolvedValue(agent as never); - - const chatIntegrationService = mock(); - const agentIntegrationPersistenceService = mock(); - 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.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(); - 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.saveCredentialIntegration.mockResolvedValue( - savedAgent as never, - ); - const chatIntegrationService = mock(); - 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.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(); - 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.saveCredentialIntegration.mockResolvedValue( - savedAgent as never, - ); - const chatIntegrationService = mock(); - 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.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.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.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(); - const agent = { - id: 'agent-1', - projectId: 'project-1', - integrations: [{ type: 'slack', credentialId: 'cred-slack' }], - }; - agentRepository.findByIdAndProjectId.mockResolvedValue(agent as never); - - const chatIntegrationService = mock(); - const agentIntegrationPersistenceService = mock(); - 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(); - const agent = { - id: 'agent-1', - projectId: 'project-1', - integrations: [{ type: 'slack', credentialId: '' }], - }; - agentRepository.findByIdAndProjectId.mockResolvedValue(agent as never); - - const chatIntegrationService = mock(); - const agentIntegrationPersistenceService = mock(); - 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.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.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(); - 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(); - 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 () => { diff --git a/packages/cli/src/modules/agents/__tests__/agent-slack-integrations.controller.test.ts b/packages/cli/src/modules/agents/__tests__/agent-slack-integrations.controller.test.ts new file mode 100644 index 00000000000..c5114dedf96 --- /dev/null +++ b/packages/cli/src/modules/agents/__tests__/agent-slack-integrations.controller.test.ts @@ -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(); + 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'); + }); +}); diff --git a/packages/cli/src/modules/agents/agent-integration-management.service.ts b/packages/cli/src/modules/agents/agent-integration-management.service.ts new file mode 100644 index 00000000000..5e4d6084f8a --- /dev/null +++ b/packages/cli/src/modules/agents/agent-integration-management.service.ts @@ -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 { + 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 }; + } +} diff --git a/packages/cli/src/modules/agents/agent-integrations.controller.ts b/packages/cli/src/modules/agents/agent-integrations.controller.ts index 31aee39078a..fc55ff74578 100644 --- a/packages/cli/src/modules/agents/agent-integrations.controller.ts +++ b/packages/cli/src/modules/agents/agent-integrations.controller.ts @@ -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 { - 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 { - 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' }; } diff --git a/packages/cli/src/modules/agents/agent-slack-integrations.controller.ts b/packages/cli/src/modules/agents/agent-slack-integrations.controller.ts new file mode 100644 index 00000000000..7efe1b722b2 --- /dev/null +++ b/packages/cli/src/modules/agents/agent-slack-integrations.controller.ts @@ -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 { + 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 { + 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 }, + }); + } + } +} diff --git a/packages/cli/src/modules/agents/agents.module.ts b/packages/cli/src/modules/agents/agents.module.ts index b94f54364f1..6801b69ca85 100644 --- a/packages/cli/src/modules/agents/agents.module.ts +++ b/packages/cli/src/modules/agents/agents.module.ts @@ -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' ); diff --git a/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts index ed30dc7505b..90191230ca5 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts @@ -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'; diff --git a/packages/cli/src/modules/agents/integrations/__tests__/component-mapper.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/component-mapper.test.ts index 62d315b3dd5..36d40abc209 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/component-mapper.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/component-mapper.test.ts @@ -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', () => { diff --git a/packages/cli/src/modules/agents/integrations/__tests__/helpers/slack/replay-test-context.ts b/packages/cli/src/modules/agents/integrations/__tests__/helpers/slack/replay-test-context.ts index 8b25740fca6..0a4618e3c55 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/helpers/slack/replay-test-context.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/helpers/slack/replay-test-context.ts @@ -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, diff --git a/packages/cli/src/modules/agents/integrations/__tests__/integration-action-executor.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/integration-action-executor.test.ts index 0acee68178a..ad0194304e7 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/integration-action-executor.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/integration-action-executor.test.ts @@ -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'; diff --git a/packages/cli/src/modules/agents/integrations/__tests__/integration-context-query-executor.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/integration-context-query-executor.test.ts index ff10bd9598f..fb94a00ae10 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/integration-context-query-executor.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/integration-context-query-executor.test.ts @@ -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 = { diff --git a/packages/cli/src/modules/agents/integrations/__tests__/slack-app-setup.service.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/slack-app-setup.service.test.ts deleted file mode 100644 index 2a21f425fea..00000000000 --- a/packages/cli/src/modules/agents/integrations/__tests__/slack-app-setup.service.test.ts +++ /dev/null @@ -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) { - 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; - body: Record; - }; - // 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; - let cacheStore: Map; - let cacheService: Mocked; - let cipher: Mocked; - let credentialsService: Mocked; - let userRepository: Mocked; - let agentRepository: Mocked; - let agentIntegrationPersistenceService: Mocked< - Pick - >; - let chatIntegrationService: Mocked; - let service: SlackAppSetupService; - - beforeEach(() => { - const httpClient = mock(); - requestMock = httpClient.request as Mock; - outboundHttp = mock(); - outboundHttp.requests.mockReturnValue(httpClient); - - cacheStore = new Map(); - cacheService = mock(); - 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.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(); - userRepository = mock(); - agentRepository = mock(); - agentRepository.findByIdAndProjectId.mockResolvedValue(agent as never); - agentIntegrationPersistenceService = - mock>(); - chatIntegrationService = mock(); - const urlService = mock(); - 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; - }; - 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(); - }); -}); diff --git a/packages/cli/src/modules/agents/integrations/__tests__/slack-integration.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/slack-integration.test.ts index 873f83423c4..39e3760f072 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/slack-integration.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/slack-integration.test.ts @@ -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.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' }), diff --git a/packages/cli/src/modules/agents/integrations/__tests__/slack-manual-setup.service.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/slack-manual-setup.service.test.ts new file mode 100644 index 00000000000..7b3e18d470a --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/__tests__/slack-manual-setup.service.test.ts @@ -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(); + const userRepository = mock(); + const cacheService = mock(); + const cipher = mock(); + 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(); + }); +}); diff --git a/packages/cli/src/modules/agents/integrations/__tests__/slack-methods.service.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/slack-methods.service.test.ts new file mode 100644 index 00000000000..9ad5e106fea --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/__tests__/slack-methods.service.test.ts @@ -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(); + const managementService = mock(); + const urlService = mock(); + urlService.getWebhookBaseUrl.mockReturnValue('https://hooks.example/'); + return { + service: new SlackMethodsService( + credentialsService, + mock(), + managementService, + urlService, + mock(), + ), + 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'); + }); +}); diff --git a/packages/cli/src/modules/agents/integrations/agent-chat-integration.ts b/packages/cli/src/modules/agents/integrations/agent-chat-integration.ts index b6d13736581..fc030b95cff 100644 --- a/packages/cli/src/modules/agents/integrations/agent-chat-integration.ts +++ b/packages/cli/src/modules/agents/integrations/agent-chat-integration.ts @@ -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; + /** 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; + /** + * 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): Promise; + /** * Optional hook run on EVERY main once the connection is live, regardless * of `skipExternalHooks`. Unlike `onAfterConnect`, this is for local runtime diff --git a/packages/cli/src/modules/agents/integrations/chat-integration.service.ts b/packages/cli/src/modules/agents/integrations/chat-integration.service.ts index d98267482c8..0fa26952d1a 100644 --- a/packages/cli/src/modules/agents/integrations/chat-integration.service.ts +++ b/packages/cli/src/modules/agents/integrations/chat-integration.service.ts @@ -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 { + 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); } diff --git a/packages/cli/src/modules/agents/integrations/integration-action-executor.ts b/packages/cli/src/modules/agents/integrations/integration-action-executor.ts index dc7596a2e4a..f1c57bdac5d 100644 --- a/packages/cli/src/modules/agents/integrations/integration-action-executor.ts +++ b/packages/cli/src/modules/agents/integrations/integration-action-executor.ts @@ -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 { 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>[0], + ): Promise { + await this.integrationRegistry.get(descriptor.integration.type)?.prepareSentThread?.(thread); + } } function supportsMessageEditing(adapter: unknown): adapter is Pick { @@ -447,20 +455,3 @@ function buildReactionTarget( : undefined; return { type: 'thread', threadId, ...(channelId ? { channelId } : {}) }; } - -async function maybeSubscribeSlackThread( - descriptor: IntegrationToolConnectionDescriptor, - thread: { subscribe?: () => Promise }, -): Promise { - if (descriptor.integration.type !== 'slack') return; - await subscribeSlackThread(thread); -} - -async function maybeSubscribeSlackSentThread( - descriptor: IntegrationToolConnectionDescriptor, - chat: ChatInstance, - threadId: string | undefined, -): Promise { - if (descriptor.integration.type !== 'slack' || !threadId) return; - await subscribeSlackThread(chat.thread(threadId)); -} diff --git a/packages/cli/src/modules/agents/integrations/platforms/__tests__/slack/synthetic-integration.test.ts b/packages/cli/src/modules/agents/integrations/platforms/__tests__/slack/synthetic-integration.test.ts index 25dde78b3a0..3e8473bbc43 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/__tests__/slack/synthetic-integration.test.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/__tests__/slack/synthetic-integration.test.ts @@ -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', () => { diff --git a/packages/cli/src/modules/agents/integrations/platforms/__tests__/telegram/integration.test.ts b/packages/cli/src/modules/agents/integrations/platforms/__tests__/telegram/integration.test.ts index c8adf279f26..f96b597738c 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/__tests__/telegram/integration.test.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/__tests__/telegram/integration.test.ts @@ -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; let integration: TelegramIntegration; diff --git a/packages/cli/src/modules/agents/integrations/platforms/slack-bridge-behavior.ts b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-bridge-behavior.ts similarity index 98% rename from packages/cli/src/modules/agents/integrations/platforms/slack-bridge-behavior.ts rename to packages/cli/src/modules/agents/integrations/platforms/slack/slack-bridge-behavior.ts index f746206b99c..c50a8e093f9 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/slack-bridge-behavior.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-bridge-behavior.ts @@ -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; diff --git a/packages/cli/src/modules/agents/integrations/platforms/slack-integration.ts b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-integration.ts similarity index 83% rename from packages/cli/src/modules/agents/integrations/platforms/slack-integration.ts rename to packages/cli/src/modules/agents/integrations/platforms/slack/slack-integration.ts index ee12c7823be..538253ffdff 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/slack-integration.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-integration.ts @@ -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 { + 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): Promise { + await subscribeSlackThread(thread); + } + getPlatformAgentContext(chat: ChatInstance): PlatformAgentContext { return getSlackPlatformAgentContext(chat); } diff --git a/packages/cli/src/modules/agents/integrations/platforms/slack/slack-manual-setup.service.ts b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-manual-setup.service.ts new file mode 100644 index 00000000000..524dc8ff832 --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-manual-setup.service.ts @@ -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 = [ + '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 { + 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 { + 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 { + 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 { + const cached = await this.cacheService.take(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(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}`; + } +} diff --git a/packages/cli/src/modules/agents/integrations/platforms/slack/slack-methods.service.ts b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-methods.service.ts new file mode 100644 index 00000000000..fb9b58eede9 --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-methods.service.ts @@ -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, + headers: Record = {}, + ): Promise> { + 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): BadRequestError { + const error = this.stringProperty(response, 'error') ?? 'unknown_error'; + return new BadRequestError(`Slack could not ${action}: ${error}`); + } + + async getAgent(agentId: string, projectId: string): Promise { + 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 { + 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, key: string): Record | undefined { + const child = record[key]; + return isRecord(child) ? child : undefined; + } + + stringProperty(record: Record | 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; + } +} diff --git a/packages/cli/src/modules/agents/integrations/platforms/slack-operations.ts b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-operations.ts similarity index 97% rename from packages/cli/src/modules/agents/integrations/platforms/slack-operations.ts rename to packages/cli/src/modules/agents/integrations/platforms/slack/slack-operations.ts index ab9746a7cae..82529ca0e30 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/slack-operations.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-operations.ts @@ -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'; diff --git a/packages/cli/src/modules/agents/integrations/platforms/telegram-integration.ts b/packages/cli/src/modules/agents/integrations/platforms/telegram-integration.ts index ab8702bf68a..bdfbdea29c8 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/telegram-integration.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/telegram-integration.ts @@ -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 { - 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 { - return createTelegramResumeExecutionContext(params); + return await Promise.resolve(createTelegramResumeExecutionContext(params)); } normalizeComponents(components: SuspendComponent[]): SuspendComponent[] { diff --git a/packages/cli/src/modules/agents/integrations/slack-app-setup.service.ts b/packages/cli/src/modules/agents/integrations/slack-app-setup.service.ts deleted file mode 100644 index 40511e44b3e..00000000000 --- a/packages/cli/src/modules/agents/integrations/slack-app-setup.service.ts +++ /dev/null @@ -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, - key: string, -): Record | undefined { - const child = record[key]; - return isRecord(child) ? child : undefined; -} - -function stringProperty( - record: Record | 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 = [ - '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 { - 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 { - const agent = await this.getAgent(options.agentId, options.projectId); - return { - manifest: this.buildManifest(agent.name, options.projectId, options.agentId), - }; - } - - async completeInstall(options: CompleteSlackAppInstallOptions): Promise { - 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 { - 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 { - const key = this.cacheKey(state); - const cached = await this.cacheService.get(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(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, - headers: Record = {}, - ): Promise> { - 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): BadRequestError { - const error = stringProperty(response, 'error') ?? 'unknown_error'; - return new BadRequestError(`Slack could not ${action}: ${error}`); - } -} diff --git a/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts b/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts index 3b7e53393a0..3fc4e671c02 100644 --- a/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/agent-tools.service.test.ts @@ -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' }), + ); }); }); diff --git a/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts b/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts index 522adb412d5..034034342f6 100644 --- a/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts +++ b/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts @@ -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, diff --git a/packages/cli/src/services/cache/__tests__/cache.service.test.ts b/packages/cli/src/services/cache/__tests__/cache.service.test.ts index 31119daaf55..00a60534a6c 100644 --- a/packages/cli/src/services/cache/__tests__/cache.service.test.ts +++ b/packages/cli/src/services/cache/__tests__/cache.service.test.ts @@ -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'); diff --git a/packages/cli/src/services/cache/cache.service.ts b/packages/cli/src/services/cache/cache.service.ts index 780e8acf2e6..5fa772b1e89 100644 --- a/packages/cli/src/services/cache/cache.service.ts +++ b/packages/cli/src/services/cache/cache.service.ts @@ -23,6 +23,8 @@ type CacheEvents = { @Service() export class CacheService extends TypedEmitter { + private readonly takingKeys = new Set(); + constructor(private readonly globalConfig: GlobalConfig) { super(); } @@ -210,6 +212,26 @@ export class CacheService extends TypedEmitter { return fallbackValue; } + /** Atomically retrieve and delete a primitive value. */ + async take(key: string): Promise { + if (!this.cache) await this.init(); + if (!key) return undefined; + + if (this.cache.kind === 'redis') { + return await this.cache.store.getdel(key); + } + + if (this.takingKeys.has(key)) return undefined; + this.takingKeys.add(key); + try { + const value = await this.cache.store.get(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 diff --git a/packages/cli/src/services/cache/redis.cache-manager.ts b/packages/cli/src/services/cache/redis.cache-manager.ts index f3a9e0b6b33..c106f77538a 100644 --- a/packages/cli/src/services/cache/redis.cache-manager.ts +++ b/packages/cli/src/services/cache/redis.cache-manager.ts @@ -32,6 +32,7 @@ export type RedisCache = Cache; export interface RedisStore extends Store { readonly isCacheable: (value: unknown) => boolean; get client(): Redis | Cluster; + getdel(key: string): Promise; hget(key: string, field: string): Promise; hgetall(key: string): Promise | undefined>; hset(key: string, fieldValueRecord: Record): Promise; @@ -57,6 +58,11 @@ function builder( if (val === undefined || val === null) return undefined; else return jsonParse(val); }, + async getdel(key: string) { + const val = await redisCache.getdel(key); + if (val === undefined || val === null) return undefined; + return jsonParse(val); + }, async expire(key: string, ttlSeconds: number) { await redisCache.expire(key, ttlSeconds); }, diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index c6eb56693ad..ddde237107b 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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", diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelListItem.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelListItem.test.ts index 6bfad52c05a..27603f450da 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelListItem.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelListItem.test.ts @@ -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: '' }, @@ -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: '', + }, + N8nIcon: { template: '' }, + N8nText: { template: '' }, + }, + }, + }); + + expect(wrapper.get('button').text()).toContain('Add to Slack'); + expect(wrapper.get('button').attributes('data-icon')).toBe('plus'); + }); }); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelModal.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelModal.test.ts index 00549c71c80..5d191af23d1 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelModal.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelModal.test.ts @@ -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>({}); const connectedCredentials = ref>({}); const selectedCredentials = ref>({}); const loadingMap = ref>({}); -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: ` +
+
+ `, + }; + 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: `
-
`, -}); - -function mountModal(props: Record) { +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: - '
', + '
', }, DialogHeader: { template: '
' }, DialogTitle: { template: '

' }, DialogFooter: { template: '
' }, N8nButton: { + props: ['disabled'], emits: ['click'], - template: '', - }, - N8nIconButton: { - emits: ['click'], - template: '', + template: '', }, + N8nIconButton: { template: '' }, N8nIcon: { template: '' }, N8nText: { template: '' }, AgentChannelListItem: { - props: ['configured', 'connected'], - template: - '
  • ', - }, - 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: ` -
    -
    +
  • `, }, }, @@ -182,359 +158,88 @@ function mountModal(props: Record) { 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((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((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((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((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], + ); }); }); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelSlackSetup.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelSlackSetup.test.ts index 365477321b9..954ba184554 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelSlackSetup.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelSlackSetup.test.ts @@ -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: {} } }), })); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSlackSettingsForm.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSlackSettingsForm.test.ts deleted file mode 100644 index 912525d5d84..00000000000 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSlackSettingsForm.test.ts +++ /dev/null @@ -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: '' }, - N8nIcon: { template: '' }, - N8nText: { template: '' }, - N8nInput: { template: '' }, - N8nCollapsiblePanel: { template: '
    ' }, - }, - }, - }); - 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', - ]), - ); - }); -}); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentChannelSetup.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentChannelSetup.test.ts index d4cac466a8e..4ecdf3a38f7 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentChannelSetup.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentChannelSetup.test.ts @@ -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((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([]); }); }); diff --git a/packages/frontend/editor-ui/src/features/agents/channels/AgentChannelStandardEditView.vue b/packages/frontend/editor-ui/src/features/agents/channels/AgentChannelStandardEditView.vue new file mode 100644 index 00000000000..3f3b4f9d0b9 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/channels/AgentChannelStandardEditView.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/agents/channels/discord/AgentChannelDiscordEditView.vue b/packages/frontend/editor-ui/src/features/agents/channels/discord/AgentChannelDiscordEditView.vue new file mode 100644 index 00000000000..20627604f99 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/channels/discord/AgentChannelDiscordEditView.vue @@ -0,0 +1,29 @@ + + + diff --git a/packages/frontend/editor-ui/src/features/agents/channels/fallback/AgentChannelFallbackView.vue b/packages/frontend/editor-ui/src/features/agents/channels/fallback/AgentChannelFallbackView.vue new file mode 100644 index 00000000000..476c80ded25 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/channels/fallback/AgentChannelFallbackView.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/agents/channels/linear/AgentChannelLinearEditView.vue b/packages/frontend/editor-ui/src/features/agents/channels/linear/AgentChannelLinearEditView.vue new file mode 100644 index 00000000000..ad157829a5d --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/channels/linear/AgentChannelLinearEditView.vue @@ -0,0 +1,30 @@ + + + diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentChannelLinearSetup.vue b/packages/frontend/editor-ui/src/features/agents/channels/linear/AgentChannelLinearSetup.vue similarity index 93% rename from packages/frontend/editor-ui/src/features/agents/components/AgentChannelLinearSetup.vue rename to packages/frontend/editor-ui/src/features/agents/channels/linear/AgentChannelLinearSetup.vue index a7494cb2102..a032976a7a2 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentChannelLinearSetup.vue +++ b/packages/frontend/editor-ui/src/features/agents/channels/linear/AgentChannelLinearSetup.vue @@ -1,14 +1,13 @@ + + diff --git a/packages/frontend/editor-ui/src/features/agents/channels/slack/AgentChannelSlackSetupView.vue b/packages/frontend/editor-ui/src/features/agents/channels/slack/AgentChannelSlackSetupView.vue new file mode 100644 index 00000000000..b6f8f01c2cc --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/channels/slack/AgentChannelSlackSetupView.vue @@ -0,0 +1,54 @@ + + + diff --git a/packages/frontend/editor-ui/src/features/agents/channels/slack/api.ts b/packages/frontend/editor-ui/src/features/agents/channels/slack/api.ts new file mode 100644 index 00000000000..84e90c8f7d8 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/channels/slack/api.ts @@ -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 => + await makeRestApiRequest(context, 'POST', `${integrationPath(projectId, agentId)}/app`, { + appConfigurationToken, + }); + +export const getSlackAgentAppManifest = async ( + context: IRestApiContext, + projectId: string, + agentId: string, +): Promise => + await makeRestApiRequest(context, 'GET', `${integrationPath(projectId, agentId)}/manifest`); diff --git a/packages/frontend/editor-ui/src/features/agents/channels/slack/useSlackChannelRuntime.test.ts b/packages/frontend/editor-ui/src/features/agents/channels/slack/useSlackChannelRuntime.test.ts new file mode 100644 index 00000000000..a73970dc91f --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/channels/slack/useSlackChannelRuntime.test.ts @@ -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((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(); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/channels/slack/useSlackChannelRuntime.ts b/packages/frontend/editor-ui/src/features/agents/channels/slack/useSlackChannelRuntime.ts new file mode 100644 index 00000000000..c66e67b51b1 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/channels/slack/useSlackChannelRuntime.ts @@ -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, + ) => Promise; +} + +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 { + return await new Promise((resolve) => { + const oauthChannel = new BroadcastChannel('oauth-callback'); + let activePoll: Promise | 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, + ): Promise { + 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, + }; +} diff --git a/packages/frontend/editor-ui/src/features/agents/channels/telegram/AgentChannelTelegramEditView.vue b/packages/frontend/editor-ui/src/features/agents/channels/telegram/AgentChannelTelegramEditView.vue new file mode 100644 index 00000000000..ece95daea31 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/channels/telegram/AgentChannelTelegramEditView.vue @@ -0,0 +1,30 @@ + + + diff --git a/packages/frontend/editor-ui/src/features/agents/channels/telegram/AgentChannelTelegramSetup.test.ts b/packages/frontend/editor-ui/src/features/agents/channels/telegram/AgentChannelTelegramSetup.test.ts new file mode 100644 index 00000000000..1284e9e2624 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/channels/telegram/AgentChannelTelegramSetup.test.ts @@ -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: `
    `, + }, +})); + +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(); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentChannelTelegramSetup.vue b/packages/frontend/editor-ui/src/features/agents/channels/telegram/AgentChannelTelegramSetup.vue similarity index 76% rename from packages/frontend/editor-ui/src/features/agents/components/AgentChannelTelegramSetup.vue rename to packages/frontend/editor-ui/src/features/agents/channels/telegram/AgentChannelTelegramSetup.vue index 5dd43a00b1f..d78a673a754 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentChannelTelegramSetup.vue +++ b/packages/frontend/editor-ui/src/features/agents/channels/telegram/AgentChannelTelegramSetup.vue @@ -1,12 +1,17 @@ @@ -98,16 +105,11 @@ defineExpose({ credentialId, currentSettings, validationError }); @edit="emit('edit')" /> -
    {{ i18n.baseText('agents.builder.addTrigger.connect') }} + + {{ errorMessage }} + + {{ i18n.baseText('agents.builder.addTrigger.editCredential') }} + +
    @@ -139,19 +157,14 @@ defineExpose({ credentialId, currentSettings, validationError }); @edit="emit('edit')" /> {{ connectedDescription }} - - + {{ errorMessage }} Promise; +} + +export interface AgentChannelRuntimeContext { + projectId: Ref; + agentId: Ref; + selectedCredentialId: Ref; + credentialModalOpen: Readonly>; + fetchStatus: (integrationTypes: string[]) => Promise; + isConnected: (integrationType: string) => boolean; + isConfigured: (integrationType: string) => boolean; + ensureAgentPersisted?: () => Promise; +} + +export interface AgentChannelRuntime { + load: () => Promise; + loading: Readonly>; +} + +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; +} diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentChannelListItem.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentChannelListItem.vue index 4140c19d8f2..94f38d9e254 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentChannelListItem.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentChannelListItem.vue @@ -1,10 +1,18 @@ - - diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentSlackSettingsForm.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentSlackSettingsForm.vue deleted file mode 100644 index 7f38b1641f9..00000000000 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentSlackSettingsForm.vue +++ /dev/null @@ -1,313 +0,0 @@ - - - - - diff --git a/packages/frontend/editor-ui/src/features/agents/composables/useAgentApi.ts b/packages/frontend/editor-ui/src/features/agents/composables/useAgentApi.ts index 919f6ee48a5..0e862909b11 100644 --- a/packages/frontend/editor-ui/src/features/agents/composables/useAgentApi.ts +++ b/packages/frontend/editor-ui/src/features/agents/composables/useAgentApi.ts @@ -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 => { - return await makeRestApiRequest( - context, - 'POST', - `/projects/${projectId}/agents/v2/${agentId}/integrations/slack/app`, - { appConfigurationToken }, - ); -}; - -export const getSlackAgentAppManifest = async ( - context: IRestApiContext, - projectId: string, - agentId: string, -): Promise => { - return await makeRestApiRequest( - context, - 'GET', - `/projects/${projectId}/agents/v2/${agentId}/integrations/slack/manifest`, - ); -}; - export type ModelInfo = AgentCatalogModel; export interface ProviderInfo { diff --git a/packages/frontend/editor-ui/src/features/agents/composables/useAgentChannelSetup.ts b/packages/frontend/editor-ui/src/features/agents/composables/useAgentChannelSetup.ts index 7c9ed3486b7..d16a9c4e5cb 100644 --- a/packages/frontend/editor-ui/src/features/agents/composables/useAgentChannelSetup.ts +++ b/packages/frontend/editor-ui/src/features/agents/composables/useAgentChannelSetup.ts @@ -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; - agentId: MaybeRefOrGetter; currentIntegration: MaybeRefOrGetter; connectedCredentials: MaybeRefOrGetter>; fetchStatus: (integrationTypes: string[]) => Promise; - 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(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 { - return await new Promise((resolve) => { - const oauthChannel = new BroadcastChannel('oauth-callback'); - let activePoll: Promise | 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, - ): Promise { - 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, }; } diff --git a/packages/frontend/editor-ui/src/features/ai/shared/components/ChannelSetupCard.test.ts b/packages/frontend/editor-ui/src/features/ai/shared/components/ChannelSetupCard.test.ts index fa3a602e249..1817c3a16bb 100644 --- a/packages/frontend/editor-ui/src/features/ai/shared/components/ChannelSetupCard.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/shared/components/ChannelSetupCard.test.ts @@ -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([]); diff --git a/packages/frontend/editor-ui/src/features/ai/shared/components/ChannelSetupCard.vue b/packages/frontend/editor-ui/src/features/ai/shared/components/ChannelSetupCard.vue index 5e10768d4e3..5dab1126896 100644 --- a/packages/frontend/editor-ui/src/features/ai/shared/components/ChannelSetupCard.vue +++ b/packages/frontend/editor-ui/src/features/ai/shared/components/ChannelSetupCard.vue @@ -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(null); const catalogLoading = ref(false); const catalogLoadFailed = ref(false); -const currentIntegration = computed(() => { - return catalog.value?.find((integration) => integration.type === props.integrationType) ?? null; +const currentIntegration = computed(() => { + 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 = 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(); +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 { - 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(
    @@ -261,96 +295,35 @@ watch(
    - - - - - - - @@ -358,7 +331,7 @@ watch(