@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
@@ -0,0 +1,368 @@
1
+ /**
2
+ * Cliente de la WhatsApp Cloud API — la API oficial de Meta.
3
+ *
4
+ * No agrega dependencias: sólo `fetch`. Cada instancia habla por UN número
5
+ * (`phoneNumberId`), que es la unidad con la que Meta cobra, limita el caudal y
6
+ * numera los errores.
7
+ *
8
+ * Por qué existe acá y no en cada aplicación: la versión del Graph caduca sola.
9
+ * Meta garantiza unos dos años por versión y después manda las llamadas a la
10
+ * más vieja que siga viva, sin avisar y cambiando el comportamiento. Teniendo
11
+ * un único cliente, subir de versión es una línea para todos los consumidores.
12
+ */
13
+
14
+ import { logger } from "../../utils/logger.ts";
15
+
16
+ const log = logger.child("whatsapp-cloud");
17
+
18
+ /** Última versión estable del Graph. Se puede pisar con `META_GRAPH_API_VERSION`. */
19
+ export const DEFAULT_GRAPH_VERSION = "v26.0";
20
+
21
+ /** Meta rechaza cualquier `text.body` de más de 4096 caracteres. */
22
+ export const WHATSAPP_TEXT_LIMIT = 4096;
23
+
24
+ /** La ventana de atención al cliente: fuera de ella sólo se aceptan plantillas. */
25
+ export const CUSTOMER_WINDOW_MS = 24 * 60 * 60 * 1000;
26
+
27
+ const RATE_WINDOW_MS = 1000;
28
+ /** Meta acepta del orden de 80 mensajes por segundo por número. */
29
+ const MAX_REQUESTS_PER_WINDOW = 80;
30
+ /** Cuánto se espera como mucho a que se libere un lugar antes de fallar. */
31
+ const MAX_THROTTLE_WAIT_MS = 5000;
32
+
33
+ export interface WhatsAppCloudClientConfig {
34
+ phoneNumberId: string;
35
+ accessToken: string;
36
+ /** Por defecto `META_GRAPH_API_VERSION`, y si no está, `DEFAULT_GRAPH_VERSION`. */
37
+ graphVersion?: string;
38
+ /** Inyectable para tests. */
39
+ fetch?: typeof fetch;
40
+ }
41
+
42
+ export interface WhatsAppTemplate {
43
+ name: string;
44
+ language?: string;
45
+ components?: unknown[];
46
+ }
47
+
48
+ export interface WhatsAppMediaDownload {
49
+ buffer: Buffer;
50
+ mimeType: string;
51
+ fileName?: string;
52
+ }
53
+
54
+ /**
55
+ * Un error de la Cloud API con su código de Meta a mano.
56
+ *
57
+ * Los códigos importan porque cambian la decisión de quien llama: 131047 no es
58
+ * un fallo, es "se cerró la ventana de 24 h y hay que mandar una plantilla".
59
+ */
60
+ export class WhatsAppCloudError extends Error {
61
+ readonly status: number;
62
+ readonly code: number;
63
+ readonly subcode?: number;
64
+ readonly fbtraceId?: string;
65
+
66
+ constructor(
67
+ status: number,
68
+ code: number,
69
+ message: string,
70
+ extra?: { subcode?: number; fbtraceId?: string }
71
+ ) {
72
+ super(message);
73
+ this.name = "WhatsAppCloudError";
74
+ this.status = status;
75
+ this.code = code;
76
+ this.subcode = extra?.subcode;
77
+ this.fbtraceId = extra?.fbtraceId;
78
+ }
79
+
80
+ static from(status: number, payload: unknown): WhatsAppCloudError {
81
+ const error = (payload as { error?: Record<string, unknown> })?.error ?? {};
82
+ const code = Number(error.code ?? 0);
83
+ const message = String(error.message ?? `HTTP ${status}`);
84
+ return new WhatsAppCloudError(status, code, `Meta API error: ${message}`, {
85
+ subcode: error.error_subcode !== undefined ? Number(error.error_subcode) : undefined,
86
+ fbtraceId: error.fbtrace_id ? String(error.fbtrace_id) : undefined,
87
+ });
88
+ }
89
+
90
+ /** 131047: pasaron más de 24 h desde el último mensaje del cliente. */
91
+ get windowClosed(): boolean {
92
+ return this.code === 131047;
93
+ }
94
+
95
+ /** 130429: caudal de la app; 131056: demasiados mensajes al mismo destinatario. */
96
+ get rateLimited(): boolean {
97
+ return this.code === 130429 || this.code === 131056 || this.status === 429;
98
+ }
99
+
100
+ /** 190: el token expiró o fue revocado. */
101
+ get tokenExpired(): boolean {
102
+ return this.code === 190;
103
+ }
104
+
105
+ /** 131026: el destinatario no puede recibir el mensaje. */
106
+ get undeliverable(): boolean {
107
+ return this.code === 131026;
108
+ }
109
+
110
+ /** 368: la cuenta de WhatsApp Business está restringida por incumplir la política. */
111
+ get accountRestricted(): boolean {
112
+ return this.code === 368;
113
+ }
114
+
115
+ /** Si reintentar tiene sentido. Un 131047 o un 368 no se arreglan reintentando. */
116
+ get retryable(): boolean {
117
+ return this.rateLimited || this.status >= 500;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Parte un texto en trozos que Meta acepte, cortando donde menos se note:
123
+ * primero entre párrafos, después entre líneas y, sólo si no queda otra, por
124
+ * el último espacio antes del límite.
125
+ */
126
+ export function splitWhatsAppText(text: string, limit = WHATSAPP_TEXT_LIMIT): string[] {
127
+ if (text.length <= limit) return text ? [text] : [];
128
+
129
+ const chunks: string[] = [];
130
+ let pending = "";
131
+
132
+ const push = (): void => {
133
+ const trimmed = pending.trim();
134
+ if (trimmed) chunks.push(trimmed);
135
+ pending = "";
136
+ };
137
+
138
+ for (const paragraph of text.split(/\n{2,}/)) {
139
+ const candidate = pending ? `${pending}\n\n${paragraph}` : paragraph;
140
+
141
+ if (candidate.length <= limit) {
142
+ pending = candidate;
143
+ continue;
144
+ }
145
+
146
+ push();
147
+
148
+ if (paragraph.length <= limit) {
149
+ pending = paragraph;
150
+ continue;
151
+ }
152
+
153
+ // Un párrafo solo ya no entra: se parte por líneas, y una línea gigante
154
+ // (un log, una tabla) por el último espacio antes del límite.
155
+ for (const line of paragraph.split("\n")) {
156
+ const withLine = pending ? `${pending}\n${line}` : line;
157
+ if (withLine.length <= limit) {
158
+ pending = withLine;
159
+ continue;
160
+ }
161
+ push();
162
+
163
+ let rest = line;
164
+ while (rest.length > limit) {
165
+ const window = rest.slice(0, limit);
166
+ const cut = window.lastIndexOf(" ");
167
+ const at = cut > limit * 0.5 ? cut : limit;
168
+ chunks.push(rest.slice(0, at).trim());
169
+ rest = rest.slice(at).trimStart();
170
+ }
171
+ pending = rest;
172
+ }
173
+ }
174
+
175
+ push();
176
+ return chunks;
177
+ }
178
+
179
+ export class WhatsAppCloudClient {
180
+ readonly phoneNumberId: string;
181
+ readonly graphVersion: string;
182
+
183
+ private readonly accessToken: string;
184
+ // Deliberadamente más laxo que `typeof fetch`: lo que se inyecta en los tests
185
+ // no implementa extras del runtime como `preconnect`, y acá no hacen falta.
186
+ private readonly fetchImpl: (input: string, init?: RequestInit) => Promise<Response>;
187
+ private bucket = { count: 0, resetAt: 0 };
188
+
189
+ constructor(config: WhatsAppCloudClientConfig) {
190
+ this.phoneNumberId = config.phoneNumberId;
191
+ this.accessToken = config.accessToken;
192
+ this.graphVersion =
193
+ config.graphVersion ?? process.env.META_GRAPH_API_VERSION ?? DEFAULT_GRAPH_VERSION;
194
+ this.fetchImpl = config.fetch ?? ((input, init) => globalThis.fetch(input, init));
195
+ }
196
+
197
+ /** Manda un texto, partido en varios mensajes si hace falta. Devuelve los ids. */
198
+ async sendText(to: string, text: string, opts?: { previewUrl?: boolean }): Promise<string[]> {
199
+ const chunks = splitWhatsAppText(text);
200
+ const ids: string[] = [];
201
+
202
+ // En serie a propósito: en paralelo los mensajes llegarían desordenados.
203
+ for (const chunk of chunks) {
204
+ const data = await this.post<{ messages?: { id?: string }[] }>({
205
+ messaging_product: "whatsapp",
206
+ recipient_type: "individual",
207
+ to,
208
+ type: "text",
209
+ text: { preview_url: opts?.previewUrl ?? false, body: chunk },
210
+ });
211
+ const id = data.messages?.[0]?.id;
212
+ if (id) ids.push(id);
213
+ }
214
+
215
+ if (chunks.length > 1) {
216
+ log.debug(`texto de ${text.length} caracteres enviado en ${chunks.length} mensajes`);
217
+ }
218
+ return ids;
219
+ }
220
+
221
+ /** Manda una plantilla aprobada: lo único que Meta acepta fuera de la ventana. */
222
+ async sendTemplate(to: string, template: WhatsAppTemplate): Promise<string> {
223
+ const data = await this.post<{ messages?: { id?: string }[] }>({
224
+ messaging_product: "whatsapp",
225
+ recipient_type: "individual",
226
+ to,
227
+ type: "template",
228
+ template: {
229
+ name: template.name,
230
+ language: { code: template.language ?? "es" },
231
+ components: template.components ?? [],
232
+ },
233
+ });
234
+ return data.messages?.[0]?.id ?? "";
235
+ }
236
+
237
+ /** Sube el audio y lo manda como nota de voz. */
238
+ async sendAudio(to: string, audio: Buffer, mimeType = "audio/ogg"): Promise<string> {
239
+ const mediaId = await this.uploadMedia(audio, mimeType, "audio.ogg");
240
+ const data = await this.post<{ messages?: { id?: string }[] }>({
241
+ messaging_product: "whatsapp",
242
+ recipient_type: "individual",
243
+ to,
244
+ type: "audio",
245
+ audio: { id: mediaId },
246
+ });
247
+ return data.messages?.[0]?.id ?? "";
248
+ }
249
+
250
+ /**
251
+ * Marca el mensaje como leído y, si se pide, muestra "escribiendo…".
252
+ *
253
+ * Van juntos en la misma llamada porque así lo define Meta. El indicador se
254
+ * apaga solo al responder, o a los 25 segundos.
255
+ */
256
+ async markRead(messageId: string, opts?: { typing?: boolean }): Promise<void> {
257
+ await this.post({
258
+ messaging_product: "whatsapp",
259
+ status: "read",
260
+ message_id: messageId,
261
+ ...(opts?.typing ? { typing_indicator: { type: "text" } } : {}),
262
+ });
263
+ }
264
+
265
+ /** Sube un archivo y devuelve su id de medio. */
266
+ async uploadMedia(bytes: Buffer, mimeType: string, fileName = "file"): Promise<string> {
267
+ await this.throttle();
268
+
269
+ const form = new FormData();
270
+ form.append("messaging_product", "whatsapp");
271
+ form.append("type", mimeType);
272
+ form.append("file", new Blob([new Uint8Array(bytes)], { type: mimeType }), fileName);
273
+
274
+ const res = await this.fetchImpl(this.url(`${this.phoneNumberId}/media`), {
275
+ method: "POST",
276
+ headers: { Authorization: `Bearer ${this.accessToken}` },
277
+ body: form,
278
+ });
279
+ const data = (await res.json().catch(() => ({}))) as { id?: string };
280
+ if (!res.ok || !data.id) throw WhatsAppCloudError.from(res.status, data);
281
+ return data.id;
282
+ }
283
+
284
+ /**
285
+ * Baja un medio recibido. Son dos pasos: el id da una URL firmada, y esa URL
286
+ * también exige el token.
287
+ */
288
+ async downloadMedia(mediaId: string): Promise<WhatsAppMediaDownload> {
289
+ await this.throttle();
290
+ const metaRes = await this.fetchImpl(this.url(mediaId), {
291
+ headers: { Authorization: `Bearer ${this.accessToken}` },
292
+ });
293
+ const meta = (await metaRes.json().catch(() => ({}))) as {
294
+ url?: string;
295
+ mime_type?: string;
296
+ file_name?: string;
297
+ };
298
+ if (!metaRes.ok || !meta.url) throw WhatsAppCloudError.from(metaRes.status, meta);
299
+
300
+ const fileRes = await this.fetchImpl(meta.url, {
301
+ headers: { Authorization: `Bearer ${this.accessToken}` },
302
+ });
303
+ if (!fileRes.ok) {
304
+ throw new WhatsAppCloudError(fileRes.status, 0, `No se pudo bajar el medio ${mediaId}`);
305
+ }
306
+
307
+ return {
308
+ buffer: Buffer.from(await fileRes.arrayBuffer()),
309
+ mimeType: meta.mime_type ?? "application/octet-stream",
310
+ fileName: meta.file_name,
311
+ };
312
+ }
313
+
314
+ private url(path: string): string {
315
+ return `https://graph.facebook.com/${this.graphVersion}/${path}`;
316
+ }
317
+
318
+ private async post<T = unknown>(body: Record<string, unknown>): Promise<T> {
319
+ await this.throttle();
320
+
321
+ const res = await this.fetchImpl(this.url(`${this.phoneNumberId}/messages`), {
322
+ method: "POST",
323
+ headers: {
324
+ Authorization: `Bearer ${this.accessToken}`,
325
+ "Content-Type": "application/json",
326
+ },
327
+ body: JSON.stringify(body),
328
+ });
329
+
330
+ const data = (await res.json().catch(() => ({}))) as T;
331
+ if (!res.ok) throw WhatsAppCloudError.from(res.status, data);
332
+ return data;
333
+ }
334
+
335
+ /**
336
+ * Cupo por número. A diferencia del de hive-cloud, que fallaba al llenarse,
337
+ * este espera a que se abra la ventana: un texto largo sale en varios
338
+ * mensajes seguidos y no tiene por qué morir por su propio caudal.
339
+ */
340
+ private async throttle(): Promise<void> {
341
+ const deadline = Date.now() + MAX_THROTTLE_WAIT_MS;
342
+
343
+ for (;;) {
344
+ const now = Date.now();
345
+ if (now >= this.bucket.resetAt) {
346
+ this.bucket = { count: 0, resetAt: now + RATE_WINDOW_MS };
347
+ }
348
+ if (this.bucket.count < MAX_REQUESTS_PER_WINDOW) {
349
+ this.bucket.count++;
350
+ return;
351
+ }
352
+ if (now >= deadline) {
353
+ throw new WhatsAppCloudError(
354
+ 429,
355
+ 130429,
356
+ `Caudal propio superado para el número ${this.phoneNumberId}`
357
+ );
358
+ }
359
+ await new Promise((resolve) => setTimeout(resolve, this.bucket.resetAt - now));
360
+ }
361
+ }
362
+ }
363
+
364
+ export function createWhatsAppCloudClient(
365
+ config: WhatsAppCloudClientConfig
366
+ ): WhatsAppCloudClient {
367
+ return new WhatsAppCloudClient(config);
368
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./client.ts";
2
+ export * from "./webhook.ts";
3
+ export * from "./channel.ts";
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Verificación y lectura de los webhooks de la WhatsApp Cloud API.
3
+ *
4
+ * Son funciones puras a propósito: quien las usa decide después qué hacer con
5
+ * el mensaje. El canal del SDK arma un `IncomingMessage`; hive-cloud, que es
6
+ * multi-inquilino sobre Postgres, arma una conversación, lo ingesta al CRM y lo
7
+ * mete en su retén. Las dos cosas comparten exactamente esto: comprobar la
8
+ * firma y entender el payload.
9
+ */
10
+
11
+ import { createHmac, timingSafeEqual } from "node:crypto";
12
+
13
+ export interface WhatsAppReferral {
14
+ /** Id del anuncio que originó la conversación (`source_id`). */
15
+ sourceId?: string;
16
+ sourceUrl?: string;
17
+ sourceType?: string;
18
+ headline?: string;
19
+ body?: string;
20
+ /** El identificador de clic que la Conversions API necesita para cerrar el círculo. */
21
+ ctwaClid?: string;
22
+ }
23
+
24
+ export interface WhatsAppMediaRef {
25
+ id: string;
26
+ mimeType?: string;
27
+ caption?: string;
28
+ fileName?: string;
29
+ /** `true` si es una nota de voz y no un archivo de audio adjunto. */
30
+ voice?: boolean;
31
+ }
32
+
33
+ export type WhatsAppMediaKind = "audio" | "image" | "document" | "video" | "sticker";
34
+
35
+ export interface WhatsAppInbound {
36
+ id: string;
37
+ from: string;
38
+ timestamp?: number;
39
+ /** `text`, `image`, `audio`, `interactive`, `button`, `location`, … */
40
+ type: string;
41
+ text?: string;
42
+ media?: WhatsAppMediaRef;
43
+ mediaKind?: WhatsAppMediaKind;
44
+ /** Respuesta a un botón o a una lista. */
45
+ interactive?: { id: string; title?: string };
46
+ referral?: WhatsAppReferral;
47
+ profileName?: string;
48
+ /** El mensaje tal como llegó, para lo que este tipo no cubra. */
49
+ raw: unknown;
50
+ }
51
+
52
+ export interface WhatsAppStatusEvent {
53
+ id: string;
54
+ recipientId: string;
55
+ /** `sent`, `delivered`, `read`, `failed`. */
56
+ status: string;
57
+ timestamp?: number;
58
+ errors?: { code: number; title?: string }[];
59
+ }
60
+
61
+ export interface WhatsAppWebhookEvent {
62
+ phoneNumberId: string;
63
+ displayPhoneNumber?: string;
64
+ messages: WhatsAppInbound[];
65
+ statuses: WhatsAppStatusEvent[];
66
+ }
67
+
68
+ /**
69
+ * Comprueba la firma `X-Hub-Signature-256` con el secreto de la app.
70
+ *
71
+ * Sin esto, cualquiera que conozca la URL puede inyectar conversaciones.
72
+ */
73
+ export function verifySignature(
74
+ rawBody: string,
75
+ signatureHeader: string | null | undefined,
76
+ appSecret: string
77
+ ): boolean {
78
+ if (!signatureHeader || !appSecret) return false;
79
+ const expected = Buffer.from(
80
+ `sha256=${createHmac("sha256", appSecret).update(rawBody).digest("hex")}`
81
+ );
82
+ const actual = Buffer.from(signatureHeader);
83
+ return expected.length === actual.length && timingSafeEqual(expected, actual);
84
+ }
85
+
86
+ /**
87
+ * Resuelve el `hub.challenge` que Meta manda al registrar el webhook.
88
+ *
89
+ * El token se comprueba con un predicado en vez de compararlo acá porque cada
90
+ * aplicación lo guarda distinto: el SDK tiene uno por canal, y hive-cloud tiene
91
+ * un hash por cuenta de cliente y un token en texto plano para el número de
92
+ * plataforma.
93
+ */
94
+ export function verifyChallenge(
95
+ query: URLSearchParams | Record<string, string | undefined>,
96
+ isValidToken: (token: string) => boolean
97
+ ): string | null {
98
+ const get = (key: string): string | undefined =>
99
+ query instanceof URLSearchParams ? (query.get(key) ?? undefined) : query[key];
100
+
101
+ const mode = get("hub.mode");
102
+ const token = get("hub.verify_token");
103
+ const challenge = get("hub.challenge");
104
+
105
+ if (mode !== "subscribe" || !token || !challenge) return null;
106
+ if (!isValidToken(token)) return null;
107
+ return challenge;
108
+ }
109
+
110
+ /** Compara dos tokens en texto plano sin filtrar información por el tiempo. */
111
+ export function safeTokenEquals(received: string, expected: string): boolean {
112
+ const a = Buffer.from(received);
113
+ const b = Buffer.from(expected);
114
+ return a.length === b.length && timingSafeEqual(a, b);
115
+ }
116
+
117
+ const MEDIA_KINDS: WhatsAppMediaKind[] = ["audio", "image", "document", "video", "sticker"];
118
+
119
+ /**
120
+ * Convierte el payload del webhook en eventos por número.
121
+ *
122
+ * Recorre **todas** las `entry` y `changes`: Meta puede agrupar varias
123
+ * notificaciones en un mismo POST, y quedarse con `entry[0].changes[0]` pierde
124
+ * mensajes en silencio.
125
+ */
126
+ export function parseWebhook(body: unknown): WhatsAppWebhookEvent[] {
127
+ const entries = (body as { entry?: unknown[] })?.entry;
128
+ if (!Array.isArray(entries)) return [];
129
+
130
+ const events: WhatsAppWebhookEvent[] = [];
131
+
132
+ for (const entry of entries) {
133
+ const changes = (entry as { changes?: unknown[] })?.changes;
134
+ if (!Array.isArray(changes)) continue;
135
+
136
+ for (const change of changes) {
137
+ const typed = change as { field?: string; value?: Record<string, any> };
138
+ if (typed.field && typed.field !== "messages") continue;
139
+
140
+ const value = typed.value;
141
+ const phoneNumberId = value?.metadata?.phone_number_id;
142
+ if (!phoneNumberId) continue;
143
+
144
+ const contacts: any[] = Array.isArray(value?.contacts) ? value!.contacts : [];
145
+ const messages: WhatsAppInbound[] = [];
146
+ const statuses: WhatsAppStatusEvent[] = [];
147
+
148
+ for (const msg of Array.isArray(value?.messages) ? value!.messages : []) {
149
+ messages.push(normalizeMessage(msg, contacts));
150
+ }
151
+
152
+ for (const status of Array.isArray(value?.statuses) ? value!.statuses : []) {
153
+ statuses.push({
154
+ id: String(status.id ?? ""),
155
+ recipientId: String(status.recipient_id ?? ""),
156
+ status: String(status.status ?? ""),
157
+ timestamp: status.timestamp ? Number(status.timestamp) : undefined,
158
+ errors: Array.isArray(status.errors)
159
+ ? status.errors.map((e: any) => ({ code: Number(e.code), title: e.title }))
160
+ : undefined,
161
+ });
162
+ }
163
+
164
+ events.push({
165
+ phoneNumberId: String(phoneNumberId),
166
+ displayPhoneNumber: value?.metadata?.display_phone_number,
167
+ messages,
168
+ statuses,
169
+ });
170
+ }
171
+ }
172
+
173
+ return events;
174
+ }
175
+
176
+ function normalizeMessage(msg: any, contacts: any[]): WhatsAppInbound {
177
+ const from = String(msg.from ?? "");
178
+ const type = String(msg.type ?? "unknown");
179
+
180
+ const inbound: WhatsAppInbound = {
181
+ id: String(msg.id ?? ""),
182
+ from,
183
+ timestamp: msg.timestamp ? Number(msg.timestamp) : undefined,
184
+ type,
185
+ profileName: contacts.find((c) => c?.wa_id === from)?.profile?.name,
186
+ raw: msg,
187
+ };
188
+
189
+ if (msg.referral?.source_id || msg.referral?.ctwa_clid) {
190
+ inbound.referral = {
191
+ sourceId: msg.referral.source_id ? String(msg.referral.source_id) : undefined,
192
+ sourceUrl: msg.referral.source_url,
193
+ sourceType: msg.referral.source_type,
194
+ headline: msg.referral.headline,
195
+ body: msg.referral.body,
196
+ ctwaClid: msg.referral.ctwa_clid ? String(msg.referral.ctwa_clid) : undefined,
197
+ };
198
+ }
199
+
200
+ if (type === "text") {
201
+ inbound.text = msg.text?.body ?? "";
202
+ return inbound;
203
+ }
204
+
205
+ if (MEDIA_KINDS.includes(type as WhatsAppMediaKind)) {
206
+ const payload = msg[type] ?? {};
207
+ inbound.mediaKind = type as WhatsAppMediaKind;
208
+ inbound.media = {
209
+ id: String(payload.id ?? ""),
210
+ mimeType: payload.mime_type,
211
+ caption: payload.caption,
212
+ fileName: payload.filename,
213
+ voice: payload.voice === true,
214
+ };
215
+ if (payload.caption) inbound.text = payload.caption;
216
+ return inbound;
217
+ }
218
+
219
+ if (type === "interactive") {
220
+ const reply = msg.interactive?.button_reply ?? msg.interactive?.list_reply;
221
+ if (reply) {
222
+ inbound.interactive = { id: String(reply.id ?? ""), title: reply.title };
223
+ inbound.text = reply.title ?? reply.id;
224
+ }
225
+ return inbound;
226
+ }
227
+
228
+ if (type === "button") {
229
+ inbound.interactive = { id: String(msg.button?.payload ?? ""), title: msg.button?.text };
230
+ inbound.text = msg.button?.text ?? msg.button?.payload;
231
+ return inbound;
232
+ }
233
+
234
+ return inbound;
235
+ }