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:
Michael Kret
2025-07-24 09:48:40 +01:00
committed by GitHub
co-authored by कारतोफ्फेलस्क्रिप्ट™ Shireen Missi
parent e61b25c53f
commit a98ed2ca49
47 changed files with 3441 additions and 71 deletions
@@ -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 = {
+43 -1
View File
@@ -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 },