Skip to content

Added biz_opaque_callback_data support #264

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Nov 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/emitters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ServerMessageResponse | Response>;
/**
* The WhatsAppAPI instance that emitted the event
Expand Down Expand Up @@ -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
*/
Expand Down
13 changes: 10 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ServerMessageResponse | Response> {
const type = message._type;

Expand All @@ -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(
Expand Down Expand Up @@ -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
};
Expand All @@ -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,
Expand All @@ -813,6 +819,7 @@ export default class WhatsAppAPI {
conversation,
pricing,
error,
biz_opaque_callback_data,
raw: data
};

Expand Down
14 changes: 12 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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";
Expand Down Expand Up @@ -710,6 +719,7 @@ export type PostData = {
status: ServerStatus;
timestamp: string;
recipient_id: string;
biz_opaque_callback_data?: string;
} & (
| {
conversation: ServerConversation;
Expand Down
80 changes: 51 additions & 29 deletions test/index.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -406,6 +407,11 @@ describe("WhatsAppAPI", function () {
}
};

const requestWithTracker = {
...request,
biz_opaque_callback_data: tracker
};

const expectedResponse = {
messaging_product: "whatsapp",
contacts: [
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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));
});
});

Expand Down Expand Up @@ -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,
Expand All @@ -1533,7 +1563,8 @@ describe("WhatsAppAPI", function () {
status,
id,
conversation,
pricing
pricing,
biz_opaque_callback_data
);

const Whatsapp = new WhatsAppAPI({
Expand All @@ -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)
);
});

Expand Down Expand Up @@ -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));
});
});

Expand Down Expand Up @@ -1776,6 +1797,7 @@ describe("WhatsAppAPI", function () {
id,
conversation,
pricing,
biz_opaque_callback_data,
raw: valid_status_mock
});
});
Expand Down
15 changes: 14 additions & 1 deletion test/webhooks.mocks.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand Down Expand Up @@ -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
) {
Expand Down