5436 lines
247 KiB
JavaScript
5436 lines
247 KiB
JavaScript
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}`))
|