@johpaz/hive-sdk 0.4.7 → 0.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -22,6 +22,74 @@
22
22
  `buildAgentContext`), de las que depende el aislamiento entre inquilinos del
23
23
  log causal descrito en *Corregido*.
24
24
 
25
+ ### WhatsApp por la API oficial de Meta
26
+
27
+ - **Canal nuevo `whatsapp_cloud`**: WhatsApp Business por la Cloud API, que es
28
+ lo que puede usar una empresa. Incluye `WhatsAppCloudClient` (texto con
29
+ partido automático en 4096 caracteres, plantillas, audio, marcar leído con
30
+ "escribiendo…" y descarga de medios), las funciones de webhook
31
+ (`verifyWhatsAppSignature`, `verifyWhatsAppChallenge`, `parseWhatsAppWebhook`)
32
+ y el canal `WhatsAppCloudChannel`. Sin dependencias nuevas: sólo `fetch`.
33
+ - El gateway enruta `GET|POST /webhooks/whatsapp-cloud/:accountId` cuando se le
34
+ pasa `channelManager`. Meta exige HTTPS público, así que hace falta un proxy
35
+ inverso o un túnel por delante.
36
+ - La versión del Graph se resuelve en un solo lugar (`META_GRAPH_API_VERSION`,
37
+ por defecto **v26.0**). Cada versión caduca a los ~2 años y Meta redirige en
38
+ silencio a la más vieja que siga viva; tenerla centralizada es lo que evita
39
+ enterarse tarde.
40
+ - Fuera de la ventana de 24 h el canal manda la plantilla de
41
+ `windowFallbackTemplate`, y si no hay ninguna configurada lanza un error que
42
+ lo dice (131047). La narración de progreso no se envía por defecto
43
+ (`sendProgress: false`): desde el 1/10/2026 Meta cobra cada mensaje de
44
+ servicio dentro de la ventana.
45
+ - El parser recorre todas las `entry` y `changes` del webhook, y el canal
46
+ descarta los reintentos de Meta por id de mensaje.
47
+ - **Baileys se carga recién al conectar el canal `whatsapp`.** Antes se
48
+ importaba al cargar el índice del SDK, así que cualquier consumidor —aunque
49
+ no usara canales— se traía Baileys entero y su parche de
50
+ `process.stderr.write` en cada proceso. Quien usa el canal por código QR no
51
+ ve ningún cambio.
52
+
53
+ ### Catálogo compartido entre inquilinos
54
+
55
+ - **El catálogo se instala una sola vez.** `tools`, `skills` y `ethics` dejan de
56
+ copiarse en la partición de cada inquilino: su contenido vive en la colección
57
+ sin prefijo —la misma que ve una instalación local— y cada inquilino guarda
58
+ únicamente lo que activó, en `catalogActivations`. Un enjambre nuevo arranca
59
+ con **cero escrituras** de catálogo, y encender una tool guarda una fila de
60
+ elección en lugar de una copia de la fila entera.
61
+ - **Providers y modelos ya no se resiembran dentro de un inquilino.** En un host
62
+ multi-inquilino el catálogo que vale es el del host, así que `seedAllData()`
63
+ saltea el estático cuando hay tenant activo. Antes borraba y recreaba los 139
64
+ modelos de `SEED_DATA` en la partición de cada enjambre, en cada arranque, para
65
+ que un instante después los pisara el host.
66
+ - Lo que un inquilino **crea** sigue siendo suyo y privado: las tools de un
67
+ endpoint de API, o una skill o un código de ética propios, se escriben en su
68
+ partición como siempre. Editar el contenido de una fila del catálogo también
69
+ deja una copia privada, y borrarla la oculta sólo para él.
70
+ - API nueva en `@johpaz/hive-sdk/storage`: `setCatalogActivation`,
71
+ `clearCatalogActivation`, `listCatalogActivations`, `sharedCatalogCol`,
72
+ `CATALOG_COLLECTIONS` y el tipo `DocStore`, que es lo que ahora devuelve
73
+ `col()` — la clase `Collection` de hive-db lo satisface tal cual, así que no
74
+ cambia nada para quien la recibe.
75
+ - **Sin inquilino en scope no cambia nada**: una instalación local sigue viendo
76
+ una sola partición, con el catálogo y su `active` en la misma fila.
77
+ - **El índice de capacidades también se comparte, y eso destapa dos fallas que
78
+ ya existían.** Todo documento declara ahora su ámbito —el inquilino que lo
79
+ escribió, o `_` si es del catálogo— y `searchCapabilities()` consulta los dos
80
+ cuando hay inquilino activo:
81
+ - Antes el catálogo se indexaba **sin** filtro de inquilino y la búsqueda
82
+ desde un enjambre filtraba **por** su inquilino, así que dentro de un
83
+ enjambre no se encontraba NADA del catálogo: ni una tool ni una skill. El
84
+ agente sólo descubría sus tools de MCP y las de sus endpoints.
85
+ - Y un reindexado del catálogo —el que corre en cada arranque del gateway—
86
+ borraba por `type` a secas, llevándose por delante lo que cada inquilino
87
+ tenía indexado. Ahora el borrado va acotado a su ámbito.
88
+ - Encima de la búsqueda manda la elección: una capacidad que el inquilino
89
+ apagó no se le ofrece, aunque esté en el catálogo y puntúe primero.
90
+ Cubierto por `packages/core/src/agent/capability-search.test.ts` y
91
+ `packages/core/src/storage/catalog.test.ts`.
92
+
25
93
  ### Corregido
26
94
 
27
95
  - **Con un tenant activo, el log causal se apagaba en lugar de acotarse.**
package/README.md CHANGED
@@ -271,4 +271,4 @@ npm view @johpaz/hive-sdk dist-tags # verificar después del release
271
271
 
272
272
  ---
273
273
 
274
- *Hive SDK v0.4.7 — MIT*
274
+ *Hive SDK v0.4.8 — MIT*
@@ -353,6 +353,7 @@ await manager.initialize();
353
353
  import {
354
354
  TelegramChannel,
355
355
  DiscordChannel,
356
+ WhatsAppCloudChannel,
356
357
  WhatsAppChannel,
357
358
  SlackChannel,
358
359
  WebChatChannel,
@@ -364,8 +365,20 @@ const telegram = new TelegramChannel({ botToken: process.env.TELEGRAM_BOT_TOKEN!
364
365
  // Discord
365
366
  const discord = new DiscordChannel({ botToken: process.env.DISCORD_BOT_TOKEN! });
366
367
 
367
- // WhatsApp
368
- const whatsapp = new WhatsAppChannel();
368
+ // WhatsApp por la API oficial de Meta — el camino para un negocio
369
+ const whatsapp = new WhatsAppCloudChannel({
370
+ enabled: true,
371
+ accountId: "ventas",
372
+ phoneNumberId: process.env.WHATSAPP_PHONE_NUMBER_ID!,
373
+ accessToken: process.env.WHATSAPP_ACCESS_TOKEN!,
374
+ appSecret: process.env.WHATSAPP_APP_SECRET!,
375
+ verifyToken: process.env.WHATSAPP_VERIFY_TOKEN!,
376
+ dmPolicy: "open",
377
+ allowFrom: [],
378
+ });
379
+
380
+ // WhatsApp por código QR (Baileys). No es oficial: uso personal.
381
+ const whatsappQr = new WhatsAppChannel();
369
382
 
370
383
  // Slack
371
384
  const slack = new SlackChannel({ botToken: process.env.SLACK_BOT_TOKEN! });
@@ -374,6 +387,45 @@ const slack = new SlackChannel({ botToken: process.env.SLACK_BOT_TOKEN! });
374
387
  const webchat = new WebChatChannel();
375
388
  ```
376
389
 
390
+ ### WhatsApp: cuál de los dos
391
+
392
+ | | `whatsapp_cloud` | `whatsapp` |
393
+ |---|---|---|
394
+ | Qué es | La API oficial de Meta (Cloud API) | Baileys, WhatsApp Web por código QR |
395
+ | Para quién | Un negocio: número de WhatsApp Business propio, plantillas de marketing y atribución de los anuncios que abren la conversación | Uso personal |
396
+ | Riesgo | Ninguno: es el camino soportado | Va contra los términos de WhatsApp; el número puede terminar bloqueado |
397
+ | Cómo recibe | Webhook: hace falta una URL HTTPS pública | Conexión propia, sin URL pública |
398
+
399
+ El canal oficial no abre ninguna conexión: Meta empuja los mensajes a una URL.
400
+ El gateway la sirve en `/webhooks/whatsapp-cloud/<cuenta>` si se le pasa el
401
+ manager, y esa es la que se registra en el panel de Meta junto con el
402
+ `verifyToken`.
403
+
404
+ ```typescript
405
+ const channelManager = new ChannelManager(await loadConfig());
406
+ await channelManager.initialize();
407
+
408
+ // Sin esto, el gateway no tiene a quién entregarle lo que manda Meta.
409
+ await startGateway({ channelManager });
410
+ ```
411
+
412
+ Tres cosas que conviene saber antes de ponerlo en producción:
413
+
414
+ - **La ventana de 24 h.** Pasado ese tiempo desde el último mensaje del cliente,
415
+ Meta sólo acepta plantillas aprobadas (error 131047). Con
416
+ `windowFallbackTemplate` el canal manda esa plantilla; sin ella, lanza un
417
+ error que lo explica en vez de fallar en silencio.
418
+ - **Cada mensaje se cobra.** Desde el 1 de octubre de 2026 los mensajes de
419
+ servicio dentro de la ventana también se pagan, así que la narración de
420
+ progreso del agente no se envía salvo que se encienda `sendProgress`. En su
421
+ lugar se renueva el "escribiendo…", que es gratis.
422
+ - **4096 caracteres por mensaje.** El cliente parte las respuestas largas solo,
423
+ cortando entre párrafos.
424
+
425
+ La versión del Graph sale de `META_GRAPH_API_VERSION` (por defecto la estable
426
+ más reciente). Conviene fijarla a conciencia: cada versión caduca a los ~2 años
427
+ y Meta redirige las llamadas a la más vieja que siga viva, sin avisar.
428
+
377
429
  ---
378
430
 
379
431
  ## Tool Runtime
@@ -483,6 +535,33 @@ const skills = await selectSkills("investigar en la web");
483
535
  Una tool declarada con `defineTool` y pasada a `createAgent` queda indexada
484
536
  automáticamente, así que el modelo puede descubrirla igual que a las nativas.
485
537
 
538
+ ### Catálogo compartido entre inquilinos
539
+
540
+ En un host multi-inquilino (varios enjambres en una sola HiveDB, ver
541
+ `runInTenant`) el catálogo —`tools`, `skills` y `ethics`— es contenido de la
542
+ INSTALACIÓN, no de cada inquilino: su contenido se instala una sola vez en la
543
+ colección sin prefijo, y cada inquilino guarda únicamente lo que activó.
544
+
545
+ ```typescript
546
+ import { col, runInTenant, setCatalogActivation } from "@johpaz/hive-sdk";
547
+
548
+ // Sin inquilino: esto instala el catálogo, una vez para todos.
549
+ await ensureHiveDb();
550
+
551
+ await runInTenant(tenantKey, async () => {
552
+ const tools = await col<ToolDoc>("tools");
553
+ await tools.scan({}); // ve el catálogo entero
554
+
555
+ // Encender una tool para ESTE inquilino: guarda su elección, no una copia.
556
+ await setCatalogActivation("tools", "web_search", { active: true });
557
+ });
558
+ ```
559
+
560
+ Lo que el inquilino crea —la tool de un endpoint de API, una skill propia— se
561
+ escribe en su partición y no lo ve nadie más. Editar el contenido de una fila del
562
+ catálogo deja una copia privada; borrarla la oculta sólo para él. Sin inquilino
563
+ en scope nada de esto se activa y `col()` se comporta como siempre.
564
+
486
565
  ---
487
566
 
488
567
  ## Config
@@ -14,13 +14,33 @@ de proceso.
14
14
  - SheetJS CE se instala desde la distribución oficial 0.20.3; el paquete npm
15
15
  abandonado en 0.18.5 no forma parte del grafo.
16
16
  - Baileys está fijado exactamente en `7.0.0-rc14` para evitar retroceder a una
17
- release candidate vulnerable.
17
+ release candidate vulnerable. Además se **carga bajo demanda**: entra en el
18
+ proceso sólo si alguien conecta el canal `whatsapp` por código QR. Un
19
+ consumidor que no lo use —hive-cloud, por ejemplo, que usa el canal oficial—
20
+ no carga su grafo ni su parche de `process.stderr.write`.
18
21
  - PptxGenJS 4.0.1 se conserva como artefacto ESM vendorizado, con licencia,
19
22
  procedencia y SHA-256. No se instala su dependencia muerta `image-size`.
20
23
 
21
24
  Toda actualización del artefacto PPTX debe verificar su origen y hash, ejecutar
22
25
  la prueba OOXML y volver a ejecutar el audit.
23
26
 
27
+ ## Webhooks de WhatsApp (Cloud API)
28
+
29
+ El canal `whatsapp_cloud` recibe por una URL pública, así que todo lo que llega
30
+ es no confiable hasta comprobar dos cosas:
31
+
32
+ - **La firma.** `verifySignature` recalcula el HMAC-SHA256 del cuerpo **crudo**
33
+ con el secreto de la app de Meta y lo compara con `X-Hub-Signature-256`
34
+ usando `timingSafeEqual`. Sin cabecera, sin secreto o con una firma que no
35
+ coincide, el webhook responde 401 y no se procesa nada. El cuerpo se lee como
36
+ texto antes de parsearlo: firmar el JSON reserializado daría distinto.
37
+ - **El token de verificación.** El `hub.verify_token` del alta se compara
38
+ también con `timingSafeEqual`; si no coincide, 403.
39
+
40
+ El canal además sólo atiende eventos de su propio `phone_number_id` —una misma
41
+ cuenta de WhatsApp Business puede tener varios números apuntando a la misma
42
+ URL— y descarta por id los reintentos de Meta.
43
+
24
44
  ## Archivos PDF
25
45
 
26
46
  Antes de leer un PDF, `office_leer_pdf` exige un archivo regular y limita el
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johpaz/hive-sdk",
3
- "version": "0.4.7",
3
+ "version": "0.4.8",
4
4
  "private": false,
5
5
  "description": "Hive SDK — The Agent Harness SDK. Build, deploy, and scale AI agent applications with multi-channel support, context engineering, and swarm orchestration.",
6
6
  "license": "MIT",
@@ -16,6 +16,13 @@ export default {
16
16
  webchat: { enabled: true },
17
17
  telegram: { enabled: false },
18
18
  discord: { enabled: false },
19
+ // WhatsApp por la API oficial de Meta. Es el camino para un negocio:
20
+ // número propio de WhatsApp Business, plantillas y atribución de los
21
+ // anuncios que abren la conversación. Recibe por webhook, así que hace
22
+ // falta exponer `/webhooks/whatsapp-cloud/<cuenta>` con HTTPS público.
23
+ whatsapp_cloud: { enabled: false },
24
+ // WhatsApp por código QR (Baileys). No es oficial: sirve para uso
25
+ // personal, no para un negocio, y el número puede terminar bloqueado.
19
26
  whatsapp: { enabled: false },
20
27
  slack: { enabled: false },
21
28
  },
@@ -31,6 +31,9 @@ async function main() {
31
31
  host: config.gateway?.host,
32
32
  port: config.gateway?.port,
33
33
  agentId: coordinatorAgent.id,
34
+ // Los canales que reciben por webhook —WhatsApp por la API oficial de
35
+ // Meta— entran por el gateway, así que necesita el manager.
36
+ channelManager,
34
37
  });
35
38
 
36
39
  log.info(`{{APP_NAME}} is running at http://${gateway.hostname}:${gateway.port}`);
@@ -21,10 +21,27 @@
21
21
  import type { IndexDoc } from "@johpaz/hive-db";
22
22
  import { getHiveDb } from "../storage/hivedb.ts";
23
23
  import { currentTenant, qualifyDocId, unqualifyDocId, scopedFilterValue } from "../storage/tenant.ts";
24
+ import { listCatalogActivations } from "../storage/catalog.ts";
24
25
  import { logger } from "../utils/logger.ts";
25
26
 
26
27
  const log = logger.child("capability-search");
27
28
 
29
+ /**
30
+ * Ámbito de los documentos que no son de ningún inquilino: el catálogo.
31
+ *
32
+ * Es el mismo `"_"` que `scopedFilterValue()` usa cuando no hay tenant, y por
33
+ * eso sirve de las dos maneras: como valor del filtro `tenant` para poder
34
+ * BUSCAR el catálogo desde dentro de un inquilino, y como parte de
35
+ * `tenant__type` para que un reindexado del catálogo BORRE sólo lo suyo.
36
+ *
37
+ * Antes el catálogo se indexaba sin filtro `tenant` alguno, y eso rompía las dos
38
+ * cosas: desde un enjambre la búsqueda filtraba por su tenant y no encontraba
39
+ * NADA del catálogo —ni una tool, ni una skill—, y un reindexado desde fuera
40
+ * borraba por `type` a secas, llevándose por delante los documentos de todos los
41
+ * inquilinos (las tools de sus endpoints, sus tools de MCP).
42
+ */
43
+ const CATALOGO = "_";
44
+
28
45
  export type CapabilityType = "tool" | "skill" | "playbook" | "mcp" | "agent";
29
46
 
30
47
  export interface CapabilityHit {
@@ -91,17 +108,22 @@ export async function searchCapabilities(
91
108
  // type; the single-type and all-types cases are one engine call.
92
109
  //
93
110
  // El índice semántico es UNO solo para todos los inquilinos (no hay
94
- // "colección" que prefijar), así que el tenant entra como un filtro más que
95
- // el motor AND-ea con el resto. Sin tenant no se añade nada: así un índice ya
96
- // construido por la app de escritorio sigue respondiendo igual.
111
+ // "colección" que prefijar), así que el ámbito entra como un filtro más que el
112
+ // motor AND-ea con el resto.
113
+ //
114
+ // Con inquilino activo se consulta DOS veces: lo suyo —las tools de sus
115
+ // endpoints, sus tools de MCP— y el catálogo compartido, que se instala una
116
+ // sola vez y es de todos (ver storage/catalog.ts). Sin inquilino se consulta
117
+ // sin filtro de ámbito: una instalación local tiene una sola partición y así
118
+ // un índice ya construido sigue respondiendo igual.
97
119
  const tenant = currentTenant();
98
- const tenantFilter = tenant ? [{ field: "tenant", value: tenant }] : [];
99
- const queries = types
100
- ? types.map((t) => ({
101
- type: t,
102
- filters: [...tenantFilter, { field: "type", value: t }],
103
- }))
104
- : [{ type: undefined, filters: tenantFilter.length ? tenantFilter : undefined }];
120
+ const ambitos = tenant ? [tenant, CATALOGO] : [null];
121
+ const queries = ambitos.flatMap((ambito) => {
122
+ const filtroAmbito = ambito ? [{ field: "tenant", value: ambito }] : [];
123
+ return types
124
+ ? types.map((t) => ({ filters: [...filtroAmbito, { field: "type", value: t }] }))
125
+ : [{ filters: filtroAmbito.length ? filtroAmbito : undefined }];
126
+ });
105
127
 
106
128
  const merged = new Map<string, CapabilityHit>();
107
129
  for (const q of queries) {
@@ -129,7 +151,10 @@ export async function searchCapabilities(
129
151
  }
130
152
  }
131
153
 
154
+ const apagados = tenant && merged.size > 0 ? await apagadosParaElInquilino() : null;
155
+
132
156
  const results = Array.from(merged.values())
157
+ .filter((hit) => !apagados?.has(hit.id))
133
158
  .sort((a, b) => b.score - a.score)
134
159
  .slice(0, k);
135
160
 
@@ -140,6 +165,27 @@ export async function searchCapabilities(
140
165
  return results;
141
166
  }
142
167
 
168
+ /**
169
+ * Lo que este inquilino apagó del catálogo compartido.
170
+ *
171
+ * El índice del catálogo es uno solo, así que no puede llevar la elección de
172
+ * nadie: se filtra al leer. Sólo se descarta lo que el inquilino decidió
173
+ * explícitamente —apagar u ocultar—; lo que nunca tocó hereda lo que diga el
174
+ * catálogo, igual que en la colección (ver storage/catalog.ts).
175
+ *
176
+ * Ofrecerle al modelo una capacidad que su workspace apagó es hacerle perder un
177
+ * turno, además de contarle que existe algo que no puede usar.
178
+ */
179
+ async function apagadosParaElInquilino(): Promise<Set<string>> {
180
+ const apagados = new Set<string>();
181
+ for (const [tipo, coleccion] of [["tool", "tools"], ["skill", "skills"]] as const) {
182
+ for (const eleccion of await listCatalogActivations(coleccion)) {
183
+ if (!eleccion.active || eleccion.hidden) apagados.add(`${tipo}:${eleccion.itemId}`);
184
+ }
185
+ }
186
+ return apagados;
187
+ }
188
+
143
189
  /**
144
190
  * Keep only hits scoring at least `ratio` of the top hit. This replaces the
145
191
  * old absolute negative-bm25 thresholds: relevance is relative to the best
@@ -169,12 +215,12 @@ export async function replaceCapabilityDocs(
169
215
  // `deleteByFilter` acepta UN SOLO filtro, así que en una base compartida
170
216
  // borrar por `type` se llevaría por delante los documentos de todos los
171
217
  // inquilinos. El campo sintético `tenant__type` (ver scopedFilterValue)
172
- // mantiene el borrado en un filtro y acotado a este tenant.
173
- await db.deleteByFilter(
174
- currentTenant()
175
- ? { field: "tenant__type", value: scopedFilterValue(type) }
176
- : { field: "type", value: type }
177
- );
218
+ // mantiene el borrado en un filtro y acotado a un ámbito.
219
+ //
220
+ // Sin inquilino el ámbito es el catálogo (`_`), no "todo": reindexar el
221
+ // catálogo al arrancar borraba las tools de los endpoints y las de MCP de cada
222
+ // inquilino, que nadie volvía a escribir hasta que ese enjambre se reconectara.
223
+ await db.deleteByFilter({ field: "tenant__type", value: scopedFilterValue(type) });
178
224
  if (docs.length === 0) return;
179
225
  await db.upsertBatch(docs.map(toIndexDoc));
180
226
  }
@@ -198,7 +244,6 @@ export async function deleteCapabilitiesByServer(serverId: string): Promise<void
198
244
 
199
245
  function toIndexDoc(doc: CapabilityDoc): IndexDoc {
200
246
  const extra = doc.extraFilters ?? [];
201
- const tenant = currentTenant();
202
247
  return {
203
248
  id: qualifyDocId(`${doc.type}:${doc.rawId}`),
204
249
  name: doc.name,
@@ -207,20 +252,16 @@ function toIndexDoc(doc: CapabilityDoc): IndexDoc {
207
252
  filters: [
208
253
  { field: "type", value: doc.type },
209
254
  ...extra,
210
- // Con tenant activo, cada filtro lleva además un gemelo `tenant__<campo>`.
211
- // Es lo que permite que los borrados masivos —que sólo aceptan un
212
- // filtro— sigan acotados a este inquilino. Sin tenant no se emite nada,
213
- // así que un índice ya construido conserva exactamente su forma.
214
- ...(tenant
215
- ? [
216
- { field: "tenant", value: tenant },
217
- { field: "tenant__type", value: scopedFilterValue(doc.type) },
218
- ...extra.map((f) => ({
219
- field: `tenant__${f.field}`,
220
- value: scopedFilterValue(f.value),
221
- })),
222
- ]
223
- : []),
255
+ // Todo documento declara su ámbito —el inquilino que lo escribió, o `_` si
256
+ // es del catálogo—, y cada filtro lleva además su gemelo
257
+ // `tenant__<campo>`. Con eso la búsqueda puede pedir un ámbito concreto y
258
+ // los borrados masivos —que aceptan un solo filtro— quedan acotados a él.
259
+ { field: "tenant", value: currentTenant() ?? CATALOGO },
260
+ { field: "tenant__type", value: scopedFilterValue(doc.type) },
261
+ ...extra.map((f) => ({
262
+ field: `tenant__${f.field}`,
263
+ value: scopedFilterValue(f.value),
264
+ })),
224
265
  ],
225
266
  };
226
267
  }
@@ -3,5 +3,6 @@ export * from "./telegram.ts";
3
3
  export * from "./discord.ts";
4
4
  export * from "./webchat.ts";
5
5
  export * from "./whatsapp.ts";
6
+ export * from "./whatsapp-cloud/index.ts";
6
7
  export * from "./slack.ts";
7
8
  export * from "./manager.ts";
@@ -5,6 +5,7 @@ import { createTelegramChannel, type TelegramConfig } from "./telegram.ts";
5
5
  import { createDiscordChannel, type DiscordConfig } from "./discord.ts";
6
6
  import { createWebChatChannel, type WebChatConfig } from "./webchat.ts";
7
7
  import { createWhatsAppChannel, WhatsAppChannel, type WhatsAppConfig } from "./whatsapp.ts";
8
+ import { createWhatsAppCloudChannel, type WhatsAppCloudConfig } from "./whatsapp-cloud/index.ts";
8
9
  import { createSlackChannel, type SlackConfig } from "./slack.ts";
9
10
  import { col } from "../storage/hive.ts";
10
11
  import type { ChannelDoc, AgentDoc, UserIdentityDoc } from "../storage/collections.ts";
@@ -216,6 +217,25 @@ export class ChannelManager {
216
217
  break;
217
218
  }
218
219
 
220
+ case "whatsapp_cloud":
221
+ // El de empresa: número de WhatsApp Business propio por la API
222
+ // oficial. No abre ninguna conexión — espera el webhook de Meta.
223
+ channel = createWhatsAppCloudChannel({
224
+ enabled: true,
225
+ accountId,
226
+ phoneNumberId: config.phoneNumberId as string,
227
+ accessToken: config.accessToken as string,
228
+ appSecret: config.appSecret as string,
229
+ verifyToken: config.verifyToken as string,
230
+ graphVersion: config.graphVersion as string | undefined,
231
+ dmPolicy: (config.dmPolicy as "open" | "pairing" | "allowlist") ?? "allowlist",
232
+ allowFrom: (config.allowFrom as string[]) ?? [],
233
+ sendProgress: (config.sendProgress as boolean) ?? false,
234
+ windowFallbackTemplate:
235
+ config.windowFallbackTemplate as WhatsAppCloudConfig["windowFallbackTemplate"],
236
+ } as WhatsAppCloudConfig);
237
+ break;
238
+
219
239
  case "slack":
220
240
  channel = createSlackChannel({
221
241
  enabled: true,
@@ -374,6 +394,26 @@ export class ChannelManager {
374
394
  }
375
395
  }
376
396
 
397
+ /**
398
+ * Entrega al canal correspondiente lo que llega por webhook — hoy, WhatsApp
399
+ * por la API oficial de Meta.
400
+ *
401
+ * Vive acá y no en el gateway porque quién tiene levantada cada cuenta lo
402
+ * sabe el manager; el gateway sólo ve una URL.
403
+ */
404
+ async handleWebhook(type: string, accountId: string, req: Request): Promise<Response> {
405
+ const channel = this.channels.get(`${type}:${accountId}`) as
406
+ | { handleWebhook?: (req: Request) => Promise<Response> }
407
+ | undefined;
408
+
409
+ if (typeof channel?.handleWebhook !== "function") {
410
+ this.log.warn(`webhook para ${type}:${accountId}, que no está levantado`);
411
+ return new Response("Unknown channel account", { status: 404 });
412
+ }
413
+
414
+ return channel.handleWebhook(req);
415
+ }
416
+
377
417
  getChannelStatus(type: string, accountId: string): { status: string; qrCode?: string } {
378
418
  const key = `${type}:${accountId}`;
379
419
  const channel = this.channels.get(key);