diff --git a/.env.example b/.env.example index 0e25947..8ce56a2 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,12 @@ TELEGRAM_GATEWAY_SERVICE_EMAIL=replace-with-gateway-service-email TELEGRAM_GATEWAY_SERVICE_PASSWORD=replace-with-random-service-password TELEGRAM_GATEWAY_ACCOUNT_ID=replace-with-default-account-id TELEGRAM_WEBHOOK_SECRET=replace-with-random-webhook-secret + +# 普通 WhatsApp 账户通过“关联设备”接入。两个值均至少 32 位且必须彼此不同。 +WHATSAPP_GATEWAY_TOKEN=replace-with-at-least-32-random-characters +WHATSAPP_WEBHOOK_SECRET=replace-with-another-32-random-secret +WHATSAPP_LOG_LEVEL=info + PUBLIC_WEBHOOK_BASE_URL=http://host.docker.internal:8088 APP_PUBLIC_URL=http://127.0.0.1:8088 diff --git a/docker-compose.yml b/docker-compose.yml index 11f123c..5809e66 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,21 @@ services: retries: 10 restart: unless-stopped + whatsapp-gateway: + build: + context: ./whatsapp-gateway + environment: + PORT: 3200 + DATA_DIR: /data + WHATSAPP_GATEWAY_TOKEN: ${WHATSAPP_GATEWAY_TOKEN:?WHATSAPP_GATEWAY_TOKEN is required} + LOG_LEVEL: ${WHATSAPP_LOG_LEVEL:-info} + volumes: + - lingxi_whatsapp_data:/data + dns: + - 1.1.1.1 + - 8.8.8.8 + restart: unless-stopped + api: build: context: ./server @@ -33,6 +48,10 @@ services: TELEGRAM_GATEWAY_SERVICE_PASSWORD: ${TELEGRAM_GATEWAY_SERVICE_PASSWORD:?TELEGRAM_GATEWAY_SERVICE_PASSWORD is required} TELEGRAM_GATEWAY_ACCOUNT_ID: ${TELEGRAM_GATEWAY_ACCOUNT_ID:-} TELEGRAM_WEBHOOK_SECRET: ${TELEGRAM_WEBHOOK_SECRET:?TELEGRAM_WEBHOOK_SECRET is required} + WHATSAPP_GATEWAY_URL: http://whatsapp-gateway:3200 + WHATSAPP_GATEWAY_TOKEN: ${WHATSAPP_GATEWAY_TOKEN:?WHATSAPP_GATEWAY_TOKEN is required} + WHATSAPP_WEBHOOK_SECRET: ${WHATSAPP_WEBHOOK_SECRET:?WHATSAPP_WEBHOOK_SECRET is required} + WHATSAPP_WEBHOOK_BASE_URL: ${WHATSAPP_WEBHOOK_BASE_URL:-http://api:3100} PUBLIC_WEBHOOK_BASE_URL: ${PUBLIC_WEBHOOK_BASE_URL:-http://host.docker.internal:8088} APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://127.0.0.1:8088} SMTP_HOST: ${SMTP_HOST:-} @@ -56,6 +75,8 @@ services: depends_on: postgres: condition: service_healthy + whatsapp-gateway: + condition: service_healthy healthcheck: test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3100/health/ready').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"] interval: 10s @@ -77,3 +98,4 @@ services: volumes: lingxi_pgdata: + lingxi_whatsapp_data: diff --git a/package.json b/package.json index 798506a..a4c5b93 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "version": "1.0.0", "type": "module", - "description": "灵犀跨境 - Telegram 跨境 AI 客户经营平台", + "description": "灵犀跨境 - Telegram、WhatsApp 与企业邮箱 AI 客户经营平台", "scripts": { "dev": "vite", "build": "tsc --noEmit && vite build", diff --git a/server/src/index.js b/server/src/index.js index 76badf2..b9677ef 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -13,6 +13,7 @@ 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 { @@ -435,7 +436,11 @@ app.use((req, res, 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/')) return 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') @@ -773,7 +778,8 @@ 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') + : 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 @@ -923,9 +929,216 @@ app.post('/api/conversations/telegram/open', async (req, res) => { 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 customerId = `wa-${channel.gatewayAccountId}-${jid}` + const customer = await putResource(req.auth.tenantId, 'customers', withDefaults('customers', { + id: customerId, + name: identity.name || jid.split('@')[0], + phone: identity.phone || '', + avatarUrl: identity.avatarUrl || undefined, + whatsapp: jid, + whatsappChatType: identity.chatType || 'private', + channel: 'whatsapp', + source: 'whatsapp', + })) + const conversationId = `${channel.id}:${jid}` + 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 || '') @@ -1045,6 +1258,40 @@ app.get('/api/conversations/:id/messages', async (req, res) => { } ok(res, list) }) +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 @@ -1089,6 +1336,25 @@ app.get('/api/conversations/:id/messages/:messageId/attachment', async (req, res }) 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 账号') @@ -1127,6 +1393,26 @@ app.post('/api/conversations/:id/read', async (req, res) => { 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) : [] @@ -1155,6 +1441,27 @@ app.post('/api/conversations/:id/read', async (req, res) => { }) 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 @@ -1167,6 +1474,29 @@ 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 @@ -1241,6 +1571,72 @@ app.post('/api/conversations/:id/messages', async (req, res) => { 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, + translationCn: translationCn || undefined, + originalLang, + targetLang: 'en', + 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, + translationCn: translationCn || undefined, + translationStatus: translationCn ? 'completed' : 'pending', + originalLang, + 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, @@ -1287,6 +1683,72 @@ app.post('/api/conversations/:id/messages', async (req, res) => { 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, '会话不存在') @@ -1336,6 +1798,24 @@ app.post('/api/conversations/:id/media', async (req, res) => { 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) @@ -1379,6 +1859,26 @@ app.post('/api/conversations/:id/album', async (req, res) => { 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) { @@ -1399,6 +1899,20 @@ function publicChannel(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, '') @@ -1565,7 +2079,34 @@ async function syncEmailTenant(tenantId, channel, { full = false } = {}) { app.get('/api/channels', async (req, res) => { const channels = await listResources(req.auth.tenantId, 'channels') - const list = channels.map(publicChannel) + 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', @@ -1578,6 +2119,12 @@ app.get('/api/channels', async (req, res) => { 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 = [ @@ -1606,6 +2153,19 @@ async function ensureChannelWebhook(tenantId, channel) { }) 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() @@ -1621,13 +2181,14 @@ app.post('/api/channels/connect', async (req, res) => { ) 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, + 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 { @@ -1665,6 +2226,7 @@ app.post('/api/channels/email/connect', async (req, res) => { } 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), @@ -1674,6 +2236,7 @@ app.post('/api/channels/email/connect', async (req, res) => { lastUid: 0, createdAt: new Date().toISOString(), lastVerifiedAt: new Date().toISOString(), + isDefault, }) await appendChannelLog( req.auth.tenantId, @@ -1701,11 +2264,10 @@ 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) + const existing = existingChannels.find( + (item) => item.type === 'telegram' && item.gatewayAccountId && String(item.account || '') === phone, + ) if (existing) { - if (String(existing.account || '') !== phone) { - throw fail(409, `当前企业已有未完成绑定的账号 ${existing.account || ''},请继续授权或先删除该渠道`) - } let state = await postGateway(`/tg-accounts/${existing.gatewayAccountId}/auth/state`, {}) let status = String(state?.status || state?.state || existing.authState || '') if (status === 'waiting_phone') { @@ -1723,9 +2285,11 @@ app.post('/api/channels/telegram/start', async (req, res) => { } 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`, {}) @@ -1792,6 +2356,119 @@ app.post('/api/channels/telegram/:id/state', async (req, res) => { 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) @@ -1822,6 +2499,191 @@ async function resolveTelegramIdentity(channel, chatId, fallback = {}, existing } } +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 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 customerId = `wa-${channel.gatewayAccountId}-${jid}` + const existingCustomer = await getResource(tenantId, 'customers', customerId) + const identity = options.identity || await whatsappRequest( + `/accounts/${channel.gatewayAccountId}/chats/${encodeURIComponent(jid)}/identity`, + ).catch(() => null) + const customer = await putResource(tenantId, 'customers', withDefaults('customers', { + ...(existingCustomer || {}), + id: customerId, + name: String(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', + 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 syncWhatsAppTenant(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 + for (const chat of chats || []) { + const jid = String(chat.id || '') + if (!jid) continue + if (options.full === true) { + await whatsappRequest( + `/accounts/${channel.gatewayAccountId}/chats/${encodeURIComponent(jid)}/history/sync`, + { + method: 'POST', + body: JSON.stringify({ pages: 20, count: 50 }), + }, + ).catch(() => null) + } + const identity = await whatsappRequest( + `/accounts/${channel.gatewayAccountId}/chats/${encodeURIComponent(jid)}/identity`, + ).catch(() => ({ name: chat.name || jid.split('@')[0] })) + const history = await whatsappRequest( + `/accounts/${channel.gatewayAccountId}/chats/${encodeURIComponent(jid)}/history?limit=2000`, + ).catch(() => ({ messages: [] })) + for (const raw of history.messages || []) { + const result = await upsertWhatsAppMessage(tenantId, channel, raw, { identity, notify: false }) + if (result?.isNew) messagesSynced++ + } + const conversationId = `${channel.id}:${jid}` + const current = await getResource(tenantId, 'conversations', conversationId) + if (current) { + await patchResource(tenantId, 'conversations', conversationId, { + unreadCount: Number(chat.unreadCount || current.unreadCount || 0), + historySyncedAt: new Date().toISOString(), + }) + } + } + await patchResource(tenantId, 'channels', channel.id, { + lastSyncAt: new Date().toISOString(), + status: 'connected', + authState: 'connected', + errorMessage: undefined, + }) + 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 syncTelegramTenant(tenantId, channel) { const gateway = await gatewayRequest(`/tg-accounts/${channel.gatewayAccountId}/chats/sync`) const chats = await gatewayRequest(`/tg-accounts/${channel.gatewayAccountId}/chats?limit=100`) @@ -1925,6 +2787,10 @@ app.post('/api/channels/:id/sync', async (req, res) => { 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)) }) @@ -1937,10 +2803,208 @@ app.post('/api/channels/test', async (req, res) => { 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, '渠道不存在') @@ -1959,6 +3023,9 @@ app.patch('/api/channels/:id', async (req, res) => { 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) @@ -1977,7 +3044,13 @@ app.delete('/api/channels/:id', async (req, res) => { 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 }) }) @@ -2657,6 +3730,189 @@ app.post('/internal/webhooks/telegram/:tenantId/:channelId', async (req, res) => ok(res, { accepted: true }) }) +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'), + ) + if (await getResource(tenantId, 'webhook-events', eventId)) { + return ok(res, { accepted: true, duplicate: true }) + } + await putResource(tenantId, 'webhook-events', { + id: eventId, + channelId, + provider: 'whatsapp', + event: req.body.event, + payload: req.body, + receivedAt: new Date().toISOString(), + }) + 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 customerId = `wa-${channel.gatewayAccountId}-${jid}` + const existing = await getResource(tenantId, 'customers', customerId) + if (!existing) continue + const identity = await whatsappRequest( + `/accounts/${channel.gatewayAccountId}/chats/${encodeURIComponent(jid)}/identity`, + ).catch(() => null) + const customer = await patchResource(tenantId, 'customers', customerId, { + 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) void syncWhatsAppTenant(tenantId, channel).catch(() => null) + } else if (req.body.event === 'history.synced') { + void syncWhatsAppTenant(tenantId, channel).catch(() => null) + } + ok(res, { accepted: true }) +}) + 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' }) @@ -2720,6 +3976,48 @@ for (const row of existingTelegramChannels.rows) { }) }) } +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') { + void syncWhatsAppTenant(tenantId, updatedChannel).catch(() => null) + } + }).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 activeEmailSyncs = new Set() async function pollConnectedEmailChannels() { diff --git a/server/src/whatsapp.js b/server/src/whatsapp.js new file mode 100644 index 0000000..1ba246d --- /dev/null +++ b/server/src/whatsapp.js @@ -0,0 +1,38 @@ +const base = () => (process.env.WHATSAPP_GATEWAY_URL || 'http://whatsapp-gateway:3200').replace(/\/$/, '') + +export async function whatsappRequest(path, options = {}) { + const token = String(process.env.WHATSAPP_GATEWAY_TOKEN || '') + if (token.length < 24) { + throw Object.assign(new Error('WhatsApp 网关令牌尚未配置'), { status: 503 }) + } + const response = await fetch(`${base()}${path}`, { + ...options, + headers: { + ...(options.body instanceof FormData ? {} : { 'content-type': 'application/json' }), + authorization: `Bearer ${token}`, + ...(options.headers || {}), + }, + }) + const raw = await response.text() + let result + try { result = raw ? JSON.parse(raw) : null } catch { result = raw } + if (!response.ok) { + throw Object.assign( + new Error(result?.message || `WhatsApp 网关请求失败 (${response.status})`), + { status: response.status, data: result?.data }, + ) + } + return result?.data ?? result +} + +export function postWhatsApp(path, body) { + return whatsappRequest(path, { method: 'POST', body: JSON.stringify(body) }) +} + +export function putWhatsApp(path, body) { + return whatsappRequest(path, { method: 'PUT', body: JSON.stringify(body) }) +} + +export function uploadWhatsApp(path, formData) { + return whatsappRequest(path, { method: 'POST', body: formData }) +} diff --git a/server/test/business.test.js b/server/test/business.test.js index c15c770..4ca0825 100644 --- a/server/test/business.test.js +++ b/server/test/business.test.js @@ -50,5 +50,7 @@ test('Email 渠道凭据使用认证加密且可正确解密', () => { assert.notEqual(encrypted, 'app-password-123') assert.match(encrypted, /^v1:/) assert.equal(decryptEmailPassword(encrypted), 'app-password-123') - assert.throws(() => decryptEmailPassword(`${encrypted.slice(0, -1)}x`)) + const tampered = encrypted.split(':') + tampered[2] = `${tampered[2][0] === 'A' ? 'B' : 'A'}${tampered[2].slice(1)}` + assert.throws(() => decryptEmailPassword(tampered.join(':'))) }) diff --git a/src/app/router/index.tsx b/src/app/router/index.tsx index 65bba2d..809eeb4 100644 --- a/src/app/router/index.tsx +++ b/src/app/router/index.tsx @@ -1,28 +1,76 @@ -import { createBrowserRouter, Navigate, RouterProvider } from 'react-router-dom' -import { useAuthStore } from '@/app/store/auth' +import { createBrowserRouter, Navigate, RouterProvider, useRouteError } from 'react-router-dom' +import { isSessionTokenExpired, useAuthStore } from '@/app/store/auth' import { AppLayout } from '@/components/layout/AppLayout' -import type { ReactNode } from 'react' +import { useEffect, type ReactNode } from 'react' import { LoginPage } from '@/pages/auth/LoginPage' import { RegisterPage } from '@/pages/auth/RegisterPage' function RequireAuth({ children }: { children: ReactNode }) { const token = useAuthStore((s) => s.token) - if (!token) return + const logout = useAuthStore((s) => s.logout) + const expired = isSessionTokenExpired(token) + useEffect(() => { + if (token && expired) logout() + }, [expired, logout, token]) + if (expired) return return <>{children} } function GuestOnly({ children }: { children: ReactNode }) { const token = useAuthStore((s) => s.token) - if (token) return + const logout = useAuthStore((s) => s.logout) + const expired = Boolean(token) && isSessionTokenExpired(token) + useEffect(() => { + if (expired) logout() + }, [expired, logout]) + if (token && !expired) return return <>{children} } +function RouteErrorFallback() { + const error = useRouteError() + + useEffect(() => { + console.error('Route render failed', error) + }, [error]) + + return ( +
+
+
+ ! +
+

页面暂时无法显示

+

+ 当前页面遇到异常。你可以重新加载;如果问题仍然存在,请返回工作台后重试。 +

+
+ + + 返回工作台 + +
+
+
+ ) +} + const router = createBrowserRouter([ - { path: '/login', element: }, - { path: '/register', element: }, + { path: '/login', element: , errorElement: }, + { path: '/register', element: , errorElement: }, { path: '/', + errorElement: , element: ( diff --git a/src/app/store/auth.ts b/src/app/store/auth.ts index e25f75d..6783217 100644 --- a/src/app/store/auth.ts +++ b/src/app/store/auth.ts @@ -2,7 +2,7 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' import type { AuthSession, Tenant, User } from '@/types' import type { RoleKey } from '@/config/roles' -import { setTokenGetter } from '@/services/api/client' +import { setTokenGetter, setUnauthorizedHandler } from '@/services/api/client' interface AuthState { token: string | null @@ -32,6 +32,26 @@ export const useAuthStore = create()( // 让 API client 能读取 token(真实后端模式使用) setTokenGetter(() => useAuthStore.getState().token) +setUnauthorizedHandler(() => { + useAuthStore.getState().logout() + if (!window.location.pathname.startsWith('/login')) { + window.location.replace('/login?reason=session-expired') + } +}) + +export function isSessionTokenExpired(token: string | null): boolean { + if (!token) return true + try { + const encoded = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/') + const padded = encoded.padEnd(Math.ceil(encoded.length / 4) * 4, '=') + const payload = JSON.parse(window.atob(padded)) as { + exp?: number + } + return !payload.exp || payload.exp * 1_000 <= Date.now() + } catch { + return true + } +} export function useCurrentRole(): RoleKey { return useAuthStore((s) => s.user?.role ?? 'viewer') diff --git a/src/components/common/LiveDataBadge.tsx b/src/components/common/LiveDataBadge.tsx index 7e007c5..6f43a24 100644 --- a/src/components/common/LiveDataBadge.tsx +++ b/src/components/common/LiveDataBadge.tsx @@ -8,7 +8,7 @@ export function LiveDataBadge({ className, text = '实时数据' }: { className? 'inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700', className, )} - title="指标由当前企业真实业务数据实时计算" + title="指标实时更新" > {text} diff --git a/src/components/customer/CustomerProfileRadar.tsx b/src/components/customer/CustomerProfileRadar.tsx index d0f19a8..bbfd628 100644 --- a/src/components/customer/CustomerProfileRadar.tsx +++ b/src/components/customer/CustomerProfileRadar.tsx @@ -37,7 +37,7 @@ export function profileRadarData(customer: Customer) { return [ { dimension: '购买意向', score: Math.max(0, Math.min(100, Number(customer.intentScore || 0))), hint: '基于询盘强度与购买信号' }, - { dimension: '成交可能', score: Number(profile.dealProbability || 0), hint: 'AI 综合预测成交概率' }, + { dimension: '成交可能', score: Number(profile.dealProbability || 0), hint: '根据当前客户资料和会话信号估算' }, { dimension: '决策能力', score: qualitativeScore(profile.decisionPower), hint: profile.decisionPower || '尚待确认采购决策权' }, { dimension: '需求紧迫', score: qualitativeScore(profile.urgency), hint: profile.urgency || '尚待确认采购时间表' }, { dimension: '客户匹配', score: fitScore, hint: '画像字段、需求与产品匹配度' }, @@ -80,7 +80,7 @@ export function CustomerProfileRadar({

客户价值雷达

-

AI 六维购买力画像 · 随会话实时更新

+

基于当前客户资料和会话信号

@@ -126,7 +126,7 @@ export function CustomerProfileRadar({ [`${value} / 100`, '画像指数']} - labelFormatter={(label) => `${label} · AI 评估`} + labelFormatter={(label) => `${label} · 参考值`} contentStyle={{ borderRadius: 14, border: '1px solid #dbeafe', @@ -135,7 +135,7 @@ export function CustomerProfileRadar({ }} /> generateReplies(conversationId), onSuccess: (data) => { setItems(Array.isArray(data) ? data : []) - toast.success('AI 回复建议已生成', '根据客户画像、知识库和历史成功回复综合生成') + toast.success('回复建议已生成', '已参考客户资料、知识库和历史回复') }, onError: (e) => toast.error(errorMessage(e)), }) @@ -43,7 +43,7 @@ export function AiReplies({

- AI 智能回复 + 回复建议

@@ -169,7 +169,7 @@ export function AiReplies({ {!items && !mutation.isPending && (
-

结合客户画像、知识库和历史沟通生成三种策略

+

生成三种回复建议

不会自动发送
)} diff --git a/src/components/inbox/Composer.tsx b/src/components/inbox/Composer.tsx index f1487de..9a1f380 100644 --- a/src/components/inbox/Composer.tsx +++ b/src/components/inbox/Composer.tsx @@ -75,7 +75,12 @@ export function Composer({ setSending(true) try { await onMedia(Array.from(files), kind) - toast.success(`${files.length} 个文件已发送到${channel === 'email' ? '客户邮箱' : ' Telegram'}`) + const destination = channel === 'email' + ? '客户邮箱' + : channel === 'whatsapp' + ? ' WhatsApp' + : ' Telegram' + toast.success(`${files.length} 个文件已发送到${destination}`) } catch { toast.error('媒体发送失败') } finally { diff --git a/src/components/inbox/ConversationInsightBar.tsx b/src/components/inbox/ConversationInsightBar.tsx index cbca593..1b762c4 100644 --- a/src/components/inbox/ConversationInsightBar.tsx +++ b/src/components/inbox/ConversationInsightBar.tsx @@ -23,7 +23,7 @@ export function ConversationInsightBar({ onOpenProfile: () => void }) { const stage = validStages.has(customer.stage) ? customer.stage : 'initial' - const summary = customer.aiProfile?.summary || conversation.summary || 'AI 正在根据会话完善客户画像' + const summary = customer.aiProfile?.summary || conversation.summary || '收到更多客户消息后将补充客户画像' return (
- AI 客户洞察 + 客户洞察

{summary}

diff --git a/src/components/inbox/ConversationList.tsx b/src/components/inbox/ConversationList.tsx index 15ee5fb..316aea2 100644 --- a/src/components/inbox/ConversationList.tsx +++ b/src/components/inbox/ConversationList.tsx @@ -1,5 +1,5 @@ import { useRef, useState } from 'react' -import { Eraser, Mail, Pin, Plus, Search, Send, ShieldAlert, Trash2 } from 'lucide-react' +import { Eraser, Mail, MessageCircle, Pin, Plus, Search, Send, ShieldAlert, Trash2 } from 'lucide-react' import { useQuery } from '@tanstack/react-query' import { getTeam } from '@/services/api/org' import type { ConversationListItem } from '@/services/api/conversations' @@ -26,7 +26,7 @@ const FILTERS = [ ] export function ConversationList({ - items, + items = [], loading, activeId, onSelect, @@ -75,8 +75,8 @@ export function ConversationList({
) : ( -

{analysis || 'AI 正在根据会话完善客户画像。'}

+

{analysis || '收到更多客户消息后将补充客户画像。'}

)} {/* AI 提取关键信息 */} {Object.values(customer.aiProfile || {}).some(Boolean) && ( @@ -240,7 +240,7 @@ export function InsightPanel({ {/* 推荐动作 */}
-

AI 推荐下一步

+

下一步建议

{customer.aiProfile.nextActions.map((a) => (

- 把分散在全球渠道的客户对话, + 集中处理客户消息,
- 沉淀为可成交的企业资产。 + 更快回复和跟进。

{[ - { icon: MessageCircle, text: 'WhatsApp / Telegram / 邮件 / 独立站,统一收件箱' }, - { icon: Languages, text: 'AI 翻译与行业术语,跨语种沟通专业自然' }, - { icon: ShieldCheck, text: '情感分析、风险预警、文化敏感检测' }, - { icon: UsersRound, text: '客户自动建档、意向评分与团队协同跟进' }, + { icon: MessageCircle, text: '集中处理 WhatsApp、Telegram、邮件和独立站消息' }, + { icon: Languages, text: '消息翻译与双语对照,支持行业术语' }, + { icon: ShieldCheck, text: '识别客户情绪、沟通意图和风险信号' }, + { icon: UsersRound, text: '管理客户资料、意向评分和团队跟进' }, ].map((f) => (
@@ -209,9 +216,9 @@ export function LoginPage() {

使用本服务前,请阅读并确认以下数据处理与服务条款。

1. 我们仅收集为您提供跨境沟通与客户管理服务所必需的信息,包括账号信息、企业信息与您主动录入的客户数据。

-

2. 您的会话数据采用全链路加密存储,未经您授权,我们不会向任何第三方披露。

-

3. 您可以随时导出或删除您的数据;企业注销后,相关数据将在 30 天内完成清理。

-

4. 使用 AI 功能时,相关内容仅用于为您生成翻译与回复建议,不会用于其他用途。

+

2. 我们按照已公布的安全措施处理会话数据;涉及第三方服务时,处理范围以隐私政策及相关服务条款为准。

+

3. 数据导出、删除和账号注销申请按照正式发布的隐私政策处理。

+

4. 使用翻译和回复建议时,相关内容可能发送至已配置的模型服务商处理,具体范围以隐私政策为准。

diff --git a/src/pages/auth/RegisterPage.tsx b/src/pages/auth/RegisterPage.tsx index ae1d6fd..998b2ef 100644 --- a/src/pages/auth/RegisterPage.tsx +++ b/src/pages/auth/RegisterPage.tsx @@ -51,7 +51,7 @@ export function RegisterPage() { const session = await registerApi(values) setSession(session) toast.success('企业创建成功', '接下来请完成企业初始化配置') - navigate('/channels?setup=telegram') + navigate('/channels?setup=choose') } catch (e) { toast.error(errorMessage(e)) } finally { diff --git a/src/pages/billing/BillingPage.tsx b/src/pages/billing/BillingPage.tsx index 204bc2f..8569637 100644 --- a/src/pages/billing/BillingPage.tsx +++ b/src/pages/billing/BillingPage.tsx @@ -33,7 +33,7 @@ export function BillingPage() { return (
- + {info && ( diff --git a/src/pages/channels/ChannelsPage.tsx b/src/pages/channels/ChannelsPage.tsx index 4e8feb4..5dc1924 100644 --- a/src/pages/channels/ChannelsPage.tsx +++ b/src/pages/channels/ChannelsPage.tsx @@ -1,20 +1,23 @@ -import { useEffect, useRef, useState } from 'react' +import { useState } from 'react' import { useNavigate, useSearchParams } from 'react-router-dom' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Activity, ArrowUpRight, + AlertTriangle, Cable, CheckCircle2, History, Mail, + MessageCircle, + Plus, MessageSquareText, Pause, Play, RefreshCw, Settings, ShieldCheck, - Smartphone, + Star, Trash2, Workflow, } from 'lucide-react' @@ -34,7 +37,8 @@ import { ChannelIcon } from '@/components/common/ChannelIcon' import { getChannels, testChannel, updateChannel, deleteChannel, getChannelLogs, connectEmailChannel, startTelegramLogin, submitTelegramCode, submitTelegramPassword, - getTelegramLoginState, syncTelegramChannel, + getTelegramLoginState, syncChannel, startWhatsAppLogin, getWhatsAppLoginState, + setDefaultChannel, } from '@/services/api/channels' import { CHANNEL_META } from '@/config/constants' import { formatRelative } from '@/utils/format' @@ -60,13 +64,21 @@ export function ChannelsPage() { const navigate = useNavigate() const [searchParams, setSearchParams] = useSearchParams() const queryClient = useQueryClient() - const { data: channels } = useQuery({ queryKey: ['channels'], queryFn: getChannels }) + const { data: channels } = useQuery({ + queryKey: ['channels'], + queryFn: getChannels, + refetchInterval: 15_000, + }) const [wizard, setWizard] = useState<{ open: boolean; type: ChannelType | null; step: number }>({ open: false, type: null, step: 0 }) const [telegramAccount, setTelegramAccount] = useState('') const [telegramCode, setTelegramCode] = useState('') const [telegramPassword, setTelegramPassword] = useState('') const [loginChannelId, setLoginChannelId] = useState('') const [authState, setAuthState] = useState('') + const [whatsappAccount, setWhatsAppAccount] = useState('') + const [whatsappChannelId, setWhatsAppChannelId] = useState('') + const [whatsappPairingCode, setWhatsAppPairingCode] = useState('') + const [whatsappQr, setWhatsAppQr] = useState('') const [connecting, setConnecting] = useState(false) const [emailPreset, setEmailPreset] = useState('custom') const [emailForm, setEmailForm] = useState({ @@ -85,7 +97,6 @@ export function ChannelsPage() { const [logDrawer, setLogDrawer] = useState(null) const [settingsDrawer, setSettingsDrawer] = useState(null) const [logItems, setLogItems] = useState([]) - const setupOpenedRef = useRef(false) const loadLogs = async (chId: string) => { const logs = await getChannelLogs(chId) @@ -122,7 +133,7 @@ export function ChannelsPage() { const completeTelegramBinding = async (channelId: string) => { let syncSummary = '' try { - const result = await syncTelegramChannel(channelId) + const result = await syncChannel(channelId) syncSummary = `,已同步 ${result.chats} 个会话和 ${result.messages} 条消息` } catch (error) { syncSummary = `;账号已连接,但首次同步失败:${errorMessage(error)}` @@ -155,15 +166,48 @@ export function ChannelsPage() { } } - useEffect(() => { - if (!channels || setupOpenedRef.current || searchParams.get('setup') !== 'telegram') return - setupOpenedRef.current = true - const telegram = channels.find((item) => item.type === 'telegram') - void openTelegramWizard(telegram) + const openWhatsAppWizard = async (channel?: ChannelConnection) => { + setWhatsAppAccount(channel?.account || '') + setWhatsAppChannelId(channel && channel.id !== 'whatsapp-available' ? channel.id : '') + setWhatsAppPairingCode(channel?.pairingCode || '') + setWhatsAppQr(channel?.qrDataUrl || '') + setWizard({ open: true, type: 'whatsapp', step: channel?.pairingCode ? 1 : 0 }) + if (!channel || channel.id === 'whatsapp-available') return + setConnecting(true) + try { + const current = await getWhatsAppLoginState(channel.id) + if (current.status === 'connected') { + await syncChannel(channel.id, { full: true }) + toast.success('WhatsApp 已连接并完成首次会话同步') + setWizard({ open: false, type: null, step: 0 }) + await queryClient.invalidateQueries() + } else { + const restarted = await startWhatsAppLogin(channel.account || '') + setWhatsAppChannelId(restarted.id) + setWhatsAppPairingCode(restarted.pairingCode || '') + setWhatsAppQr(restarted.qrDataUrl || '') + setWizard({ open: true, type: 'whatsapp', step: 1 }) + toast.success('已生成新的配对码,请在手机 WhatsApp 中完成关联设备') + await queryClient.invalidateQueries({ queryKey: ['channels'] }) + } + } catch (error) { + toast.error(errorMessage(error)) + } finally { + setConnecting(false) + } + } + + const openChannelWizard = (type: 'telegram' | 'whatsapp' | 'email', channel?: ChannelConnection) => { setSearchParams({}, { replace: true }) - // 新注册后的 setup 参数只消费一次,避免查询刷新时重复打开向导。 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [channels, searchParams, setSearchParams]) + if (type === 'telegram') { + void openTelegramWizard(channel) + } else if (type === 'whatsapp') { + void openWhatsAppWizard(channel) + } else { + applyEmailPreset('custom') + setWizard({ open: true, type: 'email', step: 0 }) + } + } const applyEmailPreset = (value: keyof typeof EMAIL_PRESETS) => { setEmailPreset(value) @@ -180,8 +224,7 @@ export function ChannelsPage() { } const connectedCount = list.filter((channel) => channel.status === 'connected').length - const telegramChannel = list.find((channel) => channel.type === 'telegram') - const telegramConnected = telegramChannel?.status === 'connected' + const setupChoice = searchParams.get('setup') === 'choose' const todayMessages = list.reduce((total, channel) => total + Number(channel.todayMessages || 0), 0) const latestSync = list .map((channel) => channel.lastSyncAt) @@ -200,11 +243,11 @@ export function ChannelsPage() { - Unified communication hub + 渠道连接

渠道与账号

- 将 Telegram 即时消息与企业邮箱汇入同一个客户工作台,统一翻译、画像、分配和跟进。 + 在一个工作台管理 WhatsApp、Telegram 与企业邮箱消息。

@@ -228,65 +271,87 @@ export function ChannelsPage() { description="连接状态、同步进度与安全设置" /> - {!telegramConnected && ( + {setupChoice && ( -
-
-
- 首次配置 · 约 2 分钟 - 使用你自己的 Telegram 工作账号 -
-

先绑定 Telegram,开始接收真实客户消息

-

- 输入国际格式手机号,依次完成 Telegram 验证码和可选的二步密码。授权成功后系统会自动注册实时 Webhook,并首次同步历史会话。 -

-
- {[ - { icon: Smartphone, step: '01', title: '验证手机号', text: '例如 +8613800138000' }, - { icon: ShieldCheck, step: '02', title: '完成安全验证', text: '验证码与二步密码' }, - { icon: RefreshCw, step: '03', title: '自动同步消息', text: '历史会话与实时消息' }, - ].map((item) => ( -
- - - -
-

{item.step} · {item.title}

-

{item.text}

-
-
- ))} -
+
+ 企业创建成功 · 渠道可稍后配置 +

选择第一个客户沟通渠道

+

+ 不强制绑定 Telegram。你可以连接 WhatsApp、Telegram 或企业邮箱,也可以先进入系统,之后再添加多个账号。 +

+
+ {[ + { type: 'telegram' as const, icon: Cable, title: 'Telegram', text: '手机号、验证码与可选二步密码' }, + { type: 'whatsapp' as const, icon: MessageCircle, title: 'WhatsApp', text: '普通账号通过关联设备配对' }, + { type: 'email' as const, icon: Mail, title: '企业邮箱', text: 'IMAP 收信与 SMTP 发信' }, + ].map((item) => ( + + ))} +
+
+
-
)} +
+
+

连接更多工作账号

+

每种渠道可绑定多个账号,并指定一个默认账号用于发起新会话。

+
+
+ + + +
+
+
{list.map((ch) => { const meta = CHANNEL_META[ch.type] const st = STATUS_MAP[ch.status] return ( -
+
- {st.label} +
+ {ch.isDefault && 默认账号} + {st.label} +

{meta.name}

@@ -300,8 +365,14 @@ export function ChannelsPage() {

{Number(ch.todayMessages || 0)} 条

-

同步状态

-

{ch.lastSyncAt ? formatRelative(ch.lastSyncAt) : '等待同步'}

+

消息连接

+

+ {ch.status === 'connected' + ? '实时同步中' + : ch.lastSyncAt + ? formatRelative(ch.lastSyncAt) + : '等待连接'} +

@@ -319,31 +390,33 @@ export function ChannelsPage() {
{ch.status === 'connected' ? ( <> - + {!ch.isDefault && ( + + )} ) : ch.status === 'paused' ? ( )} - - + {!ch.id.endsWith('-available') && ( + <> + + + + )}
@@ -392,13 +477,13 @@ export function ChannelsPage() { -

统一处理链路

-

无论消息来自 Telegram 还是 Email,都进入同一套真实业务流程。

+

消息处理

+

渠道消息进入收件箱后,可分配负责人、翻译并跟进。

{[ - ['01', '实时接收与自动同步'], - ['02', 'AI 翻译、情绪与意图识别'], - ['03', '客户画像、风险与任务联动'], + ['01', '接收并同步渠道消息'], + ['02', '分配负责人并处理消息'], + ['03', '记录客户信息与跟进事项'], ].map(([step, label]) => (
{step} @@ -421,6 +506,9 @@ export function ChannelsPage() { if (!open) { setEmailForm((current) => ({ ...current, password: '' })) resetTelegramWizard() + setWhatsAppChannelId('') + setWhatsAppPairingCode('') + setWhatsAppQr('') } }} title={`连接 ${wizard.type ? CHANNEL_META[wizard.type].name : ''}`} @@ -438,6 +526,35 @@ export function ChannelsPage() { queryClient.invalidateQueries() return } + if (wizard.type === 'whatsapp') { + if (!whatsappChannelId) { + const phone = whatsappAccount.replace(/\D/g, '') + if (!/^\d{7,15}$/.test(phone)) { + toast.warning('请输入包含国家/地区代码的 WhatsApp 手机号') + return + } + const channel = await startWhatsAppLogin(phone) + setWhatsAppChannelId(channel.id) + setWhatsAppPairingCode(channel.pairingCode || '') + setWhatsAppQr(channel.qrDataUrl || '') + setWizard({ open: true, type: 'whatsapp', step: 1 }) + toast.success('配对码已生成,请在手机 WhatsApp 中完成关联设备') + queryClient.invalidateQueries({ queryKey: ['channels'] }) + return + } + const state = await getWhatsAppLoginState(whatsappChannelId) + setWhatsAppPairingCode(state.pairingCode || state.channel.pairingCode || '') + setWhatsAppQr(state.qrDataUrl || state.channel.qrDataUrl || '') + if (state.status !== 'connected') { + toast.info('尚未检测到手机确认,请完成关联设备后再次检查') + return + } + const sync = await syncChannel(whatsappChannelId, { full: true }) + toast.success(`WhatsApp 绑定成功,已同步 ${sync.chats} 个会话和 ${sync.messages} 条消息`) + setWizard({ open: false, type: null, step: 0 }) + await queryClient.invalidateQueries() + return + } if (!loginChannelId) { const phone = telegramAccount.replace(/\s+/g, '') @@ -497,7 +614,11 @@ export function ChannelsPage() { > {wizard.type === 'email' ? '验证并连接邮箱' - : !loginChannelId + : wizard.type === 'whatsapp' + ? !whatsappChannelId + ? '生成 WhatsApp 配对码' + : '我已在手机确认,检查连接' + : !loginChannelId ? '发送 Telegram 验证码' : authState === 'waiting_password' ? '验证二步密码并完成绑定' @@ -513,7 +634,7 @@ export function ChannelsPage() { IMAP 收信 + SMTP 发信

- 密码在服务端使用 AES-256-GCM 加密保存,不会返回浏览器。Gmail、Outlook 等通常需要应用专用密码。 + Gmail、Outlook 等邮箱通常需要使用应用专用密码。

@@ -545,7 +666,7 @@ export function ChannelsPage() { type="password" value={emailForm.password} onChange={(event) => setEmailForm({ ...emailForm, password: event.target.value })} - placeholder="仅提交到本地服务端验证" + placeholder="邮箱密码或应用专用密码" />
@@ -585,6 +706,50 @@ export function ChannelsPage() {
+ ) : wizard.type === 'whatsapp' ? ( +
+
+
+ + 普通 WhatsApp 关联设备接入 +
+

+ 该方式通过 WhatsApp 的“关联设备”连接普通个人或 Business App 账号,并非 Meta 官方 Business Cloud API。 + 请勿用于垃圾营销或违反 WhatsApp 条款的自动化行为。 +

+
+ {!whatsappChannelId ? ( + + setWhatsAppAccount(event.target.value)} + placeholder="+8613800138000" + /> + + ) : ( + <> +
+

关联设备配对码

+

+ {whatsappPairingCode || '等待生成'} +

+

+ 手机 WhatsApp → 设置 → 关联设备 → 关联设备 → 使用手机号关联,然后输入上方配对码。 +

+
+ {whatsappQr && ( +
+ WhatsApp 关联设备二维码 +
+ )} + + )} +
+ 手机确认后将自动同步历史会话、联系人和媒体消息。 +
+
) : (
@@ -593,7 +758,7 @@ export function ChannelsPage() { 绑定你自己的 Telegram 工作账号

- 每个企业独立绑定账号,不会共享其他租户的 Telegram 会话或凭据。 + 可以继续添加其他工作账号,并选择一个默认账号发起新会话。

@@ -655,7 +820,7 @@ export function ChannelsPage() { )}

- 手机号、验证码和二步密码只通过加密连接发送到本企业的 Telegram Gateway,不写入浏览器本地存储。授权成功后将自动完成 Webhook 注册和首次历史同步。 + 完成验证后将自动同步历史会话、联系人和媒体消息。

)} diff --git a/src/pages/customers/CustomerDetailPage.tsx b/src/pages/customers/CustomerDetailPage.tsx index 27149f2..61971f0 100644 --- a/src/pages/customers/CustomerDetailPage.tsx +++ b/src/pages/customers/CustomerDetailPage.tsx @@ -68,7 +68,7 @@ export function CustomerDetailPage() { customerName: c.name, ownerId: user?.id || '', dueAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), - note: `由客户画像的 AI 建议创建:${title}`, + note: `由客户画像建议创建:${title}`, }) await queryClient.invalidateQueries({ queryKey: ['tasks'] }) toast.success('已创建跟进任务', '默认明天到期,可在商机与跟进中调整') @@ -115,7 +115,7 @@ export function CustomerDetailPage() {
) : (

- 暂无标签,AI 将在收到有效客户消息后自动补全。 + 暂无标签,收到更多客户消息后将自动补充。

)}
@@ -159,7 +159,7 @@ export function CustomerDetailPage() { - +
{[ @@ -174,7 +174,7 @@ export function CustomerDetailPage() { ))}

- {c.aiProfile.summary || 'AI 正在根据 Telegram 会话补全客户画像。'} + {c.aiProfile.summary || '收到更多客户消息后将补充客户画像。'}

{c.aiProfile.evidence?.length ? (
@@ -193,7 +193,7 @@ export function CustomerDetailPage() { - +
{Object.entries({ 询价数量: c.aiProfile.inquiryQuantity, diff --git a/src/pages/customers/CustomersPage.tsx b/src/pages/customers/CustomersPage.tsx index ee9d99c..14696a9 100644 --- a/src/pages/customers/CustomersPage.tsx +++ b/src/pages/customers/CustomersPage.tsx @@ -178,7 +178,7 @@ export function CustomersPage() {
diff --git a/src/pages/dashboard/DashboardPage.tsx b/src/pages/dashboard/DashboardPage.tsx index 9d75c45..bfa0035 100644 --- a/src/pages/dashboard/DashboardPage.tsx +++ b/src/pages/dashboard/DashboardPage.tsx @@ -127,8 +127,8 @@ export function DashboardPage() { {/* 核心指标 */}
-

Today

-

今日经营重点

+

工作台

+

今日重点

@@ -252,7 +252,7 @@ export function DashboardPage() {
diff --git a/src/pages/help/HelpPage.tsx b/src/pages/help/HelpPage.tsx index 7dfd29d..4badea8 100644 --- a/src/pages/help/HelpPage.tsx +++ b/src/pages/help/HelpPage.tsx @@ -7,8 +7,8 @@ const HELP_ITEMS = [ { icon: Cable, title: '连接 Telegram', desc: '手机号、验证码和二步密码登录,并同步历史会话', path: '/channels' }, { icon: BookOpen, title: '建设知识库', desc: '上传 TXT、Markdown、PDF 或 DOCX 并解析为企业知识', path: '/knowledge' }, { icon: FileUp, title: '导入与管理客户', desc: '维护客户资料、标签、负责人和跟进状态', path: '/customers' }, - { icon: Languages, title: 'AI 上下文翻译', desc: '结合当前会话上下文进行跨境商务翻译', path: '/translate' }, - { icon: UsersRound, title: '邀请团队成员', desc: '通过已配置的企业 SMTP 发送真实邀请邮件', path: '/team' }, + { icon: Languages, title: '消息翻译', desc: '翻译当前消息并保留双语对照', path: '/translate' }, + { icon: UsersRound, title: '邀请团队成员', desc: '向同事发送团队邀请邮件', path: '/team' }, { icon: CreditCard, title: '订阅与支付', desc: '通过 Stripe Checkout 管理套餐订阅', path: '/billing' }, ] diff --git a/src/pages/inbox/InboxPage.tsx b/src/pages/inbox/InboxPage.tsx index 8685981..4a06e33 100644 --- a/src/pages/inbox/InboxPage.tsx +++ b/src/pages/inbox/InboxPage.tsx @@ -22,15 +22,21 @@ import { getConversations, getConversation, getMessages, + normalizeMessage, sendMessage, sendMedia, sendAlbum, updateConversation, updateMessage, markConversationRead, + setConversationPresence, openTelegramConversation, - runTelegramConversationAction, - type TelegramConversationAction, + openWhatsAppConversation, + runConversationAction, + runWhatsAppMessageAction, + getWhatsAppGroup, + runWhatsAppGroupAction, + type ConversationAction, type ConversationListItem, } from '@/services/api/conversations' import { updateCustomer } from '@/services/api/customers' @@ -127,6 +133,8 @@ export function InboxPage() { }) const [draftText, setDraftText] = useState('') const [draftTranslationCn, setDraftTranslationCn] = useState('') + const presenceStopTimer = useRef | null>(null) + const lastPresenceSentAt = useRef(0) const [replyingTo, setReplyingTo] = useState<{ id: string; text: string } | null>(null) const [noteTarget, setNoteTarget] = useState(null) const [noteDraft, setNoteDraft] = useState('') @@ -136,14 +144,17 @@ export function InboxPage() { customer: Customer } | null>(null) const [addTelegramOpen, setAddTelegramOpen] = useState(false) + const [addConversationChannel, setAddConversationChannel] = useState<'telegram' | 'whatsapp'>('telegram') const [addTelegramType, setAddTelegramType] = useState<'user' | 'chat' | 'invite'>('user') const [addTelegramIdentifier, setAddTelegramIdentifier] = useState('') + const [addGroupParticipants, setAddGroupParticipants] = useState('') const [addTelegramLoading, setAddTelegramLoading] = useState(false) const [conversationActionTarget, setConversationActionTarget] = useState<{ item: ConversationListItem action: 'clear' | 'delete' | 'leave' } | null>(null) const [conversationActionLoading, setConversationActionLoading] = useState(false) + const [whatsappGroupOpen, setWhatsAppGroupOpen] = useState(false) const messageListRef = useRef(null) const messageContentRef = useRef(null) @@ -305,14 +316,15 @@ export function InboxPage() { }) } if (event.conversationId && event.message) { + const normalizedMessage = normalizeMessage(event.message) queryClient.setQueryData(['messages', event.conversationId], (current = []) => { const withoutOld = event.oldMessageId ? current.filter((item) => String(item.id) !== String(event.oldMessageId)) : current - const index = withoutOld.findIndex((item) => String(item.id) === String(event.message!.id)) - if (index < 0) return [...withoutOld, event.message!] + const index = withoutOld.findIndex((item) => String(item.id) === String(normalizedMessage.id)) + if (index < 0) return [...withoutOld, normalizedMessage] const next = [...withoutOld] - next[index] = { ...next[index], ...event.message! } + next[index] = normalizeMessage({ ...next[index], ...normalizedMessage }) return next }) queryClient.setQueriesData<{ list: Array<{ id: string; lastMessage?: Message }>; total: number }>( @@ -325,16 +337,23 @@ export function InboxPage() { String(item?.lastMessage?.id || '') === String(event.message?.id || '') if (!isNewMessage && !isCurrentLastMessage) return typed return updateRealtimeConversationList(typed, event.conversationId!, { - lastMessage: event.message, + lastMessage: normalizedMessage, }) }, ) } if (event.conversationId && event.conversation) { - queryClient.setQueryData(['conversation', event.conversationId], (current: any) => ({ - conversation: event.conversation, - customer: event.customer || current?.customer, - })) + queryClient.setQueryData(['conversation', event.conversationId], (current: any) => { + const customer = event.customer || current?.customer + // A realtime conversation event can arrive before the detail request. + // Do not seed an incomplete detail cache because the chat header and + // insight components require both the conversation and its customer. + if (!customer) return current + return { + conversation: event.conversation, + customer, + } + }) queryClient.setQueriesData<{ list: ConversationListItem[]; total: number }>( { queryKey: ['conversations'] }, (current) => updateRealtimeConversationList(current, event.conversationId!, { @@ -436,9 +455,30 @@ export function InboxPage() { autoTranslate, translationCn: draftTranslationCn || undefined, }) + if (convDetail?.conversation.channel === 'whatsapp') { + void setConversationPresence(convId, 'paused').catch(() => undefined) + } setDraftTranslationCn('') } + const handleDraftChange = useCallback((text: string) => { + setDraftText(text) + if (!convId || convDetail?.conversation.channel !== 'whatsapp') return + if (presenceStopTimer.current) clearTimeout(presenceStopTimer.current) + const now = Date.now() + if (text.trim() && now - lastPresenceSentAt.current > 2_000) { + lastPresenceSentAt.current = now + void setConversationPresence(convId, 'composing').catch(() => undefined) + } + presenceStopTimer.current = setTimeout(() => { + void setConversationPresence(convId, 'paused').catch(() => undefined) + }, 1_400) + }, [convId, convDetail?.conversation.channel]) + + useEffect(() => () => { + if (presenceStopTimer.current) clearTimeout(presenceStopTimer.current) + }, []) + const handleMedia = async (files: File[], kind: 'document' | 'photo' | 'audio' | 'voice' | 'sticker') => { if (!convId) return const encoded = await Promise.all(files.map(async (file) => { @@ -467,14 +507,15 @@ export function InboxPage() { } } - const executeTelegramAction = async ( - action: TelegramConversationAction, + const executeConversationAction = async ( + action: ConversationAction, item?: ConversationListItem, ) => { const id = item?.id || convId if (!id) return + const provider = (item?.channel || convDetail?.conversation.channel) === 'whatsapp' ? 'WhatsApp' : 'Telegram' try { - const result = await runTelegramConversationAction(id, action) + const result = await runConversationAction(id, action) if ('deleted' in result && result.deleted) { queryClient.removeQueries({ queryKey: ['messages', id] }) queryClient.removeQueries({ queryKey: ['conversation', id] }) @@ -482,9 +523,9 @@ export function InboxPage() { } else { await refreshConversationState(id) } - const labels: Partial> = { + const labels: Partial> = { clear: '消息记录已清空', - delete: '会话已从 Telegram 列表删除', + delete: `会话已从 ${provider} 列表删除`, pin: '会话已置顶', unpin: '已取消置顶', archive: '会话已归档', @@ -493,10 +534,10 @@ export function InboxPage() { unmute: '会话已恢复通知', 'mark-unread': '会话已标记为未读', leave: '已退出群组或频道', - block: '已拉黑该 Telegram 用户', + block: `已拉黑该 ${provider} 用户`, unblock: '已解除拉黑', } - toast.success(labels[action] || 'Telegram 操作已完成') + toast.success(labels[action] || `${provider} 操作已完成`) } catch (error) { toast.error(errorMessage(error)) throw error @@ -507,7 +548,7 @@ export function InboxPage() { if (!conversationActionTarget) return setConversationActionLoading(true) try { - await executeTelegramAction(conversationActionTarget.action, conversationActionTarget.item) + await executeConversationAction(conversationActionTarget.action, conversationActionTarget.item) setConversationActionTarget(null) } finally { setConversationActionLoading(false) @@ -517,20 +558,30 @@ export function InboxPage() { const addTelegramConversation = async () => { const identifier = addTelegramIdentifier.trim() if (!identifier) { - toast.error('请输入 Telegram 标识') + toast.error(`请输入 ${addConversationChannel === 'whatsapp' ? 'WhatsApp' : 'Telegram'} 标识`) return } setAddTelegramLoading(true) try { - const value = await openTelegramConversation({ - identifier, - targetType: addTelegramType, - }) + const value = addConversationChannel === 'whatsapp' + ? await openWhatsAppConversation({ + identifier, + targetType: addTelegramType === 'invite' ? 'invite' : addTelegramType === 'chat' ? 'group' : 'user', + subject: addTelegramType === 'chat' ? identifier : undefined, + participants: addTelegramType === 'chat' + ? addGroupParticipants.split(/[\s,;]+/).map((item) => item.trim()).filter(Boolean) + : undefined, + }) + : await openTelegramConversation({ + identifier, + targetType: addTelegramType, + }) await refreshConversationState(value.conversation.id) setAddTelegramOpen(false) setAddTelegramIdentifier('') + setAddGroupParticipants('') setConv(value.conversation.id) - toast.success('Telegram 会话已添加', value.customer.name) + toast.success(`${addConversationChannel === 'whatsapp' ? 'WhatsApp' : 'Telegram'} 会话已添加`, value.customer.name) } catch (error) { toast.error(errorMessage(error)) } finally { @@ -542,7 +593,39 @@ export function InboxPage() { quote: (msg: Message) => { if (!convId) return setReplyingTo({ id: msg.id, text: msg.originalText || msg.attachmentName || `[${msg.type}]` }) - toast.info('已引用该消息,发送后会关联 Telegram 原消息') + toast.info('已引用该消息,发送后会关联渠道原消息') + }, + react: async (msg: Message, emoji: string) => { + if (!convId || convDetail?.conversation.channel !== 'whatsapp') return + try { + const updated = await runWhatsAppMessageAction(convId, { + action: 'react', + messageId: msg.id, + emoji, + }) + if ('id' in updated) { + queryClient.setQueryData(['messages', convId], (old: Message[] | undefined) => + old?.map((item) => item.id === updated.id ? updated : item)) + } + } catch (error) { + toast.error(errorMessage(error)) + } + }, + deleteMessage: async (msg: Message) => { + if (!convId || convDetail?.conversation.channel !== 'whatsapp') return + try { + const updated = await runWhatsAppMessageAction(convId, { + action: 'delete', + messageId: msg.id, + }) + if ('id' in updated) { + queryClient.setQueryData(['messages', convId], (old: Message[] | undefined) => + old?.map((item) => item.id === updated.id ? updated : item)) + } + toast.success('消息已撤回') + } catch (error) { + toast.error(errorMessage(error)) + } }, markImportant: async (msg: Message) => { if (!convId) return @@ -564,8 +647,9 @@ export function InboxPage() { try { const { createTask } = await import('@/services/api/crm') const dueAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() + const sourceText = String(msg.originalText || '') await createTask({ - title: `跟进:${msg.originalText.slice(0, 36) || msg.attachmentName || '客户消息'}`, + title: `跟进:${sourceText.slice(0, 36) || msg.attachmentName || '客户消息'}`, type: 'revisit', priority: convDetail.customer.riskLevel === 'urgent' || convDetail.customer.riskLevel === 'high' ? 'high' : 'medium', status: 'pending', @@ -574,20 +658,21 @@ export function InboxPage() { conversationId: convId, ownerId: user?.id || '', dueAt, - note: `来源消息:${msg.originalText || msg.attachmentName || msg.type}`, + note: `来源消息:${sourceText || msg.attachmentName || msg.type}`, } as any) queryClient.invalidateQueries({ queryKey: ['tasks'] }) - toast.success('已创建真实跟进待办', '默认明天到期,可在商机与跟进中调整') + toast.success('已创建跟进待办', '默认明天到期,可在商机与跟进中调整') } catch (error) { toast.error(errorMessage(error)) } }, knowledge: (msg: Message) => { + const sourceText = String(msg.originalText || '') import('@/services/api/knowledge') .then(({ createKnowledge }) => createKnowledge({ - title: `会话消息提取:${msg.originalText.slice(0, 30)}`, - content: msg.originalText, + title: `会话消息提取:${sourceText.slice(0, 30) || msg.attachmentName || '客户消息'}`, + content: sourceText || msg.attachmentName || `[${msg.type}]`, source: 'reply', }), ) @@ -615,7 +700,7 @@ export function InboxPage() { open={noteTarget !== null} onOpenChange={(open) => { if (!open) { setNoteTarget(null); setNoteDraft('') } }} title="内部备注" - description="仅团队内部可见,不会发送给 Telegram 联系人。" + description="仅团队内部可见,不会发送给外部联系人。" footer={ <> @@ -636,20 +721,33 @@ export function InboxPage() { ) + const whatsappGroupDialog = convId ? ( + + ) : null + const telegramDialogs = ( <> @@ -657,6 +755,30 @@ export function InboxPage() { )} >
+
+ {[ + { value: 'telegram' as const, label: 'Telegram' }, + { value: 'whatsapp' as const, label: 'WhatsApp' }, + ].map((option) => ( + + ))} +
{[ { value: 'user' as const, label: '用户' }, @@ -679,15 +801,19 @@ export function InboxPage() { ))}
-
@@ -715,7 +859,7 @@ export function InboxPage() { onOpenChange={(open) => { if (!open && !conversationActionLoading) setConversationActionTarget(null) }} title={ conversationActionTarget?.action === 'delete' - ? '删除 Telegram 会话' + ? `删除 ${conversationActionTarget?.item.channel === 'whatsapp' ? 'WhatsApp' : 'Telegram'} 会话` : conversationActionTarget?.action === 'leave' ? '退出群组或频道' : '清空消息记录' @@ -743,12 +887,13 @@ export function InboxPage() { >
{conversationActionTarget?.action === 'delete' - ? String(conversationActionTarget.item.telegramChatType || '').toLowerCase() === 'channel' + ? conversationActionTarget.item.channel === 'telegram' + && String(conversationActionTarget.item.telegramChatType || '').toLowerCase() === 'channel' ? '这会先退出 Telegram 频道,再从当前账号和灵犀跨境中移除会话;不会全局删除频道帖子,也不会删除客户档案。' - : '这会清除当前 Telegram 账号中的聊天历史并从会话列表移除,但不会删除客户档案,也不会撤回客户已收到的消息。' + : `这会清除当前 ${conversationActionTarget.item.channel === 'whatsapp' ? 'WhatsApp' : 'Telegram'} 账号中的聊天历史并从会话列表移除,但不会删除客户档案,也不会撤回客户已收到的消息。` : conversationActionTarget?.action === 'leave' ? '退出后将不能继续收发该群组或频道消息,重新加入可能需要新的邀请链接或管理员审批。' - : '这会清除当前 Telegram 账号和灵犀跨境中的聊天记录,但不会撤回客户已收到的消息。'} + : `这会清除当前 ${conversationActionTarget?.item.channel === 'whatsapp' ? 'WhatsApp' : 'Telegram'} 账号和灵犀跨境中的聊天记录,但不会撤回客户已收到的消息。`}
@@ -808,8 +953,11 @@ export function InboxPage() { onConvertToTask={() => handleAction.task(msg)} onAddToKnowledge={() => handleAction.knowledge(msg)} onAddNote={() => handleAction.addNote(msg)} - customerName={convDetail?.customer.name} - customerAvatar={convDetail?.customer.avatarUrl} + onReact={(emoji) => void handleAction.react(msg, emoji)} + onDelete={() => void handleAction.deleteMessage(msg)} + customerName={convDetail?.customer?.name} + customerAvatar={convDetail?.customer?.avatarUrl} + channel={convDetail?.conversation.channel} />
) @@ -821,6 +969,7 @@ export function InboxPage() { return (
{noteDialog} + {whatsappGroupDialog} {telegramDialogs} {incomingNotice && ( setWhatsAppGroupOpen(true)} onBack={() => setMobilePanel('list')} onInsight={() => setMobilePanel('insight')} showInsightBtn /> - {convDetail && ( + {convDetail?.customer && convDetail?.conversation && (
)} @@ -910,7 +1060,7 @@ export function InboxPage() { onMedia={handleMedia} value={draftText} onChange={(text) => { - setDraftText(text) + handleDraftChange(text) setDraftTranslationCn('') }} disabled={!convId} @@ -920,7 +1070,7 @@ export function InboxPage() { />
)} - {mobilePanel === 'insight' && convDetail && ( + {mobilePanel === 'insight' && convDetail?.customer && convDetail?.conversation && (
)} @@ -1041,7 +1193,7 @@ export function InboxPage() { onMedia={handleMedia} value={draftText} onChange={(text) => { - setDraftText(text) + handleDraftChange(text) setDraftTranslationCn('') }} disabled={!convId} @@ -1053,14 +1205,14 @@ export function InboxPage() { ) : ( )}
{/* 右栏:客户洞察 340px */} - {showInsight && convDetail && ( + {showInsight && convDetail?.customer && convDetail?.conversation && (
@@ -1069,6 +1221,173 @@ export function InboxPage() { ) } +function WhatsAppGroupDialog({ + conversationId, + open, + onOpenChange, +}: { + conversationId: string + open: boolean + onOpenChange: (open: boolean) => void +}) { + const queryClient = useQueryClient() + const [subject, setSubject] = useState('') + const [description, setDescription] = useState('') + const [member, setMember] = useState('') + const [inviteCode, setInviteCode] = useState('') + const { data, isLoading } = useQuery({ + queryKey: ['whatsapp-group', conversationId], + queryFn: () => getWhatsAppGroup(conversationId), + enabled: open, + }) + useEffect(() => { + if (!data) return + setSubject(data.name || '') + setDescription(data.description || '') + }, [data]) + const action = useMutation({ + mutationFn: (payload: Parameters[1]) => + runWhatsAppGroupAction(conversationId, payload), + onSuccess: async (_value, variables) => { + await queryClient.invalidateQueries({ queryKey: ['whatsapp-group', conversationId] }) + if (variables.action !== 'invite-code') toast.success('群组设置已更新') + }, + onError: (error) => toast.error(errorMessage(error)), + }) + const updateInvite = async () => { + try { + const value = await runWhatsAppGroupAction(conversationId, { action: 'invite-code' }) as any + const code = String(value?.code || value || '') + setInviteCode(code ? `https://chat.whatsapp.com/${code}` : '') + } catch (error) { + toast.error(errorMessage(error)) + } + } + return ( + onOpenChange(false)}>关闭} + > + {isLoading ? : ( +
+
+ setSubject(event.target.value)} + placeholder="群名称" + className="rounded-lg border border-input px-3 py-2 text-sm outline-none focus:border-primary" + /> + +
+
+