@better-zap/hono 0.0.4 → 0.2.0

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/package.json CHANGED
@@ -1,8 +1,13 @@
1
1
  {
2
2
  "name": "@better-zap/hono",
3
- "version": "0.0.4",
3
+ "version": "0.2.0",
4
4
  "description": "Hono adapter and webhook runtime for Better Zap.",
5
5
  "license": "ISC",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/Dosbodoke/better-zap",
9
+ "directory": "packages/hono"
10
+ },
6
11
  "type": "module",
7
12
  "main": "./dist/index.cjs",
8
13
  "module": "./dist/index.mjs",
@@ -27,11 +32,11 @@
27
32
  "access": "public"
28
33
  },
29
34
  "dependencies": {
30
- "hono": "4.12.5",
31
- "better-zap": "0.0.4"
35
+ "hono": "^4.12.25",
36
+ "better-zap": "0.2.0"
32
37
  },
33
38
  "peerDependencies": {
34
- "hono": "4.12.5"
39
+ "hono": "^4.12.25"
35
40
  },
36
41
  "devDependencies": {
37
42
  "@cloudflare/workers-types": "^4.20240117.0",
package/dist/index.cjs DELETED
@@ -1,581 +0,0 @@
1
- Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- let hono = require("hono");
3
- let better_zap = require("better-zap");
4
- //#region src/plugins/runtime.ts
5
- function initializePlugins(options) {
6
- let pluginContext = {};
7
- let pluginServices = {};
8
- for (const plugin of options.plugins) {
9
- const result = plugin.init?.({
10
- database: options.database,
11
- config: options.config,
12
- context: {
13
- ...options.coreContext,
14
- ...pluginContext
15
- },
16
- services: {
17
- ...options.coreServices,
18
- ...pluginServices
19
- },
20
- log: options.log
21
- });
22
- if (!result) continue;
23
- if (result.context) pluginContext = {
24
- ...pluginContext,
25
- ...result.context
26
- };
27
- if (result.services) pluginServices = {
28
- ...pluginServices,
29
- ...result.services
30
- };
31
- }
32
- return {
33
- context: {
34
- ...options.coreContext,
35
- ...pluginContext
36
- },
37
- services: {
38
- ...options.coreServices,
39
- ...pluginServices
40
- }
41
- };
42
- }
43
- async function runPluginMessageHooks(options) {
44
- for (const plugin of options.plugins) {
45
- if (!plugin.hooks?.onMessage) continue;
46
- try {
47
- await plugin.hooks.onMessage(options.ctx);
48
- } catch (error) {
49
- options.log.error("plugin.on_message_failed", {
50
- pluginId: plugin.id,
51
- waMessageId: options.ctx.message.id,
52
- phone: options.ctx.phone,
53
- ...(0, better_zap.serializeError)(error)
54
- });
55
- }
56
- }
57
- }
58
- async function runPluginStatusHooks(options) {
59
- for (const plugin of options.plugins) {
60
- if (!plugin.hooks?.onStatusUpdate) continue;
61
- try {
62
- await plugin.hooks.onStatusUpdate(options.ctx);
63
- } catch (error) {
64
- options.log.error("plugin.on_status_update_failed", {
65
- pluginId: plugin.id,
66
- waMessageId: options.ctx.status.id,
67
- status: options.ctx.status.status,
68
- ...(0, better_zap.serializeError)(error)
69
- });
70
- }
71
- }
72
- }
73
- //#endregion
74
- //#region src/handler/conversations.ts
75
- async function handleListConversations(c) {
76
- try {
77
- const conversations = await c.get("store").getConversations();
78
- return c.json((0, better_zap.normalizeConversationRecords)(conversations));
79
- } catch (error) {
80
- c.get("logger").error("conversations.list_error", (0, better_zap.serializeError)(error));
81
- return c.json({ error: "Internal error fetching conversations" }, 500);
82
- }
83
- }
84
- async function handleGetConversation(c) {
85
- try {
86
- const phone = c.req.param("phone");
87
- if (!phone) return c.json({ error: "phone is required" }, 400);
88
- const store = c.get("store");
89
- const normalized = (0, better_zap.formatPhone)(decodeURIComponent(phone));
90
- const conversation = await store.getConversationByPhone(normalized);
91
- if (!conversation) return c.json({ error: "Conversation not found" }, 404);
92
- return c.json((0, better_zap.normalizeConversationRecord)(conversation));
93
- } catch (error) {
94
- c.get("logger").error("conversations.get_error", (0, better_zap.serializeError)(error));
95
- return c.json({ error: "Internal error fetching conversation" }, 500);
96
- }
97
- }
98
- async function handleGetMessages(c) {
99
- try {
100
- const phone = c.req.param("phone");
101
- if (!phone) return c.json({ error: "phone is required" }, 400);
102
- const store = c.get("store");
103
- const normalized = (0, better_zap.formatPhone)(decodeURIComponent(phone));
104
- const conversation = await store.getConversationByPhone(normalized);
105
- if (!conversation) return c.json({ error: "Conversation not found" }, 404);
106
- const cursor = c.req.query("cursor") || void 0;
107
- const limitParam = c.req.query("limit");
108
- const limit = limitParam ? parseInt(limitParam, 10) : void 0;
109
- const messages = await store.getMessagesByConversationPaginated(conversation.id, cursor, limit);
110
- return c.json(messages);
111
- } catch (error) {
112
- c.get("logger").error("conversations.messages_error", (0, better_zap.serializeError)(error));
113
- return c.json({ error: "Internal error fetching messages" }, 500);
114
- }
115
- }
116
- //#endregion
117
- //#region src/handler/send.ts
118
- function getSendResponseStatus(result) {
119
- return result.success ? 200 : result.httpStatus ?? 500;
120
- }
121
- async function handleSendText(c) {
122
- const { to, body, messageType, userId, metadata } = await c.req.json();
123
- if (!to || !body) return c.json({ error: "to and body are required" }, 400);
124
- const whatsapp = c.get("whatsapp");
125
- const logging = messageType ? {
126
- messageType,
127
- userId,
128
- metadata
129
- } : void 0;
130
- const result = await whatsapp.sendText(to, body, logging);
131
- return c.json(result, getSendResponseStatus(result));
132
- }
133
- function createSendTemplateHandler(templates) {
134
- return async function handleSendTemplate(c) {
135
- const body = await c.req.json();
136
- if (!body.to || !body.template) return c.json({ error: "to and template are required" }, 400);
137
- const whatsapp = c.get("whatsapp");
138
- const logging = body.logging ?? (body.messageType ? {
139
- messageType: body.messageType,
140
- content: body.content || `[template: ${body.template}]`,
141
- userId: body.userId,
142
- metadata: body.metadata
143
- } : void 0);
144
- let language = body.language;
145
- let components = body.components;
146
- if ("params" in body && body.params !== void 0) {
147
- if (!(0, better_zap.hasConfiguredTemplates)(templates)) return c.json({ error: "Typed template params require a configured template registry" }, 400);
148
- try {
149
- const serializedTemplate = (0, better_zap.serializeTemplateFromRegistry)(templates, body.template, {
150
- language: body.language,
151
- params: body.params
152
- });
153
- language = serializedTemplate.language;
154
- components = serializedTemplate.components;
155
- } catch (error) {
156
- const message = error instanceof Error ? error.message : "Failed to serialize template from registry";
157
- return c.json({ error: message }, 400);
158
- }
159
- }
160
- const result = await whatsapp.sendTemplate(body.to, body.template, language, components, logging);
161
- return c.json(result, getSendResponseStatus(result));
162
- };
163
- }
164
- async function handleSendInteractive(c) {
165
- const { to, type, body, buttons, buttonLabel, sections, cards, messageType, userId, metadata } = await c.req.json();
166
- if (!to || !body) return c.json({ error: "to and body are required" }, 400);
167
- const whatsapp = c.get("whatsapp");
168
- const logging = messageType ? {
169
- messageType,
170
- userId,
171
- metadata
172
- } : void 0;
173
- if (type === "list") {
174
- if (!buttonLabel || !sections) return c.json({ error: "buttonLabel and sections are required for list type" }, 400);
175
- const result = await whatsapp.sendInteractiveList(to, body, buttonLabel, sections, logging);
176
- return c.json(result, getSendResponseStatus(result));
177
- }
178
- if (type === "carousel") {
179
- if (!cards) return c.json({ error: "cards are required for carousel type" }, 400);
180
- if (cards.length < 2 || cards.length > 10) return c.json({ error: "carousel requires between 2 and 10 cards" }, 400);
181
- const result = await whatsapp.sendInteractiveMediaCarousel({
182
- to,
183
- body,
184
- cards
185
- }, logging);
186
- return c.json(result, getSendResponseStatus(result));
187
- }
188
- if (!buttons) return c.json({ error: "buttons are required for button type" }, 400);
189
- const result = await whatsapp.sendInteractiveButtons(to, body, buttons, logging);
190
- return c.json(result, getSendResponseStatus(result));
191
- }
192
- async function handleSendLocation(c) {
193
- const { to, latitude, longitude, name, address, messageType, userId, metadata } = await c.req.json();
194
- if (!to || latitude == null || longitude == null || !name || !address) return c.json({ error: "to, latitude, longitude, name, and address are required" }, 400);
195
- const whatsapp = c.get("whatsapp");
196
- const logging = messageType ? {
197
- messageType,
198
- userId,
199
- metadata
200
- } : void 0;
201
- const result = await whatsapp.sendLocation(to, latitude, longitude, name, address, logging);
202
- return c.json(result, getSendResponseStatus(result));
203
- }
204
- //#endregion
205
- //#region src/webhook/signature-verification.ts
206
- const textEncoder = new TextEncoder();
207
- let cachedMetaAppSecret = null;
208
- let cachedMetaHmacKey = null;
209
- async function verifyMetaWebhookSignature({ rawBody, signatureHeader, appSecret }) {
210
- if (!signatureHeader) return false;
211
- const [algorithm, signatureHexRaw] = signatureHeader.split("=", 2);
212
- if (algorithm?.toLowerCase() !== "sha256" || !signatureHexRaw) return false;
213
- const signatureBytes = hexToBytes(signatureHexRaw.trim());
214
- if (!signatureBytes) return false;
215
- const key = await getMetaHmacKey(appSecret);
216
- const expectedSignatureBuffer = await crypto.subtle.sign("HMAC", key, rawBody);
217
- return constantTimeEqual(new Uint8Array(expectedSignatureBuffer), signatureBytes);
218
- }
219
- function getMetaHmacKey(appSecret) {
220
- if (cachedMetaAppSecret === appSecret && cachedMetaHmacKey) return cachedMetaHmacKey;
221
- cachedMetaAppSecret = appSecret;
222
- cachedMetaHmacKey = crypto.subtle.importKey("raw", textEncoder.encode(appSecret), {
223
- name: "HMAC",
224
- hash: "SHA-256"
225
- }, false, ["sign"]);
226
- return cachedMetaHmacKey;
227
- }
228
- function hexToBytes(hex) {
229
- if (hex.length % 2 !== 0) return null;
230
- const bytes = new Uint8Array(hex.length / 2);
231
- for (let i = 0; i < bytes.length; i += 1) {
232
- const value = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
233
- if (Number.isNaN(value)) return null;
234
- bytes[i] = value;
235
- }
236
- return bytes;
237
- }
238
- function constantTimeEqual(a, b) {
239
- if (a.length !== b.length) return false;
240
- let diff = 0;
241
- for (let i = 0; i < a.length; i += 1) diff |= a[i] ^ b[i];
242
- return diff === 0;
243
- }
244
- //#endregion
245
- //#region src/webhook/message-content.ts
246
- /**
247
- * Extract human-readable content from incoming messages for audit logs.
248
- */
249
- function getMessageContent(message) {
250
- switch (message.type) {
251
- case "text": return message.text?.body || "[texto vazio]";
252
- case "image": return `[imagem${message.image?.caption ? `: ${message.image.caption}` : ""}]`;
253
- case "audio": return "[áudio]";
254
- case "video": return `[vídeo${message.video?.caption ? `: ${message.video.caption}` : ""}]`;
255
- case "document": return `[documento: ${message.document?.filename || "arquivo"}]`;
256
- case "location": return `[localização: ${message.location?.name || `${message.location?.latitude},${message.location?.longitude}`}]`;
257
- case "button": return `[botão: ${message.button?.text}]`;
258
- case "interactive":
259
- if (message.interactive?.button_reply) return `[resposta botão: ${message.interactive.button_reply.title}]`;
260
- if (message.interactive?.list_reply) return `[resposta lista: ${message.interactive.list_reply.title}]`;
261
- return "[interativo]";
262
- case "sticker": return "[figurinha]";
263
- case "reaction": return "[reação]";
264
- default: return `[${message.type}]`;
265
- }
266
- }
267
- //#endregion
268
- //#region src/webhook/create-webhook-handler.ts
269
- const textDecoder = new TextDecoder();
270
- /**
271
- * Creates a Hono router that handles the full WhatsApp webhook lifecycle.
272
- *
273
- * **SDK guarantees (non-hookable):**
274
- * - Signature is always verified before any processing
275
- * - Meta always receives a fast 200 OK (processing runs via `waitUntil`)
276
- * - Hook errors never crash the webhook (wrapped in try/catch)
277
- * - Contact is resolved and content is extracted before `onMessage`
278
- * - Status timestamp is parsed to ISO before `onStatusUpdate`
279
- *
280
- * @typeParam Env - Hono bindings type (e.g. Cloudflare Worker env).
281
- */
282
- function createWebhookHandler(config) {
283
- const log = config.log;
284
- const webhook = new hono.Hono();
285
- webhook.get("/", (c) => {
286
- const mode = c.req.query("hub.mode");
287
- const token = c.req.query("hub.verify_token");
288
- const challenge = c.req.query("hub.challenge");
289
- if (mode === "subscribe" && token === config.verifyToken) {
290
- log.info("webhook.verification_successful");
291
- return c.text(challenge || "", 200);
292
- }
293
- log.warn("webhook.verification_failed");
294
- return c.text("Forbidden", 403);
295
- });
296
- webhook.post("/", async (c) => {
297
- try {
298
- if (!config.appSecret) {
299
- log.error("webhook.missing_app_secret");
300
- return c.text("Server Misconfigured", 500);
301
- }
302
- const rawBody = await c.req.raw.arrayBuffer();
303
- if (!await verifyMetaWebhookSignature({
304
- rawBody,
305
- signatureHeader: c.req.header("x-hub-signature-256"),
306
- appSecret: config.appSecret
307
- })) {
308
- log.warn("webhook.invalid_signature");
309
- return c.text("Unauthorized", 401);
310
- }
311
- let payload;
312
- try {
313
- payload = JSON.parse(textDecoder.decode(rawBody));
314
- } catch {
315
- log.warn("webhook.invalid_payload");
316
- return c.text("Bad Request", 400);
317
- }
318
- if (c.executionCtx) c.executionCtx.waitUntil(processPayload(payload, c.env, config, log));
319
- else await processPayload(payload, c.env, config, log);
320
- return c.text("OK", 200);
321
- } catch (error) {
322
- log.error("webhook.request_error", (0, better_zap.serializeError)(error));
323
- return c.text("Internal Server Error", 500);
324
- }
325
- });
326
- return webhook;
327
- }
328
- /** Top-level dispatcher — iterates entries in the webhook payload. */
329
- async function processPayload(payload, env, config, log) {
330
- try {
331
- if (payload.object !== "whatsapp_business_account") {
332
- log.debug("webhook.ignored_payload", { object: payload.object });
333
- return;
334
- }
335
- for (const entry of payload.entry) await processEntry(entry, env, config, log);
336
- } catch (error) {
337
- log.error("webhook.async_process_error", (0, better_zap.serializeError)(error));
338
- }
339
- }
340
- /** Iterates changes within a single entry. */
341
- async function processEntry(entry, env, config, log) {
342
- for (const change of entry.changes) await processChange(change, env, config, log);
343
- }
344
- /** Routes messages, statuses, and errors to the appropriate handler. */
345
- async function processChange(change, env, config, log) {
346
- const value = change.value;
347
- if (value.messages && value.messages.length > 0) for (const message of value.messages) await processIncomingMessage(message, resolveContact(value.contacts, message), config, log);
348
- if (value.statuses && value.statuses.length > 0) for (const status of value.statuses) await processStatusUpdate(status, config, log);
349
- if (value.errors && value.errors.length > 0) {
350
- const errorHandler = config.onError ?? ((err) => {
351
- log.error("webhook.meta_error", { error: err });
352
- });
353
- for (const error of value.errors) try {
354
- errorHandler(error);
355
- } catch (hookError) {
356
- log.error("webhook.on_error_hook_failed", {
357
- metaError: error,
358
- hookError: (0, better_zap.serializeError)(hookError)
359
- });
360
- }
361
- }
362
- }
363
- /**
364
- * Processes a single incoming message:
365
- * 1. Deduplicates by waMessageId
366
- * 2. Extracts human-readable content
367
- * 3. Logs the message for audit trail
368
- * 4. Calls {@link WebhookConfig.onMessage}
369
- */
370
- async function processIncomingMessage(message, contact, config, log) {
371
- const phone = message.from;
372
- log.info("webhook.message_received", {
373
- waMessageId: message.id,
374
- phone,
375
- messageType: message.type
376
- });
377
- if (await config.logger.isDuplicate(message.id)) {
378
- log.info("webhook.duplicate_ignored", {
379
- waMessageId: message.id,
380
- phone
381
- });
382
- return;
383
- }
384
- const content = getMessageContent(message);
385
- const sentAt = /* @__PURE__ */ new Date(parseInt(message.timestamp, 10) * 1e3);
386
- const normalizedSentAt = Number.isNaN(sentAt.getTime()) ? (/* @__PURE__ */ new Date()).toISOString() : sentAt.toISOString();
387
- const { id, type, text, from, timestamp, ...rawMetadata } = message;
388
- await config.logger.logIncoming({
389
- phone,
390
- waMessageId: message.id,
391
- content,
392
- sentAt: normalizedSentAt,
393
- senderName: contact?.profile?.name,
394
- metadata: Object.keys(rawMetadata).length > 0 ? rawMetadata : void 0
395
- });
396
- const ctx = {
397
- message,
398
- contact,
399
- content,
400
- phone
401
- };
402
- try {
403
- await config.onMessage(ctx);
404
- } catch (error) {
405
- log.error("webhook.on_message_hook_failed", {
406
- waMessageId: message.id,
407
- phone,
408
- ...(0, better_zap.serializeError)(error)
409
- });
410
- }
411
- }
412
- /**
413
- * Processes a single delivery status update:
414
- * 1. Parses Unix timestamp to ISO-8601
415
- * 2. Extracts first error (if any)
416
- * 3. Atomically updates status only if it advances the lifecycle
417
- * 4. Calls {@link WebhookConfig.onStatusUpdate} only if the update was applied
418
- */
419
- async function processStatusUpdate(status, config, log) {
420
- const firstError = status.errors?.[0];
421
- const timestamp = (/* @__PURE__ */ new Date(parseInt(status.timestamp) * 1e3)).toISOString();
422
- const errorMessage = firstError?.message;
423
- const errorCode = firstError?.code;
424
- if (!await config.logger.updateStatus(status.id, status.status, timestamp, errorMessage)) return;
425
- log.info("webhook.status_updated", {
426
- waMessageId: status.id,
427
- status: status.status
428
- });
429
- const ctx = {
430
- status,
431
- timestamp,
432
- errorMessage,
433
- errorCode
434
- };
435
- try {
436
- await config.onStatusUpdate(ctx);
437
- } catch (error) {
438
- log.error("webhook.on_status_update_hook_failed", {
439
- waMessageId: status.id,
440
- ...(0, better_zap.serializeError)(error)
441
- });
442
- }
443
- }
444
- /** Matches a contact to a message by `wa_id`, falling back to the first contact. */
445
- function resolveContact(contacts, message) {
446
- if (!contacts || contacts.length === 0) return;
447
- return contacts.find((c) => c.wa_id === message.from) ?? contacts[0];
448
- }
449
- //#endregion
450
- //#region src/internal/cloudflare/constants.ts
451
- const GLOBAL_WORKSPACE_DO_ID = "global-workspace";
452
- //#endregion
453
- //#region src/internal/cloudflare/conversation-sync.ts
454
- function createConversationSyncNotifier(conversationSync) {
455
- if (!conversationSync) return;
456
- return { async notify(event) {
457
- const id = conversationSync.idFromName(GLOBAL_WORKSPACE_DO_ID);
458
- await conversationSync.get(id).fetch(new Request("http://do/sync", {
459
- method: "POST",
460
- body: JSON.stringify(event)
461
- }));
462
- } };
463
- }
464
- //#endregion
465
- //#region src/better-zap.ts
466
- function serializeRuntimeTemplate(templates, templateName, options) {
467
- return (0, better_zap.serializeTemplateFromRegistry)(templates, templateName, {
468
- language: options.language,
469
- params: options.params ?? {}
470
- });
471
- }
472
- function betterZap(options) {
473
- const { database, config, webhook: webhookHooks, conversationSync, basePath = "/api/whatsapp" } = options;
474
- const templates = options.templates ?? better_zap.EMPTY_TEMPLATE_REGISTRY;
475
- const log = (0, better_zap.createLogger)(options.logger);
476
- const logger = new better_zap.MessageLoggerService(database.whatsappLog, log, createConversationSyncNotifier(conversationSync));
477
- const whatsapp = new better_zap.WhatsAppService(config, logger, log);
478
- const coreContext = {
479
- db: database,
480
- api: whatsapp,
481
- logger
482
- };
483
- const coreServices = {
484
- whatsapp,
485
- logger
486
- };
487
- const plugins = options.plugins ?? [];
488
- const pluginRuntime = initializePlugins({
489
- plugins,
490
- database,
491
- config,
492
- coreContext,
493
- coreServices,
494
- log
495
- });
496
- const webhookRouter = createWebhookHandler({
497
- verifyToken: config.webhookToken,
498
- appSecret: config.appSecret,
499
- logger,
500
- log,
501
- onMessage: async (ctx) => {
502
- const hookContext = {
503
- ...ctx,
504
- ...pluginRuntime.context
505
- };
506
- await runPluginMessageHooks({
507
- plugins,
508
- ctx: hookContext,
509
- log
510
- });
511
- await webhookHooks.onMessage(hookContext);
512
- },
513
- onStatusUpdate: async (ctx) => {
514
- const hookContext = {
515
- ...ctx,
516
- ...pluginRuntime.context
517
- };
518
- await runPluginStatusHooks({
519
- plugins,
520
- ctx: hookContext,
521
- log
522
- });
523
- await webhookHooks.onStatusUpdate(hookContext);
524
- }
525
- });
526
- const app = new hono.Hono().basePath(basePath);
527
- app.use("*", async (c, next) => {
528
- c.set("whatsapp", whatsapp);
529
- c.set("store", database.whatsappLog);
530
- c.set("logger", log);
531
- await next();
532
- });
533
- app.route("/webhook", webhookRouter);
534
- app.post("/send/text", handleSendText);
535
- app.post("/send/template", createSendTemplateHandler(templates));
536
- app.post("/send/interactive", handleSendInteractive);
537
- app.post("/send/location", handleSendLocation);
538
- app.get("/conversations", handleListConversations);
539
- app.get("/conversations/:phone", handleGetConversation);
540
- app.get("/conversations/:phone/messages", handleGetMessages);
541
- const api = {
542
- send: {
543
- text: (to, body, opts) => whatsapp.sendText(to, body, opts),
544
- template: ((to, templateName, opts = {}) => {
545
- if (!(0, better_zap.hasConfiguredTemplates)(templates)) return whatsapp.sendTemplate(to, String(templateName), opts?.language, opts?.components, opts?.logging);
546
- const serializedTemplate = serializeRuntimeTemplate(templates, templateName, opts);
547
- return whatsapp.sendTemplate(to, String(templateName), serializedTemplate.language, serializedTemplate.components, opts.logging);
548
- }),
549
- templateRaw: (to, templateName, opts) => whatsapp.sendTemplate(to, templateName, opts?.language, opts?.components, opts?.logging),
550
- interactiveButtons: (to, body, buttons, opts) => whatsapp.sendInteractiveButtons(to, body, buttons, opts),
551
- interactiveList: (to, body, buttonLabel, sections, opts) => whatsapp.sendInteractiveList(to, body, buttonLabel, sections, opts),
552
- interactiveMediaCarousel: (data, opts) => whatsapp.sendInteractiveMediaCarousel(data, opts),
553
- location: (to, location, opts) => whatsapp.sendLocation(to, location.latitude, location.longitude, location.name, location.address, opts),
554
- markAsRead: (messageId) => whatsapp.markAsRead(messageId),
555
- reaction: (to, messageId, emoji) => whatsapp.sendReaction(to, messageId, emoji)
556
- },
557
- conversations: {
558
- list: async () => (0, better_zap.normalizeConversationRecords)(await database.whatsappLog.getConversations()),
559
- get: async (phone) => {
560
- const conversation = await database.whatsappLog.getConversationByPhone((0, better_zap.formatPhone)(phone));
561
- return conversation ? (0, better_zap.normalizeConversationRecord)(conversation) : null;
562
- },
563
- messages: async (phone, opts) => {
564
- const conversation = await database.whatsappLog.getConversationByPhone((0, better_zap.formatPhone)(phone));
565
- if (!conversation) return [];
566
- return await database.whatsappLog.getMessagesByConversationPaginated(conversation.id, opts?.cursor, opts?.limit);
567
- }
568
- }
569
- };
570
- const handler = async (request, env, executionCtx) => app.fetch(request, env, executionCtx);
571
- return {
572
- handler,
573
- api,
574
- services: pluginRuntime.services
575
- };
576
- }
577
- //#endregion
578
- exports.betterZap = betterZap;
579
- exports.createWebhookHandler = createWebhookHandler;
580
- exports.getMessageContent = getMessageContent;
581
- exports.verifyMetaWebhookSignature = verifyMetaWebhookSignature;
package/dist/index.d.cts DELETED
@@ -1,88 +0,0 @@
1
- import { BetterZapApi, BetterZapApi as BetterZapApi$1, BetterZapContext, BetterZapContext as BetterZapContext$1, BetterZapCoreConfig, BetterZapCoreConfig as BetterZapCoreConfig$1, BetterZapDatabase, BetterZapDatabase as BetterZapDatabase$1, BetterZapPlugin, BetterZapPlugin as BetterZapPlugin$1, BetterZapPluginInitContext, BetterZapPluginInitResult, BetterZapServices, BetterZapServices as BetterZapServices$1, IncomingMessage, InferBetterZapPluginContext, InferBetterZapPluginContext as InferBetterZapPluginContext$1, InferBetterZapPluginServices, InferBetterZapPluginServices as InferBetterZapPluginServices$1, Logger, LoggerConfig, MessageContext, MessageContext as MessageContext$1, MessageLoggerService, StatusContext, StatusContext as StatusContext$1, TemplateRegistry, WebhookError, WhatsAppLogStore, WhatsAppService } from "better-zap";
2
- import { Hono } from "hono";
3
-
4
- //#region src/better-zap.types.d.ts
5
- interface BetterZapConfig<TDatabase extends BetterZapDatabase$1 = BetterZapDatabase$1, TPlugins extends readonly BetterZapPlugin$1<TDatabase, any, any>[] = readonly [], TTemplates extends TemplateRegistry = {}> {
6
- database: TDatabase;
7
- config: BetterZapCoreConfig$1;
8
- plugins?: TPlugins;
9
- templates?: TTemplates;
10
- conversationSync?: DurableObjectNamespace<any>;
11
- webhook: {
12
- onMessage: (ctx: MessageContext$1 & BetterZapContext$1<TDatabase, InferBetterZapPluginContext$1<TPlugins>>) => Promise<void>;
13
- onStatusUpdate: (ctx: StatusContext$1 & BetterZapContext$1<TDatabase, InferBetterZapPluginContext$1<TPlugins>>) => Promise<void>;
14
- };
15
- basePath?: string;
16
- logger?: LoggerConfig;
17
- }
18
- interface BetterZap<TPluginServices extends Record<string, unknown> = {}, TTemplates extends TemplateRegistry = {}> {
19
- handler: (request: Request, env?: any, executionCtx?: any) => Promise<Response>;
20
- api: BetterZapApi$1<TTemplates>;
21
- services: BetterZapServices$1<TPluginServices>;
22
- }
23
- //#endregion
24
- //#region src/better-zap.d.ts
25
- declare function betterZap<TDatabase extends BetterZapDatabase$1 = BetterZapDatabase$1, TPlugins extends readonly BetterZapPlugin$1<TDatabase, any, any>[] = readonly BetterZapPlugin$1<TDatabase, any, any>[], TTemplates extends TemplateRegistry = {}>(options: BetterZapConfig<TDatabase, TPlugins, TTemplates>): BetterZap<InferBetterZapPluginServices$1<TPlugins>, TTemplates>;
26
- //#endregion
27
- //#region src/webhook/create-webhook-handler.d.ts
28
- /**
29
- * Configuration for {@link createWebhookHandler}.
30
- *
31
- * @typeParam Env - Hono bindings type (e.g. Cloudflare Worker env).
32
- */
33
- type WebhookConfig = {
34
- /** Token for the Meta verification challenge (`GET /webhook`). */verifyToken: string; /** App secret used for HMAC-SHA256 signature verification. */
35
- appSecret: string; /** logger for automatic message storage. */
36
- logger: MessageLoggerService; /** Structured logger for operational logging. */
37
- log: Logger; /** Called once per incoming message, after SDK pre-processing. */
38
- onMessage: (ctx: MessageContext$1) => Promise<void>; /** Called once per delivery status update (sent → delivered → read → failed). */
39
- onStatusUpdate: (ctx: StatusContext$1) => Promise<void>;
40
- /**
41
- * Called for Meta platform-level errors.
42
- * @default Uses the configured {@link WebhookConfig.log} logger's {@code error} method.
43
- */
44
- onError?: (error: WebhookError) => void;
45
- };
46
- /**
47
- * Creates a Hono router that handles the full WhatsApp webhook lifecycle.
48
- *
49
- * **SDK guarantees (non-hookable):**
50
- * - Signature is always verified before any processing
51
- * - Meta always receives a fast 200 OK (processing runs via `waitUntil`)
52
- * - Hook errors never crash the webhook (wrapped in try/catch)
53
- * - Contact is resolved and content is extracted before `onMessage`
54
- * - Status timestamp is parsed to ISO before `onStatusUpdate`
55
- *
56
- * @typeParam Env - Hono bindings type (e.g. Cloudflare Worker env).
57
- */
58
- declare function createWebhookHandler(config: WebhookConfig): Hono<{
59
- Bindings: Record<string, any>;
60
- }>;
61
- //#endregion
62
- //#region src/webhook/signature-verification.d.ts
63
- declare function verifyMetaWebhookSignature({
64
- rawBody,
65
- signatureHeader,
66
- appSecret
67
- }: {
68
- rawBody: ArrayBuffer;
69
- signatureHeader: string | undefined;
70
- appSecret: string;
71
- }): Promise<boolean>;
72
- //#endregion
73
- //#region src/webhook/message-content.d.ts
74
- /**
75
- * Extract human-readable content from incoming messages for audit logs.
76
- */
77
- declare function getMessageContent(message: IncomingMessage): string;
78
- //#endregion
79
- //#region src/handler/types.d.ts
80
- type BetterZapEnv = {
81
- Variables: {
82
- whatsapp: WhatsAppService;
83
- store: WhatsAppLogStore;
84
- logger: Logger;
85
- };
86
- };
87
- //#endregion
88
- export { type BetterZap, type BetterZapApi, type BetterZapConfig, type BetterZapContext, type BetterZapCoreConfig, type BetterZapDatabase, type BetterZapEnv, type BetterZapPlugin, type BetterZapPluginInitContext, type BetterZapPluginInitResult, type BetterZapServices, type InferBetterZapPluginContext, type InferBetterZapPluginServices, type MessageContext, type StatusContext, type WebhookConfig, betterZap, createWebhookHandler, getMessageContent, verifyMetaWebhookSignature };
package/dist/index.d.mts DELETED
@@ -1,88 +0,0 @@
1
- import { Hono } from "hono";
2
- import { BetterZapApi, BetterZapApi as BetterZapApi$1, BetterZapContext, BetterZapContext as BetterZapContext$1, BetterZapCoreConfig, BetterZapCoreConfig as BetterZapCoreConfig$1, BetterZapDatabase, BetterZapDatabase as BetterZapDatabase$1, BetterZapPlugin, BetterZapPlugin as BetterZapPlugin$1, BetterZapPluginInitContext, BetterZapPluginInitResult, BetterZapServices, BetterZapServices as BetterZapServices$1, IncomingMessage, InferBetterZapPluginContext, InferBetterZapPluginContext as InferBetterZapPluginContext$1, InferBetterZapPluginServices, InferBetterZapPluginServices as InferBetterZapPluginServices$1, Logger, LoggerConfig, MessageContext, MessageContext as MessageContext$1, MessageLoggerService, StatusContext, StatusContext as StatusContext$1, TemplateRegistry, WebhookError, WhatsAppLogStore, WhatsAppService } from "better-zap";
3
-
4
- //#region src/better-zap.types.d.ts
5
- interface BetterZapConfig<TDatabase extends BetterZapDatabase$1 = BetterZapDatabase$1, TPlugins extends readonly BetterZapPlugin$1<TDatabase, any, any>[] = readonly [], TTemplates extends TemplateRegistry = {}> {
6
- database: TDatabase;
7
- config: BetterZapCoreConfig$1;
8
- plugins?: TPlugins;
9
- templates?: TTemplates;
10
- conversationSync?: DurableObjectNamespace<any>;
11
- webhook: {
12
- onMessage: (ctx: MessageContext$1 & BetterZapContext$1<TDatabase, InferBetterZapPluginContext$1<TPlugins>>) => Promise<void>;
13
- onStatusUpdate: (ctx: StatusContext$1 & BetterZapContext$1<TDatabase, InferBetterZapPluginContext$1<TPlugins>>) => Promise<void>;
14
- };
15
- basePath?: string;
16
- logger?: LoggerConfig;
17
- }
18
- interface BetterZap<TPluginServices extends Record<string, unknown> = {}, TTemplates extends TemplateRegistry = {}> {
19
- handler: (request: Request, env?: any, executionCtx?: any) => Promise<Response>;
20
- api: BetterZapApi$1<TTemplates>;
21
- services: BetterZapServices$1<TPluginServices>;
22
- }
23
- //#endregion
24
- //#region src/better-zap.d.ts
25
- declare function betterZap<TDatabase extends BetterZapDatabase$1 = BetterZapDatabase$1, TPlugins extends readonly BetterZapPlugin$1<TDatabase, any, any>[] = readonly BetterZapPlugin$1<TDatabase, any, any>[], TTemplates extends TemplateRegistry = {}>(options: BetterZapConfig<TDatabase, TPlugins, TTemplates>): BetterZap<InferBetterZapPluginServices$1<TPlugins>, TTemplates>;
26
- //#endregion
27
- //#region src/webhook/create-webhook-handler.d.ts
28
- /**
29
- * Configuration for {@link createWebhookHandler}.
30
- *
31
- * @typeParam Env - Hono bindings type (e.g. Cloudflare Worker env).
32
- */
33
- type WebhookConfig = {
34
- /** Token for the Meta verification challenge (`GET /webhook`). */verifyToken: string; /** App secret used for HMAC-SHA256 signature verification. */
35
- appSecret: string; /** logger for automatic message storage. */
36
- logger: MessageLoggerService; /** Structured logger for operational logging. */
37
- log: Logger; /** Called once per incoming message, after SDK pre-processing. */
38
- onMessage: (ctx: MessageContext$1) => Promise<void>; /** Called once per delivery status update (sent → delivered → read → failed). */
39
- onStatusUpdate: (ctx: StatusContext$1) => Promise<void>;
40
- /**
41
- * Called for Meta platform-level errors.
42
- * @default Uses the configured {@link WebhookConfig.log} logger's {@code error} method.
43
- */
44
- onError?: (error: WebhookError) => void;
45
- };
46
- /**
47
- * Creates a Hono router that handles the full WhatsApp webhook lifecycle.
48
- *
49
- * **SDK guarantees (non-hookable):**
50
- * - Signature is always verified before any processing
51
- * - Meta always receives a fast 200 OK (processing runs via `waitUntil`)
52
- * - Hook errors never crash the webhook (wrapped in try/catch)
53
- * - Contact is resolved and content is extracted before `onMessage`
54
- * - Status timestamp is parsed to ISO before `onStatusUpdate`
55
- *
56
- * @typeParam Env - Hono bindings type (e.g. Cloudflare Worker env).
57
- */
58
- declare function createWebhookHandler(config: WebhookConfig): Hono<{
59
- Bindings: Record<string, any>;
60
- }>;
61
- //#endregion
62
- //#region src/webhook/signature-verification.d.ts
63
- declare function verifyMetaWebhookSignature({
64
- rawBody,
65
- signatureHeader,
66
- appSecret
67
- }: {
68
- rawBody: ArrayBuffer;
69
- signatureHeader: string | undefined;
70
- appSecret: string;
71
- }): Promise<boolean>;
72
- //#endregion
73
- //#region src/webhook/message-content.d.ts
74
- /**
75
- * Extract human-readable content from incoming messages for audit logs.
76
- */
77
- declare function getMessageContent(message: IncomingMessage): string;
78
- //#endregion
79
- //#region src/handler/types.d.ts
80
- type BetterZapEnv = {
81
- Variables: {
82
- whatsapp: WhatsAppService;
83
- store: WhatsAppLogStore;
84
- logger: Logger;
85
- };
86
- };
87
- //#endregion
88
- export { type BetterZap, type BetterZapApi, type BetterZapConfig, type BetterZapContext, type BetterZapCoreConfig, type BetterZapDatabase, type BetterZapEnv, type BetterZapPlugin, type BetterZapPluginInitContext, type BetterZapPluginInitResult, type BetterZapServices, type InferBetterZapPluginContext, type InferBetterZapPluginServices, type MessageContext, type StatusContext, type WebhookConfig, betterZap, createWebhookHandler, getMessageContent, verifyMetaWebhookSignature };
package/dist/index.mjs DELETED
@@ -1,577 +0,0 @@
1
- import { Hono } from "hono";
2
- import { EMPTY_TEMPLATE_REGISTRY, MessageLoggerService, WhatsAppService, createLogger, formatPhone, hasConfiguredTemplates, normalizeConversationRecord, normalizeConversationRecords, serializeError, serializeTemplateFromRegistry } from "better-zap";
3
- //#region src/plugins/runtime.ts
4
- function initializePlugins(options) {
5
- let pluginContext = {};
6
- let pluginServices = {};
7
- for (const plugin of options.plugins) {
8
- const result = plugin.init?.({
9
- database: options.database,
10
- config: options.config,
11
- context: {
12
- ...options.coreContext,
13
- ...pluginContext
14
- },
15
- services: {
16
- ...options.coreServices,
17
- ...pluginServices
18
- },
19
- log: options.log
20
- });
21
- if (!result) continue;
22
- if (result.context) pluginContext = {
23
- ...pluginContext,
24
- ...result.context
25
- };
26
- if (result.services) pluginServices = {
27
- ...pluginServices,
28
- ...result.services
29
- };
30
- }
31
- return {
32
- context: {
33
- ...options.coreContext,
34
- ...pluginContext
35
- },
36
- services: {
37
- ...options.coreServices,
38
- ...pluginServices
39
- }
40
- };
41
- }
42
- async function runPluginMessageHooks(options) {
43
- for (const plugin of options.plugins) {
44
- if (!plugin.hooks?.onMessage) continue;
45
- try {
46
- await plugin.hooks.onMessage(options.ctx);
47
- } catch (error) {
48
- options.log.error("plugin.on_message_failed", {
49
- pluginId: plugin.id,
50
- waMessageId: options.ctx.message.id,
51
- phone: options.ctx.phone,
52
- ...serializeError(error)
53
- });
54
- }
55
- }
56
- }
57
- async function runPluginStatusHooks(options) {
58
- for (const plugin of options.plugins) {
59
- if (!plugin.hooks?.onStatusUpdate) continue;
60
- try {
61
- await plugin.hooks.onStatusUpdate(options.ctx);
62
- } catch (error) {
63
- options.log.error("plugin.on_status_update_failed", {
64
- pluginId: plugin.id,
65
- waMessageId: options.ctx.status.id,
66
- status: options.ctx.status.status,
67
- ...serializeError(error)
68
- });
69
- }
70
- }
71
- }
72
- //#endregion
73
- //#region src/handler/conversations.ts
74
- async function handleListConversations(c) {
75
- try {
76
- const conversations = await c.get("store").getConversations();
77
- return c.json(normalizeConversationRecords(conversations));
78
- } catch (error) {
79
- c.get("logger").error("conversations.list_error", serializeError(error));
80
- return c.json({ error: "Internal error fetching conversations" }, 500);
81
- }
82
- }
83
- async function handleGetConversation(c) {
84
- try {
85
- const phone = c.req.param("phone");
86
- if (!phone) return c.json({ error: "phone is required" }, 400);
87
- const store = c.get("store");
88
- const normalized = formatPhone(decodeURIComponent(phone));
89
- const conversation = await store.getConversationByPhone(normalized);
90
- if (!conversation) return c.json({ error: "Conversation not found" }, 404);
91
- return c.json(normalizeConversationRecord(conversation));
92
- } catch (error) {
93
- c.get("logger").error("conversations.get_error", serializeError(error));
94
- return c.json({ error: "Internal error fetching conversation" }, 500);
95
- }
96
- }
97
- async function handleGetMessages(c) {
98
- try {
99
- const phone = c.req.param("phone");
100
- if (!phone) return c.json({ error: "phone is required" }, 400);
101
- const store = c.get("store");
102
- const normalized = formatPhone(decodeURIComponent(phone));
103
- const conversation = await store.getConversationByPhone(normalized);
104
- if (!conversation) return c.json({ error: "Conversation not found" }, 404);
105
- const cursor = c.req.query("cursor") || void 0;
106
- const limitParam = c.req.query("limit");
107
- const limit = limitParam ? parseInt(limitParam, 10) : void 0;
108
- const messages = await store.getMessagesByConversationPaginated(conversation.id, cursor, limit);
109
- return c.json(messages);
110
- } catch (error) {
111
- c.get("logger").error("conversations.messages_error", serializeError(error));
112
- return c.json({ error: "Internal error fetching messages" }, 500);
113
- }
114
- }
115
- //#endregion
116
- //#region src/handler/send.ts
117
- function getSendResponseStatus(result) {
118
- return result.success ? 200 : result.httpStatus ?? 500;
119
- }
120
- async function handleSendText(c) {
121
- const { to, body, messageType, userId, metadata } = await c.req.json();
122
- if (!to || !body) return c.json({ error: "to and body are required" }, 400);
123
- const whatsapp = c.get("whatsapp");
124
- const logging = messageType ? {
125
- messageType,
126
- userId,
127
- metadata
128
- } : void 0;
129
- const result = await whatsapp.sendText(to, body, logging);
130
- return c.json(result, getSendResponseStatus(result));
131
- }
132
- function createSendTemplateHandler(templates) {
133
- return async function handleSendTemplate(c) {
134
- const body = await c.req.json();
135
- if (!body.to || !body.template) return c.json({ error: "to and template are required" }, 400);
136
- const whatsapp = c.get("whatsapp");
137
- const logging = body.logging ?? (body.messageType ? {
138
- messageType: body.messageType,
139
- content: body.content || `[template: ${body.template}]`,
140
- userId: body.userId,
141
- metadata: body.metadata
142
- } : void 0);
143
- let language = body.language;
144
- let components = body.components;
145
- if ("params" in body && body.params !== void 0) {
146
- if (!hasConfiguredTemplates(templates)) return c.json({ error: "Typed template params require a configured template registry" }, 400);
147
- try {
148
- const serializedTemplate = serializeTemplateFromRegistry(templates, body.template, {
149
- language: body.language,
150
- params: body.params
151
- });
152
- language = serializedTemplate.language;
153
- components = serializedTemplate.components;
154
- } catch (error) {
155
- const message = error instanceof Error ? error.message : "Failed to serialize template from registry";
156
- return c.json({ error: message }, 400);
157
- }
158
- }
159
- const result = await whatsapp.sendTemplate(body.to, body.template, language, components, logging);
160
- return c.json(result, getSendResponseStatus(result));
161
- };
162
- }
163
- async function handleSendInteractive(c) {
164
- const { to, type, body, buttons, buttonLabel, sections, cards, messageType, userId, metadata } = await c.req.json();
165
- if (!to || !body) return c.json({ error: "to and body are required" }, 400);
166
- const whatsapp = c.get("whatsapp");
167
- const logging = messageType ? {
168
- messageType,
169
- userId,
170
- metadata
171
- } : void 0;
172
- if (type === "list") {
173
- if (!buttonLabel || !sections) return c.json({ error: "buttonLabel and sections are required for list type" }, 400);
174
- const result = await whatsapp.sendInteractiveList(to, body, buttonLabel, sections, logging);
175
- return c.json(result, getSendResponseStatus(result));
176
- }
177
- if (type === "carousel") {
178
- if (!cards) return c.json({ error: "cards are required for carousel type" }, 400);
179
- if (cards.length < 2 || cards.length > 10) return c.json({ error: "carousel requires between 2 and 10 cards" }, 400);
180
- const result = await whatsapp.sendInteractiveMediaCarousel({
181
- to,
182
- body,
183
- cards
184
- }, logging);
185
- return c.json(result, getSendResponseStatus(result));
186
- }
187
- if (!buttons) return c.json({ error: "buttons are required for button type" }, 400);
188
- const result = await whatsapp.sendInteractiveButtons(to, body, buttons, logging);
189
- return c.json(result, getSendResponseStatus(result));
190
- }
191
- async function handleSendLocation(c) {
192
- const { to, latitude, longitude, name, address, messageType, userId, metadata } = await c.req.json();
193
- if (!to || latitude == null || longitude == null || !name || !address) return c.json({ error: "to, latitude, longitude, name, and address are required" }, 400);
194
- const whatsapp = c.get("whatsapp");
195
- const logging = messageType ? {
196
- messageType,
197
- userId,
198
- metadata
199
- } : void 0;
200
- const result = await whatsapp.sendLocation(to, latitude, longitude, name, address, logging);
201
- return c.json(result, getSendResponseStatus(result));
202
- }
203
- //#endregion
204
- //#region src/webhook/signature-verification.ts
205
- const textEncoder = new TextEncoder();
206
- let cachedMetaAppSecret = null;
207
- let cachedMetaHmacKey = null;
208
- async function verifyMetaWebhookSignature({ rawBody, signatureHeader, appSecret }) {
209
- if (!signatureHeader) return false;
210
- const [algorithm, signatureHexRaw] = signatureHeader.split("=", 2);
211
- if (algorithm?.toLowerCase() !== "sha256" || !signatureHexRaw) return false;
212
- const signatureBytes = hexToBytes(signatureHexRaw.trim());
213
- if (!signatureBytes) return false;
214
- const key = await getMetaHmacKey(appSecret);
215
- const expectedSignatureBuffer = await crypto.subtle.sign("HMAC", key, rawBody);
216
- return constantTimeEqual(new Uint8Array(expectedSignatureBuffer), signatureBytes);
217
- }
218
- function getMetaHmacKey(appSecret) {
219
- if (cachedMetaAppSecret === appSecret && cachedMetaHmacKey) return cachedMetaHmacKey;
220
- cachedMetaAppSecret = appSecret;
221
- cachedMetaHmacKey = crypto.subtle.importKey("raw", textEncoder.encode(appSecret), {
222
- name: "HMAC",
223
- hash: "SHA-256"
224
- }, false, ["sign"]);
225
- return cachedMetaHmacKey;
226
- }
227
- function hexToBytes(hex) {
228
- if (hex.length % 2 !== 0) return null;
229
- const bytes = new Uint8Array(hex.length / 2);
230
- for (let i = 0; i < bytes.length; i += 1) {
231
- const value = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
232
- if (Number.isNaN(value)) return null;
233
- bytes[i] = value;
234
- }
235
- return bytes;
236
- }
237
- function constantTimeEqual(a, b) {
238
- if (a.length !== b.length) return false;
239
- let diff = 0;
240
- for (let i = 0; i < a.length; i += 1) diff |= a[i] ^ b[i];
241
- return diff === 0;
242
- }
243
- //#endregion
244
- //#region src/webhook/message-content.ts
245
- /**
246
- * Extract human-readable content from incoming messages for audit logs.
247
- */
248
- function getMessageContent(message) {
249
- switch (message.type) {
250
- case "text": return message.text?.body || "[texto vazio]";
251
- case "image": return `[imagem${message.image?.caption ? `: ${message.image.caption}` : ""}]`;
252
- case "audio": return "[áudio]";
253
- case "video": return `[vídeo${message.video?.caption ? `: ${message.video.caption}` : ""}]`;
254
- case "document": return `[documento: ${message.document?.filename || "arquivo"}]`;
255
- case "location": return `[localização: ${message.location?.name || `${message.location?.latitude},${message.location?.longitude}`}]`;
256
- case "button": return `[botão: ${message.button?.text}]`;
257
- case "interactive":
258
- if (message.interactive?.button_reply) return `[resposta botão: ${message.interactive.button_reply.title}]`;
259
- if (message.interactive?.list_reply) return `[resposta lista: ${message.interactive.list_reply.title}]`;
260
- return "[interativo]";
261
- case "sticker": return "[figurinha]";
262
- case "reaction": return "[reação]";
263
- default: return `[${message.type}]`;
264
- }
265
- }
266
- //#endregion
267
- //#region src/webhook/create-webhook-handler.ts
268
- const textDecoder = new TextDecoder();
269
- /**
270
- * Creates a Hono router that handles the full WhatsApp webhook lifecycle.
271
- *
272
- * **SDK guarantees (non-hookable):**
273
- * - Signature is always verified before any processing
274
- * - Meta always receives a fast 200 OK (processing runs via `waitUntil`)
275
- * - Hook errors never crash the webhook (wrapped in try/catch)
276
- * - Contact is resolved and content is extracted before `onMessage`
277
- * - Status timestamp is parsed to ISO before `onStatusUpdate`
278
- *
279
- * @typeParam Env - Hono bindings type (e.g. Cloudflare Worker env).
280
- */
281
- function createWebhookHandler(config) {
282
- const log = config.log;
283
- const webhook = new Hono();
284
- webhook.get("/", (c) => {
285
- const mode = c.req.query("hub.mode");
286
- const token = c.req.query("hub.verify_token");
287
- const challenge = c.req.query("hub.challenge");
288
- if (mode === "subscribe" && token === config.verifyToken) {
289
- log.info("webhook.verification_successful");
290
- return c.text(challenge || "", 200);
291
- }
292
- log.warn("webhook.verification_failed");
293
- return c.text("Forbidden", 403);
294
- });
295
- webhook.post("/", async (c) => {
296
- try {
297
- if (!config.appSecret) {
298
- log.error("webhook.missing_app_secret");
299
- return c.text("Server Misconfigured", 500);
300
- }
301
- const rawBody = await c.req.raw.arrayBuffer();
302
- if (!await verifyMetaWebhookSignature({
303
- rawBody,
304
- signatureHeader: c.req.header("x-hub-signature-256"),
305
- appSecret: config.appSecret
306
- })) {
307
- log.warn("webhook.invalid_signature");
308
- return c.text("Unauthorized", 401);
309
- }
310
- let payload;
311
- try {
312
- payload = JSON.parse(textDecoder.decode(rawBody));
313
- } catch {
314
- log.warn("webhook.invalid_payload");
315
- return c.text("Bad Request", 400);
316
- }
317
- if (c.executionCtx) c.executionCtx.waitUntil(processPayload(payload, c.env, config, log));
318
- else await processPayload(payload, c.env, config, log);
319
- return c.text("OK", 200);
320
- } catch (error) {
321
- log.error("webhook.request_error", serializeError(error));
322
- return c.text("Internal Server Error", 500);
323
- }
324
- });
325
- return webhook;
326
- }
327
- /** Top-level dispatcher — iterates entries in the webhook payload. */
328
- async function processPayload(payload, env, config, log) {
329
- try {
330
- if (payload.object !== "whatsapp_business_account") {
331
- log.debug("webhook.ignored_payload", { object: payload.object });
332
- return;
333
- }
334
- for (const entry of payload.entry) await processEntry(entry, env, config, log);
335
- } catch (error) {
336
- log.error("webhook.async_process_error", serializeError(error));
337
- }
338
- }
339
- /** Iterates changes within a single entry. */
340
- async function processEntry(entry, env, config, log) {
341
- for (const change of entry.changes) await processChange(change, env, config, log);
342
- }
343
- /** Routes messages, statuses, and errors to the appropriate handler. */
344
- async function processChange(change, env, config, log) {
345
- const value = change.value;
346
- if (value.messages && value.messages.length > 0) for (const message of value.messages) await processIncomingMessage(message, resolveContact(value.contacts, message), config, log);
347
- if (value.statuses && value.statuses.length > 0) for (const status of value.statuses) await processStatusUpdate(status, config, log);
348
- if (value.errors && value.errors.length > 0) {
349
- const errorHandler = config.onError ?? ((err) => {
350
- log.error("webhook.meta_error", { error: err });
351
- });
352
- for (const error of value.errors) try {
353
- errorHandler(error);
354
- } catch (hookError) {
355
- log.error("webhook.on_error_hook_failed", {
356
- metaError: error,
357
- hookError: serializeError(hookError)
358
- });
359
- }
360
- }
361
- }
362
- /**
363
- * Processes a single incoming message:
364
- * 1. Deduplicates by waMessageId
365
- * 2. Extracts human-readable content
366
- * 3. Logs the message for audit trail
367
- * 4. Calls {@link WebhookConfig.onMessage}
368
- */
369
- async function processIncomingMessage(message, contact, config, log) {
370
- const phone = message.from;
371
- log.info("webhook.message_received", {
372
- waMessageId: message.id,
373
- phone,
374
- messageType: message.type
375
- });
376
- if (await config.logger.isDuplicate(message.id)) {
377
- log.info("webhook.duplicate_ignored", {
378
- waMessageId: message.id,
379
- phone
380
- });
381
- return;
382
- }
383
- const content = getMessageContent(message);
384
- const sentAt = /* @__PURE__ */ new Date(parseInt(message.timestamp, 10) * 1e3);
385
- const normalizedSentAt = Number.isNaN(sentAt.getTime()) ? (/* @__PURE__ */ new Date()).toISOString() : sentAt.toISOString();
386
- const { id, type, text, from, timestamp, ...rawMetadata } = message;
387
- await config.logger.logIncoming({
388
- phone,
389
- waMessageId: message.id,
390
- content,
391
- sentAt: normalizedSentAt,
392
- senderName: contact?.profile?.name,
393
- metadata: Object.keys(rawMetadata).length > 0 ? rawMetadata : void 0
394
- });
395
- const ctx = {
396
- message,
397
- contact,
398
- content,
399
- phone
400
- };
401
- try {
402
- await config.onMessage(ctx);
403
- } catch (error) {
404
- log.error("webhook.on_message_hook_failed", {
405
- waMessageId: message.id,
406
- phone,
407
- ...serializeError(error)
408
- });
409
- }
410
- }
411
- /**
412
- * Processes a single delivery status update:
413
- * 1. Parses Unix timestamp to ISO-8601
414
- * 2. Extracts first error (if any)
415
- * 3. Atomically updates status only if it advances the lifecycle
416
- * 4. Calls {@link WebhookConfig.onStatusUpdate} only if the update was applied
417
- */
418
- async function processStatusUpdate(status, config, log) {
419
- const firstError = status.errors?.[0];
420
- const timestamp = (/* @__PURE__ */ new Date(parseInt(status.timestamp) * 1e3)).toISOString();
421
- const errorMessage = firstError?.message;
422
- const errorCode = firstError?.code;
423
- if (!await config.logger.updateStatus(status.id, status.status, timestamp, errorMessage)) return;
424
- log.info("webhook.status_updated", {
425
- waMessageId: status.id,
426
- status: status.status
427
- });
428
- const ctx = {
429
- status,
430
- timestamp,
431
- errorMessage,
432
- errorCode
433
- };
434
- try {
435
- await config.onStatusUpdate(ctx);
436
- } catch (error) {
437
- log.error("webhook.on_status_update_hook_failed", {
438
- waMessageId: status.id,
439
- ...serializeError(error)
440
- });
441
- }
442
- }
443
- /** Matches a contact to a message by `wa_id`, falling back to the first contact. */
444
- function resolveContact(contacts, message) {
445
- if (!contacts || contacts.length === 0) return;
446
- return contacts.find((c) => c.wa_id === message.from) ?? contacts[0];
447
- }
448
- //#endregion
449
- //#region src/internal/cloudflare/constants.ts
450
- const GLOBAL_WORKSPACE_DO_ID = "global-workspace";
451
- //#endregion
452
- //#region src/internal/cloudflare/conversation-sync.ts
453
- function createConversationSyncNotifier(conversationSync) {
454
- if (!conversationSync) return;
455
- return { async notify(event) {
456
- const id = conversationSync.idFromName(GLOBAL_WORKSPACE_DO_ID);
457
- await conversationSync.get(id).fetch(new Request("http://do/sync", {
458
- method: "POST",
459
- body: JSON.stringify(event)
460
- }));
461
- } };
462
- }
463
- //#endregion
464
- //#region src/better-zap.ts
465
- function serializeRuntimeTemplate(templates, templateName, options) {
466
- return serializeTemplateFromRegistry(templates, templateName, {
467
- language: options.language,
468
- params: options.params ?? {}
469
- });
470
- }
471
- function betterZap(options) {
472
- const { database, config, webhook: webhookHooks, conversationSync, basePath = "/api/whatsapp" } = options;
473
- const templates = options.templates ?? EMPTY_TEMPLATE_REGISTRY;
474
- const log = createLogger(options.logger);
475
- const logger = new MessageLoggerService(database.whatsappLog, log, createConversationSyncNotifier(conversationSync));
476
- const whatsapp = new WhatsAppService(config, logger, log);
477
- const coreContext = {
478
- db: database,
479
- api: whatsapp,
480
- logger
481
- };
482
- const coreServices = {
483
- whatsapp,
484
- logger
485
- };
486
- const plugins = options.plugins ?? [];
487
- const pluginRuntime = initializePlugins({
488
- plugins,
489
- database,
490
- config,
491
- coreContext,
492
- coreServices,
493
- log
494
- });
495
- const webhookRouter = createWebhookHandler({
496
- verifyToken: config.webhookToken,
497
- appSecret: config.appSecret,
498
- logger,
499
- log,
500
- onMessage: async (ctx) => {
501
- const hookContext = {
502
- ...ctx,
503
- ...pluginRuntime.context
504
- };
505
- await runPluginMessageHooks({
506
- plugins,
507
- ctx: hookContext,
508
- log
509
- });
510
- await webhookHooks.onMessage(hookContext);
511
- },
512
- onStatusUpdate: async (ctx) => {
513
- const hookContext = {
514
- ...ctx,
515
- ...pluginRuntime.context
516
- };
517
- await runPluginStatusHooks({
518
- plugins,
519
- ctx: hookContext,
520
- log
521
- });
522
- await webhookHooks.onStatusUpdate(hookContext);
523
- }
524
- });
525
- const app = new Hono().basePath(basePath);
526
- app.use("*", async (c, next) => {
527
- c.set("whatsapp", whatsapp);
528
- c.set("store", database.whatsappLog);
529
- c.set("logger", log);
530
- await next();
531
- });
532
- app.route("/webhook", webhookRouter);
533
- app.post("/send/text", handleSendText);
534
- app.post("/send/template", createSendTemplateHandler(templates));
535
- app.post("/send/interactive", handleSendInteractive);
536
- app.post("/send/location", handleSendLocation);
537
- app.get("/conversations", handleListConversations);
538
- app.get("/conversations/:phone", handleGetConversation);
539
- app.get("/conversations/:phone/messages", handleGetMessages);
540
- const api = {
541
- send: {
542
- text: (to, body, opts) => whatsapp.sendText(to, body, opts),
543
- template: ((to, templateName, opts = {}) => {
544
- if (!hasConfiguredTemplates(templates)) return whatsapp.sendTemplate(to, String(templateName), opts?.language, opts?.components, opts?.logging);
545
- const serializedTemplate = serializeRuntimeTemplate(templates, templateName, opts);
546
- return whatsapp.sendTemplate(to, String(templateName), serializedTemplate.language, serializedTemplate.components, opts.logging);
547
- }),
548
- templateRaw: (to, templateName, opts) => whatsapp.sendTemplate(to, templateName, opts?.language, opts?.components, opts?.logging),
549
- interactiveButtons: (to, body, buttons, opts) => whatsapp.sendInteractiveButtons(to, body, buttons, opts),
550
- interactiveList: (to, body, buttonLabel, sections, opts) => whatsapp.sendInteractiveList(to, body, buttonLabel, sections, opts),
551
- interactiveMediaCarousel: (data, opts) => whatsapp.sendInteractiveMediaCarousel(data, opts),
552
- location: (to, location, opts) => whatsapp.sendLocation(to, location.latitude, location.longitude, location.name, location.address, opts),
553
- markAsRead: (messageId) => whatsapp.markAsRead(messageId),
554
- reaction: (to, messageId, emoji) => whatsapp.sendReaction(to, messageId, emoji)
555
- },
556
- conversations: {
557
- list: async () => normalizeConversationRecords(await database.whatsappLog.getConversations()),
558
- get: async (phone) => {
559
- const conversation = await database.whatsappLog.getConversationByPhone(formatPhone(phone));
560
- return conversation ? normalizeConversationRecord(conversation) : null;
561
- },
562
- messages: async (phone, opts) => {
563
- const conversation = await database.whatsappLog.getConversationByPhone(formatPhone(phone));
564
- if (!conversation) return [];
565
- return await database.whatsappLog.getMessagesByConversationPaginated(conversation.id, opts?.cursor, opts?.limit);
566
- }
567
- }
568
- };
569
- const handler = async (request, env, executionCtx) => app.fetch(request, env, executionCtx);
570
- return {
571
- handler,
572
- api,
573
- services: pluginRuntime.services
574
- };
575
- }
576
- //#endregion
577
- export { betterZap, createWebhookHandler, getMessageContent, verifyMetaWebhookSignature };