@mafesoftware/kapso-wa 0.1.1 → 0.2.0

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 6ad0993: Webhooks, media, plantillas y reconexión: lo que consult360 y ediflow escribían a mano contra Kapso.
8
+
9
+ - `verificarFirmaWebhook(cuerpoCrudo, firmaHex, secreto)`: HMAC-SHA256 en hex sobre el cuerpo crudo contra `X-Webhook-Signature`, en tiempo constante (`node:crypto`, como `mercadopago-ar`). Sin secreto o sin firma da `false`.
10
+ - `leerEventoWebhook(crudo, nombreEvento?)`: el segundo argumento es la cabecera `X-Webhook-Event` y manda sobre `cuerpo.event`/`cuerpo.type`. **Kapso no pone el nombre del evento en el cuerpo v2**: sin la cabecera, un mensaje real sale `"ignorado"`. Llamarla sin el segundo argumento sigue funcionando igual que antes.
11
+ - `leerEventosWebhook(crudo, nombreEvento?)`: desarma el sobre de lote (`batch: true, data: [...]`) que manda Kapso con buffering. `leerEventoWebhook` con un lote ahora devuelve `"ignorado"` diciendo que se use ésta, en vez de leerlo mal.
12
+ - Mensajes entrantes: `tipo` suma `"imagen" | "video" | "documento" | "audio" | "ubicacion"`, con `media?: { id, mimeType?, nombreArchivo?, url? }` (`url` = `message.kapso.media_url`), el epígrafe en `texto` y la ubicación como `"lat,lng"`. Se agrega `nombreContacto?` (`conversation.contact_name`). El botón de respuesta rápida de una plantilla (`type: "button"`) sale como `"boton"` con su payload. Si falta `from`, el remitente sale de `conversation.phone_number`.
13
+ - Estados: `failed` trae `error?: { codigo?, titulo?, mensaje? }` del último elemento de `message.kapso.statuses`. La `fechaHora` de un estado v2 ahora sale del historial o de `message.timestamp` (antes, con el payload v2, quedaba en "ahora").
14
+ - Conexión de número: lee `customer.id` del payload v2 (`{ phone_number_id, customer: { id, external_id } }`); antes solo miraba `customer_id` suelto y el evento real salía `"ignorado"`. Suma `idExterno?` (`customer.external_id`).
15
+ - `bajarMedia(cred, mediaId)`: `GET /{media_id}?phone_number_id=…` → `download_url` (4 minutos, auth incluida) → bytes, sin mandar la clave al segundo paso.
16
+ - `registrarWebhookNumero(cred, phoneNumberId, { url, secreto, eventos? })` (kind `kapso`, por defecto `EVENTOS_WEBHOOK_NUMERO`: received/delivered/read/failed) y `listarWebhooksNumero(cred, phoneNumberId)`.
17
+ - `crearPlantilla(cred, wabaId, definicion)` y `listarPlantillas(cred, wabaId, opciones?)` por el proxy de Meta (`/{waba_id}/message_templates`), con nombre y estado.
18
+ - `crearSetupLink` suma `reconectarTelefono` (`reconnect_phone_number`); en ese caso no manda `allowed_connection_types` (Kapso lo fija al de la conexión existente y da 422 si no coincide) ni `meta_billing_mode` salvo que se pase.
19
+ - `crearCliente`: se documenta que cada app tiene que pasar su prefijo; el default `"gestionflow"` queda solo por compatibilidad.
20
+ - Nuevo tipo exportado `CredencialesPlataforma` (`{ apiKey, fetch?, timeoutMs? }`).
21
+
22
+ **Cambio de comportamiento a revisar al actualizar**: un audio, una imagen, un video o un documento entrante antes salía `tipo: "otro"` y ahora sale con su propio tipo. Una app que filtraba media con `m.tipo === "otro"` (GestionFlow lo hace para no contestarle a una nota de voz) tiene que pasar a filtrar por `m.tipo !== "texto"` o similar.
23
+
24
+ ## 0.1.2
25
+
26
+ ### Patch Changes
27
+
28
+ - Agrega la condición `"default"` a cada entrada de `exports` (raíz y subpaths,
29
+ como `/drizzle` o `/next`), justo después de `"import"`.
30
+
31
+ Sin esto, `drizzle-kit generate` (y cualquier otro loader que resuelva vía
32
+ CJS, incluido `require(esm)` de Node ≥22) fallaba con
33
+ `ERR_PACKAGE_PATH_NOT_EXPORTED` al importar, por ejemplo,
34
+ `@mafesoftware/tenant/drizzle` desde un `schema.ts`: el `exports` map solo
35
+ tenía condiciones `types` e `import`, y ninguna que un resolver CJS supiera
36
+ interpretar.
37
+
38
+ `"default"` apunta al mismo archivo `.js` que `"import"` — el paquete sigue
39
+ siendo ESM puro, no se agrega ningún build CJS — pero al ser la condición de
40
+ más baja prioridad, un loader que no entiende `"import"` cae en ella igual.
41
+
42
+ Sin cambios de API pública.
43
+
3
44
  ## 0.1.1
4
45
 
5
46
  ### Patch Changes
package/README.md CHANGED
@@ -65,26 +65,94 @@ dentroDeVentana24h(ultimaVezQueEscribio); // true | false
65
65
  import { crearCliente, crearSetupLink } from "@mafesoftware/kapso-wa";
66
66
 
67
67
  const cred = { apiKey: "kapso_..." };
68
- const r = await crearCliente(cred, "Club Náutico", clubId);
68
+ // Cada app pasa SU prefijo: el proyecto de Kapso es uno solo para todo MAFE
69
+ // Software, y el default "gestionflow" queda solo por compatibilidad.
70
+ const r = await crearCliente(cred, "Club Náutico", clubId, "padel360");
69
71
  if (r.ok) {
70
72
  const link = await crearSetupLink(cred, r.cliente.id, { volverBienA: "https://app/ok" });
71
73
  if (link.ok) redirigirA(link.url); // el club conecta su WhatsApp ahí
72
74
  }
75
+
76
+ // Si la credencial de un número ya conectado se rompió, un link atado a ESE número:
77
+ await crearSetupLink(cred, clienteId, { reconectarTelefono: "+5491145678901" });
78
+ ```
79
+
80
+ ### Webhook del número
81
+
82
+ Los mensajes llegan por un webhook **de cada número** (el de proyecto solo trae
83
+ conexiones). Se registra una vez, al conectarse el número; el secreto lo elige
84
+ la app.
85
+
86
+ ```ts
87
+ import { registrarWebhookNumero, listarWebhooksNumero, EVENTOS_WEBHOOK_NUMERO } from "@mafesoftware/kapso-wa";
88
+
89
+ const cred = { apiKey: "kapso_..." };
90
+ const ya = await listarWebhooksNumero(cred, phoneNumberId);
91
+ if (ya.ok && !ya.webhooks.some((w) => w.url === urlWebhook)) {
92
+ await registrarWebhookNumero(cred, phoneNumberId, { url: urlWebhook, secreto: secretoWebhook });
93
+ // eventos por defecto: EVENTOS_WEBHOOK_NUMERO (received, delivered, read, failed)
94
+ }
73
95
  ```
74
96
 
75
97
  ### Webhook entrante
76
98
 
77
99
  ```ts
78
- import { leerEventoWebhook } from "@mafesoftware/kapso-wa";
100
+ import { verificarFirmaWebhook, leerEventosWebhook, leerEventoWebhook } from "@mafesoftware/kapso-wa";
101
+
102
+ export async function POST(request: Request) {
103
+ const crudo = await request.text(); // el cuerpo CRUDO: re-serializarlo rompe la firma
104
+ if (!verificarFirmaWebhook(crudo, request.headers.get("x-webhook-signature"), secretoWebhook)) {
105
+ return new Response("firma inválida", { status: 401 });
106
+ }
107
+ // El nombre del evento viene en la CABECERA, no en el cuerpo.
108
+ const nombre = request.headers.get("x-webhook-event");
109
+ // Con buffering, Kapso manda un lote ({ batch: true, data: [...] }): leerEventosWebhook lo desarma.
110
+ for (const evento of leerEventosWebhook(JSON.parse(crudo), nombre)) {
111
+ if (evento.tipo === "mensaje") {
112
+ // evento.mensaje: { tipo, de, phoneNumberId, texto, payload?, media?, nombreContacto?, mensajeId, fechaHora }
113
+ } else if (evento.tipo === "estado" && evento.estado === "failed") {
114
+ // evento.error?: { codigo: 131047, titulo, mensaje }
115
+ } else if (evento.tipo === "numero_conectado") {
116
+ // evento.clienteId (Kapso), evento.idExterno ("<prefijo>:<id>")
117
+ }
118
+ // "ignorado": de otra app o que no se reconoce — el webhook igual contesta 200
119
+ }
120
+ return new Response("ok");
121
+ }
122
+
123
+ // Un evento suelto (sin buffering) también se puede leer de a uno:
124
+ const evento = leerEventoWebhook(JSON.parse(crudo), request.headers.get("x-webhook-event"));
125
+ ```
79
126
 
80
- const evento = leerEventoWebhook(cuerpoDelWebhook); // nunca tira
81
- if (evento.tipo === "mensaje") {
82
- // evento.mensaje: { tipo, de, phoneNumberId, texto, payload?, mensajeId, fechaHora }
83
- } else if (evento.tipo === "ignorado") {
84
- // evento de otra aplicación, o que no se reconoce — el webhook igual contesta 200
127
+ ### Media entrante
128
+
129
+ ```ts
130
+ import { bajarMedia } from "@mafesoftware/kapso-wa";
131
+
132
+ if (evento.tipo === "mensaje" && evento.mensaje.media) {
133
+ // `media.url` (message.kapso.media_url) suele venir ya listo; por id:
134
+ const r = await bajarMedia({ apiKey: "kapso_...", phoneNumberId }, evento.mensaje.media.id);
135
+ if (r.ok) await guardar(r.bytes, r.mimeType); // Uint8Array
85
136
  }
86
137
  ```
87
138
 
139
+ ### Plantillas
140
+
141
+ ```ts
142
+ import { crearPlantilla, listarPlantillas } from "@mafesoftware/kapso-wa";
143
+
144
+ const cred = { apiKey: "kapso_..." };
145
+ const r = await crearPlantilla(cred, wabaId, {
146
+ nombre: "ef_expensa_vence",
147
+ categoria: "UTILITY",
148
+ componentes: [{ type: "BODY", text: "Hola {{1}}, tu expensa vence el {{2}}.", example: { body_text: [["Juana", "10/11"]] } }],
149
+ });
150
+ // r.plantilla.estado === "PENDING" hasta que Meta la revisa
151
+
152
+ const lista = await listarPlantillas(cred, wabaId, { estado: "APPROVED" });
153
+ if (lista.ok) lista.plantillas.map((p) => `${p.nombre}: ${p.estado}`);
154
+ ```
155
+
88
156
  ## Probar
89
157
 
90
158
  ```bash
package/dist/index.d.ts CHANGED
@@ -31,14 +31,17 @@
31
31
  */
32
32
  /** Un `fetch` compatible. Se inyecta en los tests para no salir a la red. */
33
33
  export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
34
- export type Credenciales = {
34
+ /** Lo que necesita una llamada a la API de plataforma (clientes, webhooks, plantillas). */
35
+ export type CredencialesPlataforma = {
35
36
  apiKey: string;
36
- /** El número desde el que se manda. Es un dato del CLUB, no del deployment. */
37
- phoneNumberId: string;
38
37
  fetch?: FetchLike;
39
38
  /** Milisegundos antes de cortar. 15 s por defecto. */
40
39
  timeoutMs?: number;
41
40
  };
41
+ export type Credenciales = CredencialesPlataforma & {
42
+ /** El número desde el que se manda. Es un dato del CLUB, no del deployment. */
43
+ phoneNumberId: string;
44
+ };
42
45
  /**
43
46
  * Por qué falló, y sobre todo **si reintentar sirve**.
44
47
  *
@@ -135,12 +138,14 @@ export type ClienteKapso = {
135
138
  * `externalCustomerId` lleva el prefijo de la aplicación
136
139
  * (`gestionflow:<clubId>`) porque el webhook de conexión trae **solo** el id de
137
140
  * cliente y nunca el producto: sin prefijo no hay forma de saber de quién es.
141
+ *
142
+ * **Cada aplicación tiene que pasar SU prefijo** (`"consult360"`, `"ediflow"`…).
143
+ * El default `"gestionflow"` queda solo por compatibilidad con la primera app
144
+ * que usó el paquete: otra app que lo omita da de alta a sus clientes como si
145
+ * fueran de GestionFlow, y como el proyecto de Kapso es uno solo para todo MAFE
146
+ * Software, después no hay forma de separarlos.
138
147
  */
139
- export declare function crearCliente(cred: {
140
- apiKey: string;
141
- fetch?: FetchLike;
142
- timeoutMs?: number;
143
- }, nombre: string, clubId: string, prefijo?: string): Promise<{
148
+ export declare function crearCliente(cred: CredencialesPlataforma, nombre: string, clubId: string, prefijo?: string): Promise<{
144
149
  ok: true;
145
150
  cliente: ClienteKapso;
146
151
  } | {
@@ -170,17 +175,26 @@ export declare function crearCliente(cred: {
170
175
  * Solo hay **un link activo por cliente**: crear otro revoca el anterior. Y
171
176
  * sobre un número YA conectado no se emite un link nuevo — puede intentar una
172
177
  * segunda conexión y arruinar la que funciona.
178
+ *
179
+ * ## Reconectar un número que se cayó
180
+ *
181
+ * La excepción es `reconectarTelefono` (`reconnect_phone_number`, el número
182
+ * como se muestra, p. ej. `"+5491145678901"`): cuando la credencial se rompió
183
+ * (token revocado, cambio de contraseña), el link queda atado a ESE número y a
184
+ * su WABA, y solo renueva la credencial. Kapso fija ahí el tipo de conexión al
185
+ * de la conexión existente y contesta 422 si se le manda otro, así que en ese
186
+ * caso **no se manda `allowed_connection_types`**, y tampoco
187
+ * `meta_billing_mode` salvo que se pase explícito (es de la WABA y no cambia).
188
+ * Si el número no es de ese cliente, también es 422.
173
189
  */
174
- export declare function crearSetupLink(cred: {
175
- apiKey: string;
176
- fetch?: FetchLike;
177
- timeoutMs?: number;
178
- }, clienteId: string, opciones?: {
190
+ export declare function crearSetupLink(cred: CredencialesPlataforma, clienteId: string, opciones?: {
179
191
  metaBilling?: "customer_managed" | "partner_managed";
180
192
  volverBienA?: string;
181
193
  volverMalA?: string;
182
194
  colorPrimario?: string;
183
195
  idioma?: string;
196
+ /** Número ya conectado de este cliente cuya credencial hay que renovar. */
197
+ reconectarTelefono?: string;
184
198
  }): Promise<{
185
199
  ok: true;
186
200
  url: string;
@@ -190,18 +204,202 @@ export declare function crearSetupLink(cred: {
190
204
  categoria: CategoriaError;
191
205
  error: string;
192
206
  }>;
207
+ /**
208
+ * Los eventos que un número le manda a la aplicación por defecto.
209
+ *
210
+ * Uno por estado: no existe `whatsapp.message.status_updated`. `sent` no va
211
+ * porque eso ya lo dice la respuesta del envío.
212
+ */
213
+ export declare const EVENTOS_WEBHOOK_NUMERO: readonly ["whatsapp.message.received", "whatsapp.message.delivered", "whatsapp.message.read", "whatsapp.message.failed"];
214
+ export type WebhookNumero = {
215
+ id: string;
216
+ url: string;
217
+ eventos: string[];
218
+ activo: boolean;
219
+ };
220
+ /**
221
+ * Registra el webhook de un número (`kind: "kapso"`).
222
+ *
223
+ * Los mensajes se rutean **por número**, no por proyecto: el webhook de
224
+ * proyecto solo trae conexiones (`whatsapp.phone_number.*`). Por eso cada
225
+ * número que se conecta registra el suyo apuntando a la aplicación dueña, y un
226
+ * mensaje al número de un club nunca termina en otra app del mismo proyecto.
227
+ *
228
+ * El `secreto` es obligatorio y **Kapso no lo genera**: lo elige la app. Con uno
229
+ * solo para todos sus números, `verificarFirmaWebhook` usa un único secreto.
230
+ *
231
+ * Registrar dos veces la misma URL deja dos webhooks y cada evento llega
232
+ * duplicado: mirar antes con `listarWebhooksNumero`.
233
+ */
234
+ export declare function registrarWebhookNumero(cred: CredencialesPlataforma, phoneNumberId: string, webhook: {
235
+ url: string;
236
+ secreto: string;
237
+ eventos?: readonly string[];
238
+ }): Promise<{
239
+ ok: true;
240
+ webhook: WebhookNumero;
241
+ } | {
242
+ ok: false;
243
+ categoria: CategoriaError;
244
+ error: string;
245
+ estado?: number;
246
+ }>;
247
+ /**
248
+ * Los webhooks de un número, el más nuevo primero.
249
+ *
250
+ * Devuelve el arreglo ya desenvuelto del `{ data: [...] }` de Kapso: leer
251
+ * `.data.data` daba `undefined` y la lista se veía vacía siempre.
252
+ */
253
+ export declare function listarWebhooksNumero(cred: CredencialesPlataforma, phoneNumberId: string): Promise<{
254
+ ok: true;
255
+ webhooks: WebhookNumero[];
256
+ } | {
257
+ ok: false;
258
+ categoria: CategoriaError;
259
+ error: string;
260
+ estado?: number;
261
+ }>;
262
+ export type CategoriaPlantilla = "UTILITY" | "MARKETING" | "AUTHENTICATION";
263
+ export type Plantilla = {
264
+ id: string;
265
+ nombre: string;
266
+ idioma?: string;
267
+ /** `APPROVED`, `PENDING`, `REJECTED`… tal cual lo dice Meta. */
268
+ estado: string;
269
+ categoria?: string;
270
+ };
271
+ /**
272
+ * Da de alta una plantilla en la WABA del club. Queda `PENDING` hasta que Meta
273
+ * la revisa (minutos a horas); recién `APPROVED` se puede mandar.
274
+ *
275
+ * `componentes` es el arreglo `components` de Meta tal cual (`BODY`, `HEADER`,
276
+ * `FOOTER`, `BUTTONS`), con sus `example`: Meta rechaza un cuerpo con
277
+ * parámetros sin ejemplo. El nombre lleva el prefijo de la app (ver el
278
+ * encabezado del módulo).
279
+ *
280
+ * La WABA es del club, así que esto se corre **una vez por club** al conectar
281
+ * su número, no una vez por aplicación.
282
+ */
283
+ export declare function crearPlantilla(cred: CredencialesPlataforma, wabaId: string, definicion: {
284
+ nombre: string;
285
+ idioma?: string;
286
+ categoria: CategoriaPlantilla;
287
+ componentes: readonly unknown[];
288
+ formatoParametros?: "POSITIONAL" | "NAMED";
289
+ }): Promise<{
290
+ ok: true;
291
+ plantilla: Plantilla;
292
+ } | {
293
+ ok: false;
294
+ categoria: CategoriaError;
295
+ error: string;
296
+ estado?: number;
297
+ }>;
298
+ /**
299
+ * Las plantillas de una WABA con su estado. Sirve para saber si una ya está
300
+ * aprobada antes de mandarla, o si hay que darla de alta.
301
+ *
302
+ * Trae hasta `limite` (100 por defecto, el máximo de Meta). Si hay más,
303
+ * `siguiente` es el cursor para pedir la página que sigue con `despues`.
304
+ */
305
+ export declare function listarPlantillas(cred: CredencialesPlataforma, wabaId: string, opciones?: {
306
+ nombre?: string;
307
+ estado?: string;
308
+ limite?: number;
309
+ despues?: string;
310
+ }): Promise<{
311
+ ok: true;
312
+ plantillas: Plantilla[];
313
+ siguiente?: string;
314
+ } | {
315
+ ok: false;
316
+ categoria: CategoriaError;
317
+ error: string;
318
+ estado?: number;
319
+ }>;
320
+ /**
321
+ * Baja lo adjunto a un mensaje entrante (foto de un comprobante, audio…).
322
+ *
323
+ * Son dos pasos: `GET /{media_id}?phone_number_id=…` devuelve los datos de Meta
324
+ * más un `download_url` de Kapso con la autenticación adentro (vale **4
325
+ * minutos**), y después se baja ese `download_url` sin la clave. El `url` de
326
+ * Meta que viene al lado NO sirve: pide el token de Meta, que no tenemos.
327
+ *
328
+ * En un evento de Kapso suele venir además `mensaje.media.url`
329
+ * (`message.kapso.media_url`), que ya está espejado; esto es para cuando solo
330
+ * se tiene el id, o para bajarlo más tarde.
331
+ */
332
+ export declare function bajarMedia(cred: Credenciales, mediaId: string): Promise<{
333
+ ok: true;
334
+ bytes: Uint8Array;
335
+ mimeType: string;
336
+ nombreArchivo?: string;
337
+ } | {
338
+ ok: false;
339
+ categoria: CategoriaError;
340
+ error: string;
341
+ estado?: number;
342
+ }>;
343
+ /**
344
+ * ¿Este cuerpo lo mandó Kapso de verdad?
345
+ *
346
+ * Kapso firma cada entrega con HMAC-SHA256 sobre el **cuerpo crudo**, en hex,
347
+ * con el `secret_key` que se eligió al registrar el webhook, y lo manda en la
348
+ * cabecera `X-Webhook-Signature`.
349
+ *
350
+ * **Crudo** quiere decir los bytes tal como llegaron (`await request.text()`),
351
+ * no un `JSON.stringify` del objeto ya parseado: cualquier diferencia de orden
352
+ * de claves o de escapes rompe la firma, y lo rompe de a ratos, que es la peor
353
+ * forma de romperse.
354
+ *
355
+ * Sin secreto o sin firma el resultado es `false`, nunca "dejar pasar": un
356
+ * webhook sin verificar deja que cualquiera POSTee "el socio 1042 pregunta por
357
+ * su deuda" haciéndose pasar por su teléfono. Decidir qué hacer en desarrollo
358
+ * es de la aplicación (el paquete no mira `NODE_ENV`).
359
+ *
360
+ * Usa `node:crypto` como `verificarFirmaWebhook` de `@mafesoftware/mercadopago-ar`:
361
+ * sincrónico, y corre en Node ≥ 20 y en las funciones de Vercel. La comparación
362
+ * es en tiempo constante.
363
+ */
364
+ export declare function verificarFirmaWebhook(cuerpoCrudo: string | Uint8Array, firmaHex: string | null | undefined, secreto: string): boolean;
365
+ /** Lo adjunto a un mensaje entrante: imagen, video, documento o audio. */
366
+ export type MediaEntrante = {
367
+ /** El id de Meta. Con esto se baja por `bajarMedia`. */
368
+ id: string;
369
+ mimeType?: string;
370
+ nombreArchivo?: string;
371
+ /**
372
+ * `message.kapso.media_url`: Kapso ya lo espejó y lo deja listo para bajar
373
+ * sin pasar por `bajarMedia`. Puede no venir (p. ej. en un webhook `meta`).
374
+ */
375
+ url?: string;
376
+ };
193
377
  export type MensajeEntrante = {
194
- tipo: "texto" | "boton" | "opcion_lista" | "otro";
378
+ tipo: "texto" | "boton" | "opcion_lista" | "imagen" | "video" | "documento" | "audio" | "ubicacion" | "otro";
195
379
  /** El número que escribió, en E.164 sin `+`. */
196
380
  de: string;
197
381
  phoneNumberId: string;
198
- /** El texto, o el título del botón/opción que tocaron. */
382
+ /**
383
+ * El texto; el título del botón/opción que tocaron; el epígrafe de una
384
+ * imagen, video o documento (vacío si no tiene); o `"lat,lng"` en una
385
+ * ubicación.
386
+ */
199
387
  texto: string;
200
388
  /** El `id` que le pusimos al botón o a la opción. Es nuestro, no de Meta. */
201
389
  payload?: string;
390
+ /** Solo en imagen, video, documento y audio. */
391
+ media?: MediaEntrante;
392
+ /** `conversation.contact_name`: el nombre de perfil de WhatsApp, si Kapso lo sabe. */
393
+ nombreContacto?: string;
202
394
  mensajeId: string;
203
395
  fechaHora: Date;
204
396
  };
397
+ /** El error que Meta adjunta a un estado `failed` (p. ej. 131047, fuera de la ventana). */
398
+ export type ErrorDeEstado = {
399
+ codigo?: number;
400
+ titulo?: string;
401
+ mensaje?: string;
402
+ };
205
403
  export type EventoWebhook = {
206
404
  tipo: "mensaje";
207
405
  mensaje: MensajeEntrante;
@@ -210,20 +408,59 @@ export type EventoWebhook = {
210
408
  mensajeId: string;
211
409
  estado: string;
212
410
  fechaHora: Date;
411
+ error?: ErrorDeEstado;
213
412
  } | {
214
413
  tipo: "numero_conectado";
414
+ /** El id de cliente de Kapso (`customer.id`), el que devolvió `crearCliente`. */
215
415
  clienteId: string;
216
416
  phoneNumberId: string;
217
417
  telefono?: string;
418
+ /** `customer.external_id`: el `<prefijo>:<id>` que se le puso en `crearCliente`. */
419
+ idExterno?: string;
218
420
  } | {
219
421
  tipo: "numero_desconectado";
220
422
  clienteId: string;
221
423
  phoneNumberId: string;
424
+ idExterno?: string;
222
425
  } | {
223
426
  tipo: "ignorado";
224
427
  motivo: string;
225
428
  };
226
- export declare function leerEventoWebhook(crudo: unknown): EventoWebhook;
429
+ /**
430
+ * Lee UN evento del webhook. **Nunca tira.**
431
+ *
432
+ * **El nombre del evento viaja en la cabecera `X-Webhook-Event`, no en el
433
+ * cuerpo**: el cuerpo v2 de un mensaje es `{ message, conversation,
434
+ * phone_number_id }` y no dice qué evento es. Por eso el segundo argumento —
435
+ * pasarle `request.headers.get("x-webhook-event")` — manda sobre
436
+ * `cuerpo.event`/`cuerpo.type`, que quedan solo como respaldo. Sin la cabecera,
437
+ * un mensaje real de Kapso sale `"ignorado"`.
438
+ *
439
+ * Lo que no se entiende sale como `"ignorado"` y **el webhook igual contesta
440
+ * 200**. Si contestara error, Kapso reintenta el mismo cuerpo y los eventos que
441
+ * sí importan se quedan atrás en la cola.
442
+ *
443
+ * Un evento de un cliente que no es nuestro también es `"ignorado"`: el webhook
444
+ * de proyecto dispara para TODAS las aplicaciones que comparten el proyecto, y
445
+ * filtrar es responsabilidad de cada una.
446
+ *
447
+ * Un lote (`batch: true`, con buffering prendido) no se lee acá: sale
448
+ * `"ignorado"` diciendo que se use `leerEventosWebhook`, para no perder en
449
+ * silencio todos los mensajes menos uno.
450
+ */
451
+ export declare function leerEventoWebhook(crudo: unknown, nombreEvento?: string | null): EventoWebhook;
452
+ /**
453
+ * Lee TODO lo que trae una entrega del webhook, sea un evento suelto o un lote.
454
+ *
455
+ * Con buffering prendido para `whatsapp.message.received`, Kapso manda
456
+ * **siempre** un sobre `{ type, batch: true, data: [...], batch_info }`, aunque
457
+ * traiga un solo mensaje. Cada elemento de `data` tiene la misma forma que un
458
+ * evento suelto. Sin buffering, devuelve un arreglo de uno.
459
+ *
460
+ * El nombre del evento sale de la cabecera `X-Webhook-Event` si se pasa, y si
461
+ * no del `type` del sobre. Nunca tira.
462
+ */
463
+ export declare function leerEventosWebhook(crudo: unknown, nombreEvento?: string | null): EventoWebhook[];
227
464
  /**
228
465
  * ¿Se le puede mandar texto libre a esta persona?
229
466
  *
package/dist/index.js CHANGED
@@ -29,6 +29,7 @@
29
29
  * esa clave abre TODOS los números del proyecto, así que una aplicación toca
30
30
  * únicamente los números de sus propios clientes.
31
31
  */
32
+ import { createHmac, timingSafeEqual } from "node:crypto";
32
33
  const BASE_META = "https://api.kapso.ai/meta/whatsapp/v24.0";
33
34
  const BASE_PLATAFORMA = "https://api.kapso.ai/platform/v1";
34
35
  /* ============================================================
@@ -239,6 +240,12 @@ async function postMensaje(cred, cuerpo) {
239
240
  * `externalCustomerId` lleva el prefijo de la aplicación
240
241
  * (`gestionflow:<clubId>`) porque el webhook de conexión trae **solo** el id de
241
242
  * cliente y nunca el producto: sin prefijo no hay forma de saber de quién es.
243
+ *
244
+ * **Cada aplicación tiene que pasar SU prefijo** (`"consult360"`, `"ediflow"`…).
245
+ * El default `"gestionflow"` queda solo por compatibilidad con la primera app
246
+ * que usó el paquete: otra app que lo omita da de alta a sus clientes como si
247
+ * fueran de GestionFlow, y como el proyecto de Kapso es uno solo para todo MAFE
248
+ * Software, después no hay forma de separarlos.
242
249
  */
243
250
  export async function crearCliente(cred, nombre, clubId, prefijo = "gestionflow") {
244
251
  const r = await pedir(cred, `${BASE_PLATAFORMA}/customers`, {
@@ -280,6 +287,17 @@ export async function crearCliente(cred, nombre, clubId, prefijo = "gestionflow"
280
287
  * Solo hay **un link activo por cliente**: crear otro revoca el anterior. Y
281
288
  * sobre un número YA conectado no se emite un link nuevo — puede intentar una
282
289
  * segunda conexión y arruinar la que funciona.
290
+ *
291
+ * ## Reconectar un número que se cayó
292
+ *
293
+ * La excepción es `reconectarTelefono` (`reconnect_phone_number`, el número
294
+ * como se muestra, p. ej. `"+5491145678901"`): cuando la credencial se rompió
295
+ * (token revocado, cambio de contraseña), el link queda atado a ESE número y a
296
+ * su WABA, y solo renueva la credencial. Kapso fija ahí el tipo de conexión al
297
+ * de la conexión existente y contesta 422 si se le manda otro, así que en ese
298
+ * caso **no se manda `allowed_connection_types`**, y tampoco
299
+ * `meta_billing_mode` salvo que se pase explícito (es de la WABA y no cambia).
300
+ * Si el número no es de ese cliente, también es 422.
283
301
  */
284
302
  export async function crearSetupLink(cred, clienteId, opciones = {}) {
285
303
  const r = await pedir(cred, `${BASE_PLATAFORMA}/customers/${encodeURIComponent(clienteId)}/setup_links`, {
@@ -287,8 +305,15 @@ export async function crearSetupLink(cred, clienteId, opciones = {}) {
287
305
  body: JSON.stringify({
288
306
  setup_link: {
289
307
  language: opciones.idioma ?? "es",
290
- allowed_connection_types: ["coexistence"],
291
- meta_billing_mode: opciones.metaBilling ?? "customer_managed",
308
+ ...(opciones.reconectarTelefono
309
+ ? {
310
+ reconnect_phone_number: opciones.reconectarTelefono,
311
+ ...(opciones.metaBilling ? { meta_billing_mode: opciones.metaBilling } : {}),
312
+ }
313
+ : {
314
+ allowed_connection_types: ["coexistence"],
315
+ meta_billing_mode: opciones.metaBilling ?? "customer_managed",
316
+ }),
292
317
  ...(opciones.volverBienA ? { success_redirect_url: opciones.volverBienA } : {}),
293
318
  ...(opciones.volverMalA ? { failure_redirect_url: opciones.volverMalA } : {}),
294
319
  ...(opciones.colorPrimario ? { theme_config: { primary_color: opciones.colorPrimario } } : {}),
@@ -303,17 +328,242 @@ export async function crearSetupLink(cred, clienteId, opciones = {}) {
303
328
  return { ok: false, categoria: "rechazado", error: "Kapso no devolvió una URL" };
304
329
  return { ok: true, url: fila.url, expira: fila.expires_at };
305
330
  }
331
+ /* ============================================================
332
+ WEBHOOKS DEL NÚMERO
333
+ ============================================================ */
306
334
  /**
307
- * Lee un evento del webhook. **Nunca tira.**
335
+ * Los eventos que un número le manda a la aplicación por defecto.
308
336
  *
309
- * Lo que no se entiende sale como `"ignorado"` y **el webhook igual contesta
310
- * 200**. Si contestara error, Kapso reintenta el mismo cuerpo para siempre y
311
- * los eventos que sí importan se quedan atrás en la cola.
337
+ * Uno por estado: no existe `whatsapp.message.status_updated`. `sent` no va
338
+ * porque eso ya lo dice la respuesta del envío.
339
+ */
340
+ export const EVENTOS_WEBHOOK_NUMERO = [
341
+ "whatsapp.message.received",
342
+ "whatsapp.message.delivered",
343
+ "whatsapp.message.read",
344
+ "whatsapp.message.failed",
345
+ ];
346
+ /**
347
+ * Registra el webhook de un número (`kind: "kapso"`).
312
348
  *
313
- * Un evento de un cliente que no es nuestro también es `"ignorado"`: el webhook
314
- * de proyecto dispara para TODAS las aplicaciones que comparten el proyecto, y
315
- * filtrar es responsabilidad de cada una.
349
+ * Los mensajes se rutean **por número**, no por proyecto: el webhook de
350
+ * proyecto solo trae conexiones (`whatsapp.phone_number.*`). Por eso cada
351
+ * número que se conecta registra el suyo apuntando a la aplicación dueña, y un
352
+ * mensaje al número de un club nunca termina en otra app del mismo proyecto.
353
+ *
354
+ * El `secreto` es obligatorio y **Kapso no lo genera**: lo elige la app. Con uno
355
+ * solo para todos sus números, `verificarFirmaWebhook` usa un único secreto.
356
+ *
357
+ * Registrar dos veces la misma URL deja dos webhooks y cada evento llega
358
+ * duplicado: mirar antes con `listarWebhooksNumero`.
359
+ */
360
+ export async function registrarWebhookNumero(cred, phoneNumberId, webhook) {
361
+ if (!phoneNumberId)
362
+ return { ok: false, categoria: "numero", error: "falta el phoneNumberId" };
363
+ if (!webhook.url)
364
+ return { ok: false, categoria: "rechazado", error: "falta la URL del webhook" };
365
+ if (!webhook.secreto)
366
+ return { ok: false, categoria: "credenciales", error: "falta el secreto del webhook" };
367
+ const eventos = webhook.eventos?.length ? [...webhook.eventos] : [...EVENTOS_WEBHOOK_NUMERO];
368
+ const r = await pedir(cred, `${BASE_PLATAFORMA}/whatsapp/phone_numbers/${encodeURIComponent(phoneNumberId)}/webhooks`, {
369
+ method: "POST",
370
+ body: JSON.stringify({
371
+ whatsapp_webhook: { kind: "kapso", url: webhook.url, events: eventos, secret_key: webhook.secreto, active: true },
372
+ }),
373
+ });
374
+ if (!r.ok)
375
+ return r;
376
+ const d = r.datos;
377
+ const fila = aWebhook(d?.data ?? d);
378
+ if (!fila)
379
+ return { ok: false, categoria: "rechazado", error: "Kapso no devolvió un id de webhook" };
380
+ return { ok: true, webhook: fila };
381
+ }
382
+ /**
383
+ * Los webhooks de un número, el más nuevo primero.
384
+ *
385
+ * Devuelve el arreglo ya desenvuelto del `{ data: [...] }` de Kapso: leer
386
+ * `.data.data` daba `undefined` y la lista se veía vacía siempre.
387
+ */
388
+ export async function listarWebhooksNumero(cred, phoneNumberId) {
389
+ if (!phoneNumberId)
390
+ return { ok: false, categoria: "numero", error: "falta el phoneNumberId" };
391
+ const r = await pedir(cred, `${BASE_PLATAFORMA}/whatsapp/phone_numbers/${encodeURIComponent(phoneNumberId)}/webhooks`, {
392
+ method: "GET",
393
+ });
394
+ if (!r.ok)
395
+ return r;
396
+ const d = r.datos;
397
+ const filas = Array.isArray(d?.data) ? d.data : Array.isArray(d) ? d : [];
398
+ return { ok: true, webhooks: filas.map(aWebhook).filter((w) => w !== null) };
399
+ }
400
+ function aWebhook(crudo) {
401
+ if (!crudo?.id)
402
+ return null;
403
+ return {
404
+ id: String(crudo.id),
405
+ url: String(crudo.url ?? ""),
406
+ eventos: Array.isArray(crudo.events) ? crudo.events.map(String) : [],
407
+ activo: crudo.active !== false,
408
+ };
409
+ }
410
+ /**
411
+ * Da de alta una plantilla en la WABA del club. Queda `PENDING` hasta que Meta
412
+ * la revisa (minutos a horas); recién `APPROVED` se puede mandar.
413
+ *
414
+ * `componentes` es el arreglo `components` de Meta tal cual (`BODY`, `HEADER`,
415
+ * `FOOTER`, `BUTTONS`), con sus `example`: Meta rechaza un cuerpo con
416
+ * parámetros sin ejemplo. El nombre lleva el prefijo de la app (ver el
417
+ * encabezado del módulo).
418
+ *
419
+ * La WABA es del club, así que esto se corre **una vez por club** al conectar
420
+ * su número, no una vez por aplicación.
421
+ */
422
+ export async function crearPlantilla(cred, wabaId, definicion) {
423
+ if (!wabaId)
424
+ return { ok: false, categoria: "credenciales", error: "falta el id de la WABA" };
425
+ if (!definicion.nombre)
426
+ return { ok: false, categoria: "plantilla", error: "falta el nombre de la plantilla" };
427
+ const idioma = definicion.idioma ?? "es_AR";
428
+ const r = await pedir(cred, `${BASE_META}/${encodeURIComponent(wabaId)}/message_templates`, {
429
+ method: "POST",
430
+ body: JSON.stringify({
431
+ name: definicion.nombre,
432
+ language: idioma,
433
+ category: definicion.categoria,
434
+ ...(definicion.formatoParametros ? { parameter_format: definicion.formatoParametros } : {}),
435
+ components: definicion.componentes,
436
+ }),
437
+ });
438
+ if (!r.ok)
439
+ return r;
440
+ // El proxy de Meta contesta `{ id, status, category }` SIN el sobre `data`.
441
+ const d = r.datos;
442
+ const fila = d?.data ?? d;
443
+ if (!fila?.id)
444
+ return { ok: false, categoria: "rechazado", error: "Kapso no devolvió un id de plantilla" };
445
+ return {
446
+ ok: true,
447
+ plantilla: {
448
+ id: String(fila.id),
449
+ nombre: definicion.nombre,
450
+ idioma,
451
+ estado: String(fila.status ?? "PENDING"),
452
+ ...(fila.category ? { categoria: String(fila.category) } : {}),
453
+ },
454
+ };
455
+ }
456
+ /**
457
+ * Las plantillas de una WABA con su estado. Sirve para saber si una ya está
458
+ * aprobada antes de mandarla, o si hay que darla de alta.
459
+ *
460
+ * Trae hasta `limite` (100 por defecto, el máximo de Meta). Si hay más,
461
+ * `siguiente` es el cursor para pedir la página que sigue con `despues`.
316
462
  */
463
+ export async function listarPlantillas(cred, wabaId, opciones = {}) {
464
+ if (!wabaId)
465
+ return { ok: false, categoria: "credenciales", error: "falta el id de la WABA" };
466
+ const limite = Math.min(Math.max(1, Math.trunc(opciones.limite ?? 100)), 100);
467
+ const q = new URLSearchParams({ limit: String(limite) });
468
+ if (opciones.nombre)
469
+ q.set("name", opciones.nombre);
470
+ if (opciones.estado)
471
+ q.set("status", opciones.estado);
472
+ if (opciones.despues)
473
+ q.set("after", opciones.despues);
474
+ const r = await pedir(cred, `${BASE_META}/${encodeURIComponent(wabaId)}/message_templates?${q}`, { method: "GET" });
475
+ if (!r.ok)
476
+ return r;
477
+ const d = r.datos;
478
+ const filas = Array.isArray(d?.data) ? d.data : [];
479
+ const plantillas = filas
480
+ .filter((f) => f?.name)
481
+ .map((f) => ({
482
+ id: String(f.id ?? ""),
483
+ nombre: String(f.name),
484
+ ...(f.language ? { idioma: String(f.language) } : {}),
485
+ estado: String(f.status ?? ""),
486
+ ...(f.category ? { categoria: String(f.category) } : {}),
487
+ }));
488
+ // Meta deja `cursors.after` aun en la última página: solo hay "siguiente" si
489
+ // dice `next`, o si la página vino llena.
490
+ const despues = d?.paging?.cursors?.after;
491
+ const hayMas = Boolean(d?.paging?.next) || filas.length >= limite;
492
+ return { ok: true, plantillas, ...(despues && hayMas ? { siguiente: despues } : {}) };
493
+ }
494
+ /* ============================================================
495
+ MEDIA ENTRANTE
496
+ ============================================================ */
497
+ /**
498
+ * Baja lo adjunto a un mensaje entrante (foto de un comprobante, audio…).
499
+ *
500
+ * Son dos pasos: `GET /{media_id}?phone_number_id=…` devuelve los datos de Meta
501
+ * más un `download_url` de Kapso con la autenticación adentro (vale **4
502
+ * minutos**), y después se baja ese `download_url` sin la clave. El `url` de
503
+ * Meta que viene al lado NO sirve: pide el token de Meta, que no tenemos.
504
+ *
505
+ * En un evento de Kapso suele venir además `mensaje.media.url`
506
+ * (`message.kapso.media_url`), que ya está espejado; esto es para cuando solo
507
+ * se tiene el id, o para bajarlo más tarde.
508
+ */
509
+ export async function bajarMedia(cred, mediaId) {
510
+ if (!cred.phoneNumberId)
511
+ return { ok: false, categoria: "credenciales", error: "falta el phoneNumberId del club" };
512
+ if (!mediaId)
513
+ return { ok: false, categoria: "rechazado", error: "falta el id de la media" };
514
+ const q = new URLSearchParams({ phone_number_id: cred.phoneNumberId });
515
+ const r = await pedir(cred, `${BASE_META}/${encodeURIComponent(mediaId)}?${q}`, { method: "GET" });
516
+ if (!r.ok)
517
+ return r;
518
+ const d = r.datos;
519
+ if (!d?.download_url)
520
+ return { ok: false, categoria: "rechazado", error: "Kapso no devolvió un download_url" };
521
+ const b = await bajarBytes(cred, d.download_url);
522
+ if (!b.ok)
523
+ return b;
524
+ return {
525
+ ok: true,
526
+ bytes: b.bytes,
527
+ mimeType: d.mime_type || b.tipo || "application/octet-stream",
528
+ ...(d.filename ? { nombreArchivo: String(d.filename) } : {}),
529
+ };
530
+ }
531
+ /* ============================================================
532
+ WEBHOOK ENTRANTE
533
+ ============================================================ */
534
+ /**
535
+ * ¿Este cuerpo lo mandó Kapso de verdad?
536
+ *
537
+ * Kapso firma cada entrega con HMAC-SHA256 sobre el **cuerpo crudo**, en hex,
538
+ * con el `secret_key` que se eligió al registrar el webhook, y lo manda en la
539
+ * cabecera `X-Webhook-Signature`.
540
+ *
541
+ * **Crudo** quiere decir los bytes tal como llegaron (`await request.text()`),
542
+ * no un `JSON.stringify` del objeto ya parseado: cualquier diferencia de orden
543
+ * de claves o de escapes rompe la firma, y lo rompe de a ratos, que es la peor
544
+ * forma de romperse.
545
+ *
546
+ * Sin secreto o sin firma el resultado es `false`, nunca "dejar pasar": un
547
+ * webhook sin verificar deja que cualquiera POSTee "el socio 1042 pregunta por
548
+ * su deuda" haciéndose pasar por su teléfono. Decidir qué hacer en desarrollo
549
+ * es de la aplicación (el paquete no mira `NODE_ENV`).
550
+ *
551
+ * Usa `node:crypto` como `verificarFirmaWebhook` de `@mafesoftware/mercadopago-ar`:
552
+ * sincrónico, y corre en Node ≥ 20 y en las funciones de Vercel. La comparación
553
+ * es en tiempo constante.
554
+ */
555
+ export function verificarFirmaWebhook(cuerpoCrudo, firmaHex, secreto) {
556
+ if (!secreto || !firmaHex)
557
+ return false;
558
+ // El hex de Kapso viene en minúsculas; se normaliza por si un proxy lo toca.
559
+ const recibida = Buffer.from(firmaHex.trim().toLowerCase(), "utf8");
560
+ const calculada = Buffer.from(createHmac("sha256", secreto).update(cuerpoCrudo).digest("hex"), "utf8");
561
+ // timingSafeEqual tira si los largos difieren, así que se chequea antes. El
562
+ // largo de un HMAC no es secreto: siempre son 64 caracteres.
563
+ if (recibida.length !== calculada.length)
564
+ return false;
565
+ return timingSafeEqual(recibida, calculada);
566
+ }
317
567
  /**
318
568
  * Cuánto texto se acepta de un mensaje entrante.
319
569
  *
@@ -324,31 +574,66 @@ export async function crearSetupLink(cred, clienteId, opciones = {}) {
324
574
  */
325
575
  const MAXIMO_TEXTO = 4096;
326
576
  const soloLoQueEntra = (v) => String(v ?? "").slice(0, MAXIMO_TEXTO);
327
- export function leerEventoWebhook(crudo) {
577
+ /** Lo que Meta llama `image`/`video`/`document`/`audio`, en nuestros nombres. */
578
+ const TIPOS_MEDIA = {
579
+ image: "imagen",
580
+ video: "video",
581
+ document: "documento",
582
+ audio: "audio",
583
+ };
584
+ /**
585
+ * Lee UN evento del webhook. **Nunca tira.**
586
+ *
587
+ * **El nombre del evento viaja en la cabecera `X-Webhook-Event`, no en el
588
+ * cuerpo**: el cuerpo v2 de un mensaje es `{ message, conversation,
589
+ * phone_number_id }` y no dice qué evento es. Por eso el segundo argumento —
590
+ * pasarle `request.headers.get("x-webhook-event")` — manda sobre
591
+ * `cuerpo.event`/`cuerpo.type`, que quedan solo como respaldo. Sin la cabecera,
592
+ * un mensaje real de Kapso sale `"ignorado"`.
593
+ *
594
+ * Lo que no se entiende sale como `"ignorado"` y **el webhook igual contesta
595
+ * 200**. Si contestara error, Kapso reintenta el mismo cuerpo y los eventos que
596
+ * sí importan se quedan atrás en la cola.
597
+ *
598
+ * Un evento de un cliente que no es nuestro también es `"ignorado"`: el webhook
599
+ * de proyecto dispara para TODAS las aplicaciones que comparten el proyecto, y
600
+ * filtrar es responsabilidad de cada una.
601
+ *
602
+ * Un lote (`batch: true`, con buffering prendido) no se lee acá: sale
603
+ * `"ignorado"` diciendo que se use `leerEventosWebhook`, para no perder en
604
+ * silencio todos los mensajes menos uno.
605
+ */
606
+ export function leerEventoWebhook(crudo, nombreEvento) {
328
607
  if (!crudo || typeof crudo !== "object")
329
608
  return { tipo: "ignorado", motivo: "cuerpo vacío" };
330
609
  const e = crudo;
331
- const evento = String(e.event ?? e.type ?? "");
610
+ if (e.batch === true) {
611
+ const n = Array.isArray(e.data) ? e.data.length : 0;
612
+ return { tipo: "ignorado", motivo: `lote de ${n} eventos: usar leerEventosWebhook` };
613
+ }
614
+ const evento = String(nombreEvento?.trim() || e.event || e.type || "");
332
615
  const datos = e.data ?? e.payload ?? e;
333
- if (evento === "whatsapp.phone_number.created") {
616
+ if (evento === "whatsapp.phone_number.created" || evento === "whatsapp.phone_number.deleted") {
334
617
  const id = datos?.phone_number_id ?? datos?.id;
335
- const cliente = datos?.customer_id ?? datos?.external_customer_id;
618
+ // v2 manda `customer: { id, external_id }`; `customer_id` suelto es de v1.
619
+ const cliente = datos?.customer?.id ?? datos?.customer_id ?? datos?.external_customer_id;
620
+ const externo = datos?.customer?.external_id ?? datos?.customer?.external_customer_id;
621
+ const conectado = evento === "whatsapp.phone_number.created";
336
622
  if (!id || !cliente)
337
- return { tipo: "ignorado", motivo: "conexión sin ids" };
338
- return {
339
- tipo: "numero_conectado",
623
+ return { tipo: "ignorado", motivo: conectado ? "conexión sin ids" : "desconexión sin ids" };
624
+ const comun = {
340
625
  clienteId: String(cliente),
341
626
  phoneNumberId: String(id),
627
+ ...(externo ? { idExterno: String(externo) } : {}),
628
+ };
629
+ if (!conectado)
630
+ return { tipo: "numero_desconectado", ...comun };
631
+ return {
632
+ tipo: "numero_conectado",
633
+ ...comun,
342
634
  telefono: datos?.display_phone_number ? String(datos.display_phone_number) : undefined,
343
635
  };
344
636
  }
345
- if (evento === "whatsapp.phone_number.deleted") {
346
- const id = datos?.phone_number_id ?? datos?.id;
347
- const cliente = datos?.customer_id ?? datos?.external_customer_id;
348
- if (!id || !cliente)
349
- return { tipo: "ignorado", motivo: "desconexión sin ids" };
350
- return { tipo: "numero_desconectado", clienteId: String(cliente), phoneNumberId: String(id) };
351
- }
352
637
  // Kapso manda UN evento por estado. No existe
353
638
  // `whatsapp.message.status_updated`: suscribirse a ese nombre inventado deja
354
639
  // las marcas de entrega vacias para siempre sin que nada parezca roto.
@@ -357,58 +642,139 @@ export function leerEventoWebhook(crudo) {
357
642
  const id = datos?.message?.id ?? datos?.id ?? datos?.message_id;
358
643
  if (!id)
359
644
  return { tipo: "ignorado", motivo: "estado sin id de mensaje" };
360
- return { tipo: "estado", mensajeId: String(id), estado: estado[1], fechaHora: leerFecha(datos) };
361
- }
362
- if (evento === "whatsapp.message.received") {
363
- const m = datos?.message ?? datos;
364
- const de = m?.from ?? datos?.from;
365
- if (!de)
366
- return { tipo: "ignorado", motivo: "mensaje sin remitente" };
367
- const phoneNumberId = datos?.phone_number_id ?? m?.phone_number_id ?? datos?.metadata?.phone_number_id ?? "";
368
- const interactivo = m?.interactive;
369
- if (interactivo?.type === "button_reply") {
370
- return {
371
- tipo: "mensaje",
372
- mensaje: {
373
- tipo: "boton",
374
- de: String(de),
375
- phoneNumberId: String(phoneNumberId),
376
- texto: soloLoQueEntra(interactivo.button_reply?.title),
377
- payload: interactivo.button_reply?.id ? soloLoQueEntra(interactivo.button_reply.id) : undefined,
378
- mensajeId: String(m?.id ?? ""),
379
- fechaHora: leerFecha(m ?? datos),
380
- },
381
- };
382
- }
383
- if (interactivo?.type === "list_reply") {
384
- return {
385
- tipo: "mensaje",
386
- mensaje: {
387
- tipo: "opcion_lista",
388
- de: String(de),
389
- phoneNumberId: String(phoneNumberId),
390
- texto: soloLoQueEntra(interactivo.list_reply?.title),
391
- payload: interactivo.list_reply?.id ? soloLoQueEntra(interactivo.list_reply.id) : undefined,
392
- mensajeId: String(m?.id ?? ""),
393
- fechaHora: leerFecha(m ?? datos),
394
- },
395
- };
396
- }
397
- const texto = m?.text?.body ?? m?.body;
645
+ const error = estado[1] === "failed" ? errorDeEstado(datos) : undefined;
646
+ // En v2 la hora está en el último estado del historial, o en el mensaje;
647
+ // la raíz no trae `timestamp`, y leerla ahí fechaba todo con "ahora".
648
+ const ultimo = ultimoEstado(datos);
398
649
  return {
399
- tipo: "mensaje",
400
- mensaje: {
401
- tipo: texto ? "texto" : "otro",
402
- de: String(de),
403
- phoneNumberId: String(phoneNumberId),
404
- texto: texto ? soloLoQueEntra(texto) : "",
405
- mensajeId: String(m?.id ?? ""),
406
- fechaHora: leerFecha(m ?? datos),
407
- },
650
+ tipo: "estado",
651
+ mensajeId: String(id),
652
+ estado: estado[1],
653
+ fechaHora: leerFecha(ultimo?.timestamp != null ? ultimo : (datos?.message?.timestamp != null ? datos.message : datos)),
654
+ ...(error ? { error } : {}),
408
655
  };
409
656
  }
657
+ if (evento === "whatsapp.message.received")
658
+ return leerMensaje(datos);
410
659
  return { tipo: "ignorado", motivo: `evento no manejado: ${evento || "(sin nombre)"}` };
411
660
  }
661
+ /**
662
+ * Lee TODO lo que trae una entrega del webhook, sea un evento suelto o un lote.
663
+ *
664
+ * Con buffering prendido para `whatsapp.message.received`, Kapso manda
665
+ * **siempre** un sobre `{ type, batch: true, data: [...], batch_info }`, aunque
666
+ * traiga un solo mensaje. Cada elemento de `data` tiene la misma forma que un
667
+ * evento suelto. Sin buffering, devuelve un arreglo de uno.
668
+ *
669
+ * El nombre del evento sale de la cabecera `X-Webhook-Event` si se pasa, y si
670
+ * no del `type` del sobre. Nunca tira.
671
+ */
672
+ export function leerEventosWebhook(crudo, nombreEvento) {
673
+ if (crudo && typeof crudo === "object" && crudo.batch === true) {
674
+ const sobre = crudo;
675
+ if (!Array.isArray(sobre.data))
676
+ return [{ tipo: "ignorado", motivo: "lote sin data" }];
677
+ const evento = String(nombreEvento?.trim() || sobre.type || sobre.event || "");
678
+ return sobre.data.map((item) => leerEventoWebhook(item, evento));
679
+ }
680
+ return [leerEventoWebhook(crudo, nombreEvento)];
681
+ }
682
+ function leerMensaje(datos) {
683
+ const m = datos?.message ?? datos;
684
+ const conversacion = datos?.conversation;
685
+ // `from` puede no venir (identidades BSUID, y los elementos de un lote no lo
686
+ // traen): el teléfono de la conversación es el mismo número.
687
+ const de = m?.from ?? datos?.from ?? conversacion?.phone_number;
688
+ if (!de)
689
+ return { tipo: "ignorado", motivo: "mensaje sin remitente" };
690
+ const phoneNumberId = datos?.phone_number_id ??
691
+ m?.phone_number_id ??
692
+ datos?.metadata?.phone_number_id ??
693
+ conversacion?.phone_number_id ??
694
+ m?.kapso?.phone_number_id ??
695
+ "";
696
+ const base = {
697
+ de: String(de).replace(/^\+/, ""),
698
+ phoneNumberId: String(phoneNumberId),
699
+ ...(conversacion?.contact_name ? { nombreContacto: soloLoQueEntra(conversacion.contact_name) } : {}),
700
+ mensajeId: String(m?.id ?? ""),
701
+ fechaHora: leerFecha(m ?? datos),
702
+ };
703
+ const mensaje = (resto) => ({
704
+ tipo: "mensaje",
705
+ mensaje: { ...base, ...resto },
706
+ });
707
+ const interactivo = m?.interactive;
708
+ if (interactivo?.type === "button_reply" || interactivo?.type === "list_reply") {
709
+ const r = interactivo.type === "button_reply" ? interactivo.button_reply : interactivo.list_reply;
710
+ return mensaje({
711
+ tipo: interactivo.type === "button_reply" ? "boton" : "opcion_lista",
712
+ texto: soloLoQueEntra(r?.title),
713
+ payload: r?.id ? soloLoQueEntra(r.id) : undefined,
714
+ });
715
+ }
716
+ // El botón de respuesta rápida de una PLANTILLA no llega como `interactive`
717
+ // sino como `type: "button"`, con el payload que se aprobó en la plantilla.
718
+ if (m?.type === "button" && m?.button) {
719
+ return mensaje({
720
+ tipo: "boton",
721
+ texto: soloLoQueEntra(m.button.text),
722
+ payload: m.button.payload ? soloLoQueEntra(m.button.payload) : undefined,
723
+ });
724
+ }
725
+ const tipoMedia = TIPOS_MEDIA[String(m?.type ?? "")];
726
+ if (tipoMedia) {
727
+ const obj = m?.[m.type] ?? {};
728
+ const kapso = m?.kapso ?? {};
729
+ const datosMedia = kapso.media_data ?? {};
730
+ const url = kapso.media_url ?? datosMedia.url;
731
+ const mimeType = obj.mime_type ?? datosMedia.content_type;
732
+ const nombreArchivo = obj.filename ?? datosMedia.filename;
733
+ return mensaje({
734
+ tipo: tipoMedia,
735
+ texto: soloLoQueEntra(obj.caption ?? kapso.message_type_data?.caption ?? ""),
736
+ media: {
737
+ id: String(obj.id ?? ""),
738
+ ...(mimeType ? { mimeType: String(mimeType) } : {}),
739
+ ...(nombreArchivo ? { nombreArchivo: String(nombreArchivo) } : {}),
740
+ ...(url ? { url: String(url) } : {}),
741
+ },
742
+ });
743
+ }
744
+ if (m?.type === "location") {
745
+ const lat = Number(m.location?.latitude);
746
+ const lng = Number(m.location?.longitude);
747
+ // Una ubicación sin coordenadas no ubica nada: mejor `otro` que "NaN,NaN".
748
+ if (m.location?.latitude != null && m.location?.longitude != null && Number.isFinite(lat) && Number.isFinite(lng)) {
749
+ return mensaje({ tipo: "ubicacion", texto: `${lat},${lng}` });
750
+ }
751
+ return mensaje({ tipo: "otro", texto: "" });
752
+ }
753
+ const texto = m?.text?.body ?? m?.body;
754
+ return mensaje({ tipo: texto ? "texto" : "otro", texto: texto ? soloLoQueEntra(texto) : "" });
755
+ }
756
+ /**
757
+ * Por qué falló un mensaje. Meta lo deja en el ÚLTIMO elemento de
758
+ * `message.kapso.statuses` (el historial crudo de estados), en `errors[0]`.
759
+ */
760
+ function ultimoEstado(datos) {
761
+ const estados = datos?.message?.kapso?.statuses;
762
+ return Array.isArray(estados) && estados.length ? estados[estados.length - 1] : undefined;
763
+ }
764
+ function errorDeEstado(datos) {
765
+ const ultimo = ultimoEstado(datos);
766
+ const crudo = ultimo?.errors?.[0] ?? datos?.message?.errors?.[0] ?? datos?.errors?.[0];
767
+ if (!crudo || typeof crudo !== "object")
768
+ return undefined;
769
+ const error = {
770
+ ...(typeof crudo.code === "number" ? { codigo: crudo.code } : {}),
771
+ ...(crudo.title ? { titulo: String(crudo.title) } : {}),
772
+ ...(crudo.message ?? crudo.error_data?.details
773
+ ? { mensaje: String(crudo.message ?? crudo.error_data.details) }
774
+ : {}),
775
+ };
776
+ return Object.keys(error).length ? error : undefined;
777
+ }
412
778
  /**
413
779
  * ¿Se le puede mandar texto libre a esta persona?
414
780
  *
@@ -464,13 +830,7 @@ async function pedir(cred, url, init) {
464
830
  clearTimeout(corte);
465
831
  }
466
832
  const texto = await rta.text().catch(() => "");
467
- let datos = null;
468
- try {
469
- datos = texto ? JSON.parse(texto) : null;
470
- }
471
- catch {
472
- datos = null;
473
- }
833
+ const datos = leerJson(texto);
474
834
  if (rta.ok)
475
835
  return { ok: true, datos };
476
836
  return {
@@ -480,6 +840,47 @@ async function pedir(cred, url, init) {
480
840
  estado: rta.status,
481
841
  };
482
842
  }
843
+ /**
844
+ * Baja bytes de una URL que ya trae su autenticación (el `download_url` de
845
+ * Kapso): **sin** `X-API-Key`, que no hace falta y no tiene por qué viajar. El
846
+ * corte por tiempo cubre también la lectura del cuerpo, que es lo que tarda en
847
+ * un archivo grande.
848
+ */
849
+ async function bajarBytes(cred, url) {
850
+ const hacerFetch = cred.fetch ?? globalThis.fetch;
851
+ if (!hacerFetch)
852
+ return { ok: false, categoria: "red", error: "no hay fetch disponible" };
853
+ const control = new AbortController();
854
+ const corte = setTimeout(() => control.abort(), cred.timeoutMs ?? 15_000);
855
+ try {
856
+ const rta = await hacerFetch(url, { method: "GET", signal: control.signal });
857
+ if (!rta.ok) {
858
+ const texto = await rta.text().catch(() => "");
859
+ return {
860
+ ok: false,
861
+ categoria: categoriaDe(rta.status, texto),
862
+ error: detalleDe(leerJson(texto), texto, rta.status),
863
+ estado: rta.status,
864
+ };
865
+ }
866
+ const bytes = new Uint8Array(await rta.arrayBuffer());
867
+ return { ok: true, bytes, tipo: rta.headers?.get("content-type") ?? undefined };
868
+ }
869
+ catch (e) {
870
+ return { ok: false, categoria: "red", error: mensajeDe(e) };
871
+ }
872
+ finally {
873
+ clearTimeout(corte);
874
+ }
875
+ }
876
+ function leerJson(texto) {
877
+ try {
878
+ return texto ? JSON.parse(texto) : null;
879
+ }
880
+ catch {
881
+ return null;
882
+ }
883
+ }
483
884
  function categoriaDe(estado, texto) {
484
885
  // 402 es la facturacion de Meta pausada, y no se arregla reintentando: hasta
485
886
  // que alguien cargue un medio de pago, NINGUN envio pago sale.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mafesoftware/kapso-wa",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "WhatsApp por Kapso (proxy de la Cloud API de Meta). fetch inyectable.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -9,7 +9,8 @@
9
9
  "exports": {
10
10
  ".": {
11
11
  "types": "./dist/index.d.ts",
12
- "import": "./dist/index.js"
12
+ "import": "./dist/index.js",
13
+ "default": "./dist/index.js"
13
14
  }
14
15
  },
15
16
  "publishConfig": {