@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.js CHANGED
@@ -113,6 +113,27 @@ var messages = metaWhatsAppSchema.table("messages", {
113
113
  // parcial sobre boolean resolve isso sem cavar jsonb.
114
114
  moderationFlagged: boolean("moderation_flagged"),
115
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
+ }),
116
137
  createdAt: timestamp("created_at", {
117
138
  withTimezone: true
118
139
  }).notNull().defaultNow()
@@ -127,7 +148,11 @@ var messages = metaWhatsAppSchema.table("messages", {
127
148
  uniqueIndex("idx_messages_company_wa_message_id").on(table.companyId, table.waMessageId).where(sql`${table.waMessageId} is not null`),
128
149
  // Parcial: só as sinalizadas entram, então o índice fica do tamanho do problema e não do
129
150
  // tamanho do transcript.
130
- index("idx_messages_moderation_flagged").on(table.companyId, table.createdAt).where(sql`${table.moderationFlagged}`)
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'`)
131
156
  ]);
132
157
  var documents = metaWhatsAppSchema.table("documents", {
133
158
  id: uuid("id").primaryKey().defaultRandom(),
@@ -250,6 +275,15 @@ var settings = metaWhatsAppSchema.table("settings", {
250
275
  templateVariables: jsonb("template_variables").$type().notNull().default([]),
251
276
  welcomeMessage: text("welcome_message"),
252
277
  farewellMessage: text("farewell_message"),
278
+ /**
279
+ * Política de transcrição desta empresa. Nulo é significativo: "o painel não decidiu", e aí vale
280
+ * o padrão que o host injetou. Sem a distinção, atualizar o módulo desligaria a transcrição de
281
+ * quem já a tinha ligada por ambiente.
282
+ */
283
+ transcriptionEnabled: boolean("transcription_enabled"),
284
+ transcriptionMode: varchar("transcription_mode", {
285
+ length: 16
286
+ }).$type(),
253
287
  createdAt: timestamp("created_at", {
254
288
  withTimezone: true
255
289
  }).notNull().defaultNow(),
@@ -513,6 +547,58 @@ var MessageRepository = class {
513
547
  }).where(and2(eq2(messages.companyId, companyId), eq2(messages.waMessageId, waMessageId))).returning();
514
548
  return updated;
515
549
  }
550
+ /**
551
+ * Grava a transcrição endereçando pelo id da Meta, para quem só tem esse.
552
+ *
553
+ * Serve ao caso em que a transcrição acontece no próprio webhook — o grafo precisa do texto para
554
+ * responder ao cliente, e jogar fora o que ele já pagou para transcrever significaria transcrever
555
+ * o mesmo áudio uma segunda vez só para o painel ver.
556
+ *
557
+ * Devolve `undefined` quando não achou a mensagem: entrega duplicada e mensagem apagada são
558
+ * corridas normais, não erro.
559
+ */
560
+ async saveTranscriptionByWaMessageId(params) {
561
+ const [updated] = await this.db.update(messages).set({
562
+ transcriptionStatus: params.status,
563
+ ...params.text !== void 0 ? {
564
+ transcriptionText: params.text
565
+ } : {},
566
+ ...params.language !== void 0 ? {
567
+ transcriptionLanguage: params.language
568
+ } : {},
569
+ ...params.engine !== void 0 ? {
570
+ transcriptionEngine: params.engine
571
+ } : {}
572
+ }).where(and2(eq2(messages.companyId, params.companyId), eq2(messages.waMessageId, params.waMessageId))).returning();
573
+ return updated;
574
+ }
575
+ async findById(companyId, messageId) {
576
+ const [found] = await this.db.select().from(messages).where(and2(eq2(messages.companyId, companyId), eq2(messages.id, messageId))).limit(1);
577
+ return found;
578
+ }
579
+ /**
580
+ * Grava o resultado da transcrição. Devolve `undefined` quando a mensagem não existe (apagada
581
+ * entre o enfileiramento e a execução do job) — não é erro, é corrida normal.
582
+ *
583
+ * `text`/`language`/`engine` só são tocados quando informados: uma retentativa que volta a falhar
584
+ * atualiza o status sem apagar a transcrição parcial de uma tentativa anterior que tenha vindo de
585
+ * outro engine da cadeia.
586
+ */
587
+ async saveTranscription(params) {
588
+ const [updated] = await this.db.update(messages).set({
589
+ transcriptionStatus: params.status,
590
+ ...params.text !== void 0 ? {
591
+ transcriptionText: params.text
592
+ } : {},
593
+ ...params.language !== void 0 ? {
594
+ transcriptionLanguage: params.language
595
+ } : {},
596
+ ...params.engine !== void 0 ? {
597
+ transcriptionEngine: params.engine
598
+ } : {}
599
+ }).where(and2(eq2(messages.companyId, params.companyId), eq2(messages.id, params.messageId))).returning();
600
+ return updated;
601
+ }
516
602
  async listByConversation(params) {
517
603
  const conditions = [
518
604
  eq2(messages.companyId, params.companyId),
@@ -697,7 +783,11 @@ function toContractSettings(row) {
697
783
  templateLanguage: row.templateLanguage,
698
784
  templateVariables: row.templateVariables,
699
785
  welcomeMessage: row.welcomeMessage ?? "",
700
- farewellMessage: row.farewellMessage ?? ""
786
+ farewellMessage: row.farewellMessage ?? "",
787
+ // `?? null` e não `?? false`: nulo é "o painel não decidiu", e é o que faz valer o padrão do
788
+ // host. Colapsar para `false` desligaria quem ligou por ambiente.
789
+ transcriptionEnabled: row.transcriptionEnabled ?? null,
790
+ transcriptionMode: row.transcriptionMode ?? null
701
791
  };
702
792
  }
703
793
  __name(toContractSettings, "toContractSettings");
@@ -706,7 +796,9 @@ var EMPTY_SETTINGS = {
706
796
  templateLanguage: "pt_BR",
707
797
  templateVariables: [],
708
798
  welcomeMessage: "",
709
- farewellMessage: ""
799
+ farewellMessage: "",
800
+ transcriptionEnabled: null,
801
+ transcriptionMode: null
710
802
  };
711
803
  var SettingsRepository = class {
712
804
  static {
@@ -735,7 +827,12 @@ var SettingsRepository = class {
735
827
  templateLanguage: merged.templateLanguage,
736
828
  templateVariables: merged.templateVariables,
737
829
  welcomeMessage: merged.welcomeMessage || null,
738
- farewellMessage: merged.farewellMessage || null
830
+ farewellMessage: merged.farewellMessage || null,
831
+ // Sem `|| null`: `false` aqui é decisão explícita do painel ("desligado para esta empresa"),
832
+ // e colapsá-lo para nulo faria a empresa voltar a herdar o padrão do host — exatamente o
833
+ // oposto do que o operador acabou de pedir.
834
+ transcriptionEnabled: merged.transcriptionEnabled,
835
+ transcriptionMode: merged.transcriptionMode
739
836
  }).onConflictDoUpdate({
740
837
  target: settings.companyId,
741
838
  set: {
@@ -744,6 +841,8 @@ var SettingsRepository = class {
744
841
  templateVariables: merged.templateVariables,
745
842
  welcomeMessage: merged.welcomeMessage || null,
746
843
  farewellMessage: merged.farewellMessage || null,
844
+ transcriptionEnabled: merged.transcriptionEnabled,
845
+ transcriptionMode: merged.transcriptionMode,
747
846
  updatedAt: /* @__PURE__ */ new Date()
748
847
  }
749
848
  }).returning();
@@ -1733,13 +1832,20 @@ var FlowMediaRepository = class {
1733
1832
  // src/channel/WhatsAppChannelAdapter.ts
1734
1833
  import { WhatsAppWindowExpiredError as ProviderWindowExpiredError } from "@adatechnology/meta-graph-core";
1735
1834
  import { WindowExpiredError as WindowExpiredError2 } from "@adatechnology/meta-whatsapp-contracts";
1835
+
1836
+ // src/channel/previewMedia.ts
1837
+ import { PREVIEW_MEDIA_ID_PREFIX, toPreviewMediaId, resolvePreviewUploadId } from "@adatechnology/meta-whatsapp-contracts";
1838
+
1839
+ // src/channel/WhatsAppChannelAdapter.ts
1736
1840
  var WhatsAppChannelAdapter = class {
1737
1841
  static {
1738
1842
  __name(this, "WhatsAppChannelAdapter");
1739
1843
  }
1740
1844
  messages;
1741
- constructor(messages2) {
1845
+ previewMedia;
1846
+ constructor(messages2, previewMedia) {
1742
1847
  this.messages = messages2;
1848
+ this.previewMedia = previewMedia;
1743
1849
  }
1744
1850
  async translateErrors(operation) {
1745
1851
  try {
@@ -1783,7 +1889,21 @@ var WhatsAppChannelAdapter = class {
1783
1889
  externalMessageId: result.waMessageId
1784
1890
  };
1785
1891
  }
1892
+ /**
1893
+ * Busca o binário da mídia — da Meta, ou do storage quando o id é do simulador.
1894
+ *
1895
+ * O desvio acontece ANTES de qualquer chamada de rede: id do simulador não existe na Meta, e
1896
+ * tentar buscá-lo lá renderia um 404 confuso em vez do áudio que o operador acabou de gravar.
1897
+ */
1786
1898
  async fetchMediaAsBase64(mediaId) {
1899
+ const uploadId = this.previewMedia?.isEnabled ? resolvePreviewUploadId(mediaId) : void 0;
1900
+ if (uploadId) {
1901
+ const buffer = await this.previewMedia.objectStorage.getObject(uploadId);
1902
+ return {
1903
+ data: buffer.toString("base64"),
1904
+ mimeType: this.previewMedia.defaultMimeType ?? "audio/ogg"
1905
+ };
1906
+ }
1787
1907
  return this.translateErrors(() => this.messages.fetchMediaAsBase64(mediaId));
1788
1908
  }
1789
1909
  };
@@ -1827,6 +1947,160 @@ __name(claimWebhookDelivery, "claimWebhookDelivery");
1827
1947
 
1828
1948
  // src/channel/IngestInboundMedia.use-case.ts
1829
1949
  import { eq as eq7, and as and6 } from "drizzle-orm";
1950
+
1951
+ // src/use-cases/TranscribeAudio.use-case.ts
1952
+ import { AudioNotIngestedError, MessageNotAudioError, TranscriptionDisabledError } from "@adatechnology/meta-whatsapp-contracts";
1953
+
1954
+ // src/transcription.types.ts
1955
+ var TRANSCRIPTION_STATUS = {
1956
+ /** Falhou de forma retriável (cota, rede, 5xx) — vai sair quando alguém tentar de novo. */
1957
+ PENDING: "pending",
1958
+ /** Processado. Texto vazio aqui é áudio em silêncio, e NÃO deve ser reprocessado. */
1959
+ DONE: "done",
1960
+ /** Falha definitiva do engine (credencial, áudio corrompido, arquivo grande demais). */
1961
+ FAILED: "failed",
1962
+ /** Nenhum engine da cadeia aceita o formato. Retentar não conserta codec. */
1963
+ UNSUPPORTED: "unsupported"
1964
+ };
1965
+ var TRANSCRIPTION_MODE = {
1966
+ AUTO: "auto",
1967
+ ON_DEMAND: "onDemand"
1968
+ };
1969
+ function isRetriableTranscriptionError(error) {
1970
+ if (typeof error !== "object" || error === null) return true;
1971
+ const isRetriable = error.isRetriable;
1972
+ return typeof isRetriable === "boolean" ? isRetriable : true;
1973
+ }
1974
+ __name(isRetriableTranscriptionError, "isRetriableTranscriptionError");
1975
+ function isUnsupportedTranscriptionError(error) {
1976
+ return typeof error === "object" && error !== null && error.name === "TranscriptionUnsupportedError";
1977
+ }
1978
+ __name(isUnsupportedTranscriptionError, "isUnsupportedTranscriptionError");
1979
+ function transcriptionRetryAfterSeconds(error) {
1980
+ if (typeof error !== "object" || error === null) return void 0;
1981
+ const retryAfter = error.retryAfterSeconds;
1982
+ return typeof retryAfter === "number" ? retryAfter : void 0;
1983
+ }
1984
+ __name(transcriptionRetryAfterSeconds, "transcriptionRetryAfterSeconds");
1985
+ function isAudioMimeType(mimeType) {
1986
+ return typeof mimeType === "string" && mimeType.trim().toLowerCase().startsWith("audio/");
1987
+ }
1988
+ __name(isAudioMimeType, "isAudioMimeType");
1989
+
1990
+ // src/use-cases/TranscribeAudio.use-case.ts
1991
+ var TranscribeAudioUseCase = class {
1992
+ static {
1993
+ __name(this, "TranscribeAudioUseCase");
1994
+ }
1995
+ dependencies;
1996
+ constructor(dependencies) {
1997
+ this.dependencies = dependencies;
1998
+ }
1999
+ async execute(params) {
2000
+ const message = await this.dependencies.messageRepository.findById(params.companyId, params.messageId);
2001
+ if (!message) throw new Error(`Mensagem ${params.messageId} n\xE3o encontrada para transcri\xE7\xE3o`);
2002
+ if (message.transcriptionStatus === TRANSCRIPTION_STATUS.DONE && !params.force) {
2003
+ return {
2004
+ status: TRANSCRIPTION_STATUS.DONE,
2005
+ text: message.transcriptionText,
2006
+ language: message.transcriptionLanguage,
2007
+ engine: message.transcriptionEngine,
2008
+ alreadyTranscribed: true
2009
+ };
2010
+ }
2011
+ if (this.dependencies.resolvePolicy) {
2012
+ const policy = await this.dependencies.resolvePolicy(params.companyId);
2013
+ if (!policy.isEnabled) throw new TranscriptionDisabledError();
2014
+ }
2015
+ const audio = extractAudioReference(message);
2016
+ const buffer = await this.dependencies.objectStorage.getObject(audio.uploadId);
2017
+ return this.transcribeBuffer({
2018
+ ...params,
2019
+ buffer,
2020
+ mimeType: audio.mimeType,
2021
+ uploadId: audio.uploadId,
2022
+ message
2023
+ });
2024
+ }
2025
+ async transcribeBuffer(context) {
2026
+ try {
2027
+ const result = await this.dependencies.transcriber.transcribe({
2028
+ buffer: context.buffer,
2029
+ mimeType: context.mimeType,
2030
+ ...this.dependencies.languageHint ? {
2031
+ languageHint: this.dependencies.languageHint
2032
+ } : {}
2033
+ });
2034
+ await this.dependencies.messageRepository.saveTranscription({
2035
+ companyId: context.companyId,
2036
+ messageId: context.messageId,
2037
+ status: TRANSCRIPTION_STATUS.DONE,
2038
+ text: result.text,
2039
+ language: result.language ?? null,
2040
+ engine: result.engine
2041
+ });
2042
+ return {
2043
+ status: TRANSCRIPTION_STATUS.DONE,
2044
+ text: result.text,
2045
+ language: result.language ?? null,
2046
+ engine: result.engine,
2047
+ alreadyTranscribed: false
2048
+ };
2049
+ } catch (error) {
2050
+ await this.persistFailure(context, error);
2051
+ throw error;
2052
+ }
2053
+ }
2054
+ /**
2055
+ * Carimba o motivo antes de propagar. O status é o que impede os dois desperdícios simétricos:
2056
+ * reprocessar para sempre um codec impossível, e desistir de um áudio que só esbarrou na cota.
2057
+ */
2058
+ async persistFailure(context, error) {
2059
+ const status = resolveFailureStatus(error);
2060
+ await this.dependencies.messageRepository.saveTranscription({
2061
+ companyId: context.companyId,
2062
+ messageId: context.messageId,
2063
+ status
2064
+ });
2065
+ if (status !== TRANSCRIPTION_STATUS.PENDING) return;
2066
+ const retryAfterSeconds = transcriptionRetryAfterSeconds(error);
2067
+ await this.dependencies.hooks?.onTranscriptionDeferred?.({
2068
+ companyId: context.companyId,
2069
+ messageId: context.messageId,
2070
+ whatsappNumber: context.message.whatsappNumber,
2071
+ uploadId: context.uploadId,
2072
+ ...retryAfterSeconds !== void 0 ? {
2073
+ retryAfterSeconds
2074
+ } : {},
2075
+ reason: retryAfterSeconds !== void 0 ? "rate-limited" : "transient-failure",
2076
+ error
2077
+ });
2078
+ }
2079
+ };
2080
+ function resolveFailureStatus(error) {
2081
+ if (isUnsupportedTranscriptionError(error)) return TRANSCRIPTION_STATUS.UNSUPPORTED;
2082
+ return isRetriableTranscriptionError(error) ? TRANSCRIPTION_STATUS.PENDING : TRANSCRIPTION_STATUS.FAILED;
2083
+ }
2084
+ __name(resolveFailureStatus, "resolveFailureStatus");
2085
+ function extractAudioReference(message) {
2086
+ const payload = message.payload ?? {};
2087
+ const audio = payload["audio"];
2088
+ const mimeType = typeof payload["mimeType"] === "string" ? payload["mimeType"] : audio?.mime_type;
2089
+ if (!isAudioMimeType(mimeType) && !audio) {
2090
+ throw new MessageNotAudioError(message.id, message.type);
2091
+ }
2092
+ const uploadId = payload["uploadId"];
2093
+ if (typeof uploadId !== "string" || uploadId.length === 0) {
2094
+ throw new AudioNotIngestedError(message.id);
2095
+ }
2096
+ return {
2097
+ uploadId,
2098
+ mimeType: mimeType ?? "audio/ogg"
2099
+ };
2100
+ }
2101
+ __name(extractAudioReference, "extractAudioReference");
2102
+
2103
+ // src/channel/IngestInboundMedia.use-case.ts
1830
2104
  var IngestInboundMediaUseCase = class {
1831
2105
  static {
1832
2106
  __name(this, "IngestInboundMediaUseCase");
@@ -1835,11 +2109,13 @@ var IngestInboundMediaUseCase = class {
1835
2109
  channel;
1836
2110
  objectStorage;
1837
2111
  documentRepository;
1838
- constructor(db, channel, objectStorage, documentRepository) {
2112
+ transcription;
2113
+ constructor(db, channel, objectStorage, documentRepository, transcription) {
1839
2114
  this.db = db;
1840
2115
  this.channel = channel;
1841
2116
  this.objectStorage = objectStorage;
1842
2117
  this.documentRepository = documentRepository;
2118
+ this.transcription = transcription;
1843
2119
  }
1844
2120
  async execute(params) {
1845
2121
  const [message] = await this.db.select().from(messages).where(and6(eq7(messages.companyId, params.companyId), eq7(messages.id, params.messageId))).limit(1);
@@ -1885,9 +2161,84 @@ var IngestInboundMediaUseCase = class {
1885
2161
  sizeBytes: buffer.length,
1886
2162
  source: message.sender
1887
2163
  });
2164
+ const transcription = await this.transcribeIfAuto({
2165
+ companyId: params.companyId,
2166
+ message,
2167
+ uploadId,
2168
+ buffer,
2169
+ mimeType: mimeType || params.mimeType
2170
+ });
1888
2171
  return {
1889
2172
  uploadId,
1890
- alreadyIngested: false
2173
+ alreadyIngested: false,
2174
+ ...transcription ? {
2175
+ transcription
2176
+ } : {}
2177
+ };
2178
+ }
2179
+ /**
2180
+ * Transcreve o áudio recém-baixado, quando o modo é `auto`.
2181
+ *
2182
+ * **Nunca propaga erro.** Neste ponto o binário já está no storage e já entrou na biblioteca da
2183
+ * conversa: deixar uma falha de transcrição subir marcaria a ingestão inteira como falha, e o
2184
+ * retry do host baixaria de novo da Meta um arquivo que está salvo — gastando banda para reproduzir
2185
+ * um efeito que já aconteceu. O status fica gravado na mensagem e o `onTranscriptionDeferred`
2186
+ * avisa quem sabe reenfileirar.
2187
+ */
2188
+ async transcribeIfAuto(context) {
2189
+ const transcription = this.transcription;
2190
+ if (!transcription) return void 0;
2191
+ if (!isAudioMimeType(context.mimeType)) return void 0;
2192
+ const policy = await transcription.resolvePolicy(context.companyId);
2193
+ if (!policy.isEnabled || policy.mode !== TRANSCRIPTION_MODE.AUTO) return void 0;
2194
+ const current = await transcription.messageRepository.findById(context.companyId, context.message.id);
2195
+ if (current?.transcriptionStatus === TRANSCRIPTION_STATUS.DONE) return void 0;
2196
+ try {
2197
+ const result = await transcription.transcriber.transcribe({
2198
+ buffer: context.buffer,
2199
+ mimeType: context.mimeType,
2200
+ ...transcription.languageHint ? {
2201
+ languageHint: transcription.languageHint
2202
+ } : {}
2203
+ });
2204
+ await transcription.messageRepository.saveTranscription({
2205
+ companyId: context.companyId,
2206
+ messageId: context.message.id,
2207
+ status: TRANSCRIPTION_STATUS.DONE,
2208
+ text: result.text,
2209
+ language: result.language ?? null,
2210
+ engine: result.engine
2211
+ });
2212
+ return {
2213
+ status: TRANSCRIPTION_STATUS.DONE
2214
+ };
2215
+ } catch (error) {
2216
+ return this.recordTranscriptionFailure(context, error);
2217
+ }
2218
+ }
2219
+ async recordTranscriptionFailure(context, error) {
2220
+ const status = resolveFailureStatus(error);
2221
+ await this.transcription?.messageRepository.saveTranscription({
2222
+ companyId: context.companyId,
2223
+ messageId: context.message.id,
2224
+ status
2225
+ });
2226
+ if (status === TRANSCRIPTION_STATUS.PENDING) {
2227
+ const retryAfterSeconds = transcriptionRetryAfterSeconds(error);
2228
+ await this.transcription?.hooks?.onTranscriptionDeferred?.({
2229
+ companyId: context.companyId,
2230
+ messageId: context.message.id,
2231
+ whatsappNumber: context.message.whatsappNumber,
2232
+ uploadId: context.uploadId,
2233
+ ...retryAfterSeconds !== void 0 ? {
2234
+ retryAfterSeconds
2235
+ } : {},
2236
+ reason: retryAfterSeconds !== void 0 ? "rate-limited" : "transient-failure",
2237
+ error
2238
+ });
2239
+ }
2240
+ return {
2241
+ status
1891
2242
  };
1892
2243
  }
1893
2244
  };
@@ -2057,6 +2408,51 @@ var ReceiveWebhookUseCase = class {
2057
2408
  }
2058
2409
  };
2059
2410
 
2411
+ // src/use-cases/resolveTranscriptionPolicy.ts
2412
+ function createTranscriptionPolicyResolver(dependencies) {
2413
+ return /* @__PURE__ */ __name(async function resolveTranscriptionPolicy(companyId) {
2414
+ const settings2 = await dependencies.settingsRepository.get(companyId);
2415
+ return {
2416
+ // `??` e não `||`: `false` gravado é decisão explícita de desligar, e `||` a trocaria pelo
2417
+ // padrão do host — desligar no painel não faria nada num deploy com transcrição ligada.
2418
+ isEnabled: settings2.transcriptionEnabled ?? dependencies.defaults.isEnabled,
2419
+ mode: normalizeMode(settings2.transcriptionMode) ?? dependencies.defaults.mode
2420
+ };
2421
+ }, "resolveTranscriptionPolicy");
2422
+ }
2423
+ __name(createTranscriptionPolicyResolver, "createTranscriptionPolicyResolver");
2424
+ function normalizeMode(value) {
2425
+ if (value === TRANSCRIPTION_MODE.AUTO) return TRANSCRIPTION_MODE.AUTO;
2426
+ if (value === TRANSCRIPTION_MODE.ON_DEMAND) return TRANSCRIPTION_MODE.ON_DEMAND;
2427
+ return void 0;
2428
+ }
2429
+ __name(normalizeMode, "normalizeMode");
2430
+
2431
+ // src/use-cases/StorePreviewMedia.use-case.ts
2432
+ var StorePreviewMediaUseCase = class {
2433
+ static {
2434
+ __name(this, "StorePreviewMediaUseCase");
2435
+ }
2436
+ objectStorage;
2437
+ generateKeySuffix;
2438
+ constructor(objectStorage, generateKeySuffix) {
2439
+ this.objectStorage = objectStorage;
2440
+ this.generateKeySuffix = generateKeySuffix;
2441
+ }
2442
+ async execute(params) {
2443
+ const key = `meta-whatsapp/${params.companyId}/preview/${this.generateKeySuffix()}`;
2444
+ const { uploadId } = await this.objectStorage.upload({
2445
+ buffer: params.buffer,
2446
+ mimeType: params.mimeType,
2447
+ key
2448
+ });
2449
+ return {
2450
+ mediaId: toPreviewMediaId(uploadId),
2451
+ uploadId
2452
+ };
2453
+ }
2454
+ };
2455
+
2060
2456
  // src/createMetaWhatsAppModule.ts
2061
2457
  function createMetaWhatsAppModule(params) {
2062
2458
  const { db, config, nonceStore, providers = {}, hooks } = params;
@@ -2069,7 +2465,11 @@ function createMetaWhatsAppModule(params) {
2069
2465
  apiVersion: config.apiVersion,
2070
2466
  baseUrl: config.baseUrl
2071
2467
  });
2072
- const channel = new WhatsAppChannelAdapter(messageProvider);
2468
+ const previewMediaSupport = params.features?.previewMedia && providers.objectStorage?.getObject ? {
2469
+ isEnabled: true,
2470
+ objectStorage: providers.objectStorage
2471
+ } : void 0;
2472
+ const channel = new WhatsAppChannelAdapter(messageProvider, previewMediaSupport);
2073
2473
  const sessionRepository = new SessionRepository(db);
2074
2474
  const messageRepository = new MessageRepository(db);
2075
2475
  const settingsRepository = new SettingsRepository(db);
@@ -2100,7 +2500,39 @@ function createMetaWhatsAppModule(params) {
2100
2500
  onError: hooks?.onFlowMediaError
2101
2501
  }));
2102
2502
  }
2103
- const ingestInboundMedia = providers.objectStorage ? new IngestInboundMediaUseCase(db, channel, providers.objectStorage, documentRepository) : void 0;
2503
+ const transcriptionMode = providers.transcription?.mode ?? TRANSCRIPTION_MODE.ON_DEMAND;
2504
+ const resolveTranscriptionPolicy = providers.transcription ? createTranscriptionPolicyResolver({
2505
+ settingsRepository,
2506
+ defaults: {
2507
+ isEnabled: providers.transcription.isEnabledByDefault ?? true,
2508
+ mode: transcriptionMode
2509
+ }
2510
+ }) : void 0;
2511
+ const ingestInboundMedia = providers.objectStorage ? new IngestInboundMediaUseCase(db, channel, providers.objectStorage, documentRepository, providers.transcription && resolveTranscriptionPolicy ? {
2512
+ transcriber: providers.transcription.transcriber,
2513
+ resolvePolicy: resolveTranscriptionPolicy,
2514
+ messageRepository,
2515
+ ...providers.transcription.languageHint ? {
2516
+ languageHint: providers.transcription.languageHint
2517
+ } : {},
2518
+ ...hooks ? {
2519
+ hooks
2520
+ } : {}
2521
+ } : void 0) : void 0;
2522
+ const transcribeAudio = providers.transcription && providers.objectStorage?.getObject ? new TranscribeAudioUseCase({
2523
+ messageRepository,
2524
+ objectStorage: providers.objectStorage,
2525
+ transcriber: providers.transcription.transcriber,
2526
+ ...resolveTranscriptionPolicy ? {
2527
+ resolvePolicy: resolveTranscriptionPolicy
2528
+ } : {},
2529
+ ...providers.transcription.languageHint ? {
2530
+ languageHint: providers.transcription.languageHint
2531
+ } : {},
2532
+ ...hooks ? {
2533
+ hooks
2534
+ } : {}
2535
+ }) : void 0;
2104
2536
  const listDocuments = new ListConversationDocumentsUseCase(sessionRepository, documentRepository);
2105
2537
  return {
2106
2538
  channel,
@@ -2121,10 +2553,28 @@ function createMetaWhatsAppModule(params) {
2121
2553
  delete: new DeleteConversationUseCase(sessionRepository, documentRepository, providers.objectStorage),
2122
2554
  purgeExpiredDocuments: new PurgeExpiredDocumentsUseCase(documentRepository, providers.objectStorage),
2123
2555
  export: new ExportConversationUseCase(sessionRepository),
2556
+ // undefined quando transcrição não foi injetada, ou quando o storage não sabe ler de volta.
2557
+ // O painel consulta a ausência para decidir se desenha o botão "transcrever".
2558
+ transcribeAudio,
2124
2559
  repository: sessionRepository,
2560
+ messageRepository,
2125
2561
  documentRepository
2126
2562
  },
2563
+ /**
2564
+ * `undefined` = o host não injetou transcritor, e nenhuma configuração de empresa muda isso: a
2565
+ * capacidade não existe. Presente, `resolvePolicy` responde o que vale para uma empresa —
2566
+ * é o que a rota de configurações usa para dizer ao painel se desenha o interruptor.
2567
+ */
2568
+ transcription: providers.transcription && resolveTranscriptionPolicy ? {
2569
+ defaultMode: transcriptionMode,
2570
+ resolvePolicy: resolveTranscriptionPolicy
2571
+ } : void 0,
2127
2572
  settings: settingsRepository,
2573
+ /**
2574
+ * `undefined` quando o recurso não está ligado (ou falta storage legível). O host consulta a
2575
+ * ausência para não registrar a rota de upload — e o preview, sem a rota, esconde o microfone.
2576
+ */
2577
+ previewMedia: previewMediaSupport ? new StorePreviewMediaUseCase(providers.objectStorage, () => `${Date.now()}-${Math.random().toString(36).slice(2)}`) : void 0,
2128
2578
  webhook: {
2129
2579
  receive: receiveWebhook,
2130
2580
  // GET de verificação da Meta — o host liga na sua rota e devolve o retorno como texto puro.
@@ -2268,6 +2718,7 @@ export {
2268
2718
  META_WHATSAPP_MIGRATIONS_TABLE,
2269
2719
  MessageRepository,
2270
2720
  OptimisticLockError,
2721
+ PREVIEW_MEDIA_ID_PREFIX,
2271
2722
  PurgeExpiredDocumentsUseCase,
2272
2723
  ReceiveWebhookUseCase,
2273
2724
  ReleaseConversationUseCase,
@@ -2276,24 +2727,36 @@ export {
2276
2727
  SessionRepository,
2277
2728
  SettingsRepository,
2278
2729
  SseHub,
2730
+ StorePreviewMediaUseCase,
2731
+ TRANSCRIPTION_MODE,
2732
+ TRANSCRIPTION_STATUS,
2279
2733
  TakeoverConversationUseCase,
2734
+ TranscribeAudioUseCase,
2280
2735
  WEBHOOK_NONCE_TTL_SECONDS,
2281
2736
  WhatsAppChannelAdapter,
2282
2737
  claimWebhookDelivery,
2283
2738
  createMetaWhatsAppModule,
2284
2739
  createSendMediaAction,
2740
+ createTranscriptionPolicyResolver,
2285
2741
  documents,
2286
2742
  extractMediaDescriptor,
2287
2743
  flowGraphs,
2288
2744
  flowMedia,
2745
+ isAudioMimeType,
2746
+ isRetriableTranscriptionError,
2747
+ isUnsupportedTranscriptionError,
2289
2748
  issueSseTicket,
2290
2749
  messages,
2291
2750
  metaWhatsAppMigrationsFolder,
2292
2751
  metaWhatsAppSchema,
2293
2752
  redeemSseTicket,
2753
+ resolveFailureStatus,
2754
+ resolvePreviewUploadId,
2294
2755
  runMetaWhatsAppMigrations,
2295
2756
  sessions,
2296
2757
  settings,
2758
+ toPreviewMediaId,
2759
+ transcriptionRetryAfterSeconds,
2297
2760
  verifyWebhookChallenge,
2298
2761
  verifyWebhookSignature
2299
2762
  };