first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,365 @@
/* eslint-disable n8n-nodes-base/node-dirname-against-convention */
import type { BaseChatMemory } from '@langchain/classic/memory';
import {
limitWaitTimeOption,
sendAndWaitWebhooksDescription,
} from 'n8n-nodes-base/dist/utils/sendAndWait/descriptions';
import {
SEND_AND_WAIT_WAITING_TOOLTIP,
sendAndWaitWebhook,
} from 'n8n-nodes-base/dist/utils/sendAndWait/utils';
import {
CHAT_TRIGGER_NODE_TYPE,
CHAT_WAIT_USER_REPLY,
FREE_TEXT_CHAT_RESPONSE_TYPE,
NodeConnectionTypes,
NodeOperationError,
SEND_AND_WAIT_OPERATION,
} from 'n8n-workflow';
import type {
IExecuteFunctions,
INodeExecutionData,
INodeTypeDescription,
INodeType,
NodeTypeAndVersion,
INode,
IDataObject,
} from 'n8n-workflow';
import {
configureInputs,
configureWaitTillDate,
getChatMessage,
getSendAndWaitPropertiesForChatNode,
} from './util';
export class Chat implements INodeType {
description: INodeTypeDescription = {
usableAsTool: true,
displayName: 'Chat',
name: 'chat',
icon: 'fa:comments',
iconColor: 'black',
group: ['input'],
version: [1, 1.1, 1.2],
defaultVersion: 1.2,
description: 'Send a message into the chat',
defaults: {
name: 'Chat',
},
builderHint: {
relatedNodes: [
{
nodeType: '@n8n/n8n-nodes-langchain.chatTrigger',
relationHint:
'Required trigger for this node to work - must set responseMode to "responseNodes"',
},
],
},
codex: {
categories: ['Core Nodes', 'HITL'],
subcategories: {
HITL: ['Human in the Loop'],
},
alias: ['human', 'wait', 'hitl', 'respond', 'approve', 'confirm', 'send', 'message'],
resources: {
primaryDocumentation: [
{
url: 'https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-langchain.respondtochat/',
},
],
},
},
inputs: `={{ (${configureInputs})($parameter) }}`,
outputs: [NodeConnectionTypes.Main],
waitingNodeTooltip: SEND_AND_WAIT_WAITING_TOOLTIP,
webhooks: sendAndWaitWebhooksDescription,
properties: [
{
displayName:
"Verify you're using a chat trigger with the 'Response Mode' option set to 'Using Response Nodes'",
name: 'generalNotice',
type: 'notice',
default: '',
},
{
displayName: 'Operation',
name: 'operation',
type: 'options',
default: 'send',
noDataExpression: true,
options: [
{
name: 'Send Message',
value: 'send',
action: 'Send a message',
},
{
name: 'Send and Wait for Response',
value: SEND_AND_WAIT_OPERATION,
action: 'Send message and wait for response',
},
],
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.1 } }],
},
},
},
{
displayName: 'Message',
name: 'message',
type: 'string',
default: '',
required: true,
typeOptions: {
rows: 4,
},
},
{
displayName: 'Wait for User Reply',
name: CHAT_WAIT_USER_REPLY,
type: 'boolean',
default: true,
noDataExpression: true,
displayOptions: {
show: {
'@version': [{ _cnd: { lt: 1.1 } }],
},
},
},
...getSendAndWaitPropertiesForChatNode(),
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
displayOptions: {
hide: {
'@tool': [true],
},
},
options: [
{
displayName: 'Add Memory Input Connection',
name: 'memoryConnection',
type: 'boolean',
default: false,
displayOptions: {
hide: {
'/responseType': ['approval'],
},
},
},
{
...limitWaitTimeOption,
displayOptions: {
show: {
[`/${CHAT_WAIT_USER_REPLY}`]: [true],
},
},
},
{
...limitWaitTimeOption,
displayOptions: {
show: {
'/operation': [SEND_AND_WAIT_OPERATION],
},
},
},
],
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [limitWaitTimeOption],
displayOptions: {
show: {
'@tool': [true],
[`/${CHAT_WAIT_USER_REPLY}`]: [true],
},
},
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [limitWaitTimeOption],
displayOptions: {
show: {
'@tool': [true],
'/operation': [SEND_AND_WAIT_OPERATION],
},
},
},
],
};
webhook = sendAndWaitWebhook;
async onMessage(
context: IExecuteFunctions,
data: INodeExecutionData,
): Promise<INodeExecutionData[][]> {
const options = context.getNodeParameter('options', 0, {}) as {
memoryConnection?: boolean;
};
const nodeVersion = context.getNode().typeVersion;
let waitForReply;
if (nodeVersion >= 1.1) {
const operation = context.getNodeParameter('operation', 0, 'sendMessage');
waitForReply = operation === SEND_AND_WAIT_OPERATION;
} else {
waitForReply = context.getNodeParameter(CHAT_WAIT_USER_REPLY, 0, true) as boolean;
}
if (!waitForReply) {
const inputData = context.getInputData();
return [inputData];
}
if (options.memoryConnection) {
const memory = (await context.getInputConnectionData(NodeConnectionTypes.AiMemory, 0)) as
| BaseChatMemory
| undefined;
const message = data.json?.chatInput;
if (memory && message) {
await memory.chatHistory.addUserMessage(message as string);
}
}
if (nodeVersion < 1.1) {
return [[data]];
}
const responseType = context.getNodeParameter(
'responseType',
0,
FREE_TEXT_CHAT_RESPONSE_TYPE,
) as string;
const isFreeText = responseType === FREE_TEXT_CHAT_RESPONSE_TYPE;
if (nodeVersion <= 1.1) {
return [
[
{
...data,
json: {
// put everything under the `data` key to be consistent
// with other HITL nodes
data: {
...data.json,
// if the response type is not "Free Text" and the
// user has typed something - we assume it's
// disapproval
approved: isFreeText ? undefined : false,
},
},
},
],
];
}
let nestedData: IDataObject = {};
if (typeof data.json.data === 'object') {
nestedData = {
...data.json.data,
};
}
// if the response type is not "Free Text" and the
// user has typed something - we assume it's
// disapproval
if (!isFreeText) {
nestedData.approved = false;
}
return [
[
{
...data,
json: {
// for v1.2+, don't nest under the `data` key so that Chat
// node can be connected to the AI Agent directly
// (it expects `$json.chatInput` field)
...data.json,
data: Object.keys(nestedData).length > 0 ? nestedData : undefined,
},
},
],
];
}
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const connectedNodes = this.getParentNodes(this.getNode().name, {
includeNodeParameters: true,
});
let chatTrigger: INode | NodeTypeAndVersion | undefined | null = connectedNodes.find(
(node) => node.type === CHAT_TRIGGER_NODE_TYPE && !node.disabled,
);
if (!chatTrigger) {
try {
// try to get chat trigger from workflow if node working as a tool
chatTrigger = this.getChatTrigger();
} catch (error) {}
}
if (!chatTrigger) {
throw new NodeOperationError(
this.getNode(),
'Workflow must be started from a chat trigger node',
);
}
const parameters = chatTrigger.parameters as {
mode?: 'hostedChat' | 'webhook';
options: { responseMode: 'lastNode' | 'responseNodes' | 'streaming' | 'responseNode' };
};
if (parameters.mode === 'webhook') {
throw new NodeOperationError(
this.getNode(),
'"Embedded chat" is not supported, change the "Mode" in the chat trigger node to the "Hosted Chat"',
);
}
if (parameters.options.responseMode !== 'responseNodes') {
throw new NodeOperationError(
this.getNode(),
'"Response Mode" in the chat trigger node must be set to "Using Response Nodes"',
);
}
const message = getChatMessage(this);
const options = this.getNodeParameter('options', 0, {}) as {
memoryConnection?: boolean;
};
if (options.memoryConnection) {
const memory = (await this.getInputConnectionData(NodeConnectionTypes.AiMemory, 0)) as
| BaseChatMemory
| undefined;
if (memory) {
const text = typeof message === 'string' ? message : message.text;
await memory.chatHistory.addAIMessage(text);
}
}
const waitTill = configureWaitTillDate(this);
await this.putExecutionToWait(waitTill);
return [[{ json: {}, sendMessage: message }]];
}
}
@@ -0,0 +1,935 @@
import type { BaseChatMemory } from '@langchain/community/memory/chat_memory';
import pick from 'lodash/pick';
import {
Node,
NodeConnectionTypes,
NodeOperationError,
assertParamIsBoolean,
validateNodeParameters,
assertParamIsString,
} from 'n8n-workflow';
import type {
IDataObject,
IWebhookFunctions,
IWebhookResponseData,
INodeTypeDescription,
MultiPartFormData,
INodeExecutionData,
IBinaryData,
INodeProperties,
} from 'n8n-workflow';
import * as a from 'node:assert';
import { cssVariables } from './constants';
import { validateAuth } from './GenericFunctions';
import { createPage } from './templates';
import { assertValidLoadPreviousSessionOption } from './types';
const CHAT_TRIGGER_PATH_IDENTIFIER = 'chat';
const allowFileUploadsOption: INodeProperties = {
displayName: 'Allow File Uploads',
name: 'allowFileUploads',
type: 'boolean',
default: false,
description: 'Whether to allow file uploads in the chat',
};
const allowedFileMimeTypeOption: INodeProperties = {
displayName: 'Allowed File Mime Types',
name: 'allowedFilesMimeTypes',
type: 'string',
default: '*',
placeholder: 'e.g. image/*, text/*, application/pdf',
description:
'Allowed file types for upload. Comma-separated list of <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types" target="_blank">MIME types</a>.',
};
const respondToWebhookResponseMode = {
name: "Using 'Respond to Webhook' Node",
value: 'responseNode',
description: 'Response defined in that node',
};
const lastNodeResponseMode = {
name: 'When Last Node Finishes',
value: 'lastNode',
description: 'Returns data of the last-executed node',
};
const streamingResponseMode = {
name: 'Streaming',
value: 'streaming',
description: 'Streaming response from specified nodes (e.g. Agents)',
};
const respondNodesResponseMode = {
name: 'Using Response Nodes',
value: 'responseNodes',
description: 'Send responses to the chat by using one or more Chat nodes',
};
const commonOptionsFields: INodeProperties[] = [
// CORS parameters are only valid for when chat is used in hosted or webhook mode
{
displayName: 'Allowed Origins (CORS)',
name: 'allowedOrigins',
type: 'string',
default: '*',
description:
'Comma-separated list of URLs allowed for cross-origin non-preflight requests. Use * (default) to allow all origins.',
displayOptions: {
show: {
'/mode': ['hostedChat', 'webhook'],
},
},
},
{
...allowFileUploadsOption,
displayOptions: {
show: {
'/mode': ['hostedChat'],
},
},
},
{
...allowedFileMimeTypeOption,
displayOptions: {
show: {
'/mode': ['hostedChat'],
},
},
},
{
displayName: 'Input Placeholder',
name: 'inputPlaceholder',
type: 'string',
displayOptions: {
show: {
'/mode': ['hostedChat'],
},
},
default: 'Type your question..',
placeholder: 'e.g. Type your message here',
description: 'Shown as placeholder text in the chat input field',
},
{
displayName: 'Load Previous Session',
name: 'loadPreviousSession',
type: 'options',
options: [
{
name: 'Off',
value: 'notSupported',
description: 'Loading messages of previous session is turned off',
},
{
name: 'From Memory',
value: 'memory',
description: 'Load session messages from memory',
},
{
name: 'Manually',
value: 'manually',
description: 'Manually return messages of session',
},
],
default: 'notSupported',
description: 'If loading messages of a previous session should be enabled',
builderHint: { message: "Set to 'memory' to persist conversation history across sessions" },
},
{
displayName: 'Require Button Click to Start Chat',
name: 'showWelcomeScreen',
type: 'boolean',
displayOptions: {
show: {
'/mode': ['hostedChat'],
},
},
default: false,
description: 'Whether to show the welcome screen at the start of the chat',
},
{
displayName: 'Start Conversation Button Text',
name: 'getStarted',
type: 'string',
displayOptions: {
show: {
showWelcomeScreen: [true],
'/mode': ['hostedChat'],
},
},
default: 'New Conversation',
placeholder: 'e.g. New Conversation',
description: 'Shown as part of the welcome screen, in the middle of the chat window',
},
{
displayName: 'Subtitle',
name: 'subtitle',
type: 'string',
displayOptions: {
show: {
'/mode': ['hostedChat'],
},
},
default: "Start a chat. We're here to help you 24/7.",
placeholder: "e.g. We're here for you",
description: 'Shown at the top of the chat, under the title',
},
{
displayName: 'Title',
name: 'title',
type: 'string',
displayOptions: {
show: {
'/mode': ['hostedChat'],
},
},
default: 'Hi there! 👋',
placeholder: 'e.g. Welcome',
description: 'Shown at the top of the chat',
},
{
displayName: 'Custom Chat Styling',
name: 'customCss',
type: 'string',
typeOptions: {
rows: 10,
editor: 'cssEditor',
},
displayOptions: {
show: {
'/mode': ['hostedChat'],
},
},
default: `
${cssVariables}
/* You can override any class styles, too. Right-click inspect in Chat UI to find class to override. */
.chat-message {
max-width: 50%;
}
`.trim(),
description: 'Override default styling of the public chat interface with CSS',
},
];
export class ChatTrigger extends Node {
description: INodeTypeDescription = {
displayName: 'Chat Trigger',
name: 'chatTrigger',
icon: 'fa:comments',
iconColor: 'black',
group: ['trigger'],
version: [1, 1.1, 1.2, 1.3, 1.4],
defaultVersion: 1.4,
description: 'Runs the workflow when an n8n generated webchat is submitted',
defaults: {
name: 'When chat message received',
},
codex: {
categories: ['Core Nodes'],
resources: {
primaryDocumentation: [
{
url: 'https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-langchain.chattrigger/',
},
],
},
},
maxNodes: 1,
inputs: `={{ (() => {
if (!['hostedChat', 'webhook'].includes($parameter.mode)) {
return [];
}
if ($parameter.options?.loadPreviousSession !== 'memory') {
return [];
}
return [
{
displayName: 'Memory',
maxConnections: 1,
type: '${NodeConnectionTypes.AiMemory}',
required: true,
}
];
})() }}`,
outputs: [NodeConnectionTypes.Main],
builderHint: {
inputs: {
ai_memory: {
required: true,
displayOptions: {
show: {
mode: ['hostedChat', 'webhook'],
'options.loadPreviousSession': ['memory'],
},
},
},
},
},
credentials: [
{
// eslint-disable-next-line n8n-nodes-base/node-class-description-credentials-name-unsuffixed
name: 'httpBasicAuth',
required: true,
displayOptions: {
show: {
authentication: ['basicAuth'],
},
},
},
],
webhooks: [
{
name: 'setup',
httpMethod: 'GET',
responseMode: 'onReceived',
path: CHAT_TRIGGER_PATH_IDENTIFIER,
ndvHideUrl: true,
},
{
name: 'default',
httpMethod: 'POST',
responseMode:
'={{$parameter.options?.["responseMode"] ?? ($parameter.availableInChat ? "streaming" : "lastNode") }}',
path: CHAT_TRIGGER_PATH_IDENTIFIER,
ndvHideMethod: true,
ndvHideUrl: '={{ !$parameter.public }}',
},
],
eventTriggerDescription: 'Waiting for you to submit the chat',
activationMessage: 'You can now make calls to your production chat URL.',
triggerPanel: false,
properties: [
/**
* @note If we change this property, also update it in ChatEmbedModal.vue
*/
{
displayName: 'Make Chat Publicly Available',
name: 'public',
type: 'boolean',
default: false,
description:
'Whether the chat should be publicly available or only accessible through the manual chat interface',
},
{
displayName: 'Mode',
name: 'mode',
type: 'options',
options: [
{
name: 'Hosted Chat',
value: 'hostedChat',
description: 'Chat on a page served by n8n',
},
{
name: 'Embedded Chat',
value: 'webhook',
description: 'Chat through a widget embedded in another page, or by calling a webhook',
},
],
default: 'hostedChat',
displayOptions: {
show: {
public: [true],
},
},
},
{
displayName:
'Chat will be live at the URL above once this workflow is published. Live executions will show up in the executions tab',
name: 'hostedChatNotice',
type: 'notice',
displayOptions: {
show: {
mode: ['hostedChat'],
public: [true],
},
},
default: '',
},
{
displayName:
'Follow the instructions <a href="https://www.npmjs.com/package/@n8n/chat" target="_blank">here</a> to embed chat in a webpage (or just call the webhook URL at the top of this section). Chat will be live once you publish this workflow',
name: 'embeddedChatNotice',
type: 'notice',
displayOptions: {
show: {
mode: ['webhook'],
public: [true],
},
},
default: '',
},
{
displayName: 'Authentication',
name: 'authentication',
type: 'options',
displayOptions: {
show: {
public: [true],
},
},
options: [
{
name: 'Basic Auth',
value: 'basicAuth',
description: 'Simple username and password (the same one for all users)',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'n8n User Auth',
value: 'n8nUserAuth',
description: 'Require user to be logged in with their n8n account',
},
{
name: 'None',
value: 'none',
},
],
default: 'none',
description: 'The way to authenticate',
},
{
displayName: 'Initial Message(s)',
name: 'initialMessages',
type: 'string',
displayOptions: {
show: {
mode: ['hostedChat'],
public: [true],
},
},
typeOptions: {
rows: 3,
},
default: 'Hi there! 👋\nMy name is Nathan. How can I assist you today?',
description: 'Default messages shown at the start of the chat, one per line',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
displayName: 'Make Available in n8n Chat Hub',
name: 'availableInChat',
type: 'boolean',
default: false,
noDataExpression: true,
description:
'Whether to make the agent available in n8n Chat Hub for n8n instance users to chat with',
},
{
displayName:
'Your Chat Trigger node is out of date. To update, delete this node and insert a new Chat Trigger node.',
name: 'availableInChatNotice',
type: 'notice',
displayOptions: {
show: {
availableInChat: [true],
'@version': [{ _cnd: { lt: 1.2 } }],
},
},
default: '',
},
{
displayName:
'Your n8n users will be able to use this agent in <a href="/home/chat/" target="_blank">Chat</a> once this workflow is published. Make sure to share this workflow with at least Project Chat User access to all users who should use it.',
name: 'availableInChatNotice',
type: 'notice',
displayOptions: {
show: {
availableInChat: [true],
'@version': [{ _cnd: { gte: 1.2 } }],
},
},
default: '',
},
{
displayName: 'Agent Icon',
name: 'agentIcon',
type: 'icon',
default: { type: 'icon', value: 'bot' },
noDataExpression: true,
description: 'The icon of the agent on n8n Chat',
displayOptions: {
show: {
availableInChat: [true],
'@version': [{ _cnd: { gte: 1.2 } }],
},
},
},
{
displayName: 'Agent Name',
name: 'agentName',
type: 'string',
default: '',
noDataExpression: true,
description:
'The name of the agent on n8n Chat. Name of the workflow is used if left empty.',
displayOptions: {
show: {
availableInChat: [true],
'@version': [{ _cnd: { gte: 1.2 } }],
},
},
},
{
displayName: 'Agent Description',
name: 'agentDescription',
type: 'string',
typeOptions: {
rows: 2,
},
default: '',
noDataExpression: true,
description: 'The description of the agent on n8n Chat',
displayOptions: {
show: {
availableInChat: [true],
'@version': [{ _cnd: { gte: 1.2 } }],
},
},
},
{
displayName: 'Suggestions',
name: 'suggestedPrompts',
type: 'fixedCollection',
typeOptions: { multipleValues: true, fixedCollection: { layout: 'inline' } },
default: {},
noDataExpression: true,
placeholder: 'Add Prompt',
description:
'Suggested prompts shown to users in n8n Chat Hub to start a conversation with the agent',
displayOptions: {
show: {
availableInChat: [true],
'@version': [{ _cnd: { gte: 1.2 } }],
},
},
options: [
{
name: 'prompts',
displayName: 'Prompts',
values: [
{
displayName: 'Icon',
name: 'icon',
type: 'icon',
noDataExpression: true,
default: { type: 'icon', value: 'comment' },
},
{
displayName: 'Prompt Text',
name: 'text',
type: 'string',
default: '',
noDataExpression: true,
required: true,
},
],
},
],
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
displayOptions: {
show: {
public: [false],
'@version': [1, 1.1],
},
},
placeholder: 'Add Field',
default: {},
options: [allowFileUploadsOption, allowedFileMimeTypeOption],
},
// Options for versions 1.0 and 1.1 (without streaming)
{
displayName: 'Options',
name: 'options',
type: 'collection',
displayOptions: {
show: {
mode: ['hostedChat', 'webhook'],
public: [true],
'@version': [1, 1.1],
},
},
placeholder: 'Add Field',
default: {},
options: [
...commonOptionsFields,
{
displayName: 'Response Mode',
name: 'responseMode',
type: 'options',
options: [lastNodeResponseMode, respondToWebhookResponseMode],
default: 'lastNode',
description: 'When and how to respond to the webhook',
},
],
},
// Options for version 1.2 (with streaming)
{
displayName: 'Options',
name: 'options',
type: 'collection',
displayOptions: {
show: {
mode: ['hostedChat', 'webhook'],
public: [true],
'@version': [1.2],
},
},
placeholder: 'Add Field',
default: {},
options: [
...commonOptionsFields,
{
displayName: 'Response Mode',
name: 'responseMode',
type: 'options',
options: [lastNodeResponseMode, respondToWebhookResponseMode, streamingResponseMode],
default: 'lastNode',
description: 'When and how to respond to the webhook',
displayOptions: { show: { '/availableInChat': [false] } },
},
{
displayName: 'Response Mode',
name: 'responseMode',
type: 'options',
options: [streamingResponseMode, lastNodeResponseMode],
default: 'streaming',
description: 'When and how to respond to the webhook',
displayOptions: { show: { '/availableInChat': [true] } },
},
],
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
displayOptions: {
show: {
public: [false],
'@version': [{ _cnd: { gte: 1.3 } }],
},
},
placeholder: 'Add Field',
default: {},
options: [
allowFileUploadsOption,
allowedFileMimeTypeOption,
{
displayName: 'Response Mode',
name: 'responseMode',
type: 'options',
options: [lastNodeResponseMode, respondNodesResponseMode, streamingResponseMode],
default: 'lastNode',
description: 'When and how to respond to the chat',
displayOptions: { show: { '/availableInChat': [false] } },
},
{
displayName: 'Response Mode',
name: 'responseMode',
type: 'options',
options: [streamingResponseMode, lastNodeResponseMode, respondNodesResponseMode],
default: 'streaming',
description: 'When and how to respond to the chat',
displayOptions: { show: { '/availableInChat': [true] } },
},
],
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
displayOptions: {
show: {
mode: ['hostedChat', 'webhook'],
public: [true],
'@version': [{ _cnd: { gte: 1.3 } }],
},
},
placeholder: 'Add Field',
default: {},
options: [
...commonOptionsFields,
{
displayName: 'Response Mode',
name: 'responseMode',
type: 'options',
options: [lastNodeResponseMode, streamingResponseMode, respondToWebhookResponseMode],
default: 'lastNode',
description: 'When and how to respond to the chat',
displayOptions: { show: { '/mode': ['webhook'], '/availableInChat': [false] } },
},
{
displayName: 'Response Mode',
name: 'responseMode',
type: 'options',
options: [streamingResponseMode, lastNodeResponseMode],
default: 'streaming',
description: 'When and how to respond to the chat',
displayOptions: { show: { '/mode': ['webhook'], '/availableInChat': [true] } },
},
{
displayName: 'Response Mode',
name: 'responseMode',
type: 'options',
options: [lastNodeResponseMode, streamingResponseMode, respondNodesResponseMode],
default: 'lastNode',
description: 'When and how to respond to the chat',
displayOptions: { show: { '/mode': ['hostedChat'], '/availableInChat': [false] } },
},
{
displayName: 'Response Mode',
name: 'responseMode',
type: 'options',
options: [streamingResponseMode, lastNodeResponseMode, respondNodesResponseMode],
default: 'streaming',
description: 'When and how to respond to the chat',
displayOptions: { show: { '/mode': ['hostedChat'], '/availableInChat': [true] } },
},
],
},
],
};
private async handleFormData(context: IWebhookFunctions) {
const req = context.getRequestObject() as MultiPartFormData.Request;
a.ok(req.contentType === 'multipart/form-data', 'Expected multipart/form-data');
const options = context.getNodeParameter('options', {}) as IDataObject;
const { data, files } = req.body;
const returnItem: INodeExecutionData = {
json: data,
};
if (files && Object.keys(files).length) {
returnItem.json.files = [] as Array<Omit<IBinaryData, 'data'>>;
returnItem.binary = {};
const count = 0;
for (const fileKey of Object.keys(files)) {
const processedFiles: MultiPartFormData.File[] = [];
if (Array.isArray(files[fileKey])) {
processedFiles.push(...files[fileKey]);
} else {
processedFiles.push(files[fileKey]);
}
let fileIndex = 0;
for (const file of processedFiles) {
let binaryPropertyName = 'data';
// Remove the '[]' suffix from the binaryPropertyName if it exists
if (binaryPropertyName.endsWith('[]')) {
binaryPropertyName = binaryPropertyName.slice(0, -2);
}
if (options.binaryPropertyName) {
binaryPropertyName = `${options.binaryPropertyName.toString()}${count}`;
}
const binaryFile = await context.nodeHelpers.copyBinaryFile(
file.filepath,
file.originalFilename ?? file.newFilename,
file.mimetype,
);
const binaryKey = `${binaryPropertyName}${fileIndex}`;
const binaryInfo = {
...pick(binaryFile, ['fileName', 'fileSize', 'fileType', 'mimeType', 'fileExtension']),
binaryKey,
};
returnItem.binary = Object.assign(returnItem.binary ?? {}, {
[`${binaryKey}`]: binaryFile,
});
returnItem.json.files = [
...(returnItem.json.files as Array<Omit<IBinaryData, 'data'>>),
binaryInfo,
];
fileIndex += 1;
}
}
}
return returnItem;
}
async webhook(ctx: IWebhookFunctions): Promise<IWebhookResponseData> {
const res = ctx.getResponseObject();
const isPublic = ctx.getNodeParameter('public', false);
assertParamIsBoolean('public', isPublic, ctx.getNode());
const nodeMode = ctx.getNodeParameter('mode', 'hostedChat');
assertParamIsString('mode', nodeMode, ctx.getNode());
const mode = ctx.getMode() === 'manual' ? 'test' : 'production';
// Allow execution in manual mode (test) even when not public
if (!isPublic && mode !== 'test') {
res.status(404).end();
return {
noWebhookResponse: true,
};
}
const availableInChat = ctx.getNodeParameter('availableInChat', false);
const options = ctx.getNodeParameter('options', {});
validateNodeParameters(
options,
{
getStarted: { type: 'string' },
inputPlaceholder: { type: 'string' },
loadPreviousSession: { type: 'string' },
showWelcomeScreen: { type: 'boolean' },
subtitle: { type: 'string' },
title: { type: 'string' },
allowFileUploads: { type: 'boolean' },
allowedFilesMimeTypes: { type: 'string' },
customCss: { type: 'string' },
responseMode: { type: 'string' },
},
ctx.getNode(),
);
const loadPreviousSession = options.loadPreviousSession;
assertValidLoadPreviousSessionOption(loadPreviousSession, ctx.getNode());
const enableStreaming = availableInChat
? !options.responseMode || options.responseMode === 'streaming'
: options.responseMode === 'streaming';
const req = ctx.getRequestObject();
const webhookName = ctx.getWebhookName();
const bodyData = ctx.getBodyData() ?? {};
try {
await validateAuth(ctx);
} catch (error) {
if (error) {
res.writeHead((error as IDataObject).responseCode as number, {
'www-authenticate': 'Basic realm="Webhook"',
});
res.end((error as IDataObject).message as string);
return { noWebhookResponse: true };
}
throw error;
}
if (nodeMode === 'hostedChat') {
// Show the chat on GET request
if (webhookName === 'setup') {
const webhookUrlRaw = ctx.getNodeWebhookUrl('default');
if (!webhookUrlRaw) {
throw new NodeOperationError(ctx.getNode(), 'Default webhook url not set');
}
const webhookUrl =
mode === 'test' ? webhookUrlRaw.replace('/webhook', '/webhook-test') : webhookUrlRaw;
const authentication = ctx.getNodeParameter('authentication') as
| 'none'
| 'basicAuth'
| 'n8nUserAuth';
const initialMessagesRaw = ctx.getNodeParameter('initialMessages', '');
assertParamIsString('initialMessage', initialMessagesRaw, ctx.getNode());
const instanceId = ctx.getInstanceId();
const i18nConfig: Record<string, string> = {};
const keys = ['getStarted', 'inputPlaceholder', 'subtitle', 'title'] as const;
for (const key of keys) {
if (options[key] !== undefined) {
i18nConfig[key] = options[key];
}
}
const page = createPage({
i18n: {
en: i18nConfig,
},
showWelcomeScreen: options.showWelcomeScreen,
loadPreviousSession,
initialMessages: initialMessagesRaw,
webhookUrl,
mode,
instanceId,
authentication,
allowFileUploads: options.allowFileUploads,
allowedFilesMimeTypes: options.allowedFilesMimeTypes,
customCss: options.customCss,
enableStreaming,
});
res.status(200).send(page).end();
return {
noWebhookResponse: true,
};
}
}
if (bodyData.action === 'loadPreviousSession') {
if (options?.loadPreviousSession === 'memory') {
const memory = (await ctx.getInputConnectionData(NodeConnectionTypes.AiMemory, 0)) as
| BaseChatMemory
| undefined;
const messages = ((await memory?.chatHistory.getMessages()) ?? [])
.filter((message) => !message?.additional_kwargs?.hideFromUI)
.map((message) => message?.toJSON());
return {
webhookResponse: { data: messages },
};
} else if (!options?.loadPreviousSession || options?.loadPreviousSession === 'notSupported') {
// If messages of a previous session should not be loaded, simply return an empty array
return {
webhookResponse: { data: [] },
};
}
}
let returnData: INodeExecutionData[];
const webhookResponse: IDataObject = { status: 200 };
// Handle streaming responses
if (enableStreaming) {
// Set up streaming response headers
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Transfer-Encoding': 'chunked',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
// Flush headers immediately
res.flushHeaders();
if (req.contentType === 'multipart/form-data') {
returnData = [await this.handleFormData(ctx)];
} else {
returnData = [{ json: bodyData }];
}
return {
workflowData: [ctx.helpers.returnJsonArray(returnData)],
noWebhookResponse: true,
};
}
if (req.contentType === 'multipart/form-data') {
returnData = [await this.handleFormData(ctx)];
return {
webhookResponse,
workflowData: [returnData],
};
} else {
returnData = [{ json: bodyData }];
}
return {
webhookResponse,
workflowData: [ctx.helpers.returnJsonArray(returnData)],
};
}
}
@@ -0,0 +1,65 @@
import basicAuth from 'basic-auth';
import type { ICredentialDataDecryptedObject, IWebhookFunctions } from 'n8n-workflow';
import { ChatTriggerAuthorizationError } from './error';
import type { AuthenticationChatOption } from './types';
export async function validateAuth(context: IWebhookFunctions) {
const authentication = context.getNodeParameter(
'authentication',
'none',
) as AuthenticationChatOption;
const req = context.getRequestObject();
const headers = context.getHeaderData();
if (authentication === 'none') {
return;
} else if (authentication === 'basicAuth') {
// Basic authorization is needed to call webhook
let expectedAuth: ICredentialDataDecryptedObject | undefined;
try {
expectedAuth = await context.getCredentials<ICredentialDataDecryptedObject>('httpBasicAuth');
} catch {}
if (expectedAuth === undefined || !expectedAuth.user || !expectedAuth.password) {
// Data is not defined on node so can not authenticate
throw new ChatTriggerAuthorizationError(500, 'No authentication data defined on node!');
}
const providedAuth = basicAuth(req);
// Authorization data is missing
if (!providedAuth) throw new ChatTriggerAuthorizationError(401);
if (providedAuth.name !== expectedAuth.user || providedAuth.pass !== expectedAuth.password) {
// Provided authentication data is wrong
throw new ChatTriggerAuthorizationError(403);
}
} else if (authentication === 'n8nUserAuth') {
const webhookName = context.getWebhookName();
if (webhookName !== 'setup') {
function getCookie(name: string) {
const value = `; ${headers.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) {
return parts.pop()?.split(';').shift();
}
return '';
}
const authCookie = getCookie('n8n-auth');
if (!authCookie) {
throw new ChatTriggerAuthorizationError(401, 'User not authenticated!');
}
try {
await context.validateCookieAuth(authCookie);
} catch {
throw new ChatTriggerAuthorizationError(401, 'Invalid authentication token');
}
}
}
return;
}
@@ -0,0 +1,54 @@
# ChatTrigger Local Development
This guide explains how to set up local development for the ChatTrigger node when working with the chat bundle.
## Prerequisites
Since the chat bundle is loaded via `<script type="module">`, it needs to be served over HTTPS for local development.
## Setup Instructions
### 1. Install HTTP Server
Install the http-server globally:
```bash
npm install -g http-server
```
### 2. Generate SSL Certificate
Generate a self-signed certificate for HTTPS:
```bash
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes
```
### 3. Build the Chat Bundle
Navigate to the chat package and build it:
```bash
cd packages/frontend/@n8n/chat && pnpm run build
```
### 4. Start HTTPS Server
Run the HTTPS server to serve the chat bundle:
```bash
http-server packages/frontend/@n8n/chat/dist -g -S -C cert.pem -K key.pem --port 8443 --cors
```
### 5. Update Import Paths
Modify the import paths in `templates.ts` to point to your local server:
```html
<script type="module">
import { createChat } from 'https://127.0.0.1:8443/chat.bundle.es.js';
```
```html
<link href="https://127.0.0.1:8443/style.css" rel="stylesheet" />
```
@@ -0,0 +1,324 @@
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import type { INode, IExecuteFunctions } from 'n8n-workflow';
import {
CHAT_NODE_TYPE,
CHAT_TRIGGER_NODE_TYPE,
FREE_TEXT_CHAT_RESPONSE_TYPE,
SEND_AND_WAIT_OPERATION,
} from 'n8n-workflow';
import { Chat } from '../Chat.node';
describe('Test Chat Node', () => {
let chat: Chat;
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
beforeEach(() => {
chat = new Chat();
mockExecuteFunctions = mock<IExecuteFunctions>();
});
afterEach(() => {
jest.clearAllMocks();
});
describe('v1.0', () => {
const chatNode = mock<INode>({
name: 'Chat',
type: CHAT_NODE_TYPE,
parameters: {},
typeVersion: 1.0,
});
it('should execute and send message', async () => {
const items = [{ json: { data: 'test' } }];
mockExecuteFunctions.getInputData.mockReturnValue(items);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('message');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(false);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({
limitType: 'afterTimeInterval',
resumeAmount: 1,
resumeUnit: 'minutes',
});
mockExecuteFunctions.getNode.mockReturnValue(chatNode);
mockExecuteFunctions.getParentNodes.mockReturnValue([
{
type: CHAT_TRIGGER_NODE_TYPE,
disabled: false,
parameters: { mode: 'hostedChat', options: { responseMode: 'responseNodes' } },
} as any,
]);
const result = await chat.execute.call(mockExecuteFunctions);
expect(result).toEqual([[{ json: {}, sendMessage: 'message' }]]);
});
it('should execute and handle memory connection', async () => {
const items = [{ json: { data: 'test' } }];
mockExecuteFunctions.getInputData.mockReturnValue(items);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('message');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({ memoryConnection: true });
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({
limitType: 'afterTimeInterval',
resumeAmount: 1,
resumeUnit: 'minutes',
});
mockExecuteFunctions.getNode.mockReturnValue(chatNode);
mockExecuteFunctions.getParentNodes.mockReturnValue([
{
type: CHAT_TRIGGER_NODE_TYPE,
disabled: false,
parameters: { mode: 'hostedChat', options: { responseMode: 'responseNodes' } },
} as any,
]);
const memory = { chatHistory: { addAIMessage: jest.fn() } };
mockExecuteFunctions.getInputConnectionData.mockResolvedValueOnce(memory);
await chat.execute.call(mockExecuteFunctions);
expect(memory.chatHistory.addAIMessage).toHaveBeenCalledWith('message');
});
it('should execute without memory connection', async () => {
const items = [{ json: { data: 'test' } }];
mockExecuteFunctions.getInputData.mockReturnValue(items);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('message');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(false);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({
limitType: 'afterTimeInterval',
resumeAmount: 1,
resumeUnit: 'minutes',
});
mockExecuteFunctions.getNode.mockReturnValue(chatNode);
mockExecuteFunctions.getParentNodes.mockReturnValue([
{
type: CHAT_TRIGGER_NODE_TYPE,
disabled: false,
parameters: { mode: 'hostedChat', options: { responseMode: 'responseNodes' } },
} as any,
]);
const result = await chat.execute.call(mockExecuteFunctions);
expect(result).toEqual([[{ json: {}, sendMessage: 'message' }]]);
});
it('should execute with specified time limit', async () => {
const items = [{ json: { data: 'test' } }];
mockExecuteFunctions.getInputData.mockReturnValue(items);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('message');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(false);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({
limitType: 'atSpecifiedTime',
maxDateAndTime: new Date().toISOString(),
});
mockExecuteFunctions.getNode.mockReturnValue(chatNode);
mockExecuteFunctions.getParentNodes.mockReturnValue([
{
type: CHAT_TRIGGER_NODE_TYPE,
disabled: false,
parameters: { mode: 'hostedChat', options: { responseMode: 'responseNodes' } },
} as any,
]);
const result = await chat.execute.call(mockExecuteFunctions);
expect(result).toEqual([[{ json: {}, sendMessage: 'message' }]]);
});
it('should process onMessage without waiting for reply', async () => {
const data = { json: { chatInput: 'user message' } };
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({ memoryConnection: true });
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(false);
mockExecuteFunctions.getInputData.mockReturnValue([data]);
mockExecuteFunctions.getNode.mockReturnValue(chatNode);
mockExecuteFunctions.getParentNodes.mockReturnValue([
{
type: CHAT_TRIGGER_NODE_TYPE,
disabled: false,
parameters: { mode: 'hostedChat', options: { responseMode: 'responseNodes' } },
} as any,
]);
const result = await chat.onMessage(mockExecuteFunctions, data);
expect(result).toEqual([[data]]);
});
});
describe('v1.1', () => {
const chatNode = mock<INode>({
name: 'Chat',
type: CHAT_NODE_TYPE,
parameters: {},
typeVersion: 1.1,
});
it('should process onMessage without waiting for reply', async () => {
const data = { json: { chatInput: 'user message' } };
mockExecuteFunctions.getInputData.mockReturnValue([data]);
mockExecuteFunctions.getNode.mockReturnValue(chatNode);
mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName) => {
switch (parameterName) {
case 'operation':
return 'send';
case 'options':
return { memoryConnection: false };
default:
return undefined;
}
});
const result = await chat.onMessage(mockExecuteFunctions, data);
expect(result).toEqual([[data]]);
});
it('should process onMessage with waiting for reply and free text response type', async () => {
const data = { json: { chatInput: 'user message' } };
mockExecuteFunctions.getInputData.mockReturnValue([data]);
mockExecuteFunctions.getNode.mockReturnValue(chatNode);
mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName) => {
switch (parameterName) {
case 'operation':
return SEND_AND_WAIT_OPERATION;
case 'responseType':
return FREE_TEXT_CHAT_RESPONSE_TYPE;
case 'options':
return { memoryConnection: false };
default:
return undefined;
}
});
const result = await chat.onMessage(mockExecuteFunctions, data);
expect(result).toEqual([
[
{
...data,
json: {
data: {
...data.json,
},
},
},
],
]);
});
it('should process onMessage with waiting for reply and approval response type', async () => {
const data = { json: { chatInput: 'user message' } };
mockExecuteFunctions.getInputData.mockReturnValue([data]);
mockExecuteFunctions.getNode.mockReturnValue(chatNode);
mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName) => {
switch (parameterName) {
case 'operation':
return SEND_AND_WAIT_OPERATION;
case 'responseType':
return 'approval';
case 'options':
return { memoryConnection: false };
default:
return undefined;
}
});
const result = await chat.onMessage(mockExecuteFunctions, data);
expect(result).toEqual([
[
{
...data,
json: {
data: {
...data.json,
approved: false,
},
},
},
],
]);
});
it('should add user message to memory', async () => {
const data = { json: { chatInput: 'user message' } };
const memory = { chatHistory: { addUserMessage: jest.fn() } };
mockExecuteFunctions.getInputData.mockReturnValue([data]);
mockExecuteFunctions.getNode.mockReturnValue(chatNode);
mockExecuteFunctions.getInputConnectionData.mockResolvedValue(memory);
mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName) => {
switch (parameterName) {
case 'operation':
return SEND_AND_WAIT_OPERATION;
case 'responseType':
return FREE_TEXT_CHAT_RESPONSE_TYPE;
case 'options':
return { memoryConnection: true };
default:
return undefined;
}
});
const result = await chat.onMessage(mockExecuteFunctions, data);
expect(result).toEqual([
[
{
...data,
json: {
data: {
...data.json,
},
},
},
],
]);
expect(memory.chatHistory.addUserMessage).toHaveBeenCalledWith('user message');
});
it('v1.2 should return output data directly without nesting into `data` field (except `approved`)', async () => {
const chatNode = mock<INode>({
name: 'Chat',
type: CHAT_NODE_TYPE,
parameters: {},
typeVersion: 1.2,
});
const data = { json: { chatInput: 'user message', data: { nested: 'field' } } };
mockExecuteFunctions.getInputData.mockReturnValue([data]);
mockExecuteFunctions.getNode.mockReturnValue(chatNode);
mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName) => {
switch (parameterName) {
case 'operation':
return SEND_AND_WAIT_OPERATION;
case 'responseType':
return 'approval';
case 'options':
return { memoryConnection: false };
default:
return undefined;
}
});
const result = await chat.onMessage(mockExecuteFunctions, data);
expect(result).toEqual([
[
{
...data,
json: {
...data.json,
data: {
...data.json.data,
approved: false,
},
},
},
],
]);
});
});
});
@@ -0,0 +1,313 @@
import { jest } from '@jest/globals';
import type { Request, Response } from 'express';
import { mock } from 'jest-mock-extended';
import type { IWebhookFunctions } from 'n8n-workflow';
import { ChatTrigger } from '../ChatTrigger.node';
import type { LoadPreviousSessionChatOption } from '../types';
jest.mock('../GenericFunctions', () => ({
validateAuth: jest.fn(),
}));
describe('ChatTrigger Node', () => {
const mockContext = mock<IWebhookFunctions>();
const mockRequest = mock<Request>();
const mockResponse = mock<Response>();
let chatTrigger: ChatTrigger;
beforeEach(() => {
jest.clearAllMocks();
chatTrigger = new ChatTrigger();
mockContext.getRequestObject.mockReturnValue(mockRequest);
mockContext.getResponseObject.mockReturnValue(mockResponse);
mockContext.getNodeParameter.mockImplementation(
(
paramName: string,
defaultValue?: boolean | string | object,
): boolean | string | object | undefined => {
if (paramName === 'public') return true;
if (paramName === 'mode') return 'hostedChat';
if (paramName === 'options') return {};
return defaultValue;
},
);
mockContext.getBodyData.mockReturnValue({});
});
describe('webhook method: loadPreviousSession action', () => {
beforeEach(() => {
mockContext.getBodyData.mockReturnValue({ action: 'loadPreviousSession' });
});
it('should return empty array when loadPreviousSession is undefined', async () => {
// Mock options with undefined loadPreviousSession
mockContext.getNodeParameter.mockImplementation(
(
paramName: string,
defaultValue?: boolean | string | object,
): boolean | string | object | undefined => {
if (paramName === 'public') return true;
if (paramName === 'mode') return 'hostedChat';
if (paramName === 'options') return { loadPreviousSession: undefined };
return defaultValue;
},
);
// Call the webhook method
const result = await chatTrigger.webhook(mockContext);
// Verify the returned result contains empty data array
expect(result).toEqual({
webhookResponse: { data: [] },
});
});
it('should return empty array when loadPreviousSession is "notSupported"', async () => {
// Mock options with notSupported loadPreviousSession
mockContext.getNodeParameter.mockImplementation(
(
paramName: string,
defaultValue?: boolean | string | object,
): boolean | string | object | undefined => {
if (paramName === 'public') return true;
if (paramName === 'mode') return 'hostedChat';
if (paramName === 'options') return { loadPreviousSession: 'notSupported' };
return defaultValue;
},
);
// Call the webhook method
const result = await chatTrigger.webhook(mockContext);
// Verify the returned result contains empty data array
expect(result).toEqual({
webhookResponse: { data: [] },
});
});
it('should handle loadPreviousSession="memory" correctly', async () => {
// Mock chat history data
const mockMessages = [
{ toJSON: () => ({ content: 'Message 1' }) },
{ toJSON: () => ({ content: 'Message 2' }) },
];
// Mock memory with chat history
const mockMemory = {
chatHistory: {
getMessages: jest.fn().mockReturnValueOnce(mockMessages),
},
};
// Mock options with memory loadPreviousSession
mockContext.getNodeParameter.mockImplementation(
(
paramName: string,
defaultValue?: boolean | string | object,
): boolean | string | object | undefined => {
if (paramName === 'public') return true;
if (paramName === 'mode') return 'hostedChat';
if (paramName === 'options')
return { loadPreviousSession: 'memory' as LoadPreviousSessionChatOption };
return defaultValue;
},
);
// Mock getInputConnectionData to return memory
mockContext.getInputConnectionData.mockResolvedValue(mockMemory);
// Call the webhook method
const result = await chatTrigger.webhook(mockContext);
// Verify the returned result contains messages from memory
expect(result).toEqual({
webhookResponse: {
data: [{ content: 'Message 1' }, { content: 'Message 2' }],
},
});
});
});
describe('webhook method: streaming response mode', () => {
beforeEach(() => {
mockContext.getWebhookName.mockReturnValue('default');
mockContext.getMode.mockReturnValue('production' as any);
mockContext.getBodyData.mockReturnValue({ message: 'Hello' });
(mockContext.helpers.returnJsonArray as any) = jest.fn().mockReturnValue([]);
mockResponse.writeHead.mockImplementation(() => mockResponse);
mockResponse.flushHeaders.mockImplementation(() => undefined);
});
it('should enable streaming when responseMode is "streaming"', async () => {
// Mock options with streaming responseMode
mockContext.getNodeParameter.mockImplementation(
(
paramName: string,
defaultValue?: boolean | string | object,
): boolean | string | object | undefined => {
if (paramName === 'public') return true;
if (paramName === 'mode') return 'hostedChat';
if (paramName === 'options') return { responseMode: 'streaming' };
return defaultValue;
},
);
// Call the webhook method
const result = await chatTrigger.webhook(mockContext);
// Verify streaming headers are set
expect(mockResponse.writeHead).toHaveBeenCalledWith(200, {
'Content-Type': 'application/json; charset=utf-8',
'Transfer-Encoding': 'chunked',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
expect(mockResponse.flushHeaders).toHaveBeenCalled();
// Verify response structure for streaming
expect(result).toEqual({
workflowData: expect.any(Array),
noWebhookResponse: true,
});
});
it('should not enable streaming when responseMode is not "streaming"', async () => {
// Mock options with lastNode responseMode
mockContext.getNodeParameter.mockImplementation(
(
paramName: string,
defaultValue?: boolean | string | object,
): boolean | string | object | undefined => {
if (paramName === 'public') return true;
if (paramName === 'mode') return 'hostedChat';
if (paramName === 'options') return { responseMode: 'lastNode' };
return defaultValue;
},
);
// Call the webhook method
const result = await chatTrigger.webhook(mockContext);
// Verify streaming headers are NOT set
expect(mockResponse.writeHead).not.toHaveBeenCalled();
expect(mockResponse.flushHeaders).not.toHaveBeenCalled();
// Verify normal response structure
expect(result).toEqual({
webhookResponse: { status: 200 },
workflowData: expect.any(Array),
});
});
it('should enable streaming when availableInChat is true and responseMode is not set', async () => {
// Mock options with availableInChat true and no responseMode
mockContext.getNodeParameter.mockImplementation(
(
paramName: string,
defaultValue?: boolean | string | object,
): boolean | string | object | undefined => {
if (paramName === 'public') return true;
if (paramName === 'mode') return 'hostedChat';
if (paramName === 'options') return {};
if (paramName === 'availableInChat') return true;
return defaultValue;
},
);
// Call the webhook method
const result = await chatTrigger.webhook(mockContext);
// Verify streaming headers are set
expect(mockResponse.writeHead).toHaveBeenCalledWith(200, {
'Content-Type': 'application/json; charset=utf-8',
'Transfer-Encoding': 'chunked',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
expect(mockResponse.flushHeaders).toHaveBeenCalled();
// Verify response structure for streaming
expect(result).toEqual({
workflowData: expect.any(Array),
noWebhookResponse: true,
});
});
it('should enable streaming when availableInChat is true and responseMode is "streaming"', async () => {
// Mock options with availableInChat true and streaming responseMode
mockContext.getNodeParameter.mockImplementation(
(
paramName: string,
defaultValue?: boolean | string | object,
): boolean | string | object | undefined => {
if (paramName === 'public') return true;
if (paramName === 'mode') return 'hostedChat';
if (paramName === 'options') return { responseMode: 'streaming' };
if (paramName === 'availableInChat') return true;
return defaultValue;
},
);
// Call the webhook method
const result = await chatTrigger.webhook(mockContext);
// Verify streaming headers are set
expect(mockResponse.writeHead).toHaveBeenCalledWith(200, {
'Content-Type': 'application/json; charset=utf-8',
'Transfer-Encoding': 'chunked',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
expect(mockResponse.flushHeaders).toHaveBeenCalled();
// Verify response structure for streaming
expect(result).toEqual({
workflowData: expect.any(Array),
noWebhookResponse: true,
});
});
it('should handle multipart form data with streaming enabled', async () => {
// Mock multipart form data request
mockRequest.contentType = 'multipart/form-data';
mockRequest.body = {
data: { message: 'Hello' },
files: {},
};
// Mock options with streaming responseMode
mockContext.getNodeParameter.mockImplementation(
(
paramName: string,
defaultValue?: boolean | string | object,
): boolean | string | object | undefined => {
if (paramName === 'public') return true;
if (paramName === 'mode') return 'hostedChat';
if (paramName === 'options') return { responseMode: 'streaming' };
return defaultValue;
},
);
// Call the webhook method
const result = await chatTrigger.webhook(mockContext);
// Verify streaming headers are set
expect(mockResponse.writeHead).toHaveBeenCalledWith(200, {
'Content-Type': 'application/json; charset=utf-8',
'Transfer-Encoding': 'chunked',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
expect(mockResponse.flushHeaders).toHaveBeenCalled();
// Verify response structure for streaming
expect(result).toEqual({
workflowData: expect.any(Array),
noWebhookResponse: true,
});
});
});
});
@@ -0,0 +1,156 @@
import { mock } from 'jest-mock-extended';
import type { ICredentialDataDecryptedObject, IWebhookFunctions } from 'n8n-workflow';
import { ChatTriggerAuthorizationError } from '../error';
import { validateAuth } from '../GenericFunctions';
describe('validateAuth', () => {
const mockContext = mock<IWebhookFunctions>();
beforeEach(() => {
jest.clearAllMocks();
});
describe('authentication = none', () => {
it('should pass without error', async () => {
mockContext.getNodeParameter.calledWith('authentication').mockReturnValue('none');
await expect(validateAuth(mockContext)).resolves.toBeUndefined();
});
});
describe('authentication = basicAuth', () => {
beforeEach(() => {
mockContext.getNodeParameter.calledWith('authentication').mockReturnValue('basicAuth');
});
it('should throw 500 when credentials are not defined', async () => {
mockContext.getCredentials.mockRejectedValue(new Error('No credentials'));
await expect(validateAuth(mockContext)).rejects.toThrow(ChatTriggerAuthorizationError);
await expect(validateAuth(mockContext)).rejects.toMatchObject({
responseCode: 500,
});
});
it('should throw 401 when no auth header is provided', async () => {
mockContext.getCredentials.mockResolvedValue({
user: 'admin',
password: 'secret',
} as ICredentialDataDecryptedObject);
mockContext.getRequestObject.mockReturnValue({
headers: {},
} as never);
await expect(validateAuth(mockContext)).rejects.toThrow(ChatTriggerAuthorizationError);
await expect(validateAuth(mockContext)).rejects.toMatchObject({
responseCode: 401,
});
});
it('should throw 403 when credentials are wrong', async () => {
mockContext.getCredentials.mockResolvedValue({
user: 'admin',
password: 'secret',
} as ICredentialDataDecryptedObject);
mockContext.getRequestObject.mockReturnValue({
headers: {
authorization: 'Basic ' + Buffer.from('admin:wrong').toString('base64'),
},
} as never);
await expect(validateAuth(mockContext)).rejects.toThrow(ChatTriggerAuthorizationError);
await expect(validateAuth(mockContext)).rejects.toMatchObject({
responseCode: 403,
});
});
it('should pass with correct credentials', async () => {
mockContext.getCredentials.mockResolvedValue({
user: 'admin',
password: 'secret',
} as ICredentialDataDecryptedObject);
mockContext.getRequestObject.mockReturnValue({
headers: {
authorization: 'Basic ' + Buffer.from('admin:secret').toString('base64'),
},
} as never);
await expect(validateAuth(mockContext)).resolves.toBeUndefined();
});
});
describe('authentication = n8nUserAuth', () => {
beforeEach(() => {
mockContext.getNodeParameter.calledWith('authentication').mockReturnValue('n8nUserAuth');
});
it('should skip validation for setup webhook', async () => {
mockContext.getWebhookName.mockReturnValue('setup');
mockContext.getHeaderData.mockReturnValue({});
await expect(validateAuth(mockContext)).resolves.toBeUndefined();
});
it('should throw 401 when no n8n-auth cookie is present', async () => {
mockContext.getWebhookName.mockReturnValue('default');
mockContext.getHeaderData.mockReturnValue({});
await expect(validateAuth(mockContext)).rejects.toThrow(ChatTriggerAuthorizationError);
await expect(validateAuth(mockContext)).rejects.toMatchObject({
responseCode: 401,
message: 'User not authenticated!',
});
});
it('should throw 401 when cookie has a fake/invalid token', async () => {
mockContext.getWebhookName.mockReturnValue('default');
mockContext.getHeaderData.mockReturnValue({
cookie: 'n8n-auth=anything',
});
mockContext.validateCookieAuth.mockRejectedValue(new Error('Unauthorized'));
await expect(validateAuth(mockContext)).rejects.toThrow(ChatTriggerAuthorizationError);
await expect(validateAuth(mockContext)).rejects.toMatchObject({
responseCode: 401,
message: 'Invalid authentication token',
});
});
it('should throw 401 when validateCookieAuth rejects (revoked token)', async () => {
mockContext.getWebhookName.mockReturnValue('default');
mockContext.getHeaderData.mockReturnValue({
cookie: 'n8n-auth=some.revoked.token',
});
mockContext.validateCookieAuth.mockRejectedValue(new Error('Unauthorized'));
await expect(validateAuth(mockContext)).rejects.toThrow(ChatTriggerAuthorizationError);
await expect(validateAuth(mockContext)).rejects.toMatchObject({
responseCode: 401,
message: 'Invalid authentication token',
});
});
it('should pass with a valid token', async () => {
mockContext.getWebhookName.mockReturnValue('default');
mockContext.getHeaderData.mockReturnValue({
cookie: 'n8n-auth=valid.jwt.token',
});
mockContext.validateCookieAuth.mockResolvedValue(undefined);
await expect(validateAuth(mockContext)).resolves.toBeUndefined();
expect(mockContext.validateCookieAuth).toHaveBeenCalledWith('valid.jwt.token');
});
it('should pass when cookie has other cookies alongside n8n-auth', async () => {
mockContext.getWebhookName.mockReturnValue('default');
mockContext.getHeaderData.mockReturnValue({
cookie: 'other=value; n8n-auth=valid.jwt.token; another=thing',
});
mockContext.validateCookieAuth.mockResolvedValue(undefined);
await expect(validateAuth(mockContext)).resolves.toBeUndefined();
expect(mockContext.validateCookieAuth).toHaveBeenCalledWith('valid.jwt.token');
});
});
});
@@ -0,0 +1,380 @@
import { createPage, getSanitizedInitialMessages, getSanitizedI18nConfig } from '../templates';
describe('ChatTrigger Templates Security', () => {
const defaultParams = {
instanceId: 'test-instance',
webhookUrl: 'http://test.com/webhook',
showWelcomeScreen: false,
loadPreviousSession: 'notSupported' as const,
i18n: {
en: {},
},
mode: 'test' as const,
authentication: 'none' as const,
allowFileUploads: false,
allowedFilesMimeTypes: '',
customCss: '',
enableStreaming: false,
initialMessages: '',
};
describe('XSS Prevention in initialMessages', () => {
it('should prevent script injection through script context breakout', () => {
const maliciousInput = '</script>"%09<script>alert(document.cookie)</script>';
const result = createPage({
...defaultParams,
initialMessages: maliciousInput,
});
// Should not contain the malicious script
expect(result).not.toContain('<script>alert(document.cookie)</script>');
expect(result).not.toContain('</script>"%09<script>');
expect(result).not.toContain('alert(document.cookie)');
// Should contain initialMessages (the exact format is less important than security)
expect(result).toContain('initialMessages:');
// Should contain the tab character but not the dangerous script tags
expect(result).toContain('%09');
});
it('should sanitize common XSS payloads', () => {
const xssPayloads = [
{ input: '<img src=x onerror=alert(1)>', dangerous: ['onerror=', '<img'] },
{ input: '<svg onload=alert(1)>', dangerous: ['onload=', '<svg'] },
{ input: 'javascript:alert(1)', dangerous: ['javascript:'] },
{
input: '<iframe src="javascript:alert(1)"></iframe>',
dangerous: ['<iframe', 'javascript:'],
},
];
xssPayloads.forEach(({ input, dangerous }) => {
const result = createPage({
...defaultParams,
initialMessages: input,
});
// Should not contain dangerous HTML elements or protocols
dangerous.forEach((dangerousContent) => {
expect(result).not.toContain(dangerousContent);
});
});
});
it('should preserve legitimate messages', () => {
const legitimateMessages = [
'Hello, how can I help you?',
'Welcome to our chat service!',
'Please describe your issue.',
'Multi-line\nmessage content\nwith breaks',
];
legitimateMessages.forEach((message) => {
const result = createPage({
...defaultParams,
initialMessages: message,
});
// Should contain the sanitized legitimate content
const expectedLines = message
.split('\n')
.filter((line) => line)
.map((line) => line.trim());
expect(result).toContain(`initialMessages: ${JSON.stringify(expectedLines)}`);
});
});
it('should handle empty initialMessages', () => {
const result = createPage({
...defaultParams,
initialMessages: '',
});
// Should not include initialMessages property when empty
expect(result).not.toContain('initialMessages:');
});
it('should handle whitespace-only initialMessages', () => {
const result = createPage({
...defaultParams,
initialMessages: ' \n\n\t \n ',
});
// Should not include initialMessages property when only whitespace
expect(result).not.toContain('initialMessages:');
});
it('should filter empty lines and trim content', () => {
const result = createPage({
...defaultParams,
initialMessages: ' First message \n\n \n Second message \n',
});
// Should only include non-empty, trimmed lines
expect(result).toContain('initialMessages: ["First message","Second message"]');
});
});
describe('General Security', () => {
it('should not expose raw user input in HTML comments or other locations', () => {
const maliciousInput = '</script><script>alert("XSS")</script>';
const result = createPage({
...defaultParams,
initialMessages: maliciousInput,
});
// Should not appear anywhere in the HTML outside of the sanitized JSON
const lines = result.split('\n');
const unsafeLines = lines.filter(
(line) =>
line.includes('<script>alert("XSS")</script>') && !line.includes('initialMessages: ['),
);
expect(unsafeLines).toHaveLength(0);
});
});
describe('I18n XSS Prevention', () => {
it('should prevent script injection through i18n config values', () => {
const maliciousInput = '</script><script>alert(document.cookie)</script>';
const result = createPage({
...defaultParams,
initialMessages: '',
i18n: {
en: {
title: maliciousInput,
subtitle: maliciousInput,
getStarted: maliciousInput,
inputPlaceholder: maliciousInput,
},
},
});
// Should not contain the malicious script
expect(result).not.toContain('<script>alert(document.cookie)</script>');
expect(result).not.toContain('</script><script>');
expect(result).not.toContain('alert(document.cookie)');
// Should contain i18n config but sanitized
expect(result).toContain('i18n:');
});
it('should sanitize individual i18n fields', () => {
const xssPayload = '<img src=x onerror=alert(1)>';
const fields = ['title', 'subtitle', 'getStarted', 'inputPlaceholder'];
fields.forEach((field) => {
const config = { [field]: xssPayload };
const result = createPage({
...defaultParams,
initialMessages: '',
i18n: { en: config },
});
// Should not contain dangerous HTML
expect(result).not.toContain('onerror=');
expect(result).not.toContain('<img');
expect(result).not.toContain('alert(1)');
});
});
it('should preserve legitimate i18n content', () => {
const legitimateConfig = {
title: 'Welcome to Chat',
subtitle: 'How can we help you today?',
getStarted: 'Start Conversation',
inputPlaceholder: 'Type your message...',
};
const result = createPage({
...defaultParams,
initialMessages: '',
i18n: { en: legitimateConfig },
});
// Should contain the legitimate content
expect(result).toContain(JSON.stringify(legitimateConfig));
});
it('should handle empty i18n config', () => {
const result = createPage({
...defaultParams,
initialMessages: '',
i18n: { en: {} },
});
// Should still have i18n structure but no en property in the i18n config
expect(result).toContain('i18n: {');
expect(result).not.toContain('en: {');
});
});
describe('XSS Prevention in allowedFilesMimeTypes', () => {
it('should prevent script injection through allowedFilesMimeTypes', () => {
const maliciousInput = '</script><script>alert(document.cookie)</script>';
const result = createPage({
...defaultParams,
allowFileUploads: true,
allowedFilesMimeTypes: maliciousInput,
});
expect(result).not.toContain('<script>alert(document.cookie)</script>');
expect(result).not.toContain('</script><script>');
expect(result).not.toContain('alert(document.cookie)');
});
it('should sanitize common XSS payloads in allowedFilesMimeTypes', () => {
const xssPayloads = [
{ input: '<img src=x onerror=alert(1)>', dangerous: ['onerror=', '<img'] },
{ input: '<svg onload=alert(1)>', dangerous: ['onload=', '<svg'] },
{ input: 'javascript:alert(1)', dangerous: ['javascript:'] },
];
xssPayloads.forEach(({ input, dangerous }) => {
const result = createPage({
...defaultParams,
allowFileUploads: true,
allowedFilesMimeTypes: input,
});
dangerous.forEach((dangerousContent) => {
expect(result).not.toContain(dangerousContent);
});
});
});
it('should preserve legitimate MIME types', () => {
const legitimateMimeTypes = 'image/*,text/plain,application/pdf';
const result = createPage({
...defaultParams,
allowFileUploads: true,
allowedFilesMimeTypes: legitimateMimeTypes,
});
expect(result).toContain(legitimateMimeTypes);
});
});
describe('getSanitizedInitialMessages function', () => {
it('should sanitize XSS payloads', () => {
const maliciousInput = '</script>"%09<script>alert(document.cookie)</script>';
const result = getSanitizedInitialMessages(maliciousInput);
expect(result).toEqual(['"%09']);
expect(result.join('')).not.toContain('<script>');
expect(result.join('')).not.toContain('alert');
});
it('should remove dangerous protocols', () => {
const inputs = [
'javascript:alert(1)',
'data:text/html,<script>alert(1)</script>',
'vbscript:msgbox(1)',
];
inputs.forEach((input) => {
const result = getSanitizedInitialMessages(input);
const joined = result.join('');
expect(joined).not.toContain('javascript:');
expect(joined).not.toContain('data:');
expect(joined).not.toContain('vbscript:');
});
});
it('should preserve legitimate content', () => {
const input = 'Hello world!\nHow are you?\nGoodbye!';
const result = getSanitizedInitialMessages(input);
expect(result).toEqual(['Hello world!', 'How are you?', 'Goodbye!']);
});
it('should handle empty and whitespace-only input', () => {
expect(getSanitizedInitialMessages('')).toEqual([]);
expect(getSanitizedInitialMessages(' \n\n \t \n ')).toEqual([]);
});
it('should trim and filter empty lines', () => {
const input = ' First message \n\n \n Second message \n';
const result = getSanitizedInitialMessages(input);
expect(result).toEqual(['First message', 'Second message']);
});
});
describe('getSanitizedI18nConfig function', () => {
it('should sanitize XSS payloads in all values', () => {
const maliciousInput = '</script><script>alert(document.cookie)</script>';
const input = {
title: maliciousInput,
subtitle: maliciousInput,
getStarted: maliciousInput,
inputPlaceholder: maliciousInput,
};
const result = getSanitizedI18nConfig(input);
Object.values(result).forEach((value) => {
expect(value).not.toContain('<script>');
expect(value).not.toContain('alert');
expect(value).not.toContain('</script>');
});
});
it('should remove dangerous protocols', () => {
const input = {
title: 'javascript:alert(1)',
subtitle: 'data:text/html,<script>alert(1)</script>',
getStarted: 'vbscript:msgbox(1)',
};
const result = getSanitizedI18nConfig(input);
Object.values(result).forEach((value) => {
expect(value).not.toContain('javascript:');
expect(value).not.toContain('data:');
expect(value).not.toContain('vbscript:');
});
});
it('should preserve legitimate content', () => {
const input = {
title: 'Welcome to Chat',
subtitle: 'How can we help you today?',
getStarted: 'Start Conversation',
inputPlaceholder: 'Type your message...',
};
const result = getSanitizedI18nConfig(input);
expect(result).toEqual(input);
});
it('should handle empty object', () => {
const result = getSanitizedI18nConfig({});
expect(result).toEqual({});
});
it('should handle non-string values gracefully', () => {
const input = {
title: 'Valid title',
count: 123,
enabled: true,
obj: { test: 1 },
} as any;
const result = getSanitizedI18nConfig(input);
expect(result.title).toBe('Valid title');
expect(result.count).toBe('123');
expect(result.enabled).toBe('');
expect(result.obj).toBe('');
});
});
});
@@ -0,0 +1,79 @@
import { mock, mockDeep } from 'jest-mock-extended';
import * as sendAndWaitUtils from 'n8n-nodes-base/dist/utils/sendAndWait/utils';
import type { IExecuteFunctions, INode } from 'n8n-workflow';
import { ChatNodeMessageType, FREE_TEXT_CHAT_RESPONSE_TYPE } from 'n8n-workflow';
import { getChatMessage } from '../util';
describe('util', () => {
describe('getChatMessage', () => {
const ctx = mockDeep<IExecuteFunctions>();
beforeEach(() => {
jest.resetAllMocks();
});
it('should return a string for v1.0', () => {
ctx.getNode.mockReturnValue(mock<INode>({ typeVersion: 1.0 }));
ctx.getNodeParameter.mockReturnValue('test');
const message = getChatMessage(ctx);
expect(message).toBe('test');
});
it('should return a string for v1.1 with free text response type', () => {
ctx.getNode.mockReturnValue(mock<INode>({ typeVersion: 1.1 }));
ctx.getNodeParameter.mockImplementation((paramName) => {
switch (paramName) {
case 'responseType':
return FREE_TEXT_CHAT_RESPONSE_TYPE;
case 'message':
return 'test';
default:
return undefined;
}
});
const message = getChatMessage(ctx);
expect(message).toBe('test');
});
it('should return ChatNodeMessageWithButtons for v1.1 with approval response type', () => {
jest.spyOn(sendAndWaitUtils, 'getSendAndWaitConfig').mockReturnValue({
title: '',
message: '',
options: [
{ label: 'Disapprove', url: 'https://no.com', style: 'secondary' },
{ label: 'Approve', url: 'https://yes.com', style: 'primary' },
],
});
ctx.getNode.mockReturnValue(mock<INode>({ typeVersion: 1.1 }));
ctx.getNodeParameter.mockImplementation((paramName) => {
switch (paramName) {
case 'responseType':
return 'approval';
case 'message':
return 'test';
case 'blockUserInput':
return true;
default:
return undefined;
}
});
const message = getChatMessage(ctx);
expect(message).toEqual({
type: ChatNodeMessageType.WITH_BUTTONS,
text: 'test',
blockUserInput: true,
buttons: [
{ text: 'Approve', link: 'https://yes.com', type: 'primary' },
{ text: 'Disapprove', link: 'https://no.com', type: 'secondary' },
],
});
});
});
});
@@ -0,0 +1,128 @@
// CSS Variables are defined in `@n8n/chat/src/css/_tokens.scss`
export const cssVariables = `
:root {
/* Colors */
--chat--color--primary: #e74266;
--chat--color--primary-shade-50: #db4061;
--chat--color--primary--shade-100: #cf3c5c;
--chat--color--secondary: #20b69e;
--chat--color-secondary-shade-50: #1ca08a;
--chat--color-white: #fff;
--chat--color-light: #f2f4f8;
--chat--color-light-shade-50: #e6e9f1;
--chat--color-light-shade-100: #c2c5cc;
--chat--color-medium: #d2d4d9;
--chat--color-dark: #101330;
--chat--color-disabled: #d2d4d9;
--chat--color-typing: #404040;
/* Base Layout */
--chat--spacing: 1rem;
--chat--border-radius: 0.25rem;
--chat--transition-duration: 0.15s;
--chat--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif;
/* Window Dimensions */
--chat--window--width: 400px;
--chat--window--height: 600px;
--chat--window--bottom: var(--chat--spacing);
--chat--window--right: var(--chat--spacing);
--chat--window--z-index: 9999;
--chat--window--border: 1px solid var(--chat--color-light-shade-50);
--chat--window--border-radius: var(--chat--border-radius);
--chat--window--margin-bottom: var(--chat--spacing);
/* Header Styles */
--chat--header-height: auto;
--chat--header--padding: var(--chat--spacing);
--chat--header--background: var(--chat--color-dark);
--chat--header--color: var(--chat--color-light);
--chat--header--border-top: none;
--chat--header--border-bottom: none;
--chat--header--border-left: none;
--chat--header--border-right: none;
--chat--heading--font-size: 2em;
--chat--subtitle--font-size: inherit;
--chat--subtitle--line-height: 1.8;
/* Message Styles */
--chat--message--font-size: 1rem;
--chat--message--padding: var(--chat--spacing);
--chat--message--border-radius: var(--chat--border-radius);
--chat--message-line-height: 1.5;
--chat--message--margin-bottom: calc(var(--chat--spacing) * 1);
--chat--message--bot--background: var(--chat--color-white);
--chat--message--bot--color: var(--chat--color-dark);
--chat--message--bot--border: none;
--chat--message--user--background: var(--chat--color--secondary);
--chat--message--user--color: var(--chat--color-white);
--chat--message--user--border: none;
--chat--message--pre--background: rgba(0, 0, 0, 0.05);
--chat--messages-list--padding: var(--chat--spacing);
/* Toggle Button */
--chat--toggle--size: 64px;
--chat--toggle--width: var(--chat--toggle--size);
--chat--toggle--height: var(--chat--toggle--size);
--chat--toggle--border-radius: 50%;
--chat--toggle--background: var(--chat--color--primary);
--chat--toggle--hover--background: var(--chat--color--primary-shade-50);
--chat--toggle--active--background: var(--chat--color--primary--shade-100);
--chat--toggle--color: var(--chat--color-white);
/* Input Area */
--chat--textarea--height: 50px;
--chat--textarea--max-height: 30rem;
--chat--input--font-size: inherit;
--chat--input--border: 0;
--chat--input--border-radius: 0;
--chat--input--padding: 0.8rem;
--chat--input--background: var(--chat--color-white);
--chat--input--text-color: initial;
--chat--input--line-height: 1.5;
--chat--input--placeholder--font-size: var(--chat--input--font-size);
--chat--input--border-active: 0;
--chat--input--left--panel--width: 2rem;
/* Button Styles */
--chat--button--padding: calc(var(--chat--spacing) * 5 / 8) var(--chat--spacing);
--chat--button--border-radius: var(--chat--border-radius);
--chat--button--font-size: 1rem;
--chat--button--line-height: 1;
--chat--button--color--primary: var(--chat--color-light);
--chat--button--background--primary: var(--chat--color--secondary);
--chat--button--border--primary: none;
--chat--button--color--primary--hover: var(--chat--color-light);
--chat--button--background--primary--hover: var(--chat--color-secondary-shade-50);
--chat--button--border--primary--hover: none;
--chat--button--color--primary--disabled: var(--chat--color-light);
--chat--button--background--primary--disabled: #81bbb1;
--chat--button--border--primary--disabled: none;
--chat--button--color--secondary: var(--chat--color-light);
--chat--button--background--secondary: hsl(0, 0%, 58%);
--chat--button--border--secondary: none;
--chat--button--color--secondary--hover: var(--chat--color-light);
--chat--button--background--secondary--hover: hsl(0, 0%, 51%);
--chat--button--border--secondary--hover: none;
--chat--button--color--secondary--disabled: var(--chat--color-light);
--chat--button--background--secondary--disabled: hsl(0, 0%, 78%);
--chat--button--border--secondary--disabled: none;
--chat--close--button--color-hover: var(--chat--color--primary);
/* Send and File Buttons */
--chat--input--send--button--background: var(--chat--color-white);
--chat--input--send--button--color: var(--chat--color--secondary);
--chat--input--send--button--background-hover: var(--chat--color--primary-shade-50);
--chat--input--send--button--color-hover: var(--chat--color-secondary-shade-50);
--chat--input--file--button--background: var(--chat--color-white);
--chat--input--file--button--color: var(--chat--color--secondary);
--chat--input--file--button--background-hover: var(--chat--input--file--button--background);
--chat--input--file--button--color-hover: var(--chat--color-secondary-shade-50);
--chat--files-spacing: 0.25rem;
/* Body and Footer */
--chat--body--background: var(--chat--color-light);
--chat--footer--background: var(--chat--color-light);
--chat--footer--color: var(--chat--color-dark);
}
`;
@@ -0,0 +1,18 @@
import { ApplicationError } from '@n8n/errors';
export class ChatTriggerAuthorizationError extends ApplicationError {
constructor(
readonly responseCode: number,
message?: string,
) {
if (message === undefined) {
message = 'Authorization problem!';
if (responseCode === 401) {
message = 'Authorization is required!';
} else if (responseCode === 403) {
message = 'Authorization data is wrong!';
}
}
super(message);
}
}
@@ -0,0 +1,169 @@
import sanitizeHtml from 'sanitize-html';
import type { AuthenticationChatOption, LoadPreviousSessionChatOption } from './types';
function sanitizeUserInput(input: string): string {
// Sanitize HTML tags and entities
let sanitized = sanitizeHtml(input, {
allowedTags: [],
allowedAttributes: {},
});
// Remove dangerous protocols
sanitized = sanitized.replace(/javascript:/gi, '');
sanitized = sanitized.replace(/data:/gi, '');
sanitized = sanitized.replace(/vbscript:/gi, '');
return sanitized;
}
export function getSanitizedInitialMessages(initialMessages: string): string[] {
const sanitizedString = sanitizeUserInput(initialMessages);
return sanitizedString
.split('\n')
.map((line) => line.trim())
.filter((line) => line !== '');
}
export function getSanitizedI18nConfig(config: Record<string, string>): Record<string, string> {
const sanitized: Record<string, string> = {};
for (const [key, value] of Object.entries<string>(config)) {
sanitized[key] = sanitizeUserInput(value);
}
return sanitized;
}
export function createPage({
instanceId,
webhookUrl,
showWelcomeScreen,
loadPreviousSession,
i18n: { en },
initialMessages,
authentication,
allowFileUploads,
allowedFilesMimeTypes,
customCss,
enableStreaming,
}: {
instanceId: string;
webhookUrl?: string;
showWelcomeScreen?: boolean;
loadPreviousSession?: LoadPreviousSessionChatOption;
i18n: {
en: Record<string, string>;
};
initialMessages: string;
mode: 'test' | 'production';
authentication: AuthenticationChatOption;
allowFileUploads?: boolean;
allowedFilesMimeTypes?: string;
customCss?: string;
enableStreaming?: boolean;
}) {
const validAuthenticationOptions: AuthenticationChatOption[] = [
'none',
'basicAuth',
'n8nUserAuth',
];
const validLoadPreviousSessionOptions: LoadPreviousSessionChatOption[] = [
'manually',
'memory',
'notSupported',
];
const sanitizedAuthentication = validAuthenticationOptions.includes(authentication)
? authentication
: 'none';
const sanitizedShowWelcomeScreen = !!showWelcomeScreen;
const sanitizedAllowFileUploads = !!allowFileUploads;
const sanitizedAllowedFilesMimeTypes = sanitizeUserInput(allowedFilesMimeTypes?.toString() ?? '');
const sanitizedCustomCss = sanitizeHtml(`<style>${customCss?.toString() ?? ''}</style>`, {
allowedTags: ['style'],
allowedAttributes: false,
});
const sanitizedLoadPreviousSession = validLoadPreviousSessionOptions.includes(
loadPreviousSession as LoadPreviousSessionChatOption,
)
? loadPreviousSession
: 'notSupported';
const sanitizedInitialMessages = getSanitizedInitialMessages(initialMessages);
const sanitizedI18nConfig = getSanitizedI18nConfig(en || {});
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Chat</title>
<link href="https://cdn.jsdelivr.net/npm/normalize.css@8.0.1/normalize.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/@n8n/chat/dist/style.css" rel="stylesheet" />
<style>
html,
body,
#n8n-chat {
width: 100%;
height: 100%;
}
</style>
${sanitizedCustomCss}
</head>
<body>
<script type="module">
import { createChat } from 'https://cdn.jsdelivr.net/npm/@n8n/chat/dist/chat.bundle.es.js';
(async function () {
const authentication = '${sanitizedAuthentication}';
let metadata;
if (authentication === 'n8nUserAuth') {
try {
const response = await fetch('/rest/login', {
method: 'GET',
headers: { 'browser-id': localStorage.getItem('n8n-browserId') }
});
if (response.status !== 200) {
throw new Error('Not logged in');
}
const responseData = await response.json();
metadata = {
user: {
id: responseData.data.id,
firstName: responseData.data.firstName,
lastName: responseData.data.lastName,
email: responseData.data.email,
},
};
} catch (error) {
window.location.href = '/signin?redirect=' + window.location.href;
return;
}
}
createChat({
mode: 'fullscreen',
webhookUrl: '${webhookUrl}',
showWelcomeScreen: ${sanitizedShowWelcomeScreen},
loadPreviousSession: ${sanitizedLoadPreviousSession !== 'notSupported'},
metadata: metadata,
webhookConfig: {
headers: {
'X-Instance-Id': '${instanceId}',
}
},
allowFileUploads: ${sanitizedAllowFileUploads},
allowedFilesMimeTypes: ${JSON.stringify(sanitizedAllowedFilesMimeTypes)},
i18n: {
${Object.keys(sanitizedI18nConfig).length ? `en: ${JSON.stringify(sanitizedI18nConfig)},` : ''}
},
${sanitizedInitialMessages.length ? `initialMessages: ${JSON.stringify(sanitizedInitialMessages)},` : ''}
enableStreaming: ${!!enableStreaming},
});
})();
</script>
</body>
</html>`;
}
@@ -0,0 +1,19 @@
import type { INode } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
const validOptions = ['notSupported', 'memory', 'manually'] as const;
export type AuthenticationChatOption = 'none' | 'basicAuth' | 'n8nUserAuth';
export type LoadPreviousSessionChatOption = (typeof validOptions)[number];
function isValidLoadPreviousSessionOption(value: unknown): value is LoadPreviousSessionChatOption {
return typeof value === 'string' && (validOptions as readonly string[]).includes(value);
}
export function assertValidLoadPreviousSessionOption(
value: string | undefined,
node: INode,
): asserts value is LoadPreviousSessionChatOption | undefined {
if (value && !isValidLoadPreviousSessionOption(value)) {
throw new NodeOperationError(node, `Invalid loadPreviousSession option: ${value}`);
}
}
@@ -0,0 +1,160 @@
import {
getSendAndWaitConfig,
getSendAndWaitProperties,
} from 'n8n-nodes-base/dist/utils/sendAndWait/utils';
import {
ChatNodeMessageType,
FREE_TEXT_CHAT_RESPONSE_TYPE,
NodeOperationError,
UserError,
WAIT_INDEFINITELY,
} from 'n8n-workflow';
import type {
ChatNodeMessage,
ChatNodeMessageButtonType,
IExecuteFunctions,
INodeProperties,
INodePropertyOptions,
} from 'n8n-workflow';
export function configureWaitTillDate(context: IExecuteFunctions) {
let waitTill = WAIT_INDEFINITELY;
const limitOptions = context.getNodeParameter('options.limitWaitTime.values', 0, {}) as {
limitType?: string;
resumeAmount?: number;
resumeUnit?: string;
maxDateAndTime?: string;
};
if (Object.keys(limitOptions).length) {
try {
if (limitOptions.limitType === 'afterTimeInterval') {
let waitAmount = limitOptions.resumeAmount as number;
if (limitOptions.resumeUnit === 'minutes') {
waitAmount *= 60;
}
if (limitOptions.resumeUnit === 'hours') {
waitAmount *= 60 * 60;
}
if (limitOptions.resumeUnit === 'days') {
waitAmount *= 60 * 60 * 24;
}
waitAmount *= 1000;
waitTill = new Date(new Date().getTime() + waitAmount);
} else {
waitTill = new Date(limitOptions.maxDateAndTime as string);
}
if (isNaN(waitTill.getTime())) {
throw new UserError('Invalid date format');
}
} catch (error) {
throw new NodeOperationError(context.getNode(), 'Could not configure Limit Wait Time', {
description: error.message,
});
}
}
return waitTill;
}
export const configureInputs = (parameters: { options?: { memoryConnection?: boolean } }) => {
const inputs = [
{
type: 'main',
},
];
if (parameters.options?.memoryConnection) {
return [
...inputs,
{
type: 'ai_memory',
displayName: 'Memory',
maxConnections: 1,
},
];
}
return inputs;
};
const freeTextResponseTypeOption: INodePropertyOptions = {
name: 'Free Text',
// use a different name to not show options for `freeText` response type
value: FREE_TEXT_CHAT_RESPONSE_TYPE,
description: 'User can submit a response in the chat',
};
const blockUserInput: INodeProperties = {
displayName: 'Block User Input',
name: 'blockUserInput',
type: 'boolean',
default: false,
description: 'Whether to block input from the user while waiting for approval',
displayOptions: {
show: {
responseType: ['approval'],
},
},
};
export const getSendAndWaitPropertiesForChatNode = () => {
const originalProperties = getSendAndWaitProperties([], null);
const filteredProperties = originalProperties.filter(
// `subject` is not needed and we provide our own `message` and `options` properties
(p) => p.name !== 'subject' && p.name !== 'message' && p.name !== 'options',
);
const responseTypeProperty = filteredProperties.find((p) => p.name === 'responseType');
if (responseTypeProperty) {
const approvalOption = responseTypeProperty.options?.find(
(o) => 'value' in o && o.value === 'approval',
);
responseTypeProperty.options = approvalOption
? [
// for now we only support `approval` and `freeText` response types
approvalOption,
freeTextResponseTypeOption,
]
: [freeTextResponseTypeOption];
responseTypeProperty.default = FREE_TEXT_CHAT_RESPONSE_TYPE;
}
filteredProperties.splice(1, 0, blockUserInput);
return filteredProperties;
};
export function getChatMessage(ctx: IExecuteFunctions): ChatNodeMessage {
const nodeVersion = ctx.getNode().typeVersion;
const message = ctx.getNodeParameter('message', 0, '') as string;
if (nodeVersion < 1.1) {
return message;
}
const responseType = ctx.getNodeParameter(
'responseType',
0,
FREE_TEXT_CHAT_RESPONSE_TYPE,
) as string;
if (responseType === FREE_TEXT_CHAT_RESPONSE_TYPE) {
// for free text, we just return the message
// since the user will respond with the text in the chat
return message;
}
const blockUserInput = ctx.getNodeParameter('blockUserInput', 0, false) as boolean;
const config = getSendAndWaitConfig(ctx);
return {
type: ChatNodeMessageType.WITH_BUTTONS,
text: message,
blockUserInput,
// the buttons are reversed to show the primary button first
buttons: [...config.options].reverse().map((option) => ({
text: option.label,
link: option.url,
type: option.style as ChatNodeMessageButtonType,
})),
};
}