mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2026-07-15 17:37:21 +02:00
feat: Respond to chat and wait for response (#12546)
Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in> Co-authored-by: Shireen Missi <94372015+ShireenMissi@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
import { ExecutionRepository } from '@n8n/db';
|
||||
import type { IExecutionResponse } from '@n8n/db';
|
||||
|
||||
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
|
||||
import { WorkflowRunner } from '@/workflow-runner';
|
||||
import { mockInstance } from '@n8n/backend-test-utils';
|
||||
|
||||
import { NodeTypes } from '../../node-types';
|
||||
import { OwnershipService } from '../../services/ownership.service';
|
||||
import { ChatExecutionManager } from '../chat-execution-manager';
|
||||
import type { ChatMessage } from '../chat-service.types';
|
||||
|
||||
describe('ChatExecutionManager', () => {
|
||||
const executionRepository = mockInstance(ExecutionRepository);
|
||||
const workflowRunner = mockInstance(WorkflowRunner);
|
||||
const ownershipService = mockInstance(OwnershipService);
|
||||
const nodeTypes = mockInstance(NodeTypes);
|
||||
const chatExecutionManager = new ChatExecutionManager(
|
||||
executionRepository,
|
||||
workflowRunner,
|
||||
ownershipService,
|
||||
nodeTypes,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should handle errors from getRunData gracefully', async () => {
|
||||
const execution = { id: '1', workflowData: {}, data: {} } as IExecutionResponse;
|
||||
const message = { sessionId: '123', action: 'sendMessage', chatInput: 'input' } as ChatMessage;
|
||||
|
||||
jest
|
||||
.spyOn(chatExecutionManager as any, 'getRunData')
|
||||
.mockRejectedValue(new Error('Test error'));
|
||||
|
||||
await expect(chatExecutionManager.runWorkflow(execution, message)).rejects.toThrow(
|
||||
'Test error',
|
||||
);
|
||||
});
|
||||
|
||||
describe('runWorkflow', () => {
|
||||
it('should call WorkflowRunner.run with correct parameters', async () => {
|
||||
const execution = { id: '1', workflowData: {}, data: {} } as IExecutionResponse;
|
||||
const message = {
|
||||
sessionId: '123',
|
||||
action: 'sendMessage',
|
||||
chatInput: 'input',
|
||||
} as ChatMessage;
|
||||
const runData = { executionMode: 'manual', executionData: {}, workflowData: {} } as any;
|
||||
|
||||
jest.spyOn(chatExecutionManager as any, 'getRunData').mockResolvedValue(runData);
|
||||
|
||||
await chatExecutionManager.runWorkflow(execution, message);
|
||||
|
||||
expect(workflowRunner.run).toHaveBeenCalledWith(runData, true, true, '1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelExecution', () => {
|
||||
it('should update execution status to canceled if it is running', async () => {
|
||||
const executionId = '1';
|
||||
const execution = { id: executionId, status: 'running' } as any;
|
||||
|
||||
executionRepository.findSingleExecution.mockResolvedValue(execution);
|
||||
|
||||
await chatExecutionManager.cancelExecution(executionId);
|
||||
|
||||
expect(executionRepository.update).toHaveBeenCalledWith(
|
||||
{ id: executionId },
|
||||
{ status: 'canceled' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should update execution status to canceled if it is waiting', async () => {
|
||||
const executionId = '2';
|
||||
const execution = { id: executionId, status: 'waiting' } as any;
|
||||
|
||||
executionRepository.findSingleExecution.mockResolvedValue(execution);
|
||||
|
||||
await chatExecutionManager.cancelExecution(executionId);
|
||||
|
||||
expect(executionRepository.update).toHaveBeenCalledWith(
|
||||
{ id: executionId },
|
||||
{ status: 'canceled' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should update execution status to canceled if it is unknown', async () => {
|
||||
const executionId = '3';
|
||||
const execution = { id: executionId, status: 'unknown' } as any;
|
||||
|
||||
executionRepository.findSingleExecution.mockResolvedValue(execution);
|
||||
|
||||
await chatExecutionManager.cancelExecution(executionId);
|
||||
|
||||
expect(executionRepository.update).toHaveBeenCalledWith(
|
||||
{ id: executionId },
|
||||
{ status: 'canceled' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should not update execution status if it is not running', async () => {
|
||||
const executionId = '1';
|
||||
const execution = { id: executionId, status: 'completed' } as any;
|
||||
|
||||
executionRepository.findSingleExecution.mockResolvedValue(execution);
|
||||
|
||||
await chatExecutionManager.cancelExecution(executionId);
|
||||
|
||||
expect(executionRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findExecution', () => {
|
||||
it('should return undefined if execution does not exist', async () => {
|
||||
const executionId = 'non-existent';
|
||||
|
||||
executionRepository.findSingleExecution.mockResolvedValue(undefined);
|
||||
|
||||
const result = await chatExecutionManager.findExecution(executionId);
|
||||
|
||||
expect(result).toBeUndefined;
|
||||
});
|
||||
|
||||
it('should return execution data', async () => {
|
||||
const executionId = '1';
|
||||
const execution = { id: executionId } as any;
|
||||
|
||||
executionRepository.findSingleExecution.mockResolvedValue(execution);
|
||||
|
||||
const result = await chatExecutionManager.findExecution(executionId);
|
||||
|
||||
expect(result).toEqual(execution);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRunData', () => {
|
||||
it('should call runNode with correct parameters and return runData', async () => {
|
||||
const execution = {
|
||||
id: '1',
|
||||
workflowData: { id: 'workflowId' },
|
||||
data: {
|
||||
resultData: { pinData: {} },
|
||||
executionData: { nodeExecutionStack: [{ data: { main: [[]] } }] },
|
||||
pushRef: 'pushRef',
|
||||
},
|
||||
mode: 'manual',
|
||||
} as any;
|
||||
const message = {
|
||||
sessionId: '123',
|
||||
action: 'sendMessage',
|
||||
chatInput: 'input',
|
||||
} as ChatMessage;
|
||||
const project = { id: 'projectId' };
|
||||
const nodeExecutionData = [[{ json: message }]];
|
||||
|
||||
const getRunDataSpy = jest
|
||||
.spyOn(chatExecutionManager as any, 'runNode')
|
||||
.mockResolvedValue(nodeExecutionData);
|
||||
const getWorkflowProjectCachedSpy = jest
|
||||
.spyOn(ownershipService, 'getWorkflowProjectCached')
|
||||
.mockResolvedValue(project as any);
|
||||
|
||||
const runData = await (chatExecutionManager as any).getRunData(execution, message);
|
||||
|
||||
expect(getRunDataSpy).toHaveBeenCalledWith(execution, message);
|
||||
expect(getWorkflowProjectCachedSpy).toHaveBeenCalledWith('workflowId');
|
||||
expect(runData).toEqual({
|
||||
executionMode: 'manual',
|
||||
executionData: execution.data,
|
||||
pushRef: execution.data.pushRef,
|
||||
workflowData: execution.workflowData,
|
||||
pinData: execution.data.resultData.pinData,
|
||||
projectId: 'projectId',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('runNode', () => {
|
||||
it('should return null if node is not found', async () => {
|
||||
const execution = {
|
||||
id: '1',
|
||||
workflowData: { id: 'workflowId' },
|
||||
data: {
|
||||
resultData: { lastNodeExecuted: 'nodeId' },
|
||||
executionData: { nodeExecutionStack: [{ data: { main: [[]] } }] },
|
||||
},
|
||||
mode: 'manual',
|
||||
} as any;
|
||||
const message = {
|
||||
sessionId: '123',
|
||||
action: 'sendMessage',
|
||||
chatInput: 'input',
|
||||
} as ChatMessage;
|
||||
|
||||
jest.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue({} as any);
|
||||
const workflow = { getNode: jest.fn().mockReturnValue(null) };
|
||||
jest.spyOn(chatExecutionManager as any, 'getWorkflow').mockReturnValue(workflow);
|
||||
|
||||
const result = await (chatExecutionManager as any).runNode(execution, message);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null if executionData is undefined', async () => {
|
||||
const execution = {
|
||||
id: '1',
|
||||
workflowData: { id: 'workflowId' },
|
||||
data: {
|
||||
resultData: { lastNodeExecuted: 'nodeId' },
|
||||
executionData: { nodeExecutionStack: [] },
|
||||
},
|
||||
mode: 'manual',
|
||||
} as any;
|
||||
const message = {
|
||||
sessionId: '123',
|
||||
action: 'sendMessage',
|
||||
chatInput: 'input',
|
||||
} as ChatMessage;
|
||||
|
||||
jest.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue({} as any);
|
||||
const workflow = { getNode: jest.fn().mockReturnValue({}) };
|
||||
jest.spyOn(chatExecutionManager as any, 'getWorkflow').mockReturnValue(workflow);
|
||||
|
||||
const result = await (chatExecutionManager as any).runNode(execution, message);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should call nodeType.onMessage with correct parameters and return the result', async () => {
|
||||
const execution = {
|
||||
id: '1',
|
||||
workflowData: { id: 'workflowId' },
|
||||
data: {
|
||||
resultData: { lastNodeExecuted: 'nodeId' },
|
||||
executionData: { nodeExecutionStack: [{ data: { main: [[{}]] } }] },
|
||||
},
|
||||
mode: 'manual',
|
||||
} as any;
|
||||
const message = {
|
||||
sessionId: '123',
|
||||
action: 'sendMessage',
|
||||
chatInput: 'input',
|
||||
files: [],
|
||||
} as ChatMessage;
|
||||
const node = { type: 'testType', typeVersion: 1 };
|
||||
const nodeType = { onMessage: jest.fn().mockResolvedValue([[{ json: message }]]) };
|
||||
const workflow = {
|
||||
getNode: jest.fn().mockReturnValue(node),
|
||||
nodeTypes: { getByNameAndVersion: jest.fn().mockReturnValue(nodeType) },
|
||||
};
|
||||
jest.spyOn(chatExecutionManager as any, 'getWorkflow').mockReturnValue(workflow);
|
||||
jest.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue({} as any);
|
||||
|
||||
const result = await (chatExecutionManager as any).runNode(execution, message);
|
||||
|
||||
expect(workflow.nodeTypes.getByNameAndVersion).toHaveBeenCalledWith('testType', 1);
|
||||
expect(nodeType.onMessage).toHaveBeenCalled();
|
||||
expect(result).toEqual([[{ json: message }]]);
|
||||
});
|
||||
|
||||
it('should return nodeExecutionData with sessionId, action and chatInput', async () => {
|
||||
const execution = {
|
||||
id: '1',
|
||||
workflowData: { id: 'workflowId' },
|
||||
data: {
|
||||
resultData: { lastNodeExecuted: 'nodeId' },
|
||||
executionData: { nodeExecutionStack: [{ data: { main: [[{}]] } }] },
|
||||
},
|
||||
mode: 'manual',
|
||||
} as any;
|
||||
const message = {
|
||||
sessionId: '123',
|
||||
action: 'sendMessage',
|
||||
chatInput: 'input',
|
||||
} as ChatMessage;
|
||||
const node = { type: 'testType', typeVersion: 1 };
|
||||
const nodeType = { onMessage: jest.fn().mockResolvedValue([[{ json: message }]]) };
|
||||
const workflow = {
|
||||
getNode: jest.fn().mockReturnValue(node),
|
||||
nodeTypes: { getByNameAndVersion: jest.fn().mockReturnValue(nodeType) },
|
||||
};
|
||||
jest.spyOn(chatExecutionManager as any, 'getWorkflow').mockReturnValue(workflow);
|
||||
jest.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue({} as any);
|
||||
|
||||
const result = await (chatExecutionManager as any).runNode(execution, message);
|
||||
|
||||
expect(result).toEqual([[{ json: message }]]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { Application } from 'express';
|
||||
import { ServerResponse } from 'http';
|
||||
import type { Server as HttpServer } from 'http';
|
||||
import type { IncomingMessage } from 'http';
|
||||
import { mock, mockReset } from 'jest-mock-extended';
|
||||
import type { WebSocket } from 'ws';
|
||||
import type { WebSocketServer } from 'ws';
|
||||
|
||||
import { ChatServer } from '../chat-server';
|
||||
import type { ChatService } from '../chat-service';
|
||||
import type { ChatRequest } from '../chat-service.types';
|
||||
|
||||
jest.mock('ws');
|
||||
|
||||
describe('ChatServer', () => {
|
||||
const mockChatService = mock<ChatService>();
|
||||
const mockWsServer = mock<WebSocketServer>();
|
||||
const mockApp = mock<Application>() as unknown as Application & {
|
||||
handle: (req: IncomingMessage, res: ServerResponse) => void;
|
||||
};
|
||||
mockApp.handle = jest.fn();
|
||||
const mockHttpServer = mock<HttpServer>();
|
||||
|
||||
let chatServer: ChatServer;
|
||||
|
||||
beforeEach(() => {
|
||||
mockReset(mockChatService);
|
||||
mockReset(mockWsServer);
|
||||
mockReset(mockApp);
|
||||
mockReset(mockHttpServer);
|
||||
|
||||
chatServer = new ChatServer(mockChatService);
|
||||
|
||||
(chatServer as any).wsServer = mockWsServer;
|
||||
});
|
||||
|
||||
it('attaches upgrade listener to HTTP server', () => {
|
||||
chatServer.setup(mockHttpServer, mockApp);
|
||||
expect(mockHttpServer.on).toHaveBeenCalledWith('upgrade', expect.any(Function));
|
||||
});
|
||||
|
||||
it('handles WebSocket upgrade for /chat path', () => {
|
||||
chatServer.setup(mockHttpServer, mockApp);
|
||||
|
||||
const req = {
|
||||
url: 'http://localhost:5678/chat?sessionId=123&executionId=456',
|
||||
socket: { remoteAddress: '127.0.0.1' },
|
||||
} as ChatRequest;
|
||||
const socket = {} as any;
|
||||
const head = {} as any;
|
||||
|
||||
const upgradeHandler = mockHttpServer.on.mock.calls[0][1];
|
||||
upgradeHandler(req, socket, head);
|
||||
|
||||
expect(mockWsServer.handleUpgrade).toHaveBeenCalledWith(
|
||||
req,
|
||||
socket,
|
||||
head,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('calls attachToApp after WebSocket upgrade', () => {
|
||||
chatServer.setup(mockHttpServer, mockApp);
|
||||
|
||||
const req = {
|
||||
url: 'http://localhost:5678/chat?sessionId=123&executionId=456',
|
||||
socket: { remoteAddress: '127.0.0.1' },
|
||||
} as ChatRequest;
|
||||
const socket = {} as any;
|
||||
const head = {} as any;
|
||||
const ws = {} as WebSocket;
|
||||
|
||||
const upgradeHandler = mockHttpServer.on.mock.calls[0][1];
|
||||
upgradeHandler(req, socket, head);
|
||||
|
||||
const handleUpgradeCb = mockWsServer.handleUpgrade.mock.calls[0][3];
|
||||
handleUpgradeCb(ws, req);
|
||||
|
||||
expect(req.ws).toBe(ws);
|
||||
expect(mockApp.handle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ ws }),
|
||||
expect.any(ServerResponse),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,399 @@
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
import type { ChatExecutionManager } from '../chat-execution-manager';
|
||||
import { ChatService } from '../chat-service';
|
||||
import type { ChatRequest } from '../chat-service.types';
|
||||
import type { ErrorReporter } from 'n8n-core';
|
||||
|
||||
describe('ChatService', () => {
|
||||
let mockExecutionManager: ReturnType<typeof mock<ChatExecutionManager>>;
|
||||
let mockLogger: ReturnType<typeof mock<Logger>>;
|
||||
let mockErrorReporter: ReturnType<typeof mock<ErrorReporter>>;
|
||||
let chatService: ChatService;
|
||||
let mockWs: ReturnType<typeof mock<WebSocket>>;
|
||||
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockExecutionManager = mock<ChatExecutionManager>();
|
||||
mockLogger = mock<Logger>();
|
||||
mockErrorReporter = mock<ErrorReporter>();
|
||||
chatService = new ChatService(mockExecutionManager, mockLogger, mockErrorReporter);
|
||||
mockWs = mock<WebSocket>();
|
||||
});
|
||||
|
||||
it('should handle missing execution gracefully', async () => {
|
||||
const req = {
|
||||
ws: mockWs,
|
||||
query: {
|
||||
sessionId: '123',
|
||||
executionId: '42',
|
||||
isPublic: false,
|
||||
},
|
||||
} as unknown as ChatRequest;
|
||||
|
||||
mockExecutionManager.findExecution.mockResolvedValue(undefined);
|
||||
|
||||
try {
|
||||
await chatService.startSession(req);
|
||||
} catch (error) {
|
||||
expect(error).toBeDefined();
|
||||
expect(mockWs.send).toHaveBeenCalledWith('Execution with id "42" does not exist');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle missing WebSocket connection gracefully', async () => {
|
||||
const req = {
|
||||
ws: null,
|
||||
query: {
|
||||
sessionId: 'abc',
|
||||
executionId: '123',
|
||||
isPublic: false,
|
||||
},
|
||||
} as unknown as ChatRequest;
|
||||
|
||||
await expect(chatService.startSession(req)).rejects.toThrow('WebSocket connection is missing');
|
||||
});
|
||||
|
||||
describe('startSession', () => {
|
||||
it('should start a session and store it in sessions map', async () => {
|
||||
const mockWs = mock<WebSocket>();
|
||||
|
||||
(mockWs as any).readyState = WebSocket.OPEN;
|
||||
|
||||
const req = {
|
||||
ws: mockWs,
|
||||
query: {
|
||||
sessionId: 'abc',
|
||||
executionId: '123',
|
||||
isPublic: true,
|
||||
},
|
||||
} as unknown as ChatRequest;
|
||||
|
||||
mockExecutionManager.checkIfExecutionExists.mockResolvedValue({ id: '123' } as any);
|
||||
|
||||
await chatService.startSession(req);
|
||||
|
||||
const sessionKey = 'abc|123|public';
|
||||
const session = (chatService as any).sessions.get(sessionKey);
|
||||
|
||||
expect(session).toBeDefined();
|
||||
expect(session?.sessionId).toBe('abc');
|
||||
expect(session?.executionId).toBe('123');
|
||||
expect(session?.isPublic).toBe(true);
|
||||
expect(typeof session?.intervalId).toBe('object');
|
||||
});
|
||||
|
||||
it('should terminate existing session if the same key is used and clear interval', async () => {
|
||||
const clearIntervalSpy = jest.spyOn(global, 'clearInterval').mockImplementation();
|
||||
const req = {
|
||||
ws: mockWs,
|
||||
query: {
|
||||
sessionId: 'abc',
|
||||
executionId: '123',
|
||||
isPublic: false,
|
||||
},
|
||||
} as unknown as ChatRequest;
|
||||
|
||||
const previousConnection = mock<WebSocket>();
|
||||
|
||||
(previousConnection as any).readyState = WebSocket.OPEN;
|
||||
const dummyInterval = setInterval(() => {}, 9999);
|
||||
const sessionKey = 'abc|123|integrated';
|
||||
|
||||
(chatService as any).sessions.set(sessionKey, {
|
||||
connection: previousConnection,
|
||||
executionId: '123',
|
||||
sessionId: 'abc',
|
||||
intervalId: dummyInterval,
|
||||
waitingForResponse: false,
|
||||
isPublic: false,
|
||||
});
|
||||
|
||||
mockExecutionManager.checkIfExecutionExists.mockResolvedValue({ id: '123' } as any);
|
||||
|
||||
await chatService.startSession(req);
|
||||
|
||||
expect(previousConnection.terminate).toHaveBeenCalled();
|
||||
expect(clearIntervalSpy).toHaveBeenCalledWith(dummyInterval);
|
||||
expect((chatService as any).sessions.get(sessionKey).connection).toBe(mockWs);
|
||||
clearIntervalSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('checkHeartbeats', () => {
|
||||
it('should terminate sessions that have not sent a heartbeat recently', async () => {
|
||||
const sessionKey = 'abc|123|public';
|
||||
const session = {
|
||||
executionId: '123',
|
||||
connection: mockWs,
|
||||
lastHeartbeat: Date.now() - 61 * 1000,
|
||||
intervalId: 123,
|
||||
};
|
||||
(chatService as any).sessions.set(sessionKey, session);
|
||||
|
||||
mockExecutionManager.cancelExecution.mockResolvedValue(undefined);
|
||||
mockWs.terminate.mockImplementation(() => {});
|
||||
jest.spyOn(global, 'clearInterval').mockImplementation(() => {});
|
||||
|
||||
await (chatService as any).checkHeartbeats();
|
||||
|
||||
expect(mockExecutionManager.cancelExecution).toHaveBeenCalledWith('123');
|
||||
expect(mockWs.terminate).toHaveBeenCalled();
|
||||
expect(clearInterval).toHaveBeenCalledWith(123);
|
||||
expect((chatService as any).sessions.get(sessionKey)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should remove sessions whose connection throws an error when sending a heartbeat', async () => {
|
||||
const sessionKey = 'abc|123|public';
|
||||
const session = {
|
||||
executionId: '123',
|
||||
connection: mockWs,
|
||||
lastHeartbeat: Date.now(),
|
||||
intervalId: 123,
|
||||
};
|
||||
(chatService as any).sessions.set(sessionKey, session);
|
||||
|
||||
mockWs.send.mockImplementation(() => {
|
||||
throw new Error('Connection error');
|
||||
});
|
||||
jest.spyOn(global, 'clearInterval').mockImplementation(() => {});
|
||||
|
||||
await (chatService as any).checkHeartbeats();
|
||||
|
||||
expect(mockWs.send).toHaveBeenCalledWith('n8n|heartbeat');
|
||||
expect(clearInterval).toHaveBeenCalledWith(123);
|
||||
expect((chatService as any).sessions.get(sessionKey)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should check heartbeats and maintain sessions', async () => {
|
||||
const sessionKey = 'abc|123|public';
|
||||
mockWs.send.mockImplementation(() => {});
|
||||
const session = {
|
||||
executionId: '123',
|
||||
connection: mockWs,
|
||||
lastHeartbeat: Date.now(),
|
||||
intervalId: 123,
|
||||
};
|
||||
(chatService as any).sessions.set(sessionKey, session);
|
||||
|
||||
await (chatService as any).checkHeartbeats();
|
||||
|
||||
expect(mockWs.send).toHaveBeenCalledWith('n8n|heartbeat');
|
||||
expect((chatService as any).sessions.get(sessionKey)).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('incomingMessageHandler', () => {
|
||||
it('should return if session does not exist', async () => {
|
||||
const sessionKey = 'nonexistent';
|
||||
const data = 'test data';
|
||||
const incomingMessageHandler = (chatService as any).incomingMessageHandler(sessionKey);
|
||||
await incomingMessageHandler(data);
|
||||
|
||||
expect(mockExecutionManager.runWorkflow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle heartbeat acknowledgement', async () => {
|
||||
const sessionKey = 'abc|123|public';
|
||||
const session = {
|
||||
executionId: '123',
|
||||
lastHeartbeat: 0,
|
||||
};
|
||||
(chatService as any).sessions.set(sessionKey, session);
|
||||
|
||||
const data = 'n8n|heartbeat-ack';
|
||||
const incomingMessageHandler = (chatService as any).incomingMessageHandler(sessionKey);
|
||||
await incomingMessageHandler(data);
|
||||
|
||||
expect(session.lastHeartbeat).not.toBe(0);
|
||||
expect(mockExecutionManager.runWorkflow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should resume execution with processed message', async () => {
|
||||
const sessionKey = 'abc|123|public';
|
||||
const session = {
|
||||
executionId: '123',
|
||||
nodeWaitingForChatResponse: 'test',
|
||||
};
|
||||
(chatService as any).sessions.set(sessionKey, session);
|
||||
|
||||
const data = JSON.stringify({ action: 'sendMessage', chatInput: 'hello', sessionId: 'abc' });
|
||||
mockExecutionManager.findExecution.mockResolvedValue({
|
||||
id: '123',
|
||||
status: 'waiting',
|
||||
data: { resultData: {} },
|
||||
} as any);
|
||||
|
||||
const incomingMessageHandler = (chatService as any).incomingMessageHandler(sessionKey);
|
||||
await incomingMessageHandler(data);
|
||||
|
||||
expect(mockExecutionManager.runWorkflow).toHaveBeenCalled();
|
||||
expect(session.nodeWaitingForChatResponse).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle errors during message processing', async () => {
|
||||
const sessionKey = 'abc|123|public';
|
||||
const session = {
|
||||
executionId: '123',
|
||||
};
|
||||
(chatService as any).sessions.set(sessionKey, session);
|
||||
|
||||
const data = 'invalid json';
|
||||
const incomingMessageHandler = (chatService as any).incomingMessageHandler(sessionKey);
|
||||
await incomingMessageHandler(data);
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pollAndProcessChatResponses', () => {
|
||||
it('should return if session does not exist', async () => {
|
||||
const sessionKey = 'nonexistent';
|
||||
const pollAndProcessChatResponses = (chatService as any).pollAndProcessChatResponses(
|
||||
sessionKey,
|
||||
);
|
||||
await pollAndProcessChatResponses();
|
||||
|
||||
expect(mockExecutionManager.findExecution).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return if session is processing', async () => {
|
||||
const sessionKey = 'abc|123|public';
|
||||
(chatService as any).sessions.set(sessionKey, { isProcessing: true });
|
||||
|
||||
const pollAndProcessChatResponses = (chatService as any).pollAndProcessChatResponses(
|
||||
sessionKey,
|
||||
);
|
||||
await pollAndProcessChatResponses();
|
||||
|
||||
expect(mockExecutionManager.findExecution).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return if execution does not exist', async () => {
|
||||
const sessionKey = 'abc|123|public';
|
||||
(chatService as any).sessions.set(sessionKey, {
|
||||
isProcessing: false,
|
||||
executionId: '123',
|
||||
nodeWaitingForChatResponse: undefined,
|
||||
});
|
||||
mockExecutionManager.findExecution.mockResolvedValue(undefined);
|
||||
|
||||
const pollAndProcessChatResponses = (chatService as any).pollAndProcessChatResponses(
|
||||
sessionKey,
|
||||
);
|
||||
await pollAndProcessChatResponses();
|
||||
|
||||
expect(mockWs.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should send continue if execution status is waiting and last node name is different from nodeWaitingForChatResponse', async () => {
|
||||
const sessionKey = 'abc|123|public';
|
||||
const session = {
|
||||
isProcessing: false,
|
||||
executionId: '123',
|
||||
connection: { send: jest.fn() },
|
||||
nodeWaitingForChatResponse: 'node1',
|
||||
};
|
||||
(chatService as any).sessions.set(sessionKey, session);
|
||||
mockExecutionManager.findExecution.mockResolvedValue({
|
||||
status: 'waiting',
|
||||
data: { resultData: { lastNodeExecuted: 'node2' } },
|
||||
workflowData: { nodes: [{ name: 'node1' }] },
|
||||
} as any);
|
||||
|
||||
const pollAndProcessChatResponses = (chatService as any).pollAndProcessChatResponses(
|
||||
sessionKey,
|
||||
);
|
||||
await pollAndProcessChatResponses();
|
||||
|
||||
expect(session.connection.send).toHaveBeenCalledWith('n8n|continue');
|
||||
expect(session.nodeWaitingForChatResponse).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should send message if execution status is waiting and a message exists', async () => {
|
||||
const sessionKey = 'abc|123|public';
|
||||
const session = {
|
||||
isProcessing: false,
|
||||
executionId: '123',
|
||||
connection: { send: jest.fn() },
|
||||
sessionId: 'abc',
|
||||
nodeWaitingForChatResponse: undefined,
|
||||
};
|
||||
(chatService as any).sessions.set(sessionKey, session);
|
||||
mockExecutionManager.findExecution.mockResolvedValue({
|
||||
status: 'waiting',
|
||||
data: {
|
||||
resultData: {
|
||||
lastNodeExecuted: 'node1',
|
||||
runData: { node1: [{ data: { main: [[{ sendMessage: 'test message' }]] } }] },
|
||||
},
|
||||
},
|
||||
workflowData: { nodes: [{ name: 'node1' }] },
|
||||
} as any);
|
||||
(chatService as any).shouldResumeImmediately = jest.fn().mockReturnValue(false);
|
||||
|
||||
(chatService as any).resumeExecution = jest.fn();
|
||||
|
||||
const pollAndProcessChatResponses = (chatService as any).pollAndProcessChatResponses(
|
||||
sessionKey,
|
||||
);
|
||||
await pollAndProcessChatResponses();
|
||||
|
||||
expect(session.connection.send).toHaveBeenCalledWith('test message');
|
||||
expect(session.nodeWaitingForChatResponse).toEqual('node1');
|
||||
});
|
||||
|
||||
it('should close connection if execution status is success and shouldNotReturnLastNodeResponse is false', async () => {
|
||||
const sessionKey = 'abc|123|public';
|
||||
const session = {
|
||||
isProcessing: false,
|
||||
executionId: '123',
|
||||
connection: { close: jest.fn(), readyState: 1, once: jest.fn() },
|
||||
isPublic: false,
|
||||
};
|
||||
(chatService as any).sessions.set(sessionKey, session);
|
||||
mockExecutionManager.findExecution.mockResolvedValue({
|
||||
status: 'success',
|
||||
data: { resultData: { lastNodeExecuted: 'node1' } },
|
||||
workflowData: { nodes: [{ type: 'n8n-core.respondToWebhook', name: 'node1' }] },
|
||||
mode: 'manual',
|
||||
} as any);
|
||||
|
||||
const pollAndProcessChatResponses = (chatService as any).pollAndProcessChatResponses(
|
||||
sessionKey,
|
||||
);
|
||||
await pollAndProcessChatResponses();
|
||||
|
||||
expect(session.connection.once).toHaveBeenCalled();
|
||||
expect(session.connection.once).toHaveBeenCalledWith('drain', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should handle errors during message processing', async () => {
|
||||
const sessionKey = 'abc|123|public';
|
||||
const session = {
|
||||
isProcessing: false,
|
||||
executionId: '123',
|
||||
connection: mockWs,
|
||||
nodeWaitingForChatResponse: undefined,
|
||||
};
|
||||
(chatService as any).sessions.set(sessionKey, session);
|
||||
mockExecutionManager.findExecution.mockRejectedValue(new Error('test error'));
|
||||
|
||||
const pollAndProcessChatResponses = (chatService as any).pollAndProcessChatResponses(
|
||||
sessionKey,
|
||||
);
|
||||
await pollAndProcessChatResponses();
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,303 @@
|
||||
import type { IExecutionResponse } from '@n8n/db';
|
||||
import type { IDataObject, INode } from 'n8n-workflow';
|
||||
import { CHAT_WAIT_USER_REPLY, RESPOND_TO_WEBHOOK_NODE_TYPE } from 'n8n-workflow';
|
||||
|
||||
import { getMessage, getLastNodeExecuted, shouldResumeImmediately } from '../utils';
|
||||
|
||||
// helpers --------------------------------------------------------
|
||||
const createMockExecution = (
|
||||
overrides: IDataObject = {},
|
||||
firstExecutionData?: IDataObject,
|
||||
nodeData?: IDataObject[],
|
||||
): IExecutionResponse => {
|
||||
const firstItem = firstExecutionData ?? {
|
||||
json: { test: 'data' },
|
||||
sendMessage: 'Test message',
|
||||
};
|
||||
const nodeRunData = nodeData ?? [
|
||||
{
|
||||
data: {
|
||||
main: [[firstItem]],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
id: 'test-execution-id',
|
||||
data: {
|
||||
resultData: {
|
||||
lastNodeExecuted: 'TestNode',
|
||||
runData: {
|
||||
TestNode: nodeRunData,
|
||||
},
|
||||
},
|
||||
},
|
||||
workflowData: {
|
||||
nodes: [
|
||||
{
|
||||
name: 'TestNode',
|
||||
type: 'test-node',
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as IExecutionResponse;
|
||||
};
|
||||
|
||||
const createMockNode = (overrides: Partial<INode> = {}): INode =>
|
||||
({
|
||||
name: 'TestNode',
|
||||
type: 'test-node',
|
||||
parameters: {},
|
||||
...overrides,
|
||||
}) as INode;
|
||||
|
||||
// ---------------------------------------------------------
|
||||
|
||||
describe('getMessage', () => {
|
||||
it('should return sendMessage from the last node execution', () => {
|
||||
const execution = createMockExecution();
|
||||
const result = getMessage(execution);
|
||||
expect(result).toBe('Test message');
|
||||
});
|
||||
|
||||
it('should return undefined when no sendMessage exists', () => {
|
||||
const execution = createMockExecution(
|
||||
{},
|
||||
{
|
||||
json: { test: 'data' },
|
||||
},
|
||||
);
|
||||
const result = getMessage(execution);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when nodeExecutionData is empty', () => {
|
||||
const execution = createMockExecution({}, undefined, [
|
||||
{
|
||||
data: {
|
||||
main: [[]],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const result = getMessage(execution);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle multiple run data entries and use the last one', () => {
|
||||
const execution = createMockExecution({}, undefined, [
|
||||
{
|
||||
data: {
|
||||
main: [
|
||||
[
|
||||
{
|
||||
json: { test: 'first' },
|
||||
sendMessage: 'First message',
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
data: {
|
||||
main: [
|
||||
[
|
||||
{
|
||||
json: { test: 'second' },
|
||||
sendMessage: 'Second message',
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const result = getMessage(execution);
|
||||
expect(result).toBe('Second message');
|
||||
});
|
||||
|
||||
it('should return undefined when main data is missing', () => {
|
||||
const execution = createMockExecution({}, undefined, [
|
||||
{
|
||||
data: {},
|
||||
},
|
||||
]);
|
||||
const result = getMessage(execution);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when nodeExecutionData is undefined', () => {
|
||||
const execution = createMockExecution({
|
||||
data: {
|
||||
resultData: {
|
||||
lastNodeExecuted: 'TestNode',
|
||||
runData: {
|
||||
TestNode: [
|
||||
{
|
||||
data: {
|
||||
main: undefined,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = getMessage(execution);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLastNodeExecuted', () => {
|
||||
it('should return the node that was last executed', () => {
|
||||
const execution = createMockExecution();
|
||||
const result = getLastNodeExecuted(execution);
|
||||
expect(result).toEqual({
|
||||
name: 'TestNode',
|
||||
type: 'test-node',
|
||||
parameters: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined when last executed node is not found', () => {
|
||||
const execution = createMockExecution({
|
||||
data: {
|
||||
resultData: {
|
||||
lastNodeExecuted: 'NonExistentNode',
|
||||
runData: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = getLastNodeExecuted(execution);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should find the correct node among multiple nodes', () => {
|
||||
const execution = createMockExecution({
|
||||
data: {
|
||||
resultData: {
|
||||
lastNodeExecuted: 'SecondNode',
|
||||
runData: {},
|
||||
},
|
||||
},
|
||||
workflowData: {
|
||||
nodes: [
|
||||
{
|
||||
name: 'FirstNode',
|
||||
type: 'first-type',
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
name: 'SecondNode',
|
||||
type: 'second-type',
|
||||
parameters: { test: 'value' },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const result = getLastNodeExecuted(execution);
|
||||
expect(result).toEqual({
|
||||
name: 'SecondNode',
|
||||
type: 'second-type',
|
||||
parameters: { test: 'value' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined when workflowData.nodes is undefined', () => {
|
||||
const execution = createMockExecution({
|
||||
workflowData: undefined,
|
||||
});
|
||||
const result = getLastNodeExecuted(execution);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldResumeImmediately', () => {
|
||||
it('should return true for RESPOND_TO_WEBHOOK_NODE_TYPE', () => {
|
||||
const node = createMockNode({
|
||||
type: RESPOND_TO_WEBHOOK_NODE_TYPE,
|
||||
});
|
||||
const result = shouldResumeImmediately(node);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when CHAT_WAIT_USER_REPLY is false', () => {
|
||||
const node = createMockNode({
|
||||
parameters: {
|
||||
options: {
|
||||
[CHAT_WAIT_USER_REPLY]: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = shouldResumeImmediately(node);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when CHAT_WAIT_USER_REPLY is true', () => {
|
||||
const node = createMockNode({
|
||||
parameters: {
|
||||
options: {
|
||||
[CHAT_WAIT_USER_REPLY]: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = shouldResumeImmediately(node);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when CHAT_WAIT_USER_REPLY is undefined', () => {
|
||||
const node = createMockNode({
|
||||
parameters: {
|
||||
options: {},
|
||||
},
|
||||
});
|
||||
const result = shouldResumeImmediately(node);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when no options exist', () => {
|
||||
const node = createMockNode({
|
||||
parameters: {},
|
||||
});
|
||||
const result = shouldResumeImmediately(node);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when no parameters exist', () => {
|
||||
const node = createMockNode({
|
||||
parameters: undefined,
|
||||
});
|
||||
const result = shouldResumeImmediately(node);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle null node', () => {
|
||||
const result = shouldResumeImmediately(null as any);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle undefined node', () => {
|
||||
const result = shouldResumeImmediately(undefined as any);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when CHAT_WAIT_USER_REPLY is false directly in parameters', () => {
|
||||
const node = createMockNode({
|
||||
parameters: {
|
||||
[CHAT_WAIT_USER_REPLY]: false,
|
||||
},
|
||||
});
|
||||
const result = shouldResumeImmediately(node);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when CHAT_WAIT_USER_REPLY is true directly in parameters', () => {
|
||||
const node = createMockNode({
|
||||
parameters: {
|
||||
[CHAT_WAIT_USER_REPLY]: true,
|
||||
},
|
||||
});
|
||||
const result = shouldResumeImmediately(node);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { ExecutionRepository } from '@n8n/db';
|
||||
import type { IExecutionResponse, Project } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { ExecuteContext } from 'n8n-core';
|
||||
import type {
|
||||
IBinaryKeyData,
|
||||
INodeExecutionData,
|
||||
IWorkflowExecutionDataProcess,
|
||||
} from 'n8n-workflow';
|
||||
import { Workflow, BINARY_ENCODING } from 'n8n-workflow';
|
||||
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
|
||||
import { WorkflowRunner } from '@/workflow-runner';
|
||||
|
||||
import type { ChatMessage } from './chat-service.types';
|
||||
import { NodeTypes } from '../node-types';
|
||||
import { OwnershipService } from '../services/ownership.service';
|
||||
|
||||
@Service()
|
||||
export class ChatExecutionManager {
|
||||
constructor(
|
||||
private readonly executionRepository: ExecutionRepository,
|
||||
private readonly workflowRunner: WorkflowRunner,
|
||||
private readonly ownershipService: OwnershipService,
|
||||
private readonly nodeTypes: NodeTypes,
|
||||
) {}
|
||||
|
||||
async runWorkflow(execution: IExecutionResponse, message: ChatMessage) {
|
||||
await this.workflowRunner.run(
|
||||
await this.getRunData(execution, message),
|
||||
true,
|
||||
true,
|
||||
execution.id,
|
||||
);
|
||||
}
|
||||
|
||||
async cancelExecution(executionId: string) {
|
||||
const execution = await this.executionRepository.findSingleExecution(executionId, {
|
||||
includeData: true,
|
||||
unflattenData: true,
|
||||
});
|
||||
|
||||
if (!execution) return;
|
||||
|
||||
if (['running', 'waiting', 'unknown'].includes(execution.status)) {
|
||||
await this.executionRepository.update({ id: executionId }, { status: 'canceled' });
|
||||
}
|
||||
}
|
||||
|
||||
async findExecution(executionId: string) {
|
||||
return await this.executionRepository.findSingleExecution(executionId, {
|
||||
includeData: true,
|
||||
unflattenData: true,
|
||||
});
|
||||
}
|
||||
async checkIfExecutionExists(executionId: string) {
|
||||
return await this.executionRepository.findSingleExecution(executionId);
|
||||
}
|
||||
|
||||
private getWorkflow(execution: IExecutionResponse) {
|
||||
const { workflowData } = execution;
|
||||
return new Workflow({
|
||||
id: workflowData.id,
|
||||
name: workflowData.name,
|
||||
nodes: workflowData.nodes,
|
||||
connections: workflowData.connections,
|
||||
active: workflowData.active,
|
||||
nodeTypes: this.nodeTypes,
|
||||
staticData: workflowData.staticData,
|
||||
settings: workflowData.settings,
|
||||
});
|
||||
}
|
||||
|
||||
private async mapFilesToBinaryData(context: ExecuteContext, files: ChatMessage['files']) {
|
||||
if (!files) return;
|
||||
const binary: IBinaryKeyData = {};
|
||||
|
||||
for (const [index, file] of files.entries()) {
|
||||
const base64 = file.data;
|
||||
const buffer = Buffer.from(base64, BINARY_ENCODING);
|
||||
const binaryData = await context.helpers.prepareBinaryData(buffer, file.name, file.type);
|
||||
|
||||
binary[`data_${index}`] = binaryData;
|
||||
}
|
||||
|
||||
return binary;
|
||||
}
|
||||
|
||||
private async runNode(execution: IExecutionResponse, message: ChatMessage) {
|
||||
const workflow = this.getWorkflow(execution);
|
||||
const lastNodeExecuted = execution.data.resultData.lastNodeExecuted as string;
|
||||
const node = workflow.getNode(lastNodeExecuted);
|
||||
const additionalData = await WorkflowExecuteAdditionalData.getBase();
|
||||
const executionData = execution.data.executionData?.nodeExecutionStack[0];
|
||||
|
||||
if (!node || !executionData) return null;
|
||||
|
||||
const inputData = executionData.data;
|
||||
const connectionInputData = executionData.data.main[0];
|
||||
const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
|
||||
const context = new ExecuteContext(
|
||||
workflow,
|
||||
node,
|
||||
additionalData,
|
||||
'manual',
|
||||
execution.data,
|
||||
0,
|
||||
connectionInputData ?? [],
|
||||
inputData,
|
||||
executionData,
|
||||
[],
|
||||
);
|
||||
|
||||
const { sessionId, action, chatInput, files } = message;
|
||||
const binary = await this.mapFilesToBinaryData(context, files);
|
||||
|
||||
const nodeExecutionData: INodeExecutionData = { json: { sessionId, action, chatInput } };
|
||||
if (binary && Object.keys(binary).length > 0) {
|
||||
nodeExecutionData.binary = binary;
|
||||
}
|
||||
|
||||
if (nodeType.onMessage) {
|
||||
return await nodeType.onMessage(context, nodeExecutionData);
|
||||
}
|
||||
|
||||
return [[nodeExecutionData]];
|
||||
}
|
||||
|
||||
private async getRunData(execution: IExecutionResponse, message: ChatMessage) {
|
||||
const { workflowData, mode: executionMode, data: runExecutionData } = execution;
|
||||
|
||||
runExecutionData.executionData!.nodeExecutionStack[0].data.main = (await this.runNode(
|
||||
execution,
|
||||
message,
|
||||
)) ?? [[{ json: message }]];
|
||||
|
||||
let project: Project | undefined = undefined;
|
||||
try {
|
||||
project = await this.ownershipService.getWorkflowProjectCached(workflowData.id);
|
||||
} catch (error) {
|
||||
throw new NotFoundError('Cannot find workflow');
|
||||
}
|
||||
|
||||
const runData: IWorkflowExecutionDataProcess = {
|
||||
executionMode,
|
||||
executionData: runExecutionData,
|
||||
pushRef: runExecutionData.pushRef,
|
||||
workflowData,
|
||||
pinData: runExecutionData.resultData.pinData,
|
||||
projectId: project?.id,
|
||||
};
|
||||
|
||||
return runData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Service } from '@n8n/di';
|
||||
import { OnShutdown } from '@n8n/decorators';
|
||||
import type { Application } from 'express';
|
||||
import type { Server as HttpServer } from 'http';
|
||||
import { ServerResponse } from 'http';
|
||||
import { parse as parseUrl } from 'url';
|
||||
import type { WebSocket } from 'ws';
|
||||
import { Server as WebSocketServer } from 'ws';
|
||||
|
||||
import { ChatService } from './chat-service';
|
||||
import type { ChatRequest } from './chat-service.types';
|
||||
|
||||
interface ExpressApplication extends Application {
|
||||
handle: (req: any, res: any) => void;
|
||||
}
|
||||
|
||||
@Service()
|
||||
export class ChatServer {
|
||||
private readonly wsServer = new WebSocketServer({ noServer: true });
|
||||
|
||||
constructor(private readonly chatService: ChatService) {}
|
||||
|
||||
setup(server: HttpServer, app: Application) {
|
||||
server.on('upgrade', (req: ChatRequest, socket, head) => {
|
||||
const parsedUrl = parseUrl(req.url ?? '');
|
||||
|
||||
if (parsedUrl.pathname?.startsWith('/chat')) {
|
||||
this.wsServer.handleUpgrade(req, socket, head, (ws) => {
|
||||
this.attachToApp(req, ws, app as ExpressApplication);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.use('/chat', async (req: ChatRequest) => {
|
||||
await this.chatService.startSession(req);
|
||||
});
|
||||
}
|
||||
|
||||
private attachToApp(req: ChatRequest, ws: WebSocket, app: ExpressApplication) {
|
||||
req.ws = ws;
|
||||
const res = new ServerResponse(req);
|
||||
res.writeHead = (statusCode) => {
|
||||
if (statusCode > 200) ws.close();
|
||||
return res;
|
||||
};
|
||||
|
||||
app.handle(req, res);
|
||||
}
|
||||
|
||||
@OnShutdown()
|
||||
shutdown() {
|
||||
this.wsServer.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { Service } from '@n8n/di';
|
||||
import { OnShutdown } from '@n8n/decorators';
|
||||
import { jsonParse, UnexpectedError, ensureError } from 'n8n-workflow';
|
||||
import { type RawData, WebSocket } from 'ws';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ChatExecutionManager } from './chat-execution-manager';
|
||||
import {
|
||||
chatMessageSchema,
|
||||
type ChatMessage,
|
||||
type ChatRequest,
|
||||
Session,
|
||||
} from './chat-service.types';
|
||||
import { getLastNodeExecuted, getMessage, shouldResumeImmediately } from './utils';
|
||||
import { ErrorReporter } from 'n8n-core';
|
||||
import { IExecutionResponse } from '@n8n/db';
|
||||
|
||||
const CHECK_FOR_RESPONSE_INTERVAL = 3000;
|
||||
const DRAIN_TIMEOUT = 50;
|
||||
const HEARTBEAT_INTERVAL = 30 * 1000;
|
||||
const HEARTBEAT_TIMEOUT = 60 * 1000;
|
||||
|
||||
/**
|
||||
* let frontend know that no user input is expected
|
||||
*/
|
||||
const N8N_CONTINUE = 'n8n|continue';
|
||||
/**
|
||||
* send message for heartbeat check
|
||||
*/
|
||||
const N8N_HEARTBEAT = 'n8n|heartbeat';
|
||||
/**
|
||||
* frontend did acknowledge the heartbeat
|
||||
*/
|
||||
const N8N_HEARTBEAT_ACK = 'n8n|heartbeat-ack';
|
||||
|
||||
function closeConnection(ws: WebSocket) {
|
||||
if (ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
ws.once('drain', () => {
|
||||
ws.close();
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.close();
|
||||
}
|
||||
}, DRAIN_TIMEOUT);
|
||||
}
|
||||
|
||||
@Service()
|
||||
export class ChatService {
|
||||
private readonly sessions = new Map<string, Session>();
|
||||
private heartbeatIntervalId: NodeJS.Timeout;
|
||||
|
||||
constructor(
|
||||
private readonly executionManager: ChatExecutionManager,
|
||||
private readonly logger: Logger,
|
||||
private readonly errorReporter: ErrorReporter,
|
||||
) {
|
||||
this.heartbeatIntervalId = setInterval(
|
||||
async () => await this.checkHeartbeats(),
|
||||
HEARTBEAT_INTERVAL,
|
||||
);
|
||||
}
|
||||
|
||||
async startSession(req: ChatRequest) {
|
||||
const {
|
||||
ws,
|
||||
query: { sessionId, executionId, isPublic },
|
||||
} = req;
|
||||
|
||||
if (!ws) {
|
||||
throw new UnexpectedError('WebSocket connection is missing');
|
||||
}
|
||||
|
||||
if (!sessionId || !executionId) {
|
||||
ws.close(1008);
|
||||
return;
|
||||
}
|
||||
|
||||
const execution = await this.executionManager.checkIfExecutionExists(executionId);
|
||||
|
||||
if (!execution) {
|
||||
ws.send(`Execution with id "${executionId}" does not exist`);
|
||||
ws.close(1008);
|
||||
return;
|
||||
}
|
||||
|
||||
ws.isAlive = true;
|
||||
|
||||
const key = `${sessionId}|${executionId}|${isPublic ? 'public' : 'integrated'}`;
|
||||
|
||||
const existingSession = this.sessions.get(key);
|
||||
if (existingSession) {
|
||||
this.cleanupSession(existingSession, key);
|
||||
}
|
||||
|
||||
const onMessage = this.incomingMessageHandler(key);
|
||||
const respondToChat = this.pollAndProcessChatResponses(key);
|
||||
|
||||
const intervalId = setInterval(async () => await respondToChat(), CHECK_FOR_RESPONSE_INTERVAL);
|
||||
|
||||
ws.once('close', () => {
|
||||
ws.off('message', onMessage);
|
||||
clearInterval(intervalId);
|
||||
this.sessions.delete(key);
|
||||
});
|
||||
|
||||
ws.on('message', onMessage);
|
||||
|
||||
const session: Session = {
|
||||
connection: ws,
|
||||
executionId,
|
||||
sessionId,
|
||||
intervalId,
|
||||
isPublic: isPublic ?? false,
|
||||
isProcessing: false,
|
||||
lastHeartbeat: Date.now(),
|
||||
};
|
||||
|
||||
this.sessions.set(key, session);
|
||||
|
||||
ws.send(N8N_HEARTBEAT);
|
||||
}
|
||||
|
||||
private async processWaitingExecution(
|
||||
execution: IExecutionResponse,
|
||||
session: Session,
|
||||
sessionKey: string,
|
||||
) {
|
||||
const message = getMessage(execution);
|
||||
|
||||
if (message === undefined) return;
|
||||
|
||||
session.connection.send(message);
|
||||
|
||||
const lastNode = getLastNodeExecuted(execution);
|
||||
|
||||
if (lastNode && shouldResumeImmediately(lastNode)) {
|
||||
session.connection.send(N8N_CONTINUE);
|
||||
const data: ChatMessage = {
|
||||
action: 'sendMessage',
|
||||
chatInput: '',
|
||||
sessionId: session.sessionId,
|
||||
};
|
||||
await this.resumeExecution(session.executionId, data, sessionKey);
|
||||
session.nodeWaitingForChatResponse = undefined;
|
||||
} else {
|
||||
session.nodeWaitingForChatResponse = lastNode?.name;
|
||||
}
|
||||
}
|
||||
|
||||
private processSuccessExecution(session: Session) {
|
||||
closeConnection(session.connection);
|
||||
return;
|
||||
}
|
||||
|
||||
private waitForChatResponseOrContinue(execution: IExecutionResponse, session: Session) {
|
||||
const lastNode = getLastNodeExecuted(execution);
|
||||
|
||||
if (execution.status === 'waiting' && lastNode?.name !== session.nodeWaitingForChatResponse) {
|
||||
session.connection.send(N8N_CONTINUE);
|
||||
session.nodeWaitingForChatResponse = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private pollAndProcessChatResponses(sessionKey: string) {
|
||||
return async () => {
|
||||
const session = this.sessions.get(sessionKey);
|
||||
|
||||
if (!session) return;
|
||||
if (session.isProcessing) return;
|
||||
|
||||
try {
|
||||
session.isProcessing = true;
|
||||
|
||||
if (!session.executionId || !session.connection) return;
|
||||
|
||||
const execution = await this.getExecutionOrCleanupSession(session.executionId, sessionKey);
|
||||
|
||||
if (!execution) return;
|
||||
|
||||
if (session.nodeWaitingForChatResponse) {
|
||||
this.waitForChatResponseOrContinue(execution, session);
|
||||
return;
|
||||
}
|
||||
|
||||
if (execution.status === 'waiting') {
|
||||
await this.processWaitingExecution(execution, session, sessionKey);
|
||||
return;
|
||||
}
|
||||
|
||||
if (execution.status === 'success') {
|
||||
this.processSuccessExecution(session);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
const error = ensureError(e);
|
||||
this.errorReporter.error(error);
|
||||
|
||||
this.logger.error(
|
||||
`Error sending message to chat in session ${sessionKey}: ${error.message}`,
|
||||
);
|
||||
} finally {
|
||||
// get only active sessions, as it could have been deleted, and set isProcessing to false
|
||||
const activeSession = this.sessions.get(sessionKey);
|
||||
if (activeSession) {
|
||||
activeSession.isProcessing = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private incomingMessageHandler(sessionKey: string) {
|
||||
return async (data: RawData) => {
|
||||
try {
|
||||
const session = this.sessions.get(sessionKey);
|
||||
|
||||
if (!session) return;
|
||||
|
||||
const message = this.stringifyRawData(data);
|
||||
|
||||
if (message === N8N_HEARTBEAT_ACK) {
|
||||
session.lastHeartbeat = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
const executionId = session.executionId;
|
||||
|
||||
await this.resumeExecution(executionId, this.parseChatMessage(message), sessionKey);
|
||||
session.nodeWaitingForChatResponse = undefined;
|
||||
} catch (e) {
|
||||
const error = ensureError(e);
|
||||
this.errorReporter.error(error);
|
||||
this.logger.error(
|
||||
`Error processing message from chat in session ${sessionKey}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async resumeExecution(executionId: string, message: ChatMessage, sessionKey: string) {
|
||||
const execution = await this.getExecutionOrCleanupSession(executionId, sessionKey);
|
||||
if (!execution || execution.status !== 'waiting') return;
|
||||
await this.executionManager.runWorkflow(execution, message);
|
||||
}
|
||||
|
||||
private async getExecutionOrCleanupSession(executionId: string, sessionKey: string) {
|
||||
const execution = await this.executionManager.findExecution(executionId);
|
||||
|
||||
if (!execution || ['error', 'canceled', 'crashed'].includes(execution.status)) {
|
||||
const session = this.sessions.get(sessionKey);
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
this.cleanupSession(session, sessionKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (execution.status === 'running') return null;
|
||||
|
||||
return execution;
|
||||
}
|
||||
|
||||
private stringifyRawData(data: RawData) {
|
||||
const buffer = Array.isArray(data)
|
||||
? Buffer.concat(data.map((chunk) => Buffer.from(chunk)))
|
||||
: Buffer.from(data);
|
||||
|
||||
return buffer.toString('utf8');
|
||||
}
|
||||
|
||||
private cleanupSession(session: Session, sessionKey: string) {
|
||||
session.connection.terminate();
|
||||
clearInterval(session.intervalId);
|
||||
if (sessionKey) this.sessions.delete(sessionKey);
|
||||
}
|
||||
|
||||
private parseChatMessage(message: string): ChatMessage {
|
||||
try {
|
||||
const parsedMessage = chatMessageSchema.parse(jsonParse(message));
|
||||
|
||||
if (parsedMessage.files) {
|
||||
parsedMessage.files = parsedMessage.files.map((file) => ({
|
||||
...file,
|
||||
data: file.data.includes('base64,') ? file.data.split('base64,')[1] : file.data,
|
||||
}));
|
||||
}
|
||||
|
||||
return parsedMessage;
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
throw new UnexpectedError(
|
||||
`Chat message validation error: ${error.errors.map((error) => error.message).join(', ')}`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async checkHeartbeats() {
|
||||
try {
|
||||
const now = Date.now();
|
||||
|
||||
for (const [key, session] of this.sessions.entries()) {
|
||||
const timeSinceLastHeartbeat = now - (session.lastHeartbeat ?? 0);
|
||||
|
||||
if (timeSinceLastHeartbeat > HEARTBEAT_TIMEOUT) {
|
||||
await this.executionManager.cancelExecution(session.executionId);
|
||||
this.cleanupSession(session, key);
|
||||
} else {
|
||||
try {
|
||||
session.connection.send(N8N_HEARTBEAT);
|
||||
} catch (e) {
|
||||
this.cleanupSession(session, key);
|
||||
const error = ensureError(e);
|
||||
this.errorReporter.error(error);
|
||||
this.logger.error(`Error sending heartbeat to session ${key}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
const error = ensureError(e);
|
||||
this.errorReporter.error(error);
|
||||
this.logger.error(`Error checking heartbeats: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@OnShutdown()
|
||||
shutdown() {
|
||||
for (const [key, session] of this.sessions.entries()) {
|
||||
this.cleanupSession(session, key);
|
||||
}
|
||||
|
||||
this.sessions.clear();
|
||||
clearInterval(this.heartbeatIntervalId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { IncomingMessage } from 'http';
|
||||
import type { WebSocket } from 'ws';
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface ChatRequest extends IncomingMessage {
|
||||
url: string;
|
||||
query: {
|
||||
sessionId: string;
|
||||
executionId: string;
|
||||
isPublic?: boolean;
|
||||
};
|
||||
ws: WebSocket;
|
||||
}
|
||||
|
||||
export type Session = {
|
||||
connection: WebSocket;
|
||||
executionId: string;
|
||||
sessionId: string;
|
||||
intervalId: NodeJS.Timeout;
|
||||
nodeWaitingForChatResponse?: string;
|
||||
isPublic: boolean;
|
||||
isProcessing: boolean;
|
||||
lastHeartbeat?: number;
|
||||
};
|
||||
|
||||
export const chatMessageSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
action: z.literal('sendMessage'),
|
||||
chatInput: z.string(),
|
||||
files: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
data: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type ChatMessage = z.infer<typeof chatMessageSchema>;
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { IExecutionResponse } from '@n8n/db';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
import { CHAT_WAIT_USER_REPLY, RESPOND_TO_WEBHOOK_NODE_TYPE } from 'n8n-workflow';
|
||||
|
||||
/**
|
||||
* Returns the message to be sent of the last executed node
|
||||
*/
|
||||
export function getMessage(execution: IExecutionResponse) {
|
||||
const lastNodeExecuted = execution.data.resultData.lastNodeExecuted;
|
||||
if (typeof lastNodeExecuted !== 'string') return undefined;
|
||||
|
||||
const runIndex = execution.data.resultData.runData[lastNodeExecuted].length - 1;
|
||||
const nodeExecutionData =
|
||||
execution.data.resultData.runData[lastNodeExecuted][runIndex]?.data?.main?.[0];
|
||||
return nodeExecutionData?.[0] ? nodeExecutionData[0].sendMessage : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the last node executed
|
||||
*/
|
||||
export function getLastNodeExecuted(execution: IExecutionResponse) {
|
||||
const lastNodeExecuted = execution.data.resultData.lastNodeExecuted;
|
||||
if (typeof lastNodeExecuted !== 'string') return undefined;
|
||||
return execution.workflowData?.nodes?.find((node) => node.name === lastNodeExecuted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if execution should be resumed immediately after receivng a message
|
||||
*/
|
||||
export function shouldResumeImmediately(lastNode: INode) {
|
||||
if (lastNode?.type === RESPOND_TO_WEBHOOK_NODE_TYPE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (lastNode?.parameters?.[CHAT_WAIT_USER_REPLY] === false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const options = lastNode?.parameters?.options as {
|
||||
[CHAT_WAIT_USER_REPLY]?: boolean;
|
||||
};
|
||||
|
||||
if (options && options[CHAT_WAIT_USER_REPLY] === false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -62,6 +62,9 @@ import '@/evaluation.ee/test-runs.controller.ee';
|
||||
import '@/workflows/workflow-history.ee/workflow-history.controller.ee';
|
||||
import '@/workflows/workflows.controller';
|
||||
import '@/webhooks/webhooks.controller';
|
||||
|
||||
import { ChatServer } from './chat/chat-server';
|
||||
|
||||
import { MfaService } from './mfa/mfa.service';
|
||||
|
||||
@Service()
|
||||
@@ -478,5 +481,6 @@ export class Server extends AbstractServer {
|
||||
protected setupPushServer(): void {
|
||||
const { restEndpoint, server, app } = this;
|
||||
Container.get(Push).setupPushServer(restEndpoint, server, app);
|
||||
Container.get(ChatServer).setup(server, app);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,12 @@ import type {
|
||||
IRunExecutionData,
|
||||
IExecuteData,
|
||||
} from 'n8n-workflow';
|
||||
import { createDeferredPromise, FORM_NODE_TYPE, WAIT_NODE_TYPE } from 'n8n-workflow';
|
||||
import {
|
||||
createDeferredPromise,
|
||||
FORM_NODE_TYPE,
|
||||
WAIT_NODE_TYPE,
|
||||
CHAT_TRIGGER_NODE_TYPE,
|
||||
} from 'n8n-workflow';
|
||||
import type { Readable } from 'stream';
|
||||
import { finished } from 'stream/promises';
|
||||
|
||||
@@ -23,6 +28,7 @@ import {
|
||||
handleFormRedirectionCase,
|
||||
setupResponseNodePromise,
|
||||
prepareExecutionData,
|
||||
handleHostedChatResponse,
|
||||
} from '../webhook-helpers';
|
||||
import type { IWebhookResponseCallbackData } from '../webhook.types';
|
||||
|
||||
@@ -38,6 +44,15 @@ describe('autoDetectResponseMode', () => {
|
||||
workflow.nodes = {};
|
||||
});
|
||||
|
||||
test('should return hostedChat when start node is CHAT_TRIGGER_NODE_TYPE, method is POST, and public is true', () => {
|
||||
const workflowStartNode = mock<INode>({
|
||||
type: CHAT_TRIGGER_NODE_TYPE,
|
||||
parameters: { options: { responseMode: 'responseNodes' } },
|
||||
});
|
||||
const result = autoDetectResponseMode(workflowStartNode, workflow, 'POST');
|
||||
expect(result).toBe('hostedChat');
|
||||
});
|
||||
|
||||
test('should return undefined if start node is WAIT_NODE_TYPE with resume not equal to form', () => {
|
||||
const workflowStartNode = mock<INode>({
|
||||
type: WAIT_NODE_TYPE,
|
||||
@@ -259,6 +274,61 @@ describe('setupResponseNodePromise', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleHostedChatResponse', () => {
|
||||
it('should send executionStarted: true and executionId when responseMode is hostedChat and didSendResponse is false', async () => {
|
||||
const res = {
|
||||
send: jest.fn(),
|
||||
end: jest.fn(),
|
||||
} as unknown as express.Response;
|
||||
const executionId = 'testExecutionId';
|
||||
let didSendResponse = false;
|
||||
const responseMode = 'hostedChat';
|
||||
|
||||
(res.send as jest.Mock).mockImplementation((data) => {
|
||||
expect(data).toEqual({ executionStarted: true, executionId });
|
||||
});
|
||||
|
||||
const result = handleHostedChatResponse(res, responseMode, didSendResponse, executionId);
|
||||
|
||||
expect(res.send).toHaveBeenCalled();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(res.end).toHaveBeenCalled();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should not send response when responseMode is not hostedChat', () => {
|
||||
const res = {
|
||||
send: jest.fn(),
|
||||
end: jest.fn(),
|
||||
} as unknown as express.Response;
|
||||
const executionId = 'testExecutionId';
|
||||
let didSendResponse = false;
|
||||
const responseMode = 'responseNode';
|
||||
|
||||
const result = handleHostedChatResponse(res, responseMode, didSendResponse, executionId);
|
||||
|
||||
expect(res.send).not.toHaveBeenCalled();
|
||||
expect(res.end).not.toHaveBeenCalled();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should not send response when didSendResponse is true', () => {
|
||||
const res = {
|
||||
send: jest.fn(),
|
||||
end: jest.fn(),
|
||||
} as unknown as express.Response;
|
||||
const executionId = 'testExecutionId';
|
||||
let didSendResponse = true;
|
||||
const responseMode = 'hostedChat';
|
||||
|
||||
const result = handleHostedChatResponse(res, responseMode, didSendResponse, executionId);
|
||||
|
||||
expect(res.send).not.toHaveBeenCalled();
|
||||
expect(res.end).not.toHaveBeenCalled();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareExecutionData', () => {
|
||||
const workflowStartNode = mock<INode>({ name: 'Start' });
|
||||
const webhookResultData: IWebhookResponseData = {
|
||||
|
||||
@@ -32,6 +32,7 @@ import type {
|
||||
WebhookResponseData,
|
||||
} from 'n8n-workflow';
|
||||
import {
|
||||
CHAT_TRIGGER_NODE_TYPE,
|
||||
createDeferredPromise,
|
||||
ExecutionCancelledError,
|
||||
FORM_NODE_TYPE,
|
||||
@@ -70,6 +71,21 @@ import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-da
|
||||
import * as WorkflowHelpers from '@/workflow-helpers';
|
||||
import { WorkflowRunner } from '@/workflow-runner';
|
||||
|
||||
export function handleHostedChatResponse(
|
||||
res: express.Response,
|
||||
responseMode: WebhookResponseMode,
|
||||
didSendResponse: boolean,
|
||||
executionId: string,
|
||||
): boolean {
|
||||
if (responseMode === 'hostedChat' && !didSendResponse) {
|
||||
res.send({ executionStarted: true, executionId });
|
||||
process.nextTick(() => res.end());
|
||||
return true;
|
||||
}
|
||||
|
||||
return didSendResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all the webhooks which should be created for the given workflow
|
||||
*/
|
||||
@@ -111,6 +127,23 @@ export function getWorkflowWebhooks(
|
||||
return returnData;
|
||||
}
|
||||
|
||||
const getChatResponseMode = (workflowStartNode: INode, method: string) => {
|
||||
const parameters = workflowStartNode.parameters as {
|
||||
public: boolean;
|
||||
options?: { responseMode: string };
|
||||
};
|
||||
|
||||
if (workflowStartNode.type !== CHAT_TRIGGER_NODE_TYPE) return undefined;
|
||||
|
||||
if (method === 'GET') return 'onReceived';
|
||||
|
||||
if (method === 'POST' && parameters.options?.responseMode === 'responseNodes') {
|
||||
return 'hostedChat';
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line complexity
|
||||
export function autoDetectResponseMode(
|
||||
workflowStartNode: INode,
|
||||
@@ -133,6 +166,9 @@ export function autoDetectResponseMode(
|
||||
}
|
||||
}
|
||||
|
||||
const chatResponseMode = getChatResponseMode(workflowStartNode, method);
|
||||
if (chatResponseMode) return chatResponseMode;
|
||||
|
||||
// If there are form nodes connected to a current form node we're dealing with a multipage form
|
||||
// and we need to return the formPage response mode when a second page of the form gets submitted
|
||||
// to be able to show potential form errors correctly.
|
||||
@@ -375,7 +411,11 @@ export async function executeWebhook(
|
||||
additionalKeys,
|
||||
);
|
||||
|
||||
if (!['onReceived', 'lastNode', 'responseNode', 'formPage', 'streaming'].includes(responseMode)) {
|
||||
if (
|
||||
!['onReceived', 'lastNode', 'responseNode', 'formPage', 'streaming', 'hostedChat'].includes(
|
||||
responseMode,
|
||||
)
|
||||
) {
|
||||
// If the mode is not known we error. Is probably best like that instead of using
|
||||
// the default that people know as early as possible (probably already testing phase)
|
||||
// that something does not resolve properly.
|
||||
@@ -600,6 +640,8 @@ export async function executeWebhook(
|
||||
didSendResponse = true;
|
||||
}
|
||||
|
||||
didSendResponse = handleHostedChatResponse(res, responseMode, didSendResponse, executionId);
|
||||
|
||||
Container.get(Logger).debug(
|
||||
`Started execution of workflow "${workflow.name}" from webhook with execution ID ${executionId}`,
|
||||
{ executionId },
|
||||
|
||||
Reference in New Issue
Block a user