@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 +68 -0
- package/README.md +1 -1
- package/docs/API-TOOLS-SKILLS-CHANNELS.md +81 -2
- package/docs/SECURITY-GUARDRAILS.md +21 -1
- package/package.json +1 -1
- package/packages/cli/templates/hive-app/hive.config.ts +7 -0
- package/packages/cli/templates/hive-app/src/main.ts +3 -0
- package/packages/core/src/agent/capability-search.ts +72 -31
- package/packages/core/src/channels/index.ts +1 -0
- package/packages/core/src/channels/manager.ts +40 -0
- package/packages/core/src/channels/whatsapp-cloud/channel.ts +334 -0
- package/packages/core/src/channels/whatsapp-cloud/client.ts +368 -0
- package/packages/core/src/channels/whatsapp-cloud/index.ts +3 -0
- package/packages/core/src/channels/whatsapp-cloud/webhook.ts +235 -0
- package/packages/core/src/channels/whatsapp.ts +58 -28
- package/packages/core/src/config/loader.ts +3 -1
- package/packages/core/src/gateway/server.ts +24 -0
- package/packages/core/src/index.ts +17 -0
- package/packages/core/src/mcp/MCPClient.ts +9 -0
- package/packages/core/src/mcp/config.ts +2 -1
- package/packages/core/src/mcp/transports/index.ts +44 -1
- package/packages/core/src/storage/catalog.ts +354 -0
- package/packages/core/src/storage/hive.ts +6 -1
- package/packages/core/src/storage/index.ts +13 -0
- package/packages/core/src/storage/seed.ts +161 -128
- package/packages/core/src/tools/cron/index.ts +2 -2
- package/packages/core/src/voice/index.ts +2 -0
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
|
@@ -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
|
|
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.
|
|
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
|
|
95
|
-
//
|
|
96
|
-
//
|
|
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
|
|
99
|
-
const queries =
|
|
100
|
-
?
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
|
|
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
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
}
|
|
@@ -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);
|