@johpaz/hive-sdk 0.4.6 → 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.
Files changed (32) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +1 -1
  3. package/docs/API-TOOLS-SKILLS-CHANNELS.md +83 -4
  4. package/docs/SECURITY-GUARDRAILS.md +21 -1
  5. package/docs/UPGRADING.md +20 -0
  6. package/package.json +1 -1
  7. package/packages/cli/templates/hive-app/hive.config.ts +7 -0
  8. package/packages/cli/templates/hive-app/src/main.ts +3 -0
  9. package/packages/core/src/agent/agent-loop.ts +3 -1
  10. package/packages/core/src/agent/capability-search.ts +72 -31
  11. package/packages/core/src/agent/context-compiler.ts +6 -2
  12. package/packages/core/src/agent/reflector.ts +23 -10
  13. package/packages/core/src/channels/index.ts +1 -0
  14. package/packages/core/src/channels/manager.ts +40 -0
  15. package/packages/core/src/channels/whatsapp-cloud/channel.ts +334 -0
  16. package/packages/core/src/channels/whatsapp-cloud/client.ts +368 -0
  17. package/packages/core/src/channels/whatsapp-cloud/index.ts +3 -0
  18. package/packages/core/src/channels/whatsapp-cloud/webhook.ts +235 -0
  19. package/packages/core/src/channels/whatsapp.ts +58 -28
  20. package/packages/core/src/config/loader.ts +3 -1
  21. package/packages/core/src/gateway/server.ts +24 -0
  22. package/packages/core/src/index.ts +17 -0
  23. package/packages/core/src/mcp/MCPClient.ts +9 -0
  24. package/packages/core/src/mcp/config.ts +2 -1
  25. package/packages/core/src/mcp/transports/index.ts +44 -1
  26. package/packages/core/src/storage/catalog.ts +354 -0
  27. package/packages/core/src/storage/causal-events.ts +50 -19
  28. package/packages/core/src/storage/hive.ts +6 -1
  29. package/packages/core/src/storage/index.ts +14 -1
  30. package/packages/core/src/storage/seed.ts +161 -128
  31. package/packages/core/src/tools/cron/index.ts +2 -2
  32. package/packages/core/src/voice/index.ts +2 -0
@@ -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);
@@ -0,0 +1,334 @@
1
+ /**
2
+ * Canal de WhatsApp por la API oficial de Meta (Cloud API).
3
+ *
4
+ * Es el camino para empresas: número de WhatsApp Business propio, plantillas de
5
+ * marketing y atribución de los anuncios que abren una conversación. El otro
6
+ * canal de WhatsApp del SDK (`whatsapp`, con Baileys y código QR) no es oficial
7
+ * y sirve para uso personal, no para un negocio.
8
+ *
9
+ * A diferencia de los demás canales, este no abre ninguna conexión: Meta empuja
10
+ * los mensajes a una URL pública. Quien hospeda el canal tiene que enrutarle las
11
+ * peticiones a `handleWebhook()`; el gateway del SDK ya lo hace en
12
+ * `/webhooks/whatsapp-cloud/:accountId`.
13
+ */
14
+
15
+ import { BaseChannel, type ChannelConfig, type IncomingMessage, type OutboundMessage } from "../base.ts";
16
+ import { logger } from "../../utils/logger.ts";
17
+ import { updateDoc } from "../../storage/hive.ts";
18
+ import type { ChannelDoc } from "../../storage/collections.ts";
19
+ import {
20
+ CUSTOMER_WINDOW_MS,
21
+ WhatsAppCloudClient,
22
+ WhatsAppCloudError,
23
+ type WhatsAppTemplate,
24
+ } from "./client.ts";
25
+ import {
26
+ parseWebhook,
27
+ safeTokenEquals,
28
+ verifyChallenge,
29
+ verifySignature,
30
+ type WhatsAppInbound,
31
+ } from "./webhook.ts";
32
+
33
+ /** Cuántos ids de mensaje se recuerdan para descartar los reintentos de Meta. */
34
+ const SEEN_LIMIT = 1000;
35
+
36
+ export interface WhatsAppCloudConfig extends ChannelConfig {
37
+ accountId: string;
38
+ /** Id del número en Meta (no el número en sí). */
39
+ phoneNumberId: string;
40
+ /** Token permanente, normalmente de un usuario del sistema. */
41
+ accessToken: string;
42
+ /** Secreto de la app de Meta, con el que se firma cada webhook. */
43
+ appSecret: string;
44
+ /** El token que uno inventa y pega en el panel de Meta al registrar la URL. */
45
+ verifyToken: string;
46
+ graphVersion?: string;
47
+ /**
48
+ * Mandar la narración intermedia del agente como mensajes sueltos.
49
+ *
50
+ * Apagado a propósito: desde el 1/10/2026 Meta cobra cada mensaje de servicio
51
+ * dentro de la ventana, así que narrar el progreso sale caro. En su lugar se
52
+ * renueva el "escribiendo…", que no cuesta nada.
53
+ */
54
+ sendProgress?: boolean;
55
+ /** Plantilla aprobada para hablarle a alguien fuera de la ventana de 24 h. */
56
+ windowFallbackTemplate?: WhatsAppTemplate;
57
+ /** Inyectable para tests o para salir por un proxy. */
58
+ fetch?: typeof fetch;
59
+ }
60
+
61
+ export interface WhatsAppCloudState {
62
+ status: "disconnected" | "pending_verification" | "connected" | "error";
63
+ phoneNumberId: string;
64
+ lastWebhookAt?: number;
65
+ error?: string;
66
+ }
67
+
68
+ export class WhatsAppCloudChannel extends BaseChannel {
69
+ name = "whatsapp_cloud";
70
+ accountId: string;
71
+ config: WhatsAppCloudConfig;
72
+
73
+ readonly client: WhatsAppCloudClient;
74
+
75
+ private state: WhatsAppCloudState;
76
+ private log = logger.child("whatsapp-cloud");
77
+ /** Último mensaje entrante por sesión: abre la ventana y da el id para el "escribiendo…". */
78
+ private lastInbound: Map<string, { at: number; messageId: string }> = new Map();
79
+ private seen: Set<string> = new Set();
80
+
81
+ constructor(config: WhatsAppCloudConfig) {
82
+ super();
83
+ this.config = config;
84
+ this.accountId = config.accountId;
85
+ this.client = new WhatsAppCloudClient({
86
+ phoneNumberId: config.phoneNumberId,
87
+ accessToken: config.accessToken,
88
+ graphVersion: config.graphVersion,
89
+ fetch: config.fetch,
90
+ });
91
+ this.state = { status: "disconnected", phoneNumberId: config.phoneNumberId };
92
+ }
93
+
94
+ /** La ruta que hay que registrar en el panel de Meta, detrás de HTTPS público. */
95
+ get webhookPath(): string {
96
+ return `/webhooks/whatsapp-cloud/${this.accountId}`;
97
+ }
98
+
99
+ async start(): Promise<void> {
100
+ this.running = true;
101
+ // No hay nada que conectar: queda esperando el webhook. Hasta que Meta haga
102
+ // su verificación, el canal está dado de alta pero no recibe nada.
103
+ this.state.status = this.state.lastWebhookAt ? "connected" : "pending_verification";
104
+ this.log.info(`Canal listo — registrá ${this.webhookPath} en la app de Meta`);
105
+ }
106
+
107
+ async stop(): Promise<void> {
108
+ this.running = false;
109
+ this.state.status = "disconnected";
110
+ this.log.info("Canal detenido");
111
+ }
112
+
113
+ /**
114
+ * Atiende una petición de Meta: la verificación inicial y cada mensaje.
115
+ *
116
+ * Responde 200 en cuanto el evento es válido, sin esperar el turno del
117
+ * agente: Meta reintenta lo que tarde y termina desuscribiendo la app.
118
+ */
119
+ async handleWebhook(req: Request): Promise<Response> {
120
+ if (req.method === "GET") {
121
+ const challenge = verifyChallenge(new URL(req.url).searchParams, (token) =>
122
+ safeTokenEquals(token, this.config.verifyToken)
123
+ );
124
+ if (!challenge) {
125
+ this.log.warn("verificación rechazada: token incorrecto");
126
+ return new Response("Verification failed", { status: 403 });
127
+ }
128
+ await this.setStatus("connected");
129
+ return new Response(challenge, { status: 200 });
130
+ }
131
+
132
+ if (req.method !== "POST") {
133
+ return new Response("Method Not Allowed", { status: 405 });
134
+ }
135
+
136
+ const rawBody = await req.text();
137
+ if (!verifySignature(rawBody, req.headers.get("x-hub-signature-256"), this.config.appSecret)) {
138
+ this.log.warn("firma HMAC inválida — evento descartado");
139
+ return new Response("Invalid signature", { status: 401 });
140
+ }
141
+
142
+ let body: unknown;
143
+ try {
144
+ body = JSON.parse(rawBody);
145
+ } catch {
146
+ return new Response("Invalid JSON", { status: 400 });
147
+ }
148
+
149
+ this.state.lastWebhookAt = Date.now();
150
+ if (this.state.status !== "connected") await this.setStatus("connected");
151
+
152
+ for (const event of parseWebhook(body)) {
153
+ // Un mismo WABA puede tener varios números apuntando a la misma URL.
154
+ if (event.phoneNumberId !== this.config.phoneNumberId) continue;
155
+
156
+ for (const message of event.messages) {
157
+ void this.ingest(message).catch((error) =>
158
+ this.log.error(`no se pudo procesar ${message.id}: ${(error as Error).message}`)
159
+ );
160
+ }
161
+ }
162
+
163
+ return new Response("EVENT_RECEIVED", { status: 200 });
164
+ }
165
+
166
+ async send(sessionId: string, message: OutboundMessage): Promise<void> {
167
+ const isInterim = message.type === "progress";
168
+ if (isInterim && !this.config.sendProgress) {
169
+ // La narración no se manda: cada mensaje intermedio se cobra. El
170
+ // "escribiendo…" cuenta lo mismo y es gratis.
171
+ await this.startTyping(sessionId).catch(() => {});
172
+ return;
173
+ }
174
+
175
+ const text = message.content ?? message.chunk ?? "";
176
+ if (!text) return;
177
+
178
+ const to = this.toRecipient(sessionId);
179
+ const last = this.lastInbound.get(sessionId);
180
+ const expired = last ? Date.now() - last.at >= CUSTOMER_WINDOW_MS : false;
181
+
182
+ // Sin registro de entrante (por ejemplo tras un reinicio) se intenta igual:
183
+ // la ventana puede estar abierta y quien manda la última palabra es Meta.
184
+ if (!expired) {
185
+ try {
186
+ await this.client.sendText(to, text);
187
+ return;
188
+ } catch (error) {
189
+ if (!(error instanceof WhatsAppCloudError) || !error.windowClosed) throw error;
190
+ this.log.info(`ventana cerrada para ${to} según Meta`);
191
+ }
192
+ }
193
+
194
+ await this.sendOutsideWindow(to, text);
195
+ }
196
+
197
+ async sendAudio(sessionId: string, audio: Buffer, mimeType: string): Promise<void> {
198
+ await this.client.sendAudio(this.toRecipient(sessionId), audio, mimeType);
199
+ }
200
+
201
+ /** Muestra "escribiendo…". Meta lo apaga al responder o a los 25 segundos. */
202
+ async startTyping(sessionId: string): Promise<void> {
203
+ const last = this.lastInbound.get(sessionId);
204
+ if (!last) return;
205
+ await this.client.markRead(last.messageId, { typing: true }).catch(() => {});
206
+ }
207
+
208
+ /** No hace falta apagarlo: responder ya lo apaga. */
209
+ async stopTyping(_sessionId: string): Promise<void> {}
210
+
211
+ async markAsRead(sessionId: string, messageId?: string): Promise<void> {
212
+ const id = messageId ?? this.lastInbound.get(sessionId)?.messageId;
213
+ if (!id) return;
214
+ await this.client.markRead(id).catch(() => {});
215
+ }
216
+
217
+ getState(): WhatsAppCloudState {
218
+ return { ...this.state };
219
+ }
220
+
221
+ getConfig(): WhatsAppCloudConfig {
222
+ return { ...this.config };
223
+ }
224
+
225
+ private async ingest(message: WhatsAppInbound): Promise<void> {
226
+ if (!message.id || this.seen.has(message.id)) return;
227
+ this.remember(message.id);
228
+
229
+ if (!this.isUserAllowed(message.from)) {
230
+ this.log.info(`mensaje descartado, ${message.from} no está en la lista permitida`);
231
+ return;
232
+ }
233
+
234
+ const sessionId = this.formatSessionId(message.from, "direct");
235
+ this.lastInbound.set(sessionId, { at: Date.now(), messageId: message.id });
236
+
237
+ const incoming: IncomingMessage = {
238
+ sessionId,
239
+ channel: this.name,
240
+ accountId: this.accountId,
241
+ peerId: message.from,
242
+ peerKind: "direct",
243
+ content: message.text ?? "",
244
+ metadata: {
245
+ messageId: message.id,
246
+ timestamp: message.timestamp,
247
+ pushName: message.profileName,
248
+ type: message.type,
249
+ ...(message.referral ? { referral: message.referral } : {}),
250
+ ...(message.interactive ? { interactive: message.interactive } : {}),
251
+ },
252
+ };
253
+
254
+ if (message.media?.id && message.mediaKind) {
255
+ try {
256
+ const media = await this.client.downloadMedia(message.media.id);
257
+ if (message.mediaKind === "audio") {
258
+ incoming.audio = { buffer: media.buffer, mimeType: media.mimeType };
259
+ if (!incoming.content) incoming.content = "[Audio message]";
260
+ } else if (message.mediaKind === "image" || message.mediaKind === "sticker") {
261
+ incoming.image = {
262
+ buffer: media.buffer,
263
+ mimeType: media.mimeType,
264
+ caption: message.media.caption,
265
+ };
266
+ } else if (message.mediaKind === "document") {
267
+ incoming.document = {
268
+ buffer: media.buffer,
269
+ mimeType: media.mimeType,
270
+ fileName: message.media.fileName ?? media.fileName,
271
+ };
272
+ }
273
+ } catch (error) {
274
+ this.log.warn(`no se pudo bajar el medio ${message.media.id}: ${(error as Error).message}`);
275
+ }
276
+ }
277
+
278
+ if (!incoming.content && !incoming.audio && !incoming.image && !incoming.document) {
279
+ this.log.debug(`mensaje ${message.id} de tipo ${message.type} sin contenido utilizable`);
280
+ return;
281
+ }
282
+
283
+ // Acuse de recibo inmediato: el doble tilde y el "escribiendo…" son lo que
284
+ // le dice a la persona que su mensaje llegó, aunque la respuesta demore.
285
+ void this.client.markRead(message.id, { typing: true }).catch(() => {});
286
+
287
+ await this.handleMessage(incoming);
288
+ }
289
+
290
+ private async sendOutsideWindow(to: string, text: string): Promise<void> {
291
+ const template = this.config.windowFallbackTemplate;
292
+ if (!template) {
293
+ throw new WhatsAppCloudError(
294
+ 400,
295
+ 131047,
296
+ "Pasaron más de 24 h desde el último mensaje del cliente: Meta sólo acepta " +
297
+ "una plantilla aprobada. Configurá `windowFallbackTemplate` en el canal."
298
+ );
299
+ }
300
+ this.log.info(`fuera de la ventana: se manda la plantilla "${template.name}" en vez del texto`);
301
+ this.log.debug(`texto no enviado: ${text.slice(0, 120)}`);
302
+ await this.client.sendTemplate(to, template);
303
+ }
304
+
305
+ /** El sessionId es el `wa_id`; se tolera un prefijo por si alguien lo compone. */
306
+ private toRecipient(sessionId: string): string {
307
+ const parts = sessionId.split(":");
308
+ return (parts[parts.length - 1] ?? "").replace(/\D/g, "");
309
+ }
310
+
311
+ private remember(messageId: string): void {
312
+ this.seen.add(messageId);
313
+ if (this.seen.size > SEEN_LIMIT) {
314
+ const oldest = this.seen.values().next().value;
315
+ if (oldest) this.seen.delete(oldest);
316
+ }
317
+ }
318
+
319
+ private async setStatus(status: WhatsAppCloudState["status"]): Promise<void> {
320
+ this.state.status = status;
321
+ try {
322
+ await updateDoc<ChannelDoc>("channels", this.accountId, {
323
+ status,
324
+ last_active: Date.now(),
325
+ });
326
+ } catch {
327
+ // Sin base no se pierde nada: el estado en memoria ya quedó bien.
328
+ }
329
+ }
330
+ }
331
+
332
+ export function createWhatsAppCloudChannel(config: WhatsAppCloudConfig): WhatsAppCloudChannel {
333
+ return new WhatsAppCloudChannel(config);
334
+ }