better-zap 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/dist/index.mjs ADDED
@@ -0,0 +1,566 @@
1
+ import { createZapClient } from "./client.mjs";
2
+ //#region src/logger.ts
3
+ const LOG_LEVEL_ORDER = {
4
+ debug: 0,
5
+ info: 1,
6
+ warn: 2,
7
+ error: 3
8
+ };
9
+ const CONSOLE_METHODS = {
10
+ debug: "debug",
11
+ info: "info",
12
+ warn: "warn",
13
+ error: "error"
14
+ };
15
+ function defaultLog(level, message, context) {
16
+ const entry = {
17
+ level,
18
+ message,
19
+ context,
20
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
21
+ };
22
+ console[CONSOLE_METHODS[level]](JSON.stringify(entry));
23
+ }
24
+ function createLogger(config) {
25
+ if (config?.disabled) return noopLogger;
26
+ const minLevel = LOG_LEVEL_ORDER[config?.level ?? "info"];
27
+ const logFn = config?.log ?? defaultLog;
28
+ function emit(level, message, context) {
29
+ if (LOG_LEVEL_ORDER[level] >= minLevel) logFn(level, message, context ?? {});
30
+ }
31
+ return {
32
+ debug: (msg, ctx) => emit("debug", msg, ctx),
33
+ info: (msg, ctx) => emit("info", msg, ctx),
34
+ warn: (msg, ctx) => emit("warn", msg, ctx),
35
+ error: (msg, ctx) => emit("error", msg, ctx)
36
+ };
37
+ }
38
+ const noopLogger = {
39
+ debug() {},
40
+ info() {},
41
+ warn() {},
42
+ error() {}
43
+ };
44
+ function serializeError(err) {
45
+ if (err instanceof Error) return {
46
+ message: err.message,
47
+ name: err.name,
48
+ stack: err.stack
49
+ };
50
+ return { message: String(err) };
51
+ }
52
+ //#endregion
53
+ //#region src/utils/phone.ts
54
+ /**
55
+ * Formats a phone number to the international format required by Meta Cloud API.
56
+ * Currently defaults to Brazilian country code (55) if not provided.
57
+ * Normalizes Brazilian numbers to always include the 9th digit.
58
+ */
59
+ function formatPhone(phone) {
60
+ const digits = phone.replace(/\D/g, "");
61
+ if (digits.startsWith("55") && digits.length === 13) return digits;
62
+ if (digits.startsWith("55") && digits.length === 12) return `55${digits.slice(2, 4)}9${digits.slice(4)}`;
63
+ if (digits.length === 11) return `55${digits}`;
64
+ if (digits.length === 10) return `55${digits.slice(0, 2)}9${digits.slice(2)}`;
65
+ throw new Error(`[formatPhone] Cannot normalize phone: "${phone}" (${digits.length} digits). Expected 10–13 digit Brazilian number.`);
66
+ }
67
+ //#endregion
68
+ //#region src/utils/delay.ts
69
+ /**
70
+ * Utility function to pause execution for a given number of milliseconds.
71
+ * Useful for exponential backoff or rate limiting.
72
+ */
73
+ function delay(ms) {
74
+ return new Promise((resolve) => setTimeout(resolve, ms));
75
+ }
76
+ //#endregion
77
+ //#region src/services/whatsapp.service.ts
78
+ const META_API_VERSION = "v25.0";
79
+ const META_BASE_URL = "https://graph.facebook.com";
80
+ var WhatsAppService = class {
81
+ baseUrl;
82
+ token;
83
+ isDev;
84
+ logger;
85
+ log;
86
+ constructor(config, logger, log) {
87
+ this.baseUrl = `${META_BASE_URL}/${META_API_VERSION}/${config.phoneId}/messages`;
88
+ this.token = config.token;
89
+ this.isDev = config.environment === "development";
90
+ this.logger = logger;
91
+ this.log = log;
92
+ }
93
+ /** Send a text message (within 24h service window only). */
94
+ async sendText(to, body, logging) {
95
+ const hasUrl = /https?:\/\/\S+/i.test(body);
96
+ const payload = {
97
+ messaging_product: "whatsapp",
98
+ recipient_type: "individual",
99
+ to: formatPhone(to),
100
+ type: "text",
101
+ text: hasUrl ? {
102
+ body,
103
+ preview_url: true
104
+ } : { body }
105
+ };
106
+ return this.send(payload, {
107
+ ...logging,
108
+ messageType: logging?.messageType || "bot_reply",
109
+ content: body
110
+ });
111
+ }
112
+ /** Send a template message (works outside service window). */
113
+ async sendTemplate(to, templateName, languageCode = "pt_BR", components, logging) {
114
+ const payload = {
115
+ messaging_product: "whatsapp",
116
+ to: formatPhone(to),
117
+ type: "template",
118
+ template: {
119
+ name: templateName,
120
+ language: { code: languageCode },
121
+ ...components && { components }
122
+ }
123
+ };
124
+ return this.send(payload, logging);
125
+ }
126
+ /** Send an interactive message with reply buttons (up to 3). */
127
+ async sendInteractiveButtons(to, bodyText, buttons, logging) {
128
+ const payload = {
129
+ messaging_product: "whatsapp",
130
+ recipient_type: "individual",
131
+ to: formatPhone(to),
132
+ type: "interactive",
133
+ interactive: {
134
+ type: "button",
135
+ body: { text: bodyText },
136
+ action: { buttons: buttons.map((b) => ({
137
+ type: "reply",
138
+ reply: {
139
+ id: b.id,
140
+ title: b.title
141
+ }
142
+ })) }
143
+ }
144
+ };
145
+ return this.send(payload, {
146
+ ...logging,
147
+ messageType: logging?.messageType || "bot_reply",
148
+ content: bodyText
149
+ });
150
+ }
151
+ /** Send an interactive list message with sections and rows. */
152
+ async sendInteractiveList(to, bodyText, buttonLabel, sections, logging) {
153
+ const payload = {
154
+ messaging_product: "whatsapp",
155
+ recipient_type: "individual",
156
+ to: formatPhone(to),
157
+ type: "interactive",
158
+ interactive: {
159
+ type: "list",
160
+ body: { text: bodyText },
161
+ action: {
162
+ button: buttonLabel,
163
+ sections
164
+ }
165
+ }
166
+ };
167
+ return this.send(payload, {
168
+ ...logging,
169
+ messageType: logging?.messageType || "bot_reply",
170
+ content: bodyText
171
+ });
172
+ }
173
+ /** Send an interactive media carousel message (2-10 cards). */
174
+ async sendInteractiveMediaCarousel(data, logging) {
175
+ const { to, body, cards } = data;
176
+ if (cards.length < 2 || cards.length > 10) return {
177
+ success: false,
178
+ error: "[sendInteractiveMediaCarousel] cards must contain between 2 and 10 items"
179
+ };
180
+ const mappedCards = cards.map((card, index) => ({
181
+ card_index: index,
182
+ type: "cta_url",
183
+ header: {
184
+ type: card.header.type,
185
+ ...card.header.type === "image" ? { image: { link: card.header.link } } : { video: { link: card.header.link } }
186
+ },
187
+ ...card.bodyText ? { body: { text: card.bodyText } } : {},
188
+ action: {
189
+ name: "cta_url",
190
+ parameters: {
191
+ display_text: card.button.displayText,
192
+ url: card.button.url
193
+ }
194
+ }
195
+ }));
196
+ const payload = {
197
+ messaging_product: "whatsapp",
198
+ recipient_type: "individual",
199
+ to: formatPhone(to),
200
+ type: "interactive",
201
+ interactive: {
202
+ type: "carousel",
203
+ body: { text: body },
204
+ action: { cards: mappedCards }
205
+ }
206
+ };
207
+ return this.send(payload, {
208
+ ...logging,
209
+ messageType: logging?.messageType || "bot_reply",
210
+ content: body
211
+ });
212
+ }
213
+ /** Send a location pin message. */
214
+ async sendLocation(to, latitude, longitude, name, address, logging) {
215
+ const payload = {
216
+ messaging_product: "whatsapp",
217
+ recipient_type: "individual",
218
+ to: formatPhone(to),
219
+ type: "location",
220
+ location: {
221
+ latitude,
222
+ longitude,
223
+ name,
224
+ address
225
+ }
226
+ };
227
+ return this.send(payload, {
228
+ ...logging,
229
+ messageType: logging?.messageType || "bot_reply",
230
+ content: `[Localização: ${name} - ${address}]`
231
+ });
232
+ }
233
+ /**
234
+ * Mark an inbound message as read.
235
+ *
236
+ * @see https://developers.facebook.com/docs/whatsapp/cloud-api/messages/mark-messages-as-read
237
+ */
238
+ async markAsRead(messageId) {
239
+ const payload = {
240
+ messaging_product: "whatsapp",
241
+ status: "read",
242
+ message_id: messageId
243
+ };
244
+ if (this.isDev) {
245
+ this.log.debug("whatsapp.dev_send", {
246
+ action: "mark_as_read",
247
+ payload
248
+ });
249
+ return {
250
+ success: true,
251
+ messageId: `dev-${Date.now()}`
252
+ };
253
+ }
254
+ return this.performRequest(payload, 0);
255
+ }
256
+ /**
257
+ * Show or hide a typing indicator in the chat.
258
+ * When starting, the indicator auto-dismisses after 25 seconds or when a message is sent.
259
+ *
260
+ * @see https://developers.facebook.com/docs/whatsapp/cloud-api/typing-indicators/
261
+ */
262
+ async typingIndicator(messageId, action = "typing_on") {
263
+ const payload = {
264
+ messaging_product: "whatsapp",
265
+ status: "read",
266
+ message_id: messageId,
267
+ typing_indicator: { type: action === "typing_on" ? "text" : void 0 }
268
+ };
269
+ if (this.isDev) {
270
+ this.log.debug("whatsapp.dev_send", {
271
+ action: "typing_indicator",
272
+ typingAction: action,
273
+ payload
274
+ });
275
+ return {
276
+ success: true,
277
+ messageId: `dev-${Date.now()}`
278
+ };
279
+ }
280
+ return this.performRequest(payload, 0);
281
+ }
282
+ /**
283
+ * Add a reaction to a message.
284
+ *
285
+ * @see https://developers.facebook.com/docs/whatsapp/cloud-api/messages/reaction-messages
286
+ */
287
+ async sendReaction(to, messageId, emoji) {
288
+ const payload = {
289
+ messaging_product: "whatsapp",
290
+ recipient_type: "individual",
291
+ to: formatPhone(to),
292
+ type: "reaction",
293
+ reaction: {
294
+ message_id: messageId,
295
+ emoji
296
+ }
297
+ };
298
+ return this.send(payload);
299
+ }
300
+ /** Core send method with retry logic (2 retries, exponential backoff). */
301
+ async send(payload, logging, retries = 2) {
302
+ if (this.isDev) {
303
+ this.log.debug("whatsapp.dev_send", {
304
+ action: "send",
305
+ payload
306
+ });
307
+ return {
308
+ success: true,
309
+ messageId: `dev-${Date.now()}`
310
+ };
311
+ }
312
+ const result = await this.performRequest(payload, retries);
313
+ if (logging) try {
314
+ await this.logger.logOutgoing({
315
+ phone: payload.to,
316
+ userId: logging.userId,
317
+ messageType: logging.messageType,
318
+ content: logging.content,
319
+ templateName: payload.type === "template" ? payload.template.name : void 0,
320
+ result,
321
+ metadata: logging.metadata
322
+ });
323
+ } catch (logError) {
324
+ this.log.error("whatsapp.log_failed", serializeError(logError));
325
+ }
326
+ return result;
327
+ }
328
+ /** Actually performs the network request with retries. */
329
+ async performRequest(payload, retries) {
330
+ for (let attempt = 0; attempt <= retries; attempt++) try {
331
+ const response = await fetch(this.baseUrl, {
332
+ method: "POST",
333
+ headers: {
334
+ Authorization: `Bearer ${this.token}`,
335
+ "Content-Type": "application/json"
336
+ },
337
+ body: JSON.stringify(payload)
338
+ });
339
+ if (!response.ok) {
340
+ let errorData = null;
341
+ try {
342
+ errorData = await response.json();
343
+ } catch {
344
+ errorData = null;
345
+ }
346
+ if (response.status === 429 && attempt < retries) {
347
+ await delay(1e3 * (attempt + 1));
348
+ continue;
349
+ }
350
+ if (response.status >= 500 && attempt < retries) {
351
+ await delay(500 * (attempt + 1));
352
+ continue;
353
+ }
354
+ return {
355
+ success: false,
356
+ error: errorData?.error?.message || `HTTP ${response.status}`,
357
+ errorCode: errorData?.error?.code,
358
+ httpStatus: response.status
359
+ };
360
+ }
361
+ return {
362
+ success: true,
363
+ messageId: (await response.json()).messages[0]?.id
364
+ };
365
+ } catch (error) {
366
+ if (attempt < retries) {
367
+ await delay(500 * (attempt + 1));
368
+ continue;
369
+ }
370
+ return {
371
+ success: false,
372
+ error: error instanceof Error ? error.message : "Network error"
373
+ };
374
+ }
375
+ return {
376
+ success: false,
377
+ error: "Max retries exceeded"
378
+ };
379
+ }
380
+ };
381
+ //#endregion
382
+ //#region src/services/message-logger.service.ts
383
+ const WHATSAPP_MESSAGE_TYPES = [
384
+ "queue_position",
385
+ "next_in_line",
386
+ "queue_optin",
387
+ "marketing",
388
+ "bot_reply",
389
+ "reminder",
390
+ "satisfaction",
391
+ "incoming"
392
+ ];
393
+ var MessageLoggerService = class {
394
+ log;
395
+ constructor(store, log, notifier) {
396
+ this.store = store;
397
+ this.notifier = notifier;
398
+ this.log = log;
399
+ }
400
+ async notify(event) {
401
+ if (!this.notifier) return;
402
+ try {
403
+ await this.notifier.notify(event);
404
+ } catch (err) {
405
+ this.log.error("message_logger.sync_notify_failed", serializeError(err));
406
+ }
407
+ }
408
+ /**
409
+ * Check if a message with this waMessageId was already processed.
410
+ */
411
+ async isDuplicate(waMessageId) {
412
+ return !!await this.store.getMessageByWaId(waMessageId);
413
+ }
414
+ /**
415
+ * Log outgoing message for LGPD compliance
416
+ */
417
+ async logOutgoing(params) {
418
+ const inserted = await this.store.createWhatsAppLog({
419
+ phone: params.phone,
420
+ userId: params.userId,
421
+ direction: "outgoing",
422
+ messageType: params.messageType,
423
+ content: params.content,
424
+ templateName: params.templateName,
425
+ waMessageId: params.result.messageId,
426
+ status: params.result.success ? "sent" : "failed",
427
+ errorMessage: params.result.error,
428
+ sentAt: (/* @__PURE__ */ new Date()).toISOString(),
429
+ metadata: params.metadata
430
+ });
431
+ const conversation = await this.store.getConversationById(inserted.conversationId);
432
+ if (conversation) await this.notify({
433
+ type: "NEW_MESSAGE",
434
+ message: inserted,
435
+ conversation
436
+ });
437
+ return inserted.id;
438
+ }
439
+ /**
440
+ * Update message status from webhook callback.
441
+ * Only applies if the new status advances the lifecycle (atomic, no race conditions).
442
+ * Returns true if the update was applied, false if skipped.
443
+ */
444
+ async updateStatus(waMessageId, status, timestamp, errorMessage) {
445
+ const updates = { status };
446
+ if (status === "sent") updates.sentAt = timestamp;
447
+ else if (status === "delivered") updates.deliveredAt = timestamp;
448
+ else if (status === "read") updates.readAt = timestamp;
449
+ else if (status === "failed") updates.errorMessage = errorMessage;
450
+ const updated = await this.store.updateStatusIfProgressed(waMessageId, status, updates);
451
+ if (updated) await this.notify({
452
+ type: "STATUS_UPDATE",
453
+ waMessageId,
454
+ status,
455
+ timestamp,
456
+ deliveredAt: updates.deliveredAt,
457
+ readAt: updates.readAt
458
+ });
459
+ return updated;
460
+ }
461
+ /**
462
+ * Check if there's a recent outgoing message to this phone within N hours.
463
+ *
464
+ * @param {string} phone : The phone number to check (in E.164 format)
465
+ * @param {number} [withinHours=24] : Time in hours to look for incoming messages
466
+ */
467
+ async hasRecentOutgoingMessage(phone, withinHours = 24) {
468
+ return this.store.hasRecentOutgoingMessage(phone, withinHours);
469
+ }
470
+ /**
471
+ * Log incoming message (for audit trail)
472
+ */
473
+ async logIncoming(params) {
474
+ const inserted = await this.store.createWhatsAppLog({
475
+ phone: params.phone,
476
+ contactName: params.senderName,
477
+ waMessageId: params.waMessageId,
478
+ direction: "incoming",
479
+ messageType: "incoming",
480
+ content: params.content,
481
+ status: "delivered",
482
+ metadata: params.metadata,
483
+ sentAt: (/* @__PURE__ */ new Date()).toISOString()
484
+ });
485
+ const conversation = await this.store.getConversationById(inserted.conversationId);
486
+ if (conversation) await this.notify({
487
+ type: "NEW_MESSAGE",
488
+ message: inserted,
489
+ conversation
490
+ });
491
+ }
492
+ };
493
+ //#endregion
494
+ //#region src/template-registry.ts
495
+ const EMPTY_TEMPLATE_REGISTRY = defineTemplates({});
496
+ function defineTemplates(templates) {
497
+ return templates;
498
+ }
499
+ function hasConfiguredTemplates(templates) {
500
+ return Object.keys(templates).length > 0;
501
+ }
502
+ function getTemplateNames(templates) {
503
+ return Object.keys(templates);
504
+ }
505
+ function serializeTemplateFromRegistry(templates, templateName, options) {
506
+ const templateDefinition = templates[templateName];
507
+ if (!templateDefinition) throw new Error(`[betterZap] Template "${String(templateName)}" is not configured.`);
508
+ const params = options.params ?? {};
509
+ const components = templateDefinition.components ?? [];
510
+ const expectedParameterNames = components.flatMap((component) => component.parameters.map((parameter) => parameter.name));
511
+ const unexpectedParameterNames = Object.keys(params).filter((parameterName) => !expectedParameterNames.includes(parameterName));
512
+ if (unexpectedParameterNames.length > 0) throw new Error(`[betterZap] Unexpected template params for "${String(templateName)}": ${unexpectedParameterNames.join(", ")}`);
513
+ const serializedComponents = components.filter((component) => component.parameters.length > 0).map((component) => ({
514
+ type: component.type,
515
+ ...component.type === "button" ? {
516
+ sub_type: component.subType,
517
+ index: component.index
518
+ } : {},
519
+ parameters: component.parameters.map((parameter) => {
520
+ if (!(parameter.name in params)) throw new Error(`[betterZap] Missing template param "${parameter.name}" for "${String(templateName)}".`);
521
+ return serializeTemplateParameter(parameter, params[parameter.name]);
522
+ })
523
+ }));
524
+ return {
525
+ language: options.language ?? templateDefinition.language,
526
+ components: serializedComponents.length > 0 ? serializedComponents : void 0
527
+ };
528
+ }
529
+ function serializeTemplateParameter(parameter, value) {
530
+ switch (parameter.type) {
531
+ case "text": return {
532
+ type: "text",
533
+ text: value
534
+ };
535
+ case "payload": return {
536
+ type: "payload",
537
+ payload: value
538
+ };
539
+ case "location": return {
540
+ type: "location",
541
+ location: value
542
+ };
543
+ case "image": return {
544
+ type: "image",
545
+ image: value
546
+ };
547
+ case "video": return {
548
+ type: "video",
549
+ video: value
550
+ };
551
+ case "document": return {
552
+ type: "document",
553
+ document: value
554
+ };
555
+ case "currency": return {
556
+ type: "currency",
557
+ currency: value
558
+ };
559
+ case "date_time": return {
560
+ type: "date_time",
561
+ date_time: value
562
+ };
563
+ }
564
+ }
565
+ //#endregion
566
+ export { EMPTY_TEMPLATE_REGISTRY, MessageLoggerService, WHATSAPP_MESSAGE_TYPES, WhatsAppService, createLogger, createZapClient, defineTemplates, delay, formatPhone, getTemplateNames, hasConfiguredTemplates, noopLogger, serializeError, serializeTemplateFromRegistry };
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "better-zap",
3
+ "version": "0.0.1",
4
+ "description": "Framework-agnostic Better Zap core for typed WhatsApp integrations.",
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
+ "./client": {
22
+ "types": "./dist/client.d.mts",
23
+ "import": "./dist/client.mjs",
24
+ "require": "./dist/client.cjs"
25
+ },
26
+ "./package.json": "./package.json"
27
+ },
28
+ "engines": {
29
+ "node": ">=20"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^25.4.0",
36
+ "tsdown": "^0.21.2",
37
+ "typescript": "^5.9.3",
38
+ "vitest": "^4.1.0"
39
+ },
40
+ "scripts": {
41
+ "build": "tsdown",
42
+ "typecheck": "tsc --noEmit",
43
+ "test": "vitest run"
44
+ }
45
+ }