diff --git a/packages/nodes-base/nodes/Discord/test/v2/node/member/ban.test.ts b/packages/nodes-base/nodes/Discord/test/v2/node/member/ban.test.ts new file mode 100644 index 00000000000..cf73cf8e364 --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/node/member/ban.test.ts @@ -0,0 +1,25 @@ +import { NodeTestHarness } from '@nodes-testing/node-test-harness'; +import nock from 'nock'; + +describe('Test DiscordV2, member => ban', () => { + beforeEach(() => { + nock('https://discord.com/api/v10', { + reqheaders: { + 'x-audit-log-reason': 'Suspicious%20or%20spam%20account', + }, + }) + .persist() + .put('/guilds/1168516062791340136/bans/470936827994570762', { + delete_message_seconds: 86400, + }) + .reply(200, { success: true }); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + new NodeTestHarness().setupTests({ + workflowFiles: ['ban.workflow.json'], + }); +}); diff --git a/packages/nodes-base/nodes/Discord/test/v2/node/member/ban.workflow.json b/packages/nodes-base/nodes/Discord/test/v2/node/member/ban.workflow.json new file mode 100644 index 00000000000..418c8a8b3f3 --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/node/member/ban.workflow.json @@ -0,0 +1,78 @@ +{ + "name": "discord member ban test", + "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": "ban", + "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" + }, + "deleteMessageSeconds": 86400, + "reason": "suspicious_spam" + }, + "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/kick.test.ts b/packages/nodes-base/nodes/Discord/test/v2/node/member/kick.test.ts new file mode 100644 index 00000000000..864df3181ee --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/node/member/kick.test.ts @@ -0,0 +1,24 @@ +import { NodeTestHarness } from '@nodes-testing/node-test-harness'; +import nock from 'nock'; + +describe('Test DiscordV2, member => kick', () => { + // Uses the "Other" reason path, asserting the custom reason reaches the audit log header + beforeEach(() => { + nock('https://discord.com/api/v10', { + reqheaders: { + 'x-audit-log-reason': 'Posting%20phishing%20links', + }, + }) + .persist() + .delete('/guilds/1168516062791340136/members/470936827994570762') + .reply(200, { success: true }); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + new NodeTestHarness().setupTests({ + workflowFiles: ['kick.workflow.json'], + }); +}); diff --git a/packages/nodes-base/nodes/Discord/test/v2/node/member/kick.workflow.json b/packages/nodes-base/nodes/Discord/test/v2/node/member/kick.workflow.json new file mode 100644 index 00000000000..fa91a8b7245 --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/node/member/kick.workflow.json @@ -0,0 +1,78 @@ +{ + "name": "discord member kick test", + "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": "kick", + "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" + }, + "reason": "other", + "reasonCustom": "Posting phishing links" + }, + "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/timeout.test.ts b/packages/nodes-base/nodes/Discord/test/v2/node/member/timeout.test.ts new file mode 100644 index 00000000000..0c0205b785b --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/node/member/timeout.test.ts @@ -0,0 +1,26 @@ +import { NodeTestHarness } from '@nodes-testing/node-test-harness'; +import nock from 'nock'; + +describe('Test DiscordV2, member => timeout', () => { + beforeEach(() => { + nock('https://discord.com/api/v10', { + reqheaders: { + 'x-audit-log-reason': 'Breaking%20server%20rules', + }, + }) + .persist() + .patch( + '/guilds/1168516062791340136/members/470936827994570762', + (body) => typeof body.communication_disabled_until === 'string', + ) + .reply(200, { success: true }); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + new NodeTestHarness().setupTests({ + workflowFiles: ['timeout.workflow.json'], + }); +}); diff --git a/packages/nodes-base/nodes/Discord/test/v2/node/member/timeout.workflow.json b/packages/nodes-base/nodes/Discord/test/v2/node/member/timeout.workflow.json new file mode 100644 index 00000000000..951e60e9259 --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/node/member/timeout.workflow.json @@ -0,0 +1,78 @@ +{ + "name": "discord member timeout test", + "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": "timeout", + "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" + }, + "duration": 3600, + "reason": "rule_break" + }, + "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/unban.test.ts b/packages/nodes-base/nodes/Discord/test/v2/node/member/unban.test.ts new file mode 100644 index 00000000000..0fdd1945fae --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/node/member/unban.test.ts @@ -0,0 +1,19 @@ +import { NodeTestHarness } from '@nodes-testing/node-test-harness'; +import nock from 'nock'; + +describe('Test DiscordV2, member => unban', () => { + beforeEach(() => { + nock('https://discord.com/api/v10') + .persist() + .delete('/guilds/1168516062791340136/bans/470936827994570762') + .reply(200, { success: true }); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + new NodeTestHarness().setupTests({ + workflowFiles: ['unban.workflow.json'], + }); +}); diff --git a/packages/nodes-base/nodes/Discord/test/v2/node/member/unban.workflow.json b/packages/nodes-base/nodes/Discord/test/v2/node/member/unban.workflow.json new file mode 100644 index 00000000000..7838ade3656 --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/node/member/unban.workflow.json @@ -0,0 +1,77 @@ +{ + "name": "discord member unban test", + "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": "unban", + "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" + }, + "reason": "suspicious_spam" + }, + "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/transport/discord.api.test.ts b/packages/nodes-base/nodes/Discord/test/v2/transport/discord.api.test.ts index a562ffa2579..fa7ce41db96 100644 --- a/packages/nodes-base/nodes/Discord/test/v2/transport/discord.api.test.ts +++ b/packages/nodes-base/nodes/Discord/test/v2/transport/discord.api.test.ts @@ -262,6 +262,22 @@ describe('Discord v2 > transport', () => { expect(sleepMock).toHaveBeenNthCalledWith(2, 3000); // post-success reset-after }); + it('forwards custom headers (e.g. audit-log reason) to the request', async () => { + const { context, requestWithAuthentication } = createMockContext(); + requestWithAuthentication.mockResolvedValueOnce({ body: { success: true }, headers: {} }); + + await discordApiRequest.call(context, 'PUT', '/guilds/1/bans/2', undefined, undefined, { + 'X-Audit-Log-Reason': 'Suspicious%20or%20spam%20account', + }); + + expect(requestWithAuthentication).toHaveBeenCalledWith( + 'discordBotApi', + expect.objectContaining({ + headers: { 'X-Audit-Log-Reason': 'Suspicious%20or%20spam%20account' }, + }), + ); + }); + it('wraps non-429 errors as NodeApiError', async () => { const { context, requestWithAuthentication } = createMockContext(); requestWithAuthentication.mockRejectedValueOnce({ statusCode: 400, message: 'bad request' }); diff --git a/packages/nodes-base/nodes/Discord/v2/actions/common.description.ts b/packages/nodes-base/nodes/Discord/v2/actions/common.description.ts index d052e993e3e..86f8258bff6 100644 --- a/packages/nodes-base/nodes/Discord/v2/actions/common.description.ts +++ b/packages/nodes-base/nodes/Discord/v2/actions/common.description.ts @@ -266,6 +266,89 @@ export const roleMultiOptions: INodeProperties = { default: [], }; +// moderation -------------------------------------------------------------------------------- + +// Maps the reason option value to the text written to Discord's audit log +export const moderationReasonLabels: Record = { + suspicious_spam: 'Suspicious or spam account', + compromised: 'Compromised or hacked account', + rule_break: 'Breaking server rules', +}; + +export const moderationReason: INodeProperties = { + displayName: 'Reason', + name: 'reason', + type: 'options', + description: 'The reason recorded in the server audit log', + options: [ + { + name: 'Suspicious or Spam Account', + value: 'suspicious_spam', + }, + { + name: 'Compromised or Hacked Account', + value: 'compromised', + }, + { + name: 'Breaking Server Rules', + value: 'rule_break', + }, + { + name: 'Other', + value: 'other', + }, + ], + default: 'suspicious_spam', +}; + +export const moderationReasonCustom: INodeProperties = { + displayName: 'Custom Reason', + name: 'reasonCustom', + type: 'string', + default: '', + description: 'The custom reason recorded in the server audit log', + placeholder: 'e.g. Posting phishing links', + displayOptions: { + show: { + reason: ['other'], + }, + }, +}; + +export const banDeleteHistory: INodeProperties = { + displayName: 'Delete Message History', + name: 'deleteMessageSeconds', + type: 'options', + description: "How much of the user's recent message history to delete on ban", + options: [ + { name: 'No Cleanup', value: 0 }, + { name: 'Previous Hour', value: 3600 }, + { name: 'Previous 6 Hours', value: 21600 }, + { name: 'Previous 12 Hours', value: 43200 }, + { name: 'Previous 24 Hours', value: 86400 }, + { name: 'Previous 3 Days', value: 259200 }, + { name: 'Previous 7 Days', value: 604800 }, + ], + default: 0, +}; + +export const timeoutDuration: INodeProperties = { + displayName: 'Duration', + name: 'duration', + type: 'options', + description: 'How long the member is prevented from interacting (Discord max is 28 days)', + options: [ + { name: '60 Seconds', value: 60 }, + { name: '5 Minutes', value: 300 }, + { name: '1 Hour', value: 3600 }, + { name: '1 Day', value: 86400 }, + { name: '1 Week', value: 604800 }, + { name: '28 Days (Max)', value: 2419200 }, + { name: 'Remove Timeout', value: 'remove' }, + ], + default: 3600, +}; + export const maxResultsNumber: INodeProperties = { displayName: 'Max Results', name: 'maxResults', diff --git a/packages/nodes-base/nodes/Discord/v2/actions/member/ban.operation.ts b/packages/nodes-base/nodes/Discord/v2/actions/member/ban.operation.ts new file mode 100644 index 00000000000..18c14b236a4 --- /dev/null +++ b/packages/nodes-base/nodes/Discord/v2/actions/member/ban.operation.ts @@ -0,0 +1,75 @@ +import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow'; + +import { updateDisplayOptions } from '../../../../../utils/utilities'; +import { getAuditLogReasonHeaders, parseDiscordError, prepareErrorData } from '../../helpers/utils'; +import { discordApiRequest } from '../../transport'; +import { + banDeleteHistory, + moderationReason, + moderationReasonCustom, + userRLC, +} from '../common.description'; + +const properties: INodeProperties[] = [ + userRLC, + banDeleteHistory, + moderationReason, + moderationReasonCustom, +]; + +const displayOptions = { + show: { + resource: ['member'], + operation: ['ban'], + }, + 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 deleteMessageSeconds = this.getNodeParameter('deleteMessageSeconds', i, 0) as number; + + await discordApiRequest.call( + this, + 'PUT', + `/guilds/${guildId}/bans/${userId}`, + { delete_message_seconds: deleteMessageSeconds }, + undefined, + getAuditLogReasonHeaders.call(this, i), + ); + + 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/index.ts b/packages/nodes-base/nodes/Discord/v2/actions/member/index.ts index 5f2e87f749e..55ccf346c62 100644 --- a/packages/nodes-base/nodes/Discord/v2/actions/member/index.ts +++ b/packages/nodes-base/nodes/Discord/v2/actions/member/index.ts @@ -1,11 +1,15 @@ import type { INodeProperties } from 'n8n-workflow'; +import * as ban from './ban.operation'; import * as getAll from './getAll.operation'; +import * as kick from './kick.operation'; import * as roleAdd from './roleAdd.operation'; import * as roleRemove from './roleRemove.operation'; +import * as timeout from './timeout.operation'; +import * as unban from './unban.operation'; import { guildRLC } from '../common.description'; -export { getAll, roleAdd, roleRemove }; +export { ban, getAll, kick, roleAdd, roleRemove, timeout, unban }; export const description: INodeProperties[] = [ { @@ -20,12 +24,24 @@ export const description: INodeProperties[] = [ }, }, options: [ + { + name: 'Ban', + value: 'ban', + description: 'Ban a member from a server, optionally deleting their recent messages', + action: 'Ban a member', + }, { name: 'Get Many', value: 'getAll', description: 'Retrieve the members of a server', action: 'Get many members', }, + { + name: 'Kick', + value: 'kick', + description: 'Remove a member from a server', + action: 'Kick a member', + }, { name: 'Role Add', value: 'roleAdd', @@ -38,6 +54,18 @@ export const description: INodeProperties[] = [ description: 'Remove a role from a member', action: 'Remove a role from a member', }, + { + name: 'Timeout', + value: 'timeout', + description: 'Temporarily prevent a member from interacting', + action: 'Timeout a member', + }, + { + name: 'Unban', + value: 'unban', + description: 'Remove a ban from a member', + action: 'Unban a member', + }, ], default: 'getAll', }, @@ -50,7 +78,11 @@ export const description: INodeProperties[] = [ }, }, }, + ...ban.description, ...getAll.description, + ...kick.description, ...roleAdd.description, ...roleRemove.description, + ...timeout.description, + ...unban.description, ]; diff --git a/packages/nodes-base/nodes/Discord/v2/actions/member/kick.operation.ts b/packages/nodes-base/nodes/Discord/v2/actions/member/kick.operation.ts new file mode 100644 index 00000000000..66b8aa683cf --- /dev/null +++ b/packages/nodes-base/nodes/Discord/v2/actions/member/kick.operation.ts @@ -0,0 +1,63 @@ +import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow'; + +import { updateDisplayOptions } from '../../../../../utils/utilities'; +import { getAuditLogReasonHeaders, parseDiscordError, prepareErrorData } from '../../helpers/utils'; +import { discordApiRequest } from '../../transport'; +import { moderationReason, moderationReasonCustom, userRLC } from '../common.description'; + +const properties: INodeProperties[] = [userRLC, moderationReason, moderationReasonCustom]; + +const displayOptions = { + show: { + resource: ['member'], + operation: ['kick'], + }, + 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; + + await discordApiRequest.call( + this, + 'DELETE', + `/guilds/${guildId}/members/${userId}`, + undefined, + undefined, + getAuditLogReasonHeaders.call(this, i), + ); + + 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/timeout.operation.ts b/packages/nodes-base/nodes/Discord/v2/actions/member/timeout.operation.ts new file mode 100644 index 00000000000..f4863b41f87 --- /dev/null +++ b/packages/nodes-base/nodes/Discord/v2/actions/member/timeout.operation.ts @@ -0,0 +1,80 @@ +import { DateTime } from 'luxon'; +import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow'; + +import { updateDisplayOptions } from '../../../../../utils/utilities'; +import { getAuditLogReasonHeaders, parseDiscordError, prepareErrorData } from '../../helpers/utils'; +import { discordApiRequest } from '../../transport'; +import { + moderationReason, + moderationReasonCustom, + timeoutDuration, + userRLC, +} from '../common.description'; + +const properties: INodeProperties[] = [ + userRLC, + timeoutDuration, + moderationReason, + moderationReasonCustom, +]; + +const displayOptions = { + show: { + resource: ['member'], + operation: ['timeout'], + }, + 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 duration = this.getNodeParameter('duration', i, 3600) as number | 'remove'; + + // null clears an active timeout; otherwise set the expiry relative to now + const communicationDisabledUntil = + duration === 'remove' ? null : DateTime.now().plus({ seconds: duration }).toISO(); + + await discordApiRequest.call( + this, + 'PATCH', + `/guilds/${guildId}/members/${userId}`, + { communication_disabled_until: communicationDisabledUntil }, + undefined, + getAuditLogReasonHeaders.call(this, i), + ); + + 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/unban.operation.ts b/packages/nodes-base/nodes/Discord/v2/actions/member/unban.operation.ts new file mode 100644 index 00000000000..8c83279fa85 --- /dev/null +++ b/packages/nodes-base/nodes/Discord/v2/actions/member/unban.operation.ts @@ -0,0 +1,63 @@ +import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow'; + +import { updateDisplayOptions } from '../../../../../utils/utilities'; +import { getAuditLogReasonHeaders, parseDiscordError, prepareErrorData } from '../../helpers/utils'; +import { discordApiRequest } from '../../transport'; +import { moderationReason, moderationReasonCustom, userRLC } from '../common.description'; + +const properties: INodeProperties[] = [userRLC, moderationReason, moderationReasonCustom]; + +const displayOptions = { + show: { + resource: ['member'], + operation: ['unban'], + }, + 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; + + await discordApiRequest.call( + this, + 'DELETE', + `/guilds/${guildId}/bans/${userId}`, + undefined, + undefined, + getAuditLogReasonHeaders.call(this, i), + ); + + 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/node.type.ts b/packages/nodes-base/nodes/Discord/v2/actions/node.type.ts index 0b05d7f6938..64f4175556c 100644 --- a/packages/nodes-base/nodes/Discord/v2/actions/node.type.ts +++ b/packages/nodes-base/nodes/Discord/v2/actions/node.type.ts @@ -3,7 +3,7 @@ import type { AllEntities } from 'n8n-workflow'; type NodeMap = { channel: 'get' | 'getAll' | 'create' | 'update' | 'deleteChannel'; message: 'deleteMessage' | 'getAll' | 'get' | 'react' | 'send' | 'sendAndWait'; - member: 'getAll' | 'roleAdd' | 'roleRemove'; + member: 'ban' | 'getAll' | 'kick' | 'roleAdd' | 'roleRemove' | 'timeout' | 'unban'; webhook: 'sendLegacy'; }; diff --git a/packages/nodes-base/nodes/Discord/v2/helpers/__tests__/utils.test.ts b/packages/nodes-base/nodes/Discord/v2/helpers/__tests__/utils.test.ts index c04e3f1bef9..d8bcba8e872 100644 --- a/packages/nodes-base/nodes/Discord/v2/helpers/__tests__/utils.test.ts +++ b/packages/nodes-base/nodes/Discord/v2/helpers/__tests__/utils.test.ts @@ -1,7 +1,7 @@ import { mockDeep } from 'vitest-mock-extended'; import type { IExecuteFunctions, IDataObject } from 'n8n-workflow'; import { NodeOperationError, jsonParse } from 'n8n-workflow'; -import { prepareMultiPartForm } from '../utils'; +import { getAuditLogReasonHeaders, prepareMultiPartForm } from '../utils'; describe('Discord V2 Utils', () => { describe('prepareMultiPartForm', () => { @@ -361,4 +361,53 @@ describe('Discord V2 Utils', () => { expect(result).toBeDefined(); }); }); + + describe('getAuditLogReasonHeaders', () => { + let mockExecuteFunctions: IExecuteFunctions; + + // Returns getNodeParameter values keyed by parameter name for a single item. + const mockParams = (params: Record) => { + mockExecuteFunctions.getNodeParameter = vi + .fn() + .mockImplementation((name: string, _i: number, fallback: string) => + name in params ? params[name] : fallback, + ); + }; + + beforeEach(() => { + mockExecuteFunctions = mockDeep(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('maps a preset reason to its label and URL-encodes it', () => { + mockParams({ reason: 'suspicious_spam' }); + + const headers = getAuditLogReasonHeaders.call(mockExecuteFunctions, 0); + + expect(headers).toEqual({ 'X-Audit-Log-Reason': 'Suspicious%20or%20spam%20account' }); + }); + + it('uses the custom text when the reason is "other"', () => { + mockParams({ reason: 'other', reasonCustom: 'Posting phishing links' }); + + const headers = getAuditLogReasonHeaders.call(mockExecuteFunctions, 0); + + expect(headers).toEqual({ 'X-Audit-Log-Reason': 'Posting%20phishing%20links' }); + }); + + it('returns no header when the reason is "other" but the custom text is empty', () => { + mockParams({ reason: 'other', reasonCustom: '' }); + + expect(getAuditLogReasonHeaders.call(mockExecuteFunctions, 0)).toEqual({}); + }); + + it('returns no header when no reason is set', () => { + mockParams({}); + + expect(getAuditLogReasonHeaders.call(mockExecuteFunctions, 0)).toEqual({}); + }); + }); }); diff --git a/packages/nodes-base/nodes/Discord/v2/helpers/utils.ts b/packages/nodes-base/nodes/Discord/v2/helpers/utils.ts index ed5363b9dd7..33eb5116037 100644 --- a/packages/nodes-base/nodes/Discord/v2/helpers/utils.ts +++ b/packages/nodes-base/nodes/Discord/v2/helpers/utils.ts @@ -6,6 +6,7 @@ import { jsonParse, NodeApiError, NodeOperationError } from 'n8n-workflow'; import { getSendAndWaitConfig } from '../../../../utils/sendAndWait/utils'; import { capitalize, createUtmCampaignLink } from '../../../../utils/utilities'; +import { moderationReasonLabels } from '../actions/common.description'; import { discordApiMultiPartRequest, discordApiRequest } from '../transport'; export const createSimplifyFunction = @@ -91,6 +92,22 @@ export function prepareErrorData(this: IExecuteFunctions, error: any, i: number) ); } +// Builds the headers carrying Discord's audit-log reason from the node's +// reason/reasonCustom parameters. Empty object when no reason is set. +export function getAuditLogReasonHeaders(this: IExecuteFunctions, itemIndex: number): IDataObject { + const reason = this.getNodeParameter('reason', itemIndex, '') as string; + + const text = + reason === 'other' + ? (this.getNodeParameter('reasonCustom', itemIndex, '') as string) + : moderationReasonLabels[reason]; + + if (!text) return {}; + + // Discord requires the header value to be URL-encoded (it accepts non-ASCII reasons) + return { 'X-Audit-Log-Reason': encodeURIComponent(text) }; +} + export function prepareOptions(options: IDataObject, guildId?: string) { if (options.flags) { if ((options.flags as string[]).length === 2) { diff --git a/packages/nodes-base/nodes/Discord/v2/transport/discord.api.ts b/packages/nodes-base/nodes/Discord/v2/transport/discord.api.ts index 7a3b3847933..b9a140ddaf0 100644 --- a/packages/nodes-base/nodes/Discord/v2/transport/discord.api.ts +++ b/packages/nodes-base/nodes/Discord/v2/transport/discord.api.ts @@ -18,9 +18,9 @@ export async function discordApiRequest( endpoint: string, body?: IDataObject, qs?: IDataObject, + headers: IDataObject = {}, ) { const authentication = this.getNodeParameter('authentication', 0, 'webhook') as string; - const headers: IDataObject = {}; const credentialType = getCredentialsType(authentication);