@better-zap/hono 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 Better Zap
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
10
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
11
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
12
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
13
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
14
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
15
+ PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # @better-zap/hono
2
+
3
+ Hono runtime and webhook adapter for Better Zap.
package/dist/index.cjs ADDED
@@ -0,0 +1,572 @@
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(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(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
+ async function handleSendText(c) {
119
+ const { to, body, messageType, userId, metadata } = await c.req.json();
120
+ if (!to || !body) return c.json({ error: "to and body are required" }, 400);
121
+ const whatsapp = c.get("whatsapp");
122
+ const logging = messageType ? {
123
+ messageType,
124
+ userId,
125
+ metadata
126
+ } : void 0;
127
+ const result = await whatsapp.sendText(to, body, logging);
128
+ return c.json(result, result.success ? 200 : 500);
129
+ }
130
+ function createSendTemplateHandler(templates) {
131
+ return async function handleSendTemplate(c) {
132
+ const body = await c.req.json();
133
+ if (!body.to || !body.template) return c.json({ error: "to and template are required" }, 400);
134
+ const whatsapp = c.get("whatsapp");
135
+ const logging = body.logging ?? (body.messageType ? {
136
+ messageType: body.messageType,
137
+ content: body.content || `[template: ${body.template}]`,
138
+ userId: body.userId,
139
+ metadata: body.metadata
140
+ } : void 0);
141
+ let language = body.language;
142
+ let components = body.components;
143
+ if ("params" in body && body.params !== void 0) {
144
+ if (!(0, better_zap.hasConfiguredTemplates)(templates)) return c.json({ error: "Typed template params require a configured template registry" }, 400);
145
+ try {
146
+ const serializedTemplate = (0, better_zap.serializeTemplateFromRegistry)(templates, body.template, {
147
+ language: body.language,
148
+ params: body.params
149
+ });
150
+ language = serializedTemplate.language;
151
+ components = serializedTemplate.components;
152
+ } catch (error) {
153
+ const message = error instanceof Error ? error.message : "Failed to serialize template from registry";
154
+ return c.json({ error: message }, 400);
155
+ }
156
+ }
157
+ const result = await whatsapp.sendTemplate(body.to, body.template, language, components, logging);
158
+ return c.json(result, result.success ? 200 : 500);
159
+ };
160
+ }
161
+ async function handleSendInteractive(c) {
162
+ const { to, type, body, buttons, buttonLabel, sections, cards, messageType, userId, metadata } = await c.req.json();
163
+ if (!to || !body) return c.json({ error: "to and body are required" }, 400);
164
+ const whatsapp = c.get("whatsapp");
165
+ const logging = messageType ? {
166
+ messageType,
167
+ userId,
168
+ metadata
169
+ } : void 0;
170
+ if (type === "list") {
171
+ if (!buttonLabel || !sections) return c.json({ error: "buttonLabel and sections are required for list type" }, 400);
172
+ const result = await whatsapp.sendInteractiveList(to, body, buttonLabel, sections, logging);
173
+ return c.json(result, result.success ? 200 : 500);
174
+ }
175
+ if (type === "carousel") {
176
+ if (!cards) return c.json({ error: "cards are required for carousel type" }, 400);
177
+ if (cards.length < 2 || cards.length > 10) return c.json({ error: "carousel requires between 2 and 10 cards" }, 400);
178
+ const result = await whatsapp.sendInteractiveMediaCarousel({
179
+ to,
180
+ body,
181
+ cards
182
+ }, logging);
183
+ return c.json(result, result.success ? 200 : 500);
184
+ }
185
+ if (!buttons) return c.json({ error: "buttons are required for button type" }, 400);
186
+ const result = await whatsapp.sendInteractiveButtons(to, body, buttons, logging);
187
+ return c.json(result, result.success ? 200 : 500);
188
+ }
189
+ async function handleSendLocation(c) {
190
+ const { to, latitude, longitude, name, address, messageType, userId, metadata } = await c.req.json();
191
+ if (!to || latitude == null || longitude == null || !name || !address) return c.json({ error: "to, latitude, longitude, name, and address are required" }, 400);
192
+ const whatsapp = c.get("whatsapp");
193
+ const logging = messageType ? {
194
+ messageType,
195
+ userId,
196
+ metadata
197
+ } : void 0;
198
+ const result = await whatsapp.sendLocation(to, latitude, longitude, name, address, logging);
199
+ return c.json(result, result.success ? 200 : 500);
200
+ }
201
+ //#endregion
202
+ //#region src/webhook/signature-verification.ts
203
+ const textEncoder = new TextEncoder();
204
+ let cachedMetaAppSecret = null;
205
+ let cachedMetaHmacKey = null;
206
+ async function verifyMetaWebhookSignature({ rawBody, signatureHeader, appSecret }) {
207
+ if (!signatureHeader) return false;
208
+ const [algorithm, signatureHexRaw] = signatureHeader.split("=", 2);
209
+ if (algorithm?.toLowerCase() !== "sha256" || !signatureHexRaw) return false;
210
+ const signatureBytes = hexToBytes(signatureHexRaw.trim());
211
+ if (!signatureBytes) return false;
212
+ const key = await getMetaHmacKey(appSecret);
213
+ const expectedSignatureBuffer = await crypto.subtle.sign("HMAC", key, rawBody);
214
+ return constantTimeEqual(new Uint8Array(expectedSignatureBuffer), signatureBytes);
215
+ }
216
+ function getMetaHmacKey(appSecret) {
217
+ if (cachedMetaAppSecret === appSecret && cachedMetaHmacKey) return cachedMetaHmacKey;
218
+ cachedMetaAppSecret = appSecret;
219
+ cachedMetaHmacKey = crypto.subtle.importKey("raw", textEncoder.encode(appSecret), {
220
+ name: "HMAC",
221
+ hash: "SHA-256"
222
+ }, false, ["sign"]);
223
+ return cachedMetaHmacKey;
224
+ }
225
+ function hexToBytes(hex) {
226
+ if (hex.length % 2 !== 0) return null;
227
+ const bytes = new Uint8Array(hex.length / 2);
228
+ for (let i = 0; i < bytes.length; i += 1) {
229
+ const value = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
230
+ if (Number.isNaN(value)) return null;
231
+ bytes[i] = value;
232
+ }
233
+ return bytes;
234
+ }
235
+ function constantTimeEqual(a, b) {
236
+ if (a.length !== b.length) return false;
237
+ let diff = 0;
238
+ for (let i = 0; i < a.length; i += 1) diff |= a[i] ^ b[i];
239
+ return diff === 0;
240
+ }
241
+ //#endregion
242
+ //#region src/webhook/message-content.ts
243
+ /**
244
+ * Extract human-readable content from incoming messages for audit logs.
245
+ */
246
+ function getMessageContent(message) {
247
+ switch (message.type) {
248
+ case "text": return message.text?.body || "[texto vazio]";
249
+ case "image": return `[imagem${message.image?.caption ? `: ${message.image.caption}` : ""}]`;
250
+ case "audio": return "[áudio]";
251
+ case "video": return `[vídeo${message.video?.caption ? `: ${message.video.caption}` : ""}]`;
252
+ case "document": return `[documento: ${message.document?.filename || "arquivo"}]`;
253
+ case "location": return `[localização: ${message.location?.name || `${message.location?.latitude},${message.location?.longitude}`}]`;
254
+ case "button": return `[botão: ${message.button?.text}]`;
255
+ case "interactive":
256
+ if (message.interactive?.button_reply) return `[resposta botão: ${message.interactive.button_reply.title}]`;
257
+ if (message.interactive?.list_reply) return `[resposta lista: ${message.interactive.list_reply.title}]`;
258
+ return "[interativo]";
259
+ case "sticker": return "[figurinha]";
260
+ case "reaction": return "[reação]";
261
+ default: return `[${message.type}]`;
262
+ }
263
+ }
264
+ //#endregion
265
+ //#region src/webhook/create-webhook-handler.ts
266
+ const textDecoder = new TextDecoder();
267
+ /**
268
+ * Creates a Hono router that handles the full WhatsApp webhook lifecycle.
269
+ *
270
+ * **SDK guarantees (non-hookable):**
271
+ * - Signature is always verified before any processing
272
+ * - Meta always receives a fast 200 OK (processing runs via `waitUntil`)
273
+ * - Hook errors never crash the webhook (wrapped in try/catch)
274
+ * - Contact is resolved and content is extracted before `onMessage`
275
+ * - Status timestamp is parsed to ISO before `onStatusUpdate`
276
+ *
277
+ * @typeParam Env - Hono bindings type (e.g. Cloudflare Worker env).
278
+ */
279
+ function createWebhookHandler(config) {
280
+ const log = config.log;
281
+ const webhook = new hono.Hono();
282
+ webhook.get("/", (c) => {
283
+ const mode = c.req.query("hub.mode");
284
+ const token = c.req.query("hub.verify_token");
285
+ const challenge = c.req.query("hub.challenge");
286
+ if (mode === "subscribe" && token === config.verifyToken) {
287
+ log.info("webhook.verification_successful");
288
+ return c.text(challenge || "", 200);
289
+ }
290
+ log.warn("webhook.verification_failed");
291
+ return c.text("Forbidden", 403);
292
+ });
293
+ webhook.post("/", async (c) => {
294
+ try {
295
+ if (!config.appSecret) {
296
+ log.error("webhook.missing_app_secret");
297
+ return c.text("Server Misconfigured", 500);
298
+ }
299
+ const rawBody = await c.req.raw.arrayBuffer();
300
+ if (!await verifyMetaWebhookSignature({
301
+ rawBody,
302
+ signatureHeader: c.req.header("x-hub-signature-256"),
303
+ appSecret: config.appSecret
304
+ })) {
305
+ log.warn("webhook.invalid_signature");
306
+ return c.text("Unauthorized", 401);
307
+ }
308
+ let payload;
309
+ try {
310
+ payload = JSON.parse(textDecoder.decode(rawBody));
311
+ } catch {
312
+ log.warn("webhook.invalid_payload");
313
+ return c.text("Bad Request", 400);
314
+ }
315
+ if (c.executionCtx) c.executionCtx.waitUntil(processPayload(payload, c.env, config, log));
316
+ else await processPayload(payload, c.env, config, log);
317
+ return c.text("OK", 200);
318
+ } catch (error) {
319
+ log.error("webhook.request_error", (0, better_zap.serializeError)(error));
320
+ return c.text("Internal Server Error", 500);
321
+ }
322
+ });
323
+ return webhook;
324
+ }
325
+ /** Top-level dispatcher — iterates entries in the webhook payload. */
326
+ async function processPayload(payload, env, config, log) {
327
+ try {
328
+ if (payload.object !== "whatsapp_business_account") {
329
+ log.debug("webhook.ignored_payload", { object: payload.object });
330
+ return;
331
+ }
332
+ for (const entry of payload.entry) await processEntry(entry, env, config, log);
333
+ } catch (error) {
334
+ log.error("webhook.async_process_error", (0, better_zap.serializeError)(error));
335
+ }
336
+ }
337
+ /** Iterates changes within a single entry. */
338
+ async function processEntry(entry, env, config, log) {
339
+ for (const change of entry.changes) await processChange(change, env, config, log);
340
+ }
341
+ /** Routes messages, statuses, and errors to the appropriate handler. */
342
+ async function processChange(change, env, config, log) {
343
+ const value = change.value;
344
+ if (value.messages && value.messages.length > 0) for (const message of value.messages) await processIncomingMessage(message, resolveContact(value.contacts, message), config, log);
345
+ if (value.statuses && value.statuses.length > 0) for (const status of value.statuses) await processStatusUpdate(status, config, log);
346
+ if (value.errors && value.errors.length > 0) {
347
+ const errorHandler = config.onError ?? ((err) => {
348
+ log.error("webhook.meta_error", { error: err });
349
+ });
350
+ for (const error of value.errors) try {
351
+ errorHandler(error);
352
+ } catch (hookError) {
353
+ log.error("webhook.on_error_hook_failed", {
354
+ metaError: error,
355
+ hookError: (0, better_zap.serializeError)(hookError)
356
+ });
357
+ }
358
+ }
359
+ }
360
+ /**
361
+ * Processes a single incoming message:
362
+ * 1. Deduplicates by waMessageId
363
+ * 2. Extracts human-readable content
364
+ * 3. Logs the message for audit trail
365
+ * 4. Calls {@link WebhookConfig.onMessage}
366
+ */
367
+ async function processIncomingMessage(message, contact, config, log) {
368
+ const phone = message.from;
369
+ log.info("webhook.message_received", {
370
+ waMessageId: message.id,
371
+ phone,
372
+ messageType: message.type
373
+ });
374
+ if (await config.logger.isDuplicate(message.id)) {
375
+ log.info("webhook.duplicate_ignored", {
376
+ waMessageId: message.id,
377
+ phone
378
+ });
379
+ return;
380
+ }
381
+ const content = getMessageContent(message);
382
+ const { id, type, text, from, timestamp, ...rawMetadata } = message;
383
+ await config.logger.logIncoming({
384
+ phone,
385
+ waMessageId: message.id,
386
+ content,
387
+ senderName: contact?.profile?.name,
388
+ metadata: Object.keys(rawMetadata).length > 0 ? rawMetadata : void 0
389
+ });
390
+ const ctx = {
391
+ message,
392
+ contact,
393
+ content,
394
+ phone
395
+ };
396
+ try {
397
+ await config.onMessage(ctx);
398
+ } catch (error) {
399
+ log.error("webhook.on_message_hook_failed", {
400
+ waMessageId: message.id,
401
+ phone,
402
+ ...(0, better_zap.serializeError)(error)
403
+ });
404
+ }
405
+ }
406
+ /**
407
+ * Processes a single delivery status update:
408
+ * 1. Parses Unix timestamp to ISO-8601
409
+ * 2. Extracts first error (if any)
410
+ * 3. Atomically updates status only if it advances the lifecycle
411
+ * 4. Calls {@link WebhookConfig.onStatusUpdate} only if the update was applied
412
+ */
413
+ async function processStatusUpdate(status, config, log) {
414
+ const firstError = status.errors?.[0];
415
+ const timestamp = (/* @__PURE__ */ new Date(parseInt(status.timestamp) * 1e3)).toISOString();
416
+ const errorMessage = firstError?.message;
417
+ const errorCode = firstError?.code;
418
+ if (!await config.logger.updateStatus(status.id, status.status, timestamp, errorMessage)) return;
419
+ log.info("webhook.status_updated", {
420
+ waMessageId: status.id,
421
+ status: status.status
422
+ });
423
+ const ctx = {
424
+ status,
425
+ timestamp,
426
+ errorMessage,
427
+ errorCode
428
+ };
429
+ try {
430
+ await config.onStatusUpdate(ctx);
431
+ } catch (error) {
432
+ log.error("webhook.on_status_update_hook_failed", {
433
+ waMessageId: status.id,
434
+ ...(0, better_zap.serializeError)(error)
435
+ });
436
+ }
437
+ }
438
+ /** Matches a contact to a message by `wa_id`, falling back to the first contact. */
439
+ function resolveContact(contacts, message) {
440
+ if (!contacts || contacts.length === 0) return;
441
+ return contacts.find((c) => c.wa_id === message.from) ?? contacts[0];
442
+ }
443
+ //#endregion
444
+ //#region src/internal/cloudflare/constants.ts
445
+ const GLOBAL_WORKSPACE_DO_ID = "global-workspace";
446
+ //#endregion
447
+ //#region src/internal/cloudflare/conversation-sync.ts
448
+ function createConversationSyncNotifier(conversationSync) {
449
+ if (!conversationSync) return;
450
+ return { async notify(event) {
451
+ const id = conversationSync.idFromName(GLOBAL_WORKSPACE_DO_ID);
452
+ await conversationSync.get(id).fetch(new Request("http://do/sync", {
453
+ method: "POST",
454
+ body: JSON.stringify(event)
455
+ }));
456
+ } };
457
+ }
458
+ //#endregion
459
+ //#region src/better-zap.ts
460
+ function serializeRuntimeTemplate(templates, templateName, options) {
461
+ return (0, better_zap.serializeTemplateFromRegistry)(templates, templateName, {
462
+ language: options.language,
463
+ params: options.params ?? {}
464
+ });
465
+ }
466
+ function betterZap(options) {
467
+ const { database, config, webhook: webhookHooks, conversationSync, basePath = "/api/whatsapp" } = options;
468
+ const templates = options.templates ?? better_zap.EMPTY_TEMPLATE_REGISTRY;
469
+ const log = (0, better_zap.createLogger)(options.logger);
470
+ const logger = new better_zap.MessageLoggerService(database.whatsappLog, log, createConversationSyncNotifier(conversationSync));
471
+ const whatsapp = new better_zap.WhatsAppService(config, logger, log);
472
+ const coreContext = {
473
+ db: database,
474
+ api: whatsapp,
475
+ logger
476
+ };
477
+ const coreServices = {
478
+ whatsapp,
479
+ logger
480
+ };
481
+ const plugins = options.plugins ?? [];
482
+ const pluginRuntime = initializePlugins({
483
+ plugins,
484
+ database,
485
+ config,
486
+ coreContext,
487
+ coreServices,
488
+ log
489
+ });
490
+ const webhookRouter = createWebhookHandler({
491
+ verifyToken: config.webhookToken,
492
+ appSecret: config.appSecret,
493
+ logger,
494
+ log,
495
+ onMessage: async (ctx) => {
496
+ const hookContext = {
497
+ ...ctx,
498
+ ...pluginRuntime.context
499
+ };
500
+ await runPluginMessageHooks({
501
+ plugins,
502
+ ctx: hookContext,
503
+ log
504
+ });
505
+ await webhookHooks.onMessage(hookContext);
506
+ },
507
+ onStatusUpdate: async (ctx) => {
508
+ const hookContext = {
509
+ ...ctx,
510
+ ...pluginRuntime.context
511
+ };
512
+ await runPluginStatusHooks({
513
+ plugins,
514
+ ctx: hookContext,
515
+ log
516
+ });
517
+ await webhookHooks.onStatusUpdate(hookContext);
518
+ }
519
+ });
520
+ const app = new hono.Hono().basePath(basePath);
521
+ app.use("*", async (c, next) => {
522
+ c.set("whatsapp", whatsapp);
523
+ c.set("store", database.whatsappLog);
524
+ c.set("logger", log);
525
+ await next();
526
+ });
527
+ app.route("/webhook", webhookRouter);
528
+ app.post("/send/text", handleSendText);
529
+ app.post("/send/template", createSendTemplateHandler(templates));
530
+ app.post("/send/interactive", handleSendInteractive);
531
+ app.post("/send/location", handleSendLocation);
532
+ app.get("/conversations", handleListConversations);
533
+ app.get("/conversations/:phone", handleGetConversation);
534
+ app.get("/conversations/:phone/messages", handleGetMessages);
535
+ const api = {
536
+ send: {
537
+ text: (to, body, opts) => whatsapp.sendText(to, body, opts),
538
+ template: ((to, templateName, opts = {}) => {
539
+ if (!(0, better_zap.hasConfiguredTemplates)(templates)) return whatsapp.sendTemplate(to, String(templateName), opts?.language, opts?.components, opts?.logging);
540
+ const serializedTemplate = serializeRuntimeTemplate(templates, templateName, opts);
541
+ return whatsapp.sendTemplate(to, String(templateName), serializedTemplate.language, serializedTemplate.components, opts.logging);
542
+ }),
543
+ templateRaw: (to, templateName, opts) => whatsapp.sendTemplate(to, templateName, opts?.language, opts?.components, opts?.logging),
544
+ interactiveButtons: (to, body, buttons, opts) => whatsapp.sendInteractiveButtons(to, body, buttons, opts),
545
+ interactiveList: (to, body, buttonLabel, sections, opts) => whatsapp.sendInteractiveList(to, body, buttonLabel, sections, opts),
546
+ interactiveMediaCarousel: (data, opts) => whatsapp.sendInteractiveMediaCarousel(data, opts),
547
+ location: (to, location, opts) => whatsapp.sendLocation(to, location.latitude, location.longitude, location.name, location.address, opts),
548
+ markAsRead: (messageId) => whatsapp.markAsRead(messageId),
549
+ reaction: (to, messageId, emoji) => whatsapp.sendReaction(to, messageId, emoji)
550
+ },
551
+ conversations: {
552
+ list: () => database.whatsappLog.getConversations(),
553
+ get: (phone) => database.whatsappLog.getConversationByPhone((0, better_zap.formatPhone)(phone)),
554
+ messages: async (phone, opts) => {
555
+ const conversation = await database.whatsappLog.getConversationByPhone((0, better_zap.formatPhone)(phone));
556
+ if (!conversation) return [];
557
+ return await database.whatsappLog.getMessagesByConversationPaginated(conversation.id, opts?.cursor, opts?.limit);
558
+ }
559
+ }
560
+ };
561
+ const handler = async (request, env, executionCtx) => app.fetch(request, env, executionCtx);
562
+ return {
563
+ handler,
564
+ api,
565
+ services: pluginRuntime.services
566
+ };
567
+ }
568
+ //#endregion
569
+ exports.betterZap = betterZap;
570
+ exports.createWebhookHandler = createWebhookHandler;
571
+ exports.getMessageContent = getMessageContent;
572
+ exports.verifyMetaWebhookSignature = verifyMetaWebhookSignature;
@@ -0,0 +1,88 @@
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 };
@@ -0,0 +1,88 @@
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 ADDED
@@ -0,0 +1,568 @@
1
+ import { Hono } from "hono";
2
+ import { EMPTY_TEMPLATE_REGISTRY, MessageLoggerService, WhatsAppService, createLogger, formatPhone, hasConfiguredTemplates, 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(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(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
+ async function handleSendText(c) {
118
+ const { to, body, messageType, userId, metadata } = await c.req.json();
119
+ if (!to || !body) return c.json({ error: "to and body are required" }, 400);
120
+ const whatsapp = c.get("whatsapp");
121
+ const logging = messageType ? {
122
+ messageType,
123
+ userId,
124
+ metadata
125
+ } : void 0;
126
+ const result = await whatsapp.sendText(to, body, logging);
127
+ return c.json(result, result.success ? 200 : 500);
128
+ }
129
+ function createSendTemplateHandler(templates) {
130
+ return async function handleSendTemplate(c) {
131
+ const body = await c.req.json();
132
+ if (!body.to || !body.template) return c.json({ error: "to and template are required" }, 400);
133
+ const whatsapp = c.get("whatsapp");
134
+ const logging = body.logging ?? (body.messageType ? {
135
+ messageType: body.messageType,
136
+ content: body.content || `[template: ${body.template}]`,
137
+ userId: body.userId,
138
+ metadata: body.metadata
139
+ } : void 0);
140
+ let language = body.language;
141
+ let components = body.components;
142
+ if ("params" in body && body.params !== void 0) {
143
+ if (!hasConfiguredTemplates(templates)) return c.json({ error: "Typed template params require a configured template registry" }, 400);
144
+ try {
145
+ const serializedTemplate = serializeTemplateFromRegistry(templates, body.template, {
146
+ language: body.language,
147
+ params: body.params
148
+ });
149
+ language = serializedTemplate.language;
150
+ components = serializedTemplate.components;
151
+ } catch (error) {
152
+ const message = error instanceof Error ? error.message : "Failed to serialize template from registry";
153
+ return c.json({ error: message }, 400);
154
+ }
155
+ }
156
+ const result = await whatsapp.sendTemplate(body.to, body.template, language, components, logging);
157
+ return c.json(result, result.success ? 200 : 500);
158
+ };
159
+ }
160
+ async function handleSendInteractive(c) {
161
+ const { to, type, body, buttons, buttonLabel, sections, cards, messageType, userId, metadata } = await c.req.json();
162
+ if (!to || !body) return c.json({ error: "to and body are required" }, 400);
163
+ const whatsapp = c.get("whatsapp");
164
+ const logging = messageType ? {
165
+ messageType,
166
+ userId,
167
+ metadata
168
+ } : void 0;
169
+ if (type === "list") {
170
+ if (!buttonLabel || !sections) return c.json({ error: "buttonLabel and sections are required for list type" }, 400);
171
+ const result = await whatsapp.sendInteractiveList(to, body, buttonLabel, sections, logging);
172
+ return c.json(result, result.success ? 200 : 500);
173
+ }
174
+ if (type === "carousel") {
175
+ if (!cards) return c.json({ error: "cards are required for carousel type" }, 400);
176
+ if (cards.length < 2 || cards.length > 10) return c.json({ error: "carousel requires between 2 and 10 cards" }, 400);
177
+ const result = await whatsapp.sendInteractiveMediaCarousel({
178
+ to,
179
+ body,
180
+ cards
181
+ }, logging);
182
+ return c.json(result, result.success ? 200 : 500);
183
+ }
184
+ if (!buttons) return c.json({ error: "buttons are required for button type" }, 400);
185
+ const result = await whatsapp.sendInteractiveButtons(to, body, buttons, logging);
186
+ return c.json(result, result.success ? 200 : 500);
187
+ }
188
+ async function handleSendLocation(c) {
189
+ const { to, latitude, longitude, name, address, messageType, userId, metadata } = await c.req.json();
190
+ if (!to || latitude == null || longitude == null || !name || !address) return c.json({ error: "to, latitude, longitude, name, and address are required" }, 400);
191
+ const whatsapp = c.get("whatsapp");
192
+ const logging = messageType ? {
193
+ messageType,
194
+ userId,
195
+ metadata
196
+ } : void 0;
197
+ const result = await whatsapp.sendLocation(to, latitude, longitude, name, address, logging);
198
+ return c.json(result, result.success ? 200 : 500);
199
+ }
200
+ //#endregion
201
+ //#region src/webhook/signature-verification.ts
202
+ const textEncoder = new TextEncoder();
203
+ let cachedMetaAppSecret = null;
204
+ let cachedMetaHmacKey = null;
205
+ async function verifyMetaWebhookSignature({ rawBody, signatureHeader, appSecret }) {
206
+ if (!signatureHeader) return false;
207
+ const [algorithm, signatureHexRaw] = signatureHeader.split("=", 2);
208
+ if (algorithm?.toLowerCase() !== "sha256" || !signatureHexRaw) return false;
209
+ const signatureBytes = hexToBytes(signatureHexRaw.trim());
210
+ if (!signatureBytes) return false;
211
+ const key = await getMetaHmacKey(appSecret);
212
+ const expectedSignatureBuffer = await crypto.subtle.sign("HMAC", key, rawBody);
213
+ return constantTimeEqual(new Uint8Array(expectedSignatureBuffer), signatureBytes);
214
+ }
215
+ function getMetaHmacKey(appSecret) {
216
+ if (cachedMetaAppSecret === appSecret && cachedMetaHmacKey) return cachedMetaHmacKey;
217
+ cachedMetaAppSecret = appSecret;
218
+ cachedMetaHmacKey = crypto.subtle.importKey("raw", textEncoder.encode(appSecret), {
219
+ name: "HMAC",
220
+ hash: "SHA-256"
221
+ }, false, ["sign"]);
222
+ return cachedMetaHmacKey;
223
+ }
224
+ function hexToBytes(hex) {
225
+ if (hex.length % 2 !== 0) return null;
226
+ const bytes = new Uint8Array(hex.length / 2);
227
+ for (let i = 0; i < bytes.length; i += 1) {
228
+ const value = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
229
+ if (Number.isNaN(value)) return null;
230
+ bytes[i] = value;
231
+ }
232
+ return bytes;
233
+ }
234
+ function constantTimeEqual(a, b) {
235
+ if (a.length !== b.length) return false;
236
+ let diff = 0;
237
+ for (let i = 0; i < a.length; i += 1) diff |= a[i] ^ b[i];
238
+ return diff === 0;
239
+ }
240
+ //#endregion
241
+ //#region src/webhook/message-content.ts
242
+ /**
243
+ * Extract human-readable content from incoming messages for audit logs.
244
+ */
245
+ function getMessageContent(message) {
246
+ switch (message.type) {
247
+ case "text": return message.text?.body || "[texto vazio]";
248
+ case "image": return `[imagem${message.image?.caption ? `: ${message.image.caption}` : ""}]`;
249
+ case "audio": return "[áudio]";
250
+ case "video": return `[vídeo${message.video?.caption ? `: ${message.video.caption}` : ""}]`;
251
+ case "document": return `[documento: ${message.document?.filename || "arquivo"}]`;
252
+ case "location": return `[localização: ${message.location?.name || `${message.location?.latitude},${message.location?.longitude}`}]`;
253
+ case "button": return `[botão: ${message.button?.text}]`;
254
+ case "interactive":
255
+ if (message.interactive?.button_reply) return `[resposta botão: ${message.interactive.button_reply.title}]`;
256
+ if (message.interactive?.list_reply) return `[resposta lista: ${message.interactive.list_reply.title}]`;
257
+ return "[interativo]";
258
+ case "sticker": return "[figurinha]";
259
+ case "reaction": return "[reação]";
260
+ default: return `[${message.type}]`;
261
+ }
262
+ }
263
+ //#endregion
264
+ //#region src/webhook/create-webhook-handler.ts
265
+ const textDecoder = new TextDecoder();
266
+ /**
267
+ * Creates a Hono router that handles the full WhatsApp webhook lifecycle.
268
+ *
269
+ * **SDK guarantees (non-hookable):**
270
+ * - Signature is always verified before any processing
271
+ * - Meta always receives a fast 200 OK (processing runs via `waitUntil`)
272
+ * - Hook errors never crash the webhook (wrapped in try/catch)
273
+ * - Contact is resolved and content is extracted before `onMessage`
274
+ * - Status timestamp is parsed to ISO before `onStatusUpdate`
275
+ *
276
+ * @typeParam Env - Hono bindings type (e.g. Cloudflare Worker env).
277
+ */
278
+ function createWebhookHandler(config) {
279
+ const log = config.log;
280
+ const webhook = new Hono();
281
+ webhook.get("/", (c) => {
282
+ const mode = c.req.query("hub.mode");
283
+ const token = c.req.query("hub.verify_token");
284
+ const challenge = c.req.query("hub.challenge");
285
+ if (mode === "subscribe" && token === config.verifyToken) {
286
+ log.info("webhook.verification_successful");
287
+ return c.text(challenge || "", 200);
288
+ }
289
+ log.warn("webhook.verification_failed");
290
+ return c.text("Forbidden", 403);
291
+ });
292
+ webhook.post("/", async (c) => {
293
+ try {
294
+ if (!config.appSecret) {
295
+ log.error("webhook.missing_app_secret");
296
+ return c.text("Server Misconfigured", 500);
297
+ }
298
+ const rawBody = await c.req.raw.arrayBuffer();
299
+ if (!await verifyMetaWebhookSignature({
300
+ rawBody,
301
+ signatureHeader: c.req.header("x-hub-signature-256"),
302
+ appSecret: config.appSecret
303
+ })) {
304
+ log.warn("webhook.invalid_signature");
305
+ return c.text("Unauthorized", 401);
306
+ }
307
+ let payload;
308
+ try {
309
+ payload = JSON.parse(textDecoder.decode(rawBody));
310
+ } catch {
311
+ log.warn("webhook.invalid_payload");
312
+ return c.text("Bad Request", 400);
313
+ }
314
+ if (c.executionCtx) c.executionCtx.waitUntil(processPayload(payload, c.env, config, log));
315
+ else await processPayload(payload, c.env, config, log);
316
+ return c.text("OK", 200);
317
+ } catch (error) {
318
+ log.error("webhook.request_error", serializeError(error));
319
+ return c.text("Internal Server Error", 500);
320
+ }
321
+ });
322
+ return webhook;
323
+ }
324
+ /** Top-level dispatcher — iterates entries in the webhook payload. */
325
+ async function processPayload(payload, env, config, log) {
326
+ try {
327
+ if (payload.object !== "whatsapp_business_account") {
328
+ log.debug("webhook.ignored_payload", { object: payload.object });
329
+ return;
330
+ }
331
+ for (const entry of payload.entry) await processEntry(entry, env, config, log);
332
+ } catch (error) {
333
+ log.error("webhook.async_process_error", serializeError(error));
334
+ }
335
+ }
336
+ /** Iterates changes within a single entry. */
337
+ async function processEntry(entry, env, config, log) {
338
+ for (const change of entry.changes) await processChange(change, env, config, log);
339
+ }
340
+ /** Routes messages, statuses, and errors to the appropriate handler. */
341
+ async function processChange(change, env, config, log) {
342
+ const value = change.value;
343
+ if (value.messages && value.messages.length > 0) for (const message of value.messages) await processIncomingMessage(message, resolveContact(value.contacts, message), config, log);
344
+ if (value.statuses && value.statuses.length > 0) for (const status of value.statuses) await processStatusUpdate(status, config, log);
345
+ if (value.errors && value.errors.length > 0) {
346
+ const errorHandler = config.onError ?? ((err) => {
347
+ log.error("webhook.meta_error", { error: err });
348
+ });
349
+ for (const error of value.errors) try {
350
+ errorHandler(error);
351
+ } catch (hookError) {
352
+ log.error("webhook.on_error_hook_failed", {
353
+ metaError: error,
354
+ hookError: serializeError(hookError)
355
+ });
356
+ }
357
+ }
358
+ }
359
+ /**
360
+ * Processes a single incoming message:
361
+ * 1. Deduplicates by waMessageId
362
+ * 2. Extracts human-readable content
363
+ * 3. Logs the message for audit trail
364
+ * 4. Calls {@link WebhookConfig.onMessage}
365
+ */
366
+ async function processIncomingMessage(message, contact, config, log) {
367
+ const phone = message.from;
368
+ log.info("webhook.message_received", {
369
+ waMessageId: message.id,
370
+ phone,
371
+ messageType: message.type
372
+ });
373
+ if (await config.logger.isDuplicate(message.id)) {
374
+ log.info("webhook.duplicate_ignored", {
375
+ waMessageId: message.id,
376
+ phone
377
+ });
378
+ return;
379
+ }
380
+ const content = getMessageContent(message);
381
+ const { id, type, text, from, timestamp, ...rawMetadata } = message;
382
+ await config.logger.logIncoming({
383
+ phone,
384
+ waMessageId: message.id,
385
+ content,
386
+ senderName: contact?.profile?.name,
387
+ metadata: Object.keys(rawMetadata).length > 0 ? rawMetadata : void 0
388
+ });
389
+ const ctx = {
390
+ message,
391
+ contact,
392
+ content,
393
+ phone
394
+ };
395
+ try {
396
+ await config.onMessage(ctx);
397
+ } catch (error) {
398
+ log.error("webhook.on_message_hook_failed", {
399
+ waMessageId: message.id,
400
+ phone,
401
+ ...serializeError(error)
402
+ });
403
+ }
404
+ }
405
+ /**
406
+ * Processes a single delivery status update:
407
+ * 1. Parses Unix timestamp to ISO-8601
408
+ * 2. Extracts first error (if any)
409
+ * 3. Atomically updates status only if it advances the lifecycle
410
+ * 4. Calls {@link WebhookConfig.onStatusUpdate} only if the update was applied
411
+ */
412
+ async function processStatusUpdate(status, config, log) {
413
+ const firstError = status.errors?.[0];
414
+ const timestamp = (/* @__PURE__ */ new Date(parseInt(status.timestamp) * 1e3)).toISOString();
415
+ const errorMessage = firstError?.message;
416
+ const errorCode = firstError?.code;
417
+ if (!await config.logger.updateStatus(status.id, status.status, timestamp, errorMessage)) return;
418
+ log.info("webhook.status_updated", {
419
+ waMessageId: status.id,
420
+ status: status.status
421
+ });
422
+ const ctx = {
423
+ status,
424
+ timestamp,
425
+ errorMessage,
426
+ errorCode
427
+ };
428
+ try {
429
+ await config.onStatusUpdate(ctx);
430
+ } catch (error) {
431
+ log.error("webhook.on_status_update_hook_failed", {
432
+ waMessageId: status.id,
433
+ ...serializeError(error)
434
+ });
435
+ }
436
+ }
437
+ /** Matches a contact to a message by `wa_id`, falling back to the first contact. */
438
+ function resolveContact(contacts, message) {
439
+ if (!contacts || contacts.length === 0) return;
440
+ return contacts.find((c) => c.wa_id === message.from) ?? contacts[0];
441
+ }
442
+ //#endregion
443
+ //#region src/internal/cloudflare/constants.ts
444
+ const GLOBAL_WORKSPACE_DO_ID = "global-workspace";
445
+ //#endregion
446
+ //#region src/internal/cloudflare/conversation-sync.ts
447
+ function createConversationSyncNotifier(conversationSync) {
448
+ if (!conversationSync) return;
449
+ return { async notify(event) {
450
+ const id = conversationSync.idFromName(GLOBAL_WORKSPACE_DO_ID);
451
+ await conversationSync.get(id).fetch(new Request("http://do/sync", {
452
+ method: "POST",
453
+ body: JSON.stringify(event)
454
+ }));
455
+ } };
456
+ }
457
+ //#endregion
458
+ //#region src/better-zap.ts
459
+ function serializeRuntimeTemplate(templates, templateName, options) {
460
+ return serializeTemplateFromRegistry(templates, templateName, {
461
+ language: options.language,
462
+ params: options.params ?? {}
463
+ });
464
+ }
465
+ function betterZap(options) {
466
+ const { database, config, webhook: webhookHooks, conversationSync, basePath = "/api/whatsapp" } = options;
467
+ const templates = options.templates ?? EMPTY_TEMPLATE_REGISTRY;
468
+ const log = createLogger(options.logger);
469
+ const logger = new MessageLoggerService(database.whatsappLog, log, createConversationSyncNotifier(conversationSync));
470
+ const whatsapp = new WhatsAppService(config, logger, log);
471
+ const coreContext = {
472
+ db: database,
473
+ api: whatsapp,
474
+ logger
475
+ };
476
+ const coreServices = {
477
+ whatsapp,
478
+ logger
479
+ };
480
+ const plugins = options.plugins ?? [];
481
+ const pluginRuntime = initializePlugins({
482
+ plugins,
483
+ database,
484
+ config,
485
+ coreContext,
486
+ coreServices,
487
+ log
488
+ });
489
+ const webhookRouter = createWebhookHandler({
490
+ verifyToken: config.webhookToken,
491
+ appSecret: config.appSecret,
492
+ logger,
493
+ log,
494
+ onMessage: async (ctx) => {
495
+ const hookContext = {
496
+ ...ctx,
497
+ ...pluginRuntime.context
498
+ };
499
+ await runPluginMessageHooks({
500
+ plugins,
501
+ ctx: hookContext,
502
+ log
503
+ });
504
+ await webhookHooks.onMessage(hookContext);
505
+ },
506
+ onStatusUpdate: async (ctx) => {
507
+ const hookContext = {
508
+ ...ctx,
509
+ ...pluginRuntime.context
510
+ };
511
+ await runPluginStatusHooks({
512
+ plugins,
513
+ ctx: hookContext,
514
+ log
515
+ });
516
+ await webhookHooks.onStatusUpdate(hookContext);
517
+ }
518
+ });
519
+ const app = new Hono().basePath(basePath);
520
+ app.use("*", async (c, next) => {
521
+ c.set("whatsapp", whatsapp);
522
+ c.set("store", database.whatsappLog);
523
+ c.set("logger", log);
524
+ await next();
525
+ });
526
+ app.route("/webhook", webhookRouter);
527
+ app.post("/send/text", handleSendText);
528
+ app.post("/send/template", createSendTemplateHandler(templates));
529
+ app.post("/send/interactive", handleSendInteractive);
530
+ app.post("/send/location", handleSendLocation);
531
+ app.get("/conversations", handleListConversations);
532
+ app.get("/conversations/:phone", handleGetConversation);
533
+ app.get("/conversations/:phone/messages", handleGetMessages);
534
+ const api = {
535
+ send: {
536
+ text: (to, body, opts) => whatsapp.sendText(to, body, opts),
537
+ template: ((to, templateName, opts = {}) => {
538
+ if (!hasConfiguredTemplates(templates)) return whatsapp.sendTemplate(to, String(templateName), opts?.language, opts?.components, opts?.logging);
539
+ const serializedTemplate = serializeRuntimeTemplate(templates, templateName, opts);
540
+ return whatsapp.sendTemplate(to, String(templateName), serializedTemplate.language, serializedTemplate.components, opts.logging);
541
+ }),
542
+ templateRaw: (to, templateName, opts) => whatsapp.sendTemplate(to, templateName, opts?.language, opts?.components, opts?.logging),
543
+ interactiveButtons: (to, body, buttons, opts) => whatsapp.sendInteractiveButtons(to, body, buttons, opts),
544
+ interactiveList: (to, body, buttonLabel, sections, opts) => whatsapp.sendInteractiveList(to, body, buttonLabel, sections, opts),
545
+ interactiveMediaCarousel: (data, opts) => whatsapp.sendInteractiveMediaCarousel(data, opts),
546
+ location: (to, location, opts) => whatsapp.sendLocation(to, location.latitude, location.longitude, location.name, location.address, opts),
547
+ markAsRead: (messageId) => whatsapp.markAsRead(messageId),
548
+ reaction: (to, messageId, emoji) => whatsapp.sendReaction(to, messageId, emoji)
549
+ },
550
+ conversations: {
551
+ list: () => database.whatsappLog.getConversations(),
552
+ get: (phone) => database.whatsappLog.getConversationByPhone(formatPhone(phone)),
553
+ messages: async (phone, opts) => {
554
+ const conversation = await database.whatsappLog.getConversationByPhone(formatPhone(phone));
555
+ if (!conversation) return [];
556
+ return await database.whatsappLog.getMessagesByConversationPaginated(conversation.id, opts?.cursor, opts?.limit);
557
+ }
558
+ }
559
+ };
560
+ const handler = async (request, env, executionCtx) => app.fetch(request, env, executionCtx);
561
+ return {
562
+ handler,
563
+ api,
564
+ services: pluginRuntime.services
565
+ };
566
+ }
567
+ //#endregion
568
+ export { betterZap, createWebhookHandler, getMessageContent, verifyMetaWebhookSignature };
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@better-zap/hono",
3
+ "version": "0.0.1",
4
+ "description": "Hono adapter and webhook runtime for Better Zap.",
5
+ "license": "ISC",
6
+ "type": "module",
7
+ "main": "./dist/index.cjs",
8
+ "module": "./dist/index.mjs",
9
+ "types": "./dist/index.d.mts",
10
+ "files": [
11
+ "dist",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.mts",
18
+ "import": "./dist/index.mjs",
19
+ "require": "./dist/index.cjs"
20
+ },
21
+ "./package.json": "./package.json"
22
+ },
23
+ "engines": {
24
+ "node": ">=20"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "dependencies": {
30
+ "hono": "4.12.5",
31
+ "better-zap": "0.0.1"
32
+ },
33
+ "peerDependencies": {
34
+ "hono": "4.12.5"
35
+ },
36
+ "devDependencies": {
37
+ "@cloudflare/workers-types": "^4.20240117.0",
38
+ "@types/node": "^25.4.0",
39
+ "tsdown": "^0.21.2",
40
+ "typescript": "^5.9.3",
41
+ "vitest": "^4.1.0"
42
+ },
43
+ "scripts": {
44
+ "build": "tsdown",
45
+ "typecheck": "tsc --noEmit",
46
+ "test": "vitest run"
47
+ }
48
+ }