@better-zap/hono 0.0.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/LICENSE +15 -0
- package/README.md +3 -0
- package/dist/index.cjs +572 -0
- package/dist/index.d.cts +88 -0
- package/dist/index.d.mts +88 -0
- package/dist/index.mjs +568 -0
- package/package.json +48 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Better Zap
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
10
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
11
|
+
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
12
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
13
|
+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
14
|
+
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
15
|
+
PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
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/conversations.ts
|
|
75
|
+
async function handleListConversations(c) {
|
|
76
|
+
try {
|
|
77
|
+
const conversations = await c.get("store").getConversations();
|
|
78
|
+
return c.json(conversations);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
c.get("logger").error("conversations.list_error", (0, better_zap.serializeError)(error));
|
|
81
|
+
return c.json({ error: "Internal error fetching conversations" }, 500);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async function handleGetConversation(c) {
|
|
85
|
+
try {
|
|
86
|
+
const phone = c.req.param("phone");
|
|
87
|
+
if (!phone) return c.json({ error: "phone is required" }, 400);
|
|
88
|
+
const store = c.get("store");
|
|
89
|
+
const normalized = (0, better_zap.formatPhone)(decodeURIComponent(phone));
|
|
90
|
+
const conversation = await store.getConversationByPhone(normalized);
|
|
91
|
+
if (!conversation) return c.json({ error: "Conversation not found" }, 404);
|
|
92
|
+
return c.json(conversation);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
c.get("logger").error("conversations.get_error", (0, better_zap.serializeError)(error));
|
|
95
|
+
return c.json({ error: "Internal error fetching conversation" }, 500);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async function handleGetMessages(c) {
|
|
99
|
+
try {
|
|
100
|
+
const phone = c.req.param("phone");
|
|
101
|
+
if (!phone) return c.json({ error: "phone is required" }, 400);
|
|
102
|
+
const store = c.get("store");
|
|
103
|
+
const normalized = (0, better_zap.formatPhone)(decodeURIComponent(phone));
|
|
104
|
+
const conversation = await store.getConversationByPhone(normalized);
|
|
105
|
+
if (!conversation) return c.json({ error: "Conversation not found" }, 404);
|
|
106
|
+
const cursor = c.req.query("cursor") || void 0;
|
|
107
|
+
const limitParam = c.req.query("limit");
|
|
108
|
+
const limit = limitParam ? parseInt(limitParam, 10) : void 0;
|
|
109
|
+
const messages = await store.getMessagesByConversationPaginated(conversation.id, cursor, limit);
|
|
110
|
+
return c.json(messages);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
c.get("logger").error("conversations.messages_error", (0, better_zap.serializeError)(error));
|
|
113
|
+
return c.json({ error: "Internal error fetching messages" }, 500);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
//#endregion
|
|
117
|
+
//#region src/handler/send.ts
|
|
118
|
+
async function handleSendText(c) {
|
|
119
|
+
const { to, body, messageType, userId, metadata } = await c.req.json();
|
|
120
|
+
if (!to || !body) return c.json({ error: "to and body are required" }, 400);
|
|
121
|
+
const whatsapp = c.get("whatsapp");
|
|
122
|
+
const logging = messageType ? {
|
|
123
|
+
messageType,
|
|
124
|
+
userId,
|
|
125
|
+
metadata
|
|
126
|
+
} : void 0;
|
|
127
|
+
const result = await whatsapp.sendText(to, body, logging);
|
|
128
|
+
return c.json(result, result.success ? 200 : 500);
|
|
129
|
+
}
|
|
130
|
+
function createSendTemplateHandler(templates) {
|
|
131
|
+
return async function handleSendTemplate(c) {
|
|
132
|
+
const body = await c.req.json();
|
|
133
|
+
if (!body.to || !body.template) return c.json({ error: "to and template are required" }, 400);
|
|
134
|
+
const whatsapp = c.get("whatsapp");
|
|
135
|
+
const logging = body.logging ?? (body.messageType ? {
|
|
136
|
+
messageType: body.messageType,
|
|
137
|
+
content: body.content || `[template: ${body.template}]`,
|
|
138
|
+
userId: body.userId,
|
|
139
|
+
metadata: body.metadata
|
|
140
|
+
} : void 0);
|
|
141
|
+
let language = body.language;
|
|
142
|
+
let components = body.components;
|
|
143
|
+
if ("params" in body && body.params !== void 0) {
|
|
144
|
+
if (!(0, better_zap.hasConfiguredTemplates)(templates)) return c.json({ error: "Typed template params require a configured template registry" }, 400);
|
|
145
|
+
try {
|
|
146
|
+
const serializedTemplate = (0, better_zap.serializeTemplateFromRegistry)(templates, body.template, {
|
|
147
|
+
language: body.language,
|
|
148
|
+
params: body.params
|
|
149
|
+
});
|
|
150
|
+
language = serializedTemplate.language;
|
|
151
|
+
components = serializedTemplate.components;
|
|
152
|
+
} catch (error) {
|
|
153
|
+
const message = error instanceof Error ? error.message : "Failed to serialize template from registry";
|
|
154
|
+
return c.json({ error: message }, 400);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const result = await whatsapp.sendTemplate(body.to, body.template, language, components, logging);
|
|
158
|
+
return c.json(result, result.success ? 200 : 500);
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
async function handleSendInteractive(c) {
|
|
162
|
+
const { to, type, body, buttons, buttonLabel, sections, cards, messageType, userId, metadata } = await c.req.json();
|
|
163
|
+
if (!to || !body) return c.json({ error: "to and body are required" }, 400);
|
|
164
|
+
const whatsapp = c.get("whatsapp");
|
|
165
|
+
const logging = messageType ? {
|
|
166
|
+
messageType,
|
|
167
|
+
userId,
|
|
168
|
+
metadata
|
|
169
|
+
} : void 0;
|
|
170
|
+
if (type === "list") {
|
|
171
|
+
if (!buttonLabel || !sections) return c.json({ error: "buttonLabel and sections are required for list type" }, 400);
|
|
172
|
+
const result = await whatsapp.sendInteractiveList(to, body, buttonLabel, sections, logging);
|
|
173
|
+
return c.json(result, result.success ? 200 : 500);
|
|
174
|
+
}
|
|
175
|
+
if (type === "carousel") {
|
|
176
|
+
if (!cards) return c.json({ error: "cards are required for carousel type" }, 400);
|
|
177
|
+
if (cards.length < 2 || cards.length > 10) return c.json({ error: "carousel requires between 2 and 10 cards" }, 400);
|
|
178
|
+
const result = await whatsapp.sendInteractiveMediaCarousel({
|
|
179
|
+
to,
|
|
180
|
+
body,
|
|
181
|
+
cards
|
|
182
|
+
}, logging);
|
|
183
|
+
return c.json(result, result.success ? 200 : 500);
|
|
184
|
+
}
|
|
185
|
+
if (!buttons) return c.json({ error: "buttons are required for button type" }, 400);
|
|
186
|
+
const result = await whatsapp.sendInteractiveButtons(to, body, buttons, logging);
|
|
187
|
+
return c.json(result, result.success ? 200 : 500);
|
|
188
|
+
}
|
|
189
|
+
async function handleSendLocation(c) {
|
|
190
|
+
const { to, latitude, longitude, name, address, messageType, userId, metadata } = await c.req.json();
|
|
191
|
+
if (!to || latitude == null || longitude == null || !name || !address) return c.json({ error: "to, latitude, longitude, name, and address are required" }, 400);
|
|
192
|
+
const whatsapp = c.get("whatsapp");
|
|
193
|
+
const logging = messageType ? {
|
|
194
|
+
messageType,
|
|
195
|
+
userId,
|
|
196
|
+
metadata
|
|
197
|
+
} : void 0;
|
|
198
|
+
const result = await whatsapp.sendLocation(to, latitude, longitude, name, address, logging);
|
|
199
|
+
return c.json(result, result.success ? 200 : 500);
|
|
200
|
+
}
|
|
201
|
+
//#endregion
|
|
202
|
+
//#region src/webhook/signature-verification.ts
|
|
203
|
+
const textEncoder = new TextEncoder();
|
|
204
|
+
let cachedMetaAppSecret = null;
|
|
205
|
+
let cachedMetaHmacKey = null;
|
|
206
|
+
async function verifyMetaWebhookSignature({ rawBody, signatureHeader, appSecret }) {
|
|
207
|
+
if (!signatureHeader) return false;
|
|
208
|
+
const [algorithm, signatureHexRaw] = signatureHeader.split("=", 2);
|
|
209
|
+
if (algorithm?.toLowerCase() !== "sha256" || !signatureHexRaw) return false;
|
|
210
|
+
const signatureBytes = hexToBytes(signatureHexRaw.trim());
|
|
211
|
+
if (!signatureBytes) return false;
|
|
212
|
+
const key = await getMetaHmacKey(appSecret);
|
|
213
|
+
const expectedSignatureBuffer = await crypto.subtle.sign("HMAC", key, rawBody);
|
|
214
|
+
return constantTimeEqual(new Uint8Array(expectedSignatureBuffer), signatureBytes);
|
|
215
|
+
}
|
|
216
|
+
function getMetaHmacKey(appSecret) {
|
|
217
|
+
if (cachedMetaAppSecret === appSecret && cachedMetaHmacKey) return cachedMetaHmacKey;
|
|
218
|
+
cachedMetaAppSecret = appSecret;
|
|
219
|
+
cachedMetaHmacKey = crypto.subtle.importKey("raw", textEncoder.encode(appSecret), {
|
|
220
|
+
name: "HMAC",
|
|
221
|
+
hash: "SHA-256"
|
|
222
|
+
}, false, ["sign"]);
|
|
223
|
+
return cachedMetaHmacKey;
|
|
224
|
+
}
|
|
225
|
+
function hexToBytes(hex) {
|
|
226
|
+
if (hex.length % 2 !== 0) return null;
|
|
227
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
228
|
+
for (let i = 0; i < bytes.length; i += 1) {
|
|
229
|
+
const value = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
230
|
+
if (Number.isNaN(value)) return null;
|
|
231
|
+
bytes[i] = value;
|
|
232
|
+
}
|
|
233
|
+
return bytes;
|
|
234
|
+
}
|
|
235
|
+
function constantTimeEqual(a, b) {
|
|
236
|
+
if (a.length !== b.length) return false;
|
|
237
|
+
let diff = 0;
|
|
238
|
+
for (let i = 0; i < a.length; i += 1) diff |= a[i] ^ b[i];
|
|
239
|
+
return diff === 0;
|
|
240
|
+
}
|
|
241
|
+
//#endregion
|
|
242
|
+
//#region src/webhook/message-content.ts
|
|
243
|
+
/**
|
|
244
|
+
* Extract human-readable content from incoming messages for audit logs.
|
|
245
|
+
*/
|
|
246
|
+
function getMessageContent(message) {
|
|
247
|
+
switch (message.type) {
|
|
248
|
+
case "text": return message.text?.body || "[texto vazio]";
|
|
249
|
+
case "image": return `[imagem${message.image?.caption ? `: ${message.image.caption}` : ""}]`;
|
|
250
|
+
case "audio": return "[áudio]";
|
|
251
|
+
case "video": return `[vídeo${message.video?.caption ? `: ${message.video.caption}` : ""}]`;
|
|
252
|
+
case "document": return `[documento: ${message.document?.filename || "arquivo"}]`;
|
|
253
|
+
case "location": return `[localização: ${message.location?.name || `${message.location?.latitude},${message.location?.longitude}`}]`;
|
|
254
|
+
case "button": return `[botão: ${message.button?.text}]`;
|
|
255
|
+
case "interactive":
|
|
256
|
+
if (message.interactive?.button_reply) return `[resposta botão: ${message.interactive.button_reply.title}]`;
|
|
257
|
+
if (message.interactive?.list_reply) return `[resposta lista: ${message.interactive.list_reply.title}]`;
|
|
258
|
+
return "[interativo]";
|
|
259
|
+
case "sticker": return "[figurinha]";
|
|
260
|
+
case "reaction": return "[reação]";
|
|
261
|
+
default: return `[${message.type}]`;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
//#endregion
|
|
265
|
+
//#region src/webhook/create-webhook-handler.ts
|
|
266
|
+
const textDecoder = new TextDecoder();
|
|
267
|
+
/**
|
|
268
|
+
* Creates a Hono router that handles the full WhatsApp webhook lifecycle.
|
|
269
|
+
*
|
|
270
|
+
* **SDK guarantees (non-hookable):**
|
|
271
|
+
* - Signature is always verified before any processing
|
|
272
|
+
* - Meta always receives a fast 200 OK (processing runs via `waitUntil`)
|
|
273
|
+
* - Hook errors never crash the webhook (wrapped in try/catch)
|
|
274
|
+
* - Contact is resolved and content is extracted before `onMessage`
|
|
275
|
+
* - Status timestamp is parsed to ISO before `onStatusUpdate`
|
|
276
|
+
*
|
|
277
|
+
* @typeParam Env - Hono bindings type (e.g. Cloudflare Worker env).
|
|
278
|
+
*/
|
|
279
|
+
function createWebhookHandler(config) {
|
|
280
|
+
const log = config.log;
|
|
281
|
+
const webhook = new hono.Hono();
|
|
282
|
+
webhook.get("/", (c) => {
|
|
283
|
+
const mode = c.req.query("hub.mode");
|
|
284
|
+
const token = c.req.query("hub.verify_token");
|
|
285
|
+
const challenge = c.req.query("hub.challenge");
|
|
286
|
+
if (mode === "subscribe" && token === config.verifyToken) {
|
|
287
|
+
log.info("webhook.verification_successful");
|
|
288
|
+
return c.text(challenge || "", 200);
|
|
289
|
+
}
|
|
290
|
+
log.warn("webhook.verification_failed");
|
|
291
|
+
return c.text("Forbidden", 403);
|
|
292
|
+
});
|
|
293
|
+
webhook.post("/", async (c) => {
|
|
294
|
+
try {
|
|
295
|
+
if (!config.appSecret) {
|
|
296
|
+
log.error("webhook.missing_app_secret");
|
|
297
|
+
return c.text("Server Misconfigured", 500);
|
|
298
|
+
}
|
|
299
|
+
const rawBody = await c.req.raw.arrayBuffer();
|
|
300
|
+
if (!await verifyMetaWebhookSignature({
|
|
301
|
+
rawBody,
|
|
302
|
+
signatureHeader: c.req.header("x-hub-signature-256"),
|
|
303
|
+
appSecret: config.appSecret
|
|
304
|
+
})) {
|
|
305
|
+
log.warn("webhook.invalid_signature");
|
|
306
|
+
return c.text("Unauthorized", 401);
|
|
307
|
+
}
|
|
308
|
+
let payload;
|
|
309
|
+
try {
|
|
310
|
+
payload = JSON.parse(textDecoder.decode(rawBody));
|
|
311
|
+
} catch {
|
|
312
|
+
log.warn("webhook.invalid_payload");
|
|
313
|
+
return c.text("Bad Request", 400);
|
|
314
|
+
}
|
|
315
|
+
if (c.executionCtx) c.executionCtx.waitUntil(processPayload(payload, c.env, config, log));
|
|
316
|
+
else await processPayload(payload, c.env, config, log);
|
|
317
|
+
return c.text("OK", 200);
|
|
318
|
+
} catch (error) {
|
|
319
|
+
log.error("webhook.request_error", (0, better_zap.serializeError)(error));
|
|
320
|
+
return c.text("Internal Server Error", 500);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
return webhook;
|
|
324
|
+
}
|
|
325
|
+
/** Top-level dispatcher — iterates entries in the webhook payload. */
|
|
326
|
+
async function processPayload(payload, env, config, log) {
|
|
327
|
+
try {
|
|
328
|
+
if (payload.object !== "whatsapp_business_account") {
|
|
329
|
+
log.debug("webhook.ignored_payload", { object: payload.object });
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
for (const entry of payload.entry) await processEntry(entry, env, config, log);
|
|
333
|
+
} catch (error) {
|
|
334
|
+
log.error("webhook.async_process_error", (0, better_zap.serializeError)(error));
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
/** Iterates changes within a single entry. */
|
|
338
|
+
async function processEntry(entry, env, config, log) {
|
|
339
|
+
for (const change of entry.changes) await processChange(change, env, config, log);
|
|
340
|
+
}
|
|
341
|
+
/** Routes messages, statuses, and errors to the appropriate handler. */
|
|
342
|
+
async function processChange(change, env, config, log) {
|
|
343
|
+
const value = change.value;
|
|
344
|
+
if (value.messages && value.messages.length > 0) for (const message of value.messages) await processIncomingMessage(message, resolveContact(value.contacts, message), config, log);
|
|
345
|
+
if (value.statuses && value.statuses.length > 0) for (const status of value.statuses) await processStatusUpdate(status, config, log);
|
|
346
|
+
if (value.errors && value.errors.length > 0) {
|
|
347
|
+
const errorHandler = config.onError ?? ((err) => {
|
|
348
|
+
log.error("webhook.meta_error", { error: err });
|
|
349
|
+
});
|
|
350
|
+
for (const error of value.errors) try {
|
|
351
|
+
errorHandler(error);
|
|
352
|
+
} catch (hookError) {
|
|
353
|
+
log.error("webhook.on_error_hook_failed", {
|
|
354
|
+
metaError: error,
|
|
355
|
+
hookError: (0, better_zap.serializeError)(hookError)
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Processes a single incoming message:
|
|
362
|
+
* 1. Deduplicates by waMessageId
|
|
363
|
+
* 2. Extracts human-readable content
|
|
364
|
+
* 3. Logs the message for audit trail
|
|
365
|
+
* 4. Calls {@link WebhookConfig.onMessage}
|
|
366
|
+
*/
|
|
367
|
+
async function processIncomingMessage(message, contact, config, log) {
|
|
368
|
+
const phone = message.from;
|
|
369
|
+
log.info("webhook.message_received", {
|
|
370
|
+
waMessageId: message.id,
|
|
371
|
+
phone,
|
|
372
|
+
messageType: message.type
|
|
373
|
+
});
|
|
374
|
+
if (await config.logger.isDuplicate(message.id)) {
|
|
375
|
+
log.info("webhook.duplicate_ignored", {
|
|
376
|
+
waMessageId: message.id,
|
|
377
|
+
phone
|
|
378
|
+
});
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const content = getMessageContent(message);
|
|
382
|
+
const { id, type, text, from, timestamp, ...rawMetadata } = message;
|
|
383
|
+
await config.logger.logIncoming({
|
|
384
|
+
phone,
|
|
385
|
+
waMessageId: message.id,
|
|
386
|
+
content,
|
|
387
|
+
senderName: contact?.profile?.name,
|
|
388
|
+
metadata: Object.keys(rawMetadata).length > 0 ? rawMetadata : void 0
|
|
389
|
+
});
|
|
390
|
+
const ctx = {
|
|
391
|
+
message,
|
|
392
|
+
contact,
|
|
393
|
+
content,
|
|
394
|
+
phone
|
|
395
|
+
};
|
|
396
|
+
try {
|
|
397
|
+
await config.onMessage(ctx);
|
|
398
|
+
} catch (error) {
|
|
399
|
+
log.error("webhook.on_message_hook_failed", {
|
|
400
|
+
waMessageId: message.id,
|
|
401
|
+
phone,
|
|
402
|
+
...(0, better_zap.serializeError)(error)
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Processes a single delivery status update:
|
|
408
|
+
* 1. Parses Unix timestamp to ISO-8601
|
|
409
|
+
* 2. Extracts first error (if any)
|
|
410
|
+
* 3. Atomically updates status only if it advances the lifecycle
|
|
411
|
+
* 4. Calls {@link WebhookConfig.onStatusUpdate} only if the update was applied
|
|
412
|
+
*/
|
|
413
|
+
async function processStatusUpdate(status, config, log) {
|
|
414
|
+
const firstError = status.errors?.[0];
|
|
415
|
+
const timestamp = (/* @__PURE__ */ new Date(parseInt(status.timestamp) * 1e3)).toISOString();
|
|
416
|
+
const errorMessage = firstError?.message;
|
|
417
|
+
const errorCode = firstError?.code;
|
|
418
|
+
if (!await config.logger.updateStatus(status.id, status.status, timestamp, errorMessage)) return;
|
|
419
|
+
log.info("webhook.status_updated", {
|
|
420
|
+
waMessageId: status.id,
|
|
421
|
+
status: status.status
|
|
422
|
+
});
|
|
423
|
+
const ctx = {
|
|
424
|
+
status,
|
|
425
|
+
timestamp,
|
|
426
|
+
errorMessage,
|
|
427
|
+
errorCode
|
|
428
|
+
};
|
|
429
|
+
try {
|
|
430
|
+
await config.onStatusUpdate(ctx);
|
|
431
|
+
} catch (error) {
|
|
432
|
+
log.error("webhook.on_status_update_hook_failed", {
|
|
433
|
+
waMessageId: status.id,
|
|
434
|
+
...(0, better_zap.serializeError)(error)
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
/** Matches a contact to a message by `wa_id`, falling back to the first contact. */
|
|
439
|
+
function resolveContact(contacts, message) {
|
|
440
|
+
if (!contacts || contacts.length === 0) return;
|
|
441
|
+
return contacts.find((c) => c.wa_id === message.from) ?? contacts[0];
|
|
442
|
+
}
|
|
443
|
+
//#endregion
|
|
444
|
+
//#region src/internal/cloudflare/constants.ts
|
|
445
|
+
const GLOBAL_WORKSPACE_DO_ID = "global-workspace";
|
|
446
|
+
//#endregion
|
|
447
|
+
//#region src/internal/cloudflare/conversation-sync.ts
|
|
448
|
+
function createConversationSyncNotifier(conversationSync) {
|
|
449
|
+
if (!conversationSync) return;
|
|
450
|
+
return { async notify(event) {
|
|
451
|
+
const id = conversationSync.idFromName(GLOBAL_WORKSPACE_DO_ID);
|
|
452
|
+
await conversationSync.get(id).fetch(new Request("http://do/sync", {
|
|
453
|
+
method: "POST",
|
|
454
|
+
body: JSON.stringify(event)
|
|
455
|
+
}));
|
|
456
|
+
} };
|
|
457
|
+
}
|
|
458
|
+
//#endregion
|
|
459
|
+
//#region src/better-zap.ts
|
|
460
|
+
function serializeRuntimeTemplate(templates, templateName, options) {
|
|
461
|
+
return (0, better_zap.serializeTemplateFromRegistry)(templates, templateName, {
|
|
462
|
+
language: options.language,
|
|
463
|
+
params: options.params ?? {}
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
function betterZap(options) {
|
|
467
|
+
const { database, config, webhook: webhookHooks, conversationSync, basePath = "/api/whatsapp" } = options;
|
|
468
|
+
const templates = options.templates ?? better_zap.EMPTY_TEMPLATE_REGISTRY;
|
|
469
|
+
const log = (0, better_zap.createLogger)(options.logger);
|
|
470
|
+
const logger = new better_zap.MessageLoggerService(database.whatsappLog, log, createConversationSyncNotifier(conversationSync));
|
|
471
|
+
const whatsapp = new better_zap.WhatsAppService(config, logger, log);
|
|
472
|
+
const coreContext = {
|
|
473
|
+
db: database,
|
|
474
|
+
api: whatsapp,
|
|
475
|
+
logger
|
|
476
|
+
};
|
|
477
|
+
const coreServices = {
|
|
478
|
+
whatsapp,
|
|
479
|
+
logger
|
|
480
|
+
};
|
|
481
|
+
const plugins = options.plugins ?? [];
|
|
482
|
+
const pluginRuntime = initializePlugins({
|
|
483
|
+
plugins,
|
|
484
|
+
database,
|
|
485
|
+
config,
|
|
486
|
+
coreContext,
|
|
487
|
+
coreServices,
|
|
488
|
+
log
|
|
489
|
+
});
|
|
490
|
+
const webhookRouter = createWebhookHandler({
|
|
491
|
+
verifyToken: config.webhookToken,
|
|
492
|
+
appSecret: config.appSecret,
|
|
493
|
+
logger,
|
|
494
|
+
log,
|
|
495
|
+
onMessage: async (ctx) => {
|
|
496
|
+
const hookContext = {
|
|
497
|
+
...ctx,
|
|
498
|
+
...pluginRuntime.context
|
|
499
|
+
};
|
|
500
|
+
await runPluginMessageHooks({
|
|
501
|
+
plugins,
|
|
502
|
+
ctx: hookContext,
|
|
503
|
+
log
|
|
504
|
+
});
|
|
505
|
+
await webhookHooks.onMessage(hookContext);
|
|
506
|
+
},
|
|
507
|
+
onStatusUpdate: async (ctx) => {
|
|
508
|
+
const hookContext = {
|
|
509
|
+
...ctx,
|
|
510
|
+
...pluginRuntime.context
|
|
511
|
+
};
|
|
512
|
+
await runPluginStatusHooks({
|
|
513
|
+
plugins,
|
|
514
|
+
ctx: hookContext,
|
|
515
|
+
log
|
|
516
|
+
});
|
|
517
|
+
await webhookHooks.onStatusUpdate(hookContext);
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
const app = new hono.Hono().basePath(basePath);
|
|
521
|
+
app.use("*", async (c, next) => {
|
|
522
|
+
c.set("whatsapp", whatsapp);
|
|
523
|
+
c.set("store", database.whatsappLog);
|
|
524
|
+
c.set("logger", log);
|
|
525
|
+
await next();
|
|
526
|
+
});
|
|
527
|
+
app.route("/webhook", webhookRouter);
|
|
528
|
+
app.post("/send/text", handleSendText);
|
|
529
|
+
app.post("/send/template", createSendTemplateHandler(templates));
|
|
530
|
+
app.post("/send/interactive", handleSendInteractive);
|
|
531
|
+
app.post("/send/location", handleSendLocation);
|
|
532
|
+
app.get("/conversations", handleListConversations);
|
|
533
|
+
app.get("/conversations/:phone", handleGetConversation);
|
|
534
|
+
app.get("/conversations/:phone/messages", handleGetMessages);
|
|
535
|
+
const api = {
|
|
536
|
+
send: {
|
|
537
|
+
text: (to, body, opts) => whatsapp.sendText(to, body, opts),
|
|
538
|
+
template: ((to, templateName, opts = {}) => {
|
|
539
|
+
if (!(0, better_zap.hasConfiguredTemplates)(templates)) return whatsapp.sendTemplate(to, String(templateName), opts?.language, opts?.components, opts?.logging);
|
|
540
|
+
const serializedTemplate = serializeRuntimeTemplate(templates, templateName, opts);
|
|
541
|
+
return whatsapp.sendTemplate(to, String(templateName), serializedTemplate.language, serializedTemplate.components, opts.logging);
|
|
542
|
+
}),
|
|
543
|
+
templateRaw: (to, templateName, opts) => whatsapp.sendTemplate(to, templateName, opts?.language, opts?.components, opts?.logging),
|
|
544
|
+
interactiveButtons: (to, body, buttons, opts) => whatsapp.sendInteractiveButtons(to, body, buttons, opts),
|
|
545
|
+
interactiveList: (to, body, buttonLabel, sections, opts) => whatsapp.sendInteractiveList(to, body, buttonLabel, sections, opts),
|
|
546
|
+
interactiveMediaCarousel: (data, opts) => whatsapp.sendInteractiveMediaCarousel(data, opts),
|
|
547
|
+
location: (to, location, opts) => whatsapp.sendLocation(to, location.latitude, location.longitude, location.name, location.address, opts),
|
|
548
|
+
markAsRead: (messageId) => whatsapp.markAsRead(messageId),
|
|
549
|
+
reaction: (to, messageId, emoji) => whatsapp.sendReaction(to, messageId, emoji)
|
|
550
|
+
},
|
|
551
|
+
conversations: {
|
|
552
|
+
list: () => database.whatsappLog.getConversations(),
|
|
553
|
+
get: (phone) => database.whatsappLog.getConversationByPhone((0, better_zap.formatPhone)(phone)),
|
|
554
|
+
messages: async (phone, opts) => {
|
|
555
|
+
const conversation = await database.whatsappLog.getConversationByPhone((0, better_zap.formatPhone)(phone));
|
|
556
|
+
if (!conversation) return [];
|
|
557
|
+
return await database.whatsappLog.getMessagesByConversationPaginated(conversation.id, opts?.cursor, opts?.limit);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
const handler = async (request, env, executionCtx) => app.fetch(request, env, executionCtx);
|
|
562
|
+
return {
|
|
563
|
+
handler,
|
|
564
|
+
api,
|
|
565
|
+
services: pluginRuntime.services
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
//#endregion
|
|
569
|
+
exports.betterZap = betterZap;
|
|
570
|
+
exports.createWebhookHandler = createWebhookHandler;
|
|
571
|
+
exports.getMessageContent = getMessageContent;
|
|
572
|
+
exports.verifyMetaWebhookSignature = verifyMetaWebhookSignature;
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { BetterZapApi, BetterZapApi as BetterZapApi$1, BetterZapContext, BetterZapContext as BetterZapContext$1, BetterZapCoreConfig, BetterZapCoreConfig as BetterZapCoreConfig$1, BetterZapDatabase, BetterZapDatabase as BetterZapDatabase$1, BetterZapPlugin, BetterZapPlugin as BetterZapPlugin$1, BetterZapPluginInitContext, BetterZapPluginInitResult, BetterZapServices, BetterZapServices as BetterZapServices$1, IncomingMessage, InferBetterZapPluginContext, InferBetterZapPluginContext as InferBetterZapPluginContext$1, InferBetterZapPluginServices, InferBetterZapPluginServices as InferBetterZapPluginServices$1, Logger, LoggerConfig, MessageContext, MessageContext as MessageContext$1, MessageLoggerService, StatusContext, StatusContext as StatusContext$1, TemplateRegistry, WebhookError, WhatsAppLogStore, WhatsAppService } from "better-zap";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
|
|
4
|
+
//#region src/better-zap.types.d.ts
|
|
5
|
+
interface BetterZapConfig<TDatabase extends BetterZapDatabase$1 = BetterZapDatabase$1, TPlugins extends readonly BetterZapPlugin$1<TDatabase, any, any>[] = readonly [], TTemplates extends TemplateRegistry = {}> {
|
|
6
|
+
database: TDatabase;
|
|
7
|
+
config: BetterZapCoreConfig$1;
|
|
8
|
+
plugins?: TPlugins;
|
|
9
|
+
templates?: TTemplates;
|
|
10
|
+
conversationSync?: DurableObjectNamespace<any>;
|
|
11
|
+
webhook: {
|
|
12
|
+
onMessage: (ctx: MessageContext$1 & BetterZapContext$1<TDatabase, InferBetterZapPluginContext$1<TPlugins>>) => Promise<void>;
|
|
13
|
+
onStatusUpdate: (ctx: StatusContext$1 & BetterZapContext$1<TDatabase, InferBetterZapPluginContext$1<TPlugins>>) => Promise<void>;
|
|
14
|
+
};
|
|
15
|
+
basePath?: string;
|
|
16
|
+
logger?: LoggerConfig;
|
|
17
|
+
}
|
|
18
|
+
interface BetterZap<TPluginServices extends Record<string, unknown> = {}, TTemplates extends TemplateRegistry = {}> {
|
|
19
|
+
handler: (request: Request, env?: any, executionCtx?: any) => Promise<Response>;
|
|
20
|
+
api: BetterZapApi$1<TTemplates>;
|
|
21
|
+
services: BetterZapServices$1<TPluginServices>;
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region src/better-zap.d.ts
|
|
25
|
+
declare function betterZap<TDatabase extends BetterZapDatabase$1 = BetterZapDatabase$1, TPlugins extends readonly BetterZapPlugin$1<TDatabase, any, any>[] = readonly BetterZapPlugin$1<TDatabase, any, any>[], TTemplates extends TemplateRegistry = {}>(options: BetterZapConfig<TDatabase, TPlugins, TTemplates>): BetterZap<InferBetterZapPluginServices$1<TPlugins>, TTemplates>;
|
|
26
|
+
//#endregion
|
|
27
|
+
//#region src/webhook/create-webhook-handler.d.ts
|
|
28
|
+
/**
|
|
29
|
+
* Configuration for {@link createWebhookHandler}.
|
|
30
|
+
*
|
|
31
|
+
* @typeParam Env - Hono bindings type (e.g. Cloudflare Worker env).
|
|
32
|
+
*/
|
|
33
|
+
type WebhookConfig = {
|
|
34
|
+
/** Token for the Meta verification challenge (`GET /webhook`). */verifyToken: string; /** App secret used for HMAC-SHA256 signature verification. */
|
|
35
|
+
appSecret: string; /** logger for automatic message storage. */
|
|
36
|
+
logger: MessageLoggerService; /** Structured logger for operational logging. */
|
|
37
|
+
log: Logger; /** Called once per incoming message, after SDK pre-processing. */
|
|
38
|
+
onMessage: (ctx: MessageContext$1) => Promise<void>; /** Called once per delivery status update (sent → delivered → read → failed). */
|
|
39
|
+
onStatusUpdate: (ctx: StatusContext$1) => Promise<void>;
|
|
40
|
+
/**
|
|
41
|
+
* Called for Meta platform-level errors.
|
|
42
|
+
* @default Uses the configured {@link WebhookConfig.log} logger's {@code error} method.
|
|
43
|
+
*/
|
|
44
|
+
onError?: (error: WebhookError) => void;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Creates a Hono router that handles the full WhatsApp webhook lifecycle.
|
|
48
|
+
*
|
|
49
|
+
* **SDK guarantees (non-hookable):**
|
|
50
|
+
* - Signature is always verified before any processing
|
|
51
|
+
* - Meta always receives a fast 200 OK (processing runs via `waitUntil`)
|
|
52
|
+
* - Hook errors never crash the webhook (wrapped in try/catch)
|
|
53
|
+
* - Contact is resolved and content is extracted before `onMessage`
|
|
54
|
+
* - Status timestamp is parsed to ISO before `onStatusUpdate`
|
|
55
|
+
*
|
|
56
|
+
* @typeParam Env - Hono bindings type (e.g. Cloudflare Worker env).
|
|
57
|
+
*/
|
|
58
|
+
declare function createWebhookHandler(config: WebhookConfig): Hono<{
|
|
59
|
+
Bindings: Record<string, any>;
|
|
60
|
+
}>;
|
|
61
|
+
//#endregion
|
|
62
|
+
//#region src/webhook/signature-verification.d.ts
|
|
63
|
+
declare function verifyMetaWebhookSignature({
|
|
64
|
+
rawBody,
|
|
65
|
+
signatureHeader,
|
|
66
|
+
appSecret
|
|
67
|
+
}: {
|
|
68
|
+
rawBody: ArrayBuffer;
|
|
69
|
+
signatureHeader: string | undefined;
|
|
70
|
+
appSecret: string;
|
|
71
|
+
}): Promise<boolean>;
|
|
72
|
+
//#endregion
|
|
73
|
+
//#region src/webhook/message-content.d.ts
|
|
74
|
+
/**
|
|
75
|
+
* Extract human-readable content from incoming messages for audit logs.
|
|
76
|
+
*/
|
|
77
|
+
declare function getMessageContent(message: IncomingMessage): string;
|
|
78
|
+
//#endregion
|
|
79
|
+
//#region src/handler/types.d.ts
|
|
80
|
+
type BetterZapEnv = {
|
|
81
|
+
Variables: {
|
|
82
|
+
whatsapp: WhatsAppService;
|
|
83
|
+
store: WhatsAppLogStore;
|
|
84
|
+
logger: Logger;
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
//#endregion
|
|
88
|
+
export { type BetterZap, type BetterZapApi, type BetterZapConfig, type BetterZapContext, type BetterZapCoreConfig, type BetterZapDatabase, type BetterZapEnv, type BetterZapPlugin, type BetterZapPluginInitContext, type BetterZapPluginInitResult, type BetterZapServices, type InferBetterZapPluginContext, type InferBetterZapPluginServices, type MessageContext, type StatusContext, type WebhookConfig, betterZap, createWebhookHandler, getMessageContent, verifyMetaWebhookSignature };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { BetterZapApi, BetterZapApi as BetterZapApi$1, BetterZapContext, BetterZapContext as BetterZapContext$1, BetterZapCoreConfig, BetterZapCoreConfig as BetterZapCoreConfig$1, BetterZapDatabase, BetterZapDatabase as BetterZapDatabase$1, BetterZapPlugin, BetterZapPlugin as BetterZapPlugin$1, BetterZapPluginInitContext, BetterZapPluginInitResult, BetterZapServices, BetterZapServices as BetterZapServices$1, IncomingMessage, InferBetterZapPluginContext, InferBetterZapPluginContext as InferBetterZapPluginContext$1, InferBetterZapPluginServices, InferBetterZapPluginServices as InferBetterZapPluginServices$1, Logger, LoggerConfig, MessageContext, MessageContext as MessageContext$1, MessageLoggerService, StatusContext, StatusContext as StatusContext$1, TemplateRegistry, WebhookError, WhatsAppLogStore, WhatsAppService } from "better-zap";
|
|
3
|
+
|
|
4
|
+
//#region src/better-zap.types.d.ts
|
|
5
|
+
interface BetterZapConfig<TDatabase extends BetterZapDatabase$1 = BetterZapDatabase$1, TPlugins extends readonly BetterZapPlugin$1<TDatabase, any, any>[] = readonly [], TTemplates extends TemplateRegistry = {}> {
|
|
6
|
+
database: TDatabase;
|
|
7
|
+
config: BetterZapCoreConfig$1;
|
|
8
|
+
plugins?: TPlugins;
|
|
9
|
+
templates?: TTemplates;
|
|
10
|
+
conversationSync?: DurableObjectNamespace<any>;
|
|
11
|
+
webhook: {
|
|
12
|
+
onMessage: (ctx: MessageContext$1 & BetterZapContext$1<TDatabase, InferBetterZapPluginContext$1<TPlugins>>) => Promise<void>;
|
|
13
|
+
onStatusUpdate: (ctx: StatusContext$1 & BetterZapContext$1<TDatabase, InferBetterZapPluginContext$1<TPlugins>>) => Promise<void>;
|
|
14
|
+
};
|
|
15
|
+
basePath?: string;
|
|
16
|
+
logger?: LoggerConfig;
|
|
17
|
+
}
|
|
18
|
+
interface BetterZap<TPluginServices extends Record<string, unknown> = {}, TTemplates extends TemplateRegistry = {}> {
|
|
19
|
+
handler: (request: Request, env?: any, executionCtx?: any) => Promise<Response>;
|
|
20
|
+
api: BetterZapApi$1<TTemplates>;
|
|
21
|
+
services: BetterZapServices$1<TPluginServices>;
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region src/better-zap.d.ts
|
|
25
|
+
declare function betterZap<TDatabase extends BetterZapDatabase$1 = BetterZapDatabase$1, TPlugins extends readonly BetterZapPlugin$1<TDatabase, any, any>[] = readonly BetterZapPlugin$1<TDatabase, any, any>[], TTemplates extends TemplateRegistry = {}>(options: BetterZapConfig<TDatabase, TPlugins, TTemplates>): BetterZap<InferBetterZapPluginServices$1<TPlugins>, TTemplates>;
|
|
26
|
+
//#endregion
|
|
27
|
+
//#region src/webhook/create-webhook-handler.d.ts
|
|
28
|
+
/**
|
|
29
|
+
* Configuration for {@link createWebhookHandler}.
|
|
30
|
+
*
|
|
31
|
+
* @typeParam Env - Hono bindings type (e.g. Cloudflare Worker env).
|
|
32
|
+
*/
|
|
33
|
+
type WebhookConfig = {
|
|
34
|
+
/** Token for the Meta verification challenge (`GET /webhook`). */verifyToken: string; /** App secret used for HMAC-SHA256 signature verification. */
|
|
35
|
+
appSecret: string; /** logger for automatic message storage. */
|
|
36
|
+
logger: MessageLoggerService; /** Structured logger for operational logging. */
|
|
37
|
+
log: Logger; /** Called once per incoming message, after SDK pre-processing. */
|
|
38
|
+
onMessage: (ctx: MessageContext$1) => Promise<void>; /** Called once per delivery status update (sent → delivered → read → failed). */
|
|
39
|
+
onStatusUpdate: (ctx: StatusContext$1) => Promise<void>;
|
|
40
|
+
/**
|
|
41
|
+
* Called for Meta platform-level errors.
|
|
42
|
+
* @default Uses the configured {@link WebhookConfig.log} logger's {@code error} method.
|
|
43
|
+
*/
|
|
44
|
+
onError?: (error: WebhookError) => void;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Creates a Hono router that handles the full WhatsApp webhook lifecycle.
|
|
48
|
+
*
|
|
49
|
+
* **SDK guarantees (non-hookable):**
|
|
50
|
+
* - Signature is always verified before any processing
|
|
51
|
+
* - Meta always receives a fast 200 OK (processing runs via `waitUntil`)
|
|
52
|
+
* - Hook errors never crash the webhook (wrapped in try/catch)
|
|
53
|
+
* - Contact is resolved and content is extracted before `onMessage`
|
|
54
|
+
* - Status timestamp is parsed to ISO before `onStatusUpdate`
|
|
55
|
+
*
|
|
56
|
+
* @typeParam Env - Hono bindings type (e.g. Cloudflare Worker env).
|
|
57
|
+
*/
|
|
58
|
+
declare function createWebhookHandler(config: WebhookConfig): Hono<{
|
|
59
|
+
Bindings: Record<string, any>;
|
|
60
|
+
}>;
|
|
61
|
+
//#endregion
|
|
62
|
+
//#region src/webhook/signature-verification.d.ts
|
|
63
|
+
declare function verifyMetaWebhookSignature({
|
|
64
|
+
rawBody,
|
|
65
|
+
signatureHeader,
|
|
66
|
+
appSecret
|
|
67
|
+
}: {
|
|
68
|
+
rawBody: ArrayBuffer;
|
|
69
|
+
signatureHeader: string | undefined;
|
|
70
|
+
appSecret: string;
|
|
71
|
+
}): Promise<boolean>;
|
|
72
|
+
//#endregion
|
|
73
|
+
//#region src/webhook/message-content.d.ts
|
|
74
|
+
/**
|
|
75
|
+
* Extract human-readable content from incoming messages for audit logs.
|
|
76
|
+
*/
|
|
77
|
+
declare function getMessageContent(message: IncomingMessage): string;
|
|
78
|
+
//#endregion
|
|
79
|
+
//#region src/handler/types.d.ts
|
|
80
|
+
type BetterZapEnv = {
|
|
81
|
+
Variables: {
|
|
82
|
+
whatsapp: WhatsAppService;
|
|
83
|
+
store: WhatsAppLogStore;
|
|
84
|
+
logger: Logger;
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
//#endregion
|
|
88
|
+
export { type BetterZap, type BetterZapApi, type BetterZapConfig, type BetterZapContext, type BetterZapCoreConfig, type BetterZapDatabase, type BetterZapEnv, type BetterZapPlugin, type BetterZapPluginInitContext, type BetterZapPluginInitResult, type BetterZapServices, type InferBetterZapPluginContext, type InferBetterZapPluginServices, type MessageContext, type StatusContext, type WebhookConfig, betterZap, createWebhookHandler, getMessageContent, verifyMetaWebhookSignature };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { EMPTY_TEMPLATE_REGISTRY, MessageLoggerService, WhatsAppService, createLogger, formatPhone, hasConfiguredTemplates, serializeError, serializeTemplateFromRegistry } from "better-zap";
|
|
3
|
+
//#region src/plugins/runtime.ts
|
|
4
|
+
function initializePlugins(options) {
|
|
5
|
+
let pluginContext = {};
|
|
6
|
+
let pluginServices = {};
|
|
7
|
+
for (const plugin of options.plugins) {
|
|
8
|
+
const result = plugin.init?.({
|
|
9
|
+
database: options.database,
|
|
10
|
+
config: options.config,
|
|
11
|
+
context: {
|
|
12
|
+
...options.coreContext,
|
|
13
|
+
...pluginContext
|
|
14
|
+
},
|
|
15
|
+
services: {
|
|
16
|
+
...options.coreServices,
|
|
17
|
+
...pluginServices
|
|
18
|
+
},
|
|
19
|
+
log: options.log
|
|
20
|
+
});
|
|
21
|
+
if (!result) continue;
|
|
22
|
+
if (result.context) pluginContext = {
|
|
23
|
+
...pluginContext,
|
|
24
|
+
...result.context
|
|
25
|
+
};
|
|
26
|
+
if (result.services) pluginServices = {
|
|
27
|
+
...pluginServices,
|
|
28
|
+
...result.services
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
context: {
|
|
33
|
+
...options.coreContext,
|
|
34
|
+
...pluginContext
|
|
35
|
+
},
|
|
36
|
+
services: {
|
|
37
|
+
...options.coreServices,
|
|
38
|
+
...pluginServices
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
async function runPluginMessageHooks(options) {
|
|
43
|
+
for (const plugin of options.plugins) {
|
|
44
|
+
if (!plugin.hooks?.onMessage) continue;
|
|
45
|
+
try {
|
|
46
|
+
await plugin.hooks.onMessage(options.ctx);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
options.log.error("plugin.on_message_failed", {
|
|
49
|
+
pluginId: plugin.id,
|
|
50
|
+
waMessageId: options.ctx.message.id,
|
|
51
|
+
phone: options.ctx.phone,
|
|
52
|
+
...serializeError(error)
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function runPluginStatusHooks(options) {
|
|
58
|
+
for (const plugin of options.plugins) {
|
|
59
|
+
if (!plugin.hooks?.onStatusUpdate) continue;
|
|
60
|
+
try {
|
|
61
|
+
await plugin.hooks.onStatusUpdate(options.ctx);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
options.log.error("plugin.on_status_update_failed", {
|
|
64
|
+
pluginId: plugin.id,
|
|
65
|
+
waMessageId: options.ctx.status.id,
|
|
66
|
+
status: options.ctx.status.status,
|
|
67
|
+
...serializeError(error)
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
//#endregion
|
|
73
|
+
//#region src/handler/conversations.ts
|
|
74
|
+
async function handleListConversations(c) {
|
|
75
|
+
try {
|
|
76
|
+
const conversations = await c.get("store").getConversations();
|
|
77
|
+
return c.json(conversations);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
c.get("logger").error("conversations.list_error", serializeError(error));
|
|
80
|
+
return c.json({ error: "Internal error fetching conversations" }, 500);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function handleGetConversation(c) {
|
|
84
|
+
try {
|
|
85
|
+
const phone = c.req.param("phone");
|
|
86
|
+
if (!phone) return c.json({ error: "phone is required" }, 400);
|
|
87
|
+
const store = c.get("store");
|
|
88
|
+
const normalized = formatPhone(decodeURIComponent(phone));
|
|
89
|
+
const conversation = await store.getConversationByPhone(normalized);
|
|
90
|
+
if (!conversation) return c.json({ error: "Conversation not found" }, 404);
|
|
91
|
+
return c.json(conversation);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
c.get("logger").error("conversations.get_error", serializeError(error));
|
|
94
|
+
return c.json({ error: "Internal error fetching conversation" }, 500);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async function handleGetMessages(c) {
|
|
98
|
+
try {
|
|
99
|
+
const phone = c.req.param("phone");
|
|
100
|
+
if (!phone) return c.json({ error: "phone is required" }, 400);
|
|
101
|
+
const store = c.get("store");
|
|
102
|
+
const normalized = formatPhone(decodeURIComponent(phone));
|
|
103
|
+
const conversation = await store.getConversationByPhone(normalized);
|
|
104
|
+
if (!conversation) return c.json({ error: "Conversation not found" }, 404);
|
|
105
|
+
const cursor = c.req.query("cursor") || void 0;
|
|
106
|
+
const limitParam = c.req.query("limit");
|
|
107
|
+
const limit = limitParam ? parseInt(limitParam, 10) : void 0;
|
|
108
|
+
const messages = await store.getMessagesByConversationPaginated(conversation.id, cursor, limit);
|
|
109
|
+
return c.json(messages);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
c.get("logger").error("conversations.messages_error", serializeError(error));
|
|
112
|
+
return c.json({ error: "Internal error fetching messages" }, 500);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
//#endregion
|
|
116
|
+
//#region src/handler/send.ts
|
|
117
|
+
async function handleSendText(c) {
|
|
118
|
+
const { to, body, messageType, userId, metadata } = await c.req.json();
|
|
119
|
+
if (!to || !body) return c.json({ error: "to and body are required" }, 400);
|
|
120
|
+
const whatsapp = c.get("whatsapp");
|
|
121
|
+
const logging = messageType ? {
|
|
122
|
+
messageType,
|
|
123
|
+
userId,
|
|
124
|
+
metadata
|
|
125
|
+
} : void 0;
|
|
126
|
+
const result = await whatsapp.sendText(to, body, logging);
|
|
127
|
+
return c.json(result, result.success ? 200 : 500);
|
|
128
|
+
}
|
|
129
|
+
function createSendTemplateHandler(templates) {
|
|
130
|
+
return async function handleSendTemplate(c) {
|
|
131
|
+
const body = await c.req.json();
|
|
132
|
+
if (!body.to || !body.template) return c.json({ error: "to and template are required" }, 400);
|
|
133
|
+
const whatsapp = c.get("whatsapp");
|
|
134
|
+
const logging = body.logging ?? (body.messageType ? {
|
|
135
|
+
messageType: body.messageType,
|
|
136
|
+
content: body.content || `[template: ${body.template}]`,
|
|
137
|
+
userId: body.userId,
|
|
138
|
+
metadata: body.metadata
|
|
139
|
+
} : void 0);
|
|
140
|
+
let language = body.language;
|
|
141
|
+
let components = body.components;
|
|
142
|
+
if ("params" in body && body.params !== void 0) {
|
|
143
|
+
if (!hasConfiguredTemplates(templates)) return c.json({ error: "Typed template params require a configured template registry" }, 400);
|
|
144
|
+
try {
|
|
145
|
+
const serializedTemplate = serializeTemplateFromRegistry(templates, body.template, {
|
|
146
|
+
language: body.language,
|
|
147
|
+
params: body.params
|
|
148
|
+
});
|
|
149
|
+
language = serializedTemplate.language;
|
|
150
|
+
components = serializedTemplate.components;
|
|
151
|
+
} catch (error) {
|
|
152
|
+
const message = error instanceof Error ? error.message : "Failed to serialize template from registry";
|
|
153
|
+
return c.json({ error: message }, 400);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const result = await whatsapp.sendTemplate(body.to, body.template, language, components, logging);
|
|
157
|
+
return c.json(result, result.success ? 200 : 500);
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
async function handleSendInteractive(c) {
|
|
161
|
+
const { to, type, body, buttons, buttonLabel, sections, cards, messageType, userId, metadata } = await c.req.json();
|
|
162
|
+
if (!to || !body) return c.json({ error: "to and body are required" }, 400);
|
|
163
|
+
const whatsapp = c.get("whatsapp");
|
|
164
|
+
const logging = messageType ? {
|
|
165
|
+
messageType,
|
|
166
|
+
userId,
|
|
167
|
+
metadata
|
|
168
|
+
} : void 0;
|
|
169
|
+
if (type === "list") {
|
|
170
|
+
if (!buttonLabel || !sections) return c.json({ error: "buttonLabel and sections are required for list type" }, 400);
|
|
171
|
+
const result = await whatsapp.sendInteractiveList(to, body, buttonLabel, sections, logging);
|
|
172
|
+
return c.json(result, result.success ? 200 : 500);
|
|
173
|
+
}
|
|
174
|
+
if (type === "carousel") {
|
|
175
|
+
if (!cards) return c.json({ error: "cards are required for carousel type" }, 400);
|
|
176
|
+
if (cards.length < 2 || cards.length > 10) return c.json({ error: "carousel requires between 2 and 10 cards" }, 400);
|
|
177
|
+
const result = await whatsapp.sendInteractiveMediaCarousel({
|
|
178
|
+
to,
|
|
179
|
+
body,
|
|
180
|
+
cards
|
|
181
|
+
}, logging);
|
|
182
|
+
return c.json(result, result.success ? 200 : 500);
|
|
183
|
+
}
|
|
184
|
+
if (!buttons) return c.json({ error: "buttons are required for button type" }, 400);
|
|
185
|
+
const result = await whatsapp.sendInteractiveButtons(to, body, buttons, logging);
|
|
186
|
+
return c.json(result, result.success ? 200 : 500);
|
|
187
|
+
}
|
|
188
|
+
async function handleSendLocation(c) {
|
|
189
|
+
const { to, latitude, longitude, name, address, messageType, userId, metadata } = await c.req.json();
|
|
190
|
+
if (!to || latitude == null || longitude == null || !name || !address) return c.json({ error: "to, latitude, longitude, name, and address are required" }, 400);
|
|
191
|
+
const whatsapp = c.get("whatsapp");
|
|
192
|
+
const logging = messageType ? {
|
|
193
|
+
messageType,
|
|
194
|
+
userId,
|
|
195
|
+
metadata
|
|
196
|
+
} : void 0;
|
|
197
|
+
const result = await whatsapp.sendLocation(to, latitude, longitude, name, address, logging);
|
|
198
|
+
return c.json(result, result.success ? 200 : 500);
|
|
199
|
+
}
|
|
200
|
+
//#endregion
|
|
201
|
+
//#region src/webhook/signature-verification.ts
|
|
202
|
+
const textEncoder = new TextEncoder();
|
|
203
|
+
let cachedMetaAppSecret = null;
|
|
204
|
+
let cachedMetaHmacKey = null;
|
|
205
|
+
async function verifyMetaWebhookSignature({ rawBody, signatureHeader, appSecret }) {
|
|
206
|
+
if (!signatureHeader) return false;
|
|
207
|
+
const [algorithm, signatureHexRaw] = signatureHeader.split("=", 2);
|
|
208
|
+
if (algorithm?.toLowerCase() !== "sha256" || !signatureHexRaw) return false;
|
|
209
|
+
const signatureBytes = hexToBytes(signatureHexRaw.trim());
|
|
210
|
+
if (!signatureBytes) return false;
|
|
211
|
+
const key = await getMetaHmacKey(appSecret);
|
|
212
|
+
const expectedSignatureBuffer = await crypto.subtle.sign("HMAC", key, rawBody);
|
|
213
|
+
return constantTimeEqual(new Uint8Array(expectedSignatureBuffer), signatureBytes);
|
|
214
|
+
}
|
|
215
|
+
function getMetaHmacKey(appSecret) {
|
|
216
|
+
if (cachedMetaAppSecret === appSecret && cachedMetaHmacKey) return cachedMetaHmacKey;
|
|
217
|
+
cachedMetaAppSecret = appSecret;
|
|
218
|
+
cachedMetaHmacKey = crypto.subtle.importKey("raw", textEncoder.encode(appSecret), {
|
|
219
|
+
name: "HMAC",
|
|
220
|
+
hash: "SHA-256"
|
|
221
|
+
}, false, ["sign"]);
|
|
222
|
+
return cachedMetaHmacKey;
|
|
223
|
+
}
|
|
224
|
+
function hexToBytes(hex) {
|
|
225
|
+
if (hex.length % 2 !== 0) return null;
|
|
226
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
227
|
+
for (let i = 0; i < bytes.length; i += 1) {
|
|
228
|
+
const value = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
229
|
+
if (Number.isNaN(value)) return null;
|
|
230
|
+
bytes[i] = value;
|
|
231
|
+
}
|
|
232
|
+
return bytes;
|
|
233
|
+
}
|
|
234
|
+
function constantTimeEqual(a, b) {
|
|
235
|
+
if (a.length !== b.length) return false;
|
|
236
|
+
let diff = 0;
|
|
237
|
+
for (let i = 0; i < a.length; i += 1) diff |= a[i] ^ b[i];
|
|
238
|
+
return diff === 0;
|
|
239
|
+
}
|
|
240
|
+
//#endregion
|
|
241
|
+
//#region src/webhook/message-content.ts
|
|
242
|
+
/**
|
|
243
|
+
* Extract human-readable content from incoming messages for audit logs.
|
|
244
|
+
*/
|
|
245
|
+
function getMessageContent(message) {
|
|
246
|
+
switch (message.type) {
|
|
247
|
+
case "text": return message.text?.body || "[texto vazio]";
|
|
248
|
+
case "image": return `[imagem${message.image?.caption ? `: ${message.image.caption}` : ""}]`;
|
|
249
|
+
case "audio": return "[áudio]";
|
|
250
|
+
case "video": return `[vídeo${message.video?.caption ? `: ${message.video.caption}` : ""}]`;
|
|
251
|
+
case "document": return `[documento: ${message.document?.filename || "arquivo"}]`;
|
|
252
|
+
case "location": return `[localização: ${message.location?.name || `${message.location?.latitude},${message.location?.longitude}`}]`;
|
|
253
|
+
case "button": return `[botão: ${message.button?.text}]`;
|
|
254
|
+
case "interactive":
|
|
255
|
+
if (message.interactive?.button_reply) return `[resposta botão: ${message.interactive.button_reply.title}]`;
|
|
256
|
+
if (message.interactive?.list_reply) return `[resposta lista: ${message.interactive.list_reply.title}]`;
|
|
257
|
+
return "[interativo]";
|
|
258
|
+
case "sticker": return "[figurinha]";
|
|
259
|
+
case "reaction": return "[reação]";
|
|
260
|
+
default: return `[${message.type}]`;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
//#endregion
|
|
264
|
+
//#region src/webhook/create-webhook-handler.ts
|
|
265
|
+
const textDecoder = new TextDecoder();
|
|
266
|
+
/**
|
|
267
|
+
* Creates a Hono router that handles the full WhatsApp webhook lifecycle.
|
|
268
|
+
*
|
|
269
|
+
* **SDK guarantees (non-hookable):**
|
|
270
|
+
* - Signature is always verified before any processing
|
|
271
|
+
* - Meta always receives a fast 200 OK (processing runs via `waitUntil`)
|
|
272
|
+
* - Hook errors never crash the webhook (wrapped in try/catch)
|
|
273
|
+
* - Contact is resolved and content is extracted before `onMessage`
|
|
274
|
+
* - Status timestamp is parsed to ISO before `onStatusUpdate`
|
|
275
|
+
*
|
|
276
|
+
* @typeParam Env - Hono bindings type (e.g. Cloudflare Worker env).
|
|
277
|
+
*/
|
|
278
|
+
function createWebhookHandler(config) {
|
|
279
|
+
const log = config.log;
|
|
280
|
+
const webhook = new Hono();
|
|
281
|
+
webhook.get("/", (c) => {
|
|
282
|
+
const mode = c.req.query("hub.mode");
|
|
283
|
+
const token = c.req.query("hub.verify_token");
|
|
284
|
+
const challenge = c.req.query("hub.challenge");
|
|
285
|
+
if (mode === "subscribe" && token === config.verifyToken) {
|
|
286
|
+
log.info("webhook.verification_successful");
|
|
287
|
+
return c.text(challenge || "", 200);
|
|
288
|
+
}
|
|
289
|
+
log.warn("webhook.verification_failed");
|
|
290
|
+
return c.text("Forbidden", 403);
|
|
291
|
+
});
|
|
292
|
+
webhook.post("/", async (c) => {
|
|
293
|
+
try {
|
|
294
|
+
if (!config.appSecret) {
|
|
295
|
+
log.error("webhook.missing_app_secret");
|
|
296
|
+
return c.text("Server Misconfigured", 500);
|
|
297
|
+
}
|
|
298
|
+
const rawBody = await c.req.raw.arrayBuffer();
|
|
299
|
+
if (!await verifyMetaWebhookSignature({
|
|
300
|
+
rawBody,
|
|
301
|
+
signatureHeader: c.req.header("x-hub-signature-256"),
|
|
302
|
+
appSecret: config.appSecret
|
|
303
|
+
})) {
|
|
304
|
+
log.warn("webhook.invalid_signature");
|
|
305
|
+
return c.text("Unauthorized", 401);
|
|
306
|
+
}
|
|
307
|
+
let payload;
|
|
308
|
+
try {
|
|
309
|
+
payload = JSON.parse(textDecoder.decode(rawBody));
|
|
310
|
+
} catch {
|
|
311
|
+
log.warn("webhook.invalid_payload");
|
|
312
|
+
return c.text("Bad Request", 400);
|
|
313
|
+
}
|
|
314
|
+
if (c.executionCtx) c.executionCtx.waitUntil(processPayload(payload, c.env, config, log));
|
|
315
|
+
else await processPayload(payload, c.env, config, log);
|
|
316
|
+
return c.text("OK", 200);
|
|
317
|
+
} catch (error) {
|
|
318
|
+
log.error("webhook.request_error", serializeError(error));
|
|
319
|
+
return c.text("Internal Server Error", 500);
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
return webhook;
|
|
323
|
+
}
|
|
324
|
+
/** Top-level dispatcher — iterates entries in the webhook payload. */
|
|
325
|
+
async function processPayload(payload, env, config, log) {
|
|
326
|
+
try {
|
|
327
|
+
if (payload.object !== "whatsapp_business_account") {
|
|
328
|
+
log.debug("webhook.ignored_payload", { object: payload.object });
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
for (const entry of payload.entry) await processEntry(entry, env, config, log);
|
|
332
|
+
} catch (error) {
|
|
333
|
+
log.error("webhook.async_process_error", serializeError(error));
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/** Iterates changes within a single entry. */
|
|
337
|
+
async function processEntry(entry, env, config, log) {
|
|
338
|
+
for (const change of entry.changes) await processChange(change, env, config, log);
|
|
339
|
+
}
|
|
340
|
+
/** Routes messages, statuses, and errors to the appropriate handler. */
|
|
341
|
+
async function processChange(change, env, config, log) {
|
|
342
|
+
const value = change.value;
|
|
343
|
+
if (value.messages && value.messages.length > 0) for (const message of value.messages) await processIncomingMessage(message, resolveContact(value.contacts, message), config, log);
|
|
344
|
+
if (value.statuses && value.statuses.length > 0) for (const status of value.statuses) await processStatusUpdate(status, config, log);
|
|
345
|
+
if (value.errors && value.errors.length > 0) {
|
|
346
|
+
const errorHandler = config.onError ?? ((err) => {
|
|
347
|
+
log.error("webhook.meta_error", { error: err });
|
|
348
|
+
});
|
|
349
|
+
for (const error of value.errors) try {
|
|
350
|
+
errorHandler(error);
|
|
351
|
+
} catch (hookError) {
|
|
352
|
+
log.error("webhook.on_error_hook_failed", {
|
|
353
|
+
metaError: error,
|
|
354
|
+
hookError: serializeError(hookError)
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Processes a single incoming message:
|
|
361
|
+
* 1. Deduplicates by waMessageId
|
|
362
|
+
* 2. Extracts human-readable content
|
|
363
|
+
* 3. Logs the message for audit trail
|
|
364
|
+
* 4. Calls {@link WebhookConfig.onMessage}
|
|
365
|
+
*/
|
|
366
|
+
async function processIncomingMessage(message, contact, config, log) {
|
|
367
|
+
const phone = message.from;
|
|
368
|
+
log.info("webhook.message_received", {
|
|
369
|
+
waMessageId: message.id,
|
|
370
|
+
phone,
|
|
371
|
+
messageType: message.type
|
|
372
|
+
});
|
|
373
|
+
if (await config.logger.isDuplicate(message.id)) {
|
|
374
|
+
log.info("webhook.duplicate_ignored", {
|
|
375
|
+
waMessageId: message.id,
|
|
376
|
+
phone
|
|
377
|
+
});
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const content = getMessageContent(message);
|
|
381
|
+
const { id, type, text, from, timestamp, ...rawMetadata } = message;
|
|
382
|
+
await config.logger.logIncoming({
|
|
383
|
+
phone,
|
|
384
|
+
waMessageId: message.id,
|
|
385
|
+
content,
|
|
386
|
+
senderName: contact?.profile?.name,
|
|
387
|
+
metadata: Object.keys(rawMetadata).length > 0 ? rawMetadata : void 0
|
|
388
|
+
});
|
|
389
|
+
const ctx = {
|
|
390
|
+
message,
|
|
391
|
+
contact,
|
|
392
|
+
content,
|
|
393
|
+
phone
|
|
394
|
+
};
|
|
395
|
+
try {
|
|
396
|
+
await config.onMessage(ctx);
|
|
397
|
+
} catch (error) {
|
|
398
|
+
log.error("webhook.on_message_hook_failed", {
|
|
399
|
+
waMessageId: message.id,
|
|
400
|
+
phone,
|
|
401
|
+
...serializeError(error)
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Processes a single delivery status update:
|
|
407
|
+
* 1. Parses Unix timestamp to ISO-8601
|
|
408
|
+
* 2. Extracts first error (if any)
|
|
409
|
+
* 3. Atomically updates status only if it advances the lifecycle
|
|
410
|
+
* 4. Calls {@link WebhookConfig.onStatusUpdate} only if the update was applied
|
|
411
|
+
*/
|
|
412
|
+
async function processStatusUpdate(status, config, log) {
|
|
413
|
+
const firstError = status.errors?.[0];
|
|
414
|
+
const timestamp = (/* @__PURE__ */ new Date(parseInt(status.timestamp) * 1e3)).toISOString();
|
|
415
|
+
const errorMessage = firstError?.message;
|
|
416
|
+
const errorCode = firstError?.code;
|
|
417
|
+
if (!await config.logger.updateStatus(status.id, status.status, timestamp, errorMessage)) return;
|
|
418
|
+
log.info("webhook.status_updated", {
|
|
419
|
+
waMessageId: status.id,
|
|
420
|
+
status: status.status
|
|
421
|
+
});
|
|
422
|
+
const ctx = {
|
|
423
|
+
status,
|
|
424
|
+
timestamp,
|
|
425
|
+
errorMessage,
|
|
426
|
+
errorCode
|
|
427
|
+
};
|
|
428
|
+
try {
|
|
429
|
+
await config.onStatusUpdate(ctx);
|
|
430
|
+
} catch (error) {
|
|
431
|
+
log.error("webhook.on_status_update_hook_failed", {
|
|
432
|
+
waMessageId: status.id,
|
|
433
|
+
...serializeError(error)
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
/** Matches a contact to a message by `wa_id`, falling back to the first contact. */
|
|
438
|
+
function resolveContact(contacts, message) {
|
|
439
|
+
if (!contacts || contacts.length === 0) return;
|
|
440
|
+
return contacts.find((c) => c.wa_id === message.from) ?? contacts[0];
|
|
441
|
+
}
|
|
442
|
+
//#endregion
|
|
443
|
+
//#region src/internal/cloudflare/constants.ts
|
|
444
|
+
const GLOBAL_WORKSPACE_DO_ID = "global-workspace";
|
|
445
|
+
//#endregion
|
|
446
|
+
//#region src/internal/cloudflare/conversation-sync.ts
|
|
447
|
+
function createConversationSyncNotifier(conversationSync) {
|
|
448
|
+
if (!conversationSync) return;
|
|
449
|
+
return { async notify(event) {
|
|
450
|
+
const id = conversationSync.idFromName(GLOBAL_WORKSPACE_DO_ID);
|
|
451
|
+
await conversationSync.get(id).fetch(new Request("http://do/sync", {
|
|
452
|
+
method: "POST",
|
|
453
|
+
body: JSON.stringify(event)
|
|
454
|
+
}));
|
|
455
|
+
} };
|
|
456
|
+
}
|
|
457
|
+
//#endregion
|
|
458
|
+
//#region src/better-zap.ts
|
|
459
|
+
function serializeRuntimeTemplate(templates, templateName, options) {
|
|
460
|
+
return serializeTemplateFromRegistry(templates, templateName, {
|
|
461
|
+
language: options.language,
|
|
462
|
+
params: options.params ?? {}
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
function betterZap(options) {
|
|
466
|
+
const { database, config, webhook: webhookHooks, conversationSync, basePath = "/api/whatsapp" } = options;
|
|
467
|
+
const templates = options.templates ?? EMPTY_TEMPLATE_REGISTRY;
|
|
468
|
+
const log = createLogger(options.logger);
|
|
469
|
+
const logger = new MessageLoggerService(database.whatsappLog, log, createConversationSyncNotifier(conversationSync));
|
|
470
|
+
const whatsapp = new WhatsAppService(config, logger, log);
|
|
471
|
+
const coreContext = {
|
|
472
|
+
db: database,
|
|
473
|
+
api: whatsapp,
|
|
474
|
+
logger
|
|
475
|
+
};
|
|
476
|
+
const coreServices = {
|
|
477
|
+
whatsapp,
|
|
478
|
+
logger
|
|
479
|
+
};
|
|
480
|
+
const plugins = options.plugins ?? [];
|
|
481
|
+
const pluginRuntime = initializePlugins({
|
|
482
|
+
plugins,
|
|
483
|
+
database,
|
|
484
|
+
config,
|
|
485
|
+
coreContext,
|
|
486
|
+
coreServices,
|
|
487
|
+
log
|
|
488
|
+
});
|
|
489
|
+
const webhookRouter = createWebhookHandler({
|
|
490
|
+
verifyToken: config.webhookToken,
|
|
491
|
+
appSecret: config.appSecret,
|
|
492
|
+
logger,
|
|
493
|
+
log,
|
|
494
|
+
onMessage: async (ctx) => {
|
|
495
|
+
const hookContext = {
|
|
496
|
+
...ctx,
|
|
497
|
+
...pluginRuntime.context
|
|
498
|
+
};
|
|
499
|
+
await runPluginMessageHooks({
|
|
500
|
+
plugins,
|
|
501
|
+
ctx: hookContext,
|
|
502
|
+
log
|
|
503
|
+
});
|
|
504
|
+
await webhookHooks.onMessage(hookContext);
|
|
505
|
+
},
|
|
506
|
+
onStatusUpdate: async (ctx) => {
|
|
507
|
+
const hookContext = {
|
|
508
|
+
...ctx,
|
|
509
|
+
...pluginRuntime.context
|
|
510
|
+
};
|
|
511
|
+
await runPluginStatusHooks({
|
|
512
|
+
plugins,
|
|
513
|
+
ctx: hookContext,
|
|
514
|
+
log
|
|
515
|
+
});
|
|
516
|
+
await webhookHooks.onStatusUpdate(hookContext);
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
const app = new Hono().basePath(basePath);
|
|
520
|
+
app.use("*", async (c, next) => {
|
|
521
|
+
c.set("whatsapp", whatsapp);
|
|
522
|
+
c.set("store", database.whatsappLog);
|
|
523
|
+
c.set("logger", log);
|
|
524
|
+
await next();
|
|
525
|
+
});
|
|
526
|
+
app.route("/webhook", webhookRouter);
|
|
527
|
+
app.post("/send/text", handleSendText);
|
|
528
|
+
app.post("/send/template", createSendTemplateHandler(templates));
|
|
529
|
+
app.post("/send/interactive", handleSendInteractive);
|
|
530
|
+
app.post("/send/location", handleSendLocation);
|
|
531
|
+
app.get("/conversations", handleListConversations);
|
|
532
|
+
app.get("/conversations/:phone", handleGetConversation);
|
|
533
|
+
app.get("/conversations/:phone/messages", handleGetMessages);
|
|
534
|
+
const api = {
|
|
535
|
+
send: {
|
|
536
|
+
text: (to, body, opts) => whatsapp.sendText(to, body, opts),
|
|
537
|
+
template: ((to, templateName, opts = {}) => {
|
|
538
|
+
if (!hasConfiguredTemplates(templates)) return whatsapp.sendTemplate(to, String(templateName), opts?.language, opts?.components, opts?.logging);
|
|
539
|
+
const serializedTemplate = serializeRuntimeTemplate(templates, templateName, opts);
|
|
540
|
+
return whatsapp.sendTemplate(to, String(templateName), serializedTemplate.language, serializedTemplate.components, opts.logging);
|
|
541
|
+
}),
|
|
542
|
+
templateRaw: (to, templateName, opts) => whatsapp.sendTemplate(to, templateName, opts?.language, opts?.components, opts?.logging),
|
|
543
|
+
interactiveButtons: (to, body, buttons, opts) => whatsapp.sendInteractiveButtons(to, body, buttons, opts),
|
|
544
|
+
interactiveList: (to, body, buttonLabel, sections, opts) => whatsapp.sendInteractiveList(to, body, buttonLabel, sections, opts),
|
|
545
|
+
interactiveMediaCarousel: (data, opts) => whatsapp.sendInteractiveMediaCarousel(data, opts),
|
|
546
|
+
location: (to, location, opts) => whatsapp.sendLocation(to, location.latitude, location.longitude, location.name, location.address, opts),
|
|
547
|
+
markAsRead: (messageId) => whatsapp.markAsRead(messageId),
|
|
548
|
+
reaction: (to, messageId, emoji) => whatsapp.sendReaction(to, messageId, emoji)
|
|
549
|
+
},
|
|
550
|
+
conversations: {
|
|
551
|
+
list: () => database.whatsappLog.getConversations(),
|
|
552
|
+
get: (phone) => database.whatsappLog.getConversationByPhone(formatPhone(phone)),
|
|
553
|
+
messages: async (phone, opts) => {
|
|
554
|
+
const conversation = await database.whatsappLog.getConversationByPhone(formatPhone(phone));
|
|
555
|
+
if (!conversation) return [];
|
|
556
|
+
return await database.whatsappLog.getMessagesByConversationPaginated(conversation.id, opts?.cursor, opts?.limit);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
const handler = async (request, env, executionCtx) => app.fetch(request, env, executionCtx);
|
|
561
|
+
return {
|
|
562
|
+
handler,
|
|
563
|
+
api,
|
|
564
|
+
services: pluginRuntime.services
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
//#endregion
|
|
568
|
+
export { betterZap, createWebhookHandler, getMessageContent, verifyMetaWebhookSignature };
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@better-zap/hono",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Hono adapter and webhook runtime for Better Zap.",
|
|
5
|
+
"license": "ISC",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.cjs",
|
|
8
|
+
"module": "./dist/index.mjs",
|
|
9
|
+
"types": "./dist/index.d.mts",
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.mts",
|
|
18
|
+
"import": "./dist/index.mjs",
|
|
19
|
+
"require": "./dist/index.cjs"
|
|
20
|
+
},
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=20"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"hono": "4.12.5",
|
|
31
|
+
"better-zap": "0.0.1"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"hono": "4.12.5"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@cloudflare/workers-types": "^4.20240117.0",
|
|
38
|
+
"@types/node": "^25.4.0",
|
|
39
|
+
"tsdown": "^0.21.2",
|
|
40
|
+
"typescript": "^5.9.3",
|
|
41
|
+
"vitest": "^4.1.0"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsdown",
|
|
45
|
+
"typecheck": "tsc --noEmit",
|
|
46
|
+
"test": "vitest run"
|
|
47
|
+
}
|
|
48
|
+
}
|