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

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