import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { Client, LocalAuth, MessageMedia } from 'whatsapp-web.js'; import { WhatsappGateway } from './whatsapp.gateway'; import { AttendanceAssignmentService } from '../attendance/attendance-assignment.service'; import { ContactsService } from '../contacts/contacts.service'; import { WhatsappTemplateService } from './whatsapp-template.service'; import * as fs from 'fs'; import * as path from 'path'; @Injectable() export class WhatsappService implements OnModuleInit { private client: Client; private readonly logger = new Logger(WhatsappService.name); private status: 'DISCONNECTED' | 'AWAITING_QR' | 'CONNECTED' = 'DISCONNECTED'; private currentQr: string | null = null; private readonly processedIncomingMessages = new Set(); private readonly incomingRoutingQueues = new Map>(); private readonly chatMessagesCache = new Map(); constructor( private readonly gateway: WhatsappGateway, private readonly assignmentService: AttendanceAssignmentService, private readonly contactsService: ContactsService, private readonly whatsappTemplateService: WhatsappTemplateService, ) {} async onModuleInit() { this.logger.log('Inicializando WhatsApp Client...'); this.client = new Client({ authStrategy: new LocalAuth({ dataPath: './whatsapp-session' }), puppeteer: { headless: true, executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-accelerated-2d-canvas', '--no-first-run', '--disable-gpu'] }, webVersionCache: { type: 'none' } }); this.client.on('qr', (qr) => { this.logger.log('QR Code recebido. Envie para o frontend.'); this.status = 'AWAITING_QR'; this.currentQr = qr; this.gateway.emitQrCode(qr); this.gateway.emitStatus(this.status); }); this.client.on('ready', () => { this.logger.log('WhatsApp Web Conectado!'); this.status = 'CONNECTED'; this.currentQr = null; this.gateway.emitStatus(this.status); }); this.client.on('authenticated', () => { this.logger.log('WhatsApp Autenticado'); }); this.client.on('auth_failure', (msg) => { this.logger.error('Falha na Autenticação do WhatsApp', msg); this.status = 'DISCONNECTED'; this.gateway.emitStatus(this.status); }); this.client.on('disconnected', (reason) => { this.logger.warn('WhatsApp Desconectado', reason); this.status = 'DISCONNECTED'; this.gateway.emitStatus(this.status); }); // Mudar para message_create captura tanto mensagens recebidas quanto enviadas no WhatsApp this.client.on('message_create', async (msg) => { if (msg.from === 'status@broadcast') return; const remoteJid = msg.id.remote || (msg.fromMe ? msg.to : msg.from); const messageId = msg.id._serialized; const messageBody = (msg.body || '').trim(); this.logger.log(`Mensagem registrada (fromMe: ${msg.fromMe}) remote: ${remoteJid} - ${messageBody}`); let mediaData: any = null; if (msg.hasMedia) { try { this.logger.log(`Baixando mídia em tempo real de ${msg.id._serialized}...`); const media = await msg.downloadMedia(); if (media) { mediaData = { mimetype: media.mimetype, data: media.data, filename: media.filename || 'arquivo' }; } } catch (err) { this.logger.error(`Erro ao baixar mídia em tempo real de ${msg.id._serialized}:`, err); } } // Transmite a mensagem em tempo real para o frontend this.gateway.emitNewMessage({ from: remoteJid, body: msg.body, timestamp: msg.timestamp, isGroupMsg: remoteJid.endsWith('@g.us'), id: messageId, fromMe: msg.fromMe, notifyName: msg['_data']?.notifyName || '', hasMedia: msg.hasMedia, media: mediaData }); // Salva ou atualiza a conversa na persistência híbrida await this.addOrUpdatePersistentChat(remoteJid, { name: msg['_data']?.notifyName || remoteJid.split('@')[0], preview: msg.hasMedia ? `[Mídia: ${mediaData?.filename || 'Arquivo'}]` : (msg.body || '[Mídia]'), timestamp: msg.timestamp, unreadCount: msg.fromMe ? 0 : 1, lastMessageFromMe: msg.fromMe }); if (!msg.fromMe) { if (messageBody || msg.hasMedia) { await this.assignmentService.markCustomerReplied(remoteJid); } if (!messageBody) { this.logger.log(`Triagem ignorada para ${remoteJid}: mensagem sem texto.`); return; } if (this.processedIncomingMessages.has(messageId)) { return; } this.processedIncomingMessages.add(messageId); if (this.processedIncomingMessages.size > 1000) { const [oldest] = this.processedIncomingMessages; this.processedIncomingMessages.delete(oldest); } await this.enqueueIncomingRoute(remoteJid, msg, messageBody, messageId); } }); this.client.initialize(); } private async enqueueIncomingRoute(remoteJid: string, msg: any, messageBody: string, messageId: string) { const previousRoute = this.incomingRoutingQueues.get(remoteJid) || Promise.resolve(); let nextRoute: Promise; nextRoute = previousRoute .catch(() => undefined) .then(async () => { try { const routeResult = await this.assignmentService.routeIncomingMessage( remoteJid, messageBody, messageId, await this.getBotMessageVariables(remoteJid, msg), ); if (routeResult.shouldSendBotMessage && routeResult.botMessage) { this.logger.log(`Agente Virtual Sothis roteou ${remoteJid} para area ${routeResult.assignment?.area_nome || routeResult.assignment?.area_id}`); await msg.reply(routeResult.botMessage); } } catch (err) { this.logger.error(`Erro ao rotear conversa inicial de ${remoteJid}:`, err); } }) .finally(() => { if (this.incomingRoutingQueues.get(remoteJid) === nextRoute) { this.incomingRoutingQueues.delete(remoteJid); } }); this.incomingRoutingQueues.set(remoteJid, nextRoute); await nextRoute; } private getPersistFilePath() { return path.join(process.cwd(), 'whatsapp-chats-persist.json'); } private async loadPersistentChats(): Promise { try { const filepath = this.getPersistFilePath(); if (!fs.existsSync(filepath)) { return {}; } const data = fs.readFileSync(filepath, 'utf-8'); return JSON.parse(data); } catch (err) { this.logger.error('Erro ao ler chats persistentes:', err); return {}; } } private async savePersistentChats(chats: any): Promise { try { const filepath = this.getPersistFilePath(); fs.writeFileSync(filepath, JSON.stringify(chats, null, 2), 'utf-8'); } catch (err) { this.logger.error('Erro ao salvar chats persistentes:', err); } } private async addOrUpdatePersistentChat(chatId: string, data: { name?: string, preview?: string, timestamp?: number, unreadCount?: number, lastMessageFromMe?: boolean }) { if (chatId === 'status@broadcast' || chatId.endsWith('@g.us')) return; const chats = await this.loadPersistentChats(); const existing = chats[chatId] || {}; const isNumber = (val: string) => /^\d+$/.test(val); let finalName = existing.name || data.name || chatId.split('@')[0]; if (data.name && !isNumber(data.name)) { finalName = data.name; } if (isNumber(finalName) && this.status === 'CONNECTED') { try { const contact = await this.client.getContactById(chatId); if (contact) { if (contact.name && !isNumber(contact.name)) { finalName = contact.name; } else if (contact.pushname && !isNumber(contact.pushname)) { finalName = contact.pushname; } } } catch (err) { // Ignorar erros na consulta do Puppeteer } } chats[chatId] = { id: { server: chatId.split('@')[1] || 'c.us', user: chatId.split('@')[0], _serialized: chatId }, name: finalName, isGroup: false, isReadOnly: false, unreadCount: data.unreadCount !== undefined ? data.unreadCount : (existing.unreadCount || 0), timestamp: data.timestamp || existing.timestamp || Math.floor(Date.now() / 1000), archived: false, pinned: false, isLocked: false, isMuted: false, preview: data.preview || existing.preview || '', lastMessageFromMe: data.lastMessageFromMe !== undefined ? data.lastMessageFromMe : Boolean(existing.lastMessageFromMe) }; await this.savePersistentChats(chats); } getStatus() { return this.status; } getCurrentQr() { return this.currentQr; } async getChats() { if (this.status !== 'CONNECTED') return []; let liveChats: any[] = []; try { liveChats = await this.retryWhatsappRead(() => this.client.getChats()); } catch (err) { if (this.isTransientWhatsappFrameError(err)) { this.logger.warn(`WhatsApp Web ainda estabilizando ao carregar chats: ${this.getErrorMessage(err)}`); } else { this.logger.error('Erro ao chamar client.getChats():', err); } } const persistentChatsObj = await this.loadPersistentChats(); const mergedChatsMap = new Map(); // Adiciona os chats persistentes locais primeiro Object.values(persistentChatsObj).forEach((c: any) => { mergedChatsMap.set(c.id._serialized, c); }); // Adiciona/Mescla com os chats em tempo real do Puppeteer liveChats.forEach((c: any) => { if (!c.isGroup && c.id.server === 'c.us') { const serializedId = c.id._serialized; const existingPersistent = persistentChatsObj[serializedId] || {}; const isNumber = (val: string) => /^\d+$/.test(val); let finalName = c.name || existingPersistent.name || c.id.user; if (isNumber(c.name) && existingPersistent.name && !isNumber(existingPersistent.name)) { finalName = existingPersistent.name; } mergedChatsMap.set(serializedId, { id: c.id, name: finalName, isGroup: c.isGroup, isReadOnly: c.isReadOnly || false, unreadCount: c.unreadCount !== undefined ? c.unreadCount : (existingPersistent.unreadCount || 0), timestamp: c.timestamp || existingPersistent.timestamp || Math.floor(Date.now() / 1000), archived: c.archived || false, pinned: c.pinned || false, isLocked: c.isLocked || false, isMuted: c.isMuted || false, preview: c.lastMessage ? (c.lastMessage.body || '[Mídia]') : (existingPersistent.preview || ''), lastMessageFromMe: c.lastMessage?.fromMe !== undefined ? Boolean(c.lastMessage.fromMe) : Boolean(existingPersistent.lastMessageFromMe) }); } }); const conversas = Array.from(mergedChatsMap.values()); // Ordenar chats pelo timestamp mais recente conversas.sort((a, b) => b.timestamp - a.timestamp); // Buscar todas as atribuições para enriquecer os chats return Promise.all(conversas.map(async chat => { const assignment = await this.assignmentService.getAssignment(chat.id._serialized); const contactProfile = await this.getCustomerContact(chat.id._serialized); const phone = contactProfile?.phone || await this.resolveContactPhone(chat.id._serialized); return { ...chat, name: contactProfile?.name || chat.name, contactProfile: contactProfile ? { ...contactProfile, phone } : { chat_id: chat.id._serialized, phone, name: null, company: null, note: null, }, assignment: assignment || null }; })); } private async resolveContactPhone(chatId: string) { try { const lidPhone = await this.resolveLidPhone(chatId); if (lidPhone) return lidPhone; const contact = await this.retryWhatsappRead(() => this.client.getContactById(chatId)); const phone = (contact as any)?.number || (contact as any)?.phoneNumber || (contact as any)?.id?.user || ''; if (phone && !String(phone).includes('@') && !chatId.includes(`${phone}@lid`)) { return String(phone); } const formattedNumber = await contact.getFormattedNumber().catch(() => ''); return formattedNumber || (chatId.endsWith('@lid') ? '' : chatId.split('@')[0]); } catch { return chatId.endsWith('@lid') ? '' : chatId.split('@')[0]; } } private async resolveLidPhone(chatId: string) { if (!chatId.endsWith('@lid')) return ''; try { const page = (this.client as any).pupPage; if (!page) return ''; const phone = await this.retryWhatsappRead(() => page.evaluate(async (serializedChatId: string) => { try { const result = await (window as any).WWebJS?.enforceLidAndPnRetrieval?.(serializedChatId); return result?.phone?._serialized || result?.phone?.user || ''; } catch { return ''; } }, chatId), ); return phone ? String(phone).split('@')[0] : ''; } catch { return ''; } } private async getCustomerContact(chatId: string) { try { const contact = await this.contactsService.getContact(chatId); return contact?.updated_at ? contact : null; } catch { return null; } } private async getBotMessageVariables(chatId: string, msg: any) { const contactProfile = await this.getCustomerContact(chatId); const notifyName = String(msg?.['_data']?.notifyName || '').trim(); const pushName = String(msg?.['_data']?.pushName || '').trim(); const phone = contactProfile?.phone || await this.resolveContactPhone(chatId); const fallbackName = notifyName || pushName || ''; return { nome: contactProfile?.name || fallbackName, telefone: phone, }; } async getChatMessages(chatId: string) { if (this.status !== 'CONNECTED') return []; try { const messages = await this.retryWhatsappRead(async () => { const chat = await this.client.getChatById(chatId); return chat.fetchMessages({ limit: 50 }); }); this.chatMessagesCache.set(chatId, messages); return messages; } catch (err) { const cachedMessages = this.chatMessagesCache.get(chatId) || []; if (this.isTransientWhatsappFrameError(err)) { this.logger.warn(`WhatsApp Web ainda estabilizando ao carregar mensagens de ${chatId}. Retornando cache local (${cachedMessages.length}).`); } else { this.logger.error(`Erro ao carregar mensagens de ${chatId}. Retornando cache local (${cachedMessages.length}).`, err); } return cachedMessages; } } async getMessageMedia(chatId: string, messageId: string) { if (this.status !== 'CONNECTED') throw new Error('WhatsApp não está conectado'); this.logger.log(`Buscando mídia do chat ${chatId}, mensagem ${messageId}`); const messages = await this.retryWhatsappRead(async () => { const chat = await this.client.getChatById(chatId); return chat.fetchMessages({ limit: 50 }); }); const msg = messages.find(m => m.id._serialized === messageId); if (msg && msg.hasMedia) { this.logger.log(`Baixando mídia para mensagem ${messageId}...`); const media = await msg.downloadMedia(); if (media) { return { mimetype: media.mimetype, data: media.data, filename: media.filename || 'arquivo' }; } } throw new Error('Mídia não encontrada para esta mensagem'); } private async retryWhatsappRead(operation: () => Promise, retries = 1): Promise { try { return await operation(); } catch (err) { if (retries <= 0 || !this.isTransientWhatsappFrameError(err)) { throw err; } await this.sleep(350); return this.retryWhatsappRead(operation, retries - 1); } } private sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } private isTransientWhatsappFrameError(err: unknown) { const message = this.getErrorMessage(err).toLowerCase(); return ( message.includes('detached frame') || message.includes('execution context was destroyed') || message.includes('most likely because of a navigation') ); } private getErrorMessage(err: unknown) { if (err instanceof Error) return err.message; return String(err || ''); } async sendMessage(to: string, message: string, media?: { data: string; mimetype: string; filename?: string }, senderName?: string) { if (this.status !== 'CONNECTED') throw new Error('WhatsApp não está conectado'); const canSendMessage = await this.assignmentService.canSendAgentMessage(to); if (!canSendMessage) { throw new Error('Aguarde o cliente responder antes de enviar novas mensagens.'); } const outboundMessage = this.formatOutboundMessage(message, senderName); let sentMsg; if (media) { this.logger.log(`Enviando mídia para ${to}: ${media.filename} (${media.mimetype})`); const messageMedia = new MessageMedia(media.mimetype, media.data, media.filename); sentMsg = await this.client.sendMessage(to, messageMedia, { caption: outboundMessage }); } else { sentMsg = await this.client.sendMessage(to, outboundMessage); } // Sincronizar na persistência também! await this.addOrUpdatePersistentChat(to, { name: to.split('@')[0], preview: media ? `[Mídia: ${media.filename || 'Arquivo'}]` : message, timestamp: Math.floor(Date.now() / 1000), unreadCount: 0, lastMessageFromMe: true }); await this.assignmentService.clearTransferNote(to); return sentMsg; } async startAttendance( to: string, templateId: number, userId: number, areaId?: number | null, variables?: Record, ) { const template = await this.whatsappTemplateService.getTemplateById(templateId); if (!template) { throw new Error('Template de WhatsApp nao encontrado'); } const renderedContent = this.renderTemplateContent(template.content, variables); const sentMessage = await this.sendMessage(to, renderedContent); const chatId = this.getSentMessageChatId(sentMessage, to); const assignment = await this.assignmentService.assignChat(chatId, userId, areaId || null); const lockedAssignment = await this.assignmentService.markAwaitingCustomerReply(chatId); return { chatId, template: { ...template, content: renderedContent }, messageId: sentMessage?.id?._serialized || null, assignment: lockedAssignment || assignment, }; } private getSentMessageChatId(sentMessage: any, fallbackChatId: string) { const remote = sentMessage?.id?.remote || sentMessage?._data?.id?.remote || sentMessage?.to || sentMessage?.from || fallbackChatId; return String(remote || fallbackChatId); } private renderTemplateContent(content: string, variables?: Record) { return String(content || '').replace(/\{([^{}]+)\}/g, (match, key) => { const cleanKey = String(key || '').trim(); const lowerKey = cleanKey.toLowerCase(); const asciiKey = lowerKey.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); const value = variables?.[cleanKey] ?? variables?.[lowerKey] ?? variables?.[asciiKey]; const normalized = String(value || '').trim(); return normalized || match; }); } private formatOutboundMessage(message: string, senderName?: string) { const cleanMessage = (message || '').trim(); const cleanSenderName = (senderName || '').trim(); if (!cleanSenderName) { return cleanMessage; } if (!cleanMessage) { return ''; } return `*Atendente: ${cleanSenderName}*\n\n${cleanMessage}`; } }