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