From 7f09132fad30bf4e6866bb6719a354c0e4ab277a Mon Sep 17 00:00:00 2001 From: wmq Date: Tue, 28 Jul 2026 22:36:58 +0800 Subject: [PATCH] feat(history): complete messaging core before multi-account gateways --- .env.example | 5 + docker-compose.email-test.yml | 14 + docker-compose.yml | 2 + docker/greenmail-test.Dockerfile | 4 + public/chat-pattern.svg | 22 + server/package-lock.json | 487 +++++++ server/package.json | 10 +- server/src/ai.js | 66 +- server/src/email.js | 176 +++ server/src/index.js | 1140 ++++++++++++++++- server/test/business.test.js | 10 + server/test/email-api-e2e.test.js | 169 +++ server/test/email-protocol.test.js | 86 ++ src/app/router/index.tsx | 45 +- src/components/charts/index.tsx | 2 +- src/components/common/StatCard.tsx | 13 +- .../customer/CustomerProfileRadar.tsx | 181 +++ .../customer/CustomerSemanticTag.tsx | 107 ++ src/components/inbox/AiReplies.tsx | 49 +- src/components/inbox/Composer.tsx | 66 +- .../inbox/ConversationInsightBar.tsx | 47 +- src/components/inbox/ConversationList.tsx | 183 ++- src/components/inbox/InsightPanel.tsx | 78 +- src/components/inbox/MessageBubble.tsx | 142 +- src/components/layout/AppLayout.tsx | 2 +- src/components/layout/GlobalRealtime.tsx | 8 +- src/components/layout/Sidebar.tsx | 42 +- src/components/layout/Topbar.tsx | 5 +- src/components/ui/card.tsx | 6 +- src/components/ui/dropdown.tsx | 2 +- src/components/ui/tabs.tsx | 18 +- src/config/navigation.ts | 27 +- src/i18n/locales/en.ts | 2 +- src/i18n/locales/zh.ts | 6 +- src/pages/analytics/AnalyticsPage.tsx | 2 +- src/pages/auth/RegisterPage.tsx | 6 +- src/pages/channels/ChannelsPage.tsx | 702 ++++++++-- src/pages/customers/CustomerDetailPage.tsx | 145 ++- src/pages/customers/CustomersPage.tsx | 2 +- src/pages/dashboard/DashboardPage.tsx | 62 +- src/pages/inbox/InboxPage.tsx | 709 ++++++++-- src/pages/risks/RisksPage.tsx | 23 +- src/pages/translate/TranslatePage.tsx | 34 +- src/services/api/ai.ts | 10 +- src/services/api/channels.ts | 23 + src/services/api/conversations.ts | 39 +- src/styles/index.css | 169 ++- src/types/ai.ts | 13 + src/types/crm.ts | 1 + src/types/messaging.ts | 35 + src/types/org.ts | 2 +- tailwind.config.js | 20 +- tests/e2e/inbox-ai-live.spec.ts | 8 + tests/e2e/lingxi.spec.ts | 155 ++- 54 files changed, 4781 insertions(+), 601 deletions(-) create mode 100644 docker-compose.email-test.yml create mode 100644 docker/greenmail-test.Dockerfile create mode 100644 public/chat-pattern.svg create mode 100644 server/src/email.js create mode 100644 server/test/email-api-e2e.test.js create mode 100644 server/test/email-protocol.test.js create mode 100644 src/components/customer/CustomerProfileRadar.tsx create mode 100644 src/components/customer/CustomerSemanticTag.tsx diff --git a/.env.example b/.env.example index 183b9ff..0e25947 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,11 @@ SMTP_USER= SMTP_PASSWORD= SMTP_FROM= +# 企业邮箱渠道(IMAP 收信 + SMTP 发信) +# 建议使用 32 字节以上独立随机值;未配置时回退到 LX_JWT_SECRET 派生密钥。 +EMAIL_CREDENTIAL_KEY= +EMAIL_POLL_MS=30000 + # 真实 Stripe Checkout STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= diff --git a/docker-compose.email-test.yml b/docker-compose.email-test.yml new file mode 100644 index 0000000..e24de4d --- /dev/null +++ b/docker-compose.email-test.yml @@ -0,0 +1,14 @@ +name: lingxi-email-protocol + +services: + greenmail: + image: lingxi-greenmail-test:2.1.8-runtime + ports: + - "127.0.0.1:3025:3025" + - "127.0.0.1:3143:3143" + healthcheck: + test: ["CMD-SHELL", "nc -z 127.0.0.1 3025"] + interval: 3s + timeout: 2s + retries: 20 + restart: unless-stopped diff --git a/docker-compose.yml b/docker-compose.yml index 4ed8bc9..11f123c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,6 +41,8 @@ services: SMTP_USER: ${SMTP_USER:-} SMTP_PASSWORD: ${SMTP_PASSWORD:-} SMTP_FROM: ${SMTP_FROM:-} + EMAIL_CREDENTIAL_KEY: ${EMAIL_CREDENTIAL_KEY:-} + EMAIL_POLL_MS: ${EMAIL_POLL_MS:-30000} STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} STRIPE_PRICE_LITE: ${STRIPE_PRICE_LITE:-} STRIPE_PRICE_STANDARD: ${STRIPE_PRICE_STANDARD:-} diff --git a/docker/greenmail-test.Dockerfile b/docker/greenmail-test.Dockerfile new file mode 100644 index 0000000..4f6fe28 --- /dev/null +++ b/docker/greenmail-test.Dockerfile @@ -0,0 +1,4 @@ +FROM lingxi-greenmail-test:2.1.8 + +ENTRYPOINT ["java"] +CMD ["-Dgreenmail.setup.test.smtp", "-Dgreenmail.setup.test.imap", "-Dgreenmail.hostname=0.0.0.0", "-Dgreenmail.users.login=email", "-Dgreenmail.users=sender:pwd@local.test,receiver:pwd@local.test", "-jar", "/tmp/greenmail.jar"] diff --git a/public/chat-pattern.svg b/public/chat-pattern.svg new file mode 100644 index 0000000..8b7a37f --- /dev/null +++ b/public/chat-pattern.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/server/package-lock.json b/server/package-lock.json index 6d30ffb..3bb11bf 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -12,7 +12,9 @@ "cors": "^2.8.5", "express": "^5.1.0", "helmet": "^8.1.0", + "imapflow": "^1.6.1", "jsonwebtoken": "^9.0.2", + "mailparser": "^3.9.14", "mammoth": "^1.9.0", "nodemailer": "^7.0.5", "pdf-parse": "^1.1.1", @@ -20,6 +22,28 @@ "ws": "^8.18.3" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.12.0", + "resolved": "https://registry.npmmirror.com/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.12.0.tgz", + "integrity": "sha512-oELmoyA6ML9jDRMV3kgcMQFKxUfBU0yFVn6yTctVaLT5ygXnxH52I3TZEgV9EhXJC68/uFvE5Daj1/25c0Xa/A==", + "license": "MIT", + "dependencies": { + "domelementtype": "~2.3.0", + "domhandler": "~5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + }, + "peerDependencies": { + "selderee": "~0.12.0" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.13", "resolved": "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.13.tgz", @@ -29,6 +53,17 @@ "node": ">=10.0.0" } }, + "node_modules/@zone-eu/mailsplit": { + "version": "5.4.14", + "resolved": "https://registry.npmmirror.com/@zone-eu/mailsplit/-/mailsplit-5.4.14.tgz", + "integrity": "sha512-rz0FQOhN3Vq1XrSeSSa9+dPcaFbBxmQPjiZm6zS9oxdVHV7rOWIAYX3yP2YAUf0qBncY8CI+NogzPCmMVrMXcw==", + "license": "(MIT OR EUPL-1.1+)", + "dependencies": { + "libbase64": "1.3.0", + "libmime": "5.4.1", + "libqp": "2.1.1" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", @@ -51,6 +86,15 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", @@ -244,6 +288,15 @@ } } }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmmirror.com/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz", @@ -259,6 +312,61 @@ "integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==", "license": "BSD-2-Clause" }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmmirror.com/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmmirror.com/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/duck": { "version": "0.1.12", "resolved": "https://registry.npmmirror.com/duck/-/duck-0.1.12.tgz", @@ -306,6 +414,27 @@ "node": ">= 0.8" } }, + "node_modules/encoding-japanese": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/encoding-japanese/-/encoding-japanese-2.2.0.tgz", + "integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==", + "license": "MIT", + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", @@ -515,6 +644,15 @@ "node": ">= 0.4" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, "node_modules/helmet": { "version": "8.3.0", "resolved": "https://registry.npmmirror.com/helmet/-/helmet-8.3.0.tgz", @@ -527,6 +665,56 @@ "url": "https://github.com/sponsors/EvanHahn" } }, + "node_modules/html-to-text": { + "version": "10.0.0", + "resolved": "https://registry.npmmirror.com/html-to-text/-/html-to-text-10.0.0.tgz", + "integrity": "sha512-2OH59Gtprdczel+7Rxgpz9hGVJREaf8Lt1H4kZwWHpEn70VQKRuMNGsb2eDbwaTzrYzb0hheiOG1P7Dim0B4dQ==", + "license": "MIT", + "dependencies": { + "@selderee/plugin-htmlparser2": "~0.12.0", + "deepmerge-ts": "^7.1.5", + "dom-serializer": "^2.0.0", + "htmlparser2": "^10.1.0", + "selderee": "~0.12.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmmirror.com/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz", @@ -563,6 +751,22 @@ "url": "https://opencollective.com/express" } }, + "node_modules/imapflow": { + "version": "1.6.1", + "resolved": "https://registry.npmmirror.com/imapflow/-/imapflow-1.6.1.tgz", + "integrity": "sha512-lBkh0ZcKeYHDS3sWfxEcD7j24frrrn4wtbe0UUtLqdLwMSvm9Mygx8j7ZtzWgnvqI7V1DkNfcsidJ/qasb7FBA==", + "license": "MIT", + "dependencies": { + "@zone-eu/mailsplit": "5.4.14", + "encoding-japanese": "2.2.0", + "iconv-lite": "0.7.3", + "libbase64": "1.3.0", + "libmime": "5.4.1", + "libqp": "2.1.1", + "pino": "10.3.1", + "socks": "2.8.9" + } + }, "node_modules/immediate": { "version": "3.0.6", "resolved": "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz", @@ -575,6 +779,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.3.1", + "resolved": "https://registry.npmmirror.com/ip-address/-/ip-address-10.3.1.tgz", + "integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -651,6 +864,39 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/leac": { + "version": "0.7.0", + "resolved": "https://registry.npmmirror.com/leac/-/leac-0.7.0.tgz", + "integrity": "sha512-qMrZeyEekgdRQ9o6a4NAB2EQZrv827GJdn1vnapwSJ90hWRB4TzUSunvacPkxQ2TnNqHNI1/zSt0hlo0crG8Jw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, + "node_modules/libbase64": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/libbase64/-/libbase64-1.3.0.tgz", + "integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==", + "license": "MIT" + }, + "node_modules/libmime": { + "version": "5.4.1", + "resolved": "https://registry.npmmirror.com/libmime/-/libmime-5.4.1.tgz", + "integrity": "sha512-0wHGhsofo9IdQPenr3BBHXuxcwMq4atFUTsZ9Ogc1OvI5h4rUdDIrBQEN9JHjCXfDMrE59LUMJWsTD82wTYk8A==", + "license": "MIT", + "dependencies": { + "encoding-japanese": "2.2.0", + "iconv-lite": "0.7.3", + "libbase64": "1.3.0", + "libqp": "2.1.1" + } + }, + "node_modules/libqp": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/libqp/-/libqp-2.1.1.tgz", + "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", + "license": "MIT" + }, "node_modules/lie": { "version": "3.3.0", "resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz", @@ -660,6 +906,25 @@ "immediate": "~3.0.5" } }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmmirror.com/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -713,6 +978,33 @@ "underscore": "^1.13.1" } }, + "node_modules/mailparser": { + "version": "3.9.14", + "resolved": "https://registry.npmmirror.com/mailparser/-/mailparser-3.9.14.tgz", + "integrity": "sha512-3QD6TRXcyXtq2NCuyA2AEjqmallQkyxYmZI9GMCIvQDCaB9Uc034WUI1x8RUBYFnk5+p7h14JEz4O/lrxQmttw==", + "license": "MIT", + "dependencies": { + "@zone-eu/mailsplit": "5.4.14", + "encoding-japanese": "2.2.0", + "he": "1.2.0", + "html-to-text": "10.0.0", + "iconv-lite": "0.7.3", + "libmime": "5.4.1", + "linkify-it": "5.0.2", + "nodemailer": "9.0.3", + "punycode.js": "2.3.1", + "tlds": "1.261.0" + } + }, + "node_modules/mailparser/node_modules/nodemailer": { + "version": "9.0.3", + "resolved": "https://registry.npmmirror.com/nodemailer/-/nodemailer-9.0.3.tgz", + "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/mammoth": { "version": "1.12.0", "resolved": "https://registry.npmmirror.com/mammoth/-/mammoth-1.12.0.tgz", @@ -847,6 +1139,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz", @@ -880,6 +1181,19 @@ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "license": "(MIT AND Zlib)" }, + "node_modules/parseley": { + "version": "0.13.1", + "resolved": "https://registry.npmmirror.com/parseley/-/parseley-0.13.1.tgz", + "integrity": "sha512-uNBJZzmb60l6p6VWLTmevizNAGnE0xoSf1n0B4q3ntegDNzcS68NRCcBDZTcyXHxt2XhBChsCuqj4M+nChvE/A==", + "license": "MIT", + "dependencies": { + "leac": "^0.7.0", + "peberminta": "^0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz", @@ -924,6 +1238,15 @@ "url": "https://github.com/sponsors/mehmet-kozan" } }, + "node_modules/peberminta": { + "version": "0.10.0", + "resolved": "https://registry.npmmirror.com/peberminta/-/peberminta-0.10.0.tgz", + "integrity": "sha512-80B2AsU+I4Qdb0ZAPSfe9UwvGzwkM37IKIFEvdS3D/3Ndgv2bsuJ0bfG1+iEYO+l7Gfd4EUJmuRyq7efLgRMzQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, "node_modules/pg": { "version": "8.22.0", "resolved": "https://registry.npmmirror.com/pg/-/pg-8.22.0.tgz", @@ -1013,6 +1336,43 @@ "split2": "^4.1.0" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmmirror.com/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmmirror.com/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/postgres-array/-/postgres-array-2.0.0.tgz", @@ -1058,6 +1418,22 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1071,6 +1447,15 @@ "node": ">= 0.10" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.3", "resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.3.tgz", @@ -1087,6 +1472,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.3.0", "resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.3.0.tgz", @@ -1136,6 +1527,15 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmmirror.com/router/-/router-2.2.0.tgz", @@ -1172,12 +1572,33 @@ ], "license": "MIT" }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmmirror.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/selderee": { + "version": "0.12.0", + "resolved": "https://registry.npmmirror.com/selderee/-/selderee-0.12.0.tgz", + "integrity": "sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==", + "license": "MIT", + "dependencies": { + "parseley": "~0.13.1" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", @@ -1319,6 +1740,39 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmmirror.com/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmmirror.com/split2/-/split2-4.2.0.tgz", @@ -1358,6 +1812,33 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/tlds": { + "version": "1.261.0", + "resolved": "https://registry.npmmirror.com/tlds/-/tlds-1.261.0.tgz", + "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", + "license": "MIT", + "bin": { + "tlds": "bin.js" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1398,6 +1879,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, "node_modules/underscore": { "version": "1.13.8", "resolved": "https://registry.npmmirror.com/underscore/-/underscore-1.13.8.tgz", diff --git a/server/package.json b/server/package.json index 20bdec3..5007c95 100644 --- a/server/package.json +++ b/server/package.json @@ -5,18 +5,22 @@ "type": "module", "scripts": { "start": "node src/index.js", - "test": "node --test test/business.test.js" + "test": "node --test test/business.test.js", + "test:email-protocol": "node --test test/email-protocol.test.js", + "test:email-api": "node --test test/email-api-e2e.test.js" }, "dependencies": { "bcryptjs": "^2.4.3", "cors": "^2.8.5", "express": "^5.1.0", "helmet": "^8.1.0", + "imapflow": "^1.6.1", "jsonwebtoken": "^9.0.2", + "mailparser": "^3.9.14", "mammoth": "^1.9.0", "nodemailer": "^7.0.5", "pdf-parse": "^1.1.1", - "pg": "^8.13.1" - ,"ws": "^8.18.3" + "pg": "^8.13.1", + "ws": "^8.18.3" } } diff --git a/server/src/ai.js b/server/src/ai.js index f8ec421..8a48ef2 100644 --- a/server/src/ai.js +++ b/server/src/ai.js @@ -10,32 +10,48 @@ function parseJson(text) { export async function deepseek(system, user, json = false) { const apiKey = process.env.DEEPSEEK_API_KEY if (!apiKey) throw Object.assign(new Error('DEEPSEEK_API_KEY 尚未配置'), { status: 503 }) - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), Number(process.env.AI_TIMEOUT_MS || 45000)) - try { - const response = await fetch(`${(process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com/anthropic').replace(/\/$/, '')}/v1/messages`, { - method: 'POST', - signal: controller.signal, - headers: { - 'content-type': 'application/json', - 'x-api-key': apiKey, - 'anthropic-version': '2023-06-01', - }, - body: JSON.stringify({ - model: process.env.DEEPSEEK_MODEL || 'deepseek-v4-flash', - max_tokens: Number(process.env.AI_MAX_TOKENS || 4096), - temperature: json ? 0.1 : 0.3, - system, - messages: [{ role: 'user', content: [{ type: 'text', text: user }] }], - }), - }) - const body = await response.json() - if (!response.ok) throw Object.assign(new Error(body?.error?.message || `AI 请求失败 (${response.status})`), { status: 502 }) - const text = contentText(body) - return json ? parseJson(text) : text - } finally { - clearTimeout(timeout) + const endpoint = `${(process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com/anthropic').replace(/\/$/, '')}/v1/messages` + const attempts = json ? 2 : 1 + let prompt = user + let lastError + + for (let attempt = 0; attempt < attempts; attempt += 1) { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), Number(process.env.AI_TIMEOUT_MS || 45000)) + try { + const response = await fetch(endpoint, { + method: 'POST', + signal: controller.signal, + headers: { + 'content-type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: process.env.DEEPSEEK_MODEL || 'deepseek-v4-flash', + max_tokens: Number(process.env.AI_MAX_TOKENS || 4096), + temperature: json ? 0 : 0.3, + system, + messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }], + }), + }) + const body = await response.json() + if (!response.ok) { + throw Object.assign(new Error(body?.error?.message || `AI 请求失败 (${response.status})`), { status: 502 }) + } + const text = contentText(body) + if (!json) return text + try { + return parseJson(text) + } catch (error) { + lastError = error + prompt = `${user}\n\n上一次响应不是合法 JSON。请重新生成完整结果:只允许输出一个可被 JSON.parse 直接解析的 JSON 值;不得使用 Markdown;字符串中的双引号、换行和反斜杠必须正确转义。` + } + } finally { + clearTimeout(timeout) + } } + throw Object.assign(new Error('AI 返回格式异常,自动重试后仍无法解析'), { status: 502, cause: lastError }) } export function conversationContext(messages) { diff --git a/server/src/email.js b/server/src/email.js new file mode 100644 index 0000000..1ecb137 --- /dev/null +++ b/server/src/email.js @@ -0,0 +1,176 @@ +import crypto from 'node:crypto' +import nodemailer from 'nodemailer' +import { ImapFlow } from 'imapflow' +import { simpleParser } from 'mailparser' + +function encryptionKey() { + const secret = process.env.EMAIL_CREDENTIAL_KEY || process.env.JWT_SECRET + if (!secret) throw Object.assign(new Error('EMAIL_CREDENTIAL_KEY 或 JWT_SECRET 尚未配置'), { status: 503 }) + return crypto.createHash('sha256').update(secret).digest() +} + +export function encryptEmailPassword(password) { + const iv = crypto.randomBytes(12) + const cipher = crypto.createCipheriv('aes-256-gcm', encryptionKey(), iv) + const encrypted = Buffer.concat([cipher.update(String(password), 'utf8'), cipher.final()]) + return `v1:${iv.toString('base64url')}:${cipher.getAuthTag().toString('base64url')}:${encrypted.toString('base64url')}` +} + +export function decryptEmailPassword(value) { + const [version, ivValue, tagValue, encryptedValue] = String(value || '').split(':') + if (version !== 'v1' || !ivValue || !tagValue || !encryptedValue) { + throw Object.assign(new Error('Email 凭据无效,请重新连接邮箱'), { status: 409 }) + } + const decipher = crypto.createDecipheriv('aes-256-gcm', encryptionKey(), Buffer.from(ivValue, 'base64url')) + decipher.setAuthTag(Buffer.from(tagValue, 'base64url')) + return Buffer.concat([ + decipher.update(Buffer.from(encryptedValue, 'base64url')), + decipher.final(), + ]).toString('utf8') +} + +function imapClient(channel, password) { + return new ImapFlow({ + host: channel.imapHost, + port: Number(channel.imapPort || 993), + secure: channel.imapSecure !== false, + auth: { user: channel.account, pass: password }, + logger: false, + tls: { rejectUnauthorized: channel.allowInvalidCertificate !== true }, + }) +} + +function smtpTransport(channel, password) { + return nodemailer.createTransport({ + host: channel.smtpHost, + port: Number(channel.smtpPort || 465), + secure: channel.smtpSecure !== false, + auth: { user: channel.account, pass: password }, + tls: { rejectUnauthorized: channel.allowInvalidCertificate !== true }, + }) +} + +export async function verifyEmailConnection(channel, password) { + const startedAt = Date.now() + const imap = imapClient(channel, password) + try { + await imap.connect() + await imap.mailboxOpen(channel.imapFolder || 'INBOX', { readOnly: true }) + } finally { + if (imap.usable) await imap.logout().catch(() => undefined) + } + const smtp = smtpTransport(channel, password) + await smtp.verify() + smtp.close() + return { latencyMs: Date.now() - startedAt } +} + +function address(value) { + const item = value?.value?.[0] + return { + name: String(item?.name || item?.address || '').trim(), + email: String(item?.address || '').trim().toLowerCase(), + } +} + +export async function fetchEmailMessages(channel, { full = false } = {}) { + const password = decryptEmailPassword(channel.encryptedPassword) + const client = imapClient(channel, password) + const messages = [] + try { + await client.connect() + await client.mailboxOpen(channel.imapFolder || 'INBOX') + let uids + if (!full && Number(channel.lastUid || 0) > 0) { + uids = await client.search({ uid: `${Number(channel.lastUid) + 1}:*` }, { uid: true }) + } else { + const days = Math.max(1, Math.min(365, Number(channel.syncDays || 30))) + uids = await client.search({ since: new Date(Date.now() - days * 86400000) }, { uid: true }) + } + uids = (uids || []).slice(-500) + if (!uids.length) return { messages, lastUid: Number(channel.lastUid || 0) } + + for await (const row of client.fetch(uids, { + uid: true, + envelope: true, + flags: true, + internalDate: true, + source: true, + }, { uid: true })) { + const parsed = await simpleParser(row.source) + const sender = address(parsed.from) + const recipients = parsed.to?.value || [] + const attachments = (parsed.attachments || []).map((attachment, index) => ({ + id: `${row.uid}-${index + 1}`, + name: attachment.filename || `attachment-${index + 1}`, + mimeType: attachment.contentType || 'application/octet-stream', + size: attachment.size || attachment.content?.length || 0, + contentId: attachment.contentId, + base64: attachment.content?.length <= 20 * 1024 * 1024 + ? attachment.content.toString('base64') + : undefined, + })) + messages.push({ + uid: Number(row.uid), + messageId: String(parsed.messageId || `imap-${row.uid}`), + inReplyTo: parsed.inReplyTo || undefined, + references: Array.isArray(parsed.references) ? parsed.references : parsed.references ? [parsed.references] : [], + subject: String(parsed.subject || '(无主题)').trim(), + from: sender, + to: recipients.map((item) => ({ name: item.name || item.address || '', email: String(item.address || '').toLowerCase() })), + text: String(parsed.text || '').trim() || String(parsed.html || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(), + html: typeof parsed.html === 'string' ? parsed.html : undefined, + date: (parsed.date || row.internalDate || new Date()).toISOString(), + seen: row.flags?.has('\\Seen') || false, + attachments, + }) + } + return { + messages: messages.sort((a, b) => Date.parse(a.date) - Date.parse(b.date)), + lastUid: Math.max(Number(channel.lastUid || 0), ...messages.map((message) => message.uid)), + } + } finally { + if (client.usable) await client.logout().catch(() => undefined) + } +} + +export async function sendEmailMessage(channel, { + to, + subject, + text, + html, + inReplyTo, + references, + attachments, +}) { + const password = decryptEmailPassword(channel.encryptedPassword) + const transport = smtpTransport(channel, password) + try { + return await transport.sendMail({ + from: channel.displayName ? { name: channel.displayName, address: channel.account } : channel.account, + to, + subject, + text, + html, + inReplyTo, + references, + attachments, + }) + } finally { + transport.close() + } +} + +export async function markEmailUidsSeen(channel, uids) { + const values = [...new Set(uids.map(Number).filter(Number.isFinite))] + if (!values.length) return + const password = decryptEmailPassword(channel.encryptedPassword) + const client = imapClient(channel, password) + try { + await client.connect() + await client.mailboxOpen(channel.imapFolder || 'INBOX') + await client.messageFlagsAdd(values, ['\\Seen'], { uid: true }) + } finally { + if (client.usable) await client.logout().catch(() => undefined) + } +} diff --git a/server/src/index.js b/server/src/index.js index 05adb04..76badf2 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -15,6 +15,14 @@ import { import { gatewayRequest, postGateway, putGateway, uploadGateway } from './gateway.js' import { conversationContext, deepseek } from './ai.js' import { countBy, dateKey, percent, telegramAttachment } from './business.js' +import { + decryptEmailPassword, + encryptEmailPassword, + fetchEmailMessages, + markEmailUidsSeen, + sendEmailMessage, + verifyEmailConnection, +} from './email.js' const app = express() const httpServer = http.createServer(app) @@ -86,12 +94,14 @@ ${contextText ? `\n${contextText}\n\n` : ''}\n${text} } function enqueueAutomaticTranslation(tenantId, conversationId, message) { - if (!message || message.sender !== 'customer' || message.type !== 'text' || !String(message.originalText || '').trim()) return + if (!message || !['customer', 'agent'].includes(message.sender) || message.type !== 'text' || !String(message.originalText || '').trim()) return + if (message.sender === 'agent' && /[\u3400-\u9fff]/u.test(String(message.originalText || ''))) return if (message.translationStatus === 'failed' && Date.parse(message.translationRetryAt || '') > Date.now()) return const key = `${tenantId}:${conversationId}:${message.id}` - if (message.translatedText || automaticTranslationKeys.has(key)) return + const targetField = message.sender === 'agent' ? 'translationCn' : 'translatedText' + if (message[targetField] || automaticTranslationKeys.has(key)) return automaticTranslationKeys.add(key) - automaticTranslationQueue.push({ key, tenantId, conversationId, message }) + automaticTranslationQueue.push({ key, tenantId, conversationId, message, targetField }) pumpAutomaticTranslations() } @@ -130,9 +140,9 @@ function pumpAutomaticTranslations() { } } -async function processAutomaticTranslation({ tenantId, conversationId, message }) { +async function processAutomaticTranslation({ tenantId, conversationId, message, targetField }) { const current = await getResource(tenantId, `messages:${conversationId}`, String(message.id)) - if (!current || current.translatedText) return + if (!current || current[targetField]) return const text = String(current.originalText || '').trim() if (!text) return const sharedKey = `${message.id}:${crypto.createHash('sha256').update(text).digest('hex')}` @@ -153,7 +163,7 @@ async function processAutomaticTranslation({ tenantId, conversationId, message } if (!latest) return const translated = await putResource(tenantId, `messages:${conversationId}`, { ...latest, - translatedText: String(result.translated), + [targetField]: String(result.translated), originalLang: String(result.detectedLang || latest.originalLang || 'auto'), translationStatus: 'completed', translatedAt: new Date().toISOString(), @@ -713,9 +723,308 @@ app.get('/api/conversations', async (req, res) => { const customers = await listResources(req.auth.tenantId, 'customers') const customerMap = new Map(customers.map((x) => [x.id, x])) if (req.query.channel) conversations = conversations.filter((x) => x.channel === req.query.channel) + const filter = String(req.query.filter || 'all') + if (filter === 'archived') conversations = conversations.filter((x) => x.archived) + else conversations = conversations.filter((x) => !x.archived) + if (filter === 'unread') conversations = conversations.filter((x) => Number(x.unreadCount || 0) > 0) + if (filter === 'pending') conversations = conversations.filter((x) => x.status === 'pending') + if (filter === 'high-intent') conversations = conversations.filter((x) => x.intentLevel === 'high') + if (filter === 'risk') conversations = conversations.filter((x) => x.hasRisk) + if (filter === 'unassigned') conversations = conversations.filter((x) => !x.assigneeId) + if (filter === 'mine' && req.query.userId) conversations = conversations.filter((x) => x.assigneeId === req.query.userId) + const keyword = String(req.query.keyword || '').trim().toLowerCase() + if (keyword) { + conversations = conversations.filter((conversation) => { + const customer = customerMap.get(conversation.customerId) + return JSON.stringify({ conversation, customer }).toLowerCase().includes(keyword) + }) + } + // 会话排序只由最后一条真实消息驱动。查看、清零未读、分配负责人等 + // 维护操作会更新资源行,但不应让会话无故置顶。 + conversations.sort((a, b) => { + if (Boolean(a.isPinned) !== Boolean(b.isPinned)) return a.isPinned ? -1 : 1 + const activityTime = (conversation) => Date.parse( + conversation.lastMessage?.createdAt || + conversation.lastMessageAt || + conversation.createdAt || + conversation.updatedAt || + '', + ) || 0 + const timeDifference = activityTime(b) - activityTime(a) + if (timeDifference) return timeDifference + + // 邮件 Date 头通常只有秒级精度;同秒消息使用 IMAP UID / Telegram + // 消息 ID 打破平局,避免已读更新后的数据库行顺序影响会话位置。 + const activitySequence = (conversation) => Number( + conversation.lastMessage?.emailUid || + conversation.lastMessage?.telegramMessageId || + conversation.lastMessage?.id || + 0, + ) || 0 + const sequenceDifference = activitySequence(b) - activitySequence(a) + if (sequenceDifference) return sequenceDifference + return String(b.id).localeCompare(String(a.id)) + }) const list = conversations.map((x) => ({ ...x, customer: customerMap.get(x.customerId) })).filter((x) => x.customer) ok(res, { list, total: list.length }) }) + +async function connectedTelegramChannel(tenantId, channelId) { + const channels = await listResources(tenantId, 'channels') + const channel = channelId + ? channels.find((item) => item.id === channelId) + : channels.find((item) => item.type === 'telegram' && item.status === 'connected') + if (!channel || channel.type !== 'telegram') throw fail(409, '没有可用的 Telegram 渠道') + if (!channel.gatewayAccountId) throw fail(409, 'Telegram 渠道未关联账号网关') + return channel +} + +function telegramChatId(result) { + const value = result?.tgChatId ?? result?.chatId ?? result?.chat_id ?? result?.id + const number = Number(value) + return Number.isSafeInteger(number) && number !== 0 ? String(value) : '' +} + +function telegramServiceMessageText(content = {}) { + const type = String(content['@type'] || '') + const labels = { + messageContactRegistered: '该联系人已加入 Telegram', + messageChatJoinByLink: '通过邀请链接加入了群组', + messageChatJoinByRequest: '入群申请已通过', + messagePinMessage: '置顶了一条消息', + messageChatDeleteMember: '一名成员已退出或被移除', + messageChatSetMessageAutoDeleteTime: '群组自动删除时间已更新', + } + if (type === 'messageBasicGroupChatCreate' || type === 'messageSupergroupChatCreate') { + return content.title ? `创建了群组「${content.title}」` : '创建了群组' + } + if (type === 'messageChatChangeTitle') { + return content.title ? `群组名称已修改为「${content.title}」` : '群组名称已修改' + } + if (type === 'messageChatAddMembers') return '新增了群组成员' + return labels[type] || '' +} + +async function importTelegramConversation(tenantId, channel, rawChatId) { + const chatId = String(rawChatId) + const conversationId = `${channel.id}:${chatId}` + // An explicit add/open means the user intentionally wants this chat back. + await deleteResource(tenantId, 'conversation-tombstones', conversationId) + const chat = await gatewayRequest(`/tg-accounts/${channel.gatewayAccountId}/chats/${chatId}`) + const identity = await resolveTelegramIdentity(channel, chatId, chat, {}) + const chatType = String(identity.chatType || chat.type || chat.raw?.type?.['@type'] || 'private') + const customerId = `tg-${channel.gatewayAccountId}-${chatId}` + const existingCustomer = await getResource(tenantId, 'customers', customerId) + const customer = await putResource(tenantId, 'customers', withDefaults('customers', { + ...(existingCustomer || {}), + ...identity, + id: customerId, + telegram: chatId, + telegramChatType: chatType, + channel: 'telegram', + source: 'telegram', + })) + const existingConversation = await getResource(tenantId, 'conversations', conversationId) + const history = await gatewayRequest( + `/tg-accounts/${channel.gatewayAccountId}/chats/${chatId}/history?limit=100`, + ).catch(() => ({ messages: [] })) + let lastMessage = existingConversation?.lastMessage + for (const raw of [...(history?.messages || [])].reverse()) { + const content = raw.content || {} + const serviceText = telegramServiceMessageText(content) + const typeMap = { + messagePhoto: 'image', + messageDocument: 'file', + messageVoiceNote: 'audio', + messageAudio: 'audio', + messageSticker: 'file', + } + const existingMessage = await getResource(tenantId, `messages:${conversationId}`, String(raw.id)) + lastMessage = await putResource(tenantId, `messages:${conversationId}`, { + ...(existingMessage || {}), + id: String(raw.id), + conversationId, + sender: serviceText ? 'system' : raw.is_outgoing ? 'agent' : 'customer', + type: typeMap[content['@type']] || 'text', + originalText: + serviceText + || existingMessage?.originalText + || content?.text?.text + || content?.caption?.text, + translatedText: serviceText ? undefined : existingMessage?.translatedText, + translationCn: serviceText ? undefined : existingMessage?.translationCn, + translationStatus: serviceText ? undefined : existingMessage?.translationStatus, + originalLang: existingMessage?.originalLang || 'auto', + status: raw.is_outgoing ? 'sent' : 'delivered', + createdAt: new Date(Number(raw.date || Date.now() / 1000) * 1000).toISOString(), + telegramContent: content, + ...telegramAttachment(content), + }) + } + const conversation = await putResource(tenantId, 'conversations', { + ...(existingConversation || {}), + id: conversationId, + customerId: customer.id, + channel: 'telegram', + channelId: channel.id, + telegramChatId: chatId, + telegramChatType: chatType, + unreadCount: Number(chat.unreadCount || chat.raw?.unread_count || 0), + status: 'replied', + assigneeId: existingConversation?.assigneeId || null, + intentLevel: existingConversation?.intentLevel || 'low', + sentiment: existingConversation?.sentiment || 'neutral', + hasRisk: existingConversation?.hasRisk || false, + summary: existingConversation?.summary || '', + lastMessage, + createdAt: existingConversation?.createdAt || new Date().toISOString(), + }) + return { conversation, customer } +} + +app.post('/api/conversations/telegram/open', async (req, res) => { + const identifier = String(req.body.identifier || '').trim() + const targetType = String(req.body.targetType || 'user') + if (!identifier) throw fail(400, '请输入 Telegram 用户、群组或邀请链接') + const channel = await connectedTelegramChannel(req.auth.tenantId, req.body.channelId) + const accountPath = `/tg-accounts/${channel.gatewayAccountId}` + let result + const inviteLink = /^(?:https?:\/\/)?(?:t\.me|telegram\.(?:me|dog))\/(?:\+|joinchat\/)/i.test(identifier) + const username = identifier + .replace(/^https?:\/\/(?:t\.me|telegram\.(?:me|dog))\//i, '') + .replace(/^@/, '') + .split(/[/?#]/)[0] + + if (inviteLink || targetType === 'invite') { + result = await postGateway(`${accountPath}/groups/join-by-invite-link`, { inviteLink: identifier }) + } else if (targetType === 'chat' || targetType === 'group') { + result = /^-?\d+$/.test(identifier) + ? await gatewayRequest(`${accountPath}/chats/${identifier}`) + : await postGateway(`${accountPath}/dialogs/open-by-username`, { username }) + } else if (/^\+\d{7,15}$/.test(identifier)) { + const user = await postGateway(`${accountPath}/dialogs/open-by-phone`, { phoneNumber: identifier }) + const userId = Number(user?.id ?? user?.user_id) + if (!Number.isSafeInteger(userId) || userId <= 0) throw fail(404, '未找到该手机号对应的 Telegram 用户') + result = await postGateway(`${accountPath}/dialogs/open-by-user/${userId}`, {}) + } else if (/^\d+$/.test(identifier)) { + result = await postGateway(`${accountPath}/dialogs/open-by-user/${identifier}`, {}) + } else { + result = await postGateway(`${accountPath}/dialogs/open-by-username`, { username }) + } + + const chatId = telegramChatId(result) + if (!chatId) throw fail(502, 'Telegram 返回结果中缺少有效会话 ID') + const value = await importTelegramConversation(req.auth.tenantId, channel, chatId) + await audit(req, 'telegram.conversation.open', value.conversation.id, { + targetType, + identifier: /^\+\d/.test(identifier) ? `${identifier.slice(0, 4)}***` : identifier, + chatId, + }) + ok(res, value) +}) + +app.post('/api/conversations/:id/actions', async (req, res) => { + const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id) + if (!conversation) throw fail(404, '会话不存在') + if (conversation.channel !== 'telegram') throw fail(409, '当前操作仅支持 Telegram 会话') + const channel = await connectedTelegramChannel(req.auth.tenantId, conversation.channelId) + const chatId = String(conversation.telegramChatId || '') + if (!/^-?\d+$/.test(chatId)) throw fail(409, '会话缺少有效 Telegram Chat ID') + const action = String(req.body.action || '') + const isTelegramChannel = String(conversation.telegramChatType || '').toLowerCase() === 'channel' + const isTelegramGroupLike = + chatId.startsWith('-') + || ['group', 'supergroup', 'channel'].includes( + String(conversation.telegramChatType || '').toLowerCase(), + ) + if ( + ['clear', 'delete', 'leave', 'block', 'unblock'].includes(action) + && !['admin', 'manager'].includes(req.auth.role) + ) { + throw fail(403, '仅管理员或经理可执行清空、删除、退出和拉黑操作') + } + const base = `/tg-accounts/${channel.gatewayAccountId}` + let patch = {} + + if (action === 'clear' || action === 'delete') { + if (action === 'clear' && isTelegramChannel) { + throw fail(409, 'Telegram 频道不支持仅为当前账号清空历史;请使用“退出并删除会话”') + } + if (action === 'delete' && isTelegramGroupLike) { + await postGateway(`${base}/chats/${chatId}/leave`, {}).catch((error) => { + if (Number(error?.status || 0) !== 400) throw error + }) + } else { + await gatewayRequest(`${base}/chats/${chatId}/history`, { + method: 'DELETE', + body: JSON.stringify({ + removeFromChatList: action === 'delete', + revoke: false, + }), + }) + } + await pool.query( + 'DELETE FROM resources WHERE tenant_id=$1 AND kind=$2', + [req.auth.tenantId, `messages:${conversation.id}`], + ) + if (action === 'delete') { + await putResource(req.auth.tenantId, 'conversation-tombstones', { + id: conversation.id, + channelId: conversation.channelId, + telegramChatId: chatId, + telegramChatType: conversation.telegramChatType, + deletedAt: new Date().toISOString(), + }) + await deleteResource(req.auth.tenantId, 'conversations', conversation.id) + await audit(req, 'telegram.conversation.delete', conversation.id, { chatId }) + ok(res, { success: true, deleted: true }) + return + } + const { lastMessage: _lastMessage, ...withoutLastMessage } = conversation + const value = await putResource(req.auth.tenantId, 'conversations', { + ...withoutLastMessage, + unreadCount: 0, + historyComplete: true, + historyOldestMessageId: undefined, + }) + await audit(req, 'telegram.conversation.clear', conversation.id, { chatId }) + ok(res, value) + return + } + + if (action === 'pin' || action === 'unpin') { + await postGateway(`${base}/chats/${chatId}/pin`, { pinned: action === 'pin', archived: false }) + patch = { isPinned: action === 'pin' } + } else if (action === 'archive' || action === 'unarchive') { + await postGateway(`${base}/chats/${chatId}/${action}`, {}) + patch = { archived: action === 'archive' } + } else if (action === 'mute' || action === 'unmute') { + const seconds = action === 'mute' + ? Math.max(60, Math.min(31_536_000, Number(req.body.seconds || 28_800))) + : 0 + await putGateway(`${base}/chats/${chatId}/mute`, { seconds }) + patch = { mutedUntil: seconds ? new Date(Date.now() + seconds * 1000).toISOString() : null } + } else if (action === 'mark-unread') { + await postGateway(`${base}/chats/${chatId}/marked-unread`, { marked: true }) + patch = { unreadCount: Math.max(1, Number(conversation.unreadCount || 0)), markedUnread: true } + } else if (action === 'leave') { + await postGateway(`${base}/chats/${chatId}/leave`, {}) + patch = { leftAt: new Date().toISOString(), status: 'closed' } + } else if (action === 'block' || action === 'unblock') { + const customer = await getResource(req.auth.tenantId, 'customers', conversation.customerId) + const userId = Number(customer?.telegramUserId) + if (!Number.isSafeInteger(userId) || userId <= 0) throw fail(409, '该会话没有可拉黑的 Telegram 用户 ID') + await postGateway(`${base}/dialogs/users/${userId}/${action}`, {}) + patch = { isBlocked: action === 'block' } + } else { + throw fail(400, '不支持的 Telegram 会话操作') + } + + const value = await patchResource(req.auth.tenantId, 'conversations', conversation.id, patch) + await audit(req, `telegram.conversation.${action}`, conversation.id, { chatId, ...patch }) + ok(res, value) +}) + app.get('/api/conversations/:id', async (req, res) => { const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id) if (!conversation) throw fail(404, '会话不存在') @@ -724,14 +1033,16 @@ app.get('/api/conversations/:id', async (req, res) => { }) app.get('/api/conversations/:id/messages', async (req, res) => { const list = await listResources(req.auth.tenantId, `messages:${req.params.id}`) - for (const message of list.slice(0, 50)) { - enqueueAutomaticTranslation(req.auth.tenantId, req.params.id, message) - } list.sort((a, b) => { const time = Date.parse(a.createdAt || '') - Date.parse(b.createdAt || '') if (time) return time return Number(a.id || 0) - Number(b.id || 0) }) + // 首次打开会话时为最近的历史消息补齐译文。按真实消息时间取最近 + // 100 条,避免资源更新时间导致旧消息长期占据回填窗口。 + for (const message of list.slice(-100)) { + enqueueAutomaticTranslation(req.auth.tenantId, req.params.id, message) + } ok(res, list) }) app.patch('/api/conversations/:id/messages/:messageId', async (req, res) => { @@ -762,6 +1073,22 @@ app.get('/api/conversations/:id/messages/:messageId/attachment', async (req, res const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id) if (!conversation) throw fail(404, '会话不存在') const message = await getResource(req.auth.tenantId, `messages:${req.params.id}`, req.params.messageId) + if (conversation.channel === 'email') { + const attachments = await listResources( + req.auth.tenantId, + `email-attachments:${req.params.id}:${req.params.messageId}`, + ) + const attachment = req.query.attachmentId + ? attachments.find((item) => item.id === req.query.attachmentId) + : attachments[0] + if (!attachment?.base64) throw fail(404, '邮件附件不存在或超过 20MB 下载限制') + ok(res, { + dataUrl: `data:${attachment.mimeType || 'application/octet-stream'};base64,${attachment.base64}`, + mimeType: attachment.mimeType, + name: attachment.name, + }) + return + } if (!message?.telegramFileId) throw fail(404, '该消息没有可下载的 Telegram 文件') const channel = await getResource(req.auth.tenantId, 'channels', conversation.channelId) if (!channel?.gatewayAccountId) throw fail(409, '会话未关联 Telegram 账号') @@ -779,6 +1106,27 @@ app.post('/api/conversations/:id/read', async (req, res) => { const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id) if (!conversation) throw fail(404, '会话不存在') const channel = await getResource(req.auth.tenantId, 'channels', conversation.channelId) + if (conversation.channel === 'email') { + if (!channel?.encryptedPassword) throw fail(409, '会话未关联可用 Email 渠道') + const messages = await listResources(req.auth.tenantId, `messages:${req.params.id}`) + const requested = Array.isArray(req.body.messageIds) ? new Set(req.body.messageIds.map(String)) : null + const uids = messages + .filter((message) => message.sender === 'customer' && (!requested || requested.has(String(message.id)))) + .map((message) => Number(message.emailUid)) + .filter(Number.isFinite) + await markEmailUidsSeen(channel, uids) + const updated = await patchResource(req.auth.tenantId, 'conversations', req.params.id, { + unreadCount: 0, + lastReadAt: new Date().toISOString(), + }) + broadcast(req.auth.tenantId, { + type: 'conversation.read', + conversationId: req.params.id, + conversation: updated, + }) + ok(res, updated) + return + } if (!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) : [] @@ -819,11 +1167,11 @@ 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) - if (!channel?.gatewayAccountId || !conversation.telegramChatId) throw fail(409, '会话未关联可用 Telegram 渠道') const originalText = String(req.body.text || '').trim() if (!originalText) throw fail(400, '消息内容不能为空') let telegramText = originalText let originalLang = 'auto' + let translationCn = String(req.body.translationCn || '').trim().slice(0, 8000) if (req.body.autoTranslate !== false && /[\u3400-\u9fff]/u.test(originalText)) { const context = await listResources(req.auth.tenantId, `messages:${conversation.id}`) const translation = await deepseek( @@ -834,47 +1182,161 @@ app.post('/api/conversations/:id/messages', async (req, res) => { if (!translation?.translated) throw fail(502, 'AI 未返回英文全文译文,消息未发送') telegramText = String(translation.translated).trim() originalLang = String(translation.detectedLang || 'zh-CN') + translationCn = originalText } + if (conversation.channel === 'email') { + if (!channel?.encryptedPassword) throw fail(409, '会话未关联可用 Email 渠道') + const customer = await getResource(req.auth.tenantId, 'customers', conversation.customerId) + if (!customer?.email) throw fail(409, '客户没有可用邮箱地址') + const history = (await listResources(req.auth.tenantId, `messages:${conversation.id}`)) + .sort((a, b) => Date.parse(a.createdAt || '') - Date.parse(b.createdAt || '')) + const latestEmail = [...history].reverse().find((message) => message.emailMessageId) + const baseSubject = normalizeEmailSubject(conversation.emailSubject || latestEmail?.emailSubject || '业务沟通') + const result = await sendEmailMessage(channel, { + to: customer.email, + subject: /^re:/i.test(conversation.emailSubject || '') ? conversation.emailSubject : `Re: ${baseSubject}`, + text: telegramText, + inReplyTo: latestEmail?.emailMessageId, + references: [ + ...(latestEmail?.emailReferences || []), + ...(latestEmail?.emailMessageId ? [latestEmail.emailMessageId] : []), + ], + }) + const message = await putResource(req.auth.tenantId, `messages:${conversation.id}`, { + id: stableEmailId('email-outgoing', result.messageId || uid()), + conversationId: conversation.id, + sender: 'agent', + type: 'text', + originalText, + translatedText: telegramText !== originalText ? telegramText : undefined, + translationCn: translationCn || undefined, + translationStatus: translationCn ? 'completed' : 'pending', + originalLang, + status: 'sent', + replyToId: req.body.replyToId ? String(req.body.replyToId) : undefined, + replyPreview: req.body.replyPreview ? String(req.body.replyPreview).slice(0, 240) : undefined, + emailMessageId: result.messageId, + emailInReplyTo: latestEmail?.emailMessageId, + emailReferences: latestEmail?.emailReferences || [], + emailSubject: `Re: ${baseSubject}`, + createdAt: new Date().toISOString(), + }) + const updatedConversation = await patchResource(req.auth.tenantId, 'conversations', conversation.id, { + lastMessage: message, + status: 'replied', + updatedAt: message.createdAt, + }) + await audit(req, 'email.message.send', conversation.id, { + to: customer.email, + messageId: result.messageId, + }) + broadcast(req.auth.tenantId, { + type: 'message.updated', + conversationId: conversation.id, + message, + customer, + conversation: updatedConversation, + }) + ok(res, message) + enqueueAutomaticTranslation(req.auth.tenantId, conversation.id, message) + return + } + if (!channel?.gatewayAccountId || !conversation.telegramChatId) throw fail(409, '会话未关联可用 Telegram 渠道') const result = await postGateway(`/tg-accounts/${channel.gatewayAccountId}/messages/send-direct`, { chatId: conversation.telegramChatId, text: telegramText, replyToMessageId: req.body.replyToId, idempotencyKey: req.get('idempotency-key') || uid(), }) const resultMessageId = String(result?.id || result?.messageId || uid()) - if (telegramText !== originalText) { - await putResource(req.auth.tenantId, 'outgoing-message-meta', { - id: resultMessageId, - conversationId: conversation.id, - originalText, - translatedText: telegramText, - originalLang, - targetLang: 'en', - createdAt: new Date().toISOString(), - }) - } + 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: req.body.type || 'text', originalText, translatedText: telegramText !== originalText ? telegramText : undefined, + translationCn: translationCn || undefined, + translationStatus: translationCn ? 'completed' : 'pending', originalLang, status: result?.deliveryState === 'sent' ? 'sent' : 'sending', replyToId: req.body.replyToId ? String(req.body.replyToId) : undefined, replyPreview: req.body.replyPreview ? String(req.body.replyPreview).slice(0, 240) : undefined, createdAt: new Date().toISOString(), }) - await patchResource(req.auth.tenantId, 'conversations', conversation.id, { lastMessage: message, status: 'replied' }) - broadcast(req.auth.tenantId, { type: 'message.updated', conversationId: conversation.id, message }) + const updatedConversation = await patchResource( + req.auth.tenantId, + 'conversations', + conversation.id, + { lastMessage: message, status: 'replied' }, + ) + const customer = await getResource(req.auth.tenantId, 'customers', conversation.customerId) + broadcast(req.auth.tenantId, { + type: 'message.updated', + conversationId: conversation.id, + message, + customer, + conversation: updatedConversation, + }) ok(res, message) + enqueueAutomaticTranslation(req.auth.tenantId, conversation.id, message) }) app.post('/api/conversations/:id/media', async (req, res) => { const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id) if (!conversation) throw fail(404, '会话不存在') const channel = await getResource(req.auth.tenantId, 'channels', conversation.channelId) - if (!channel?.gatewayAccountId) throw fail(409, '会话未关联 Telegram 账号') const { name, mimeType, base64, kind = 'document' } = req.body const bytes = Buffer.from(String(base64 || ''), 'base64') if (!name || !bytes.length) throw fail(400, '请选择有效文件') if (bytes.length > 20 * 1024 * 1024) throw fail(413, '单个文件不能超过 20MB') + if (conversation.channel === 'email') { + if (!channel?.encryptedPassword) throw fail(409, '会话未关联可用 Email 渠道') + const customer = await getResource(req.auth.tenantId, 'customers', conversation.customerId) + if (!customer?.email) throw fail(409, '客户没有可用邮箱地址') + const result = await sendEmailMessage(channel, { + to: customer.email, + subject: `Re: ${normalizeEmailSubject(conversation.emailSubject || '业务沟通')}`, + text: String(req.body.caption || `请查收附件:${name}`), + attachments: [{ filename: name, content: bytes, contentType: mimeType || 'application/octet-stream' }], + }) + const message = await putResource(req.auth.tenantId, `messages:${conversation.id}`, { + id: stableEmailId('email-outgoing', result.messageId || uid()), + conversationId: conversation.id, + sender: 'agent', + type: mimeType?.startsWith('image/') ? 'image' : 'file', + originalText: String(req.body.caption || ''), + originalLang: 'auto', + status: 'sent', + attachmentName: name, + attachmentMimeType: mimeType || 'application/octet-stream', + attachmentSize: bytes.length, + emailMessageId: result.messageId, + emailSubject: `Re: ${normalizeEmailSubject(conversation.emailSubject || '业务沟通')}`, + createdAt: new Date().toISOString(), + }) + const updatedConversation = await patchResource(req.auth.tenantId, 'conversations', conversation.id, { + lastMessage: message, + status: 'replied', + updatedAt: message.createdAt, + }) + await audit(req, 'email.attachment.send', conversation.id, { name, size: bytes.length }) + broadcast(req.auth.tenantId, { + type: 'message.updated', + conversationId: conversation.id, + message, + customer, + conversation: updatedConversation, + }) + ok(res, message) + return + } + if (!channel?.gatewayAccountId) throw fail(409, '会话未关联 Telegram 账号') const form = new FormData() form.append('file', new Blob([bytes], { type: mimeType || 'application/octet-stream' }), name) form.append('chatId', String(conversation.telegramChatId)) @@ -888,17 +1350,39 @@ app.post('/api/conversations/:id/album', async (req, res) => { const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id) if (!conversation) throw fail(404, '会话不存在') const channel = await getResource(req.auth.tenantId, 'channels', conversation.channelId) - if (!channel?.gatewayAccountId) throw fail(409, '会话未关联 Telegram 账号') const files = Array.isArray(req.body.files) ? req.body.files : [] if (files.length < 2 || files.length > 10) throw fail(400, '相册必须包含 2 到 10 个图片或视频') - const form = new FormData() let totalSize = 0 + const prepared = [] for (const item of files) { const bytes = Buffer.from(String(item.base64 || ''), 'base64') if (!item.name || !bytes.length) throw fail(400, '相册包含无效文件') totalSize += bytes.length if (bytes.length > 20 * 1024 * 1024 || totalSize > 100 * 1024 * 1024) throw fail(413, '相册文件过大') - form.append('files', new Blob([bytes], { type: item.mimeType || 'image/jpeg' }), item.name) + prepared.push({ ...item, bytes }) + } + if (conversation.channel === 'email') { + if (!channel?.encryptedPassword) throw fail(409, '会话未关联可用 Email 渠道') + const customer = await getResource(req.auth.tenantId, 'customers', conversation.customerId) + if (!customer?.email) throw fail(409, '客户没有可用邮箱地址') + const result = await sendEmailMessage(channel, { + to: customer.email, + subject: `Re: ${normalizeEmailSubject(conversation.emailSubject || '业务沟通')}`, + text: `请查收 ${prepared.length} 个附件。`, + attachments: prepared.map((item) => ({ + filename: item.name, + content: item.bytes, + contentType: item.mimeType || 'application/octet-stream', + })), + }) + await audit(req, 'email.album.send', conversation.id, { count: prepared.length, size: totalSize }) + ok(res, { success: true, messageId: result.messageId, count: prepared.length }) + return + } + if (!channel?.gatewayAccountId) throw fail(409, '会话未关联 Telegram 账号') + const form = new FormData() + for (const item of prepared) { + form.append('files', new Blob([item.bytes], { type: item.mimeType || 'image/jpeg' }), item.name) } form.append('chatId', String(conversation.telegramChatId)) const result = await uploadGateway(`/tg-accounts/${channel.gatewayAccountId}/files/upload-album`, form) @@ -906,12 +1390,195 @@ app.post('/api/conversations/:id/album', async (req, res) => { ok(res, result) }) +function publicChannel(channel) { + if (!channel) return channel + const { + encryptedPassword: _encryptedPassword, + ...safe + } = channel + return safe +} + +function normalizeEmailSubject(subject) { + return String(subject || '(无主题)') + .replace(/^\s*((re|fw|fwd)\s*:\s*)+/ig, '') + .replace(/\s+/g, ' ') + .trim() +} + +function stableEmailId(prefix, value) { + return `${prefix}-${crypto.createHash('sha256').update(String(value)).digest('hex').slice(0, 24)}` +} + +async function appendChannelLog(tenantId, channelId, detail, status = 'success', action = 'sync') { + return putResource(tenantId, `channel-logs:${channelId}`, { + id: uid(), + channelId, + action, + detail, + status, + createdAt: new Date().toISOString(), + }) +} + +async function syncEmailTenant(tenantId, channel, { full = false } = {}) { + if (!channel?.encryptedPassword) throw fail(409, 'Email 渠道凭据缺失,请重新连接') + const result = await fetchEmailMessages(channel, { full }) + let messagesSynced = 0 + let conversationsUpdated = 0 + + for (const incoming of result.messages) { + const remote = incoming.from.email && incoming.from.email !== String(channel.account || '').toLowerCase() + ? incoming.from + : incoming.to.find((item) => item.email && item.email !== String(channel.account || '').toLowerCase()) + if (!remote?.email) continue + + const customers = await listResources(tenantId, 'customers') + const existingCustomer = customers.find((customer) => + String(customer.email || '').toLowerCase() === remote.email, + ) + const customerId = existingCustomer?.id || stableEmailId('email-customer', `${channel.id}:${remote.email}`) + const customer = await putResource(tenantId, 'customers', withDefaults('customers', { + ...(existingCustomer || {}), + id: customerId, + name: remote.name || existingCustomer?.name || remote.email.split('@')[0], + email: remote.email, + channel: 'email', + source: 'email', + sourcePath: channel.account, + })) + + const normalizedSubject = normalizeEmailSubject(incoming.subject) + const threadKey = `${remote.email}:${normalizedSubject.toLowerCase()}` + const conversationId = stableEmailId('email-conversation', `${channel.id}:${threadKey}`) + const existingConversation = await getResource(tenantId, 'conversations', conversationId) + const messageId = stableEmailId('email-message', `${channel.id}:${incoming.uid}`) + const existingMessage = await getResource(tenantId, `messages:${conversationId}`, messageId) + const outgoing = incoming.from.email === String(channel.account || '').toLowerCase() + + const attachmentRefs = [] + for (const attachment of incoming.attachments || []) { + const attachmentId = stableEmailId('email-attachment', `${channel.id}:${incoming.uid}:${attachment.id}`) + if (attachment.base64) { + await putResource(tenantId, `email-attachments:${conversationId}:${messageId}`, { + id: attachmentId, + name: attachment.name, + mimeType: attachment.mimeType, + size: attachment.size, + base64: attachment.base64, + createdAt: incoming.date, + }) + } + attachmentRefs.push({ + id: attachmentId, + name: attachment.name, + mimeType: attachment.mimeType, + size: attachment.size, + available: Boolean(attachment.base64), + }) + } + + const message = await putResource(tenantId, `messages:${conversationId}`, { + ...(existingMessage || {}), + id: messageId, + conversationId, + sender: outgoing ? 'agent' : 'customer', + senderName: incoming.from.name || incoming.from.email, + type: 'text', + originalText: incoming.text || (attachmentRefs.length ? `[${attachmentRefs.length} 个附件]` : '(空邮件)'), + originalLang: existingMessage?.originalLang || 'auto', + status: outgoing ? 'sent' : incoming.seen ? 'read' : 'delivered', + createdAt: incoming.date, + emailUid: incoming.uid, + emailMessageId: incoming.messageId, + emailInReplyTo: incoming.inReplyTo, + emailReferences: incoming.references, + emailSubject: incoming.subject, + emailAttachments: attachmentRefs, + }) + + const unreadIncrement = !existingMessage && !outgoing && !incoming.seen ? 1 : 0 + const updatedConversation = await putResource(tenantId, 'conversations', { + ...(existingConversation || {}), + id: conversationId, + customerId: customer.id, + channel: 'email', + channelId: channel.id, + emailThreadKey: threadKey, + emailSubject: normalizedSubject, + unreadCount: Math.max(0, Number(existingConversation?.unreadCount || 0) + unreadIncrement), + status: !outgoing && !incoming.seen ? 'pending' : existingConversation?.status || 'replied', + assigneeId: existingConversation?.assigneeId || null, + intentLevel: existingConversation?.intentLevel || 'low', + sentiment: existingConversation?.sentiment || 'neutral', + hasRisk: existingConversation?.hasRisk || false, + summary: existingConversation?.summary || normalizedSubject, + lastMessage: message, + createdAt: existingConversation?.createdAt || incoming.date, + updatedAt: incoming.date, + }) + if (!existingMessage) { + messagesSynced++ + if (!outgoing) { + enqueueAutomaticTranslation(tenantId, conversationId, message) + enqueueAutomaticProfile(tenantId, conversationId) + await putResource(tenantId, 'notifications', { + id: uid(), + type: 'message', + title: `新邮件 · ${customer.name}`, + description: incoming.subject || '收到一封新邮件', + link: `/inbox?c=${encodeURIComponent(conversationId)}`, + read: false, + createdAt: new Date().toISOString(), + }) + } + } + conversationsUpdated++ + broadcast(tenantId, { + type: existingMessage ? 'message.updated' : 'message.received', + conversationId, + message, + customer, + conversation: updatedConversation, + }) + } + + await patchResource(tenantId, 'channels', channel.id, { + lastUid: result.lastUid, + lastSyncAt: new Date().toISOString(), + status: 'connected', + errorMessage: undefined, + }) + await appendChannelLog( + tenantId, + channel.id, + `Email 同步完成:${conversationsUpdated} 个线程,新增 ${messagesSynced} 封邮件`, + ) + broadcast(tenantId, { + type: 'sync.completed', + channelId: channel.id, + chats: conversationsUpdated, + messages: messagesSynced, + }) + return { chats: conversationsUpdated, messages: messagesSynced, lastUid: result.lastUid } +} + app.get('/api/channels', async (req, res) => { - const telegram = await listResources(req.auth.tenantId, 'channels') - ok(res, telegram.length ? telegram : [{ - id: 'telegram-available', type: 'telegram', name: 'Telegram', status: 'unconnected', - todayMessages: 0, autoReply: false, - }]) + const channels = await listResources(req.auth.tenantId, 'channels') + const list = channels.map(publicChannel) + if (!channels.some((channel) => channel.type === 'telegram')) { + list.push({ + id: 'telegram-available', type: 'telegram', name: 'Telegram', status: 'unconnected', + todayMessages: 0, autoReply: false, + }) + } + if (!channels.some((channel) => channel.type === 'email')) { + list.push({ + id: 'email-available', type: 'email', name: '企业邮箱', status: 'unconnected', + todayMessages: 0, autoReply: false, + }) + } + ok(res, list) }) const telegramWebhookEvents = [ 'message.received', 'message.pending', 'message.sent', 'message.failed', @@ -941,8 +1608,18 @@ async function ensureChannelWebhook(tenantId, channel) { } app.post('/api/channels/connect', async (req, res) => { if (req.body.type !== 'telegram') throw fail(400, '当前版本仅支持 Telegram') - const accountId = req.body.gatewayAccountId || process.env.TELEGRAM_GATEWAY_ACCOUNT_ID - if (!accountId) throw fail(400, '缺少 Telegram 网关账号 ID') + const accountId = String(req.body.gatewayAccountId || '').trim() + if (!accountId) throw fail(400, '请使用手机号验证码流程绑定 Telegram,不允许自动占用服务器公共账号') + const duplicateBinding = await pool.query( + `SELECT 1 FROM resources + WHERE kind='channels' + AND tenant_id<>$1 + AND data->>'type'='telegram' + AND data->>'gatewayAccountId'=$2 + LIMIT 1`, + [req.auth.tenantId, String(accountId)], + ) + if (duplicateBinding.rowCount) throw fail(409, '该 Telegram 网关账号已绑定其他企业') const state = await postGateway(`/tg-accounts/${accountId}/auth/state`, {}) let channel = await putResource(req.auth.tenantId, 'channels', { type: 'telegram', name: 'Telegram', @@ -966,9 +1643,84 @@ app.post('/api/channels/connect', async (req, res) => { } ok(res, channel) }) +app.post('/api/channels/email/connect', async (req, res) => { + const account = String(req.body.account || '').trim().toLowerCase() + const password = String(req.body.password || '') + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(account)) throw fail(400, '请输入有效邮箱地址') + if (!password) throw fail(400, '请输入邮箱密码或应用专用密码') + const draft = { + type: 'email', + name: String(req.body.name || '企业邮箱').slice(0, 80), + displayName: String(req.body.displayName || '').slice(0, 120), + account, + imapHost: String(req.body.imapHost || '').trim(), + imapPort: Number(req.body.imapPort || 993), + imapSecure: req.body.imapSecure !== false, + imapFolder: String(req.body.imapFolder || 'INBOX').trim(), + smtpHost: String(req.body.smtpHost || '').trim(), + smtpPort: Number(req.body.smtpPort || 465), + smtpSecure: req.body.smtpSecure !== false, + syncDays: Math.max(1, Math.min(365, Number(req.body.syncDays || 30))), + allowInvalidCertificate: req.body.allowInvalidCertificate === true, + } + if (!draft.imapHost || !draft.smtpHost) throw fail(400, '请填写 IMAP 和 SMTP 服务器地址') + const verification = await verifyEmailConnection(draft, password) + let channel = await putResource(req.auth.tenantId, 'channels', { + ...draft, + encryptedPassword: encryptEmailPassword(password), + status: 'connected', + todayMessages: 0, + autoReply: false, + lastUid: 0, + createdAt: new Date().toISOString(), + lastVerifiedAt: new Date().toISOString(), + }) + await appendChannelLog( + req.auth.tenantId, + channel.id, + `IMAP 与 SMTP 验证成功(${verification.latencyMs}ms)`, + 'success', + 'connect', + ) + try { + await syncEmailTenant(req.auth.tenantId, channel, { full: true }) + channel = await getResource(req.auth.tenantId, 'channels', channel.id) + } catch (error) { + channel = await patchResource(req.auth.tenantId, 'channels', channel.id, { + errorMessage: `首次同步失败:${error.message}`, + }) + } + await audit(req, 'email.channel.connect', channel.id, { + account, + imapHost: draft.imapHost, + smtpHost: draft.smtpHost, + }) + ok(res, publicChannel(channel)) +}) app.post('/api/channels/telegram/start', async (req, res) => { const phone = String(req.body.phone || '').trim() if (!/^\+\d{7,15}$/.test(phone)) throw fail(400, '请输入国际格式手机号') + const existingChannels = await listResources(req.auth.tenantId, 'channels') + const existing = existingChannels.find((item) => item.type === 'telegram' && item.gatewayAccountId) + 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') { + state = await postGateway(`/tg-accounts/${existing.gatewayAccountId}/auth/phone`, { phoneNumber: phone }) + status = String(state?.status || state?.state || 'waiting_code') + } + const connectionReady = status === 'ready' && state?.connectionState === 'ready' + let channel = await patchResource(req.auth.tenantId, 'channels', existing.id, { + authState: status, + status: connectionReady ? 'connected' : 'disconnected', + }) + if (connectionReady) channel = await ensureChannelWebhook(req.auth.tenantId, channel) + ok(res, channel) + return + } const account = await postGateway('/tg-accounts', { phoneNumber: phone, firstName: 'Lingxi' }) await postGateway(`/tg-accounts/${account.id}/start`, {}) const channel = await putResource(req.auth.tenantId, 'channels', { @@ -981,7 +1733,11 @@ app.post('/api/channels/telegram/start', async (req, res) => { await new Promise((resolve) => setTimeout(resolve, 250)) } const authState = await postGateway(`/tg-accounts/${account.id}/auth/phone`, { phoneNumber: phone }) - ok(res, await patchResource(req.auth.tenantId, 'channels', channel.id, { authState: authState?.status || 'waiting_code' })) + const updated = await patchResource(req.auth.tenantId, 'channels', channel.id, { + authState: authState?.status || 'waiting_code', + }) + await audit(req, 'telegram.channel.auth.start', updated.id, { phoneMasked: phone.replace(/\d(?=\d{4})/g, '*') }) + ok(res, updated) }) app.post('/api/channels/telegram/:id/phone', async (req, res) => { const ch = await getResource(req.auth.tenantId, 'channels', req.params.id) @@ -991,23 +1747,49 @@ app.post('/api/channels/telegram/:id/phone', async (req, res) => { app.post('/api/channels/telegram/:id/code', async (req, res) => { const ch = await getResource(req.auth.tenantId, 'channels', req.params.id) if (!ch) throw fail(404, '渠道不存在') - const authState = await postGateway(`/tg-accounts/${ch.gatewayAccountId}/auth/code`, { code: req.body.code }) + const code = String(req.body.code || '').replace(/\s+/g, '') + if (!/^\d{4,8}$/.test(code)) throw fail(400, '请输入 4—8 位 Telegram 验证码') + const authState = await postGateway(`/tg-accounts/${ch.gatewayAccountId}/auth/code`, { code }) const channel = await patchResource(req.auth.tenantId, 'channels', ch.id, { authState: authState.status, status: authState.status === 'ready' && authState.connectionState === 'ready' ? 'connected' : 'disconnected', }) const readyChannel = authState.status === 'ready' ? await ensureChannelWebhook(req.auth.tenantId, channel) : channel + await audit(req, 'telegram.channel.auth.code', ch.id, { status: authState.status }) ok(res, { ...authState, channel: readyChannel }) }) app.post('/api/channels/telegram/:id/password', async (req, res) => { const ch = await getResource(req.auth.tenantId, 'channels', req.params.id) if (!ch) throw fail(404, '渠道不存在') - const authState = await postGateway(`/tg-accounts/${ch.gatewayAccountId}/auth/password`, { password: req.body.password }) + const password = String(req.body.password || '') + if (!password) throw fail(400, '请输入 Telegram 二步验证密码') + const authState = await postGateway(`/tg-accounts/${ch.gatewayAccountId}/auth/password`, { password }) const channel = await patchResource(req.auth.tenantId, 'channels', ch.id, { authState: authState.status, status: authState.status === 'ready' && authState.connectionState === 'ready' ? 'connected' : 'disconnected', }) - ok(res, { ...authState, channel: await ensureChannelWebhook(req.auth.tenantId, channel) }) + const readyChannel = authState.status === 'ready' + ? await ensureChannelWebhook(req.auth.tenantId, channel) + : channel + await audit(req, 'telegram.channel.auth.password', ch.id, { status: authState.status }) + ok(res, { ...authState, channel: readyChannel }) +}) +app.post('/api/channels/telegram/:id/state', async (req, res) => { + const ch = await getResource(req.auth.tenantId, 'channels', req.params.id) + if (!ch || ch.type !== 'telegram' || !ch.gatewayAccountId) throw fail(404, 'Telegram 渠道不存在') + let authState = await postGateway(`/tg-accounts/${ch.gatewayAccountId}/auth/state`, {}) + let status = String(authState?.status || authState?.state || ch.authState || 'waiting_code') + if (status === 'waiting_phone' && ch.account) { + authState = await postGateway(`/tg-accounts/${ch.gatewayAccountId}/auth/phone`, { phoneNumber: ch.account }) + status = String(authState?.status || authState?.state || 'waiting_code') + } + const connectionReady = status === 'ready' && authState?.connectionState === 'ready' + let channel = await patchResource(req.auth.tenantId, 'channels', ch.id, { + authState: status, + status: connectionReady ? 'connected' : 'disconnected', + }) + if (connectionReady) channel = await ensureChannelWebhook(req.auth.tenantId, channel) + ok(res, { ...authState, status, channel }) }) function telegramPhotoId(photo) { @@ -1036,6 +1818,7 @@ async function resolveTelegramIdentity(channel, chatId, fallback = {}, existing phone: String(identity?.phone || existing.phone || ''), avatarUrl, telegramPhotoFileId: photoFileId || existing.telegramPhotoFileId, + telegramChatType: String(identity?.chatType || fallback.type || existing.telegramChatType || 'private'), } } @@ -1045,6 +1828,8 @@ async function syncTelegramTenant(tenantId, channel) { let messagesSynced = 0 for (const chat of chats || []) { const chatId = String(chat.tgChatId) + const conversationId = `${channel.id}:${chatId}` + if (await getResource(tenantId, 'conversation-tombstones', conversationId)) continue const customerId = `tg-${channel.gatewayAccountId}-${chatId}` const existingCustomer = await getResource(tenantId, 'customers', customerId) const identity = await resolveTelegramIdentity(channel, chatId, chat, existingCustomer || {}) @@ -1053,9 +1838,9 @@ async function syncTelegramTenant(tenantId, channel) { ...identity, id: customerId, telegram: chatId, + telegramChatType: identity.telegramChatType || chat.type, source: 'telegram', })) - const conversationId = `${channel.id}:${chatId}` const existingConversation = await getResource(tenantId, 'conversations', conversationId) const recent = await gatewayRequest( `/tg-accounts/${channel.gatewayAccountId}/chats/${chatId}/history?limit=100`, @@ -1085,12 +1870,23 @@ async function syncTelegramTenant(tenantId, channel) { let lastMessage for (const raw of [...(history?.messages || [])].reverse()) { const content = raw.content || {} + const serviceText = telegramServiceMessageText(content) const typeMap = { messagePhoto: 'image', messageDocument: 'file', messageVoiceNote: 'audio', messageAudio: 'audio', messageSticker: 'file' } + const existingMessage = await getResource(tenantId, `messages:${conversationId}`, String(raw.id)) lastMessage = await putResource(tenantId, `messages:${conversationId}`, { - id: String(raw.id), conversationId, sender: raw.is_outgoing ? 'agent' : 'customer', + ...(existingMessage || {}), + id: String(raw.id), conversationId, + sender: serviceText ? 'system' : raw.is_outgoing ? 'agent' : 'customer', type: typeMap[content['@type']] || 'text', - originalText: content?.text?.text || content?.caption?.text || '', - originalLang: 'auto', status: raw.is_outgoing ? 'sent' : 'delivered', + originalText: + serviceText + || existingMessage?.originalText + || content?.text?.text + || content?.caption?.text, + translatedText: serviceText ? undefined : existingMessage?.translatedText, + translationCn: serviceText ? undefined : existingMessage?.translationCn, + translationStatus: serviceText ? undefined : existingMessage?.translationStatus, + originalLang: existingMessage?.originalLang || 'auto', status: raw.is_outgoing ? 'sent' : 'delivered', createdAt: new Date(Number(raw.date || Date.now() / 1000) * 1000).toISOString(), telegramContent: content, ...telegramAttachment(content), @@ -1100,7 +1896,8 @@ async function syncTelegramTenant(tenantId, channel) { await putResource(tenantId, 'conversations', { ...(existingConversation || {}), id: conversationId, customerId: customer.id, channel: 'telegram', channelId: channel.id, - telegramChatId: chatId, unreadCount: Number(chat.unreadCount || 0), + telegramChatId: chatId, telegramChatType: identity.telegramChatType || chat.type, + unreadCount: Number(chat.unreadCount || 0), status: Number(chat.unreadCount || 0) ? 'pending' : 'replied', assigneeId: null, intentLevel: 'low', sentiment: 'neutral', hasRisk: false, summary: '', lastMessage: lastMessage || (chat.lastMessage ? { @@ -1124,6 +1921,10 @@ async function syncTelegramTenant(tenantId, channel) { app.post('/api/channels/:id/sync', async (req, res) => { const channel = await getResource(req.auth.tenantId, 'channels', req.params.id) if (!channel) throw fail(404, '渠道不存在') + if (channel.type === 'email') { + ok(res, await syncEmailTenant(req.auth.tenantId, channel, { full: req.body.full === true })) + return + } ok(res, await syncTelegramTenant(req.auth.tenantId, channel)) }) @@ -1131,16 +1932,53 @@ app.post('/api/channels/test', async (req, res) => { const start = Date.now() const ch = await getResource(req.auth.tenantId, 'channels', req.body.id) if (!ch) throw fail(404, '渠道不存在') + if (ch.type === 'email') { + const verification = await verifyEmailConnection(ch, decryptEmailPassword(ch.encryptedPassword)) + ok(res, { success: true, latencyMs: verification.latencyMs, message: 'Email IMAP 与 SMTP 连接正常' }) + return + } 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.patch('/api/channels/:id', async (req, res) => { - const value = await patchResource(req.auth.tenantId, 'channels', req.params.id, req.body) - if (!value) throw fail(404, '渠道不存在'); ok(res, value) + const current = await getResource(req.auth.tenantId, 'channels', req.params.id) + if (!current) throw fail(404, '渠道不存在') + const patch = {} + if (typeof req.body.autoReply === 'boolean') patch.autoReply = req.body.autoReply + if (req.body.defaultOwnerId !== undefined) patch.defaultOwnerId = String(req.body.defaultOwnerId || '') + if (req.body.status !== undefined) { + const status = String(req.body.status) + if (!['connected', 'paused'].includes(status)) throw fail(400, '只允许暂停或恢复渠道') + if (current.type === 'telegram' && current.webhookId) { + const webhookUrl = `${(process.env.PUBLIC_WEBHOOK_BASE_URL || 'http://host.docker.internal:8088').replace(/\/$/, '')}/internal/webhooks/telegram/${req.auth.tenantId}/${current.id}` + await putGateway(`/tg-accounts/${current.gatewayAccountId}/webhooks/${current.webhookId}`, { + url: webhookUrl, + events: telegramWebhookEvents, + isActive: status === 'connected', + secret: process.env.TELEGRAM_WEBHOOK_SECRET, + }) + } + patch.status = status + } + const value = await patchResource(req.auth.tenantId, 'channels', req.params.id, patch) + await audit(req, 'channel.update', req.params.id, patch) + ok(res, publicChannel(value)) }) app.delete('/api/channels/:id', async (req, res) => { - if (!(await deleteResource(req.auth.tenantId, 'channels', req.params.id))) throw fail(404, '渠道不存在') + const channel = await getResource(req.auth.tenantId, 'channels', req.params.id) + if (!channel) throw fail(404, '渠道不存在') + if (channel.type === 'telegram' && channel.gatewayAccountId && channel.webhookId) { + const webhookUrl = `${(process.env.PUBLIC_WEBHOOK_BASE_URL || 'http://host.docker.internal:8088').replace(/\/$/, '')}/internal/webhooks/telegram/${req.auth.tenantId}/${channel.id}` + await putGateway(`/tg-accounts/${channel.gatewayAccountId}/webhooks/${channel.webhookId}`, { + url: webhookUrl, + events: telegramWebhookEvents, + isActive: false, + secret: process.env.TELEGRAM_WEBHOOK_SECRET, + }).catch(() => null) + } + await deleteResource(req.auth.tenantId, 'channels', req.params.id) + await audit(req, 'channel.delete', req.params.id, { type: channel.type, account: channel.account }) ok(res, { success: true }) }) @@ -1155,7 +1993,34 @@ app.post('/api/ai/translate', async (req, res) => { `目标语言:${req.body.targetLang}\n场景:${req.body.scenario || '跨境商务'}\n上下文:\n${conversationContext(messages)}\n待翻译:\n${req.body.text}\n输出 {"translated":"...","detectedLang":"ISO语言代码","terms":[{"source":"...","target":"..."}]}`, true, ) - ok(res, result) + const historyItem = await putResource(req.auth.tenantId, 'translation-history', { + sourceLang: req.body.sourceLang || result.detectedLang || 'auto', + targetLang: req.body.targetLang, + originalText: req.body.text, + translatedText: result.translated, + detectedLang: result.detectedLang, + terms: result.terms || [], + scenario: req.body.scenario || '跨境商务', + favorite: false, + createdBy: req.auth.userId, + createdAt: new Date().toISOString(), + }) + ok(res, { ...result, historyItem }) +}) +app.get('/api/ai/translations', async (req, res) => { + const list = (await listResources(req.auth.tenantId, 'translation-history')) + .sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt))) + .slice(0, 100) + ok(res, list) +}) +app.patch('/api/ai/translations/:id', async (req, res) => { + const value = await patchResource(req.auth.tenantId, 'translation-history', req.params.id, { + favorite: Boolean(req.body.favorite), + updatedAt: new Date().toISOString(), + }) + if (!value) throw fail(404, '翻译记录不存在') + await audit(req, 'translation.favorite', req.params.id, { favorite: value.favorite }) + ok(res, value) }) app.post('/api/ai/analyze', async (req, res) => { const result = await analyzeAndPersistConversation(req.auth.tenantId, req.body.conversationId) @@ -1166,11 +2031,32 @@ app.post('/api/ai/analyze', async (req, res) => { app.post('/api/ai/replies', async (req, res) => { const messages = await messagesFor(req) const result = await deepseek( - '你是跨境销售助理。只输出严格 JSON 数组,生成三条可人工确认的英文回复建议。content 必须是可直接发送给海外客户的完整英文;reason、expectedEffect 和 cultureTip 使用中文。', - `${conversationContext(messages)}\n输出数组元素结构 {"id":"唯一值","style":"stable|active|brief","content":"英文回复","targetLang":"en","reason":"中文理由","knowledgeRefs":[],"expectedEffect":"中文说明","cultureTip":"中文提示"}`, + '你是跨境销售助理。只输出严格 JSON 数组,生成三条可人工确认的英文回复建议。content 必须是可直接发送给海外客户的完整英文;translationCn 必须是 content 准确、自然且完整的简体中文翻译;reason、expectedEffect 和 cultureTip 使用中文。translationCn 仅供中国客服理解,不得添加英文中不存在的信息。', + `${conversationContext(messages)}\n输出数组元素结构 {"id":"唯一值","style":"stable|active|brief","content":"英文回复","translationCn":"英文回复的完整简体中文翻译","targetLang":"en","reason":"中文理由","knowledgeRefs":[],"expectedEffect":"中文说明","cultureTip":"中文提示"}`, true, ) - ok(res, result) + if (!Array.isArray(result)) throw fail(502, 'AI 回复建议格式异常') + let suggestions = result.slice(0, 3) + const missingTranslations = suggestions.filter((item) => !String(item?.translationCn || '').trim()) + if (missingTranslations.length) { + const translated = await deepseek( + '你是专业商务翻译。只输出严格 JSON 数组。将每个 content 完整翻译成自然、准确的简体中文,不得遗漏或添加信息。', + `待翻译内容:${JSON.stringify(missingTranslations.map(({ id, content }) => ({ id, content })))}\n输出结构:[{"id":"原始 id","translationCn":"完整简体中文翻译"}]`, + true, + ) + const translationMap = new Map( + (Array.isArray(translated) ? translated : []) + .map((item) => [String(item?.id || ''), String(item?.translationCn || '').trim()]), + ) + suggestions = suggestions.map((item) => ({ + ...item, + translationCn: String(item?.translationCn || translationMap.get(String(item?.id || '')) || '').trim(), + })) + } + if (suggestions.length !== 3 || suggestions.some((item) => !item?.content || !item?.translationCn)) { + throw fail(502, 'AI 回复建议缺少完整的中英文内容') + } + ok(res, suggestions) }) app.post('/api/ai/adoptions', async (req, res) => { const value = await putResource(req.auth.tenantId, 'ai-adoptions', { @@ -1259,7 +2145,16 @@ app.get('/api/dashboard/overview', async (req, res) => { }, todos: tasks.filter((x) => x.status !== 'done').slice(0, 7), highValueCustomers: customers.sort((a, b) => (b.intentScore || 0) - (a.intentScore || 0)).slice(0, 6), - channelOverview: [{ channel: 'telegram', todayMessages: messages.filter((message) => dateKey(message.createdAt) === today).length, pending: conversations.filter((x) => x.status === 'pending').length, unread }], + channelOverview: [...new Set(conversations.map((conversation) => conversation.channel))].map((channel) => { + const channelConversations = conversations.filter((conversation) => conversation.channel === channel) + const ids = new Set(channelConversations.map((conversation) => conversation.id)) + return { + channel, + todayMessages: messages.filter((message) => ids.has(message.conversationId) && dateKey(message.createdAt) === today).length, + pending: channelConversations.filter((conversation) => conversation.status === 'pending').length, + unread: channelConversations.reduce((total, conversation) => total + Number(conversation.unreadCount || 0), 0), + } + }), inquiryTrend: days.map((day) => ({ date: day.slice(5), inquiries: conversations.filter((item) => dateKey(item.createdAt) === day).length, @@ -1395,8 +2290,33 @@ app.post('/api/notifications/read-all', async (req, res) => { }) app.get('/api/knowledge/packs', (_req, res) => ok(res, [])) app.get('/api/knowledge/stats', async (req, res) => { - const list = await listResources(req.auth.tenantId, 'knowledge') - ok(res, { totalCitations: list.reduce((n, x) => n + Number(x.citedCount || 0), 0), aiReplyCitationRate: 0, topUsed: [], lowQuality: [], outdated: [] }) + const [list, adoptions] = await Promise.all([ + listResources(req.auth.tenantId, 'knowledge'), + listResources(req.auth.tenantId, 'ai-adoptions'), + ]) + const totalCitations = list.reduce((total, item) => total + Number(item.citedCount || 0), 0) + const outdatedBefore = Date.now() - 180 * 86400000 + ok(res, { + totalCitations, + aiReplyCitationRate: adoptions.length ? Math.min(1, totalCitations / adoptions.length) : 0, + topUsed: [...list] + .filter((item) => Number(item.citedCount || 0) > 0) + .sort((a, b) => Number(b.citedCount || 0) - Number(a.citedCount || 0)) + .slice(0, 8) + .map((item) => ({ id: item.id, title: item.title, citedCount: Number(item.citedCount || 0) })), + lowQuality: list + .filter((item) => item.quality === 'low' || String(item.content || '').trim().length < 80) + .slice(0, 20) + .map((item) => ({ + id: item.id, + title: item.title, + reason: item.quality === 'low' ? '质量标记为低' : '有效内容少于 80 字', + })), + outdated: list + .filter((item) => Date.parse(item.updatedAt || item.createdAt || '') < outdatedBefore) + .slice(0, 20) + .map((item) => ({ id: item.id, title: item.title, updatedAt: item.updatedAt || item.createdAt })), + }) }) app.post('/api/knowledge/upload', async (req, res) => { const files = Array.isArray(req.body.files) ? req.body.files : [] @@ -1425,19 +2345,35 @@ app.post('/api/knowledge/search-test', async (req, res) => { const list = await listResources(req.auth.tenantId, 'knowledge') const words = String(req.body.question || '').toLowerCase().split(/\s+/) const hits = list.filter((x) => words.some((w) => JSON.stringify(x).toLowerCase().includes(w))).slice(0, 5).map((item) => ({ item, score: 1, snippet: String(item.content || '').slice(0, 120) })) + for (const hit of hits) { + hit.item = await putResource(req.auth.tenantId, 'knowledge', { + ...hit.item, + citedCount: Number(hit.item.citedCount || 0) + 1, + lastCitedAt: new Date().toISOString(), + }) + } ok(res, { answer: hits.length ? await deepseek('仅根据给出的企业知识回答,不得编造。', `问题:${req.body.question}\n知识:${JSON.stringify(hits)}`) : '知识库中没有匹配内容。', hits }) }) app.get('/api/billing', async (req, res) => { - const [{ rows }, orders, invoices] = await Promise.all([ + const [{ rows }, orders, invoices, userCount, conversations, knowledge] = await Promise.all([ pool.query('SELECT plan FROM tenants WHERE id=$1', [req.auth.tenantId]), listResources(req.auth.tenantId, 'billing-orders'), listResources(req.auth.tenantId, 'billing-invoices'), + pool.query('SELECT COUNT(*)::int AS count FROM users WHERE tenant_id=$1 AND status<>$2', [req.auth.tenantId, 'disabled']), + listResources(req.auth.tenantId, 'conversations'), + listResources(req.auth.tenantId, 'knowledge'), ]) + const messageGroups = await Promise.all(conversations.map((conversation) => listResources(req.auth.tenantId, `messages:${conversation.id}`))) + const messages = messageGroups.flat() + const translations = messages.filter((message) => message.translatedText).length + const knowledgeBytes = knowledge.reduce((total, item) => total + Buffer.byteLength(String(item.content || ''), 'utf8'), 0) const planKey = rows[0]?.plan || 'lite' ok(res, { info: { planKey, planName: planKey, expireAt: '', autoRenew: false, usage: { - members: { used: 0, total: 100, unit: '人' }, messages: { used: 0, total: 100000, unit: '条' }, - translations: { used: 0, total: 100000, unit: '次' }, knowledgeStorage: { used: 0, total: 10240, unit: 'MB' }, + members: { used: Number(userCount.rows[0]?.count || 0), total: 100, unit: '人' }, + messages: { used: messages.length, total: 100000, unit: '条' }, + translations: { used: translations, total: 100000, unit: '次' }, + knowledgeStorage: { used: Number((knowledgeBytes / 1024 / 1024).toFixed(2)), total: 10240, unit: 'MB' }, } }, orders, invoices, }) @@ -1540,7 +2476,11 @@ app.get('/api/analytics', async (req, res) => { repurchaseRate: { value: percent(customers.filter((item) => item.stage === 'repurchase').length, customers.length), delta: 0, isPercent: true }, }, customerGrowth: days.map((day) => ({ date: day.slice(5), count: customers.filter((item) => dateKey(item.createdAt || item.updatedAt) === day).length })), - channelInquiries: [{ channel: 'telegram', name: 'Telegram', count: conversations.filter((item) => item.channel === 'telegram').length }], + channelInquiries: [...countBy(conversations, 'channel')].map(([channel, count]) => ({ + channel, + name: channel === 'telegram' ? 'Telegram' : channel === 'email' ? 'Email' : channel, + count, + })), countryDistribution: [...countryCounts].map(([country, count]) => ({ country, name: country, count })), sourcePaths: [...sourceCounts].map(([path, count]) => ({ path, count })), funnel: [...stageCounts].map(([stage, count]) => ({ stage, label: stage, count })), @@ -1594,12 +2534,21 @@ app.post('/internal/webhooks/telegram/:tenantId/:channelId', async (req, res) => const { tenantId, channelId } = req.params const channel = await getResource(tenantId, 'channels', channelId) if (!channel) throw fail(404, 'Webhook 渠道映射不存在') + if (channel.status === 'paused') return ok(res, { accepted: true, ignoredPausedChannel: true }) const eventId = String(req.body.id || req.body.updateId || crypto.createHash('sha256').update(req.rawBody || Buffer.from(JSON.stringify(req.body))).digest('hex')) if (await getResource(tenantId, 'webhook-events', eventId)) return ok(res, { accepted: true, duplicate: true }) await putResource(tenantId, 'webhook-events', { id: eventId, payload: req.body, receivedAt: new Date().toISOString() }) const telegramMessage = req.body?.data?.message if (['message.received', 'message.pending', 'message.sent', 'message.failed'].includes(req.body.event) && telegramMessage) { const chatId = String(telegramMessage.chat_id) + const conversationId = `${channelId}:${chatId}` + const tombstone = await getResource(tenantId, 'conversation-tombstones', conversationId) + if (tombstone && req.body.event !== 'message.received') { + return ok(res, { accepted: true, ignoredDeletedConversation: true }) + } + if (tombstone) { + await deleteResource(tenantId, 'conversation-tombstones', conversationId) + } const customerId = `tg-${channel.gatewayAccountId}-${chatId}` let customer = await getResource(tenantId, 'customers', customerId) const identity = await resolveTelegramIdentity(channel, chatId, {}, customer || {}) @@ -1608,12 +2557,14 @@ app.post('/internal/webhooks/telegram/:tenantId/:channelId', async (req, res) => ...identity, id: customerId, telegram: chatId, + telegramChatType: identity.telegramChatType, source: 'telegram', })) - const conversationId = `${channelId}:${chatId}` const outgoing = Boolean(telegramMessage.is_outgoing) const content = telegramMessage.content || {} - const originalText = content?.text?.text || content?.caption?.text || '' + const serviceText = telegramServiceMessageText(content) + const originalText = content?.text?.text || content?.caption?.text || serviceText + const inboundCustomerMessage = !outgoing && !serviceText const typeMap = { messagePhoto: 'image', messageDocument: 'file', @@ -1629,6 +2580,9 @@ app.post('/internal/webhooks/telegram/:tenantId/:channelId', async (req, res) => ? 'sent' : 'sending' const oldMessageId = String(req.body?.data?.old_message_id || '') + const previousMessage = oldMessageId + ? await getResource(tenantId, `messages:${conversationId}`, oldMessageId) + : await getResource(tenantId, `messages:${conversationId}`, String(telegramMessage.id)) if (oldMessageId && oldMessageId !== String(telegramMessage.id)) { await deleteResource(tenantId, `messages:${conversationId}`, oldMessageId) } @@ -1636,22 +2590,33 @@ app.post('/internal/webhooks/telegram/:tenantId/:channelId', async (req, res) => ? await getResource(tenantId, 'outgoing-message-meta', oldMessageId || String(telegramMessage.id)) : null const message = await putResource(tenantId, `messages:${conversationId}`, { - id: String(telegramMessage.id), conversationId, sender: outgoing ? 'agent' : 'customer', + ...(previousMessage || {}), + id: String(telegramMessage.id), conversationId, + sender: serviceText ? 'system' : outgoing ? 'agent' : 'customer', senderName: outgoing ? '我' : customer.name, type: typeMap[content['@type']] || 'text', - originalText: outgoingMeta?.originalText || originalText, - translatedText: outgoingMeta?.translatedText, - originalLang: outgoingMeta?.originalLang || 'auto', + originalText: serviceText || outgoingMeta?.originalText || previousMessage?.originalText || originalText, + translatedText: serviceText ? undefined : outgoingMeta?.translatedText || previousMessage?.translatedText, + translationCn: serviceText ? undefined : outgoingMeta?.translationCn || previousMessage?.translationCn, + originalLang: outgoingMeta?.originalLang || previousMessage?.originalLang || 'auto', status: messageStatus, - translationStatus: !outgoing && content['@type'] === 'messageText' && originalText ? 'pending' : undefined, + translationStatus: content['@type'] === 'messageText' && originalText + ? outgoingMeta?.translationCn || previousMessage?.translationCn + ? 'completed' + : 'pending' + : undefined, createdAt: new Date(Number(telegramMessage.date || Date.now() / 1000) * 1000).toISOString(), ...telegramAttachment(content), }) const previous = await getResource(tenantId, 'conversations', conversationId) - await putResource(tenantId, 'conversations', { + const updatedConversation = await putResource(tenantId, 'conversations', { id: conversationId, customerId: customer.id, channel: 'telegram', channelId, - telegramChatId: chatId, unreadCount: outgoing ? Number(previous?.unreadCount || 0) : Number(previous?.unreadCount || 0) + 1, - status: outgoing ? 'replied' : 'pending', assigneeId: previous?.assigneeId || null, + telegramChatId: chatId, telegramChatType: identity.telegramChatType, + unreadCount: inboundCustomerMessage + ? Number(previous?.unreadCount || 0) + 1 + : Number(previous?.unreadCount || 0), + status: inboundCustomerMessage ? 'pending' : previous?.status || 'replied', + assigneeId: previous?.assigneeId || null, intentLevel: previous?.intentLevel || 'low', sentiment: previous?.sentiment || 'neutral', hasRisk: previous?.hasRisk || false, summary: previous?.summary || '', lastMessage: message, createdAt: previous?.createdAt || new Date().toISOString(), @@ -1661,9 +2626,10 @@ app.post('/internal/webhooks/telegram/:tenantId/:channelId', async (req, res) => conversationId, message, customer, + conversation: updatedConversation, oldMessageId: oldMessageId || undefined, }) - if (!outgoing && req.body.event === 'message.received') { + if (inboundCustomerMessage && req.body.event === 'message.received') { await putResource(tenantId, 'notifications', { id: `telegram-message-${channelId}-${telegramMessage.id}`, type: 'inquiry', @@ -1684,7 +2650,7 @@ app.post('/internal/webhooks/telegram/:tenantId/:channelId', async (req, res) => await deleteResource(tenantId, 'outgoing-message-meta', outgoingMeta.id) } enqueueAutomaticTranslation(tenantId, conversationId, message) - if (!outgoing && req.body.event === 'message.received') { + if (inboundCustomerMessage && req.body.event === 'message.received') { enqueueAutomaticProfile(tenantId, conversationId) } } @@ -1742,7 +2708,10 @@ await migrate() await repairCorruptedTranslations() const existingTelegramChannels = await pool.query( `SELECT tenant_id, data FROM resources - WHERE kind='channels' AND data->>'type'='telegram' AND data ? 'gatewayAccountId'`, + WHERE kind='channels' + AND data->>'type'='telegram' + AND data->>'status'='connected' + AND data ? 'gatewayAccountId'`, ) for (const row of existingTelegramChannels.rows) { await ensureChannelWebhook(String(row.tenant_id), row.data).catch(async (error) => { @@ -1751,4 +2720,35 @@ for (const row of existingTelegramChannels.rows) { }) }) } + +const activeEmailSyncs = new Set() +async function pollConnectedEmailChannels() { + const { rows } = await pool.query( + `SELECT tenant_id, data FROM resources + WHERE kind='channels' + AND data->>'type'='email' + AND data->>'status'='connected'`, + ) + for (const row of rows) { + const tenantId = String(row.tenant_id) + const channel = row.data + const key = `${tenantId}:${channel.id}` + if (activeEmailSyncs.has(key)) continue + activeEmailSyncs.add(key) + void syncEmailTenant(tenantId, channel) + .catch(async (error) => { + await patchResource(tenantId, 'channels', channel.id, { + errorMessage: `自动收信失败:${error.message}`, + }) + await appendChannelLog(tenantId, channel.id, `自动收信失败:${error.message}`, 'failed') + }) + .finally(() => activeEmailSyncs.delete(key)) + } +} +setTimeout(() => void pollConnectedEmailChannels(), 5_000).unref?.() +setInterval( + () => void pollConnectedEmailChannels(), + Math.max(15_000, Number(process.env.EMAIL_POLL_MS || 30_000)), +).unref?.() + httpServer.listen(Number(process.env.PORT || 3100), '0.0.0.0', () => console.log(`灵犀跨境 API listening on ${process.env.PORT || 3100}`)) diff --git a/server/test/business.test.js b/server/test/business.test.js index a6ec0d8..c15c770 100644 --- a/server/test/business.test.js +++ b/server/test/business.test.js @@ -1,6 +1,7 @@ import test from 'node:test' import assert from 'node:assert/strict' import { countBy, dateKey, percent, telegramAttachment } from '../src/business.js' +import { decryptEmailPassword, encryptEmailPassword } from '../src/email.js' test('Telegram 图片、文档、语音和贴纸映射为可下载文件', () => { assert.deepEqual( @@ -42,3 +43,12 @@ test('业务指标工具处理百分比、分组和无效日期', () => { { new: 2, won: 1 }, ) }) + +test('Email 渠道凭据使用认证加密且可正确解密', () => { + process.env.EMAIL_CREDENTIAL_KEY = 'lingxi-email-test-key-with-more-than-32-bytes' + const encrypted = encryptEmailPassword('app-password-123') + 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`)) +}) diff --git a/server/test/email-api-e2e.test.js b/server/test/email-api-e2e.test.js new file mode 100644 index 0000000..a246a12 --- /dev/null +++ b/server/test/email-api-e2e.test.js @@ -0,0 +1,169 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + encryptEmailPassword, + fetchEmailMessages, + sendEmailMessage, +} from '../src/email.js' + +const enabled = process.env.RUN_EMAIL_API_E2E === '1' +const baseUrl = process.env.EMAIL_API_E2E_BASE_URL || 'http://127.0.0.1:8088' + +function localChannel(account) { + return { + account, + encryptedPassword: encryptEmailPassword('pwd'), + imapHost: '127.0.0.1', + imapPort: 3143, + imapSecure: false, + imapFolder: 'INBOX', + smtpHost: '127.0.0.1', + smtpPort: 3025, + smtpSecure: false, + syncDays: 7, + } +} + +function unwrap(payload) { + return payload && typeof payload === 'object' && 'data' in payload ? payload.data : payload +} + +async function request(path, { token, method = 'GET', body } = {}) { + const response = await fetch(`${baseUrl}${path}`, { + method, + headers: { + 'content-type': 'application/json', + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + const text = await response.text() + const payload = text ? JSON.parse(text) : null + if (!response.ok) throw new Error(`${method} ${path} -> ${response.status}: ${payload?.message || text}`) + return unwrap(payload) +} + +async function waitForSenderMessage(predicate) { + const sender = localChannel('sender@local.test') + for (let attempt = 0; attempt < 20; attempt += 1) { + const result = await fetchEmailMessages(sender, { full: true }) + const message = result.messages.find(predicate) + if (message) return message + await new Promise((resolve) => setTimeout(resolve, 150)) + } + throw new Error('等待网关发出的邮件进入 sender IMAP 超时') +} + +test('真实 HTTP 网关完成 Email 连接、全量同步、附件读取、回复与已读', { skip: !enabled }, async () => { + process.env.JWT_SECRET ||= 'lingxi-email-api-e2e-secret' + const suffix = Date.now() + const senderChannel = localChannel('sender@local.test') + const olderSubject = `Lingxi sorting older ${suffix}` + const newerSubject = `Lingxi sorting newer ${suffix}` + await sendEmailMessage(senderChannel, { + to: 'receiver@local.test', + subject: olderSubject, + text: 'Older conversation used for stable ordering', + }) + await new Promise((resolve) => setTimeout(resolve, 30)) + await sendEmailMessage(senderChannel, { + to: 'receiver@local.test', + subject: newerSubject, + text: 'Newer conversation must remain above the opened older conversation', + }) + + const session = await request('/api/auth/register', { + method: 'POST', + body: { + name: 'Email API E2E', + account: `email-api-${suffix}@local.test`, + password: 'Lingxi-Email-Api-2026!', + company: 'Lingxi Protocol Lab', + industry: '跨境电商', + teamSize: '1-10人', + }, + }) + assert.ok(session.token) + + const connected = await request('/api/channels/email/connect', { + token: session.token, + method: 'POST', + body: { + name: '本地协议邮箱', + displayName: 'Lingxi Receiver', + account: 'receiver@local.test', + password: 'pwd', + imapHost: 'host.docker.internal', + imapPort: 3143, + imapSecure: false, + imapFolder: 'INBOX', + smtpHost: 'host.docker.internal', + smtpPort: 3025, + smtpSecure: false, + syncDays: 7, + }, + }) + assert.equal(connected.status, 'connected') + assert.equal(connected.encryptedPassword, undefined) + + const conversations = await request('/api/conversations?channel=email', { token: session.token }) + assert.ok(conversations.list.length >= 2) + const newerConversation = conversations.list.find((item) => item.emailSubject === newerSubject) + const olderConversation = conversations.list.find((item) => item.emailSubject === olderSubject) + assert.ok(newerConversation) + assert.ok(olderConversation) + assert.ok(conversations.list.indexOf(newerConversation) < conversations.list.indexOf(olderConversation)) + + const olderMessages = await request(`/api/conversations/${olderConversation.id}/messages`, { token: session.token }) + await request(`/api/conversations/${olderConversation.id}/read`, { + token: session.token, + method: 'POST', + body: { messageIds: olderMessages.map((message) => message.id) }, + }) + const afterRead = await request('/api/conversations?channel=email', { token: session.token }) + const relevantOrder = afterRead.list + .filter((item) => [newerConversation.id, olderConversation.id].includes(item.id)) + .map((item) => item.id) + assert.deepEqual(relevantOrder, [newerConversation.id, olderConversation.id]) + + let conversation + let incoming + for (const candidate of conversations.list.filter((item) => item.customer?.email === 'sender@local.test')) { + const candidateMessages = await request(`/api/conversations/${candidate.id}/messages`, { token: session.token }) + const candidateIncoming = candidateMessages.find((message) => message.sender === 'customer' && message.emailAttachments?.length) + if (candidateIncoming) { + conversation = candidate + incoming = candidateIncoming + break + } + } + assert.ok(conversation) + assert.ok(incoming) + const attachment = await request( + `/api/conversations/${conversation.id}/messages/${incoming.id}/attachment?attachmentId=${encodeURIComponent(incoming.emailAttachments[0].id)}`, + { token: session.token }, + ) + assert.equal(attachment.name, 'lingxi-proof.txt') + assert.ok(attachment.dataUrl.startsWith('data:text/plain;base64,')) + + const replyText = `Lingxi gateway API reply ${suffix}` + const sent = await request(`/api/conversations/${conversation.id}/messages`, { + token: session.token, + method: 'POST', + body: { text: replyText, autoTranslate: false }, + }) + assert.equal(sent.status, 'sent') + assert.ok(sent.emailMessageId) + + const receivedBySender = await waitForSenderMessage((message) => message.messageId === sent.emailMessageId) + assert.equal(receivedBySender.text, replyText) + assert.ok(receivedBySender.inReplyTo) + + await request(`/api/conversations/${conversation.id}/read`, { + token: session.token, + method: 'POST', + body: { messageIds: [incoming.id] }, + }) + const receiver = await fetchEmailMessages(localChannel('receiver@local.test'), { full: true }) + assert.equal(receiver.messages.find((message) => message.uid === incoming.emailUid)?.seen, true) +}) diff --git a/server/test/email-protocol.test.js b/server/test/email-protocol.test.js new file mode 100644 index 0000000..287fd5f --- /dev/null +++ b/server/test/email-protocol.test.js @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + encryptEmailPassword, + fetchEmailMessages, + markEmailUidsSeen, + sendEmailMessage, + verifyEmailConnection, +} from '../src/email.js' + +const enabled = process.env.RUN_EMAIL_PROTOCOL_TEST === '1' + +function channel(account) { + return { + id: `protocol-${account}`, + type: 'email', + account, + displayName: account.startsWith('sender') ? 'Lingxi Sender' : 'Lingxi Receiver', + encryptedPassword: encryptEmailPassword('pwd'), + imapHost: process.env.EMAIL_TEST_HOST || '127.0.0.1', + imapPort: Number(process.env.EMAIL_TEST_IMAP_PORT || 3143), + imapSecure: false, + imapFolder: 'INBOX', + smtpHost: process.env.EMAIL_TEST_HOST || '127.0.0.1', + smtpPort: Number(process.env.EMAIL_TEST_SMTP_PORT || 3025), + smtpSecure: false, + syncDays: 7, + } +} + +async function waitForMessage(target, predicate) { + for (let attempt = 0; attempt < 20; attempt += 1) { + const result = await fetchEmailMessages(target, { full: true }) + const message = result.messages.find(predicate) + if (message) return { message, result } + await new Promise((resolve) => setTimeout(resolve, 150)) + } + throw new Error('等待邮件进入 IMAP INBOX 超时') +} + +test('真实 SMTP 发件、IMAP 收件、附件解析、已读与回复头', { skip: !enabled }, async () => { + process.env.JWT_SECRET ||= 'lingxi-email-protocol-test-secret' + const sender = channel('sender@local.test') + const receiver = channel('receiver@local.test') + const subject = `Lingxi protocol ${Date.now()}` + + const senderCheck = await verifyEmailConnection(sender, 'pwd') + const receiverCheck = await verifyEmailConnection(receiver, 'pwd') + assert.ok(senderCheck.latencyMs >= 0) + assert.ok(receiverCheck.latencyMs >= 0) + assert.ok(!sender.encryptedPassword.includes('pwd')) + + const sent = await sendEmailMessage(sender, { + to: receiver.account, + subject, + text: 'Lingxi IMAP/SMTP real protocol message', + attachments: [{ + filename: 'lingxi-proof.txt', + content: Buffer.from('real email attachment'), + contentType: 'text/plain', + }], + }) + assert.ok(sent.messageId) + + const received = await waitForMessage(receiver, (message) => message.subject === subject) + assert.equal(received.message.text, 'Lingxi IMAP/SMTP real protocol message') + assert.equal(received.message.from.email, sender.account) + assert.equal(received.message.attachments.length, 1) + assert.equal(received.message.attachments[0].name, 'lingxi-proof.txt') + assert.equal(Buffer.from(received.message.attachments[0].base64, 'base64').toString(), 'real email attachment') + + await markEmailUidsSeen(receiver, [received.message.uid]) + const marked = await fetchEmailMessages(receiver, { full: true }) + assert.equal(marked.messages.find((message) => message.uid === received.message.uid)?.seen, true) + + const reply = await sendEmailMessage(receiver, { + to: sender.account, + subject: `Re: ${subject}`, + text: 'Reply received by Lingxi', + inReplyTo: received.message.messageId, + references: [received.message.messageId], + }) + const replyReceived = await waitForMessage(sender, (message) => message.messageId === reply.messageId) + assert.equal(replyReceived.message.inReplyTo, received.message.messageId) + assert.ok(replyReceived.message.references.includes(received.message.messageId)) +}) diff --git a/src/app/router/index.tsx b/src/app/router/index.tsx index 272fd5b..65bba2d 100644 --- a/src/app/router/index.tsx +++ b/src/app/router/index.tsx @@ -5,21 +5,6 @@ import type { ReactNode } from 'react' import { LoginPage } from '@/pages/auth/LoginPage' import { RegisterPage } from '@/pages/auth/RegisterPage' -import { DashboardPage } from '@/pages/dashboard/DashboardPage' -import { InboxPage } from '@/pages/inbox/InboxPage' -import { CustomersPage } from '@/pages/customers/CustomersPage' -import { CustomerDetailPage } from '@/pages/customers/CustomerDetailPage' -import { OpportunitiesPage } from '@/pages/opportunities/OpportunitiesPage' -import { TranslatePage } from '@/pages/translate/TranslatePage' -import { RisksPage } from '@/pages/risks/RisksPage' -import { KnowledgePage } from '@/pages/knowledge/KnowledgePage' -import { AnalyticsPage } from '@/pages/analytics/AnalyticsPage' -import { ChannelsPage } from '@/pages/channels/ChannelsPage' -import { TeamPage } from '@/pages/team/TeamPage' -import { BillingPage } from '@/pages/billing/BillingPage' -import { SettingsPage } from '@/pages/settings/SettingsPage' -import { HelpPage } from '@/pages/help/HelpPage' -import { NotFoundPage } from '@/pages/NotFoundPage' function RequireAuth({ children }: { children: ReactNode }) { const token = useAuthStore((s) => s.token) @@ -45,21 +30,21 @@ const router = createBrowserRouter([ ), children: [ { index: true, element: }, - { path: 'dashboard', element: }, - { path: 'inbox', element: }, - { path: 'customers', element: }, - { path: 'customers/:id', element: }, - { path: 'opportunities', element: }, - { path: 'translate', element: }, - { path: 'risks', element: }, - { path: 'knowledge', element: }, - { path: 'analytics', element: }, - { path: 'channels', element: }, - { path: 'team', element: }, - { path: 'billing', element: }, - { path: 'settings', element: }, - { path: 'help', element: }, - { path: '*', element: }, + { path: 'dashboard', lazy: async () => ({ Component: (await import('@/pages/dashboard/DashboardPage')).DashboardPage }) }, + { path: 'inbox', lazy: async () => ({ Component: (await import('@/pages/inbox/InboxPage')).InboxPage }) }, + { path: 'customers', lazy: async () => ({ Component: (await import('@/pages/customers/CustomersPage')).CustomersPage }) }, + { path: 'customers/:id', lazy: async () => ({ Component: (await import('@/pages/customers/CustomerDetailPage')).CustomerDetailPage }) }, + { path: 'opportunities', lazy: async () => ({ Component: (await import('@/pages/opportunities/OpportunitiesPage')).OpportunitiesPage }) }, + { path: 'translate', lazy: async () => ({ Component: (await import('@/pages/translate/TranslatePage')).TranslatePage }) }, + { path: 'risks', lazy: async () => ({ Component: (await import('@/pages/risks/RisksPage')).RisksPage }) }, + { path: 'knowledge', lazy: async () => ({ Component: (await import('@/pages/knowledge/KnowledgePage')).KnowledgePage }) }, + { path: 'analytics', lazy: async () => ({ Component: (await import('@/pages/analytics/AnalyticsPage')).AnalyticsPage }) }, + { path: 'channels', lazy: async () => ({ Component: (await import('@/pages/channels/ChannelsPage')).ChannelsPage }) }, + { path: 'team', lazy: async () => ({ Component: (await import('@/pages/team/TeamPage')).TeamPage }) }, + { path: 'billing', lazy: async () => ({ Component: (await import('@/pages/billing/BillingPage')).BillingPage }) }, + { path: 'settings', lazy: async () => ({ Component: (await import('@/pages/settings/SettingsPage')).SettingsPage }) }, + { path: 'help', lazy: async () => ({ Component: (await import('@/pages/help/HelpPage')).HelpPage }) }, + { path: '*', lazy: async () => ({ Component: (await import('@/pages/NotFoundPage')).NotFoundPage }) }, ], }, ]) diff --git a/src/components/charts/index.tsx b/src/components/charts/index.tsx index 28cef13..1a508ef 100644 --- a/src/components/charts/index.tsx +++ b/src/components/charts/index.tsx @@ -18,7 +18,7 @@ import { Card, CardHeader } from '@/components/ui/card' import { LiveDataBadge } from '@/components/common/LiveDataBadge' import { cn } from '@/utils/cn' -export const CHART_COLORS = ['#247b71', '#32998c', '#52b3a5', '#83cfc2', '#f59e0b', '#3b82f6', '#8b5cf6', '#94a3b8'] +export const CHART_COLORS = ['#2563eb', '#3b82f6', '#60a5fa', '#8b5cf6', '#06b6d4', '#10b981', '#f59e0b', '#94a3b8'] export function ChartCard({ title, diff --git a/src/components/common/StatCard.tsx b/src/components/common/StatCard.tsx index fc86f76..4ef5637 100644 --- a/src/components/common/StatCard.tsx +++ b/src/components/common/StatCard.tsx @@ -27,17 +27,20 @@ export function StatCard({ const DeltaIcon = neutral ? Minus : (delta ?? 0) > 0 ? TrendingUp : TrendingDown return (
- {title} - + {title} +
-
- {value} +
+ {value} {suffix && {suffix}}
{showDelta && ( diff --git a/src/components/customer/CustomerProfileRadar.tsx b/src/components/customer/CustomerProfileRadar.tsx new file mode 100644 index 0000000..d0f19a8 --- /dev/null +++ b/src/components/customer/CustomerProfileRadar.tsx @@ -0,0 +1,181 @@ +import { useId } from 'react' +import { + PolarAngleAxis, + PolarGrid, + PolarRadiusAxis, + Radar, + RadarChart, + ResponsiveContainer, + Tooltip, +} from 'recharts' +import { Sparkles, TrendingUp } from 'lucide-react' +import type { Customer } from '@/types' +import { cn } from '@/utils/cn' + +function qualitativeScore(value?: string, fallback = 50) { + const text = String(value || '').toLowerCase() + if (!text) return fallback + if (/高|强|紧急|决策|老板|owner|director|high|urgent/.test(text)) return 86 + if (/中|一般|参与|medium|normal/.test(text)) return 62 + if (/低|弱|未知|助理|low|unknown/.test(text)) return 36 + return fallback +} + +export function profileRadarData(customer: Customer) { + const profile = customer.aiProfile || {} + const extracted = [ + profile.companyName, + profile.industry, + profile.businessModel, + profile.inquiryQuantity, + profile.targetPrice, + profile.budget, + profile.purchaseTimeline, + profile.payment, + ].filter(Boolean).length + const fitScore = Math.min(96, 42 + extracted * 6 + (profile.keyNeeds?.length || 0) * 4) + + return [ + { dimension: '购买意向', score: Math.max(0, Math.min(100, Number(customer.intentScore || 0))), hint: '基于询盘强度与购买信号' }, + { dimension: '成交可能', score: Number(profile.dealProbability || 0), hint: 'AI 综合预测成交概率' }, + { dimension: '决策能力', score: qualitativeScore(profile.decisionPower), hint: profile.decisionPower || '尚待确认采购决策权' }, + { dimension: '需求紧迫', score: qualitativeScore(profile.urgency), hint: profile.urgency || '尚待确认采购时间表' }, + { dimension: '客户匹配', score: fitScore, hint: '画像字段、需求与产品匹配度' }, + { dimension: '关系健康', score: Math.max(0, 100 - Number(profile.churnProbability || 0)), hint: '流失风险的反向健康度' }, + ] +} + +export function CustomerProfileRadar({ + customer, + compact = false, + className, +}: { + customer: Customer + compact?: boolean + className?: string +}) { + const data = profileRadarData(customer) + const average = Math.round(data.reduce((sum, item) => sum + item.score, 0) / data.length) + const chartId = useId().replace(/:/g, '') + const gradientId = `radar-fill-${chartId}` + const glowId = `radar-glow-${chartId}` + const level = average >= 75 ? '高潜客户' : average >= 55 ? '重点培育' : '持续观察' + const levelTone = average >= 75 + ? 'text-blue-700 bg-blue-50 border-blue-100' + : average >= 55 + ? 'text-violet-700 bg-violet-50 border-violet-100' + : 'text-amber-700 bg-amber-50 border-amber-100' + + return ( +
+
+
+
+
+ + + +
+

客户价值雷达

+

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

+
+
+ + + {level} + +
+
+
+

综合指数

+

{average}

+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + [`${value} / 100`, '画像指数']} + labelFormatter={(label) => `${label} · AI 评估`} + contentStyle={{ + borderRadius: 14, + border: '1px solid #dbeafe', + boxShadow: '0 18px 40px rgba(37,99,235,.14)', + fontSize: 12, + }} + /> + + + + +
+ {!compact && ( +
+ {data.map((item) => ( +
+
+ {item.dimension} + {item.score} +
+
+
+
+
+ ))} +
+ )} +
+ ) +} diff --git a/src/components/customer/CustomerSemanticTag.tsx b/src/components/customer/CustomerSemanticTag.tsx new file mode 100644 index 0000000..9f8c149 --- /dev/null +++ b/src/components/customer/CustomerSemanticTag.tsx @@ -0,0 +1,107 @@ +import { Tag } from 'lucide-react' +import { cn } from '@/utils/cn' + +type TagCategory = 'positive' | 'emotion' | 'risk' | 'aftersales' | 'quality' | 'service' | 'profile' + +const categoryConfig: Record = { + positive: { + label: '积极信号', + className: 'border-emerald-200/90 bg-emerald-50 text-emerald-700', + dot: 'bg-emerald-500 shadow-[0_0_0_2px_rgba(16,185,129,.12)]', + }, + emotion: { + label: '客户情绪', + className: 'border-amber-200/90 bg-amber-50 text-amber-700', + dot: 'bg-amber-500 shadow-[0_0_0_2px_rgba(245,158,11,.12)]', + }, + risk: { + label: '投诉风险', + className: 'border-rose-200/90 bg-rose-50 text-rose-700', + dot: 'bg-rose-500 shadow-[0_0_0_2px_rgba(244,63,94,.12)]', + }, + aftersales: { + label: '售后处理', + className: 'border-orange-200/90 bg-orange-50 text-orange-700', + dot: 'bg-orange-500 shadow-[0_0_0_2px_rgba(249,115,22,.12)]', + }, + quality: { + label: '产品质量', + className: 'border-sky-200/90 bg-sky-50 text-sky-700', + dot: 'bg-sky-500 shadow-[0_0_0_2px_rgba(14,165,233,.12)]', + }, + service: { + label: '服务沟通', + className: 'border-violet-200/90 bg-violet-50 text-violet-700', + dot: 'bg-violet-500 shadow-[0_0_0_2px_rgba(139,92,246,.12)]', + }, + profile: { + label: '客户属性', + className: 'border-slate-200 bg-slate-50 text-slate-600', + dot: 'bg-slate-400 shadow-[0_0_0_2px_rgba(100,116,139,.1)]', + }, +} + +export function customerTagCategory(tag: string): TagCategory { + const value = tag.toLowerCase().replace(/[_-]+/g, ' ') + + // Positive outcomes take priority for phrases such as “投诉处理成功”. + if (/success|satisfaction|repurchase|positive|resolved|回升|缓和|修复|改进|成功|复购|满意|转正/.test(value)) return 'positive' + if (/emotion|anger|angry|insult|情绪|愤怒|高情绪|冲突/.test(value)) return 'emotion' + if (/urgent|escalation|crisis|complaint|投诉|危机|辱骂|欺诈|风险/.test(value)) return 'risk' + if (/after.?sales|refund|return|replace|售后|退款|退货|补偿|换货|更换/.test(value)) return 'aftersales' + if (/quality|defect|product|holes?|smell|品质|质量|瑕疵|破损|异味|制造|尺寸|材质/.test(value)) return 'quality' + if (/agent|service|communication|feedback|客服|坐席|沟通|销售反馈/.test(value)) return 'service' + return 'profile' +} + +export function CustomerSemanticTag({ + tag, + className, +}: { + tag: string + className?: string +}) { + const category = customerTagCategory(tag) + const config = categoryConfig[category] + + return ( + + + {tag} + + ) +} + +export function CustomerTagLegend({ tags }: { tags: string[] }) { + const counts = tags.reduce>>((result, tag) => { + const category = customerTagCategory(tag) + result[category] = (result[category] || 0) + 1 + return result + }, {}) + + return ( +
+ + + 标签画像 + + {(Object.keys(categoryConfig) as TagCategory[]) + .filter((category) => counts[category]) + .map((category) => ( + + + {categoryConfig[category].label} + {counts[category]} + + ))} +
+ ) +} diff --git a/src/components/inbox/AiReplies.tsx b/src/components/inbox/AiReplies.tsx index 9173c00..1ac821a 100644 --- a/src/components/inbox/AiReplies.tsx +++ b/src/components/inbox/AiReplies.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { useMutation } from '@tanstack/react-query' -import { Sparkles, Copy, Edit, Check, Lightbulb, Globe, AlertTriangle, BookOpen, X } from 'lucide-react' +import { Sparkles, Copy, Edit, Check, Lightbulb, Globe, AlertTriangle, BookOpen, Languages, X } from 'lucide-react' import { generateReplies } from '@/services/api/ai' import type { AiReplySuggestion } from '@/types/ai' import { Button } from '@/components/ui/button' @@ -22,8 +22,8 @@ export function AiReplies({ onClose, }: { conversationId: string - onAdopt: (content: string) => void - onEdit: (content: string) => void + onAdopt: (content: string, translationCn: string) => void + onEdit: (content: string, translationCn: string) => void onClose: () => void }) { const [items, setItems] = useState(null) @@ -39,10 +39,10 @@ export function AiReplies({ }) return ( -
+
-

- +

+ AI 智能回复

@@ -94,7 +94,29 @@ export function AiReplies({ {languageName(s.targetLang)}
-

{s.content}

+
+
+
+ + 发送给客户 · English +
+

{s.content}

+
+ {s.translationCn && ( +
+
+ + 中文参考 · 不会发送 +
+

+ {s.translationCn} +

+
+ )} +
{(s.knowledgeRefs || []).slice(0, 2).map((ref) => ( @@ -111,7 +133,7 @@ export function AiReplies({ {s.reason}
- + {s.expectedEffect}
{s.cultureTip && ( @@ -123,11 +145,11 @@ export function AiReplies({

- - @@ -146,9 +168,10 @@ export function AiReplies({ )} {!items && !mutation.isPending && ( -

- 点击「生成回复建议」让 AI 根据客户上下文、知识库与历史成功回复为您生成回复 -

+
+

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

+ 不会自动发送 +
)}
) diff --git a/src/components/inbox/Composer.tsx b/src/components/inbox/Composer.tsx index 7b5a046..f1487de 100644 --- a/src/components/inbox/Composer.tsx +++ b/src/components/inbox/Composer.tsx @@ -19,7 +19,7 @@ import { Dropdown } from '@/components/ui/dropdown' import { Switch } from '@/components/ui/switch' import { Badge } from '@/components/ui/badge' import { toast } from '@/app/store/toast' -import { cn } from '@/utils/cn' +import type { ChannelType } from '@/types' const QUICK_PHRASES = [ { label: '报价催促', text: 'Friendly reminder: the quotation is valid until this Friday. Please confirm at your earliest convenience.' }, @@ -47,6 +47,7 @@ export function Composer({ disabled, replyingTo, onCancelReply, + channel = 'telegram', }: { onSend: (text: string, autoTranslate?: boolean) => Promise onMedia?: (files: File[], kind: 'document' | 'photo' | 'audio' | 'voice' | 'sticker') => Promise @@ -55,6 +56,7 @@ export function Composer({ disabled?: boolean replyingTo?: { id: string; text: string } | null onCancelReply?: () => void + channel?: ChannelType }) { const [internalText, setInternalText] = useState('') const [sending, setSending] = useState(false) @@ -73,7 +75,7 @@ export function Composer({ setSending(true) try { await onMedia(Array.from(files), kind) - toast.success(`${files.length} 个文件已发送到 Telegram`) + toast.success(`${files.length} 个文件已发送到${channel === 'email' ? '客户邮箱' : ' Telegram'}`) } catch { toast.error('媒体发送失败') } finally { @@ -120,11 +122,11 @@ export function Composer({ } const toolbar = ( -
+
{/* 话术 */} + - - + {channel !== 'email' && ( + <> + + + + )} uploadFiles(e.target.files, 'document')} /> uploadFiles(e.target.files, 'photo')} /> uploadFiles(e.target.files, 'voice')} /> @@ -182,7 +188,7 @@ export function Composer({ type="button" disabled={aiLoading || !text.trim()} onClick={() => aiAction(t.action)} - className="rounded px-1.5 py-1 text-xs text-teal-600 hover:bg-teal-50 disabled:opacity-50" + className="shrink-0 rounded-lg px-2 py-1.5 text-xs font-medium text-primary-700 hover:bg-primary-50 disabled:opacity-50" > {t.label} @@ -190,7 +196,7 @@ export function Composer({ + } @@ -200,9 +206,9 @@ export function Composer({ ) return ( -
+
{toolbar} -
+
{replyingTo && (
@@ -220,9 +226,9 @@ export function Composer({ value={text} onChange={(e) => setText(e.target.value)} onKeyDown={handleKeyDown} - placeholder="输入回复内容,Enter 发送,Shift+Enter 换行" + placeholder={channel === 'email' ? '撰写邮件回复,Enter 发送,Shift+Enter 换行' : '输入回复内容,Enter 发送,Shift+Enter 换行'} rows={3} - className="w-full resize-none rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground outline-none focus:border-primary focus:ring-1 focus:ring-ring scrollbar-thin" + className="min-h-[86px] w-full resize-none rounded-2xl border border-slate-200 bg-slate-50/60 px-4 py-3.5 text-sm leading-6 placeholder:text-slate-400 outline-none transition-all focus:border-primary-300 focus:bg-white focus:ring-4 focus:ring-primary-50 scrollbar-thin" disabled={sending || disabled} /> @@ -230,7 +236,7 @@ export function Composer({
{aiLoading && ( @@ -240,7 +246,7 @@ export function Composer({
diff --git a/src/components/inbox/ConversationInsightBar.tsx b/src/components/inbox/ConversationInsightBar.tsx index 0539460..cbca593 100644 --- a/src/components/inbox/ConversationInsightBar.tsx +++ b/src/components/inbox/ConversationInsightBar.tsx @@ -1,8 +1,6 @@ -import { Brain, Building2, ChevronRight } from 'lucide-react' +import { Brain, ChevronRight } from 'lucide-react' import type { Conversation, Customer, CustomerStage } from '@/types' -import { Avatar } from '@/components/ui/avatar' import { Button } from '@/components/ui/button' -import { CountryFlag } from '@/components/common/CountryFlag' import { CustomerStageBadge, IntentScoreBadge, @@ -28,30 +26,21 @@ export function ConversationInsightBar({ const summary = customer.aiProfile?.summary || conversation.summary || 'AI 正在根据会话完善客户画像' return ( -
-
- -
-
- {customer.name} - {customer.telegramUsername && ( - - @{customer.telegramUsername} - - )} - - {customer.company && ( - - {customer.company} - - )} -
-

- - {summary} -

+
+
+ + + +
+ + AI 客户洞察 + +

{summary}

-
+
意向 @@ -59,13 +48,13 @@ export function ConversationInsightBar({
-
-
- 意向 +
+ 意向 diff --git a/src/components/inbox/ConversationList.tsx b/src/components/inbox/ConversationList.tsx index 0723d2a..15ee5fb 100644 --- a/src/components/inbox/ConversationList.tsx +++ b/src/components/inbox/ConversationList.tsx @@ -1,4 +1,5 @@ -import { Search, ShieldAlert } from 'lucide-react' +import { useRef, useState } from 'react' +import { Eraser, Mail, 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' @@ -21,6 +22,7 @@ const FILTERS = [ { value: 'risk', label: '有风险' }, { value: 'unassigned', label: '无负责人' }, { value: 'mine', label: '我的客户' }, + { value: 'archived', label: '已归档' }, ] export function ConversationList({ @@ -32,6 +34,11 @@ export function ConversationList({ onFilterChange, keyword, onKeywordChange, + channel, + onChannelChange, + onAdd, + onClear, + onDelete, }: { items: ConversationListItem[] loading: boolean @@ -41,34 +48,82 @@ export function ConversationList({ onFilterChange: (f: string) => void keyword: string onKeywordChange: (k: string) => void + channel: string + onChannelChange: (channel: string) => void + onAdd: () => void + onClear: (item: ConversationListItem) => void + onDelete: (item: ConversationListItem) => void }) { const { t } = useI18n() const { data: team } = useQuery({ queryKey: ['team'], queryFn: getTeam, staleTime: 300_000 }) const memberName = (id: string | null) => (id ? (team?.find((m) => m.id === id)?.name ?? '—') : null) + const [swipedId, setSwipedId] = useState(null) + const [dragOffset, setDragOffset] = useState(0) + const touchStart = useRef<{ x: number; y: number; id: string } | null>(null) return (
- {/* 搜索 */} -
+
+
+
+

消息

+

{items.length} 个活跃会话

+
+
+ + 实时连接 + + +
+
onKeywordChange(e.target.value)} placeholder="搜索客户/国家/产品/消息内容" - className="h-9 w-full rounded-md border border-input bg-muted/40 pl-8 pr-2 text-sm placeholder:text-muted-foreground focus:border-primary focus:bg-card focus:outline-none rtl:pl-2 rtl:pr-8" + className="h-10 w-full rounded-full border border-transparent bg-slate-100/80 pl-9 pr-3 text-sm placeholder:text-slate-400 focus:border-sky-200 focus:bg-white focus:outline-none focus:ring-4 focus:ring-sky-50 rtl:pl-2 rtl:pr-8" />
- {/* 筛选 */} -
+
+ {[ + { value: '', label: '全部', icon: null }, + { value: 'telegram', label: 'Telegram', icon: Send }, + { value: 'email', label: 'Email', icon: Mail }, + ].map((item) => ( + + ))} +
+
{FILTERS.map((f) => (
{/* 列表 */} -
+
{loading && Array.from({ length: 8 }).map((_, i) => ( -
+
@@ -97,17 +152,80 @@ export function ConversationList({ const c = item.customer const active = item.id === activeId const intent = intentLevelMeta(item.intentLevel) + const isTelegramChannel = String(item.telegramChatType || '').toLowerCase() === 'channel' return ( - + )} + +
)} - > + + +
) })}
diff --git a/src/components/inbox/InsightPanel.tsx b/src/components/inbox/InsightPanel.tsx index a1c5650..59f96f5 100644 --- a/src/components/inbox/InsightPanel.tsx +++ b/src/components/inbox/InsightPanel.tsx @@ -14,7 +14,9 @@ import { } from 'lucide-react' import { useMutation, useQueryClient } from '@tanstack/react-query' import { updateCustomer } from '@/services/api/customers' +import { createTask } from '@/services/api/crm' import { analyzeConversation } from '@/services/api/ai' +import { useAuthStore } from '@/app/store/auth' import type { Customer, Conversation } from '@/types' import { Avatar } from '@/components/ui/avatar' import { Button } from '@/components/ui/button' @@ -29,6 +31,8 @@ import { IntentScoreBadge, CustomerStageBadge, SentimentBadge, RiskLevelBadge } import { CUSTOMER_STAGES, NEXT_ACTIONS, countryMeta, INTENT_LEVELS } from '@/config/constants' import { toast, errorMessage } from '@/app/store/toast' import { cn } from '@/utils/cn' +import { CustomerProfileRadar } from '@/components/customer/CustomerProfileRadar' +import { CustomerSemanticTag } from '@/components/customer/CustomerSemanticTag' export function InsightPanel({ customer, @@ -38,6 +42,7 @@ export function InsightPanel({ conversation: Conversation }) { const queryClient = useQueryClient() + const user = useAuthStore((state) => state.user) const [analyzing, setAnalyzing] = useState(false) const [analysis, setAnalysis] = useState(customer.aiProfile.summary) const c = countryMeta(customer.country) @@ -54,6 +59,25 @@ export function InsightPanel({ }, onError: (e) => toast.error(errorMessage(e)), }) + const taskMut = useMutation({ + mutationFn: (title: string) => createTask({ + title, + type: 'revisit', + priority: customer.riskLevel === 'urgent' || customer.riskLevel === 'high' ? 'high' : 'medium', + status: 'pending', + customerId: customer.id, + customerName: customer.name, + conversationId: conversation.id, + ownerId: user?.id || '', + dueAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + note: `由 AI 客户洞察建议创建:${title}`, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['tasks'] }) + toast.success('已创建跟进任务', '默认明天到期') + }, + onError: (error) => toast.error(errorMessage(error)), + }) const handleAnalyze = async () => { setAnalyzing(true) @@ -67,9 +91,9 @@ export function InsightPanel({ } return ( -
+
{/* 客户基本信息 */} -
+
@@ -120,7 +144,7 @@ export function InsightPanel({ {p} ))} {customer.tags?.map((t) => ( - {t} + ))}
@@ -151,9 +175,14 @@ export function InsightPanel({
{/* AI 分析 */} -
+
+ +
-

AI 分析摘要

+
+

AI 洞察摘要

+

基于完整对话实时更新

+
@@ -164,41 +193,24 @@ export function InsightPanel({ 正在分析会话上下文…
) : ( -

{analysis}

+

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

)} {/* AI 提取关键信息 */} {Object.values(customer.aiProfile || {}).some(Boolean) && ( -
+
{Object.entries({ - 公司: customer.aiProfile.companyName, 行业: customer.aiProfile.industry, - 商业模式: customer.aiProfile.businessModel, 采购角色: customer.aiProfile.purchasingRole, - 决策权: customer.aiProfile.decisionPower, 询价数量: customer.aiProfile.inquiryQuantity, 目标价格: customer.aiProfile.targetPrice, 预算: customer.aiProfile.budget, MOQ: customer.aiProfile.moqRequirement, 样品: customer.aiProfile.sampleNeed, - 定制: customer.aiProfile.customRequirement, 交付: customer.aiProfile.deliveryTime, - 紧迫程度: customer.aiProfile.urgency, - 采购周期: customer.aiProfile.purchaseTimeline, - 物流: customer.aiProfile.logistics, - 付款: customer.aiProfile.payment, - 核心需求: customer.aiProfile.keyNeeds?.join('、'), - 痛点: customer.aiProfile.painPoints?.join('、'), - 顾虑: customer.aiProfile.concerns?.join('、'), - 异议: customer.aiProfile.objections?.join('、'), - 购买信号: customer.aiProfile.buyingSignals?.join('、'), - 竞品: customer.aiProfile.competitorMentions?.join('、'), - 沟通风格: customer.aiProfile.communicationStyle, - 建议语气: customer.aiProfile.recommendedTone, - 跟进时机: customer.aiProfile.followUpTiming, }).filter(([, v]) => v).map(([k, v]) => ( -
- {k} - {v as string} +
+

{k}

+

{v as string}

))}
@@ -209,7 +221,7 @@ export function InsightPanel({ ['流失概率', customer.aiProfile.churnProbability], ['分析可信度', customer.aiProfile.confidenceScore], ].map(([label, value]) => ( -
+

{label}

{Number(value || 0)}%

@@ -223,17 +235,19 @@ export function InsightPanel({
) : null} +
{/* 推荐动作 */}
-

AI 推荐下一步

-
+

AI 推荐下一步

+
{customer.aiProfile.nextActions.map((a) => ( diff --git a/src/components/inbox/MessageBubble.tsx b/src/components/inbox/MessageBubble.tsx index 840e0df..62580be 100644 --- a/src/components/inbox/MessageBubble.tsx +++ b/src/components/inbox/MessageBubble.tsx @@ -13,6 +13,7 @@ import { BookOpen, MoreHorizontal, Languages, + Globe, ChevronDown, ChevronUp, Download, @@ -31,6 +32,48 @@ import { getMessageAttachment } from '@/services/api/conversations' export type ViewMode = 'original' | 'translated' | 'bilingual' +function EmailAttachmentLink({ + conversationId, + messageId, + attachment, +}: { + conversationId: string + messageId: string + attachment: NonNullable[number] +}) { + const { data, isLoading, error } = useQuery({ + queryKey: ['email-attachment', conversationId, messageId, attachment.id], + queryFn: () => getMessageAttachment(conversationId, messageId, attachment.id), + enabled: attachment.available, + staleTime: Infinity, + retry: 1, + }) + const size = attachment.size >= 1024 * 1024 + ? `${(attachment.size / 1024 / 1024).toFixed(1)} MB` + : `${Math.max(1, Math.round(attachment.size / 1024))} KB` + + return ( + { + if (!data?.dataUrl) event.preventDefault() + }} + className={cn( + 'mt-2 flex items-center gap-2 rounded-xl border border-slate-200/80 bg-white/75 px-3 py-2 transition-colors', + data?.dataUrl && 'hover:border-sky-200 hover:bg-sky-50', + )} + > + {isLoading ? : } + + {attachment.name} + {error ? '附件暂不可用' : size} + + {data?.dataUrl && } + + ) +} + export function MessageBubble({ msg, viewMode, @@ -62,7 +105,16 @@ export function MessageBubble({ rawTranslation.length > (msg.originalText.length <= 20 ? 160 : Math.max(500, msg.originalText.length * 8)) const translatedText = translationCorrupted ? '' : rawTranslation const hasTranslation = translatedText && translatedText !== msg.originalText + const outgoingChineseNote = isMe + ? String( + msg.translationCn || + (hasTranslation && /[\u3400-\u9fff]/u.test(msg.originalText) ? msg.originalText : ''), + ).trim() + : '' + const outgoingSentText = isMe && translatedText ? translatedText : msg.originalText const longTranslation = translatedText.length > 260 || translatedText.split('\n').length > 6 + const hasKnownLanguage = Boolean(msg.originalLang && !['und', 'auto', 'unknown'].includes(msg.originalLang)) + const sourceLanguage = hasKnownLanguage ? languageName(msg.originalLang) : '原文' const { data: attachment, isLoading: attachmentLoading, error: attachmentError } = useQuery({ queryKey: ['message-attachment', msg.conversationId, msg.id, msg.telegramFileId], queryFn: () => getMessageAttachment(msg.conversationId, msg.id), @@ -104,6 +156,20 @@ export function MessageBubble({ ) const renderContent = () => { + if (msg.type !== 'text') { + const labels = { image: '图片', audio: '语音', file: '文件', product: '产品卡片' } + return msg.originalText ? ( +

{msg.originalText}

+ ) : ( +
+ + {labels[msg.type]} +
+ ) + } + if (!String(msg.originalText || '').trim()) { + return

Telegram 服务通知

+ } if ( (viewMode === 'bilingual' || viewMode === 'translated') && msg.sender === 'customer' && @@ -124,12 +190,54 @@ export function MessageBubble({
) } + if (isMe && viewMode === 'bilingual') { + return ( +
+
+ + + 客户收到 · {translatedText ? '英文译文' : '原文'} + +

{outgoingSentText}

+
+ {outgoingChineseNote ? ( +
+ + + 中文备注 · 仅内部可见 + +

+ {outgoingChineseNote} +

+
+ ) : msg.translationStatus === 'pending' || msg.translationStatus === 'failed' ? ( +
+ + {msg.translationStatus === 'failed' ? '中文备注生成失败,将自动重试' : 'AI 正在生成中文备注…'} +
+ ) : null} +
+ ) + } + if (isMe && viewMode === 'translated' && !translatedText && outgoingChineseNote) { + return ( +
+ + + 中文备注 · 仅内部可见 + +

+ {outgoingChineseNote} +

+
+ ) + } if (viewMode === 'bilingual' && hasTranslation) { return (
- 原文 · {languageName(msg.originalLang)} + {hasKnownLanguage ? `原文 · ${sourceLanguage}` : '原文'}

{msg.originalText}

@@ -148,7 +256,7 @@ export function MessageBubble({
- {isMe ? `${languageName(msg.originalLang)} → 英文发送内容` : `${languageName(msg.originalLang)} → 中文`} + {isMe ? `${sourceLanguage} → 英文发送内容` : `${sourceLanguage} → 中文`}
{translationContent(false)}
@@ -156,10 +264,14 @@ export function MessageBubble({ } return (
- - {languageName(msg.originalLang)} - -

{msg.originalText}

+ {hasKnownLanguage && ( + + {sourceLanguage} + + )} +

+ {msg.originalText} +

) } @@ -195,7 +307,7 @@ export function MessageBubble({ data-testid="message-bubble" data-sender={msg.sender} className={cn( - 'group flex gap-2 px-4 py-2.5 will-change-transform', + 'group flex gap-2 px-3 py-2 will-change-transform sm:px-4 sm:py-2.5', isMe ? 'justify-end motion-safe:animate-message-in-right' : 'justify-start motion-safe:animate-message-in-left', )} onMouseEnter={() => setShowActions(true)} @@ -217,11 +329,13 @@ export function MessageBubble({ )} {/* 消息气泡 */}
@@ -267,6 +381,14 @@ export function MessageBubble({ {msg.telegramFileId && attachmentError && (

文件暂时无法下载,请稍后重试

)} + {msg.emailAttachments?.map((emailAttachment) => ( + + ))}
{/* 底栏:时间 + 状态 */} diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index 826bff2..70124e9 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -25,7 +25,7 @@ export function AppLayout() {
-
+
diff --git a/src/components/layout/GlobalRealtime.tsx b/src/components/layout/GlobalRealtime.tsx index 36c0950..c305bea 100644 --- a/src/components/layout/GlobalRealtime.tsx +++ b/src/components/layout/GlobalRealtime.tsx @@ -12,7 +12,9 @@ export function GlobalRealtime() { const queryClient = useQueryClient() useEffect(() => { - if (!token) return + // Inbox owns the richer message socket so it can update message and + // conversation caches atomically. Keep only one live socket per page. + if (!token || location.pathname.startsWith('/inbox')) return const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' let socket: WebSocket | undefined let reconnectTimer: ReturnType | undefined @@ -34,12 +36,12 @@ export function GlobalRealtime() { socket.onmessage = (raw) => { let event: { type?: string; message?: Message; customer?: Customer; conversationId?: string } try { event = JSON.parse(String(raw.data)) } catch { return } - if (event.type !== 'message.created' || event.message?.sender !== 'customer') return + if (!['message.created', 'message.received'].includes(event.type || '') || event.message?.sender !== 'customer') return void queryClient.invalidateQueries({ queryKey: ['notifications'] }) void queryClient.invalidateQueries({ queryKey: ['conversations'] }) if (!location.pathname.startsWith('/inbox')) { const preview = event.message.translatedText || event.message.originalText || `[${event.message.type}]` - toast.info(`${event.customer?.name || event.message.senderName || 'Telegram 客户'} 发来新消息`, preview.slice(0, 120)) + toast.info(`${event.customer?.name || event.message.senderName || '客户'} 发来新消息`, preview.slice(0, 120)) } } socket.onerror = () => socket?.close() diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 1f8ec3e..27d16f1 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -26,33 +26,49 @@ export function Sidebar() { const usagePct = msgUsage ? Math.round((msgUsage.used / msgUsage.total) * 100) : 0 const items = NAV_ITEMS.filter((item) => !item.permission || can(item.permission)) + const groups = [ + { key: 'core', label: '核心业务' }, + { key: 'intelligence', label: '知识与洞察' }, + { key: 'admin', label: '企业管理' }, + ] as const return (
- + diff --git a/src/pages/auth/RegisterPage.tsx b/src/pages/auth/RegisterPage.tsx index 4199398..ae1d6fd 100644 --- a/src/pages/auth/RegisterPage.tsx +++ b/src/pages/auth/RegisterPage.tsx @@ -16,7 +16,7 @@ import { INDUSTRIES } from '@/config/constants' const schema = z.object({ name: z.string().min(2, '请输入您的姓名'), account: z.string().min(5, '请输入手机号或邮箱'), - password: z.string().min(6, '密码至少6位').max(32, '密码最长32位'), + password: z.string().min(8, '密码至少8位').max(32, '密码最长32位'), company: z.string().min(2, '请输入企业名称'), industry: z.string().min(1, '请选择所属行业'), teamSize: z.string().min(1, '请选择团队规模'), @@ -51,7 +51,7 @@ export function RegisterPage() { const session = await registerApi(values) setSession(session) toast.success('企业创建成功', '接下来请完成企业初始化配置') - navigate('/channels') + navigate('/channels?setup=telegram') } catch (e) { toast.error(errorMessage(e)) } finally { @@ -79,7 +79,7 @@ export function RegisterPage() { - + diff --git a/src/pages/channels/ChannelsPage.tsx b/src/pages/channels/ChannelsPage.tsx index e95fb77..4e8feb4 100644 --- a/src/pages/channels/ChannelsPage.tsx +++ b/src/pages/channels/ChannelsPage.tsx @@ -1,6 +1,23 @@ -import { useState, useEffect } from 'react' +import { useEffect, useRef, useState } from 'react' +import { useNavigate, useSearchParams } from 'react-router-dom' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { Cable, Plus, Pause, Play, RefreshCw, Trash2, History, Settings } from 'lucide-react' +import { + Activity, + ArrowUpRight, + Cable, + CheckCircle2, + History, + Mail, + MessageSquareText, + Pause, + Play, + RefreshCw, + Settings, + ShieldCheck, + Smartphone, + Trash2, + Workflow, +} from 'lucide-react' import { toast, errorMessage } from '@/app/store/toast' import { confirmDialog } from '@/app/store/confirm' import { PageHeader } from '@/components/common/PageHeader' @@ -15,12 +32,12 @@ import { Input, Field } from '@/components/ui/input' import { PermissionGate } from '@/components/common/PermissionGate' import { ChannelIcon } from '@/components/common/ChannelIcon' import { - getChannels, connectChannel, testChannel, updateChannel, deleteChannel, getChannelLogs, - startTelegramLogin, submitTelegramCode, submitTelegramPassword, syncTelegramChannel, + getChannels, testChannel, updateChannel, deleteChannel, getChannelLogs, + connectEmailChannel, startTelegramLogin, submitTelegramCode, submitTelegramPassword, + getTelegramLoginState, syncTelegramChannel, } from '@/services/api/channels' import { CHANNEL_META } from '@/config/constants' import { formatRelative } from '@/utils/format' -import { cn } from '@/utils/cn' import type { ChannelType, ChannelConnection, ChannelSyncLog } from '@/types' const STATUS_MAP: Record = { @@ -31,7 +48,17 @@ const STATUS_MAP: Record = { unconnected: { label: '未连接', color: 'bg-gray-100 text-gray-400' }, } +const EMAIL_PRESETS = { + gmail: { label: 'Gmail', imapHost: 'imap.gmail.com', imapPort: 993, imapSecure: true, smtpHost: 'smtp.gmail.com', smtpPort: 465, smtpSecure: true }, + outlook: { label: 'Outlook / Microsoft 365', imapHost: 'outlook.office365.com', imapPort: 993, imapSecure: true, smtpHost: 'smtp.office365.com', smtpPort: 587, smtpSecure: false }, + qq: { label: 'QQ 企业邮箱', imapHost: 'imap.exmail.qq.com', imapPort: 993, imapSecure: true, smtpHost: 'smtp.exmail.qq.com', smtpPort: 465, smtpSecure: true }, + netease: { label: '网易企业邮箱', imapHost: 'imap.qiye.163.com', imapPort: 993, imapSecure: true, smtpHost: 'smtp.qiye.163.com', smtpPort: 465, smtpSecure: true }, + custom: { label: '自定义服务器', imapHost: '', imapPort: 993, imapSecure: true, smtpHost: '', smtpPort: 465, smtpSecure: true }, +} as const + export function ChannelsPage() { + const navigate = useNavigate() + const [searchParams, setSearchParams] = useSearchParams() const queryClient = useQueryClient() const { data: channels } = useQuery({ queryKey: ['channels'], queryFn: getChannels }) const [wizard, setWizard] = useState<{ open: boolean; type: ChannelType | null; step: number }>({ open: false, type: null, step: 0 }) @@ -40,9 +67,25 @@ export function ChannelsPage() { const [telegramPassword, setTelegramPassword] = useState('') const [loginChannelId, setLoginChannelId] = useState('') const [authState, setAuthState] = useState('') + const [connecting, setConnecting] = useState(false) + const [emailPreset, setEmailPreset] = useState('custom') + const [emailForm, setEmailForm] = useState({ + displayName: '', + account: '', + password: '', + imapHost: '', + imapPort: 993, + imapSecure: true, + imapFolder: 'INBOX', + smtpHost: '', + smtpPort: 465, + smtpSecure: true, + syncDays: 30, + }) 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) @@ -58,116 +101,564 @@ export function ChannelsPage() { const list = channels ?? [] - return ( -
- c.status === 'connected').length} 个渠道已连接 · 共 ${list.length} 个可连接渠道`} /> + const resetTelegramWizard = () => { + setTelegramAccount('') + setTelegramCode('') + setTelegramPassword('') + setLoginChannelId('') + setAuthState('') + } -
+ const setTelegramStepFromState = (state: string) => { + if (state === 'waiting_password') { + setAuthState('waiting_password') + setWizard({ open: true, type: 'telegram', step: 2 }) + return + } + setAuthState('waiting_code') + setWizard({ open: true, type: 'telegram', step: 1 }) + } + + const completeTelegramBinding = async (channelId: string) => { + let syncSummary = '' + try { + const result = await syncTelegramChannel(channelId) + syncSummary = `,已同步 ${result.chats} 个会话和 ${result.messages} 条消息` + } catch (error) { + syncSummary = `;账号已连接,但首次同步失败:${errorMessage(error)}` + } + toast.success(`Telegram 绑定成功${syncSummary}`) + resetTelegramWizard() + setWizard({ open: false, type: null, step: 0 }) + await queryClient.invalidateQueries() + } + + const openTelegramWizard = async (channel?: ChannelConnection) => { + resetTelegramWizard() + setTelegramAccount(channel?.account || '') + setWizard({ open: true, type: 'telegram', step: 0 }) + if (!channel || channel.id === 'telegram-available') return + setConnecting(true) + try { + const result = await getTelegramLoginState(channel.id) + setLoginChannelId(channel.id) + if (result.status === 'ready' && result.connectionState === 'ready') { + await completeTelegramBinding(channel.id) + return + } + setTelegramStepFromState(result.status) + toast.info(result.status === 'waiting_password' ? '请继续输入二步验证密码' : '请继续输入 Telegram 验证码') + } catch (error) { + toast.error(errorMessage(error)) + } finally { + setConnecting(false) + } + } + + useEffect(() => { + if (!channels || setupOpenedRef.current || searchParams.get('setup') !== 'telegram') return + setupOpenedRef.current = true + const telegram = channels.find((item) => item.type === 'telegram') + void openTelegramWizard(telegram) + setSearchParams({}, { replace: true }) + // 新注册后的 setup 参数只消费一次,避免查询刷新时重复打开向导。 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [channels, searchParams, setSearchParams]) + + const applyEmailPreset = (value: keyof typeof EMAIL_PRESETS) => { + setEmailPreset(value) + const preset = EMAIL_PRESETS[value] + setEmailForm((current) => ({ + ...current, + imapHost: preset.imapHost, + imapPort: preset.imapPort, + imapSecure: preset.imapSecure, + smtpHost: preset.smtpHost, + smtpPort: preset.smtpPort, + smtpSecure: preset.smtpSecure, + })) + } + + const connectedCount = list.filter((channel) => channel.status === 'connected').length + const telegramChannel = list.find((channel) => channel.type === 'telegram') + const telegramConnected = telegramChannel?.status === 'connected' + const todayMessages = list.reduce((total, channel) => total + Number(channel.todayMessages || 0), 0) + const latestSync = list + .map((channel) => channel.lastSyncAt) + .filter(Boolean) + .sort() + .at(-1) + + return ( +
+
+
+
+
+
+ + + + + Unified communication hub +
+

渠道与账号

+

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

+
+
+ {[ + { label: '在线账号', value: `${connectedCount}/${list.length}`, icon: CheckCircle2 }, + { label: '今日消息', value: String(todayMessages), icon: MessageSquareText }, + { label: '最近同步', value: latestSync ? formatRelative(latestSync) : '等待首次同步', icon: Activity }, + ].map((stat) => ( +
+ +

{stat.value}

+

{stat.label}

+
+ ))} +
+
+
+ + + + {!telegramConnected && ( + +
+
+
+ 首次配置 · 约 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}

+
+
+ ))} +
+
+ +
+
+ )} + +
{list.map((ch) => { const meta = CHANNEL_META[ch.type] const st = STATUS_MAP[ch.status] return ( - + +
+
- +
+ +
{st.label}
-

{meta.name}

-

{meta.desc}

- {ch.account &&

账号:{ch.account}

} - {ch.todayMessages > 0 &&

今日消息:{ch.todayMessages} 条

} - {ch.lastSyncAt &&

最近同步:{formatRelative(ch.lastSyncAt)}

} +
+

{meta.name}

+

{meta.desc}

+ {ch.account &&

{ch.account}

} +
+ +
+
+

今日消息

+

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

+
+
+

同步状态

+

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

+
+
+
+ {(ch.type === 'email' + ? ['IMAP 收信', 'SMTP 发信', '附件同步'] + : ['实时收发', '历史同步', '媒体消息'] + ).map((capability) => ( + + {capability} + + ))} +
{ch.errorMessage &&

{ch.errorMessage}

} -
+
{ch.status === 'connected' ? ( <> - + try { + const result = await syncTelegramChannel(ch.id) + toast.success(`同步完成:${result.chats} 个会话,${result.messages} 条新消息`) + queryClient.invalidateQueries() + } catch (error) { + toast.error(errorMessage(error)) + } + }}>立即同步 + ) : ch.status === 'paused' ? ( - + ) : ch.status === 'error' ? ( - + ) : ( - + )}
+
) })} + +
+
+ + + +

统一处理链路

+

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

+
+ {[ + ['01', '实时接收与自动同步'], + ['02', 'AI 翻译、情绪与意图识别'], + ['03', '客户画像、风险与任务联动'], + ].map(([step, label]) => ( +
+ {step} + {label} +
+ ))} +
+ +
+
{/* 连接向导 */} setWizard({ ...wizard, open: v })} + onOpenChange={(open) => { + setWizard({ ...wizard, open }) + if (!open) { + setEmailForm((current) => ({ ...current, password: '' })) + resetTelegramWizard() + } + }} title={`连接 ${wizard.type ? CHANNEL_META[wizard.type].name : ''}`} footer={ + > + {wizard.type === 'email' + ? '验证并连接邮箱' + : !loginChannelId + ? '发送 Telegram 验证码' + : authState === 'waiting_password' + ? '验证二步密码并完成绑定' + : '验证并完成绑定'} + } > -
-
- 步骤 {wizard.step + 1}/7 - 连接现有 Telegram 网关账号 + {wizard.type === 'email' ? ( +
+
+
+ + IMAP 收信 + SMTP 发信 +
+

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

+
+ + setEmailForm({ ...emailForm, displayName: event.target.value })} + placeholder="例如:Lingxi Sales" + /> + + + setEmailForm({ ...emailForm, account: event.target.value })} + placeholder="sales@company.com" + /> + +
+ + setEmailForm({ ...emailForm, password: event.target.value })} + placeholder="仅提交到本地服务端验证" + /> + +
+ + setEmailForm({ ...emailForm, imapHost: event.target.value })} placeholder="imap.company.com" /> + + + setEmailForm({ ...emailForm, imapPort: Number(event.target.value) })} /> + + + setEmailForm({ ...emailForm, smtpHost: event.target.value })} placeholder="smtp.company.com" /> + + + setEmailForm({ ...emailForm, smtpPort: Number(event.target.value) })} /> + +
+
+ + setEmailForm({ ...emailForm, imapFolder: event.target.value })} /> + + + setTelegramAccount(e.target.value)} placeholder="+8613800138000" /> - - )} - {authState === 'waiting_code' && ( - - setTelegramCode(e.target.value)} placeholder="请输入验证码" /> - - )} - {authState === 'waiting_password' && ( - - setTelegramPassword(e.target.value)} /> - - )} -

手机号、验证码和二步密码只发送到服务端 Telegram Gateway,不在浏览器持久化。

-
+ ) : ( +
+
+
+ + 绑定你自己的 Telegram 工作账号 +
+

+ 每个企业独立绑定账号,不会共享其他租户的 Telegram 会话或凭据。 +

+
+
+ {[ + { step: 0, label: '手机号' }, + { step: 1, label: '验证码' }, + { step: 2, label: '二步密码' }, + ].map((item) => { + const active = wizard.step === item.step + const complete = wizard.step > item.step + return ( +
+ {complete ? : {item.step + 1}} + {item.label} +
+ ) + })} +
+ {!loginChannelId && ( + + setTelegramAccount(e.target.value)} + placeholder="+8613800138000" + /> + + )} + {authState === 'waiting_code' && ( + + setTelegramCode(e.target.value.replace(/\D/g, '').slice(0, 8))} + placeholder="请输入 4—8 位验证码" + /> + + )} + {authState === 'waiting_password' && ( + + setTelegramPassword(e.target.value)} + placeholder="输入 Telegram 二步验证密码" + /> + + )} +

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

+
+ )}
{/* 同步日志 */} @@ -180,6 +671,73 @@ export function ChannelsPage() {
))} + + setSettingsDrawer(null)} + title={settingsDrawer ? `${CHANNEL_META[settingsDrawer.type].name} 连接详情` : '连接详情'} + width="w-[420px]" + > + {settingsDrawer && ( +
+
+ +
+

{settingsDrawer.account || settingsDrawer.name}

+

+ {STATUS_MAP[settingsDrawer.status]?.label} · 最近同步 {settingsDrawer.lastSyncAt ? formatRelative(settingsDrawer.lastSyncAt) : '尚未同步'} +

+
+
+ + {settingsDrawer.type === 'email' && ( +
+ {[ + ['IMAP', `${settingsDrawer.imapHost}:${settingsDrawer.imapPort}`], + ['SMTP', `${settingsDrawer.smtpHost}:${settingsDrawer.smtpPort}`], + ['文件夹', settingsDrawer.imapFolder || 'INBOX'], + ['同步范围', `最近 ${settingsDrawer.syncDays || 30} 天`], + ].map(([label, value]) => ( +
+

{label}

+

{value}

+
+ ))} +
+ )} + +
+ +
+ + +
+ )} +
) } diff --git a/src/pages/customers/CustomerDetailPage.tsx b/src/pages/customers/CustomerDetailPage.tsx index 6c475e4..27149f2 100644 --- a/src/pages/customers/CustomerDetailPage.tsx +++ b/src/pages/customers/CustomerDetailPage.tsx @@ -3,6 +3,8 @@ import { useNavigate, useParams } from 'react-router-dom' import { useQuery, useQueryClient } from '@tanstack/react-query' import { ArrowLeft, Edit, Send, UserRoundPlus, Tag, GitMerge, Download, ExternalLink } from 'lucide-react' import { getCustomer, updateCustomer } from '@/services/api/customers' +import { createTask } from '@/services/api/crm' +import { useAuthStore } from '@/app/store/auth' import { useI18n } from '@/i18n' import { toast, errorMessage } from '@/app/store/toast' import { PageLoading, ErrorState, EmptyState } from '@/components/ui/feedback' @@ -22,6 +24,8 @@ import { CountryFlag } from '@/components/common/CountryFlag' import { ChannelIcon } from '@/components/common/ChannelIcon' import { IntentScoreBadge, CustomerStageBadge, SentimentBadge, RiskLevelBadge } from '@/components/common/StatusBadges' import { ChipPicker } from '@/components/customer/CustomerFormDialog' +import { CustomerProfileRadar } from '@/components/customer/CustomerProfileRadar' +import { CustomerSemanticTag, CustomerTagLegend, customerTagCategory } from '@/components/customer/CustomerSemanticTag' import { CUSTOMER_STAGES, CUSTOMER_TAGS, NEXT_ACTIONS } from '@/config/constants' import { formatRelative, formatDateTime } from '@/utils/format' import { cn } from '@/utils/cn' @@ -31,6 +35,7 @@ export function CustomerDetailPage() { const { id } = useParams<{ id: string }>() const navigate = useNavigate() const queryClient = useQueryClient() + const user = useAuthStore((state) => state.user) const [tab, setTab] = useState('overview') const [tagDialog, setTagDialog] = useState(false) @@ -52,22 +57,42 @@ export function CustomerDetailPage() { if (isError || !data) return refetch()} /> const c = data.customer + const createSuggestedTask = async (title: string) => { + try { + await createTask({ + title, + type: 'revisit', + priority: c.riskLevel === 'urgent' || c.riskLevel === 'high' ? 'high' : 'medium', + status: 'pending', + customerId: c.id, + customerName: c.name, + ownerId: user?.id || '', + dueAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + note: `由客户画像的 AI 建议创建:${title}`, + }) + await queryClient.invalidateQueries({ queryKey: ['tasks'] }) + toast.success('已创建跟进任务', '默认明天到期,可在商机与跟进中调整') + } catch (error) { + toast.error(errorMessage(error)) + } + } const FILE_ICONS: Record = { quote: '📄', contract: '📝', image: '🖼️', doc: '📁', other: '📎' } return ( -
+
{/* 顶部客户画像卡片 */} - +
+

Customer intelligence

-

{c.name}

+

{c.name}

{c.isKey && ⭐ 重点客户} @@ -80,8 +105,19 @@ export function CustomerDetailPage() { {c.riskLevel !== 'none' && } 负责人:{c.ownerId === 'u-003' ? '李婷' : c.ownerId === 'u-004' ? '赵磊' : c.ownerId === 'u-005' ? '周敏' : c.ownerId === 'u-008' ? '郑楠' : '未分配'}
-
- {c.tags.map((t) => {t})} +
+ + {c.tags.length > 0 ? ( +
+ {[...c.tags] + .sort((a, b) => customerTagCategory(a).localeCompare(customerTagCategory(b))) + .map((tag) => )} +
+ ) : ( +

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

+ )}
@@ -119,43 +155,46 @@ export function CustomerDetailPage() {
{tab === 'overview' && ( -
- - -
- {[ - '✅ 识别来源渠道(WhatsApp)', - '✅ 记录来源参数(官网按钮带入)', - '✅ 匹配客户身份(手机号 + 姓名字段)', - '✅ 自动发现重复客户(未匹配到重复)', - '✅ 人工确认建档(管理员 / 业务主管确认)', - '✅ 完整客户档案已建立', - ].map((step, i) => ( -
- {i + 1} - {step.replace('✅ ', '')} +
+ + + + +
+
+ {[ + ['成交概率', c.aiProfile.dealProbability || 0, 'text-emerald-700 bg-emerald-50'], + ['流失风险', c.aiProfile.churnProbability || 0, 'text-amber-700 bg-amber-50'], + ['分析可信度', c.aiProfile.confidenceScore || 0, 'text-blue-700 bg-blue-50'], + ].map(([label, value, color]) => ( +
+

{label}

+

{Number(value)}%

+
+ ))} +
+

+ {c.aiProfile.summary || 'AI 正在根据 Telegram 会话补全客户画像。'} +

+ {c.aiProfile.evidence?.length ? ( +
+

关键判断依据

+
+ {c.aiProfile.evidence.slice(0, 4).map((item) => ( +
+ + {item} +
+ ))} +
- ))} + ) : null}
- - -

{c.aiProfile.summary}

- {c.aiProfile.nextActions.length > 0 && ( -
-

推荐动作

-
- {c.aiProfile.nextActions.map((a) => ( - - ))} -
-
- )} -
- {/* AI 提取信息 */} - - -
+ + + +
{Object.entries({ 询价数量: c.aiProfile.inquiryQuantity, 目标价格: c.aiProfile.targetPrice, @@ -165,16 +204,32 @@ export function CustomerDetailPage() { 交付时间: c.aiProfile.deliveryTime, 物流方式: c.aiProfile.logistics, 付款方式: c.aiProfile.payment, - 售后诉求: c.aiProfile.aftersales, - 主要顾虑: c.aiProfile.concerns?.join('、'), - }).filter(([, v]) => v).map(([k, v]) => ( -
- {k} -

{v as string}

+ }).map(([k, v]) => ( +
+ {k} +

+ {(v as string) || '待识别'} +

))}
+ + + +
+ {(c.aiProfile.nextActions.length ? c.aiProfile.nextActions : ['补充确认采购数量', '确认目标交期']).slice(0, 5).map((action, index) => ( + + ))} +
+
)} diff --git a/src/pages/customers/CustomersPage.tsx b/src/pages/customers/CustomersPage.tsx index 1d1d497..ee9d99c 100644 --- a/src/pages/customers/CustomersPage.tsx +++ b/src/pages/customers/CustomersPage.tsx @@ -270,7 +270,7 @@ export function CustomersPage() { )} {list.map((customer: Customer) => ( - + e.stopPropagation()}> setSelected((s) => { const ns = new Set(s); v ? ns.add(customer.id) : ns.delete(customer.id); return ns })} /> navigate(`/customers/${customer.id}`)}>
diff --git a/src/pages/dashboard/DashboardPage.tsx b/src/pages/dashboard/DashboardPage.tsx index 76a0688..9d75c45 100644 --- a/src/pages/dashboard/DashboardPage.tsx +++ b/src/pages/dashboard/DashboardPage.tsx @@ -92,43 +92,53 @@ export function DashboardPage() { const s = data.stats return ( -
+
{/* 顶部欢迎区 */} -
-
-

- {greeting},{user?.name} -

-

- {dateStr} · {tenant?.name} · {t('dashboard.updatedAt')} {formatDateTime(data.updatedAt).slice(11)} -

+
+
+
+

Business command center

+

+ {greeting},{user?.name} +

+

+ {dateStr} · {tenant?.name} · 数据更新于 {formatDateTime(data.updatedAt).slice(11)} +

+
+
+ {plan.name} + + +
-
- {plan.name} - - +
+ {quickActions.slice(0, 5).map((a) => ( + + ))}
- {/* 快捷操作 */} -
- {quickActions.map((a) => ( - - ))} -
- {/* 核心指标 */} +
+
+

Today

+

今日经营重点

+
+ +
navigate('/inbox')} /> navigate('/inbox?filter=pending')} /> navigate('/customers?intentLevel=high')} /> navigate('/opportunities?tab=tasks')} /> +
+
navigate('/risks')} /> diff --git a/src/pages/inbox/InboxPage.tsx b/src/pages/inbox/InboxPage.tsx index da93d0b..8685981 100644 --- a/src/pages/inbox/InboxPage.tsx +++ b/src/pages/inbox/InboxPage.tsx @@ -1,7 +1,23 @@ import { useState, useCallback, useEffect, useLayoutEffect, useRef } from 'react' import { useSearchParams } from 'react-router-dom' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { ArrowLeft, UserRound, ChevronLeft, MessageCircle, X } from 'lucide-react' +import { + Archive, + ArrowLeft, + Ban, + BellOff, + Check, + ChevronLeft, + Eraser, + LogOut, + MessageCircle, + MoreHorizontal, + Palette, + Pin, + Trash2, + UserRound, + X, +} from 'lucide-react' import { getConversations, getConversation, @@ -12,6 +28,10 @@ import { updateConversation, updateMessage, markConversationRead, + openTelegramConversation, + runTelegramConversationAction, + type TelegramConversationAction, + type ConversationListItem, } from '@/services/api/conversations' import { updateCustomer } from '@/services/api/customers' import { useAuthStore } from '@/app/store/auth' @@ -20,11 +40,11 @@ import { toast, errorMessage } from '@/app/store/toast' import { PageLoading, ErrorState, EmptyState } from '@/components/ui/feedback' import { Button } from '@/components/ui/button' import { Dialog } from '@/components/ui/dialog' -import { Segmented, type TabItem } from '@/components/ui/tabs' +import { Dropdown } from '@/components/ui/dropdown' +import { Segmented } from '@/components/ui/tabs' import { Avatar } from '@/components/ui/avatar' import { ChannelIcon } from '@/components/common/ChannelIcon' import { CountryFlag } from '@/components/common/CountryFlag' -import { IntentScoreBadge } from '@/components/common/StatusBadges' import type { ViewMode } from '@/components/inbox/MessageBubble' import type { Conversation, Customer, Message } from '@/types' import { ConversationList } from '@/components/inbox/ConversationList' @@ -35,6 +55,50 @@ import { AiReplies } from '@/components/inbox/AiReplies' import { ConversationInsightBar } from '@/components/inbox/ConversationInsightBar' import { useDebounce } from '@/hooks/useDebounce' import { getWebSocketTicket } from '@/services/api/auth' +import { cn } from '@/utils/cn' + +type ChatWallpaper = 'ocean' | 'aurora' | 'sunset' | 'mint' + +const CHAT_WALLPAPERS: Array<{ + value: ChatWallpaper + label: string + swatch: string +}> = [ + { value: 'ocean', label: '清爽蓝', swatch: 'from-blue-300 via-sky-100 to-white' }, + { value: 'aurora', label: '极光紫', swatch: 'from-violet-300 via-fuchsia-100 to-cyan-100' }, + { value: 'sunset', label: '日落橙', swatch: 'from-orange-200 via-rose-100 to-violet-100' }, + { value: 'mint', label: '薄荷绿', swatch: 'from-emerald-200 via-teal-100 to-sky-100' }, +] + +function sortRealtimeConversations(items: ConversationListItem[]) { + return [...items].sort((a, b) => { + const pinned = Number(Boolean(b.isPinned)) - Number(Boolean(a.isPinned)) + if (pinned) return pinned + const bTime = Date.parse(b.lastMessage?.createdAt || b.updatedAt || '') || 0 + const aTime = Date.parse(a.lastMessage?.createdAt || a.updatedAt || '') || 0 + if (bTime !== aTime) return bTime - aTime + return String(b.id).localeCompare(String(a.id)) + }) +} + +function updateRealtimeConversationList( + current: { list: ConversationListItem[]; total: number } | undefined, + conversationId: string, + patch: Partial, +) { + if (!current) return current + let matched = false + const list = current.list.map((item) => { + if (item.id !== conversationId) return item + matched = true + return { + ...item, + ...patch, + customer: patch.customer ? { ...item.customer, ...patch.customer } : item.customer, + } + }) + return matched ? { ...current, list: sortRealtimeConversations(list) } : current +} export function InboxPage() { const [searchParams, setSearchParams] = useSearchParams() @@ -48,11 +112,21 @@ export function InboxPage() { const [keyword, setKeyword] = useState('') const debouncedKeyword = useDebounce(keyword, 300) - const [viewMode, setViewMode] = useState('bilingual') + const [viewMode, setViewMode] = useState(() => { + const saved = window.localStorage.getItem('lingxi-message-view-mode') + return ['original', 'translated', 'bilingual'].includes(String(saved)) + ? saved as ViewMode + : 'bilingual' + }) const [mobilePanel, setMobilePanel] = useState<'list' | 'chat' | 'insight'>('list') const [showInsight, setShowInsight] = useState(false) // desktop right panel toggle - const [showAiReplies, setShowAiReplies] = useState(true) + const [showAiReplies, setShowAiReplies] = useState(false) + const [chatWallpaper, setChatWallpaper] = useState(() => { + const saved = window.localStorage.getItem('lingxi-chat-wallpaper') + return CHAT_WALLPAPERS.some((item) => item.value === saved) ? saved as ChatWallpaper : 'ocean' + }) const [draftText, setDraftText] = useState('') + const [draftTranslationCn, setDraftTranslationCn] = useState('') const [replyingTo, setReplyingTo] = useState<{ id: string; text: string } | null>(null) const [noteTarget, setNoteTarget] = useState(null) const [noteDraft, setNoteDraft] = useState('') @@ -61,15 +135,38 @@ export function InboxPage() { message: Message customer: Customer } | null>(null) + const [addTelegramOpen, setAddTelegramOpen] = useState(false) + const [addTelegramType, setAddTelegramType] = useState<'user' | 'chat' | 'invite'>('user') + const [addTelegramIdentifier, setAddTelegramIdentifier] = useState('') + const [addTelegramLoading, setAddTelegramLoading] = useState(false) + const [conversationActionTarget, setConversationActionTarget] = useState<{ + item: ConversationListItem + action: 'clear' | 'delete' | 'leave' + } | null>(null) + const [conversationActionLoading, setConversationActionLoading] = useState(false) + const messageListRef = useRef(null) + const messageContentRef = useRef(null) + const activeConversationQueryKeyRef = useRef([ + 'conversations', + filter, + channel, + debouncedKeyword, + ]) + activeConversationQueryKeyRef.current = ['conversations', filter, channel, debouncedKeyword] useEffect(() => { setDraftText('') + setDraftTranslationCn('') setReplyingTo(null) setShowAiReplies(true) if (isMobile && convId) setMobilePanel('chat') }, [convId]) + useEffect(() => { + window.localStorage.setItem('lingxi-chat-wallpaper', chatWallpaper) + }, [chatWallpaper]) + // 会话列表 const { data: convData, isLoading: convLoading } = useQuery({ queryKey: ['conversations', filter, channel, debouncedKeyword], @@ -108,6 +205,34 @@ export function InboxPage() { return () => cancelAnimationFrame(frame) }, [convId, messages?.length, lastMessageId]) + // Keep the newest message visible while translations, attachments and the + // surrounding composer finish measuring. Do not override a user scrolling up. + useEffect(() => { + const container = messageListRef.current + const content = messageContentRef.current + if (!container || !content || !convId) return + + let initialLayout = true + const scrollIfFollowingLatest = () => { + const distanceFromBottom = + container.scrollHeight - container.clientHeight - container.scrollTop + if (initialLayout || distanceFromBottom <= 160) { + container.scrollTop = container.scrollHeight + } + initialLayout = false + } + + const frame = requestAnimationFrame(scrollIfFollowingLatest) + const observer = new ResizeObserver(scrollIfFollowingLatest) + observer.observe(container) + observer.observe(content) + + return () => { + cancelAnimationFrame(frame) + observer.disconnect() + } + }, [convId]) + useEffect(() => { if (!convId || !messages?.length) return const unreadIds = messages.filter((message) => message.sender === 'customer').slice(-100).map((message) => message.id) @@ -160,8 +285,15 @@ export function InboxPage() { conversation?: Conversation } try { event = JSON.parse(String(raw.data)) } catch { return } + const activeConversationCache = queryClient.getQueryData<{ list: ConversationListItem[] }>( + activeConversationQueryKeyRef.current, + ) + const conversationWasVisible = Boolean( + event.conversationId + && activeConversationCache?.list.some((item) => item.id === event.conversationId), + ) if ( - event.type === 'message.created' && + ['message.created', 'message.received'].includes(event.type || '') && event.conversationId && event.message?.sender === 'customer' && event.customer @@ -185,27 +317,38 @@ export function InboxPage() { }) queryClient.setQueriesData<{ list: Array<{ id: string; lastMessage?: Message }>; total: number }>( { queryKey: ['conversations'] }, - (current) => current ? { - ...current, - list: current.list.map((item) => item.id === event.conversationId - ? { ...item, lastMessage: event.message } - : item), - } : current, + (current) => { + const typed = current as { list: ConversationListItem[]; total: number } | undefined + const item = typed?.list.find((entry) => entry.id === event.conversationId) + const isNewMessage = ['message.created', 'message.received'].includes(event.type || '') + const isCurrentLastMessage = + String(item?.lastMessage?.id || '') === String(event.message?.id || '') + if (!isNewMessage && !isCurrentLastMessage) return typed + return updateRealtimeConversationList(typed, event.conversationId!, { + lastMessage: event.message, + }) + }, ) } - if (event.conversationId && event.customer && event.conversation) { - queryClient.setQueryData(['conversation', event.conversationId], { + if (event.conversationId && event.conversation) { + queryClient.setQueryData(['conversation', event.conversationId], (current: any) => ({ conversation: event.conversation, - customer: event.customer, - }) - queryClient.setQueriesData({ queryKey: ['conversations'] }, (current: any) => current ? { - ...current, - list: current.list.map((item: any) => item.id === event.conversationId - ? { ...item, ...event.conversation, customer: event.customer } - : item), - } : current) + customer: event.customer || current?.customer, + })) + queryClient.setQueriesData<{ list: ConversationListItem[]; total: number }>( + { queryKey: ['conversations'] }, + (current) => updateRealtimeConversationList(current, event.conversationId!, { + ...event.conversation, + ...(event.customer ? { customer: event.customer } : {}), + }), + ) + } + if ( + event.type === 'sync.completed' + || (event.conversationId && !conversationWasVisible) + ) { + void queryClient.invalidateQueries({ queryKey: ['conversations'] }) } - void queryClient.invalidateQueries({ queryKey: ['conversations'] }) } socket.onerror = () => socket?.close() socket.onclose = () => { @@ -245,19 +388,34 @@ export function InboxPage() { [setSearchParams], ) + const setChannel = useCallback( + (value: string) => { + setSearchParams((prev) => { + if (value) prev.set('channel', value) + else prev.delete('channel') + prev.delete('c') + return prev + }) + }, + [setSearchParams], + ) + const sendMsg = useMutation({ mutationFn: async ({ text, convId: id, autoTranslate, + translationCn, }: { text: string convId: string autoTranslate?: boolean + translationCn?: string }) => { return sendMessage(id, { text, autoTranslate, + translationCn, replyToId: replyingTo?.id, replyPreview: replyingTo?.text, }) @@ -272,7 +430,13 @@ export function InboxPage() { const handleSend = async (text: string, autoTranslate = true) => { if (!convId) return - await sendMsg.mutateAsync({ text, convId, autoTranslate }) + await sendMsg.mutateAsync({ + text, + convId, + autoTranslate, + translationCn: draftTranslationCn || undefined, + }) + setDraftTranslationCn('') } const handleMedia = async (files: File[], kind: 'document' | 'photo' | 'audio' | 'voice' | 'sticker') => { @@ -293,6 +457,87 @@ export function InboxPage() { for (const item of encoded) await sendMedia(convId, { ...item, kind }) } + const refreshConversationState = async (id?: string) => { + await queryClient.invalidateQueries({ queryKey: ['conversations'] }) + if (id) { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['conversation', id] }), + queryClient.invalidateQueries({ queryKey: ['messages', id] }), + ]) + } + } + + const executeTelegramAction = async ( + action: TelegramConversationAction, + item?: ConversationListItem, + ) => { + const id = item?.id || convId + if (!id) return + try { + const result = await runTelegramConversationAction(id, action) + if ('deleted' in result && result.deleted) { + queryClient.removeQueries({ queryKey: ['messages', id] }) + queryClient.removeQueries({ queryKey: ['conversation', id] }) + if (convId === id) setSearchParams((params) => { params.delete('c'); return params }) + } else { + await refreshConversationState(id) + } + const labels: Partial> = { + clear: '消息记录已清空', + delete: '会话已从 Telegram 列表删除', + pin: '会话已置顶', + unpin: '已取消置顶', + archive: '会话已归档', + unarchive: '会话已移出归档', + mute: '会话已静音 8 小时', + unmute: '会话已恢复通知', + 'mark-unread': '会话已标记为未读', + leave: '已退出群组或频道', + block: '已拉黑该 Telegram 用户', + unblock: '已解除拉黑', + } + toast.success(labels[action] || 'Telegram 操作已完成') + } catch (error) { + toast.error(errorMessage(error)) + throw error + } + } + + const confirmTelegramAction = async () => { + if (!conversationActionTarget) return + setConversationActionLoading(true) + try { + await executeTelegramAction(conversationActionTarget.action, conversationActionTarget.item) + setConversationActionTarget(null) + } finally { + setConversationActionLoading(false) + } + } + + const addTelegramConversation = async () => { + const identifier = addTelegramIdentifier.trim() + if (!identifier) { + toast.error('请输入 Telegram 标识') + return + } + setAddTelegramLoading(true) + try { + const value = await openTelegramConversation({ + identifier, + targetType: addTelegramType, + }) + await refreshConversationState(value.conversation.id) + setAddTelegramOpen(false) + setAddTelegramIdentifier('') + setConv(value.conversation.id) + toast.success('Telegram 会话已添加', value.customer.name) + } catch (error) { + toast.error(errorMessage(error)) + } finally { + setAddTelegramLoading(false) + } + } + const handleAction = { quote: (msg: Message) => { if (!convId) return @@ -391,8 +636,127 @@ export function InboxPage() { ) - const handleAdopt = (content: string) => { + const telegramDialogs = ( + <> + + + + + )} + > +
+
+ {[ + { value: 'user' as const, label: '用户' }, + { value: 'chat' as const, label: '群组/频道' }, + { value: 'invite' as const, label: '邀请链接' }, + ].map((option) => ( + + ))} +
+
+ + setAddTelegramIdentifier(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter' && !addTelegramLoading) void addTelegramConversation() + }} + placeholder={ + addTelegramType === 'user' + ? '@username、+1 234… 或 123456789' + : addTelegramType === 'chat' + ? '@groupname 或 -1001234567890' + : 'https://t.me/+xxxxxxxx' + } + className="h-11 w-full rounded-xl border border-slate-200 bg-white px-3.5 text-sm outline-none transition focus:border-primary-300 focus:ring-4 focus:ring-primary-50" + /> +

+ 用户 ID 与 Chat ID 含义不同,请先选择正确类型。邀请链接加入群组可能需要管理员审批。 +

+
+
+
+ + { if (!open && !conversationActionLoading) setConversationActionTarget(null) }} + title={ + conversationActionTarget?.action === 'delete' + ? '删除 Telegram 会话' + : conversationActionTarget?.action === 'leave' + ? '退出群组或频道' + : '清空消息记录' + } + description={`当前会话:${conversationActionTarget?.item.customer.name || ''}`} + persistent={conversationActionLoading} + footer={( + <> + + + + )} + > +
+ {conversationActionTarget?.action === 'delete' + ? String(conversationActionTarget.item.telegramChatType || '').toLowerCase() === 'channel' + ? '这会先退出 Telegram 频道,再从当前账号和灵犀跨境中移除会话;不会全局删除频道帖子,也不会删除客户档案。' + : '这会清除当前 Telegram 账号中的聊天历史并从会话列表移除,但不会删除客户档案,也不会撤回客户已收到的消息。' + : conversationActionTarget?.action === 'leave' + ? '退出后将不能继续收发该群组或频道消息,重新加入可能需要新的邀请链接或管理员审批。' + : '这会清除当前 Telegram 账号和灵犀跨境中的聊天记录,但不会撤回客户已收到的消息。'} +
+
+ + ) + + const handleAdopt = (content: string, translationCn: string) => { setDraftText(content) + setDraftTranslationCn(translationCn) setShowAiReplies(false) if (convId) { void import('@/services/api/ai').then(({ recordAiAdoption }) => recordAiAdoption(convId, content)) @@ -400,25 +764,64 @@ export function InboxPage() { toast.success('回复已采用,请在输入区编辑后发送') } - const handleEdit = (content: string) => { + const handleEdit = (content: string, translationCn: string) => { setDraftText(content) + setDraftTranslationCn(translationCn) setShowAiReplies(false) toast.info('回复已加载到输入区,请按需编辑', content.slice(0, 50)) } - const VIEW_LABELS: TabItem[] = [ - { value: 'original', label: '原文' }, - { value: 'translated', label: '译文' }, - { value: 'bilingual', label: '双语' }, - ] + const handleViewModeChange = (nextMode: ViewMode) => { + setViewMode(nextMode) + window.localStorage.setItem('lingxi-message-view-mode', nextMode) + } const listItems = convData?.list ?? [] + const renderMessageTimeline = () => { + let previousDay = '' + const today = new Date().toDateString() + const yesterday = new Date(Date.now() - 86_400_000).toDateString() + return (messages ?? []).map((msg) => { + const messageDate = new Date(msg.createdAt) + const dayKey = messageDate.toDateString() + const showDivider = dayKey !== previousDay + previousDay = dayKey + const dayLabel = dayKey === today + ? '今天' + : dayKey === yesterday + ? '昨天' + : new Intl.DateTimeFormat('zh-CN', { month: 'long', day: 'numeric', weekday: 'short' }).format(messageDate) + return ( +
+ {showDivider && ( +
+ + {dayLabel} + +
+ )} + handleAction.quote(msg)} + onMarkImportant={() => handleAction.markImportant(msg)} + onConvertToTask={() => handleAction.task(msg)} + onAddToKnowledge={() => handleAction.knowledge(msg)} + onAddNote={() => handleAction.addNote(msg)} + customerName={convDetail?.customer.name} + customerAvatar={convDetail?.customer.avatarUrl} + /> +
+ ) + }) + } // 移动端:当前面板 if (isMobile) { return ( -
+
{noteDialog} + {telegramDialogs} {incomingNotice && ( setAddTelegramOpen(true)} + onClear={(item) => setConversationActionTarget({ item, action: 'clear' })} + onDelete={(item) => setConversationActionTarget({ item, action: 'delete' })} /> )} {mobilePanel === 'chat' && ( -
+
{ + const item = listItems.find((entry) => entry.id === convId) + if (!item) return + if (['clear', 'delete', 'leave'].includes(action)) { + setConversationActionTarget({ item, action: action as 'clear' | 'delete' | 'leave' }) + } else { + void executeTelegramAction(action, item) + } + }} onBack={() => setMobilePanel('list')} onInsight={() => setMobilePanel('insight')} showInsightBtn @@ -460,23 +879,17 @@ export function InboxPage() { onOpenProfile={() => setMobilePanel('insight')} /> )} -
- {msgsLoading ? : <> - {(messages ?? []).map((msg) => ( - handleAction.quote(msg)} - onMarkImportant={() => handleAction.markImportant(msg)} - onConvertToTask={() => handleAction.task(msg)} - onAddToKnowledge={() => handleAction.knowledge(msg)} - onAddNote={() => handleAction.addNote(msg)} - customerName={convDetail?.customer.name} - customerAvatar={convDetail?.customer.avatarUrl} - /> - ))} - } +
+
0 && 'flex flex-col justify-end', + )} + > + {msgsLoading ? : renderMessageTimeline()} +
{showAiReplies && convId ? ( { + setDraftText(text) + setDraftTranslationCn('') + }} disabled={!convId} replyingTo={replyingTo} onCancelReply={() => setReplyingTo(null)} + channel={convDetail?.conversation.channel} />
)} @@ -520,8 +937,9 @@ export function InboxPage() { // 桌面端三栏布局 return ( -
+
{noteDialog} + {telegramDialogs} {incomingNotice && ( )} {/* 左栏:会话列表 360px */} -
+
setAddTelegramOpen(true)} + onClear={(item) => setConversationActionTarget({ item, action: 'clear' })} + onDelete={(item) => setConversationActionTarget({ item, action: 'delete' })} />
{/* 中栏:聊天区 flex-1 */} -
+
{convId ? ( <> { + const item = listItems.find((entry) => entry.id === convId) + if (!item) return + if (['clear', 'delete', 'leave'].includes(action)) { + setConversationActionTarget({ item, action: action as 'clear' | 'delete' | 'leave' }) + } else { + void executeTelegramAction(action, item) + } + }} onInsight={() => setShowInsight((v) => !v)} showInsightBtn /> @@ -566,27 +1006,21 @@ export function InboxPage() { onOpenProfile={() => setShowInsight(true)} /> )} -
- {msgsLoading ? ( - - ) : (messages?.length ?? 0) === 0 ? ( - - ) : ( - (messages ?? []).map((msg) => ( - handleAction.quote(msg)} - onMarkImportant={() => handleAction.markImportant(msg)} - onConvertToTask={() => handleAction.task(msg)} - onAddToKnowledge={() => handleAction.knowledge(msg)} - onAddNote={() => handleAction.addNote(msg)} - customerName={convDetail?.customer.name} - customerAvatar={convDetail?.customer.avatarUrl} - /> - )) - )} +
+
0 && 'flex flex-col justify-end', + )} + > + {msgsLoading ? ( + + ) : (messages?.length ?? 0) === 0 ? ( + + ) : renderMessageTimeline()} +
{showAiReplies ? ( { + setDraftText(text) + setDraftTranslationCn('') + }} disabled={!convId} replyingTo={replyingTo} onCancelReply={() => setReplyingTo(null)} + channel={convDetail?.conversation.channel} /> ) : ( @@ -623,7 +1061,7 @@ export function InboxPage() { {/* 右栏:客户洞察 340px */} {showInsight && convDetail && ( -
+
)} @@ -638,6 +1076,9 @@ function ChatHeader({ loading, viewMode, onViewModeChange, + wallpaper, + onWallpaperChange, + onTelegramAction, onBack, onInsight, showInsightBtn, @@ -647,6 +1088,9 @@ function ChatHeader({ loading: boolean viewMode: ViewMode onViewModeChange: (v: ViewMode) => void + wallpaper: ChatWallpaper + onWallpaperChange: (wallpaper: ChatWallpaper) => void + onTelegramAction: (action: TelegramConversationAction) => void onBack?: () => void onInsight?: () => void showInsightBtn?: boolean @@ -654,8 +1098,9 @@ function ChatHeader({ if (!convId) return null const c = detail?.customer const conv = detail?.conversation + const isTelegramChannel = String(conv?.telegramChatType || c?.telegramChatType || '').toLowerCase() === 'channel' return ( -
+
{onBack && ( + )} + items={CHAT_WALLPAPERS.map((item) => ({ + key: item.value, + label: ( + + + {item.label} + {wallpaper === item.value && } + + ), + onSelect: () => onWallpaperChange(item.value), + }))} + /> + {conv?.channel === 'telegram' && ( + + + + )} + items={[ + { + key: conv.isPinned ? 'unpin' : 'pin', + label: conv.isPinned ? '取消置顶' : '置顶会话', + icon: Pin, + onSelect: () => onTelegramAction(conv.isPinned ? 'unpin' : 'pin'), + }, + { + key: conv.archived ? 'unarchive' : 'archive', + label: conv.archived ? '移出归档' : '归档会话', + icon: Archive, + onSelect: () => onTelegramAction(conv.archived ? 'unarchive' : 'archive'), + }, + { + key: conv.mutedUntil ? 'unmute' : 'mute', + label: conv.mutedUntil ? '恢复通知' : '静音 8 小时', + icon: BellOff, + onSelect: () => onTelegramAction(conv.mutedUntil ? 'unmute' : 'mute'), + }, + { + key: 'mark-unread', + label: '标记为未读', + icon: MessageCircle, + onSelect: () => onTelegramAction('mark-unread'), + }, + { + key: conv.isBlocked ? 'unblock' : 'block', + label: conv.isBlocked ? '解除拉黑' : '拉黑用户', + icon: Ban, + disabled: !c?.telegramUserId, + onSelect: () => onTelegramAction(conv.isBlocked ? 'unblock' : 'block'), + }, + 'separator', + ...(!isTelegramChannel + ? [{ + key: 'clear', + label: '清空消息', + icon: Eraser, + onSelect: () => onTelegramAction('clear' as TelegramConversationAction), + }] + : []), + ...(String(conv.telegramChatType || c?.telegramChatType || '').toLowerCase() !== 'private' + ? [{ + key: 'leave', + label: '退出群组/频道', + icon: LogOut, + danger: true, + onSelect: () => onTelegramAction('leave' as TelegramConversationAction), + }] + : []), + { + key: 'delete', + label: '删除会话', + icon: Trash2, + danger: true, + onSelect: () => onTelegramAction('delete'), + }, + ]} + /> + )} {showInsightBtn && onInsight && ( + + } /> @@ -77,7 +80,10 @@ export function RisksPage() { {list.map((r) => { const meta = riskLevelMeta(r.level) return ( - setDetailId(r.id)}> + { + setDetailId(r.id) + setNoteDraft(r.note || '') + }}>
@@ -119,8 +125,15 @@ export function RisksPage() {

添加备注

-