@mafesoftware/kapso-wa 0.1.1

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 ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - a8db00c: Agrega la LICENSE (copia de la de la raíz, MIT) a cada `packages/*` que
8
+ todavía no la tenía en su checkout — `arca-ar`/`correo`/`mercadopago-ar` ya
9
+ la tenían. `npm`/`bun pm pack` ya subían la LICENSE de la raíz al tarball
10
+ publicado aunque no estuviera acá (confirmado con un pack en seco), pero
11
+ `tests/estructura.test.ts` ahora también exige que cada paquete la tenga en
12
+ su checkout, y `scripts/nuevo-paquete.ts` la copia sola para los paquetes
13
+ nuevos.
14
+
15
+ ## 0.1.0
16
+
17
+ WhatsApp por Kapso (proxy de la Cloud API de Meta): normalización de números
18
+ argentinos (`aNumeroWhatsApp`), envío de texto, plantillas, botones y listas
19
+ (`enviarTexto`, `enviarPlantilla`, `enviarBotones`, `enviarLista`,
20
+ `enviarAviso`), onboarding de un club (`crearCliente`, `crearSetupLink`) y
21
+ lectura del webhook entrante (`leerEventoWebhook`). `fetch` inyectable,
22
+ resultados en vez de excepciones.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MAFE Software
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,92 @@
1
+ # @mafesoftware/kapso-wa
2
+
3
+ WhatsApp por Kapso (proxy de la Cloud API de Meta). fetch inyectable.
4
+
5
+ Parte de la familia de paquetes de MAFE Software: sin dependencias de framework,
6
+ sin ORM, y **puros** salvo donde se indique. Todo lo que sale a la red acepta un
7
+ `fetch` inyectable, así que los tests corren sin red.
8
+
9
+ ```bash
10
+ bun add @mafesoftware/kapso-wa
11
+ ```
12
+
13
+ La documentación de cada función está en `src/index.ts`, con **el motivo de
14
+ cada decisión** al lado. Los tests (`tests/`) son la otra mitad de la
15
+ documentación: cada uno dice qué bug evita.
16
+
17
+ ## API
18
+
19
+ ### Números
20
+
21
+ ```ts
22
+ import { aNumeroWhatsApp } from "@mafesoftware/kapso-wa";
23
+
24
+ aNumeroWhatsApp("011 4567-8901"); // "5491145678901"
25
+ aNumeroWhatsApp("+54 9 11 4567 8901"); // "5491145678901"
26
+ aNumeroWhatsApp("no es un teléfono"); // null (nunca un número adivinado)
27
+ ```
28
+
29
+ ### Envío de mensajes
30
+
31
+ Todas devuelven `Resultado` (`{ ok: true, id }` o `{ ok: false, categoria, error }`) y **nunca tiran**.
32
+
33
+ ```ts
34
+ import { enviarTexto, enviarPlantilla, enviarBotones, enviarLista, enviarAviso, dentroDeVentana24h } from "@mafesoftware/kapso-wa";
35
+
36
+ const cred = { apiKey: "kapso_...", phoneNumberId: "1234567890" };
37
+
38
+ await enviarTexto(cred, "5491145678901", "¡Gracias por tu reserva!"); // solo dentro de la ventana de 24h
39
+
40
+ await enviarPlantilla(cred, "5491145678901", "gf_cuota_vence", ["Juana", "$12.000"]); // fuera de la ventana
41
+
42
+ await enviarBotones(cred, "5491145678901", "¿Confirmás tu turno?", [
43
+ { id: "reserva:confirmar", titulo: "Confirmar" },
44
+ { id: "reserva:cancelar", titulo: "Cancelar" },
45
+ ]);
46
+
47
+ await enviarLista(cred, "5491145678901", "Turnos libres:", "Ver turnos", [
48
+ { titulo: "Cancha 1", opciones: [{ id: "turno:1", titulo: "18:00" }] },
49
+ ]);
50
+
51
+ // Elige sola: texto si escribió hace menos de 24h, plantilla si no.
52
+ await enviarAviso(cred, "5491145678901", {
53
+ ultimoMensajeEntrante: ultimaVezQueEscribio,
54
+ texto: "Tu cuota de septiembre está vencida.",
55
+ plantilla: "gf_cuota_vence",
56
+ parametros: ["septiembre"],
57
+ });
58
+
59
+ dentroDeVentana24h(ultimaVezQueEscribio); // true | false
60
+ ```
61
+
62
+ ### Onboarding de un club
63
+
64
+ ```ts
65
+ import { crearCliente, crearSetupLink } from "@mafesoftware/kapso-wa";
66
+
67
+ const cred = { apiKey: "kapso_..." };
68
+ const r = await crearCliente(cred, "Club Náutico", clubId);
69
+ if (r.ok) {
70
+ const link = await crearSetupLink(cred, r.cliente.id, { volverBienA: "https://app/ok" });
71
+ if (link.ok) redirigirA(link.url); // el club conecta su WhatsApp ahí
72
+ }
73
+ ```
74
+
75
+ ### Webhook entrante
76
+
77
+ ```ts
78
+ import { leerEventoWebhook } from "@mafesoftware/kapso-wa";
79
+
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
85
+ }
86
+ ```
87
+
88
+ ## Probar
89
+
90
+ ```bash
91
+ bun test
92
+ ```
@@ -0,0 +1,248 @@
1
+ /**
2
+ * WhatsApp por [Kapso](https://kapso.ai), que es un proxy de la Cloud API de
3
+ * Meta con la aprobación de Tech Provider ya resuelta.
4
+ *
5
+ * Sin dependencias, sin framework: `fetch` inyectable para los tests y
6
+ * **resultados en vez de excepciones** — un aviso de WhatsApp es un aviso, y
7
+ * mandarlo no puede tumbar la reserva ni el cobro que lo dispara.
8
+ *
9
+ * ## Las tres cosas que hay que saber antes de tocar esto
10
+ *
11
+ * 1. **Afuera de la ventana de 24 horas solo salen PLANTILLAS aprobadas.** Si
12
+ * la persona no nos escribió en las últimas 24 h, un texto libre da 422. Un
13
+ * aviso de vencimiento de cuota siempre es plantilla; una respuesta del
14
+ * asistente adentro de una conversación abierta puede ser texto.
15
+ * 2. **El nombre de la plantilla lleva el prefijo de la aplicación**
16
+ * (`gf_cuota_vence`). Todas las apps de MAFE comparten el número de prueba,
17
+ * y dos plantillas con el mismo nombre y distinto cuerpo se pisan.
18
+ * 3. **Un HTTP 402 no es un error de código: es la facturación de Meta.**
19
+ * Kapso contesta `Paid WhatsApp sends are paused until the billing issue is
20
+ * resolved` y no sale NINGÚN mensaje pago. La integración puede estar
21
+ * perfecta de punta a punta y no enviar nada, así que este paquete le da
22
+ * una categoría propia — un reintento no arregla nada y hay que avisarle a
23
+ * una persona.
24
+ *
25
+ * ## Multi-tenant
26
+ *
27
+ * La clave de API es **del proyecto**, no del club: alcanza con guardar el
28
+ * `phoneNumberId` de cada club para mandar en su nombre. La contracara es que
29
+ * esa clave abre TODOS los números del proyecto, así que una aplicación toca
30
+ * únicamente los números de sus propios clientes.
31
+ */
32
+ /** Un `fetch` compatible. Se inyecta en los tests para no salir a la red. */
33
+ export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
34
+ export type Credenciales = {
35
+ apiKey: string;
36
+ /** El número desde el que se manda. Es un dato del CLUB, no del deployment. */
37
+ phoneNumberId: string;
38
+ fetch?: FetchLike;
39
+ /** Milisegundos antes de cortar. 15 s por defecto. */
40
+ timeoutMs?: number;
41
+ };
42
+ /**
43
+ * Por qué falló, y sobre todo **si reintentar sirve**.
44
+ *
45
+ * - `red`, `limite` — sí, más tarde.
46
+ * - `credenciales`, `numero`, `plantilla`, `ventana`, `rechazado` — no: son
47
+ * errores de configuración o de contenido, y reintentar los repite igual.
48
+ * - `facturacion` — no, y además **ningún** envío pago va a salir hasta que
49
+ * alguien resuelva el medio de pago de Meta. Es el único que merece una
50
+ * alarma y no una fila de reintentos.
51
+ */
52
+ export type CategoriaError = "red" | "credenciales" | "facturacion" | "limite" | "numero" | "plantilla" | "ventana" | "rechazado";
53
+ export type Resultado = {
54
+ ok: true;
55
+ id: string;
56
+ } | {
57
+ ok: false;
58
+ categoria: CategoriaError;
59
+ error: string;
60
+ estado?: number;
61
+ };
62
+ /**
63
+ * Un celular argentino a E.164 sin `+`, como lo quiere WhatsApp: `549` + los
64
+ * diez dígitos nacionales.
65
+ *
66
+ * Devuelve `null` si el número no se reconoce, **nunca un número adivinado**:
67
+ * mandarle el aviso de deuda de un socio a otra persona es peor que no
68
+ * mandarlo.
69
+ */
70
+ export declare function aNumeroWhatsApp(telefono: string | null | undefined, paisPorDefecto?: string): string | null;
71
+ /**
72
+ * Texto libre. **Solo adentro de la ventana de 24 horas.**
73
+ *
74
+ * Es lo que usa el asistente para contestar una conversación que la persona
75
+ * abrió. Para avisar algo "en frío" va `enviarPlantilla`.
76
+ */
77
+ export declare function enviarTexto(cred: Credenciales, para: string, texto: string): Promise<Resultado>;
78
+ export type ParametroPlantilla = string | {
79
+ tipo: "texto";
80
+ valor: string;
81
+ };
82
+ /**
83
+ * Una plantilla aprobada por Meta. Es lo único que sale en frío.
84
+ *
85
+ * Los parámetros son **posicionales** (`{{1}}`, `{{2}}`…) y el orden tiene que
86
+ * coincidir con el que se aprobó. Ese es el error más caro del módulo: la
87
+ * plantilla se manda, Meta la acepta, y al socio le llega su deuda en el lugar
88
+ * donde iba la fecha. No hay forma de validarlo desde acá — se valida con un
89
+ * test que arme los parámetros con la misma función que los manda.
90
+ */
91
+ export declare function enviarPlantilla(cred: Credenciales, para: string, plantilla: string, parametros?: readonly ParametroPlantilla[], idioma?: string): Promise<Resultado>;
92
+ /**
93
+ * Botones de respuesta rápida. Hasta 3, y es un límite de Meta.
94
+ *
95
+ * Se prefieren a los WhatsApp Flows para un producto multi-tenant: un Flow
96
+ * pertenece a una WABA, así que habría que crear, cifrar y publicar uno por
97
+ * club — y publicarlo exige que Meta le verifique el portfolio **a cada club**,
98
+ * con documentación societaria. La mayoría no lo va a hacer. Con mensajes
99
+ * interactivos, el club número doscientos anda sin que nadie configure nada.
100
+ *
101
+ * Cada botón lleva un `id` que vuelve en el webhook: ahí va nuestro propio
102
+ * identificador (`reserva:abc123`), y por eso el payload de otra aplicación que
103
+ * comparta el número no resuelve y se ignora solo.
104
+ */
105
+ export declare function enviarBotones(cred: Credenciales, para: string, cuerpo: string, botones: readonly {
106
+ id: string;
107
+ titulo: string;
108
+ }[], opciones?: {
109
+ encabezado?: string;
110
+ pie?: string;
111
+ }): Promise<Resultado>;
112
+ /**
113
+ * Una lista desplegable. Hasta 10 opciones en total, repartidas en secciones.
114
+ *
115
+ * Es lo que usa el asistente para ofrecer los turnos libres de una cancha.
116
+ */
117
+ export declare function enviarLista(cred: Credenciales, para: string, cuerpo: string, textoBoton: string, secciones: readonly {
118
+ titulo: string;
119
+ opciones: readonly {
120
+ id: string;
121
+ titulo: string;
122
+ descripcion?: string;
123
+ }[];
124
+ }[], opciones?: {
125
+ encabezado?: string;
126
+ pie?: string;
127
+ }): Promise<Resultado>;
128
+ export type ClienteKapso = {
129
+ id: string;
130
+ externalCustomerId: string;
131
+ };
132
+ /**
133
+ * Da de alta un club como cliente de Kapso.
134
+ *
135
+ * `externalCustomerId` lleva el prefijo de la aplicación
136
+ * (`gestionflow:<clubId>`) porque el webhook de conexión trae **solo** el id de
137
+ * cliente y nunca el producto: sin prefijo no hay forma de saber de quién es.
138
+ */
139
+ export declare function crearCliente(cred: {
140
+ apiKey: string;
141
+ fetch?: FetchLike;
142
+ timeoutMs?: number;
143
+ }, nombre: string, clubId: string, prefijo?: string): Promise<{
144
+ ok: true;
145
+ cliente: ClienteKapso;
146
+ } | {
147
+ ok: false;
148
+ categoria: CategoriaError;
149
+ error: string;
150
+ }>;
151
+ /**
152
+ * El link que el club abre para conectar su WhatsApp. Tarda unos cinco minutos.
153
+ *
154
+ * ## Dos decisiones que NO se pueden cambiar después
155
+ *
156
+ * - **`allowed_connection_types: ["coexistence"]`**, siempre. La alternativa
157
+ * (`dedicated`) le apaga la app de WhatsApp Business en el teléfono, y un
158
+ * club usa ese WhatsApp todo el día. Su ventaja —mil mensajes por segundo—
159
+ * resuelve un problema que no tenemos.
160
+ * - **`metaBilling`** decide quién le paga a Meta, y cambiarlo en una WABA ya
161
+ * conectada es un ticket de soporte. Peor: borrar el número y reconectar con
162
+ * el otro modo **no lo cambia**, porque el modo es de la WABA y la WABA
163
+ * sobrevive al número. Solo se lee en el PRIMER registro.
164
+ *
165
+ * `partner_managed` (nosotros le pagamos a Meta y lo revendemos) **no
166
+ * funciona con una WABA en pesos**, y una WABA argentina con tarjeta local ya
167
+ * configurada queda en pesos. Por eso el modo se decide **por club** y no por
168
+ * entorno: una variable global obliga a todos al caso raro.
169
+ *
170
+ * Solo hay **un link activo por cliente**: crear otro revoca el anterior. Y
171
+ * sobre un número YA conectado no se emite un link nuevo — puede intentar una
172
+ * segunda conexión y arruinar la que funciona.
173
+ */
174
+ export declare function crearSetupLink(cred: {
175
+ apiKey: string;
176
+ fetch?: FetchLike;
177
+ timeoutMs?: number;
178
+ }, clienteId: string, opciones?: {
179
+ metaBilling?: "customer_managed" | "partner_managed";
180
+ volverBienA?: string;
181
+ volverMalA?: string;
182
+ colorPrimario?: string;
183
+ idioma?: string;
184
+ }): Promise<{
185
+ ok: true;
186
+ url: string;
187
+ expira?: string;
188
+ } | {
189
+ ok: false;
190
+ categoria: CategoriaError;
191
+ error: string;
192
+ }>;
193
+ export type MensajeEntrante = {
194
+ tipo: "texto" | "boton" | "opcion_lista" | "otro";
195
+ /** El número que escribió, en E.164 sin `+`. */
196
+ de: string;
197
+ phoneNumberId: string;
198
+ /** El texto, o el título del botón/opción que tocaron. */
199
+ texto: string;
200
+ /** El `id` que le pusimos al botón o a la opción. Es nuestro, no de Meta. */
201
+ payload?: string;
202
+ mensajeId: string;
203
+ fechaHora: Date;
204
+ };
205
+ export type EventoWebhook = {
206
+ tipo: "mensaje";
207
+ mensaje: MensajeEntrante;
208
+ } | {
209
+ tipo: "estado";
210
+ mensajeId: string;
211
+ estado: string;
212
+ fechaHora: Date;
213
+ } | {
214
+ tipo: "numero_conectado";
215
+ clienteId: string;
216
+ phoneNumberId: string;
217
+ telefono?: string;
218
+ } | {
219
+ tipo: "numero_desconectado";
220
+ clienteId: string;
221
+ phoneNumberId: string;
222
+ } | {
223
+ tipo: "ignorado";
224
+ motivo: string;
225
+ };
226
+ export declare function leerEventoWebhook(crudo: unknown): EventoWebhook;
227
+ /**
228
+ * ¿Se le puede mandar texto libre a esta persona?
229
+ *
230
+ * La ventana de servicio son 24 horas desde el ÚLTIMO mensaje que ELLA mandó.
231
+ * Afuera, solo plantillas. `null` significa "nunca escribió", que también es
232
+ * afuera.
233
+ */
234
+ export declare function dentroDeVentana24h(ultimoMensajeEntrante: Date | null | undefined, ahora?: Date): boolean;
235
+ /**
236
+ * Elige solo: texto adentro de la ventana, plantilla afuera.
237
+ *
238
+ * Es el patrón de todo aviso del producto, y escrito a mano en cada llamada se
239
+ * olvida justo en el aviso que sale de madrugada.
240
+ */
241
+ export declare function enviarAviso(cred: Credenciales, para: string, opciones: {
242
+ ultimoMensajeEntrante?: Date | null;
243
+ texto: string;
244
+ plantilla: string;
245
+ parametros?: readonly ParametroPlantilla[];
246
+ idioma?: string;
247
+ ahora?: Date;
248
+ }): Promise<Resultado>;
package/dist/index.js ADDED
@@ -0,0 +1,533 @@
1
+ /**
2
+ * WhatsApp por [Kapso](https://kapso.ai), que es un proxy de la Cloud API de
3
+ * Meta con la aprobación de Tech Provider ya resuelta.
4
+ *
5
+ * Sin dependencias, sin framework: `fetch` inyectable para los tests y
6
+ * **resultados en vez de excepciones** — un aviso de WhatsApp es un aviso, y
7
+ * mandarlo no puede tumbar la reserva ni el cobro que lo dispara.
8
+ *
9
+ * ## Las tres cosas que hay que saber antes de tocar esto
10
+ *
11
+ * 1. **Afuera de la ventana de 24 horas solo salen PLANTILLAS aprobadas.** Si
12
+ * la persona no nos escribió en las últimas 24 h, un texto libre da 422. Un
13
+ * aviso de vencimiento de cuota siempre es plantilla; una respuesta del
14
+ * asistente adentro de una conversación abierta puede ser texto.
15
+ * 2. **El nombre de la plantilla lleva el prefijo de la aplicación**
16
+ * (`gf_cuota_vence`). Todas las apps de MAFE comparten el número de prueba,
17
+ * y dos plantillas con el mismo nombre y distinto cuerpo se pisan.
18
+ * 3. **Un HTTP 402 no es un error de código: es la facturación de Meta.**
19
+ * Kapso contesta `Paid WhatsApp sends are paused until the billing issue is
20
+ * resolved` y no sale NINGÚN mensaje pago. La integración puede estar
21
+ * perfecta de punta a punta y no enviar nada, así que este paquete le da
22
+ * una categoría propia — un reintento no arregla nada y hay que avisarle a
23
+ * una persona.
24
+ *
25
+ * ## Multi-tenant
26
+ *
27
+ * La clave de API es **del proyecto**, no del club: alcanza con guardar el
28
+ * `phoneNumberId` de cada club para mandar en su nombre. La contracara es que
29
+ * esa clave abre TODOS los números del proyecto, así que una aplicación toca
30
+ * únicamente los números de sus propios clientes.
31
+ */
32
+ const BASE_META = "https://api.kapso.ai/meta/whatsapp/v24.0";
33
+ const BASE_PLATAFORMA = "https://api.kapso.ai/platform/v1";
34
+ /* ============================================================
35
+ NÚMEROS
36
+ ============================================================ */
37
+ /**
38
+ * Un celular argentino a E.164 sin `+`, como lo quiere WhatsApp: `549` + los
39
+ * diez dígitos nacionales.
40
+ *
41
+ * Devuelve `null` si el número no se reconoce, **nunca un número adivinado**:
42
+ * mandarle el aviso de deuda de un socio a otra persona es peor que no
43
+ * mandarlo.
44
+ */
45
+ export function aNumeroWhatsApp(telefono, paisPorDefecto = "54") {
46
+ if (!telefono)
47
+ return null;
48
+ let d = String(telefono).replace(/\D/g, "");
49
+ if (!d)
50
+ return null;
51
+ // Ya viene internacional.
52
+ if (d.startsWith("00"))
53
+ d = d.slice(2);
54
+ if (paisPorDefecto === "54") {
55
+ // Se saca el 54 y el 9 para normalizar, y se rearma. Asi entran por igual
56
+ // "011 4567-8901", "+54 9 11 4567 8901" y "5491145678901".
57
+ if (d.startsWith("54"))
58
+ d = d.slice(2);
59
+ if (d.startsWith("9"))
60
+ d = d.slice(1);
61
+ // El 0 de larga distancia y el 15 de celular no viajan a WhatsApp.
62
+ if (d.startsWith("0"))
63
+ d = d.slice(1);
64
+ /**
65
+ * El 15 se saca SOLO si sobran dígitos.
66
+ *
67
+ * Con diez dígitos el número ya está en formato nacional y no hay nada que
68
+ * quitar: sacarle un "15" que en realidad es parte del número local lo
69
+ * dejaba en ocho o nueve dígitos y la función devolvía `null`. O sea que un
70
+ * socio con un número como `11 1523-4567` no se identificaba nunca cuando
71
+ * le escribía al WhatsApp del club — Lia lo trataba como desconocido y no
72
+ * había nada que dijera por qué.
73
+ *
74
+ * La ambigüedad es real: mirando los dígitos no se distingue el 15 de
75
+ * acceso del 15 que arranca la parte local. El largo sí la resuelve.
76
+ */
77
+ if (d.length > 10)
78
+ d = d.replace(/^(\d{2,4})15(\d{6,8})$/, "$1$2");
79
+ if (d.length !== 10)
80
+ return null;
81
+ return `549${d}`;
82
+ }
83
+ if (d.length < 8 || d.length > 15)
84
+ return null;
85
+ return d.startsWith(paisPorDefecto) ? d : `${paisPorDefecto}${d}`;
86
+ }
87
+ /* ============================================================
88
+ ENVÍO
89
+ ============================================================ */
90
+ /**
91
+ * Texto libre. **Solo adentro de la ventana de 24 horas.**
92
+ *
93
+ * Es lo que usa el asistente para contestar una conversación que la persona
94
+ * abrió. Para avisar algo "en frío" va `enviarPlantilla`.
95
+ */
96
+ export function enviarTexto(cred, para, texto) {
97
+ return postMensaje(cred, {
98
+ messaging_product: "whatsapp",
99
+ to: para,
100
+ type: "text",
101
+ text: { body: texto, preview_url: false },
102
+ });
103
+ }
104
+ /**
105
+ * Una plantilla aprobada por Meta. Es lo único que sale en frío.
106
+ *
107
+ * Los parámetros son **posicionales** (`{{1}}`, `{{2}}`…) y el orden tiene que
108
+ * coincidir con el que se aprobó. Ese es el error más caro del módulo: la
109
+ * plantilla se manda, Meta la acepta, y al socio le llega su deuda en el lugar
110
+ * donde iba la fecha. No hay forma de validarlo desde acá — se valida con un
111
+ * test que arme los parámetros con la misma función que los manda.
112
+ */
113
+ export function enviarPlantilla(cred, para, plantilla, parametros = [], idioma = "es_AR") {
114
+ const components = parametros.length
115
+ ? [
116
+ {
117
+ type: "body",
118
+ parameters: parametros.map((p) => ({
119
+ type: "text",
120
+ text: typeof p === "string" ? p : p.valor,
121
+ })),
122
+ },
123
+ ]
124
+ : undefined;
125
+ return postMensaje(cred, {
126
+ messaging_product: "whatsapp",
127
+ to: para,
128
+ type: "template",
129
+ template: {
130
+ name: plantilla,
131
+ language: { code: idioma },
132
+ ...(components ? { components } : {}),
133
+ },
134
+ });
135
+ }
136
+ /**
137
+ * Botones de respuesta rápida. Hasta 3, y es un límite de Meta.
138
+ *
139
+ * Se prefieren a los WhatsApp Flows para un producto multi-tenant: un Flow
140
+ * pertenece a una WABA, así que habría que crear, cifrar y publicar uno por
141
+ * club — y publicarlo exige que Meta le verifique el portfolio **a cada club**,
142
+ * con documentación societaria. La mayoría no lo va a hacer. Con mensajes
143
+ * interactivos, el club número doscientos anda sin que nadie configure nada.
144
+ *
145
+ * Cada botón lleva un `id` que vuelve en el webhook: ahí va nuestro propio
146
+ * identificador (`reserva:abc123`), y por eso el payload de otra aplicación que
147
+ * comparta el número no resuelve y se ignora solo.
148
+ */
149
+ export function enviarBotones(cred, para, cuerpo, botones, opciones = {}) {
150
+ if (!botones.length) {
151
+ return Promise.resolve({ ok: false, categoria: "rechazado", error: "sin botones" });
152
+ }
153
+ if (botones.length > 3) {
154
+ // Meta corta en 3 y devuelve un error opaco. Mejor decirlo acá.
155
+ return Promise.resolve({
156
+ ok: false,
157
+ categoria: "rechazado",
158
+ error: `WhatsApp acepta hasta 3 botones, se pasaron ${botones.length}`,
159
+ });
160
+ }
161
+ return postMensaje(cred, {
162
+ messaging_product: "whatsapp",
163
+ to: para,
164
+ type: "interactive",
165
+ interactive: {
166
+ type: "button",
167
+ ...(opciones.encabezado ? { header: { type: "text", text: opciones.encabezado } } : {}),
168
+ body: { text: cuerpo },
169
+ ...(opciones.pie ? { footer: { text: opciones.pie } } : {}),
170
+ action: {
171
+ buttons: botones.map((b) => ({
172
+ type: "reply",
173
+ reply: { id: b.id, title: recortar(b.titulo, 20) },
174
+ })),
175
+ },
176
+ },
177
+ });
178
+ }
179
+ /**
180
+ * Una lista desplegable. Hasta 10 opciones en total, repartidas en secciones.
181
+ *
182
+ * Es lo que usa el asistente para ofrecer los turnos libres de una cancha.
183
+ */
184
+ export function enviarLista(cred, para, cuerpo, textoBoton, secciones, opciones = {}) {
185
+ const total = secciones.reduce((n, s) => n + s.opciones.length, 0);
186
+ if (!total) {
187
+ return Promise.resolve({ ok: false, categoria: "rechazado", error: "lista sin opciones" });
188
+ }
189
+ if (total > 10) {
190
+ return Promise.resolve({
191
+ ok: false,
192
+ categoria: "rechazado",
193
+ error: `WhatsApp acepta hasta 10 opciones, se pasaron ${total}`,
194
+ });
195
+ }
196
+ return postMensaje(cred, {
197
+ messaging_product: "whatsapp",
198
+ to: para,
199
+ type: "interactive",
200
+ interactive: {
201
+ type: "list",
202
+ ...(opciones.encabezado ? { header: { type: "text", text: opciones.encabezado } } : {}),
203
+ body: { text: cuerpo },
204
+ ...(opciones.pie ? { footer: { text: opciones.pie } } : {}),
205
+ action: {
206
+ button: recortar(textoBoton, 20),
207
+ sections: secciones.map((s) => ({
208
+ title: recortar(s.titulo, 24),
209
+ rows: s.opciones.map((o) => ({
210
+ id: o.id,
211
+ title: recortar(o.titulo, 24),
212
+ ...(o.descripcion ? { description: recortar(o.descripcion, 72) } : {}),
213
+ })),
214
+ })),
215
+ },
216
+ },
217
+ });
218
+ }
219
+ async function postMensaje(cred, cuerpo) {
220
+ if (!cred.apiKey)
221
+ return { ok: false, categoria: "credenciales", error: "falta la clave de Kapso" };
222
+ if (!cred.phoneNumberId) {
223
+ return { ok: false, categoria: "credenciales", error: "falta el phoneNumberId del club" };
224
+ }
225
+ const cuerpoTipado = cuerpo;
226
+ if (!cuerpoTipado.to)
227
+ return { ok: false, categoria: "numero", error: "destinatario vacío" };
228
+ const r = await pedir(cred, `${BASE_META}/${encodeURIComponent(cred.phoneNumberId)}/messages`, { method: "POST", body: JSON.stringify(cuerpo) });
229
+ if (!r.ok)
230
+ return r;
231
+ // Cloud API devuelve el id adentro de `messages[0].id`. Si el cuerpo cambia
232
+ // de forma, es preferible un id vacio a tirar: el mensaje YA salio.
233
+ const cuerpoRta = r.datos;
234
+ return { ok: true, id: cuerpoRta?.messages?.[0]?.id ?? "" };
235
+ }
236
+ /**
237
+ * Da de alta un club como cliente de Kapso.
238
+ *
239
+ * `externalCustomerId` lleva el prefijo de la aplicación
240
+ * (`gestionflow:<clubId>`) porque el webhook de conexión trae **solo** el id de
241
+ * cliente y nunca el producto: sin prefijo no hay forma de saber de quién es.
242
+ */
243
+ export async function crearCliente(cred, nombre, clubId, prefijo = "gestionflow") {
244
+ const r = await pedir(cred, `${BASE_PLATAFORMA}/customers`, {
245
+ method: "POST",
246
+ body: JSON.stringify({
247
+ customer: { name: nombre, external_customer_id: `${prefijo}:${clubId}` },
248
+ }),
249
+ });
250
+ if (!r.ok)
251
+ return r;
252
+ const d = r.datos;
253
+ const fila = d?.data ?? d;
254
+ if (!fila?.id)
255
+ return { ok: false, categoria: "rechazado", error: "Kapso no devolvió un id de cliente" };
256
+ return {
257
+ ok: true,
258
+ cliente: { id: fila.id, externalCustomerId: fila.external_customer_id ?? `${prefijo}:${clubId}` },
259
+ };
260
+ }
261
+ /**
262
+ * El link que el club abre para conectar su WhatsApp. Tarda unos cinco minutos.
263
+ *
264
+ * ## Dos decisiones que NO se pueden cambiar después
265
+ *
266
+ * - **`allowed_connection_types: ["coexistence"]`**, siempre. La alternativa
267
+ * (`dedicated`) le apaga la app de WhatsApp Business en el teléfono, y un
268
+ * club usa ese WhatsApp todo el día. Su ventaja —mil mensajes por segundo—
269
+ * resuelve un problema que no tenemos.
270
+ * - **`metaBilling`** decide quién le paga a Meta, y cambiarlo en una WABA ya
271
+ * conectada es un ticket de soporte. Peor: borrar el número y reconectar con
272
+ * el otro modo **no lo cambia**, porque el modo es de la WABA y la WABA
273
+ * sobrevive al número. Solo se lee en el PRIMER registro.
274
+ *
275
+ * `partner_managed` (nosotros le pagamos a Meta y lo revendemos) **no
276
+ * funciona con una WABA en pesos**, y una WABA argentina con tarjeta local ya
277
+ * configurada queda en pesos. Por eso el modo se decide **por club** y no por
278
+ * entorno: una variable global obliga a todos al caso raro.
279
+ *
280
+ * Solo hay **un link activo por cliente**: crear otro revoca el anterior. Y
281
+ * sobre un número YA conectado no se emite un link nuevo — puede intentar una
282
+ * segunda conexión y arruinar la que funciona.
283
+ */
284
+ export async function crearSetupLink(cred, clienteId, opciones = {}) {
285
+ const r = await pedir(cred, `${BASE_PLATAFORMA}/customers/${encodeURIComponent(clienteId)}/setup_links`, {
286
+ method: "POST",
287
+ body: JSON.stringify({
288
+ setup_link: {
289
+ language: opciones.idioma ?? "es",
290
+ allowed_connection_types: ["coexistence"],
291
+ meta_billing_mode: opciones.metaBilling ?? "customer_managed",
292
+ ...(opciones.volverBienA ? { success_redirect_url: opciones.volverBienA } : {}),
293
+ ...(opciones.volverMalA ? { failure_redirect_url: opciones.volverMalA } : {}),
294
+ ...(opciones.colorPrimario ? { theme_config: { primary_color: opciones.colorPrimario } } : {}),
295
+ },
296
+ }),
297
+ });
298
+ if (!r.ok)
299
+ return r;
300
+ const d = r.datos;
301
+ const fila = d?.data ?? d;
302
+ if (!fila?.url)
303
+ return { ok: false, categoria: "rechazado", error: "Kapso no devolvió una URL" };
304
+ return { ok: true, url: fila.url, expira: fila.expires_at };
305
+ }
306
+ /**
307
+ * Lee un evento del webhook. **Nunca tira.**
308
+ *
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.
312
+ *
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.
316
+ */
317
+ /**
318
+ * Cuánto texto se acepta de un mensaje entrante.
319
+ *
320
+ * WhatsApp topea un mensaje en 4096 caracteres, así que más que eso no viene de
321
+ * una persona. Sin corte, ese cuerpo se guarda entero en la tabla de mensajes y
322
+ * se le pasa al asistente: un solo POST con megabytes de texto llena la base y
323
+ * el hilo del club queda inservible.
324
+ */
325
+ const MAXIMO_TEXTO = 4096;
326
+ const soloLoQueEntra = (v) => String(v ?? "").slice(0, MAXIMO_TEXTO);
327
+ export function leerEventoWebhook(crudo) {
328
+ if (!crudo || typeof crudo !== "object")
329
+ return { tipo: "ignorado", motivo: "cuerpo vacío" };
330
+ const e = crudo;
331
+ const evento = String(e.event ?? e.type ?? "");
332
+ const datos = e.data ?? e.payload ?? e;
333
+ if (evento === "whatsapp.phone_number.created") {
334
+ const id = datos?.phone_number_id ?? datos?.id;
335
+ const cliente = datos?.customer_id ?? datos?.external_customer_id;
336
+ if (!id || !cliente)
337
+ return { tipo: "ignorado", motivo: "conexión sin ids" };
338
+ return {
339
+ tipo: "numero_conectado",
340
+ clienteId: String(cliente),
341
+ phoneNumberId: String(id),
342
+ telefono: datos?.display_phone_number ? String(datos.display_phone_number) : undefined,
343
+ };
344
+ }
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
+ // Kapso manda UN evento por estado. No existe
353
+ // `whatsapp.message.status_updated`: suscribirse a ese nombre inventado deja
354
+ // las marcas de entrega vacias para siempre sin que nada parezca roto.
355
+ const estado = /^whatsapp\.message\.(delivered|read|failed|sent)$/.exec(evento);
356
+ if (estado) {
357
+ const id = datos?.message?.id ?? datos?.id ?? datos?.message_id;
358
+ if (!id)
359
+ 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;
398
+ 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
+ },
408
+ };
409
+ }
410
+ return { tipo: "ignorado", motivo: `evento no manejado: ${evento || "(sin nombre)"}` };
411
+ }
412
+ /**
413
+ * ¿Se le puede mandar texto libre a esta persona?
414
+ *
415
+ * La ventana de servicio son 24 horas desde el ÚLTIMO mensaje que ELLA mandó.
416
+ * Afuera, solo plantillas. `null` significa "nunca escribió", que también es
417
+ * afuera.
418
+ */
419
+ export function dentroDeVentana24h(ultimoMensajeEntrante, ahora = new Date()) {
420
+ if (!ultimoMensajeEntrante)
421
+ return false;
422
+ const ms = ahora.getTime() - ultimoMensajeEntrante.getTime();
423
+ // Un mensaje del futuro (reloj desfasado) no abre la ventana: mandar texto
424
+ // libre afuera de ella da 422 y el aviso se pierde entero.
425
+ return ms >= 0 && ms < 24 * 3600 * 1000;
426
+ }
427
+ /**
428
+ * Elige solo: texto adentro de la ventana, plantilla afuera.
429
+ *
430
+ * Es el patrón de todo aviso del producto, y escrito a mano en cada llamada se
431
+ * olvida justo en el aviso que sale de madrugada.
432
+ */
433
+ export function enviarAviso(cred, para, opciones) {
434
+ if (dentroDeVentana24h(opciones.ultimoMensajeEntrante, opciones.ahora)) {
435
+ return enviarTexto(cred, para, opciones.texto);
436
+ }
437
+ return enviarPlantilla(cred, para, opciones.plantilla, opciones.parametros ?? [], opciones.idioma);
438
+ }
439
+ async function pedir(cred, url, init) {
440
+ if (!cred.apiKey)
441
+ return { ok: false, categoria: "credenciales", error: "falta la clave de Kapso" };
442
+ const hacerFetch = cred.fetch ?? globalThis.fetch;
443
+ if (!hacerFetch)
444
+ return { ok: false, categoria: "red", error: "no hay fetch disponible" };
445
+ const control = new AbortController();
446
+ const corte = setTimeout(() => control.abort(), cred.timeoutMs ?? 15_000);
447
+ let rta;
448
+ try {
449
+ rta = await hacerFetch(url, {
450
+ ...init,
451
+ signal: control.signal,
452
+ headers: {
453
+ "X-API-Key": cred.apiKey,
454
+ "Content-Type": "application/json",
455
+ ...(init.headers ?? {}),
456
+ },
457
+ });
458
+ }
459
+ catch (e) {
460
+ // Todo lo que sea salir a la red es `red`: reintentar sirve.
461
+ return { ok: false, categoria: "red", error: mensajeDe(e) };
462
+ }
463
+ finally {
464
+ clearTimeout(corte);
465
+ }
466
+ 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
+ }
474
+ if (rta.ok)
475
+ return { ok: true, datos };
476
+ return {
477
+ ok: false,
478
+ categoria: categoriaDe(rta.status, texto),
479
+ error: detalleDe(datos, texto, rta.status),
480
+ estado: rta.status,
481
+ };
482
+ }
483
+ function categoriaDe(estado, texto) {
484
+ // 402 es la facturacion de Meta pausada, y no se arregla reintentando: hasta
485
+ // que alguien cargue un medio de pago, NINGUN envio pago sale.
486
+ if (estado === 402)
487
+ return "facturacion";
488
+ if (estado === 401 || estado === 403)
489
+ return "credenciales";
490
+ if (estado === 429)
491
+ return "limite";
492
+ if (estado >= 500)
493
+ return "red";
494
+ const t = texto.toLowerCase();
495
+ if (t.includes("template"))
496
+ return "plantilla";
497
+ if (t.includes("24") && (t.includes("window") || t.includes("ventana")))
498
+ return "ventana";
499
+ if (t.includes("phone") || t.includes("recipient"))
500
+ return "numero";
501
+ return "rechazado";
502
+ }
503
+ function detalleDe(datos, texto, estado) {
504
+ const d = datos;
505
+ const msg = d?.error?.message ?? d?.message;
506
+ if (typeof msg === "string" && msg)
507
+ return msg;
508
+ return texto ? texto.slice(0, 500) : `HTTP ${estado}`;
509
+ }
510
+ function leerFecha(o) {
511
+ const crudo = o?.timestamp ?? o?.created_at ?? o?.occurred_at;
512
+ if (crudo == null)
513
+ return new Date();
514
+ // Cloud API manda el timestamp en SEGUNDOS y como texto. Pasarlo directo a
515
+ // `new Date()` da 1970, y un aviso fechado en 1970 queda fuera de todo rango.
516
+ if (typeof crudo === "number")
517
+ return new Date(crudo < 1e12 ? crudo * 1000 : crudo);
518
+ if (/^\d+$/.test(String(crudo))) {
519
+ const n = Number(crudo);
520
+ return new Date(n < 1e12 ? n * 1000 : n);
521
+ }
522
+ const f = new Date(String(crudo));
523
+ return Number.isNaN(f.getTime()) ? new Date() : f;
524
+ }
525
+ function recortar(s, largo) {
526
+ const t = String(s ?? "").trim();
527
+ return t.length <= largo ? t : `${t.slice(0, largo - 1)}…`;
528
+ }
529
+ function mensajeDe(e) {
530
+ if (e instanceof Error)
531
+ return e.name === "AbortError" ? "tiempo de espera agotado" : e.message;
532
+ return String(e);
533
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@mafesoftware/kapso-wa",
3
+ "version": "0.1.1",
4
+ "description": "WhatsApp por Kapso (proxy de la Cloud API de Meta). fetch inyectable.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "publishConfig": {
16
+ "access": "public",
17
+ "provenance": true
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "README.md",
22
+ "CHANGELOG.md"
23
+ ],
24
+ "scripts": {
25
+ "build": "tsc -p tsconfig.build.json",
26
+ "test": "vitest run",
27
+ "typecheck": "tsc --noEmit"
28
+ },
29
+ "dependencies": {},
30
+ "engines": {
31
+ "node": ">=20"
32
+ },
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/mafesoftware/paquetes.git",
36
+ "directory": "packages/kapso-wa"
37
+ }
38
+ }