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