@johpaz/hive-sdk 0.4.7 → 0.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,334 @@
1
+ /**
2
+ * Canal de WhatsApp por la API oficial de Meta (Cloud API).
3
+ *
4
+ * Es el camino para empresas: número de WhatsApp Business propio, plantillas de
5
+ * marketing y atribución de los anuncios que abren una conversación. El otro
6
+ * canal de WhatsApp del SDK (`whatsapp`, con Baileys y código QR) no es oficial
7
+ * y sirve para uso personal, no para un negocio.
8
+ *
9
+ * A diferencia de los demás canales, este no abre ninguna conexión: Meta empuja
10
+ * los mensajes a una URL pública. Quien hospeda el canal tiene que enrutarle las
11
+ * peticiones a `handleWebhook()`; el gateway del SDK ya lo hace en
12
+ * `/webhooks/whatsapp-cloud/:accountId`.
13
+ */
14
+
15
+ import { BaseChannel, type ChannelConfig, type IncomingMessage, type OutboundMessage } from "../base.ts";
16
+ import { logger } from "../../utils/logger.ts";
17
+ import { updateDoc } from "../../storage/hive.ts";
18
+ import type { ChannelDoc } from "../../storage/collections.ts";
19
+ import {
20
+ CUSTOMER_WINDOW_MS,
21
+ WhatsAppCloudClient,
22
+ WhatsAppCloudError,
23
+ type WhatsAppTemplate,
24
+ } from "./client.ts";
25
+ import {
26
+ parseWebhook,
27
+ safeTokenEquals,
28
+ verifyChallenge,
29
+ verifySignature,
30
+ type WhatsAppInbound,
31
+ } from "./webhook.ts";
32
+
33
+ /** Cuántos ids de mensaje se recuerdan para descartar los reintentos de Meta. */
34
+ const SEEN_LIMIT = 1000;
35
+
36
+ export interface WhatsAppCloudConfig extends ChannelConfig {
37
+ accountId: string;
38
+ /** Id del número en Meta (no el número en sí). */
39
+ phoneNumberId: string;
40
+ /** Token permanente, normalmente de un usuario del sistema. */
41
+ accessToken: string;
42
+ /** Secreto de la app de Meta, con el que se firma cada webhook. */
43
+ appSecret: string;
44
+ /** El token que uno inventa y pega en el panel de Meta al registrar la URL. */
45
+ verifyToken: string;
46
+ graphVersion?: string;
47
+ /**
48
+ * Mandar la narración intermedia del agente como mensajes sueltos.
49
+ *
50
+ * Apagado a propósito: desde el 1/10/2026 Meta cobra cada mensaje de servicio
51
+ * dentro de la ventana, así que narrar el progreso sale caro. En su lugar se
52
+ * renueva el "escribiendo…", que no cuesta nada.
53
+ */
54
+ sendProgress?: boolean;
55
+ /** Plantilla aprobada para hablarle a alguien fuera de la ventana de 24 h. */
56
+ windowFallbackTemplate?: WhatsAppTemplate;
57
+ /** Inyectable para tests o para salir por un proxy. */
58
+ fetch?: typeof fetch;
59
+ }
60
+
61
+ export interface WhatsAppCloudState {
62
+ status: "disconnected" | "pending_verification" | "connected" | "error";
63
+ phoneNumberId: string;
64
+ lastWebhookAt?: number;
65
+ error?: string;
66
+ }
67
+
68
+ export class WhatsAppCloudChannel extends BaseChannel {
69
+ name = "whatsapp_cloud";
70
+ accountId: string;
71
+ config: WhatsAppCloudConfig;
72
+
73
+ readonly client: WhatsAppCloudClient;
74
+
75
+ private state: WhatsAppCloudState;
76
+ private log = logger.child("whatsapp-cloud");
77
+ /** Último mensaje entrante por sesión: abre la ventana y da el id para el "escribiendo…". */
78
+ private lastInbound: Map<string, { at: number; messageId: string }> = new Map();
79
+ private seen: Set<string> = new Set();
80
+
81
+ constructor(config: WhatsAppCloudConfig) {
82
+ super();
83
+ this.config = config;
84
+ this.accountId = config.accountId;
85
+ this.client = new WhatsAppCloudClient({
86
+ phoneNumberId: config.phoneNumberId,
87
+ accessToken: config.accessToken,
88
+ graphVersion: config.graphVersion,
89
+ fetch: config.fetch,
90
+ });
91
+ this.state = { status: "disconnected", phoneNumberId: config.phoneNumberId };
92
+ }
93
+
94
+ /** La ruta que hay que registrar en el panel de Meta, detrás de HTTPS público. */
95
+ get webhookPath(): string {
96
+ return `/webhooks/whatsapp-cloud/${this.accountId}`;
97
+ }
98
+
99
+ async start(): Promise<void> {
100
+ this.running = true;
101
+ // No hay nada que conectar: queda esperando el webhook. Hasta que Meta haga
102
+ // su verificación, el canal está dado de alta pero no recibe nada.
103
+ this.state.status = this.state.lastWebhookAt ? "connected" : "pending_verification";
104
+ this.log.info(`Canal listo — registrá ${this.webhookPath} en la app de Meta`);
105
+ }
106
+
107
+ async stop(): Promise<void> {
108
+ this.running = false;
109
+ this.state.status = "disconnected";
110
+ this.log.info("Canal detenido");
111
+ }
112
+
113
+ /**
114
+ * Atiende una petición de Meta: la verificación inicial y cada mensaje.
115
+ *
116
+ * Responde 200 en cuanto el evento es válido, sin esperar el turno del
117
+ * agente: Meta reintenta lo que tarde y termina desuscribiendo la app.
118
+ */
119
+ async handleWebhook(req: Request): Promise<Response> {
120
+ if (req.method === "GET") {
121
+ const challenge = verifyChallenge(new URL(req.url).searchParams, (token) =>
122
+ safeTokenEquals(token, this.config.verifyToken)
123
+ );
124
+ if (!challenge) {
125
+ this.log.warn("verificación rechazada: token incorrecto");
126
+ return new Response("Verification failed", { status: 403 });
127
+ }
128
+ await this.setStatus("connected");
129
+ return new Response(challenge, { status: 200 });
130
+ }
131
+
132
+ if (req.method !== "POST") {
133
+ return new Response("Method Not Allowed", { status: 405 });
134
+ }
135
+
136
+ const rawBody = await req.text();
137
+ if (!verifySignature(rawBody, req.headers.get("x-hub-signature-256"), this.config.appSecret)) {
138
+ this.log.warn("firma HMAC inválida — evento descartado");
139
+ return new Response("Invalid signature", { status: 401 });
140
+ }
141
+
142
+ let body: unknown;
143
+ try {
144
+ body = JSON.parse(rawBody);
145
+ } catch {
146
+ return new Response("Invalid JSON", { status: 400 });
147
+ }
148
+
149
+ this.state.lastWebhookAt = Date.now();
150
+ if (this.state.status !== "connected") await this.setStatus("connected");
151
+
152
+ for (const event of parseWebhook(body)) {
153
+ // Un mismo WABA puede tener varios números apuntando a la misma URL.
154
+ if (event.phoneNumberId !== this.config.phoneNumberId) continue;
155
+
156
+ for (const message of event.messages) {
157
+ void this.ingest(message).catch((error) =>
158
+ this.log.error(`no se pudo procesar ${message.id}: ${(error as Error).message}`)
159
+ );
160
+ }
161
+ }
162
+
163
+ return new Response("EVENT_RECEIVED", { status: 200 });
164
+ }
165
+
166
+ async send(sessionId: string, message: OutboundMessage): Promise<void> {
167
+ const isInterim = message.type === "progress";
168
+ if (isInterim && !this.config.sendProgress) {
169
+ // La narración no se manda: cada mensaje intermedio se cobra. El
170
+ // "escribiendo…" cuenta lo mismo y es gratis.
171
+ await this.startTyping(sessionId).catch(() => {});
172
+ return;
173
+ }
174
+
175
+ const text = message.content ?? message.chunk ?? "";
176
+ if (!text) return;
177
+
178
+ const to = this.toRecipient(sessionId);
179
+ const last = this.lastInbound.get(sessionId);
180
+ const expired = last ? Date.now() - last.at >= CUSTOMER_WINDOW_MS : false;
181
+
182
+ // Sin registro de entrante (por ejemplo tras un reinicio) se intenta igual:
183
+ // la ventana puede estar abierta y quien manda la última palabra es Meta.
184
+ if (!expired) {
185
+ try {
186
+ await this.client.sendText(to, text);
187
+ return;
188
+ } catch (error) {
189
+ if (!(error instanceof WhatsAppCloudError) || !error.windowClosed) throw error;
190
+ this.log.info(`ventana cerrada para ${to} según Meta`);
191
+ }
192
+ }
193
+
194
+ await this.sendOutsideWindow(to, text);
195
+ }
196
+
197
+ async sendAudio(sessionId: string, audio: Buffer, mimeType: string): Promise<void> {
198
+ await this.client.sendAudio(this.toRecipient(sessionId), audio, mimeType);
199
+ }
200
+
201
+ /** Muestra "escribiendo…". Meta lo apaga al responder o a los 25 segundos. */
202
+ async startTyping(sessionId: string): Promise<void> {
203
+ const last = this.lastInbound.get(sessionId);
204
+ if (!last) return;
205
+ await this.client.markRead(last.messageId, { typing: true }).catch(() => {});
206
+ }
207
+
208
+ /** No hace falta apagarlo: responder ya lo apaga. */
209
+ async stopTyping(_sessionId: string): Promise<void> {}
210
+
211
+ async markAsRead(sessionId: string, messageId?: string): Promise<void> {
212
+ const id = messageId ?? this.lastInbound.get(sessionId)?.messageId;
213
+ if (!id) return;
214
+ await this.client.markRead(id).catch(() => {});
215
+ }
216
+
217
+ getState(): WhatsAppCloudState {
218
+ return { ...this.state };
219
+ }
220
+
221
+ getConfig(): WhatsAppCloudConfig {
222
+ return { ...this.config };
223
+ }
224
+
225
+ private async ingest(message: WhatsAppInbound): Promise<void> {
226
+ if (!message.id || this.seen.has(message.id)) return;
227
+ this.remember(message.id);
228
+
229
+ if (!this.isUserAllowed(message.from)) {
230
+ this.log.info(`mensaje descartado, ${message.from} no está en la lista permitida`);
231
+ return;
232
+ }
233
+
234
+ const sessionId = this.formatSessionId(message.from, "direct");
235
+ this.lastInbound.set(sessionId, { at: Date.now(), messageId: message.id });
236
+
237
+ const incoming: IncomingMessage = {
238
+ sessionId,
239
+ channel: this.name,
240
+ accountId: this.accountId,
241
+ peerId: message.from,
242
+ peerKind: "direct",
243
+ content: message.text ?? "",
244
+ metadata: {
245
+ messageId: message.id,
246
+ timestamp: message.timestamp,
247
+ pushName: message.profileName,
248
+ type: message.type,
249
+ ...(message.referral ? { referral: message.referral } : {}),
250
+ ...(message.interactive ? { interactive: message.interactive } : {}),
251
+ },
252
+ };
253
+
254
+ if (message.media?.id && message.mediaKind) {
255
+ try {
256
+ const media = await this.client.downloadMedia(message.media.id);
257
+ if (message.mediaKind === "audio") {
258
+ incoming.audio = { buffer: media.buffer, mimeType: media.mimeType };
259
+ if (!incoming.content) incoming.content = "[Audio message]";
260
+ } else if (message.mediaKind === "image" || message.mediaKind === "sticker") {
261
+ incoming.image = {
262
+ buffer: media.buffer,
263
+ mimeType: media.mimeType,
264
+ caption: message.media.caption,
265
+ };
266
+ } else if (message.mediaKind === "document") {
267
+ incoming.document = {
268
+ buffer: media.buffer,
269
+ mimeType: media.mimeType,
270
+ fileName: message.media.fileName ?? media.fileName,
271
+ };
272
+ }
273
+ } catch (error) {
274
+ this.log.warn(`no se pudo bajar el medio ${message.media.id}: ${(error as Error).message}`);
275
+ }
276
+ }
277
+
278
+ if (!incoming.content && !incoming.audio && !incoming.image && !incoming.document) {
279
+ this.log.debug(`mensaje ${message.id} de tipo ${message.type} sin contenido utilizable`);
280
+ return;
281
+ }
282
+
283
+ // Acuse de recibo inmediato: el doble tilde y el "escribiendo…" son lo que
284
+ // le dice a la persona que su mensaje llegó, aunque la respuesta demore.
285
+ void this.client.markRead(message.id, { typing: true }).catch(() => {});
286
+
287
+ await this.handleMessage(incoming);
288
+ }
289
+
290
+ private async sendOutsideWindow(to: string, text: string): Promise<void> {
291
+ const template = this.config.windowFallbackTemplate;
292
+ if (!template) {
293
+ throw new WhatsAppCloudError(
294
+ 400,
295
+ 131047,
296
+ "Pasaron más de 24 h desde el último mensaje del cliente: Meta sólo acepta " +
297
+ "una plantilla aprobada. Configurá `windowFallbackTemplate` en el canal."
298
+ );
299
+ }
300
+ this.log.info(`fuera de la ventana: se manda la plantilla "${template.name}" en vez del texto`);
301
+ this.log.debug(`texto no enviado: ${text.slice(0, 120)}`);
302
+ await this.client.sendTemplate(to, template);
303
+ }
304
+
305
+ /** El sessionId es el `wa_id`; se tolera un prefijo por si alguien lo compone. */
306
+ private toRecipient(sessionId: string): string {
307
+ const parts = sessionId.split(":");
308
+ return (parts[parts.length - 1] ?? "").replace(/\D/g, "");
309
+ }
310
+
311
+ private remember(messageId: string): void {
312
+ this.seen.add(messageId);
313
+ if (this.seen.size > SEEN_LIMIT) {
314
+ const oldest = this.seen.values().next().value;
315
+ if (oldest) this.seen.delete(oldest);
316
+ }
317
+ }
318
+
319
+ private async setStatus(status: WhatsAppCloudState["status"]): Promise<void> {
320
+ this.state.status = status;
321
+ try {
322
+ await updateDoc<ChannelDoc>("channels", this.accountId, {
323
+ status,
324
+ last_active: Date.now(),
325
+ });
326
+ } catch {
327
+ // Sin base no se pierde nada: el estado en memoria ya quedó bien.
328
+ }
329
+ }
330
+ }
331
+
332
+ export function createWhatsAppCloudChannel(config: WhatsAppCloudConfig): WhatsAppCloudChannel {
333
+ return new WhatsAppCloudChannel(config);
334
+ }
@@ -0,0 +1,368 @@
1
+ /**
2
+ * Cliente de la WhatsApp Cloud API — la API oficial de Meta.
3
+ *
4
+ * No agrega dependencias: sólo `fetch`. Cada instancia habla por UN número
5
+ * (`phoneNumberId`), que es la unidad con la que Meta cobra, limita el caudal y
6
+ * numera los errores.
7
+ *
8
+ * Por qué existe acá y no en cada aplicación: la versión del Graph caduca sola.
9
+ * Meta garantiza unos dos años por versión y después manda las llamadas a la
10
+ * más vieja que siga viva, sin avisar y cambiando el comportamiento. Teniendo
11
+ * un único cliente, subir de versión es una línea para todos los consumidores.
12
+ */
13
+
14
+ import { logger } from "../../utils/logger.ts";
15
+
16
+ const log = logger.child("whatsapp-cloud");
17
+
18
+ /** Última versión estable del Graph. Se puede pisar con `META_GRAPH_API_VERSION`. */
19
+ export const DEFAULT_GRAPH_VERSION = "v26.0";
20
+
21
+ /** Meta rechaza cualquier `text.body` de más de 4096 caracteres. */
22
+ export const WHATSAPP_TEXT_LIMIT = 4096;
23
+
24
+ /** La ventana de atención al cliente: fuera de ella sólo se aceptan plantillas. */
25
+ export const CUSTOMER_WINDOW_MS = 24 * 60 * 60 * 1000;
26
+
27
+ const RATE_WINDOW_MS = 1000;
28
+ /** Meta acepta del orden de 80 mensajes por segundo por número. */
29
+ const MAX_REQUESTS_PER_WINDOW = 80;
30
+ /** Cuánto se espera como mucho a que se libere un lugar antes de fallar. */
31
+ const MAX_THROTTLE_WAIT_MS = 5000;
32
+
33
+ export interface WhatsAppCloudClientConfig {
34
+ phoneNumberId: string;
35
+ accessToken: string;
36
+ /** Por defecto `META_GRAPH_API_VERSION`, y si no está, `DEFAULT_GRAPH_VERSION`. */
37
+ graphVersion?: string;
38
+ /** Inyectable para tests. */
39
+ fetch?: typeof fetch;
40
+ }
41
+
42
+ export interface WhatsAppTemplate {
43
+ name: string;
44
+ language?: string;
45
+ components?: unknown[];
46
+ }
47
+
48
+ export interface WhatsAppMediaDownload {
49
+ buffer: Buffer;
50
+ mimeType: string;
51
+ fileName?: string;
52
+ }
53
+
54
+ /**
55
+ * Un error de la Cloud API con su código de Meta a mano.
56
+ *
57
+ * Los códigos importan porque cambian la decisión de quien llama: 131047 no es
58
+ * un fallo, es "se cerró la ventana de 24 h y hay que mandar una plantilla".
59
+ */
60
+ export class WhatsAppCloudError extends Error {
61
+ readonly status: number;
62
+ readonly code: number;
63
+ readonly subcode?: number;
64
+ readonly fbtraceId?: string;
65
+
66
+ constructor(
67
+ status: number,
68
+ code: number,
69
+ message: string,
70
+ extra?: { subcode?: number; fbtraceId?: string }
71
+ ) {
72
+ super(message);
73
+ this.name = "WhatsAppCloudError";
74
+ this.status = status;
75
+ this.code = code;
76
+ this.subcode = extra?.subcode;
77
+ this.fbtraceId = extra?.fbtraceId;
78
+ }
79
+
80
+ static from(status: number, payload: unknown): WhatsAppCloudError {
81
+ const error = (payload as { error?: Record<string, unknown> })?.error ?? {};
82
+ const code = Number(error.code ?? 0);
83
+ const message = String(error.message ?? `HTTP ${status}`);
84
+ return new WhatsAppCloudError(status, code, `Meta API error: ${message}`, {
85
+ subcode: error.error_subcode !== undefined ? Number(error.error_subcode) : undefined,
86
+ fbtraceId: error.fbtrace_id ? String(error.fbtrace_id) : undefined,
87
+ });
88
+ }
89
+
90
+ /** 131047: pasaron más de 24 h desde el último mensaje del cliente. */
91
+ get windowClosed(): boolean {
92
+ return this.code === 131047;
93
+ }
94
+
95
+ /** 130429: caudal de la app; 131056: demasiados mensajes al mismo destinatario. */
96
+ get rateLimited(): boolean {
97
+ return this.code === 130429 || this.code === 131056 || this.status === 429;
98
+ }
99
+
100
+ /** 190: el token expiró o fue revocado. */
101
+ get tokenExpired(): boolean {
102
+ return this.code === 190;
103
+ }
104
+
105
+ /** 131026: el destinatario no puede recibir el mensaje. */
106
+ get undeliverable(): boolean {
107
+ return this.code === 131026;
108
+ }
109
+
110
+ /** 368: la cuenta de WhatsApp Business está restringida por incumplir la política. */
111
+ get accountRestricted(): boolean {
112
+ return this.code === 368;
113
+ }
114
+
115
+ /** Si reintentar tiene sentido. Un 131047 o un 368 no se arreglan reintentando. */
116
+ get retryable(): boolean {
117
+ return this.rateLimited || this.status >= 500;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Parte un texto en trozos que Meta acepte, cortando donde menos se note:
123
+ * primero entre párrafos, después entre líneas y, sólo si no queda otra, por
124
+ * el último espacio antes del límite.
125
+ */
126
+ export function splitWhatsAppText(text: string, limit = WHATSAPP_TEXT_LIMIT): string[] {
127
+ if (text.length <= limit) return text ? [text] : [];
128
+
129
+ const chunks: string[] = [];
130
+ let pending = "";
131
+
132
+ const push = (): void => {
133
+ const trimmed = pending.trim();
134
+ if (trimmed) chunks.push(trimmed);
135
+ pending = "";
136
+ };
137
+
138
+ for (const paragraph of text.split(/\n{2,}/)) {
139
+ const candidate = pending ? `${pending}\n\n${paragraph}` : paragraph;
140
+
141
+ if (candidate.length <= limit) {
142
+ pending = candidate;
143
+ continue;
144
+ }
145
+
146
+ push();
147
+
148
+ if (paragraph.length <= limit) {
149
+ pending = paragraph;
150
+ continue;
151
+ }
152
+
153
+ // Un párrafo solo ya no entra: se parte por líneas, y una línea gigante
154
+ // (un log, una tabla) por el último espacio antes del límite.
155
+ for (const line of paragraph.split("\n")) {
156
+ const withLine = pending ? `${pending}\n${line}` : line;
157
+ if (withLine.length <= limit) {
158
+ pending = withLine;
159
+ continue;
160
+ }
161
+ push();
162
+
163
+ let rest = line;
164
+ while (rest.length > limit) {
165
+ const window = rest.slice(0, limit);
166
+ const cut = window.lastIndexOf(" ");
167
+ const at = cut > limit * 0.5 ? cut : limit;
168
+ chunks.push(rest.slice(0, at).trim());
169
+ rest = rest.slice(at).trimStart();
170
+ }
171
+ pending = rest;
172
+ }
173
+ }
174
+
175
+ push();
176
+ return chunks;
177
+ }
178
+
179
+ export class WhatsAppCloudClient {
180
+ readonly phoneNumberId: string;
181
+ readonly graphVersion: string;
182
+
183
+ private readonly accessToken: string;
184
+ // Deliberadamente más laxo que `typeof fetch`: lo que se inyecta en los tests
185
+ // no implementa extras del runtime como `preconnect`, y acá no hacen falta.
186
+ private readonly fetchImpl: (input: string, init?: RequestInit) => Promise<Response>;
187
+ private bucket = { count: 0, resetAt: 0 };
188
+
189
+ constructor(config: WhatsAppCloudClientConfig) {
190
+ this.phoneNumberId = config.phoneNumberId;
191
+ this.accessToken = config.accessToken;
192
+ this.graphVersion =
193
+ config.graphVersion ?? process.env.META_GRAPH_API_VERSION ?? DEFAULT_GRAPH_VERSION;
194
+ this.fetchImpl = config.fetch ?? ((input, init) => globalThis.fetch(input, init));
195
+ }
196
+
197
+ /** Manda un texto, partido en varios mensajes si hace falta. Devuelve los ids. */
198
+ async sendText(to: string, text: string, opts?: { previewUrl?: boolean }): Promise<string[]> {
199
+ const chunks = splitWhatsAppText(text);
200
+ const ids: string[] = [];
201
+
202
+ // En serie a propósito: en paralelo los mensajes llegarían desordenados.
203
+ for (const chunk of chunks) {
204
+ const data = await this.post<{ messages?: { id?: string }[] }>({
205
+ messaging_product: "whatsapp",
206
+ recipient_type: "individual",
207
+ to,
208
+ type: "text",
209
+ text: { preview_url: opts?.previewUrl ?? false, body: chunk },
210
+ });
211
+ const id = data.messages?.[0]?.id;
212
+ if (id) ids.push(id);
213
+ }
214
+
215
+ if (chunks.length > 1) {
216
+ log.debug(`texto de ${text.length} caracteres enviado en ${chunks.length} mensajes`);
217
+ }
218
+ return ids;
219
+ }
220
+
221
+ /** Manda una plantilla aprobada: lo único que Meta acepta fuera de la ventana. */
222
+ async sendTemplate(to: string, template: WhatsAppTemplate): Promise<string> {
223
+ const data = await this.post<{ messages?: { id?: string }[] }>({
224
+ messaging_product: "whatsapp",
225
+ recipient_type: "individual",
226
+ to,
227
+ type: "template",
228
+ template: {
229
+ name: template.name,
230
+ language: { code: template.language ?? "es" },
231
+ components: template.components ?? [],
232
+ },
233
+ });
234
+ return data.messages?.[0]?.id ?? "";
235
+ }
236
+
237
+ /** Sube el audio y lo manda como nota de voz. */
238
+ async sendAudio(to: string, audio: Buffer, mimeType = "audio/ogg"): Promise<string> {
239
+ const mediaId = await this.uploadMedia(audio, mimeType, "audio.ogg");
240
+ const data = await this.post<{ messages?: { id?: string }[] }>({
241
+ messaging_product: "whatsapp",
242
+ recipient_type: "individual",
243
+ to,
244
+ type: "audio",
245
+ audio: { id: mediaId },
246
+ });
247
+ return data.messages?.[0]?.id ?? "";
248
+ }
249
+
250
+ /**
251
+ * Marca el mensaje como leído y, si se pide, muestra "escribiendo…".
252
+ *
253
+ * Van juntos en la misma llamada porque así lo define Meta. El indicador se
254
+ * apaga solo al responder, o a los 25 segundos.
255
+ */
256
+ async markRead(messageId: string, opts?: { typing?: boolean }): Promise<void> {
257
+ await this.post({
258
+ messaging_product: "whatsapp",
259
+ status: "read",
260
+ message_id: messageId,
261
+ ...(opts?.typing ? { typing_indicator: { type: "text" } } : {}),
262
+ });
263
+ }
264
+
265
+ /** Sube un archivo y devuelve su id de medio. */
266
+ async uploadMedia(bytes: Buffer, mimeType: string, fileName = "file"): Promise<string> {
267
+ await this.throttle();
268
+
269
+ const form = new FormData();
270
+ form.append("messaging_product", "whatsapp");
271
+ form.append("type", mimeType);
272
+ form.append("file", new Blob([new Uint8Array(bytes)], { type: mimeType }), fileName);
273
+
274
+ const res = await this.fetchImpl(this.url(`${this.phoneNumberId}/media`), {
275
+ method: "POST",
276
+ headers: { Authorization: `Bearer ${this.accessToken}` },
277
+ body: form,
278
+ });
279
+ const data = (await res.json().catch(() => ({}))) as { id?: string };
280
+ if (!res.ok || !data.id) throw WhatsAppCloudError.from(res.status, data);
281
+ return data.id;
282
+ }
283
+
284
+ /**
285
+ * Baja un medio recibido. Son dos pasos: el id da una URL firmada, y esa URL
286
+ * también exige el token.
287
+ */
288
+ async downloadMedia(mediaId: string): Promise<WhatsAppMediaDownload> {
289
+ await this.throttle();
290
+ const metaRes = await this.fetchImpl(this.url(mediaId), {
291
+ headers: { Authorization: `Bearer ${this.accessToken}` },
292
+ });
293
+ const meta = (await metaRes.json().catch(() => ({}))) as {
294
+ url?: string;
295
+ mime_type?: string;
296
+ file_name?: string;
297
+ };
298
+ if (!metaRes.ok || !meta.url) throw WhatsAppCloudError.from(metaRes.status, meta);
299
+
300
+ const fileRes = await this.fetchImpl(meta.url, {
301
+ headers: { Authorization: `Bearer ${this.accessToken}` },
302
+ });
303
+ if (!fileRes.ok) {
304
+ throw new WhatsAppCloudError(fileRes.status, 0, `No se pudo bajar el medio ${mediaId}`);
305
+ }
306
+
307
+ return {
308
+ buffer: Buffer.from(await fileRes.arrayBuffer()),
309
+ mimeType: meta.mime_type ?? "application/octet-stream",
310
+ fileName: meta.file_name,
311
+ };
312
+ }
313
+
314
+ private url(path: string): string {
315
+ return `https://graph.facebook.com/${this.graphVersion}/${path}`;
316
+ }
317
+
318
+ private async post<T = unknown>(body: Record<string, unknown>): Promise<T> {
319
+ await this.throttle();
320
+
321
+ const res = await this.fetchImpl(this.url(`${this.phoneNumberId}/messages`), {
322
+ method: "POST",
323
+ headers: {
324
+ Authorization: `Bearer ${this.accessToken}`,
325
+ "Content-Type": "application/json",
326
+ },
327
+ body: JSON.stringify(body),
328
+ });
329
+
330
+ const data = (await res.json().catch(() => ({}))) as T;
331
+ if (!res.ok) throw WhatsAppCloudError.from(res.status, data);
332
+ return data;
333
+ }
334
+
335
+ /**
336
+ * Cupo por número. A diferencia del de hive-cloud, que fallaba al llenarse,
337
+ * este espera a que se abra la ventana: un texto largo sale en varios
338
+ * mensajes seguidos y no tiene por qué morir por su propio caudal.
339
+ */
340
+ private async throttle(): Promise<void> {
341
+ const deadline = Date.now() + MAX_THROTTLE_WAIT_MS;
342
+
343
+ for (;;) {
344
+ const now = Date.now();
345
+ if (now >= this.bucket.resetAt) {
346
+ this.bucket = { count: 0, resetAt: now + RATE_WINDOW_MS };
347
+ }
348
+ if (this.bucket.count < MAX_REQUESTS_PER_WINDOW) {
349
+ this.bucket.count++;
350
+ return;
351
+ }
352
+ if (now >= deadline) {
353
+ throw new WhatsAppCloudError(
354
+ 429,
355
+ 130429,
356
+ `Caudal propio superado para el número ${this.phoneNumberId}`
357
+ );
358
+ }
359
+ await new Promise((resolve) => setTimeout(resolve, this.bucket.resetAt - now));
360
+ }
361
+ }
362
+ }
363
+
364
+ export function createWhatsAppCloudClient(
365
+ config: WhatsAppCloudClientConfig
366
+ ): WhatsAppCloudClient {
367
+ return new WhatsAppCloudClient(config);
368
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./client.ts";
2
+ export * from "./webhook.ts";
3
+ export * from "./channel.ts";