@adatechnology/meta-whatsapp-module 0.2.0-rc.9 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +1745 -162
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1712 -136
- package/dist/index.d.ts +1712 -136
- package/dist/index.js +1697 -147
- package/dist/index.js.map +1 -1
- package/dist/migrations/0006_conversation_documents.sql +19 -0
- package/dist/migrations/0007_flow_media.sql +18 -0
- package/dist/migrations/0008_message_transcription.sql +5 -0
- package/dist/migrations/0009_settings_transcription_policy.sql +2 -0
- package/dist/migrations/0010_flow_media_meta_ids.sql +10 -0
- package/dist/migrations/meta/_journal.json +28 -0
- package/dist/testing/index.cjs +150 -0
- package/dist/testing/index.cjs.map +1 -1
- package/dist/testing/index.d.cts +42 -2
- package/dist/testing/index.d.ts +42 -2
- package/dist/testing/index.js +147 -1
- package/dist/testing/index.js.map +1 -1
- package/package.json +4 -4
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,14 +22,23 @@ 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
|
+
FlowMediaIdRepository: () => FlowMediaIdRepository,
|
|
34
|
+
FlowMediaRepository: () => FlowMediaRepository,
|
|
29
35
|
GetFlowGraphUseCase: () => GetFlowGraphUseCase,
|
|
30
36
|
GetLiveFlowPositionsUseCase: () => GetLiveFlowPositionsUseCase,
|
|
37
|
+
InboundEffectsDispatcher: () => InboundEffectsDispatcher,
|
|
31
38
|
IngestInboundMediaUseCase: () => IngestInboundMediaUseCase,
|
|
32
39
|
InvalidFlowGraphError: () => InvalidFlowGraphError,
|
|
40
|
+
ListCompanyDocumentsUseCase: () => ListCompanyDocumentsUseCase,
|
|
41
|
+
ListConversationDocumentsUseCase: () => ListConversationDocumentsUseCase,
|
|
33
42
|
ListConversationsUseCase: () => ListConversationsUseCase,
|
|
34
43
|
ListFlowGraphsUseCase: () => ListFlowGraphsUseCase,
|
|
35
44
|
ListMessagesUseCase: () => ListMessagesUseCase,
|
|
@@ -37,6 +46,10 @@ __export(index_exports, {
|
|
|
37
46
|
META_WHATSAPP_MIGRATIONS_TABLE: () => META_WHATSAPP_MIGRATIONS_TABLE,
|
|
38
47
|
MessageRepository: () => MessageRepository,
|
|
39
48
|
OptimisticLockError: () => OptimisticLockError,
|
|
49
|
+
PREVIEW_MEDIA_ID_PREFIX: () => import_meta_whatsapp_contracts8.PREVIEW_MEDIA_ID_PREFIX,
|
|
50
|
+
PRODUCT_LIST_LIMIT: () => PRODUCT_LIST_LIMIT,
|
|
51
|
+
ProcessInboundDispatchUseCase: () => ProcessInboundDispatchUseCase,
|
|
52
|
+
PurgeExpiredDocumentsUseCase: () => PurgeExpiredDocumentsUseCase,
|
|
40
53
|
ReceiveWebhookUseCase: () => ReceiveWebhookUseCase,
|
|
41
54
|
ReleaseConversationUseCase: () => ReleaseConversationUseCase,
|
|
42
55
|
SaveFlowGraphUseCase: () => SaveFlowGraphUseCase,
|
|
@@ -44,21 +57,41 @@ __export(index_exports, {
|
|
|
44
57
|
SessionRepository: () => SessionRepository,
|
|
45
58
|
SettingsRepository: () => SettingsRepository,
|
|
46
59
|
SseHub: () => SseHub,
|
|
60
|
+
StorePreviewMediaUseCase: () => StorePreviewMediaUseCase,
|
|
61
|
+
TRANSCRIPTION_MODE: () => TRANSCRIPTION_MODE,
|
|
62
|
+
TRANSCRIPTION_STATUS: () => TRANSCRIPTION_STATUS,
|
|
47
63
|
TakeoverConversationUseCase: () => TakeoverConversationUseCase,
|
|
48
|
-
|
|
64
|
+
TranscribeAudioUseCase: () => TranscribeAudioUseCase,
|
|
65
|
+
WEBHOOK_CLAIM_TTL_SECONDS: () => import_meta_graph_core2.WEBHOOK_CLAIM_TTL_SECONDS,
|
|
66
|
+
WEBHOOK_NONCE_TTL_SECONDS: () => import_meta_graph_core2.WEBHOOK_NONCE_TTL_SECONDS,
|
|
49
67
|
WhatsAppChannelAdapter: () => WhatsAppChannelAdapter,
|
|
68
|
+
buildInboundJobId: () => buildInboundJobId,
|
|
50
69
|
claimWebhookDelivery: () => claimWebhookDelivery,
|
|
70
|
+
confirmWebhookDelivery: () => confirmWebhookDelivery,
|
|
51
71
|
createMetaWhatsAppModule: () => createMetaWhatsAppModule,
|
|
72
|
+
createSendMediaAction: () => createSendMediaAction,
|
|
73
|
+
createSendProductListAction: () => createSendProductListAction,
|
|
74
|
+
createTranscriptionPolicyResolver: () => createTranscriptionPolicyResolver,
|
|
75
|
+
documents: () => documents,
|
|
52
76
|
extractMediaDescriptor: () => extractMediaDescriptor,
|
|
53
77
|
flowGraphs: () => flowGraphs,
|
|
78
|
+
flowMedia: () => flowMedia,
|
|
79
|
+
isAudioMimeType: () => isAudioMimeType,
|
|
80
|
+
isRetriableTranscriptionError: () => isRetriableTranscriptionError,
|
|
81
|
+
isUnsupportedTranscriptionError: () => isUnsupportedTranscriptionError,
|
|
54
82
|
issueSseTicket: () => issueSseTicket,
|
|
55
83
|
messages: () => messages,
|
|
56
84
|
metaWhatsAppMigrationsFolder: () => metaWhatsAppMigrationsFolder,
|
|
57
85
|
metaWhatsAppSchema: () => metaWhatsAppSchema,
|
|
58
86
|
redeemSseTicket: () => redeemSseTicket,
|
|
87
|
+
resolveFailureStatus: () => resolveFailureStatus,
|
|
88
|
+
resolvePreviewUploadId: () => import_meta_whatsapp_contracts8.resolvePreviewUploadId,
|
|
59
89
|
runMetaWhatsAppMigrations: () => runMetaWhatsAppMigrations,
|
|
60
90
|
sessions: () => sessions,
|
|
61
91
|
settings: () => settings,
|
|
92
|
+
toPreviewMediaId: () => import_meta_whatsapp_contracts8.toPreviewMediaId,
|
|
93
|
+
toSessionContract: () => toSessionContract,
|
|
94
|
+
transcriptionRetryAfterSeconds: () => transcriptionRetryAfterSeconds,
|
|
62
95
|
verifyWebhookChallenge: () => verifyWebhookChallenge,
|
|
63
96
|
verifyWebhookSignature: () => verifyWebhookSignature
|
|
64
97
|
});
|
|
@@ -66,6 +99,7 @@ module.exports = __toCommonJS(index_exports);
|
|
|
66
99
|
|
|
67
100
|
// src/createMetaWhatsAppModule.ts
|
|
68
101
|
var import_meta_whatsapp_provider = require("@adatechnology/meta-whatsapp-provider");
|
|
102
|
+
var import_meta_whatsapp_contracts13 = require("@adatechnology/meta-whatsapp-contracts");
|
|
69
103
|
|
|
70
104
|
// src/repositories/SessionRepository.ts
|
|
71
105
|
var import_drizzle_orm2 = require("drizzle-orm");
|
|
@@ -168,6 +202,27 @@ var messages = metaWhatsAppSchema.table("messages", {
|
|
|
168
202
|
// parcial sobre boolean resolve isso sem cavar jsonb.
|
|
169
203
|
moderationFlagged: (0, import_pg_core.boolean)("moderation_flagged"),
|
|
170
204
|
moderationTerms: (0, import_pg_core.jsonb)("moderation_terms").$type(),
|
|
205
|
+
/**
|
|
206
|
+
* Transcrição de áudio. `null` em `transcription_status` significa NÃO AVALIADO — áudio nunca
|
|
207
|
+
* pedido (modo sob demanda), transcrição desligada, mensagem anterior ao recurso, ou mensagem
|
|
208
|
+
* que não é áudio. Diferente de `'done'` com texto vazio, que é áudio em silêncio já processado
|
|
209
|
+
* e que NÃO deve ser reprocessado.
|
|
210
|
+
*
|
|
211
|
+
* Colunas em vez de chave em `payload` pelo mesmo motivo da moderação: "quais áudios ficaram
|
|
212
|
+
* pendentes" e "quais falharam" são consultas de operação, e índice parcial resolve sem cavar
|
|
213
|
+
* jsonb. Buscar texto de áudio também deixa de exigir varredura de payload.
|
|
214
|
+
*/
|
|
215
|
+
transcriptionStatus: (0, import_pg_core.varchar)("transcription_status", {
|
|
216
|
+
length: 16
|
|
217
|
+
}).$type(),
|
|
218
|
+
transcriptionText: (0, import_pg_core.text)("transcription_text"),
|
|
219
|
+
transcriptionLanguage: (0, import_pg_core.varchar)("transcription_language", {
|
|
220
|
+
length: 32
|
|
221
|
+
}),
|
|
222
|
+
/** Qual engine produziu. Com uma cadeia de engines, é o que responde "por que esta saiu ruim". */
|
|
223
|
+
transcriptionEngine: (0, import_pg_core.varchar)("transcription_engine", {
|
|
224
|
+
length: 32
|
|
225
|
+
}),
|
|
171
226
|
createdAt: (0, import_pg_core.timestamp)("created_at", {
|
|
172
227
|
withTimezone: true
|
|
173
228
|
}).notNull().defaultNow()
|
|
@@ -182,7 +237,51 @@ var messages = metaWhatsAppSchema.table("messages", {
|
|
|
182
237
|
(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`),
|
|
183
238
|
// Parcial: só as sinalizadas entram, então o índice fica do tamanho do problema e não do
|
|
184
239
|
// tamanho do transcript.
|
|
185
|
-
(0, import_pg_core.index)("idx_messages_moderation_flagged").on(table.companyId, table.createdAt).where(import_drizzle_orm.sql`${table.moderationFlagged}`)
|
|
240
|
+
(0, import_pg_core.index)("idx_messages_moderation_flagged").on(table.companyId, table.createdAt).where(import_drizzle_orm.sql`${table.moderationFlagged}`),
|
|
241
|
+
// Alimenta a varredura de retomada: "quais áudios ficaram pendentes de transcrição". Parcial
|
|
242
|
+
// porque pendente é estado transitório e raro — o índice fica do tamanho da fila atrasada, não
|
|
243
|
+
// do transcript inteiro.
|
|
244
|
+
(0, import_pg_core.index)("idx_messages_transcription_pending").on(table.companyId, table.createdAt).where(import_drizzle_orm.sql`${table.transcriptionStatus} = 'pending'`)
|
|
245
|
+
]);
|
|
246
|
+
var documents = metaWhatsAppSchema.table("documents", {
|
|
247
|
+
id: (0, import_pg_core.uuid)("id").primaryKey().defaultRandom(),
|
|
248
|
+
companyId: (0, import_pg_core.uuid)("company_id").notNull(),
|
|
249
|
+
sessionId: (0, import_pg_core.uuid)("session_id").notNull().references(() => sessions.id, {
|
|
250
|
+
onDelete: "cascade"
|
|
251
|
+
}),
|
|
252
|
+
// Anulável e `set null`: apagar a mensagem não deve apagar o arquivo da biblioteca, e arquivo
|
|
253
|
+
// do atendente nasce sem mensagem.
|
|
254
|
+
messageId: (0, import_pg_core.uuid)("message_id").references(() => messages.id, {
|
|
255
|
+
onDelete: "set null"
|
|
256
|
+
}),
|
|
257
|
+
uploadId: (0, import_pg_core.varchar)("upload_id", {
|
|
258
|
+
length: 256
|
|
259
|
+
}).notNull(),
|
|
260
|
+
filename: (0, import_pg_core.varchar)("filename", {
|
|
261
|
+
length: 512
|
|
262
|
+
}).notNull(),
|
|
263
|
+
mimeType: (0, import_pg_core.varchar)("mime_type", {
|
|
264
|
+
length: 128
|
|
265
|
+
}).notNull(),
|
|
266
|
+
sizeBytes: (0, import_pg_core.integer)("size_bytes").notNull(),
|
|
267
|
+
// Do provider de storage, que é endereçado por conteúdo — serve para reconciliar storage
|
|
268
|
+
// contra tabela e para detectar o mesmo binário chegando duas vezes.
|
|
269
|
+
sha256: (0, import_pg_core.varchar)("sha256", {
|
|
270
|
+
length: 64
|
|
271
|
+
}),
|
|
272
|
+
source: (0, import_pg_core.varchar)("source", {
|
|
273
|
+
length: 12
|
|
274
|
+
}).notNull(),
|
|
275
|
+
linkedAt: (0, import_pg_core.timestamp)("linked_at", {
|
|
276
|
+
withTimezone: true
|
|
277
|
+
}).notNull().defaultNow()
|
|
278
|
+
}, (table) => [
|
|
279
|
+
(0, import_pg_core.index)("idx_documents_session_linked").on(table.sessionId, table.linkedAt),
|
|
280
|
+
// Para varredura de retenção por idade sem passar por sessão.
|
|
281
|
+
(0, import_pg_core.index)("idx_documents_company_linked").on(table.companyId, table.linkedAt),
|
|
282
|
+
// O mesmo objeto não pode ser linkado duas vezes na mesma empresa: o job de ingestão é
|
|
283
|
+
// reentregue por retry, e sem isto a reentrega criaria linha duplicada no painel.
|
|
284
|
+
(0, import_pg_core.uniqueIndex)("idx_documents_company_upload").on(table.companyId, table.uploadId)
|
|
186
285
|
]);
|
|
187
286
|
var flowGraphs = metaWhatsAppSchema.table("flow_graphs", {
|
|
188
287
|
id: (0, import_pg_core.uuid)("id").primaryKey().defaultRandom(),
|
|
@@ -213,6 +312,54 @@ var flowGraphs = metaWhatsAppSchema.table("flow_graphs", {
|
|
|
213
312
|
}, (table) => [
|
|
214
313
|
(0, import_pg_core.uniqueIndex)("idx_flow_graphs_company_key").on(table.companyId, table.key)
|
|
215
314
|
]);
|
|
315
|
+
var flowMedia = metaWhatsAppSchema.table("flow_media", {
|
|
316
|
+
id: (0, import_pg_core.uuid)("id").primaryKey().defaultRandom(),
|
|
317
|
+
companyId: (0, import_pg_core.uuid)("company_id").notNull(),
|
|
318
|
+
flowKey: (0, import_pg_core.varchar)("flow_key", {
|
|
319
|
+
length: 64
|
|
320
|
+
}).notNull(),
|
|
321
|
+
nodeId: (0, import_pg_core.varchar)("node_id", {
|
|
322
|
+
length: 64
|
|
323
|
+
}).notNull(),
|
|
324
|
+
uploadId: (0, import_pg_core.varchar)("upload_id", {
|
|
325
|
+
length: 256
|
|
326
|
+
}).notNull(),
|
|
327
|
+
filename: (0, import_pg_core.varchar)("filename", {
|
|
328
|
+
length: 512
|
|
329
|
+
}).notNull(),
|
|
330
|
+
mimeType: (0, import_pg_core.varchar)("mime_type", {
|
|
331
|
+
length: 128
|
|
332
|
+
}).notNull(),
|
|
333
|
+
sizeBytes: (0, import_pg_core.integer)("size_bytes").notNull(),
|
|
334
|
+
// Legenda da mídia no WhatsApp. Por arquivo, não por nó: um nó que manda tabela de preços e
|
|
335
|
+
// um folder precisa de textos diferentes para cada um.
|
|
336
|
+
caption: (0, import_pg_core.text)("caption"),
|
|
337
|
+
// Ordem de envio dentro do nó — o cliente recebe as mensagens em sequência, e "tabela antes
|
|
338
|
+
// do folder" é decisão de quem edita, não do banco.
|
|
339
|
+
sortOrder: (0, import_pg_core.integer)("sort_order").notNull().default(0),
|
|
340
|
+
// Desligar sem desanexar: trocar o material da campanha é o caso comum, e apagar a linha
|
|
341
|
+
// perderia a ordem e a legenda já ajustadas.
|
|
342
|
+
active: (0, import_pg_core.boolean)("active").notNull().default(true),
|
|
343
|
+
// Id do arquivo já subido para a Meta, por número remetente. Sem isto, o MESMO binário subia de
|
|
344
|
+
// novo para cada cliente que passava pelo nó: a Meta aceita reusar o id por 30 dias.
|
|
345
|
+
//
|
|
346
|
+
// Mapa por `phone_number_id`, e não coluna única: o id é escopado ao número que envia, e uma
|
|
347
|
+
// instalação com dois números mandaria o id de um pelo outro. A validade não é gravada de
|
|
348
|
+
// propósito — confiar em "30 dias" calculados erra nos casos de borda, e quem decide é a
|
|
349
|
+
// recusa da Meta, que devolve ao caminho de subir o binário.
|
|
350
|
+
metaMediaIds: (0, import_pg_core.jsonb)("meta_media_ids").$type().notNull().default({}),
|
|
351
|
+
createdAt: (0, import_pg_core.timestamp)("created_at", {
|
|
352
|
+
withTimezone: true
|
|
353
|
+
}).notNull().defaultNow(),
|
|
354
|
+
updatedAt: (0, import_pg_core.timestamp)("updated_at", {
|
|
355
|
+
withTimezone: true
|
|
356
|
+
}).notNull().defaultNow()
|
|
357
|
+
}, (table) => [
|
|
358
|
+
(0, import_pg_core.index)("idx_flow_media_node").on(table.companyId, table.flowKey, table.nodeId, table.sortOrder),
|
|
359
|
+
// O mesmo arquivo anexado duas vezes ao MESMO nó é erro de clique no editor, e o cliente
|
|
360
|
+
// receberia o documento repetido.
|
|
361
|
+
(0, import_pg_core.uniqueIndex)("idx_flow_media_node_upload").on(table.companyId, table.flowKey, table.nodeId, table.uploadId)
|
|
362
|
+
]);
|
|
216
363
|
var settings = metaWhatsAppSchema.table("settings", {
|
|
217
364
|
companyId: (0, import_pg_core.uuid)("company_id").primaryKey(),
|
|
218
365
|
templateName: (0, import_pg_core.varchar)("template_name", {
|
|
@@ -225,6 +372,15 @@ var settings = metaWhatsAppSchema.table("settings", {
|
|
|
225
372
|
templateVariables: (0, import_pg_core.jsonb)("template_variables").$type().notNull().default([]),
|
|
226
373
|
welcomeMessage: (0, import_pg_core.text)("welcome_message"),
|
|
227
374
|
farewellMessage: (0, import_pg_core.text)("farewell_message"),
|
|
375
|
+
/**
|
|
376
|
+
* Política de transcrição desta empresa. Nulo é significativo: "o painel não decidiu", e aí vale
|
|
377
|
+
* o padrão que o host injetou. Sem a distinção, atualizar o módulo desligaria a transcrição de
|
|
378
|
+
* quem já a tinha ligada por ambiente.
|
|
379
|
+
*/
|
|
380
|
+
transcriptionEnabled: (0, import_pg_core.boolean)("transcription_enabled"),
|
|
381
|
+
transcriptionMode: (0, import_pg_core.varchar)("transcription_mode", {
|
|
382
|
+
length: 16
|
|
383
|
+
}).$type(),
|
|
228
384
|
createdAt: (0, import_pg_core.timestamp)("created_at", {
|
|
229
385
|
withTimezone: true
|
|
230
386
|
}).notNull().defaultNow(),
|
|
@@ -256,6 +412,10 @@ var conversationSummaryProjection = {
|
|
|
256
412
|
and (${sessions}.last_agent_read_at is null or m.created_at > ${sessions}.last_agent_read_at)
|
|
257
413
|
)`
|
|
258
414
|
};
|
|
415
|
+
function sessionContextPatch(patch) {
|
|
416
|
+
return import_drizzle_orm2.sql`${sessions.context} || ${JSON.stringify(patch)}::jsonb`;
|
|
417
|
+
}
|
|
418
|
+
__name(sessionContextPatch, "sessionContextPatch");
|
|
259
419
|
var SessionRepository = class {
|
|
260
420
|
static {
|
|
261
421
|
__name(this, "SessionRepository");
|
|
@@ -286,6 +446,9 @@ var SessionRepository = class {
|
|
|
286
446
|
}).returning();
|
|
287
447
|
return created;
|
|
288
448
|
}
|
|
449
|
+
// O `context` jsonb é o ponto de extensão oficial para estado de sessão por produto: o módulo
|
|
450
|
+
// não conhece a forma, o consumidor a declara em TSessionContext. Este setter SUBSTITUI o
|
|
451
|
+
// objeto inteiro — para acumular respostas ao longo da conversa use patchContext.
|
|
289
452
|
async setState(companyId, whatsappNumber, state, context) {
|
|
290
453
|
await this.db.update(sessions).set({
|
|
291
454
|
currentState: state,
|
|
@@ -296,6 +459,25 @@ var SessionRepository = class {
|
|
|
296
459
|
updatedAt: import_drizzle_orm2.sql`now()`
|
|
297
460
|
}).where((0, import_drizzle_orm2.and)((0, import_drizzle_orm2.eq)(sessions.companyId, companyId), (0, import_drizzle_orm2.eq)(sessions.whatsappNumber, whatsappNumber)));
|
|
298
461
|
}
|
|
462
|
+
// Mescla parcial do context, feita no banco (`||`) e não por read-modify-write no host: duas
|
|
463
|
+
// mensagens do mesmo cliente processadas em paralelo sobrescreveriam uma à outra, e o campo
|
|
464
|
+
// acumula justamente as respostas coletadas ao longo da conversa. Chave presente no patch
|
|
465
|
+
// vence a existente; as demais permanecem.
|
|
466
|
+
async patchContext(companyId, whatsappNumber, patch) {
|
|
467
|
+
await this.db.update(sessions).set({
|
|
468
|
+
context: sessionContextPatch(patch),
|
|
469
|
+
lastActivity: import_drizzle_orm2.sql`now()`,
|
|
470
|
+
updatedAt: import_drizzle_orm2.sql`now()`
|
|
471
|
+
}).where((0, import_drizzle_orm2.and)((0, import_drizzle_orm2.eq)(sessions.companyId, companyId), (0, import_drizzle_orm2.eq)(sessions.whatsappNumber, whatsappNumber)));
|
|
472
|
+
}
|
|
473
|
+
// Leitura tipada do estado de sessão do produto. Devolve undefined quando a sessão não existe —
|
|
474
|
+
// distinto de existir com context vazio, que devolve o objeto vazio.
|
|
475
|
+
async readContext(companyId, whatsappNumber) {
|
|
476
|
+
const [row] = await this.db.select({
|
|
477
|
+
context: sessions.context
|
|
478
|
+
}).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);
|
|
479
|
+
return row?.context;
|
|
480
|
+
}
|
|
299
481
|
// Posição no grafo de fluxo — chamado pelo host a cada transição do FlowInterpreter.
|
|
300
482
|
// Passar null em ambos desliga o rastreio (ex.: conversa saiu do motor de fluxo).
|
|
301
483
|
async setFlowPosition(companyId, whatsappNumber, flowKey, currentNodeId) {
|
|
@@ -338,6 +520,16 @@ var SessionRepository = class {
|
|
|
338
520
|
async release(companyId, whatsappNumber) {
|
|
339
521
|
await this.setMode(companyId, whatsappNumber, "bot", null);
|
|
340
522
|
}
|
|
523
|
+
/**
|
|
524
|
+
* Apaga a sessão; a cascata das FKs leva mensagens e documentos.
|
|
525
|
+
*
|
|
526
|
+
* Não apaga o binário no storage — isso é passo de aplicação, e é por isso que este método é
|
|
527
|
+
* chamado por `DeleteConversationUseCase` e não diretamente pelo host. Chamar daqui sem apagar os
|
|
528
|
+
* objetos antes deixa mídia órfã sendo cobrada para sempre.
|
|
529
|
+
*/
|
|
530
|
+
async deleteByNumber(companyId, whatsappNumber) {
|
|
531
|
+
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)));
|
|
532
|
+
}
|
|
341
533
|
async requestHuman(companyId, whatsappNumber) {
|
|
342
534
|
await this.db.update(sessions).set({
|
|
343
535
|
humanRequestedAt: import_drizzle_orm2.sql`now()`,
|
|
@@ -452,6 +644,58 @@ var MessageRepository = class {
|
|
|
452
644
|
}).where((0, import_drizzle_orm3.and)((0, import_drizzle_orm3.eq)(messages.companyId, companyId), (0, import_drizzle_orm3.eq)(messages.waMessageId, waMessageId))).returning();
|
|
453
645
|
return updated;
|
|
454
646
|
}
|
|
647
|
+
/**
|
|
648
|
+
* Grava a transcrição endereçando pelo id da Meta, para quem só tem esse.
|
|
649
|
+
*
|
|
650
|
+
* Serve ao caso em que a transcrição acontece no próprio webhook — o grafo precisa do texto para
|
|
651
|
+
* responder ao cliente, e jogar fora o que ele já pagou para transcrever significaria transcrever
|
|
652
|
+
* o mesmo áudio uma segunda vez só para o painel ver.
|
|
653
|
+
*
|
|
654
|
+
* Devolve `undefined` quando não achou a mensagem: entrega duplicada e mensagem apagada são
|
|
655
|
+
* corridas normais, não erro.
|
|
656
|
+
*/
|
|
657
|
+
async saveTranscriptionByWaMessageId(params) {
|
|
658
|
+
const [updated] = await this.db.update(messages).set({
|
|
659
|
+
transcriptionStatus: params.status,
|
|
660
|
+
...params.text !== void 0 ? {
|
|
661
|
+
transcriptionText: params.text
|
|
662
|
+
} : {},
|
|
663
|
+
...params.language !== void 0 ? {
|
|
664
|
+
transcriptionLanguage: params.language
|
|
665
|
+
} : {},
|
|
666
|
+
...params.engine !== void 0 ? {
|
|
667
|
+
transcriptionEngine: params.engine
|
|
668
|
+
} : {}
|
|
669
|
+
}).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();
|
|
670
|
+
return updated;
|
|
671
|
+
}
|
|
672
|
+
async findById(companyId, messageId) {
|
|
673
|
+
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);
|
|
674
|
+
return found;
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Grava o resultado da transcrição. Devolve `undefined` quando a mensagem não existe (apagada
|
|
678
|
+
* entre o enfileiramento e a execução do job) — não é erro, é corrida normal.
|
|
679
|
+
*
|
|
680
|
+
* `text`/`language`/`engine` só são tocados quando informados: uma retentativa que volta a falhar
|
|
681
|
+
* atualiza o status sem apagar a transcrição parcial de uma tentativa anterior que tenha vindo de
|
|
682
|
+
* outro engine da cadeia.
|
|
683
|
+
*/
|
|
684
|
+
async saveTranscription(params) {
|
|
685
|
+
const [updated] = await this.db.update(messages).set({
|
|
686
|
+
transcriptionStatus: params.status,
|
|
687
|
+
...params.text !== void 0 ? {
|
|
688
|
+
transcriptionText: params.text
|
|
689
|
+
} : {},
|
|
690
|
+
...params.language !== void 0 ? {
|
|
691
|
+
transcriptionLanguage: params.language
|
|
692
|
+
} : {},
|
|
693
|
+
...params.engine !== void 0 ? {
|
|
694
|
+
transcriptionEngine: params.engine
|
|
695
|
+
} : {}
|
|
696
|
+
}).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();
|
|
697
|
+
return updated;
|
|
698
|
+
}
|
|
455
699
|
async listByConversation(params) {
|
|
456
700
|
const conditions = [
|
|
457
701
|
(0, import_drizzle_orm3.eq)(messages.companyId, params.companyId),
|
|
@@ -501,12 +745,21 @@ var FlowGraphRepository = class {
|
|
|
501
745
|
__name(this, "FlowGraphRepository");
|
|
502
746
|
}
|
|
503
747
|
db;
|
|
504
|
-
|
|
748
|
+
cache;
|
|
749
|
+
// Cache opcional: sem ele o repositório se comporta exatamente como antes, lendo sempre do
|
|
750
|
+
// banco. É o host que decide se quer cachear e com qual provedor (ver CacheInterface).
|
|
751
|
+
constructor(db, cache) {
|
|
505
752
|
this.db = db;
|
|
753
|
+
this.cache = cache;
|
|
506
754
|
}
|
|
507
755
|
async get(companyId, key) {
|
|
756
|
+
const cached = await this.cache?.read(companyId, key);
|
|
757
|
+
if (cached) return cached;
|
|
508
758
|
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);
|
|
509
|
-
|
|
759
|
+
if (!row) return void 0;
|
|
760
|
+
const graph = toContractGraph(row);
|
|
761
|
+
await this.cache?.write(companyId, graph);
|
|
762
|
+
return graph;
|
|
510
763
|
}
|
|
511
764
|
async list(companyId) {
|
|
512
765
|
const rows = await this.db.select().from(flowGraphs).where((0, import_drizzle_orm4.eq)(flowGraphs.companyId, companyId));
|
|
@@ -536,7 +789,9 @@ var FlowGraphRepository = class {
|
|
|
536
789
|
showInMenu: graph.showInMenu ?? false,
|
|
537
790
|
menuOptionLabel: graph.menuOptionLabel
|
|
538
791
|
}).returning();
|
|
539
|
-
|
|
792
|
+
const createdGraph = toContractGraph(created);
|
|
793
|
+
await this.cache?.invalidate(companyId, createdGraph.key);
|
|
794
|
+
return createdGraph;
|
|
540
795
|
}
|
|
541
796
|
// Lock otimista: a escrita só aplica se `expectedVersion` ainda bater com o que está salvo —
|
|
542
797
|
// senão, alguém mais salvou entretanto e o editor precisa recarregar (ver comentário no schema).
|
|
@@ -550,10 +805,12 @@ var FlowGraphRepository = class {
|
|
|
550
805
|
updatedAt: /* @__PURE__ */ new Date()
|
|
551
806
|
}).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();
|
|
552
807
|
if (rows.length === 0) throw new OptimisticLockError(graph.key);
|
|
808
|
+
await this.cache?.invalidate(companyId, graph.key);
|
|
553
809
|
return toContractGraph(rows[0]);
|
|
554
810
|
}
|
|
555
811
|
async delete(companyId, key) {
|
|
556
812
|
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)));
|
|
813
|
+
await this.cache?.invalidate(companyId, key);
|
|
557
814
|
}
|
|
558
815
|
// T4.2 — GetLiveFlowPositions: agrega sessões ativas por (flowKey, currentNodeId), lendo as
|
|
559
816
|
// colunas dedicadas gravadas por SessionRepository.setFlowPosition. Agrega no banco (GROUP BY,
|
|
@@ -573,6 +830,48 @@ var FlowGraphRepository = class {
|
|
|
573
830
|
}
|
|
574
831
|
};
|
|
575
832
|
|
|
833
|
+
// src/repositories/FlowGraphCache.ts
|
|
834
|
+
var KEY_PREFIX = "meta-whatsapp:flow-graph";
|
|
835
|
+
var DEFAULT_FLOW_GRAPH_CACHE_TTL_SECONDS = 300;
|
|
836
|
+
var FlowGraphCache = class {
|
|
837
|
+
static {
|
|
838
|
+
__name(this, "FlowGraphCache");
|
|
839
|
+
}
|
|
840
|
+
provider;
|
|
841
|
+
ttlSeconds;
|
|
842
|
+
constructor(provider, ttlSeconds = DEFAULT_FLOW_GRAPH_CACHE_TTL_SECONDS) {
|
|
843
|
+
this.provider = provider;
|
|
844
|
+
this.ttlSeconds = ttlSeconds;
|
|
845
|
+
}
|
|
846
|
+
// companyId na chave, e não só a flowKey: a chave do fluxo é escolhida por quem edita e se
|
|
847
|
+
// repete entre empresas — 'consorcio' existe em todas — então uma chave sem tenant serviria o
|
|
848
|
+
// grafo de uma empresa para a conversa de outra.
|
|
849
|
+
keyFor(companyId, flowKey) {
|
|
850
|
+
return `${KEY_PREFIX}:${companyId}:${flowKey}`;
|
|
851
|
+
}
|
|
852
|
+
async read(companyId, flowKey) {
|
|
853
|
+
try {
|
|
854
|
+
const cached = await this.provider.get(this.keyFor(companyId, flowKey));
|
|
855
|
+
if (!cached) return void 0;
|
|
856
|
+
return JSON.parse(cached);
|
|
857
|
+
} catch {
|
|
858
|
+
return void 0;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
async write(companyId, graph) {
|
|
862
|
+
try {
|
|
863
|
+
await this.provider.set(this.keyFor(companyId, graph.key), JSON.stringify(graph), this.ttlSeconds);
|
|
864
|
+
} catch {
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
async invalidate(companyId, flowKey) {
|
|
868
|
+
try {
|
|
869
|
+
await this.provider.delete(this.keyFor(companyId, flowKey));
|
|
870
|
+
} catch {
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
};
|
|
874
|
+
|
|
576
875
|
// src/repositories/SettingsRepository.ts
|
|
577
876
|
var import_drizzle_orm5 = require("drizzle-orm");
|
|
578
877
|
function toContractSettings(row) {
|
|
@@ -581,7 +880,11 @@ function toContractSettings(row) {
|
|
|
581
880
|
templateLanguage: row.templateLanguage,
|
|
582
881
|
templateVariables: row.templateVariables,
|
|
583
882
|
welcomeMessage: row.welcomeMessage ?? "",
|
|
584
|
-
farewellMessage: row.farewellMessage ?? ""
|
|
883
|
+
farewellMessage: row.farewellMessage ?? "",
|
|
884
|
+
// `?? null` e não `?? false`: nulo é "o painel não decidiu", e é o que faz valer o padrão do
|
|
885
|
+
// host. Colapsar para `false` desligaria quem ligou por ambiente.
|
|
886
|
+
transcriptionEnabled: row.transcriptionEnabled ?? null,
|
|
887
|
+
transcriptionMode: row.transcriptionMode ?? null
|
|
585
888
|
};
|
|
586
889
|
}
|
|
587
890
|
__name(toContractSettings, "toContractSettings");
|
|
@@ -590,7 +893,9 @@ var EMPTY_SETTINGS = {
|
|
|
590
893
|
templateLanguage: "pt_BR",
|
|
591
894
|
templateVariables: [],
|
|
592
895
|
welcomeMessage: "",
|
|
593
|
-
farewellMessage: ""
|
|
896
|
+
farewellMessage: "",
|
|
897
|
+
transcriptionEnabled: null,
|
|
898
|
+
transcriptionMode: null
|
|
594
899
|
};
|
|
595
900
|
var SettingsRepository = class {
|
|
596
901
|
static {
|
|
@@ -619,7 +924,12 @@ var SettingsRepository = class {
|
|
|
619
924
|
templateLanguage: merged.templateLanguage,
|
|
620
925
|
templateVariables: merged.templateVariables,
|
|
621
926
|
welcomeMessage: merged.welcomeMessage || null,
|
|
622
|
-
farewellMessage: merged.farewellMessage || null
|
|
927
|
+
farewellMessage: merged.farewellMessage || null,
|
|
928
|
+
// Sem `|| null`: `false` aqui é decisão explícita do painel ("desligado para esta empresa"),
|
|
929
|
+
// e colapsá-lo para nulo faria a empresa voltar a herdar o padrão do host — exatamente o
|
|
930
|
+
// oposto do que o operador acabou de pedir.
|
|
931
|
+
transcriptionEnabled: merged.transcriptionEnabled,
|
|
932
|
+
transcriptionMode: merged.transcriptionMode
|
|
623
933
|
}).onConflictDoUpdate({
|
|
624
934
|
target: settings.companyId,
|
|
625
935
|
set: {
|
|
@@ -628,6 +938,8 @@ var SettingsRepository = class {
|
|
|
628
938
|
templateVariables: merged.templateVariables,
|
|
629
939
|
welcomeMessage: merged.welcomeMessage || null,
|
|
630
940
|
farewellMessage: merged.farewellMessage || null,
|
|
941
|
+
transcriptionEnabled: merged.transcriptionEnabled,
|
|
942
|
+
transcriptionMode: merged.transcriptionMode,
|
|
631
943
|
updatedAt: /* @__PURE__ */ new Date()
|
|
632
944
|
}
|
|
633
945
|
}).returning();
|
|
@@ -706,11 +1018,13 @@ var SendMessageUseCase = class {
|
|
|
706
1018
|
sessionRepository;
|
|
707
1019
|
logMessage;
|
|
708
1020
|
objectStorage;
|
|
709
|
-
|
|
1021
|
+
documentRepository;
|
|
1022
|
+
constructor(channel, sessionRepository, logMessage, objectStorage, documentRepository) {
|
|
710
1023
|
this.channel = channel;
|
|
711
1024
|
this.sessionRepository = sessionRepository;
|
|
712
1025
|
this.logMessage = logMessage;
|
|
713
1026
|
this.objectStorage = objectStorage;
|
|
1027
|
+
this.documentRepository = documentRepository;
|
|
714
1028
|
}
|
|
715
1029
|
async assertWithinWindow(companyId, whatsappNumber) {
|
|
716
1030
|
const hours = await this.sessionRepository.hoursSinceLastInbound(companyId, whatsappNumber);
|
|
@@ -747,7 +1061,7 @@ var SendMessageUseCase = class {
|
|
|
747
1061
|
mimeType: params.mimeType,
|
|
748
1062
|
key: `meta-whatsapp/${params.companyId}/${Date.now()}-${params.filename}`
|
|
749
1063
|
})).uploadId : void 0;
|
|
750
|
-
|
|
1064
|
+
const saved = await this.logMessage.execute({
|
|
751
1065
|
companyId: params.companyId,
|
|
752
1066
|
whatsappNumber: params.whatsappNumber,
|
|
753
1067
|
direction: "outbound",
|
|
@@ -766,6 +1080,19 @@ var SendMessageUseCase = class {
|
|
|
766
1080
|
status: "sent",
|
|
767
1081
|
startState: params.startState
|
|
768
1082
|
});
|
|
1083
|
+
if (saved && uploadId) {
|
|
1084
|
+
await this.documentRepository?.link({
|
|
1085
|
+
companyId: params.companyId,
|
|
1086
|
+
sessionId: saved.sessionId,
|
|
1087
|
+
messageId: saved.id,
|
|
1088
|
+
uploadId,
|
|
1089
|
+
filename: params.filename,
|
|
1090
|
+
mimeType: params.mimeType,
|
|
1091
|
+
sizeBytes: params.buffer.length,
|
|
1092
|
+
source: params.sender
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
return saved;
|
|
769
1096
|
}
|
|
770
1097
|
// Template é o único envio que ignora a janela — é justamente o mecanismo que a Meta oferece
|
|
771
1098
|
// para reabri-la.
|
|
@@ -883,8 +1210,330 @@ var ListMessagesUseCase = class {
|
|
|
883
1210
|
}
|
|
884
1211
|
};
|
|
885
1212
|
|
|
886
|
-
// src/use-cases/
|
|
1213
|
+
// src/use-cases/ListConversationDocuments.use-case.ts
|
|
887
1214
|
var import_meta_whatsapp_contracts4 = require("@adatechnology/meta-whatsapp-contracts");
|
|
1215
|
+
var ListConversationDocumentsUseCase = class {
|
|
1216
|
+
static {
|
|
1217
|
+
__name(this, "ListConversationDocumentsUseCase");
|
|
1218
|
+
}
|
|
1219
|
+
sessionRepository;
|
|
1220
|
+
documentRepository;
|
|
1221
|
+
constructor(sessionRepository, documentRepository) {
|
|
1222
|
+
this.sessionRepository = sessionRepository;
|
|
1223
|
+
this.documentRepository = documentRepository;
|
|
1224
|
+
}
|
|
1225
|
+
async execute(params) {
|
|
1226
|
+
const session = await this.sessionRepository.getContext(params.companyId, params.whatsappNumber);
|
|
1227
|
+
if (!session) throw new import_meta_whatsapp_contracts4.SessionNotFoundError(params.whatsappNumber);
|
|
1228
|
+
const { rows, total } = await this.documentRepository.listByConversation({
|
|
1229
|
+
companyId: params.companyId,
|
|
1230
|
+
sessionId: session.id,
|
|
1231
|
+
...params.search ? {
|
|
1232
|
+
search: params.search
|
|
1233
|
+
} : {},
|
|
1234
|
+
...params.sources && params.sources.length > 0 ? {
|
|
1235
|
+
sources: params.sources
|
|
1236
|
+
} : {},
|
|
1237
|
+
...params.sortDirection ? {
|
|
1238
|
+
sortDirection: params.sortDirection
|
|
1239
|
+
} : {},
|
|
1240
|
+
...params.page ? {
|
|
1241
|
+
page: params.page
|
|
1242
|
+
} : {},
|
|
1243
|
+
...params.limit ? {
|
|
1244
|
+
limit: params.limit
|
|
1245
|
+
} : {}
|
|
1246
|
+
});
|
|
1247
|
+
return {
|
|
1248
|
+
documents: rows.map((row) => ({
|
|
1249
|
+
id: row.uploadId,
|
|
1250
|
+
filename: row.filename,
|
|
1251
|
+
mimeType: row.mimeType,
|
|
1252
|
+
sizeBytes: row.sizeBytes,
|
|
1253
|
+
source: row.source,
|
|
1254
|
+
linkedAt: row.linkedAt.toISOString()
|
|
1255
|
+
})),
|
|
1256
|
+
total
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
};
|
|
1260
|
+
|
|
1261
|
+
// src/use-cases/ListCompanyDocuments.use-case.ts
|
|
1262
|
+
var ListCompanyDocumentsUseCase = class {
|
|
1263
|
+
static {
|
|
1264
|
+
__name(this, "ListCompanyDocumentsUseCase");
|
|
1265
|
+
}
|
|
1266
|
+
documentRepository;
|
|
1267
|
+
constructor(documentRepository) {
|
|
1268
|
+
this.documentRepository = documentRepository;
|
|
1269
|
+
}
|
|
1270
|
+
async execute(params) {
|
|
1271
|
+
const { rows, total } = await this.documentRepository.listByCompany({
|
|
1272
|
+
companyId: params.companyId,
|
|
1273
|
+
...params.search ? {
|
|
1274
|
+
search: params.search
|
|
1275
|
+
} : {},
|
|
1276
|
+
...params.sources && params.sources.length > 0 ? {
|
|
1277
|
+
sources: params.sources
|
|
1278
|
+
} : {},
|
|
1279
|
+
...params.sortDirection ? {
|
|
1280
|
+
sortDirection: params.sortDirection
|
|
1281
|
+
} : {},
|
|
1282
|
+
...params.page ? {
|
|
1283
|
+
page: params.page
|
|
1284
|
+
} : {},
|
|
1285
|
+
...params.limit ? {
|
|
1286
|
+
limit: params.limit
|
|
1287
|
+
} : {}
|
|
1288
|
+
});
|
|
1289
|
+
return {
|
|
1290
|
+
documents: rows.map((row) => ({
|
|
1291
|
+
id: row.uploadId,
|
|
1292
|
+
conversationId: row.whatsappNumber,
|
|
1293
|
+
filename: row.filename,
|
|
1294
|
+
mimeType: row.mimeType,
|
|
1295
|
+
sizeBytes: row.sizeBytes,
|
|
1296
|
+
source: row.source,
|
|
1297
|
+
linkedAt: row.linkedAt.toISOString()
|
|
1298
|
+
})),
|
|
1299
|
+
total
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
};
|
|
1303
|
+
|
|
1304
|
+
// src/use-cases/DeleteConversation.use-case.ts
|
|
1305
|
+
var import_meta_whatsapp_contracts5 = require("@adatechnology/meta-whatsapp-contracts");
|
|
1306
|
+
var DeleteConversationUseCase = class {
|
|
1307
|
+
static {
|
|
1308
|
+
__name(this, "DeleteConversationUseCase");
|
|
1309
|
+
}
|
|
1310
|
+
sessionRepository;
|
|
1311
|
+
documentRepository;
|
|
1312
|
+
objectStorage;
|
|
1313
|
+
constructor(sessionRepository, documentRepository, objectStorage) {
|
|
1314
|
+
this.sessionRepository = sessionRepository;
|
|
1315
|
+
this.documentRepository = documentRepository;
|
|
1316
|
+
this.objectStorage = objectStorage;
|
|
1317
|
+
}
|
|
1318
|
+
async execute(params) {
|
|
1319
|
+
const session = await this.sessionRepository.getContext(params.companyId, params.whatsappNumber);
|
|
1320
|
+
if (!session) throw new import_meta_whatsapp_contracts5.SessionNotFoundError(params.whatsappNumber);
|
|
1321
|
+
const uploadIds = await this.documentRepository.listUploadIdsBySession(params.companyId, session.id);
|
|
1322
|
+
if (uploadIds.length > 0 && !this.objectStorage?.delete) {
|
|
1323
|
+
throw new Error("storage_delete_unsupported: a conversa tem arquivos e o storage injetado n\xE3o implementa delete");
|
|
1324
|
+
}
|
|
1325
|
+
const failedObjects = [];
|
|
1326
|
+
let deletedObjects = 0;
|
|
1327
|
+
for (const uploadId of uploadIds) {
|
|
1328
|
+
try {
|
|
1329
|
+
await this.objectStorage?.delete?.(uploadId);
|
|
1330
|
+
deletedObjects++;
|
|
1331
|
+
} catch {
|
|
1332
|
+
failedObjects.push(uploadId);
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
if (failedObjects.length > 0) return {
|
|
1336
|
+
deletedObjects,
|
|
1337
|
+
failedObjects
|
|
1338
|
+
};
|
|
1339
|
+
await this.sessionRepository.deleteByNumber(params.companyId, params.whatsappNumber);
|
|
1340
|
+
return {
|
|
1341
|
+
deletedObjects,
|
|
1342
|
+
failedObjects: []
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1345
|
+
};
|
|
1346
|
+
|
|
1347
|
+
// src/use-cases/PurgeExpiredDocuments.use-case.ts
|
|
1348
|
+
var DEFAULT_BATCH_SIZE = 50;
|
|
1349
|
+
var HOURS_IN_DAY = 24;
|
|
1350
|
+
var MILLISECONDS_IN_HOUR = 60 * 60 * 1e3;
|
|
1351
|
+
var PurgeExpiredDocumentsUseCase = class {
|
|
1352
|
+
static {
|
|
1353
|
+
__name(this, "PurgeExpiredDocumentsUseCase");
|
|
1354
|
+
}
|
|
1355
|
+
documentRepository;
|
|
1356
|
+
objectStorage;
|
|
1357
|
+
constructor(documentRepository, objectStorage) {
|
|
1358
|
+
this.documentRepository = documentRepository;
|
|
1359
|
+
this.objectStorage = objectStorage;
|
|
1360
|
+
}
|
|
1361
|
+
async execute(params) {
|
|
1362
|
+
if (!this.objectStorage?.delete) {
|
|
1363
|
+
throw new Error("storage_delete_unsupported: reten\xE7\xE3o exige um storage que implemente delete");
|
|
1364
|
+
}
|
|
1365
|
+
const reference = params.now ?? /* @__PURE__ */ new Date();
|
|
1366
|
+
const olderThan = new Date(reference.getTime() - params.retentionDays * HOURS_IN_DAY * MILLISECONDS_IN_HOUR);
|
|
1367
|
+
const expired = await this.documentRepository.listExpired(params.companyId, olderThan, params.batchSize ?? DEFAULT_BATCH_SIZE);
|
|
1368
|
+
const failed = [];
|
|
1369
|
+
let purged = 0;
|
|
1370
|
+
for (const document of expired) {
|
|
1371
|
+
try {
|
|
1372
|
+
await this.objectStorage.delete(document.uploadId);
|
|
1373
|
+
} catch {
|
|
1374
|
+
failed.push(document.uploadId);
|
|
1375
|
+
continue;
|
|
1376
|
+
}
|
|
1377
|
+
await this.documentRepository.deleteById(params.companyId, document.id);
|
|
1378
|
+
purged++;
|
|
1379
|
+
}
|
|
1380
|
+
return {
|
|
1381
|
+
purged,
|
|
1382
|
+
failed
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
};
|
|
1386
|
+
|
|
1387
|
+
// src/repositories/DocumentRepository.ts
|
|
1388
|
+
var import_drizzle_orm6 = require("drizzle-orm");
|
|
1389
|
+
var DEFAULT_LIMIT3 = 50;
|
|
1390
|
+
function companyDocumentSearch(search) {
|
|
1391
|
+
const term = search?.trim();
|
|
1392
|
+
if (!term) return void 0;
|
|
1393
|
+
const digits = term.replace(/\D/g, "");
|
|
1394
|
+
const byFilename = (0, import_drizzle_orm6.ilike)(documents.filename, `%${term}%`);
|
|
1395
|
+
if (!digits) return byFilename;
|
|
1396
|
+
return (0, import_drizzle_orm6.or)(byFilename, (0, import_drizzle_orm6.ilike)(sessions.whatsappNumber, `%${digits}%`));
|
|
1397
|
+
}
|
|
1398
|
+
__name(companyDocumentSearch, "companyDocumentSearch");
|
|
1399
|
+
var DocumentRepository = class {
|
|
1400
|
+
static {
|
|
1401
|
+
__name(this, "DocumentRepository");
|
|
1402
|
+
}
|
|
1403
|
+
db;
|
|
1404
|
+
constructor(db) {
|
|
1405
|
+
this.db = db;
|
|
1406
|
+
}
|
|
1407
|
+
/**
|
|
1408
|
+
* Idempotente por (companyId, uploadId), garantido pelo índice único e não por SELECT prévio: o
|
|
1409
|
+
* job de ingestão é reentregue por retry e duas tentativas concorrentes passariam as duas por uma
|
|
1410
|
+
* checagem, duplicando a linha no painel.
|
|
1411
|
+
*
|
|
1412
|
+
* Devolve `undefined` quando o documento já estava linkado.
|
|
1413
|
+
*/
|
|
1414
|
+
async link(params) {
|
|
1415
|
+
const values = {
|
|
1416
|
+
companyId: params.companyId,
|
|
1417
|
+
sessionId: params.sessionId,
|
|
1418
|
+
messageId: params.messageId ?? null,
|
|
1419
|
+
uploadId: params.uploadId,
|
|
1420
|
+
filename: params.filename,
|
|
1421
|
+
mimeType: params.mimeType,
|
|
1422
|
+
sizeBytes: params.sizeBytes,
|
|
1423
|
+
sha256: params.sha256 ?? null,
|
|
1424
|
+
source: params.source
|
|
1425
|
+
};
|
|
1426
|
+
const [created] = await this.db.insert(documents).values(values).onConflictDoNothing().returning();
|
|
1427
|
+
return created;
|
|
1428
|
+
}
|
|
1429
|
+
async listByConversation(params) {
|
|
1430
|
+
const filters = [
|
|
1431
|
+
(0, import_drizzle_orm6.eq)(documents.companyId, params.companyId),
|
|
1432
|
+
(0, import_drizzle_orm6.eq)(documents.sessionId, params.sessionId)
|
|
1433
|
+
];
|
|
1434
|
+
if (params.search) filters.push((0, import_drizzle_orm6.ilike)(documents.filename, `%${params.search}%`));
|
|
1435
|
+
if (params.sources && params.sources.length > 0) {
|
|
1436
|
+
filters.push((0, import_drizzle_orm6.inArray)(documents.source, [
|
|
1437
|
+
...params.sources
|
|
1438
|
+
]));
|
|
1439
|
+
}
|
|
1440
|
+
const where = (0, import_drizzle_orm6.and)(...filters);
|
|
1441
|
+
const limit = params.limit ?? DEFAULT_LIMIT3;
|
|
1442
|
+
const page = params.page && params.page > 0 ? params.page : 1;
|
|
1443
|
+
const orderBy = params.sortDirection === "asc" ? (0, import_drizzle_orm6.asc)(documents.linkedAt) : (0, import_drizzle_orm6.desc)(documents.linkedAt);
|
|
1444
|
+
const [rows, counted] = await Promise.all([
|
|
1445
|
+
this.db.select().from(documents).where(where).orderBy(orderBy).limit(limit).offset((page - 1) * limit),
|
|
1446
|
+
this.db.select({
|
|
1447
|
+
value: (0, import_drizzle_orm6.count)()
|
|
1448
|
+
}).from(documents).where(where)
|
|
1449
|
+
]);
|
|
1450
|
+
return {
|
|
1451
|
+
rows,
|
|
1452
|
+
total: counted[0]?.value ?? 0
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1455
|
+
/**
|
|
1456
|
+
* A biblioteca da EMPRESA inteira, não de uma conversa.
|
|
1457
|
+
*
|
|
1458
|
+
* A busca casa nome do arquivo OU telefone da conversa — ver `companyDocumentSearch`.
|
|
1459
|
+
*
|
|
1460
|
+
* Faz join com `sessions` para carregar de qual conversa cada arquivo veio — numa lista global,
|
|
1461
|
+
* arquivo sem essa referência é inútil: o atendente vê "comprovante.pdf" e não sabe de quem.
|
|
1462
|
+
*
|
|
1463
|
+
* Ordena por `linkedAt` apoiada no índice `idx_documents_company_linked`, que já existia para a
|
|
1464
|
+
* varredura de retenção.
|
|
1465
|
+
*/
|
|
1466
|
+
async listByCompany(params) {
|
|
1467
|
+
const filters = [
|
|
1468
|
+
(0, import_drizzle_orm6.eq)(documents.companyId, params.companyId)
|
|
1469
|
+
];
|
|
1470
|
+
const search = companyDocumentSearch(params.search);
|
|
1471
|
+
if (search) filters.push(search);
|
|
1472
|
+
if (params.sources && params.sources.length > 0) {
|
|
1473
|
+
filters.push((0, import_drizzle_orm6.inArray)(documents.source, [
|
|
1474
|
+
...params.sources
|
|
1475
|
+
]));
|
|
1476
|
+
}
|
|
1477
|
+
const where = (0, import_drizzle_orm6.and)(...filters);
|
|
1478
|
+
const limit = params.limit ?? DEFAULT_LIMIT3;
|
|
1479
|
+
const page = params.page && params.page > 0 ? params.page : 1;
|
|
1480
|
+
const orderBy = params.sortDirection === "asc" ? (0, import_drizzle_orm6.asc)(documents.linkedAt) : (0, import_drizzle_orm6.desc)(documents.linkedAt);
|
|
1481
|
+
const [rows, counted] = await Promise.all([
|
|
1482
|
+
this.db.select({
|
|
1483
|
+
id: documents.id,
|
|
1484
|
+
uploadId: documents.uploadId,
|
|
1485
|
+
filename: documents.filename,
|
|
1486
|
+
mimeType: documents.mimeType,
|
|
1487
|
+
sizeBytes: documents.sizeBytes,
|
|
1488
|
+
source: documents.source,
|
|
1489
|
+
linkedAt: documents.linkedAt,
|
|
1490
|
+
// Só o número: o NOME do cliente é dado do produto (tabela própria dele), não do
|
|
1491
|
+
// módulo. Quem quiser exibir "Marina Alves" enriquece na borda.
|
|
1492
|
+
whatsappNumber: sessions.whatsappNumber
|
|
1493
|
+
}).from(documents).innerJoin(sessions, (0, import_drizzle_orm6.eq)(documents.sessionId, sessions.id)).where(where).orderBy(orderBy).limit(limit).offset((page - 1) * limit),
|
|
1494
|
+
// O mesmo join da listagem, e não só `from(documents)`: a busca pode citar
|
|
1495
|
+
// `sessions.whatsapp_number`, e uma contagem sem a tabela na cláusula não compila — pior,
|
|
1496
|
+
// se compilasse, o total divergiria das linhas e a paginação prometeria páginas vazias.
|
|
1497
|
+
this.db.select({
|
|
1498
|
+
value: (0, import_drizzle_orm6.count)()
|
|
1499
|
+
}).from(documents).innerJoin(sessions, (0, import_drizzle_orm6.eq)(documents.sessionId, sessions.id)).where(where)
|
|
1500
|
+
]);
|
|
1501
|
+
return {
|
|
1502
|
+
rows,
|
|
1503
|
+
total: counted[0]?.value ?? 0
|
|
1504
|
+
};
|
|
1505
|
+
}
|
|
1506
|
+
/**
|
|
1507
|
+
* Um documento pela key do objeto. Serve para recuperar o nome original na hora de assinar o
|
|
1508
|
+
* download: a key é caminho no bucket e salvaria o arquivo com o id da Meta.
|
|
1509
|
+
*/
|
|
1510
|
+
async findByUploadId(companyId, uploadId) {
|
|
1511
|
+
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);
|
|
1512
|
+
return row;
|
|
1513
|
+
}
|
|
1514
|
+
/**
|
|
1515
|
+
* Os objetos a apagar no storage antes de a linha sumir.
|
|
1516
|
+
*
|
|
1517
|
+
* Existe porque a cascata da FK apaga a linha e deixa o binário órfão: quem for apagar a conversa
|
|
1518
|
+
* precisa desta lista primeiro, senão paga armazenamento para sempre por arquivo inalcançável.
|
|
1519
|
+
*/
|
|
1520
|
+
async listUploadIdsBySession(companyId, sessionId) {
|
|
1521
|
+
const rows = await this.db.select({
|
|
1522
|
+
uploadId: documents.uploadId
|
|
1523
|
+
}).from(documents).where((0, import_drizzle_orm6.and)((0, import_drizzle_orm6.eq)(documents.companyId, companyId), (0, import_drizzle_orm6.eq)(documents.sessionId, sessionId)));
|
|
1524
|
+
return rows.map((row) => row.uploadId);
|
|
1525
|
+
}
|
|
1526
|
+
/** Varredura de retenção por idade — o par é o mesmo cuidado com o objeto no storage. */
|
|
1527
|
+
async listExpired(companyId, olderThan, limit = DEFAULT_LIMIT3) {
|
|
1528
|
+
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);
|
|
1529
|
+
}
|
|
1530
|
+
async deleteById(companyId, id) {
|
|
1531
|
+
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)));
|
|
1532
|
+
}
|
|
1533
|
+
};
|
|
1534
|
+
|
|
1535
|
+
// src/use-cases/ExportConversation.use-case.ts
|
|
1536
|
+
var import_meta_whatsapp_contracts6 = require("@adatechnology/meta-whatsapp-contracts");
|
|
888
1537
|
var ExportConversationUseCase = class {
|
|
889
1538
|
static {
|
|
890
1539
|
__name(this, "ExportConversationUseCase");
|
|
@@ -895,7 +1544,7 @@ var ExportConversationUseCase = class {
|
|
|
895
1544
|
}
|
|
896
1545
|
async execute(params) {
|
|
897
1546
|
const result = await this.sessionRepository.exportConversation(params.companyId, params.whatsappNumber);
|
|
898
|
-
if (!result) throw new
|
|
1547
|
+
if (!result) throw new import_meta_whatsapp_contracts6.SessionNotFoundError(params.whatsappNumber);
|
|
899
1548
|
return result;
|
|
900
1549
|
}
|
|
901
1550
|
};
|
|
@@ -975,7 +1624,7 @@ var GetLiveFlowPositionsUseCase = class {
|
|
|
975
1624
|
};
|
|
976
1625
|
|
|
977
1626
|
// src/flows/FlowInterpreter.ts
|
|
978
|
-
var
|
|
1627
|
+
var import_meta_whatsapp_contracts7 = require("@adatechnology/meta-whatsapp-contracts");
|
|
979
1628
|
var DEFAULT_MAX_STEPS = 50;
|
|
980
1629
|
function evaluateCondition(node, context) {
|
|
981
1630
|
if (!node.conditionContextKey || !node.conditionOperator || !node.conditionValue) return false;
|
|
@@ -1076,9 +1725,9 @@ var FlowInterpreter = class {
|
|
|
1076
1725
|
kind: "terminal",
|
|
1077
1726
|
context: input.context
|
|
1078
1727
|
};
|
|
1079
|
-
if ((0,
|
|
1728
|
+
if ((0, import_meta_whatsapp_contracts7.isCrossFlowTarget)(nextNodeId)) return {
|
|
1080
1729
|
kind: "cross-flow",
|
|
1081
|
-
flowKey: (0,
|
|
1730
|
+
flowKey: (0, import_meta_whatsapp_contracts7.crossFlowKey)(nextNodeId),
|
|
1082
1731
|
context: input.context
|
|
1083
1732
|
};
|
|
1084
1733
|
if (!input.graph.nodes[nextNodeId]) return {
|
|
@@ -1132,45 +1781,341 @@ var FlowInterpreter = class {
|
|
|
1132
1781
|
}
|
|
1133
1782
|
};
|
|
1134
1783
|
|
|
1135
|
-
// src/
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1784
|
+
// src/flows/createSendMediaAction.ts
|
|
1785
|
+
function mediaTypeFor2(mimeType) {
|
|
1786
|
+
if (mimeType.startsWith("image/")) return "image";
|
|
1787
|
+
if (mimeType.startsWith("audio/")) return "audio";
|
|
1788
|
+
if (mimeType.startsWith("video/")) return "video";
|
|
1789
|
+
return "document";
|
|
1790
|
+
}
|
|
1791
|
+
__name(mediaTypeFor2, "mediaTypeFor");
|
|
1792
|
+
async function sendAttachment(params) {
|
|
1793
|
+
const { attachment, channel, to, cache } = params;
|
|
1794
|
+
const common = {
|
|
1795
|
+
to,
|
|
1796
|
+
mimeType: attachment.mimeType,
|
|
1797
|
+
filename: attachment.filename,
|
|
1798
|
+
caption: attachment.caption ?? void 0
|
|
1799
|
+
};
|
|
1800
|
+
const cacheKey = cache ? {
|
|
1801
|
+
flowMediaId: attachment.id,
|
|
1802
|
+
senderKey: cache.senderKey
|
|
1803
|
+
} : void 0;
|
|
1804
|
+
if (cache && cacheKey) {
|
|
1805
|
+
const knownMediaId = await cache.store.get(cacheKey);
|
|
1806
|
+
if (knownMediaId) {
|
|
1807
|
+
try {
|
|
1808
|
+
return await channel.sendMedia({
|
|
1809
|
+
...common,
|
|
1810
|
+
mediaId: knownMediaId
|
|
1811
|
+
});
|
|
1812
|
+
} catch {
|
|
1813
|
+
await cache.store.clear(cacheKey);
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1145
1816
|
}
|
|
1146
|
-
|
|
1817
|
+
const buffer = await params.objectStorage.getObject(attachment.uploadId);
|
|
1818
|
+
const result = await channel.sendMedia({
|
|
1819
|
+
...common,
|
|
1820
|
+
buffer
|
|
1821
|
+
});
|
|
1822
|
+
if (cache && cacheKey && result.mediaId) await cache.store.set({
|
|
1823
|
+
...cacheKey,
|
|
1824
|
+
mediaId: result.mediaId
|
|
1825
|
+
});
|
|
1826
|
+
return result;
|
|
1827
|
+
}
|
|
1828
|
+
__name(sendAttachment, "sendAttachment");
|
|
1829
|
+
function createSendMediaAction(params) {
|
|
1830
|
+
return async ({ node, session, channel }) => {
|
|
1831
|
+
if (!session.flowKey) return;
|
|
1832
|
+
const location = {
|
|
1833
|
+
companyId: session.companyId,
|
|
1834
|
+
flowKey: session.flowKey,
|
|
1835
|
+
nodeId: node.id
|
|
1836
|
+
};
|
|
1837
|
+
const attachments = await params.flowMediaRepository.listActive(location);
|
|
1838
|
+
for (const attachment of attachments) {
|
|
1839
|
+
try {
|
|
1840
|
+
const { externalMessageId } = await sendAttachment({
|
|
1841
|
+
attachment,
|
|
1842
|
+
channel,
|
|
1843
|
+
to: session.whatsappNumber,
|
|
1844
|
+
objectStorage: params.objectStorage,
|
|
1845
|
+
...params.mediaIdCache ? {
|
|
1846
|
+
cache: params.mediaIdCache
|
|
1847
|
+
} : {}
|
|
1848
|
+
});
|
|
1849
|
+
await params.logMessage.execute({
|
|
1850
|
+
companyId: session.companyId,
|
|
1851
|
+
whatsappNumber: session.whatsappNumber,
|
|
1852
|
+
direction: "outbound",
|
|
1853
|
+
sender: "bot",
|
|
1854
|
+
agentUserId: null,
|
|
1855
|
+
type: mediaTypeFor2(attachment.mimeType),
|
|
1856
|
+
content: attachment.caption ?? attachment.filename,
|
|
1857
|
+
payload: {
|
|
1858
|
+
filename: attachment.filename,
|
|
1859
|
+
mimeType: attachment.mimeType,
|
|
1860
|
+
uploadId: attachment.uploadId,
|
|
1861
|
+
flowMediaId: attachment.id
|
|
1862
|
+
},
|
|
1863
|
+
waMessageId: externalMessageId,
|
|
1864
|
+
status: "sent",
|
|
1865
|
+
startState: params.startState
|
|
1866
|
+
});
|
|
1867
|
+
} catch (error) {
|
|
1868
|
+
params.onError?.(error, {
|
|
1869
|
+
flowKey: location.flowKey,
|
|
1870
|
+
nodeId: node.id,
|
|
1871
|
+
uploadId: attachment.uploadId
|
|
1872
|
+
});
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
};
|
|
1876
|
+
}
|
|
1877
|
+
__name(createSendMediaAction, "createSendMediaAction");
|
|
1878
|
+
|
|
1879
|
+
// src/flows/createSendProductListAction.ts
|
|
1880
|
+
var PRODUCT_LIST_LIMIT = {
|
|
1881
|
+
ITEMS: 30,
|
|
1882
|
+
SECTIONS: 10
|
|
1883
|
+
};
|
|
1884
|
+
function createSendProductListAction(params) {
|
|
1885
|
+
return async ({ node, session, channel }) => {
|
|
1886
|
+
if (!channel.sendProductList) return;
|
|
1887
|
+
const actionParams = node.actionParams ?? {};
|
|
1147
1888
|
try {
|
|
1148
|
-
|
|
1889
|
+
const available = await listAvailableProducts({
|
|
1890
|
+
catalog: params.catalog,
|
|
1891
|
+
catalogId: params.catalogId,
|
|
1892
|
+
...actionParams.search ? {
|
|
1893
|
+
search: actionParams.search
|
|
1894
|
+
} : {}
|
|
1895
|
+
});
|
|
1896
|
+
if (available.length === 0) return;
|
|
1897
|
+
const bodyText = actionParams.bodyText ?? "Veja o que temos dispon\xEDvel:";
|
|
1898
|
+
const { externalMessageId } = await channel.sendProductList({
|
|
1899
|
+
to: session.whatsappNumber,
|
|
1900
|
+
headerText: actionParams.headerText ?? "Nossos produtos",
|
|
1901
|
+
body: bodyText,
|
|
1902
|
+
...actionParams.footerText ? {
|
|
1903
|
+
footerText: actionParams.footerText
|
|
1904
|
+
} : {},
|
|
1905
|
+
sections: [
|
|
1906
|
+
{
|
|
1907
|
+
title: actionParams.sectionTitle ?? "Dispon\xEDveis",
|
|
1908
|
+
retailerIds: available.map((product) => product.retailerId)
|
|
1909
|
+
}
|
|
1910
|
+
]
|
|
1911
|
+
});
|
|
1912
|
+
await params.logMessage.execute({
|
|
1913
|
+
companyId: session.companyId,
|
|
1914
|
+
whatsappNumber: session.whatsappNumber,
|
|
1915
|
+
direction: "outbound",
|
|
1916
|
+
sender: "bot",
|
|
1917
|
+
agentUserId: null,
|
|
1918
|
+
type: "interactive",
|
|
1919
|
+
content: bodyText,
|
|
1920
|
+
payload: {
|
|
1921
|
+
kind: "product_list",
|
|
1922
|
+
productCount: available.length
|
|
1923
|
+
},
|
|
1924
|
+
waMessageId: externalMessageId,
|
|
1925
|
+
status: "sent",
|
|
1926
|
+
startState: params.startState
|
|
1927
|
+
});
|
|
1149
1928
|
} catch (error) {
|
|
1150
|
-
|
|
1151
|
-
|
|
1929
|
+
params.onError?.(error, {
|
|
1930
|
+
flowKey: session.flowKey ?? "",
|
|
1931
|
+
nodeId: node.id
|
|
1932
|
+
});
|
|
1152
1933
|
}
|
|
1934
|
+
};
|
|
1935
|
+
}
|
|
1936
|
+
__name(createSendProductListAction, "createSendProductListAction");
|
|
1937
|
+
async function listAvailableProducts(params) {
|
|
1938
|
+
const products = await params.catalog.listProducts({
|
|
1939
|
+
catalogId: params.catalogId,
|
|
1940
|
+
...params.search ? {
|
|
1941
|
+
search: params.search
|
|
1942
|
+
} : {}
|
|
1943
|
+
});
|
|
1944
|
+
return products.filter((product) => product.availability === "in stock").slice(0, PRODUCT_LIST_LIMIT.ITEMS);
|
|
1945
|
+
}
|
|
1946
|
+
__name(listAvailableProducts, "listAvailableProducts");
|
|
1947
|
+
|
|
1948
|
+
// src/repositories/FlowMediaRepository.ts
|
|
1949
|
+
var import_drizzle_orm7 = require("drizzle-orm");
|
|
1950
|
+
var FlowMediaRepository = class {
|
|
1951
|
+
static {
|
|
1952
|
+
__name(this, "FlowMediaRepository");
|
|
1153
1953
|
}
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
externalMessageId: result.waMessageId
|
|
1158
|
-
};
|
|
1159
|
-
}
|
|
1160
|
-
async sendMedia(params) {
|
|
1161
|
-
const result = await this.translateErrors(() => this.messages.sendMedia(params));
|
|
1162
|
-
return {
|
|
1163
|
-
externalMessageId: result.waMessageId
|
|
1164
|
-
};
|
|
1165
|
-
}
|
|
1166
|
-
async sendTemplate(params) {
|
|
1167
|
-
const result = await this.translateErrors(() => this.messages.sendTemplate(params));
|
|
1168
|
-
return {
|
|
1169
|
-
externalMessageId: result.waMessageId
|
|
1170
|
-
};
|
|
1954
|
+
db;
|
|
1955
|
+
constructor(db) {
|
|
1956
|
+
this.db = db;
|
|
1171
1957
|
}
|
|
1172
|
-
|
|
1173
|
-
|
|
1958
|
+
locationFilter(location) {
|
|
1959
|
+
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));
|
|
1960
|
+
}
|
|
1961
|
+
// O que o nó realmente envia, na ordem de envio. `active` filtrado aqui e não no chamador:
|
|
1962
|
+
// é a razão de a coluna existir, e um chamador que esquecesse do filtro mandaria ao cliente
|
|
1963
|
+
// justamente o material que alguém desligou.
|
|
1964
|
+
async listActive(location) {
|
|
1965
|
+
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));
|
|
1966
|
+
}
|
|
1967
|
+
// Inclui os desligados — é a visão do editor, onde desligar precisa continuar visível para
|
|
1968
|
+
// poder ser religado.
|
|
1969
|
+
async listAll(location) {
|
|
1970
|
+
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));
|
|
1971
|
+
}
|
|
1972
|
+
/**
|
|
1973
|
+
* Anexa um arquivo já existente no storage ao nó.
|
|
1974
|
+
*
|
|
1975
|
+
* `onConflictDoUpdate` em vez de deixar estourar: reanexar o mesmo arquivo é clique repetido no
|
|
1976
|
+
* editor, e o esperado ali é atualizar a legenda/ordem — não um erro de índice único na cara de
|
|
1977
|
+
* quem está montando o fluxo.
|
|
1978
|
+
*/
|
|
1979
|
+
async attach(params) {
|
|
1980
|
+
const [row] = await this.db.insert(flowMedia).values({
|
|
1981
|
+
companyId: params.companyId,
|
|
1982
|
+
flowKey: params.flowKey,
|
|
1983
|
+
nodeId: params.nodeId,
|
|
1984
|
+
uploadId: params.uploadId,
|
|
1985
|
+
filename: params.filename,
|
|
1986
|
+
mimeType: params.mimeType,
|
|
1987
|
+
sizeBytes: params.sizeBytes,
|
|
1988
|
+
caption: params.caption ?? null,
|
|
1989
|
+
sortOrder: params.sortOrder ?? 0
|
|
1990
|
+
}).onConflictDoUpdate({
|
|
1991
|
+
target: [
|
|
1992
|
+
flowMedia.companyId,
|
|
1993
|
+
flowMedia.flowKey,
|
|
1994
|
+
flowMedia.nodeId,
|
|
1995
|
+
flowMedia.uploadId
|
|
1996
|
+
],
|
|
1997
|
+
set: {
|
|
1998
|
+
caption: params.caption ?? null,
|
|
1999
|
+
sortOrder: params.sortOrder ?? 0,
|
|
2000
|
+
active: true,
|
|
2001
|
+
updatedAt: import_drizzle_orm7.sql`now()`
|
|
2002
|
+
}
|
|
2003
|
+
}).returning();
|
|
2004
|
+
return row;
|
|
2005
|
+
}
|
|
2006
|
+
async update(params) {
|
|
2007
|
+
const [row] = await this.db.update(flowMedia).set({
|
|
2008
|
+
...params.caption !== void 0 ? {
|
|
2009
|
+
caption: params.caption
|
|
2010
|
+
} : {},
|
|
2011
|
+
...params.sortOrder !== void 0 ? {
|
|
2012
|
+
sortOrder: params.sortOrder
|
|
2013
|
+
} : {},
|
|
2014
|
+
...params.active !== void 0 ? {
|
|
2015
|
+
active: params.active
|
|
2016
|
+
} : {},
|
|
2017
|
+
updatedAt: import_drizzle_orm7.sql`now()`
|
|
2018
|
+
}).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();
|
|
2019
|
+
return row;
|
|
2020
|
+
}
|
|
2021
|
+
/**
|
|
2022
|
+
* Desanexa do nó. Não toca no storage de propósito: o mesmo `uploadId` pode estar anexado a
|
|
2023
|
+
* outro nó ou a outro fluxo, e apagar o binário aqui quebraria os demais. Quem apaga objeto é o
|
|
2024
|
+
* host, que é dono da biblioteca de arquivos.
|
|
2025
|
+
*/
|
|
2026
|
+
async detach(params) {
|
|
2027
|
+
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)));
|
|
2028
|
+
}
|
|
2029
|
+
// Chamado ao salvar o grafo: nós apagados no editor deixam linhas que nada mais alcança.
|
|
2030
|
+
async detachRemovedNodes(params) {
|
|
2031
|
+
const condition = params.existingNodeIds.length === 0 ? void 0 : import_drizzle_orm7.sql`${flowMedia.nodeId} NOT IN ${params.existingNodeIds}`;
|
|
2032
|
+
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));
|
|
2033
|
+
}
|
|
2034
|
+
};
|
|
2035
|
+
|
|
2036
|
+
// src/repositories/FlowMediaIdRepository.ts
|
|
2037
|
+
var import_drizzle_orm8 = require("drizzle-orm");
|
|
2038
|
+
var FlowMediaIdRepository = class {
|
|
2039
|
+
static {
|
|
2040
|
+
__name(this, "FlowMediaIdRepository");
|
|
2041
|
+
}
|
|
2042
|
+
db;
|
|
2043
|
+
constructor(db) {
|
|
2044
|
+
this.db = db;
|
|
2045
|
+
}
|
|
2046
|
+
async get(params) {
|
|
2047
|
+
const rows = await this.db.select({
|
|
2048
|
+
ids: flowMedia.metaMediaIds
|
|
2049
|
+
}).from(flowMedia).where((0, import_drizzle_orm8.eq)(flowMedia.id, params.flowMediaId)).limit(1);
|
|
2050
|
+
return rows[0]?.ids?.[params.senderKey];
|
|
2051
|
+
}
|
|
2052
|
+
/**
|
|
2053
|
+
* Grava só a chave deste número.
|
|
2054
|
+
*
|
|
2055
|
+
* `jsonb_set` no banco, e não ler-alterar-escrever na aplicação: dois clientes passando pelo nó
|
|
2056
|
+
* ao mesmo tempo com números diferentes leriam o mesmo mapa e o último gravaria por cima,
|
|
2057
|
+
* apagando o id do outro. A escrita atômica não tem essa janela.
|
|
2058
|
+
*/
|
|
2059
|
+
async set(params) {
|
|
2060
|
+
await this.db.update(flowMedia).set({
|
|
2061
|
+
metaMediaIds: import_drizzle_orm8.sql`jsonb_set(${flowMedia.metaMediaIds}, ARRAY[${params.senderKey}::text], to_jsonb(${params.mediaId}::text), true)`
|
|
2062
|
+
}).where((0, import_drizzle_orm8.eq)(flowMedia.id, params.flowMediaId));
|
|
2063
|
+
}
|
|
2064
|
+
/** Remove só a chave deste número — o id do outro número continua válido. */
|
|
2065
|
+
async clear(params) {
|
|
2066
|
+
await this.db.update(flowMedia).set({
|
|
2067
|
+
metaMediaIds: import_drizzle_orm8.sql`${flowMedia.metaMediaIds} - ${params.senderKey}::text`
|
|
2068
|
+
}).where((0, import_drizzle_orm8.eq)(flowMedia.id, params.flowMediaId));
|
|
2069
|
+
}
|
|
2070
|
+
};
|
|
2071
|
+
|
|
2072
|
+
// src/channel/WhatsAppChannelAdapter.ts
|
|
2073
|
+
var import_meta_graph_core = require("@adatechnology/meta-graph-core");
|
|
2074
|
+
var import_meta_whatsapp_contracts9 = require("@adatechnology/meta-whatsapp-contracts");
|
|
2075
|
+
|
|
2076
|
+
// src/channel/previewMedia.ts
|
|
2077
|
+
var import_meta_whatsapp_contracts8 = require("@adatechnology/meta-whatsapp-contracts");
|
|
2078
|
+
|
|
2079
|
+
// src/channel/WhatsAppChannelAdapter.ts
|
|
2080
|
+
var WhatsAppChannelAdapter = class {
|
|
2081
|
+
static {
|
|
2082
|
+
__name(this, "WhatsAppChannelAdapter");
|
|
2083
|
+
}
|
|
2084
|
+
messages;
|
|
2085
|
+
previewMedia;
|
|
2086
|
+
constructor(messages2, previewMedia) {
|
|
2087
|
+
this.messages = messages2;
|
|
2088
|
+
this.previewMedia = previewMedia;
|
|
2089
|
+
}
|
|
2090
|
+
async translateErrors(operation) {
|
|
2091
|
+
try {
|
|
2092
|
+
return await operation();
|
|
2093
|
+
} catch (error) {
|
|
2094
|
+
if (error instanceof import_meta_graph_core.WhatsAppWindowExpiredError) throw new import_meta_whatsapp_contracts9.WindowExpiredError();
|
|
2095
|
+
throw error;
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
async sendText(to, body) {
|
|
2099
|
+
const result = await this.translateErrors(() => this.messages.sendText(to, body));
|
|
2100
|
+
return {
|
|
2101
|
+
externalMessageId: result.waMessageId
|
|
2102
|
+
};
|
|
2103
|
+
}
|
|
2104
|
+
async sendMedia(params) {
|
|
2105
|
+
const result = await this.translateErrors(() => this.messages.sendMedia(params));
|
|
2106
|
+
return {
|
|
2107
|
+
externalMessageId: result.waMessageId,
|
|
2108
|
+
mediaId: result.mediaId
|
|
2109
|
+
};
|
|
2110
|
+
}
|
|
2111
|
+
async sendTemplate(params) {
|
|
2112
|
+
const result = await this.translateErrors(() => this.messages.sendTemplate(params));
|
|
2113
|
+
return {
|
|
2114
|
+
externalMessageId: result.waMessageId
|
|
2115
|
+
};
|
|
2116
|
+
}
|
|
2117
|
+
async sendInteractiveList(params) {
|
|
2118
|
+
const result = await this.translateErrors(() => this.messages.sendInteractiveList({
|
|
1174
2119
|
to: params.to,
|
|
1175
2120
|
bodyText: params.body,
|
|
1176
2121
|
buttonText: params.buttonLabel,
|
|
@@ -1185,49 +2130,405 @@ var WhatsAppChannelAdapter = class {
|
|
|
1185
2130
|
externalMessageId: result.waMessageId
|
|
1186
2131
|
};
|
|
1187
2132
|
}
|
|
2133
|
+
async sendInteractiveButtons(params) {
|
|
2134
|
+
const result = await this.translateErrors(() => this.messages.sendInteractiveButtons({
|
|
2135
|
+
to: params.to,
|
|
2136
|
+
bodyText: params.body,
|
|
2137
|
+
buttons: params.buttons
|
|
2138
|
+
}));
|
|
2139
|
+
return {
|
|
2140
|
+
externalMessageId: result.waMessageId
|
|
2141
|
+
};
|
|
2142
|
+
}
|
|
2143
|
+
async sendProductList(params) {
|
|
2144
|
+
const result = await this.translateErrors(() => this.messages.sendProductListMessage({
|
|
2145
|
+
to: params.to,
|
|
2146
|
+
headerText: params.headerText,
|
|
2147
|
+
bodyText: params.body,
|
|
2148
|
+
...params.footerText ? {
|
|
2149
|
+
footerText: params.footerText
|
|
2150
|
+
} : {},
|
|
2151
|
+
sections: params.sections
|
|
2152
|
+
}));
|
|
2153
|
+
return {
|
|
2154
|
+
externalMessageId: result.waMessageId
|
|
2155
|
+
};
|
|
2156
|
+
}
|
|
2157
|
+
/**
|
|
2158
|
+
* Busca o binário da mídia — da Meta, ou do storage quando o id é do simulador.
|
|
2159
|
+
*
|
|
2160
|
+
* O desvio acontece ANTES de qualquer chamada de rede: id do simulador não existe na Meta, e
|
|
2161
|
+
* tentar buscá-lo lá renderia um 404 confuso em vez do áudio que o operador acabou de gravar.
|
|
2162
|
+
*/
|
|
1188
2163
|
async fetchMediaAsBase64(mediaId) {
|
|
2164
|
+
const uploadId = this.previewMedia?.isEnabled ? (0, import_meta_whatsapp_contracts8.resolvePreviewUploadId)(mediaId) : void 0;
|
|
2165
|
+
if (uploadId) {
|
|
2166
|
+
const buffer = await this.previewMedia.objectStorage.getObject(uploadId);
|
|
2167
|
+
return {
|
|
2168
|
+
data: buffer.toString("base64"),
|
|
2169
|
+
mimeType: this.previewMedia.defaultMimeType ?? "audio/ogg"
|
|
2170
|
+
};
|
|
2171
|
+
}
|
|
1189
2172
|
return this.translateErrors(() => this.messages.fetchMediaAsBase64(mediaId));
|
|
1190
2173
|
}
|
|
1191
2174
|
};
|
|
1192
2175
|
|
|
1193
2176
|
// src/channel/ReceiveWebhook.use-case.ts
|
|
1194
|
-
var
|
|
2177
|
+
var import_meta_whatsapp_contracts12 = require("@adatechnology/meta-whatsapp-contracts");
|
|
1195
2178
|
|
|
1196
2179
|
// src/channel/webhookSecurity.ts
|
|
1197
|
-
var
|
|
1198
|
-
var
|
|
1199
|
-
var
|
|
1200
|
-
function safeEqualStrings(left, right) {
|
|
1201
|
-
const leftDigest = (0, import_node_crypto.createHmac)("sha256", "constant-time-compare").update(left).digest();
|
|
1202
|
-
const rightDigest = (0, import_node_crypto.createHmac)("sha256", "constant-time-compare").update(right).digest();
|
|
1203
|
-
return (0, import_node_crypto.timingSafeEqual)(leftDigest, rightDigest);
|
|
1204
|
-
}
|
|
1205
|
-
__name(safeEqualStrings, "safeEqualStrings");
|
|
2180
|
+
var import_meta_graph_core2 = require("@adatechnology/meta-graph-core");
|
|
2181
|
+
var import_meta_whatsapp_contracts10 = require("@adatechnology/meta-whatsapp-contracts");
|
|
2182
|
+
var WEBHOOK_NONCE_NAMESPACE = "meta-whatsapp";
|
|
1206
2183
|
function verifyWebhookChallenge(params) {
|
|
1207
|
-
if (params
|
|
1208
|
-
throw new import_meta_whatsapp_contracts7.InvalidWebhookSignatureError();
|
|
1209
|
-
}
|
|
1210
|
-
if (!safeEqualStrings(params.token, params.expectedToken)) {
|
|
1211
|
-
throw new import_meta_whatsapp_contracts7.InvalidWebhookSignatureError();
|
|
1212
|
-
}
|
|
2184
|
+
if (!(0, import_meta_graph_core2.isValidWebhookChallenge)(params)) throw new import_meta_whatsapp_contracts10.InvalidWebhookSignatureError();
|
|
1213
2185
|
return params.challenge;
|
|
1214
2186
|
}
|
|
1215
2187
|
__name(verifyWebhookChallenge, "verifyWebhookChallenge");
|
|
1216
2188
|
function verifyWebhookSignature(params) {
|
|
1217
|
-
|
|
1218
|
-
if (!signatureHeader?.startsWith("sha256=")) throw new import_meta_whatsapp_contracts7.InvalidWebhookSignatureError();
|
|
1219
|
-
const expected = (0, import_node_crypto.createHmac)("sha256", appSecret).update(rawBody).digest("hex");
|
|
1220
|
-
const received = signatureHeader.slice("sha256=".length);
|
|
1221
|
-
if (!safeEqualStrings(received, expected)) throw new import_meta_whatsapp_contracts7.InvalidWebhookSignatureError();
|
|
2189
|
+
if (!(0, import_meta_graph_core2.isValidWebhookSignature)(params)) throw new import_meta_whatsapp_contracts10.InvalidWebhookSignatureError();
|
|
1222
2190
|
}
|
|
1223
2191
|
__name(verifyWebhookSignature, "verifyWebhookSignature");
|
|
2192
|
+
function deliveryKey(signatureHeader) {
|
|
2193
|
+
return (0, import_meta_graph_core2.buildWebhookDeliveryKey)({
|
|
2194
|
+
namespace: WEBHOOK_NONCE_NAMESPACE,
|
|
2195
|
+
signatureHeader
|
|
2196
|
+
});
|
|
2197
|
+
}
|
|
2198
|
+
__name(deliveryKey, "deliveryKey");
|
|
1224
2199
|
async function claimWebhookDelivery(params) {
|
|
1225
|
-
|
|
1226
|
-
return params.nonceStore.setIfAbsent(key, params.ttlSeconds ?? WEBHOOK_NONCE_TTL_SECONDS);
|
|
2200
|
+
return params.nonceStore.setIfAbsent(deliveryKey(params.signatureHeader), params.ttlSeconds ?? import_meta_graph_core2.WEBHOOK_CLAIM_TTL_SECONDS);
|
|
1227
2201
|
}
|
|
1228
2202
|
__name(claimWebhookDelivery, "claimWebhookDelivery");
|
|
2203
|
+
async function confirmWebhookDelivery(params) {
|
|
2204
|
+
await params.nonceStore.confirm?.(deliveryKey(params.signatureHeader), params.ttlSeconds ?? import_meta_graph_core2.WEBHOOK_NONCE_TTL_SECONDS);
|
|
2205
|
+
}
|
|
2206
|
+
__name(confirmWebhookDelivery, "confirmWebhookDelivery");
|
|
1229
2207
|
|
|
1230
|
-
// src/channel/
|
|
2208
|
+
// src/channel/IngestInboundMedia.use-case.ts
|
|
2209
|
+
var import_drizzle_orm9 = require("drizzle-orm");
|
|
2210
|
+
|
|
2211
|
+
// src/use-cases/TranscribeAudio.use-case.ts
|
|
2212
|
+
var import_meta_whatsapp_contracts11 = require("@adatechnology/meta-whatsapp-contracts");
|
|
2213
|
+
|
|
2214
|
+
// src/transcription.types.ts
|
|
2215
|
+
var TRANSCRIPTION_STATUS = {
|
|
2216
|
+
/** Falhou de forma retriável (cota, rede, 5xx) — vai sair quando alguém tentar de novo. */
|
|
2217
|
+
PENDING: "pending",
|
|
2218
|
+
/** Processado. Texto vazio aqui é áudio em silêncio, e NÃO deve ser reprocessado. */
|
|
2219
|
+
DONE: "done",
|
|
2220
|
+
/** Falha definitiva do engine (credencial, áudio corrompido, arquivo grande demais). */
|
|
2221
|
+
FAILED: "failed",
|
|
2222
|
+
/** Nenhum engine da cadeia aceita o formato. Retentar não conserta codec. */
|
|
2223
|
+
UNSUPPORTED: "unsupported"
|
|
2224
|
+
};
|
|
2225
|
+
var TRANSCRIPTION_MODE = {
|
|
2226
|
+
AUTO: "auto",
|
|
2227
|
+
ON_DEMAND: "onDemand"
|
|
2228
|
+
};
|
|
2229
|
+
function isRetriableTranscriptionError(error) {
|
|
2230
|
+
if (typeof error !== "object" || error === null) return true;
|
|
2231
|
+
const isRetriable = error.isRetriable;
|
|
2232
|
+
return typeof isRetriable === "boolean" ? isRetriable : true;
|
|
2233
|
+
}
|
|
2234
|
+
__name(isRetriableTranscriptionError, "isRetriableTranscriptionError");
|
|
2235
|
+
function isUnsupportedTranscriptionError(error) {
|
|
2236
|
+
return typeof error === "object" && error !== null && error.name === "TranscriptionUnsupportedError";
|
|
2237
|
+
}
|
|
2238
|
+
__name(isUnsupportedTranscriptionError, "isUnsupportedTranscriptionError");
|
|
2239
|
+
function transcriptionRetryAfterSeconds(error) {
|
|
2240
|
+
if (typeof error !== "object" || error === null) return void 0;
|
|
2241
|
+
const retryAfter = error.retryAfterSeconds;
|
|
2242
|
+
return typeof retryAfter === "number" ? retryAfter : void 0;
|
|
2243
|
+
}
|
|
2244
|
+
__name(transcriptionRetryAfterSeconds, "transcriptionRetryAfterSeconds");
|
|
2245
|
+
function isAudioMimeType(mimeType) {
|
|
2246
|
+
return typeof mimeType === "string" && mimeType.trim().toLowerCase().startsWith("audio/");
|
|
2247
|
+
}
|
|
2248
|
+
__name(isAudioMimeType, "isAudioMimeType");
|
|
2249
|
+
|
|
2250
|
+
// src/use-cases/TranscribeAudio.use-case.ts
|
|
2251
|
+
var TranscribeAudioUseCase = class {
|
|
2252
|
+
static {
|
|
2253
|
+
__name(this, "TranscribeAudioUseCase");
|
|
2254
|
+
}
|
|
2255
|
+
dependencies;
|
|
2256
|
+
constructor(dependencies) {
|
|
2257
|
+
this.dependencies = dependencies;
|
|
2258
|
+
}
|
|
2259
|
+
async execute(params) {
|
|
2260
|
+
const message = await this.dependencies.messageRepository.findById(params.companyId, params.messageId);
|
|
2261
|
+
if (!message) throw new Error(`Mensagem ${params.messageId} n\xE3o encontrada para transcri\xE7\xE3o`);
|
|
2262
|
+
if (message.transcriptionStatus === TRANSCRIPTION_STATUS.DONE && !params.force) {
|
|
2263
|
+
return {
|
|
2264
|
+
status: TRANSCRIPTION_STATUS.DONE,
|
|
2265
|
+
text: message.transcriptionText,
|
|
2266
|
+
language: message.transcriptionLanguage,
|
|
2267
|
+
engine: message.transcriptionEngine,
|
|
2268
|
+
alreadyTranscribed: true
|
|
2269
|
+
};
|
|
2270
|
+
}
|
|
2271
|
+
if (this.dependencies.resolvePolicy) {
|
|
2272
|
+
const policy = await this.dependencies.resolvePolicy(params.companyId);
|
|
2273
|
+
if (!policy.isEnabled) throw new import_meta_whatsapp_contracts11.TranscriptionDisabledError();
|
|
2274
|
+
}
|
|
2275
|
+
const audio = extractAudioReference(message);
|
|
2276
|
+
const buffer = await this.dependencies.objectStorage.getObject(audio.uploadId);
|
|
2277
|
+
return this.transcribeBuffer({
|
|
2278
|
+
...params,
|
|
2279
|
+
buffer,
|
|
2280
|
+
mimeType: audio.mimeType,
|
|
2281
|
+
uploadId: audio.uploadId,
|
|
2282
|
+
message
|
|
2283
|
+
});
|
|
2284
|
+
}
|
|
2285
|
+
async transcribeBuffer(context) {
|
|
2286
|
+
try {
|
|
2287
|
+
const result = await this.dependencies.transcriber.transcribe({
|
|
2288
|
+
buffer: context.buffer,
|
|
2289
|
+
mimeType: context.mimeType,
|
|
2290
|
+
...this.dependencies.languageHint ? {
|
|
2291
|
+
languageHint: this.dependencies.languageHint
|
|
2292
|
+
} : {}
|
|
2293
|
+
});
|
|
2294
|
+
await this.dependencies.messageRepository.saveTranscription({
|
|
2295
|
+
companyId: context.companyId,
|
|
2296
|
+
messageId: context.messageId,
|
|
2297
|
+
status: TRANSCRIPTION_STATUS.DONE,
|
|
2298
|
+
text: result.text,
|
|
2299
|
+
language: result.language ?? null,
|
|
2300
|
+
engine: result.engine
|
|
2301
|
+
});
|
|
2302
|
+
return {
|
|
2303
|
+
status: TRANSCRIPTION_STATUS.DONE,
|
|
2304
|
+
text: result.text,
|
|
2305
|
+
language: result.language ?? null,
|
|
2306
|
+
engine: result.engine,
|
|
2307
|
+
alreadyTranscribed: false
|
|
2308
|
+
};
|
|
2309
|
+
} catch (error) {
|
|
2310
|
+
await this.persistFailure(context, error);
|
|
2311
|
+
throw error;
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
/**
|
|
2315
|
+
* Carimba o motivo antes de propagar. O status é o que impede os dois desperdícios simétricos:
|
|
2316
|
+
* reprocessar para sempre um codec impossível, e desistir de um áudio que só esbarrou na cota.
|
|
2317
|
+
*/
|
|
2318
|
+
async persistFailure(context, error) {
|
|
2319
|
+
const status = resolveFailureStatus(error);
|
|
2320
|
+
await this.dependencies.messageRepository.saveTranscription({
|
|
2321
|
+
companyId: context.companyId,
|
|
2322
|
+
messageId: context.messageId,
|
|
2323
|
+
status
|
|
2324
|
+
});
|
|
2325
|
+
if (status !== TRANSCRIPTION_STATUS.PENDING) return;
|
|
2326
|
+
const retryAfterSeconds = transcriptionRetryAfterSeconds(error);
|
|
2327
|
+
await this.dependencies.hooks?.onTranscriptionDeferred?.({
|
|
2328
|
+
companyId: context.companyId,
|
|
2329
|
+
messageId: context.messageId,
|
|
2330
|
+
whatsappNumber: context.message.whatsappNumber,
|
|
2331
|
+
uploadId: context.uploadId,
|
|
2332
|
+
...retryAfterSeconds !== void 0 ? {
|
|
2333
|
+
retryAfterSeconds
|
|
2334
|
+
} : {},
|
|
2335
|
+
reason: retryAfterSeconds !== void 0 ? "rate-limited" : "transient-failure",
|
|
2336
|
+
error
|
|
2337
|
+
});
|
|
2338
|
+
}
|
|
2339
|
+
};
|
|
2340
|
+
function resolveFailureStatus(error) {
|
|
2341
|
+
if (isUnsupportedTranscriptionError(error)) return TRANSCRIPTION_STATUS.UNSUPPORTED;
|
|
2342
|
+
return isRetriableTranscriptionError(error) ? TRANSCRIPTION_STATUS.PENDING : TRANSCRIPTION_STATUS.FAILED;
|
|
2343
|
+
}
|
|
2344
|
+
__name(resolveFailureStatus, "resolveFailureStatus");
|
|
2345
|
+
function extractAudioReference(message) {
|
|
2346
|
+
const payload = message.payload ?? {};
|
|
2347
|
+
const audio = payload["audio"];
|
|
2348
|
+
const mimeType = typeof payload["mimeType"] === "string" ? payload["mimeType"] : audio?.mime_type;
|
|
2349
|
+
if (!isAudioMimeType(mimeType) && !audio) {
|
|
2350
|
+
throw new import_meta_whatsapp_contracts11.MessageNotAudioError(message.id, message.type);
|
|
2351
|
+
}
|
|
2352
|
+
const uploadId = payload["uploadId"];
|
|
2353
|
+
if (typeof uploadId !== "string" || uploadId.length === 0) {
|
|
2354
|
+
throw new import_meta_whatsapp_contracts11.AudioNotIngestedError(message.id);
|
|
2355
|
+
}
|
|
2356
|
+
return {
|
|
2357
|
+
uploadId,
|
|
2358
|
+
mimeType: mimeType ?? "audio/ogg"
|
|
2359
|
+
};
|
|
2360
|
+
}
|
|
2361
|
+
__name(extractAudioReference, "extractAudioReference");
|
|
2362
|
+
|
|
2363
|
+
// src/channel/IngestInboundMedia.use-case.ts
|
|
2364
|
+
var IngestInboundMediaUseCase = class {
|
|
2365
|
+
static {
|
|
2366
|
+
__name(this, "IngestInboundMediaUseCase");
|
|
2367
|
+
}
|
|
2368
|
+
db;
|
|
2369
|
+
channel;
|
|
2370
|
+
objectStorage;
|
|
2371
|
+
documentRepository;
|
|
2372
|
+
transcription;
|
|
2373
|
+
constructor(db, channel, objectStorage, documentRepository, transcription) {
|
|
2374
|
+
this.db = db;
|
|
2375
|
+
this.channel = channel;
|
|
2376
|
+
this.objectStorage = objectStorage;
|
|
2377
|
+
this.documentRepository = documentRepository;
|
|
2378
|
+
this.transcription = transcription;
|
|
2379
|
+
}
|
|
2380
|
+
async execute(params) {
|
|
2381
|
+
const [message] = await this.db.select().from(messages).where((0, import_drizzle_orm9.and)((0, import_drizzle_orm9.eq)(messages.companyId, params.companyId), (0, import_drizzle_orm9.eq)(messages.id, params.messageId))).limit(1);
|
|
2382
|
+
if (!message) throw new Error(`Mensagem ${params.messageId} n\xE3o encontrada para ingest\xE3o de m\xEDdia`);
|
|
2383
|
+
const payload = message.payload ?? {};
|
|
2384
|
+
if (payload["uploadId"] && payload["sourceMediaId"] === params.sourceMediaId) {
|
|
2385
|
+
return {
|
|
2386
|
+
uploadId: String(payload["uploadId"]),
|
|
2387
|
+
alreadyIngested: true
|
|
2388
|
+
};
|
|
2389
|
+
}
|
|
2390
|
+
const { data, mimeType } = await this.channel.fetchMediaAsBase64(params.sourceMediaId);
|
|
2391
|
+
const buffer = Buffer.from(data, "base64");
|
|
2392
|
+
const { uploadId } = await this.objectStorage.upload({
|
|
2393
|
+
buffer,
|
|
2394
|
+
mimeType: mimeType || params.mimeType,
|
|
2395
|
+
key: `meta-whatsapp/${params.companyId}/inbound/${params.sourceMediaId}`
|
|
2396
|
+
});
|
|
2397
|
+
const updatedPayload = {
|
|
2398
|
+
...payload,
|
|
2399
|
+
uploadId,
|
|
2400
|
+
sourceMediaId: params.sourceMediaId,
|
|
2401
|
+
mimeType: mimeType || params.mimeType,
|
|
2402
|
+
// O tamanho já está na mão (é o buffer que acabou de ser copiado) e a bolha de documento o
|
|
2403
|
+
// exibe ao lado do tipo. Sem gravar aqui, a UI mostraria "PDF" sem o "· 180 KB", e buscá-lo
|
|
2404
|
+
// depois custaria uma consulta à tabela de documentos por mensagem renderizada.
|
|
2405
|
+
sizeBytes: buffer.length,
|
|
2406
|
+
...params.filename ? {
|
|
2407
|
+
filename: params.filename
|
|
2408
|
+
} : {}
|
|
2409
|
+
};
|
|
2410
|
+
await this.db.update(messages).set({
|
|
2411
|
+
payload: updatedPayload
|
|
2412
|
+
}).where((0, import_drizzle_orm9.and)((0, import_drizzle_orm9.eq)(messages.companyId, params.companyId), (0, import_drizzle_orm9.eq)(messages.id, params.messageId)));
|
|
2413
|
+
await this.documentRepository?.link({
|
|
2414
|
+
companyId: params.companyId,
|
|
2415
|
+
sessionId: message.sessionId,
|
|
2416
|
+
messageId: message.id,
|
|
2417
|
+
uploadId,
|
|
2418
|
+
// Áudio e sticker chegam sem nome; sem um rótulo o painel mostraria linha vazia.
|
|
2419
|
+
filename: params.filename ?? `${params.sourceMediaId}`,
|
|
2420
|
+
mimeType: mimeType || params.mimeType,
|
|
2421
|
+
sizeBytes: buffer.length,
|
|
2422
|
+
source: message.sender
|
|
2423
|
+
});
|
|
2424
|
+
const transcription = await this.transcribeIfAuto({
|
|
2425
|
+
companyId: params.companyId,
|
|
2426
|
+
message,
|
|
2427
|
+
uploadId,
|
|
2428
|
+
buffer,
|
|
2429
|
+
mimeType: mimeType || params.mimeType
|
|
2430
|
+
});
|
|
2431
|
+
return {
|
|
2432
|
+
uploadId,
|
|
2433
|
+
alreadyIngested: false,
|
|
2434
|
+
...transcription ? {
|
|
2435
|
+
transcription
|
|
2436
|
+
} : {}
|
|
2437
|
+
};
|
|
2438
|
+
}
|
|
2439
|
+
/**
|
|
2440
|
+
* Transcreve o áudio recém-baixado, quando o modo é `auto`.
|
|
2441
|
+
*
|
|
2442
|
+
* **Nunca propaga erro.** Neste ponto o binário já está no storage e já entrou na biblioteca da
|
|
2443
|
+
* conversa: deixar uma falha de transcrição subir marcaria a ingestão inteira como falha, e o
|
|
2444
|
+
* retry do host baixaria de novo da Meta um arquivo que está salvo — gastando banda para reproduzir
|
|
2445
|
+
* um efeito que já aconteceu. O status fica gravado na mensagem e o `onTranscriptionDeferred`
|
|
2446
|
+
* avisa quem sabe reenfileirar.
|
|
2447
|
+
*/
|
|
2448
|
+
async transcribeIfAuto(context) {
|
|
2449
|
+
const transcription = this.transcription;
|
|
2450
|
+
if (!transcription) return void 0;
|
|
2451
|
+
if (!isAudioMimeType(context.mimeType)) return void 0;
|
|
2452
|
+
const policy = await transcription.resolvePolicy(context.companyId);
|
|
2453
|
+
if (!policy.isEnabled || policy.mode !== TRANSCRIPTION_MODE.AUTO) return void 0;
|
|
2454
|
+
const current = await transcription.messageRepository.findById(context.companyId, context.message.id);
|
|
2455
|
+
if (current?.transcriptionStatus === TRANSCRIPTION_STATUS.DONE) return void 0;
|
|
2456
|
+
try {
|
|
2457
|
+
const result = await transcription.transcriber.transcribe({
|
|
2458
|
+
buffer: context.buffer,
|
|
2459
|
+
mimeType: context.mimeType,
|
|
2460
|
+
...transcription.languageHint ? {
|
|
2461
|
+
languageHint: transcription.languageHint
|
|
2462
|
+
} : {}
|
|
2463
|
+
});
|
|
2464
|
+
await transcription.messageRepository.saveTranscription({
|
|
2465
|
+
companyId: context.companyId,
|
|
2466
|
+
messageId: context.message.id,
|
|
2467
|
+
status: TRANSCRIPTION_STATUS.DONE,
|
|
2468
|
+
text: result.text,
|
|
2469
|
+
language: result.language ?? null,
|
|
2470
|
+
engine: result.engine
|
|
2471
|
+
});
|
|
2472
|
+
return {
|
|
2473
|
+
status: TRANSCRIPTION_STATUS.DONE
|
|
2474
|
+
};
|
|
2475
|
+
} catch (error) {
|
|
2476
|
+
return this.recordTranscriptionFailure(context, error);
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
async recordTranscriptionFailure(context, error) {
|
|
2480
|
+
const status = resolveFailureStatus(error);
|
|
2481
|
+
await this.transcription?.messageRepository.saveTranscription({
|
|
2482
|
+
companyId: context.companyId,
|
|
2483
|
+
messageId: context.message.id,
|
|
2484
|
+
status
|
|
2485
|
+
});
|
|
2486
|
+
if (status === TRANSCRIPTION_STATUS.PENDING) {
|
|
2487
|
+
const retryAfterSeconds = transcriptionRetryAfterSeconds(error);
|
|
2488
|
+
await this.transcription?.hooks?.onTranscriptionDeferred?.({
|
|
2489
|
+
companyId: context.companyId,
|
|
2490
|
+
messageId: context.message.id,
|
|
2491
|
+
whatsappNumber: context.message.whatsappNumber,
|
|
2492
|
+
uploadId: context.uploadId,
|
|
2493
|
+
...retryAfterSeconds !== void 0 ? {
|
|
2494
|
+
retryAfterSeconds
|
|
2495
|
+
} : {},
|
|
2496
|
+
reason: retryAfterSeconds !== void 0 ? "rate-limited" : "transient-failure",
|
|
2497
|
+
error
|
|
2498
|
+
});
|
|
2499
|
+
}
|
|
2500
|
+
return {
|
|
2501
|
+
status
|
|
2502
|
+
};
|
|
2503
|
+
}
|
|
2504
|
+
};
|
|
2505
|
+
function extractMediaDescriptor(message) {
|
|
2506
|
+
const payload = message.payload ?? {};
|
|
2507
|
+
for (const key of [
|
|
2508
|
+
"image",
|
|
2509
|
+
"audio",
|
|
2510
|
+
"video",
|
|
2511
|
+
"document",
|
|
2512
|
+
"sticker"
|
|
2513
|
+
]) {
|
|
2514
|
+
const media = payload[key];
|
|
2515
|
+
if (media?.id) {
|
|
2516
|
+
return {
|
|
2517
|
+
sourceMediaId: media.id,
|
|
2518
|
+
mimeType: media.mime_type ?? "application/octet-stream",
|
|
2519
|
+
filename: media.filename
|
|
2520
|
+
};
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
return void 0;
|
|
2524
|
+
}
|
|
2525
|
+
__name(extractMediaDescriptor, "extractMediaDescriptor");
|
|
2526
|
+
|
|
2527
|
+
// src/channel/inboundDispatch.ts
|
|
2528
|
+
function buildInboundJobId(job) {
|
|
2529
|
+
return job.kind === "message" ? `wa-inbound-message:${job.message.id}` : `wa-inbound-status:${job.status.id}:${job.status.status}`;
|
|
2530
|
+
}
|
|
2531
|
+
__name(buildInboundJobId, "buildInboundJobId");
|
|
1231
2532
|
function toSessionContract(row) {
|
|
1232
2533
|
return {
|
|
1233
2534
|
id: row.id,
|
|
@@ -1248,6 +2549,74 @@ function toSessionContract(row) {
|
|
|
1248
2549
|
};
|
|
1249
2550
|
}
|
|
1250
2551
|
__name(toSessionContract, "toSessionContract");
|
|
2552
|
+
function extractAnswer(message) {
|
|
2553
|
+
const interactive = message.interactive;
|
|
2554
|
+
if (interactive?.button_reply) return interactive.button_reply.id;
|
|
2555
|
+
if (interactive?.list_reply) return interactive.list_reply.id;
|
|
2556
|
+
return message.text?.body;
|
|
2557
|
+
}
|
|
2558
|
+
__name(extractAnswer, "extractAnswer");
|
|
2559
|
+
var InboundEffectsDispatcher = class {
|
|
2560
|
+
static {
|
|
2561
|
+
__name(this, "InboundEffectsDispatcher");
|
|
2562
|
+
}
|
|
2563
|
+
params;
|
|
2564
|
+
constructor(params) {
|
|
2565
|
+
this.params = params;
|
|
2566
|
+
}
|
|
2567
|
+
async run(job) {
|
|
2568
|
+
if (job.kind === "message") return this.runMessageEffects(job);
|
|
2569
|
+
return this.runStatusEffects(job);
|
|
2570
|
+
}
|
|
2571
|
+
async runMessageEffects(job) {
|
|
2572
|
+
const { companyId, message, savedMessageId, media } = job;
|
|
2573
|
+
if (media) {
|
|
2574
|
+
await this.params.hooks?.onMediaReceived?.({
|
|
2575
|
+
companyId,
|
|
2576
|
+
messageId: savedMessageId,
|
|
2577
|
+
whatsappNumber: message.from,
|
|
2578
|
+
sourceMediaId: media.sourceMediaId,
|
|
2579
|
+
mimeType: media.mimeType,
|
|
2580
|
+
...media.filename ? {
|
|
2581
|
+
filename: media.filename
|
|
2582
|
+
} : {}
|
|
2583
|
+
});
|
|
2584
|
+
}
|
|
2585
|
+
const sessionRow = await this.params.sessionRepository.getContext(companyId, message.from);
|
|
2586
|
+
if (!sessionRow) return;
|
|
2587
|
+
if (sessionRow.mode === "human") return;
|
|
2588
|
+
const outcome = await this.params.hooks?.onMessageReceived?.(message, toSessionContract(sessionRow), job.profileName === void 0 ? {} : {
|
|
2589
|
+
profileName: job.profileName
|
|
2590
|
+
});
|
|
2591
|
+
if (outcome?.outcome === "handled") return;
|
|
2592
|
+
void extractAnswer(message);
|
|
2593
|
+
}
|
|
2594
|
+
async runStatusEffects(job) {
|
|
2595
|
+
const sessionRow = await this.params.sessionRepository.getContext(job.companyId, job.whatsappNumber);
|
|
2596
|
+
await this.params.hooks?.onStatusUpdate?.(job.status, sessionRow ? toSessionContract(sessionRow) : null);
|
|
2597
|
+
}
|
|
2598
|
+
};
|
|
2599
|
+
var ProcessInboundDispatchUseCase = class {
|
|
2600
|
+
static {
|
|
2601
|
+
__name(this, "ProcessInboundDispatchUseCase");
|
|
2602
|
+
}
|
|
2603
|
+
dispatcher;
|
|
2604
|
+
constructor(dispatcher) {
|
|
2605
|
+
this.dispatcher = dispatcher;
|
|
2606
|
+
}
|
|
2607
|
+
async execute(job) {
|
|
2608
|
+
await this.dispatcher.run(job);
|
|
2609
|
+
}
|
|
2610
|
+
};
|
|
2611
|
+
|
|
2612
|
+
// src/channel/ReceiveWebhook.use-case.ts
|
|
2613
|
+
var EMPTY_RESULT = {
|
|
2614
|
+
messagesProcessed: 0,
|
|
2615
|
+
statusesProcessed: 0,
|
|
2616
|
+
ignoredForeignNumber: 0,
|
|
2617
|
+
accountEventsProcessed: 0,
|
|
2618
|
+
unhandledEvents: 0
|
|
2619
|
+
};
|
|
1251
2620
|
function extractContent(message) {
|
|
1252
2621
|
if (message.text?.body) return message.text.body;
|
|
1253
2622
|
const interactive = message.interactive;
|
|
@@ -1260,13 +2629,6 @@ function extractContent(message) {
|
|
|
1260
2629
|
return message.image?.caption ?? message.document?.caption ?? null;
|
|
1261
2630
|
}
|
|
1262
2631
|
__name(extractContent, "extractContent");
|
|
1263
|
-
function extractAnswer(message) {
|
|
1264
|
-
const interactive = message.interactive;
|
|
1265
|
-
if (interactive?.button_reply) return interactive.button_reply.id;
|
|
1266
|
-
if (interactive?.list_reply) return interactive.list_reply.id;
|
|
1267
|
-
return message.text?.body;
|
|
1268
|
-
}
|
|
1269
|
-
__name(extractAnswer, "extractAnswer");
|
|
1270
2632
|
function extractPayload(message) {
|
|
1271
2633
|
const payload = {};
|
|
1272
2634
|
if (message.interactive) payload["interactive"] = message.interactive;
|
|
@@ -1280,6 +2642,14 @@ function extractPayload(message) {
|
|
|
1280
2642
|
return Object.keys(payload).length > 0 ? payload : null;
|
|
1281
2643
|
}
|
|
1282
2644
|
__name(extractPayload, "extractPayload");
|
|
2645
|
+
var ACCOUNT_EVENT_FIELDS = [
|
|
2646
|
+
import_meta_whatsapp_contracts12.WHATSAPP_WEBHOOK_FIELDS.TEMPLATE_STATUS_UPDATE,
|
|
2647
|
+
import_meta_whatsapp_contracts12.WHATSAPP_WEBHOOK_FIELDS.PHONE_NUMBER_QUALITY_UPDATE
|
|
2648
|
+
];
|
|
2649
|
+
function isAccountEventField(field) {
|
|
2650
|
+
return field !== void 0 && ACCOUNT_EVENT_FIELDS.includes(field);
|
|
2651
|
+
}
|
|
2652
|
+
__name(isAccountEventField, "isAccountEventField");
|
|
1283
2653
|
var ReceiveWebhookUseCase = class {
|
|
1284
2654
|
static {
|
|
1285
2655
|
__name(this, "ReceiveWebhookUseCase");
|
|
@@ -1287,7 +2657,17 @@ var ReceiveWebhookUseCase = class {
|
|
|
1287
2657
|
params;
|
|
1288
2658
|
constructor(params) {
|
|
1289
2659
|
this.params = params;
|
|
2660
|
+
this.dispatcher = new InboundEffectsDispatcher({
|
|
2661
|
+
sessionRepository: params.sessionRepository,
|
|
2662
|
+
...params.hooks ? {
|
|
2663
|
+
hooks: params.hooks
|
|
2664
|
+
} : {},
|
|
2665
|
+
...params.realtime ? {
|
|
2666
|
+
realtime: params.realtime
|
|
2667
|
+
} : {}
|
|
2668
|
+
});
|
|
1290
2669
|
}
|
|
2670
|
+
dispatcher;
|
|
1291
2671
|
async execute(input) {
|
|
1292
2672
|
verifyWebhookSignature({
|
|
1293
2673
|
rawBody: input.rawBody,
|
|
@@ -1300,32 +2680,106 @@ var ReceiveWebhookUseCase = class {
|
|
|
1300
2680
|
});
|
|
1301
2681
|
if (!claimed) return {
|
|
1302
2682
|
duplicate: true,
|
|
1303
|
-
|
|
1304
|
-
statusesProcessed: 0
|
|
2683
|
+
...EMPTY_RESULT
|
|
1305
2684
|
};
|
|
1306
2685
|
const rawText = typeof input.rawBody === "string" ? input.rawBody : input.rawBody.toString("utf8");
|
|
1307
|
-
const
|
|
2686
|
+
const parsed = import_meta_whatsapp_contracts12.whatsAppWebhookPayloadSchema.safeParse(JSON.parse(rawText));
|
|
2687
|
+
if (!parsed.success) {
|
|
2688
|
+
await this.params.hooks?.onUnhandledWebhookEvent?.({
|
|
2689
|
+
field: void 0,
|
|
2690
|
+
reason: "invalid-shape",
|
|
2691
|
+
value: rawText
|
|
2692
|
+
});
|
|
2693
|
+
return {
|
|
2694
|
+
duplicate: false,
|
|
2695
|
+
...EMPTY_RESULT,
|
|
2696
|
+
unhandledEvents: 1
|
|
2697
|
+
};
|
|
2698
|
+
}
|
|
2699
|
+
const payload = parsed.data;
|
|
1308
2700
|
let messagesProcessed = 0;
|
|
1309
2701
|
let statusesProcessed = 0;
|
|
2702
|
+
let ignoredForeignNumber = 0;
|
|
2703
|
+
let accountEventsProcessed = 0;
|
|
2704
|
+
let unhandledEvents = 0;
|
|
1310
2705
|
for (const entry of payload.entry) {
|
|
1311
2706
|
for (const change of entry.changes) {
|
|
2707
|
+
if (isAccountEventField(change.field)) {
|
|
2708
|
+
const handled = await this.handleAccountEvent(change);
|
|
2709
|
+
if (handled) accountEventsProcessed++;
|
|
2710
|
+
else unhandledEvents++;
|
|
2711
|
+
continue;
|
|
2712
|
+
}
|
|
2713
|
+
const targetNumber = change.value.metadata?.phone_number_id;
|
|
2714
|
+
if (targetNumber && targetNumber !== this.params.phoneNumberId) {
|
|
2715
|
+
ignoredForeignNumber++;
|
|
2716
|
+
continue;
|
|
2717
|
+
}
|
|
1312
2718
|
for (const message of change.value.messages ?? []) {
|
|
1313
|
-
|
|
2719
|
+
const profileName = (0, import_meta_whatsapp_contracts12.resolveContactProfileName)({
|
|
2720
|
+
contacts: change.value.contacts,
|
|
2721
|
+
from: message.from
|
|
2722
|
+
});
|
|
2723
|
+
await this.handleMessage(input.companyId, message, profileName);
|
|
1314
2724
|
messagesProcessed++;
|
|
1315
2725
|
}
|
|
1316
2726
|
for (const status of change.value.statuses ?? []) {
|
|
1317
2727
|
await this.handleStatus(input.companyId, status);
|
|
1318
2728
|
statusesProcessed++;
|
|
1319
2729
|
}
|
|
2730
|
+
const carriedConversationData = (change.value.messages?.length ?? 0) > 0 || (change.value.message_echoes?.length ?? 0) > 0 || (change.value.statuses?.length ?? 0) > 0;
|
|
2731
|
+
if (!carriedConversationData) {
|
|
2732
|
+
unhandledEvents++;
|
|
2733
|
+
await this.params.hooks?.onUnhandledWebhookEvent?.({
|
|
2734
|
+
field: change.field,
|
|
2735
|
+
reason: "unknown-field",
|
|
2736
|
+
value: change.value
|
|
2737
|
+
});
|
|
2738
|
+
}
|
|
1320
2739
|
}
|
|
1321
2740
|
}
|
|
2741
|
+
await confirmWebhookDelivery({
|
|
2742
|
+
nonceStore: this.params.nonceStore,
|
|
2743
|
+
signatureHeader: input.signatureHeader
|
|
2744
|
+
});
|
|
1322
2745
|
return {
|
|
1323
2746
|
duplicate: false,
|
|
1324
2747
|
messagesProcessed,
|
|
1325
|
-
statusesProcessed
|
|
2748
|
+
statusesProcessed,
|
|
2749
|
+
ignoredForeignNumber,
|
|
2750
|
+
accountEventsProcessed,
|
|
2751
|
+
unhandledEvents
|
|
1326
2752
|
};
|
|
1327
2753
|
}
|
|
1328
|
-
|
|
2754
|
+
// Devolve `false` quando o corpo não bate com o schema do field — o chamador conta como não
|
|
2755
|
+
// tratado. O host é avisado nos dois casos, mas com `reason` diferente.
|
|
2756
|
+
async handleAccountEvent(change) {
|
|
2757
|
+
if (change.field === import_meta_whatsapp_contracts12.WHATSAPP_WEBHOOK_FIELDS.TEMPLATE_STATUS_UPDATE) {
|
|
2758
|
+
const update = import_meta_whatsapp_contracts12.whatsAppTemplateStatusUpdateSchema.safeParse(change.value);
|
|
2759
|
+
if (!update.success) {
|
|
2760
|
+
await this.params.hooks?.onUnhandledWebhookEvent?.({
|
|
2761
|
+
field: change.field,
|
|
2762
|
+
reason: "invalid-shape",
|
|
2763
|
+
value: change.value
|
|
2764
|
+
});
|
|
2765
|
+
return false;
|
|
2766
|
+
}
|
|
2767
|
+
await this.params.hooks?.onTemplateStatusUpdate?.(update.data);
|
|
2768
|
+
return true;
|
|
2769
|
+
}
|
|
2770
|
+
const quality = import_meta_whatsapp_contracts12.whatsAppPhoneNumberQualityUpdateSchema.safeParse(change.value);
|
|
2771
|
+
if (!quality.success) {
|
|
2772
|
+
await this.params.hooks?.onUnhandledWebhookEvent?.({
|
|
2773
|
+
field: change.field,
|
|
2774
|
+
reason: "invalid-shape",
|
|
2775
|
+
value: change.value
|
|
2776
|
+
});
|
|
2777
|
+
return false;
|
|
2778
|
+
}
|
|
2779
|
+
await this.params.hooks?.onPhoneNumberQualityUpdate?.(quality.data);
|
|
2780
|
+
return true;
|
|
2781
|
+
}
|
|
2782
|
+
async handleMessage(companyId, message, profileName) {
|
|
1329
2783
|
const saved = await this.params.logMessage.execute({
|
|
1330
2784
|
companyId,
|
|
1331
2785
|
whatsappNumber: message.from,
|
|
@@ -1339,12 +2793,20 @@ var ReceiveWebhookUseCase = class {
|
|
|
1339
2793
|
startState: this.params.startState
|
|
1340
2794
|
});
|
|
1341
2795
|
if (!saved) return;
|
|
1342
|
-
const
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
2796
|
+
const media = extractMediaDescriptor(saved);
|
|
2797
|
+
await this.dispatch({
|
|
2798
|
+
kind: "message",
|
|
2799
|
+
companyId,
|
|
2800
|
+
message,
|
|
2801
|
+
savedMessageId: saved.id,
|
|
2802
|
+
...media ? {
|
|
2803
|
+
media
|
|
2804
|
+
} : {},
|
|
2805
|
+
...profileName ? {
|
|
2806
|
+
profileName
|
|
2807
|
+
} : {},
|
|
2808
|
+
receivedAt: Date.now()
|
|
2809
|
+
});
|
|
1348
2810
|
}
|
|
1349
2811
|
async handleStatus(companyId, status) {
|
|
1350
2812
|
const updated = await this.params.messageRepository.updateMessageStatus(companyId, status.id, status.status);
|
|
@@ -1353,81 +2815,71 @@ var ReceiveWebhookUseCase = class {
|
|
|
1353
2815
|
waMessageId: status.id,
|
|
1354
2816
|
status: status.status
|
|
1355
2817
|
});
|
|
1356
|
-
|
|
1357
|
-
|
|
2818
|
+
await this.dispatch({
|
|
2819
|
+
kind: "status",
|
|
2820
|
+
companyId,
|
|
2821
|
+
status,
|
|
2822
|
+
whatsappNumber: updated.whatsappNumber,
|
|
2823
|
+
receivedAt: Date.now()
|
|
2824
|
+
});
|
|
2825
|
+
}
|
|
2826
|
+
// Com fila configurada, os efeitos saem da requisição do webhook; sem ela, rodam aqui mesmo e o
|
|
2827
|
+
// comportamento é o de sempre. É o mesmo `InboundEffectsDispatcher` nos dois caminhos.
|
|
2828
|
+
async dispatch(job) {
|
|
2829
|
+
if (this.params.inboundQueue) {
|
|
2830
|
+
await this.params.inboundQueue.enqueue(job, {
|
|
2831
|
+
jobId: buildInboundJobId(job)
|
|
2832
|
+
});
|
|
2833
|
+
return;
|
|
2834
|
+
}
|
|
2835
|
+
await this.dispatcher.run(job);
|
|
1358
2836
|
}
|
|
1359
2837
|
};
|
|
1360
2838
|
|
|
1361
|
-
// src/
|
|
1362
|
-
|
|
1363
|
-
|
|
2839
|
+
// src/use-cases/resolveTranscriptionPolicy.ts
|
|
2840
|
+
function createTranscriptionPolicyResolver(dependencies) {
|
|
2841
|
+
return /* @__PURE__ */ __name(async function resolveTranscriptionPolicy(companyId) {
|
|
2842
|
+
const settings2 = await dependencies.settingsRepository.get(companyId);
|
|
2843
|
+
return {
|
|
2844
|
+
// `??` e não `||`: `false` gravado é decisão explícita de desligar, e `||` a trocaria pelo
|
|
2845
|
+
// padrão do host — desligar no painel não faria nada num deploy com transcrição ligada.
|
|
2846
|
+
isEnabled: settings2.transcriptionEnabled ?? dependencies.defaults.isEnabled,
|
|
2847
|
+
mode: normalizeMode(settings2.transcriptionMode) ?? dependencies.defaults.mode
|
|
2848
|
+
};
|
|
2849
|
+
}, "resolveTranscriptionPolicy");
|
|
2850
|
+
}
|
|
2851
|
+
__name(createTranscriptionPolicyResolver, "createTranscriptionPolicyResolver");
|
|
2852
|
+
function normalizeMode(value) {
|
|
2853
|
+
if (value === TRANSCRIPTION_MODE.AUTO) return TRANSCRIPTION_MODE.AUTO;
|
|
2854
|
+
if (value === TRANSCRIPTION_MODE.ON_DEMAND) return TRANSCRIPTION_MODE.ON_DEMAND;
|
|
2855
|
+
return void 0;
|
|
2856
|
+
}
|
|
2857
|
+
__name(normalizeMode, "normalizeMode");
|
|
2858
|
+
|
|
2859
|
+
// src/use-cases/StorePreviewMedia.use-case.ts
|
|
2860
|
+
var StorePreviewMediaUseCase = class {
|
|
1364
2861
|
static {
|
|
1365
|
-
__name(this, "
|
|
2862
|
+
__name(this, "StorePreviewMediaUseCase");
|
|
1366
2863
|
}
|
|
1367
|
-
db;
|
|
1368
|
-
channel;
|
|
1369
2864
|
objectStorage;
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
this.channel = channel;
|
|
2865
|
+
generateKeySuffix;
|
|
2866
|
+
constructor(objectStorage, generateKeySuffix) {
|
|
1373
2867
|
this.objectStorage = objectStorage;
|
|
2868
|
+
this.generateKeySuffix = generateKeySuffix;
|
|
1374
2869
|
}
|
|
1375
2870
|
async execute(params) {
|
|
1376
|
-
const
|
|
1377
|
-
if (!message) throw new Error(`Mensagem ${params.messageId} n\xE3o encontrada para ingest\xE3o de m\xEDdia`);
|
|
1378
|
-
const payload = message.payload ?? {};
|
|
1379
|
-
if (payload["uploadId"] && payload["sourceMediaId"] === params.sourceMediaId) {
|
|
1380
|
-
return {
|
|
1381
|
-
uploadId: String(payload["uploadId"]),
|
|
1382
|
-
alreadyIngested: true
|
|
1383
|
-
};
|
|
1384
|
-
}
|
|
1385
|
-
const { data, mimeType } = await this.channel.fetchMediaAsBase64(params.sourceMediaId);
|
|
1386
|
-
const buffer = Buffer.from(data, "base64");
|
|
2871
|
+
const key = `meta-whatsapp/${params.companyId}/preview/${this.generateKeySuffix()}`;
|
|
1387
2872
|
const { uploadId } = await this.objectStorage.upload({
|
|
1388
|
-
buffer,
|
|
1389
|
-
mimeType:
|
|
1390
|
-
key
|
|
2873
|
+
buffer: params.buffer,
|
|
2874
|
+
mimeType: params.mimeType,
|
|
2875
|
+
key
|
|
1391
2876
|
});
|
|
1392
|
-
const updatedPayload = {
|
|
1393
|
-
...payload,
|
|
1394
|
-
uploadId,
|
|
1395
|
-
sourceMediaId: params.sourceMediaId,
|
|
1396
|
-
mimeType: mimeType || params.mimeType,
|
|
1397
|
-
...params.filename ? {
|
|
1398
|
-
filename: params.filename
|
|
1399
|
-
} : {}
|
|
1400
|
-
};
|
|
1401
|
-
await this.db.update(messages).set({
|
|
1402
|
-
payload: updatedPayload
|
|
1403
|
-
}).where((0, import_drizzle_orm6.and)((0, import_drizzle_orm6.eq)(messages.companyId, params.companyId), (0, import_drizzle_orm6.eq)(messages.id, params.messageId)));
|
|
1404
2877
|
return {
|
|
1405
|
-
uploadId,
|
|
1406
|
-
|
|
2878
|
+
mediaId: (0, import_meta_whatsapp_contracts8.toPreviewMediaId)(uploadId),
|
|
2879
|
+
uploadId
|
|
1407
2880
|
};
|
|
1408
2881
|
}
|
|
1409
2882
|
};
|
|
1410
|
-
function extractMediaDescriptor(message) {
|
|
1411
|
-
const payload = message.payload ?? {};
|
|
1412
|
-
for (const key of [
|
|
1413
|
-
"image",
|
|
1414
|
-
"audio",
|
|
1415
|
-
"video",
|
|
1416
|
-
"document",
|
|
1417
|
-
"sticker"
|
|
1418
|
-
]) {
|
|
1419
|
-
const media = payload[key];
|
|
1420
|
-
if (media?.id) {
|
|
1421
|
-
return {
|
|
1422
|
-
sourceMediaId: media.id,
|
|
1423
|
-
mimeType: media.mime_type ?? "application/octet-stream",
|
|
1424
|
-
filename: media.filename
|
|
1425
|
-
};
|
|
1426
|
-
}
|
|
1427
|
-
}
|
|
1428
|
-
return void 0;
|
|
1429
|
-
}
|
|
1430
|
-
__name(extractMediaDescriptor, "extractMediaDescriptor");
|
|
1431
2883
|
|
|
1432
2884
|
// src/createMetaWhatsAppModule.ts
|
|
1433
2885
|
function createMetaWhatsAppModule(params) {
|
|
@@ -1441,15 +2893,24 @@ function createMetaWhatsAppModule(params) {
|
|
|
1441
2893
|
apiVersion: config.apiVersion,
|
|
1442
2894
|
baseUrl: config.baseUrl
|
|
1443
2895
|
});
|
|
1444
|
-
const
|
|
2896
|
+
const previewMediaSupport = params.features?.previewMedia && providers.objectStorage?.getObject ? {
|
|
2897
|
+
isEnabled: true,
|
|
2898
|
+
objectStorage: providers.objectStorage
|
|
2899
|
+
} : void 0;
|
|
2900
|
+
const channel = new WhatsAppChannelAdapter(messageProvider, previewMediaSupport);
|
|
1445
2901
|
const sessionRepository = new SessionRepository(db);
|
|
1446
2902
|
const messageRepository = new MessageRepository(db);
|
|
1447
2903
|
const settingsRepository = new SettingsRepository(db);
|
|
1448
|
-
const
|
|
2904
|
+
const flowGraphCacheFeature = params.features?.flowGraphCache ?? false;
|
|
2905
|
+
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;
|
|
2906
|
+
const flowGraphRepository = new FlowGraphRepository(db, flowGraphCache);
|
|
2907
|
+
const documentRepository = new DocumentRepository(db);
|
|
2908
|
+
const flowMediaRepository = new FlowMediaRepository(db);
|
|
1449
2909
|
const logMessage = new LogMessageUseCase(sessionRepository, messageRepository, providers.realtime, providers.moderator);
|
|
1450
|
-
const sendMessage = new SendMessageUseCase(channel, sessionRepository, logMessage, providers.objectStorage);
|
|
2910
|
+
const sendMessage = new SendMessageUseCase(channel, sessionRepository, logMessage, providers.objectStorage, documentRepository);
|
|
1451
2911
|
const receiveWebhook = new ReceiveWebhookUseCase({
|
|
1452
2912
|
appSecret: config.appSecret,
|
|
2913
|
+
phoneNumberId: config.phoneNumberId,
|
|
1453
2914
|
nonceStore,
|
|
1454
2915
|
sessionRepository,
|
|
1455
2916
|
messageRepository,
|
|
@@ -1459,7 +2920,66 @@ function createMetaWhatsAppModule(params) {
|
|
|
1459
2920
|
realtime: providers.realtime
|
|
1460
2921
|
});
|
|
1461
2922
|
const flowInterpreter = flowEngineEnabled ? new FlowInterpreter() : void 0;
|
|
1462
|
-
|
|
2923
|
+
if (flowInterpreter && providers.objectStorage?.getObject) {
|
|
2924
|
+
flowInterpreter.registerFlowAction(import_meta_whatsapp_contracts13.FLOW_ACTION_KIND.SEND_MEDIA, createSendMediaAction({
|
|
2925
|
+
flowMediaRepository,
|
|
2926
|
+
objectStorage: providers.objectStorage,
|
|
2927
|
+
logMessage,
|
|
2928
|
+
startState,
|
|
2929
|
+
// Cache ligado por padrão, com o store do próprio módulo: a tabela é daqui e a coluna é
|
|
2930
|
+
// daqui, então exigir que cada host escrevesse o repositório faria todos reescreverem o
|
|
2931
|
+
// mesmo código — e, até escreverem, o mesmo binário continuaria subindo por cliente. Quem
|
|
2932
|
+
// quiser outro lugar (Redis) troca o `store`; quem não fizer nada já sai sem ressubir.
|
|
2933
|
+
mediaIdCache: {
|
|
2934
|
+
store: new FlowMediaIdRepository(db),
|
|
2935
|
+
senderKey: config.phoneNumberId
|
|
2936
|
+
},
|
|
2937
|
+
onError: hooks?.onFlowMediaError
|
|
2938
|
+
}));
|
|
2939
|
+
}
|
|
2940
|
+
if (flowInterpreter && providers.catalog && config.catalogId) {
|
|
2941
|
+
flowInterpreter.registerFlowAction(import_meta_whatsapp_contracts13.FLOW_ACTION_KIND.SEND_PRODUCT_LIST, createSendProductListAction({
|
|
2942
|
+
catalog: providers.catalog,
|
|
2943
|
+
catalogId: config.catalogId,
|
|
2944
|
+
logMessage,
|
|
2945
|
+
startState,
|
|
2946
|
+
onError: hooks?.onFlowProductListError
|
|
2947
|
+
}));
|
|
2948
|
+
}
|
|
2949
|
+
const transcriptionMode = providers.transcription?.mode ?? TRANSCRIPTION_MODE.ON_DEMAND;
|
|
2950
|
+
const resolveTranscriptionPolicy = providers.transcription ? createTranscriptionPolicyResolver({
|
|
2951
|
+
settingsRepository,
|
|
2952
|
+
defaults: {
|
|
2953
|
+
isEnabled: providers.transcription.isEnabledByDefault ?? true,
|
|
2954
|
+
mode: transcriptionMode
|
|
2955
|
+
}
|
|
2956
|
+
}) : void 0;
|
|
2957
|
+
const ingestInboundMedia = providers.objectStorage ? new IngestInboundMediaUseCase(db, channel, providers.objectStorage, documentRepository, providers.transcription && resolveTranscriptionPolicy ? {
|
|
2958
|
+
transcriber: providers.transcription.transcriber,
|
|
2959
|
+
resolvePolicy: resolveTranscriptionPolicy,
|
|
2960
|
+
messageRepository,
|
|
2961
|
+
...providers.transcription.languageHint ? {
|
|
2962
|
+
languageHint: providers.transcription.languageHint
|
|
2963
|
+
} : {},
|
|
2964
|
+
...hooks ? {
|
|
2965
|
+
hooks
|
|
2966
|
+
} : {}
|
|
2967
|
+
} : void 0) : void 0;
|
|
2968
|
+
const transcribeAudio = providers.transcription && providers.objectStorage?.getObject ? new TranscribeAudioUseCase({
|
|
2969
|
+
messageRepository,
|
|
2970
|
+
objectStorage: providers.objectStorage,
|
|
2971
|
+
transcriber: providers.transcription.transcriber,
|
|
2972
|
+
...resolveTranscriptionPolicy ? {
|
|
2973
|
+
resolvePolicy: resolveTranscriptionPolicy
|
|
2974
|
+
} : {},
|
|
2975
|
+
...providers.transcription.languageHint ? {
|
|
2976
|
+
languageHint: providers.transcription.languageHint
|
|
2977
|
+
} : {},
|
|
2978
|
+
...hooks ? {
|
|
2979
|
+
hooks
|
|
2980
|
+
} : {}
|
|
2981
|
+
}) : void 0;
|
|
2982
|
+
const listDocuments = new ListConversationDocumentsUseCase(sessionRepository, documentRepository);
|
|
1463
2983
|
return {
|
|
1464
2984
|
channel,
|
|
1465
2985
|
// undefined quando providers.objectStorage não foi injetado.
|
|
@@ -1471,10 +2991,36 @@ function createMetaWhatsAppModule(params) {
|
|
|
1471
2991
|
release: new ReleaseConversationUseCase(sessionRepository, providers.realtime),
|
|
1472
2992
|
list: new ListConversationsUseCase(sessionRepository),
|
|
1473
2993
|
listMessages: new ListMessagesUseCase(sessionRepository, messageRepository),
|
|
2994
|
+
listDocuments,
|
|
2995
|
+
// Biblioteca da empresa inteira, para uma tela de Documentos fora da conversa.
|
|
2996
|
+
listCompanyDocuments: new ListCompanyDocumentsUseCase(documentRepository),
|
|
2997
|
+
// Apaga a mídia no storage antes das linhas — a cascata da FK sozinha deixaria os binários
|
|
2998
|
+
// órfãos, já que a lista de uploadId vive justamente nas linhas que ela derruba.
|
|
2999
|
+
delete: new DeleteConversationUseCase(sessionRepository, documentRepository, providers.objectStorage),
|
|
3000
|
+
purgeExpiredDocuments: new PurgeExpiredDocumentsUseCase(documentRepository, providers.objectStorage),
|
|
1474
3001
|
export: new ExportConversationUseCase(sessionRepository),
|
|
1475
|
-
|
|
3002
|
+
// undefined quando transcrição não foi injetada, ou quando o storage não sabe ler de volta.
|
|
3003
|
+
// O painel consulta a ausência para decidir se desenha o botão "transcrever".
|
|
3004
|
+
transcribeAudio,
|
|
3005
|
+
repository: sessionRepository,
|
|
3006
|
+
messageRepository,
|
|
3007
|
+
documentRepository
|
|
1476
3008
|
},
|
|
3009
|
+
/**
|
|
3010
|
+
* `undefined` = o host não injetou transcritor, e nenhuma configuração de empresa muda isso: a
|
|
3011
|
+
* capacidade não existe. Presente, `resolvePolicy` responde o que vale para uma empresa —
|
|
3012
|
+
* é o que a rota de configurações usa para dizer ao painel se desenha o interruptor.
|
|
3013
|
+
*/
|
|
3014
|
+
transcription: providers.transcription && resolveTranscriptionPolicy ? {
|
|
3015
|
+
defaultMode: transcriptionMode,
|
|
3016
|
+
resolvePolicy: resolveTranscriptionPolicy
|
|
3017
|
+
} : void 0,
|
|
1477
3018
|
settings: settingsRepository,
|
|
3019
|
+
/**
|
|
3020
|
+
* `undefined` quando o recurso não está ligado (ou falta storage legível). O host consulta a
|
|
3021
|
+
* ausência para não registrar a rota de upload — e o preview, sem a rota, esconde o microfone.
|
|
3022
|
+
*/
|
|
3023
|
+
previewMedia: previewMediaSupport ? new StorePreviewMediaUseCase(providers.objectStorage, () => `${Date.now()}-${Math.random().toString(36).slice(2)}`) : void 0,
|
|
1478
3024
|
webhook: {
|
|
1479
3025
|
receive: receiveWebhook,
|
|
1480
3026
|
// GET de verificação da Meta — o host liga na sua rota e devolve o retorno como texto puro.
|
|
@@ -1493,7 +3039,11 @@ function createMetaWhatsAppModule(params) {
|
|
|
1493
3039
|
save: new SaveFlowGraphUseCase(flowGraphRepository),
|
|
1494
3040
|
delete: new DeleteFlowGraphUseCase(flowGraphRepository),
|
|
1495
3041
|
livePositions: new GetLiveFlowPositionsUseCase(flowGraphRepository),
|
|
1496
|
-
repository: flowGraphRepository
|
|
3042
|
+
repository: flowGraphRepository,
|
|
3043
|
+
// Biblioteca de mídia dos nós `send_media` — o host liga nas rotas do editor
|
|
3044
|
+
// (anexar/reordenar/desligar). Existe mesmo sem storage injetado: gerenciar anexos é
|
|
3045
|
+
// consultar a tabela, e só o ENVIO precisa dos bytes.
|
|
3046
|
+
mediaRepository: flowMediaRepository
|
|
1497
3047
|
} : void 0,
|
|
1498
3048
|
catalog: providers.catalog
|
|
1499
3049
|
};
|
|
@@ -1516,7 +3066,7 @@ async function runMetaWhatsAppMigrations(params) {
|
|
|
1516
3066
|
__name(runMetaWhatsAppMigrations, "runMetaWhatsAppMigrations");
|
|
1517
3067
|
|
|
1518
3068
|
// src/realtime/SseHub.ts
|
|
1519
|
-
var
|
|
3069
|
+
var import_node_crypto = require("crypto");
|
|
1520
3070
|
var TICKET_TTL_SECONDS = 60;
|
|
1521
3071
|
var SseHub = class {
|
|
1522
3072
|
static {
|
|
@@ -1529,7 +3079,7 @@ var SseHub = class {
|
|
|
1529
3079
|
relaySubscriptions = /* @__PURE__ */ new Map();
|
|
1530
3080
|
// Identifica esta instância do processo para descartar o próprio eco (pub/sub entrega a
|
|
1531
3081
|
// mensagem a todos os inscritos, inclusive quem publicou).
|
|
1532
|
-
instanceId = (0,
|
|
3082
|
+
instanceId = (0, import_node_crypto.randomUUID)();
|
|
1533
3083
|
constructor(relay) {
|
|
1534
3084
|
this.relay = relay;
|
|
1535
3085
|
}
|
|
@@ -1575,7 +3125,7 @@ var SseHub = class {
|
|
|
1575
3125
|
}
|
|
1576
3126
|
};
|
|
1577
3127
|
async function issueSseTicket(store, companyId, whatsappNumber) {
|
|
1578
|
-
const ticket = (0,
|
|
3128
|
+
const ticket = (0, import_node_crypto.randomUUID)();
|
|
1579
3129
|
await store.set(`sse:ticket:${ticket}`, JSON.stringify({
|
|
1580
3130
|
companyId,
|
|
1581
3131
|
whatsappNumber
|
|
@@ -1593,14 +3143,23 @@ __name(redeemSseTicket, "redeemSseTicket");
|
|
|
1593
3143
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1594
3144
|
0 && (module.exports = {
|
|
1595
3145
|
CreateFlowGraphUseCase,
|
|
3146
|
+
DEFAULT_FLOW_GRAPH_CACHE_TTL_SECONDS,
|
|
3147
|
+
DeleteConversationUseCase,
|
|
1596
3148
|
DeleteFlowGraphUseCase,
|
|
3149
|
+
DocumentRepository,
|
|
1597
3150
|
ExportConversationUseCase,
|
|
3151
|
+
FlowGraphCache,
|
|
1598
3152
|
FlowGraphRepository,
|
|
1599
3153
|
FlowInterpreter,
|
|
3154
|
+
FlowMediaIdRepository,
|
|
3155
|
+
FlowMediaRepository,
|
|
1600
3156
|
GetFlowGraphUseCase,
|
|
1601
3157
|
GetLiveFlowPositionsUseCase,
|
|
3158
|
+
InboundEffectsDispatcher,
|
|
1602
3159
|
IngestInboundMediaUseCase,
|
|
1603
3160
|
InvalidFlowGraphError,
|
|
3161
|
+
ListCompanyDocumentsUseCase,
|
|
3162
|
+
ListConversationDocumentsUseCase,
|
|
1604
3163
|
ListConversationsUseCase,
|
|
1605
3164
|
ListFlowGraphsUseCase,
|
|
1606
3165
|
ListMessagesUseCase,
|
|
@@ -1608,6 +3167,10 @@ __name(redeemSseTicket, "redeemSseTicket");
|
|
|
1608
3167
|
META_WHATSAPP_MIGRATIONS_TABLE,
|
|
1609
3168
|
MessageRepository,
|
|
1610
3169
|
OptimisticLockError,
|
|
3170
|
+
PREVIEW_MEDIA_ID_PREFIX,
|
|
3171
|
+
PRODUCT_LIST_LIMIT,
|
|
3172
|
+
ProcessInboundDispatchUseCase,
|
|
3173
|
+
PurgeExpiredDocumentsUseCase,
|
|
1611
3174
|
ReceiveWebhookUseCase,
|
|
1612
3175
|
ReleaseConversationUseCase,
|
|
1613
3176
|
SaveFlowGraphUseCase,
|
|
@@ -1615,21 +3178,41 @@ __name(redeemSseTicket, "redeemSseTicket");
|
|
|
1615
3178
|
SessionRepository,
|
|
1616
3179
|
SettingsRepository,
|
|
1617
3180
|
SseHub,
|
|
3181
|
+
StorePreviewMediaUseCase,
|
|
3182
|
+
TRANSCRIPTION_MODE,
|
|
3183
|
+
TRANSCRIPTION_STATUS,
|
|
1618
3184
|
TakeoverConversationUseCase,
|
|
3185
|
+
TranscribeAudioUseCase,
|
|
3186
|
+
WEBHOOK_CLAIM_TTL_SECONDS,
|
|
1619
3187
|
WEBHOOK_NONCE_TTL_SECONDS,
|
|
1620
3188
|
WhatsAppChannelAdapter,
|
|
3189
|
+
buildInboundJobId,
|
|
1621
3190
|
claimWebhookDelivery,
|
|
3191
|
+
confirmWebhookDelivery,
|
|
1622
3192
|
createMetaWhatsAppModule,
|
|
3193
|
+
createSendMediaAction,
|
|
3194
|
+
createSendProductListAction,
|
|
3195
|
+
createTranscriptionPolicyResolver,
|
|
3196
|
+
documents,
|
|
1623
3197
|
extractMediaDescriptor,
|
|
1624
3198
|
flowGraphs,
|
|
3199
|
+
flowMedia,
|
|
3200
|
+
isAudioMimeType,
|
|
3201
|
+
isRetriableTranscriptionError,
|
|
3202
|
+
isUnsupportedTranscriptionError,
|
|
1625
3203
|
issueSseTicket,
|
|
1626
3204
|
messages,
|
|
1627
3205
|
metaWhatsAppMigrationsFolder,
|
|
1628
3206
|
metaWhatsAppSchema,
|
|
1629
3207
|
redeemSseTicket,
|
|
3208
|
+
resolveFailureStatus,
|
|
3209
|
+
resolvePreviewUploadId,
|
|
1630
3210
|
runMetaWhatsAppMigrations,
|
|
1631
3211
|
sessions,
|
|
1632
3212
|
settings,
|
|
3213
|
+
toPreviewMediaId,
|
|
3214
|
+
toSessionContract,
|
|
3215
|
+
transcriptionRetryAfterSeconds,
|
|
1633
3216
|
verifyWebhookChallenge,
|
|
1634
3217
|
verifyWebhookSignature
|
|
1635
3218
|
});
|