whatsapp-crm-common
Version:
Componentes compartidos para servicios de WhatsApp CRM - Common utilities and types for WhatsApp CRM system
732 lines • 30.3 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.MessageRepository = void 0;
const baileys_1 = require("@whiskeysockets/baileys");
const extract_message_content_1 = __importDefault(require("../utils/extract-message-content"));
const repository_errors_1 = require("../errors/repository-errors");
const logger_1 = __importDefault(require("../utils/logger"));
const base_repository_1 = __importDefault(require("./base-repository"));
// filepath: src/repository/MessageRepository.ts
class MessageRepository extends base_repository_1.default {
/**
* Guarda múltiples mensajes en la base de datos en una sola operación
* @param messages Lista de mensajes WAMessage a guardar
* @param tenantId ID del tenant
* @param agentId ID del agente
* @returns Los mensajes procesados e insertados
*/
async bulkInsertMessages(messages, tenantId, agentId) {
if (!messages || messages.length === 0)
return [];
const client = await this.pool.connect();
const insertedMessages = [];
try {
await client.query("BEGIN");
// Primero asegurar que los chats existan
const chatIds = [
...new Set(messages.map((msg) => msg.key.remoteJid).filter(Boolean)),
];
for (const chatId of chatIds) {
await client.query(`
INSERT INTO chats (id, tenant_id, agent_id, is_group, created_at, updated_at)
VALUES ($1, $2, $3, $4, NOW(), NOW())
ON CONFLICT (id, tenant_id, agent_id) DO NOTHING
`, [
chatId,
tenantId,
agentId,
chatId?.endsWith("@g.us") || false,
]);
}
// Extraer todos los sender_ids únicos (excluyendo null)
const senderIds = [
...new Set(messages
.map((msg) => msg.key.participant || msg.key.remoteJid)
.filter(Boolean)),
];
// Verificar qué contactos ya existen
const existingContactsQuery = `
SELECT id FROM contacts
WHERE id = ANY($1::text[]) AND tenant_id = $2 AND agent_id = $3
`;
const existingContactsResult = await client.query(existingContactsQuery, [senderIds, tenantId, agentId]);
const existingContactIds = new Set(existingContactsResult.rows.map((row) => row.id));
// Crear contactos placeholder para los que no existen
const missingContactIds = senderIds.filter((id) => !existingContactIds.has(id));
if (missingContactIds.length > 0) {
const insertContactsQuery = `
INSERT INTO contacts (id, tenant_id, agent_id, name, phone_number)
VALUES ${missingContactIds
.map((_, i) => `($${i * 5 + 1}, $${i * 5 + 2}, $${i * 5 + 3}, $${i * 5 + 4}, $${i * 5 + 5})`)
.join(", ")}
ON CONFLICT (id, tenant_id, agent_id) DO NOTHING
`;
const contactParams = missingContactIds.flatMap((id) => [
id,
tenantId,
agentId,
id.split("@")[0] || "Unknown",
id.split("@")[0] || "Unknown",
]);
await client.query(insertContactsQuery, contactParams);
}
// Preparar los valores para la inserción en lote
const values = messages.map((msg) => {
const messageContent = (0, extract_message_content_1.default)(msg);
// Convert messageTimestamp to number, handling various formats
let timestamp;
if (typeof msg.messageTimestamp === "number") {
timestamp = msg.messageTimestamp;
}
else if (msg.messageTimestamp &&
typeof msg.messageTimestamp.toNumber === "function") {
// Handle Long object from Protocol Buffers
timestamp = msg.messageTimestamp.toNumber();
}
else {
timestamp = Math.floor(Date.now() / 1000);
}
return {
id: msg.key.id || "",
chatId: msg.key.remoteJid || "",
tenantId,
agentId,
senderId: msg.key.participant || msg.key.remoteJid || null,
fromMe: msg.key.fromMe || false,
messageTimestamp: timestamp,
messageType: messageContent.type,
textContent: messageContent.content,
mediaUrl: messageContent.mediaUrl,
quotedMessageId: msg.message?.extendedTextMessage?.contextInfo
?.quotedMessage || null,
status: msg.status || "received",
rawData: JSON.stringify(msg),
};
});
// Agregar los valores procesados al array de mensajes insertados
insertedMessages.push(...values);
// Crear consulta con parámetros para evitar SQL injection
const queryText = `
INSERT INTO messages (
id, chat_id, tenant_id, agent_id, sender_id, from_me,
message_timestamp, message_type, text_content, media_url,
quoted_message_id, status, raw_data
)
VALUES
${values
.map((_, i) => `($${i * 13 + 1}, $${i * 13 + 2}, $${i * 13 + 3}, $${i * 13 + 4}, $${i * 13 + 5}, $${i * 13 + 6},
$${i * 13 + 7}, $${i * 13 + 8}, $${i * 13 + 9}, $${i * 13 + 10}, $${i * 13 + 11}, $${i * 13 + 12}, $${i * 13 + 13}::jsonb)`)
.join(",")}
ON CONFLICT (id, chat_id, tenant_id, agent_id)
DO UPDATE SET
message_timestamp = EXCLUDED.message_timestamp,
message_type = EXCLUDED.message_type,
text_content = EXCLUDED.text_content,
media_url = EXCLUDED.media_url,
status = EXCLUDED.status,
raw_data = EXCLUDED.raw_data
RETURNING *
`;
// Aplanar los valores para pasarlos a la consulta
const flatParams = values.flatMap((v) => [
v.id,
v.chatId,
v.tenantId,
v.agentId,
v.senderId,
v.fromMe,
v.messageTimestamp,
v.messageType,
v.textContent,
v.mediaUrl,
v.quotedMessageId,
v.status,
v.rawData,
]);
// Ejecutar la consulta y obtener los resultados
const result = await client.query(queryText, flatParams);
// Reemplazar los mensajes procesados con los datos reales de la BD
if (result.rows.length > 0) {
// Clear the array and replace with actual database records
insertedMessages.length = 0;
insertedMessages.push(...result.rows);
}
await client.query("COMMIT");
return insertedMessages;
}
catch (error) {
await client.query("ROLLBACK");
const repositoryError = new repository_errors_1.MessageRepositoryError({
tenantId,
agentId,
operation: 'bulkInsertMessages',
details: {
messageCount: messages.length,
errorMessage: error instanceof Error ? error.message : String(error)
}
}, error instanceof Error ? error : new Error(String(error)));
logger_1.default.error('Failed to bulk insert messages', repositoryError.getLogContext());
throw repositoryError;
}
finally {
client.release();
}
// Ensure a return value on all code paths
return insertedMessages;
}
async handleMessageUpdate(tenantId, agentId, messageUpdate) {
const { key, update } = messageUpdate;
const messageId = key.id;
const chatId = key.remoteJid;
// Construir el objeto de actualización SQL
const updateFields = {};
const updateData = {
update_type: "",
update_data: update,
update_timestamp: Date.now(),
message_id: messageId,
chat_id: chatId,
tenant_id: tenantId,
agent_id: agentId,
};
// 1. Actualización de Estado
if (update.status) {
updateFields.status = update.status;
updateData.update_type = "status";
}
// 2. Mensaje Editado
if (update.message?.editedMessage) {
updateFields.edited_message = JSON.stringify(update.message.editedMessage);
updateFields.is_edited = true;
updateFields.edit_timestamp = update.messageTimestamp || Date.now();
updateData.update_type = "edit";
}
// 3. Mensaje Revocado
if (update.messageStubType === baileys_1.WAMessageStubType.REVOKE ||
update.message === null) {
updateFields.is_revoked = true;
updateFields.revoke_timestamp = Date.now();
updateFields.message_stub_type = update.messageStubType;
updateData.update_type = "revoke";
}
// 4. Actualizaciones de Encuestas
if (update.pollUpdates) {
updateFields.poll_updates = JSON.stringify(update.pollUpdates);
updateData.update_type = "poll";
}
// 5. Reacciones
if (update.reactions) {
updateFields.reaction_updates = JSON.stringify(update.reactions);
updateData.update_type = "reaction";
}
// 6. Timestamp actualizado
if (update.messageTimestamp) {
updateFields.message_timestamp = update.messageTimestamp;
}
// 7. Participante (para grupos)
if (key.participant) {
updateFields.participant = key.participant;
}
// // 8. Información de reenvío
// if (update.forwardingScore) {
// updateFields.forwarding_score = update.forwardingScore;
// updateFields.is_forwarded = true;
// }
// 9. Actualización completa del mensaje
if (update.message && !update.message.editedMessage) {
updateFields.raw_data = JSON.stringify(update.message);
updateData.update_type = "content";
}
// Ejecutar la actualización en la base de datos
await this.updateMessageInDatabase(messageId || "", chatId || "", updateFields, updateData);
}
async updateMessageInDatabase(messageId, chatId, updateFields, updateData) {
const client = await this.pool.connect();
try {
await client.query("BEGIN");
// 1. Actualizar la tabla messages
if (Object.keys(updateFields).length > 0) {
const setClause = Object.keys(updateFields)
.map((key, index) => `${key} = $${index + 4}`)
.join(", ");
const values = [
messageId,
chatId,
...Object.values(updateFields),
];
const updateQuery = `
UPDATE public.messages
SET ${setClause}
WHERE id = $1 AND chat_id = $2 AND tenant_id = $3 AND agent_id = $4
`;
await client.query(updateQuery, values);
}
await client.query("COMMIT");
}
catch (error) {
await client.query("ROLLBACK");
const repositoryError = new repository_errors_1.MessageRepositoryError({
tenantId: 'unknown', // This method signature doesn't have tenantId/agentId
agentId: 0,
operation: 'updateMessageInDatabase',
messageId,
chatId,
details: {
updateFields: Object.keys(updateFields),
errorMessage: error instanceof Error ? error.message : String(error)
}
}, error instanceof Error ? error : new Error(String(error)));
logger_1.default.error('Failed to update message in database', repositoryError.getLogContext());
throw repositoryError;
}
finally {
client.release();
}
}
/**
* Procesa actualizaciones de mensajes en lote
*/
async handleBulkMessageUpdates(tenantId, agentId, messageUpdates) {
if (messageUpdates.length === 0)
return;
const client = await this.pool.connect();
try {
await client.query("BEGIN");
// Preparar datos para bulk update
const messageUpdateData = [];
for (const { key, update } of messageUpdates) {
const messageId = key.id;
const chatId = key.remoteJid;
const updateFields = this.buildUpdateFields(update, key);
if (Object.keys(updateFields).length > 0) {
messageUpdateData.push({
id: messageId,
chat_id: chatId,
tenant_id: tenantId,
agent_id: agentId,
...updateFields,
});
}
}
// Bulk update usando UNNEST
if (messageUpdateData.length > 0) {
await this.performBulkMessageUpdate(client, messageUpdateData);
}
await client.query("COMMIT");
console.log(`Actualizados ${messageUpdates.length} mensajes en lote`);
}
catch (error) {
await client.query("ROLLBACK");
const repositoryError = new repository_errors_1.MessageRepositoryError({
tenantId,
agentId,
operation: 'handleBulkMessageUpdates',
details: {
updateCount: messageUpdates.length,
errorMessage: error instanceof Error ? error.message : String(error)
}
}, error instanceof Error ? error : new Error(String(error)));
logger_1.default.error('Failed to handle bulk message updates', repositoryError.getLogContext());
throw repositoryError;
}
finally {
client.release();
}
}
/**
* Ejecuta una actualización masiva de mensajes
*/
async performBulkMessageUpdate(client, updateData) {
const fields = Object.keys(updateData[0]).filter((k) => !["id", "chat_id", "tenant_id", "agent_id"].includes(k));
// Define which fields are jsonb type
const jsonbFields = [
"edited_message",
"raw_data",
"poll_updates",
"reaction_updates",
];
const values = updateData
.map((row) => `('${row.id}', '${row.chat_id}', '${row.tenant_id}', ${row.agent_id}, ${fields
.map((field) => {
const value = row[field];
if (value === null || value === undefined)
return "NULL";
if (typeof value === "boolean")
return value;
if (typeof value === "number")
return value;
// Handle jsonb fields with proper casting
if (jsonbFields.includes(field)) {
return typeof value === "object"
? `'${JSON.stringify(value)}'::jsonb`
: `'${value}'::jsonb`;
}
if (typeof value === "object")
return `'${JSON.stringify(value)}'`;
return `'${value}'`;
})
.join(", ")})`)
.join(", ");
const setClause = fields
.map((field) => `${field} = data.${field}`)
.join(", ");
const query = `
UPDATE public.messages
SET ${setClause}
FROM (VALUES ${values}) AS data(id, chat_id, tenant_id, agent_id, ${fields.join(", ")})
WHERE messages.id = data.id
AND messages.chat_id = data.chat_id
AND messages.tenant_id = data.tenant_id
AND messages.agent_id = data.agent_id
`;
await client.query(query);
}
/**
* Construye los campos de actualización para un mensaje
*/
buildUpdateFields(update, key) {
const updateFields = {};
// 1. Actualización de Estado
if (update.status) {
updateFields.status = update.status;
}
// 2. Mensaje Editado
if (update.message?.editedMessage) {
updateFields.edited_message = JSON.stringify(update.message.editedMessage);
updateFields.is_edited = true;
updateFields.edit_timestamp = update.messageTimestamp || Date.now();
}
// 3. Mensaje Revocado
if (update.messageStubType === baileys_1.WAMessageStubType.REVOKE ||
update.message === null) {
updateFields.is_revoked = true;
updateFields.revoke_timestamp = Date.now();
updateFields.message_stub_type = update.messageStubType;
}
// 4. Actualizaciones de Encuestas
if (update.pollUpdates) {
updateFields.poll_updates = JSON.stringify(update.pollUpdates);
}
// 5. Reacciones
if (update.reactions) {
updateFields.reaction_updates = JSON.stringify(update.reactions);
}
// 6. Timestamp actualizado
if (update.messageTimestamp) {
updateFields.message_timestamp = update.messageTimestamp;
}
// 7. Participante (para grupos)
if (key.participant) {
updateFields.participant = key.participant;
}
// 9. Actualización completa del mensaje
if (update.message && !update.message.editedMessage) {
updateFields.raw_data = JSON.stringify(update.message);
}
return updateFields;
}
/**
* Determina el tipo de actualización de un mensaje
*/
determineUpdateType(update) {
if (update.status)
return "status";
if (update.message?.editedMessage)
return "edit";
if (update.messageStubType === baileys_1.WAMessageStubType.REVOKE ||
update.message === null)
return "revoke";
if (update.pollUpdates)
return "poll";
if (update.reactions)
return "reaction";
if (update.message)
return "content";
return "unknown";
}
/**
* Maneja la eliminación de mensajes
*/
async handleMessageDelete(tenantId, agentId, deleteEvent) {
const client = await this.pool.connect();
try {
await client.query("BEGIN");
if ("keys" in deleteEvent) {
// Eliminar mensajes específicos
await this.handleSpecificMessageDeletes(tenantId, agentId, client, deleteEvent.keys);
}
else if (deleteEvent.all) {
// Eliminar todos los mensajes de un chat
await this.handleChatMessageDeletes(tenantId, agentId, client, deleteEvent.jid);
}
await client.query("COMMIT");
}
catch (error) {
await client.query("ROLLBACK");
const repositoryError = new repository_errors_1.MessageRepositoryError({
tenantId,
agentId,
operation: 'handleMessageDelete',
details: {
deleteType: 'keys' in deleteEvent ? 'specific' : 'all',
errorMessage: error instanceof Error ? error.message : String(error)
}
}, error instanceof Error ? error : new Error(String(error)));
logger_1.default.error('Failed to handle message delete', repositoryError.getLogContext());
throw repositoryError;
}
finally {
client.release();
}
}
/**
* Maneja eliminaciones específicas de mensajes
*/
async handleSpecificMessageDeletes(tenantId, agentId, client, keys) {
if (keys.length === 0)
return;
// Preparar datos para bulk update
const deleteData = keys.map((key) => ({
id: key.id,
chat_id: key.remoteJid,
from_me: key.fromMe || false,
participant: key.participant || null,
}));
// Bulk update para marcar como eliminados
const values = deleteData
.map((item, index) => {
const baseIndex = index * 4;
return `($${baseIndex + 1}, $${baseIndex + 2}, $${baseIndex + 3}, $${baseIndex + 4})`;
})
.join(", ");
const flatValues = deleteData.flatMap((item) => [
item.id,
item.chat_id,
item.from_me,
item.participant,
]);
const query = `
UPDATE public.messages
SET
is_deleted = TRUE,
deleted_timestamp = $${flatValues.length + 1},
delete_type = 'user'
FROM (VALUES ${values}) AS delete_keys(id, chat_id, from_me, participant)
WHERE messages.id = delete_keys.id
AND messages.chat_id = delete_keys.chat_id
AND messages.from_me = delete_keys.from_me
AND (
delete_keys.participant IS NULL
OR messages.participant = delete_keys.participant
)
AND messages.tenant_id = $${flatValues.length + 2}
AND messages.agent_id = $${flatValues.length + 3}
`;
await client.query(query, [
...flatValues,
Date.now(), // deleted_timestamp
tenantId, // tenant_id
agentId, // agent_id
]);
// Insertar en historial de eliminaciones
await this.insertDeleteHistory(client, deleteData, "specific", tenantId, agentId);
}
/**
* Maneja eliminaciones de todos los mensajes de un chat
*/
async handleChatMessageDeletes(tenantId, agentId, client, jid) {
const query = `
UPDATE public.messages
SET
is_deleted = TRUE,
deleted_timestamp = $1,
delete_type = 'chat_clear'
WHERE chat_id = $2
AND tenant_id = $3
AND agent_id = $4
AND is_deleted = FALSE
`;
await client.query(query, [Date.now(), jid, tenantId, agentId]);
// Insertar en historial
await this.insertDeleteHistory(client, [{ chat_id: jid }], "chat_clear", tenantId, agentId);
}
/**
* Inserta historial de eliminaciones
*/
async insertDeleteHistory(client, deleteData, deleteType, tenantId, agentId) {
const historyValues = deleteData
.map((item) => `('${item.id || "ALL"}', '${item.chat_id}', '${tenantId}', ${agentId}, '${deleteType}', '${JSON.stringify(item)}', ${Date.now()})`)
.join(", ");
const historyQuery = `
INSERT INTO public.message_updates
(message_id, chat_id, tenant_id, agent_id, update_type, update_data, update_timestamp)
VALUES ${historyValues}
`;
await client.query(historyQuery);
}
/**
* Maneja reacciones a mensajes
*/
async handleMessageReactions(tenantId, agentId, reactions) {
if (reactions.length === 0)
return;
const client = await this.pool.connect();
try {
await client.query("BEGIN");
for (const { key, reaction } of reactions) {
await this.handleSingleReaction(tenantId, agentId, client, key, reaction);
}
await client.query("COMMIT");
console.log(`Procesadas ${reactions.length} reacciones`);
}
catch (error) {
await client.query("ROLLBACK");
const repositoryError = new repository_errors_1.MessageRepositoryError({
tenantId,
agentId,
operation: 'handleReactions',
details: {
reactionCount: reactions.length,
errorMessage: error instanceof Error ? error.message : String(error)
}
}, error instanceof Error ? error : new Error(String(error)));
logger_1.default.error('Failed to handle reactions', repositoryError.getLogContext());
throw repositoryError;
}
finally {
client.release();
}
}
/**
* Maneja una reacción individual a un mensaje
*/
async handleSingleReaction(tenantId, agentId, client, key, reaction) {
const messageId = key.id;
const chatId = key.remoteJid;
const reactorJid = (0, baileys_1.getKeyAuthor)(reaction.key);
// Obtener las reacciones actuales del mensaje
const getCurrentReactionsQuery = `
SELECT reactions
FROM public.messages
WHERE id = $1 AND chat_id = $2
AND tenant_id = $3 AND agent_id = $4
`;
const result = await client.query(getCurrentReactionsQuery, [
messageId,
chatId,
tenantId,
agentId,
]);
if (result.rows.length === 0)
return;
let currentReactions = result.rows[0].reactions || [];
// Filtrar reacciones existentes del mismo usuario
currentReactions = currentReactions.filter((r) => r.reactor_jid !== reactorJid);
// Si hay texto en la reacción, agregarla; si no, es una eliminación
if (reaction.text) {
currentReactions.push({
reactor_jid: reactorJid,
reaction_text: reaction.text,
reaction_timestamp: reaction.senderTimestampMs || Date.now(),
});
}
// Actualizar el mensaje con las nuevas reacciones
const updateQuery = `
UPDATE public.messages
SET reactions = $1
WHERE id = $2 AND chat_id = $3
AND tenant_id = $4 AND agent_id = $5
`;
await client.query(updateQuery, [
JSON.stringify(currentReactions),
messageId,
chatId,
tenantId,
agentId,
]);
}
/**
* Maneja actualizaciones de recibo de mensajes
*/
async handleMessageReceiptUpdates(updates, tenantId, agentId) {
if (updates.length === 0)
return;
const client = await this.pool.connect();
try {
await client.query("BEGIN");
for (const { key, receipt } of updates) {
await this.handleSingleReceipt(tenantId, agentId, client, key, receipt);
}
await client.query("COMMIT");
console.log(`Procesados ${updates.length} recibos`);
}
catch (error) {
await client.query("ROLLBACK");
const repositoryError = new repository_errors_1.MessageRepositoryError({
tenantId,
agentId,
operation: 'handleMessageReceiptUpdates',
details: {
receiptCount: updates.length,
errorMessage: error instanceof Error ? error.message : String(error)
}
}, error instanceof Error ? error : new Error(String(error)));
logger_1.default.error('Failed to handle message receipt updates', repositoryError.getLogContext());
throw repositoryError;
}
finally {
client.release();
}
}
/**
* Maneja un recibo individual de mensaje
*/
async handleSingleReceipt(tenantId, agentId, client, key, receipt) {
const messageId = key.id;
const chatId = key.remoteJid;
const userJid = receipt.userJid;
// Obtener los recibos actuales del mensaje
const getCurrentReceiptsQuery = `
SELECT user_receipts
FROM public.messages
WHERE id = $1 AND chat_id = $2
AND tenant_id = $3 AND agent_id = $4
`;
const result = await client.query(getCurrentReceiptsQuery, [
messageId,
chatId,
tenantId,
agentId,
]);
if (result.rows.length === 0)
return;
let currentReceipts = result.rows[0].user_receipts || [];
// Filtrar recibos existentes del mismo usuario
currentReceipts = currentReceipts.filter((r) => r.user_jid !== userJid);
// Agregar el nuevo recibo
currentReceipts.push({
user_jid: userJid,
receipt_timestamp: receipt.receiptTimestamp,
read_timestamp: receipt.readTimestamp,
});
// Actualizar el mensaje con los nuevos recibos
const updateQuery = `
UPDATE public.messages
SET user_receipts = $1
WHERE id = $2 AND chat_id = $3
AND tenant_id = $4 AND agent_id = $5
`;
await client.query(updateQuery, [
JSON.stringify(currentReceipts),
messageId,
chatId,
tenantId,
agentId,
]);
}
}
exports.MessageRepository = MessageRepository;
//# sourceMappingURL=message-repository.js.map