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