PERF: Remoção do whatsapp-web.js para acelerar o build utilizando a meta API
Some checks are pending
Deploy Dev / deploy (push) Waiting to run
Some checks are pending
Deploy Dev / deploy (push) Waiting to run
This commit is contained in:
parent
d39a03c05f
commit
cfd6f2deae
@ -1,7 +1,5 @@
|
|||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
WORKDIR /app
|
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 ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@ -29,7 +29,6 @@
|
|||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
"whatsapp-web.js": "^1.34.7",
|
|
||||||
"winston": "^3.19.0",
|
"winston": "^3.19.0",
|
||||||
"winston-daily-rotate-file": "^5.0.0"
|
"winston-daily-rotate-file": "^5.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,33 +1,14 @@
|
|||||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
import { Injectable, Logger, NotImplementedException } from '@nestjs/common';
|
||||||
import { Client, LocalAuth, MessageMedia } from 'whatsapp-web.js';
|
|
||||||
import { WhatsappGateway } from './whatsapp.gateway';
|
import { WhatsappGateway } from './whatsapp.gateway';
|
||||||
import { AttendanceAssignmentService } from '../attendance/attendance-assignment.service';
|
import { AttendanceAssignmentService } from '../attendance/attendance-assignment.service';
|
||||||
import { ContactsService } from '../contacts/contacts.service';
|
import { ContactsService } from '../contacts/contacts.service';
|
||||||
import { WhatsappTemplateService } from './whatsapp-template.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()
|
@Injectable()
|
||||||
export class WhatsappService implements OnModuleInit {
|
export class WhatsappService {
|
||||||
private client: Client;
|
|
||||||
private readonly logger = new Logger(WhatsappService.name);
|
private readonly logger = new Logger(WhatsappService.name);
|
||||||
private status: 'DISCONNECTED' | 'AWAITING_QR' | 'CONNECTED' = 'DISCONNECTED';
|
|
||||||
private currentQr: string | null = null;
|
|
||||||
private readonly processedIncomingMessages = new Set<string>();
|
private readonly processedIncomingMessages = new Set<string>();
|
||||||
private readonly incomingRoutingQueues = new Map<string, Promise<void>>();
|
private readonly incomingRoutingQueues = new Map<string, Promise<void>>();
|
||||||
private readonly chatMessagesCache = new Map<string, any[]>();
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly gateway: WhatsappGateway,
|
private readonly gateway: WhatsappGateway,
|
||||||
@ -36,126 +17,6 @@ export class WhatsappService implements OnModuleInit {
|
|||||||
private readonly whatsappTemplateService: WhatsappTemplateService,
|
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) {
|
private async enqueueIncomingRoute(remoteJid: string, msg: any, messageBody: string, messageId: string) {
|
||||||
const previousRoute = this.incomingRoutingQueues.get(remoteJid) || Promise.resolve();
|
const previousRoute = this.incomingRoutingQueues.get(remoteJid) || Promise.resolve();
|
||||||
|
|
||||||
@ -188,213 +49,20 @@ export class WhatsappService implements OnModuleInit {
|
|||||||
await nextRoute;
|
await nextRoute;
|
||||||
}
|
}
|
||||||
|
|
||||||
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, 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() {
|
getStatus() {
|
||||||
return this.status;
|
return 'META_API';
|
||||||
}
|
|
||||||
|
|
||||||
getCurrentQr() {
|
|
||||||
return this.currentQr;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getChats() {
|
async getChats() {
|
||||||
if (this.status !== 'CONNECTED') return [];
|
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();
|
async getChatMessages(_chatId: string) {
|
||||||
const mergedChatsMap = new Map<string, any>();
|
return [];
|
||||||
|
|
||||||
// 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, {
|
async getMessageMedia(_chatId: string, _messageId: string) {
|
||||||
id: c.id,
|
throw new NotImplementedException('Busca de mídia não disponível na Meta API ainda.');
|
||||||
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) {
|
private async getCustomerContact(chatId: string) {
|
||||||
@ -409,122 +77,15 @@ export class WhatsappService implements OnModuleInit {
|
|||||||
private async getBotMessageVariables(chatId: string, msg: any) {
|
private async getBotMessageVariables(chatId: string, msg: any) {
|
||||||
const contactProfile = await this.getCustomerContact(chatId);
|
const contactProfile = await this.getCustomerContact(chatId);
|
||||||
const notifyName = String(msg?.['_data']?.notifyName || '').trim();
|
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 {
|
return {
|
||||||
nome: contactProfile?.name || fallbackName,
|
nome: contactProfile?.name || notifyName,
|
||||||
telefone: phone,
|
telefone: contactProfile?.phone || chatId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getChatMessages(chatId: string) {
|
async sendMessage(to: string, message: string, _media?: { data: string; mimetype: string; filename?: string }, _senderName?: string) {
|
||||||
if (this.status !== 'CONNECTED') return [];
|
throw new NotImplementedException(`Envio via Meta API não implementado ainda. Destinatário: ${to}, mensagem: "${message}"`);
|
||||||
|
|
||||||
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<T>(operation: () => Promise<T>, retries = 1): Promise<T> {
|
|
||||||
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(
|
async startAttendance(
|
||||||
@ -535,35 +96,22 @@ export class WhatsappService implements OnModuleInit {
|
|||||||
variables?: Record<string, string | null | undefined>,
|
variables?: Record<string, string | null | undefined>,
|
||||||
) {
|
) {
|
||||||
const template = await this.whatsappTemplateService.getTemplateById(templateId);
|
const template = await this.whatsappTemplateService.getTemplateById(templateId);
|
||||||
if (!template) {
|
if (!template) throw new Error('Template de WhatsApp nao encontrado');
|
||||||
throw new Error('Template de WhatsApp nao encontrado');
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderedContent = this.renderTemplateContent(template.content, variables);
|
const renderedContent = this.renderTemplateContent(template.content, variables);
|
||||||
const sentMessage = await this.sendMessage(to, renderedContent);
|
await this.sendMessage(to, renderedContent);
|
||||||
const chatId = this.getSentMessageChatId(sentMessage, to);
|
|
||||||
const assignment = await this.assignmentService.assignChat(chatId, userId, areaId || null);
|
const assignment = await this.assignmentService.assignChat(to, userId, areaId || null);
|
||||||
const lockedAssignment = await this.assignmentService.markAwaitingCustomerReply(chatId);
|
const lockedAssignment = await this.assignmentService.markAwaitingCustomerReply(to);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
chatId,
|
chatId: to,
|
||||||
template: { ...template, content: renderedContent },
|
template: { ...template, content: renderedContent },
|
||||||
messageId: sentMessage?.id?._serialized || null,
|
messageId: null,
|
||||||
assignment: lockedAssignment || assignment,
|
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<string, string | null | undefined>) {
|
private renderTemplateContent(content: string, variables?: Record<string, string | null | undefined>) {
|
||||||
return String(content || '').replace(/\{([^{}]+)\}/g, (match, key) => {
|
return String(content || '').replace(/\{([^{}]+)\}/g, (match, key) => {
|
||||||
const cleanKey = String(key || '').trim();
|
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<void> {
|
async handleMetaWebhookEvent(body: any): Promise<void> {
|
||||||
if (body?.object !== 'whatsapp_business_account') return;
|
if (body?.object !== 'whatsapp_business_account') return;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user