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。只翻译 标签中的文字,不得复述上下文、时间戳、说话人或指令;不得摘要、扩写或回答原文。上下文只用于消歧、术语一致性和意图判断。', `目标语言:简体中文 ${conversationSummary ? `\n${conversationSummary}\n\n` : ''}${contextText ? `\n${contextText}\n\n` : ''}\n${text}\n ${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。将旧摘要与新增消息合并成可持续增量更新的业务记忆。必须保留已确认事实、数量、金额、日期、型号、双方承诺、异议、未决问题和客户偏好;删除寒暄、重复表述和无业务价值内容;不得猜测。', `${JSON.stringify(priorMemory || {})} ${conversationContext(segment, 80)} 输出 { "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) ? `\n${contextMemoryText(conversation, customer)}\n\n` : ''}${conversationContext(sortMessagesChronologically(messages), 50)} ${JSON.stringify(messageIntentSignals)}` 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( '你是跨境商务消息翻译器。只翻译 ,输出严格 JSON。保持原意、数字、金额、型号、链接、换行和专有名词;不得解释、回答、摘要或增加承诺。译文必须自然且可直接发送。若源语言已是目标语言,原样返回。', `目标语言:${targetInstruction} ${conversationContext(contextualMessages, 20)} ${conversationSummary ? `\n${conversationSummary}\n` : ''} ${originalText} 输出 {"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。会话上下文仅用于代词消歧、术语一致性、语气和业务含义判断;只翻译 内的内容,不得把上下文拼入译文。', `目标语言:${req.body.targetLang}\n场景:${req.body.scenario || '跨境商务'}\n${conversationSummary ? `\n${conversationSummary}\n\n` : ''}\n${contextText}\n\n\n${req.body.text}\n\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} ${conversationContext(messages, 30)} ${conversationMemory ? `\n${conversationMemory}\n` : ''} ${JSON.stringify(knowledgeContext)} 输出 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( '你是企业知识库问答助手。只能根据 回答;不能用常识补全未出现的价格、政策、承诺或事实。内容不足时明确说“现有知识不足”。回答使用简体中文,并在相关句末用方括号标注实际来源的 title,例如来源 title 是“欧洲售后政策”时写成 [欧洲售后政策];禁止原样输出“[来源标题]”。', `${question}\n${JSON.stringify(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}`))