fix(whatsapp): mensagens do celular perdidas para contatos @lid + crash P2002

Três correções independentes para mensagens enviadas/recebidas via celular:

1. WhatsAppConnectionManager: o listener CB:message agora captura também
   recipient_pn (InfiniteAPI v7+) além de peer_recipient_pn (Baileys oficial),
   garantindo que o PN correto do destinatário seja gravado no peerRecipientHints
   antes do messages.upsert ser emitido.

2. MessageHandler.processMessage: adicionado fast path fromMe=false via
   remoteJidAlt para contatos @lid (InfiniteAPI já resolve o PN do remetente).
   Para fromMe=true onde InfiniteAPI resolveu @lid→nosso próprio JID incorretamente,
   recupera o JID correto via peerRecipientHints antes de descartar como self-chat.

3. MessageHandler.processHistoryBatch: corrigido guard senderPn que não testava
   !msg.key.fromMe; para mensagens enviadas, senderPn é o nosso próprio JID,
   não o destinatário — o que criava self-chats corrompidos no DB.

4. whatsapp.send.routes: adicionado .catch P2002 no upsert da rota POST /send;
   race condition com eco Baileys causava unhandled rejection → exit(1) no Node 20.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Deploy Agent
2026-05-12 03:10:50 +02:00
parent ab688b7ef8
commit 9d082a17e4
3 changed files with 75 additions and 20 deletions
@@ -285,24 +285,29 @@ export class WhatsAppConnectionManager {
const msgHandler = new MessageHandler(sock, instanceId, tenantId, this.io, this.chatbotService)
this.msgHandlers.set(instanceId, msgHandler)
// ─── Intercepta stanzas brutas pra extrair peer_recipient_pn ─────────
// O Baileys (decode-wa-message.js) expõe só sender_pn/participant_pn no
// key — mas NÃO expõe peer_recipient_pn, que é a única fonte de verdade
// pro telefone real do DESTINATÁRIO em mensagens fromMe=true enviadas
// pelo celular pra contatos @lid novos (sem mapeamento salvo).
// ─── Intercepta stanzas brutas pra extrair PN real do destinatário ──
// Dois cenários distintos de mensagens do celular que chegam sem PN:
//
// Sem essa dica, `resolveLidToPhoneJid` retornaria null e a mensagem
// seria descartada — o usuário relatou exatamente esse sintoma ("envio
// pelo celular da sessão pro número X e a msg não aparece na inbox").
// Cenário A (Baileys oficial): mensagem fromMe=true com remoteJid=@lid
// O campo peer_recipient_pn na stanza bruta contém o PN do destinatário.
// Baileys não expõe esse campo em key, por isso lemos aqui.
//
// O listener roda em paralelo com o handler interno do Baileys: ambos
// são callbacks síncronos do EventEmitter de ws, então o mapa é
// Cenário B (InfiniteAPI v7+): mensagem fromMe=true onde o InfiniteAPI
// resolve automaticamente @lid → nosso próprio JID (usando sender_pn
// da stanza, que para fromMe=true é NOSSO PN, não o do destinatário).
// O PN correto do destinatário está em recipient_pn na stanza.
// Sem essa dica, processMessage descarta a mensagem como self-chat.
//
// O listener é síncrono (mesmo EventEmitter do ws), então o mapa é
// populado antes do `messages.upsert` ser emitido de forma assíncrona.
;(sock.ws as any).on('CB:message', (node: any) => {
const msgId = node?.attrs?.id
const peerRecipientPn = node?.attrs?.peer_recipient_pn
if (msgId && peerRecipientPn) {
msgHandler.setPeerRecipientHint(msgId, peerRecipientPn)
if (!msgId) return
// peer_recipient_pn: Cenário A (@lid não resolvido, Baileys oficial)
// recipient_pn: Cenário B (InfiniteAPI resolveu @lid para nosso PN)
const hint = node?.attrs?.peer_recipient_pn ?? node?.attrs?.recipient_pn
if (hint) {
msgHandler.setPeerRecipientHint(msgId, hint)
}
})
@@ -279,8 +279,12 @@ export class MessageHandler {
if (remoteJidAlt && remoteJidAlt.endsWith('@s.whatsapp.net')) {
jidRaw = normalizeJid(remoteJidAlt)
} else {
// 2. senderPn / participantPn
const senderPn = (msg.key as any).senderPn || (msg.key as any).participantPn
// 2. senderPn / participantPn — SOMENTE para mensagens recebidas (fromMe=false).
// Para fromMe=true, senderPn é o NOSSO próprio JID (somos o remetente),
// não o JID do destinatário — usar aqui criaria um self-chat corrompido no DB.
const senderPn = !msg.key.fromMe
? ((msg.key as any).senderPn || (msg.key as any).participantPn)
: null
if (senderPn) {
jidRaw = normalizeJid(senderPn)
if (!jidRaw.endsWith('@s.whatsapp.net')) continue
@@ -452,7 +456,17 @@ export class MessageHandler {
*/
let originalLidJid: string | null = null
if (key.remoteJid.endsWith('@lid')) {
const phoneJid = await this.resolveLidToPhoneJid(key.remoteJid, key as any)
// Fast path para fromMe=false: InfiniteAPI preenche remoteJidAlt com o PN
// do remetente (que é o contato) quando addressing_mode=pn. Para fromMe=true,
// remoteJidAlt contem o NOSSO próprio PN (senderAlt = sender_pn = nosso número)
// — por isso o fast path só é seguro para mensagens recebidas.
const remoteJidAlt = (key as any).remoteJidAlt as string | undefined
let phoneJid: string | null = null
if (!key.fromMe && remoteJidAlt && remoteJidAlt.endsWith('@s.whatsapp.net')) {
phoneJid = normalizeJid(remoteJidAlt)
} else {
phoneJid = await this.resolveLidToPhoneJid(key.remoteJid, key as any)
}
if (!phoneJid) {
// Sem resolução: não há como saber o número real do contato.
// A mensagem será perdida, mas o mapeamento será capturado no futuro
@@ -480,12 +494,36 @@ export class MessageHandler {
const fromMe = key.fromMe ?? false
const remoteJidRaw = key.remoteJid
/** JID normalizado (sem sufixo de dispositivo :1, :2 etc) */
const remoteJid = normalizeJid(remoteJidRaw)
let remoteJid = normalizeJid(remoteJidRaw)
const messageId = key.id!
// Ignora mensagens de/para o próprio número da sessão (self-chat / notas pessoais)
// Ignora mensagens de/para o próprio número da sessão (self-chat / notas pessoais).
//
// Caso especial (InfiniteAPI v7+): para mensagens fromMe=true enviadas do celular
// a um contato LID, o InfiniteAPI pode ter resolvido remoteJid para o nosso próprio
// JID (usando sender_pn = nosso PN em vez de recipient_pn = PN do destinatário).
// Nesse caso, o CB:message listener armazenou recipient_pn em peerRecipientHints.
// Tentamos recuperar o JID correto antes de descartar como self-chat.
const ownJid = normalizeJid(this.sock.user?.id ?? '')
if (ownJid && remoteJid === ownJid) return
if (ownJid && remoteJid === ownJid) {
if (fromMe) {
const hint = this.peerRecipientHints.get(messageId)
if (hint) {
this.peerRecipientHints.delete(messageId)
const correctJid = normalizeJid(hint)
if (correctJid && correctJid.endsWith('@s.whatsapp.net') && correctJid !== ownJid) {
remoteJid = correctJid
;(key as any).remoteJid = correctJid
} else {
return
}
} else {
return
}
} else {
return
}
}
// ── 1. Contact: criar ou atualizar ────────────────────────────────────
// REGRA IMPORTANTE sobre pushName:
@@ -223,7 +223,10 @@ export function buildSendRoutes(manager: WhatsAppConnectionManager): Router {
richPayloadData = { text, footer, nativeCarousel }
}
const msg = await prisma.message.upsert({
// Race condition: o eco do Baileys pode chegar ANTES desta linha e criar o
// registro primeiro, fazendo o upsert falhar com P2002 (unique constraint).
// Nesses casos, buscamos a mensagem que já foi criada pelo eco.
let msg = await prisma.message.upsert({
where: { instanceId_messageId: { instanceId, messageId } },
create: {
tenantId,
@@ -239,6 +242,15 @@ export function buildSendRoutes(manager: WhatsAppConnectionManager): Router {
timestamp: new Date(),
},
update: {}, // já existe (eco do Baileys chegou primeiro) — não sobrescreve
}).catch(async (err: any) => {
if (err?.code === 'P2002') {
// Eco do Baileys criou o registro antes de nós — busca e retorna ele
const existing = await prisma.message.findUnique({
where: { instanceId_messageId: { instanceId, messageId } },
})
if (existing) return existing
}
throw err
})
// ── Emite eventos Socket.IO para o frontend atualizar em tempo real ───────