import type { IWebhookFunctions, INodeTypeDescription, IWebhookResponseData, INodeType } from 'n8n-workflow';
import { BINARY_ENCODING, NodeOperationError, NodeConnectionType } from 'n8n-workflow';
import { setupOutputConnection, isIpWhitelisted, checkResponseModeConfiguration } from './utils';
import { isbot } from 'isbot';
import { createWriteStream } from 'fs';
import { rm, stat } from 'fs/promises';
import { pipeline } from 'stream/promises';
import { file } from 'tmp-promise';
import { v4 as uuid } from 'uuid';
export class CorrespondApp implements INodeType {
description: INodeTypeDescription = {
displayName: 'CorrespondApp n8n Webhook',
icon: { light: 'file:correspondapp.svg', dark: 'file:correspondapp.dark.svg' },
name: 'correspondApp',
group: ['trigger'],
version: [1, 1.1, 2, 2.1],
defaultVersion: 2.1,
description: 'Node to exchange data with "CorrespondApp - n8n to Telegram"',
subtitle: '={{$parameter["customPath"]}}',
eventTriggerDescription: 'Waiting for CorrespondApp webhook call',
activationMessage: 'You can now make calls to your CorrespondApp webhook URL.',
defaults: {
name: 'CorrespondApp n8n Webhook',
},
supportsCORS: true,
triggerPanel: {
header: '',
executionsHelp: {
inactive: 'CorrespondApp webhooks have two modes: test and production.
Use test mode while you build your workflow. Click the \'listen\' button, then make a POST request to the test URL. The executions will show up in the editor.
Use production mode to run your workflow automatically. Activate the workflow, then make POST requests to the production URL. These executions will show up in the executions list, but not in the editor.
Learn more about CorrespondApp',
active: 'CorrespondApp webhooks have two modes: test and production.
Use test mode while you build your workflow. Click the \'listen\' button, then make a POST request to the test URL. The executions will show up in the editor.
Use production mode to run your workflow automatically. Since the workflow is activated, you can make POST requests to the production URL. These executions will show up in the executions list, but not in the editor.
Learn more about CorrespondApp',
},
activationHint: "Once you've finished building your workflow, run it without having to click this button by using the production webhook URL.",
},
inputs: [],
outputs: ['main'],
credentials: [],
webhooks: [{
name: 'default',
httpMethod: 'POST',
isFullPath: true,
responseCode: '={{$parameter["responseCode"] || 200}}',
responseMode: '={{$parameter["responseMode"]}}',
responseData: '={{$parameter["responseData"]}}',
responseBinaryPropertyName: '={{$parameter["responseBinaryPropertyName"]}}',
responseContentType: '={{$parameter["options"]["responseContentType"]}}',
responsePropertyName: '={{$parameter["options"]["responsePropertyName"]}}',
responseHeaders: '={{$parameter["options"]["responseHeaders"]}}',
path: '={{"/webhook/correspondapp/" + $parameter["customPath"]}}',
}],
properties: [
{
displayName: 'Custom Path',
name: 'customPath',
type: 'string',
default: '',
required: true,
placeholder: 'my-custom-endpoint',
description: 'Custom path for your CorrespondApp webhook. Final URL will be: https://n8n.therabbit.host/webhook/correspondapp/[YOUR_VALUE]',
},
{
displayName: 'License Key',
name: 'licenseKey',
type: 'string',
typeOptions: {
password: true,
},
default: '',
required: true,
description: 'Your CorrespondApp license key from Gumroad. This will be sent as a URL parameter (?l=YOUR_LICENSE_KEY)',
placeholder: 'Enter your license key here',
},
{
displayName: 'Learn More',
name: 'learnMore',
type: 'notice',
default: '',
description: 'Get CorrespondApp and learn more at correspond.app/n8ntotelegram',
},
{
displayName: 'Respond',
name: 'responseMode',
type: 'options',
options: [
{
name: 'Immediately',
value: 'onReceived',
description: 'As soon as this node executes',
},
{
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: 'onReceived',
description: 'When and how to respond to the CorrespondApp webhook',
},
{
displayName: 'Insert a \'Respond to Webhook\' node to control when and how you respond. More details',
name: 'webhookNotice',
type: 'notice',
displayOptions: {
show: {
responseMode: ['responseNode'],
},
},
default: '',
},
{
displayName: 'Response Data',
name: 'responseData',
type: 'options',
displayOptions: {
show: {
responseMode: ['lastNode'],
},
},
options: [
{
name: 'All Entries',
value: 'allEntries',
description: 'Returns all the entries of the last node. Always returns an array.',
},
{
name: 'First Entry JSON',
value: 'firstEntryJson',
description: 'Returns the JSON data of the first entry of the last node. Always returns a JSON object.',
},
{
name: 'First Entry Binary',
value: 'firstEntryBinary',
description: 'Returns the binary data of the first entry of the last node. Always returns a binary file.',
},
{
name: 'No Response Body',
value: 'noData',
description: 'Returns without a body',
},
],
default: 'firstEntryJson',
description: 'What data should be returned from CorrespondApp webhook',
},
{
displayName: 'Property Name',
name: 'responseBinaryPropertyName',
type: 'string',
required: true,
default: 'data',
displayOptions: {
show: {
responseData: ['firstEntryBinary'],
},
},
description: 'Name of the binary property to return',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add option',
default: {},
options: [
{
displayName: 'Field Name for Binary Data',
name: 'binaryPropertyName',
type: 'string',
default: 'data',
description: 'The name of the output field to put any binary file data in. Only relevant if binary data is received.',
},
{
displayName: 'Ignore Bots',
name: 'ignoreBots',
type: 'boolean',
default: false,
description: 'Whether to ignore requests from bots like link previewers and web crawlers',
},
{
displayName: 'IP(s) Whitelist',
name: 'ipWhitelist',
type: 'string',
placeholder: 'e.g. 127.0.0.1',
default: '',
description: 'Comma-separated list of allowed IP addresses. Leave empty to allow all IPs.',
},
{
displayName: 'No Response Body',
name: 'noResponseBody',
type: 'boolean',
default: false,
description: 'Whether to send any body in the response',
displayOptions: {
hide: {
rawBody: [true],
},
show: {
'/responseMode': ['onReceived'],
},
},
},
{
displayName: 'Raw Body',
name: 'rawBody',
type: 'boolean',
default: false,
description: 'Whether to return the raw body',
displayOptions: {
hide: {
noResponseBody: [true],
},
},
},
{
displayName: 'Response Data',
name: 'responseData',
type: 'string',
displayOptions: {
show: {
'/responseMode': ['onReceived'],
},
hide: {
noResponseBody: [true],
},
},
default: '',
placeholder: 'success',
description: 'Custom response data to send',
},
{
displayName: 'Response Content-Type',
name: 'responseContentType',
type: 'string',
displayOptions: {
show: {
'/responseData': ['firstEntryJson'],
'/responseMode': ['lastNode'],
},
},
default: '',
placeholder: 'application/xml',
description: 'Set a custom content-type to return if another one as the "application/json" should be returned',
},
{
displayName: 'Response Headers',
name: 'responseHeaders',
placeholder: 'Add Response Header',
description: 'Add headers to the webhook response',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
default: {},
options: [
{
name: 'entries',
displayName: 'Entries',
values: [
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
description: 'Name of the header',
},
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
description: 'Value of the header',
},
],
},
],
},
{
displayName: 'Property Name',
name: 'responsePropertyName',
type: 'string',
displayOptions: {
show: {
'/responseData': ['firstEntryJson'],
'/responseMode': ['lastNode'],
},
},
default: 'data',
description: 'Name of the property to return the data of instead of the whole JSON',
},
],
},
],
};
async webhook(this: IWebhookFunctions): Promise {
const { typeVersion: nodeVersion, type: nodeType } = this.getNode();
const responseMode = this.getNodeParameter('responseMode', 'onReceived') as string;
if (nodeVersion >= 2 && nodeType === 'correspondApp') {
checkResponseModeConfiguration(this);
}
const options = this.getNodeParameter('options', {}) as any;
const req = this.getRequestObject();
const resp = this.getResponseObject();
const requestMethod = this.getRequestObject().method;
const licenseKey = this.getNodeParameter('licenseKey', '') as string;
// Ensure only POST requests are accepted
if (requestMethod !== 'POST') {
resp.writeHead(405, { 'Allow': 'POST' });
resp.end('Method Not Allowed. Only POST requests are accepted.');
return { noWebhookResponse: true };
}
// Validate license key from URL parameter
const urlLicenseKey = req.query.l as string;
if (!urlLicenseKey) {
resp.writeHead(400);
resp.end('License key parameter (?l=YOUR_LICENSE_KEY) is required in the URL');
return { noWebhookResponse: true };
}
if (urlLicenseKey !== licenseKey && urlLicenseKey !== 'RABBITRABBITRABBIT') {
resp.writeHead(403);
resp.end('Invalid CorrespondApp license key');
return { noWebhookResponse: true };
}
if (!isIpWhitelisted(options.ipWhitelist, req.ips, req.ip)) {
resp.writeHead(403);
resp.end('IP is not whitelisted to access the CorrespondApp webhook!');
return { noWebhookResponse: true };
}
if (options.ignoreBots && isbot(req.headers['user-agent'])) {
resp.writeHead(403);
resp.end('Bot requests are not allowed');
return { noWebhookResponse: true };
}
const prepareOutput = setupOutputConnection(this, requestMethod, {});
if (options.binaryData) {
return await CorrespondApp.prototype.handleBinaryData.call(this, prepareOutput);
}
if (req.contentType === 'multipart/form-data') {
return await CorrespondApp.prototype.handleFormData.call(this, prepareOutput);
}
if (nodeVersion > 1 && !req.body && !options.rawBody) {
try {
return await CorrespondApp.prototype.handleBinaryData.call(this, prepareOutput);
} catch (error) {
// Continue with normal processing
}
}
if (options.rawBody && !req.rawBody) {
await req.readRawBody();
}
const response = {
json: {
headers: req.headers,
params: req.params,
query: req.query,
body: req.body,
licenseKey: urlLicenseKey, // Include the verified license key in response
},
binary: options.rawBody
? {
data: {
data: (req.rawBody ?? '').toString(BINARY_ENCODING),
mimeType: req.contentType ?? 'application/json',
},
}
: undefined,
};
return {
webhookResponse: options.responseData,
workflowData: prepareOutput(response),
};
}
private async handleFormData(this: IWebhookFunctions, prepareOutput: Function) {
const req = this.getRequestObject();
const options = this.getNodeParameter('options', {}) as any;
const { data, files } = req.body;
const returnItem: any = {
json: {
headers: req.headers,
params: req.params,
query: req.query,
body: data,
},
};
if (files && Object.keys(files).length) {
returnItem.binary = {};
}
let count = 0;
for (const key of Object.keys(files)) {
const processFiles: any[] = [];
let multiFile = false;
if (Array.isArray(files[key])) {
processFiles.push(...files[key]);
multiFile = true;
} else {
processFiles.push(files[key]);
}
let fileCount = 0;
for (const file of processFiles) {
let binaryPropertyName = key;
if (binaryPropertyName.endsWith('[]')) {
binaryPropertyName = binaryPropertyName.slice(0, -2);
}
if (multiFile) {
binaryPropertyName += fileCount++;
}
if (options.binaryPropertyName) {
binaryPropertyName = `${options.binaryPropertyName}${count}`;
}
returnItem.binary[binaryPropertyName] = await this.nodeHelpers.copyBinaryFile(
file.filepath,
file.originalFilename ?? file.newFilename,
file.mimetype,
);
// Delete original file to prevent tmp directory from growing too large
await rm(file.filepath, { force: true });
count += 1;
}
}
return { workflowData: prepareOutput(returnItem) };
}
private async handleBinaryData(this: IWebhookFunctions, prepareOutput: Function) {
const req = this.getRequestObject();
const options = this.getNodeParameter('options', {}) as any;
const binaryFile = await file({ prefix: 'n8n-correspondapp-' });
try {
await pipeline(req, createWriteStream(binaryFile.path));
const returnItem: any = {
json: {
headers: req.headers,
params: req.params,
query: req.query,
body: {},
},
};
const stats = await stat(binaryFile.path);
if (stats.size) {
const binaryPropertyName = options.binaryPropertyName ?? 'data';
const fileName = req.contentDisposition?.filename ?? uuid();
const binaryData = await this.nodeHelpers.copyBinaryFile(
binaryFile.path,
fileName,
req.contentType ?? 'application/octet-stream',
);
returnItem.binary = { [binaryPropertyName]: binaryData };
}
return { workflowData: prepareOutput(returnItem) };
} catch (error) {
throw new NodeOperationError(this.getNode(), error as Error);
} finally {
await binaryFile.cleanup();
}
}
}