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