diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 60e857fcc1..4e4492604f 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -91,6 +91,7 @@ import { AuthStateProvider } from '@utils/use-multi-file-auth-state-provider-fil import { useMultiFileAuthStateRedisDb } from '@utils/use-multi-file-auth-state-redis-db'; import axios from 'axios'; import makeWASocket, { + ALL_WA_PATCH_NAMES, AnyMessageContent, BufferedEventData, BufferJSON, @@ -466,6 +467,7 @@ export class BaileysStartupService extends ChannelStartupService { if (connection === 'open') { this.instance.wuid = this.client.user.id.replace(/:\d+/, ''); + void this.resyncAppStateAfterConnection(); try { const profilePic = await this.profilePicture(this.instance.wuid); this.instance.profilePictureUrl = profilePic.profilePictureUrl; @@ -1085,376 +1087,419 @@ export class BaileysStartupService extends ChannelStartupService { ) => { try { for (const received of messages) { - if ( - received?.messageStubParameters?.some?.((param) => - [ - 'No matching sessions found for message', - 'Bad MAC', - 'failed to decrypt message', - 'SessionError', - 'Invalid PreKey ID', - 'No session record', - 'No session found to decrypt message', - 'Message absent from node', - ].some((err) => param?.includes?.(err)), - ) - ) { - this.logger.warn(`Message ignored with messageStubParameters: ${JSON.stringify(received, null, 2)}`); - continue; - } - if (received.message?.conversation || received.message?.extendedTextMessage?.text) { - const text = received.message?.conversation || received.message?.extendedTextMessage?.text; + try { + if ( + received?.messageStubParameters?.some?.((param) => + [ + 'No matching sessions found for message', + 'Bad MAC', + 'failed to decrypt message', + 'SessionError', + 'Invalid PreKey ID', + 'No session record', + 'No session found to decrypt message', + 'Message absent from node', + ].some((err) => param?.includes?.(err)), + ) + ) { + try { + const resendId = await this.client.requestPlaceholderResend(received.key); + this.logger.warn( + `Message decryption/placeholder issue; requested resend. key=${received.key?.id}, resendId=${resendId}`, + ); + } catch (error) { + this.logger.error({ + local: 'messages.upsert.requestPlaceholderResend', + message: error?.message, + stack: error?.stack, + keyId: received.key?.id, + }); + } + continue; + } + if (received.message?.conversation || received.message?.extendedTextMessage?.text) { + const text = received.message?.conversation || received.message?.extendedTextMessage?.text; - if (text == 'requestPlaceholder' && !requestId) { - const messageId = await this.client.requestPlaceholderResend(received.key); + if (text == 'requestPlaceholder' && !requestId) { + const messageId = await this.client.requestPlaceholderResend(received.key); - console.log('requested placeholder resync, id=', messageId); - } else if (requestId) { - console.log('Message received from phone, id=', requestId, received); - } + console.log('requested placeholder resync, id=', messageId); + } else if (requestId) { + console.log('Message received from phone, id=', requestId, received); + } - if (text == 'onDemandHistSync') { - const messageId = await this.client.fetchMessageHistory(50, received.key, received.messageTimestamp!); - console.log('requested on-demand sync, id=', messageId); + if (text == 'onDemandHistSync') { + const messageId = await this.client.fetchMessageHistory(50, received.key, received.messageTimestamp!); + console.log('requested on-demand sync, id=', messageId); + } } - } - const editedMessage = - received?.message?.protocolMessage || received?.message?.editedMessage?.message?.protocolMessage; + const editedMessage = + received?.message?.protocolMessage || received?.message?.editedMessage?.message?.protocolMessage; - if (editedMessage) { - if (this.configService.get('CHATWOOT').ENABLED && this.localChatwoot?.enabled) - this.chatwootService.eventWhatsapp( - 'messages.edit', - { instanceName: this.instance.name, instanceId: this.instance.id }, - editedMessage, - ); + if (editedMessage) { + if (this.configService.get('CHATWOOT').ENABLED && this.localChatwoot?.enabled) + this.chatwootService.eventWhatsapp( + 'messages.edit', + { instanceName: this.instance.name, instanceId: this.instance.id }, + editedMessage, + ); - await this.sendDataWebhook(Events.MESSAGES_EDITED, editedMessage); + await this.sendDataWebhook(Events.MESSAGES_EDITED, editedMessage); + + if (received.key?.id && editedMessage.key?.id) { + await this.baileysCache.set(`protocol_${received.key.id}`, editedMessage.key.id, 60 * 60 * 24); + } - if (received.key?.id && editedMessage.key?.id) { - await this.baileysCache.set(`protocol_${received.key.id}`, editedMessage.key.id, 60 * 60 * 24); + const oldMessage = await this.getMessage(editedMessage.key, true); + if ((oldMessage as any)?.id) { + const editedMessageTimestamp = Long.isLong(received?.messageTimestamp) + ? Math.floor(received?.messageTimestamp.toNumber()) + : Math.floor(received?.messageTimestamp as number); + + await this.prismaRepository.message.update({ + where: { id: (oldMessage as any).id }, + data: { + message: editedMessage.editedMessage as any, + messageTimestamp: editedMessageTimestamp, + status: 'EDITED', + }, + }); + await this.prismaRepository.messageUpdate.create({ + data: { + fromMe: editedMessage.key.fromMe, + keyId: editedMessage.key.id, + remoteJid: editedMessage.key.remoteJid, + status: 'EDITED', + instanceId: this.instanceId, + messageId: (oldMessage as any).id, + }, + }); + } } - const oldMessage = await this.getMessage(editedMessage.key, true); - if ((oldMessage as any)?.id) { - const editedMessageTimestamp = Long.isLong(received?.messageTimestamp) - ? Math.floor(received?.messageTimestamp.toNumber()) - : Math.floor(received?.messageTimestamp as number); - - await this.prismaRepository.message.update({ - where: { id: (oldMessage as any).id }, - data: { - message: editedMessage.editedMessage as any, - messageTimestamp: editedMessageTimestamp, - status: 'EDITED', - }, - }); - await this.prismaRepository.messageUpdate.create({ - data: { - fromMe: editedMessage.key.fromMe, - keyId: editedMessage.key.id, - remoteJid: editedMessage.key.remoteJid, - status: 'EDITED', - instanceId: this.instanceId, - messageId: (oldMessage as any).id, - }, - }); + if ((type !== 'notify' && type !== 'append') || editedMessage || !received?.message) { + continue; } - } - if ((type !== 'notify' && type !== 'append') || editedMessage || !received?.message) { - continue; - } + if (Long.isLong(received.messageTimestamp)) { + received.messageTimestamp = received.messageTimestamp?.toNumber(); + } - if (Long.isLong(received.messageTimestamp)) { - received.messageTimestamp = received.messageTimestamp?.toNumber(); - } + if (settings?.groupsIgnore && received.key.remoteJid.includes('@g.us')) { + continue; + } - if (settings?.groupsIgnore && received.key.remoteJid.includes('@g.us')) { - continue; - } + const messageRaw = this.prepareMessage(received); - const existingChat = await this.prismaRepository.chat.findFirst({ - where: { instanceId: this.instanceId, remoteJid: received.key.remoteJid }, - select: { id: true, name: true }, - }); + const originalRemoteJid = messageRaw.key?.remoteJid; + const campaign = this.getCampaignMetadata(messageRaw.contextInfo); + const isMetaAdsMessage = campaign?.isMetaAds === true; + const phoneNumber = await this.resolvePhoneNumber(received.key as ExtendedIMessageKey); + const chatLabels = await this.getChatLabels(received.key as ExtendedIMessageKey); - if ( - existingChat && - received.pushName && - existingChat.name !== received.pushName && - received.pushName.trim().length > 0 && - !received.key.fromMe && - !received.key.remoteJid.includes('@g.us') - ) { - this.sendDataWebhook(Events.CHATS_UPSERT, [{ ...existingChat, name: received.pushName }]); - if (this.configService.get('DATABASE').SAVE_DATA.CHATS) { - try { - await this.prismaRepository.chat.update({ - where: { id: existingChat.id }, - data: { name: received.pushName }, - }); - } catch { - console.log(`Chat insert record ignored: ${received.key.remoteJid} - ${this.instanceId}`); + const createWebhookMessage = () => { + const webhookKey = { ...messageRaw.key } as ExtendedIMessageKey & { remoteJidLid?: string }; + if (originalRemoteJid?.includes('@lid')) { + webhookKey.remoteJidLid = originalRemoteJid; } + + return { + ...messageRaw, + key: webhookKey, + phoneNumber: phoneNumber ?? undefined, + phoneJid: phoneNumber ? `${phoneNumber}@s.whatsapp.net` : undefined, + labelIds: chatLabels.labelIds, + labels: chatLabels.labels, + campaign, + message: this.deserializeMessageBuffers(messageRaw.message), + contextInfo: this.deserializeMessageBuffers(messageRaw.contextInfo), + }; + }; + + const sendMessageWebhook = () => { + void this.sendDataWebhook(Events.MESSAGES_UPSERT, createWebhookMessage()).catch((error) => { + this.logger.error({ + local: 'messages.upsert.sendDataWebhook', + message: error?.message, + stack: error?.stack, + keyId: received.key?.id, + }); + }); + }; + + // Meta Ads messages need to leave before database/media/chatbot processing can abort the handler. + if (isMetaAdsMessage) { + sendMessageWebhook(); } - } - const messageRaw = this.prepareMessage(received); + const existingChat = await this.prismaRepository.chat.findFirst({ + where: { instanceId: this.instanceId, remoteJid: received.key.remoteJid }, + select: { id: true, name: true }, + }); + + if ( + existingChat && + received.pushName && + existingChat.name !== received.pushName && + received.pushName.trim().length > 0 && + !received.key.fromMe && + !received.key.remoteJid.includes('@g.us') + ) { + this.sendDataWebhook(Events.CHATS_UPSERT, [{ ...existingChat, name: received.pushName }]); + if (this.configService.get('DATABASE').SAVE_DATA.CHATS) { + try { + await this.prismaRepository.chat.update({ + where: { id: existingChat.id }, + data: { name: received.pushName }, + }); + } catch { + console.log(`Chat insert record ignored: ${received.key.remoteJid} - ${this.instanceId}`); + } + } + } - if (messageRaw.messageType === 'pollUpdateMessage') { - const pollCreationKey = messageRaw.message.pollUpdateMessage.pollCreationMessageKey; - const pollMessage = (await this.getMessage(pollCreationKey, true)) as proto.IWebMessageInfo; - const pollMessageSecret = (await this.getMessage(pollCreationKey)) as any; + if (messageRaw.messageType === 'pollUpdateMessage') { + const pollCreationKey = messageRaw.message.pollUpdateMessage.pollCreationMessageKey; + const pollMessage = (await this.getMessage(pollCreationKey, true)) as proto.IWebMessageInfo; + const pollMessageSecret = (await this.getMessage(pollCreationKey)) as any; - if (pollMessage) { - const pollOptions = - (pollMessage.message as any).pollCreationMessage?.options || - (pollMessage.message as any).pollCreationMessageV3?.options || - []; - const pollVote = messageRaw.message.pollUpdateMessage.vote; + if (pollMessage) { + const pollOptions = + (pollMessage.message as any).pollCreationMessage?.options || + (pollMessage.message as any).pollCreationMessageV3?.options || + []; + const pollVote = messageRaw.message.pollUpdateMessage.vote; - const voterJid = received.key.fromMe - ? this.instance.wuid - : received.key.participant || received.key.remoteJid; + const voterJid = received.key.fromMe + ? this.instance.wuid + : received.key.participant || received.key.remoteJid; - let pollEncKey = pollMessageSecret?.messageContextInfo?.messageSecret; + let pollEncKey = pollMessageSecret?.messageContextInfo?.messageSecret; - let successfulVoterJid = voterJid; + let successfulVoterJid = voterJid; - if (typeof pollEncKey === 'string') { - pollEncKey = Buffer.from(pollEncKey, 'base64'); - } else if (pollEncKey?.type === 'Buffer' && Array.isArray(pollEncKey.data)) { - pollEncKey = Buffer.from(pollEncKey.data); - } + if (typeof pollEncKey === 'string') { + pollEncKey = Buffer.from(pollEncKey, 'base64'); + } else if (pollEncKey?.type === 'Buffer' && Array.isArray(pollEncKey.data)) { + pollEncKey = Buffer.from(pollEncKey.data); + } - if (Buffer.isBuffer(pollEncKey) && pollEncKey.length === 44) { - pollEncKey = Buffer.from(pollEncKey.toString('utf8'), 'base64'); - } + if (Buffer.isBuffer(pollEncKey) && pollEncKey.length === 44) { + pollEncKey = Buffer.from(pollEncKey.toString('utf8'), 'base64'); + } - if (pollVote.encPayload && pollEncKey) { - const creatorCandidates = [ - this.instance.wuid, - this.client.user?.lid, - pollMessage.key.participant, - (pollMessage.key as any).participantAlt, - pollMessage.key.remoteJid, - ]; - - const key = received.key as any; - const voterCandidates = [ - this.instance.wuid, - this.client.user?.lid, - key.participant, - key.participantAlt, - key.remoteJidAlt, - key.remoteJid, - ]; - - const uniqueCreators = [ - ...new Set(creatorCandidates.filter(Boolean).map((id) => jidNormalizedUser(id))), - ]; - const uniqueVoters = [...new Set(voterCandidates.filter(Boolean).map((id) => jidNormalizedUser(id)))]; - - let decryptedVote; - - for (const creator of uniqueCreators) { - for (const voter of uniqueVoters) { - try { - decryptedVote = decryptPollVote(pollVote, { - pollCreatorJid: creator, - pollMsgId: pollMessage.key.id, - pollEncKey, - voterJid: voter, - } as any); - if (decryptedVote) { - successfulVoterJid = voter; - break; + if (pollVote.encPayload && pollEncKey) { + const creatorCandidates = [ + this.instance.wuid, + this.client.user?.lid, + pollMessage.key.participant, + (pollMessage.key as any).participantAlt, + pollMessage.key.remoteJid, + ]; + + const key = received.key as any; + const voterCandidates = [ + this.instance.wuid, + this.client.user?.lid, + key.participant, + key.participantAlt, + key.remoteJidAlt, + key.remoteJid, + ]; + + const uniqueCreators = [ + ...new Set(creatorCandidates.filter(Boolean).map((id) => jidNormalizedUser(id))), + ]; + const uniqueVoters = [...new Set(voterCandidates.filter(Boolean).map((id) => jidNormalizedUser(id)))]; + + let decryptedVote; + + for (const creator of uniqueCreators) { + for (const voter of uniqueVoters) { + try { + decryptedVote = decryptPollVote(pollVote, { + pollCreatorJid: creator, + pollMsgId: pollMessage.key.id, + pollEncKey, + voterJid: voter, + } as any); + if (decryptedVote) { + successfulVoterJid = voter; + break; + } + } catch { + // Continue trying } - } catch { - // Continue trying } + if (decryptedVote) break; } - if (decryptedVote) break; - } - if (decryptedVote) { - Object.assign(pollVote, decryptedVote); + if (decryptedVote) { + Object.assign(pollVote, decryptedVote); + } } - } - const selectedOptions = pollVote?.selectedOptions || []; + const selectedOptions = pollVote?.selectedOptions || []; - const selectedOptionNames = pollOptions - .filter((option) => { - const hash = createHash('sha256').update(option.optionName).digest(); - return selectedOptions.some((selected) => Buffer.compare(selected, hash) === 0); - }) - .map((option) => option.optionName); + const selectedOptionNames = pollOptions + .filter((option) => { + const hash = createHash('sha256').update(option.optionName).digest(); + return selectedOptions.some((selected) => Buffer.compare(selected, hash) === 0); + }) + .map((option) => option.optionName); - messageRaw.message.pollUpdateMessage.vote.selectedOptions = selectedOptionNames; + messageRaw.message.pollUpdateMessage.vote.selectedOptions = selectedOptionNames; - const pollUpdates = pollOptions.map((option) => ({ - name: option.optionName, - voters: selectedOptionNames.includes(option.optionName) ? [successfulVoterJid] : [], - })); + const pollUpdates = pollOptions.map((option) => ({ + name: option.optionName, + voters: selectedOptionNames.includes(option.optionName) ? [successfulVoterJid] : [], + })); - messageRaw.pollUpdates = pollUpdates; + messageRaw.pollUpdates = pollUpdates; + } } - } - const isMedia = - received?.message?.imageMessage || - received?.message?.videoMessage || - received?.message?.stickerMessage || - received?.message?.documentMessage || - received?.message?.documentWithCaptionMessage || - received?.message?.ptvMessage || - received?.message?.audioMessage; + const isMedia = + received?.message?.imageMessage || + received?.message?.videoMessage || + received?.message?.stickerMessage || + received?.message?.documentMessage || + received?.message?.documentWithCaptionMessage || + received?.message?.ptvMessage || + received?.message?.audioMessage; - const isVideo = received?.message?.videoMessage; + const isVideo = received?.message?.videoMessage; - if (this.localSettings.readMessages && received.key.id !== 'status@broadcast') { - await this.client.readMessages([received.key]); - } + if (this.localSettings.readMessages && received.key.id !== 'status@broadcast') { + await this.client.readMessages([received.key]); + } - if (this.localSettings.readStatus && received.key.id === 'status@broadcast') { - await this.client.readMessages([received.key]); - } + if (this.localSettings.readStatus && received.key.id === 'status@broadcast') { + await this.client.readMessages([received.key]); + } - if ( - this.configService.get('CHATWOOT').ENABLED && - this.localChatwoot?.enabled && - !received.key.id.includes('@broadcast') - ) { - const chatwootSentMessage = await this.chatwootService.eventWhatsapp( - Events.MESSAGES_UPSERT, - { instanceName: this.instance.name, instanceId: this.instanceId }, - messageRaw, - ); + if ( + this.configService.get('CHATWOOT').ENABLED && + this.localChatwoot?.enabled && + !received.key.id.includes('@broadcast') + ) { + const chatwootSentMessage = await this.chatwootService.eventWhatsapp( + Events.MESSAGES_UPSERT, + { instanceName: this.instance.name, instanceId: this.instanceId }, + messageRaw, + ); - if (chatwootSentMessage?.id) { - messageRaw.chatwootMessageId = chatwootSentMessage.id; - messageRaw.chatwootInboxId = chatwootSentMessage.inbox_id; - messageRaw.chatwootConversationId = chatwootSentMessage.conversation_id; + if (chatwootSentMessage?.id) { + messageRaw.chatwootMessageId = chatwootSentMessage.id; + messageRaw.chatwootInboxId = chatwootSentMessage.inbox_id; + messageRaw.chatwootConversationId = chatwootSentMessage.conversation_id; + } } - } - if (this.configService.get('OPENAI').ENABLED && received?.message?.audioMessage) { - const openAiDefaultSettings = await this.prismaRepository.openaiSetting.findFirst({ - where: { instanceId: this.instanceId }, - include: { OpenaiCreds: true }, - }); + if (this.configService.get('OPENAI').ENABLED && received?.message?.audioMessage) { + const openAiDefaultSettings = await this.prismaRepository.openaiSetting.findFirst({ + where: { instanceId: this.instanceId }, + include: { OpenaiCreds: true }, + }); - if (openAiDefaultSettings && openAiDefaultSettings.openaiCredsId && openAiDefaultSettings.speechToText) { - messageRaw.message.speechToText = `[audio] ${await this.openaiService.speechToText(received, this)}`; + if (openAiDefaultSettings && openAiDefaultSettings.openaiCredsId && openAiDefaultSettings.speechToText) { + messageRaw.message.speechToText = `[audio] ${await this.openaiService.speechToText(received, this)}`; + } } - } - if (this.configService.get('DATABASE').SAVE_DATA.NEW_MESSAGE) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { pollUpdates, ...messageData } = messageRaw; - const msg = await this.prismaRepository.message.create({ data: messageData }); - - const { remoteJid } = received.key; - const timestamp = msg.messageTimestamp; - const fromMe = received.key.fromMe.toString(); - const messageKey = `${remoteJid}_${timestamp}_${fromMe}`; - - const cachedTimestamp = await this.baileysCache.get(messageKey); - - if (!cachedTimestamp) { - if (!received.key.fromMe) { - if (msg.status === status[3]) { - this.logger.log(`Update not read messages ${remoteJid}`); - await this.updateChatUnreadMessages(remoteJid); - } else if (msg.status === status[4]) { + if (this.configService.get('DATABASE').SAVE_DATA.NEW_MESSAGE) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { pollUpdates, ...messageData } = messageRaw; + const msg = await this.prismaRepository.message.create({ data: messageData }); + + const { remoteJid } = received.key; + const timestamp = msg.messageTimestamp; + const fromMe = received.key.fromMe.toString(); + const messageKey = `${remoteJid}_${timestamp}_${fromMe}`; + + const cachedTimestamp = await this.baileysCache.get(messageKey); + + if (!cachedTimestamp) { + if (!received.key.fromMe) { + if (msg.status === status[3]) { + this.logger.log(`Update not read messages ${remoteJid}`); + await this.updateChatUnreadMessages(remoteJid); + } else if (msg.status === status[4]) { + this.logger.log(`Update readed messages ${remoteJid} - ${timestamp}`); + await this.updateMessagesReadedByTimestamp(remoteJid, timestamp); + } + } else { + // is send message by me this.logger.log(`Update readed messages ${remoteJid} - ${timestamp}`); await this.updateMessagesReadedByTimestamp(remoteJid, timestamp); } + + await this.baileysCache.set(messageKey, true, this.MESSAGE_CACHE_TTL_SECONDS); } else { - // is send message by me - this.logger.log(`Update readed messages ${remoteJid} - ${timestamp}`); - await this.updateMessagesReadedByTimestamp(remoteJid, timestamp); + this.logger.info(`Update readed messages duplicated ignored [avoid deadlock]: ${messageKey}`); } - await this.baileysCache.set(messageKey, true, this.MESSAGE_CACHE_TTL_SECONDS); - } else { - this.logger.info(`Update readed messages duplicated ignored [avoid deadlock]: ${messageKey}`); - } - - if (isMedia) { - if (this.configService.get('S3').ENABLE) { - try { - if (isVideo && !this.configService.get('S3').SAVE_VIDEO) { - this.logger.warn('Video upload is disabled. Skipping video upload.'); - // Skip video upload by returning early from this block - return; - } - - const message: any = received; - - // Verificação adicional para garantir que há conteúdo de mídia real - const hasRealMedia = this.hasValidMediaContent(message); - - if (!hasRealMedia) { - this.logger.warn('Message detected as media but contains no valid media content'); - } else { - const media = await this.getBase64FromMediaMessage({ message }, true); - - if (!media) { - this.logger.verbose('No valid media to upload (messageContextInfo only), skipping MinIO'); + if (isMedia) { + if (this.configService.get('S3').ENABLE) { + try { + if (isVideo && !this.configService.get('S3').SAVE_VIDEO) { + this.logger.warn('Video upload is disabled. Skipping video upload.'); + // Skip video upload by returning early from this block return; } - const { buffer, mediaType, fileName, size } = media; - const mimetype = mimeTypes.lookup(fileName).toString(); - const fullName = join( - `${this.instance.id}`, - received.key.remoteJid, - mediaType, - `${Date.now()}_${fileName}`, - ); - await s3Service.uploadFile(fullName, buffer, size.fileLength?.low, { 'Content-Type': mimetype }); + const message: any = received; - await this.prismaRepository.media.create({ - data: { - messageId: msg.id, - instanceId: this.instanceId, - type: mediaType, - fileName: fullName, - mimetype, - }, - }); + // Verificação adicional para garantir que há conteúdo de mídia real + const hasRealMedia = this.hasValidMediaContent(message); - const mediaUrl = await s3Service.getObjectUrl(fullName); + if (!hasRealMedia) { + this.logger.warn('Message detected as media but contains no valid media content'); + } else { + const media = await this.getBase64FromMediaMessage({ message }, true); - messageRaw.message.mediaUrl = mediaUrl; + if (!media) { + this.logger.verbose('No valid media to upload (messageContextInfo only), skipping MinIO'); + return; + } - await this.prismaRepository.message.update({ where: { id: msg.id }, data: messageRaw }); + const { buffer, mediaType, fileName, size } = media; + const mimetype = mimeTypes.lookup(fileName).toString(); + const fullName = join( + `${this.instance.id}`, + received.key.remoteJid, + mediaType, + `${Date.now()}_${fileName}`, + ); + await s3Service.uploadFile(fullName, buffer, size.fileLength?.low, { 'Content-Type': mimetype }); + + await this.prismaRepository.media.create({ + data: { + messageId: msg.id, + instanceId: this.instanceId, + type: mediaType, + fileName: fullName, + mimetype, + }, + }); + + const mediaUrl = await s3Service.getObjectUrl(fullName); + + messageRaw.message.mediaUrl = mediaUrl; + + await this.prismaRepository.message.update({ where: { id: msg.id }, data: messageRaw }); + } + } catch (error) { + this.logger.error(['Error on upload file to minio', error?.message, error?.stack]); } - } catch (error) { - this.logger.error(['Error on upload file to minio', error?.message, error?.stack]); } } } - } - - if (this.localWebhook.enabled) { - if (isMedia && this.localWebhook.webhookBase64) { - try { - const buffer = await downloadMediaMessage( - { key: received.key, message: received?.message }, - 'buffer', - {}, - { logger: P({ level: 'error' }) as any, reuploadRequest: this.client.updateMediaMessage }, - ); - if (buffer) { - messageRaw.message.base64 = buffer.toString('base64'); - } else { - // retry to download media + if (this.localWebhook.enabled) { + if (isMedia && this.localWebhook.webhookBase64) { + try { const buffer = await downloadMediaMessage( { key: received.key, message: received?.message }, 'buffer', @@ -1464,91 +1509,112 @@ export class BaileysStartupService extends ChannelStartupService { if (buffer) { messageRaw.message.base64 = buffer.toString('base64'); + } else { + // retry to download media + const buffer = await downloadMediaMessage( + { key: received.key, message: received?.message }, + 'buffer', + {}, + { logger: P({ level: 'error' }) as any, reuploadRequest: this.client.updateMediaMessage }, + ); + + if (buffer) { + messageRaw.message.base64 = buffer.toString('base64'); + } } + } catch (error) { + this.logger.error(['Error converting media to base64', error?.message]); } - } catch (error) { - this.logger.error(['Error converting media to base64', error?.message]); } } - } - this.logger.verbose(messageRaw); + this.logger.verbose(messageRaw); - sendTelemetry(`received.message.${messageRaw.messageType ?? 'unknown'}`); - if (messageRaw.key.remoteJid?.includes('@lid') && messageRaw.key.remoteJidAlt) { - messageRaw.key.remoteJid = messageRaw.key.remoteJidAlt; - } - console.log(messageRaw); + sendTelemetry(`received.message.${messageRaw.messageType ?? 'unknown'}`); + console.log(messageRaw); - this.sendDataWebhook(Events.MESSAGES_UPSERT, messageRaw); + if (!isMetaAdsMessage) { + sendMessageWebhook(); + } - await chatbotController.emit({ - instance: { instanceName: this.instance.name, instanceId: this.instanceId }, - remoteJid: messageRaw.key.remoteJid, - msg: messageRaw, - pushName: messageRaw.pushName, - }); + await chatbotController.emit({ + instance: { instanceName: this.instance.name, instanceId: this.instanceId }, + remoteJid: messageRaw.key.remoteJid, + msg: messageRaw, + pushName: messageRaw.pushName, + }); - const contact = await this.prismaRepository.contact.findFirst({ - where: { remoteJid: received.key.remoteJid, instanceId: this.instanceId }, - }); + const contact = await this.prismaRepository.contact.findFirst({ + where: { remoteJid: received.key.remoteJid, instanceId: this.instanceId }, + }); - 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, - instanceId: this.instanceId, - }; + 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, + instanceId: this.instanceId, + }; - if (contactRaw.remoteJid === 'status@broadcast') { - continue; - } + if (contactRaw.remoteJid === 'status@broadcast') { + 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 (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); + if (contact) { + this.sendDataWebhook(Events.CONTACTS_UPDATE, contactRaw); - if (this.configService.get('CHATWOOT').ENABLED && this.localChatwoot?.enabled) { - await this.chatwootService.eventWhatsapp( - Events.CONTACTS_UPDATE, - { instanceName: this.instance.name, instanceId: this.instanceId }, - contactRaw, - ); + if (this.configService.get('CHATWOOT').ENABLED && this.localChatwoot?.enabled) { + await this.chatwootService.eventWhatsapp( + Events.CONTACTS_UPDATE, + { instanceName: this.instance.name, instanceId: this.instanceId }, + contactRaw, + ); + } + + if (this.configService.get('DATABASE').SAVE_DATA.CONTACTS) + await this.prismaRepository.contact.upsert({ + where: { + remoteJid_instanceId: { remoteJid: contactRaw.remoteJid, instanceId: contactRaw.instanceId }, + }, + create: contactRaw, + update: contactRaw, + }); + + continue; } + this.sendDataWebhook(Events.CONTACTS_UPSERT, contactRaw); + if (this.configService.get('DATABASE').SAVE_DATA.CONTACTS) await this.prismaRepository.contact.upsert({ where: { remoteJid_instanceId: { remoteJid: contactRaw.remoteJid, instanceId: contactRaw.instanceId } }, - create: contactRaw, update: contactRaw, + create: contactRaw, }); - - continue; - } - - this.sendDataWebhook(Events.CONTACTS_UPSERT, contactRaw); - - if (this.configService.get('DATABASE').SAVE_DATA.CONTACTS) - await this.prismaRepository.contact.upsert({ - where: { remoteJid_instanceId: { remoteJid: contactRaw.remoteJid, instanceId: contactRaw.instanceId } }, - update: contactRaw, - create: contactRaw, + } catch (error) { + this.logger.error({ + local: 'messages.upsert.message', + message: error?.message, + stack: error?.stack, + keyId: received?.key?.id, + remoteJid: received?.key?.remoteJid, }); + } } } catch (error) { this.logger.error(error); @@ -4703,6 +4769,172 @@ export class BaileysStartupService extends ChannelStartupService { return messageRaw; } + private normalizePhoneNumberFromJid(jid?: string | null): string | null { + if (!jid || !isPnUser(jid)) { + return null; + } + + const user = jid.split('@')[0]?.split(':')[0]; + return user && /^\d+$/.test(user) ? user : null; + } + + private getCampaignMetadata(contextInfo?: any): Record | undefined { + if (!contextInfo) { + return undefined; + } + + const externalAdReply = contextInfo.externalAdReply; + const identifiers = { + sourceId: externalAdReply?.sourceId, + ctwaClid: externalAdReply?.ctwaClid, + ref: externalAdReply?.ref, + clientCampaignId: contextInfo.smbClientCampaignId, + serverCampaignId: contextInfo.smbServerCampaignId, + }; + + const isMetaAds = + contextInfo.conversionSource === 'FB_Ads' || + contextInfo.entryPointConversionSource === 'ctwa_ad' || + Object.values(identifiers).some((value) => value !== undefined && value !== null) || + contextInfo.ctwaSignals !== undefined || + contextInfo.ctwaPayload !== undefined; + + if (!isMetaAds) { + return undefined; + } + + return { + isMetaAds: true, + conversionSource: contextInfo.conversionSource, + entryPointConversionSource: contextInfo.entryPointConversionSource, + entryPointConversionApp: contextInfo.entryPointConversionApp, + identifiers, + ctwaSignals: contextInfo.ctwaSignals, + ctwaPayload: contextInfo.ctwaPayload, + externalAdReply, + }; + } + + private async getChatLabels(key: ExtendedIMessageKey): Promise<{ labelIds: string[]; labels: LabelDto[] }> { + const chatJids = [key.remoteJid, key.remoteJidAlt].filter( + (jid, index, candidates): jid is string => Boolean(jid) && candidates.indexOf(jid) === index, + ); + + if (chatJids.length === 0) { + return { labelIds: [], labels: [] }; + } + + try { + const chats = await this.prismaRepository.chat.findMany({ + where: { instanceId: this.instanceId, remoteJid: { in: chatJids } }, + select: { labels: true }, + }); + + const labelIds = [ + ...new Set( + chats.flatMap((chat) => + Array.isArray(chat.labels) + ? chat.labels.filter((labelId): labelId is string => typeof labelId === 'string') + : [], + ), + ), + ]; + + if (labelIds.length === 0) { + return { labelIds: [], labels: [] }; + } + + const labels = await this.prismaRepository.label.findMany({ + where: { instanceId: this.instanceId, labelId: { in: labelIds } }, + select: { labelId: true, name: true, color: true, predefinedId: true }, + }); + + return { + labelIds, + labels: labels.map((label) => ({ + id: label.labelId, + name: label.name, + color: label.color, + predefinedId: label.predefinedId, + })), + }; + } catch (error) { + this.logger.warn({ + local: 'messages.upsert.getChatLabels', + message: error?.message, + stack: error?.stack, + remoteJid: key.remoteJid, + }); + + return { labelIds: [], labels: [] }; + } + } + + private async resyncAppStateAfterConnection(): Promise { + try { + await this.client.resyncAppState(ALL_WA_PATCH_NAMES, false); + this.logger.info('WhatsApp app state resynchronized after connection'); + } catch (error) { + this.logger.warn({ + local: 'connection.update.resyncAppState', + message: error?.message, + stack: error?.stack, + }); + } + } + + private async resolvePhoneNumber(key: ExtendedIMessageKey): Promise { + const directCandidates = [key.remoteJidAlt, key.participantAlt, key.remoteJid, key.participant]; + + for (const candidate of directCandidates) { + const phoneNumber = this.normalizePhoneNumberFromJid(candidate); + if (phoneNumber) { + return phoneNumber; + } + } + + const lidCandidates = [key.remoteJid, key.participant, key.remoteJidAlt, key.participantAlt].filter( + (candidate): candidate is string => !!candidate?.includes('@lid'), + ); + + for (const lid of lidCandidates) { + try { + const mappedJid = await this.client.signalRepository.lidMapping.getPNForLID(lid); + const phoneNumber = this.normalizePhoneNumberFromJid(mappedJid); + if (phoneNumber) { + return phoneNumber; + } + } catch (error) { + this.logger.debug({ + local: 'messages.upsert.resolvePhoneNumber', + message: error?.message, + lid, + }); + } + } + + if (lidCandidates.length === 0) { + return null; + } + + try { + const cachedMappings = await getOnWhatsappCache(lidCandidates); + for (const mapping of cachedMappings) { + const phoneNumber = this.normalizePhoneNumberFromJid(`${mapping.number}@s.whatsapp.net`); + if (phoneNumber) { + return phoneNumber; + } + } + } catch (error) { + this.logger.debug({ + local: 'messages.upsert.resolvePhoneNumber.cache', + message: error?.message, + }); + } + + return null; + } + private async syncChatwootLostMessages() { if (this.configService.get('CHATWOOT').ENABLED && this.localChatwoot?.enabled) { const chatwootConfig = await this.findChatwoot();