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

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,328 @@
import { mockDeep } from 'jest-mock-extended';
import type { IExecuteFunctions, IDataObject } from 'n8n-workflow';
import { NodeOperationError, jsonParse } from 'n8n-workflow';
import { prepareMultiPartForm } from '../utils';
describe('Discord V2 Utils', () => {
describe('prepareMultiPartForm', () => {
let mockExecuteFunctions: IExecuteFunctions;
beforeEach(() => {
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
});
afterEach(() => {
jest.resetAllMocks();
});
it('should create multipart form with single file', async () => {
const files: IDataObject[] = [{ inputFieldName: 'file1' }];
const jsonPayload: IDataObject = { content: 'Test message' };
const itemIndex = 0;
const binaryData = {
data: 'base64data',
mimeType: 'image/png',
fileName: 'test.png',
fileExtension: 'png',
};
mockExecuteFunctions.helpers.assertBinaryData = jest.fn().mockReturnValue(binaryData);
mockExecuteFunctions.helpers.getBinaryDataBuffer = jest
.fn()
.mockResolvedValue(Buffer.from('test file content'));
mockExecuteFunctions.getNode = jest.fn().mockReturnValue({ name: 'Discord' });
const result = await prepareMultiPartForm.call(
mockExecuteFunctions,
files,
jsonPayload,
itemIndex,
);
expect(mockExecuteFunctions.helpers.assertBinaryData).toHaveBeenCalledWith(0, 'file1');
expect(mockExecuteFunctions.helpers.getBinaryDataBuffer).toHaveBeenCalledWith(0, 'file1');
expect(result).toBeDefined();
});
it('should create multipart form with multiple files', async () => {
const files: IDataObject[] = [{ inputFieldName: 'file1' }, { inputFieldName: 'file2' }];
const jsonPayload: IDataObject = { content: 'Test message with multiple files' };
const itemIndex = 0;
const binaryData1 = {
data: 'base64data1',
mimeType: 'image/png',
fileName: 'test1.png',
fileExtension: 'png',
};
const binaryData2 = {
data: 'base64data2',
mimeType: 'image/jpeg',
fileName: 'test2.jpg',
fileExtension: 'jpg',
};
mockExecuteFunctions.helpers.assertBinaryData = jest
.fn()
.mockReturnValueOnce(binaryData1)
.mockReturnValueOnce(binaryData2);
mockExecuteFunctions.helpers.getBinaryDataBuffer = jest
.fn()
.mockResolvedValueOnce(Buffer.from('test file content 1'))
.mockResolvedValueOnce(Buffer.from('test file content 2'));
mockExecuteFunctions.getNode = jest.fn().mockReturnValue({ name: 'Discord' });
const result = await prepareMultiPartForm.call(
mockExecuteFunctions,
files,
jsonPayload,
itemIndex,
);
expect(mockExecuteFunctions.helpers.assertBinaryData).toHaveBeenCalledTimes(2);
expect(mockExecuteFunctions.helpers.getBinaryDataBuffer).toHaveBeenCalledTimes(2);
expect(result).toBeDefined();
});
it('should add file extension from fileExtension property when filename has no extension', async () => {
const files: IDataObject[] = [{ inputFieldName: 'file1' }];
const jsonPayload: IDataObject = { content: 'Test message' };
const itemIndex = 0;
const binaryData = {
data: 'base64data',
mimeType: 'image/png',
fileName: 'testfile',
fileExtension: 'png',
};
mockExecuteFunctions.helpers.assertBinaryData = jest.fn().mockReturnValue(binaryData);
mockExecuteFunctions.helpers.getBinaryDataBuffer = jest
.fn()
.mockResolvedValue(Buffer.from('test file content'));
mockExecuteFunctions.getNode = jest.fn().mockReturnValue({ name: 'Discord' });
const result = await prepareMultiPartForm.call(
mockExecuteFunctions,
files,
jsonPayload,
itemIndex,
);
expect(result).toBeDefined();
});
it('should add file extension from mimeType when filename has no extension and fileExtension is missing', async () => {
const files: IDataObject[] = [{ inputFieldName: 'file1' }];
const jsonPayload: IDataObject = { content: 'Test message' };
const itemIndex = 0;
const binaryData = {
data: 'base64data',
mimeType: 'image/jpeg',
fileName: 'testfile',
};
mockExecuteFunctions.helpers.assertBinaryData = jest.fn().mockReturnValue(binaryData);
mockExecuteFunctions.helpers.getBinaryDataBuffer = jest
.fn()
.mockResolvedValue(Buffer.from('test file content'));
mockExecuteFunctions.getNode = jest.fn().mockReturnValue({ name: 'Discord' });
const result = await prepareMultiPartForm.call(
mockExecuteFunctions,
files,
jsonPayload,
itemIndex,
);
expect(result).toBeDefined();
});
it('should throw error when binary data is not found', async () => {
const files: IDataObject[] = [{ inputFieldName: 'file1' }];
const jsonPayload: IDataObject = { content: 'Test message' };
const itemIndex = 0;
mockExecuteFunctions.helpers.assertBinaryData = jest.fn().mockReturnValue(null);
mockExecuteFunctions.getNode = jest.fn().mockReturnValue({ name: 'Discord' });
await expect(
prepareMultiPartForm.call(mockExecuteFunctions, files, jsonPayload, itemIndex),
).rejects.toThrow(NodeOperationError);
await expect(
prepareMultiPartForm.call(mockExecuteFunctions, files, jsonPayload, itemIndex),
).rejects.toThrow('Input item [0] does not contain binary data on property file1');
});
it('should handle file with complete filename including extension', async () => {
const files: IDataObject[] = [{ inputFieldName: 'file1' }];
const jsonPayload: IDataObject = { content: 'Test message' };
const itemIndex = 0;
const binaryData = {
data: 'base64data',
mimeType: 'application/pdf',
fileName: 'document.pdf',
fileExtension: 'pdf',
};
mockExecuteFunctions.helpers.assertBinaryData = jest.fn().mockReturnValue(binaryData);
mockExecuteFunctions.helpers.getBinaryDataBuffer = jest
.fn()
.mockResolvedValue(Buffer.from('pdf content'));
mockExecuteFunctions.getNode = jest.fn().mockReturnValue({ name: 'Discord' });
const result = await prepareMultiPartForm.call(
mockExecuteFunctions,
files,
jsonPayload,
itemIndex,
);
expect(result).toBeDefined();
});
it('should include attachments in payload_json with correct structure', async () => {
const files: IDataObject[] = [{ inputFieldName: 'file1' }, { inputFieldName: 'file2' }];
const jsonPayload: IDataObject = { content: 'Test with attachments' };
const itemIndex = 0;
const binaryData1 = {
data: 'base64data1',
mimeType: 'image/png',
fileName: 'image1.png',
};
const binaryData2 = {
data: 'base64data2',
mimeType: 'text/plain',
fileName: 'text.txt',
};
mockExecuteFunctions.helpers.assertBinaryData = jest
.fn()
.mockReturnValueOnce(binaryData1)
.mockReturnValueOnce(binaryData2);
mockExecuteFunctions.helpers.getBinaryDataBuffer = jest
.fn()
.mockResolvedValueOnce(Buffer.from('image content'))
.mockResolvedValueOnce(Buffer.from('text content'));
mockExecuteFunctions.getNode = jest.fn().mockReturnValue({ name: 'Discord' });
const result = await prepareMultiPartForm.call(
mockExecuteFunctions,
files,
jsonPayload,
itemIndex,
);
expect(result).toBeDefined();
const payloadJsonField = result
.getBuffer()
.toString()
.match(
/Content-Disposition: form-data; name="payload_json"[\s\S]*?\r\n\r\n([\s\S]*?)\r\n--/,
);
expect(payloadJsonField).not.toBeNull();
const payloadJson = jsonParse<{
content: string;
attachments: Array<{ id: number; filename: string }>;
}>(payloadJsonField![1]);
expect(payloadJson.content).toBe('Test with attachments');
expect(payloadJson.attachments).toBeDefined();
expect(Array.isArray(payloadJson.attachments)).toBe(true);
expect(payloadJson.attachments).toHaveLength(2);
expect(payloadJson.attachments[0]).toEqual({
id: 0,
filename: 'image1.png',
});
expect(payloadJson.attachments[1]).toEqual({
id: 1,
filename: 'text.txt',
});
});
it('should handle empty files array', async () => {
const files: IDataObject[] = [];
const jsonPayload: IDataObject = { content: 'Test message no files' };
const itemIndex = 0;
mockExecuteFunctions.getNode = jest.fn().mockReturnValue({ name: 'Discord' });
const result = await prepareMultiPartForm.call(
mockExecuteFunctions,
files,
jsonPayload,
itemIndex,
);
expect(result).toBeDefined();
});
it('should properly format filename when only mimeType is available', async () => {
const files: IDataObject[] = [{ inputFieldName: 'file1' }];
const jsonPayload: IDataObject = { content: 'Test message' };
const itemIndex = 0;
const binaryData = {
data: 'base64data',
mimeType: 'application/json',
fileName: 'data',
};
mockExecuteFunctions.helpers.assertBinaryData = jest.fn().mockReturnValue(binaryData);
mockExecuteFunctions.helpers.getBinaryDataBuffer = jest
.fn()
.mockResolvedValue(Buffer.from('json content'));
mockExecuteFunctions.getNode = jest.fn().mockReturnValue({ name: 'Discord' });
const result = await prepareMultiPartForm.call(
mockExecuteFunctions,
files,
jsonPayload,
itemIndex,
);
expect(result).toBeDefined();
});
it('should handle different item indices correctly', async () => {
const files: IDataObject[] = [{ inputFieldName: 'file1' }];
const jsonPayload: IDataObject = { content: 'Test message' };
const itemIndex = 2;
const binaryData = {
data: 'base64data',
mimeType: 'image/png',
fileName: 'test.png',
};
mockExecuteFunctions.helpers.assertBinaryData = jest.fn().mockReturnValue(binaryData);
mockExecuteFunctions.helpers.getBinaryDataBuffer = jest
.fn()
.mockResolvedValue(Buffer.from('test file content'));
mockExecuteFunctions.getNode = jest.fn().mockReturnValue({ name: 'Discord' });
const result = await prepareMultiPartForm.call(
mockExecuteFunctions,
files,
jsonPayload,
itemIndex,
);
expect(mockExecuteFunctions.helpers.assertBinaryData).toHaveBeenCalledWith(2, 'file1');
expect(mockExecuteFunctions.helpers.getBinaryDataBuffer).toHaveBeenCalledWith(2, 'file1');
expect(result).toBeDefined();
});
});
});
@@ -0,0 +1,419 @@
import FormData from 'form-data';
import isEmpty from 'lodash/isEmpty';
import { extension } from 'mime-types';
import type { IDataObject, IExecuteFunctions, INode } from 'n8n-workflow';
import { jsonParse, NodeApiError, NodeOperationError } from 'n8n-workflow';
import { getSendAndWaitConfig } from '../../../../utils/sendAndWait/utils';
import { capitalize, createUtmCampaignLink } from '../../../../utils/utilities';
import { discordApiMultiPartRequest, discordApiRequest } from '../transport';
export const createSimplifyFunction =
(includedFields: string[]) =>
(item: IDataObject): IDataObject => {
const result: IDataObject = {};
for (const field of includedFields) {
if (item[field] === undefined) continue;
result[field] = item[field];
}
return result;
};
export function parseDiscordError(this: IExecuteFunctions, error: any, itemIndex = 0) {
let errorData = error.cause?.error;
const errorOptions: IDataObject = { itemIndex };
error.description =
Array.isArray(error.messages) && error.messages.length ? error.messages[0] : error.description;
if (!errorData && error.description) {
try {
const errorString = (error.description as string).split(' - ')[1];
if (errorString) {
errorData = jsonParse(errorString);
}
} catch (err) {}
}
if (errorData?.message) {
errorOptions.message = errorData.message;
}
if ((error?.message as string)?.toLowerCase()?.includes('bad request') && errorData) {
if (errorData?.message) {
errorOptions.message = errorData.message;
}
if (errorData?.errors?.embeds) {
const embedErrors = errorData.errors.embeds?.[0];
const embedErrorsKeys = Object.keys(embedErrors).map((key) => capitalize(key));
if (embedErrorsKeys.length) {
const message =
embedErrorsKeys.length === 1
? `The parameter ${embedErrorsKeys[0]} is not properly formatted`
: `The parameters ${embedErrorsKeys.join(', ')} are not properly formatted`;
errorOptions.message = message;
errorOptions.description = 'Review the formatting or clear it';
}
return new NodeOperationError(this.getNode(), errorData.errors, errorOptions);
}
if (errorData?.errors?.message_reference) {
errorOptions.message = "The message to reply to ID can't be found";
errorOptions.description =
'Check the "Message to Reply to" parameter and remove it if you don\'t want to reply to an existing message';
return new NodeOperationError(this.getNode(), errorData.errors, errorOptions);
}
if (errorOptions.message === 'Cannot send an empty message') {
errorOptions.description =
'Something has to be send to the channel whether it is a message, an embed or a file';
}
}
return new NodeOperationError(this.getNode(), errorData || error, errorOptions);
}
export function prepareErrorData(this: IExecuteFunctions, error: any, i: number) {
let description = error.description;
try {
description = JSON.parse(error.description as string);
} catch (err) {}
return this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ error: error.message, description }),
{ itemData: { item: i } },
);
}
export function prepareOptions(options: IDataObject, guildId?: string) {
if (options.flags) {
if ((options.flags as string[]).length === 2) {
options.flags = (1 << 2) + (1 << 12);
} else if ((options.flags as string[]).includes('SUPPRESS_EMBEDS')) {
options.flags = 1 << 2;
} else if ((options.flags as string[]).includes('SUPPRESS_NOTIFICATIONS')) {
options.flags = 1 << 12;
}
}
if (options.message_reference) {
options.message_reference = {
message_id: options.message_reference,
guild_id: guildId,
};
}
return options;
}
export function prepareEmbeds(this: IExecuteFunctions, embeds: IDataObject[]) {
return embeds
.map((embed) => {
let embedReturnData: IDataObject = {};
if (embed.inputMethod === 'json') {
if (typeof embed.json === 'object') {
embedReturnData = embed.json as IDataObject;
}
try {
embedReturnData = jsonParse(embed.json as string);
} catch (error) {
throw new NodeOperationError(this.getNode(), 'Not a valid JSON', error);
}
} else {
delete embed.inputMethod;
for (const key of Object.keys(embed)) {
if (embed[key] !== '') {
embedReturnData[key] = embed[key];
}
}
}
if (embedReturnData.author) {
embedReturnData.author = {
name: embedReturnData.author,
};
}
if (embedReturnData.color && typeof embedReturnData.color === 'string') {
embedReturnData.color = parseInt(embedReturnData.color.replace('#', ''), 16);
}
if (embedReturnData.video) {
embedReturnData.video = {
url: embedReturnData.video,
width: 1270,
height: 720,
};
}
if (embedReturnData.thumbnail) {
embedReturnData.thumbnail = {
url: embedReturnData.thumbnail,
};
}
if (embedReturnData.image) {
embedReturnData.image = {
url: embedReturnData.image,
};
}
return embedReturnData;
})
.filter((embed) => !isEmpty(embed));
}
export async function prepareMultiPartForm(
this: IExecuteFunctions,
files: IDataObject[],
jsonPayload: IDataObject,
i: number,
) {
const multiPartBody = new FormData();
const attachments: IDataObject[] = [];
const filesData: IDataObject[] = [];
for (const [index, file] of files.entries()) {
const binaryData = this.helpers.assertBinaryData(i, file.inputFieldName as string);
if (!binaryData) {
throw new NodeOperationError(
this.getNode(),
`Input item [${i}] does not contain binary data on property ${file.inputFieldName}`,
);
}
let filename = binaryData.fileName as string;
if (!filename.includes('.')) {
if (binaryData.fileExtension) {
filename += `.${binaryData.fileExtension}`;
}
if (binaryData.mimeType) {
filename += `.${extension(binaryData.mimeType)}`;
}
}
attachments.push({
id: index,
filename,
});
filesData.push({
data: await this.helpers.getBinaryDataBuffer(i, file.inputFieldName as string),
name: filename,
mime: binaryData.mimeType,
});
}
multiPartBody.append('payload_json', JSON.stringify({ ...jsonPayload, attachments }), {
contentType: 'application/json',
});
for (const [index, binaryData] of filesData.entries()) {
multiPartBody.append(`files[${index}]`, binaryData.data, {
contentType: binaryData.name as string,
filename: binaryData.mime as string,
});
}
return multiPartBody;
}
export function checkAccessToGuild(
node: INode,
guildId: string,
userGuilds: IDataObject[],
itemIndex = 0,
) {
if (!userGuilds.some((guild) => guild.id === guildId)) {
throw new NodeOperationError(
node,
`You do not have access to the guild with the id ${guildId}`,
{
itemIndex,
level: 'warning',
},
);
}
}
export async function checkAccessToChannel(
this: IExecuteFunctions,
channelId: string,
userGuilds: IDataObject[],
itemIndex = 0,
) {
let guildId = '';
try {
const channel = await discordApiRequest.call(this, 'GET', `/channels/${channelId}`);
guildId = channel.guild_id;
} catch (error) {}
if (!guildId) {
throw new NodeOperationError(
this.getNode(),
`Could not find server for channel with the id ${channelId}`,
{
itemIndex,
},
);
}
checkAccessToGuild(this.getNode(), guildId, userGuilds, itemIndex);
}
export async function setupChannelGetter(this: IExecuteFunctions, userGuilds: IDataObject[]) {
const isOAuth2 = this.getNodeParameter('authentication', 0) === 'oAuth2';
return async (i: number) => {
const channelId = this.getNodeParameter('channelId', i, undefined, {
extractValue: true,
}) as string;
if (isOAuth2) await checkAccessToChannel.call(this, channelId, userGuilds, i);
return channelId;
};
}
export async function sendDiscordMessage(
this: IExecuteFunctions,
{
guildId,
userGuilds,
isOAuth2,
body,
files = [],
itemIndex = 0,
}: {
guildId: string;
userGuilds: IDataObject[];
isOAuth2: boolean;
body: IDataObject;
files?: IDataObject[];
itemIndex?: number;
},
) {
const sendTo = this.getNodeParameter('sendTo', itemIndex) as string;
let channelId = '';
if (sendTo === 'user') {
const userId = this.getNodeParameter('userId', itemIndex, undefined, {
extractValue: true,
}) as string;
if (isOAuth2) {
try {
await discordApiRequest.call(this, 'GET', `/guilds/${guildId}/members/${userId}`);
} catch (error) {
if (error instanceof NodeApiError && error.httpCode === '404') {
throw new NodeOperationError(
this.getNode(),
`User with the id ${userId} is not a member of the selected guild`,
{
itemIndex,
},
);
}
throw new NodeOperationError(this.getNode(), error, {
itemIndex,
});
}
}
channelId = (
(await discordApiRequest.call(this, 'POST', '/users/@me/channels', {
recipient_id: userId,
})) as IDataObject
).id as string;
if (!channelId) {
throw new NodeOperationError(
this.getNode(),
'Could not create a channel to send direct message to',
{ itemIndex },
);
}
}
if (sendTo === 'channel') {
channelId = this.getNodeParameter('channelId', itemIndex, undefined, {
extractValue: true,
}) as string;
}
if (isOAuth2 && sendTo !== 'user') {
await checkAccessToChannel.call(this, channelId, userGuilds, itemIndex);
}
if (!channelId) {
throw new NodeOperationError(this.getNode(), 'Channel ID is required', {
itemIndex,
});
}
let response: IDataObject[] = [];
if (files?.length) {
const multiPartBody = await prepareMultiPartForm.call(this, files, body, itemIndex);
response = await discordApiMultiPartRequest.call(
this,
'POST',
`/channels/${channelId}/messages`,
multiPartBody,
);
} else {
response = await discordApiRequest.call(this, 'POST', `/channels/${channelId}/messages`, body);
}
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(response),
{ itemData: { item: itemIndex } },
);
return executionData;
}
export function createSendAndWaitMessageBody(context: IExecuteFunctions) {
const config = getSendAndWaitConfig(context);
let description = config.message;
if (config.appendAttribution !== false) {
const instanceId = context.getInstanceId();
const attributionText = 'This message was sent automatically with ';
const link = createUtmCampaignLink('n8n-nodes-base.discord', instanceId);
description = `${config.message}\n\n_${attributionText}_[n8n](${link})`;
}
const body = {
embeds: [
{
description,
color: 5814783,
},
],
components: [
{
type: 1,
components: config.options.map((option) => {
return {
type: 2,
style: 5,
label: option.label,
url: option.url,
};
}),
},
],
};
return body;
}