Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
292 changes: 247 additions & 45 deletions src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ export class ChatwootService {

private provider: any;

// Cache para deduplicação de orderMessage (evita mensagens duplicadas)
private processedOrderIds: Map<string, number> = new Map();
private readonly ORDER_CACHE_TTL_MS = 30000; // 30 segundos

// Cache para mapeamento LID → Número Normal (resolve problema de @lid)
private lidToPhoneMap: Map<string, { phone: string; timestamp: number }> = new Map();
private readonly LID_CACHE_TTL_MS = 3600000; // 1 hora

constructor(
private readonly waMonitor: WAMonitoringService,
private readonly configService: ConfigService,
Expand Down Expand Up @@ -632,10 +640,32 @@ export class ChatwootService {
public async createConversation(instance: InstanceDto, body: any) {
const isLid = body.key.addressingMode === 'lid';
const isGroup = body.key.remoteJid.endsWith('@g.us');
const phoneNumber = isLid && !isGroup ? body.key.remoteJidAlt : body.key.remoteJid;
const { remoteJid } = body.key;
const cacheKey = `${instance.instanceName}:createConversation-${remoteJid}`;
const lockKey = `${instance.instanceName}:lock:createConversation-${remoteJid}`;
let phoneNumber = isLid && !isGroup ? body.key.remoteJidAlt : body.key.remoteJid;
let { remoteJid } = body.key;

// CORREÇÃO LID: Resolve LID para número normal antes de processar
if (isLid && !isGroup) {
const resolvedPhone = await this.resolveLidToPhone(instance, body.key);

if (resolvedPhone && resolvedPhone !== remoteJid) {
this.logger.verbose(`LID detected and resolved: ${remoteJid} → ${resolvedPhone}`);
phoneNumber = resolvedPhone;

// Salva mapeamento se temos remoteJidAlt
if (body.key.remoteJidAlt) {
this.saveLidMapping(remoteJid, body.key.remoteJidAlt);
}
} else if (body.key.remoteJidAlt) {
// Se não resolveu mas tem remoteJidAlt, usa ele
phoneNumber = body.key.remoteJidAlt;
this.saveLidMapping(remoteJid, body.key.remoteJidAlt);
this.logger.verbose(`Using remoteJidAlt for LID: ${remoteJid} → ${phoneNumber}`);
}
}

// Usa phoneNumber como base para cache (não o LID)
const cacheKey = `${instance.instanceName}:createConversation-${phoneNumber}`;
const lockKey = `${instance.instanceName}:lock:createConversation-${phoneNumber}`;
const maxWaitTime = 5000; // 5 seconds
const client = await this.clientCw(instance);
if (!client) return null;
Expand Down Expand Up @@ -943,20 +973,39 @@ export class ChatwootService {

const sourceReplyId = quotedMsg?.chatwootMessageId || null;

// Filtra valores null/undefined do content_attributes para evitar erro 406
const filteredReplyToIds = Object.fromEntries(
Object.entries(replyToIds).filter(([_, value]) => value != null)
);

// Monta o objeto data, incluindo content_attributes apenas se houver dados válidos
const messageData: any = {
content: content,
message_type: messageType,
content_type: 'text', // Explicitamente define como texto para Chatwoot 4.x
attachments: attachments,
private: privateMessage || false,
};

// Adiciona source_id apenas se existir
if (sourceId) {
messageData.source_id = sourceId;
}

// Adiciona content_attributes apenas se houver dados válidos
if (Object.keys(filteredReplyToIds).length > 0) {
messageData.content_attributes = filteredReplyToIds;
}

// Adiciona source_reply_id apenas se existir
if (sourceReplyId) {
messageData.source_reply_id = sourceReplyId.toString();
}

const message = await client.messages.create({
accountId: this.provider.accountId,
conversationId: conversationId,
data: {
content: content,
message_type: messageType,
attachments: attachments,
private: privateMessage || false,
source_id: sourceId,
content_attributes: {
...replyToIds,
},
source_reply_id: sourceReplyId ? sourceReplyId.toString() : null,
},
data: messageData,
});

if (!message) {
Expand Down Expand Up @@ -1082,11 +1131,14 @@ export class ChatwootService {
if (messageBody && instance) {
const replyToIds = await this.getReplyToIds(messageBody, instance);

if (replyToIds.in_reply_to || replyToIds.in_reply_to_external_id) {
const content = JSON.stringify({
...replyToIds,
});
data.append('content_attributes', content);
// Filtra valores null/undefined antes de enviar
const filteredReplyToIds = Object.fromEntries(
Object.entries(replyToIds).filter(([_, value]) => value != null)
);

if (Object.keys(filteredReplyToIds).length > 0) {
const contentAttrs = JSON.stringify(filteredReplyToIds);
data.append('content_attributes', contentAttrs);
}
}

Expand Down Expand Up @@ -1758,41 +1810,92 @@ export class ChatwootService {
}

private getTypeMessage(msg: any) {
const types = {
conversation: msg.conversation,
imageMessage: msg.imageMessage?.caption,
videoMessage: msg.videoMessage?.caption,
extendedTextMessage: msg.extendedTextMessage?.text,
messageContextInfo: msg.messageContextInfo?.stanzaId,
stickerMessage: undefined,
documentMessage: msg.documentMessage?.caption,
documentWithCaptionMessage: msg.documentWithCaptionMessage?.message?.documentMessage?.caption,
audioMessage: msg.audioMessage ? (msg.audioMessage.caption ?? '') : undefined,
contactMessage: msg.contactMessage?.vcard,
contactsArrayMessage: msg.contactsArrayMessage,
locationMessage: msg.locationMessage,
liveLocationMessage: msg.liveLocationMessage,
listMessage: msg.listMessage,
listResponseMessage: msg.listResponseMessage,
viewOnceMessageV2:
msg?.message?.viewOnceMessageV2?.message?.imageMessage?.url ||
msg?.message?.viewOnceMessageV2?.message?.videoMessage?.url ||
msg?.message?.viewOnceMessageV2?.message?.audioMessage?.url,
};

return types;
}
const types = {
conversation: msg.conversation,
imageMessage: msg.imageMessage?.caption,
videoMessage: msg.videoMessage?.caption,
extendedTextMessage: msg.extendedTextMessage?.text,
messageContextInfo: msg.messageContextInfo?.stanzaId,
stickerMessage: undefined,
documentMessage: msg.documentMessage?.caption,
documentWithCaptionMessage: msg.documentWithCaptionMessage?.message?.documentMessage?.caption,
audioMessage: msg.audioMessage ? (msg.audioMessage.caption ?? '') : undefined,
contactMessage: msg.contactMessage?.vcard,
contactsArrayMessage: msg.contactsArrayMessage,
locationMessage: msg.locationMessage,
liveLocationMessage: msg.liveLocationMessage,
listMessage: msg.listMessage,
listResponseMessage: msg.listResponseMessage,
orderMessage: msg.orderMessage,
viewOnceMessageV2:
msg?.message?.viewOnceMessageV2?.message?.imageMessage?.url ||
msg?.message?.viewOnceMessageV2?.message?.videoMessage?.url ||
msg?.message?.viewOnceMessageV2?.message?.audioMessage?.url,
};

return types;
}

private getMessageContent(types: any) {
const typeKey = Object.keys(types).find((key) => types[key] !== undefined);

let result = typeKey ? types[typeKey] : undefined;

// Remove externalAdReplyBody| in Chatwoot (Already Have)
// Remove externalAdReplyBody| in Chatwoot
if (result && typeof result === 'string' && result.includes('externalAdReplyBody|')) {
result = result.split('externalAdReplyBody|').filter(Boolean).join('');
}

// Tratamento de Pedidos do Catálogo (WhatsApp Business Catalog)
if (typeKey === 'orderMessage' && result.orderId) {
const now = Date.now();
// Limpa entradas antigas do cache
this.processedOrderIds.forEach((timestamp, id) => {
if (now - timestamp > this.ORDER_CACHE_TTL_MS) {
this.processedOrderIds.delete(id);
}
});
// Verifica se já processou este orderId
if (this.processedOrderIds.has(result.orderId)) {
return undefined; // Ignora duplicado
}
this.processedOrderIds.set(result.orderId, now);
}
if (typeKey === 'orderMessage') {
// Extrai o valor - pode ser Long, objeto {low, high}, ou número direto
let rawPrice = 0;
const amount = result.totalAmount1000;

if (Long.isLong(amount)) {
rawPrice = amount.toNumber();
} else if (amount && typeof amount === 'object' && 'low' in amount) {
// Formato {low: number, high: number, unsigned: boolean}
rawPrice = Long.fromValue(amount).toNumber();
} else if (typeof amount === 'number') {
rawPrice = amount;
}

const price = (rawPrice / 1000).toLocaleString('pt-BR', {
style: 'currency',
currency: result.totalCurrencyCode || 'BRL',
});

const itemCount = result.itemCount || 1;
const orderTitle = result.orderTitle || 'Produto do catálogo';
const orderId = result.orderId || 'N/A';

return (
`🛒 *NOVO PEDIDO NO CATÁLOGO*\n` +
`━━━━━━━━━━━━━━━━━━━━━\n` +
`📦 *Produto:* ${orderTitle}\n` +
`📊 *Quantidade:* ${itemCount}\n` +
`💰 *Total:* ${price}\n` +
`🆔 *Pedido:* #${orderId}\n` +
`━━━━━━━━━━━━━━━━━━━━━\n` +
`_Responda para atender este pedido!_`
);
}

if (typeKey === 'locationMessage' || typeKey === 'liveLocationMessage') {
const latitude = result.degreesLatitude;
const longitude = result.degreesLongitude;
Expand Down Expand Up @@ -1993,6 +2096,29 @@ export class ChatwootService {
}
}

// CORREÇÃO LID: Resolve LID para número normal antes de processar evento
if (body?.key?.remoteJid && body.key.remoteJid.includes('@lid') && !body.key.remoteJid.endsWith('@g.us')) {
const originalJid = body.key.remoteJid;
const resolvedPhone = await this.resolveLidToPhone(instance, body.key);

if (resolvedPhone && resolvedPhone !== originalJid) {
this.logger.verbose(`Event LID resolved: ${originalJid} → ${resolvedPhone}`);
body.key.remoteJid = resolvedPhone;

// Salva mapeamento se temos remoteJidAlt
if (body.key.remoteJidAlt) {
this.saveLidMapping(originalJid, body.key.remoteJidAlt);
}
} else if (body.key.remoteJidAlt && !body.key.remoteJidAlt.includes('@lid')) {
// Se não resolveu mas tem remoteJidAlt válido, usa ele
this.logger.verbose(`Using remoteJidAlt for event: ${originalJid} → ${body.key.remoteJidAlt}`);
body.key.remoteJid = body.key.remoteJidAlt;
this.saveLidMapping(originalJid, body.key.remoteJidAlt);
} else {
this.logger.warn(`Could not resolve LID for event, keeping original: ${originalJid}`);
}
}

if (event === 'messages.upsert' || event === 'send.message') {
this.logger.info(`[${event}] New message received - Instance: ${JSON.stringify(body, null, 2)}`);
if (body.key.remoteJid === 'status@broadcast') {
Expand Down Expand Up @@ -2537,6 +2663,82 @@ export class ChatwootService {
return remoteJid.replace(/:\d+/, '').split('@')[0];
}

/**
* Limpa entradas antigas do cache de mapeamento LID
*/
private cleanLidCache() {
const now = Date.now();
this.lidToPhoneMap.forEach((value, lid) => {
if (now - value.timestamp > this.LID_CACHE_TTL_MS) {
this.lidToPhoneMap.delete(lid);
}
});
}

/**
* Salva mapeamento LID → Número Normal
*/
private saveLidMapping(lid: string, phoneNumber: string) {
if (!lid || !phoneNumber || !lid.includes('@lid')) {
return;
}

this.cleanLidCache();
this.lidToPhoneMap.set(lid, {
phone: phoneNumber,
timestamp: Date.now(),
});

this.logger.verbose(`LID mapping saved: ${lid} → ${phoneNumber}`);
}

/**
* Resolve LID para Número Normal
* Retorna o número normal se encontrado, ou o LID original se não encontrado
*/
private async resolveLidToPhone(instance: InstanceDto, messageKey: any): Promise<string | null> {
const { remoteJid, remoteJidAlt } = messageKey;

// Se não for LID, retorna o próprio remoteJid
if (!remoteJid || !remoteJid.includes('@lid')) {
return remoteJid;
}

// 1. Tenta buscar no cache
const cached = this.lidToPhoneMap.get(remoteJid);
if (cached) {
this.logger.verbose(`LID resolved from cache: ${remoteJid} → ${cached.phone}`);
return cached.phone;
}

// 2. Se tem remoteJidAlt (número alternativo), usa ele e salva no cache
if (remoteJidAlt && !remoteJidAlt.includes('@lid')) {
this.saveLidMapping(remoteJid, remoteJidAlt);
this.logger.verbose(`LID resolved from remoteJidAlt: ${remoteJid} → ${remoteJidAlt}`);
return remoteJidAlt;
}

// 3. Tenta buscar no banco de dados do Chatwoot
try {
const lidIdentifier = this.normalizeJidIdentifier(remoteJid);
const contact = await this.findContactByIdentifier(instance, lidIdentifier);

if (contact && contact.phone_number) {
// Converte +554498860240 → [email protected]
const phoneNumber = contact.phone_number.replace('+', '') + '@s.whatsapp.net';
this.saveLidMapping(remoteJid, phoneNumber);
this.logger.verbose(`LID resolved from database: ${remoteJid} → ${phoneNumber}`);
return phoneNumber;
}
} catch (error) {
this.logger.warn(`Error resolving LID from database: ${error}`);
}

// 4. Se não encontrou, retorna null (será necessário criar novo contato)
this.logger.warn(`Could not resolve LID: ${remoteJid}`);
return null;
}

public startImportHistoryMessages(instance: InstanceDto) {
if (!this.isImportHistoryAvailable()) {
return;
Expand Down