11 Commits

31 changed files with 1257 additions and 135 deletions
+3
View File
@@ -41,6 +41,9 @@ SMTP_FROM=
# 建议使用 32 字节以上独立随机值;未配置时回退到 LX_JWT_SECRET 派生密钥。 # 建议使用 32 字节以上独立随机值;未配置时回退到 LX_JWT_SECRET 派生密钥。
EMAIL_CREDENTIAL_KEY= EMAIL_CREDENTIAL_KEY=
EMAIL_POLL_MS=30000 EMAIL_POLL_MS=30000
MESSAGE_RECONCILE_MS=60000
WHATSAPP_GATEWAY_TIMEOUT_MS=30000
TELEGRAM_GATEWAY_TIMEOUT_MS=30000
# 真实 Stripe Checkout # 真实 Stripe Checkout
STRIPE_SECRET_KEY= STRIPE_SECRET_KEY=
+2
View File
@@ -4,6 +4,8 @@
## [Unreleased] ## [Unreleased]
- 完成双消息网关 P0 可靠性闭环:Webhook 采用 processing/completed/failed 可恢复状态机,Telegram 编辑与撤回落库,60 秒增量游标对账、5 分钟死信自动重放、每日资料刷新和每周受控全量对账。
- Telegram 会话发现改为 TDLib 持续加载并持久化全部会话,平台按 offset 分页读取;网关请求增加超时与任务并发合并,避免单个资料接口阻塞全部同步。
- 修复 WhatsApp 账号重新授权后因网关账号 ID 与联系人名称变化导致历史会话分叉的问题;客户按稳定 JID 复用,旧渠道消息、任务、风险和商机自动归并,并保留最近有效的联系人资料。 - 修复 WhatsApp 账号重新授权后因网关账号 ID 与联系人名称变化导致历史会话分叉的问题;客户按稳定 JID 复用,旧渠道消息、任务、风险和商机自动归并,并保留最近有效的联系人资料。
### 新增 ### 新增
+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/whatsapp-identity.test.js", "test": "node --test test/business.test.js test/reliability.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"
}, },
+1 -1
View File
@@ -71,7 +71,7 @@ export async function deepseek(system, user, jsonOrOptions = false) {
export function conversationContext(messages, limit = 30) { export function conversationContext(messages, limit = 30) {
return messages.slice(-limit).map((m) => { return messages.slice(-limit).map((m) => {
const role = m.sender === 'agent' ? '坐席' : m.sender === 'system' ? '系统' : '客户' const role = m.sender === 'agent' ? '我方' : m.sender === 'system' ? '系统' : '对方'
const content = m.originalText || m.attachmentName || `[${m.type || '消息'}]` const content = m.originalText || m.attachmentName || `[${m.type || '消息'}]`
const translation = m.translatedText && m.translatedText !== content ? `(译文:${m.translatedText})` : '' const translation = m.translatedText && m.translatedText !== content ? `(译文:${m.translatedText})` : ''
return `${m.createdAt || ''} ${role}: ${content}${translation}` return `${m.createdAt || ''} ${role}: ${content}${translation}`
+19
View File
@@ -74,3 +74,22 @@ export const countBy = (list, key) => {
} }
return counts return counts
} }
export const businessRelationshipLabels = {
buyer: '下游买家',
supplier: '上游供应商',
partner: '合作伙伴',
distributor: '经销商',
procurement_agent: '采购代理',
logistics: '物流服务商',
service_provider: '专业服务商',
internal: '内部同事',
other: '其他',
unknown: '待确认',
}
export function normalizeBusinessRelationship(value) {
const type = Object.hasOwn(businessRelationshipLabels, String(value || ''))
? String(value)
: 'unknown'
return { type, label: businessRelationshipLabels[type] }
}
+3
View File
@@ -45,6 +45,9 @@ export async function gatewayRequest(path, options = {}) {
if (!gatewayToken || tokenExpiresSoon(gatewayToken)) await refreshGatewayToken() if (!gatewayToken || tokenExpiresSoon(gatewayToken)) await refreshGatewayToken()
const send = () => fetch(`${base()}${path}`, { const send = () => fetch(`${base()}${path}`, {
...options, ...options,
signal: options.signal || AbortSignal.timeout(
Math.max(5_000, Number(process.env.TELEGRAM_GATEWAY_TIMEOUT_MS || 30_000)),
),
headers: { headers: {
...(options.body instanceof FormData ? {} : { 'content-type': 'application/json' }), ...(options.body instanceof FormData ? {} : { 'content-type': 'application/json' }),
authorization: `Bearer ${gatewayToken}`, authorization: `Bearer ${gatewayToken}`,
+711 -85
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
export function webhookEventClaimDecision(existing, now = Date.now(), leaseMs = 2 * 60_000) {
if (existing?.status === 'completed') return 'duplicate'
const processingAt = Date.parse(existing?.processingAt || '')
if (
existing?.status === 'processing'
&& Number.isFinite(processingAt)
&& processingAt > now - leaseMs
) return 'busy'
return 'claim'
}
export function isTimestampOlderThan(value, ageMs, now = Date.now()) {
const parsed = Date.parse(value || '')
return !Number.isFinite(parsed) || parsed < now - ageMs
}
+123
View File
@@ -0,0 +1,123 @@
const TONE_INSTRUCTIONS = {
professional: '专业但不官腔,表达清楚、克制,像熟悉业务的真人销售在聊天',
friendly: '友好自然,适度口语化,像长期合作伙伴沟通,不刻意热情',
concise: '简洁直接,优先给结论和下一步,不重复客户已经知道的信息',
warm: '温和、有同理心,但不使用夸张道歉、空泛安慰或营销套话',
}
export function replyToneInstruction(value) {
return TONE_INSTRUCTIONS[String(value || '').toLowerCase()] || TONE_INSTRUCTIONS.professional
}
export const HUMAN_REPLY_SYSTEM_PROMPT = `你是“灵犀跨境”的商务沟通助手。你的任务不是写客服范文,而是替一位真实、可靠、熟悉业务的销售人员起草可以直接发送的聊天回复。
写作原则:
1. 先回应客户最新一条消息里尚未解决的核心问题,再给必要的下一步;不要复述整段历史。
2. 匹配客户的语言、地区表达、正式程度和消息长度。客户只说一句时,通常也用一两句回答;不为显得专业而写长文。
3. 延续当前对话。会话已经开始后不要重新问候、重复称呼客户或重新自我介绍。
4. 像真人聊天:句式自然、具体,允许使用目标语言中常见的缩写和口语连接词,但保持商务礼貌。不要比客户更随意;客户未使用俚语、感叹号或表情时,不要主动使用。
5. 客诉场景只需针对具体问题真诚确认一次,然后说明能做的动作或提出一个关键问题;避免连续道歉和空泛安慰。
6. 信息足够时直接作答;确实缺少决定性信息时最多问一个有用的问题。不要在一句回复里堆多个反问。
7. 三条建议必须是三种真实策略,而不是同一句话替换近义词:stable=清晰可靠,active=温暖主动,brief=简洁直接。
8. 禁止客服和 AI 套话,包括但不限于:Dear customer、尊敬的客户、please rest assured、请放心、we value your feedback、感谢您的耐心与理解、为您带来不便深表歉意、How may I assist you、如有任何问题请随时联系我们。除非上下文确实需要,也不要使用“希望您一切都好”。
9. 不得提及“AI、提示词、客户画像、会话记忆、知识库、情绪识别”等内部信息。
10. 不得编造价格、库存、产品参数、交期、物流状态、退款结果、政策或任何承诺。缺少事实时自然地说明需要确认,不要假装确定。“今天、稍后、尽快、本周内、几个小时内”等时间词也属于承诺,只有企业知识或会话事实明确给出时才能使用。
11. 可以承诺“去确认”,但不能在没有依据时承诺确认结果、完成时间、补偿方案、重新寄样或其他尚未批准的动作。正确写法是“我先确认是否可以调整,确认后告诉你”。
输出要求:只输出严格 JSON 数组。content 使用客户语言且可直接发送;translationCn 是 content 完整准确的简体中文翻译;knowledgeRefs 只能填写已提供知识的 id;reason、expectedEffect 和 cultureTip 使用简体中文。`
export function buildHumanReplyUserPrompt({
targetInstruction,
tone,
latestCustomerMessage,
latestAgentMessage,
conversation,
conversationMemory,
knowledgeContext,
}) {
return `客户目标语言:${targetInstruction}
企业设定语气:${replyToneInstruction(tone)}
客户最新消息(回复重点):${latestCustomerMessage || '未找到明确文本,请根据最近会话判断'}
坐席上一条回复(避免重复):${latestAgentMessage || '无'}
<conversation>
${conversation}
</conversation>
${conversationMemory ? `<conversation_memory>\n${conversationMemory}\n</conversation_memory>` : ''}
<enterprise_knowledge>
${JSON.stringify(knowledgeContext || [])}
</enterprise_knowledge>
事实边界:${knowledgeContext?.length ? '只能使用上述企业知识和会话中明确出现的业务事实。' : '当前没有匹配到企业知识;不得加入任何具体时间、结果、方案或承诺,只能说明需要确认的事项。'}
分别生成以下三种建议:
- stable:最稳妥、事实清楚、自然推进下一步;
- active:更温暖主动,但不催促、不夸张、不强行促单;
- brief:保留必要信息的最短自然回复。
生成前静默检查:是否直接回应了最新消息;是否重复问候或复述历史;是否像群发模板;是否包含未经证实的承诺;三条是否真正不同。不要输出检查过程。
输出恰好 3 个元素,每种 style 各一个:
{"id":"唯一值","style":"stable|active|brief","content":"可直接发送的客户语言回复","translationCn":"完整中文翻译","targetLang":"实际 ISO 语言代码","reason":"这条回复为何适合当前语境","knowledgeRefs":[],"expectedEffect":"预期沟通效果","cultureTip":"只有确有必要时填写,否则为空字符串"}`
}
function normalizedWords(value) {
return String(value || '')
.toLowerCase()
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
.split(/\s+/)
.filter(Boolean)
}
function similarity(left, right) {
const a = new Set(normalizedWords(left))
const b = new Set(normalizedWords(right))
if (!a.size || !b.size) return 0
let overlap = 0
for (const word of a) if (b.has(word)) overlap += 1
return overlap / Math.min(a.size, b.size)
}
const TEMPLATE_PATTERNS = [
/\bdear customer\b/i,
/\bplease rest assured\b/i,
/\bwe value your feedback\b/i,
/\bhow may i assist you\b/i,
/尊敬的客户/,
/请您放心/,
/感谢您的耐心与理解/,
/为您带来不便深表歉意/,
]
const UNSUPPORTED_TIMELINE_PATTERNS = [
/\b(?:today|tomorrow|shortly|asap)\b/i,
/\bby (?:the )?end of\b/i,
/\bwithin \d+\s*(?:minutes?|hours?|days?|weeks?)\b/i,
/(?:今天|明天|稍后|很快|尽快|本周内|几(?:分钟|小时|天)内)/,
]
export function replySuggestionIssues(suggestions, { knowledgeProvided = false } = {}) {
if (!Array.isArray(suggestions) || suggestions.length !== 3) return ['必须返回三条建议']
const issues = []
const styles = new Set(suggestions.map((item) => String(item?.style || '')))
for (const style of ['stable', 'active', 'brief']) {
if (!styles.has(style)) issues.push(`缺少 ${style} 风格`)
}
suggestions.forEach((item, index) => {
const content = String(item?.content || '').trim()
if (!content) issues.push(`第 ${index + 1} 条没有正文`)
if (TEMPLATE_PATTERNS.some((pattern) => pattern.test(content))) {
issues.push(`第 ${index + 1} 条包含模板化客服话术`)
}
if (!knowledgeProvided && UNSUPPORTED_TIMELINE_PATTERNS.some((pattern) => pattern.test(content))) {
issues.push(`第 ${index + 1} 条包含无事实依据的时间承诺`)
}
})
for (let i = 0; i < suggestions.length; i += 1) {
for (let j = i + 1; j < suggestions.length; j += 1) {
if (similarity(suggestions[i]?.content, suggestions[j]?.content) >= 0.82) {
issues.push(`第 ${i + 1} 条和第 ${j + 1} 条过于相似`)
}
}
}
return issues
}
+23 -1
View File
@@ -1,7 +1,29 @@
import crypto from 'node:crypto' import crypto from 'node:crypto'
export function canonicalWhatsAppJid(jid, identity = {}) {
const normalizeDevice = (value) => String(value || '')
.trim()
.toLowerCase()
.replace(/:\d+(?=@s\.whatsapp\.net$)/, '')
const raw = normalizeDevice(jid)
if (!raw || raw.endsWith('@g.us') || raw.endsWith('@newsletter')) return raw
const canonical = normalizeDevice(identity?.canonicalJid)
if (canonical.endsWith('@s.whatsapp.net')) return canonical
const phone = String(identity?.phone || '').split(':')[0].replace(/\D/g, '')
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 = String(jid || '').trim().toLowerCase() 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)
return `wa-customer-${hash}` return `wa-customer-${hash}`
} }
+3
View File
@@ -7,6 +7,9 @@ export async function whatsappRequest(path, options = {}) {
} }
const response = await fetch(`${base()}${path}`, { const response = await fetch(`${base()}${path}`, {
...options, ...options,
signal: options.signal || AbortSignal.timeout(
Math.max(5_000, Number(process.env.WHATSAPP_GATEWAY_TIMEOUT_MS || 30_000)),
),
headers: { headers: {
...(options.body instanceof FormData ? {} : { 'content-type': 'application/json' }), ...(options.body instanceof FormData ? {} : { 'content-type': 'application/json' }),
authorization: `Bearer ${token}`, authorization: `Bearer ${token}`,
+56 -1
View File
@@ -1,7 +1,19 @@
import test from 'node:test' import test from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { countBy, dateKey, percent, telegramAttachment } from '../src/business.js' import {
countBy,
dateKey,
normalizeBusinessRelationship,
percent,
telegramAttachment,
} from '../src/business.js'
import { decryptEmailPassword, encryptEmailPassword } from '../src/email.js' import { decryptEmailPassword, encryptEmailPassword } from '../src/email.js'
import {
HUMAN_REPLY_SYSTEM_PROMPT,
buildHumanReplyUserPrompt,
replySuggestionIssues,
replyToneInstruction,
} from '../src/reply-suggestions.js'
test('Telegram 图片、文档、语音和贴纸映射为可下载文件', () => { test('Telegram 图片、文档、语音和贴纸映射为可下载文件', () => {
assert.deepEqual( assert.deepEqual(
@@ -44,6 +56,11 @@ test('业务指标工具处理百分比、分组和无效日期', () => {
) )
}) })
test('业务关系分类使用受控类型和中文标签', () => {
assert.deepEqual(normalizeBusinessRelationship('supplier'), { type: 'supplier', label: '上游供应商' })
assert.deepEqual(normalizeBusinessRelationship('unsupported'), { type: 'unknown', label: '待确认' })
})
test('Email 渠道凭据使用认证加密且可正确解密', () => { test('Email 渠道凭据使用认证加密且可正确解密', () => {
process.env.EMAIL_CREDENTIAL_KEY = 'lingxi-email-test-key-with-more-than-32-bytes' process.env.EMAIL_CREDENTIAL_KEY = 'lingxi-email-test-key-with-more-than-32-bytes'
const encrypted = encryptEmailPassword('app-password-123') const encrypted = encryptEmailPassword('app-password-123')
@@ -54,3 +71,41 @@ test('Email 渠道凭据使用认证加密且可正确解密', () => {
tampered[2] = `${tampered[2][0] === 'A' ? 'B' : 'A'}${tampered[2].slice(1)}` tampered[2] = `${tampered[2][0] === 'A' ? 'B' : 'A'}${tampered[2].slice(1)}`
assert.throws(() => decryptEmailPassword(tampered.join(':'))) assert.throws(() => decryptEmailPassword(tampered.join(':')))
}) })
test('回复建议提示词突出最新消息、避免重复并遵循企业语气', () => {
const prompt = buildHumanReplyUserPrompt({
targetInstruction: '使用西班牙语',
tone: 'friendly',
latestCustomerMessage: '¿Cuándo pueden enviarlo?',
latestAgentMessage: 'I will check it.',
conversation: '客户: ¿Cuándo pueden enviarlo?',
conversationMemory: '客户关注交期',
knowledgeContext: [],
})
assert.match(HUMAN_REPLY_SYSTEM_PROMPT, /不是写客服范文/)
assert.match(HUMAN_REPLY_SYSTEM_PROMPT, /不要重新问候/)
assert.match(prompt, /¿Cuándo pueden enviarlo\?/)
assert.match(prompt, /坐席上一条回复(避免重复)/)
assert.match(prompt, /长期合作伙伴/)
assert.match(replyToneInstruction('concise'), /简洁直接/)
})
test('回复建议质量检查拦截模板话术和同质草稿', () => {
const valid = [
{ style: 'stable', content: 'I can confirm the dispatch date with our warehouse and update you once I have it.' },
{ style: 'active', content: "Let me check the warehouse schedule and come back to you with the exact date." },
{ style: 'brief', content: "I'll verify the dispatch date and get back to you." },
]
assert.deepEqual(replySuggestionIssues(valid), [])
const invalid = valid.map((item) => ({ ...item }))
invalid[0].content = 'Dear customer, please rest assured that we value your feedback.'
invalid[2] = { style: 'active', content: invalid[1].content }
const issues = replySuggestionIssues(invalid)
assert.ok(issues.some((item) => item.includes('模板化')))
assert.ok(issues.some((item) => item.includes('缺少 brief')))
assert.ok(issues.some((item) => item.includes('过于相似')))
assert.ok(replySuggestionIssues([
...valid.slice(0, 2),
{ style: 'brief', content: "I'll confirm it today." },
]).some((item) => item.includes('时间承诺')))
})
+18
View File
@@ -0,0 +1,18 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { isTimestampOlderThan, webhookEventClaimDecision } from '../src/reliability.js'
test('completed webhooks deduplicate while failed and expired processing events can resume', () => {
const now = Date.parse('2026-08-01T12:00:00.000Z')
assert.equal(webhookEventClaimDecision({ status: 'completed' }, now), 'duplicate')
assert.equal(webhookEventClaimDecision({ status: 'failed' }, now), 'claim')
assert.equal(webhookEventClaimDecision({ status: 'processing', processingAt: '2026-08-01T11:59:30.000Z' }, now), 'busy')
assert.equal(webhookEventClaimDecision({ status: 'processing', processingAt: '2026-08-01T11:57:00.000Z' }, now), 'claim')
})
test('missing timestamps are due and recent maintenance timestamps are not', () => {
const now = Date.parse('2026-08-01T12:00:00.000Z')
assert.equal(isTimestampOlderThan(undefined, 60_000, now), true)
assert.equal(isTimestampOlderThan('2026-08-01T11:58:00.000Z', 60_000, now), true)
assert.equal(isTimestampOlderThan('2026-08-01T11:59:30.000Z', 60_000, now), false)
})
+19
View File
@@ -1,11 +1,30 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import { import {
canonicalWhatsAppJid,
isWeakWhatsAppDisplayName, isWeakWhatsAppDisplayName,
resolvedWhatsAppJid,
reusableWhatsAppConversations, reusableWhatsAppConversations,
whatsappCustomerStableId, whatsappCustomerStableId,
} from '../src/whatsapp-identity.js' } from '../src/whatsapp-identity.js'
test('WhatsApp LID resolves to the same canonical phone identity', () => {
const phoneJid = '15551234567@s.whatsapp.net'
assert.equal(canonicalWhatsAppJid('987654321@lid', { phone: '+1 555 123 4567' }), phoneJid)
assert.equal(canonicalWhatsAppJid('987654321@lid', { canonicalJid: phoneJid }), phoneJid)
assert.equal(whatsappCustomerStableId(canonicalWhatsAppJid('987654321@lid', { phone: '+1 555 123 4567' })), whatsappCustomerStableId(phoneJid))
assert.equal(canonicalWhatsAppJid('15551234567:4@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'),
+3 -3
View File
@@ -10,9 +10,9 @@ import { toast, errorMessage } from '@/app/store/toast'
import { languageName } from '@/config/languages' import { languageName } from '@/config/languages'
const STYLE_LABELS = { const STYLE_LABELS = {
stable: { title: '稳健回复', color: 'bg-blue-100 text-blue-700', icon: '🛡️' }, stable: { title: '清晰可靠', color: 'bg-blue-100 text-blue-700', icon: '🛡️' },
active: { title: '积极促单回复', color: 'bg-emerald-100 text-emerald-700', icon: '🔥' }, active: { title: '温暖主动', color: 'bg-violet-100 text-violet-700', icon: '💬' },
brief: { title: '简洁快速回复', color: 'bg-gray-100 text-gray-600', icon: '⚡' }, brief: { title: '简洁直接', color: 'bg-gray-100 text-gray-600', icon: '⚡' },
} }
export function AiReplies({ export function AiReplies({
@@ -25,7 +25,7 @@ export function ConversationInsightBar({
aiBackfill?: AiBackfillStatus aiBackfill?: AiBackfillStatus
}) { }) {
const stage = validStages.has(customer.stage) ? customer.stage : 'initial' const stage = validStages.has(customer.stage) ? customer.stage : 'initial'
const summary = customer.aiProfile?.summary || conversation.summary || '收到更多客户消息后将补充客户画像' const summary = customer.aiProfile?.summary || conversation.summary || '收到更多消息后将补充联系人画像'
return ( return (
<div <div
@@ -38,7 +38,7 @@ export function ConversationInsightBar({
</span> </span>
<div className="flex min-w-0 flex-1 items-center gap-2"> <div className="flex min-w-0 flex-1 items-center gap-2">
<span className="hidden shrink-0 text-[10px] font-semibold uppercase tracking-[0.12em] text-violet-600 sm:inline"> <span className="hidden shrink-0 text-[10px] font-semibold uppercase tracking-[0.12em] text-violet-600 sm:inline">
客户洞察 对话洞察
</span> </span>
<p className="truncate text-xs leading-5 text-slate-600">{summary}</p> <p className="truncate text-xs leading-5 text-slate-600">{summary}</p>
</div> </div>
@@ -55,12 +55,17 @@ export function ConversationInsightBar({
<span className="flex items-center gap-1 text-xs text-muted-foreground"> <span className="flex items-center gap-1 text-xs text-muted-foreground">
意向 <IntentScoreBadge score={Number(customer.intentScore || 0)} /> 意向 <IntentScoreBadge score={Number(customer.intentScore || 0)} />
</span> </span>
{customer.aiProfile?.relationshipLabel && (
<span className="rounded-full bg-indigo-50 px-2 py-1 text-[10px] font-medium text-indigo-700">
{customer.aiProfile.relationshipLabel}
</span>
)}
<SentimentBadge sentiment={customer.sentiment || 'neutral'} /> <SentimentBadge sentiment={customer.sentiment || 'neutral'} />
<CustomerStageBadge stage={stage as CustomerStage} /> <CustomerStageBadge stage={stage as CustomerStage} />
<RiskLevelBadge level={customer.riskLevel || 'none'} /> <RiskLevelBadge level={customer.riskLevel || 'none'} />
</div> </div>
<Button variant="ghost" size="sm" onClick={onOpenProfile} title="打开客户画像"> <Button variant="ghost" size="sm" onClick={onOpenProfile} title="打开联系人画像">
<span className="hidden lg:inline">客户画像</span> <span className="hidden lg:inline">联系人画像</span>
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</Button> </Button>
</div> </div>
+6 -4
View File
@@ -31,6 +31,7 @@ import { IntentScoreBadge, CustomerStageBadge, SentimentBadge, RiskLevelBadge }
import { CUSTOMER_STAGES, NEXT_ACTIONS, countryMeta, INTENT_LEVELS } from '@/config/constants' import { CUSTOMER_STAGES, NEXT_ACTIONS, countryMeta, INTENT_LEVELS } from '@/config/constants'
import { toast, errorMessage } from '@/app/store/toast' import { toast, errorMessage } from '@/app/store/toast'
import { cn } from '@/utils/cn' import { cn } from '@/utils/cn'
import { formatProductName } from '@/utils/format'
import { CustomerProfileRadar } from '@/components/customer/CustomerProfileRadar' import { CustomerProfileRadar } from '@/components/customer/CustomerProfileRadar'
import { CustomerSemanticTag } from '@/components/customer/CustomerSemanticTag' import { CustomerSemanticTag } from '@/components/customer/CustomerSemanticTag'
@@ -151,8 +152,8 @@ export function InsightPanel({
</div> </div>
<div className="mt-3 flex flex-wrap gap-1.5"> <div className="mt-3 flex flex-wrap gap-1.5">
{customer.products?.map((p) => ( {customer.products?.map((p, index) => (
<Badge key={p} variant="primary">{p}</Badge> <Badge key={`${formatProductName(p)}-${index}`} variant="primary">{formatProductName(p)}</Badge>
))} ))}
{customer.tags?.map((t) => ( {customer.tags?.map((t) => (
<CustomerSemanticTag key={t} tag={t} /> <CustomerSemanticTag key={t} tag={t} />
@@ -249,14 +250,15 @@ export function InsightPanel({
<span className="ml-2 text-sm text-muted-foreground">正在分析会话上下文…</span> <span className="ml-2 text-sm text-muted-foreground">正在分析会话上下文…</span>
</div> </div>
) : ( ) : (
<p className="mt-3 text-xs leading-6 text-slate-600">{analysis || '收到更多客户消息后将补充客户画像。'}</p> <p className="mt-3 text-xs leading-6 text-slate-600">{analysis || '收到更多消息后将补充联系人画像。'}</p>
)} )}
{/* AI 提取关键信息 */} {/* AI 提取关键信息 */}
{Object.values(customer.aiProfile || {}).some(Boolean) && ( {Object.values(customer.aiProfile || {}).some(Boolean) && (
<div className="mt-4 grid grid-cols-2 gap-2 border-t border-slate-100 pt-4"> <div className="mt-4 grid grid-cols-2 gap-2 border-t border-slate-100 pt-4">
{Object.entries({ {Object.entries({
关系类型: customer.aiProfile.relationshipLabel,
行业: customer.aiProfile.industry, 行业: customer.aiProfile.industry,
采购角色: customer.aiProfile.purchasingRole, 业务身份: customer.aiProfile.purchasingRole,
询价数量: customer.aiProfile.inquiryQuantity, 询价数量: customer.aiProfile.inquiryQuantity,
目标价格: customer.aiProfile.targetPrice, 目标价格: customer.aiProfile.targetPrice,
预算: customer.aiProfile.budget, 预算: customer.aiProfile.budget,
+4 -3
View File
@@ -48,6 +48,7 @@ import {
INTENT_LEVELS, INTENT_LEVELS,
} from '@/config/constants' } from '@/config/constants'
import { cn } from '@/utils/cn' import { cn } from '@/utils/cn'
import { formatProductName } from '@/utils/format'
import type { Customer } from '@/types' import type { Customer } from '@/types'
import type { ChannelType } from '@/types/messaging' import type { ChannelType } from '@/types/messaging'
@@ -285,7 +286,7 @@ export function CustomersPage() {
<Td onClick={() => navigate(`/customers/${customer.id}`)}><CountryFlag code={customer.country} /></Td> <Td onClick={() => navigate(`/customers/${customer.id}`)}><CountryFlag code={customer.country} /></Td>
<Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="text-xs">{customer.email || customer.phone || '—'}</span></Td> <Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="text-xs">{customer.email || customer.phone || '—'}</span></Td>
<Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="flex items-center gap-1"><ChannelIcon channel={customer.channel} size="xs" withBg={false} />{customer.channel}</span></Td> <Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="flex items-center gap-1"><ChannelIcon channel={customer.channel} size="xs" withBg={false} />{customer.channel}</span></Td>
<Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="text-xs">{customer.products[0] || '—'}</span></Td> <Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="text-xs">{formatProductName(customer.products?.[0]) || '—'}</span></Td>
<Td><IntentScoreBadge score={customer.intentScore} /></Td> <Td><IntentScoreBadge score={customer.intentScore} /></Td>
<Td><CustomerStageBadge stage={customer.stage} /></Td> <Td><CustomerStageBadge stage={customer.stage} /></Td>
<Td><SentimentBadge sentiment={customer.sentiment} emojiOnly /></Td> <Td><SentimentBadge sentiment={customer.sentiment} emojiOnly /></Td>
@@ -333,8 +334,8 @@ export function CustomersPage() {
<SentimentBadge sentiment={customer.sentiment} emojiOnly /> <SentimentBadge sentiment={customer.sentiment} emojiOnly />
{customer.riskLevel !== 'none' && <RiskLevelBadge level={customer.riskLevel} />} {customer.riskLevel !== 'none' && <RiskLevelBadge level={customer.riskLevel} />}
</div> </div>
{customer.products.length > 0 && ( {customer.products?.length > 0 && (
<p className="mt-2 text-xs text-muted-foreground truncate">{customer.products[0]}</p> <p className="mt-2 text-xs text-muted-foreground truncate">{formatProductName(customer.products[0])}</p>
)} )}
</Card> </Card>
))} ))}
+2 -2
View File
@@ -30,7 +30,7 @@ import { useAuthStore } from '@/app/store/auth'
import { useI18n } from '@/i18n' import { useI18n } from '@/i18n'
import { getPlan } from '@/config/pricingPlans' import { getPlan } from '@/config/pricingPlans'
import { countryMeta, taskTypeLabel, CHANNEL_META, riskLevelMeta } from '@/config/constants' import { countryMeta, taskTypeLabel, CHANNEL_META, riskLevelMeta } from '@/config/constants'
import { formatDateTime, formatRelative } from '@/utils/format' import { formatDateTime, formatProductName, formatRelative } from '@/utils/format'
import { toast, errorMessage } from '@/app/store/toast' import { toast, errorMessage } from '@/app/store/toast'
import { PageLoading, ErrorState, EmptyState } from '@/components/ui/feedback' import { PageLoading, ErrorState, EmptyState } from '@/components/ui/feedback'
import { Card, CardHeader } from '@/components/ui/card' import { Card, CardHeader } from '@/components/ui/card'
@@ -235,7 +235,7 @@ export function DashboardPage() {
<CountryFlag code={c.country} withName={false} /> <CountryFlag code={c.country} withName={false} />
</div> </div>
<p className="mt-0.5 truncate text-xs text-muted-foreground"> <p className="mt-0.5 truncate text-xs text-muted-foreground">
{c.products[0] ?? '—'} · 建议:{c.aiProfile.nextActions[0] ?? '跟进'} {formatProductName(c.products?.[0]) || '—'} · 建议:{c.aiProfile?.nextActions?.[0] ?? '跟进'}
</p> </p>
</div> </div>
<div className="flex shrink-0 flex-col items-end gap-1"> <div className="flex shrink-0 flex-col items-end gap-1">
+34 -6
View File
@@ -94,7 +94,7 @@ function updateRealtimeConversationList(
conversationId: string, conversationId: string,
patch: Partial<ConversationListItem>, patch: Partial<ConversationListItem>,
) { ) {
if (!current) return current if (!current || !Array.isArray(current.list)) return current
let matched = false let matched = false
const list = current.list.map((item) => { const list = current.list.map((item) => {
if (item.id !== conversationId) return item if (item.id !== conversationId) return item
@@ -255,10 +255,14 @@ export function InboxPage() {
queryClient.setQueryData(['conversation', convId], (current: any) => current queryClient.setQueryData(['conversation', convId], (current: any) => current
? { ...current, conversation: { ...current.conversation, ...conversation } } ? { ...current, conversation: { ...current.conversation, ...conversation } }
: current) : current)
queryClient.setQueriesData<any>({ queryKey: ['conversations'] }, (current: any) => current ? { queryClient.setQueriesData<any>({ queryKey: ['conversations'] }, (current: any) => (
current && Array.isArray(current.list)
? {
...current, ...current,
list: current.list.map((item: any) => item.id === convId ? { ...item, unreadCount: 0 } : item), list: current.list.map((item: any) => item.id === convId ? { ...item, unreadCount: 0 } : item),
} : current) }
: current
))
}) })
.catch(() => undefined) .catch(() => undefined)
}, [convId, lastMessageId, queryClient]) }, [convId, lastMessageId, queryClient])
@@ -300,12 +304,34 @@ 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 && Array.isArray(current.list)
? {
...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,
) )
const conversationWasVisible = Boolean( const conversationWasVisible = Boolean(
event.conversationId event.conversationId
&& activeConversationCache?.list.some((item) => item.id === event.conversationId), && Array.isArray(activeConversationCache?.list)
&& activeConversationCache.list.some((item) => item.id === event.conversationId),
) )
if ( if (
['message.created', 'message.received'].includes(event.type || '') && ['message.created', 'message.received'].includes(event.type || '') &&
@@ -335,7 +361,9 @@ export function InboxPage() {
{ queryKey: ['conversations'] }, { queryKey: ['conversations'] },
(current) => { (current) => {
const typed = current as { list: ConversationListItem[]; total: number } | undefined const typed = current as { list: ConversationListItem[]; total: number } | undefined
const item = typed?.list.find((entry) => entry.id === event.conversationId) const item = Array.isArray(typed?.list)
? typed.list.find((entry) => entry.id === event.conversationId)
: undefined
const isNewMessage = ['message.created', 'message.received'].includes(event.type || '') const isNewMessage = ['message.created', 'message.received'].includes(event.type || '')
const isCurrentLastMessage = const isCurrentLastMessage =
String(item?.lastMessage?.id || '') === String(event.message?.id || '') String(item?.lastMessage?.id || '') === String(event.message?.id || '')
@@ -396,7 +424,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) => {
+5
View File
@@ -14,6 +14,10 @@ export type CustomerStage =
/** AI 自动提取的客户画像 */ /** AI 自动提取的客户画像 */
export interface AiCustomerProfile { export interface AiCustomerProfile {
relationshipType?: 'buyer' | 'supplier' | 'partner' | 'distributor' | 'procurement_agent' | 'logistics' | 'service_provider' | 'internal' | 'other' | 'unknown'
relationshipLabel?: string
relationshipConfidence?: number
relationshipEvidence?: string[]
inquiryQuantity?: string inquiryQuantity?: string
targetPrice?: string targetPrice?: string
moqRequirement?: string moqRequirement?: string
@@ -88,6 +92,7 @@ export interface Customer {
createdAt: string createdAt: string
isKey: boolean isKey: boolean
aiProfile: AiCustomerProfile aiProfile: AiCustomerProfile
relationshipType?: AiCustomerProfile['relationshipType']
telegramUsername?: string telegramUsername?: string
telegramUserId?: string telegramUserId?: string
avatarUrl?: string avatarUrl?: string
+3
View File
@@ -69,6 +69,9 @@ export interface Message {
}> }>
telegramFileId?: number telegramFileId?: number
whatsappMessageId?: string whatsappMessageId?: string
gatewayEventAt?: string
ingestedAt?: string
realtimeLatencyMs?: number
whatsappContentType?: string whatsappContentType?: string
attachmentMimeType?: string attachmentMimeType?: string
attachmentSize?: number attachmentSize?: number
+12
View File
@@ -71,3 +71,15 @@ export function localTimeByOffset(offsetHours: number): string {
const target = new Date(utc + offsetHours * 3600000) const target = new Date(utc + offsetHours * 3600000)
return `${pad(target.getHours())}:${pad(target.getMinutes())}` return `${pad(target.getHours())}:${pad(target.getMinutes())}`
} }
export function formatProductName(value: unknown): string {
if (typeof value === 'string' || typeof value === 'number') return String(value).trim()
if (!value || typeof value !== 'object') return ''
const product = value as Record<string, unknown>
for (const key of ['name', 'product', 'category']) {
if (typeof product[key] === 'string' || typeof product[key] === 'number') {
return String(product[key]).trim()
}
}
return ''
}
+5
View File
@@ -38,6 +38,11 @@ CORS_ORIGINS=https://your-control-plane.example
Production uses migrations and keeps TypeORM synchronization disabled. Production uses migrations and keeps TypeORM synchronization disabled.
If Docker build containers cannot reach the default Debian repositories, set
`DEBIAN_MIRROR` and `DEBIAN_SECURITY_MIRROR` to reachable mirrors. HTTP mirrors
can bootstrap older base images that do not yet contain CA certificates; keep
all application and credential traffic on HTTPS.
## Start ## Start
From the project root: From the project root:
@@ -6,6 +6,7 @@ import {
UpdateDateColumn, UpdateDateColumn,
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
Index,
} from 'typeorm'; } from 'typeorm';
import { TgAccount } from './tg-account.entity'; import { TgAccount } from './tg-account.entity';
@@ -18,6 +19,7 @@ export enum ChatType {
} }
@Entity('tg_chats') @Entity('tg_chats')
@Index('IDX_tg_chat_account_identity', ['accountId', 'tgChatId'], { unique: true })
export class TgChat { export class TgChat {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id: string; id: string;
@@ -0,0 +1,36 @@
import { MigrationInterface, QueryRunner, TableIndex } from 'typeorm';
export class TgChatIdentityUnique1700000003000 implements MigrationInterface {
name = 'TgChatIdentityUnique1700000003000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM tg_chats older
USING tg_chats newer
WHERE older."accountId" = newer."accountId"
AND older."tgChatId" = newer."tgChatId"
AND (
older."updatedAt" < newer."updatedAt"
OR (older."updatedAt" = newer."updatedAt" AND older.id < newer.id)
)
`);
const table = await queryRunner.getTable('tg_chats');
if (!table?.indices.some((index) => index.name === 'IDX_tg_chat_account_identity')) {
await queryRunner.createIndex(
'tg_chats',
new TableIndex({
name: 'IDX_tg_chat_account_identity',
columnNames: ['accountId', 'tgChatId'],
isUnique: true,
}),
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
const table = await queryRunner.getTable('tg_chats');
if (table?.indices.some((index) => index.name === 'IDX_tg_chat_account_identity')) {
await queryRunner.dropIndex('tg_chats', 'IDX_tg_chat_account_identity');
}
}
}
@@ -19,8 +19,8 @@ export class TgChatsService {
return this.chatRepo.find({ return this.chatRepo.find({
where: { accountId }, where: { accountId },
order: { lastMessageDate: 'DESC', updatedAt: 'DESC' }, order: { lastMessageDate: 'DESC', updatedAt: 'DESC' },
take: limit, take: Math.min(200, Math.max(1, Number(limit) || 50)),
skip: offset, skip: Math.max(0, Number(offset) || 0),
}); });
} }
@@ -106,6 +106,21 @@ export class TgChatsService {
async syncChatsFromTdlib(tenantId: string, accountId: string): Promise<unknown> { async syncChatsFromTdlib(tenantId: string, accountId: string): Promise<unknown> {
await this.tgAccountsService.findById(tenantId, accountId); await this.tgAccountsService.findById(tenantId, accountId);
let loadedPages = 0;
for (; loadedPages < 100; loadedPages++) {
try {
await this.tdlibClientManager.sendRequest(accountId, {
'@type': 'loadChats',
chat_list: { '@type': 'chatListMain' },
limit: 100,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (/404|all chats.*loaded|not found/i.test(message)) break;
throw error;
}
}
await new Promise((resolve) => setTimeout(resolve, 250));
const result = await this.tdlibClientManager.sendRequest(accountId, { const result = await this.tdlibClientManager.sendRequest(accountId, {
'@type': 'getChats', '@type': 'getChats',
limit: 100, limit: 100,
@@ -123,7 +138,8 @@ export class TgChatsService {
// A deleted/inaccessible chat must not abort the remaining first sync. // A deleted/inaccessible chat must not abort the remaining first sync.
} }
} }
return { total: chatIds.length, synced: synced.length, chatIds }; const total = await this.chatRepo.count({ where: { accountId } });
return { total, synced: synced.length, loadedPages, chatIds };
} }
private async persistTdlibChat(accountId: string, chat: Record<string, any>): Promise<TgChat> { private async persistTdlibChat(accountId: string, chat: Record<string, any>): Promise<TgChat> {
@@ -331,6 +347,11 @@ export class TgChatsService {
chat = this.chatRepo.create({ accountId, ...data }); chat = this.chatRepo.create({ accountId, ...data });
} }
return this.chatRepo.save(chat); const { account: _account, ...persisted } = chat;
// TypeORM's DeepPartial type does not understand the JSON transformer fields.
await this.chatRepo.upsert(persisted as any, ['accountId', 'tgChatId']);
return this.chatRepo.findOneOrFail({
where: { accountId, tgChatId: data.tgChatId },
});
} }
} }
@@ -5,10 +5,11 @@ import { TdlibModule } from '../tdlib/tdlib.module';
import { TgWebhooksModule } from '../tg-webhooks/tg-webhooks.module'; import { TgWebhooksModule } from '../tg-webhooks/tg-webhooks.module';
import { TgUpdatesService } from './tg-updates.service'; import { TgUpdatesService } from './tg-updates.service';
import { TgAccount } from '../../database/entities/tg-account.entity'; import { TgAccount } from '../../database/entities/tg-account.entity';
import { TgChat } from '../../database/entities/tg-chat.entity';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([TgRawUpdate, TgAccount]), TypeOrmModule.forFeature([TgRawUpdate, TgAccount, TgChat]),
TdlibModule, TdlibModule,
TgWebhooksModule, TgWebhooksModule,
], ],
@@ -9,6 +9,7 @@ import {
TgAccount, TgAccount,
TgAccountStatus, TgAccountStatus,
} from '../../database/entities/tg-account.entity'; } from '../../database/entities/tg-account.entity';
import { ChatType, TgChat } from '../../database/entities/tg-chat.entity';
@Injectable() @Injectable()
export class TgUpdatesService implements OnModuleInit, OnModuleDestroy { export class TgUpdatesService implements OnModuleInit, OnModuleDestroy {
@@ -20,6 +21,8 @@ export class TgUpdatesService implements OnModuleInit, OnModuleDestroy {
private readonly rawUpdateRepo: Repository<TgRawUpdate>, private readonly rawUpdateRepo: Repository<TgRawUpdate>,
@InjectRepository(TgAccount) @InjectRepository(TgAccount)
private readonly accountRepo: Repository<TgAccount>, private readonly accountRepo: Repository<TgAccount>,
@InjectRepository(TgChat)
private readonly chatRepo: Repository<TgChat>,
private readonly tdlib: TdlibClientManagerService, private readonly tdlib: TdlibClientManagerService,
private readonly webhooks: TgWebhooksService, private readonly webhooks: TgWebhooksService,
) {} ) {}
@@ -50,6 +53,11 @@ export class TgUpdatesService implements OnModuleInit, OnModuleDestroy {
if (updateType === 'updateAuthorizationState') { if (updateType === 'updateAuthorizationState') {
await this.updateAccountAuthorization(update.accountId, update.data); await this.updateAccountAuthorization(update.accountId, update.data);
} }
if (updateType === 'updateNewChat') {
await this.persistChat(update.accountId, update.data.chat as Record<string, any>);
} else if (updateType === 'updateChatLastMessage') {
await this.persistChatLastMessage(update.accountId, update.data);
}
const event = this.mapWebhookEvent(updateType, update.data); const event = this.mapWebhookEvent(updateType, update.data);
if (event) { if (event) {
@@ -60,6 +68,59 @@ export class TgUpdatesService implements OnModuleInit, OnModuleDestroy {
await this.rawUpdateRepo.save(saved); await this.rawUpdateRepo.save(saved);
} }
private messageText(content: Record<string, any> = {}): string {
return String(
content.text?.text
|| content.caption?.text
|| (content['@type'] ? `[${content['@type']}]` : ''),
);
}
private async persistChat(accountId: string, chat: Record<string, any> | undefined) {
if (!chat?.id) return;
const rawType = String(chat.type?.['@type'] || '');
const typeMap: Record<string, ChatType> = {
chatTypePrivate: ChatType.PRIVATE,
chatTypeBasicGroup: ChatType.GROUP,
chatTypeSupergroup: chat.type?.is_channel ? ChatType.CHANNEL : ChatType.SUPERGROUP,
chatTypeSecret: ChatType.SECRET,
};
const existing = await this.chatRepo.findOne({
where: { accountId, tgChatId: Number(chat.id) },
});
const value = existing || this.chatRepo.create({ accountId, tgChatId: Number(chat.id) });
Object.assign(value, {
type: typeMap[rawType] || existing?.type || ChatType.PRIVATE,
title: String(chat.title || existing?.title || `Telegram ${chat.id}`),
lastMessage: this.messageText(chat.last_message?.content) || existing?.lastMessage || '',
lastMessageId: Number(chat.last_message?.id || existing?.lastMessageId || 0),
lastMessageDate: chat.last_message?.date
? new Date(Number(chat.last_message.date) * 1000)
: existing?.lastMessageDate,
unreadCount: Number(chat.unread_count ?? existing?.unreadCount ?? 0),
photo: chat.photo || existing?.photo,
raw: { ...(existing?.raw || {}), ...chat },
});
const { account: _account, ...persisted } = value;
// TypeORM's DeepPartial type does not understand the JSON transformer fields.
await this.chatRepo.upsert(persisted as any, ['accountId', 'tgChatId']);
}
private async persistChatLastMessage(accountId: string, update: Record<string, any>) {
const chatId = Number(update.chat_id || 0);
if (!chatId) return;
const existing = await this.chatRepo.findOne({ where: { accountId, tgChatId: chatId } });
if (!existing) return;
const message = update.last_message || {};
existing.lastMessage = this.messageText(message.content) || existing.lastMessage;
existing.lastMessageId = Number(message.id || existing.lastMessageId || 0);
existing.lastMessageDate = message.date
? new Date(Number(message.date) * 1000)
: existing.lastMessageDate;
existing.raw = { ...(existing.raw || {}), last_message: message };
await this.chatRepo.save(existing);
}
private async updateAccountAuthorization( private async updateAccountAuthorization(
accountId: string, accountId: string,
data: Record<string, unknown>, data: Record<string, unknown>,
@@ -159,10 +159,12 @@ describe('Business service behavior', () => {
}); });
it('persists Telegram broadcast channels as channel instead of supergroup', async () => { it('persists Telegram broadcast channels as channel instead of supergroup', async () => {
let stored: any;
const repo = { const repo = {
findOne: jest.fn().mockResolvedValue(null), findOne: jest.fn().mockResolvedValue(null),
create: jest.fn((value) => value), create: jest.fn((value) => value),
save: jest.fn(async (value) => value), upsert: jest.fn(async (value) => { stored = value; }),
findOneOrFail: jest.fn(async () => stored),
}; };
const service = new TgChatsService(repo as any, tdlib as any, accounts as any); const service = new TgChatsService(repo as any, tdlib as any, accounts as any);
const saved = await (service as any).persistTdlibChat('account-1', { const saved = await (service as any).persistTdlibChat('account-1', {
+45 -15
View File
@@ -125,7 +125,7 @@ async function saveWebhookDeliveries() {
const sorted = [...webhookDeliveries.values()] const sorted = [...webhookDeliveries.values()]
.sort((a, b) => Date.parse(b.createdAt || '') - Date.parse(a.createdAt || '')) .sort((a, b) => Date.parse(b.createdAt || '') - Date.parse(a.createdAt || ''))
const active = sorted.filter((item) => !['delivered', 'dead'].includes(item.status)) const active = sorted.filter((item) => !['delivered', 'dead'].includes(item.status))
const terminal = sorted.filter((item) => ['delivered', 'dead'].includes(item.status)).slice(0, 10_000) const terminal = sorted.filter((item) => ['delivered', 'dead'].includes(item.status)).slice(0, 2_000)
const values = [...active, ...terminal] const values = [...active, ...terminal]
const keep = new Set(values.map((item) => item.id)) const keep = new Set(values.map((item) => item.id))
for (const id of webhookDeliveries.keys()) { for (const id of webhookDeliveries.keys()) {
@@ -134,11 +134,20 @@ async function saveWebhookDeliveries() {
await atomicJson(webhookDeliveriesFile, values) await atomicJson(webhookDeliveriesFile, values)
} }
let webhookDeliverySaveChain = Promise.resolve()
function queueWebhookDeliveriesSave() {
webhookDeliverySaveChain = webhookDeliverySaveChain
.catch(() => undefined)
.then(() => saveWebhookDeliveries())
.catch((error) => log.error({ err: error }, 'webhook delivery persistence failed'))
return webhookDeliverySaveChain
}
async function saveOutboundJobs() { async function saveOutboundJobs() {
const sorted = [...outboundJobs.values()] const sorted = [...outboundJobs.values()]
.sort((a, b) => Date.parse(b.createdAt || '') - Date.parse(a.createdAt || '')) .sort((a, b) => Date.parse(b.createdAt || '') - Date.parse(a.createdAt || ''))
const active = sorted.filter((item) => !['sent', 'dead'].includes(item.status)) const active = sorted.filter((item) => !['sent', 'dead'].includes(item.status))
const terminal = sorted.filter((item) => ['sent', 'dead'].includes(item.status)).slice(0, 10_000) const terminal = sorted.filter((item) => ['sent', 'dead'].includes(item.status)).slice(0, 2_000)
const values = [...active, ...terminal] const values = [...active, ...terminal]
const keep = new Set(values.map((item) => item.id)) const keep = new Set(values.map((item) => item.id))
for (const id of outboundJobs.keys()) { for (const id of outboundJobs.keys()) {
@@ -147,6 +156,15 @@ async function saveOutboundJobs() {
await atomicJson(outboundJobsFile, values) await atomicJson(outboundJobsFile, values)
} }
let outboundJobsSaveChain = Promise.resolve()
function queueOutboundJobsSave() {
outboundJobsSaveChain = outboundJobsSaveChain
.catch(() => undefined)
.then(() => saveOutboundJobs())
.catch((error) => log.error({ err: error }, 'outbound job persistence failed'))
return outboundJobsSaveChain
}
async function saveAccounts() { async function saveAccounts() {
const values = [...accounts.values()].map(({ qrDataUrl, pairingCode, ...item }) => item) const values = [...accounts.values()].map(({ qrDataUrl, pairingCode, ...item }) => item)
await atomicJson(accountsFile, values) await atomicJson(accountsFile, values)
@@ -484,7 +502,7 @@ async function processWebhookDelivery(deliveryId) {
delivery.status = 'delivering' delivery.status = 'delivering'
delivery.attempts = Number(delivery.attempts || 0) + 1 delivery.attempts = Number(delivery.attempts || 0) + 1
delivery.updatedAt = new Date().toISOString() delivery.updatedAt = new Date().toISOString()
await saveWebhookDeliveries() void queueWebhookDeliveriesSave()
try { try {
const signature = crypto.createHmac('sha256', webhookSecret) const signature = crypto.createHmac('sha256', webhookSecret)
.update(delivery.payload) .update(delivery.payload)
@@ -523,13 +541,19 @@ async function processWebhookDelivery(deliveryId) {
} finally { } finally {
delivery.updatedAt = new Date().toISOString() delivery.updatedAt = new Date().toISOString()
webhookDeliveriesProcessing.delete(deliveryId) webhookDeliveriesProcessing.delete(deliveryId)
await saveWebhookDeliveries() await queueWebhookDeliveriesSave()
} }
} }
async function deliverWebhook(account, event, data) { async function deliverWebhook(account, event, data) {
if (!account.webhookUrl || !account.webhookActive || !accountWebhookSecret(account)) return if (!account.webhookUrl || !account.webhookActive || !accountWebhookSecret(account)) return
const id = crypto.randomUUID() const messageId = ['message.received', 'message.sent', 'message.edited'].includes(event)
? String(data?.message?.id || '')
: ''
const id = messageId
? `wa-${crypto.createHash('sha256').update(`${account.id}:${event}:${messageId}`).digest('hex').slice(0, 32)}`
: crypto.randomUUID()
if (webhookDeliveries.has(id)) return
const payload = JSON.stringify({ const payload = JSON.stringify({
id, id,
event, event,
@@ -547,8 +571,8 @@ async function deliverWebhook(account, event, data) {
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}) })
await saveWebhookDeliveries()
scheduleWebhookDelivery(id) scheduleWebhookDelivery(id)
await queueWebhookDeliveriesSave()
} }
function queuedOutboundMessage(job) { function queuedOutboundMessage(job) {
@@ -611,7 +635,7 @@ async function processOutboundJob(jobId) {
job.status = 'queued' job.status = 'queued'
job.nextAttemptAt = new Date(Date.now() + 5_000).toISOString() job.nextAttemptAt = new Date(Date.now() + 5_000).toISOString()
job.updatedAt = new Date().toISOString() job.updatedAt = new Date().toISOString()
await saveOutboundJobs() await queueOutboundJobsSave()
scheduleOutboundJob(job.id, 5_000) scheduleOutboundJob(job.id, 5_000)
return queuedOutboundMessage(job) return queuedOutboundMessage(job)
} }
@@ -621,7 +645,7 @@ async function processOutboundJob(jobId) {
job.throttled = true job.throttled = true
job.nextAttemptAt = new Date(Date.now() + throttleDelay).toISOString() job.nextAttemptAt = new Date(Date.now() + throttleDelay).toISOString()
job.updatedAt = new Date().toISOString() job.updatedAt = new Date().toISOString()
await saveOutboundJobs() await queueOutboundJobsSave()
scheduleOutboundJob(job.id, throttleDelay) scheduleOutboundJob(job.id, throttleDelay)
return queuedOutboundMessage(job) return queuedOutboundMessage(job)
} }
@@ -629,7 +653,7 @@ async function processOutboundJob(jobId) {
job.status = 'sending' job.status = 'sending'
job.attempts = Number(job.attempts || 0) + 1 job.attempts = Number(job.attempts || 0) + 1
job.updatedAt = new Date().toISOString() job.updatedAt = new Date().toISOString()
await saveOutboundJobs() void queueOutboundJobsSave()
try { try {
const store = await loadStore(job.accountId) const store = await loadStore(job.accountId)
const quoted = job.quotedMessageId const quoted = job.quotedMessageId
@@ -713,7 +737,7 @@ async function processOutboundJob(jobId) {
} finally { } finally {
job.updatedAt = new Date().toISOString() job.updatedAt = new Date().toISOString()
outboundJobsProcessing.delete(job.id) outboundJobsProcessing.delete(job.id)
await saveOutboundJobs() await queueOutboundJobsSave()
} }
} }
@@ -781,6 +805,7 @@ async function connectAccount(accountId) {
id: jidNormalizedUser(sock.user.id), id: jidNormalizedUser(sock.user.id),
name: sock.user.name || account.name || account.phoneNumber, name: sock.user.name || account.name || account.phoneNumber,
} : account.user } : account.user
delete account.lastError
delete account.qrDataUrl delete account.qrDataUrl
delete account.pairingCode delete account.pairingCode
const session = sessions.get(accountId) const session = sessions.get(accountId)
@@ -1415,7 +1440,7 @@ app.post('/accounts/:id/backup/restore', async (req, res) => {
if (delivery.accountId === account.id) webhookDeliveries.delete(id) if (delivery.accountId === account.id) webhookDeliveries.delete(id)
} }
for (const delivery of snapshot.webhookDeliveries || []) webhookDeliveries.set(delivery.id, delivery) for (const delivery of snapshot.webhookDeliveries || []) webhookDeliveries.set(delivery.id, delivery)
await Promise.all([saveAccounts(), saveOutboundJobs(), saveWebhookDeliveries()]) await Promise.all([saveAccounts(), queueOutboundJobsSave(), queueWebhookDeliveriesSave()])
await connectAccount(account.id) await connectAccount(account.id)
res.json({ code: 0, data: { restored: true, account: accountPublic(account) } }) res.json({ code: 0, data: { restored: true, account: accountPublic(account) } })
}) })
@@ -1571,16 +1596,21 @@ app.get('/accounts/:id/chats/:jid/identity', async (req, res) => {
phoneJid = jid phoneJid = jid
} }
} }
phoneJid = jidNormalizedUser(phoneJid)
const contact = store.contacts.get(jid) || store.contacts.get(phoneJid) || {} const contact = store.contacts.get(jid) || store.contacts.get(phoneJid) || {}
const group = jid.endsWith('@g.us') const group = jid.endsWith('@g.us')
? await sock.groupMetadata(jid).catch(() => store.groups.get(jid)) ? await sock.groupMetadata(jid).catch(() => store.groups.get(jid))
: null : null
const avatarUrl = await sock.profilePictureUrl(jid, 'image') const includeProfile = req.query.includeProfile !== 'false'
const avatarUrl = includeProfile
? await sock.profilePictureUrl(jid, 'image')
.catch(() => phoneJid !== jid ? sock.profilePictureUrl(phoneJid, 'image').catch(() => '') : '') .catch(() => phoneJid !== jid ? sock.profilePictureUrl(phoneJid, 'image').catch(() => '') : '')
: ''
res.json({ res.json({
code: 0, code: 0,
data: { data: {
id: jid, id: jid,
canonicalJid: phoneJid.endsWith('@s.whatsapp.net') ? phoneJid : jid,
name: group?.subject || contact.name || contact.notify || contact.verifiedName || jid.split('@')[0], name: group?.subject || contact.name || contact.notify || contact.verifiedName || jid.split('@')[0],
phone: phoneJid.endsWith('@s.whatsapp.net') ? phoneJid.split('@')[0] : '', phone: phoneJid.endsWith('@s.whatsapp.net') ? phoneJid.split('@')[0] : '',
avatarUrl, avatarUrl,
@@ -1663,7 +1693,7 @@ app.post('/accounts/:id/messages/send', async (req, res) => {
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
} }
outboundJobs.set(job.id, job) outboundJobs.set(job.id, job)
await saveOutboundJobs() await queueOutboundJobsSave()
const result = await processOutboundJob(job.id) const result = await processOutboundJob(job.id)
res.json({ code: 0, data: result || queuedOutboundMessage(job) }) res.json({ code: 0, data: result || queuedOutboundMessage(job) })
}) })
@@ -1776,7 +1806,7 @@ app.post('/accounts/:id/outbound-jobs/:jobId/retry', async (req, res) => {
delete job.deadAt delete job.deadAt
delete job.lastError delete job.lastError
delete job.nextAttemptAt delete job.nextAttemptAt
await saveOutboundJobs() await queueOutboundJobsSave()
const result = await processOutboundJob(job.id) const result = await processOutboundJob(job.id)
res.json({ code: 0, data: result || queuedOutboundMessage(job) }) res.json({ code: 0, data: result || queuedOutboundMessage(job) })
}) })
@@ -1835,7 +1865,7 @@ app.post('/accounts/:id/messages/media', upload.single('file'), async (req, res)
job.filePath = path.join(outboundMediaDir, `${job.id}.bin`) job.filePath = path.join(outboundMediaDir, `${job.id}.bin`)
await fs.writeFile(job.filePath, req.file.buffer, { mode: 0o600 }) await fs.writeFile(job.filePath, req.file.buffer, { mode: 0o600 })
outboundJobs.set(job.id, job) outboundJobs.set(job.id, job)
await saveOutboundJobs() await queueOutboundJobsSave()
const result = await processOutboundJob(job.id) const result = await processOutboundJob(job.id)
res.json({ code: 0, data: result || queuedOutboundMessage(job) }) res.json({ code: 0, data: result || queuedOutboundMessage(job) })
}) })