@adatechnology/meta-whatsapp-module 0.2.0-rc.11 → 0.2.0-rc.13
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 +527 -92
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +467 -3
- package/dist/index.d.ts +467 -3
- package/dist/index.js +507 -78
- package/dist/index.js.map +1 -1
- package/dist/migrations/0006_conversation_documents.sql +19 -0
- package/dist/migrations/meta/_journal.json +7 -0
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -128,6 +128,46 @@ var messages = metaWhatsAppSchema.table("messages", {
|
|
|
128
128
|
// tamanho do transcript.
|
|
129
129
|
index("idx_messages_moderation_flagged").on(table.companyId, table.createdAt).where(sql`${table.moderationFlagged}`)
|
|
130
130
|
]);
|
|
131
|
+
var documents = metaWhatsAppSchema.table("documents", {
|
|
132
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
133
|
+
companyId: uuid("company_id").notNull(),
|
|
134
|
+
sessionId: uuid("session_id").notNull().references(() => sessions.id, {
|
|
135
|
+
onDelete: "cascade"
|
|
136
|
+
}),
|
|
137
|
+
// Anulável e `set null`: apagar a mensagem não deve apagar o arquivo da biblioteca, e arquivo
|
|
138
|
+
// do atendente nasce sem mensagem.
|
|
139
|
+
messageId: uuid("message_id").references(() => messages.id, {
|
|
140
|
+
onDelete: "set null"
|
|
141
|
+
}),
|
|
142
|
+
uploadId: varchar("upload_id", {
|
|
143
|
+
length: 256
|
|
144
|
+
}).notNull(),
|
|
145
|
+
filename: varchar("filename", {
|
|
146
|
+
length: 512
|
|
147
|
+
}).notNull(),
|
|
148
|
+
mimeType: varchar("mime_type", {
|
|
149
|
+
length: 128
|
|
150
|
+
}).notNull(),
|
|
151
|
+
sizeBytes: integer("size_bytes").notNull(),
|
|
152
|
+
// Do provider de storage, que é endereçado por conteúdo — serve para reconciliar storage
|
|
153
|
+
// contra tabela e para detectar o mesmo binário chegando duas vezes.
|
|
154
|
+
sha256: varchar("sha256", {
|
|
155
|
+
length: 64
|
|
156
|
+
}),
|
|
157
|
+
source: varchar("source", {
|
|
158
|
+
length: 12
|
|
159
|
+
}).notNull(),
|
|
160
|
+
linkedAt: timestamp("linked_at", {
|
|
161
|
+
withTimezone: true
|
|
162
|
+
}).notNull().defaultNow()
|
|
163
|
+
}, (table) => [
|
|
164
|
+
index("idx_documents_session_linked").on(table.sessionId, table.linkedAt),
|
|
165
|
+
// Para varredura de retenção por idade sem passar por sessão.
|
|
166
|
+
index("idx_documents_company_linked").on(table.companyId, table.linkedAt),
|
|
167
|
+
// O mesmo objeto não pode ser linkado duas vezes na mesma empresa: o job de ingestão é
|
|
168
|
+
// reentregue por retry, e sem isto a reentrega criaria linha duplicada no painel.
|
|
169
|
+
uniqueIndex("idx_documents_company_upload").on(table.companyId, table.uploadId)
|
|
170
|
+
]);
|
|
131
171
|
var flowGraphs = metaWhatsAppSchema.table("flow_graphs", {
|
|
132
172
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
133
173
|
companyId: uuid("company_id").notNull(),
|
|
@@ -308,6 +348,16 @@ var SessionRepository = class {
|
|
|
308
348
|
async release(companyId, whatsappNumber) {
|
|
309
349
|
await this.setMode(companyId, whatsappNumber, "bot", null);
|
|
310
350
|
}
|
|
351
|
+
/**
|
|
352
|
+
* Apaga a sessão; a cascata das FKs leva mensagens e documentos.
|
|
353
|
+
*
|
|
354
|
+
* Não apaga o binário no storage — isso é passo de aplicação, e é por isso que este método é
|
|
355
|
+
* chamado por `DeleteConversationUseCase` e não diretamente pelo host. Chamar daqui sem apagar os
|
|
356
|
+
* objetos antes deixa mídia órfã sendo cobrada para sempre.
|
|
357
|
+
*/
|
|
358
|
+
async deleteByNumber(companyId, whatsappNumber) {
|
|
359
|
+
await this.db.delete(sessions).where(and(eq(sessions.companyId, companyId), eq(sessions.whatsappNumber, whatsappNumber)));
|
|
360
|
+
}
|
|
311
361
|
async requestHuman(companyId, whatsappNumber) {
|
|
312
362
|
await this.db.update(sessions).set({
|
|
313
363
|
humanRequestedAt: sql2`now()`,
|
|
@@ -676,11 +726,13 @@ var SendMessageUseCase = class {
|
|
|
676
726
|
sessionRepository;
|
|
677
727
|
logMessage;
|
|
678
728
|
objectStorage;
|
|
679
|
-
|
|
729
|
+
documentRepository;
|
|
730
|
+
constructor(channel, sessionRepository, logMessage, objectStorage, documentRepository) {
|
|
680
731
|
this.channel = channel;
|
|
681
732
|
this.sessionRepository = sessionRepository;
|
|
682
733
|
this.logMessage = logMessage;
|
|
683
734
|
this.objectStorage = objectStorage;
|
|
735
|
+
this.documentRepository = documentRepository;
|
|
684
736
|
}
|
|
685
737
|
async assertWithinWindow(companyId, whatsappNumber) {
|
|
686
738
|
const hours = await this.sessionRepository.hoursSinceLastInbound(companyId, whatsappNumber);
|
|
@@ -717,7 +769,7 @@ var SendMessageUseCase = class {
|
|
|
717
769
|
mimeType: params.mimeType,
|
|
718
770
|
key: `meta-whatsapp/${params.companyId}/${Date.now()}-${params.filename}`
|
|
719
771
|
})).uploadId : void 0;
|
|
720
|
-
|
|
772
|
+
const saved = await this.logMessage.execute({
|
|
721
773
|
companyId: params.companyId,
|
|
722
774
|
whatsappNumber: params.whatsappNumber,
|
|
723
775
|
direction: "outbound",
|
|
@@ -736,6 +788,19 @@ var SendMessageUseCase = class {
|
|
|
736
788
|
status: "sent",
|
|
737
789
|
startState: params.startState
|
|
738
790
|
});
|
|
791
|
+
if (saved && uploadId) {
|
|
792
|
+
await this.documentRepository?.link({
|
|
793
|
+
companyId: params.companyId,
|
|
794
|
+
sessionId: saved.sessionId,
|
|
795
|
+
messageId: saved.id,
|
|
796
|
+
uploadId,
|
|
797
|
+
filename: params.filename,
|
|
798
|
+
mimeType: params.mimeType,
|
|
799
|
+
sizeBytes: params.buffer.length,
|
|
800
|
+
source: params.sender
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
return saved;
|
|
739
804
|
}
|
|
740
805
|
// Template é o único envio que ignora a janela — é justamente o mecanismo que a Meta oferece
|
|
741
806
|
// para reabri-la.
|
|
@@ -853,8 +918,330 @@ var ListMessagesUseCase = class {
|
|
|
853
918
|
}
|
|
854
919
|
};
|
|
855
920
|
|
|
856
|
-
// src/use-cases/
|
|
921
|
+
// src/use-cases/ListConversationDocuments.use-case.ts
|
|
857
922
|
import { SessionNotFoundError as SessionNotFoundError2 } from "@adatechnology/meta-whatsapp-contracts";
|
|
923
|
+
var ListConversationDocumentsUseCase = class {
|
|
924
|
+
static {
|
|
925
|
+
__name(this, "ListConversationDocumentsUseCase");
|
|
926
|
+
}
|
|
927
|
+
sessionRepository;
|
|
928
|
+
documentRepository;
|
|
929
|
+
constructor(sessionRepository, documentRepository) {
|
|
930
|
+
this.sessionRepository = sessionRepository;
|
|
931
|
+
this.documentRepository = documentRepository;
|
|
932
|
+
}
|
|
933
|
+
async execute(params) {
|
|
934
|
+
const session = await this.sessionRepository.getContext(params.companyId, params.whatsappNumber);
|
|
935
|
+
if (!session) throw new SessionNotFoundError2(params.whatsappNumber);
|
|
936
|
+
const { rows, total } = await this.documentRepository.listByConversation({
|
|
937
|
+
companyId: params.companyId,
|
|
938
|
+
sessionId: session.id,
|
|
939
|
+
...params.search ? {
|
|
940
|
+
search: params.search
|
|
941
|
+
} : {},
|
|
942
|
+
...params.sources && params.sources.length > 0 ? {
|
|
943
|
+
sources: params.sources
|
|
944
|
+
} : {},
|
|
945
|
+
...params.sortDirection ? {
|
|
946
|
+
sortDirection: params.sortDirection
|
|
947
|
+
} : {},
|
|
948
|
+
...params.page ? {
|
|
949
|
+
page: params.page
|
|
950
|
+
} : {},
|
|
951
|
+
...params.limit ? {
|
|
952
|
+
limit: params.limit
|
|
953
|
+
} : {}
|
|
954
|
+
});
|
|
955
|
+
return {
|
|
956
|
+
documents: rows.map((row) => ({
|
|
957
|
+
id: row.uploadId,
|
|
958
|
+
filename: row.filename,
|
|
959
|
+
mimeType: row.mimeType,
|
|
960
|
+
sizeBytes: row.sizeBytes,
|
|
961
|
+
source: row.source,
|
|
962
|
+
linkedAt: row.linkedAt.toISOString()
|
|
963
|
+
})),
|
|
964
|
+
total
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
};
|
|
968
|
+
|
|
969
|
+
// src/use-cases/ListCompanyDocuments.use-case.ts
|
|
970
|
+
var ListCompanyDocumentsUseCase = class {
|
|
971
|
+
static {
|
|
972
|
+
__name(this, "ListCompanyDocumentsUseCase");
|
|
973
|
+
}
|
|
974
|
+
documentRepository;
|
|
975
|
+
constructor(documentRepository) {
|
|
976
|
+
this.documentRepository = documentRepository;
|
|
977
|
+
}
|
|
978
|
+
async execute(params) {
|
|
979
|
+
const { rows, total } = await this.documentRepository.listByCompany({
|
|
980
|
+
companyId: params.companyId,
|
|
981
|
+
...params.search ? {
|
|
982
|
+
search: params.search
|
|
983
|
+
} : {},
|
|
984
|
+
...params.sources && params.sources.length > 0 ? {
|
|
985
|
+
sources: params.sources
|
|
986
|
+
} : {},
|
|
987
|
+
...params.sortDirection ? {
|
|
988
|
+
sortDirection: params.sortDirection
|
|
989
|
+
} : {},
|
|
990
|
+
...params.page ? {
|
|
991
|
+
page: params.page
|
|
992
|
+
} : {},
|
|
993
|
+
...params.limit ? {
|
|
994
|
+
limit: params.limit
|
|
995
|
+
} : {}
|
|
996
|
+
});
|
|
997
|
+
return {
|
|
998
|
+
documents: rows.map((row) => ({
|
|
999
|
+
id: row.uploadId,
|
|
1000
|
+
conversationId: row.whatsappNumber,
|
|
1001
|
+
filename: row.filename,
|
|
1002
|
+
mimeType: row.mimeType,
|
|
1003
|
+
sizeBytes: row.sizeBytes,
|
|
1004
|
+
source: row.source,
|
|
1005
|
+
linkedAt: row.linkedAt.toISOString()
|
|
1006
|
+
})),
|
|
1007
|
+
total
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
};
|
|
1011
|
+
|
|
1012
|
+
// src/use-cases/DeleteConversation.use-case.ts
|
|
1013
|
+
import { SessionNotFoundError as SessionNotFoundError3 } from "@adatechnology/meta-whatsapp-contracts";
|
|
1014
|
+
var DeleteConversationUseCase = class {
|
|
1015
|
+
static {
|
|
1016
|
+
__name(this, "DeleteConversationUseCase");
|
|
1017
|
+
}
|
|
1018
|
+
sessionRepository;
|
|
1019
|
+
documentRepository;
|
|
1020
|
+
objectStorage;
|
|
1021
|
+
constructor(sessionRepository, documentRepository, objectStorage) {
|
|
1022
|
+
this.sessionRepository = sessionRepository;
|
|
1023
|
+
this.documentRepository = documentRepository;
|
|
1024
|
+
this.objectStorage = objectStorage;
|
|
1025
|
+
}
|
|
1026
|
+
async execute(params) {
|
|
1027
|
+
const session = await this.sessionRepository.getContext(params.companyId, params.whatsappNumber);
|
|
1028
|
+
if (!session) throw new SessionNotFoundError3(params.whatsappNumber);
|
|
1029
|
+
const uploadIds = await this.documentRepository.listUploadIdsBySession(params.companyId, session.id);
|
|
1030
|
+
if (uploadIds.length > 0 && !this.objectStorage?.delete) {
|
|
1031
|
+
throw new Error("storage_delete_unsupported: a conversa tem arquivos e o storage injetado n\xE3o implementa delete");
|
|
1032
|
+
}
|
|
1033
|
+
const failedObjects = [];
|
|
1034
|
+
let deletedObjects = 0;
|
|
1035
|
+
for (const uploadId of uploadIds) {
|
|
1036
|
+
try {
|
|
1037
|
+
await this.objectStorage?.delete?.(uploadId);
|
|
1038
|
+
deletedObjects++;
|
|
1039
|
+
} catch {
|
|
1040
|
+
failedObjects.push(uploadId);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
if (failedObjects.length > 0) return {
|
|
1044
|
+
deletedObjects,
|
|
1045
|
+
failedObjects
|
|
1046
|
+
};
|
|
1047
|
+
await this.sessionRepository.deleteByNumber(params.companyId, params.whatsappNumber);
|
|
1048
|
+
return {
|
|
1049
|
+
deletedObjects,
|
|
1050
|
+
failedObjects: []
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
};
|
|
1054
|
+
|
|
1055
|
+
// src/use-cases/PurgeExpiredDocuments.use-case.ts
|
|
1056
|
+
var DEFAULT_BATCH_SIZE = 50;
|
|
1057
|
+
var HOURS_IN_DAY = 24;
|
|
1058
|
+
var MILLISECONDS_IN_HOUR = 60 * 60 * 1e3;
|
|
1059
|
+
var PurgeExpiredDocumentsUseCase = class {
|
|
1060
|
+
static {
|
|
1061
|
+
__name(this, "PurgeExpiredDocumentsUseCase");
|
|
1062
|
+
}
|
|
1063
|
+
documentRepository;
|
|
1064
|
+
objectStorage;
|
|
1065
|
+
constructor(documentRepository, objectStorage) {
|
|
1066
|
+
this.documentRepository = documentRepository;
|
|
1067
|
+
this.objectStorage = objectStorage;
|
|
1068
|
+
}
|
|
1069
|
+
async execute(params) {
|
|
1070
|
+
if (!this.objectStorage?.delete) {
|
|
1071
|
+
throw new Error("storage_delete_unsupported: reten\xE7\xE3o exige um storage que implemente delete");
|
|
1072
|
+
}
|
|
1073
|
+
const reference = params.now ?? /* @__PURE__ */ new Date();
|
|
1074
|
+
const olderThan = new Date(reference.getTime() - params.retentionDays * HOURS_IN_DAY * MILLISECONDS_IN_HOUR);
|
|
1075
|
+
const expired = await this.documentRepository.listExpired(params.companyId, olderThan, params.batchSize ?? DEFAULT_BATCH_SIZE);
|
|
1076
|
+
const failed = [];
|
|
1077
|
+
let purged = 0;
|
|
1078
|
+
for (const document of expired) {
|
|
1079
|
+
try {
|
|
1080
|
+
await this.objectStorage.delete(document.uploadId);
|
|
1081
|
+
} catch {
|
|
1082
|
+
failed.push(document.uploadId);
|
|
1083
|
+
continue;
|
|
1084
|
+
}
|
|
1085
|
+
await this.documentRepository.deleteById(params.companyId, document.id);
|
|
1086
|
+
purged++;
|
|
1087
|
+
}
|
|
1088
|
+
return {
|
|
1089
|
+
purged,
|
|
1090
|
+
failed
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
};
|
|
1094
|
+
|
|
1095
|
+
// src/repositories/DocumentRepository.ts
|
|
1096
|
+
import { and as and4, asc, count, desc as desc2, eq as eq5, ilike, inArray, lt as lt2, or } from "drizzle-orm";
|
|
1097
|
+
var DEFAULT_LIMIT3 = 50;
|
|
1098
|
+
function companyDocumentSearch(search) {
|
|
1099
|
+
const term = search?.trim();
|
|
1100
|
+
if (!term) return void 0;
|
|
1101
|
+
const digits = term.replace(/\D/g, "");
|
|
1102
|
+
const byFilename = ilike(documents.filename, `%${term}%`);
|
|
1103
|
+
if (!digits) return byFilename;
|
|
1104
|
+
return or(byFilename, ilike(sessions.whatsappNumber, `%${digits}%`));
|
|
1105
|
+
}
|
|
1106
|
+
__name(companyDocumentSearch, "companyDocumentSearch");
|
|
1107
|
+
var DocumentRepository = class {
|
|
1108
|
+
static {
|
|
1109
|
+
__name(this, "DocumentRepository");
|
|
1110
|
+
}
|
|
1111
|
+
db;
|
|
1112
|
+
constructor(db) {
|
|
1113
|
+
this.db = db;
|
|
1114
|
+
}
|
|
1115
|
+
/**
|
|
1116
|
+
* Idempotente por (companyId, uploadId), garantido pelo índice único e não por SELECT prévio: o
|
|
1117
|
+
* job de ingestão é reentregue por retry e duas tentativas concorrentes passariam as duas por uma
|
|
1118
|
+
* checagem, duplicando a linha no painel.
|
|
1119
|
+
*
|
|
1120
|
+
* Devolve `undefined` quando o documento já estava linkado.
|
|
1121
|
+
*/
|
|
1122
|
+
async link(params) {
|
|
1123
|
+
const values = {
|
|
1124
|
+
companyId: params.companyId,
|
|
1125
|
+
sessionId: params.sessionId,
|
|
1126
|
+
messageId: params.messageId ?? null,
|
|
1127
|
+
uploadId: params.uploadId,
|
|
1128
|
+
filename: params.filename,
|
|
1129
|
+
mimeType: params.mimeType,
|
|
1130
|
+
sizeBytes: params.sizeBytes,
|
|
1131
|
+
sha256: params.sha256 ?? null,
|
|
1132
|
+
source: params.source
|
|
1133
|
+
};
|
|
1134
|
+
const [created] = await this.db.insert(documents).values(values).onConflictDoNothing().returning();
|
|
1135
|
+
return created;
|
|
1136
|
+
}
|
|
1137
|
+
async listByConversation(params) {
|
|
1138
|
+
const filters = [
|
|
1139
|
+
eq5(documents.companyId, params.companyId),
|
|
1140
|
+
eq5(documents.sessionId, params.sessionId)
|
|
1141
|
+
];
|
|
1142
|
+
if (params.search) filters.push(ilike(documents.filename, `%${params.search}%`));
|
|
1143
|
+
if (params.sources && params.sources.length > 0) {
|
|
1144
|
+
filters.push(inArray(documents.source, [
|
|
1145
|
+
...params.sources
|
|
1146
|
+
]));
|
|
1147
|
+
}
|
|
1148
|
+
const where = and4(...filters);
|
|
1149
|
+
const limit = params.limit ?? DEFAULT_LIMIT3;
|
|
1150
|
+
const page = params.page && params.page > 0 ? params.page : 1;
|
|
1151
|
+
const orderBy = params.sortDirection === "asc" ? asc(documents.linkedAt) : desc2(documents.linkedAt);
|
|
1152
|
+
const [rows, counted] = await Promise.all([
|
|
1153
|
+
this.db.select().from(documents).where(where).orderBy(orderBy).limit(limit).offset((page - 1) * limit),
|
|
1154
|
+
this.db.select({
|
|
1155
|
+
value: count()
|
|
1156
|
+
}).from(documents).where(where)
|
|
1157
|
+
]);
|
|
1158
|
+
return {
|
|
1159
|
+
rows,
|
|
1160
|
+
total: counted[0]?.value ?? 0
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* A biblioteca da EMPRESA inteira, não de uma conversa.
|
|
1165
|
+
*
|
|
1166
|
+
* A busca casa nome do arquivo OU telefone da conversa — ver `companyDocumentSearch`.
|
|
1167
|
+
*
|
|
1168
|
+
* Faz join com `sessions` para carregar de qual conversa cada arquivo veio — numa lista global,
|
|
1169
|
+
* arquivo sem essa referência é inútil: o atendente vê "comprovante.pdf" e não sabe de quem.
|
|
1170
|
+
*
|
|
1171
|
+
* Ordena por `linkedAt` apoiada no índice `idx_documents_company_linked`, que já existia para a
|
|
1172
|
+
* varredura de retenção.
|
|
1173
|
+
*/
|
|
1174
|
+
async listByCompany(params) {
|
|
1175
|
+
const filters = [
|
|
1176
|
+
eq5(documents.companyId, params.companyId)
|
|
1177
|
+
];
|
|
1178
|
+
const search = companyDocumentSearch(params.search);
|
|
1179
|
+
if (search) filters.push(search);
|
|
1180
|
+
if (params.sources && params.sources.length > 0) {
|
|
1181
|
+
filters.push(inArray(documents.source, [
|
|
1182
|
+
...params.sources
|
|
1183
|
+
]));
|
|
1184
|
+
}
|
|
1185
|
+
const where = and4(...filters);
|
|
1186
|
+
const limit = params.limit ?? DEFAULT_LIMIT3;
|
|
1187
|
+
const page = params.page && params.page > 0 ? params.page : 1;
|
|
1188
|
+
const orderBy = params.sortDirection === "asc" ? asc(documents.linkedAt) : desc2(documents.linkedAt);
|
|
1189
|
+
const [rows, counted] = await Promise.all([
|
|
1190
|
+
this.db.select({
|
|
1191
|
+
id: documents.id,
|
|
1192
|
+
uploadId: documents.uploadId,
|
|
1193
|
+
filename: documents.filename,
|
|
1194
|
+
mimeType: documents.mimeType,
|
|
1195
|
+
sizeBytes: documents.sizeBytes,
|
|
1196
|
+
source: documents.source,
|
|
1197
|
+
linkedAt: documents.linkedAt,
|
|
1198
|
+
// Só o número: o NOME do cliente é dado do produto (tabela própria dele), não do
|
|
1199
|
+
// módulo. Quem quiser exibir "Marina Alves" enriquece na borda.
|
|
1200
|
+
whatsappNumber: sessions.whatsappNumber
|
|
1201
|
+
}).from(documents).innerJoin(sessions, eq5(documents.sessionId, sessions.id)).where(where).orderBy(orderBy).limit(limit).offset((page - 1) * limit),
|
|
1202
|
+
// O mesmo join da listagem, e não só `from(documents)`: a busca pode citar
|
|
1203
|
+
// `sessions.whatsapp_number`, e uma contagem sem a tabela na cláusula não compila — pior,
|
|
1204
|
+
// se compilasse, o total divergiria das linhas e a paginação prometeria páginas vazias.
|
|
1205
|
+
this.db.select({
|
|
1206
|
+
value: count()
|
|
1207
|
+
}).from(documents).innerJoin(sessions, eq5(documents.sessionId, sessions.id)).where(where)
|
|
1208
|
+
]);
|
|
1209
|
+
return {
|
|
1210
|
+
rows,
|
|
1211
|
+
total: counted[0]?.value ?? 0
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
/**
|
|
1215
|
+
* Um documento pela key do objeto. Serve para recuperar o nome original na hora de assinar o
|
|
1216
|
+
* download: a key é caminho no bucket e salvaria o arquivo com o id da Meta.
|
|
1217
|
+
*/
|
|
1218
|
+
async findByUploadId(companyId, uploadId) {
|
|
1219
|
+
const [row] = await this.db.select().from(documents).where(and4(eq5(documents.companyId, companyId), eq5(documents.uploadId, uploadId))).limit(1);
|
|
1220
|
+
return row;
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* Os objetos a apagar no storage antes de a linha sumir.
|
|
1224
|
+
*
|
|
1225
|
+
* Existe porque a cascata da FK apaga a linha e deixa o binário órfão: quem for apagar a conversa
|
|
1226
|
+
* precisa desta lista primeiro, senão paga armazenamento para sempre por arquivo inalcançável.
|
|
1227
|
+
*/
|
|
1228
|
+
async listUploadIdsBySession(companyId, sessionId) {
|
|
1229
|
+
const rows = await this.db.select({
|
|
1230
|
+
uploadId: documents.uploadId
|
|
1231
|
+
}).from(documents).where(and4(eq5(documents.companyId, companyId), eq5(documents.sessionId, sessionId)));
|
|
1232
|
+
return rows.map((row) => row.uploadId);
|
|
1233
|
+
}
|
|
1234
|
+
/** Varredura de retenção por idade — o par é o mesmo cuidado com o objeto no storage. */
|
|
1235
|
+
async listExpired(companyId, olderThan, limit = DEFAULT_LIMIT3) {
|
|
1236
|
+
return this.db.select().from(documents).where(and4(eq5(documents.companyId, companyId), lt2(documents.linkedAt, olderThan))).orderBy(documents.linkedAt).limit(limit);
|
|
1237
|
+
}
|
|
1238
|
+
async deleteById(companyId, id) {
|
|
1239
|
+
await this.db.delete(documents).where(and4(eq5(documents.companyId, companyId), eq5(documents.id, id)));
|
|
1240
|
+
}
|
|
1241
|
+
};
|
|
1242
|
+
|
|
1243
|
+
// src/use-cases/ExportConversation.use-case.ts
|
|
1244
|
+
import { SessionNotFoundError as SessionNotFoundError4 } from "@adatechnology/meta-whatsapp-contracts";
|
|
858
1245
|
var ExportConversationUseCase = class {
|
|
859
1246
|
static {
|
|
860
1247
|
__name(this, "ExportConversationUseCase");
|
|
@@ -865,7 +1252,7 @@ var ExportConversationUseCase = class {
|
|
|
865
1252
|
}
|
|
866
1253
|
async execute(params) {
|
|
867
1254
|
const result = await this.sessionRepository.exportConversation(params.companyId, params.whatsappNumber);
|
|
868
|
-
if (!result) throw new
|
|
1255
|
+
if (!result) throw new SessionNotFoundError4(params.whatsappNumber);
|
|
869
1256
|
return result;
|
|
870
1257
|
}
|
|
871
1258
|
};
|
|
@@ -1197,6 +1584,90 @@ async function claimWebhookDelivery(params) {
|
|
|
1197
1584
|
}
|
|
1198
1585
|
__name(claimWebhookDelivery, "claimWebhookDelivery");
|
|
1199
1586
|
|
|
1587
|
+
// src/channel/IngestInboundMedia.use-case.ts
|
|
1588
|
+
import { eq as eq6, and as and5 } from "drizzle-orm";
|
|
1589
|
+
var IngestInboundMediaUseCase = class {
|
|
1590
|
+
static {
|
|
1591
|
+
__name(this, "IngestInboundMediaUseCase");
|
|
1592
|
+
}
|
|
1593
|
+
db;
|
|
1594
|
+
channel;
|
|
1595
|
+
objectStorage;
|
|
1596
|
+
documentRepository;
|
|
1597
|
+
constructor(db, channel, objectStorage, documentRepository) {
|
|
1598
|
+
this.db = db;
|
|
1599
|
+
this.channel = channel;
|
|
1600
|
+
this.objectStorage = objectStorage;
|
|
1601
|
+
this.documentRepository = documentRepository;
|
|
1602
|
+
}
|
|
1603
|
+
async execute(params) {
|
|
1604
|
+
const [message] = await this.db.select().from(messages).where(and5(eq6(messages.companyId, params.companyId), eq6(messages.id, params.messageId))).limit(1);
|
|
1605
|
+
if (!message) throw new Error(`Mensagem ${params.messageId} n\xE3o encontrada para ingest\xE3o de m\xEDdia`);
|
|
1606
|
+
const payload = message.payload ?? {};
|
|
1607
|
+
if (payload["uploadId"] && payload["sourceMediaId"] === params.sourceMediaId) {
|
|
1608
|
+
return {
|
|
1609
|
+
uploadId: String(payload["uploadId"]),
|
|
1610
|
+
alreadyIngested: true
|
|
1611
|
+
};
|
|
1612
|
+
}
|
|
1613
|
+
const { data, mimeType } = await this.channel.fetchMediaAsBase64(params.sourceMediaId);
|
|
1614
|
+
const buffer = Buffer.from(data, "base64");
|
|
1615
|
+
const { uploadId } = await this.objectStorage.upload({
|
|
1616
|
+
buffer,
|
|
1617
|
+
mimeType: mimeType || params.mimeType,
|
|
1618
|
+
key: `meta-whatsapp/${params.companyId}/inbound/${params.sourceMediaId}`
|
|
1619
|
+
});
|
|
1620
|
+
const updatedPayload = {
|
|
1621
|
+
...payload,
|
|
1622
|
+
uploadId,
|
|
1623
|
+
sourceMediaId: params.sourceMediaId,
|
|
1624
|
+
mimeType: mimeType || params.mimeType,
|
|
1625
|
+
...params.filename ? {
|
|
1626
|
+
filename: params.filename
|
|
1627
|
+
} : {}
|
|
1628
|
+
};
|
|
1629
|
+
await this.db.update(messages).set({
|
|
1630
|
+
payload: updatedPayload
|
|
1631
|
+
}).where(and5(eq6(messages.companyId, params.companyId), eq6(messages.id, params.messageId)));
|
|
1632
|
+
await this.documentRepository?.link({
|
|
1633
|
+
companyId: params.companyId,
|
|
1634
|
+
sessionId: message.sessionId,
|
|
1635
|
+
messageId: message.id,
|
|
1636
|
+
uploadId,
|
|
1637
|
+
// Áudio e sticker chegam sem nome; sem um rótulo o painel mostraria linha vazia.
|
|
1638
|
+
filename: params.filename ?? `${params.sourceMediaId}`,
|
|
1639
|
+
mimeType: mimeType || params.mimeType,
|
|
1640
|
+
sizeBytes: buffer.length,
|
|
1641
|
+
source: message.sender
|
|
1642
|
+
});
|
|
1643
|
+
return {
|
|
1644
|
+
uploadId,
|
|
1645
|
+
alreadyIngested: false
|
|
1646
|
+
};
|
|
1647
|
+
}
|
|
1648
|
+
};
|
|
1649
|
+
function extractMediaDescriptor(message) {
|
|
1650
|
+
const payload = message.payload ?? {};
|
|
1651
|
+
for (const key of [
|
|
1652
|
+
"image",
|
|
1653
|
+
"audio",
|
|
1654
|
+
"video",
|
|
1655
|
+
"document",
|
|
1656
|
+
"sticker"
|
|
1657
|
+
]) {
|
|
1658
|
+
const media = payload[key];
|
|
1659
|
+
if (media?.id) {
|
|
1660
|
+
return {
|
|
1661
|
+
sourceMediaId: media.id,
|
|
1662
|
+
mimeType: media.mime_type ?? "application/octet-stream",
|
|
1663
|
+
filename: media.filename
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
return void 0;
|
|
1668
|
+
}
|
|
1669
|
+
__name(extractMediaDescriptor, "extractMediaDescriptor");
|
|
1670
|
+
|
|
1200
1671
|
// src/channel/ReceiveWebhook.use-case.ts
|
|
1201
1672
|
function toSessionContract(row) {
|
|
1202
1673
|
return {
|
|
@@ -1309,6 +1780,19 @@ var ReceiveWebhookUseCase = class {
|
|
|
1309
1780
|
startState: this.params.startState
|
|
1310
1781
|
});
|
|
1311
1782
|
if (!saved) return;
|
|
1783
|
+
const media = extractMediaDescriptor(saved);
|
|
1784
|
+
if (media) {
|
|
1785
|
+
await this.params.hooks?.onMediaReceived?.({
|
|
1786
|
+
companyId,
|
|
1787
|
+
messageId: saved.id,
|
|
1788
|
+
whatsappNumber: message.from,
|
|
1789
|
+
sourceMediaId: media.sourceMediaId,
|
|
1790
|
+
mimeType: media.mimeType,
|
|
1791
|
+
...media.filename ? {
|
|
1792
|
+
filename: media.filename
|
|
1793
|
+
} : {}
|
|
1794
|
+
});
|
|
1795
|
+
}
|
|
1312
1796
|
const sessionRow = await this.params.sessionRepository.getContext(companyId, message.from);
|
|
1313
1797
|
if (!sessionRow) return;
|
|
1314
1798
|
if (sessionRow.mode === "human") return;
|
|
@@ -1328,77 +1812,6 @@ var ReceiveWebhookUseCase = class {
|
|
|
1328
1812
|
}
|
|
1329
1813
|
};
|
|
1330
1814
|
|
|
1331
|
-
// src/channel/IngestInboundMedia.use-case.ts
|
|
1332
|
-
import { eq as eq5, and as and4 } from "drizzle-orm";
|
|
1333
|
-
var IngestInboundMediaUseCase = class {
|
|
1334
|
-
static {
|
|
1335
|
-
__name(this, "IngestInboundMediaUseCase");
|
|
1336
|
-
}
|
|
1337
|
-
db;
|
|
1338
|
-
channel;
|
|
1339
|
-
objectStorage;
|
|
1340
|
-
constructor(db, channel, objectStorage) {
|
|
1341
|
-
this.db = db;
|
|
1342
|
-
this.channel = channel;
|
|
1343
|
-
this.objectStorage = objectStorage;
|
|
1344
|
-
}
|
|
1345
|
-
async execute(params) {
|
|
1346
|
-
const [message] = await this.db.select().from(messages).where(and4(eq5(messages.companyId, params.companyId), eq5(messages.id, params.messageId))).limit(1);
|
|
1347
|
-
if (!message) throw new Error(`Mensagem ${params.messageId} n\xE3o encontrada para ingest\xE3o de m\xEDdia`);
|
|
1348
|
-
const payload = message.payload ?? {};
|
|
1349
|
-
if (payload["uploadId"] && payload["sourceMediaId"] === params.sourceMediaId) {
|
|
1350
|
-
return {
|
|
1351
|
-
uploadId: String(payload["uploadId"]),
|
|
1352
|
-
alreadyIngested: true
|
|
1353
|
-
};
|
|
1354
|
-
}
|
|
1355
|
-
const { data, mimeType } = await this.channel.fetchMediaAsBase64(params.sourceMediaId);
|
|
1356
|
-
const buffer = Buffer.from(data, "base64");
|
|
1357
|
-
const { uploadId } = await this.objectStorage.upload({
|
|
1358
|
-
buffer,
|
|
1359
|
-
mimeType: mimeType || params.mimeType,
|
|
1360
|
-
key: `meta-whatsapp/${params.companyId}/inbound/${params.sourceMediaId}`
|
|
1361
|
-
});
|
|
1362
|
-
const updatedPayload = {
|
|
1363
|
-
...payload,
|
|
1364
|
-
uploadId,
|
|
1365
|
-
sourceMediaId: params.sourceMediaId,
|
|
1366
|
-
mimeType: mimeType || params.mimeType,
|
|
1367
|
-
...params.filename ? {
|
|
1368
|
-
filename: params.filename
|
|
1369
|
-
} : {}
|
|
1370
|
-
};
|
|
1371
|
-
await this.db.update(messages).set({
|
|
1372
|
-
payload: updatedPayload
|
|
1373
|
-
}).where(and4(eq5(messages.companyId, params.companyId), eq5(messages.id, params.messageId)));
|
|
1374
|
-
return {
|
|
1375
|
-
uploadId,
|
|
1376
|
-
alreadyIngested: false
|
|
1377
|
-
};
|
|
1378
|
-
}
|
|
1379
|
-
};
|
|
1380
|
-
function extractMediaDescriptor(message) {
|
|
1381
|
-
const payload = message.payload ?? {};
|
|
1382
|
-
for (const key of [
|
|
1383
|
-
"image",
|
|
1384
|
-
"audio",
|
|
1385
|
-
"video",
|
|
1386
|
-
"document",
|
|
1387
|
-
"sticker"
|
|
1388
|
-
]) {
|
|
1389
|
-
const media = payload[key];
|
|
1390
|
-
if (media?.id) {
|
|
1391
|
-
return {
|
|
1392
|
-
sourceMediaId: media.id,
|
|
1393
|
-
mimeType: media.mime_type ?? "application/octet-stream",
|
|
1394
|
-
filename: media.filename
|
|
1395
|
-
};
|
|
1396
|
-
}
|
|
1397
|
-
}
|
|
1398
|
-
return void 0;
|
|
1399
|
-
}
|
|
1400
|
-
__name(extractMediaDescriptor, "extractMediaDescriptor");
|
|
1401
|
-
|
|
1402
1815
|
// src/createMetaWhatsAppModule.ts
|
|
1403
1816
|
function createMetaWhatsAppModule(params) {
|
|
1404
1817
|
const { db, config, nonceStore, providers = {}, hooks } = params;
|
|
@@ -1416,8 +1829,9 @@ function createMetaWhatsAppModule(params) {
|
|
|
1416
1829
|
const messageRepository = new MessageRepository(db);
|
|
1417
1830
|
const settingsRepository = new SettingsRepository(db);
|
|
1418
1831
|
const flowGraphRepository = new FlowGraphRepository(db);
|
|
1832
|
+
const documentRepository = new DocumentRepository(db);
|
|
1419
1833
|
const logMessage = new LogMessageUseCase(sessionRepository, messageRepository, providers.realtime, providers.moderator);
|
|
1420
|
-
const sendMessage = new SendMessageUseCase(channel, sessionRepository, logMessage, providers.objectStorage);
|
|
1834
|
+
const sendMessage = new SendMessageUseCase(channel, sessionRepository, logMessage, providers.objectStorage, documentRepository);
|
|
1421
1835
|
const receiveWebhook = new ReceiveWebhookUseCase({
|
|
1422
1836
|
appSecret: config.appSecret,
|
|
1423
1837
|
nonceStore,
|
|
@@ -1429,7 +1843,8 @@ function createMetaWhatsAppModule(params) {
|
|
|
1429
1843
|
realtime: providers.realtime
|
|
1430
1844
|
});
|
|
1431
1845
|
const flowInterpreter = flowEngineEnabled ? new FlowInterpreter() : void 0;
|
|
1432
|
-
const ingestInboundMedia = providers.objectStorage ? new IngestInboundMediaUseCase(db, channel, providers.objectStorage) : void 0;
|
|
1846
|
+
const ingestInboundMedia = providers.objectStorage ? new IngestInboundMediaUseCase(db, channel, providers.objectStorage, documentRepository) : void 0;
|
|
1847
|
+
const listDocuments = new ListConversationDocumentsUseCase(sessionRepository, documentRepository);
|
|
1433
1848
|
return {
|
|
1434
1849
|
channel,
|
|
1435
1850
|
// undefined quando providers.objectStorage não foi injetado.
|
|
@@ -1441,8 +1856,16 @@ function createMetaWhatsAppModule(params) {
|
|
|
1441
1856
|
release: new ReleaseConversationUseCase(sessionRepository, providers.realtime),
|
|
1442
1857
|
list: new ListConversationsUseCase(sessionRepository),
|
|
1443
1858
|
listMessages: new ListMessagesUseCase(sessionRepository, messageRepository),
|
|
1859
|
+
listDocuments,
|
|
1860
|
+
// Biblioteca da empresa inteira, para uma tela de Documentos fora da conversa.
|
|
1861
|
+
listCompanyDocuments: new ListCompanyDocumentsUseCase(documentRepository),
|
|
1862
|
+
// Apaga a mídia no storage antes das linhas — a cascata da FK sozinha deixaria os binários
|
|
1863
|
+
// órfãos, já que a lista de uploadId vive justamente nas linhas que ela derruba.
|
|
1864
|
+
delete: new DeleteConversationUseCase(sessionRepository, documentRepository, providers.objectStorage),
|
|
1865
|
+
purgeExpiredDocuments: new PurgeExpiredDocumentsUseCase(documentRepository, providers.objectStorage),
|
|
1444
1866
|
export: new ExportConversationUseCase(sessionRepository),
|
|
1445
|
-
repository: sessionRepository
|
|
1867
|
+
repository: sessionRepository,
|
|
1868
|
+
documentRepository
|
|
1446
1869
|
},
|
|
1447
1870
|
settings: settingsRepository,
|
|
1448
1871
|
webhook: {
|
|
@@ -1562,7 +1985,9 @@ async function redeemSseTicket(store, ticket) {
|
|
|
1562
1985
|
__name(redeemSseTicket, "redeemSseTicket");
|
|
1563
1986
|
export {
|
|
1564
1987
|
CreateFlowGraphUseCase,
|
|
1988
|
+
DeleteConversationUseCase,
|
|
1565
1989
|
DeleteFlowGraphUseCase,
|
|
1990
|
+
DocumentRepository,
|
|
1566
1991
|
ExportConversationUseCase,
|
|
1567
1992
|
FlowGraphRepository,
|
|
1568
1993
|
FlowInterpreter,
|
|
@@ -1570,6 +1995,8 @@ export {
|
|
|
1570
1995
|
GetLiveFlowPositionsUseCase,
|
|
1571
1996
|
IngestInboundMediaUseCase,
|
|
1572
1997
|
InvalidFlowGraphError,
|
|
1998
|
+
ListCompanyDocumentsUseCase,
|
|
1999
|
+
ListConversationDocumentsUseCase,
|
|
1573
2000
|
ListConversationsUseCase,
|
|
1574
2001
|
ListFlowGraphsUseCase,
|
|
1575
2002
|
ListMessagesUseCase,
|
|
@@ -1577,6 +2004,7 @@ export {
|
|
|
1577
2004
|
META_WHATSAPP_MIGRATIONS_TABLE,
|
|
1578
2005
|
MessageRepository,
|
|
1579
2006
|
OptimisticLockError,
|
|
2007
|
+
PurgeExpiredDocumentsUseCase,
|
|
1580
2008
|
ReceiveWebhookUseCase,
|
|
1581
2009
|
ReleaseConversationUseCase,
|
|
1582
2010
|
SaveFlowGraphUseCase,
|
|
@@ -1589,6 +2017,7 @@ export {
|
|
|
1589
2017
|
WhatsAppChannelAdapter,
|
|
1590
2018
|
claimWebhookDelivery,
|
|
1591
2019
|
createMetaWhatsAppModule,
|
|
2020
|
+
documents,
|
|
1592
2021
|
extractMediaDescriptor,
|
|
1593
2022
|
flowGraphs,
|
|
1594
2023
|
issueSseTicket,
|