Compare commits
No commits in common. "da17cbda2d17bc968cc9b90830664d222c8724cc" and "8f3cb9f75f8ca55a58c1f66a4ba6f12cd89b4d2f" have entirely different histories.
da17cbda2d
...
8f3cb9f75f
@ -73,65 +73,8 @@ function killPid(pid) {
|
||||
execSync(`kill -TERM ${pid}`, { stdio: 'ignore' });
|
||||
}
|
||||
|
||||
function getWindowsWhatsappSessionPids() {
|
||||
const sessionPath = path.resolve(process.cwd(), 'whatsapp-session');
|
||||
const escapedSessionPath = sessionPath.replace(/\\/g, '\\\\');
|
||||
const command = [
|
||||
'Get-CimInstance Win32_Process',
|
||||
`Where-Object { $_.Name -eq 'chrome.exe' -and $_.CommandLine -like '*${escapedSessionPath}*' }`,
|
||||
'Select-Object -ExpandProperty ProcessId',
|
||||
].join(' | ');
|
||||
|
||||
const output = execSync(`powershell -NoProfile -Command "${command}"`, {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
output
|
||||
.split(/\r?\n/)
|
||||
.map((pid) => pid.trim())
|
||||
.filter(Boolean)
|
||||
.filter((pid) => pid !== String(process.pid)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function getUnixWhatsappSessionPids() {
|
||||
const sessionPath = path.resolve(process.cwd(), 'whatsapp-session');
|
||||
const output = execSync(`pgrep -f "${sessionPath}"`, {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
output
|
||||
.split(/\r?\n/)
|
||||
.map((pid) => pid.trim())
|
||||
.filter(Boolean)
|
||||
.filter((pid) => pid !== String(process.pid)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function killWhatsappSessionBrowsers() {
|
||||
try {
|
||||
const pids = process.platform === 'win32' ? getWindowsWhatsappSessionPids() : getUnixWhatsappSessionPids();
|
||||
if (!pids.length) return;
|
||||
|
||||
pids.forEach(killPid);
|
||||
console.log(`Sessao WhatsApp liberada. Processo(s) Chrome encerrado(s): ${pids.join(', ')}`);
|
||||
} catch {
|
||||
// Se nao houver processo usando a sessao, seguimos normalmente.
|
||||
}
|
||||
}
|
||||
|
||||
const port = getConfiguredPort();
|
||||
|
||||
killWhatsappSessionBrowsers();
|
||||
|
||||
try {
|
||||
const pids = process.platform === 'win32' ? getWindowsPids(port) : getUnixPids(port);
|
||||
|
||||
|
||||
@ -1,13 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminAccessController } from './admin-access.controller';
|
||||
import { AdminAccessService } from './admin-access.service';
|
||||
import { AgentNotesController } from './agent-notes.controller';
|
||||
import { AgentNotesService } from './agent-notes.service';
|
||||
import { CustomerContactsController } from './customer-contacts.controller';
|
||||
import { CustomerContactsService } from './customer-contacts.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AdminAccessController, AgentNotesController, CustomerContactsController],
|
||||
providers: [AdminAccessService, AgentNotesService, CustomerContactsService],
|
||||
controllers: [AdminAccessController],
|
||||
providers: [AdminAccessService],
|
||||
})
|
||||
export class AdminModule {}
|
||||
|
||||
@ -1,22 +0,0 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { AgentNotesService } from './agent-notes.service';
|
||||
|
||||
@Controller('agent/notes')
|
||||
export class AgentNotesController {
|
||||
constructor(private readonly agentNotesService: AgentNotesService) {}
|
||||
|
||||
@Get()
|
||||
listNotes(@Query('userId') userId: string) {
|
||||
return this.agentNotesService.listNotes(Number(userId));
|
||||
}
|
||||
|
||||
@Post()
|
||||
createNote(@Body() body: { userId: number; text: string }) {
|
||||
return this.agentNotesService.createNote(Number(body.userId), body.text);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
deleteNote(@Param('id') id: string, @Query('userId') userId: string) {
|
||||
return this.agentNotesService.deleteNote(Number(userId), Number(id));
|
||||
}
|
||||
}
|
||||
@ -1,57 +0,0 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { DatabaseService } from '../../infra/database/database.service';
|
||||
|
||||
@Injectable()
|
||||
export class AgentNotesService implements OnModuleInit {
|
||||
constructor(private readonly database: DatabaseService) {}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.database.query(`
|
||||
CREATE TABLE IF NOT EXISTS agent_notes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES usuarios(id) ON DELETE CASCADE,
|
||||
text TEXT NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
async listNotes(userId: number) {
|
||||
const result = await this.database.query(
|
||||
`
|
||||
SELECT id, user_id, text, created_at
|
||||
FROM agent_notes
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async createNote(userId: number, text: string) {
|
||||
const result = await this.database.query(
|
||||
`
|
||||
INSERT INTO agent_notes (user_id, text)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, user_id, text, created_at
|
||||
`,
|
||||
[userId, text],
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async deleteNote(userId: number, noteId: number) {
|
||||
await this.database.query(
|
||||
`
|
||||
DELETE FROM agent_notes
|
||||
WHERE id = $1 AND user_id = $2
|
||||
`,
|
||||
[noteId, userId],
|
||||
);
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@ -1,32 +0,0 @@
|
||||
import { Body, Controller, Get, Param, Put } from '@nestjs/common';
|
||||
import { CustomerContactsService } from './customer-contacts.service';
|
||||
|
||||
@Controller('contacts')
|
||||
export class CustomerContactsController {
|
||||
constructor(private readonly customerContactsService: CustomerContactsService) {}
|
||||
|
||||
@Get()
|
||||
listContacts() {
|
||||
return this.customerContactsService.listContacts();
|
||||
}
|
||||
|
||||
@Get(':chatId')
|
||||
getContact(@Param('chatId') chatId: string) {
|
||||
return this.customerContactsService.getContact(decodeURIComponent(chatId));
|
||||
}
|
||||
|
||||
@Put(':chatId')
|
||||
saveContact(
|
||||
@Param('chatId') chatId: string,
|
||||
@Body() body: { phone?: string | null; name?: string | null; company?: string | null; note?: string | null; userId?: number | null },
|
||||
) {
|
||||
return this.customerContactsService.saveContact({
|
||||
chatId: decodeURIComponent(chatId),
|
||||
phone: body.phone,
|
||||
name: body.name,
|
||||
company: body.company,
|
||||
note: body.note,
|
||||
userId: body.userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,110 +0,0 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { DatabaseService } from '../../infra/database/database.service';
|
||||
|
||||
interface SaveContactInput {
|
||||
chatId: string;
|
||||
phone?: string | null;
|
||||
name?: string | null;
|
||||
company?: string | null;
|
||||
note?: string | null;
|
||||
userId?: number | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CustomerContactsService implements OnModuleInit {
|
||||
constructor(private readonly database: DatabaseService) {}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.database.query(`
|
||||
CREATE TABLE IF NOT EXISTS agenda_contatos (
|
||||
chat_id VARCHAR(255) PRIMARY KEY,
|
||||
phone VARCHAR(80) NOT NULL,
|
||||
name VARCHAR(255),
|
||||
company VARCHAR(255),
|
||||
note TEXT,
|
||||
updated_by_user_id INTEGER REFERENCES usuarios(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
async getContact(chatId: string) {
|
||||
const result = await this.database.query(
|
||||
`
|
||||
SELECT chat_id, phone, name, company, note, updated_by_user_id, created_at, updated_at
|
||||
FROM agenda_contatos
|
||||
WHERE chat_id = $1
|
||||
LIMIT 1
|
||||
`,
|
||||
[chatId],
|
||||
);
|
||||
|
||||
return result.rows[0] || this.buildDefaultContact(chatId);
|
||||
}
|
||||
|
||||
async listContacts() {
|
||||
const result = await this.database.query(
|
||||
`
|
||||
SELECT chat_id, phone, name, company, note, updated_by_user_id, created_at, updated_at
|
||||
FROM agenda_contatos
|
||||
ORDER BY updated_at DESC NULLS LAST, created_at DESC NULLS LAST
|
||||
LIMIT 80
|
||||
`,
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async saveContact(input: SaveContactInput) {
|
||||
const result = await this.database.query(
|
||||
`
|
||||
INSERT INTO agenda_contatos (
|
||||
chat_id, phone, name, company, note, updated_by_user_id, created_at, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (chat_id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
company = EXCLUDED.company,
|
||||
note = EXCLUDED.note,
|
||||
updated_by_user_id = EXCLUDED.updated_by_user_id,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
RETURNING chat_id, phone, name, company, note, updated_by_user_id, created_at, updated_at
|
||||
`,
|
||||
[
|
||||
input.chatId,
|
||||
this.normalizePhone(input.phone, input.chatId),
|
||||
this.normalizeText(input.name),
|
||||
this.normalizeText(input.company),
|
||||
this.normalizeText(input.note),
|
||||
input.userId || null,
|
||||
],
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
private buildDefaultContact(chatId: string) {
|
||||
return {
|
||||
chat_id: chatId,
|
||||
phone: this.normalizePhone(null, chatId),
|
||||
name: null,
|
||||
company: null,
|
||||
note: null,
|
||||
updated_by_user_id: null,
|
||||
created_at: null,
|
||||
updated_at: null,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizePhone(phone: string | null | undefined, chatId: string) {
|
||||
const cleanPhone = String(phone || '').trim();
|
||||
if (cleanPhone) return cleanPhone;
|
||||
return chatId.endsWith('@lid') ? '' : String(chatId || '').split('@')[0];
|
||||
}
|
||||
|
||||
private normalizeText(value?: string | null) {
|
||||
const text = String(value || '').trim();
|
||||
return text || null;
|
||||
}
|
||||
}
|
||||
@ -19,8 +19,6 @@ const SUPPORT_KEYWORDS = [
|
||||
'internet',
|
||||
'instabilidade',
|
||||
'sistema',
|
||||
'link',
|
||||
'caiu',
|
||||
];
|
||||
|
||||
const FINANCE_KEYWORDS = [
|
||||
@ -74,7 +72,6 @@ export class WhatsappAssignmentService implements OnModuleInit {
|
||||
ADD COLUMN IF NOT EXISTS routing_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS last_routed_message_id VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS last_bot_sent_at TIMESTAMP WITH TIME ZONE,
|
||||
ADD COLUMN IF NOT EXISTS awaiting_customer_reply BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP;
|
||||
`);
|
||||
}
|
||||
@ -91,7 +88,6 @@ export class WhatsappAssignmentService implements OnModuleInit {
|
||||
user_id = EXCLUDED.user_id,
|
||||
area_id = COALESCE(EXCLUDED.area_id, whatsapp_chat_atribuicoes.area_id),
|
||||
status = 'assigned',
|
||||
awaiting_customer_reply = FALSE,
|
||||
assigned_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
RETURNING *;
|
||||
@ -189,53 +185,6 @@ export class WhatsappAssignmentService implements OnModuleInit {
|
||||
return result.rows[0] ? this.enrichAssignment(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
async clearTransferNote(chatId: string) {
|
||||
const result = await this.db.query(
|
||||
`
|
||||
UPDATE whatsapp_chat_atribuicoes
|
||||
SET transfer_note = NULL, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE chat_id = $1
|
||||
RETURNING *
|
||||
`,
|
||||
[chatId],
|
||||
);
|
||||
|
||||
return result.rows[0] ? this.enrichAssignment(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
async markAwaitingCustomerReply(chatId: string) {
|
||||
const result = await this.db.query(
|
||||
`
|
||||
UPDATE whatsapp_chat_atribuicoes
|
||||
SET awaiting_customer_reply = TRUE, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE chat_id = $1
|
||||
RETURNING *
|
||||
`,
|
||||
[chatId],
|
||||
);
|
||||
|
||||
return result.rows[0] ? this.enrichAssignment(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
async markCustomerReplied(chatId: string) {
|
||||
const result = await this.db.query(
|
||||
`
|
||||
UPDATE whatsapp_chat_atribuicoes
|
||||
SET awaiting_customer_reply = FALSE, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE chat_id = $1 AND awaiting_customer_reply = TRUE
|
||||
RETURNING *
|
||||
`,
|
||||
[chatId],
|
||||
);
|
||||
|
||||
return result.rows[0] ? this.enrichAssignment(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
async canSendAgentMessage(chatId: string) {
|
||||
const assignment = await this.getAssignment(chatId);
|
||||
return !assignment?.awaiting_customer_reply;
|
||||
}
|
||||
|
||||
async routeIncomingMessage(chatId: string, message: string, messageId?: string) {
|
||||
const cleanMessage = (message || '').trim();
|
||||
if (!cleanMessage) {
|
||||
|
||||
@ -34,11 +34,6 @@ export class WhatsappController {
|
||||
return this.whatsappService.sendMessage(body.to, body.message, body.media, body.senderName);
|
||||
}
|
||||
|
||||
@Post('start-attendance')
|
||||
async startAttendance(@Body() body: { to: string; templateId: number; userId: number; areaId?: number | null; variables?: Record<string, string | null | undefined> }) {
|
||||
return this.whatsappService.startAttendance(body.to, body.templateId, body.userId, body.areaId, body.variables);
|
||||
}
|
||||
|
||||
@Post('assign')
|
||||
async assignChat(@Body() body: { chatId: string; userId: string; areaId?: string }) {
|
||||
return this.assignmentService.assignChat(body.chatId, body.userId, body.areaId);
|
||||
|
||||
@ -14,7 +14,6 @@ export class WhatsappService implements OnModuleInit {
|
||||
private currentQr: string | null = null;
|
||||
private readonly processedIncomingMessages = new Set<string>();
|
||||
private readonly incomingRoutingQueues = new Map<string, Promise<void>>();
|
||||
private readonly chatMessagesCache = new Map<string, any[]>();
|
||||
|
||||
constructor(
|
||||
private readonly gateway: WhatsappGateway,
|
||||
@ -136,15 +135,10 @@ export class WhatsappService implements OnModuleInit {
|
||||
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
|
||||
unreadCount: msg.fromMe ? 0 : 1
|
||||
});
|
||||
|
||||
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;
|
||||
@ -220,7 +214,7 @@ export class WhatsappService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
private async addOrUpdatePersistentChat(chatId: string, data: { name?: string, preview?: string, timestamp?: number, unreadCount?: number, lastMessageFromMe?: boolean }) {
|
||||
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();
|
||||
@ -262,8 +256,7 @@ export class WhatsappService implements OnModuleInit {
|
||||
pinned: false,
|
||||
isLocked: false,
|
||||
isMuted: false,
|
||||
preview: data.preview || existing.preview || '',
|
||||
lastMessageFromMe: data.lastMessageFromMe !== undefined ? data.lastMessageFromMe : Boolean(existing.lastMessageFromMe)
|
||||
preview: data.preview || existing.preview || ''
|
||||
};
|
||||
|
||||
await this.savePersistentChats(chats);
|
||||
@ -282,13 +275,9 @@ export class WhatsappService implements OnModuleInit {
|
||||
|
||||
let liveChats: any[] = [];
|
||||
try {
|
||||
liveChats = await this.retryWhatsappRead(() => this.client.getChats());
|
||||
liveChats = await 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);
|
||||
}
|
||||
this.logger.error('Erro ao chamar client.getChats():', err);
|
||||
}
|
||||
|
||||
const persistentChatsObj = await this.loadPersistentChats();
|
||||
@ -322,8 +311,7 @@ export class WhatsappService implements OnModuleInit {
|
||||
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)
|
||||
preview: c.lastMessage ? (c.lastMessage.body || '[Mídia]') : (existingPersistent.preview || '')
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -336,118 +324,25 @@ export class WhatsappService implements OnModuleInit {
|
||||
// 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 result = await this.db.query(
|
||||
`
|
||||
SELECT chat_id, phone, name, company, note, updated_by_user_id, created_at, updated_at
|
||||
FROM agenda_contatos
|
||||
WHERE chat_id = $1
|
||||
LIMIT 1
|
||||
`,
|
||||
[chatId],
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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 messages = await this.retryWhatsappRead(async () => {
|
||||
const chat = await this.client.getChatById(chatId);
|
||||
return chat.fetchMessages({ limit: 50 });
|
||||
});
|
||||
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) {
|
||||
@ -465,43 +360,8 @@ export class WhatsappService implements OnModuleInit {
|
||||
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);
|
||||
|
||||
@ -519,47 +379,12 @@ export class WhatsappService implements OnModuleInit {
|
||||
name: to.split('@')[0],
|
||||
preview: media ? `[Mídia: ${media.filename || 'Arquivo'}]` : message,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
unreadCount: 0,
|
||||
lastMessageFromMe: true
|
||||
unreadCount: 0
|
||||
});
|
||||
await this.assignmentService.clearTransferNote(to);
|
||||
|
||||
return sentMsg;
|
||||
}
|
||||
|
||||
async startAttendance(
|
||||
to: string,
|
||||
templateId: number,
|
||||
userId: number,
|
||||
areaId?: number | null,
|
||||
variables?: Record<string, string | null | undefined>,
|
||||
) {
|
||||
const template = await this.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 assignment = await this.assignmentService.assignChat(to, userId, areaId || null);
|
||||
const lockedAssignment = await this.assignmentService.markAwaitingCustomerReply(to);
|
||||
|
||||
return {
|
||||
chatId: to,
|
||||
template: { ...template, content: renderedContent },
|
||||
messageId: sentMessage?.id?._serialized || null,
|
||||
assignment: lockedAssignment || assignment,
|
||||
};
|
||||
}
|
||||
|
||||
private renderTemplateContent(content: string, variables?: Record<string, string | null | undefined>) {
|
||||
return String(content || '').replace(/\{([a-zA-Z0-9_]+)\}/g, (match, key) => {
|
||||
const value = variables?.[key] ?? variables?.[String(key).toLowerCase()];
|
||||
const normalized = String(value || '').trim();
|
||||
return normalized || match;
|
||||
});
|
||||
}
|
||||
|
||||
private formatOutboundMessage(message: string, senderName?: string) {
|
||||
const cleanMessage = (message || '').trim();
|
||||
const cleanSenderName = (senderName || '').trim();
|
||||
@ -580,11 +405,6 @@ export class WhatsappService implements OnModuleInit {
|
||||
return res.rows;
|
||||
}
|
||||
|
||||
private async getTemplateById(id: number) {
|
||||
const res = await this.db.query('SELECT * FROM whatsapp_templates WHERE id = $1 LIMIT 1', [id]);
|
||||
return res.rows[0] || null;
|
||||
}
|
||||
|
||||
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 *',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user