diff --git a/package-lock.json b/package-lock.json index ddb79c32..032f4064 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "amqplib": "^0.10.5", "audio-decode": "^2.2.3", "axios": "^1.7.9", - "baileys": "^7.0.0-rc.5", + "baileys": "github:WhiskeySockets/Baileys#master", "class-validator": "^0.14.1", "compression": "^1.7.5", "cors": "^2.8.5", @@ -70,8 +70,6 @@ "devDependencies": { "@commitlint/cli": "^19.8.1", "@commitlint/config-conventional": "^19.8.1", - "@swc/core": "^1.13.5", - "@swc/helpers": "^0.5.17", "@types/compression": "^1.7.5", "@types/cors": "^2.8.17", "@types/express": "^4.17.18", @@ -4397,8 +4395,9 @@ "version": "1.13.5", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.5.tgz", "integrity": "sha512-WezcBo8a0Dg2rnR82zhwoR6aRNxeTGfK5QCD6TQ+kg3xx/zNT02s/0o+81h/3zhvFSB24NtqEr8FTw88O5W/JQ==", - "devOptional": true, "hasInstallScript": true, + "optional": true, + "peer": true, "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.24" @@ -4438,11 +4437,11 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=10" } @@ -4454,11 +4453,11 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=10" } @@ -4470,11 +4469,11 @@ "cpu": [ "arm" ], - "dev": true, "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -4486,11 +4485,11 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -4502,11 +4501,11 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -4518,11 +4517,11 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -4534,11 +4533,11 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -4550,11 +4549,11 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=10" } @@ -4566,11 +4565,11 @@ "cpu": [ "ia32" ], - "dev": true, "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=10" } @@ -4582,11 +4581,11 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=10" } @@ -4595,13 +4594,15 @@ "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "devOptional": true + "optional": true, + "peer": true }, "node_modules/@swc/helpers": { "version": "0.5.17", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", - "dev": true, + "optional": true, + "peer": true, "dependencies": { "tslib": "^2.8.0" } @@ -4610,7 +4611,8 @@ "version": "0.1.25", "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", - "devOptional": true, + "optional": true, + "peer": true, "dependencies": { "@swc/counter": "^0.1.3" } @@ -5647,14 +5649,14 @@ }, "node_modules/baileys": { "version": "7.0.0-rc.5", - "resolved": "https://registry.npmjs.org/baileys/-/baileys-7.0.0-rc.5.tgz", - "integrity": "sha512-y95gW7UtKbD4dQb46G75rnr0U0LtnBItA002ARggDiCgm92Z8wnM+wxqC8OI/sDFanz3TgzqE4t7MPwNusUqUQ==", + "resolved": "git+ssh://git@github.com/WhiskeySockets/Baileys.git#928fa6ac6669f1621da2ba033192f3661d9c05d0", "hasInstallScript": true, + "license": "MIT", "dependencies": { "@cacheable/node-cache": "^1.4.0", "@hapi/boom": "^9.1.3", "async-mutex": "^0.5.0", - "libsignal": "git+https://github.com/whiskeysockets/libsignal-node.git", + "libsignal": "git+https://github.com/whiskeysockets/libsignal-node", "lru-cache": "^11.1.0", "music-metadata": "^11.7.0", "p-queue": "^9.0.0", diff --git a/package.json b/package.json index b3e2b827..6ad80205 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "amqplib": "^0.10.5", "audio-decode": "^2.2.3", "axios": "^1.7.9", - "baileys": "^7.0.0-rc.5", + "baileys": "github:WhiskeySockets/Baileys#master", "class-validator": "^0.14.1", "compression": "^1.7.5", "cors": "^2.8.5", diff --git a/src/api/integrations/channel/whatsapp/baileysMessage.processor.ts b/src/api/integrations/channel/whatsapp/baileysMessage.processor.ts index 79fedd93..c2c5931e 100644 --- a/src/api/integrations/channel/whatsapp/baileysMessage.processor.ts +++ b/src/api/integrations/channel/whatsapp/baileysMessage.processor.ts @@ -1,5 +1,5 @@ import { Logger } from '@config/logger.config'; -import { BaileysEventMap, MessageUpsertType, proto } from 'baileys'; +import { BaileysEventMap, MessageUpsertType, WAMessage } from 'baileys'; import { catchError, concatMap, delay, EMPTY, from, retryWhen, Subject, Subscription, take, tap } from 'rxjs'; type MessageUpsertPayload = BaileysEventMap['messages.upsert']; @@ -12,7 +12,7 @@ export class BaileysMessageProcessor { private subscription?: Subscription; protected messageSubject = new Subject<{ - messages: proto.IWebMessageInfo[]; + messages: WAMessage[]; type: MessageUpsertType; requestId?: string; settings: any; diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 6986bb29..87c218dd 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -133,7 +133,6 @@ import { Label } from 'baileys/lib/Types/Label'; import { LabelAssociation } from 'baileys/lib/Types/LabelAssociation'; import { spawn } from 'child_process'; import { isArray, isBase64, isURL } from 'class-validator'; -import { randomBytes } from 'crypto'; import EventEmitter2 from 'eventemitter2'; import ffmpeg from 'fluent-ffmpeg'; import FormData from 'form-data'; @@ -876,6 +875,7 @@ export class BaileysStartupService extends ChannelStartupService { 'contacts.update': async (contacts: Partial[]) => { const contactsRaw: { remoteJid: string; pushName?: string; profilePicUrl?: string; instanceId: string }[] = []; for await (const contact of contacts) { + this.logger.debug(`Updating contact: ${JSON.stringify(contact, null, 2)}`); contactsRaw.push({ remoteJid: contact.id, pushName: contact?.name ?? contact?.verifiedName, @@ -895,10 +895,7 @@ export class BaileysStartupService extends ChannelStartupService { ); await this.prismaRepository.$transaction(updateTransactions); - const usersContacts = contactsRaw.filter((c) => c.remoteJid.includes('@s.whatsapp')); - if (usersContacts) { - await saveOnWhatsappCache(usersContacts.map((c) => ({ remoteJid: c.remoteJid }))); - } + //const usersContacts = contactsRaw.filter((c) => c.remoteJid.includes('@s.whatsapp')); }, }; @@ -1136,7 +1133,7 @@ export class BaileysStartupService extends ChannelStartupService { const messageKey = `${this.instance.id}_${received.key.id}`; const cached = await this.baileysCache.get(messageKey); - if (cached && !editedMessage) { + if (cached && !editedMessage && !requestId) { this.logger.info(`Message duplicated ignored: ${received.key.id}`); continue; } @@ -1349,7 +1346,7 @@ export class BaileysStartupService extends ChannelStartupService { } } - this.logger.log(messageRaw); + this.logger.verbose(messageRaw); sendTelemetry(`received.message.${messageRaw.messageType ?? 'unknown'}`); @@ -1366,7 +1363,12 @@ export class BaileysStartupService extends ChannelStartupService { where: { remoteJid: received.key.remoteJid, instanceId: this.instanceId }, }); - const contactRaw: { remoteJid: string; pushName: string; profilePicUrl?: string; instanceId: string } = { + const contactRaw: { + remoteJid: string; + pushName: string; + profilePicUrl?: string; + instanceId: string; + } = { remoteJid: received.key.remoteJid, pushName: received.key.fromMe ? '' : received.key.fromMe == null ? '' : received.pushName, profilePicUrl: (await this.profilePicture(received.key.remoteJid)).profilePictureUrl, @@ -1377,6 +1379,17 @@ export class BaileysStartupService extends ChannelStartupService { continue; } + if (contactRaw.remoteJid.includes('@s.whatsapp') || contactRaw.remoteJid.includes('@lid')) { + await saveOnWhatsappCache([ + { + remoteJid: + messageRaw.key.addressingMode === 'lid' ? messageRaw.key.remoteJidAlt : messageRaw.key.remoteJid, + remoteJidAlt: messageRaw.key.remoteJidAlt, + lid: messageRaw.key.addressingMode === 'lid' ? 'lid' : null, + }, + ]); + } + if (contact) { this.sendDataWebhook(Events.CONTACTS_UPDATE, contactRaw); @@ -1406,10 +1419,6 @@ export class BaileysStartupService extends ChannelStartupService { update: contactRaw, create: contactRaw, }); - - if (contactRaw.remoteJid.includes('@s.whatsapp')) { - await saveOnWhatsappCache([{ remoteJid: contactRaw.remoteJid }]); - } } } catch (error) { this.logger.error(error); @@ -1417,7 +1426,7 @@ export class BaileysStartupService extends ChannelStartupService { }, 'messages.update': async (args: { update: Partial; key: WAMessageKey }[], settings: any) => { - this.logger.log(`Update messages ${JSON.stringify(args, undefined, 2)}`); + this.logger.verbose(`Update messages ${JSON.stringify(args, undefined, 2)}`); const readChatToUpdate: Record = {}; // {remoteJid: true} @@ -1798,7 +1807,7 @@ export class BaileysStartupService extends ChannelStartupService { } if (events['group-participants.update']) { - const payload = events['group-participants.update']; + const payload = events['group-participants.update'] as any; this.groupHandler['group-participants.update'](payload); } } @@ -1966,6 +1975,7 @@ export class BaileysStartupService extends ChannelStartupService { quoted: any, messageId?: string, ephemeralExpiration?: number, + contextInfo?: any, // participants?: GroupParticipant[], ) { sender = sender.toLowerCase(); @@ -1982,8 +1992,8 @@ export class BaileysStartupService extends ChannelStartupService { if (ephemeralExpiration) option.ephemeralExpiration = ephemeralExpiration; + // NOTE: NÃO DEVEMOS GERAR O messageId AQUI, SOMENTE SE VIER INFORMADO POR PARAMETRO. A GERAÇÃO ANTERIOR IMPEDE O WZAP DE IDENTIFICAR A SOURCE. if (messageId) option.messageId = messageId; - else option.messageId = '3EB0' + randomBytes(18).toString('hex').toUpperCase(); if (message['viewOnceMessage']) { const m = generateWAMessageFromContent(sender, message, { @@ -2020,10 +2030,19 @@ export class BaileysStartupService extends ChannelStartupService { } } + if (contextInfo) { + message['contextInfo'] = contextInfo; + } + if (message['conversation']) { return await this.client.sendMessage( sender, - { text: message['conversation'], mentions, linkPreview: linkPreview } as unknown as AnyMessageContent, + { + text: message['conversation'], + mentions, + linkPreview: linkPreview, + contextInfo: message['contextInfo'], + } as unknown as AnyMessageContent, option as unknown as MiscMessageGenerationOptions, ); } @@ -2031,7 +2050,11 @@ export class BaileysStartupService extends ChannelStartupService { if (!message['audio'] && !message['poll'] && !message['sticker'] && sender != 'status@broadcast') { return await this.client.sendMessage( sender, - { forward: { key: { remoteJid: this.instance.wuid, fromMe: true }, message }, mentions }, + { + forward: { key: { remoteJid: this.instance.wuid, fromMe: true }, message }, + mentions, + contextInfo: message['contextInfo'], + }, option as unknown as MiscMessageGenerationOptions, ); } @@ -2162,7 +2185,7 @@ export class BaileysStartupService extends ChannelStartupService { if (options?.quoted) { const m = options?.quoted; - const msg = m?.message ? m : ((await this.getMessage(m.key, true)) as proto.IWebMessageInfo); + const msg = m?.message ? m : ((await this.getMessage(m.key, true)) as WAMessage); if (msg) { quoted = msg; @@ -2172,6 +2195,8 @@ export class BaileysStartupService extends ChannelStartupService { let messageSent: WAMessage; let mentions: string[]; + let contextInfo: any; + if (isJidGroup(sender)) { let group; try { @@ -2210,7 +2235,27 @@ export class BaileysStartupService extends ChannelStartupService { // group?.participants, ); } else { - messageSent = await this.sendMessage(sender, message, mentions, linkPreview, quoted); + contextInfo = { + mentionedJid: [], + groupMentions: [], + //expiration: 7776000, + ephemeralSettingTimestamp: { + low: Math.floor(Date.now() / 1000) - 172800, + high: 0, + unsigned: false, + }, + disappearingMode: { initiator: 0 }, + }; + messageSent = await this.sendMessage( + sender, + message, + mentions, + linkPreview, + quoted, + null, + undefined, + contextInfo, + ); } if (Long.isLong(messageSent?.messageTimestamp)) { @@ -2330,7 +2375,7 @@ export class BaileysStartupService extends ChannelStartupService { } } - this.logger.log(messageRaw); + this.logger.verbose(JSON.stringify(messageSent, null, 2)); this.sendDataWebhook(Events.SEND_MESSAGE, messageRaw); @@ -3337,120 +3382,128 @@ export class BaileysStartupService extends ChannelStartupService { where: { instanceId: this.instanceId, remoteJid: { in: jids.users.map(({ jid }) => jid) } }, }); - // Separate @lid numbers from normal numbers - const lidUsers = jids.users.filter(({ jid }) => jid.includes('@lid')); - const normalUsers = jids.users.filter(({ jid }) => !jid.includes('@lid')); + // TODO: Unificar verificação de cache para todos os números (normais e LID) + const numbersToVerify = jids.users.map(({ jid }) => jid.replace('+', '')); - // For normal numbers, use traditional Baileys verification - let normalVerifiedUsers: OnWhatsAppDto[] = []; - if (normalUsers.length > 0) { - console.log('normalUsers', normalUsers); - const numbersToVerify = normalUsers.map(({ jid }) => jid.replace('+', '')); - console.log('numbersToVerify', numbersToVerify); + // TODO: Busca TODOS os números no cache + const cachedNumbers = await getOnWhatsappCache(numbersToVerify); - const cachedNumbers = await getOnWhatsappCache(numbersToVerify); - console.log('cachedNumbers', cachedNumbers); + // Separa números que estão e não estão no cache + const cachedJids = new Set(cachedNumbers.flatMap((cached) => cached.jidOptions)); + const numbersNotInCache = numbersToVerify.filter((jid) => !cachedJids.has(jid)); - const filteredNumbers = numbersToVerify.filter( - (jid) => !cachedNumbers.some((cached) => cached.jidOptions.includes(jid)), - ); - console.log('filteredNumbers', filteredNumbers); + // TODO: Só chama Baileys para números normais (@s.whatsapp.net) que não estão no cache + let verify: { jid: string; exists: boolean }[] = []; + const normalNumbersNotInCache = numbersNotInCache.filter((jid) => !jid.includes('@lid')); - const verify = await this.client.onWhatsApp(...filteredNumbers); - console.log('verify', verify); - normalVerifiedUsers = await Promise.all( - normalUsers.map(async (user) => { - let numberVerified: (typeof verify)[0] | null = null; - - const cached = cachedNumbers.find((cached) => cached.jidOptions.includes(user.jid.replace('+', ''))); - if (cached) { - return new OnWhatsAppDto( - cached.remoteJid, - true, - user.number, - contacts.find((c) => c.remoteJid === cached.remoteJid)?.pushName, - cached.lid || (cached.remoteJid.includes('@lid') ? cached.remoteJid.split('@')[1] : undefined), - ); - } - - // Brazilian numbers - if (user.number.startsWith('55')) { - const numberWithDigit = - user.number.slice(4, 5) === '9' && user.number.length === 13 - ? user.number - : `${user.number.slice(0, 4)}9${user.number.slice(4)}`; - const numberWithoutDigit = - user.number.length === 12 ? user.number : user.number.slice(0, 4) + user.number.slice(5); - - numberVerified = verify.find( - (v) => v.jid === `${numberWithDigit}@s.whatsapp.net` || v.jid === `${numberWithoutDigit}@s.whatsapp.net`, - ); - } - - // Mexican/Argentina numbers - // Ref: https://faq.whatsapp.com/1294841057948784 - if (!numberVerified && (user.number.startsWith('52') || user.number.startsWith('54'))) { - let prefix = ''; - if (user.number.startsWith('52')) { - prefix = '1'; - } - if (user.number.startsWith('54')) { - prefix = '9'; - } - - const numberWithDigit = - user.number.slice(2, 3) === prefix && user.number.length === 13 - ? user.number - : `${user.number.slice(0, 2)}${prefix}${user.number.slice(2)}`; - const numberWithoutDigit = - user.number.length === 12 ? user.number : user.number.slice(0, 2) + user.number.slice(3); - - numberVerified = verify.find( - (v) => v.jid === `${numberWithDigit}@s.whatsapp.net` || v.jid === `${numberWithoutDigit}@s.whatsapp.net`, - ); - } - - if (!numberVerified) { - numberVerified = verify.find((v) => v.jid === user.jid); - } - - const numberJid = numberVerified?.jid || user.jid; - - return new OnWhatsAppDto( - numberJid, - !!numberVerified?.exists, - user.number, - contacts.find((c) => c.remoteJid === numberJid)?.pushName, - undefined, - ); - }), - ); + if (normalNumbersNotInCache.length > 0) { + this.logger.verbose(`Checking ${normalNumbersNotInCache.length} numbers via Baileys (not found in cache)`); + verify = await this.client.onWhatsApp(...normalNumbersNotInCache); } - // For @lid numbers, always consider them as valid - const lidVerifiedUsers: OnWhatsAppDto[] = lidUsers.map((user) => { - return new OnWhatsAppDto( - user.jid, - true, - user.number, - contacts.find((c) => c.remoteJid === user.jid)?.pushName, - user.jid.split('@')[1], - ); - }); + const verifiedUsers = await Promise.all( + jids.users.map(async (user) => { + // TODO: Tenta pegar do cache primeiro (funciona para todos: normais e LID) + const cached = cachedNumbers.find((cached) => cached.jidOptions.includes(user.jid.replace('+', ''))); + + if (cached) { + this.logger.verbose(`Number ${user.number} found in cache`); + return new OnWhatsAppDto( + cached.remoteJid, + true, + user.number, + contacts.find((c) => c.remoteJid === cached.remoteJid)?.pushName, + cached.lid || (cached.remoteJid.includes('@lid') ? 'lid' : undefined), + ); + } + + // TODO: Se é número LID e não está no cache, considerar como válido + if (user.jid.includes('@lid')) { + return new OnWhatsAppDto( + user.jid, + true, + user.number, + contacts.find((c) => c.remoteJid === user.jid)?.pushName, + 'lid', + ); + } + + // TODO: Se não está no cache e é número normal, usa a verificação do Baileys + let numberVerified: (typeof verify)[0] | null = null; + + // Brazilian numbers + if (user.number.startsWith('55')) { + const numberWithDigit = + user.number.slice(4, 5) === '9' && user.number.length === 13 + ? user.number + : `${user.number.slice(0, 4)}9${user.number.slice(4)}`; + const numberWithoutDigit = + user.number.length === 12 ? user.number : user.number.slice(0, 4) + user.number.slice(5); + + numberVerified = verify.find( + (v) => v.jid === `${numberWithDigit}@s.whatsapp.net` || v.jid === `${numberWithoutDigit}@s.whatsapp.net`, + ); + } + + // Mexican/Argentina numbers + // Ref: https://faq.whatsapp.com/1294841057948784 + if (!numberVerified && (user.number.startsWith('52') || user.number.startsWith('54'))) { + let prefix = ''; + if (user.number.startsWith('52')) { + prefix = '1'; + } + if (user.number.startsWith('54')) { + prefix = '9'; + } + + const numberWithDigit = + user.number.slice(2, 3) === prefix && user.number.length === 13 + ? user.number + : `${user.number.slice(0, 2)}${prefix}${user.number.slice(2)}`; + const numberWithoutDigit = + user.number.length === 12 ? user.number : user.number.slice(0, 2) + user.number.slice(3); + + numberVerified = verify.find( + (v) => v.jid === `${numberWithDigit}@s.whatsapp.net` || v.jid === `${numberWithoutDigit}@s.whatsapp.net`, + ); + } + + if (!numberVerified) { + numberVerified = verify.find((v) => v.jid === user.jid); + } + + const numberJid = numberVerified?.jid || user.jid; + + return new OnWhatsAppDto( + numberJid, + !!numberVerified?.exists, + user.number, + contacts.find((c) => c.remoteJid === numberJid)?.pushName, + undefined, + ); + }), + ); // Combine results - onWhatsapp.push(...normalVerifiedUsers, ...lidVerifiedUsers); + onWhatsapp.push(...verifiedUsers); - // Save to cache only valid numbers - await saveOnWhatsappCache( - onWhatsapp - .filter((user) => user.exists) - .map((user) => ({ + // TODO: Salvar no cache apenas números que NÃO estavam no cache + const numbersToCache = onWhatsapp.filter((user) => { + if (!user.exists) return false; + // Verifica se estava no cache usando jidOptions + const cached = cachedNumbers?.find((cached) => cached.jidOptions.includes(user.jid.replace('+', ''))); + return !cached; + }); + + if (numbersToCache.length > 0) { + this.logger.verbose(`Salvando ${numbersToCache.length} números no cache`); + await saveOnWhatsappCache( + numbersToCache.map((user) => ({ remoteJid: user.jid, - jidOptions: user.jid.replace('+', ''), - lid: user.lid, + lid: user.lid === 'lid' ? 'lid' : undefined, })), - ); + ); + } return onWhatsapp; } @@ -3633,10 +3686,7 @@ export class BaileysStartupService extends ChannelStartupService { } } - if ( - Object.keys(msg.message).length === 1 && - Object.prototype.hasOwnProperty.call(msg.message, 'messageContextInfo') - ) { + if ('messageContextInfo' in msg.message && Object.keys(msg.message).length === 1) { throw 'The message is messageContextInfo'; } diff --git a/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts b/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts index a50fce38..e698b767 100644 --- a/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts +++ b/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts @@ -23,7 +23,7 @@ import { Chatwoot as ChatwootModel, Contact as ContactModel, Message as MessageM import i18next from '@utils/i18n'; import { sendTelemetry } from '@utils/sendTelemetry'; import axios from 'axios'; -import { proto, WAMessageKey } from 'baileys'; +import { WAMessageContent, WAMessageKey } from 'baileys'; import dayjs from 'dayjs'; import FormData from 'form-data'; import { Jimp, JimpMime } from 'jimp'; @@ -32,8 +32,6 @@ import mimeTypes from 'mime-types'; import path from 'path'; import { Readable } from 'stream'; -const MIN_CONNECTION_NOTIFICATION_INTERVAL_MS = 30000; // 30 seconds - interface ChatwootMessage { messageId?: number; inboxId?: number; @@ -45,22 +43,6 @@ interface ChatwootMessage { export class ChatwootService { private readonly logger = new Logger('ChatwootService'); - // HTTP timeout constants - private readonly MEDIA_DOWNLOAD_TIMEOUT_MS = 60000; // 60 seconds for large files - - // S3/MinIO retry configuration (external storage - longer delays, fewer retries) - private readonly S3_MAX_RETRIES = 3; - private readonly S3_BASE_DELAY_MS = 1000; // Base delay: 1 second - private readonly S3_MAX_DELAY_MS = 8000; // Max delay: 8 seconds - - // Database polling retry configuration (internal DB - shorter delays, more retries) - private readonly DB_POLLING_MAX_RETRIES = 5; - private readonly DB_POLLING_BASE_DELAY_MS = 100; // Base delay: 100ms - private readonly DB_POLLING_MAX_DELAY_MS = 2000; // Max delay: 2 seconds - - // Webhook processing delay - private readonly WEBHOOK_INITIAL_DELAY_MS = 500; // Initial delay before processing webhook - // Lock polling delay private readonly LOCK_POLLING_DELAY_MS = 300; // Delay between lock status checks @@ -588,8 +570,10 @@ export class ChatwootService { } public async createConversation(instance: InstanceDto, body: any) { - const isLid = body.key.addressingMode === 'lid' && body.key.remoteJidAlt; - const remoteJid = isLid ? body.key.remoteJidAlt : body.key.remoteJid; + 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.remoteJid; const cacheKey = `${instance.instanceName}:createConversation-${remoteJid}`; const lockKey = `${instance.instanceName}:lock:createConversation-${remoteJid}`; const maxWaitTime = 5000; // 5 seconds @@ -598,19 +582,19 @@ export class ChatwootService { try { // Processa atualização de contatos já criados @lid - if (isLid && body.key.remoteJidAlt !== body.key.remoteJid) { - const contact = await this.findContact(instance, body.key.remoteJid.split('@')[0]); - if (contact && contact.identifier !== body.key.remoteJidAlt) { + if (phoneNumber && remoteJid && !isGroup) { + const contact = await this.findContact(instance, phoneNumber.split('@')[0]); + if (contact && contact.identifier !== remoteJid) { this.logger.verbose( - `Identifier needs update: (contact.identifier: ${contact.identifier}, body.key.remoteJid: ${body.key.remoteJid}, body.key.remoteJidAlt: ${body.key.remoteJidAlt}`, + `Identifier needs update: (contact.identifier: ${contact.identifier}, phoneNumber: ${phoneNumber}, body.key.remoteJidAlt: ${remoteJid}`, ); const updateContact = await this.updateContact(instance, contact.id, { - identifier: body.key.remoteJidAlt, - phone_number: `+${body.key.remoteJidAlt.split('@')[0]}`, + identifier: remoteJid, + phone_number: `+${phoneNumber.split('@')[0]}`, }); if (updateContact === null) { - const baseContact = await this.findContact(instance, body.key.remoteJidAlt.split('@')[0]); + const baseContact = await this.findContact(instance, phoneNumber.split('@')[0]); if (baseContact) { await this.mergeContacts(baseContact.id, contact.id); this.logger.verbose( @@ -626,7 +610,7 @@ export class ChatwootService { // If it already exists in the cache, return conversationId if (await this.cache.has(cacheKey)) { const conversationId = (await this.cache.get(cacheKey)) as number; - this.logger.verbose(`Found conversation to: ${remoteJid}, conversation ID: ${conversationId}`); + this.logger.verbose(`Found conversation to: ${phoneNumber}, conversation ID: ${conversationId}`); let conversationExists: conversation | boolean; try { conversationExists = await client.conversations.get({ @@ -677,8 +661,7 @@ export class ChatwootService { return (await this.cache.get(cacheKey)) as number; } - const isGroup = remoteJid.includes('@g.us'); - const chatId = isGroup ? remoteJid : remoteJid.split('@')[0].split(':')[0]; + const chatId = isGroup ? remoteJid : phoneNumber.split('@')[0].split(':')[0]; let nameContact = !body.key.fromMe ? body.pushName : chatId; const filterInbox = await this.getInbox(instance); if (!filterInbox) return null; @@ -688,14 +671,15 @@ export class ChatwootService { const group = await this.waMonitor.waInstances[instance.instanceName].client.groupMetadata(chatId); this.logger.verbose(`Group metadata: ${JSON.stringify(group)}`); + const participantJid = isLid && !body.key.fromMe ? body.key.participantAlt : body.key.participant; nameContact = `${group.subject} (GROUP)`; const picture_url = await this.waMonitor.waInstances[instance.instanceName].profilePicture( - body.key.participant.split('@')[0], + participantJid.split('@')[0], ); this.logger.verbose(`Participant profile picture URL: ${JSON.stringify(picture_url)}`); - const findParticipant = await this.findContact(instance, body.key.participant.split('@')[0]); + const findParticipant = await this.findContact(instance, participantJid.split('@')[0]); this.logger.verbose(`Found participant: ${JSON.stringify(findParticipant)}`); if (findParticipant) { @@ -708,12 +692,12 @@ export class ChatwootService { } else { await this.createContact( instance, - body.key.participant.split('@')[0], + participantJid.split('@')[0], filterInbox.id, false, body.pushName, picture_url.profilePictureUrl || null, - body.key.participant, + participantJid, ); } } @@ -721,6 +705,7 @@ export class ChatwootService { const picture_url = await this.waMonitor.waInstances[instance.instanceName].profilePicture(chatId); this.logger.verbose(`Contact profile picture URL: ${JSON.stringify(picture_url)}`); + this.logger.verbose(`Searching contact for: ${chatId}`); let contact = await this.findContact(instance, chatId); if (contact) { @@ -1158,140 +1143,20 @@ export class ChatwootService { public async sendAttachment(waInstance: any, number: string, media: any, caption?: string, options?: Options) { try { - // Sempre baixar o arquivo do MinIO/S3 antes de enviar - // URLs presigned podem expirar, então convertemos para base64 - let mediaBuffer: Buffer; - let mimeType: string; - let fileName: string; + const parsedMedia = path.parse(decodeURIComponent(media)); + let mimeType = mimeTypes.lookup(parsedMedia?.ext) || ''; + let fileName = parsedMedia?.name + parsedMedia?.ext; - try { - this.logger.verbose(`Downloading media from: ${media}`); + if (!mimeType) { + const parts = media.split('/'); + fileName = decodeURIComponent(parts[parts.length - 1]); - // Tentar fazer download do arquivo com autenticação do Chatwoot - // maxRedirects: 0 para não seguir redirects automaticamente const response = await axios.get(media, { responseType: 'arraybuffer', - timeout: this.MEDIA_DOWNLOAD_TIMEOUT_MS, - headers: { - api_access_token: this.provider.token, - }, - maxRedirects: 0, // Não seguir redirects automaticamente - validateStatus: (status) => status < 500, // Aceitar redirects (301, 302, 307) }); - - this.logger.verbose(`Initial response status: ${response.status}`); - - // Se for redirect, pegar a URL de destino e fazer novo request - if (response.status >= 300 && response.status < 400) { - const redirectUrl = response.headers.location; - this.logger.verbose(`Redirect to: ${redirectUrl}`); - - if (redirectUrl) { - // Fazer novo request para a URL do S3/MinIO (sem autenticação, pois é presigned URL) - // IMPORTANTE: Chatwoot pode gerar a URL presigned ANTES de fazer upload - // Vamos tentar com retry usando exponential backoff se receber 404 (arquivo ainda não disponível) - this.logger.verbose('Downloading from S3/MinIO...'); - - let s3Response; - let retryCount = 0; - const maxRetries = this.S3_MAX_RETRIES; - const baseDelay = this.S3_BASE_DELAY_MS; - const maxDelay = this.S3_MAX_DELAY_MS; - - while (retryCount <= maxRetries) { - s3Response = await axios.get(redirectUrl, { - responseType: 'arraybuffer', - timeout: this.MEDIA_DOWNLOAD_TIMEOUT_MS, - validateStatus: (status) => status < 500, - }); - - this.logger.verbose( - `S3 response status: ${s3Response.status}, size: ${s3Response.data?.byteLength || 0} bytes (attempt ${retryCount + 1}/${maxRetries + 1})`, - ); - - // Se não for 404, sair do loop - if (s3Response.status !== 404) { - break; - } - - // Se for 404 e ainda tem tentativas, aguardar com exponential backoff e tentar novamente - if (retryCount < maxRetries) { - // Exponential backoff com max delay (seguindo padrão do webhook controller) - const backoffDelay = Math.min(baseDelay * Math.pow(2, retryCount), maxDelay); - const errorBody = s3Response.data?.toString ? s3Response.data.toString('utf-8') : s3Response.data; - this.logger.warn( - `File not yet available in S3/MinIO (attempt ${retryCount + 1}/${maxRetries + 1}). Retrying in ${backoffDelay}ms with exponential backoff...`, - ); - this.logger.verbose(`MinIO Response: ${errorBody}`); - await new Promise((resolve) => setTimeout(resolve, backoffDelay)); - retryCount++; - } else { - // Última tentativa falhou - break; - } - } - - // Após todas as tentativas, verificar o status final - if (s3Response.status === 404) { - const errorBody = s3Response.data?.toString ? s3Response.data.toString('utf-8') : s3Response.data; - this.logger.error(`File not found in S3/MinIO after ${maxRetries + 1} attempts. URL: ${redirectUrl}`); - this.logger.error(`MinIO Error Response: ${errorBody}`); - throw new Error( - 'File not found in S3/MinIO (404). The file may have been deleted, the URL is incorrect, or Chatwoot has not finished uploading yet.', - ); - } - - if (s3Response.status === 403) { - this.logger.error(`Access denied to S3/MinIO. URL may have expired: ${redirectUrl}`); - throw new Error( - 'Access denied to S3/MinIO (403). Presigned URL may have expired. Check S3_PRESIGNED_EXPIRATION setting.', - ); - } - - if (s3Response.status >= 400) { - this.logger.error(`S3/MinIO error ${s3Response.status}: ${s3Response.statusText}`); - throw new Error(`S3/MinIO error ${s3Response.status}: ${s3Response.statusText}`); - } - - mediaBuffer = Buffer.from(s3Response.data); - mimeType = s3Response.headers['content-type'] || 'application/octet-stream'; - this.logger.verbose(`Downloaded ${mediaBuffer.length} bytes from S3, type: ${mimeType}`); - } else { - this.logger.error('Redirect response without Location header'); - throw new Error('Redirect without Location header'); - } - } else if (response.status === 404) { - this.logger.error(`File not found (404) at: ${media}`); - throw new Error('File not found (404). The attachment may not exist in Chatwoot storage.'); - } else if (response.status >= 400) { - this.logger.error(`HTTP ${response.status}: ${response.statusText} for URL: ${media}`); - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } else { - // Download direto sem redirect - mediaBuffer = Buffer.from(response.data); - mimeType = response.headers['content-type'] || 'application/octet-stream'; - this.logger.verbose(`Downloaded ${mediaBuffer.length} bytes directly, type: ${mimeType}`); - } - - // Extrair nome do arquivo da URL ou usar o content-disposition - const parsedMedia = path.parse(decodeURIComponent(media)); - if (parsedMedia?.name && parsedMedia?.ext) { - fileName = parsedMedia.name + parsedMedia.ext; - } else { - const parts = media.split('/'); - fileName = decodeURIComponent(parts[parts.length - 1].split('?')[0]); - } - - this.logger.verbose(`File name: ${fileName}, size: ${mediaBuffer.length} bytes`); - } catch (downloadError) { - this.logger.error('[MEDIA DOWNLOAD] ❌ Error downloading media from: ' + media); - this.logger.error(`[MEDIA DOWNLOAD] Error message: ${downloadError.message}`); - this.logger.error(`[MEDIA DOWNLOAD] Error stack: ${downloadError.stack}`); - this.logger.error(`[MEDIA DOWNLOAD] Full error: ${JSON.stringify(downloadError, null, 2)}`); - throw new Error(`Failed to download media: ${downloadError.message}`); + mimeType = response.headers['content-type']; } - // Determinar o tipo de mídia pelo mimetype let type = 'document'; switch (mimeType.split('/')[0]) { @@ -1309,13 +1174,11 @@ export class ChatwootService { break; } - // Para áudio, usar base64 com data URI if (type === 'audio') { - const base64Audio = `data:${mimeType};base64,${mediaBuffer.toString('base64')}`; const data: SendAudioDto = { number: number, - audio: base64Audio, - delay: 1200, + audio: media, + delay: Math.floor(Math.random() * (2000 - 500 + 1)) + 500, quoted: options?.quoted, }; @@ -1326,12 +1189,8 @@ export class ChatwootService { return messageSent; } - // Para outros tipos, converter para base64 puro (sem prefixo data URI) - const base64Media = mediaBuffer.toString('base64'); - const documentExtensions = ['.gif', '.svg', '.tiff', '.tif', '.dxf', '.dwg']; - const parsedExt = path.parse(fileName)?.ext; - if (type === 'image' && parsedExt && documentExtensions.includes(parsedExt)) { + if (type === 'image' && parsedMedia && documentExtensions.includes(parsedMedia?.ext)) { type = 'document'; } @@ -1339,7 +1198,7 @@ export class ChatwootService { number: number, mediatype: type as any, fileName: fileName, - media: base64Media, // Base64 puro, sem prefixo + media: media, delay: 1200, quoted: options?.quoted, }; @@ -1395,87 +1254,9 @@ export class ChatwootService { }); } - /** - * Processa deleção de mensagem em background - * Método assíncrono chamado via setImmediate para não bloquear resposta do webhook - */ - private async processDeletion(instance: InstanceDto, body: any, deleteLockKey: string) { - this.logger.warn(`[DELETE] 🗑️ Processing deletion - messageId: ${body.id}`); - const waInstance = this.waMonitor.waInstances[instance.instanceName]; - - // Buscar TODAS as mensagens com esse chatwootMessageId (pode ser múltiplos anexos) - const messages = await this.prismaRepository.message.findMany({ - where: { - chatwootMessageId: body.id, - instanceId: instance.instanceId, - }, - }); - - if (messages && messages.length > 0) { - this.logger.warn(`[DELETE] Found ${messages.length} message(s) to delete from Chatwoot message ${body.id}`); - this.logger.verbose(`[DELETE] Messages keys: ${messages.map((m) => (m.key as any)?.id).join(', ')}`); - - // Deletar cada mensagem no WhatsApp - for (const message of messages) { - const key = message.key as WAMessageKey; - this.logger.warn( - `[DELETE] Attempting to delete WhatsApp message - keyId: ${key?.id}, remoteJid: ${key?.remoteJid}`, - ); - - try { - await waInstance?.client.sendMessage(key.remoteJid, { delete: key }); - this.logger.warn(`[DELETE] ✅ Message ${key.id} deleted in WhatsApp successfully`); - } catch (error) { - this.logger.error(`[DELETE] ❌ Error deleting message ${key.id} in WhatsApp: ${error}`); - this.logger.error(`[DELETE] Error details: ${JSON.stringify(error, null, 2)}`); - } - } - - // Remover todas as mensagens do banco de dados - await this.prismaRepository.message.deleteMany({ - where: { - instanceId: instance.instanceId, - chatwootMessageId: body.id, - }, - }); - this.logger.warn(`[DELETE] ✅ SUCCESS: ${messages.length} message(s) deleted from WhatsApp and database`); - } else { - // Mensagem não encontrada - pode ser uma mensagem antiga que foi substituída por edição - this.logger.warn(`[DELETE] ⚠️ WARNING: Message not found in DB - chatwootMessageId: ${body.id}`); - } - - // Liberar lock após processar - await this.cache.delete(deleteLockKey); - } - public async receiveWebhook(instance: InstanceDto, body: any) { try { - // IMPORTANTE: Verificar lock de deleção ANTES do delay inicial - // para evitar race condition com webhooks duplicados - let isDeletionEvent = false; - if (body.event === 'message_updated' && body.content_attributes?.deleted) { - isDeletionEvent = true; - const deleteLockKey = `${instance.instanceName}:deleteMessage-${body.id}`; - - // Verificar se já está processando esta deleção - if (await this.cache.has(deleteLockKey)) { - this.logger.warn(`[DELETE] ⏭️ SKIPPING: Deletion already in progress for messageId: ${body.id}`); - return { message: 'already_processing' }; - } - - // Adquirir lock IMEDIATAMENTE por 30 segundos - await this.cache.set(deleteLockKey, true, 30); - - this.logger.warn( - `[WEBHOOK-DELETE] Event: ${body.event}, messageId: ${body.id}, conversation: ${body.conversation?.id}`, - ); - } - - // Para deleções, processar IMEDIATAMENTE (sem delay) - // Para outros eventos, aguardar delay inicial - if (!isDeletionEvent) { - await new Promise((resolve) => setTimeout(resolve, this.WEBHOOK_INITIAL_DELAY_MS)); - } + await new Promise((resolve) => setTimeout(resolve, 500)); const client = await this.clientCw(instance); @@ -1494,39 +1275,6 @@ export class ChatwootService { this.cache.delete(keyToDelete); } - // Log para debug de mensagens deletadas - if (body.event === 'message_updated') { - this.logger.verbose( - `Message updated event - deleted: ${body.content_attributes?.deleted}, messageId: ${body.id}`, - ); - } - - // Processar deleção de mensagem ANTES das outras validações - if (body.event === 'message_updated' && body.content_attributes?.deleted) { - // Lock já foi adquirido no início do método (antes do delay) - const deleteLockKey = `${instance.instanceName}:deleteMessage-${body.id}`; - - // ESTRATÉGIA: Processar em background e responder IMEDIATAMENTE - // Isso evita timeout do Chatwoot (5s) quando há muitas imagens (> 5s de processamento) - this.logger.warn(`[DELETE] 🚀 Starting background deletion - messageId: ${body.id}`); - - // Executar em background (sem await) - não bloqueia resposta do webhook - setImmediate(async () => { - try { - await this.processDeletion(instance, body, deleteLockKey); - } catch (error) { - this.logger.error(`[DELETE] ❌ Background deletion failed for messageId ${body.id}: ${error}`); - } - }); - - // RESPONDER IMEDIATAMENTE ao Chatwoot (< 50ms) - return { - message: 'deletion_accepted', - messageId: body.id, - note: 'Deletion is being processed in background', - }; - } - if ( !body?.conversation || body.private || @@ -1548,6 +1296,7 @@ export class ChatwootService { const senderName = body?.conversation?.messages[0]?.sender?.available_name || body?.sender?.name; const waInstance = this.waMonitor.waInstances[instance.instanceName]; + instance.instanceId = waInstance.instanceId; if (body.event === 'message_updated' && body.content_attributes?.deleted) { const message = await this.prismaRepository.message.findFirst({ @@ -1670,63 +1419,44 @@ export class ChatwootService { for (const message of body.conversation.messages) { if (message.attachments && message.attachments.length > 0) { - // Processa anexos de forma assíncrona para não bloquear o webhook - const processAttachments = async () => { - for (const attachment of message.attachments) { - if (!messageReceived) { - formatText = null; - } - - const options: Options = { - quoted: await this.getQuotedMessage(body, instance), - }; - - try { - const messageSent = await this.sendAttachment( - waInstance, - chatId, - attachment.data_url, - formatText, - options, - ); - - if (!messageSent && body.conversation?.id) { - this.onSendMessageError(instance, body.conversation?.id); - } - - if (messageSent) { - await this.updateChatwootMessageId( - { - ...messageSent, - owner: instance.instanceName, - }, - { - messageId: body.id, - inboxId: body.inbox?.id, - conversationId: body.conversation?.id, - contactInboxSourceId: body.conversation?.contact_inbox?.source_id, - }, - instance, - ); - } - } catch (error) { - this.logger.error(error); - if (body.conversation?.id) { - this.onSendMessageError(instance, body.conversation?.id, error); - } - } + for (const attachment of message.attachments) { + if (!messageReceived) { + formatText = null; } - }; - // Executa em background sem bloquear - processAttachments().catch((error) => { - this.logger.error(error); - }); + const options: Options = { + quoted: await this.getQuotedMessage(body, instance), + }; + + const messageSent = await this.sendAttachment( + waInstance, + chatId, + attachment.data_url, + formatText, + options, + ); + if (!messageSent && body.conversation?.id) { + this.onSendMessageError(instance, body.conversation?.id); + } + + await this.updateChatwootMessageId( + { + ...messageSent, + }, + { + messageId: body.id, + inboxId: body.inbox?.id, + conversationId: body.conversation?.id, + contactInboxSourceId: body.conversation?.contact_inbox?.source_id, + }, + instance, + ); + } } else { const data: SendTextDto = { number: chatId, text: formatText, - delay: 1200, + delay: Math.floor(Math.random() * (2000 - 500 + 1)) + 500, quoted: await this.getQuotedMessage(body, instance), }; @@ -1744,7 +1474,9 @@ export class ChatwootService { } await this.updateChatwootMessageId( - messageSent, // Já tem instanceId + { + ...messageSent, + }, { messageId: body.id, inboxId: body.inbox?.id, @@ -1811,7 +1543,7 @@ export class ChatwootService { const data: SendTextDto = { number: chatId, text: body.content.replace(/\\\r\n|\\\n|\n/g, '\n'), - delay: 1200, + delay: Math.floor(Math.random() * (2000 - 500 + 1)) + 500, }; sendTelemetry('/message/sendText'); @@ -1835,55 +1567,6 @@ export class ChatwootService { const key = message.key as WAMessageKey; if (!chatwootMessageIds.messageId || !key?.id) { - this.logger.verbose( - `Skipping updateChatwootMessageId - messageId: ${chatwootMessageIds.messageId}, keyId: ${key?.id}`, - ); - return; - } - - // Use instanceId from message or fallback to instance - const instanceId = message.instanceId || instance.instanceId; - - this.logger.verbose( - `Updating message with chatwootMessageId: ${chatwootMessageIds.messageId}, keyId: ${key.id}, instanceId: ${instanceId}`, - ); - - // Verifica se a mensagem existe antes de atualizar usando polling com exponential backoff - let retries = 0; - const maxRetries = this.DB_POLLING_MAX_RETRIES; - const baseDelay = this.DB_POLLING_BASE_DELAY_MS; - const maxDelay = this.DB_POLLING_MAX_DELAY_MS; - let messageExists = false; - - while (retries < maxRetries && !messageExists) { - const existingMessage = await this.prismaRepository.message.findFirst({ - where: { - instanceId: instanceId, - key: { - path: ['id'], - equals: key.id, - }, - }, - }); - - if (existingMessage) { - messageExists = true; - this.logger.verbose(`Message found in database after ${retries} retries`); - } else { - retries++; - if (retries < maxRetries) { - // Exponential backoff com max delay (seguindo padrão do sistema) - const backoffDelay = Math.min(baseDelay * Math.pow(2, retries - 1), maxDelay); - this.logger.verbose(`Message not found, retry ${retries}/${maxRetries} in ${backoffDelay}ms`); - await new Promise((resolve) => setTimeout(resolve, backoffDelay)); - } else { - this.logger.verbose(`Message not found after ${retries} attempts`); - } - } - } - - if (!messageExists) { - this.logger.warn(`Message not found in database after ${maxRetries} retries, keyId: ${key.id}`); return; } @@ -1896,7 +1579,7 @@ export class ChatwootService { "chatwootInboxId" = ${chatwootMessageIds.inboxId}, "chatwootContactInboxSourceId" = ${chatwootMessageIds.contactInboxSourceId}, "chatwootIsRead" = ${chatwootMessageIds.isRead || false} - WHERE "instanceId" = ${instanceId} + WHERE "instanceId" = ${instance.instanceId} AND "key"->>'id' = ${key.id} `; @@ -1952,11 +1635,12 @@ export class ChatwootService { }); const key = message?.key as WAMessageKey; + const messageContent = message?.message as WAMessageContent; - if (message && key?.id) { + if (messageContent && key?.id) { return { - key: message.key as proto.IMessageKey, - message: message.message as proto.IMessage, + key: key, + message: messageContent, }; } } @@ -2346,7 +2030,10 @@ export class ChatwootService { if (body.key.remoteJid.includes('@g.us')) { const participantName = body.pushName; - const rawPhoneNumber = body.key.participant.split('@')[0]; + const rawPhoneNumber = + body.key.addressingMode === 'lid' && !body.key.fromMe + ? body.key.participantAlt.split('@')[0] + : body.key.participant.split('@')[0]; const phoneMatch = rawPhoneNumber.match(/^(\d{2})(\d{2})(\d{4})(\d{4})$/); let formattedPhoneNumber: string; @@ -2360,9 +2047,11 @@ export class ChatwootService { let content: string; if (!body.key.fromMe) { - content = `**${formattedPhoneNumber} - ${participantName}:**\n\n${bodyMessage}`; + content = bodyMessage + ? `**${formattedPhoneNumber} - ${participantName}:**\n\n${bodyMessage}` + : `**${formattedPhoneNumber} - ${participantName}:**`; } else { - content = `${bodyMessage}`; + content = bodyMessage ? bodyMessage : ''; } const send = await this.sendData( @@ -2487,7 +2176,10 @@ export class ChatwootService { if (body.key.remoteJid.includes('@g.us')) { const participantName = body.pushName; - const rawPhoneNumber = body.key.participant.split('@')[0]; + const rawPhoneNumber = + body.key.addressingMode === 'lid' && !body.key.fromMe + ? body.key.participantAlt.split('@')[0] + : body.key.participant.split('@')[0]; const phoneMatch = rawPhoneNumber.match(/^(\d{2})(\d{2})(\d{4})(\d{4})$/); let formattedPhoneNumber: string; @@ -2688,7 +2380,7 @@ export class ChatwootService { chatwootImport.clearAll(instance); } // Se não foi via QR code, verifica o throttling. - else if (timeSinceLastNotification >= MIN_CONNECTION_NOTIFICATION_INTERVAL_MS) { + else if (timeSinceLastNotification >= 30000) { const msgConnection = i18next.t('cw.inbox.connected'); await this.createBotMessage(instance, msgConnection, 'incoming'); waInstance.lastConnectionNotification = now; diff --git a/src/utils/onWhatsappCache.ts b/src/utils/onWhatsappCache.ts index 68f88ba4..edf38bab 100644 --- a/src/utils/onWhatsappCache.ts +++ b/src/utils/onWhatsappCache.ts @@ -1,7 +1,10 @@ import { prismaRepository } from '@api/server.module'; import { configService, Database } from '@config/env.config'; +import { Logger } from '@config/logger.config'; import dayjs from 'dayjs'; +const logger = new Logger('OnWhatsappCache'); + function getAvailableNumbers(remoteJid: string) { const numbersAvailable: string[] = []; @@ -11,6 +14,11 @@ function getAvailableNumbers(remoteJid: string) { const [number, domain] = remoteJid.split('@'); + // TODO: Se já for @lid, retornar apenas ele mesmo SEM adicionar @domain novamente + if (domain === 'lid' || domain === 'g.us') { + return [remoteJid]; // Retorna direto para @lid e @g.us + } + // Brazilian numbers if (remoteJid.startsWith('55')) { const numberWithDigit = @@ -47,35 +55,89 @@ function getAvailableNumbers(remoteJid: string) { numbersAvailable.push(remoteJid); } + // TODO: Adiciona @domain apenas para números que não são @lid return numbersAvailable.map((number) => `${number}@${domain}`); } interface ISaveOnWhatsappCacheParams { remoteJid: string; - lid?: string; + remoteJidAlt?: string; + lid?: 'lid' | undefined; } export async function saveOnWhatsappCache(data: ISaveOnWhatsappCacheParams[]) { if (configService.get('DATABASE').SAVE_DATA.IS_ON_WHATSAPP) { - const upsertsQuery = data.map((item) => { + for (const item of data) { const remoteJid = item.remoteJid.startsWith('+') ? item.remoteJid.slice(1) : item.remoteJid; - const numbersAvailable = getAvailableNumbers(remoteJid); - return prismaRepository.isOnWhatsapp.upsert({ - create: { - remoteJid: remoteJid, - jidOptions: numbersAvailable.join(','), - lid: item.lid, + // TODO: Buscar registro existente PRIMEIRO para preservar dados + const allJids = [remoteJid]; + + const altJid = + item.remoteJidAlt && item.remoteJidAlt.includes('@lid') + ? item.remoteJidAlt.startsWith('+') + ? item.remoteJidAlt.slice(1) + : item.remoteJidAlt + : null; + + if (altJid) { + allJids.push(altJid); + } + + const expandedJids = allJids.flatMap((jid) => getAvailableNumbers(jid)); + + const existingRecord = await prismaRepository.isOnWhatsapp.findFirst({ + where: { + OR: expandedJids.map((jid) => ({ jidOptions: { contains: jid } })), }, - update: { - jidOptions: numbersAvailable.join(','), - lid: item.lid, - }, - where: { remoteJid: remoteJid }, }); - }); - await prismaRepository.$transaction(upsertsQuery); + logger.verbose(`Register exists: ${existingRecord ? existingRecord.remoteJid : 'não not found'}`); + + const finalJidOptions = [...expandedJids]; + + if (existingRecord?.jidOptions) { + const existingJids = existingRecord.jidOptions.split(','); + // TODO: Adicionar JIDs existentes que não estão na lista atual + existingJids.forEach((jid) => { + if (!finalJidOptions.includes(jid)) { + finalJidOptions.push(jid); + } + }); + } + + // TODO: Se tiver remoteJidAlt com @lid novo, adicionar + if (altJid) { + if (!finalJidOptions.includes(altJid)) { + finalJidOptions.push(altJid); + } + } + + const uniqueNumbers = Array.from(new Set(finalJidOptions)); + + logger.verbose( + `Saving: remoteJid=${remoteJid}, jidOptions=${uniqueNumbers.join(',')}, lid=${item.lid === 'lid' || item.remoteJid?.includes('@lid') ? 'lid' : null}`, + ); + + if (existingRecord) { + await prismaRepository.isOnWhatsapp.update({ + where: { id: existingRecord.id }, + data: { + remoteJid: remoteJid, + jidOptions: uniqueNumbers.join(','), + lid: item.lid === 'lid' || item.remoteJid?.includes('@lid') ? 'lid' : null, + }, + }); + } else { + await prismaRepository.isOnWhatsapp.create({ + data: { + remoteJid: remoteJid, + jidOptions: uniqueNumbers.join(','), + lid: item.lid === 'lid' || item.remoteJid?.includes('@lid') ? 'lid' : null, + }, + }); + } + } } }