From 00b0ebb77569d9b0d9fc95ee1c90bd074b9bfe82 Mon Sep 17 00:00:00 2001 From: wmq Date: Sat, 1 Aug 2026 23:13:28 +0800 Subject: [PATCH] fix(whatsapp): preserve history across reauthorization --- CHANGELOG.md | 2 + server/package.json | 2 +- server/src/index.js | 150 ++++++++++++++++++++++++-- server/src/whatsapp-identity.js | 36 +++++++ server/test/whatsapp-identity.test.js | 32 ++++++ 5 files changed, 210 insertions(+), 12 deletions(-) create mode 100644 server/src/whatsapp-identity.js create mode 100644 server/test/whatsapp-identity.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 9785c40..58edef3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ## [Unreleased] +- 修复 WhatsApp 账号重新授权后因网关账号 ID 与联系人名称变化导致历史会话分叉的问题;客户按稳定 JID 复用,旧渠道消息、任务、风险和商机自动归并,并保留最近有效的联系人资料。 + ### 新增 - Telegram Gateway 支持环境级默认 SOCKS5/HTTP/MTProto 代理,账号专属绑定仍具有更高优先级。 diff --git a/server/package.json b/server/package.json index 00e04c9..d52f7d9 100644 --- a/server/package.json +++ b/server/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "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-api": "node --test test/email-api-e2e.test.js" }, diff --git a/server/src/index.js b/server/src/index.js index d76b8e7..79386f2 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -18,6 +18,12 @@ import { conversationContext, deepseek } from './ai.js' import { countBy, dateKey, percent, telegramAttachment } from './business.js' import { resolveCustomerCountry } from './country.js' import { knowledgeMetadata, rankKnowledge } from './knowledge.js' +import { + isWeakWhatsAppDisplayName, + reusableWhatsAppConversations, + whatsappConversationActivityAt, + whatsappCustomerStableId, +} from './whatsapp-identity.js' import { decryptEmailPassword, encryptEmailPassword, @@ -1475,18 +1481,27 @@ app.post('/api/conversations/whatsapp/open', async (req, res) => { const identity = await whatsappRequest( `/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', { + ...(existingCustomer || {}), id: customerId, - name: identity.name || jid.split('@')[0], - phone: identity.phone || '', - avatarUrl: identity.avatarUrl || undefined, + name: !isWeakWhatsAppDisplayName(existingCustomer?.name, jid) + ? existingCustomer.name + : identity.name || jid.split('@')[0], + phone: identity.phone || existingCustomer?.phone || '', + avatarUrl: identity.avatarUrl || existingCustomer?.avatarUrl || undefined, whatsapp: jid, whatsappChatType: identity.chatType || 'private', channel: 'whatsapp', source: 'whatsapp', })) - const conversationId = `${channel.id}:${jid}` await deleteResource(req.auth.tenantId, 'conversation-tombstones', conversationId) const previous = await getResource(req.auth.tenantId, 'conversations', conversationId) const conversation = await putResource(req.auth.tenantId, 'conversations', { @@ -3146,6 +3161,32 @@ function whatsappMessageType(type) { 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 = {}) { const jid = String(rawMessage.chatId || '') if (!jid || !rawMessage.id) return null @@ -3172,15 +3213,19 @@ async function upsertWhatsAppMessage(tenantId, channel, rawMessage, options = {} }) return { message, reaction: rawMessage, isNew: false } } - const customerId = `wa-${channel.gatewayAccountId}-${jid}` - const existingCustomer = await getResource(tenantId, 'customers', customerId) + const existingCustomer = await resolveWhatsAppCustomer(tenantId, channel, jid, conversationId) + const customerId = existingCustomer?.id || whatsappCustomerStableId(jid) const identity = options.identity || await whatsappRequest( `/accounts/${channel.gatewayAccountId}/chats/${encodeURIComponent(jid)}/identity`, ).catch(() => null) const customer = await putResource(tenantId, 'customers', withDefaults('customers', { ...(existingCustomer || {}), 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 || ''), avatarUrl: identity?.avatarUrl || existingCustomer?.avatarUrl, whatsapp: jid, @@ -3267,6 +3312,85 @@ async function upsertWhatsAppMessage(tenantId, channel, rawMessage, options = {} 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 = {}) { const state = await whatsappRequest(`/accounts/${channel.gatewayAccountId}/auth/state`) if (state.status !== 'connected' || !['open', 'ready'].includes(state.connectionState)) { @@ -3314,6 +3438,7 @@ async function performWhatsAppTenantSync(tenantId, channel, options = {}) { ? String(history.beforeCursor) : '' } while (historyCursor && historyPages < 100) + messagesSynced += await mergeLegacyWhatsAppConversations(tenantId, channel, jid) const conversationId = `${channel.id}:${jid}` const current = await getResource(tenantId, 'conversations', conversationId) if (current) { @@ -4757,14 +4882,17 @@ app.post('/internal/webhooks/whatsapp/:tenantId/:channelId', async (req, res) => for (const contact of req.body?.data?.contacts || []) { const jid = String(contact.id || '') if (!jid) continue - const customerId = `wa-${channel.gatewayAccountId}-${jid}` - const existing = await getResource(tenantId, 'customers', customerId) + const conversationId = `${channel.id}:${jid}` + const existing = await resolveWhatsAppCustomer(tenantId, channel, jid, conversationId) if (!existing) continue + const customerId = existing.id const identity = await whatsappRequest( `/accounts/${channel.gatewayAccountId}/chats/${encodeURIComponent(jid)}/identity`, ).catch(() => null) 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, avatarUrl: identity?.avatarUrl || existing.avatarUrl, whatsappVerifiedName: contact.verifiedName || existing.whatsappVerifiedName, diff --git a/server/src/whatsapp-identity.js b/server/src/whatsapp-identity.js new file mode 100644 index 0000000..90d78b9 --- /dev/null +++ b/server/src/whatsapp-identity.js @@ -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)) +} diff --git a/server/test/whatsapp-identity.test.js b/server/test/whatsapp-identity.test.js new file mode 100644 index 0000000..aea727a --- /dev/null +++ b/server/test/whatsapp-identity.test.js @@ -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) +})