2026-05-18 11:14:17 -03:00
|
|
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
|
|
|
|
import { Client, LocalAuth, MessageMedia } from 'whatsapp-web.js';
|
|
|
|
|
import { WhatsappGateway } from './whatsapp.gateway';
|
|
|
|
|
import { WhatsappAssignmentService } from './whatsapp-assignment.service';
|
2026-05-18 13:28:17 -03:00
|
|
|
import { DatabaseService } from '../../infra/database/database.service';
|
2026-05-18 11:14:17 -03:00
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
|
private readonly gateway: WhatsappGateway,
|
2026-05-18 13:28:17 -03:00
|
|
|
private readonly assignmentService: WhatsappAssignmentService,
|
|
|
|
|
private readonly db: DatabaseService
|
2026-05-18 11:14:17 -03:00
|
|
|
) {}
|
|
|
|
|
|
2026-05-18 13:28:17 -03:00
|
|
|
async onModuleInit() {
|
|
|
|
|
// Inicialização da tabela de templates no banco
|
|
|
|
|
try {
|
|
|
|
|
await this.db.query(`
|
|
|
|
|
CREATE TABLE IF NOT EXISTS whatsapp_templates (
|
|
|
|
|
id SERIAL PRIMARY KEY,
|
|
|
|
|
name VARCHAR(255) NOT NULL UNIQUE,
|
|
|
|
|
content TEXT NOT NULL,
|
|
|
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
|
|
|
|
);
|
|
|
|
|
`);
|
|
|
|
|
await this.db.query(`
|
|
|
|
|
INSERT INTO whatsapp_templates (name, content) VALUES
|
|
|
|
|
('aviso_fatura', 'Olá, {nome}. Estamos entrando em contato para lembrá-lo que a sua fatura está programada para {data}.'),
|
|
|
|
|
('boas_vindas', 'Olá, {nome}! Obrigado por entrar em contato conosco. Como podemos te ajudar hoje?'),
|
|
|
|
|
('lembrete_consulta', 'Olá, {nome}. Gostaríamos de confirmar o seu agendamento para {data}. Está confirmado?'),
|
|
|
|
|
('suporte_tecnico', 'Olá, {nome}. Sou o atendente e irei te auxiliar no seu suporte sob protocolo {protocolo}.')
|
|
|
|
|
ON CONFLICT (name) DO NOTHING;
|
|
|
|
|
`);
|
|
|
|
|
this.logger.log('Tabela de templates do WhatsApp verificada/criada com sucesso no PostgreSQL!');
|
|
|
|
|
} catch (err) {
|
|
|
|
|
this.logger.error('Erro ao verificar/criar tabela de templates no PostgreSQL:', err);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 11:14:17 -03:00
|
|
|
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);
|
|
|
|
|
this.logger.log(`Mensagem registrada (fromMe: ${msg.fromMe}) remote: ${remoteJid} - ${msg.body}`);
|
|
|
|
|
|
|
|
|
|
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: msg.id._serialized,
|
|
|
|
|
fromMe: msg.fromMe,
|
|
|
|
|
notifyName: msg['_data']?.notifyName || '',
|
|
|
|
|
hasMedia: msg.hasMedia,
|
|
|
|
|
media: mediaData
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Salva ou atualiza a conversa na persistência híbrida
|
2026-05-18 13:28:17 -03:00
|
|
|
const persistentChats = await this.loadPersistentChats();
|
|
|
|
|
const isNewNumber = !persistentChats[remoteJid];
|
|
|
|
|
|
2026-05-18 11:14:17 -03:00
|
|
|
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
|
|
|
|
|
});
|
2026-05-18 13:28:17 -03:00
|
|
|
|
|
|
|
|
if (!msg.fromMe && isNewNumber) {
|
|
|
|
|
try {
|
|
|
|
|
this.logger.log(`Auto-resposta de boas vindas enviada para novo contato: ${remoteJid}`);
|
|
|
|
|
await this.client.sendMessage(remoteJid, "Olá! Seja bem-vindo a Sothis Telecom Como podemos te ajudar?");
|
|
|
|
|
} catch (err) {
|
|
|
|
|
this.logger.error(`Erro ao enviar auto-resposta de boas vindas para ${remoteJid}:`, err);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-18 11:14:17 -03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.client.initialize();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getPersistFilePath() {
|
|
|
|
|
return path.join(process.cwd(), 'whatsapp-chats-persist.json');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async loadPersistentChats(): Promise<any> {
|
|
|
|
|
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<void> {
|
|
|
|
|
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 }) {
|
|
|
|
|
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 || ''
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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.client.getChats();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
this.logger.error('Erro ao chamar client.getChats():', err);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const persistentChatsObj = await this.loadPersistentChats();
|
|
|
|
|
const mergedChatsMap = new Map<string, any>();
|
|
|
|
|
|
|
|
|
|
// 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 || '')
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
return {
|
|
|
|
|
...chat,
|
|
|
|
|
assignment: assignment || null
|
|
|
|
|
};
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getChatMessages(chatId: string) {
|
|
|
|
|
if (this.status !== 'CONNECTED') return [];
|
|
|
|
|
const chat = await this.client.getChatById(chatId);
|
|
|
|
|
return chat.fetchMessages({ limit: 50 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 chat = await this.client.getChatById(chatId);
|
|
|
|
|
const messages = await 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');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async sendMessage(to: string, message: string, media?: { data: string; mimetype: string; filename?: string }) {
|
|
|
|
|
if (this.status !== 'CONNECTED') throw new Error('WhatsApp não está conectado');
|
|
|
|
|
|
|
|
|
|
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: message });
|
|
|
|
|
} else {
|
|
|
|
|
sentMsg = await this.client.sendMessage(to, message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return sentMsg;
|
|
|
|
|
}
|
2026-05-18 13:28:17 -03:00
|
|
|
|
|
|
|
|
async getTemplates() {
|
|
|
|
|
const res = await this.db.query('SELECT * FROM whatsapp_templates ORDER BY id ASC');
|
|
|
|
|
return res.rows;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async saveTemplate(name: string, content: string) {
|
|
|
|
|
const res = await this.db.query(
|
|
|
|
|
'INSERT INTO whatsapp_templates (name, content) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET content = EXCLUDED.content, updated_at = CURRENT_TIMESTAMP RETURNING *',
|
|
|
|
|
[name, content]
|
|
|
|
|
);
|
|
|
|
|
return res.rows[0];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async updateTemplate(id: number, name: string, content: string) {
|
|
|
|
|
const res = await this.db.query(
|
|
|
|
|
'UPDATE whatsapp_templates SET name = $1, content = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3 RETURNING *',
|
|
|
|
|
[name, content, id]
|
|
|
|
|
);
|
|
|
|
|
return res.rows[0];
|
|
|
|
|
}
|
2026-05-18 11:14:17 -03:00
|
|
|
}
|