@adatechnology/meta-whatsapp-module 0.2.0-rc.2 → 0.2.0-rc.21
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 +1408 -106
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1778 -240
- package/dist/index.d.ts +1778 -240
- package/dist/index.js +1368 -92
- package/dist/index.js.map +1 -1
- package/dist/migrations/0004_widen_message_type.sql +1 -0
- 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/meta/_journal.json +76 -0
- 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 +23 -8
- package/dist/migrations/20260725195853_freezing_switch/snapshot.json +0 -555
- package/dist/migrations/20260725210958_military_leper_queen/snapshot.json +0 -742
- package/dist/migrations/20260725214800_illegal_black_tarantula/snapshot.json +0 -810
- package/dist/migrations/20260725234507_normal_viper/snapshot.json +0 -930
- /package/dist/migrations/{20260725195853_freezing_switch/migration.sql → 0000_freezing_switch.sql} +0 -0
- /package/dist/migrations/{20260725210958_military_leper_queen/migration.sql → 0001_military_leper_queen.sql} +0 -0
- /package/dist/migrations/{20260725214800_illegal_black_tarantula/migration.sql → 0002_illegal_black_tarantula.sql} +0 -0
- /package/dist/migrations/{20260725234507_normal_viper/migration.sql → 0003_normal_viper.sql} +0 -0
package/dist/index.js
CHANGED
|
@@ -10,6 +10,7 @@ var __dirname = /* @__PURE__ */ getDirname();
|
|
|
10
10
|
|
|
11
11
|
// src/createMetaWhatsAppModule.ts
|
|
12
12
|
import { WhatsAppMessageProvider } from "@adatechnology/meta-whatsapp-provider";
|
|
13
|
+
import { FLOW_ACTION_KIND } from "@adatechnology/meta-whatsapp-contracts";
|
|
13
14
|
|
|
14
15
|
// src/repositories/SessionRepository.ts
|
|
15
16
|
import { and, desc, eq, sql as sql2 } from "drizzle-orm";
|
|
@@ -85,9 +86,14 @@ var messages = metaWhatsAppSchema.table("messages", {
|
|
|
85
86
|
length: 12
|
|
86
87
|
}).notNull(),
|
|
87
88
|
agentUserId: uuid("agent_user_id"),
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
// 32, não 16: os tipos da própria Meta são curtos ('text', 'interactive'), mas o host rotula
|
|
90
|
+
type: (
|
|
91
|
+
// a saída com o subtipo que enviou ('interactive_buttons' já tem 19). Apertar aqui só
|
|
92
|
+
// transfere para o consumidor a escolha entre truncar o rótulo e estourar o insert.
|
|
93
|
+
varchar("type", {
|
|
94
|
+
length: 32
|
|
95
|
+
}).notNull().default("text")
|
|
96
|
+
),
|
|
91
97
|
content: text("content"),
|
|
92
98
|
// T5.4 — nunca base64 aqui; mídia vive no ObjectStorageInterface do host, referenciada por
|
|
93
99
|
// uploadId dentro deste jsonb.
|
|
@@ -101,6 +107,33 @@ var messages = metaWhatsAppSchema.table("messages", {
|
|
|
101
107
|
readAt: timestamp("read_at", {
|
|
102
108
|
withTimezone: true
|
|
103
109
|
}),
|
|
110
|
+
// Moderação de conteúdo. `null` significa NÃO AVALIADO (moderação desligada, ou mensagem
|
|
111
|
+
// anterior ao recurso) — diferente de `false`, que é avaliado e limpo. Colunas em vez de chave
|
|
112
|
+
// dentro de `payload` porque "listar o que foi sinalizado" é consulta de operação, e índice
|
|
113
|
+
// parcial sobre boolean resolve isso sem cavar jsonb.
|
|
114
|
+
moderationFlagged: boolean("moderation_flagged"),
|
|
115
|
+
moderationTerms: jsonb("moderation_terms").$type(),
|
|
116
|
+
/**
|
|
117
|
+
* Transcrição de áudio. `null` em `transcription_status` significa NÃO AVALIADO — áudio nunca
|
|
118
|
+
* pedido (modo sob demanda), transcrição desligada, mensagem anterior ao recurso, ou mensagem
|
|
119
|
+
* que não é áudio. Diferente de `'done'` com texto vazio, que é áudio em silêncio já processado
|
|
120
|
+
* e que NÃO deve ser reprocessado.
|
|
121
|
+
*
|
|
122
|
+
* Colunas em vez de chave em `payload` pelo mesmo motivo da moderação: "quais áudios ficaram
|
|
123
|
+
* pendentes" e "quais falharam" são consultas de operação, e índice parcial resolve sem cavar
|
|
124
|
+
* jsonb. Buscar texto de áudio também deixa de exigir varredura de payload.
|
|
125
|
+
*/
|
|
126
|
+
transcriptionStatus: varchar("transcription_status", {
|
|
127
|
+
length: 16
|
|
128
|
+
}).$type(),
|
|
129
|
+
transcriptionText: text("transcription_text"),
|
|
130
|
+
transcriptionLanguage: varchar("transcription_language", {
|
|
131
|
+
length: 32
|
|
132
|
+
}),
|
|
133
|
+
/** Qual engine produziu. Com uma cadeia de engines, é o que responde "por que esta saiu ruim". */
|
|
134
|
+
transcriptionEngine: varchar("transcription_engine", {
|
|
135
|
+
length: 32
|
|
136
|
+
}),
|
|
104
137
|
createdAt: timestamp("created_at", {
|
|
105
138
|
withTimezone: true
|
|
106
139
|
}).notNull().defaultNow()
|
|
@@ -112,7 +145,54 @@ var messages = metaWhatsAppSchema.table("messages", {
|
|
|
112
145
|
// paralelo, então as duas passariam pela checagem e inseririam duplicado. Parcial porque
|
|
113
146
|
// mensagens outbound ainda sem waMessageId (envio em curso) são legitimamente NULL, e NULLs
|
|
114
147
|
// não podem competir entre si por unicidade.
|
|
115
|
-
uniqueIndex("idx_messages_company_wa_message_id").on(table.companyId, table.waMessageId).where(sql`${table.waMessageId} is not null`)
|
|
148
|
+
uniqueIndex("idx_messages_company_wa_message_id").on(table.companyId, table.waMessageId).where(sql`${table.waMessageId} is not null`),
|
|
149
|
+
// Parcial: só as sinalizadas entram, então o índice fica do tamanho do problema e não do
|
|
150
|
+
// tamanho do transcript.
|
|
151
|
+
index("idx_messages_moderation_flagged").on(table.companyId, table.createdAt).where(sql`${table.moderationFlagged}`),
|
|
152
|
+
// Alimenta a varredura de retomada: "quais áudios ficaram pendentes de transcrição". Parcial
|
|
153
|
+
// porque pendente é estado transitório e raro — o índice fica do tamanho da fila atrasada, não
|
|
154
|
+
// do transcript inteiro.
|
|
155
|
+
index("idx_messages_transcription_pending").on(table.companyId, table.createdAt).where(sql`${table.transcriptionStatus} = 'pending'`)
|
|
156
|
+
]);
|
|
157
|
+
var documents = metaWhatsAppSchema.table("documents", {
|
|
158
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
159
|
+
companyId: uuid("company_id").notNull(),
|
|
160
|
+
sessionId: uuid("session_id").notNull().references(() => sessions.id, {
|
|
161
|
+
onDelete: "cascade"
|
|
162
|
+
}),
|
|
163
|
+
// Anulável e `set null`: apagar a mensagem não deve apagar o arquivo da biblioteca, e arquivo
|
|
164
|
+
// do atendente nasce sem mensagem.
|
|
165
|
+
messageId: uuid("message_id").references(() => messages.id, {
|
|
166
|
+
onDelete: "set null"
|
|
167
|
+
}),
|
|
168
|
+
uploadId: varchar("upload_id", {
|
|
169
|
+
length: 256
|
|
170
|
+
}).notNull(),
|
|
171
|
+
filename: varchar("filename", {
|
|
172
|
+
length: 512
|
|
173
|
+
}).notNull(),
|
|
174
|
+
mimeType: varchar("mime_type", {
|
|
175
|
+
length: 128
|
|
176
|
+
}).notNull(),
|
|
177
|
+
sizeBytes: integer("size_bytes").notNull(),
|
|
178
|
+
// Do provider de storage, que é endereçado por conteúdo — serve para reconciliar storage
|
|
179
|
+
// contra tabela e para detectar o mesmo binário chegando duas vezes.
|
|
180
|
+
sha256: varchar("sha256", {
|
|
181
|
+
length: 64
|
|
182
|
+
}),
|
|
183
|
+
source: varchar("source", {
|
|
184
|
+
length: 12
|
|
185
|
+
}).notNull(),
|
|
186
|
+
linkedAt: timestamp("linked_at", {
|
|
187
|
+
withTimezone: true
|
|
188
|
+
}).notNull().defaultNow()
|
|
189
|
+
}, (table) => [
|
|
190
|
+
index("idx_documents_session_linked").on(table.sessionId, table.linkedAt),
|
|
191
|
+
// Para varredura de retenção por idade sem passar por sessão.
|
|
192
|
+
index("idx_documents_company_linked").on(table.companyId, table.linkedAt),
|
|
193
|
+
// O mesmo objeto não pode ser linkado duas vezes na mesma empresa: o job de ingestão é
|
|
194
|
+
// reentregue por retry, e sem isto a reentrega criaria linha duplicada no painel.
|
|
195
|
+
uniqueIndex("idx_documents_company_upload").on(table.companyId, table.uploadId)
|
|
116
196
|
]);
|
|
117
197
|
var flowGraphs = metaWhatsAppSchema.table("flow_graphs", {
|
|
118
198
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
@@ -143,6 +223,46 @@ var flowGraphs = metaWhatsAppSchema.table("flow_graphs", {
|
|
|
143
223
|
}, (table) => [
|
|
144
224
|
uniqueIndex("idx_flow_graphs_company_key").on(table.companyId, table.key)
|
|
145
225
|
]);
|
|
226
|
+
var flowMedia = metaWhatsAppSchema.table("flow_media", {
|
|
227
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
228
|
+
companyId: uuid("company_id").notNull(),
|
|
229
|
+
flowKey: varchar("flow_key", {
|
|
230
|
+
length: 64
|
|
231
|
+
}).notNull(),
|
|
232
|
+
nodeId: varchar("node_id", {
|
|
233
|
+
length: 64
|
|
234
|
+
}).notNull(),
|
|
235
|
+
uploadId: varchar("upload_id", {
|
|
236
|
+
length: 256
|
|
237
|
+
}).notNull(),
|
|
238
|
+
filename: varchar("filename", {
|
|
239
|
+
length: 512
|
|
240
|
+
}).notNull(),
|
|
241
|
+
mimeType: varchar("mime_type", {
|
|
242
|
+
length: 128
|
|
243
|
+
}).notNull(),
|
|
244
|
+
sizeBytes: integer("size_bytes").notNull(),
|
|
245
|
+
// Legenda da mídia no WhatsApp. Por arquivo, não por nó: um nó que manda tabela de preços e
|
|
246
|
+
// um folder precisa de textos diferentes para cada um.
|
|
247
|
+
caption: text("caption"),
|
|
248
|
+
// Ordem de envio dentro do nó — o cliente recebe as mensagens em sequência, e "tabela antes
|
|
249
|
+
// do folder" é decisão de quem edita, não do banco.
|
|
250
|
+
sortOrder: integer("sort_order").notNull().default(0),
|
|
251
|
+
// Desligar sem desanexar: trocar o material da campanha é o caso comum, e apagar a linha
|
|
252
|
+
// perderia a ordem e a legenda já ajustadas.
|
|
253
|
+
active: boolean("active").notNull().default(true),
|
|
254
|
+
createdAt: timestamp("created_at", {
|
|
255
|
+
withTimezone: true
|
|
256
|
+
}).notNull().defaultNow(),
|
|
257
|
+
updatedAt: timestamp("updated_at", {
|
|
258
|
+
withTimezone: true
|
|
259
|
+
}).notNull().defaultNow()
|
|
260
|
+
}, (table) => [
|
|
261
|
+
index("idx_flow_media_node").on(table.companyId, table.flowKey, table.nodeId, table.sortOrder),
|
|
262
|
+
// O mesmo arquivo anexado duas vezes ao MESMO nó é erro de clique no editor, e o cliente
|
|
263
|
+
// receberia o documento repetido.
|
|
264
|
+
uniqueIndex("idx_flow_media_node_upload").on(table.companyId, table.flowKey, table.nodeId, table.uploadId)
|
|
265
|
+
]);
|
|
146
266
|
var settings = metaWhatsAppSchema.table("settings", {
|
|
147
267
|
companyId: uuid("company_id").primaryKey(),
|
|
148
268
|
templateName: varchar("template_name", {
|
|
@@ -155,6 +275,15 @@ var settings = metaWhatsAppSchema.table("settings", {
|
|
|
155
275
|
templateVariables: jsonb("template_variables").$type().notNull().default([]),
|
|
156
276
|
welcomeMessage: text("welcome_message"),
|
|
157
277
|
farewellMessage: text("farewell_message"),
|
|
278
|
+
/**
|
|
279
|
+
* Política de transcrição desta empresa. Nulo é significativo: "o painel não decidiu", e aí vale
|
|
280
|
+
* o padrão que o host injetou. Sem a distinção, atualizar o módulo desligaria a transcrição de
|
|
281
|
+
* quem já a tinha ligada por ambiente.
|
|
282
|
+
*/
|
|
283
|
+
transcriptionEnabled: boolean("transcription_enabled"),
|
|
284
|
+
transcriptionMode: varchar("transcription_mode", {
|
|
285
|
+
length: 16
|
|
286
|
+
}).$type(),
|
|
158
287
|
createdAt: timestamp("created_at", {
|
|
159
288
|
withTimezone: true
|
|
160
289
|
}).notNull().defaultNow(),
|
|
@@ -165,6 +294,31 @@ var settings = metaWhatsAppSchema.table("settings", {
|
|
|
165
294
|
|
|
166
295
|
// src/repositories/SessionRepository.ts
|
|
167
296
|
var DEFAULT_LIMIT = 20;
|
|
297
|
+
var conversationSummaryProjection = {
|
|
298
|
+
lastContent: sql2`(
|
|
299
|
+
select m.content from ${messages} m
|
|
300
|
+
where m.company_id = ${sessions}.company_id and m.session_id = ${sessions}.id
|
|
301
|
+
order by m.created_at desc limit 1
|
|
302
|
+
)`,
|
|
303
|
+
lastDirection: sql2`(
|
|
304
|
+
select m.direction from ${messages} m
|
|
305
|
+
where m.company_id = ${sessions}.company_id and m.session_id = ${sessions}.id
|
|
306
|
+
order by m.created_at desc limit 1
|
|
307
|
+
)`,
|
|
308
|
+
// Entradas do cliente depois da última leitura do atendente. Sessão nunca lida conta
|
|
309
|
+
// tudo — é o comportamento esperado de uma conversa que ninguém abriu ainda.
|
|
310
|
+
unread: sql2`(
|
|
311
|
+
select count(*)::int from ${messages} m
|
|
312
|
+
where m.company_id = ${sessions}.company_id
|
|
313
|
+
and m.session_id = ${sessions}.id
|
|
314
|
+
and m.direction = 'inbound'
|
|
315
|
+
and (${sessions}.last_agent_read_at is null or m.created_at > ${sessions}.last_agent_read_at)
|
|
316
|
+
)`
|
|
317
|
+
};
|
|
318
|
+
function sessionContextPatch(patch) {
|
|
319
|
+
return sql2`${sessions.context} || ${JSON.stringify(patch)}::jsonb`;
|
|
320
|
+
}
|
|
321
|
+
__name(sessionContextPatch, "sessionContextPatch");
|
|
168
322
|
var SessionRepository = class {
|
|
169
323
|
static {
|
|
170
324
|
__name(this, "SessionRepository");
|
|
@@ -195,6 +349,9 @@ var SessionRepository = class {
|
|
|
195
349
|
}).returning();
|
|
196
350
|
return created;
|
|
197
351
|
}
|
|
352
|
+
// O `context` jsonb é o ponto de extensão oficial para estado de sessão por produto: o módulo
|
|
353
|
+
// não conhece a forma, o consumidor a declara em TSessionContext. Este setter SUBSTITUI o
|
|
354
|
+
// objeto inteiro — para acumular respostas ao longo da conversa use patchContext.
|
|
198
355
|
async setState(companyId, whatsappNumber, state, context) {
|
|
199
356
|
await this.db.update(sessions).set({
|
|
200
357
|
currentState: state,
|
|
@@ -205,6 +362,25 @@ var SessionRepository = class {
|
|
|
205
362
|
updatedAt: sql2`now()`
|
|
206
363
|
}).where(and(eq(sessions.companyId, companyId), eq(sessions.whatsappNumber, whatsappNumber)));
|
|
207
364
|
}
|
|
365
|
+
// Mescla parcial do context, feita no banco (`||`) e não por read-modify-write no host: duas
|
|
366
|
+
// mensagens do mesmo cliente processadas em paralelo sobrescreveriam uma à outra, e o campo
|
|
367
|
+
// acumula justamente as respostas coletadas ao longo da conversa. Chave presente no patch
|
|
368
|
+
// vence a existente; as demais permanecem.
|
|
369
|
+
async patchContext(companyId, whatsappNumber, patch) {
|
|
370
|
+
await this.db.update(sessions).set({
|
|
371
|
+
context: sessionContextPatch(patch),
|
|
372
|
+
lastActivity: sql2`now()`,
|
|
373
|
+
updatedAt: sql2`now()`
|
|
374
|
+
}).where(and(eq(sessions.companyId, companyId), eq(sessions.whatsappNumber, whatsappNumber)));
|
|
375
|
+
}
|
|
376
|
+
// Leitura tipada do estado de sessão do produto. Devolve undefined quando a sessão não existe —
|
|
377
|
+
// distinto de existir com context vazio, que devolve o objeto vazio.
|
|
378
|
+
async readContext(companyId, whatsappNumber) {
|
|
379
|
+
const [row] = await this.db.select({
|
|
380
|
+
context: sessions.context
|
|
381
|
+
}).from(sessions).where(and(eq(sessions.companyId, companyId), eq(sessions.whatsappNumber, whatsappNumber))).limit(1);
|
|
382
|
+
return row?.context;
|
|
383
|
+
}
|
|
208
384
|
// Posição no grafo de fluxo — chamado pelo host a cada transição do FlowInterpreter.
|
|
209
385
|
// Passar null em ambos desliga o rastreio (ex.: conversa saiu do motor de fluxo).
|
|
210
386
|
async setFlowPosition(companyId, whatsappNumber, flowKey, currentNodeId) {
|
|
@@ -247,6 +423,16 @@ var SessionRepository = class {
|
|
|
247
423
|
async release(companyId, whatsappNumber) {
|
|
248
424
|
await this.setMode(companyId, whatsappNumber, "bot", null);
|
|
249
425
|
}
|
|
426
|
+
/**
|
|
427
|
+
* Apaga a sessão; a cascata das FKs leva mensagens e documentos.
|
|
428
|
+
*
|
|
429
|
+
* Não apaga o binário no storage — isso é passo de aplicação, e é por isso que este método é
|
|
430
|
+
* chamado por `DeleteConversationUseCase` e não diretamente pelo host. Chamar daqui sem apagar os
|
|
431
|
+
* objetos antes deixa mídia órfã sendo cobrada para sempre.
|
|
432
|
+
*/
|
|
433
|
+
async deleteByNumber(companyId, whatsappNumber) {
|
|
434
|
+
await this.db.delete(sessions).where(and(eq(sessions.companyId, companyId), eq(sessions.whatsappNumber, whatsappNumber)));
|
|
435
|
+
}
|
|
250
436
|
async requestHuman(companyId, whatsappNumber) {
|
|
251
437
|
await this.db.update(sessions).set({
|
|
252
438
|
humanRequestedAt: sql2`now()`,
|
|
@@ -280,17 +466,28 @@ var SessionRepository = class {
|
|
|
280
466
|
currentState: sessions.currentState,
|
|
281
467
|
lastActivity: sessions.lastActivity,
|
|
282
468
|
lastInboundAt: sessions.lastInboundAt,
|
|
283
|
-
humanRequestedAt: sessions.humanRequestedAt
|
|
469
|
+
humanRequestedAt: sessions.humanRequestedAt,
|
|
470
|
+
// Prévia e contagem saem de subquery correlacionada em vez de N+1 na volta: uma inbox
|
|
471
|
+
// lista dezenas de conversas por página, e uma query por linha é o gargalo clássico
|
|
472
|
+
// dessa tela. Ambos os campos são dados do próprio módulo — deixá-los para o host
|
|
473
|
+
// obrigaria todo consumidor a reescrever o mesmo join contra tabelas que não são dele.
|
|
474
|
+
...conversationSummaryProjection
|
|
284
475
|
}).from(sessions).where(and(...conditions)).orderBy(desc(sessions.lastActivity)).limit(limit).offset(offset);
|
|
285
476
|
return rows.map((row) => ({
|
|
286
477
|
id: row.id,
|
|
287
478
|
whatsappNumber: row.whatsappNumber,
|
|
479
|
+
...row.lastContent !== null ? {
|
|
480
|
+
lastContent: row.lastContent
|
|
481
|
+
} : {},
|
|
482
|
+
...row.lastDirection !== null ? {
|
|
483
|
+
lastDirection: row.lastDirection
|
|
484
|
+
} : {},
|
|
288
485
|
lastAt: row.lastActivity.toISOString(),
|
|
289
486
|
lastInboundAt: row.lastInboundAt?.toISOString() ?? null,
|
|
290
487
|
mode: row.mode,
|
|
291
488
|
assignedUserId: row.assignedUserId,
|
|
292
489
|
waitingHuman: row.humanRequestedAt !== null,
|
|
293
|
-
unread:
|
|
490
|
+
unread: Number(row.unread),
|
|
294
491
|
currentState: row.currentState
|
|
295
492
|
}));
|
|
296
493
|
}
|
|
@@ -334,7 +531,9 @@ var MessageRepository = class {
|
|
|
334
531
|
content: params.content ?? null,
|
|
335
532
|
payload: params.payload ?? null,
|
|
336
533
|
waMessageId: params.waMessageId ?? null,
|
|
337
|
-
status: params.status ?? null
|
|
534
|
+
status: params.status ?? null,
|
|
535
|
+
moderationFlagged: params.moderationFlagged ?? null,
|
|
536
|
+
moderationTerms: params.moderationTerms ?? null
|
|
338
537
|
};
|
|
339
538
|
const [created] = await this.db.insert(messages).values(values).onConflictDoNothing().returning();
|
|
340
539
|
return created;
|
|
@@ -348,6 +547,58 @@ var MessageRepository = class {
|
|
|
348
547
|
}).where(and2(eq2(messages.companyId, companyId), eq2(messages.waMessageId, waMessageId))).returning();
|
|
349
548
|
return updated;
|
|
350
549
|
}
|
|
550
|
+
/**
|
|
551
|
+
* Grava a transcrição endereçando pelo id da Meta, para quem só tem esse.
|
|
552
|
+
*
|
|
553
|
+
* Serve ao caso em que a transcrição acontece no próprio webhook — o grafo precisa do texto para
|
|
554
|
+
* responder ao cliente, e jogar fora o que ele já pagou para transcrever significaria transcrever
|
|
555
|
+
* o mesmo áudio uma segunda vez só para o painel ver.
|
|
556
|
+
*
|
|
557
|
+
* Devolve `undefined` quando não achou a mensagem: entrega duplicada e mensagem apagada são
|
|
558
|
+
* corridas normais, não erro.
|
|
559
|
+
*/
|
|
560
|
+
async saveTranscriptionByWaMessageId(params) {
|
|
561
|
+
const [updated] = await this.db.update(messages).set({
|
|
562
|
+
transcriptionStatus: params.status,
|
|
563
|
+
...params.text !== void 0 ? {
|
|
564
|
+
transcriptionText: params.text
|
|
565
|
+
} : {},
|
|
566
|
+
...params.language !== void 0 ? {
|
|
567
|
+
transcriptionLanguage: params.language
|
|
568
|
+
} : {},
|
|
569
|
+
...params.engine !== void 0 ? {
|
|
570
|
+
transcriptionEngine: params.engine
|
|
571
|
+
} : {}
|
|
572
|
+
}).where(and2(eq2(messages.companyId, params.companyId), eq2(messages.waMessageId, params.waMessageId))).returning();
|
|
573
|
+
return updated;
|
|
574
|
+
}
|
|
575
|
+
async findById(companyId, messageId) {
|
|
576
|
+
const [found] = await this.db.select().from(messages).where(and2(eq2(messages.companyId, companyId), eq2(messages.id, messageId))).limit(1);
|
|
577
|
+
return found;
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Grava o resultado da transcrição. Devolve `undefined` quando a mensagem não existe (apagada
|
|
581
|
+
* entre o enfileiramento e a execução do job) — não é erro, é corrida normal.
|
|
582
|
+
*
|
|
583
|
+
* `text`/`language`/`engine` só são tocados quando informados: uma retentativa que volta a falhar
|
|
584
|
+
* atualiza o status sem apagar a transcrição parcial de uma tentativa anterior que tenha vindo de
|
|
585
|
+
* outro engine da cadeia.
|
|
586
|
+
*/
|
|
587
|
+
async saveTranscription(params) {
|
|
588
|
+
const [updated] = await this.db.update(messages).set({
|
|
589
|
+
transcriptionStatus: params.status,
|
|
590
|
+
...params.text !== void 0 ? {
|
|
591
|
+
transcriptionText: params.text
|
|
592
|
+
} : {},
|
|
593
|
+
...params.language !== void 0 ? {
|
|
594
|
+
transcriptionLanguage: params.language
|
|
595
|
+
} : {},
|
|
596
|
+
...params.engine !== void 0 ? {
|
|
597
|
+
transcriptionEngine: params.engine
|
|
598
|
+
} : {}
|
|
599
|
+
}).where(and2(eq2(messages.companyId, params.companyId), eq2(messages.id, params.messageId))).returning();
|
|
600
|
+
return updated;
|
|
601
|
+
}
|
|
351
602
|
async listByConversation(params) {
|
|
352
603
|
const conditions = [
|
|
353
604
|
eq2(messages.companyId, params.companyId),
|
|
@@ -397,12 +648,21 @@ var FlowGraphRepository = class {
|
|
|
397
648
|
__name(this, "FlowGraphRepository");
|
|
398
649
|
}
|
|
399
650
|
db;
|
|
400
|
-
|
|
651
|
+
cache;
|
|
652
|
+
// Cache opcional: sem ele o repositório se comporta exatamente como antes, lendo sempre do
|
|
653
|
+
// banco. É o host que decide se quer cachear e com qual provedor (ver CacheInterface).
|
|
654
|
+
constructor(db, cache) {
|
|
401
655
|
this.db = db;
|
|
656
|
+
this.cache = cache;
|
|
402
657
|
}
|
|
403
658
|
async get(companyId, key) {
|
|
659
|
+
const cached = await this.cache?.read(companyId, key);
|
|
660
|
+
if (cached) return cached;
|
|
404
661
|
const [row] = await this.db.select().from(flowGraphs).where(and3(eq3(flowGraphs.companyId, companyId), eq3(flowGraphs.key, key))).limit(1);
|
|
405
|
-
|
|
662
|
+
if (!row) return void 0;
|
|
663
|
+
const graph = toContractGraph(row);
|
|
664
|
+
await this.cache?.write(companyId, graph);
|
|
665
|
+
return graph;
|
|
406
666
|
}
|
|
407
667
|
async list(companyId) {
|
|
408
668
|
const rows = await this.db.select().from(flowGraphs).where(eq3(flowGraphs.companyId, companyId));
|
|
@@ -432,7 +692,9 @@ var FlowGraphRepository = class {
|
|
|
432
692
|
showInMenu: graph.showInMenu ?? false,
|
|
433
693
|
menuOptionLabel: graph.menuOptionLabel
|
|
434
694
|
}).returning();
|
|
435
|
-
|
|
695
|
+
const createdGraph = toContractGraph(created);
|
|
696
|
+
await this.cache?.invalidate(companyId, createdGraph.key);
|
|
697
|
+
return createdGraph;
|
|
436
698
|
}
|
|
437
699
|
// Lock otimista: a escrita só aplica se `expectedVersion` ainda bater com o que está salvo —
|
|
438
700
|
// senão, alguém mais salvou entretanto e o editor precisa recarregar (ver comentário no schema).
|
|
@@ -446,10 +708,12 @@ var FlowGraphRepository = class {
|
|
|
446
708
|
updatedAt: /* @__PURE__ */ new Date()
|
|
447
709
|
}).where(and3(eq3(flowGraphs.companyId, companyId), eq3(flowGraphs.key, graph.key), eq3(flowGraphs.version, expectedVersion))).returning();
|
|
448
710
|
if (rows.length === 0) throw new OptimisticLockError(graph.key);
|
|
711
|
+
await this.cache?.invalidate(companyId, graph.key);
|
|
449
712
|
return toContractGraph(rows[0]);
|
|
450
713
|
}
|
|
451
714
|
async delete(companyId, key) {
|
|
452
715
|
await this.db.delete(flowGraphs).where(and3(eq3(flowGraphs.companyId, companyId), eq3(flowGraphs.key, key)));
|
|
716
|
+
await this.cache?.invalidate(companyId, key);
|
|
453
717
|
}
|
|
454
718
|
// T4.2 — GetLiveFlowPositions: agrega sessões ativas por (flowKey, currentNodeId), lendo as
|
|
455
719
|
// colunas dedicadas gravadas por SessionRepository.setFlowPosition. Agrega no banco (GROUP BY,
|
|
@@ -469,6 +733,48 @@ var FlowGraphRepository = class {
|
|
|
469
733
|
}
|
|
470
734
|
};
|
|
471
735
|
|
|
736
|
+
// src/repositories/FlowGraphCache.ts
|
|
737
|
+
var KEY_PREFIX = "meta-whatsapp:flow-graph";
|
|
738
|
+
var DEFAULT_FLOW_GRAPH_CACHE_TTL_SECONDS = 300;
|
|
739
|
+
var FlowGraphCache = class {
|
|
740
|
+
static {
|
|
741
|
+
__name(this, "FlowGraphCache");
|
|
742
|
+
}
|
|
743
|
+
provider;
|
|
744
|
+
ttlSeconds;
|
|
745
|
+
constructor(provider, ttlSeconds = DEFAULT_FLOW_GRAPH_CACHE_TTL_SECONDS) {
|
|
746
|
+
this.provider = provider;
|
|
747
|
+
this.ttlSeconds = ttlSeconds;
|
|
748
|
+
}
|
|
749
|
+
// companyId na chave, e não só a flowKey: a chave do fluxo é escolhida por quem edita e se
|
|
750
|
+
// repete entre empresas — 'consorcio' existe em todas — então uma chave sem tenant serviria o
|
|
751
|
+
// grafo de uma empresa para a conversa de outra.
|
|
752
|
+
keyFor(companyId, flowKey) {
|
|
753
|
+
return `${KEY_PREFIX}:${companyId}:${flowKey}`;
|
|
754
|
+
}
|
|
755
|
+
async read(companyId, flowKey) {
|
|
756
|
+
try {
|
|
757
|
+
const cached = await this.provider.get(this.keyFor(companyId, flowKey));
|
|
758
|
+
if (!cached) return void 0;
|
|
759
|
+
return JSON.parse(cached);
|
|
760
|
+
} catch {
|
|
761
|
+
return void 0;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
async write(companyId, graph) {
|
|
765
|
+
try {
|
|
766
|
+
await this.provider.set(this.keyFor(companyId, graph.key), JSON.stringify(graph), this.ttlSeconds);
|
|
767
|
+
} catch {
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
async invalidate(companyId, flowKey) {
|
|
771
|
+
try {
|
|
772
|
+
await this.provider.delete(this.keyFor(companyId, flowKey));
|
|
773
|
+
} catch {
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
};
|
|
777
|
+
|
|
472
778
|
// src/repositories/SettingsRepository.ts
|
|
473
779
|
import { eq as eq4 } from "drizzle-orm";
|
|
474
780
|
function toContractSettings(row) {
|
|
@@ -477,7 +783,11 @@ function toContractSettings(row) {
|
|
|
477
783
|
templateLanguage: row.templateLanguage,
|
|
478
784
|
templateVariables: row.templateVariables,
|
|
479
785
|
welcomeMessage: row.welcomeMessage ?? "",
|
|
480
|
-
farewellMessage: row.farewellMessage ?? ""
|
|
786
|
+
farewellMessage: row.farewellMessage ?? "",
|
|
787
|
+
// `?? null` e não `?? false`: nulo é "o painel não decidiu", e é o que faz valer o padrão do
|
|
788
|
+
// host. Colapsar para `false` desligaria quem ligou por ambiente.
|
|
789
|
+
transcriptionEnabled: row.transcriptionEnabled ?? null,
|
|
790
|
+
transcriptionMode: row.transcriptionMode ?? null
|
|
481
791
|
};
|
|
482
792
|
}
|
|
483
793
|
__name(toContractSettings, "toContractSettings");
|
|
@@ -486,7 +796,9 @@ var EMPTY_SETTINGS = {
|
|
|
486
796
|
templateLanguage: "pt_BR",
|
|
487
797
|
templateVariables: [],
|
|
488
798
|
welcomeMessage: "",
|
|
489
|
-
farewellMessage: ""
|
|
799
|
+
farewellMessage: "",
|
|
800
|
+
transcriptionEnabled: null,
|
|
801
|
+
transcriptionMode: null
|
|
490
802
|
};
|
|
491
803
|
var SettingsRepository = class {
|
|
492
804
|
static {
|
|
@@ -515,7 +827,12 @@ var SettingsRepository = class {
|
|
|
515
827
|
templateLanguage: merged.templateLanguage,
|
|
516
828
|
templateVariables: merged.templateVariables,
|
|
517
829
|
welcomeMessage: merged.welcomeMessage || null,
|
|
518
|
-
farewellMessage: merged.farewellMessage || null
|
|
830
|
+
farewellMessage: merged.farewellMessage || null,
|
|
831
|
+
// Sem `|| null`: `false` aqui é decisão explícita do painel ("desligado para esta empresa"),
|
|
832
|
+
// e colapsá-lo para nulo faria a empresa voltar a herdar o padrão do host — exatamente o
|
|
833
|
+
// oposto do que o operador acabou de pedir.
|
|
834
|
+
transcriptionEnabled: merged.transcriptionEnabled,
|
|
835
|
+
transcriptionMode: merged.transcriptionMode
|
|
519
836
|
}).onConflictDoUpdate({
|
|
520
837
|
target: settings.companyId,
|
|
521
838
|
set: {
|
|
@@ -524,6 +841,8 @@ var SettingsRepository = class {
|
|
|
524
841
|
templateVariables: merged.templateVariables,
|
|
525
842
|
welcomeMessage: merged.welcomeMessage || null,
|
|
526
843
|
farewellMessage: merged.farewellMessage || null,
|
|
844
|
+
transcriptionEnabled: merged.transcriptionEnabled,
|
|
845
|
+
transcriptionMode: merged.transcriptionMode,
|
|
527
846
|
updatedAt: /* @__PURE__ */ new Date()
|
|
528
847
|
}
|
|
529
848
|
}).returning();
|
|
@@ -550,16 +869,19 @@ var LogMessageUseCase = class {
|
|
|
550
869
|
sessionRepository;
|
|
551
870
|
messageRepository;
|
|
552
871
|
realtime;
|
|
553
|
-
|
|
872
|
+
moderator;
|
|
873
|
+
constructor(sessionRepository, messageRepository, realtime, moderator) {
|
|
554
874
|
this.sessionRepository = sessionRepository;
|
|
555
875
|
this.messageRepository = messageRepository;
|
|
556
876
|
this.realtime = realtime;
|
|
877
|
+
this.moderator = moderator;
|
|
557
878
|
}
|
|
558
879
|
async execute(params) {
|
|
559
880
|
const session = await this.sessionRepository.getOrCreate(params.companyId, params.whatsappNumber, params.startState);
|
|
560
881
|
const saved = await this.messageRepository.insertMessage({
|
|
561
882
|
...params,
|
|
562
|
-
sessionId: session.id
|
|
883
|
+
sessionId: session.id,
|
|
884
|
+
...this.moderationOf(params)
|
|
563
885
|
});
|
|
564
886
|
if (!saved) return void 0;
|
|
565
887
|
if (params.direction === "inbound") {
|
|
@@ -572,6 +894,20 @@ var LogMessageUseCase = class {
|
|
|
572
894
|
this.realtime?.emit("global", "data-changed", {});
|
|
573
895
|
return saved;
|
|
574
896
|
}
|
|
897
|
+
// Só o que o cliente escreveu: marcar o que o próprio atendente ou o bot enviou não sinaliza
|
|
898
|
+
// abuso, apenas sujaria o transcript com etiqueta na resposta de quem atende.
|
|
899
|
+
moderationOf(params) {
|
|
900
|
+
if (!this.moderator || params.direction !== "inbound") return {};
|
|
901
|
+
const text2 = params.content?.trim();
|
|
902
|
+
if (!text2) return {};
|
|
903
|
+
const verdict = this.moderator.inspect(text2);
|
|
904
|
+
return {
|
|
905
|
+
moderationFlagged: verdict.isOffensive,
|
|
906
|
+
moderationTerms: verdict.isOffensive ? [
|
|
907
|
+
...verdict.matchedTerms
|
|
908
|
+
] : null
|
|
909
|
+
};
|
|
910
|
+
}
|
|
575
911
|
};
|
|
576
912
|
|
|
577
913
|
// src/use-cases/SendMessage.use-case.ts
|
|
@@ -585,11 +921,13 @@ var SendMessageUseCase = class {
|
|
|
585
921
|
sessionRepository;
|
|
586
922
|
logMessage;
|
|
587
923
|
objectStorage;
|
|
588
|
-
|
|
924
|
+
documentRepository;
|
|
925
|
+
constructor(channel, sessionRepository, logMessage, objectStorage, documentRepository) {
|
|
589
926
|
this.channel = channel;
|
|
590
927
|
this.sessionRepository = sessionRepository;
|
|
591
928
|
this.logMessage = logMessage;
|
|
592
929
|
this.objectStorage = objectStorage;
|
|
930
|
+
this.documentRepository = documentRepository;
|
|
593
931
|
}
|
|
594
932
|
async assertWithinWindow(companyId, whatsappNumber) {
|
|
595
933
|
const hours = await this.sessionRepository.hoursSinceLastInbound(companyId, whatsappNumber);
|
|
@@ -626,7 +964,7 @@ var SendMessageUseCase = class {
|
|
|
626
964
|
mimeType: params.mimeType,
|
|
627
965
|
key: `meta-whatsapp/${params.companyId}/${Date.now()}-${params.filename}`
|
|
628
966
|
})).uploadId : void 0;
|
|
629
|
-
|
|
967
|
+
const saved = await this.logMessage.execute({
|
|
630
968
|
companyId: params.companyId,
|
|
631
969
|
whatsappNumber: params.whatsappNumber,
|
|
632
970
|
direction: "outbound",
|
|
@@ -645,6 +983,19 @@ var SendMessageUseCase = class {
|
|
|
645
983
|
status: "sent",
|
|
646
984
|
startState: params.startState
|
|
647
985
|
});
|
|
986
|
+
if (saved && uploadId) {
|
|
987
|
+
await this.documentRepository?.link({
|
|
988
|
+
companyId: params.companyId,
|
|
989
|
+
sessionId: saved.sessionId,
|
|
990
|
+
messageId: saved.id,
|
|
991
|
+
uploadId,
|
|
992
|
+
filename: params.filename,
|
|
993
|
+
mimeType: params.mimeType,
|
|
994
|
+
sizeBytes: params.buffer.length,
|
|
995
|
+
source: params.sender
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
return saved;
|
|
648
999
|
}
|
|
649
1000
|
// Template é o único envio que ignora a janela — é justamente o mecanismo que a Meta oferece
|
|
650
1001
|
// para reabri-la.
|
|
@@ -762,8 +1113,330 @@ var ListMessagesUseCase = class {
|
|
|
762
1113
|
}
|
|
763
1114
|
};
|
|
764
1115
|
|
|
765
|
-
// src/use-cases/
|
|
1116
|
+
// src/use-cases/ListConversationDocuments.use-case.ts
|
|
766
1117
|
import { SessionNotFoundError as SessionNotFoundError2 } from "@adatechnology/meta-whatsapp-contracts";
|
|
1118
|
+
var ListConversationDocumentsUseCase = class {
|
|
1119
|
+
static {
|
|
1120
|
+
__name(this, "ListConversationDocumentsUseCase");
|
|
1121
|
+
}
|
|
1122
|
+
sessionRepository;
|
|
1123
|
+
documentRepository;
|
|
1124
|
+
constructor(sessionRepository, documentRepository) {
|
|
1125
|
+
this.sessionRepository = sessionRepository;
|
|
1126
|
+
this.documentRepository = documentRepository;
|
|
1127
|
+
}
|
|
1128
|
+
async execute(params) {
|
|
1129
|
+
const session = await this.sessionRepository.getContext(params.companyId, params.whatsappNumber);
|
|
1130
|
+
if (!session) throw new SessionNotFoundError2(params.whatsappNumber);
|
|
1131
|
+
const { rows, total } = await this.documentRepository.listByConversation({
|
|
1132
|
+
companyId: params.companyId,
|
|
1133
|
+
sessionId: session.id,
|
|
1134
|
+
...params.search ? {
|
|
1135
|
+
search: params.search
|
|
1136
|
+
} : {},
|
|
1137
|
+
...params.sources && params.sources.length > 0 ? {
|
|
1138
|
+
sources: params.sources
|
|
1139
|
+
} : {},
|
|
1140
|
+
...params.sortDirection ? {
|
|
1141
|
+
sortDirection: params.sortDirection
|
|
1142
|
+
} : {},
|
|
1143
|
+
...params.page ? {
|
|
1144
|
+
page: params.page
|
|
1145
|
+
} : {},
|
|
1146
|
+
...params.limit ? {
|
|
1147
|
+
limit: params.limit
|
|
1148
|
+
} : {}
|
|
1149
|
+
});
|
|
1150
|
+
return {
|
|
1151
|
+
documents: rows.map((row) => ({
|
|
1152
|
+
id: row.uploadId,
|
|
1153
|
+
filename: row.filename,
|
|
1154
|
+
mimeType: row.mimeType,
|
|
1155
|
+
sizeBytes: row.sizeBytes,
|
|
1156
|
+
source: row.source,
|
|
1157
|
+
linkedAt: row.linkedAt.toISOString()
|
|
1158
|
+
})),
|
|
1159
|
+
total
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
};
|
|
1163
|
+
|
|
1164
|
+
// src/use-cases/ListCompanyDocuments.use-case.ts
|
|
1165
|
+
var ListCompanyDocumentsUseCase = class {
|
|
1166
|
+
static {
|
|
1167
|
+
__name(this, "ListCompanyDocumentsUseCase");
|
|
1168
|
+
}
|
|
1169
|
+
documentRepository;
|
|
1170
|
+
constructor(documentRepository) {
|
|
1171
|
+
this.documentRepository = documentRepository;
|
|
1172
|
+
}
|
|
1173
|
+
async execute(params) {
|
|
1174
|
+
const { rows, total } = await this.documentRepository.listByCompany({
|
|
1175
|
+
companyId: params.companyId,
|
|
1176
|
+
...params.search ? {
|
|
1177
|
+
search: params.search
|
|
1178
|
+
} : {},
|
|
1179
|
+
...params.sources && params.sources.length > 0 ? {
|
|
1180
|
+
sources: params.sources
|
|
1181
|
+
} : {},
|
|
1182
|
+
...params.sortDirection ? {
|
|
1183
|
+
sortDirection: params.sortDirection
|
|
1184
|
+
} : {},
|
|
1185
|
+
...params.page ? {
|
|
1186
|
+
page: params.page
|
|
1187
|
+
} : {},
|
|
1188
|
+
...params.limit ? {
|
|
1189
|
+
limit: params.limit
|
|
1190
|
+
} : {}
|
|
1191
|
+
});
|
|
1192
|
+
return {
|
|
1193
|
+
documents: rows.map((row) => ({
|
|
1194
|
+
id: row.uploadId,
|
|
1195
|
+
conversationId: row.whatsappNumber,
|
|
1196
|
+
filename: row.filename,
|
|
1197
|
+
mimeType: row.mimeType,
|
|
1198
|
+
sizeBytes: row.sizeBytes,
|
|
1199
|
+
source: row.source,
|
|
1200
|
+
linkedAt: row.linkedAt.toISOString()
|
|
1201
|
+
})),
|
|
1202
|
+
total
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
};
|
|
1206
|
+
|
|
1207
|
+
// src/use-cases/DeleteConversation.use-case.ts
|
|
1208
|
+
import { SessionNotFoundError as SessionNotFoundError3 } from "@adatechnology/meta-whatsapp-contracts";
|
|
1209
|
+
var DeleteConversationUseCase = class {
|
|
1210
|
+
static {
|
|
1211
|
+
__name(this, "DeleteConversationUseCase");
|
|
1212
|
+
}
|
|
1213
|
+
sessionRepository;
|
|
1214
|
+
documentRepository;
|
|
1215
|
+
objectStorage;
|
|
1216
|
+
constructor(sessionRepository, documentRepository, objectStorage) {
|
|
1217
|
+
this.sessionRepository = sessionRepository;
|
|
1218
|
+
this.documentRepository = documentRepository;
|
|
1219
|
+
this.objectStorage = objectStorage;
|
|
1220
|
+
}
|
|
1221
|
+
async execute(params) {
|
|
1222
|
+
const session = await this.sessionRepository.getContext(params.companyId, params.whatsappNumber);
|
|
1223
|
+
if (!session) throw new SessionNotFoundError3(params.whatsappNumber);
|
|
1224
|
+
const uploadIds = await this.documentRepository.listUploadIdsBySession(params.companyId, session.id);
|
|
1225
|
+
if (uploadIds.length > 0 && !this.objectStorage?.delete) {
|
|
1226
|
+
throw new Error("storage_delete_unsupported: a conversa tem arquivos e o storage injetado n\xE3o implementa delete");
|
|
1227
|
+
}
|
|
1228
|
+
const failedObjects = [];
|
|
1229
|
+
let deletedObjects = 0;
|
|
1230
|
+
for (const uploadId of uploadIds) {
|
|
1231
|
+
try {
|
|
1232
|
+
await this.objectStorage?.delete?.(uploadId);
|
|
1233
|
+
deletedObjects++;
|
|
1234
|
+
} catch {
|
|
1235
|
+
failedObjects.push(uploadId);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
if (failedObjects.length > 0) return {
|
|
1239
|
+
deletedObjects,
|
|
1240
|
+
failedObjects
|
|
1241
|
+
};
|
|
1242
|
+
await this.sessionRepository.deleteByNumber(params.companyId, params.whatsappNumber);
|
|
1243
|
+
return {
|
|
1244
|
+
deletedObjects,
|
|
1245
|
+
failedObjects: []
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
};
|
|
1249
|
+
|
|
1250
|
+
// src/use-cases/PurgeExpiredDocuments.use-case.ts
|
|
1251
|
+
var DEFAULT_BATCH_SIZE = 50;
|
|
1252
|
+
var HOURS_IN_DAY = 24;
|
|
1253
|
+
var MILLISECONDS_IN_HOUR = 60 * 60 * 1e3;
|
|
1254
|
+
var PurgeExpiredDocumentsUseCase = class {
|
|
1255
|
+
static {
|
|
1256
|
+
__name(this, "PurgeExpiredDocumentsUseCase");
|
|
1257
|
+
}
|
|
1258
|
+
documentRepository;
|
|
1259
|
+
objectStorage;
|
|
1260
|
+
constructor(documentRepository, objectStorage) {
|
|
1261
|
+
this.documentRepository = documentRepository;
|
|
1262
|
+
this.objectStorage = objectStorage;
|
|
1263
|
+
}
|
|
1264
|
+
async execute(params) {
|
|
1265
|
+
if (!this.objectStorage?.delete) {
|
|
1266
|
+
throw new Error("storage_delete_unsupported: reten\xE7\xE3o exige um storage que implemente delete");
|
|
1267
|
+
}
|
|
1268
|
+
const reference = params.now ?? /* @__PURE__ */ new Date();
|
|
1269
|
+
const olderThan = new Date(reference.getTime() - params.retentionDays * HOURS_IN_DAY * MILLISECONDS_IN_HOUR);
|
|
1270
|
+
const expired = await this.documentRepository.listExpired(params.companyId, olderThan, params.batchSize ?? DEFAULT_BATCH_SIZE);
|
|
1271
|
+
const failed = [];
|
|
1272
|
+
let purged = 0;
|
|
1273
|
+
for (const document of expired) {
|
|
1274
|
+
try {
|
|
1275
|
+
await this.objectStorage.delete(document.uploadId);
|
|
1276
|
+
} catch {
|
|
1277
|
+
failed.push(document.uploadId);
|
|
1278
|
+
continue;
|
|
1279
|
+
}
|
|
1280
|
+
await this.documentRepository.deleteById(params.companyId, document.id);
|
|
1281
|
+
purged++;
|
|
1282
|
+
}
|
|
1283
|
+
return {
|
|
1284
|
+
purged,
|
|
1285
|
+
failed
|
|
1286
|
+
};
|
|
1287
|
+
}
|
|
1288
|
+
};
|
|
1289
|
+
|
|
1290
|
+
// src/repositories/DocumentRepository.ts
|
|
1291
|
+
import { and as and4, asc, count, desc as desc2, eq as eq5, ilike, inArray, lt as lt2, or } from "drizzle-orm";
|
|
1292
|
+
var DEFAULT_LIMIT3 = 50;
|
|
1293
|
+
function companyDocumentSearch(search) {
|
|
1294
|
+
const term = search?.trim();
|
|
1295
|
+
if (!term) return void 0;
|
|
1296
|
+
const digits = term.replace(/\D/g, "");
|
|
1297
|
+
const byFilename = ilike(documents.filename, `%${term}%`);
|
|
1298
|
+
if (!digits) return byFilename;
|
|
1299
|
+
return or(byFilename, ilike(sessions.whatsappNumber, `%${digits}%`));
|
|
1300
|
+
}
|
|
1301
|
+
__name(companyDocumentSearch, "companyDocumentSearch");
|
|
1302
|
+
var DocumentRepository = class {
|
|
1303
|
+
static {
|
|
1304
|
+
__name(this, "DocumentRepository");
|
|
1305
|
+
}
|
|
1306
|
+
db;
|
|
1307
|
+
constructor(db) {
|
|
1308
|
+
this.db = db;
|
|
1309
|
+
}
|
|
1310
|
+
/**
|
|
1311
|
+
* Idempotente por (companyId, uploadId), garantido pelo índice único e não por SELECT prévio: o
|
|
1312
|
+
* job de ingestão é reentregue por retry e duas tentativas concorrentes passariam as duas por uma
|
|
1313
|
+
* checagem, duplicando a linha no painel.
|
|
1314
|
+
*
|
|
1315
|
+
* Devolve `undefined` quando o documento já estava linkado.
|
|
1316
|
+
*/
|
|
1317
|
+
async link(params) {
|
|
1318
|
+
const values = {
|
|
1319
|
+
companyId: params.companyId,
|
|
1320
|
+
sessionId: params.sessionId,
|
|
1321
|
+
messageId: params.messageId ?? null,
|
|
1322
|
+
uploadId: params.uploadId,
|
|
1323
|
+
filename: params.filename,
|
|
1324
|
+
mimeType: params.mimeType,
|
|
1325
|
+
sizeBytes: params.sizeBytes,
|
|
1326
|
+
sha256: params.sha256 ?? null,
|
|
1327
|
+
source: params.source
|
|
1328
|
+
};
|
|
1329
|
+
const [created] = await this.db.insert(documents).values(values).onConflictDoNothing().returning();
|
|
1330
|
+
return created;
|
|
1331
|
+
}
|
|
1332
|
+
async listByConversation(params) {
|
|
1333
|
+
const filters = [
|
|
1334
|
+
eq5(documents.companyId, params.companyId),
|
|
1335
|
+
eq5(documents.sessionId, params.sessionId)
|
|
1336
|
+
];
|
|
1337
|
+
if (params.search) filters.push(ilike(documents.filename, `%${params.search}%`));
|
|
1338
|
+
if (params.sources && params.sources.length > 0) {
|
|
1339
|
+
filters.push(inArray(documents.source, [
|
|
1340
|
+
...params.sources
|
|
1341
|
+
]));
|
|
1342
|
+
}
|
|
1343
|
+
const where = and4(...filters);
|
|
1344
|
+
const limit = params.limit ?? DEFAULT_LIMIT3;
|
|
1345
|
+
const page = params.page && params.page > 0 ? params.page : 1;
|
|
1346
|
+
const orderBy = params.sortDirection === "asc" ? asc(documents.linkedAt) : desc2(documents.linkedAt);
|
|
1347
|
+
const [rows, counted] = await Promise.all([
|
|
1348
|
+
this.db.select().from(documents).where(where).orderBy(orderBy).limit(limit).offset((page - 1) * limit),
|
|
1349
|
+
this.db.select({
|
|
1350
|
+
value: count()
|
|
1351
|
+
}).from(documents).where(where)
|
|
1352
|
+
]);
|
|
1353
|
+
return {
|
|
1354
|
+
rows,
|
|
1355
|
+
total: counted[0]?.value ?? 0
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
/**
|
|
1359
|
+
* A biblioteca da EMPRESA inteira, não de uma conversa.
|
|
1360
|
+
*
|
|
1361
|
+
* A busca casa nome do arquivo OU telefone da conversa — ver `companyDocumentSearch`.
|
|
1362
|
+
*
|
|
1363
|
+
* Faz join com `sessions` para carregar de qual conversa cada arquivo veio — numa lista global,
|
|
1364
|
+
* arquivo sem essa referência é inútil: o atendente vê "comprovante.pdf" e não sabe de quem.
|
|
1365
|
+
*
|
|
1366
|
+
* Ordena por `linkedAt` apoiada no índice `idx_documents_company_linked`, que já existia para a
|
|
1367
|
+
* varredura de retenção.
|
|
1368
|
+
*/
|
|
1369
|
+
async listByCompany(params) {
|
|
1370
|
+
const filters = [
|
|
1371
|
+
eq5(documents.companyId, params.companyId)
|
|
1372
|
+
];
|
|
1373
|
+
const search = companyDocumentSearch(params.search);
|
|
1374
|
+
if (search) filters.push(search);
|
|
1375
|
+
if (params.sources && params.sources.length > 0) {
|
|
1376
|
+
filters.push(inArray(documents.source, [
|
|
1377
|
+
...params.sources
|
|
1378
|
+
]));
|
|
1379
|
+
}
|
|
1380
|
+
const where = and4(...filters);
|
|
1381
|
+
const limit = params.limit ?? DEFAULT_LIMIT3;
|
|
1382
|
+
const page = params.page && params.page > 0 ? params.page : 1;
|
|
1383
|
+
const orderBy = params.sortDirection === "asc" ? asc(documents.linkedAt) : desc2(documents.linkedAt);
|
|
1384
|
+
const [rows, counted] = await Promise.all([
|
|
1385
|
+
this.db.select({
|
|
1386
|
+
id: documents.id,
|
|
1387
|
+
uploadId: documents.uploadId,
|
|
1388
|
+
filename: documents.filename,
|
|
1389
|
+
mimeType: documents.mimeType,
|
|
1390
|
+
sizeBytes: documents.sizeBytes,
|
|
1391
|
+
source: documents.source,
|
|
1392
|
+
linkedAt: documents.linkedAt,
|
|
1393
|
+
// Só o número: o NOME do cliente é dado do produto (tabela própria dele), não do
|
|
1394
|
+
// módulo. Quem quiser exibir "Marina Alves" enriquece na borda.
|
|
1395
|
+
whatsappNumber: sessions.whatsappNumber
|
|
1396
|
+
}).from(documents).innerJoin(sessions, eq5(documents.sessionId, sessions.id)).where(where).orderBy(orderBy).limit(limit).offset((page - 1) * limit),
|
|
1397
|
+
// O mesmo join da listagem, e não só `from(documents)`: a busca pode citar
|
|
1398
|
+
// `sessions.whatsapp_number`, e uma contagem sem a tabela na cláusula não compila — pior,
|
|
1399
|
+
// se compilasse, o total divergiria das linhas e a paginação prometeria páginas vazias.
|
|
1400
|
+
this.db.select({
|
|
1401
|
+
value: count()
|
|
1402
|
+
}).from(documents).innerJoin(sessions, eq5(documents.sessionId, sessions.id)).where(where)
|
|
1403
|
+
]);
|
|
1404
|
+
return {
|
|
1405
|
+
rows,
|
|
1406
|
+
total: counted[0]?.value ?? 0
|
|
1407
|
+
};
|
|
1408
|
+
}
|
|
1409
|
+
/**
|
|
1410
|
+
* Um documento pela key do objeto. Serve para recuperar o nome original na hora de assinar o
|
|
1411
|
+
* download: a key é caminho no bucket e salvaria o arquivo com o id da Meta.
|
|
1412
|
+
*/
|
|
1413
|
+
async findByUploadId(companyId, uploadId) {
|
|
1414
|
+
const [row] = await this.db.select().from(documents).where(and4(eq5(documents.companyId, companyId), eq5(documents.uploadId, uploadId))).limit(1);
|
|
1415
|
+
return row;
|
|
1416
|
+
}
|
|
1417
|
+
/**
|
|
1418
|
+
* Os objetos a apagar no storage antes de a linha sumir.
|
|
1419
|
+
*
|
|
1420
|
+
* Existe porque a cascata da FK apaga a linha e deixa o binário órfão: quem for apagar a conversa
|
|
1421
|
+
* precisa desta lista primeiro, senão paga armazenamento para sempre por arquivo inalcançável.
|
|
1422
|
+
*/
|
|
1423
|
+
async listUploadIdsBySession(companyId, sessionId) {
|
|
1424
|
+
const rows = await this.db.select({
|
|
1425
|
+
uploadId: documents.uploadId
|
|
1426
|
+
}).from(documents).where(and4(eq5(documents.companyId, companyId), eq5(documents.sessionId, sessionId)));
|
|
1427
|
+
return rows.map((row) => row.uploadId);
|
|
1428
|
+
}
|
|
1429
|
+
/** Varredura de retenção por idade — o par é o mesmo cuidado com o objeto no storage. */
|
|
1430
|
+
async listExpired(companyId, olderThan, limit = DEFAULT_LIMIT3) {
|
|
1431
|
+
return this.db.select().from(documents).where(and4(eq5(documents.companyId, companyId), lt2(documents.linkedAt, olderThan))).orderBy(documents.linkedAt).limit(limit);
|
|
1432
|
+
}
|
|
1433
|
+
async deleteById(companyId, id) {
|
|
1434
|
+
await this.db.delete(documents).where(and4(eq5(documents.companyId, companyId), eq5(documents.id, id)));
|
|
1435
|
+
}
|
|
1436
|
+
};
|
|
1437
|
+
|
|
1438
|
+
// src/use-cases/ExportConversation.use-case.ts
|
|
1439
|
+
import { SessionNotFoundError as SessionNotFoundError4 } from "@adatechnology/meta-whatsapp-contracts";
|
|
767
1440
|
var ExportConversationUseCase = class {
|
|
768
1441
|
static {
|
|
769
1442
|
__name(this, "ExportConversationUseCase");
|
|
@@ -774,7 +1447,7 @@ var ExportConversationUseCase = class {
|
|
|
774
1447
|
}
|
|
775
1448
|
async execute(params) {
|
|
776
1449
|
const result = await this.sessionRepository.exportConversation(params.companyId, params.whatsappNumber);
|
|
777
|
-
if (!result) throw new
|
|
1450
|
+
if (!result) throw new SessionNotFoundError4(params.whatsappNumber);
|
|
778
1451
|
return result;
|
|
779
1452
|
}
|
|
780
1453
|
};
|
|
@@ -1011,16 +1684,168 @@ var FlowInterpreter = class {
|
|
|
1011
1684
|
}
|
|
1012
1685
|
};
|
|
1013
1686
|
|
|
1687
|
+
// src/flows/createSendMediaAction.ts
|
|
1688
|
+
function mediaTypeFor2(mimeType) {
|
|
1689
|
+
if (mimeType.startsWith("image/")) return "image";
|
|
1690
|
+
if (mimeType.startsWith("audio/")) return "audio";
|
|
1691
|
+
if (mimeType.startsWith("video/")) return "video";
|
|
1692
|
+
return "document";
|
|
1693
|
+
}
|
|
1694
|
+
__name(mediaTypeFor2, "mediaTypeFor");
|
|
1695
|
+
function createSendMediaAction(params) {
|
|
1696
|
+
return async ({ node, session, channel }) => {
|
|
1697
|
+
if (!session.flowKey) return;
|
|
1698
|
+
const location = {
|
|
1699
|
+
companyId: session.companyId,
|
|
1700
|
+
flowKey: session.flowKey,
|
|
1701
|
+
nodeId: node.id
|
|
1702
|
+
};
|
|
1703
|
+
const attachments = await params.flowMediaRepository.listActive(location);
|
|
1704
|
+
for (const attachment of attachments) {
|
|
1705
|
+
try {
|
|
1706
|
+
const buffer = await params.objectStorage.getObject(attachment.uploadId);
|
|
1707
|
+
const { externalMessageId } = await channel.sendMedia({
|
|
1708
|
+
to: session.whatsappNumber,
|
|
1709
|
+
buffer,
|
|
1710
|
+
mimeType: attachment.mimeType,
|
|
1711
|
+
filename: attachment.filename,
|
|
1712
|
+
caption: attachment.caption ?? void 0
|
|
1713
|
+
});
|
|
1714
|
+
await params.logMessage.execute({
|
|
1715
|
+
companyId: session.companyId,
|
|
1716
|
+
whatsappNumber: session.whatsappNumber,
|
|
1717
|
+
direction: "outbound",
|
|
1718
|
+
sender: "bot",
|
|
1719
|
+
agentUserId: null,
|
|
1720
|
+
type: mediaTypeFor2(attachment.mimeType),
|
|
1721
|
+
content: attachment.caption ?? attachment.filename,
|
|
1722
|
+
payload: {
|
|
1723
|
+
filename: attachment.filename,
|
|
1724
|
+
mimeType: attachment.mimeType,
|
|
1725
|
+
uploadId: attachment.uploadId,
|
|
1726
|
+
flowMediaId: attachment.id
|
|
1727
|
+
},
|
|
1728
|
+
waMessageId: externalMessageId,
|
|
1729
|
+
status: "sent",
|
|
1730
|
+
startState: params.startState
|
|
1731
|
+
});
|
|
1732
|
+
} catch (error) {
|
|
1733
|
+
params.onError?.(error, {
|
|
1734
|
+
flowKey: location.flowKey,
|
|
1735
|
+
nodeId: node.id,
|
|
1736
|
+
uploadId: attachment.uploadId
|
|
1737
|
+
});
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
};
|
|
1741
|
+
}
|
|
1742
|
+
__name(createSendMediaAction, "createSendMediaAction");
|
|
1743
|
+
|
|
1744
|
+
// src/repositories/FlowMediaRepository.ts
|
|
1745
|
+
import { and as and5, asc as asc2, eq as eq6, sql as sql4 } from "drizzle-orm";
|
|
1746
|
+
var FlowMediaRepository = class {
|
|
1747
|
+
static {
|
|
1748
|
+
__name(this, "FlowMediaRepository");
|
|
1749
|
+
}
|
|
1750
|
+
db;
|
|
1751
|
+
constructor(db) {
|
|
1752
|
+
this.db = db;
|
|
1753
|
+
}
|
|
1754
|
+
locationFilter(location) {
|
|
1755
|
+
return and5(eq6(flowMedia.companyId, location.companyId), eq6(flowMedia.flowKey, location.flowKey), eq6(flowMedia.nodeId, location.nodeId));
|
|
1756
|
+
}
|
|
1757
|
+
// O que o nó realmente envia, na ordem de envio. `active` filtrado aqui e não no chamador:
|
|
1758
|
+
// é a razão de a coluna existir, e um chamador que esquecesse do filtro mandaria ao cliente
|
|
1759
|
+
// justamente o material que alguém desligou.
|
|
1760
|
+
async listActive(location) {
|
|
1761
|
+
return this.db.select().from(flowMedia).where(and5(this.locationFilter(location), eq6(flowMedia.active, true))).orderBy(asc2(flowMedia.sortOrder), asc2(flowMedia.createdAt));
|
|
1762
|
+
}
|
|
1763
|
+
// Inclui os desligados — é a visão do editor, onde desligar precisa continuar visível para
|
|
1764
|
+
// poder ser religado.
|
|
1765
|
+
async listAll(location) {
|
|
1766
|
+
return this.db.select().from(flowMedia).where(this.locationFilter(location)).orderBy(asc2(flowMedia.sortOrder), asc2(flowMedia.createdAt));
|
|
1767
|
+
}
|
|
1768
|
+
/**
|
|
1769
|
+
* Anexa um arquivo já existente no storage ao nó.
|
|
1770
|
+
*
|
|
1771
|
+
* `onConflictDoUpdate` em vez de deixar estourar: reanexar o mesmo arquivo é clique repetido no
|
|
1772
|
+
* editor, e o esperado ali é atualizar a legenda/ordem — não um erro de índice único na cara de
|
|
1773
|
+
* quem está montando o fluxo.
|
|
1774
|
+
*/
|
|
1775
|
+
async attach(params) {
|
|
1776
|
+
const [row] = await this.db.insert(flowMedia).values({
|
|
1777
|
+
companyId: params.companyId,
|
|
1778
|
+
flowKey: params.flowKey,
|
|
1779
|
+
nodeId: params.nodeId,
|
|
1780
|
+
uploadId: params.uploadId,
|
|
1781
|
+
filename: params.filename,
|
|
1782
|
+
mimeType: params.mimeType,
|
|
1783
|
+
sizeBytes: params.sizeBytes,
|
|
1784
|
+
caption: params.caption ?? null,
|
|
1785
|
+
sortOrder: params.sortOrder ?? 0
|
|
1786
|
+
}).onConflictDoUpdate({
|
|
1787
|
+
target: [
|
|
1788
|
+
flowMedia.companyId,
|
|
1789
|
+
flowMedia.flowKey,
|
|
1790
|
+
flowMedia.nodeId,
|
|
1791
|
+
flowMedia.uploadId
|
|
1792
|
+
],
|
|
1793
|
+
set: {
|
|
1794
|
+
caption: params.caption ?? null,
|
|
1795
|
+
sortOrder: params.sortOrder ?? 0,
|
|
1796
|
+
active: true,
|
|
1797
|
+
updatedAt: sql4`now()`
|
|
1798
|
+
}
|
|
1799
|
+
}).returning();
|
|
1800
|
+
return row;
|
|
1801
|
+
}
|
|
1802
|
+
async update(params) {
|
|
1803
|
+
const [row] = await this.db.update(flowMedia).set({
|
|
1804
|
+
...params.caption !== void 0 ? {
|
|
1805
|
+
caption: params.caption
|
|
1806
|
+
} : {},
|
|
1807
|
+
...params.sortOrder !== void 0 ? {
|
|
1808
|
+
sortOrder: params.sortOrder
|
|
1809
|
+
} : {},
|
|
1810
|
+
...params.active !== void 0 ? {
|
|
1811
|
+
active: params.active
|
|
1812
|
+
} : {},
|
|
1813
|
+
updatedAt: sql4`now()`
|
|
1814
|
+
}).where(and5(eq6(flowMedia.companyId, params.companyId), eq6(flowMedia.id, params.id))).returning();
|
|
1815
|
+
return row;
|
|
1816
|
+
}
|
|
1817
|
+
/**
|
|
1818
|
+
* Desanexa do nó. Não toca no storage de propósito: o mesmo `uploadId` pode estar anexado a
|
|
1819
|
+
* outro nó ou a outro fluxo, e apagar o binário aqui quebraria os demais. Quem apaga objeto é o
|
|
1820
|
+
* host, que é dono da biblioteca de arquivos.
|
|
1821
|
+
*/
|
|
1822
|
+
async detach(params) {
|
|
1823
|
+
await this.db.delete(flowMedia).where(and5(eq6(flowMedia.companyId, params.companyId), eq6(flowMedia.id, params.id)));
|
|
1824
|
+
}
|
|
1825
|
+
// Chamado ao salvar o grafo: nós apagados no editor deixam linhas que nada mais alcança.
|
|
1826
|
+
async detachRemovedNodes(params) {
|
|
1827
|
+
const condition = params.existingNodeIds.length === 0 ? void 0 : sql4`${flowMedia.nodeId} NOT IN ${params.existingNodeIds}`;
|
|
1828
|
+
await this.db.delete(flowMedia).where(and5(eq6(flowMedia.companyId, params.companyId), eq6(flowMedia.flowKey, params.flowKey), condition));
|
|
1829
|
+
}
|
|
1830
|
+
};
|
|
1831
|
+
|
|
1014
1832
|
// src/channel/WhatsAppChannelAdapter.ts
|
|
1015
1833
|
import { WhatsAppWindowExpiredError as ProviderWindowExpiredError } from "@adatechnology/meta-graph-core";
|
|
1016
1834
|
import { WindowExpiredError as WindowExpiredError2 } from "@adatechnology/meta-whatsapp-contracts";
|
|
1835
|
+
|
|
1836
|
+
// src/channel/previewMedia.ts
|
|
1837
|
+
import { PREVIEW_MEDIA_ID_PREFIX, toPreviewMediaId, resolvePreviewUploadId } from "@adatechnology/meta-whatsapp-contracts";
|
|
1838
|
+
|
|
1839
|
+
// src/channel/WhatsAppChannelAdapter.ts
|
|
1017
1840
|
var WhatsAppChannelAdapter = class {
|
|
1018
1841
|
static {
|
|
1019
1842
|
__name(this, "WhatsAppChannelAdapter");
|
|
1020
1843
|
}
|
|
1021
1844
|
messages;
|
|
1022
|
-
|
|
1845
|
+
previewMedia;
|
|
1846
|
+
constructor(messages2, previewMedia) {
|
|
1023
1847
|
this.messages = messages2;
|
|
1848
|
+
this.previewMedia = previewMedia;
|
|
1024
1849
|
}
|
|
1025
1850
|
async translateErrors(operation) {
|
|
1026
1851
|
try {
|
|
@@ -1064,7 +1889,31 @@ var WhatsAppChannelAdapter = class {
|
|
|
1064
1889
|
externalMessageId: result.waMessageId
|
|
1065
1890
|
};
|
|
1066
1891
|
}
|
|
1892
|
+
async sendInteractiveButtons(params) {
|
|
1893
|
+
const result = await this.translateErrors(() => this.messages.sendInteractiveButtons({
|
|
1894
|
+
to: params.to,
|
|
1895
|
+
bodyText: params.body,
|
|
1896
|
+
buttons: params.buttons
|
|
1897
|
+
}));
|
|
1898
|
+
return {
|
|
1899
|
+
externalMessageId: result.waMessageId
|
|
1900
|
+
};
|
|
1901
|
+
}
|
|
1902
|
+
/**
|
|
1903
|
+
* Busca o binário da mídia — da Meta, ou do storage quando o id é do simulador.
|
|
1904
|
+
*
|
|
1905
|
+
* O desvio acontece ANTES de qualquer chamada de rede: id do simulador não existe na Meta, e
|
|
1906
|
+
* tentar buscá-lo lá renderia um 404 confuso em vez do áudio que o operador acabou de gravar.
|
|
1907
|
+
*/
|
|
1067
1908
|
async fetchMediaAsBase64(mediaId) {
|
|
1909
|
+
const uploadId = this.previewMedia?.isEnabled ? resolvePreviewUploadId(mediaId) : void 0;
|
|
1910
|
+
if (uploadId) {
|
|
1911
|
+
const buffer = await this.previewMedia.objectStorage.getObject(uploadId);
|
|
1912
|
+
return {
|
|
1913
|
+
data: buffer.toString("base64"),
|
|
1914
|
+
mimeType: this.previewMedia.defaultMimeType ?? "audio/ogg"
|
|
1915
|
+
};
|
|
1916
|
+
}
|
|
1068
1917
|
return this.translateErrors(() => this.messages.fetchMediaAsBase64(mediaId));
|
|
1069
1918
|
}
|
|
1070
1919
|
};
|
|
@@ -1106,6 +1955,325 @@ async function claimWebhookDelivery(params) {
|
|
|
1106
1955
|
}
|
|
1107
1956
|
__name(claimWebhookDelivery, "claimWebhookDelivery");
|
|
1108
1957
|
|
|
1958
|
+
// src/channel/IngestInboundMedia.use-case.ts
|
|
1959
|
+
import { eq as eq7, and as and6 } from "drizzle-orm";
|
|
1960
|
+
|
|
1961
|
+
// src/use-cases/TranscribeAudio.use-case.ts
|
|
1962
|
+
import { AudioNotIngestedError, MessageNotAudioError, TranscriptionDisabledError } from "@adatechnology/meta-whatsapp-contracts";
|
|
1963
|
+
|
|
1964
|
+
// src/transcription.types.ts
|
|
1965
|
+
var TRANSCRIPTION_STATUS = {
|
|
1966
|
+
/** Falhou de forma retriável (cota, rede, 5xx) — vai sair quando alguém tentar de novo. */
|
|
1967
|
+
PENDING: "pending",
|
|
1968
|
+
/** Processado. Texto vazio aqui é áudio em silêncio, e NÃO deve ser reprocessado. */
|
|
1969
|
+
DONE: "done",
|
|
1970
|
+
/** Falha definitiva do engine (credencial, áudio corrompido, arquivo grande demais). */
|
|
1971
|
+
FAILED: "failed",
|
|
1972
|
+
/** Nenhum engine da cadeia aceita o formato. Retentar não conserta codec. */
|
|
1973
|
+
UNSUPPORTED: "unsupported"
|
|
1974
|
+
};
|
|
1975
|
+
var TRANSCRIPTION_MODE = {
|
|
1976
|
+
AUTO: "auto",
|
|
1977
|
+
ON_DEMAND: "onDemand"
|
|
1978
|
+
};
|
|
1979
|
+
function isRetriableTranscriptionError(error) {
|
|
1980
|
+
if (typeof error !== "object" || error === null) return true;
|
|
1981
|
+
const isRetriable = error.isRetriable;
|
|
1982
|
+
return typeof isRetriable === "boolean" ? isRetriable : true;
|
|
1983
|
+
}
|
|
1984
|
+
__name(isRetriableTranscriptionError, "isRetriableTranscriptionError");
|
|
1985
|
+
function isUnsupportedTranscriptionError(error) {
|
|
1986
|
+
return typeof error === "object" && error !== null && error.name === "TranscriptionUnsupportedError";
|
|
1987
|
+
}
|
|
1988
|
+
__name(isUnsupportedTranscriptionError, "isUnsupportedTranscriptionError");
|
|
1989
|
+
function transcriptionRetryAfterSeconds(error) {
|
|
1990
|
+
if (typeof error !== "object" || error === null) return void 0;
|
|
1991
|
+
const retryAfter = error.retryAfterSeconds;
|
|
1992
|
+
return typeof retryAfter === "number" ? retryAfter : void 0;
|
|
1993
|
+
}
|
|
1994
|
+
__name(transcriptionRetryAfterSeconds, "transcriptionRetryAfterSeconds");
|
|
1995
|
+
function isAudioMimeType(mimeType) {
|
|
1996
|
+
return typeof mimeType === "string" && mimeType.trim().toLowerCase().startsWith("audio/");
|
|
1997
|
+
}
|
|
1998
|
+
__name(isAudioMimeType, "isAudioMimeType");
|
|
1999
|
+
|
|
2000
|
+
// src/use-cases/TranscribeAudio.use-case.ts
|
|
2001
|
+
var TranscribeAudioUseCase = class {
|
|
2002
|
+
static {
|
|
2003
|
+
__name(this, "TranscribeAudioUseCase");
|
|
2004
|
+
}
|
|
2005
|
+
dependencies;
|
|
2006
|
+
constructor(dependencies) {
|
|
2007
|
+
this.dependencies = dependencies;
|
|
2008
|
+
}
|
|
2009
|
+
async execute(params) {
|
|
2010
|
+
const message = await this.dependencies.messageRepository.findById(params.companyId, params.messageId);
|
|
2011
|
+
if (!message) throw new Error(`Mensagem ${params.messageId} n\xE3o encontrada para transcri\xE7\xE3o`);
|
|
2012
|
+
if (message.transcriptionStatus === TRANSCRIPTION_STATUS.DONE && !params.force) {
|
|
2013
|
+
return {
|
|
2014
|
+
status: TRANSCRIPTION_STATUS.DONE,
|
|
2015
|
+
text: message.transcriptionText,
|
|
2016
|
+
language: message.transcriptionLanguage,
|
|
2017
|
+
engine: message.transcriptionEngine,
|
|
2018
|
+
alreadyTranscribed: true
|
|
2019
|
+
};
|
|
2020
|
+
}
|
|
2021
|
+
if (this.dependencies.resolvePolicy) {
|
|
2022
|
+
const policy = await this.dependencies.resolvePolicy(params.companyId);
|
|
2023
|
+
if (!policy.isEnabled) throw new TranscriptionDisabledError();
|
|
2024
|
+
}
|
|
2025
|
+
const audio = extractAudioReference(message);
|
|
2026
|
+
const buffer = await this.dependencies.objectStorage.getObject(audio.uploadId);
|
|
2027
|
+
return this.transcribeBuffer({
|
|
2028
|
+
...params,
|
|
2029
|
+
buffer,
|
|
2030
|
+
mimeType: audio.mimeType,
|
|
2031
|
+
uploadId: audio.uploadId,
|
|
2032
|
+
message
|
|
2033
|
+
});
|
|
2034
|
+
}
|
|
2035
|
+
async transcribeBuffer(context) {
|
|
2036
|
+
try {
|
|
2037
|
+
const result = await this.dependencies.transcriber.transcribe({
|
|
2038
|
+
buffer: context.buffer,
|
|
2039
|
+
mimeType: context.mimeType,
|
|
2040
|
+
...this.dependencies.languageHint ? {
|
|
2041
|
+
languageHint: this.dependencies.languageHint
|
|
2042
|
+
} : {}
|
|
2043
|
+
});
|
|
2044
|
+
await this.dependencies.messageRepository.saveTranscription({
|
|
2045
|
+
companyId: context.companyId,
|
|
2046
|
+
messageId: context.messageId,
|
|
2047
|
+
status: TRANSCRIPTION_STATUS.DONE,
|
|
2048
|
+
text: result.text,
|
|
2049
|
+
language: result.language ?? null,
|
|
2050
|
+
engine: result.engine
|
|
2051
|
+
});
|
|
2052
|
+
return {
|
|
2053
|
+
status: TRANSCRIPTION_STATUS.DONE,
|
|
2054
|
+
text: result.text,
|
|
2055
|
+
language: result.language ?? null,
|
|
2056
|
+
engine: result.engine,
|
|
2057
|
+
alreadyTranscribed: false
|
|
2058
|
+
};
|
|
2059
|
+
} catch (error) {
|
|
2060
|
+
await this.persistFailure(context, error);
|
|
2061
|
+
throw error;
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
/**
|
|
2065
|
+
* Carimba o motivo antes de propagar. O status é o que impede os dois desperdícios simétricos:
|
|
2066
|
+
* reprocessar para sempre um codec impossível, e desistir de um áudio que só esbarrou na cota.
|
|
2067
|
+
*/
|
|
2068
|
+
async persistFailure(context, error) {
|
|
2069
|
+
const status = resolveFailureStatus(error);
|
|
2070
|
+
await this.dependencies.messageRepository.saveTranscription({
|
|
2071
|
+
companyId: context.companyId,
|
|
2072
|
+
messageId: context.messageId,
|
|
2073
|
+
status
|
|
2074
|
+
});
|
|
2075
|
+
if (status !== TRANSCRIPTION_STATUS.PENDING) return;
|
|
2076
|
+
const retryAfterSeconds = transcriptionRetryAfterSeconds(error);
|
|
2077
|
+
await this.dependencies.hooks?.onTranscriptionDeferred?.({
|
|
2078
|
+
companyId: context.companyId,
|
|
2079
|
+
messageId: context.messageId,
|
|
2080
|
+
whatsappNumber: context.message.whatsappNumber,
|
|
2081
|
+
uploadId: context.uploadId,
|
|
2082
|
+
...retryAfterSeconds !== void 0 ? {
|
|
2083
|
+
retryAfterSeconds
|
|
2084
|
+
} : {},
|
|
2085
|
+
reason: retryAfterSeconds !== void 0 ? "rate-limited" : "transient-failure",
|
|
2086
|
+
error
|
|
2087
|
+
});
|
|
2088
|
+
}
|
|
2089
|
+
};
|
|
2090
|
+
function resolveFailureStatus(error) {
|
|
2091
|
+
if (isUnsupportedTranscriptionError(error)) return TRANSCRIPTION_STATUS.UNSUPPORTED;
|
|
2092
|
+
return isRetriableTranscriptionError(error) ? TRANSCRIPTION_STATUS.PENDING : TRANSCRIPTION_STATUS.FAILED;
|
|
2093
|
+
}
|
|
2094
|
+
__name(resolveFailureStatus, "resolveFailureStatus");
|
|
2095
|
+
function extractAudioReference(message) {
|
|
2096
|
+
const payload = message.payload ?? {};
|
|
2097
|
+
const audio = payload["audio"];
|
|
2098
|
+
const mimeType = typeof payload["mimeType"] === "string" ? payload["mimeType"] : audio?.mime_type;
|
|
2099
|
+
if (!isAudioMimeType(mimeType) && !audio) {
|
|
2100
|
+
throw new MessageNotAudioError(message.id, message.type);
|
|
2101
|
+
}
|
|
2102
|
+
const uploadId = payload["uploadId"];
|
|
2103
|
+
if (typeof uploadId !== "string" || uploadId.length === 0) {
|
|
2104
|
+
throw new AudioNotIngestedError(message.id);
|
|
2105
|
+
}
|
|
2106
|
+
return {
|
|
2107
|
+
uploadId,
|
|
2108
|
+
mimeType: mimeType ?? "audio/ogg"
|
|
2109
|
+
};
|
|
2110
|
+
}
|
|
2111
|
+
__name(extractAudioReference, "extractAudioReference");
|
|
2112
|
+
|
|
2113
|
+
// src/channel/IngestInboundMedia.use-case.ts
|
|
2114
|
+
var IngestInboundMediaUseCase = class {
|
|
2115
|
+
static {
|
|
2116
|
+
__name(this, "IngestInboundMediaUseCase");
|
|
2117
|
+
}
|
|
2118
|
+
db;
|
|
2119
|
+
channel;
|
|
2120
|
+
objectStorage;
|
|
2121
|
+
documentRepository;
|
|
2122
|
+
transcription;
|
|
2123
|
+
constructor(db, channel, objectStorage, documentRepository, transcription) {
|
|
2124
|
+
this.db = db;
|
|
2125
|
+
this.channel = channel;
|
|
2126
|
+
this.objectStorage = objectStorage;
|
|
2127
|
+
this.documentRepository = documentRepository;
|
|
2128
|
+
this.transcription = transcription;
|
|
2129
|
+
}
|
|
2130
|
+
async execute(params) {
|
|
2131
|
+
const [message] = await this.db.select().from(messages).where(and6(eq7(messages.companyId, params.companyId), eq7(messages.id, params.messageId))).limit(1);
|
|
2132
|
+
if (!message) throw new Error(`Mensagem ${params.messageId} n\xE3o encontrada para ingest\xE3o de m\xEDdia`);
|
|
2133
|
+
const payload = message.payload ?? {};
|
|
2134
|
+
if (payload["uploadId"] && payload["sourceMediaId"] === params.sourceMediaId) {
|
|
2135
|
+
return {
|
|
2136
|
+
uploadId: String(payload["uploadId"]),
|
|
2137
|
+
alreadyIngested: true
|
|
2138
|
+
};
|
|
2139
|
+
}
|
|
2140
|
+
const { data, mimeType } = await this.channel.fetchMediaAsBase64(params.sourceMediaId);
|
|
2141
|
+
const buffer = Buffer.from(data, "base64");
|
|
2142
|
+
const { uploadId } = await this.objectStorage.upload({
|
|
2143
|
+
buffer,
|
|
2144
|
+
mimeType: mimeType || params.mimeType,
|
|
2145
|
+
key: `meta-whatsapp/${params.companyId}/inbound/${params.sourceMediaId}`
|
|
2146
|
+
});
|
|
2147
|
+
const updatedPayload = {
|
|
2148
|
+
...payload,
|
|
2149
|
+
uploadId,
|
|
2150
|
+
sourceMediaId: params.sourceMediaId,
|
|
2151
|
+
mimeType: mimeType || params.mimeType,
|
|
2152
|
+
// O tamanho já está na mão (é o buffer que acabou de ser copiado) e a bolha de documento o
|
|
2153
|
+
// exibe ao lado do tipo. Sem gravar aqui, a UI mostraria "PDF" sem o "· 180 KB", e buscá-lo
|
|
2154
|
+
// depois custaria uma consulta à tabela de documentos por mensagem renderizada.
|
|
2155
|
+
sizeBytes: buffer.length,
|
|
2156
|
+
...params.filename ? {
|
|
2157
|
+
filename: params.filename
|
|
2158
|
+
} : {}
|
|
2159
|
+
};
|
|
2160
|
+
await this.db.update(messages).set({
|
|
2161
|
+
payload: updatedPayload
|
|
2162
|
+
}).where(and6(eq7(messages.companyId, params.companyId), eq7(messages.id, params.messageId)));
|
|
2163
|
+
await this.documentRepository?.link({
|
|
2164
|
+
companyId: params.companyId,
|
|
2165
|
+
sessionId: message.sessionId,
|
|
2166
|
+
messageId: message.id,
|
|
2167
|
+
uploadId,
|
|
2168
|
+
// Áudio e sticker chegam sem nome; sem um rótulo o painel mostraria linha vazia.
|
|
2169
|
+
filename: params.filename ?? `${params.sourceMediaId}`,
|
|
2170
|
+
mimeType: mimeType || params.mimeType,
|
|
2171
|
+
sizeBytes: buffer.length,
|
|
2172
|
+
source: message.sender
|
|
2173
|
+
});
|
|
2174
|
+
const transcription = await this.transcribeIfAuto({
|
|
2175
|
+
companyId: params.companyId,
|
|
2176
|
+
message,
|
|
2177
|
+
uploadId,
|
|
2178
|
+
buffer,
|
|
2179
|
+
mimeType: mimeType || params.mimeType
|
|
2180
|
+
});
|
|
2181
|
+
return {
|
|
2182
|
+
uploadId,
|
|
2183
|
+
alreadyIngested: false,
|
|
2184
|
+
...transcription ? {
|
|
2185
|
+
transcription
|
|
2186
|
+
} : {}
|
|
2187
|
+
};
|
|
2188
|
+
}
|
|
2189
|
+
/**
|
|
2190
|
+
* Transcreve o áudio recém-baixado, quando o modo é `auto`.
|
|
2191
|
+
*
|
|
2192
|
+
* **Nunca propaga erro.** Neste ponto o binário já está no storage e já entrou na biblioteca da
|
|
2193
|
+
* conversa: deixar uma falha de transcrição subir marcaria a ingestão inteira como falha, e o
|
|
2194
|
+
* retry do host baixaria de novo da Meta um arquivo que está salvo — gastando banda para reproduzir
|
|
2195
|
+
* um efeito que já aconteceu. O status fica gravado na mensagem e o `onTranscriptionDeferred`
|
|
2196
|
+
* avisa quem sabe reenfileirar.
|
|
2197
|
+
*/
|
|
2198
|
+
async transcribeIfAuto(context) {
|
|
2199
|
+
const transcription = this.transcription;
|
|
2200
|
+
if (!transcription) return void 0;
|
|
2201
|
+
if (!isAudioMimeType(context.mimeType)) return void 0;
|
|
2202
|
+
const policy = await transcription.resolvePolicy(context.companyId);
|
|
2203
|
+
if (!policy.isEnabled || policy.mode !== TRANSCRIPTION_MODE.AUTO) return void 0;
|
|
2204
|
+
const current = await transcription.messageRepository.findById(context.companyId, context.message.id);
|
|
2205
|
+
if (current?.transcriptionStatus === TRANSCRIPTION_STATUS.DONE) return void 0;
|
|
2206
|
+
try {
|
|
2207
|
+
const result = await transcription.transcriber.transcribe({
|
|
2208
|
+
buffer: context.buffer,
|
|
2209
|
+
mimeType: context.mimeType,
|
|
2210
|
+
...transcription.languageHint ? {
|
|
2211
|
+
languageHint: transcription.languageHint
|
|
2212
|
+
} : {}
|
|
2213
|
+
});
|
|
2214
|
+
await transcription.messageRepository.saveTranscription({
|
|
2215
|
+
companyId: context.companyId,
|
|
2216
|
+
messageId: context.message.id,
|
|
2217
|
+
status: TRANSCRIPTION_STATUS.DONE,
|
|
2218
|
+
text: result.text,
|
|
2219
|
+
language: result.language ?? null,
|
|
2220
|
+
engine: result.engine
|
|
2221
|
+
});
|
|
2222
|
+
return {
|
|
2223
|
+
status: TRANSCRIPTION_STATUS.DONE
|
|
2224
|
+
};
|
|
2225
|
+
} catch (error) {
|
|
2226
|
+
return this.recordTranscriptionFailure(context, error);
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
async recordTranscriptionFailure(context, error) {
|
|
2230
|
+
const status = resolveFailureStatus(error);
|
|
2231
|
+
await this.transcription?.messageRepository.saveTranscription({
|
|
2232
|
+
companyId: context.companyId,
|
|
2233
|
+
messageId: context.message.id,
|
|
2234
|
+
status
|
|
2235
|
+
});
|
|
2236
|
+
if (status === TRANSCRIPTION_STATUS.PENDING) {
|
|
2237
|
+
const retryAfterSeconds = transcriptionRetryAfterSeconds(error);
|
|
2238
|
+
await this.transcription?.hooks?.onTranscriptionDeferred?.({
|
|
2239
|
+
companyId: context.companyId,
|
|
2240
|
+
messageId: context.message.id,
|
|
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
|
+
return {
|
|
2251
|
+
status
|
|
2252
|
+
};
|
|
2253
|
+
}
|
|
2254
|
+
};
|
|
2255
|
+
function extractMediaDescriptor(message) {
|
|
2256
|
+
const payload = message.payload ?? {};
|
|
2257
|
+
for (const key of [
|
|
2258
|
+
"image",
|
|
2259
|
+
"audio",
|
|
2260
|
+
"video",
|
|
2261
|
+
"document",
|
|
2262
|
+
"sticker"
|
|
2263
|
+
]) {
|
|
2264
|
+
const media = payload[key];
|
|
2265
|
+
if (media?.id) {
|
|
2266
|
+
return {
|
|
2267
|
+
sourceMediaId: media.id,
|
|
2268
|
+
mimeType: media.mime_type ?? "application/octet-stream",
|
|
2269
|
+
filename: media.filename
|
|
2270
|
+
};
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
return void 0;
|
|
2274
|
+
}
|
|
2275
|
+
__name(extractMediaDescriptor, "extractMediaDescriptor");
|
|
2276
|
+
|
|
1109
2277
|
// src/channel/ReceiveWebhook.use-case.ts
|
|
1110
2278
|
function toSessionContract(row) {
|
|
1111
2279
|
return {
|
|
@@ -1113,6 +2281,8 @@ function toSessionContract(row) {
|
|
|
1113
2281
|
companyId: row.companyId,
|
|
1114
2282
|
whatsappNumber: row.whatsappNumber,
|
|
1115
2283
|
currentState: row.currentState,
|
|
2284
|
+
flowKey: row.flowKey,
|
|
2285
|
+
currentNodeId: row.currentNodeId,
|
|
1116
2286
|
context: row.context,
|
|
1117
2287
|
mode: row.mode,
|
|
1118
2288
|
assignedUserId: row.assignedUserId,
|
|
@@ -1178,14 +2348,21 @@ var ReceiveWebhookUseCase = class {
|
|
|
1178
2348
|
if (!claimed) return {
|
|
1179
2349
|
duplicate: true,
|
|
1180
2350
|
messagesProcessed: 0,
|
|
1181
|
-
statusesProcessed: 0
|
|
2351
|
+
statusesProcessed: 0,
|
|
2352
|
+
ignoredForeignNumber: 0
|
|
1182
2353
|
};
|
|
1183
2354
|
const rawText = typeof input.rawBody === "string" ? input.rawBody : input.rawBody.toString("utf8");
|
|
1184
2355
|
const payload = whatsAppWebhookPayloadSchema.parse(JSON.parse(rawText));
|
|
1185
2356
|
let messagesProcessed = 0;
|
|
1186
2357
|
let statusesProcessed = 0;
|
|
2358
|
+
let ignoredForeignNumber = 0;
|
|
1187
2359
|
for (const entry of payload.entry) {
|
|
1188
2360
|
for (const change of entry.changes) {
|
|
2361
|
+
const targetNumber = change.value.metadata?.phone_number_id;
|
|
2362
|
+
if (targetNumber && targetNumber !== this.params.phoneNumberId) {
|
|
2363
|
+
ignoredForeignNumber++;
|
|
2364
|
+
continue;
|
|
2365
|
+
}
|
|
1189
2366
|
for (const message of change.value.messages ?? []) {
|
|
1190
2367
|
await this.handleMessage(input.companyId, message);
|
|
1191
2368
|
messagesProcessed++;
|
|
@@ -1199,7 +2376,8 @@ var ReceiveWebhookUseCase = class {
|
|
|
1199
2376
|
return {
|
|
1200
2377
|
duplicate: false,
|
|
1201
2378
|
messagesProcessed,
|
|
1202
|
-
statusesProcessed
|
|
2379
|
+
statusesProcessed,
|
|
2380
|
+
ignoredForeignNumber
|
|
1203
2381
|
};
|
|
1204
2382
|
}
|
|
1205
2383
|
async handleMessage(companyId, message) {
|
|
@@ -1216,6 +2394,19 @@ var ReceiveWebhookUseCase = class {
|
|
|
1216
2394
|
startState: this.params.startState
|
|
1217
2395
|
});
|
|
1218
2396
|
if (!saved) return;
|
|
2397
|
+
const media = extractMediaDescriptor(saved);
|
|
2398
|
+
if (media) {
|
|
2399
|
+
await this.params.hooks?.onMediaReceived?.({
|
|
2400
|
+
companyId,
|
|
2401
|
+
messageId: saved.id,
|
|
2402
|
+
whatsappNumber: message.from,
|
|
2403
|
+
sourceMediaId: media.sourceMediaId,
|
|
2404
|
+
mimeType: media.mimeType,
|
|
2405
|
+
...media.filename ? {
|
|
2406
|
+
filename: media.filename
|
|
2407
|
+
} : {}
|
|
2408
|
+
});
|
|
2409
|
+
}
|
|
1219
2410
|
const sessionRow = await this.params.sessionRepository.getContext(companyId, message.from);
|
|
1220
2411
|
if (!sessionRow) return;
|
|
1221
2412
|
if (sessionRow.mode === "human") return;
|
|
@@ -1235,76 +2426,50 @@ var ReceiveWebhookUseCase = class {
|
|
|
1235
2426
|
}
|
|
1236
2427
|
};
|
|
1237
2428
|
|
|
1238
|
-
// src/
|
|
1239
|
-
|
|
1240
|
-
|
|
2429
|
+
// src/use-cases/resolveTranscriptionPolicy.ts
|
|
2430
|
+
function createTranscriptionPolicyResolver(dependencies) {
|
|
2431
|
+
return /* @__PURE__ */ __name(async function resolveTranscriptionPolicy(companyId) {
|
|
2432
|
+
const settings2 = await dependencies.settingsRepository.get(companyId);
|
|
2433
|
+
return {
|
|
2434
|
+
// `??` e não `||`: `false` gravado é decisão explícita de desligar, e `||` a trocaria pelo
|
|
2435
|
+
// padrão do host — desligar no painel não faria nada num deploy com transcrição ligada.
|
|
2436
|
+
isEnabled: settings2.transcriptionEnabled ?? dependencies.defaults.isEnabled,
|
|
2437
|
+
mode: normalizeMode(settings2.transcriptionMode) ?? dependencies.defaults.mode
|
|
2438
|
+
};
|
|
2439
|
+
}, "resolveTranscriptionPolicy");
|
|
2440
|
+
}
|
|
2441
|
+
__name(createTranscriptionPolicyResolver, "createTranscriptionPolicyResolver");
|
|
2442
|
+
function normalizeMode(value) {
|
|
2443
|
+
if (value === TRANSCRIPTION_MODE.AUTO) return TRANSCRIPTION_MODE.AUTO;
|
|
2444
|
+
if (value === TRANSCRIPTION_MODE.ON_DEMAND) return TRANSCRIPTION_MODE.ON_DEMAND;
|
|
2445
|
+
return void 0;
|
|
2446
|
+
}
|
|
2447
|
+
__name(normalizeMode, "normalizeMode");
|
|
2448
|
+
|
|
2449
|
+
// src/use-cases/StorePreviewMedia.use-case.ts
|
|
2450
|
+
var StorePreviewMediaUseCase = class {
|
|
1241
2451
|
static {
|
|
1242
|
-
__name(this, "
|
|
2452
|
+
__name(this, "StorePreviewMediaUseCase");
|
|
1243
2453
|
}
|
|
1244
|
-
db;
|
|
1245
|
-
channel;
|
|
1246
2454
|
objectStorage;
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
this.channel = channel;
|
|
2455
|
+
generateKeySuffix;
|
|
2456
|
+
constructor(objectStorage, generateKeySuffix) {
|
|
1250
2457
|
this.objectStorage = objectStorage;
|
|
2458
|
+
this.generateKeySuffix = generateKeySuffix;
|
|
1251
2459
|
}
|
|
1252
2460
|
async execute(params) {
|
|
1253
|
-
const
|
|
1254
|
-
if (!message) throw new Error(`Mensagem ${params.messageId} n\xE3o encontrada para ingest\xE3o de m\xEDdia`);
|
|
1255
|
-
const payload = message.payload ?? {};
|
|
1256
|
-
if (payload["uploadId"] && payload["sourceMediaId"] === params.sourceMediaId) {
|
|
1257
|
-
return {
|
|
1258
|
-
uploadId: String(payload["uploadId"]),
|
|
1259
|
-
alreadyIngested: true
|
|
1260
|
-
};
|
|
1261
|
-
}
|
|
1262
|
-
const { data, mimeType } = await this.channel.fetchMediaAsBase64(params.sourceMediaId);
|
|
1263
|
-
const buffer = Buffer.from(data, "base64");
|
|
2461
|
+
const key = `meta-whatsapp/${params.companyId}/preview/${this.generateKeySuffix()}`;
|
|
1264
2462
|
const { uploadId } = await this.objectStorage.upload({
|
|
1265
|
-
buffer,
|
|
1266
|
-
mimeType:
|
|
1267
|
-
key
|
|
2463
|
+
buffer: params.buffer,
|
|
2464
|
+
mimeType: params.mimeType,
|
|
2465
|
+
key
|
|
1268
2466
|
});
|
|
1269
|
-
const updatedPayload = {
|
|
1270
|
-
...payload,
|
|
1271
|
-
uploadId,
|
|
1272
|
-
sourceMediaId: params.sourceMediaId,
|
|
1273
|
-
mimeType: mimeType || params.mimeType,
|
|
1274
|
-
...params.filename ? {
|
|
1275
|
-
filename: params.filename
|
|
1276
|
-
} : {}
|
|
1277
|
-
};
|
|
1278
|
-
await this.db.update(messages).set({
|
|
1279
|
-
payload: updatedPayload
|
|
1280
|
-
}).where(and4(eq5(messages.companyId, params.companyId), eq5(messages.id, params.messageId)));
|
|
1281
2467
|
return {
|
|
1282
|
-
uploadId,
|
|
1283
|
-
|
|
2468
|
+
mediaId: toPreviewMediaId(uploadId),
|
|
2469
|
+
uploadId
|
|
1284
2470
|
};
|
|
1285
2471
|
}
|
|
1286
2472
|
};
|
|
1287
|
-
function extractMediaDescriptor(message) {
|
|
1288
|
-
const payload = message.payload ?? {};
|
|
1289
|
-
for (const key of [
|
|
1290
|
-
"image",
|
|
1291
|
-
"audio",
|
|
1292
|
-
"video",
|
|
1293
|
-
"document",
|
|
1294
|
-
"sticker"
|
|
1295
|
-
]) {
|
|
1296
|
-
const media = payload[key];
|
|
1297
|
-
if (media?.id) {
|
|
1298
|
-
return {
|
|
1299
|
-
sourceMediaId: media.id,
|
|
1300
|
-
mimeType: media.mime_type ?? "application/octet-stream",
|
|
1301
|
-
filename: media.filename
|
|
1302
|
-
};
|
|
1303
|
-
}
|
|
1304
|
-
}
|
|
1305
|
-
return void 0;
|
|
1306
|
-
}
|
|
1307
|
-
__name(extractMediaDescriptor, "extractMediaDescriptor");
|
|
1308
2473
|
|
|
1309
2474
|
// src/createMetaWhatsAppModule.ts
|
|
1310
2475
|
function createMetaWhatsAppModule(params) {
|
|
@@ -1318,15 +2483,24 @@ function createMetaWhatsAppModule(params) {
|
|
|
1318
2483
|
apiVersion: config.apiVersion,
|
|
1319
2484
|
baseUrl: config.baseUrl
|
|
1320
2485
|
});
|
|
1321
|
-
const
|
|
2486
|
+
const previewMediaSupport = params.features?.previewMedia && providers.objectStorage?.getObject ? {
|
|
2487
|
+
isEnabled: true,
|
|
2488
|
+
objectStorage: providers.objectStorage
|
|
2489
|
+
} : void 0;
|
|
2490
|
+
const channel = new WhatsAppChannelAdapter(messageProvider, previewMediaSupport);
|
|
1322
2491
|
const sessionRepository = new SessionRepository(db);
|
|
1323
2492
|
const messageRepository = new MessageRepository(db);
|
|
1324
2493
|
const settingsRepository = new SettingsRepository(db);
|
|
1325
|
-
const
|
|
1326
|
-
const
|
|
1327
|
-
const
|
|
2494
|
+
const flowGraphCacheFeature = params.features?.flowGraphCache ?? false;
|
|
2495
|
+
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;
|
|
2496
|
+
const flowGraphRepository = new FlowGraphRepository(db, flowGraphCache);
|
|
2497
|
+
const documentRepository = new DocumentRepository(db);
|
|
2498
|
+
const flowMediaRepository = new FlowMediaRepository(db);
|
|
2499
|
+
const logMessage = new LogMessageUseCase(sessionRepository, messageRepository, providers.realtime, providers.moderator);
|
|
2500
|
+
const sendMessage = new SendMessageUseCase(channel, sessionRepository, logMessage, providers.objectStorage, documentRepository);
|
|
1328
2501
|
const receiveWebhook = new ReceiveWebhookUseCase({
|
|
1329
2502
|
appSecret: config.appSecret,
|
|
2503
|
+
phoneNumberId: config.phoneNumberId,
|
|
1330
2504
|
nonceStore,
|
|
1331
2505
|
sessionRepository,
|
|
1332
2506
|
messageRepository,
|
|
@@ -1336,7 +2510,49 @@ function createMetaWhatsAppModule(params) {
|
|
|
1336
2510
|
realtime: providers.realtime
|
|
1337
2511
|
});
|
|
1338
2512
|
const flowInterpreter = flowEngineEnabled ? new FlowInterpreter() : void 0;
|
|
1339
|
-
|
|
2513
|
+
if (flowInterpreter && providers.objectStorage?.getObject) {
|
|
2514
|
+
flowInterpreter.registerFlowAction(FLOW_ACTION_KIND.SEND_MEDIA, createSendMediaAction({
|
|
2515
|
+
flowMediaRepository,
|
|
2516
|
+
objectStorage: providers.objectStorage,
|
|
2517
|
+
logMessage,
|
|
2518
|
+
startState,
|
|
2519
|
+
onError: hooks?.onFlowMediaError
|
|
2520
|
+
}));
|
|
2521
|
+
}
|
|
2522
|
+
const transcriptionMode = providers.transcription?.mode ?? TRANSCRIPTION_MODE.ON_DEMAND;
|
|
2523
|
+
const resolveTranscriptionPolicy = providers.transcription ? createTranscriptionPolicyResolver({
|
|
2524
|
+
settingsRepository,
|
|
2525
|
+
defaults: {
|
|
2526
|
+
isEnabled: providers.transcription.isEnabledByDefault ?? true,
|
|
2527
|
+
mode: transcriptionMode
|
|
2528
|
+
}
|
|
2529
|
+
}) : void 0;
|
|
2530
|
+
const ingestInboundMedia = providers.objectStorage ? new IngestInboundMediaUseCase(db, channel, providers.objectStorage, documentRepository, providers.transcription && resolveTranscriptionPolicy ? {
|
|
2531
|
+
transcriber: providers.transcription.transcriber,
|
|
2532
|
+
resolvePolicy: resolveTranscriptionPolicy,
|
|
2533
|
+
messageRepository,
|
|
2534
|
+
...providers.transcription.languageHint ? {
|
|
2535
|
+
languageHint: providers.transcription.languageHint
|
|
2536
|
+
} : {},
|
|
2537
|
+
...hooks ? {
|
|
2538
|
+
hooks
|
|
2539
|
+
} : {}
|
|
2540
|
+
} : void 0) : void 0;
|
|
2541
|
+
const transcribeAudio = providers.transcription && providers.objectStorage?.getObject ? new TranscribeAudioUseCase({
|
|
2542
|
+
messageRepository,
|
|
2543
|
+
objectStorage: providers.objectStorage,
|
|
2544
|
+
transcriber: providers.transcription.transcriber,
|
|
2545
|
+
...resolveTranscriptionPolicy ? {
|
|
2546
|
+
resolvePolicy: resolveTranscriptionPolicy
|
|
2547
|
+
} : {},
|
|
2548
|
+
...providers.transcription.languageHint ? {
|
|
2549
|
+
languageHint: providers.transcription.languageHint
|
|
2550
|
+
} : {},
|
|
2551
|
+
...hooks ? {
|
|
2552
|
+
hooks
|
|
2553
|
+
} : {}
|
|
2554
|
+
}) : void 0;
|
|
2555
|
+
const listDocuments = new ListConversationDocumentsUseCase(sessionRepository, documentRepository);
|
|
1340
2556
|
return {
|
|
1341
2557
|
channel,
|
|
1342
2558
|
// undefined quando providers.objectStorage não foi injetado.
|
|
@@ -1348,10 +2564,36 @@ function createMetaWhatsAppModule(params) {
|
|
|
1348
2564
|
release: new ReleaseConversationUseCase(sessionRepository, providers.realtime),
|
|
1349
2565
|
list: new ListConversationsUseCase(sessionRepository),
|
|
1350
2566
|
listMessages: new ListMessagesUseCase(sessionRepository, messageRepository),
|
|
2567
|
+
listDocuments,
|
|
2568
|
+
// Biblioteca da empresa inteira, para uma tela de Documentos fora da conversa.
|
|
2569
|
+
listCompanyDocuments: new ListCompanyDocumentsUseCase(documentRepository),
|
|
2570
|
+
// Apaga a mídia no storage antes das linhas — a cascata da FK sozinha deixaria os binários
|
|
2571
|
+
// órfãos, já que a lista de uploadId vive justamente nas linhas que ela derruba.
|
|
2572
|
+
delete: new DeleteConversationUseCase(sessionRepository, documentRepository, providers.objectStorage),
|
|
2573
|
+
purgeExpiredDocuments: new PurgeExpiredDocumentsUseCase(documentRepository, providers.objectStorage),
|
|
1351
2574
|
export: new ExportConversationUseCase(sessionRepository),
|
|
1352
|
-
|
|
2575
|
+
// undefined quando transcrição não foi injetada, ou quando o storage não sabe ler de volta.
|
|
2576
|
+
// O painel consulta a ausência para decidir se desenha o botão "transcrever".
|
|
2577
|
+
transcribeAudio,
|
|
2578
|
+
repository: sessionRepository,
|
|
2579
|
+
messageRepository,
|
|
2580
|
+
documentRepository
|
|
1353
2581
|
},
|
|
2582
|
+
/**
|
|
2583
|
+
* `undefined` = o host não injetou transcritor, e nenhuma configuração de empresa muda isso: a
|
|
2584
|
+
* capacidade não existe. Presente, `resolvePolicy` responde o que vale para uma empresa —
|
|
2585
|
+
* é o que a rota de configurações usa para dizer ao painel se desenha o interruptor.
|
|
2586
|
+
*/
|
|
2587
|
+
transcription: providers.transcription && resolveTranscriptionPolicy ? {
|
|
2588
|
+
defaultMode: transcriptionMode,
|
|
2589
|
+
resolvePolicy: resolveTranscriptionPolicy
|
|
2590
|
+
} : void 0,
|
|
1354
2591
|
settings: settingsRepository,
|
|
2592
|
+
/**
|
|
2593
|
+
* `undefined` quando o recurso não está ligado (ou falta storage legível). O host consulta a
|
|
2594
|
+
* ausência para não registrar a rota de upload — e o preview, sem a rota, esconde o microfone.
|
|
2595
|
+
*/
|
|
2596
|
+
previewMedia: previewMediaSupport ? new StorePreviewMediaUseCase(providers.objectStorage, () => `${Date.now()}-${Math.random().toString(36).slice(2)}`) : void 0,
|
|
1355
2597
|
webhook: {
|
|
1356
2598
|
receive: receiveWebhook,
|
|
1357
2599
|
// GET de verificação da Meta — o host liga na sua rota e devolve o retorno como texto puro.
|
|
@@ -1370,7 +2612,11 @@ function createMetaWhatsAppModule(params) {
|
|
|
1370
2612
|
save: new SaveFlowGraphUseCase(flowGraphRepository),
|
|
1371
2613
|
delete: new DeleteFlowGraphUseCase(flowGraphRepository),
|
|
1372
2614
|
livePositions: new GetLiveFlowPositionsUseCase(flowGraphRepository),
|
|
1373
|
-
repository: flowGraphRepository
|
|
2615
|
+
repository: flowGraphRepository,
|
|
2616
|
+
// Biblioteca de mídia dos nós `send_media` — o host liga nas rotas do editor
|
|
2617
|
+
// (anexar/reordenar/desligar). Existe mesmo sem storage injetado: gerenciar anexos é
|
|
2618
|
+
// consultar a tabela, e só o ENVIO precisa dos bytes.
|
|
2619
|
+
mediaRepository: flowMediaRepository
|
|
1374
2620
|
} : void 0,
|
|
1375
2621
|
catalog: providers.catalog
|
|
1376
2622
|
};
|
|
@@ -1379,11 +2625,15 @@ __name(createMetaWhatsAppModule, "createMetaWhatsAppModule");
|
|
|
1379
2625
|
|
|
1380
2626
|
// src/runMigrations.ts
|
|
1381
2627
|
import { join } from "path";
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
2628
|
+
var META_WHATSAPP_MIGRATIONS_TABLE = "meta_whatsapp_migrations";
|
|
2629
|
+
function metaWhatsAppMigrationsFolder() {
|
|
2630
|
+
return join(__dirname, "migrations");
|
|
2631
|
+
}
|
|
2632
|
+
__name(metaWhatsAppMigrationsFolder, "metaWhatsAppMigrationsFolder");
|
|
2633
|
+
async function runMetaWhatsAppMigrations(params) {
|
|
2634
|
+
await params.migrate(params.db, {
|
|
2635
|
+
migrationsFolder: metaWhatsAppMigrationsFolder(),
|
|
2636
|
+
migrationsTable: META_WHATSAPP_MIGRATIONS_TABLE
|
|
1387
2637
|
});
|
|
1388
2638
|
}
|
|
1389
2639
|
__name(runMetaWhatsAppMigrations, "runMetaWhatsAppMigrations");
|
|
@@ -1465,20 +2715,30 @@ async function redeemSseTicket(store, ticket) {
|
|
|
1465
2715
|
__name(redeemSseTicket, "redeemSseTicket");
|
|
1466
2716
|
export {
|
|
1467
2717
|
CreateFlowGraphUseCase,
|
|
2718
|
+
DEFAULT_FLOW_GRAPH_CACHE_TTL_SECONDS,
|
|
2719
|
+
DeleteConversationUseCase,
|
|
1468
2720
|
DeleteFlowGraphUseCase,
|
|
2721
|
+
DocumentRepository,
|
|
1469
2722
|
ExportConversationUseCase,
|
|
2723
|
+
FlowGraphCache,
|
|
1470
2724
|
FlowGraphRepository,
|
|
1471
2725
|
FlowInterpreter,
|
|
2726
|
+
FlowMediaRepository,
|
|
1472
2727
|
GetFlowGraphUseCase,
|
|
1473
2728
|
GetLiveFlowPositionsUseCase,
|
|
1474
2729
|
IngestInboundMediaUseCase,
|
|
1475
2730
|
InvalidFlowGraphError,
|
|
2731
|
+
ListCompanyDocumentsUseCase,
|
|
2732
|
+
ListConversationDocumentsUseCase,
|
|
1476
2733
|
ListConversationsUseCase,
|
|
1477
2734
|
ListFlowGraphsUseCase,
|
|
1478
2735
|
ListMessagesUseCase,
|
|
1479
2736
|
LogMessageUseCase,
|
|
2737
|
+
META_WHATSAPP_MIGRATIONS_TABLE,
|
|
1480
2738
|
MessageRepository,
|
|
1481
2739
|
OptimisticLockError,
|
|
2740
|
+
PREVIEW_MEDIA_ID_PREFIX,
|
|
2741
|
+
PurgeExpiredDocumentsUseCase,
|
|
1482
2742
|
ReceiveWebhookUseCase,
|
|
1483
2743
|
ReleaseConversationUseCase,
|
|
1484
2744
|
SaveFlowGraphUseCase,
|
|
@@ -1486,20 +2746,36 @@ export {
|
|
|
1486
2746
|
SessionRepository,
|
|
1487
2747
|
SettingsRepository,
|
|
1488
2748
|
SseHub,
|
|
2749
|
+
StorePreviewMediaUseCase,
|
|
2750
|
+
TRANSCRIPTION_MODE,
|
|
2751
|
+
TRANSCRIPTION_STATUS,
|
|
1489
2752
|
TakeoverConversationUseCase,
|
|
2753
|
+
TranscribeAudioUseCase,
|
|
1490
2754
|
WEBHOOK_NONCE_TTL_SECONDS,
|
|
1491
2755
|
WhatsAppChannelAdapter,
|
|
1492
2756
|
claimWebhookDelivery,
|
|
1493
2757
|
createMetaWhatsAppModule,
|
|
2758
|
+
createSendMediaAction,
|
|
2759
|
+
createTranscriptionPolicyResolver,
|
|
2760
|
+
documents,
|
|
1494
2761
|
extractMediaDescriptor,
|
|
1495
2762
|
flowGraphs,
|
|
2763
|
+
flowMedia,
|
|
2764
|
+
isAudioMimeType,
|
|
2765
|
+
isRetriableTranscriptionError,
|
|
2766
|
+
isUnsupportedTranscriptionError,
|
|
1496
2767
|
issueSseTicket,
|
|
1497
2768
|
messages,
|
|
2769
|
+
metaWhatsAppMigrationsFolder,
|
|
1498
2770
|
metaWhatsAppSchema,
|
|
1499
2771
|
redeemSseTicket,
|
|
2772
|
+
resolveFailureStatus,
|
|
2773
|
+
resolvePreviewUploadId,
|
|
1500
2774
|
runMetaWhatsAppMigrations,
|
|
1501
2775
|
sessions,
|
|
1502
2776
|
settings,
|
|
2777
|
+
toPreviewMediaId,
|
|
2778
|
+
transcriptionRetryAfterSeconds,
|
|
1503
2779
|
verifyWebhookChallenge,
|
|
1504
2780
|
verifyWebhookSignature
|
|
1505
2781
|
};
|