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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  var __defProp = Object.defineProperty;
2
2
  var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
3
3
 
4
- // ../../../node_modules/.pnpm/tsup@8.5.1_@swc+core@1.15.40_@swc+helpers@0.5.23__jiti@2.7.0_postcss@8.5.15_tsx@4.23.1_typescript@5.9.3_yaml@2.9.0/node_modules/tsup/assets/esm_shims.js
4
+ // ../../../node_modules/.pnpm/tsup@8.5.1_@swc+core@1.15.40_@swc+helpers@0.5.23__jiti@2.7.0_postcss@8.5.26_tsx@4.23.1_typescript@5.9.3_yaml@2.9.0/node_modules/tsup/assets/esm_shims.js
5
5
  import path from "path";
6
6
  import { fileURLToPath } from "url";
7
7
  var getFilename = /* @__PURE__ */ __name(() => fileURLToPath(import.meta.url), "getFilename");
@@ -10,6 +10,7 @@ var __dirname = /* @__PURE__ */ getDirname();
10
10
 
11
11
  // src/createMetaWhatsAppModule.ts
12
12
  import { WhatsAppMessageProvider } from "@adatechnology/meta-whatsapp-provider";
13
+ import { FLOW_ACTION_KIND } from "@adatechnology/meta-whatsapp-contracts";
13
14
 
14
15
  // src/repositories/SessionRepository.ts
15
16
  import { and, desc, eq, sql as sql2 } from "drizzle-orm";
@@ -85,9 +86,14 @@ var messages = metaWhatsAppSchema.table("messages", {
85
86
  length: 12
86
87
  }).notNull(),
87
88
  agentUserId: uuid("agent_user_id"),
88
- type: varchar("type", {
89
- length: 16
90
- }).notNull().default("text"),
89
+ // 32, não 16: os tipos da própria Meta são curtos ('text', 'interactive'), mas o host rotula
90
+ type: (
91
+ // a saída com o subtipo que enviou ('interactive_buttons' já tem 19). Apertar aqui só
92
+ // transfere para o consumidor a escolha entre truncar o rótulo e estourar o insert.
93
+ varchar("type", {
94
+ length: 32
95
+ }).notNull().default("text")
96
+ ),
91
97
  content: text("content"),
92
98
  // T5.4 — nunca base64 aqui; mídia vive no ObjectStorageInterface do host, referenciada por
93
99
  // uploadId dentro deste jsonb.
@@ -101,6 +107,33 @@ var messages = metaWhatsAppSchema.table("messages", {
101
107
  readAt: timestamp("read_at", {
102
108
  withTimezone: true
103
109
  }),
110
+ // Moderação de conteúdo. `null` significa NÃO AVALIADO (moderação desligada, ou mensagem
111
+ // anterior ao recurso) — diferente de `false`, que é avaliado e limpo. Colunas em vez de chave
112
+ // dentro de `payload` porque "listar o que foi sinalizado" é consulta de operação, e índice
113
+ // parcial sobre boolean resolve isso sem cavar jsonb.
114
+ moderationFlagged: boolean("moderation_flagged"),
115
+ moderationTerms: jsonb("moderation_terms").$type(),
116
+ /**
117
+ * Transcrição de áudio. `null` em `transcription_status` significa NÃO AVALIADO — áudio nunca
118
+ * pedido (modo sob demanda), transcrição desligada, mensagem anterior ao recurso, ou mensagem
119
+ * que não é áudio. Diferente de `'done'` com texto vazio, que é áudio em silêncio já processado
120
+ * e que NÃO deve ser reprocessado.
121
+ *
122
+ * Colunas em vez de chave em `payload` pelo mesmo motivo da moderação: "quais áudios ficaram
123
+ * pendentes" e "quais falharam" são consultas de operação, e índice parcial resolve sem cavar
124
+ * jsonb. Buscar texto de áudio também deixa de exigir varredura de payload.
125
+ */
126
+ transcriptionStatus: varchar("transcription_status", {
127
+ length: 16
128
+ }).$type(),
129
+ transcriptionText: text("transcription_text"),
130
+ transcriptionLanguage: varchar("transcription_language", {
131
+ length: 32
132
+ }),
133
+ /** Qual engine produziu. Com uma cadeia de engines, é o que responde "por que esta saiu ruim". */
134
+ transcriptionEngine: varchar("transcription_engine", {
135
+ length: 32
136
+ }),
104
137
  createdAt: timestamp("created_at", {
105
138
  withTimezone: true
106
139
  }).notNull().defaultNow()
@@ -112,7 +145,54 @@ var messages = metaWhatsAppSchema.table("messages", {
112
145
  // paralelo, então as duas passariam pela checagem e inseririam duplicado. Parcial porque
113
146
  // mensagens outbound ainda sem waMessageId (envio em curso) são legitimamente NULL, e NULLs
114
147
  // não podem competir entre si por unicidade.
115
- uniqueIndex("idx_messages_company_wa_message_id").on(table.companyId, table.waMessageId).where(sql`${table.waMessageId} is not null`)
148
+ uniqueIndex("idx_messages_company_wa_message_id").on(table.companyId, table.waMessageId).where(sql`${table.waMessageId} is not null`),
149
+ // Parcial: só as sinalizadas entram, então o índice fica do tamanho do problema e não do
150
+ // tamanho do transcript.
151
+ index("idx_messages_moderation_flagged").on(table.companyId, table.createdAt).where(sql`${table.moderationFlagged}`),
152
+ // Alimenta a varredura de retomada: "quais áudios ficaram pendentes de transcrição". Parcial
153
+ // porque pendente é estado transitório e raro — o índice fica do tamanho da fila atrasada, não
154
+ // do transcript inteiro.
155
+ index("idx_messages_transcription_pending").on(table.companyId, table.createdAt).where(sql`${table.transcriptionStatus} = 'pending'`)
156
+ ]);
157
+ var documents = metaWhatsAppSchema.table("documents", {
158
+ id: uuid("id").primaryKey().defaultRandom(),
159
+ companyId: uuid("company_id").notNull(),
160
+ sessionId: uuid("session_id").notNull().references(() => sessions.id, {
161
+ onDelete: "cascade"
162
+ }),
163
+ // Anulável e `set null`: apagar a mensagem não deve apagar o arquivo da biblioteca, e arquivo
164
+ // do atendente nasce sem mensagem.
165
+ messageId: uuid("message_id").references(() => messages.id, {
166
+ onDelete: "set null"
167
+ }),
168
+ uploadId: varchar("upload_id", {
169
+ length: 256
170
+ }).notNull(),
171
+ filename: varchar("filename", {
172
+ length: 512
173
+ }).notNull(),
174
+ mimeType: varchar("mime_type", {
175
+ length: 128
176
+ }).notNull(),
177
+ sizeBytes: integer("size_bytes").notNull(),
178
+ // Do provider de storage, que é endereçado por conteúdo — serve para reconciliar storage
179
+ // contra tabela e para detectar o mesmo binário chegando duas vezes.
180
+ sha256: varchar("sha256", {
181
+ length: 64
182
+ }),
183
+ source: varchar("source", {
184
+ length: 12
185
+ }).notNull(),
186
+ linkedAt: timestamp("linked_at", {
187
+ withTimezone: true
188
+ }).notNull().defaultNow()
189
+ }, (table) => [
190
+ index("idx_documents_session_linked").on(table.sessionId, table.linkedAt),
191
+ // Para varredura de retenção por idade sem passar por sessão.
192
+ index("idx_documents_company_linked").on(table.companyId, table.linkedAt),
193
+ // O mesmo objeto não pode ser linkado duas vezes na mesma empresa: o job de ingestão é
194
+ // reentregue por retry, e sem isto a reentrega criaria linha duplicada no painel.
195
+ uniqueIndex("idx_documents_company_upload").on(table.companyId, table.uploadId)
116
196
  ]);
117
197
  var flowGraphs = metaWhatsAppSchema.table("flow_graphs", {
118
198
  id: uuid("id").primaryKey().defaultRandom(),
@@ -143,6 +223,46 @@ var flowGraphs = metaWhatsAppSchema.table("flow_graphs", {
143
223
  }, (table) => [
144
224
  uniqueIndex("idx_flow_graphs_company_key").on(table.companyId, table.key)
145
225
  ]);
226
+ var flowMedia = metaWhatsAppSchema.table("flow_media", {
227
+ id: uuid("id").primaryKey().defaultRandom(),
228
+ companyId: uuid("company_id").notNull(),
229
+ flowKey: varchar("flow_key", {
230
+ length: 64
231
+ }).notNull(),
232
+ nodeId: varchar("node_id", {
233
+ length: 64
234
+ }).notNull(),
235
+ uploadId: varchar("upload_id", {
236
+ length: 256
237
+ }).notNull(),
238
+ filename: varchar("filename", {
239
+ length: 512
240
+ }).notNull(),
241
+ mimeType: varchar("mime_type", {
242
+ length: 128
243
+ }).notNull(),
244
+ sizeBytes: integer("size_bytes").notNull(),
245
+ // Legenda da mídia no WhatsApp. Por arquivo, não por nó: um nó que manda tabela de preços e
246
+ // um folder precisa de textos diferentes para cada um.
247
+ caption: text("caption"),
248
+ // Ordem de envio dentro do nó — o cliente recebe as mensagens em sequência, e "tabela antes
249
+ // do folder" é decisão de quem edita, não do banco.
250
+ sortOrder: integer("sort_order").notNull().default(0),
251
+ // Desligar sem desanexar: trocar o material da campanha é o caso comum, e apagar a linha
252
+ // perderia a ordem e a legenda já ajustadas.
253
+ active: boolean("active").notNull().default(true),
254
+ createdAt: timestamp("created_at", {
255
+ withTimezone: true
256
+ }).notNull().defaultNow(),
257
+ updatedAt: timestamp("updated_at", {
258
+ withTimezone: true
259
+ }).notNull().defaultNow()
260
+ }, (table) => [
261
+ index("idx_flow_media_node").on(table.companyId, table.flowKey, table.nodeId, table.sortOrder),
262
+ // O mesmo arquivo anexado duas vezes ao MESMO nó é erro de clique no editor, e o cliente
263
+ // receberia o documento repetido.
264
+ uniqueIndex("idx_flow_media_node_upload").on(table.companyId, table.flowKey, table.nodeId, table.uploadId)
265
+ ]);
146
266
  var settings = metaWhatsAppSchema.table("settings", {
147
267
  companyId: uuid("company_id").primaryKey(),
148
268
  templateName: varchar("template_name", {
@@ -155,6 +275,15 @@ var settings = metaWhatsAppSchema.table("settings", {
155
275
  templateVariables: jsonb("template_variables").$type().notNull().default([]),
156
276
  welcomeMessage: text("welcome_message"),
157
277
  farewellMessage: text("farewell_message"),
278
+ /**
279
+ * Política de transcrição desta empresa. Nulo é significativo: "o painel não decidiu", e aí vale
280
+ * o padrão que o host injetou. Sem a distinção, atualizar o módulo desligaria a transcrição de
281
+ * quem já a tinha ligada por ambiente.
282
+ */
283
+ transcriptionEnabled: boolean("transcription_enabled"),
284
+ transcriptionMode: varchar("transcription_mode", {
285
+ length: 16
286
+ }).$type(),
158
287
  createdAt: timestamp("created_at", {
159
288
  withTimezone: true
160
289
  }).notNull().defaultNow(),
@@ -165,6 +294,31 @@ var settings = metaWhatsAppSchema.table("settings", {
165
294
 
166
295
  // src/repositories/SessionRepository.ts
167
296
  var DEFAULT_LIMIT = 20;
297
+ var conversationSummaryProjection = {
298
+ lastContent: sql2`(
299
+ select m.content from ${messages} m
300
+ where m.company_id = ${sessions}.company_id and m.session_id = ${sessions}.id
301
+ order by m.created_at desc limit 1
302
+ )`,
303
+ lastDirection: sql2`(
304
+ select m.direction from ${messages} m
305
+ where m.company_id = ${sessions}.company_id and m.session_id = ${sessions}.id
306
+ order by m.created_at desc limit 1
307
+ )`,
308
+ // Entradas do cliente depois da última leitura do atendente. Sessão nunca lida conta
309
+ // tudo — é o comportamento esperado de uma conversa que ninguém abriu ainda.
310
+ unread: sql2`(
311
+ select count(*)::int from ${messages} m
312
+ where m.company_id = ${sessions}.company_id
313
+ and m.session_id = ${sessions}.id
314
+ and m.direction = 'inbound'
315
+ and (${sessions}.last_agent_read_at is null or m.created_at > ${sessions}.last_agent_read_at)
316
+ )`
317
+ };
318
+ function sessionContextPatch(patch) {
319
+ return sql2`${sessions.context} || ${JSON.stringify(patch)}::jsonb`;
320
+ }
321
+ __name(sessionContextPatch, "sessionContextPatch");
168
322
  var SessionRepository = class {
169
323
  static {
170
324
  __name(this, "SessionRepository");
@@ -195,6 +349,9 @@ var SessionRepository = class {
195
349
  }).returning();
196
350
  return created;
197
351
  }
352
+ // O `context` jsonb é o ponto de extensão oficial para estado de sessão por produto: o módulo
353
+ // não conhece a forma, o consumidor a declara em TSessionContext. Este setter SUBSTITUI o
354
+ // objeto inteiro — para acumular respostas ao longo da conversa use patchContext.
198
355
  async setState(companyId, whatsappNumber, state, context) {
199
356
  await this.db.update(sessions).set({
200
357
  currentState: state,
@@ -205,6 +362,25 @@ var SessionRepository = class {
205
362
  updatedAt: sql2`now()`
206
363
  }).where(and(eq(sessions.companyId, companyId), eq(sessions.whatsappNumber, whatsappNumber)));
207
364
  }
365
+ // Mescla parcial do context, feita no banco (`||`) e não por read-modify-write no host: duas
366
+ // mensagens do mesmo cliente processadas em paralelo sobrescreveriam uma à outra, e o campo
367
+ // acumula justamente as respostas coletadas ao longo da conversa. Chave presente no patch
368
+ // vence a existente; as demais permanecem.
369
+ async patchContext(companyId, whatsappNumber, patch) {
370
+ await this.db.update(sessions).set({
371
+ context: sessionContextPatch(patch),
372
+ lastActivity: sql2`now()`,
373
+ updatedAt: sql2`now()`
374
+ }).where(and(eq(sessions.companyId, companyId), eq(sessions.whatsappNumber, whatsappNumber)));
375
+ }
376
+ // Leitura tipada do estado de sessão do produto. Devolve undefined quando a sessão não existe —
377
+ // distinto de existir com context vazio, que devolve o objeto vazio.
378
+ async readContext(companyId, whatsappNumber) {
379
+ const [row] = await this.db.select({
380
+ context: sessions.context
381
+ }).from(sessions).where(and(eq(sessions.companyId, companyId), eq(sessions.whatsappNumber, whatsappNumber))).limit(1);
382
+ return row?.context;
383
+ }
208
384
  // Posição no grafo de fluxo — chamado pelo host a cada transição do FlowInterpreter.
209
385
  // Passar null em ambos desliga o rastreio (ex.: conversa saiu do motor de fluxo).
210
386
  async setFlowPosition(companyId, whatsappNumber, flowKey, currentNodeId) {
@@ -247,6 +423,16 @@ var SessionRepository = class {
247
423
  async release(companyId, whatsappNumber) {
248
424
  await this.setMode(companyId, whatsappNumber, "bot", null);
249
425
  }
426
+ /**
427
+ * Apaga a sessão; a cascata das FKs leva mensagens e documentos.
428
+ *
429
+ * Não apaga o binário no storage — isso é passo de aplicação, e é por isso que este método é
430
+ * chamado por `DeleteConversationUseCase` e não diretamente pelo host. Chamar daqui sem apagar os
431
+ * objetos antes deixa mídia órfã sendo cobrada para sempre.
432
+ */
433
+ async deleteByNumber(companyId, whatsappNumber) {
434
+ await this.db.delete(sessions).where(and(eq(sessions.companyId, companyId), eq(sessions.whatsappNumber, whatsappNumber)));
435
+ }
250
436
  async requestHuman(companyId, whatsappNumber) {
251
437
  await this.db.update(sessions).set({
252
438
  humanRequestedAt: sql2`now()`,
@@ -280,17 +466,28 @@ var SessionRepository = class {
280
466
  currentState: sessions.currentState,
281
467
  lastActivity: sessions.lastActivity,
282
468
  lastInboundAt: sessions.lastInboundAt,
283
- humanRequestedAt: sessions.humanRequestedAt
469
+ humanRequestedAt: sessions.humanRequestedAt,
470
+ // Prévia e contagem saem de subquery correlacionada em vez de N+1 na volta: uma inbox
471
+ // lista dezenas de conversas por página, e uma query por linha é o gargalo clássico
472
+ // dessa tela. Ambos os campos são dados do próprio módulo — deixá-los para o host
473
+ // obrigaria todo consumidor a reescrever o mesmo join contra tabelas que não são dele.
474
+ ...conversationSummaryProjection
284
475
  }).from(sessions).where(and(...conditions)).orderBy(desc(sessions.lastActivity)).limit(limit).offset(offset);
285
476
  return rows.map((row) => ({
286
477
  id: row.id,
287
478
  whatsappNumber: row.whatsappNumber,
479
+ ...row.lastContent !== null ? {
480
+ lastContent: row.lastContent
481
+ } : {},
482
+ ...row.lastDirection !== null ? {
483
+ lastDirection: row.lastDirection
484
+ } : {},
288
485
  lastAt: row.lastActivity.toISOString(),
289
486
  lastInboundAt: row.lastInboundAt?.toISOString() ?? null,
290
487
  mode: row.mode,
291
488
  assignedUserId: row.assignedUserId,
292
489
  waitingHuman: row.humanRequestedAt !== null,
293
- unread: 0,
490
+ unread: Number(row.unread),
294
491
  currentState: row.currentState
295
492
  }));
296
493
  }
@@ -334,7 +531,9 @@ var MessageRepository = class {
334
531
  content: params.content ?? null,
335
532
  payload: params.payload ?? null,
336
533
  waMessageId: params.waMessageId ?? null,
337
- status: params.status ?? null
534
+ status: params.status ?? null,
535
+ moderationFlagged: params.moderationFlagged ?? null,
536
+ moderationTerms: params.moderationTerms ?? null
338
537
  };
339
538
  const [created] = await this.db.insert(messages).values(values).onConflictDoNothing().returning();
340
539
  return created;
@@ -348,6 +547,58 @@ var MessageRepository = class {
348
547
  }).where(and2(eq2(messages.companyId, companyId), eq2(messages.waMessageId, waMessageId))).returning();
349
548
  return updated;
350
549
  }
550
+ /**
551
+ * Grava a transcrição endereçando pelo id da Meta, para quem só tem esse.
552
+ *
553
+ * Serve ao caso em que a transcrição acontece no próprio webhook — o grafo precisa do texto para
554
+ * responder ao cliente, e jogar fora o que ele já pagou para transcrever significaria transcrever
555
+ * o mesmo áudio uma segunda vez só para o painel ver.
556
+ *
557
+ * Devolve `undefined` quando não achou a mensagem: entrega duplicada e mensagem apagada são
558
+ * corridas normais, não erro.
559
+ */
560
+ async saveTranscriptionByWaMessageId(params) {
561
+ const [updated] = await this.db.update(messages).set({
562
+ transcriptionStatus: params.status,
563
+ ...params.text !== void 0 ? {
564
+ transcriptionText: params.text
565
+ } : {},
566
+ ...params.language !== void 0 ? {
567
+ transcriptionLanguage: params.language
568
+ } : {},
569
+ ...params.engine !== void 0 ? {
570
+ transcriptionEngine: params.engine
571
+ } : {}
572
+ }).where(and2(eq2(messages.companyId, params.companyId), eq2(messages.waMessageId, params.waMessageId))).returning();
573
+ return updated;
574
+ }
575
+ async findById(companyId, messageId) {
576
+ const [found] = await this.db.select().from(messages).where(and2(eq2(messages.companyId, companyId), eq2(messages.id, messageId))).limit(1);
577
+ return found;
578
+ }
579
+ /**
580
+ * Grava o resultado da transcrição. Devolve `undefined` quando a mensagem não existe (apagada
581
+ * entre o enfileiramento e a execução do job) — não é erro, é corrida normal.
582
+ *
583
+ * `text`/`language`/`engine` só são tocados quando informados: uma retentativa que volta a falhar
584
+ * atualiza o status sem apagar a transcrição parcial de uma tentativa anterior que tenha vindo de
585
+ * outro engine da cadeia.
586
+ */
587
+ async saveTranscription(params) {
588
+ const [updated] = await this.db.update(messages).set({
589
+ transcriptionStatus: params.status,
590
+ ...params.text !== void 0 ? {
591
+ transcriptionText: params.text
592
+ } : {},
593
+ ...params.language !== void 0 ? {
594
+ transcriptionLanguage: params.language
595
+ } : {},
596
+ ...params.engine !== void 0 ? {
597
+ transcriptionEngine: params.engine
598
+ } : {}
599
+ }).where(and2(eq2(messages.companyId, params.companyId), eq2(messages.id, params.messageId))).returning();
600
+ return updated;
601
+ }
351
602
  async listByConversation(params) {
352
603
  const conditions = [
353
604
  eq2(messages.companyId, params.companyId),
@@ -397,12 +648,21 @@ var FlowGraphRepository = class {
397
648
  __name(this, "FlowGraphRepository");
398
649
  }
399
650
  db;
400
- constructor(db) {
651
+ cache;
652
+ // Cache opcional: sem ele o repositório se comporta exatamente como antes, lendo sempre do
653
+ // banco. É o host que decide se quer cachear e com qual provedor (ver CacheInterface).
654
+ constructor(db, cache) {
401
655
  this.db = db;
656
+ this.cache = cache;
402
657
  }
403
658
  async get(companyId, key) {
659
+ const cached = await this.cache?.read(companyId, key);
660
+ if (cached) return cached;
404
661
  const [row] = await this.db.select().from(flowGraphs).where(and3(eq3(flowGraphs.companyId, companyId), eq3(flowGraphs.key, key))).limit(1);
405
- return row ? toContractGraph(row) : void 0;
662
+ if (!row) return void 0;
663
+ const graph = toContractGraph(row);
664
+ await this.cache?.write(companyId, graph);
665
+ return graph;
406
666
  }
407
667
  async list(companyId) {
408
668
  const rows = await this.db.select().from(flowGraphs).where(eq3(flowGraphs.companyId, companyId));
@@ -432,7 +692,9 @@ var FlowGraphRepository = class {
432
692
  showInMenu: graph.showInMenu ?? false,
433
693
  menuOptionLabel: graph.menuOptionLabel
434
694
  }).returning();
435
- return toContractGraph(created);
695
+ const createdGraph = toContractGraph(created);
696
+ await this.cache?.invalidate(companyId, createdGraph.key);
697
+ return createdGraph;
436
698
  }
437
699
  // Lock otimista: a escrita só aplica se `expectedVersion` ainda bater com o que está salvo —
438
700
  // senão, alguém mais salvou entretanto e o editor precisa recarregar (ver comentário no schema).
@@ -446,10 +708,12 @@ var FlowGraphRepository = class {
446
708
  updatedAt: /* @__PURE__ */ new Date()
447
709
  }).where(and3(eq3(flowGraphs.companyId, companyId), eq3(flowGraphs.key, graph.key), eq3(flowGraphs.version, expectedVersion))).returning();
448
710
  if (rows.length === 0) throw new OptimisticLockError(graph.key);
711
+ await this.cache?.invalidate(companyId, graph.key);
449
712
  return toContractGraph(rows[0]);
450
713
  }
451
714
  async delete(companyId, key) {
452
715
  await this.db.delete(flowGraphs).where(and3(eq3(flowGraphs.companyId, companyId), eq3(flowGraphs.key, key)));
716
+ await this.cache?.invalidate(companyId, key);
453
717
  }
454
718
  // T4.2 — GetLiveFlowPositions: agrega sessões ativas por (flowKey, currentNodeId), lendo as
455
719
  // colunas dedicadas gravadas por SessionRepository.setFlowPosition. Agrega no banco (GROUP BY,
@@ -469,6 +733,48 @@ var FlowGraphRepository = class {
469
733
  }
470
734
  };
471
735
 
736
+ // src/repositories/FlowGraphCache.ts
737
+ var KEY_PREFIX = "meta-whatsapp:flow-graph";
738
+ var DEFAULT_FLOW_GRAPH_CACHE_TTL_SECONDS = 300;
739
+ var FlowGraphCache = class {
740
+ static {
741
+ __name(this, "FlowGraphCache");
742
+ }
743
+ provider;
744
+ ttlSeconds;
745
+ constructor(provider, ttlSeconds = DEFAULT_FLOW_GRAPH_CACHE_TTL_SECONDS) {
746
+ this.provider = provider;
747
+ this.ttlSeconds = ttlSeconds;
748
+ }
749
+ // companyId na chave, e não só a flowKey: a chave do fluxo é escolhida por quem edita e se
750
+ // repete entre empresas — 'consorcio' existe em todas — então uma chave sem tenant serviria o
751
+ // grafo de uma empresa para a conversa de outra.
752
+ keyFor(companyId, flowKey) {
753
+ return `${KEY_PREFIX}:${companyId}:${flowKey}`;
754
+ }
755
+ async read(companyId, flowKey) {
756
+ try {
757
+ const cached = await this.provider.get(this.keyFor(companyId, flowKey));
758
+ if (!cached) return void 0;
759
+ return JSON.parse(cached);
760
+ } catch {
761
+ return void 0;
762
+ }
763
+ }
764
+ async write(companyId, graph) {
765
+ try {
766
+ await this.provider.set(this.keyFor(companyId, graph.key), JSON.stringify(graph), this.ttlSeconds);
767
+ } catch {
768
+ }
769
+ }
770
+ async invalidate(companyId, flowKey) {
771
+ try {
772
+ await this.provider.delete(this.keyFor(companyId, flowKey));
773
+ } catch {
774
+ }
775
+ }
776
+ };
777
+
472
778
  // src/repositories/SettingsRepository.ts
473
779
  import { eq as eq4 } from "drizzle-orm";
474
780
  function toContractSettings(row) {
@@ -477,7 +783,11 @@ function toContractSettings(row) {
477
783
  templateLanguage: row.templateLanguage,
478
784
  templateVariables: row.templateVariables,
479
785
  welcomeMessage: row.welcomeMessage ?? "",
480
- farewellMessage: row.farewellMessage ?? ""
786
+ farewellMessage: row.farewellMessage ?? "",
787
+ // `?? null` e não `?? false`: nulo é "o painel não decidiu", e é o que faz valer o padrão do
788
+ // host. Colapsar para `false` desligaria quem ligou por ambiente.
789
+ transcriptionEnabled: row.transcriptionEnabled ?? null,
790
+ transcriptionMode: row.transcriptionMode ?? null
481
791
  };
482
792
  }
483
793
  __name(toContractSettings, "toContractSettings");
@@ -486,7 +796,9 @@ var EMPTY_SETTINGS = {
486
796
  templateLanguage: "pt_BR",
487
797
  templateVariables: [],
488
798
  welcomeMessage: "",
489
- farewellMessage: ""
799
+ farewellMessage: "",
800
+ transcriptionEnabled: null,
801
+ transcriptionMode: null
490
802
  };
491
803
  var SettingsRepository = class {
492
804
  static {
@@ -515,7 +827,12 @@ var SettingsRepository = class {
515
827
  templateLanguage: merged.templateLanguage,
516
828
  templateVariables: merged.templateVariables,
517
829
  welcomeMessage: merged.welcomeMessage || null,
518
- farewellMessage: merged.farewellMessage || null
830
+ farewellMessage: merged.farewellMessage || null,
831
+ // Sem `|| null`: `false` aqui é decisão explícita do painel ("desligado para esta empresa"),
832
+ // e colapsá-lo para nulo faria a empresa voltar a herdar o padrão do host — exatamente o
833
+ // oposto do que o operador acabou de pedir.
834
+ transcriptionEnabled: merged.transcriptionEnabled,
835
+ transcriptionMode: merged.transcriptionMode
519
836
  }).onConflictDoUpdate({
520
837
  target: settings.companyId,
521
838
  set: {
@@ -524,6 +841,8 @@ var SettingsRepository = class {
524
841
  templateVariables: merged.templateVariables,
525
842
  welcomeMessage: merged.welcomeMessage || null,
526
843
  farewellMessage: merged.farewellMessage || null,
844
+ transcriptionEnabled: merged.transcriptionEnabled,
845
+ transcriptionMode: merged.transcriptionMode,
527
846
  updatedAt: /* @__PURE__ */ new Date()
528
847
  }
529
848
  }).returning();
@@ -550,16 +869,19 @@ var LogMessageUseCase = class {
550
869
  sessionRepository;
551
870
  messageRepository;
552
871
  realtime;
553
- constructor(sessionRepository, messageRepository, realtime) {
872
+ moderator;
873
+ constructor(sessionRepository, messageRepository, realtime, moderator) {
554
874
  this.sessionRepository = sessionRepository;
555
875
  this.messageRepository = messageRepository;
556
876
  this.realtime = realtime;
877
+ this.moderator = moderator;
557
878
  }
558
879
  async execute(params) {
559
880
  const session = await this.sessionRepository.getOrCreate(params.companyId, params.whatsappNumber, params.startState);
560
881
  const saved = await this.messageRepository.insertMessage({
561
882
  ...params,
562
- sessionId: session.id
883
+ sessionId: session.id,
884
+ ...this.moderationOf(params)
563
885
  });
564
886
  if (!saved) return void 0;
565
887
  if (params.direction === "inbound") {
@@ -572,6 +894,20 @@ var LogMessageUseCase = class {
572
894
  this.realtime?.emit("global", "data-changed", {});
573
895
  return saved;
574
896
  }
897
+ // Só o que o cliente escreveu: marcar o que o próprio atendente ou o bot enviou não sinaliza
898
+ // abuso, apenas sujaria o transcript com etiqueta na resposta de quem atende.
899
+ moderationOf(params) {
900
+ if (!this.moderator || params.direction !== "inbound") return {};
901
+ const text2 = params.content?.trim();
902
+ if (!text2) return {};
903
+ const verdict = this.moderator.inspect(text2);
904
+ return {
905
+ moderationFlagged: verdict.isOffensive,
906
+ moderationTerms: verdict.isOffensive ? [
907
+ ...verdict.matchedTerms
908
+ ] : null
909
+ };
910
+ }
575
911
  };
576
912
 
577
913
  // src/use-cases/SendMessage.use-case.ts
@@ -585,11 +921,13 @@ var SendMessageUseCase = class {
585
921
  sessionRepository;
586
922
  logMessage;
587
923
  objectStorage;
588
- constructor(channel, sessionRepository, logMessage, objectStorage) {
924
+ documentRepository;
925
+ constructor(channel, sessionRepository, logMessage, objectStorage, documentRepository) {
589
926
  this.channel = channel;
590
927
  this.sessionRepository = sessionRepository;
591
928
  this.logMessage = logMessage;
592
929
  this.objectStorage = objectStorage;
930
+ this.documentRepository = documentRepository;
593
931
  }
594
932
  async assertWithinWindow(companyId, whatsappNumber) {
595
933
  const hours = await this.sessionRepository.hoursSinceLastInbound(companyId, whatsappNumber);
@@ -626,7 +964,7 @@ var SendMessageUseCase = class {
626
964
  mimeType: params.mimeType,
627
965
  key: `meta-whatsapp/${params.companyId}/${Date.now()}-${params.filename}`
628
966
  })).uploadId : void 0;
629
- return this.logMessage.execute({
967
+ const saved = await this.logMessage.execute({
630
968
  companyId: params.companyId,
631
969
  whatsappNumber: params.whatsappNumber,
632
970
  direction: "outbound",
@@ -645,6 +983,19 @@ var SendMessageUseCase = class {
645
983
  status: "sent",
646
984
  startState: params.startState
647
985
  });
986
+ if (saved && uploadId) {
987
+ await this.documentRepository?.link({
988
+ companyId: params.companyId,
989
+ sessionId: saved.sessionId,
990
+ messageId: saved.id,
991
+ uploadId,
992
+ filename: params.filename,
993
+ mimeType: params.mimeType,
994
+ sizeBytes: params.buffer.length,
995
+ source: params.sender
996
+ });
997
+ }
998
+ return saved;
648
999
  }
649
1000
  // Template é o único envio que ignora a janela — é justamente o mecanismo que a Meta oferece
650
1001
  // para reabri-la.
@@ -762,8 +1113,330 @@ var ListMessagesUseCase = class {
762
1113
  }
763
1114
  };
764
1115
 
765
- // src/use-cases/ExportConversation.use-case.ts
1116
+ // src/use-cases/ListConversationDocuments.use-case.ts
766
1117
  import { SessionNotFoundError as SessionNotFoundError2 } from "@adatechnology/meta-whatsapp-contracts";
1118
+ var ListConversationDocumentsUseCase = class {
1119
+ static {
1120
+ __name(this, "ListConversationDocumentsUseCase");
1121
+ }
1122
+ sessionRepository;
1123
+ documentRepository;
1124
+ constructor(sessionRepository, documentRepository) {
1125
+ this.sessionRepository = sessionRepository;
1126
+ this.documentRepository = documentRepository;
1127
+ }
1128
+ async execute(params) {
1129
+ const session = await this.sessionRepository.getContext(params.companyId, params.whatsappNumber);
1130
+ if (!session) throw new SessionNotFoundError2(params.whatsappNumber);
1131
+ const { rows, total } = await this.documentRepository.listByConversation({
1132
+ companyId: params.companyId,
1133
+ sessionId: session.id,
1134
+ ...params.search ? {
1135
+ search: params.search
1136
+ } : {},
1137
+ ...params.sources && params.sources.length > 0 ? {
1138
+ sources: params.sources
1139
+ } : {},
1140
+ ...params.sortDirection ? {
1141
+ sortDirection: params.sortDirection
1142
+ } : {},
1143
+ ...params.page ? {
1144
+ page: params.page
1145
+ } : {},
1146
+ ...params.limit ? {
1147
+ limit: params.limit
1148
+ } : {}
1149
+ });
1150
+ return {
1151
+ documents: rows.map((row) => ({
1152
+ id: row.uploadId,
1153
+ filename: row.filename,
1154
+ mimeType: row.mimeType,
1155
+ sizeBytes: row.sizeBytes,
1156
+ source: row.source,
1157
+ linkedAt: row.linkedAt.toISOString()
1158
+ })),
1159
+ total
1160
+ };
1161
+ }
1162
+ };
1163
+
1164
+ // src/use-cases/ListCompanyDocuments.use-case.ts
1165
+ var ListCompanyDocumentsUseCase = class {
1166
+ static {
1167
+ __name(this, "ListCompanyDocumentsUseCase");
1168
+ }
1169
+ documentRepository;
1170
+ constructor(documentRepository) {
1171
+ this.documentRepository = documentRepository;
1172
+ }
1173
+ async execute(params) {
1174
+ const { rows, total } = await this.documentRepository.listByCompany({
1175
+ companyId: params.companyId,
1176
+ ...params.search ? {
1177
+ search: params.search
1178
+ } : {},
1179
+ ...params.sources && params.sources.length > 0 ? {
1180
+ sources: params.sources
1181
+ } : {},
1182
+ ...params.sortDirection ? {
1183
+ sortDirection: params.sortDirection
1184
+ } : {},
1185
+ ...params.page ? {
1186
+ page: params.page
1187
+ } : {},
1188
+ ...params.limit ? {
1189
+ limit: params.limit
1190
+ } : {}
1191
+ });
1192
+ return {
1193
+ documents: rows.map((row) => ({
1194
+ id: row.uploadId,
1195
+ conversationId: row.whatsappNumber,
1196
+ filename: row.filename,
1197
+ mimeType: row.mimeType,
1198
+ sizeBytes: row.sizeBytes,
1199
+ source: row.source,
1200
+ linkedAt: row.linkedAt.toISOString()
1201
+ })),
1202
+ total
1203
+ };
1204
+ }
1205
+ };
1206
+
1207
+ // src/use-cases/DeleteConversation.use-case.ts
1208
+ import { SessionNotFoundError as SessionNotFoundError3 } from "@adatechnology/meta-whatsapp-contracts";
1209
+ var DeleteConversationUseCase = class {
1210
+ static {
1211
+ __name(this, "DeleteConversationUseCase");
1212
+ }
1213
+ sessionRepository;
1214
+ documentRepository;
1215
+ objectStorage;
1216
+ constructor(sessionRepository, documentRepository, objectStorage) {
1217
+ this.sessionRepository = sessionRepository;
1218
+ this.documentRepository = documentRepository;
1219
+ this.objectStorage = objectStorage;
1220
+ }
1221
+ async execute(params) {
1222
+ const session = await this.sessionRepository.getContext(params.companyId, params.whatsappNumber);
1223
+ if (!session) throw new SessionNotFoundError3(params.whatsappNumber);
1224
+ const uploadIds = await this.documentRepository.listUploadIdsBySession(params.companyId, session.id);
1225
+ if (uploadIds.length > 0 && !this.objectStorage?.delete) {
1226
+ throw new Error("storage_delete_unsupported: a conversa tem arquivos e o storage injetado n\xE3o implementa delete");
1227
+ }
1228
+ const failedObjects = [];
1229
+ let deletedObjects = 0;
1230
+ for (const uploadId of uploadIds) {
1231
+ try {
1232
+ await this.objectStorage?.delete?.(uploadId);
1233
+ deletedObjects++;
1234
+ } catch {
1235
+ failedObjects.push(uploadId);
1236
+ }
1237
+ }
1238
+ if (failedObjects.length > 0) return {
1239
+ deletedObjects,
1240
+ failedObjects
1241
+ };
1242
+ await this.sessionRepository.deleteByNumber(params.companyId, params.whatsappNumber);
1243
+ return {
1244
+ deletedObjects,
1245
+ failedObjects: []
1246
+ };
1247
+ }
1248
+ };
1249
+
1250
+ // src/use-cases/PurgeExpiredDocuments.use-case.ts
1251
+ var DEFAULT_BATCH_SIZE = 50;
1252
+ var HOURS_IN_DAY = 24;
1253
+ var MILLISECONDS_IN_HOUR = 60 * 60 * 1e3;
1254
+ var PurgeExpiredDocumentsUseCase = class {
1255
+ static {
1256
+ __name(this, "PurgeExpiredDocumentsUseCase");
1257
+ }
1258
+ documentRepository;
1259
+ objectStorage;
1260
+ constructor(documentRepository, objectStorage) {
1261
+ this.documentRepository = documentRepository;
1262
+ this.objectStorage = objectStorage;
1263
+ }
1264
+ async execute(params) {
1265
+ if (!this.objectStorage?.delete) {
1266
+ throw new Error("storage_delete_unsupported: reten\xE7\xE3o exige um storage que implemente delete");
1267
+ }
1268
+ const reference = params.now ?? /* @__PURE__ */ new Date();
1269
+ const olderThan = new Date(reference.getTime() - params.retentionDays * HOURS_IN_DAY * MILLISECONDS_IN_HOUR);
1270
+ const expired = await this.documentRepository.listExpired(params.companyId, olderThan, params.batchSize ?? DEFAULT_BATCH_SIZE);
1271
+ const failed = [];
1272
+ let purged = 0;
1273
+ for (const document of expired) {
1274
+ try {
1275
+ await this.objectStorage.delete(document.uploadId);
1276
+ } catch {
1277
+ failed.push(document.uploadId);
1278
+ continue;
1279
+ }
1280
+ await this.documentRepository.deleteById(params.companyId, document.id);
1281
+ purged++;
1282
+ }
1283
+ return {
1284
+ purged,
1285
+ failed
1286
+ };
1287
+ }
1288
+ };
1289
+
1290
+ // src/repositories/DocumentRepository.ts
1291
+ import { and as and4, asc, count, desc as desc2, eq as eq5, ilike, inArray, lt as lt2, or } from "drizzle-orm";
1292
+ var DEFAULT_LIMIT3 = 50;
1293
+ function companyDocumentSearch(search) {
1294
+ const term = search?.trim();
1295
+ if (!term) return void 0;
1296
+ const digits = term.replace(/\D/g, "");
1297
+ const byFilename = ilike(documents.filename, `%${term}%`);
1298
+ if (!digits) return byFilename;
1299
+ return or(byFilename, ilike(sessions.whatsappNumber, `%${digits}%`));
1300
+ }
1301
+ __name(companyDocumentSearch, "companyDocumentSearch");
1302
+ var DocumentRepository = class {
1303
+ static {
1304
+ __name(this, "DocumentRepository");
1305
+ }
1306
+ db;
1307
+ constructor(db) {
1308
+ this.db = db;
1309
+ }
1310
+ /**
1311
+ * Idempotente por (companyId, uploadId), garantido pelo índice único e não por SELECT prévio: o
1312
+ * job de ingestão é reentregue por retry e duas tentativas concorrentes passariam as duas por uma
1313
+ * checagem, duplicando a linha no painel.
1314
+ *
1315
+ * Devolve `undefined` quando o documento já estava linkado.
1316
+ */
1317
+ async link(params) {
1318
+ const values = {
1319
+ companyId: params.companyId,
1320
+ sessionId: params.sessionId,
1321
+ messageId: params.messageId ?? null,
1322
+ uploadId: params.uploadId,
1323
+ filename: params.filename,
1324
+ mimeType: params.mimeType,
1325
+ sizeBytes: params.sizeBytes,
1326
+ sha256: params.sha256 ?? null,
1327
+ source: params.source
1328
+ };
1329
+ const [created] = await this.db.insert(documents).values(values).onConflictDoNothing().returning();
1330
+ return created;
1331
+ }
1332
+ async listByConversation(params) {
1333
+ const filters = [
1334
+ eq5(documents.companyId, params.companyId),
1335
+ eq5(documents.sessionId, params.sessionId)
1336
+ ];
1337
+ if (params.search) filters.push(ilike(documents.filename, `%${params.search}%`));
1338
+ if (params.sources && params.sources.length > 0) {
1339
+ filters.push(inArray(documents.source, [
1340
+ ...params.sources
1341
+ ]));
1342
+ }
1343
+ const where = and4(...filters);
1344
+ const limit = params.limit ?? DEFAULT_LIMIT3;
1345
+ const page = params.page && params.page > 0 ? params.page : 1;
1346
+ const orderBy = params.sortDirection === "asc" ? asc(documents.linkedAt) : desc2(documents.linkedAt);
1347
+ const [rows, counted] = await Promise.all([
1348
+ this.db.select().from(documents).where(where).orderBy(orderBy).limit(limit).offset((page - 1) * limit),
1349
+ this.db.select({
1350
+ value: count()
1351
+ }).from(documents).where(where)
1352
+ ]);
1353
+ return {
1354
+ rows,
1355
+ total: counted[0]?.value ?? 0
1356
+ };
1357
+ }
1358
+ /**
1359
+ * A biblioteca da EMPRESA inteira, não de uma conversa.
1360
+ *
1361
+ * A busca casa nome do arquivo OU telefone da conversa — ver `companyDocumentSearch`.
1362
+ *
1363
+ * Faz join com `sessions` para carregar de qual conversa cada arquivo veio — numa lista global,
1364
+ * arquivo sem essa referência é inútil: o atendente vê "comprovante.pdf" e não sabe de quem.
1365
+ *
1366
+ * Ordena por `linkedAt` apoiada no índice `idx_documents_company_linked`, que já existia para a
1367
+ * varredura de retenção.
1368
+ */
1369
+ async listByCompany(params) {
1370
+ const filters = [
1371
+ eq5(documents.companyId, params.companyId)
1372
+ ];
1373
+ const search = companyDocumentSearch(params.search);
1374
+ if (search) filters.push(search);
1375
+ if (params.sources && params.sources.length > 0) {
1376
+ filters.push(inArray(documents.source, [
1377
+ ...params.sources
1378
+ ]));
1379
+ }
1380
+ const where = and4(...filters);
1381
+ const limit = params.limit ?? DEFAULT_LIMIT3;
1382
+ const page = params.page && params.page > 0 ? params.page : 1;
1383
+ const orderBy = params.sortDirection === "asc" ? asc(documents.linkedAt) : desc2(documents.linkedAt);
1384
+ const [rows, counted] = await Promise.all([
1385
+ this.db.select({
1386
+ id: documents.id,
1387
+ uploadId: documents.uploadId,
1388
+ filename: documents.filename,
1389
+ mimeType: documents.mimeType,
1390
+ sizeBytes: documents.sizeBytes,
1391
+ source: documents.source,
1392
+ linkedAt: documents.linkedAt,
1393
+ // Só o número: o NOME do cliente é dado do produto (tabela própria dele), não do
1394
+ // módulo. Quem quiser exibir "Marina Alves" enriquece na borda.
1395
+ whatsappNumber: sessions.whatsappNumber
1396
+ }).from(documents).innerJoin(sessions, eq5(documents.sessionId, sessions.id)).where(where).orderBy(orderBy).limit(limit).offset((page - 1) * limit),
1397
+ // O mesmo join da listagem, e não só `from(documents)`: a busca pode citar
1398
+ // `sessions.whatsapp_number`, e uma contagem sem a tabela na cláusula não compila — pior,
1399
+ // se compilasse, o total divergiria das linhas e a paginação prometeria páginas vazias.
1400
+ this.db.select({
1401
+ value: count()
1402
+ }).from(documents).innerJoin(sessions, eq5(documents.sessionId, sessions.id)).where(where)
1403
+ ]);
1404
+ return {
1405
+ rows,
1406
+ total: counted[0]?.value ?? 0
1407
+ };
1408
+ }
1409
+ /**
1410
+ * Um documento pela key do objeto. Serve para recuperar o nome original na hora de assinar o
1411
+ * download: a key é caminho no bucket e salvaria o arquivo com o id da Meta.
1412
+ */
1413
+ async findByUploadId(companyId, uploadId) {
1414
+ const [row] = await this.db.select().from(documents).where(and4(eq5(documents.companyId, companyId), eq5(documents.uploadId, uploadId))).limit(1);
1415
+ return row;
1416
+ }
1417
+ /**
1418
+ * Os objetos a apagar no storage antes de a linha sumir.
1419
+ *
1420
+ * Existe porque a cascata da FK apaga a linha e deixa o binário órfão: quem for apagar a conversa
1421
+ * precisa desta lista primeiro, senão paga armazenamento para sempre por arquivo inalcançável.
1422
+ */
1423
+ async listUploadIdsBySession(companyId, sessionId) {
1424
+ const rows = await this.db.select({
1425
+ uploadId: documents.uploadId
1426
+ }).from(documents).where(and4(eq5(documents.companyId, companyId), eq5(documents.sessionId, sessionId)));
1427
+ return rows.map((row) => row.uploadId);
1428
+ }
1429
+ /** Varredura de retenção por idade — o par é o mesmo cuidado com o objeto no storage. */
1430
+ async listExpired(companyId, olderThan, limit = DEFAULT_LIMIT3) {
1431
+ return this.db.select().from(documents).where(and4(eq5(documents.companyId, companyId), lt2(documents.linkedAt, olderThan))).orderBy(documents.linkedAt).limit(limit);
1432
+ }
1433
+ async deleteById(companyId, id) {
1434
+ await this.db.delete(documents).where(and4(eq5(documents.companyId, companyId), eq5(documents.id, id)));
1435
+ }
1436
+ };
1437
+
1438
+ // src/use-cases/ExportConversation.use-case.ts
1439
+ import { SessionNotFoundError as SessionNotFoundError4 } from "@adatechnology/meta-whatsapp-contracts";
767
1440
  var ExportConversationUseCase = class {
768
1441
  static {
769
1442
  __name(this, "ExportConversationUseCase");
@@ -774,7 +1447,7 @@ var ExportConversationUseCase = class {
774
1447
  }
775
1448
  async execute(params) {
776
1449
  const result = await this.sessionRepository.exportConversation(params.companyId, params.whatsappNumber);
777
- if (!result) throw new SessionNotFoundError2(params.whatsappNumber);
1450
+ if (!result) throw new SessionNotFoundError4(params.whatsappNumber);
778
1451
  return result;
779
1452
  }
780
1453
  };
@@ -1011,16 +1684,237 @@ var FlowInterpreter = class {
1011
1684
  }
1012
1685
  };
1013
1686
 
1687
+ // src/flows/createSendMediaAction.ts
1688
+ function mediaTypeFor2(mimeType) {
1689
+ if (mimeType.startsWith("image/")) return "image";
1690
+ if (mimeType.startsWith("audio/")) return "audio";
1691
+ if (mimeType.startsWith("video/")) return "video";
1692
+ return "document";
1693
+ }
1694
+ __name(mediaTypeFor2, "mediaTypeFor");
1695
+ function createSendMediaAction(params) {
1696
+ return async ({ node, session, channel }) => {
1697
+ if (!session.flowKey) return;
1698
+ const location = {
1699
+ companyId: session.companyId,
1700
+ flowKey: session.flowKey,
1701
+ nodeId: node.id
1702
+ };
1703
+ const attachments = await params.flowMediaRepository.listActive(location);
1704
+ for (const attachment of attachments) {
1705
+ try {
1706
+ const buffer = await params.objectStorage.getObject(attachment.uploadId);
1707
+ const { externalMessageId } = await channel.sendMedia({
1708
+ to: session.whatsappNumber,
1709
+ buffer,
1710
+ mimeType: attachment.mimeType,
1711
+ filename: attachment.filename,
1712
+ caption: attachment.caption ?? void 0
1713
+ });
1714
+ await params.logMessage.execute({
1715
+ companyId: session.companyId,
1716
+ whatsappNumber: session.whatsappNumber,
1717
+ direction: "outbound",
1718
+ sender: "bot",
1719
+ agentUserId: null,
1720
+ type: mediaTypeFor2(attachment.mimeType),
1721
+ content: attachment.caption ?? attachment.filename,
1722
+ payload: {
1723
+ filename: attachment.filename,
1724
+ mimeType: attachment.mimeType,
1725
+ uploadId: attachment.uploadId,
1726
+ flowMediaId: attachment.id
1727
+ },
1728
+ waMessageId: externalMessageId,
1729
+ status: "sent",
1730
+ startState: params.startState
1731
+ });
1732
+ } catch (error) {
1733
+ params.onError?.(error, {
1734
+ flowKey: location.flowKey,
1735
+ nodeId: node.id,
1736
+ uploadId: attachment.uploadId
1737
+ });
1738
+ }
1739
+ }
1740
+ };
1741
+ }
1742
+ __name(createSendMediaAction, "createSendMediaAction");
1743
+
1744
+ // src/flows/createSendProductListAction.ts
1745
+ var PRODUCT_LIST_LIMIT = {
1746
+ ITEMS: 30,
1747
+ SECTIONS: 10
1748
+ };
1749
+ function createSendProductListAction(params) {
1750
+ return async ({ node, session, channel }) => {
1751
+ if (!channel.sendProductList) return;
1752
+ const actionParams = node.actionParams ?? {};
1753
+ try {
1754
+ const available = await listAvailableProducts({
1755
+ catalog: params.catalog,
1756
+ catalogId: params.catalogId,
1757
+ ...actionParams.search ? {
1758
+ search: actionParams.search
1759
+ } : {}
1760
+ });
1761
+ if (available.length === 0) return;
1762
+ const bodyText = actionParams.bodyText ?? "Veja o que temos dispon\xEDvel:";
1763
+ const { externalMessageId } = await channel.sendProductList({
1764
+ to: session.whatsappNumber,
1765
+ headerText: actionParams.headerText ?? "Nossos produtos",
1766
+ body: bodyText,
1767
+ ...actionParams.footerText ? {
1768
+ footerText: actionParams.footerText
1769
+ } : {},
1770
+ sections: [
1771
+ {
1772
+ title: actionParams.sectionTitle ?? "Dispon\xEDveis",
1773
+ retailerIds: available.map((product) => product.retailerId)
1774
+ }
1775
+ ]
1776
+ });
1777
+ await params.logMessage.execute({
1778
+ companyId: session.companyId,
1779
+ whatsappNumber: session.whatsappNumber,
1780
+ direction: "outbound",
1781
+ sender: "bot",
1782
+ agentUserId: null,
1783
+ type: "interactive",
1784
+ content: bodyText,
1785
+ payload: {
1786
+ kind: "product_list",
1787
+ productCount: available.length
1788
+ },
1789
+ waMessageId: externalMessageId,
1790
+ status: "sent",
1791
+ startState: params.startState
1792
+ });
1793
+ } catch (error) {
1794
+ params.onError?.(error, {
1795
+ flowKey: session.flowKey ?? "",
1796
+ nodeId: node.id
1797
+ });
1798
+ }
1799
+ };
1800
+ }
1801
+ __name(createSendProductListAction, "createSendProductListAction");
1802
+ async function listAvailableProducts(params) {
1803
+ const products = await params.catalog.listProducts({
1804
+ catalogId: params.catalogId,
1805
+ ...params.search ? {
1806
+ search: params.search
1807
+ } : {}
1808
+ });
1809
+ return products.filter((product) => product.availability === "in stock").slice(0, PRODUCT_LIST_LIMIT.ITEMS);
1810
+ }
1811
+ __name(listAvailableProducts, "listAvailableProducts");
1812
+
1813
+ // src/repositories/FlowMediaRepository.ts
1814
+ import { and as and5, asc as asc2, eq as eq6, sql as sql4 } from "drizzle-orm";
1815
+ var FlowMediaRepository = class {
1816
+ static {
1817
+ __name(this, "FlowMediaRepository");
1818
+ }
1819
+ db;
1820
+ constructor(db) {
1821
+ this.db = db;
1822
+ }
1823
+ locationFilter(location) {
1824
+ return and5(eq6(flowMedia.companyId, location.companyId), eq6(flowMedia.flowKey, location.flowKey), eq6(flowMedia.nodeId, location.nodeId));
1825
+ }
1826
+ // O que o nó realmente envia, na ordem de envio. `active` filtrado aqui e não no chamador:
1827
+ // é a razão de a coluna existir, e um chamador que esquecesse do filtro mandaria ao cliente
1828
+ // justamente o material que alguém desligou.
1829
+ async listActive(location) {
1830
+ return this.db.select().from(flowMedia).where(and5(this.locationFilter(location), eq6(flowMedia.active, true))).orderBy(asc2(flowMedia.sortOrder), asc2(flowMedia.createdAt));
1831
+ }
1832
+ // Inclui os desligados — é a visão do editor, onde desligar precisa continuar visível para
1833
+ // poder ser religado.
1834
+ async listAll(location) {
1835
+ return this.db.select().from(flowMedia).where(this.locationFilter(location)).orderBy(asc2(flowMedia.sortOrder), asc2(flowMedia.createdAt));
1836
+ }
1837
+ /**
1838
+ * Anexa um arquivo já existente no storage ao nó.
1839
+ *
1840
+ * `onConflictDoUpdate` em vez de deixar estourar: reanexar o mesmo arquivo é clique repetido no
1841
+ * editor, e o esperado ali é atualizar a legenda/ordem — não um erro de índice único na cara de
1842
+ * quem está montando o fluxo.
1843
+ */
1844
+ async attach(params) {
1845
+ const [row] = await this.db.insert(flowMedia).values({
1846
+ companyId: params.companyId,
1847
+ flowKey: params.flowKey,
1848
+ nodeId: params.nodeId,
1849
+ uploadId: params.uploadId,
1850
+ filename: params.filename,
1851
+ mimeType: params.mimeType,
1852
+ sizeBytes: params.sizeBytes,
1853
+ caption: params.caption ?? null,
1854
+ sortOrder: params.sortOrder ?? 0
1855
+ }).onConflictDoUpdate({
1856
+ target: [
1857
+ flowMedia.companyId,
1858
+ flowMedia.flowKey,
1859
+ flowMedia.nodeId,
1860
+ flowMedia.uploadId
1861
+ ],
1862
+ set: {
1863
+ caption: params.caption ?? null,
1864
+ sortOrder: params.sortOrder ?? 0,
1865
+ active: true,
1866
+ updatedAt: sql4`now()`
1867
+ }
1868
+ }).returning();
1869
+ return row;
1870
+ }
1871
+ async update(params) {
1872
+ const [row] = await this.db.update(flowMedia).set({
1873
+ ...params.caption !== void 0 ? {
1874
+ caption: params.caption
1875
+ } : {},
1876
+ ...params.sortOrder !== void 0 ? {
1877
+ sortOrder: params.sortOrder
1878
+ } : {},
1879
+ ...params.active !== void 0 ? {
1880
+ active: params.active
1881
+ } : {},
1882
+ updatedAt: sql4`now()`
1883
+ }).where(and5(eq6(flowMedia.companyId, params.companyId), eq6(flowMedia.id, params.id))).returning();
1884
+ return row;
1885
+ }
1886
+ /**
1887
+ * Desanexa do nó. Não toca no storage de propósito: o mesmo `uploadId` pode estar anexado a
1888
+ * outro nó ou a outro fluxo, e apagar o binário aqui quebraria os demais. Quem apaga objeto é o
1889
+ * host, que é dono da biblioteca de arquivos.
1890
+ */
1891
+ async detach(params) {
1892
+ await this.db.delete(flowMedia).where(and5(eq6(flowMedia.companyId, params.companyId), eq6(flowMedia.id, params.id)));
1893
+ }
1894
+ // Chamado ao salvar o grafo: nós apagados no editor deixam linhas que nada mais alcança.
1895
+ async detachRemovedNodes(params) {
1896
+ const condition = params.existingNodeIds.length === 0 ? void 0 : sql4`${flowMedia.nodeId} NOT IN ${params.existingNodeIds}`;
1897
+ await this.db.delete(flowMedia).where(and5(eq6(flowMedia.companyId, params.companyId), eq6(flowMedia.flowKey, params.flowKey), condition));
1898
+ }
1899
+ };
1900
+
1014
1901
  // src/channel/WhatsAppChannelAdapter.ts
1015
1902
  import { WhatsAppWindowExpiredError as ProviderWindowExpiredError } from "@adatechnology/meta-graph-core";
1016
1903
  import { WindowExpiredError as WindowExpiredError2 } from "@adatechnology/meta-whatsapp-contracts";
1904
+
1905
+ // src/channel/previewMedia.ts
1906
+ import { PREVIEW_MEDIA_ID_PREFIX, toPreviewMediaId, resolvePreviewUploadId } from "@adatechnology/meta-whatsapp-contracts";
1907
+
1908
+ // src/channel/WhatsAppChannelAdapter.ts
1017
1909
  var WhatsAppChannelAdapter = class {
1018
1910
  static {
1019
1911
  __name(this, "WhatsAppChannelAdapter");
1020
1912
  }
1021
1913
  messages;
1022
- constructor(messages2) {
1914
+ previewMedia;
1915
+ constructor(messages2, previewMedia) {
1023
1916
  this.messages = messages2;
1917
+ this.previewMedia = previewMedia;
1024
1918
  }
1025
1919
  async translateErrors(operation) {
1026
1920
  try {
@@ -1064,55 +1958,413 @@ var WhatsAppChannelAdapter = class {
1064
1958
  externalMessageId: result.waMessageId
1065
1959
  };
1066
1960
  }
1067
- async fetchMediaAsBase64(mediaId) {
1068
- return this.translateErrors(() => this.messages.fetchMediaAsBase64(mediaId));
1069
- }
1070
- };
1071
-
1072
- // src/channel/ReceiveWebhook.use-case.ts
1073
- import { whatsAppWebhookPayloadSchema } from "@adatechnology/meta-whatsapp-contracts";
1961
+ async sendInteractiveButtons(params) {
1962
+ const result = await this.translateErrors(() => this.messages.sendInteractiveButtons({
1963
+ to: params.to,
1964
+ bodyText: params.body,
1965
+ buttons: params.buttons
1966
+ }));
1967
+ return {
1968
+ externalMessageId: result.waMessageId
1969
+ };
1970
+ }
1971
+ async sendProductList(params) {
1972
+ const result = await this.translateErrors(() => this.messages.sendProductListMessage({
1973
+ to: params.to,
1974
+ headerText: params.headerText,
1975
+ bodyText: params.body,
1976
+ ...params.footerText ? {
1977
+ footerText: params.footerText
1978
+ } : {},
1979
+ sections: params.sections
1980
+ }));
1981
+ return {
1982
+ externalMessageId: result.waMessageId
1983
+ };
1984
+ }
1985
+ /**
1986
+ * Busca o binário da mídia — da Meta, ou do storage quando o id é do simulador.
1987
+ *
1988
+ * O desvio acontece ANTES de qualquer chamada de rede: id do simulador não existe na Meta, e
1989
+ * tentar buscá-lo lá renderia um 404 confuso em vez do áudio que o operador acabou de gravar.
1990
+ */
1991
+ async fetchMediaAsBase64(mediaId) {
1992
+ const uploadId = this.previewMedia?.isEnabled ? resolvePreviewUploadId(mediaId) : void 0;
1993
+ if (uploadId) {
1994
+ const buffer = await this.previewMedia.objectStorage.getObject(uploadId);
1995
+ return {
1996
+ data: buffer.toString("base64"),
1997
+ mimeType: this.previewMedia.defaultMimeType ?? "audio/ogg"
1998
+ };
1999
+ }
2000
+ return this.translateErrors(() => this.messages.fetchMediaAsBase64(mediaId));
2001
+ }
2002
+ };
2003
+
2004
+ // src/channel/ReceiveWebhook.use-case.ts
2005
+ import { whatsAppWebhookPayloadSchema } from "@adatechnology/meta-whatsapp-contracts";
1074
2006
 
1075
2007
  // src/channel/webhookSecurity.ts
1076
- import { createHmac, timingSafeEqual } from "crypto";
2008
+ import { WEBHOOK_CLAIM_TTL_SECONDS, WEBHOOK_NONCE_TTL_SECONDS, buildWebhookDeliveryKey, isValidWebhookChallenge, isValidWebhookSignature } from "@adatechnology/meta-graph-core";
1077
2009
  import { InvalidWebhookSignatureError } from "@adatechnology/meta-whatsapp-contracts";
1078
- var WEBHOOK_NONCE_TTL_SECONDS = 300;
1079
- function safeEqualStrings(left, right) {
1080
- const leftDigest = createHmac("sha256", "constant-time-compare").update(left).digest();
1081
- const rightDigest = createHmac("sha256", "constant-time-compare").update(right).digest();
1082
- return timingSafeEqual(leftDigest, rightDigest);
1083
- }
1084
- __name(safeEqualStrings, "safeEqualStrings");
2010
+ var WEBHOOK_NONCE_NAMESPACE = "meta-whatsapp";
1085
2011
  function verifyWebhookChallenge(params) {
1086
- if (params.mode !== "subscribe" || !params.token || !params.challenge) {
1087
- throw new InvalidWebhookSignatureError();
1088
- }
1089
- if (!safeEqualStrings(params.token, params.expectedToken)) {
1090
- throw new InvalidWebhookSignatureError();
1091
- }
2012
+ if (!isValidWebhookChallenge(params)) throw new InvalidWebhookSignatureError();
1092
2013
  return params.challenge;
1093
2014
  }
1094
2015
  __name(verifyWebhookChallenge, "verifyWebhookChallenge");
1095
2016
  function verifyWebhookSignature(params) {
1096
- const { rawBody, signatureHeader, appSecret } = params;
1097
- if (!signatureHeader?.startsWith("sha256=")) throw new InvalidWebhookSignatureError();
1098
- const expected = createHmac("sha256", appSecret).update(rawBody).digest("hex");
1099
- const received = signatureHeader.slice("sha256=".length);
1100
- if (!safeEqualStrings(received, expected)) throw new InvalidWebhookSignatureError();
2017
+ if (!isValidWebhookSignature(params)) throw new InvalidWebhookSignatureError();
1101
2018
  }
1102
2019
  __name(verifyWebhookSignature, "verifyWebhookSignature");
2020
+ function deliveryKey(signatureHeader) {
2021
+ return buildWebhookDeliveryKey({
2022
+ namespace: WEBHOOK_NONCE_NAMESPACE,
2023
+ signatureHeader
2024
+ });
2025
+ }
2026
+ __name(deliveryKey, "deliveryKey");
1103
2027
  async function claimWebhookDelivery(params) {
1104
- const key = `meta-whatsapp:webhook:${params.signatureHeader}`;
1105
- return params.nonceStore.setIfAbsent(key, params.ttlSeconds ?? WEBHOOK_NONCE_TTL_SECONDS);
2028
+ return params.nonceStore.setIfAbsent(deliveryKey(params.signatureHeader), params.ttlSeconds ?? WEBHOOK_CLAIM_TTL_SECONDS);
1106
2029
  }
1107
2030
  __name(claimWebhookDelivery, "claimWebhookDelivery");
2031
+ async function confirmWebhookDelivery(params) {
2032
+ await params.nonceStore.confirm?.(deliveryKey(params.signatureHeader), params.ttlSeconds ?? WEBHOOK_NONCE_TTL_SECONDS);
2033
+ }
2034
+ __name(confirmWebhookDelivery, "confirmWebhookDelivery");
1108
2035
 
1109
- // src/channel/ReceiveWebhook.use-case.ts
2036
+ // src/channel/IngestInboundMedia.use-case.ts
2037
+ import { eq as eq7, and as and6 } from "drizzle-orm";
2038
+
2039
+ // src/use-cases/TranscribeAudio.use-case.ts
2040
+ import { AudioNotIngestedError, MessageNotAudioError, TranscriptionDisabledError } from "@adatechnology/meta-whatsapp-contracts";
2041
+
2042
+ // src/transcription.types.ts
2043
+ var TRANSCRIPTION_STATUS = {
2044
+ /** Falhou de forma retriável (cota, rede, 5xx) — vai sair quando alguém tentar de novo. */
2045
+ PENDING: "pending",
2046
+ /** Processado. Texto vazio aqui é áudio em silêncio, e NÃO deve ser reprocessado. */
2047
+ DONE: "done",
2048
+ /** Falha definitiva do engine (credencial, áudio corrompido, arquivo grande demais). */
2049
+ FAILED: "failed",
2050
+ /** Nenhum engine da cadeia aceita o formato. Retentar não conserta codec. */
2051
+ UNSUPPORTED: "unsupported"
2052
+ };
2053
+ var TRANSCRIPTION_MODE = {
2054
+ AUTO: "auto",
2055
+ ON_DEMAND: "onDemand"
2056
+ };
2057
+ function isRetriableTranscriptionError(error) {
2058
+ if (typeof error !== "object" || error === null) return true;
2059
+ const isRetriable = error.isRetriable;
2060
+ return typeof isRetriable === "boolean" ? isRetriable : true;
2061
+ }
2062
+ __name(isRetriableTranscriptionError, "isRetriableTranscriptionError");
2063
+ function isUnsupportedTranscriptionError(error) {
2064
+ return typeof error === "object" && error !== null && error.name === "TranscriptionUnsupportedError";
2065
+ }
2066
+ __name(isUnsupportedTranscriptionError, "isUnsupportedTranscriptionError");
2067
+ function transcriptionRetryAfterSeconds(error) {
2068
+ if (typeof error !== "object" || error === null) return void 0;
2069
+ const retryAfter = error.retryAfterSeconds;
2070
+ return typeof retryAfter === "number" ? retryAfter : void 0;
2071
+ }
2072
+ __name(transcriptionRetryAfterSeconds, "transcriptionRetryAfterSeconds");
2073
+ function isAudioMimeType(mimeType) {
2074
+ return typeof mimeType === "string" && mimeType.trim().toLowerCase().startsWith("audio/");
2075
+ }
2076
+ __name(isAudioMimeType, "isAudioMimeType");
2077
+
2078
+ // src/use-cases/TranscribeAudio.use-case.ts
2079
+ var TranscribeAudioUseCase = class {
2080
+ static {
2081
+ __name(this, "TranscribeAudioUseCase");
2082
+ }
2083
+ dependencies;
2084
+ constructor(dependencies) {
2085
+ this.dependencies = dependencies;
2086
+ }
2087
+ async execute(params) {
2088
+ const message = await this.dependencies.messageRepository.findById(params.companyId, params.messageId);
2089
+ if (!message) throw new Error(`Mensagem ${params.messageId} n\xE3o encontrada para transcri\xE7\xE3o`);
2090
+ if (message.transcriptionStatus === TRANSCRIPTION_STATUS.DONE && !params.force) {
2091
+ return {
2092
+ status: TRANSCRIPTION_STATUS.DONE,
2093
+ text: message.transcriptionText,
2094
+ language: message.transcriptionLanguage,
2095
+ engine: message.transcriptionEngine,
2096
+ alreadyTranscribed: true
2097
+ };
2098
+ }
2099
+ if (this.dependencies.resolvePolicy) {
2100
+ const policy = await this.dependencies.resolvePolicy(params.companyId);
2101
+ if (!policy.isEnabled) throw new TranscriptionDisabledError();
2102
+ }
2103
+ const audio = extractAudioReference(message);
2104
+ const buffer = await this.dependencies.objectStorage.getObject(audio.uploadId);
2105
+ return this.transcribeBuffer({
2106
+ ...params,
2107
+ buffer,
2108
+ mimeType: audio.mimeType,
2109
+ uploadId: audio.uploadId,
2110
+ message
2111
+ });
2112
+ }
2113
+ async transcribeBuffer(context) {
2114
+ try {
2115
+ const result = await this.dependencies.transcriber.transcribe({
2116
+ buffer: context.buffer,
2117
+ mimeType: context.mimeType,
2118
+ ...this.dependencies.languageHint ? {
2119
+ languageHint: this.dependencies.languageHint
2120
+ } : {}
2121
+ });
2122
+ await this.dependencies.messageRepository.saveTranscription({
2123
+ companyId: context.companyId,
2124
+ messageId: context.messageId,
2125
+ status: TRANSCRIPTION_STATUS.DONE,
2126
+ text: result.text,
2127
+ language: result.language ?? null,
2128
+ engine: result.engine
2129
+ });
2130
+ return {
2131
+ status: TRANSCRIPTION_STATUS.DONE,
2132
+ text: result.text,
2133
+ language: result.language ?? null,
2134
+ engine: result.engine,
2135
+ alreadyTranscribed: false
2136
+ };
2137
+ } catch (error) {
2138
+ await this.persistFailure(context, error);
2139
+ throw error;
2140
+ }
2141
+ }
2142
+ /**
2143
+ * Carimba o motivo antes de propagar. O status é o que impede os dois desperdícios simétricos:
2144
+ * reprocessar para sempre um codec impossível, e desistir de um áudio que só esbarrou na cota.
2145
+ */
2146
+ async persistFailure(context, error) {
2147
+ const status = resolveFailureStatus(error);
2148
+ await this.dependencies.messageRepository.saveTranscription({
2149
+ companyId: context.companyId,
2150
+ messageId: context.messageId,
2151
+ status
2152
+ });
2153
+ if (status !== TRANSCRIPTION_STATUS.PENDING) return;
2154
+ const retryAfterSeconds = transcriptionRetryAfterSeconds(error);
2155
+ await this.dependencies.hooks?.onTranscriptionDeferred?.({
2156
+ companyId: context.companyId,
2157
+ messageId: context.messageId,
2158
+ whatsappNumber: context.message.whatsappNumber,
2159
+ uploadId: context.uploadId,
2160
+ ...retryAfterSeconds !== void 0 ? {
2161
+ retryAfterSeconds
2162
+ } : {},
2163
+ reason: retryAfterSeconds !== void 0 ? "rate-limited" : "transient-failure",
2164
+ error
2165
+ });
2166
+ }
2167
+ };
2168
+ function resolveFailureStatus(error) {
2169
+ if (isUnsupportedTranscriptionError(error)) return TRANSCRIPTION_STATUS.UNSUPPORTED;
2170
+ return isRetriableTranscriptionError(error) ? TRANSCRIPTION_STATUS.PENDING : TRANSCRIPTION_STATUS.FAILED;
2171
+ }
2172
+ __name(resolveFailureStatus, "resolveFailureStatus");
2173
+ function extractAudioReference(message) {
2174
+ const payload = message.payload ?? {};
2175
+ const audio = payload["audio"];
2176
+ const mimeType = typeof payload["mimeType"] === "string" ? payload["mimeType"] : audio?.mime_type;
2177
+ if (!isAudioMimeType(mimeType) && !audio) {
2178
+ throw new MessageNotAudioError(message.id, message.type);
2179
+ }
2180
+ const uploadId = payload["uploadId"];
2181
+ if (typeof uploadId !== "string" || uploadId.length === 0) {
2182
+ throw new AudioNotIngestedError(message.id);
2183
+ }
2184
+ return {
2185
+ uploadId,
2186
+ mimeType: mimeType ?? "audio/ogg"
2187
+ };
2188
+ }
2189
+ __name(extractAudioReference, "extractAudioReference");
2190
+
2191
+ // src/channel/IngestInboundMedia.use-case.ts
2192
+ var IngestInboundMediaUseCase = class {
2193
+ static {
2194
+ __name(this, "IngestInboundMediaUseCase");
2195
+ }
2196
+ db;
2197
+ channel;
2198
+ objectStorage;
2199
+ documentRepository;
2200
+ transcription;
2201
+ constructor(db, channel, objectStorage, documentRepository, transcription) {
2202
+ this.db = db;
2203
+ this.channel = channel;
2204
+ this.objectStorage = objectStorage;
2205
+ this.documentRepository = documentRepository;
2206
+ this.transcription = transcription;
2207
+ }
2208
+ async execute(params) {
2209
+ const [message] = await this.db.select().from(messages).where(and6(eq7(messages.companyId, params.companyId), eq7(messages.id, params.messageId))).limit(1);
2210
+ if (!message) throw new Error(`Mensagem ${params.messageId} n\xE3o encontrada para ingest\xE3o de m\xEDdia`);
2211
+ const payload = message.payload ?? {};
2212
+ if (payload["uploadId"] && payload["sourceMediaId"] === params.sourceMediaId) {
2213
+ return {
2214
+ uploadId: String(payload["uploadId"]),
2215
+ alreadyIngested: true
2216
+ };
2217
+ }
2218
+ const { data, mimeType } = await this.channel.fetchMediaAsBase64(params.sourceMediaId);
2219
+ const buffer = Buffer.from(data, "base64");
2220
+ const { uploadId } = await this.objectStorage.upload({
2221
+ buffer,
2222
+ mimeType: mimeType || params.mimeType,
2223
+ key: `meta-whatsapp/${params.companyId}/inbound/${params.sourceMediaId}`
2224
+ });
2225
+ const updatedPayload = {
2226
+ ...payload,
2227
+ uploadId,
2228
+ sourceMediaId: params.sourceMediaId,
2229
+ mimeType: mimeType || params.mimeType,
2230
+ // O tamanho já está na mão (é o buffer que acabou de ser copiado) e a bolha de documento o
2231
+ // exibe ao lado do tipo. Sem gravar aqui, a UI mostraria "PDF" sem o "· 180 KB", e buscá-lo
2232
+ // depois custaria uma consulta à tabela de documentos por mensagem renderizada.
2233
+ sizeBytes: buffer.length,
2234
+ ...params.filename ? {
2235
+ filename: params.filename
2236
+ } : {}
2237
+ };
2238
+ await this.db.update(messages).set({
2239
+ payload: updatedPayload
2240
+ }).where(and6(eq7(messages.companyId, params.companyId), eq7(messages.id, params.messageId)));
2241
+ await this.documentRepository?.link({
2242
+ companyId: params.companyId,
2243
+ sessionId: message.sessionId,
2244
+ messageId: message.id,
2245
+ uploadId,
2246
+ // Áudio e sticker chegam sem nome; sem um rótulo o painel mostraria linha vazia.
2247
+ filename: params.filename ?? `${params.sourceMediaId}`,
2248
+ mimeType: mimeType || params.mimeType,
2249
+ sizeBytes: buffer.length,
2250
+ source: message.sender
2251
+ });
2252
+ const transcription = await this.transcribeIfAuto({
2253
+ companyId: params.companyId,
2254
+ message,
2255
+ uploadId,
2256
+ buffer,
2257
+ mimeType: mimeType || params.mimeType
2258
+ });
2259
+ return {
2260
+ uploadId,
2261
+ alreadyIngested: false,
2262
+ ...transcription ? {
2263
+ transcription
2264
+ } : {}
2265
+ };
2266
+ }
2267
+ /**
2268
+ * Transcreve o áudio recém-baixado, quando o modo é `auto`.
2269
+ *
2270
+ * **Nunca propaga erro.** Neste ponto o binário já está no storage e já entrou na biblioteca da
2271
+ * conversa: deixar uma falha de transcrição subir marcaria a ingestão inteira como falha, e o
2272
+ * retry do host baixaria de novo da Meta um arquivo que está salvo — gastando banda para reproduzir
2273
+ * um efeito que já aconteceu. O status fica gravado na mensagem e o `onTranscriptionDeferred`
2274
+ * avisa quem sabe reenfileirar.
2275
+ */
2276
+ async transcribeIfAuto(context) {
2277
+ const transcription = this.transcription;
2278
+ if (!transcription) return void 0;
2279
+ if (!isAudioMimeType(context.mimeType)) return void 0;
2280
+ const policy = await transcription.resolvePolicy(context.companyId);
2281
+ if (!policy.isEnabled || policy.mode !== TRANSCRIPTION_MODE.AUTO) return void 0;
2282
+ const current = await transcription.messageRepository.findById(context.companyId, context.message.id);
2283
+ if (current?.transcriptionStatus === TRANSCRIPTION_STATUS.DONE) return void 0;
2284
+ try {
2285
+ const result = await transcription.transcriber.transcribe({
2286
+ buffer: context.buffer,
2287
+ mimeType: context.mimeType,
2288
+ ...transcription.languageHint ? {
2289
+ languageHint: transcription.languageHint
2290
+ } : {}
2291
+ });
2292
+ await transcription.messageRepository.saveTranscription({
2293
+ companyId: context.companyId,
2294
+ messageId: context.message.id,
2295
+ status: TRANSCRIPTION_STATUS.DONE,
2296
+ text: result.text,
2297
+ language: result.language ?? null,
2298
+ engine: result.engine
2299
+ });
2300
+ return {
2301
+ status: TRANSCRIPTION_STATUS.DONE
2302
+ };
2303
+ } catch (error) {
2304
+ return this.recordTranscriptionFailure(context, error);
2305
+ }
2306
+ }
2307
+ async recordTranscriptionFailure(context, error) {
2308
+ const status = resolveFailureStatus(error);
2309
+ await this.transcription?.messageRepository.saveTranscription({
2310
+ companyId: context.companyId,
2311
+ messageId: context.message.id,
2312
+ status
2313
+ });
2314
+ if (status === TRANSCRIPTION_STATUS.PENDING) {
2315
+ const retryAfterSeconds = transcriptionRetryAfterSeconds(error);
2316
+ await this.transcription?.hooks?.onTranscriptionDeferred?.({
2317
+ companyId: context.companyId,
2318
+ messageId: context.message.id,
2319
+ whatsappNumber: context.message.whatsappNumber,
2320
+ uploadId: context.uploadId,
2321
+ ...retryAfterSeconds !== void 0 ? {
2322
+ retryAfterSeconds
2323
+ } : {},
2324
+ reason: retryAfterSeconds !== void 0 ? "rate-limited" : "transient-failure",
2325
+ error
2326
+ });
2327
+ }
2328
+ return {
2329
+ status
2330
+ };
2331
+ }
2332
+ };
2333
+ function extractMediaDescriptor(message) {
2334
+ const payload = message.payload ?? {};
2335
+ for (const key of [
2336
+ "image",
2337
+ "audio",
2338
+ "video",
2339
+ "document",
2340
+ "sticker"
2341
+ ]) {
2342
+ const media = payload[key];
2343
+ if (media?.id) {
2344
+ return {
2345
+ sourceMediaId: media.id,
2346
+ mimeType: media.mime_type ?? "application/octet-stream",
2347
+ filename: media.filename
2348
+ };
2349
+ }
2350
+ }
2351
+ return void 0;
2352
+ }
2353
+ __name(extractMediaDescriptor, "extractMediaDescriptor");
2354
+
2355
+ // src/channel/inboundDispatch.ts
2356
+ function buildInboundJobId(job) {
2357
+ return job.kind === "message" ? `wa-inbound-message:${job.message.id}` : `wa-inbound-status:${job.status.id}:${job.status.status}`;
2358
+ }
2359
+ __name(buildInboundJobId, "buildInboundJobId");
1110
2360
  function toSessionContract(row) {
1111
2361
  return {
1112
2362
  id: row.id,
1113
2363
  companyId: row.companyId,
1114
2364
  whatsappNumber: row.whatsappNumber,
1115
2365
  currentState: row.currentState,
2366
+ flowKey: row.flowKey,
2367
+ currentNodeId: row.currentNodeId,
1116
2368
  context: row.context,
1117
2369
  mode: row.mode,
1118
2370
  assignedUserId: row.assignedUserId,
@@ -1125,6 +2377,65 @@ function toSessionContract(row) {
1125
2377
  };
1126
2378
  }
1127
2379
  __name(toSessionContract, "toSessionContract");
2380
+ function extractAnswer(message) {
2381
+ const interactive = message.interactive;
2382
+ if (interactive?.button_reply) return interactive.button_reply.id;
2383
+ if (interactive?.list_reply) return interactive.list_reply.id;
2384
+ return message.text?.body;
2385
+ }
2386
+ __name(extractAnswer, "extractAnswer");
2387
+ var InboundEffectsDispatcher = class {
2388
+ static {
2389
+ __name(this, "InboundEffectsDispatcher");
2390
+ }
2391
+ params;
2392
+ constructor(params) {
2393
+ this.params = params;
2394
+ }
2395
+ async run(job) {
2396
+ if (job.kind === "message") return this.runMessageEffects(job);
2397
+ return this.runStatusEffects(job);
2398
+ }
2399
+ async runMessageEffects(job) {
2400
+ const { companyId, message, savedMessageId, media } = job;
2401
+ if (media) {
2402
+ await this.params.hooks?.onMediaReceived?.({
2403
+ companyId,
2404
+ messageId: savedMessageId,
2405
+ whatsappNumber: message.from,
2406
+ sourceMediaId: media.sourceMediaId,
2407
+ mimeType: media.mimeType,
2408
+ ...media.filename ? {
2409
+ filename: media.filename
2410
+ } : {}
2411
+ });
2412
+ }
2413
+ const sessionRow = await this.params.sessionRepository.getContext(companyId, message.from);
2414
+ if (!sessionRow) return;
2415
+ if (sessionRow.mode === "human") return;
2416
+ const outcome = await this.params.hooks?.onMessageReceived?.(message, toSessionContract(sessionRow));
2417
+ if (outcome?.outcome === "handled") return;
2418
+ void extractAnswer(message);
2419
+ }
2420
+ async runStatusEffects(job) {
2421
+ const sessionRow = await this.params.sessionRepository.getContext(job.companyId, job.whatsappNumber);
2422
+ await this.params.hooks?.onStatusUpdate?.(job.status, sessionRow ? toSessionContract(sessionRow) : null);
2423
+ }
2424
+ };
2425
+ var ProcessInboundDispatchUseCase = class {
2426
+ static {
2427
+ __name(this, "ProcessInboundDispatchUseCase");
2428
+ }
2429
+ dispatcher;
2430
+ constructor(dispatcher) {
2431
+ this.dispatcher = dispatcher;
2432
+ }
2433
+ async execute(job) {
2434
+ await this.dispatcher.run(job);
2435
+ }
2436
+ };
2437
+
2438
+ // src/channel/ReceiveWebhook.use-case.ts
1128
2439
  function extractContent(message) {
1129
2440
  if (message.text?.body) return message.text.body;
1130
2441
  const interactive = message.interactive;
@@ -1137,13 +2448,6 @@ function extractContent(message) {
1137
2448
  return message.image?.caption ?? message.document?.caption ?? null;
1138
2449
  }
1139
2450
  __name(extractContent, "extractContent");
1140
- function extractAnswer(message) {
1141
- const interactive = message.interactive;
1142
- if (interactive?.button_reply) return interactive.button_reply.id;
1143
- if (interactive?.list_reply) return interactive.list_reply.id;
1144
- return message.text?.body;
1145
- }
1146
- __name(extractAnswer, "extractAnswer");
1147
2451
  function extractPayload(message) {
1148
2452
  const payload = {};
1149
2453
  if (message.interactive) payload["interactive"] = message.interactive;
@@ -1164,7 +2468,17 @@ var ReceiveWebhookUseCase = class {
1164
2468
  params;
1165
2469
  constructor(params) {
1166
2470
  this.params = params;
2471
+ this.dispatcher = new InboundEffectsDispatcher({
2472
+ sessionRepository: params.sessionRepository,
2473
+ ...params.hooks ? {
2474
+ hooks: params.hooks
2475
+ } : {},
2476
+ ...params.realtime ? {
2477
+ realtime: params.realtime
2478
+ } : {}
2479
+ });
1167
2480
  }
2481
+ dispatcher;
1168
2482
  async execute(input) {
1169
2483
  verifyWebhookSignature({
1170
2484
  rawBody: input.rawBody,
@@ -1178,14 +2492,21 @@ var ReceiveWebhookUseCase = class {
1178
2492
  if (!claimed) return {
1179
2493
  duplicate: true,
1180
2494
  messagesProcessed: 0,
1181
- statusesProcessed: 0
2495
+ statusesProcessed: 0,
2496
+ ignoredForeignNumber: 0
1182
2497
  };
1183
2498
  const rawText = typeof input.rawBody === "string" ? input.rawBody : input.rawBody.toString("utf8");
1184
2499
  const payload = whatsAppWebhookPayloadSchema.parse(JSON.parse(rawText));
1185
2500
  let messagesProcessed = 0;
1186
2501
  let statusesProcessed = 0;
2502
+ let ignoredForeignNumber = 0;
1187
2503
  for (const entry of payload.entry) {
1188
2504
  for (const change of entry.changes) {
2505
+ const targetNumber = change.value.metadata?.phone_number_id;
2506
+ if (targetNumber && targetNumber !== this.params.phoneNumberId) {
2507
+ ignoredForeignNumber++;
2508
+ continue;
2509
+ }
1189
2510
  for (const message of change.value.messages ?? []) {
1190
2511
  await this.handleMessage(input.companyId, message);
1191
2512
  messagesProcessed++;
@@ -1196,10 +2517,15 @@ var ReceiveWebhookUseCase = class {
1196
2517
  }
1197
2518
  }
1198
2519
  }
2520
+ await confirmWebhookDelivery({
2521
+ nonceStore: this.params.nonceStore,
2522
+ signatureHeader: input.signatureHeader
2523
+ });
1199
2524
  return {
1200
2525
  duplicate: false,
1201
2526
  messagesProcessed,
1202
- statusesProcessed
2527
+ statusesProcessed,
2528
+ ignoredForeignNumber
1203
2529
  };
1204
2530
  }
1205
2531
  async handleMessage(companyId, message) {
@@ -1216,12 +2542,17 @@ var ReceiveWebhookUseCase = class {
1216
2542
  startState: this.params.startState
1217
2543
  });
1218
2544
  if (!saved) return;
1219
- const sessionRow = await this.params.sessionRepository.getContext(companyId, message.from);
1220
- if (!sessionRow) return;
1221
- if (sessionRow.mode === "human") return;
1222
- const outcome = await this.params.hooks?.onMessageReceived?.(message, toSessionContract(sessionRow));
1223
- if (outcome?.outcome === "handled") return;
1224
- void extractAnswer(message);
2545
+ const media = extractMediaDescriptor(saved);
2546
+ await this.dispatch({
2547
+ kind: "message",
2548
+ companyId,
2549
+ message,
2550
+ savedMessageId: saved.id,
2551
+ ...media ? {
2552
+ media
2553
+ } : {},
2554
+ receivedAt: Date.now()
2555
+ });
1225
2556
  }
1226
2557
  async handleStatus(companyId, status) {
1227
2558
  const updated = await this.params.messageRepository.updateMessageStatus(companyId, status.id, status.status);
@@ -1230,81 +2561,71 @@ var ReceiveWebhookUseCase = class {
1230
2561
  waMessageId: status.id,
1231
2562
  status: status.status
1232
2563
  });
1233
- const sessionRow = await this.params.sessionRepository.getContext(companyId, updated.whatsappNumber);
1234
- await this.params.hooks?.onStatusUpdate?.(status, sessionRow ? toSessionContract(sessionRow) : null);
2564
+ await this.dispatch({
2565
+ kind: "status",
2566
+ companyId,
2567
+ status,
2568
+ whatsappNumber: updated.whatsappNumber,
2569
+ receivedAt: Date.now()
2570
+ });
2571
+ }
2572
+ // Com fila configurada, os efeitos saem da requisição do webhook; sem ela, rodam aqui mesmo e o
2573
+ // comportamento é o de sempre. É o mesmo `InboundEffectsDispatcher` nos dois caminhos.
2574
+ async dispatch(job) {
2575
+ if (this.params.inboundQueue) {
2576
+ await this.params.inboundQueue.enqueue(job, {
2577
+ jobId: buildInboundJobId(job)
2578
+ });
2579
+ return;
2580
+ }
2581
+ await this.dispatcher.run(job);
1235
2582
  }
1236
2583
  };
1237
2584
 
1238
- // src/channel/IngestInboundMedia.use-case.ts
1239
- import { eq as eq5, and as and4 } from "drizzle-orm";
1240
- var IngestInboundMediaUseCase = class {
2585
+ // src/use-cases/resolveTranscriptionPolicy.ts
2586
+ function createTranscriptionPolicyResolver(dependencies) {
2587
+ return /* @__PURE__ */ __name(async function resolveTranscriptionPolicy(companyId) {
2588
+ const settings2 = await dependencies.settingsRepository.get(companyId);
2589
+ return {
2590
+ // `??` e não `||`: `false` gravado é decisão explícita de desligar, e `||` a trocaria pelo
2591
+ // padrão do host — desligar no painel não faria nada num deploy com transcrição ligada.
2592
+ isEnabled: settings2.transcriptionEnabled ?? dependencies.defaults.isEnabled,
2593
+ mode: normalizeMode(settings2.transcriptionMode) ?? dependencies.defaults.mode
2594
+ };
2595
+ }, "resolveTranscriptionPolicy");
2596
+ }
2597
+ __name(createTranscriptionPolicyResolver, "createTranscriptionPolicyResolver");
2598
+ function normalizeMode(value) {
2599
+ if (value === TRANSCRIPTION_MODE.AUTO) return TRANSCRIPTION_MODE.AUTO;
2600
+ if (value === TRANSCRIPTION_MODE.ON_DEMAND) return TRANSCRIPTION_MODE.ON_DEMAND;
2601
+ return void 0;
2602
+ }
2603
+ __name(normalizeMode, "normalizeMode");
2604
+
2605
+ // src/use-cases/StorePreviewMedia.use-case.ts
2606
+ var StorePreviewMediaUseCase = class {
1241
2607
  static {
1242
- __name(this, "IngestInboundMediaUseCase");
2608
+ __name(this, "StorePreviewMediaUseCase");
1243
2609
  }
1244
- db;
1245
- channel;
1246
2610
  objectStorage;
1247
- constructor(db, channel, objectStorage) {
1248
- this.db = db;
1249
- this.channel = channel;
2611
+ generateKeySuffix;
2612
+ constructor(objectStorage, generateKeySuffix) {
1250
2613
  this.objectStorage = objectStorage;
2614
+ this.generateKeySuffix = generateKeySuffix;
1251
2615
  }
1252
2616
  async execute(params) {
1253
- const [message] = await this.db.select().from(messages).where(and4(eq5(messages.companyId, params.companyId), eq5(messages.id, params.messageId))).limit(1);
1254
- if (!message) throw new Error(`Mensagem ${params.messageId} n\xE3o encontrada para ingest\xE3o de m\xEDdia`);
1255
- const payload = message.payload ?? {};
1256
- if (payload["uploadId"] && payload["sourceMediaId"] === params.sourceMediaId) {
1257
- return {
1258
- uploadId: String(payload["uploadId"]),
1259
- alreadyIngested: true
1260
- };
1261
- }
1262
- const { data, mimeType } = await this.channel.fetchMediaAsBase64(params.sourceMediaId);
1263
- const buffer = Buffer.from(data, "base64");
2617
+ const key = `meta-whatsapp/${params.companyId}/preview/${this.generateKeySuffix()}`;
1264
2618
  const { uploadId } = await this.objectStorage.upload({
1265
- buffer,
1266
- mimeType: mimeType || params.mimeType,
1267
- key: `meta-whatsapp/${params.companyId}/inbound/${params.sourceMediaId}`
2619
+ buffer: params.buffer,
2620
+ mimeType: params.mimeType,
2621
+ key
1268
2622
  });
1269
- const updatedPayload = {
1270
- ...payload,
1271
- uploadId,
1272
- sourceMediaId: params.sourceMediaId,
1273
- mimeType: mimeType || params.mimeType,
1274
- ...params.filename ? {
1275
- filename: params.filename
1276
- } : {}
1277
- };
1278
- await this.db.update(messages).set({
1279
- payload: updatedPayload
1280
- }).where(and4(eq5(messages.companyId, params.companyId), eq5(messages.id, params.messageId)));
1281
2623
  return {
1282
- uploadId,
1283
- alreadyIngested: false
2624
+ mediaId: toPreviewMediaId(uploadId),
2625
+ uploadId
1284
2626
  };
1285
2627
  }
1286
2628
  };
1287
- function extractMediaDescriptor(message) {
1288
- const payload = message.payload ?? {};
1289
- for (const key of [
1290
- "image",
1291
- "audio",
1292
- "video",
1293
- "document",
1294
- "sticker"
1295
- ]) {
1296
- const media = payload[key];
1297
- if (media?.id) {
1298
- return {
1299
- sourceMediaId: media.id,
1300
- mimeType: media.mime_type ?? "application/octet-stream",
1301
- filename: media.filename
1302
- };
1303
- }
1304
- }
1305
- return void 0;
1306
- }
1307
- __name(extractMediaDescriptor, "extractMediaDescriptor");
1308
2629
 
1309
2630
  // src/createMetaWhatsAppModule.ts
1310
2631
  function createMetaWhatsAppModule(params) {
@@ -1318,15 +2639,24 @@ function createMetaWhatsAppModule(params) {
1318
2639
  apiVersion: config.apiVersion,
1319
2640
  baseUrl: config.baseUrl
1320
2641
  });
1321
- const channel = new WhatsAppChannelAdapter(messageProvider);
2642
+ const previewMediaSupport = params.features?.previewMedia && providers.objectStorage?.getObject ? {
2643
+ isEnabled: true,
2644
+ objectStorage: providers.objectStorage
2645
+ } : void 0;
2646
+ const channel = new WhatsAppChannelAdapter(messageProvider, previewMediaSupport);
1322
2647
  const sessionRepository = new SessionRepository(db);
1323
2648
  const messageRepository = new MessageRepository(db);
1324
2649
  const settingsRepository = new SettingsRepository(db);
1325
- const flowGraphRepository = new FlowGraphRepository(db);
1326
- const logMessage = new LogMessageUseCase(sessionRepository, messageRepository, providers.realtime);
1327
- const sendMessage = new SendMessageUseCase(channel, sessionRepository, logMessage, providers.objectStorage);
2650
+ const flowGraphCacheFeature = params.features?.flowGraphCache ?? false;
2651
+ 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;
2652
+ const flowGraphRepository = new FlowGraphRepository(db, flowGraphCache);
2653
+ const documentRepository = new DocumentRepository(db);
2654
+ const flowMediaRepository = new FlowMediaRepository(db);
2655
+ const logMessage = new LogMessageUseCase(sessionRepository, messageRepository, providers.realtime, providers.moderator);
2656
+ const sendMessage = new SendMessageUseCase(channel, sessionRepository, logMessage, providers.objectStorage, documentRepository);
1328
2657
  const receiveWebhook = new ReceiveWebhookUseCase({
1329
2658
  appSecret: config.appSecret,
2659
+ phoneNumberId: config.phoneNumberId,
1330
2660
  nonceStore,
1331
2661
  sessionRepository,
1332
2662
  messageRepository,
@@ -1336,7 +2666,58 @@ function createMetaWhatsAppModule(params) {
1336
2666
  realtime: providers.realtime
1337
2667
  });
1338
2668
  const flowInterpreter = flowEngineEnabled ? new FlowInterpreter() : void 0;
1339
- const ingestInboundMedia = providers.objectStorage ? new IngestInboundMediaUseCase(db, channel, providers.objectStorage) : void 0;
2669
+ if (flowInterpreter && providers.objectStorage?.getObject) {
2670
+ flowInterpreter.registerFlowAction(FLOW_ACTION_KIND.SEND_MEDIA, createSendMediaAction({
2671
+ flowMediaRepository,
2672
+ objectStorage: providers.objectStorage,
2673
+ logMessage,
2674
+ startState,
2675
+ onError: hooks?.onFlowMediaError
2676
+ }));
2677
+ }
2678
+ if (flowInterpreter && providers.catalog && config.catalogId) {
2679
+ flowInterpreter.registerFlowAction(FLOW_ACTION_KIND.SEND_PRODUCT_LIST, createSendProductListAction({
2680
+ catalog: providers.catalog,
2681
+ catalogId: config.catalogId,
2682
+ logMessage,
2683
+ startState,
2684
+ onError: hooks?.onFlowProductListError
2685
+ }));
2686
+ }
2687
+ const transcriptionMode = providers.transcription?.mode ?? TRANSCRIPTION_MODE.ON_DEMAND;
2688
+ const resolveTranscriptionPolicy = providers.transcription ? createTranscriptionPolicyResolver({
2689
+ settingsRepository,
2690
+ defaults: {
2691
+ isEnabled: providers.transcription.isEnabledByDefault ?? true,
2692
+ mode: transcriptionMode
2693
+ }
2694
+ }) : void 0;
2695
+ const ingestInboundMedia = providers.objectStorage ? new IngestInboundMediaUseCase(db, channel, providers.objectStorage, documentRepository, providers.transcription && resolveTranscriptionPolicy ? {
2696
+ transcriber: providers.transcription.transcriber,
2697
+ resolvePolicy: resolveTranscriptionPolicy,
2698
+ messageRepository,
2699
+ ...providers.transcription.languageHint ? {
2700
+ languageHint: providers.transcription.languageHint
2701
+ } : {},
2702
+ ...hooks ? {
2703
+ hooks
2704
+ } : {}
2705
+ } : void 0) : void 0;
2706
+ const transcribeAudio = providers.transcription && providers.objectStorage?.getObject ? new TranscribeAudioUseCase({
2707
+ messageRepository,
2708
+ objectStorage: providers.objectStorage,
2709
+ transcriber: providers.transcription.transcriber,
2710
+ ...resolveTranscriptionPolicy ? {
2711
+ resolvePolicy: resolveTranscriptionPolicy
2712
+ } : {},
2713
+ ...providers.transcription.languageHint ? {
2714
+ languageHint: providers.transcription.languageHint
2715
+ } : {},
2716
+ ...hooks ? {
2717
+ hooks
2718
+ } : {}
2719
+ }) : void 0;
2720
+ const listDocuments = new ListConversationDocumentsUseCase(sessionRepository, documentRepository);
1340
2721
  return {
1341
2722
  channel,
1342
2723
  // undefined quando providers.objectStorage não foi injetado.
@@ -1348,10 +2729,36 @@ function createMetaWhatsAppModule(params) {
1348
2729
  release: new ReleaseConversationUseCase(sessionRepository, providers.realtime),
1349
2730
  list: new ListConversationsUseCase(sessionRepository),
1350
2731
  listMessages: new ListMessagesUseCase(sessionRepository, messageRepository),
2732
+ listDocuments,
2733
+ // Biblioteca da empresa inteira, para uma tela de Documentos fora da conversa.
2734
+ listCompanyDocuments: new ListCompanyDocumentsUseCase(documentRepository),
2735
+ // Apaga a mídia no storage antes das linhas — a cascata da FK sozinha deixaria os binários
2736
+ // órfãos, já que a lista de uploadId vive justamente nas linhas que ela derruba.
2737
+ delete: new DeleteConversationUseCase(sessionRepository, documentRepository, providers.objectStorage),
2738
+ purgeExpiredDocuments: new PurgeExpiredDocumentsUseCase(documentRepository, providers.objectStorage),
1351
2739
  export: new ExportConversationUseCase(sessionRepository),
1352
- repository: sessionRepository
2740
+ // undefined quando transcrição não foi injetada, ou quando o storage não sabe ler de volta.
2741
+ // O painel consulta a ausência para decidir se desenha o botão "transcrever".
2742
+ transcribeAudio,
2743
+ repository: sessionRepository,
2744
+ messageRepository,
2745
+ documentRepository
1353
2746
  },
2747
+ /**
2748
+ * `undefined` = o host não injetou transcritor, e nenhuma configuração de empresa muda isso: a
2749
+ * capacidade não existe. Presente, `resolvePolicy` responde o que vale para uma empresa —
2750
+ * é o que a rota de configurações usa para dizer ao painel se desenha o interruptor.
2751
+ */
2752
+ transcription: providers.transcription && resolveTranscriptionPolicy ? {
2753
+ defaultMode: transcriptionMode,
2754
+ resolvePolicy: resolveTranscriptionPolicy
2755
+ } : void 0,
1354
2756
  settings: settingsRepository,
2757
+ /**
2758
+ * `undefined` quando o recurso não está ligado (ou falta storage legível). O host consulta a
2759
+ * ausência para não registrar a rota de upload — e o preview, sem a rota, esconde o microfone.
2760
+ */
2761
+ previewMedia: previewMediaSupport ? new StorePreviewMediaUseCase(providers.objectStorage, () => `${Date.now()}-${Math.random().toString(36).slice(2)}`) : void 0,
1355
2762
  webhook: {
1356
2763
  receive: receiveWebhook,
1357
2764
  // GET de verificação da Meta — o host liga na sua rota e devolve o retorno como texto puro.
@@ -1370,7 +2777,11 @@ function createMetaWhatsAppModule(params) {
1370
2777
  save: new SaveFlowGraphUseCase(flowGraphRepository),
1371
2778
  delete: new DeleteFlowGraphUseCase(flowGraphRepository),
1372
2779
  livePositions: new GetLiveFlowPositionsUseCase(flowGraphRepository),
1373
- repository: flowGraphRepository
2780
+ repository: flowGraphRepository,
2781
+ // Biblioteca de mídia dos nós `send_media` — o host liga nas rotas do editor
2782
+ // (anexar/reordenar/desligar). Existe mesmo sem storage injetado: gerenciar anexos é
2783
+ // consultar a tabela, e só o ENVIO precisa dos bytes.
2784
+ mediaRepository: flowMediaRepository
1374
2785
  } : void 0,
1375
2786
  catalog: providers.catalog
1376
2787
  };
@@ -1469,14 +2880,22 @@ async function redeemSseTicket(store, ticket) {
1469
2880
  __name(redeemSseTicket, "redeemSseTicket");
1470
2881
  export {
1471
2882
  CreateFlowGraphUseCase,
2883
+ DEFAULT_FLOW_GRAPH_CACHE_TTL_SECONDS,
2884
+ DeleteConversationUseCase,
1472
2885
  DeleteFlowGraphUseCase,
2886
+ DocumentRepository,
1473
2887
  ExportConversationUseCase,
2888
+ FlowGraphCache,
1474
2889
  FlowGraphRepository,
1475
2890
  FlowInterpreter,
2891
+ FlowMediaRepository,
1476
2892
  GetFlowGraphUseCase,
1477
2893
  GetLiveFlowPositionsUseCase,
2894
+ InboundEffectsDispatcher,
1478
2895
  IngestInboundMediaUseCase,
1479
2896
  InvalidFlowGraphError,
2897
+ ListCompanyDocumentsUseCase,
2898
+ ListConversationDocumentsUseCase,
1480
2899
  ListConversationsUseCase,
1481
2900
  ListFlowGraphsUseCase,
1482
2901
  ListMessagesUseCase,
@@ -1484,6 +2903,10 @@ export {
1484
2903
  META_WHATSAPP_MIGRATIONS_TABLE,
1485
2904
  MessageRepository,
1486
2905
  OptimisticLockError,
2906
+ PREVIEW_MEDIA_ID_PREFIX,
2907
+ PRODUCT_LIST_LIMIT,
2908
+ ProcessInboundDispatchUseCase,
2909
+ PurgeExpiredDocumentsUseCase,
1487
2910
  ReceiveWebhookUseCase,
1488
2911
  ReleaseConversationUseCase,
1489
2912
  SaveFlowGraphUseCase,
@@ -1491,21 +2914,41 @@ export {
1491
2914
  SessionRepository,
1492
2915
  SettingsRepository,
1493
2916
  SseHub,
2917
+ StorePreviewMediaUseCase,
2918
+ TRANSCRIPTION_MODE,
2919
+ TRANSCRIPTION_STATUS,
1494
2920
  TakeoverConversationUseCase,
2921
+ TranscribeAudioUseCase,
2922
+ WEBHOOK_CLAIM_TTL_SECONDS,
1495
2923
  WEBHOOK_NONCE_TTL_SECONDS,
1496
2924
  WhatsAppChannelAdapter,
2925
+ buildInboundJobId,
1497
2926
  claimWebhookDelivery,
2927
+ confirmWebhookDelivery,
1498
2928
  createMetaWhatsAppModule,
2929
+ createSendMediaAction,
2930
+ createSendProductListAction,
2931
+ createTranscriptionPolicyResolver,
2932
+ documents,
1499
2933
  extractMediaDescriptor,
1500
2934
  flowGraphs,
2935
+ flowMedia,
2936
+ isAudioMimeType,
2937
+ isRetriableTranscriptionError,
2938
+ isUnsupportedTranscriptionError,
1501
2939
  issueSseTicket,
1502
2940
  messages,
1503
2941
  metaWhatsAppMigrationsFolder,
1504
2942
  metaWhatsAppSchema,
1505
2943
  redeemSseTicket,
2944
+ resolveFailureStatus,
2945
+ resolvePreviewUploadId,
1506
2946
  runMetaWhatsAppMigrations,
1507
2947
  sessions,
1508
2948
  settings,
2949
+ toPreviewMediaId,
2950
+ toSessionContract,
2951
+ transcriptionRetryAfterSeconds,
1509
2952
  verifyWebhookChallenge,
1510
2953
  verifyWebhookSignature
1511
2954
  };