Skip to content

Commit 97defb3

Browse files
feat: WhatsApp Business Cloud Node - new operation sendAndWait (#12941)
Co-authored-by: Tomi Turtiainen <[email protected]>
1 parent 83d03d5 commit 97defb3

File tree

13 files changed

+457
-74
lines changed

13 files changed

+457
-74
lines changed

packages/core/src/execution-engine/__tests__/workflow-execute.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import type {
3131
IWorkflowExecuteAdditionalData,
3232
WorkflowTestData,
3333
RelatedExecution,
34+
IExecuteFunctions,
3435
} from 'n8n-workflow';
3536
import {
3637
ApplicationError,
@@ -1462,4 +1463,90 @@ describe('WorkflowExecute', () => {
14621463
expect(runExecutionData.executionData?.nodeExecutionStack).toContain(executionData);
14631464
});
14641465
});
1466+
1467+
describe('customOperations', () => {
1468+
const nodeTypes = mock<INodeTypes>();
1469+
const testNode = mock<INode>();
1470+
1471+
const workflow = new Workflow({
1472+
nodeTypes,
1473+
nodes: [testNode],
1474+
connections: {},
1475+
active: false,
1476+
});
1477+
1478+
const executionData = mock<IExecuteData>({
1479+
node: { parameters: { resource: 'test', operation: 'test' } },
1480+
data: { main: [[{ json: {} }]] },
1481+
});
1482+
const runExecutionData = mock<IRunExecutionData>();
1483+
const additionalData = mock<IWorkflowExecuteAdditionalData>();
1484+
const workflowExecute = new WorkflowExecute(additionalData, 'manual');
1485+
1486+
test('should execute customOperations', async () => {
1487+
const nodeType = mock<INodeType>({
1488+
description: {
1489+
properties: [],
1490+
},
1491+
execute: undefined,
1492+
customOperations: {
1493+
test: {
1494+
async test(this: IExecuteFunctions) {
1495+
return [[{ json: { customOperationsRun: true } }]];
1496+
},
1497+
},
1498+
},
1499+
});
1500+
1501+
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
1502+
1503+
const runPromise = workflowExecute.runNode(
1504+
workflow,
1505+
executionData,
1506+
runExecutionData,
1507+
0,
1508+
additionalData,
1509+
'manual',
1510+
);
1511+
1512+
const result = await runPromise;
1513+
1514+
expect(result).toEqual({ data: [[{ json: { customOperationsRun: true } }]], hints: [] });
1515+
});
1516+
1517+
test('should throw error if customOperation and execute both defined', async () => {
1518+
const nodeType = mock<INodeType>({
1519+
description: {
1520+
properties: [],
1521+
},
1522+
async execute(this: IExecuteFunctions) {
1523+
return [];
1524+
},
1525+
customOperations: {
1526+
test: {
1527+
async test(this: IExecuteFunctions) {
1528+
return [];
1529+
},
1530+
},
1531+
},
1532+
});
1533+
1534+
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
1535+
1536+
try {
1537+
await workflowExecute.runNode(
1538+
workflow,
1539+
executionData,
1540+
runExecutionData,
1541+
0,
1542+
additionalData,
1543+
'manual',
1544+
);
1545+
} catch (error) {
1546+
expect(error.message).toBe(
1547+
'Node type cannot have both customOperations and execute defined',
1548+
);
1549+
}
1550+
});
1551+
});
14651552
});

packages/core/src/execution-engine/workflow-execute.ts

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import type {
4040
IRunNodeResponse,
4141
IWorkflowIssues,
4242
INodeIssues,
43+
INodeType,
4344
} from 'n8n-workflow';
4445
import {
4546
LoggerProxy as Logger,
@@ -49,6 +50,7 @@ import {
4950
sleep,
5051
ExecutionCancelledError,
5152
Node,
53+
UnexpectedError,
5254
} from 'n8n-workflow';
5355
import PCancelable from 'p-cancelable';
5456

@@ -971,6 +973,26 @@ export class WorkflowExecute {
971973
return workflowIssues;
972974
}
973975

976+
private getCustomOperation(node: INode, type: INodeType) {
977+
if (!type.customOperations) return undefined;
978+
979+
if (type.execute) {
980+
throw new UnexpectedError('Node type cannot have both customOperations and execute defined');
981+
}
982+
983+
if (!node.parameters) return undefined;
984+
985+
const { customOperations } = type;
986+
const { resource, operation } = node.parameters;
987+
988+
if (typeof resource !== 'string' || typeof operation !== 'string') return undefined;
989+
if (!customOperations[resource] || !customOperations[resource][operation]) return undefined;
990+
991+
const customOperation = customOperations[resource][operation];
992+
993+
return customOperation;
994+
}
995+
974996
/** Executes the given node */
975997
// eslint-disable-next-line complexity
976998
async runNode(
@@ -1000,8 +1022,16 @@ export class WorkflowExecute {
10001022

10011023
const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
10021024

1025+
const isDeclarativeNode = nodeType.description.requestDefaults !== undefined;
1026+
1027+
const customOperation = this.getCustomOperation(node, nodeType);
1028+
10031029
let connectionInputData: INodeExecutionData[] = [];
1004-
if (nodeType.execute || (!nodeType.poll && !nodeType.trigger && !nodeType.webhook)) {
1030+
if (
1031+
nodeType.execute ||
1032+
customOperation ||
1033+
(!nodeType.poll && !nodeType.trigger && !nodeType.webhook)
1034+
) {
10051035
// Only stop if first input is empty for execute runs. For all others run anyways
10061036
// because then it is a trigger node. As they only pass data through and so the input-data
10071037
// becomes output-data it has to be possible.
@@ -1060,7 +1090,7 @@ export class WorkflowExecute {
10601090
inputData = newInputData;
10611091
}
10621092

1063-
if (nodeType.execute) {
1093+
if (nodeType.execute || customOperation) {
10641094
const closeFunctions: CloseFunction[] = [];
10651095
const context = new ExecuteContext(
10661096
workflow,
@@ -1076,10 +1106,16 @@ export class WorkflowExecute {
10761106
abortSignal,
10771107
);
10781108

1079-
const data =
1080-
nodeType instanceof Node
1081-
? await nodeType.execute(context)
1082-
: await nodeType.execute.call(context);
1109+
let data;
1110+
1111+
if (customOperation) {
1112+
data = await customOperation.call(context);
1113+
} else if (nodeType.execute) {
1114+
data =
1115+
nodeType instanceof Node
1116+
? await nodeType.execute(context)
1117+
: await nodeType.execute.call(context);
1118+
}
10831119

10841120
const closeFunctionsResults = await Promise.allSettled(
10851121
closeFunctions.map(async (fn) => await fn()),
@@ -1152,8 +1188,10 @@ export class WorkflowExecute {
11521188
}
11531189
// For trigger nodes in any mode except "manual" do we simply pass the data through
11541190
return { data: inputData.main as INodeExecutionData[][] };
1155-
} else if (nodeType.webhook) {
1156-
// For webhook nodes always simply pass the data through
1191+
} else if (nodeType.webhook && !isDeclarativeNode) {
1192+
// Check if the node have requestDefaults(Declarative Node),
1193+
// else for webhook nodes always simply pass the data through
1194+
// as webhook method would be called by WebhookService
11571195
return { data: inputData.main as INodeExecutionData[][] };
11581196
} else {
11591197
// NOTE: This block is only called by nodes tests.

packages/nodes-base/nodes/WhatsApp/GenericFunctions.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import type {
1414
WhatsAppAppWebhookSubscriptionsResponse,
1515
WhatsAppAppWebhookSubscription,
1616
} from './types';
17+
import type { SendAndWaitConfig } from '../../utils/sendAndWait/utils';
18+
export const WHATSAPP_BASE_URL = 'https://graph.facebook.com/v13.0/';
1719

1820
async function appAccessTokenRead(
1921
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,
@@ -102,3 +104,27 @@ export async function appWebhookSubscriptionDelete(
102104
payload: { object },
103105
});
104106
}
107+
108+
export const createMessage = (
109+
sendAndWaitConfig: SendAndWaitConfig,
110+
phoneNumberId: string,
111+
recipientPhoneNumber: string,
112+
): IHttpRequestOptions => {
113+
const buttons = sendAndWaitConfig.options.map((option) => {
114+
return `*${option.label}:*\n_${sendAndWaitConfig.url}?approved=${option.value}_\n\n`;
115+
});
116+
117+
return {
118+
baseURL: WHATSAPP_BASE_URL,
119+
method: 'POST',
120+
url: `${phoneNumberId}/messages`,
121+
body: {
122+
messaging_product: 'whatsapp',
123+
text: {
124+
body: `${sendAndWaitConfig.message}\n\n${buttons.join('')}`,
125+
},
126+
type: 'text',
127+
to: recipientPhoneNumber,
128+
},
129+
};
130+
};

packages/nodes-base/nodes/WhatsApp/MessageFunctions.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,12 +169,13 @@ export async function componentsRequest(
169169
return requestOptions;
170170
}
171171

172+
export const sanitizePhoneNumber = (phoneNumber: string) => phoneNumber.replace(/[\-\(\)\+]/g, '');
173+
172174
export async function cleanPhoneNumber(
173175
this: IExecuteSingleFunctions,
174176
requestOptions: IHttpRequestOptions,
175177
): Promise<IHttpRequestOptions> {
176-
let phoneNumber = this.getNodeParameter('recipientPhoneNumber') as string;
177-
phoneNumber = phoneNumber.replace(/[\-\(\)\+]/g, '');
178+
const phoneNumber = sanitizePhoneNumber(this.getNodeParameter('recipientPhoneNumber') as string);
178179

179180
if (!requestOptions.body) {
180181
requestOptions.body = {};

packages/nodes-base/nodes/WhatsApp/MessagesDescription.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import countryCodes from 'currency-codes';
2-
import type { INodeProperties } from 'n8n-workflow';
2+
import { SEND_AND_WAIT_OPERATION, type INodeProperties } from 'n8n-workflow';
33

44
import {
55
cleanPhoneNumber,
@@ -32,6 +32,11 @@ export const messageFields: INodeProperties[] = [
3232
value: 'send',
3333
action: 'Send message',
3434
},
35+
{
36+
name: 'Send and Wait for Response',
37+
value: SEND_AND_WAIT_OPERATION,
38+
action: 'Send message and wait for response',
39+
},
3540
{
3641
name: 'Send Template',
3742
value: 'sendTemplate',

packages/nodes-base/nodes/WhatsApp/WhatsApp.node.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
"node": "n8n-nodes-base.whatsApp",
33
"nodeVersion": "1.0",
44
"codexVersion": "1.0",
5-
"categories": ["Communication"],
5+
"categories": ["Communication", "HITL"],
6+
"subcategories": {
7+
"HITL": ["Human in the Loop"]
8+
},
69
"resources": {
710
"credentialDocumentation": [
811
{

packages/nodes-base/nodes/WhatsApp/WhatsApp.node.ts

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,19 @@
1-
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
2-
import { NodeConnectionType } from 'n8n-workflow';
1+
import type { IExecuteFunctions, INodeType, INodeTypeDescription } from 'n8n-workflow';
2+
import { NodeConnectionType, NodeOperationError, SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
33

4+
import { createMessage, WHATSAPP_BASE_URL } from './GenericFunctions';
45
import { mediaFields, mediaTypeFields } from './MediaDescription';
6+
import { sanitizePhoneNumber } from './MessageFunctions';
57
import { messageFields, messageTypeFields } from './MessagesDescription';
8+
import { configureWaitTillDate } from '../../utils/sendAndWait/configureWaitTillDate.util';
9+
import { sendAndWaitWebhooksDescription } from '../../utils/sendAndWait/descriptions';
10+
import {
11+
getSendAndWaitConfig,
12+
getSendAndWaitProperties,
13+
sendAndWaitWebhook,
14+
} from '../../utils/sendAndWait/utils';
15+
16+
const WHATSAPP_CREDENTIALS_TYPE = 'whatsAppApi';
617

718
export class WhatsApp implements INodeType {
819
description: INodeTypeDescription = {
@@ -19,14 +30,15 @@ export class WhatsApp implements INodeType {
1930
usableAsTool: true,
2031
inputs: [NodeConnectionType.Main],
2132
outputs: [NodeConnectionType.Main],
33+
webhooks: sendAndWaitWebhooksDescription,
2234
credentials: [
2335
{
24-
name: 'whatsAppApi',
36+
name: WHATSAPP_CREDENTIALS_TYPE,
2537
required: true,
2638
},
2739
],
2840
requestDefaults: {
29-
baseURL: 'https://graph.facebook.com/v13.0/',
41+
baseURL: WHATSAPP_BASE_URL,
3042
},
3143
properties: [
3244
{
@@ -50,6 +62,42 @@ export class WhatsApp implements INodeType {
5062
...mediaFields,
5163
...messageTypeFields,
5264
...mediaTypeFields,
65+
...getSendAndWaitProperties([], 'message', undefined, {
66+
noButtonStyle: true,
67+
defaultApproveLabel: '✓ Approve',
68+
defaultDisapproveLabel: '✗ Decline',
69+
}).filter((p) => p.name !== 'subject'),
5370
],
5471
};
72+
73+
webhook = sendAndWaitWebhook;
74+
75+
customOperations = {
76+
message: {
77+
async [SEND_AND_WAIT_OPERATION](this: IExecuteFunctions) {
78+
try {
79+
const phoneNumberId = this.getNodeParameter('phoneNumberId', 0) as string;
80+
81+
const recipientPhoneNumber = sanitizePhoneNumber(
82+
this.getNodeParameter('recipientPhoneNumber', 0) as string,
83+
);
84+
85+
const config = getSendAndWaitConfig(this);
86+
87+
await this.helpers.httpRequestWithAuthentication.call(
88+
this,
89+
WHATSAPP_CREDENTIALS_TYPE,
90+
createMessage(config, phoneNumberId, recipientPhoneNumber),
91+
);
92+
93+
const waitTill = configureWaitTillDate(this);
94+
95+
await this.putExecutionToWait(waitTill);
96+
return [this.getInputData()];
97+
} catch (error) {
98+
throw new NodeOperationError(this.getNode(), error);
99+
}
100+
},
101+
},
102+
};
55103
}

0 commit comments

Comments
 (0)