@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 +495 -19
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +514 -34
- package/dist/index.d.ts +514 -34
- package/dist/index.js +472 -9
- package/dist/index.js.map +1 -1
- package/dist/migrations/0008_message_transcription.sql +5 -0
- package/dist/migrations/0009_settings_transcription_policy.sql +2 -0
- package/dist/migrations/meta/_journal.json +14 -0
- package/dist/testing/index.cjs +150 -0
- package/dist/testing/index.cjs.map +1 -1
- package/dist/testing/index.d.cts +42 -2
- package/dist/testing/index.d.ts +42 -2
- package/dist/testing/index.js +147 -1
- package/dist/testing/index.js.map +1 -1
- package/package.json +2 -2
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
};
|