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
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:
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
+313
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+156
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user