@adatechnology/meta-whatsapp-module 0.2.0-rc.2 → 0.2.0-rc.20

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.
Files changed (28) hide show
  1. package/dist/index.cjs +1398 -106
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +1762 -234
  4. package/dist/index.d.ts +1762 -234
  5. package/dist/index.js +1358 -92
  6. package/dist/index.js.map +1 -1
  7. package/dist/migrations/0004_widen_message_type.sql +1 -0
  8. package/dist/migrations/0005_message_moderation.sql +3 -0
  9. package/dist/migrations/0006_conversation_documents.sql +19 -0
  10. package/dist/migrations/0007_flow_media.sql +18 -0
  11. package/dist/migrations/0008_message_transcription.sql +5 -0
  12. package/dist/migrations/0009_settings_transcription_policy.sql +2 -0
  13. package/dist/migrations/meta/_journal.json +76 -0
  14. package/dist/testing/index.cjs +222 -0
  15. package/dist/testing/index.cjs.map +1 -0
  16. package/dist/testing/index.d.cts +74 -0
  17. package/dist/testing/index.d.ts +74 -0
  18. package/dist/testing/index.js +186 -0
  19. package/dist/testing/index.js.map +1 -0
  20. package/package.json +23 -8
  21. package/dist/migrations/20260725195853_freezing_switch/snapshot.json +0 -555
  22. package/dist/migrations/20260725210958_military_leper_queen/snapshot.json +0 -742
  23. package/dist/migrations/20260725214800_illegal_black_tarantula/snapshot.json +0 -810
  24. package/dist/migrations/20260725234507_normal_viper/snapshot.json +0 -930
  25. /package/dist/migrations/{20260725195853_freezing_switch/migration.sql → 0000_freezing_switch.sql} +0 -0
  26. /package/dist/migrations/{20260725210958_military_leper_queen/migration.sql → 0001_military_leper_queen.sql} +0 -0
  27. /package/dist/migrations/{20260725214800_illegal_black_tarantula/migration.sql → 0002_illegal_black_tarantula.sql} +0 -0
  28. /package/dist/migrations/{20260725234507_normal_viper/migration.sql → 0003_normal_viper.sql} +0 -0
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,20 +22,30 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
22
22
  var index_exports = {};
23
23
  __export(index_exports, {
24
24
  CreateFlowGraphUseCase: () => CreateFlowGraphUseCase,
25
+ DEFAULT_FLOW_GRAPH_CACHE_TTL_SECONDS: () => DEFAULT_FLOW_GRAPH_CACHE_TTL_SECONDS,
26
+ DeleteConversationUseCase: () => DeleteConversationUseCase,
25
27
  DeleteFlowGraphUseCase: () => DeleteFlowGraphUseCase,
28
+ DocumentRepository: () => DocumentRepository,
26
29
  ExportConversationUseCase: () => ExportConversationUseCase,
30
+ FlowGraphCache: () => FlowGraphCache,
27
31
  FlowGraphRepository: () => FlowGraphRepository,
28
32
  FlowInterpreter: () => FlowInterpreter,
33
+ FlowMediaRepository: () => FlowMediaRepository,
29
34
  GetFlowGraphUseCase: () => GetFlowGraphUseCase,
30
35
  GetLiveFlowPositionsUseCase: () => GetLiveFlowPositionsUseCase,
31
36
  IngestInboundMediaUseCase: () => IngestInboundMediaUseCase,
32
37
  InvalidFlowGraphError: () => InvalidFlowGraphError,
38
+ ListCompanyDocumentsUseCase: () => ListCompanyDocumentsUseCase,
39
+ ListConversationDocumentsUseCase: () => ListConversationDocumentsUseCase,
33
40
  ListConversationsUseCase: () => ListConversationsUseCase,
34
41
  ListFlowGraphsUseCase: () => ListFlowGraphsUseCase,
35
42
  ListMessagesUseCase: () => ListMessagesUseCase,
36
43
  LogMessageUseCase: () => LogMessageUseCase,
44
+ META_WHATSAPP_MIGRATIONS_TABLE: () => META_WHATSAPP_MIGRATIONS_TABLE,
37
45
  MessageRepository: () => MessageRepository,
38
46
  OptimisticLockError: () => OptimisticLockError,
47
+ PREVIEW_MEDIA_ID_PREFIX: () => import_meta_whatsapp_contracts8.PREVIEW_MEDIA_ID_PREFIX,
48
+ PurgeExpiredDocumentsUseCase: () => PurgeExpiredDocumentsUseCase,
39
49
  ReceiveWebhookUseCase: () => ReceiveWebhookUseCase,
40
50
  ReleaseConversationUseCase: () => ReleaseConversationUseCase,
41
51
  SaveFlowGraphUseCase: () => SaveFlowGraphUseCase,
@@ -43,20 +53,36 @@ __export(index_exports, {
43
53
  SessionRepository: () => SessionRepository,
44
54
  SettingsRepository: () => SettingsRepository,
45
55
  SseHub: () => SseHub,
56
+ StorePreviewMediaUseCase: () => StorePreviewMediaUseCase,
57
+ TRANSCRIPTION_MODE: () => TRANSCRIPTION_MODE,
58
+ TRANSCRIPTION_STATUS: () => TRANSCRIPTION_STATUS,
46
59
  TakeoverConversationUseCase: () => TakeoverConversationUseCase,
60
+ TranscribeAudioUseCase: () => TranscribeAudioUseCase,
47
61
  WEBHOOK_NONCE_TTL_SECONDS: () => WEBHOOK_NONCE_TTL_SECONDS,
48
62
  WhatsAppChannelAdapter: () => WhatsAppChannelAdapter,
49
63
  claimWebhookDelivery: () => claimWebhookDelivery,
50
64
  createMetaWhatsAppModule: () => createMetaWhatsAppModule,
65
+ createSendMediaAction: () => createSendMediaAction,
66
+ createTranscriptionPolicyResolver: () => createTranscriptionPolicyResolver,
67
+ documents: () => documents,
51
68
  extractMediaDescriptor: () => extractMediaDescriptor,
52
69
  flowGraphs: () => flowGraphs,
70
+ flowMedia: () => flowMedia,
71
+ isAudioMimeType: () => isAudioMimeType,
72
+ isRetriableTranscriptionError: () => isRetriableTranscriptionError,
73
+ isUnsupportedTranscriptionError: () => isUnsupportedTranscriptionError,
53
74
  issueSseTicket: () => issueSseTicket,
54
75
  messages: () => messages,
76
+ metaWhatsAppMigrationsFolder: () => metaWhatsAppMigrationsFolder,
55
77
  metaWhatsAppSchema: () => metaWhatsAppSchema,
56
78
  redeemSseTicket: () => redeemSseTicket,
79
+ resolveFailureStatus: () => resolveFailureStatus,
80
+ resolvePreviewUploadId: () => import_meta_whatsapp_contracts8.resolvePreviewUploadId,
57
81
  runMetaWhatsAppMigrations: () => runMetaWhatsAppMigrations,
58
82
  sessions: () => sessions,
59
83
  settings: () => settings,
84
+ toPreviewMediaId: () => import_meta_whatsapp_contracts8.toPreviewMediaId,
85
+ transcriptionRetryAfterSeconds: () => transcriptionRetryAfterSeconds,
60
86
  verifyWebhookChallenge: () => verifyWebhookChallenge,
61
87
  verifyWebhookSignature: () => verifyWebhookSignature
62
88
  });
@@ -64,6 +90,7 @@ module.exports = __toCommonJS(index_exports);
64
90
 
65
91
  // src/createMetaWhatsAppModule.ts
66
92
  var import_meta_whatsapp_provider = require("@adatechnology/meta-whatsapp-provider");
93
+ var import_meta_whatsapp_contracts13 = require("@adatechnology/meta-whatsapp-contracts");
67
94
 
68
95
  // src/repositories/SessionRepository.ts
69
96
  var import_drizzle_orm2 = require("drizzle-orm");
@@ -139,9 +166,14 @@ var messages = metaWhatsAppSchema.table("messages", {
139
166
  length: 12
140
167
  }).notNull(),
141
168
  agentUserId: (0, import_pg_core.uuid)("agent_user_id"),
142
- type: (0, import_pg_core.varchar)("type", {
143
- length: 16
144
- }).notNull().default("text"),
169
+ // 32, não 16: os tipos da própria Meta são curtos ('text', 'interactive'), mas o host rotula
170
+ type: (
171
+ // a saída com o subtipo que enviou ('interactive_buttons' já tem 19). Apertar aqui só
172
+ // transfere para o consumidor a escolha entre truncar o rótulo e estourar o insert.
173
+ (0, import_pg_core.varchar)("type", {
174
+ length: 32
175
+ }).notNull().default("text")
176
+ ),
145
177
  content: (0, import_pg_core.text)("content"),
146
178
  // T5.4 — nunca base64 aqui; mídia vive no ObjectStorageInterface do host, referenciada por
147
179
  // uploadId dentro deste jsonb.
@@ -155,6 +187,33 @@ var messages = metaWhatsAppSchema.table("messages", {
155
187
  readAt: (0, import_pg_core.timestamp)("read_at", {
156
188
  withTimezone: true
157
189
  }),
190
+ // Moderação de conteúdo. `null` significa NÃO AVALIADO (moderação desligada, ou mensagem
191
+ // anterior ao recurso) — diferente de `false`, que é avaliado e limpo. Colunas em vez de chave
192
+ // dentro de `payload` porque "listar o que foi sinalizado" é consulta de operação, e índice
193
+ // parcial sobre boolean resolve isso sem cavar jsonb.
194
+ moderationFlagged: (0, import_pg_core.boolean)("moderation_flagged"),
195
+ moderationTerms: (0, import_pg_core.jsonb)("moderation_terms").$type(),
196
+ /**
197
+ * Transcrição de áudio. `null` em `transcription_status` significa NÃO AVALIADO — áudio nunca
198
+ * pedido (modo sob demanda), transcrição desligada, mensagem anterior ao recurso, ou mensagem
199
+ * que não é áudio. Diferente de `'done'` com texto vazio, que é áudio em silêncio já processado
200
+ * e que NÃO deve ser reprocessado.
201
+ *
202
+ * Colunas em vez de chave em `payload` pelo mesmo motivo da moderação: "quais áudios ficaram
203
+ * pendentes" e "quais falharam" são consultas de operação, e índice parcial resolve sem cavar
204
+ * jsonb. Buscar texto de áudio também deixa de exigir varredura de payload.
205
+ */
206
+ transcriptionStatus: (0, import_pg_core.varchar)("transcription_status", {
207
+ length: 16
208
+ }).$type(),
209
+ transcriptionText: (0, import_pg_core.text)("transcription_text"),
210
+ transcriptionLanguage: (0, import_pg_core.varchar)("transcription_language", {
211
+ length: 32
212
+ }),
213
+ /** Qual engine produziu. Com uma cadeia de engines, é o que responde "por que esta saiu ruim". */
214
+ transcriptionEngine: (0, import_pg_core.varchar)("transcription_engine", {
215
+ length: 32
216
+ }),
158
217
  createdAt: (0, import_pg_core.timestamp)("created_at", {
159
218
  withTimezone: true
160
219
  }).notNull().defaultNow()
@@ -166,7 +225,54 @@ var messages = metaWhatsAppSchema.table("messages", {
166
225
  // paralelo, então as duas passariam pela checagem e inseririam duplicado. Parcial porque
167
226
  // mensagens outbound ainda sem waMessageId (envio em curso) são legitimamente NULL, e NULLs
168
227
  // não podem competir entre si por unicidade.
169
- (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`)
228
+ (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`),
229
+ // Parcial: só as sinalizadas entram, então o índice fica do tamanho do problema e não do
230
+ // tamanho do transcript.
231
+ (0, import_pg_core.index)("idx_messages_moderation_flagged").on(table.companyId, table.createdAt).where(import_drizzle_orm.sql`${table.moderationFlagged}`),
232
+ // Alimenta a varredura de retomada: "quais áudios ficaram pendentes de transcrição". Parcial
233
+ // porque pendente é estado transitório e raro — o índice fica do tamanho da fila atrasada, não
234
+ // do transcript inteiro.
235
+ (0, import_pg_core.index)("idx_messages_transcription_pending").on(table.companyId, table.createdAt).where(import_drizzle_orm.sql`${table.transcriptionStatus} = 'pending'`)
236
+ ]);
237
+ var documents = metaWhatsAppSchema.table("documents", {
238
+ id: (0, import_pg_core.uuid)("id").primaryKey().defaultRandom(),
239
+ companyId: (0, import_pg_core.uuid)("company_id").notNull(),
240
+ sessionId: (0, import_pg_core.uuid)("session_id").notNull().references(() => sessions.id, {
241
+ onDelete: "cascade"
242
+ }),
243
+ // Anulável e `set null`: apagar a mensagem não deve apagar o arquivo da biblioteca, e arquivo
244
+ // do atendente nasce sem mensagem.
245
+ messageId: (0, import_pg_core.uuid)("message_id").references(() => messages.id, {
246
+ onDelete: "set null"
247
+ }),
248
+ uploadId: (0, import_pg_core.varchar)("upload_id", {
249
+ length: 256
250
+ }).notNull(),
251
+ filename: (0, import_pg_core.varchar)("filename", {
252
+ length: 512
253
+ }).notNull(),
254
+ mimeType: (0, import_pg_core.varchar)("mime_type", {
255
+ length: 128
256
+ }).notNull(),
257
+ sizeBytes: (0, import_pg_core.integer)("size_bytes").notNull(),
258
+ // Do provider de storage, que é endereçado por conteúdo — serve para reconciliar storage
259
+ // contra tabela e para detectar o mesmo binário chegando duas vezes.
260
+ sha256: (0, import_pg_core.varchar)("sha256", {
261
+ length: 64
262
+ }),
263
+ source: (0, import_pg_core.varchar)("source", {
264
+ length: 12
265
+ }).notNull(),
266
+ linkedAt: (0, import_pg_core.timestamp)("linked_at", {
267
+ withTimezone: true
268
+ }).notNull().defaultNow()
269
+ }, (table) => [
270
+ (0, import_pg_core.index)("idx_documents_session_linked").on(table.sessionId, table.linkedAt),
271
+ // Para varredura de retenção por idade sem passar por sessão.
272
+ (0, import_pg_core.index)("idx_documents_company_linked").on(table.companyId, table.linkedAt),
273
+ // O mesmo objeto não pode ser linkado duas vezes na mesma empresa: o job de ingestão é
274
+ // reentregue por retry, e sem isto a reentrega criaria linha duplicada no painel.
275
+ (0, import_pg_core.uniqueIndex)("idx_documents_company_upload").on(table.companyId, table.uploadId)
170
276
  ]);
171
277
  var flowGraphs = metaWhatsAppSchema.table("flow_graphs", {
172
278
  id: (0, import_pg_core.uuid)("id").primaryKey().defaultRandom(),
@@ -197,6 +303,46 @@ var flowGraphs = metaWhatsAppSchema.table("flow_graphs", {
197
303
  }, (table) => [
198
304
  (0, import_pg_core.uniqueIndex)("idx_flow_graphs_company_key").on(table.companyId, table.key)
199
305
  ]);
306
+ var flowMedia = metaWhatsAppSchema.table("flow_media", {
307
+ id: (0, import_pg_core.uuid)("id").primaryKey().defaultRandom(),
308
+ companyId: (0, import_pg_core.uuid)("company_id").notNull(),
309
+ flowKey: (0, import_pg_core.varchar)("flow_key", {
310
+ length: 64
311
+ }).notNull(),
312
+ nodeId: (0, import_pg_core.varchar)("node_id", {
313
+ length: 64
314
+ }).notNull(),
315
+ uploadId: (0, import_pg_core.varchar)("upload_id", {
316
+ length: 256
317
+ }).notNull(),
318
+ filename: (0, import_pg_core.varchar)("filename", {
319
+ length: 512
320
+ }).notNull(),
321
+ mimeType: (0, import_pg_core.varchar)("mime_type", {
322
+ length: 128
323
+ }).notNull(),
324
+ sizeBytes: (0, import_pg_core.integer)("size_bytes").notNull(),
325
+ // Legenda da mídia no WhatsApp. Por arquivo, não por nó: um nó que manda tabela de preços e
326
+ // um folder precisa de textos diferentes para cada um.
327
+ caption: (0, import_pg_core.text)("caption"),
328
+ // Ordem de envio dentro do nó — o cliente recebe as mensagens em sequência, e "tabela antes
329
+ // do folder" é decisão de quem edita, não do banco.
330
+ sortOrder: (0, import_pg_core.integer)("sort_order").notNull().default(0),
331
+ // Desligar sem desanexar: trocar o material da campanha é o caso comum, e apagar a linha
332
+ // perderia a ordem e a legenda já ajustadas.
333
+ active: (0, import_pg_core.boolean)("active").notNull().default(true),
334
+ createdAt: (0, import_pg_core.timestamp)("created_at", {
335
+ withTimezone: true
336
+ }).notNull().defaultNow(),
337
+ updatedAt: (0, import_pg_core.timestamp)("updated_at", {
338
+ withTimezone: true
339
+ }).notNull().defaultNow()
340
+ }, (table) => [
341
+ (0, import_pg_core.index)("idx_flow_media_node").on(table.companyId, table.flowKey, table.nodeId, table.sortOrder),
342
+ // O mesmo arquivo anexado duas vezes ao MESMO nó é erro de clique no editor, e o cliente
343
+ // receberia o documento repetido.
344
+ (0, import_pg_core.uniqueIndex)("idx_flow_media_node_upload").on(table.companyId, table.flowKey, table.nodeId, table.uploadId)
345
+ ]);
200
346
  var settings = metaWhatsAppSchema.table("settings", {
201
347
  companyId: (0, import_pg_core.uuid)("company_id").primaryKey(),
202
348
  templateName: (0, import_pg_core.varchar)("template_name", {
@@ -209,6 +355,15 @@ var settings = metaWhatsAppSchema.table("settings", {
209
355
  templateVariables: (0, import_pg_core.jsonb)("template_variables").$type().notNull().default([]),
210
356
  welcomeMessage: (0, import_pg_core.text)("welcome_message"),
211
357
  farewellMessage: (0, import_pg_core.text)("farewell_message"),
358
+ /**
359
+ * Política de transcrição desta empresa. Nulo é significativo: "o painel não decidiu", e aí vale
360
+ * o padrão que o host injetou. Sem a distinção, atualizar o módulo desligaria a transcrição de
361
+ * quem já a tinha ligada por ambiente.
362
+ */
363
+ transcriptionEnabled: (0, import_pg_core.boolean)("transcription_enabled"),
364
+ transcriptionMode: (0, import_pg_core.varchar)("transcription_mode", {
365
+ length: 16
366
+ }).$type(),
212
367
  createdAt: (0, import_pg_core.timestamp)("created_at", {
213
368
  withTimezone: true
214
369
  }).notNull().defaultNow(),
@@ -219,6 +374,31 @@ var settings = metaWhatsAppSchema.table("settings", {
219
374
 
220
375
  // src/repositories/SessionRepository.ts
221
376
  var DEFAULT_LIMIT = 20;
377
+ var conversationSummaryProjection = {
378
+ lastContent: import_drizzle_orm2.sql`(
379
+ select m.content from ${messages} m
380
+ where m.company_id = ${sessions}.company_id and m.session_id = ${sessions}.id
381
+ order by m.created_at desc limit 1
382
+ )`,
383
+ lastDirection: import_drizzle_orm2.sql`(
384
+ select m.direction from ${messages} m
385
+ where m.company_id = ${sessions}.company_id and m.session_id = ${sessions}.id
386
+ order by m.created_at desc limit 1
387
+ )`,
388
+ // Entradas do cliente depois da última leitura do atendente. Sessão nunca lida conta
389
+ // tudo — é o comportamento esperado de uma conversa que ninguém abriu ainda.
390
+ unread: import_drizzle_orm2.sql`(
391
+ select count(*)::int from ${messages} m
392
+ where m.company_id = ${sessions}.company_id
393
+ and m.session_id = ${sessions}.id
394
+ and m.direction = 'inbound'
395
+ and (${sessions}.last_agent_read_at is null or m.created_at > ${sessions}.last_agent_read_at)
396
+ )`
397
+ };
398
+ function sessionContextPatch(patch) {
399
+ return import_drizzle_orm2.sql`${sessions.context} || ${JSON.stringify(patch)}::jsonb`;
400
+ }
401
+ __name(sessionContextPatch, "sessionContextPatch");
222
402
  var SessionRepository = class {
223
403
  static {
224
404
  __name(this, "SessionRepository");
@@ -249,6 +429,9 @@ var SessionRepository = class {
249
429
  }).returning();
250
430
  return created;
251
431
  }
432
+ // O `context` jsonb é o ponto de extensão oficial para estado de sessão por produto: o módulo
433
+ // não conhece a forma, o consumidor a declara em TSessionContext. Este setter SUBSTITUI o
434
+ // objeto inteiro — para acumular respostas ao longo da conversa use patchContext.
252
435
  async setState(companyId, whatsappNumber, state, context) {
253
436
  await this.db.update(sessions).set({
254
437
  currentState: state,
@@ -259,6 +442,25 @@ var SessionRepository = class {
259
442
  updatedAt: import_drizzle_orm2.sql`now()`
260
443
  }).where((0, import_drizzle_orm2.and)((0, import_drizzle_orm2.eq)(sessions.companyId, companyId), (0, import_drizzle_orm2.eq)(sessions.whatsappNumber, whatsappNumber)));
261
444
  }
445
+ // Mescla parcial do context, feita no banco (`||`) e não por read-modify-write no host: duas
446
+ // mensagens do mesmo cliente processadas em paralelo sobrescreveriam uma à outra, e o campo
447
+ // acumula justamente as respostas coletadas ao longo da conversa. Chave presente no patch
448
+ // vence a existente; as demais permanecem.
449
+ async patchContext(companyId, whatsappNumber, patch) {
450
+ await this.db.update(sessions).set({
451
+ context: sessionContextPatch(patch),
452
+ lastActivity: import_drizzle_orm2.sql`now()`,
453
+ updatedAt: import_drizzle_orm2.sql`now()`
454
+ }).where((0, import_drizzle_orm2.and)((0, import_drizzle_orm2.eq)(sessions.companyId, companyId), (0, import_drizzle_orm2.eq)(sessions.whatsappNumber, whatsappNumber)));
455
+ }
456
+ // Leitura tipada do estado de sessão do produto. Devolve undefined quando a sessão não existe —
457
+ // distinto de existir com context vazio, que devolve o objeto vazio.
458
+ async readContext(companyId, whatsappNumber) {
459
+ const [row] = await this.db.select({
460
+ context: sessions.context
461
+ }).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);
462
+ return row?.context;
463
+ }
262
464
  // Posição no grafo de fluxo — chamado pelo host a cada transição do FlowInterpreter.
263
465
  // Passar null em ambos desliga o rastreio (ex.: conversa saiu do motor de fluxo).
264
466
  async setFlowPosition(companyId, whatsappNumber, flowKey, currentNodeId) {
@@ -301,6 +503,16 @@ var SessionRepository = class {
301
503
  async release(companyId, whatsappNumber) {
302
504
  await this.setMode(companyId, whatsappNumber, "bot", null);
303
505
  }
506
+ /**
507
+ * Apaga a sessão; a cascata das FKs leva mensagens e documentos.
508
+ *
509
+ * Não apaga o binário no storage — isso é passo de aplicação, e é por isso que este método é
510
+ * chamado por `DeleteConversationUseCase` e não diretamente pelo host. Chamar daqui sem apagar os
511
+ * objetos antes deixa mídia órfã sendo cobrada para sempre.
512
+ */
513
+ async deleteByNumber(companyId, whatsappNumber) {
514
+ 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)));
515
+ }
304
516
  async requestHuman(companyId, whatsappNumber) {
305
517
  await this.db.update(sessions).set({
306
518
  humanRequestedAt: import_drizzle_orm2.sql`now()`,
@@ -334,17 +546,28 @@ var SessionRepository = class {
334
546
  currentState: sessions.currentState,
335
547
  lastActivity: sessions.lastActivity,
336
548
  lastInboundAt: sessions.lastInboundAt,
337
- humanRequestedAt: sessions.humanRequestedAt
549
+ humanRequestedAt: sessions.humanRequestedAt,
550
+ // Prévia e contagem saem de subquery correlacionada em vez de N+1 na volta: uma inbox
551
+ // lista dezenas de conversas por página, e uma query por linha é o gargalo clássico
552
+ // dessa tela. Ambos os campos são dados do próprio módulo — deixá-los para o host
553
+ // obrigaria todo consumidor a reescrever o mesmo join contra tabelas que não são dele.
554
+ ...conversationSummaryProjection
338
555
  }).from(sessions).where((0, import_drizzle_orm2.and)(...conditions)).orderBy((0, import_drizzle_orm2.desc)(sessions.lastActivity)).limit(limit).offset(offset);
339
556
  return rows.map((row) => ({
340
557
  id: row.id,
341
558
  whatsappNumber: row.whatsappNumber,
559
+ ...row.lastContent !== null ? {
560
+ lastContent: row.lastContent
561
+ } : {},
562
+ ...row.lastDirection !== null ? {
563
+ lastDirection: row.lastDirection
564
+ } : {},
342
565
  lastAt: row.lastActivity.toISOString(),
343
566
  lastInboundAt: row.lastInboundAt?.toISOString() ?? null,
344
567
  mode: row.mode,
345
568
  assignedUserId: row.assignedUserId,
346
569
  waitingHuman: row.humanRequestedAt !== null,
347
- unread: 0,
570
+ unread: Number(row.unread),
348
571
  currentState: row.currentState
349
572
  }));
350
573
  }
@@ -388,7 +611,9 @@ var MessageRepository = class {
388
611
  content: params.content ?? null,
389
612
  payload: params.payload ?? null,
390
613
  waMessageId: params.waMessageId ?? null,
391
- status: params.status ?? null
614
+ status: params.status ?? null,
615
+ moderationFlagged: params.moderationFlagged ?? null,
616
+ moderationTerms: params.moderationTerms ?? null
392
617
  };
393
618
  const [created] = await this.db.insert(messages).values(values).onConflictDoNothing().returning();
394
619
  return created;
@@ -402,6 +627,58 @@ var MessageRepository = class {
402
627
  }).where((0, import_drizzle_orm3.and)((0, import_drizzle_orm3.eq)(messages.companyId, companyId), (0, import_drizzle_orm3.eq)(messages.waMessageId, waMessageId))).returning();
403
628
  return updated;
404
629
  }
630
+ /**
631
+ * Grava a transcrição endereçando pelo id da Meta, para quem só tem esse.
632
+ *
633
+ * Serve ao caso em que a transcrição acontece no próprio webhook — o grafo precisa do texto para
634
+ * responder ao cliente, e jogar fora o que ele já pagou para transcrever significaria transcrever
635
+ * o mesmo áudio uma segunda vez só para o painel ver.
636
+ *
637
+ * Devolve `undefined` quando não achou a mensagem: entrega duplicada e mensagem apagada são
638
+ * corridas normais, não erro.
639
+ */
640
+ async saveTranscriptionByWaMessageId(params) {
641
+ const [updated] = await this.db.update(messages).set({
642
+ transcriptionStatus: params.status,
643
+ ...params.text !== void 0 ? {
644
+ transcriptionText: params.text
645
+ } : {},
646
+ ...params.language !== void 0 ? {
647
+ transcriptionLanguage: params.language
648
+ } : {},
649
+ ...params.engine !== void 0 ? {
650
+ transcriptionEngine: params.engine
651
+ } : {}
652
+ }).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();
653
+ return updated;
654
+ }
655
+ async findById(companyId, messageId) {
656
+ 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);
657
+ return found;
658
+ }
659
+ /**
660
+ * Grava o resultado da transcrição. Devolve `undefined` quando a mensagem não existe (apagada
661
+ * entre o enfileiramento e a execução do job) — não é erro, é corrida normal.
662
+ *
663
+ * `text`/`language`/`engine` só são tocados quando informados: uma retentativa que volta a falhar
664
+ * atualiza o status sem apagar a transcrição parcial de uma tentativa anterior que tenha vindo de
665
+ * outro engine da cadeia.
666
+ */
667
+ async saveTranscription(params) {
668
+ const [updated] = await this.db.update(messages).set({
669
+ transcriptionStatus: params.status,
670
+ ...params.text !== void 0 ? {
671
+ transcriptionText: params.text
672
+ } : {},
673
+ ...params.language !== void 0 ? {
674
+ transcriptionLanguage: params.language
675
+ } : {},
676
+ ...params.engine !== void 0 ? {
677
+ transcriptionEngine: params.engine
678
+ } : {}
679
+ }).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();
680
+ return updated;
681
+ }
405
682
  async listByConversation(params) {
406
683
  const conditions = [
407
684
  (0, import_drizzle_orm3.eq)(messages.companyId, params.companyId),
@@ -451,12 +728,21 @@ var FlowGraphRepository = class {
451
728
  __name(this, "FlowGraphRepository");
452
729
  }
453
730
  db;
454
- constructor(db) {
731
+ cache;
732
+ // Cache opcional: sem ele o repositório se comporta exatamente como antes, lendo sempre do
733
+ // banco. É o host que decide se quer cachear e com qual provedor (ver CacheInterface).
734
+ constructor(db, cache) {
455
735
  this.db = db;
736
+ this.cache = cache;
456
737
  }
457
738
  async get(companyId, key) {
739
+ const cached = await this.cache?.read(companyId, key);
740
+ if (cached) return cached;
458
741
  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);
459
- return row ? toContractGraph(row) : void 0;
742
+ if (!row) return void 0;
743
+ const graph = toContractGraph(row);
744
+ await this.cache?.write(companyId, graph);
745
+ return graph;
460
746
  }
461
747
  async list(companyId) {
462
748
  const rows = await this.db.select().from(flowGraphs).where((0, import_drizzle_orm4.eq)(flowGraphs.companyId, companyId));
@@ -486,7 +772,9 @@ var FlowGraphRepository = class {
486
772
  showInMenu: graph.showInMenu ?? false,
487
773
  menuOptionLabel: graph.menuOptionLabel
488
774
  }).returning();
489
- return toContractGraph(created);
775
+ const createdGraph = toContractGraph(created);
776
+ await this.cache?.invalidate(companyId, createdGraph.key);
777
+ return createdGraph;
490
778
  }
491
779
  // Lock otimista: a escrita só aplica se `expectedVersion` ainda bater com o que está salvo —
492
780
  // senão, alguém mais salvou entretanto e o editor precisa recarregar (ver comentário no schema).
@@ -500,10 +788,12 @@ var FlowGraphRepository = class {
500
788
  updatedAt: /* @__PURE__ */ new Date()
501
789
  }).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();
502
790
  if (rows.length === 0) throw new OptimisticLockError(graph.key);
791
+ await this.cache?.invalidate(companyId, graph.key);
503
792
  return toContractGraph(rows[0]);
504
793
  }
505
794
  async delete(companyId, key) {
506
795
  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)));
796
+ await this.cache?.invalidate(companyId, key);
507
797
  }
508
798
  // T4.2 — GetLiveFlowPositions: agrega sessões ativas por (flowKey, currentNodeId), lendo as
509
799
  // colunas dedicadas gravadas por SessionRepository.setFlowPosition. Agrega no banco (GROUP BY,
@@ -523,6 +813,48 @@ var FlowGraphRepository = class {
523
813
  }
524
814
  };
525
815
 
816
+ // src/repositories/FlowGraphCache.ts
817
+ var KEY_PREFIX = "meta-whatsapp:flow-graph";
818
+ var DEFAULT_FLOW_GRAPH_CACHE_TTL_SECONDS = 300;
819
+ var FlowGraphCache = class {
820
+ static {
821
+ __name(this, "FlowGraphCache");
822
+ }
823
+ provider;
824
+ ttlSeconds;
825
+ constructor(provider, ttlSeconds = DEFAULT_FLOW_GRAPH_CACHE_TTL_SECONDS) {
826
+ this.provider = provider;
827
+ this.ttlSeconds = ttlSeconds;
828
+ }
829
+ // companyId na chave, e não só a flowKey: a chave do fluxo é escolhida por quem edita e se
830
+ // repete entre empresas — 'consorcio' existe em todas — então uma chave sem tenant serviria o
831
+ // grafo de uma empresa para a conversa de outra.
832
+ keyFor(companyId, flowKey) {
833
+ return `${KEY_PREFIX}:${companyId}:${flowKey}`;
834
+ }
835
+ async read(companyId, flowKey) {
836
+ try {
837
+ const cached = await this.provider.get(this.keyFor(companyId, flowKey));
838
+ if (!cached) return void 0;
839
+ return JSON.parse(cached);
840
+ } catch {
841
+ return void 0;
842
+ }
843
+ }
844
+ async write(companyId, graph) {
845
+ try {
846
+ await this.provider.set(this.keyFor(companyId, graph.key), JSON.stringify(graph), this.ttlSeconds);
847
+ } catch {
848
+ }
849
+ }
850
+ async invalidate(companyId, flowKey) {
851
+ try {
852
+ await this.provider.delete(this.keyFor(companyId, flowKey));
853
+ } catch {
854
+ }
855
+ }
856
+ };
857
+
526
858
  // src/repositories/SettingsRepository.ts
527
859
  var import_drizzle_orm5 = require("drizzle-orm");
528
860
  function toContractSettings(row) {
@@ -531,7 +863,11 @@ function toContractSettings(row) {
531
863
  templateLanguage: row.templateLanguage,
532
864
  templateVariables: row.templateVariables,
533
865
  welcomeMessage: row.welcomeMessage ?? "",
534
- farewellMessage: row.farewellMessage ?? ""
866
+ farewellMessage: row.farewellMessage ?? "",
867
+ // `?? null` e não `?? false`: nulo é "o painel não decidiu", e é o que faz valer o padrão do
868
+ // host. Colapsar para `false` desligaria quem ligou por ambiente.
869
+ transcriptionEnabled: row.transcriptionEnabled ?? null,
870
+ transcriptionMode: row.transcriptionMode ?? null
535
871
  };
536
872
  }
537
873
  __name(toContractSettings, "toContractSettings");
@@ -540,7 +876,9 @@ var EMPTY_SETTINGS = {
540
876
  templateLanguage: "pt_BR",
541
877
  templateVariables: [],
542
878
  welcomeMessage: "",
543
- farewellMessage: ""
879
+ farewellMessage: "",
880
+ transcriptionEnabled: null,
881
+ transcriptionMode: null
544
882
  };
545
883
  var SettingsRepository = class {
546
884
  static {
@@ -569,7 +907,12 @@ var SettingsRepository = class {
569
907
  templateLanguage: merged.templateLanguage,
570
908
  templateVariables: merged.templateVariables,
571
909
  welcomeMessage: merged.welcomeMessage || null,
572
- farewellMessage: merged.farewellMessage || null
910
+ farewellMessage: merged.farewellMessage || null,
911
+ // Sem `|| null`: `false` aqui é decisão explícita do painel ("desligado para esta empresa"),
912
+ // e colapsá-lo para nulo faria a empresa voltar a herdar o padrão do host — exatamente o
913
+ // oposto do que o operador acabou de pedir.
914
+ transcriptionEnabled: merged.transcriptionEnabled,
915
+ transcriptionMode: merged.transcriptionMode
573
916
  }).onConflictDoUpdate({
574
917
  target: settings.companyId,
575
918
  set: {
@@ -578,6 +921,8 @@ var SettingsRepository = class {
578
921
  templateVariables: merged.templateVariables,
579
922
  welcomeMessage: merged.welcomeMessage || null,
580
923
  farewellMessage: merged.farewellMessage || null,
924
+ transcriptionEnabled: merged.transcriptionEnabled,
925
+ transcriptionMode: merged.transcriptionMode,
581
926
  updatedAt: /* @__PURE__ */ new Date()
582
927
  }
583
928
  }).returning();
@@ -604,16 +949,19 @@ var LogMessageUseCase = class {
604
949
  sessionRepository;
605
950
  messageRepository;
606
951
  realtime;
607
- constructor(sessionRepository, messageRepository, realtime) {
952
+ moderator;
953
+ constructor(sessionRepository, messageRepository, realtime, moderator) {
608
954
  this.sessionRepository = sessionRepository;
609
955
  this.messageRepository = messageRepository;
610
956
  this.realtime = realtime;
957
+ this.moderator = moderator;
611
958
  }
612
959
  async execute(params) {
613
960
  const session = await this.sessionRepository.getOrCreate(params.companyId, params.whatsappNumber, params.startState);
614
961
  const saved = await this.messageRepository.insertMessage({
615
962
  ...params,
616
- sessionId: session.id
963
+ sessionId: session.id,
964
+ ...this.moderationOf(params)
617
965
  });
618
966
  if (!saved) return void 0;
619
967
  if (params.direction === "inbound") {
@@ -626,6 +974,20 @@ var LogMessageUseCase = class {
626
974
  this.realtime?.emit("global", "data-changed", {});
627
975
  return saved;
628
976
  }
977
+ // Só o que o cliente escreveu: marcar o que o próprio atendente ou o bot enviou não sinaliza
978
+ // abuso, apenas sujaria o transcript com etiqueta na resposta de quem atende.
979
+ moderationOf(params) {
980
+ if (!this.moderator || params.direction !== "inbound") return {};
981
+ const text2 = params.content?.trim();
982
+ if (!text2) return {};
983
+ const verdict = this.moderator.inspect(text2);
984
+ return {
985
+ moderationFlagged: verdict.isOffensive,
986
+ moderationTerms: verdict.isOffensive ? [
987
+ ...verdict.matchedTerms
988
+ ] : null
989
+ };
990
+ }
629
991
  };
630
992
 
631
993
  // src/use-cases/SendMessage.use-case.ts
@@ -639,11 +1001,13 @@ var SendMessageUseCase = class {
639
1001
  sessionRepository;
640
1002
  logMessage;
641
1003
  objectStorage;
642
- constructor(channel, sessionRepository, logMessage, objectStorage) {
1004
+ documentRepository;
1005
+ constructor(channel, sessionRepository, logMessage, objectStorage, documentRepository) {
643
1006
  this.channel = channel;
644
1007
  this.sessionRepository = sessionRepository;
645
1008
  this.logMessage = logMessage;
646
1009
  this.objectStorage = objectStorage;
1010
+ this.documentRepository = documentRepository;
647
1011
  }
648
1012
  async assertWithinWindow(companyId, whatsappNumber) {
649
1013
  const hours = await this.sessionRepository.hoursSinceLastInbound(companyId, whatsappNumber);
@@ -680,7 +1044,7 @@ var SendMessageUseCase = class {
680
1044
  mimeType: params.mimeType,
681
1045
  key: `meta-whatsapp/${params.companyId}/${Date.now()}-${params.filename}`
682
1046
  })).uploadId : void 0;
683
- return this.logMessage.execute({
1047
+ const saved = await this.logMessage.execute({
684
1048
  companyId: params.companyId,
685
1049
  whatsappNumber: params.whatsappNumber,
686
1050
  direction: "outbound",
@@ -699,6 +1063,19 @@ var SendMessageUseCase = class {
699
1063
  status: "sent",
700
1064
  startState: params.startState
701
1065
  });
1066
+ if (saved && uploadId) {
1067
+ await this.documentRepository?.link({
1068
+ companyId: params.companyId,
1069
+ sessionId: saved.sessionId,
1070
+ messageId: saved.id,
1071
+ uploadId,
1072
+ filename: params.filename,
1073
+ mimeType: params.mimeType,
1074
+ sizeBytes: params.buffer.length,
1075
+ source: params.sender
1076
+ });
1077
+ }
1078
+ return saved;
702
1079
  }
703
1080
  // Template é o único envio que ignora a janela — é justamente o mecanismo que a Meta oferece
704
1081
  // para reabri-la.
@@ -816,8 +1193,330 @@ var ListMessagesUseCase = class {
816
1193
  }
817
1194
  };
818
1195
 
819
- // src/use-cases/ExportConversation.use-case.ts
1196
+ // src/use-cases/ListConversationDocuments.use-case.ts
820
1197
  var import_meta_whatsapp_contracts4 = require("@adatechnology/meta-whatsapp-contracts");
1198
+ var ListConversationDocumentsUseCase = class {
1199
+ static {
1200
+ __name(this, "ListConversationDocumentsUseCase");
1201
+ }
1202
+ sessionRepository;
1203
+ documentRepository;
1204
+ constructor(sessionRepository, documentRepository) {
1205
+ this.sessionRepository = sessionRepository;
1206
+ this.documentRepository = documentRepository;
1207
+ }
1208
+ async execute(params) {
1209
+ const session = await this.sessionRepository.getContext(params.companyId, params.whatsappNumber);
1210
+ if (!session) throw new import_meta_whatsapp_contracts4.SessionNotFoundError(params.whatsappNumber);
1211
+ const { rows, total } = await this.documentRepository.listByConversation({
1212
+ companyId: params.companyId,
1213
+ sessionId: session.id,
1214
+ ...params.search ? {
1215
+ search: params.search
1216
+ } : {},
1217
+ ...params.sources && params.sources.length > 0 ? {
1218
+ sources: params.sources
1219
+ } : {},
1220
+ ...params.sortDirection ? {
1221
+ sortDirection: params.sortDirection
1222
+ } : {},
1223
+ ...params.page ? {
1224
+ page: params.page
1225
+ } : {},
1226
+ ...params.limit ? {
1227
+ limit: params.limit
1228
+ } : {}
1229
+ });
1230
+ return {
1231
+ documents: rows.map((row) => ({
1232
+ id: row.uploadId,
1233
+ filename: row.filename,
1234
+ mimeType: row.mimeType,
1235
+ sizeBytes: row.sizeBytes,
1236
+ source: row.source,
1237
+ linkedAt: row.linkedAt.toISOString()
1238
+ })),
1239
+ total
1240
+ };
1241
+ }
1242
+ };
1243
+
1244
+ // src/use-cases/ListCompanyDocuments.use-case.ts
1245
+ var ListCompanyDocumentsUseCase = class {
1246
+ static {
1247
+ __name(this, "ListCompanyDocumentsUseCase");
1248
+ }
1249
+ documentRepository;
1250
+ constructor(documentRepository) {
1251
+ this.documentRepository = documentRepository;
1252
+ }
1253
+ async execute(params) {
1254
+ const { rows, total } = await this.documentRepository.listByCompany({
1255
+ companyId: params.companyId,
1256
+ ...params.search ? {
1257
+ search: params.search
1258
+ } : {},
1259
+ ...params.sources && params.sources.length > 0 ? {
1260
+ sources: params.sources
1261
+ } : {},
1262
+ ...params.sortDirection ? {
1263
+ sortDirection: params.sortDirection
1264
+ } : {},
1265
+ ...params.page ? {
1266
+ page: params.page
1267
+ } : {},
1268
+ ...params.limit ? {
1269
+ limit: params.limit
1270
+ } : {}
1271
+ });
1272
+ return {
1273
+ documents: rows.map((row) => ({
1274
+ id: row.uploadId,
1275
+ conversationId: row.whatsappNumber,
1276
+ filename: row.filename,
1277
+ mimeType: row.mimeType,
1278
+ sizeBytes: row.sizeBytes,
1279
+ source: row.source,
1280
+ linkedAt: row.linkedAt.toISOString()
1281
+ })),
1282
+ total
1283
+ };
1284
+ }
1285
+ };
1286
+
1287
+ // src/use-cases/DeleteConversation.use-case.ts
1288
+ var import_meta_whatsapp_contracts5 = require("@adatechnology/meta-whatsapp-contracts");
1289
+ var DeleteConversationUseCase = class {
1290
+ static {
1291
+ __name(this, "DeleteConversationUseCase");
1292
+ }
1293
+ sessionRepository;
1294
+ documentRepository;
1295
+ objectStorage;
1296
+ constructor(sessionRepository, documentRepository, objectStorage) {
1297
+ this.sessionRepository = sessionRepository;
1298
+ this.documentRepository = documentRepository;
1299
+ this.objectStorage = objectStorage;
1300
+ }
1301
+ async execute(params) {
1302
+ const session = await this.sessionRepository.getContext(params.companyId, params.whatsappNumber);
1303
+ if (!session) throw new import_meta_whatsapp_contracts5.SessionNotFoundError(params.whatsappNumber);
1304
+ const uploadIds = await this.documentRepository.listUploadIdsBySession(params.companyId, session.id);
1305
+ if (uploadIds.length > 0 && !this.objectStorage?.delete) {
1306
+ throw new Error("storage_delete_unsupported: a conversa tem arquivos e o storage injetado n\xE3o implementa delete");
1307
+ }
1308
+ const failedObjects = [];
1309
+ let deletedObjects = 0;
1310
+ for (const uploadId of uploadIds) {
1311
+ try {
1312
+ await this.objectStorage?.delete?.(uploadId);
1313
+ deletedObjects++;
1314
+ } catch {
1315
+ failedObjects.push(uploadId);
1316
+ }
1317
+ }
1318
+ if (failedObjects.length > 0) return {
1319
+ deletedObjects,
1320
+ failedObjects
1321
+ };
1322
+ await this.sessionRepository.deleteByNumber(params.companyId, params.whatsappNumber);
1323
+ return {
1324
+ deletedObjects,
1325
+ failedObjects: []
1326
+ };
1327
+ }
1328
+ };
1329
+
1330
+ // src/use-cases/PurgeExpiredDocuments.use-case.ts
1331
+ var DEFAULT_BATCH_SIZE = 50;
1332
+ var HOURS_IN_DAY = 24;
1333
+ var MILLISECONDS_IN_HOUR = 60 * 60 * 1e3;
1334
+ var PurgeExpiredDocumentsUseCase = class {
1335
+ static {
1336
+ __name(this, "PurgeExpiredDocumentsUseCase");
1337
+ }
1338
+ documentRepository;
1339
+ objectStorage;
1340
+ constructor(documentRepository, objectStorage) {
1341
+ this.documentRepository = documentRepository;
1342
+ this.objectStorage = objectStorage;
1343
+ }
1344
+ async execute(params) {
1345
+ if (!this.objectStorage?.delete) {
1346
+ throw new Error("storage_delete_unsupported: reten\xE7\xE3o exige um storage que implemente delete");
1347
+ }
1348
+ const reference = params.now ?? /* @__PURE__ */ new Date();
1349
+ const olderThan = new Date(reference.getTime() - params.retentionDays * HOURS_IN_DAY * MILLISECONDS_IN_HOUR);
1350
+ const expired = await this.documentRepository.listExpired(params.companyId, olderThan, params.batchSize ?? DEFAULT_BATCH_SIZE);
1351
+ const failed = [];
1352
+ let purged = 0;
1353
+ for (const document of expired) {
1354
+ try {
1355
+ await this.objectStorage.delete(document.uploadId);
1356
+ } catch {
1357
+ failed.push(document.uploadId);
1358
+ continue;
1359
+ }
1360
+ await this.documentRepository.deleteById(params.companyId, document.id);
1361
+ purged++;
1362
+ }
1363
+ return {
1364
+ purged,
1365
+ failed
1366
+ };
1367
+ }
1368
+ };
1369
+
1370
+ // src/repositories/DocumentRepository.ts
1371
+ var import_drizzle_orm6 = require("drizzle-orm");
1372
+ var DEFAULT_LIMIT3 = 50;
1373
+ function companyDocumentSearch(search) {
1374
+ const term = search?.trim();
1375
+ if (!term) return void 0;
1376
+ const digits = term.replace(/\D/g, "");
1377
+ const byFilename = (0, import_drizzle_orm6.ilike)(documents.filename, `%${term}%`);
1378
+ if (!digits) return byFilename;
1379
+ return (0, import_drizzle_orm6.or)(byFilename, (0, import_drizzle_orm6.ilike)(sessions.whatsappNumber, `%${digits}%`));
1380
+ }
1381
+ __name(companyDocumentSearch, "companyDocumentSearch");
1382
+ var DocumentRepository = class {
1383
+ static {
1384
+ __name(this, "DocumentRepository");
1385
+ }
1386
+ db;
1387
+ constructor(db) {
1388
+ this.db = db;
1389
+ }
1390
+ /**
1391
+ * Idempotente por (companyId, uploadId), garantido pelo índice único e não por SELECT prévio: o
1392
+ * job de ingestão é reentregue por retry e duas tentativas concorrentes passariam as duas por uma
1393
+ * checagem, duplicando a linha no painel.
1394
+ *
1395
+ * Devolve `undefined` quando o documento já estava linkado.
1396
+ */
1397
+ async link(params) {
1398
+ const values = {
1399
+ companyId: params.companyId,
1400
+ sessionId: params.sessionId,
1401
+ messageId: params.messageId ?? null,
1402
+ uploadId: params.uploadId,
1403
+ filename: params.filename,
1404
+ mimeType: params.mimeType,
1405
+ sizeBytes: params.sizeBytes,
1406
+ sha256: params.sha256 ?? null,
1407
+ source: params.source
1408
+ };
1409
+ const [created] = await this.db.insert(documents).values(values).onConflictDoNothing().returning();
1410
+ return created;
1411
+ }
1412
+ async listByConversation(params) {
1413
+ const filters = [
1414
+ (0, import_drizzle_orm6.eq)(documents.companyId, params.companyId),
1415
+ (0, import_drizzle_orm6.eq)(documents.sessionId, params.sessionId)
1416
+ ];
1417
+ if (params.search) filters.push((0, import_drizzle_orm6.ilike)(documents.filename, `%${params.search}%`));
1418
+ if (params.sources && params.sources.length > 0) {
1419
+ filters.push((0, import_drizzle_orm6.inArray)(documents.source, [
1420
+ ...params.sources
1421
+ ]));
1422
+ }
1423
+ const where = (0, import_drizzle_orm6.and)(...filters);
1424
+ const limit = params.limit ?? DEFAULT_LIMIT3;
1425
+ const page = params.page && params.page > 0 ? params.page : 1;
1426
+ const orderBy = params.sortDirection === "asc" ? (0, import_drizzle_orm6.asc)(documents.linkedAt) : (0, import_drizzle_orm6.desc)(documents.linkedAt);
1427
+ const [rows, counted] = await Promise.all([
1428
+ this.db.select().from(documents).where(where).orderBy(orderBy).limit(limit).offset((page - 1) * limit),
1429
+ this.db.select({
1430
+ value: (0, import_drizzle_orm6.count)()
1431
+ }).from(documents).where(where)
1432
+ ]);
1433
+ return {
1434
+ rows,
1435
+ total: counted[0]?.value ?? 0
1436
+ };
1437
+ }
1438
+ /**
1439
+ * A biblioteca da EMPRESA inteira, não de uma conversa.
1440
+ *
1441
+ * A busca casa nome do arquivo OU telefone da conversa — ver `companyDocumentSearch`.
1442
+ *
1443
+ * Faz join com `sessions` para carregar de qual conversa cada arquivo veio — numa lista global,
1444
+ * arquivo sem essa referência é inútil: o atendente vê "comprovante.pdf" e não sabe de quem.
1445
+ *
1446
+ * Ordena por `linkedAt` apoiada no índice `idx_documents_company_linked`, que já existia para a
1447
+ * varredura de retenção.
1448
+ */
1449
+ async listByCompany(params) {
1450
+ const filters = [
1451
+ (0, import_drizzle_orm6.eq)(documents.companyId, params.companyId)
1452
+ ];
1453
+ const search = companyDocumentSearch(params.search);
1454
+ if (search) filters.push(search);
1455
+ if (params.sources && params.sources.length > 0) {
1456
+ filters.push((0, import_drizzle_orm6.inArray)(documents.source, [
1457
+ ...params.sources
1458
+ ]));
1459
+ }
1460
+ const where = (0, import_drizzle_orm6.and)(...filters);
1461
+ const limit = params.limit ?? DEFAULT_LIMIT3;
1462
+ const page = params.page && params.page > 0 ? params.page : 1;
1463
+ const orderBy = params.sortDirection === "asc" ? (0, import_drizzle_orm6.asc)(documents.linkedAt) : (0, import_drizzle_orm6.desc)(documents.linkedAt);
1464
+ const [rows, counted] = await Promise.all([
1465
+ this.db.select({
1466
+ id: documents.id,
1467
+ uploadId: documents.uploadId,
1468
+ filename: documents.filename,
1469
+ mimeType: documents.mimeType,
1470
+ sizeBytes: documents.sizeBytes,
1471
+ source: documents.source,
1472
+ linkedAt: documents.linkedAt,
1473
+ // Só o número: o NOME do cliente é dado do produto (tabela própria dele), não do
1474
+ // módulo. Quem quiser exibir "Marina Alves" enriquece na borda.
1475
+ whatsappNumber: sessions.whatsappNumber
1476
+ }).from(documents).innerJoin(sessions, (0, import_drizzle_orm6.eq)(documents.sessionId, sessions.id)).where(where).orderBy(orderBy).limit(limit).offset((page - 1) * limit),
1477
+ // O mesmo join da listagem, e não só `from(documents)`: a busca pode citar
1478
+ // `sessions.whatsapp_number`, e uma contagem sem a tabela na cláusula não compila — pior,
1479
+ // se compilasse, o total divergiria das linhas e a paginação prometeria páginas vazias.
1480
+ this.db.select({
1481
+ value: (0, import_drizzle_orm6.count)()
1482
+ }).from(documents).innerJoin(sessions, (0, import_drizzle_orm6.eq)(documents.sessionId, sessions.id)).where(where)
1483
+ ]);
1484
+ return {
1485
+ rows,
1486
+ total: counted[0]?.value ?? 0
1487
+ };
1488
+ }
1489
+ /**
1490
+ * Um documento pela key do objeto. Serve para recuperar o nome original na hora de assinar o
1491
+ * download: a key é caminho no bucket e salvaria o arquivo com o id da Meta.
1492
+ */
1493
+ async findByUploadId(companyId, uploadId) {
1494
+ 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);
1495
+ return row;
1496
+ }
1497
+ /**
1498
+ * Os objetos a apagar no storage antes de a linha sumir.
1499
+ *
1500
+ * Existe porque a cascata da FK apaga a linha e deixa o binário órfão: quem for apagar a conversa
1501
+ * precisa desta lista primeiro, senão paga armazenamento para sempre por arquivo inalcançável.
1502
+ */
1503
+ async listUploadIdsBySession(companyId, sessionId) {
1504
+ const rows = await this.db.select({
1505
+ uploadId: documents.uploadId
1506
+ }).from(documents).where((0, import_drizzle_orm6.and)((0, import_drizzle_orm6.eq)(documents.companyId, companyId), (0, import_drizzle_orm6.eq)(documents.sessionId, sessionId)));
1507
+ return rows.map((row) => row.uploadId);
1508
+ }
1509
+ /** Varredura de retenção por idade — o par é o mesmo cuidado com o objeto no storage. */
1510
+ async listExpired(companyId, olderThan, limit = DEFAULT_LIMIT3) {
1511
+ 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);
1512
+ }
1513
+ async deleteById(companyId, id) {
1514
+ 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)));
1515
+ }
1516
+ };
1517
+
1518
+ // src/use-cases/ExportConversation.use-case.ts
1519
+ var import_meta_whatsapp_contracts6 = require("@adatechnology/meta-whatsapp-contracts");
821
1520
  var ExportConversationUseCase = class {
822
1521
  static {
823
1522
  __name(this, "ExportConversationUseCase");
@@ -828,7 +1527,7 @@ var ExportConversationUseCase = class {
828
1527
  }
829
1528
  async execute(params) {
830
1529
  const result = await this.sessionRepository.exportConversation(params.companyId, params.whatsappNumber);
831
- if (!result) throw new import_meta_whatsapp_contracts4.SessionNotFoundError(params.whatsappNumber);
1530
+ if (!result) throw new import_meta_whatsapp_contracts6.SessionNotFoundError(params.whatsappNumber);
832
1531
  return result;
833
1532
  }
834
1533
  };
@@ -908,7 +1607,7 @@ var GetLiveFlowPositionsUseCase = class {
908
1607
  };
909
1608
 
910
1609
  // src/flows/FlowInterpreter.ts
911
- var import_meta_whatsapp_contracts5 = require("@adatechnology/meta-whatsapp-contracts");
1610
+ var import_meta_whatsapp_contracts7 = require("@adatechnology/meta-whatsapp-contracts");
912
1611
  var DEFAULT_MAX_STEPS = 50;
913
1612
  function evaluateCondition(node, context) {
914
1613
  if (!node.conditionContextKey || !node.conditionOperator || !node.conditionValue) return false;
@@ -1009,9 +1708,9 @@ var FlowInterpreter = class {
1009
1708
  kind: "terminal",
1010
1709
  context: input.context
1011
1710
  };
1012
- if ((0, import_meta_whatsapp_contracts5.isCrossFlowTarget)(nextNodeId)) return {
1711
+ if ((0, import_meta_whatsapp_contracts7.isCrossFlowTarget)(nextNodeId)) return {
1013
1712
  kind: "cross-flow",
1014
- flowKey: (0, import_meta_whatsapp_contracts5.crossFlowKey)(nextNodeId),
1713
+ flowKey: (0, import_meta_whatsapp_contracts7.crossFlowKey)(nextNodeId),
1015
1714
  context: input.context
1016
1715
  };
1017
1716
  if (!input.graph.nodes[nextNodeId]) return {
@@ -1065,22 +1764,174 @@ var FlowInterpreter = class {
1065
1764
  }
1066
1765
  };
1067
1766
 
1767
+ // src/flows/createSendMediaAction.ts
1768
+ function mediaTypeFor2(mimeType) {
1769
+ if (mimeType.startsWith("image/")) return "image";
1770
+ if (mimeType.startsWith("audio/")) return "audio";
1771
+ if (mimeType.startsWith("video/")) return "video";
1772
+ return "document";
1773
+ }
1774
+ __name(mediaTypeFor2, "mediaTypeFor");
1775
+ function createSendMediaAction(params) {
1776
+ return async ({ node, session, channel }) => {
1777
+ if (!session.flowKey) return;
1778
+ const location = {
1779
+ companyId: session.companyId,
1780
+ flowKey: session.flowKey,
1781
+ nodeId: node.id
1782
+ };
1783
+ const attachments = await params.flowMediaRepository.listActive(location);
1784
+ for (const attachment of attachments) {
1785
+ try {
1786
+ const buffer = await params.objectStorage.getObject(attachment.uploadId);
1787
+ const { externalMessageId } = await channel.sendMedia({
1788
+ to: session.whatsappNumber,
1789
+ buffer,
1790
+ mimeType: attachment.mimeType,
1791
+ filename: attachment.filename,
1792
+ caption: attachment.caption ?? void 0
1793
+ });
1794
+ await params.logMessage.execute({
1795
+ companyId: session.companyId,
1796
+ whatsappNumber: session.whatsappNumber,
1797
+ direction: "outbound",
1798
+ sender: "bot",
1799
+ agentUserId: null,
1800
+ type: mediaTypeFor2(attachment.mimeType),
1801
+ content: attachment.caption ?? attachment.filename,
1802
+ payload: {
1803
+ filename: attachment.filename,
1804
+ mimeType: attachment.mimeType,
1805
+ uploadId: attachment.uploadId,
1806
+ flowMediaId: attachment.id
1807
+ },
1808
+ waMessageId: externalMessageId,
1809
+ status: "sent",
1810
+ startState: params.startState
1811
+ });
1812
+ } catch (error) {
1813
+ params.onError?.(error, {
1814
+ flowKey: location.flowKey,
1815
+ nodeId: node.id,
1816
+ uploadId: attachment.uploadId
1817
+ });
1818
+ }
1819
+ }
1820
+ };
1821
+ }
1822
+ __name(createSendMediaAction, "createSendMediaAction");
1823
+
1824
+ // src/repositories/FlowMediaRepository.ts
1825
+ var import_drizzle_orm7 = require("drizzle-orm");
1826
+ var FlowMediaRepository = class {
1827
+ static {
1828
+ __name(this, "FlowMediaRepository");
1829
+ }
1830
+ db;
1831
+ constructor(db) {
1832
+ this.db = db;
1833
+ }
1834
+ locationFilter(location) {
1835
+ 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));
1836
+ }
1837
+ // O que o nó realmente envia, na ordem de envio. `active` filtrado aqui e não no chamador:
1838
+ // é a razão de a coluna existir, e um chamador que esquecesse do filtro mandaria ao cliente
1839
+ // justamente o material que alguém desligou.
1840
+ async listActive(location) {
1841
+ 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));
1842
+ }
1843
+ // Inclui os desligados — é a visão do editor, onde desligar precisa continuar visível para
1844
+ // poder ser religado.
1845
+ async listAll(location) {
1846
+ 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));
1847
+ }
1848
+ /**
1849
+ * Anexa um arquivo já existente no storage ao nó.
1850
+ *
1851
+ * `onConflictDoUpdate` em vez de deixar estourar: reanexar o mesmo arquivo é clique repetido no
1852
+ * editor, e o esperado ali é atualizar a legenda/ordem — não um erro de índice único na cara de
1853
+ * quem está montando o fluxo.
1854
+ */
1855
+ async attach(params) {
1856
+ const [row] = await this.db.insert(flowMedia).values({
1857
+ companyId: params.companyId,
1858
+ flowKey: params.flowKey,
1859
+ nodeId: params.nodeId,
1860
+ uploadId: params.uploadId,
1861
+ filename: params.filename,
1862
+ mimeType: params.mimeType,
1863
+ sizeBytes: params.sizeBytes,
1864
+ caption: params.caption ?? null,
1865
+ sortOrder: params.sortOrder ?? 0
1866
+ }).onConflictDoUpdate({
1867
+ target: [
1868
+ flowMedia.companyId,
1869
+ flowMedia.flowKey,
1870
+ flowMedia.nodeId,
1871
+ flowMedia.uploadId
1872
+ ],
1873
+ set: {
1874
+ caption: params.caption ?? null,
1875
+ sortOrder: params.sortOrder ?? 0,
1876
+ active: true,
1877
+ updatedAt: import_drizzle_orm7.sql`now()`
1878
+ }
1879
+ }).returning();
1880
+ return row;
1881
+ }
1882
+ async update(params) {
1883
+ const [row] = await this.db.update(flowMedia).set({
1884
+ ...params.caption !== void 0 ? {
1885
+ caption: params.caption
1886
+ } : {},
1887
+ ...params.sortOrder !== void 0 ? {
1888
+ sortOrder: params.sortOrder
1889
+ } : {},
1890
+ ...params.active !== void 0 ? {
1891
+ active: params.active
1892
+ } : {},
1893
+ updatedAt: import_drizzle_orm7.sql`now()`
1894
+ }).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();
1895
+ return row;
1896
+ }
1897
+ /**
1898
+ * Desanexa do nó. Não toca no storage de propósito: o mesmo `uploadId` pode estar anexado a
1899
+ * outro nó ou a outro fluxo, e apagar o binário aqui quebraria os demais. Quem apaga objeto é o
1900
+ * host, que é dono da biblioteca de arquivos.
1901
+ */
1902
+ async detach(params) {
1903
+ 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)));
1904
+ }
1905
+ // Chamado ao salvar o grafo: nós apagados no editor deixam linhas que nada mais alcança.
1906
+ async detachRemovedNodes(params) {
1907
+ const condition = params.existingNodeIds.length === 0 ? void 0 : import_drizzle_orm7.sql`${flowMedia.nodeId} NOT IN ${params.existingNodeIds}`;
1908
+ 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));
1909
+ }
1910
+ };
1911
+
1068
1912
  // src/channel/WhatsAppChannelAdapter.ts
1069
1913
  var import_meta_graph_core = require("@adatechnology/meta-graph-core");
1070
- var import_meta_whatsapp_contracts6 = require("@adatechnology/meta-whatsapp-contracts");
1914
+ var import_meta_whatsapp_contracts9 = require("@adatechnology/meta-whatsapp-contracts");
1915
+
1916
+ // src/channel/previewMedia.ts
1917
+ var import_meta_whatsapp_contracts8 = require("@adatechnology/meta-whatsapp-contracts");
1918
+
1919
+ // src/channel/WhatsAppChannelAdapter.ts
1071
1920
  var WhatsAppChannelAdapter = class {
1072
1921
  static {
1073
1922
  __name(this, "WhatsAppChannelAdapter");
1074
1923
  }
1075
1924
  messages;
1076
- constructor(messages2) {
1925
+ previewMedia;
1926
+ constructor(messages2, previewMedia) {
1077
1927
  this.messages = messages2;
1928
+ this.previewMedia = previewMedia;
1078
1929
  }
1079
1930
  async translateErrors(operation) {
1080
1931
  try {
1081
1932
  return await operation();
1082
1933
  } catch (error) {
1083
- if (error instanceof import_meta_graph_core.WhatsAppWindowExpiredError) throw new import_meta_whatsapp_contracts6.WindowExpiredError();
1934
+ if (error instanceof import_meta_graph_core.WhatsAppWindowExpiredError) throw new import_meta_whatsapp_contracts9.WindowExpiredError();
1084
1935
  throw error;
1085
1936
  }
1086
1937
  }
@@ -1118,17 +1969,31 @@ var WhatsAppChannelAdapter = class {
1118
1969
  externalMessageId: result.waMessageId
1119
1970
  };
1120
1971
  }
1972
+ /**
1973
+ * Busca o binário da mídia — da Meta, ou do storage quando o id é do simulador.
1974
+ *
1975
+ * O desvio acontece ANTES de qualquer chamada de rede: id do simulador não existe na Meta, e
1976
+ * tentar buscá-lo lá renderia um 404 confuso em vez do áudio que o operador acabou de gravar.
1977
+ */
1121
1978
  async fetchMediaAsBase64(mediaId) {
1979
+ const uploadId = this.previewMedia?.isEnabled ? (0, import_meta_whatsapp_contracts8.resolvePreviewUploadId)(mediaId) : void 0;
1980
+ if (uploadId) {
1981
+ const buffer = await this.previewMedia.objectStorage.getObject(uploadId);
1982
+ return {
1983
+ data: buffer.toString("base64"),
1984
+ mimeType: this.previewMedia.defaultMimeType ?? "audio/ogg"
1985
+ };
1986
+ }
1122
1987
  return this.translateErrors(() => this.messages.fetchMediaAsBase64(mediaId));
1123
1988
  }
1124
1989
  };
1125
1990
 
1126
1991
  // src/channel/ReceiveWebhook.use-case.ts
1127
- var import_meta_whatsapp_contracts8 = require("@adatechnology/meta-whatsapp-contracts");
1992
+ var import_meta_whatsapp_contracts12 = require("@adatechnology/meta-whatsapp-contracts");
1128
1993
 
1129
1994
  // src/channel/webhookSecurity.ts
1130
1995
  var import_node_crypto = require("crypto");
1131
- var import_meta_whatsapp_contracts7 = require("@adatechnology/meta-whatsapp-contracts");
1996
+ var import_meta_whatsapp_contracts10 = require("@adatechnology/meta-whatsapp-contracts");
1132
1997
  var WEBHOOK_NONCE_TTL_SECONDS = 300;
1133
1998
  function safeEqualStrings(left, right) {
1134
1999
  const leftDigest = (0, import_node_crypto.createHmac)("sha256", "constant-time-compare").update(left).digest();
@@ -1138,20 +2003,20 @@ function safeEqualStrings(left, right) {
1138
2003
  __name(safeEqualStrings, "safeEqualStrings");
1139
2004
  function verifyWebhookChallenge(params) {
1140
2005
  if (params.mode !== "subscribe" || !params.token || !params.challenge) {
1141
- throw new import_meta_whatsapp_contracts7.InvalidWebhookSignatureError();
2006
+ throw new import_meta_whatsapp_contracts10.InvalidWebhookSignatureError();
1142
2007
  }
1143
2008
  if (!safeEqualStrings(params.token, params.expectedToken)) {
1144
- throw new import_meta_whatsapp_contracts7.InvalidWebhookSignatureError();
2009
+ throw new import_meta_whatsapp_contracts10.InvalidWebhookSignatureError();
1145
2010
  }
1146
2011
  return params.challenge;
1147
2012
  }
1148
2013
  __name(verifyWebhookChallenge, "verifyWebhookChallenge");
1149
2014
  function verifyWebhookSignature(params) {
1150
2015
  const { rawBody, signatureHeader, appSecret } = params;
1151
- if (!signatureHeader?.startsWith("sha256=")) throw new import_meta_whatsapp_contracts7.InvalidWebhookSignatureError();
2016
+ if (!signatureHeader?.startsWith("sha256=")) throw new import_meta_whatsapp_contracts10.InvalidWebhookSignatureError();
1152
2017
  const expected = (0, import_node_crypto.createHmac)("sha256", appSecret).update(rawBody).digest("hex");
1153
2018
  const received = signatureHeader.slice("sha256=".length);
1154
- if (!safeEqualStrings(received, expected)) throw new import_meta_whatsapp_contracts7.InvalidWebhookSignatureError();
2019
+ if (!safeEqualStrings(received, expected)) throw new import_meta_whatsapp_contracts10.InvalidWebhookSignatureError();
1155
2020
  }
1156
2021
  __name(verifyWebhookSignature, "verifyWebhookSignature");
1157
2022
  async function claimWebhookDelivery(params) {
@@ -1160,6 +2025,325 @@ async function claimWebhookDelivery(params) {
1160
2025
  }
1161
2026
  __name(claimWebhookDelivery, "claimWebhookDelivery");
1162
2027
 
2028
+ // src/channel/IngestInboundMedia.use-case.ts
2029
+ var import_drizzle_orm8 = require("drizzle-orm");
2030
+
2031
+ // src/use-cases/TranscribeAudio.use-case.ts
2032
+ var import_meta_whatsapp_contracts11 = require("@adatechnology/meta-whatsapp-contracts");
2033
+
2034
+ // src/transcription.types.ts
2035
+ var TRANSCRIPTION_STATUS = {
2036
+ /** Falhou de forma retriável (cota, rede, 5xx) — vai sair quando alguém tentar de novo. */
2037
+ PENDING: "pending",
2038
+ /** Processado. Texto vazio aqui é áudio em silêncio, e NÃO deve ser reprocessado. */
2039
+ DONE: "done",
2040
+ /** Falha definitiva do engine (credencial, áudio corrompido, arquivo grande demais). */
2041
+ FAILED: "failed",
2042
+ /** Nenhum engine da cadeia aceita o formato. Retentar não conserta codec. */
2043
+ UNSUPPORTED: "unsupported"
2044
+ };
2045
+ var TRANSCRIPTION_MODE = {
2046
+ AUTO: "auto",
2047
+ ON_DEMAND: "onDemand"
2048
+ };
2049
+ function isRetriableTranscriptionError(error) {
2050
+ if (typeof error !== "object" || error === null) return true;
2051
+ const isRetriable = error.isRetriable;
2052
+ return typeof isRetriable === "boolean" ? isRetriable : true;
2053
+ }
2054
+ __name(isRetriableTranscriptionError, "isRetriableTranscriptionError");
2055
+ function isUnsupportedTranscriptionError(error) {
2056
+ return typeof error === "object" && error !== null && error.name === "TranscriptionUnsupportedError";
2057
+ }
2058
+ __name(isUnsupportedTranscriptionError, "isUnsupportedTranscriptionError");
2059
+ function transcriptionRetryAfterSeconds(error) {
2060
+ if (typeof error !== "object" || error === null) return void 0;
2061
+ const retryAfter = error.retryAfterSeconds;
2062
+ return typeof retryAfter === "number" ? retryAfter : void 0;
2063
+ }
2064
+ __name(transcriptionRetryAfterSeconds, "transcriptionRetryAfterSeconds");
2065
+ function isAudioMimeType(mimeType) {
2066
+ return typeof mimeType === "string" && mimeType.trim().toLowerCase().startsWith("audio/");
2067
+ }
2068
+ __name(isAudioMimeType, "isAudioMimeType");
2069
+
2070
+ // src/use-cases/TranscribeAudio.use-case.ts
2071
+ var TranscribeAudioUseCase = class {
2072
+ static {
2073
+ __name(this, "TranscribeAudioUseCase");
2074
+ }
2075
+ dependencies;
2076
+ constructor(dependencies) {
2077
+ this.dependencies = dependencies;
2078
+ }
2079
+ async execute(params) {
2080
+ const message = await this.dependencies.messageRepository.findById(params.companyId, params.messageId);
2081
+ if (!message) throw new Error(`Mensagem ${params.messageId} n\xE3o encontrada para transcri\xE7\xE3o`);
2082
+ if (message.transcriptionStatus === TRANSCRIPTION_STATUS.DONE && !params.force) {
2083
+ return {
2084
+ status: TRANSCRIPTION_STATUS.DONE,
2085
+ text: message.transcriptionText,
2086
+ language: message.transcriptionLanguage,
2087
+ engine: message.transcriptionEngine,
2088
+ alreadyTranscribed: true
2089
+ };
2090
+ }
2091
+ if (this.dependencies.resolvePolicy) {
2092
+ const policy = await this.dependencies.resolvePolicy(params.companyId);
2093
+ if (!policy.isEnabled) throw new import_meta_whatsapp_contracts11.TranscriptionDisabledError();
2094
+ }
2095
+ const audio = extractAudioReference(message);
2096
+ const buffer = await this.dependencies.objectStorage.getObject(audio.uploadId);
2097
+ return this.transcribeBuffer({
2098
+ ...params,
2099
+ buffer,
2100
+ mimeType: audio.mimeType,
2101
+ uploadId: audio.uploadId,
2102
+ message
2103
+ });
2104
+ }
2105
+ async transcribeBuffer(context) {
2106
+ try {
2107
+ const result = await this.dependencies.transcriber.transcribe({
2108
+ buffer: context.buffer,
2109
+ mimeType: context.mimeType,
2110
+ ...this.dependencies.languageHint ? {
2111
+ languageHint: this.dependencies.languageHint
2112
+ } : {}
2113
+ });
2114
+ await this.dependencies.messageRepository.saveTranscription({
2115
+ companyId: context.companyId,
2116
+ messageId: context.messageId,
2117
+ status: TRANSCRIPTION_STATUS.DONE,
2118
+ text: result.text,
2119
+ language: result.language ?? null,
2120
+ engine: result.engine
2121
+ });
2122
+ return {
2123
+ status: TRANSCRIPTION_STATUS.DONE,
2124
+ text: result.text,
2125
+ language: result.language ?? null,
2126
+ engine: result.engine,
2127
+ alreadyTranscribed: false
2128
+ };
2129
+ } catch (error) {
2130
+ await this.persistFailure(context, error);
2131
+ throw error;
2132
+ }
2133
+ }
2134
+ /**
2135
+ * Carimba o motivo antes de propagar. O status é o que impede os dois desperdícios simétricos:
2136
+ * reprocessar para sempre um codec impossível, e desistir de um áudio que só esbarrou na cota.
2137
+ */
2138
+ async persistFailure(context, error) {
2139
+ const status = resolveFailureStatus(error);
2140
+ await this.dependencies.messageRepository.saveTranscription({
2141
+ companyId: context.companyId,
2142
+ messageId: context.messageId,
2143
+ status
2144
+ });
2145
+ if (status !== TRANSCRIPTION_STATUS.PENDING) return;
2146
+ const retryAfterSeconds = transcriptionRetryAfterSeconds(error);
2147
+ await this.dependencies.hooks?.onTranscriptionDeferred?.({
2148
+ companyId: context.companyId,
2149
+ messageId: context.messageId,
2150
+ whatsappNumber: context.message.whatsappNumber,
2151
+ uploadId: context.uploadId,
2152
+ ...retryAfterSeconds !== void 0 ? {
2153
+ retryAfterSeconds
2154
+ } : {},
2155
+ reason: retryAfterSeconds !== void 0 ? "rate-limited" : "transient-failure",
2156
+ error
2157
+ });
2158
+ }
2159
+ };
2160
+ function resolveFailureStatus(error) {
2161
+ if (isUnsupportedTranscriptionError(error)) return TRANSCRIPTION_STATUS.UNSUPPORTED;
2162
+ return isRetriableTranscriptionError(error) ? TRANSCRIPTION_STATUS.PENDING : TRANSCRIPTION_STATUS.FAILED;
2163
+ }
2164
+ __name(resolveFailureStatus, "resolveFailureStatus");
2165
+ function extractAudioReference(message) {
2166
+ const payload = message.payload ?? {};
2167
+ const audio = payload["audio"];
2168
+ const mimeType = typeof payload["mimeType"] === "string" ? payload["mimeType"] : audio?.mime_type;
2169
+ if (!isAudioMimeType(mimeType) && !audio) {
2170
+ throw new import_meta_whatsapp_contracts11.MessageNotAudioError(message.id, message.type);
2171
+ }
2172
+ const uploadId = payload["uploadId"];
2173
+ if (typeof uploadId !== "string" || uploadId.length === 0) {
2174
+ throw new import_meta_whatsapp_contracts11.AudioNotIngestedError(message.id);
2175
+ }
2176
+ return {
2177
+ uploadId,
2178
+ mimeType: mimeType ?? "audio/ogg"
2179
+ };
2180
+ }
2181
+ __name(extractAudioReference, "extractAudioReference");
2182
+
2183
+ // src/channel/IngestInboundMedia.use-case.ts
2184
+ var IngestInboundMediaUseCase = class {
2185
+ static {
2186
+ __name(this, "IngestInboundMediaUseCase");
2187
+ }
2188
+ db;
2189
+ channel;
2190
+ objectStorage;
2191
+ documentRepository;
2192
+ transcription;
2193
+ constructor(db, channel, objectStorage, documentRepository, transcription) {
2194
+ this.db = db;
2195
+ this.channel = channel;
2196
+ this.objectStorage = objectStorage;
2197
+ this.documentRepository = documentRepository;
2198
+ this.transcription = transcription;
2199
+ }
2200
+ async execute(params) {
2201
+ const [message] = await this.db.select().from(messages).where((0, import_drizzle_orm8.and)((0, import_drizzle_orm8.eq)(messages.companyId, params.companyId), (0, import_drizzle_orm8.eq)(messages.id, params.messageId))).limit(1);
2202
+ if (!message) throw new Error(`Mensagem ${params.messageId} n\xE3o encontrada para ingest\xE3o de m\xEDdia`);
2203
+ const payload = message.payload ?? {};
2204
+ if (payload["uploadId"] && payload["sourceMediaId"] === params.sourceMediaId) {
2205
+ return {
2206
+ uploadId: String(payload["uploadId"]),
2207
+ alreadyIngested: true
2208
+ };
2209
+ }
2210
+ const { data, mimeType } = await this.channel.fetchMediaAsBase64(params.sourceMediaId);
2211
+ const buffer = Buffer.from(data, "base64");
2212
+ const { uploadId } = await this.objectStorage.upload({
2213
+ buffer,
2214
+ mimeType: mimeType || params.mimeType,
2215
+ key: `meta-whatsapp/${params.companyId}/inbound/${params.sourceMediaId}`
2216
+ });
2217
+ const updatedPayload = {
2218
+ ...payload,
2219
+ uploadId,
2220
+ sourceMediaId: params.sourceMediaId,
2221
+ mimeType: mimeType || params.mimeType,
2222
+ // O tamanho já está na mão (é o buffer que acabou de ser copiado) e a bolha de documento o
2223
+ // exibe ao lado do tipo. Sem gravar aqui, a UI mostraria "PDF" sem o "· 180 KB", e buscá-lo
2224
+ // depois custaria uma consulta à tabela de documentos por mensagem renderizada.
2225
+ sizeBytes: buffer.length,
2226
+ ...params.filename ? {
2227
+ filename: params.filename
2228
+ } : {}
2229
+ };
2230
+ await this.db.update(messages).set({
2231
+ payload: updatedPayload
2232
+ }).where((0, import_drizzle_orm8.and)((0, import_drizzle_orm8.eq)(messages.companyId, params.companyId), (0, import_drizzle_orm8.eq)(messages.id, params.messageId)));
2233
+ await this.documentRepository?.link({
2234
+ companyId: params.companyId,
2235
+ sessionId: message.sessionId,
2236
+ messageId: message.id,
2237
+ uploadId,
2238
+ // Áudio e sticker chegam sem nome; sem um rótulo o painel mostraria linha vazia.
2239
+ filename: params.filename ?? `${params.sourceMediaId}`,
2240
+ mimeType: mimeType || params.mimeType,
2241
+ sizeBytes: buffer.length,
2242
+ source: message.sender
2243
+ });
2244
+ const transcription = await this.transcribeIfAuto({
2245
+ companyId: params.companyId,
2246
+ message,
2247
+ uploadId,
2248
+ buffer,
2249
+ mimeType: mimeType || params.mimeType
2250
+ });
2251
+ return {
2252
+ uploadId,
2253
+ alreadyIngested: false,
2254
+ ...transcription ? {
2255
+ transcription
2256
+ } : {}
2257
+ };
2258
+ }
2259
+ /**
2260
+ * Transcreve o áudio recém-baixado, quando o modo é `auto`.
2261
+ *
2262
+ * **Nunca propaga erro.** Neste ponto o binário já está no storage e já entrou na biblioteca da
2263
+ * conversa: deixar uma falha de transcrição subir marcaria a ingestão inteira como falha, e o
2264
+ * retry do host baixaria de novo da Meta um arquivo que está salvo — gastando banda para reproduzir
2265
+ * um efeito que já aconteceu. O status fica gravado na mensagem e o `onTranscriptionDeferred`
2266
+ * avisa quem sabe reenfileirar.
2267
+ */
2268
+ async transcribeIfAuto(context) {
2269
+ const transcription = this.transcription;
2270
+ if (!transcription) return void 0;
2271
+ if (!isAudioMimeType(context.mimeType)) return void 0;
2272
+ const policy = await transcription.resolvePolicy(context.companyId);
2273
+ if (!policy.isEnabled || policy.mode !== TRANSCRIPTION_MODE.AUTO) return void 0;
2274
+ const current = await transcription.messageRepository.findById(context.companyId, context.message.id);
2275
+ if (current?.transcriptionStatus === TRANSCRIPTION_STATUS.DONE) return void 0;
2276
+ try {
2277
+ const result = await transcription.transcriber.transcribe({
2278
+ buffer: context.buffer,
2279
+ mimeType: context.mimeType,
2280
+ ...transcription.languageHint ? {
2281
+ languageHint: transcription.languageHint
2282
+ } : {}
2283
+ });
2284
+ await transcription.messageRepository.saveTranscription({
2285
+ companyId: context.companyId,
2286
+ messageId: context.message.id,
2287
+ status: TRANSCRIPTION_STATUS.DONE,
2288
+ text: result.text,
2289
+ language: result.language ?? null,
2290
+ engine: result.engine
2291
+ });
2292
+ return {
2293
+ status: TRANSCRIPTION_STATUS.DONE
2294
+ };
2295
+ } catch (error) {
2296
+ return this.recordTranscriptionFailure(context, error);
2297
+ }
2298
+ }
2299
+ async recordTranscriptionFailure(context, error) {
2300
+ const status = resolveFailureStatus(error);
2301
+ await this.transcription?.messageRepository.saveTranscription({
2302
+ companyId: context.companyId,
2303
+ messageId: context.message.id,
2304
+ status
2305
+ });
2306
+ if (status === TRANSCRIPTION_STATUS.PENDING) {
2307
+ const retryAfterSeconds = transcriptionRetryAfterSeconds(error);
2308
+ await this.transcription?.hooks?.onTranscriptionDeferred?.({
2309
+ companyId: context.companyId,
2310
+ messageId: context.message.id,
2311
+ whatsappNumber: context.message.whatsappNumber,
2312
+ uploadId: context.uploadId,
2313
+ ...retryAfterSeconds !== void 0 ? {
2314
+ retryAfterSeconds
2315
+ } : {},
2316
+ reason: retryAfterSeconds !== void 0 ? "rate-limited" : "transient-failure",
2317
+ error
2318
+ });
2319
+ }
2320
+ return {
2321
+ status
2322
+ };
2323
+ }
2324
+ };
2325
+ function extractMediaDescriptor(message) {
2326
+ const payload = message.payload ?? {};
2327
+ for (const key of [
2328
+ "image",
2329
+ "audio",
2330
+ "video",
2331
+ "document",
2332
+ "sticker"
2333
+ ]) {
2334
+ const media = payload[key];
2335
+ if (media?.id) {
2336
+ return {
2337
+ sourceMediaId: media.id,
2338
+ mimeType: media.mime_type ?? "application/octet-stream",
2339
+ filename: media.filename
2340
+ };
2341
+ }
2342
+ }
2343
+ return void 0;
2344
+ }
2345
+ __name(extractMediaDescriptor, "extractMediaDescriptor");
2346
+
1163
2347
  // src/channel/ReceiveWebhook.use-case.ts
1164
2348
  function toSessionContract(row) {
1165
2349
  return {
@@ -1167,6 +2351,8 @@ function toSessionContract(row) {
1167
2351
  companyId: row.companyId,
1168
2352
  whatsappNumber: row.whatsappNumber,
1169
2353
  currentState: row.currentState,
2354
+ flowKey: row.flowKey,
2355
+ currentNodeId: row.currentNodeId,
1170
2356
  context: row.context,
1171
2357
  mode: row.mode,
1172
2358
  assignedUserId: row.assignedUserId,
@@ -1232,14 +2418,21 @@ var ReceiveWebhookUseCase = class {
1232
2418
  if (!claimed) return {
1233
2419
  duplicate: true,
1234
2420
  messagesProcessed: 0,
1235
- statusesProcessed: 0
2421
+ statusesProcessed: 0,
2422
+ ignoredForeignNumber: 0
1236
2423
  };
1237
2424
  const rawText = typeof input.rawBody === "string" ? input.rawBody : input.rawBody.toString("utf8");
1238
- const payload = import_meta_whatsapp_contracts8.whatsAppWebhookPayloadSchema.parse(JSON.parse(rawText));
2425
+ const payload = import_meta_whatsapp_contracts12.whatsAppWebhookPayloadSchema.parse(JSON.parse(rawText));
1239
2426
  let messagesProcessed = 0;
1240
2427
  let statusesProcessed = 0;
2428
+ let ignoredForeignNumber = 0;
1241
2429
  for (const entry of payload.entry) {
1242
2430
  for (const change of entry.changes) {
2431
+ const targetNumber = change.value.metadata?.phone_number_id;
2432
+ if (targetNumber && targetNumber !== this.params.phoneNumberId) {
2433
+ ignoredForeignNumber++;
2434
+ continue;
2435
+ }
1243
2436
  for (const message of change.value.messages ?? []) {
1244
2437
  await this.handleMessage(input.companyId, message);
1245
2438
  messagesProcessed++;
@@ -1253,7 +2446,8 @@ var ReceiveWebhookUseCase = class {
1253
2446
  return {
1254
2447
  duplicate: false,
1255
2448
  messagesProcessed,
1256
- statusesProcessed
2449
+ statusesProcessed,
2450
+ ignoredForeignNumber
1257
2451
  };
1258
2452
  }
1259
2453
  async handleMessage(companyId, message) {
@@ -1270,6 +2464,19 @@ var ReceiveWebhookUseCase = class {
1270
2464
  startState: this.params.startState
1271
2465
  });
1272
2466
  if (!saved) return;
2467
+ const media = extractMediaDescriptor(saved);
2468
+ if (media) {
2469
+ await this.params.hooks?.onMediaReceived?.({
2470
+ companyId,
2471
+ messageId: saved.id,
2472
+ whatsappNumber: message.from,
2473
+ sourceMediaId: media.sourceMediaId,
2474
+ mimeType: media.mimeType,
2475
+ ...media.filename ? {
2476
+ filename: media.filename
2477
+ } : {}
2478
+ });
2479
+ }
1273
2480
  const sessionRow = await this.params.sessionRepository.getContext(companyId, message.from);
1274
2481
  if (!sessionRow) return;
1275
2482
  if (sessionRow.mode === "human") return;
@@ -1289,76 +2496,50 @@ var ReceiveWebhookUseCase = class {
1289
2496
  }
1290
2497
  };
1291
2498
 
1292
- // src/channel/IngestInboundMedia.use-case.ts
1293
- var import_drizzle_orm6 = require("drizzle-orm");
1294
- var IngestInboundMediaUseCase = class {
2499
+ // src/use-cases/resolveTranscriptionPolicy.ts
2500
+ function createTranscriptionPolicyResolver(dependencies) {
2501
+ return /* @__PURE__ */ __name(async function resolveTranscriptionPolicy(companyId) {
2502
+ const settings2 = await dependencies.settingsRepository.get(companyId);
2503
+ return {
2504
+ // `??` e não `||`: `false` gravado é decisão explícita de desligar, e `||` a trocaria pelo
2505
+ // padrão do host — desligar no painel não faria nada num deploy com transcrição ligada.
2506
+ isEnabled: settings2.transcriptionEnabled ?? dependencies.defaults.isEnabled,
2507
+ mode: normalizeMode(settings2.transcriptionMode) ?? dependencies.defaults.mode
2508
+ };
2509
+ }, "resolveTranscriptionPolicy");
2510
+ }
2511
+ __name(createTranscriptionPolicyResolver, "createTranscriptionPolicyResolver");
2512
+ function normalizeMode(value) {
2513
+ if (value === TRANSCRIPTION_MODE.AUTO) return TRANSCRIPTION_MODE.AUTO;
2514
+ if (value === TRANSCRIPTION_MODE.ON_DEMAND) return TRANSCRIPTION_MODE.ON_DEMAND;
2515
+ return void 0;
2516
+ }
2517
+ __name(normalizeMode, "normalizeMode");
2518
+
2519
+ // src/use-cases/StorePreviewMedia.use-case.ts
2520
+ var StorePreviewMediaUseCase = class {
1295
2521
  static {
1296
- __name(this, "IngestInboundMediaUseCase");
2522
+ __name(this, "StorePreviewMediaUseCase");
1297
2523
  }
1298
- db;
1299
- channel;
1300
2524
  objectStorage;
1301
- constructor(db, channel, objectStorage) {
1302
- this.db = db;
1303
- this.channel = channel;
2525
+ generateKeySuffix;
2526
+ constructor(objectStorage, generateKeySuffix) {
1304
2527
  this.objectStorage = objectStorage;
2528
+ this.generateKeySuffix = generateKeySuffix;
1305
2529
  }
1306
2530
  async execute(params) {
1307
- 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);
1308
- if (!message) throw new Error(`Mensagem ${params.messageId} n\xE3o encontrada para ingest\xE3o de m\xEDdia`);
1309
- const payload = message.payload ?? {};
1310
- if (payload["uploadId"] && payload["sourceMediaId"] === params.sourceMediaId) {
1311
- return {
1312
- uploadId: String(payload["uploadId"]),
1313
- alreadyIngested: true
1314
- };
1315
- }
1316
- const { data, mimeType } = await this.channel.fetchMediaAsBase64(params.sourceMediaId);
1317
- const buffer = Buffer.from(data, "base64");
2531
+ const key = `meta-whatsapp/${params.companyId}/preview/${this.generateKeySuffix()}`;
1318
2532
  const { uploadId } = await this.objectStorage.upload({
1319
- buffer,
1320
- mimeType: mimeType || params.mimeType,
1321
- key: `meta-whatsapp/${params.companyId}/inbound/${params.sourceMediaId}`
2533
+ buffer: params.buffer,
2534
+ mimeType: params.mimeType,
2535
+ key
1322
2536
  });
1323
- const updatedPayload = {
1324
- ...payload,
1325
- uploadId,
1326
- sourceMediaId: params.sourceMediaId,
1327
- mimeType: mimeType || params.mimeType,
1328
- ...params.filename ? {
1329
- filename: params.filename
1330
- } : {}
1331
- };
1332
- await this.db.update(messages).set({
1333
- payload: updatedPayload
1334
- }).where((0, import_drizzle_orm6.and)((0, import_drizzle_orm6.eq)(messages.companyId, params.companyId), (0, import_drizzle_orm6.eq)(messages.id, params.messageId)));
1335
2537
  return {
1336
- uploadId,
1337
- alreadyIngested: false
2538
+ mediaId: (0, import_meta_whatsapp_contracts8.toPreviewMediaId)(uploadId),
2539
+ uploadId
1338
2540
  };
1339
2541
  }
1340
2542
  };
1341
- function extractMediaDescriptor(message) {
1342
- const payload = message.payload ?? {};
1343
- for (const key of [
1344
- "image",
1345
- "audio",
1346
- "video",
1347
- "document",
1348
- "sticker"
1349
- ]) {
1350
- const media = payload[key];
1351
- if (media?.id) {
1352
- return {
1353
- sourceMediaId: media.id,
1354
- mimeType: media.mime_type ?? "application/octet-stream",
1355
- filename: media.filename
1356
- };
1357
- }
1358
- }
1359
- return void 0;
1360
- }
1361
- __name(extractMediaDescriptor, "extractMediaDescriptor");
1362
2543
 
1363
2544
  // src/createMetaWhatsAppModule.ts
1364
2545
  function createMetaWhatsAppModule(params) {
@@ -1372,15 +2553,24 @@ function createMetaWhatsAppModule(params) {
1372
2553
  apiVersion: config.apiVersion,
1373
2554
  baseUrl: config.baseUrl
1374
2555
  });
1375
- const channel = new WhatsAppChannelAdapter(messageProvider);
2556
+ const previewMediaSupport = params.features?.previewMedia && providers.objectStorage?.getObject ? {
2557
+ isEnabled: true,
2558
+ objectStorage: providers.objectStorage
2559
+ } : void 0;
2560
+ const channel = new WhatsAppChannelAdapter(messageProvider, previewMediaSupport);
1376
2561
  const sessionRepository = new SessionRepository(db);
1377
2562
  const messageRepository = new MessageRepository(db);
1378
2563
  const settingsRepository = new SettingsRepository(db);
1379
- const flowGraphRepository = new FlowGraphRepository(db);
1380
- const logMessage = new LogMessageUseCase(sessionRepository, messageRepository, providers.realtime);
1381
- const sendMessage = new SendMessageUseCase(channel, sessionRepository, logMessage, providers.objectStorage);
2564
+ const flowGraphCacheFeature = params.features?.flowGraphCache ?? false;
2565
+ 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;
2566
+ const flowGraphRepository = new FlowGraphRepository(db, flowGraphCache);
2567
+ const documentRepository = new DocumentRepository(db);
2568
+ const flowMediaRepository = new FlowMediaRepository(db);
2569
+ const logMessage = new LogMessageUseCase(sessionRepository, messageRepository, providers.realtime, providers.moderator);
2570
+ const sendMessage = new SendMessageUseCase(channel, sessionRepository, logMessage, providers.objectStorage, documentRepository);
1382
2571
  const receiveWebhook = new ReceiveWebhookUseCase({
1383
2572
  appSecret: config.appSecret,
2573
+ phoneNumberId: config.phoneNumberId,
1384
2574
  nonceStore,
1385
2575
  sessionRepository,
1386
2576
  messageRepository,
@@ -1390,7 +2580,49 @@ function createMetaWhatsAppModule(params) {
1390
2580
  realtime: providers.realtime
1391
2581
  });
1392
2582
  const flowInterpreter = flowEngineEnabled ? new FlowInterpreter() : void 0;
1393
- const ingestInboundMedia = providers.objectStorage ? new IngestInboundMediaUseCase(db, channel, providers.objectStorage) : void 0;
2583
+ if (flowInterpreter && providers.objectStorage?.getObject) {
2584
+ flowInterpreter.registerFlowAction(import_meta_whatsapp_contracts13.FLOW_ACTION_KIND.SEND_MEDIA, createSendMediaAction({
2585
+ flowMediaRepository,
2586
+ objectStorage: providers.objectStorage,
2587
+ logMessage,
2588
+ startState,
2589
+ onError: hooks?.onFlowMediaError
2590
+ }));
2591
+ }
2592
+ const transcriptionMode = providers.transcription?.mode ?? TRANSCRIPTION_MODE.ON_DEMAND;
2593
+ const resolveTranscriptionPolicy = providers.transcription ? createTranscriptionPolicyResolver({
2594
+ settingsRepository,
2595
+ defaults: {
2596
+ isEnabled: providers.transcription.isEnabledByDefault ?? true,
2597
+ mode: transcriptionMode
2598
+ }
2599
+ }) : void 0;
2600
+ const ingestInboundMedia = providers.objectStorage ? new IngestInboundMediaUseCase(db, channel, providers.objectStorage, documentRepository, providers.transcription && resolveTranscriptionPolicy ? {
2601
+ transcriber: providers.transcription.transcriber,
2602
+ resolvePolicy: resolveTranscriptionPolicy,
2603
+ messageRepository,
2604
+ ...providers.transcription.languageHint ? {
2605
+ languageHint: providers.transcription.languageHint
2606
+ } : {},
2607
+ ...hooks ? {
2608
+ hooks
2609
+ } : {}
2610
+ } : void 0) : void 0;
2611
+ const transcribeAudio = providers.transcription && providers.objectStorage?.getObject ? new TranscribeAudioUseCase({
2612
+ messageRepository,
2613
+ objectStorage: providers.objectStorage,
2614
+ transcriber: providers.transcription.transcriber,
2615
+ ...resolveTranscriptionPolicy ? {
2616
+ resolvePolicy: resolveTranscriptionPolicy
2617
+ } : {},
2618
+ ...providers.transcription.languageHint ? {
2619
+ languageHint: providers.transcription.languageHint
2620
+ } : {},
2621
+ ...hooks ? {
2622
+ hooks
2623
+ } : {}
2624
+ }) : void 0;
2625
+ const listDocuments = new ListConversationDocumentsUseCase(sessionRepository, documentRepository);
1394
2626
  return {
1395
2627
  channel,
1396
2628
  // undefined quando providers.objectStorage não foi injetado.
@@ -1402,10 +2634,36 @@ function createMetaWhatsAppModule(params) {
1402
2634
  release: new ReleaseConversationUseCase(sessionRepository, providers.realtime),
1403
2635
  list: new ListConversationsUseCase(sessionRepository),
1404
2636
  listMessages: new ListMessagesUseCase(sessionRepository, messageRepository),
2637
+ listDocuments,
2638
+ // Biblioteca da empresa inteira, para uma tela de Documentos fora da conversa.
2639
+ listCompanyDocuments: new ListCompanyDocumentsUseCase(documentRepository),
2640
+ // Apaga a mídia no storage antes das linhas — a cascata da FK sozinha deixaria os binários
2641
+ // órfãos, já que a lista de uploadId vive justamente nas linhas que ela derruba.
2642
+ delete: new DeleteConversationUseCase(sessionRepository, documentRepository, providers.objectStorage),
2643
+ purgeExpiredDocuments: new PurgeExpiredDocumentsUseCase(documentRepository, providers.objectStorage),
1405
2644
  export: new ExportConversationUseCase(sessionRepository),
1406
- repository: sessionRepository
2645
+ // undefined quando transcrição não foi injetada, ou quando o storage não sabe ler de volta.
2646
+ // O painel consulta a ausência para decidir se desenha o botão "transcrever".
2647
+ transcribeAudio,
2648
+ repository: sessionRepository,
2649
+ messageRepository,
2650
+ documentRepository
1407
2651
  },
2652
+ /**
2653
+ * `undefined` = o host não injetou transcritor, e nenhuma configuração de empresa muda isso: a
2654
+ * capacidade não existe. Presente, `resolvePolicy` responde o que vale para uma empresa —
2655
+ * é o que a rota de configurações usa para dizer ao painel se desenha o interruptor.
2656
+ */
2657
+ transcription: providers.transcription && resolveTranscriptionPolicy ? {
2658
+ defaultMode: transcriptionMode,
2659
+ resolvePolicy: resolveTranscriptionPolicy
2660
+ } : void 0,
1408
2661
  settings: settingsRepository,
2662
+ /**
2663
+ * `undefined` quando o recurso não está ligado (ou falta storage legível). O host consulta a
2664
+ * ausência para não registrar a rota de upload — e o preview, sem a rota, esconde o microfone.
2665
+ */
2666
+ previewMedia: previewMediaSupport ? new StorePreviewMediaUseCase(providers.objectStorage, () => `${Date.now()}-${Math.random().toString(36).slice(2)}`) : void 0,
1409
2667
  webhook: {
1410
2668
  receive: receiveWebhook,
1411
2669
  // GET de verificação da Meta — o host liga na sua rota e devolve o retorno como texto puro.
@@ -1424,7 +2682,11 @@ function createMetaWhatsAppModule(params) {
1424
2682
  save: new SaveFlowGraphUseCase(flowGraphRepository),
1425
2683
  delete: new DeleteFlowGraphUseCase(flowGraphRepository),
1426
2684
  livePositions: new GetLiveFlowPositionsUseCase(flowGraphRepository),
1427
- repository: flowGraphRepository
2685
+ repository: flowGraphRepository,
2686
+ // Biblioteca de mídia dos nós `send_media` — o host liga nas rotas do editor
2687
+ // (anexar/reordenar/desligar). Existe mesmo sem storage injetado: gerenciar anexos é
2688
+ // consultar a tabela, e só o ENVIO precisa dos bytes.
2689
+ mediaRepository: flowMediaRepository
1428
2690
  } : void 0,
1429
2691
  catalog: providers.catalog
1430
2692
  };
@@ -1433,11 +2695,15 @@ __name(createMetaWhatsAppModule, "createMetaWhatsAppModule");
1433
2695
 
1434
2696
  // src/runMigrations.ts
1435
2697
  var import_node_path = require("path");
1436
- var import_migrator = require("drizzle-orm/bun-sql/migrator");
1437
- async function runMetaWhatsAppMigrations(db) {
1438
- await (0, import_migrator.migrate)(db, {
1439
- migrationsFolder: (0, import_node_path.join)(__dirname, "migrations"),
1440
- migrationsTable: "meta_whatsapp_migrations"
2698
+ var META_WHATSAPP_MIGRATIONS_TABLE = "meta_whatsapp_migrations";
2699
+ function metaWhatsAppMigrationsFolder() {
2700
+ return (0, import_node_path.join)(__dirname, "migrations");
2701
+ }
2702
+ __name(metaWhatsAppMigrationsFolder, "metaWhatsAppMigrationsFolder");
2703
+ async function runMetaWhatsAppMigrations(params) {
2704
+ await params.migrate(params.db, {
2705
+ migrationsFolder: metaWhatsAppMigrationsFolder(),
2706
+ migrationsTable: META_WHATSAPP_MIGRATIONS_TABLE
1441
2707
  });
1442
2708
  }
1443
2709
  __name(runMetaWhatsAppMigrations, "runMetaWhatsAppMigrations");
@@ -1520,20 +2786,30 @@ __name(redeemSseTicket, "redeemSseTicket");
1520
2786
  // Annotate the CommonJS export names for ESM import in node:
1521
2787
  0 && (module.exports = {
1522
2788
  CreateFlowGraphUseCase,
2789
+ DEFAULT_FLOW_GRAPH_CACHE_TTL_SECONDS,
2790
+ DeleteConversationUseCase,
1523
2791
  DeleteFlowGraphUseCase,
2792
+ DocumentRepository,
1524
2793
  ExportConversationUseCase,
2794
+ FlowGraphCache,
1525
2795
  FlowGraphRepository,
1526
2796
  FlowInterpreter,
2797
+ FlowMediaRepository,
1527
2798
  GetFlowGraphUseCase,
1528
2799
  GetLiveFlowPositionsUseCase,
1529
2800
  IngestInboundMediaUseCase,
1530
2801
  InvalidFlowGraphError,
2802
+ ListCompanyDocumentsUseCase,
2803
+ ListConversationDocumentsUseCase,
1531
2804
  ListConversationsUseCase,
1532
2805
  ListFlowGraphsUseCase,
1533
2806
  ListMessagesUseCase,
1534
2807
  LogMessageUseCase,
2808
+ META_WHATSAPP_MIGRATIONS_TABLE,
1535
2809
  MessageRepository,
1536
2810
  OptimisticLockError,
2811
+ PREVIEW_MEDIA_ID_PREFIX,
2812
+ PurgeExpiredDocumentsUseCase,
1537
2813
  ReceiveWebhookUseCase,
1538
2814
  ReleaseConversationUseCase,
1539
2815
  SaveFlowGraphUseCase,
@@ -1541,20 +2817,36 @@ __name(redeemSseTicket, "redeemSseTicket");
1541
2817
  SessionRepository,
1542
2818
  SettingsRepository,
1543
2819
  SseHub,
2820
+ StorePreviewMediaUseCase,
2821
+ TRANSCRIPTION_MODE,
2822
+ TRANSCRIPTION_STATUS,
1544
2823
  TakeoverConversationUseCase,
2824
+ TranscribeAudioUseCase,
1545
2825
  WEBHOOK_NONCE_TTL_SECONDS,
1546
2826
  WhatsAppChannelAdapter,
1547
2827
  claimWebhookDelivery,
1548
2828
  createMetaWhatsAppModule,
2829
+ createSendMediaAction,
2830
+ createTranscriptionPolicyResolver,
2831
+ documents,
1549
2832
  extractMediaDescriptor,
1550
2833
  flowGraphs,
2834
+ flowMedia,
2835
+ isAudioMimeType,
2836
+ isRetriableTranscriptionError,
2837
+ isUnsupportedTranscriptionError,
1551
2838
  issueSseTicket,
1552
2839
  messages,
2840
+ metaWhatsAppMigrationsFolder,
1553
2841
  metaWhatsAppSchema,
1554
2842
  redeemSseTicket,
2843
+ resolveFailureStatus,
2844
+ resolvePreviewUploadId,
1555
2845
  runMetaWhatsAppMigrations,
1556
2846
  sessions,
1557
2847
  settings,
2848
+ toPreviewMediaId,
2849
+ transcriptionRetryAfterSeconds,
1558
2850
  verifyWebhookChallenge,
1559
2851
  verifyWebhookSignature
1560
2852
  });