feat(WhatsApp Business node): WhatsApp node (#3659)

* feat: base structure for whatsapp node with credentials

* feat: messages operation

* feat: create generic api call with credentials and test first operation

* fix: add missing template params

* fix: language code for template

* feat: media type and start of template components

* fix: remove provider name from media type

* lintfix

* fix: format

* feat: media operations w/o upload media type

* ♻️ Convert WhatsApp Business node to declarative style

* 🐛 form data not being sent with boundary in header

*  add media operations to WhatsApp

*  add credentials test to WhatsApp credentials

* ♻️ move preview url to optional collection in whatsapp message

* ♻️ renamed media operations in whatsapp node

* :refactor: move media file name to optional fields in whatsapp node

*  add upload from n8n for whatsapp node message resource

* 🔥 remove other template component types in whatsapp node

* :speech_bubble: add specialised text for media types in WhatsApp node

*  Load dinamically phone number and template name

*  Add action property to all operations

* 🔥 Remove unnecessary imports

*  Use getBinaryDataBuffer helper

*  Add components property

*  send components for whatsapp templates and template language

* 🏷️ fix WhatsApp node message function types

* 🏷️ fix any in whatsapp message functions

* 🔥 remove unused import

*  Improvements

*  Add send location

*  Add send contact

*  Small improvement

* ♻️ changes for review

* 🐛 fix presend error

* ♻️ change lat/long to numbers with proper clamping

* fix: bad merge

* refactor: changes for review

* update package-lock.json

* update package.-lock.json

* update

Co-authored-by: cxgarcia <schlaubitzcristobal@gmail.com>
Co-authored-by: ricardo <ricardoespinoza105@gmail.com>
This commit is contained in:
Valya 2022-09-30 01:17:46 +01:00 committed by GitHub
parent f37d6ba03b
commit f63710a892
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 2126 additions and 3 deletions

View file

@ -492,7 +492,6 @@ async function parseRequestObject(requestObject: IDataObject) {
* gzip (ignored - default already works)
* resolveWithFullResponse (implemented elsewhere)
*/
return axiosConfig;
}
@ -737,7 +736,10 @@ function convertN8nRequestToAxios(n8nRequest: IHttpRequestOptions): AxiosRequest
// We are only setting content type headers if the user did
// not set it already manually. We're not overriding, even if it's wrong.
if (body instanceof FormData) {
axiosRequest.headers['Content-Type'] = 'multipart/form-data';
axiosRequest.headers = {
...axiosRequest.headers,
...body.getHeaders(),
};
} else if (body instanceof URLSearchParams) {
axiosRequest.headers['Content-Type'] = 'application/x-www-form-urlencoded';
}

View file

@ -0,0 +1,58 @@
import {
IAuthenticateGeneric,
ICredentialDataDecryptedObject,
ICredentialTestRequest,
ICredentialType,
IHttpRequestOptions,
INodeProperties,
NodePropertyTypes,
} from 'n8n-workflow';
export class WhatsAppApi implements ICredentialType {
name = 'whatsAppApi';
displayName = 'WhatsApp API';
documentationUrl = 'whatsApp';
properties: INodeProperties[] = [
{
displayName: 'Access Token',
type: 'string',
name: 'accessToken',
default: '',
required: true,
},
{
displayName: 'Bussiness Account ID',
type: 'string',
name: 'businessAccountId',
default: '',
required: true,
},
];
authenticate: IAuthenticateGeneric = {
type: 'generic',
properties: {
headers: {
Authorization: '=Bearer {{$credentials.accessToken}}',
},
},
};
test: ICredentialTestRequest = {
request: {
baseURL: 'https://graph.facebook.com/v13.0',
url: '/',
ignoreHttpStatusErrors: true,
},
rules: [
{
type: 'responseSuccessBody',
properties: {
key: 'error.type',
value: 'OAuthException',
message: 'Invalid access token',
},
},
],
};
}

View file

@ -0,0 +1,184 @@
import { INodeProperties } from 'n8n-workflow';
import { setupUpload } from './MediaFunctions';
export const mediaFields: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
noDataExpression: true,
type: 'options',
placeholder: '',
options: [
{
name: 'Upload',
value: 'mediaUpload',
action: 'Upload media',
},
{
name: 'Download',
value: 'mediaUrlGet',
action: 'Download media',
},
{
name: 'Delete',
value: 'mediaDelete',
action: 'Delete media',
},
],
default: 'mediaUpload',
displayOptions: {
show: {
resource: ['media'],
},
},
// eslint-disable-next-line n8n-nodes-base/node-param-description-weak
description: 'The operation to perform on the media',
},
];
export const mediaTypeFields: INodeProperties[] = [
// ----------------------------------
// operation: mediaUpload
// ----------------------------------
{
displayName: 'Sender Phone Number (or ID)',
name: 'phoneNumberId',
type: 'options',
typeOptions: {
loadOptions: {
routing: {
request: {
url: '={{$credentials.businessAccountId}}/phone_numbers',
method: 'GET',
},
output: {
postReceive: [
{
type: 'rootProperty',
properties: {
property: 'data',
},
},
{
type: 'setKeyValue',
properties: {
name: '={{$responseItem.display_phone_number}} - {{$responseItem.verified_name}}',
value: '={{$responseItem.id}}',
},
},
{
type: 'sort',
properties: {
key: 'name',
},
},
],
},
},
},
},
default: '',
placeholder: '',
routing: {
request: {
method: 'POST',
url: '={{$value}}/media',
},
},
displayOptions: {
show: {
operation: ['mediaUpload'],
resource: ['media'],
},
},
required: true,
description: "The ID of the business account's phone number to store the media",
},
{
displayName: 'Property Name',
name: 'mediaPropertyName',
type: 'string',
default: 'data',
displayOptions: {
show: {
operation: ['mediaUpload'],
resource: ['media'],
},
},
required: true,
description: 'Name of the binary property which contains the data for the file to be uploaded',
routing: {
send: {
preSend: [setupUpload],
},
},
},
// ----------------------------------
// type: mediaUrlGet
// ----------------------------------
{
displayName: 'Media ID',
name: 'mediaGetId',
type: 'string',
default: '',
displayOptions: {
show: {
operation: ['mediaUrlGet'],
resource: ['media'],
},
},
routing: {
request: {
method: 'GET',
url: '=/{{$value}}',
},
},
required: true,
description: 'The ID of the media',
},
// ----------------------------------
// type: mediaUrlGet
// ----------------------------------
{
displayName: 'Media ID',
name: 'mediaDeleteId',
type: 'string',
default: '',
displayOptions: {
show: {
operation: ['mediaDelete'],
resource: ['media'],
},
},
routing: {
request: {
method: 'DELETE',
url: '=/{{$value}}',
},
},
required: true,
description: 'The ID of the media',
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: ['media'],
operation: ['mediaUpload'],
},
},
options: [
{
displayName: 'Filename',
name: 'mediaFileName',
type: 'string',
default: '',
description: 'The name to use for the file',
},
],
},
];

View file

@ -0,0 +1,44 @@
import {
IDataObject,
IExecuteSingleFunctions,
IHttpRequestOptions,
NodeOperationError,
} from 'n8n-workflow';
import FormData from 'form-data';
export async function setupUpload(
this: IExecuteSingleFunctions,
requestOptions: IHttpRequestOptions,
) {
const mediaPropertyName = this.getNodeParameter('mediaPropertyName') as string;
if (!mediaPropertyName) {
return requestOptions;
}
if (this.getInputData().binary?.[mediaPropertyName] === undefined || !mediaPropertyName.trim()) {
throw new NodeOperationError(this.getNode(), 'Could not find file in node input data', {
description: `Theres no key called '${mediaPropertyName}' with binary data in it`,
});
}
const binaryFile = this.getInputData().binary![mediaPropertyName]!;
const mediaFileName = (this.getNodeParameter('additionalFields') as IDataObject).mediaFileName as
| string
| undefined;
const binaryFileName = binaryFile.fileName;
if (!mediaFileName && !binaryFileName) {
throw new NodeOperationError(this.getNode(), 'No file name given for media upload.');
}
const mimeType = binaryFile.mimeType;
const buffer = await this.helpers.getBinaryDataBuffer(mediaPropertyName);
const data = new FormData();
data.append('file', buffer, {
contentType: mimeType,
filename: mediaFileName || binaryFileName,
});
data.append('messaging_product', 'whatsapp');
requestOptions.body = data;
return requestOptions;
}

View file

@ -0,0 +1,259 @@
import set from 'lodash.set';
import { BinaryDataManager } from 'n8n-core';
import {
IDataObject,
IExecuteSingleFunctions,
IHttpRequestOptions,
IN8nHttpFullResponse,
INodeExecutionData,
JsonObject,
NodeApiError,
NodeOperationError,
} from 'n8n-workflow';
import FormData from 'form-data';
interface WhatsAppApiError {
error: {
message: string;
type: string;
code: number;
fbtrace_id: string;
};
}
export async function addTemplateComponents(
this: IExecuteSingleFunctions,
requestOptions: IHttpRequestOptions,
) {
const params = this.getNodeParameter('templateParameters') as IDataObject;
if (!params?.parameter) {
return requestOptions;
}
const components = [
{
type: 'body',
parameters: params.parameter,
},
];
if (!requestOptions.body) {
requestOptions.body = {};
}
set(requestOptions.body as {}, 'template.components', components);
return requestOptions;
}
export async function setType(this: IExecuteSingleFunctions, requestOptions: IHttpRequestOptions) {
const operation = this.getNodeParameter('operation') as string;
const messageType = this.getNodeParameter('messageType', null) as string | null;
let actualType = messageType;
if (operation === 'sendTemplate') {
actualType = 'template';
}
Object.assign(requestOptions.body, { type: actualType });
return requestOptions;
}
export async function mediaUploadFromItem(
this: IExecuteSingleFunctions,
requestOptions: IHttpRequestOptions,
) {
const mediaPropertyName = this.getNodeParameter('mediaPropertyName') as string;
if (this.getInputData().binary?.[mediaPropertyName] === undefined) {
throw new NodeOperationError(
this.getNode(),
`The binary property "${mediaPropertyName}" does not exist. So no file can be written!`,
);
}
const binaryFile = this.getInputData().binary![mediaPropertyName]!;
const mediaFileName = (this.getNodeParameter('additionalFields') as IDataObject).mediaFilename as
| string
| undefined;
const binaryFileName = binaryFile.fileName;
if (!mediaFileName && !binaryFileName) {
throw new NodeOperationError(this.getNode(), 'No file name given for media upload.');
}
const mimeType = binaryFile.mimeType;
const data = new FormData();
data.append('file', await BinaryDataManager.getInstance().retrieveBinaryData(binaryFile), {
contentType: mimeType,
filename: mediaFileName || binaryFileName,
});
data.append('messaging_product', 'whatsapp');
const phoneNumberId = this.getNodeParameter('phoneNumberId') as string;
const result = (await this.helpers.httpRequestWithAuthentication.call(this, 'whatsAppApi', {
url: `/${phoneNumberId}/media`,
baseURL: requestOptions.baseURL,
method: 'POST',
body: data,
})) as IDataObject;
const operation = this.getNodeParameter('messageType') as string;
if (!requestOptions.body) {
requestOptions.body = {};
}
set(requestOptions.body as {}, `${operation}.id`, result.id);
if (operation === 'document') {
set(requestOptions.body as {}, `${operation}.filename`, mediaFileName || binaryFileName);
}
return requestOptions;
}
export async function templateInfo(
this: IExecuteSingleFunctions,
requestOptions: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
const template = this.getNodeParameter('template') as string;
const [name, language] = template.split('|');
if (!requestOptions.body) {
requestOptions.body = {};
}
set(requestOptions.body as {}, 'template.name', name);
set(requestOptions.body as {}, 'template.language.code', language);
return requestOptions;
}
export async function componentsRequest(
this: IExecuteSingleFunctions,
requestOptions: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
const components = this.getNodeParameter('components') as IDataObject;
const componentsRet: object[] = [];
if (!components?.component) {
return requestOptions;
}
for (const component of components.component as IDataObject[]) {
const comp: IDataObject = {
type: component.type,
};
if (component.type === 'body') {
comp.parameters = (((component.bodyParameters as IDataObject)!.parameter as IDataObject[]) ||
[])!.map((i: IDataObject) => {
if (i.type === 'text') {
return i;
} else if (i.type === 'currency') {
return {
type: 'currency',
currency: {
code: i.code,
fallback_value: i.fallback_value,
amount_1000: (i.amount_1000 as number) * 1000,
},
};
} else if (i.type === 'date_time') {
return {
type: 'date_time',
date_time: {
fallback_value: i.date_time,
},
};
}
});
} else if (component.type === 'button') {
comp.index = component.index?.toString();
comp.sub_type = component.sub_type;
comp.parameters = [(component.buttonParameters as IDataObject).parameter];
} else if (component.type === 'header') {
comp.parameters = (
(component.headerParameters as IDataObject).parameter as IDataObject[]
).map((i: IDataObject) => {
if (i.type === 'image') {
return {
type: 'image',
image: {
link: i.imageLink,
},
};
}
return i;
});
}
componentsRet.push(comp);
}
if (!requestOptions.body) {
requestOptions.body = {};
}
set(requestOptions.body as {}, 'template.components', componentsRet);
return requestOptions;
}
export async function cleanPhoneNumber(
this: IExecuteSingleFunctions,
requestOptions: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
let phoneNumber = this.getNodeParameter('recipientPhoneNumber') as string;
phoneNumber = phoneNumber.replace(/[\-\(\)\+]/g, '');
if (!requestOptions.body) {
requestOptions.body = {};
}
set(requestOptions.body as {}, 'to', phoneNumber);
return requestOptions;
}
export async function sendErrorPostReceive(
this: IExecuteSingleFunctions,
data: INodeExecutionData[],
response: IN8nHttpFullResponse,
): Promise<INodeExecutionData[]> {
if (response.statusCode === 500) {
throw new NodeApiError(
this.getNode(),
{},
{
message: 'Sending failed',
description:
'If youre sending to a new test number, try sending a message to it from within the Meta developer portal first.',
httpCode: '500',
},
);
} else if (response.statusCode === 400) {
const error = { ...(response.body as WhatsAppApiError).error };
error.message = error.message.replace(/^\(#\d+\) /, '');
const messageType = this.getNodeParameter('messageType', 'media');
if (error.message.endsWith('is not a valid whatsapp business account media attachment ID')) {
throw new NodeApiError(
this.getNode(),
{ error },
{
message: `Invalid ${messageType} ID`,
description: error.message,
httpCode: '400',
},
);
} else if (error.message.endsWith('is not a valid URI.')) {
throw new NodeApiError(
this.getNode(),
{ error },
{
message: `Invalid ${messageType} URL`,
description: error.message,
httpCode: '400',
},
);
}
throw new NodeApiError(
this.getNode(),
{ ...(response as unknown as JsonObject), body: { error } },
{},
);
} else if (response.statusCode > 399) {
throw new NodeApiError(this.getNode(), response as unknown as JsonObject);
}
return data;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,53 @@
import { INodeType, INodeTypeDescription } from 'n8n-workflow';
import { messageFields, messageTypeFields } from './MessagesDescription';
import { mediaFields, mediaTypeFields } from './MediaDescription';
export class WhatsApp implements INodeType {
description: INodeTypeDescription = {
displayName: 'WhatsApp Business Cloud',
name: 'whatsApp',
icon: 'file:whatsapp.svg',
group: ['output'],
version: 1,
subtitle: '={{ $parameter["resource"] + ": " + $parameter["operation"] }}',
description: 'Access WhatsApp API',
defaults: {
name: 'WhatsApp',
},
inputs: ['main'],
outputs: ['main'],
credentials: [
{
name: 'whatsAppApi',
required: true,
},
],
requestDefaults: {
baseURL: 'https://graph.facebook.com/v13.0/',
},
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Message',
value: 'message',
},
{
name: 'Media',
value: 'media',
},
],
default: 'message',
},
...messageFields,
...mediaFields,
...messageTypeFields,
...mediaTypeFields,
],
};
}

View file

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 48 48" width="60px" height="60px">
<path fill="#fff" d="m4.868 43.303 2.694-9.835a18.941 18.941 0 0 1-2.535-9.489C5.032 13.514 13.548 5 24.014 5a18.867 18.867 0 0 1 13.43 5.566A18.866 18.866 0 0 1 43 23.994c-.004 10.465-8.522 18.98-18.986 18.98-.001 0 0 0 0 0h-.008a18.965 18.965 0 0 1-9.073-2.311l-10.065 2.64z"/>
<path fill="#fff" d="M4.868 43.803a.499.499 0 0 1-.482-.631l2.639-9.636a19.48 19.48 0 0 1-2.497-9.556C4.532 13.238 13.273 4.5 24.014 4.5a19.367 19.367 0 0 1 13.784 5.713A19.362 19.362 0 0 1 43.5 23.994c-.004 10.741-8.746 19.48-19.486 19.48a19.535 19.535 0 0 1-9.144-2.277l-9.875 2.589a.457.457 0 0 1-.127.017z"/>
<path fill="#cfd8dc" d="M24.014 5a18.867 18.867 0 0 1 13.43 5.566A18.866 18.866 0 0 1 43 23.994c-.004 10.465-8.522 18.98-18.986 18.98h-.008a18.965 18.965 0 0 1-9.073-2.311l-10.065 2.64 2.694-9.835a18.941 18.941 0 0 1-2.535-9.489C5.032 13.514 13.548 5 24.014 5m0-1C12.998 4 4.032 12.962 4.027 23.979a20.01 20.01 0 0 0 2.461 9.622L3.903 43.04a.998.998 0 0 0 1.219 1.231l9.687-2.54a20.026 20.026 0 0 0 9.197 2.244c11.024 0 19.99-8.963 19.995-19.98A19.856 19.856 0 0 0 38.153 9.86 19.869 19.869 0 0 0 24.014 4z"/>
<path fill="#40c351" d="M35.176 12.832a15.673 15.673 0 0 0-11.157-4.626c-8.704 0-15.783 7.076-15.787 15.774a15.738 15.738 0 0 0 2.413 8.396l.376.597-1.595 5.821 5.973-1.566.577.342a15.75 15.75 0 0 0 8.032 2.199h.006c8.698 0 15.777-7.077 15.78-15.776a15.68 15.68 0 0 0-4.618-11.161z"/>
<path fill="#fff" d="M19.268 16.045c-.355-.79-.729-.806-1.068-.82-.277-.012-.593-.011-.909-.011-.316 0-.83.119-1.265.594-.435.475-1.661 1.622-1.661 3.956 0 2.334 1.7 4.59 1.937 4.906.237.316 3.282 5.259 8.104 7.161 4.007 1.58 4.823 1.266 5.693 1.187.87-.079 2.807-1.147 3.202-2.255.395-1.108.395-2.057.277-2.255-.119-.198-.435-.316-.909-.554s-2.807-1.385-3.242-1.543c-.435-.158-.751-.237-1.068.238-.316.474-1.225 1.543-1.502 1.859-.277.317-.554.357-1.028.119s-2.002-.738-3.815-2.354c-1.41-1.257-2.362-2.81-2.639-3.285-.277-.474-.03-.731.208-.968.213-.213.474-.554.712-.831.237-.277.316-.475.474-.791.158-.317.079-.594-.04-.831-.117-.238-1.039-2.584-1.461-3.522z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -318,6 +318,7 @@
"dist/credentials/WebflowApi.credentials.js",
"dist/credentials/WebflowOAuth2Api.credentials.js",
"dist/credentials/WekanApi.credentials.js",
"dist/credentials/WhatsAppApi.credentials.js",
"dist/credentials/WiseApi.credentials.js",
"dist/credentials/WooCommerceApi.credentials.js",
"dist/credentials/WordpressApi.credentials.js",
@ -684,6 +685,7 @@
"dist/nodes/Webflow/WebflowTrigger.node.js",
"dist/nodes/Webhook/Webhook.node.js",
"dist/nodes/Wekan/Wekan.node.js",
"dist/nodes/WhatsApp/WhatsApp.node.js",
"dist/nodes/Wise/Wise.node.js",
"dist/nodes/Wise/WiseTrigger.node.js",
"dist/nodes/WooCommerce/WooCommerce.node.js",
@ -751,6 +753,7 @@
"cheerio": "1.0.0-rc.6",
"chokidar": "3.5.2",
"cron": "~1.7.2",
"currency-codes": "^2.1.0",
"eventsource": "^2.0.2",
"fast-glob": "^3.2.5",
"fflate": "^0.7.0",

View file

@ -217,7 +217,12 @@ export class RoutingNode {
returnData.push({ json: {}, error: error.message });
continue;
}
throw new NodeApiError(this.node, error, { runIndex, itemIndex: i });
throw new NodeApiError(this.node, error, {
runIndex,
itemIndex: i,
message: error?.message,
description: error?.description,
});
}
}