@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 +891 -14
- package/dist/index.d.cts +116 -27
- package/dist/index.d.mts +116 -27
- package/dist/index.mjs +892 -15
- package/package.json +9 -4
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
|
|
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.
|
|
366
|
-
* 2.
|
|
367
|
-
* 3.
|
|
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),
|