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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.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,54 @@ 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
+ // Id do arquivo já subido para a Meta, por número remetente. Sem isto, o MESMO binário subia de
255
+ // novo para cada cliente que passava pelo nó: a Meta aceita reusar o id por 30 dias.
256
+ //
257
+ // Mapa por `phone_number_id`, e não coluna única: o id é escopado ao número que envia, e uma
258
+ // instalação com dois números mandaria o id de um pelo outro. A validade não é gravada de
259
+ // propósito — confiar em "30 dias" calculados erra nos casos de borda, e quem decide é a
260
+ // recusa da Meta, que devolve ao caminho de subir o binário.
261
+ metaMediaIds: jsonb("meta_media_ids").$type().notNull().default({}),
262
+ createdAt: timestamp("created_at", {
263
+ withTimezone: true
264
+ }).notNull().defaultNow(),
265
+ updatedAt: timestamp("updated_at", {
266
+ withTimezone: true
267
+ }).notNull().defaultNow()
268
+ }, (table) => [
269
+ index("idx_flow_media_node").on(table.companyId, table.flowKey, table.nodeId, table.sortOrder),
270
+ // O mesmo arquivo anexado duas vezes ao MESMO nó é erro de clique no editor, e o cliente
271
+ // receberia o documento repetido.
272
+ uniqueIndex("idx_flow_media_node_upload").on(table.companyId, table.flowKey, table.nodeId, table.uploadId)
273
+ ]);
146
274
  var settings = metaWhatsAppSchema.table("settings", {
147
275
  companyId: uuid("company_id").primaryKey(),
148
276
  templateName: varchar("template_name", {
@@ -155,6 +283,15 @@ var settings = metaWhatsAppSchema.table("settings", {
155
283
  templateVariables: jsonb("template_variables").$type().notNull().default([]),
156
284
  welcomeMessage: text("welcome_message"),
157
285
  farewellMessage: text("farewell_message"),
286
+ /**
287
+ * Política de transcrição desta empresa. Nulo é significativo: "o painel não decidiu", e aí vale
288
+ * o padrão que o host injetou. Sem a distinção, atualizar o módulo desligaria a transcrição de
289
+ * quem já a tinha ligada por ambiente.
290
+ */
291
+ transcriptionEnabled: boolean("transcription_enabled"),
292
+ transcriptionMode: varchar("transcription_mode", {
293
+ length: 16
294
+ }).$type(),
158
295
  createdAt: timestamp("created_at", {
159
296
  withTimezone: true
160
297
  }).notNull().defaultNow(),
@@ -165,6 +302,31 @@ var settings = metaWhatsAppSchema.table("settings", {
165
302
 
166
303
  // src/repositories/SessionRepository.ts
167
304
  var DEFAULT_LIMIT = 20;
305
+ var conversationSummaryProjection = {
306
+ lastContent: sql2`(
307
+ select m.content from ${messages} m
308
+ where m.company_id = ${sessions}.company_id and m.session_id = ${sessions}.id
309
+ order by m.created_at desc limit 1
310
+ )`,
311
+ lastDirection: sql2`(
312
+ select m.direction from ${messages} m
313
+ where m.company_id = ${sessions}.company_id and m.session_id = ${sessions}.id
314
+ order by m.created_at desc limit 1
315
+ )`,
316
+ // Entradas do cliente depois da última leitura do atendente. Sessão nunca lida conta
317
+ // tudo — é o comportamento esperado de uma conversa que ninguém abriu ainda.
318
+ unread: sql2`(
319
+ select count(*)::int from ${messages} m
320
+ where m.company_id = ${sessions}.company_id
321
+ and m.session_id = ${sessions}.id
322
+ and m.direction = 'inbound'
323
+ and (${sessions}.last_agent_read_at is null or m.created_at > ${sessions}.last_agent_read_at)
324
+ )`
325
+ };
326
+ function sessionContextPatch(patch) {
327
+ return sql2`${sessions.context} || ${JSON.stringify(patch)}::jsonb`;
328
+ }
329
+ __name(sessionContextPatch, "sessionContextPatch");
168
330
  var SessionRepository = class {
169
331
  static {
170
332
  __name(this, "SessionRepository");
@@ -195,6 +357,9 @@ var SessionRepository = class {
195
357
  }).returning();
196
358
  return created;
197
359
  }
360
+ // O `context` jsonb é o ponto de extensão oficial para estado de sessão por produto: o módulo
361
+ // não conhece a forma, o consumidor a declara em TSessionContext. Este setter SUBSTITUI o
362
+ // objeto inteiro — para acumular respostas ao longo da conversa use patchContext.
198
363
  async setState(companyId, whatsappNumber, state, context) {
199
364
  await this.db.update(sessions).set({
200
365
  currentState: state,
@@ -205,6 +370,25 @@ var SessionRepository = class {
205
370
  updatedAt: sql2`now()`
206
371
  }).where(and(eq(sessions.companyId, companyId), eq(sessions.whatsappNumber, whatsappNumber)));
207
372
  }
373
+ // Mescla parcial do context, feita no banco (`||`) e não por read-modify-write no host: duas
374
+ // mensagens do mesmo cliente processadas em paralelo sobrescreveriam uma à outra, e o campo
375
+ // acumula justamente as respostas coletadas ao longo da conversa. Chave presente no patch
376
+ // vence a existente; as demais permanecem.
377
+ async patchContext(companyId, whatsappNumber, patch) {
378
+ await this.db.update(sessions).set({
379
+ context: sessionContextPatch(patch),
380
+ lastActivity: sql2`now()`,
381
+ updatedAt: sql2`now()`
382
+ }).where(and(eq(sessions.companyId, companyId), eq(sessions.whatsappNumber, whatsappNumber)));
383
+ }
384
+ // Leitura tipada do estado de sessão do produto. Devolve undefined quando a sessão não existe —
385
+ // distinto de existir com context vazio, que devolve o objeto vazio.
386
+ async readContext(companyId, whatsappNumber) {
387
+ const [row] = await this.db.select({
388
+ context: sessions.context
389
+ }).from(sessions).where(and(eq(sessions.companyId, companyId), eq(sessions.whatsappNumber, whatsappNumber))).limit(1);
390
+ return row?.context;
391
+ }
208
392
  // Posição no grafo de fluxo — chamado pelo host a cada transição do FlowInterpreter.
209
393
  // Passar null em ambos desliga o rastreio (ex.: conversa saiu do motor de fluxo).
210
394
  async setFlowPosition(companyId, whatsappNumber, flowKey, currentNodeId) {
@@ -247,6 +431,16 @@ var SessionRepository = class {
247
431
  async release(companyId, whatsappNumber) {
248
432
  await this.setMode(companyId, whatsappNumber, "bot", null);
249
433
  }
434
+ /**
435
+ * Apaga a sessão; a cascata das FKs leva mensagens e documentos.
436
+ *
437
+ * Não apaga o binário no storage — isso é passo de aplicação, e é por isso que este método é
438
+ * chamado por `DeleteConversationUseCase` e não diretamente pelo host. Chamar daqui sem apagar os
439
+ * objetos antes deixa mídia órfã sendo cobrada para sempre.
440
+ */
441
+ async deleteByNumber(companyId, whatsappNumber) {
442
+ await this.db.delete(sessions).where(and(eq(sessions.companyId, companyId), eq(sessions.whatsappNumber, whatsappNumber)));
443
+ }
250
444
  async requestHuman(companyId, whatsappNumber) {
251
445
  await this.db.update(sessions).set({
252
446
  humanRequestedAt: sql2`now()`,
@@ -280,17 +474,28 @@ var SessionRepository = class {
280
474
  currentState: sessions.currentState,
281
475
  lastActivity: sessions.lastActivity,
282
476
  lastInboundAt: sessions.lastInboundAt,
283
- humanRequestedAt: sessions.humanRequestedAt
477
+ humanRequestedAt: sessions.humanRequestedAt,
478
+ // Prévia e contagem saem de subquery correlacionada em vez de N+1 na volta: uma inbox
479
+ // lista dezenas de conversas por página, e uma query por linha é o gargalo clássico
480
+ // dessa tela. Ambos os campos são dados do próprio módulo — deixá-los para o host
481
+ // obrigaria todo consumidor a reescrever o mesmo join contra tabelas que não são dele.
482
+ ...conversationSummaryProjection
284
483
  }).from(sessions).where(and(...conditions)).orderBy(desc(sessions.lastActivity)).limit(limit).offset(offset);
285
484
  return rows.map((row) => ({
286
485
  id: row.id,
287
486
  whatsappNumber: row.whatsappNumber,
487
+ ...row.lastContent !== null ? {
488
+ lastContent: row.lastContent
489
+ } : {},
490
+ ...row.lastDirection !== null ? {
491
+ lastDirection: row.lastDirection
492
+ } : {},
288
493
  lastAt: row.lastActivity.toISOString(),
289
494
  lastInboundAt: row.lastInboundAt?.toISOString() ?? null,
290
495
  mode: row.mode,
291
496
  assignedUserId: row.assignedUserId,
292
497
  waitingHuman: row.humanRequestedAt !== null,
293
- unread: 0,
498
+ unread: Number(row.unread),
294
499
  currentState: row.currentState
295
500
  }));
296
501
  }
@@ -334,7 +539,9 @@ var MessageRepository = class {
334
539
  content: params.content ?? null,
335
540
  payload: params.payload ?? null,
336
541
  waMessageId: params.waMessageId ?? null,
337
- status: params.status ?? null
542
+ status: params.status ?? null,
543
+ moderationFlagged: params.moderationFlagged ?? null,
544
+ moderationTerms: params.moderationTerms ?? null
338
545
  };
339
546
  const [created] = await this.db.insert(messages).values(values).onConflictDoNothing().returning();
340
547
  return created;
@@ -348,6 +555,58 @@ var MessageRepository = class {
348
555
  }).where(and2(eq2(messages.companyId, companyId), eq2(messages.waMessageId, waMessageId))).returning();
349
556
  return updated;
350
557
  }
558
+ /**
559
+ * Grava a transcrição endereçando pelo id da Meta, para quem só tem esse.
560
+ *
561
+ * Serve ao caso em que a transcrição acontece no próprio webhook — o grafo precisa do texto para
562
+ * responder ao cliente, e jogar fora o que ele já pagou para transcrever significaria transcrever
563
+ * o mesmo áudio uma segunda vez só para o painel ver.
564
+ *
565
+ * Devolve `undefined` quando não achou a mensagem: entrega duplicada e mensagem apagada são
566
+ * corridas normais, não erro.
567
+ */
568
+ async saveTranscriptionByWaMessageId(params) {
569
+ const [updated] = await this.db.update(messages).set({
570
+ transcriptionStatus: params.status,
571
+ ...params.text !== void 0 ? {
572
+ transcriptionText: params.text
573
+ } : {},
574
+ ...params.language !== void 0 ? {
575
+ transcriptionLanguage: params.language
576
+ } : {},
577
+ ...params.engine !== void 0 ? {
578
+ transcriptionEngine: params.engine
579
+ } : {}
580
+ }).where(and2(eq2(messages.companyId, params.companyId), eq2(messages.waMessageId, params.waMessageId))).returning();
581
+ return updated;
582
+ }
583
+ async findById(companyId, messageId) {
584
+ const [found] = await this.db.select().from(messages).where(and2(eq2(messages.companyId, companyId), eq2(messages.id, messageId))).limit(1);
585
+ return found;
586
+ }
587
+ /**
588
+ * Grava o resultado da transcrição. Devolve `undefined` quando a mensagem não existe (apagada
589
+ * entre o enfileiramento e a execução do job) — não é erro, é corrida normal.
590
+ *
591
+ * `text`/`language`/`engine` só são tocados quando informados: uma retentativa que volta a falhar
592
+ * atualiza o status sem apagar a transcrição parcial de uma tentativa anterior que tenha vindo de
593
+ * outro engine da cadeia.
594
+ */
595
+ async saveTranscription(params) {
596
+ const [updated] = await this.db.update(messages).set({
597
+ transcriptionStatus: params.status,
598
+ ...params.text !== void 0 ? {
599
+ transcriptionText: params.text
600
+ } : {},
601
+ ...params.language !== void 0 ? {
602
+ transcriptionLanguage: params.language
603
+ } : {},
604
+ ...params.engine !== void 0 ? {
605
+ transcriptionEngine: params.engine
606
+ } : {}
607
+ }).where(and2(eq2(messages.companyId, params.companyId), eq2(messages.id, params.messageId))).returning();
608
+ return updated;
609
+ }
351
610
  async listByConversation(params) {
352
611
  const conditions = [
353
612
  eq2(messages.companyId, params.companyId),
@@ -397,12 +656,21 @@ var FlowGraphRepository = class {
397
656
  __name(this, "FlowGraphRepository");
398
657
  }
399
658
  db;
400
- constructor(db) {
659
+ cache;
660
+ // Cache opcional: sem ele o repositório se comporta exatamente como antes, lendo sempre do
661
+ // banco. É o host que decide se quer cachear e com qual provedor (ver CacheInterface).
662
+ constructor(db, cache) {
401
663
  this.db = db;
664
+ this.cache = cache;
402
665
  }
403
666
  async get(companyId, key) {
667
+ const cached = await this.cache?.read(companyId, key);
668
+ if (cached) return cached;
404
669
  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;
670
+ if (!row) return void 0;
671
+ const graph = toContractGraph(row);
672
+ await this.cache?.write(companyId, graph);
673
+ return graph;
406
674
  }
407
675
  async list(companyId) {
408
676
  const rows = await this.db.select().from(flowGraphs).where(eq3(flowGraphs.companyId, companyId));
@@ -432,7 +700,9 @@ var FlowGraphRepository = class {
432
700
  showInMenu: graph.showInMenu ?? false,
433
701
  menuOptionLabel: graph.menuOptionLabel
434
702
  }).returning();
435
- return toContractGraph(created);
703
+ const createdGraph = toContractGraph(created);
704
+ await this.cache?.invalidate(companyId, createdGraph.key);
705
+ return createdGraph;
436
706
  }
437
707
  // Lock otimista: a escrita só aplica se `expectedVersion` ainda bater com o que está salvo —
438
708
  // senão, alguém mais salvou entretanto e o editor precisa recarregar (ver comentário no schema).
@@ -446,10 +716,12 @@ var FlowGraphRepository = class {
446
716
  updatedAt: /* @__PURE__ */ new Date()
447
717
  }).where(and3(eq3(flowGraphs.companyId, companyId), eq3(flowGraphs.key, graph.key), eq3(flowGraphs.version, expectedVersion))).returning();
448
718
  if (rows.length === 0) throw new OptimisticLockError(graph.key);
719
+ await this.cache?.invalidate(companyId, graph.key);
449
720
  return toContractGraph(rows[0]);
450
721
  }
451
722
  async delete(companyId, key) {
452
723
  await this.db.delete(flowGraphs).where(and3(eq3(flowGraphs.companyId, companyId), eq3(flowGraphs.key, key)));
724
+ await this.cache?.invalidate(companyId, key);
453
725
  }
454
726
  // T4.2 — GetLiveFlowPositions: agrega sessões ativas por (flowKey, currentNodeId), lendo as
455
727
  // colunas dedicadas gravadas por SessionRepository.setFlowPosition. Agrega no banco (GROUP BY,
@@ -469,6 +741,48 @@ var FlowGraphRepository = class {
469
741
  }
470
742
  };
471
743
 
744
+ // src/repositories/FlowGraphCache.ts
745
+ var KEY_PREFIX = "meta-whatsapp:flow-graph";
746
+ var DEFAULT_FLOW_GRAPH_CACHE_TTL_SECONDS = 300;
747
+ var FlowGraphCache = class {
748
+ static {
749
+ __name(this, "FlowGraphCache");
750
+ }
751
+ provider;
752
+ ttlSeconds;
753
+ constructor(provider, ttlSeconds = DEFAULT_FLOW_GRAPH_CACHE_TTL_SECONDS) {
754
+ this.provider = provider;
755
+ this.ttlSeconds = ttlSeconds;
756
+ }
757
+ // companyId na chave, e não só a flowKey: a chave do fluxo é escolhida por quem edita e se
758
+ // repete entre empresas — 'consorcio' existe em todas — então uma chave sem tenant serviria o
759
+ // grafo de uma empresa para a conversa de outra.
760
+ keyFor(companyId, flowKey) {
761
+ return `${KEY_PREFIX}:${companyId}:${flowKey}`;
762
+ }
763
+ async read(companyId, flowKey) {
764
+ try {
765
+ const cached = await this.provider.get(this.keyFor(companyId, flowKey));
766
+ if (!cached) return void 0;
767
+ return JSON.parse(cached);
768
+ } catch {
769
+ return void 0;
770
+ }
771
+ }
772
+ async write(companyId, graph) {
773
+ try {
774
+ await this.provider.set(this.keyFor(companyId, graph.key), JSON.stringify(graph), this.ttlSeconds);
775
+ } catch {
776
+ }
777
+ }
778
+ async invalidate(companyId, flowKey) {
779
+ try {
780
+ await this.provider.delete(this.keyFor(companyId, flowKey));
781
+ } catch {
782
+ }
783
+ }
784
+ };
785
+
472
786
  // src/repositories/SettingsRepository.ts
473
787
  import { eq as eq4 } from "drizzle-orm";
474
788
  function toContractSettings(row) {
@@ -477,7 +791,11 @@ function toContractSettings(row) {
477
791
  templateLanguage: row.templateLanguage,
478
792
  templateVariables: row.templateVariables,
479
793
  welcomeMessage: row.welcomeMessage ?? "",
480
- farewellMessage: row.farewellMessage ?? ""
794
+ farewellMessage: row.farewellMessage ?? "",
795
+ // `?? null` e não `?? false`: nulo é "o painel não decidiu", e é o que faz valer o padrão do
796
+ // host. Colapsar para `false` desligaria quem ligou por ambiente.
797
+ transcriptionEnabled: row.transcriptionEnabled ?? null,
798
+ transcriptionMode: row.transcriptionMode ?? null
481
799
  };
482
800
  }
483
801
  __name(toContractSettings, "toContractSettings");
@@ -486,7 +804,9 @@ var EMPTY_SETTINGS = {
486
804
  templateLanguage: "pt_BR",
487
805
  templateVariables: [],
488
806
  welcomeMessage: "",
489
- farewellMessage: ""
807
+ farewellMessage: "",
808
+ transcriptionEnabled: null,
809
+ transcriptionMode: null
490
810
  };
491
811
  var SettingsRepository = class {
492
812
  static {
@@ -515,7 +835,12 @@ var SettingsRepository = class {
515
835
  templateLanguage: merged.templateLanguage,
516
836
  templateVariables: merged.templateVariables,
517
837
  welcomeMessage: merged.welcomeMessage || null,
518
- farewellMessage: merged.farewellMessage || null
838
+ farewellMessage: merged.farewellMessage || null,
839
+ // Sem `|| null`: `false` aqui é decisão explícita do painel ("desligado para esta empresa"),
840
+ // e colapsá-lo para nulo faria a empresa voltar a herdar o padrão do host — exatamente o
841
+ // oposto do que o operador acabou de pedir.
842
+ transcriptionEnabled: merged.transcriptionEnabled,
843
+ transcriptionMode: merged.transcriptionMode
519
844
  }).onConflictDoUpdate({
520
845
  target: settings.companyId,
521
846
  set: {
@@ -524,6 +849,8 @@ var SettingsRepository = class {
524
849
  templateVariables: merged.templateVariables,
525
850
  welcomeMessage: merged.welcomeMessage || null,
526
851
  farewellMessage: merged.farewellMessage || null,
852
+ transcriptionEnabled: merged.transcriptionEnabled,
853
+ transcriptionMode: merged.transcriptionMode,
527
854
  updatedAt: /* @__PURE__ */ new Date()
528
855
  }
529
856
  }).returning();
@@ -550,16 +877,19 @@ var LogMessageUseCase = class {
550
877
  sessionRepository;
551
878
  messageRepository;
552
879
  realtime;
553
- constructor(sessionRepository, messageRepository, realtime) {
880
+ moderator;
881
+ constructor(sessionRepository, messageRepository, realtime, moderator) {
554
882
  this.sessionRepository = sessionRepository;
555
883
  this.messageRepository = messageRepository;
556
884
  this.realtime = realtime;
885
+ this.moderator = moderator;
557
886
  }
558
887
  async execute(params) {
559
888
  const session = await this.sessionRepository.getOrCreate(params.companyId, params.whatsappNumber, params.startState);
560
889
  const saved = await this.messageRepository.insertMessage({
561
890
  ...params,
562
- sessionId: session.id
891
+ sessionId: session.id,
892
+ ...this.moderationOf(params)
563
893
  });
564
894
  if (!saved) return void 0;
565
895
  if (params.direction === "inbound") {
@@ -572,6 +902,20 @@ var LogMessageUseCase = class {
572
902
  this.realtime?.emit("global", "data-changed", {});
573
903
  return saved;
574
904
  }
905
+ // Só o que o cliente escreveu: marcar o que o próprio atendente ou o bot enviou não sinaliza
906
+ // abuso, apenas sujaria o transcript com etiqueta na resposta de quem atende.
907
+ moderationOf(params) {
908
+ if (!this.moderator || params.direction !== "inbound") return {};
909
+ const text2 = params.content?.trim();
910
+ if (!text2) return {};
911
+ const verdict = this.moderator.inspect(text2);
912
+ return {
913
+ moderationFlagged: verdict.isOffensive,
914
+ moderationTerms: verdict.isOffensive ? [
915
+ ...verdict.matchedTerms
916
+ ] : null
917
+ };
918
+ }
575
919
  };
576
920
 
577
921
  // src/use-cases/SendMessage.use-case.ts
@@ -585,11 +929,13 @@ var SendMessageUseCase = class {
585
929
  sessionRepository;
586
930
  logMessage;
587
931
  objectStorage;
588
- constructor(channel, sessionRepository, logMessage, objectStorage) {
932
+ documentRepository;
933
+ constructor(channel, sessionRepository, logMessage, objectStorage, documentRepository) {
589
934
  this.channel = channel;
590
935
  this.sessionRepository = sessionRepository;
591
936
  this.logMessage = logMessage;
592
937
  this.objectStorage = objectStorage;
938
+ this.documentRepository = documentRepository;
593
939
  }
594
940
  async assertWithinWindow(companyId, whatsappNumber) {
595
941
  const hours = await this.sessionRepository.hoursSinceLastInbound(companyId, whatsappNumber);
@@ -626,7 +972,7 @@ var SendMessageUseCase = class {
626
972
  mimeType: params.mimeType,
627
973
  key: `meta-whatsapp/${params.companyId}/${Date.now()}-${params.filename}`
628
974
  })).uploadId : void 0;
629
- return this.logMessage.execute({
975
+ const saved = await this.logMessage.execute({
630
976
  companyId: params.companyId,
631
977
  whatsappNumber: params.whatsappNumber,
632
978
  direction: "outbound",
@@ -645,6 +991,19 @@ var SendMessageUseCase = class {
645
991
  status: "sent",
646
992
  startState: params.startState
647
993
  });
994
+ if (saved && uploadId) {
995
+ await this.documentRepository?.link({
996
+ companyId: params.companyId,
997
+ sessionId: saved.sessionId,
998
+ messageId: saved.id,
999
+ uploadId,
1000
+ filename: params.filename,
1001
+ mimeType: params.mimeType,
1002
+ sizeBytes: params.buffer.length,
1003
+ source: params.sender
1004
+ });
1005
+ }
1006
+ return saved;
648
1007
  }
649
1008
  // Template é o único envio que ignora a janela — é justamente o mecanismo que a Meta oferece
650
1009
  // para reabri-la.
@@ -762,8 +1121,330 @@ var ListMessagesUseCase = class {
762
1121
  }
763
1122
  };
764
1123
 
765
- // src/use-cases/ExportConversation.use-case.ts
1124
+ // src/use-cases/ListConversationDocuments.use-case.ts
766
1125
  import { SessionNotFoundError as SessionNotFoundError2 } from "@adatechnology/meta-whatsapp-contracts";
1126
+ var ListConversationDocumentsUseCase = class {
1127
+ static {
1128
+ __name(this, "ListConversationDocumentsUseCase");
1129
+ }
1130
+ sessionRepository;
1131
+ documentRepository;
1132
+ constructor(sessionRepository, documentRepository) {
1133
+ this.sessionRepository = sessionRepository;
1134
+ this.documentRepository = documentRepository;
1135
+ }
1136
+ async execute(params) {
1137
+ const session = await this.sessionRepository.getContext(params.companyId, params.whatsappNumber);
1138
+ if (!session) throw new SessionNotFoundError2(params.whatsappNumber);
1139
+ const { rows, total } = await this.documentRepository.listByConversation({
1140
+ companyId: params.companyId,
1141
+ sessionId: session.id,
1142
+ ...params.search ? {
1143
+ search: params.search
1144
+ } : {},
1145
+ ...params.sources && params.sources.length > 0 ? {
1146
+ sources: params.sources
1147
+ } : {},
1148
+ ...params.sortDirection ? {
1149
+ sortDirection: params.sortDirection
1150
+ } : {},
1151
+ ...params.page ? {
1152
+ page: params.page
1153
+ } : {},
1154
+ ...params.limit ? {
1155
+ limit: params.limit
1156
+ } : {}
1157
+ });
1158
+ return {
1159
+ documents: rows.map((row) => ({
1160
+ id: row.uploadId,
1161
+ filename: row.filename,
1162
+ mimeType: row.mimeType,
1163
+ sizeBytes: row.sizeBytes,
1164
+ source: row.source,
1165
+ linkedAt: row.linkedAt.toISOString()
1166
+ })),
1167
+ total
1168
+ };
1169
+ }
1170
+ };
1171
+
1172
+ // src/use-cases/ListCompanyDocuments.use-case.ts
1173
+ var ListCompanyDocumentsUseCase = class {
1174
+ static {
1175
+ __name(this, "ListCompanyDocumentsUseCase");
1176
+ }
1177
+ documentRepository;
1178
+ constructor(documentRepository) {
1179
+ this.documentRepository = documentRepository;
1180
+ }
1181
+ async execute(params) {
1182
+ const { rows, total } = await this.documentRepository.listByCompany({
1183
+ companyId: params.companyId,
1184
+ ...params.search ? {
1185
+ search: params.search
1186
+ } : {},
1187
+ ...params.sources && params.sources.length > 0 ? {
1188
+ sources: params.sources
1189
+ } : {},
1190
+ ...params.sortDirection ? {
1191
+ sortDirection: params.sortDirection
1192
+ } : {},
1193
+ ...params.page ? {
1194
+ page: params.page
1195
+ } : {},
1196
+ ...params.limit ? {
1197
+ limit: params.limit
1198
+ } : {}
1199
+ });
1200
+ return {
1201
+ documents: rows.map((row) => ({
1202
+ id: row.uploadId,
1203
+ conversationId: row.whatsappNumber,
1204
+ filename: row.filename,
1205
+ mimeType: row.mimeType,
1206
+ sizeBytes: row.sizeBytes,
1207
+ source: row.source,
1208
+ linkedAt: row.linkedAt.toISOString()
1209
+ })),
1210
+ total
1211
+ };
1212
+ }
1213
+ };
1214
+
1215
+ // src/use-cases/DeleteConversation.use-case.ts
1216
+ import { SessionNotFoundError as SessionNotFoundError3 } from "@adatechnology/meta-whatsapp-contracts";
1217
+ var DeleteConversationUseCase = class {
1218
+ static {
1219
+ __name(this, "DeleteConversationUseCase");
1220
+ }
1221
+ sessionRepository;
1222
+ documentRepository;
1223
+ objectStorage;
1224
+ constructor(sessionRepository, documentRepository, objectStorage) {
1225
+ this.sessionRepository = sessionRepository;
1226
+ this.documentRepository = documentRepository;
1227
+ this.objectStorage = objectStorage;
1228
+ }
1229
+ async execute(params) {
1230
+ const session = await this.sessionRepository.getContext(params.companyId, params.whatsappNumber);
1231
+ if (!session) throw new SessionNotFoundError3(params.whatsappNumber);
1232
+ const uploadIds = await this.documentRepository.listUploadIdsBySession(params.companyId, session.id);
1233
+ if (uploadIds.length > 0 && !this.objectStorage?.delete) {
1234
+ throw new Error("storage_delete_unsupported: a conversa tem arquivos e o storage injetado n\xE3o implementa delete");
1235
+ }
1236
+ const failedObjects = [];
1237
+ let deletedObjects = 0;
1238
+ for (const uploadId of uploadIds) {
1239
+ try {
1240
+ await this.objectStorage?.delete?.(uploadId);
1241
+ deletedObjects++;
1242
+ } catch {
1243
+ failedObjects.push(uploadId);
1244
+ }
1245
+ }
1246
+ if (failedObjects.length > 0) return {
1247
+ deletedObjects,
1248
+ failedObjects
1249
+ };
1250
+ await this.sessionRepository.deleteByNumber(params.companyId, params.whatsappNumber);
1251
+ return {
1252
+ deletedObjects,
1253
+ failedObjects: []
1254
+ };
1255
+ }
1256
+ };
1257
+
1258
+ // src/use-cases/PurgeExpiredDocuments.use-case.ts
1259
+ var DEFAULT_BATCH_SIZE = 50;
1260
+ var HOURS_IN_DAY = 24;
1261
+ var MILLISECONDS_IN_HOUR = 60 * 60 * 1e3;
1262
+ var PurgeExpiredDocumentsUseCase = class {
1263
+ static {
1264
+ __name(this, "PurgeExpiredDocumentsUseCase");
1265
+ }
1266
+ documentRepository;
1267
+ objectStorage;
1268
+ constructor(documentRepository, objectStorage) {
1269
+ this.documentRepository = documentRepository;
1270
+ this.objectStorage = objectStorage;
1271
+ }
1272
+ async execute(params) {
1273
+ if (!this.objectStorage?.delete) {
1274
+ throw new Error("storage_delete_unsupported: reten\xE7\xE3o exige um storage que implemente delete");
1275
+ }
1276
+ const reference = params.now ?? /* @__PURE__ */ new Date();
1277
+ const olderThan = new Date(reference.getTime() - params.retentionDays * HOURS_IN_DAY * MILLISECONDS_IN_HOUR);
1278
+ const expired = await this.documentRepository.listExpired(params.companyId, olderThan, params.batchSize ?? DEFAULT_BATCH_SIZE);
1279
+ const failed = [];
1280
+ let purged = 0;
1281
+ for (const document of expired) {
1282
+ try {
1283
+ await this.objectStorage.delete(document.uploadId);
1284
+ } catch {
1285
+ failed.push(document.uploadId);
1286
+ continue;
1287
+ }
1288
+ await this.documentRepository.deleteById(params.companyId, document.id);
1289
+ purged++;
1290
+ }
1291
+ return {
1292
+ purged,
1293
+ failed
1294
+ };
1295
+ }
1296
+ };
1297
+
1298
+ // src/repositories/DocumentRepository.ts
1299
+ import { and as and4, asc, count, desc as desc2, eq as eq5, ilike, inArray, lt as lt2, or } from "drizzle-orm";
1300
+ var DEFAULT_LIMIT3 = 50;
1301
+ function companyDocumentSearch(search) {
1302
+ const term = search?.trim();
1303
+ if (!term) return void 0;
1304
+ const digits = term.replace(/\D/g, "");
1305
+ const byFilename = ilike(documents.filename, `%${term}%`);
1306
+ if (!digits) return byFilename;
1307
+ return or(byFilename, ilike(sessions.whatsappNumber, `%${digits}%`));
1308
+ }
1309
+ __name(companyDocumentSearch, "companyDocumentSearch");
1310
+ var DocumentRepository = class {
1311
+ static {
1312
+ __name(this, "DocumentRepository");
1313
+ }
1314
+ db;
1315
+ constructor(db) {
1316
+ this.db = db;
1317
+ }
1318
+ /**
1319
+ * Idempotente por (companyId, uploadId), garantido pelo índice único e não por SELECT prévio: o
1320
+ * job de ingestão é reentregue por retry e duas tentativas concorrentes passariam as duas por uma
1321
+ * checagem, duplicando a linha no painel.
1322
+ *
1323
+ * Devolve `undefined` quando o documento já estava linkado.
1324
+ */
1325
+ async link(params) {
1326
+ const values = {
1327
+ companyId: params.companyId,
1328
+ sessionId: params.sessionId,
1329
+ messageId: params.messageId ?? null,
1330
+ uploadId: params.uploadId,
1331
+ filename: params.filename,
1332
+ mimeType: params.mimeType,
1333
+ sizeBytes: params.sizeBytes,
1334
+ sha256: params.sha256 ?? null,
1335
+ source: params.source
1336
+ };
1337
+ const [created] = await this.db.insert(documents).values(values).onConflictDoNothing().returning();
1338
+ return created;
1339
+ }
1340
+ async listByConversation(params) {
1341
+ const filters = [
1342
+ eq5(documents.companyId, params.companyId),
1343
+ eq5(documents.sessionId, params.sessionId)
1344
+ ];
1345
+ if (params.search) filters.push(ilike(documents.filename, `%${params.search}%`));
1346
+ if (params.sources && params.sources.length > 0) {
1347
+ filters.push(inArray(documents.source, [
1348
+ ...params.sources
1349
+ ]));
1350
+ }
1351
+ const where = and4(...filters);
1352
+ const limit = params.limit ?? DEFAULT_LIMIT3;
1353
+ const page = params.page && params.page > 0 ? params.page : 1;
1354
+ const orderBy = params.sortDirection === "asc" ? asc(documents.linkedAt) : desc2(documents.linkedAt);
1355
+ const [rows, counted] = await Promise.all([
1356
+ this.db.select().from(documents).where(where).orderBy(orderBy).limit(limit).offset((page - 1) * limit),
1357
+ this.db.select({
1358
+ value: count()
1359
+ }).from(documents).where(where)
1360
+ ]);
1361
+ return {
1362
+ rows,
1363
+ total: counted[0]?.value ?? 0
1364
+ };
1365
+ }
1366
+ /**
1367
+ * A biblioteca da EMPRESA inteira, não de uma conversa.
1368
+ *
1369
+ * A busca casa nome do arquivo OU telefone da conversa — ver `companyDocumentSearch`.
1370
+ *
1371
+ * Faz join com `sessions` para carregar de qual conversa cada arquivo veio — numa lista global,
1372
+ * arquivo sem essa referência é inútil: o atendente vê "comprovante.pdf" e não sabe de quem.
1373
+ *
1374
+ * Ordena por `linkedAt` apoiada no índice `idx_documents_company_linked`, que já existia para a
1375
+ * varredura de retenção.
1376
+ */
1377
+ async listByCompany(params) {
1378
+ const filters = [
1379
+ eq5(documents.companyId, params.companyId)
1380
+ ];
1381
+ const search = companyDocumentSearch(params.search);
1382
+ if (search) filters.push(search);
1383
+ if (params.sources && params.sources.length > 0) {
1384
+ filters.push(inArray(documents.source, [
1385
+ ...params.sources
1386
+ ]));
1387
+ }
1388
+ const where = and4(...filters);
1389
+ const limit = params.limit ?? DEFAULT_LIMIT3;
1390
+ const page = params.page && params.page > 0 ? params.page : 1;
1391
+ const orderBy = params.sortDirection === "asc" ? asc(documents.linkedAt) : desc2(documents.linkedAt);
1392
+ const [rows, counted] = await Promise.all([
1393
+ this.db.select({
1394
+ id: documents.id,
1395
+ uploadId: documents.uploadId,
1396
+ filename: documents.filename,
1397
+ mimeType: documents.mimeType,
1398
+ sizeBytes: documents.sizeBytes,
1399
+ source: documents.source,
1400
+ linkedAt: documents.linkedAt,
1401
+ // Só o número: o NOME do cliente é dado do produto (tabela própria dele), não do
1402
+ // módulo. Quem quiser exibir "Marina Alves" enriquece na borda.
1403
+ whatsappNumber: sessions.whatsappNumber
1404
+ }).from(documents).innerJoin(sessions, eq5(documents.sessionId, sessions.id)).where(where).orderBy(orderBy).limit(limit).offset((page - 1) * limit),
1405
+ // O mesmo join da listagem, e não só `from(documents)`: a busca pode citar
1406
+ // `sessions.whatsapp_number`, e uma contagem sem a tabela na cláusula não compila — pior,
1407
+ // se compilasse, o total divergiria das linhas e a paginação prometeria páginas vazias.
1408
+ this.db.select({
1409
+ value: count()
1410
+ }).from(documents).innerJoin(sessions, eq5(documents.sessionId, sessions.id)).where(where)
1411
+ ]);
1412
+ return {
1413
+ rows,
1414
+ total: counted[0]?.value ?? 0
1415
+ };
1416
+ }
1417
+ /**
1418
+ * Um documento pela key do objeto. Serve para recuperar o nome original na hora de assinar o
1419
+ * download: a key é caminho no bucket e salvaria o arquivo com o id da Meta.
1420
+ */
1421
+ async findByUploadId(companyId, uploadId) {
1422
+ const [row] = await this.db.select().from(documents).where(and4(eq5(documents.companyId, companyId), eq5(documents.uploadId, uploadId))).limit(1);
1423
+ return row;
1424
+ }
1425
+ /**
1426
+ * Os objetos a apagar no storage antes de a linha sumir.
1427
+ *
1428
+ * Existe porque a cascata da FK apaga a linha e deixa o binário órfão: quem for apagar a conversa
1429
+ * precisa desta lista primeiro, senão paga armazenamento para sempre por arquivo inalcançável.
1430
+ */
1431
+ async listUploadIdsBySession(companyId, sessionId) {
1432
+ const rows = await this.db.select({
1433
+ uploadId: documents.uploadId
1434
+ }).from(documents).where(and4(eq5(documents.companyId, companyId), eq5(documents.sessionId, sessionId)));
1435
+ return rows.map((row) => row.uploadId);
1436
+ }
1437
+ /** Varredura de retenção por idade — o par é o mesmo cuidado com o objeto no storage. */
1438
+ async listExpired(companyId, olderThan, limit = DEFAULT_LIMIT3) {
1439
+ return this.db.select().from(documents).where(and4(eq5(documents.companyId, companyId), lt2(documents.linkedAt, olderThan))).orderBy(documents.linkedAt).limit(limit);
1440
+ }
1441
+ async deleteById(companyId, id) {
1442
+ await this.db.delete(documents).where(and4(eq5(documents.companyId, companyId), eq5(documents.id, id)));
1443
+ }
1444
+ };
1445
+
1446
+ // src/use-cases/ExportConversation.use-case.ts
1447
+ import { SessionNotFoundError as SessionNotFoundError4 } from "@adatechnology/meta-whatsapp-contracts";
767
1448
  var ExportConversationUseCase = class {
768
1449
  static {
769
1450
  __name(this, "ExportConversationUseCase");
@@ -774,7 +1455,7 @@ var ExportConversationUseCase = class {
774
1455
  }
775
1456
  async execute(params) {
776
1457
  const result = await this.sessionRepository.exportConversation(params.companyId, params.whatsappNumber);
777
- if (!result) throw new SessionNotFoundError2(params.whatsappNumber);
1458
+ if (!result) throw new SessionNotFoundError4(params.whatsappNumber);
778
1459
  return result;
779
1460
  }
780
1461
  };
@@ -1011,41 +1692,337 @@ var FlowInterpreter = class {
1011
1692
  }
1012
1693
  };
1013
1694
 
1014
- // src/channel/WhatsAppChannelAdapter.ts
1015
- import { WhatsAppWindowExpiredError as ProviderWindowExpiredError } from "@adatechnology/meta-graph-core";
1016
- import { WindowExpiredError as WindowExpiredError2 } from "@adatechnology/meta-whatsapp-contracts";
1017
- var WhatsAppChannelAdapter = class {
1018
- static {
1019
- __name(this, "WhatsAppChannelAdapter");
1020
- }
1021
- messages;
1022
- constructor(messages2) {
1023
- this.messages = messages2;
1695
+ // src/flows/createSendMediaAction.ts
1696
+ function mediaTypeFor2(mimeType) {
1697
+ if (mimeType.startsWith("image/")) return "image";
1698
+ if (mimeType.startsWith("audio/")) return "audio";
1699
+ if (mimeType.startsWith("video/")) return "video";
1700
+ return "document";
1701
+ }
1702
+ __name(mediaTypeFor2, "mediaTypeFor");
1703
+ async function sendAttachment(params) {
1704
+ const { attachment, channel, to, cache } = params;
1705
+ const common = {
1706
+ to,
1707
+ mimeType: attachment.mimeType,
1708
+ filename: attachment.filename,
1709
+ caption: attachment.caption ?? void 0
1710
+ };
1711
+ const cacheKey = cache ? {
1712
+ flowMediaId: attachment.id,
1713
+ senderKey: cache.senderKey
1714
+ } : void 0;
1715
+ if (cache && cacheKey) {
1716
+ const knownMediaId = await cache.store.get(cacheKey);
1717
+ if (knownMediaId) {
1718
+ try {
1719
+ return await channel.sendMedia({
1720
+ ...common,
1721
+ mediaId: knownMediaId
1722
+ });
1723
+ } catch {
1724
+ await cache.store.clear(cacheKey);
1725
+ }
1726
+ }
1024
1727
  }
1025
- async translateErrors(operation) {
1728
+ const buffer = await params.objectStorage.getObject(attachment.uploadId);
1729
+ const result = await channel.sendMedia({
1730
+ ...common,
1731
+ buffer
1732
+ });
1733
+ if (cache && cacheKey && result.mediaId) await cache.store.set({
1734
+ ...cacheKey,
1735
+ mediaId: result.mediaId
1736
+ });
1737
+ return result;
1738
+ }
1739
+ __name(sendAttachment, "sendAttachment");
1740
+ function createSendMediaAction(params) {
1741
+ return async ({ node, session, channel }) => {
1742
+ if (!session.flowKey) return;
1743
+ const location = {
1744
+ companyId: session.companyId,
1745
+ flowKey: session.flowKey,
1746
+ nodeId: node.id
1747
+ };
1748
+ const attachments = await params.flowMediaRepository.listActive(location);
1749
+ for (const attachment of attachments) {
1750
+ try {
1751
+ const { externalMessageId } = await sendAttachment({
1752
+ attachment,
1753
+ channel,
1754
+ to: session.whatsappNumber,
1755
+ objectStorage: params.objectStorage,
1756
+ ...params.mediaIdCache ? {
1757
+ cache: params.mediaIdCache
1758
+ } : {}
1759
+ });
1760
+ await params.logMessage.execute({
1761
+ companyId: session.companyId,
1762
+ whatsappNumber: session.whatsappNumber,
1763
+ direction: "outbound",
1764
+ sender: "bot",
1765
+ agentUserId: null,
1766
+ type: mediaTypeFor2(attachment.mimeType),
1767
+ content: attachment.caption ?? attachment.filename,
1768
+ payload: {
1769
+ filename: attachment.filename,
1770
+ mimeType: attachment.mimeType,
1771
+ uploadId: attachment.uploadId,
1772
+ flowMediaId: attachment.id
1773
+ },
1774
+ waMessageId: externalMessageId,
1775
+ status: "sent",
1776
+ startState: params.startState
1777
+ });
1778
+ } catch (error) {
1779
+ params.onError?.(error, {
1780
+ flowKey: location.flowKey,
1781
+ nodeId: node.id,
1782
+ uploadId: attachment.uploadId
1783
+ });
1784
+ }
1785
+ }
1786
+ };
1787
+ }
1788
+ __name(createSendMediaAction, "createSendMediaAction");
1789
+
1790
+ // src/flows/createSendProductListAction.ts
1791
+ var PRODUCT_LIST_LIMIT = {
1792
+ ITEMS: 30,
1793
+ SECTIONS: 10
1794
+ };
1795
+ function createSendProductListAction(params) {
1796
+ return async ({ node, session, channel }) => {
1797
+ if (!channel.sendProductList) return;
1798
+ const actionParams = node.actionParams ?? {};
1026
1799
  try {
1027
- return await operation();
1800
+ const available = await listAvailableProducts({
1801
+ catalog: params.catalog,
1802
+ catalogId: params.catalogId,
1803
+ ...actionParams.search ? {
1804
+ search: actionParams.search
1805
+ } : {}
1806
+ });
1807
+ if (available.length === 0) return;
1808
+ const bodyText = actionParams.bodyText ?? "Veja o que temos dispon\xEDvel:";
1809
+ const { externalMessageId } = await channel.sendProductList({
1810
+ to: session.whatsappNumber,
1811
+ headerText: actionParams.headerText ?? "Nossos produtos",
1812
+ body: bodyText,
1813
+ ...actionParams.footerText ? {
1814
+ footerText: actionParams.footerText
1815
+ } : {},
1816
+ sections: [
1817
+ {
1818
+ title: actionParams.sectionTitle ?? "Dispon\xEDveis",
1819
+ retailerIds: available.map((product) => product.retailerId)
1820
+ }
1821
+ ]
1822
+ });
1823
+ await params.logMessage.execute({
1824
+ companyId: session.companyId,
1825
+ whatsappNumber: session.whatsappNumber,
1826
+ direction: "outbound",
1827
+ sender: "bot",
1828
+ agentUserId: null,
1829
+ type: "interactive",
1830
+ content: bodyText,
1831
+ payload: {
1832
+ kind: "product_list",
1833
+ productCount: available.length
1834
+ },
1835
+ waMessageId: externalMessageId,
1836
+ status: "sent",
1837
+ startState: params.startState
1838
+ });
1028
1839
  } catch (error) {
1029
- if (error instanceof ProviderWindowExpiredError) throw new WindowExpiredError2();
1030
- throw error;
1840
+ params.onError?.(error, {
1841
+ flowKey: session.flowKey ?? "",
1842
+ nodeId: node.id
1843
+ });
1031
1844
  }
1845
+ };
1846
+ }
1847
+ __name(createSendProductListAction, "createSendProductListAction");
1848
+ async function listAvailableProducts(params) {
1849
+ const products = await params.catalog.listProducts({
1850
+ catalogId: params.catalogId,
1851
+ ...params.search ? {
1852
+ search: params.search
1853
+ } : {}
1854
+ });
1855
+ return products.filter((product) => product.availability === "in stock").slice(0, PRODUCT_LIST_LIMIT.ITEMS);
1856
+ }
1857
+ __name(listAvailableProducts, "listAvailableProducts");
1858
+
1859
+ // src/repositories/FlowMediaRepository.ts
1860
+ import { and as and5, asc as asc2, eq as eq6, sql as sql4 } from "drizzle-orm";
1861
+ var FlowMediaRepository = class {
1862
+ static {
1863
+ __name(this, "FlowMediaRepository");
1032
1864
  }
1033
- async sendText(to, body) {
1034
- const result = await this.translateErrors(() => this.messages.sendText(to, body));
1035
- return {
1036
- externalMessageId: result.waMessageId
1037
- };
1038
- }
1039
- async sendMedia(params) {
1040
- const result = await this.translateErrors(() => this.messages.sendMedia(params));
1041
- return {
1042
- externalMessageId: result.waMessageId
1043
- };
1865
+ db;
1866
+ constructor(db) {
1867
+ this.db = db;
1044
1868
  }
1045
- async sendTemplate(params) {
1046
- const result = await this.translateErrors(() => this.messages.sendTemplate(params));
1047
- return {
1048
- externalMessageId: result.waMessageId
1869
+ locationFilter(location) {
1870
+ return and5(eq6(flowMedia.companyId, location.companyId), eq6(flowMedia.flowKey, location.flowKey), eq6(flowMedia.nodeId, location.nodeId));
1871
+ }
1872
+ // O que o nó realmente envia, na ordem de envio. `active` filtrado aqui e não no chamador:
1873
+ // é a razão de a coluna existir, e um chamador que esquecesse do filtro mandaria ao cliente
1874
+ // justamente o material que alguém desligou.
1875
+ async listActive(location) {
1876
+ return this.db.select().from(flowMedia).where(and5(this.locationFilter(location), eq6(flowMedia.active, true))).orderBy(asc2(flowMedia.sortOrder), asc2(flowMedia.createdAt));
1877
+ }
1878
+ // Inclui os desligados — é a visão do editor, onde desligar precisa continuar visível para
1879
+ // poder ser religado.
1880
+ async listAll(location) {
1881
+ return this.db.select().from(flowMedia).where(this.locationFilter(location)).orderBy(asc2(flowMedia.sortOrder), asc2(flowMedia.createdAt));
1882
+ }
1883
+ /**
1884
+ * Anexa um arquivo já existente no storage ao nó.
1885
+ *
1886
+ * `onConflictDoUpdate` em vez de deixar estourar: reanexar o mesmo arquivo é clique repetido no
1887
+ * editor, e o esperado ali é atualizar a legenda/ordem — não um erro de índice único na cara de
1888
+ * quem está montando o fluxo.
1889
+ */
1890
+ async attach(params) {
1891
+ const [row] = await this.db.insert(flowMedia).values({
1892
+ companyId: params.companyId,
1893
+ flowKey: params.flowKey,
1894
+ nodeId: params.nodeId,
1895
+ uploadId: params.uploadId,
1896
+ filename: params.filename,
1897
+ mimeType: params.mimeType,
1898
+ sizeBytes: params.sizeBytes,
1899
+ caption: params.caption ?? null,
1900
+ sortOrder: params.sortOrder ?? 0
1901
+ }).onConflictDoUpdate({
1902
+ target: [
1903
+ flowMedia.companyId,
1904
+ flowMedia.flowKey,
1905
+ flowMedia.nodeId,
1906
+ flowMedia.uploadId
1907
+ ],
1908
+ set: {
1909
+ caption: params.caption ?? null,
1910
+ sortOrder: params.sortOrder ?? 0,
1911
+ active: true,
1912
+ updatedAt: sql4`now()`
1913
+ }
1914
+ }).returning();
1915
+ return row;
1916
+ }
1917
+ async update(params) {
1918
+ const [row] = await this.db.update(flowMedia).set({
1919
+ ...params.caption !== void 0 ? {
1920
+ caption: params.caption
1921
+ } : {},
1922
+ ...params.sortOrder !== void 0 ? {
1923
+ sortOrder: params.sortOrder
1924
+ } : {},
1925
+ ...params.active !== void 0 ? {
1926
+ active: params.active
1927
+ } : {},
1928
+ updatedAt: sql4`now()`
1929
+ }).where(and5(eq6(flowMedia.companyId, params.companyId), eq6(flowMedia.id, params.id))).returning();
1930
+ return row;
1931
+ }
1932
+ /**
1933
+ * Desanexa do nó. Não toca no storage de propósito: o mesmo `uploadId` pode estar anexado a
1934
+ * outro nó ou a outro fluxo, e apagar o binário aqui quebraria os demais. Quem apaga objeto é o
1935
+ * host, que é dono da biblioteca de arquivos.
1936
+ */
1937
+ async detach(params) {
1938
+ await this.db.delete(flowMedia).where(and5(eq6(flowMedia.companyId, params.companyId), eq6(flowMedia.id, params.id)));
1939
+ }
1940
+ // Chamado ao salvar o grafo: nós apagados no editor deixam linhas que nada mais alcança.
1941
+ async detachRemovedNodes(params) {
1942
+ const condition = params.existingNodeIds.length === 0 ? void 0 : sql4`${flowMedia.nodeId} NOT IN ${params.existingNodeIds}`;
1943
+ await this.db.delete(flowMedia).where(and5(eq6(flowMedia.companyId, params.companyId), eq6(flowMedia.flowKey, params.flowKey), condition));
1944
+ }
1945
+ };
1946
+
1947
+ // src/repositories/FlowMediaIdRepository.ts
1948
+ import { eq as eq7, sql as sql5 } from "drizzle-orm";
1949
+ var FlowMediaIdRepository = class {
1950
+ static {
1951
+ __name(this, "FlowMediaIdRepository");
1952
+ }
1953
+ db;
1954
+ constructor(db) {
1955
+ this.db = db;
1956
+ }
1957
+ async get(params) {
1958
+ const rows = await this.db.select({
1959
+ ids: flowMedia.metaMediaIds
1960
+ }).from(flowMedia).where(eq7(flowMedia.id, params.flowMediaId)).limit(1);
1961
+ return rows[0]?.ids?.[params.senderKey];
1962
+ }
1963
+ /**
1964
+ * Grava só a chave deste número.
1965
+ *
1966
+ * `jsonb_set` no banco, e não ler-alterar-escrever na aplicação: dois clientes passando pelo nó
1967
+ * ao mesmo tempo com números diferentes leriam o mesmo mapa e o último gravaria por cima,
1968
+ * apagando o id do outro. A escrita atômica não tem essa janela.
1969
+ */
1970
+ async set(params) {
1971
+ await this.db.update(flowMedia).set({
1972
+ metaMediaIds: sql5`jsonb_set(${flowMedia.metaMediaIds}, ARRAY[${params.senderKey}::text], to_jsonb(${params.mediaId}::text), true)`
1973
+ }).where(eq7(flowMedia.id, params.flowMediaId));
1974
+ }
1975
+ /** Remove só a chave deste número — o id do outro número continua válido. */
1976
+ async clear(params) {
1977
+ await this.db.update(flowMedia).set({
1978
+ metaMediaIds: sql5`${flowMedia.metaMediaIds} - ${params.senderKey}::text`
1979
+ }).where(eq7(flowMedia.id, params.flowMediaId));
1980
+ }
1981
+ };
1982
+
1983
+ // src/channel/WhatsAppChannelAdapter.ts
1984
+ import { WhatsAppWindowExpiredError as ProviderWindowExpiredError } from "@adatechnology/meta-graph-core";
1985
+ import { WindowExpiredError as WindowExpiredError2 } from "@adatechnology/meta-whatsapp-contracts";
1986
+
1987
+ // src/channel/previewMedia.ts
1988
+ import { PREVIEW_MEDIA_ID_PREFIX, toPreviewMediaId, resolvePreviewUploadId } from "@adatechnology/meta-whatsapp-contracts";
1989
+
1990
+ // src/channel/WhatsAppChannelAdapter.ts
1991
+ var WhatsAppChannelAdapter = class {
1992
+ static {
1993
+ __name(this, "WhatsAppChannelAdapter");
1994
+ }
1995
+ messages;
1996
+ previewMedia;
1997
+ constructor(messages2, previewMedia) {
1998
+ this.messages = messages2;
1999
+ this.previewMedia = previewMedia;
2000
+ }
2001
+ async translateErrors(operation) {
2002
+ try {
2003
+ return await operation();
2004
+ } catch (error) {
2005
+ if (error instanceof ProviderWindowExpiredError) throw new WindowExpiredError2();
2006
+ throw error;
2007
+ }
2008
+ }
2009
+ async sendText(to, body) {
2010
+ const result = await this.translateErrors(() => this.messages.sendText(to, body));
2011
+ return {
2012
+ externalMessageId: result.waMessageId
2013
+ };
2014
+ }
2015
+ async sendMedia(params) {
2016
+ const result = await this.translateErrors(() => this.messages.sendMedia(params));
2017
+ return {
2018
+ externalMessageId: result.waMessageId,
2019
+ mediaId: result.mediaId
2020
+ };
2021
+ }
2022
+ async sendTemplate(params) {
2023
+ const result = await this.translateErrors(() => this.messages.sendTemplate(params));
2024
+ return {
2025
+ externalMessageId: result.waMessageId
1049
2026
  };
1050
2027
  }
1051
2028
  async sendInteractiveList(params) {
@@ -1064,55 +2041,413 @@ var WhatsAppChannelAdapter = class {
1064
2041
  externalMessageId: result.waMessageId
1065
2042
  };
1066
2043
  }
2044
+ async sendInteractiveButtons(params) {
2045
+ const result = await this.translateErrors(() => this.messages.sendInteractiveButtons({
2046
+ to: params.to,
2047
+ bodyText: params.body,
2048
+ buttons: params.buttons
2049
+ }));
2050
+ return {
2051
+ externalMessageId: result.waMessageId
2052
+ };
2053
+ }
2054
+ async sendProductList(params) {
2055
+ const result = await this.translateErrors(() => this.messages.sendProductListMessage({
2056
+ to: params.to,
2057
+ headerText: params.headerText,
2058
+ bodyText: params.body,
2059
+ ...params.footerText ? {
2060
+ footerText: params.footerText
2061
+ } : {},
2062
+ sections: params.sections
2063
+ }));
2064
+ return {
2065
+ externalMessageId: result.waMessageId
2066
+ };
2067
+ }
2068
+ /**
2069
+ * Busca o binário da mídia — da Meta, ou do storage quando o id é do simulador.
2070
+ *
2071
+ * O desvio acontece ANTES de qualquer chamada de rede: id do simulador não existe na Meta, e
2072
+ * tentar buscá-lo lá renderia um 404 confuso em vez do áudio que o operador acabou de gravar.
2073
+ */
1067
2074
  async fetchMediaAsBase64(mediaId) {
2075
+ const uploadId = this.previewMedia?.isEnabled ? resolvePreviewUploadId(mediaId) : void 0;
2076
+ if (uploadId) {
2077
+ const buffer = await this.previewMedia.objectStorage.getObject(uploadId);
2078
+ return {
2079
+ data: buffer.toString("base64"),
2080
+ mimeType: this.previewMedia.defaultMimeType ?? "audio/ogg"
2081
+ };
2082
+ }
1068
2083
  return this.translateErrors(() => this.messages.fetchMediaAsBase64(mediaId));
1069
2084
  }
1070
2085
  };
1071
2086
 
1072
2087
  // src/channel/ReceiveWebhook.use-case.ts
1073
- import { whatsAppWebhookPayloadSchema } from "@adatechnology/meta-whatsapp-contracts";
2088
+ import { whatsAppWebhookPayloadSchema, whatsAppTemplateStatusUpdateSchema, whatsAppPhoneNumberQualityUpdateSchema, WHATSAPP_WEBHOOK_FIELDS } from "@adatechnology/meta-whatsapp-contracts";
1074
2089
 
1075
2090
  // src/channel/webhookSecurity.ts
1076
- import { createHmac, timingSafeEqual } from "crypto";
2091
+ import { WEBHOOK_CLAIM_TTL_SECONDS, WEBHOOK_NONCE_TTL_SECONDS, buildWebhookDeliveryKey, isValidWebhookChallenge, isValidWebhookSignature } from "@adatechnology/meta-graph-core";
1077
2092
  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");
2093
+ var WEBHOOK_NONCE_NAMESPACE = "meta-whatsapp";
1085
2094
  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
- }
2095
+ if (!isValidWebhookChallenge(params)) throw new InvalidWebhookSignatureError();
1092
2096
  return params.challenge;
1093
2097
  }
1094
2098
  __name(verifyWebhookChallenge, "verifyWebhookChallenge");
1095
2099
  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();
2100
+ if (!isValidWebhookSignature(params)) throw new InvalidWebhookSignatureError();
1101
2101
  }
1102
2102
  __name(verifyWebhookSignature, "verifyWebhookSignature");
2103
+ function deliveryKey(signatureHeader) {
2104
+ return buildWebhookDeliveryKey({
2105
+ namespace: WEBHOOK_NONCE_NAMESPACE,
2106
+ signatureHeader
2107
+ });
2108
+ }
2109
+ __name(deliveryKey, "deliveryKey");
1103
2110
  async function claimWebhookDelivery(params) {
1104
- const key = `meta-whatsapp:webhook:${params.signatureHeader}`;
1105
- return params.nonceStore.setIfAbsent(key, params.ttlSeconds ?? WEBHOOK_NONCE_TTL_SECONDS);
2111
+ return params.nonceStore.setIfAbsent(deliveryKey(params.signatureHeader), params.ttlSeconds ?? WEBHOOK_CLAIM_TTL_SECONDS);
1106
2112
  }
1107
2113
  __name(claimWebhookDelivery, "claimWebhookDelivery");
2114
+ async function confirmWebhookDelivery(params) {
2115
+ await params.nonceStore.confirm?.(deliveryKey(params.signatureHeader), params.ttlSeconds ?? WEBHOOK_NONCE_TTL_SECONDS);
2116
+ }
2117
+ __name(confirmWebhookDelivery, "confirmWebhookDelivery");
1108
2118
 
1109
- // src/channel/ReceiveWebhook.use-case.ts
2119
+ // src/channel/IngestInboundMedia.use-case.ts
2120
+ import { eq as eq8, and as and6 } from "drizzle-orm";
2121
+
2122
+ // src/use-cases/TranscribeAudio.use-case.ts
2123
+ import { AudioNotIngestedError, MessageNotAudioError, TranscriptionDisabledError } from "@adatechnology/meta-whatsapp-contracts";
2124
+
2125
+ // src/transcription.types.ts
2126
+ var TRANSCRIPTION_STATUS = {
2127
+ /** Falhou de forma retriável (cota, rede, 5xx) — vai sair quando alguém tentar de novo. */
2128
+ PENDING: "pending",
2129
+ /** Processado. Texto vazio aqui é áudio em silêncio, e NÃO deve ser reprocessado. */
2130
+ DONE: "done",
2131
+ /** Falha definitiva do engine (credencial, áudio corrompido, arquivo grande demais). */
2132
+ FAILED: "failed",
2133
+ /** Nenhum engine da cadeia aceita o formato. Retentar não conserta codec. */
2134
+ UNSUPPORTED: "unsupported"
2135
+ };
2136
+ var TRANSCRIPTION_MODE = {
2137
+ AUTO: "auto",
2138
+ ON_DEMAND: "onDemand"
2139
+ };
2140
+ function isRetriableTranscriptionError(error) {
2141
+ if (typeof error !== "object" || error === null) return true;
2142
+ const isRetriable = error.isRetriable;
2143
+ return typeof isRetriable === "boolean" ? isRetriable : true;
2144
+ }
2145
+ __name(isRetriableTranscriptionError, "isRetriableTranscriptionError");
2146
+ function isUnsupportedTranscriptionError(error) {
2147
+ return typeof error === "object" && error !== null && error.name === "TranscriptionUnsupportedError";
2148
+ }
2149
+ __name(isUnsupportedTranscriptionError, "isUnsupportedTranscriptionError");
2150
+ function transcriptionRetryAfterSeconds(error) {
2151
+ if (typeof error !== "object" || error === null) return void 0;
2152
+ const retryAfter = error.retryAfterSeconds;
2153
+ return typeof retryAfter === "number" ? retryAfter : void 0;
2154
+ }
2155
+ __name(transcriptionRetryAfterSeconds, "transcriptionRetryAfterSeconds");
2156
+ function isAudioMimeType(mimeType) {
2157
+ return typeof mimeType === "string" && mimeType.trim().toLowerCase().startsWith("audio/");
2158
+ }
2159
+ __name(isAudioMimeType, "isAudioMimeType");
2160
+
2161
+ // src/use-cases/TranscribeAudio.use-case.ts
2162
+ var TranscribeAudioUseCase = class {
2163
+ static {
2164
+ __name(this, "TranscribeAudioUseCase");
2165
+ }
2166
+ dependencies;
2167
+ constructor(dependencies) {
2168
+ this.dependencies = dependencies;
2169
+ }
2170
+ async execute(params) {
2171
+ const message = await this.dependencies.messageRepository.findById(params.companyId, params.messageId);
2172
+ if (!message) throw new Error(`Mensagem ${params.messageId} n\xE3o encontrada para transcri\xE7\xE3o`);
2173
+ if (message.transcriptionStatus === TRANSCRIPTION_STATUS.DONE && !params.force) {
2174
+ return {
2175
+ status: TRANSCRIPTION_STATUS.DONE,
2176
+ text: message.transcriptionText,
2177
+ language: message.transcriptionLanguage,
2178
+ engine: message.transcriptionEngine,
2179
+ alreadyTranscribed: true
2180
+ };
2181
+ }
2182
+ if (this.dependencies.resolvePolicy) {
2183
+ const policy = await this.dependencies.resolvePolicy(params.companyId);
2184
+ if (!policy.isEnabled) throw new TranscriptionDisabledError();
2185
+ }
2186
+ const audio = extractAudioReference(message);
2187
+ const buffer = await this.dependencies.objectStorage.getObject(audio.uploadId);
2188
+ return this.transcribeBuffer({
2189
+ ...params,
2190
+ buffer,
2191
+ mimeType: audio.mimeType,
2192
+ uploadId: audio.uploadId,
2193
+ message
2194
+ });
2195
+ }
2196
+ async transcribeBuffer(context) {
2197
+ try {
2198
+ const result = await this.dependencies.transcriber.transcribe({
2199
+ buffer: context.buffer,
2200
+ mimeType: context.mimeType,
2201
+ ...this.dependencies.languageHint ? {
2202
+ languageHint: this.dependencies.languageHint
2203
+ } : {}
2204
+ });
2205
+ await this.dependencies.messageRepository.saveTranscription({
2206
+ companyId: context.companyId,
2207
+ messageId: context.messageId,
2208
+ status: TRANSCRIPTION_STATUS.DONE,
2209
+ text: result.text,
2210
+ language: result.language ?? null,
2211
+ engine: result.engine
2212
+ });
2213
+ return {
2214
+ status: TRANSCRIPTION_STATUS.DONE,
2215
+ text: result.text,
2216
+ language: result.language ?? null,
2217
+ engine: result.engine,
2218
+ alreadyTranscribed: false
2219
+ };
2220
+ } catch (error) {
2221
+ await this.persistFailure(context, error);
2222
+ throw error;
2223
+ }
2224
+ }
2225
+ /**
2226
+ * Carimba o motivo antes de propagar. O status é o que impede os dois desperdícios simétricos:
2227
+ * reprocessar para sempre um codec impossível, e desistir de um áudio que só esbarrou na cota.
2228
+ */
2229
+ async persistFailure(context, error) {
2230
+ const status = resolveFailureStatus(error);
2231
+ await this.dependencies.messageRepository.saveTranscription({
2232
+ companyId: context.companyId,
2233
+ messageId: context.messageId,
2234
+ status
2235
+ });
2236
+ if (status !== TRANSCRIPTION_STATUS.PENDING) return;
2237
+ const retryAfterSeconds = transcriptionRetryAfterSeconds(error);
2238
+ await this.dependencies.hooks?.onTranscriptionDeferred?.({
2239
+ companyId: context.companyId,
2240
+ messageId: context.messageId,
2241
+ whatsappNumber: context.message.whatsappNumber,
2242
+ uploadId: context.uploadId,
2243
+ ...retryAfterSeconds !== void 0 ? {
2244
+ retryAfterSeconds
2245
+ } : {},
2246
+ reason: retryAfterSeconds !== void 0 ? "rate-limited" : "transient-failure",
2247
+ error
2248
+ });
2249
+ }
2250
+ };
2251
+ function resolveFailureStatus(error) {
2252
+ if (isUnsupportedTranscriptionError(error)) return TRANSCRIPTION_STATUS.UNSUPPORTED;
2253
+ return isRetriableTranscriptionError(error) ? TRANSCRIPTION_STATUS.PENDING : TRANSCRIPTION_STATUS.FAILED;
2254
+ }
2255
+ __name(resolveFailureStatus, "resolveFailureStatus");
2256
+ function extractAudioReference(message) {
2257
+ const payload = message.payload ?? {};
2258
+ const audio = payload["audio"];
2259
+ const mimeType = typeof payload["mimeType"] === "string" ? payload["mimeType"] : audio?.mime_type;
2260
+ if (!isAudioMimeType(mimeType) && !audio) {
2261
+ throw new MessageNotAudioError(message.id, message.type);
2262
+ }
2263
+ const uploadId = payload["uploadId"];
2264
+ if (typeof uploadId !== "string" || uploadId.length === 0) {
2265
+ throw new AudioNotIngestedError(message.id);
2266
+ }
2267
+ return {
2268
+ uploadId,
2269
+ mimeType: mimeType ?? "audio/ogg"
2270
+ };
2271
+ }
2272
+ __name(extractAudioReference, "extractAudioReference");
2273
+
2274
+ // src/channel/IngestInboundMedia.use-case.ts
2275
+ var IngestInboundMediaUseCase = class {
2276
+ static {
2277
+ __name(this, "IngestInboundMediaUseCase");
2278
+ }
2279
+ db;
2280
+ channel;
2281
+ objectStorage;
2282
+ documentRepository;
2283
+ transcription;
2284
+ constructor(db, channel, objectStorage, documentRepository, transcription) {
2285
+ this.db = db;
2286
+ this.channel = channel;
2287
+ this.objectStorage = objectStorage;
2288
+ this.documentRepository = documentRepository;
2289
+ this.transcription = transcription;
2290
+ }
2291
+ async execute(params) {
2292
+ const [message] = await this.db.select().from(messages).where(and6(eq8(messages.companyId, params.companyId), eq8(messages.id, params.messageId))).limit(1);
2293
+ if (!message) throw new Error(`Mensagem ${params.messageId} n\xE3o encontrada para ingest\xE3o de m\xEDdia`);
2294
+ const payload = message.payload ?? {};
2295
+ if (payload["uploadId"] && payload["sourceMediaId"] === params.sourceMediaId) {
2296
+ return {
2297
+ uploadId: String(payload["uploadId"]),
2298
+ alreadyIngested: true
2299
+ };
2300
+ }
2301
+ const { data, mimeType } = await this.channel.fetchMediaAsBase64(params.sourceMediaId);
2302
+ const buffer = Buffer.from(data, "base64");
2303
+ const { uploadId } = await this.objectStorage.upload({
2304
+ buffer,
2305
+ mimeType: mimeType || params.mimeType,
2306
+ key: `meta-whatsapp/${params.companyId}/inbound/${params.sourceMediaId}`
2307
+ });
2308
+ const updatedPayload = {
2309
+ ...payload,
2310
+ uploadId,
2311
+ sourceMediaId: params.sourceMediaId,
2312
+ mimeType: mimeType || params.mimeType,
2313
+ // O tamanho já está na mão (é o buffer que acabou de ser copiado) e a bolha de documento o
2314
+ // exibe ao lado do tipo. Sem gravar aqui, a UI mostraria "PDF" sem o "· 180 KB", e buscá-lo
2315
+ // depois custaria uma consulta à tabela de documentos por mensagem renderizada.
2316
+ sizeBytes: buffer.length,
2317
+ ...params.filename ? {
2318
+ filename: params.filename
2319
+ } : {}
2320
+ };
2321
+ await this.db.update(messages).set({
2322
+ payload: updatedPayload
2323
+ }).where(and6(eq8(messages.companyId, params.companyId), eq8(messages.id, params.messageId)));
2324
+ await this.documentRepository?.link({
2325
+ companyId: params.companyId,
2326
+ sessionId: message.sessionId,
2327
+ messageId: message.id,
2328
+ uploadId,
2329
+ // Áudio e sticker chegam sem nome; sem um rótulo o painel mostraria linha vazia.
2330
+ filename: params.filename ?? `${params.sourceMediaId}`,
2331
+ mimeType: mimeType || params.mimeType,
2332
+ sizeBytes: buffer.length,
2333
+ source: message.sender
2334
+ });
2335
+ const transcription = await this.transcribeIfAuto({
2336
+ companyId: params.companyId,
2337
+ message,
2338
+ uploadId,
2339
+ buffer,
2340
+ mimeType: mimeType || params.mimeType
2341
+ });
2342
+ return {
2343
+ uploadId,
2344
+ alreadyIngested: false,
2345
+ ...transcription ? {
2346
+ transcription
2347
+ } : {}
2348
+ };
2349
+ }
2350
+ /**
2351
+ * Transcreve o áudio recém-baixado, quando o modo é `auto`.
2352
+ *
2353
+ * **Nunca propaga erro.** Neste ponto o binário já está no storage e já entrou na biblioteca da
2354
+ * conversa: deixar uma falha de transcrição subir marcaria a ingestão inteira como falha, e o
2355
+ * retry do host baixaria de novo da Meta um arquivo que está salvo — gastando banda para reproduzir
2356
+ * um efeito que já aconteceu. O status fica gravado na mensagem e o `onTranscriptionDeferred`
2357
+ * avisa quem sabe reenfileirar.
2358
+ */
2359
+ async transcribeIfAuto(context) {
2360
+ const transcription = this.transcription;
2361
+ if (!transcription) return void 0;
2362
+ if (!isAudioMimeType(context.mimeType)) return void 0;
2363
+ const policy = await transcription.resolvePolicy(context.companyId);
2364
+ if (!policy.isEnabled || policy.mode !== TRANSCRIPTION_MODE.AUTO) return void 0;
2365
+ const current = await transcription.messageRepository.findById(context.companyId, context.message.id);
2366
+ if (current?.transcriptionStatus === TRANSCRIPTION_STATUS.DONE) return void 0;
2367
+ try {
2368
+ const result = await transcription.transcriber.transcribe({
2369
+ buffer: context.buffer,
2370
+ mimeType: context.mimeType,
2371
+ ...transcription.languageHint ? {
2372
+ languageHint: transcription.languageHint
2373
+ } : {}
2374
+ });
2375
+ await transcription.messageRepository.saveTranscription({
2376
+ companyId: context.companyId,
2377
+ messageId: context.message.id,
2378
+ status: TRANSCRIPTION_STATUS.DONE,
2379
+ text: result.text,
2380
+ language: result.language ?? null,
2381
+ engine: result.engine
2382
+ });
2383
+ return {
2384
+ status: TRANSCRIPTION_STATUS.DONE
2385
+ };
2386
+ } catch (error) {
2387
+ return this.recordTranscriptionFailure(context, error);
2388
+ }
2389
+ }
2390
+ async recordTranscriptionFailure(context, error) {
2391
+ const status = resolveFailureStatus(error);
2392
+ await this.transcription?.messageRepository.saveTranscription({
2393
+ companyId: context.companyId,
2394
+ messageId: context.message.id,
2395
+ status
2396
+ });
2397
+ if (status === TRANSCRIPTION_STATUS.PENDING) {
2398
+ const retryAfterSeconds = transcriptionRetryAfterSeconds(error);
2399
+ await this.transcription?.hooks?.onTranscriptionDeferred?.({
2400
+ companyId: context.companyId,
2401
+ messageId: context.message.id,
2402
+ whatsappNumber: context.message.whatsappNumber,
2403
+ uploadId: context.uploadId,
2404
+ ...retryAfterSeconds !== void 0 ? {
2405
+ retryAfterSeconds
2406
+ } : {},
2407
+ reason: retryAfterSeconds !== void 0 ? "rate-limited" : "transient-failure",
2408
+ error
2409
+ });
2410
+ }
2411
+ return {
2412
+ status
2413
+ };
2414
+ }
2415
+ };
2416
+ function extractMediaDescriptor(message) {
2417
+ const payload = message.payload ?? {};
2418
+ for (const key of [
2419
+ "image",
2420
+ "audio",
2421
+ "video",
2422
+ "document",
2423
+ "sticker"
2424
+ ]) {
2425
+ const media = payload[key];
2426
+ if (media?.id) {
2427
+ return {
2428
+ sourceMediaId: media.id,
2429
+ mimeType: media.mime_type ?? "application/octet-stream",
2430
+ filename: media.filename
2431
+ };
2432
+ }
2433
+ }
2434
+ return void 0;
2435
+ }
2436
+ __name(extractMediaDescriptor, "extractMediaDescriptor");
2437
+
2438
+ // src/channel/inboundDispatch.ts
2439
+ function buildInboundJobId(job) {
2440
+ return job.kind === "message" ? `wa-inbound-message:${job.message.id}` : `wa-inbound-status:${job.status.id}:${job.status.status}`;
2441
+ }
2442
+ __name(buildInboundJobId, "buildInboundJobId");
1110
2443
  function toSessionContract(row) {
1111
2444
  return {
1112
2445
  id: row.id,
1113
2446
  companyId: row.companyId,
1114
2447
  whatsappNumber: row.whatsappNumber,
1115
2448
  currentState: row.currentState,
2449
+ flowKey: row.flowKey,
2450
+ currentNodeId: row.currentNodeId,
1116
2451
  context: row.context,
1117
2452
  mode: row.mode,
1118
2453
  assignedUserId: row.assignedUserId,
@@ -1125,6 +2460,72 @@ function toSessionContract(row) {
1125
2460
  };
1126
2461
  }
1127
2462
  __name(toSessionContract, "toSessionContract");
2463
+ function extractAnswer(message) {
2464
+ const interactive = message.interactive;
2465
+ if (interactive?.button_reply) return interactive.button_reply.id;
2466
+ if (interactive?.list_reply) return interactive.list_reply.id;
2467
+ return message.text?.body;
2468
+ }
2469
+ __name(extractAnswer, "extractAnswer");
2470
+ var InboundEffectsDispatcher = class {
2471
+ static {
2472
+ __name(this, "InboundEffectsDispatcher");
2473
+ }
2474
+ params;
2475
+ constructor(params) {
2476
+ this.params = params;
2477
+ }
2478
+ async run(job) {
2479
+ if (job.kind === "message") return this.runMessageEffects(job);
2480
+ return this.runStatusEffects(job);
2481
+ }
2482
+ async runMessageEffects(job) {
2483
+ const { companyId, message, savedMessageId, media } = job;
2484
+ if (media) {
2485
+ await this.params.hooks?.onMediaReceived?.({
2486
+ companyId,
2487
+ messageId: savedMessageId,
2488
+ whatsappNumber: message.from,
2489
+ sourceMediaId: media.sourceMediaId,
2490
+ mimeType: media.mimeType,
2491
+ ...media.filename ? {
2492
+ filename: media.filename
2493
+ } : {}
2494
+ });
2495
+ }
2496
+ const sessionRow = await this.params.sessionRepository.getContext(companyId, message.from);
2497
+ if (!sessionRow) return;
2498
+ if (sessionRow.mode === "human") return;
2499
+ const outcome = await this.params.hooks?.onMessageReceived?.(message, toSessionContract(sessionRow));
2500
+ if (outcome?.outcome === "handled") return;
2501
+ void extractAnswer(message);
2502
+ }
2503
+ async runStatusEffects(job) {
2504
+ const sessionRow = await this.params.sessionRepository.getContext(job.companyId, job.whatsappNumber);
2505
+ await this.params.hooks?.onStatusUpdate?.(job.status, sessionRow ? toSessionContract(sessionRow) : null);
2506
+ }
2507
+ };
2508
+ var ProcessInboundDispatchUseCase = class {
2509
+ static {
2510
+ __name(this, "ProcessInboundDispatchUseCase");
2511
+ }
2512
+ dispatcher;
2513
+ constructor(dispatcher) {
2514
+ this.dispatcher = dispatcher;
2515
+ }
2516
+ async execute(job) {
2517
+ await this.dispatcher.run(job);
2518
+ }
2519
+ };
2520
+
2521
+ // src/channel/ReceiveWebhook.use-case.ts
2522
+ var EMPTY_RESULT = {
2523
+ messagesProcessed: 0,
2524
+ statusesProcessed: 0,
2525
+ ignoredForeignNumber: 0,
2526
+ accountEventsProcessed: 0,
2527
+ unhandledEvents: 0
2528
+ };
1128
2529
  function extractContent(message) {
1129
2530
  if (message.text?.body) return message.text.body;
1130
2531
  const interactive = message.interactive;
@@ -1137,13 +2538,6 @@ function extractContent(message) {
1137
2538
  return message.image?.caption ?? message.document?.caption ?? null;
1138
2539
  }
1139
2540
  __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
2541
  function extractPayload(message) {
1148
2542
  const payload = {};
1149
2543
  if (message.interactive) payload["interactive"] = message.interactive;
@@ -1157,6 +2551,14 @@ function extractPayload(message) {
1157
2551
  return Object.keys(payload).length > 0 ? payload : null;
1158
2552
  }
1159
2553
  __name(extractPayload, "extractPayload");
2554
+ var ACCOUNT_EVENT_FIELDS = [
2555
+ WHATSAPP_WEBHOOK_FIELDS.TEMPLATE_STATUS_UPDATE,
2556
+ WHATSAPP_WEBHOOK_FIELDS.PHONE_NUMBER_QUALITY_UPDATE
2557
+ ];
2558
+ function isAccountEventField(field) {
2559
+ return field !== void 0 && ACCOUNT_EVENT_FIELDS.includes(field);
2560
+ }
2561
+ __name(isAccountEventField, "isAccountEventField");
1160
2562
  var ReceiveWebhookUseCase = class {
1161
2563
  static {
1162
2564
  __name(this, "ReceiveWebhookUseCase");
@@ -1164,7 +2566,17 @@ var ReceiveWebhookUseCase = class {
1164
2566
  params;
1165
2567
  constructor(params) {
1166
2568
  this.params = params;
2569
+ this.dispatcher = new InboundEffectsDispatcher({
2570
+ sessionRepository: params.sessionRepository,
2571
+ ...params.hooks ? {
2572
+ hooks: params.hooks
2573
+ } : {},
2574
+ ...params.realtime ? {
2575
+ realtime: params.realtime
2576
+ } : {}
2577
+ });
1167
2578
  }
2579
+ dispatcher;
1168
2580
  async execute(input) {
1169
2581
  verifyWebhookSignature({
1170
2582
  rawBody: input.rawBody,
@@ -1177,15 +2589,41 @@ var ReceiveWebhookUseCase = class {
1177
2589
  });
1178
2590
  if (!claimed) return {
1179
2591
  duplicate: true,
1180
- messagesProcessed: 0,
1181
- statusesProcessed: 0
2592
+ ...EMPTY_RESULT
1182
2593
  };
1183
2594
  const rawText = typeof input.rawBody === "string" ? input.rawBody : input.rawBody.toString("utf8");
1184
- const payload = whatsAppWebhookPayloadSchema.parse(JSON.parse(rawText));
2595
+ const parsed = whatsAppWebhookPayloadSchema.safeParse(JSON.parse(rawText));
2596
+ if (!parsed.success) {
2597
+ await this.params.hooks?.onUnhandledWebhookEvent?.({
2598
+ field: void 0,
2599
+ reason: "invalid-shape",
2600
+ value: rawText
2601
+ });
2602
+ return {
2603
+ duplicate: false,
2604
+ ...EMPTY_RESULT,
2605
+ unhandledEvents: 1
2606
+ };
2607
+ }
2608
+ const payload = parsed.data;
1185
2609
  let messagesProcessed = 0;
1186
2610
  let statusesProcessed = 0;
2611
+ let ignoredForeignNumber = 0;
2612
+ let accountEventsProcessed = 0;
2613
+ let unhandledEvents = 0;
1187
2614
  for (const entry of payload.entry) {
1188
2615
  for (const change of entry.changes) {
2616
+ if (isAccountEventField(change.field)) {
2617
+ const handled = await this.handleAccountEvent(change);
2618
+ if (handled) accountEventsProcessed++;
2619
+ else unhandledEvents++;
2620
+ continue;
2621
+ }
2622
+ const targetNumber = change.value.metadata?.phone_number_id;
2623
+ if (targetNumber && targetNumber !== this.params.phoneNumberId) {
2624
+ ignoredForeignNumber++;
2625
+ continue;
2626
+ }
1189
2627
  for (const message of change.value.messages ?? []) {
1190
2628
  await this.handleMessage(input.companyId, message);
1191
2629
  messagesProcessed++;
@@ -1194,14 +2632,58 @@ var ReceiveWebhookUseCase = class {
1194
2632
  await this.handleStatus(input.companyId, status);
1195
2633
  statusesProcessed++;
1196
2634
  }
2635
+ const carriedConversationData = (change.value.messages?.length ?? 0) > 0 || (change.value.message_echoes?.length ?? 0) > 0 || (change.value.statuses?.length ?? 0) > 0;
2636
+ if (!carriedConversationData) {
2637
+ unhandledEvents++;
2638
+ await this.params.hooks?.onUnhandledWebhookEvent?.({
2639
+ field: change.field,
2640
+ reason: "unknown-field",
2641
+ value: change.value
2642
+ });
2643
+ }
1197
2644
  }
1198
2645
  }
2646
+ await confirmWebhookDelivery({
2647
+ nonceStore: this.params.nonceStore,
2648
+ signatureHeader: input.signatureHeader
2649
+ });
1199
2650
  return {
1200
2651
  duplicate: false,
1201
2652
  messagesProcessed,
1202
- statusesProcessed
2653
+ statusesProcessed,
2654
+ ignoredForeignNumber,
2655
+ accountEventsProcessed,
2656
+ unhandledEvents
1203
2657
  };
1204
2658
  }
2659
+ // Devolve `false` quando o corpo não bate com o schema do field — o chamador conta como não
2660
+ // tratado. O host é avisado nos dois casos, mas com `reason` diferente.
2661
+ async handleAccountEvent(change) {
2662
+ if (change.field === WHATSAPP_WEBHOOK_FIELDS.TEMPLATE_STATUS_UPDATE) {
2663
+ const update = whatsAppTemplateStatusUpdateSchema.safeParse(change.value);
2664
+ if (!update.success) {
2665
+ await this.params.hooks?.onUnhandledWebhookEvent?.({
2666
+ field: change.field,
2667
+ reason: "invalid-shape",
2668
+ value: change.value
2669
+ });
2670
+ return false;
2671
+ }
2672
+ await this.params.hooks?.onTemplateStatusUpdate?.(update.data);
2673
+ return true;
2674
+ }
2675
+ const quality = whatsAppPhoneNumberQualityUpdateSchema.safeParse(change.value);
2676
+ if (!quality.success) {
2677
+ await this.params.hooks?.onUnhandledWebhookEvent?.({
2678
+ field: change.field,
2679
+ reason: "invalid-shape",
2680
+ value: change.value
2681
+ });
2682
+ return false;
2683
+ }
2684
+ await this.params.hooks?.onPhoneNumberQualityUpdate?.(quality.data);
2685
+ return true;
2686
+ }
1205
2687
  async handleMessage(companyId, message) {
1206
2688
  const saved = await this.params.logMessage.execute({
1207
2689
  companyId,
@@ -1216,12 +2698,17 @@ var ReceiveWebhookUseCase = class {
1216
2698
  startState: this.params.startState
1217
2699
  });
1218
2700
  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);
2701
+ const media = extractMediaDescriptor(saved);
2702
+ await this.dispatch({
2703
+ kind: "message",
2704
+ companyId,
2705
+ message,
2706
+ savedMessageId: saved.id,
2707
+ ...media ? {
2708
+ media
2709
+ } : {},
2710
+ receivedAt: Date.now()
2711
+ });
1225
2712
  }
1226
2713
  async handleStatus(companyId, status) {
1227
2714
  const updated = await this.params.messageRepository.updateMessageStatus(companyId, status.id, status.status);
@@ -1230,81 +2717,71 @@ var ReceiveWebhookUseCase = class {
1230
2717
  waMessageId: status.id,
1231
2718
  status: status.status
1232
2719
  });
1233
- const sessionRow = await this.params.sessionRepository.getContext(companyId, updated.whatsappNumber);
1234
- await this.params.hooks?.onStatusUpdate?.(status, sessionRow ? toSessionContract(sessionRow) : null);
2720
+ await this.dispatch({
2721
+ kind: "status",
2722
+ companyId,
2723
+ status,
2724
+ whatsappNumber: updated.whatsappNumber,
2725
+ receivedAt: Date.now()
2726
+ });
2727
+ }
2728
+ // Com fila configurada, os efeitos saem da requisição do webhook; sem ela, rodam aqui mesmo e o
2729
+ // comportamento é o de sempre. É o mesmo `InboundEffectsDispatcher` nos dois caminhos.
2730
+ async dispatch(job) {
2731
+ if (this.params.inboundQueue) {
2732
+ await this.params.inboundQueue.enqueue(job, {
2733
+ jobId: buildInboundJobId(job)
2734
+ });
2735
+ return;
2736
+ }
2737
+ await this.dispatcher.run(job);
1235
2738
  }
1236
2739
  };
1237
2740
 
1238
- // src/channel/IngestInboundMedia.use-case.ts
1239
- import { eq as eq5, and as and4 } from "drizzle-orm";
1240
- var IngestInboundMediaUseCase = class {
2741
+ // src/use-cases/resolveTranscriptionPolicy.ts
2742
+ function createTranscriptionPolicyResolver(dependencies) {
2743
+ return /* @__PURE__ */ __name(async function resolveTranscriptionPolicy(companyId) {
2744
+ const settings2 = await dependencies.settingsRepository.get(companyId);
2745
+ return {
2746
+ // `??` e não `||`: `false` gravado é decisão explícita de desligar, e `||` a trocaria pelo
2747
+ // padrão do host — desligar no painel não faria nada num deploy com transcrição ligada.
2748
+ isEnabled: settings2.transcriptionEnabled ?? dependencies.defaults.isEnabled,
2749
+ mode: normalizeMode(settings2.transcriptionMode) ?? dependencies.defaults.mode
2750
+ };
2751
+ }, "resolveTranscriptionPolicy");
2752
+ }
2753
+ __name(createTranscriptionPolicyResolver, "createTranscriptionPolicyResolver");
2754
+ function normalizeMode(value) {
2755
+ if (value === TRANSCRIPTION_MODE.AUTO) return TRANSCRIPTION_MODE.AUTO;
2756
+ if (value === TRANSCRIPTION_MODE.ON_DEMAND) return TRANSCRIPTION_MODE.ON_DEMAND;
2757
+ return void 0;
2758
+ }
2759
+ __name(normalizeMode, "normalizeMode");
2760
+
2761
+ // src/use-cases/StorePreviewMedia.use-case.ts
2762
+ var StorePreviewMediaUseCase = class {
1241
2763
  static {
1242
- __name(this, "IngestInboundMediaUseCase");
2764
+ __name(this, "StorePreviewMediaUseCase");
1243
2765
  }
1244
- db;
1245
- channel;
1246
2766
  objectStorage;
1247
- constructor(db, channel, objectStorage) {
1248
- this.db = db;
1249
- this.channel = channel;
2767
+ generateKeySuffix;
2768
+ constructor(objectStorage, generateKeySuffix) {
1250
2769
  this.objectStorage = objectStorage;
2770
+ this.generateKeySuffix = generateKeySuffix;
1251
2771
  }
1252
2772
  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");
2773
+ const key = `meta-whatsapp/${params.companyId}/preview/${this.generateKeySuffix()}`;
1264
2774
  const { uploadId } = await this.objectStorage.upload({
1265
- buffer,
1266
- mimeType: mimeType || params.mimeType,
1267
- key: `meta-whatsapp/${params.companyId}/inbound/${params.sourceMediaId}`
2775
+ buffer: params.buffer,
2776
+ mimeType: params.mimeType,
2777
+ key
1268
2778
  });
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
2779
  return {
1282
- uploadId,
1283
- alreadyIngested: false
2780
+ mediaId: toPreviewMediaId(uploadId),
2781
+ uploadId
1284
2782
  };
1285
2783
  }
1286
2784
  };
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
2785
 
1309
2786
  // src/createMetaWhatsAppModule.ts
1310
2787
  function createMetaWhatsAppModule(params) {
@@ -1318,15 +2795,24 @@ function createMetaWhatsAppModule(params) {
1318
2795
  apiVersion: config.apiVersion,
1319
2796
  baseUrl: config.baseUrl
1320
2797
  });
1321
- const channel = new WhatsAppChannelAdapter(messageProvider);
2798
+ const previewMediaSupport = params.features?.previewMedia && providers.objectStorage?.getObject ? {
2799
+ isEnabled: true,
2800
+ objectStorage: providers.objectStorage
2801
+ } : void 0;
2802
+ const channel = new WhatsAppChannelAdapter(messageProvider, previewMediaSupport);
1322
2803
  const sessionRepository = new SessionRepository(db);
1323
2804
  const messageRepository = new MessageRepository(db);
1324
2805
  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);
2806
+ const flowGraphCacheFeature = params.features?.flowGraphCache ?? false;
2807
+ 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;
2808
+ const flowGraphRepository = new FlowGraphRepository(db, flowGraphCache);
2809
+ const documentRepository = new DocumentRepository(db);
2810
+ const flowMediaRepository = new FlowMediaRepository(db);
2811
+ const logMessage = new LogMessageUseCase(sessionRepository, messageRepository, providers.realtime, providers.moderator);
2812
+ const sendMessage = new SendMessageUseCase(channel, sessionRepository, logMessage, providers.objectStorage, documentRepository);
1328
2813
  const receiveWebhook = new ReceiveWebhookUseCase({
1329
2814
  appSecret: config.appSecret,
2815
+ phoneNumberId: config.phoneNumberId,
1330
2816
  nonceStore,
1331
2817
  sessionRepository,
1332
2818
  messageRepository,
@@ -1336,7 +2822,66 @@ function createMetaWhatsAppModule(params) {
1336
2822
  realtime: providers.realtime
1337
2823
  });
1338
2824
  const flowInterpreter = flowEngineEnabled ? new FlowInterpreter() : void 0;
1339
- const ingestInboundMedia = providers.objectStorage ? new IngestInboundMediaUseCase(db, channel, providers.objectStorage) : void 0;
2825
+ if (flowInterpreter && providers.objectStorage?.getObject) {
2826
+ flowInterpreter.registerFlowAction(FLOW_ACTION_KIND.SEND_MEDIA, createSendMediaAction({
2827
+ flowMediaRepository,
2828
+ objectStorage: providers.objectStorage,
2829
+ logMessage,
2830
+ startState,
2831
+ // Cache ligado por padrão, com o store do próprio módulo: a tabela é daqui e a coluna é
2832
+ // daqui, então exigir que cada host escrevesse o repositório faria todos reescreverem o
2833
+ // mesmo código — e, até escreverem, o mesmo binário continuaria subindo por cliente. Quem
2834
+ // quiser outro lugar (Redis) troca o `store`; quem não fizer nada já sai sem ressubir.
2835
+ mediaIdCache: {
2836
+ store: new FlowMediaIdRepository(db),
2837
+ senderKey: config.phoneNumberId
2838
+ },
2839
+ onError: hooks?.onFlowMediaError
2840
+ }));
2841
+ }
2842
+ if (flowInterpreter && providers.catalog && config.catalogId) {
2843
+ flowInterpreter.registerFlowAction(FLOW_ACTION_KIND.SEND_PRODUCT_LIST, createSendProductListAction({
2844
+ catalog: providers.catalog,
2845
+ catalogId: config.catalogId,
2846
+ logMessage,
2847
+ startState,
2848
+ onError: hooks?.onFlowProductListError
2849
+ }));
2850
+ }
2851
+ const transcriptionMode = providers.transcription?.mode ?? TRANSCRIPTION_MODE.ON_DEMAND;
2852
+ const resolveTranscriptionPolicy = providers.transcription ? createTranscriptionPolicyResolver({
2853
+ settingsRepository,
2854
+ defaults: {
2855
+ isEnabled: providers.transcription.isEnabledByDefault ?? true,
2856
+ mode: transcriptionMode
2857
+ }
2858
+ }) : void 0;
2859
+ const ingestInboundMedia = providers.objectStorage ? new IngestInboundMediaUseCase(db, channel, providers.objectStorage, documentRepository, providers.transcription && resolveTranscriptionPolicy ? {
2860
+ transcriber: providers.transcription.transcriber,
2861
+ resolvePolicy: resolveTranscriptionPolicy,
2862
+ messageRepository,
2863
+ ...providers.transcription.languageHint ? {
2864
+ languageHint: providers.transcription.languageHint
2865
+ } : {},
2866
+ ...hooks ? {
2867
+ hooks
2868
+ } : {}
2869
+ } : void 0) : void 0;
2870
+ const transcribeAudio = providers.transcription && providers.objectStorage?.getObject ? new TranscribeAudioUseCase({
2871
+ messageRepository,
2872
+ objectStorage: providers.objectStorage,
2873
+ transcriber: providers.transcription.transcriber,
2874
+ ...resolveTranscriptionPolicy ? {
2875
+ resolvePolicy: resolveTranscriptionPolicy
2876
+ } : {},
2877
+ ...providers.transcription.languageHint ? {
2878
+ languageHint: providers.transcription.languageHint
2879
+ } : {},
2880
+ ...hooks ? {
2881
+ hooks
2882
+ } : {}
2883
+ }) : void 0;
2884
+ const listDocuments = new ListConversationDocumentsUseCase(sessionRepository, documentRepository);
1340
2885
  return {
1341
2886
  channel,
1342
2887
  // undefined quando providers.objectStorage não foi injetado.
@@ -1348,10 +2893,36 @@ function createMetaWhatsAppModule(params) {
1348
2893
  release: new ReleaseConversationUseCase(sessionRepository, providers.realtime),
1349
2894
  list: new ListConversationsUseCase(sessionRepository),
1350
2895
  listMessages: new ListMessagesUseCase(sessionRepository, messageRepository),
2896
+ listDocuments,
2897
+ // Biblioteca da empresa inteira, para uma tela de Documentos fora da conversa.
2898
+ listCompanyDocuments: new ListCompanyDocumentsUseCase(documentRepository),
2899
+ // Apaga a mídia no storage antes das linhas — a cascata da FK sozinha deixaria os binários
2900
+ // órfãos, já que a lista de uploadId vive justamente nas linhas que ela derruba.
2901
+ delete: new DeleteConversationUseCase(sessionRepository, documentRepository, providers.objectStorage),
2902
+ purgeExpiredDocuments: new PurgeExpiredDocumentsUseCase(documentRepository, providers.objectStorage),
1351
2903
  export: new ExportConversationUseCase(sessionRepository),
1352
- repository: sessionRepository
2904
+ // undefined quando transcrição não foi injetada, ou quando o storage não sabe ler de volta.
2905
+ // O painel consulta a ausência para decidir se desenha o botão "transcrever".
2906
+ transcribeAudio,
2907
+ repository: sessionRepository,
2908
+ messageRepository,
2909
+ documentRepository
1353
2910
  },
2911
+ /**
2912
+ * `undefined` = o host não injetou transcritor, e nenhuma configuração de empresa muda isso: a
2913
+ * capacidade não existe. Presente, `resolvePolicy` responde o que vale para uma empresa —
2914
+ * é o que a rota de configurações usa para dizer ao painel se desenha o interruptor.
2915
+ */
2916
+ transcription: providers.transcription && resolveTranscriptionPolicy ? {
2917
+ defaultMode: transcriptionMode,
2918
+ resolvePolicy: resolveTranscriptionPolicy
2919
+ } : void 0,
1354
2920
  settings: settingsRepository,
2921
+ /**
2922
+ * `undefined` quando o recurso não está ligado (ou falta storage legível). O host consulta a
2923
+ * ausência para não registrar a rota de upload — e o preview, sem a rota, esconde o microfone.
2924
+ */
2925
+ previewMedia: previewMediaSupport ? new StorePreviewMediaUseCase(providers.objectStorage, () => `${Date.now()}-${Math.random().toString(36).slice(2)}`) : void 0,
1355
2926
  webhook: {
1356
2927
  receive: receiveWebhook,
1357
2928
  // GET de verificação da Meta — o host liga na sua rota e devolve o retorno como texto puro.
@@ -1370,7 +2941,11 @@ function createMetaWhatsAppModule(params) {
1370
2941
  save: new SaveFlowGraphUseCase(flowGraphRepository),
1371
2942
  delete: new DeleteFlowGraphUseCase(flowGraphRepository),
1372
2943
  livePositions: new GetLiveFlowPositionsUseCase(flowGraphRepository),
1373
- repository: flowGraphRepository
2944
+ repository: flowGraphRepository,
2945
+ // Biblioteca de mídia dos nós `send_media` — o host liga nas rotas do editor
2946
+ // (anexar/reordenar/desligar). Existe mesmo sem storage injetado: gerenciar anexos é
2947
+ // consultar a tabela, e só o ENVIO precisa dos bytes.
2948
+ mediaRepository: flowMediaRepository
1374
2949
  } : void 0,
1375
2950
  catalog: providers.catalog
1376
2951
  };
@@ -1469,14 +3044,23 @@ async function redeemSseTicket(store, ticket) {
1469
3044
  __name(redeemSseTicket, "redeemSseTicket");
1470
3045
  export {
1471
3046
  CreateFlowGraphUseCase,
3047
+ DEFAULT_FLOW_GRAPH_CACHE_TTL_SECONDS,
3048
+ DeleteConversationUseCase,
1472
3049
  DeleteFlowGraphUseCase,
3050
+ DocumentRepository,
1473
3051
  ExportConversationUseCase,
3052
+ FlowGraphCache,
1474
3053
  FlowGraphRepository,
1475
3054
  FlowInterpreter,
3055
+ FlowMediaIdRepository,
3056
+ FlowMediaRepository,
1476
3057
  GetFlowGraphUseCase,
1477
3058
  GetLiveFlowPositionsUseCase,
3059
+ InboundEffectsDispatcher,
1478
3060
  IngestInboundMediaUseCase,
1479
3061
  InvalidFlowGraphError,
3062
+ ListCompanyDocumentsUseCase,
3063
+ ListConversationDocumentsUseCase,
1480
3064
  ListConversationsUseCase,
1481
3065
  ListFlowGraphsUseCase,
1482
3066
  ListMessagesUseCase,
@@ -1484,6 +3068,10 @@ export {
1484
3068
  META_WHATSAPP_MIGRATIONS_TABLE,
1485
3069
  MessageRepository,
1486
3070
  OptimisticLockError,
3071
+ PREVIEW_MEDIA_ID_PREFIX,
3072
+ PRODUCT_LIST_LIMIT,
3073
+ ProcessInboundDispatchUseCase,
3074
+ PurgeExpiredDocumentsUseCase,
1487
3075
  ReceiveWebhookUseCase,
1488
3076
  ReleaseConversationUseCase,
1489
3077
  SaveFlowGraphUseCase,
@@ -1491,21 +3079,41 @@ export {
1491
3079
  SessionRepository,
1492
3080
  SettingsRepository,
1493
3081
  SseHub,
3082
+ StorePreviewMediaUseCase,
3083
+ TRANSCRIPTION_MODE,
3084
+ TRANSCRIPTION_STATUS,
1494
3085
  TakeoverConversationUseCase,
3086
+ TranscribeAudioUseCase,
3087
+ WEBHOOK_CLAIM_TTL_SECONDS,
1495
3088
  WEBHOOK_NONCE_TTL_SECONDS,
1496
3089
  WhatsAppChannelAdapter,
3090
+ buildInboundJobId,
1497
3091
  claimWebhookDelivery,
3092
+ confirmWebhookDelivery,
1498
3093
  createMetaWhatsAppModule,
3094
+ createSendMediaAction,
3095
+ createSendProductListAction,
3096
+ createTranscriptionPolicyResolver,
3097
+ documents,
1499
3098
  extractMediaDescriptor,
1500
3099
  flowGraphs,
3100
+ flowMedia,
3101
+ isAudioMimeType,
3102
+ isRetriableTranscriptionError,
3103
+ isUnsupportedTranscriptionError,
1501
3104
  issueSseTicket,
1502
3105
  messages,
1503
3106
  metaWhatsAppMigrationsFolder,
1504
3107
  metaWhatsAppSchema,
1505
3108
  redeemSseTicket,
3109
+ resolveFailureStatus,
3110
+ resolvePreviewUploadId,
1506
3111
  runMetaWhatsAppMigrations,
1507
3112
  sessions,
1508
3113
  settings,
3114
+ toPreviewMediaId,
3115
+ toSessionContract,
3116
+ transcriptionRetryAfterSeconds,
1509
3117
  verifyWebhookChallenge,
1510
3118
  verifyWebhookSignature
1511
3119
  };