@better-zap/hono 0.2.0 → 0.2.2

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/dist/index.cjs ADDED
@@ -0,0 +1,1458 @@
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/coexistence.ts
75
+ const ROUTES_NOT_CONFIGURED = "Coexistence routes are not configured";
76
+ const STORAGE_NOT_CONFIGURED = "Coexistence storage is not configured";
77
+ const SYNC_DEADLINE_MS = 1440 * 60 * 1e3;
78
+ const PREFLIGHT_FAILURE_FIELDS = [
79
+ ["unsupportedCountry", "unsupported_country"],
80
+ ["unsupportedAppVersion", "unsupported_app_version"],
81
+ ["lowActivityNumber", "low_activity_number"],
82
+ ["priorProviderWabaRegistration", "prior_provider_waba_registration"],
83
+ ["missingPaymentSetup", "missing_payment_setup"]
84
+ ];
85
+ function getCoexistence(c) {
86
+ return c.get("coexistence");
87
+ }
88
+ function getCoexistenceStore(c) {
89
+ return c.get("coexistenceStore");
90
+ }
91
+ function shouldSubscribeWabaAfterCodeExchange(c) {
92
+ return c.get("subscribeWabaAfterCodeExchange") !== false;
93
+ }
94
+ function graphResultResponse(c, result) {
95
+ if (result.success) return c.json(result.data ?? { success: true });
96
+ return c.json({
97
+ success: false,
98
+ error: result.error ?? "Meta Graph request failed",
99
+ code: "meta_graph_request_failed",
100
+ ...result.errorCode ? { errorCode: result.errorCode } : {},
101
+ ...result.details ? { details: result.details } : {}
102
+ }, result.httpStatus ?? 502);
103
+ }
104
+ function isSessionPayload(value) {
105
+ return typeof value === "object" && value !== null && "event" in value && typeof value.event === "string";
106
+ }
107
+ function resolveSessionPayload(body) {
108
+ if (isSessionPayload(body.session)) return body.session;
109
+ if (isSessionPayload(body.sessionInfo)) return body.sessionInfo;
110
+ if (isSessionPayload(body)) return body;
111
+ if (typeof body.data === "object" && body.data !== null) return {
112
+ event: "FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING",
113
+ data: body.data
114
+ };
115
+ return null;
116
+ }
117
+ function resolveCode(body, session) {
118
+ if (typeof body.code === "string" && body.code.length > 0) return body.code;
119
+ if (typeof session?.data?.code === "string" && session.data.code.length > 0) return session.data.code;
120
+ return null;
121
+ }
122
+ function createRecordId(prefix) {
123
+ return `${prefix}_${crypto.randomUUID()}`;
124
+ }
125
+ function optionalString(value) {
126
+ return typeof value === "string" && value.length > 0 ? value : void 0;
127
+ }
128
+ function optionalNumber(value) {
129
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
130
+ }
131
+ function resolveIdempotencyKey(c, body) {
132
+ return optionalString(body.idempotencyKey) ?? optionalString(c.req.header("Idempotency-Key")) ?? optionalString(c.req.header("X-Idempotency-Key"));
133
+ }
134
+ function validateStateAndNonce(body, session) {
135
+ const state = optionalString(body.state);
136
+ const expectedState = optionalString(body.expectedState) ?? optionalString(session.data?.state);
137
+ if (expectedState && state !== expectedState) return "state mismatch";
138
+ const nonce = optionalString(body.nonce);
139
+ const expectedNonce = optionalString(body.expectedNonce) ?? optionalString(session.data?.nonce);
140
+ if (expectedNonce && nonce !== expectedNonce) return "nonce mismatch";
141
+ return null;
142
+ }
143
+ async function recordOnboardingSession(c, input) {
144
+ await getCoexistenceStore(c)?.recordOnboardingSession({
145
+ id: input.idempotencyKey ?? createRecordId("coexistence_session"),
146
+ event: input.session.event,
147
+ accountId: input.session.data?.business_id,
148
+ wabaId: input.session.data?.waba_id,
149
+ phoneNumberId: input.session.data?.phone_number_id,
150
+ payload: {
151
+ ...input.session,
152
+ data: {
153
+ ...input.session.data,
154
+ ...input.state ? { state: input.state } : {},
155
+ ...input.nonce ? { nonce: input.nonce } : {},
156
+ ...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}
157
+ }
158
+ },
159
+ ...input.preflight ? { preflight: input.preflight } : {}
160
+ });
161
+ }
162
+ function isObject(value) {
163
+ return typeof value === "object" && value !== null;
164
+ }
165
+ function boolField(value, key) {
166
+ return typeof value[key] === "boolean" ? value[key] : void 0;
167
+ }
168
+ function stringField(value, key) {
169
+ return typeof value[key] === "string" ? value[key] : void 0;
170
+ }
171
+ function failureCodesField(value) {
172
+ if (!Array.isArray(value?.failureCodes)) return;
173
+ return value.failureCodes.filter((code) => typeof code === "string");
174
+ }
175
+ function resolvePreflightState(body, session) {
176
+ const source = isObject(body.preflight) ? body.preflight : isObject(body.eligibility) ? body.eligibility : void 0;
177
+ const billing = isObject(body.billing) ? body.billing : void 0;
178
+ if (!source && !billing) return;
179
+ const state = {
180
+ phoneNumberId: stringField(source ?? {}, "phoneNumberId") ?? session?.data?.phone_number_id,
181
+ wabaId: stringField(source ?? {}, "wabaId") ?? session?.data?.waba_id,
182
+ displayPhoneNumber: stringField(source ?? {}, "displayPhoneNumber") ?? session?.data?.display_phone_number,
183
+ eligibilityStatus: stringField(source ?? {}, "eligibilityStatus"),
184
+ billingStatus: stringField(source ?? {}, "billingStatus") ?? stringField(billing ?? {}, "status"),
185
+ unsupportedCountry: boolField(source ?? {}, "unsupportedCountry"),
186
+ unsupportedAppVersion: boolField(source ?? {}, "unsupportedAppVersion"),
187
+ lowActivityNumber: boolField(source ?? {}, "lowActivityNumber"),
188
+ priorProviderWabaRegistration: boolField(source ?? {}, "priorProviderWabaRegistration"),
189
+ missingPaymentSetup: boolField(source ?? {}, "missingPaymentSetup") ?? boolField(billing ?? {}, "missingPaymentSetup") ?? (stringField(billing ?? {}, "status") === "missing_payment_setup" ? true : void 0),
190
+ failureCodes: failureCodesField(source),
191
+ metadata: {
192
+ ...isObject(source?.metadata) ? { eligibility: source.metadata } : {},
193
+ ...isObject(billing) ? { billing } : {}
194
+ }
195
+ };
196
+ state.failureCodes = getPreflightFailureCodes(state);
197
+ return state;
198
+ }
199
+ function getPreflightFailureCodes(state) {
200
+ const explicit = Array.isArray(state.failureCodes) ? state.failureCodes.filter((code) => typeof code === "string") : [];
201
+ const derived = PREFLIGHT_FAILURE_FIELDS.flatMap(([field, code]) => state[field] ? [code] : []);
202
+ return [...new Set([...explicit, ...derived])];
203
+ }
204
+ function preflightFailureResponse(c, failureCodes) {
205
+ return c.json({
206
+ success: false,
207
+ error: "Coexistence preflight failed",
208
+ code: "coexistence_preflight_failed",
209
+ failureCodes
210
+ }, 422);
211
+ }
212
+ async function handleEmbeddedSignupCallback(c) {
213
+ const coexistence = getCoexistence(c);
214
+ if (!coexistence) return c.json({ error: ROUTES_NOT_CONFIGURED }, 501);
215
+ const coexistenceStore = getCoexistenceStore(c);
216
+ if (!coexistenceStore) return c.json({ error: STORAGE_NOT_CONFIGURED }, 501);
217
+ try {
218
+ const body = await c.req.json();
219
+ const session = resolveSessionPayload(body);
220
+ if (!session) return c.json({ error: "session is required" }, 400);
221
+ const stateError = validateStateAndNonce(body, session);
222
+ if (stateError) return c.json({ error: stateError }, 400);
223
+ const normalizedEvent = (0, better_zap.normalizeCoexistenceSessionEvent)(session.event);
224
+ const code = resolveCode(body, session);
225
+ const idempotencyKey = resolveIdempotencyKey(c, body);
226
+ const state = optionalString(body.state);
227
+ const nonce = optionalString(body.nonce);
228
+ const preflight = resolvePreflightState(body, session);
229
+ if (idempotencyKey && coexistenceStore.getRawEventStatus) {
230
+ const existing = await coexistenceStore.getRawEventStatus(idempotencyKey);
231
+ if (existing?.status === "processed") return c.json({
232
+ success: true,
233
+ status: "duplicate",
234
+ idempotencyKey,
235
+ result: existing.result ?? null
236
+ });
237
+ }
238
+ await recordOnboardingSession(c, {
239
+ session,
240
+ idempotencyKey,
241
+ state,
242
+ nonce,
243
+ ...preflight ? { preflight } : {}
244
+ });
245
+ if (preflight) {
246
+ await coexistenceStore.upsertPreflightState?.(preflight);
247
+ const failureCodes = getPreflightFailureCodes(preflight);
248
+ if (failureCodes.length > 0) return preflightFailureResponse(c, failureCodes);
249
+ }
250
+ if (normalizedEvent !== "FINISH") return c.json({
251
+ success: true,
252
+ status: "recorded",
253
+ event: normalizedEvent,
254
+ session
255
+ });
256
+ if (!code) return c.json({ error: "code is required" }, 400);
257
+ const tokenExchange = await coexistence.exchangeEmbeddedSignupCode({
258
+ code,
259
+ redirectUri: typeof body.redirectUri === "string" ? body.redirectUri : void 0
260
+ });
261
+ if (!tokenExchange.success) {
262
+ if (idempotencyKey) await coexistenceStore.updateRawEventStatus?.({
263
+ id: idempotencyKey,
264
+ status: "failed",
265
+ error: tokenExchange.error ?? "Meta Graph request failed"
266
+ });
267
+ return graphResultResponse(c, tokenExchange);
268
+ }
269
+ if (!tokenExchange.data) {
270
+ if (idempotencyKey) await coexistenceStore.updateRawEventStatus?.({
271
+ id: idempotencyKey,
272
+ status: "failed",
273
+ error: "Token exchange returned no credential data"
274
+ });
275
+ return c.json({
276
+ success: false,
277
+ error: "Token exchange returned no credential data"
278
+ }, 502);
279
+ }
280
+ let subscriptionStatus = "not_requested";
281
+ let subscriptionResult;
282
+ if (session.data?.waba_id) if (shouldSubscribeWabaAfterCodeExchange(c)) {
283
+ subscriptionResult = await coexistence.subscribeWaba({
284
+ wabaId: session.data.waba_id,
285
+ accessToken: tokenExchange.data.accessToken
286
+ });
287
+ subscriptionStatus = subscriptionResult.success ? "subscribed" : "failed";
288
+ } else subscriptionStatus = "skipped";
289
+ if (session.data?.waba_id && session.data.phone_number_id) await coexistenceStore.upsertConnectedAccount({
290
+ wabaId: session.data.waba_id,
291
+ businessId: session.data.business_id,
292
+ accountId: session.data.business_id,
293
+ phoneNumberId: session.data.phone_number_id,
294
+ displayPhoneNumber: session.data.display_phone_number,
295
+ credentialRef: tokenExchange.data.credentialRef,
296
+ credentialProvider: tokenExchange.data.credentialProvider,
297
+ credentialMetadata: tokenExchange.data.credentialMetadata,
298
+ ...preflight ? { preflight } : {},
299
+ metadata: {
300
+ onboardingEvent: session.event,
301
+ tokenType: tokenExchange.data.tokenType,
302
+ tokenExpiresIn: optionalNumber(tokenExchange.data.expiresIn),
303
+ subscriptionStatus,
304
+ ...subscriptionResult ? { subscription: {
305
+ success: subscriptionResult.success,
306
+ error: subscriptionResult.error,
307
+ errorCode: subscriptionResult.errorCode,
308
+ httpStatus: subscriptionResult.httpStatus,
309
+ details: subscriptionResult.details
310
+ } } : {}
311
+ }
312
+ });
313
+ if (subscriptionResult && !subscriptionResult.success) {
314
+ await coexistenceStore.recordLifecycleEvent({
315
+ accountId: session.data?.business_id,
316
+ wabaId: session.data?.waba_id,
317
+ phoneNumberId: session.data?.phone_number_id,
318
+ event: "WABA_SUBSCRIPTION_FAILED",
319
+ payload: {
320
+ session,
321
+ error: subscriptionResult.error,
322
+ errorCode: subscriptionResult.errorCode,
323
+ httpStatus: subscriptionResult.httpStatus,
324
+ details: subscriptionResult.details
325
+ }
326
+ });
327
+ if (idempotencyKey) await coexistenceStore.updateRawEventStatus?.({
328
+ id: idempotencyKey,
329
+ status: "failed",
330
+ error: subscriptionResult.error ?? "Meta Graph request failed",
331
+ result: {
332
+ phase: "subscribe_waba",
333
+ wabaId: session.data?.waba_id,
334
+ error: subscriptionResult.error,
335
+ errorCode: subscriptionResult.errorCode,
336
+ details: subscriptionResult.details
337
+ }
338
+ });
339
+ return c.json({
340
+ success: false,
341
+ phase: "subscribe_waba",
342
+ error: subscriptionResult.error ?? "Meta Graph request failed",
343
+ ...subscriptionResult.errorCode ? { errorCode: subscriptionResult.errorCode } : {},
344
+ ...subscriptionResult.details ? { details: subscriptionResult.details } : {}
345
+ }, subscriptionResult.httpStatus ?? 502);
346
+ }
347
+ const responseBody = {
348
+ success: true,
349
+ status: "exchanged",
350
+ session,
351
+ subscriptionStatus
352
+ };
353
+ if (idempotencyKey) await coexistenceStore.updateRawEventStatus?.({
354
+ id: idempotencyKey,
355
+ status: "processed",
356
+ result: responseBody
357
+ });
358
+ return c.json(responseBody);
359
+ } catch (error) {
360
+ c.get("logger").error("coexistence.callback_error", (0, better_zap.serializeError)(error));
361
+ return c.json({
362
+ error: "Internal error handling coexistence callback",
363
+ code: "coexistence_callback_failed"
364
+ }, 500);
365
+ }
366
+ }
367
+ async function handlePhoneStatus(c) {
368
+ const coexistence = getCoexistence(c);
369
+ if (!coexistence) return c.json({ error: ROUTES_NOT_CONFIGURED }, 501);
370
+ try {
371
+ const phoneNumberId = c.req.param("phoneNumberId");
372
+ if (!phoneNumberId) return c.json({ error: "phoneNumberId is required" }, 400);
373
+ return graphResultResponse(c, await coexistence.getPhoneStatus({ phoneNumberId }));
374
+ } catch (error) {
375
+ c.get("logger").error("coexistence.status_error", (0, better_zap.serializeError)(error));
376
+ return c.json({
377
+ error: "Internal error fetching coexistence phone status",
378
+ code: "coexistence_status_failed"
379
+ }, 500);
380
+ }
381
+ }
382
+ async function handleSyncRequest(c, syncType) {
383
+ const coexistence = getCoexistence(c);
384
+ if (!coexistence) return c.json({ error: ROUTES_NOT_CONFIGURED }, 501);
385
+ try {
386
+ const phoneNumberId = c.req.param("phoneNumberId");
387
+ if (!phoneNumberId) return c.json({ error: "phoneNumberId is required" }, 400);
388
+ const blocked = await getBlockedConnectedAccount(c, phoneNumberId);
389
+ if (blocked) return c.json({
390
+ success: false,
391
+ error: "Coexistence account is not usable",
392
+ code: "coexistence_account_not_usable",
393
+ status: blocked.status,
394
+ phoneNumberId
395
+ }, 409);
396
+ const body = await resolveOptionalJsonBody(c);
397
+ const coexistenceStore = getCoexistenceStore(c);
398
+ const inFlight = await coexistenceStore?.getInFlightSyncJob?.({
399
+ phoneNumberId,
400
+ syncType
401
+ });
402
+ if (inFlight) return c.json({
403
+ success: false,
404
+ error: "Coexistence sync already in flight",
405
+ code: "sync_already_in_flight",
406
+ requestId: inFlight.requestId,
407
+ deadlineAt: inFlight.deadlineAt
408
+ }, 409);
409
+ const preflight = await coexistenceStore?.getPreflightStateByPhoneNumberId?.(phoneNumberId) ?? resolveInlinePreflightState(body, phoneNumberId);
410
+ const failureCodes = preflight ? getPreflightFailureCodes(preflight) : [];
411
+ if (failureCodes.length > 0) return preflightFailureResponse(c, failureCodes);
412
+ const result = syncType === "smb_app_state_sync" ? await coexistence.startContactsSync({ phoneNumberId }) : await coexistence.startHistorySync({ phoneNumberId });
413
+ if (!result.success) return graphResultResponse(c, result);
414
+ await recordSyncJob(c, {
415
+ phoneNumberId,
416
+ syncType,
417
+ result: result.data,
418
+ onboardingSessionId: typeof body?.onboardingSessionId === "string" ? body.onboardingSessionId : void 0
419
+ });
420
+ return graphResultResponse(c, result);
421
+ } catch (error) {
422
+ c.get("logger").error("coexistence.sync_error", (0, better_zap.serializeError)(error));
423
+ return c.json({
424
+ error: "Internal error requesting coexistence sync",
425
+ code: "coexistence_sync_failed"
426
+ }, 500);
427
+ }
428
+ }
429
+ async function getBlockedConnectedAccount(c, phoneNumberId) {
430
+ const coexistenceStore = getCoexistenceStore(c);
431
+ if (!coexistenceStore) return null;
432
+ const account = await coexistenceStore.getConnectedAccountByPhoneNumberId(phoneNumberId);
433
+ if (!account) return null;
434
+ return account.usable === false || account.status === "offboarded" ? account : null;
435
+ }
436
+ async function recordSyncJob(c, input) {
437
+ const requestId = input.result?.request_id;
438
+ const coexistenceStore = getCoexistenceStore(c);
439
+ if (!requestId || !coexistenceStore) return;
440
+ const requestedAt = /* @__PURE__ */ new Date();
441
+ const deadlineAt = input.syncType === "history" ? new Date(requestedAt.getTime() + SYNC_DEADLINE_MS) : void 0;
442
+ await coexistenceStore.createSyncJob({
443
+ requestId,
444
+ syncType: input.syncType,
445
+ onboardingSessionId: input.onboardingSessionId,
446
+ phoneNumberId: input.phoneNumberId,
447
+ status: "requested",
448
+ requestedAt,
449
+ deadlineAt,
450
+ metadata: { response: input.result }
451
+ });
452
+ }
453
+ async function resolveOptionalJsonBody(c) {
454
+ if (!(c.req.header("content-type") ?? "").includes("application/json")) return;
455
+ try {
456
+ return await c.req.json();
457
+ } catch {
458
+ return;
459
+ }
460
+ }
461
+ function resolveInlinePreflightState(body, phoneNumberId) {
462
+ if (!body) return;
463
+ const state = resolvePreflightState(body, null);
464
+ if (!state) return;
465
+ return {
466
+ ...state,
467
+ phoneNumberId: state.phoneNumberId ?? phoneNumberId
468
+ };
469
+ }
470
+ function handleContactsSync(c) {
471
+ return handleSyncRequest(c, "smb_app_state_sync");
472
+ }
473
+ function handleHistorySync(c) {
474
+ return handleSyncRequest(c, "history");
475
+ }
476
+ //#endregion
477
+ //#region src/handler/conversations.ts
478
+ async function handleListConversations(c) {
479
+ try {
480
+ const conversations = await c.get("store").getConversations();
481
+ return c.json((0, better_zap.normalizeConversationRecords)(conversations));
482
+ } catch (error) {
483
+ c.get("logger").error("conversations.list_error", (0, better_zap.serializeError)(error));
484
+ return c.json({ error: "Internal error fetching conversations" }, 500);
485
+ }
486
+ }
487
+ async function handleGetConversation(c) {
488
+ try {
489
+ const phone = c.req.param("phone");
490
+ if (!phone) return c.json({ error: "phone is required" }, 400);
491
+ const store = c.get("store");
492
+ const normalized = (0, better_zap.formatPhone)(decodeURIComponent(phone));
493
+ const conversation = await store.getConversationByPhone(normalized);
494
+ if (!conversation) return c.json({ error: "Conversation not found" }, 404);
495
+ return c.json((0, better_zap.normalizeConversationRecord)(conversation));
496
+ } catch (error) {
497
+ c.get("logger").error("conversations.get_error", (0, better_zap.serializeError)(error));
498
+ return c.json({ error: "Internal error fetching conversation" }, 500);
499
+ }
500
+ }
501
+ async function handleGetMessages(c) {
502
+ try {
503
+ const phone = c.req.param("phone");
504
+ if (!phone) return c.json({ error: "phone is required" }, 400);
505
+ const store = c.get("store");
506
+ const normalized = (0, better_zap.formatPhone)(decodeURIComponent(phone));
507
+ const conversation = await store.getConversationByPhone(normalized);
508
+ if (!conversation) return c.json({ error: "Conversation not found" }, 404);
509
+ const cursor = c.req.query("cursor") || void 0;
510
+ const limitParam = c.req.query("limit");
511
+ const limit = limitParam ? parseInt(limitParam, 10) : void 0;
512
+ const messages = await store.getMessagesByConversationPaginated(conversation.id, cursor, limit);
513
+ return c.json(messages);
514
+ } catch (error) {
515
+ c.get("logger").error("conversations.messages_error", (0, better_zap.serializeError)(error));
516
+ return c.json({ error: "Internal error fetching messages" }, 500);
517
+ }
518
+ }
519
+ //#endregion
520
+ //#region src/handler/send.ts
521
+ function getSendResponseStatus(result) {
522
+ return result.success ? 200 : result.httpStatus ?? 500;
523
+ }
524
+ async function handleSendText(c) {
525
+ const { to, body, messageType, userId, metadata } = await c.req.json();
526
+ if (!to || !body) return c.json({ error: "to and body are required" }, 400);
527
+ const whatsapp = c.get("whatsapp");
528
+ const logging = messageType ? {
529
+ messageType,
530
+ userId,
531
+ metadata
532
+ } : void 0;
533
+ const result = await whatsapp.sendText(to, body, logging);
534
+ return c.json(result, getSendResponseStatus(result));
535
+ }
536
+ function createSendTemplateHandler(templates) {
537
+ return async function handleSendTemplate(c) {
538
+ const body = await c.req.json();
539
+ if (!body.to || !body.template) return c.json({ error: "to and template are required" }, 400);
540
+ const whatsapp = c.get("whatsapp");
541
+ const logging = body.logging ?? (body.messageType ? {
542
+ messageType: body.messageType,
543
+ content: body.content || `[template: ${body.template}]`,
544
+ userId: body.userId,
545
+ metadata: body.metadata
546
+ } : void 0);
547
+ let language = body.language;
548
+ let components = body.components;
549
+ if ("params" in body && body.params !== void 0) {
550
+ if (!(0, better_zap.hasConfiguredTemplates)(templates)) return c.json({ error: "Typed template params require a configured template registry" }, 400);
551
+ try {
552
+ const serializedTemplate = (0, better_zap.serializeTemplateFromRegistry)(templates, body.template, {
553
+ language: body.language,
554
+ params: body.params
555
+ });
556
+ language = serializedTemplate.language;
557
+ components = serializedTemplate.components;
558
+ } catch (error) {
559
+ const message = error instanceof Error ? error.message : "Failed to serialize template from registry";
560
+ return c.json({ error: message }, 400);
561
+ }
562
+ }
563
+ const result = await whatsapp.sendTemplate(body.to, body.template, language, components, logging);
564
+ return c.json(result, getSendResponseStatus(result));
565
+ };
566
+ }
567
+ async function handleSendInteractive(c) {
568
+ const { to, type, body, buttons, buttonLabel, sections, cards, messageType, userId, metadata } = await c.req.json();
569
+ if (!to || !body) return c.json({ error: "to and body are required" }, 400);
570
+ const whatsapp = c.get("whatsapp");
571
+ const logging = messageType ? {
572
+ messageType,
573
+ userId,
574
+ metadata
575
+ } : void 0;
576
+ if (type === "list") {
577
+ if (!buttonLabel || !sections) return c.json({ error: "buttonLabel and sections are required for list type" }, 400);
578
+ const result = await whatsapp.sendInteractiveList(to, body, buttonLabel, sections, logging);
579
+ return c.json(result, getSendResponseStatus(result));
580
+ }
581
+ if (type === "carousel") {
582
+ if (!cards) return c.json({ error: "cards are required for carousel type" }, 400);
583
+ if (cards.length < 2 || cards.length > 10) return c.json({ error: "carousel requires between 2 and 10 cards" }, 400);
584
+ const result = await whatsapp.sendInteractiveMediaCarousel({
585
+ to,
586
+ body,
587
+ cards
588
+ }, logging);
589
+ return c.json(result, getSendResponseStatus(result));
590
+ }
591
+ if (!buttons) return c.json({ error: "buttons are required for button type" }, 400);
592
+ const result = await whatsapp.sendInteractiveButtons(to, body, buttons, logging);
593
+ return c.json(result, getSendResponseStatus(result));
594
+ }
595
+ async function handleSendLocation(c) {
596
+ const { to, latitude, longitude, name, address, messageType, userId, metadata } = await c.req.json();
597
+ if (!to || latitude == null || longitude == null || !name || !address) return c.json({ error: "to, latitude, longitude, name, and address are required" }, 400);
598
+ const whatsapp = c.get("whatsapp");
599
+ const logging = messageType ? {
600
+ messageType,
601
+ userId,
602
+ metadata
603
+ } : void 0;
604
+ const result = await whatsapp.sendLocation(to, latitude, longitude, name, address, logging);
605
+ return c.json(result, getSendResponseStatus(result));
606
+ }
607
+ //#endregion
608
+ //#region src/webhook/signature-verification.ts
609
+ const textEncoder = new TextEncoder();
610
+ let cachedMetaAppSecret = null;
611
+ let cachedMetaHmacKey = null;
612
+ async function verifyMetaWebhookSignature({ rawBody, signatureHeader, appSecret }) {
613
+ if (!signatureHeader) return false;
614
+ const [algorithm, signatureHexRaw] = signatureHeader.split("=", 2);
615
+ if (algorithm?.toLowerCase() !== "sha256" || !signatureHexRaw) return false;
616
+ const signatureBytes = hexToBytes(signatureHexRaw.trim());
617
+ if (!signatureBytes) return false;
618
+ const key = await getMetaHmacKey(appSecret);
619
+ const expectedSignatureBuffer = await crypto.subtle.sign("HMAC", key, rawBody);
620
+ return constantTimeEqual(new Uint8Array(expectedSignatureBuffer), signatureBytes);
621
+ }
622
+ function getMetaHmacKey(appSecret) {
623
+ if (cachedMetaAppSecret === appSecret && cachedMetaHmacKey) return cachedMetaHmacKey;
624
+ cachedMetaAppSecret = appSecret;
625
+ cachedMetaHmacKey = crypto.subtle.importKey("raw", textEncoder.encode(appSecret), {
626
+ name: "HMAC",
627
+ hash: "SHA-256"
628
+ }, false, ["sign"]);
629
+ return cachedMetaHmacKey;
630
+ }
631
+ function hexToBytes(hex) {
632
+ if (hex.length % 2 !== 0) return null;
633
+ const bytes = new Uint8Array(hex.length / 2);
634
+ for (let i = 0; i < bytes.length; i += 1) {
635
+ const value = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
636
+ if (Number.isNaN(value)) return null;
637
+ bytes[i] = value;
638
+ }
639
+ return bytes;
640
+ }
641
+ function constantTimeEqual(a, b) {
642
+ if (a.length !== b.length) return false;
643
+ let diff = 0;
644
+ for (let i = 0; i < a.length; i += 1) diff |= a[i] ^ b[i];
645
+ return diff === 0;
646
+ }
647
+ //#endregion
648
+ //#region src/webhook/message-content.ts
649
+ /**
650
+ * Extract human-readable content from incoming messages for audit logs.
651
+ */
652
+ function getMessageContent(message) {
653
+ switch (message.type) {
654
+ case "text": return message.text?.body || "[texto vazio]";
655
+ case "image": return `[imagem${message.image?.caption ? `: ${message.image.caption}` : ""}]`;
656
+ case "audio": return "[áudio]";
657
+ case "video": return `[vídeo${message.video?.caption ? `: ${message.video.caption}` : ""}]`;
658
+ case "document": return `[documento: ${message.document?.filename || "arquivo"}]`;
659
+ case "location": return `[localização: ${message.location?.name || `${message.location?.latitude},${message.location?.longitude}`}]`;
660
+ case "button": return `[botão: ${message.button?.text}]`;
661
+ case "interactive":
662
+ if (message.interactive?.button_reply) return `[resposta botão: ${message.interactive.button_reply.title}]`;
663
+ if (message.interactive?.list_reply) return `[resposta lista: ${message.interactive.list_reply.title}]`;
664
+ return "[interativo]";
665
+ case "sticker": return "[figurinha]";
666
+ case "reaction": return "[reação]";
667
+ default: return `[${message.type}]`;
668
+ }
669
+ }
670
+ //#endregion
671
+ //#region src/webhook/create-webhook-handler.ts
672
+ const textDecoder = new TextDecoder();
673
+ /**
674
+ * Creates a Hono router that handles the full WhatsApp webhook lifecycle.
675
+ *
676
+ * **SDK guarantees (non-hookable):**
677
+ * - Signature is always verified before any processing
678
+ * - Meta always receives a fast 200 OK (processing runs via `waitUntil`)
679
+ * - Hook errors never crash the webhook (wrapped in try/catch)
680
+ * - Contact is resolved and content is extracted before `onMessage`
681
+ * - Status timestamp is parsed to ISO before `onStatusUpdate`
682
+ *
683
+ * @typeParam Env - Hono bindings type (e.g. Cloudflare Worker env).
684
+ */
685
+ function createWebhookHandler(config) {
686
+ const log = config.log;
687
+ const webhook = new hono.Hono();
688
+ webhook.get("/", (c) => {
689
+ const mode = c.req.query("hub.mode");
690
+ const token = c.req.query("hub.verify_token");
691
+ const challenge = c.req.query("hub.challenge");
692
+ if (mode === "subscribe" && token === config.verifyToken) {
693
+ log.info("webhook.verification_successful");
694
+ return c.text(challenge || "", 200);
695
+ }
696
+ log.warn("webhook.verification_failed");
697
+ return c.text("Forbidden", 403);
698
+ });
699
+ webhook.post("/", async (c) => {
700
+ try {
701
+ if (!config.appSecret) {
702
+ log.error("webhook.missing_app_secret");
703
+ return c.text("Server Misconfigured", 500);
704
+ }
705
+ const rawBody = await c.req.raw.arrayBuffer();
706
+ if (!await verifyMetaWebhookSignature({
707
+ rawBody,
708
+ signatureHeader: c.req.header("x-hub-signature-256"),
709
+ appSecret: config.appSecret
710
+ })) {
711
+ log.warn("webhook.invalid_signature");
712
+ return c.text("Unauthorized", 401);
713
+ }
714
+ let payload;
715
+ try {
716
+ payload = JSON.parse(textDecoder.decode(rawBody));
717
+ } catch {
718
+ log.warn("webhook.invalid_payload");
719
+ return c.text("Bad Request", 400);
720
+ }
721
+ if (c.executionCtx) c.executionCtx.waitUntil(processPayload(payload, c.env, config, log));
722
+ else await processPayload(payload, c.env, config, log);
723
+ return c.text("OK", 200);
724
+ } catch (error) {
725
+ log.error("webhook.request_error", (0, better_zap.serializeError)(error));
726
+ return c.text("Internal Server Error", 500);
727
+ }
728
+ });
729
+ return webhook;
730
+ }
731
+ /** Top-level dispatcher — iterates entries in the webhook payload. */
732
+ async function processPayload(payload, env, config, log) {
733
+ try {
734
+ if (payload.object !== "whatsapp_business_account") {
735
+ log.debug("webhook.ignored_payload", { object: payload.object });
736
+ return;
737
+ }
738
+ for (const entry of payload.entry) await processEntry(entry, env, config, log);
739
+ } catch (error) {
740
+ log.error("webhook.async_process_error", (0, better_zap.serializeError)(error));
741
+ }
742
+ }
743
+ /** Iterates changes within a single entry. */
744
+ async function processEntry(entry, env, config, log) {
745
+ for (const change of entry.changes) await processChange(change, env, config, log);
746
+ }
747
+ /** Routes webhook changes to field-specific processors. */
748
+ async function processChange(change, env, config, log) {
749
+ switch (change.field) {
750
+ case "messages":
751
+ await processMessagesChange(change, env, config, log);
752
+ return;
753
+ case "history":
754
+ await processHistoryChange(change, config, log);
755
+ return;
756
+ case "smb_app_state_sync":
757
+ await processSmbAppStateSyncChange(change, config, log);
758
+ return;
759
+ case "smb_message_echoes":
760
+ await processSmbMessageEchoesChange(change, config, log);
761
+ return;
762
+ case "account_update":
763
+ await processAccountUpdateChange(change, config, log);
764
+ return;
765
+ case "account_offboarded":
766
+ await processAccountOffboardedChange(change, config, log);
767
+ return;
768
+ case "account_reconnected":
769
+ await processAccountReconnectedChange(change, config, log);
770
+ return;
771
+ default:
772
+ log.debug("webhook.unknown_field_ignored", { field: change.field });
773
+ return;
774
+ }
775
+ }
776
+ /** Routes ordinary messages, statuses, and errors to the existing handlers. */
777
+ async function processMessagesChange(change, _env, config, log) {
778
+ const value = change.value;
779
+ const messageClassification = classifyCoexistenceMessages(value.messages);
780
+ if (messageClassification === "edit") {
781
+ await processMessageEditChange(change, config, log);
782
+ return;
783
+ }
784
+ if (messageClassification === "revoke") {
785
+ await processMessageRevokeChange(change, config, log);
786
+ return;
787
+ }
788
+ if (messageClassification === "unsupported") {
789
+ await processUnsupportedMessagesChange(change, config, log);
790
+ return;
791
+ }
792
+ if (value.messages && value.messages.length > 0) for (const message of value.messages) await processIncomingMessage(message, resolveContact(value.contacts, message), config, log);
793
+ if (value.statuses && value.statuses.length > 0) for (const status of value.statuses) await processStatusUpdate(status, config, log);
794
+ if (value.errors && value.errors.length > 0) {
795
+ const errorHandler = config.onError ?? ((err) => {
796
+ log.error("webhook.meta_error", { error: err });
797
+ });
798
+ for (const error of value.errors) try {
799
+ errorHandler(error);
800
+ } catch (hookError) {
801
+ log.error("webhook.on_error_hook_failed", {
802
+ metaError: error,
803
+ hookError: (0, better_zap.serializeError)(hookError)
804
+ });
805
+ }
806
+ }
807
+ }
808
+ async function processHistoryChange(change, config, log) {
809
+ const value = change.value;
810
+ const requestId = value.request_id;
811
+ let importedMessages = 0;
812
+ let duplicateMessages = 0;
813
+ let hasHistoryErrors = (value.errors?.length ?? 0) > 0;
814
+ if (requestId && config.database?.coexistence) await config.database.coexistence.updateSyncJobByRequestId(requestId, {
815
+ status: "processing",
816
+ metadata: { field: "history" }
817
+ });
818
+ if (value.errors && value.errors.length > 0) await processCoexistenceErrors(change, value, config, log);
819
+ for (const chunk of value.history ?? []) {
820
+ if (chunk.errors && chunk.errors.length > 0) {
821
+ hasHistoryErrors = true;
822
+ await processCoexistenceErrors(change, {
823
+ ...value,
824
+ errors: chunk.errors
825
+ }, config, log);
826
+ }
827
+ for (const message of chunk.messages ?? []) {
828
+ if (message.revoked || message.type === "revoked") {
829
+ await runOptionalHook(() => config.onCoexistenceMessageRevoke?.({
830
+ value: {
831
+ ...value,
832
+ messages: [message]
833
+ },
834
+ change,
835
+ revokedMessages: 1
836
+ }), "webhook.on_coexistence_message_revoke_hook_failed", log);
837
+ continue;
838
+ }
839
+ if (message.edited || message.type === "message_edit") {
840
+ await runOptionalHook(() => config.onCoexistenceMessageEdit?.({
841
+ value: {
842
+ ...value,
843
+ messages: [message]
844
+ },
845
+ change,
846
+ editedMessages: 1
847
+ }), "webhook.on_coexistence_message_edit_hook_failed", log);
848
+ continue;
849
+ }
850
+ if (message.unsupported || message.type === "unsupported" || (message.errors?.length ?? 0) > 0) {
851
+ await processCoexistenceErrors(change, {
852
+ ...value,
853
+ unsupported: true,
854
+ errors: (message.errors ?? []).map(toWebhookError)
855
+ }, config, log);
856
+ continue;
857
+ }
858
+ const result = await importCoexistenceMessage({
859
+ message,
860
+ contacts: value.contacts,
861
+ metadata: value.metadata,
862
+ requestId,
863
+ source: "history",
864
+ config
865
+ });
866
+ if (result === "created") importedMessages += 1;
867
+ else if (result === "duplicate") duplicateMessages += 1;
868
+ }
869
+ for (const status of chunk.statuses ?? []) await processStatusUpdate(status, config, log);
870
+ }
871
+ if (requestId && config.database?.coexistence) {
872
+ const updatedAt = /* @__PURE__ */ new Date();
873
+ await config.database.coexistence.updateSyncJobByRequestId(requestId, {
874
+ status: hasHistoryErrors ? "failed" : "completed",
875
+ ...hasHistoryErrors ? { failedAt: updatedAt } : { completedAt: updatedAt },
876
+ updatedAt,
877
+ metadata: {
878
+ field: "history",
879
+ importedMessages,
880
+ duplicateMessages
881
+ }
882
+ });
883
+ }
884
+ await runOptionalHook(() => config.onCoexistenceHistory?.({
885
+ value,
886
+ change,
887
+ importedMessages,
888
+ duplicateMessages
889
+ }), "webhook.on_coexistence_history_hook_failed", log);
890
+ }
891
+ async function processSmbAppStateSyncChange(change, config, log) {
892
+ const value = change.value;
893
+ let upsertedContacts = 0;
894
+ let removedContacts = 0;
895
+ const hasSyncErrors = (value.errors?.length ?? 0) > 0;
896
+ if (value.request_id && config.database?.coexistence) await config.database.coexistence.updateSyncJobByRequestId(value.request_id, {
897
+ status: "processing",
898
+ metadata: { field: "smb_app_state_sync" }
899
+ });
900
+ if (hasSyncErrors) await processCoexistenceErrors(change, value, config, log);
901
+ if (!hasSyncErrors && config.database?.coexistence) for (const contact of value.contacts ?? []) {
902
+ if (contact.removed) {
903
+ await config.database.coexistence.removeContact({
904
+ waId: contact.wa_id,
905
+ phoneNumberId: value.metadata?.phone_number_id
906
+ });
907
+ removedContacts += 1;
908
+ continue;
909
+ }
910
+ await config.database.coexistence.upsertContact({
911
+ waId: contact.wa_id,
912
+ phoneNumberId: value.metadata?.phone_number_id,
913
+ displayName: contact.profile?.name,
914
+ removed: false,
915
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
916
+ metadata: contact
917
+ });
918
+ upsertedContacts += 1;
919
+ }
920
+ if (value.request_id && config.database?.coexistence) {
921
+ const updatedAt = /* @__PURE__ */ new Date();
922
+ await config.database.coexistence.updateSyncJobByRequestId(value.request_id, {
923
+ status: hasSyncErrors ? "failed" : "completed",
924
+ ...hasSyncErrors ? { failedAt: updatedAt } : { completedAt: updatedAt },
925
+ updatedAt,
926
+ metadata: {
927
+ field: "smb_app_state_sync",
928
+ upsertedContacts,
929
+ removedContacts,
930
+ errors: value.errors
931
+ }
932
+ });
933
+ }
934
+ await runOptionalHook(() => config.onSmbAppStateSync?.({
935
+ value,
936
+ change,
937
+ upsertedContacts,
938
+ removedContacts
939
+ }), "webhook.on_smb_app_state_sync_hook_failed", log);
940
+ }
941
+ async function processMessageEditChange(change, config, log) {
942
+ const value = change.value;
943
+ const editedMessages = value.messages.length;
944
+ await runOptionalHook(() => config.onCoexistenceMessageEdit?.({
945
+ value,
946
+ change,
947
+ editedMessages
948
+ }), "webhook.on_coexistence_message_edit_hook_failed", log);
949
+ }
950
+ async function processMessageRevokeChange(change, config, log) {
951
+ const value = change.value;
952
+ const revokedMessages = value.messages.length;
953
+ await runOptionalHook(() => config.onCoexistenceMessageRevoke?.({
954
+ value,
955
+ change,
956
+ revokedMessages
957
+ }), "webhook.on_coexistence_message_revoke_hook_failed", log);
958
+ }
959
+ async function processUnsupportedMessagesChange(change, config, log) {
960
+ const value = change.value;
961
+ const errors = [...value.errors ?? [], ...(value.messages ?? []).flatMap((message) => (message.errors ?? []).map(toWebhookError))];
962
+ await runOptionalHook(() => config.onCoexistenceUnsupportedMessage?.({
963
+ value,
964
+ change,
965
+ errors
966
+ }), "webhook.on_coexistence_unsupported_hook_failed", log);
967
+ }
968
+ async function processSmbMessageEchoesChange(change, config, log) {
969
+ const value = change.value;
970
+ let importedMessages = 0;
971
+ let duplicateMessages = 0;
972
+ for (const message of value.messages ?? []) {
973
+ const result = await importCoexistenceMessage({
974
+ message,
975
+ contacts: value.contacts,
976
+ metadata: value.metadata,
977
+ source: "smb_message_echoes",
978
+ forceDirection: "outgoing",
979
+ config
980
+ });
981
+ if (result === "created") importedMessages += 1;
982
+ else if (result === "duplicate") duplicateMessages += 1;
983
+ }
984
+ await runOptionalHook(() => config.onSmbMessageEcho?.({
985
+ value,
986
+ change,
987
+ importedMessages,
988
+ duplicateMessages
989
+ }), "webhook.on_smb_message_echo_hook_failed", log);
990
+ }
991
+ async function processAccountUpdateChange(change, config, log) {
992
+ const value = change.value;
993
+ if (config.database?.coexistence) await config.database.coexistence.recordLifecycleEvent({
994
+ wabaId: value.waba_info?.waba_id,
995
+ phoneNumberId: value.phone_number_id,
996
+ accountId: value.waba_info?.owner_business_id,
997
+ event: value.event ?? "account_update",
998
+ payload: value,
999
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
1000
+ });
1001
+ await runOptionalHook(() => config.onCoexistenceAccountUpdate?.({
1002
+ value,
1003
+ change
1004
+ }), "webhook.on_coexistence_account_update_hook_failed", log);
1005
+ }
1006
+ async function processAccountOffboardedChange(change, config, log) {
1007
+ const value = change.value;
1008
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1009
+ if (config.database?.coexistence) {
1010
+ await config.database.coexistence.recordLifecycleEvent({
1011
+ wabaId: value.waba_info?.waba_id,
1012
+ phoneNumberId: value.phone_number_id ?? value.metadata?.phone_number_id,
1013
+ accountId: value.waba_info?.owner_business_id,
1014
+ event: value.event ?? "ACCOUNT_OFFBOARDED",
1015
+ payload: value,
1016
+ createdAt: now
1017
+ });
1018
+ await upsertLifecycleAccountState(value, config, {
1019
+ status: "offboarded",
1020
+ usable: false,
1021
+ offboardedAt: now,
1022
+ updatedAt: now,
1023
+ metadata: {
1024
+ lifecycleEvent: "account_offboarded",
1025
+ reason: value.reason,
1026
+ raw: value
1027
+ }
1028
+ });
1029
+ }
1030
+ await runOptionalHook(() => config.onCoexistenceAccountOffboarded?.({
1031
+ value,
1032
+ change
1033
+ }), "webhook.on_coexistence_account_offboarded_hook_failed", log);
1034
+ }
1035
+ async function processAccountReconnectedChange(change, config, log) {
1036
+ const value = change.value;
1037
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1038
+ if (config.database?.coexistence) {
1039
+ await config.database.coexistence.recordLifecycleEvent({
1040
+ wabaId: value.waba_info?.waba_id,
1041
+ phoneNumberId: value.phone_number_id ?? value.metadata?.phone_number_id,
1042
+ accountId: value.waba_info?.owner_business_id,
1043
+ event: value.event ?? "ACCOUNT_RECONNECTED",
1044
+ payload: value,
1045
+ createdAt: now
1046
+ });
1047
+ await upsertLifecycleAccountState(value, config, {
1048
+ status: "reconnected",
1049
+ usable: true,
1050
+ reconnectedAt: now,
1051
+ updatedAt: now,
1052
+ metadata: {
1053
+ lifecycleEvent: "account_reconnected",
1054
+ reconnectReason: value.reconnect_reason,
1055
+ cloudApiProducts: value.cloud_api_products,
1056
+ raw: value
1057
+ }
1058
+ });
1059
+ }
1060
+ await runOptionalHook(() => config.onCoexistenceAccountReconnected?.({
1061
+ value,
1062
+ change
1063
+ }), "webhook.on_coexistence_account_reconnected_hook_failed", log);
1064
+ }
1065
+ async function processCoexistenceErrors(change, value, config, log) {
1066
+ const errors = value.errors ?? [];
1067
+ const requestId = "request_id" in value && typeof value.request_id === "string" ? value.request_id : void 0;
1068
+ if (requestId && config.database?.coexistence) await config.database.coexistence.updateSyncJobByRequestId(requestId, {
1069
+ status: "failed",
1070
+ metadata: {
1071
+ field: change.field,
1072
+ errors,
1073
+ isHistoryOptOut: errors.some((error) => error.code === 2593109)
1074
+ }
1075
+ });
1076
+ await runOptionalHook(() => config.onCoexistenceUnsupportedMessage?.({
1077
+ value,
1078
+ change,
1079
+ errors
1080
+ }), "webhook.on_coexistence_unsupported_hook_failed", log);
1081
+ }
1082
+ async function upsertLifecycleAccountState(value, config, patch) {
1083
+ const store = config.database?.coexistence;
1084
+ const phoneNumberId = value.phone_number_id ?? value.metadata?.phone_number_id;
1085
+ const wabaId = value.waba_info?.waba_id;
1086
+ if (!store || !phoneNumberId || !wabaId) return;
1087
+ const existing = await store.getConnectedAccountByPhoneNumberId(phoneNumberId) ?? await store.getConnectedAccountByWabaId(wabaId);
1088
+ await store.upsertConnectedAccount({
1089
+ wabaId,
1090
+ phoneNumberId,
1091
+ accountId: value.waba_info?.owner_business_id ?? existing?.accountId,
1092
+ businessId: existing?.businessId,
1093
+ displayPhoneNumber: value.metadata?.display_phone_number ?? existing?.displayPhoneNumber,
1094
+ ...existing,
1095
+ ...patch,
1096
+ metadata: {
1097
+ ...existing?.metadata,
1098
+ ...patch.metadata
1099
+ }
1100
+ });
1101
+ }
1102
+ async function importCoexistenceMessage(input) {
1103
+ if (!input.config.database?.coexistence) return "skipped";
1104
+ const direction = input.forceDirection ?? resolveMessageDirection(input.message, input.metadata?.display_phone_number);
1105
+ const contact = resolveCoexistenceContact(input.contacts, input.message);
1106
+ const phone = resolveConversationPhone(input.message, direction, contact);
1107
+ return await input.config.logger.logImportedMessage({
1108
+ phone,
1109
+ waMessageId: input.message.id,
1110
+ direction,
1111
+ content: getMessageContent(input.message),
1112
+ sentAt: parseWebhookTimestamp(input.message.timestamp),
1113
+ senderName: contact?.profile?.name,
1114
+ metadata: {
1115
+ source: input.source,
1116
+ requestId: input.requestId,
1117
+ phoneNumberId: input.metadata?.phone_number_id,
1118
+ raw: input.message
1119
+ }
1120
+ }) ? "created" : "duplicate";
1121
+ }
1122
+ function resolveMessageDirection(message, businessDisplayPhoneNumber) {
1123
+ if (!businessDisplayPhoneNumber) return "incoming";
1124
+ return (0, better_zap.formatPhone)(message.from) === (0, better_zap.formatPhone)(businessDisplayPhoneNumber) ? "outgoing" : "incoming";
1125
+ }
1126
+ function resolveConversationPhone(message, direction, contact) {
1127
+ if (direction === "incoming") return message.from;
1128
+ const messageWithRecipient = message;
1129
+ return messageWithRecipient.to ?? messageWithRecipient.recipient_id ?? contact?.wa_id ?? message.from;
1130
+ }
1131
+ function parseWebhookTimestamp(timestamp) {
1132
+ const sentAt = /* @__PURE__ */ new Date(parseInt(timestamp, 10) * 1e3);
1133
+ return Number.isNaN(sentAt.getTime()) ? (/* @__PURE__ */ new Date()).toISOString() : sentAt.toISOString();
1134
+ }
1135
+ async function runOptionalHook(run, logEvent, log) {
1136
+ try {
1137
+ await run();
1138
+ } catch (error) {
1139
+ log.error(logEvent, (0, better_zap.serializeError)(error));
1140
+ }
1141
+ }
1142
+ /**
1143
+ * Processes a single incoming message:
1144
+ * 1. Extracts human-readable content
1145
+ * 2. Atomically logs and deduplicates by waMessageId
1146
+ * 3. Calls {@link WebhookConfig.onMessage}
1147
+ */
1148
+ async function processIncomingMessage(message, contact, config, log) {
1149
+ const phone = message.from;
1150
+ log.info("webhook.message_received", {
1151
+ waMessageId: message.id,
1152
+ phone,
1153
+ messageType: message.type
1154
+ });
1155
+ const content = getMessageContent(message);
1156
+ const sentAt = /* @__PURE__ */ new Date(parseInt(message.timestamp, 10) * 1e3);
1157
+ const normalizedSentAt = Number.isNaN(sentAt.getTime()) ? (/* @__PURE__ */ new Date()).toISOString() : sentAt.toISOString();
1158
+ const { id, type, text, from, timestamp, ...rawMetadata } = message;
1159
+ if (!await config.logger.logIncoming({
1160
+ phone,
1161
+ waMessageId: message.id,
1162
+ content,
1163
+ sentAt: normalizedSentAt,
1164
+ senderName: contact?.profile?.name,
1165
+ metadata: Object.keys(rawMetadata).length > 0 ? rawMetadata : void 0
1166
+ })) {
1167
+ log.info("webhook.duplicate_ignored", {
1168
+ waMessageId: message.id,
1169
+ phone
1170
+ });
1171
+ return;
1172
+ }
1173
+ const ctx = {
1174
+ message,
1175
+ contact,
1176
+ content,
1177
+ phone
1178
+ };
1179
+ try {
1180
+ await config.onMessage(ctx);
1181
+ } catch (error) {
1182
+ log.error("webhook.on_message_hook_failed", {
1183
+ waMessageId: message.id,
1184
+ phone,
1185
+ ...(0, better_zap.serializeError)(error)
1186
+ });
1187
+ }
1188
+ }
1189
+ /**
1190
+ * Processes a single delivery status update:
1191
+ * 1. Parses Unix timestamp to ISO-8601
1192
+ * 2. Extracts first error (if any)
1193
+ * 3. Atomically updates status only if it advances the lifecycle
1194
+ * 4. Calls {@link WebhookConfig.onStatusUpdate} only if the update was applied
1195
+ */
1196
+ async function processStatusUpdate(status, config, log) {
1197
+ const firstError = status.errors?.[0];
1198
+ const timestamp = (/* @__PURE__ */ new Date(parseInt(status.timestamp) * 1e3)).toISOString();
1199
+ const errorMessage = firstError?.message;
1200
+ const errorCode = firstError?.code;
1201
+ if (!await config.logger.updateStatus(status.id, status.status, timestamp, errorMessage)) return;
1202
+ log.info("webhook.status_updated", {
1203
+ waMessageId: status.id,
1204
+ status: status.status
1205
+ });
1206
+ const ctx = {
1207
+ status,
1208
+ timestamp,
1209
+ errorMessage,
1210
+ errorCode
1211
+ };
1212
+ try {
1213
+ await config.onStatusUpdate(ctx);
1214
+ } catch (error) {
1215
+ log.error("webhook.on_status_update_hook_failed", {
1216
+ waMessageId: status.id,
1217
+ ...(0, better_zap.serializeError)(error)
1218
+ });
1219
+ }
1220
+ }
1221
+ /** Matches a contact to a message by `wa_id`, falling back to the first contact. */
1222
+ function resolveContact(contacts, message) {
1223
+ if (!contacts || contacts.length === 0) return;
1224
+ return contacts.find((c) => c.wa_id === message.from) ?? contacts[0];
1225
+ }
1226
+ function resolveCoexistenceContact(contacts, message) {
1227
+ if (!contacts || contacts.length === 0) return;
1228
+ return contacts.find((c) => c.wa_id === message.from) ?? contacts[0];
1229
+ }
1230
+ function classifyCoexistenceMessages(messages) {
1231
+ if (!messages || messages.length === 0) return "ordinary";
1232
+ if (messages.some((message) => message.revoked || message.type === "revoked")) return "revoke";
1233
+ if (messages.some((message) => message.edited || message.type === "message_edit")) return "edit";
1234
+ if (messages.some((message) => message.unsupported || message.type === "unsupported" || (message.errors?.length ?? 0) > 0)) return "unsupported";
1235
+ return "ordinary";
1236
+ }
1237
+ function toWebhookError(error) {
1238
+ return {
1239
+ ...error,
1240
+ error_data: error.error_data ?? { details: error.message }
1241
+ };
1242
+ }
1243
+ //#endregion
1244
+ //#region src/internal/cloudflare/constants.ts
1245
+ const GLOBAL_WORKSPACE_DO_ID = "global-workspace";
1246
+ //#endregion
1247
+ //#region src/internal/cloudflare/conversation-sync.ts
1248
+ function createConversationSyncNotifier(conversationSync) {
1249
+ if (!conversationSync) return;
1250
+ return { async notify(event) {
1251
+ const id = conversationSync.idFromName(GLOBAL_WORKSPACE_DO_ID);
1252
+ await conversationSync.get(id).fetch(new Request("http://do/sync", {
1253
+ method: "POST",
1254
+ body: JSON.stringify(event)
1255
+ }));
1256
+ } };
1257
+ }
1258
+ //#endregion
1259
+ //#region src/better-zap.ts
1260
+ function serializeRuntimeTemplate(templates, templateName, options) {
1261
+ return (0, better_zap.serializeTemplateFromRegistry)(templates, templateName, {
1262
+ language: options.language,
1263
+ params: options.params ?? {}
1264
+ });
1265
+ }
1266
+ function betterZap(options) {
1267
+ const { database, config, webhook: webhookHooks, conversationSync, basePath = "/api/whatsapp" } = options;
1268
+ const templates = options.templates ?? better_zap.EMPTY_TEMPLATE_REGISTRY;
1269
+ const log = (0, better_zap.createLogger)(options.logger);
1270
+ const logger = new better_zap.MessageLoggerService(database.whatsappLog, log, createConversationSyncNotifier(conversationSync));
1271
+ const whatsapp = new better_zap.WhatsAppService(config, logger, log);
1272
+ const coexistence = options.coexistence && options.coexistence.enabled !== false ? options.coexistence.service ?? new better_zap.CoexistenceService({
1273
+ accessToken: options.coexistence.accessToken,
1274
+ appId: options.coexistence.appId,
1275
+ appSecret: options.coexistence.appSecret,
1276
+ graphApiVersion: options.coexistence.graphApiVersion,
1277
+ graphBaseUrl: options.coexistence.graphBaseUrl,
1278
+ fetch: options.coexistence.fetch,
1279
+ credentialProvider: options.coexistence.credentials
1280
+ }) : void 0;
1281
+ const coreContext = {
1282
+ db: database,
1283
+ api: whatsapp,
1284
+ logger
1285
+ };
1286
+ const coreServices = {
1287
+ whatsapp,
1288
+ logger
1289
+ };
1290
+ const plugins = options.plugins ?? [];
1291
+ const pluginRuntime = initializePlugins({
1292
+ plugins,
1293
+ database,
1294
+ config,
1295
+ coreContext,
1296
+ coreServices,
1297
+ log
1298
+ });
1299
+ const webhookRouter = createWebhookHandler({
1300
+ verifyToken: config.webhookToken,
1301
+ appSecret: config.appSecret,
1302
+ logger,
1303
+ log,
1304
+ database,
1305
+ onMessage: async (ctx) => {
1306
+ const hookContext = {
1307
+ ...ctx,
1308
+ ...pluginRuntime.context
1309
+ };
1310
+ await runPluginMessageHooks({
1311
+ plugins,
1312
+ ctx: hookContext,
1313
+ log
1314
+ });
1315
+ await webhookHooks.onMessage(hookContext);
1316
+ },
1317
+ onStatusUpdate: async (ctx) => {
1318
+ const hookContext = {
1319
+ ...ctx,
1320
+ ...pluginRuntime.context
1321
+ };
1322
+ await runPluginStatusHooks({
1323
+ plugins,
1324
+ ctx: hookContext,
1325
+ log
1326
+ });
1327
+ await webhookHooks.onStatusUpdate(hookContext);
1328
+ },
1329
+ onCoexistenceHistory: async (ctx) => {
1330
+ await webhookHooks.onCoexistenceHistory?.({
1331
+ ...ctx,
1332
+ ...pluginRuntime.context
1333
+ });
1334
+ },
1335
+ onSmbAppStateSync: async (ctx) => {
1336
+ await webhookHooks.onSmbAppStateSync?.({
1337
+ ...ctx,
1338
+ ...pluginRuntime.context
1339
+ });
1340
+ },
1341
+ onSmbMessageEcho: async (ctx) => {
1342
+ await webhookHooks.onSmbMessageEcho?.({
1343
+ ...ctx,
1344
+ ...pluginRuntime.context
1345
+ });
1346
+ },
1347
+ onCoexistenceAccountUpdate: async (ctx) => {
1348
+ await webhookHooks.onCoexistenceAccountUpdate?.({
1349
+ ...ctx,
1350
+ ...pluginRuntime.context
1351
+ });
1352
+ },
1353
+ onCoexistenceAccountOffboarded: async (ctx) => {
1354
+ await webhookHooks.onCoexistenceAccountOffboarded?.({
1355
+ ...ctx,
1356
+ ...pluginRuntime.context
1357
+ });
1358
+ },
1359
+ onCoexistenceAccountReconnected: async (ctx) => {
1360
+ await webhookHooks.onCoexistenceAccountReconnected?.({
1361
+ ...ctx,
1362
+ ...pluginRuntime.context
1363
+ });
1364
+ },
1365
+ onCoexistenceMessageEdit: async (ctx) => {
1366
+ await webhookHooks.onCoexistenceMessageEdit?.({
1367
+ ...ctx,
1368
+ ...pluginRuntime.context
1369
+ });
1370
+ },
1371
+ onCoexistenceMessageRevoke: async (ctx) => {
1372
+ await webhookHooks.onCoexistenceMessageRevoke?.({
1373
+ ...ctx,
1374
+ ...pluginRuntime.context
1375
+ });
1376
+ },
1377
+ onCoexistenceUnsupportedMessage: async (ctx) => {
1378
+ await webhookHooks.onCoexistenceUnsupportedMessage?.({
1379
+ ...ctx,
1380
+ ...pluginRuntime.context
1381
+ });
1382
+ }
1383
+ });
1384
+ const app = new hono.Hono().basePath(basePath);
1385
+ app.use("*", async (c, next) => {
1386
+ c.set("whatsapp", whatsapp);
1387
+ c.set("store", database.whatsappLog);
1388
+ c.set("logger", log);
1389
+ c.set("coexistence", coexistence);
1390
+ c.set("coexistenceStore", database.coexistence);
1391
+ c.set("subscribeWabaAfterCodeExchange", options.coexistence?.subscribeWabaAfterCodeExchange ?? true);
1392
+ await next();
1393
+ });
1394
+ app.route("/webhook", webhookRouter);
1395
+ if (options.authorizeAppRequest) app.use("*", async (c, next) => {
1396
+ try {
1397
+ if (!await options.authorizeAppRequest?.({
1398
+ request: c.req.raw,
1399
+ env: c.env
1400
+ })) return c.json({ error: "Unauthorized" }, 401);
1401
+ await next();
1402
+ } catch (error) {
1403
+ log.error("app_api.authorization_failed", (0, better_zap.serializeError)(error));
1404
+ return c.json({ error: "Authorization failed" }, 500);
1405
+ }
1406
+ });
1407
+ app.post("/send/text", handleSendText);
1408
+ app.post("/send/template", createSendTemplateHandler(templates));
1409
+ app.post("/send/interactive", handleSendInteractive);
1410
+ app.post("/send/location", handleSendLocation);
1411
+ app.get("/conversations", handleListConversations);
1412
+ app.get("/conversations/:phone", handleGetConversation);
1413
+ app.get("/conversations/:phone/messages", handleGetMessages);
1414
+ app.post("/coexistence/embedded-signup/callback", handleEmbeddedSignupCallback);
1415
+ app.get("/coexistence/phone-numbers/:phoneNumberId/status", handlePhoneStatus);
1416
+ app.post("/coexistence/phone-numbers/:phoneNumberId/sync/contacts", handleContactsSync);
1417
+ app.post("/coexistence/phone-numbers/:phoneNumberId/sync/history", handleHistorySync);
1418
+ const api = {
1419
+ send: {
1420
+ text: (to, body, opts) => whatsapp.sendText(to, body, opts),
1421
+ template: ((to, templateName, opts = {}) => {
1422
+ if (!(0, better_zap.hasConfiguredTemplates)(templates)) return whatsapp.sendTemplate(to, String(templateName), opts?.language, opts?.components, opts?.logging);
1423
+ const serializedTemplate = serializeRuntimeTemplate(templates, templateName, opts);
1424
+ return whatsapp.sendTemplate(to, String(templateName), serializedTemplate.language, serializedTemplate.components, opts.logging);
1425
+ }),
1426
+ templateRaw: (to, templateName, opts) => whatsapp.sendTemplate(to, templateName, opts?.language, opts?.components, opts?.logging),
1427
+ interactiveButtons: (to, body, buttons, opts) => whatsapp.sendInteractiveButtons(to, body, buttons, opts),
1428
+ interactiveList: (to, body, buttonLabel, sections, opts) => whatsapp.sendInteractiveList(to, body, buttonLabel, sections, opts),
1429
+ interactiveMediaCarousel: (data, opts) => whatsapp.sendInteractiveMediaCarousel(data, opts),
1430
+ location: (to, location, opts) => whatsapp.sendLocation(to, location.latitude, location.longitude, location.name, location.address, opts),
1431
+ markAsRead: (messageId) => whatsapp.markAsRead(messageId),
1432
+ reaction: (to, messageId, emoji) => whatsapp.sendReaction(to, messageId, emoji)
1433
+ },
1434
+ conversations: {
1435
+ list: async () => (0, better_zap.normalizeConversationRecords)(await database.whatsappLog.getConversations()),
1436
+ get: async (phone) => {
1437
+ const conversation = await database.whatsappLog.getConversationByPhone((0, better_zap.formatPhone)(phone));
1438
+ return conversation ? (0, better_zap.normalizeConversationRecord)(conversation) : null;
1439
+ },
1440
+ messages: async (phone, opts) => {
1441
+ const conversation = await database.whatsappLog.getConversationByPhone((0, better_zap.formatPhone)(phone));
1442
+ if (!conversation) return [];
1443
+ return await database.whatsappLog.getMessagesByConversationPaginated(conversation.id, opts?.cursor, opts?.limit);
1444
+ }
1445
+ }
1446
+ };
1447
+ const handler = async (request, env, executionCtx) => app.fetch(request, env, executionCtx);
1448
+ return {
1449
+ handler,
1450
+ api,
1451
+ services: pluginRuntime.services
1452
+ };
1453
+ }
1454
+ //#endregion
1455
+ exports.betterZap = betterZap;
1456
+ exports.createWebhookHandler = createWebhookHandler;
1457
+ exports.getMessageContent = getMessageContent;
1458
+ exports.verifyMetaWebhookSignature = verifyMetaWebhookSignature;