UNPKG

whatsapp-crm-common

Version:

Componentes compartidos para servicios de WhatsApp CRM - Common utilities and types for WhatsApp CRM system

732 lines 30.3 kB
"use strict"; 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