@adatechnology/meta-whatsapp-module 0.2.0-rc.2 → 0.2.0-rc.20
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 +1398 -106
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1762 -234
- package/dist/index.d.ts +1762 -234
- package/dist/index.js +1358 -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.cjs
CHANGED
|
@@ -8,11 +8,11 @@ var __export = (target, all) => {
|
|
|
8
8
|
for (var name in all)
|
|
9
9
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
10
|
};
|
|
11
|
-
var __copyProps = (to, from, except,
|
|
11
|
+
var __copyProps = (to, from, except, desc3) => {
|
|
12
12
|
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
13
|
for (let key of __getOwnPropNames(from))
|
|
14
14
|
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
15
|
-
__defProp(to, key, { get: () => from[key], enumerable: !(
|
|
15
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc3 = __getOwnPropDesc(from, key)) || desc3.enumerable });
|
|
16
16
|
}
|
|
17
17
|
return to;
|
|
18
18
|
};
|
|
@@ -22,20 +22,30 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
22
22
|
var index_exports = {};
|
|
23
23
|
__export(index_exports, {
|
|
24
24
|
CreateFlowGraphUseCase: () => CreateFlowGraphUseCase,
|
|
25
|
+
DEFAULT_FLOW_GRAPH_CACHE_TTL_SECONDS: () => DEFAULT_FLOW_GRAPH_CACHE_TTL_SECONDS,
|
|
26
|
+
DeleteConversationUseCase: () => DeleteConversationUseCase,
|
|
25
27
|
DeleteFlowGraphUseCase: () => DeleteFlowGraphUseCase,
|
|
28
|
+
DocumentRepository: () => DocumentRepository,
|
|
26
29
|
ExportConversationUseCase: () => ExportConversationUseCase,
|
|
30
|
+
FlowGraphCache: () => FlowGraphCache,
|
|
27
31
|
FlowGraphRepository: () => FlowGraphRepository,
|
|
28
32
|
FlowInterpreter: () => FlowInterpreter,
|
|
33
|
+
FlowMediaRepository: () => FlowMediaRepository,
|
|
29
34
|
GetFlowGraphUseCase: () => GetFlowGraphUseCase,
|
|
30
35
|
GetLiveFlowPositionsUseCase: () => GetLiveFlowPositionsUseCase,
|
|
31
36
|
IngestInboundMediaUseCase: () => IngestInboundMediaUseCase,
|
|
32
37
|
InvalidFlowGraphError: () => InvalidFlowGraphError,
|
|
38
|
+
ListCompanyDocumentsUseCase: () => ListCompanyDocumentsUseCase,
|
|
39
|
+
ListConversationDocumentsUseCase: () => ListConversationDocumentsUseCase,
|
|
33
40
|
ListConversationsUseCase: () => ListConversationsUseCase,
|
|
34
41
|
ListFlowGraphsUseCase: () => ListFlowGraphsUseCase,
|
|
35
42
|
ListMessagesUseCase: () => ListMessagesUseCase,
|
|
36
43
|
LogMessageUseCase: () => LogMessageUseCase,
|
|
44
|
+
META_WHATSAPP_MIGRATIONS_TABLE: () => META_WHATSAPP_MIGRATIONS_TABLE,
|
|
37
45
|
MessageRepository: () => MessageRepository,
|
|
38
46
|
OptimisticLockError: () => OptimisticLockError,
|
|
47
|
+
PREVIEW_MEDIA_ID_PREFIX: () => import_meta_whatsapp_contracts8.PREVIEW_MEDIA_ID_PREFIX,
|
|
48
|
+
PurgeExpiredDocumentsUseCase: () => PurgeExpiredDocumentsUseCase,
|
|
39
49
|
ReceiveWebhookUseCase: () => ReceiveWebhookUseCase,
|
|
40
50
|
ReleaseConversationUseCase: () => ReleaseConversationUseCase,
|
|
41
51
|
SaveFlowGraphUseCase: () => SaveFlowGraphUseCase,
|
|
@@ -43,20 +53,36 @@ __export(index_exports, {
|
|
|
43
53
|
SessionRepository: () => SessionRepository,
|
|
44
54
|
SettingsRepository: () => SettingsRepository,
|
|
45
55
|
SseHub: () => SseHub,
|
|
56
|
+
StorePreviewMediaUseCase: () => StorePreviewMediaUseCase,
|
|
57
|
+
TRANSCRIPTION_MODE: () => TRANSCRIPTION_MODE,
|
|
58
|
+
TRANSCRIPTION_STATUS: () => TRANSCRIPTION_STATUS,
|
|
46
59
|
TakeoverConversationUseCase: () => TakeoverConversationUseCase,
|
|
60
|
+
TranscribeAudioUseCase: () => TranscribeAudioUseCase,
|
|
47
61
|
WEBHOOK_NONCE_TTL_SECONDS: () => WEBHOOK_NONCE_TTL_SECONDS,
|
|
48
62
|
WhatsAppChannelAdapter: () => WhatsAppChannelAdapter,
|
|
49
63
|
claimWebhookDelivery: () => claimWebhookDelivery,
|
|
50
64
|
createMetaWhatsAppModule: () => createMetaWhatsAppModule,
|
|
65
|
+
createSendMediaAction: () => createSendMediaAction,
|
|
66
|
+
createTranscriptionPolicyResolver: () => createTranscriptionPolicyResolver,
|
|
67
|
+
documents: () => documents,
|
|
51
68
|
extractMediaDescriptor: () => extractMediaDescriptor,
|
|
52
69
|
flowGraphs: () => flowGraphs,
|
|
70
|
+
flowMedia: () => flowMedia,
|
|
71
|
+
isAudioMimeType: () => isAudioMimeType,
|
|
72
|
+
isRetriableTranscriptionError: () => isRetriableTranscriptionError,
|
|
73
|
+
isUnsupportedTranscriptionError: () => isUnsupportedTranscriptionError,
|
|
53
74
|
issueSseTicket: () => issueSseTicket,
|
|
54
75
|
messages: () => messages,
|
|
76
|
+
metaWhatsAppMigrationsFolder: () => metaWhatsAppMigrationsFolder,
|
|
55
77
|
metaWhatsAppSchema: () => metaWhatsAppSchema,
|
|
56
78
|
redeemSseTicket: () => redeemSseTicket,
|
|
79
|
+
resolveFailureStatus: () => resolveFailureStatus,
|
|
80
|
+
resolvePreviewUploadId: () => import_meta_whatsapp_contracts8.resolvePreviewUploadId,
|
|
57
81
|
runMetaWhatsAppMigrations: () => runMetaWhatsAppMigrations,
|
|
58
82
|
sessions: () => sessions,
|
|
59
83
|
settings: () => settings,
|
|
84
|
+
toPreviewMediaId: () => import_meta_whatsapp_contracts8.toPreviewMediaId,
|
|
85
|
+
transcriptionRetryAfterSeconds: () => transcriptionRetryAfterSeconds,
|
|
60
86
|
verifyWebhookChallenge: () => verifyWebhookChallenge,
|
|
61
87
|
verifyWebhookSignature: () => verifyWebhookSignature
|
|
62
88
|
});
|
|
@@ -64,6 +90,7 @@ module.exports = __toCommonJS(index_exports);
|
|
|
64
90
|
|
|
65
91
|
// src/createMetaWhatsAppModule.ts
|
|
66
92
|
var import_meta_whatsapp_provider = require("@adatechnology/meta-whatsapp-provider");
|
|
93
|
+
var import_meta_whatsapp_contracts13 = require("@adatechnology/meta-whatsapp-contracts");
|
|
67
94
|
|
|
68
95
|
// src/repositories/SessionRepository.ts
|
|
69
96
|
var import_drizzle_orm2 = require("drizzle-orm");
|
|
@@ -139,9 +166,14 @@ var messages = metaWhatsAppSchema.table("messages", {
|
|
|
139
166
|
length: 12
|
|
140
167
|
}).notNull(),
|
|
141
168
|
agentUserId: (0, import_pg_core.uuid)("agent_user_id"),
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
169
|
+
// 32, não 16: os tipos da própria Meta são curtos ('text', 'interactive'), mas o host rotula
|
|
170
|
+
type: (
|
|
171
|
+
// a saída com o subtipo que enviou ('interactive_buttons' já tem 19). Apertar aqui só
|
|
172
|
+
// transfere para o consumidor a escolha entre truncar o rótulo e estourar o insert.
|
|
173
|
+
(0, import_pg_core.varchar)("type", {
|
|
174
|
+
length: 32
|
|
175
|
+
}).notNull().default("text")
|
|
176
|
+
),
|
|
145
177
|
content: (0, import_pg_core.text)("content"),
|
|
146
178
|
// T5.4 — nunca base64 aqui; mídia vive no ObjectStorageInterface do host, referenciada por
|
|
147
179
|
// uploadId dentro deste jsonb.
|
|
@@ -155,6 +187,33 @@ var messages = metaWhatsAppSchema.table("messages", {
|
|
|
155
187
|
readAt: (0, import_pg_core.timestamp)("read_at", {
|
|
156
188
|
withTimezone: true
|
|
157
189
|
}),
|
|
190
|
+
// Moderação de conteúdo. `null` significa NÃO AVALIADO (moderação desligada, ou mensagem
|
|
191
|
+
// anterior ao recurso) — diferente de `false`, que é avaliado e limpo. Colunas em vez de chave
|
|
192
|
+
// dentro de `payload` porque "listar o que foi sinalizado" é consulta de operação, e índice
|
|
193
|
+
// parcial sobre boolean resolve isso sem cavar jsonb.
|
|
194
|
+
moderationFlagged: (0, import_pg_core.boolean)("moderation_flagged"),
|
|
195
|
+
moderationTerms: (0, import_pg_core.jsonb)("moderation_terms").$type(),
|
|
196
|
+
/**
|
|
197
|
+
* Transcrição de áudio. `null` em `transcription_status` significa NÃO AVALIADO — áudio nunca
|
|
198
|
+
* pedido (modo sob demanda), transcrição desligada, mensagem anterior ao recurso, ou mensagem
|
|
199
|
+
* que não é áudio. Diferente de `'done'` com texto vazio, que é áudio em silêncio já processado
|
|
200
|
+
* e que NÃO deve ser reprocessado.
|
|
201
|
+
*
|
|
202
|
+
* Colunas em vez de chave em `payload` pelo mesmo motivo da moderação: "quais áudios ficaram
|
|
203
|
+
* pendentes" e "quais falharam" são consultas de operação, e índice parcial resolve sem cavar
|
|
204
|
+
* jsonb. Buscar texto de áudio também deixa de exigir varredura de payload.
|
|
205
|
+
*/
|
|
206
|
+
transcriptionStatus: (0, import_pg_core.varchar)("transcription_status", {
|
|
207
|
+
length: 16
|
|
208
|
+
}).$type(),
|
|
209
|
+
transcriptionText: (0, import_pg_core.text)("transcription_text"),
|
|
210
|
+
transcriptionLanguage: (0, import_pg_core.varchar)("transcription_language", {
|
|
211
|
+
length: 32
|
|
212
|
+
}),
|
|
213
|
+
/** Qual engine produziu. Com uma cadeia de engines, é o que responde "por que esta saiu ruim". */
|
|
214
|
+
transcriptionEngine: (0, import_pg_core.varchar)("transcription_engine", {
|
|
215
|
+
length: 32
|
|
216
|
+
}),
|
|
158
217
|
createdAt: (0, import_pg_core.timestamp)("created_at", {
|
|
159
218
|
withTimezone: true
|
|
160
219
|
}).notNull().defaultNow()
|
|
@@ -166,7 +225,54 @@ var messages = metaWhatsAppSchema.table("messages", {
|
|
|
166
225
|
// paralelo, então as duas passariam pela checagem e inseririam duplicado. Parcial porque
|
|
167
226
|
// mensagens outbound ainda sem waMessageId (envio em curso) são legitimamente NULL, e NULLs
|
|
168
227
|
// não podem competir entre si por unicidade.
|
|
169
|
-
(0, import_pg_core.uniqueIndex)("idx_messages_company_wa_message_id").on(table.companyId, table.waMessageId).where(import_drizzle_orm.sql`${table.waMessageId} is not null`)
|
|
228
|
+
(0, import_pg_core.uniqueIndex)("idx_messages_company_wa_message_id").on(table.companyId, table.waMessageId).where(import_drizzle_orm.sql`${table.waMessageId} is not null`),
|
|
229
|
+
// Parcial: só as sinalizadas entram, então o índice fica do tamanho do problema e não do
|
|
230
|
+
// tamanho do transcript.
|
|
231
|
+
(0, import_pg_core.index)("idx_messages_moderation_flagged").on(table.companyId, table.createdAt).where(import_drizzle_orm.sql`${table.moderationFlagged}`),
|
|
232
|
+
// Alimenta a varredura de retomada: "quais áudios ficaram pendentes de transcrição". Parcial
|
|
233
|
+
// porque pendente é estado transitório e raro — o índice fica do tamanho da fila atrasada, não
|
|
234
|
+
// do transcript inteiro.
|
|
235
|
+
(0, import_pg_core.index)("idx_messages_transcription_pending").on(table.companyId, table.createdAt).where(import_drizzle_orm.sql`${table.transcriptionStatus} = 'pending'`)
|
|
236
|
+
]);
|
|
237
|
+
var documents = metaWhatsAppSchema.table("documents", {
|
|
238
|
+
id: (0, import_pg_core.uuid)("id").primaryKey().defaultRandom(),
|
|
239
|
+
companyId: (0, import_pg_core.uuid)("company_id").notNull(),
|
|
240
|
+
sessionId: (0, import_pg_core.uuid)("session_id").notNull().references(() => sessions.id, {
|
|
241
|
+
onDelete: "cascade"
|
|
242
|
+
}),
|
|
243
|
+
// Anulável e `set null`: apagar a mensagem não deve apagar o arquivo da biblioteca, e arquivo
|
|
244
|
+
// do atendente nasce sem mensagem.
|
|
245
|
+
messageId: (0, import_pg_core.uuid)("message_id").references(() => messages.id, {
|
|
246
|
+
onDelete: "set null"
|
|
247
|
+
}),
|
|
248
|
+
uploadId: (0, import_pg_core.varchar)("upload_id", {
|
|
249
|
+
length: 256
|
|
250
|
+
}).notNull(),
|
|
251
|
+
filename: (0, import_pg_core.varchar)("filename", {
|
|
252
|
+
length: 512
|
|
253
|
+
}).notNull(),
|
|
254
|
+
mimeType: (0, import_pg_core.varchar)("mime_type", {
|
|
255
|
+
length: 128
|
|
256
|
+
}).notNull(),
|
|
257
|
+
sizeBytes: (0, import_pg_core.integer)("size_bytes").notNull(),
|
|
258
|
+
// Do provider de storage, que é endereçado por conteúdo — serve para reconciliar storage
|
|
259
|
+
// contra tabela e para detectar o mesmo binário chegando duas vezes.
|
|
260
|
+
sha256: (0, import_pg_core.varchar)("sha256", {
|
|
261
|
+
length: 64
|
|
262
|
+
}),
|
|
263
|
+
source: (0, import_pg_core.varchar)("source", {
|
|
264
|
+
length: 12
|
|
265
|
+
}).notNull(),
|
|
266
|
+
linkedAt: (0, import_pg_core.timestamp)("linked_at", {
|
|
267
|
+
withTimezone: true
|
|
268
|
+
}).notNull().defaultNow()
|
|
269
|
+
}, (table) => [
|
|
270
|
+
(0, import_pg_core.index)("idx_documents_session_linked").on(table.sessionId, table.linkedAt),
|
|
271
|
+
// Para varredura de retenção por idade sem passar por sessão.
|
|
272
|
+
(0, import_pg_core.index)("idx_documents_company_linked").on(table.companyId, table.linkedAt),
|
|
273
|
+
// O mesmo objeto não pode ser linkado duas vezes na mesma empresa: o job de ingestão é
|
|
274
|
+
// reentregue por retry, e sem isto a reentrega criaria linha duplicada no painel.
|
|
275
|
+
(0, import_pg_core.uniqueIndex)("idx_documents_company_upload").on(table.companyId, table.uploadId)
|
|
170
276
|
]);
|
|
171
277
|
var flowGraphs = metaWhatsAppSchema.table("flow_graphs", {
|
|
172
278
|
id: (0, import_pg_core.uuid)("id").primaryKey().defaultRandom(),
|
|
@@ -197,6 +303,46 @@ var flowGraphs = metaWhatsAppSchema.table("flow_graphs", {
|
|
|
197
303
|
}, (table) => [
|
|
198
304
|
(0, import_pg_core.uniqueIndex)("idx_flow_graphs_company_key").on(table.companyId, table.key)
|
|
199
305
|
]);
|
|
306
|
+
var flowMedia = metaWhatsAppSchema.table("flow_media", {
|
|
307
|
+
id: (0, import_pg_core.uuid)("id").primaryKey().defaultRandom(),
|
|
308
|
+
companyId: (0, import_pg_core.uuid)("company_id").notNull(),
|
|
309
|
+
flowKey: (0, import_pg_core.varchar)("flow_key", {
|
|
310
|
+
length: 64
|
|
311
|
+
}).notNull(),
|
|
312
|
+
nodeId: (0, import_pg_core.varchar)("node_id", {
|
|
313
|
+
length: 64
|
|
314
|
+
}).notNull(),
|
|
315
|
+
uploadId: (0, import_pg_core.varchar)("upload_id", {
|
|
316
|
+
length: 256
|
|
317
|
+
}).notNull(),
|
|
318
|
+
filename: (0, import_pg_core.varchar)("filename", {
|
|
319
|
+
length: 512
|
|
320
|
+
}).notNull(),
|
|
321
|
+
mimeType: (0, import_pg_core.varchar)("mime_type", {
|
|
322
|
+
length: 128
|
|
323
|
+
}).notNull(),
|
|
324
|
+
sizeBytes: (0, import_pg_core.integer)("size_bytes").notNull(),
|
|
325
|
+
// Legenda da mídia no WhatsApp. Por arquivo, não por nó: um nó que manda tabela de preços e
|
|
326
|
+
// um folder precisa de textos diferentes para cada um.
|
|
327
|
+
caption: (0, import_pg_core.text)("caption"),
|
|
328
|
+
// Ordem de envio dentro do nó — o cliente recebe as mensagens em sequência, e "tabela antes
|
|
329
|
+
// do folder" é decisão de quem edita, não do banco.
|
|
330
|
+
sortOrder: (0, import_pg_core.integer)("sort_order").notNull().default(0),
|
|
331
|
+
// Desligar sem desanexar: trocar o material da campanha é o caso comum, e apagar a linha
|
|
332
|
+
// perderia a ordem e a legenda já ajustadas.
|
|
333
|
+
active: (0, import_pg_core.boolean)("active").notNull().default(true),
|
|
334
|
+
createdAt: (0, import_pg_core.timestamp)("created_at", {
|
|
335
|
+
withTimezone: true
|
|
336
|
+
}).notNull().defaultNow(),
|
|
337
|
+
updatedAt: (0, import_pg_core.timestamp)("updated_at", {
|
|
338
|
+
withTimezone: true
|
|
339
|
+
}).notNull().defaultNow()
|
|
340
|
+
}, (table) => [
|
|
341
|
+
(0, import_pg_core.index)("idx_flow_media_node").on(table.companyId, table.flowKey, table.nodeId, table.sortOrder),
|
|
342
|
+
// O mesmo arquivo anexado duas vezes ao MESMO nó é erro de clique no editor, e o cliente
|
|
343
|
+
// receberia o documento repetido.
|
|
344
|
+
(0, import_pg_core.uniqueIndex)("idx_flow_media_node_upload").on(table.companyId, table.flowKey, table.nodeId, table.uploadId)
|
|
345
|
+
]);
|
|
200
346
|
var settings = metaWhatsAppSchema.table("settings", {
|
|
201
347
|
companyId: (0, import_pg_core.uuid)("company_id").primaryKey(),
|
|
202
348
|
templateName: (0, import_pg_core.varchar)("template_name", {
|
|
@@ -209,6 +355,15 @@ var settings = metaWhatsAppSchema.table("settings", {
|
|
|
209
355
|
templateVariables: (0, import_pg_core.jsonb)("template_variables").$type().notNull().default([]),
|
|
210
356
|
welcomeMessage: (0, import_pg_core.text)("welcome_message"),
|
|
211
357
|
farewellMessage: (0, import_pg_core.text)("farewell_message"),
|
|
358
|
+
/**
|
|
359
|
+
* Política de transcrição desta empresa. Nulo é significativo: "o painel não decidiu", e aí vale
|
|
360
|
+
* o padrão que o host injetou. Sem a distinção, atualizar o módulo desligaria a transcrição de
|
|
361
|
+
* quem já a tinha ligada por ambiente.
|
|
362
|
+
*/
|
|
363
|
+
transcriptionEnabled: (0, import_pg_core.boolean)("transcription_enabled"),
|
|
364
|
+
transcriptionMode: (0, import_pg_core.varchar)("transcription_mode", {
|
|
365
|
+
length: 16
|
|
366
|
+
}).$type(),
|
|
212
367
|
createdAt: (0, import_pg_core.timestamp)("created_at", {
|
|
213
368
|
withTimezone: true
|
|
214
369
|
}).notNull().defaultNow(),
|
|
@@ -219,6 +374,31 @@ var settings = metaWhatsAppSchema.table("settings", {
|
|
|
219
374
|
|
|
220
375
|
// src/repositories/SessionRepository.ts
|
|
221
376
|
var DEFAULT_LIMIT = 20;
|
|
377
|
+
var conversationSummaryProjection = {
|
|
378
|
+
lastContent: import_drizzle_orm2.sql`(
|
|
379
|
+
select m.content from ${messages} m
|
|
380
|
+
where m.company_id = ${sessions}.company_id and m.session_id = ${sessions}.id
|
|
381
|
+
order by m.created_at desc limit 1
|
|
382
|
+
)`,
|
|
383
|
+
lastDirection: import_drizzle_orm2.sql`(
|
|
384
|
+
select m.direction from ${messages} m
|
|
385
|
+
where m.company_id = ${sessions}.company_id and m.session_id = ${sessions}.id
|
|
386
|
+
order by m.created_at desc limit 1
|
|
387
|
+
)`,
|
|
388
|
+
// Entradas do cliente depois da última leitura do atendente. Sessão nunca lida conta
|
|
389
|
+
// tudo — é o comportamento esperado de uma conversa que ninguém abriu ainda.
|
|
390
|
+
unread: import_drizzle_orm2.sql`(
|
|
391
|
+
select count(*)::int from ${messages} m
|
|
392
|
+
where m.company_id = ${sessions}.company_id
|
|
393
|
+
and m.session_id = ${sessions}.id
|
|
394
|
+
and m.direction = 'inbound'
|
|
395
|
+
and (${sessions}.last_agent_read_at is null or m.created_at > ${sessions}.last_agent_read_at)
|
|
396
|
+
)`
|
|
397
|
+
};
|
|
398
|
+
function sessionContextPatch(patch) {
|
|
399
|
+
return import_drizzle_orm2.sql`${sessions.context} || ${JSON.stringify(patch)}::jsonb`;
|
|
400
|
+
}
|
|
401
|
+
__name(sessionContextPatch, "sessionContextPatch");
|
|
222
402
|
var SessionRepository = class {
|
|
223
403
|
static {
|
|
224
404
|
__name(this, "SessionRepository");
|
|
@@ -249,6 +429,9 @@ var SessionRepository = class {
|
|
|
249
429
|
}).returning();
|
|
250
430
|
return created;
|
|
251
431
|
}
|
|
432
|
+
// O `context` jsonb é o ponto de extensão oficial para estado de sessão por produto: o módulo
|
|
433
|
+
// não conhece a forma, o consumidor a declara em TSessionContext. Este setter SUBSTITUI o
|
|
434
|
+
// objeto inteiro — para acumular respostas ao longo da conversa use patchContext.
|
|
252
435
|
async setState(companyId, whatsappNumber, state, context) {
|
|
253
436
|
await this.db.update(sessions).set({
|
|
254
437
|
currentState: state,
|
|
@@ -259,6 +442,25 @@ var SessionRepository = class {
|
|
|
259
442
|
updatedAt: import_drizzle_orm2.sql`now()`
|
|
260
443
|
}).where((0, import_drizzle_orm2.and)((0, import_drizzle_orm2.eq)(sessions.companyId, companyId), (0, import_drizzle_orm2.eq)(sessions.whatsappNumber, whatsappNumber)));
|
|
261
444
|
}
|
|
445
|
+
// Mescla parcial do context, feita no banco (`||`) e não por read-modify-write no host: duas
|
|
446
|
+
// mensagens do mesmo cliente processadas em paralelo sobrescreveriam uma à outra, e o campo
|
|
447
|
+
// acumula justamente as respostas coletadas ao longo da conversa. Chave presente no patch
|
|
448
|
+
// vence a existente; as demais permanecem.
|
|
449
|
+
async patchContext(companyId, whatsappNumber, patch) {
|
|
450
|
+
await this.db.update(sessions).set({
|
|
451
|
+
context: sessionContextPatch(patch),
|
|
452
|
+
lastActivity: import_drizzle_orm2.sql`now()`,
|
|
453
|
+
updatedAt: import_drizzle_orm2.sql`now()`
|
|
454
|
+
}).where((0, import_drizzle_orm2.and)((0, import_drizzle_orm2.eq)(sessions.companyId, companyId), (0, import_drizzle_orm2.eq)(sessions.whatsappNumber, whatsappNumber)));
|
|
455
|
+
}
|
|
456
|
+
// Leitura tipada do estado de sessão do produto. Devolve undefined quando a sessão não existe —
|
|
457
|
+
// distinto de existir com context vazio, que devolve o objeto vazio.
|
|
458
|
+
async readContext(companyId, whatsappNumber) {
|
|
459
|
+
const [row] = await this.db.select({
|
|
460
|
+
context: sessions.context
|
|
461
|
+
}).from(sessions).where((0, import_drizzle_orm2.and)((0, import_drizzle_orm2.eq)(sessions.companyId, companyId), (0, import_drizzle_orm2.eq)(sessions.whatsappNumber, whatsappNumber))).limit(1);
|
|
462
|
+
return row?.context;
|
|
463
|
+
}
|
|
262
464
|
// Posição no grafo de fluxo — chamado pelo host a cada transição do FlowInterpreter.
|
|
263
465
|
// Passar null em ambos desliga o rastreio (ex.: conversa saiu do motor de fluxo).
|
|
264
466
|
async setFlowPosition(companyId, whatsappNumber, flowKey, currentNodeId) {
|
|
@@ -301,6 +503,16 @@ var SessionRepository = class {
|
|
|
301
503
|
async release(companyId, whatsappNumber) {
|
|
302
504
|
await this.setMode(companyId, whatsappNumber, "bot", null);
|
|
303
505
|
}
|
|
506
|
+
/**
|
|
507
|
+
* Apaga a sessão; a cascata das FKs leva mensagens e documentos.
|
|
508
|
+
*
|
|
509
|
+
* Não apaga o binário no storage — isso é passo de aplicação, e é por isso que este método é
|
|
510
|
+
* chamado por `DeleteConversationUseCase` e não diretamente pelo host. Chamar daqui sem apagar os
|
|
511
|
+
* objetos antes deixa mídia órfã sendo cobrada para sempre.
|
|
512
|
+
*/
|
|
513
|
+
async deleteByNumber(companyId, whatsappNumber) {
|
|
514
|
+
await this.db.delete(sessions).where((0, import_drizzle_orm2.and)((0, import_drizzle_orm2.eq)(sessions.companyId, companyId), (0, import_drizzle_orm2.eq)(sessions.whatsappNumber, whatsappNumber)));
|
|
515
|
+
}
|
|
304
516
|
async requestHuman(companyId, whatsappNumber) {
|
|
305
517
|
await this.db.update(sessions).set({
|
|
306
518
|
humanRequestedAt: import_drizzle_orm2.sql`now()`,
|
|
@@ -334,17 +546,28 @@ var SessionRepository = class {
|
|
|
334
546
|
currentState: sessions.currentState,
|
|
335
547
|
lastActivity: sessions.lastActivity,
|
|
336
548
|
lastInboundAt: sessions.lastInboundAt,
|
|
337
|
-
humanRequestedAt: sessions.humanRequestedAt
|
|
549
|
+
humanRequestedAt: sessions.humanRequestedAt,
|
|
550
|
+
// Prévia e contagem saem de subquery correlacionada em vez de N+1 na volta: uma inbox
|
|
551
|
+
// lista dezenas de conversas por página, e uma query por linha é o gargalo clássico
|
|
552
|
+
// dessa tela. Ambos os campos são dados do próprio módulo — deixá-los para o host
|
|
553
|
+
// obrigaria todo consumidor a reescrever o mesmo join contra tabelas que não são dele.
|
|
554
|
+
...conversationSummaryProjection
|
|
338
555
|
}).from(sessions).where((0, import_drizzle_orm2.and)(...conditions)).orderBy((0, import_drizzle_orm2.desc)(sessions.lastActivity)).limit(limit).offset(offset);
|
|
339
556
|
return rows.map((row) => ({
|
|
340
557
|
id: row.id,
|
|
341
558
|
whatsappNumber: row.whatsappNumber,
|
|
559
|
+
...row.lastContent !== null ? {
|
|
560
|
+
lastContent: row.lastContent
|
|
561
|
+
} : {},
|
|
562
|
+
...row.lastDirection !== null ? {
|
|
563
|
+
lastDirection: row.lastDirection
|
|
564
|
+
} : {},
|
|
342
565
|
lastAt: row.lastActivity.toISOString(),
|
|
343
566
|
lastInboundAt: row.lastInboundAt?.toISOString() ?? null,
|
|
344
567
|
mode: row.mode,
|
|
345
568
|
assignedUserId: row.assignedUserId,
|
|
346
569
|
waitingHuman: row.humanRequestedAt !== null,
|
|
347
|
-
unread:
|
|
570
|
+
unread: Number(row.unread),
|
|
348
571
|
currentState: row.currentState
|
|
349
572
|
}));
|
|
350
573
|
}
|
|
@@ -388,7 +611,9 @@ var MessageRepository = class {
|
|
|
388
611
|
content: params.content ?? null,
|
|
389
612
|
payload: params.payload ?? null,
|
|
390
613
|
waMessageId: params.waMessageId ?? null,
|
|
391
|
-
status: params.status ?? null
|
|
614
|
+
status: params.status ?? null,
|
|
615
|
+
moderationFlagged: params.moderationFlagged ?? null,
|
|
616
|
+
moderationTerms: params.moderationTerms ?? null
|
|
392
617
|
};
|
|
393
618
|
const [created] = await this.db.insert(messages).values(values).onConflictDoNothing().returning();
|
|
394
619
|
return created;
|
|
@@ -402,6 +627,58 @@ var MessageRepository = class {
|
|
|
402
627
|
}).where((0, import_drizzle_orm3.and)((0, import_drizzle_orm3.eq)(messages.companyId, companyId), (0, import_drizzle_orm3.eq)(messages.waMessageId, waMessageId))).returning();
|
|
403
628
|
return updated;
|
|
404
629
|
}
|
|
630
|
+
/**
|
|
631
|
+
* Grava a transcrição endereçando pelo id da Meta, para quem só tem esse.
|
|
632
|
+
*
|
|
633
|
+
* Serve ao caso em que a transcrição acontece no próprio webhook — o grafo precisa do texto para
|
|
634
|
+
* responder ao cliente, e jogar fora o que ele já pagou para transcrever significaria transcrever
|
|
635
|
+
* o mesmo áudio uma segunda vez só para o painel ver.
|
|
636
|
+
*
|
|
637
|
+
* Devolve `undefined` quando não achou a mensagem: entrega duplicada e mensagem apagada são
|
|
638
|
+
* corridas normais, não erro.
|
|
639
|
+
*/
|
|
640
|
+
async saveTranscriptionByWaMessageId(params) {
|
|
641
|
+
const [updated] = await this.db.update(messages).set({
|
|
642
|
+
transcriptionStatus: params.status,
|
|
643
|
+
...params.text !== void 0 ? {
|
|
644
|
+
transcriptionText: params.text
|
|
645
|
+
} : {},
|
|
646
|
+
...params.language !== void 0 ? {
|
|
647
|
+
transcriptionLanguage: params.language
|
|
648
|
+
} : {},
|
|
649
|
+
...params.engine !== void 0 ? {
|
|
650
|
+
transcriptionEngine: params.engine
|
|
651
|
+
} : {}
|
|
652
|
+
}).where((0, import_drizzle_orm3.and)((0, import_drizzle_orm3.eq)(messages.companyId, params.companyId), (0, import_drizzle_orm3.eq)(messages.waMessageId, params.waMessageId))).returning();
|
|
653
|
+
return updated;
|
|
654
|
+
}
|
|
655
|
+
async findById(companyId, messageId) {
|
|
656
|
+
const [found] = await this.db.select().from(messages).where((0, import_drizzle_orm3.and)((0, import_drizzle_orm3.eq)(messages.companyId, companyId), (0, import_drizzle_orm3.eq)(messages.id, messageId))).limit(1);
|
|
657
|
+
return found;
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Grava o resultado da transcrição. Devolve `undefined` quando a mensagem não existe (apagada
|
|
661
|
+
* entre o enfileiramento e a execução do job) — não é erro, é corrida normal.
|
|
662
|
+
*
|
|
663
|
+
* `text`/`language`/`engine` só são tocados quando informados: uma retentativa que volta a falhar
|
|
664
|
+
* atualiza o status sem apagar a transcrição parcial de uma tentativa anterior que tenha vindo de
|
|
665
|
+
* outro engine da cadeia.
|
|
666
|
+
*/
|
|
667
|
+
async saveTranscription(params) {
|
|
668
|
+
const [updated] = await this.db.update(messages).set({
|
|
669
|
+
transcriptionStatus: params.status,
|
|
670
|
+
...params.text !== void 0 ? {
|
|
671
|
+
transcriptionText: params.text
|
|
672
|
+
} : {},
|
|
673
|
+
...params.language !== void 0 ? {
|
|
674
|
+
transcriptionLanguage: params.language
|
|
675
|
+
} : {},
|
|
676
|
+
...params.engine !== void 0 ? {
|
|
677
|
+
transcriptionEngine: params.engine
|
|
678
|
+
} : {}
|
|
679
|
+
}).where((0, import_drizzle_orm3.and)((0, import_drizzle_orm3.eq)(messages.companyId, params.companyId), (0, import_drizzle_orm3.eq)(messages.id, params.messageId))).returning();
|
|
680
|
+
return updated;
|
|
681
|
+
}
|
|
405
682
|
async listByConversation(params) {
|
|
406
683
|
const conditions = [
|
|
407
684
|
(0, import_drizzle_orm3.eq)(messages.companyId, params.companyId),
|
|
@@ -451,12 +728,21 @@ var FlowGraphRepository = class {
|
|
|
451
728
|
__name(this, "FlowGraphRepository");
|
|
452
729
|
}
|
|
453
730
|
db;
|
|
454
|
-
|
|
731
|
+
cache;
|
|
732
|
+
// Cache opcional: sem ele o repositório se comporta exatamente como antes, lendo sempre do
|
|
733
|
+
// banco. É o host que decide se quer cachear e com qual provedor (ver CacheInterface).
|
|
734
|
+
constructor(db, cache) {
|
|
455
735
|
this.db = db;
|
|
736
|
+
this.cache = cache;
|
|
456
737
|
}
|
|
457
738
|
async get(companyId, key) {
|
|
739
|
+
const cached = await this.cache?.read(companyId, key);
|
|
740
|
+
if (cached) return cached;
|
|
458
741
|
const [row] = await this.db.select().from(flowGraphs).where((0, import_drizzle_orm4.and)((0, import_drizzle_orm4.eq)(flowGraphs.companyId, companyId), (0, import_drizzle_orm4.eq)(flowGraphs.key, key))).limit(1);
|
|
459
|
-
|
|
742
|
+
if (!row) return void 0;
|
|
743
|
+
const graph = toContractGraph(row);
|
|
744
|
+
await this.cache?.write(companyId, graph);
|
|
745
|
+
return graph;
|
|
460
746
|
}
|
|
461
747
|
async list(companyId) {
|
|
462
748
|
const rows = await this.db.select().from(flowGraphs).where((0, import_drizzle_orm4.eq)(flowGraphs.companyId, companyId));
|
|
@@ -486,7 +772,9 @@ var FlowGraphRepository = class {
|
|
|
486
772
|
showInMenu: graph.showInMenu ?? false,
|
|
487
773
|
menuOptionLabel: graph.menuOptionLabel
|
|
488
774
|
}).returning();
|
|
489
|
-
|
|
775
|
+
const createdGraph = toContractGraph(created);
|
|
776
|
+
await this.cache?.invalidate(companyId, createdGraph.key);
|
|
777
|
+
return createdGraph;
|
|
490
778
|
}
|
|
491
779
|
// Lock otimista: a escrita só aplica se `expectedVersion` ainda bater com o que está salvo —
|
|
492
780
|
// senão, alguém mais salvou entretanto e o editor precisa recarregar (ver comentário no schema).
|
|
@@ -500,10 +788,12 @@ var FlowGraphRepository = class {
|
|
|
500
788
|
updatedAt: /* @__PURE__ */ new Date()
|
|
501
789
|
}).where((0, import_drizzle_orm4.and)((0, import_drizzle_orm4.eq)(flowGraphs.companyId, companyId), (0, import_drizzle_orm4.eq)(flowGraphs.key, graph.key), (0, import_drizzle_orm4.eq)(flowGraphs.version, expectedVersion))).returning();
|
|
502
790
|
if (rows.length === 0) throw new OptimisticLockError(graph.key);
|
|
791
|
+
await this.cache?.invalidate(companyId, graph.key);
|
|
503
792
|
return toContractGraph(rows[0]);
|
|
504
793
|
}
|
|
505
794
|
async delete(companyId, key) {
|
|
506
795
|
await this.db.delete(flowGraphs).where((0, import_drizzle_orm4.and)((0, import_drizzle_orm4.eq)(flowGraphs.companyId, companyId), (0, import_drizzle_orm4.eq)(flowGraphs.key, key)));
|
|
796
|
+
await this.cache?.invalidate(companyId, key);
|
|
507
797
|
}
|
|
508
798
|
// T4.2 — GetLiveFlowPositions: agrega sessões ativas por (flowKey, currentNodeId), lendo as
|
|
509
799
|
// colunas dedicadas gravadas por SessionRepository.setFlowPosition. Agrega no banco (GROUP BY,
|
|
@@ -523,6 +813,48 @@ var FlowGraphRepository = class {
|
|
|
523
813
|
}
|
|
524
814
|
};
|
|
525
815
|
|
|
816
|
+
// src/repositories/FlowGraphCache.ts
|
|
817
|
+
var KEY_PREFIX = "meta-whatsapp:flow-graph";
|
|
818
|
+
var DEFAULT_FLOW_GRAPH_CACHE_TTL_SECONDS = 300;
|
|
819
|
+
var FlowGraphCache = class {
|
|
820
|
+
static {
|
|
821
|
+
__name(this, "FlowGraphCache");
|
|
822
|
+
}
|
|
823
|
+
provider;
|
|
824
|
+
ttlSeconds;
|
|
825
|
+
constructor(provider, ttlSeconds = DEFAULT_FLOW_GRAPH_CACHE_TTL_SECONDS) {
|
|
826
|
+
this.provider = provider;
|
|
827
|
+
this.ttlSeconds = ttlSeconds;
|
|
828
|
+
}
|
|
829
|
+
// companyId na chave, e não só a flowKey: a chave do fluxo é escolhida por quem edita e se
|
|
830
|
+
// repete entre empresas — 'consorcio' existe em todas — então uma chave sem tenant serviria o
|
|
831
|
+
// grafo de uma empresa para a conversa de outra.
|
|
832
|
+
keyFor(companyId, flowKey) {
|
|
833
|
+
return `${KEY_PREFIX}:${companyId}:${flowKey}`;
|
|
834
|
+
}
|
|
835
|
+
async read(companyId, flowKey) {
|
|
836
|
+
try {
|
|
837
|
+
const cached = await this.provider.get(this.keyFor(companyId, flowKey));
|
|
838
|
+
if (!cached) return void 0;
|
|
839
|
+
return JSON.parse(cached);
|
|
840
|
+
} catch {
|
|
841
|
+
return void 0;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
async write(companyId, graph) {
|
|
845
|
+
try {
|
|
846
|
+
await this.provider.set(this.keyFor(companyId, graph.key), JSON.stringify(graph), this.ttlSeconds);
|
|
847
|
+
} catch {
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
async invalidate(companyId, flowKey) {
|
|
851
|
+
try {
|
|
852
|
+
await this.provider.delete(this.keyFor(companyId, flowKey));
|
|
853
|
+
} catch {
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
};
|
|
857
|
+
|
|
526
858
|
// src/repositories/SettingsRepository.ts
|
|
527
859
|
var import_drizzle_orm5 = require("drizzle-orm");
|
|
528
860
|
function toContractSettings(row) {
|
|
@@ -531,7 +863,11 @@ function toContractSettings(row) {
|
|
|
531
863
|
templateLanguage: row.templateLanguage,
|
|
532
864
|
templateVariables: row.templateVariables,
|
|
533
865
|
welcomeMessage: row.welcomeMessage ?? "",
|
|
534
|
-
farewellMessage: row.farewellMessage ?? ""
|
|
866
|
+
farewellMessage: row.farewellMessage ?? "",
|
|
867
|
+
// `?? null` e não `?? false`: nulo é "o painel não decidiu", e é o que faz valer o padrão do
|
|
868
|
+
// host. Colapsar para `false` desligaria quem ligou por ambiente.
|
|
869
|
+
transcriptionEnabled: row.transcriptionEnabled ?? null,
|
|
870
|
+
transcriptionMode: row.transcriptionMode ?? null
|
|
535
871
|
};
|
|
536
872
|
}
|
|
537
873
|
__name(toContractSettings, "toContractSettings");
|
|
@@ -540,7 +876,9 @@ var EMPTY_SETTINGS = {
|
|
|
540
876
|
templateLanguage: "pt_BR",
|
|
541
877
|
templateVariables: [],
|
|
542
878
|
welcomeMessage: "",
|
|
543
|
-
farewellMessage: ""
|
|
879
|
+
farewellMessage: "",
|
|
880
|
+
transcriptionEnabled: null,
|
|
881
|
+
transcriptionMode: null
|
|
544
882
|
};
|
|
545
883
|
var SettingsRepository = class {
|
|
546
884
|
static {
|
|
@@ -569,7 +907,12 @@ var SettingsRepository = class {
|
|
|
569
907
|
templateLanguage: merged.templateLanguage,
|
|
570
908
|
templateVariables: merged.templateVariables,
|
|
571
909
|
welcomeMessage: merged.welcomeMessage || null,
|
|
572
|
-
farewellMessage: merged.farewellMessage || null
|
|
910
|
+
farewellMessage: merged.farewellMessage || null,
|
|
911
|
+
// Sem `|| null`: `false` aqui é decisão explícita do painel ("desligado para esta empresa"),
|
|
912
|
+
// e colapsá-lo para nulo faria a empresa voltar a herdar o padrão do host — exatamente o
|
|
913
|
+
// oposto do que o operador acabou de pedir.
|
|
914
|
+
transcriptionEnabled: merged.transcriptionEnabled,
|
|
915
|
+
transcriptionMode: merged.transcriptionMode
|
|
573
916
|
}).onConflictDoUpdate({
|
|
574
917
|
target: settings.companyId,
|
|
575
918
|
set: {
|
|
@@ -578,6 +921,8 @@ var SettingsRepository = class {
|
|
|
578
921
|
templateVariables: merged.templateVariables,
|
|
579
922
|
welcomeMessage: merged.welcomeMessage || null,
|
|
580
923
|
farewellMessage: merged.farewellMessage || null,
|
|
924
|
+
transcriptionEnabled: merged.transcriptionEnabled,
|
|
925
|
+
transcriptionMode: merged.transcriptionMode,
|
|
581
926
|
updatedAt: /* @__PURE__ */ new Date()
|
|
582
927
|
}
|
|
583
928
|
}).returning();
|
|
@@ -604,16 +949,19 @@ var LogMessageUseCase = class {
|
|
|
604
949
|
sessionRepository;
|
|
605
950
|
messageRepository;
|
|
606
951
|
realtime;
|
|
607
|
-
|
|
952
|
+
moderator;
|
|
953
|
+
constructor(sessionRepository, messageRepository, realtime, moderator) {
|
|
608
954
|
this.sessionRepository = sessionRepository;
|
|
609
955
|
this.messageRepository = messageRepository;
|
|
610
956
|
this.realtime = realtime;
|
|
957
|
+
this.moderator = moderator;
|
|
611
958
|
}
|
|
612
959
|
async execute(params) {
|
|
613
960
|
const session = await this.sessionRepository.getOrCreate(params.companyId, params.whatsappNumber, params.startState);
|
|
614
961
|
const saved = await this.messageRepository.insertMessage({
|
|
615
962
|
...params,
|
|
616
|
-
sessionId: session.id
|
|
963
|
+
sessionId: session.id,
|
|
964
|
+
...this.moderationOf(params)
|
|
617
965
|
});
|
|
618
966
|
if (!saved) return void 0;
|
|
619
967
|
if (params.direction === "inbound") {
|
|
@@ -626,6 +974,20 @@ var LogMessageUseCase = class {
|
|
|
626
974
|
this.realtime?.emit("global", "data-changed", {});
|
|
627
975
|
return saved;
|
|
628
976
|
}
|
|
977
|
+
// Só o que o cliente escreveu: marcar o que o próprio atendente ou o bot enviou não sinaliza
|
|
978
|
+
// abuso, apenas sujaria o transcript com etiqueta na resposta de quem atende.
|
|
979
|
+
moderationOf(params) {
|
|
980
|
+
if (!this.moderator || params.direction !== "inbound") return {};
|
|
981
|
+
const text2 = params.content?.trim();
|
|
982
|
+
if (!text2) return {};
|
|
983
|
+
const verdict = this.moderator.inspect(text2);
|
|
984
|
+
return {
|
|
985
|
+
moderationFlagged: verdict.isOffensive,
|
|
986
|
+
moderationTerms: verdict.isOffensive ? [
|
|
987
|
+
...verdict.matchedTerms
|
|
988
|
+
] : null
|
|
989
|
+
};
|
|
990
|
+
}
|
|
629
991
|
};
|
|
630
992
|
|
|
631
993
|
// src/use-cases/SendMessage.use-case.ts
|
|
@@ -639,11 +1001,13 @@ var SendMessageUseCase = class {
|
|
|
639
1001
|
sessionRepository;
|
|
640
1002
|
logMessage;
|
|
641
1003
|
objectStorage;
|
|
642
|
-
|
|
1004
|
+
documentRepository;
|
|
1005
|
+
constructor(channel, sessionRepository, logMessage, objectStorage, documentRepository) {
|
|
643
1006
|
this.channel = channel;
|
|
644
1007
|
this.sessionRepository = sessionRepository;
|
|
645
1008
|
this.logMessage = logMessage;
|
|
646
1009
|
this.objectStorage = objectStorage;
|
|
1010
|
+
this.documentRepository = documentRepository;
|
|
647
1011
|
}
|
|
648
1012
|
async assertWithinWindow(companyId, whatsappNumber) {
|
|
649
1013
|
const hours = await this.sessionRepository.hoursSinceLastInbound(companyId, whatsappNumber);
|
|
@@ -680,7 +1044,7 @@ var SendMessageUseCase = class {
|
|
|
680
1044
|
mimeType: params.mimeType,
|
|
681
1045
|
key: `meta-whatsapp/${params.companyId}/${Date.now()}-${params.filename}`
|
|
682
1046
|
})).uploadId : void 0;
|
|
683
|
-
|
|
1047
|
+
const saved = await this.logMessage.execute({
|
|
684
1048
|
companyId: params.companyId,
|
|
685
1049
|
whatsappNumber: params.whatsappNumber,
|
|
686
1050
|
direction: "outbound",
|
|
@@ -699,6 +1063,19 @@ var SendMessageUseCase = class {
|
|
|
699
1063
|
status: "sent",
|
|
700
1064
|
startState: params.startState
|
|
701
1065
|
});
|
|
1066
|
+
if (saved && uploadId) {
|
|
1067
|
+
await this.documentRepository?.link({
|
|
1068
|
+
companyId: params.companyId,
|
|
1069
|
+
sessionId: saved.sessionId,
|
|
1070
|
+
messageId: saved.id,
|
|
1071
|
+
uploadId,
|
|
1072
|
+
filename: params.filename,
|
|
1073
|
+
mimeType: params.mimeType,
|
|
1074
|
+
sizeBytes: params.buffer.length,
|
|
1075
|
+
source: params.sender
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
return saved;
|
|
702
1079
|
}
|
|
703
1080
|
// Template é o único envio que ignora a janela — é justamente o mecanismo que a Meta oferece
|
|
704
1081
|
// para reabri-la.
|
|
@@ -816,8 +1193,330 @@ var ListMessagesUseCase = class {
|
|
|
816
1193
|
}
|
|
817
1194
|
};
|
|
818
1195
|
|
|
819
|
-
// src/use-cases/
|
|
1196
|
+
// src/use-cases/ListConversationDocuments.use-case.ts
|
|
820
1197
|
var import_meta_whatsapp_contracts4 = require("@adatechnology/meta-whatsapp-contracts");
|
|
1198
|
+
var ListConversationDocumentsUseCase = class {
|
|
1199
|
+
static {
|
|
1200
|
+
__name(this, "ListConversationDocumentsUseCase");
|
|
1201
|
+
}
|
|
1202
|
+
sessionRepository;
|
|
1203
|
+
documentRepository;
|
|
1204
|
+
constructor(sessionRepository, documentRepository) {
|
|
1205
|
+
this.sessionRepository = sessionRepository;
|
|
1206
|
+
this.documentRepository = documentRepository;
|
|
1207
|
+
}
|
|
1208
|
+
async execute(params) {
|
|
1209
|
+
const session = await this.sessionRepository.getContext(params.companyId, params.whatsappNumber);
|
|
1210
|
+
if (!session) throw new import_meta_whatsapp_contracts4.SessionNotFoundError(params.whatsappNumber);
|
|
1211
|
+
const { rows, total } = await this.documentRepository.listByConversation({
|
|
1212
|
+
companyId: params.companyId,
|
|
1213
|
+
sessionId: session.id,
|
|
1214
|
+
...params.search ? {
|
|
1215
|
+
search: params.search
|
|
1216
|
+
} : {},
|
|
1217
|
+
...params.sources && params.sources.length > 0 ? {
|
|
1218
|
+
sources: params.sources
|
|
1219
|
+
} : {},
|
|
1220
|
+
...params.sortDirection ? {
|
|
1221
|
+
sortDirection: params.sortDirection
|
|
1222
|
+
} : {},
|
|
1223
|
+
...params.page ? {
|
|
1224
|
+
page: params.page
|
|
1225
|
+
} : {},
|
|
1226
|
+
...params.limit ? {
|
|
1227
|
+
limit: params.limit
|
|
1228
|
+
} : {}
|
|
1229
|
+
});
|
|
1230
|
+
return {
|
|
1231
|
+
documents: rows.map((row) => ({
|
|
1232
|
+
id: row.uploadId,
|
|
1233
|
+
filename: row.filename,
|
|
1234
|
+
mimeType: row.mimeType,
|
|
1235
|
+
sizeBytes: row.sizeBytes,
|
|
1236
|
+
source: row.source,
|
|
1237
|
+
linkedAt: row.linkedAt.toISOString()
|
|
1238
|
+
})),
|
|
1239
|
+
total
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
};
|
|
1243
|
+
|
|
1244
|
+
// src/use-cases/ListCompanyDocuments.use-case.ts
|
|
1245
|
+
var ListCompanyDocumentsUseCase = class {
|
|
1246
|
+
static {
|
|
1247
|
+
__name(this, "ListCompanyDocumentsUseCase");
|
|
1248
|
+
}
|
|
1249
|
+
documentRepository;
|
|
1250
|
+
constructor(documentRepository) {
|
|
1251
|
+
this.documentRepository = documentRepository;
|
|
1252
|
+
}
|
|
1253
|
+
async execute(params) {
|
|
1254
|
+
const { rows, total } = await this.documentRepository.listByCompany({
|
|
1255
|
+
companyId: params.companyId,
|
|
1256
|
+
...params.search ? {
|
|
1257
|
+
search: params.search
|
|
1258
|
+
} : {},
|
|
1259
|
+
...params.sources && params.sources.length > 0 ? {
|
|
1260
|
+
sources: params.sources
|
|
1261
|
+
} : {},
|
|
1262
|
+
...params.sortDirection ? {
|
|
1263
|
+
sortDirection: params.sortDirection
|
|
1264
|
+
} : {},
|
|
1265
|
+
...params.page ? {
|
|
1266
|
+
page: params.page
|
|
1267
|
+
} : {},
|
|
1268
|
+
...params.limit ? {
|
|
1269
|
+
limit: params.limit
|
|
1270
|
+
} : {}
|
|
1271
|
+
});
|
|
1272
|
+
return {
|
|
1273
|
+
documents: rows.map((row) => ({
|
|
1274
|
+
id: row.uploadId,
|
|
1275
|
+
conversationId: row.whatsappNumber,
|
|
1276
|
+
filename: row.filename,
|
|
1277
|
+
mimeType: row.mimeType,
|
|
1278
|
+
sizeBytes: row.sizeBytes,
|
|
1279
|
+
source: row.source,
|
|
1280
|
+
linkedAt: row.linkedAt.toISOString()
|
|
1281
|
+
})),
|
|
1282
|
+
total
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
};
|
|
1286
|
+
|
|
1287
|
+
// src/use-cases/DeleteConversation.use-case.ts
|
|
1288
|
+
var import_meta_whatsapp_contracts5 = require("@adatechnology/meta-whatsapp-contracts");
|
|
1289
|
+
var DeleteConversationUseCase = class {
|
|
1290
|
+
static {
|
|
1291
|
+
__name(this, "DeleteConversationUseCase");
|
|
1292
|
+
}
|
|
1293
|
+
sessionRepository;
|
|
1294
|
+
documentRepository;
|
|
1295
|
+
objectStorage;
|
|
1296
|
+
constructor(sessionRepository, documentRepository, objectStorage) {
|
|
1297
|
+
this.sessionRepository = sessionRepository;
|
|
1298
|
+
this.documentRepository = documentRepository;
|
|
1299
|
+
this.objectStorage = objectStorage;
|
|
1300
|
+
}
|
|
1301
|
+
async execute(params) {
|
|
1302
|
+
const session = await this.sessionRepository.getContext(params.companyId, params.whatsappNumber);
|
|
1303
|
+
if (!session) throw new import_meta_whatsapp_contracts5.SessionNotFoundError(params.whatsappNumber);
|
|
1304
|
+
const uploadIds = await this.documentRepository.listUploadIdsBySession(params.companyId, session.id);
|
|
1305
|
+
if (uploadIds.length > 0 && !this.objectStorage?.delete) {
|
|
1306
|
+
throw new Error("storage_delete_unsupported: a conversa tem arquivos e o storage injetado n\xE3o implementa delete");
|
|
1307
|
+
}
|
|
1308
|
+
const failedObjects = [];
|
|
1309
|
+
let deletedObjects = 0;
|
|
1310
|
+
for (const uploadId of uploadIds) {
|
|
1311
|
+
try {
|
|
1312
|
+
await this.objectStorage?.delete?.(uploadId);
|
|
1313
|
+
deletedObjects++;
|
|
1314
|
+
} catch {
|
|
1315
|
+
failedObjects.push(uploadId);
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
if (failedObjects.length > 0) return {
|
|
1319
|
+
deletedObjects,
|
|
1320
|
+
failedObjects
|
|
1321
|
+
};
|
|
1322
|
+
await this.sessionRepository.deleteByNumber(params.companyId, params.whatsappNumber);
|
|
1323
|
+
return {
|
|
1324
|
+
deletedObjects,
|
|
1325
|
+
failedObjects: []
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
};
|
|
1329
|
+
|
|
1330
|
+
// src/use-cases/PurgeExpiredDocuments.use-case.ts
|
|
1331
|
+
var DEFAULT_BATCH_SIZE = 50;
|
|
1332
|
+
var HOURS_IN_DAY = 24;
|
|
1333
|
+
var MILLISECONDS_IN_HOUR = 60 * 60 * 1e3;
|
|
1334
|
+
var PurgeExpiredDocumentsUseCase = class {
|
|
1335
|
+
static {
|
|
1336
|
+
__name(this, "PurgeExpiredDocumentsUseCase");
|
|
1337
|
+
}
|
|
1338
|
+
documentRepository;
|
|
1339
|
+
objectStorage;
|
|
1340
|
+
constructor(documentRepository, objectStorage) {
|
|
1341
|
+
this.documentRepository = documentRepository;
|
|
1342
|
+
this.objectStorage = objectStorage;
|
|
1343
|
+
}
|
|
1344
|
+
async execute(params) {
|
|
1345
|
+
if (!this.objectStorage?.delete) {
|
|
1346
|
+
throw new Error("storage_delete_unsupported: reten\xE7\xE3o exige um storage que implemente delete");
|
|
1347
|
+
}
|
|
1348
|
+
const reference = params.now ?? /* @__PURE__ */ new Date();
|
|
1349
|
+
const olderThan = new Date(reference.getTime() - params.retentionDays * HOURS_IN_DAY * MILLISECONDS_IN_HOUR);
|
|
1350
|
+
const expired = await this.documentRepository.listExpired(params.companyId, olderThan, params.batchSize ?? DEFAULT_BATCH_SIZE);
|
|
1351
|
+
const failed = [];
|
|
1352
|
+
let purged = 0;
|
|
1353
|
+
for (const document of expired) {
|
|
1354
|
+
try {
|
|
1355
|
+
await this.objectStorage.delete(document.uploadId);
|
|
1356
|
+
} catch {
|
|
1357
|
+
failed.push(document.uploadId);
|
|
1358
|
+
continue;
|
|
1359
|
+
}
|
|
1360
|
+
await this.documentRepository.deleteById(params.companyId, document.id);
|
|
1361
|
+
purged++;
|
|
1362
|
+
}
|
|
1363
|
+
return {
|
|
1364
|
+
purged,
|
|
1365
|
+
failed
|
|
1366
|
+
};
|
|
1367
|
+
}
|
|
1368
|
+
};
|
|
1369
|
+
|
|
1370
|
+
// src/repositories/DocumentRepository.ts
|
|
1371
|
+
var import_drizzle_orm6 = require("drizzle-orm");
|
|
1372
|
+
var DEFAULT_LIMIT3 = 50;
|
|
1373
|
+
function companyDocumentSearch(search) {
|
|
1374
|
+
const term = search?.trim();
|
|
1375
|
+
if (!term) return void 0;
|
|
1376
|
+
const digits = term.replace(/\D/g, "");
|
|
1377
|
+
const byFilename = (0, import_drizzle_orm6.ilike)(documents.filename, `%${term}%`);
|
|
1378
|
+
if (!digits) return byFilename;
|
|
1379
|
+
return (0, import_drizzle_orm6.or)(byFilename, (0, import_drizzle_orm6.ilike)(sessions.whatsappNumber, `%${digits}%`));
|
|
1380
|
+
}
|
|
1381
|
+
__name(companyDocumentSearch, "companyDocumentSearch");
|
|
1382
|
+
var DocumentRepository = class {
|
|
1383
|
+
static {
|
|
1384
|
+
__name(this, "DocumentRepository");
|
|
1385
|
+
}
|
|
1386
|
+
db;
|
|
1387
|
+
constructor(db) {
|
|
1388
|
+
this.db = db;
|
|
1389
|
+
}
|
|
1390
|
+
/**
|
|
1391
|
+
* Idempotente por (companyId, uploadId), garantido pelo índice único e não por SELECT prévio: o
|
|
1392
|
+
* job de ingestão é reentregue por retry e duas tentativas concorrentes passariam as duas por uma
|
|
1393
|
+
* checagem, duplicando a linha no painel.
|
|
1394
|
+
*
|
|
1395
|
+
* Devolve `undefined` quando o documento já estava linkado.
|
|
1396
|
+
*/
|
|
1397
|
+
async link(params) {
|
|
1398
|
+
const values = {
|
|
1399
|
+
companyId: params.companyId,
|
|
1400
|
+
sessionId: params.sessionId,
|
|
1401
|
+
messageId: params.messageId ?? null,
|
|
1402
|
+
uploadId: params.uploadId,
|
|
1403
|
+
filename: params.filename,
|
|
1404
|
+
mimeType: params.mimeType,
|
|
1405
|
+
sizeBytes: params.sizeBytes,
|
|
1406
|
+
sha256: params.sha256 ?? null,
|
|
1407
|
+
source: params.source
|
|
1408
|
+
};
|
|
1409
|
+
const [created] = await this.db.insert(documents).values(values).onConflictDoNothing().returning();
|
|
1410
|
+
return created;
|
|
1411
|
+
}
|
|
1412
|
+
async listByConversation(params) {
|
|
1413
|
+
const filters = [
|
|
1414
|
+
(0, import_drizzle_orm6.eq)(documents.companyId, params.companyId),
|
|
1415
|
+
(0, import_drizzle_orm6.eq)(documents.sessionId, params.sessionId)
|
|
1416
|
+
];
|
|
1417
|
+
if (params.search) filters.push((0, import_drizzle_orm6.ilike)(documents.filename, `%${params.search}%`));
|
|
1418
|
+
if (params.sources && params.sources.length > 0) {
|
|
1419
|
+
filters.push((0, import_drizzle_orm6.inArray)(documents.source, [
|
|
1420
|
+
...params.sources
|
|
1421
|
+
]));
|
|
1422
|
+
}
|
|
1423
|
+
const where = (0, import_drizzle_orm6.and)(...filters);
|
|
1424
|
+
const limit = params.limit ?? DEFAULT_LIMIT3;
|
|
1425
|
+
const page = params.page && params.page > 0 ? params.page : 1;
|
|
1426
|
+
const orderBy = params.sortDirection === "asc" ? (0, import_drizzle_orm6.asc)(documents.linkedAt) : (0, import_drizzle_orm6.desc)(documents.linkedAt);
|
|
1427
|
+
const [rows, counted] = await Promise.all([
|
|
1428
|
+
this.db.select().from(documents).where(where).orderBy(orderBy).limit(limit).offset((page - 1) * limit),
|
|
1429
|
+
this.db.select({
|
|
1430
|
+
value: (0, import_drizzle_orm6.count)()
|
|
1431
|
+
}).from(documents).where(where)
|
|
1432
|
+
]);
|
|
1433
|
+
return {
|
|
1434
|
+
rows,
|
|
1435
|
+
total: counted[0]?.value ?? 0
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
/**
|
|
1439
|
+
* A biblioteca da EMPRESA inteira, não de uma conversa.
|
|
1440
|
+
*
|
|
1441
|
+
* A busca casa nome do arquivo OU telefone da conversa — ver `companyDocumentSearch`.
|
|
1442
|
+
*
|
|
1443
|
+
* Faz join com `sessions` para carregar de qual conversa cada arquivo veio — numa lista global,
|
|
1444
|
+
* arquivo sem essa referência é inútil: o atendente vê "comprovante.pdf" e não sabe de quem.
|
|
1445
|
+
*
|
|
1446
|
+
* Ordena por `linkedAt` apoiada no índice `idx_documents_company_linked`, que já existia para a
|
|
1447
|
+
* varredura de retenção.
|
|
1448
|
+
*/
|
|
1449
|
+
async listByCompany(params) {
|
|
1450
|
+
const filters = [
|
|
1451
|
+
(0, import_drizzle_orm6.eq)(documents.companyId, params.companyId)
|
|
1452
|
+
];
|
|
1453
|
+
const search = companyDocumentSearch(params.search);
|
|
1454
|
+
if (search) filters.push(search);
|
|
1455
|
+
if (params.sources && params.sources.length > 0) {
|
|
1456
|
+
filters.push((0, import_drizzle_orm6.inArray)(documents.source, [
|
|
1457
|
+
...params.sources
|
|
1458
|
+
]));
|
|
1459
|
+
}
|
|
1460
|
+
const where = (0, import_drizzle_orm6.and)(...filters);
|
|
1461
|
+
const limit = params.limit ?? DEFAULT_LIMIT3;
|
|
1462
|
+
const page = params.page && params.page > 0 ? params.page : 1;
|
|
1463
|
+
const orderBy = params.sortDirection === "asc" ? (0, import_drizzle_orm6.asc)(documents.linkedAt) : (0, import_drizzle_orm6.desc)(documents.linkedAt);
|
|
1464
|
+
const [rows, counted] = await Promise.all([
|
|
1465
|
+
this.db.select({
|
|
1466
|
+
id: documents.id,
|
|
1467
|
+
uploadId: documents.uploadId,
|
|
1468
|
+
filename: documents.filename,
|
|
1469
|
+
mimeType: documents.mimeType,
|
|
1470
|
+
sizeBytes: documents.sizeBytes,
|
|
1471
|
+
source: documents.source,
|
|
1472
|
+
linkedAt: documents.linkedAt,
|
|
1473
|
+
// Só o número: o NOME do cliente é dado do produto (tabela própria dele), não do
|
|
1474
|
+
// módulo. Quem quiser exibir "Marina Alves" enriquece na borda.
|
|
1475
|
+
whatsappNumber: sessions.whatsappNumber
|
|
1476
|
+
}).from(documents).innerJoin(sessions, (0, import_drizzle_orm6.eq)(documents.sessionId, sessions.id)).where(where).orderBy(orderBy).limit(limit).offset((page - 1) * limit),
|
|
1477
|
+
// O mesmo join da listagem, e não só `from(documents)`: a busca pode citar
|
|
1478
|
+
// `sessions.whatsapp_number`, e uma contagem sem a tabela na cláusula não compila — pior,
|
|
1479
|
+
// se compilasse, o total divergiria das linhas e a paginação prometeria páginas vazias.
|
|
1480
|
+
this.db.select({
|
|
1481
|
+
value: (0, import_drizzle_orm6.count)()
|
|
1482
|
+
}).from(documents).innerJoin(sessions, (0, import_drizzle_orm6.eq)(documents.sessionId, sessions.id)).where(where)
|
|
1483
|
+
]);
|
|
1484
|
+
return {
|
|
1485
|
+
rows,
|
|
1486
|
+
total: counted[0]?.value ?? 0
|
|
1487
|
+
};
|
|
1488
|
+
}
|
|
1489
|
+
/**
|
|
1490
|
+
* Um documento pela key do objeto. Serve para recuperar o nome original na hora de assinar o
|
|
1491
|
+
* download: a key é caminho no bucket e salvaria o arquivo com o id da Meta.
|
|
1492
|
+
*/
|
|
1493
|
+
async findByUploadId(companyId, uploadId) {
|
|
1494
|
+
const [row] = await this.db.select().from(documents).where((0, import_drizzle_orm6.and)((0, import_drizzle_orm6.eq)(documents.companyId, companyId), (0, import_drizzle_orm6.eq)(documents.uploadId, uploadId))).limit(1);
|
|
1495
|
+
return row;
|
|
1496
|
+
}
|
|
1497
|
+
/**
|
|
1498
|
+
* Os objetos a apagar no storage antes de a linha sumir.
|
|
1499
|
+
*
|
|
1500
|
+
* Existe porque a cascata da FK apaga a linha e deixa o binário órfão: quem for apagar a conversa
|
|
1501
|
+
* precisa desta lista primeiro, senão paga armazenamento para sempre por arquivo inalcançável.
|
|
1502
|
+
*/
|
|
1503
|
+
async listUploadIdsBySession(companyId, sessionId) {
|
|
1504
|
+
const rows = await this.db.select({
|
|
1505
|
+
uploadId: documents.uploadId
|
|
1506
|
+
}).from(documents).where((0, import_drizzle_orm6.and)((0, import_drizzle_orm6.eq)(documents.companyId, companyId), (0, import_drizzle_orm6.eq)(documents.sessionId, sessionId)));
|
|
1507
|
+
return rows.map((row) => row.uploadId);
|
|
1508
|
+
}
|
|
1509
|
+
/** Varredura de retenção por idade — o par é o mesmo cuidado com o objeto no storage. */
|
|
1510
|
+
async listExpired(companyId, olderThan, limit = DEFAULT_LIMIT3) {
|
|
1511
|
+
return this.db.select().from(documents).where((0, import_drizzle_orm6.and)((0, import_drizzle_orm6.eq)(documents.companyId, companyId), (0, import_drizzle_orm6.lt)(documents.linkedAt, olderThan))).orderBy(documents.linkedAt).limit(limit);
|
|
1512
|
+
}
|
|
1513
|
+
async deleteById(companyId, id) {
|
|
1514
|
+
await this.db.delete(documents).where((0, import_drizzle_orm6.and)((0, import_drizzle_orm6.eq)(documents.companyId, companyId), (0, import_drizzle_orm6.eq)(documents.id, id)));
|
|
1515
|
+
}
|
|
1516
|
+
};
|
|
1517
|
+
|
|
1518
|
+
// src/use-cases/ExportConversation.use-case.ts
|
|
1519
|
+
var import_meta_whatsapp_contracts6 = require("@adatechnology/meta-whatsapp-contracts");
|
|
821
1520
|
var ExportConversationUseCase = class {
|
|
822
1521
|
static {
|
|
823
1522
|
__name(this, "ExportConversationUseCase");
|
|
@@ -828,7 +1527,7 @@ var ExportConversationUseCase = class {
|
|
|
828
1527
|
}
|
|
829
1528
|
async execute(params) {
|
|
830
1529
|
const result = await this.sessionRepository.exportConversation(params.companyId, params.whatsappNumber);
|
|
831
|
-
if (!result) throw new
|
|
1530
|
+
if (!result) throw new import_meta_whatsapp_contracts6.SessionNotFoundError(params.whatsappNumber);
|
|
832
1531
|
return result;
|
|
833
1532
|
}
|
|
834
1533
|
};
|
|
@@ -908,7 +1607,7 @@ var GetLiveFlowPositionsUseCase = class {
|
|
|
908
1607
|
};
|
|
909
1608
|
|
|
910
1609
|
// src/flows/FlowInterpreter.ts
|
|
911
|
-
var
|
|
1610
|
+
var import_meta_whatsapp_contracts7 = require("@adatechnology/meta-whatsapp-contracts");
|
|
912
1611
|
var DEFAULT_MAX_STEPS = 50;
|
|
913
1612
|
function evaluateCondition(node, context) {
|
|
914
1613
|
if (!node.conditionContextKey || !node.conditionOperator || !node.conditionValue) return false;
|
|
@@ -1009,9 +1708,9 @@ var FlowInterpreter = class {
|
|
|
1009
1708
|
kind: "terminal",
|
|
1010
1709
|
context: input.context
|
|
1011
1710
|
};
|
|
1012
|
-
if ((0,
|
|
1711
|
+
if ((0, import_meta_whatsapp_contracts7.isCrossFlowTarget)(nextNodeId)) return {
|
|
1013
1712
|
kind: "cross-flow",
|
|
1014
|
-
flowKey: (0,
|
|
1713
|
+
flowKey: (0, import_meta_whatsapp_contracts7.crossFlowKey)(nextNodeId),
|
|
1015
1714
|
context: input.context
|
|
1016
1715
|
};
|
|
1017
1716
|
if (!input.graph.nodes[nextNodeId]) return {
|
|
@@ -1065,22 +1764,174 @@ var FlowInterpreter = class {
|
|
|
1065
1764
|
}
|
|
1066
1765
|
};
|
|
1067
1766
|
|
|
1767
|
+
// src/flows/createSendMediaAction.ts
|
|
1768
|
+
function mediaTypeFor2(mimeType) {
|
|
1769
|
+
if (mimeType.startsWith("image/")) return "image";
|
|
1770
|
+
if (mimeType.startsWith("audio/")) return "audio";
|
|
1771
|
+
if (mimeType.startsWith("video/")) return "video";
|
|
1772
|
+
return "document";
|
|
1773
|
+
}
|
|
1774
|
+
__name(mediaTypeFor2, "mediaTypeFor");
|
|
1775
|
+
function createSendMediaAction(params) {
|
|
1776
|
+
return async ({ node, session, channel }) => {
|
|
1777
|
+
if (!session.flowKey) return;
|
|
1778
|
+
const location = {
|
|
1779
|
+
companyId: session.companyId,
|
|
1780
|
+
flowKey: session.flowKey,
|
|
1781
|
+
nodeId: node.id
|
|
1782
|
+
};
|
|
1783
|
+
const attachments = await params.flowMediaRepository.listActive(location);
|
|
1784
|
+
for (const attachment of attachments) {
|
|
1785
|
+
try {
|
|
1786
|
+
const buffer = await params.objectStorage.getObject(attachment.uploadId);
|
|
1787
|
+
const { externalMessageId } = await channel.sendMedia({
|
|
1788
|
+
to: session.whatsappNumber,
|
|
1789
|
+
buffer,
|
|
1790
|
+
mimeType: attachment.mimeType,
|
|
1791
|
+
filename: attachment.filename,
|
|
1792
|
+
caption: attachment.caption ?? void 0
|
|
1793
|
+
});
|
|
1794
|
+
await params.logMessage.execute({
|
|
1795
|
+
companyId: session.companyId,
|
|
1796
|
+
whatsappNumber: session.whatsappNumber,
|
|
1797
|
+
direction: "outbound",
|
|
1798
|
+
sender: "bot",
|
|
1799
|
+
agentUserId: null,
|
|
1800
|
+
type: mediaTypeFor2(attachment.mimeType),
|
|
1801
|
+
content: attachment.caption ?? attachment.filename,
|
|
1802
|
+
payload: {
|
|
1803
|
+
filename: attachment.filename,
|
|
1804
|
+
mimeType: attachment.mimeType,
|
|
1805
|
+
uploadId: attachment.uploadId,
|
|
1806
|
+
flowMediaId: attachment.id
|
|
1807
|
+
},
|
|
1808
|
+
waMessageId: externalMessageId,
|
|
1809
|
+
status: "sent",
|
|
1810
|
+
startState: params.startState
|
|
1811
|
+
});
|
|
1812
|
+
} catch (error) {
|
|
1813
|
+
params.onError?.(error, {
|
|
1814
|
+
flowKey: location.flowKey,
|
|
1815
|
+
nodeId: node.id,
|
|
1816
|
+
uploadId: attachment.uploadId
|
|
1817
|
+
});
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
};
|
|
1821
|
+
}
|
|
1822
|
+
__name(createSendMediaAction, "createSendMediaAction");
|
|
1823
|
+
|
|
1824
|
+
// src/repositories/FlowMediaRepository.ts
|
|
1825
|
+
var import_drizzle_orm7 = require("drizzle-orm");
|
|
1826
|
+
var FlowMediaRepository = class {
|
|
1827
|
+
static {
|
|
1828
|
+
__name(this, "FlowMediaRepository");
|
|
1829
|
+
}
|
|
1830
|
+
db;
|
|
1831
|
+
constructor(db) {
|
|
1832
|
+
this.db = db;
|
|
1833
|
+
}
|
|
1834
|
+
locationFilter(location) {
|
|
1835
|
+
return (0, import_drizzle_orm7.and)((0, import_drizzle_orm7.eq)(flowMedia.companyId, location.companyId), (0, import_drizzle_orm7.eq)(flowMedia.flowKey, location.flowKey), (0, import_drizzle_orm7.eq)(flowMedia.nodeId, location.nodeId));
|
|
1836
|
+
}
|
|
1837
|
+
// O que o nó realmente envia, na ordem de envio. `active` filtrado aqui e não no chamador:
|
|
1838
|
+
// é a razão de a coluna existir, e um chamador que esquecesse do filtro mandaria ao cliente
|
|
1839
|
+
// justamente o material que alguém desligou.
|
|
1840
|
+
async listActive(location) {
|
|
1841
|
+
return this.db.select().from(flowMedia).where((0, import_drizzle_orm7.and)(this.locationFilter(location), (0, import_drizzle_orm7.eq)(flowMedia.active, true))).orderBy((0, import_drizzle_orm7.asc)(flowMedia.sortOrder), (0, import_drizzle_orm7.asc)(flowMedia.createdAt));
|
|
1842
|
+
}
|
|
1843
|
+
// Inclui os desligados — é a visão do editor, onde desligar precisa continuar visível para
|
|
1844
|
+
// poder ser religado.
|
|
1845
|
+
async listAll(location) {
|
|
1846
|
+
return this.db.select().from(flowMedia).where(this.locationFilter(location)).orderBy((0, import_drizzle_orm7.asc)(flowMedia.sortOrder), (0, import_drizzle_orm7.asc)(flowMedia.createdAt));
|
|
1847
|
+
}
|
|
1848
|
+
/**
|
|
1849
|
+
* Anexa um arquivo já existente no storage ao nó.
|
|
1850
|
+
*
|
|
1851
|
+
* `onConflictDoUpdate` em vez de deixar estourar: reanexar o mesmo arquivo é clique repetido no
|
|
1852
|
+
* editor, e o esperado ali é atualizar a legenda/ordem — não um erro de índice único na cara de
|
|
1853
|
+
* quem está montando o fluxo.
|
|
1854
|
+
*/
|
|
1855
|
+
async attach(params) {
|
|
1856
|
+
const [row] = await this.db.insert(flowMedia).values({
|
|
1857
|
+
companyId: params.companyId,
|
|
1858
|
+
flowKey: params.flowKey,
|
|
1859
|
+
nodeId: params.nodeId,
|
|
1860
|
+
uploadId: params.uploadId,
|
|
1861
|
+
filename: params.filename,
|
|
1862
|
+
mimeType: params.mimeType,
|
|
1863
|
+
sizeBytes: params.sizeBytes,
|
|
1864
|
+
caption: params.caption ?? null,
|
|
1865
|
+
sortOrder: params.sortOrder ?? 0
|
|
1866
|
+
}).onConflictDoUpdate({
|
|
1867
|
+
target: [
|
|
1868
|
+
flowMedia.companyId,
|
|
1869
|
+
flowMedia.flowKey,
|
|
1870
|
+
flowMedia.nodeId,
|
|
1871
|
+
flowMedia.uploadId
|
|
1872
|
+
],
|
|
1873
|
+
set: {
|
|
1874
|
+
caption: params.caption ?? null,
|
|
1875
|
+
sortOrder: params.sortOrder ?? 0,
|
|
1876
|
+
active: true,
|
|
1877
|
+
updatedAt: import_drizzle_orm7.sql`now()`
|
|
1878
|
+
}
|
|
1879
|
+
}).returning();
|
|
1880
|
+
return row;
|
|
1881
|
+
}
|
|
1882
|
+
async update(params) {
|
|
1883
|
+
const [row] = await this.db.update(flowMedia).set({
|
|
1884
|
+
...params.caption !== void 0 ? {
|
|
1885
|
+
caption: params.caption
|
|
1886
|
+
} : {},
|
|
1887
|
+
...params.sortOrder !== void 0 ? {
|
|
1888
|
+
sortOrder: params.sortOrder
|
|
1889
|
+
} : {},
|
|
1890
|
+
...params.active !== void 0 ? {
|
|
1891
|
+
active: params.active
|
|
1892
|
+
} : {},
|
|
1893
|
+
updatedAt: import_drizzle_orm7.sql`now()`
|
|
1894
|
+
}).where((0, import_drizzle_orm7.and)((0, import_drizzle_orm7.eq)(flowMedia.companyId, params.companyId), (0, import_drizzle_orm7.eq)(flowMedia.id, params.id))).returning();
|
|
1895
|
+
return row;
|
|
1896
|
+
}
|
|
1897
|
+
/**
|
|
1898
|
+
* Desanexa do nó. Não toca no storage de propósito: o mesmo `uploadId` pode estar anexado a
|
|
1899
|
+
* outro nó ou a outro fluxo, e apagar o binário aqui quebraria os demais. Quem apaga objeto é o
|
|
1900
|
+
* host, que é dono da biblioteca de arquivos.
|
|
1901
|
+
*/
|
|
1902
|
+
async detach(params) {
|
|
1903
|
+
await this.db.delete(flowMedia).where((0, import_drizzle_orm7.and)((0, import_drizzle_orm7.eq)(flowMedia.companyId, params.companyId), (0, import_drizzle_orm7.eq)(flowMedia.id, params.id)));
|
|
1904
|
+
}
|
|
1905
|
+
// Chamado ao salvar o grafo: nós apagados no editor deixam linhas que nada mais alcança.
|
|
1906
|
+
async detachRemovedNodes(params) {
|
|
1907
|
+
const condition = params.existingNodeIds.length === 0 ? void 0 : import_drizzle_orm7.sql`${flowMedia.nodeId} NOT IN ${params.existingNodeIds}`;
|
|
1908
|
+
await this.db.delete(flowMedia).where((0, import_drizzle_orm7.and)((0, import_drizzle_orm7.eq)(flowMedia.companyId, params.companyId), (0, import_drizzle_orm7.eq)(flowMedia.flowKey, params.flowKey), condition));
|
|
1909
|
+
}
|
|
1910
|
+
};
|
|
1911
|
+
|
|
1068
1912
|
// src/channel/WhatsAppChannelAdapter.ts
|
|
1069
1913
|
var import_meta_graph_core = require("@adatechnology/meta-graph-core");
|
|
1070
|
-
var
|
|
1914
|
+
var import_meta_whatsapp_contracts9 = require("@adatechnology/meta-whatsapp-contracts");
|
|
1915
|
+
|
|
1916
|
+
// src/channel/previewMedia.ts
|
|
1917
|
+
var import_meta_whatsapp_contracts8 = require("@adatechnology/meta-whatsapp-contracts");
|
|
1918
|
+
|
|
1919
|
+
// src/channel/WhatsAppChannelAdapter.ts
|
|
1071
1920
|
var WhatsAppChannelAdapter = class {
|
|
1072
1921
|
static {
|
|
1073
1922
|
__name(this, "WhatsAppChannelAdapter");
|
|
1074
1923
|
}
|
|
1075
1924
|
messages;
|
|
1076
|
-
|
|
1925
|
+
previewMedia;
|
|
1926
|
+
constructor(messages2, previewMedia) {
|
|
1077
1927
|
this.messages = messages2;
|
|
1928
|
+
this.previewMedia = previewMedia;
|
|
1078
1929
|
}
|
|
1079
1930
|
async translateErrors(operation) {
|
|
1080
1931
|
try {
|
|
1081
1932
|
return await operation();
|
|
1082
1933
|
} catch (error) {
|
|
1083
|
-
if (error instanceof import_meta_graph_core.WhatsAppWindowExpiredError) throw new
|
|
1934
|
+
if (error instanceof import_meta_graph_core.WhatsAppWindowExpiredError) throw new import_meta_whatsapp_contracts9.WindowExpiredError();
|
|
1084
1935
|
throw error;
|
|
1085
1936
|
}
|
|
1086
1937
|
}
|
|
@@ -1118,17 +1969,31 @@ var WhatsAppChannelAdapter = class {
|
|
|
1118
1969
|
externalMessageId: result.waMessageId
|
|
1119
1970
|
};
|
|
1120
1971
|
}
|
|
1972
|
+
/**
|
|
1973
|
+
* Busca o binário da mídia — da Meta, ou do storage quando o id é do simulador.
|
|
1974
|
+
*
|
|
1975
|
+
* O desvio acontece ANTES de qualquer chamada de rede: id do simulador não existe na Meta, e
|
|
1976
|
+
* tentar buscá-lo lá renderia um 404 confuso em vez do áudio que o operador acabou de gravar.
|
|
1977
|
+
*/
|
|
1121
1978
|
async fetchMediaAsBase64(mediaId) {
|
|
1979
|
+
const uploadId = this.previewMedia?.isEnabled ? (0, import_meta_whatsapp_contracts8.resolvePreviewUploadId)(mediaId) : void 0;
|
|
1980
|
+
if (uploadId) {
|
|
1981
|
+
const buffer = await this.previewMedia.objectStorage.getObject(uploadId);
|
|
1982
|
+
return {
|
|
1983
|
+
data: buffer.toString("base64"),
|
|
1984
|
+
mimeType: this.previewMedia.defaultMimeType ?? "audio/ogg"
|
|
1985
|
+
};
|
|
1986
|
+
}
|
|
1122
1987
|
return this.translateErrors(() => this.messages.fetchMediaAsBase64(mediaId));
|
|
1123
1988
|
}
|
|
1124
1989
|
};
|
|
1125
1990
|
|
|
1126
1991
|
// src/channel/ReceiveWebhook.use-case.ts
|
|
1127
|
-
var
|
|
1992
|
+
var import_meta_whatsapp_contracts12 = require("@adatechnology/meta-whatsapp-contracts");
|
|
1128
1993
|
|
|
1129
1994
|
// src/channel/webhookSecurity.ts
|
|
1130
1995
|
var import_node_crypto = require("crypto");
|
|
1131
|
-
var
|
|
1996
|
+
var import_meta_whatsapp_contracts10 = require("@adatechnology/meta-whatsapp-contracts");
|
|
1132
1997
|
var WEBHOOK_NONCE_TTL_SECONDS = 300;
|
|
1133
1998
|
function safeEqualStrings(left, right) {
|
|
1134
1999
|
const leftDigest = (0, import_node_crypto.createHmac)("sha256", "constant-time-compare").update(left).digest();
|
|
@@ -1138,20 +2003,20 @@ function safeEqualStrings(left, right) {
|
|
|
1138
2003
|
__name(safeEqualStrings, "safeEqualStrings");
|
|
1139
2004
|
function verifyWebhookChallenge(params) {
|
|
1140
2005
|
if (params.mode !== "subscribe" || !params.token || !params.challenge) {
|
|
1141
|
-
throw new
|
|
2006
|
+
throw new import_meta_whatsapp_contracts10.InvalidWebhookSignatureError();
|
|
1142
2007
|
}
|
|
1143
2008
|
if (!safeEqualStrings(params.token, params.expectedToken)) {
|
|
1144
|
-
throw new
|
|
2009
|
+
throw new import_meta_whatsapp_contracts10.InvalidWebhookSignatureError();
|
|
1145
2010
|
}
|
|
1146
2011
|
return params.challenge;
|
|
1147
2012
|
}
|
|
1148
2013
|
__name(verifyWebhookChallenge, "verifyWebhookChallenge");
|
|
1149
2014
|
function verifyWebhookSignature(params) {
|
|
1150
2015
|
const { rawBody, signatureHeader, appSecret } = params;
|
|
1151
|
-
if (!signatureHeader?.startsWith("sha256=")) throw new
|
|
2016
|
+
if (!signatureHeader?.startsWith("sha256=")) throw new import_meta_whatsapp_contracts10.InvalidWebhookSignatureError();
|
|
1152
2017
|
const expected = (0, import_node_crypto.createHmac)("sha256", appSecret).update(rawBody).digest("hex");
|
|
1153
2018
|
const received = signatureHeader.slice("sha256=".length);
|
|
1154
|
-
if (!safeEqualStrings(received, expected)) throw new
|
|
2019
|
+
if (!safeEqualStrings(received, expected)) throw new import_meta_whatsapp_contracts10.InvalidWebhookSignatureError();
|
|
1155
2020
|
}
|
|
1156
2021
|
__name(verifyWebhookSignature, "verifyWebhookSignature");
|
|
1157
2022
|
async function claimWebhookDelivery(params) {
|
|
@@ -1160,6 +2025,325 @@ async function claimWebhookDelivery(params) {
|
|
|
1160
2025
|
}
|
|
1161
2026
|
__name(claimWebhookDelivery, "claimWebhookDelivery");
|
|
1162
2027
|
|
|
2028
|
+
// src/channel/IngestInboundMedia.use-case.ts
|
|
2029
|
+
var import_drizzle_orm8 = require("drizzle-orm");
|
|
2030
|
+
|
|
2031
|
+
// src/use-cases/TranscribeAudio.use-case.ts
|
|
2032
|
+
var import_meta_whatsapp_contracts11 = require("@adatechnology/meta-whatsapp-contracts");
|
|
2033
|
+
|
|
2034
|
+
// src/transcription.types.ts
|
|
2035
|
+
var TRANSCRIPTION_STATUS = {
|
|
2036
|
+
/** Falhou de forma retriável (cota, rede, 5xx) — vai sair quando alguém tentar de novo. */
|
|
2037
|
+
PENDING: "pending",
|
|
2038
|
+
/** Processado. Texto vazio aqui é áudio em silêncio, e NÃO deve ser reprocessado. */
|
|
2039
|
+
DONE: "done",
|
|
2040
|
+
/** Falha definitiva do engine (credencial, áudio corrompido, arquivo grande demais). */
|
|
2041
|
+
FAILED: "failed",
|
|
2042
|
+
/** Nenhum engine da cadeia aceita o formato. Retentar não conserta codec. */
|
|
2043
|
+
UNSUPPORTED: "unsupported"
|
|
2044
|
+
};
|
|
2045
|
+
var TRANSCRIPTION_MODE = {
|
|
2046
|
+
AUTO: "auto",
|
|
2047
|
+
ON_DEMAND: "onDemand"
|
|
2048
|
+
};
|
|
2049
|
+
function isRetriableTranscriptionError(error) {
|
|
2050
|
+
if (typeof error !== "object" || error === null) return true;
|
|
2051
|
+
const isRetriable = error.isRetriable;
|
|
2052
|
+
return typeof isRetriable === "boolean" ? isRetriable : true;
|
|
2053
|
+
}
|
|
2054
|
+
__name(isRetriableTranscriptionError, "isRetriableTranscriptionError");
|
|
2055
|
+
function isUnsupportedTranscriptionError(error) {
|
|
2056
|
+
return typeof error === "object" && error !== null && error.name === "TranscriptionUnsupportedError";
|
|
2057
|
+
}
|
|
2058
|
+
__name(isUnsupportedTranscriptionError, "isUnsupportedTranscriptionError");
|
|
2059
|
+
function transcriptionRetryAfterSeconds(error) {
|
|
2060
|
+
if (typeof error !== "object" || error === null) return void 0;
|
|
2061
|
+
const retryAfter = error.retryAfterSeconds;
|
|
2062
|
+
return typeof retryAfter === "number" ? retryAfter : void 0;
|
|
2063
|
+
}
|
|
2064
|
+
__name(transcriptionRetryAfterSeconds, "transcriptionRetryAfterSeconds");
|
|
2065
|
+
function isAudioMimeType(mimeType) {
|
|
2066
|
+
return typeof mimeType === "string" && mimeType.trim().toLowerCase().startsWith("audio/");
|
|
2067
|
+
}
|
|
2068
|
+
__name(isAudioMimeType, "isAudioMimeType");
|
|
2069
|
+
|
|
2070
|
+
// src/use-cases/TranscribeAudio.use-case.ts
|
|
2071
|
+
var TranscribeAudioUseCase = class {
|
|
2072
|
+
static {
|
|
2073
|
+
__name(this, "TranscribeAudioUseCase");
|
|
2074
|
+
}
|
|
2075
|
+
dependencies;
|
|
2076
|
+
constructor(dependencies) {
|
|
2077
|
+
this.dependencies = dependencies;
|
|
2078
|
+
}
|
|
2079
|
+
async execute(params) {
|
|
2080
|
+
const message = await this.dependencies.messageRepository.findById(params.companyId, params.messageId);
|
|
2081
|
+
if (!message) throw new Error(`Mensagem ${params.messageId} n\xE3o encontrada para transcri\xE7\xE3o`);
|
|
2082
|
+
if (message.transcriptionStatus === TRANSCRIPTION_STATUS.DONE && !params.force) {
|
|
2083
|
+
return {
|
|
2084
|
+
status: TRANSCRIPTION_STATUS.DONE,
|
|
2085
|
+
text: message.transcriptionText,
|
|
2086
|
+
language: message.transcriptionLanguage,
|
|
2087
|
+
engine: message.transcriptionEngine,
|
|
2088
|
+
alreadyTranscribed: true
|
|
2089
|
+
};
|
|
2090
|
+
}
|
|
2091
|
+
if (this.dependencies.resolvePolicy) {
|
|
2092
|
+
const policy = await this.dependencies.resolvePolicy(params.companyId);
|
|
2093
|
+
if (!policy.isEnabled) throw new import_meta_whatsapp_contracts11.TranscriptionDisabledError();
|
|
2094
|
+
}
|
|
2095
|
+
const audio = extractAudioReference(message);
|
|
2096
|
+
const buffer = await this.dependencies.objectStorage.getObject(audio.uploadId);
|
|
2097
|
+
return this.transcribeBuffer({
|
|
2098
|
+
...params,
|
|
2099
|
+
buffer,
|
|
2100
|
+
mimeType: audio.mimeType,
|
|
2101
|
+
uploadId: audio.uploadId,
|
|
2102
|
+
message
|
|
2103
|
+
});
|
|
2104
|
+
}
|
|
2105
|
+
async transcribeBuffer(context) {
|
|
2106
|
+
try {
|
|
2107
|
+
const result = await this.dependencies.transcriber.transcribe({
|
|
2108
|
+
buffer: context.buffer,
|
|
2109
|
+
mimeType: context.mimeType,
|
|
2110
|
+
...this.dependencies.languageHint ? {
|
|
2111
|
+
languageHint: this.dependencies.languageHint
|
|
2112
|
+
} : {}
|
|
2113
|
+
});
|
|
2114
|
+
await this.dependencies.messageRepository.saveTranscription({
|
|
2115
|
+
companyId: context.companyId,
|
|
2116
|
+
messageId: context.messageId,
|
|
2117
|
+
status: TRANSCRIPTION_STATUS.DONE,
|
|
2118
|
+
text: result.text,
|
|
2119
|
+
language: result.language ?? null,
|
|
2120
|
+
engine: result.engine
|
|
2121
|
+
});
|
|
2122
|
+
return {
|
|
2123
|
+
status: TRANSCRIPTION_STATUS.DONE,
|
|
2124
|
+
text: result.text,
|
|
2125
|
+
language: result.language ?? null,
|
|
2126
|
+
engine: result.engine,
|
|
2127
|
+
alreadyTranscribed: false
|
|
2128
|
+
};
|
|
2129
|
+
} catch (error) {
|
|
2130
|
+
await this.persistFailure(context, error);
|
|
2131
|
+
throw error;
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
/**
|
|
2135
|
+
* Carimba o motivo antes de propagar. O status é o que impede os dois desperdícios simétricos:
|
|
2136
|
+
* reprocessar para sempre um codec impossível, e desistir de um áudio que só esbarrou na cota.
|
|
2137
|
+
*/
|
|
2138
|
+
async persistFailure(context, error) {
|
|
2139
|
+
const status = resolveFailureStatus(error);
|
|
2140
|
+
await this.dependencies.messageRepository.saveTranscription({
|
|
2141
|
+
companyId: context.companyId,
|
|
2142
|
+
messageId: context.messageId,
|
|
2143
|
+
status
|
|
2144
|
+
});
|
|
2145
|
+
if (status !== TRANSCRIPTION_STATUS.PENDING) return;
|
|
2146
|
+
const retryAfterSeconds = transcriptionRetryAfterSeconds(error);
|
|
2147
|
+
await this.dependencies.hooks?.onTranscriptionDeferred?.({
|
|
2148
|
+
companyId: context.companyId,
|
|
2149
|
+
messageId: context.messageId,
|
|
2150
|
+
whatsappNumber: context.message.whatsappNumber,
|
|
2151
|
+
uploadId: context.uploadId,
|
|
2152
|
+
...retryAfterSeconds !== void 0 ? {
|
|
2153
|
+
retryAfterSeconds
|
|
2154
|
+
} : {},
|
|
2155
|
+
reason: retryAfterSeconds !== void 0 ? "rate-limited" : "transient-failure",
|
|
2156
|
+
error
|
|
2157
|
+
});
|
|
2158
|
+
}
|
|
2159
|
+
};
|
|
2160
|
+
function resolveFailureStatus(error) {
|
|
2161
|
+
if (isUnsupportedTranscriptionError(error)) return TRANSCRIPTION_STATUS.UNSUPPORTED;
|
|
2162
|
+
return isRetriableTranscriptionError(error) ? TRANSCRIPTION_STATUS.PENDING : TRANSCRIPTION_STATUS.FAILED;
|
|
2163
|
+
}
|
|
2164
|
+
__name(resolveFailureStatus, "resolveFailureStatus");
|
|
2165
|
+
function extractAudioReference(message) {
|
|
2166
|
+
const payload = message.payload ?? {};
|
|
2167
|
+
const audio = payload["audio"];
|
|
2168
|
+
const mimeType = typeof payload["mimeType"] === "string" ? payload["mimeType"] : audio?.mime_type;
|
|
2169
|
+
if (!isAudioMimeType(mimeType) && !audio) {
|
|
2170
|
+
throw new import_meta_whatsapp_contracts11.MessageNotAudioError(message.id, message.type);
|
|
2171
|
+
}
|
|
2172
|
+
const uploadId = payload["uploadId"];
|
|
2173
|
+
if (typeof uploadId !== "string" || uploadId.length === 0) {
|
|
2174
|
+
throw new import_meta_whatsapp_contracts11.AudioNotIngestedError(message.id);
|
|
2175
|
+
}
|
|
2176
|
+
return {
|
|
2177
|
+
uploadId,
|
|
2178
|
+
mimeType: mimeType ?? "audio/ogg"
|
|
2179
|
+
};
|
|
2180
|
+
}
|
|
2181
|
+
__name(extractAudioReference, "extractAudioReference");
|
|
2182
|
+
|
|
2183
|
+
// src/channel/IngestInboundMedia.use-case.ts
|
|
2184
|
+
var IngestInboundMediaUseCase = class {
|
|
2185
|
+
static {
|
|
2186
|
+
__name(this, "IngestInboundMediaUseCase");
|
|
2187
|
+
}
|
|
2188
|
+
db;
|
|
2189
|
+
channel;
|
|
2190
|
+
objectStorage;
|
|
2191
|
+
documentRepository;
|
|
2192
|
+
transcription;
|
|
2193
|
+
constructor(db, channel, objectStorage, documentRepository, transcription) {
|
|
2194
|
+
this.db = db;
|
|
2195
|
+
this.channel = channel;
|
|
2196
|
+
this.objectStorage = objectStorage;
|
|
2197
|
+
this.documentRepository = documentRepository;
|
|
2198
|
+
this.transcription = transcription;
|
|
2199
|
+
}
|
|
2200
|
+
async execute(params) {
|
|
2201
|
+
const [message] = await this.db.select().from(messages).where((0, import_drizzle_orm8.and)((0, import_drizzle_orm8.eq)(messages.companyId, params.companyId), (0, import_drizzle_orm8.eq)(messages.id, params.messageId))).limit(1);
|
|
2202
|
+
if (!message) throw new Error(`Mensagem ${params.messageId} n\xE3o encontrada para ingest\xE3o de m\xEDdia`);
|
|
2203
|
+
const payload = message.payload ?? {};
|
|
2204
|
+
if (payload["uploadId"] && payload["sourceMediaId"] === params.sourceMediaId) {
|
|
2205
|
+
return {
|
|
2206
|
+
uploadId: String(payload["uploadId"]),
|
|
2207
|
+
alreadyIngested: true
|
|
2208
|
+
};
|
|
2209
|
+
}
|
|
2210
|
+
const { data, mimeType } = await this.channel.fetchMediaAsBase64(params.sourceMediaId);
|
|
2211
|
+
const buffer = Buffer.from(data, "base64");
|
|
2212
|
+
const { uploadId } = await this.objectStorage.upload({
|
|
2213
|
+
buffer,
|
|
2214
|
+
mimeType: mimeType || params.mimeType,
|
|
2215
|
+
key: `meta-whatsapp/${params.companyId}/inbound/${params.sourceMediaId}`
|
|
2216
|
+
});
|
|
2217
|
+
const updatedPayload = {
|
|
2218
|
+
...payload,
|
|
2219
|
+
uploadId,
|
|
2220
|
+
sourceMediaId: params.sourceMediaId,
|
|
2221
|
+
mimeType: mimeType || params.mimeType,
|
|
2222
|
+
// O tamanho já está na mão (é o buffer que acabou de ser copiado) e a bolha de documento o
|
|
2223
|
+
// exibe ao lado do tipo. Sem gravar aqui, a UI mostraria "PDF" sem o "· 180 KB", e buscá-lo
|
|
2224
|
+
// depois custaria uma consulta à tabela de documentos por mensagem renderizada.
|
|
2225
|
+
sizeBytes: buffer.length,
|
|
2226
|
+
...params.filename ? {
|
|
2227
|
+
filename: params.filename
|
|
2228
|
+
} : {}
|
|
2229
|
+
};
|
|
2230
|
+
await this.db.update(messages).set({
|
|
2231
|
+
payload: updatedPayload
|
|
2232
|
+
}).where((0, import_drizzle_orm8.and)((0, import_drizzle_orm8.eq)(messages.companyId, params.companyId), (0, import_drizzle_orm8.eq)(messages.id, params.messageId)));
|
|
2233
|
+
await this.documentRepository?.link({
|
|
2234
|
+
companyId: params.companyId,
|
|
2235
|
+
sessionId: message.sessionId,
|
|
2236
|
+
messageId: message.id,
|
|
2237
|
+
uploadId,
|
|
2238
|
+
// Áudio e sticker chegam sem nome; sem um rótulo o painel mostraria linha vazia.
|
|
2239
|
+
filename: params.filename ?? `${params.sourceMediaId}`,
|
|
2240
|
+
mimeType: mimeType || params.mimeType,
|
|
2241
|
+
sizeBytes: buffer.length,
|
|
2242
|
+
source: message.sender
|
|
2243
|
+
});
|
|
2244
|
+
const transcription = await this.transcribeIfAuto({
|
|
2245
|
+
companyId: params.companyId,
|
|
2246
|
+
message,
|
|
2247
|
+
uploadId,
|
|
2248
|
+
buffer,
|
|
2249
|
+
mimeType: mimeType || params.mimeType
|
|
2250
|
+
});
|
|
2251
|
+
return {
|
|
2252
|
+
uploadId,
|
|
2253
|
+
alreadyIngested: false,
|
|
2254
|
+
...transcription ? {
|
|
2255
|
+
transcription
|
|
2256
|
+
} : {}
|
|
2257
|
+
};
|
|
2258
|
+
}
|
|
2259
|
+
/**
|
|
2260
|
+
* Transcreve o áudio recém-baixado, quando o modo é `auto`.
|
|
2261
|
+
*
|
|
2262
|
+
* **Nunca propaga erro.** Neste ponto o binário já está no storage e já entrou na biblioteca da
|
|
2263
|
+
* conversa: deixar uma falha de transcrição subir marcaria a ingestão inteira como falha, e o
|
|
2264
|
+
* retry do host baixaria de novo da Meta um arquivo que está salvo — gastando banda para reproduzir
|
|
2265
|
+
* um efeito que já aconteceu. O status fica gravado na mensagem e o `onTranscriptionDeferred`
|
|
2266
|
+
* avisa quem sabe reenfileirar.
|
|
2267
|
+
*/
|
|
2268
|
+
async transcribeIfAuto(context) {
|
|
2269
|
+
const transcription = this.transcription;
|
|
2270
|
+
if (!transcription) return void 0;
|
|
2271
|
+
if (!isAudioMimeType(context.mimeType)) return void 0;
|
|
2272
|
+
const policy = await transcription.resolvePolicy(context.companyId);
|
|
2273
|
+
if (!policy.isEnabled || policy.mode !== TRANSCRIPTION_MODE.AUTO) return void 0;
|
|
2274
|
+
const current = await transcription.messageRepository.findById(context.companyId, context.message.id);
|
|
2275
|
+
if (current?.transcriptionStatus === TRANSCRIPTION_STATUS.DONE) return void 0;
|
|
2276
|
+
try {
|
|
2277
|
+
const result = await transcription.transcriber.transcribe({
|
|
2278
|
+
buffer: context.buffer,
|
|
2279
|
+
mimeType: context.mimeType,
|
|
2280
|
+
...transcription.languageHint ? {
|
|
2281
|
+
languageHint: transcription.languageHint
|
|
2282
|
+
} : {}
|
|
2283
|
+
});
|
|
2284
|
+
await transcription.messageRepository.saveTranscription({
|
|
2285
|
+
companyId: context.companyId,
|
|
2286
|
+
messageId: context.message.id,
|
|
2287
|
+
status: TRANSCRIPTION_STATUS.DONE,
|
|
2288
|
+
text: result.text,
|
|
2289
|
+
language: result.language ?? null,
|
|
2290
|
+
engine: result.engine
|
|
2291
|
+
});
|
|
2292
|
+
return {
|
|
2293
|
+
status: TRANSCRIPTION_STATUS.DONE
|
|
2294
|
+
};
|
|
2295
|
+
} catch (error) {
|
|
2296
|
+
return this.recordTranscriptionFailure(context, error);
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
async recordTranscriptionFailure(context, error) {
|
|
2300
|
+
const status = resolveFailureStatus(error);
|
|
2301
|
+
await this.transcription?.messageRepository.saveTranscription({
|
|
2302
|
+
companyId: context.companyId,
|
|
2303
|
+
messageId: context.message.id,
|
|
2304
|
+
status
|
|
2305
|
+
});
|
|
2306
|
+
if (status === TRANSCRIPTION_STATUS.PENDING) {
|
|
2307
|
+
const retryAfterSeconds = transcriptionRetryAfterSeconds(error);
|
|
2308
|
+
await this.transcription?.hooks?.onTranscriptionDeferred?.({
|
|
2309
|
+
companyId: context.companyId,
|
|
2310
|
+
messageId: context.message.id,
|
|
2311
|
+
whatsappNumber: context.message.whatsappNumber,
|
|
2312
|
+
uploadId: context.uploadId,
|
|
2313
|
+
...retryAfterSeconds !== void 0 ? {
|
|
2314
|
+
retryAfterSeconds
|
|
2315
|
+
} : {},
|
|
2316
|
+
reason: retryAfterSeconds !== void 0 ? "rate-limited" : "transient-failure",
|
|
2317
|
+
error
|
|
2318
|
+
});
|
|
2319
|
+
}
|
|
2320
|
+
return {
|
|
2321
|
+
status
|
|
2322
|
+
};
|
|
2323
|
+
}
|
|
2324
|
+
};
|
|
2325
|
+
function extractMediaDescriptor(message) {
|
|
2326
|
+
const payload = message.payload ?? {};
|
|
2327
|
+
for (const key of [
|
|
2328
|
+
"image",
|
|
2329
|
+
"audio",
|
|
2330
|
+
"video",
|
|
2331
|
+
"document",
|
|
2332
|
+
"sticker"
|
|
2333
|
+
]) {
|
|
2334
|
+
const media = payload[key];
|
|
2335
|
+
if (media?.id) {
|
|
2336
|
+
return {
|
|
2337
|
+
sourceMediaId: media.id,
|
|
2338
|
+
mimeType: media.mime_type ?? "application/octet-stream",
|
|
2339
|
+
filename: media.filename
|
|
2340
|
+
};
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
return void 0;
|
|
2344
|
+
}
|
|
2345
|
+
__name(extractMediaDescriptor, "extractMediaDescriptor");
|
|
2346
|
+
|
|
1163
2347
|
// src/channel/ReceiveWebhook.use-case.ts
|
|
1164
2348
|
function toSessionContract(row) {
|
|
1165
2349
|
return {
|
|
@@ -1167,6 +2351,8 @@ function toSessionContract(row) {
|
|
|
1167
2351
|
companyId: row.companyId,
|
|
1168
2352
|
whatsappNumber: row.whatsappNumber,
|
|
1169
2353
|
currentState: row.currentState,
|
|
2354
|
+
flowKey: row.flowKey,
|
|
2355
|
+
currentNodeId: row.currentNodeId,
|
|
1170
2356
|
context: row.context,
|
|
1171
2357
|
mode: row.mode,
|
|
1172
2358
|
assignedUserId: row.assignedUserId,
|
|
@@ -1232,14 +2418,21 @@ var ReceiveWebhookUseCase = class {
|
|
|
1232
2418
|
if (!claimed) return {
|
|
1233
2419
|
duplicate: true,
|
|
1234
2420
|
messagesProcessed: 0,
|
|
1235
|
-
statusesProcessed: 0
|
|
2421
|
+
statusesProcessed: 0,
|
|
2422
|
+
ignoredForeignNumber: 0
|
|
1236
2423
|
};
|
|
1237
2424
|
const rawText = typeof input.rawBody === "string" ? input.rawBody : input.rawBody.toString("utf8");
|
|
1238
|
-
const payload =
|
|
2425
|
+
const payload = import_meta_whatsapp_contracts12.whatsAppWebhookPayloadSchema.parse(JSON.parse(rawText));
|
|
1239
2426
|
let messagesProcessed = 0;
|
|
1240
2427
|
let statusesProcessed = 0;
|
|
2428
|
+
let ignoredForeignNumber = 0;
|
|
1241
2429
|
for (const entry of payload.entry) {
|
|
1242
2430
|
for (const change of entry.changes) {
|
|
2431
|
+
const targetNumber = change.value.metadata?.phone_number_id;
|
|
2432
|
+
if (targetNumber && targetNumber !== this.params.phoneNumberId) {
|
|
2433
|
+
ignoredForeignNumber++;
|
|
2434
|
+
continue;
|
|
2435
|
+
}
|
|
1243
2436
|
for (const message of change.value.messages ?? []) {
|
|
1244
2437
|
await this.handleMessage(input.companyId, message);
|
|
1245
2438
|
messagesProcessed++;
|
|
@@ -1253,7 +2446,8 @@ var ReceiveWebhookUseCase = class {
|
|
|
1253
2446
|
return {
|
|
1254
2447
|
duplicate: false,
|
|
1255
2448
|
messagesProcessed,
|
|
1256
|
-
statusesProcessed
|
|
2449
|
+
statusesProcessed,
|
|
2450
|
+
ignoredForeignNumber
|
|
1257
2451
|
};
|
|
1258
2452
|
}
|
|
1259
2453
|
async handleMessage(companyId, message) {
|
|
@@ -1270,6 +2464,19 @@ var ReceiveWebhookUseCase = class {
|
|
|
1270
2464
|
startState: this.params.startState
|
|
1271
2465
|
});
|
|
1272
2466
|
if (!saved) return;
|
|
2467
|
+
const media = extractMediaDescriptor(saved);
|
|
2468
|
+
if (media) {
|
|
2469
|
+
await this.params.hooks?.onMediaReceived?.({
|
|
2470
|
+
companyId,
|
|
2471
|
+
messageId: saved.id,
|
|
2472
|
+
whatsappNumber: message.from,
|
|
2473
|
+
sourceMediaId: media.sourceMediaId,
|
|
2474
|
+
mimeType: media.mimeType,
|
|
2475
|
+
...media.filename ? {
|
|
2476
|
+
filename: media.filename
|
|
2477
|
+
} : {}
|
|
2478
|
+
});
|
|
2479
|
+
}
|
|
1273
2480
|
const sessionRow = await this.params.sessionRepository.getContext(companyId, message.from);
|
|
1274
2481
|
if (!sessionRow) return;
|
|
1275
2482
|
if (sessionRow.mode === "human") return;
|
|
@@ -1289,76 +2496,50 @@ var ReceiveWebhookUseCase = class {
|
|
|
1289
2496
|
}
|
|
1290
2497
|
};
|
|
1291
2498
|
|
|
1292
|
-
// src/
|
|
1293
|
-
|
|
1294
|
-
|
|
2499
|
+
// src/use-cases/resolveTranscriptionPolicy.ts
|
|
2500
|
+
function createTranscriptionPolicyResolver(dependencies) {
|
|
2501
|
+
return /* @__PURE__ */ __name(async function resolveTranscriptionPolicy(companyId) {
|
|
2502
|
+
const settings2 = await dependencies.settingsRepository.get(companyId);
|
|
2503
|
+
return {
|
|
2504
|
+
// `??` e não `||`: `false` gravado é decisão explícita de desligar, e `||` a trocaria pelo
|
|
2505
|
+
// padrão do host — desligar no painel não faria nada num deploy com transcrição ligada.
|
|
2506
|
+
isEnabled: settings2.transcriptionEnabled ?? dependencies.defaults.isEnabled,
|
|
2507
|
+
mode: normalizeMode(settings2.transcriptionMode) ?? dependencies.defaults.mode
|
|
2508
|
+
};
|
|
2509
|
+
}, "resolveTranscriptionPolicy");
|
|
2510
|
+
}
|
|
2511
|
+
__name(createTranscriptionPolicyResolver, "createTranscriptionPolicyResolver");
|
|
2512
|
+
function normalizeMode(value) {
|
|
2513
|
+
if (value === TRANSCRIPTION_MODE.AUTO) return TRANSCRIPTION_MODE.AUTO;
|
|
2514
|
+
if (value === TRANSCRIPTION_MODE.ON_DEMAND) return TRANSCRIPTION_MODE.ON_DEMAND;
|
|
2515
|
+
return void 0;
|
|
2516
|
+
}
|
|
2517
|
+
__name(normalizeMode, "normalizeMode");
|
|
2518
|
+
|
|
2519
|
+
// src/use-cases/StorePreviewMedia.use-case.ts
|
|
2520
|
+
var StorePreviewMediaUseCase = class {
|
|
1295
2521
|
static {
|
|
1296
|
-
__name(this, "
|
|
2522
|
+
__name(this, "StorePreviewMediaUseCase");
|
|
1297
2523
|
}
|
|
1298
|
-
db;
|
|
1299
|
-
channel;
|
|
1300
2524
|
objectStorage;
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
this.channel = channel;
|
|
2525
|
+
generateKeySuffix;
|
|
2526
|
+
constructor(objectStorage, generateKeySuffix) {
|
|
1304
2527
|
this.objectStorage = objectStorage;
|
|
2528
|
+
this.generateKeySuffix = generateKeySuffix;
|
|
1305
2529
|
}
|
|
1306
2530
|
async execute(params) {
|
|
1307
|
-
const
|
|
1308
|
-
if (!message) throw new Error(`Mensagem ${params.messageId} n\xE3o encontrada para ingest\xE3o de m\xEDdia`);
|
|
1309
|
-
const payload = message.payload ?? {};
|
|
1310
|
-
if (payload["uploadId"] && payload["sourceMediaId"] === params.sourceMediaId) {
|
|
1311
|
-
return {
|
|
1312
|
-
uploadId: String(payload["uploadId"]),
|
|
1313
|
-
alreadyIngested: true
|
|
1314
|
-
};
|
|
1315
|
-
}
|
|
1316
|
-
const { data, mimeType } = await this.channel.fetchMediaAsBase64(params.sourceMediaId);
|
|
1317
|
-
const buffer = Buffer.from(data, "base64");
|
|
2531
|
+
const key = `meta-whatsapp/${params.companyId}/preview/${this.generateKeySuffix()}`;
|
|
1318
2532
|
const { uploadId } = await this.objectStorage.upload({
|
|
1319
|
-
buffer,
|
|
1320
|
-
mimeType:
|
|
1321
|
-
key
|
|
2533
|
+
buffer: params.buffer,
|
|
2534
|
+
mimeType: params.mimeType,
|
|
2535
|
+
key
|
|
1322
2536
|
});
|
|
1323
|
-
const updatedPayload = {
|
|
1324
|
-
...payload,
|
|
1325
|
-
uploadId,
|
|
1326
|
-
sourceMediaId: params.sourceMediaId,
|
|
1327
|
-
mimeType: mimeType || params.mimeType,
|
|
1328
|
-
...params.filename ? {
|
|
1329
|
-
filename: params.filename
|
|
1330
|
-
} : {}
|
|
1331
|
-
};
|
|
1332
|
-
await this.db.update(messages).set({
|
|
1333
|
-
payload: updatedPayload
|
|
1334
|
-
}).where((0, import_drizzle_orm6.and)((0, import_drizzle_orm6.eq)(messages.companyId, params.companyId), (0, import_drizzle_orm6.eq)(messages.id, params.messageId)));
|
|
1335
2537
|
return {
|
|
1336
|
-
uploadId,
|
|
1337
|
-
|
|
2538
|
+
mediaId: (0, import_meta_whatsapp_contracts8.toPreviewMediaId)(uploadId),
|
|
2539
|
+
uploadId
|
|
1338
2540
|
};
|
|
1339
2541
|
}
|
|
1340
2542
|
};
|
|
1341
|
-
function extractMediaDescriptor(message) {
|
|
1342
|
-
const payload = message.payload ?? {};
|
|
1343
|
-
for (const key of [
|
|
1344
|
-
"image",
|
|
1345
|
-
"audio",
|
|
1346
|
-
"video",
|
|
1347
|
-
"document",
|
|
1348
|
-
"sticker"
|
|
1349
|
-
]) {
|
|
1350
|
-
const media = payload[key];
|
|
1351
|
-
if (media?.id) {
|
|
1352
|
-
return {
|
|
1353
|
-
sourceMediaId: media.id,
|
|
1354
|
-
mimeType: media.mime_type ?? "application/octet-stream",
|
|
1355
|
-
filename: media.filename
|
|
1356
|
-
};
|
|
1357
|
-
}
|
|
1358
|
-
}
|
|
1359
|
-
return void 0;
|
|
1360
|
-
}
|
|
1361
|
-
__name(extractMediaDescriptor, "extractMediaDescriptor");
|
|
1362
2543
|
|
|
1363
2544
|
// src/createMetaWhatsAppModule.ts
|
|
1364
2545
|
function createMetaWhatsAppModule(params) {
|
|
@@ -1372,15 +2553,24 @@ function createMetaWhatsAppModule(params) {
|
|
|
1372
2553
|
apiVersion: config.apiVersion,
|
|
1373
2554
|
baseUrl: config.baseUrl
|
|
1374
2555
|
});
|
|
1375
|
-
const
|
|
2556
|
+
const previewMediaSupport = params.features?.previewMedia && providers.objectStorage?.getObject ? {
|
|
2557
|
+
isEnabled: true,
|
|
2558
|
+
objectStorage: providers.objectStorage
|
|
2559
|
+
} : void 0;
|
|
2560
|
+
const channel = new WhatsAppChannelAdapter(messageProvider, previewMediaSupport);
|
|
1376
2561
|
const sessionRepository = new SessionRepository(db);
|
|
1377
2562
|
const messageRepository = new MessageRepository(db);
|
|
1378
2563
|
const settingsRepository = new SettingsRepository(db);
|
|
1379
|
-
const
|
|
1380
|
-
const
|
|
1381
|
-
const
|
|
2564
|
+
const flowGraphCacheFeature = params.features?.flowGraphCache ?? false;
|
|
2565
|
+
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;
|
|
2566
|
+
const flowGraphRepository = new FlowGraphRepository(db, flowGraphCache);
|
|
2567
|
+
const documentRepository = new DocumentRepository(db);
|
|
2568
|
+
const flowMediaRepository = new FlowMediaRepository(db);
|
|
2569
|
+
const logMessage = new LogMessageUseCase(sessionRepository, messageRepository, providers.realtime, providers.moderator);
|
|
2570
|
+
const sendMessage = new SendMessageUseCase(channel, sessionRepository, logMessage, providers.objectStorage, documentRepository);
|
|
1382
2571
|
const receiveWebhook = new ReceiveWebhookUseCase({
|
|
1383
2572
|
appSecret: config.appSecret,
|
|
2573
|
+
phoneNumberId: config.phoneNumberId,
|
|
1384
2574
|
nonceStore,
|
|
1385
2575
|
sessionRepository,
|
|
1386
2576
|
messageRepository,
|
|
@@ -1390,7 +2580,49 @@ function createMetaWhatsAppModule(params) {
|
|
|
1390
2580
|
realtime: providers.realtime
|
|
1391
2581
|
});
|
|
1392
2582
|
const flowInterpreter = flowEngineEnabled ? new FlowInterpreter() : void 0;
|
|
1393
|
-
|
|
2583
|
+
if (flowInterpreter && providers.objectStorage?.getObject) {
|
|
2584
|
+
flowInterpreter.registerFlowAction(import_meta_whatsapp_contracts13.FLOW_ACTION_KIND.SEND_MEDIA, createSendMediaAction({
|
|
2585
|
+
flowMediaRepository,
|
|
2586
|
+
objectStorage: providers.objectStorage,
|
|
2587
|
+
logMessage,
|
|
2588
|
+
startState,
|
|
2589
|
+
onError: hooks?.onFlowMediaError
|
|
2590
|
+
}));
|
|
2591
|
+
}
|
|
2592
|
+
const transcriptionMode = providers.transcription?.mode ?? TRANSCRIPTION_MODE.ON_DEMAND;
|
|
2593
|
+
const resolveTranscriptionPolicy = providers.transcription ? createTranscriptionPolicyResolver({
|
|
2594
|
+
settingsRepository,
|
|
2595
|
+
defaults: {
|
|
2596
|
+
isEnabled: providers.transcription.isEnabledByDefault ?? true,
|
|
2597
|
+
mode: transcriptionMode
|
|
2598
|
+
}
|
|
2599
|
+
}) : void 0;
|
|
2600
|
+
const ingestInboundMedia = providers.objectStorage ? new IngestInboundMediaUseCase(db, channel, providers.objectStorage, documentRepository, providers.transcription && resolveTranscriptionPolicy ? {
|
|
2601
|
+
transcriber: providers.transcription.transcriber,
|
|
2602
|
+
resolvePolicy: resolveTranscriptionPolicy,
|
|
2603
|
+
messageRepository,
|
|
2604
|
+
...providers.transcription.languageHint ? {
|
|
2605
|
+
languageHint: providers.transcription.languageHint
|
|
2606
|
+
} : {},
|
|
2607
|
+
...hooks ? {
|
|
2608
|
+
hooks
|
|
2609
|
+
} : {}
|
|
2610
|
+
} : void 0) : void 0;
|
|
2611
|
+
const transcribeAudio = providers.transcription && providers.objectStorage?.getObject ? new TranscribeAudioUseCase({
|
|
2612
|
+
messageRepository,
|
|
2613
|
+
objectStorage: providers.objectStorage,
|
|
2614
|
+
transcriber: providers.transcription.transcriber,
|
|
2615
|
+
...resolveTranscriptionPolicy ? {
|
|
2616
|
+
resolvePolicy: resolveTranscriptionPolicy
|
|
2617
|
+
} : {},
|
|
2618
|
+
...providers.transcription.languageHint ? {
|
|
2619
|
+
languageHint: providers.transcription.languageHint
|
|
2620
|
+
} : {},
|
|
2621
|
+
...hooks ? {
|
|
2622
|
+
hooks
|
|
2623
|
+
} : {}
|
|
2624
|
+
}) : void 0;
|
|
2625
|
+
const listDocuments = new ListConversationDocumentsUseCase(sessionRepository, documentRepository);
|
|
1394
2626
|
return {
|
|
1395
2627
|
channel,
|
|
1396
2628
|
// undefined quando providers.objectStorage não foi injetado.
|
|
@@ -1402,10 +2634,36 @@ function createMetaWhatsAppModule(params) {
|
|
|
1402
2634
|
release: new ReleaseConversationUseCase(sessionRepository, providers.realtime),
|
|
1403
2635
|
list: new ListConversationsUseCase(sessionRepository),
|
|
1404
2636
|
listMessages: new ListMessagesUseCase(sessionRepository, messageRepository),
|
|
2637
|
+
listDocuments,
|
|
2638
|
+
// Biblioteca da empresa inteira, para uma tela de Documentos fora da conversa.
|
|
2639
|
+
listCompanyDocuments: new ListCompanyDocumentsUseCase(documentRepository),
|
|
2640
|
+
// Apaga a mídia no storage antes das linhas — a cascata da FK sozinha deixaria os binários
|
|
2641
|
+
// órfãos, já que a lista de uploadId vive justamente nas linhas que ela derruba.
|
|
2642
|
+
delete: new DeleteConversationUseCase(sessionRepository, documentRepository, providers.objectStorage),
|
|
2643
|
+
purgeExpiredDocuments: new PurgeExpiredDocumentsUseCase(documentRepository, providers.objectStorage),
|
|
1405
2644
|
export: new ExportConversationUseCase(sessionRepository),
|
|
1406
|
-
|
|
2645
|
+
// undefined quando transcrição não foi injetada, ou quando o storage não sabe ler de volta.
|
|
2646
|
+
// O painel consulta a ausência para decidir se desenha o botão "transcrever".
|
|
2647
|
+
transcribeAudio,
|
|
2648
|
+
repository: sessionRepository,
|
|
2649
|
+
messageRepository,
|
|
2650
|
+
documentRepository
|
|
1407
2651
|
},
|
|
2652
|
+
/**
|
|
2653
|
+
* `undefined` = o host não injetou transcritor, e nenhuma configuração de empresa muda isso: a
|
|
2654
|
+
* capacidade não existe. Presente, `resolvePolicy` responde o que vale para uma empresa —
|
|
2655
|
+
* é o que a rota de configurações usa para dizer ao painel se desenha o interruptor.
|
|
2656
|
+
*/
|
|
2657
|
+
transcription: providers.transcription && resolveTranscriptionPolicy ? {
|
|
2658
|
+
defaultMode: transcriptionMode,
|
|
2659
|
+
resolvePolicy: resolveTranscriptionPolicy
|
|
2660
|
+
} : void 0,
|
|
1408
2661
|
settings: settingsRepository,
|
|
2662
|
+
/**
|
|
2663
|
+
* `undefined` quando o recurso não está ligado (ou falta storage legível). O host consulta a
|
|
2664
|
+
* ausência para não registrar a rota de upload — e o preview, sem a rota, esconde o microfone.
|
|
2665
|
+
*/
|
|
2666
|
+
previewMedia: previewMediaSupport ? new StorePreviewMediaUseCase(providers.objectStorage, () => `${Date.now()}-${Math.random().toString(36).slice(2)}`) : void 0,
|
|
1409
2667
|
webhook: {
|
|
1410
2668
|
receive: receiveWebhook,
|
|
1411
2669
|
// GET de verificação da Meta — o host liga na sua rota e devolve o retorno como texto puro.
|
|
@@ -1424,7 +2682,11 @@ function createMetaWhatsAppModule(params) {
|
|
|
1424
2682
|
save: new SaveFlowGraphUseCase(flowGraphRepository),
|
|
1425
2683
|
delete: new DeleteFlowGraphUseCase(flowGraphRepository),
|
|
1426
2684
|
livePositions: new GetLiveFlowPositionsUseCase(flowGraphRepository),
|
|
1427
|
-
repository: flowGraphRepository
|
|
2685
|
+
repository: flowGraphRepository,
|
|
2686
|
+
// Biblioteca de mídia dos nós `send_media` — o host liga nas rotas do editor
|
|
2687
|
+
// (anexar/reordenar/desligar). Existe mesmo sem storage injetado: gerenciar anexos é
|
|
2688
|
+
// consultar a tabela, e só o ENVIO precisa dos bytes.
|
|
2689
|
+
mediaRepository: flowMediaRepository
|
|
1428
2690
|
} : void 0,
|
|
1429
2691
|
catalog: providers.catalog
|
|
1430
2692
|
};
|
|
@@ -1433,11 +2695,15 @@ __name(createMetaWhatsAppModule, "createMetaWhatsAppModule");
|
|
|
1433
2695
|
|
|
1434
2696
|
// src/runMigrations.ts
|
|
1435
2697
|
var import_node_path = require("path");
|
|
1436
|
-
var
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
2698
|
+
var META_WHATSAPP_MIGRATIONS_TABLE = "meta_whatsapp_migrations";
|
|
2699
|
+
function metaWhatsAppMigrationsFolder() {
|
|
2700
|
+
return (0, import_node_path.join)(__dirname, "migrations");
|
|
2701
|
+
}
|
|
2702
|
+
__name(metaWhatsAppMigrationsFolder, "metaWhatsAppMigrationsFolder");
|
|
2703
|
+
async function runMetaWhatsAppMigrations(params) {
|
|
2704
|
+
await params.migrate(params.db, {
|
|
2705
|
+
migrationsFolder: metaWhatsAppMigrationsFolder(),
|
|
2706
|
+
migrationsTable: META_WHATSAPP_MIGRATIONS_TABLE
|
|
1441
2707
|
});
|
|
1442
2708
|
}
|
|
1443
2709
|
__name(runMetaWhatsAppMigrations, "runMetaWhatsAppMigrations");
|
|
@@ -1520,20 +2786,30 @@ __name(redeemSseTicket, "redeemSseTicket");
|
|
|
1520
2786
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1521
2787
|
0 && (module.exports = {
|
|
1522
2788
|
CreateFlowGraphUseCase,
|
|
2789
|
+
DEFAULT_FLOW_GRAPH_CACHE_TTL_SECONDS,
|
|
2790
|
+
DeleteConversationUseCase,
|
|
1523
2791
|
DeleteFlowGraphUseCase,
|
|
2792
|
+
DocumentRepository,
|
|
1524
2793
|
ExportConversationUseCase,
|
|
2794
|
+
FlowGraphCache,
|
|
1525
2795
|
FlowGraphRepository,
|
|
1526
2796
|
FlowInterpreter,
|
|
2797
|
+
FlowMediaRepository,
|
|
1527
2798
|
GetFlowGraphUseCase,
|
|
1528
2799
|
GetLiveFlowPositionsUseCase,
|
|
1529
2800
|
IngestInboundMediaUseCase,
|
|
1530
2801
|
InvalidFlowGraphError,
|
|
2802
|
+
ListCompanyDocumentsUseCase,
|
|
2803
|
+
ListConversationDocumentsUseCase,
|
|
1531
2804
|
ListConversationsUseCase,
|
|
1532
2805
|
ListFlowGraphsUseCase,
|
|
1533
2806
|
ListMessagesUseCase,
|
|
1534
2807
|
LogMessageUseCase,
|
|
2808
|
+
META_WHATSAPP_MIGRATIONS_TABLE,
|
|
1535
2809
|
MessageRepository,
|
|
1536
2810
|
OptimisticLockError,
|
|
2811
|
+
PREVIEW_MEDIA_ID_PREFIX,
|
|
2812
|
+
PurgeExpiredDocumentsUseCase,
|
|
1537
2813
|
ReceiveWebhookUseCase,
|
|
1538
2814
|
ReleaseConversationUseCase,
|
|
1539
2815
|
SaveFlowGraphUseCase,
|
|
@@ -1541,20 +2817,36 @@ __name(redeemSseTicket, "redeemSseTicket");
|
|
|
1541
2817
|
SessionRepository,
|
|
1542
2818
|
SettingsRepository,
|
|
1543
2819
|
SseHub,
|
|
2820
|
+
StorePreviewMediaUseCase,
|
|
2821
|
+
TRANSCRIPTION_MODE,
|
|
2822
|
+
TRANSCRIPTION_STATUS,
|
|
1544
2823
|
TakeoverConversationUseCase,
|
|
2824
|
+
TranscribeAudioUseCase,
|
|
1545
2825
|
WEBHOOK_NONCE_TTL_SECONDS,
|
|
1546
2826
|
WhatsAppChannelAdapter,
|
|
1547
2827
|
claimWebhookDelivery,
|
|
1548
2828
|
createMetaWhatsAppModule,
|
|
2829
|
+
createSendMediaAction,
|
|
2830
|
+
createTranscriptionPolicyResolver,
|
|
2831
|
+
documents,
|
|
1549
2832
|
extractMediaDescriptor,
|
|
1550
2833
|
flowGraphs,
|
|
2834
|
+
flowMedia,
|
|
2835
|
+
isAudioMimeType,
|
|
2836
|
+
isRetriableTranscriptionError,
|
|
2837
|
+
isUnsupportedTranscriptionError,
|
|
1551
2838
|
issueSseTicket,
|
|
1552
2839
|
messages,
|
|
2840
|
+
metaWhatsAppMigrationsFolder,
|
|
1553
2841
|
metaWhatsAppSchema,
|
|
1554
2842
|
redeemSseTicket,
|
|
2843
|
+
resolveFailureStatus,
|
|
2844
|
+
resolvePreviewUploadId,
|
|
1555
2845
|
runMetaWhatsAppMigrations,
|
|
1556
2846
|
sessions,
|
|
1557
2847
|
settings,
|
|
2848
|
+
toPreviewMediaId,
|
|
2849
|
+
transcriptionRetryAfterSeconds,
|
|
1558
2850
|
verifyWebhookChallenge,
|
|
1559
2851
|
verifyWebhookSignature
|
|
1560
2852
|
});
|