Skip to content

Commit d56f312

Browse files
Merge pull request #2393 from ValdecirMysian/main
feat(chatwoot): add support for WhatsApp catalog orderMessage
2 parents 5283fdc + cfa475d commit d56f312

File tree

1 file changed

+282
-45
lines changed

1 file changed

+282
-45
lines changed

src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts

Lines changed: 282 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,14 @@ export class ChatwootService {
4949

5050
private provider: any;
5151

52+
// Cache para deduplicação de orderMessage (evita mensagens duplicadas)
53+
private processedOrderIds: Map<string, number> = new Map();
54+
private readonly ORDER_CACHE_TTL_MS = 30000; // 30 segundos
55+
56+
// Cache para mapeamento LID → Número Normal (resolve problema de @lid)
57+
private lidToPhoneMap: Map<string, { phone: string; timestamp: number }> = new Map();
58+
private readonly LID_CACHE_TTL_MS = 3600000; // 1 hora
59+
5260
constructor(
5361
private readonly waMonitor: WAMonitoringService,
5462
private readonly configService: ConfigService,
@@ -632,10 +640,32 @@ export class ChatwootService {
632640
public async createConversation(instance: InstanceDto, body: any) {
633641
const isLid = body.key.addressingMode === 'lid';
634642
const isGroup = body.key.remoteJid.endsWith('@g.us');
635-
const phoneNumber = isLid && !isGroup ? body.key.remoteJidAlt : body.key.remoteJid;
636-
const { remoteJid } = body.key;
637-
const cacheKey = `${instance.instanceName}:createConversation-${remoteJid}`;
638-
const lockKey = `${instance.instanceName}:lock:createConversation-${remoteJid}`;
643+
let phoneNumber = isLid && !isGroup ? body.key.remoteJidAlt : body.key.remoteJid;
644+
let { remoteJid } = body.key;
645+
646+
// CORREÇÃO LID: Resolve LID para número normal antes de processar
647+
if (isLid && !isGroup) {
648+
const resolvedPhone = await this.resolveLidToPhone(instance, body.key);
649+
650+
if (resolvedPhone && resolvedPhone !== remoteJid) {
651+
this.logger.verbose(`LID detected and resolved: ${remoteJid}${resolvedPhone}`);
652+
phoneNumber = resolvedPhone;
653+
654+
// Salva mapeamento se temos remoteJidAlt
655+
if (body.key.remoteJidAlt) {
656+
this.saveLidMapping(remoteJid, body.key.remoteJidAlt);
657+
}
658+
} else if (body.key.remoteJidAlt) {
659+
// Se não resolveu mas tem remoteJidAlt, usa ele
660+
phoneNumber = body.key.remoteJidAlt;
661+
this.saveLidMapping(remoteJid, body.key.remoteJidAlt);
662+
this.logger.verbose(`Using remoteJidAlt for LID: ${remoteJid}${phoneNumber}`);
663+
}
664+
}
665+
666+
// Usa phoneNumber como base para cache (não o LID)
667+
const cacheKey = `${instance.instanceName}:createConversation-${phoneNumber}`;
668+
const lockKey = `${instance.instanceName}:lock:createConversation-${phoneNumber}`;
639669
const maxWaitTime = 5000; // 5 seconds
640670
const client = await this.clientCw(instance);
641671
if (!client) return null;
@@ -943,20 +973,39 @@ export class ChatwootService {
943973

944974
const sourceReplyId = quotedMsg?.chatwootMessageId || null;
945975

976+
// Filtra valores null/undefined do content_attributes para evitar erro 406
977+
const filteredReplyToIds = Object.fromEntries(
978+
Object.entries(replyToIds).filter(([_, value]) => value != null)
979+
);
980+
981+
// Monta o objeto data, incluindo content_attributes apenas se houver dados válidos
982+
const messageData: any = {
983+
content: content,
984+
message_type: messageType,
985+
content_type: 'text', // Explicitamente define como texto para Chatwoot 4.x
986+
attachments: attachments,
987+
private: privateMessage || false,
988+
};
989+
990+
// Adiciona source_id apenas se existir
991+
if (sourceId) {
992+
messageData.source_id = sourceId;
993+
}
994+
995+
// Adiciona content_attributes apenas se houver dados válidos
996+
if (Object.keys(filteredReplyToIds).length > 0) {
997+
messageData.content_attributes = filteredReplyToIds;
998+
}
999+
1000+
// Adiciona source_reply_id apenas se existir
1001+
if (sourceReplyId) {
1002+
messageData.source_reply_id = sourceReplyId.toString();
1003+
}
1004+
9461005
const message = await client.messages.create({
9471006
accountId: this.provider.accountId,
9481007
conversationId: conversationId,
949-
data: {
950-
content: content,
951-
message_type: messageType,
952-
attachments: attachments,
953-
private: privateMessage || false,
954-
source_id: sourceId,
955-
content_attributes: {
956-
...replyToIds,
957-
},
958-
source_reply_id: sourceReplyId ? sourceReplyId.toString() : null,
959-
},
1008+
data: messageData,
9601009
});
9611010

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

1085-
if (replyToIds.in_reply_to || replyToIds.in_reply_to_external_id) {
1086-
const content = JSON.stringify({
1087-
...replyToIds,
1088-
});
1089-
data.append('content_attributes', content);
1134+
// Filtra valores null/undefined antes de enviar
1135+
const filteredReplyToIds = Object.fromEntries(
1136+
Object.entries(replyToIds).filter(([_, value]) => value != null)
1137+
);
1138+
1139+
if (Object.keys(filteredReplyToIds).length > 0) {
1140+
const contentAttrs = JSON.stringify(filteredReplyToIds);
1141+
data.append('content_attributes', contentAttrs);
10901142
}
10911143
}
10921144

@@ -1789,41 +1841,127 @@ export class ChatwootService {
17891841
}
17901842

17911843
private getTypeMessage(msg: any) {
1792-
const types = {
1793-
conversation: msg.conversation,
1794-
imageMessage: msg.imageMessage?.caption,
1795-
videoMessage: msg.videoMessage?.caption,
1796-
extendedTextMessage: msg.extendedTextMessage?.text,
1797-
messageContextInfo: msg.messageContextInfo?.stanzaId,
1798-
stickerMessage: undefined,
1799-
documentMessage: msg.documentMessage?.caption,
1800-
documentWithCaptionMessage: msg.documentWithCaptionMessage?.message?.documentMessage?.caption,
1801-
audioMessage: msg.audioMessage ? (msg.audioMessage.caption ?? '') : undefined,
1802-
contactMessage: msg.contactMessage?.vcard,
1803-
contactsArrayMessage: msg.contactsArrayMessage,
1804-
locationMessage: msg.locationMessage,
1805-
liveLocationMessage: msg.liveLocationMessage,
1806-
listMessage: msg.listMessage,
1807-
listResponseMessage: msg.listResponseMessage,
1808-
viewOnceMessageV2:
1809-
msg?.message?.viewOnceMessageV2?.message?.imageMessage?.url ||
1810-
msg?.message?.viewOnceMessageV2?.message?.videoMessage?.url ||
1811-
msg?.message?.viewOnceMessageV2?.message?.audioMessage?.url,
1812-
};
1813-
1814-
return types;
1815-
}
1844+
const types = {
1845+
conversation: msg.conversation,
1846+
imageMessage: msg.imageMessage?.caption,
1847+
videoMessage: msg.videoMessage?.caption,
1848+
extendedTextMessage: msg.extendedTextMessage?.text,
1849+
messageContextInfo: msg.messageContextInfo?.stanzaId,
1850+
stickerMessage: undefined,
1851+
documentMessage: msg.documentMessage?.caption,
1852+
documentWithCaptionMessage: msg.documentWithCaptionMessage?.message?.documentMessage?.caption,
1853+
audioMessage: msg.audioMessage ? (msg.audioMessage.caption ?? '') : undefined,
1854+
contactMessage: msg.contactMessage?.vcard,
1855+
contactsArrayMessage: msg.contactsArrayMessage,
1856+
locationMessage: msg.locationMessage,
1857+
liveLocationMessage: msg.liveLocationMessage,
1858+
listMessage: msg.listMessage,
1859+
listResponseMessage: msg.listResponseMessage,
1860+
orderMessage: msg.orderMessage,
1861+
quotedProductMessage: msg.contextInfo?.quotedMessage?.productMessage,
1862+
viewOnceMessageV2:
1863+
msg?.message?.viewOnceMessageV2?.message?.imageMessage?.url ||
1864+
msg?.message?.viewOnceMessageV2?.message?.videoMessage?.url ||
1865+
msg?.message?.viewOnceMessageV2?.message?.audioMessage?.url,
1866+
};
1867+
1868+
return types;
1869+
}
18161870

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

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

1822-
// Remove externalAdReplyBody| in Chatwoot (Already Have)
1876+
// Remove externalAdReplyBody| in Chatwoot
18231877
if (result && typeof result === 'string' && result.includes('externalAdReplyBody|')) {
18241878
result = result.split('externalAdReplyBody|').filter(Boolean).join('');
18251879
}
18261880

1881+
// Tratamento de Pedidos do Catálogo (WhatsApp Business Catalog)
1882+
if (typeKey === 'orderMessage' && result.orderId) {
1883+
const now = Date.now();
1884+
// Limpa entradas antigas do cache
1885+
this.processedOrderIds.forEach((timestamp, id) => {
1886+
if (now - timestamp > this.ORDER_CACHE_TTL_MS) {
1887+
this.processedOrderIds.delete(id);
1888+
}
1889+
});
1890+
// Verifica se já processou este orderId
1891+
if (this.processedOrderIds.has(result.orderId)) {
1892+
return undefined; // Ignora duplicado
1893+
}
1894+
this.processedOrderIds.set(result.orderId, now);
1895+
}
1896+
// Tratamento de Produto citado (WhatsApp Desktop)
1897+
if (typeKey === 'quotedProductMessage' && result?.product) {
1898+
const product = result.product;
1899+
1900+
// Extrai preço
1901+
let rawPrice = 0;
1902+
const amount = product.priceAmount1000;
1903+
1904+
if (Long.isLong(amount)) {
1905+
rawPrice = amount.toNumber();
1906+
} else if (amount && typeof amount === 'object' && 'low' in amount) {
1907+
rawPrice = Long.fromValue(amount).toNumber();
1908+
} else if (typeof amount === 'number') {
1909+
rawPrice = amount;
1910+
}
1911+
1912+
const price = (rawPrice / 1000).toLocaleString('pt-BR', {
1913+
style: 'currency',
1914+
currency: product.currencyCode || 'BRL',
1915+
});
1916+
1917+
const productTitle = product.title || 'Produto do catálogo';
1918+
const productId = product.productId || 'N/A';
1919+
1920+
return (
1921+
`🛒 *PRODUTO DO CATÁLOGO (Desktop)*\n` +
1922+
`━━━━━━━━━━━━━━━━━━━━━\n` +
1923+
`📦 *Produto:* ${productTitle}\n` +
1924+
`💰 *Preço:* ${price}\n` +
1925+
`🆔 *Código:* ${productId}\n` +
1926+
`━━━━━━━━━━━━━━━━━━━━━\n` +
1927+
`_Cliente perguntou: "${types.conversation || 'Me envia este produto?'}"_`
1928+
);
1929+
}
1930+
if (typeKey === 'orderMessage') {
1931+
// Extrai o valor - pode ser Long, objeto {low, high}, ou número direto
1932+
let rawPrice = 0;
1933+
const amount = result.totalAmount1000;
1934+
1935+
if (Long.isLong(amount)) {
1936+
rawPrice = amount.toNumber();
1937+
} else if (amount && typeof amount === 'object' && 'low' in amount) {
1938+
// Formato {low: number, high: number, unsigned: boolean}
1939+
rawPrice = Long.fromValue(amount).toNumber();
1940+
} else if (typeof amount === 'number') {
1941+
rawPrice = amount;
1942+
}
1943+
1944+
const price = (rawPrice / 1000).toLocaleString('pt-BR', {
1945+
style: 'currency',
1946+
currency: result.totalCurrencyCode || 'BRL',
1947+
});
1948+
1949+
const itemCount = result.itemCount || 1;
1950+
const orderTitle = result.orderTitle || 'Produto do catálogo';
1951+
const orderId = result.orderId || 'N/A';
1952+
1953+
return (
1954+
`🛒 *NOVO PEDIDO NO CATÁLOGO*\n` +
1955+
`━━━━━━━━━━━━━━━━━━━━━\n` +
1956+
`📦 *Produto:* ${orderTitle}\n` +
1957+
`📊 *Quantidade:* ${itemCount}\n` +
1958+
`💰 *Total:* ${price}\n` +
1959+
`🆔 *Pedido:* #${orderId}\n` +
1960+
`━━━━━━━━━━━━━━━━━━━━━\n` +
1961+
`_Responda para atender este pedido!_`
1962+
);
1963+
}
1964+
18271965
if (typeKey === 'locationMessage' || typeKey === 'liveLocationMessage') {
18281966
const latitude = result.degreesLatitude;
18291967
const longitude = result.degreesLongitude;
@@ -2024,6 +2162,29 @@ export class ChatwootService {
20242162
}
20252163
}
20262164

2165+
// CORREÇÃO LID: Resolve LID para número normal antes de processar evento
2166+
if (body?.key?.remoteJid && body.key.remoteJid.includes('@lid') && !body.key.remoteJid.endsWith('@g.us')) {
2167+
const originalJid = body.key.remoteJid;
2168+
const resolvedPhone = await this.resolveLidToPhone(instance, body.key);
2169+
2170+
if (resolvedPhone && resolvedPhone !== originalJid) {
2171+
this.logger.verbose(`Event LID resolved: ${originalJid}${resolvedPhone}`);
2172+
body.key.remoteJid = resolvedPhone;
2173+
2174+
// Salva mapeamento se temos remoteJidAlt
2175+
if (body.key.remoteJidAlt) {
2176+
this.saveLidMapping(originalJid, body.key.remoteJidAlt);
2177+
}
2178+
} else if (body.key.remoteJidAlt && !body.key.remoteJidAlt.includes('@lid')) {
2179+
// Se não resolveu mas tem remoteJidAlt válido, usa ele
2180+
this.logger.verbose(`Using remoteJidAlt for event: ${originalJid}${body.key.remoteJidAlt}`);
2181+
body.key.remoteJid = body.key.remoteJidAlt;
2182+
this.saveLidMapping(originalJid, body.key.remoteJidAlt);
2183+
} else {
2184+
this.logger.warn(`Could not resolve LID for event, keeping original: ${originalJid}`);
2185+
}
2186+
}
2187+
20272188
if (event === 'messages.upsert' || event === 'send.message') {
20282189
this.logger.info(`[${event}] New message received - Instance: ${JSON.stringify(body, null, 2)}`);
20292190
if (body.key.remoteJid === 'status@broadcast') {
@@ -2568,6 +2729,82 @@ export class ChatwootService {
25682729
return remoteJid.replace(/:\d+/, '').split('@')[0];
25692730
}
25702731

2732+
/**
2733+
* Limpa entradas antigas do cache de mapeamento LID
2734+
*/
2735+
private cleanLidCache() {
2736+
const now = Date.now();
2737+
this.lidToPhoneMap.forEach((value, lid) => {
2738+
if (now - value.timestamp > this.LID_CACHE_TTL_MS) {
2739+
this.lidToPhoneMap.delete(lid);
2740+
}
2741+
});
2742+
}
2743+
2744+
/**
2745+
* Salva mapeamento LID → Número Normal
2746+
*/
2747+
private saveLidMapping(lid: string, phoneNumber: string) {
2748+
if (!lid || !phoneNumber || !lid.includes('@lid')) {
2749+
return;
2750+
}
2751+
2752+
this.cleanLidCache();
2753+
this.lidToPhoneMap.set(lid, {
2754+
phone: phoneNumber,
2755+
timestamp: Date.now(),
2756+
});
2757+
2758+
this.logger.verbose(`LID mapping saved: ${lid}${phoneNumber}`);
2759+
}
2760+
2761+
/**
2762+
* Resolve LID para Número Normal
2763+
* Retorna o número normal se encontrado, ou o LID original se não encontrado
2764+
*/
2765+
private async resolveLidToPhone(instance: InstanceDto, messageKey: any): Promise<string | null> {
2766+
const { remoteJid, remoteJidAlt } = messageKey;
2767+
2768+
// Se não for LID, retorna o próprio remoteJid
2769+
if (!remoteJid || !remoteJid.includes('@lid')) {
2770+
return remoteJid;
2771+
}
2772+
2773+
// 1. Tenta buscar no cache
2774+
const cached = this.lidToPhoneMap.get(remoteJid);
2775+
if (cached) {
2776+
this.logger.verbose(`LID resolved from cache: ${remoteJid}${cached.phone}`);
2777+
return cached.phone;
2778+
}
2779+
2780+
// 2. Se tem remoteJidAlt (número alternativo), usa ele e salva no cache
2781+
if (remoteJidAlt && !remoteJidAlt.includes('@lid')) {
2782+
this.saveLidMapping(remoteJid, remoteJidAlt);
2783+
this.logger.verbose(`LID resolved from remoteJidAlt: ${remoteJid}${remoteJidAlt}`);
2784+
return remoteJidAlt;
2785+
}
2786+
2787+
// 3. Tenta buscar no banco de dados do Chatwoot
2788+
try {
2789+
const lidIdentifier = this.normalizeJidIdentifier(remoteJid);
2790+
const contact = await this.findContactByIdentifier(instance, lidIdentifier);
2791+
2792+
if (contact && contact.phone_number) {
2793+
// Converte +554498860240 → 554498860240@s.whatsapp.net
2794+
const phoneNumber = contact.phone_number.replace('+', '') + '@s.whatsapp.net';
2795+
this.saveLidMapping(remoteJid, phoneNumber);
2796+
this.logger.verbose(`LID resolved from database: ${remoteJid}${phoneNumber}`);
2797+
return phoneNumber;
2798+
}
2799+
} catch (error) {
2800+
this.logger.warn(`Error resolving LID from database: ${error}`);
2801+
}
2802+
2803+
// 4. Se não encontrou, retorna null (será necessário criar novo contato)
2804+
this.logger.warn(`Could not resolve LID: ${remoteJid}`);
2805+
return null;
2806+
}
2807+
25712808
public startImportHistoryMessages(instance: InstanceDto) {
25722809
if (!this.isImportHistoryAvailable()) {
25732810
return;

0 commit comments

Comments
 (0)