@adatechnology/meta-whatsapp-module 0.2.0-rc.16 → 0.2.0-rc.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -44,6 +44,7 @@ __export(index_exports, {
44
44
  META_WHATSAPP_MIGRATIONS_TABLE: () => META_WHATSAPP_MIGRATIONS_TABLE,
45
45
  MessageRepository: () => MessageRepository,
46
46
  OptimisticLockError: () => OptimisticLockError,
47
+ PREVIEW_MEDIA_ID_PREFIX: () => import_meta_whatsapp_contracts8.PREVIEW_MEDIA_ID_PREFIX,
47
48
  PurgeExpiredDocumentsUseCase: () => PurgeExpiredDocumentsUseCase,
48
49
  ReceiveWebhookUseCase: () => ReceiveWebhookUseCase,
49
50
  ReleaseConversationUseCase: () => ReleaseConversationUseCase,
@@ -52,24 +53,36 @@ __export(index_exports, {
52
53
  SessionRepository: () => SessionRepository,
53
54
  SettingsRepository: () => SettingsRepository,
54
55
  SseHub: () => SseHub,
56
+ StorePreviewMediaUseCase: () => StorePreviewMediaUseCase,
57
+ TRANSCRIPTION_MODE: () => TRANSCRIPTION_MODE,
58
+ TRANSCRIPTION_STATUS: () => TRANSCRIPTION_STATUS,
55
59
  TakeoverConversationUseCase: () => TakeoverConversationUseCase,
60
+ TranscribeAudioUseCase: () => TranscribeAudioUseCase,
56
61
  WEBHOOK_NONCE_TTL_SECONDS: () => WEBHOOK_NONCE_TTL_SECONDS,
57
62
  WhatsAppChannelAdapter: () => WhatsAppChannelAdapter,
58
63
  claimWebhookDelivery: () => claimWebhookDelivery,
59
64
  createMetaWhatsAppModule: () => createMetaWhatsAppModule,
60
65
  createSendMediaAction: () => createSendMediaAction,
66
+ createTranscriptionPolicyResolver: () => createTranscriptionPolicyResolver,
61
67
  documents: () => documents,
62
68
  extractMediaDescriptor: () => extractMediaDescriptor,
63
69
  flowGraphs: () => flowGraphs,
64
70
  flowMedia: () => flowMedia,
71
+ isAudioMimeType: () => isAudioMimeType,
72
+ isRetriableTranscriptionError: () => isRetriableTranscriptionError,
73
+ isUnsupportedTranscriptionError: () => isUnsupportedTranscriptionError,
65
74
  issueSseTicket: () => issueSseTicket,
66
75
  messages: () => messages,
67
76
  metaWhatsAppMigrationsFolder: () => metaWhatsAppMigrationsFolder,
68
77
  metaWhatsAppSchema: () => metaWhatsAppSchema,
69
78
  redeemSseTicket: () => redeemSseTicket,
79
+ resolveFailureStatus: () => resolveFailureStatus,
80
+ resolvePreviewUploadId: () => import_meta_whatsapp_contracts8.resolvePreviewUploadId,
70
81
  runMetaWhatsAppMigrations: () => runMetaWhatsAppMigrations,
71
82
  sessions: () => sessions,
72
83
  settings: () => settings,
84
+ toPreviewMediaId: () => import_meta_whatsapp_contracts8.toPreviewMediaId,
85
+ transcriptionRetryAfterSeconds: () => transcriptionRetryAfterSeconds,
73
86
  verifyWebhookChallenge: () => verifyWebhookChallenge,
74
87
  verifyWebhookSignature: () => verifyWebhookSignature
75
88
  });
@@ -77,7 +90,7 @@ module.exports = __toCommonJS(index_exports);
77
90
 
78
91
  // src/createMetaWhatsAppModule.ts
79
92
  var import_meta_whatsapp_provider = require("@adatechnology/meta-whatsapp-provider");
80
- var import_meta_whatsapp_contracts11 = require("@adatechnology/meta-whatsapp-contracts");
93
+ var import_meta_whatsapp_contracts13 = require("@adatechnology/meta-whatsapp-contracts");
81
94
 
82
95
  // src/repositories/SessionRepository.ts
83
96
  var import_drizzle_orm2 = require("drizzle-orm");
@@ -180,6 +193,27 @@ var messages = metaWhatsAppSchema.table("messages", {
180
193
  // parcial sobre boolean resolve isso sem cavar jsonb.
181
194
  moderationFlagged: (0, import_pg_core.boolean)("moderation_flagged"),
182
195
  moderationTerms: (0, import_pg_core.jsonb)("moderation_terms").$type(),
196
+ /**
197
+ * Transcrição de áudio. `null` em `transcription_status` significa NÃO AVALIADO — áudio nunca
198
+ * pedido (modo sob demanda), transcrição desligada, mensagem anterior ao recurso, ou mensagem
199
+ * que não é áudio. Diferente de `'done'` com texto vazio, que é áudio em silêncio já processado
200
+ * e que NÃO deve ser reprocessado.
201
+ *
202
+ * Colunas em vez de chave em `payload` pelo mesmo motivo da moderação: "quais áudios ficaram
203
+ * pendentes" e "quais falharam" são consultas de operação, e índice parcial resolve sem cavar
204
+ * jsonb. Buscar texto de áudio também deixa de exigir varredura de payload.
205
+ */
206
+ transcriptionStatus: (0, import_pg_core.varchar)("transcription_status", {
207
+ length: 16
208
+ }).$type(),
209
+ transcriptionText: (0, import_pg_core.text)("transcription_text"),
210
+ transcriptionLanguage: (0, import_pg_core.varchar)("transcription_language", {
211
+ length: 32
212
+ }),
213
+ /** Qual engine produziu. Com uma cadeia de engines, é o que responde "por que esta saiu ruim". */
214
+ transcriptionEngine: (0, import_pg_core.varchar)("transcription_engine", {
215
+ length: 32
216
+ }),
183
217
  createdAt: (0, import_pg_core.timestamp)("created_at", {
184
218
  withTimezone: true
185
219
  }).notNull().defaultNow()
@@ -194,7 +228,11 @@ var messages = metaWhatsAppSchema.table("messages", {
194
228
  (0, import_pg_core.uniqueIndex)("idx_messages_company_wa_message_id").on(table.companyId, table.waMessageId).where(import_drizzle_orm.sql`${table.waMessageId} is not null`),
195
229
  // Parcial: só as sinalizadas entram, então o índice fica do tamanho do problema e não do
196
230
  // tamanho do transcript.
197
- (0, import_pg_core.index)("idx_messages_moderation_flagged").on(table.companyId, table.createdAt).where(import_drizzle_orm.sql`${table.moderationFlagged}`)
231
+ (0, import_pg_core.index)("idx_messages_moderation_flagged").on(table.companyId, table.createdAt).where(import_drizzle_orm.sql`${table.moderationFlagged}`),
232
+ // Alimenta a varredura de retomada: "quais áudios ficaram pendentes de transcrição". Parcial
233
+ // porque pendente é estado transitório e raro — o índice fica do tamanho da fila atrasada, não
234
+ // do transcript inteiro.
235
+ (0, import_pg_core.index)("idx_messages_transcription_pending").on(table.companyId, table.createdAt).where(import_drizzle_orm.sql`${table.transcriptionStatus} = 'pending'`)
198
236
  ]);
199
237
  var documents = metaWhatsAppSchema.table("documents", {
200
238
  id: (0, import_pg_core.uuid)("id").primaryKey().defaultRandom(),
@@ -317,6 +355,15 @@ var settings = metaWhatsAppSchema.table("settings", {
317
355
  templateVariables: (0, import_pg_core.jsonb)("template_variables").$type().notNull().default([]),
318
356
  welcomeMessage: (0, import_pg_core.text)("welcome_message"),
319
357
  farewellMessage: (0, import_pg_core.text)("farewell_message"),
358
+ /**
359
+ * Política de transcrição desta empresa. Nulo é significativo: "o painel não decidiu", e aí vale
360
+ * o padrão que o host injetou. Sem a distinção, atualizar o módulo desligaria a transcrição de
361
+ * quem já a tinha ligada por ambiente.
362
+ */
363
+ transcriptionEnabled: (0, import_pg_core.boolean)("transcription_enabled"),
364
+ transcriptionMode: (0, import_pg_core.varchar)("transcription_mode", {
365
+ length: 16
366
+ }).$type(),
320
367
  createdAt: (0, import_pg_core.timestamp)("created_at", {
321
368
  withTimezone: true
322
369
  }).notNull().defaultNow(),
@@ -580,6 +627,58 @@ var MessageRepository = class {
580
627
  }).where((0, import_drizzle_orm3.and)((0, import_drizzle_orm3.eq)(messages.companyId, companyId), (0, import_drizzle_orm3.eq)(messages.waMessageId, waMessageId))).returning();
581
628
  return updated;
582
629
  }
630
+ /**
631
+ * Grava a transcrição endereçando pelo id da Meta, para quem só tem esse.
632
+ *
633
+ * Serve ao caso em que a transcrição acontece no próprio webhook — o grafo precisa do texto para
634
+ * responder ao cliente, e jogar fora o que ele já pagou para transcrever significaria transcrever
635
+ * o mesmo áudio uma segunda vez só para o painel ver.
636
+ *
637
+ * Devolve `undefined` quando não achou a mensagem: entrega duplicada e mensagem apagada são
638
+ * corridas normais, não erro.
639
+ */
640
+ async saveTranscriptionByWaMessageId(params) {
641
+ const [updated] = await this.db.update(messages).set({
642
+ transcriptionStatus: params.status,
643
+ ...params.text !== void 0 ? {
644
+ transcriptionText: params.text
645
+ } : {},
646
+ ...params.language !== void 0 ? {
647
+ transcriptionLanguage: params.language
648
+ } : {},
649
+ ...params.engine !== void 0 ? {
650
+ transcriptionEngine: params.engine
651
+ } : {}
652
+ }).where((0, import_drizzle_orm3.and)((0, import_drizzle_orm3.eq)(messages.companyId, params.companyId), (0, import_drizzle_orm3.eq)(messages.waMessageId, params.waMessageId))).returning();
653
+ return updated;
654
+ }
655
+ async findById(companyId, messageId) {
656
+ const [found] = await this.db.select().from(messages).where((0, import_drizzle_orm3.and)((0, import_drizzle_orm3.eq)(messages.companyId, companyId), (0, import_drizzle_orm3.eq)(messages.id, messageId))).limit(1);
657
+ return found;
658
+ }
659
+ /**
660
+ * Grava o resultado da transcrição. Devolve `undefined` quando a mensagem não existe (apagada
661
+ * entre o enfileiramento e a execução do job) — não é erro, é corrida normal.
662
+ *
663
+ * `text`/`language`/`engine` só são tocados quando informados: uma retentativa que volta a falhar
664
+ * atualiza o status sem apagar a transcrição parcial de uma tentativa anterior que tenha vindo de
665
+ * outro engine da cadeia.
666
+ */
667
+ async saveTranscription(params) {
668
+ const [updated] = await this.db.update(messages).set({
669
+ transcriptionStatus: params.status,
670
+ ...params.text !== void 0 ? {
671
+ transcriptionText: params.text
672
+ } : {},
673
+ ...params.language !== void 0 ? {
674
+ transcriptionLanguage: params.language
675
+ } : {},
676
+ ...params.engine !== void 0 ? {
677
+ transcriptionEngine: params.engine
678
+ } : {}
679
+ }).where((0, import_drizzle_orm3.and)((0, import_drizzle_orm3.eq)(messages.companyId, params.companyId), (0, import_drizzle_orm3.eq)(messages.id, params.messageId))).returning();
680
+ return updated;
681
+ }
583
682
  async listByConversation(params) {
584
683
  const conditions = [
585
684
  (0, import_drizzle_orm3.eq)(messages.companyId, params.companyId),
@@ -764,7 +863,11 @@ function toContractSettings(row) {
764
863
  templateLanguage: row.templateLanguage,
765
864
  templateVariables: row.templateVariables,
766
865
  welcomeMessage: row.welcomeMessage ?? "",
767
- farewellMessage: row.farewellMessage ?? ""
866
+ farewellMessage: row.farewellMessage ?? "",
867
+ // `?? null` e não `?? false`: nulo é "o painel não decidiu", e é o que faz valer o padrão do
868
+ // host. Colapsar para `false` desligaria quem ligou por ambiente.
869
+ transcriptionEnabled: row.transcriptionEnabled ?? null,
870
+ transcriptionMode: row.transcriptionMode ?? null
768
871
  };
769
872
  }
770
873
  __name(toContractSettings, "toContractSettings");
@@ -773,7 +876,9 @@ var EMPTY_SETTINGS = {
773
876
  templateLanguage: "pt_BR",
774
877
  templateVariables: [],
775
878
  welcomeMessage: "",
776
- farewellMessage: ""
879
+ farewellMessage: "",
880
+ transcriptionEnabled: null,
881
+ transcriptionMode: null
777
882
  };
778
883
  var SettingsRepository = class {
779
884
  static {
@@ -802,7 +907,12 @@ var SettingsRepository = class {
802
907
  templateLanguage: merged.templateLanguage,
803
908
  templateVariables: merged.templateVariables,
804
909
  welcomeMessage: merged.welcomeMessage || null,
805
- farewellMessage: merged.farewellMessage || null
910
+ farewellMessage: merged.farewellMessage || null,
911
+ // Sem `|| null`: `false` aqui é decisão explícita do painel ("desligado para esta empresa"),
912
+ // e colapsá-lo para nulo faria a empresa voltar a herdar o padrão do host — exatamente o
913
+ // oposto do que o operador acabou de pedir.
914
+ transcriptionEnabled: merged.transcriptionEnabled,
915
+ transcriptionMode: merged.transcriptionMode
806
916
  }).onConflictDoUpdate({
807
917
  target: settings.companyId,
808
918
  set: {
@@ -811,6 +921,8 @@ var SettingsRepository = class {
811
921
  templateVariables: merged.templateVariables,
812
922
  welcomeMessage: merged.welcomeMessage || null,
813
923
  farewellMessage: merged.farewellMessage || null,
924
+ transcriptionEnabled: merged.transcriptionEnabled,
925
+ transcriptionMode: merged.transcriptionMode,
814
926
  updatedAt: /* @__PURE__ */ new Date()
815
927
  }
816
928
  }).returning();
@@ -1799,20 +1911,27 @@ var FlowMediaRepository = class {
1799
1911
 
1800
1912
  // src/channel/WhatsAppChannelAdapter.ts
1801
1913
  var import_meta_graph_core = require("@adatechnology/meta-graph-core");
1914
+ var import_meta_whatsapp_contracts9 = require("@adatechnology/meta-whatsapp-contracts");
1915
+
1916
+ // src/channel/previewMedia.ts
1802
1917
  var import_meta_whatsapp_contracts8 = require("@adatechnology/meta-whatsapp-contracts");
1918
+
1919
+ // src/channel/WhatsAppChannelAdapter.ts
1803
1920
  var WhatsAppChannelAdapter = class {
1804
1921
  static {
1805
1922
  __name(this, "WhatsAppChannelAdapter");
1806
1923
  }
1807
1924
  messages;
1808
- constructor(messages2) {
1925
+ previewMedia;
1926
+ constructor(messages2, previewMedia) {
1809
1927
  this.messages = messages2;
1928
+ this.previewMedia = previewMedia;
1810
1929
  }
1811
1930
  async translateErrors(operation) {
1812
1931
  try {
1813
1932
  return await operation();
1814
1933
  } catch (error) {
1815
- if (error instanceof import_meta_graph_core.WhatsAppWindowExpiredError) throw new import_meta_whatsapp_contracts8.WindowExpiredError();
1934
+ if (error instanceof import_meta_graph_core.WhatsAppWindowExpiredError) throw new import_meta_whatsapp_contracts9.WindowExpiredError();
1816
1935
  throw error;
1817
1936
  }
1818
1937
  }
@@ -1850,17 +1969,31 @@ var WhatsAppChannelAdapter = class {
1850
1969
  externalMessageId: result.waMessageId
1851
1970
  };
1852
1971
  }
1972
+ /**
1973
+ * Busca o binário da mídia — da Meta, ou do storage quando o id é do simulador.
1974
+ *
1975
+ * O desvio acontece ANTES de qualquer chamada de rede: id do simulador não existe na Meta, e
1976
+ * tentar buscá-lo lá renderia um 404 confuso em vez do áudio que o operador acabou de gravar.
1977
+ */
1853
1978
  async fetchMediaAsBase64(mediaId) {
1979
+ const uploadId = this.previewMedia?.isEnabled ? (0, import_meta_whatsapp_contracts8.resolvePreviewUploadId)(mediaId) : void 0;
1980
+ if (uploadId) {
1981
+ const buffer = await this.previewMedia.objectStorage.getObject(uploadId);
1982
+ return {
1983
+ data: buffer.toString("base64"),
1984
+ mimeType: this.previewMedia.defaultMimeType ?? "audio/ogg"
1985
+ };
1986
+ }
1854
1987
  return this.translateErrors(() => this.messages.fetchMediaAsBase64(mediaId));
1855
1988
  }
1856
1989
  };
1857
1990
 
1858
1991
  // src/channel/ReceiveWebhook.use-case.ts
1859
- var import_meta_whatsapp_contracts10 = require("@adatechnology/meta-whatsapp-contracts");
1992
+ var import_meta_whatsapp_contracts12 = require("@adatechnology/meta-whatsapp-contracts");
1860
1993
 
1861
1994
  // src/channel/webhookSecurity.ts
1862
1995
  var import_node_crypto = require("crypto");
1863
- var import_meta_whatsapp_contracts9 = require("@adatechnology/meta-whatsapp-contracts");
1996
+ var import_meta_whatsapp_contracts10 = require("@adatechnology/meta-whatsapp-contracts");
1864
1997
  var WEBHOOK_NONCE_TTL_SECONDS = 300;
1865
1998
  function safeEqualStrings(left, right) {
1866
1999
  const leftDigest = (0, import_node_crypto.createHmac)("sha256", "constant-time-compare").update(left).digest();
@@ -1870,20 +2003,20 @@ function safeEqualStrings(left, right) {
1870
2003
  __name(safeEqualStrings, "safeEqualStrings");
1871
2004
  function verifyWebhookChallenge(params) {
1872
2005
  if (params.mode !== "subscribe" || !params.token || !params.challenge) {
1873
- throw new import_meta_whatsapp_contracts9.InvalidWebhookSignatureError();
2006
+ throw new import_meta_whatsapp_contracts10.InvalidWebhookSignatureError();
1874
2007
  }
1875
2008
  if (!safeEqualStrings(params.token, params.expectedToken)) {
1876
- throw new import_meta_whatsapp_contracts9.InvalidWebhookSignatureError();
2009
+ throw new import_meta_whatsapp_contracts10.InvalidWebhookSignatureError();
1877
2010
  }
1878
2011
  return params.challenge;
1879
2012
  }
1880
2013
  __name(verifyWebhookChallenge, "verifyWebhookChallenge");
1881
2014
  function verifyWebhookSignature(params) {
1882
2015
  const { rawBody, signatureHeader, appSecret } = params;
1883
- if (!signatureHeader?.startsWith("sha256=")) throw new import_meta_whatsapp_contracts9.InvalidWebhookSignatureError();
2016
+ if (!signatureHeader?.startsWith("sha256=")) throw new import_meta_whatsapp_contracts10.InvalidWebhookSignatureError();
1884
2017
  const expected = (0, import_node_crypto.createHmac)("sha256", appSecret).update(rawBody).digest("hex");
1885
2018
  const received = signatureHeader.slice("sha256=".length);
1886
- if (!safeEqualStrings(received, expected)) throw new import_meta_whatsapp_contracts9.InvalidWebhookSignatureError();
2019
+ if (!safeEqualStrings(received, expected)) throw new import_meta_whatsapp_contracts10.InvalidWebhookSignatureError();
1887
2020
  }
1888
2021
  __name(verifyWebhookSignature, "verifyWebhookSignature");
1889
2022
  async function claimWebhookDelivery(params) {
@@ -1894,6 +2027,160 @@ __name(claimWebhookDelivery, "claimWebhookDelivery");
1894
2027
 
1895
2028
  // src/channel/IngestInboundMedia.use-case.ts
1896
2029
  var import_drizzle_orm8 = require("drizzle-orm");
2030
+
2031
+ // src/use-cases/TranscribeAudio.use-case.ts
2032
+ var import_meta_whatsapp_contracts11 = require("@adatechnology/meta-whatsapp-contracts");
2033
+
2034
+ // src/transcription.types.ts
2035
+ var TRANSCRIPTION_STATUS = {
2036
+ /** Falhou de forma retriável (cota, rede, 5xx) — vai sair quando alguém tentar de novo. */
2037
+ PENDING: "pending",
2038
+ /** Processado. Texto vazio aqui é áudio em silêncio, e NÃO deve ser reprocessado. */
2039
+ DONE: "done",
2040
+ /** Falha definitiva do engine (credencial, áudio corrompido, arquivo grande demais). */
2041
+ FAILED: "failed",
2042
+ /** Nenhum engine da cadeia aceita o formato. Retentar não conserta codec. */
2043
+ UNSUPPORTED: "unsupported"
2044
+ };
2045
+ var TRANSCRIPTION_MODE = {
2046
+ AUTO: "auto",
2047
+ ON_DEMAND: "onDemand"
2048
+ };
2049
+ function isRetriableTranscriptionError(error) {
2050
+ if (typeof error !== "object" || error === null) return true;
2051
+ const isRetriable = error.isRetriable;
2052
+ return typeof isRetriable === "boolean" ? isRetriable : true;
2053
+ }
2054
+ __name(isRetriableTranscriptionError, "isRetriableTranscriptionError");
2055
+ function isUnsupportedTranscriptionError(error) {
2056
+ return typeof error === "object" && error !== null && error.name === "TranscriptionUnsupportedError";
2057
+ }
2058
+ __name(isUnsupportedTranscriptionError, "isUnsupportedTranscriptionError");
2059
+ function transcriptionRetryAfterSeconds(error) {
2060
+ if (typeof error !== "object" || error === null) return void 0;
2061
+ const retryAfter = error.retryAfterSeconds;
2062
+ return typeof retryAfter === "number" ? retryAfter : void 0;
2063
+ }
2064
+ __name(transcriptionRetryAfterSeconds, "transcriptionRetryAfterSeconds");
2065
+ function isAudioMimeType(mimeType) {
2066
+ return typeof mimeType === "string" && mimeType.trim().toLowerCase().startsWith("audio/");
2067
+ }
2068
+ __name(isAudioMimeType, "isAudioMimeType");
2069
+
2070
+ // src/use-cases/TranscribeAudio.use-case.ts
2071
+ var TranscribeAudioUseCase = class {
2072
+ static {
2073
+ __name(this, "TranscribeAudioUseCase");
2074
+ }
2075
+ dependencies;
2076
+ constructor(dependencies) {
2077
+ this.dependencies = dependencies;
2078
+ }
2079
+ async execute(params) {
2080
+ const message = await this.dependencies.messageRepository.findById(params.companyId, params.messageId);
2081
+ if (!message) throw new Error(`Mensagem ${params.messageId} n\xE3o encontrada para transcri\xE7\xE3o`);
2082
+ if (message.transcriptionStatus === TRANSCRIPTION_STATUS.DONE && !params.force) {
2083
+ return {
2084
+ status: TRANSCRIPTION_STATUS.DONE,
2085
+ text: message.transcriptionText,
2086
+ language: message.transcriptionLanguage,
2087
+ engine: message.transcriptionEngine,
2088
+ alreadyTranscribed: true
2089
+ };
2090
+ }
2091
+ if (this.dependencies.resolvePolicy) {
2092
+ const policy = await this.dependencies.resolvePolicy(params.companyId);
2093
+ if (!policy.isEnabled) throw new import_meta_whatsapp_contracts11.TranscriptionDisabledError();
2094
+ }
2095
+ const audio = extractAudioReference(message);
2096
+ const buffer = await this.dependencies.objectStorage.getObject(audio.uploadId);
2097
+ return this.transcribeBuffer({
2098
+ ...params,
2099
+ buffer,
2100
+ mimeType: audio.mimeType,
2101
+ uploadId: audio.uploadId,
2102
+ message
2103
+ });
2104
+ }
2105
+ async transcribeBuffer(context) {
2106
+ try {
2107
+ const result = await this.dependencies.transcriber.transcribe({
2108
+ buffer: context.buffer,
2109
+ mimeType: context.mimeType,
2110
+ ...this.dependencies.languageHint ? {
2111
+ languageHint: this.dependencies.languageHint
2112
+ } : {}
2113
+ });
2114
+ await this.dependencies.messageRepository.saveTranscription({
2115
+ companyId: context.companyId,
2116
+ messageId: context.messageId,
2117
+ status: TRANSCRIPTION_STATUS.DONE,
2118
+ text: result.text,
2119
+ language: result.language ?? null,
2120
+ engine: result.engine
2121
+ });
2122
+ return {
2123
+ status: TRANSCRIPTION_STATUS.DONE,
2124
+ text: result.text,
2125
+ language: result.language ?? null,
2126
+ engine: result.engine,
2127
+ alreadyTranscribed: false
2128
+ };
2129
+ } catch (error) {
2130
+ await this.persistFailure(context, error);
2131
+ throw error;
2132
+ }
2133
+ }
2134
+ /**
2135
+ * Carimba o motivo antes de propagar. O status é o que impede os dois desperdícios simétricos:
2136
+ * reprocessar para sempre um codec impossível, e desistir de um áudio que só esbarrou na cota.
2137
+ */
2138
+ async persistFailure(context, error) {
2139
+ const status = resolveFailureStatus(error);
2140
+ await this.dependencies.messageRepository.saveTranscription({
2141
+ companyId: context.companyId,
2142
+ messageId: context.messageId,
2143
+ status
2144
+ });
2145
+ if (status !== TRANSCRIPTION_STATUS.PENDING) return;
2146
+ const retryAfterSeconds = transcriptionRetryAfterSeconds(error);
2147
+ await this.dependencies.hooks?.onTranscriptionDeferred?.({
2148
+ companyId: context.companyId,
2149
+ messageId: context.messageId,
2150
+ whatsappNumber: context.message.whatsappNumber,
2151
+ uploadId: context.uploadId,
2152
+ ...retryAfterSeconds !== void 0 ? {
2153
+ retryAfterSeconds
2154
+ } : {},
2155
+ reason: retryAfterSeconds !== void 0 ? "rate-limited" : "transient-failure",
2156
+ error
2157
+ });
2158
+ }
2159
+ };
2160
+ function resolveFailureStatus(error) {
2161
+ if (isUnsupportedTranscriptionError(error)) return TRANSCRIPTION_STATUS.UNSUPPORTED;
2162
+ return isRetriableTranscriptionError(error) ? TRANSCRIPTION_STATUS.PENDING : TRANSCRIPTION_STATUS.FAILED;
2163
+ }
2164
+ __name(resolveFailureStatus, "resolveFailureStatus");
2165
+ function extractAudioReference(message) {
2166
+ const payload = message.payload ?? {};
2167
+ const audio = payload["audio"];
2168
+ const mimeType = typeof payload["mimeType"] === "string" ? payload["mimeType"] : audio?.mime_type;
2169
+ if (!isAudioMimeType(mimeType) && !audio) {
2170
+ throw new import_meta_whatsapp_contracts11.MessageNotAudioError(message.id, message.type);
2171
+ }
2172
+ const uploadId = payload["uploadId"];
2173
+ if (typeof uploadId !== "string" || uploadId.length === 0) {
2174
+ throw new import_meta_whatsapp_contracts11.AudioNotIngestedError(message.id);
2175
+ }
2176
+ return {
2177
+ uploadId,
2178
+ mimeType: mimeType ?? "audio/ogg"
2179
+ };
2180
+ }
2181
+ __name(extractAudioReference, "extractAudioReference");
2182
+
2183
+ // src/channel/IngestInboundMedia.use-case.ts
1897
2184
  var IngestInboundMediaUseCase = class {
1898
2185
  static {
1899
2186
  __name(this, "IngestInboundMediaUseCase");
@@ -1902,11 +2189,13 @@ var IngestInboundMediaUseCase = class {
1902
2189
  channel;
1903
2190
  objectStorage;
1904
2191
  documentRepository;
1905
- constructor(db, channel, objectStorage, documentRepository) {
2192
+ transcription;
2193
+ constructor(db, channel, objectStorage, documentRepository, transcription) {
1906
2194
  this.db = db;
1907
2195
  this.channel = channel;
1908
2196
  this.objectStorage = objectStorage;
1909
2197
  this.documentRepository = documentRepository;
2198
+ this.transcription = transcription;
1910
2199
  }
1911
2200
  async execute(params) {
1912
2201
  const [message] = await this.db.select().from(messages).where((0, import_drizzle_orm8.and)((0, import_drizzle_orm8.eq)(messages.companyId, params.companyId), (0, import_drizzle_orm8.eq)(messages.id, params.messageId))).limit(1);
@@ -1952,9 +2241,84 @@ var IngestInboundMediaUseCase = class {
1952
2241
  sizeBytes: buffer.length,
1953
2242
  source: message.sender
1954
2243
  });
2244
+ const transcription = await this.transcribeIfAuto({
2245
+ companyId: params.companyId,
2246
+ message,
2247
+ uploadId,
2248
+ buffer,
2249
+ mimeType: mimeType || params.mimeType
2250
+ });
1955
2251
  return {
1956
2252
  uploadId,
1957
- alreadyIngested: false
2253
+ alreadyIngested: false,
2254
+ ...transcription ? {
2255
+ transcription
2256
+ } : {}
2257
+ };
2258
+ }
2259
+ /**
2260
+ * Transcreve o áudio recém-baixado, quando o modo é `auto`.
2261
+ *
2262
+ * **Nunca propaga erro.** Neste ponto o binário já está no storage e já entrou na biblioteca da
2263
+ * conversa: deixar uma falha de transcrição subir marcaria a ingestão inteira como falha, e o
2264
+ * retry do host baixaria de novo da Meta um arquivo que está salvo — gastando banda para reproduzir
2265
+ * um efeito que já aconteceu. O status fica gravado na mensagem e o `onTranscriptionDeferred`
2266
+ * avisa quem sabe reenfileirar.
2267
+ */
2268
+ async transcribeIfAuto(context) {
2269
+ const transcription = this.transcription;
2270
+ if (!transcription) return void 0;
2271
+ if (!isAudioMimeType(context.mimeType)) return void 0;
2272
+ const policy = await transcription.resolvePolicy(context.companyId);
2273
+ if (!policy.isEnabled || policy.mode !== TRANSCRIPTION_MODE.AUTO) return void 0;
2274
+ const current = await transcription.messageRepository.findById(context.companyId, context.message.id);
2275
+ if (current?.transcriptionStatus === TRANSCRIPTION_STATUS.DONE) return void 0;
2276
+ try {
2277
+ const result = await transcription.transcriber.transcribe({
2278
+ buffer: context.buffer,
2279
+ mimeType: context.mimeType,
2280
+ ...transcription.languageHint ? {
2281
+ languageHint: transcription.languageHint
2282
+ } : {}
2283
+ });
2284
+ await transcription.messageRepository.saveTranscription({
2285
+ companyId: context.companyId,
2286
+ messageId: context.message.id,
2287
+ status: TRANSCRIPTION_STATUS.DONE,
2288
+ text: result.text,
2289
+ language: result.language ?? null,
2290
+ engine: result.engine
2291
+ });
2292
+ return {
2293
+ status: TRANSCRIPTION_STATUS.DONE
2294
+ };
2295
+ } catch (error) {
2296
+ return this.recordTranscriptionFailure(context, error);
2297
+ }
2298
+ }
2299
+ async recordTranscriptionFailure(context, error) {
2300
+ const status = resolveFailureStatus(error);
2301
+ await this.transcription?.messageRepository.saveTranscription({
2302
+ companyId: context.companyId,
2303
+ messageId: context.message.id,
2304
+ status
2305
+ });
2306
+ if (status === TRANSCRIPTION_STATUS.PENDING) {
2307
+ const retryAfterSeconds = transcriptionRetryAfterSeconds(error);
2308
+ await this.transcription?.hooks?.onTranscriptionDeferred?.({
2309
+ companyId: context.companyId,
2310
+ messageId: context.message.id,
2311
+ whatsappNumber: context.message.whatsappNumber,
2312
+ uploadId: context.uploadId,
2313
+ ...retryAfterSeconds !== void 0 ? {
2314
+ retryAfterSeconds
2315
+ } : {},
2316
+ reason: retryAfterSeconds !== void 0 ? "rate-limited" : "transient-failure",
2317
+ error
2318
+ });
2319
+ }
2320
+ return {
2321
+ status
1958
2322
  };
1959
2323
  }
1960
2324
  };
@@ -2057,7 +2421,7 @@ var ReceiveWebhookUseCase = class {
2057
2421
  statusesProcessed: 0
2058
2422
  };
2059
2423
  const rawText = typeof input.rawBody === "string" ? input.rawBody : input.rawBody.toString("utf8");
2060
- const payload = import_meta_whatsapp_contracts10.whatsAppWebhookPayloadSchema.parse(JSON.parse(rawText));
2424
+ const payload = import_meta_whatsapp_contracts12.whatsAppWebhookPayloadSchema.parse(JSON.parse(rawText));
2061
2425
  let messagesProcessed = 0;
2062
2426
  let statusesProcessed = 0;
2063
2427
  for (const entry of payload.entry) {
@@ -2124,6 +2488,51 @@ var ReceiveWebhookUseCase = class {
2124
2488
  }
2125
2489
  };
2126
2490
 
2491
+ // src/use-cases/resolveTranscriptionPolicy.ts
2492
+ function createTranscriptionPolicyResolver(dependencies) {
2493
+ return /* @__PURE__ */ __name(async function resolveTranscriptionPolicy(companyId) {
2494
+ const settings2 = await dependencies.settingsRepository.get(companyId);
2495
+ return {
2496
+ // `??` e não `||`: `false` gravado é decisão explícita de desligar, e `||` a trocaria pelo
2497
+ // padrão do host — desligar no painel não faria nada num deploy com transcrição ligada.
2498
+ isEnabled: settings2.transcriptionEnabled ?? dependencies.defaults.isEnabled,
2499
+ mode: normalizeMode(settings2.transcriptionMode) ?? dependencies.defaults.mode
2500
+ };
2501
+ }, "resolveTranscriptionPolicy");
2502
+ }
2503
+ __name(createTranscriptionPolicyResolver, "createTranscriptionPolicyResolver");
2504
+ function normalizeMode(value) {
2505
+ if (value === TRANSCRIPTION_MODE.AUTO) return TRANSCRIPTION_MODE.AUTO;
2506
+ if (value === TRANSCRIPTION_MODE.ON_DEMAND) return TRANSCRIPTION_MODE.ON_DEMAND;
2507
+ return void 0;
2508
+ }
2509
+ __name(normalizeMode, "normalizeMode");
2510
+
2511
+ // src/use-cases/StorePreviewMedia.use-case.ts
2512
+ var StorePreviewMediaUseCase = class {
2513
+ static {
2514
+ __name(this, "StorePreviewMediaUseCase");
2515
+ }
2516
+ objectStorage;
2517
+ generateKeySuffix;
2518
+ constructor(objectStorage, generateKeySuffix) {
2519
+ this.objectStorage = objectStorage;
2520
+ this.generateKeySuffix = generateKeySuffix;
2521
+ }
2522
+ async execute(params) {
2523
+ const key = `meta-whatsapp/${params.companyId}/preview/${this.generateKeySuffix()}`;
2524
+ const { uploadId } = await this.objectStorage.upload({
2525
+ buffer: params.buffer,
2526
+ mimeType: params.mimeType,
2527
+ key
2528
+ });
2529
+ return {
2530
+ mediaId: (0, import_meta_whatsapp_contracts8.toPreviewMediaId)(uploadId),
2531
+ uploadId
2532
+ };
2533
+ }
2534
+ };
2535
+
2127
2536
  // src/createMetaWhatsAppModule.ts
2128
2537
  function createMetaWhatsAppModule(params) {
2129
2538
  const { db, config, nonceStore, providers = {}, hooks } = params;
@@ -2136,7 +2545,11 @@ function createMetaWhatsAppModule(params) {
2136
2545
  apiVersion: config.apiVersion,
2137
2546
  baseUrl: config.baseUrl
2138
2547
  });
2139
- const channel = new WhatsAppChannelAdapter(messageProvider);
2548
+ const previewMediaSupport = params.features?.previewMedia && providers.objectStorage?.getObject ? {
2549
+ isEnabled: true,
2550
+ objectStorage: providers.objectStorage
2551
+ } : void 0;
2552
+ const channel = new WhatsAppChannelAdapter(messageProvider, previewMediaSupport);
2140
2553
  const sessionRepository = new SessionRepository(db);
2141
2554
  const messageRepository = new MessageRepository(db);
2142
2555
  const settingsRepository = new SettingsRepository(db);
@@ -2159,7 +2572,7 @@ function createMetaWhatsAppModule(params) {
2159
2572
  });
2160
2573
  const flowInterpreter = flowEngineEnabled ? new FlowInterpreter() : void 0;
2161
2574
  if (flowInterpreter && providers.objectStorage?.getObject) {
2162
- flowInterpreter.registerFlowAction(import_meta_whatsapp_contracts11.FLOW_ACTION_KIND.SEND_MEDIA, createSendMediaAction({
2575
+ flowInterpreter.registerFlowAction(import_meta_whatsapp_contracts13.FLOW_ACTION_KIND.SEND_MEDIA, createSendMediaAction({
2163
2576
  flowMediaRepository,
2164
2577
  objectStorage: providers.objectStorage,
2165
2578
  logMessage,
@@ -2167,7 +2580,39 @@ function createMetaWhatsAppModule(params) {
2167
2580
  onError: hooks?.onFlowMediaError
2168
2581
  }));
2169
2582
  }
2170
- const ingestInboundMedia = providers.objectStorage ? new IngestInboundMediaUseCase(db, channel, providers.objectStorage, documentRepository) : void 0;
2583
+ const transcriptionMode = providers.transcription?.mode ?? TRANSCRIPTION_MODE.ON_DEMAND;
2584
+ const resolveTranscriptionPolicy = providers.transcription ? createTranscriptionPolicyResolver({
2585
+ settingsRepository,
2586
+ defaults: {
2587
+ isEnabled: providers.transcription.isEnabledByDefault ?? true,
2588
+ mode: transcriptionMode
2589
+ }
2590
+ }) : void 0;
2591
+ const ingestInboundMedia = providers.objectStorage ? new IngestInboundMediaUseCase(db, channel, providers.objectStorage, documentRepository, providers.transcription && resolveTranscriptionPolicy ? {
2592
+ transcriber: providers.transcription.transcriber,
2593
+ resolvePolicy: resolveTranscriptionPolicy,
2594
+ messageRepository,
2595
+ ...providers.transcription.languageHint ? {
2596
+ languageHint: providers.transcription.languageHint
2597
+ } : {},
2598
+ ...hooks ? {
2599
+ hooks
2600
+ } : {}
2601
+ } : void 0) : void 0;
2602
+ const transcribeAudio = providers.transcription && providers.objectStorage?.getObject ? new TranscribeAudioUseCase({
2603
+ messageRepository,
2604
+ objectStorage: providers.objectStorage,
2605
+ transcriber: providers.transcription.transcriber,
2606
+ ...resolveTranscriptionPolicy ? {
2607
+ resolvePolicy: resolveTranscriptionPolicy
2608
+ } : {},
2609
+ ...providers.transcription.languageHint ? {
2610
+ languageHint: providers.transcription.languageHint
2611
+ } : {},
2612
+ ...hooks ? {
2613
+ hooks
2614
+ } : {}
2615
+ }) : void 0;
2171
2616
  const listDocuments = new ListConversationDocumentsUseCase(sessionRepository, documentRepository);
2172
2617
  return {
2173
2618
  channel,
@@ -2188,10 +2633,28 @@ function createMetaWhatsAppModule(params) {
2188
2633
  delete: new DeleteConversationUseCase(sessionRepository, documentRepository, providers.objectStorage),
2189
2634
  purgeExpiredDocuments: new PurgeExpiredDocumentsUseCase(documentRepository, providers.objectStorage),
2190
2635
  export: new ExportConversationUseCase(sessionRepository),
2636
+ // undefined quando transcrição não foi injetada, ou quando o storage não sabe ler de volta.
2637
+ // O painel consulta a ausência para decidir se desenha o botão "transcrever".
2638
+ transcribeAudio,
2191
2639
  repository: sessionRepository,
2640
+ messageRepository,
2192
2641
  documentRepository
2193
2642
  },
2643
+ /**
2644
+ * `undefined` = o host não injetou transcritor, e nenhuma configuração de empresa muda isso: a
2645
+ * capacidade não existe. Presente, `resolvePolicy` responde o que vale para uma empresa —
2646
+ * é o que a rota de configurações usa para dizer ao painel se desenha o interruptor.
2647
+ */
2648
+ transcription: providers.transcription && resolveTranscriptionPolicy ? {
2649
+ defaultMode: transcriptionMode,
2650
+ resolvePolicy: resolveTranscriptionPolicy
2651
+ } : void 0,
2194
2652
  settings: settingsRepository,
2653
+ /**
2654
+ * `undefined` quando o recurso não está ligado (ou falta storage legível). O host consulta a
2655
+ * ausência para não registrar a rota de upload — e o preview, sem a rota, esconde o microfone.
2656
+ */
2657
+ previewMedia: previewMediaSupport ? new StorePreviewMediaUseCase(providers.objectStorage, () => `${Date.now()}-${Math.random().toString(36).slice(2)}`) : void 0,
2195
2658
  webhook: {
2196
2659
  receive: receiveWebhook,
2197
2660
  // GET de verificação da Meta — o host liga na sua rota e devolve o retorno como texto puro.
@@ -2336,6 +2799,7 @@ __name(redeemSseTicket, "redeemSseTicket");
2336
2799
  META_WHATSAPP_MIGRATIONS_TABLE,
2337
2800
  MessageRepository,
2338
2801
  OptimisticLockError,
2802
+ PREVIEW_MEDIA_ID_PREFIX,
2339
2803
  PurgeExpiredDocumentsUseCase,
2340
2804
  ReceiveWebhookUseCase,
2341
2805
  ReleaseConversationUseCase,
@@ -2344,24 +2808,36 @@ __name(redeemSseTicket, "redeemSseTicket");
2344
2808
  SessionRepository,
2345
2809
  SettingsRepository,
2346
2810
  SseHub,
2811
+ StorePreviewMediaUseCase,
2812
+ TRANSCRIPTION_MODE,
2813
+ TRANSCRIPTION_STATUS,
2347
2814
  TakeoverConversationUseCase,
2815
+ TranscribeAudioUseCase,
2348
2816
  WEBHOOK_NONCE_TTL_SECONDS,
2349
2817
  WhatsAppChannelAdapter,
2350
2818
  claimWebhookDelivery,
2351
2819
  createMetaWhatsAppModule,
2352
2820
  createSendMediaAction,
2821
+ createTranscriptionPolicyResolver,
2353
2822
  documents,
2354
2823
  extractMediaDescriptor,
2355
2824
  flowGraphs,
2356
2825
  flowMedia,
2826
+ isAudioMimeType,
2827
+ isRetriableTranscriptionError,
2828
+ isUnsupportedTranscriptionError,
2357
2829
  issueSseTicket,
2358
2830
  messages,
2359
2831
  metaWhatsAppMigrationsFolder,
2360
2832
  metaWhatsAppSchema,
2361
2833
  redeemSseTicket,
2834
+ resolveFailureStatus,
2835
+ resolvePreviewUploadId,
2362
2836
  runMetaWhatsAppMigrations,
2363
2837
  sessions,
2364
2838
  settings,
2839
+ toPreviewMediaId,
2840
+ transcriptionRetryAfterSeconds,
2365
2841
  verifyWebhookChallenge,
2366
2842
  verifyWebhookSignature
2367
2843
  });