mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 01:56:46 +00:00
feat: Add Chat Trigger node (#7409)
Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> Co-authored-by: Jan Oberhauser <jan.oberhauser@gmail.com> Co-authored-by: Jesper Bylund <mail@jesperbylund.com> Co-authored-by: OlegIvaniv <me@olegivaniv.com> Co-authored-by: Deborah <deborah@starfallprojects.co.uk> Co-authored-by: Jan Oberhauser <janober@users.noreply.github.com> Co-authored-by: Jon <jonathan.bennetts@gmail.com> Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in> Co-authored-by: Michael Kret <88898367+michael-radency@users.noreply.github.com> Co-authored-by: Giulio Andreini <andreini@netseven.it> Co-authored-by: Mason Geloso <Mason.geloso@gmail.com> Co-authored-by: Mason Geloso <hone@Masons-Mac-mini.local> Co-authored-by: Mutasem Aldmour <mutasem@n8n.io>
This commit is contained in:
@@ -0,0 +1,428 @@
|
||||
import {
|
||||
type IDataObject,
|
||||
type IWebhookFunctions,
|
||||
type IWebhookResponseData,
|
||||
type INodeType,
|
||||
type INodeTypeDescription,
|
||||
NodeConnectionType,
|
||||
} from 'n8n-workflow';
|
||||
import { pick } from 'lodash';
|
||||
import type { BaseChatMemory } from 'langchain/memory';
|
||||
import { createPage } from './templates';
|
||||
import { validateAuth } from './GenericFunctions';
|
||||
import type { LoadPreviousSessionChatOption } from './types';
|
||||
|
||||
const CHAT_TRIGGER_PATH_IDENTIFIER = 'chat';
|
||||
|
||||
export class ChatTrigger implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Chat Trigger',
|
||||
name: 'chatTrigger',
|
||||
icon: 'fa:comments',
|
||||
group: ['trigger'],
|
||||
version: 1,
|
||||
description: 'Runs the workflow when an n8n generated webchat is submitted',
|
||||
defaults: {
|
||||
name: 'Chat Trigger',
|
||||
},
|
||||
codex: {
|
||||
categories: ['Core Nodes'],
|
||||
resources: {
|
||||
primaryDocumentation: [
|
||||
{
|
||||
url: 'https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-langchain.chattrigger/',
|
||||
},
|
||||
],
|
||||
},
|
||||
subcategories: {
|
||||
'Core Nodes': ['Other Trigger Nodes'],
|
||||
},
|
||||
},
|
||||
supportsCORS: true,
|
||||
maxNodes: 1,
|
||||
inputs: `={{ (() => {
|
||||
if (!['hostedChat', 'webhook'].includes($parameter.mode)) {
|
||||
return [];
|
||||
}
|
||||
if ($parameter.options?.loadPreviousSession !== 'memory') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
displayName: 'Memory',
|
||||
maxConnections: 1,
|
||||
type: '${NodeConnectionType.AiMemory}',
|
||||
required: true,
|
||||
}
|
||||
];
|
||||
})() }}`,
|
||||
outputs: ['main'],
|
||||
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"] || "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 you activate this workflow. 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 activate 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',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
displayOptions: {
|
||||
show: {
|
||||
mode: ['hostedChat', 'webhook'],
|
||||
public: [true],
|
||||
},
|
||||
},
|
||||
placeholder: 'Add Field',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
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',
|
||||
},
|
||||
{
|
||||
displayName: 'Response Mode',
|
||||
name: 'responseMode',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'When Last Node Finishes',
|
||||
value: 'lastNode',
|
||||
description: 'Returns data of the last-executed node',
|
||||
},
|
||||
{
|
||||
name: "Using 'Respond to Webhook' Node",
|
||||
value: 'responseNode',
|
||||
description: 'Response defined in that node',
|
||||
},
|
||||
],
|
||||
default: 'lastNode',
|
||||
description: 'When and how to respond to the webhook',
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
|
||||
const res = this.getResponseObject();
|
||||
|
||||
const isPublic = this.getNodeParameter('public', false) as boolean;
|
||||
const nodeMode = this.getNodeParameter('mode', 'hostedChat') as string;
|
||||
if (!isPublic) {
|
||||
res.status(404).end();
|
||||
return {
|
||||
noWebhookResponse: true,
|
||||
};
|
||||
}
|
||||
|
||||
const webhookName = this.getWebhookName();
|
||||
const mode = this.getMode() === 'manual' ? 'test' : 'production';
|
||||
const bodyData = this.getBodyData() ?? {};
|
||||
|
||||
const options = this.getNodeParameter('options', {}) as {
|
||||
getStarted?: string;
|
||||
inputPlaceholder?: string;
|
||||
loadPreviousSession?: LoadPreviousSessionChatOption;
|
||||
showWelcomeScreen?: boolean;
|
||||
subtitle?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
if (nodeMode === 'hostedChat') {
|
||||
try {
|
||||
await validateAuth(this);
|
||||
} catch (error) {
|
||||
if (error) {
|
||||
res.writeHead(error.responseCode as number, {
|
||||
'www-authenticate': 'Basic realm="Webhook"',
|
||||
});
|
||||
res.end(error.message as string);
|
||||
return { noWebhookResponse: true };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Show the chat on GET request
|
||||
if (webhookName === 'setup') {
|
||||
const webhookUrlRaw = this.getNodeWebhookUrl('default') as string;
|
||||
const webhookUrl =
|
||||
mode === 'test' ? webhookUrlRaw.replace('/webhook', '/webhook-test') : webhookUrlRaw;
|
||||
const authentication = this.getNodeParameter('authentication') as
|
||||
| 'none'
|
||||
| 'basicAuth'
|
||||
| 'n8nUserAuth';
|
||||
const initialMessagesRaw = this.getNodeParameter('initialMessages', '') as string;
|
||||
const initialMessages = initialMessagesRaw
|
||||
.split('\n')
|
||||
.filter((line) => line)
|
||||
.map((line) => line.trim());
|
||||
const instanceId = this.getInstanceId();
|
||||
|
||||
const i18nConfig = pick(options, ['getStarted', 'inputPlaceholder', 'subtitle', 'title']);
|
||||
|
||||
const page = createPage({
|
||||
i18n: {
|
||||
en: i18nConfig,
|
||||
},
|
||||
showWelcomeScreen: options.showWelcomeScreen,
|
||||
loadPreviousSession: options.loadPreviousSession,
|
||||
initialMessages,
|
||||
webhookUrl,
|
||||
mode,
|
||||
instanceId,
|
||||
authentication,
|
||||
});
|
||||
|
||||
res.status(200).send(page).end();
|
||||
return {
|
||||
noWebhookResponse: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (bodyData.action === 'loadPreviousSession') {
|
||||
if (options?.loadPreviousSession === 'memory') {
|
||||
const memory = (await this.getInputConnectionData(NodeConnectionType.AiMemory, 0)) as
|
||||
| BaseChatMemory
|
||||
| undefined;
|
||||
const messages = ((await memory?.chatHistory.getMessages()) ?? []).map(
|
||||
(message) => message?.toJSON(),
|
||||
);
|
||||
return {
|
||||
webhookResponse: { data: messages },
|
||||
};
|
||||
} else if (options?.loadPreviousSession === 'notSupported') {
|
||||
// If messages of a previous session should not be loaded, simply return an empty array
|
||||
return {
|
||||
webhookResponse: { data: [] },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const returnData: IDataObject = { ...bodyData };
|
||||
const webhookResponse: IDataObject = { status: 200 };
|
||||
return {
|
||||
webhookResponse,
|
||||
workflowData: [this.helpers.returnJsonArray(returnData)],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { ICredentialDataDecryptedObject, IWebhookFunctions } from 'n8n-workflow';
|
||||
import basicAuth from 'basic-auth';
|
||||
import { ChatTriggerAuthorizationError } from './error';
|
||||
import type { AuthenticationChatOption } from './types';
|
||||
|
||||
export async function validateAuth(context: IWebhookFunctions) {
|
||||
const authentication = context.getNodeParameter('authentication') 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('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();
|
||||
|
||||
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 && webhookName !== 'setup') {
|
||||
// Data is not defined on node so can not authenticate
|
||||
throw new ChatTriggerAuthorizationError(500, 'User not authenticated!');
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export class ChatTriggerAuthorizationError extends Error {
|
||||
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,106 @@
|
||||
import type { AuthenticationChatOption, LoadPreviousSessionChatOption } from './types';
|
||||
|
||||
export function createPage({
|
||||
instanceId,
|
||||
webhookUrl,
|
||||
showWelcomeScreen,
|
||||
loadPreviousSession,
|
||||
i18n: { en },
|
||||
initialMessages,
|
||||
authentication,
|
||||
}: {
|
||||
instanceId: string;
|
||||
webhookUrl?: string;
|
||||
showWelcomeScreen?: boolean;
|
||||
loadPreviousSession?: LoadPreviousSessionChatOption;
|
||||
i18n: {
|
||||
en: Record<string, string>;
|
||||
};
|
||||
initialMessages: string[];
|
||||
mode: 'test' | 'production';
|
||||
authentication: AuthenticationChatOption;
|
||||
}) {
|
||||
const validAuthenticationOptions: AuthenticationChatOption[] = [
|
||||
'none',
|
||||
'basicAuth',
|
||||
'n8nUserAuth',
|
||||
];
|
||||
const validLoadPreviousSessionOptions: LoadPreviousSessionChatOption[] = [
|
||||
'manually',
|
||||
'memory',
|
||||
'notSupported',
|
||||
];
|
||||
|
||||
const sanitizedAuthentication = validAuthenticationOptions.includes(authentication)
|
||||
? authentication
|
||||
: 'none';
|
||||
const sanitizedShowWelcomeScreen = !!showWelcomeScreen;
|
||||
const sanitizedLoadPreviousSession = validLoadPreviousSessionOptions.includes(
|
||||
loadPreviousSession as LoadPreviousSessionChatOption,
|
||||
)
|
||||
? loadPreviousSession
|
||||
: 'notSupported';
|
||||
|
||||
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/style.css" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<script type="module">
|
||||
import { createChat } from 'https://cdn.jsdelivr.net/npm/@n8n/chat@latest/chat.bundle.es.js';
|
||||
|
||||
(async function () {
|
||||
const authentication = '${sanitizedAuthentication}';
|
||||
let metadata;
|
||||
if (authentication === 'n8nUserAuth') {
|
||||
try {
|
||||
const response = await fetch('/rest/login', {
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
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: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Instance-Id': '${instanceId}',
|
||||
}
|
||||
},
|
||||
i18n: {
|
||||
${en ? `en: ${JSON.stringify(en)},` : ''}
|
||||
},
|
||||
${initialMessages.length ? `initialMessages: ${JSON.stringify(initialMessages)},` : ''}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type AuthenticationChatOption = 'none' | 'basicAuth' | 'n8nUserAuth';
|
||||
export type LoadPreviousSessionChatOption = 'manually' | 'memory' | 'notSupported';
|
||||
Reference in New Issue
Block a user