Files
lingxi-web/server/src/index.js
T

5436 lines
247 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import express from 'express'
import http from 'node:http'
import cors from 'cors'
import helmet from 'helmet'
import jwt from 'jsonwebtoken'
import bcrypt from 'bcryptjs'
import crypto from 'node:crypto'
import { WebSocketServer } from 'ws'
import nodemailer from 'nodemailer'
import mammoth from 'mammoth'
import pdfParse from 'pdf-parse'
import {
audit, deleteResource, getResource, listResources, migrate, patchResource, pool, putResource, uid,
} from './db.js'
import { gatewayRequest, postGateway, putGateway, uploadGateway } from './gateway.js'
import { postWhatsApp, putWhatsApp, uploadWhatsApp, whatsappRequest } from './whatsapp.js'
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 { isTimestampOlderThan, webhookEventClaimDecision } from './reliability.js'
import {
isWeakWhatsAppDisplayName,
reusableWhatsAppConversations,
whatsappConversationActivityAt,
whatsappCustomerStableId,
} from './whatsapp-identity.js'
import {
decryptEmailPassword,
encryptEmailPassword,
fetchEmailMessages,
markEmailUidsSeen,
sendEmailMessage,
verifyEmailConnection,
} from './email.js'
const app = express()
const httpServer = http.createServer(app)
const sockets = new WebSocketServer({ server: httpServer, path: '/api/ws' })
const socketTenants = new Map()
sockets.on('connection', async (socket, request) => {
try {
const url = new URL(request.url, 'http://localhost')
const payload = jwt.verify(url.searchParams.get('ticket') || '', process.env.JWT_SECRET || '')
if (payload.type !== 'ws' || !payload.jti) throw new Error('invalid websocket ticket')
const ticket = await getResource(payload.tenantId, 'ws-tickets', payload.jti)
if (!ticket || Date.parse(ticket.expiresAt || '') <= Date.now()) throw new Error('expired websocket ticket')
await deleteResource(payload.tenantId, 'ws-tickets', payload.jti)
socketTenants.set(socket, payload.tenantId)
socket.on('close', () => socketTenants.delete(socket))
socket.send(JSON.stringify({ type: 'connected', at: new Date().toISOString() }))
} catch {
socket.close(1008, 'unauthorized')
}
})
function broadcast(tenantId, event) {
const payload = JSON.stringify({ ...event, at: event.at || new Date().toISOString() })
for (const client of sockets.clients) {
if (socketTenants.get(client) === tenantId && client.readyState === 1) client.send(payload)
}
}
const automaticTranslationQueues = { high: [], low: [] }
const automaticTranslationKeys = new Set()
const automaticTranslationCache = new Map()
const whatsappSyncStates = new Map()
const whatsappSyncSchedules = new Map()
let activeAutomaticTranslations = 0
let activeLowPriorityTranslations = 0
let lowPriorityResumeAt = 0
function sortMessagesChronologically(messages) {
return [...messages].sort((a, b) => {
const time = Date.parse(a.createdAt || '') - Date.parse(b.createdAt || '')
return time || String(a.id || '').localeCompare(String(b.id || ''))
})
}
function contextMemoryText(conversation, customer) {
const memory = conversation?.aiContextMemory
if (memory?.summary) {
return [
`会话长期摘要:${memory.summary}`,
memory.facts?.length ? `已确认事实:${memory.facts.join(';')}` : '',
memory.commitments?.length ? `双方承诺:${memory.commitments.join(';')}` : '',
memory.products?.length ? `产品与型号:${memory.products.join(';')}` : '',
memory.unresolved?.length ? `未决事项:${memory.unresolved.join(';')}` : '',
memory.preferences?.length ? `客户偏好:${memory.preferences.join(';')}` : '',
].filter(Boolean).join('\n').slice(0, 3200)
}
return String(conversation?.summary || customer?.aiProfile?.summary || '').trim().slice(0, 1600)
}
function isNonLinguisticText(text) {
return !/[\p{L}]/u.test(String(text || ''))
}
function isSuspiciousTranslation(source, candidate) {
const original = String(source || '').trim()
const translated = String(candidate || '').trim()
if (!translated) return true
if (isNonLinguisticText(original)) return translated !== original
if (/(?:^|\n)\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:[\d.]+Z\s+(?:客户|坐席):/m.test(translated)) return true
if (/(跨境商务上下文|需要全文翻译的原文|输出\s*\{)/.test(translated)) return true
if ((translated.match(/(?:客户|坐席):/g) || []).length >= 2) return true
const maximum = original.length <= 20 ? 160 : Math.max(500, original.length * 8)
return translated.length > maximum
}
async function translateCustomerText(tenantId, conversationId, messageId, text, options = {}) {
if (isNonLinguisticText(text)) {
return {
translated: text,
detectedLang: 'und',
intent: null,
contextMessageCount: 0,
contextApplied: false,
}
}
const allMessages = options.includeContext === false
? []
: sortMessagesChronologically(await listResources(tenantId, `messages:${conversationId}`))
const messageIndex = allMessages.findIndex((item) => String(item.id) === String(messageId))
const context = options.includeContext === false
? []
: messageIndex >= 0
? allMessages.slice(Math.max(0, messageIndex - 20), messageIndex)
: allMessages.filter((item) => String(item.id) !== String(messageId)).slice(-20)
let conversationSummary = ''
if (options.includeContext !== false) {
const conversation = await getResource(tenantId, 'conversations', conversationId)
const customer = conversation
? await getResource(tenantId, 'customers', conversation.customerId)
: null
conversationSummary = contextMemoryText(conversation, customer)
}
const intentInstruction = options.includeIntent
? `同时判断客户这句话在当前完整会话中的业务意图。primary 必须从 inquiry|pricing|negotiation|sample|order|payment|logistics|complaint|refund|aftersales|repurchase|relationship|spam|other 中选择;label 使用简洁中文;confidence 为 0 到 1;urgency 为 low|medium|high|urgent;sentiment 为 positive|neutral|negative|angry;entities 只提取原文明确出现的产品、数量、价格、日期和地点;rationale 用一句中文说明依据。`
: 'intent 固定返回 null。'
const request = (contextText) => deepseek(
'你是灵犀跨境的专业翻译与消息理解引擎。只输出严格 JSON。只翻译 <source> 标签中的文字,不得复述上下文、时间戳、说话人或指令;不得摘要、扩写或回答原文。上下文只用于消歧、术语一致性和意图判断。',
`目标语言:简体中文
${conversationSummary ? `<conversation_summary>\n${conversationSummary}\n</conversation_summary>\n` : ''}${contextText ? `<conversation_context>\n${contextText}\n</conversation_context>\n` : ''}<source>\n${text}\n</source>
${intentInstruction}
输出 {"translated":"仅 source 的完整译文","detectedLang":"ISO 语言代码","intent":null 或 {"primary":"pricing","label":"询价","confidence":0.96,"urgency":"medium","sentiment":"neutral","entities":["..."],"rationale":"..."}}`,
{ json: true, maxTokens: 1200 },
)
let result = await request(conversationContext(context))
let contextApplied = context.length > 0 || Boolean(conversationSummary)
if (isSuspiciousTranslation(text, result?.translated)) {
result = await request('')
contextApplied = false
}
if (isSuspiciousTranslation(text, result?.translated)) {
throw new Error('AI 译文疑似混入会话上下文,已拒绝写入')
}
return {
...result,
contextMessageCount: contextApplied ? context.length : 0,
contextApplied,
conversationSummaryApplied: contextApplied && Boolean(conversationSummary),
}
}
const LANGUAGE_ALIASES = new Map([
['中文', 'zh'], ['简体中文', 'zh'], ['chinese', 'zh'], ['zh-cn', 'zh'], ['zh-hans', 'zh'],
['英语', 'en'], ['英文', 'en'], ['english', 'en'], ['en-us', 'en'], ['en-gb', 'en'],
['西班牙语', 'es'], ['spanish', 'es'], ['español', 'es'],
['葡萄牙语', 'pt'], ['portuguese', 'pt'], ['português', 'pt'],
['法语', 'fr'], ['french', 'fr'], ['français', 'fr'],
['德语', 'de'], ['german', 'de'], ['deutsch', 'de'],
['阿拉伯语', 'ar'], ['arabic', 'ar'], ['العربية', 'ar'],
['俄语', 'ru'], ['russian', 'ru'], ['русский', 'ru'],
['日语', 'ja'], ['japanese', 'ja'], ['日本語', 'ja'],
['韩语', 'ko'], ['korean', 'ko'], ['한국어', 'ko'],
['意大利语', 'it'], ['italian', 'it'], ['italiano', 'it'],
['土耳其语', 'tr'], ['turkish', 'tr'], ['türkçe', 'tr'],
['越南语', 'vi'], ['vietnamese', 'vi'], ['tiếng việt', 'vi'],
['泰语', 'th'], ['thai', 'th'], ['ไทย', 'th'],
['印尼语', 'id'], ['印度尼西亚语', 'id'], ['indonesian', 'id'],
['马来语', 'ms'], ['malay', 'ms'],
['印地语', 'hi'], ['hindi', 'hi'], ['हिन्दी', 'hi'],
['孟加拉语', 'bn'], ['bengali', 'bn'],
['乌尔都语', 'ur'], ['urdu', 'ur'],
['波斯语', 'fa'], ['persian', 'fa'], ['farsi', 'fa'],
['荷兰语', 'nl'], ['dutch', 'nl'], ['nederlands', 'nl'],
['波兰语', 'pl'], ['polish', 'pl'], ['polski', 'pl'],
['乌克兰语', 'uk'], ['ukrainian', 'uk'],
['希伯来语', 'he'], ['hebrew', 'he'],
['斯瓦希里语', 'sw'], ['swahili', 'sw'],
])
function normalizeLanguageCode(value) {
const source = String(value || '').trim()
if (!source) return ''
const lowered = source.toLowerCase()
if (['auto', 'und', 'unknown'].includes(lowered)) return ''
if (LANGUAGE_ALIASES.has(lowered)) return LANGUAGE_ALIASES.get(lowered)
const code = lowered.split(/[-_]/)[0]
return /^[a-z]{2,3}$/.test(code) ? code : ''
}
async function resolveConversationLanguage(tenantId, conversation, requestedTarget = 'auto') {
const explicit = normalizeLanguageCode(requestedTarget)
if (explicit) return { code: explicit, source: 'manual' }
const messages = (await listResources(tenantId, `messages:${conversation.id}`))
.sort((a, b) => Date.parse(a.createdAt || '') - Date.parse(b.createdAt || ''))
const recentCustomerLanguage = [...messages]
.reverse()
.filter((message) => message.sender === 'customer')
.map((message) => normalizeLanguageCode(message.originalLang))
.find(Boolean)
if (recentCustomerLanguage) return { code: recentCustomerLanguage, source: 'recent-message', messages }
const customer = await getResource(tenantId, 'customers', conversation.customerId)
const profileLanguage = normalizeLanguageCode(
customer?.language || customer?.aiProfile?.preferredLanguage,
)
if (profileLanguage) return { code: profileLanguage, source: 'customer-profile', messages }
return { code: 'auto', source: 'model', messages }
}
function enqueueAutomaticTranslation(tenantId, conversationId, message, options = {}) {
if (!message || !['customer', 'agent'].includes(message.sender) || message.type !== 'text' || !String(message.originalText || '').trim()) return false
if (message.sender === 'agent' && /[\u3400-\u9fff]/u.test(String(message.originalText || ''))) return false
if (message.translationStatus === 'failed' && Date.parse(message.translationRetryAt || '') > Date.now()) return false
const key = `${tenantId}:${conversationId}:${message.id}`
const targetField = message.sender === 'agent' ? 'translationCn' : 'translatedText'
const mayNeedIntent = message.sender === 'customer' && !message.intentAnalysis
if ((!mayNeedIntent && message[targetField]) || automaticTranslationKeys.has(key)) return false
const priority = options.priority === 'low' ? 'low' : 'high'
automaticTranslationKeys.add(key)
automaticTranslationQueues[priority].push({
key, tenantId, conversationId, message, targetField, priority,
})
if (priority === 'high') enqueueContextCompaction(tenantId, conversationId)
pumpAutomaticTranslations()
return true
}
function pumpAutomaticTranslations() {
while (activeAutomaticTranslations < 3) {
let job = automaticTranslationQueues.high.shift()
if (
!job
&& activeLowPriorityTranslations === 0
&& automaticTranslationQueues.low.length
&& Date.now() >= lowPriorityResumeAt
) {
job = automaticTranslationQueues.low.shift()
}
if (!job) break
activeAutomaticTranslations++
if (job.priority === 'low') activeLowPriorityTranslations++
void processAutomaticTranslation(job)
.catch(async (error) => {
console.error(JSON.stringify({
message: '消息自动翻译或意图识别失败',
tenantId: job.tenantId,
conversationId: job.conversationId,
telegramMessageId: job.message.id,
error: error.message,
}))
const current = await getResource(job.tenantId, `messages:${job.conversationId}`, String(job.message.id))
if (current) {
const failed = await putResource(job.tenantId, `messages:${job.conversationId}`, {
...current,
translationStatus: 'failed',
translationRetryAt: new Date(Date.now() + 60_000).toISOString(),
})
broadcast(job.tenantId, {
type: 'message.updated',
conversationId: job.conversationId,
message: failed,
})
}
})
.finally(() => {
automaticTranslationKeys.delete(job.key)
activeAutomaticTranslations--
if (job.priority === 'low') {
activeLowPriorityTranslations--
lowPriorityResumeAt = Date.now() + 800
setTimeout(pumpAutomaticTranslations, 800).unref?.()
}
pumpAutomaticTranslations()
})
}
}
async function processAutomaticTranslation({ tenantId, conversationId, message, targetField }) {
const current = await getResource(tenantId, `messages:${conversationId}`, String(message.id))
if (!current) return
const [conversation, settings] = await Promise.all([
getResource(tenantId, 'conversations', conversationId),
getResource(tenantId, 'singleton', 'settings'),
])
const intentEnabled = current.sender === 'customer'
&& conversation?.messageIntentEnabled !== false
&& (settings?.ai?.messageIntentAnalysis ?? true) !== false
if (current[targetField] && (!intentEnabled || current.intentAnalysis)) return
const text = String(current.originalText || '').trim()
if (!text) return
const sharedKey = `${message.id}:${intentEnabled ? 'intent' : 'translation'}:${crypto.createHash('sha256').update(text).digest('hex')}`
let translationPromise = automaticTranslationCache.get(sharedKey)
if (!translationPromise) {
translationPromise = translateCustomerText(tenantId, conversationId, message.id, text, {
includeIntent: intentEnabled,
includeContext: (settings?.ai?.contextualTranslation ?? true) !== false,
}).catch((error) => {
automaticTranslationCache.delete(sharedKey)
throw error
})
automaticTranslationCache.set(sharedKey, translationPromise)
setTimeout(() => automaticTranslationCache.delete(sharedKey), 10 * 60 * 1000).unref?.()
}
const result = await translationPromise
if (!result?.translated || isSuspiciousTranslation(text, result.translated)) {
throw new Error('AI 未返回可信的完整译文')
}
const latest = await getResource(tenantId, `messages:${conversationId}`, String(message.id))
if (!latest) return
const intent = intentEnabled && result.intent && typeof result.intent === 'object'
? {
primary: String(result.intent.primary || 'other'),
label: String(result.intent.label || '其他'),
confidence: Math.max(0, Math.min(1, Number(result.intent.confidence || 0))),
urgency: ['low', 'medium', 'high', 'urgent'].includes(result.intent.urgency)
? result.intent.urgency
: 'low',
sentiment: ['positive', 'neutral', 'negative', 'angry'].includes(result.intent.sentiment)
? result.intent.sentiment
: 'neutral',
entities: Array.isArray(result.intent.entities)
? result.intent.entities.map(String).filter(Boolean).slice(0, 8)
: [],
rationale: String(result.intent.rationale || '').slice(0, 240),
analyzedAt: new Date().toISOString(),
model: process.env.DEEPSEEK_MODEL || 'deepseek-v4-flash',
}
: latest.intentAnalysis
const translated = await putResource(tenantId, `messages:${conversationId}`, {
...latest,
[targetField]: latest[targetField] || String(result.translated),
originalLang: String(result.detectedLang || latest.originalLang || 'auto'),
translationStatus: 'completed',
translatedAt: new Date().toISOString(),
translationMethod: result.contextApplied ? 'ai-contextual' : 'ai-direct',
translationContextMessages: Number(result.contextMessageCount || 0),
translationConversationSummaryApplied: Boolean(result.conversationSummaryApplied),
translationModel: process.env.DEEPSEEK_MODEL || 'deepseek-v4-flash',
...(intentEnabled ? {
intentAnalysis: intent,
intentStatus: intent ? 'completed' : 'failed',
} : {}),
})
if (String(conversation?.lastMessage?.id || '') === String(translated.id)) {
await patchResource(tenantId, 'conversations', conversationId, { lastMessage: translated })
}
broadcast(tenantId, { type: 'message.updated', conversationId, message: translated })
}
function messageNeedsAutomaticAi(message, conversation, settings) {
if (
!message
|| !['customer', 'agent'].includes(message.sender)
|| message.type !== 'text'
|| !String(message.originalText || '').trim()
|| isNonLinguisticText(message.originalText)
) return false
if (message.sender === 'agent') {
return !/[\u3400-\u9fff]/u.test(String(message.originalText || ''))
&& !String(message.translationCn || '').trim()
}
const intentEnabled = conversation?.messageIntentEnabled !== false
&& (settings?.ai?.messageIntentAnalysis ?? true) !== false
return !String(message.translatedText || '').trim()
|| (intentEnabled && !message.intentAnalysis)
}
const activeBackfillJobs = new Set()
const backfillTimers = new Map()
async function runConversationBackfill(tenantId, conversationId) {
const key = `${tenantId}:${conversationId}`
if (activeBackfillJobs.has(key)) return
activeBackfillJobs.add(key)
try {
const [conversation, settings, messages] = await Promise.all([
getResource(tenantId, 'conversations', conversationId),
getResource(tenantId, 'singleton', 'settings'),
listResources(tenantId, `messages:${conversationId}`),
])
if (!conversation) {
await deleteResource(tenantId, 'ai-backfill-jobs', conversationId)
return
}
const eligible = sortMessagesChronologically(messages)
.filter((message) => (
['customer', 'agent'].includes(message.sender)
&& message.type === 'text'
&& !isNonLinguisticText(message.originalText)
))
const pending = eligible.filter((message) => messageNeedsAutomaticAi(message, conversation, settings))
const current = await getResource(tenantId, 'ai-backfill-jobs', conversationId)
const completedCount = eligible.length - pending.length
const status = pending.length ? 'running' : 'completed'
const job = await putResource(tenantId, 'ai-backfill-jobs', {
...(current || {}),
id: conversationId,
conversationId,
status,
totalCount: eligible.length,
completedCount,
pendingCount: pending.length,
progress: eligible.length ? Number((completedCount / eligible.length).toFixed(4)) : 1,
startedAt: current?.startedAt || new Date().toISOString(),
completedAt: pending.length ? null : new Date().toISOString(),
lastError: '',
})
broadcast(tenantId, {
type: 'conversation.ai-backfill.updated',
conversationId,
backfill: job,
})
if (!pending.length) return
for (const message of pending.slice(0, 4)) {
enqueueAutomaticTranslation(tenantId, conversationId, message, { priority: 'low' })
}
const timer = setTimeout(() => {
backfillTimers.delete(key)
void runConversationBackfill(tenantId, conversationId)
}, 15_000)
timer.unref?.()
backfillTimers.set(key, timer)
} catch (error) {
await putResource(tenantId, 'ai-backfill-jobs', {
id: conversationId,
conversationId,
status: 'failed',
lastError: String(error.message || error),
retryAt: new Date(Date.now() + 30_000).toISOString(),
})
const timer = setTimeout(() => {
backfillTimers.delete(key)
void runConversationBackfill(tenantId, conversationId)
}, 30_000)
timer.unref?.()
backfillTimers.set(key, timer)
} finally {
activeBackfillJobs.delete(key)
}
}
async function scheduleConversationBackfill(tenantId, conversationId) {
const existing = await getResource(tenantId, 'ai-backfill-jobs', conversationId)
if (!existing || existing.status === 'completed' || existing.status === 'failed') {
await putResource(tenantId, 'ai-backfill-jobs', {
...(existing || {}),
id: conversationId,
conversationId,
status: 'queued',
queuedAt: new Date().toISOString(),
completedAt: null,
})
}
void runConversationBackfill(tenantId, conversationId)
}
const discoveredBackfillTenants = new Set()
function discoverTenantBackfills(tenantId, conversations) {
if (discoveredBackfillTenants.has(tenantId)) return
discoveredBackfillTenants.add(tenantId)
void (async () => {
for (const conversation of conversations) {
await scheduleConversationBackfill(tenantId, conversation.id)
enqueueContextCompaction(tenantId, conversation.id)
await new Promise((resolve) => setTimeout(resolve, 40))
}
})().catch((error) => {
discoveredBackfillTenants.delete(tenantId)
console.error(JSON.stringify({
message: '会话历史 AI 回填发现失败',
tenantId,
error: error.message,
}))
})
}
const contextCompactionQueue = []
const contextCompactionKeys = new Set()
let activeContextCompactions = 0
function enqueueContextCompaction(tenantId, conversationId) {
const key = `${tenantId}:${conversationId}`
if (contextCompactionKeys.has(key)) return
contextCompactionKeys.add(key)
contextCompactionQueue.push({ key, tenantId, conversationId })
pumpContextCompactions()
}
function pumpContextCompactions() {
if (activeContextCompactions > 0 || !contextCompactionQueue.length) return
const job = contextCompactionQueue.shift()
activeContextCompactions++
void compactConversationContext(job.tenantId, job.conversationId)
.catch(async (error) => {
await patchResource(job.tenantId, 'conversations', job.conversationId, {
aiContextStatus: 'failed',
aiContextError: String(error.message || error),
aiContextRetryAt: new Date(Date.now() + 60_000).toISOString(),
})
})
.finally(() => {
contextCompactionKeys.delete(job.key)
activeContextCompactions--
setTimeout(pumpContextCompactions, 1200).unref?.()
})
}
async function compactConversationContext(tenantId, conversationId) {
const [conversation, messages] = await Promise.all([
getResource(tenantId, 'conversations', conversationId),
listResources(tenantId, `messages:${conversationId}`),
])
if (!conversation) return
const ordered = sortMessagesChronologically(messages)
.filter((message) => ['customer', 'agent'].includes(message.sender))
const totalCharacters = ordered.reduce(
(sum, message) => sum + String(message.originalText || '').length,
0,
)
if (ordered.length <= 80 && totalCharacters <= 16_000) {
if (conversation.aiContextStatus !== 'ready') {
await patchResource(tenantId, 'conversations', conversationId, {
aiContextStatus: 'waiting',
aiContextSourceMessageCount: 0,
aiContextPendingMessages: Math.max(0, ordered.length - 30),
})
}
return
}
const compactableEnd = Math.max(0, ordered.length - 30)
const compactable = ordered.slice(0, compactableEnd)
const priorMemory = conversation.aiContextMemory || null
const compactedIds = new Set(
Array.isArray(conversation.aiContextCompactedMessageIds)
? conversation.aiContextCompactedMessageIds.map(String)
: [],
)
// 兼容已生成长期记忆但尚未写入逐条检查点的旧数据。
if (!compactedIds.size && priorMemory?.updatedThroughMessageId) {
const previousIndex = ordered.findIndex(
(message) => String(message.id) === String(priorMemory.updatedThroughMessageId),
)
for (const message of ordered.slice(0, Math.min(compactableEnd, previousIndex + 1))) {
compactedIds.add(String(message.id))
}
}
const segment = compactable
.filter((message) => !compactedIds.has(String(message.id)))
.slice(0, 80)
if (!segment.length) {
await patchResource(tenantId, 'conversations', conversationId, {
aiContextStatus: 'ready',
aiContextPendingMessages: 0,
aiContextSourceMessageCount: compactedIds.size,
aiContextCompactedMessageIds: [...compactedIds],
})
return
}
await patchResource(tenantId, 'conversations', conversationId, {
aiContextStatus: 'compressing',
aiContextError: '',
})
const result = await deepseek(
'你是企业会话长期记忆压缩器。只输出严格 JSON。将旧摘要与新增消息合并成可持续增量更新的业务记忆。必须保留已确认事实、数量、金额、日期、型号、双方承诺、异议、未决问题和客户偏好;删除寒暄、重复表述和无业务价值内容;不得猜测。',
`<previous_memory>${JSON.stringify(priorMemory || {})}</previous_memory>
<new_messages>${conversationContext(segment, 80)}</new_messages>
输出 {
"summary":"不超过500字的会话长期摘要",
"facts":["已确认事实,最多12条"],
"commitments":["双方承诺,最多10条"],
"products":["产品、型号和数量,最多10条"],
"unresolved":["未决问题,最多10条"],
"preferences":["客户偏好,最多8条"]
}`,
{ json: true, maxTokens: 1800 },
)
for (const message of segment) compactedIds.add(String(message.id))
const memory = {
summary: String(result.summary || '').slice(0, 2000),
facts: Array.isArray(result.facts) ? result.facts.map(String).slice(0, 12) : [],
commitments: Array.isArray(result.commitments) ? result.commitments.map(String).slice(0, 10) : [],
products: Array.isArray(result.products) ? result.products.map(String).slice(0, 10) : [],
unresolved: Array.isArray(result.unresolved) ? result.unresolved.map(String).slice(0, 10) : [],
preferences: Array.isArray(result.preferences) ? result.preferences.map(String).slice(0, 8) : [],
updatedThroughMessageId: String(segment.at(-1).id),
sourceMessageCount: compactedIds.size,
updatedAt: new Date().toISOString(),
model: process.env.DEEPSEEK_MODEL || 'deepseek-v4-flash',
version: 1,
}
const remaining = compactable.filter(
(message) => !compactedIds.has(String(message.id)),
).length
const updated = await patchResource(tenantId, 'conversations', conversationId, {
aiContextMemory: memory,
aiContextCompactedMessageIds: [...compactedIds],
aiContextStatus: remaining ? 'queued' : 'ready',
aiContextPendingMessages: remaining,
aiContextSourceMessageCount: memory.sourceMessageCount,
aiContextCompressedAt: memory.updatedAt,
})
broadcast(tenantId, {
type: 'conversation.context.updated',
conversationId,
conversation: updated,
})
if (remaining) {
setTimeout(() => enqueueContextCompaction(tenantId, conversationId), 2000).unref?.()
}
}
const automaticProfileTimers = new Map()
const automaticProfileCache = new Map()
function enqueueAutomaticProfile(tenantId, conversationId) {
const key = `${tenantId}:${conversationId}`
const current = automaticProfileTimers.get(key)
if (current) clearTimeout(current)
const timer = setTimeout(() => {
automaticProfileTimers.delete(key)
void analyzeAndPersistConversation(tenantId, conversationId).catch((error) => {
console.error(JSON.stringify({
message: 'Telegram 客户画像自动分析失败',
tenantId,
conversationId,
error: error.message,
}))
})
}, 1200)
timer.unref?.()
automaticProfileTimers.set(key, timer)
}
async function analyzeAndPersistConversation(tenantId, conversationId) {
const conversation = await getResource(tenantId, 'conversations', conversationId)
if (!conversation) throw new Error('待分析会话不存在')
const customer = await getResource(tenantId, 'customers', conversation.customerId)
if (!customer) throw new Error('待分析客户不存在')
const messages = sortMessagesChronologically(
await listResources(tenantId, `messages:${conversationId}`),
)
const messageIntentSignals = messages
.filter((message) => message.sender === 'customer' && message.intentAnalysis)
.slice(-50)
.map((message) => ({
at: message.createdAt,
text: String(message.originalText || '').slice(0, 240),
intent: message.intentAnalysis.primary,
label: message.intentAnalysis.label,
confidence: message.intentAnalysis.confidence,
urgency: message.intentAnalysis.urgency,
sentiment: message.intentAnalysis.sentiment,
}))
const context = `${contextMemoryText(conversation, customer) ? `<conversation_memory>\n${contextMemoryText(conversation, customer)}\n</conversation_memory>\n` : ''}${conversationContext(sortMessagesChronologically(messages), 50)}
<message_intent_signals>${JSON.stringify(messageIntentSignals)}</message_intent_signals>`
if (!context.trim()) return null
const sharedKey = crypto.createHash('sha256').update(context).digest('hex')
let analysisPromise = automaticProfileCache.get(sharedKey)
if (!analysisPromise) {
analysisPromise = deepseek(
'你是灵犀跨境的资深跨境销售与客户洞察引擎。只输出严格 JSON,不要 Markdown。综合完整会话、上下文变化和明确证据,分析采购角色、需求、预算、时效、异议、成交信号、风险、沟通偏好与下一步策略。没有依据的字段必须为空,不得臆造。',
`${context}\n输出 {
"intentScore":0到100,
"intentLevel":"high|medium-high|medium|low",
"stage":"initial|comparing|sample|custom|closing|fulfillment|aftersales|repurchase",
"sentiment":"positive|neutral-positive|neutral|negative|angry",
"riskLevel":"none|low|medium|high|urgent",
"riskNote":"",
"products":[],
"tags":[],
"summary":"中文画像摘要",
"nextActions":[],
"aiProfile":{
"companyName":"",
"industry":"",
"businessModel":"",
"purchasingRole":"",
"decisionPower":"",
"inquiryQuantity":"",
"targetPrice":"",
"budget":"",
"moqRequirement":"",
"sampleNeed":"",
"customRequirement":"",
"deliveryTime":"",
"urgency":"",
"purchaseTimeline":"",
"logistics":"",
"payment":"",
"aftersales":"",
"keyNeeds":[],
"painPoints":[],
"concerns":[],
"objections":[],
"buyingSignals":[],
"competitorMentions":[],
"communicationStyle":"",
"recommendedTone":"",
"preferredLanguage":"",
"followUpTiming":"",
"dealProbability":0到100,
"churnProbability":0到100,
"confidenceScore":0到100,
"intentBreakdown":[{"intent":"pricing","label":"询价","count":2,"share":0.5}],
"lastMessageIntent":{"intent":"pricing","label":"询价","urgency":"medium"},
"evidence":["最多5条会话证据,简短转述"]
}
}`,
true,
).catch((error) => {
automaticProfileCache.delete(sharedKey)
throw error
})
automaticProfileCache.set(sharedKey, analysisPromise)
setTimeout(() => automaticProfileCache.delete(sharedKey), 10 * 60 * 1000).unref?.()
}
const result = await analysisPromise
const intentScore = Math.max(0, Math.min(100, Number(result.intentScore || 0)))
const updatedCustomer = await putResource(tenantId, 'customers', {
...customer,
company: String(result.aiProfile?.companyName || '').trim() || customer.company,
language: String(result.aiProfile?.preferredLanguage || '').trim() || customer.language,
products: Array.from(new Set([...(customer.products || []), ...(result.products || [])])),
tags: Array.from(new Set([...(customer.tags || []), ...(result.tags || [])])),
intentScore,
intentLevel: result.intentLevel || customer.intentLevel || 'low',
stage: result.stage || customer.stage || 'initial',
sentiment: result.sentiment || customer.sentiment || 'neutral',
riskLevel: result.riskLevel || customer.riskLevel || 'none',
aiProfile: {
...(customer.aiProfile || {}),
...(result.aiProfile || {}),
summary: String(result.summary || ''),
nextActions: Array.isArray(result.nextActions) ? result.nextActions : [],
profileVersion: 2,
},
aiAnalyzedAt: new Date().toISOString(),
})
const updatedConversation = await patchResource(tenantId, 'conversations', conversationId, {
intentLevel: updatedCustomer.intentLevel,
sentiment: updatedCustomer.sentiment,
hasRisk: updatedCustomer.riskLevel !== 'none',
summary: updatedCustomer.aiProfile.summary,
aiAnalyzedAt: updatedCustomer.aiAnalyzedAt,
})
let generatedRisk
let generatedTask
if (['high', 'urgent'].includes(updatedCustomer.riskLevel)) {
generatedRisk = await putResource(tenantId, 'risks', {
id: `ai-risk-${conversationId}`,
type: updatedCustomer.sentiment === 'angry' ? 'complaint' : 'negative-sentiment',
level: updatedCustomer.riskLevel,
customerId: updatedCustomer.id,
customerName: updatedCustomer.name,
conversationId,
reason: String(result.riskNote || result.summary || 'AI 检测到高风险客户'),
aiExplanation: String(result.summary || ''),
suggestion: (result.nextActions || []).join(';') || '请尽快人工跟进',
ownerId: updatedCustomer.ownerId || null,
createdAt: new Date().toISOString(),
status: 'open',
source: 'ai',
})
generatedTask = await putResource(tenantId, 'tasks', {
id: `ai-risk-task-${conversationId}`,
title: `紧急跟进:${updatedCustomer.name}`,
type: updatedCustomer.stage === 'aftersales' ? 'aftersales' : 'revisit',
priority: 'high',
status: 'pending',
customerId: updatedCustomer.id,
customerName: updatedCustomer.name,
conversationId,
ownerId: updatedCustomer.ownerId || '',
dueAt: new Date(Date.now() + (updatedCustomer.riskLevel === 'urgent' ? 2 : 12) * 60 * 60 * 1000).toISOString(),
note: String(result.riskNote || result.summary || ''),
relatedSummary: String(result.summary || ''),
createdAt: new Date().toISOString(),
source: 'ai-risk',
})
}
broadcast(tenantId, {
type: 'conversation.insight.updated',
conversationId,
customer: updatedCustomer,
conversation: updatedConversation,
generatedRisk,
generatedTask,
})
return {
...result,
intentScore,
customer: updatedCustomer,
conversation: updatedConversation,
}
}
app.set('trust proxy', 1)
app.use(helmet())
app.use(cors({ origin: process.env.CORS_ORIGIN?.split(',') || true }))
app.use(express.json({
limit: '10mb',
verify: (req, _res, buffer) => { req.rawBody = buffer },
}))
const secret = process.env.JWT_SECRET || ''
if (secret.length < 32) throw new Error('JWT_SECRET 必须至少 32 个字符')
const ok = (res, data, message = 'ok') => res.json({ code: 0, message, data, requestId: res.getHeader('x-request-id') })
const fail = (status, message) => Object.assign(new Error(message), { status })
const tokenFor = (user) => jwt.sign({
userId: user.id,
tenantId: user.tenant_id,
role: user.role,
tokenVersion: Number(user.token_version || 0),
jti: uid(),
}, secret, { expiresIn: '12h' })
const loginAttempts = new Map()
function loginAttemptKey(req, account) {
return `${req.ip}:${account}`
}
function assertLoginAllowed(req, account) {
const key = loginAttemptKey(req, account)
const value = loginAttempts.get(key)
if (!value || value.resetAt <= Date.now()) {
loginAttempts.delete(key)
return
}
if (value.count >= 5) {
const seconds = Math.ceil((value.resetAt - Date.now()) / 1000)
throw fail(429, `登录尝试过于频繁,请在 ${seconds} 秒后重试`)
}
}
function recordLoginFailure(req, account) {
const key = loginAttemptKey(req, account)
const current = loginAttempts.get(key)
loginAttempts.set(key, current && current.resetAt > Date.now()
? { ...current, count: current.count + 1 }
: { count: 1, resetAt: Date.now() + 15 * 60 * 1000 })
}
function authorizeRequest(req) {
if (!req.auth || !req.path.startsWith('/api/')) return
const role = req.auth.role
const write = !['GET', 'HEAD', 'OPTIONS'].includes(req.method)
if (!write) {
let readable = ['admin', 'manager', 'sales', 'service', 'viewer']
if (req.path.startsWith('/api/settings') || req.path.startsWith('/api/api-keys') || req.path.startsWith('/api/billing')) {
readable = ['admin']
} else if (req.path.startsWith('/api/team')) {
readable = ['admin', 'manager']
} else if (req.path.startsWith('/api/channels') || req.path.startsWith('/api/webhooks')) {
readable = ['admin', 'manager']
} else if (req.path.startsWith('/api/analytics')) {
readable = ['admin', 'manager', 'viewer']
} else if (req.path.startsWith('/api/opportunities')) {
readable = ['admin', 'manager', 'sales']
} else if (req.path.startsWith('/api/tasks')) {
readable = ['admin', 'manager', 'sales', 'service']
} else if (req.path.startsWith('/api/risks') || req.path.startsWith('/api/knowledge')) {
readable = ['admin', 'manager', 'service']
}
if (!readable.includes(role)) throw fail(403, '当前角色无权查看此数据')
return
}
if (['/api/auth/ws-ticket', '/api/auth/change-password', '/api/auth/logout-all'].includes(req.path)) return
let allowed = ['admin', 'manager', 'sales', 'service']
if (req.path.startsWith('/api/team') || req.path.startsWith('/api/settings') || req.path.startsWith('/api/billing')) {
allowed = ['admin']
} else if (req.path.startsWith('/api/channels') || req.path.includes('/import') || req.path.includes('/merge')) {
allowed = ['admin', 'manager']
} else if (req.method === 'DELETE') {
allowed = ['admin', 'manager']
} else if (req.path.startsWith('/api/opportunities') || req.path.startsWith('/api/tasks')) {
allowed = ['admin', 'manager', 'sales', 'service']
} else if (req.path.startsWith('/api/risks') || req.path.startsWith('/api/knowledge')) {
allowed = ['admin', 'manager', 'service']
}
if (!allowed.includes(role)) throw fail(403, '当前角色无权执行此操作')
}
app.use((req, res, next) => {
const requestId = req.get('x-request-id') || uid()
res.setHeader('x-request-id', requestId)
next()
})
const publicPaths = new Set(['/api/auth/login', '/api/auth/register', '/api/billing/stripe-webhook', '/health/live', '/health/ready'])
app.use(async (req, _res, next) => {
if (
publicPaths.has(req.path)
|| req.path.startsWith('/internal/webhooks/telegram/')
|| req.path.startsWith('/internal/webhooks/whatsapp/')
) return next()
const raw = req.get('authorization')?.replace(/^Bearer\s+/i, '')
try {
const apiKey = req.get('x-api-key')
if (apiKey) {
const hash = crypto.createHash('sha256').update(apiKey).digest('hex')
const { rows } = await pool.query(
`SELECT tenant_id, data FROM resources
WHERE kind='api-keys' AND data->>'hash'=$1 AND COALESCE((data->>'revoked')::boolean,false)=false
LIMIT 1`,
[hash],
)
if (!rows[0]) throw new Error('invalid api key')
req.auth = {
userId: rows[0].data.createdBy,
tenantId: String(rows[0].tenant_id),
role: rows[0].data.role || 'service',
apiKeyId: rows[0].data.id,
}
} else {
req.auth = jwt.verify(raw || '', secret)
const { rows } = await pool.query(
'SELECT status,token_version,role FROM users WHERE id=$1 AND tenant_id=$2',
[req.auth.userId, req.auth.tenantId],
)
if (!rows[0] || rows[0].status !== 'active' || Number(rows[0].token_version || 0) !== Number(req.auth.tokenVersion || 0)) {
throw new Error('revoked token')
}
req.auth.role = rows[0].role
}
authorizeRequest(req)
next()
} catch (error) {
next(error?.status ? error : fail(401, '登录状态已失效,请重新登录'))
}
})
async function session(user) {
const { rows } = await pool.query('SELECT * FROM tenants WHERE id=$1', [user.tenant_id])
const tenant = rows[0]
return {
token: tokenFor(user),
user: {
id: user.id, name: user.name, email: user.account.includes('@') ? user.account : '',
phone: user.phone || (user.account.includes('@') ? '' : user.account), role: user.role,
department: user.department, title: user.title, status: user.status,
lastLoginAt: user.last_login_at || new Date().toISOString(),
},
tenant: {
id: tenant.id, name: tenant.name, industry: tenant.industry, size: tenant.size,
plan: tenant.plan, planExpireAt: new Date(Date.now() + 365 * 86400000).toISOString(), country: tenant.country,
},
onboarded: true,
}
}
app.post('/api/auth/register', async (req, res) => {
const { name, account, password, company, industry = '', teamSize = '' } = req.body
if (!name || !account || !company || String(password || '').length < 8) throw fail(400, '姓名、账号、企业名称必填,密码至少 8 位')
const exists = await pool.query('SELECT 1 FROM users WHERE account=$1', [String(account).toLowerCase()])
if (exists.rowCount) throw fail(409, '账号已注册')
const tenantId = uid(); const userId = uid(); const now = new Date()
const client = await pool.connect()
try {
await client.query('BEGIN')
await client.query('INSERT INTO tenants(id,name,industry,size) VALUES($1,$2,$3,$4)', [tenantId, company, industry, teamSize])
const hash = await bcrypt.hash(password, 12)
await client.query(
`INSERT INTO users(id,tenant_id,name,account,password_hash,role,title,status,last_login_at)
VALUES($1,$2,$3,$4,$5,'admin','管理员','active',$6)`,
[userId, tenantId, name, String(account).toLowerCase(), hash, now],
)
await client.query('COMMIT')
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
const { rows } = await pool.query('SELECT * FROM users WHERE id=$1', [userId])
ok(res, await session(rows[0]), '注册成功')
})
app.post('/api/auth/login', async (req, res) => {
const account = String(req.body.account || '').toLowerCase()
assertLoginAllowed(req, account)
const { rows } = await pool.query('SELECT * FROM users WHERE account=$1', [account])
const user = rows[0]
if (!user || user.status !== 'active' || !(await bcrypt.compare(String(req.body.password || ''), user.password_hash))) {
recordLoginFailure(req, account)
if (user?.tenant_id) {
await pool.query(
'INSERT INTO login_records(id,tenant_id,user_id,account,device,ip,status) VALUES($1,$2,$3,$4,$5,$6,$7)',
[uid(), user.tenant_id, user.id, account, String(req.get('user-agent') || '').slice(0, 500), req.ip, 'failed'],
)
}
throw fail(401, '账号或密码错误')
}
loginAttempts.delete(loginAttemptKey(req, account))
await pool.query('UPDATE users SET last_login_at=now() WHERE id=$1', [user.id])
await pool.query(
'INSERT INTO login_records(id,tenant_id,user_id,account,device,ip,status) VALUES($1,$2,$3,$4,$5,$6,$7)',
[uid(), user.tenant_id, user.id, account, String(req.get('user-agent') || '').slice(0, 500), req.ip, 'success'],
)
ok(res, await session({ ...user, last_login_at: new Date() }))
})
app.get('/api/auth/me', async (req, res) => {
const { rows } = await pool.query('SELECT * FROM users WHERE id=$1 AND tenant_id=$2', [req.auth.userId, req.auth.tenantId])
if (!rows[0]) throw fail(404, '用户不存在')
const value = await session(rows[0])
ok(res, { user: value.user, tenant: value.tenant })
})
app.post('/api/auth/ws-ticket', async (req, res) => {
const id = uid()
const expiresAt = new Date(Date.now() + 60_000).toISOString()
await putResource(req.auth.tenantId, 'ws-tickets', {
id,
userId: req.auth.userId,
expiresAt,
})
ok(res, {
ticket: jwt.sign({
userId: req.auth.userId,
tenantId: req.auth.tenantId,
type: 'ws',
jti: id,
}, secret, { expiresIn: '60s' }),
expiresAt,
})
})
app.post('/api/auth/change-password', async (req, res) => {
const { rows } = await pool.query(
'SELECT * FROM users WHERE id=$1 AND tenant_id=$2',
[req.auth.userId, req.auth.tenantId],
)
const user = rows[0]
if (!user || !(await bcrypt.compare(String(req.body.currentPassword || ''), user.password_hash))) {
throw fail(400, '当前密码不正确')
}
const password = String(req.body.newPassword || '')
if (password.length < 10 || !/[A-Za-z]/.test(password) || !/\d/.test(password)) {
throw fail(400, '新密码至少 10 位,且必须包含字母和数字')
}
await pool.query(
'UPDATE users SET password_hash=$1,token_version=token_version+1 WHERE id=$2 AND tenant_id=$3',
[await bcrypt.hash(password, 12), req.auth.userId, req.auth.tenantId],
)
await audit(req, 'auth.password.change', req.auth.userId)
ok(res, { success: true, loginRequired: true })
})
app.post('/api/auth/logout-all', async (req, res) => {
await pool.query(
'UPDATE users SET token_version=token_version+1 WHERE id=$1 AND tenant_id=$2',
[req.auth.userId, req.auth.tenantId],
)
await audit(req, 'auth.sessions.revoke', req.auth.userId)
ok(res, { success: true })
})
app.get('/api/api-keys', async (req, res) => {
if (req.auth.role !== 'admin') throw fail(403, '仅管理员可查看 API Key')
const list = await listResources(req.auth.tenantId, 'api-keys')
ok(res, list.map(({ hash: _hash, ...item }) => item))
})
app.post('/api/api-keys', async (req, res) => {
if (req.auth.role !== 'admin') throw fail(403, '仅管理员可创建 API Key')
const plaintext = `lx_${crypto.randomBytes(32).toString('base64url')}`
const value = await putResource(req.auth.tenantId, 'api-keys', {
id: uid(),
name: String(req.body.name || 'API Key').slice(0, 80),
prefix: plaintext.slice(0, 10),
hash: crypto.createHash('sha256').update(plaintext).digest('hex'),
role: req.body.role === 'viewer' ? 'viewer' : 'service',
createdBy: req.auth.userId,
createdAt: new Date().toISOString(),
revoked: false,
})
await audit(req, 'api-key.create', value.id)
ok(res, { ...value, hash: undefined, key: plaintext })
})
app.delete('/api/api-keys/:id', async (req, res) => {
if (req.auth.role !== 'admin') throw fail(403, '仅管理员可撤销 API Key')
const value = await patchResource(req.auth.tenantId, 'api-keys', req.params.id, {
revoked: true,
revokedAt: new Date().toISOString(),
})
if (!value) throw fail(404, 'API Key 不存在')
await audit(req, 'api-key.revoke', req.params.id)
ok(res, { success: true })
})
const kinds = {
customers: 'customers', opportunities: 'opportunities', tasks: 'tasks', risks: 'risks',
knowledge: 'knowledge',
}
function withDefaults(kind, body) {
if (kind === 'customers') {
const value = {
name: '', company: '', country: '', channel: 'telegram', email: '', phone: '',
telegram: '', products: [], tags: [], source: 'telegram', stage: 'new',
intentScore: 0, intentLevel: 'low', sentiment: 'neutral', riskLevel: 'none',
valueLevel: 'D', isKey: false, ownerId: null,
aiProfile: { summary: '', keyPoints: [], nextActions: [] },
...body,
}
return {
...value,
...resolveCustomerCountry(value, {}, {
manual: value.countrySource === 'manual',
}),
}
}
return body
}
for (const [path, kind] of Object.entries(kinds)) {
app.get(`/api/${path}`, async (req, res) => {
let list = await listResources(req.auth.tenantId, kind)
if (kind === 'customers') {
list = await Promise.all(list.map(async (customer) => {
const country = resolveCustomerCountry(customer, customer)
if (
customer.country === country.country
&& customer.countrySource === country.countrySource
) return customer
return putResource(req.auth.tenantId, 'customers', { ...customer, ...country })
}))
}
const keyword = String(req.query.keyword || '').toLowerCase()
if (keyword) list = list.filter((x) => JSON.stringify(x).toLowerCase().includes(keyword))
if (kind === 'knowledge' && req.query.category) {
list = list.filter((item) => item.category === req.query.category)
}
ok(res, { list, total: list.length })
})
app.post(`/api/${path}`, async (req, res) => {
const payload = kind === 'customers' && req.body.country
? { ...req.body, countrySource: 'manual' }
: req.body
const prepared = withDefaults(kind, payload)
const enriched = kind === 'knowledge'
? {
enabled: true,
version: 1,
status: 'ready',
citedCount: 0,
languages: ['auto'],
quality: 'normal',
source: 'manual',
...prepared,
...knowledgeMetadata(prepared.content),
}
: prepared
const value = await putResource(req.auth.tenantId, kind, { ...enriched, createdAt: new Date().toISOString() })
await audit(req, `${kind}.create`, value.id); ok(res, value)
})
app.patch(`/api/${path}/:id`, async (req, res) => {
const current = await getResource(req.auth.tenantId, kind, req.params.id)
if (!current) throw fail(404, '记录不存在')
let patch = req.body
if (kind === 'customers') {
const merged = { ...current, ...req.body }
patch = {
...req.body,
...resolveCustomerCountry(merged, current, {
manual: Object.prototype.hasOwnProperty.call(req.body, 'country'),
}),
}
}
if (kind === 'knowledge' && Object.prototype.hasOwnProperty.call(req.body, 'content')) {
patch = { ...patch, ...knowledgeMetadata(req.body.content) }
}
const value = await patchResource(req.auth.tenantId, kind, req.params.id, patch)
if (!value) throw fail(404, '记录不存在')
await audit(req, `${kind}.update`, req.params.id, patch); ok(res, value)
})
app.delete(`/api/${path}/:id`, async (req, res) => {
if (!(await deleteResource(req.auth.tenantId, kind, req.params.id))) throw fail(404, '记录不存在')
await audit(req, `${kind}.delete`, req.params.id); ok(res, { success: true })
})
}
app.get('/api/customers/duplicates', async (req, res) => {
const list = await listResources(req.auth.tenantId, 'customers')
const pairs = []
for (let i = 0; i < list.length; i++) for (let j = i + 1; j < list.length; j++) {
if ((list[i].email && list[i].email === list[j].email) || (list[i].phone && list[i].phone === list[j].phone)) pairs.push({ a: list[i], b: list[j], reasons: ['联系方式重复'] })
}
ok(res, pairs.map((x, index) => ({ id: `duplicate-${index + 1}`, reason: x.reasons.join('、'), customers: [x.a, x.b] })))
})
app.get('/api/customers/export', async (req, res) => {
const list = await listResources(req.auth.tenantId, 'customers')
const escape = (v) => `"${String(v ?? '').replaceAll('"', '""')}"`
ok(res, { csv: ['name,company,email,phone,country', ...list.map((x) => [x.name, x.company, x.email, x.phone, x.country].map(escape).join(','))].join('\n') })
})
app.post('/api/customers/import', async (req, res) => {
let imported = 0
for (const row of req.body.rows || []) { if (row.name) { await putResource(req.auth.tenantId, 'customers', row); imported++ } }
ok(res, { imported, skipped: (req.body.rows || []).length - imported })
})
app.post('/api/customers/merge', async (req, res) => {
const keep = await getResource(req.auth.tenantId, 'customers', req.body.keepId)
const merge = await getResource(req.auth.tenantId, 'customers', req.body.mergeId)
if (!keep || !merge) throw fail(404, '客户不存在')
const value = await putResource(req.auth.tenantId, 'customers', { ...merge, ...keep, id: keep.id })
await deleteResource(req.auth.tenantId, 'customers', merge.id); ok(res, value)
})
app.get('/api/customers/:id', async (req, res) => {
const customer = await getResource(req.auth.tenantId, 'customers', req.params.id)
if (!customer) throw fail(404, '客户不存在')
const [conversations, opportunities, tasks, risks, files, logs] = await Promise.all([
listResources(req.auth.tenantId, 'conversations'),
listResources(req.auth.tenantId, 'opportunities'),
listResources(req.auth.tenantId, 'tasks'),
listResources(req.auth.tenantId, 'risks'),
listResources(req.auth.tenantId, `customer-files:${customer.id}`),
pool.query(
`SELECT id,user_id,action,target,detail,created_at
FROM audit_logs
WHERE tenant_id=$1 AND (target=$2 OR detail->>'customerId'=$2)
ORDER BY created_at DESC LIMIT 200`,
[req.auth.tenantId, customer.id],
),
])
ok(res, {
customer,
conversations: conversations.filter((x) => x.customerId === customer.id),
opportunities: opportunities.filter((x) => x.customerId === customer.id),
tasks: tasks.filter((x) => x.customerId === customer.id),
risks: risks.filter((x) => x.customerId === customer.id),
files,
logs: logs.rows.map((row) => ({
id: row.id,
customerId: customer.id,
actorName: row.user_id ? '团队成员' : '系统',
action: row.action,
detail: typeof row.detail === 'string' ? row.detail : JSON.stringify(row.detail),
createdAt: row.created_at,
})),
})
})
app.get('/api/conversations', async (req, res) => {
let conversations = await listResources(req.auth.tenantId, 'conversations')
const customers = await listResources(req.auth.tenantId, 'customers')
const customerMap = new Map(customers.map((x) => [x.id, x]))
if (req.query.channel) conversations = conversations.filter((x) => x.channel === req.query.channel)
const filter = String(req.query.filter || 'all')
if (filter === 'archived') conversations = conversations.filter((x) => x.archived)
else conversations = conversations.filter((x) => !x.archived)
if (filter === 'unread') conversations = conversations.filter((x) => Number(x.unreadCount || 0) > 0)
if (filter === 'pending') conversations = conversations.filter((x) => x.status === 'pending')
if (filter === 'high-intent') conversations = conversations.filter((x) => x.intentLevel === 'high')
if (filter === 'risk') conversations = conversations.filter((x) => x.hasRisk)
if (filter === 'unassigned') conversations = conversations.filter((x) => !x.assigneeId)
if (filter === 'mine' && req.query.userId) conversations = conversations.filter((x) => x.assigneeId === req.query.userId)
const keyword = String(req.query.keyword || '').trim().toLowerCase()
if (keyword) {
conversations = conversations.filter((conversation) => {
const customer = customerMap.get(conversation.customerId)
return JSON.stringify({ conversation, customer }).toLowerCase().includes(keyword)
})
}
// 会话排序只由最后一条真实消息驱动。查看、清零未读、分配负责人等
// 维护操作会更新资源行,但不应让会话无故置顶。
conversations.sort((a, b) => {
if (Boolean(a.isPinned) !== Boolean(b.isPinned)) return a.isPinned ? -1 : 1
const activityTime = (conversation) => Date.parse(
conversation.lastMessage?.createdAt ||
conversation.lastMessageAt ||
conversation.createdAt ||
conversation.updatedAt ||
'',
) || 0
const timeDifference = activityTime(b) - activityTime(a)
if (timeDifference) return timeDifference
// 邮件 Date 头通常只有秒级精度;同秒消息使用 IMAP UID / Telegram
// 消息 ID 打破平局,避免已读更新后的数据库行顺序影响会话位置。
const activitySequence = (conversation) => Number(
conversation.lastMessage?.emailUid ||
conversation.lastMessage?.telegramMessageId ||
conversation.lastMessage?.id ||
0,
) || 0
const sequenceDifference = activitySequence(b) - activitySequence(a)
if (sequenceDifference) return sequenceDifference
return String(b.id).localeCompare(String(a.id))
})
const list = conversations.map((x) => ({ ...x, customer: customerMap.get(x.customerId) })).filter((x) => x.customer)
discoverTenantBackfills(req.auth.tenantId, list)
ok(res, { list, total: list.length })
})
async function connectedTelegramChannel(tenantId, channelId) {
const channels = await listResources(tenantId, 'channels')
const channel = channelId
? channels.find((item) => item.id === channelId)
: channels.find((item) => item.type === 'telegram' && item.status === 'connected' && item.isDefault)
|| channels.find((item) => item.type === 'telegram' && item.status === 'connected')
if (!channel || channel.type !== 'telegram') throw fail(409, '没有可用的 Telegram 渠道')
if (!channel.gatewayAccountId) throw fail(409, 'Telegram 渠道未关联账号网关')
return channel
}
function telegramChatId(result) {
const value = result?.tgChatId ?? result?.chatId ?? result?.chat_id ?? result?.id
const number = Number(value)
return Number.isSafeInteger(number) && number !== 0 ? String(value) : ''
}
function telegramServiceMessageText(content = {}) {
const type = String(content['@type'] || '')
const labels = {
messageContactRegistered: '该联系人已加入 Telegram',
messageChatJoinByLink: '通过邀请链接加入了群组',
messageChatJoinByRequest: '入群申请已通过',
messagePinMessage: '置顶了一条消息',
messageChatDeleteMember: '一名成员已退出或被移除',
messageChatSetMessageAutoDeleteTime: '群组自动删除时间已更新',
}
if (type === 'messageBasicGroupChatCreate' || type === 'messageSupergroupChatCreate') {
return content.title ? `创建了群组「${content.title}」` : '创建了群组'
}
if (type === 'messageChatChangeTitle') {
return content.title ? `群组名称已修改为「${content.title}」` : '群组名称已修改'
}
if (type === 'messageChatAddMembers') return '新增了群组成员'
return labels[type] || ''
}
async function importTelegramConversation(tenantId, channel, rawChatId) {
const chatId = String(rawChatId)
const conversationId = `${channel.id}:${chatId}`
// An explicit add/open means the user intentionally wants this chat back.
await deleteResource(tenantId, 'conversation-tombstones', conversationId)
const chat = await gatewayRequest(`/tg-accounts/${channel.gatewayAccountId}/chats/${chatId}`)
const identity = await resolveTelegramIdentity(channel, chatId, chat, {})
const chatType = String(identity.chatType || chat.type || chat.raw?.type?.['@type'] || 'private')
const customerId = `tg-${channel.gatewayAccountId}-${chatId}`
const existingCustomer = await getResource(tenantId, 'customers', customerId)
const customer = await putResource(tenantId, 'customers', withDefaults('customers', {
...(existingCustomer || {}),
...identity,
id: customerId,
telegram: chatId,
telegramChatType: chatType,
channel: 'telegram',
source: 'telegram',
}))
const existingConversation = await getResource(tenantId, 'conversations', conversationId)
const history = await gatewayRequest(
`/tg-accounts/${channel.gatewayAccountId}/chats/${chatId}/history?limit=100`,
).catch(() => ({ messages: [] }))
let lastMessage = existingConversation?.lastMessage
for (const raw of [...(history?.messages || [])].reverse()) {
const content = raw.content || {}
const serviceText = telegramServiceMessageText(content)
const typeMap = {
messagePhoto: 'image',
messageDocument: 'file',
messageVoiceNote: 'audio',
messageAudio: 'audio',
messageSticker: 'file',
}
const existingMessage = await getResource(tenantId, `messages:${conversationId}`, String(raw.id))
lastMessage = await putResource(tenantId, `messages:${conversationId}`, {
...(existingMessage || {}),
id: String(raw.id),
conversationId,
sender: serviceText ? 'system' : raw.is_outgoing ? 'agent' : 'customer',
type: typeMap[content['@type']] || 'text',
originalText:
serviceText
|| existingMessage?.originalText
|| content?.text?.text
|| content?.caption?.text,
translatedText: serviceText ? undefined : existingMessage?.translatedText,
translationCn: serviceText ? undefined : existingMessage?.translationCn,
translationStatus: serviceText ? undefined : existingMessage?.translationStatus,
originalLang: existingMessage?.originalLang || 'auto',
status: raw.is_outgoing ? 'sent' : 'delivered',
createdAt: new Date(Number(raw.date || Date.now() / 1000) * 1000).toISOString(),
telegramContent: content,
...telegramAttachment(content),
})
}
const conversation = await putResource(tenantId, 'conversations', {
...(existingConversation || {}),
id: conversationId,
customerId: customer.id,
channel: 'telegram',
channelId: channel.id,
telegramChatId: chatId,
telegramChatType: chatType,
unreadCount: Number(chat.unreadCount || chat.raw?.unread_count || 0),
status: 'replied',
assigneeId: existingConversation?.assigneeId || null,
intentLevel: existingConversation?.intentLevel || 'low',
sentiment: existingConversation?.sentiment || 'neutral',
hasRisk: existingConversation?.hasRisk || false,
summary: existingConversation?.summary || '',
lastMessage,
createdAt: existingConversation?.createdAt || new Date().toISOString(),
})
return { conversation, customer }
}
app.post('/api/conversations/telegram/open', async (req, res) => {
const identifier = String(req.body.identifier || '').trim()
const targetType = String(req.body.targetType || 'user')
if (!identifier) throw fail(400, '请输入 Telegram 用户、群组或邀请链接')
const channel = await connectedTelegramChannel(req.auth.tenantId, req.body.channelId)
const accountPath = `/tg-accounts/${channel.gatewayAccountId}`
let result
const inviteLink = /^(?:https?:\/\/)?(?:t\.me|telegram\.(?:me|dog))\/(?:\+|joinchat\/)/i.test(identifier)
const username = identifier
.replace(/^https?:\/\/(?:t\.me|telegram\.(?:me|dog))\//i, '')
.replace(/^@/, '')
.split(/[/?#]/)[0]
if (inviteLink || targetType === 'invite') {
result = await postGateway(`${accountPath}/groups/join-by-invite-link`, { inviteLink: identifier })
} else if (targetType === 'chat' || targetType === 'group') {
result = /^-?\d+$/.test(identifier)
? await gatewayRequest(`${accountPath}/chats/${identifier}`)
: await postGateway(`${accountPath}/dialogs/open-by-username`, { username })
} else if (/^\+\d{7,15}$/.test(identifier)) {
const user = await postGateway(`${accountPath}/dialogs/open-by-phone`, { phoneNumber: identifier })
const userId = Number(user?.id ?? user?.user_id)
if (!Number.isSafeInteger(userId) || userId <= 0) throw fail(404, '未找到该手机号对应的 Telegram 用户')
result = await postGateway(`${accountPath}/dialogs/open-by-user/${userId}`, {})
} else if (/^\d+$/.test(identifier)) {
result = await postGateway(`${accountPath}/dialogs/open-by-user/${identifier}`, {})
} else {
result = await postGateway(`${accountPath}/dialogs/open-by-username`, { username })
}
const chatId = telegramChatId(result)
if (!chatId) throw fail(502, 'Telegram 返回结果中缺少有效会话 ID')
const value = await importTelegramConversation(req.auth.tenantId, channel, chatId)
await audit(req, 'telegram.conversation.open', value.conversation.id, {
targetType,
identifier: /^\+\d/.test(identifier) ? `${identifier.slice(0, 4)}***` : identifier,
chatId,
})
ok(res, value)
})
app.post('/api/conversations/whatsapp/open', async (req, res) => {
const channels = await listResources(req.auth.tenantId, 'channels')
const channel = req.body.channelId
? channels.find((item) => item.id === req.body.channelId)
: channels.find((item) => item.type === 'whatsapp' && item.status === 'connected' && item.isDefault)
|| channels.find((item) => item.type === 'whatsapp' && item.status === 'connected')
if (!channel || channel.type !== 'whatsapp' || !channel.gatewayAccountId) {
throw fail(409, '没有可用的 WhatsApp 账号')
}
const targetType = String(req.body.targetType || 'user')
let jid
if (targetType === 'invite') {
const result = await postWhatsApp(`/accounts/${channel.gatewayAccountId}/groups/join`, {
inviteLink: String(req.body.identifier || ''),
})
jid = result.jid
} else if (targetType === 'group') {
const result = await postWhatsApp(`/accounts/${channel.gatewayAccountId}/groups`, {
subject: String(req.body.subject || '').trim(),
participants: Array.isArray(req.body.participants) ? req.body.participants : [],
})
jid = result.id || result.gid || result.jid
} else {
const phoneNumber = String(req.body.identifier || '').replace(/\D/g, '')
if (!/^\d{7,15}$/.test(phoneNumber)) throw fail(400, '请输入包含国家代码的 WhatsApp 手机号')
const result = await postWhatsApp(`/accounts/${channel.gatewayAccountId}/contacts/check`, {
phoneNumber,
})
const match = (Array.isArray(result) ? result : []).find((item) => item.exists)
if (!match?.jid) throw fail(404, '该手机号尚未注册 WhatsApp')
jid = match.jid
}
if (!jid) throw fail(502, 'WhatsApp 未返回有效会话标识')
const identity = await whatsappRequest(
`/accounts/${channel.gatewayAccountId}/chats/${encodeURIComponent(jid)}/identity`,
)
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: !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',
}))
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', {
...(previous || {}),
id: conversationId,
customerId,
channel: 'whatsapp',
channelId: channel.id,
whatsappJid: jid,
whatsappChatType: identity.chatType || 'private',
unreadCount: Number(previous?.unreadCount || 0),
status: previous?.status || 'replied',
assigneeId: previous?.assigneeId || null,
intentLevel: previous?.intentLevel || 'low',
sentiment: previous?.sentiment || 'neutral',
hasRisk: previous?.hasRisk || false,
summary: previous?.summary || '',
createdAt: previous?.createdAt || new Date().toISOString(),
})
await audit(req, 'whatsapp.conversation.open', conversation.id, {
targetType,
channelId: channel.id,
})
ok(res, { conversation, customer })
})
app.get('/api/conversations/:id/whatsapp/group', async (req, res) => {
const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id)
if (!conversation) throw fail(404, '会话不存在')
if (conversation.channel !== 'whatsapp' || !String(conversation.whatsappJid || '').endsWith('@g.us')) {
throw fail(409, '当前会话不是 WhatsApp 群组')
}
const channel = await getResource(req.auth.tenantId, 'channels', conversation.channelId)
if (!channel?.gatewayAccountId) throw fail(409, '群组未关联可用 WhatsApp 账号')
ok(res, await whatsappRequest(
`/accounts/${channel.gatewayAccountId}/chats/${encodeURIComponent(conversation.whatsappJid)}/identity`,
))
})
app.post('/api/conversations/:id/whatsapp/group/action', async (req, res) => {
if (!['admin', 'manager'].includes(req.auth.role)) {
throw fail(403, '仅管理员或经理可管理 WhatsApp 群组')
}
const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id)
if (!conversation) throw fail(404, '会话不存在')
if (conversation.channel !== 'whatsapp' || !String(conversation.whatsappJid || '').endsWith('@g.us')) {
throw fail(409, '当前会话不是 WhatsApp 群组')
}
const channel = await getResource(req.auth.tenantId, 'channels', conversation.channelId)
if (!channel?.gatewayAccountId) throw fail(409, '群组未关联可用 WhatsApp 账号')
const action = String(req.body.action || '')
const allowed = ['add', 'remove', 'promote', 'demote', 'subject', 'description', 'invite-code', 'revoke-invite']
if (!allowed.includes(action)) throw fail(400, '不支持的 WhatsApp 群组操作')
const value = await postWhatsApp(
`/accounts/${channel.gatewayAccountId}/groups/${encodeURIComponent(conversation.whatsappJid)}/action`,
{
action,
participants: Array.isArray(req.body.participants) ? req.body.participants : [],
subject: req.body.subject,
description: req.body.description,
},
)
if (action === 'subject' && req.body.subject) {
const customer = await getResource(req.auth.tenantId, 'customers', conversation.customerId)
if (customer) await patchResource(req.auth.tenantId, 'customers', customer.id, { name: String(req.body.subject) })
}
await audit(req, `whatsapp.group.${action}`, conversation.id, {
participants: Array.isArray(req.body.participants) ? req.body.participants.length : 0,
})
ok(res, value)
})
app.post('/api/conversations/:id/actions', async (req, res) => {
const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id)
if (!conversation) throw fail(404, '会话不存在')
if (conversation.channel === 'whatsapp') {
const action = String(req.body.action || '')
if (
['clear', 'delete', 'leave', 'block', 'unblock'].includes(action)
&& !['admin', 'manager'].includes(req.auth.role)
) {
throw fail(403, '仅管理员或经理可执行清空、删除、退出和拉黑操作')
}
const channel = await getResource(req.auth.tenantId, 'channels', conversation.channelId)
if (!channel?.gatewayAccountId || !conversation.whatsappJid) {
throw fail(409, '会话未关联可用 WhatsApp 账号')
}
const gatewayActions = {
archive: 'archive',
unarchive: 'unarchive',
mute: 'mute',
unmute: 'unmute',
'mark-unread': 'mark-unread',
leave: 'leave',
block: 'block',
unblock: 'unblock',
pin: 'pin',
unpin: 'unpin',
}
if (action === 'clear' || action === 'delete') {
await postWhatsApp(
`/accounts/${channel.gatewayAccountId}/chats/${encodeURIComponent(conversation.whatsappJid)}/action`,
{ action },
)
await pool.query(
'DELETE FROM resources WHERE tenant_id=$1 AND kind=$2',
[req.auth.tenantId, `messages:${conversation.id}`],
)
if (action === 'delete') {
await putResource(req.auth.tenantId, 'conversation-tombstones', {
id: conversation.id,
channelId: conversation.channelId,
whatsappJid: conversation.whatsappJid,
deletedAt: new Date().toISOString(),
})
await deleteResource(req.auth.tenantId, 'conversations', conversation.id)
await audit(req, 'whatsapp.conversation.delete', conversation.id, {
jid: conversation.whatsappJid,
})
ok(res, { success: true, deleted: true })
return
}
const { lastMessage: _lastMessage, ...withoutLastMessage } = conversation
const value = await putResource(req.auth.tenantId, 'conversations', {
...withoutLastMessage,
unreadCount: 0,
historyComplete: true,
})
await audit(req, 'whatsapp.conversation.clear', conversation.id, {})
ok(res, value)
return
}
const gatewayAction = gatewayActions[action]
if (!gatewayAction) throw fail(400, '不支持的 WhatsApp 会话操作')
await postWhatsApp(
`/accounts/${channel.gatewayAccountId}/chats/${encodeURIComponent(conversation.whatsappJid)}/action`,
{
action: gatewayAction,
until: action === 'mute'
? Date.now() + Math.max(60, Number(req.body.seconds || 28_800)) * 1000
: undefined,
},
)
const patchMap = {
pin: { isPinned: true },
unpin: { isPinned: false },
archive: { archived: true },
unarchive: { archived: false },
mute: { mutedUntil: new Date(Date.now() + Math.max(60, Number(req.body.seconds || 28_800)) * 1000).toISOString() },
unmute: { mutedUntil: null },
'mark-unread': { unreadCount: Math.max(1, Number(conversation.unreadCount || 0)), markedUnread: true },
leave: { leftAt: new Date().toISOString(), status: 'closed' },
block: { isBlocked: true },
unblock: { isBlocked: false },
}
const value = await patchResource(req.auth.tenantId, 'conversations', conversation.id, patchMap[action])
await audit(req, `whatsapp.conversation.${action}`, conversation.id, {
jid: conversation.whatsappJid,
})
ok(res, value)
return
}
if (conversation.channel !== 'telegram') throw fail(409, '当前操作仅支持 Telegram 会话')
const channel = await connectedTelegramChannel(req.auth.tenantId, conversation.channelId)
const chatId = String(conversation.telegramChatId || '')
if (!/^-?\d+$/.test(chatId)) throw fail(409, '会话缺少有效 Telegram Chat ID')
const action = String(req.body.action || '')
const isTelegramChannel = String(conversation.telegramChatType || '').toLowerCase() === 'channel'
const isTelegramGroupLike =
chatId.startsWith('-')
|| ['group', 'supergroup', 'channel'].includes(
String(conversation.telegramChatType || '').toLowerCase(),
)
if (
['clear', 'delete', 'leave', 'block', 'unblock'].includes(action)
&& !['admin', 'manager'].includes(req.auth.role)
) {
throw fail(403, '仅管理员或经理可执行清空、删除、退出和拉黑操作')
}
const base = `/tg-accounts/${channel.gatewayAccountId}`
let patch = {}
if (action === 'clear' || action === 'delete') {
if (action === 'clear' && isTelegramChannel) {
throw fail(409, 'Telegram 频道不支持仅为当前账号清空历史;请使用“退出并删除会话”')
}
if (action === 'delete' && isTelegramGroupLike) {
await postGateway(`${base}/chats/${chatId}/leave`, {}).catch((error) => {
if (Number(error?.status || 0) !== 400) throw error
})
} else {
await gatewayRequest(`${base}/chats/${chatId}/history`, {
method: 'DELETE',
body: JSON.stringify({
removeFromChatList: action === 'delete',
revoke: false,
}),
})
}
await pool.query(
'DELETE FROM resources WHERE tenant_id=$1 AND kind=$2',
[req.auth.tenantId, `messages:${conversation.id}`],
)
if (action === 'delete') {
await putResource(req.auth.tenantId, 'conversation-tombstones', {
id: conversation.id,
channelId: conversation.channelId,
telegramChatId: chatId,
telegramChatType: conversation.telegramChatType,
deletedAt: new Date().toISOString(),
})
await deleteResource(req.auth.tenantId, 'conversations', conversation.id)
await audit(req, 'telegram.conversation.delete', conversation.id, { chatId })
ok(res, { success: true, deleted: true })
return
}
const { lastMessage: _lastMessage, ...withoutLastMessage } = conversation
const value = await putResource(req.auth.tenantId, 'conversations', {
...withoutLastMessage,
unreadCount: 0,
historyComplete: true,
historyOldestMessageId: undefined,
})
await audit(req, 'telegram.conversation.clear', conversation.id, { chatId })
ok(res, value)
return
}
if (action === 'pin' || action === 'unpin') {
await postGateway(`${base}/chats/${chatId}/pin`, { pinned: action === 'pin', archived: false })
patch = { isPinned: action === 'pin' }
} else if (action === 'archive' || action === 'unarchive') {
await postGateway(`${base}/chats/${chatId}/${action}`, {})
patch = { archived: action === 'archive' }
} else if (action === 'mute' || action === 'unmute') {
const seconds = action === 'mute'
? Math.max(60, Math.min(31_536_000, Number(req.body.seconds || 28_800)))
: 0
await putGateway(`${base}/chats/${chatId}/mute`, { seconds })
patch = { mutedUntil: seconds ? new Date(Date.now() + seconds * 1000).toISOString() : null }
} else if (action === 'mark-unread') {
await postGateway(`${base}/chats/${chatId}/marked-unread`, { marked: true })
patch = { unreadCount: Math.max(1, Number(conversation.unreadCount || 0)), markedUnread: true }
} else if (action === 'leave') {
await postGateway(`${base}/chats/${chatId}/leave`, {})
patch = { leftAt: new Date().toISOString(), status: 'closed' }
} else if (action === 'block' || action === 'unblock') {
const customer = await getResource(req.auth.tenantId, 'customers', conversation.customerId)
const userId = Number(customer?.telegramUserId)
if (!Number.isSafeInteger(userId) || userId <= 0) throw fail(409, '该会话没有可拉黑的 Telegram 用户 ID')
await postGateway(`${base}/dialogs/users/${userId}/${action}`, {})
patch = { isBlocked: action === 'block' }
} else {
throw fail(400, '不支持的 Telegram 会话操作')
}
const value = await patchResource(req.auth.tenantId, 'conversations', conversation.id, patch)
await audit(req, `telegram.conversation.${action}`, conversation.id, { chatId, ...patch })
ok(res, value)
})
app.get('/api/conversations/:id', async (req, res) => {
const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id)
if (!conversation) throw fail(404, '会话不存在')
const [customer, aiBackfill] = await Promise.all([
getResource(req.auth.tenantId, 'customers', conversation.customerId),
getResource(req.auth.tenantId, 'ai-backfill-jobs', req.params.id),
])
ok(res, { conversation, customer, aiBackfill })
})
app.get('/api/conversations/:id/messages', async (req, res) => {
const list = await listResources(req.auth.tenantId, `messages:${req.params.id}`)
list.sort((a, b) => {
const time = Date.parse(a.createdAt || '') - Date.parse(b.createdAt || '')
if (time) return time
return Number(a.id || 0) - Number(b.id || 0)
})
// 首次打开会话时为最近的历史消息补齐译文和意图。历史回填限制
// 为 30 条控制模型成本,后续实时消息会逐条处理。
for (const message of list.slice(-30)) {
enqueueAutomaticTranslation(req.auth.tenantId, req.params.id, message)
}
void scheduleConversationBackfill(req.auth.tenantId, req.params.id)
enqueueContextCompaction(req.auth.tenantId, req.params.id)
ok(res, list)
})
app.get('/api/conversations/:id/ai-status', async (req, res) => {
const [conversation, backfill] = await Promise.all([
getResource(req.auth.tenantId, 'conversations', req.params.id),
getResource(req.auth.tenantId, 'ai-backfill-jobs', req.params.id),
])
if (!conversation) throw fail(404, '会话不存在')
ok(res, {
backfill: backfill || {
status: 'not-started',
totalCount: 0,
completedCount: 0,
pendingCount: 0,
progress: 0,
},
context: {
status: conversation.aiContextStatus || 'waiting',
sourceMessageCount: Number(conversation.aiContextSourceMessageCount || 0),
pendingMessages: Number(conversation.aiContextPendingMessages || 0),
compressedAt: conversation.aiContextCompressedAt || null,
memory: conversation.aiContextMemory || null,
},
})
})
app.get('/api/conversations/:id/messages/page', async (req, res) => {
const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id)
if (!conversation) throw fail(404, '会话不存在')
const limit = Math.max(1, Math.min(200, Number(req.query.limit || 50)))
const before = String(req.query.before || req.query.cursor || '')
const after = String(req.query.after || '')
const list = await listResources(req.auth.tenantId, `messages:${req.params.id}`)
list.sort((a, b) => {
const time = Date.parse(a.createdAt || '') - Date.parse(b.createdAt || '')
return time || String(a.id).localeCompare(String(b.id))
})
let start = 0
let end = list.length
if (before) {
const index = list.findIndex((item) => String(item.id) === before)
if (index >= 0) end = index
}
if (after) {
const index = list.findIndex((item) => String(item.id) === after)
if (index >= 0) start = index + 1
}
const page = after
? list.slice(start, start + limit)
: list.slice(Math.max(start, end - limit), end)
const firstIndex = page.length ? list.findIndex((item) => item.id === page[0].id) : -1
const lastIndex = page.length ? list.findIndex((item) => item.id === page.at(-1).id) : -1
ok(res, {
items: page,
beforeCursor: firstIndex > 0 ? String(page[0].id) : null,
afterCursor: lastIndex >= 0 && lastIndex < list.length - 1 ? String(page.at(-1).id) : null,
hasMoreBefore: firstIndex > 0,
hasMoreAfter: lastIndex >= 0 && lastIndex < list.length - 1,
})
})
app.patch('/api/conversations/:id/messages/:messageId', async (req, res) => {
const allowed = {}
if (typeof req.body.isImportant === 'boolean') allowed.isImportant = req.body.isImportant
if (typeof req.body.internalNote === 'string') allowed.internalNote = req.body.internalNote.slice(0, 2000)
if (!Object.keys(allowed).length) throw fail(400, '没有可更新的消息字段')
const value = await patchResource(
req.auth.tenantId,
`messages:${req.params.id}`,
req.params.messageId,
allowed,
)
if (!value) throw fail(404, '消息不存在')
const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id)
if (String(conversation?.lastMessage?.id || '') === String(value.id)) {
await patchResource(req.auth.tenantId, 'conversations', req.params.id, { lastMessage: value })
}
await audit(req, 'message.update', value.id, allowed)
broadcast(req.auth.tenantId, {
type: 'message.updated',
conversationId: req.params.id,
message: value,
})
ok(res, value)
})
app.post('/api/conversations/:id/messages/:messageId/analyze', async (req, res) => {
const message = await getResource(
req.auth.tenantId,
`messages:${req.params.id}`,
req.params.messageId,
)
if (!message) throw fail(404, '消息不存在')
if (message.sender !== 'customer' || message.type !== 'text' || isNonLinguisticText(message.originalText)) {
throw fail(422, '当前消息没有可识别的客户文本')
}
const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id)
if (!conversation) throw fail(404, '会话不存在')
if (conversation.messageIntentEnabled === false) {
throw fail(409, '当前会话已关闭逐句意图识别')
}
if (req.body.force === true && message.intentAnalysis) {
await putResource(req.auth.tenantId, `messages:${req.params.id}`, {
...message,
intentAnalysis: null,
intentStatus: 'pending',
})
}
await processAutomaticTranslation({
tenantId: req.auth.tenantId,
conversationId: req.params.id,
message,
targetField: 'translatedText',
})
ok(res, await getResource(req.auth.tenantId, `messages:${req.params.id}`, req.params.messageId))
})
app.get('/api/conversations/:id/messages/:messageId/attachment', async (req, res) => {
const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id)
if (!conversation) throw fail(404, '会话不存在')
const message = await getResource(req.auth.tenantId, `messages:${req.params.id}`, req.params.messageId)
if (conversation.channel === 'email') {
const attachments = await listResources(
req.auth.tenantId,
`email-attachments:${req.params.id}:${req.params.messageId}`,
)
const attachment = req.query.attachmentId
? attachments.find((item) => item.id === req.query.attachmentId)
: attachments[0]
if (!attachment?.base64) throw fail(404, '邮件附件不存在或超过 20MB 下载限制')
ok(res, {
dataUrl: `data:${attachment.mimeType || 'application/octet-stream'};base64,${attachment.base64}`,
mimeType: attachment.mimeType,
name: attachment.name,
})
return
}
if (conversation.channel === 'whatsapp') {
const channel = await getResource(req.auth.tenantId, 'channels', conversation.channelId)
if (!['image', 'audio', 'file'].includes(message?.type)) {
throw fail(404, '该消息不是 WhatsApp 媒体消息')
}
if (!channel?.gatewayAccountId || !message?.whatsappMessageId) {
throw fail(404, '该消息没有可下载的 WhatsApp 媒体')
}
const file = await whatsappRequest(
`/accounts/${channel.gatewayAccountId}/messages/${encodeURIComponent(message.whatsappMessageId)}/media`,
)
const mimeType = message.attachmentMimeType || file?.mimetype || 'application/octet-stream'
ok(res, {
dataUrl: `data:${mimeType};base64,${file.base64}`,
mimeType,
name: message.attachmentName || file.fileName || `whatsapp-${message.whatsappMessageId}`,
})
return
}
if (!message?.telegramFileId) throw fail(404, '该消息没有可下载的 Telegram 文件')
const channel = await getResource(req.auth.tenantId, 'channels', conversation.channelId)
if (!channel?.gatewayAccountId) throw fail(409, '会话未关联 Telegram 账号')
const file = await gatewayRequest(
`/tg-accounts/${channel.gatewayAccountId}/files/tdlib/${Number(message.telegramFileId)}/base64`,
)
const mimeType = message.attachmentMimeType || file?.mimeType || 'application/octet-stream'
ok(res, {
dataUrl: `data:${mimeType};base64,${file.base64}`,
mimeType,
name: message.attachmentName || `telegram-file-${message.telegramFileId}`,
})
})
app.post('/api/conversations/:id/read', async (req, res) => {
const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id)
if (!conversation) throw fail(404, '会话不存在')
const channel = await getResource(req.auth.tenantId, 'channels', conversation.channelId)
if (conversation.channel === 'email') {
if (!channel?.encryptedPassword) throw fail(409, '会话未关联可用 Email 渠道')
const messages = await listResources(req.auth.tenantId, `messages:${req.params.id}`)
const requested = Array.isArray(req.body.messageIds) ? new Set(req.body.messageIds.map(String)) : null
const uids = messages
.filter((message) => message.sender === 'customer' && (!requested || requested.has(String(message.id))))
.map((message) => Number(message.emailUid))
.filter(Number.isFinite)
await markEmailUidsSeen(channel, uids)
const updated = await patchResource(req.auth.tenantId, 'conversations', req.params.id, {
unreadCount: 0,
lastReadAt: new Date().toISOString(),
})
broadcast(req.auth.tenantId, {
type: 'conversation.read',
conversationId: req.params.id,
conversation: updated,
})
ok(res, updated)
return
}
if (conversation.channel === 'whatsapp') {
if (!channel?.gatewayAccountId || !conversation.whatsappJid) {
throw fail(409, '会话未关联可用 WhatsApp 渠道')
}
await postWhatsApp(
`/accounts/${channel.gatewayAccountId}/chats/${encodeURIComponent(conversation.whatsappJid)}/read`,
{},
)
const updated = await patchResource(req.auth.tenantId, 'conversations', req.params.id, {
unreadCount: 0,
lastReadAt: new Date().toISOString(),
})
broadcast(req.auth.tenantId, {
type: 'conversation.read',
conversationId: req.params.id,
conversation: updated,
})
ok(res, updated)
return
}
if (!channel?.gatewayAccountId || !conversation.telegramChatId) throw fail(409, '会话未关联可用 Telegram 渠道')
const messages = await listResources(req.auth.tenantId, `messages:${req.params.id}`)
const requested = Array.isArray(req.body.messageIds) ? req.body.messageIds.map(String) : []
const messageIds = (requested.length
? requested
: messages.filter((message) => message.sender === 'customer').map((message) => String(message.id)))
.filter((id) => /^\d+$/.test(id))
.slice(-100)
.map(Number)
if (messageIds.length) {
await postGateway(`/tg-accounts/${channel.gatewayAccountId}/messages/read`, {
chatId: Number(conversation.telegramChatId),
messageIds,
forceRead: true,
})
}
const updated = await patchResource(req.auth.tenantId, 'conversations', req.params.id, {
unreadCount: 0,
lastReadAt: new Date().toISOString(),
lastReadMessageId: messageIds.length ? String(messageIds.at(-1)) : conversation.lastReadMessageId,
})
broadcast(req.auth.tenantId, {
type: 'conversation.read',
conversationId: req.params.id,
conversation: updated,
})
ok(res, updated)
})
app.post('/api/conversations/:id/presence', async (req, res) => {
const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id)
if (!conversation) throw fail(404, '会话不存在')
if (conversation.channel !== 'whatsapp') {
ok(res, { success: true, ignored: true })
return
}
const channel = await getResource(req.auth.tenantId, 'channels', conversation.channelId)
if (!channel?.gatewayAccountId || !conversation.whatsappJid) {
throw fail(409, '会话未关联可用 WhatsApp 渠道')
}
const presence = ['composing', 'recording', 'paused', 'available', 'unavailable'].includes(req.body.presence)
? req.body.presence
: 'composing'
await postWhatsApp(
`/accounts/${channel.gatewayAccountId}/chats/${encodeURIComponent(conversation.whatsappJid)}/presence`,
{ presence },
)
ok(res, { success: true, presence })
})
app.patch('/api/conversations/:id', async (req, res) => {
const patch = { ...req.body }
if (patch.markRead) patch.unreadCount = 0
delete patch.markRead
const value = await patchResource(req.auth.tenantId, 'conversations', req.params.id, patch)
if (!value) throw fail(404, '会话不存在')
if (patch.messageIntentEnabled === true) {
const messages = await listResources(req.auth.tenantId, `messages:${req.params.id}`)
for (const message of messages.slice(-30)) {
if (message.sender === 'customer' && !message.intentAnalysis) {
enqueueAutomaticTranslation(req.auth.tenantId, req.params.id, message)
}
}
void scheduleConversationBackfill(req.auth.tenantId, req.params.id)
}
ok(res, value)
})
app.post('/api/conversations/:id/messages', async (req, res) => {
const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id)
if (!conversation) throw fail(404, '会话不存在')
const channel = await getResource(req.auth.tenantId, 'channels', conversation.channelId)
const idempotencyKey = String(
req.get('idempotency-key')
|| req.body.idempotencyKey
|| uid(),
).slice(0, 160)
if (conversation.channel === 'whatsapp') {
const prior = await getResource(
req.auth.tenantId,
'message-idempotency',
`${conversation.id}:${idempotencyKey}`,
)
if (prior?.messageId) {
const priorMessage = await getResource(
req.auth.tenantId,
`messages:${conversation.id}`,
prior.messageId,
)
if (priorMessage) {
ok(res, priorMessage)
return
}
}
}
const originalText = String(req.body.text || '').trim()
if (!originalText) throw fail(400, '消息内容不能为空')
let telegramText = originalText
let originalLang = 'auto'
let targetLang = normalizeLanguageCode(req.body.targetLang) || 'auto'
let translationCn = String(req.body.translationCn || '').trim().slice(0, 8000)
let translationMethod = 'source'
let translationContextMessages = 0
let translationModel = ''
let translationConversationSummaryApplied = false
if (req.body.autoTranslate !== false) {
const resolvedLanguage = await resolveConversationLanguage(
req.auth.tenantId,
conversation,
req.body.targetLang,
)
const context = sortMessagesChronologically(resolvedLanguage.messages
|| await listResources(req.auth.tenantId, `messages:${conversation.id}`)
)
const settings = await getResource(req.auth.tenantId, 'singleton', 'settings')
const contextualMessages = (settings?.ai?.contextualTranslation ?? true) !== false
? context.slice(-20)
: []
const customer = contextualMessages.length
? await getResource(req.auth.tenantId, 'customers', conversation.customerId)
: null
const conversationSummary = contextualMessages.length
? contextMemoryText(conversation, customer)
: ''
const targetInstruction = resolvedLanguage.code === 'auto'
? '自动判断最近一条客户消息使用的主要语言;若无客户语言线索,使用英语'
: `ISO 代码 ${resolvedLanguage.code} 对应的语言`
const translation = await deepseek(
'你是跨境商务消息翻译器。只翻译 <source>,输出严格 JSON。保持原意、数字、金额、型号、链接、换行和专有名词;不得解释、回答、摘要或增加承诺。译文必须自然且可直接发送。若源语言已是目标语言,原样返回。',
`目标语言:${targetInstruction}
<conversation_context>
${conversationContext(contextualMessages, 20)}
</conversation_context>
${conversationSummary ? `<conversation_summary>\n${conversationSummary}\n</conversation_summary>` : ''}
<source>
${originalText}
</source>
输出 {"translated":"完整译文","detectedLang":"源语言 ISO 代码","targetLang":"实际目标语言 ISO 代码"}`,
{ json: true, maxTokens: Math.min(2200, Math.max(600, originalText.length * 3)) },
)
if (!translation?.translated) throw fail(502, 'AI 未返回完整译文,消息未发送')
telegramText = String(translation.translated).trim()
originalLang = normalizeLanguageCode(translation.detectedLang) || 'auto'
targetLang = normalizeLanguageCode(translation.targetLang)
|| normalizeLanguageCode(resolvedLanguage.code)
|| 'en'
if (/[\u3400-\u9fff]/u.test(originalText)) translationCn = originalText
translationContextMessages = contextualMessages.length
translationConversationSummaryApplied = Boolean(conversationSummary)
translationMethod = contextualMessages.length ? 'ai-contextual' : 'ai-direct'
translationModel = process.env.DEEPSEEK_MODEL || 'deepseek-v4-flash'
}
if (conversation.channel === 'email') {
if (!channel?.encryptedPassword) throw fail(409, '会话未关联可用 Email 渠道')
const customer = await getResource(req.auth.tenantId, 'customers', conversation.customerId)
if (!customer?.email) throw fail(409, '客户没有可用邮箱地址')
const history = (await listResources(req.auth.tenantId, `messages:${conversation.id}`))
.sort((a, b) => Date.parse(a.createdAt || '') - Date.parse(b.createdAt || ''))
const latestEmail = [...history].reverse().find((message) => message.emailMessageId)
const baseSubject = normalizeEmailSubject(conversation.emailSubject || latestEmail?.emailSubject || '业务沟通')
const result = await sendEmailMessage(channel, {
to: customer.email,
subject: /^re:/i.test(conversation.emailSubject || '') ? conversation.emailSubject : `Re: ${baseSubject}`,
text: telegramText,
inReplyTo: latestEmail?.emailMessageId,
references: [
...(latestEmail?.emailReferences || []),
...(latestEmail?.emailMessageId ? [latestEmail.emailMessageId] : []),
],
})
const message = await putResource(req.auth.tenantId, `messages:${conversation.id}`, {
id: stableEmailId('email-outgoing', result.messageId || uid()),
conversationId: conversation.id,
sender: 'agent',
type: 'text',
originalText,
translatedText: telegramText !== originalText ? telegramText : undefined,
translationMethod,
translationContextMessages,
translationConversationSummaryApplied,
translationModel,
translationCn: translationCn || undefined,
translationStatus: translationCn ? 'completed' : 'pending',
originalLang,
targetLang,
status: 'sent',
replyToId: req.body.replyToId ? String(req.body.replyToId) : undefined,
replyPreview: req.body.replyPreview ? String(req.body.replyPreview).slice(0, 240) : undefined,
emailMessageId: result.messageId,
emailInReplyTo: latestEmail?.emailMessageId,
emailReferences: latestEmail?.emailReferences || [],
emailSubject: `Re: ${baseSubject}`,
createdAt: new Date().toISOString(),
})
const updatedConversation = await patchResource(req.auth.tenantId, 'conversations', conversation.id, {
lastMessage: message,
status: 'replied',
updatedAt: message.createdAt,
})
await audit(req, 'email.message.send', conversation.id, {
to: customer.email,
messageId: result.messageId,
})
broadcast(req.auth.tenantId, {
type: 'message.updated',
conversationId: conversation.id,
message,
customer,
conversation: updatedConversation,
})
ok(res, message)
enqueueAutomaticTranslation(req.auth.tenantId, conversation.id, message)
return
}
if (conversation.channel === 'whatsapp') {
if (!channel?.gatewayAccountId || !conversation.whatsappJid) {
throw fail(409, '会话未关联可用 WhatsApp 账号')
}
const result = await postWhatsApp(`/accounts/${channel.gatewayAccountId}/messages/send`, {
jid: conversation.whatsappJid,
text: telegramText,
quotedMessageId: req.body.replyToId ? String(req.body.replyToId) : undefined,
idempotencyKey,
clientMessageId: req.body.clientMessageId ? String(req.body.clientMessageId) : undefined,
})
const resultMessageId = String(result?.id || uid())
await putResource(req.auth.tenantId, 'outgoing-message-meta', {
id: resultMessageId,
conversationId: conversation.id,
originalText,
translatedText: telegramText !== originalText ? telegramText : undefined,
translationMethod,
translationContextMessages,
translationConversationSummaryApplied,
translationModel,
translationCn: translationCn || undefined,
originalLang,
targetLang,
createdAt: new Date().toISOString(),
})
const message = await putResource(req.auth.tenantId, `messages:${conversation.id}`, {
id: resultMessageId,
conversationId: conversation.id,
sender: 'agent',
type: 'text',
originalText,
translatedText: telegramText !== originalText ? telegramText : undefined,
translationMethod,
translationContextMessages,
translationConversationSummaryApplied,
translationModel,
translationCn: translationCn || undefined,
translationStatus: translationCn ? 'completed' : 'pending',
originalLang,
targetLang,
status: result?.deliveryStatus || (result?.queued ? 'sending' : 'sent'),
replyToId: req.body.replyToId ? String(req.body.replyToId) : undefined,
replyPreview: req.body.replyPreview ? String(req.body.replyPreview).slice(0, 240) : undefined,
whatsappMessageId: resultMessageId,
createdAt: result?.createdAt || new Date().toISOString(),
})
await putResource(req.auth.tenantId, 'message-idempotency', {
id: `${conversation.id}:${idempotencyKey}`,
conversationId: conversation.id,
messageId: resultMessageId,
idempotencyKey,
createdAt: new Date().toISOString(),
})
const updatedConversation = await patchResource(req.auth.tenantId, 'conversations', conversation.id, {
lastMessage: message,
status: 'replied',
updatedAt: message.createdAt,
})
const customer = await getResource(req.auth.tenantId, 'customers', conversation.customerId)
broadcast(req.auth.tenantId, {
type: 'message.updated',
conversationId: conversation.id,
message,
customer,
conversation: updatedConversation,
})
await audit(req, 'whatsapp.message.send', conversation.id, {
messageId: resultMessageId,
channelId: channel.id,
})
ok(res, message)
enqueueAutomaticTranslation(req.auth.tenantId, conversation.id, message)
return
}
if (!channel?.gatewayAccountId || !conversation.telegramChatId) throw fail(409, '会话未关联可用 Telegram 渠道')
const result = await postGateway(`/tg-accounts/${channel.gatewayAccountId}/messages/send-direct`, {
chatId: conversation.telegramChatId, text: telegramText, replyToMessageId: req.body.replyToId,
idempotencyKey: req.get('idempotency-key') || uid(),
})
const resultMessageId = String(result?.id || result?.messageId || uid())
await putResource(req.auth.tenantId, 'outgoing-message-meta', {
id: resultMessageId,
conversationId: conversation.id,
originalText,
translatedText: telegramText !== originalText ? telegramText : undefined,
translationMethod,
translationContextMessages,
translationConversationSummaryApplied,
translationModel,
translationCn: translationCn || undefined,
originalLang,
targetLang,
createdAt: new Date().toISOString(),
})
const message = await putResource(req.auth.tenantId, `messages:${conversation.id}`, {
id: resultMessageId, conversationId: conversation.id,
sender: 'agent', type: req.body.type || 'text', originalText,
translatedText: telegramText !== originalText ? telegramText : undefined,
translationMethod,
translationContextMessages,
translationConversationSummaryApplied,
translationModel,
translationCn: translationCn || undefined,
translationStatus: translationCn ? 'completed' : 'pending',
originalLang,
targetLang,
status: result?.deliveryState === 'sent' ? 'sent' : 'sending',
replyToId: req.body.replyToId ? String(req.body.replyToId) : undefined,
replyPreview: req.body.replyPreview ? String(req.body.replyPreview).slice(0, 240) : undefined,
createdAt: new Date().toISOString(),
})
const updatedConversation = await patchResource(
req.auth.tenantId,
'conversations',
conversation.id,
{ lastMessage: message, status: 'replied' },
)
const customer = await getResource(req.auth.tenantId, 'customers', conversation.customerId)
broadcast(req.auth.tenantId, {
type: 'message.updated',
conversationId: conversation.id,
message,
customer,
conversation: updatedConversation,
})
ok(res, message)
enqueueAutomaticTranslation(req.auth.tenantId, conversation.id, message)
})
app.post('/api/conversations/:id/messages/action', async (req, res) => {
const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id)
if (!conversation) throw fail(404, '会话不存在')
if (conversation.channel !== 'whatsapp') {
throw fail(409, '该操作当前仅支持 WhatsApp 会话')
}
const channel = await getResource(req.auth.tenantId, 'channels', conversation.channelId)
if (!channel?.gatewayAccountId || !conversation.whatsappJid) {
throw fail(409, '会话未关联可用 WhatsApp 账号')
}
const action = String(req.body.action || '')
const allowed = ['react', 'edit', 'delete', 'forward', 'poll', 'location', 'contact', 'disappearing']
if (!allowed.includes(action)) throw fail(400, '不支持的 WhatsApp 消息操作')
const payload = {
...req.body,
action,
jid: conversation.whatsappJid,
}
if (action === 'forward') {
const destination = await getResource(
req.auth.tenantId,
'conversations',
String(req.body.destinationConversationId || req.params.id),
)
if (!destination || destination.channel !== 'whatsapp' || destination.channelId !== channel.id) {
throw fail(400, '转发目标必须是同一 WhatsApp 账号下的会话')
}
payload.destinationJid = destination.whatsappJid
}
delete payload.destinationConversationId
const result = await postWhatsApp(
`/accounts/${channel.gatewayAccountId}/messages/action`,
payload,
)
let response = result
const targetId = String(req.body.messageId || '')
if (action === 'edit' && targetId) {
response = await patchResource(req.auth.tenantId, `messages:${conversation.id}`, targetId, {
originalText: String(req.body.text || ''),
editedAt: new Date().toISOString(),
})
} else if (action === 'delete' && targetId) {
response = await patchResource(req.auth.tenantId, `messages:${conversation.id}`, targetId, {
originalText: '',
isDeleted: true,
deletedAt: new Date().toISOString(),
})
} else if (result?.id && result?.chatId) {
response = (await upsertWhatsAppMessage(req.auth.tenantId, channel, result, { notify: false }))?.message || result
}
if (response?.id && ['edit', 'delete'].includes(action)) {
broadcast(req.auth.tenantId, {
type: 'message.updated',
conversationId: conversation.id,
message: response,
})
}
await audit(req, `whatsapp.message.${action}`, conversation.id, {
messageId: targetId || result?.id,
})
ok(res, response || { success: true })
})
app.post('/api/conversations/:id/media', async (req, res) => {
const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id)
if (!conversation) throw fail(404, '会话不存在')
const channel = await getResource(req.auth.tenantId, 'channels', conversation.channelId)
const { name, mimeType, base64, kind = 'document' } = req.body
const bytes = Buffer.from(String(base64 || ''), 'base64')
if (!name || !bytes.length) throw fail(400, '请选择有效文件')
if (bytes.length > 20 * 1024 * 1024) throw fail(413, '单个文件不能超过 20MB')
if (conversation.channel === 'email') {
if (!channel?.encryptedPassword) throw fail(409, '会话未关联可用 Email 渠道')
const customer = await getResource(req.auth.tenantId, 'customers', conversation.customerId)
if (!customer?.email) throw fail(409, '客户没有可用邮箱地址')
const result = await sendEmailMessage(channel, {
to: customer.email,
subject: `Re: ${normalizeEmailSubject(conversation.emailSubject || '业务沟通')}`,
text: String(req.body.caption || `请查收附件:${name}`),
attachments: [{ filename: name, content: bytes, contentType: mimeType || 'application/octet-stream' }],
})
const message = await putResource(req.auth.tenantId, `messages:${conversation.id}`, {
id: stableEmailId('email-outgoing', result.messageId || uid()),
conversationId: conversation.id,
sender: 'agent',
type: mimeType?.startsWith('image/') ? 'image' : 'file',
originalText: String(req.body.caption || ''),
originalLang: 'auto',
status: 'sent',
attachmentName: name,
attachmentMimeType: mimeType || 'application/octet-stream',
attachmentSize: bytes.length,
emailMessageId: result.messageId,
emailSubject: `Re: ${normalizeEmailSubject(conversation.emailSubject || '业务沟通')}`,
createdAt: new Date().toISOString(),
})
const updatedConversation = await patchResource(req.auth.tenantId, 'conversations', conversation.id, {
lastMessage: message,
status: 'replied',
updatedAt: message.createdAt,
})
await audit(req, 'email.attachment.send', conversation.id, { name, size: bytes.length })
broadcast(req.auth.tenantId, {
type: 'message.updated',
conversationId: conversation.id,
message,
customer,
conversation: updatedConversation,
})
ok(res, message)
return
}
if (conversation.channel === 'whatsapp') {
if (!channel?.gatewayAccountId || !conversation.whatsappJid) {
throw fail(409, '会话未关联可用 WhatsApp 账号')
}
const form = new FormData()
form.append('file', new Blob([bytes], { type: mimeType || 'application/octet-stream' }), name)
form.append('jid', String(conversation.whatsappJid))
form.append('caption', String(req.body.caption || ''))
form.append('ptt', String(kind === 'voice'))
form.append('kind', String(kind))
form.append('idempotencyKey', String(req.body.idempotencyKey || req.get('idempotency-key') || uid()))
if (req.body.clientMessageId) form.append('clientMessageId', String(req.body.clientMessageId))
const result = await uploadWhatsApp(`/accounts/${channel.gatewayAccountId}/messages/media`, form)
const saved = await upsertWhatsAppMessage(req.auth.tenantId, channel, result, { notify: false })
await audit(req, 'whatsapp.media.send', conversation.id, { name, kind, size: bytes.length })
ok(res, saved?.message || result)
return
}
if (!channel?.gatewayAccountId) throw fail(409, '会话未关联 Telegram 账号')
const form = new FormData()
form.append('file', new Blob([bytes], { type: mimeType || 'application/octet-stream' }), name)
form.append('chatId', String(conversation.telegramChatId))
form.append('kind', kind)
const result = await uploadGateway(`/tg-accounts/${channel.gatewayAccountId}/files/upload`, form)
await audit(req, 'telegram.media.send', conversation.id, { name, kind, size: bytes.length })
ok(res, result)
})
app.post('/api/conversations/:id/album', async (req, res) => {
const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id)
if (!conversation) throw fail(404, '会话不存在')
const channel = await getResource(req.auth.tenantId, 'channels', conversation.channelId)
const files = Array.isArray(req.body.files) ? req.body.files : []
if (files.length < 2 || files.length > 10) throw fail(400, '相册必须包含 2 到 10 个图片或视频')
let totalSize = 0
const prepared = []
for (const item of files) {
const bytes = Buffer.from(String(item.base64 || ''), 'base64')
if (!item.name || !bytes.length) throw fail(400, '相册包含无效文件')
totalSize += bytes.length
if (bytes.length > 20 * 1024 * 1024 || totalSize > 100 * 1024 * 1024) throw fail(413, '相册文件过大')
prepared.push({ ...item, bytes })
}
if (conversation.channel === 'email') {
if (!channel?.encryptedPassword) throw fail(409, '会话未关联可用 Email 渠道')
const customer = await getResource(req.auth.tenantId, 'customers', conversation.customerId)
if (!customer?.email) throw fail(409, '客户没有可用邮箱地址')
const result = await sendEmailMessage(channel, {
to: customer.email,
subject: `Re: ${normalizeEmailSubject(conversation.emailSubject || '业务沟通')}`,
text: `请查收 ${prepared.length} 个附件。`,
attachments: prepared.map((item) => ({
filename: item.name,
content: item.bytes,
contentType: item.mimeType || 'application/octet-stream',
})),
})
await audit(req, 'email.album.send', conversation.id, { count: prepared.length, size: totalSize })
ok(res, { success: true, messageId: result.messageId, count: prepared.length })
return
}
if (conversation.channel === 'whatsapp') {
if (!channel?.gatewayAccountId || !conversation.whatsappJid) {
throw fail(409, '会话未关联可用 WhatsApp 账号')
}
const form = new FormData()
for (const item of prepared) {
form.append('files', new Blob([item.bytes], { type: item.mimeType || 'image/jpeg' }), item.name)
}
form.append('jid', String(conversation.whatsappJid))
form.append('caption', String(req.body.caption || ''))
const result = await uploadWhatsApp(`/accounts/${channel.gatewayAccountId}/messages/album`, form)
const sent = []
for (const raw of result?.messages || []) {
const saved = await upsertWhatsAppMessage(req.auth.tenantId, channel, raw, { notify: false })
sent.push(saved?.message || raw)
}
await audit(req, 'whatsapp.album.send', conversation.id, { count: sent.length, size: totalSize })
ok(res, { success: true, count: sent.length, messages: sent, parent: result?.parent })
return
}
if (!channel?.gatewayAccountId) throw fail(409, '会话未关联 Telegram 账号')
const form = new FormData()
for (const item of prepared) {
form.append('files', new Blob([item.bytes], { type: item.mimeType || 'image/jpeg' }), item.name)
}
form.append('chatId', String(conversation.telegramChatId))
const result = await uploadGateway(`/tg-accounts/${channel.gatewayAccountId}/files/upload-album`, form)
await audit(req, 'telegram.album.send', conversation.id, { count: files.length, size: totalSize })
ok(res, result)
})
function publicChannel(channel) {
if (!channel) return channel
const {
encryptedPassword: _encryptedPassword,
...safe
} = channel
return safe
}
async function shouldBeDefaultChannel(tenantId, type) {
const channels = await listResources(tenantId, 'channels')
return !channels.some((item) => item.type === type && item.status !== 'unconnected')
}
async function promoteDefaultChannel(tenantId, type, excludingId) {
const channels = await listResources(tenantId, 'channels')
const replacement = channels.find(
(item) => item.id !== excludingId && item.type === type && item.status === 'connected',
)
if (replacement) await patchResource(tenantId, 'channels', replacement.id, { isDefault: true })
return replacement
}
function normalizeEmailSubject(subject) {
return String(subject || '(无主题)')
.replace(/^\s*((re|fw|fwd)\s*:\s*)+/ig, '')
.replace(/\s+/g, ' ')
.trim()
}
function stableEmailId(prefix, value) {
return `${prefix}-${crypto.createHash('sha256').update(String(value)).digest('hex').slice(0, 24)}`
}
async function appendChannelLog(tenantId, channelId, detail, status = 'success', action = 'sync') {
return putResource(tenantId, `channel-logs:${channelId}`, {
id: uid(),
channelId,
action,
detail,
status,
createdAt: new Date().toISOString(),
})
}
async function syncEmailTenant(tenantId, channel, { full = false } = {}) {
if (!channel?.encryptedPassword) throw fail(409, 'Email 渠道凭据缺失,请重新连接')
const result = await fetchEmailMessages(channel, { full })
let messagesSynced = 0
let conversationsUpdated = 0
for (const incoming of result.messages) {
const remote = incoming.from.email && incoming.from.email !== String(channel.account || '').toLowerCase()
? incoming.from
: incoming.to.find((item) => item.email && item.email !== String(channel.account || '').toLowerCase())
if (!remote?.email) continue
const customers = await listResources(tenantId, 'customers')
const existingCustomer = customers.find((customer) =>
String(customer.email || '').toLowerCase() === remote.email,
)
const customerId = existingCustomer?.id || stableEmailId('email-customer', `${channel.id}:${remote.email}`)
const customer = await putResource(tenantId, 'customers', withDefaults('customers', {
...(existingCustomer || {}),
id: customerId,
name: remote.name || existingCustomer?.name || remote.email.split('@')[0],
email: remote.email,
channel: 'email',
source: 'email',
sourcePath: channel.account,
}))
const normalizedSubject = normalizeEmailSubject(incoming.subject)
const threadKey = `${remote.email}:${normalizedSubject.toLowerCase()}`
const conversationId = stableEmailId('email-conversation', `${channel.id}:${threadKey}`)
const existingConversation = await getResource(tenantId, 'conversations', conversationId)
const messageId = stableEmailId('email-message', `${channel.id}:${incoming.uid}`)
const existingMessage = await getResource(tenantId, `messages:${conversationId}`, messageId)
const outgoing = incoming.from.email === String(channel.account || '').toLowerCase()
const attachmentRefs = []
for (const attachment of incoming.attachments || []) {
const attachmentId = stableEmailId('email-attachment', `${channel.id}:${incoming.uid}:${attachment.id}`)
if (attachment.base64) {
await putResource(tenantId, `email-attachments:${conversationId}:${messageId}`, {
id: attachmentId,
name: attachment.name,
mimeType: attachment.mimeType,
size: attachment.size,
base64: attachment.base64,
createdAt: incoming.date,
})
}
attachmentRefs.push({
id: attachmentId,
name: attachment.name,
mimeType: attachment.mimeType,
size: attachment.size,
available: Boolean(attachment.base64),
})
}
const message = await putResource(tenantId, `messages:${conversationId}`, {
...(existingMessage || {}),
id: messageId,
conversationId,
sender: outgoing ? 'agent' : 'customer',
senderName: incoming.from.name || incoming.from.email,
type: 'text',
originalText: incoming.text || (attachmentRefs.length ? `[${attachmentRefs.length} 个附件]` : '(空邮件)'),
originalLang: existingMessage?.originalLang || 'auto',
status: outgoing ? 'sent' : incoming.seen ? 'read' : 'delivered',
createdAt: incoming.date,
emailUid: incoming.uid,
emailMessageId: incoming.messageId,
emailInReplyTo: incoming.inReplyTo,
emailReferences: incoming.references,
emailSubject: incoming.subject,
emailAttachments: attachmentRefs,
})
const unreadIncrement = !existingMessage && !outgoing && !incoming.seen ? 1 : 0
const updatedConversation = await putResource(tenantId, 'conversations', {
...(existingConversation || {}),
id: conversationId,
customerId: customer.id,
channel: 'email',
channelId: channel.id,
emailThreadKey: threadKey,
emailSubject: normalizedSubject,
unreadCount: Math.max(0, Number(existingConversation?.unreadCount || 0) + unreadIncrement),
status: !outgoing && !incoming.seen ? 'pending' : existingConversation?.status || 'replied',
assigneeId: existingConversation?.assigneeId || null,
intentLevel: existingConversation?.intentLevel || 'low',
sentiment: existingConversation?.sentiment || 'neutral',
hasRisk: existingConversation?.hasRisk || false,
summary: existingConversation?.summary || normalizedSubject,
lastMessage: message,
createdAt: existingConversation?.createdAt || incoming.date,
updatedAt: incoming.date,
})
if (!existingMessage) {
messagesSynced++
if (!outgoing) {
enqueueAutomaticTranslation(tenantId, conversationId, message)
enqueueAutomaticProfile(tenantId, conversationId)
await putResource(tenantId, 'notifications', {
id: uid(),
type: 'message',
title: `新邮件 · ${customer.name}`,
description: incoming.subject || '收到一封新邮件',
link: `/inbox?c=${encodeURIComponent(conversationId)}`,
read: false,
createdAt: new Date().toISOString(),
})
}
}
conversationsUpdated++
broadcast(tenantId, {
type: existingMessage ? 'message.updated' : 'message.received',
conversationId,
message,
customer,
conversation: updatedConversation,
})
}
await patchResource(tenantId, 'channels', channel.id, {
lastUid: result.lastUid,
lastSyncAt: new Date().toISOString(),
status: 'connected',
errorMessage: undefined,
})
await appendChannelLog(
tenantId,
channel.id,
`Email 同步完成:${conversationsUpdated} 个线程,新增 ${messagesSynced} 封邮件`,
)
broadcast(tenantId, {
type: 'sync.completed',
channelId: channel.id,
chats: conversationsUpdated,
messages: messagesSynced,
})
return { chats: conversationsUpdated, messages: messagesSynced, lastUid: result.lastUid }
}
app.get('/api/channels', async (req, res) => {
const channels = await listResources(req.auth.tenantId, 'channels')
const settings = (await getResource(req.auth.tenantId, 'singleton', 'settings')) || defaultSettings
let timezone = String(settings.timezone || 'Asia/Hong_Kong')
try {
new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format()
} catch {
timezone = 'Asia/Hong_Kong'
}
const { rows: messageCounts } = await pool.query(
`SELECT conversation.data->>'channelId' AS channel_id, COUNT(message.id)::int AS count
FROM resources conversation
JOIN resources message
ON message.tenant_id=conversation.tenant_id
AND message.kind=('messages:' || conversation.id)
WHERE conversation.tenant_id=$1
AND conversation.kind='conversations'
AND COALESCE(message.data->>'createdAt', '') ~ '^\\d{4}-\\d{2}-\\d{2}T'
AND (message.data->>'createdAt')::timestamptz
>= (date_trunc('day', now() AT TIME ZONE $2) AT TIME ZONE $2)
AND (message.data->>'createdAt')::timestamptz
< ((date_trunc('day', now() AT TIME ZONE $2) + interval '1 day') AT TIME ZONE $2)
GROUP BY conversation.data->>'channelId'`,
[req.auth.tenantId, timezone],
)
const todayByChannel = new Map(messageCounts.map((row) => [String(row.channel_id), Number(row.count || 0)]))
const list = channels.map((channel) => publicChannel({
...channel,
todayMessages: todayByChannel.get(String(channel.id)) || 0,
}))
if (!channels.some((channel) => channel.type === 'telegram')) {
list.push({
id: 'telegram-available', type: 'telegram', name: 'Telegram', status: 'unconnected',
todayMessages: 0, autoReply: false,
})
}
if (!channels.some((channel) => channel.type === 'email')) {
list.push({
id: 'email-available', type: 'email', name: '企业邮箱', status: 'unconnected',
todayMessages: 0, autoReply: false,
})
}
if (!channels.some((channel) => channel.type === 'whatsapp')) {
list.push({
id: 'whatsapp-available', type: 'whatsapp', name: 'WhatsApp', status: 'unconnected',
todayMessages: 0, autoReply: false,
})
}
ok(res, list)
})
const telegramWebhookEvents = [
'message.received', 'message.pending', 'message.sent', 'message.failed',
'message.edited', 'message.deleted', 'update.new_chat',
]
async function ensureChannelWebhook(tenantId, channel) {
const webhookUrl = `${(process.env.PUBLIC_WEBHOOK_BASE_URL || 'http://host.docker.internal:8088').replace(/\/$/, '')}/internal/webhooks/telegram/${tenantId}/${channel.id}`
if (channel.webhookId) {
await putGateway(`/tg-accounts/${channel.gatewayAccountId}/webhooks/${channel.webhookId}`, {
url: webhookUrl,
events: telegramWebhookEvents,
isActive: true,
secret: process.env.TELEGRAM_WEBHOOK_SECRET,
})
return patchResource(tenantId, 'channels', channel.id, {
errorMessage: undefined,
webhookSyncedAt: new Date().toISOString(),
})
}
const webhook = await postGateway(`/tg-accounts/${channel.gatewayAccountId}/webhooks`, {
url: webhookUrl,
events: telegramWebhookEvents,
secret: process.env.TELEGRAM_WEBHOOK_SECRET,
maxRetries: 8,
})
return patchResource(tenantId, 'channels', channel.id, { webhookId: webhook?.id, errorMessage: undefined })
}
async function ensureWhatsAppWebhook(tenantId, channel, active = true) {
const webhookUrl = `${(process.env.WHATSAPP_WEBHOOK_BASE_URL || 'http://api:3100').replace(/\/$/, '')}/internal/webhooks/whatsapp/${tenantId}/${channel.id}`
await putWhatsApp(`/accounts/${channel.gatewayAccountId}/webhook`, {
url: webhookUrl,
secret: process.env.WHATSAPP_WEBHOOK_SECRET,
active,
})
return patchResource(tenantId, 'channels', channel.id, {
webhookSyncedAt: new Date().toISOString(),
errorMessage: undefined,
})
}
app.post('/api/channels/connect', async (req, res) => {
if (req.body.type !== 'telegram') throw fail(400, '当前版本仅支持 Telegram')
const accountId = String(req.body.gatewayAccountId || '').trim()
if (!accountId) throw fail(400, '请使用手机号验证码流程绑定 Telegram,不允许自动占用服务器公共账号')
const duplicateBinding = await pool.query(
`SELECT 1 FROM resources
WHERE kind='channels'
AND tenant_id<>$1
AND data->>'type'='telegram'
AND data->>'gatewayAccountId'=$2
LIMIT 1`,
[req.auth.tenantId, String(accountId)],
)
if (duplicateBinding.rowCount) throw fail(409, '该 Telegram 网关账号已绑定其他企业')
const state = await postGateway(`/tg-accounts/${accountId}/auth/state`, {})
const isDefault = await shouldBeDefaultChannel(req.auth.tenantId, 'telegram')
let channel = await putResource(req.auth.tenantId, 'channels', {
type: 'telegram', name: 'Telegram',
status: (state?.status === 'ready' || state?.state === 'ready') && state?.connectionState === 'ready'
? 'connected'
: 'disconnected',
account: req.body.account || '', gatewayAccountId: accountId, lastSyncAt: new Date().toISOString(),
todayMessages: 0, autoReply: false, isDefault,
})
const webhookUrl = `${(process.env.PUBLIC_WEBHOOK_BASE_URL || 'http://host.docker.internal:8088').replace(/\/$/, '')}/internal/webhooks/telegram/${req.auth.tenantId}/${channel.id}`
try {
const webhook = await postGateway(`/tg-accounts/${accountId}/webhooks`, {
url: webhookUrl,
events: telegramWebhookEvents,
secret: process.env.TELEGRAM_WEBHOOK_SECRET,
maxRetries: 8,
})
channel = await patchResource(req.auth.tenantId, 'channels', channel.id, { webhookId: webhook?.id })
} catch (error) {
channel = await patchResource(req.auth.tenantId, 'channels', channel.id, { errorMessage: `Webhook 注册失败:${error.message}` })
}
ok(res, channel)
})
app.post('/api/channels/email/connect', async (req, res) => {
const account = String(req.body.account || '').trim().toLowerCase()
const password = String(req.body.password || '')
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(account)) throw fail(400, '请输入有效邮箱地址')
if (!password) throw fail(400, '请输入邮箱密码或应用专用密码')
const draft = {
type: 'email',
name: String(req.body.name || '企业邮箱').slice(0, 80),
displayName: String(req.body.displayName || '').slice(0, 120),
account,
imapHost: String(req.body.imapHost || '').trim(),
imapPort: Number(req.body.imapPort || 993),
imapSecure: req.body.imapSecure !== false,
imapFolder: String(req.body.imapFolder || 'INBOX').trim(),
smtpHost: String(req.body.smtpHost || '').trim(),
smtpPort: Number(req.body.smtpPort || 465),
smtpSecure: req.body.smtpSecure !== false,
syncDays: Math.max(1, Math.min(365, Number(req.body.syncDays || 30))),
allowInvalidCertificate: req.body.allowInvalidCertificate === true,
}
if (!draft.imapHost || !draft.smtpHost) throw fail(400, '请填写 IMAP 和 SMTP 服务器地址')
const verification = await verifyEmailConnection(draft, password)
const isDefault = await shouldBeDefaultChannel(req.auth.tenantId, 'email')
let channel = await putResource(req.auth.tenantId, 'channels', {
...draft,
encryptedPassword: encryptEmailPassword(password),
status: 'connected',
todayMessages: 0,
autoReply: false,
lastUid: 0,
createdAt: new Date().toISOString(),
lastVerifiedAt: new Date().toISOString(),
isDefault,
})
await appendChannelLog(
req.auth.tenantId,
channel.id,
`IMAP 与 SMTP 验证成功(${verification.latencyMs}ms)`,
'success',
'connect',
)
try {
await syncEmailTenant(req.auth.tenantId, channel, { full: true })
channel = await getResource(req.auth.tenantId, 'channels', channel.id)
} catch (error) {
channel = await patchResource(req.auth.tenantId, 'channels', channel.id, {
errorMessage: `首次同步失败:${error.message}`,
})
}
await audit(req, 'email.channel.connect', channel.id, {
account,
imapHost: draft.imapHost,
smtpHost: draft.smtpHost,
})
ok(res, publicChannel(channel))
})
app.post('/api/channels/telegram/start', async (req, res) => {
const phone = String(req.body.phone || '').trim()
if (!/^\+\d{7,15}$/.test(phone)) throw fail(400, '请输入国际格式手机号')
const existingChannels = await listResources(req.auth.tenantId, 'channels')
const existing = existingChannels.find(
(item) => item.type === 'telegram' && item.gatewayAccountId && String(item.account || '') === phone,
)
if (existing) {
let state = await postGateway(`/tg-accounts/${existing.gatewayAccountId}/auth/state`, {})
let status = String(state?.status || state?.state || existing.authState || '')
if (status === 'waiting_phone') {
state = await postGateway(`/tg-accounts/${existing.gatewayAccountId}/auth/phone`, { phoneNumber: phone })
status = String(state?.status || state?.state || 'waiting_code')
}
const connectionReady = status === 'ready' && state?.connectionState === 'ready'
let channel = await patchResource(req.auth.tenantId, 'channels', existing.id, {
authState: status,
status: connectionReady ? 'connected' : 'disconnected',
})
if (connectionReady) channel = await ensureChannelWebhook(req.auth.tenantId, channel)
ok(res, channel)
return
}
const account = await postGateway('/tg-accounts', { phoneNumber: phone, firstName: 'Lingxi' })
await postGateway(`/tg-accounts/${account.id}/start`, {})
const isDefault = await shouldBeDefaultChannel(req.auth.tenantId, 'telegram')
const channel = await putResource(req.auth.tenantId, 'channels', {
type: 'telegram', name: 'Telegram', status: 'disconnected', account: phone,
gatewayAccountId: account.id, authState: 'waiting_phone', todayMessages: 0, autoReply: false,
isDefault,
})
for (let attempt = 0; attempt < 20; attempt++) {
const authState = await postGateway(`/tg-accounts/${account.id}/auth/state`, {})
if (authState?.status === 'waiting_phone') break
await new Promise((resolve) => setTimeout(resolve, 250))
}
const authState = await postGateway(`/tg-accounts/${account.id}/auth/phone`, { phoneNumber: phone })
const updated = await patchResource(req.auth.tenantId, 'channels', channel.id, {
authState: authState?.status || 'waiting_code',
})
await audit(req, 'telegram.channel.auth.start', updated.id, { phoneMasked: phone.replace(/\d(?=\d{4})/g, '*') })
ok(res, updated)
})
app.post('/api/channels/telegram/:id/phone', async (req, res) => {
const ch = await getResource(req.auth.tenantId, 'channels', req.params.id)
if (!ch) throw fail(404, '渠道不存在')
ok(res, await postGateway(`/tg-accounts/${ch.gatewayAccountId}/auth/phone`, { phoneNumber: req.body.phone }))
})
app.post('/api/channels/telegram/:id/code', async (req, res) => {
const ch = await getResource(req.auth.tenantId, 'channels', req.params.id)
if (!ch) throw fail(404, '渠道不存在')
const code = String(req.body.code || '').replace(/\s+/g, '')
if (!/^\d{4,8}$/.test(code)) throw fail(400, '请输入 4—8 位 Telegram 验证码')
const authState = await postGateway(`/tg-accounts/${ch.gatewayAccountId}/auth/code`, { code })
const channel = await patchResource(req.auth.tenantId, 'channels', ch.id, {
authState: authState.status,
status: authState.status === 'ready' && authState.connectionState === 'ready' ? 'connected' : 'disconnected',
})
const readyChannel = authState.status === 'ready' ? await ensureChannelWebhook(req.auth.tenantId, channel) : channel
await audit(req, 'telegram.channel.auth.code', ch.id, { status: authState.status })
ok(res, { ...authState, channel: readyChannel })
})
app.post('/api/channels/telegram/:id/password', async (req, res) => {
const ch = await getResource(req.auth.tenantId, 'channels', req.params.id)
if (!ch) throw fail(404, '渠道不存在')
const password = String(req.body.password || '')
if (!password) throw fail(400, '请输入 Telegram 二步验证密码')
const authState = await postGateway(`/tg-accounts/${ch.gatewayAccountId}/auth/password`, { password })
const channel = await patchResource(req.auth.tenantId, 'channels', ch.id, {
authState: authState.status,
status: authState.status === 'ready' && authState.connectionState === 'ready' ? 'connected' : 'disconnected',
})
const readyChannel = authState.status === 'ready'
? await ensureChannelWebhook(req.auth.tenantId, channel)
: channel
await audit(req, 'telegram.channel.auth.password', ch.id, { status: authState.status })
ok(res, { ...authState, channel: readyChannel })
})
app.post('/api/channels/telegram/:id/state', async (req, res) => {
const ch = await getResource(req.auth.tenantId, 'channels', req.params.id)
if (!ch || ch.type !== 'telegram' || !ch.gatewayAccountId) throw fail(404, 'Telegram 渠道不存在')
let authState = await postGateway(`/tg-accounts/${ch.gatewayAccountId}/auth/state`, {})
let status = String(authState?.status || authState?.state || ch.authState || 'waiting_code')
if (status === 'waiting_phone' && ch.account) {
authState = await postGateway(`/tg-accounts/${ch.gatewayAccountId}/auth/phone`, { phoneNumber: ch.account })
status = String(authState?.status || authState?.state || 'waiting_code')
}
const connectionReady = status === 'ready' && authState?.connectionState === 'ready'
let channel = await patchResource(req.auth.tenantId, 'channels', ch.id, {
authState: status,
status: connectionReady ? 'connected' : 'disconnected',
})
if (connectionReady) channel = await ensureChannelWebhook(req.auth.tenantId, channel)
ok(res, { ...authState, status, channel })
})
app.post('/api/channels/whatsapp/start', async (req, res) => {
const phoneNumber = String(req.body.phone || '').replace(/\D/g, '')
if (!/^\d{7,15}$/.test(phoneNumber)) throw fail(400, '请输入包含国家代码的 WhatsApp 手机号')
if (req.body.acceptedUnofficialRisk !== true) {
throw fail(400, '请先确认普通 WhatsApp 关联设备接入的非官方风险')
}
const existingChannels = await listResources(req.auth.tenantId, 'channels')
const existing = existingChannels.find(
(item) => item.type === 'whatsapp' && String(item.account || '').replace(/\D/g, '') === phoneNumber,
)
if (existing?.gatewayAccountId) {
let state = await whatsappRequest(`/accounts/${existing.gatewayAccountId}/auth/state`)
let connected = state.status === 'connected' && ['open', 'ready'].includes(state.connectionState)
if (!connected) {
const pairing = await postWhatsApp(`/accounts/${existing.gatewayAccountId}/pairing-code`, { phoneNumber })
state = {
...pairing.account,
pairingCode: pairing.pairingCode,
qrDataUrl: pairing.account?.qrDataUrl,
}
connected = false
}
let channel = await patchResource(req.auth.tenantId, 'channels', existing.id, {
status: connected ? 'connected' : 'disconnected',
authState: connected ? state.status : 'waiting_pairing',
pairingCode: state.pairingCode,
qrDataUrl: state.qrDataUrl,
errorMessage: undefined,
})
if (connected) channel = await ensureWhatsAppWebhook(req.auth.tenantId, channel)
ok(res, { ...publicChannel(channel), pairingCode: state.pairingCode, qrDataUrl: state.qrDataUrl })
return
}
const duplicateBinding = await pool.query(
`SELECT 1 FROM resources
WHERE kind='channels'
AND tenant_id<>$1
AND data->>'type'='whatsapp'
AND regexp_replace(COALESCE(data->>'account',''), '\\D', '', 'g')=$2
LIMIT 1`,
[req.auth.tenantId, phoneNumber],
)
if (duplicateBinding.rowCount) throw fail(409, '该 WhatsApp 账号已绑定其他企业')
let account
try {
account = await postWhatsApp('/accounts', {
phoneNumber,
name: String(req.body.name || `WhatsApp ${phoneNumber.slice(-4)}`).slice(0, 80),
ownerRef: req.auth.tenantId,
})
} catch (error) {
// Recover an account created by this tenant when the API process stopped
// between gateway account creation and channel persistence.
if (error.status !== 409 || !error.data?.id || error.data.ownerRef !== req.auth.tenantId) throw error
account = error.data
}
const pairing = await postWhatsApp(`/accounts/${account.id}/pairing-code`, { phoneNumber })
const isDefault = await shouldBeDefaultChannel(req.auth.tenantId, 'whatsapp')
let channel = await putResource(req.auth.tenantId, 'channels', {
type: 'whatsapp',
provider: 'baileys-linked-device',
name: String(req.body.name || `WhatsApp ${phoneNumber.slice(-4)}`).slice(0, 80),
account: `+${phoneNumber}`,
gatewayAccountId: account.id,
status: 'disconnected',
authState: 'waiting_pairing',
pairingCode: pairing.pairingCode,
qrDataUrl: pairing.account?.qrDataUrl,
todayMessages: 0,
autoReply: false,
isDefault,
createdAt: new Date().toISOString(),
})
channel = await ensureWhatsAppWebhook(req.auth.tenantId, channel)
await audit(req, 'whatsapp.channel.auth.start', channel.id, {
phoneMasked: `+${phoneNumber.replace(/\d(?=\d{4})/g, '*')}`,
provider: 'baileys-linked-device',
})
ok(res, publicChannel(channel))
})
app.post('/api/channels/whatsapp/:id/state', async (req, res) => {
const channel = await getResource(req.auth.tenantId, 'channels', req.params.id)
if (!channel || channel.type !== 'whatsapp' || !channel.gatewayAccountId) {
throw fail(404, 'WhatsApp 渠道不存在')
}
const state = await whatsappRequest(`/accounts/${channel.gatewayAccountId}/auth/state`)
const connected = state.status === 'connected' && ['open', 'ready'].includes(state.connectionState)
let updated = await patchResource(req.auth.tenantId, 'channels', channel.id, {
status: connected ? 'connected' : 'disconnected',
authState: state.status,
pairingCode: state.pairingCode,
qrDataUrl: state.qrDataUrl,
lastVerifiedAt: connected ? new Date().toISOString() : channel.lastVerifiedAt,
})
if (connected) updated = await ensureWhatsAppWebhook(req.auth.tenantId, updated)
ok(res, { ...state, channel: publicChannel(updated) })
})
app.post('/api/channels/:id/default', async (req, res) => {
const target = await getResource(req.auth.tenantId, 'channels', req.params.id)
if (!target) throw fail(404, '渠道不存在')
if (target.status !== 'connected') throw fail(409, '只能将已连接账号设为默认账号')
const channels = await listResources(req.auth.tenantId, 'channels')
await Promise.all(channels
.filter((item) => item.type === target.type && item.id !== target.id && item.isDefault)
.map((item) => patchResource(req.auth.tenantId, 'channels', item.id, { isDefault: false })))
const updated = await patchResource(req.auth.tenantId, 'channels', target.id, { isDefault: true })
await audit(req, 'channel.default.set', target.id, { type: target.type, account: target.account })
broadcast(req.auth.tenantId, { type: 'channel.default.changed', channel: publicChannel(updated) })
ok(res, publicChannel(updated))
})
function telegramPhotoId(photo) {
const value = photo?.small?.id ?? photo?.big?.id
const number = Number(value)
return Number.isFinite(number) && number > 0 ? number : undefined
}
async function resolveTelegramIdentity(channel, chatId, fallback = {}, existing = {}) {
const identity = await gatewayRequest(
`/tg-accounts/${channel.gatewayAccountId}/chats/${chatId}/identity/profile`,
).catch(() => null)
const photo = identity?.photo || fallback.photo
const photoFileId = telegramPhotoId(photo)
let avatarUrl = existing.avatarUrl
if (photoFileId && photoFileId !== Number(existing.telegramPhotoFileId || 0)) {
const image = await gatewayRequest(
`/tg-accounts/${channel.gatewayAccountId}/files/tdlib/${photoFileId}/base64`,
).catch(() => null)
if (image?.base64) avatarUrl = `data:${image.mimeType || 'image/jpeg'};base64,${image.base64}`
}
return {
name: String(identity?.title || fallback.title || existing.name || `Telegram ${chatId}`).trim(),
telegramUsername: String(identity?.username || existing.telegramUsername || '').replace(/^@/, ''),
telegramUserId: identity?.userId ? String(identity.userId) : existing.telegramUserId,
phone: String(identity?.phone || existing.phone || ''),
avatarUrl,
telegramPhotoFileId: photoFileId || existing.telegramPhotoFileId,
telegramChatType: String(identity?.chatType || fallback.type || existing.telegramChatType || 'private'),
}
}
function whatsappMessageType(type) {
if (type === 'image') return 'image'
if (type === 'audio') return 'audio'
if (['video', 'file', 'sticker', 'contact', 'location', 'poll'].includes(type)) return 'file'
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
const conversationId = `${channel.id}:${jid}`
if (await getResource(tenantId, 'conversation-tombstones', conversationId)) return null
if (rawMessage.type === 'reaction' && rawMessage.quotedMessageId) {
const targetId = String(rawMessage.quotedMessageId)
const target = await getResource(tenantId, `messages:${conversationId}`, targetId)
if (!target) return null
const actor = String(rawMessage.senderId || (rawMessage.fromMe ? 'agent' : jid))
const reactions = (Array.isArray(target.reactions) ? target.reactions : [])
.filter((item) => String(item.actor) !== actor)
if (rawMessage.text) reactions.push({
actor,
emoji: String(rawMessage.text),
fromMe: Boolean(rawMessage.fromMe),
updatedAt: rawMessage.createdAt || new Date().toISOString(),
})
const message = await patchResource(tenantId, `messages:${conversationId}`, targetId, { reactions })
broadcast(tenantId, {
type: 'message.updated',
conversationId,
message,
})
return { message, reaction: rawMessage, isNew: false }
}
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`,
{ signal: AbortSignal.timeout(8_000) },
).catch(() => null)
const customer = await putResource(tenantId, 'customers', withDefaults('customers', {
...(existingCustomer || {}),
id: customerId,
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,
whatsappChatType: identity?.chatType || existingCustomer?.whatsappChatType || 'private',
channel: 'whatsapp',
source: 'whatsapp',
}))
const existingMessage = await getResource(tenantId, `messages:${conversationId}`, String(rawMessage.id))
const outgoingMeta = rawMessage.fromMe
? await getResource(tenantId, 'outgoing-message-meta', String(rawMessage.id))
: null
const message = await putResource(tenantId, `messages:${conversationId}`, {
...(existingMessage || {}),
id: String(rawMessage.id),
conversationId,
sender: rawMessage.fromMe ? 'agent' : 'customer',
senderName: rawMessage.pushName || undefined,
type: whatsappMessageType(rawMessage.type),
originalText: outgoingMeta?.originalText || existingMessage?.originalText || String(rawMessage.text || rawMessage.caption || ''),
translatedText: outgoingMeta?.translatedText || existingMessage?.translatedText,
translationCn: outgoingMeta?.translationCn || existingMessage?.translationCn,
translationStatus: outgoingMeta?.translationCn || existingMessage?.translationCn ? 'completed' : 'pending',
originalLang: outgoingMeta?.originalLang || existingMessage?.originalLang || 'auto',
targetLang: outgoingMeta?.targetLang || existingMessage?.targetLang,
status: rawMessage.fromMe ? rawMessage.deliveryStatus || 'sent' : 'delivered',
createdAt: rawMessage.createdAt || new Date(Number(rawMessage.timestamp || Date.now() / 1000) * 1000).toISOString(),
editedAt: rawMessage.edited ? new Date().toISOString() : existingMessage?.editedAt,
isDeleted: Boolean(rawMessage.deleted || existingMessage?.isDeleted),
deletedAt: rawMessage.deletedAt || existingMessage?.deletedAt,
replyToId: rawMessage.quotedMessageId || undefined,
attachmentName: rawMessage.fileName || undefined,
attachmentMimeType: rawMessage.mimetype || undefined,
attachmentSize: Number(rawMessage.fileLength || 0) || undefined,
whatsappMessageId: String(rawMessage.id),
whatsappContentType: rawMessage.contentType,
})
const existingConversation = await getResource(tenantId, 'conversations', conversationId)
const isNewIncoming = !existingMessage && !rawMessage.fromMe
const conversation = await putResource(tenantId, 'conversations', {
...(existingConversation || {}),
id: conversationId,
customerId: customer.id,
channel: 'whatsapp',
channelId: channel.id,
whatsappJid: jid,
whatsappChatType: identity?.chatType || existingConversation?.whatsappChatType || 'private',
unreadCount: Math.max(0, Number(existingConversation?.unreadCount || 0) + (isNewIncoming ? 1 : 0)),
status: rawMessage.fromMe ? 'replied' : 'pending',
assigneeId: existingConversation?.assigneeId || null,
intentLevel: existingConversation?.intentLevel || 'low',
sentiment: existingConversation?.sentiment || 'neutral',
hasRisk: existingConversation?.hasRisk || false,
summary: existingConversation?.summary || '',
lastMessage: !existingConversation?.lastMessage
|| Date.parse(message.createdAt) >= Date.parse(existingConversation.lastMessage.createdAt || '')
? message
: existingConversation.lastMessage,
createdAt: existingConversation?.createdAt || message.createdAt,
updatedAt: message.createdAt,
})
if (isNewIncoming) {
enqueueAutomaticTranslation(tenantId, conversationId, message)
enqueueAutomaticProfile(tenantId, conversationId)
if (options.notify !== false) {
await putResource(tenantId, 'notifications', {
id: uid(),
type: 'message',
title: `WhatsApp 新消息 · ${customer.name}`,
description: message.originalText || message.attachmentName || '收到一条媒体消息',
link: `/inbox?c=${encodeURIComponent(conversationId)}`,
read: false,
createdAt: new Date().toISOString(),
})
}
}
broadcast(tenantId, {
type: existingMessage ? 'message.updated' : 'message.received',
conversationId,
message,
customer,
conversation,
})
if (outgoingMeta) await deleteResource(tenantId, 'outgoing-message-meta', outgoingMeta.id)
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)) {
throw fail(409, 'WhatsApp 账号尚未完成关联设备配对')
}
const chats = await whatsappRequest(`/accounts/${channel.gatewayAccountId}/chats`)
let messagesSynced = 0
if (options.full === true) {
await patchResource(tenantId, 'channels', channel.id, {
historySyncStatus: 'running',
historySyncStartedAt: new Date().toISOString(),
historySyncChatsCompleted: 0,
historySyncChatsTotal: chats?.length || 0,
historySyncMessagesSynced: 0,
historySyncError: undefined,
})
}
for (const [chatIndex, chat] of (chats || []).entries()) {
const jid = String(chat.id || '')
if (!jid) continue
const conversationId = `${channel.id}:${jid}`
const currentBeforeSync = await getResource(tenantId, 'conversations', conversationId)
const profileDue = options.refreshProfiles === true || !currentBeforeSync?.customerId
if (options.full === true) {
await whatsappRequest(
`/accounts/${channel.gatewayAccountId}/chats/${encodeURIComponent(jid)}/history/sync`,
{
method: 'POST',
body: JSON.stringify({ pages: 50, count: 50 }),
},
).catch(() => null)
}
const currentCustomer = currentBeforeSync?.customerId
? await getResource(tenantId, 'customers', currentBeforeSync.customerId)
: null
const identity = profileDue || !currentCustomer
? await whatsappRequest(
`/accounts/${channel.gatewayAccountId}/chats/${encodeURIComponent(jid)}/identity`,
{ signal: AbortSignal.timeout(8_000) },
).catch(() => ({ name: chat.name || jid.split('@')[0] }))
: {
name: currentCustomer.name,
phone: currentCustomer.phone,
avatarUrl: currentCustomer.avatarUrl,
chatType: currentBeforeSync.whatsappChatType,
}
if (profileDue && currentCustomer) {
const customer = await patchResource(tenantId, 'customers', currentCustomer.id, {
name: !isWeakWhatsAppDisplayName(currentCustomer.name, jid)
? currentCustomer.name
: identity?.name || currentCustomer.name,
phone: identity?.phone || currentCustomer.phone,
avatarUrl: identity?.avatarUrl || currentCustomer.avatarUrl,
whatsappChatType: identity?.chatType || currentCustomer.whatsappChatType,
profileSyncedAt: new Date().toISOString(),
})
broadcast(tenantId, { type: 'customer.updated', customerId: currentCustomer.id, customer })
}
let historyCursor = options.full === true
? ''
: String(currentBeforeSync?.whatsappReconcileCursor || currentBeforeSync?.lastMessage?.whatsappMessageId || '')
let historyPages = 0
let latestProcessedMessageId = historyCursor
do {
const cursorQuery = historyCursor
? options.full === true
? `&before=${encodeURIComponent(historyCursor)}`
: `&after=${encodeURIComponent(historyCursor)}`
: ''
const history = await whatsappRequest(
`/accounts/${channel.gatewayAccountId}/chats/${encodeURIComponent(jid)}/history?limit=${options.full === true ? 2000 : 500}${cursorQuery}`,
).catch(() => ({ messages: [], beforeCursor: null, hasMoreBefore: false }))
for (const raw of history.messages || []) {
const result = await upsertWhatsAppMessage(tenantId, channel, raw, { identity, notify: false })
if (result?.isNew) messagesSynced++
latestProcessedMessageId = String(raw.id || latestProcessedMessageId)
}
historyPages++
historyCursor = options.full === true
? history.hasMoreBefore && history.beforeCursor ? String(history.beforeCursor) : ''
: history.hasMoreAfter && history.afterCursor ? String(history.afterCursor) : ''
} while (historyCursor && historyPages < (options.full === true ? 100 : 20))
messagesSynced += await mergeLegacyWhatsAppConversations(tenantId, channel, jid)
const current = await getResource(tenantId, 'conversations', conversationId)
if (current) {
await patchResource(tenantId, 'conversations', conversationId, {
unreadCount: Number(chat.unreadCount || current.unreadCount || 0),
whatsappReconcileCursor: latestProcessedMessageId || current.whatsappReconcileCursor,
profileSyncedAt: profileDue ? new Date().toISOString() : current.profileSyncedAt,
lastReconciledAt: new Date().toISOString(),
historySyncedAt: new Date().toISOString(),
})
}
if (options.full === true) {
await patchResource(tenantId, 'channels', channel.id, {
historySyncChatsCompleted: chatIndex + 1,
historySyncChatsTotal: chats.length,
historySyncMessagesSynced: messagesSynced,
historySyncUpdatedAt: new Date().toISOString(),
})
}
}
await patchResource(tenantId, 'channels', channel.id, {
lastSyncAt: new Date().toISOString(),
status: 'connected',
authState: 'connected',
errorMessage: undefined,
...(options.full === true ? {
historySyncStatus: 'completed',
historySyncCompletedAt: new Date().toISOString(),
historySyncChatsCompleted: chats?.length || 0,
historySyncChatsTotal: chats?.length || 0,
historySyncMessagesSynced: messagesSynced,
} : {}),
})
await appendChannelLog(
tenantId,
channel.id,
`WhatsApp 同步完成:${chats?.length || 0} 个会话,新增 ${messagesSynced} 条消息`,
)
broadcast(tenantId, {
type: 'sync.completed',
channelId: channel.id,
chats: chats?.length || 0,
messages: messagesSynced,
})
return { chats: chats?.length || 0, messages: messagesSynced }
}
async function syncWhatsAppTenant(tenantId, channel, options = {}) {
const key = `${tenantId}:${channel.id}`
const existing = whatsappSyncStates.get(key)
if (existing) {
if (options.full === true && !existing.runningFull) existing.fullRequested = true
return existing.promise
}
const syncState = {
fullRequested: options.full === true,
runningFull: false,
promise: null,
}
syncState.promise = (async () => {
let result = { chats: 0, messages: 0 }
try {
do {
const full = syncState.fullRequested
syncState.fullRequested = false
syncState.runningFull = full
result = await performWhatsAppTenantSync(tenantId, channel, { full })
syncState.runningFull = false
} while (syncState.fullRequested)
return result
} catch (error) {
if (syncState.runningFull) {
await patchResource(tenantId, 'channels', channel.id, {
historySyncStatus: 'failed',
historySyncError: String(error?.message || error),
historySyncUpdatedAt: new Date().toISOString(),
}).catch(() => null)
}
throw error
} finally {
whatsappSyncStates.delete(key)
}
})()
whatsappSyncStates.set(key, syncState)
return syncState.promise
}
function scheduleWhatsAppSync(tenantId, channel, options = {}, attempt = 0) {
const key = `${tenantId}:${channel.id}`
const scheduled = whatsappSyncSchedules.get(key)
if (scheduled) {
if (options.full === true) scheduled.full = true
return
}
const state = { full: options.full === true, timer: null }
const delay = attempt === 0 ? 2_000 : Math.min(60_000, 2_000 * (2 ** attempt))
state.timer = setTimeout(async () => {
whatsappSyncSchedules.delete(key)
try {
await syncWhatsAppTenant(tenantId, channel, { full: state.full })
} catch (error) {
if (attempt < 6) {
scheduleWhatsAppSync(tenantId, channel, { full: state.full }, attempt + 1)
} else {
await patchResource(tenantId, 'channels', channel.id, {
historySyncStatus: 'failed',
historySyncError: String(error?.message || error),
historySyncUpdatedAt: new Date().toISOString(),
}).catch(() => null)
}
}
}, delay)
state.timer.unref?.()
whatsappSyncSchedules.set(key, state)
}
async function syncTelegramTenant(tenantId, channel, options = {}) {
const full = options.full === true
const refreshProfiles = options.refreshProfiles === true
const gateway = full
? await gatewayRequest(`/tg-accounts/${channel.gatewayAccountId}/chats/sync`)
: { incremental: true }
const chats = []
for (let offset = 0; offset < 100_000; offset += 200) {
const page = await gatewayRequest(
`/tg-accounts/${channel.gatewayAccountId}/chats?limit=200&offset=${offset}`,
)
chats.push(...(page || []))
if (!page || page.length < 200) break
}
let messagesSynced = 0
for (const chat of chats || []) {
const chatId = String(chat.tgChatId)
const conversationId = `${channel.id}:${chatId}`
if (await getResource(tenantId, 'conversation-tombstones', conversationId)) continue
const existingConversation = await getResource(tenantId, 'conversations', conversationId)
const gatewayLastMessageId = String(chat.lastMessageId || '')
const profileDue = refreshProfiles || !existingConversation?.customerId
if (
!full
&& existingConversation
&& gatewayLastMessageId
&& gatewayLastMessageId === String(existingConversation.telegramLastMessageId || '')
&& !profileDue
) {
await patchResource(tenantId, 'conversations', conversationId, {
unreadCount: Number(chat.unreadCount || 0),
lastReconciledAt: new Date().toISOString(),
})
continue
}
const customerId = `tg-${channel.gatewayAccountId}-${chatId}`
const existingCustomer = await getResource(tenantId, 'customers', customerId)
const identity = profileDue || !existingCustomer
? await resolveTelegramIdentity(channel, chatId, chat, existingCustomer || {})
: existingCustomer
const customer = await putResource(tenantId, 'customers', withDefaults('customers', {
...(existingCustomer || {}),
...identity,
id: customerId,
telegram: chatId,
telegramChatType: identity.telegramChatType || chat.type,
source: 'telegram',
}))
const recent = await gatewayRequest(
`/tg-accounts/${channel.gatewayAccountId}/chats/${chatId}/history?limit=100`,
).catch(() => ({ messages: [] }))
const historyMessages = [...(recent?.messages || [])]
let historyComplete = Boolean(existingConversation?.historyComplete)
let cursor = historyMessages.at(-1)?.id
let pages = 1
const previousLastMessageId = String(existingConversation?.telegramLastMessageId || '')
const hasPreviousLastMessage = () => previousLastMessageId
&& historyMessages.some((message) => String(message.id) === previousLastMessageId)
if (full && existingConversation?.historyOldestMessageId) {
cursor = existingConversation.historyOldestMessageId
}
const maxPages = full ? 50 : 10
while (
cursor
&& pages < maxPages
&& (full ? !historyComplete : previousLastMessageId && !hasPreviousLastMessage())
) {
const page = await gatewayRequest(
`/tg-accounts/${channel.gatewayAccountId}/chats/${chatId}/history?limit=100&fromMessageId=${encodeURIComponent(String(cursor))}`,
).catch(() => ({ messages: [] }))
const rows = page?.messages || []
const known = new Set(historyMessages.map((message) => String(message.id)))
const newRows = rows.filter((message) => !known.has(String(message.id)))
historyMessages.push(...newRows)
pages++
if (rows.length < 100 || !newRows.length) {
if (full) historyComplete = true
break
}
cursor = rows.at(-1)?.id
}
if (full && historyMessages.length < 100) historyComplete = true
const history = { messages: historyMessages }
let lastMessage
for (const raw of [...(history?.messages || [])].reverse()) {
const content = raw.content || {}
const serviceText = telegramServiceMessageText(content)
const typeMap = { messagePhoto: 'image', messageDocument: 'file', messageVoiceNote: 'audio', messageAudio: 'audio', messageSticker: 'file' }
const existingMessage = await getResource(tenantId, `messages:${conversationId}`, String(raw.id))
lastMessage = await putResource(tenantId, `messages:${conversationId}`, {
...(existingMessage || {}),
id: String(raw.id), conversationId,
sender: serviceText ? 'system' : raw.is_outgoing ? 'agent' : 'customer',
type: typeMap[content['@type']] || 'text',
originalText:
serviceText
|| existingMessage?.originalText
|| content?.text?.text
|| content?.caption?.text,
translatedText: serviceText ? undefined : existingMessage?.translatedText,
translationCn: serviceText ? undefined : existingMessage?.translationCn,
translationStatus: serviceText ? undefined : existingMessage?.translationStatus,
originalLang: existingMessage?.originalLang || 'auto', status: raw.is_outgoing ? 'sent' : 'delivered',
createdAt: new Date(Number(raw.date || Date.now() / 1000) * 1000).toISOString(),
telegramContent: content,
...telegramAttachment(content),
})
messagesSynced++
}
await putResource(tenantId, 'conversations', {
...(existingConversation || {}),
id: conversationId, customerId: customer.id, channel: 'telegram', channelId: channel.id,
telegramChatId: chatId, telegramChatType: identity.telegramChatType || chat.type,
unreadCount: Number(chat.unreadCount || 0),
status: Number(chat.unreadCount || 0) ? 'pending' : 'replied', assigneeId: null,
intentLevel: 'low', sentiment: 'neutral', hasRisk: false, summary: '',
lastMessage: lastMessage || (chat.lastMessage ? {
id: String(chat.lastMessageId || `${chatId}-last`), conversationId, sender: 'customer',
type: 'text', originalText: chat.lastMessage, originalLang: 'auto', status: 'delivered',
createdAt: chat.lastMessageDate || new Date().toISOString(),
} : undefined),
historyComplete,
historyOldestMessageId: historyMessages.at(-1)?.id
? String(historyMessages.at(-1).id)
: existingConversation?.historyOldestMessageId,
telegramLastMessageId: gatewayLastMessageId
|| String(lastMessage?.id || existingConversation?.telegramLastMessageId || ''),
profileSyncedAt: profileDue ? new Date().toISOString() : existingConversation?.profileSyncedAt,
lastReconciledAt: new Date().toISOString(),
historySyncedAt: new Date().toISOString(),
createdAt: existingConversation?.createdAt || new Date().toISOString(),
})
}
await patchResource(tenantId, 'channels', channel.id, { lastSyncAt: new Date().toISOString() })
broadcast(tenantId, { type: 'sync.completed', channelId: channel.id, chats: chats?.length || 0, messages: messagesSynced })
return { gateway, chats: chats?.length || 0, messages: messagesSynced, full }
}
app.post('/api/channels/:id/sync', async (req, res) => {
const channel = await getResource(req.auth.tenantId, 'channels', req.params.id)
if (!channel) throw fail(404, '渠道不存在')
if (channel.type === 'email') {
ok(res, await syncEmailTenant(req.auth.tenantId, channel, { full: req.body.full === true }))
return
}
if (channel.type === 'whatsapp') {
ok(res, await syncWhatsAppTenant(req.auth.tenantId, channel, { full: req.body.full === true }))
return
}
ok(res, await syncTelegramTenant(req.auth.tenantId, channel, { full: req.body.full === true }))
})
app.post('/api/channels/test', async (req, res) => {
const start = Date.now()
const ch = await getResource(req.auth.tenantId, 'channels', req.body.id)
if (!ch) throw fail(404, '渠道不存在')
if (ch.type === 'email') {
const verification = await verifyEmailConnection(ch, decryptEmailPassword(ch.encryptedPassword))
ok(res, { success: true, latencyMs: verification.latencyMs, message: 'Email IMAP 与 SMTP 连接正常' })
return
}
if (ch.type === 'whatsapp') {
const state = await whatsappRequest(`/accounts/${ch.gatewayAccountId}/auth/state`)
const connected = state.status === 'connected' && ['open', 'ready'].includes(state.connectionState)
if (!connected) throw fail(409, 'WhatsApp 账号尚未完成关联设备配对')
ok(res, { success: true, latencyMs: Date.now() - start, message: 'WhatsApp 网关连接正常' })
return
}
await gatewayRequest(`/tg-accounts/${ch.gatewayAccountId}`)
ok(res, { success: true, latencyMs: Date.now() - start, message: 'Telegram 网关连接正常' })
})
app.get('/api/channels/:id/logs', async (req, res) => ok(res, await listResources(req.auth.tenantId, `channel-logs:${req.params.id}`)))
app.get('/api/channels/:id/webhook-deliveries', async (req, res) => {
const channel = await getResource(req.auth.tenantId, 'channels', req.params.id)
if (!channel) throw fail(404, '渠道不存在')
if (channel.type !== 'whatsapp' || !channel.gatewayAccountId) {
throw fail(409, '该接口仅支持已绑定的 WhatsApp 渠道')
}
const query = new URLSearchParams()
if (req.query.status) query.set('status', String(req.query.status))
query.set('limit', String(Math.max(1, Math.min(200, Number(req.query.limit || 50)))))
ok(res, await whatsappRequest(
`/accounts/${channel.gatewayAccountId}/webhook-deliveries?${query.toString()}`,
))
})
app.post('/api/channels/:id/webhook-deliveries/:deliveryId/replay', async (req, res) => {
const channel = await getResource(req.auth.tenantId, 'channels', req.params.id)
if (!channel) throw fail(404, '渠道不存在')
if (channel.type !== 'whatsapp' || !channel.gatewayAccountId) {
throw fail(409, '该接口仅支持已绑定的 WhatsApp 渠道')
}
const value = await postWhatsApp(
`/accounts/${channel.gatewayAccountId}/webhook-deliveries/${encodeURIComponent(req.params.deliveryId)}/replay`,
{},
)
await audit(req, 'whatsapp.webhook.replay', req.params.deliveryId, { channelId: channel.id })
ok(res, value)
})
app.get('/api/channels/:id/outbound-jobs', async (req, res) => {
const channel = await getResource(req.auth.tenantId, 'channels', req.params.id)
if (!channel) throw fail(404, '渠道不存在')
if (channel.type !== 'whatsapp' || !channel.gatewayAccountId) {
throw fail(409, '该接口仅支持已绑定的 WhatsApp 渠道')
}
const query = new URLSearchParams()
if (req.query.status) query.set('status', String(req.query.status))
query.set('limit', String(Math.max(1, Math.min(200, Number(req.query.limit || 50)))))
ok(res, await whatsappRequest(
`/accounts/${channel.gatewayAccountId}/outbound-jobs?${query.toString()}`,
))
})
app.post('/api/channels/:id/outbound-jobs/:jobId/retry', async (req, res) => {
const channel = await getResource(req.auth.tenantId, 'channels', req.params.id)
if (!channel) throw fail(404, '渠道不存在')
if (channel.type !== 'whatsapp' || !channel.gatewayAccountId) {
throw fail(409, '该接口仅支持已绑定的 WhatsApp 渠道')
}
const value = await postWhatsApp(
`/accounts/${channel.gatewayAccountId}/outbound-jobs/${encodeURIComponent(req.params.jobId)}/retry`,
{},
)
await audit(req, 'whatsapp.outbound.retry', req.params.jobId, { channelId: channel.id })
ok(res, value)
})
app.get('/api/channels/:id/whatsapp/contacts', async (req, res) => {
const channel = await getResource(req.auth.tenantId, 'channels', req.params.id)
if (!channel || channel.type !== 'whatsapp' || !channel.gatewayAccountId) {
throw fail(404, 'WhatsApp 渠道不存在')
}
const query = new URLSearchParams({
query: String(req.query.query || ''),
cursor: String(req.query.cursor || '0'),
limit: String(req.query.limit || '50'),
})
ok(res, await whatsappRequest(
`/accounts/${channel.gatewayAccountId}/contacts?${query.toString()}`,
))
})
app.put('/api/channels/:id/whatsapp/proxy', async (req, res) => {
const channel = await getResource(req.auth.tenantId, 'channels', req.params.id)
if (!channel || channel.type !== 'whatsapp' || !channel.gatewayAccountId) {
throw fail(404, 'WhatsApp 渠道不存在')
}
const value = await whatsappRequest(`/accounts/${channel.gatewayAccountId}/proxy`, {
method: 'PUT',
body: JSON.stringify({
url: String(req.body.url || ''),
test: req.body.test !== false,
}),
})
await audit(req, 'whatsapp.proxy.update', channel.id, {
configured: Boolean(req.body.url),
})
ok(res, value)
})
app.post('/api/channels/:id/whatsapp/reauthorize', async (req, res) => {
const channel = await getResource(req.auth.tenantId, 'channels', req.params.id)
if (!channel || channel.type !== 'whatsapp' || !channel.gatewayAccountId) {
throw fail(404, 'WhatsApp 渠道不存在')
}
const value = await postWhatsApp(`/accounts/${channel.gatewayAccountId}/reauthorize`, {
confirm: channel.gatewayAccountId,
})
await patchResource(req.auth.tenantId, 'channels', channel.id, {
status: 'disconnected',
authState: 'waiting_pairing',
credentialRotatedAt: new Date().toISOString(),
})
await audit(req, 'whatsapp.credentials.reauthorize', channel.id, {})
ok(res, value)
})
app.get('/api/channels/:id/whatsapp/devices', async (req, res) => {
const channel = await getResource(req.auth.tenantId, 'channels', req.params.id)
if (!channel || channel.type !== 'whatsapp' || !channel.gatewayAccountId) {
throw fail(404, 'WhatsApp 渠道不存在')
}
ok(res, await whatsappRequest(`/accounts/${channel.gatewayAccountId}/devices`))
})
app.delete('/api/channels/:id/whatsapp/devices/:deviceId', async (req, res) => {
const channel = await getResource(req.auth.tenantId, 'channels', req.params.id)
if (!channel || channel.type !== 'whatsapp' || !channel.gatewayAccountId) {
throw fail(404, 'WhatsApp 渠道不存在')
}
const value = await whatsappRequest(
`/accounts/${channel.gatewayAccountId}/devices/${encodeURIComponent(req.params.deviceId)}`,
{
method: 'DELETE',
body: JSON.stringify({ confirm: channel.gatewayAccountId }),
},
)
await patchResource(req.auth.tenantId, 'channels', channel.id, {
status: 'disconnected',
authState: 'logged_out',
})
await audit(req, 'whatsapp.device.terminate', channel.id, {
deviceId: req.params.deviceId,
})
ok(res, value)
})
app.patch('/api/channels/:id/whatsapp/profile', async (req, res) => {
const channel = await getResource(req.auth.tenantId, 'channels', req.params.id)
if (!channel || channel.type !== 'whatsapp' || !channel.gatewayAccountId) {
throw fail(404, 'WhatsApp 渠道不存在')
}
const value = await whatsappRequest(`/accounts/${channel.gatewayAccountId}/profile`, {
method: 'PATCH',
body: JSON.stringify({
...(req.body.name !== undefined ? { name: req.body.name } : {}),
...(req.body.status !== undefined ? { status: req.body.status } : {}),
}),
})
await audit(req, 'whatsapp.profile.update', channel.id, {
nameChanged: req.body.name !== undefined,
statusChanged: req.body.status !== undefined,
})
ok(res, value)
})
app.post('/api/channels/:id/whatsapp/profile/picture', async (req, res) => {
const channel = await getResource(req.auth.tenantId, 'channels', req.params.id)
if (!channel || channel.type !== 'whatsapp' || !channel.gatewayAccountId) {
throw fail(404, 'WhatsApp 渠道不存在')
}
const bytes = Buffer.from(String(req.body.base64 || ''), 'base64')
if (!bytes.length || bytes.length > 8 * 1024 * 1024) throw fail(400, '请选择 8MB 以内的头像图片')
const form = new FormData()
form.append(
'file',
new Blob([bytes], { type: String(req.body.mimeType || 'image/jpeg') }),
String(req.body.name || 'whatsapp-profile.jpg'),
)
const value = await uploadWhatsApp(
`/accounts/${channel.gatewayAccountId}/profile/picture`,
form,
)
await audit(req, 'whatsapp.profile.picture.update', channel.id, { size: bytes.length })
ok(res, value)
})
app.get('/api/channels/whatsapp/metrics', async (req, res) => {
ok(res, await whatsappRequest('/metrics'))
})
app.get('/api/channels/:id/whatsapp/backup', async (req, res) => {
const channel = await getResource(req.auth.tenantId, 'channels', req.params.id)
if (!channel || channel.type !== 'whatsapp' || !channel.gatewayAccountId) {
throw fail(404, 'WhatsApp 渠道不存在')
}
const value = await whatsappRequest(`/accounts/${channel.gatewayAccountId}/backup`)
await audit(req, 'whatsapp.backup.create', channel.id, {
createdAt: value.createdAt,
})
ok(res, value)
})
app.post('/api/channels/:id/whatsapp/backup/restore', async (req, res) => {
const channel = await getResource(req.auth.tenantId, 'channels', req.params.id)
if (!channel || channel.type !== 'whatsapp' || !channel.gatewayAccountId) {
throw fail(404, 'WhatsApp 渠道不存在')
}
const value = await postWhatsApp(`/accounts/${channel.gatewayAccountId}/backup/restore`, {
blob: String(req.body.blob || ''),
confirm: channel.gatewayAccountId,
})
await audit(req, 'whatsapp.backup.restore', channel.id, {})
ok(res, value)
})
app.patch('/api/channels/:id', async (req, res) => {
const current = await getResource(req.auth.tenantId, 'channels', req.params.id)
if (!current) throw fail(404, '渠道不存在')
const patch = {}
if (typeof req.body.autoReply === 'boolean') patch.autoReply = req.body.autoReply
if (req.body.defaultOwnerId !== undefined) patch.defaultOwnerId = String(req.body.defaultOwnerId || '')
if (req.body.status !== undefined) {
const status = String(req.body.status)
if (!['connected', 'paused'].includes(status)) throw fail(400, '只允许暂停或恢复渠道')
if (current.type === 'telegram' && current.webhookId) {
const webhookUrl = `${(process.env.PUBLIC_WEBHOOK_BASE_URL || 'http://host.docker.internal:8088').replace(/\/$/, '')}/internal/webhooks/telegram/${req.auth.tenantId}/${current.id}`
await putGateway(`/tg-accounts/${current.gatewayAccountId}/webhooks/${current.webhookId}`, {
url: webhookUrl,
events: telegramWebhookEvents,
isActive: status === 'connected',
secret: process.env.TELEGRAM_WEBHOOK_SECRET,
})
}
if (current.type === 'whatsapp' && current.gatewayAccountId) {
await ensureWhatsAppWebhook(req.auth.tenantId, current, status === 'connected')
}
patch.status = status
}
const value = await patchResource(req.auth.tenantId, 'channels', req.params.id, patch)
await audit(req, 'channel.update', req.params.id, patch)
ok(res, publicChannel(value))
})
app.delete('/api/channels/:id', async (req, res) => {
const channel = await getResource(req.auth.tenantId, 'channels', req.params.id)
if (!channel) throw fail(404, '渠道不存在')
if (channel.type === 'telegram' && channel.gatewayAccountId && channel.webhookId) {
const webhookUrl = `${(process.env.PUBLIC_WEBHOOK_BASE_URL || 'http://host.docker.internal:8088').replace(/\/$/, '')}/internal/webhooks/telegram/${req.auth.tenantId}/${channel.id}`
await putGateway(`/tg-accounts/${channel.gatewayAccountId}/webhooks/${channel.webhookId}`, {
url: webhookUrl,
events: telegramWebhookEvents,
isActive: false,
secret: process.env.TELEGRAM_WEBHOOK_SECRET,
}).catch(() => null)
}
if (channel.type === 'whatsapp' && channel.gatewayAccountId) {
await ensureWhatsAppWebhook(req.auth.tenantId, channel, false).catch(() => null)
await postWhatsApp(`/accounts/${channel.gatewayAccountId}/logout`, {}).catch(() => null)
await whatsappRequest(`/accounts/${channel.gatewayAccountId}`, { method: 'DELETE' }).catch(() => null)
}
await deleteResource(req.auth.tenantId, 'channels', req.params.id)
if (channel.isDefault) await promoteDefaultChannel(req.auth.tenantId, channel.type, channel.id)
await audit(req, 'channel.delete', req.params.id, { type: channel.type, account: channel.account })
ok(res, { success: true })
})
async function messagesFor(req) {
if (!req.body.conversationId) return []
return sortMessagesChronologically(
await listResources(req.auth.tenantId, `messages:${req.body.conversationId}`),
)
}
async function retrieveKnowledge(tenantId, query, limit = 5) {
const items = await listResources(tenantId, 'knowledge')
return rankKnowledge(items, query, limit)
}
app.post('/api/ai/translate', async (req, res) => {
const messages = await messagesFor(req)
const settings = await getResource(req.auth.tenantId, 'singleton', 'settings')
const contextEnabled = (settings?.ai?.contextualTranslation ?? true) !== false
const contextText = contextEnabled ? conversationContext(messages, 30) : ''
const translationConversation = contextEnabled && req.body.conversationId
? await getResource(req.auth.tenantId, 'conversations', req.body.conversationId)
: null
const translationCustomer = translationConversation
? await getResource(req.auth.tenantId, 'customers', translationConversation.customerId)
: null
const conversationSummary = contextMemoryText(translationConversation, translationCustomer)
const result = await deepseek(
'你是灵犀跨境的专业跨境贸易翻译。只输出严格 JSON,不要 Markdown。会话上下文仅用于代词消歧、术语一致性、语气和业务含义判断;只翻译 <source> 内的内容,不得把上下文拼入译文。',
`目标语言:${req.body.targetLang}\n场景:${req.body.scenario || '跨境商务'}\n${conversationSummary ? `<conversation_summary>\n${conversationSummary}\n</conversation_summary>\n` : ''}<conversation_context>\n${contextText}\n</conversation_context>\n<source>\n${req.body.text}\n</source>\n输出 {"translated":"仅 source 的完整译文","detectedLang":"ISO语言代码","terms":[{"source":"...","target":"..."}]}`,
{ json: true, maxTokens: Math.min(2200, Math.max(600, String(req.body.text || '').length * 3)) },
)
const historyItem = await putResource(req.auth.tenantId, 'translation-history', {
sourceLang: req.body.sourceLang || result.detectedLang || 'auto',
targetLang: req.body.targetLang,
originalText: req.body.text,
translatedText: result.translated,
detectedLang: result.detectedLang,
terms: result.terms || [],
scenario: req.body.scenario || '跨境商务',
favorite: false,
createdBy: req.auth.userId,
createdAt: new Date().toISOString(),
})
ok(res, {
...result,
historyItem,
meta: {
method: contextText ? 'ai-contextual' : 'ai-direct',
contextApplied: Boolean(contextText || conversationSummary),
contextMessageCount: Math.min(messages.length, 30),
conversationSummaryApplied: Boolean(conversationSummary),
model: process.env.DEEPSEEK_MODEL || 'deepseek-v4-flash',
},
})
})
app.get('/api/ai/translations', async (req, res) => {
const list = (await listResources(req.auth.tenantId, 'translation-history'))
.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))
.slice(0, 100)
ok(res, list)
})
app.patch('/api/ai/translations/:id', async (req, res) => {
const value = await patchResource(req.auth.tenantId, 'translation-history', req.params.id, {
favorite: Boolean(req.body.favorite),
updatedAt: new Date().toISOString(),
})
if (!value) throw fail(404, '翻译记录不存在')
await audit(req, 'translation.favorite', req.params.id, { favorite: value.favorite })
ok(res, value)
})
app.post('/api/ai/analyze', async (req, res) => {
const result = await analyzeAndPersistConversation(req.auth.tenantId, req.body.conversationId)
if (!result) throw fail(400, '会话没有可分析的消息')
await putResource(req.auth.tenantId, 'ai-analysis', { ...result, conversationId: req.body.conversationId })
ok(res, result)
})
app.post('/api/ai/replies', async (req, res) => {
const messages = await messagesFor(req)
const conversation = req.body.conversationId
? await getResource(req.auth.tenantId, 'conversations', req.body.conversationId)
: null
if (!conversation) throw fail(404, '会话不存在')
const replyCustomer = await getResource(req.auth.tenantId, 'customers', conversation.customerId)
const conversationMemory = contextMemoryText(conversation, replyCustomer)
const resolvedLanguage = await resolveConversationLanguage(
req.auth.tenantId,
conversation,
req.body.targetLang,
)
const targetInstruction = resolvedLanguage.code === 'auto'
? '根据最近客户消息自动判断;没有语言线索时使用英语'
: `使用 ISO 代码 ${resolvedLanguage.code} 对应的语言`
const recentCustomerText = [...messages]
.reverse()
.filter((message) => message.sender === 'customer')
.slice(0, 4)
.map((message) => message.originalText)
.filter(Boolean)
.reverse()
.join('\n')
const knowledgeHits = await retrieveKnowledge(
req.auth.tenantId,
`${conversationMemory}\n${recentCustomerText}`,
5,
)
const knowledgeContext = knowledgeHits.length
? knowledgeHits.map((hit) => ({
id: hit.item.id,
title: hit.item.title,
score: Number(hit.score.toFixed(3)),
excerpt: hit.snippet,
}))
: []
const result = await deepseek(
'你是跨境销售回复助手。基于会话事实和给定企业知识生成三条可人工确认的回复,不得编造价格、库存、交期、政策或承诺。知识不足时明确需要人工确认。只输出严格 JSON 数组。content 使用客户语言且可直接发送;translationCn 是 content 完整准确的简体中文翻译;knowledgeRefs 只能填写已提供知识的 id;其余说明使用简体中文。',
`客户目标语言:${targetInstruction}
<conversation>
${conversationContext(messages, 30)}
</conversation>
${conversationMemory ? `<conversation_memory>\n${conversationMemory}\n</conversation_memory>` : ''}
<enterprise_knowledge>
${JSON.stringify(knowledgeContext)}
</enterprise_knowledge>
输出 3 个元素:
{"id":"唯一值","style":"stable|active|brief","content":"客户语言回复","translationCn":"完整中文翻译","targetLang":"实际 ISO 语言代码","reason":"选择理由","knowledgeRefs":[],"expectedEffect":"预期效果","cultureTip":"必要时填写"}`,
{ json: true, maxTokens: 2600 },
)
if (!Array.isArray(result)) throw fail(502, 'AI 回复建议格式异常')
const allowedKnowledge = new Map(knowledgeHits.map((hit) => [String(hit.item.id), hit.item]))
let suggestions = result.slice(0, 3).map((item) => ({
...item,
id: String(item.id || uid()),
knowledgeRefs: Array.isArray(item.knowledgeRefs)
? [...new Set(item.knowledgeRefs.map(String))]
.filter((id) => allowedKnowledge.has(id))
.slice(0, 3)
: [],
}))
const missingTranslations = suggestions.filter((item) => !String(item?.translationCn || '').trim())
if (missingTranslations.length) {
const translated = await deepseek(
'你是专业商务翻译。只输出严格 JSON 数组。将每个 content 完整翻译成自然、准确的简体中文,不得遗漏或添加信息。',
`待翻译内容:${JSON.stringify(missingTranslations.map(({ id, content }) => ({ id, content })))}\n输出结构:[{"id":"原始 id","translationCn":"完整简体中文翻译"}]`,
{ json: true, maxTokens: 1400 },
)
const translationMap = new Map(
(Array.isArray(translated) ? translated : [])
.map((item) => [String(item?.id || ''), String(item?.translationCn || '').trim()]),
)
suggestions = suggestions.map((item) => ({
...item,
translationCn: String(item?.translationCn || translationMap.get(String(item?.id || '')) || '').trim(),
}))
}
if (suggestions.length !== 3 || suggestions.some((item) => !item?.content || !item?.translationCn)) {
throw fail(502, 'AI 回复建议缺少完整的中英文内容')
}
suggestions = await Promise.all(suggestions.map(async (suggestion) => {
const knowledgeRefs = suggestion.knowledgeRefs || []
await putResource(req.auth.tenantId, 'ai-reply-suggestions', {
...suggestion,
conversationId: conversation.id,
knowledgeRefs,
createdAt: new Date().toISOString(),
})
return {
...suggestion,
knowledgeRefs: knowledgeRefs.map((id) => allowedKnowledge.get(id)?.title || id),
knowledgeRefIds: knowledgeRefs,
}
}))
ok(res, suggestions)
})
app.post('/api/ai/adoptions', async (req, res) => {
const suggestion = req.body.suggestionId
? await getResource(req.auth.tenantId, 'ai-reply-suggestions', String(req.body.suggestionId))
: null
const value = await putResource(req.auth.tenantId, 'ai-adoptions', {
id: uid(),
conversationId: String(req.body.conversationId || ''),
suggestionId: String(req.body.suggestionId || ''),
contentHash: crypto.createHash('sha256').update(String(req.body.content || '')).digest('hex'),
adoptedBy: req.auth.userId,
createdAt: new Date().toISOString(),
})
for (const knowledgeId of suggestion?.knowledgeRefs || []) {
const item = await getResource(req.auth.tenantId, 'knowledge', String(knowledgeId))
if (!item) continue
await putResource(req.auth.tenantId, 'knowledge', {
...item,
citedCount: Number(item.citedCount || 0) + 1,
lastCitedAt: new Date().toISOString(),
})
}
ok(res, value)
})
app.post('/api/ai/summarize', async (req, res) => {
const messages = await messagesFor(req)
ok(res, { summary: await deepseek('用中文简洁总结跨境商务会话。', conversationContext(messages)) })
})
app.post('/api/ai/text-action', async (req, res) => {
const target = normalizeLanguageCode(req.body.targetLang) || req.body.targetLang || ''
ok(res, {
result: await deepseek(
'你是跨境商务写作助手。只返回处理后的正文,不要标题、解释或引号。保留事实、数字、专有名词和原有承诺边界。',
`操作:${req.body.action}\n语气:${req.body.tone || ''}\n目标语言:${target}\n文本:${req.body.text}`,
{ maxTokens: 1600 },
),
})
})
const emptyKpi = () => ({ value: 0, delta: 0 })
async function businessDataset(tenantId) {
const [customers, conversations, tasks, risks, opportunities, adoptions, usersResult] = await Promise.all([
listResources(tenantId, 'customers'),
listResources(tenantId, 'conversations'),
listResources(tenantId, 'tasks'),
listResources(tenantId, 'risks'),
listResources(tenantId, 'opportunities'),
listResources(tenantId, 'ai-adoptions'),
pool.query('SELECT id,name FROM users WHERE tenant_id=$1', [tenantId]),
])
const messageGroups = await Promise.all(conversations.map(async (conversation) => ({
conversation,
messages: (await listResources(tenantId, `messages:${conversation.id}`))
.sort((a, b) => Date.parse(a.createdAt || '') - Date.parse(b.createdAt || '')),
})))
const responseTimes = []
const repliesByOwner = new Map()
for (const group of messageGroups) {
for (let index = 0; index < group.messages.length; index++) {
const message = group.messages[index]
if (message.sender !== 'customer') continue
const reply = group.messages.slice(index + 1).find((item) => item.sender === 'agent')
if (!reply) continue
const minutes = Math.max(0, (Date.parse(reply.createdAt) - Date.parse(message.createdAt)) / 60000)
if (Number.isFinite(minutes) && minutes < 7 * 24 * 60) {
responseTimes.push(minutes)
const owner = group.conversation.assigneeId
if (owner) {
const row = repliesByOwner.get(owner) || { total: 0, count: 0 }
row.total += minutes
row.count++
repliesByOwner.set(owner, row)
}
}
}
}
return {
customers, conversations, tasks, risks, opportunities, adoptions,
users: usersResult.rows,
messageGroups,
messages: messageGroups.flatMap((group) => group.messages),
responseTimes,
repliesByOwner,
}
}
app.get('/api/dashboard/overview', async (req, res) => {
const data = await businessDataset(req.auth.tenantId)
const { customers, conversations, tasks, risks, opportunities, messages, responseTimes, users, repliesByOwner, adoptions } = data
const unread = conversations.reduce((n, x) => n + Number(x.unreadCount || 0), 0)
const today = dateKey()
const won = opportunities.filter((item) => item.stage === 'won').length
const outgoing = messages.filter((message) => message.sender === 'agent').length
const days = Array.from({ length: 7 }, (_, index) => dateKey(Date.now() - (6 - index) * 86400000))
const sources = countBy(customers, (customer) => customer.sourcePath || customer.source || 'telegram')
const stages = countBy(customers, 'stage')
const teamNames = new Map(users.map((user) => [user.id, user.name]))
ok(res, {
stats: {
newInquiriesToday: { value: conversations.filter((x) => String(x.createdAt || '').slice(0, 10) === today).length, delta: 0 },
pendingReply: { value: unread, delta: 0 }, highIntentCustomers: { value: customers.filter((x) => x.intentScore >= 75).length, delta: 0 },
todayFollowUps: { value: tasks.filter((x) => x.status !== 'done').length, delta: 0 }, openRisks: { value: risks.filter((x) => x.status !== 'resolved').length, delta: 0 },
avgFirstResponseMin: { value: responseTimes.length ? Math.round(responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length) : 0, delta: 0, unit: '分钟' },
monthConversionRate: { value: percent(won, opportunities.length), delta: 0, isPercent: true },
aiAdoptionRate: { value: percent(adoptions.length, Math.max(outgoing, 1)), delta: 0, isPercent: true },
},
todos: tasks.filter((x) => x.status !== 'done').slice(0, 7),
highValueCustomers: customers.sort((a, b) => (b.intentScore || 0) - (a.intentScore || 0)).slice(0, 6),
channelOverview: [...new Set(conversations.map((conversation) => conversation.channel))].map((channel) => {
const channelConversations = conversations.filter((conversation) => conversation.channel === channel)
const ids = new Set(channelConversations.map((conversation) => conversation.id))
return {
channel,
todayMessages: messages.filter((message) => ids.has(message.conversationId) && dateKey(message.createdAt) === today).length,
pending: channelConversations.filter((conversation) => conversation.status === 'pending').length,
unread: channelConversations.reduce((total, conversation) => total + Number(conversation.unreadCount || 0), 0),
}
}),
inquiryTrend: days.map((day) => ({
date: day.slice(5),
inquiries: conversations.filter((item) => dateKey(item.createdAt) === day).length,
customers: customers.filter((item) => dateKey(item.createdAt || item.updatedAt) === day).length,
})),
sourceDistribution: [...sources].map(([name, value]) => ({ name, value })),
funnel: [...stages].map(([stage, count]) => ({ stage, label: stage, count })),
teamReplyRank: [...repliesByOwner].map(([memberId, row]) => ({
memberId,
name: teamNames.get(memberId) || '未命名成员',
avgResponseMin: Math.round(row.total / row.count),
replies: row.count,
})).sort((a, b) => a.avgResponseMin - b.avgResponseMin),
recentRisks: risks.slice(0, 5), updatedAt: new Date().toISOString(),
})
})
app.get('/api/team', async (req, res) => {
const [{ rows }, customers, tasks] = await Promise.all([
pool.query('SELECT * FROM users WHERE tenant_id=$1 ORDER BY created_at', [req.auth.tenantId]),
listResources(req.auth.tenantId, 'customers'),
listResources(req.auth.tenantId, 'tasks'),
])
ok(res, rows.map((x) => ({
id: x.id,
name: x.name,
email: x.account,
phone: x.phone,
department: x.department,
role: x.role,
online: Date.parse(x.last_login_at || '') > Date.now() - 15 * 60 * 1000,
customerCount: customers.filter((customer) => customer.ownerId === x.id).length,
todoCount: tasks.filter((task) => task.ownerId === x.id && task.status !== 'done').length,
lastLoginAt: x.last_login_at,
status: x.status,
joinedAt: x.created_at,
})))
})
app.get('/api/team/operation-logs', async (req, res) => {
const { rows } = await pool.query('SELECT * FROM audit_logs WHERE tenant_id=$1 ORDER BY created_at DESC LIMIT 200', [req.auth.tenantId])
ok(res, rows.map((x) => ({ id: x.id, actorId: x.user_id, actorName: '', action: x.action, target: x.target, detail: JSON.stringify(x.detail), ip: x.ip, createdAt: x.created_at })))
})
app.get('/api/team/login-records', async (req, res) => {
const { rows } = await pool.query(
`SELECT lr.*,u.name AS member_name
FROM login_records lr LEFT JOIN users u ON u.id=lr.user_id
WHERE lr.tenant_id=$1 ORDER BY lr.created_at DESC LIMIT 200`,
[req.auth.tenantId],
)
ok(res, rows.map((row) => ({
id: row.id,
memberId: row.user_id,
memberName: row.member_name || row.account,
device: row.device || 'Unknown',
ip: row.ip,
location: row.ip || 'Unknown',
status: row.status,
createdAt: row.created_at,
})))
})
app.post('/api/team/invite', async (req, res) => {
if (req.auth.role !== 'admin') throw fail(403, '仅管理员可邀请成员')
const email = String(req.body.email || '').trim().toLowerCase()
if (!email.includes('@')) throw fail(400, '请输入有效邮箱')
if (!process.env.SMTP_HOST || !process.env.SMTP_USER || !process.env.SMTP_PASSWORD) throw fail(503, 'SMTP 邮件服务尚未配置')
const temporaryPassword = `Lx!${crypto.randomBytes(12).toString('base64url')}`
const userId = uid()
const exists = await pool.query('SELECT 1 FROM users WHERE account=$1', [email])
if (exists.rowCount) throw fail(409, '该邮箱已加入团队')
await pool.query(
`INSERT INTO users(id,tenant_id,name,account,password_hash,phone,role,department,title,status)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,'pending')`,
[userId, req.auth.tenantId, req.body.name || email.split('@')[0], email, await bcrypt.hash(temporaryPassword, 12),
req.body.phone || '', req.body.role || 'sales', req.body.department || '', req.body.title || '成员'],
)
const transport = nodemailer.createTransport({
host: process.env.SMTP_HOST, port: Number(process.env.SMTP_PORT || 587),
secure: String(process.env.SMTP_SECURE || 'false') === 'true',
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD },
})
try {
await transport.verify()
await transport.sendMail({
from: process.env.SMTP_FROM || process.env.SMTP_USER, to: email,
subject: '邀请加入灵犀跨境',
text: `你已被邀请加入灵犀跨境。登录地址:${process.env.APP_PUBLIC_URL || 'http://127.0.0.1:8088'},临时密码:${temporaryPassword}。首次登录后请立即修改密码。`,
})
await pool.query("UPDATE users SET status='active' WHERE id=$1", [userId])
} catch (error) {
await pool.query('DELETE FROM users WHERE id=$1', [userId])
throw fail(502, `邀请邮件发送失败:${error.message}`)
}
const { rows } = await pool.query('SELECT * FROM users WHERE id=$1', [userId])
await audit(req, 'team.invite', userId, { email })
ok(res, rows[0])
})
app.patch('/api/team/:id', async (req, res) => {
if (req.auth.role !== 'admin') throw fail(403, '仅管理员可修改成员')
const allowed = ['name', 'phone', 'department', 'role', 'status']; const values = []; const sets = []
for (const key of allowed) if (key in req.body) { values.push(req.body[key]); sets.push(`${key}=$${values.length}`) }
values.push(req.params.id, req.auth.tenantId)
const { rows } = await pool.query(`UPDATE users SET ${sets.join(',')} WHERE id=$${values.length - 1} AND tenant_id=$${values.length} RETURNING *`, values)
if (!rows[0]) throw fail(404, '成员不存在'); ok(res, rows[0])
})
app.delete('/api/team/:id', (_req, _res, next) => next(fail(501, '成员删除改为禁用,避免审计数据丢失')))
const defaultSettings = {
company: { name: '灵犀跨境', industry: '', size: '', contact: '', email: '', intro: '', products: [], regions: [] },
ai: { replyLanguage: 'zh-CN', replyTone: 'professional', autoGenerate: true, requireConfirm: true, sentimentAnalysis: true, messageIntentAnalysis: true, contextualTranslation: true, riskAlert: true, cultureCheck: true, allowHistoryRef: true },
notification: { newInquiry: true, highIntent: true, riskAlert: true, taskDue: true, taskOverdue: true, channelOffline: true, planExpire: true, byEmail: false, bySms: false },
translate: { industry: '', formality: 'professional', targetCountry: '', localization: true, keepBrand: true, keepModel: true, autoUnit: true, cultureReminder: true, religionReminder: true },
language: 'zh-CN', timezone: 'Asia/Hong_Kong', sessionRetentionMonths: 12, brandColor: '#2563eb', onboarding: {},
}
app.get('/api/settings', async (req, res) => ok(res, (await getResource(req.auth.tenantId, 'singleton', 'settings')) || defaultSettings))
app.patch('/api/settings', async (req, res) => {
const current = (await getResource(req.auth.tenantId, 'singleton', 'settings')) || defaultSettings
const value = await putResource(req.auth.tenantId, 'singleton', {
...current,
...req.body,
company: { ...current.company, ...(req.body.company || {}) },
ai: { ...current.ai, ...(req.body.ai || {}) },
notification: { ...current.notification, ...(req.body.notification || {}) },
locale: { ...current.locale, ...(req.body.locale || {}) },
id: 'settings',
})
await audit(req, 'settings.update', 'settings', Object.keys(req.body || {}))
ok(res, value)
})
app.get('/api/notifications', async (req, res) => ok(res, await listResources(req.auth.tenantId, 'notifications')))
app.post('/api/notifications/read-all', async (req, res) => {
for (const n of await listResources(req.auth.tenantId, 'notifications')) await putResource(req.auth.tenantId, 'notifications', { ...n, read: true })
ok(res, { success: true })
})
app.get('/api/knowledge/packs', (_req, res) => ok(res, []))
app.get('/api/knowledge/stats', async (req, res) => {
const [list, adoptions] = await Promise.all([
listResources(req.auth.tenantId, 'knowledge'),
listResources(req.auth.tenantId, 'ai-adoptions'),
])
const totalCitations = list.reduce((total, item) => total + Number(item.citedCount || 0), 0)
const outdatedBefore = Date.now() - 180 * 86400000
ok(res, {
totalCitations,
aiReplyCitationRate: adoptions.length ? Math.min(1, totalCitations / adoptions.length) : 0,
topUsed: [...list]
.filter((item) => Number(item.citedCount || 0) > 0)
.sort((a, b) => Number(b.citedCount || 0) - Number(a.citedCount || 0))
.slice(0, 8)
.map((item) => ({ id: item.id, title: item.title, citedCount: Number(item.citedCount || 0) })),
lowQuality: list
.filter((item) => item.quality === 'low' || String(item.content || '').trim().length < 80)
.slice(0, 20)
.map((item) => ({
id: item.id,
title: item.title,
reason: item.quality === 'low' ? '质量标记为低' : '有效内容少于 80 字',
})),
outdated: list
.filter((item) => Date.parse(item.updatedAt || item.createdAt || '') < outdatedBefore)
.slice(0, 20)
.map((item) => ({ id: item.id, title: item.title, updatedAt: item.updatedAt || item.createdAt })),
})
})
app.post('/api/knowledge/upload', async (req, res) => {
const files = Array.isArray(req.body.files) ? req.body.files : []
const items = []
for (const file of files) {
const buffer = Buffer.from(String(file.base64 || ''), 'base64')
if (!buffer.length || buffer.length > 20 * 1024 * 1024) throw fail(400, `文件 ${file.name || ''} 无内容或超过 20MB`)
const extension = String(file.name || '').split('.').pop()?.toLowerCase()
let content = ''
if (['txt', 'md', 'csv', 'json', 'html'].includes(extension)) content = buffer.toString('utf8')
else if (extension === 'docx') content = (await mammoth.extractRawText({ buffer })).value
else if (extension === 'pdf') content = (await pdfParse(buffer)).text
else throw fail(415, `暂不支持 ${extension || '未知'} 文件`)
content = content
.replace(/\u0000/g, '')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{4,}/g, '\n\n')
.trim()
if (content.length < 20) throw fail(422, `文件 ${file.name || ''} 未解析出足够的文本内容`)
items.push(await putResource(req.auth.tenantId, 'knowledge', {
title: String(file.name).replace(/\.[^.]+$/, ''), category: 'product',
content, tags: ['文件导入'], enabled: true, version: 1,
updatedBy: req.auth.userId, source: 'file', fileName: file.name, fileType: extension,
status: 'ready', citedCount: 0, languages: ['auto'], quality: 'normal',
parseMessage: `已解析 ${content.length.toLocaleString('zh-CN')} 个字符`,
...knowledgeMetadata(content),
createdAt: new Date().toISOString(),
}))
}
await audit(req, 'knowledge.upload', '', { count: items.length })
ok(res, { items })
})
app.post('/api/knowledge/search-test', async (req, res) => {
const question = String(req.body.question || '').trim()
if (question.length < 2) throw fail(400, '请输入至少 2 个字符的问题')
const hits = await retrieveKnowledge(req.auth.tenantId, question, 6)
if (!hits.length) {
ok(res, { answer: '知识库中没有找到可支持回答的内容。', hits: [] })
return
}
const sources = hits.map((hit) => ({
id: hit.item.id,
title: hit.item.title,
excerpt: hit.snippet,
}))
const answer = await deepseek(
'你是企业知识库问答助手。只能根据 <sources> 回答;不能用常识补全未出现的价格、政策、承诺或事实。内容不足时明确说“现有知识不足”。回答使用简体中文,并在相关句末用方括号标注实际来源的 title,例如来源 title 是“欧洲售后政策”时写成 [欧洲售后政策];禁止原样输出“[来源标题]”。',
`<question>${question}</question>\n<sources>${JSON.stringify(sources)}</sources>`,
{ maxTokens: 1200 },
)
ok(res, {
answer,
hits: hits.map((hit) => ({
item: hit.item,
score: hit.score,
snippet: hit.snippet,
chunkIndex: hit.chunkIndex,
})),
})
})
app.get('/api/billing', async (req, res) => {
const [{ rows }, orders, invoices, userCount, conversations, knowledge] = await Promise.all([
pool.query('SELECT plan FROM tenants WHERE id=$1', [req.auth.tenantId]),
listResources(req.auth.tenantId, 'billing-orders'),
listResources(req.auth.tenantId, 'billing-invoices'),
pool.query('SELECT COUNT(*)::int AS count FROM users WHERE tenant_id=$1 AND status<>$2', [req.auth.tenantId, 'disabled']),
listResources(req.auth.tenantId, 'conversations'),
listResources(req.auth.tenantId, 'knowledge'),
])
const messageGroups = await Promise.all(conversations.map((conversation) => listResources(req.auth.tenantId, `messages:${conversation.id}`)))
const messages = messageGroups.flat()
const translations = messages.filter((message) => message.translatedText).length
const knowledgeBytes = knowledge.reduce((total, item) => total + Buffer.byteLength(String(item.content || ''), 'utf8'), 0)
const planKey = rows[0]?.plan || 'lite'
ok(res, {
info: { planKey, planName: planKey, expireAt: '', autoRenew: false, usage: {
members: { used: Number(userCount.rows[0]?.count || 0), total: 100, unit: '人' },
messages: { used: messages.length, total: 100000, unit: '条' },
translations: { used: translations, total: 100000, unit: '次' },
knowledgeStorage: { used: Number((knowledgeBytes / 1024 / 1024).toFixed(2)), total: 10240, unit: 'MB' },
} },
orders, invoices,
})
})
app.post('/api/billing/upgrade', async (req, res) => {
const priceMap = {
lite: process.env.STRIPE_PRICE_LITE,
standard: process.env.STRIPE_PRICE_STANDARD,
pro: process.env.STRIPE_PRICE_PRO,
growth: process.env.STRIPE_PRICE_GROWTH,
}
const price = priceMap[req.body.planKey]
if (!process.env.STRIPE_SECRET_KEY || !price) throw fail(503, 'Stripe 商户密钥或套餐 Price ID 尚未配置')
const form = new URLSearchParams({
mode: 'subscription', 'line_items[0][price]': price, 'line_items[0][quantity]': '1',
success_url: `${process.env.APP_PUBLIC_URL || 'http://127.0.0.1:8088'}/billing?payment=success&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.APP_PUBLIC_URL || 'http://127.0.0.1:8088'}/billing?payment=cancelled`,
'metadata[tenantId]': req.auth.tenantId, 'metadata[planKey]': req.body.planKey,
})
const response = await fetch('https://api.stripe.com/v1/checkout/sessions', {
method: 'POST',
headers: { authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`, 'content-type': 'application/x-www-form-urlencoded' },
body: form,
})
const checkout = await response.json()
if (!response.ok) throw fail(502, checkout?.error?.message || '创建支付会话失败')
await putResource(req.auth.tenantId, 'billing-orders', {
id: checkout.id, planKey: req.body.planKey, status: 'pending',
checkoutUrl: checkout.url, createdAt: new Date().toISOString(),
})
ok(res, { checkoutUrl: checkout.url, sessionId: checkout.id })
})
app.post('/api/billing/stripe-webhook', async (req, res) => {
const signingSecret = process.env.STRIPE_WEBHOOK_SECRET
const signature = req.get('stripe-signature') || ''
if (!signingSecret || !signature) throw fail(401, 'Stripe Webhook 签名配置缺失')
const parts = Object.fromEntries(signature.split(',').map((part) => part.split('=', 2)))
const timestamp = Number(parts.t)
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > 300) throw fail(401, 'Stripe Webhook 已过期')
const expected = crypto.createHmac('sha256', signingSecret)
.update(`${timestamp}.${req.rawBody.toString('utf8')}`).digest('hex')
const provided = String(parts.v1 || '')
if (provided.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(expected))) {
throw fail(401, 'Stripe Webhook 签名无效')
}
const event = req.body
if (event.type === 'checkout.session.completed') {
const session = event.data?.object || {}
const tenantId = session.metadata?.tenantId
const planKey = session.metadata?.planKey
if (tenantId && planKey) {
await pool.query('UPDATE tenants SET plan=$1 WHERE id=$2', [planKey, tenantId])
await putResource(tenantId, 'billing-orders', {
id: session.id, planKey, status: 'paid', amountTotal: session.amount_total,
currency: session.currency, customerId: session.customer, subscriptionId: session.subscription,
paidAt: new Date().toISOString(),
})
await putResource(tenantId, 'billing-invoices', {
id: session.payment_intent || session.id, orderId: session.id, planKey,
amount: session.amount_total, currency: session.currency, status: 'paid',
createdAt: new Date().toISOString(),
})
broadcast(tenantId, { type: 'billing.updated', planKey, status: 'paid' })
}
}
ok(res, { received: true })
})
app.get('/api/analytics', async (req, res) => {
const data = await businessDataset(req.auth.tenantId)
const { customers, conversations, tasks, risks, opportunities, messages, responseTimes, users, repliesByOwner, adoptions } = data
const days = Array.from({ length: 30 }, (_, index) => dateKey(Date.now() - (29 - index) * 86400000))
const won = opportunities.filter((item) => item.stage === 'won')
const doneTasks = tasks.filter((item) => item.status === 'done')
const handledRisks = risks.filter((item) => ['resolved', 'ignored'].includes(item.status))
const translated = messages.filter((message) => message.translatedText).length
const productCounts = new Map()
for (const customer of customers) for (const product of customer.products || []) {
productCounts.set(product, (productCounts.get(product) || 0) + 1)
}
const countryCounts = countBy(customers, 'country')
const stageCounts = countBy(customers, 'stage')
const sourceCounts = countBy(customers, (customer) => customer.sourcePath || customer.source || 'telegram')
const userNames = new Map(users.map((user) => [user.id, user.name]))
const ownerCustomers = countBy(customers, 'ownerId')
const ownerWon = countBy(won, 'ownerId')
const overdue = tasks.filter((task) => task.status !== 'done' && Date.parse(task.dueAt || '') < Date.now())
const now = Date.now()
ok(res, {
kpis: {
newCustomers: { value: customers.filter((item) => Date.parse(item.createdAt || item.updatedAt || '') >= now - 30 * 86400000).length, delta: 0 },
validInquiries: { value: conversations.length, delta: 0 },
highIntent: { value: customers.filter((item) => Number(item.intentScore || 0) >= 75).length, delta: 0 },
conversionRate: { value: percent(won.length, opportunities.length), delta: 0, isPercent: true },
avgResponseMin: { value: responseTimes.length ? Math.round(responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length) : 0, delta: 0, unit: '分钟' },
aiTranslateUsage: { value: translated, delta: 0 },
aiAdoptionRate: { value: percent(adoptions.length, messages.filter((item) => item.sender === 'agent').length), delta: 0, isPercent: true },
taskCompletionRate: { value: percent(doneTasks.length, tasks.length), delta: 0, isPercent: true },
riskHandleRate: { value: percent(handledRisks.length, risks.length), delta: 0, isPercent: true },
repurchaseRate: { value: percent(customers.filter((item) => item.stage === 'repurchase').length, customers.length), delta: 0, isPercent: true },
},
customerGrowth: days.map((day) => ({ date: day.slice(5), count: customers.filter((item) => dateKey(item.createdAt || item.updatedAt) === day).length })),
channelInquiries: [...countBy(conversations, 'channel')].map(([channel, count]) => ({
channel,
name: channel === 'telegram' ? 'Telegram' : channel === 'email' ? 'Email' : channel,
count,
})),
countryDistribution: [...countryCounts].map(([country, count]) => ({ country, name: country, count })),
sourcePaths: [...sourceCounts].map(([path, count]) => ({ path, count })),
funnel: [...stageCounts].map(([stage, count]) => ({ stage, label: stage, count })),
intentDistribution: [
{ range: '0-24', count: customers.filter((item) => Number(item.intentScore || 0) < 25).length },
{ range: '25-49', count: customers.filter((item) => Number(item.intentScore || 0) >= 25 && Number(item.intentScore || 0) < 50).length },
{ range: '50-74', count: customers.filter((item) => Number(item.intentScore || 0) >= 50 && Number(item.intentScore || 0) < 75).length },
{ range: '75-100', count: customers.filter((item) => Number(item.intentScore || 0) >= 75).length },
],
sentimentTrend: days.map((day) => {
const rows = customers.filter((item) => dateKey(item.aiAnalyzedAt || item.updatedAt || item.createdAt) === day)
return {
date: day.slice(5),
positive: rows.filter((item) => ['positive', 'neutral-positive'].includes(item.sentiment)).length,
neutral: rows.filter((item) => item.sentiment === 'neutral').length,
negative: rows.filter((item) => ['negative', 'angry'].includes(item.sentiment)).length,
}
}),
riskTrend: days.map((day) => ({ date: day.slice(5), count: risks.filter((item) => dateKey(item.createdAt) === day).length })),
productRank: [...productCounts].map(([product, inquiries]) => ({ product, inquiries })).sort((a, b) => b.inquiries - a.inquiries).slice(0, 10),
teamReplyRank: [...repliesByOwner].map(([id, row]) => ({ name: userNames.get(id) || '未命名成员', avgResponseMin: Math.round(row.total / row.count) })).sort((a, b) => a.avgResponseMin - b.avgResponseMin),
teamConversionRank: users.map((user) => ({ name: user.name, rate: percent(ownerWon.get(user.id) || 0, ownerCustomers.get(user.id) || 0) })).sort((a, b) => b.rate - a.rate),
taskCompletion: users.map((user) => {
const rows = tasks.filter((item) => item.ownerId === user.id)
return { name: user.name, done: rows.filter((item) => item.status === 'done').length, total: rows.length }
}),
churnReasons: [...countBy(risks, 'type')].map(([reason, count]) => ({ reason, count })),
industryDistribution: [...countBy(customers, (item) => item.aiProfile?.industry || '未知')].map(([industry, count]) => ({ industry, count })),
bossBoard: {
urgentHighValue: customers.filter((item) => Number(item.intentScore || 0) >= 75 && ['high', 'urgent'].includes(item.riskLevel)).slice(0, 8).map((item) => ({ id: item.id, name: item.name, country: item.country, reason: item.aiProfile?.summary || '高意向且存在风险', intentScore: item.intentScore })),
overdueMembers: users.map((user) => ({ id: user.id, name: user.name, overdueCount: overdue.filter((task) => task.ownerId === user.id).length })).filter((item) => item.overdueCount > 0),
bestChannel: { channel: 'telegram', name: 'Telegram', conversion: percent(won.length, opportunities.length) },
fastestCountries: [...countryCounts].map(([country, count]) => ({ country, name: country, growth: percent(count, Math.max(customers.length, 1)) / 100 })).sort((a, b) => b.growth - a.growth).slice(0, 6),
topProducts: [...productCounts].map(([product, inquiries]) => ({ product, inquiries })).sort((a, b) => b.inquiries - a.inquiries).slice(0, 6),
churningHighIntent: customers.filter((item) => Number(item.intentScore || 0) >= 75 && now - Date.parse(item.lastContactAt || item.updatedAt || '') > 7 * 86400000).slice(0, 8).map((item) => ({ id: item.id, name: item.name, country: item.country, daysSilent: Math.floor((now - Date.parse(item.lastContactAt || item.updatedAt)) / 86400000), intentScore: item.intentScore })),
},
})
})
app.get('/api/search', async (req, res) => {
const q = String(req.query.q || '').toLowerCase()
const result = {}
for (const kind of ['customers', 'conversations', 'knowledge', 'tasks']) result[kind] = (await listResources(req.auth.tenantId, kind)).filter((x) => JSON.stringify(x).toLowerCase().includes(q)).slice(0, 5).map((x) => ({ id: x.id, title: x.name || x.title || x.summary || '', sub: x.company || x.content || '' }))
ok(res, result)
})
async function claimWebhookEvent(tenantId, eventId, value) {
const existing = await getResource(tenantId, 'webhook-events', eventId)
const decision = webhookEventClaimDecision(existing)
if (decision === 'duplicate') return { duplicate: true, existing }
if (decision === 'busy') {
throw fail(409, 'Webhook event is already processing')
}
const event = await putResource(tenantId, 'webhook-events', {
...(existing || {}),
...value,
id: eventId,
status: 'processing',
attempts: Number(existing?.attempts || 0) + 1,
receivedAt: existing?.receivedAt || new Date().toISOString(),
processingAt: new Date().toISOString(),
lastError: undefined,
})
return { duplicate: false, event }
}
async function completeWebhookEvent(tenantId, eventId) {
await patchResource(tenantId, 'webhook-events', eventId, {
status: 'completed',
completedAt: new Date().toISOString(),
lastError: undefined,
})
}
async function failWebhookEvent(tenantId, eventId, error) {
await patchResource(tenantId, 'webhook-events', eventId, {
status: 'failed',
failedAt: new Date().toISOString(),
lastError: String(error?.message || error),
}).catch(() => null)
}
app.post('/internal/webhooks/telegram/:tenantId/:channelId', async (req, res) => {
const expected = process.env.TELEGRAM_WEBHOOK_SECRET
const received = String(req.get('x-webhook-signature') || '').replace(/^sha256=/, '')
const calculated = crypto.createHmac('sha256', expected || '').update(req.rawBody || Buffer.from(JSON.stringify(req.body))).digest('hex')
if (!expected || received.length !== calculated.length || !crypto.timingSafeEqual(Buffer.from(received), Buffer.from(calculated))) throw fail(401, 'Webhook 签名无效')
const { tenantId, channelId } = req.params
const channel = await getResource(tenantId, 'channels', channelId)
if (!channel) throw fail(404, 'Webhook 渠道映射不存在')
if (channel.status === 'paused') return ok(res, { accepted: true, ignoredPausedChannel: true })
const eventId = String(req.body.id || req.body.updateId || crypto.createHash('sha256').update(req.rawBody || Buffer.from(JSON.stringify(req.body))).digest('hex'))
const claim = await claimWebhookEvent(tenantId, eventId, {
channelId,
provider: 'telegram',
event: req.body.event,
payload: req.body,
})
if (claim.duplicate) return ok(res, { accepted: true, duplicate: true })
try {
const telegramMessage = req.body?.data?.message
if (['message.received', 'message.pending', 'message.sent', 'message.failed'].includes(req.body.event) && telegramMessage) {
const chatId = String(telegramMessage.chat_id)
const conversationId = `${channelId}:${chatId}`
const tombstone = await getResource(tenantId, 'conversation-tombstones', conversationId)
if (tombstone && req.body.event !== 'message.received') {
await completeWebhookEvent(tenantId, eventId)
return ok(res, { accepted: true, ignoredDeletedConversation: true })
}
if (tombstone) {
await deleteResource(tenantId, 'conversation-tombstones', conversationId)
}
const customerId = `tg-${channel.gatewayAccountId}-${chatId}`
let customer = await getResource(tenantId, 'customers', customerId)
const identity = await resolveTelegramIdentity(channel, chatId, {}, customer || {})
customer = await putResource(tenantId, 'customers', withDefaults('customers', {
...(customer || {}),
...identity,
id: customerId,
telegram: chatId,
telegramChatType: identity.telegramChatType,
source: 'telegram',
}))
const outgoing = Boolean(telegramMessage.is_outgoing)
const content = telegramMessage.content || {}
const serviceText = telegramServiceMessageText(content)
const originalText = content?.text?.text || content?.caption?.text || serviceText
const inboundCustomerMessage = !outgoing && !serviceText
const typeMap = {
messagePhoto: 'image',
messageDocument: 'file',
messageVoiceNote: 'audio',
messageAudio: 'audio',
messageSticker: 'file',
}
const messageStatus = !outgoing
? 'delivered'
: req.body.event === 'message.failed'
? 'failed'
: req.body.event === 'message.sent'
? 'sent'
: 'sending'
const oldMessageId = String(req.body?.data?.old_message_id || '')
const previousMessage = oldMessageId
? await getResource(tenantId, `messages:${conversationId}`, oldMessageId)
: await getResource(tenantId, `messages:${conversationId}`, String(telegramMessage.id))
if (oldMessageId && oldMessageId !== String(telegramMessage.id)) {
await deleteResource(tenantId, `messages:${conversationId}`, oldMessageId)
}
const outgoingMeta = outgoing
? await getResource(tenantId, 'outgoing-message-meta', oldMessageId || String(telegramMessage.id))
: null
const message = await putResource(tenantId, `messages:${conversationId}`, {
...(previousMessage || {}),
id: String(telegramMessage.id), conversationId,
sender: serviceText ? 'system' : outgoing ? 'agent' : 'customer',
senderName: outgoing ? '我' : customer.name,
type: typeMap[content['@type']] || 'text',
originalText: serviceText || outgoingMeta?.originalText || previousMessage?.originalText || originalText,
translatedText: serviceText ? undefined : outgoingMeta?.translatedText || previousMessage?.translatedText,
translationCn: serviceText ? undefined : outgoingMeta?.translationCn || previousMessage?.translationCn,
originalLang: outgoingMeta?.originalLang || previousMessage?.originalLang || 'auto',
targetLang: outgoingMeta?.targetLang || previousMessage?.targetLang,
status: messageStatus,
translationStatus: content['@type'] === 'messageText' && originalText
? outgoingMeta?.translationCn || previousMessage?.translationCn
? 'completed'
: 'pending'
: undefined,
createdAt: new Date(Number(telegramMessage.date || Date.now() / 1000) * 1000).toISOString(),
...telegramAttachment(content),
})
const previous = await getResource(tenantId, 'conversations', conversationId)
const updatedConversation = await putResource(tenantId, 'conversations', {
id: conversationId, customerId: customer.id, channel: 'telegram', channelId,
telegramChatId: chatId, telegramChatType: identity.telegramChatType,
unreadCount: inboundCustomerMessage
? Number(previous?.unreadCount || 0) + 1
: Number(previous?.unreadCount || 0),
status: inboundCustomerMessage ? 'pending' : previous?.status || 'replied',
assigneeId: previous?.assigneeId || null,
intentLevel: previous?.intentLevel || 'low', sentiment: previous?.sentiment || 'neutral',
hasRisk: previous?.hasRisk || false, summary: previous?.summary || '',
lastMessage: message, createdAt: previous?.createdAt || new Date().toISOString(),
})
broadcast(tenantId, {
type: 'message.created',
conversationId,
message,
customer,
conversation: updatedConversation,
oldMessageId: oldMessageId || undefined,
})
if (inboundCustomerMessage && req.body.event === 'message.received') {
await putResource(tenantId, 'notifications', {
id: `telegram-message-${channelId}-${telegramMessage.id}`,
type: 'inquiry',
title: `${customer.name} 发来新消息`,
description: originalText || (typeMap[content['@type']] === 'image'
? '[图片]'
: typeMap[content['@type']] === 'audio'
? '[语音]'
: typeMap[content['@type']] === 'file'
? '[文件]'
: '[新消息]'),
link: `/inbox?c=${encodeURIComponent(conversationId)}`,
read: false,
createdAt: new Date().toISOString(),
})
}
if (outgoingMeta && req.body.event === 'message.sent') {
await deleteResource(tenantId, 'outgoing-message-meta', outgoingMeta.id)
}
enqueueAutomaticTranslation(tenantId, conversationId, message)
if (inboundCustomerMessage && req.body.event === 'message.received') {
enqueueAutomaticProfile(tenantId, conversationId)
}
}
if (req.body.event === 'message.edited') {
const update = req.body?.data || {}
const chatId = String(update.chat_id || '')
const messageId = String(update.message_id || '')
if (chatId && messageId) {
const conversationId = `${channelId}:${chatId}`
const existing = await getResource(tenantId, `messages:${conversationId}`, messageId)
if (existing) {
const content = update.new_content || existing.telegramContent || {}
const originalText = content?.text?.text || content?.caption?.text || existing.originalText
const message = await patchResource(tenantId, `messages:${conversationId}`, messageId, {
originalText,
telegramContent: content,
editedAt: new Date().toISOString(),
translatedText: undefined,
translationCn: undefined,
translationStatus: originalText ? 'pending' : existing.translationStatus,
})
const conversation = await getResource(tenantId, 'conversations', conversationId)
if (String(conversation?.lastMessage?.id || '') === messageId) {
await patchResource(tenantId, 'conversations', conversationId, { lastMessage: message })
}
broadcast(tenantId, { type: 'message.updated', conversationId, message })
if (message) enqueueAutomaticTranslation(tenantId, conversationId, message)
}
}
} else if (req.body.event === 'message.deleted') {
const update = req.body?.data || {}
const chatId = String(update.chat_id || '')
const conversationId = `${channelId}:${chatId}`
for (const rawId of update.message_ids || []) {
const messageId = String(rawId)
const message = await patchResource(tenantId, `messages:${conversationId}`, messageId, {
isDeleted: true,
deletedAt: new Date().toISOString(),
})
if (!message) continue
const conversation = await getResource(tenantId, 'conversations', conversationId)
if (String(conversation?.lastMessage?.id || '') === messageId) {
await patchResource(tenantId, 'conversations', conversationId, { lastMessage: message })
}
broadcast(tenantId, { type: 'message.updated', conversationId, message })
}
}
await completeWebhookEvent(tenantId, eventId)
ok(res, { accepted: true })
} catch (error) {
await failWebhookEvent(tenantId, eventId, error)
throw error
}
})
app.post('/internal/webhooks/whatsapp/:tenantId/:channelId', async (req, res) => {
const expected = String(process.env.WHATSAPP_WEBHOOK_SECRET || '')
const received = String(req.get('x-webhook-signature') || '').replace(/^sha256=/, '')
const calculated = crypto.createHmac('sha256', expected)
.update(req.rawBody || Buffer.from(JSON.stringify(req.body)))
.digest('hex')
if (
!expected
|| received.length !== calculated.length
|| !crypto.timingSafeEqual(Buffer.from(received), Buffer.from(calculated))
) throw fail(401, 'WhatsApp Webhook 签名无效')
const { tenantId, channelId } = req.params
const channel = await getResource(tenantId, 'channels', channelId)
if (!channel || channel.type !== 'whatsapp') throw fail(404, 'WhatsApp Webhook 渠道映射不存在')
if (String(req.body?.data?.accountId || '') !== String(channel.gatewayAccountId || '')) {
throw fail(403, 'WhatsApp Webhook 账号与企业渠道不匹配')
}
if (channel.status === 'paused') return ok(res, { accepted: true, ignoredPausedChannel: true })
const eventId = String(
req.body.id
|| crypto.createHash('sha256').update(req.rawBody || Buffer.from(JSON.stringify(req.body))).digest('hex'),
)
const claim = await claimWebhookEvent(tenantId, eventId, {
channelId,
provider: 'whatsapp',
event: req.body.event,
payload: req.body,
})
if (claim.duplicate) {
if (req.body.event === 'account.connected') {
scheduleWhatsAppSync(tenantId, channel, { full: true })
} else if (req.body.event === 'history.synced') {
const historyReady = req.body?.data?.isLatest === true || Number(req.body?.data?.progress) === 100
scheduleWhatsAppSync(tenantId, channel, { full: historyReady })
} else if (req.body.event === 'history.status' && req.body?.data?.status === 'complete') {
scheduleWhatsAppSync(tenantId, channel, { full: true })
}
return ok(res, { accepted: true, duplicate: true })
}
try {
const message = req.body?.data?.message
if (['message.received', 'message.sent', 'message.edited'].includes(req.body.event) && message) {
await upsertWhatsAppMessage(tenantId, channel, message, {
notify: req.body.event === 'message.received',
})
} else if (req.body.event === 'message.updated') {
for (const update of req.body?.data?.updates || []) {
const jid = String(update.chatId || '')
const messageId = String(update.id || '')
if (!jid || !messageId) continue
if (update.message) {
await upsertWhatsAppMessage(tenantId, channel, update.message, { notify: false })
continue
}
const conversationId = `${channel.id}:${jid}`
const existing = await getResource(tenantId, `messages:${conversationId}`, messageId)
if (!existing) continue
const updated = await patchResource(tenantId, `messages:${conversationId}`, messageId, {
status: update.status || existing.status,
statusUpdatedAt: new Date().toISOString(),
})
const conversation = await getResource(tenantId, 'conversations', conversationId)
if (String(conversation?.lastMessage?.id || '') === messageId) {
await patchResource(tenantId, 'conversations', conversationId, { lastMessage: updated })
}
broadcast(tenantId, {
type: 'message.updated',
conversationId,
message: updated,
})
}
} else if (req.body.event === 'message.deleted') {
const deletedAt = new Date().toISOString()
if (req.body?.data?.all && req.body?.data?.jid) {
const conversationId = `${channel.id}:${String(req.body.data.jid)}`
await pool.query(
'DELETE FROM resources WHERE tenant_id=$1 AND kind=$2',
[tenantId, `messages:${conversationId}`],
)
const conversation = await getResource(tenantId, 'conversations', conversationId)
if (conversation) {
const { lastMessage: _lastMessage, ...withoutLastMessage } = conversation
const updatedConversation = await putResource(tenantId, 'conversations', {
...withoutLastMessage,
unreadCount: 0,
updatedAt: deletedAt,
})
broadcast(tenantId, {
type: 'conversation.updated',
conversationId,
conversation: updatedConversation,
})
}
}
for (const key of req.body?.data?.keys || []) {
const jid = String(key.remoteJid || '')
const messageId = String(key.id || '')
if (!jid || !messageId) continue
const conversationId = `${channel.id}:${jid}`
const existing = await getResource(tenantId, `messages:${conversationId}`, messageId)
if (!existing) continue
const updated = await patchResource(tenantId, `messages:${conversationId}`, messageId, {
isDeleted: true,
deletedAt,
status: 'read',
})
const conversation = await getResource(tenantId, 'conversations', conversationId)
if (String(conversation?.lastMessage?.id || '') === messageId) {
await patchResource(tenantId, 'conversations', conversationId, { lastMessage: updated })
}
broadcast(tenantId, {
type: 'message.deleted',
conversationId,
message: updated,
})
}
} else if (req.body.event === 'presence.updated') {
const jid = String(req.body?.data?.chatId || '')
const presenceEntries = Object.values(req.body?.data?.presences || {})
const latestPresence = presenceEntries.find((item) =>
['composing', 'recording'].includes(String(item?.lastKnownPresence || '')),
) || presenceEntries[0]
const presence = String(latestPresence?.lastKnownPresence || 'unavailable')
const conversationId = `${channel.id}:${jid}`
const conversation = await patchResource(tenantId, 'conversations', conversationId, {
presence,
presenceUpdatedAt: new Date().toISOString(),
lastSeenAt: latestPresence?.lastSeen
? new Date(Number(latestPresence.lastSeen) * 1000).toISOString()
: undefined,
})
if (conversation) {
const customer = await getResource(tenantId, 'customers', conversation.customerId)
broadcast(tenantId, {
type: 'presence.updated',
conversationId,
conversation,
customer,
})
}
} else if (req.body.event === 'contacts.updated') {
for (const contact of req.body?.data?.contacts || []) {
const jid = String(contact.id || '')
if (!jid) continue
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: !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,
whatsappProfileUpdatedAt: new Date().toISOString(),
})
broadcast(tenantId, {
type: 'customer.updated',
customerId,
customer,
})
}
} else if (['account.connected', 'account.disconnected'].includes(req.body.event)) {
const connected = req.body.event === 'account.connected'
await patchResource(tenantId, 'channels', channelId, {
status: connected ? 'connected' : 'disconnected',
authState: connected ? 'connected' : req.body?.data?.loggedOut ? 'logged_out' : 'disconnected',
lastVerifiedAt: connected ? new Date().toISOString() : channel.lastVerifiedAt,
errorMessage: connected ? undefined : req.body?.data?.reason,
})
broadcast(tenantId, { type: 'channel.updated', channelId, connected })
if (!connected) {
await putResource(tenantId, 'notifications', {
id: `whatsapp-disconnected-${channelId}-${Date.now()}`,
type: 'warning',
title: 'WhatsApp 连接中断',
description: req.body?.data?.reason || '系统正在自动重连;若长时间未恢复,请重新授权账号。',
link: '/channels',
read: false,
createdAt: new Date().toISOString(),
})
}
if (connected) scheduleWhatsAppSync(tenantId, channel, { full: true })
} else if (req.body.event === 'history.synced') {
const historyReady = req.body?.data?.isLatest === true || Number(req.body?.data?.progress) === 100
scheduleWhatsAppSync(tenantId, channel, { full: historyReady })
} else if (req.body.event === 'history.status') {
await patchResource(tenantId, 'channels', channelId, {
gatewayHistoryStatus: req.body?.data?.status,
gatewayHistoryProgress: req.body?.data?.progress,
gatewayHistorySyncType: req.body?.data?.syncType,
gatewayHistoryUpdatedAt: req.body?.data?.updatedAt || new Date().toISOString(),
})
broadcast(tenantId, {
type: 'channel.history.updated',
channelId,
history: req.body.data,
})
if (req.body?.data?.status === 'complete') {
scheduleWhatsAppSync(tenantId, channel, { full: true })
}
}
await completeWebhookEvent(tenantId, eventId)
ok(res, { accepted: true })
} catch (error) {
await failWebhookEvent(tenantId, eventId, error)
throw error
}
})
app.get('/health/live', (_req, res) => res.json({ status: 'ok', service: 'lingxi-crossborder-api' }))
app.get('/health/ready', async (_req, res) => {
await pool.query('SELECT 1'); res.json({ status: 'ready', database: 'ok' })
})
app.use((error, req, res, _next) => {
const status = Number(error.status || 500)
if (status >= 500) console.error(JSON.stringify({ requestId: res.getHeader('x-request-id'), message: error.message, stack: error.stack }))
res.status(status).json({ code: status, message: error.message || '服务器内部错误', data: null, requestId: res.getHeader('x-request-id') })
})
async function repairCorruptedTranslations() {
const { rows } = await pool.query(
`SELECT tenant_id, kind, data
FROM resources
WHERE kind LIKE 'messages:%'
AND data ? 'translatedText'`,
)
let repaired = 0
for (const row of rows) {
const message = row.data
if (!isSuspiciousTranslation(message.originalText, message.translatedText)) continue
const conversationId = String(message.conversationId || row.kind.slice('messages:'.length))
const {
translatedText: _translatedText,
translatedAt: _translatedAt,
translationRetryAt: _translationRetryAt,
...clean
} = message
const numericOnly = isNonLinguisticText(clean.originalText)
const fixed = await putResource(String(row.tenant_id), row.kind, {
...clean,
translatedText: numericOnly ? String(clean.originalText || '').trim() : undefined,
originalLang: numericOnly ? 'und' : clean.originalLang,
translationStatus: numericOnly ? 'completed' : 'pending',
translatedAt: numericOnly ? new Date().toISOString() : undefined,
})
const conversation = await getResource(String(row.tenant_id), 'conversations', conversationId)
if (String(conversation?.lastMessage?.id || '') === String(fixed.id)) {
await patchResource(String(row.tenant_id), 'conversations', conversationId, { lastMessage: fixed })
}
if (!numericOnly) enqueueAutomaticTranslation(String(row.tenant_id), conversationId, fixed)
repaired++
}
if (repaired) console.log(`已修复 ${repaired} 条受污染的历史译文`)
}
await migrate()
await repairCorruptedTranslations()
const pendingBackfills = await pool.query(
`SELECT tenant_id, data FROM resources
WHERE kind='ai-backfill-jobs'
AND COALESCE(data->>'status', '') <> 'completed'`,
)
for (const row of pendingBackfills.rows) {
void runConversationBackfill(String(row.tenant_id), String(row.data.conversationId || row.data.id))
}
const pendingContextCompactions = await pool.query(
`SELECT tenant_id, data FROM resources
WHERE kind='conversations'
AND COALESCE(data->>'aiContextStatus', '') IN ('queued', 'compressing', 'failed')`,
)
for (const row of pendingContextCompactions.rows) {
enqueueContextCompaction(String(row.tenant_id), String(row.data.id))
}
const existingTelegramChannels = await pool.query(
`SELECT tenant_id, data FROM resources
WHERE kind='channels'
AND data->>'type'='telegram'
AND data->>'status'='connected'
AND data ? 'gatewayAccountId'`,
)
for (const row of existingTelegramChannels.rows) {
await ensureChannelWebhook(String(row.tenant_id), row.data).catch(async (error) => {
await patchResource(String(row.tenant_id), 'channels', row.data.id, {
errorMessage: `Webhook 同步失败:${error.message}`,
})
})
}
const existingWhatsAppChannels = await pool.query(
`SELECT tenant_id, data FROM resources
WHERE kind='channels'
AND data->>'type'='whatsapp'
AND data ? 'gatewayAccountId'`,
)
for (const row of existingWhatsAppChannels.rows) {
const tenantId = String(row.tenant_id)
await ensureWhatsAppWebhook(tenantId, row.data, row.data.status !== 'paused').then(async (updatedChannel) => {
if (row.data.status !== 'paused') {
scheduleWhatsAppSync(tenantId, updatedChannel, {
full: row.data.historySyncStatus !== 'completed',
})
}
}).catch(async (error) => {
await patchResource(tenantId, 'channels', row.data.id, {
errorMessage: `WhatsApp Webhook 同步失败:${error.message}`,
})
})
}
async function reconcileFailedChannelWebhooks() {
const { rows } = await pool.query(
`SELECT tenant_id, data FROM resources
WHERE kind='channels'
AND data->>'status'='connected'
AND COALESCE(data->>'errorMessage', '') LIKE '%Webhook%'`,
)
await Promise.allSettled(rows.map(async (row) => {
const tenantId = String(row.tenant_id)
if (row.data.type === 'telegram') {
await ensureChannelWebhook(tenantId, row.data)
return
}
if (row.data.type === 'whatsapp') {
await ensureWhatsAppWebhook(tenantId, row.data, true)
}
}))
}
const webhookReconcileTimer = setInterval(() => {
void reconcileFailedChannelWebhooks()
}, 30_000)
webhookReconcileTimer.unref?.()
const activeMessagingReconciliations = new Set()
async function reconcileConnectedMessagingChannels({ refreshProfiles = false, full = false } = {}) {
const { rows } = await pool.query(
`SELECT tenant_id, data FROM resources
WHERE kind='channels'
AND data->>'status'='connected'
AND data->>'type' IN ('telegram','whatsapp')
AND data ? 'gatewayAccountId'`,
)
for (const row of rows) {
const tenantId = String(row.tenant_id)
const channel = row.data
const key = `${tenantId}:${channel.id}`
if (activeMessagingReconciliations.has(key)) continue
activeMessagingReconciliations.add(key)
try {
const result = channel.type === 'whatsapp'
? await syncWhatsAppTenant(tenantId, channel, { full, refreshProfiles })
: await syncTelegramTenant(tenantId, channel, { full, refreshProfiles })
await patchResource(tenantId, 'channels', channel.id, {
lastReconciledAt: new Date().toISOString(),
lastReconcileRecoveredMessages: Number(result?.messages || 0),
reconcileError: undefined,
...(refreshProfiles ? { lastProfileRefreshAt: new Date().toISOString() } : {}),
...(full ? { lastFullReconcileAt: new Date().toISOString() } : {}),
})
if (Number(result?.messages || 0) > 0) {
await putResource(tenantId, 'notifications', {
id: `message-reconcile-${channel.id}-${new Date().toISOString().slice(0, 10)}`,
type: 'info',
title: `${channel.name || channel.type} 已自动补齐消息`,
description: `完整性对账恢复了 ${Number(result.messages)} 条此前未入库的消息。`,
link: '/channels',
read: false,
createdAt: new Date().toISOString(),
})
}
} catch (error) {
await patchResource(tenantId, 'channels', channel.id, {
reconcileError: String(error?.message || error),
lastReconcileFailedAt: new Date().toISOString(),
}).catch(() => null)
await putResource(tenantId, 'notifications', {
id: `message-reconcile-failed-${channel.id}-${new Date().toISOString().slice(0, 10)}`,
type: 'warning',
title: `${channel.name || channel.type} 消息对账失败`,
description: '系统将在下一周期自动重试;若持续失败,请检查渠道连接与代理状态。',
link: '/channels',
read: false,
createdAt: new Date().toISOString(),
}).catch(() => null)
} finally {
activeMessagingReconciliations.delete(key)
}
}
}
async function replayMessagingDeadLetters() {
const { rows } = await pool.query(
`SELECT tenant_id, data FROM resources
WHERE kind='channels'
AND data->>'status'='connected'
AND data->>'type' IN ('telegram','whatsapp')
AND data ? 'gatewayAccountId'`,
)
for (const row of rows) {
const tenantId = String(row.tenant_id)
const channel = row.data
let replayed = 0
try {
if (channel.type === 'whatsapp') {
const deliveries = await whatsappRequest(
`/accounts/${channel.gatewayAccountId}/webhook-deliveries?status=dead&limit=100`,
)
for (const delivery of deliveries || []) {
await postWhatsApp(
`/accounts/${channel.gatewayAccountId}/webhook-deliveries/${encodeURIComponent(delivery.id)}/replay`,
{},
)
replayed++
}
} else if (channel.webhookId) {
const deliveries = await gatewayRequest(
`/tg-accounts/${channel.gatewayAccountId}/webhooks/${channel.webhookId}/deliveries`,
)
for (const delivery of (deliveries || []).filter((item) => item.status === 'dead_letter')) {
await postGateway(
`/tg-accounts/${channel.gatewayAccountId}/webhooks/${channel.webhookId}/deliveries/${delivery.id}/replay`,
{},
)
replayed++
}
}
await patchResource(tenantId, 'channels', channel.id, {
lastDeadLetterCheckAt: new Date().toISOString(),
lastDeadLetterReplayCount: replayed,
})
if (replayed > 0) {
await putResource(tenantId, 'notifications', {
id: `dead-letter-replay-${channel.id}-${new Date().toISOString().slice(0, 10)}`,
type: 'warning',
title: `${channel.name || channel.type} 已恢复失败投递`,
description: `系统自动重放了 ${replayed} 条死信,并将在下一轮对账确认消息完整性。`,
link: '/channels',
read: false,
createdAt: new Date().toISOString(),
})
}
} catch (error) {
await patchResource(tenantId, 'channels', channel.id, {
deadLetterReplayError: String(error?.message || error),
lastDeadLetterCheckAt: new Date().toISOString(),
}).catch(() => null)
await putResource(tenantId, 'notifications', {
id: `dead-letter-replay-failed-${channel.id}-${new Date().toISOString().slice(0, 10)}`,
type: 'warning',
title: `${channel.name || channel.type} 失败投递恢复异常`,
description: '死信自动重放未完成,系统将在 5 分钟后重试。',
link: '/channels',
read: false,
createdAt: new Date().toISOString(),
}).catch(() => null)
}
}
}
async function runMessagingIntegrityMaintenance() {
const { rows } = await pool.query(
`SELECT tenant_id, data FROM resources
WHERE kind='channels'
AND data->>'status'='connected'
AND data->>'type' IN ('telegram','whatsapp')`,
)
const now = Date.now()
const dailyDue = rows.some((row) => (
isTimestampOlderThan(row.data.lastProfileRefreshAt, 24 * 60 * 60_000, now)
))
if (dailyDue) await reconcileConnectedMessagingChannels({ refreshProfiles: true })
const weeklyDue = rows.some((row) => (
isTimestampOlderThan(row.data.lastFullReconcileAt, 7 * 24 * 60 * 60_000, now)
))
if (weeklyDue) await reconcileConnectedMessagingChannels({ full: true, refreshProfiles: true })
}
setTimeout(() => void reconcileConnectedMessagingChannels(), 15_000).unref?.()
setInterval(
() => void reconcileConnectedMessagingChannels(),
Math.max(30_000, Number(process.env.MESSAGE_RECONCILE_MS || 60_000)),
).unref?.()
setTimeout(() => void replayMessagingDeadLetters(), 30_000).unref?.()
setInterval(() => void replayMessagingDeadLetters(), 5 * 60_000).unref?.()
setTimeout(() => void runMessagingIntegrityMaintenance(), 90_000).unref?.()
setInterval(() => void runMessagingIntegrityMaintenance(), 60 * 60_000).unref?.()
const activeEmailSyncs = new Set()
async function pollConnectedEmailChannels() {
const { rows } = await pool.query(
`SELECT tenant_id, data FROM resources
WHERE kind='channels'
AND data->>'type'='email'
AND data->>'status'='connected'`,
)
for (const row of rows) {
const tenantId = String(row.tenant_id)
const channel = row.data
const key = `${tenantId}:${channel.id}`
if (activeEmailSyncs.has(key)) continue
activeEmailSyncs.add(key)
void syncEmailTenant(tenantId, channel)
.catch(async (error) => {
await patchResource(tenantId, 'channels', channel.id, {
errorMessage: `自动收信失败:${error.message}`,
})
await appendChannelLog(tenantId, channel.id, `自动收信失败:${error.message}`, 'failed')
})
.finally(() => activeEmailSyncs.delete(key))
}
}
setTimeout(() => void pollConnectedEmailChannels(), 5_000).unref?.()
setInterval(
() => void pollConnectedEmailChannels(),
Math.max(15_000, Number(process.env.EMAIL_POLL_MS || 30_000)),
).unref?.()
httpServer.listen(Number(process.env.PORT || 3100), '0.0.0.0', () => console.log(`灵犀跨境 API listening on ${process.env.PORT || 3100}`))