fix(whatsapp): preserve history across reauthorization

This commit is contained in:
wmq
2026-08-01 23:13:28 +08:00
parent 785a5b196c
commit 00b0ebb775
5 changed files with 210 additions and 12 deletions
+2
View File
@@ -4,6 +4,8 @@
## [Unreleased] ## [Unreleased]
- 修复 WhatsApp 账号重新授权后因网关账号 ID 与联系人名称变化导致历史会话分叉的问题;客户按稳定 JID 复用,旧渠道消息、任务、风险和商机自动归并,并保留最近有效的联系人资料。
### 新增 ### 新增
- Telegram Gateway 支持环境级默认 SOCKS5/HTTP/MTProto 代理,账号专属绑定仍具有更高优先级。 - Telegram Gateway 支持环境级默认 SOCKS5/HTTP/MTProto 代理,账号专属绑定仍具有更高优先级。
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "node src/index.js", "start": "node src/index.js",
"test": "node --test test/business.test.js", "test": "node --test test/business.test.js test/whatsapp-identity.test.js",
"test:email-protocol": "node --test test/email-protocol.test.js", "test:email-protocol": "node --test test/email-protocol.test.js",
"test:email-api": "node --test test/email-api-e2e.test.js" "test:email-api": "node --test test/email-api-e2e.test.js"
}, },
+139 -11
View File
@@ -18,6 +18,12 @@ import { conversationContext, deepseek } from './ai.js'
import { countBy, dateKey, percent, telegramAttachment } from './business.js' import { countBy, dateKey, percent, telegramAttachment } from './business.js'
import { resolveCustomerCountry } from './country.js' import { resolveCustomerCountry } from './country.js'
import { knowledgeMetadata, rankKnowledge } from './knowledge.js' import { knowledgeMetadata, rankKnowledge } from './knowledge.js'
import {
isWeakWhatsAppDisplayName,
reusableWhatsAppConversations,
whatsappConversationActivityAt,
whatsappCustomerStableId,
} from './whatsapp-identity.js'
import { import {
decryptEmailPassword, decryptEmailPassword,
encryptEmailPassword, encryptEmailPassword,
@@ -1475,18 +1481,27 @@ app.post('/api/conversations/whatsapp/open', async (req, res) => {
const identity = await whatsappRequest( const identity = await whatsappRequest(
`/accounts/${channel.gatewayAccountId}/chats/${encodeURIComponent(jid)}/identity`, `/accounts/${channel.gatewayAccountId}/chats/${encodeURIComponent(jid)}/identity`,
) )
const customerId = `wa-${channel.gatewayAccountId}-${jid}` const conversationId = `${channel.id}:${jid}`
const existingCustomer = await resolveWhatsAppCustomer(
req.auth.tenantId,
channel,
jid,
conversationId,
)
const customerId = existingCustomer?.id || whatsappCustomerStableId(jid)
const customer = await putResource(req.auth.tenantId, 'customers', withDefaults('customers', { const customer = await putResource(req.auth.tenantId, 'customers', withDefaults('customers', {
...(existingCustomer || {}),
id: customerId, id: customerId,
name: identity.name || jid.split('@')[0], name: !isWeakWhatsAppDisplayName(existingCustomer?.name, jid)
phone: identity.phone || '', ? existingCustomer.name
avatarUrl: identity.avatarUrl || undefined, : identity.name || jid.split('@')[0],
phone: identity.phone || existingCustomer?.phone || '',
avatarUrl: identity.avatarUrl || existingCustomer?.avatarUrl || undefined,
whatsapp: jid, whatsapp: jid,
whatsappChatType: identity.chatType || 'private', whatsappChatType: identity.chatType || 'private',
channel: 'whatsapp', channel: 'whatsapp',
source: 'whatsapp', source: 'whatsapp',
})) }))
const conversationId = `${channel.id}:${jid}`
await deleteResource(req.auth.tenantId, 'conversation-tombstones', conversationId) await deleteResource(req.auth.tenantId, 'conversation-tombstones', conversationId)
const previous = await getResource(req.auth.tenantId, 'conversations', conversationId) const previous = await getResource(req.auth.tenantId, 'conversations', conversationId)
const conversation = await putResource(req.auth.tenantId, 'conversations', { const conversation = await putResource(req.auth.tenantId, 'conversations', {
@@ -3146,6 +3161,32 @@ function whatsappMessageType(type) {
return 'text' return 'text'
} }
async function resolveWhatsAppCustomer(tenantId, channel, jid, conversationId) {
const current = await getResource(tenantId, 'conversations', conversationId)
if (current?.customerId) {
const customer = await getResource(tenantId, 'customers', current.customerId)
if (customer) return customer
}
const [channels, conversations] = await Promise.all([
listResources(tenantId, 'channels'),
listResources(tenantId, 'conversations'),
])
const activeChannelIds = new Set(channels.map((item) => String(item.id)))
const reusable = reusableWhatsAppConversations(
conversations,
activeChannelIds,
channel.id,
jid,
)
for (const conversation of reusable) {
if (!conversation.customerId) continue
const customer = await getResource(tenantId, 'customers', conversation.customerId)
if (customer) return customer
}
return getResource(tenantId, 'customers', whatsappCustomerStableId(jid))
}
async function upsertWhatsAppMessage(tenantId, channel, rawMessage, options = {}) { async function upsertWhatsAppMessage(tenantId, channel, rawMessage, options = {}) {
const jid = String(rawMessage.chatId || '') const jid = String(rawMessage.chatId || '')
if (!jid || !rawMessage.id) return null if (!jid || !rawMessage.id) return null
@@ -3172,15 +3213,19 @@ async function upsertWhatsAppMessage(tenantId, channel, rawMessage, options = {}
}) })
return { message, reaction: rawMessage, isNew: false } return { message, reaction: rawMessage, isNew: false }
} }
const customerId = `wa-${channel.gatewayAccountId}-${jid}` const existingCustomer = await resolveWhatsAppCustomer(tenantId, channel, jid, conversationId)
const existingCustomer = await getResource(tenantId, 'customers', customerId) const customerId = existingCustomer?.id || whatsappCustomerStableId(jid)
const identity = options.identity || await whatsappRequest( const identity = options.identity || await whatsappRequest(
`/accounts/${channel.gatewayAccountId}/chats/${encodeURIComponent(jid)}/identity`, `/accounts/${channel.gatewayAccountId}/chats/${encodeURIComponent(jid)}/identity`,
).catch(() => null) ).catch(() => null)
const customer = await putResource(tenantId, 'customers', withDefaults('customers', { const customer = await putResource(tenantId, 'customers', withDefaults('customers', {
...(existingCustomer || {}), ...(existingCustomer || {}),
id: customerId, id: customerId,
name: String(identity?.name || rawMessage.pushName || existingCustomer?.name || jid.split('@')[0]), name: String(
!isWeakWhatsAppDisplayName(existingCustomer?.name, jid)
? existingCustomer.name
: identity?.name || rawMessage.pushName || existingCustomer?.name || jid.split('@')[0],
),
phone: String(identity?.phone || existingCustomer?.phone || ''), phone: String(identity?.phone || existingCustomer?.phone || ''),
avatarUrl: identity?.avatarUrl || existingCustomer?.avatarUrl, avatarUrl: identity?.avatarUrl || existingCustomer?.avatarUrl,
whatsapp: jid, whatsapp: jid,
@@ -3267,6 +3312,85 @@ async function upsertWhatsAppMessage(tenantId, channel, rawMessage, options = {}
return { message, customer, conversation, isNew: !existingMessage } return { message, customer, conversation, isNew: !existingMessage }
} }
async function mergeLegacyWhatsAppConversations(tenantId, channel, jid) {
const targetId = `${channel.id}:${jid}`
const target = await getResource(tenantId, 'conversations', targetId)
if (!target) return 0
const [channels, conversations] = await Promise.all([
listResources(tenantId, 'channels'),
listResources(tenantId, 'conversations'),
])
const activeChannelIds = new Set(channels.map((item) => String(item.id)))
const legacy = conversations.filter((item) => (
item.channel === 'whatsapp'
&& String(item.whatsappJid || '') === jid
&& String(item.id) !== targetId
&& !activeChannelIds.has(String(item.channelId || String(item.id).split(':')[0]))
))
if (!legacy.length) return 0
let imported = 0
let earliestCreatedAt = target.createdAt
let lastMessage = target.lastMessage
let customerId = target.customerId
let canonicalActivityAt = whatsappConversationActivityAt(target)
const mergedCustomerIds = new Set(target.customerId ? [target.customerId] : [])
const mergedIds = new Set(target.mergedLegacyConversationIds || [])
for (const source of legacy) {
if (source.customerId) mergedCustomerIds.add(source.customerId)
const sourceActivityAt = whatsappConversationActivityAt(source)
if (source.customerId && sourceActivityAt >= canonicalActivityAt) {
customerId = source.customerId
canonicalActivityAt = sourceActivityAt
}
const messages = await listResources(tenantId, `messages:${source.id}`)
for (const message of messages) {
const exists = await getResource(tenantId, `messages:${targetId}`, message.id)
await putResource(tenantId, `messages:${targetId}`, {
...message,
conversationId: targetId,
})
if (!exists) imported++
if (!lastMessage || Date.parse(message.createdAt || '') > Date.parse(lastMessage.createdAt || '')) {
lastMessage = { ...message, conversationId: targetId }
}
}
if (!earliestCreatedAt || Date.parse(source.createdAt || '') < Date.parse(earliestCreatedAt)) {
earliestCreatedAt = source.createdAt
}
mergedIds.add(source.id)
for (const message of messages) {
await deleteResource(tenantId, `messages:${source.id}`, message.id)
}
await deleteResource(tenantId, 'conversations', source.id)
}
await patchResource(tenantId, 'conversations', targetId, {
customerId,
createdAt: earliestCreatedAt || target.createdAt,
lastMessage,
mergedLegacyConversationIds: [...mergedIds],
historySyncedAt: new Date().toISOString(),
})
for (const duplicateCustomerId of mergedCustomerIds) {
if (!duplicateCustomerId || duplicateCustomerId === customerId) continue
for (const kind of ['opportunities', 'tasks', 'risks']) {
const resources = await listResources(tenantId, kind)
for (const resource of resources.filter((item) => item.customerId === duplicateCustomerId)) {
await patchResource(tenantId, kind, resource.id, { customerId })
}
}
const remainingConversations = (await listResources(tenantId, 'conversations'))
.some((item) => item.customerId === duplicateCustomerId)
const remainingBusinessReferences = (await Promise.all(
['opportunities', 'tasks', 'risks'].map((kind) => listResources(tenantId, kind)),
)).some((items) => items.some((item) => item.customerId === duplicateCustomerId))
if (!remainingConversations && !remainingBusinessReferences) {
await deleteResource(tenantId, 'customers', duplicateCustomerId)
}
}
return imported
}
async function performWhatsAppTenantSync(tenantId, channel, options = {}) { async function performWhatsAppTenantSync(tenantId, channel, options = {}) {
const state = await whatsappRequest(`/accounts/${channel.gatewayAccountId}/auth/state`) const state = await whatsappRequest(`/accounts/${channel.gatewayAccountId}/auth/state`)
if (state.status !== 'connected' || !['open', 'ready'].includes(state.connectionState)) { if (state.status !== 'connected' || !['open', 'ready'].includes(state.connectionState)) {
@@ -3314,6 +3438,7 @@ async function performWhatsAppTenantSync(tenantId, channel, options = {}) {
? String(history.beforeCursor) ? String(history.beforeCursor)
: '' : ''
} while (historyCursor && historyPages < 100) } while (historyCursor && historyPages < 100)
messagesSynced += await mergeLegacyWhatsAppConversations(tenantId, channel, jid)
const conversationId = `${channel.id}:${jid}` const conversationId = `${channel.id}:${jid}`
const current = await getResource(tenantId, 'conversations', conversationId) const current = await getResource(tenantId, 'conversations', conversationId)
if (current) { if (current) {
@@ -4757,14 +4882,17 @@ app.post('/internal/webhooks/whatsapp/:tenantId/:channelId', async (req, res) =>
for (const contact of req.body?.data?.contacts || []) { for (const contact of req.body?.data?.contacts || []) {
const jid = String(contact.id || '') const jid = String(contact.id || '')
if (!jid) continue if (!jid) continue
const customerId = `wa-${channel.gatewayAccountId}-${jid}` const conversationId = `${channel.id}:${jid}`
const existing = await getResource(tenantId, 'customers', customerId) const existing = await resolveWhatsAppCustomer(tenantId, channel, jid, conversationId)
if (!existing) continue if (!existing) continue
const customerId = existing.id
const identity = await whatsappRequest( const identity = await whatsappRequest(
`/accounts/${channel.gatewayAccountId}/chats/${encodeURIComponent(jid)}/identity`, `/accounts/${channel.gatewayAccountId}/chats/${encodeURIComponent(jid)}/identity`,
).catch(() => null) ).catch(() => null)
const customer = await patchResource(tenantId, 'customers', customerId, { const customer = await patchResource(tenantId, 'customers', customerId, {
name: identity?.name || contact.name || contact.notify || contact.verifiedName || existing.name, name: !isWeakWhatsAppDisplayName(existing.name, jid)
? existing.name
: identity?.name || contact.name || contact.notify || contact.verifiedName || existing.name,
phone: identity?.phone || existing.phone, phone: identity?.phone || existing.phone,
avatarUrl: identity?.avatarUrl || existing.avatarUrl, avatarUrl: identity?.avatarUrl || existing.avatarUrl,
whatsappVerifiedName: contact.verifiedName || existing.whatsappVerifiedName, whatsappVerifiedName: contact.verifiedName || existing.whatsappVerifiedName,
+36
View File
@@ -0,0 +1,36 @@
import crypto from 'node:crypto'
export function whatsappCustomerStableId(jid) {
const normalized = String(jid || '').trim().toLowerCase()
const hash = crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 24)
return `wa-customer-${hash}`
}
export function whatsappConversationActivityAt(conversation) {
return Date.parse(
conversation?.lastMessage?.createdAt
|| conversation?.updatedAt
|| conversation?.createdAt
|| '',
) || 0
}
export function isWeakWhatsAppDisplayName(value, jid) {
const name = String(value || '').trim()
const jidName = String(jid || '').split('@')[0].replace(/\D/g, '')
const digits = name.replace(/\D/g, '')
return !name || (digits.length >= 7 && (!jidName || digits === jidName))
}
export function reusableWhatsAppConversations(conversations, activeChannelIds, channelId, jid) {
return conversations
.filter((item) => (
item.channel === 'whatsapp'
&& String(item.whatsappJid || '') === String(jid)
&& (
String(item.channelId || '') === String(channelId)
|| !activeChannelIds.has(String(item.channelId || String(item.id).split(':')[0]))
)
))
.sort((left, right) => whatsappConversationActivityAt(right) - whatsappConversationActivityAt(left))
}
+32
View File
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
isWeakWhatsAppDisplayName,
reusableWhatsAppConversations,
whatsappCustomerStableId,
} from '../src/whatsapp-identity.js'
test('WhatsApp customer id remains stable after gateway reauthorization', () => {
assert.equal(
whatsappCustomerStableId('15551234567@s.whatsapp.net'),
whatsappCustomerStableId('15551234567@s.whatsapp.net'),
)
assert.match(whatsappCustomerStableId('15551234567@s.whatsapp.net'), /^wa-customer-[a-f0-9]{24}$/)
})
test('the latest inactive conversation is reused while another active account stays isolated', () => {
const jid = '15551234567@s.whatsapp.net'
const conversations = [
{ id: 'old:newest', channel: 'whatsapp', channelId: 'old', whatsappJid: jid, customerId: 'abigail', lastMessage: { createdAt: '2026-07-31T07:51:37.000Z' } },
{ id: 'older:history', channel: 'whatsapp', channelId: 'older', whatsappJid: jid, customerId: 'legacy', lastMessage: { createdAt: '2026-07-29T17:25:42.000Z' } },
{ id: 'other:active', channel: 'whatsapp', channelId: 'other', whatsappJid: jid, customerId: 'other-account', lastMessage: { createdAt: '2026-08-01T00:00:00.000Z' } },
]
const result = reusableWhatsAppConversations(conversations, new Set(['current', 'other']), 'current', jid)
assert.deepEqual(result.map((item) => item.customerId), ['abigail', 'legacy'])
})
test('a saved contact name is not replaced by a phone-number fallback', () => {
const jid = '15551234567@s.whatsapp.net'
assert.equal(isWeakWhatsAppDisplayName('Abigail', jid), false)
assert.equal(isWeakWhatsAppDisplayName('+1 555 123 4567', jid), true)
})