@adatechnology/meta-whatsapp-module 0.2.0-rc.3 → 0.2.0-rc.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -8,11 +8,11 @@ var __export = (target, all) => {
8
8
  for (var name in all)
9
9
  __defProp(target, name, { get: all[name], enumerable: true });
10
10
  };
11
- var __copyProps = (to, from, except, desc2) => {
11
+ var __copyProps = (to, from, except, desc3) => {
12
12
  if (from && typeof from === "object" || typeof from === "function") {
13
13
  for (let key of __getOwnPropNames(from))
14
14
  if (!__hasOwnProp.call(to, key) && key !== except)
15
- __defProp(to, key, { get: () => from[key], enumerable: !(desc2 = __getOwnPropDesc(from, key)) || desc2.enumerable });
15
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc3 = __getOwnPropDesc(from, key)) || desc3.enumerable });
16
16
  }
17
17
  return to;
18
18
  };
@@ -22,14 +22,22 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
22
22
  var index_exports = {};
23
23
  __export(index_exports, {
24
24
  CreateFlowGraphUseCase: () => CreateFlowGraphUseCase,
25
+ DEFAULT_FLOW_GRAPH_CACHE_TTL_SECONDS: () => DEFAULT_FLOW_GRAPH_CACHE_TTL_SECONDS,
26
+ DeleteConversationUseCase: () => DeleteConversationUseCase,
25
27
  DeleteFlowGraphUseCase: () => DeleteFlowGraphUseCase,
28
+ DocumentRepository: () => DocumentRepository,
26
29
  ExportConversationUseCase: () => ExportConversationUseCase,
30
+ FlowGraphCache: () => FlowGraphCache,
27
31
  FlowGraphRepository: () => FlowGraphRepository,
28
32
  FlowInterpreter: () => FlowInterpreter,
33
+ FlowMediaRepository: () => FlowMediaRepository,
29
34
  GetFlowGraphUseCase: () => GetFlowGraphUseCase,
30
35
  GetLiveFlowPositionsUseCase: () => GetLiveFlowPositionsUseCase,
36
+ InboundEffectsDispatcher: () => InboundEffectsDispatcher,
31
37
  IngestInboundMediaUseCase: () => IngestInboundMediaUseCase,
32
38
  InvalidFlowGraphError: () => InvalidFlowGraphError,
39
+ ListCompanyDocumentsUseCase: () => ListCompanyDocumentsUseCase,
40
+ ListConversationDocumentsUseCase: () => ListConversationDocumentsUseCase,
33
41
  ListConversationsUseCase: () => ListConversationsUseCase,
34
42
  ListFlowGraphsUseCase: () => ListFlowGraphsUseCase,
35
43
  ListMessagesUseCase: () => ListMessagesUseCase,
@@ -37,6 +45,10 @@ __export(index_exports, {
37
45
  META_WHATSAPP_MIGRATIONS_TABLE: () => META_WHATSAPP_MIGRATIONS_TABLE,
38
46
  MessageRepository: () => MessageRepository,
39
47
  OptimisticLockError: () => OptimisticLockError,
48
+ PREVIEW_MEDIA_ID_PREFIX: () => import_meta_whatsapp_contracts8.PREVIEW_MEDIA_ID_PREFIX,
49
+ PRODUCT_LIST_LIMIT: () => PRODUCT_LIST_LIMIT,
50
+ ProcessInboundDispatchUseCase: () => ProcessInboundDispatchUseCase,
51
+ PurgeExpiredDocumentsUseCase: () => PurgeExpiredDocumentsUseCase,
40
52
  ReceiveWebhookUseCase: () => ReceiveWebhookUseCase,
41
53
  ReleaseConversationUseCase: () => ReleaseConversationUseCase,
42
54
  SaveFlowGraphUseCase: () => SaveFlowGraphUseCase,
@@ -44,21 +56,41 @@ __export(index_exports, {
44
56
  SessionRepository: () => SessionRepository,
45
57
  SettingsRepository: () => SettingsRepository,
46
58
  SseHub: () => SseHub,
59
+ StorePreviewMediaUseCase: () => StorePreviewMediaUseCase,
60
+ TRANSCRIPTION_MODE: () => TRANSCRIPTION_MODE,
61
+ TRANSCRIPTION_STATUS: () => TRANSCRIPTION_STATUS,
47
62
  TakeoverConversationUseCase: () => TakeoverConversationUseCase,
48
- WEBHOOK_NONCE_TTL_SECONDS: () => WEBHOOK_NONCE_TTL_SECONDS,
63
+ TranscribeAudioUseCase: () => TranscribeAudioUseCase,
64
+ WEBHOOK_CLAIM_TTL_SECONDS: () => import_meta_graph_core2.WEBHOOK_CLAIM_TTL_SECONDS,
65
+ WEBHOOK_NONCE_TTL_SECONDS: () => import_meta_graph_core2.WEBHOOK_NONCE_TTL_SECONDS,
49
66
  WhatsAppChannelAdapter: () => WhatsAppChannelAdapter,
67
+ buildInboundJobId: () => buildInboundJobId,
50
68
  claimWebhookDelivery: () => claimWebhookDelivery,
69
+ confirmWebhookDelivery: () => confirmWebhookDelivery,
51
70
  createMetaWhatsAppModule: () => createMetaWhatsAppModule,
71
+ createSendMediaAction: () => createSendMediaAction,
72
+ createSendProductListAction: () => createSendProductListAction,
73
+ createTranscriptionPolicyResolver: () => createTranscriptionPolicyResolver,
74
+ documents: () => documents,
52
75
  extractMediaDescriptor: () => extractMediaDescriptor,
53
76
  flowGraphs: () => flowGraphs,
77
+ flowMedia: () => flowMedia,
78
+ isAudioMimeType: () => isAudioMimeType,
79
+ isRetriableTranscriptionError: () => isRetriableTranscriptionError,
80
+ isUnsupportedTranscriptionError: () => isUnsupportedTranscriptionError,
54
81
  issueSseTicket: () => issueSseTicket,
55
82
  messages: () => messages,
56
83
  metaWhatsAppMigrationsFolder: () => metaWhatsAppMigrationsFolder,
57
84
  metaWhatsAppSchema: () => metaWhatsAppSchema,
58
85
  redeemSseTicket: () => redeemSseTicket,
86
+ resolveFailureStatus: () => resolveFailureStatus,
87
+ resolvePreviewUploadId: () => import_meta_whatsapp_contracts8.resolvePreviewUploadId,
59
88
  runMetaWhatsAppMigrations: () => runMetaWhatsAppMigrations,
60
89
  sessions: () => sessions,
61
90
  settings: () => settings,
91
+ toPreviewMediaId: () => import_meta_whatsapp_contracts8.toPreviewMediaId,
92
+ toSessionContract: () => toSessionContract,
93
+ transcriptionRetryAfterSeconds: () => transcriptionRetryAfterSeconds,
62
94
  verifyWebhookChallenge: () => verifyWebhookChallenge,
63
95
  verifyWebhookSignature: () => verifyWebhookSignature
64
96
  });
@@ -66,6 +98,7 @@ module.exports = __toCommonJS(index_exports);
66
98
 
67
99
  // src/createMetaWhatsAppModule.ts
68
100
  var import_meta_whatsapp_provider = require("@adatechnology/meta-whatsapp-provider");
101
+ var import_meta_whatsapp_contracts13 = require("@adatechnology/meta-whatsapp-contracts");
69
102
 
70
103
  // src/repositories/SessionRepository.ts
71
104
  var import_drizzle_orm2 = require("drizzle-orm");
@@ -141,9 +174,14 @@ var messages = metaWhatsAppSchema.table("messages", {
141
174
  length: 12
142
175
  }).notNull(),
143
176
  agentUserId: (0, import_pg_core.uuid)("agent_user_id"),
144
- type: (0, import_pg_core.varchar)("type", {
145
- length: 16
146
- }).notNull().default("text"),
177
+ // 32, não 16: os tipos da própria Meta são curtos ('text', 'interactive'), mas o host rotula
178
+ type: (
179
+ // a saída com o subtipo que enviou ('interactive_buttons' já tem 19). Apertar aqui só
180
+ // transfere para o consumidor a escolha entre truncar o rótulo e estourar o insert.
181
+ (0, import_pg_core.varchar)("type", {
182
+ length: 32
183
+ }).notNull().default("text")
184
+ ),
147
185
  content: (0, import_pg_core.text)("content"),
148
186
  // T5.4 — nunca base64 aqui; mídia vive no ObjectStorageInterface do host, referenciada por
149
187
  // uploadId dentro deste jsonb.
@@ -157,6 +195,33 @@ var messages = metaWhatsAppSchema.table("messages", {
157
195
  readAt: (0, import_pg_core.timestamp)("read_at", {
158
196
  withTimezone: true
159
197
  }),
198
+ // Moderação de conteúdo. `null` significa NÃO AVALIADO (moderação desligada, ou mensagem
199
+ // anterior ao recurso) — diferente de `false`, que é avaliado e limpo. Colunas em vez de chave
200
+ // dentro de `payload` porque "listar o que foi sinalizado" é consulta de operação, e índice
201
+ // parcial sobre boolean resolve isso sem cavar jsonb.
202
+ moderationFlagged: (0, import_pg_core.boolean)("moderation_flagged"),
203
+ moderationTerms: (0, import_pg_core.jsonb)("moderation_terms").$type(),
204
+ /**
205
+ * Transcrição de áudio. `null` em `transcription_status` significa NÃO AVALIADO — áudio nunca
206
+ * pedido (modo sob demanda), transcrição desligada, mensagem anterior ao recurso, ou mensagem
207
+ * que não é áudio. Diferente de `'done'` com texto vazio, que é áudio em silêncio já processado
208
+ * e que NÃO deve ser reprocessado.
209
+ *
210
+ * Colunas em vez de chave em `payload` pelo mesmo motivo da moderação: "quais áudios ficaram
211
+ * pendentes" e "quais falharam" são consultas de operação, e índice parcial resolve sem cavar
212
+ * jsonb. Buscar texto de áudio também deixa de exigir varredura de payload.
213
+ */
214
+ transcriptionStatus: (0, import_pg_core.varchar)("transcription_status", {
215
+ length: 16
216
+ }).$type(),
217
+ transcriptionText: (0, import_pg_core.text)("transcription_text"),
218
+ transcriptionLanguage: (0, import_pg_core.varchar)("transcription_language", {
219
+ length: 32
220
+ }),
221
+ /** Qual engine produziu. Com uma cadeia de engines, é o que responde "por que esta saiu ruim". */
222
+ transcriptionEngine: (0, import_pg_core.varchar)("transcription_engine", {
223
+ length: 32
224
+ }),
160
225
  createdAt: (0, import_pg_core.timestamp)("created_at", {
161
226
  withTimezone: true
162
227
  }).notNull().defaultNow()
@@ -168,7 +233,54 @@ var messages = metaWhatsAppSchema.table("messages", {
168
233
  // paralelo, então as duas passariam pela checagem e inseririam duplicado. Parcial porque
169
234
  // mensagens outbound ainda sem waMessageId (envio em curso) são legitimamente NULL, e NULLs
170
235
  // não podem competir entre si por unicidade.
171
- (0, import_pg_core.uniqueIndex)("idx_messages_company_wa_message_id").on(table.companyId, table.waMessageId).where(import_drizzle_orm.sql`${table.waMessageId} is not null`)
236
+ (0, import_pg_core.uniqueIndex)("idx_messages_company_wa_message_id").on(table.companyId, table.waMessageId).where(import_drizzle_orm.sql`${table.waMessageId} is not null`),
237
+ // Parcial: só as sinalizadas entram, então o índice fica do tamanho do problema e não do
238
+ // tamanho do transcript.
239
+ (0, import_pg_core.index)("idx_messages_moderation_flagged").on(table.companyId, table.createdAt).where(import_drizzle_orm.sql`${table.moderationFlagged}`),
240
+ // Alimenta a varredura de retomada: "quais áudios ficaram pendentes de transcrição". Parcial
241
+ // porque pendente é estado transitório e raro — o índice fica do tamanho da fila atrasada, não
242
+ // do transcript inteiro.
243
+ (0, import_pg_core.index)("idx_messages_transcription_pending").on(table.companyId, table.createdAt).where(import_drizzle_orm.sql`${table.transcriptionStatus} = 'pending'`)
244
+ ]);
245
+ var documents = metaWhatsAppSchema.table("documents", {
246
+ id: (0, import_pg_core.uuid)("id").primaryKey().defaultRandom(),
247
+ companyId: (0, import_pg_core.uuid)("company_id").notNull(),
248
+ sessionId: (0, import_pg_core.uuid)("session_id").notNull().references(() => sessions.id, {
249
+ onDelete: "cascade"
250
+ }),
251
+ // Anulável e `set null`: apagar a mensagem não deve apagar o arquivo da biblioteca, e arquivo
252
+ // do atendente nasce sem mensagem.
253
+ messageId: (0, import_pg_core.uuid)("message_id").references(() => messages.id, {
254
+ onDelete: "set null"
255
+ }),
256
+ uploadId: (0, import_pg_core.varchar)("upload_id", {
257
+ length: 256
258
+ }).notNull(),
259
+ filename: (0, import_pg_core.varchar)("filename", {
260
+ length: 512
261
+ }).notNull(),
262
+ mimeType: (0, import_pg_core.varchar)("mime_type", {
263
+ length: 128
264
+ }).notNull(),
265
+ sizeBytes: (0, import_pg_core.integer)("size_bytes").notNull(),
266
+ // Do provider de storage, que é endereçado por conteúdo — serve para reconciliar storage
267
+ // contra tabela e para detectar o mesmo binário chegando duas vezes.
268
+ sha256: (0, import_pg_core.varchar)("sha256", {
269
+ length: 64
270
+ }),
271
+ source: (0, import_pg_core.varchar)("source", {
272
+ length: 12
273
+ }).notNull(),
274
+ linkedAt: (0, import_pg_core.timestamp)("linked_at", {
275
+ withTimezone: true
276
+ }).notNull().defaultNow()
277
+ }, (table) => [
278
+ (0, import_pg_core.index)("idx_documents_session_linked").on(table.sessionId, table.linkedAt),
279
+ // Para varredura de retenção por idade sem passar por sessão.
280
+ (0, import_pg_core.index)("idx_documents_company_linked").on(table.companyId, table.linkedAt),
281
+ // O mesmo objeto não pode ser linkado duas vezes na mesma empresa: o job de ingestão é
282
+ // reentregue por retry, e sem isto a reentrega criaria linha duplicada no painel.
283
+ (0, import_pg_core.uniqueIndex)("idx_documents_company_upload").on(table.companyId, table.uploadId)
172
284
  ]);
173
285
  var flowGraphs = metaWhatsAppSchema.table("flow_graphs", {
174
286
  id: (0, import_pg_core.uuid)("id").primaryKey().defaultRandom(),
@@ -199,6 +311,46 @@ var flowGraphs = metaWhatsAppSchema.table("flow_graphs", {
199
311
  }, (table) => [
200
312
  (0, import_pg_core.uniqueIndex)("idx_flow_graphs_company_key").on(table.companyId, table.key)
201
313
  ]);
314
+ var flowMedia = metaWhatsAppSchema.table("flow_media", {
315
+ id: (0, import_pg_core.uuid)("id").primaryKey().defaultRandom(),
316
+ companyId: (0, import_pg_core.uuid)("company_id").notNull(),
317
+ flowKey: (0, import_pg_core.varchar)("flow_key", {
318
+ length: 64
319
+ }).notNull(),
320
+ nodeId: (0, import_pg_core.varchar)("node_id", {
321
+ length: 64
322
+ }).notNull(),
323
+ uploadId: (0, import_pg_core.varchar)("upload_id", {
324
+ length: 256
325
+ }).notNull(),
326
+ filename: (0, import_pg_core.varchar)("filename", {
327
+ length: 512
328
+ }).notNull(),
329
+ mimeType: (0, import_pg_core.varchar)("mime_type", {
330
+ length: 128
331
+ }).notNull(),
332
+ sizeBytes: (0, import_pg_core.integer)("size_bytes").notNull(),
333
+ // Legenda da mídia no WhatsApp. Por arquivo, não por nó: um nó que manda tabela de preços e
334
+ // um folder precisa de textos diferentes para cada um.
335
+ caption: (0, import_pg_core.text)("caption"),
336
+ // Ordem de envio dentro do nó — o cliente recebe as mensagens em sequência, e "tabela antes
337
+ // do folder" é decisão de quem edita, não do banco.
338
+ sortOrder: (0, import_pg_core.integer)("sort_order").notNull().default(0),
339
+ // Desligar sem desanexar: trocar o material da campanha é o caso comum, e apagar a linha
340
+ // perderia a ordem e a legenda já ajustadas.
341
+ active: (0, import_pg_core.boolean)("active").notNull().default(true),
342
+ createdAt: (0, import_pg_core.timestamp)("created_at", {
343
+ withTimezone: true
344
+ }).notNull().defaultNow(),
345
+ updatedAt: (0, import_pg_core.timestamp)("updated_at", {
346
+ withTimezone: true
347
+ }).notNull().defaultNow()
348
+ }, (table) => [
349
+ (0, import_pg_core.index)("idx_flow_media_node").on(table.companyId, table.flowKey, table.nodeId, table.sortOrder),
350
+ // O mesmo arquivo anexado duas vezes ao MESMO nó é erro de clique no editor, e o cliente
351
+ // receberia o documento repetido.
352
+ (0, import_pg_core.uniqueIndex)("idx_flow_media_node_upload").on(table.companyId, table.flowKey, table.nodeId, table.uploadId)
353
+ ]);
202
354
  var settings = metaWhatsAppSchema.table("settings", {
203
355
  companyId: (0, import_pg_core.uuid)("company_id").primaryKey(),
204
356
  templateName: (0, import_pg_core.varchar)("template_name", {
@@ -211,6 +363,15 @@ var settings = metaWhatsAppSchema.table("settings", {
211
363
  templateVariables: (0, import_pg_core.jsonb)("template_variables").$type().notNull().default([]),
212
364
  welcomeMessage: (0, import_pg_core.text)("welcome_message"),
213
365
  farewellMessage: (0, import_pg_core.text)("farewell_message"),
366
+ /**
367
+ * Política de transcrição desta empresa. Nulo é significativo: "o painel não decidiu", e aí vale
368
+ * o padrão que o host injetou. Sem a distinção, atualizar o módulo desligaria a transcrição de
369
+ * quem já a tinha ligada por ambiente.
370
+ */
371
+ transcriptionEnabled: (0, import_pg_core.boolean)("transcription_enabled"),
372
+ transcriptionMode: (0, import_pg_core.varchar)("transcription_mode", {
373
+ length: 16
374
+ }).$type(),
214
375
  createdAt: (0, import_pg_core.timestamp)("created_at", {
215
376
  withTimezone: true
216
377
  }).notNull().defaultNow(),
@@ -221,6 +382,31 @@ var settings = metaWhatsAppSchema.table("settings", {
221
382
 
222
383
  // src/repositories/SessionRepository.ts
223
384
  var DEFAULT_LIMIT = 20;
385
+ var conversationSummaryProjection = {
386
+ lastContent: import_drizzle_orm2.sql`(
387
+ select m.content from ${messages} m
388
+ where m.company_id = ${sessions}.company_id and m.session_id = ${sessions}.id
389
+ order by m.created_at desc limit 1
390
+ )`,
391
+ lastDirection: import_drizzle_orm2.sql`(
392
+ select m.direction from ${messages} m
393
+ where m.company_id = ${sessions}.company_id and m.session_id = ${sessions}.id
394
+ order by m.created_at desc limit 1
395
+ )`,
396
+ // Entradas do cliente depois da última leitura do atendente. Sessão nunca lida conta
397
+ // tudo — é o comportamento esperado de uma conversa que ninguém abriu ainda.
398
+ unread: import_drizzle_orm2.sql`(
399
+ select count(*)::int from ${messages} m
400
+ where m.company_id = ${sessions}.company_id
401
+ and m.session_id = ${sessions}.id
402
+ and m.direction = 'inbound'
403
+ and (${sessions}.last_agent_read_at is null or m.created_at > ${sessions}.last_agent_read_at)
404
+ )`
405
+ };
406
+ function sessionContextPatch(patch) {
407
+ return import_drizzle_orm2.sql`${sessions.context} || ${JSON.stringify(patch)}::jsonb`;
408
+ }
409
+ __name(sessionContextPatch, "sessionContextPatch");
224
410
  var SessionRepository = class {
225
411
  static {
226
412
  __name(this, "SessionRepository");
@@ -251,6 +437,9 @@ var SessionRepository = class {
251
437
  }).returning();
252
438
  return created;
253
439
  }
440
+ // O `context` jsonb é o ponto de extensão oficial para estado de sessão por produto: o módulo
441
+ // não conhece a forma, o consumidor a declara em TSessionContext. Este setter SUBSTITUI o
442
+ // objeto inteiro — para acumular respostas ao longo da conversa use patchContext.
254
443
  async setState(companyId, whatsappNumber, state, context) {
255
444
  await this.db.update(sessions).set({
256
445
  currentState: state,
@@ -261,6 +450,25 @@ var SessionRepository = class {
261
450
  updatedAt: import_drizzle_orm2.sql`now()`
262
451
  }).where((0, import_drizzle_orm2.and)((0, import_drizzle_orm2.eq)(sessions.companyId, companyId), (0, import_drizzle_orm2.eq)(sessions.whatsappNumber, whatsappNumber)));
263
452
  }
453
+ // Mescla parcial do context, feita no banco (`||`) e não por read-modify-write no host: duas
454
+ // mensagens do mesmo cliente processadas em paralelo sobrescreveriam uma à outra, e o campo
455
+ // acumula justamente as respostas coletadas ao longo da conversa. Chave presente no patch
456
+ // vence a existente; as demais permanecem.
457
+ async patchContext(companyId, whatsappNumber, patch) {
458
+ await this.db.update(sessions).set({
459
+ context: sessionContextPatch(patch),
460
+ lastActivity: import_drizzle_orm2.sql`now()`,
461
+ updatedAt: import_drizzle_orm2.sql`now()`
462
+ }).where((0, import_drizzle_orm2.and)((0, import_drizzle_orm2.eq)(sessions.companyId, companyId), (0, import_drizzle_orm2.eq)(sessions.whatsappNumber, whatsappNumber)));
463
+ }
464
+ // Leitura tipada do estado de sessão do produto. Devolve undefined quando a sessão não existe —
465
+ // distinto de existir com context vazio, que devolve o objeto vazio.
466
+ async readContext(companyId, whatsappNumber) {
467
+ const [row] = await this.db.select({
468
+ context: sessions.context
469
+ }).from(sessions).where((0, import_drizzle_orm2.and)((0, import_drizzle_orm2.eq)(sessions.companyId, companyId), (0, import_drizzle_orm2.eq)(sessions.whatsappNumber, whatsappNumber))).limit(1);
470
+ return row?.context;
471
+ }
264
472
  // Posição no grafo de fluxo — chamado pelo host a cada transição do FlowInterpreter.
265
473
  // Passar null em ambos desliga o rastreio (ex.: conversa saiu do motor de fluxo).
266
474
  async setFlowPosition(companyId, whatsappNumber, flowKey, currentNodeId) {
@@ -303,6 +511,16 @@ var SessionRepository = class {
303
511
  async release(companyId, whatsappNumber) {
304
512
  await this.setMode(companyId, whatsappNumber, "bot", null);
305
513
  }
514
+ /**
515
+ * Apaga a sessão; a cascata das FKs leva mensagens e documentos.
516
+ *
517
+ * Não apaga o binário no storage — isso é passo de aplicação, e é por isso que este método é
518
+ * chamado por `DeleteConversationUseCase` e não diretamente pelo host. Chamar daqui sem apagar os
519
+ * objetos antes deixa mídia órfã sendo cobrada para sempre.
520
+ */
521
+ async deleteByNumber(companyId, whatsappNumber) {
522
+ await this.db.delete(sessions).where((0, import_drizzle_orm2.and)((0, import_drizzle_orm2.eq)(sessions.companyId, companyId), (0, import_drizzle_orm2.eq)(sessions.whatsappNumber, whatsappNumber)));
523
+ }
306
524
  async requestHuman(companyId, whatsappNumber) {
307
525
  await this.db.update(sessions).set({
308
526
  humanRequestedAt: import_drizzle_orm2.sql`now()`,
@@ -336,17 +554,28 @@ var SessionRepository = class {
336
554
  currentState: sessions.currentState,
337
555
  lastActivity: sessions.lastActivity,
338
556
  lastInboundAt: sessions.lastInboundAt,
339
- humanRequestedAt: sessions.humanRequestedAt
557
+ humanRequestedAt: sessions.humanRequestedAt,
558
+ // Prévia e contagem saem de subquery correlacionada em vez de N+1 na volta: uma inbox
559
+ // lista dezenas de conversas por página, e uma query por linha é o gargalo clássico
560
+ // dessa tela. Ambos os campos são dados do próprio módulo — deixá-los para o host
561
+ // obrigaria todo consumidor a reescrever o mesmo join contra tabelas que não são dele.
562
+ ...conversationSummaryProjection
340
563
  }).from(sessions).where((0, import_drizzle_orm2.and)(...conditions)).orderBy((0, import_drizzle_orm2.desc)(sessions.lastActivity)).limit(limit).offset(offset);
341
564
  return rows.map((row) => ({
342
565
  id: row.id,
343
566
  whatsappNumber: row.whatsappNumber,
567
+ ...row.lastContent !== null ? {
568
+ lastContent: row.lastContent
569
+ } : {},
570
+ ...row.lastDirection !== null ? {
571
+ lastDirection: row.lastDirection
572
+ } : {},
344
573
  lastAt: row.lastActivity.toISOString(),
345
574
  lastInboundAt: row.lastInboundAt?.toISOString() ?? null,
346
575
  mode: row.mode,
347
576
  assignedUserId: row.assignedUserId,
348
577
  waitingHuman: row.humanRequestedAt !== null,
349
- unread: 0,
578
+ unread: Number(row.unread),
350
579
  currentState: row.currentState
351
580
  }));
352
581
  }
@@ -390,7 +619,9 @@ var MessageRepository = class {
390
619
  content: params.content ?? null,
391
620
  payload: params.payload ?? null,
392
621
  waMessageId: params.waMessageId ?? null,
393
- status: params.status ?? null
622
+ status: params.status ?? null,
623
+ moderationFlagged: params.moderationFlagged ?? null,
624
+ moderationTerms: params.moderationTerms ?? null
394
625
  };
395
626
  const [created] = await this.db.insert(messages).values(values).onConflictDoNothing().returning();
396
627
  return created;
@@ -404,6 +635,58 @@ var MessageRepository = class {
404
635
  }).where((0, import_drizzle_orm3.and)((0, import_drizzle_orm3.eq)(messages.companyId, companyId), (0, import_drizzle_orm3.eq)(messages.waMessageId, waMessageId))).returning();
405
636
  return updated;
406
637
  }
638
+ /**
639
+ * Grava a transcrição endereçando pelo id da Meta, para quem só tem esse.
640
+ *
641
+ * Serve ao caso em que a transcrição acontece no próprio webhook — o grafo precisa do texto para
642
+ * responder ao cliente, e jogar fora o que ele já pagou para transcrever significaria transcrever
643
+ * o mesmo áudio uma segunda vez só para o painel ver.
644
+ *
645
+ * Devolve `undefined` quando não achou a mensagem: entrega duplicada e mensagem apagada são
646
+ * corridas normais, não erro.
647
+ */
648
+ async saveTranscriptionByWaMessageId(params) {
649
+ const [updated] = await this.db.update(messages).set({
650
+ transcriptionStatus: params.status,
651
+ ...params.text !== void 0 ? {
652
+ transcriptionText: params.text
653
+ } : {},
654
+ ...params.language !== void 0 ? {
655
+ transcriptionLanguage: params.language
656
+ } : {},
657
+ ...params.engine !== void 0 ? {
658
+ transcriptionEngine: params.engine
659
+ } : {}
660
+ }).where((0, import_drizzle_orm3.and)((0, import_drizzle_orm3.eq)(messages.companyId, params.companyId), (0, import_drizzle_orm3.eq)(messages.waMessageId, params.waMessageId))).returning();
661
+ return updated;
662
+ }
663
+ async findById(companyId, messageId) {
664
+ const [found] = await this.db.select().from(messages).where((0, import_drizzle_orm3.and)((0, import_drizzle_orm3.eq)(messages.companyId, companyId), (0, import_drizzle_orm3.eq)(messages.id, messageId))).limit(1);
665
+ return found;
666
+ }
667
+ /**
668
+ * Grava o resultado da transcrição. Devolve `undefined` quando a mensagem não existe (apagada
669
+ * entre o enfileiramento e a execução do job) — não é erro, é corrida normal.
670
+ *
671
+ * `text`/`language`/`engine` só são tocados quando informados: uma retentativa que volta a falhar
672
+ * atualiza o status sem apagar a transcrição parcial de uma tentativa anterior que tenha vindo de
673
+ * outro engine da cadeia.
674
+ */
675
+ async saveTranscription(params) {
676
+ const [updated] = await this.db.update(messages).set({
677
+ transcriptionStatus: params.status,
678
+ ...params.text !== void 0 ? {
679
+ transcriptionText: params.text
680
+ } : {},
681
+ ...params.language !== void 0 ? {
682
+ transcriptionLanguage: params.language
683
+ } : {},
684
+ ...params.engine !== void 0 ? {
685
+ transcriptionEngine: params.engine
686
+ } : {}
687
+ }).where((0, import_drizzle_orm3.and)((0, import_drizzle_orm3.eq)(messages.companyId, params.companyId), (0, import_drizzle_orm3.eq)(messages.id, params.messageId))).returning();
688
+ return updated;
689
+ }
407
690
  async listByConversation(params) {
408
691
  const conditions = [
409
692
  (0, import_drizzle_orm3.eq)(messages.companyId, params.companyId),
@@ -453,12 +736,21 @@ var FlowGraphRepository = class {
453
736
  __name(this, "FlowGraphRepository");
454
737
  }
455
738
  db;
456
- constructor(db) {
739
+ cache;
740
+ // Cache opcional: sem ele o repositório se comporta exatamente como antes, lendo sempre do
741
+ // banco. É o host que decide se quer cachear e com qual provedor (ver CacheInterface).
742
+ constructor(db, cache) {
457
743
  this.db = db;
744
+ this.cache = cache;
458
745
  }
459
746
  async get(companyId, key) {
747
+ const cached = await this.cache?.read(companyId, key);
748
+ if (cached) return cached;
460
749
  const [row] = await this.db.select().from(flowGraphs).where((0, import_drizzle_orm4.and)((0, import_drizzle_orm4.eq)(flowGraphs.companyId, companyId), (0, import_drizzle_orm4.eq)(flowGraphs.key, key))).limit(1);
461
- return row ? toContractGraph(row) : void 0;
750
+ if (!row) return void 0;
751
+ const graph = toContractGraph(row);
752
+ await this.cache?.write(companyId, graph);
753
+ return graph;
462
754
  }
463
755
  async list(companyId) {
464
756
  const rows = await this.db.select().from(flowGraphs).where((0, import_drizzle_orm4.eq)(flowGraphs.companyId, companyId));
@@ -488,7 +780,9 @@ var FlowGraphRepository = class {
488
780
  showInMenu: graph.showInMenu ?? false,
489
781
  menuOptionLabel: graph.menuOptionLabel
490
782
  }).returning();
491
- return toContractGraph(created);
783
+ const createdGraph = toContractGraph(created);
784
+ await this.cache?.invalidate(companyId, createdGraph.key);
785
+ return createdGraph;
492
786
  }
493
787
  // Lock otimista: a escrita só aplica se `expectedVersion` ainda bater com o que está salvo —
494
788
  // senão, alguém mais salvou entretanto e o editor precisa recarregar (ver comentário no schema).
@@ -502,10 +796,12 @@ var FlowGraphRepository = class {
502
796
  updatedAt: /* @__PURE__ */ new Date()
503
797
  }).where((0, import_drizzle_orm4.and)((0, import_drizzle_orm4.eq)(flowGraphs.companyId, companyId), (0, import_drizzle_orm4.eq)(flowGraphs.key, graph.key), (0, import_drizzle_orm4.eq)(flowGraphs.version, expectedVersion))).returning();
504
798
  if (rows.length === 0) throw new OptimisticLockError(graph.key);
799
+ await this.cache?.invalidate(companyId, graph.key);
505
800
  return toContractGraph(rows[0]);
506
801
  }
507
802
  async delete(companyId, key) {
508
803
  await this.db.delete(flowGraphs).where((0, import_drizzle_orm4.and)((0, import_drizzle_orm4.eq)(flowGraphs.companyId, companyId), (0, import_drizzle_orm4.eq)(flowGraphs.key, key)));
804
+ await this.cache?.invalidate(companyId, key);
509
805
  }
510
806
  // T4.2 — GetLiveFlowPositions: agrega sessões ativas por (flowKey, currentNodeId), lendo as
511
807
  // colunas dedicadas gravadas por SessionRepository.setFlowPosition. Agrega no banco (GROUP BY,
@@ -525,6 +821,48 @@ var FlowGraphRepository = class {
525
821
  }
526
822
  };
527
823
 
824
+ // src/repositories/FlowGraphCache.ts
825
+ var KEY_PREFIX = "meta-whatsapp:flow-graph";
826
+ var DEFAULT_FLOW_GRAPH_CACHE_TTL_SECONDS = 300;
827
+ var FlowGraphCache = class {
828
+ static {
829
+ __name(this, "FlowGraphCache");
830
+ }
831
+ provider;
832
+ ttlSeconds;
833
+ constructor(provider, ttlSeconds = DEFAULT_FLOW_GRAPH_CACHE_TTL_SECONDS) {
834
+ this.provider = provider;
835
+ this.ttlSeconds = ttlSeconds;
836
+ }
837
+ // companyId na chave, e não só a flowKey: a chave do fluxo é escolhida por quem edita e se
838
+ // repete entre empresas — 'consorcio' existe em todas — então uma chave sem tenant serviria o
839
+ // grafo de uma empresa para a conversa de outra.
840
+ keyFor(companyId, flowKey) {
841
+ return `${KEY_PREFIX}:${companyId}:${flowKey}`;
842
+ }
843
+ async read(companyId, flowKey) {
844
+ try {
845
+ const cached = await this.provider.get(this.keyFor(companyId, flowKey));
846
+ if (!cached) return void 0;
847
+ return JSON.parse(cached);
848
+ } catch {
849
+ return void 0;
850
+ }
851
+ }
852
+ async write(companyId, graph) {
853
+ try {
854
+ await this.provider.set(this.keyFor(companyId, graph.key), JSON.stringify(graph), this.ttlSeconds);
855
+ } catch {
856
+ }
857
+ }
858
+ async invalidate(companyId, flowKey) {
859
+ try {
860
+ await this.provider.delete(this.keyFor(companyId, flowKey));
861
+ } catch {
862
+ }
863
+ }
864
+ };
865
+
528
866
  // src/repositories/SettingsRepository.ts
529
867
  var import_drizzle_orm5 = require("drizzle-orm");
530
868
  function toContractSettings(row) {
@@ -533,7 +871,11 @@ function toContractSettings(row) {
533
871
  templateLanguage: row.templateLanguage,
534
872
  templateVariables: row.templateVariables,
535
873
  welcomeMessage: row.welcomeMessage ?? "",
536
- farewellMessage: row.farewellMessage ?? ""
874
+ farewellMessage: row.farewellMessage ?? "",
875
+ // `?? null` e não `?? false`: nulo é "o painel não decidiu", e é o que faz valer o padrão do
876
+ // host. Colapsar para `false` desligaria quem ligou por ambiente.
877
+ transcriptionEnabled: row.transcriptionEnabled ?? null,
878
+ transcriptionMode: row.transcriptionMode ?? null
537
879
  };
538
880
  }
539
881
  __name(toContractSettings, "toContractSettings");
@@ -542,7 +884,9 @@ var EMPTY_SETTINGS = {
542
884
  templateLanguage: "pt_BR",
543
885
  templateVariables: [],
544
886
  welcomeMessage: "",
545
- farewellMessage: ""
887
+ farewellMessage: "",
888
+ transcriptionEnabled: null,
889
+ transcriptionMode: null
546
890
  };
547
891
  var SettingsRepository = class {
548
892
  static {
@@ -571,7 +915,12 @@ var SettingsRepository = class {
571
915
  templateLanguage: merged.templateLanguage,
572
916
  templateVariables: merged.templateVariables,
573
917
  welcomeMessage: merged.welcomeMessage || null,
574
- farewellMessage: merged.farewellMessage || null
918
+ farewellMessage: merged.farewellMessage || null,
919
+ // Sem `|| null`: `false` aqui é decisão explícita do painel ("desligado para esta empresa"),
920
+ // e colapsá-lo para nulo faria a empresa voltar a herdar o padrão do host — exatamente o
921
+ // oposto do que o operador acabou de pedir.
922
+ transcriptionEnabled: merged.transcriptionEnabled,
923
+ transcriptionMode: merged.transcriptionMode
575
924
  }).onConflictDoUpdate({
576
925
  target: settings.companyId,
577
926
  set: {
@@ -580,6 +929,8 @@ var SettingsRepository = class {
580
929
  templateVariables: merged.templateVariables,
581
930
  welcomeMessage: merged.welcomeMessage || null,
582
931
  farewellMessage: merged.farewellMessage || null,
932
+ transcriptionEnabled: merged.transcriptionEnabled,
933
+ transcriptionMode: merged.transcriptionMode,
583
934
  updatedAt: /* @__PURE__ */ new Date()
584
935
  }
585
936
  }).returning();
@@ -606,16 +957,19 @@ var LogMessageUseCase = class {
606
957
  sessionRepository;
607
958
  messageRepository;
608
959
  realtime;
609
- constructor(sessionRepository, messageRepository, realtime) {
960
+ moderator;
961
+ constructor(sessionRepository, messageRepository, realtime, moderator) {
610
962
  this.sessionRepository = sessionRepository;
611
963
  this.messageRepository = messageRepository;
612
964
  this.realtime = realtime;
965
+ this.moderator = moderator;
613
966
  }
614
967
  async execute(params) {
615
968
  const session = await this.sessionRepository.getOrCreate(params.companyId, params.whatsappNumber, params.startState);
616
969
  const saved = await this.messageRepository.insertMessage({
617
970
  ...params,
618
- sessionId: session.id
971
+ sessionId: session.id,
972
+ ...this.moderationOf(params)
619
973
  });
620
974
  if (!saved) return void 0;
621
975
  if (params.direction === "inbound") {
@@ -628,6 +982,20 @@ var LogMessageUseCase = class {
628
982
  this.realtime?.emit("global", "data-changed", {});
629
983
  return saved;
630
984
  }
985
+ // Só o que o cliente escreveu: marcar o que o próprio atendente ou o bot enviou não sinaliza
986
+ // abuso, apenas sujaria o transcript com etiqueta na resposta de quem atende.
987
+ moderationOf(params) {
988
+ if (!this.moderator || params.direction !== "inbound") return {};
989
+ const text2 = params.content?.trim();
990
+ if (!text2) return {};
991
+ const verdict = this.moderator.inspect(text2);
992
+ return {
993
+ moderationFlagged: verdict.isOffensive,
994
+ moderationTerms: verdict.isOffensive ? [
995
+ ...verdict.matchedTerms
996
+ ] : null
997
+ };
998
+ }
631
999
  };
632
1000
 
633
1001
  // src/use-cases/SendMessage.use-case.ts
@@ -641,11 +1009,13 @@ var SendMessageUseCase = class {
641
1009
  sessionRepository;
642
1010
  logMessage;
643
1011
  objectStorage;
644
- constructor(channel, sessionRepository, logMessage, objectStorage) {
1012
+ documentRepository;
1013
+ constructor(channel, sessionRepository, logMessage, objectStorage, documentRepository) {
645
1014
  this.channel = channel;
646
1015
  this.sessionRepository = sessionRepository;
647
1016
  this.logMessage = logMessage;
648
1017
  this.objectStorage = objectStorage;
1018
+ this.documentRepository = documentRepository;
649
1019
  }
650
1020
  async assertWithinWindow(companyId, whatsappNumber) {
651
1021
  const hours = await this.sessionRepository.hoursSinceLastInbound(companyId, whatsappNumber);
@@ -682,7 +1052,7 @@ var SendMessageUseCase = class {
682
1052
  mimeType: params.mimeType,
683
1053
  key: `meta-whatsapp/${params.companyId}/${Date.now()}-${params.filename}`
684
1054
  })).uploadId : void 0;
685
- return this.logMessage.execute({
1055
+ const saved = await this.logMessage.execute({
686
1056
  companyId: params.companyId,
687
1057
  whatsappNumber: params.whatsappNumber,
688
1058
  direction: "outbound",
@@ -701,6 +1071,19 @@ var SendMessageUseCase = class {
701
1071
  status: "sent",
702
1072
  startState: params.startState
703
1073
  });
1074
+ if (saved && uploadId) {
1075
+ await this.documentRepository?.link({
1076
+ companyId: params.companyId,
1077
+ sessionId: saved.sessionId,
1078
+ messageId: saved.id,
1079
+ uploadId,
1080
+ filename: params.filename,
1081
+ mimeType: params.mimeType,
1082
+ sizeBytes: params.buffer.length,
1083
+ source: params.sender
1084
+ });
1085
+ }
1086
+ return saved;
704
1087
  }
705
1088
  // Template é o único envio que ignora a janela — é justamente o mecanismo que a Meta oferece
706
1089
  // para reabri-la.
@@ -818,8 +1201,330 @@ var ListMessagesUseCase = class {
818
1201
  }
819
1202
  };
820
1203
 
821
- // src/use-cases/ExportConversation.use-case.ts
1204
+ // src/use-cases/ListConversationDocuments.use-case.ts
822
1205
  var import_meta_whatsapp_contracts4 = require("@adatechnology/meta-whatsapp-contracts");
1206
+ var ListConversationDocumentsUseCase = class {
1207
+ static {
1208
+ __name(this, "ListConversationDocumentsUseCase");
1209
+ }
1210
+ sessionRepository;
1211
+ documentRepository;
1212
+ constructor(sessionRepository, documentRepository) {
1213
+ this.sessionRepository = sessionRepository;
1214
+ this.documentRepository = documentRepository;
1215
+ }
1216
+ async execute(params) {
1217
+ const session = await this.sessionRepository.getContext(params.companyId, params.whatsappNumber);
1218
+ if (!session) throw new import_meta_whatsapp_contracts4.SessionNotFoundError(params.whatsappNumber);
1219
+ const { rows, total } = await this.documentRepository.listByConversation({
1220
+ companyId: params.companyId,
1221
+ sessionId: session.id,
1222
+ ...params.search ? {
1223
+ search: params.search
1224
+ } : {},
1225
+ ...params.sources && params.sources.length > 0 ? {
1226
+ sources: params.sources
1227
+ } : {},
1228
+ ...params.sortDirection ? {
1229
+ sortDirection: params.sortDirection
1230
+ } : {},
1231
+ ...params.page ? {
1232
+ page: params.page
1233
+ } : {},
1234
+ ...params.limit ? {
1235
+ limit: params.limit
1236
+ } : {}
1237
+ });
1238
+ return {
1239
+ documents: rows.map((row) => ({
1240
+ id: row.uploadId,
1241
+ filename: row.filename,
1242
+ mimeType: row.mimeType,
1243
+ sizeBytes: row.sizeBytes,
1244
+ source: row.source,
1245
+ linkedAt: row.linkedAt.toISOString()
1246
+ })),
1247
+ total
1248
+ };
1249
+ }
1250
+ };
1251
+
1252
+ // src/use-cases/ListCompanyDocuments.use-case.ts
1253
+ var ListCompanyDocumentsUseCase = class {
1254
+ static {
1255
+ __name(this, "ListCompanyDocumentsUseCase");
1256
+ }
1257
+ documentRepository;
1258
+ constructor(documentRepository) {
1259
+ this.documentRepository = documentRepository;
1260
+ }
1261
+ async execute(params) {
1262
+ const { rows, total } = await this.documentRepository.listByCompany({
1263
+ companyId: params.companyId,
1264
+ ...params.search ? {
1265
+ search: params.search
1266
+ } : {},
1267
+ ...params.sources && params.sources.length > 0 ? {
1268
+ sources: params.sources
1269
+ } : {},
1270
+ ...params.sortDirection ? {
1271
+ sortDirection: params.sortDirection
1272
+ } : {},
1273
+ ...params.page ? {
1274
+ page: params.page
1275
+ } : {},
1276
+ ...params.limit ? {
1277
+ limit: params.limit
1278
+ } : {}
1279
+ });
1280
+ return {
1281
+ documents: rows.map((row) => ({
1282
+ id: row.uploadId,
1283
+ conversationId: row.whatsappNumber,
1284
+ filename: row.filename,
1285
+ mimeType: row.mimeType,
1286
+ sizeBytes: row.sizeBytes,
1287
+ source: row.source,
1288
+ linkedAt: row.linkedAt.toISOString()
1289
+ })),
1290
+ total
1291
+ };
1292
+ }
1293
+ };
1294
+
1295
+ // src/use-cases/DeleteConversation.use-case.ts
1296
+ var import_meta_whatsapp_contracts5 = require("@adatechnology/meta-whatsapp-contracts");
1297
+ var DeleteConversationUseCase = class {
1298
+ static {
1299
+ __name(this, "DeleteConversationUseCase");
1300
+ }
1301
+ sessionRepository;
1302
+ documentRepository;
1303
+ objectStorage;
1304
+ constructor(sessionRepository, documentRepository, objectStorage) {
1305
+ this.sessionRepository = sessionRepository;
1306
+ this.documentRepository = documentRepository;
1307
+ this.objectStorage = objectStorage;
1308
+ }
1309
+ async execute(params) {
1310
+ const session = await this.sessionRepository.getContext(params.companyId, params.whatsappNumber);
1311
+ if (!session) throw new import_meta_whatsapp_contracts5.SessionNotFoundError(params.whatsappNumber);
1312
+ const uploadIds = await this.documentRepository.listUploadIdsBySession(params.companyId, session.id);
1313
+ if (uploadIds.length > 0 && !this.objectStorage?.delete) {
1314
+ throw new Error("storage_delete_unsupported: a conversa tem arquivos e o storage injetado n\xE3o implementa delete");
1315
+ }
1316
+ const failedObjects = [];
1317
+ let deletedObjects = 0;
1318
+ for (const uploadId of uploadIds) {
1319
+ try {
1320
+ await this.objectStorage?.delete?.(uploadId);
1321
+ deletedObjects++;
1322
+ } catch {
1323
+ failedObjects.push(uploadId);
1324
+ }
1325
+ }
1326
+ if (failedObjects.length > 0) return {
1327
+ deletedObjects,
1328
+ failedObjects
1329
+ };
1330
+ await this.sessionRepository.deleteByNumber(params.companyId, params.whatsappNumber);
1331
+ return {
1332
+ deletedObjects,
1333
+ failedObjects: []
1334
+ };
1335
+ }
1336
+ };
1337
+
1338
+ // src/use-cases/PurgeExpiredDocuments.use-case.ts
1339
+ var DEFAULT_BATCH_SIZE = 50;
1340
+ var HOURS_IN_DAY = 24;
1341
+ var MILLISECONDS_IN_HOUR = 60 * 60 * 1e3;
1342
+ var PurgeExpiredDocumentsUseCase = class {
1343
+ static {
1344
+ __name(this, "PurgeExpiredDocumentsUseCase");
1345
+ }
1346
+ documentRepository;
1347
+ objectStorage;
1348
+ constructor(documentRepository, objectStorage) {
1349
+ this.documentRepository = documentRepository;
1350
+ this.objectStorage = objectStorage;
1351
+ }
1352
+ async execute(params) {
1353
+ if (!this.objectStorage?.delete) {
1354
+ throw new Error("storage_delete_unsupported: reten\xE7\xE3o exige um storage que implemente delete");
1355
+ }
1356
+ const reference = params.now ?? /* @__PURE__ */ new Date();
1357
+ const olderThan = new Date(reference.getTime() - params.retentionDays * HOURS_IN_DAY * MILLISECONDS_IN_HOUR);
1358
+ const expired = await this.documentRepository.listExpired(params.companyId, olderThan, params.batchSize ?? DEFAULT_BATCH_SIZE);
1359
+ const failed = [];
1360
+ let purged = 0;
1361
+ for (const document of expired) {
1362
+ try {
1363
+ await this.objectStorage.delete(document.uploadId);
1364
+ } catch {
1365
+ failed.push(document.uploadId);
1366
+ continue;
1367
+ }
1368
+ await this.documentRepository.deleteById(params.companyId, document.id);
1369
+ purged++;
1370
+ }
1371
+ return {
1372
+ purged,
1373
+ failed
1374
+ };
1375
+ }
1376
+ };
1377
+
1378
+ // src/repositories/DocumentRepository.ts
1379
+ var import_drizzle_orm6 = require("drizzle-orm");
1380
+ var DEFAULT_LIMIT3 = 50;
1381
+ function companyDocumentSearch(search) {
1382
+ const term = search?.trim();
1383
+ if (!term) return void 0;
1384
+ const digits = term.replace(/\D/g, "");
1385
+ const byFilename = (0, import_drizzle_orm6.ilike)(documents.filename, `%${term}%`);
1386
+ if (!digits) return byFilename;
1387
+ return (0, import_drizzle_orm6.or)(byFilename, (0, import_drizzle_orm6.ilike)(sessions.whatsappNumber, `%${digits}%`));
1388
+ }
1389
+ __name(companyDocumentSearch, "companyDocumentSearch");
1390
+ var DocumentRepository = class {
1391
+ static {
1392
+ __name(this, "DocumentRepository");
1393
+ }
1394
+ db;
1395
+ constructor(db) {
1396
+ this.db = db;
1397
+ }
1398
+ /**
1399
+ * Idempotente por (companyId, uploadId), garantido pelo índice único e não por SELECT prévio: o
1400
+ * job de ingestão é reentregue por retry e duas tentativas concorrentes passariam as duas por uma
1401
+ * checagem, duplicando a linha no painel.
1402
+ *
1403
+ * Devolve `undefined` quando o documento já estava linkado.
1404
+ */
1405
+ async link(params) {
1406
+ const values = {
1407
+ companyId: params.companyId,
1408
+ sessionId: params.sessionId,
1409
+ messageId: params.messageId ?? null,
1410
+ uploadId: params.uploadId,
1411
+ filename: params.filename,
1412
+ mimeType: params.mimeType,
1413
+ sizeBytes: params.sizeBytes,
1414
+ sha256: params.sha256 ?? null,
1415
+ source: params.source
1416
+ };
1417
+ const [created] = await this.db.insert(documents).values(values).onConflictDoNothing().returning();
1418
+ return created;
1419
+ }
1420
+ async listByConversation(params) {
1421
+ const filters = [
1422
+ (0, import_drizzle_orm6.eq)(documents.companyId, params.companyId),
1423
+ (0, import_drizzle_orm6.eq)(documents.sessionId, params.sessionId)
1424
+ ];
1425
+ if (params.search) filters.push((0, import_drizzle_orm6.ilike)(documents.filename, `%${params.search}%`));
1426
+ if (params.sources && params.sources.length > 0) {
1427
+ filters.push((0, import_drizzle_orm6.inArray)(documents.source, [
1428
+ ...params.sources
1429
+ ]));
1430
+ }
1431
+ const where = (0, import_drizzle_orm6.and)(...filters);
1432
+ const limit = params.limit ?? DEFAULT_LIMIT3;
1433
+ const page = params.page && params.page > 0 ? params.page : 1;
1434
+ const orderBy = params.sortDirection === "asc" ? (0, import_drizzle_orm6.asc)(documents.linkedAt) : (0, import_drizzle_orm6.desc)(documents.linkedAt);
1435
+ const [rows, counted] = await Promise.all([
1436
+ this.db.select().from(documents).where(where).orderBy(orderBy).limit(limit).offset((page - 1) * limit),
1437
+ this.db.select({
1438
+ value: (0, import_drizzle_orm6.count)()
1439
+ }).from(documents).where(where)
1440
+ ]);
1441
+ return {
1442
+ rows,
1443
+ total: counted[0]?.value ?? 0
1444
+ };
1445
+ }
1446
+ /**
1447
+ * A biblioteca da EMPRESA inteira, não de uma conversa.
1448
+ *
1449
+ * A busca casa nome do arquivo OU telefone da conversa — ver `companyDocumentSearch`.
1450
+ *
1451
+ * Faz join com `sessions` para carregar de qual conversa cada arquivo veio — numa lista global,
1452
+ * arquivo sem essa referência é inútil: o atendente vê "comprovante.pdf" e não sabe de quem.
1453
+ *
1454
+ * Ordena por `linkedAt` apoiada no índice `idx_documents_company_linked`, que já existia para a
1455
+ * varredura de retenção.
1456
+ */
1457
+ async listByCompany(params) {
1458
+ const filters = [
1459
+ (0, import_drizzle_orm6.eq)(documents.companyId, params.companyId)
1460
+ ];
1461
+ const search = companyDocumentSearch(params.search);
1462
+ if (search) filters.push(search);
1463
+ if (params.sources && params.sources.length > 0) {
1464
+ filters.push((0, import_drizzle_orm6.inArray)(documents.source, [
1465
+ ...params.sources
1466
+ ]));
1467
+ }
1468
+ const where = (0, import_drizzle_orm6.and)(...filters);
1469
+ const limit = params.limit ?? DEFAULT_LIMIT3;
1470
+ const page = params.page && params.page > 0 ? params.page : 1;
1471
+ const orderBy = params.sortDirection === "asc" ? (0, import_drizzle_orm6.asc)(documents.linkedAt) : (0, import_drizzle_orm6.desc)(documents.linkedAt);
1472
+ const [rows, counted] = await Promise.all([
1473
+ this.db.select({
1474
+ id: documents.id,
1475
+ uploadId: documents.uploadId,
1476
+ filename: documents.filename,
1477
+ mimeType: documents.mimeType,
1478
+ sizeBytes: documents.sizeBytes,
1479
+ source: documents.source,
1480
+ linkedAt: documents.linkedAt,
1481
+ // Só o número: o NOME do cliente é dado do produto (tabela própria dele), não do
1482
+ // módulo. Quem quiser exibir "Marina Alves" enriquece na borda.
1483
+ whatsappNumber: sessions.whatsappNumber
1484
+ }).from(documents).innerJoin(sessions, (0, import_drizzle_orm6.eq)(documents.sessionId, sessions.id)).where(where).orderBy(orderBy).limit(limit).offset((page - 1) * limit),
1485
+ // O mesmo join da listagem, e não só `from(documents)`: a busca pode citar
1486
+ // `sessions.whatsapp_number`, e uma contagem sem a tabela na cláusula não compila — pior,
1487
+ // se compilasse, o total divergiria das linhas e a paginação prometeria páginas vazias.
1488
+ this.db.select({
1489
+ value: (0, import_drizzle_orm6.count)()
1490
+ }).from(documents).innerJoin(sessions, (0, import_drizzle_orm6.eq)(documents.sessionId, sessions.id)).where(where)
1491
+ ]);
1492
+ return {
1493
+ rows,
1494
+ total: counted[0]?.value ?? 0
1495
+ };
1496
+ }
1497
+ /**
1498
+ * Um documento pela key do objeto. Serve para recuperar o nome original na hora de assinar o
1499
+ * download: a key é caminho no bucket e salvaria o arquivo com o id da Meta.
1500
+ */
1501
+ async findByUploadId(companyId, uploadId) {
1502
+ const [row] = await this.db.select().from(documents).where((0, import_drizzle_orm6.and)((0, import_drizzle_orm6.eq)(documents.companyId, companyId), (0, import_drizzle_orm6.eq)(documents.uploadId, uploadId))).limit(1);
1503
+ return row;
1504
+ }
1505
+ /**
1506
+ * Os objetos a apagar no storage antes de a linha sumir.
1507
+ *
1508
+ * Existe porque a cascata da FK apaga a linha e deixa o binário órfão: quem for apagar a conversa
1509
+ * precisa desta lista primeiro, senão paga armazenamento para sempre por arquivo inalcançável.
1510
+ */
1511
+ async listUploadIdsBySession(companyId, sessionId) {
1512
+ const rows = await this.db.select({
1513
+ uploadId: documents.uploadId
1514
+ }).from(documents).where((0, import_drizzle_orm6.and)((0, import_drizzle_orm6.eq)(documents.companyId, companyId), (0, import_drizzle_orm6.eq)(documents.sessionId, sessionId)));
1515
+ return rows.map((row) => row.uploadId);
1516
+ }
1517
+ /** Varredura de retenção por idade — o par é o mesmo cuidado com o objeto no storage. */
1518
+ async listExpired(companyId, olderThan, limit = DEFAULT_LIMIT3) {
1519
+ return this.db.select().from(documents).where((0, import_drizzle_orm6.and)((0, import_drizzle_orm6.eq)(documents.companyId, companyId), (0, import_drizzle_orm6.lt)(documents.linkedAt, olderThan))).orderBy(documents.linkedAt).limit(limit);
1520
+ }
1521
+ async deleteById(companyId, id) {
1522
+ await this.db.delete(documents).where((0, import_drizzle_orm6.and)((0, import_drizzle_orm6.eq)(documents.companyId, companyId), (0, import_drizzle_orm6.eq)(documents.id, id)));
1523
+ }
1524
+ };
1525
+
1526
+ // src/use-cases/ExportConversation.use-case.ts
1527
+ var import_meta_whatsapp_contracts6 = require("@adatechnology/meta-whatsapp-contracts");
823
1528
  var ExportConversationUseCase = class {
824
1529
  static {
825
1530
  __name(this, "ExportConversationUseCase");
@@ -830,7 +1535,7 @@ var ExportConversationUseCase = class {
830
1535
  }
831
1536
  async execute(params) {
832
1537
  const result = await this.sessionRepository.exportConversation(params.companyId, params.whatsappNumber);
833
- if (!result) throw new import_meta_whatsapp_contracts4.SessionNotFoundError(params.whatsappNumber);
1538
+ if (!result) throw new import_meta_whatsapp_contracts6.SessionNotFoundError(params.whatsappNumber);
834
1539
  return result;
835
1540
  }
836
1541
  };
@@ -910,7 +1615,7 @@ var GetLiveFlowPositionsUseCase = class {
910
1615
  };
911
1616
 
912
1617
  // src/flows/FlowInterpreter.ts
913
- var import_meta_whatsapp_contracts5 = require("@adatechnology/meta-whatsapp-contracts");
1618
+ var import_meta_whatsapp_contracts7 = require("@adatechnology/meta-whatsapp-contracts");
914
1619
  var DEFAULT_MAX_STEPS = 50;
915
1620
  function evaluateCondition(node, context) {
916
1621
  if (!node.conditionContextKey || !node.conditionOperator || !node.conditionValue) return false;
@@ -1011,9 +1716,9 @@ var FlowInterpreter = class {
1011
1716
  kind: "terminal",
1012
1717
  context: input.context
1013
1718
  };
1014
- if ((0, import_meta_whatsapp_contracts5.isCrossFlowTarget)(nextNodeId)) return {
1719
+ if ((0, import_meta_whatsapp_contracts7.isCrossFlowTarget)(nextNodeId)) return {
1015
1720
  kind: "cross-flow",
1016
- flowKey: (0, import_meta_whatsapp_contracts5.crossFlowKey)(nextNodeId),
1721
+ flowKey: (0, import_meta_whatsapp_contracts7.crossFlowKey)(nextNodeId),
1017
1722
  context: input.context
1018
1723
  };
1019
1724
  if (!input.graph.nodes[nextNodeId]) return {
@@ -1067,22 +1772,243 @@ var FlowInterpreter = class {
1067
1772
  }
1068
1773
  };
1069
1774
 
1775
+ // src/flows/createSendMediaAction.ts
1776
+ function mediaTypeFor2(mimeType) {
1777
+ if (mimeType.startsWith("image/")) return "image";
1778
+ if (mimeType.startsWith("audio/")) return "audio";
1779
+ if (mimeType.startsWith("video/")) return "video";
1780
+ return "document";
1781
+ }
1782
+ __name(mediaTypeFor2, "mediaTypeFor");
1783
+ function createSendMediaAction(params) {
1784
+ return async ({ node, session, channel }) => {
1785
+ if (!session.flowKey) return;
1786
+ const location = {
1787
+ companyId: session.companyId,
1788
+ flowKey: session.flowKey,
1789
+ nodeId: node.id
1790
+ };
1791
+ const attachments = await params.flowMediaRepository.listActive(location);
1792
+ for (const attachment of attachments) {
1793
+ try {
1794
+ const buffer = await params.objectStorage.getObject(attachment.uploadId);
1795
+ const { externalMessageId } = await channel.sendMedia({
1796
+ to: session.whatsappNumber,
1797
+ buffer,
1798
+ mimeType: attachment.mimeType,
1799
+ filename: attachment.filename,
1800
+ caption: attachment.caption ?? void 0
1801
+ });
1802
+ await params.logMessage.execute({
1803
+ companyId: session.companyId,
1804
+ whatsappNumber: session.whatsappNumber,
1805
+ direction: "outbound",
1806
+ sender: "bot",
1807
+ agentUserId: null,
1808
+ type: mediaTypeFor2(attachment.mimeType),
1809
+ content: attachment.caption ?? attachment.filename,
1810
+ payload: {
1811
+ filename: attachment.filename,
1812
+ mimeType: attachment.mimeType,
1813
+ uploadId: attachment.uploadId,
1814
+ flowMediaId: attachment.id
1815
+ },
1816
+ waMessageId: externalMessageId,
1817
+ status: "sent",
1818
+ startState: params.startState
1819
+ });
1820
+ } catch (error) {
1821
+ params.onError?.(error, {
1822
+ flowKey: location.flowKey,
1823
+ nodeId: node.id,
1824
+ uploadId: attachment.uploadId
1825
+ });
1826
+ }
1827
+ }
1828
+ };
1829
+ }
1830
+ __name(createSendMediaAction, "createSendMediaAction");
1831
+
1832
+ // src/flows/createSendProductListAction.ts
1833
+ var PRODUCT_LIST_LIMIT = {
1834
+ ITEMS: 30,
1835
+ SECTIONS: 10
1836
+ };
1837
+ function createSendProductListAction(params) {
1838
+ return async ({ node, session, channel }) => {
1839
+ if (!channel.sendProductList) return;
1840
+ const actionParams = node.actionParams ?? {};
1841
+ try {
1842
+ const available = await listAvailableProducts({
1843
+ catalog: params.catalog,
1844
+ catalogId: params.catalogId,
1845
+ ...actionParams.search ? {
1846
+ search: actionParams.search
1847
+ } : {}
1848
+ });
1849
+ if (available.length === 0) return;
1850
+ const bodyText = actionParams.bodyText ?? "Veja o que temos dispon\xEDvel:";
1851
+ const { externalMessageId } = await channel.sendProductList({
1852
+ to: session.whatsappNumber,
1853
+ headerText: actionParams.headerText ?? "Nossos produtos",
1854
+ body: bodyText,
1855
+ ...actionParams.footerText ? {
1856
+ footerText: actionParams.footerText
1857
+ } : {},
1858
+ sections: [
1859
+ {
1860
+ title: actionParams.sectionTitle ?? "Dispon\xEDveis",
1861
+ retailerIds: available.map((product) => product.retailerId)
1862
+ }
1863
+ ]
1864
+ });
1865
+ await params.logMessage.execute({
1866
+ companyId: session.companyId,
1867
+ whatsappNumber: session.whatsappNumber,
1868
+ direction: "outbound",
1869
+ sender: "bot",
1870
+ agentUserId: null,
1871
+ type: "interactive",
1872
+ content: bodyText,
1873
+ payload: {
1874
+ kind: "product_list",
1875
+ productCount: available.length
1876
+ },
1877
+ waMessageId: externalMessageId,
1878
+ status: "sent",
1879
+ startState: params.startState
1880
+ });
1881
+ } catch (error) {
1882
+ params.onError?.(error, {
1883
+ flowKey: session.flowKey ?? "",
1884
+ nodeId: node.id
1885
+ });
1886
+ }
1887
+ };
1888
+ }
1889
+ __name(createSendProductListAction, "createSendProductListAction");
1890
+ async function listAvailableProducts(params) {
1891
+ const products = await params.catalog.listProducts({
1892
+ catalogId: params.catalogId,
1893
+ ...params.search ? {
1894
+ search: params.search
1895
+ } : {}
1896
+ });
1897
+ return products.filter((product) => product.availability === "in stock").slice(0, PRODUCT_LIST_LIMIT.ITEMS);
1898
+ }
1899
+ __name(listAvailableProducts, "listAvailableProducts");
1900
+
1901
+ // src/repositories/FlowMediaRepository.ts
1902
+ var import_drizzle_orm7 = require("drizzle-orm");
1903
+ var FlowMediaRepository = class {
1904
+ static {
1905
+ __name(this, "FlowMediaRepository");
1906
+ }
1907
+ db;
1908
+ constructor(db) {
1909
+ this.db = db;
1910
+ }
1911
+ locationFilter(location) {
1912
+ return (0, import_drizzle_orm7.and)((0, import_drizzle_orm7.eq)(flowMedia.companyId, location.companyId), (0, import_drizzle_orm7.eq)(flowMedia.flowKey, location.flowKey), (0, import_drizzle_orm7.eq)(flowMedia.nodeId, location.nodeId));
1913
+ }
1914
+ // O que o nó realmente envia, na ordem de envio. `active` filtrado aqui e não no chamador:
1915
+ // é a razão de a coluna existir, e um chamador que esquecesse do filtro mandaria ao cliente
1916
+ // justamente o material que alguém desligou.
1917
+ async listActive(location) {
1918
+ return this.db.select().from(flowMedia).where((0, import_drizzle_orm7.and)(this.locationFilter(location), (0, import_drizzle_orm7.eq)(flowMedia.active, true))).orderBy((0, import_drizzle_orm7.asc)(flowMedia.sortOrder), (0, import_drizzle_orm7.asc)(flowMedia.createdAt));
1919
+ }
1920
+ // Inclui os desligados — é a visão do editor, onde desligar precisa continuar visível para
1921
+ // poder ser religado.
1922
+ async listAll(location) {
1923
+ return this.db.select().from(flowMedia).where(this.locationFilter(location)).orderBy((0, import_drizzle_orm7.asc)(flowMedia.sortOrder), (0, import_drizzle_orm7.asc)(flowMedia.createdAt));
1924
+ }
1925
+ /**
1926
+ * Anexa um arquivo já existente no storage ao nó.
1927
+ *
1928
+ * `onConflictDoUpdate` em vez de deixar estourar: reanexar o mesmo arquivo é clique repetido no
1929
+ * editor, e o esperado ali é atualizar a legenda/ordem — não um erro de índice único na cara de
1930
+ * quem está montando o fluxo.
1931
+ */
1932
+ async attach(params) {
1933
+ const [row] = await this.db.insert(flowMedia).values({
1934
+ companyId: params.companyId,
1935
+ flowKey: params.flowKey,
1936
+ nodeId: params.nodeId,
1937
+ uploadId: params.uploadId,
1938
+ filename: params.filename,
1939
+ mimeType: params.mimeType,
1940
+ sizeBytes: params.sizeBytes,
1941
+ caption: params.caption ?? null,
1942
+ sortOrder: params.sortOrder ?? 0
1943
+ }).onConflictDoUpdate({
1944
+ target: [
1945
+ flowMedia.companyId,
1946
+ flowMedia.flowKey,
1947
+ flowMedia.nodeId,
1948
+ flowMedia.uploadId
1949
+ ],
1950
+ set: {
1951
+ caption: params.caption ?? null,
1952
+ sortOrder: params.sortOrder ?? 0,
1953
+ active: true,
1954
+ updatedAt: import_drizzle_orm7.sql`now()`
1955
+ }
1956
+ }).returning();
1957
+ return row;
1958
+ }
1959
+ async update(params) {
1960
+ const [row] = await this.db.update(flowMedia).set({
1961
+ ...params.caption !== void 0 ? {
1962
+ caption: params.caption
1963
+ } : {},
1964
+ ...params.sortOrder !== void 0 ? {
1965
+ sortOrder: params.sortOrder
1966
+ } : {},
1967
+ ...params.active !== void 0 ? {
1968
+ active: params.active
1969
+ } : {},
1970
+ updatedAt: import_drizzle_orm7.sql`now()`
1971
+ }).where((0, import_drizzle_orm7.and)((0, import_drizzle_orm7.eq)(flowMedia.companyId, params.companyId), (0, import_drizzle_orm7.eq)(flowMedia.id, params.id))).returning();
1972
+ return row;
1973
+ }
1974
+ /**
1975
+ * Desanexa do nó. Não toca no storage de propósito: o mesmo `uploadId` pode estar anexado a
1976
+ * outro nó ou a outro fluxo, e apagar o binário aqui quebraria os demais. Quem apaga objeto é o
1977
+ * host, que é dono da biblioteca de arquivos.
1978
+ */
1979
+ async detach(params) {
1980
+ await this.db.delete(flowMedia).where((0, import_drizzle_orm7.and)((0, import_drizzle_orm7.eq)(flowMedia.companyId, params.companyId), (0, import_drizzle_orm7.eq)(flowMedia.id, params.id)));
1981
+ }
1982
+ // Chamado ao salvar o grafo: nós apagados no editor deixam linhas que nada mais alcança.
1983
+ async detachRemovedNodes(params) {
1984
+ const condition = params.existingNodeIds.length === 0 ? void 0 : import_drizzle_orm7.sql`${flowMedia.nodeId} NOT IN ${params.existingNodeIds}`;
1985
+ await this.db.delete(flowMedia).where((0, import_drizzle_orm7.and)((0, import_drizzle_orm7.eq)(flowMedia.companyId, params.companyId), (0, import_drizzle_orm7.eq)(flowMedia.flowKey, params.flowKey), condition));
1986
+ }
1987
+ };
1988
+
1070
1989
  // src/channel/WhatsAppChannelAdapter.ts
1071
1990
  var import_meta_graph_core = require("@adatechnology/meta-graph-core");
1072
- var import_meta_whatsapp_contracts6 = require("@adatechnology/meta-whatsapp-contracts");
1991
+ var import_meta_whatsapp_contracts9 = require("@adatechnology/meta-whatsapp-contracts");
1992
+
1993
+ // src/channel/previewMedia.ts
1994
+ var import_meta_whatsapp_contracts8 = require("@adatechnology/meta-whatsapp-contracts");
1995
+
1996
+ // src/channel/WhatsAppChannelAdapter.ts
1073
1997
  var WhatsAppChannelAdapter = class {
1074
1998
  static {
1075
1999
  __name(this, "WhatsAppChannelAdapter");
1076
2000
  }
1077
2001
  messages;
1078
- constructor(messages2) {
2002
+ previewMedia;
2003
+ constructor(messages2, previewMedia) {
1079
2004
  this.messages = messages2;
2005
+ this.previewMedia = previewMedia;
1080
2006
  }
1081
2007
  async translateErrors(operation) {
1082
2008
  try {
1083
2009
  return await operation();
1084
2010
  } catch (error) {
1085
- if (error instanceof import_meta_graph_core.WhatsAppWindowExpiredError) throw new import_meta_whatsapp_contracts6.WindowExpiredError();
2011
+ if (error instanceof import_meta_graph_core.WhatsAppWindowExpiredError) throw new import_meta_whatsapp_contracts9.WindowExpiredError();
1086
2012
  throw error;
1087
2013
  }
1088
2014
  }
@@ -1120,55 +2046,413 @@ var WhatsAppChannelAdapter = class {
1120
2046
  externalMessageId: result.waMessageId
1121
2047
  };
1122
2048
  }
2049
+ async sendInteractiveButtons(params) {
2050
+ const result = await this.translateErrors(() => this.messages.sendInteractiveButtons({
2051
+ to: params.to,
2052
+ bodyText: params.body,
2053
+ buttons: params.buttons
2054
+ }));
2055
+ return {
2056
+ externalMessageId: result.waMessageId
2057
+ };
2058
+ }
2059
+ async sendProductList(params) {
2060
+ const result = await this.translateErrors(() => this.messages.sendProductListMessage({
2061
+ to: params.to,
2062
+ headerText: params.headerText,
2063
+ bodyText: params.body,
2064
+ ...params.footerText ? {
2065
+ footerText: params.footerText
2066
+ } : {},
2067
+ sections: params.sections
2068
+ }));
2069
+ return {
2070
+ externalMessageId: result.waMessageId
2071
+ };
2072
+ }
2073
+ /**
2074
+ * Busca o binário da mídia — da Meta, ou do storage quando o id é do simulador.
2075
+ *
2076
+ * O desvio acontece ANTES de qualquer chamada de rede: id do simulador não existe na Meta, e
2077
+ * tentar buscá-lo lá renderia um 404 confuso em vez do áudio que o operador acabou de gravar.
2078
+ */
1123
2079
  async fetchMediaAsBase64(mediaId) {
2080
+ const uploadId = this.previewMedia?.isEnabled ? (0, import_meta_whatsapp_contracts8.resolvePreviewUploadId)(mediaId) : void 0;
2081
+ if (uploadId) {
2082
+ const buffer = await this.previewMedia.objectStorage.getObject(uploadId);
2083
+ return {
2084
+ data: buffer.toString("base64"),
2085
+ mimeType: this.previewMedia.defaultMimeType ?? "audio/ogg"
2086
+ };
2087
+ }
1124
2088
  return this.translateErrors(() => this.messages.fetchMediaAsBase64(mediaId));
1125
2089
  }
1126
2090
  };
1127
2091
 
1128
2092
  // src/channel/ReceiveWebhook.use-case.ts
1129
- var import_meta_whatsapp_contracts8 = require("@adatechnology/meta-whatsapp-contracts");
2093
+ var import_meta_whatsapp_contracts12 = require("@adatechnology/meta-whatsapp-contracts");
1130
2094
 
1131
2095
  // src/channel/webhookSecurity.ts
1132
- var import_node_crypto = require("crypto");
1133
- var import_meta_whatsapp_contracts7 = require("@adatechnology/meta-whatsapp-contracts");
1134
- var WEBHOOK_NONCE_TTL_SECONDS = 300;
1135
- function safeEqualStrings(left, right) {
1136
- const leftDigest = (0, import_node_crypto.createHmac)("sha256", "constant-time-compare").update(left).digest();
1137
- const rightDigest = (0, import_node_crypto.createHmac)("sha256", "constant-time-compare").update(right).digest();
1138
- return (0, import_node_crypto.timingSafeEqual)(leftDigest, rightDigest);
1139
- }
1140
- __name(safeEqualStrings, "safeEqualStrings");
2096
+ var import_meta_graph_core2 = require("@adatechnology/meta-graph-core");
2097
+ var import_meta_whatsapp_contracts10 = require("@adatechnology/meta-whatsapp-contracts");
2098
+ var WEBHOOK_NONCE_NAMESPACE = "meta-whatsapp";
1141
2099
  function verifyWebhookChallenge(params) {
1142
- if (params.mode !== "subscribe" || !params.token || !params.challenge) {
1143
- throw new import_meta_whatsapp_contracts7.InvalidWebhookSignatureError();
1144
- }
1145
- if (!safeEqualStrings(params.token, params.expectedToken)) {
1146
- throw new import_meta_whatsapp_contracts7.InvalidWebhookSignatureError();
1147
- }
2100
+ if (!(0, import_meta_graph_core2.isValidWebhookChallenge)(params)) throw new import_meta_whatsapp_contracts10.InvalidWebhookSignatureError();
1148
2101
  return params.challenge;
1149
2102
  }
1150
2103
  __name(verifyWebhookChallenge, "verifyWebhookChallenge");
1151
2104
  function verifyWebhookSignature(params) {
1152
- const { rawBody, signatureHeader, appSecret } = params;
1153
- if (!signatureHeader?.startsWith("sha256=")) throw new import_meta_whatsapp_contracts7.InvalidWebhookSignatureError();
1154
- const expected = (0, import_node_crypto.createHmac)("sha256", appSecret).update(rawBody).digest("hex");
1155
- const received = signatureHeader.slice("sha256=".length);
1156
- if (!safeEqualStrings(received, expected)) throw new import_meta_whatsapp_contracts7.InvalidWebhookSignatureError();
2105
+ if (!(0, import_meta_graph_core2.isValidWebhookSignature)(params)) throw new import_meta_whatsapp_contracts10.InvalidWebhookSignatureError();
1157
2106
  }
1158
2107
  __name(verifyWebhookSignature, "verifyWebhookSignature");
2108
+ function deliveryKey(signatureHeader) {
2109
+ return (0, import_meta_graph_core2.buildWebhookDeliveryKey)({
2110
+ namespace: WEBHOOK_NONCE_NAMESPACE,
2111
+ signatureHeader
2112
+ });
2113
+ }
2114
+ __name(deliveryKey, "deliveryKey");
1159
2115
  async function claimWebhookDelivery(params) {
1160
- const key = `meta-whatsapp:webhook:${params.signatureHeader}`;
1161
- return params.nonceStore.setIfAbsent(key, params.ttlSeconds ?? WEBHOOK_NONCE_TTL_SECONDS);
2116
+ return params.nonceStore.setIfAbsent(deliveryKey(params.signatureHeader), params.ttlSeconds ?? import_meta_graph_core2.WEBHOOK_CLAIM_TTL_SECONDS);
1162
2117
  }
1163
2118
  __name(claimWebhookDelivery, "claimWebhookDelivery");
2119
+ async function confirmWebhookDelivery(params) {
2120
+ await params.nonceStore.confirm?.(deliveryKey(params.signatureHeader), params.ttlSeconds ?? import_meta_graph_core2.WEBHOOK_NONCE_TTL_SECONDS);
2121
+ }
2122
+ __name(confirmWebhookDelivery, "confirmWebhookDelivery");
1164
2123
 
1165
- // src/channel/ReceiveWebhook.use-case.ts
2124
+ // src/channel/IngestInboundMedia.use-case.ts
2125
+ var import_drizzle_orm8 = require("drizzle-orm");
2126
+
2127
+ // src/use-cases/TranscribeAudio.use-case.ts
2128
+ var import_meta_whatsapp_contracts11 = require("@adatechnology/meta-whatsapp-contracts");
2129
+
2130
+ // src/transcription.types.ts
2131
+ var TRANSCRIPTION_STATUS = {
2132
+ /** Falhou de forma retriável (cota, rede, 5xx) — vai sair quando alguém tentar de novo. */
2133
+ PENDING: "pending",
2134
+ /** Processado. Texto vazio aqui é áudio em silêncio, e NÃO deve ser reprocessado. */
2135
+ DONE: "done",
2136
+ /** Falha definitiva do engine (credencial, áudio corrompido, arquivo grande demais). */
2137
+ FAILED: "failed",
2138
+ /** Nenhum engine da cadeia aceita o formato. Retentar não conserta codec. */
2139
+ UNSUPPORTED: "unsupported"
2140
+ };
2141
+ var TRANSCRIPTION_MODE = {
2142
+ AUTO: "auto",
2143
+ ON_DEMAND: "onDemand"
2144
+ };
2145
+ function isRetriableTranscriptionError(error) {
2146
+ if (typeof error !== "object" || error === null) return true;
2147
+ const isRetriable = error.isRetriable;
2148
+ return typeof isRetriable === "boolean" ? isRetriable : true;
2149
+ }
2150
+ __name(isRetriableTranscriptionError, "isRetriableTranscriptionError");
2151
+ function isUnsupportedTranscriptionError(error) {
2152
+ return typeof error === "object" && error !== null && error.name === "TranscriptionUnsupportedError";
2153
+ }
2154
+ __name(isUnsupportedTranscriptionError, "isUnsupportedTranscriptionError");
2155
+ function transcriptionRetryAfterSeconds(error) {
2156
+ if (typeof error !== "object" || error === null) return void 0;
2157
+ const retryAfter = error.retryAfterSeconds;
2158
+ return typeof retryAfter === "number" ? retryAfter : void 0;
2159
+ }
2160
+ __name(transcriptionRetryAfterSeconds, "transcriptionRetryAfterSeconds");
2161
+ function isAudioMimeType(mimeType) {
2162
+ return typeof mimeType === "string" && mimeType.trim().toLowerCase().startsWith("audio/");
2163
+ }
2164
+ __name(isAudioMimeType, "isAudioMimeType");
2165
+
2166
+ // src/use-cases/TranscribeAudio.use-case.ts
2167
+ var TranscribeAudioUseCase = class {
2168
+ static {
2169
+ __name(this, "TranscribeAudioUseCase");
2170
+ }
2171
+ dependencies;
2172
+ constructor(dependencies) {
2173
+ this.dependencies = dependencies;
2174
+ }
2175
+ async execute(params) {
2176
+ const message = await this.dependencies.messageRepository.findById(params.companyId, params.messageId);
2177
+ if (!message) throw new Error(`Mensagem ${params.messageId} n\xE3o encontrada para transcri\xE7\xE3o`);
2178
+ if (message.transcriptionStatus === TRANSCRIPTION_STATUS.DONE && !params.force) {
2179
+ return {
2180
+ status: TRANSCRIPTION_STATUS.DONE,
2181
+ text: message.transcriptionText,
2182
+ language: message.transcriptionLanguage,
2183
+ engine: message.transcriptionEngine,
2184
+ alreadyTranscribed: true
2185
+ };
2186
+ }
2187
+ if (this.dependencies.resolvePolicy) {
2188
+ const policy = await this.dependencies.resolvePolicy(params.companyId);
2189
+ if (!policy.isEnabled) throw new import_meta_whatsapp_contracts11.TranscriptionDisabledError();
2190
+ }
2191
+ const audio = extractAudioReference(message);
2192
+ const buffer = await this.dependencies.objectStorage.getObject(audio.uploadId);
2193
+ return this.transcribeBuffer({
2194
+ ...params,
2195
+ buffer,
2196
+ mimeType: audio.mimeType,
2197
+ uploadId: audio.uploadId,
2198
+ message
2199
+ });
2200
+ }
2201
+ async transcribeBuffer(context) {
2202
+ try {
2203
+ const result = await this.dependencies.transcriber.transcribe({
2204
+ buffer: context.buffer,
2205
+ mimeType: context.mimeType,
2206
+ ...this.dependencies.languageHint ? {
2207
+ languageHint: this.dependencies.languageHint
2208
+ } : {}
2209
+ });
2210
+ await this.dependencies.messageRepository.saveTranscription({
2211
+ companyId: context.companyId,
2212
+ messageId: context.messageId,
2213
+ status: TRANSCRIPTION_STATUS.DONE,
2214
+ text: result.text,
2215
+ language: result.language ?? null,
2216
+ engine: result.engine
2217
+ });
2218
+ return {
2219
+ status: TRANSCRIPTION_STATUS.DONE,
2220
+ text: result.text,
2221
+ language: result.language ?? null,
2222
+ engine: result.engine,
2223
+ alreadyTranscribed: false
2224
+ };
2225
+ } catch (error) {
2226
+ await this.persistFailure(context, error);
2227
+ throw error;
2228
+ }
2229
+ }
2230
+ /**
2231
+ * Carimba o motivo antes de propagar. O status é o que impede os dois desperdícios simétricos:
2232
+ * reprocessar para sempre um codec impossível, e desistir de um áudio que só esbarrou na cota.
2233
+ */
2234
+ async persistFailure(context, error) {
2235
+ const status = resolveFailureStatus(error);
2236
+ await this.dependencies.messageRepository.saveTranscription({
2237
+ companyId: context.companyId,
2238
+ messageId: context.messageId,
2239
+ status
2240
+ });
2241
+ if (status !== TRANSCRIPTION_STATUS.PENDING) return;
2242
+ const retryAfterSeconds = transcriptionRetryAfterSeconds(error);
2243
+ await this.dependencies.hooks?.onTranscriptionDeferred?.({
2244
+ companyId: context.companyId,
2245
+ messageId: context.messageId,
2246
+ whatsappNumber: context.message.whatsappNumber,
2247
+ uploadId: context.uploadId,
2248
+ ...retryAfterSeconds !== void 0 ? {
2249
+ retryAfterSeconds
2250
+ } : {},
2251
+ reason: retryAfterSeconds !== void 0 ? "rate-limited" : "transient-failure",
2252
+ error
2253
+ });
2254
+ }
2255
+ };
2256
+ function resolveFailureStatus(error) {
2257
+ if (isUnsupportedTranscriptionError(error)) return TRANSCRIPTION_STATUS.UNSUPPORTED;
2258
+ return isRetriableTranscriptionError(error) ? TRANSCRIPTION_STATUS.PENDING : TRANSCRIPTION_STATUS.FAILED;
2259
+ }
2260
+ __name(resolveFailureStatus, "resolveFailureStatus");
2261
+ function extractAudioReference(message) {
2262
+ const payload = message.payload ?? {};
2263
+ const audio = payload["audio"];
2264
+ const mimeType = typeof payload["mimeType"] === "string" ? payload["mimeType"] : audio?.mime_type;
2265
+ if (!isAudioMimeType(mimeType) && !audio) {
2266
+ throw new import_meta_whatsapp_contracts11.MessageNotAudioError(message.id, message.type);
2267
+ }
2268
+ const uploadId = payload["uploadId"];
2269
+ if (typeof uploadId !== "string" || uploadId.length === 0) {
2270
+ throw new import_meta_whatsapp_contracts11.AudioNotIngestedError(message.id);
2271
+ }
2272
+ return {
2273
+ uploadId,
2274
+ mimeType: mimeType ?? "audio/ogg"
2275
+ };
2276
+ }
2277
+ __name(extractAudioReference, "extractAudioReference");
2278
+
2279
+ // src/channel/IngestInboundMedia.use-case.ts
2280
+ var IngestInboundMediaUseCase = class {
2281
+ static {
2282
+ __name(this, "IngestInboundMediaUseCase");
2283
+ }
2284
+ db;
2285
+ channel;
2286
+ objectStorage;
2287
+ documentRepository;
2288
+ transcription;
2289
+ constructor(db, channel, objectStorage, documentRepository, transcription) {
2290
+ this.db = db;
2291
+ this.channel = channel;
2292
+ this.objectStorage = objectStorage;
2293
+ this.documentRepository = documentRepository;
2294
+ this.transcription = transcription;
2295
+ }
2296
+ async execute(params) {
2297
+ const [message] = await this.db.select().from(messages).where((0, import_drizzle_orm8.and)((0, import_drizzle_orm8.eq)(messages.companyId, params.companyId), (0, import_drizzle_orm8.eq)(messages.id, params.messageId))).limit(1);
2298
+ if (!message) throw new Error(`Mensagem ${params.messageId} n\xE3o encontrada para ingest\xE3o de m\xEDdia`);
2299
+ const payload = message.payload ?? {};
2300
+ if (payload["uploadId"] && payload["sourceMediaId"] === params.sourceMediaId) {
2301
+ return {
2302
+ uploadId: String(payload["uploadId"]),
2303
+ alreadyIngested: true
2304
+ };
2305
+ }
2306
+ const { data, mimeType } = await this.channel.fetchMediaAsBase64(params.sourceMediaId);
2307
+ const buffer = Buffer.from(data, "base64");
2308
+ const { uploadId } = await this.objectStorage.upload({
2309
+ buffer,
2310
+ mimeType: mimeType || params.mimeType,
2311
+ key: `meta-whatsapp/${params.companyId}/inbound/${params.sourceMediaId}`
2312
+ });
2313
+ const updatedPayload = {
2314
+ ...payload,
2315
+ uploadId,
2316
+ sourceMediaId: params.sourceMediaId,
2317
+ mimeType: mimeType || params.mimeType,
2318
+ // O tamanho já está na mão (é o buffer que acabou de ser copiado) e a bolha de documento o
2319
+ // exibe ao lado do tipo. Sem gravar aqui, a UI mostraria "PDF" sem o "· 180 KB", e buscá-lo
2320
+ // depois custaria uma consulta à tabela de documentos por mensagem renderizada.
2321
+ sizeBytes: buffer.length,
2322
+ ...params.filename ? {
2323
+ filename: params.filename
2324
+ } : {}
2325
+ };
2326
+ await this.db.update(messages).set({
2327
+ payload: updatedPayload
2328
+ }).where((0, import_drizzle_orm8.and)((0, import_drizzle_orm8.eq)(messages.companyId, params.companyId), (0, import_drizzle_orm8.eq)(messages.id, params.messageId)));
2329
+ await this.documentRepository?.link({
2330
+ companyId: params.companyId,
2331
+ sessionId: message.sessionId,
2332
+ messageId: message.id,
2333
+ uploadId,
2334
+ // Áudio e sticker chegam sem nome; sem um rótulo o painel mostraria linha vazia.
2335
+ filename: params.filename ?? `${params.sourceMediaId}`,
2336
+ mimeType: mimeType || params.mimeType,
2337
+ sizeBytes: buffer.length,
2338
+ source: message.sender
2339
+ });
2340
+ const transcription = await this.transcribeIfAuto({
2341
+ companyId: params.companyId,
2342
+ message,
2343
+ uploadId,
2344
+ buffer,
2345
+ mimeType: mimeType || params.mimeType
2346
+ });
2347
+ return {
2348
+ uploadId,
2349
+ alreadyIngested: false,
2350
+ ...transcription ? {
2351
+ transcription
2352
+ } : {}
2353
+ };
2354
+ }
2355
+ /**
2356
+ * Transcreve o áudio recém-baixado, quando o modo é `auto`.
2357
+ *
2358
+ * **Nunca propaga erro.** Neste ponto o binário já está no storage e já entrou na biblioteca da
2359
+ * conversa: deixar uma falha de transcrição subir marcaria a ingestão inteira como falha, e o
2360
+ * retry do host baixaria de novo da Meta um arquivo que está salvo — gastando banda para reproduzir
2361
+ * um efeito que já aconteceu. O status fica gravado na mensagem e o `onTranscriptionDeferred`
2362
+ * avisa quem sabe reenfileirar.
2363
+ */
2364
+ async transcribeIfAuto(context) {
2365
+ const transcription = this.transcription;
2366
+ if (!transcription) return void 0;
2367
+ if (!isAudioMimeType(context.mimeType)) return void 0;
2368
+ const policy = await transcription.resolvePolicy(context.companyId);
2369
+ if (!policy.isEnabled || policy.mode !== TRANSCRIPTION_MODE.AUTO) return void 0;
2370
+ const current = await transcription.messageRepository.findById(context.companyId, context.message.id);
2371
+ if (current?.transcriptionStatus === TRANSCRIPTION_STATUS.DONE) return void 0;
2372
+ try {
2373
+ const result = await transcription.transcriber.transcribe({
2374
+ buffer: context.buffer,
2375
+ mimeType: context.mimeType,
2376
+ ...transcription.languageHint ? {
2377
+ languageHint: transcription.languageHint
2378
+ } : {}
2379
+ });
2380
+ await transcription.messageRepository.saveTranscription({
2381
+ companyId: context.companyId,
2382
+ messageId: context.message.id,
2383
+ status: TRANSCRIPTION_STATUS.DONE,
2384
+ text: result.text,
2385
+ language: result.language ?? null,
2386
+ engine: result.engine
2387
+ });
2388
+ return {
2389
+ status: TRANSCRIPTION_STATUS.DONE
2390
+ };
2391
+ } catch (error) {
2392
+ return this.recordTranscriptionFailure(context, error);
2393
+ }
2394
+ }
2395
+ async recordTranscriptionFailure(context, error) {
2396
+ const status = resolveFailureStatus(error);
2397
+ await this.transcription?.messageRepository.saveTranscription({
2398
+ companyId: context.companyId,
2399
+ messageId: context.message.id,
2400
+ status
2401
+ });
2402
+ if (status === TRANSCRIPTION_STATUS.PENDING) {
2403
+ const retryAfterSeconds = transcriptionRetryAfterSeconds(error);
2404
+ await this.transcription?.hooks?.onTranscriptionDeferred?.({
2405
+ companyId: context.companyId,
2406
+ messageId: context.message.id,
2407
+ whatsappNumber: context.message.whatsappNumber,
2408
+ uploadId: context.uploadId,
2409
+ ...retryAfterSeconds !== void 0 ? {
2410
+ retryAfterSeconds
2411
+ } : {},
2412
+ reason: retryAfterSeconds !== void 0 ? "rate-limited" : "transient-failure",
2413
+ error
2414
+ });
2415
+ }
2416
+ return {
2417
+ status
2418
+ };
2419
+ }
2420
+ };
2421
+ function extractMediaDescriptor(message) {
2422
+ const payload = message.payload ?? {};
2423
+ for (const key of [
2424
+ "image",
2425
+ "audio",
2426
+ "video",
2427
+ "document",
2428
+ "sticker"
2429
+ ]) {
2430
+ const media = payload[key];
2431
+ if (media?.id) {
2432
+ return {
2433
+ sourceMediaId: media.id,
2434
+ mimeType: media.mime_type ?? "application/octet-stream",
2435
+ filename: media.filename
2436
+ };
2437
+ }
2438
+ }
2439
+ return void 0;
2440
+ }
2441
+ __name(extractMediaDescriptor, "extractMediaDescriptor");
2442
+
2443
+ // src/channel/inboundDispatch.ts
2444
+ function buildInboundJobId(job) {
2445
+ return job.kind === "message" ? `wa-inbound-message:${job.message.id}` : `wa-inbound-status:${job.status.id}:${job.status.status}`;
2446
+ }
2447
+ __name(buildInboundJobId, "buildInboundJobId");
1166
2448
  function toSessionContract(row) {
1167
2449
  return {
1168
2450
  id: row.id,
1169
2451
  companyId: row.companyId,
1170
2452
  whatsappNumber: row.whatsappNumber,
1171
2453
  currentState: row.currentState,
2454
+ flowKey: row.flowKey,
2455
+ currentNodeId: row.currentNodeId,
1172
2456
  context: row.context,
1173
2457
  mode: row.mode,
1174
2458
  assignedUserId: row.assignedUserId,
@@ -1181,6 +2465,65 @@ function toSessionContract(row) {
1181
2465
  };
1182
2466
  }
1183
2467
  __name(toSessionContract, "toSessionContract");
2468
+ function extractAnswer(message) {
2469
+ const interactive = message.interactive;
2470
+ if (interactive?.button_reply) return interactive.button_reply.id;
2471
+ if (interactive?.list_reply) return interactive.list_reply.id;
2472
+ return message.text?.body;
2473
+ }
2474
+ __name(extractAnswer, "extractAnswer");
2475
+ var InboundEffectsDispatcher = class {
2476
+ static {
2477
+ __name(this, "InboundEffectsDispatcher");
2478
+ }
2479
+ params;
2480
+ constructor(params) {
2481
+ this.params = params;
2482
+ }
2483
+ async run(job) {
2484
+ if (job.kind === "message") return this.runMessageEffects(job);
2485
+ return this.runStatusEffects(job);
2486
+ }
2487
+ async runMessageEffects(job) {
2488
+ const { companyId, message, savedMessageId, media } = job;
2489
+ if (media) {
2490
+ await this.params.hooks?.onMediaReceived?.({
2491
+ companyId,
2492
+ messageId: savedMessageId,
2493
+ whatsappNumber: message.from,
2494
+ sourceMediaId: media.sourceMediaId,
2495
+ mimeType: media.mimeType,
2496
+ ...media.filename ? {
2497
+ filename: media.filename
2498
+ } : {}
2499
+ });
2500
+ }
2501
+ const sessionRow = await this.params.sessionRepository.getContext(companyId, message.from);
2502
+ if (!sessionRow) return;
2503
+ if (sessionRow.mode === "human") return;
2504
+ const outcome = await this.params.hooks?.onMessageReceived?.(message, toSessionContract(sessionRow));
2505
+ if (outcome?.outcome === "handled") return;
2506
+ void extractAnswer(message);
2507
+ }
2508
+ async runStatusEffects(job) {
2509
+ const sessionRow = await this.params.sessionRepository.getContext(job.companyId, job.whatsappNumber);
2510
+ await this.params.hooks?.onStatusUpdate?.(job.status, sessionRow ? toSessionContract(sessionRow) : null);
2511
+ }
2512
+ };
2513
+ var ProcessInboundDispatchUseCase = class {
2514
+ static {
2515
+ __name(this, "ProcessInboundDispatchUseCase");
2516
+ }
2517
+ dispatcher;
2518
+ constructor(dispatcher) {
2519
+ this.dispatcher = dispatcher;
2520
+ }
2521
+ async execute(job) {
2522
+ await this.dispatcher.run(job);
2523
+ }
2524
+ };
2525
+
2526
+ // src/channel/ReceiveWebhook.use-case.ts
1184
2527
  function extractContent(message) {
1185
2528
  if (message.text?.body) return message.text.body;
1186
2529
  const interactive = message.interactive;
@@ -1193,13 +2536,6 @@ function extractContent(message) {
1193
2536
  return message.image?.caption ?? message.document?.caption ?? null;
1194
2537
  }
1195
2538
  __name(extractContent, "extractContent");
1196
- function extractAnswer(message) {
1197
- const interactive = message.interactive;
1198
- if (interactive?.button_reply) return interactive.button_reply.id;
1199
- if (interactive?.list_reply) return interactive.list_reply.id;
1200
- return message.text?.body;
1201
- }
1202
- __name(extractAnswer, "extractAnswer");
1203
2539
  function extractPayload(message) {
1204
2540
  const payload = {};
1205
2541
  if (message.interactive) payload["interactive"] = message.interactive;
@@ -1220,7 +2556,17 @@ var ReceiveWebhookUseCase = class {
1220
2556
  params;
1221
2557
  constructor(params) {
1222
2558
  this.params = params;
2559
+ this.dispatcher = new InboundEffectsDispatcher({
2560
+ sessionRepository: params.sessionRepository,
2561
+ ...params.hooks ? {
2562
+ hooks: params.hooks
2563
+ } : {},
2564
+ ...params.realtime ? {
2565
+ realtime: params.realtime
2566
+ } : {}
2567
+ });
1223
2568
  }
2569
+ dispatcher;
1224
2570
  async execute(input) {
1225
2571
  verifyWebhookSignature({
1226
2572
  rawBody: input.rawBody,
@@ -1234,14 +2580,21 @@ var ReceiveWebhookUseCase = class {
1234
2580
  if (!claimed) return {
1235
2581
  duplicate: true,
1236
2582
  messagesProcessed: 0,
1237
- statusesProcessed: 0
2583
+ statusesProcessed: 0,
2584
+ ignoredForeignNumber: 0
1238
2585
  };
1239
2586
  const rawText = typeof input.rawBody === "string" ? input.rawBody : input.rawBody.toString("utf8");
1240
- const payload = import_meta_whatsapp_contracts8.whatsAppWebhookPayloadSchema.parse(JSON.parse(rawText));
2587
+ const payload = import_meta_whatsapp_contracts12.whatsAppWebhookPayloadSchema.parse(JSON.parse(rawText));
1241
2588
  let messagesProcessed = 0;
1242
2589
  let statusesProcessed = 0;
2590
+ let ignoredForeignNumber = 0;
1243
2591
  for (const entry of payload.entry) {
1244
2592
  for (const change of entry.changes) {
2593
+ const targetNumber = change.value.metadata?.phone_number_id;
2594
+ if (targetNumber && targetNumber !== this.params.phoneNumberId) {
2595
+ ignoredForeignNumber++;
2596
+ continue;
2597
+ }
1245
2598
  for (const message of change.value.messages ?? []) {
1246
2599
  await this.handleMessage(input.companyId, message);
1247
2600
  messagesProcessed++;
@@ -1252,10 +2605,15 @@ var ReceiveWebhookUseCase = class {
1252
2605
  }
1253
2606
  }
1254
2607
  }
2608
+ await confirmWebhookDelivery({
2609
+ nonceStore: this.params.nonceStore,
2610
+ signatureHeader: input.signatureHeader
2611
+ });
1255
2612
  return {
1256
2613
  duplicate: false,
1257
2614
  messagesProcessed,
1258
- statusesProcessed
2615
+ statusesProcessed,
2616
+ ignoredForeignNumber
1259
2617
  };
1260
2618
  }
1261
2619
  async handleMessage(companyId, message) {
@@ -1272,12 +2630,17 @@ var ReceiveWebhookUseCase = class {
1272
2630
  startState: this.params.startState
1273
2631
  });
1274
2632
  if (!saved) return;
1275
- const sessionRow = await this.params.sessionRepository.getContext(companyId, message.from);
1276
- if (!sessionRow) return;
1277
- if (sessionRow.mode === "human") return;
1278
- const outcome = await this.params.hooks?.onMessageReceived?.(message, toSessionContract(sessionRow));
1279
- if (outcome?.outcome === "handled") return;
1280
- void extractAnswer(message);
2633
+ const media = extractMediaDescriptor(saved);
2634
+ await this.dispatch({
2635
+ kind: "message",
2636
+ companyId,
2637
+ message,
2638
+ savedMessageId: saved.id,
2639
+ ...media ? {
2640
+ media
2641
+ } : {},
2642
+ receivedAt: Date.now()
2643
+ });
1281
2644
  }
1282
2645
  async handleStatus(companyId, status) {
1283
2646
  const updated = await this.params.messageRepository.updateMessageStatus(companyId, status.id, status.status);
@@ -1286,81 +2649,71 @@ var ReceiveWebhookUseCase = class {
1286
2649
  waMessageId: status.id,
1287
2650
  status: status.status
1288
2651
  });
1289
- const sessionRow = await this.params.sessionRepository.getContext(companyId, updated.whatsappNumber);
1290
- await this.params.hooks?.onStatusUpdate?.(status, sessionRow ? toSessionContract(sessionRow) : null);
2652
+ await this.dispatch({
2653
+ kind: "status",
2654
+ companyId,
2655
+ status,
2656
+ whatsappNumber: updated.whatsappNumber,
2657
+ receivedAt: Date.now()
2658
+ });
2659
+ }
2660
+ // Com fila configurada, os efeitos saem da requisição do webhook; sem ela, rodam aqui mesmo e o
2661
+ // comportamento é o de sempre. É o mesmo `InboundEffectsDispatcher` nos dois caminhos.
2662
+ async dispatch(job) {
2663
+ if (this.params.inboundQueue) {
2664
+ await this.params.inboundQueue.enqueue(job, {
2665
+ jobId: buildInboundJobId(job)
2666
+ });
2667
+ return;
2668
+ }
2669
+ await this.dispatcher.run(job);
1291
2670
  }
1292
2671
  };
1293
2672
 
1294
- // src/channel/IngestInboundMedia.use-case.ts
1295
- var import_drizzle_orm6 = require("drizzle-orm");
1296
- var IngestInboundMediaUseCase = class {
2673
+ // src/use-cases/resolveTranscriptionPolicy.ts
2674
+ function createTranscriptionPolicyResolver(dependencies) {
2675
+ return /* @__PURE__ */ __name(async function resolveTranscriptionPolicy(companyId) {
2676
+ const settings2 = await dependencies.settingsRepository.get(companyId);
2677
+ return {
2678
+ // `??` e não `||`: `false` gravado é decisão explícita de desligar, e `||` a trocaria pelo
2679
+ // padrão do host — desligar no painel não faria nada num deploy com transcrição ligada.
2680
+ isEnabled: settings2.transcriptionEnabled ?? dependencies.defaults.isEnabled,
2681
+ mode: normalizeMode(settings2.transcriptionMode) ?? dependencies.defaults.mode
2682
+ };
2683
+ }, "resolveTranscriptionPolicy");
2684
+ }
2685
+ __name(createTranscriptionPolicyResolver, "createTranscriptionPolicyResolver");
2686
+ function normalizeMode(value) {
2687
+ if (value === TRANSCRIPTION_MODE.AUTO) return TRANSCRIPTION_MODE.AUTO;
2688
+ if (value === TRANSCRIPTION_MODE.ON_DEMAND) return TRANSCRIPTION_MODE.ON_DEMAND;
2689
+ return void 0;
2690
+ }
2691
+ __name(normalizeMode, "normalizeMode");
2692
+
2693
+ // src/use-cases/StorePreviewMedia.use-case.ts
2694
+ var StorePreviewMediaUseCase = class {
1297
2695
  static {
1298
- __name(this, "IngestInboundMediaUseCase");
2696
+ __name(this, "StorePreviewMediaUseCase");
1299
2697
  }
1300
- db;
1301
- channel;
1302
2698
  objectStorage;
1303
- constructor(db, channel, objectStorage) {
1304
- this.db = db;
1305
- this.channel = channel;
2699
+ generateKeySuffix;
2700
+ constructor(objectStorage, generateKeySuffix) {
1306
2701
  this.objectStorage = objectStorage;
2702
+ this.generateKeySuffix = generateKeySuffix;
1307
2703
  }
1308
2704
  async execute(params) {
1309
- const [message] = await this.db.select().from(messages).where((0, import_drizzle_orm6.and)((0, import_drizzle_orm6.eq)(messages.companyId, params.companyId), (0, import_drizzle_orm6.eq)(messages.id, params.messageId))).limit(1);
1310
- if (!message) throw new Error(`Mensagem ${params.messageId} n\xE3o encontrada para ingest\xE3o de m\xEDdia`);
1311
- const payload = message.payload ?? {};
1312
- if (payload["uploadId"] && payload["sourceMediaId"] === params.sourceMediaId) {
1313
- return {
1314
- uploadId: String(payload["uploadId"]),
1315
- alreadyIngested: true
1316
- };
1317
- }
1318
- const { data, mimeType } = await this.channel.fetchMediaAsBase64(params.sourceMediaId);
1319
- const buffer = Buffer.from(data, "base64");
2705
+ const key = `meta-whatsapp/${params.companyId}/preview/${this.generateKeySuffix()}`;
1320
2706
  const { uploadId } = await this.objectStorage.upload({
1321
- buffer,
1322
- mimeType: mimeType || params.mimeType,
1323
- key: `meta-whatsapp/${params.companyId}/inbound/${params.sourceMediaId}`
2707
+ buffer: params.buffer,
2708
+ mimeType: params.mimeType,
2709
+ key
1324
2710
  });
1325
- const updatedPayload = {
1326
- ...payload,
1327
- uploadId,
1328
- sourceMediaId: params.sourceMediaId,
1329
- mimeType: mimeType || params.mimeType,
1330
- ...params.filename ? {
1331
- filename: params.filename
1332
- } : {}
1333
- };
1334
- await this.db.update(messages).set({
1335
- payload: updatedPayload
1336
- }).where((0, import_drizzle_orm6.and)((0, import_drizzle_orm6.eq)(messages.companyId, params.companyId), (0, import_drizzle_orm6.eq)(messages.id, params.messageId)));
1337
2711
  return {
1338
- uploadId,
1339
- alreadyIngested: false
2712
+ mediaId: (0, import_meta_whatsapp_contracts8.toPreviewMediaId)(uploadId),
2713
+ uploadId
1340
2714
  };
1341
2715
  }
1342
2716
  };
1343
- function extractMediaDescriptor(message) {
1344
- const payload = message.payload ?? {};
1345
- for (const key of [
1346
- "image",
1347
- "audio",
1348
- "video",
1349
- "document",
1350
- "sticker"
1351
- ]) {
1352
- const media = payload[key];
1353
- if (media?.id) {
1354
- return {
1355
- sourceMediaId: media.id,
1356
- mimeType: media.mime_type ?? "application/octet-stream",
1357
- filename: media.filename
1358
- };
1359
- }
1360
- }
1361
- return void 0;
1362
- }
1363
- __name(extractMediaDescriptor, "extractMediaDescriptor");
1364
2717
 
1365
2718
  // src/createMetaWhatsAppModule.ts
1366
2719
  function createMetaWhatsAppModule(params) {
@@ -1374,15 +2727,24 @@ function createMetaWhatsAppModule(params) {
1374
2727
  apiVersion: config.apiVersion,
1375
2728
  baseUrl: config.baseUrl
1376
2729
  });
1377
- const channel = new WhatsAppChannelAdapter(messageProvider);
2730
+ const previewMediaSupport = params.features?.previewMedia && providers.objectStorage?.getObject ? {
2731
+ isEnabled: true,
2732
+ objectStorage: providers.objectStorage
2733
+ } : void 0;
2734
+ const channel = new WhatsAppChannelAdapter(messageProvider, previewMediaSupport);
1378
2735
  const sessionRepository = new SessionRepository(db);
1379
2736
  const messageRepository = new MessageRepository(db);
1380
2737
  const settingsRepository = new SettingsRepository(db);
1381
- const flowGraphRepository = new FlowGraphRepository(db);
1382
- const logMessage = new LogMessageUseCase(sessionRepository, messageRepository, providers.realtime);
1383
- const sendMessage = new SendMessageUseCase(channel, sessionRepository, logMessage, providers.objectStorage);
2738
+ const flowGraphCacheFeature = params.features?.flowGraphCache ?? false;
2739
+ const flowGraphCache = flowGraphCacheFeature && providers.cache ? new FlowGraphCache(providers.cache, typeof flowGraphCacheFeature === "object" ? flowGraphCacheFeature.ttlSeconds ?? DEFAULT_FLOW_GRAPH_CACHE_TTL_SECONDS : DEFAULT_FLOW_GRAPH_CACHE_TTL_SECONDS) : void 0;
2740
+ const flowGraphRepository = new FlowGraphRepository(db, flowGraphCache);
2741
+ const documentRepository = new DocumentRepository(db);
2742
+ const flowMediaRepository = new FlowMediaRepository(db);
2743
+ const logMessage = new LogMessageUseCase(sessionRepository, messageRepository, providers.realtime, providers.moderator);
2744
+ const sendMessage = new SendMessageUseCase(channel, sessionRepository, logMessage, providers.objectStorage, documentRepository);
1384
2745
  const receiveWebhook = new ReceiveWebhookUseCase({
1385
2746
  appSecret: config.appSecret,
2747
+ phoneNumberId: config.phoneNumberId,
1386
2748
  nonceStore,
1387
2749
  sessionRepository,
1388
2750
  messageRepository,
@@ -1392,7 +2754,58 @@ function createMetaWhatsAppModule(params) {
1392
2754
  realtime: providers.realtime
1393
2755
  });
1394
2756
  const flowInterpreter = flowEngineEnabled ? new FlowInterpreter() : void 0;
1395
- const ingestInboundMedia = providers.objectStorage ? new IngestInboundMediaUseCase(db, channel, providers.objectStorage) : void 0;
2757
+ if (flowInterpreter && providers.objectStorage?.getObject) {
2758
+ flowInterpreter.registerFlowAction(import_meta_whatsapp_contracts13.FLOW_ACTION_KIND.SEND_MEDIA, createSendMediaAction({
2759
+ flowMediaRepository,
2760
+ objectStorage: providers.objectStorage,
2761
+ logMessage,
2762
+ startState,
2763
+ onError: hooks?.onFlowMediaError
2764
+ }));
2765
+ }
2766
+ if (flowInterpreter && providers.catalog && config.catalogId) {
2767
+ flowInterpreter.registerFlowAction(import_meta_whatsapp_contracts13.FLOW_ACTION_KIND.SEND_PRODUCT_LIST, createSendProductListAction({
2768
+ catalog: providers.catalog,
2769
+ catalogId: config.catalogId,
2770
+ logMessage,
2771
+ startState,
2772
+ onError: hooks?.onFlowProductListError
2773
+ }));
2774
+ }
2775
+ const transcriptionMode = providers.transcription?.mode ?? TRANSCRIPTION_MODE.ON_DEMAND;
2776
+ const resolveTranscriptionPolicy = providers.transcription ? createTranscriptionPolicyResolver({
2777
+ settingsRepository,
2778
+ defaults: {
2779
+ isEnabled: providers.transcription.isEnabledByDefault ?? true,
2780
+ mode: transcriptionMode
2781
+ }
2782
+ }) : void 0;
2783
+ const ingestInboundMedia = providers.objectStorage ? new IngestInboundMediaUseCase(db, channel, providers.objectStorage, documentRepository, providers.transcription && resolveTranscriptionPolicy ? {
2784
+ transcriber: providers.transcription.transcriber,
2785
+ resolvePolicy: resolveTranscriptionPolicy,
2786
+ messageRepository,
2787
+ ...providers.transcription.languageHint ? {
2788
+ languageHint: providers.transcription.languageHint
2789
+ } : {},
2790
+ ...hooks ? {
2791
+ hooks
2792
+ } : {}
2793
+ } : void 0) : void 0;
2794
+ const transcribeAudio = providers.transcription && providers.objectStorage?.getObject ? new TranscribeAudioUseCase({
2795
+ messageRepository,
2796
+ objectStorage: providers.objectStorage,
2797
+ transcriber: providers.transcription.transcriber,
2798
+ ...resolveTranscriptionPolicy ? {
2799
+ resolvePolicy: resolveTranscriptionPolicy
2800
+ } : {},
2801
+ ...providers.transcription.languageHint ? {
2802
+ languageHint: providers.transcription.languageHint
2803
+ } : {},
2804
+ ...hooks ? {
2805
+ hooks
2806
+ } : {}
2807
+ }) : void 0;
2808
+ const listDocuments = new ListConversationDocumentsUseCase(sessionRepository, documentRepository);
1396
2809
  return {
1397
2810
  channel,
1398
2811
  // undefined quando providers.objectStorage não foi injetado.
@@ -1404,10 +2817,36 @@ function createMetaWhatsAppModule(params) {
1404
2817
  release: new ReleaseConversationUseCase(sessionRepository, providers.realtime),
1405
2818
  list: new ListConversationsUseCase(sessionRepository),
1406
2819
  listMessages: new ListMessagesUseCase(sessionRepository, messageRepository),
2820
+ listDocuments,
2821
+ // Biblioteca da empresa inteira, para uma tela de Documentos fora da conversa.
2822
+ listCompanyDocuments: new ListCompanyDocumentsUseCase(documentRepository),
2823
+ // Apaga a mídia no storage antes das linhas — a cascata da FK sozinha deixaria os binários
2824
+ // órfãos, já que a lista de uploadId vive justamente nas linhas que ela derruba.
2825
+ delete: new DeleteConversationUseCase(sessionRepository, documentRepository, providers.objectStorage),
2826
+ purgeExpiredDocuments: new PurgeExpiredDocumentsUseCase(documentRepository, providers.objectStorage),
1407
2827
  export: new ExportConversationUseCase(sessionRepository),
1408
- repository: sessionRepository
2828
+ // undefined quando transcrição não foi injetada, ou quando o storage não sabe ler de volta.
2829
+ // O painel consulta a ausência para decidir se desenha o botão "transcrever".
2830
+ transcribeAudio,
2831
+ repository: sessionRepository,
2832
+ messageRepository,
2833
+ documentRepository
1409
2834
  },
2835
+ /**
2836
+ * `undefined` = o host não injetou transcritor, e nenhuma configuração de empresa muda isso: a
2837
+ * capacidade não existe. Presente, `resolvePolicy` responde o que vale para uma empresa —
2838
+ * é o que a rota de configurações usa para dizer ao painel se desenha o interruptor.
2839
+ */
2840
+ transcription: providers.transcription && resolveTranscriptionPolicy ? {
2841
+ defaultMode: transcriptionMode,
2842
+ resolvePolicy: resolveTranscriptionPolicy
2843
+ } : void 0,
1410
2844
  settings: settingsRepository,
2845
+ /**
2846
+ * `undefined` quando o recurso não está ligado (ou falta storage legível). O host consulta a
2847
+ * ausência para não registrar a rota de upload — e o preview, sem a rota, esconde o microfone.
2848
+ */
2849
+ previewMedia: previewMediaSupport ? new StorePreviewMediaUseCase(providers.objectStorage, () => `${Date.now()}-${Math.random().toString(36).slice(2)}`) : void 0,
1411
2850
  webhook: {
1412
2851
  receive: receiveWebhook,
1413
2852
  // GET de verificação da Meta — o host liga na sua rota e devolve o retorno como texto puro.
@@ -1426,7 +2865,11 @@ function createMetaWhatsAppModule(params) {
1426
2865
  save: new SaveFlowGraphUseCase(flowGraphRepository),
1427
2866
  delete: new DeleteFlowGraphUseCase(flowGraphRepository),
1428
2867
  livePositions: new GetLiveFlowPositionsUseCase(flowGraphRepository),
1429
- repository: flowGraphRepository
2868
+ repository: flowGraphRepository,
2869
+ // Biblioteca de mídia dos nós `send_media` — o host liga nas rotas do editor
2870
+ // (anexar/reordenar/desligar). Existe mesmo sem storage injetado: gerenciar anexos é
2871
+ // consultar a tabela, e só o ENVIO precisa dos bytes.
2872
+ mediaRepository: flowMediaRepository
1430
2873
  } : void 0,
1431
2874
  catalog: providers.catalog
1432
2875
  };
@@ -1449,7 +2892,7 @@ async function runMetaWhatsAppMigrations(params) {
1449
2892
  __name(runMetaWhatsAppMigrations, "runMetaWhatsAppMigrations");
1450
2893
 
1451
2894
  // src/realtime/SseHub.ts
1452
- var import_node_crypto2 = require("crypto");
2895
+ var import_node_crypto = require("crypto");
1453
2896
  var TICKET_TTL_SECONDS = 60;
1454
2897
  var SseHub = class {
1455
2898
  static {
@@ -1462,7 +2905,7 @@ var SseHub = class {
1462
2905
  relaySubscriptions = /* @__PURE__ */ new Map();
1463
2906
  // Identifica esta instância do processo para descartar o próprio eco (pub/sub entrega a
1464
2907
  // mensagem a todos os inscritos, inclusive quem publicou).
1465
- instanceId = (0, import_node_crypto2.randomUUID)();
2908
+ instanceId = (0, import_node_crypto.randomUUID)();
1466
2909
  constructor(relay) {
1467
2910
  this.relay = relay;
1468
2911
  }
@@ -1508,7 +2951,7 @@ var SseHub = class {
1508
2951
  }
1509
2952
  };
1510
2953
  async function issueSseTicket(store, companyId, whatsappNumber) {
1511
- const ticket = (0, import_node_crypto2.randomUUID)();
2954
+ const ticket = (0, import_node_crypto.randomUUID)();
1512
2955
  await store.set(`sse:ticket:${ticket}`, JSON.stringify({
1513
2956
  companyId,
1514
2957
  whatsappNumber
@@ -1526,14 +2969,22 @@ __name(redeemSseTicket, "redeemSseTicket");
1526
2969
  // Annotate the CommonJS export names for ESM import in node:
1527
2970
  0 && (module.exports = {
1528
2971
  CreateFlowGraphUseCase,
2972
+ DEFAULT_FLOW_GRAPH_CACHE_TTL_SECONDS,
2973
+ DeleteConversationUseCase,
1529
2974
  DeleteFlowGraphUseCase,
2975
+ DocumentRepository,
1530
2976
  ExportConversationUseCase,
2977
+ FlowGraphCache,
1531
2978
  FlowGraphRepository,
1532
2979
  FlowInterpreter,
2980
+ FlowMediaRepository,
1533
2981
  GetFlowGraphUseCase,
1534
2982
  GetLiveFlowPositionsUseCase,
2983
+ InboundEffectsDispatcher,
1535
2984
  IngestInboundMediaUseCase,
1536
2985
  InvalidFlowGraphError,
2986
+ ListCompanyDocumentsUseCase,
2987
+ ListConversationDocumentsUseCase,
1537
2988
  ListConversationsUseCase,
1538
2989
  ListFlowGraphsUseCase,
1539
2990
  ListMessagesUseCase,
@@ -1541,6 +2992,10 @@ __name(redeemSseTicket, "redeemSseTicket");
1541
2992
  META_WHATSAPP_MIGRATIONS_TABLE,
1542
2993
  MessageRepository,
1543
2994
  OptimisticLockError,
2995
+ PREVIEW_MEDIA_ID_PREFIX,
2996
+ PRODUCT_LIST_LIMIT,
2997
+ ProcessInboundDispatchUseCase,
2998
+ PurgeExpiredDocumentsUseCase,
1544
2999
  ReceiveWebhookUseCase,
1545
3000
  ReleaseConversationUseCase,
1546
3001
  SaveFlowGraphUseCase,
@@ -1548,21 +3003,41 @@ __name(redeemSseTicket, "redeemSseTicket");
1548
3003
  SessionRepository,
1549
3004
  SettingsRepository,
1550
3005
  SseHub,
3006
+ StorePreviewMediaUseCase,
3007
+ TRANSCRIPTION_MODE,
3008
+ TRANSCRIPTION_STATUS,
1551
3009
  TakeoverConversationUseCase,
3010
+ TranscribeAudioUseCase,
3011
+ WEBHOOK_CLAIM_TTL_SECONDS,
1552
3012
  WEBHOOK_NONCE_TTL_SECONDS,
1553
3013
  WhatsAppChannelAdapter,
3014
+ buildInboundJobId,
1554
3015
  claimWebhookDelivery,
3016
+ confirmWebhookDelivery,
1555
3017
  createMetaWhatsAppModule,
3018
+ createSendMediaAction,
3019
+ createSendProductListAction,
3020
+ createTranscriptionPolicyResolver,
3021
+ documents,
1556
3022
  extractMediaDescriptor,
1557
3023
  flowGraphs,
3024
+ flowMedia,
3025
+ isAudioMimeType,
3026
+ isRetriableTranscriptionError,
3027
+ isUnsupportedTranscriptionError,
1558
3028
  issueSseTicket,
1559
3029
  messages,
1560
3030
  metaWhatsAppMigrationsFolder,
1561
3031
  metaWhatsAppSchema,
1562
3032
  redeemSseTicket,
3033
+ resolveFailureStatus,
3034
+ resolvePreviewUploadId,
1563
3035
  runMetaWhatsAppMigrations,
1564
3036
  sessions,
1565
3037
  settings,
3038
+ toPreviewMediaId,
3039
+ toSessionContract,
3040
+ transcriptionRetryAfterSeconds,
1566
3041
  verifyWebhookChallenge,
1567
3042
  verifyWebhookSignature
1568
3043
  });