diff --git a/packages/nodes-base/nodes/Discord/Discord.node.json b/packages/nodes-base/nodes/Discord/Discord.node.json index 08ee4ac000..aee457f714 100644 --- a/packages/nodes-base/nodes/Discord/Discord.node.json +++ b/packages/nodes-base/nodes/Discord/Discord.node.json @@ -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"] } diff --git a/packages/nodes-base/nodes/Discord/test/v2/node/message/sendAndWait.test.ts b/packages/nodes-base/nodes/Discord/test/v2/node/message/sendAndWait.test.ts new file mode 100644 index 0000000000..c405651e1e --- /dev/null +++ b/packages/nodes-base/nodes/Discord/test/v2/node/message/sendAndWait.test.ts @@ -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; + + beforeEach(() => { + discord = new DiscordV2(versionDescription); + mockExecuteFunctions = mock(); + 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)', + }, + ], + }, + ); + }); +}); diff --git a/packages/nodes-base/nodes/Discord/v2/DiscordV2.node.ts b/packages/nodes-base/nodes/Discord/v2/DiscordV2.node.ts index 20b4cec13f..12cad5706d 100644 --- a/packages/nodes-base/nodes/Discord/v2/DiscordV2.node.ts +++ b/packages/nodes-base/nodes/Discord/v2/DiscordV2.node.ts @@ -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 { return await router.call(this); } 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 7acccea904..d052e993e3 100644 --- a/packages/nodes-base/nodes/Discord/v2/actions/common.description.ts +++ b/packages/nodes-base/nodes/Discord/v2/actions/common.description.ts @@ -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'], + }, + }, + }, +]; diff --git a/packages/nodes-base/nodes/Discord/v2/actions/message/index.ts b/packages/nodes-base/nodes/Discord/v2/actions/message/index.ts index c040cb94e7..6d2eaa5c9b 100644 --- a/packages/nodes-base/nodes/Discord/v2/actions/message/index.ts +++ b/packages/nodes-base/nodes/Discord/v2/actions/message/index.ts @@ -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, ]; diff --git a/packages/nodes-base/nodes/Discord/v2/actions/message/send.operation.ts b/packages/nodes-base/nodes/Discord/v2/actions/message/send.operation.ts index bcd49d7a8f..3c72cb9330 100644 --- a/packages/nodes-base/nodes/Discord/v2/actions/message/send.operation.ts +++ b/packages/nodes-base/nodes/Discord/v2/actions/message/send.operation.ts @@ -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); diff --git a/packages/nodes-base/nodes/Discord/v2/actions/message/sendAndWait.operation.ts b/packages/nodes-base/nodes/Discord/v2/actions/message/sendAndWait.operation.ts new file mode 100644 index 0000000000..0c6114e44f --- /dev/null +++ b/packages/nodes-base/nodes/Discord/v2/actions/message/sendAndWait.operation.ts @@ -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 { + 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; +} 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 c415fe663d..0b05d7f693 100644 --- a/packages/nodes-base/nodes/Discord/v2/actions/node.type.ts +++ b/packages/nodes-base/nodes/Discord/v2/actions/node.type.ts @@ -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'; }; diff --git a/packages/nodes-base/nodes/Discord/v2/actions/router.ts b/packages/nodes-base/nodes/Discord/v2/actions/router.ts index a43534e141..1e163be0e9 100644 --- a/packages/nodes-base/nodes/Discord/v2/actions/router.ts +++ b/packages/nodes-base/nodes/Discord/v2/actions/router.ts @@ -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); diff --git a/packages/nodes-base/nodes/Discord/v2/actions/versionDescription.ts b/packages/nodes-base/nodes/Discord/v2/actions/versionDescription.ts index 8d79650890..8fae978756 100644 --- a/packages/nodes-base/nodes/Discord/v2/actions/versionDescription.ts +++ b/packages/nodes-base/nodes/Discord/v2/actions/versionDescription.ts @@ -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', diff --git a/packages/nodes-base/nodes/Discord/v2/helpers/utils.ts b/packages/nodes-base/nodes/Discord/v2/helpers/utils.ts index 7f1922adb4..1824149348 100644 --- a/packages/nodes-base/nodes/Discord/v2/helpers/utils.ts +++ b/packages/nodes-base/nodes/Discord/v2/helpers/utils.ts @@ -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; +} diff --git a/packages/nodes-base/nodes/EmailSend/EmailSend.node.json b/packages/nodes-base/nodes/EmailSend/EmailSend.node.json index 24a055b4dc..d5297c6694 100644 --- a/packages/nodes-base/nodes/EmailSend/EmailSend.node.json +++ b/packages/nodes-base/nodes/EmailSend/EmailSend.node.json @@ -30,5 +30,5 @@ } ] }, - "alias": ["SMTP"] + "alias": ["SMTP", "email", "human", "form", "wait", "hitl", "approval"] } diff --git a/packages/nodes-base/nodes/Google/Chat/GoogleChat.node.json b/packages/nodes-base/nodes/Google/Chat/GoogleChat.node.json index 27b527cbdd..06853fd679 100644 --- a/packages/nodes-base/nodes/Google/Chat/GoogleChat.node.json +++ b/packages/nodes-base/nodes/Google/Chat/GoogleChat.node.json @@ -17,5 +17,6 @@ "url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.googlechat/" } ] - } + }, + "alias": ["human", "form", "wait", "hitl", "approval"] } diff --git a/packages/nodes-base/nodes/Google/Gmail/Gmail.node.json b/packages/nodes-base/nodes/Google/Gmail/Gmail.node.json index 4b2bdfd566..5983e1fee1 100644 --- a/packages/nodes-base/nodes/Google/Gmail/Gmail.node.json +++ b/packages/nodes-base/nodes/Google/Gmail/Gmail.node.json @@ -55,5 +55,5 @@ } ] }, - "alias": ["email", "human", "form", "wait"] + "alias": ["email", "human", "form", "wait", "hitl", "approval"] } diff --git a/packages/nodes-base/nodes/Microsoft/Outlook/MicrosoftOutlook.node.json b/packages/nodes-base/nodes/Microsoft/Outlook/MicrosoftOutlook.node.json index 1ef67e0407..2b7b91e80e 100644 --- a/packages/nodes-base/nodes/Microsoft/Outlook/MicrosoftOutlook.node.json +++ b/packages/nodes-base/nodes/Microsoft/Outlook/MicrosoftOutlook.node.json @@ -18,5 +18,5 @@ } ] }, - "alias": ["email"] + "alias": ["email", "human", "form", "wait", "hitl", "approval"] } diff --git a/packages/nodes-base/nodes/Slack/Slack.node.json b/packages/nodes-base/nodes/Slack/Slack.node.json index cd9eb40b62..43aaf48936 100644 --- a/packages/nodes-base/nodes/Slack/Slack.node.json +++ b/packages/nodes-base/nodes/Slack/Slack.node.json @@ -6,7 +6,7 @@ "subcategories": { "HITL": ["Human in the Loop"] }, - "alias": ["human", "form", "wait"], + "alias": ["human", "form", "wait", "hitl", "approval"], "resources": { "credentialDocumentation": [ { diff --git a/packages/nodes-base/nodes/Telegram/Telegram.node.json b/packages/nodes-base/nodes/Telegram/Telegram.node.json index 5d8b2d0181..c5f924c32b 100644 --- a/packages/nodes-base/nodes/Telegram/Telegram.node.json +++ b/packages/nodes-base/nodes/Telegram/Telegram.node.json @@ -6,6 +6,7 @@ "subcategories": { "HITL": ["Human in the Loop"] }, + "alias": ["human", "form", "wait", "hitl", "approval"], "resources": { "credentialDocumentation": [ {