@better-zap/hono 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Hono } from "hono";
2
- import { EMPTY_TEMPLATE_REGISTRY, MessageLoggerService, WhatsAppService, createLogger, formatPhone, hasConfiguredTemplates, normalizeConversationRecord, normalizeConversationRecords, serializeError, serializeTemplateFromRegistry } from "better-zap";
2
+ import { CoexistenceService, EMPTY_TEMPLATE_REGISTRY, MessageLoggerService, WhatsAppService, createLogger, formatPhone, hasConfiguredTemplates, normalizeCoexistenceSessionEvent, normalizeConversationRecord, normalizeConversationRecords, serializeError, serializeTemplateFromRegistry } from "better-zap";
3
3
  //#region src/plugins/runtime.ts
4
4
  function initializePlugins(options) {
5
5
  let pluginContext = {};
@@ -70,6 +70,409 @@ async function runPluginStatusHooks(options) {
70
70
  }
71
71
  }
72
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
73
476
  //#region src/handler/conversations.ts
74
477
  async function handleListConversations(c) {
75
478
  try {
@@ -340,9 +743,51 @@ async function processPayload(payload, env, config, log) {
340
743
  async function processEntry(entry, env, config, log) {
341
744
  for (const change of entry.changes) await processChange(change, env, config, log);
342
745
  }
343
- /** Routes messages, statuses, and errors to the appropriate handler. */
746
+ /** Routes webhook changes to field-specific processors. */
344
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) {
345
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
+ }
346
791
  if (value.messages && value.messages.length > 0) for (const message of value.messages) await processIncomingMessage(message, resolveContact(value.contacts, message), config, log);
347
792
  if (value.statuses && value.statuses.length > 0) for (const status of value.statuses) await processStatusUpdate(status, config, log);
348
793
  if (value.errors && value.errors.length > 0) {
@@ -359,12 +804,345 @@ async function processChange(change, env, config, log) {
359
804
  }
360
805
  }
361
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
+ }
362
1141
  /**
363
1142
  * Processes a single incoming message:
364
- * 1. Deduplicates by waMessageId
365
- * 2. Extracts human-readable content
366
- * 3. Logs the message for audit trail
367
- * 4. Calls {@link WebhookConfig.onMessage}
1143
+ * 1. Extracts human-readable content
1144
+ * 2. Atomically logs and deduplicates by waMessageId
1145
+ * 3. Calls {@link WebhookConfig.onMessage}
368
1146
  */
369
1147
  async function processIncomingMessage(message, contact, config, log) {
370
1148
  const phone = message.from;
@@ -373,25 +1151,24 @@ async function processIncomingMessage(message, contact, config, log) {
373
1151
  phone,
374
1152
  messageType: message.type
375
1153
  });
376
- if (await config.logger.isDuplicate(message.id)) {
377
- log.info("webhook.duplicate_ignored", {
378
- waMessageId: message.id,
379
- phone
380
- });
381
- return;
382
- }
383
1154
  const content = getMessageContent(message);
384
1155
  const sentAt = /* @__PURE__ */ new Date(parseInt(message.timestamp, 10) * 1e3);
385
1156
  const normalizedSentAt = Number.isNaN(sentAt.getTime()) ? (/* @__PURE__ */ new Date()).toISOString() : sentAt.toISOString();
386
1157
  const { id, type, text, from, timestamp, ...rawMetadata } = message;
387
- await config.logger.logIncoming({
1158
+ if (!await config.logger.logIncoming({
388
1159
  phone,
389
1160
  waMessageId: message.id,
390
1161
  content,
391
1162
  sentAt: normalizedSentAt,
392
1163
  senderName: contact?.profile?.name,
393
1164
  metadata: Object.keys(rawMetadata).length > 0 ? rawMetadata : void 0
394
- });
1165
+ })) {
1166
+ log.info("webhook.duplicate_ignored", {
1167
+ waMessageId: message.id,
1168
+ phone
1169
+ });
1170
+ return;
1171
+ }
395
1172
  const ctx = {
396
1173
  message,
397
1174
  contact,
@@ -445,6 +1222,23 @@ function resolveContact(contacts, message) {
445
1222
  if (!contacts || contacts.length === 0) return;
446
1223
  return contacts.find((c) => c.wa_id === message.from) ?? contacts[0];
447
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
+ }
448
1242
  //#endregion
449
1243
  //#region src/internal/cloudflare/constants.ts
450
1244
  const GLOBAL_WORKSPACE_DO_ID = "global-workspace";
@@ -474,6 +1268,15 @@ function betterZap(options) {
474
1268
  const log = createLogger(options.logger);
475
1269
  const logger = new MessageLoggerService(database.whatsappLog, log, createConversationSyncNotifier(conversationSync));
476
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;
477
1280
  const coreContext = {
478
1281
  db: database,
479
1282
  api: whatsapp,
@@ -497,6 +1300,7 @@ function betterZap(options) {
497
1300
  appSecret: config.appSecret,
498
1301
  logger,
499
1302
  log,
1303
+ database,
500
1304
  onMessage: async (ctx) => {
501
1305
  const hookContext = {
502
1306
  ...ctx,
@@ -520,6 +1324,60 @@ function betterZap(options) {
520
1324
  log
521
1325
  });
522
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
+ });
523
1381
  }
524
1382
  });
525
1383
  const app = new Hono().basePath(basePath);
@@ -527,9 +1385,24 @@ function betterZap(options) {
527
1385
  c.set("whatsapp", whatsapp);
528
1386
  c.set("store", database.whatsappLog);
529
1387
  c.set("logger", log);
1388
+ c.set("coexistence", coexistence);
1389
+ c.set("coexistenceStore", database.coexistence);
1390
+ c.set("subscribeWabaAfterCodeExchange", options.coexistence?.subscribeWabaAfterCodeExchange ?? true);
530
1391
  await next();
531
1392
  });
532
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
+ });
533
1406
  app.post("/send/text", handleSendText);
534
1407
  app.post("/send/template", createSendTemplateHandler(templates));
535
1408
  app.post("/send/interactive", handleSendInteractive);
@@ -537,6 +1410,10 @@ function betterZap(options) {
537
1410
  app.get("/conversations", handleListConversations);
538
1411
  app.get("/conversations/:phone", handleGetConversation);
539
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);
540
1417
  const api = {
541
1418
  send: {
542
1419
  text: (to, body, opts) => whatsapp.sendText(to, body, opts),