diff --git a/Dockerfile b/Dockerfile index 6c76c36..3480062 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,5 @@ FROM node:20-alpine WORKDIR /app -RUN apk add --no-cache chromium nss freetype harfbuzz ca-certificates ttf-freefont -ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser COPY package*.json ./ RUN npm ci COPY . . diff --git a/package.json b/package.json index 5001a71..e26c45f 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,6 @@ "rxjs": "^7.8.2", "socket.io": "^4.8.3", "swagger-ui-express": "^5.0.1", - "whatsapp-web.js": "^1.34.7", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0" }, diff --git a/src/modules/whatsapp/whatsapp.service.ts b/src/modules/whatsapp/whatsapp.service.ts index 6393434..c0f59aa 100644 --- a/src/modules/whatsapp/whatsapp.service.ts +++ b/src/modules/whatsapp/whatsapp.service.ts @@ -1,33 +1,14 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; -import { Client, LocalAuth, MessageMedia } from 'whatsapp-web.js'; +import { Injectable, Logger, NotImplementedException } from '@nestjs/common'; 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'; - -function getBrowserExecutablePath() { - if (process.env.PUPPETEER_EXECUTABLE_PATH) { - return process.env.PUPPETEER_EXECUTABLE_PATH; - } - - if (process.platform === 'win32') { - return 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'; - } - - return undefined; -} @Injectable() -export class WhatsappService implements OnModuleInit { - private client: Client; +export class WhatsappService { 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, @@ -36,126 +17,6 @@ export class WhatsappService implements OnModuleInit { 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: getBrowserExecutablePath(), - 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(); @@ -188,213 +49,20 @@ export class WhatsappService implements OnModuleInit { 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; + return 'META_API'; } 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 - }; - })); + return []; } - 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]; - } + async getChatMessages(_chatId: string) { + return []; } - 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 ''; - } + async getMessageMedia(_chatId: string, _messageId: string) { + throw new NotImplementedException('Busca de mídia não disponível na Meta API ainda.'); } private async getCustomerContact(chatId: string) { @@ -409,122 +77,15 @@ export class WhatsappService implements OnModuleInit { 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, + nome: contactProfile?.name || notifyName, + telefone: contactProfile?.phone || chatId, }; } - 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 sendMessage(to: string, message: string, _media?: { data: string; mimetype: string; filename?: string }, _senderName?: string) { + throw new NotImplementedException(`Envio via Meta API não implementado ainda. Destinatário: ${to}, mensagem: "${message}"`); } async startAttendance( @@ -535,35 +96,22 @@ export class WhatsappService implements OnModuleInit { variables?: Record, ) { const template = await this.whatsappTemplateService.getTemplateById(templateId); - if (!template) { - throw new Error('Template de WhatsApp nao encontrado'); - } + 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); + await this.sendMessage(to, renderedContent); + + const assignment = await this.assignmentService.assignChat(to, userId, areaId || null); + const lockedAssignment = await this.assignmentService.markAwaitingCustomerReply(to); return { - chatId, + chatId: to, template: { ...template, content: renderedContent }, - messageId: sentMessage?.id?._serialized || null, + messageId: 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(); @@ -575,20 +123,6 @@ export class WhatsappService implements OnModuleInit { }); } - 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}`; - } async handleMetaWebhookEvent(body: any): Promise { if (body?.object !== 'whatsapp_business_account') return;