UNPKG

whatsapp-api-js

Version:

A TypeScript server agnostic Whatsapp's Official API framework

759 lines (758 loc) 24.7 kB
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 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 true, API operations will return the fetch promise instead. Intended for low level debugging. */ parsed; /** * 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) * * @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 = {}; //#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, parsed = true, 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; } this.parsed = !!parsed; } //#region Message Operations /** * Send a Whatsapp message * * @example * ```ts * import { WhatsAppAPI } from "whatsapp-api-js"; * import { Text } from "whatsapp-api-js/messages/text"; * * const Whatsapp = new WhatsAppAPI({ * token: "YOUR_TOKEN", * appSecret: "YOUR_APP_SECRET" * }); * * Whatsapp.sendMessage( * "BOT_PHONE_ID", * "USER_PHONE", * new Text("Hello World") * ).then(console.log); * ``` * * @param phoneID - The bot's phone ID * @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 512B string, useful for tracking (length not checked by the framework) * @returns The server response */ 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 = this.parsed ? await (await promise).json() : void 0; const args = { phoneID, to, type, message, request, id: response ? "messages" in response ? response.messages[0].id : void 0 : void 0, held_for_quality_assessment: response ? "messages" in response ? "message_status" in response.messages[0] ? response.messages[0].message_status === "held_for_quality_assessment" : void 0 : void 0 : void 0, response, offload: WhatsAppAPI.offload, Whatsapp: this }; try { await this.on?.sent?.(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; } /** * Mark a message as read * * @param phoneID - The bot's phone ID * @param messageId - The message ID * @returns The server response */ async markAsRead(phoneID, messageId) { 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 }) } ); return this.getBody(promise); } //#endregion //#region QR Operations /** * Generate a QR code for sharing the bot * * @param phoneID - The bot's phone ID * @param message - The quick message on the QR code * @param format - The format of the QR code * @returns The server response */ 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); } /** * Get one or many QR codes of the bot * * @param phoneID - The bot's phone ID * @param id - The QR's id to find. If not specified, all QRs will be returned * @returns The server response */ async retrieveQR(phoneID, id) { const promise = this.$$apiFetch$$( `https://graph.facebook.com/${this.v}/${phoneID}/message_qrdls/${id ?? ""}` ); return this.getBody(promise); } /** * Update a QR code of the bot * * @param phoneID - The bot's phone ID * @param id - The QR's id to edit * @param message - The new quick message for the QR code * @returns The server response */ 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); } /** * Delete a QR code of the bot * * @param phoneID - The bot's phone ID * @param id - The QR's id to delete * @returns The server response */ 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 /** * Get a Media object data with an ID * * @see {@link fetchMedia} * * @param id - The Media's ID * @param phoneID - Business phone number ID. If included, the operation will only be processed if the ID matches the ID of the business phone number that the media was uploaded on. * @returns The server response */ 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); } /** * Upload a Media to the API server * * @example * ```ts * // author ekoeryanto on issue #322 * import { WhatsAppAPI } from "whatsapp-api-js"; * * const token = "token"; * const appSecret = "appSecret"; * * const Whatsapp = new WhatsAppAPI({ token, appSecret }); * * const url = "https://example.com/image.png"; * * const image = await fetch(url); * const blob = await image.blob(); * * // If required: * // import FormData from "undici"; * * const form = new FormData(); * form.set("file", blob); * * console.log(await Whatsapp.uploadMedia("phoneID", form)); * // Expected output: { id: "mediaID" } * ``` * * @example * ```ts * import { WhatsAppAPI } from "whatsapp-api-js"; * * const token = "token"; * const appSecret = "appSecret"; * * const Whatsapp = new WhatsAppAPI({ token, appSecret }); * * // If required: * // import FormData from "undici"; * // import { Blob } from "node:buffer"; * * const form = new FormData(); * * // If you don't mind reading the whole file into memory: * form.set("file", new Blob([fs.readFileSync("image.png")], "image/png")); * * // If you do, you will need to use streams. The module "form-data", * // although not spec compliant (hence needing to set check to false), * // has an easy way to do this: * // form.append("file", fs.createReadStream("image.png"), { contentType: "image/png" }); * * console.log(await Whatsapp.uploadMedia("phoneID", form)); * // Expected output: { id: "mediaID" } * ``` * * @param phoneID - The bot's phone ID * @param form - The Media's FormData. Must have a 'file' property with the file to upload as a blob and a valid mime-type in the 'type' field of the blob. Example for Node ^18: `new FormData().set("file", new Blob([stringOrFileBuffer], "image/png"));` Previous versions of Node will need an external FormData, such as undici's. To use non spec complaints versions of FormData (eg: form-data) or Blob set the 'check' parameter to false. * @param check - If the FormData should be checked before uploading. The FormData must have the method .get("name") to work with the checks. If it doesn't (for example, using the module "form-data"), set this to false. * @returns The server response * @throws If check is set to true and form is not a FormData * @throws If check is set to true and the form doesn't have valid required properties (file, type) * @throws If check is set to true and the form file is too big for the file type */ 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); } /** * Get a Media fetch from an url. * When using this method, be sure to pass a trusted url, since the request will be authenticated with the token. * * @example * ```ts * import { WhatsAppAPI } from "whatsapp-api-js"; * * const token = "token"; * const appSecret = "appSecret"; * * const Whatsapp = new WhatsAppAPI({ token, appSecret }); * * const id = "mediaID"; * const { url } = await Whatsapp.retrieveMedia(id); * const response = Whatsapp.fetchMedia(url); * ``` * * @param url - The Media's url * @returns The fetch raw response * @throws If url is not a valid url */ 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)" } }); } /** * Delete a Media object with an ID * * @param id - The Media's ID * @param phoneID - Business phone number ID. If included, the operation will only be processed if the ID matches the ID of the business phone number that the media was uploaded on. * @returns The server response */ 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 /** * Block a user from sending messages to the bot * * The block API has 2 restrictions: * - You can only block users that have messaged your business in the last 24 hours * - You can only block up to 64k users * * @param phoneID - The bot's phone ID from which to block * @param users - The user phone numbers to block (the API doesn't fail if it's empty) * @returns The server response */ 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); } /** * Unblock a user from the bot's block list * * @remarks Contrary to blocking, unblocking isn't restricted by the 24 hours rule * * @param phoneID - The bot's phone ID from which to unblock * @param users - The user phone numbers to unblock (the API doesn't fail if it's empty) * @returns The server response */ 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 value = data.entry[0].changes[0].value; const phoneID = value.metadata.phone_number_id; if ("messages" 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 ), block: () => this.blockUser(phoneID, from), offload: WhatsAppAPI.offload, Whatsapp: this }; return this.on?.message?.(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?.(args); } throw new WhatsAppAPIUnexpectedError("Unexpected payload", 200); } /** * GET helper, must be called inside the get function of your code. * Used once at the first webhook setup. * * @example * ```ts * // Simple http example implementation with Whatsapp.get() on Node@^19 * import { WhatsAppAPI } from "whatsapp-api-js"; * import { WhatsAppAPIError } from "whatsapp-api-js/errors"; * import { NodeNext } from "whatsapp-api-js/setup/node"; * * import { createServer } from "http"; * * const token = "token"; * const appSecret = "appSecret"; * const Whatsapp = new WhatsAppAPI(NodeNext({ token, appSecret })); * * function handler(req, res) { * if (req.method == "GET") { * const params = new URLSearchParams(req.url.split("?")[1]); * * try { * const response = Whatsapp.get(Object.fromEntries(params)); * res.writeHead(200, {"Content-Type": "text/html"}); * res.write(response); * } catch (err) { * res.writeHead(err instanceof WhatsAppAPIError ? err.httpStatus : 500); * } * * res.end(); * } else res.writeHead(501).end(); * }; * * const server = createServer(handler); * server.listen(3000); * ``` * * @param params - The request object sent by Whatsapp * @returns The challenge string, it must be the http response body * @throws Class {@link WhatsAppAPIMissingVerifyTokenError} if webhookVerifyToken is not specified * @throws Class {@link WhatsAppAPIMissingSearchParamsError} if the request is missing data * @throws Class {@link WhatsAppAPIFailedToVerifyTokenError} if the verification tokens don't match */ 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(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(); const keyBuffer = encoder.encode(this.appSecret); const key = await this.subtle.importKey( "raw", keyBuffer, { name: "HMAC", hash: "SHA-256" }, true, ["sign", "verify"] ); const data = encoder.encode(escapeUnicode(raw_body)); const result = await this.subtle.sign("HMAC", key, data); const result_array = Array.from(new Uint8Array(result)); const check = result_array.map((b) => b.toString(16).padStart(2, "0")).join(""); return signature === check; } /** * Get the body of a fetch response * * @internal * @param promise - The fetch response * @returns The json body parsed */ async getBody(promise) { return this.parsed ? await (await promise).json() : promise; } /** * 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