fix(whatsapp): prevent merged lid conversations from returning

This commit is contained in:
wmq
2026-08-03 01:35:44 +08:00
parent 9017c26f5b
commit 48d719e520
4 changed files with 59 additions and 5 deletions
+18 -3
View File
@@ -33,6 +33,7 @@ import { isTimestampOlderThan, webhookEventClaimDecision } from './reliability.j
import { import {
canonicalWhatsAppJid, canonicalWhatsAppJid,
isWeakWhatsAppDisplayName, isWeakWhatsAppDisplayName,
resolvedWhatsAppJid,
reusableWhatsAppConversations, reusableWhatsAppConversations,
whatsappConversationActivityAt, whatsappConversationActivityAt,
whatsappCustomerStableId, whatsappCustomerStableId,
@@ -3231,7 +3232,9 @@ async function upsertWhatsAppMessage(tenantId, channel, rawMessage, options = {}
if (!rawJid || !rawMessage.id) return null if (!rawJid || !rawMessage.id) return null
const aliasCacheKey = `${channel.gatewayAccountId}:${rawJid}` const aliasCacheKey = `${channel.gatewayAccountId}:${rawJid}`
const cachedJid = whatsappCanonicalJidCache.get(aliasCacheKey) const cachedJid = whatsappCanonicalJidCache.get(aliasCacheKey)
const preliminaryJid = cachedJid || rawJid const rawCanonicalJid = canonicalWhatsAppJid(rawJid)
const hasCanonicalAlias = Boolean(cachedJid && cachedJid !== rawCanonicalJid)
const preliminaryJid = hasCanonicalAlias ? cachedJid : rawCanonicalJid
const rawConversationId = `${channel.id}:${rawJid}` const rawConversationId = `${channel.id}:${rawJid}`
const preliminaryConversationId = `${channel.id}:${preliminaryJid}` const preliminaryConversationId = `${channel.id}:${preliminaryJid}`
const preliminaryCustomer = await resolveWhatsAppCustomer( const preliminaryCustomer = await resolveWhatsAppCustomer(
@@ -3242,7 +3245,7 @@ async function upsertWhatsAppMessage(tenantId, channel, rawMessage, options = {}
) )
const needsIdentity = Boolean( const needsIdentity = Boolean(
options.identity options.identity
|| (rawJid.endsWith('@lid') && !cachedJid) || (rawJid.endsWith('@lid') && !hasCanonicalAlias)
|| !preliminaryCustomer || !preliminaryCustomer
|| isWeakWhatsAppDisplayName(preliminaryCustomer?.name, rawJid), || isWeakWhatsAppDisplayName(preliminaryCustomer?.name, rawJid),
) )
@@ -3256,8 +3259,15 @@ async function upsertWhatsAppMessage(tenantId, channel, rawMessage, options = {}
avatarUrl: preliminaryCustomer.avatarUrl, avatarUrl: preliminaryCustomer.avatarUrl,
chatType: preliminaryCustomer.whatsappChatType, chatType: preliminaryCustomer.whatsappChatType,
}) })
const jid = cachedJid || canonicalWhatsAppJid(rawJid, identity || {}) const jid = resolvedWhatsAppJid(rawJid, identity || {}, cachedJid)
// A LID is a temporary alias, not a stable customer identity. Never cache an
// unresolved LID as canonical; otherwise a later sync can recreate a merged
// duplicate conversation even after the gateway learns its phone JID.
if (jid !== rawCanonicalJid || !rawJid.endsWith('@lid')) {
whatsappCanonicalJidCache.set(aliasCacheKey, jid) whatsappCanonicalJidCache.set(aliasCacheKey, jid)
} else {
whatsappCanonicalJidCache.delete(aliasCacheKey)
}
const conversationId = `${channel.id}:${jid}` const conversationId = `${channel.id}:${jid}`
if ( if (
await getResource(tenantId, 'conversation-tombstones', conversationId) await getResource(tenantId, 'conversation-tombstones', conversationId)
@@ -3478,6 +3488,11 @@ async function mergeLegacyWhatsAppConversations(tenantId, channel, jid, aliasJid
await deleteResource(tenantId, `messages:${source.id}`, message.id) await deleteResource(tenantId, `messages:${source.id}`, message.id)
} }
await deleteResource(tenantId, 'conversations', source.id) await deleteResource(tenantId, 'conversations', source.id)
broadcast(tenantId, {
type: 'conversation.deleted',
conversationId: source.id,
mergedIntoConversationId: targetId,
})
} }
await patchResource(tenantId, 'conversations', targetId, { await patchResource(tenantId, 'conversations', targetId, {
customerId, customerId,
+9
View File
@@ -13,6 +13,15 @@ export function canonicalWhatsAppJid(jid, identity = {}) {
return phone ? `${phone}@s.whatsapp.net` : raw return phone ? `${phone}@s.whatsapp.net` : raw
} }
export function resolvedWhatsAppJid(jid, identity = {}, cachedJid = '') {
const fallback = canonicalWhatsAppJid(jid)
const resolved = canonicalWhatsAppJid(jid, identity)
if (resolved && resolved !== fallback) return resolved
const cached = canonicalWhatsAppJid(cachedJid)
return cached && cached !== fallback ? cached : fallback
}
export function whatsappCustomerStableId(jid) { export function whatsappCustomerStableId(jid) {
const normalized = canonicalWhatsAppJid(jid) const normalized = canonicalWhatsAppJid(jid)
const hash = crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 24) const hash = crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 24)
+9
View File
@@ -3,6 +3,7 @@ import test from 'node:test'
import { import {
canonicalWhatsAppJid, canonicalWhatsAppJid,
isWeakWhatsAppDisplayName, isWeakWhatsAppDisplayName,
resolvedWhatsAppJid,
reusableWhatsAppConversations, reusableWhatsAppConversations,
whatsappCustomerStableId, whatsappCustomerStableId,
} from '../src/whatsapp-identity.js' } from '../src/whatsapp-identity.js'
@@ -16,6 +17,14 @@ test('WhatsApp LID resolves to the same canonical phone identity', () => {
assert.equal(canonicalWhatsAppJid('987654321@lid', { canonicalJid: '15551234567:0@s.whatsapp.net' }), phoneJid) assert.equal(canonicalWhatsAppJid('987654321@lid', { canonicalJid: '15551234567:0@s.whatsapp.net' }), phoneJid)
}) })
test('a newly resolved phone identity overrides a stale cached LID', () => {
const lid = '987654321@lid'
const phoneJid = '15551234567@s.whatsapp.net'
assert.equal(resolvedWhatsAppJid(lid, {}, lid), lid)
assert.equal(resolvedWhatsAppJid(lid, { phone: '+1 555 123 4567' }, lid), phoneJid)
assert.equal(resolvedWhatsAppJid(lid, {}, phoneJid), phoneJid)
})
test('WhatsApp customer id remains stable after gateway reauthorization', () => { test('WhatsApp customer id remains stable after gateway reauthorization', () => {
assert.equal( assert.equal(
whatsappCustomerStableId('15551234567@s.whatsapp.net'), whatsappCustomerStableId('15551234567@s.whatsapp.net'),
+22 -1
View File
@@ -300,6 +300,27 @@ export function InboxPage() {
backfill?: import('@/types').AiBackfillStatus backfill?: import('@/types').AiBackfillStatus
} }
try { event = JSON.parse(String(raw.data)) } catch { return } try { event = JSON.parse(String(raw.data)) } catch { return }
if (event.type === 'conversation.deleted' && event.conversationId) {
queryClient.removeQueries({ queryKey: ['conversation', event.conversationId] })
queryClient.removeQueries({ queryKey: ['messages', event.conversationId] })
queryClient.setQueriesData<{ list: ConversationListItem[]; total: number }>(
{ queryKey: ['conversations'] },
(current) => current
? {
...current,
list: current.list.filter((item) => item.id !== event.conversationId),
total: Math.max(0, current.total - (current.list.some((item) => item.id === event.conversationId) ? 1 : 0)),
}
: current,
)
if (convId === event.conversationId) {
setSearchParams((prev) => {
prev.delete('c')
return prev
})
}
return
}
const activeConversationCache = queryClient.getQueryData<{ list: ConversationListItem[] }>( const activeConversationCache = queryClient.getQueryData<{ list: ConversationListItem[] }>(
activeConversationQueryKeyRef.current, activeConversationQueryKeyRef.current,
) )
@@ -396,7 +417,7 @@ export function InboxPage() {
if (reconnectTimer) clearTimeout(reconnectTimer) if (reconnectTimer) clearTimeout(reconnectTimer)
socket?.close() socket?.close()
} }
}, [token, queryClient]) }, [token, queryClient, convId, setSearchParams])
const setConv = useCallback( const setConv = useCallback(
(id: string) => { (id: string) => {