feat(Discord Node): Add member moderation actions (#33486)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Jon <jonathan.bennetts@gmail.com>
This commit is contained in:
jabbson
2026-08-11 10:57:22 -03:00
committed by GitHub
parent 42130c5362
commit 6bd31e5e1e
19 changed files with 887 additions and 4 deletions
@@ -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'],
});
});
@@ -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": []
}
@@ -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'],
});
});
@@ -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": []
}
@@ -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'],
});
});
@@ -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": []
}
@@ -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'],
});
});
@@ -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": []
}
@@ -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' });
@@ -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<string, string> = {
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',
@@ -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<INodeExecutionData[]> {
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;
}
@@ -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,
];
@@ -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<INodeExecutionData[]> {
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;
}
@@ -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<INodeExecutionData[]> {
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;
}
@@ -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<INodeExecutionData[]> {
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;
}
@@ -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';
};
@@ -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<string, string>) => {
mockExecuteFunctions.getNodeParameter = vi
.fn()
.mockImplementation((name: string, _i: number, fallback: string) =>
name in params ? params[name] : fallback,
);
};
beforeEach(() => {
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
});
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({});
});
});
});
@@ -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) {
@@ -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);