fix(whatsapp): preserve history across reauthorization
This commit is contained in:
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- 修复 WhatsApp 账号重新授权后因网关账号 ID 与联系人名称变化导致历史会话分叉的问题;客户按稳定 JID 复用,旧渠道消息、任务、风险和商机自动归并,并保留最近有效的联系人资料。
|
||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|
||||||
- Telegram Gateway 支持环境级默认 SOCKS5/HTTP/MTProto 代理,账号专属绑定仍具有更高优先级。
|
- Telegram Gateway 支持环境级默认 SOCKS5/HTTP/MTProto 代理,账号专属绑定仍具有更高优先级。
|
||||||
|
|||||||
+1
-1
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user