diff --git a/src/emitters.ts b/src/emitters.ts index 83d746ef..c1777010 100644 --- a/src/emitters.ts +++ b/src/emitters.ts @@ -90,11 +90,13 @@ export type OnMessageArgs = { * * @param response - The message to send as a reply * @param context - Wether to mention the current message, defaults to false + * @param biz_opaque_callback_data - An arbitrary 256B string, useful for tracking * @returns WhatsAppAPI.sendMessage return value */ reply: ( response: ClientMessage, - context?: boolean + context?: boolean, + biz_opaque_callback_data?: string ) => Promise; /** * The WhatsAppAPI instance that emitted the event @@ -142,6 +144,10 @@ export type OnStatusArgs = { * The error object */ error?: ServerError; + /** + * Arbitrary string included in sent messages + */ + biz_opaque_callback_data?: string; /** * The raw data from the API */ diff --git a/src/index.ts b/src/index.ts index daa49b5c..401ab8b1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -204,13 +204,15 @@ export default class WhatsAppAPI { * @param to - The user's phone number * @param message - A Whatsapp message, built using the corresponding module for each type of message. * @param context - The message ID of the message to reply to + * @param biz_opaque_callback_data - An arbitrary 256B string, useful for tracking (length not checked by the framework) * @returns The server response */ async sendMessage( phoneID: string, to: string, message: ClientMessage, - context?: string + context?: string, + biz_opaque_callback_data?: string ): Promise { const type = message._type; @@ -228,6 +230,8 @@ export default class WhatsAppAPI { message._build(); if (context) request.context = { message_id: context }; + if (biz_opaque_callback_data) + request.biz_opaque_callback_data = biz_opaque_callback_data; // Make the post request const promise = this.fetch( @@ -784,12 +788,13 @@ export default class WhatsAppAPI { message, name, raw: data, - reply: (response, context = false) => + reply: (response, context = false, biz_opaque_callback_data) => this.sendMessage( phoneID, from, response, - context ? message.id : undefined + context ? message.id : undefined, + biz_opaque_callback_data ), Whatsapp: this }; @@ -804,6 +809,7 @@ export default class WhatsAppAPI { const conversation = statuses.conversation; const pricing = statuses.pricing; const error = statuses.errors?.[0]; + const biz_opaque_callback_data = statuses.biz_opaque_callback_data; const args: OnStatusArgs = { phoneID, @@ -813,6 +819,7 @@ export default class WhatsAppAPI { conversation, pricing, error, + biz_opaque_callback_data, raw: data }; diff --git a/src/types.ts b/src/types.ts index 4c95baa1..a99289fd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -326,9 +326,9 @@ export type ClientMessageRequest = { */ to: string; /** - * Undocumented, optional (the framework doesn't use it) + * Currently you can only send messages to individuals */ - recipient_type?: "individual"; + recipient_type: "individual"; /** * The message to reply to */ @@ -338,6 +338,15 @@ export type ClientMessageRequest = { */ message_id: string; }; + /** + * An arbitrary 256B string, useful for tracking. + * + * Any app subscribed to the messages webhook field on the WhatsApp Business Account can get this string, + * as it is included in statuses object within webhook payloads. + * + * Cloud API does not process this field, it just returns it as part of sent/delivered/read message webhooks. + */ + biz_opaque_callback_data?: string; } & ( | { type: "text"; @@ -710,6 +719,7 @@ export type PostData = { status: ServerStatus; timestamp: string; recipient_id: string; + biz_opaque_callback_data?: string; } & ( | { conversation: ServerConversation; diff --git a/test/index.test.cjs b/test/index.test.cjs index 28e41c10..47022530 100644 --- a/test/index.test.cjs +++ b/test/index.test.cjs @@ -388,6 +388,7 @@ describe("WhatsAppAPI", function () { const user = "3"; const id = "something_random"; const context = "another_random_id"; + const tracker = "tracker"; const type = "text"; const message = new Text("Hello world"); @@ -406,6 +407,11 @@ describe("WhatsAppAPI", function () { } }; + const requestWithTracker = { + ...request, + biz_opaque_callback_data: tracker + }; + const expectedResponse = { messaging_product: "whatsapp", contacts: [ @@ -478,6 +484,31 @@ describe("WhatsAppAPI", function () { deepEqual(response, expectedResponse); }); + it("should be able to send with a tracker (biz_opaque_callback_data)", async function () { + clientFacebook + .intercept({ + path: `/${Whatsapp.v}/${bot}/messages`, + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json" + }, + body: JSON.stringify(requestWithTracker) + }) + .reply(200, expectedResponse) + .times(1); + + const response = await Whatsapp.sendMessage( + bot, + user, + message, + undefined, + tracker + ); + + deepEqual(response, expectedResponse); + }); + it("should return the raw fetch response if parsed is false", async function () { Whatsapp.parsed = false; @@ -1419,6 +1450,10 @@ describe("WhatsAppAPI", function () { }); describe("Webhooks", function () { + function threw(i) { + return (e) => e === i; + } + describe("Get", function () { const mode = "subscribe"; const challenge = "challenge"; @@ -1449,40 +1484,34 @@ describe("WhatsAppAPI", function () { }); it("should throw 500 if webhookVerifyToken is not specified", function () { - const compare = (e) => e === 500; - delete Whatsapp.webhookVerifyToken; throws(function () { Whatsapp.get(params); - }, compare); + }, threw(500)); }); it("should throw 400 if the request is missing data", function () { - const compare = (e) => e === 400; - throws(function () { Whatsapp.get({}); - }, compare); + }, threw(400)); throws(function () { Whatsapp.get({ "hub.mode": mode }); - }, compare); + }, threw(400)); throws(function () { Whatsapp.get({ "hub.verify_token": token }); - }, compare); + }, threw(400)); }); it("should throw 403 if the verification tokens don't match", function () { - const compare = (e) => e === 403; - throws(function () { Whatsapp.get( { ...params, "hub.verify_token": "wrong" }, token ); - }, compare); + }, threw(403)); }); }); @@ -1520,6 +1549,7 @@ describe("WhatsAppAPI", function () { billable: true, category: "business-initiated" }; + const biz_opaque_callback_data = "5"; const valid_message_mock = new MessageWebhookMock( phoneID, @@ -1533,7 +1563,8 @@ describe("WhatsAppAPI", function () { status, id, conversation, - pricing + pricing, + biz_opaque_callback_data ); const Whatsapp = new WhatsAppAPI({ @@ -1553,47 +1584,39 @@ describe("WhatsAppAPI", function () { describe("Validation", function () { describe("Secure truthy (default)", function () { it("should throw 400 if rawBody is missing", function () { - const compare = (e) => e === 400; - - rejects(Whatsapp.post(valid_message_mock), compare); + rejects(Whatsapp.post(valid_message_mock), threw(400)); rejects( Whatsapp.post(valid_message_mock, undefined), - compare + threw(400) ); }); it("should throw 401 if signature is missing", function () { - const compare = (e) => e === 401; - rejects( Whatsapp.post(valid_message_mock, body), - compare + threw(401) ); rejects( Whatsapp.post(valid_message_mock, body, undefined), - compare + threw(401) ); }); it("should throw 500 if appSecret is not specified", function () { - const compare = (e) => e === 500; - delete Whatsapp.appSecret; rejects( Whatsapp.post(valid_message_mock, body, signature), - compare + threw(500) ); }); it("should throw 401 if the signature doesn't match the hash", function () { - const compare = (e) => e === 401; - rejects( Whatsapp.post(valid_message_mock, body, "wrong"), - compare + threw(401) ); }); @@ -1626,11 +1649,9 @@ describe("WhatsAppAPI", function () { }); it("should throw 400 if the request isn't a valid WhatsApp Cloud API request (data.object)", function () { - const compare = (e) => e === 400; - Whatsapp.secure = false; - rejects(Whatsapp.post({}), compare); + rejects(Whatsapp.post({}), threw(400)); }); }); @@ -1776,6 +1797,7 @@ describe("WhatsAppAPI", function () { id, conversation, pricing, + biz_opaque_callback_data, raw: valid_status_mock }); }); diff --git a/test/webhooks.mocks.cjs b/test/webhooks.mocks.cjs index fe6d9ca2..42e5974d 100644 --- a/test/webhooks.mocks.cjs +++ b/test/webhooks.mocks.cjs @@ -50,7 +50,15 @@ class StatusWebhookMock { /** * Helper class to test the status post request, conditionally creating the object based on the available data */ - constructor(phoneID, phone, status, messageID, conversation, pricing) { + constructor( + phoneID, + phone, + status, + messageID, + conversation, + pricing, + biz_opaque_callback_data + ) { this.object = "whatsapp_business_account"; this.entry = [ { @@ -95,6 +103,11 @@ class StatusWebhookMock { this.entry[0].changes[0].value.statuses[0].pricing = pricing; } + if (biz_opaque_callback_data) { + this.entry[0].changes[0].value.statuses[0].biz_opaque_callback_data = + biz_opaque_callback_data; + } + if ( Object.keys(this.entry[0].changes[0].value.statuses[0]).length === 0 ) {