mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
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:
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user