mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat: WhatsApp Business Cloud Node - new operation sendAndWait (#12941)
Co-authored-by: Tomi Turtiainen <10324676+tomi@users.noreply.github.com>
This commit is contained in:
parent
83d03d53eb
commit
97defb3a83
|
@ -31,6 +31,7 @@ import type {
|
||||||
IWorkflowExecuteAdditionalData,
|
IWorkflowExecuteAdditionalData,
|
||||||
WorkflowTestData,
|
WorkflowTestData,
|
||||||
RelatedExecution,
|
RelatedExecution,
|
||||||
|
IExecuteFunctions,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import {
|
import {
|
||||||
ApplicationError,
|
ApplicationError,
|
||||||
|
@ -1462,4 +1463,90 @@ describe('WorkflowExecute', () => {
|
||||||
expect(runExecutionData.executionData?.nodeExecutionStack).toContain(executionData);
|
expect(runExecutionData.executionData?.nodeExecutionStack).toContain(executionData);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('customOperations', () => {
|
||||||
|
const nodeTypes = mock<INodeTypes>();
|
||||||
|
const testNode = mock<INode>();
|
||||||
|
|
||||||
|
const workflow = new Workflow({
|
||||||
|
nodeTypes,
|
||||||
|
nodes: [testNode],
|
||||||
|
connections: {},
|
||||||
|
active: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const executionData = mock<IExecuteData>({
|
||||||
|
node: { parameters: { resource: 'test', operation: 'test' } },
|
||||||
|
data: { main: [[{ json: {} }]] },
|
||||||
|
});
|
||||||
|
const runExecutionData = mock<IRunExecutionData>();
|
||||||
|
const additionalData = mock<IWorkflowExecuteAdditionalData>();
|
||||||
|
const workflowExecute = new WorkflowExecute(additionalData, 'manual');
|
||||||
|
|
||||||
|
test('should execute customOperations', async () => {
|
||||||
|
const nodeType = mock<INodeType>({
|
||||||
|
description: {
|
||||||
|
properties: [],
|
||||||
|
},
|
||||||
|
execute: undefined,
|
||||||
|
customOperations: {
|
||||||
|
test: {
|
||||||
|
async test(this: IExecuteFunctions) {
|
||||||
|
return [[{ json: { customOperationsRun: true } }]];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||||
|
|
||||||
|
const runPromise = workflowExecute.runNode(
|
||||||
|
workflow,
|
||||||
|
executionData,
|
||||||
|
runExecutionData,
|
||||||
|
0,
|
||||||
|
additionalData,
|
||||||
|
'manual',
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await runPromise;
|
||||||
|
|
||||||
|
expect(result).toEqual({ data: [[{ json: { customOperationsRun: true } }]], hints: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should throw error if customOperation and execute both defined', async () => {
|
||||||
|
const nodeType = mock<INodeType>({
|
||||||
|
description: {
|
||||||
|
properties: [],
|
||||||
|
},
|
||||||
|
async execute(this: IExecuteFunctions) {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
customOperations: {
|
||||||
|
test: {
|
||||||
|
async test(this: IExecuteFunctions) {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await workflowExecute.runNode(
|
||||||
|
workflow,
|
||||||
|
executionData,
|
||||||
|
runExecutionData,
|
||||||
|
0,
|
||||||
|
additionalData,
|
||||||
|
'manual',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
expect(error.message).toBe(
|
||||||
|
'Node type cannot have both customOperations and execute defined',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -40,6 +40,7 @@ import type {
|
||||||
IRunNodeResponse,
|
IRunNodeResponse,
|
||||||
IWorkflowIssues,
|
IWorkflowIssues,
|
||||||
INodeIssues,
|
INodeIssues,
|
||||||
|
INodeType,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import {
|
import {
|
||||||
LoggerProxy as Logger,
|
LoggerProxy as Logger,
|
||||||
|
@ -49,6 +50,7 @@ import {
|
||||||
sleep,
|
sleep,
|
||||||
ExecutionCancelledError,
|
ExecutionCancelledError,
|
||||||
Node,
|
Node,
|
||||||
|
UnexpectedError,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import PCancelable from 'p-cancelable';
|
import PCancelable from 'p-cancelable';
|
||||||
|
|
||||||
|
@ -971,6 +973,26 @@ export class WorkflowExecute {
|
||||||
return workflowIssues;
|
return workflowIssues;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getCustomOperation(node: INode, type: INodeType) {
|
||||||
|
if (!type.customOperations) return undefined;
|
||||||
|
|
||||||
|
if (type.execute) {
|
||||||
|
throw new UnexpectedError('Node type cannot have both customOperations and execute defined');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!node.parameters) return undefined;
|
||||||
|
|
||||||
|
const { customOperations } = type;
|
||||||
|
const { resource, operation } = node.parameters;
|
||||||
|
|
||||||
|
if (typeof resource !== 'string' || typeof operation !== 'string') return undefined;
|
||||||
|
if (!customOperations[resource] || !customOperations[resource][operation]) return undefined;
|
||||||
|
|
||||||
|
const customOperation = customOperations[resource][operation];
|
||||||
|
|
||||||
|
return customOperation;
|
||||||
|
}
|
||||||
|
|
||||||
/** Executes the given node */
|
/** Executes the given node */
|
||||||
// eslint-disable-next-line complexity
|
// eslint-disable-next-line complexity
|
||||||
async runNode(
|
async runNode(
|
||||||
|
@ -1000,8 +1022,16 @@ export class WorkflowExecute {
|
||||||
|
|
||||||
const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
|
const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
|
||||||
|
|
||||||
|
const isDeclarativeNode = nodeType.description.requestDefaults !== undefined;
|
||||||
|
|
||||||
|
const customOperation = this.getCustomOperation(node, nodeType);
|
||||||
|
|
||||||
let connectionInputData: INodeExecutionData[] = [];
|
let connectionInputData: INodeExecutionData[] = [];
|
||||||
if (nodeType.execute || (!nodeType.poll && !nodeType.trigger && !nodeType.webhook)) {
|
if (
|
||||||
|
nodeType.execute ||
|
||||||
|
customOperation ||
|
||||||
|
(!nodeType.poll && !nodeType.trigger && !nodeType.webhook)
|
||||||
|
) {
|
||||||
// Only stop if first input is empty for execute runs. For all others run anyways
|
// Only stop if first input is empty for execute runs. For all others run anyways
|
||||||
// because then it is a trigger node. As they only pass data through and so the input-data
|
// because then it is a trigger node. As they only pass data through and so the input-data
|
||||||
// becomes output-data it has to be possible.
|
// becomes output-data it has to be possible.
|
||||||
|
@ -1060,7 +1090,7 @@ export class WorkflowExecute {
|
||||||
inputData = newInputData;
|
inputData = newInputData;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nodeType.execute) {
|
if (nodeType.execute || customOperation) {
|
||||||
const closeFunctions: CloseFunction[] = [];
|
const closeFunctions: CloseFunction[] = [];
|
||||||
const context = new ExecuteContext(
|
const context = new ExecuteContext(
|
||||||
workflow,
|
workflow,
|
||||||
|
@ -1076,10 +1106,16 @@ export class WorkflowExecute {
|
||||||
abortSignal,
|
abortSignal,
|
||||||
);
|
);
|
||||||
|
|
||||||
const data =
|
let data;
|
||||||
|
|
||||||
|
if (customOperation) {
|
||||||
|
data = await customOperation.call(context);
|
||||||
|
} else if (nodeType.execute) {
|
||||||
|
data =
|
||||||
nodeType instanceof Node
|
nodeType instanceof Node
|
||||||
? await nodeType.execute(context)
|
? await nodeType.execute(context)
|
||||||
: await nodeType.execute.call(context);
|
: await nodeType.execute.call(context);
|
||||||
|
}
|
||||||
|
|
||||||
const closeFunctionsResults = await Promise.allSettled(
|
const closeFunctionsResults = await Promise.allSettled(
|
||||||
closeFunctions.map(async (fn) => await fn()),
|
closeFunctions.map(async (fn) => await fn()),
|
||||||
|
@ -1152,8 +1188,10 @@ export class WorkflowExecute {
|
||||||
}
|
}
|
||||||
// For trigger nodes in any mode except "manual" do we simply pass the data through
|
// For trigger nodes in any mode except "manual" do we simply pass the data through
|
||||||
return { data: inputData.main as INodeExecutionData[][] };
|
return { data: inputData.main as INodeExecutionData[][] };
|
||||||
} else if (nodeType.webhook) {
|
} else if (nodeType.webhook && !isDeclarativeNode) {
|
||||||
// For webhook nodes always simply pass the data through
|
// Check if the node have requestDefaults(Declarative Node),
|
||||||
|
// else for webhook nodes always simply pass the data through
|
||||||
|
// as webhook method would be called by WebhookService
|
||||||
return { data: inputData.main as INodeExecutionData[][] };
|
return { data: inputData.main as INodeExecutionData[][] };
|
||||||
} else {
|
} else {
|
||||||
// NOTE: This block is only called by nodes tests.
|
// NOTE: This block is only called by nodes tests.
|
||||||
|
|
|
@ -14,6 +14,8 @@ import type {
|
||||||
WhatsAppAppWebhookSubscriptionsResponse,
|
WhatsAppAppWebhookSubscriptionsResponse,
|
||||||
WhatsAppAppWebhookSubscription,
|
WhatsAppAppWebhookSubscription,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
import type { SendAndWaitConfig } from '../../utils/sendAndWait/utils';
|
||||||
|
export const WHATSAPP_BASE_URL = 'https://graph.facebook.com/v13.0/';
|
||||||
|
|
||||||
async function appAccessTokenRead(
|
async function appAccessTokenRead(
|
||||||
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,
|
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,
|
||||||
|
@ -102,3 +104,27 @@ export async function appWebhookSubscriptionDelete(
|
||||||
payload: { object },
|
payload: { object },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const createMessage = (
|
||||||
|
sendAndWaitConfig: SendAndWaitConfig,
|
||||||
|
phoneNumberId: string,
|
||||||
|
recipientPhoneNumber: string,
|
||||||
|
): IHttpRequestOptions => {
|
||||||
|
const buttons = sendAndWaitConfig.options.map((option) => {
|
||||||
|
return `*${option.label}:*\n_${sendAndWaitConfig.url}?approved=${option.value}_\n\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseURL: WHATSAPP_BASE_URL,
|
||||||
|
method: 'POST',
|
||||||
|
url: `${phoneNumberId}/messages`,
|
||||||
|
body: {
|
||||||
|
messaging_product: 'whatsapp',
|
||||||
|
text: {
|
||||||
|
body: `${sendAndWaitConfig.message}\n\n${buttons.join('')}`,
|
||||||
|
},
|
||||||
|
type: 'text',
|
||||||
|
to: recipientPhoneNumber,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
|
@ -169,12 +169,13 @@ export async function componentsRequest(
|
||||||
return requestOptions;
|
return requestOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const sanitizePhoneNumber = (phoneNumber: string) => phoneNumber.replace(/[\-\(\)\+]/g, '');
|
||||||
|
|
||||||
export async function cleanPhoneNumber(
|
export async function cleanPhoneNumber(
|
||||||
this: IExecuteSingleFunctions,
|
this: IExecuteSingleFunctions,
|
||||||
requestOptions: IHttpRequestOptions,
|
requestOptions: IHttpRequestOptions,
|
||||||
): Promise<IHttpRequestOptions> {
|
): Promise<IHttpRequestOptions> {
|
||||||
let phoneNumber = this.getNodeParameter('recipientPhoneNumber') as string;
|
const phoneNumber = sanitizePhoneNumber(this.getNodeParameter('recipientPhoneNumber') as string);
|
||||||
phoneNumber = phoneNumber.replace(/[\-\(\)\+]/g, '');
|
|
||||||
|
|
||||||
if (!requestOptions.body) {
|
if (!requestOptions.body) {
|
||||||
requestOptions.body = {};
|
requestOptions.body = {};
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import countryCodes from 'currency-codes';
|
import countryCodes from 'currency-codes';
|
||||||
import type { INodeProperties } from 'n8n-workflow';
|
import { SEND_AND_WAIT_OPERATION, type INodeProperties } from 'n8n-workflow';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
cleanPhoneNumber,
|
cleanPhoneNumber,
|
||||||
|
@ -32,6 +32,11 @@ export const messageFields: INodeProperties[] = [
|
||||||
value: 'send',
|
value: 'send',
|
||||||
action: 'Send message',
|
action: 'Send message',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Send and Wait for Response',
|
||||||
|
value: SEND_AND_WAIT_OPERATION,
|
||||||
|
action: 'Send message and wait for response',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Send Template',
|
name: 'Send Template',
|
||||||
value: 'sendTemplate',
|
value: 'sendTemplate',
|
||||||
|
|
|
@ -2,7 +2,10 @@
|
||||||
"node": "n8n-nodes-base.whatsApp",
|
"node": "n8n-nodes-base.whatsApp",
|
||||||
"nodeVersion": "1.0",
|
"nodeVersion": "1.0",
|
||||||
"codexVersion": "1.0",
|
"codexVersion": "1.0",
|
||||||
"categories": ["Communication"],
|
"categories": ["Communication", "HITL"],
|
||||||
|
"subcategories": {
|
||||||
|
"HITL": ["Human in the Loop"]
|
||||||
|
},
|
||||||
"resources": {
|
"resources": {
|
||||||
"credentialDocumentation": [
|
"credentialDocumentation": [
|
||||||
{
|
{
|
||||||
|
|
|
@ -1,8 +1,19 @@
|
||||||
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
|
import type { IExecuteFunctions, INodeType, INodeTypeDescription } from 'n8n-workflow';
|
||||||
import { NodeConnectionType } from 'n8n-workflow';
|
import { NodeConnectionType, NodeOperationError, SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { createMessage, WHATSAPP_BASE_URL } from './GenericFunctions';
|
||||||
import { mediaFields, mediaTypeFields } from './MediaDescription';
|
import { mediaFields, mediaTypeFields } from './MediaDescription';
|
||||||
|
import { sanitizePhoneNumber } from './MessageFunctions';
|
||||||
import { messageFields, messageTypeFields } from './MessagesDescription';
|
import { messageFields, messageTypeFields } from './MessagesDescription';
|
||||||
|
import { configureWaitTillDate } from '../../utils/sendAndWait/configureWaitTillDate.util';
|
||||||
|
import { sendAndWaitWebhooksDescription } from '../../utils/sendAndWait/descriptions';
|
||||||
|
import {
|
||||||
|
getSendAndWaitConfig,
|
||||||
|
getSendAndWaitProperties,
|
||||||
|
sendAndWaitWebhook,
|
||||||
|
} from '../../utils/sendAndWait/utils';
|
||||||
|
|
||||||
|
const WHATSAPP_CREDENTIALS_TYPE = 'whatsAppApi';
|
||||||
|
|
||||||
export class WhatsApp implements INodeType {
|
export class WhatsApp implements INodeType {
|
||||||
description: INodeTypeDescription = {
|
description: INodeTypeDescription = {
|
||||||
|
@ -19,14 +30,15 @@ export class WhatsApp implements INodeType {
|
||||||
usableAsTool: true,
|
usableAsTool: true,
|
||||||
inputs: [NodeConnectionType.Main],
|
inputs: [NodeConnectionType.Main],
|
||||||
outputs: [NodeConnectionType.Main],
|
outputs: [NodeConnectionType.Main],
|
||||||
|
webhooks: sendAndWaitWebhooksDescription,
|
||||||
credentials: [
|
credentials: [
|
||||||
{
|
{
|
||||||
name: 'whatsAppApi',
|
name: WHATSAPP_CREDENTIALS_TYPE,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
requestDefaults: {
|
requestDefaults: {
|
||||||
baseURL: 'https://graph.facebook.com/v13.0/',
|
baseURL: WHATSAPP_BASE_URL,
|
||||||
},
|
},
|
||||||
properties: [
|
properties: [
|
||||||
{
|
{
|
||||||
|
@ -50,6 +62,42 @@ export class WhatsApp implements INodeType {
|
||||||
...mediaFields,
|
...mediaFields,
|
||||||
...messageTypeFields,
|
...messageTypeFields,
|
||||||
...mediaTypeFields,
|
...mediaTypeFields,
|
||||||
|
...getSendAndWaitProperties([], 'message', undefined, {
|
||||||
|
noButtonStyle: true,
|
||||||
|
defaultApproveLabel: '✓ Approve',
|
||||||
|
defaultDisapproveLabel: '✗ Decline',
|
||||||
|
}).filter((p) => p.name !== 'subject'),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
webhook = sendAndWaitWebhook;
|
||||||
|
|
||||||
|
customOperations = {
|
||||||
|
message: {
|
||||||
|
async [SEND_AND_WAIT_OPERATION](this: IExecuteFunctions) {
|
||||||
|
try {
|
||||||
|
const phoneNumberId = this.getNodeParameter('phoneNumberId', 0) as string;
|
||||||
|
|
||||||
|
const recipientPhoneNumber = sanitizePhoneNumber(
|
||||||
|
this.getNodeParameter('recipientPhoneNumber', 0) as string,
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = getSendAndWaitConfig(this);
|
||||||
|
|
||||||
|
await this.helpers.httpRequestWithAuthentication.call(
|
||||||
|
this,
|
||||||
|
WHATSAPP_CREDENTIALS_TYPE,
|
||||||
|
createMessage(config, phoneNumberId, recipientPhoneNumber),
|
||||||
|
);
|
||||||
|
|
||||||
|
const waitTill = configureWaitTillDate(this);
|
||||||
|
|
||||||
|
await this.putExecutionToWait(waitTill);
|
||||||
|
return [this.getInputData()];
|
||||||
|
} catch (error) {
|
||||||
|
throw new NodeOperationError(this.getNode(), error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@ import {
|
||||||
type IWebhookFunctions,
|
type IWebhookFunctions,
|
||||||
type IWebhookResponseData,
|
type IWebhookResponseData,
|
||||||
NodeConnectionType,
|
NodeConnectionType,
|
||||||
|
type INodeProperties,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -16,7 +17,58 @@ import {
|
||||||
appWebhookSubscriptionList,
|
appWebhookSubscriptionList,
|
||||||
} from './GenericFunctions';
|
} from './GenericFunctions';
|
||||||
import type { WhatsAppPageEvent } from './types';
|
import type { WhatsAppPageEvent } from './types';
|
||||||
import { whatsappTriggerDescription } from './WhatsappDescription';
|
|
||||||
|
const whatsappTriggerDescription: INodeProperties[] = [
|
||||||
|
{
|
||||||
|
displayName: 'Trigger On',
|
||||||
|
name: 'updates',
|
||||||
|
type: 'multiOptions',
|
||||||
|
required: true,
|
||||||
|
default: [],
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'Account Review Update',
|
||||||
|
value: 'account_review_update',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Account Update',
|
||||||
|
value: 'account_update',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Business Capability Update',
|
||||||
|
value: 'business_capability_update',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Message Template Quality Update',
|
||||||
|
value: 'message_template_quality_update',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Message Template Status Update',
|
||||||
|
value: 'message_template_status_update',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Messages',
|
||||||
|
value: 'messages',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Phone Number Name Update',
|
||||||
|
value: 'phone_number_name_update',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Phone Number Quality Update',
|
||||||
|
value: 'phone_number_quality_update',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Security',
|
||||||
|
value: 'security',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Template Category Update',
|
||||||
|
value: 'template_category_update',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export class WhatsAppTrigger implements INodeType {
|
export class WhatsAppTrigger implements INodeType {
|
||||||
description: INodeTypeDescription = {
|
description: INodeTypeDescription = {
|
||||||
|
|
|
@ -1,53 +0,0 @@
|
||||||
import type { INodeProperties } from 'n8n-workflow';
|
|
||||||
|
|
||||||
export const whatsappTriggerDescription: INodeProperties[] = [
|
|
||||||
{
|
|
||||||
displayName: 'Trigger On',
|
|
||||||
name: 'updates',
|
|
||||||
type: 'multiOptions',
|
|
||||||
required: true,
|
|
||||||
default: [],
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
name: 'Account Review Update',
|
|
||||||
value: 'account_review_update',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Account Update',
|
|
||||||
value: 'account_update',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Business Capability Update',
|
|
||||||
value: 'business_capability_update',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Message Template Quality Update',
|
|
||||||
value: 'message_template_quality_update',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Message Template Status Update',
|
|
||||||
value: 'message_template_status_update',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Messages',
|
|
||||||
value: 'messages',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Phone Number Name Update',
|
|
||||||
value: 'phone_number_name_update',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Phone Number Quality Update',
|
|
||||||
value: 'phone_number_quality_update',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Security',
|
|
||||||
value: 'security',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Template Category Update',
|
|
||||||
value: 'template_category_update',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
|
@ -0,0 +1,68 @@
|
||||||
|
import type { MockProxy } from 'jest-mock-extended';
|
||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
import { type IExecuteFunctions } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { WhatsApp } from '../../WhatsApp.node';
|
||||||
|
|
||||||
|
describe('Test WhatsApp Business Cloud, sendAndWait operation', () => {
|
||||||
|
let whatsApp: WhatsApp;
|
||||||
|
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
whatsApp = new WhatsApp();
|
||||||
|
mockExecuteFunctions = mock<IExecuteFunctions>();
|
||||||
|
|
||||||
|
mockExecuteFunctions.helpers = {
|
||||||
|
httpRequestWithAuthentication: jest.fn().mockResolvedValue({}),
|
||||||
|
} 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 === 'phoneNumberId') return '11111';
|
||||||
|
if (key === 'recipientPhoneNumber') return '22222';
|
||||||
|
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 whatsApp.customOperations.message.sendAndWait.call(mockExecuteFunctions);
|
||||||
|
|
||||||
|
expect(result).toEqual([items]);
|
||||||
|
|
||||||
|
expect(mockExecuteFunctions.putExecutionToWait).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
expect(mockExecuteFunctions.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
|
||||||
|
'whatsAppApi',
|
||||||
|
{
|
||||||
|
baseURL: 'https://graph.facebook.com/v13.0/',
|
||||||
|
body: {
|
||||||
|
messaging_product: 'whatsapp',
|
||||||
|
text: {
|
||||||
|
body: 'my message\n\n*Approve:*\n_http://localhost/waiting-webhook/nodeID?approved=true_\n\n',
|
||||||
|
},
|
||||||
|
to: '22222',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
method: 'POST',
|
||||||
|
url: '11111/messages',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
96
packages/nodes-base/nodes/WhatsApp/tests/utils.test.ts
Normal file
96
packages/nodes-base/nodes/WhatsApp/tests/utils.test.ts
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
import type { IHttpRequestOptions } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import type { SendAndWaitConfig } from '../../../utils/sendAndWait/utils';
|
||||||
|
import { createMessage, WHATSAPP_BASE_URL } from '../GenericFunctions';
|
||||||
|
import { sanitizePhoneNumber } from '../MessageFunctions';
|
||||||
|
|
||||||
|
describe('sanitizePhoneNumber', () => {
|
||||||
|
const testNumber = '+99-(000)-111-2222';
|
||||||
|
|
||||||
|
it('should remove hyphens, parentheses, and plus signs from the phone number', () => {
|
||||||
|
expect(sanitizePhoneNumber(testNumber)).toBe('990001112222');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return an empty string if input is empty', () => {
|
||||||
|
expect(sanitizePhoneNumber('')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the same number if no special characters are present', () => {
|
||||||
|
expect(sanitizePhoneNumber('990001112222')).toBe('990001112222');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle numbers with spaces correctly (not removing them)', () => {
|
||||||
|
expect(sanitizePhoneNumber('+99 000 111 2222')).toBe('99 000 111 2222');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createMessage', () => {
|
||||||
|
const mockSendAndWaitConfig: SendAndWaitConfig = {
|
||||||
|
title: '',
|
||||||
|
message: 'Please approve an option:',
|
||||||
|
url: 'https://example.com/approve',
|
||||||
|
options: [
|
||||||
|
{ label: 'Yes', value: 'yes', style: 'primary' },
|
||||||
|
{ label: 'No', value: 'no', style: 'secondary' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const phoneID = '123456789';
|
||||||
|
const recipientPhone = '990001112222';
|
||||||
|
|
||||||
|
it('should return a valid HTTP request object', () => {
|
||||||
|
const request: IHttpRequestOptions = createMessage(
|
||||||
|
mockSendAndWaitConfig,
|
||||||
|
phoneID,
|
||||||
|
recipientPhone,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(request).toEqual({
|
||||||
|
baseURL: WHATSAPP_BASE_URL,
|
||||||
|
method: 'POST',
|
||||||
|
url: `${phoneID}/messages`,
|
||||||
|
body: {
|
||||||
|
messaging_product: 'whatsapp',
|
||||||
|
text: {
|
||||||
|
body:
|
||||||
|
'Please approve an option:\n\n' +
|
||||||
|
'*Yes:*\n_https://example.com/approve?approved=yes_\n\n' +
|
||||||
|
'*No:*\n_https://example.com/approve?approved=no_\n\n',
|
||||||
|
},
|
||||||
|
type: 'text',
|
||||||
|
to: recipientPhone,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle a single option correctly', () => {
|
||||||
|
const singleOptionConfig: SendAndWaitConfig = {
|
||||||
|
title: '',
|
||||||
|
message: 'Choose an option:',
|
||||||
|
url: 'https://example.com/approve',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: 'Confirm',
|
||||||
|
value: 'confirm',
|
||||||
|
style: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const request: IHttpRequestOptions = createMessage(singleOptionConfig, phoneID, recipientPhone);
|
||||||
|
|
||||||
|
expect(request).toEqual({
|
||||||
|
baseURL: WHATSAPP_BASE_URL,
|
||||||
|
method: 'POST',
|
||||||
|
url: `${phoneID}/messages`,
|
||||||
|
body: {
|
||||||
|
messaging_product: 'whatsapp',
|
||||||
|
text: {
|
||||||
|
body: 'Choose an option:\n\n*Confirm:*\n_https://example.com/approve?approved=confirm_\n\n',
|
||||||
|
},
|
||||||
|
type: 'text',
|
||||||
|
to: recipientPhone,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -24,7 +24,7 @@ import { formFieldsProperties } from '../../nodes/Form/Form.node';
|
||||||
import { prepareFormData, prepareFormReturnItem, resolveRawData } from '../../nodes/Form/utils';
|
import { prepareFormData, prepareFormReturnItem, resolveRawData } from '../../nodes/Form/utils';
|
||||||
import { escapeHtml } from '../utilities';
|
import { escapeHtml } from '../utilities';
|
||||||
|
|
||||||
type SendAndWaitConfig = {
|
export type SendAndWaitConfig = {
|
||||||
title: string;
|
title: string;
|
||||||
message: string;
|
message: string;
|
||||||
url: string;
|
url: string;
|
||||||
|
|
|
@ -1560,12 +1560,12 @@ export interface SupplyData {
|
||||||
closeFunction?: CloseFunction;
|
closeFunction?: CloseFunction;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NodeOutput = INodeExecutionData[][] | NodeExecutionWithMetadata[][] | null;
|
||||||
|
|
||||||
export interface INodeType {
|
export interface INodeType {
|
||||||
description: INodeTypeDescription;
|
description: INodeTypeDescription;
|
||||||
supplyData?(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData>;
|
supplyData?(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData>;
|
||||||
execute?(
|
execute?(this: IExecuteFunctions): Promise<NodeOutput>;
|
||||||
this: IExecuteFunctions,
|
|
||||||
): Promise<INodeExecutionData[][] | NodeExecutionWithMetadata[][] | null>;
|
|
||||||
poll?(this: IPollFunctions): Promise<INodeExecutionData[][] | null>;
|
poll?(this: IPollFunctions): Promise<INodeExecutionData[][] | null>;
|
||||||
trigger?(this: ITriggerFunctions): Promise<ITriggerResponse | undefined>;
|
trigger?(this: ITriggerFunctions): Promise<ITriggerResponse | undefined>;
|
||||||
webhook?(this: IWebhookFunctions): Promise<IWebhookResponseData>;
|
webhook?(this: IWebhookFunctions): Promise<IWebhookResponseData>;
|
||||||
|
@ -1602,6 +1602,18 @@ export interface INodeType {
|
||||||
[method in WebhookSetupMethodNames]: (this: IHookFunctions) => Promise<boolean>;
|
[method in WebhookSetupMethodNames]: (this: IHookFunctions) => Promise<boolean>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* Defines custom operations for nodes that do not implement an `execute` method, such as declarative nodes.
|
||||||
|
* This function will be invoked instead of `execute` for a specific resource and operation.
|
||||||
|
* Should be either `execute` or `customOperations` defined for a node, but not both.
|
||||||
|
*
|
||||||
|
* @property customOperations - Maps specific resource and operation to a custom function
|
||||||
|
*/
|
||||||
|
customOperations?: {
|
||||||
|
[resource: string]: {
|
||||||
|
[operation: string]: (this: IExecuteFunctions) => Promise<NodeOutput>;
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
Loading…
Reference in a new issue