fix(whatsapp): prevent merged lid conversations from returning
This commit is contained in:
+18
-3
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user