@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.cjs CHANGED
@@ -71,6 +71,409 @@ async function runPluginStatusHooks(options) {
71
71
  }
72
72
  }
73
73
  //#endregion
74
+ //#region src/handler/coexistence.ts
75
+ const ROUTES_NOT_CONFIGURED = "Coexistence routes are not configured";
76
+ const STORAGE_NOT_CONFIGURED = "Coexistence storage is not configured";
77
+ const SYNC_DEADLINE_MS = 1440 * 60 * 1e3;
78
+ const PREFLIGHT_FAILURE_FIELDS = [
79
+ ["unsupportedCountry", "unsupported_country"],
80
+ ["unsupportedAppVersion", "unsupported_app_version"],
81
+ ["lowActivityNumber", "low_activity_number"],
82
+ ["priorProviderWabaRegistration", "prior_provider_waba_registration"],
83
+ ["missingPaymentSetup", "missing_payment_setup"]
84
+ ];
85
+ function getCoexistence(c) {
86
+ return c.get("coexistence");
87
+ }
88
+ function getCoexistenceStore(c) {
89
+ return c.get("coexistenceStore");
90
+ }
91
+ function shouldSubscribeWabaAfterCodeExchange(c) {
92
+ return c.get("subscribeWabaAfterCodeExchange") !== false;
93
+ }
94
+ function graphResultResponse(c, result) {
95
+ if (result.success) return c.json(result.data ?? { success: true });
96
+ return c.json({
97
+ success: false,
98
+ error: result.error ?? "Meta Graph request failed",
99
+ code: "meta_graph_request_failed",
100
+ ...result.errorCode ? { errorCode: result.errorCode } : {},
101
+ ...result.details ? { details: result.details } : {}
102
+ }, result.httpStatus ?? 502);
103
+ }
104
+ function isSessionPayload(value) {
105
+ return typeof value === "object" && value !== null && "event" in value && typeof value.event === "string";
106
+ }
107
+ function resolveSessionPayload(body) {
108
+ if (isSessionPayload(body.session)) return body.session;
109
+ if (isSessionPayload(body.sessionInfo)) return body.sessionInfo;
110
+ if (isSessionPayload(body)) return body;
111
+ if (typeof body.data === "object" && body.data !== null) return {
112
+ event: "FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING",
113
+ data: body.data
114
+ };
115
+ return null;
116
+ }
117
+ function resolveCode(body, session) {
118
+ if (typeof body.code === "string" && body.code.length > 0) return body.code;
119
+ if (typeof session?.data?.code === "string" && session.data.code.length > 0) return session.data.code;
120
+ return null;
121
+ }
122
+ function createRecordId(prefix) {
123
+ return `${prefix}_${crypto.randomUUID()}`;
124
+ }
125
+ function optionalString(value) {
126
+ return typeof value === "string" && value.length > 0 ? value : void 0;
127
+ }
128
+ function optionalNumber(value) {
129
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
130
+ }
131
+ function resolveIdempotencyKey(c, body) {
132
+ return optionalString(body.idempotencyKey) ?? optionalString(c.req.header("Idempotency-Key")) ?? optionalString(c.req.header("X-Idempotency-Key"));
133
+ }
134
+ function validateStateAndNonce(body, session) {
135
+ const state = optionalString(body.state);
136
+ const expectedState = optionalString(body.expectedState) ?? optionalString(session.data?.state);
137
+ if (expectedState && state !== expectedState) return "state mismatch";
138
+ const nonce = optionalString(body.nonce);
139
+ const expectedNonce = optionalString(body.expectedNonce) ?? optionalString(session.data?.nonce);
140
+ if (expectedNonce && nonce !== expectedNonce) return "nonce mismatch";
141
+ return null;
142
+ }
143
+ async function recordOnboardingSession(c, input) {
144
+ await getCoexistenceStore(c)?.recordOnboardingSession({
145
+ id: input.idempotencyKey ?? createRecordId("coexistence_session"),
146
+ event: input.session.event,
147
+ accountId: input.session.data?.business_id,
148
+ wabaId: input.session.data?.waba_id,
149
+ phoneNumberId: input.session.data?.phone_number_id,
150
+ payload: {
151
+ ...input.session,
152
+ data: {
153
+ ...input.session.data,
154
+ ...input.state ? { state: input.state } : {},
155
+ ...input.nonce ? { nonce: input.nonce } : {},
156
+ ...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}
157
+ }
158
+ },
159
+ ...input.preflight ? { preflight: input.preflight } : {}
160
+ });
161
+ }
162
+ function isObject(value) {
163
+ return typeof value === "object" && value !== null;
164
+ }
165
+ function boolField(value, key) {
166
+ return typeof value[key] === "boolean" ? value[key] : void 0;
167
+ }
168
+ function stringField(value, key) {
169
+ return typeof value[key] === "string" ? value[key] : void 0;
170
+ }
171
+ function failureCodesField(value) {
172
+ if (!Array.isArray(value?.failureCodes)) return;
173
+ return value.failureCodes.filter((code) => typeof code === "string");
174
+ }
175
+ function resolvePreflightState(body, session) {
176
+ const source = isObject(body.preflight) ? body.preflight : isObject(body.eligibility) ? body.eligibility : void 0;
177
+ const billing = isObject(body.billing) ? body.billing : void 0;
178
+ if (!source && !billing) return;
179
+ const state = {
180
+ phoneNumberId: stringField(source ?? {}, "phoneNumberId") ?? session?.data?.phone_number_id,
181
+ wabaId: stringField(source ?? {}, "wabaId") ?? session?.data?.waba_id,
182
+ displayPhoneNumber: stringField(source ?? {}, "displayPhoneNumber") ?? session?.data?.display_phone_number,
183
+ eligibilityStatus: stringField(source ?? {}, "eligibilityStatus"),
184
+ billingStatus: stringField(source ?? {}, "billingStatus") ?? stringField(billing ?? {}, "status"),
185
+ unsupportedCountry: boolField(source ?? {}, "unsupportedCountry"),
186
+ unsupportedAppVersion: boolField(source ?? {}, "unsupportedAppVersion"),
187
+ lowActivityNumber: boolField(source ?? {}, "lowActivityNumber"),
188
+ priorProviderWabaRegistration: boolField(source ?? {}, "priorProviderWabaRegistration"),
189
+ missingPaymentSetup: boolField(source ?? {}, "missingPaymentSetup") ?? boolField(billing ?? {}, "missingPaymentSetup") ?? (stringField(billing ?? {}, "status") === "missing_payment_setup" ? true : void 0),
190
+ failureCodes: failureCodesField(source),
191
+ metadata: {
192
+ ...isObject(source?.metadata) ? { eligibility: source.metadata } : {},
193
+ ...isObject(billing) ? { billing } : {}
194
+ }
195
+ };
196
+ state.failureCodes = getPreflightFailureCodes(state);
197
+ return state;
198
+ }
199
+ function getPreflightFailureCodes(state) {
200
+ const explicit = Array.isArray(state.failureCodes) ? state.failureCodes.filter((code) => typeof code === "string") : [];
201
+ const derived = PREFLIGHT_FAILURE_FIELDS.flatMap(([field, code]) => state[field] ? [code] : []);
202
+ return [...new Set([...explicit, ...derived])];
203
+ }
204
+ function preflightFailureResponse(c, failureCodes) {
205
+ return c.json({
206
+ success: false,
207
+ error: "Coexistence preflight failed",
208
+ code: "coexistence_preflight_failed",
209
+ failureCodes
210
+ }, 422);
211
+ }
212
+ async function handleEmbeddedSignupCallback(c) {
213
+ const coexistence = getCoexistence(c);
214
+ if (!coexistence) return c.json({ error: ROUTES_NOT_CONFIGURED }, 501);
215
+ const coexistenceStore = getCoexistenceStore(c);
216
+ if (!coexistenceStore) return c.json({ error: STORAGE_NOT_CONFIGURED }, 501);
217
+ try {
218
+ const body = await c.req.json();
219
+ const session = resolveSessionPayload(body);
220
+ if (!session) return c.json({ error: "session is required" }, 400);
221
+ const stateError = validateStateAndNonce(body, session);
222
+ if (stateError) return c.json({ error: stateError }, 400);
223
+ const normalizedEvent = (0, better_zap.normalizeCoexistenceSessionEvent)(session.event);
224
+ const code = resolveCode(body, session);
225
+ const idempotencyKey = resolveIdempotencyKey(c, body);
226
+ const state = optionalString(body.state);
227
+ const nonce = optionalString(body.nonce);
228
+ const preflight = resolvePreflightState(body, session);
229
+ if (idempotencyKey && coexistenceStore.getRawEventStatus) {
230
+ const existing = await coexistenceStore.getRawEventStatus(idempotencyKey);
231
+ if (existing?.status === "processed") return c.json({
232
+ success: true,
233
+ status: "duplicate",
234
+ idempotencyKey,
235
+ result: existing.result ?? null
236
+ });
237
+ }
238
+ await recordOnboardingSession(c, {
239
+ session,
240
+ idempotencyKey,
241
+ state,
242
+ nonce,
243
+ ...preflight ? { preflight } : {}
244
+ });
245
+ if (preflight) {
246
+ await coexistenceStore.upsertPreflightState?.(preflight);
247
+ const failureCodes = getPreflightFailureCodes(preflight);
248
+ if (failureCodes.length > 0) return preflightFailureResponse(c, failureCodes);
249
+ }
250
+ if (normalizedEvent !== "FINISH") return c.json({
251
+ success: true,
252
+ status: "recorded",
253
+ event: normalizedEvent,
254
+ session
255
+ });
256
+ if (!code) return c.json({ error: "code is required" }, 400);
257
+ const tokenExchange = await coexistence.exchangeEmbeddedSignupCode({
258
+ code,
259
+ redirectUri: typeof body.redirectUri === "string" ? body.redirectUri : void 0
260
+ });
261
+ if (!tokenExchange.success) {
262
+ if (idempotencyKey) await coexistenceStore.updateRawEventStatus?.({
263
+ id: idempotencyKey,
264
+ status: "failed",
265
+ error: tokenExchange.error ?? "Meta Graph request failed"
266
+ });
267
+ return graphResultResponse(c, tokenExchange);
268
+ }
269
+ if (!tokenExchange.data) {
270
+ if (idempotencyKey) await coexistenceStore.updateRawEventStatus?.({
271
+ id: idempotencyKey,
272
+ status: "failed",
273
+ error: "Token exchange returned no credential data"
274
+ });
275
+ return c.json({
276
+ success: false,
277
+ error: "Token exchange returned no credential data"
278
+ }, 502);
279
+ }
280
+ let subscriptionStatus = "not_requested";
281
+ let subscriptionResult;
282
+ if (session.data?.waba_id) if (shouldSubscribeWabaAfterCodeExchange(c)) {
283
+ subscriptionResult = await coexistence.subscribeWaba({
284
+ wabaId: session.data.waba_id,
285
+ accessToken: tokenExchange.data.accessToken
286
+ });
287
+ subscriptionStatus = subscriptionResult.success ? "subscribed" : "failed";
288
+ } else subscriptionStatus = "skipped";
289
+ if (session.data?.waba_id && session.data.phone_number_id) await coexistenceStore.upsertConnectedAccount({
290
+ wabaId: session.data.waba_id,
291
+ businessId: session.data.business_id,
292
+ accountId: session.data.business_id,
293
+ phoneNumberId: session.data.phone_number_id,
294
+ displayPhoneNumber: session.data.display_phone_number,
295
+ credentialRef: tokenExchange.data.credentialRef,
296
+ credentialProvider: tokenExchange.data.credentialProvider,
297
+ credentialMetadata: tokenExchange.data.credentialMetadata,
298
+ ...preflight ? { preflight } : {},
299
+ metadata: {
300
+ onboardingEvent: session.event,
301
+ tokenType: tokenExchange.data.tokenType,
302
+ tokenExpiresIn: optionalNumber(tokenExchange.data.expiresIn),
303
+ subscriptionStatus,
304
+ ...subscriptionResult ? { subscription: {
305
+ success: subscriptionResult.success,
306
+ error: subscriptionResult.error,
307
+ errorCode: subscriptionResult.errorCode,
308
+ httpStatus: subscriptionResult.httpStatus,
309
+ details: subscriptionResult.details
310
+ } } : {}
311
+ }
312
+ });
313
+ if (subscriptionResult && !subscriptionResult.success) {
314
+ await coexistenceStore.recordLifecycleEvent({
315
+ accountId: session.data?.business_id,
316
+ wabaId: session.data?.waba_id,
317
+ phoneNumberId: session.data?.phone_number_id,
318
+ event: "WABA_SUBSCRIPTION_FAILED",
319
+ payload: {
320
+ session,
321
+ error: subscriptionResult.error,
322
+ errorCode: subscriptionResult.errorCode,
323
+ httpStatus: subscriptionResult.httpStatus,
324
+ details: subscriptionResult.details
325
+ }
326
+ });
327
+ if (idempotencyKey) await coexistenceStore.updateRawEventStatus?.({
328
+ id: idempotencyKey,
329
+ status: "failed",
330
+ error: subscriptionResult.error ?? "Meta Graph request failed",
331
+ result: {
332
+ phase: "subscribe_waba",
333
+ wabaId: session.data?.waba_id,
334
+ error: subscriptionResult.error,
335
+ errorCode: subscriptionResult.errorCode,
336
+ details: subscriptionResult.details
337
+ }
338
+ });
339
+ return c.json({
340
+ success: false,
341
+ phase: "subscribe_waba",
342
+ error: subscriptionResult.error ?? "Meta Graph request failed",
343
+ ...subscriptionResult.errorCode ? { errorCode: subscriptionResult.errorCode } : {},
344
+ ...subscriptionResult.details ? { details: subscriptionResult.details } : {}
345
+ }, subscriptionResult.httpStatus ?? 502);
346
+ }
347
+ const responseBody = {
348
+ success: true,
349
+ status: "exchanged",
350
+ session,
351
+ subscriptionStatus
352
+ };
353
+ if (idempotencyKey) await coexistenceStore.updateRawEventStatus?.({
354
+ id: idempotencyKey,
355
+ status: "processed",
356
+ result: responseBody
357
+ });
358
+ return c.json(responseBody);
359
+ } catch (error) {
360
+ c.get("logger").error("coexistence.callback_error", (0, better_zap.serializeError)(error));
361
+ return c.json({
362
+ error: "Internal error handling coexistence callback",
363
+ code: "coexistence_callback_failed"
364
+ }, 500);
365
+ }
366
+ }
367
+ async function handlePhoneStatus(c) {
368
+ const coexistence = getCoexistence(c);
369
+ if (!coexistence) return c.json({ error: ROUTES_NOT_CONFIGURED }, 501);
370
+ try {
371
+ const phoneNumberId = c.req.param("phoneNumberId");
372
+ if (!phoneNumberId) return c.json({ error: "phoneNumberId is required" }, 400);
373
+ return graphResultResponse(c, await coexistence.getPhoneStatus({ phoneNumberId }));
374
+ } catch (error) {
375
+ c.get("logger").error("coexistence.status_error", (0, better_zap.serializeError)(error));
376
+ return c.json({
377
+ error: "Internal error fetching coexistence phone status",
378
+ code: "coexistence_status_failed"
379
+ }, 500);
380
+ }
381
+ }
382
+ async function handleSyncRequest(c, syncType) {
383
+ const coexistence = getCoexistence(c);
384
+ if (!coexistence) return c.json({ error: ROUTES_NOT_CONFIGURED }, 501);
385
+ try {
386
+ const phoneNumberId = c.req.param("phoneNumberId");
387
+ if (!phoneNumberId) return c.json({ error: "phoneNumberId is required" }, 400);
388
+ const blocked = await getBlockedConnectedAccount(c, phoneNumberId);
389
+ if (blocked) return c.json({
390
+ success: false,
391
+ error: "Coexistence account is not usable",
392
+ code: "coexistence_account_not_usable",
393
+ status: blocked.status,
394
+ phoneNumberId
395
+ }, 409);
396
+ const body = await resolveOptionalJsonBody(c);
397
+ const coexistenceStore = getCoexistenceStore(c);
398
+ const inFlight = await coexistenceStore?.getInFlightSyncJob?.({
399
+ phoneNumberId,
400
+ syncType
401
+ });
402
+ if (inFlight) return c.json({
403
+ success: false,
404
+ error: "Coexistence sync already in flight",
405
+ code: "sync_already_in_flight",
406
+ requestId: inFlight.requestId,
407
+ deadlineAt: inFlight.deadlineAt
408
+ }, 409);
409
+ const preflight = await coexistenceStore?.getPreflightStateByPhoneNumberId?.(phoneNumberId) ?? resolveInlinePreflightState(body, phoneNumberId);
410
+ const failureCodes = preflight ? getPreflightFailureCodes(preflight) : [];
411
+ if (failureCodes.length > 0) return preflightFailureResponse(c, failureCodes);
412
+ const result = syncType === "smb_app_state_sync" ? await coexistence.startContactsSync({ phoneNumberId }) : await coexistence.startHistorySync({ phoneNumberId });
413
+ if (!result.success) return graphResultResponse(c, result);
414
+ await recordSyncJob(c, {
415
+ phoneNumberId,
416
+ syncType,
417
+ result: result.data,
418
+ onboardingSessionId: typeof body?.onboardingSessionId === "string" ? body.onboardingSessionId : void 0
419
+ });
420
+ return graphResultResponse(c, result);
421
+ } catch (error) {
422
+ c.get("logger").error("coexistence.sync_error", (0, better_zap.serializeError)(error));
423
+ return c.json({
424
+ error: "Internal error requesting coexistence sync",
425
+ code: "coexistence_sync_failed"
426
+ }, 500);
427
+ }
428
+ }
429
+ async function getBlockedConnectedAccount(c, phoneNumberId) {
430
+ const coexistenceStore = getCoexistenceStore(c);
431
+ if (!coexistenceStore) return null;
432
+ const account = await coexistenceStore.getConnectedAccountByPhoneNumberId(phoneNumberId);
433
+ if (!account) return null;
434
+ return account.usable === false || account.status === "offboarded" ? account : null;
435
+ }
436
+ async function recordSyncJob(c, input) {
437
+ const requestId = input.result?.request_id;
438
+ const coexistenceStore = getCoexistenceStore(c);
439
+ if (!requestId || !coexistenceStore) return;
440
+ const requestedAt = /* @__PURE__ */ new Date();
441
+ const deadlineAt = input.syncType === "history" ? new Date(requestedAt.getTime() + SYNC_DEADLINE_MS) : void 0;
442
+ await coexistenceStore.createSyncJob({
443
+ requestId,
444
+ syncType: input.syncType,
445
+ onboardingSessionId: input.onboardingSessionId,
446
+ phoneNumberId: input.phoneNumberId,
447
+ status: "requested",
448
+ requestedAt,
449
+ deadlineAt,
450
+ metadata: { response: input.result }
451
+ });
452
+ }
453
+ async function resolveOptionalJsonBody(c) {
454
+ if (!(c.req.header("content-type") ?? "").includes("application/json")) return;
455
+ try {
456
+ return await c.req.json();
457
+ } catch {
458
+ return;
459
+ }
460
+ }
461
+ function resolveInlinePreflightState(body, phoneNumberId) {
462
+ if (!body) return;
463
+ const state = resolvePreflightState(body, null);
464
+ if (!state) return;
465
+ return {
466
+ ...state,
467
+ phoneNumberId: state.phoneNumberId ?? phoneNumberId
468
+ };
469
+ }
470
+ function handleContactsSync(c) {
471
+ return handleSyncRequest(c, "smb_app_state_sync");
472
+ }
473
+ function handleHistorySync(c) {
474
+ return handleSyncRequest(c, "history");
475
+ }
476
+ //#endregion
74
477
  //#region src/handler/conversations.ts
75
478
  async function handleListConversations(c) {
76
479
  try {
@@ -341,9 +744,51 @@ async function processPayload(payload, env, config, log) {
341
744
  async function processEntry(entry, env, config, log) {
342
745
  for (const change of entry.changes) await processChange(change, env, config, log);
343
746
  }
344
- /** Routes messages, statuses, and errors to the appropriate handler. */
747
+ /** Routes webhook changes to field-specific processors. */
345
748
  async function processChange(change, env, config, log) {
749
+ switch (change.field) {
750
+ case "messages":
751
+ await processMessagesChange(change, env, config, log);
752
+ return;
753
+ case "history":
754
+ await processHistoryChange(change, config, log);
755
+ return;
756
+ case "smb_app_state_sync":
757
+ await processSmbAppStateSyncChange(change, config, log);
758
+ return;
759
+ case "smb_message_echoes":
760
+ await processSmbMessageEchoesChange(change, config, log);
761
+ return;
762
+ case "account_update":
763
+ await processAccountUpdateChange(change, config, log);
764
+ return;
765
+ case "account_offboarded":
766
+ await processAccountOffboardedChange(change, config, log);
767
+ return;
768
+ case "account_reconnected":
769
+ await processAccountReconnectedChange(change, config, log);
770
+ return;
771
+ default:
772
+ log.debug("webhook.unknown_field_ignored", { field: change.field });
773
+ return;
774
+ }
775
+ }
776
+ /** Routes ordinary messages, statuses, and errors to the existing handlers. */
777
+ async function processMessagesChange(change, _env, config, log) {
346
778
  const value = change.value;
779
+ const messageClassification = classifyCoexistenceMessages(value.messages);
780
+ if (messageClassification === "edit") {
781
+ await processMessageEditChange(change, config, log);
782
+ return;
783
+ }
784
+ if (messageClassification === "revoke") {
785
+ await processMessageRevokeChange(change, config, log);
786
+ return;
787
+ }
788
+ if (messageClassification === "unsupported") {
789
+ await processUnsupportedMessagesChange(change, config, log);
790
+ return;
791
+ }
347
792
  if (value.messages && value.messages.length > 0) for (const message of value.messages) await processIncomingMessage(message, resolveContact(value.contacts, message), config, log);
348
793
  if (value.statuses && value.statuses.length > 0) for (const status of value.statuses) await processStatusUpdate(status, config, log);
349
794
  if (value.errors && value.errors.length > 0) {
@@ -360,12 +805,345 @@ async function processChange(change, env, config, log) {
360
805
  }
361
806
  }
362
807
  }
808
+ async function processHistoryChange(change, config, log) {
809
+ const value = change.value;
810
+ const requestId = value.request_id;
811
+ let importedMessages = 0;
812
+ let duplicateMessages = 0;
813
+ let hasHistoryErrors = (value.errors?.length ?? 0) > 0;
814
+ if (requestId && config.database?.coexistence) await config.database.coexistence.updateSyncJobByRequestId(requestId, {
815
+ status: "processing",
816
+ metadata: { field: "history" }
817
+ });
818
+ if (value.errors && value.errors.length > 0) await processCoexistenceErrors(change, value, config, log);
819
+ for (const chunk of value.history ?? []) {
820
+ if (chunk.errors && chunk.errors.length > 0) {
821
+ hasHistoryErrors = true;
822
+ await processCoexistenceErrors(change, {
823
+ ...value,
824
+ errors: chunk.errors
825
+ }, config, log);
826
+ }
827
+ for (const message of chunk.messages ?? []) {
828
+ if (message.revoked || message.type === "revoked") {
829
+ await runOptionalHook(() => config.onCoexistenceMessageRevoke?.({
830
+ value: {
831
+ ...value,
832
+ messages: [message]
833
+ },
834
+ change,
835
+ revokedMessages: 1
836
+ }), "webhook.on_coexistence_message_revoke_hook_failed", log);
837
+ continue;
838
+ }
839
+ if (message.edited || message.type === "message_edit") {
840
+ await runOptionalHook(() => config.onCoexistenceMessageEdit?.({
841
+ value: {
842
+ ...value,
843
+ messages: [message]
844
+ },
845
+ change,
846
+ editedMessages: 1
847
+ }), "webhook.on_coexistence_message_edit_hook_failed", log);
848
+ continue;
849
+ }
850
+ if (message.unsupported || message.type === "unsupported" || (message.errors?.length ?? 0) > 0) {
851
+ await processCoexistenceErrors(change, {
852
+ ...value,
853
+ unsupported: true,
854
+ errors: (message.errors ?? []).map(toWebhookError)
855
+ }, config, log);
856
+ continue;
857
+ }
858
+ const result = await importCoexistenceMessage({
859
+ message,
860
+ contacts: value.contacts,
861
+ metadata: value.metadata,
862
+ requestId,
863
+ source: "history",
864
+ config
865
+ });
866
+ if (result === "created") importedMessages += 1;
867
+ else if (result === "duplicate") duplicateMessages += 1;
868
+ }
869
+ for (const status of chunk.statuses ?? []) await processStatusUpdate(status, config, log);
870
+ }
871
+ if (requestId && config.database?.coexistence) {
872
+ const updatedAt = /* @__PURE__ */ new Date();
873
+ await config.database.coexistence.updateSyncJobByRequestId(requestId, {
874
+ status: hasHistoryErrors ? "failed" : "completed",
875
+ ...hasHistoryErrors ? { failedAt: updatedAt } : { completedAt: updatedAt },
876
+ updatedAt,
877
+ metadata: {
878
+ field: "history",
879
+ importedMessages,
880
+ duplicateMessages
881
+ }
882
+ });
883
+ }
884
+ await runOptionalHook(() => config.onCoexistenceHistory?.({
885
+ value,
886
+ change,
887
+ importedMessages,
888
+ duplicateMessages
889
+ }), "webhook.on_coexistence_history_hook_failed", log);
890
+ }
891
+ async function processSmbAppStateSyncChange(change, config, log) {
892
+ const value = change.value;
893
+ let upsertedContacts = 0;
894
+ let removedContacts = 0;
895
+ const hasSyncErrors = (value.errors?.length ?? 0) > 0;
896
+ if (value.request_id && config.database?.coexistence) await config.database.coexistence.updateSyncJobByRequestId(value.request_id, {
897
+ status: "processing",
898
+ metadata: { field: "smb_app_state_sync" }
899
+ });
900
+ if (hasSyncErrors) await processCoexistenceErrors(change, value, config, log);
901
+ if (!hasSyncErrors && config.database?.coexistence) for (const contact of value.contacts ?? []) {
902
+ if (contact.removed) {
903
+ await config.database.coexistence.removeContact({
904
+ waId: contact.wa_id,
905
+ phoneNumberId: value.metadata?.phone_number_id
906
+ });
907
+ removedContacts += 1;
908
+ continue;
909
+ }
910
+ await config.database.coexistence.upsertContact({
911
+ waId: contact.wa_id,
912
+ phoneNumberId: value.metadata?.phone_number_id,
913
+ displayName: contact.profile?.name,
914
+ removed: false,
915
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
916
+ metadata: contact
917
+ });
918
+ upsertedContacts += 1;
919
+ }
920
+ if (value.request_id && config.database?.coexistence) {
921
+ const updatedAt = /* @__PURE__ */ new Date();
922
+ await config.database.coexistence.updateSyncJobByRequestId(value.request_id, {
923
+ status: hasSyncErrors ? "failed" : "completed",
924
+ ...hasSyncErrors ? { failedAt: updatedAt } : { completedAt: updatedAt },
925
+ updatedAt,
926
+ metadata: {
927
+ field: "smb_app_state_sync",
928
+ upsertedContacts,
929
+ removedContacts,
930
+ errors: value.errors
931
+ }
932
+ });
933
+ }
934
+ await runOptionalHook(() => config.onSmbAppStateSync?.({
935
+ value,
936
+ change,
937
+ upsertedContacts,
938
+ removedContacts
939
+ }), "webhook.on_smb_app_state_sync_hook_failed", log);
940
+ }
941
+ async function processMessageEditChange(change, config, log) {
942
+ const value = change.value;
943
+ const editedMessages = value.messages.length;
944
+ await runOptionalHook(() => config.onCoexistenceMessageEdit?.({
945
+ value,
946
+ change,
947
+ editedMessages
948
+ }), "webhook.on_coexistence_message_edit_hook_failed", log);
949
+ }
950
+ async function processMessageRevokeChange(change, config, log) {
951
+ const value = change.value;
952
+ const revokedMessages = value.messages.length;
953
+ await runOptionalHook(() => config.onCoexistenceMessageRevoke?.({
954
+ value,
955
+ change,
956
+ revokedMessages
957
+ }), "webhook.on_coexistence_message_revoke_hook_failed", log);
958
+ }
959
+ async function processUnsupportedMessagesChange(change, config, log) {
960
+ const value = change.value;
961
+ const errors = [...value.errors ?? [], ...(value.messages ?? []).flatMap((message) => (message.errors ?? []).map(toWebhookError))];
962
+ await runOptionalHook(() => config.onCoexistenceUnsupportedMessage?.({
963
+ value,
964
+ change,
965
+ errors
966
+ }), "webhook.on_coexistence_unsupported_hook_failed", log);
967
+ }
968
+ async function processSmbMessageEchoesChange(change, config, log) {
969
+ const value = change.value;
970
+ let importedMessages = 0;
971
+ let duplicateMessages = 0;
972
+ for (const message of value.messages ?? []) {
973
+ const result = await importCoexistenceMessage({
974
+ message,
975
+ contacts: value.contacts,
976
+ metadata: value.metadata,
977
+ source: "smb_message_echoes",
978
+ forceDirection: "outgoing",
979
+ config
980
+ });
981
+ if (result === "created") importedMessages += 1;
982
+ else if (result === "duplicate") duplicateMessages += 1;
983
+ }
984
+ await runOptionalHook(() => config.onSmbMessageEcho?.({
985
+ value,
986
+ change,
987
+ importedMessages,
988
+ duplicateMessages
989
+ }), "webhook.on_smb_message_echo_hook_failed", log);
990
+ }
991
+ async function processAccountUpdateChange(change, config, log) {
992
+ const value = change.value;
993
+ if (config.database?.coexistence) await config.database.coexistence.recordLifecycleEvent({
994
+ wabaId: value.waba_info?.waba_id,
995
+ phoneNumberId: value.phone_number_id,
996
+ accountId: value.waba_info?.owner_business_id,
997
+ event: value.event ?? "account_update",
998
+ payload: value,
999
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
1000
+ });
1001
+ await runOptionalHook(() => config.onCoexistenceAccountUpdate?.({
1002
+ value,
1003
+ change
1004
+ }), "webhook.on_coexistence_account_update_hook_failed", log);
1005
+ }
1006
+ async function processAccountOffboardedChange(change, config, log) {
1007
+ const value = change.value;
1008
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1009
+ if (config.database?.coexistence) {
1010
+ await config.database.coexistence.recordLifecycleEvent({
1011
+ wabaId: value.waba_info?.waba_id,
1012
+ phoneNumberId: value.phone_number_id ?? value.metadata?.phone_number_id,
1013
+ accountId: value.waba_info?.owner_business_id,
1014
+ event: value.event ?? "ACCOUNT_OFFBOARDED",
1015
+ payload: value,
1016
+ createdAt: now
1017
+ });
1018
+ await upsertLifecycleAccountState(value, config, {
1019
+ status: "offboarded",
1020
+ usable: false,
1021
+ offboardedAt: now,
1022
+ updatedAt: now,
1023
+ metadata: {
1024
+ lifecycleEvent: "account_offboarded",
1025
+ reason: value.reason,
1026
+ raw: value
1027
+ }
1028
+ });
1029
+ }
1030
+ await runOptionalHook(() => config.onCoexistenceAccountOffboarded?.({
1031
+ value,
1032
+ change
1033
+ }), "webhook.on_coexistence_account_offboarded_hook_failed", log);
1034
+ }
1035
+ async function processAccountReconnectedChange(change, config, log) {
1036
+ const value = change.value;
1037
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1038
+ if (config.database?.coexistence) {
1039
+ await config.database.coexistence.recordLifecycleEvent({
1040
+ wabaId: value.waba_info?.waba_id,
1041
+ phoneNumberId: value.phone_number_id ?? value.metadata?.phone_number_id,
1042
+ accountId: value.waba_info?.owner_business_id,
1043
+ event: value.event ?? "ACCOUNT_RECONNECTED",
1044
+ payload: value,
1045
+ createdAt: now
1046
+ });
1047
+ await upsertLifecycleAccountState(value, config, {
1048
+ status: "reconnected",
1049
+ usable: true,
1050
+ reconnectedAt: now,
1051
+ updatedAt: now,
1052
+ metadata: {
1053
+ lifecycleEvent: "account_reconnected",
1054
+ reconnectReason: value.reconnect_reason,
1055
+ cloudApiProducts: value.cloud_api_products,
1056
+ raw: value
1057
+ }
1058
+ });
1059
+ }
1060
+ await runOptionalHook(() => config.onCoexistenceAccountReconnected?.({
1061
+ value,
1062
+ change
1063
+ }), "webhook.on_coexistence_account_reconnected_hook_failed", log);
1064
+ }
1065
+ async function processCoexistenceErrors(change, value, config, log) {
1066
+ const errors = value.errors ?? [];
1067
+ const requestId = "request_id" in value && typeof value.request_id === "string" ? value.request_id : void 0;
1068
+ if (requestId && config.database?.coexistence) await config.database.coexistence.updateSyncJobByRequestId(requestId, {
1069
+ status: "failed",
1070
+ metadata: {
1071
+ field: change.field,
1072
+ errors,
1073
+ isHistoryOptOut: errors.some((error) => error.code === 2593109)
1074
+ }
1075
+ });
1076
+ await runOptionalHook(() => config.onCoexistenceUnsupportedMessage?.({
1077
+ value,
1078
+ change,
1079
+ errors
1080
+ }), "webhook.on_coexistence_unsupported_hook_failed", log);
1081
+ }
1082
+ async function upsertLifecycleAccountState(value, config, patch) {
1083
+ const store = config.database?.coexistence;
1084
+ const phoneNumberId = value.phone_number_id ?? value.metadata?.phone_number_id;
1085
+ const wabaId = value.waba_info?.waba_id;
1086
+ if (!store || !phoneNumberId || !wabaId) return;
1087
+ const existing = await store.getConnectedAccountByPhoneNumberId(phoneNumberId) ?? await store.getConnectedAccountByWabaId(wabaId);
1088
+ await store.upsertConnectedAccount({
1089
+ wabaId,
1090
+ phoneNumberId,
1091
+ accountId: value.waba_info?.owner_business_id ?? existing?.accountId,
1092
+ businessId: existing?.businessId,
1093
+ displayPhoneNumber: value.metadata?.display_phone_number ?? existing?.displayPhoneNumber,
1094
+ ...existing,
1095
+ ...patch,
1096
+ metadata: {
1097
+ ...existing?.metadata,
1098
+ ...patch.metadata
1099
+ }
1100
+ });
1101
+ }
1102
+ async function importCoexistenceMessage(input) {
1103
+ if (!input.config.database?.coexistence) return "skipped";
1104
+ const direction = input.forceDirection ?? resolveMessageDirection(input.message, input.metadata?.display_phone_number);
1105
+ const contact = resolveCoexistenceContact(input.contacts, input.message);
1106
+ const phone = resolveConversationPhone(input.message, direction, contact);
1107
+ return await input.config.logger.logImportedMessage({
1108
+ phone,
1109
+ waMessageId: input.message.id,
1110
+ direction,
1111
+ content: getMessageContent(input.message),
1112
+ sentAt: parseWebhookTimestamp(input.message.timestamp),
1113
+ senderName: contact?.profile?.name,
1114
+ metadata: {
1115
+ source: input.source,
1116
+ requestId: input.requestId,
1117
+ phoneNumberId: input.metadata?.phone_number_id,
1118
+ raw: input.message
1119
+ }
1120
+ }) ? "created" : "duplicate";
1121
+ }
1122
+ function resolveMessageDirection(message, businessDisplayPhoneNumber) {
1123
+ if (!businessDisplayPhoneNumber) return "incoming";
1124
+ return (0, better_zap.formatPhone)(message.from) === (0, better_zap.formatPhone)(businessDisplayPhoneNumber) ? "outgoing" : "incoming";
1125
+ }
1126
+ function resolveConversationPhone(message, direction, contact) {
1127
+ if (direction === "incoming") return message.from;
1128
+ const messageWithRecipient = message;
1129
+ return messageWithRecipient.to ?? messageWithRecipient.recipient_id ?? contact?.wa_id ?? message.from;
1130
+ }
1131
+ function parseWebhookTimestamp(timestamp) {
1132
+ const sentAt = /* @__PURE__ */ new Date(parseInt(timestamp, 10) * 1e3);
1133
+ return Number.isNaN(sentAt.getTime()) ? (/* @__PURE__ */ new Date()).toISOString() : sentAt.toISOString();
1134
+ }
1135
+ async function runOptionalHook(run, logEvent, log) {
1136
+ try {
1137
+ await run();
1138
+ } catch (error) {
1139
+ log.error(logEvent, (0, better_zap.serializeError)(error));
1140
+ }
1141
+ }
363
1142
  /**
364
1143
  * Processes a single incoming message:
365
- * 1. Deduplicates by waMessageId
366
- * 2. Extracts human-readable content
367
- * 3. Logs the message for audit trail
368
- * 4. Calls {@link WebhookConfig.onMessage}
1144
+ * 1. Extracts human-readable content
1145
+ * 2. Atomically logs and deduplicates by waMessageId
1146
+ * 3. Calls {@link WebhookConfig.onMessage}
369
1147
  */
370
1148
  async function processIncomingMessage(message, contact, config, log) {
371
1149
  const phone = message.from;
@@ -374,25 +1152,24 @@ async function processIncomingMessage(message, contact, config, log) {
374
1152
  phone,
375
1153
  messageType: message.type
376
1154
  });
377
- if (await config.logger.isDuplicate(message.id)) {
378
- log.info("webhook.duplicate_ignored", {
379
- waMessageId: message.id,
380
- phone
381
- });
382
- return;
383
- }
384
1155
  const content = getMessageContent(message);
385
1156
  const sentAt = /* @__PURE__ */ new Date(parseInt(message.timestamp, 10) * 1e3);
386
1157
  const normalizedSentAt = Number.isNaN(sentAt.getTime()) ? (/* @__PURE__ */ new Date()).toISOString() : sentAt.toISOString();
387
1158
  const { id, type, text, from, timestamp, ...rawMetadata } = message;
388
- await config.logger.logIncoming({
1159
+ if (!await config.logger.logIncoming({
389
1160
  phone,
390
1161
  waMessageId: message.id,
391
1162
  content,
392
1163
  sentAt: normalizedSentAt,
393
1164
  senderName: contact?.profile?.name,
394
1165
  metadata: Object.keys(rawMetadata).length > 0 ? rawMetadata : void 0
395
- });
1166
+ })) {
1167
+ log.info("webhook.duplicate_ignored", {
1168
+ waMessageId: message.id,
1169
+ phone
1170
+ });
1171
+ return;
1172
+ }
396
1173
  const ctx = {
397
1174
  message,
398
1175
  contact,
@@ -446,6 +1223,23 @@ function resolveContact(contacts, message) {
446
1223
  if (!contacts || contacts.length === 0) return;
447
1224
  return contacts.find((c) => c.wa_id === message.from) ?? contacts[0];
448
1225
  }
1226
+ function resolveCoexistenceContact(contacts, message) {
1227
+ if (!contacts || contacts.length === 0) return;
1228
+ return contacts.find((c) => c.wa_id === message.from) ?? contacts[0];
1229
+ }
1230
+ function classifyCoexistenceMessages(messages) {
1231
+ if (!messages || messages.length === 0) return "ordinary";
1232
+ if (messages.some((message) => message.revoked || message.type === "revoked")) return "revoke";
1233
+ if (messages.some((message) => message.edited || message.type === "message_edit")) return "edit";
1234
+ if (messages.some((message) => message.unsupported || message.type === "unsupported" || (message.errors?.length ?? 0) > 0)) return "unsupported";
1235
+ return "ordinary";
1236
+ }
1237
+ function toWebhookError(error) {
1238
+ return {
1239
+ ...error,
1240
+ error_data: error.error_data ?? { details: error.message }
1241
+ };
1242
+ }
449
1243
  //#endregion
450
1244
  //#region src/internal/cloudflare/constants.ts
451
1245
  const GLOBAL_WORKSPACE_DO_ID = "global-workspace";
@@ -475,6 +1269,15 @@ function betterZap(options) {
475
1269
  const log = (0, better_zap.createLogger)(options.logger);
476
1270
  const logger = new better_zap.MessageLoggerService(database.whatsappLog, log, createConversationSyncNotifier(conversationSync));
477
1271
  const whatsapp = new better_zap.WhatsAppService(config, logger, log);
1272
+ const coexistence = options.coexistence && options.coexistence.enabled !== false ? options.coexistence.service ?? new better_zap.CoexistenceService({
1273
+ accessToken: options.coexistence.accessToken,
1274
+ appId: options.coexistence.appId,
1275
+ appSecret: options.coexistence.appSecret,
1276
+ graphApiVersion: options.coexistence.graphApiVersion,
1277
+ graphBaseUrl: options.coexistence.graphBaseUrl,
1278
+ fetch: options.coexistence.fetch,
1279
+ credentialProvider: options.coexistence.credentials
1280
+ }) : void 0;
478
1281
  const coreContext = {
479
1282
  db: database,
480
1283
  api: whatsapp,
@@ -498,6 +1301,7 @@ function betterZap(options) {
498
1301
  appSecret: config.appSecret,
499
1302
  logger,
500
1303
  log,
1304
+ database,
501
1305
  onMessage: async (ctx) => {
502
1306
  const hookContext = {
503
1307
  ...ctx,
@@ -521,6 +1325,60 @@ function betterZap(options) {
521
1325
  log
522
1326
  });
523
1327
  await webhookHooks.onStatusUpdate(hookContext);
1328
+ },
1329
+ onCoexistenceHistory: async (ctx) => {
1330
+ await webhookHooks.onCoexistenceHistory?.({
1331
+ ...ctx,
1332
+ ...pluginRuntime.context
1333
+ });
1334
+ },
1335
+ onSmbAppStateSync: async (ctx) => {
1336
+ await webhookHooks.onSmbAppStateSync?.({
1337
+ ...ctx,
1338
+ ...pluginRuntime.context
1339
+ });
1340
+ },
1341
+ onSmbMessageEcho: async (ctx) => {
1342
+ await webhookHooks.onSmbMessageEcho?.({
1343
+ ...ctx,
1344
+ ...pluginRuntime.context
1345
+ });
1346
+ },
1347
+ onCoexistenceAccountUpdate: async (ctx) => {
1348
+ await webhookHooks.onCoexistenceAccountUpdate?.({
1349
+ ...ctx,
1350
+ ...pluginRuntime.context
1351
+ });
1352
+ },
1353
+ onCoexistenceAccountOffboarded: async (ctx) => {
1354
+ await webhookHooks.onCoexistenceAccountOffboarded?.({
1355
+ ...ctx,
1356
+ ...pluginRuntime.context
1357
+ });
1358
+ },
1359
+ onCoexistenceAccountReconnected: async (ctx) => {
1360
+ await webhookHooks.onCoexistenceAccountReconnected?.({
1361
+ ...ctx,
1362
+ ...pluginRuntime.context
1363
+ });
1364
+ },
1365
+ onCoexistenceMessageEdit: async (ctx) => {
1366
+ await webhookHooks.onCoexistenceMessageEdit?.({
1367
+ ...ctx,
1368
+ ...pluginRuntime.context
1369
+ });
1370
+ },
1371
+ onCoexistenceMessageRevoke: async (ctx) => {
1372
+ await webhookHooks.onCoexistenceMessageRevoke?.({
1373
+ ...ctx,
1374
+ ...pluginRuntime.context
1375
+ });
1376
+ },
1377
+ onCoexistenceUnsupportedMessage: async (ctx) => {
1378
+ await webhookHooks.onCoexistenceUnsupportedMessage?.({
1379
+ ...ctx,
1380
+ ...pluginRuntime.context
1381
+ });
524
1382
  }
525
1383
  });
526
1384
  const app = new hono.Hono().basePath(basePath);
@@ -528,9 +1386,24 @@ function betterZap(options) {
528
1386
  c.set("whatsapp", whatsapp);
529
1387
  c.set("store", database.whatsappLog);
530
1388
  c.set("logger", log);
1389
+ c.set("coexistence", coexistence);
1390
+ c.set("coexistenceStore", database.coexistence);
1391
+ c.set("subscribeWabaAfterCodeExchange", options.coexistence?.subscribeWabaAfterCodeExchange ?? true);
531
1392
  await next();
532
1393
  });
533
1394
  app.route("/webhook", webhookRouter);
1395
+ if (options.authorizeAppRequest) app.use("*", async (c, next) => {
1396
+ try {
1397
+ if (!await options.authorizeAppRequest?.({
1398
+ request: c.req.raw,
1399
+ env: c.env
1400
+ })) return c.json({ error: "Unauthorized" }, 401);
1401
+ await next();
1402
+ } catch (error) {
1403
+ log.error("app_api.authorization_failed", (0, better_zap.serializeError)(error));
1404
+ return c.json({ error: "Authorization failed" }, 500);
1405
+ }
1406
+ });
534
1407
  app.post("/send/text", handleSendText);
535
1408
  app.post("/send/template", createSendTemplateHandler(templates));
536
1409
  app.post("/send/interactive", handleSendInteractive);
@@ -538,6 +1411,10 @@ function betterZap(options) {
538
1411
  app.get("/conversations", handleListConversations);
539
1412
  app.get("/conversations/:phone", handleGetConversation);
540
1413
  app.get("/conversations/:phone/messages", handleGetMessages);
1414
+ app.post("/coexistence/embedded-signup/callback", handleEmbeddedSignupCallback);
1415
+ app.get("/coexistence/phone-numbers/:phoneNumberId/status", handlePhoneStatus);
1416
+ app.post("/coexistence/phone-numbers/:phoneNumberId/sync/contacts", handleContactsSync);
1417
+ app.post("/coexistence/phone-numbers/:phoneNumberId/sync/history", handleHistorySync);
541
1418
  const api = {
542
1419
  send: {
543
1420
  text: (to, body, opts) => whatsapp.sendText(to, body, opts),