whatsapp-api-js
Version:
A TypeScript server agnostic Whatsapp's Official API framework
703 lines (702 loc) • 21.1 kB
JavaScript
import {
ClientMessage
} from "./types.js";
import { escapeUnicode } from "./utils.js";
import { DEFAULT_API_VERSION } from "./types.js";
import {
WhatsAppAPIMissingAppSecretError,
WhatsAppAPIMissingCryptoSubtleError,
WhatsAppAPIMissingRawBodyError,
WhatsAppAPIMissingSignatureError,
WhatsAppAPIMissingVerifyTokenError,
WhatsAppAPIUnexpectedError,
WhatsAppAPIFailedToVerifyError,
WhatsAppAPIMissingSearchParamsError,
WhatsAppAPIFailedToVerifyTokenError
} from "./errors.js";
class WhatsAppAPI {
//#region Properties
/**
* The API token
*/
token;
/**
* The app secret
*/
appSecret;
/**
* The HMAC key derived from the app secret
*
* @remarks This is lazily initialized by {@link verifyRequestSignature}
*/
key;
/**
* The webhook verify token
*/
webhookVerifyToken;
/**
* The API version to use
*/
v;
/**
* The fetch function for the requests
*/
fetch;
/**
* The CryptoSubtle library for checking the signatures
*/
subtle;
/**
* If false, the API will be used in a less secure way, removing the need for appSecret. Defaults to true.
*/
secure;
/**
* The callbacks for the events (message, sent, status, call)
*
* @example
* ```ts
* const Whatsapp = new WhatsAppAPI({
* token: "my-token",
* appSecret: "my-app-secret"
* });
*
* // Set the callback
* Whatsapp.on.message = ({ from, phoneID }) => console.log(`Message from ${from} to bot ${phoneID}`);
*
* // If you need to disable the callback:
* // Whatsapp.on.message = undefined;
* ```
*/
on = {
call: {}
};
//#endregion
/**
* Main entry point for the API.
*
* It's highly recommended reading the named parameters docs at
* {@link types.TheBasicConstructorArguments},
* at least for `token`, `appSecret` and `webhookVerifyToken` properties,
* which are the most common in normal usage.
*
* The other parameters are used for fine tunning the framework,
* such as `ponyfill`, which allows the code to execute on platforms
* that are missing standard APIs such as fetch and crypto.
*
* @example
* ```ts
* import { WhatsAppAPI } from "whatsapp-api-js";
*
* const Whatsapp = new WhatsAppAPI({
* token: "YOUR_TOKEN",
* appSecret: "YOUR_APP_SECRET"
* });
* ```
*
* @template EmittersReturnType - The return type of the emitters
* ({@link OnMessage}, {@link OnStatus})
*
* @throws If fetch is not defined in the enviroment and the provided ponyfill isn't a function
* @throws If secure is true, crypto.subtle is not defined in the enviroment and the provided ponyfill isn't an object
*/
constructor({
token,
appSecret,
webhookVerifyToken,
v,
secure = true,
ponyfill = {}
}) {
this.token = token;
this.secure = !!secure;
if (this.secure) {
this.appSecret = appSecret;
if (typeof ponyfill.subtle !== "object" && (typeof crypto !== "object" || typeof crypto?.subtle !== "object")) {
throw new Error(
"subtle is not defined in the enviroment. Consider using a setup helper, defined at 'whatsapp-api-js/setup', or provide a valid ponyfill object with the argument 'ponyfill.subtle'."
);
}
this.subtle = ponyfill.subtle || crypto.subtle;
}
if (webhookVerifyToken) this.webhookVerifyToken = webhookVerifyToken;
if (typeof ponyfill.fetch !== "function" && typeof fetch !== "function") {
throw new Error(
"fetch is not defined in the enviroment. Consider using a setup helper, defined at 'whatsapp-api-js/setup', or provide a valid ponyfill object with the argument 'ponyfill.fetch'."
);
}
this.fetch = ponyfill.fetch || fetch;
if (v) this.v = v;
else {
console.warn(
`[whatsapp-api-js] Cloud API version not defined. In production, it's strongly recommended pinning it to the desired version with the "v" argument. Defaulting to "${DEFAULT_API_VERSION}".`
);
this.v = DEFAULT_API_VERSION;
}
}
//#region Message Operations
async sendMessage(phoneID, to, message, context, biz_opaque_callback_data) {
const type = message._type;
const request = {
messaging_product: "whatsapp",
type,
to
};
request[type] = message;
if (context) request.context = { message_id: context };
if (biz_opaque_callback_data)
request.biz_opaque_callback_data = biz_opaque_callback_data;
const promise = this.$$apiFetch$$(
`https://graph.facebook.com/${this.v}/${phoneID}/messages`,
{
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(request)
}
);
const response = await this.getBody(promise);
const has_msg = "messages" in response;
const args = {
phoneID,
to,
type,
message,
request,
id: has_msg ? response.messages[0].id : void 0,
held_for_quality_assessment: has_msg ? "message_status" in response.messages[0] ? response.messages[0].message_status === "held_for_quality_assessment" : void 0 : void 0,
response,
offload: WhatsAppAPI.offload,
Whatsapp: this
};
try {
await this.on?.sent?.call(null, args);
} catch (error) {
console.error(error);
}
return response ?? promise;
}
broadcastMessage(phoneID, to, message_builder, batch_size = 50, delay = 1e3) {
const responses = [];
if (batch_size < 1) {
throw new RangeError("batch_size must be greater than 0");
}
if (delay < 0) {
throw new RangeError("delay must be greater or equal to 0");
}
to.forEach((data, i) => {
responses.push(
new Promise((resolve) => {
setTimeout(
async () => {
let phone;
let message;
if (message_builder instanceof ClientMessage) {
phone = data;
message = message_builder;
} else {
[phone, message] = await message_builder(
data
);
}
this.sendMessage(phoneID, phone, message).then(
resolve
);
},
delay * (i / batch_size | 0)
);
})
);
});
return responses;
}
async markAsRead(phoneID, messageId, indicator) {
const promise = this.$$apiFetch$$(
`https://graph.facebook.com/${this.v}/${phoneID}/messages`,
{
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
messaging_product: "whatsapp",
status: "read",
message_id: messageId,
typing_indicator: indicator ? { type: indicator } : void 0
})
}
);
return this.getBody(promise);
}
//#endregion
//#region Call Operations
async initiateCall(phoneID, to, sdp, biz_opaque_callback_data) {
const promise = this.$$apiFetch$$(
`https://graph.facebook.com/${phoneID}/calls`,
{
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
messaging_product: "whatsapp",
to,
action: "connect",
biz_opaque_callback_data,
session: {
sdp_type: "offer",
sdp
}
})
}
);
return this.getBody(promise);
}
async preacceptCall(phoneID, callID, sdp) {
const promise = this.$$apiFetch$$(
`https://graph.facebook.com/${phoneID}/calls`,
{
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
messaging_product: "whatsapp",
call_id: callID,
action: "pre_accept",
session: {
sdp_type: "offer",
sdp
}
})
}
);
return this.getBody(promise);
}
async rejectCall(phoneID, callID) {
const promise = this.$$apiFetch$$(
`https://graph.facebook.com/${phoneID}/calls`,
{
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
messaging_product: "whatsapp",
call_id: callID,
action: "reject"
})
}
);
return this.getBody(promise);
}
async acceptCall(phoneID, callID, sdp, biz_opaque_callback_data) {
const promise = this.$$apiFetch$$(
`https://graph.facebook.com/${phoneID}/calls`,
{
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
messaging_product: "whatsapp",
call_id: callID,
action: "accept",
biz_opaque_callback_data,
session: {
sdp_type: "offer",
sdp
}
})
}
);
return this.getBody(promise);
}
async terminateCall(phoneID, callID) {
const promise = this.$$apiFetch$$(
`https://graph.facebook.com/${phoneID}/calls`,
{
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
messaging_product: "whatsapp",
call_id: callID,
action: "terminate"
})
}
);
return this.getBody(promise);
}
//#endregion
//#region QR Operations
async createQR(phoneID, message, format = "png") {
const promise = this.$$apiFetch$$(
`https://graph.facebook.com/${this.v}/${phoneID}/message_qrdls?generate_qr_image=${format}&prefilled_message=${message}`,
{
method: "POST"
}
);
return this.getBody(promise);
}
async retrieveQR(phoneID, id) {
const promise = this.$$apiFetch$$(
`https://graph.facebook.com/${this.v}/${phoneID}/message_qrdls/${id ?? ""}`
);
return this.getBody(promise);
}
async updateQR(phoneID, id, message) {
const promise = this.$$apiFetch$$(
`https://graph.facebook.com/${this.v}/${phoneID}/message_qrdls/${id}?prefilled_message=${message}`,
{
method: "POST"
}
);
return this.getBody(promise);
}
async deleteQR(phoneID, id) {
const promise = this.$$apiFetch$$(
`https://graph.facebook.com/${this.v}/${phoneID}/message_qrdls/${id}`,
{
method: "DELETE"
}
);
return this.getBody(promise);
}
//#endregion
//#region Media Operations
async retrieveMedia(id, phoneID) {
const params = phoneID ? `phone_number_id=${phoneID}` : "";
const promise = this.$$apiFetch$$(
`https://graph.facebook.com/${this.v}/${id}?${params}`
);
return this.getBody(promise);
}
async uploadMedia(phoneID, form, check = true) {
if (check) {
if (!form || typeof form !== "object" || !("get" in form) || typeof form.get !== "function")
throw new TypeError(
"File's Form must be an instance of FormData"
);
const file = form.get("file");
if (!file.type)
throw new Error("File's Blob must have a type specified");
const validMediaTypes = [
"audio/aac",
"audio/mp4",
"audio/mpeg",
"audio/amr",
"audio/ogg",
"text/plain",
"application/pdf",
"application/vnd.ms-powerpoint",
"application/msword",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"image/jpeg",
"image/png",
"video/mp4",
"video/3gp",
"image/webp"
];
if (!validMediaTypes.includes(file.type))
throw new Error(`Invalid media type: ${file.type}`);
const validMediaSizes = {
audio: 16e6,
text: 1e8,
application: 1e8,
image: 5e6,
video: 16e6,
sticker: 5e5
};
const mediaType = file.type === "image/webp" ? "sticker" : file.type.split("/")[0];
if (file.size && file.size > validMediaSizes[mediaType])
throw new Error(
`File is too big (${file.size} bytes) for a ${mediaType} (${validMediaSizes[mediaType]} bytes limit)`
);
}
const promise = this.$$apiFetch$$(
`https://graph.facebook.com/${this.v}/${phoneID}/media?messaging_product=whatsapp`,
{
method: "POST",
body: form
}
);
return this.getBody(promise);
}
fetchMedia(url) {
return this.$$apiFetch$$(new URL(url), {
headers: {
// Thanks @tecoad
"User-Agent": "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
}
});
}
async deleteMedia(id, phoneID) {
const params = phoneID ? `phone_number_id=${phoneID}` : "";
const promise = this.$$apiFetch$$(
`https://graph.facebook.com/${this.v}/${id}?${params}`,
{
method: "DELETE"
}
);
return this.getBody(promise);
}
// #endregion
// #region Block Operations
async blockUser(phoneID, ...users) {
const promise = this.$$apiFetch$$(
`https://graph.facebook.com/${phoneID}/block_users`,
{
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
messaging_product: "whatsapp",
block_users: users.map((user) => ({ user }))
})
}
);
return this.getBody(promise);
}
async unblockUser(phoneID, ...users) {
const promise = this.$$apiFetch$$(
`https://graph.facebook.com/${phoneID}/block_users`,
{
method: "DELETE",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
messaging_product: "whatsapp",
block_users: users.map((user) => ({ user }))
})
}
);
return this.getBody(promise);
}
async post(data, raw_body, signature) {
if (this.secure) {
if (!raw_body) throw new WhatsAppAPIMissingRawBodyError();
if (!signature) throw new WhatsAppAPIMissingSignatureError();
if (!await this.verifyRequestSignature(raw_body, signature)) {
throw new WhatsAppAPIFailedToVerifyError();
}
}
if (!data.object) {
throw new WhatsAppAPIUnexpectedError("Invalid payload", 400);
}
const { field, value } = data.entry[0].changes[0];
const phoneID = value.metadata.phone_number_id;
if (field === "messages") {
if (field in value) {
const message = value.messages[0];
const contact = value.contacts?.[0];
const from = contact?.wa_id ?? message.from;
const name = contact?.profile.name;
const args = {
phoneID,
from,
message,
name,
raw: data,
reply: (response, context = false, biz_opaque_callback_data) => this.sendMessage(
phoneID,
from,
response,
context ? message.id : void 0,
biz_opaque_callback_data
),
received: (i) => this.markAsRead(phoneID, message.id, i),
block: () => this.blockUser(phoneID, from),
offload: WhatsAppAPI.offload,
Whatsapp: this
};
return this.on?.message?.call(null, args);
} else if ("statuses" in value) {
const statuses = value.statuses[0];
const phone = statuses.recipient_id;
const status = statuses.status;
const id = statuses.id;
const timestamp = statuses.timestamp;
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 = {
phoneID,
phone,
status,
id,
timestamp,
conversation,
pricing,
error,
biz_opaque_callback_data,
raw: data,
offload: WhatsAppAPI.offload,
Whatsapp: this
};
return this.on?.status?.call(null, args);
}
} else if (field === "calls") {
if (field in value) {
const call = value.calls[0];
const contact = value.contacts?.[0];
const from = contact?.wa_id ?? call.from;
const name = contact?.profile.name;
if (call.event === "connect") {
const args = {
phoneID,
from,
call,
name,
raw: data,
preaccept: () => this.preacceptCall(
phoneID,
call.id,
call.session.sdp
),
accept: (biz_opaque_callback_data) => this.acceptCall(
phoneID,
call.id,
call.session.sdp,
biz_opaque_callback_data
),
reject: () => this.rejectCall(phoneID, call.id),
terminate: () => this.terminateCall(phoneID, call.id),
offload: WhatsAppAPI.offload,
Whatsapp: this
};
return this.on?.call?.connect?.call(null, args);
} else if (call.event === "terminate") {
const args = {
phoneID,
from,
call,
name,
raw: data,
offload: WhatsAppAPI.offload,
Whatsapp: this
};
return this.on?.call?.terminate?.call(null, args);
}
} else if ("statuses" in value) {
const statuses = value.statuses[0];
const phone = statuses.recipient_id;
const status = statuses.status;
const id = statuses.id;
const timestamp = statuses.timestamp;
const biz_opaque_callback_data = statuses.biz_opaque_callback_data;
const args = {
phoneID,
phone,
status,
id,
timestamp,
biz_opaque_callback_data,
raw: data,
offload: WhatsAppAPI.offload,
Whatsapp: this
};
return this.on?.call?.status?.call(null, args);
}
}
throw new WhatsAppAPIUnexpectedError("Unexpected payload", 200);
}
get(params) {
if (!this.webhookVerifyToken) {
throw new WhatsAppAPIMissingVerifyTokenError();
}
const {
"hub.mode": mode,
"hub.verify_token": token,
"hub.challenge": challenge
} = params;
if (!mode || !token) {
throw new WhatsAppAPIMissingSearchParamsError();
}
if (mode === "subscribe" && token === this.webhookVerifyToken) {
return challenge;
}
throw new WhatsAppAPIFailedToVerifyTokenError();
}
// #endregion
/**
* Make an authenticated request to any url.
* When using this method, be sure to pass a trusted url, since the request will be authenticated with the token.
*
* It's strongly recommended NOT using this method as you might risk exposing your API key accidentally,
* but it's here in case you need a specific API operation which is not implemented by the library.
*
* @param url - The url to fetch
* @param options - The fetch options (headers.Authorization is already included)
* @returns The fetch response
*/
async $$apiFetch$$(url, options = {}) {
return this.fetch.call(null, url, {
...options,
headers: {
Authorization: `Bearer ${this.token}`,
...options.headers
}
});
}
/**
* Verify the signature of a request
*
* @param raw_body - The raw body of the request
* @param signature - The signature to validate
* @returns If the signature is valid
* @throws Class {@link WhatsAppAPIMissingAppSecretError} if the appSecret isn't defined
* @throws Class {@link WhatsAppAPIMissingCryptoSubtleError} if crypto.subtle or ponyfill isn't available
*/
async verifyRequestSignature(raw_body, signature) {
if (!this.appSecret) throw new WhatsAppAPIMissingAppSecretError();
if (!this.subtle) throw new WhatsAppAPIMissingCryptoSubtleError();
signature = signature.split("sha256=")[1];
if (!signature) return false;
const encoder = new TextEncoder();
if (!this.key) {
this.key = await this.subtle.importKey(
"raw",
encoder.encode(this.appSecret),
{ name: "HMAC", hash: "SHA-256" },
true,
["verify"]
);
}
return crypto.subtle.verify(
"HMAC",
this.key,
Buffer.from(signature, "hex"),
encoder.encode(escapeUnicode(raw_body))
);
}
/**
* Get the body of a fetch response
*
* @internal
* @param promise - The fetch response
* @returns The json body parsed
*/
async getBody(promise) {
return (await promise).json();
}
/**
* Offload a function to the next tick of the event loop
*
* @param f - The function to offload from the main thread
*/
static offload(f) {
Promise.resolve().then(f);
}
}
export {
WhatsAppAPI
};
//# sourceMappingURL=index.js.map