From 6a53c2a375ca71ffad1491da5ae7e6ec461a1a56 Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Wed, 8 Nov 2023 16:11:23 +0200 Subject: [PATCH] feat(Discord Node): Overhaul (#5351) Github issue / Community forum post (link here to close automatically): --------- Co-authored-by: Giulio Andreini Co-authored-by: Marcus --- packages/editor-ui/src/constants.ts | 2 + .../credentials/DiscordBotApi.credentials.ts | 43 ++ .../DiscordOAuth2Api.credentials.ts | 56 +++ .../DiscordWebhookApi.credentials.ts | 23 + .../nodes-base/nodes/Discord/Discord.node.ts | 288 +---------- .../test/v2/node/channel/create.test.ts | 66 +++ .../test/v2/node/channel/create.workflow.json | 106 ++++ .../v2/node/channel/deleteChannel.test.ts | 62 +++ .../node/channel/deleteChannel.workflow.json | 112 +++++ .../Discord/test/v2/node/channel/get.test.ts | 112 +++++ .../test/v2/node/channel/get.workflow.json | 113 +++++ .../test/v2/node/channel/getAll.test.ts | 141 ++++++ .../test/v2/node/channel/getAll.workflow.json | 191 +++++++ .../test/v2/node/channel/update.test.ts | 69 +++ .../test/v2/node/channel/update.workflow.json | 126 +++++ .../test/v2/node/member/getAll.test.ts | 90 ++++ .../test/v2/node/member/getAll.workflow.json | 134 +++++ .../test/v2/node/member/roleAdd.test.ts | 54 ++ .../test/v2/node/member/roleAdd.workflow.json | 104 ++++ .../test/v2/node/member/roleRemove.test.ts | 62 +++ .../v2/node/member/roleRemove.workflow.json | 106 ++++ .../v2/node/message/deleteMessage.test.ts | 54 ++ .../node/message/deleteMessage.workflow.json | 103 ++++ .../Discord/test/v2/node/message/get.test.ts | 73 +++ .../test/v2/node/message/get.workflow.json | 125 +++++ .../test/v2/node/message/getAll.test.ts | 98 ++++ .../test/v2/node/message/getAll.workflow.json | 144 ++++++ .../test/v2/node/message/react.test.ts | 54 ++ .../test/v2/node/message/react.workflow.json | 104 ++++ .../Discord/test/v2/node/message/send.test.ts | 107 ++++ .../test/v2/node/message/send.workflow.json | 155 ++++++ .../test/v2/node/webhook/sendLegacy.test.ts | 104 ++++ .../v2/node/webhook/sendLegacy.workflow.json | 141 ++++++ .../nodes/Discord/test/v2/utils.test.ts | 160 ++++++ .../nodes/Discord/v1/DiscordV1.node.ts | 291 +++++++++++ .../nodes/Discord/{ => v1}/Interfaces.ts | 0 .../nodes/Discord/v2/DiscordV2.node.ts | 35 ++ .../v2/actions/channel/create.operation.ts | 206 ++++++++ .../channel/deleteChannel.operation.ts | 61 +++ .../v2/actions/channel/get.operation.ts | 61 +++ .../v2/actions/channel/getAll.operation.ts | 96 ++++ .../nodes/Discord/v2/actions/channel/index.ts | 72 +++ .../v2/actions/channel/update.operation.ts | 153 ++++++ .../Discord/v2/actions/common.description.ts | 466 ++++++++++++++++++ .../v2/actions/member/getAll.operation.ts | 121 +++++ .../nodes/Discord/v2/actions/member/index.ts | 56 +++ .../v2/actions/member/roleAdd.operation.ts | 63 +++ .../v2/actions/member/roleRemove.operation.ts | 63 +++ .../message/deleteMessage.operation.ts | 63 +++ .../v2/actions/message/get.operation.ts | 97 ++++ .../v2/actions/message/getAll.operation.ts | 124 +++++ .../nodes/Discord/v2/actions/message/index.ts | 72 +++ .../v2/actions/message/react.operation.ts | 79 +++ .../v2/actions/message/send.operation.ts | 228 +++++++++ .../nodes/Discord/v2/actions/node.type.ts | 10 + .../nodes/Discord/v2/actions/router.ts | 68 +++ .../Discord/v2/actions/versionDescription.ts | 106 ++++ .../nodes/Discord/v2/actions/webhook/index.ts | 29 ++ .../actions/webhook/sendLegacy.operation.ts | 167 +++++++ .../nodes/Discord/v2/helpers/utils.ts | 287 +++++++++++ .../nodes/Discord/v2/methods/index.ts | 2 + .../nodes/Discord/v2/methods/listSearch.ts | 164 ++++++ .../nodes/Discord/v2/methods/loadOptions.ts | 46 ++ .../nodes/Discord/v2/transport/discord.api.ts | 101 ++++ .../nodes/Discord/v2/transport/helpers.ts | 47 ++ .../nodes/Discord/v2/transport/index.ts | 2 + packages/nodes-base/package.json | 3 + packages/nodes-base/utils/descriptions.ts | 25 + packages/nodes-base/utils/utilities.ts | 13 +- 69 files changed, 6688 insertions(+), 271 deletions(-) create mode 100644 packages/nodes-base/credentials/DiscordBotApi.credentials.ts create mode 100644 packages/nodes-base/credentials/DiscordOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/credentials/DiscordWebhookApi.credentials.ts create mode 100644 packages/nodes-base/nodes/Discord/test/v2/node/channel/create.test.ts create mode 100644 packages/nodes-base/nodes/Discord/test/v2/node/channel/create.workflow.json create mode 100644 packages/nodes-base/nodes/Discord/test/v2/node/channel/deleteChannel.test.ts create mode 100644 packages/nodes-base/nodes/Discord/test/v2/node/channel/deleteChannel.workflow.json create mode 100644 packages/nodes-base/nodes/Discord/test/v2/node/channel/get.test.ts create mode 100644 packages/nodes-base/nodes/Discord/test/v2/node/channel/get.workflow.json create mode 100644 packages/nodes-base/nodes/Discord/test/v2/node/channel/getAll.test.ts create mode 100644 packages/nodes-base/nodes/Discord/test/v2/node/channel/getAll.workflow.json create mode 100644 packages/nodes-base/nodes/Discord/test/v2/node/channel/update.test.ts create mode 100644 packages/nodes-base/nodes/Discord/test/v2/node/channel/update.workflow.json create mode 100644 packages/nodes-base/nodes/Discord/test/v2/node/member/getAll.test.ts create mode 100644 packages/nodes-base/nodes/Discord/test/v2/node/member/getAll.workflow.json create mode 100644 packages/nodes-base/nodes/Discord/test/v2/node/member/roleAdd.test.ts create mode 100644 packages/nodes-base/nodes/Discord/test/v2/node/member/roleAdd.workflow.json create mode 100644 packages/nodes-base/nodes/Discord/test/v2/node/member/roleRemove.test.ts create mode 100644 packages/nodes-base/nodes/Discord/test/v2/node/member/roleRemove.workflow.json create mode 100644 packages/nodes-base/nodes/Discord/test/v2/node/message/deleteMessage.test.ts create mode 100644 packages/nodes-base/nodes/Discord/test/v2/node/message/deleteMessage.workflow.json create mode 100644 packages/nodes-base/nodes/Discord/test/v2/node/message/get.test.ts create mode 100644 packages/nodes-base/nodes/Discord/test/v2/node/message/get.workflow.json create mode 100644 packages/nodes-base/nodes/Discord/test/v2/node/message/getAll.test.ts create mode 100644 packages/nodes-base/nodes/Discord/test/v2/node/message/getAll.workflow.json create mode 100644 packages/nodes-base/nodes/Discord/test/v2/node/message/react.test.ts create mode 100644 packages/nodes-base/nodes/Discord/test/v2/node/message/react.workflow.json create mode 100644 packages/nodes-base/nodes/Discord/test/v2/node/message/send.test.ts create mode 100644 packages/nodes-base/nodes/Discord/test/v2/node/message/send.workflow.json create mode 100644 packages/nodes-base/nodes/Discord/test/v2/node/webhook/sendLegacy.test.ts create mode 100644 packages/nodes-base/nodes/Discord/test/v2/node/webhook/sendLegacy.workflow.json create mode 100644 packages/nodes-base/nodes/Discord/test/v2/utils.test.ts create mode 100644 packages/nodes-base/nodes/Discord/v1/DiscordV1.node.ts rename packages/nodes-base/nodes/Discord/{ => v1}/Interfaces.ts (100%) create mode 100644 packages/nodes-base/nodes/Discord/v2/DiscordV2.node.ts create mode 100644 packages/nodes-base/nodes/Discord/v2/actions/channel/create.operation.ts create mode 100644 packages/nodes-base/nodes/Discord/v2/actions/channel/deleteChannel.operation.ts create mode 100644 packages/nodes-base/nodes/Discord/v2/actions/channel/get.operation.ts create mode 100644 packages/nodes-base/nodes/Discord/v2/actions/channel/getAll.operation.ts create mode 100644 packages/nodes-base/nodes/Discord/v2/actions/channel/index.ts create mode 100644 packages/nodes-base/nodes/Discord/v2/actions/channel/update.operation.ts create mode 100644 packages/nodes-base/nodes/Discord/v2/actions/common.description.ts create mode 100644 packages/nodes-base/nodes/Discord/v2/actions/member/getAll.operation.ts create mode 100644 packages/nodes-base/nodes/Discord/v2/actions/member/index.ts create mode 100644 packages/nodes-base/nodes/Discord/v2/actions/member/roleAdd.operation.ts create mode 100644 packages/nodes-base/nodes/Discord/v2/actions/member/roleRemove.operation.ts create mode 100644 packages/nodes-base/nodes/Discord/v2/actions/message/deleteMessage.operation.ts create mode 100644 packages/nodes-base/nodes/Discord/v2/actions/message/get.operation.ts create mode 100644 packages/nodes-base/nodes/Discord/v2/actions/message/getAll.operation.ts create mode 100644 packages/nodes-base/nodes/Discord/v2/actions/message/index.ts create mode 100644 packages/nodes-base/nodes/Discord/v2/actions/message/react.operation.ts create mode 100644 packages/nodes-base/nodes/Discord/v2/actions/message/send.operation.ts create mode 100644 packages/nodes-base/nodes/Discord/v2/actions/node.type.ts create mode 100644 packages/nodes-base/nodes/Discord/v2/actions/router.ts create mode 100644 packages/nodes-base/nodes/Discord/v2/actions/versionDescription.ts create mode 100644 packages/nodes-base/nodes/Discord/v2/actions/webhook/index.ts create mode 100644 packages/nodes-base/nodes/Discord/v2/actions/webhook/sendLegacy.operation.ts create mode 100644 packages/nodes-base/nodes/Discord/v2/helpers/utils.ts create mode 100644 packages/nodes-base/nodes/Discord/v2/methods/index.ts create mode 100644 packages/nodes-base/nodes/Discord/v2/methods/listSearch.ts create mode 100644 packages/nodes-base/nodes/Discord/v2/methods/loadOptions.ts create mode 100644 packages/nodes-base/nodes/Discord/v2/transport/discord.api.ts create mode 100644 packages/nodes-base/nodes/Discord/v2/transport/helpers.ts create mode 100644 packages/nodes-base/nodes/Discord/v2/transport/index.ts diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index 9452605db1..c4b2f48239 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -155,6 +155,7 @@ export const WOOCOMMERCE_TRIGGER_NODE_TYPE = 'n8n-nodes-base.wooCommerceTrigger' export const XERO_NODE_TYPE = 'n8n-nodes-base.xero'; export const ZENDESK_NODE_TYPE = 'n8n-nodes-base.zendesk'; export const ZENDESK_TRIGGER_NODE_TYPE = 'n8n-nodes-base.zendeskTrigger'; +export const DISCORD_NODE_TYPE = 'n8n-nodes-base.discord'; export const EXECUTABLE_TRIGGER_NODE_TYPES = [ START_NODE_TYPE, @@ -576,6 +577,7 @@ export const KEEP_AUTH_IN_NDV_FOR_NODES = [ HTTP_REQUEST_NODE_TYPE, WEBHOOK_NODE_TYPE, WAIT_NODE_TYPE, + DISCORD_NODE_TYPE, ]; export const MAIN_AUTH_FIELD_NAME = 'authentication'; export const NODE_RESOURCE_FIELD_NAME = 'resource'; diff --git a/packages/nodes-base/credentials/DiscordBotApi.credentials.ts b/packages/nodes-base/credentials/DiscordBotApi.credentials.ts new file mode 100644 index 0000000000..464ae963ed --- /dev/null +++ b/packages/nodes-base/credentials/DiscordBotApi.credentials.ts @@ -0,0 +1,43 @@ +import type { + IAuthenticateGeneric, + ICredentialTestRequest, + ICredentialType, + INodeProperties, +} from 'n8n-workflow'; + +export class DiscordBotApi implements ICredentialType { + name = 'discordBotApi'; + + displayName = 'Discord Bot API'; + + documentationUrl = 'discord'; + + properties: INodeProperties[] = [ + { + displayName: 'Bot Token', + name: 'botToken', + type: 'string', + default: '', + required: true, + typeOptions: { + password: true, + }, + }, + ]; + + authenticate: IAuthenticateGeneric = { + type: 'generic', + properties: { + headers: { + Authorization: '=Bot {{$credentials.botToken}}', + }, + }, + }; + + test: ICredentialTestRequest = { + request: { + baseURL: 'https://discord.com/api/v10/', + url: '/users/@me/guilds', + }, + }; +} diff --git a/packages/nodes-base/credentials/DiscordOAuth2Api.credentials.ts b/packages/nodes-base/credentials/DiscordOAuth2Api.credentials.ts new file mode 100644 index 0000000000..d0a026507e --- /dev/null +++ b/packages/nodes-base/credentials/DiscordOAuth2Api.credentials.ts @@ -0,0 +1,56 @@ +import type { ICredentialType, INodeProperties } from 'n8n-workflow'; + +export class DiscordOAuth2Api implements ICredentialType { + name = 'discordOAuth2Api'; + + extends = ['oAuth2Api']; + + displayName = 'Discord OAuth2 API'; + + documentationUrl = 'discord'; + + properties: INodeProperties[] = [ + { + displayName: 'Bot Token', + name: 'botToken', + type: 'string', + default: '', + typeOptions: { + password: true, + }, + }, + { + displayName: 'Grant Type', + name: 'grantType', + type: 'hidden', + default: 'authorizationCode', + }, + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden', + default: 'https://discord.com/api/oauth2/authorize', + required: true, + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden', + default: 'https://discord.com/api/oauth2/token', + required: true, + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden', + default: 'identify guilds guilds.join bot', + required: true, + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden', + default: 'permissions=1642758929655', + }, + ]; +} diff --git a/packages/nodes-base/credentials/DiscordWebhookApi.credentials.ts b/packages/nodes-base/credentials/DiscordWebhookApi.credentials.ts new file mode 100644 index 0000000000..b0fcc51f23 --- /dev/null +++ b/packages/nodes-base/credentials/DiscordWebhookApi.credentials.ts @@ -0,0 +1,23 @@ +import type { ICredentialType, INodeProperties } from 'n8n-workflow'; + +export class DiscordWebhookApi implements ICredentialType { + name = 'discordWebhookApi'; + + displayName = 'Discord Webhook'; + + documentationUrl = 'discord'; + + properties: INodeProperties[] = [ + { + displayName: 'Webhook URL', + name: 'webhookUri', + type: 'string', + required: true, + default: '', + placeholder: 'https://discord.com/api/webhooks/ID/TOKEN', + typeOptions: { + password: true, + }, + }, + ]; +} diff --git a/packages/nodes-base/nodes/Discord/Discord.node.ts b/packages/nodes-base/nodes/Discord/Discord.node.ts index 41df8ad45d..9a7b950eb8 100644 --- a/packages/nodes-base/nodes/Discord/Discord.node.ts +++ b/packages/nodes-base/nodes/Discord/Discord.node.ts @@ -1,275 +1,25 @@ -import type { - IExecuteFunctions, - INodeExecutionData, - INodeType, - INodeTypeDescription, -} from 'n8n-workflow'; -import { jsonParse, NodeApiError, NodeOperationError, sleep } from 'n8n-workflow'; +import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow'; +import { VersionedNodeType } from 'n8n-workflow'; -import type { DiscordAttachment, DiscordWebhook } from './Interfaces'; -export class Discord implements INodeType { - description: INodeTypeDescription = { - displayName: 'Discord', - name: 'discord', - icon: 'file:discord.svg', - group: ['output'], - version: 1, - description: 'Sends data to Discord', - defaults: { - name: 'Discord', - }, - inputs: ['main'], - outputs: ['main'], - properties: [ - { - displayName: 'Webhook URL', - name: 'webhookUri', - type: 'string', - required: true, - default: '', - placeholder: 'https://discord.com/api/webhooks/ID/TOKEN', - }, - { - displayName: 'Content', - name: 'text', - type: 'string', - typeOptions: { - maxValue: 2000, - }, - default: '', - placeholder: 'Hello World!', - }, - { - displayName: 'Additional Fields', - name: 'options', - type: 'collection', - placeholder: 'Add Option', - default: {}, - options: [ - { - displayName: 'Allowed Mentions', - name: 'allowedMentions', - type: 'json', - typeOptions: { alwaysOpenEditWindow: true, editor: 'code' }, - default: '', - }, - { - displayName: 'Attachments', - name: 'attachments', - type: 'json', - typeOptions: { alwaysOpenEditWindow: true, editor: 'code' }, - default: '', - }, - { - displayName: 'Avatar URL', - name: 'avatarUrl', - type: 'string', - default: '', - }, - { - displayName: 'Components', - name: 'components', - type: 'json', - typeOptions: { alwaysOpenEditWindow: true, editor: 'code' }, - default: '', - }, - { - displayName: 'Embeds', - name: 'embeds', - type: 'json', - typeOptions: { alwaysOpenEditWindow: true, editor: 'code' }, - default: '', - }, - { - displayName: 'Flags', - name: 'flags', - type: 'number', - default: '', - }, - { - displayName: 'JSON Payload', - name: 'payloadJson', - type: 'json', - typeOptions: { alwaysOpenEditWindow: true, editor: 'code' }, - default: '', - }, - { - displayName: 'Username', - name: 'username', - type: 'string', - default: '', - placeholder: 'User', - }, - { - displayName: 'TTS', - name: 'tts', - type: 'boolean', - default: false, - description: 'Whether this message be sent as a Text To Speech message', - }, - ], - }, - ], - }; +import { DiscordV1 } from './v1/DiscordV1.node'; +import { DiscordV2 } from './v2/DiscordV2.node'; - async execute(this: IExecuteFunctions): Promise { - const returnData: INodeExecutionData[] = []; +export class Discord extends VersionedNodeType { + constructor() { + const baseDescription: INodeTypeBaseDescription = { + displayName: 'Discord', + name: 'discord', + icon: 'file:discord.svg', + group: ['output'], + defaultVersion: 2, + description: 'Sends data to Discord', + }; - const webhookUri = this.getNodeParameter('webhookUri', 0, '') as string; + const nodeVersions: IVersionedNodeType['nodeVersions'] = { + 1: new DiscordV1(baseDescription), + 2: new DiscordV2(baseDescription), + }; - if (!webhookUri) throw new NodeOperationError(this.getNode(), 'Webhook uri is required.'); - - const items = this.getInputData(); - const length = items.length; - for (let i = 0; i < length; i++) { - const body: DiscordWebhook = {}; - - const iterationWebhookUri = this.getNodeParameter('webhookUri', i) as string; - body.content = this.getNodeParameter('text', i) as string; - const options = this.getNodeParameter('options', i); - - if (!body.content && !options.embeds) { - throw new NodeOperationError(this.getNode(), 'Either content or embeds must be set.', { - itemIndex: i, - }); - } - if (options.embeds) { - try { - //@ts-expect-error - body.embeds = JSON.parse(options.embeds); - } catch (e) { - throw new NodeOperationError(this.getNode(), 'Embeds must be valid JSON.', { - itemIndex: i, - }); - } - if (!Array.isArray(body.embeds)) { - throw new NodeOperationError(this.getNode(), 'Embeds must be an array of embeds.', { - itemIndex: i, - }); - } - } - if (options.username) { - body.username = options.username as string; - } - - if (options.components) { - try { - //@ts-expect-error - body.components = JSON.parse(options.components); - } catch (e) { - throw new NodeOperationError(this.getNode(), 'Components must be valid JSON.', { - itemIndex: i, - }); - } - } - - if (options.allowed_mentions) { - //@ts-expect-error - body.allowed_mentions = jsonParse(options.allowed_mentions); - } - - if (options.avatarUrl) { - body.avatar_url = options.avatarUrl as string; - } - - if (options.flags) { - body.flags = options.flags as number; - } - - if (options.tts) { - body.tts = options.tts as boolean; - } - - if (options.payloadJson) { - //@ts-expect-error - body.payload_json = jsonParse(options.payloadJson); - } - - if (options.attachments) { - //@ts-expect-error - body.attachments = jsonParse(options.attachments as DiscordAttachment[]); - } - - //* Not used props, delete them from the payload as Discord won't need them :^ - if (!body.content) delete body.content; - if (!body.username) delete body.username; - if (!body.avatar_url) delete body.avatar_url; - if (!body.embeds) delete body.embeds; - if (!body.allowed_mentions) delete body.allowed_mentions; - if (!body.flags) delete body.flags; - if (!body.components) delete body.components; - if (!body.payload_json) delete body.payload_json; - if (!body.attachments) delete body.attachments; - - let requestOptions; - - if (!body.payload_json) { - requestOptions = { - resolveWithFullResponse: true, - method: 'POST', - body, - uri: iterationWebhookUri, - headers: { - 'content-type': 'application/json; charset=utf-8', - }, - json: true, - }; - } else { - requestOptions = { - resolveWithFullResponse: true, - method: 'POST', - body, - uri: iterationWebhookUri, - headers: { - 'content-type': 'multipart/form-data; charset=utf-8', - }, - }; - } - let maxTries = 5; - let response; - - do { - try { - response = await this.helpers.request(requestOptions); - const resetAfter = response.headers['x-ratelimit-reset-after'] * 1000; - const remainingRatelimit = response.headers['x-ratelimit-remaining']; - - // remaining requests 0 - // https://discord.com/developers/docs/topics/rate-limits - if (!+remainingRatelimit) { - await sleep(resetAfter ?? 1000); - } - - break; - } catch (error) { - // HTTP/1.1 429 TOO MANY REQUESTS - // Await when the current rate limit will reset - // https://discord.com/developers/docs/topics/rate-limits - if (error.statusCode === 429) { - const retryAfter = error.response?.headers['retry-after'] || 1000; - - await sleep(+retryAfter); - - continue; - } - - throw error; - } - } while (--maxTries); - - if (maxTries <= 0) { - throw new NodeApiError(this.getNode(), { - error: 'Could not send Webhook message. Max amount of rate-limit retries reached.', - }); - } - - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray({ success: true }), - { itemData: { item: i } }, - ); - returnData.push(...executionData); - } - - return [returnData]; + super(nodeVersions, baseDescription); } } diff --git a/packages/nodes-base/nodes/Discord/test/v2/node/channel/create.test.ts b/packages/nodes-base/nodes/Discord/test/v2/node/channel/create.test.ts new file mode 100644 index 0000000000..f1497191bc --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/node/channel/create.test.ts @@ -0,0 +1,66 @@ +import type { INodeTypes } from 'n8n-workflow'; +import nock from 'nock'; +import * as transport from '../../../../v2/transport/discord.api'; +import { getResultNodeData, setup, workflowToTests } from '@test/nodes/Helpers'; +import type { WorkflowTestData } from '@test/nodes/types'; +import { executeWorkflow } from '@test/nodes/ExecuteWorkflow'; + +const discordApiRequestSpy = jest.spyOn(transport, 'discordApiRequest'); + +discordApiRequestSpy.mockImplementation(async (method: string, endpoint) => { + if (method === 'POST') { + return { + id: '1168528323006181417', + type: 0, + last_message_id: null, + flags: 0, + guild_id: '1168516062791340136', + name: 'third', + parent_id: null, + rate_limit_per_user: 0, + topic: null, + position: 3, + permission_overwrites: [], + nsfw: false, + }; + } +}); + +describe('Test DiscordV2, channel => create', () => { + const workflows = ['nodes/Discord/test/v2/node/channel/create.workflow.json']; + const tests = workflowToTests(workflows); + + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.resetAllMocks(); + }); + + const nodeTypes = setup(tests); + + const testNode = async (testData: WorkflowTestData, types: INodeTypes) => { + const { result } = await executeWorkflow(testData, types); + + const resultNodeData = getResultNodeData(result, testData); + + resultNodeData.forEach(({ nodeName, resultData }) => { + return expect(resultData).toEqual(testData.output.nodeData[nodeName]); + }); + + expect(discordApiRequestSpy).toHaveBeenCalledTimes(1); + expect(discordApiRequestSpy).toHaveBeenCalledWith( + 'POST', + '/guilds/1168516062791340136/channels', + { name: 'third', type: '0' }, + ); + + expect(result.finished).toEqual(true); + }; + + for (const testData of tests) { + test(testData.description, async () => testNode(testData, nodeTypes)); + } +}); diff --git a/packages/nodes-base/nodes/Discord/test/v2/node/channel/create.workflow.json b/packages/nodes-base/nodes/Discord/test/v2/node/channel/create.workflow.json new file mode 100644 index 0000000000..52e95e2abc --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/node/channel/create.workflow.json @@ -0,0 +1,106 @@ +{ + "name": "discord overhaul tests", + "nodes": [ + { + "parameters": {}, + "id": "254a9d9b-43bf-4f6e-a761-d78146a05838", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + -660, + 560 + ] + }, + { + "parameters": { + "guildId": { + "__rl": true, + "value": "1168516062791340136", + "mode": "list", + "cachedResultName": "TEST server", + "cachedResultUrl": "https://discord.com/channels/1168516062791340136" + }, + "name": "third", + "options": {} + }, + "id": "7e638897-0581-42e6-8b89-494908e0ae75", + "name": "Bot test", + "type": "n8n-nodes-base.discord", + "typeVersion": 2, + "position": [ + -420, + 560 + ], + "credentials": { + "discordBotApi": { + "id": "KaIz8dqE3Vy1E3iL", + "name": "Discord Bot account" + } + } + }, + { + "parameters": {}, + "id": "10450e91-8642-4b92-af15-9d5ad161b527", + "name": "No Operation, do nothing", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ + -200, + 560 + ] + } + ], + "pinData": { + "No Operation, do nothing": [ + { + "json": { + "id": "1168528323006181417", + "type": 0, + "last_message_id": null, + "flags": 0, + "guild_id": "1168516062791340136", + "name": "third", + "parent_id": null, + "rate_limit_per_user": 0, + "topic": null, + "position": 3, + "permission_overwrites": [], + "nsfw": false + } + } + ] + }, + "connections": { + "When clicking \"Execute Workflow\"": { + "main": [ + [ + { + "node": "Bot test", + "type": "main", + "index": 0 + } + ] + ] + }, + "Bot test": { + "main": [ + [ + { + "node": "No Operation, do nothing", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": {}, + "versionId": "df4fbb47-eb25-4564-b9ad-f16931e35665", + "id": "4DdFKgGmLX07cXvG", + "meta": { + "instanceId": "b888bd11cd1ddbb95450babf3e199556799d999b896f650de768b8370ee50363" + }, + "tags": [] +} diff --git a/packages/nodes-base/nodes/Discord/test/v2/node/channel/deleteChannel.test.ts b/packages/nodes-base/nodes/Discord/test/v2/node/channel/deleteChannel.test.ts new file mode 100644 index 0000000000..abb5f6df42 --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/node/channel/deleteChannel.test.ts @@ -0,0 +1,62 @@ +import type { INodeTypes } from 'n8n-workflow'; +import nock from 'nock'; +import * as transport from '../../../../v2/transport/discord.api'; +import { getResultNodeData, setup, workflowToTests } from '@test/nodes/Helpers'; +import type { WorkflowTestData } from '@test/nodes/types'; +import { executeWorkflow } from '@test/nodes/ExecuteWorkflow'; + +const discordApiRequestSpy = jest.spyOn(transport, 'discordApiRequest'); + +discordApiRequestSpy.mockImplementation(async (method: string, endpoint) => { + if (method === 'DELETE') { + return { + id: '1168528323006181417', + type: 0, + last_message_id: null, + flags: 0, + guild_id: '1168516062791340136', + name: 'third', + parent_id: null, + rate_limit_per_user: 0, + topic: null, + position: 3, + permission_overwrites: [], + nsfw: false, + }; + } +}); + +describe('Test DiscordV2, channel => deleteChannel', () => { + const workflows = ['nodes/Discord/test/v2/node/channel/deleteChannel.workflow.json']; + const tests = workflowToTests(workflows); + + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.resetAllMocks(); + }); + + const nodeTypes = setup(tests); + + const testNode = async (testData: WorkflowTestData, types: INodeTypes) => { + const { result } = await executeWorkflow(testData, types); + + const resultNodeData = getResultNodeData(result, testData); + + resultNodeData.forEach(({ nodeName, resultData }) => { + return expect(resultData).toEqual(testData.output.nodeData[nodeName]); + }); + + expect(discordApiRequestSpy).toHaveBeenCalledTimes(1); + expect(discordApiRequestSpy).toHaveBeenCalledWith('DELETE', '/channels/1168528323006181417'); + + expect(result.finished).toEqual(true); + }; + + for (const testData of tests) { + test(testData.description, async () => testNode(testData, nodeTypes)); + } +}); diff --git a/packages/nodes-base/nodes/Discord/test/v2/node/channel/deleteChannel.workflow.json b/packages/nodes-base/nodes/Discord/test/v2/node/channel/deleteChannel.workflow.json new file mode 100644 index 0000000000..051f3ff02d --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/node/channel/deleteChannel.workflow.json @@ -0,0 +1,112 @@ +{ + "name": "discord overhaul tests", + "nodes": [ + { + "parameters": {}, + "id": "254a9d9b-43bf-4f6e-a761-d78146a05838", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + -660, + 560 + ] + }, + { + "parameters": { + "operation": "deleteChannel", + "guildId": { + "__rl": true, + "value": "1168516062791340136", + "mode": "list", + "cachedResultName": "TEST server", + "cachedResultUrl": "https://discord.com/channels/1168516062791340136" + }, + "channelId": { + "__rl": true, + "value": "1168528323006181417", + "mode": "list", + "cachedResultName": "third", + "cachedResultUrl": "https://discord.com/channels/1168516062791340136/1168528323006181417" + } + }, + "id": "7e638897-0581-42e6-8b89-494908e0ae75", + "name": "Bot test", + "type": "n8n-nodes-base.discord", + "typeVersion": 2, + "position": [ + -420, + 560 + ], + "credentials": { + "discordBotApi": { + "id": "KaIz8dqE3Vy1E3iL", + "name": "Discord Bot account" + } + } + }, + { + "parameters": {}, + "id": "10450e91-8642-4b92-af15-9d5ad161b527", + "name": "No Operation, do nothing", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ + -200, + 560 + ] + } + ], + "pinData": { + "No Operation, do nothing": [ + { + "json": { + "id": "1168528323006181417", + "type": 0, + "last_message_id": null, + "flags": 0, + "guild_id": "1168516062791340136", + "name": "third", + "parent_id": null, + "rate_limit_per_user": 0, + "topic": null, + "position": 3, + "permission_overwrites": [], + "nsfw": false + } + } + ] + }, + "connections": { + "When clicking \"Execute Workflow\"": { + "main": [ + [ + { + "node": "Bot test", + "type": "main", + "index": 0 + } + ] + ] + }, + "Bot test": { + "main": [ + [ + { + "node": "No Operation, do nothing", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": {}, + "versionId": "f0027d0b-87f7-4a39-bc9c-2838078eed60", + "id": "4DdFKgGmLX07cXvG", + "meta": { + "instanceId": "b888bd11cd1ddbb95450babf3e199556799d999b896f650de768b8370ee50363" + }, + "tags": [] +} diff --git a/packages/nodes-base/nodes/Discord/test/v2/node/channel/get.test.ts b/packages/nodes-base/nodes/Discord/test/v2/node/channel/get.test.ts new file mode 100644 index 0000000000..61fa6d8da6 --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/node/channel/get.test.ts @@ -0,0 +1,112 @@ +import type { INodeTypes } from 'n8n-workflow'; +import nock from 'nock'; +import type { OptionsWithUrl } from 'request-promise-native'; +import * as transport from '../../../../v2/transport/helpers'; +import { getResultNodeData, setup, workflowToTests } from '@test/nodes/Helpers'; +import type { WorkflowTestData } from '@test/nodes/types'; +import { executeWorkflow } from '@test/nodes/ExecuteWorkflow'; + +const requestApiSpy = jest.spyOn(transport, 'requestApi'); + +requestApiSpy.mockImplementation( + async (options: OptionsWithUrl, credentialType: string, endpoint: string) => { + if (endpoint === '/users/@me/guilds') { + return { + headers: {}, + body: [ + { + id: '1168516062791340136', + }, + ], + }; + } else { + return { + headers: {}, + body: { + id: '1168516240332034067', + type: 0, + last_message_id: null, + flags: 0, + guild_id: '1168516062791340136', + name: 'first', + parent_id: '1168516063340789831', + rate_limit_per_user: 0, + topic: null, + position: 1, + permission_overwrites: [], + nsfw: false, + }, + }; + } + }, +); + +describe('Test DiscordV2, channel => get', () => { + const workflows = ['nodes/Discord/test/v2/node/channel/get.workflow.json']; + const tests = workflowToTests(workflows); + + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.resetAllMocks(); + }); + + const nodeTypes = setup(tests); + + const testNode = async (testData: WorkflowTestData, types: INodeTypes) => { + const { result } = await executeWorkflow(testData, types); + + const resultNodeData = getResultNodeData(result, testData); + + resultNodeData.forEach(({ nodeName, resultData }) => { + return expect(resultData).toEqual(testData.output.nodeData[nodeName]); + }); + + expect(requestApiSpy).toHaveBeenCalledTimes(3); + expect(requestApiSpy).toHaveBeenCalledWith( + { + body: undefined, + headers: {}, + json: true, + method: 'GET', + qs: undefined, + url: 'https://discord.com/api/v10/users/@me/guilds', + }, + 'discordOAuth2Api', + '/users/@me/guilds', + ); + expect(requestApiSpy).toHaveBeenCalledWith( + { + body: undefined, + headers: {}, + json: true, + method: 'GET', + qs: undefined, + url: 'https://discord.com/api/v10/channels/1168516240332034067', + }, + 'discordOAuth2Api', + '/channels/1168516240332034067', + ); + expect(requestApiSpy).toHaveBeenCalledWith( + { + body: undefined, + headers: {}, + json: true, + method: 'GET', + qs: undefined, + url: 'https://discord.com/api/v10/channels/1168516240332034067', + }, + 'discordOAuth2Api', + '/channels/1168516240332034067', + ); + + expect(result.finished).toEqual(true); + }; + + for (const testData of tests) { + test(testData.description, async () => testNode(testData, nodeTypes)); + } +}); diff --git a/packages/nodes-base/nodes/Discord/test/v2/node/channel/get.workflow.json b/packages/nodes-base/nodes/Discord/test/v2/node/channel/get.workflow.json new file mode 100644 index 0000000000..dd3b1844eb --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/node/channel/get.workflow.json @@ -0,0 +1,113 @@ +{ + "name": "discord overhaul copy", + "nodes": [ + { + "parameters": {}, + "id": "fe1dd916-f466-40c7-9dfa-dfec59219a86", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + -560, + 780 + ] + }, + { + "parameters": { + "authentication": "oAuth2", + "operation": "get", + "guildId": { + "__rl": true, + "value": "1168516062791340136", + "mode": "list", + "cachedResultName": "TEST server", + "cachedResultUrl": "https://discord.com/channels/1168516062791340136" + }, + "channelId": { + "__rl": true, + "value": "1168516240332034067", + "mode": "list", + "cachedResultName": "first", + "cachedResultUrl": "https://discord.com/channels/1168516062791340136/1168516240332034067" + } + }, + "id": "09cccc50-10d2-49a1-9b9a-9ba1a11a3657", + "name": "OAuth test", + "type": "n8n-nodes-base.discord", + "typeVersion": 2, + "position": [ + -320, + 780 + ], + "credentials": { + "discordOAuth2Api": { + "id": "79", + "name": "Discord account" + } + } + }, + { + "parameters": {}, + "id": "7f367512-810f-4d5d-9020-0f01a47039f7", + "name": "No Operation, do nothing", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ + -80, + 780 + ] + } + ], + "pinData": { + "No Operation, do nothing": [ + { + "json": { + "id": "1168516240332034067", + "type": 0, + "last_message_id": null, + "flags": 0, + "guild_id": "1168516062791340136", + "name": "first", + "parent_id": "1168516063340789831", + "rate_limit_per_user": 0, + "topic": null, + "position": 1, + "permission_overwrites": [], + "nsfw": false + } + } + ] + }, + "connections": { + "When clicking \"Execute Workflow\"": { + "main": [ + [ + { + "node": "OAuth test", + "type": "main", + "index": 0 + } + ] + ] + }, + "OAuth test": { + "main": [ + [ + { + "node": "No Operation, do nothing", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": {}, + "versionId": "0089557d-57da-45ea-abd1-c7b57691e10a", + "id": "m3OrE6gaFHxa5InI", + "meta": { + "instanceId": "b888bd11cd1ddbb95450babf3e199556799d999b896f650de768b8370ee50363" + }, + "tags": [] +} diff --git a/packages/nodes-base/nodes/Discord/test/v2/node/channel/getAll.test.ts b/packages/nodes-base/nodes/Discord/test/v2/node/channel/getAll.test.ts new file mode 100644 index 0000000000..342cb9b62a --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/node/channel/getAll.test.ts @@ -0,0 +1,141 @@ +import type { INodeTypes } from 'n8n-workflow'; +import nock from 'nock'; +import * as transport from '../../../../v2/transport/discord.api'; +import { getResultNodeData, setup, workflowToTests } from '@test/nodes/Helpers'; +import type { WorkflowTestData } from '@test/nodes/types'; +import { executeWorkflow } from '@test/nodes/ExecuteWorkflow'; + +const discordApiRequestSpy = jest.spyOn(transport, 'discordApiRequest'); + +discordApiRequestSpy.mockImplementation(async (method: string, endpoint) => { + if (method === 'GET') { + return [ + { + id: '1168516063340789831', + type: 4, + flags: 0, + guild_id: '1168516062791340136', + name: 'Text Channels', + parent_id: null, + position: 0, + permission_overwrites: [], + }, + { + id: '1168516063340789832', + type: 4, + flags: 0, + guild_id: '1168516062791340136', + name: 'Voice Channels', + parent_id: null, + position: 0, + permission_overwrites: [], + }, + { + id: '1168516063340789833', + type: 0, + last_message_id: '1168518371239792720', + flags: 0, + guild_id: '1168516062791340136', + name: 'general', + parent_id: '1168516063340789831', + rate_limit_per_user: 0, + topic: null, + position: 0, + permission_overwrites: [], + nsfw: false, + icon_emoji: { + id: null, + name: '👋', + }, + theme_color: null, + }, + { + id: '1168516063340789834', + type: 2, + last_message_id: null, + flags: 0, + guild_id: '1168516062791340136', + name: 'General', + parent_id: '1168516063340789832', + rate_limit_per_user: 0, + bitrate: 64000, + user_limit: 0, + rtc_region: null, + position: 0, + permission_overwrites: [], + nsfw: false, + icon_emoji: { + id: null, + name: '🎙️', + }, + theme_color: null, + }, + { + id: '1168516240332034067', + type: 0, + last_message_id: null, + flags: 0, + guild_id: '1168516062791340136', + name: 'first-channel', + parent_id: '1168516063340789831', + rate_limit_per_user: 30, + topic: 'This is channel topic', + position: 3, + permission_overwrites: [], + nsfw: true, + }, + { + id: '1168516269079793766', + type: 0, + last_message_id: null, + flags: 0, + guild_id: '1168516062791340136', + name: 'second', + parent_id: '1168516063340789831', + rate_limit_per_user: 0, + topic: null, + position: 2, + permission_overwrites: [], + nsfw: false, + }, + ]; + } +}); + +describe('Test DiscordV2, channel => getAll', () => { + const workflows = ['nodes/Discord/test/v2/node/channel/getAll.workflow.json']; + const tests = workflowToTests(workflows); + + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.resetAllMocks(); + }); + + const nodeTypes = setup(tests); + + const testNode = async (testData: WorkflowTestData, types: INodeTypes) => { + const { result } = await executeWorkflow(testData, types); + + const resultNodeData = getResultNodeData(result, testData); + + resultNodeData.forEach(({ nodeName, resultData }) => { + return expect(resultData).toEqual(testData.output.nodeData[nodeName]); + }); + + expect(discordApiRequestSpy).toHaveBeenCalledTimes(1); + expect(discordApiRequestSpy).toHaveBeenCalledWith( + 'GET', + '/guilds/1168516062791340136/channels', + ); + + expect(result.finished).toEqual(true); + }; + + for (const testData of tests) { + test(testData.description, async () => testNode(testData, nodeTypes)); + } +}); diff --git a/packages/nodes-base/nodes/Discord/test/v2/node/channel/getAll.workflow.json b/packages/nodes-base/nodes/Discord/test/v2/node/channel/getAll.workflow.json new file mode 100644 index 0000000000..02b813627a --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/node/channel/getAll.workflow.json @@ -0,0 +1,191 @@ +{ + "name": "discord overhaul tests", + "nodes": [ + { + "parameters": {}, + "id": "254a9d9b-43bf-4f6e-a761-d78146a05838", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + -660, + 560 + ] + }, + { + "parameters": { + "operation": "getAll", + "guildId": { + "__rl": true, + "value": "1168516062791340136", + "mode": "list", + "cachedResultName": "TEST server", + "cachedResultUrl": "https://discord.com/channels/1168516062791340136" + }, + "returnAll": true, + "options": {} + }, + "id": "7e638897-0581-42e6-8b89-494908e0ae75", + "name": "Bot test", + "type": "n8n-nodes-base.discord", + "typeVersion": 2, + "position": [ + -420, + 560 + ], + "credentials": { + "discordBotApi": { + "id": "KaIz8dqE3Vy1E3iL", + "name": "Discord Bot account" + } + } + }, + { + "parameters": {}, + "id": "10450e91-8642-4b92-af15-9d5ad161b527", + "name": "No Operation, do nothing", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ + -200, + 560 + ] + } + ], + "pinData": { + "No Operation, do nothing": [ + { + "json": { + "id": "1168516063340789831", + "type": 4, + "flags": 0, + "guild_id": "1168516062791340136", + "name": "Text Channels", + "parent_id": null, + "position": 0, + "permission_overwrites": [] + } + }, + { + "json": { + "id": "1168516063340789832", + "type": 4, + "flags": 0, + "guild_id": "1168516062791340136", + "name": "Voice Channels", + "parent_id": null, + "position": 0, + "permission_overwrites": [] + } + }, + { + "json": { + "id": "1168516063340789833", + "type": 0, + "last_message_id": "1168518371239792720", + "flags": 0, + "guild_id": "1168516062791340136", + "name": "general", + "parent_id": "1168516063340789831", + "rate_limit_per_user": 0, + "topic": null, + "position": 0, + "permission_overwrites": [], + "nsfw": false, + "icon_emoji": { + "id": null, + "name": "👋" + }, + "theme_color": null + } + }, + { + "json": { + "id": "1168516063340789834", + "type": 2, + "last_message_id": null, + "flags": 0, + "guild_id": "1168516062791340136", + "name": "General", + "parent_id": "1168516063340789832", + "rate_limit_per_user": 0, + "bitrate": 64000, + "user_limit": 0, + "rtc_region": null, + "position": 0, + "permission_overwrites": [], + "nsfw": false, + "icon_emoji": { + "id": null, + "name": "🎙️" + }, + "theme_color": null + } + }, + { + "json": { + "id": "1168516240332034067", + "type": 0, + "last_message_id": null, + "flags": 0, + "guild_id": "1168516062791340136", + "name": "first-channel", + "parent_id": "1168516063340789831", + "rate_limit_per_user": 30, + "topic": "This is channel topic", + "position": 3, + "permission_overwrites": [], + "nsfw": true + } + }, + { + "json": { + "id": "1168516269079793766", + "type": 0, + "last_message_id": null, + "flags": 0, + "guild_id": "1168516062791340136", + "name": "second", + "parent_id": "1168516063340789831", + "rate_limit_per_user": 0, + "topic": null, + "position": 2, + "permission_overwrites": [], + "nsfw": false + } + } + ] + }, + "connections": { + "When clicking \"Execute Workflow\"": { + "main": [ + [ + { + "node": "Bot test", + "type": "main", + "index": 0 + } + ] + ] + }, + "Bot test": { + "main": [ + [ + { + "node": "No Operation, do nothing", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": {}, + "versionId": "905c7383-202e-4391-97a5-e2c579421c17", + "id": "4DdFKgGmLX07cXvG", + "meta": { + "instanceId": "b888bd11cd1ddbb95450babf3e199556799d999b896f650de768b8370ee50363" + }, + "tags": [] +} diff --git a/packages/nodes-base/nodes/Discord/test/v2/node/channel/update.test.ts b/packages/nodes-base/nodes/Discord/test/v2/node/channel/update.test.ts new file mode 100644 index 0000000000..c42c56a8ff --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/node/channel/update.test.ts @@ -0,0 +1,69 @@ +import type { INodeTypes } from 'n8n-workflow'; +import nock from 'nock'; +import * as transport from '../../../../v2/transport/discord.api'; +import { getResultNodeData, setup, workflowToTests } from '@test/nodes/Helpers'; +import type { WorkflowTestData } from '@test/nodes/types'; +import { executeWorkflow } from '@test/nodes/ExecuteWorkflow'; + +const discordApiRequestSpy = jest.spyOn(transport, 'discordApiRequest'); + +discordApiRequestSpy.mockImplementation(async (method: string, endpoint) => { + if (method === 'PATCH') { + return { + id: '1168516240332034067', + type: 0, + last_message_id: null, + flags: 0, + guild_id: '1168516062791340136', + name: 'first-channel', + parent_id: '1168516063340789831', + rate_limit_per_user: 30, + topic: 'This is channel topic', + position: 3, + permission_overwrites: [], + nsfw: true, + }; + } +}); + +describe('Test DiscordV2, channel => update', () => { + const workflows = ['nodes/Discord/test/v2/node/channel/update.workflow.json']; + const tests = workflowToTests(workflows); + + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.resetAllMocks(); + }); + + const nodeTypes = setup(tests); + + const testNode = async (testData: WorkflowTestData, types: INodeTypes) => { + const { result } = await executeWorkflow(testData, types); + + const resultNodeData = getResultNodeData(result, testData); + + resultNodeData.forEach(({ nodeName, resultData }) => { + return expect(resultData).toEqual(testData.output.nodeData[nodeName]); + }); + + expect(discordApiRequestSpy).toHaveBeenCalledTimes(1); + expect(discordApiRequestSpy).toHaveBeenCalledWith('PATCH', '/channels/1168516240332034067', { + name: 'First Channel', + nsfw: true, + parent_id: '1168516063340789831', + position: 3, + rate_limit_per_user: 30, + topic: 'This is channel topic', + }); + + expect(result.finished).toEqual(true); + }; + + for (const testData of tests) { + test(testData.description, async () => testNode(testData, nodeTypes)); + } +}); diff --git a/packages/nodes-base/nodes/Discord/test/v2/node/channel/update.workflow.json b/packages/nodes-base/nodes/Discord/test/v2/node/channel/update.workflow.json new file mode 100644 index 0000000000..149229e373 --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/node/channel/update.workflow.json @@ -0,0 +1,126 @@ +{ + "name": "discord overhaul tests", + "nodes": [ + { + "parameters": {}, + "id": "254a9d9b-43bf-4f6e-a761-d78146a05838", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + -660, + 560 + ] + }, + { + "parameters": { + "operation": "update", + "guildId": { + "__rl": true, + "value": "1168516062791340136", + "mode": "list", + "cachedResultName": "TEST server", + "cachedResultUrl": "https://discord.com/channels/1168516062791340136" + }, + "channelId": { + "__rl": true, + "value": "1168516240332034067", + "mode": "list", + "cachedResultName": "first", + "cachedResultUrl": "https://discord.com/channels/1168516062791340136/1168516240332034067" + }, + "name": "First Channel", + "options": { + "nsfw": true, + "categoryId": { + "__rl": true, + "value": "1168516063340789831", + "mode": "list", + "cachedResultName": "Text Channels", + "cachedResultUrl": "https://discord.com/channels/1168516062791340136/1168516063340789831" + }, + "position": 3, + "rate_limit_per_user": 30, + "topic": "This is channel topic" + } + }, + "id": "7e638897-0581-42e6-8b89-494908e0ae75", + "name": "Bot test", + "type": "n8n-nodes-base.discord", + "typeVersion": 2, + "position": [ + -420, + 560 + ], + "credentials": { + "discordBotApi": { + "id": "KaIz8dqE3Vy1E3iL", + "name": "Discord Bot account" + } + } + }, + { + "parameters": {}, + "id": "10450e91-8642-4b92-af15-9d5ad161b527", + "name": "No Operation, do nothing", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ + -200, + 560 + ] + } + ], + "pinData": { + "No Operation, do nothing": [ + { + "json": { + "id": "1168516240332034067", + "type": 0, + "last_message_id": null, + "flags": 0, + "guild_id": "1168516062791340136", + "name": "first-channel", + "parent_id": "1168516063340789831", + "rate_limit_per_user": 30, + "topic": "This is channel topic", + "position": 3, + "permission_overwrites": [], + "nsfw": true + } + } + ] + }, + "connections": { + "When clicking \"Execute Workflow\"": { + "main": [ + [ + { + "node": "Bot test", + "type": "main", + "index": 0 + } + ] + ] + }, + "Bot test": { + "main": [ + [ + { + "node": "No Operation, do nothing", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": {}, + "versionId": "fc822909-732e-444f-9537-54b7a85a7bd7", + "id": "4DdFKgGmLX07cXvG", + "meta": { + "instanceId": "b888bd11cd1ddbb95450babf3e199556799d999b896f650de768b8370ee50363" + }, + "tags": [] +} diff --git a/packages/nodes-base/nodes/Discord/test/v2/node/member/getAll.test.ts b/packages/nodes-base/nodes/Discord/test/v2/node/member/getAll.test.ts new file mode 100644 index 0000000000..80589af952 --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/node/member/getAll.test.ts @@ -0,0 +1,90 @@ +import type { INodeTypes } from 'n8n-workflow'; +import nock from 'nock'; +import * as transport from '../../../../v2/transport/discord.api'; +import { getResultNodeData, setup, workflowToTests } from '@test/nodes/Helpers'; +import type { WorkflowTestData } from '@test/nodes/types'; +import { executeWorkflow } from '@test/nodes/ExecuteWorkflow'; + +const discordApiRequestSpy = jest.spyOn(transport, 'discordApiRequest'); + +discordApiRequestSpy.mockImplementation(async (method: string, endpoint) => { + if (method === 'GET') { + return [ + { + user: { + id: '470936827994570762', + username: 'michael', + avatar: null, + discriminator: '0', + public_flags: 0, + premium_type: 0, + flags: 0, + banner: null, + accent_color: null, + global_name: 'Michael', + avatar_decoration_data: null, + banner_color: null, + }, + roles: [], + }, + { + user: { + id: '1070667629972430879', + username: 'n8n-node-overhaul', + avatar: null, + discriminator: '1037', + public_flags: 0, + premium_type: 0, + flags: 0, + bot: true, + banner: null, + accent_color: null, + global_name: null, + avatar_decoration_data: null, + banner_color: null, + }, + roles: ['1168518368526077992'], + }, + ]; + } +}); + +describe('Test DiscordV2, member => getAll', () => { + const workflows = ['nodes/Discord/test/v2/node/member/getAll.workflow.json']; + const tests = workflowToTests(workflows); + + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.resetAllMocks(); + }); + + const nodeTypes = setup(tests); + + const testNode = async (testData: WorkflowTestData, types: INodeTypes) => { + const { result } = await executeWorkflow(testData, types); + + const resultNodeData = getResultNodeData(result, testData); + + resultNodeData.forEach(({ nodeName, resultData }) => { + return expect(resultData).toEqual(testData.output.nodeData[nodeName]); + }); + + expect(discordApiRequestSpy).toHaveBeenCalledTimes(1); + expect(discordApiRequestSpy).toHaveBeenCalledWith( + 'GET', + '/guilds/1168516062791340136/members', + undefined, + { limit: 2 }, + ); + + expect(result.finished).toEqual(true); + }; + + for (const testData of tests) { + test(testData.description, async () => testNode(testData, nodeTypes)); + } +}); diff --git a/packages/nodes-base/nodes/Discord/test/v2/node/member/getAll.workflow.json b/packages/nodes-base/nodes/Discord/test/v2/node/member/getAll.workflow.json new file mode 100644 index 0000000000..d2f1bc8b4f --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/node/member/getAll.workflow.json @@ -0,0 +1,134 @@ +{ + "name": "discord overhaul tests", + "nodes": [ + { + "parameters": {}, + "id": "254a9d9b-43bf-4f6e-a761-d78146a05838", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + -660, + 560 + ] + }, + { + "parameters": { + "resource": "member", + "guildId": { + "__rl": true, + "value": "1168516062791340136", + "mode": "list", + "cachedResultName": "TEST server", + "cachedResultUrl": "https://discord.com/channels/1168516062791340136" + }, + "limit": 2, + "options": { + "simplify": true + } + }, + "id": "7e638897-0581-42e6-8b89-494908e0ae75", + "name": "Bot test", + "type": "n8n-nodes-base.discord", + "typeVersion": 2, + "position": [ + -420, + 560 + ], + "credentials": { + "discordBotApi": { + "id": "KaIz8dqE3Vy1E3iL", + "name": "Discord Bot account" + } + } + }, + { + "parameters": {}, + "id": "10450e91-8642-4b92-af15-9d5ad161b527", + "name": "No Operation, do nothing", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ + -200, + 560 + ] + } + ], + "pinData": { + "No Operation, do nothing": [ + { + "json": { + "user": { + "id": "470936827994570762", + "username": "michael", + "avatar": null, + "discriminator": "0", + "public_flags": 0, + "premium_type": 0, + "flags": 0, + "banner": null, + "accent_color": null, + "global_name": "Michael", + "avatar_decoration_data": null, + "banner_color": null + }, + "roles": [] + } + }, + { + "json": { + "user": { + "id": "1070667629972430879", + "username": "n8n-node-overhaul", + "avatar": null, + "discriminator": "1037", + "public_flags": 0, + "premium_type": 0, + "flags": 0, + "bot": true, + "banner": null, + "accent_color": null, + "global_name": null, + "avatar_decoration_data": null, + "banner_color": null + }, + "roles": [ + "1168518368526077992" + ] + } + } + ] + }, + "connections": { + "When clicking \"Execute Workflow\"": { + "main": [ + [ + { + "node": "Bot test", + "type": "main", + "index": 0 + } + ] + ] + }, + "Bot test": { + "main": [ + [ + { + "node": "No Operation, do nothing", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": {}, + "versionId": "61375773-2f25-4eae-9ef6-e64e69fc9714", + "id": "4DdFKgGmLX07cXvG", + "meta": { + "instanceId": "b888bd11cd1ddbb95450babf3e199556799d999b896f650de768b8370ee50363" + }, + "tags": [] +} diff --git a/packages/nodes-base/nodes/Discord/test/v2/node/member/roleAdd.test.ts b/packages/nodes-base/nodes/Discord/test/v2/node/member/roleAdd.test.ts new file mode 100644 index 0000000000..1fe60cd181 --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/node/member/roleAdd.test.ts @@ -0,0 +1,54 @@ +import type { INodeTypes } from 'n8n-workflow'; +import nock from 'nock'; +import * as transport from '../../../../v2/transport/discord.api'; +import { getResultNodeData, setup, workflowToTests } from '@test/nodes/Helpers'; +import type { WorkflowTestData } from '@test/nodes/types'; +import { executeWorkflow } from '@test/nodes/ExecuteWorkflow'; + +const discordApiRequestSpy = jest.spyOn(transport, 'discordApiRequest'); + +discordApiRequestSpy.mockImplementation(async (method: string, endpoint) => { + if (method === 'PUT') { + return { + success: true, + }; + } +}); + +describe('Test DiscordV2, member => roleAdd', () => { + const workflows = ['nodes/Discord/test/v2/node/member/roleAdd.workflow.json']; + const tests = workflowToTests(workflows); + + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.resetAllMocks(); + }); + + const nodeTypes = setup(tests); + + const testNode = async (testData: WorkflowTestData, types: INodeTypes) => { + const { result } = await executeWorkflow(testData, types); + + const resultNodeData = getResultNodeData(result, testData); + + resultNodeData.forEach(({ nodeName, resultData }) => { + return expect(resultData).toEqual(testData.output.nodeData[nodeName]); + }); + + expect(discordApiRequestSpy).toHaveBeenCalledTimes(1); + expect(discordApiRequestSpy).toHaveBeenCalledWith( + 'PUT', + '/guilds/1168516062791340136/members/470936827994570762/roles/1168772374540320890', + ); + + expect(result.finished).toEqual(true); + }; + + for (const testData of tests) { + test(testData.description, async () => testNode(testData, nodeTypes)); + } +}); diff --git a/packages/nodes-base/nodes/Discord/test/v2/node/member/roleAdd.workflow.json b/packages/nodes-base/nodes/Discord/test/v2/node/member/roleAdd.workflow.json new file mode 100644 index 0000000000..fe3dd6e02c --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/node/member/roleAdd.workflow.json @@ -0,0 +1,104 @@ +{ + "name": "discord overhaul tests", + "nodes": [ + { + "parameters": {}, + "id": "254a9d9b-43bf-4f6e-a761-d78146a05838", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + -660, + 560 + ] + }, + { + "parameters": { + "resource": "member", + "operation": "roleAdd", + "guildId": { + "__rl": true, + "value": "1168516062791340136", + "mode": "list", + "cachedResultName": "TEST server", + "cachedResultUrl": "https://discord.com/channels/1168516062791340136" + }, + "userId": { + "__rl": true, + "value": "470936827994570762", + "mode": "list", + "cachedResultName": "michael" + }, + "role": [ + "1168772374540320890" + ] + }, + "id": "7e638897-0581-42e6-8b89-494908e0ae75", + "name": "Bot test", + "type": "n8n-nodes-base.discord", + "typeVersion": 2, + "position": [ + -420, + 560 + ], + "credentials": { + "discordBotApi": { + "id": "KaIz8dqE3Vy1E3iL", + "name": "Discord Bot account" + } + } + }, + { + "parameters": {}, + "id": "10450e91-8642-4b92-af15-9d5ad161b527", + "name": "No Operation, do nothing", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ + -200, + 560 + ] + } + ], + "pinData": { + "No Operation, do nothing": [ + { + "json": { + "success": true + } + } + ] + }, + "connections": { + "When clicking \"Execute Workflow\"": { + "main": [ + [ + { + "node": "Bot test", + "type": "main", + "index": 0 + } + ] + ] + }, + "Bot test": { + "main": [ + [ + { + "node": "No Operation, do nothing", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": {}, + "versionId": "ad26d0d9-faf3-4070-8909-8c2b6f0749f9", + "id": "4DdFKgGmLX07cXvG", + "meta": { + "instanceId": "b888bd11cd1ddbb95450babf3e199556799d999b896f650de768b8370ee50363" + }, + "tags": [] +} diff --git a/packages/nodes-base/nodes/Discord/test/v2/node/member/roleRemove.test.ts b/packages/nodes-base/nodes/Discord/test/v2/node/member/roleRemove.test.ts new file mode 100644 index 0000000000..c9befeca61 --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/node/member/roleRemove.test.ts @@ -0,0 +1,62 @@ +import type { INodeTypes } from 'n8n-workflow'; +import nock from 'nock'; +import * as transport from '../../../../v2/transport/discord.api'; +import { getResultNodeData, setup, workflowToTests } from '@test/nodes/Helpers'; +import type { WorkflowTestData } from '@test/nodes/types'; +import { executeWorkflow } from '@test/nodes/ExecuteWorkflow'; + +const discordApiRequestSpy = jest.spyOn(transport, 'discordApiRequest'); + +discordApiRequestSpy.mockImplementation(async (method: string, endpoint) => { + if (method === 'DELETE') { + return { + success: true, + }; + } +}); + +describe('Test DiscordV2, member => roleRemove', () => { + const workflows = ['nodes/Discord/test/v2/node/member/roleRemove.workflow.json']; + const tests = workflowToTests(workflows); + + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.resetAllMocks(); + }); + + const nodeTypes = setup(tests); + + const testNode = async (testData: WorkflowTestData, types: INodeTypes) => { + const { result } = await executeWorkflow(testData, types); + + const resultNodeData = getResultNodeData(result, testData); + + resultNodeData.forEach(({ nodeName, resultData }) => { + return expect(resultData).toEqual(testData.output.nodeData[nodeName]); + }); + + expect(discordApiRequestSpy).toHaveBeenCalledTimes(3); + expect(discordApiRequestSpy).toHaveBeenCalledWith( + 'DELETE', + '/guilds/1168516062791340136/members/470936827994570762/roles/1168773588963299428', + ); + expect(discordApiRequestSpy).toHaveBeenCalledWith( + 'DELETE', + '/guilds/1168516062791340136/members/470936827994570762/roles/1168773645800308756', + ); + expect(discordApiRequestSpy).toHaveBeenCalledWith( + 'DELETE', + '/guilds/1168516062791340136/members/470936827994570762/roles/1168772374540320890', + ); + + expect(result.finished).toEqual(true); + }; + + for (const testData of tests) { + test(testData.description, async () => testNode(testData, nodeTypes)); + } +}); diff --git a/packages/nodes-base/nodes/Discord/test/v2/node/member/roleRemove.workflow.json b/packages/nodes-base/nodes/Discord/test/v2/node/member/roleRemove.workflow.json new file mode 100644 index 0000000000..980c0d7e7d --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/node/member/roleRemove.workflow.json @@ -0,0 +1,106 @@ +{ + "name": "discord overhaul tests", + "nodes": [ + { + "parameters": {}, + "id": "254a9d9b-43bf-4f6e-a761-d78146a05838", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + -660, + 560 + ] + }, + { + "parameters": { + "resource": "member", + "operation": "roleRemove", + "guildId": { + "__rl": true, + "value": "1168516062791340136", + "mode": "list", + "cachedResultName": "TEST server", + "cachedResultUrl": "https://discord.com/channels/1168516062791340136" + }, + "userId": { + "__rl": true, + "value": "470936827994570762", + "mode": "list", + "cachedResultName": "michael" + }, + "role": [ + "1168773588963299428", + "1168773645800308756", + "1168772374540320890" + ] + }, + "id": "7e638897-0581-42e6-8b89-494908e0ae75", + "name": "Bot test", + "type": "n8n-nodes-base.discord", + "typeVersion": 2, + "position": [ + -420, + 560 + ], + "credentials": { + "discordBotApi": { + "id": "KaIz8dqE3Vy1E3iL", + "name": "Discord Bot account" + } + } + }, + { + "parameters": {}, + "id": "10450e91-8642-4b92-af15-9d5ad161b527", + "name": "No Operation, do nothing", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ + -200, + 560 + ] + } + ], + "pinData": { + "No Operation, do nothing": [ + { + "json": { + "success": true + } + } + ] + }, + "connections": { + "When clicking \"Execute Workflow\"": { + "main": [ + [ + { + "node": "Bot test", + "type": "main", + "index": 0 + } + ] + ] + }, + "Bot test": { + "main": [ + [ + { + "node": "No Operation, do nothing", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": {}, + "versionId": "e3780e00-5acb-4c2f-8c4f-85a9fb6698c9", + "id": "4DdFKgGmLX07cXvG", + "meta": { + "instanceId": "b888bd11cd1ddbb95450babf3e199556799d999b896f650de768b8370ee50363" + }, + "tags": [] +} diff --git a/packages/nodes-base/nodes/Discord/test/v2/node/message/deleteMessage.test.ts b/packages/nodes-base/nodes/Discord/test/v2/node/message/deleteMessage.test.ts new file mode 100644 index 0000000000..5a2fc3c578 --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/node/message/deleteMessage.test.ts @@ -0,0 +1,54 @@ +import type { INodeTypes } from 'n8n-workflow'; +import nock from 'nock'; +import * as transport from '../../../../v2/transport/discord.api'; +import { getResultNodeData, setup, workflowToTests } from '@test/nodes/Helpers'; +import type { WorkflowTestData } from '@test/nodes/types'; +import { executeWorkflow } from '@test/nodes/ExecuteWorkflow'; + +const discordApiRequestSpy = jest.spyOn(transport, 'discordApiRequest'); + +discordApiRequestSpy.mockImplementation(async (method: string, endpoint) => { + if (method === 'DELETE') { + return { + success: true, + }; + } +}); + +describe('Test DiscordV2, message => deleteMessage', () => { + const workflows = ['nodes/Discord/test/v2/node/message/deleteMessage.workflow.json']; + const tests = workflowToTests(workflows); + + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.resetAllMocks(); + }); + + const nodeTypes = setup(tests); + + const testNode = async (testData: WorkflowTestData, types: INodeTypes) => { + const { result } = await executeWorkflow(testData, types); + + const resultNodeData = getResultNodeData(result, testData); + + resultNodeData.forEach(({ nodeName, resultData }) => { + return expect(resultData).toEqual(testData.output.nodeData[nodeName]); + }); + + expect(discordApiRequestSpy).toHaveBeenCalledTimes(1); + expect(discordApiRequestSpy).toHaveBeenCalledWith( + 'DELETE', + '/channels/1168516240332034067/messages/1168776343194972210', + ); + + expect(result.finished).toEqual(true); + }; + + for (const testData of tests) { + test(testData.description, async () => testNode(testData, nodeTypes)); + } +}); diff --git a/packages/nodes-base/nodes/Discord/test/v2/node/message/deleteMessage.workflow.json b/packages/nodes-base/nodes/Discord/test/v2/node/message/deleteMessage.workflow.json new file mode 100644 index 0000000000..84cd5870be --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/node/message/deleteMessage.workflow.json @@ -0,0 +1,103 @@ +{ + "name": "discord overhaul tests", + "nodes": [ + { + "parameters": {}, + "id": "254a9d9b-43bf-4f6e-a761-d78146a05838", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + -660, + 560 + ] + }, + { + "parameters": { + "resource": "message", + "operation": "deleteMessage", + "guildId": { + "__rl": true, + "value": "1168516062791340136", + "mode": "list", + "cachedResultName": "TEST server", + "cachedResultUrl": "https://discord.com/channels/1168516062791340136" + }, + "channelId": { + "__rl": true, + "value": "1168516240332034067", + "mode": "list", + "cachedResultName": "first-channel", + "cachedResultUrl": "https://discord.com/channels/1168516062791340136/1168516240332034067" + }, + "messageId": "1168776343194972210" + }, + "id": "7e638897-0581-42e6-8b89-494908e0ae75", + "name": "Bot test", + "type": "n8n-nodes-base.discord", + "typeVersion": 2, + "position": [ + -420, + 560 + ], + "credentials": { + "discordBotApi": { + "id": "KaIz8dqE3Vy1E3iL", + "name": "Discord Bot account" + } + } + }, + { + "parameters": {}, + "id": "10450e91-8642-4b92-af15-9d5ad161b527", + "name": "No Operation, do nothing", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ + -200, + 560 + ] + } + ], + "pinData": { + "No Operation, do nothing": [ + { + "json": { + "success": true + } + } + ] + }, + "connections": { + "When clicking \"Execute Workflow\"": { + "main": [ + [ + { + "node": "Bot test", + "type": "main", + "index": 0 + } + ] + ] + }, + "Bot test": { + "main": [ + [ + { + "node": "No Operation, do nothing", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": {}, + "versionId": "15dbf56e-707a-4b5c-814c-ecf78d96d87f", + "id": "4DdFKgGmLX07cXvG", + "meta": { + "instanceId": "b888bd11cd1ddbb95450babf3e199556799d999b896f650de768b8370ee50363" + }, + "tags": [] +} diff --git a/packages/nodes-base/nodes/Discord/test/v2/node/message/get.test.ts b/packages/nodes-base/nodes/Discord/test/v2/node/message/get.test.ts new file mode 100644 index 0000000000..5dd36d8711 --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/node/message/get.test.ts @@ -0,0 +1,73 @@ +import type { INodeTypes } from 'n8n-workflow'; +import nock from 'nock'; +import * as transport from '../../../../v2/transport/discord.api'; +import { getResultNodeData, setup, workflowToTests } from '@test/nodes/Helpers'; +import type { WorkflowTestData } from '@test/nodes/types'; +import { executeWorkflow } from '@test/nodes/ExecuteWorkflow'; + +const discordApiRequestSpy = jest.spyOn(transport, 'discordApiRequest'); + +discordApiRequestSpy.mockImplementation(async (method: string, endpoint) => { + if (method === 'GET') { + return { + id: '1168777380144369718', + channel_id: '1168516240332034067', + author: { + id: '1070667629972430879', + username: 'n8n-node-overhaul', + avatar: null, + discriminator: '1037', + public_flags: 0, + premium_type: 0, + flags: 0, + bot: true, + banner: null, + accent_color: null, + global_name: null, + avatar_decoration_data: null, + banner_color: null, + }, + content: 'msg 3', + timestamp: '2023-10-31T05:04:02.260000+00:00', + type: 0, + }; + } +}); + +describe('Test DiscordV2, message => get', () => { + const workflows = ['nodes/Discord/test/v2/node/message/get.workflow.json']; + const tests = workflowToTests(workflows); + + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.resetAllMocks(); + }); + + const nodeTypes = setup(tests); + + const testNode = async (testData: WorkflowTestData, types: INodeTypes) => { + const { result } = await executeWorkflow(testData, types); + + const resultNodeData = getResultNodeData(result, testData); + + resultNodeData.forEach(({ nodeName, resultData }) => { + return expect(resultData).toEqual(testData.output.nodeData[nodeName]); + }); + + expect(discordApiRequestSpy).toHaveBeenCalledTimes(1); + expect(discordApiRequestSpy).toHaveBeenCalledWith( + 'GET', + '/channels/1168516240332034067/messages/1168777380144369718', + ); + + expect(result.finished).toEqual(true); + }; + + for (const testData of tests) { + test(testData.description, async () => testNode(testData, nodeTypes)); + } +}); diff --git a/packages/nodes-base/nodes/Discord/test/v2/node/message/get.workflow.json b/packages/nodes-base/nodes/Discord/test/v2/node/message/get.workflow.json new file mode 100644 index 0000000000..85df4cfdbe --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/node/message/get.workflow.json @@ -0,0 +1,125 @@ +{ + "name": "discord overhaul tests", + "nodes": [ + { + "parameters": {}, + "id": "254a9d9b-43bf-4f6e-a761-d78146a05838", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + -660, + 560 + ] + }, + { + "parameters": { + "resource": "message", + "operation": "get", + "guildId": { + "__rl": true, + "value": "1168516062791340136", + "mode": "list", + "cachedResultName": "TEST server", + "cachedResultUrl": "https://discord.com/channels/1168516062791340136" + }, + "channelId": { + "__rl": true, + "value": "1168516240332034067", + "mode": "list", + "cachedResultName": "first-channel", + "cachedResultUrl": "https://discord.com/channels/1168516062791340136/1168516240332034067" + }, + "messageId": "1168777380144369718", + "options": { + "simplify": true + } + }, + "id": "7e638897-0581-42e6-8b89-494908e0ae75", + "name": "Bot test", + "type": "n8n-nodes-base.discord", + "typeVersion": 2, + "position": [ + -420, + 560 + ], + "credentials": { + "discordBotApi": { + "id": "KaIz8dqE3Vy1E3iL", + "name": "Discord Bot account" + } + } + }, + { + "parameters": {}, + "id": "10450e91-8642-4b92-af15-9d5ad161b527", + "name": "No Operation, do nothing", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ + -200, + 560 + ] + } + ], + "pinData": { + "No Operation, do nothing": [ + { + "json": { + "id": "1168777380144369718", + "channel_id": "1168516240332034067", + "author": { + "id": "1070667629972430879", + "username": "n8n-node-overhaul", + "avatar": null, + "discriminator": "1037", + "public_flags": 0, + "premium_type": 0, + "flags": 0, + "bot": true, + "banner": null, + "accent_color": null, + "global_name": null, + "avatar_decoration_data": null, + "banner_color": null + }, + "content": "msg 3", + "timestamp": "2023-10-31T05:04:02.260000+00:00", + "type": 0 + } + } + ] + }, + "connections": { + "When clicking \"Execute Workflow\"": { + "main": [ + [ + { + "node": "Bot test", + "type": "main", + "index": 0 + } + ] + ] + }, + "Bot test": { + "main": [ + [ + { + "node": "No Operation, do nothing", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": {}, + "versionId": "876e52a8-2fb3-4efc-9ef0-123807be3806", + "id": "4DdFKgGmLX07cXvG", + "meta": { + "instanceId": "b888bd11cd1ddbb95450babf3e199556799d999b896f650de768b8370ee50363" + }, + "tags": [] +} diff --git a/packages/nodes-base/nodes/Discord/test/v2/node/message/getAll.test.ts b/packages/nodes-base/nodes/Discord/test/v2/node/message/getAll.test.ts new file mode 100644 index 0000000000..0d2d4d7702 --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/node/message/getAll.test.ts @@ -0,0 +1,98 @@ +import type { INodeTypes } from 'n8n-workflow'; +import nock from 'nock'; +import * as transport from '../../../../v2/transport/discord.api'; +import { getResultNodeData, setup, workflowToTests } from '@test/nodes/Helpers'; +import type { WorkflowTestData } from '@test/nodes/types'; +import { executeWorkflow } from '@test/nodes/ExecuteWorkflow'; + +const discordApiRequestSpy = jest.spyOn(transport, 'discordApiRequest'); + +discordApiRequestSpy.mockImplementation(async (method: string, endpoint) => { + if (method === 'GET') { + return [ + { + id: '1168784010269433998', + type: 0, + content: 'msg 4', + channel_id: '1168516240332034067', + author: { + id: '1070667629972430879', + username: 'n8n-node-overhaul', + avatar: null, + discriminator: '1037', + public_flags: 0, + premium_type: 0, + flags: 0, + bot: true, + banner: null, + accent_color: null, + global_name: null, + avatar_decoration_data: null, + banner_color: null, + }, + attachments: [], + embeds: [ + { + type: 'rich', + title: 'Some Title', + description: 'description', + color: 2112935, + timestamp: '2023-10-30T22:00:00+00:00', + author: { + name: 'Me', + }, + }, + ], + mentions: [], + mention_roles: [], + pinned: false, + mention_everyone: false, + tts: false, + timestamp: '2023-10-31T05:30:23.005000+00:00', + edited_timestamp: null, + flags: 0, + components: [], + }, + ]; + } +}); + +describe('Test DiscordV2, message => getAll', () => { + const workflows = ['nodes/Discord/test/v2/node/message/getAll.workflow.json']; + const tests = workflowToTests(workflows); + + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.resetAllMocks(); + }); + + const nodeTypes = setup(tests); + + const testNode = async (testData: WorkflowTestData, types: INodeTypes) => { + const { result } = await executeWorkflow(testData, types); + + const resultNodeData = getResultNodeData(result, testData); + + resultNodeData.forEach(({ nodeName, resultData }) => { + return expect(resultData).toEqual(testData.output.nodeData[nodeName]); + }); + + expect(discordApiRequestSpy).toHaveBeenCalledTimes(1); + expect(discordApiRequestSpy).toHaveBeenCalledWith( + 'GET', + '/channels/1168516240332034067/messages', + undefined, + { limit: 1 }, + ); + + expect(result.finished).toEqual(true); + }; + + for (const testData of tests) { + test(testData.description, async () => testNode(testData, nodeTypes)); + } +}); diff --git a/packages/nodes-base/nodes/Discord/test/v2/node/message/getAll.workflow.json b/packages/nodes-base/nodes/Discord/test/v2/node/message/getAll.workflow.json new file mode 100644 index 0000000000..212ae76271 --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/node/message/getAll.workflow.json @@ -0,0 +1,144 @@ +{ + "name": "discord overhaul tests", + "nodes": [ + { + "parameters": {}, + "id": "254a9d9b-43bf-4f6e-a761-d78146a05838", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + -660, + 560 + ] + }, + { + "parameters": { + "resource": "message", + "operation": "getAll", + "guildId": { + "__rl": true, + "value": "1168516062791340136", + "mode": "list", + "cachedResultName": "TEST server", + "cachedResultUrl": "https://discord.com/channels/1168516062791340136" + }, + "channelId": { + "__rl": true, + "value": "1168516240332034067", + "mode": "list", + "cachedResultName": "first-channel", + "cachedResultUrl": "https://discord.com/channels/1168516062791340136/1168516240332034067" + }, + "limit": 1, + "options": {} + }, + "id": "7e638897-0581-42e6-8b89-494908e0ae75", + "name": "Bot test", + "type": "n8n-nodes-base.discord", + "typeVersion": 2, + "position": [ + -420, + 560 + ], + "credentials": { + "discordBotApi": { + "id": "KaIz8dqE3Vy1E3iL", + "name": "Discord Bot account" + } + } + }, + { + "parameters": {}, + "id": "10450e91-8642-4b92-af15-9d5ad161b527", + "name": "No Operation, do nothing", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ + -200, + 560 + ] + } + ], + "pinData": { + "No Operation, do nothing": [ + { + "json": { + "id": "1168784010269433998", + "type": 0, + "content": "msg 4", + "channel_id": "1168516240332034067", + "author": { + "id": "1070667629972430879", + "username": "n8n-node-overhaul", + "avatar": null, + "discriminator": "1037", + "public_flags": 0, + "premium_type": 0, + "flags": 0, + "bot": true, + "banner": null, + "accent_color": null, + "global_name": null, + "avatar_decoration_data": null, + "banner_color": null + }, + "attachments": [], + "embeds": [ + { + "type": "rich", + "title": "Some Title", + "description": "description", + "color": 2112935, + "timestamp": "2023-10-30T22:00:00+00:00", + "author": { + "name": "Me" + } + } + ], + "mentions": [], + "mention_roles": [], + "pinned": false, + "mention_everyone": false, + "tts": false, + "timestamp": "2023-10-31T05:30:23.005000+00:00", + "edited_timestamp": null, + "flags": 0, + "components": [] + } + } + ] + }, + "connections": { + "When clicking \"Execute Workflow\"": { + "main": [ + [ + { + "node": "Bot test", + "type": "main", + "index": 0 + } + ] + ] + }, + "Bot test": { + "main": [ + [ + { + "node": "No Operation, do nothing", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": {}, + "versionId": "fd45b651-cad2-4985-bcee-9c87efeb9af5", + "id": "4DdFKgGmLX07cXvG", + "meta": { + "instanceId": "b888bd11cd1ddbb95450babf3e199556799d999b896f650de768b8370ee50363" + }, + "tags": [] +} diff --git a/packages/nodes-base/nodes/Discord/test/v2/node/message/react.test.ts b/packages/nodes-base/nodes/Discord/test/v2/node/message/react.test.ts new file mode 100644 index 0000000000..c830c6a580 --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/node/message/react.test.ts @@ -0,0 +1,54 @@ +import type { INodeTypes } from 'n8n-workflow'; +import nock from 'nock'; +import * as transport from '../../../../v2/transport/discord.api'; +import { getResultNodeData, setup, workflowToTests } from '@test/nodes/Helpers'; +import type { WorkflowTestData } from '@test/nodes/types'; +import { executeWorkflow } from '@test/nodes/ExecuteWorkflow'; + +const discordApiRequestSpy = jest.spyOn(transport, 'discordApiRequest'); + +discordApiRequestSpy.mockImplementation(async (method: string, endpoint) => { + if (method === 'PUT') { + return { + success: true, + }; + } +}); + +describe('Test DiscordV2, message => react', () => { + const workflows = ['nodes/Discord/test/v2/node/message/react.workflow.json']; + const tests = workflowToTests(workflows); + + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.resetAllMocks(); + }); + + const nodeTypes = setup(tests); + + const testNode = async (testData: WorkflowTestData, types: INodeTypes) => { + const { result } = await executeWorkflow(testData, types); + + const resultNodeData = getResultNodeData(result, testData); + + resultNodeData.forEach(({ nodeName, resultData }) => { + return expect(resultData).toEqual(testData.output.nodeData[nodeName]); + }); + + expect(discordApiRequestSpy).toHaveBeenCalledTimes(1); + expect(discordApiRequestSpy).toHaveBeenCalledWith( + 'PUT', + '/channels/1168516240332034067/messages/1168777380144369718/reactions/%F0%9F%98%80/@me', + ); + + expect(result.finished).toEqual(true); + }; + + for (const testData of tests) { + test(testData.description, async () => testNode(testData, nodeTypes)); + } +}); diff --git a/packages/nodes-base/nodes/Discord/test/v2/node/message/react.workflow.json b/packages/nodes-base/nodes/Discord/test/v2/node/message/react.workflow.json new file mode 100644 index 0000000000..9f27053f6e --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/node/message/react.workflow.json @@ -0,0 +1,104 @@ +{ + "name": "discord overhaul tests", + "nodes": [ + { + "parameters": {}, + "id": "254a9d9b-43bf-4f6e-a761-d78146a05838", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + -660, + 560 + ] + }, + { + "parameters": { + "resource": "message", + "operation": "react", + "guildId": { + "__rl": true, + "value": "1168516062791340136", + "mode": "list", + "cachedResultName": "TEST server", + "cachedResultUrl": "https://discord.com/channels/1168516062791340136" + }, + "channelId": { + "__rl": true, + "value": "1168516240332034067", + "mode": "list", + "cachedResultName": "first-channel", + "cachedResultUrl": "https://discord.com/channels/1168516062791340136/1168516240332034067" + }, + "messageId": "1168777380144369718", + "emoji": "😀" + }, + "id": "7e638897-0581-42e6-8b89-494908e0ae75", + "name": "Bot test", + "type": "n8n-nodes-base.discord", + "typeVersion": 2, + "position": [ + -420, + 560 + ], + "credentials": { + "discordBotApi": { + "id": "KaIz8dqE3Vy1E3iL", + "name": "Discord Bot account" + } + } + }, + { + "parameters": {}, + "id": "10450e91-8642-4b92-af15-9d5ad161b527", + "name": "No Operation, do nothing", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ + -200, + 560 + ] + } + ], + "pinData": { + "No Operation, do nothing": [ + { + "json": { + "success": true + } + } + ] + }, + "connections": { + "When clicking \"Execute Workflow\"": { + "main": [ + [ + { + "node": "Bot test", + "type": "main", + "index": 0 + } + ] + ] + }, + "Bot test": { + "main": [ + [ + { + "node": "No Operation, do nothing", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": {}, + "versionId": "cf2de495-fe80-4a98-9d06-c251ea1661ad", + "id": "4DdFKgGmLX07cXvG", + "meta": { + "instanceId": "b888bd11cd1ddbb95450babf3e199556799d999b896f650de768b8370ee50363" + }, + "tags": [] +} diff --git a/packages/nodes-base/nodes/Discord/test/v2/node/message/send.test.ts b/packages/nodes-base/nodes/Discord/test/v2/node/message/send.test.ts new file mode 100644 index 0000000000..81292e3fdc --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/node/message/send.test.ts @@ -0,0 +1,107 @@ +import type { INodeTypes } from 'n8n-workflow'; +import nock from 'nock'; +import * as transport from '../../../../v2/transport/discord.api'; +import { getResultNodeData, setup, workflowToTests } from '@test/nodes/Helpers'; +import type { WorkflowTestData } from '@test/nodes/types'; +import { executeWorkflow } from '@test/nodes/ExecuteWorkflow'; + +const discordApiRequestSpy = jest.spyOn(transport, 'discordApiRequest'); + +discordApiRequestSpy.mockImplementation(async (method: string, endpoint) => { + if (method === 'POST') { + return { + id: '1168784010269433998', + type: 0, + content: 'msg 4', + channel_id: '1168516240332034067', + author: { + id: '1070667629972430879', + username: 'n8n-node-overhaul', + avatar: null, + discriminator: '1037', + public_flags: 0, + premium_type: 0, + flags: 0, + bot: true, + banner: null, + accent_color: null, + global_name: null, + avatar_decoration_data: null, + banner_color: null, + }, + attachments: [], + embeds: [ + { + type: 'rich', + title: 'Some Title', + description: 'description', + color: 2112935, + timestamp: '2023-10-30T22:00:00+00:00', + author: { + name: 'Me', + }, + }, + ], + mentions: [], + mention_roles: [], + pinned: false, + mention_everyone: false, + tts: false, + timestamp: '2023-10-31T05:30:23.005000+00:00', + edited_timestamp: null, + flags: 0, + components: [], + referenced_message: null, + }; + } +}); + +describe('Test DiscordV2, message => send', () => { + const workflows = ['nodes/Discord/test/v2/node/message/send.workflow.json']; + const tests = workflowToTests(workflows); + + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.resetAllMocks(); + }); + + const nodeTypes = setup(tests); + + const testNode = async (testData: WorkflowTestData, types: INodeTypes) => { + const { result } = await executeWorkflow(testData, types); + + const resultNodeData = getResultNodeData(result, testData); + + resultNodeData.forEach(({ nodeName, resultData }) => { + return expect(resultData).toEqual(testData.output.nodeData[nodeName]); + }); + + expect(discordApiRequestSpy).toHaveBeenCalledTimes(1); + expect(discordApiRequestSpy).toHaveBeenCalledWith( + 'POST', + '/channels/1168516240332034067/messages', + { + content: 'msg 4', + embeds: [ + { + author: { name: 'Me' }, + color: 2112935, + description: 'description', + timestamp: '2023-10-30T22:00:00.000Z', + title: 'Some Title', + }, + ], + }, + ); + + expect(result.finished).toEqual(true); + }; + + for (const testData of tests) { + test(testData.description, async () => testNode(testData, nodeTypes)); + } +}); diff --git a/packages/nodes-base/nodes/Discord/test/v2/node/message/send.workflow.json b/packages/nodes-base/nodes/Discord/test/v2/node/message/send.workflow.json new file mode 100644 index 0000000000..f1ca99ef37 --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/node/message/send.workflow.json @@ -0,0 +1,155 @@ +{ + "name": "discord overhaul tests", + "nodes": [ + { + "parameters": {}, + "id": "254a9d9b-43bf-4f6e-a761-d78146a05838", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + -660, + 560 + ] + }, + { + "parameters": { + "resource": "message", + "guildId": { + "__rl": true, + "value": "1168516062791340136", + "mode": "list", + "cachedResultName": "TEST server", + "cachedResultUrl": "https://discord.com/channels/1168516062791340136" + }, + "channelId": { + "__rl": true, + "value": "1168516240332034067", + "mode": "list", + "cachedResultName": "first-channel", + "cachedResultUrl": "https://discord.com/channels/1168516062791340136/1168516240332034067" + }, + "content": "msg 4", + "options": {}, + "embeds": { + "values": [ + { + "description": "description", + "author": "Me", + "color": "#203DA7", + "timestamp": "2023-10-30T22:00:00.000Z", + "title": "Some Title" + } + ] + } + }, + "id": "7e638897-0581-42e6-8b89-494908e0ae75", + "name": "Bot test", + "type": "n8n-nodes-base.discord", + "typeVersion": 2, + "position": [ + -420, + 560 + ], + "credentials": { + "discordBotApi": { + "id": "KaIz8dqE3Vy1E3iL", + "name": "Discord Bot account" + } + } + }, + { + "parameters": {}, + "id": "10450e91-8642-4b92-af15-9d5ad161b527", + "name": "No Operation, do nothing", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ + -200, + 560 + ] + } + ], + "pinData": { + "No Operation, do nothing": [ + { + "json": { + "id": "1168784010269433998", + "type": 0, + "content": "msg 4", + "channel_id": "1168516240332034067", + "author": { + "id": "1070667629972430879", + "username": "n8n-node-overhaul", + "avatar": null, + "discriminator": "1037", + "public_flags": 0, + "premium_type": 0, + "flags": 0, + "bot": true, + "banner": null, + "accent_color": null, + "global_name": null, + "avatar_decoration_data": null, + "banner_color": null + }, + "attachments": [], + "embeds": [ + { + "type": "rich", + "title": "Some Title", + "description": "description", + "color": 2112935, + "timestamp": "2023-10-30T22:00:00+00:00", + "author": { + "name": "Me" + } + } + ], + "mentions": [], + "mention_roles": [], + "pinned": false, + "mention_everyone": false, + "tts": false, + "timestamp": "2023-10-31T05:30:23.005000+00:00", + "edited_timestamp": null, + "flags": 0, + "components": [], + "referenced_message": null + } + } + ] + }, + "connections": { + "When clicking \"Execute Workflow\"": { + "main": [ + [ + { + "node": "Bot test", + "type": "main", + "index": 0 + } + ] + ] + }, + "Bot test": { + "main": [ + [ + { + "node": "No Operation, do nothing", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": {}, + "versionId": "5b208250-62db-4b00-9e02-53392eb838a9", + "id": "4DdFKgGmLX07cXvG", + "meta": { + "instanceId": "b888bd11cd1ddbb95450babf3e199556799d999b896f650de768b8370ee50363" + }, + "tags": [] +} diff --git a/packages/nodes-base/nodes/Discord/test/v2/node/webhook/sendLegacy.test.ts b/packages/nodes-base/nodes/Discord/test/v2/node/webhook/sendLegacy.test.ts new file mode 100644 index 0000000000..04c1e77218 --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/node/webhook/sendLegacy.test.ts @@ -0,0 +1,104 @@ +import type { INodeTypes } from 'n8n-workflow'; +import nock from 'nock'; +import * as transport from '../../../../v2/transport/discord.api'; +import { getResultNodeData, setup, workflowToTests } from '@test/nodes/Helpers'; +import type { WorkflowTestData } from '@test/nodes/types'; +import { executeWorkflow } from '@test/nodes/ExecuteWorkflow'; + +const discordApiRequestSpy = jest.spyOn(transport, 'discordApiRequest'); + +discordApiRequestSpy.mockImplementation(async (method: string, endpoint) => { + if (method === 'POST') { + return { + id: '1168768986385747999', + type: 0, + content: 'TEST Message', + channel_id: '1074646335082479626', + author: { + id: '1153265494955135077', + username: 'TEST_USER', + avatar: null, + discriminator: '0000', + public_flags: 0, + flags: 0, + bot: true, + global_name: null, + }, + attachments: [], + embeds: [ + { + type: 'rich', + description: 'some description', + color: 10930459, + timestamp: '2023-10-17T21:00:00+00:00', + author: { + name: 'Michael', + }, + }, + ], + mentions: [], + mention_roles: [], + pinned: false, + mention_everyone: false, + tts: true, + timestamp: '2023-10-31T04:30:41.032000+00:00', + edited_timestamp: null, + flags: 4096, + components: [], + webhook_id: '1153265494955135077', + }; + } +}); + +describe('Test DiscordV2, webhook => sendLegacy', () => { + const workflows = ['nodes/Discord/test/v2/node/webhook/sendLegacy.workflow.json']; + const tests = workflowToTests(workflows); + + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.resetAllMocks(); + }); + + const nodeTypes = setup(tests); + + const testNode = async (testData: WorkflowTestData, types: INodeTypes) => { + const { result } = await executeWorkflow(testData, types); + + const resultNodeData = getResultNodeData(result, testData); + + resultNodeData.forEach(({ nodeName, resultData }) => { + return expect(resultData).toEqual(testData.output.nodeData[nodeName]); + }); + + expect(discordApiRequestSpy).toHaveBeenCalledTimes(1); + expect(discordApiRequestSpy).toHaveBeenCalledWith( + 'POST', + '', + { + content: 'TEST Message', + embeds: [ + { + author: { name: 'Michael' }, + color: 10930459, + description: 'some description', + timestamp: '2023-10-17T21:00:00.000Z', + }, + ], + flags: 4096, + tts: true, + username: 'TEST_USER', + }, + { wait: true }, + ); + + expect(result.finished).toEqual(true); + }; + + for (const testData of tests) { + test(testData.description, async () => testNode(testData, nodeTypes)); + } +}); diff --git a/packages/nodes-base/nodes/Discord/test/v2/node/webhook/sendLegacy.workflow.json b/packages/nodes-base/nodes/Discord/test/v2/node/webhook/sendLegacy.workflow.json new file mode 100644 index 0000000000..3ae3b9b01b --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/node/webhook/sendLegacy.workflow.json @@ -0,0 +1,141 @@ +{ + "name": "discord overhaul tests copy", + "nodes": [ + { + "parameters": {}, + "id": "8fb04834-2c97-4f21-9300-0f38b0e82f08", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + -660, + 560 + ] + }, + { + "parameters": { + "authentication": "webhook", + "content": "TEST Message", + "options": { + "flags": [ + "SUPPRESS_NOTIFICATIONS" + ], + "tts": true, + "username": "TEST_USER", + "wait": true + }, + "embeds": { + "values": [ + { + "description": "some description", + "author": "Michael", + "color": "#A6C91B", + "timestamp": "2023-10-17T21:00:00.000Z" + } + ] + } + }, + "id": "61f96217-f6b3-4989-be70-77b723e8e169", + "name": "Bot test", + "type": "n8n-nodes-base.discord", + "typeVersion": 2, + "position": [ + -420, + 560 + ], + "credentials": { + "discordWebhookApi": { + "id": "86", + "name": "Discord account 3" + } + } + }, + { + "parameters": {}, + "id": "c9c936f7-7dee-40d2-bcf6-255cc9d6d5e8", + "name": "No Operation, do nothing", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ + -200, + 560 + ] + } + ], + "pinData": { + "No Operation, do nothing": [ + { + "json": { + "id": "1168768986385747999", + "type": 0, + "content": "TEST Message", + "channel_id": "1074646335082479626", + "author": { + "id": "1153265494955135077", + "username": "TEST_USER", + "avatar": null, + "discriminator": "0000", + "public_flags": 0, + "flags": 0, + "bot": true, + "global_name": null + }, + "attachments": [], + "embeds": [ + { + "type": "rich", + "description": "some description", + "color": 10930459, + "timestamp": "2023-10-17T21:00:00+00:00", + "author": { + "name": "Michael" + } + } + ], + "mentions": [], + "mention_roles": [], + "pinned": false, + "mention_everyone": false, + "tts": true, + "timestamp": "2023-10-31T04:30:41.032000+00:00", + "edited_timestamp": null, + "flags": 4096, + "components": [], + "webhook_id": "1153265494955135077" + } + } + ] + }, + "connections": { + "When clicking \"Execute Workflow\"": { + "main": [ + [ + { + "node": "Bot test", + "type": "main", + "index": 0 + } + ] + ] + }, + "Bot test": { + "main": [ + [ + { + "node": "No Operation, do nothing", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": {}, + "versionId": "a4deab55-3791-4e57-b879-d804cd839348", + "id": "Hpl0rsKs6xAbHVO4", + "meta": { + "instanceId": "b888bd11cd1ddbb95450babf3e199556799d999b896f650de768b8370ee50363" + }, + "tags": [] +} diff --git a/packages/nodes-base/nodes/Discord/test/v2/utils.test.ts b/packages/nodes-base/nodes/Discord/test/v2/utils.test.ts new file mode 100644 index 0000000000..a54d48eee9 --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/utils.test.ts @@ -0,0 +1,160 @@ +import type { IExecuteFunctions, INode } from 'n8n-workflow'; +import { + createSimplifyFunction, + prepareOptions, + prepareEmbeds, + checkAccessToGuild, + setupChannelGetter, +} from '../../v2/helpers/utils'; + +import * as transport from '../../v2//transport/discord.api'; + +const node: INode = { + id: '1', + name: 'Discord node', + typeVersion: 2, + type: 'n8n-nodes-base.discord', + position: [60, 760], + parameters: { + resource: 'channel', + operation: 'get', + }, +}; + +describe('Test Discord > createSimplifyFunction', () => { + it('should create function', () => { + const result = createSimplifyFunction(['message_reference']); + expect(result).toBeDefined(); + expect(typeof result).toBe('function'); + }); + + it('should return object containing only specified fields', () => { + const simplify = createSimplifyFunction(['id', 'name']); + const data = { + id: '123', + name: 'test', + type: 'test', + randomField: 'test', + }; + const result = simplify(data); + expect(result).toEqual({ + id: '123', + name: 'test', + }); + }); +}); + +describe('Test Discord > prepareOptions', () => { + it('should return correct flag value', () => { + const result = prepareOptions({ + flags: ['SUPPRESS_EMBEDS', 'SUPPRESS_NOTIFICATIONS'], + }); + expect(result.flags).toBe((1 << 2) + (1 << 12)); + }); + + it('should convert message_reference', () => { + const result = prepareOptions( + { + message_reference: '123456', + }, + '789000', + ); + expect(result.message_reference).toEqual({ + message_id: '123456', + guild_id: '789000', + }); + }); +}); + +describe('Test Discord > prepareEmbeds', () => { + it('should return return empty object removing empty strings', () => { + const embeds = [ + { + test1: 'test', + test2: 'test', + description: 'test', + }, + ]; + + const executeFunction = {}; + + const result = prepareEmbeds.call(executeFunction as unknown as IExecuteFunctions, embeds); + + expect(result).toEqual(embeds); + }); +}); + +describe('Test Discord > checkAccessToGuild', () => { + it('should throw error', () => { + const guildId = '123456'; + const guilds = [ + { + id: '789000', + }, + ]; + + expect(() => { + checkAccessToGuild(node, guildId, guilds); + }).toThrow('You do not have access to the guild with the id 123456'); + }); + + it('should pass', () => { + const guildId = '123456'; + const guilds = [ + { + id: '123456', + }, + { + id: '789000', + }, + ]; + + expect(() => { + checkAccessToGuild(node, guildId, guilds); + }).not.toThrow(); + }); +}); + +describe('Test Discord > setupChannelGetter & checkAccessToChannel', () => { + const discordApiRequestSpy = jest.spyOn(transport, 'discordApiRequest'); + discordApiRequestSpy.mockImplementation(async (method: string) => { + if (method === 'GET') { + return { + guild_id: '123456', + }; + } + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it('should setup channel getter and get channel id', async () => { + const fakeExecuteFunction = (auth: string) => { + return { + getNodeParameter: (parameter: string) => { + if (parameter === 'authentication') return auth; + if (parameter === 'channelId') return '42'; + }, + getNode: () => node, + } as unknown as IExecuteFunctions; + }; + + const userGuilds = [ + { + id: '789000', + }, + ]; + + try { + const getChannel = await setupChannelGetter.call(fakeExecuteFunction('oAuth2'), userGuilds); + await getChannel(0); + } catch (error) { + expect(error.message).toBe('You do not have access to the guild with the id 123456'); + } + + const getChannel = await setupChannelGetter.call(fakeExecuteFunction('botToken'), userGuilds); + const channelId = await getChannel(0); + expect(channelId).toBe('42'); + }); +}); diff --git a/packages/nodes-base/nodes/Discord/v1/DiscordV1.node.ts b/packages/nodes-base/nodes/Discord/v1/DiscordV1.node.ts new file mode 100644 index 0000000000..00cc56d03f --- /dev/null +++ b/packages/nodes-base/nodes/Discord/v1/DiscordV1.node.ts @@ -0,0 +1,291 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ + +import type { + IExecuteFunctions, + INodeExecutionData, + INodeType, + INodeTypeBaseDescription, + INodeTypeDescription, +} from 'n8n-workflow'; +import { jsonParse, NodeApiError, NodeOperationError, sleep } from 'n8n-workflow'; + +import type { DiscordAttachment, DiscordWebhook } from './Interfaces'; + +import { oldVersionNotice } from '../../../utils/descriptions'; + +const versionDescription: INodeTypeDescription = { + displayName: 'Discord', + name: 'discord', + icon: 'file:discord.svg', + group: ['output'], + version: 1, + description: 'Sends data to Discord', + defaults: { + name: 'Discord', + }, + inputs: ['main'], + outputs: ['main'], + properties: [ + oldVersionNotice, + { + displayName: 'Webhook URL', + name: 'webhookUri', + type: 'string', + required: true, + default: '', + placeholder: 'https://discord.com/api/webhooks/ID/TOKEN', + }, + { + displayName: 'Content', + name: 'text', + type: 'string', + typeOptions: { + maxValue: 2000, + }, + default: '', + placeholder: 'Hello World!', + }, + { + displayName: 'Additional Fields', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Allowed Mentions', + name: 'allowedMentions', + type: 'json', + typeOptions: { alwaysOpenEditWindow: true, editor: 'code' }, + default: '', + }, + { + displayName: 'Attachments', + name: 'attachments', + type: 'json', + typeOptions: { alwaysOpenEditWindow: true, editor: 'code' }, + default: '', + }, + { + displayName: 'Avatar URL', + name: 'avatarUrl', + type: 'string', + default: '', + }, + { + displayName: 'Components', + name: 'components', + type: 'json', + typeOptions: { alwaysOpenEditWindow: true, editor: 'code' }, + default: '', + }, + { + displayName: 'Embeds', + name: 'embeds', + type: 'json', + typeOptions: { alwaysOpenEditWindow: true, editor: 'code' }, + default: '', + }, + { + displayName: 'Flags', + name: 'flags', + type: 'number', + default: '', + }, + { + displayName: 'JSON Payload', + name: 'payloadJson', + type: 'json', + typeOptions: { alwaysOpenEditWindow: true, editor: 'code' }, + default: '', + }, + { + displayName: 'Username', + name: 'username', + type: 'string', + default: '', + placeholder: 'User', + }, + { + displayName: 'TTS', + name: 'tts', + type: 'boolean', + default: false, + description: 'Whether this message be sent as a Text To Speech message', + }, + ], + }, + ], +}; + +export class DiscordV1 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + ...versionDescription, + }; + } + + async execute(this: IExecuteFunctions) { + const returnData: INodeExecutionData[] = []; + + const webhookUri = this.getNodeParameter('webhookUri', 0, '') as string; + + if (!webhookUri) throw new NodeOperationError(this.getNode(), 'Webhook uri is required.'); + + const items = this.getInputData(); + const length = items.length; + for (let i = 0; i < length; i++) { + const body: DiscordWebhook = {}; + + const iterationWebhookUri = this.getNodeParameter('webhookUri', i) as string; + body.content = this.getNodeParameter('text', i) as string; + const options = this.getNodeParameter('options', i); + + if (!body.content && !options.embeds) { + throw new NodeOperationError(this.getNode(), 'Either content or embeds must be set.', { + itemIndex: i, + }); + } + if (options.embeds) { + try { + //@ts-expect-error + body.embeds = JSON.parse(options.embeds); + if (!Array.isArray(body.embeds)) { + throw new NodeOperationError(this.getNode(), 'Embeds must be an array of embeds.', { + itemIndex: i, + }); + } + } catch (e) { + throw new NodeOperationError(this.getNode(), 'Embeds must be valid JSON.', { + itemIndex: i, + }); + } + } + if (options.username) { + body.username = options.username as string; + } + + if (options.components) { + try { + //@ts-expect-error + body.components = JSON.parse(options.components); + } catch (e) { + throw new NodeOperationError(this.getNode(), 'Components must be valid JSON.', { + itemIndex: i, + }); + } + } + + if (options.allowed_mentions) { + //@ts-expect-error + body.allowed_mentions = jsonParse(options.allowed_mentions); + } + + if (options.avatarUrl) { + body.avatar_url = options.avatarUrl as string; + } + + if (options.flags) { + body.flags = options.flags as number; + } + + if (options.tts) { + body.tts = options.tts as boolean; + } + + if (options.payloadJson) { + //@ts-expect-error + body.payload_json = jsonParse(options.payloadJson); + } + + if (options.attachments) { + //@ts-expect-error + body.attachments = jsonParse(options.attachments as DiscordAttachment[]); + } + + //* Not used props, delete them from the payload as Discord won't need them :^ + if (!body.content) delete body.content; + if (!body.username) delete body.username; + if (!body.avatar_url) delete body.avatar_url; + if (!body.embeds) delete body.embeds; + if (!body.allowed_mentions) delete body.allowed_mentions; + if (!body.flags) delete body.flags; + if (!body.components) delete body.components; + if (!body.payload_json) delete body.payload_json; + if (!body.attachments) delete body.attachments; + + let requestOptions; + + if (!body.payload_json) { + requestOptions = { + resolveWithFullResponse: true, + method: 'POST', + body, + uri: iterationWebhookUri, + headers: { + 'content-type': 'application/json; charset=utf-8', + }, + json: true, + }; + } else { + requestOptions = { + resolveWithFullResponse: true, + method: 'POST', + body, + uri: iterationWebhookUri, + headers: { + 'content-type': 'multipart/form-data; charset=utf-8', + }, + }; + } + let maxTries = 5; + let response; + + do { + try { + response = await this.helpers.request(requestOptions); + const resetAfter = response.headers['x-ratelimit-reset-after'] * 1000; + const remainingRatelimit = response.headers['x-ratelimit-remaining']; + + // remaining requests 0 + // https://discord.com/developers/docs/topics/rate-limits + if (!+remainingRatelimit) { + await sleep(resetAfter ?? 1000); + } + + break; + } catch (error) { + // HTTP/1.1 429 TOO MANY REQUESTS + // Await when the current rate limit will reset + // https://discord.com/developers/docs/topics/rate-limits + if (error.statusCode === 429) { + const retryAfter = error.response?.headers['retry-after'] || 1000; + + await sleep(+retryAfter); + + continue; + } + + throw error; + } + } while (--maxTries); + + if (maxTries <= 0) { + throw new NodeApiError(this.getNode(), { + error: 'Could not send Webhook message. Max amount of rate-limit retries reached.', + }); + } + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray({ success: true }), + { itemData: { item: i } }, + ); + returnData.push(...executionData); + } + + return [returnData]; + } +} diff --git a/packages/nodes-base/nodes/Discord/Interfaces.ts b/packages/nodes-base/nodes/Discord/v1/Interfaces.ts similarity index 100% rename from packages/nodes-base/nodes/Discord/Interfaces.ts rename to packages/nodes-base/nodes/Discord/v1/Interfaces.ts diff --git a/packages/nodes-base/nodes/Discord/v2/DiscordV2.node.ts b/packages/nodes-base/nodes/Discord/v2/DiscordV2.node.ts new file mode 100644 index 0000000000..cfec663665 --- /dev/null +++ b/packages/nodes-base/nodes/Discord/v2/DiscordV2.node.ts @@ -0,0 +1,35 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ + +import type { + IExecuteFunctions, + INodeExecutionData, + INodeType, + INodeTypeBaseDescription, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { listSearch, loadOptions } from './methods'; + +import { router } from './actions/router'; + +import { versionDescription } from './actions/versionDescription'; + +export class DiscordV2 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + ...versionDescription, + }; + } + + methods = { + listSearch, + loadOptions, + }; + + async execute(this: IExecuteFunctions): Promise { + return router.call(this); + } +} diff --git a/packages/nodes-base/nodes/Discord/v2/actions/channel/create.operation.ts b/packages/nodes-base/nodes/Discord/v2/actions/channel/create.operation.ts new file mode 100644 index 0000000000..5372fb92b7 --- /dev/null +++ b/packages/nodes-base/nodes/Discord/v2/actions/channel/create.operation.ts @@ -0,0 +1,206 @@ +import type { + IDataObject, + IExecuteFunctions, + INodeExecutionData, + INodeProperties, +} from 'n8n-workflow'; +import { updateDisplayOptions } from '../../../../../utils/utilities'; +import { parseDiscordError, prepareErrorData } from '../../helpers/utils'; +import { discordApiRequest } from '../../transport'; +import { categoryRLC } from '../common.description'; + +const properties: INodeProperties[] = [ + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + required: true, + description: 'The name of the channel', + placeholder: 'e.g. new-channel', + }, + { + displayName: 'Type', + name: 'type', + type: 'options', + default: '0', + required: true, + description: 'The type of channel to create', + options: [ + { + name: 'Guild Text', + value: '0', + }, + { + name: 'Guild Voice', + value: '2', + }, + { + name: 'Guild Category', + value: '4', + }, + ], + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Age-Restricted (NSFW)', + name: 'nsfw', + type: 'boolean', + default: false, + description: 'Whether the content of the channel might be nsfw (not safe for work)', + displayOptions: { + hide: { + '/type': ['4'], + }, + }, + }, + { + displayName: 'Bitrate', + name: 'bitrate', + type: 'number', + default: 8000, + placeholder: 'e.g. 8000', + typeOptions: { + minValue: 8000, + maxValue: 96000, + }, + description: 'The bitrate (in bits) of the voice channel', + displayOptions: { + show: { + '/type': ['2'], + }, + }, + }, + { + ...categoryRLC, + displayOptions: { + hide: { + '/type': ['4'], + }, + }, + }, + { + displayName: 'Position', + name: 'position', + type: 'number', + default: 1, + }, + { + displayName: 'Rate Limit Per User', + name: 'rate_limit_per_user', + type: 'number', + default: 0, + description: 'Amount of seconds a user has to wait before sending another message', + displayOptions: { + hide: { + '/type': ['4'], + }, + }, + }, + { + displayName: 'Topic', + name: 'topic', + type: 'string', + default: '', + typeOptions: { + rows: 2, + }, + description: 'The channel topic description (0-1024 characters)', + placeholder: 'e.g. This channel is about…', + displayOptions: { + hide: { + '/type': ['4'], + }, + }, + }, + { + displayName: 'User Limit', + name: 'user_limit', + type: 'number', + default: 0, + typeOptions: { + minValue: 0, + maxValue: 99, + }, + placeholder: 'e.g. 20', + description: + 'The limit for the number of members that can be in the channel (0 refers to no limit)', + displayOptions: { + show: { + '/type': ['2'], + }, + }, + }, + ], + }, +]; + +const displayOptions = { + show: { + resource: ['channel'], + operation: ['create'], + }, + hide: { + authentication: ['webhook'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute( + this: IExecuteFunctions, + guildId: string, +): Promise { + const returnData: INodeExecutionData[] = []; + const items = this.getInputData(); + + for (let i = 0; i < items.length; i++) { + try { + const name = this.getNodeParameter('name', i) as string; + const type = this.getNodeParameter('type', i) as string; + const options = this.getNodeParameter('options', i); + + if (options.categoryId) { + options.parent_id = (options.categoryId as IDataObject).value; + delete options.categoryId; + } + + const body: IDataObject = { + name, + type, + ...options, + }; + + const response = await discordApiRequest.call( + this, + 'POST', + `/guilds/${guildId}/channels`, + body, + ); + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response), + { itemData: { item: i } }, + ); + + returnData.push(...executionData); + } catch (error) { + const err = parseDiscordError.call(this, error, i); + + if (this.continueOnFail()) { + returnData.push(...prepareErrorData.call(this, err, i)); + continue; + } + + throw err; + } + } + + return returnData; +} diff --git a/packages/nodes-base/nodes/Discord/v2/actions/channel/deleteChannel.operation.ts b/packages/nodes-base/nodes/Discord/v2/actions/channel/deleteChannel.operation.ts new file mode 100644 index 0000000000..14a601938e --- /dev/null +++ b/packages/nodes-base/nodes/Discord/v2/actions/channel/deleteChannel.operation.ts @@ -0,0 +1,61 @@ +import type { + IDataObject, + IExecuteFunctions, + INodeExecutionData, + INodeProperties, +} from 'n8n-workflow'; +import { updateDisplayOptions } from '../../../../../utils/utilities'; +import { parseDiscordError, prepareErrorData, setupChannelGetter } from '../../helpers/utils'; +import { discordApiRequest } from '../../transport'; +import { channelRLC } from '../common.description'; + +const properties: INodeProperties[] = [channelRLC]; + +const displayOptions = { + show: { + resource: ['channel'], + operation: ['deleteChannel'], + }, + hide: { + authentication: ['webhook'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute( + this: IExecuteFunctions, + guildId: string, + userGuilds: IDataObject[], +): Promise { + const returnData: INodeExecutionData[] = []; + const items = this.getInputData(); + + const getChannelId = await setupChannelGetter.call(this, userGuilds); + + for (let i = 0; i < items.length; i++) { + try { + const channelId = await getChannelId(i); + + const response = await discordApiRequest.call(this, 'DELETE', `/channels/${channelId}`); + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response), + { itemData: { item: i } }, + ); + + returnData.push(...executionData); + } catch (error) { + const err = parseDiscordError.call(this, error, i); + + if (this.continueOnFail()) { + returnData.push(...prepareErrorData.call(this, err, i)); + continue; + } + + throw err; + } + } + + return returnData; +} diff --git a/packages/nodes-base/nodes/Discord/v2/actions/channel/get.operation.ts b/packages/nodes-base/nodes/Discord/v2/actions/channel/get.operation.ts new file mode 100644 index 0000000000..5d0a09d4bd --- /dev/null +++ b/packages/nodes-base/nodes/Discord/v2/actions/channel/get.operation.ts @@ -0,0 +1,61 @@ +import type { + IDataObject, + IExecuteFunctions, + INodeExecutionData, + INodeProperties, +} from 'n8n-workflow'; +import { updateDisplayOptions } from '../../../../../utils/utilities'; +import { parseDiscordError, prepareErrorData, setupChannelGetter } from '../../helpers/utils'; +import { discordApiRequest } from '../../transport'; +import { channelRLC } from '../common.description'; + +const properties: INodeProperties[] = [channelRLC]; + +const displayOptions = { + show: { + resource: ['channel'], + operation: ['get'], + }, + hide: { + authentication: ['webhook'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute( + this: IExecuteFunctions, + guildId: string, + userGuilds: IDataObject[], +): Promise { + const returnData: INodeExecutionData[] = []; + const items = this.getInputData(); + + const getChannelId = await setupChannelGetter.call(this, userGuilds); + + for (let i = 0; i < items.length; i++) { + try { + const channelId = await getChannelId(i); + + const response = await discordApiRequest.call(this, 'GET', `/channels/${channelId}`); + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response), + { itemData: { item: i } }, + ); + + returnData.push(...executionData); + } catch (error) { + const err = parseDiscordError.call(this, error, i); + + if (this.continueOnFail()) { + returnData.push(...prepareErrorData.call(this, err, i)); + continue; + } + + throw err; + } + } + + return returnData; +} diff --git a/packages/nodes-base/nodes/Discord/v2/actions/channel/getAll.operation.ts b/packages/nodes-base/nodes/Discord/v2/actions/channel/getAll.operation.ts new file mode 100644 index 0000000000..c71f305b42 --- /dev/null +++ b/packages/nodes-base/nodes/Discord/v2/actions/channel/getAll.operation.ts @@ -0,0 +1,96 @@ +import type { + IDataObject, + IExecuteFunctions, + INodeExecutionData, + INodeProperties, +} from 'n8n-workflow'; +import { updateDisplayOptions } from '../../../../../utils/utilities'; +import { parseDiscordError, prepareErrorData } from '../../helpers/utils'; +import { discordApiRequest } from '../../transport'; +import { returnAllOrLimit } from '../../../../../utils/descriptions'; + +const properties: INodeProperties[] = [ + ...returnAllOrLimit, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Filter by Type', + name: 'filter', + type: 'multiOptions', + default: [], + options: [ + { + name: 'Guild Text', + value: 0, + }, + { + name: 'Guild Voice', + value: 2, + }, + { + name: 'Guild Category', + value: 4, + }, + ], + }, + ], + }, +]; + +const displayOptions = { + show: { + resource: ['channel'], + operation: ['getAll'], + }, + hide: { + authentication: ['webhook'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute( + this: IExecuteFunctions, + guildId: string, +): Promise { + const returnData: INodeExecutionData[] = []; + + try { + const returnAll = this.getNodeParameter('returnAll', 0, false); + let response = await discordApiRequest.call(this, 'GET', `/guilds/${guildId}/channels`); + + if (!returnAll) { + const limit = this.getNodeParameter('limit', 0); + response = (response as IDataObject[]).slice(0, limit); + } + + const options = this.getNodeParameter('options', 0, {}); + + if (options.filter) { + const filter = options.filter as number[]; + response = (response as IDataObject[]).filter((item) => filter.includes(item.type as number)); + } + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response), + { itemData: { item: 0 } }, + ); + + returnData.push(...executionData); + } catch (error) { + const err = parseDiscordError.call(this, error); + + if (this.continueOnFail()) { + returnData.push(...prepareErrorData.call(this, err, 0)); + } + + throw err; + } + + return returnData; +} diff --git a/packages/nodes-base/nodes/Discord/v2/actions/channel/index.ts b/packages/nodes-base/nodes/Discord/v2/actions/channel/index.ts new file mode 100644 index 0000000000..1de485b85a --- /dev/null +++ b/packages/nodes-base/nodes/Discord/v2/actions/channel/index.ts @@ -0,0 +1,72 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import * as create from './create.operation'; +import * as get from './get.operation'; +import * as getAll from './getAll.operation'; +import * as update from './update.operation'; +import * as deleteChannel from './deleteChannel.operation'; +import { guildRLC } from '../common.description'; + +export { create, get, getAll, update, deleteChannel }; + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['channel'], + authentication: ['botToken', 'oAuth2'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a new channel', + action: 'Create a channel', + }, + { + name: 'Delete', + value: 'deleteChannel', + description: 'Delete a channel', + action: 'Delete a channel', + }, + { + name: 'Get', + value: 'get', + description: 'Get a channel', + action: 'Get a channel', + }, + { + name: 'Get Many', + value: 'getAll', + description: 'Retrieve the channels of a server', + action: 'Get many channels', + }, + { + name: 'Update', + value: 'update', + description: 'Update a channel', + action: 'Update a channel', + }, + ], + default: 'create', + }, + { + ...guildRLC, + displayOptions: { + show: { + resource: ['channel'], + authentication: ['botToken', 'oAuth2'], + }, + }, + }, + ...create.description, + ...deleteChannel.description, + ...get.description, + ...getAll.description, + ...update.description, +]; diff --git a/packages/nodes-base/nodes/Discord/v2/actions/channel/update.operation.ts b/packages/nodes-base/nodes/Discord/v2/actions/channel/update.operation.ts new file mode 100644 index 0000000000..670a875ae1 --- /dev/null +++ b/packages/nodes-base/nodes/Discord/v2/actions/channel/update.operation.ts @@ -0,0 +1,153 @@ +import type { + IDataObject, + IExecuteFunctions, + INodeExecutionData, + INodeProperties, +} from 'n8n-workflow'; +import { updateDisplayOptions } from '../../../../../utils/utilities'; +import { parseDiscordError, prepareErrorData, setupChannelGetter } from '../../helpers/utils'; +import { discordApiRequest } from '../../transport'; +import { categoryRLC, channelRLC } from '../common.description'; + +const properties: INodeProperties[] = [ + channelRLC, + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + description: + "The new name of the channel. Fill this field only if you want to change the channel's name.", + placeholder: 'e.g. new-channel-name', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Age-Restricted (NSFW)', + name: 'nsfw', + type: 'boolean', + default: false, + description: 'Whether the content of the channel might be nsfw (not safe for work)', + }, + { + displayName: 'Bitrate', + name: 'bitrate', + type: 'number', + default: 8000, + typeOptions: { + minValue: 8000, + maxValue: 96000, + }, + description: 'The bitrate (in bits) of the voice channel', + hint: 'Only applicable to voice channels', + }, + categoryRLC, + { + displayName: 'Position', + name: 'position', + type: 'number', + default: 1, + }, + + { + displayName: 'Rate Limit Per User', + name: 'rate_limit_per_user', + type: 'number', + default: 0, + description: 'Amount of seconds a user has to wait before sending another message', + }, + { + displayName: 'Topic', + name: 'topic', + type: 'string', + default: '', + typeOptions: { + rows: 2, + }, + description: 'The channel topic description (0-1024 characters)', + placeholder: 'e.g. This channel is about…', + }, + { + displayName: 'User Limit', + name: 'user_limit', + type: 'number', + default: 0, + typeOptions: { + minValue: 0, + maxValue: 99, + }, + placeholder: 'e.g. 20', + hint: 'Only applicable to voice channels', + description: + 'The limit for the number of members that can be in the channel (0 refers to no limit)', + }, + ], + }, +]; + +const displayOptions = { + show: { + resource: ['channel'], + operation: ['update'], + }, + hide: { + authentication: ['webhook'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute( + this: IExecuteFunctions, + guildId: string, + userGuilds: IDataObject[], +): Promise { + const returnData: INodeExecutionData[] = []; + const items = this.getInputData(); + + const getChannelId = await setupChannelGetter.call(this, userGuilds); + + for (let i = 0; i < items.length; i++) { + try { + const channelId = await getChannelId(i); + + const name = this.getNodeParameter('name', i) as string; + const options = this.getNodeParameter('options', i); + + if (options.categoryId) { + options.parent_id = (options.categoryId as IDataObject).value; + delete options.categoryId; + } + + const body: IDataObject = { + name, + ...options, + }; + + const response = await discordApiRequest.call(this, 'PATCH', `/channels/${channelId}`, body); + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response), + { itemData: { item: i } }, + ); + + returnData.push(...executionData); + } catch (error) { + const err = parseDiscordError.call(this, error, i); + + if (this.continueOnFail()) { + returnData.push(...prepareErrorData.call(this, err, i)); + continue; + } + + throw err; + } + } + + return returnData; +} diff --git a/packages/nodes-base/nodes/Discord/v2/actions/common.description.ts b/packages/nodes-base/nodes/Discord/v2/actions/common.description.ts new file mode 100644 index 0000000000..fd5ae047ee --- /dev/null +++ b/packages/nodes-base/nodes/Discord/v2/actions/common.description.ts @@ -0,0 +1,466 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { updateDisplayOptions } from '../../../../utils/utilities'; + +export const guildRLC: INodeProperties = { + displayName: 'Server', + name: 'guildId', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + description: 'Select the server (guild) that your bot is connected to', + modes: [ + { + displayName: 'By Name', + name: 'list', + type: 'list', + placeholder: 'e.g. my-server', + typeOptions: { + searchListMethod: 'guildSearch', + }, + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + placeholder: 'e.g. https://discord.com/channels/[guild-id]', + extractValue: { + type: 'regex', + regex: 'https:\\/\\/discord.com\\/channels\\/([0-9]+)', + }, + validation: [ + { + type: 'regex', + properties: { + regex: 'https:\\/\\/discord.com\\/channels\\/([0-9]+)', + errorMessage: 'Not a valid Discord Server URL', + }, + }, + ], + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: 'e.g. 896347036838936576', + validation: [ + { + type: 'regex', + properties: { + regex: '[0-9]+', + errorMessage: 'Not a valid Discord Server ID', + }, + }, + ], + }, + ], +}; + +export const channelRLC: INodeProperties = { + displayName: 'Channel', + name: 'channelId', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + description: 'Select the channel by name, URL, or ID', + modes: [ + { + displayName: 'By Name', + name: 'list', + type: 'list', + placeholder: 'e.g. my-channel', + typeOptions: { + searchListMethod: 'channelSearch', + }, + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + placeholder: 'e.g. https://discord.com/channels/[guild-id]/[channel-id]', + extractValue: { + type: 'regex', + regex: 'https:\\/\\/discord.com\\/channels\\/[0-9]+\\/([0-9]+)', + }, + validation: [ + { + type: 'regex', + properties: { + regex: 'https:\\/\\/discord.com\\/channels\\/[0-9]+\\/([0-9]+)', + errorMessage: 'Not a valid Discord Channel URL', + }, + }, + ], + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: 'e.g. 896347036838936576', + validation: [ + { + type: 'regex', + properties: { + regex: '[0-9]+', + errorMessage: 'Not a valid Discord Channel ID', + }, + }, + ], + }, + ], +}; + +export const textChannelRLC: INodeProperties = { + displayName: 'Channel', + name: 'channelId', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + description: 'Select the channel by name, URL, or ID', + modes: [ + { + displayName: 'By Name', + name: 'list', + type: 'list', + placeholder: 'e.g. my-channel', + typeOptions: { + searchListMethod: 'textChannelSearch', + }, + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + placeholder: 'e.g. https://discord.com/channels/[guild-id]/[channel-id]', + extractValue: { + type: 'regex', + regex: 'https:\\/\\/discord.com\\/channels\\/[0-9]+\\/([0-9]+)', + }, + validation: [ + { + type: 'regex', + properties: { + regex: 'https:\\/\\/discord.com\\/channels\\/[0-9]+\\/([0-9]+)', + errorMessage: 'Not a valid Discord Channel URL', + }, + }, + ], + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: 'e.g. 896347036838936576', + validation: [ + { + type: 'regex', + properties: { + regex: '[0-9]+', + errorMessage: 'Not a valid Discord Channel ID', + }, + }, + ], + }, + ], +}; + +export const categoryRLC: INodeProperties = { + displayName: 'Parent Category', + name: 'categoryId', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The parent category where you want the channel to appear', + modes: [ + { + displayName: 'By Name', + name: 'list', + type: 'list', + placeholder: 'e.g. my-channel', + typeOptions: { + searchListMethod: 'categorySearch', + }, + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + placeholder: 'e.g. https://discord.com/channels/[guild-id]/[channel-id]', + extractValue: { + type: 'regex', + regex: 'https:\\/\\/discord.com\\/channels\\/[0-9]+\\/([0-9]+)', + }, + validation: [ + { + type: 'regex', + properties: { + regex: 'https:\\/\\/discord.com\\/channels\\/[0-9]+\\/([0-9]+)', + errorMessage: 'Not a valid Discord Category URL', + }, + }, + ], + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: 'e.g. 896347036838936576', + validation: [ + { + type: 'regex', + properties: { + regex: '[0-9]+', + errorMessage: 'Not a valid Discord Category ID', + }, + }, + ], + }, + ], +}; + +export const userRLC: INodeProperties = { + displayName: 'User', + name: 'userId', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'Select the user you want to assign a role to', + modes: [ + { + displayName: 'By Name', + name: 'list', + type: 'list', + placeholder: 'e.g. DiscordUser', + typeOptions: { + searchListMethod: 'userSearch', + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: 'e.g. 786953432728469534', + validation: [ + { + type: 'regex', + properties: { + regex: '[0-9]+', + errorMessage: 'Not a valid User ID', + }, + }, + ], + }, + ], +}; + +export const roleMultiOptions: INodeProperties = { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-multi-options + displayName: 'Role', + name: 'role', + type: 'multiOptions', + typeOptions: { + loadOptionsMethod: 'getRoles', + loadOptionsDependsOn: ['userId.value', 'guildId.value', 'operation'], + }, + required: true, + // eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-multi-options + description: 'Select the roles you want to add to the user', + default: [], +}; + +export const maxResultsNumber: INodeProperties = { + displayName: 'Max Results', + name: 'maxResults', + type: 'number', + typeOptions: { + minValue: 1, + }, + default: 50, + description: 'Maximum number of results. Too many results may slow down the query.', +}; + +export const messageIdString: INodeProperties = { + displayName: 'Message ID', + name: 'messageId', + type: 'string', + default: '', + required: true, + description: 'The ID of the message', + placeholder: 'e.g. 1057576506244726804', +}; + +export const simplifyBoolean: INodeProperties = { + displayName: 'Simplify', + name: 'simplify', + type: 'boolean', + default: true, + description: 'Whether to return a simplified version of the response instead of the raw data', +}; + +// embeds ----------------------------------------------------------------------------------------- +const embedFields: INodeProperties[] = [ + { + displayName: 'Description (Required)', + name: 'description', + type: 'string', + default: '', + description: 'The description of embed', + placeholder: 'e.g. My description', + typeOptions: { + rows: 2, + }, + }, + { + displayName: 'Author', + name: 'author', + type: 'string', + default: '', + description: 'The name of the author', + placeholder: 'e.g. John Doe', + }, + { + displayName: 'Color', + name: 'color', + // eslint-disable-next-line n8n-nodes-base/node-param-color-type-unused + type: 'color', + default: '', + description: 'Color code of the embed', + placeholder: 'e.g. 12123432', + }, + { + displayName: 'Timestamp', + name: 'timestamp', + type: 'dateTime', + default: '', + description: 'The time displayed at the bottom of the embed. Provide in ISO8601 format.', + placeholder: 'e.g. 2023-02-08 09:30:26', + }, + { + displayName: 'Title', + name: 'title', + type: 'string', + default: '', + description: 'The title of embed', + placeholder: "e.g. Embed's title", + }, + { + displayName: 'URL', + name: 'url', + type: 'string', + default: '', + description: 'The URL where you want to link the embed to', + placeholder: 'e.g. https://discord.com/', + }, + { + displayName: 'URL Image', + name: 'image', + type: 'string', + default: '', + description: 'Source URL of image (only supports http(s) and attachments)', + placeholder: 'e.g. https://example.com/image.png', + }, + { + displayName: 'URL Thumbnail', + name: 'thumbnail', + type: 'string', + default: '', + description: 'Source URL of thumbnail (only supports http(s) and attachments)', + placeholder: 'e.g. https://example.com/image.png', + }, + { + displayName: 'URL Video', + name: 'video', + type: 'string', + default: '', + description: 'Source URL of video', + placeholder: 'e.g. https://example.com/video.mp4', + }, +]; + +const embedFieldsDescription = updateDisplayOptions( + { + show: { + inputMethod: ['fields'], + }, + }, + embedFields, +); + +export const embedsFixedCollection: INodeProperties = { + displayName: 'Embeds', + name: 'embeds', + type: 'fixedCollection', + placeholder: 'Add Embeds', + typeOptions: { + multipleValues: true, + }, + default: [], + options: [ + { + displayName: 'Values', + name: 'values', + values: [ + { + displayName: 'Input Method', + name: 'inputMethod', + type: 'options', + options: [ + { + name: 'Enter Fields', + value: 'fields', + }, + { + name: 'Raw JSON', + value: 'json', + }, + ], + default: 'fields', + }, + { + displayName: 'Value', + name: 'json', + type: 'string', + default: '={}', + typeOptions: { + editor: 'json', + editorLanguage: 'json', + rows: 2, + }, + displayOptions: { + show: { + inputMethod: ['json'], + }, + }, + }, + ...embedFieldsDescription, + ], + }, + ], +}; + +// ------------------------------------------------------------------------------------------- + +export const filesFixedCollection: INodeProperties = { + displayName: 'Files', + name: 'files', + type: 'fixedCollection', + placeholder: 'Add Files', + typeOptions: { + multipleValues: true, + }, + default: [], + options: [ + { + displayName: 'Values', + name: 'values', + values: [ + { + displayName: 'Input Data Field Name', + name: 'inputFieldName', + type: 'string', + default: 'data', + description: 'The contents of the file being sent with the message', + placeholder: 'e.g. data', + hint: 'The name of the input field containing the binary file data to be sent', + }, + ], + }, + ], +}; diff --git a/packages/nodes-base/nodes/Discord/v2/actions/member/getAll.operation.ts b/packages/nodes-base/nodes/Discord/v2/actions/member/getAll.operation.ts new file mode 100644 index 0000000000..6394997d83 --- /dev/null +++ b/packages/nodes-base/nodes/Discord/v2/actions/member/getAll.operation.ts @@ -0,0 +1,121 @@ +import type { + IDataObject, + IExecuteFunctions, + INodeExecutionData, + INodeProperties, +} from 'n8n-workflow'; +import { updateDisplayOptions } from '../../../../../utils/utilities'; +import { createSimplifyFunction, parseDiscordError, prepareErrorData } from '../../helpers/utils'; +import { discordApiRequest } from '../../transport'; +import { simplifyBoolean } from '../common.description'; +import { returnAllOrLimit } from '../../../../../utils/descriptions'; + +const properties: INodeProperties[] = [ + ...returnAllOrLimit, + { + displayName: 'After', + name: 'after', + type: 'string', + default: '', + placeholder: 'e.g. 786953432728469534', + description: 'The ID of the user after which to return the members', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [simplifyBoolean], + }, +]; + +const displayOptions = { + show: { + resource: ['member'], + operation: ['getAll'], + }, + hide: { + authentication: ['webhook'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute( + this: IExecuteFunctions, + guildId: string, +): Promise { + const returnData: INodeExecutionData[] = []; + + const returnAll = this.getNodeParameter('returnAll', 0, false); + const after = this.getNodeParameter('after', 0); + + const qs: IDataObject = {}; + + if (!returnAll) { + const limit = this.getNodeParameter('limit', 0); + qs.limit = limit; + } + + if (after) { + qs.after = after; + } + + let response: IDataObject[] = []; + + try { + if (!returnAll) { + const limit = this.getNodeParameter('limit', 0); + qs.limit = limit; + response = await discordApiRequest.call( + this, + 'GET', + `/guilds/${guildId}/members`, + undefined, + qs, + ); + } else { + let responseData; + qs.limit = 100; + + do { + responseData = await discordApiRequest.call( + this, + 'GET', + `/guilds/${guildId}/members`, + undefined, + qs, + ); + if (!responseData?.length) break; + qs.after = responseData[responseData.length - 1].user.id; + response.push(...responseData); + } while (responseData.length); + } + + const simplify = this.getNodeParameter('options.simplify', 0, false) as boolean; + + if (simplify) { + const simplifyResponse = createSimplifyFunction(['user', 'roles', 'permissions']); + + response = response.map(simplifyResponse); + } + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response), + { itemData: { item: 0 } }, + ); + + returnData.push(...executionData); + } catch (error) { + const err = parseDiscordError.call(this, error); + + if (this.continueOnFail()) { + returnData.push(...prepareErrorData.call(this, err, 0)); + } + + throw err; + } + + return returnData; +} diff --git a/packages/nodes-base/nodes/Discord/v2/actions/member/index.ts b/packages/nodes-base/nodes/Discord/v2/actions/member/index.ts new file mode 100644 index 0000000000..5f2e87f749 --- /dev/null +++ b/packages/nodes-base/nodes/Discord/v2/actions/member/index.ts @@ -0,0 +1,56 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import * as getAll from './getAll.operation'; +import * as roleAdd from './roleAdd.operation'; +import * as roleRemove from './roleRemove.operation'; +import { guildRLC } from '../common.description'; + +export { getAll, roleAdd, roleRemove }; + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['member'], + authentication: ['botToken', 'oAuth2'], + }, + }, + options: [ + { + name: 'Get Many', + value: 'getAll', + description: 'Retrieve the members of a server', + action: 'Get many members', + }, + { + name: 'Role Add', + value: 'roleAdd', + description: 'Add a role to a member', + action: 'Add a role to a member', + }, + { + name: 'Role Remove', + value: 'roleRemove', + description: 'Remove a role from a member', + action: 'Remove a role from a member', + }, + ], + default: 'getAll', + }, + { + ...guildRLC, + displayOptions: { + show: { + resource: ['member'], + authentication: ['botToken', 'oAuth2'], + }, + }, + }, + ...getAll.description, + ...roleAdd.description, + ...roleRemove.description, +]; diff --git a/packages/nodes-base/nodes/Discord/v2/actions/member/roleAdd.operation.ts b/packages/nodes-base/nodes/Discord/v2/actions/member/roleAdd.operation.ts new file mode 100644 index 0000000000..4cd83fe2fd --- /dev/null +++ b/packages/nodes-base/nodes/Discord/v2/actions/member/roleAdd.operation.ts @@ -0,0 +1,63 @@ +import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow'; +import { updateDisplayOptions } from '../../../../../utils/utilities'; +import { parseDiscordError, prepareErrorData } from '../../helpers/utils'; +import { discordApiRequest } from '../../transport'; +import { roleMultiOptions, userRLC } from '../common.description'; + +const properties: INodeProperties[] = [userRLC, roleMultiOptions]; + +const displayOptions = { + show: { + resource: ['member'], + operation: ['roleAdd'], + }, + hide: { + authentication: ['webhook'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute( + this: IExecuteFunctions, + guildId: string, +): Promise { + const returnData: INodeExecutionData[] = []; + const items = this.getInputData(); + + for (let i = 0; i < items.length; i++) { + try { + const userId = this.getNodeParameter('userId', i, undefined, { + extractValue: true, + }) as string; + + const roles = this.getNodeParameter('role', i, []) as string[]; + + for (const roleId of roles) { + await discordApiRequest.call( + this, + 'PUT', + `/guilds/${guildId}/members/${userId}/roles/${roleId}`, + ); + } + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray({ success: true }), + { itemData: { item: i } }, + ); + + returnData.push(...executionData); + } catch (error) { + const err = parseDiscordError.call(this, error, i); + + if (this.continueOnFail()) { + returnData.push(...prepareErrorData.call(this, err, i)); + continue; + } + + throw err; + } + } + + return returnData; +} diff --git a/packages/nodes-base/nodes/Discord/v2/actions/member/roleRemove.operation.ts b/packages/nodes-base/nodes/Discord/v2/actions/member/roleRemove.operation.ts new file mode 100644 index 0000000000..025975054b --- /dev/null +++ b/packages/nodes-base/nodes/Discord/v2/actions/member/roleRemove.operation.ts @@ -0,0 +1,63 @@ +import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow'; +import { updateDisplayOptions } from '../../../../../utils/utilities'; +import { parseDiscordError, prepareErrorData } from '../../helpers/utils'; +import { discordApiRequest } from '../../transport'; +import { roleMultiOptions, userRLC } from '../common.description'; + +const properties: INodeProperties[] = [userRLC, roleMultiOptions]; + +const displayOptions = { + show: { + resource: ['member'], + operation: ['roleRemove'], + }, + hide: { + authentication: ['webhook'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute( + this: IExecuteFunctions, + guildId: string, +): Promise { + const returnData: INodeExecutionData[] = []; + const items = this.getInputData(); + + for (let i = 0; i < items.length; i++) { + try { + const userId = this.getNodeParameter('userId', i, undefined, { + extractValue: true, + }) as string; + + const roles = this.getNodeParameter('role', i, []) as string[]; + + for (const roleId of roles) { + await discordApiRequest.call( + this, + 'DELETE', + `/guilds/${guildId}/members/${userId}/roles/${roleId}`, + ); + } + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray({ success: true }), + { itemData: { item: i } }, + ); + + returnData.push(...executionData); + } catch (error) { + const err = parseDiscordError.call(this, error, i); + + if (this.continueOnFail()) { + returnData.push(...prepareErrorData.call(this, err, i)); + continue; + } + + throw err; + } + } + + return returnData; +} diff --git a/packages/nodes-base/nodes/Discord/v2/actions/message/deleteMessage.operation.ts b/packages/nodes-base/nodes/Discord/v2/actions/message/deleteMessage.operation.ts new file mode 100644 index 0000000000..2d605ad086 --- /dev/null +++ b/packages/nodes-base/nodes/Discord/v2/actions/message/deleteMessage.operation.ts @@ -0,0 +1,63 @@ +import type { + IDataObject, + IExecuteFunctions, + INodeExecutionData, + INodeProperties, +} from 'n8n-workflow'; +import { updateDisplayOptions } from '../../../../../utils/utilities'; +import { parseDiscordError, prepareErrorData, setupChannelGetter } from '../../helpers/utils'; +import { discordApiRequest } from '../../transport'; +import { channelRLC, messageIdString } from '../common.description'; + +const properties: INodeProperties[] = [channelRLC, messageIdString]; + +const displayOptions = { + show: { + resource: ['message'], + operation: ['deleteMessage'], + }, + hide: { + authentication: ['webhook'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute( + this: IExecuteFunctions, + guildId: string, + userGuilds: IDataObject[], +): Promise { + const returnData: INodeExecutionData[] = []; + const items = this.getInputData(); + + const getChannelId = await setupChannelGetter.call(this, userGuilds); + + for (let i = 0; i < items.length; i++) { + try { + const channelId = await getChannelId(i); + + const messageId = this.getNodeParameter('messageId', i) as string; + + await discordApiRequest.call(this, 'DELETE', `/channels/${channelId}/messages/${messageId}`); + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray({ success: true }), + { itemData: { item: i } }, + ); + + returnData.push(...executionData); + } catch (error) { + const err = parseDiscordError.call(this, error, i); + + if (this.continueOnFail()) { + returnData.push(...prepareErrorData.call(this, err, i)); + continue; + } + + throw err; + } + } + + return returnData; +} diff --git a/packages/nodes-base/nodes/Discord/v2/actions/message/get.operation.ts b/packages/nodes-base/nodes/Discord/v2/actions/message/get.operation.ts new file mode 100644 index 0000000000..b9366088fa --- /dev/null +++ b/packages/nodes-base/nodes/Discord/v2/actions/message/get.operation.ts @@ -0,0 +1,97 @@ +import type { + IDataObject, + IExecuteFunctions, + INodeExecutionData, + INodeProperties, +} from 'n8n-workflow'; +import { updateDisplayOptions } from '../../../../../utils/utilities'; +import { + createSimplifyFunction, + parseDiscordError, + prepareErrorData, + setupChannelGetter, +} from '../../helpers/utils'; +import { discordApiRequest } from '../../transport'; +import { channelRLC, messageIdString, simplifyBoolean } from '../common.description'; + +const properties: INodeProperties[] = [ + channelRLC, + messageIdString, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [simplifyBoolean], + }, +]; + +const displayOptions = { + show: { + resource: ['message'], + operation: ['get'], + }, + hide: { + authentication: ['webhook'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute( + this: IExecuteFunctions, + guildId: string, + userGuilds: IDataObject[], +): Promise { + const returnData: INodeExecutionData[] = []; + const items = this.getInputData(); + const simplifyResponse = createSimplifyFunction([ + 'id', + 'channel_id', + 'author', + 'content', + 'timestamp', + 'type', + ]); + + const getChannelId = await setupChannelGetter.call(this, userGuilds); + + for (let i = 0; i < items.length; i++) { + try { + const channelId = await getChannelId(i); + + const messageId = this.getNodeParameter('messageId', i) as string; + + let response = await discordApiRequest.call( + this, + 'GET', + `/channels/${channelId}/messages/${messageId}`, + ); + + const simplify = this.getNodeParameter('options.simplify', i, false) as boolean; + + if (simplify) { + response = simplifyResponse(response); + } + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response), + { itemData: { item: i } }, + ); + + returnData.push(...executionData); + } catch (error) { + const err = parseDiscordError.call(this, error, i); + + if (this.continueOnFail()) { + returnData.push(...prepareErrorData.call(this, err, i)); + continue; + } + + throw err; + } + } + + return returnData; +} diff --git a/packages/nodes-base/nodes/Discord/v2/actions/message/getAll.operation.ts b/packages/nodes-base/nodes/Discord/v2/actions/message/getAll.operation.ts new file mode 100644 index 0000000000..0378bfc82b --- /dev/null +++ b/packages/nodes-base/nodes/Discord/v2/actions/message/getAll.operation.ts @@ -0,0 +1,124 @@ +import type { + IDataObject, + IExecuteFunctions, + INodeExecutionData, + INodeProperties, +} from 'n8n-workflow'; +import { updateDisplayOptions } from '../../../../../utils/utilities'; +import { + createSimplifyFunction, + parseDiscordError, + prepareErrorData, + setupChannelGetter, +} from '../../helpers/utils'; +import { discordApiRequest } from '../../transport'; +import { channelRLC, simplifyBoolean } from '../common.description'; +import { returnAllOrLimit } from '../../../../../utils/descriptions'; + +const properties: INodeProperties[] = [ + channelRLC, + ...returnAllOrLimit, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [simplifyBoolean], + }, +]; + +const displayOptions = { + show: { + resource: ['message'], + operation: ['getAll'], + }, + hide: { + authentication: ['webhook'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute( + this: IExecuteFunctions, + guildId: string, + userGuilds: IDataObject[], +): Promise { + const returnData: INodeExecutionData[] = []; + const items = this.getInputData(); + const simplifyResponse = createSimplifyFunction([ + 'id', + 'channel_id', + 'author', + 'content', + 'timestamp', + 'type', + ]); + + const getChannelId = await setupChannelGetter.call(this, userGuilds); + + for (let i = 0; i < items.length; i++) { + try { + const channelId = await getChannelId(i); + + const returnAll = this.getNodeParameter('returnAll', i, false); + + const qs: IDataObject = {}; + + let response: IDataObject[] = []; + + if (!returnAll) { + const limit = this.getNodeParameter('limit', 0); + qs.limit = limit; + response = await discordApiRequest.call( + this, + 'GET', + `/channels/${channelId}/messages`, + undefined, + qs, + ); + } else { + let responseData; + qs.limit = 100; + + do { + responseData = await discordApiRequest.call( + this, + 'GET', + `/channels/${channelId}/messages`, + undefined, + qs, + ); + if (!responseData?.length) break; + qs.before = responseData[responseData.length - 1].id; + response.push(...responseData); + } while (responseData.length); + } + + const simplify = this.getNodeParameter('options.simplify', i, false) as boolean; + + if (simplify) { + response = response.map(simplifyResponse); + } + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response), + { itemData: { item: i } }, + ); + + returnData.push(...executionData); + } catch (error) { + const err = parseDiscordError.call(this, error, i); + + if (this.continueOnFail()) { + returnData.push(...prepareErrorData.call(this, err, i)); + continue; + } + + throw err; + } + } + + return returnData; +} diff --git a/packages/nodes-base/nodes/Discord/v2/actions/message/index.ts b/packages/nodes-base/nodes/Discord/v2/actions/message/index.ts new file mode 100644 index 0000000000..b3ab3a0cda --- /dev/null +++ b/packages/nodes-base/nodes/Discord/v2/actions/message/index.ts @@ -0,0 +1,72 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import * as getAll from './getAll.operation'; +import * as react from './react.operation'; +import * as send from './send.operation'; +import * as deleteMessage from './deleteMessage.operation'; +import * as get from './get.operation'; +import { guildRLC } from '../common.description'; + +export { getAll, react, send, deleteMessage, get }; + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['message'], + authentication: ['botToken', 'oAuth2'], + }, + }, + options: [ + { + name: 'Delete', + value: 'deleteMessage', + description: 'Delete a message in a channel', + action: 'Delete a message', + }, + { + name: 'Get', + value: 'get', + description: 'Get a message in a channel', + action: 'Get a message', + }, + { + name: 'Get Many', + value: 'getAll', + description: 'Retrieve the latest messages in a channel', + action: 'Get many messages', + }, + { + name: 'React with Emoji', + value: 'react', + description: 'React to a message with an emoji', + action: 'React with an emoji to a message', + }, + { + name: 'Send', + value: 'send', + description: 'Send a message to a channel, thread, or member', + action: 'Send a message', + }, + ], + default: 'send', + }, + { + ...guildRLC, + displayOptions: { + show: { + resource: ['message'], + authentication: ['botToken', 'oAuth2'], + }, + }, + }, + ...getAll.description, + ...react.description, + ...send.description, + ...deleteMessage.description, + ...get.description, +]; diff --git a/packages/nodes-base/nodes/Discord/v2/actions/message/react.operation.ts b/packages/nodes-base/nodes/Discord/v2/actions/message/react.operation.ts new file mode 100644 index 0000000000..a12acad581 --- /dev/null +++ b/packages/nodes-base/nodes/Discord/v2/actions/message/react.operation.ts @@ -0,0 +1,79 @@ +import type { + IDataObject, + IExecuteFunctions, + INodeExecutionData, + INodeProperties, +} from 'n8n-workflow'; +import { updateDisplayOptions } from '../../../../../utils/utilities'; +import { parseDiscordError, prepareErrorData, setupChannelGetter } from '../../helpers/utils'; +import { discordApiRequest } from '../../transport'; +import { channelRLC, messageIdString } from '../common.description'; + +const properties: INodeProperties[] = [ + channelRLC, + messageIdString, + { + displayName: 'Emoji', + name: 'emoji', + type: 'string', + default: '', + required: true, + description: 'The emoji you want to react with', + }, +]; + +const displayOptions = { + show: { + resource: ['message'], + operation: ['react'], + }, + hide: { + authentication: ['webhook'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute( + this: IExecuteFunctions, + guildId: string, + userGuilds: IDataObject[], +): Promise { + const returnData: INodeExecutionData[] = []; + const items = this.getInputData(); + + const getChannelId = await setupChannelGetter.call(this, userGuilds); + + for (let i = 0; i < items.length; i++) { + try { + const channelId = await getChannelId(i); + + const messageId = this.getNodeParameter('messageId', i) as string; + const emoji = this.getNodeParameter('emoji', i) as string; + + await discordApiRequest.call( + this, + 'PUT', + `/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/@me`, + ); + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray({ success: true }), + { itemData: { item: i } }, + ); + + returnData.push(...executionData); + } catch (error) { + const err = parseDiscordError.call(this, error, i); + + if (this.continueOnFail()) { + returnData.push(...prepareErrorData.call(this, err, i)); + continue; + } + + throw err; + } + } + + return returnData; +} diff --git a/packages/nodes-base/nodes/Discord/v2/actions/message/send.operation.ts b/packages/nodes-base/nodes/Discord/v2/actions/message/send.operation.ts new file mode 100644 index 0000000000..e3d9362747 --- /dev/null +++ b/packages/nodes-base/nodes/Discord/v2/actions/message/send.operation.ts @@ -0,0 +1,228 @@ +import type { + IDataObject, + IExecuteFunctions, + INodeExecutionData, + INodeProperties, +} from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; +import { updateDisplayOptions } from '../../../../../utils/utilities'; +import { discordApiMultiPartRequest, discordApiRequest } from '../../transport'; +import { + embedsFixedCollection, + filesFixedCollection, + textChannelRLC, + userRLC, +} from '../common.description'; + +import { + checkAccessToChannel, + parseDiscordError, + prepareEmbeds, + prepareErrorData, + prepareMultiPartForm, + prepareOptions, +} from '../../helpers/utils'; + +const properties: INodeProperties[] = [ + { + displayName: 'Send To', + name: 'sendTo', + type: 'options', + options: [ + { + name: 'User', + value: 'user', + }, + { + name: 'Channel', + value: 'channel', + }, + ], + default: 'channel', + description: 'Send message to a channel or DM to a user', + }, + + { + ...userRLC, + displayOptions: { + show: { + sendTo: ['user'], + }, + }, + }, + { + ...textChannelRLC, + displayOptions: { + show: { + sendTo: ['channel'], + }, + }, + }, + { + displayName: 'Message', + name: 'content', + type: 'string', + default: '', + required: true, + description: 'The content of the message (up to 2000 characters)', + placeholder: 'e.g. My message', + typeOptions: { + rows: 2, + }, + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Flags', + name: 'flags', + type: 'multiOptions', + default: [], + description: + 'Message flags. More info.”.', + options: [ + { + name: 'Suppress Embeds', + value: 'SUPPRESS_EMBEDS', + }, + { + name: 'Suppress Notifications', + value: 'SUPPRESS_NOTIFICATIONS', + }, + ], + }, + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased + displayName: 'Message to Reply to', + name: 'message_reference', + type: 'string', + default: '', + description: 'Fill this to make your message a reply. Add the message ID.', + placeholder: 'e.g. 1059467601836773386', + }, + { + displayName: 'Text-to-Speech (TTS)', + name: 'tts', + type: 'boolean', + default: false, + description: 'Whether to have a bot reading the message directly in the channel', + }, + ], + }, + embedsFixedCollection, + filesFixedCollection, +]; + +const displayOptions = { + show: { + resource: ['message'], + operation: ['send'], + }, + hide: { + authentication: ['webhook'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute( + this: IExecuteFunctions, + guildId: string, + userGuilds: IDataObject[], +): Promise { + const returnData: INodeExecutionData[] = []; + const items = this.getInputData(); + + const isOAuth2 = this.getNodeParameter('authentication', 0) === 'oAuth2'; + + for (let i = 0; i < items.length; i++) { + const content = this.getNodeParameter('content', i) as string; + const options = prepareOptions(this.getNodeParameter('options', i, {}), guildId); + + const embeds = (this.getNodeParameter('embeds', i, undefined) as IDataObject) + ?.values as IDataObject[]; + const files = (this.getNodeParameter('files', i, undefined) as IDataObject) + ?.values as IDataObject[]; + + const body: IDataObject = { + content, + ...options, + }; + + if (embeds) { + body.embeds = prepareEmbeds.call(this, embeds, i); + } + + try { + const sendTo = this.getNodeParameter('sendTo', i) as string; + + let channelId = ''; + + if (sendTo === 'user') { + const userId = this.getNodeParameter('userId', i, undefined, { + extractValue: true, + }) as string; + + channelId = ( + (await discordApiRequest.call(this, 'POST', '/users/@me/channels', { + recipient_id: userId, + })) as IDataObject + ).id as string; + } + + if (sendTo === 'channel') { + channelId = this.getNodeParameter('channelId', i, undefined, { + extractValue: true, + }) as string; + } + + if (!channelId) { + throw new NodeOperationError(this.getNode(), 'Channel ID is required'); + } + + if (isOAuth2) await checkAccessToChannel.call(this, channelId, userGuilds, i); + + let response: IDataObject[] = []; + + if (files?.length) { + const multiPartBody = await prepareMultiPartForm.call(this, items, files, body, i); + + 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: i } }, + ); + + returnData.push(...executionData); + } catch (error) { + const err = parseDiscordError.call(this, error, i); + + if (this.continueOnFail()) { + returnData.push(...prepareErrorData.call(this, err, i)); + continue; + } + + throw err; + } + } + + return returnData; +} diff --git a/packages/nodes-base/nodes/Discord/v2/actions/node.type.ts b/packages/nodes-base/nodes/Discord/v2/actions/node.type.ts new file mode 100644 index 0000000000..c415fe663d --- /dev/null +++ b/packages/nodes-base/nodes/Discord/v2/actions/node.type.ts @@ -0,0 +1,10 @@ +import type { AllEntities } from 'n8n-workflow'; + +type NodeMap = { + channel: 'get' | 'getAll' | 'create' | 'update' | 'deleteChannel'; + message: 'deleteMessage' | 'getAll' | 'get' | 'react' | 'send'; + member: 'getAll' | 'roleAdd' | 'roleRemove'; + webhook: 'sendLegacy'; +}; + +export type Discord = AllEntities; diff --git a/packages/nodes-base/nodes/Discord/v2/actions/router.ts b/packages/nodes-base/nodes/Discord/v2/actions/router.ts new file mode 100644 index 0000000000..2a65ca6145 --- /dev/null +++ b/packages/nodes-base/nodes/Discord/v2/actions/router.ts @@ -0,0 +1,68 @@ +import type { IDataObject, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; + +import { discordApiRequest } from '../transport'; +import { checkAccessToGuild } from '../helpers/utils'; + +import * as message from './message'; +import * as channel from './channel'; +import * as member from './member'; +import * as webhook from './webhook'; +import type { Discord } from './node.type'; + +export async function router(this: IExecuteFunctions) { + let returnData: INodeExecutionData[] = []; + + let resource = 'webhook'; + //resource parameter is hidden when authentication is set to webhook + //prevent error when getting resource parameter + try { + resource = this.getNodeParameter('resource', 0); + } catch (error) {} + const operation = this.getNodeParameter('operation', 0); + + let guildId = ''; + let userGuilds: IDataObject[] = []; + + if (resource !== 'webhook') { + guildId = this.getNodeParameter('guildId', 0, '', { + extractValue: true, + }) as string; + + const isOAuth2 = this.getNodeParameter('authentication', 0, '') === 'oAuth2'; + + if (isOAuth2) { + userGuilds = (await discordApiRequest.call( + this, + 'GET', + '/users/@me/guilds', + )) as IDataObject[]; + + checkAccessToGuild(this.getNode(), guildId, userGuilds); + } + } + + const discord = { + resource, + operation, + } as Discord; + + switch (discord.resource) { + case 'channel': + returnData = await channel[discord.operation].execute.call(this, guildId, userGuilds); + break; + case 'message': + returnData = await message[discord.operation].execute.call(this, guildId, userGuilds); + break; + case 'member': + returnData = await member[discord.operation].execute.call(this, guildId); + break; + case 'webhook': + returnData = await webhook[discord.operation].execute.call(this); + break; + default: + throw new NodeOperationError(this.getNode(), `The resource "${resource}" is not known`); + } + + return [returnData]; +} diff --git a/packages/nodes-base/nodes/Discord/v2/actions/versionDescription.ts b/packages/nodes-base/nodes/Discord/v2/actions/versionDescription.ts new file mode 100644 index 0000000000..23c7e3337f --- /dev/null +++ b/packages/nodes-base/nodes/Discord/v2/actions/versionDescription.ts @@ -0,0 +1,106 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ +import type { INodeTypeDescription } from 'n8n-workflow'; + +import * as message from './message'; +import * as channel from './channel'; +import * as member from './member'; +import * as webhook from './webhook'; + +export const versionDescription: INodeTypeDescription = { + displayName: 'Discord', + name: 'discord', + icon: 'file:discord.svg', + group: ['output'], + version: 2, + subtitle: '={{ $parameter["operation"] + ": " + $parameter["resource"] }}', + description: 'Sends data to Discord', + defaults: { + name: 'Discord', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'discordBotApi', + required: true, + displayOptions: { + show: { + authentication: ['botToken'], + }, + }, + }, + { + name: 'discordOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: ['oAuth2'], + }, + }, + }, + { + name: 'discordWebhookApi', + displayOptions: { + show: { + authentication: ['webhook'], + }, + }, + }, + ], + properties: [ + { + displayName: 'Connection Type', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Bot Token', + value: 'botToken', + description: 'Manage messages, channels, and members on a server', + }, + { + name: 'OAuth2', + value: 'oAuth2', + description: 'Manage messages, channels, and members on a server', + }, + { + name: 'Webhook', + value: 'webhook', + description: 'Send messages to a specific channel', + }, + ], + default: 'botToken', + }, + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Channel', + value: 'channel', + }, + { + name: 'Message', + value: 'message', + }, + { + name: 'Member', + value: 'member', + }, + ], + default: 'channel', + displayOptions: { + hide: { + authentication: ['webhook'], + }, + }, + }, + + ...message.description, + ...channel.description, + ...member.description, + ...webhook.description, + ], +}; diff --git a/packages/nodes-base/nodes/Discord/v2/actions/webhook/index.ts b/packages/nodes-base/nodes/Discord/v2/actions/webhook/index.ts new file mode 100644 index 0000000000..0faa42665b --- /dev/null +++ b/packages/nodes-base/nodes/Discord/v2/actions/webhook/index.ts @@ -0,0 +1,29 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import * as sendLegacy from './sendLegacy.operation'; + +export { sendLegacy }; + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + authentication: ['webhook'], + }, + }, + options: [ + { + name: 'Send a Message', + value: 'sendLegacy', + description: 'Send a message to a channel using the webhook', + action: 'Send a message', + }, + ], + default: 'sendLegacy', + }, + ...sendLegacy.description, +]; diff --git a/packages/nodes-base/nodes/Discord/v2/actions/webhook/sendLegacy.operation.ts b/packages/nodes-base/nodes/Discord/v2/actions/webhook/sendLegacy.operation.ts new file mode 100644 index 0000000000..29a9733739 --- /dev/null +++ b/packages/nodes-base/nodes/Discord/v2/actions/webhook/sendLegacy.operation.ts @@ -0,0 +1,167 @@ +import type { + IDataObject, + IExecuteFunctions, + INodeExecutionData, + INodeProperties, +} from 'n8n-workflow'; +import { updateDisplayOptions } from '../../../../../utils/utilities'; +import { discordApiMultiPartRequest, discordApiRequest } from '../../transport'; + +import { + parseDiscordError, + prepareEmbeds, + prepareErrorData, + prepareMultiPartForm, + prepareOptions, +} from '../../helpers/utils'; + +import { embedsFixedCollection, filesFixedCollection } from '../common.description'; + +const properties: INodeProperties[] = [ + { + displayName: 'Message', + name: 'content', + type: 'string', + default: '', + required: true, + description: 'The content of the message (up to 2000 characters)', + placeholder: 'e.g. My message', + typeOptions: { + rows: 2, + }, + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Avatar URL', + name: 'avatar_url', + type: 'string', + default: '', + description: 'Override the default avatar of the webhook', + placeholder: 'e.g. https://example.com/image.png', + }, + { + displayName: 'Flags', + name: 'flags', + type: 'multiOptions', + default: [], + description: + 'Message flags. More info.”.', + options: [ + { + name: 'Suppress Embeds', + value: 'SUPPRESS_EMBEDS', + }, + { + name: 'Suppress Notifications', + value: 'SUPPRESS_NOTIFICATIONS', + }, + ], + }, + { + displayName: 'Text-to-Speech (TTS)', + name: 'tts', + type: 'boolean', + default: false, + description: 'Whether to have a bot reading the message directly in the channel', + }, + { + displayName: 'Username', + name: 'username', + type: 'string', + default: '', + description: 'Override the default username of the webhook', + placeholder: 'e.g. My Username', + }, + { + displayName: 'Wait', + name: 'wait', + type: 'boolean', + default: false, + description: 'Whether wait for the message to be created before returning its response', + }, + ], + }, + embedsFixedCollection, + filesFixedCollection, +]; + +const displayOptions = { + show: { + operation: ['sendLegacy'], + }, + hide: { + authentication: ['botToken', 'oAuth2'], + }, +}; + +export const description = updateDisplayOptions(displayOptions, properties); + +export async function execute(this: IExecuteFunctions): Promise { + const returnData: INodeExecutionData[] = []; + const items = this.getInputData(); + + for (let i = 0; i < items.length; i++) { + const content = this.getNodeParameter('content', i) as string; + const options = prepareOptions(this.getNodeParameter('options', i, {})); + + const embeds = (this.getNodeParameter('embeds', i, undefined) as IDataObject) + ?.values as IDataObject[]; + const files = (this.getNodeParameter('files', i, undefined) as IDataObject) + ?.values as IDataObject[]; + + let qs: IDataObject | undefined = undefined; + + if (options.wait) { + qs = { + wait: options.wait, + }; + + delete options.wait; + } + + const body: IDataObject = { + content, + ...options, + }; + + if (embeds) { + body.embeds = prepareEmbeds.call(this, embeds); + } + + try { + let response: IDataObject[] = []; + + if (files?.length) { + const multiPartBody = await prepareMultiPartForm.call(this, items, files, body, i); + + response = await discordApiMultiPartRequest.call(this, 'POST', '', multiPartBody); + } else { + response = await discordApiRequest.call(this, 'POST', '', body, qs); + } + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(response), + { itemData: { item: i } }, + ); + + returnData.push(...executionData); + } catch (error) { + const err = parseDiscordError.call(this, error, i); + + if (this.continueOnFail()) { + returnData.push(...prepareErrorData.call(this, err, i)); + continue; + } + + throw err; + } + } + + return returnData; +} diff --git a/packages/nodes-base/nodes/Discord/v2/helpers/utils.ts b/packages/nodes-base/nodes/Discord/v2/helpers/utils.ts new file mode 100644 index 0000000000..65f550a8e7 --- /dev/null +++ b/packages/nodes-base/nodes/Discord/v2/helpers/utils.ts @@ -0,0 +1,287 @@ +import type { + IBinaryKeyData, + IDataObject, + IExecuteFunctions, + INode, + INodeExecutionData, +} from 'n8n-workflow'; +import { jsonParse, NodeOperationError } from 'n8n-workflow'; +import { isEmpty } from 'lodash'; +import FormData from 'form-data'; +import { capitalize } from '../../../../utils/utilities'; +import { extension } from 'mime-types'; +import { 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 }; + + 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); + } + } + 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[], i = 0) { + return embeds + .map((embed, index) => { + 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.description) { + throw new NodeOperationError( + this.getNode(), + `Description is required, embed ${index} in item ${i} is missing it`, + ); + } + + 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, + items: INodeExecutionData[], + 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 = (items[i].binary as IBinaryKeyData)?.[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, + }, + ); + } +} + +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 fing 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; + }; +} diff --git a/packages/nodes-base/nodes/Discord/v2/methods/index.ts b/packages/nodes-base/nodes/Discord/v2/methods/index.ts new file mode 100644 index 0000000000..073c80a2ea --- /dev/null +++ b/packages/nodes-base/nodes/Discord/v2/methods/index.ts @@ -0,0 +1,2 @@ +export * as listSearch from './listSearch'; +export * as loadOptions from './loadOptions'; diff --git a/packages/nodes-base/nodes/Discord/v2/methods/listSearch.ts b/packages/nodes-base/nodes/Discord/v2/methods/listSearch.ts new file mode 100644 index 0000000000..902dfb1563 --- /dev/null +++ b/packages/nodes-base/nodes/Discord/v2/methods/listSearch.ts @@ -0,0 +1,164 @@ +import { + type IDataObject, + type ILoadOptionsFunctions, + type INodeListSearchResult, +} from 'n8n-workflow'; +import { discordApiRequest } from '../transport'; +import { checkAccessToGuild } from '../helpers/utils'; + +async function getGuildId(this: ILoadOptionsFunctions) { + const guildId = this.getNodeParameter('guildId', undefined, { + extractValue: true, + }) as string; + + const isOAuth2 = this.getNodeParameter('authentication', '') === 'oAuth2'; + + if (isOAuth2) { + const userGuilds = (await discordApiRequest.call( + this, + 'GET', + '/users/@me/guilds', + )) as IDataObject[]; + + checkAccessToGuild(this.getNode(), guildId, userGuilds); + } + + return guildId; +} + +async function checkBotAccessToGuild(this: ILoadOptionsFunctions, guildId: string, botId: string) { + try { + const members: Array<{ user: { id: string } }> = await discordApiRequest.call( + this, + 'GET', + `/guilds/${guildId}/members`, + undefined, + { limit: 1000 }, + ); + + return members.some((member) => member.user.id === botId); + } catch (error) {} + + return false; +} + +export async function guildSearch(this: ILoadOptionsFunctions): Promise { + const response = (await discordApiRequest.call( + this, + 'GET', + '/users/@me/guilds', + )) as IDataObject[]; + + let guilds: IDataObject[] = []; + + const isOAuth2 = this.getNodeParameter('authentication', 0) === 'oAuth2'; + + if (isOAuth2) { + const botId = (await discordApiRequest.call(this, 'GET', '/users/@me')).id as string; + + for (const guild of response) { + if (!(await checkBotAccessToGuild.call(this, guild.id as string, botId))) continue; + guilds.push(guild); + } + } else { + guilds = response; + } + + return { + results: guilds.map((guild) => ({ + name: guild.name as string, + value: guild.id as string, + url: `https://discord.com/channels/${guild.id}`, + })), + }; +} + +export async function channelSearch(this: ILoadOptionsFunctions): Promise { + const guildId = await getGuildId.call(this); + const response = await discordApiRequest.call(this, 'GET', `/guilds/${guildId}/channels`); + + return { + results: (response as IDataObject[]) + .filter((cannel) => cannel.type !== 4) // Filter out categories + .map((channel) => ({ + name: channel.name as string, + value: channel.id as string, + url: `https://discord.com/channels/${guildId}/${channel.id}`, + })), + }; +} + +export async function textChannelSearch( + this: ILoadOptionsFunctions, +): Promise { + const guildId = await getGuildId.call(this); + + const response = await discordApiRequest.call(this, 'GET', `/guilds/${guildId}/channels`); + + return { + results: (response as IDataObject[]) + .filter((cannel) => ![2, 4].includes(cannel.type as number)) // Only text channels + .map((channel) => ({ + name: channel.name as string, + value: channel.id as string, + url: `https://discord.com/channels/${guildId}/${channel.id}`, + })), + }; +} + +export async function categorySearch(this: ILoadOptionsFunctions): Promise { + const guildId = await getGuildId.call(this); + + const response = await discordApiRequest.call(this, 'GET', `/guilds/${guildId}/channels`); + + return { + results: (response as IDataObject[]) + .filter((cannel) => cannel.type === 4) // Return only categories + .map((channel) => ({ + name: channel.name as string, + value: channel.id as string, + url: `https://discord.com/channels/${guildId}/${channel.id}`, + })), + }; +} + +export async function userSearch( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, +): Promise { + const guildId = await getGuildId.call(this); + + const limit = 100; + const qs = { limit, after: paginationToken }; + + const response = await discordApiRequest.call( + this, + 'GET', + `/guilds/${guildId}/members`, + undefined, + qs, + ); + + if (response.length === 0) { + return { + results: [], + paginationToken: undefined, + }; + } + + let lastUserId; + + //less then limit means that there are no more users to return, so leave lastUserId undefined + if (!(response.length < limit)) { + lastUserId = response[response.length - 1].user.id as string; + } + + return { + results: (response as Array<{ user: IDataObject }>).map(({ user }) => ({ + name: user.username as string, + value: user.id as string, + })), + paginationToken: lastUserId, + }; +} diff --git a/packages/nodes-base/nodes/Discord/v2/methods/loadOptions.ts b/packages/nodes-base/nodes/Discord/v2/methods/loadOptions.ts new file mode 100644 index 0000000000..ab79a4bd1d --- /dev/null +++ b/packages/nodes-base/nodes/Discord/v2/methods/loadOptions.ts @@ -0,0 +1,46 @@ +import type { IDataObject, ILoadOptionsFunctions, INodePropertyOptions } from 'n8n-workflow'; +import { discordApiRequest } from '../transport'; +import { checkAccessToGuild } from '../helpers/utils'; + +export async function getRoles(this: ILoadOptionsFunctions): Promise { + const guildId = this.getNodeParameter('guildId', undefined, { + extractValue: true, + }) as string; + + const isOAuth2 = this.getNodeParameter('authentication', '') === 'oAuth2'; + + if (isOAuth2) { + const userGuilds = (await discordApiRequest.call( + this, + 'GET', + '/users/@me/guilds', + )) as IDataObject[]; + + checkAccessToGuild(this.getNode(), guildId, userGuilds); + } + + let response = await discordApiRequest.call(this, 'GET', `/guilds/${guildId}/roles`); + + const operations = this.getNodeParameter('operation') as string; + + if (operations === 'roleRemove') { + const userId = this.getNodeParameter('userId', undefined, { + extractValue: true, + }) as string; + + const userRoles = (( + await discordApiRequest.call(this, 'GET', `/guilds/${guildId}/members/${userId}`) + ).roles || []) as string[]; + + response = response.filter((role: IDataObject) => { + return userRoles.includes(role.id as string); + }); + } + + return response + .filter((role: IDataObject) => role.name !== '@everyone' && !role.managed) + .map((role: IDataObject) => ({ + name: role.name as string, + value: role.id as string, + })); +} diff --git a/packages/nodes-base/nodes/Discord/v2/transport/discord.api.ts b/packages/nodes-base/nodes/Discord/v2/transport/discord.api.ts new file mode 100644 index 0000000000..5c3c3db1ba --- /dev/null +++ b/packages/nodes-base/nodes/Discord/v2/transport/discord.api.ts @@ -0,0 +1,101 @@ +import type { OptionsWithUrl } from 'request'; + +import type { + IDataObject, + IExecuteFunctions, + IExecuteSingleFunctions, + IHookFunctions, + ILoadOptionsFunctions, +} from 'n8n-workflow'; + +import { sleep, NodeApiError, jsonParse } from 'n8n-workflow'; + +import type FormData from 'form-data'; +import { getCredentialsType, requestApi } from './helpers'; + +export async function discordApiRequest( + this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, + method: string, + endpoint: string, + body?: IDataObject, + qs?: IDataObject, +) { + const authentication = this.getNodeParameter('authentication', 0, 'webhook') as string; + const headers: IDataObject = {}; + + const credentialType = getCredentialsType(authentication); + + const options: OptionsWithUrl = { + headers, + method, + qs, + body, + url: `https://discord.com/api/v10${endpoint}`, + json: true, + }; + + if (credentialType === 'discordWebhookApi') { + const credentials = await this.getCredentials('discordWebhookApi'); + options.url = credentials.webhookUri as string; + } + + try { + const response = await requestApi.call(this, options, credentialType, endpoint); + + const resetAfter = Number(response.headers['x-ratelimit-reset-after']); + const remaining = Number(response.headers['x-ratelimit-remaining']); + + if (remaining === 0) { + await sleep(resetAfter); + } else { + await sleep(20); //prevent excing global rate limit of 50 requests per second + } + + return response.body || { success: true }; + } catch (error) { + throw new NodeApiError(this.getNode(), error); + } +} + +export async function discordApiMultiPartRequest( + this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, + method: string, + endpoint: string, + formData: FormData, +) { + const headers: IDataObject = { + 'content-type': 'multipart/form-data; charset=utf-8', + }; + const authentication = this.getNodeParameter('authentication', 0, 'webhook') as string; + + const credentialType = getCredentialsType(authentication); + + const options: OptionsWithUrl = { + headers, + method, + formData, + url: `https://discord.com/api/v10${endpoint}`, + }; + + if (credentialType === 'discordWebhookApi') { + const credentials = await this.getCredentials('discordWebhookApi'); + options.url = credentials.webhookUri as string; + } + + try { + const response = await requestApi.call(this, options, credentialType, endpoint); + + const resetAfter = Number(response.headers['x-ratelimit-reset-after']); + const remaining = Number(response.headers['x-ratelimit-remaining']); + + if (remaining === 0) { + await sleep(resetAfter); + } else { + await sleep(20); //prevent excing global rate limit of 50 requests per second + } + + return jsonParse(response.body); + } catch (error) { + throw new NodeApiError(this.getNode(), error); + } +} diff --git a/packages/nodes-base/nodes/Discord/v2/transport/helpers.ts b/packages/nodes-base/nodes/Discord/v2/transport/helpers.ts new file mode 100644 index 0000000000..39341c5f4f --- /dev/null +++ b/packages/nodes-base/nodes/Discord/v2/transport/helpers.ts @@ -0,0 +1,47 @@ +import type { OptionsWithUrl } from 'request'; + +import type { + IDataObject, + IExecuteFunctions, + IExecuteSingleFunctions, + IHookFunctions, + ILoadOptionsFunctions, +} from 'n8n-workflow'; + +export const getCredentialsType = (authentication: string) => { + let credentialType = ''; + switch (authentication) { + case 'botToken': + credentialType = 'discordBotApi'; + break; + case 'oAuth2': + credentialType = 'discordOAuth2Api'; + break; + case 'webhook': + credentialType = 'discordWebhookApi'; + break; + default: + credentialType = 'discordBotApi'; + } + return credentialType; +}; + +export async function requestApi( + this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, + options: OptionsWithUrl, + credentialType: string, + endpoint: string, +) { + let response; + if (credentialType === 'discordOAuth2Api' && endpoint !== '/users/@me/guilds') { + const credentials = await this.getCredentials('discordOAuth2Api'); + (options.headers as IDataObject)!.Authorization = `Bot ${credentials.botToken}`; + response = await this.helpers.request({ ...options, resolveWithFullResponse: true }); + } else { + response = await this.helpers.requestWithAuthentication.call(this, credentialType, { + ...options, + resolveWithFullResponse: true, + }); + } + return response; +} diff --git a/packages/nodes-base/nodes/Discord/v2/transport/index.ts b/packages/nodes-base/nodes/Discord/v2/transport/index.ts new file mode 100644 index 0000000000..0e90e2df38 --- /dev/null +++ b/packages/nodes-base/nodes/Discord/v2/transport/index.ts @@ -0,0 +1,2 @@ +export * from './discord.api'; +export * from './helpers'; diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 8ec393a46c..af8e82564f 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -89,6 +89,9 @@ "dist/credentials/DeepLApi.credentials.js", "dist/credentials/DemioApi.credentials.js", "dist/credentials/DhlApi.credentials.js", + "dist/credentials/DiscordBotApi.credentials.js", + "dist/credentials/DiscordOAuth2Api.credentials.js", + "dist/credentials/DiscordWebhookApi.credentials.js", "dist/credentials/DiscourseApi.credentials.js", "dist/credentials/DisqusApi.credentials.js", "dist/credentials/DriftApi.credentials.js", diff --git a/packages/nodes-base/utils/descriptions.ts b/packages/nodes-base/utils/descriptions.ts index 78abc22251..614858a28d 100644 --- a/packages/nodes-base/utils/descriptions.ts +++ b/packages/nodes-base/utils/descriptions.ts @@ -7,3 +7,28 @@ export const oldVersionNotice: INodeProperties = { type: 'notice', default: '', }; + +export const returnAllOrLimit: INodeProperties[] = [ + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + description: 'Whether to return all results or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + returnAll: [false], + }, + }, + typeOptions: { + minValue: 1, + }, + default: 100, + description: 'Max number of results to return', + }, +]; diff --git a/packages/nodes-base/utils/utilities.ts b/packages/nodes-base/utils/utilities.ts index 9149c80e2e..9fe3be14a6 100644 --- a/packages/nodes-base/utils/utilities.ts +++ b/packages/nodes-base/utils/utilities.ts @@ -294,10 +294,19 @@ export function flattenObject(data: IDataObject) { } /** - * Generate Paired Item Data by length of input array + * Capitalizes the first letter of a string * - * @param {number} length + * @param {string} string The string to capitalize */ +export function capitalize(str: string): string { + if (!str) return str; + + const chars = str.split(''); + chars[0] = chars[0].toUpperCase(); + + return chars.join(''); +} + export function generatePairedItemData(length: number): IPairedItemData[] { return Array.from({ length }, (_, item) => ({ item,