@adatechnology/meta-whatsapp-module 0.1.0

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.js ADDED
@@ -0,0 +1,1506 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
3
+
4
+ // ../../../node_modules/.pnpm/tsup@8.5.1_@swc+core@1.15.40_@swc+helpers@0.5.23__jiti@2.7.0_postcss@8.5.15_tsx@4.23.1_typescript@5.9.3_yaml@2.9.0/node_modules/tsup/assets/esm_shims.js
5
+ import path from "path";
6
+ import { fileURLToPath } from "url";
7
+ var getFilename = /* @__PURE__ */ __name(() => fileURLToPath(import.meta.url), "getFilename");
8
+ var getDirname = /* @__PURE__ */ __name(() => path.dirname(getFilename()), "getDirname");
9
+ var __dirname = /* @__PURE__ */ getDirname();
10
+
11
+ // src/createMetaWhatsAppModule.ts
12
+ import { WhatsAppMessageProvider } from "@adatechnology/meta-whatsapp-provider";
13
+
14
+ // src/repositories/SessionRepository.ts
15
+ import { and, desc, eq, sql as sql2 } from "drizzle-orm";
16
+
17
+ // src/schema/schema.ts
18
+ import { sql } from "drizzle-orm";
19
+ import { pgSchema, uuid, varchar, jsonb, timestamp, text, integer, boolean, index, uniqueIndex } from "drizzle-orm/pg-core";
20
+ var metaWhatsAppSchema = pgSchema("meta_whatsapp");
21
+ var sessions = metaWhatsAppSchema.table("sessions", {
22
+ id: uuid("id").primaryKey().defaultRandom(),
23
+ // Multiempresa: toda entidade carrega companyId, vindo do contexto autenticado do host —
24
+ // nunca de um campo livre do cliente (ver database.md "Consistência e multiempresa").
25
+ companyId: uuid("company_id").notNull(),
26
+ whatsappNumber: varchar("whatsapp_number", {
27
+ length: 20
28
+ }).notNull(),
29
+ // varchar (não enum): a máquina de estados de cada produto evolui de forma independente do módulo.
30
+ currentState: varchar("current_state", {
31
+ length: 64
32
+ }).notNull().default("start"),
33
+ // Posição no grafo, quando o motor de fluxo está em uso — colunas separadas em vez de
34
+ // convencionar "flowKey:nodeId" dentro de currentState: além de ser um contrato implícito que
35
+ // nada garantia, a concatenação estourava o varchar(64) (flow key já é varchar(64) sozinha).
36
+ flowKey: varchar("flow_key", {
37
+ length: 64
38
+ }),
39
+ currentNodeId: varchar("current_node_id", {
40
+ length: 64
41
+ }),
42
+ context: jsonb("context").$type().notNull().default({}),
43
+ mode: varchar("mode", {
44
+ length: 12
45
+ }).notNull().default("bot"),
46
+ assignedUserId: uuid("assigned_user_id"),
47
+ humanRequestedAt: timestamp("human_requested_at", {
48
+ withTimezone: true
49
+ }),
50
+ lastInboundAt: timestamp("last_inbound_at", {
51
+ withTimezone: true
52
+ }),
53
+ lastAgentReadAt: timestamp("last_agent_read_at", {
54
+ withTimezone: true
55
+ }),
56
+ lastActivity: timestamp("last_activity", {
57
+ withTimezone: true
58
+ }).notNull().defaultNow(),
59
+ createdAt: timestamp("created_at", {
60
+ withTimezone: true
61
+ }).notNull().defaultNow(),
62
+ updatedAt: timestamp("updated_at", {
63
+ withTimezone: true
64
+ }).notNull().defaultNow()
65
+ }, (table) => [
66
+ // Único por empresa (não globalmente) — dois hosts diferentes podem atender o mesmo número.
67
+ uniqueIndex("idx_sessions_company_number").on(table.companyId, table.whatsappNumber),
68
+ index("idx_sessions_company_mode").on(table.companyId, table.mode),
69
+ // Alimenta getLiveFlowPositions (agregação por nó do grafo).
70
+ index("idx_sessions_company_flow_node").on(table.companyId, table.flowKey, table.currentNodeId)
71
+ ]);
72
+ var messages = metaWhatsAppSchema.table("messages", {
73
+ id: uuid("id").primaryKey().defaultRandom(),
74
+ companyId: uuid("company_id").notNull(),
75
+ sessionId: uuid("session_id").notNull().references(() => sessions.id, {
76
+ onDelete: "cascade"
77
+ }),
78
+ whatsappNumber: varchar("whatsapp_number", {
79
+ length: 20
80
+ }).notNull(),
81
+ direction: varchar("direction", {
82
+ length: 12
83
+ }).notNull(),
84
+ sender: varchar("sender", {
85
+ length: 12
86
+ }).notNull(),
87
+ agentUserId: uuid("agent_user_id"),
88
+ type: varchar("type", {
89
+ length: 16
90
+ }).notNull().default("text"),
91
+ content: text("content"),
92
+ // T5.4 — nunca base64 aqui; mídia vive no ObjectStorageInterface do host, referenciada por
93
+ // uploadId dentro deste jsonb.
94
+ payload: jsonb("payload").$type(),
95
+ waMessageId: varchar("wa_message_id", {
96
+ length: 128
97
+ }),
98
+ status: varchar("status", {
99
+ length: 16
100
+ }),
101
+ readAt: timestamp("read_at", {
102
+ withTimezone: true
103
+ }),
104
+ createdAt: timestamp("created_at", {
105
+ withTimezone: true
106
+ }).notNull().defaultNow()
107
+ }, (table) => [
108
+ index("idx_messages_session_created").on(table.sessionId, table.createdAt),
109
+ index("idx_messages_company_number_created").on(table.companyId, table.whatsappNumber, table.createdAt),
110
+ // Parcial e ÚNICO: é o que de fato garante idempotência de entrega. Um SELECT-antes-de-INSERT
111
+ // não basta — a Meta reenvia a mesma entrega e várias instâncias do host processam em
112
+ // paralelo, então as duas passariam pela checagem e inseririam duplicado. Parcial porque
113
+ // mensagens outbound ainda sem waMessageId (envio em curso) são legitimamente NULL, e NULLs
114
+ // não podem competir entre si por unicidade.
115
+ uniqueIndex("idx_messages_company_wa_message_id").on(table.companyId, table.waMessageId).where(sql`${table.waMessageId} is not null`)
116
+ ]);
117
+ var flowGraphs = metaWhatsAppSchema.table("flow_graphs", {
118
+ id: uuid("id").primaryKey().defaultRandom(),
119
+ companyId: uuid("company_id").notNull(),
120
+ key: varchar("key", {
121
+ length: 64
122
+ }).notNull(),
123
+ label: varchar("label", {
124
+ length: 120
125
+ }).notNull(),
126
+ startNodeId: varchar("start_node_id", {
127
+ length: 64
128
+ }).notNull(),
129
+ nodes: jsonb("nodes").$type().notNull().default({}),
130
+ // Otimista: o editor manda a versão que carregou; salvar com versão desatualizada é
131
+ // rejeitado pelo host (evita duas abas sobrescreverem uma a outra silenciosamente).
132
+ version: integer("version").notNull().default(1),
133
+ showInMenu: boolean("show_in_menu").notNull().default(false),
134
+ menuOptionLabel: varchar("menu_option_label", {
135
+ length: 64
136
+ }),
137
+ createdAt: timestamp("created_at", {
138
+ withTimezone: true
139
+ }).notNull().defaultNow(),
140
+ updatedAt: timestamp("updated_at", {
141
+ withTimezone: true
142
+ }).notNull().defaultNow()
143
+ }, (table) => [
144
+ uniqueIndex("idx_flow_graphs_company_key").on(table.companyId, table.key)
145
+ ]);
146
+ var settings = metaWhatsAppSchema.table("settings", {
147
+ companyId: uuid("company_id").primaryKey(),
148
+ templateName: varchar("template_name", {
149
+ length: 128
150
+ }),
151
+ templateLanguage: varchar("template_language", {
152
+ length: 16
153
+ }).notNull().default("pt_BR"),
154
+ // Mapa posicional {{1}}, {{2}}... → token resolvido no envio (ex.: '{clientName}').
155
+ templateVariables: jsonb("template_variables").$type().notNull().default([]),
156
+ welcomeMessage: text("welcome_message"),
157
+ farewellMessage: text("farewell_message"),
158
+ createdAt: timestamp("created_at", {
159
+ withTimezone: true
160
+ }).notNull().defaultNow(),
161
+ updatedAt: timestamp("updated_at", {
162
+ withTimezone: true
163
+ }).notNull().defaultNow()
164
+ });
165
+
166
+ // src/repositories/SessionRepository.ts
167
+ var DEFAULT_LIMIT = 20;
168
+ var SessionRepository = class {
169
+ static {
170
+ __name(this, "SessionRepository");
171
+ }
172
+ db;
173
+ constructor(db) {
174
+ this.db = db;
175
+ }
176
+ async getContext(companyId, whatsappNumber) {
177
+ const [row] = await this.db.select().from(sessions).where(and(eq(sessions.companyId, companyId), eq(sessions.whatsappNumber, whatsappNumber))).limit(1);
178
+ return row;
179
+ }
180
+ async getOrCreate(companyId, whatsappNumber, startState) {
181
+ const existing = await this.getContext(companyId, whatsappNumber);
182
+ if (existing) return existing;
183
+ const [created] = await this.db.insert(sessions).values({
184
+ companyId,
185
+ whatsappNumber,
186
+ currentState: startState
187
+ }).onConflictDoUpdate({
188
+ target: [
189
+ sessions.companyId,
190
+ sessions.whatsappNumber
191
+ ],
192
+ set: {
193
+ lastActivity: sql2`now()`
194
+ }
195
+ }).returning();
196
+ return created;
197
+ }
198
+ async setState(companyId, whatsappNumber, state, context) {
199
+ await this.db.update(sessions).set({
200
+ currentState: state,
201
+ ...context !== void 0 ? {
202
+ context
203
+ } : {},
204
+ lastActivity: sql2`now()`,
205
+ updatedAt: sql2`now()`
206
+ }).where(and(eq(sessions.companyId, companyId), eq(sessions.whatsappNumber, whatsappNumber)));
207
+ }
208
+ // Posição no grafo de fluxo — chamado pelo host a cada transição do FlowInterpreter.
209
+ // Passar null em ambos desliga o rastreio (ex.: conversa saiu do motor de fluxo).
210
+ async setFlowPosition(companyId, whatsappNumber, flowKey, currentNodeId) {
211
+ await this.db.update(sessions).set({
212
+ flowKey,
213
+ currentNodeId,
214
+ lastActivity: sql2`now()`,
215
+ updatedAt: sql2`now()`
216
+ }).where(and(eq(sessions.companyId, companyId), eq(sessions.whatsappNumber, whatsappNumber)));
217
+ }
218
+ // Marca a chegada de uma mensagem DO CLIENTE. É este carimbo — e não lastActivity, que também
219
+ // se move em ações do atendente/bot — que define a janela de 24h do WhatsApp: fora dela, só
220
+ // template aprovado reabre a conversa (ver WindowExpiredError nos contracts).
221
+ async touchInbound(companyId, whatsappNumber) {
222
+ await this.db.update(sessions).set({
223
+ lastInboundAt: sql2`now()`,
224
+ lastActivity: sql2`now()`,
225
+ updatedAt: sql2`now()`
226
+ }).where(and(eq(sessions.companyId, companyId), eq(sessions.whatsappNumber, whatsappNumber)));
227
+ }
228
+ // Horas desde a última mensagem do cliente — undefined se ele nunca escreveu. O canal (Fase 5)
229
+ // usa isto para decidir entre mensagem livre e template antes de chamar a Graph API.
230
+ async hoursSinceLastInbound(companyId, whatsappNumber) {
231
+ const session = await this.getContext(companyId, whatsappNumber);
232
+ if (!session?.lastInboundAt) return void 0;
233
+ return (Date.now() - session.lastInboundAt.getTime()) / (1e3 * 60 * 60);
234
+ }
235
+ async setMode(companyId, whatsappNumber, mode, assignedUserId) {
236
+ await this.db.update(sessions).set({
237
+ mode,
238
+ assignedUserId: assignedUserId ?? null,
239
+ updatedAt: sql2`now()`
240
+ }).where(and(eq(sessions.companyId, companyId), eq(sessions.whatsappNumber, whatsappNumber)));
241
+ }
242
+ // T3.3 — takeover: atendente assume a conversa, tirando-a do modo bot.
243
+ async takeover(companyId, whatsappNumber, agentUserId) {
244
+ await this.setMode(companyId, whatsappNumber, "human", agentUserId);
245
+ }
246
+ // T3.3 — release: devolve a conversa ao bot.
247
+ async release(companyId, whatsappNumber) {
248
+ await this.setMode(companyId, whatsappNumber, "bot", null);
249
+ }
250
+ async requestHuman(companyId, whatsappNumber) {
251
+ await this.db.update(sessions).set({
252
+ humanRequestedAt: sql2`now()`,
253
+ updatedAt: sql2`now()`
254
+ }).where(and(eq(sessions.companyId, companyId), eq(sessions.whatsappNumber, whatsappNumber)));
255
+ }
256
+ async markRead(companyId, whatsappNumber) {
257
+ await this.db.update(sessions).set({
258
+ lastAgentReadAt: sql2`now()`
259
+ }).where(and(eq(sessions.companyId, companyId), eq(sessions.whatsappNumber, whatsappNumber)));
260
+ }
261
+ async markAllRead(companyId, userId) {
262
+ const result = await this.db.update(sessions).set({
263
+ lastAgentReadAt: sql2`now()`
264
+ }).where(and(eq(sessions.companyId, companyId), eq(sessions.assignedUserId, userId)));
265
+ return Number(result.rowCount ?? 0);
266
+ }
267
+ async listByContextFilters(companyId, filters) {
268
+ const limit = filters.limit ?? DEFAULT_LIMIT;
269
+ const offset = ((filters.page ?? 1) - 1) * limit;
270
+ const conditions = [
271
+ eq(sessions.companyId, companyId)
272
+ ];
273
+ if (filters.waitingHuman) conditions.push(sql2`${sessions.humanRequestedAt} is not null`);
274
+ if (filters.search) conditions.push(sql2`${sessions.whatsappNumber} ilike ${"%" + filters.search + "%"}`);
275
+ const rows = await this.db.select({
276
+ id: sessions.id,
277
+ whatsappNumber: sessions.whatsappNumber,
278
+ mode: sessions.mode,
279
+ assignedUserId: sessions.assignedUserId,
280
+ currentState: sessions.currentState,
281
+ lastActivity: sessions.lastActivity,
282
+ lastInboundAt: sessions.lastInboundAt,
283
+ humanRequestedAt: sessions.humanRequestedAt
284
+ }).from(sessions).where(and(...conditions)).orderBy(desc(sessions.lastActivity)).limit(limit).offset(offset);
285
+ return rows.map((row) => ({
286
+ id: row.id,
287
+ whatsappNumber: row.whatsappNumber,
288
+ lastAt: row.lastActivity.toISOString(),
289
+ lastInboundAt: row.lastInboundAt?.toISOString() ?? null,
290
+ mode: row.mode,
291
+ assignedUserId: row.assignedUserId,
292
+ waitingHuman: row.humanRequestedAt !== null,
293
+ unread: 0,
294
+ currentState: row.currentState
295
+ }));
296
+ }
297
+ // T3.3 — export: transcript completo de uma conversa, mensagens em ordem cronológica.
298
+ async exportConversation(companyId, whatsappNumber) {
299
+ const session = await this.getContext(companyId, whatsappNumber);
300
+ if (!session) return null;
301
+ const rows = await this.db.select().from(messages).where(and(eq(messages.companyId, companyId), eq(messages.sessionId, session.id))).orderBy(messages.createdAt);
302
+ return {
303
+ session,
304
+ messages: rows
305
+ };
306
+ }
307
+ };
308
+
309
+ // src/repositories/MessageRepository.ts
310
+ import { and as and2, eq as eq2, lt } from "drizzle-orm";
311
+ var DEFAULT_LIMIT2 = 50;
312
+ var MessageRepository = class {
313
+ static {
314
+ __name(this, "MessageRepository");
315
+ }
316
+ db;
317
+ constructor(db) {
318
+ this.db = db;
319
+ }
320
+ // Idempotente por (companyId, waMessageId) — o mesmo webhook chega mais de uma vez (a Meta
321
+ // reenvia em instabilidade) e várias instâncias do host processam em paralelo. A garantia é o
322
+ // índice único parcial no banco + onConflictDoNothing, NÃO um SELECT prévio: duas entregas
323
+ // concorrentes passariam as duas pela checagem e inseririam duplicado.
324
+ // Devolve undefined quando a mensagem já existia (nada foi inserido).
325
+ async insertMessage(params) {
326
+ const values = {
327
+ companyId: params.companyId,
328
+ sessionId: params.sessionId,
329
+ whatsappNumber: params.whatsappNumber,
330
+ direction: params.direction,
331
+ sender: params.sender,
332
+ agentUserId: params.agentUserId ?? null,
333
+ type: params.type,
334
+ content: params.content ?? null,
335
+ payload: params.payload ?? null,
336
+ waMessageId: params.waMessageId ?? null,
337
+ status: params.status ?? null
338
+ };
339
+ const [created] = await this.db.insert(messages).values(values).onConflictDoNothing().returning();
340
+ return created;
341
+ }
342
+ async updateMessageStatus(companyId, waMessageId, status) {
343
+ const [updated] = await this.db.update(messages).set({
344
+ status,
345
+ ...status === "read" ? {
346
+ readAt: /* @__PURE__ */ new Date()
347
+ } : {}
348
+ }).where(and2(eq2(messages.companyId, companyId), eq2(messages.waMessageId, waMessageId))).returning();
349
+ return updated;
350
+ }
351
+ async listByConversation(params) {
352
+ const conditions = [
353
+ eq2(messages.companyId, params.companyId),
354
+ eq2(messages.sessionId, params.sessionId)
355
+ ];
356
+ if (params.before) conditions.push(lt(messages.createdAt, new Date(params.before)));
357
+ return this.db.select().from(messages).where(and2(...conditions)).orderBy(messages.createdAt).limit(params.limit ?? DEFAULT_LIMIT2);
358
+ }
359
+ };
360
+
361
+ // src/repositories/FlowGraphRepository.ts
362
+ import { and as and3, eq as eq3, isNotNull, sql as sql3 } from "drizzle-orm";
363
+ import { flowGraphNodesSchema } from "@adatechnology/meta-whatsapp-contracts";
364
+ var InvalidFlowGraphError = class extends Error {
365
+ static {
366
+ __name(this, "InvalidFlowGraphError");
367
+ }
368
+ validationMessage;
369
+ constructor(key, validationMessage) {
370
+ super(`Grafo do fluxo "${key}" est\xE1 malformado no banco: ${validationMessage}`), this.validationMessage = validationMessage;
371
+ this.name = "InvalidFlowGraphError";
372
+ }
373
+ };
374
+ function toContractGraph(row) {
375
+ const parsed = flowGraphNodesSchema.safeParse(row.nodes);
376
+ if (!parsed.success) throw new InvalidFlowGraphError(row.key, parsed.error.message);
377
+ return {
378
+ key: row.key,
379
+ label: row.label,
380
+ startNodeId: row.startNodeId,
381
+ version: row.version,
382
+ nodes: parsed.data
383
+ };
384
+ }
385
+ __name(toContractGraph, "toContractGraph");
386
+ var OptimisticLockError = class extends Error {
387
+ static {
388
+ __name(this, "OptimisticLockError");
389
+ }
390
+ constructor(key) {
391
+ super(`Fluxo "${key}" foi alterado por outra sess\xE3o \u2014 recarregue antes de salvar.`);
392
+ this.name = "OptimisticLockError";
393
+ }
394
+ };
395
+ var FlowGraphRepository = class {
396
+ static {
397
+ __name(this, "FlowGraphRepository");
398
+ }
399
+ db;
400
+ constructor(db) {
401
+ this.db = db;
402
+ }
403
+ async get(companyId, key) {
404
+ const [row] = await this.db.select().from(flowGraphs).where(and3(eq3(flowGraphs.companyId, companyId), eq3(flowGraphs.key, key))).limit(1);
405
+ return row ? toContractGraph(row) : void 0;
406
+ }
407
+ async list(companyId) {
408
+ const rows = await this.db.select().from(flowGraphs).where(eq3(flowGraphs.companyId, companyId));
409
+ return rows.map((row) => ({
410
+ key: row.key,
411
+ label: row.label,
412
+ nodeCount: Object.keys(row.nodes).length,
413
+ showInMenu: row.showInMenu,
414
+ menuOptionLabel: row.menuOptionLabel ?? void 0,
415
+ updatedAt: row.updatedAt.toISOString()
416
+ }));
417
+ }
418
+ // Valida antes de gravar: aceitar um grafo malformado e só reclamar na leitura empurraria a
419
+ // falha para o meio de uma conversa real, em vez de barrar quem salvou.
420
+ assertValidNodes(key, nodes) {
421
+ const parsed = flowGraphNodesSchema.safeParse(nodes);
422
+ if (!parsed.success) throw new InvalidFlowGraphError(key, parsed.error.message);
423
+ }
424
+ async create(companyId, graph) {
425
+ this.assertValidNodes(graph.key, graph.nodes);
426
+ const [created] = await this.db.insert(flowGraphs).values({
427
+ companyId,
428
+ key: graph.key,
429
+ label: graph.label,
430
+ startNodeId: graph.startNodeId,
431
+ nodes: graph.nodes,
432
+ showInMenu: graph.showInMenu ?? false,
433
+ menuOptionLabel: graph.menuOptionLabel
434
+ }).returning();
435
+ return toContractGraph(created);
436
+ }
437
+ // Lock otimista: a escrita só aplica se `expectedVersion` ainda bater com o que está salvo —
438
+ // senão, alguém mais salvou entretanto e o editor precisa recarregar (ver comentário no schema).
439
+ async save(companyId, graph, expectedVersion) {
440
+ this.assertValidNodes(graph.key, graph.nodes);
441
+ const rows = await this.db.update(flowGraphs).set({
442
+ label: graph.label,
443
+ startNodeId: graph.startNodeId,
444
+ nodes: graph.nodes,
445
+ version: expectedVersion + 1,
446
+ updatedAt: /* @__PURE__ */ new Date()
447
+ }).where(and3(eq3(flowGraphs.companyId, companyId), eq3(flowGraphs.key, graph.key), eq3(flowGraphs.version, expectedVersion))).returning();
448
+ if (rows.length === 0) throw new OptimisticLockError(graph.key);
449
+ return toContractGraph(rows[0]);
450
+ }
451
+ async delete(companyId, key) {
452
+ await this.db.delete(flowGraphs).where(and3(eq3(flowGraphs.companyId, companyId), eq3(flowGraphs.key, key)));
453
+ }
454
+ // T4.2 — GetLiveFlowPositions: agrega sessões ativas por (flowKey, currentNodeId), lendo as
455
+ // colunas dedicadas gravadas por SessionRepository.setFlowPosition. Agrega no banco (GROUP BY,
456
+ // coberto por idx_sessions_company_flow_node) em vez de trazer toda sessão da empresa para
457
+ // contar em memória.
458
+ async getLiveFlowPositions(companyId) {
459
+ const rows = await this.db.select({
460
+ flowKey: sessions.flowKey,
461
+ nodeId: sessions.currentNodeId,
462
+ count: sql3`count(*)::int`
463
+ }).from(sessions).where(and3(eq3(sessions.companyId, companyId), eq3(sessions.mode, "bot"), isNotNull(sessions.flowKey), isNotNull(sessions.currentNodeId))).groupBy(sessions.flowKey, sessions.currentNodeId);
464
+ return rows.map((row) => ({
465
+ flowKey: row.flowKey,
466
+ nodeId: row.nodeId,
467
+ count: row.count
468
+ }));
469
+ }
470
+ };
471
+
472
+ // src/repositories/SettingsRepository.ts
473
+ import { eq as eq4 } from "drizzle-orm";
474
+ function toContractSettings(row) {
475
+ return {
476
+ templateName: row.templateName ?? "",
477
+ templateLanguage: row.templateLanguage,
478
+ templateVariables: row.templateVariables,
479
+ welcomeMessage: row.welcomeMessage ?? "",
480
+ farewellMessage: row.farewellMessage ?? ""
481
+ };
482
+ }
483
+ __name(toContractSettings, "toContractSettings");
484
+ var EMPTY_SETTINGS = {
485
+ templateName: "",
486
+ templateLanguage: "pt_BR",
487
+ templateVariables: [],
488
+ welcomeMessage: "",
489
+ farewellMessage: ""
490
+ };
491
+ var SettingsRepository = class {
492
+ static {
493
+ __name(this, "SettingsRepository");
494
+ }
495
+ db;
496
+ constructor(db) {
497
+ this.db = db;
498
+ }
499
+ // Empresa sem linha de configuração é o estado normal logo após instalar o módulo — devolve
500
+ // os defaults em vez de undefined, para o chamador não precisar tratar "ainda não configurado"
501
+ // como caso especial em todo lugar.
502
+ async get(companyId) {
503
+ const [row] = await this.db.select().from(settings).where(eq4(settings.companyId, companyId)).limit(1);
504
+ return row ? toContractSettings(row) : EMPTY_SETTINGS;
505
+ }
506
+ async save(companyId, update) {
507
+ const current = await this.get(companyId);
508
+ const merged = {
509
+ ...current,
510
+ ...update
511
+ };
512
+ const [row] = await this.db.insert(settings).values({
513
+ companyId,
514
+ templateName: merged.templateName || null,
515
+ templateLanguage: merged.templateLanguage,
516
+ templateVariables: merged.templateVariables,
517
+ welcomeMessage: merged.welcomeMessage || null,
518
+ farewellMessage: merged.farewellMessage || null
519
+ }).onConflictDoUpdate({
520
+ target: settings.companyId,
521
+ set: {
522
+ templateName: merged.templateName || null,
523
+ templateLanguage: merged.templateLanguage,
524
+ templateVariables: merged.templateVariables,
525
+ welcomeMessage: merged.welcomeMessage || null,
526
+ farewellMessage: merged.farewellMessage || null,
527
+ updatedAt: /* @__PURE__ */ new Date()
528
+ }
529
+ }).returning();
530
+ return toContractSettings(row);
531
+ }
532
+ // Resolve {{1}}, {{2}}... a partir do mapa configurado + o contexto da conversa. Ex.: se
533
+ // templateVariables = ['{clientName}', '{city}'], devolve [nome, cidade] na ordem posicional
534
+ // que a Graph API espera nos bodyParameters.
535
+ async resolveTemplateVariables(companyId, context) {
536
+ const { templateVariables } = await this.get(companyId);
537
+ return templateVariables.map((token) => {
538
+ const key = token.replace(/^\{|\}$/g, "");
539
+ const value = context[key];
540
+ return value === void 0 || value === null ? "" : String(value);
541
+ });
542
+ }
543
+ };
544
+
545
+ // src/use-cases/LogMessage.use-case.ts
546
+ var LogMessageUseCase = class {
547
+ static {
548
+ __name(this, "LogMessageUseCase");
549
+ }
550
+ sessionRepository;
551
+ messageRepository;
552
+ realtime;
553
+ constructor(sessionRepository, messageRepository, realtime) {
554
+ this.sessionRepository = sessionRepository;
555
+ this.messageRepository = messageRepository;
556
+ this.realtime = realtime;
557
+ }
558
+ async execute(params) {
559
+ const session = await this.sessionRepository.getOrCreate(params.companyId, params.whatsappNumber, params.startState);
560
+ const saved = await this.messageRepository.insertMessage({
561
+ ...params,
562
+ sessionId: session.id
563
+ });
564
+ if (!saved) return void 0;
565
+ if (params.direction === "inbound") {
566
+ await this.sessionRepository.touchInbound(params.companyId, params.whatsappNumber);
567
+ }
568
+ this.realtime?.emit(`conv:${params.whatsappNumber}`, "message", {
569
+ direction: params.direction,
570
+ sender: params.sender
571
+ });
572
+ this.realtime?.emit("global", "data-changed", {});
573
+ return saved;
574
+ }
575
+ };
576
+
577
+ // src/use-cases/SendMessage.use-case.ts
578
+ import { WindowExpiredError } from "@adatechnology/meta-whatsapp-contracts";
579
+ var WHATSAPP_WINDOW_HOURS = 24;
580
+ var SendMessageUseCase = class {
581
+ static {
582
+ __name(this, "SendMessageUseCase");
583
+ }
584
+ channel;
585
+ sessionRepository;
586
+ logMessage;
587
+ objectStorage;
588
+ constructor(channel, sessionRepository, logMessage, objectStorage) {
589
+ this.channel = channel;
590
+ this.sessionRepository = sessionRepository;
591
+ this.logMessage = logMessage;
592
+ this.objectStorage = objectStorage;
593
+ }
594
+ async assertWithinWindow(companyId, whatsappNumber) {
595
+ const hours = await this.sessionRepository.hoursSinceLastInbound(companyId, whatsappNumber);
596
+ if (hours === void 0) throw new WindowExpiredError();
597
+ if (hours >= WHATSAPP_WINDOW_HOURS) throw new WindowExpiredError(hours);
598
+ }
599
+ async sendText(params) {
600
+ await this.assertWithinWindow(params.companyId, params.whatsappNumber);
601
+ const { externalMessageId } = await this.channel.sendText(params.whatsappNumber, params.body);
602
+ return this.logMessage.execute({
603
+ companyId: params.companyId,
604
+ whatsappNumber: params.whatsappNumber,
605
+ direction: "outbound",
606
+ sender: params.sender,
607
+ agentUserId: params.agentUserId ?? null,
608
+ type: "text",
609
+ content: params.body,
610
+ waMessageId: externalMessageId,
611
+ status: "sent",
612
+ startState: params.startState
613
+ });
614
+ }
615
+ async sendMedia(params) {
616
+ await this.assertWithinWindow(params.companyId, params.whatsappNumber);
617
+ const { externalMessageId } = await this.channel.sendMedia({
618
+ to: params.whatsappNumber,
619
+ buffer: params.buffer,
620
+ mimeType: params.mimeType,
621
+ filename: params.filename,
622
+ caption: params.caption
623
+ });
624
+ const uploadId = this.objectStorage ? (await this.objectStorage.upload({
625
+ buffer: params.buffer,
626
+ mimeType: params.mimeType,
627
+ key: `meta-whatsapp/${params.companyId}/${Date.now()}-${params.filename}`
628
+ })).uploadId : void 0;
629
+ return this.logMessage.execute({
630
+ companyId: params.companyId,
631
+ whatsappNumber: params.whatsappNumber,
632
+ direction: "outbound",
633
+ sender: params.sender,
634
+ agentUserId: params.agentUserId ?? null,
635
+ type: mediaTypeFor(params.mimeType),
636
+ content: params.caption ?? params.filename,
637
+ payload: {
638
+ filename: params.filename,
639
+ mimeType: params.mimeType,
640
+ ...uploadId ? {
641
+ uploadId
642
+ } : {}
643
+ },
644
+ waMessageId: externalMessageId,
645
+ status: "sent",
646
+ startState: params.startState
647
+ });
648
+ }
649
+ // Template é o único envio que ignora a janela — é justamente o mecanismo que a Meta oferece
650
+ // para reabri-la.
651
+ async sendTemplate(params) {
652
+ const { externalMessageId } = await this.channel.sendTemplate({
653
+ to: params.whatsappNumber,
654
+ templateName: params.templateName,
655
+ languageCode: params.languageCode ?? "pt_BR",
656
+ bodyParameters: params.bodyParameters
657
+ });
658
+ return this.logMessage.execute({
659
+ companyId: params.companyId,
660
+ whatsappNumber: params.whatsappNumber,
661
+ direction: "outbound",
662
+ sender: params.sender,
663
+ agentUserId: params.agentUserId ?? null,
664
+ type: "template",
665
+ content: params.templateName,
666
+ payload: {
667
+ templateName: params.templateName,
668
+ bodyParameters: params.bodyParameters ?? []
669
+ },
670
+ waMessageId: externalMessageId,
671
+ status: "sent",
672
+ startState: params.startState
673
+ });
674
+ }
675
+ };
676
+ function mediaTypeFor(mimeType) {
677
+ if (mimeType.startsWith("image/")) return "image";
678
+ if (mimeType.startsWith("audio/")) return "audio";
679
+ if (mimeType.startsWith("video/")) return "video";
680
+ return "document";
681
+ }
682
+ __name(mediaTypeFor, "mediaTypeFor");
683
+
684
+ // src/use-cases/TakeoverConversation.use-case.ts
685
+ var TakeoverConversationUseCase = class {
686
+ static {
687
+ __name(this, "TakeoverConversationUseCase");
688
+ }
689
+ sessionRepository;
690
+ realtime;
691
+ constructor(sessionRepository, realtime) {
692
+ this.sessionRepository = sessionRepository;
693
+ this.realtime = realtime;
694
+ }
695
+ async execute(params) {
696
+ await this.sessionRepository.takeover(params.companyId, params.whatsappNumber, params.agentUserId);
697
+ this.realtime?.emit(`conv:${params.whatsappNumber}`, "mode-changed", {
698
+ mode: "human",
699
+ assignedUserId: params.agentUserId
700
+ });
701
+ this.realtime?.emit("global", "data-changed", {});
702
+ }
703
+ };
704
+
705
+ // src/use-cases/ReleaseConversation.use-case.ts
706
+ var ReleaseConversationUseCase = class {
707
+ static {
708
+ __name(this, "ReleaseConversationUseCase");
709
+ }
710
+ sessionRepository;
711
+ realtime;
712
+ constructor(sessionRepository, realtime) {
713
+ this.sessionRepository = sessionRepository;
714
+ this.realtime = realtime;
715
+ }
716
+ async execute(params) {
717
+ await this.sessionRepository.release(params.companyId, params.whatsappNumber);
718
+ this.realtime?.emit(`conv:${params.whatsappNumber}`, "mode-changed", {
719
+ mode: "bot",
720
+ assignedUserId: null
721
+ });
722
+ this.realtime?.emit("global", "data-changed", {});
723
+ }
724
+ };
725
+
726
+ // src/use-cases/ListConversations.use-case.ts
727
+ var ListConversationsUseCase = class {
728
+ static {
729
+ __name(this, "ListConversationsUseCase");
730
+ }
731
+ sessionRepository;
732
+ constructor(sessionRepository) {
733
+ this.sessionRepository = sessionRepository;
734
+ }
735
+ async execute(params) {
736
+ return this.sessionRepository.listByContextFilters(params.companyId, params.filters ?? {});
737
+ }
738
+ };
739
+
740
+ // src/use-cases/ListMessages.use-case.ts
741
+ import { SessionNotFoundError } from "@adatechnology/meta-whatsapp-contracts";
742
+ var ListMessagesUseCase = class {
743
+ static {
744
+ __name(this, "ListMessagesUseCase");
745
+ }
746
+ sessionRepository;
747
+ messageRepository;
748
+ constructor(sessionRepository, messageRepository) {
749
+ this.sessionRepository = sessionRepository;
750
+ this.messageRepository = messageRepository;
751
+ }
752
+ async execute(params) {
753
+ const session = await this.sessionRepository.getContext(params.companyId, params.whatsappNumber);
754
+ if (!session) throw new SessionNotFoundError(params.whatsappNumber);
755
+ const repoParams = {
756
+ companyId: params.companyId,
757
+ sessionId: session.id,
758
+ limit: params.limit,
759
+ before: params.before
760
+ };
761
+ return this.messageRepository.listByConversation(repoParams);
762
+ }
763
+ };
764
+
765
+ // src/use-cases/ExportConversation.use-case.ts
766
+ import { SessionNotFoundError as SessionNotFoundError2 } from "@adatechnology/meta-whatsapp-contracts";
767
+ var ExportConversationUseCase = class {
768
+ static {
769
+ __name(this, "ExportConversationUseCase");
770
+ }
771
+ sessionRepository;
772
+ constructor(sessionRepository) {
773
+ this.sessionRepository = sessionRepository;
774
+ }
775
+ async execute(params) {
776
+ const result = await this.sessionRepository.exportConversation(params.companyId, params.whatsappNumber);
777
+ if (!result) throw new SessionNotFoundError2(params.whatsappNumber);
778
+ return result;
779
+ }
780
+ };
781
+
782
+ // src/use-cases/FlowGraph.use-cases.ts
783
+ var GetFlowGraphUseCase = class {
784
+ static {
785
+ __name(this, "GetFlowGraphUseCase");
786
+ }
787
+ flowGraphRepository;
788
+ constructor(flowGraphRepository) {
789
+ this.flowGraphRepository = flowGraphRepository;
790
+ }
791
+ async execute(params) {
792
+ return this.flowGraphRepository.get(params.companyId, params.key);
793
+ }
794
+ };
795
+ var ListFlowGraphsUseCase = class {
796
+ static {
797
+ __name(this, "ListFlowGraphsUseCase");
798
+ }
799
+ flowGraphRepository;
800
+ constructor(flowGraphRepository) {
801
+ this.flowGraphRepository = flowGraphRepository;
802
+ }
803
+ async execute(params) {
804
+ return this.flowGraphRepository.list(params.companyId);
805
+ }
806
+ };
807
+ var CreateFlowGraphUseCase = class {
808
+ static {
809
+ __name(this, "CreateFlowGraphUseCase");
810
+ }
811
+ flowGraphRepository;
812
+ constructor(flowGraphRepository) {
813
+ this.flowGraphRepository = flowGraphRepository;
814
+ }
815
+ async execute(params) {
816
+ return this.flowGraphRepository.create(params.companyId, params);
817
+ }
818
+ };
819
+ var SaveFlowGraphUseCase = class {
820
+ static {
821
+ __name(this, "SaveFlowGraphUseCase");
822
+ }
823
+ flowGraphRepository;
824
+ constructor(flowGraphRepository) {
825
+ this.flowGraphRepository = flowGraphRepository;
826
+ }
827
+ async execute(params) {
828
+ return this.flowGraphRepository.save(params.companyId, params.graph, params.expectedVersion);
829
+ }
830
+ };
831
+ var DeleteFlowGraphUseCase = class {
832
+ static {
833
+ __name(this, "DeleteFlowGraphUseCase");
834
+ }
835
+ flowGraphRepository;
836
+ constructor(flowGraphRepository) {
837
+ this.flowGraphRepository = flowGraphRepository;
838
+ }
839
+ async execute(params) {
840
+ return this.flowGraphRepository.delete(params.companyId, params.key);
841
+ }
842
+ };
843
+ var GetLiveFlowPositionsUseCase = class {
844
+ static {
845
+ __name(this, "GetLiveFlowPositionsUseCase");
846
+ }
847
+ flowGraphRepository;
848
+ constructor(flowGraphRepository) {
849
+ this.flowGraphRepository = flowGraphRepository;
850
+ }
851
+ async execute(params) {
852
+ return this.flowGraphRepository.getLiveFlowPositions(params.companyId);
853
+ }
854
+ };
855
+
856
+ // src/flows/FlowInterpreter.ts
857
+ import { isCrossFlowTarget, crossFlowKey } from "@adatechnology/meta-whatsapp-contracts";
858
+ var DEFAULT_MAX_STEPS = 50;
859
+ function evaluateCondition(node, context) {
860
+ if (!node.conditionContextKey || !node.conditionOperator || !node.conditionValue) return false;
861
+ const actual = context[node.conditionContextKey];
862
+ const expected = node.conditionValue;
863
+ switch (node.conditionOperator) {
864
+ case "==":
865
+ return String(actual) === expected;
866
+ case "!=":
867
+ return String(actual) !== expected;
868
+ case "contains":
869
+ return typeof actual === "string" && actual.includes(expected);
870
+ case ">":
871
+ case ">=":
872
+ case "<":
873
+ case "<=": {
874
+ const actualNum = Number(actual);
875
+ const expectedNum = Number(expected);
876
+ if (Number.isNaN(actualNum) || Number.isNaN(expectedNum)) return false;
877
+ if (node.conditionOperator === ">") return actualNum > expectedNum;
878
+ if (node.conditionOperator === ">=") return actualNum >= expectedNum;
879
+ if (node.conditionOperator === "<") return actualNum < expectedNum;
880
+ return actualNum <= expectedNum;
881
+ }
882
+ default:
883
+ return false;
884
+ }
885
+ }
886
+ __name(evaluateCondition, "evaluateCondition");
887
+ function resolveNext(node, answerId) {
888
+ if (!node.next) return void 0;
889
+ if (typeof node.next === "string") return node.next;
890
+ if (answerId && node.next.byAnswer[answerId]) return node.next.byAnswer[answerId];
891
+ return node.next.default;
892
+ }
893
+ __name(resolveNext, "resolveNext");
894
+ var FlowInterpreter = class {
895
+ static {
896
+ __name(this, "FlowInterpreter");
897
+ }
898
+ actionHandlers = /* @__PURE__ */ new Map();
899
+ registerFlowAction(kind, handler) {
900
+ this.actionHandlers.set(kind, handler);
901
+ }
902
+ async step(input) {
903
+ const node = input.graph.nodes[input.currentNodeId];
904
+ if (!node) return {
905
+ kind: "terminal",
906
+ context: input.context
907
+ };
908
+ if (node.type === "condition") {
909
+ const branch = evaluateCondition(node, input.context) ? "true" : "false";
910
+ return this.moveTo(input, resolveNext(node, branch));
911
+ }
912
+ if (node.type === "action") {
913
+ const handler = node.actionKind ? this.actionHandlers.get(node.actionKind) : void 0;
914
+ if (!handler) return {
915
+ kind: "terminal",
916
+ context: input.context
917
+ };
918
+ const result = await handler({
919
+ node,
920
+ session: input.session,
921
+ channel: input.channel,
922
+ context: input.context
923
+ });
924
+ const contextAfterAction = result?.context ? {
925
+ ...input.context,
926
+ ...result.context
927
+ } : input.context;
928
+ const nextNodeId2 = result?.next ?? resolveNext(node, void 0);
929
+ return this.moveTo({
930
+ ...input,
931
+ context: contextAfterAction
932
+ }, nextNodeId2);
933
+ }
934
+ const isChoice = node.type === "menu" || node.questionType === "choice";
935
+ if (input.userAnswer === void 0) {
936
+ return {
937
+ kind: "awaiting-answer",
938
+ nodeId: input.currentNodeId,
939
+ context: input.context
940
+ };
941
+ }
942
+ const updatedContext = node.contextKey ? {
943
+ ...input.context,
944
+ [node.contextKey]: input.userAnswer
945
+ } : input.context;
946
+ const answerId = isChoice ? input.userAnswer : void 0;
947
+ const nextNodeId = resolveNext(node, answerId);
948
+ return this.moveTo({
949
+ ...input,
950
+ context: updatedContext
951
+ }, nextNodeId);
952
+ }
953
+ moveTo(input, nextNodeId) {
954
+ if (!nextNodeId) return {
955
+ kind: "terminal",
956
+ context: input.context
957
+ };
958
+ if (isCrossFlowTarget(nextNodeId)) return {
959
+ kind: "cross-flow",
960
+ flowKey: crossFlowKey(nextNodeId),
961
+ context: input.context
962
+ };
963
+ if (!input.graph.nodes[nextNodeId]) return {
964
+ kind: "terminal",
965
+ context: input.context
966
+ };
967
+ return {
968
+ kind: "advanced",
969
+ nodeId: nextNodeId,
970
+ context: input.context
971
+ };
972
+ }
973
+ // Avança pelo grafo até parar num ponto que exige algo de fora: uma pergunta esperando resposta
974
+ // do cliente, o fim do fluxo, ou um salto para outro fluxo. Nós de condição e ação não pedem
975
+ // input nenhum, então parar neles obrigaria o chamador a reimplementar este laço — e sem
976
+ // proteção, um grafo com ciclo de condições (A→B→A) o prenderia para sempre.
977
+ //
978
+ // O corte por `maxSteps` é uma rede de segurança de runtime, não a defesa principal: um ciclo é
979
+ // erro de autoria e deveria ser barrado na publicação. Como validateGraph (no editor) hoje
980
+ // detecta nó inalcançável mas NÃO detecta ciclo, um grafo cíclico é publicável — então o
981
+ // runtime precisa sobreviver a ele em vez de travar o processo que atende o webhook.
982
+ async run(input, options) {
983
+ const maxSteps = options?.maxSteps ?? DEFAULT_MAX_STEPS;
984
+ const visited = [
985
+ input.currentNodeId
986
+ ];
987
+ let current = input;
988
+ let steps = 0;
989
+ while (steps < maxSteps) {
990
+ const result = await this.step(current);
991
+ steps++;
992
+ if (result.kind !== "advanced") return {
993
+ ...result,
994
+ visited,
995
+ steps
996
+ };
997
+ visited.push(result.nodeId);
998
+ current = {
999
+ ...current,
1000
+ currentNodeId: result.nodeId,
1001
+ context: result.context,
1002
+ userAnswer: void 0
1003
+ };
1004
+ }
1005
+ return {
1006
+ kind: "max-steps-exceeded",
1007
+ context: current.context,
1008
+ visited,
1009
+ steps
1010
+ };
1011
+ }
1012
+ };
1013
+
1014
+ // src/channel/WhatsAppChannelAdapter.ts
1015
+ import { WhatsAppWindowExpiredError as ProviderWindowExpiredError } from "@adatechnology/meta-graph-core";
1016
+ import { WindowExpiredError as WindowExpiredError2 } from "@adatechnology/meta-whatsapp-contracts";
1017
+ var WhatsAppChannelAdapter = class {
1018
+ static {
1019
+ __name(this, "WhatsAppChannelAdapter");
1020
+ }
1021
+ messages;
1022
+ constructor(messages2) {
1023
+ this.messages = messages2;
1024
+ }
1025
+ async translateErrors(operation) {
1026
+ try {
1027
+ return await operation();
1028
+ } catch (error) {
1029
+ if (error instanceof ProviderWindowExpiredError) throw new WindowExpiredError2();
1030
+ throw error;
1031
+ }
1032
+ }
1033
+ async sendText(to, body) {
1034
+ const result = await this.translateErrors(() => this.messages.sendText(to, body));
1035
+ return {
1036
+ externalMessageId: result.waMessageId
1037
+ };
1038
+ }
1039
+ async sendMedia(params) {
1040
+ const result = await this.translateErrors(() => this.messages.sendMedia(params));
1041
+ return {
1042
+ externalMessageId: result.waMessageId
1043
+ };
1044
+ }
1045
+ async sendTemplate(params) {
1046
+ const result = await this.translateErrors(() => this.messages.sendTemplate(params));
1047
+ return {
1048
+ externalMessageId: result.waMessageId
1049
+ };
1050
+ }
1051
+ async sendInteractiveList(params) {
1052
+ const result = await this.translateErrors(() => this.messages.sendInteractiveList({
1053
+ to: params.to,
1054
+ bodyText: params.body,
1055
+ buttonText: params.buttonLabel,
1056
+ sections: [
1057
+ {
1058
+ title: params.buttonLabel,
1059
+ rows: params.rows
1060
+ }
1061
+ ]
1062
+ }));
1063
+ return {
1064
+ externalMessageId: result.waMessageId
1065
+ };
1066
+ }
1067
+ async fetchMediaAsBase64(mediaId) {
1068
+ return this.translateErrors(() => this.messages.fetchMediaAsBase64(mediaId));
1069
+ }
1070
+ };
1071
+
1072
+ // src/channel/ReceiveWebhook.use-case.ts
1073
+ import { whatsAppWebhookPayloadSchema } from "@adatechnology/meta-whatsapp-contracts";
1074
+
1075
+ // src/channel/webhookSecurity.ts
1076
+ import { createHmac, timingSafeEqual } from "crypto";
1077
+ import { InvalidWebhookSignatureError } from "@adatechnology/meta-whatsapp-contracts";
1078
+ var WEBHOOK_NONCE_TTL_SECONDS = 300;
1079
+ function safeEqualStrings(left, right) {
1080
+ const leftDigest = createHmac("sha256", "constant-time-compare").update(left).digest();
1081
+ const rightDigest = createHmac("sha256", "constant-time-compare").update(right).digest();
1082
+ return timingSafeEqual(leftDigest, rightDigest);
1083
+ }
1084
+ __name(safeEqualStrings, "safeEqualStrings");
1085
+ function verifyWebhookChallenge(params) {
1086
+ if (params.mode !== "subscribe" || !params.token || !params.challenge) {
1087
+ throw new InvalidWebhookSignatureError();
1088
+ }
1089
+ if (!safeEqualStrings(params.token, params.expectedToken)) {
1090
+ throw new InvalidWebhookSignatureError();
1091
+ }
1092
+ return params.challenge;
1093
+ }
1094
+ __name(verifyWebhookChallenge, "verifyWebhookChallenge");
1095
+ function verifyWebhookSignature(params) {
1096
+ const { rawBody, signatureHeader, appSecret } = params;
1097
+ if (!signatureHeader?.startsWith("sha256=")) throw new InvalidWebhookSignatureError();
1098
+ const expected = createHmac("sha256", appSecret).update(rawBody).digest("hex");
1099
+ const received = signatureHeader.slice("sha256=".length);
1100
+ if (!safeEqualStrings(received, expected)) throw new InvalidWebhookSignatureError();
1101
+ }
1102
+ __name(verifyWebhookSignature, "verifyWebhookSignature");
1103
+ async function claimWebhookDelivery(params) {
1104
+ const key = `meta-whatsapp:webhook:${params.signatureHeader}`;
1105
+ return params.nonceStore.setIfAbsent(key, params.ttlSeconds ?? WEBHOOK_NONCE_TTL_SECONDS);
1106
+ }
1107
+ __name(claimWebhookDelivery, "claimWebhookDelivery");
1108
+
1109
+ // src/channel/ReceiveWebhook.use-case.ts
1110
+ function toSessionContract(row) {
1111
+ return {
1112
+ id: row.id,
1113
+ companyId: row.companyId,
1114
+ whatsappNumber: row.whatsappNumber,
1115
+ currentState: row.currentState,
1116
+ context: row.context,
1117
+ mode: row.mode,
1118
+ assignedUserId: row.assignedUserId,
1119
+ humanRequestedAt: row.humanRequestedAt?.toISOString() ?? null,
1120
+ lastInboundAt: row.lastInboundAt?.toISOString() ?? null,
1121
+ lastAgentReadAt: row.lastAgentReadAt?.toISOString() ?? null,
1122
+ lastActivity: row.lastActivity.toISOString(),
1123
+ createdAt: row.createdAt.toISOString(),
1124
+ updatedAt: row.updatedAt.toISOString()
1125
+ };
1126
+ }
1127
+ __name(toSessionContract, "toSessionContract");
1128
+ function extractContent(message) {
1129
+ if (message.text?.body) return message.text.body;
1130
+ const interactive = message.interactive;
1131
+ if (interactive?.button_reply) return interactive.button_reply.title || interactive.button_reply.id;
1132
+ if (interactive?.list_reply) return interactive.list_reply.title || interactive.list_reply.id;
1133
+ if (message.order) {
1134
+ const items = message.order.product_items.reduce((sum, item) => sum + item.quantity, 0);
1135
+ return `Pedido: ${items} item(ns)`;
1136
+ }
1137
+ return message.image?.caption ?? message.document?.caption ?? null;
1138
+ }
1139
+ __name(extractContent, "extractContent");
1140
+ function extractAnswer(message) {
1141
+ const interactive = message.interactive;
1142
+ if (interactive?.button_reply) return interactive.button_reply.id;
1143
+ if (interactive?.list_reply) return interactive.list_reply.id;
1144
+ return message.text?.body;
1145
+ }
1146
+ __name(extractAnswer, "extractAnswer");
1147
+ function extractPayload(message) {
1148
+ const payload = {};
1149
+ if (message.interactive) payload["interactive"] = message.interactive;
1150
+ if (message.order) payload["order"] = message.order;
1151
+ if (message.image) payload["image"] = message.image;
1152
+ if (message.audio) payload["audio"] = message.audio;
1153
+ if (message.video) payload["video"] = message.video;
1154
+ if (message.document) payload["document"] = message.document;
1155
+ if (message.sticker) payload["sticker"] = message.sticker;
1156
+ if (message.context?.referred_product) payload["referredProduct"] = message.context.referred_product;
1157
+ return Object.keys(payload).length > 0 ? payload : null;
1158
+ }
1159
+ __name(extractPayload, "extractPayload");
1160
+ var ReceiveWebhookUseCase = class {
1161
+ static {
1162
+ __name(this, "ReceiveWebhookUseCase");
1163
+ }
1164
+ params;
1165
+ constructor(params) {
1166
+ this.params = params;
1167
+ }
1168
+ async execute(input) {
1169
+ verifyWebhookSignature({
1170
+ rawBody: input.rawBody,
1171
+ signatureHeader: input.signatureHeader,
1172
+ appSecret: this.params.appSecret
1173
+ });
1174
+ const claimed = await claimWebhookDelivery({
1175
+ nonceStore: this.params.nonceStore,
1176
+ signatureHeader: input.signatureHeader
1177
+ });
1178
+ if (!claimed) return {
1179
+ duplicate: true,
1180
+ messagesProcessed: 0,
1181
+ statusesProcessed: 0
1182
+ };
1183
+ const rawText = typeof input.rawBody === "string" ? input.rawBody : input.rawBody.toString("utf8");
1184
+ const payload = whatsAppWebhookPayloadSchema.parse(JSON.parse(rawText));
1185
+ let messagesProcessed = 0;
1186
+ let statusesProcessed = 0;
1187
+ for (const entry of payload.entry) {
1188
+ for (const change of entry.changes) {
1189
+ for (const message of change.value.messages ?? []) {
1190
+ await this.handleMessage(input.companyId, message);
1191
+ messagesProcessed++;
1192
+ }
1193
+ for (const status of change.value.statuses ?? []) {
1194
+ await this.handleStatus(input.companyId, status);
1195
+ statusesProcessed++;
1196
+ }
1197
+ }
1198
+ }
1199
+ return {
1200
+ duplicate: false,
1201
+ messagesProcessed,
1202
+ statusesProcessed
1203
+ };
1204
+ }
1205
+ async handleMessage(companyId, message) {
1206
+ const saved = await this.params.logMessage.execute({
1207
+ companyId,
1208
+ whatsappNumber: message.from,
1209
+ direction: "inbound",
1210
+ sender: "customer",
1211
+ type: message.type,
1212
+ content: extractContent(message),
1213
+ payload: extractPayload(message),
1214
+ waMessageId: message.id,
1215
+ status: "received",
1216
+ startState: this.params.startState
1217
+ });
1218
+ if (!saved) return;
1219
+ const sessionRow = await this.params.sessionRepository.getContext(companyId, message.from);
1220
+ if (!sessionRow) return;
1221
+ if (sessionRow.mode === "human") return;
1222
+ const outcome = await this.params.hooks?.onMessageReceived?.(message, toSessionContract(sessionRow));
1223
+ if (outcome?.outcome === "handled") return;
1224
+ void extractAnswer(message);
1225
+ }
1226
+ async handleStatus(companyId, status) {
1227
+ const updated = await this.params.messageRepository.updateMessageStatus(companyId, status.id, status.status);
1228
+ if (!updated) return;
1229
+ this.params.realtime?.emit(`conv:${updated.whatsappNumber}`, "message-status", {
1230
+ waMessageId: status.id,
1231
+ status: status.status
1232
+ });
1233
+ const sessionRow = await this.params.sessionRepository.getContext(companyId, updated.whatsappNumber);
1234
+ await this.params.hooks?.onStatusUpdate?.(status, sessionRow ? toSessionContract(sessionRow) : null);
1235
+ }
1236
+ };
1237
+
1238
+ // src/channel/IngestInboundMedia.use-case.ts
1239
+ import { eq as eq5, and as and4 } from "drizzle-orm";
1240
+ var IngestInboundMediaUseCase = class {
1241
+ static {
1242
+ __name(this, "IngestInboundMediaUseCase");
1243
+ }
1244
+ db;
1245
+ channel;
1246
+ objectStorage;
1247
+ constructor(db, channel, objectStorage) {
1248
+ this.db = db;
1249
+ this.channel = channel;
1250
+ this.objectStorage = objectStorage;
1251
+ }
1252
+ async execute(params) {
1253
+ const [message] = await this.db.select().from(messages).where(and4(eq5(messages.companyId, params.companyId), eq5(messages.id, params.messageId))).limit(1);
1254
+ if (!message) throw new Error(`Mensagem ${params.messageId} n\xE3o encontrada para ingest\xE3o de m\xEDdia`);
1255
+ const payload = message.payload ?? {};
1256
+ if (payload["uploadId"] && payload["sourceMediaId"] === params.sourceMediaId) {
1257
+ return {
1258
+ uploadId: String(payload["uploadId"]),
1259
+ alreadyIngested: true
1260
+ };
1261
+ }
1262
+ const { data, mimeType } = await this.channel.fetchMediaAsBase64(params.sourceMediaId);
1263
+ const buffer = Buffer.from(data, "base64");
1264
+ const { uploadId } = await this.objectStorage.upload({
1265
+ buffer,
1266
+ mimeType: mimeType || params.mimeType,
1267
+ key: `meta-whatsapp/${params.companyId}/inbound/${params.sourceMediaId}`
1268
+ });
1269
+ const updatedPayload = {
1270
+ ...payload,
1271
+ uploadId,
1272
+ sourceMediaId: params.sourceMediaId,
1273
+ mimeType: mimeType || params.mimeType,
1274
+ ...params.filename ? {
1275
+ filename: params.filename
1276
+ } : {}
1277
+ };
1278
+ await this.db.update(messages).set({
1279
+ payload: updatedPayload
1280
+ }).where(and4(eq5(messages.companyId, params.companyId), eq5(messages.id, params.messageId)));
1281
+ return {
1282
+ uploadId,
1283
+ alreadyIngested: false
1284
+ };
1285
+ }
1286
+ };
1287
+ function extractMediaDescriptor(message) {
1288
+ const payload = message.payload ?? {};
1289
+ for (const key of [
1290
+ "image",
1291
+ "audio",
1292
+ "video",
1293
+ "document",
1294
+ "sticker"
1295
+ ]) {
1296
+ const media = payload[key];
1297
+ if (media?.id) {
1298
+ return {
1299
+ sourceMediaId: media.id,
1300
+ mimeType: media.mime_type ?? "application/octet-stream",
1301
+ filename: media.filename
1302
+ };
1303
+ }
1304
+ }
1305
+ return void 0;
1306
+ }
1307
+ __name(extractMediaDescriptor, "extractMediaDescriptor");
1308
+
1309
+ // src/createMetaWhatsAppModule.ts
1310
+ function createMetaWhatsAppModule(params) {
1311
+ const { db, config, nonceStore, providers = {}, hooks } = params;
1312
+ const startState = params.startState ?? "start";
1313
+ const flowEngineEnabled = params.features?.flowEngine ?? true;
1314
+ const messageProvider = new WhatsAppMessageProvider({
1315
+ accessToken: config.accessToken,
1316
+ phoneNumberId: config.phoneNumberId,
1317
+ wabaId: config.wabaId,
1318
+ apiVersion: config.apiVersion,
1319
+ baseUrl: config.baseUrl
1320
+ });
1321
+ const channel = new WhatsAppChannelAdapter(messageProvider);
1322
+ const sessionRepository = new SessionRepository(db);
1323
+ const messageRepository = new MessageRepository(db);
1324
+ const settingsRepository = new SettingsRepository(db);
1325
+ const flowGraphRepository = new FlowGraphRepository(db);
1326
+ const logMessage = new LogMessageUseCase(sessionRepository, messageRepository, providers.realtime);
1327
+ const sendMessage = new SendMessageUseCase(channel, sessionRepository, logMessage, providers.objectStorage);
1328
+ const receiveWebhook = new ReceiveWebhookUseCase({
1329
+ appSecret: config.appSecret,
1330
+ nonceStore,
1331
+ sessionRepository,
1332
+ messageRepository,
1333
+ logMessage,
1334
+ startState,
1335
+ hooks,
1336
+ realtime: providers.realtime
1337
+ });
1338
+ const flowInterpreter = flowEngineEnabled ? new FlowInterpreter() : void 0;
1339
+ const ingestInboundMedia = providers.objectStorage ? new IngestInboundMediaUseCase(db, channel, providers.objectStorage) : void 0;
1340
+ return {
1341
+ channel,
1342
+ // undefined quando providers.objectStorage não foi injetado.
1343
+ ingestInboundMedia,
1344
+ conversations: {
1345
+ log: logMessage,
1346
+ send: sendMessage,
1347
+ takeover: new TakeoverConversationUseCase(sessionRepository, providers.realtime),
1348
+ release: new ReleaseConversationUseCase(sessionRepository, providers.realtime),
1349
+ list: new ListConversationsUseCase(sessionRepository),
1350
+ listMessages: new ListMessagesUseCase(sessionRepository, messageRepository),
1351
+ export: new ExportConversationUseCase(sessionRepository),
1352
+ repository: sessionRepository
1353
+ },
1354
+ settings: settingsRepository,
1355
+ webhook: {
1356
+ receive: receiveWebhook,
1357
+ // GET de verificação da Meta — o host liga na sua rota e devolve o retorno como texto puro.
1358
+ verifyChallenge: /* @__PURE__ */ __name((query) => verifyWebhookChallenge({
1359
+ ...query,
1360
+ expectedToken: config.webhookVerifyToken
1361
+ }), "verifyChallenge")
1362
+ },
1363
+ // undefined quando features.flowEngine === false.
1364
+ flows: flowInterpreter ? {
1365
+ interpreter: flowInterpreter,
1366
+ registerFlowAction: /* @__PURE__ */ __name((kind, handler) => flowInterpreter.registerFlowAction(kind, handler), "registerFlowAction"),
1367
+ get: new GetFlowGraphUseCase(flowGraphRepository),
1368
+ list: new ListFlowGraphsUseCase(flowGraphRepository),
1369
+ create: new CreateFlowGraphUseCase(flowGraphRepository),
1370
+ save: new SaveFlowGraphUseCase(flowGraphRepository),
1371
+ delete: new DeleteFlowGraphUseCase(flowGraphRepository),
1372
+ livePositions: new GetLiveFlowPositionsUseCase(flowGraphRepository),
1373
+ repository: flowGraphRepository
1374
+ } : void 0,
1375
+ catalog: providers.catalog
1376
+ };
1377
+ }
1378
+ __name(createMetaWhatsAppModule, "createMetaWhatsAppModule");
1379
+
1380
+ // src/runMigrations.ts
1381
+ import { join } from "path";
1382
+ import { migrate } from "drizzle-orm/bun-sql/migrator";
1383
+ async function runMetaWhatsAppMigrations(db) {
1384
+ await migrate(db, {
1385
+ migrationsFolder: join(__dirname, "migrations"),
1386
+ migrationsTable: "meta_whatsapp_migrations"
1387
+ });
1388
+ }
1389
+ __name(runMetaWhatsAppMigrations, "runMetaWhatsAppMigrations");
1390
+
1391
+ // src/realtime/SseHub.ts
1392
+ import { randomUUID } from "crypto";
1393
+ var TICKET_TTL_SECONDS = 60;
1394
+ var SseHub = class {
1395
+ static {
1396
+ __name(this, "SseHub");
1397
+ }
1398
+ relay;
1399
+ localListeners = /* @__PURE__ */ new Map();
1400
+ // Uma assinatura de relay por CANAL, compartilhada por todos os listeners locais dele — uma
1401
+ // por listener faria o relay entregar N cópias num canal com N conexões abertas.
1402
+ relaySubscriptions = /* @__PURE__ */ new Map();
1403
+ // Identifica esta instância do processo para descartar o próprio eco (pub/sub entrega a
1404
+ // mensagem a todos os inscritos, inclusive quem publicou).
1405
+ instanceId = randomUUID();
1406
+ constructor(relay) {
1407
+ this.relay = relay;
1408
+ }
1409
+ emit(channel, event, payload) {
1410
+ this.dispatchLocal(channel, event, payload);
1411
+ if (this.relay) {
1412
+ const envelope = {
1413
+ originId: this.instanceId,
1414
+ event,
1415
+ payload
1416
+ };
1417
+ this.relay.publish(channel, JSON.stringify(envelope)).catch(() => void 0);
1418
+ }
1419
+ }
1420
+ dispatchLocal(channel, event, payload) {
1421
+ const listeners = this.localListeners.get(channel);
1422
+ if (!listeners) return;
1423
+ for (const listener of listeners) listener(event, payload);
1424
+ }
1425
+ async subscribe(channel, listener) {
1426
+ if (!this.localListeners.has(channel)) this.localListeners.set(channel, /* @__PURE__ */ new Set());
1427
+ this.localListeners.get(channel).add(listener);
1428
+ if (this.relay && !this.relaySubscriptions.has(channel)) {
1429
+ this.relaySubscriptions.set(channel, this.relay.subscribe(channel, (message) => {
1430
+ try {
1431
+ const envelope = JSON.parse(message);
1432
+ if (envelope.originId === this.instanceId) return;
1433
+ this.dispatchLocal(channel, envelope.event, envelope.payload);
1434
+ } catch {
1435
+ }
1436
+ }));
1437
+ }
1438
+ return () => {
1439
+ const listeners = this.localListeners.get(channel);
1440
+ listeners?.delete(listener);
1441
+ if (listeners && listeners.size === 0) {
1442
+ this.localListeners.delete(channel);
1443
+ const pendingUnsubscribe = this.relaySubscriptions.get(channel);
1444
+ this.relaySubscriptions.delete(channel);
1445
+ pendingUnsubscribe?.then((unsubscribe) => unsubscribe()).catch(() => void 0);
1446
+ }
1447
+ };
1448
+ }
1449
+ };
1450
+ async function issueSseTicket(store, companyId, whatsappNumber) {
1451
+ const ticket = randomUUID();
1452
+ await store.set(`sse:ticket:${ticket}`, JSON.stringify({
1453
+ companyId,
1454
+ whatsappNumber
1455
+ }), TICKET_TTL_SECONDS);
1456
+ return ticket;
1457
+ }
1458
+ __name(issueSseTicket, "issueSseTicket");
1459
+ async function redeemSseTicket(store, ticket) {
1460
+ const raw = await store.get(`sse:ticket:${ticket}`);
1461
+ if (!raw) return null;
1462
+ await store.delete(`sse:ticket:${ticket}`);
1463
+ return JSON.parse(raw);
1464
+ }
1465
+ __name(redeemSseTicket, "redeemSseTicket");
1466
+ export {
1467
+ CreateFlowGraphUseCase,
1468
+ DeleteFlowGraphUseCase,
1469
+ ExportConversationUseCase,
1470
+ FlowGraphRepository,
1471
+ FlowInterpreter,
1472
+ GetFlowGraphUseCase,
1473
+ GetLiveFlowPositionsUseCase,
1474
+ IngestInboundMediaUseCase,
1475
+ InvalidFlowGraphError,
1476
+ ListConversationsUseCase,
1477
+ ListFlowGraphsUseCase,
1478
+ ListMessagesUseCase,
1479
+ LogMessageUseCase,
1480
+ MessageRepository,
1481
+ OptimisticLockError,
1482
+ ReceiveWebhookUseCase,
1483
+ ReleaseConversationUseCase,
1484
+ SaveFlowGraphUseCase,
1485
+ SendMessageUseCase,
1486
+ SessionRepository,
1487
+ SettingsRepository,
1488
+ SseHub,
1489
+ TakeoverConversationUseCase,
1490
+ WEBHOOK_NONCE_TTL_SECONDS,
1491
+ WhatsAppChannelAdapter,
1492
+ claimWebhookDelivery,
1493
+ createMetaWhatsAppModule,
1494
+ extractMediaDescriptor,
1495
+ flowGraphs,
1496
+ issueSseTicket,
1497
+ messages,
1498
+ metaWhatsAppSchema,
1499
+ redeemSseTicket,
1500
+ runMetaWhatsAppMigrations,
1501
+ sessions,
1502
+ settings,
1503
+ verifyWebhookChallenge,
1504
+ verifyWebhookSignature
1505
+ };
1506
+ //# sourceMappingURL=index.js.map