@mafesoftware/kapso-wa 0.1.2 → 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 +21 -0
- package/README.md +75 -7
- package/dist/index.d.ts +253 -16
- package/dist/index.js +478 -77
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
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
|
+
|
|
3
24
|
## 0.1.2
|
|
4
25
|
|
|
5
26
|
### 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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
291
|
-
|
|
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
|
-
*
|
|
335
|
+
* Los eventos que un número le manda a la aplicación por defecto.
|
|
308
336
|
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
|
|
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
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
const
|
|
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: "
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
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.
|