feat(Discord Node): New sendAndWait operation (#12894)

Co-authored-by: Dana <152518854+dana-gill@users.noreply.github.com>
This commit is contained in:
Michael Kret 2025-01-31 13:44:42 +02:00 committed by GitHub
parent 066908060f
commit d47bfddd65
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 379 additions and 135 deletions

View file

@ -2,7 +2,10 @@
"node": "n8n-nodes-base.discord",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Communication"],
"categories": ["Communication", "HITL"],
"subcategories": {
"HITL": ["Human in the Loop"]
},
"resources": {
"credentialDocumentation": [
{
@ -14,5 +17,6 @@
"url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.discord/"
}
]
}
},
"alias": ["human", "form", "wait", "hitl", "approval"]
}

View file

@ -0,0 +1,93 @@
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import { SEND_AND_WAIT_OPERATION, type IExecuteFunctions } from 'n8n-workflow';
import { versionDescription } from '../../../../v2/actions/versionDescription';
import { DiscordV2 } from '../../../../v2/DiscordV2.node';
import * as transport from '../../../../v2/transport/discord.api';
jest.mock('../../../../v2/transport/discord.api', () => {
const originalModule = jest.requireActual('../../../../v2/transport/discord.api');
return {
...originalModule,
discordApiRequest: jest.fn(async function (method: string) {
if (method === 'POST') {
return {};
}
}),
};
});
describe('Test DiscordV2, message => sendAndWait', () => {
let discord: DiscordV2;
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
beforeEach(() => {
discord = new DiscordV2(versionDescription);
mockExecuteFunctions = mock<IExecuteFunctions>();
mockExecuteFunctions.helpers = {
constructExecutionMetaData: jest.fn(() => []),
returnJsonArray: jest.fn(() => []),
} as any;
});
afterEach(() => {
jest.clearAllMocks();
});
it('should send message and put execution to wait', async () => {
const items = [{ json: { data: 'test' } }];
mockExecuteFunctions.getNodeParameter.mockImplementation((key: string) => {
if (key === 'operation') return SEND_AND_WAIT_OPERATION;
if (key === 'resource') return 'message';
if (key === 'authentication') return 'botToken';
if (key === 'message') return 'my message';
if (key === 'subject') return '';
if (key === 'approvalOptions.values') return {};
if (key === 'responseType') return 'approval';
if (key === 'sendTo') return 'channel';
if (key === 'channelId') return 'channelID';
if (key === 'options.limitWaitTime.values') return {};
});
mockExecuteFunctions.putExecutionToWait.mockImplementation();
mockExecuteFunctions.getInputData.mockReturnValue(items);
mockExecuteFunctions.getInstanceId.mockReturnValue('instanceId');
mockExecuteFunctions.evaluateExpression.mockReturnValueOnce('http://localhost/waiting-webhook');
mockExecuteFunctions.evaluateExpression.mockReturnValueOnce('nodeID');
const result = await discord.execute.call(mockExecuteFunctions);
expect(result).toEqual([items]);
expect(transport.discordApiRequest).toHaveBeenCalledTimes(1);
expect(mockExecuteFunctions.putExecutionToWait).toHaveBeenCalledTimes(1);
expect(transport.discordApiRequest).toHaveBeenCalledWith(
'POST',
'/channels/channelID/messages',
{
components: [
{
components: [
{
label: 'Approve',
style: 5,
type: 2,
url: 'http://localhost/waiting-webhook/nodeID?approved=true',
},
],
type: 1,
},
],
embeds: [
{
color: 5814783,
description:
'my message\n\n_This message was sent automatically with _[n8n](https://n8n.io/?utm_source=n8n-internal&utm_medium=powered_by&utm_campaign=n8n-nodes-base.telegram_instanceId)',
},
],
},
);
});
});

View file

@ -9,6 +9,7 @@ import type {
import { router } from './actions/router';
import { versionDescription } from './actions/versionDescription';
import { listSearch, loadOptions } from './methods';
import { sendAndWaitWebhook } from '../../../utils/sendAndWait/utils';
export class DiscordV2 implements INodeType {
description: INodeTypeDescription;
@ -25,6 +26,8 @@ export class DiscordV2 implements INodeType {
loadOptions,
};
webhook = sendAndWaitWebhook;
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
return await router.call(this);
}

View file

@ -463,3 +463,40 @@ export const filesFixedCollection: INodeProperties = {
},
],
};
export const sendToProperties: 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'],
},
},
},
];

View file

@ -1,13 +1,14 @@
import type { INodeProperties } from 'n8n-workflow';
import { SEND_AND_WAIT_OPERATION, type INodeProperties } from 'n8n-workflow';
import * as deleteMessage from './deleteMessage.operation';
import * as get from './get.operation';
import * as getAll from './getAll.operation';
import * as react from './react.operation';
import * as send from './send.operation';
import * as sendAndWait from './sendAndWait.operation';
import { guildRLC } from '../common.description';
export { getAll, react, send, deleteMessage, get };
export { getAll, react, send, deleteMessage, get, sendAndWait };
export const description: INodeProperties[] = [
{
@ -52,6 +53,12 @@ export const description: INodeProperties[] = [
description: 'Send a message to a channel, thread, or member',
action: 'Send a message',
},
{
name: 'Send and Wait for Response',
value: SEND_AND_WAIT_OPERATION,
description: 'Send a message and wait for response',
action: 'Send message and wait for response',
},
],
default: 'send',
},
@ -69,4 +76,5 @@ export const description: INodeProperties[] = [
...send.description,
...deleteMessage.description,
...get.description,
...sendAndWait.description,
];

View file

@ -4,60 +4,23 @@ import type {
INodeExecutionData,
INodeProperties,
} from 'n8n-workflow';
import { NodeApiError, NodeOperationError } from 'n8n-workflow';
import { updateDisplayOptions } from '../../../../../utils/utilities';
import {
checkAccessToChannel,
parseDiscordError,
prepareEmbeds,
prepareErrorData,
prepareMultiPartForm,
prepareOptions,
sendDiscordMessage,
} from '../../helpers/utils';
import { discordApiMultiPartRequest, discordApiRequest } from '../../transport';
import {
embedsFixedCollection,
filesFixedCollection,
textChannelRLC,
userRLC,
sendToProperties,
} from '../common.description';
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'],
},
},
},
...sendToProperties,
{
displayName: 'Message',
name: 'content',
@ -157,90 +120,17 @@ export async function execute(
}
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;
if (isOAuth2) {
try {
await discordApiRequest.call(this, 'GET', `/guilds/${guildId}/members/${userId}`);
} catch (error) {
if (error instanceof NodeApiError && error.httpCode === '404') {
throw new NodeOperationError(
this.getNode(),
`User with the id ${userId} is not a member of the selected guild`,
{
itemIndex: i,
},
);
}
throw new NodeOperationError(this.getNode(), error, {
itemIndex: i,
});
}
}
channelId = (
(await discordApiRequest.call(this, 'POST', '/users/@me/channels', {
recipient_id: userId,
})) as IDataObject
).id as string;
if (!channelId) {
throw new NodeOperationError(
this.getNode(),
'Could not create a channel to send direct message to',
{ itemIndex: i },
);
}
}
if (sendTo === 'channel') {
channelId = this.getNodeParameter('channelId', i, undefined, {
extractValue: true,
}) as string;
}
if (isOAuth2 && sendTo !== 'user') {
await checkAccessToChannel.call(this, channelId, userGuilds, i);
}
if (!channelId) {
throw new NodeOperationError(this.getNode(), 'Channel ID is required', { itemIndex: 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`,
returnData.push(
...(await sendDiscordMessage.call(this, {
guildId,
userGuilds,
isOAuth2,
body,
);
}
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(response),
{ itemData: { item: i } },
items,
files,
itemIndex: i,
})),
);
returnData.push(...executionData);
} catch (error) {
const err = parseDiscordError.call(this, error, i);

View file

@ -0,0 +1,56 @@
import type {
IDataObject,
IExecuteFunctions,
INodeExecutionData,
INodeProperties,
} from 'n8n-workflow';
import { getSendAndWaitProperties } from '../../../../../utils/sendAndWait/utils';
import {
createSendAndWaitMessageBody,
parseDiscordError,
prepareErrorData,
sendDiscordMessage,
} from '../../helpers/utils';
import { sendToProperties } from '../common.description';
export const description: INodeProperties[] = getSendAndWaitProperties(
sendToProperties,
'message',
undefined,
{
noButtonStyle: true,
defaultApproveLabel: '✓ Approve',
defaultDisapproveLabel: '✗ Decline',
},
).filter((p) => p.name !== 'subject');
export async function execute(
this: IExecuteFunctions,
guildId: string,
userGuilds: IDataObject[],
): Promise<INodeExecutionData[]> {
const items = this.getInputData();
const isOAuth2 = this.getNodeParameter('authentication', 0) === 'oAuth2';
try {
await sendDiscordMessage.call(this, {
guildId,
userGuilds,
isOAuth2,
body: createSendAndWaitMessageBody(this),
items,
});
} catch (error) {
const err = parseDiscordError.call(this, error, 0);
if (this.continueOnFail()) {
return prepareErrorData.call(this, err, 0);
}
throw err;
}
return items;
}

View file

@ -2,7 +2,7 @@ import type { AllEntities } from 'n8n-workflow';
type NodeMap = {
channel: 'get' | 'getAll' | 'create' | 'update' | 'deleteChannel';
message: 'deleteMessage' | 'getAll' | 'get' | 'react' | 'send';
message: 'deleteMessage' | 'getAll' | 'get' | 'react' | 'send' | 'sendAndWait';
member: 'getAll' | 'roleAdd' | 'roleRemove';
webhook: 'sendLegacy';
};

View file

@ -1,11 +1,12 @@
import type { IDataObject, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { NodeOperationError, SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
import * as channel from './channel';
import * as member from './member';
import * as message from './message';
import type { Discord } from './node.type';
import * as webhook from './webhook';
import { configureWaitTillDate } from '../../../../utils/sendAndWait/utils';
import { checkAccessToGuild } from '../helpers/utils';
import { discordApiRequest } from '../transport';
@ -46,6 +47,15 @@ export async function router(this: IExecuteFunctions) {
operation,
} as Discord;
if (discord.resource === 'message' && discord.operation === SEND_AND_WAIT_OPERATION) {
returnData = await message[discord.operation].execute.call(this, guildId, userGuilds);
const waitTill = configureWaitTillDate(this);
await this.putExecutionToWait(waitTill);
return [returnData];
}
switch (discord.resource) {
case 'channel':
returnData = await channel[discord.operation].execute.call(this, guildId, userGuilds);

View file

@ -5,6 +5,7 @@ import * as channel from './channel';
import * as member from './member';
import * as message from './message';
import * as webhook from './webhook';
import { sendAndWaitWebhooksDescription } from '../../../../utils/sendAndWait/descriptions';
export const versionDescription: INodeTypeDescription = {
displayName: 'Discord',
@ -19,6 +20,7 @@ export const versionDescription: INodeTypeDescription = {
},
inputs: [NodeConnectionType.Main],
outputs: [NodeConnectionType.Main],
webhooks: sendAndWaitWebhooksDescription,
credentials: [
{
name: 'discordBotApi',

View file

@ -8,10 +8,11 @@ import type {
INode,
INodeExecutionData,
} from 'n8n-workflow';
import { jsonParse, NodeOperationError } from 'n8n-workflow';
import { jsonParse, NodeApiError, NodeOperationError } from 'n8n-workflow';
import { getSendAndWaitConfig } from '../../../../utils/sendAndWait/utils';
import { capitalize } from '../../../../utils/utilities';
import { discordApiRequest } from '../transport';
import { discordApiMultiPartRequest, discordApiRequest } from '../transport';
export const createSimplifyFunction =
(includedFields: string[]) =>
@ -285,3 +286,141 @@ export async function setupChannelGetter(this: IExecuteFunctions, userGuilds: ID
return channelId;
};
}
export async function sendDiscordMessage(
this: IExecuteFunctions,
{
guildId,
userGuilds,
isOAuth2,
body,
items,
files = [],
itemIndex = 0,
}: {
guildId: string;
userGuilds: IDataObject[];
isOAuth2: boolean;
body: IDataObject;
items: INodeExecutionData[];
files?: IDataObject[];
itemIndex?: number;
},
) {
const sendTo = this.getNodeParameter('sendTo', itemIndex) as string;
let channelId = '';
if (sendTo === 'user') {
const userId = this.getNodeParameter('userId', itemIndex, undefined, {
extractValue: true,
}) as string;
if (isOAuth2) {
try {
await discordApiRequest.call(this, 'GET', `/guilds/${guildId}/members/${userId}`);
} catch (error) {
if (error instanceof NodeApiError && error.httpCode === '404') {
throw new NodeOperationError(
this.getNode(),
`User with the id ${userId} is not a member of the selected guild`,
{
itemIndex,
},
);
}
throw new NodeOperationError(this.getNode(), error, {
itemIndex,
});
}
}
channelId = (
(await discordApiRequest.call(this, 'POST', '/users/@me/channels', {
recipient_id: userId,
})) as IDataObject
).id as string;
if (!channelId) {
throw new NodeOperationError(
this.getNode(),
'Could not create a channel to send direct message to',
{ itemIndex },
);
}
}
if (sendTo === 'channel') {
channelId = this.getNodeParameter('channelId', itemIndex, undefined, {
extractValue: true,
}) as string;
}
if (isOAuth2 && sendTo !== 'user') {
await checkAccessToChannel.call(this, channelId, userGuilds, itemIndex);
}
if (!channelId) {
throw new NodeOperationError(this.getNode(), 'Channel ID is required', {
itemIndex,
});
}
let response: IDataObject[] = [];
if (files?.length) {
const multiPartBody = await prepareMultiPartForm.call(this, items, files, body, itemIndex);
response = await discordApiMultiPartRequest.call(
this,
'POST',
`/channels/${channelId}/messages`,
multiPartBody,
);
} else {
response = await discordApiRequest.call(this, 'POST', `/channels/${channelId}/messages`, body);
}
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(response),
{ itemData: { item: itemIndex } },
);
return executionData;
}
export function createSendAndWaitMessageBody(context: IExecuteFunctions) {
const config = getSendAndWaitConfig(context);
const instanceId = context.getInstanceId();
const attributionText = 'This message was sent automatically with ';
const link = `https://n8n.io/?utm_source=n8n-internal&utm_medium=powered_by&utm_campaign=${encodeURIComponent(
'n8n-nodes-base.telegram',
)}${instanceId ? '_' + instanceId : ''}`;
const description = `${config.message}\n\n_${attributionText}_[n8n](${link})`;
const body = {
embeds: [
{
description,
color: 5814783,
},
],
components: [
{
type: 1,
components: config.options.map((option) => {
return {
type: 2,
style: 5,
label: option.label,
url: `${config.url}?approved=${option.value}`,
};
}),
},
],
};
return body;
}

View file

@ -30,5 +30,5 @@
}
]
},
"alias": ["SMTP"]
"alias": ["SMTP", "email", "human", "form", "wait", "hitl", "approval"]
}

View file

@ -17,5 +17,6 @@
"url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.googlechat/"
}
]
}
},
"alias": ["human", "form", "wait", "hitl", "approval"]
}

View file

@ -55,5 +55,5 @@
}
]
},
"alias": ["email", "human", "form", "wait"]
"alias": ["email", "human", "form", "wait", "hitl", "approval"]
}

View file

@ -18,5 +18,5 @@
}
]
},
"alias": ["email"]
"alias": ["email", "human", "form", "wait", "hitl", "approval"]
}

View file

@ -6,7 +6,7 @@
"subcategories": {
"HITL": ["Human in the Loop"]
},
"alias": ["human", "form", "wait"],
"alias": ["human", "form", "wait", "hitl", "approval"],
"resources": {
"credentialDocumentation": [
{

View file

@ -6,6 +6,7 @@
"subcategories": {
"HITL": ["Human in the Loop"]
},
"alias": ["human", "form", "wait", "hitl", "approval"],
"resources": {
"credentialDocumentation": [
{