Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a7c894138e | |||
| 45b94374b0 | |||
| d585fabb7a | |||
| 48d719e520 | |||
| 9017c26f5b | |||
| 82f00ffb43 | |||
| 0109e58160 | |||
| bffd2f4fe6 | |||
| 7e739ad7b8 | |||
| bb696c7de8 | |||
| ad126606f9 | |||
| 00b0ebb775 | |||
| 785a5b196c | |||
| cf25d71fcf |
@@ -2,6 +2,9 @@
|
|||||||
LX_DB_PASSWORD=replace-with-strong-database-password
|
LX_DB_PASSWORD=replace-with-strong-database-password
|
||||||
LX_JWT_SECRET=replace-with-at-least-32-random-characters
|
LX_JWT_SECRET=replace-with-at-least-32-random-characters
|
||||||
LX_WEB_PORT=8088
|
LX_WEB_PORT=8088
|
||||||
|
DEBIAN_MIRROR=https://deb.debian.org/debian
|
||||||
|
DEBIAN_SECURITY_MIRROR=https://deb.debian.org/debian-security
|
||||||
|
FFMPEG_BINARIES_URL=https://github.com/eugeneware/ffmpeg-static/releases/download
|
||||||
|
|
||||||
# DeepSeek Anthropic-compatible API
|
# DeepSeek Anthropic-compatible API
|
||||||
DEEPSEEK_API_KEY=replace-with-rotated-deepseek-key
|
DEEPSEEK_API_KEY=replace-with-rotated-deepseek-key
|
||||||
@@ -20,6 +23,8 @@ TELEGRAM_WEBHOOK_SECRET=replace-with-random-webhook-secret
|
|||||||
WHATSAPP_GATEWAY_TOKEN=replace-with-at-least-32-random-characters
|
WHATSAPP_GATEWAY_TOKEN=replace-with-at-least-32-random-characters
|
||||||
WHATSAPP_WEBHOOK_SECRET=replace-with-another-32-random-secret
|
WHATSAPP_WEBHOOK_SECRET=replace-with-another-32-random-secret
|
||||||
WHATSAPP_LOG_LEVEL=info
|
WHATSAPP_LOG_LEVEL=info
|
||||||
|
# 可选。只影响 WhatsApp 网关出站,不设置系统或平台全局代理。
|
||||||
|
WHATSAPP_DEFAULT_PROXY_URL=
|
||||||
|
|
||||||
PUBLIC_WEBHOOK_BASE_URL=http://host.docker.internal:8088
|
PUBLIC_WEBHOOK_BASE_URL=http://host.docker.internal:8088
|
||||||
APP_PUBLIC_URL=http://127.0.0.1:8088
|
APP_PUBLIC_URL=http://127.0.0.1:8088
|
||||||
@@ -36,6 +41,9 @@ SMTP_FROM=
|
|||||||
# 建议使用 32 字节以上独立随机值;未配置时回退到 LX_JWT_SECRET 派生密钥。
|
# 建议使用 32 字节以上独立随机值;未配置时回退到 LX_JWT_SECRET 派生密钥。
|
||||||
EMAIL_CREDENTIAL_KEY=
|
EMAIL_CREDENTIAL_KEY=
|
||||||
EMAIL_POLL_MS=30000
|
EMAIL_POLL_MS=30000
|
||||||
|
MESSAGE_RECONCILE_MS=60000
|
||||||
|
WHATSAPP_GATEWAY_TIMEOUT_MS=30000
|
||||||
|
TELEGRAM_GATEWAY_TIMEOUT_MS=30000
|
||||||
|
|
||||||
# 真实 Stripe Checkout
|
# 真实 Stripe Checkout
|
||||||
STRIPE_SECRET_KEY=
|
STRIPE_SECRET_KEY=
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ storage/
|
|||||||
*.sqlite3
|
*.sqlite3
|
||||||
*.dump
|
*.dump
|
||||||
*.sql
|
*.sql
|
||||||
|
!telegram-gateway/docker/scripts/init-db.sql
|
||||||
*.rdb
|
*.rdb
|
||||||
*.binlog
|
*.binlog
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,28 @@
|
|||||||
|
|
||||||
本项目遵循语义化版本。早期版本由经过脱敏的本地源码快照恢复,因此使用带日期的 `snapshot-*` 标签;自 `v1.0.0` 起按正式发布流程维护。
|
本项目遵循语义化版本。早期版本由经过脱敏的本地源码快照恢复,因此使用带日期的 `snapshot-*` 标签;自 `v1.0.0` 起按正式发布流程维护。
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
- 完成双消息网关 P0 可靠性闭环:Webhook 采用 processing/completed/failed 可恢复状态机,Telegram 编辑与撤回落库,60 秒增量游标对账、5 分钟死信自动重放、每日资料刷新和每周受控全量对账。
|
||||||
|
- Telegram 会话发现改为 TDLib 持续加载并持久化全部会话,平台按 offset 分页读取;网关请求增加超时与任务并发合并,避免单个资料接口阻塞全部同步。
|
||||||
|
- 修复 WhatsApp 账号重新授权后因网关账号 ID 与联系人名称变化导致历史会话分叉的问题;客户按稳定 JID 复用,旧渠道消息、任务、风险和商机自动归并,并保留最近有效的联系人资料。
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- Telegram Gateway 支持环境级默认 SOCKS5/HTTP/MTProto 代理,账号专属绑定仍具有更高优先级。
|
||||||
|
- WhatsApp Gateway 支持环境级默认 HTTP/HTTPS 代理,账号专属代理仍可覆盖默认值。
|
||||||
|
- 默认代理仅作用于两个消息网关,不改变平台、邮箱、数据库或 Docker 的全局网络出口。
|
||||||
|
- Docker 构建支持可配置的 Debian 与 FFmpeg 下载镜像,适配受限网络环境。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- 将 Telegram Gateway 首次启动所需的 PostgreSQL 初始化脚本纳入版本控制,避免空数据库因挂载目录而启动失败。
|
||||||
|
- Telegram Gateway 本机调试端口改为 `127.0.0.1:3010`,避免与 Gitea 的 `3000` 端口冲突并阻止公网直连管理接口。
|
||||||
|
- 修复 WhatsApp `FULL` 历史通知被 Baileys 默认过滤的问题;首次连接和历史阶段完成后自动执行逐会话分页补齐。
|
||||||
|
- WhatsApp 历史同步增加并发合并、进度持久化、失败状态和平台数据库游标分页,避免重复同步及单次 2000 条截断。
|
||||||
|
- 修复 WhatsApp 先于平台 API 连通时首次同步被静默丢弃的问题,增加延迟启动、指数重试和重复 Webhook 恢复。
|
||||||
|
- WhatsApp 媒体与历史文件下载接入 Undici 代理 Dispatcher,并可恢复处理旧版本曾忽略但仍保存在缓存中的历史通知。
|
||||||
|
|
||||||
## [1.1.0] - 2026-08-01
|
## [1.1.0] - 2026-08-01
|
||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
FROM node:20-alpine AS web-build
|
FROM node:20-alpine AS web-build
|
||||||
|
ARG FFMPEG_BINARIES_URL=https://github.com/eugeneware/ffmpeg-static/releases/download
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|||||||
@@ -19,10 +19,14 @@ services:
|
|||||||
whatsapp-gateway:
|
whatsapp-gateway:
|
||||||
build:
|
build:
|
||||||
context: ./whatsapp-gateway
|
context: ./whatsapp-gateway
|
||||||
|
args:
|
||||||
|
DEBIAN_MIRROR: ${DEBIAN_MIRROR:-https://deb.debian.org/debian}
|
||||||
|
DEBIAN_SECURITY_MIRROR: ${DEBIAN_SECURITY_MIRROR:-https://deb.debian.org/debian-security}
|
||||||
environment:
|
environment:
|
||||||
PORT: 3200
|
PORT: 3200
|
||||||
DATA_DIR: /data
|
DATA_DIR: /data
|
||||||
WHATSAPP_GATEWAY_TOKEN: ${WHATSAPP_GATEWAY_TOKEN:?WHATSAPP_GATEWAY_TOKEN is required}
|
WHATSAPP_GATEWAY_TOKEN: ${WHATSAPP_GATEWAY_TOKEN:?WHATSAPP_GATEWAY_TOKEN is required}
|
||||||
|
WHATSAPP_DEFAULT_PROXY_URL: ${WHATSAPP_DEFAULT_PROXY_URL:-}
|
||||||
LOG_LEVEL: ${WHATSAPP_LOG_LEVEL:-info}
|
LOG_LEVEL: ${WHATSAPP_LOG_LEVEL:-info}
|
||||||
volumes:
|
volumes:
|
||||||
- lingxi_whatsapp_data:/data
|
- lingxi_whatsapp_data:/data
|
||||||
@@ -89,6 +93,8 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
FFMPEG_BINARIES_URL: ${FFMPEG_BINARIES_URL:-https://github.com/eugeneware/ffmpeg-static/releases/download}
|
||||||
ports:
|
ports:
|
||||||
- "${LX_WEB_PORT:-8088}:80"
|
- "${LX_WEB_PORT:-8088}:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -37,6 +37,24 @@ docker compose logs --tail=100 api whatsapp-gateway web
|
|||||||
|
|
||||||
平台的 `TELEGRAM_GATEWAY_URL` 指向网关 `/api` 地址。生产环境应通过私有网络连接,不把 Gateway 管理接口直接暴露公网。详细配置见 [Telegram Gateway 接入指南](TELEGRAM-GATEWAY.md)。
|
平台的 `TELEGRAM_GATEWAY_URL` 指向网关 `/api` 地址。生产环境应通过私有网络连接,不把 Gateway 管理接口直接暴露公网。详细配置见 [Telegram Gateway 接入指南](TELEGRAM-GATEWAY.md)。
|
||||||
|
|
||||||
|
### 2.1 仅消息网关使用出站代理
|
||||||
|
|
||||||
|
生产环境可以创建一个不映射宿主机端口的专用 Docker 网络和 V2Ray/Xray 容器,并只将 Telegram、WhatsApp 网关接入该网络。不要设置 Docker Daemon、宿主机或平台 API 的全局 `HTTP_PROXY`/`HTTPS_PROXY`。
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Telegram Gateway
|
||||||
|
TG_DEFAULT_PROXY_TYPE=socks5
|
||||||
|
TG_DEFAULT_PROXY_HOST=lingxi-egress
|
||||||
|
TG_DEFAULT_PROXY_PORT=10808
|
||||||
|
|
||||||
|
# 平台 WhatsApp Gateway
|
||||||
|
WHATSAPP_DEFAULT_PROXY_URL=http://lingxi-egress:10809
|
||||||
|
```
|
||||||
|
|
||||||
|
账号级代理优先于上述默认代理。代理容器应使用 `internal/external Docker network + no host ports` 的方式隔离;配置文件和认证信息仅保存在服务器受限目录,不进入 Git。上线前至少验证 Telegram API 与 WhatsApp Web 的 HTTPS 连接,并确认 API、数据库、邮箱和 AI 服务未配置全局代理变量。
|
||||||
|
|
||||||
|
若部署网络无法访问 Debian 官方源或 FFmpeg GitHub Release,可通过 `.env` 的 `DEBIAN_MIRROR`、`DEBIAN_SECURITY_MIRROR` 和 `FFMPEG_BINARIES_URL` 指定构建镜像;这些变量只影响镜像构建,不改变运行时出口。
|
||||||
|
|
||||||
## 3. 生产入口
|
## 3. 生产入口
|
||||||
|
|
||||||
在 Compose 外层部署 Caddy、Nginx 或云负载均衡:
|
在 Compose 外层部署 Caddy、Nginx 或云负载均衡:
|
||||||
@@ -49,6 +67,8 @@ docker compose logs --tail=100 api whatsapp-gateway web
|
|||||||
|
|
||||||
将 `APP_PUBLIC_URL` 和 `PUBLIC_WEBHOOK_BASE_URL` 改为正式 HTTPS 地址,随后重建 API。
|
将 `APP_PUBLIC_URL` 和 `PUBLIC_WEBHOOK_BASE_URL` 改为正式 HTTPS 地址,随后重建 API。
|
||||||
|
|
||||||
|
当前生产拓扑使用 Caddy 与 NPS 时,应在 NPS HTTP Host 中将域名指向平台内网 `8088`,Caddy 只反向代理到 NPS 的本机 HTTP 入口。修改前分别备份 NPS Host 配置和 Caddyfile,验证配置后再重启 NPS、平滑重载 Caddy。
|
||||||
|
|
||||||
## 4. 更新
|
## 4. 更新
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ Copy-Item telegram-gateway/.env.example telegram-gateway/.env
|
|||||||
- `JWT_SECRET`、`ADMIN_API_KEY`:分别生成强随机值,不得复用。
|
- `JWT_SECRET`、`ADMIN_API_KEY`:分别生成强随机值,不得复用。
|
||||||
- `DB_PASSWORD`:生产数据库强密码。
|
- `DB_PASSWORD`:生产数据库强密码。
|
||||||
- `CORS_ORIGINS`:仅填写受信任的管理入口;平台服务端调用不依赖浏览器跨域。
|
- `CORS_ORIGINS`:仅填写受信任的管理入口;平台服务端调用不依赖浏览器跨域。
|
||||||
|
- `TG_DEFAULT_PROXY_*`:可选的默认 TDLib 代理;账号绑定的专属代理优先。
|
||||||
|
|
||||||
生产环境不要使用示例中的 `postgres` 密码。`.env` 不得提交 Git。
|
生产环境不要使用示例中的 `postgres` 密码。`.env` 不得提交 Git。
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/index.js",
|
"start": "node src/index.js",
|
||||||
"test": "node --test test/business.test.js",
|
"test": "node --test test/business.test.js test/reliability.test.js test/whatsapp-identity.test.js",
|
||||||
"test:email-protocol": "node --test test/email-protocol.test.js",
|
"test:email-protocol": "node --test test/email-protocol.test.js",
|
||||||
"test:email-api": "node --test test/email-api-e2e.test.js"
|
"test:email-api": "node --test test/email-api-e2e.test.js"
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -71,7 +71,7 @@ export async function deepseek(system, user, jsonOrOptions = false) {
|
|||||||
|
|
||||||
export function conversationContext(messages, limit = 30) {
|
export function conversationContext(messages, limit = 30) {
|
||||||
return messages.slice(-limit).map((m) => {
|
return messages.slice(-limit).map((m) => {
|
||||||
const role = m.sender === 'agent' ? '坐席' : m.sender === 'system' ? '系统' : '客户'
|
const role = m.sender === 'agent' ? '我方' : m.sender === 'system' ? '系统' : '对方'
|
||||||
const content = m.originalText || m.attachmentName || `[${m.type || '消息'}]`
|
const content = m.originalText || m.attachmentName || `[${m.type || '消息'}]`
|
||||||
const translation = m.translatedText && m.translatedText !== content ? `(译文:${m.translatedText})` : ''
|
const translation = m.translatedText && m.translatedText !== content ? `(译文:${m.translatedText})` : ''
|
||||||
return `${m.createdAt || ''} ${role}: ${content}${translation}`
|
return `${m.createdAt || ''} ${role}: ${content}${translation}`
|
||||||
|
|||||||
@@ -74,3 +74,22 @@ export const countBy = (list, key) => {
|
|||||||
}
|
}
|
||||||
return counts
|
return counts
|
||||||
}
|
}
|
||||||
|
export const businessRelationshipLabels = {
|
||||||
|
buyer: '下游买家',
|
||||||
|
supplier: '上游供应商',
|
||||||
|
partner: '合作伙伴',
|
||||||
|
distributor: '经销商',
|
||||||
|
procurement_agent: '采购代理',
|
||||||
|
logistics: '物流服务商',
|
||||||
|
service_provider: '专业服务商',
|
||||||
|
internal: '内部同事',
|
||||||
|
other: '其他',
|
||||||
|
unknown: '待确认',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeBusinessRelationship(value) {
|
||||||
|
const type = Object.hasOwn(businessRelationshipLabels, String(value || ''))
|
||||||
|
? String(value)
|
||||||
|
: 'unknown'
|
||||||
|
return { type, label: businessRelationshipLabels[type] }
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ export async function gatewayRequest(path, options = {}) {
|
|||||||
if (!gatewayToken || tokenExpiresSoon(gatewayToken)) await refreshGatewayToken()
|
if (!gatewayToken || tokenExpiresSoon(gatewayToken)) await refreshGatewayToken()
|
||||||
const send = () => fetch(`${base()}${path}`, {
|
const send = () => fetch(`${base()}${path}`, {
|
||||||
...options,
|
...options,
|
||||||
|
signal: options.signal || AbortSignal.timeout(
|
||||||
|
Math.max(5_000, Number(process.env.TELEGRAM_GATEWAY_TIMEOUT_MS || 30_000)),
|
||||||
|
),
|
||||||
headers: {
|
headers: {
|
||||||
...(options.body instanceof FormData ? {} : { 'content-type': 'application/json' }),
|
...(options.body instanceof FormData ? {} : { 'content-type': 'application/json' }),
|
||||||
authorization: `Bearer ${gatewayToken}`,
|
authorization: `Bearer ${gatewayToken}`,
|
||||||
|
|||||||
+975
-90
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
|||||||
|
export function webhookEventClaimDecision(existing, now = Date.now(), leaseMs = 2 * 60_000) {
|
||||||
|
if (existing?.status === 'completed') return 'duplicate'
|
||||||
|
const processingAt = Date.parse(existing?.processingAt || '')
|
||||||
|
if (
|
||||||
|
existing?.status === 'processing'
|
||||||
|
&& Number.isFinite(processingAt)
|
||||||
|
&& processingAt > now - leaseMs
|
||||||
|
) return 'busy'
|
||||||
|
return 'claim'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTimestampOlderThan(value, ageMs, now = Date.now()) {
|
||||||
|
const parsed = Date.parse(value || '')
|
||||||
|
return !Number.isFinite(parsed) || parsed < now - ageMs
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
const TONE_INSTRUCTIONS = {
|
||||||
|
professional: '专业但不官腔,表达清楚、克制,像熟悉业务的真人销售在聊天',
|
||||||
|
friendly: '友好自然,适度口语化,像长期合作伙伴沟通,不刻意热情',
|
||||||
|
concise: '简洁直接,优先给结论和下一步,不重复客户已经知道的信息',
|
||||||
|
warm: '温和、有同理心,但不使用夸张道歉、空泛安慰或营销套话',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replyToneInstruction(value) {
|
||||||
|
return TONE_INSTRUCTIONS[String(value || '').toLowerCase()] || TONE_INSTRUCTIONS.professional
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HUMAN_REPLY_SYSTEM_PROMPT = `你是“灵犀跨境”的商务沟通助手。你的任务不是写客服范文,而是替一位真实、可靠、熟悉业务的销售人员起草可以直接发送的聊天回复。
|
||||||
|
|
||||||
|
写作原则:
|
||||||
|
1. 先回应客户最新一条消息里尚未解决的核心问题,再给必要的下一步;不要复述整段历史。
|
||||||
|
2. 匹配客户的语言、地区表达、正式程度和消息长度。客户只说一句时,通常也用一两句回答;不为显得专业而写长文。
|
||||||
|
3. 延续当前对话。会话已经开始后不要重新问候、重复称呼客户或重新自我介绍。
|
||||||
|
4. 像真人聊天:句式自然、具体,允许使用目标语言中常见的缩写和口语连接词,但保持商务礼貌。不要比客户更随意;客户未使用俚语、感叹号或表情时,不要主动使用。
|
||||||
|
5. 客诉场景只需针对具体问题真诚确认一次,然后说明能做的动作或提出一个关键问题;避免连续道歉和空泛安慰。
|
||||||
|
6. 信息足够时直接作答;确实缺少决定性信息时最多问一个有用的问题。不要在一句回复里堆多个反问。
|
||||||
|
7. 三条建议必须是三种真实策略,而不是同一句话替换近义词:stable=清晰可靠,active=温暖主动,brief=简洁直接。
|
||||||
|
8. 禁止客服和 AI 套话,包括但不限于:Dear customer、尊敬的客户、please rest assured、请放心、we value your feedback、感谢您的耐心与理解、为您带来不便深表歉意、How may I assist you、如有任何问题请随时联系我们。除非上下文确实需要,也不要使用“希望您一切都好”。
|
||||||
|
9. 不得提及“AI、提示词、客户画像、会话记忆、知识库、情绪识别”等内部信息。
|
||||||
|
10. 不得编造价格、库存、产品参数、交期、物流状态、退款结果、政策或任何承诺。缺少事实时自然地说明需要确认,不要假装确定。“今天、稍后、尽快、本周内、几个小时内”等时间词也属于承诺,只有企业知识或会话事实明确给出时才能使用。
|
||||||
|
11. 可以承诺“去确认”,但不能在没有依据时承诺确认结果、完成时间、补偿方案、重新寄样或其他尚未批准的动作。正确写法是“我先确认是否可以调整,确认后告诉你”。
|
||||||
|
|
||||||
|
输出要求:只输出严格 JSON 数组。content 使用客户语言且可直接发送;translationCn 是 content 完整准确的简体中文翻译;knowledgeRefs 只能填写已提供知识的 id;reason、expectedEffect 和 cultureTip 使用简体中文。`
|
||||||
|
|
||||||
|
export function buildHumanReplyUserPrompt({
|
||||||
|
targetInstruction,
|
||||||
|
tone,
|
||||||
|
latestCustomerMessage,
|
||||||
|
latestAgentMessage,
|
||||||
|
conversation,
|
||||||
|
conversationMemory,
|
||||||
|
knowledgeContext,
|
||||||
|
}) {
|
||||||
|
return `客户目标语言:${targetInstruction}
|
||||||
|
企业设定语气:${replyToneInstruction(tone)}
|
||||||
|
客户最新消息(回复重点):${latestCustomerMessage || '未找到明确文本,请根据最近会话判断'}
|
||||||
|
坐席上一条回复(避免重复):${latestAgentMessage || '无'}
|
||||||
|
|
||||||
|
<conversation>
|
||||||
|
${conversation}
|
||||||
|
</conversation>
|
||||||
|
${conversationMemory ? `<conversation_memory>\n${conversationMemory}\n</conversation_memory>` : ''}
|
||||||
|
<enterprise_knowledge>
|
||||||
|
${JSON.stringify(knowledgeContext || [])}
|
||||||
|
</enterprise_knowledge>
|
||||||
|
事实边界:${knowledgeContext?.length ? '只能使用上述企业知识和会话中明确出现的业务事实。' : '当前没有匹配到企业知识;不得加入任何具体时间、结果、方案或承诺,只能说明需要确认的事项。'}
|
||||||
|
|
||||||
|
分别生成以下三种建议:
|
||||||
|
- stable:最稳妥、事实清楚、自然推进下一步;
|
||||||
|
- active:更温暖主动,但不催促、不夸张、不强行促单;
|
||||||
|
- brief:保留必要信息的最短自然回复。
|
||||||
|
|
||||||
|
生成前静默检查:是否直接回应了最新消息;是否重复问候或复述历史;是否像群发模板;是否包含未经证实的承诺;三条是否真正不同。不要输出检查过程。
|
||||||
|
|
||||||
|
输出恰好 3 个元素,每种 style 各一个:
|
||||||
|
{"id":"唯一值","style":"stable|active|brief","content":"可直接发送的客户语言回复","translationCn":"完整中文翻译","targetLang":"实际 ISO 语言代码","reason":"这条回复为何适合当前语境","knowledgeRefs":[],"expectedEffect":"预期沟通效果","cultureTip":"只有确有必要时填写,否则为空字符串"}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedWords(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function similarity(left, right) {
|
||||||
|
const a = new Set(normalizedWords(left))
|
||||||
|
const b = new Set(normalizedWords(right))
|
||||||
|
if (!a.size || !b.size) return 0
|
||||||
|
let overlap = 0
|
||||||
|
for (const word of a) if (b.has(word)) overlap += 1
|
||||||
|
return overlap / Math.min(a.size, b.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEMPLATE_PATTERNS = [
|
||||||
|
/\bdear customer\b/i,
|
||||||
|
/\bplease rest assured\b/i,
|
||||||
|
/\bwe value your feedback\b/i,
|
||||||
|
/\bhow may i assist you\b/i,
|
||||||
|
/尊敬的客户/,
|
||||||
|
/请您放心/,
|
||||||
|
/感谢您的耐心与理解/,
|
||||||
|
/为您带来不便深表歉意/,
|
||||||
|
]
|
||||||
|
|
||||||
|
const UNSUPPORTED_TIMELINE_PATTERNS = [
|
||||||
|
/\b(?:today|tomorrow|shortly|asap)\b/i,
|
||||||
|
/\bby (?:the )?end of\b/i,
|
||||||
|
/\bwithin \d+\s*(?:minutes?|hours?|days?|weeks?)\b/i,
|
||||||
|
/(?:今天|明天|稍后|很快|尽快|本周内|几(?:分钟|小时|天)内)/,
|
||||||
|
]
|
||||||
|
|
||||||
|
export function replySuggestionIssues(suggestions, { knowledgeProvided = false } = {}) {
|
||||||
|
if (!Array.isArray(suggestions) || suggestions.length !== 3) return ['必须返回三条建议']
|
||||||
|
const issues = []
|
||||||
|
const styles = new Set(suggestions.map((item) => String(item?.style || '')))
|
||||||
|
for (const style of ['stable', 'active', 'brief']) {
|
||||||
|
if (!styles.has(style)) issues.push(`缺少 ${style} 风格`)
|
||||||
|
}
|
||||||
|
suggestions.forEach((item, index) => {
|
||||||
|
const content = String(item?.content || '').trim()
|
||||||
|
if (!content) issues.push(`第 ${index + 1} 条没有正文`)
|
||||||
|
if (TEMPLATE_PATTERNS.some((pattern) => pattern.test(content))) {
|
||||||
|
issues.push(`第 ${index + 1} 条包含模板化客服话术`)
|
||||||
|
}
|
||||||
|
if (!knowledgeProvided && UNSUPPORTED_TIMELINE_PATTERNS.some((pattern) => pattern.test(content))) {
|
||||||
|
issues.push(`第 ${index + 1} 条包含无事实依据的时间承诺`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
for (let i = 0; i < suggestions.length; i += 1) {
|
||||||
|
for (let j = i + 1; j < suggestions.length; j += 1) {
|
||||||
|
if (similarity(suggestions[i]?.content, suggestions[j]?.content) >= 0.82) {
|
||||||
|
issues.push(`第 ${i + 1} 条和第 ${j + 1} 条过于相似`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return issues
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import crypto from 'node:crypto'
|
||||||
|
|
||||||
|
export function canonicalWhatsAppJid(jid, identity = {}) {
|
||||||
|
const normalizeDevice = (value) => String(value || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/:\d+(?=@s\.whatsapp\.net$)/, '')
|
||||||
|
const raw = normalizeDevice(jid)
|
||||||
|
if (!raw || raw.endsWith('@g.us') || raw.endsWith('@newsletter')) return raw
|
||||||
|
const canonical = normalizeDevice(identity?.canonicalJid)
|
||||||
|
if (canonical.endsWith('@s.whatsapp.net')) return canonical
|
||||||
|
const phone = String(identity?.phone || '').split(':')[0].replace(/\D/g, '')
|
||||||
|
return phone ? `${phone}@s.whatsapp.net` : raw
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvedWhatsAppJid(jid, identity = {}, cachedJid = '') {
|
||||||
|
const fallback = canonicalWhatsAppJid(jid)
|
||||||
|
const resolved = canonicalWhatsAppJid(jid, identity)
|
||||||
|
if (resolved && resolved !== fallback) return resolved
|
||||||
|
|
||||||
|
const cached = canonicalWhatsAppJid(cachedJid)
|
||||||
|
return cached && cached !== fallback ? cached : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export function whatsappCustomerStableId(jid) {
|
||||||
|
const normalized = canonicalWhatsAppJid(jid)
|
||||||
|
const hash = crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 24)
|
||||||
|
return `wa-customer-${hash}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function whatsappConversationActivityAt(conversation) {
|
||||||
|
return Date.parse(
|
||||||
|
conversation?.lastMessage?.createdAt
|
||||||
|
|| conversation?.updatedAt
|
||||||
|
|| conversation?.createdAt
|
||||||
|
|| '',
|
||||||
|
) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWeakWhatsAppDisplayName(value, jid) {
|
||||||
|
const name = String(value || '').trim()
|
||||||
|
const jidName = String(jid || '').split('@')[0].replace(/\D/g, '')
|
||||||
|
const digits = name.replace(/\D/g, '')
|
||||||
|
return !name || (digits.length >= 7 && (!jidName || digits === jidName))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reusableWhatsAppConversations(conversations, activeChannelIds, channelId, jid) {
|
||||||
|
return conversations
|
||||||
|
.filter((item) => (
|
||||||
|
item.channel === 'whatsapp'
|
||||||
|
&& String(item.whatsappJid || '') === String(jid)
|
||||||
|
&& (
|
||||||
|
String(item.channelId || '') === String(channelId)
|
||||||
|
|| !activeChannelIds.has(String(item.channelId || String(item.id).split(':')[0]))
|
||||||
|
)
|
||||||
|
))
|
||||||
|
.sort((left, right) => whatsappConversationActivityAt(right) - whatsappConversationActivityAt(left))
|
||||||
|
}
|
||||||
@@ -7,6 +7,9 @@ export async function whatsappRequest(path, options = {}) {
|
|||||||
}
|
}
|
||||||
const response = await fetch(`${base()}${path}`, {
|
const response = await fetch(`${base()}${path}`, {
|
||||||
...options,
|
...options,
|
||||||
|
signal: options.signal || AbortSignal.timeout(
|
||||||
|
Math.max(5_000, Number(process.env.WHATSAPP_GATEWAY_TIMEOUT_MS || 30_000)),
|
||||||
|
),
|
||||||
headers: {
|
headers: {
|
||||||
...(options.body instanceof FormData ? {} : { 'content-type': 'application/json' }),
|
...(options.body instanceof FormData ? {} : { 'content-type': 'application/json' }),
|
||||||
authorization: `Bearer ${token}`,
|
authorization: `Bearer ${token}`,
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { countBy, dateKey, percent, telegramAttachment } from '../src/business.js'
|
import {
|
||||||
|
countBy,
|
||||||
|
dateKey,
|
||||||
|
normalizeBusinessRelationship,
|
||||||
|
percent,
|
||||||
|
telegramAttachment,
|
||||||
|
} from '../src/business.js'
|
||||||
import { decryptEmailPassword, encryptEmailPassword } from '../src/email.js'
|
import { decryptEmailPassword, encryptEmailPassword } from '../src/email.js'
|
||||||
|
import {
|
||||||
|
HUMAN_REPLY_SYSTEM_PROMPT,
|
||||||
|
buildHumanReplyUserPrompt,
|
||||||
|
replySuggestionIssues,
|
||||||
|
replyToneInstruction,
|
||||||
|
} from '../src/reply-suggestions.js'
|
||||||
|
|
||||||
test('Telegram 图片、文档、语音和贴纸映射为可下载文件', () => {
|
test('Telegram 图片、文档、语音和贴纸映射为可下载文件', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
@@ -44,6 +56,11 @@ test('业务指标工具处理百分比、分组和无效日期', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('业务关系分类使用受控类型和中文标签', () => {
|
||||||
|
assert.deepEqual(normalizeBusinessRelationship('supplier'), { type: 'supplier', label: '上游供应商' })
|
||||||
|
assert.deepEqual(normalizeBusinessRelationship('unsupported'), { type: 'unknown', label: '待确认' })
|
||||||
|
})
|
||||||
|
|
||||||
test('Email 渠道凭据使用认证加密且可正确解密', () => {
|
test('Email 渠道凭据使用认证加密且可正确解密', () => {
|
||||||
process.env.EMAIL_CREDENTIAL_KEY = 'lingxi-email-test-key-with-more-than-32-bytes'
|
process.env.EMAIL_CREDENTIAL_KEY = 'lingxi-email-test-key-with-more-than-32-bytes'
|
||||||
const encrypted = encryptEmailPassword('app-password-123')
|
const encrypted = encryptEmailPassword('app-password-123')
|
||||||
@@ -54,3 +71,41 @@ test('Email 渠道凭据使用认证加密且可正确解密', () => {
|
|||||||
tampered[2] = `${tampered[2][0] === 'A' ? 'B' : 'A'}${tampered[2].slice(1)}`
|
tampered[2] = `${tampered[2][0] === 'A' ? 'B' : 'A'}${tampered[2].slice(1)}`
|
||||||
assert.throws(() => decryptEmailPassword(tampered.join(':')))
|
assert.throws(() => decryptEmailPassword(tampered.join(':')))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('回复建议提示词突出最新消息、避免重复并遵循企业语气', () => {
|
||||||
|
const prompt = buildHumanReplyUserPrompt({
|
||||||
|
targetInstruction: '使用西班牙语',
|
||||||
|
tone: 'friendly',
|
||||||
|
latestCustomerMessage: '¿Cuándo pueden enviarlo?',
|
||||||
|
latestAgentMessage: 'I will check it.',
|
||||||
|
conversation: '客户: ¿Cuándo pueden enviarlo?',
|
||||||
|
conversationMemory: '客户关注交期',
|
||||||
|
knowledgeContext: [],
|
||||||
|
})
|
||||||
|
assert.match(HUMAN_REPLY_SYSTEM_PROMPT, /不是写客服范文/)
|
||||||
|
assert.match(HUMAN_REPLY_SYSTEM_PROMPT, /不要重新问候/)
|
||||||
|
assert.match(prompt, /¿Cuándo pueden enviarlo\?/)
|
||||||
|
assert.match(prompt, /坐席上一条回复(避免重复)/)
|
||||||
|
assert.match(prompt, /长期合作伙伴/)
|
||||||
|
assert.match(replyToneInstruction('concise'), /简洁直接/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('回复建议质量检查拦截模板话术和同质草稿', () => {
|
||||||
|
const valid = [
|
||||||
|
{ style: 'stable', content: 'I can confirm the dispatch date with our warehouse and update you once I have it.' },
|
||||||
|
{ style: 'active', content: "Let me check the warehouse schedule and come back to you with the exact date." },
|
||||||
|
{ style: 'brief', content: "I'll verify the dispatch date and get back to you." },
|
||||||
|
]
|
||||||
|
assert.deepEqual(replySuggestionIssues(valid), [])
|
||||||
|
const invalid = valid.map((item) => ({ ...item }))
|
||||||
|
invalid[0].content = 'Dear customer, please rest assured that we value your feedback.'
|
||||||
|
invalid[2] = { style: 'active', content: invalid[1].content }
|
||||||
|
const issues = replySuggestionIssues(invalid)
|
||||||
|
assert.ok(issues.some((item) => item.includes('模板化')))
|
||||||
|
assert.ok(issues.some((item) => item.includes('缺少 brief')))
|
||||||
|
assert.ok(issues.some((item) => item.includes('过于相似')))
|
||||||
|
assert.ok(replySuggestionIssues([
|
||||||
|
...valid.slice(0, 2),
|
||||||
|
{ style: 'brief', content: "I'll confirm it today." },
|
||||||
|
]).some((item) => item.includes('时间承诺')))
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { isTimestampOlderThan, webhookEventClaimDecision } from '../src/reliability.js'
|
||||||
|
|
||||||
|
test('completed webhooks deduplicate while failed and expired processing events can resume', () => {
|
||||||
|
const now = Date.parse('2026-08-01T12:00:00.000Z')
|
||||||
|
assert.equal(webhookEventClaimDecision({ status: 'completed' }, now), 'duplicate')
|
||||||
|
assert.equal(webhookEventClaimDecision({ status: 'failed' }, now), 'claim')
|
||||||
|
assert.equal(webhookEventClaimDecision({ status: 'processing', processingAt: '2026-08-01T11:59:30.000Z' }, now), 'busy')
|
||||||
|
assert.equal(webhookEventClaimDecision({ status: 'processing', processingAt: '2026-08-01T11:57:00.000Z' }, now), 'claim')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('missing timestamps are due and recent maintenance timestamps are not', () => {
|
||||||
|
const now = Date.parse('2026-08-01T12:00:00.000Z')
|
||||||
|
assert.equal(isTimestampOlderThan(undefined, 60_000, now), true)
|
||||||
|
assert.equal(isTimestampOlderThan('2026-08-01T11:58:00.000Z', 60_000, now), true)
|
||||||
|
assert.equal(isTimestampOlderThan('2026-08-01T11:59:30.000Z', 60_000, now), false)
|
||||||
|
})
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import {
|
||||||
|
canonicalWhatsAppJid,
|
||||||
|
isWeakWhatsAppDisplayName,
|
||||||
|
resolvedWhatsAppJid,
|
||||||
|
reusableWhatsAppConversations,
|
||||||
|
whatsappCustomerStableId,
|
||||||
|
} from '../src/whatsapp-identity.js'
|
||||||
|
|
||||||
|
test('WhatsApp LID resolves to the same canonical phone identity', () => {
|
||||||
|
const phoneJid = '15551234567@s.whatsapp.net'
|
||||||
|
assert.equal(canonicalWhatsAppJid('987654321@lid', { phone: '+1 555 123 4567' }), phoneJid)
|
||||||
|
assert.equal(canonicalWhatsAppJid('987654321@lid', { canonicalJid: phoneJid }), phoneJid)
|
||||||
|
assert.equal(whatsappCustomerStableId(canonicalWhatsAppJid('987654321@lid', { phone: '+1 555 123 4567' })), whatsappCustomerStableId(phoneJid))
|
||||||
|
assert.equal(canonicalWhatsAppJid('15551234567:4@s.whatsapp.net'), phoneJid)
|
||||||
|
assert.equal(canonicalWhatsAppJid('987654321@lid', { canonicalJid: '15551234567:0@s.whatsapp.net' }), phoneJid)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a newly resolved phone identity overrides a stale cached LID', () => {
|
||||||
|
const lid = '987654321@lid'
|
||||||
|
const phoneJid = '15551234567@s.whatsapp.net'
|
||||||
|
assert.equal(resolvedWhatsAppJid(lid, {}, lid), lid)
|
||||||
|
assert.equal(resolvedWhatsAppJid(lid, { phone: '+1 555 123 4567' }, lid), phoneJid)
|
||||||
|
assert.equal(resolvedWhatsAppJid(lid, {}, phoneJid), phoneJid)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('WhatsApp customer id remains stable after gateway reauthorization', () => {
|
||||||
|
assert.equal(
|
||||||
|
whatsappCustomerStableId('15551234567@s.whatsapp.net'),
|
||||||
|
whatsappCustomerStableId('15551234567@s.whatsapp.net'),
|
||||||
|
)
|
||||||
|
assert.match(whatsappCustomerStableId('15551234567@s.whatsapp.net'), /^wa-customer-[a-f0-9]{24}$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the latest inactive conversation is reused while another active account stays isolated', () => {
|
||||||
|
const jid = '15551234567@s.whatsapp.net'
|
||||||
|
const conversations = [
|
||||||
|
{ id: 'old:newest', channel: 'whatsapp', channelId: 'old', whatsappJid: jid, customerId: 'abigail', lastMessage: { createdAt: '2026-07-31T07:51:37.000Z' } },
|
||||||
|
{ id: 'older:history', channel: 'whatsapp', channelId: 'older', whatsappJid: jid, customerId: 'legacy', lastMessage: { createdAt: '2026-07-29T17:25:42.000Z' } },
|
||||||
|
{ id: 'other:active', channel: 'whatsapp', channelId: 'other', whatsappJid: jid, customerId: 'other-account', lastMessage: { createdAt: '2026-08-01T00:00:00.000Z' } },
|
||||||
|
]
|
||||||
|
const result = reusableWhatsAppConversations(conversations, new Set(['current', 'other']), 'current', jid)
|
||||||
|
assert.deepEqual(result.map((item) => item.customerId), ['abigail', 'legacy'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a saved contact name is not replaced by a phone-number fallback', () => {
|
||||||
|
const jid = '15551234567@s.whatsapp.net'
|
||||||
|
assert.equal(isWeakWhatsAppDisplayName('Abigail', jid), false)
|
||||||
|
assert.equal(isWeakWhatsAppDisplayName('+1 555 123 4567', jid), true)
|
||||||
|
})
|
||||||
@@ -10,9 +10,9 @@ import { toast, errorMessage } from '@/app/store/toast'
|
|||||||
import { languageName } from '@/config/languages'
|
import { languageName } from '@/config/languages'
|
||||||
|
|
||||||
const STYLE_LABELS = {
|
const STYLE_LABELS = {
|
||||||
stable: { title: '稳健回复', color: 'bg-blue-100 text-blue-700', icon: '🛡️' },
|
stable: { title: '清晰可靠', color: 'bg-blue-100 text-blue-700', icon: '🛡️' },
|
||||||
active: { title: '积极促单回复', color: 'bg-emerald-100 text-emerald-700', icon: '🔥' },
|
active: { title: '温暖主动', color: 'bg-violet-100 text-violet-700', icon: '💬' },
|
||||||
brief: { title: '简洁快速回复', color: 'bg-gray-100 text-gray-600', icon: '⚡' },
|
brief: { title: '简洁直接', color: 'bg-gray-100 text-gray-600', icon: '⚡' },
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AiReplies({
|
export function AiReplies({
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function ConversationInsightBar({
|
|||||||
aiBackfill?: AiBackfillStatus
|
aiBackfill?: AiBackfillStatus
|
||||||
}) {
|
}) {
|
||||||
const stage = validStages.has(customer.stage) ? customer.stage : 'initial'
|
const stage = validStages.has(customer.stage) ? customer.stage : 'initial'
|
||||||
const summary = customer.aiProfile?.summary || conversation.summary || '收到更多客户消息后将补充客户画像'
|
const summary = customer.aiProfile?.summary || conversation.summary || '收到更多消息后将补充联系人画像'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -38,7 +38,7 @@ export function ConversationInsightBar({
|
|||||||
</span>
|
</span>
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
<span className="hidden shrink-0 text-[10px] font-semibold uppercase tracking-[0.12em] text-violet-600 sm:inline">
|
<span className="hidden shrink-0 text-[10px] font-semibold uppercase tracking-[0.12em] text-violet-600 sm:inline">
|
||||||
客户洞察
|
对话洞察
|
||||||
</span>
|
</span>
|
||||||
<p className="truncate text-xs leading-5 text-slate-600">{summary}</p>
|
<p className="truncate text-xs leading-5 text-slate-600">{summary}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,12 +55,17 @@ export function ConversationInsightBar({
|
|||||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
意向 <IntentScoreBadge score={Number(customer.intentScore || 0)} />
|
意向 <IntentScoreBadge score={Number(customer.intentScore || 0)} />
|
||||||
</span>
|
</span>
|
||||||
|
{customer.aiProfile?.relationshipLabel && (
|
||||||
|
<span className="rounded-full bg-indigo-50 px-2 py-1 text-[10px] font-medium text-indigo-700">
|
||||||
|
{customer.aiProfile.relationshipLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<SentimentBadge sentiment={customer.sentiment || 'neutral'} />
|
<SentimentBadge sentiment={customer.sentiment || 'neutral'} />
|
||||||
<CustomerStageBadge stage={stage as CustomerStage} />
|
<CustomerStageBadge stage={stage as CustomerStage} />
|
||||||
<RiskLevelBadge level={customer.riskLevel || 'none'} />
|
<RiskLevelBadge level={customer.riskLevel || 'none'} />
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" onClick={onOpenProfile} title="打开客户画像">
|
<Button variant="ghost" size="sm" onClick={onOpenProfile} title="打开联系人画像">
|
||||||
<span className="hidden lg:inline">客户画像</span>
|
<span className="hidden lg:inline">联系人画像</span>
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { IntentScoreBadge, CustomerStageBadge, SentimentBadge, RiskLevelBadge }
|
|||||||
import { CUSTOMER_STAGES, NEXT_ACTIONS, countryMeta, INTENT_LEVELS } from '@/config/constants'
|
import { CUSTOMER_STAGES, NEXT_ACTIONS, countryMeta, INTENT_LEVELS } from '@/config/constants'
|
||||||
import { toast, errorMessage } from '@/app/store/toast'
|
import { toast, errorMessage } from '@/app/store/toast'
|
||||||
import { cn } from '@/utils/cn'
|
import { cn } from '@/utils/cn'
|
||||||
|
import { formatProductName } from '@/utils/format'
|
||||||
import { CustomerProfileRadar } from '@/components/customer/CustomerProfileRadar'
|
import { CustomerProfileRadar } from '@/components/customer/CustomerProfileRadar'
|
||||||
import { CustomerSemanticTag } from '@/components/customer/CustomerSemanticTag'
|
import { CustomerSemanticTag } from '@/components/customer/CustomerSemanticTag'
|
||||||
|
|
||||||
@@ -151,8 +152,8 @@ export function InsightPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||||
{customer.products?.map((p) => (
|
{customer.products?.map((p, index) => (
|
||||||
<Badge key={p} variant="primary">{p}</Badge>
|
<Badge key={`${formatProductName(p)}-${index}`} variant="primary">{formatProductName(p)}</Badge>
|
||||||
))}
|
))}
|
||||||
{customer.tags?.map((t) => (
|
{customer.tags?.map((t) => (
|
||||||
<CustomerSemanticTag key={t} tag={t} />
|
<CustomerSemanticTag key={t} tag={t} />
|
||||||
@@ -249,14 +250,15 @@ export function InsightPanel({
|
|||||||
<span className="ml-2 text-sm text-muted-foreground">正在分析会话上下文…</span>
|
<span className="ml-2 text-sm text-muted-foreground">正在分析会话上下文…</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-3 text-xs leading-6 text-slate-600">{analysis || '收到更多客户消息后将补充客户画像。'}</p>
|
<p className="mt-3 text-xs leading-6 text-slate-600">{analysis || '收到更多消息后将补充联系人画像。'}</p>
|
||||||
)}
|
)}
|
||||||
{/* AI 提取关键信息 */}
|
{/* AI 提取关键信息 */}
|
||||||
{Object.values(customer.aiProfile || {}).some(Boolean) && (
|
{Object.values(customer.aiProfile || {}).some(Boolean) && (
|
||||||
<div className="mt-4 grid grid-cols-2 gap-2 border-t border-slate-100 pt-4">
|
<div className="mt-4 grid grid-cols-2 gap-2 border-t border-slate-100 pt-4">
|
||||||
{Object.entries({
|
{Object.entries({
|
||||||
|
关系类型: customer.aiProfile.relationshipLabel,
|
||||||
行业: customer.aiProfile.industry,
|
行业: customer.aiProfile.industry,
|
||||||
采购角色: customer.aiProfile.purchasingRole,
|
业务身份: customer.aiProfile.purchasingRole,
|
||||||
询价数量: customer.aiProfile.inquiryQuantity,
|
询价数量: customer.aiProfile.inquiryQuantity,
|
||||||
目标价格: customer.aiProfile.targetPrice,
|
目标价格: customer.aiProfile.targetPrice,
|
||||||
预算: customer.aiProfile.budget,
|
预算: customer.aiProfile.budget,
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
INTENT_LEVELS,
|
INTENT_LEVELS,
|
||||||
} from '@/config/constants'
|
} from '@/config/constants'
|
||||||
import { cn } from '@/utils/cn'
|
import { cn } from '@/utils/cn'
|
||||||
|
import { formatProductName } from '@/utils/format'
|
||||||
import type { Customer } from '@/types'
|
import type { Customer } from '@/types'
|
||||||
import type { ChannelType } from '@/types/messaging'
|
import type { ChannelType } from '@/types/messaging'
|
||||||
|
|
||||||
@@ -285,7 +286,7 @@ export function CustomersPage() {
|
|||||||
<Td onClick={() => navigate(`/customers/${customer.id}`)}><CountryFlag code={customer.country} /></Td>
|
<Td onClick={() => navigate(`/customers/${customer.id}`)}><CountryFlag code={customer.country} /></Td>
|
||||||
<Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="text-xs">{customer.email || customer.phone || '—'}</span></Td>
|
<Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="text-xs">{customer.email || customer.phone || '—'}</span></Td>
|
||||||
<Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="flex items-center gap-1"><ChannelIcon channel={customer.channel} size="xs" withBg={false} />{customer.channel}</span></Td>
|
<Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="flex items-center gap-1"><ChannelIcon channel={customer.channel} size="xs" withBg={false} />{customer.channel}</span></Td>
|
||||||
<Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="text-xs">{customer.products[0] || '—'}</span></Td>
|
<Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="text-xs">{formatProductName(customer.products?.[0]) || '—'}</span></Td>
|
||||||
<Td><IntentScoreBadge score={customer.intentScore} /></Td>
|
<Td><IntentScoreBadge score={customer.intentScore} /></Td>
|
||||||
<Td><CustomerStageBadge stage={customer.stage} /></Td>
|
<Td><CustomerStageBadge stage={customer.stage} /></Td>
|
||||||
<Td><SentimentBadge sentiment={customer.sentiment} emojiOnly /></Td>
|
<Td><SentimentBadge sentiment={customer.sentiment} emojiOnly /></Td>
|
||||||
@@ -333,8 +334,8 @@ export function CustomersPage() {
|
|||||||
<SentimentBadge sentiment={customer.sentiment} emojiOnly />
|
<SentimentBadge sentiment={customer.sentiment} emojiOnly />
|
||||||
{customer.riskLevel !== 'none' && <RiskLevelBadge level={customer.riskLevel} />}
|
{customer.riskLevel !== 'none' && <RiskLevelBadge level={customer.riskLevel} />}
|
||||||
</div>
|
</div>
|
||||||
{customer.products.length > 0 && (
|
{customer.products?.length > 0 && (
|
||||||
<p className="mt-2 text-xs text-muted-foreground truncate">{customer.products[0]}</p>
|
<p className="mt-2 text-xs text-muted-foreground truncate">{formatProductName(customer.products[0])}</p>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import { useAuthStore } from '@/app/store/auth'
|
|||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
import { getPlan } from '@/config/pricingPlans'
|
import { getPlan } from '@/config/pricingPlans'
|
||||||
import { countryMeta, taskTypeLabel, CHANNEL_META, riskLevelMeta } from '@/config/constants'
|
import { countryMeta, taskTypeLabel, CHANNEL_META, riskLevelMeta } from '@/config/constants'
|
||||||
import { formatDateTime, formatRelative } from '@/utils/format'
|
import { formatDateTime, formatProductName, formatRelative } from '@/utils/format'
|
||||||
import { toast, errorMessage } from '@/app/store/toast'
|
import { toast, errorMessage } from '@/app/store/toast'
|
||||||
import { PageLoading, ErrorState, EmptyState } from '@/components/ui/feedback'
|
import { PageLoading, ErrorState, EmptyState } from '@/components/ui/feedback'
|
||||||
import { Card, CardHeader } from '@/components/ui/card'
|
import { Card, CardHeader } from '@/components/ui/card'
|
||||||
@@ -235,7 +235,7 @@ export function DashboardPage() {
|
|||||||
<CountryFlag code={c.country} withName={false} />
|
<CountryFlag code={c.country} withName={false} />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||||
{c.products[0] ?? '—'} · 建议:{c.aiProfile.nextActions[0] ?? '跟进'}
|
{formatProductName(c.products?.[0]) || '—'} · 建议:{c.aiProfile?.nextActions?.[0] ?? '跟进'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ function updateRealtimeConversationList(
|
|||||||
conversationId: string,
|
conversationId: string,
|
||||||
patch: Partial<ConversationListItem>,
|
patch: Partial<ConversationListItem>,
|
||||||
) {
|
) {
|
||||||
if (!current) return current
|
if (!current || !Array.isArray(current.list)) return current
|
||||||
let matched = false
|
let matched = false
|
||||||
const list = current.list.map((item) => {
|
const list = current.list.map((item) => {
|
||||||
if (item.id !== conversationId) return item
|
if (item.id !== conversationId) return item
|
||||||
@@ -255,10 +255,14 @@ export function InboxPage() {
|
|||||||
queryClient.setQueryData(['conversation', convId], (current: any) => current
|
queryClient.setQueryData(['conversation', convId], (current: any) => current
|
||||||
? { ...current, conversation: { ...current.conversation, ...conversation } }
|
? { ...current, conversation: { ...current.conversation, ...conversation } }
|
||||||
: current)
|
: current)
|
||||||
queryClient.setQueriesData<any>({ queryKey: ['conversations'] }, (current: any) => current ? {
|
queryClient.setQueriesData<any>({ queryKey: ['conversations'] }, (current: any) => (
|
||||||
|
current && Array.isArray(current.list)
|
||||||
|
? {
|
||||||
...current,
|
...current,
|
||||||
list: current.list.map((item: any) => item.id === convId ? { ...item, unreadCount: 0 } : item),
|
list: current.list.map((item: any) => item.id === convId ? { ...item, unreadCount: 0 } : item),
|
||||||
} : current)
|
}
|
||||||
|
: current
|
||||||
|
))
|
||||||
})
|
})
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
}, [convId, lastMessageId, queryClient])
|
}, [convId, lastMessageId, queryClient])
|
||||||
@@ -300,12 +304,34 @@ export function InboxPage() {
|
|||||||
backfill?: import('@/types').AiBackfillStatus
|
backfill?: import('@/types').AiBackfillStatus
|
||||||
}
|
}
|
||||||
try { event = JSON.parse(String(raw.data)) } catch { return }
|
try { event = JSON.parse(String(raw.data)) } catch { return }
|
||||||
|
if (event.type === 'conversation.deleted' && event.conversationId) {
|
||||||
|
queryClient.removeQueries({ queryKey: ['conversation', event.conversationId] })
|
||||||
|
queryClient.removeQueries({ queryKey: ['messages', event.conversationId] })
|
||||||
|
queryClient.setQueriesData<{ list: ConversationListItem[]; total: number }>(
|
||||||
|
{ queryKey: ['conversations'] },
|
||||||
|
(current) => current && Array.isArray(current.list)
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
list: current.list.filter((item) => item.id !== event.conversationId),
|
||||||
|
total: Math.max(0, current.total - (current.list.some((item) => item.id === event.conversationId) ? 1 : 0)),
|
||||||
|
}
|
||||||
|
: current,
|
||||||
|
)
|
||||||
|
if (convId === event.conversationId) {
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
prev.delete('c')
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
const activeConversationCache = queryClient.getQueryData<{ list: ConversationListItem[] }>(
|
const activeConversationCache = queryClient.getQueryData<{ list: ConversationListItem[] }>(
|
||||||
activeConversationQueryKeyRef.current,
|
activeConversationQueryKeyRef.current,
|
||||||
)
|
)
|
||||||
const conversationWasVisible = Boolean(
|
const conversationWasVisible = Boolean(
|
||||||
event.conversationId
|
event.conversationId
|
||||||
&& activeConversationCache?.list.some((item) => item.id === event.conversationId),
|
&& Array.isArray(activeConversationCache?.list)
|
||||||
|
&& activeConversationCache.list.some((item) => item.id === event.conversationId),
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
['message.created', 'message.received'].includes(event.type || '') &&
|
['message.created', 'message.received'].includes(event.type || '') &&
|
||||||
@@ -335,7 +361,9 @@ export function InboxPage() {
|
|||||||
{ queryKey: ['conversations'] },
|
{ queryKey: ['conversations'] },
|
||||||
(current) => {
|
(current) => {
|
||||||
const typed = current as { list: ConversationListItem[]; total: number } | undefined
|
const typed = current as { list: ConversationListItem[]; total: number } | undefined
|
||||||
const item = typed?.list.find((entry) => entry.id === event.conversationId)
|
const item = Array.isArray(typed?.list)
|
||||||
|
? typed.list.find((entry) => entry.id === event.conversationId)
|
||||||
|
: undefined
|
||||||
const isNewMessage = ['message.created', 'message.received'].includes(event.type || '')
|
const isNewMessage = ['message.created', 'message.received'].includes(event.type || '')
|
||||||
const isCurrentLastMessage =
|
const isCurrentLastMessage =
|
||||||
String(item?.lastMessage?.id || '') === String(event.message?.id || '')
|
String(item?.lastMessage?.id || '') === String(event.message?.id || '')
|
||||||
@@ -396,7 +424,7 @@ export function InboxPage() {
|
|||||||
if (reconnectTimer) clearTimeout(reconnectTimer)
|
if (reconnectTimer) clearTimeout(reconnectTimer)
|
||||||
socket?.close()
|
socket?.close()
|
||||||
}
|
}
|
||||||
}, [token, queryClient])
|
}, [token, queryClient, convId, setSearchParams])
|
||||||
|
|
||||||
const setConv = useCallback(
|
const setConv = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ export type CustomerStage =
|
|||||||
|
|
||||||
/** AI 自动提取的客户画像 */
|
/** AI 自动提取的客户画像 */
|
||||||
export interface AiCustomerProfile {
|
export interface AiCustomerProfile {
|
||||||
|
relationshipType?: 'buyer' | 'supplier' | 'partner' | 'distributor' | 'procurement_agent' | 'logistics' | 'service_provider' | 'internal' | 'other' | 'unknown'
|
||||||
|
relationshipLabel?: string
|
||||||
|
relationshipConfidence?: number
|
||||||
|
relationshipEvidence?: string[]
|
||||||
inquiryQuantity?: string
|
inquiryQuantity?: string
|
||||||
targetPrice?: string
|
targetPrice?: string
|
||||||
moqRequirement?: string
|
moqRequirement?: string
|
||||||
@@ -88,6 +92,7 @@ export interface Customer {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
isKey: boolean
|
isKey: boolean
|
||||||
aiProfile: AiCustomerProfile
|
aiProfile: AiCustomerProfile
|
||||||
|
relationshipType?: AiCustomerProfile['relationshipType']
|
||||||
telegramUsername?: string
|
telegramUsername?: string
|
||||||
telegramUserId?: string
|
telegramUserId?: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ export interface Message {
|
|||||||
}>
|
}>
|
||||||
telegramFileId?: number
|
telegramFileId?: number
|
||||||
whatsappMessageId?: string
|
whatsappMessageId?: string
|
||||||
|
gatewayEventAt?: string
|
||||||
|
ingestedAt?: string
|
||||||
|
realtimeLatencyMs?: number
|
||||||
whatsappContentType?: string
|
whatsappContentType?: string
|
||||||
attachmentMimeType?: string
|
attachmentMimeType?: string
|
||||||
attachmentSize?: number
|
attachmentSize?: number
|
||||||
|
|||||||
@@ -71,3 +71,15 @@ export function localTimeByOffset(offsetHours: number): string {
|
|||||||
const target = new Date(utc + offsetHours * 3600000)
|
const target = new Date(utc + offsetHours * 3600000)
|
||||||
return `${pad(target.getHours())}:${pad(target.getMinutes())}`
|
return `${pad(target.getHours())}:${pad(target.getMinutes())}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatProductName(value: unknown): string {
|
||||||
|
if (typeof value === 'string' || typeof value === 'number') return String(value).trim()
|
||||||
|
if (!value || typeof value !== 'object') return ''
|
||||||
|
const product = value as Record<string, unknown>
|
||||||
|
for (const key of ['name', 'product', 'category']) {
|
||||||
|
if (typeof product[key] === 'string' || typeof product[key] === 'number') {
|
||||||
|
return String(product[key]).trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
# Application
|
# Application
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
# Optional image build mirrors for networks where deb.debian.org is unavailable.
|
||||||
|
DEBIAN_MIRROR=https://deb.debian.org/debian
|
||||||
|
DEBIAN_SECURITY_MIRROR=https://deb.debian.org/debian-security
|
||||||
PORT=3000
|
PORT=3000
|
||||||
APP_NAME=TelegramUserGateway
|
APP_NAME=TelegramUserGateway
|
||||||
CORS_ORIGINS=
|
CORS_ORIGINS=
|
||||||
@@ -29,6 +32,14 @@ TDLIB_PATH=/usr/local/lib/libtdjson.so
|
|||||||
TG_API_ID=0
|
TG_API_ID=0
|
||||||
TG_API_HASH=your_api_hash_here
|
TG_API_HASH=your_api_hash_here
|
||||||
|
|
||||||
|
# 可选默认代理。账号未绑定专属代理时使用;只影响 Telegram TDLib 出站。
|
||||||
|
TG_DEFAULT_PROXY_TYPE=
|
||||||
|
TG_DEFAULT_PROXY_HOST=
|
||||||
|
TG_DEFAULT_PROXY_PORT=
|
||||||
|
TG_DEFAULT_PROXY_USERNAME=
|
||||||
|
TG_DEFAULT_PROXY_PASSWORD=
|
||||||
|
TG_DEFAULT_PROXY_SECRET=
|
||||||
|
|
||||||
# Rate Limiting
|
# Rate Limiting
|
||||||
RATE_LIMIT_DEFAULT_TTL=60000
|
RATE_LIMIT_DEFAULT_TTL=60000
|
||||||
RATE_LIMIT_DEFAULT_LIMIT=30
|
RATE_LIMIT_DEFAULT_LIMIT=30
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ CORS_ORIGINS=https://your-control-plane.example
|
|||||||
|
|
||||||
Production uses migrations and keeps TypeORM synchronization disabled.
|
Production uses migrations and keeps TypeORM synchronization disabled.
|
||||||
|
|
||||||
|
If Docker build containers cannot reach the default Debian repositories, set
|
||||||
|
`DEBIAN_MIRROR` and `DEBIAN_SECURITY_MIRROR` to reachable mirrors. HTTP mirrors
|
||||||
|
can bootstrap older base images that do not yet contain CA certificates; keep
|
||||||
|
all application and credential traffic on HTTPS.
|
||||||
|
|
||||||
## Start
|
## Start
|
||||||
|
|
||||||
From the project root:
|
From the project root:
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
FROM node:20-bookworm-slim AS builder
|
FROM node:20-bookworm-slim AS builder
|
||||||
|
|
||||||
|
ARG DEBIAN_MIRROR=https://deb.debian.org/debian
|
||||||
|
ARG DEBIAN_SECURITY_MIRROR=https://deb.debian.org/debian-security
|
||||||
|
RUN sed -i "s|http://deb.debian.org/debian-security|${DEBIAN_SECURITY_MIRROR}|g; s|http://deb.debian.org/debian|${DEBIAN_MIRROR}|g" /etc/apt/sources.list.d/debian.sources
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends g++ make python3 \
|
&& apt-get install -y --no-install-recommends g++ make python3 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
@@ -22,6 +25,9 @@ RUN npm prune --omit=dev
|
|||||||
|
|
||||||
FROM node:20-bookworm-slim
|
FROM node:20-bookworm-slim
|
||||||
|
|
||||||
|
ARG DEBIAN_MIRROR=https://deb.debian.org/debian
|
||||||
|
ARG DEBIAN_SECURITY_MIRROR=https://deb.debian.org/debian-security
|
||||||
|
RUN sed -i "s|http://deb.debian.org/debian-security|${DEBIAN_SECURITY_MIRROR}|g; s|http://deb.debian.org/debian|${DEBIAN_MIRROR}|g" /etc/apt/sources.list.d/debian.sources
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends tini ca-certificates \
|
&& apt-get install -y --no-install-recommends tini ca-certificates \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|||||||
@@ -30,9 +30,12 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: docker/Dockerfile
|
dockerfile: docker/Dockerfile
|
||||||
|
args:
|
||||||
|
DEBIAN_MIRROR: ${DEBIAN_MIRROR:-https://deb.debian.org/debian}
|
||||||
|
DEBIAN_SECURITY_MIRROR: ${DEBIAN_SECURITY_MIRROR:-https://deb.debian.org/debian-security}
|
||||||
container_name: tgw-app
|
container_name: tgw-app
|
||||||
ports:
|
ports:
|
||||||
- '3000:3000'
|
- '127.0.0.1:3010:3000'
|
||||||
env_file:
|
env_file:
|
||||||
- ../.env
|
- ../.env
|
||||||
environment:
|
environment:
|
||||||
@@ -52,6 +55,12 @@ services:
|
|||||||
TDLIB_PATH: /app/node_modules/@prebuilt-tdlib/linux-x64-glibc/libtdjson.so
|
TDLIB_PATH: /app/node_modules/@prebuilt-tdlib/linux-x64-glibc/libtdjson.so
|
||||||
TG_API_ID: ${TG_API_ID}
|
TG_API_ID: ${TG_API_ID}
|
||||||
TG_API_HASH: ${TG_API_HASH}
|
TG_API_HASH: ${TG_API_HASH}
|
||||||
|
TG_DEFAULT_PROXY_TYPE: ${TG_DEFAULT_PROXY_TYPE:-}
|
||||||
|
TG_DEFAULT_PROXY_HOST: ${TG_DEFAULT_PROXY_HOST:-}
|
||||||
|
TG_DEFAULT_PROXY_PORT: ${TG_DEFAULT_PROXY_PORT:-}
|
||||||
|
TG_DEFAULT_PROXY_USERNAME: ${TG_DEFAULT_PROXY_USERNAME:-}
|
||||||
|
TG_DEFAULT_PROXY_PASSWORD: ${TG_DEFAULT_PROXY_PASSWORD:-}
|
||||||
|
TG_DEFAULT_PROXY_SECRET: ${TG_DEFAULT_PROXY_SECRET:-}
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Telegram User Gateway - Database Initialization
|
||||||
|
-- This script runs on first PostgreSQL startup
|
||||||
|
|
||||||
|
-- Enable UUID extension
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- Create indexes will be handled by TypeORM synchronize in dev
|
||||||
|
-- or by migrations in production
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
ManyToOne,
|
ManyToOne,
|
||||||
JoinColumn,
|
JoinColumn,
|
||||||
|
Index,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { TgAccount } from './tg-account.entity';
|
import { TgAccount } from './tg-account.entity';
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ export enum ChatType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Entity('tg_chats')
|
@Entity('tg_chats')
|
||||||
|
@Index('IDX_tg_chat_account_identity', ['accountId', 'tgChatId'], { unique: true })
|
||||||
export class TgChat {
|
export class TgChat {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { MigrationInterface, QueryRunner, TableIndex } from 'typeorm';
|
||||||
|
|
||||||
|
export class TgChatIdentityUnique1700000003000 implements MigrationInterface {
|
||||||
|
name = 'TgChatIdentityUnique1700000003000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
DELETE FROM tg_chats older
|
||||||
|
USING tg_chats newer
|
||||||
|
WHERE older."accountId" = newer."accountId"
|
||||||
|
AND older."tgChatId" = newer."tgChatId"
|
||||||
|
AND (
|
||||||
|
older."updatedAt" < newer."updatedAt"
|
||||||
|
OR (older."updatedAt" = newer."updatedAt" AND older.id < newer.id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
const table = await queryRunner.getTable('tg_chats');
|
||||||
|
if (!table?.indices.some((index) => index.name === 'IDX_tg_chat_account_identity')) {
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'tg_chats',
|
||||||
|
new TableIndex({
|
||||||
|
name: 'IDX_tg_chat_account_identity',
|
||||||
|
columnNames: ['accountId', 'tgChatId'],
|
||||||
|
isUnique: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const table = await queryRunner.getTable('tg_chats');
|
||||||
|
if (table?.indices.some((index) => index.name === 'IDX_tg_chat_account_identity')) {
|
||||||
|
await queryRunner.dropIndex('tg_chats', 'IDX_tg_chat_account_identity');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -171,7 +171,7 @@ export class TgAccountsService implements OnModuleInit {
|
|||||||
const binding = await this.bindingRepo.findOne({
|
const binding = await this.bindingRepo.findOne({
|
||||||
where: { accountId, isActive: true },
|
where: { accountId, isActive: true },
|
||||||
});
|
});
|
||||||
if (!binding) return undefined;
|
if (!binding) return this.getDefaultProxy();
|
||||||
|
|
||||||
const proxy = await this.proxyRepo
|
const proxy = await this.proxyRepo
|
||||||
.createQueryBuilder('proxy')
|
.createQueryBuilder('proxy')
|
||||||
@@ -179,7 +179,7 @@ export class TgAccountsService implements OnModuleInit {
|
|||||||
.where('proxy.id = :id', { id: binding.proxyId })
|
.where('proxy.id = :id', { id: binding.proxyId })
|
||||||
.andWhere('proxy.isActive = :isActive', { isActive: true })
|
.andWhere('proxy.isActive = :isActive', { isActive: true })
|
||||||
.getOne();
|
.getOne();
|
||||||
if (!proxy) return undefined;
|
if (!proxy) return this.getDefaultProxy();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: proxy.type,
|
type: proxy.type,
|
||||||
@@ -190,4 +190,27 @@ export class TgAccountsService implements OnModuleInit {
|
|||||||
secret: proxy.secret,
|
secret: proxy.secret,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getDefaultProxy() {
|
||||||
|
const host = String(process.env.TG_DEFAULT_PROXY_HOST || '').trim();
|
||||||
|
const port = Number(process.env.TG_DEFAULT_PROXY_PORT || 0);
|
||||||
|
if (!host || !Number.isInteger(port) || port < 1 || port > 65535) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = String(process.env.TG_DEFAULT_PROXY_TYPE || 'socks5').toLowerCase();
|
||||||
|
if (!['socks5', 'http', 'mtproto'].includes(type)) {
|
||||||
|
this.logger.warn(`Ignoring unsupported TG_DEFAULT_PROXY_TYPE: ${type}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
username: process.env.TG_DEFAULT_PROXY_USERNAME || '',
|
||||||
|
password: process.env.TG_DEFAULT_PROXY_PASSWORD || '',
|
||||||
|
secret: process.env.TG_DEFAULT_PROXY_SECRET || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ export class TgChatsService {
|
|||||||
return this.chatRepo.find({
|
return this.chatRepo.find({
|
||||||
where: { accountId },
|
where: { accountId },
|
||||||
order: { lastMessageDate: 'DESC', updatedAt: 'DESC' },
|
order: { lastMessageDate: 'DESC', updatedAt: 'DESC' },
|
||||||
take: limit,
|
take: Math.min(200, Math.max(1, Number(limit) || 50)),
|
||||||
skip: offset,
|
skip: Math.max(0, Number(offset) || 0),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +106,21 @@ export class TgChatsService {
|
|||||||
|
|
||||||
async syncChatsFromTdlib(tenantId: string, accountId: string): Promise<unknown> {
|
async syncChatsFromTdlib(tenantId: string, accountId: string): Promise<unknown> {
|
||||||
await this.tgAccountsService.findById(tenantId, accountId);
|
await this.tgAccountsService.findById(tenantId, accountId);
|
||||||
|
let loadedPages = 0;
|
||||||
|
for (; loadedPages < 100; loadedPages++) {
|
||||||
|
try {
|
||||||
|
await this.tdlibClientManager.sendRequest(accountId, {
|
||||||
|
'@type': 'loadChats',
|
||||||
|
chat_list: { '@type': 'chatListMain' },
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (/404|all chats.*loaded|not found/i.test(message)) break;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
const result = await this.tdlibClientManager.sendRequest(accountId, {
|
const result = await this.tdlibClientManager.sendRequest(accountId, {
|
||||||
'@type': 'getChats',
|
'@type': 'getChats',
|
||||||
limit: 100,
|
limit: 100,
|
||||||
@@ -123,7 +138,8 @@ export class TgChatsService {
|
|||||||
// A deleted/inaccessible chat must not abort the remaining first sync.
|
// A deleted/inaccessible chat must not abort the remaining first sync.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { total: chatIds.length, synced: synced.length, chatIds };
|
const total = await this.chatRepo.count({ where: { accountId } });
|
||||||
|
return { total, synced: synced.length, loadedPages, chatIds };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async persistTdlibChat(accountId: string, chat: Record<string, any>): Promise<TgChat> {
|
private async persistTdlibChat(accountId: string, chat: Record<string, any>): Promise<TgChat> {
|
||||||
@@ -331,6 +347,11 @@ export class TgChatsService {
|
|||||||
chat = this.chatRepo.create({ accountId, ...data });
|
chat = this.chatRepo.create({ accountId, ...data });
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.chatRepo.save(chat);
|
const { account: _account, ...persisted } = chat;
|
||||||
|
// TypeORM's DeepPartial type does not understand the JSON transformer fields.
|
||||||
|
await this.chatRepo.upsert(persisted as any, ['accountId', 'tgChatId']);
|
||||||
|
return this.chatRepo.findOneOrFail({
|
||||||
|
where: { accountId, tgChatId: data.tgChatId },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import { TdlibModule } from '../tdlib/tdlib.module';
|
|||||||
import { TgWebhooksModule } from '../tg-webhooks/tg-webhooks.module';
|
import { TgWebhooksModule } from '../tg-webhooks/tg-webhooks.module';
|
||||||
import { TgUpdatesService } from './tg-updates.service';
|
import { TgUpdatesService } from './tg-updates.service';
|
||||||
import { TgAccount } from '../../database/entities/tg-account.entity';
|
import { TgAccount } from '../../database/entities/tg-account.entity';
|
||||||
|
import { TgChat } from '../../database/entities/tg-chat.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([TgRawUpdate, TgAccount]),
|
TypeOrmModule.forFeature([TgRawUpdate, TgAccount, TgChat]),
|
||||||
TdlibModule,
|
TdlibModule,
|
||||||
TgWebhooksModule,
|
TgWebhooksModule,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
TgAccount,
|
TgAccount,
|
||||||
TgAccountStatus,
|
TgAccountStatus,
|
||||||
} from '../../database/entities/tg-account.entity';
|
} from '../../database/entities/tg-account.entity';
|
||||||
|
import { ChatType, TgChat } from '../../database/entities/tg-chat.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TgUpdatesService implements OnModuleInit, OnModuleDestroy {
|
export class TgUpdatesService implements OnModuleInit, OnModuleDestroy {
|
||||||
@@ -20,6 +21,8 @@ export class TgUpdatesService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private readonly rawUpdateRepo: Repository<TgRawUpdate>,
|
private readonly rawUpdateRepo: Repository<TgRawUpdate>,
|
||||||
@InjectRepository(TgAccount)
|
@InjectRepository(TgAccount)
|
||||||
private readonly accountRepo: Repository<TgAccount>,
|
private readonly accountRepo: Repository<TgAccount>,
|
||||||
|
@InjectRepository(TgChat)
|
||||||
|
private readonly chatRepo: Repository<TgChat>,
|
||||||
private readonly tdlib: TdlibClientManagerService,
|
private readonly tdlib: TdlibClientManagerService,
|
||||||
private readonly webhooks: TgWebhooksService,
|
private readonly webhooks: TgWebhooksService,
|
||||||
) {}
|
) {}
|
||||||
@@ -50,6 +53,11 @@ export class TgUpdatesService implements OnModuleInit, OnModuleDestroy {
|
|||||||
if (updateType === 'updateAuthorizationState') {
|
if (updateType === 'updateAuthorizationState') {
|
||||||
await this.updateAccountAuthorization(update.accountId, update.data);
|
await this.updateAccountAuthorization(update.accountId, update.data);
|
||||||
}
|
}
|
||||||
|
if (updateType === 'updateNewChat') {
|
||||||
|
await this.persistChat(update.accountId, update.data.chat as Record<string, any>);
|
||||||
|
} else if (updateType === 'updateChatLastMessage') {
|
||||||
|
await this.persistChatLastMessage(update.accountId, update.data);
|
||||||
|
}
|
||||||
|
|
||||||
const event = this.mapWebhookEvent(updateType, update.data);
|
const event = this.mapWebhookEvent(updateType, update.data);
|
||||||
if (event) {
|
if (event) {
|
||||||
@@ -60,6 +68,59 @@ export class TgUpdatesService implements OnModuleInit, OnModuleDestroy {
|
|||||||
await this.rawUpdateRepo.save(saved);
|
await this.rawUpdateRepo.save(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private messageText(content: Record<string, any> = {}): string {
|
||||||
|
return String(
|
||||||
|
content.text?.text
|
||||||
|
|| content.caption?.text
|
||||||
|
|| (content['@type'] ? `[${content['@type']}]` : ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistChat(accountId: string, chat: Record<string, any> | undefined) {
|
||||||
|
if (!chat?.id) return;
|
||||||
|
const rawType = String(chat.type?.['@type'] || '');
|
||||||
|
const typeMap: Record<string, ChatType> = {
|
||||||
|
chatTypePrivate: ChatType.PRIVATE,
|
||||||
|
chatTypeBasicGroup: ChatType.GROUP,
|
||||||
|
chatTypeSupergroup: chat.type?.is_channel ? ChatType.CHANNEL : ChatType.SUPERGROUP,
|
||||||
|
chatTypeSecret: ChatType.SECRET,
|
||||||
|
};
|
||||||
|
const existing = await this.chatRepo.findOne({
|
||||||
|
where: { accountId, tgChatId: Number(chat.id) },
|
||||||
|
});
|
||||||
|
const value = existing || this.chatRepo.create({ accountId, tgChatId: Number(chat.id) });
|
||||||
|
Object.assign(value, {
|
||||||
|
type: typeMap[rawType] || existing?.type || ChatType.PRIVATE,
|
||||||
|
title: String(chat.title || existing?.title || `Telegram ${chat.id}`),
|
||||||
|
lastMessage: this.messageText(chat.last_message?.content) || existing?.lastMessage || '',
|
||||||
|
lastMessageId: Number(chat.last_message?.id || existing?.lastMessageId || 0),
|
||||||
|
lastMessageDate: chat.last_message?.date
|
||||||
|
? new Date(Number(chat.last_message.date) * 1000)
|
||||||
|
: existing?.lastMessageDate,
|
||||||
|
unreadCount: Number(chat.unread_count ?? existing?.unreadCount ?? 0),
|
||||||
|
photo: chat.photo || existing?.photo,
|
||||||
|
raw: { ...(existing?.raw || {}), ...chat },
|
||||||
|
});
|
||||||
|
const { account: _account, ...persisted } = value;
|
||||||
|
// TypeORM's DeepPartial type does not understand the JSON transformer fields.
|
||||||
|
await this.chatRepo.upsert(persisted as any, ['accountId', 'tgChatId']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistChatLastMessage(accountId: string, update: Record<string, any>) {
|
||||||
|
const chatId = Number(update.chat_id || 0);
|
||||||
|
if (!chatId) return;
|
||||||
|
const existing = await this.chatRepo.findOne({ where: { accountId, tgChatId: chatId } });
|
||||||
|
if (!existing) return;
|
||||||
|
const message = update.last_message || {};
|
||||||
|
existing.lastMessage = this.messageText(message.content) || existing.lastMessage;
|
||||||
|
existing.lastMessageId = Number(message.id || existing.lastMessageId || 0);
|
||||||
|
existing.lastMessageDate = message.date
|
||||||
|
? new Date(Number(message.date) * 1000)
|
||||||
|
: existing.lastMessageDate;
|
||||||
|
existing.raw = { ...(existing.raw || {}), last_message: message };
|
||||||
|
await this.chatRepo.save(existing);
|
||||||
|
}
|
||||||
|
|
||||||
private async updateAccountAuthorization(
|
private async updateAccountAuthorization(
|
||||||
accountId: string,
|
accountId: string,
|
||||||
data: Record<string, unknown>,
|
data: Record<string, unknown>,
|
||||||
|
|||||||
@@ -159,10 +159,12 @@ describe('Business service behavior', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('persists Telegram broadcast channels as channel instead of supergroup', async () => {
|
it('persists Telegram broadcast channels as channel instead of supergroup', async () => {
|
||||||
|
let stored: any;
|
||||||
const repo = {
|
const repo = {
|
||||||
findOne: jest.fn().mockResolvedValue(null),
|
findOne: jest.fn().mockResolvedValue(null),
|
||||||
create: jest.fn((value) => value),
|
create: jest.fn((value) => value),
|
||||||
save: jest.fn(async (value) => value),
|
upsert: jest.fn(async (value) => { stored = value; }),
|
||||||
|
findOneOrFail: jest.fn(async () => stored),
|
||||||
};
|
};
|
||||||
const service = new TgChatsService(repo as any, tdlib as any, accounts as any);
|
const service = new TgChatsService(repo as any, tdlib as any, accounts as any);
|
||||||
const saved = await (service as any).persistTdlibChat('account-1', {
|
const saved = await (service as any).persistTdlibChat('account-1', {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
FROM node:20-bookworm-slim
|
FROM node:20-bookworm-slim
|
||||||
|
|
||||||
|
ARG DEBIAN_MIRROR=https://deb.debian.org/debian
|
||||||
|
ARG DEBIAN_SECURITY_MIRROR=https://deb.debian.org/debian-security
|
||||||
|
RUN sed -i "s|http://deb.debian.org/debian-security|${DEBIAN_SECURITY_MIRROR}|g; s|http://deb.debian.org/debian|${DEBIAN_MIRROR}|g" /etc/apt/sources.list.d/debian.sources
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
Generated
+11
-1
@@ -15,7 +15,8 @@
|
|||||||
"https-proxy-agent": "^7.0.6",
|
"https-proxy-agent": "^7.0.6",
|
||||||
"multer": "2.2.0",
|
"multer": "2.2.0",
|
||||||
"pino": "9.6.0",
|
"pino": "9.6.0",
|
||||||
"qrcode": "1.5.4"
|
"qrcode": "1.5.4",
|
||||||
|
"undici": "^6.21.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@borewit/text-codec": {
|
"node_modules/@borewit/text-codec": {
|
||||||
@@ -2624,6 +2625,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/undici": {
|
||||||
|
"version": "6.21.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/undici/-/undici-6.21.3.tgz",
|
||||||
|
"integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "8.3.0",
|
"version": "8.3.0",
|
||||||
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-8.3.0.tgz",
|
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-8.3.0.tgz",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"https-proxy-agent": "^7.0.6",
|
"https-proxy-agent": "^7.0.6",
|
||||||
"multer": "2.2.0",
|
"multer": "2.2.0",
|
||||||
"pino": "9.6.0",
|
"pino": "9.6.0",
|
||||||
"qrcode": "1.5.4"
|
"qrcode": "1.5.4",
|
||||||
|
"undici": "^6.21.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+157
-19
@@ -9,10 +9,12 @@ import path from 'node:path'
|
|||||||
import https from 'node:https'
|
import https from 'node:https'
|
||||||
import { Boom } from '@hapi/boom'
|
import { Boom } from '@hapi/boom'
|
||||||
import { HttpsProxyAgent } from 'https-proxy-agent'
|
import { HttpsProxyAgent } from 'https-proxy-agent'
|
||||||
|
import { ProxyAgent as UndiciProxyAgent } from 'undici'
|
||||||
import makeWASocket, {
|
import makeWASocket, {
|
||||||
Browsers,
|
Browsers,
|
||||||
BufferJSON,
|
BufferJSON,
|
||||||
DisconnectReason,
|
DisconnectReason,
|
||||||
|
downloadAndProcessHistorySyncNotification,
|
||||||
downloadMediaMessage,
|
downloadMediaMessage,
|
||||||
fetchLatestBaileysVersion,
|
fetchLatestBaileysVersion,
|
||||||
getContentType,
|
getContentType,
|
||||||
@@ -31,6 +33,7 @@ const accountsFile = path.join(dataDir, 'accounts.json')
|
|||||||
const webhookDeliveriesFile = path.join(dataDir, 'webhook-deliveries.json')
|
const webhookDeliveriesFile = path.join(dataDir, 'webhook-deliveries.json')
|
||||||
const outboundJobsFile = path.join(dataDir, 'outbound-jobs.json')
|
const outboundJobsFile = path.join(dataDir, 'outbound-jobs.json')
|
||||||
const gatewayToken = String(process.env.WHATSAPP_GATEWAY_TOKEN || '')
|
const gatewayToken = String(process.env.WHATSAPP_GATEWAY_TOKEN || '')
|
||||||
|
const defaultProxyUrl = String(process.env.WHATSAPP_DEFAULT_PROXY_URL || '').trim()
|
||||||
const maxMessagesPerChat = Math.max(2_000, Number(process.env.MAX_MESSAGES_PER_CHAT || 20_000))
|
const maxMessagesPerChat = Math.max(2_000, Number(process.env.MAX_MESSAGES_PER_CHAT || 20_000))
|
||||||
const log = pino({ level: process.env.LOG_LEVEL || 'info' })
|
const log = pino({ level: process.env.LOG_LEVEL || 'info' })
|
||||||
|
|
||||||
@@ -90,7 +93,7 @@ function accountWebhookSecret(account) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function accountProxyUrl(account) {
|
function accountProxyUrl(account) {
|
||||||
return decryptCredential(account?.proxyUrlEncrypted || '')
|
return decryptCredential(account?.proxyUrlEncrypted || '') || defaultProxyUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
function jsonSafe(value) {
|
function jsonSafe(value) {
|
||||||
@@ -122,7 +125,7 @@ async function saveWebhookDeliveries() {
|
|||||||
const sorted = [...webhookDeliveries.values()]
|
const sorted = [...webhookDeliveries.values()]
|
||||||
.sort((a, b) => Date.parse(b.createdAt || '') - Date.parse(a.createdAt || ''))
|
.sort((a, b) => Date.parse(b.createdAt || '') - Date.parse(a.createdAt || ''))
|
||||||
const active = sorted.filter((item) => !['delivered', 'dead'].includes(item.status))
|
const active = sorted.filter((item) => !['delivered', 'dead'].includes(item.status))
|
||||||
const terminal = sorted.filter((item) => ['delivered', 'dead'].includes(item.status)).slice(0, 10_000)
|
const terminal = sorted.filter((item) => ['delivered', 'dead'].includes(item.status)).slice(0, 2_000)
|
||||||
const values = [...active, ...terminal]
|
const values = [...active, ...terminal]
|
||||||
const keep = new Set(values.map((item) => item.id))
|
const keep = new Set(values.map((item) => item.id))
|
||||||
for (const id of webhookDeliveries.keys()) {
|
for (const id of webhookDeliveries.keys()) {
|
||||||
@@ -131,11 +134,20 @@ async function saveWebhookDeliveries() {
|
|||||||
await atomicJson(webhookDeliveriesFile, values)
|
await atomicJson(webhookDeliveriesFile, values)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let webhookDeliverySaveChain = Promise.resolve()
|
||||||
|
function queueWebhookDeliveriesSave() {
|
||||||
|
webhookDeliverySaveChain = webhookDeliverySaveChain
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(() => saveWebhookDeliveries())
|
||||||
|
.catch((error) => log.error({ err: error }, 'webhook delivery persistence failed'))
|
||||||
|
return webhookDeliverySaveChain
|
||||||
|
}
|
||||||
|
|
||||||
async function saveOutboundJobs() {
|
async function saveOutboundJobs() {
|
||||||
const sorted = [...outboundJobs.values()]
|
const sorted = [...outboundJobs.values()]
|
||||||
.sort((a, b) => Date.parse(b.createdAt || '') - Date.parse(a.createdAt || ''))
|
.sort((a, b) => Date.parse(b.createdAt || '') - Date.parse(a.createdAt || ''))
|
||||||
const active = sorted.filter((item) => !['sent', 'dead'].includes(item.status))
|
const active = sorted.filter((item) => !['sent', 'dead'].includes(item.status))
|
||||||
const terminal = sorted.filter((item) => ['sent', 'dead'].includes(item.status)).slice(0, 10_000)
|
const terminal = sorted.filter((item) => ['sent', 'dead'].includes(item.status)).slice(0, 2_000)
|
||||||
const values = [...active, ...terminal]
|
const values = [...active, ...terminal]
|
||||||
const keep = new Set(values.map((item) => item.id))
|
const keep = new Set(values.map((item) => item.id))
|
||||||
for (const id of outboundJobs.keys()) {
|
for (const id of outboundJobs.keys()) {
|
||||||
@@ -144,6 +156,15 @@ async function saveOutboundJobs() {
|
|||||||
await atomicJson(outboundJobsFile, values)
|
await atomicJson(outboundJobsFile, values)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let outboundJobsSaveChain = Promise.resolve()
|
||||||
|
function queueOutboundJobsSave() {
|
||||||
|
outboundJobsSaveChain = outboundJobsSaveChain
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(() => saveOutboundJobs())
|
||||||
|
.catch((error) => log.error({ err: error }, 'outbound job persistence failed'))
|
||||||
|
return outboundJobsSaveChain
|
||||||
|
}
|
||||||
|
|
||||||
async function saveAccounts() {
|
async function saveAccounts() {
|
||||||
const values = [...accounts.values()].map(({ qrDataUrl, pairingCode, ...item }) => item)
|
const values = [...accounts.values()].map(({ qrDataUrl, pairingCode, ...item }) => item)
|
||||||
await atomicJson(accountsFile, values)
|
await atomicJson(accountsFile, values)
|
||||||
@@ -307,6 +328,61 @@ function upsertMessage(accountId, raw) {
|
|||||||
return message
|
return message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function replayStoredHistoryNotifications(
|
||||||
|
accountId, account, store, logger, processedIds = new Set(), downloadOptions = {},
|
||||||
|
) {
|
||||||
|
const replayed = new Set(account.replayedHistoryNotificationIds || [])
|
||||||
|
const candidates = []
|
||||||
|
for (const messages of store.messages.values()) {
|
||||||
|
for (const item of messages || []) {
|
||||||
|
const notification = item.raw?.message?.protocolMessage?.historySyncNotification
|
||||||
|
if (!notification || !item.id || replayed.has(item.id) || processedIds.has(item.id)) continue
|
||||||
|
candidates.push({ id: item.id, notification })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
const data = await downloadAndProcessHistorySyncNotification(
|
||||||
|
candidate.notification, downloadOptions, logger,
|
||||||
|
)
|
||||||
|
for (const chat of data.chats || []) {
|
||||||
|
const id = String(chat.id || '')
|
||||||
|
if (id) store.chats.set(id, { ...(store.chats.get(id) || {}), ...jsonSafe(chat), id })
|
||||||
|
}
|
||||||
|
for (const contact of data.contacts || []) {
|
||||||
|
const id = String(contact.id || '')
|
||||||
|
if (id) store.contacts.set(id, { ...(store.contacts.get(id) || {}), ...jsonSafe(contact), id })
|
||||||
|
}
|
||||||
|
for (const message of data.messages || []) upsertMessage(accountId, message)
|
||||||
|
replayed.add(candidate.id)
|
||||||
|
account.replayedHistoryNotificationIds = [...replayed].slice(-100)
|
||||||
|
account.historySync = {
|
||||||
|
status: 'processing',
|
||||||
|
syncType: data.syncType ?? candidate.notification.syncType ?? null,
|
||||||
|
progress: data.progress ?? candidate.notification.progress ?? null,
|
||||||
|
chats: data.chats?.length || 0,
|
||||||
|
contacts: data.contacts?.length || 0,
|
||||||
|
messages: data.messages?.length || 0,
|
||||||
|
recovered: true,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
await saveAccounts()
|
||||||
|
scheduleStoreSave(accountId)
|
||||||
|
void deliverWebhook(account, 'history.synced', {
|
||||||
|
chats: data.chats?.length || 0,
|
||||||
|
contacts: data.contacts?.length || 0,
|
||||||
|
messages: data.messages?.length || 0,
|
||||||
|
progress: data.progress ?? candidate.notification.progress ?? null,
|
||||||
|
syncType: data.syncType ?? candidate.notification.syncType ?? null,
|
||||||
|
recovered: true,
|
||||||
|
isLatest: true,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ err: error, messageId: candidate.id }, 'stored history notification replay failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function storedMessage(accountId, jid, messageId) {
|
function storedMessage(accountId, jid, messageId) {
|
||||||
const store = stores.get(accountId)
|
const store = stores.get(accountId)
|
||||||
return (store?.messages.get(jid) || []).find((item) => item.id === messageId)
|
return (store?.messages.get(jid) || []).find((item) => item.id === messageId)
|
||||||
@@ -426,7 +502,7 @@ async function processWebhookDelivery(deliveryId) {
|
|||||||
delivery.status = 'delivering'
|
delivery.status = 'delivering'
|
||||||
delivery.attempts = Number(delivery.attempts || 0) + 1
|
delivery.attempts = Number(delivery.attempts || 0) + 1
|
||||||
delivery.updatedAt = new Date().toISOString()
|
delivery.updatedAt = new Date().toISOString()
|
||||||
await saveWebhookDeliveries()
|
void queueWebhookDeliveriesSave()
|
||||||
try {
|
try {
|
||||||
const signature = crypto.createHmac('sha256', webhookSecret)
|
const signature = crypto.createHmac('sha256', webhookSecret)
|
||||||
.update(delivery.payload)
|
.update(delivery.payload)
|
||||||
@@ -465,13 +541,19 @@ async function processWebhookDelivery(deliveryId) {
|
|||||||
} finally {
|
} finally {
|
||||||
delivery.updatedAt = new Date().toISOString()
|
delivery.updatedAt = new Date().toISOString()
|
||||||
webhookDeliveriesProcessing.delete(deliveryId)
|
webhookDeliveriesProcessing.delete(deliveryId)
|
||||||
await saveWebhookDeliveries()
|
await queueWebhookDeliveriesSave()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deliverWebhook(account, event, data) {
|
async function deliverWebhook(account, event, data) {
|
||||||
if (!account.webhookUrl || !account.webhookActive || !accountWebhookSecret(account)) return
|
if (!account.webhookUrl || !account.webhookActive || !accountWebhookSecret(account)) return
|
||||||
const id = crypto.randomUUID()
|
const messageId = ['message.received', 'message.sent', 'message.edited'].includes(event)
|
||||||
|
? String(data?.message?.id || '')
|
||||||
|
: ''
|
||||||
|
const id = messageId
|
||||||
|
? `wa-${crypto.createHash('sha256').update(`${account.id}:${event}:${messageId}`).digest('hex').slice(0, 32)}`
|
||||||
|
: crypto.randomUUID()
|
||||||
|
if (webhookDeliveries.has(id)) return
|
||||||
const payload = JSON.stringify({
|
const payload = JSON.stringify({
|
||||||
id,
|
id,
|
||||||
event,
|
event,
|
||||||
@@ -489,8 +571,8 @@ async function deliverWebhook(account, event, data) {
|
|||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
await saveWebhookDeliveries()
|
|
||||||
scheduleWebhookDelivery(id)
|
scheduleWebhookDelivery(id)
|
||||||
|
await queueWebhookDeliveriesSave()
|
||||||
}
|
}
|
||||||
|
|
||||||
function queuedOutboundMessage(job) {
|
function queuedOutboundMessage(job) {
|
||||||
@@ -553,7 +635,7 @@ async function processOutboundJob(jobId) {
|
|||||||
job.status = 'queued'
|
job.status = 'queued'
|
||||||
job.nextAttemptAt = new Date(Date.now() + 5_000).toISOString()
|
job.nextAttemptAt = new Date(Date.now() + 5_000).toISOString()
|
||||||
job.updatedAt = new Date().toISOString()
|
job.updatedAt = new Date().toISOString()
|
||||||
await saveOutboundJobs()
|
await queueOutboundJobsSave()
|
||||||
scheduleOutboundJob(job.id, 5_000)
|
scheduleOutboundJob(job.id, 5_000)
|
||||||
return queuedOutboundMessage(job)
|
return queuedOutboundMessage(job)
|
||||||
}
|
}
|
||||||
@@ -563,7 +645,7 @@ async function processOutboundJob(jobId) {
|
|||||||
job.throttled = true
|
job.throttled = true
|
||||||
job.nextAttemptAt = new Date(Date.now() + throttleDelay).toISOString()
|
job.nextAttemptAt = new Date(Date.now() + throttleDelay).toISOString()
|
||||||
job.updatedAt = new Date().toISOString()
|
job.updatedAt = new Date().toISOString()
|
||||||
await saveOutboundJobs()
|
await queueOutboundJobsSave()
|
||||||
scheduleOutboundJob(job.id, throttleDelay)
|
scheduleOutboundJob(job.id, throttleDelay)
|
||||||
return queuedOutboundMessage(job)
|
return queuedOutboundMessage(job)
|
||||||
}
|
}
|
||||||
@@ -571,7 +653,7 @@ async function processOutboundJob(jobId) {
|
|||||||
job.status = 'sending'
|
job.status = 'sending'
|
||||||
job.attempts = Number(job.attempts || 0) + 1
|
job.attempts = Number(job.attempts || 0) + 1
|
||||||
job.updatedAt = new Date().toISOString()
|
job.updatedAt = new Date().toISOString()
|
||||||
await saveOutboundJobs()
|
void queueOutboundJobsSave()
|
||||||
try {
|
try {
|
||||||
const store = await loadStore(job.accountId)
|
const store = await loadStore(job.accountId)
|
||||||
const quoted = job.quotedMessageId
|
const quoted = job.quotedMessageId
|
||||||
@@ -655,7 +737,7 @@ async function processOutboundJob(jobId) {
|
|||||||
} finally {
|
} finally {
|
||||||
job.updatedAt = new Date().toISOString()
|
job.updatedAt = new Date().toISOString()
|
||||||
outboundJobsProcessing.delete(job.id)
|
outboundJobsProcessing.delete(job.id)
|
||||||
await saveOutboundJobs()
|
await queueOutboundJobsSave()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -671,10 +753,13 @@ async function connectAccount(accountId) {
|
|||||||
const authPath = path.join(authDir, accountId)
|
const authPath = path.join(authDir, accountId)
|
||||||
await fs.mkdir(authPath, { recursive: true, mode: 0o700 })
|
await fs.mkdir(authPath, { recursive: true, mode: 0o700 })
|
||||||
const { state, saveCreds } = await useMultiFileAuthState(authPath)
|
const { state, saveCreds } = await useMultiFileAuthState(authPath)
|
||||||
const { version } = await fetchLatestBaileysVersion()
|
|
||||||
const logger = log.child({ accountId })
|
const logger = log.child({ accountId })
|
||||||
const proxyUrl = accountProxyUrl(account)
|
const proxyUrl = accountProxyUrl(account)
|
||||||
const proxyAgent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined
|
const proxyAgent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined
|
||||||
|
const fetchDispatcher = proxyUrl ? new UndiciProxyAgent(proxyUrl) : undefined
|
||||||
|
const { version } = await fetchLatestBaileysVersion(
|
||||||
|
fetchDispatcher ? { dispatcher: fetchDispatcher } : {},
|
||||||
|
)
|
||||||
const sock = makeWASocket({
|
const sock = makeWASocket({
|
||||||
version,
|
version,
|
||||||
logger,
|
logger,
|
||||||
@@ -684,7 +769,11 @@ async function connectAccount(accountId) {
|
|||||||
},
|
},
|
||||||
browser: Browsers.ubuntu('Lingxi Crossborder'),
|
browser: Browsers.ubuntu('Lingxi Crossborder'),
|
||||||
...(proxyAgent ? { agent: proxyAgent, fetchAgent: proxyAgent } : {}),
|
...(proxyAgent ? { agent: proxyAgent, fetchAgent: proxyAgent } : {}),
|
||||||
|
...(fetchDispatcher ? { options: { dispatcher: fetchDispatcher } } : {}),
|
||||||
syncFullHistory: true,
|
syncFullHistory: true,
|
||||||
|
// Baileys 7 excludes FULL history notifications by default even when
|
||||||
|
// syncFullHistory is enabled. Accept every server-provided history phase.
|
||||||
|
shouldSyncHistoryMessage: () => true,
|
||||||
markOnlineOnConnect: false,
|
markOnlineOnConnect: false,
|
||||||
generateHighQualityLinkPreview: true,
|
generateHighQualityLinkPreview: true,
|
||||||
getMessage: async (key) => {
|
getMessage: async (key) => {
|
||||||
@@ -693,7 +782,7 @@ async function connectAccount(accountId) {
|
|||||||
},
|
},
|
||||||
cachedGroupMetadata: async (jid) => store.groups.get(jid),
|
cachedGroupMetadata: async (jid) => store.groups.get(jid),
|
||||||
})
|
})
|
||||||
sessions.set(accountId, { sock, connecting: true })
|
sessions.set(accountId, { sock, connecting: true, fetchDispatcher })
|
||||||
account.status = state.creds.registered ? 'connecting' : 'waiting_pairing'
|
account.status = state.creds.registered ? 'connecting' : 'waiting_pairing'
|
||||||
account.updatedAt = new Date().toISOString()
|
account.updatedAt = new Date().toISOString()
|
||||||
await saveAccounts()
|
await saveAccounts()
|
||||||
@@ -716,11 +805,24 @@ async function connectAccount(accountId) {
|
|||||||
id: jidNormalizedUser(sock.user.id),
|
id: jidNormalizedUser(sock.user.id),
|
||||||
name: sock.user.name || account.name || account.phoneNumber,
|
name: sock.user.name || account.name || account.phoneNumber,
|
||||||
} : account.user
|
} : account.user
|
||||||
|
delete account.lastError
|
||||||
delete account.qrDataUrl
|
delete account.qrDataUrl
|
||||||
delete account.pairingCode
|
delete account.pairingCode
|
||||||
const session = sessions.get(accountId)
|
const session = sessions.get(accountId)
|
||||||
if (session) session.connecting = false
|
if (session) session.connecting = false
|
||||||
await saveAccounts()
|
await saveAccounts()
|
||||||
|
const processedIds = new Set(
|
||||||
|
(state.creds.processedHistoryMessages || []).map((item) => String(item?.key?.id || '')),
|
||||||
|
)
|
||||||
|
void replayStoredHistoryNotifications(
|
||||||
|
accountId,
|
||||||
|
account,
|
||||||
|
store,
|
||||||
|
logger,
|
||||||
|
processedIds,
|
||||||
|
fetchDispatcher ? { dispatcher: fetchDispatcher } : {},
|
||||||
|
)
|
||||||
|
.catch((error) => logger.warn({ err: error }, 'stored history recovery failed'))
|
||||||
for (const job of outboundJobs.values()) {
|
for (const job of outboundJobs.values()) {
|
||||||
if (job.accountId === accountId && ['queued', 'retrying', 'sending'].includes(job.status)) {
|
if (job.accountId === accountId && ['queued', 'retrying', 'sending'].includes(job.status)) {
|
||||||
scheduleOutboundJob(job.id)
|
scheduleOutboundJob(job.id)
|
||||||
@@ -732,7 +834,9 @@ async function connectAccount(accountId) {
|
|||||||
if (sessions.get(accountId)?.sock !== sock) return
|
if (sessions.get(accountId)?.sock !== sock) return
|
||||||
const statusCode = new Boom(update.lastDisconnect?.error).output?.statusCode
|
const statusCode = new Boom(update.lastDisconnect?.error).output?.statusCode
|
||||||
const loggedOut = statusCode === DisconnectReason.loggedOut
|
const loggedOut = statusCode === DisconnectReason.loggedOut
|
||||||
|
const session = sessions.get(accountId)
|
||||||
sessions.delete(accountId)
|
sessions.delete(accountId)
|
||||||
|
if (session?.fetchDispatcher) void session.fetchDispatcher.close().catch(() => undefined)
|
||||||
account.status = loggedOut ? 'logged_out' : 'disconnected'
|
account.status = loggedOut ? 'logged_out' : 'disconnected'
|
||||||
account.connectionState = loggedOut ? 'logged_out' : 'reconnecting'
|
account.connectionState = loggedOut ? 'logged_out' : 'reconnecting'
|
||||||
account.lastError = update.lastDisconnect?.error?.message || `连接关闭 (${statusCode || 'unknown'})`
|
account.lastError = update.lastDisconnect?.error?.message || `连接关闭 (${statusCode || 'unknown'})`
|
||||||
@@ -752,7 +856,9 @@ async function connectAccount(accountId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
sock.ev.on('messaging-history.set', ({ chats = [], contacts = [], messages = [] }) => {
|
sock.ev.on('messaging-history.set', ({
|
||||||
|
chats = [], contacts = [], messages = [], isLatest, progress, syncType, chunkOrder,
|
||||||
|
}) => {
|
||||||
for (const chat of chats) {
|
for (const chat of chats) {
|
||||||
const id = String(chat.id || '')
|
const id = String(chat.id || '')
|
||||||
if (id) store.chats.set(id, { ...(store.chats.get(id) || {}), ...jsonSafe(chat), id })
|
if (id) store.chats.set(id, { ...(store.chats.get(id) || {}), ...jsonSafe(chat), id })
|
||||||
@@ -762,13 +868,40 @@ async function connectAccount(accountId) {
|
|||||||
if (id) store.contacts.set(id, { ...(store.contacts.get(id) || {}), ...jsonSafe(contact), id })
|
if (id) store.contacts.set(id, { ...(store.contacts.get(id) || {}), ...jsonSafe(contact), id })
|
||||||
}
|
}
|
||||||
for (const item of messages) upsertMessage(accountId, item)
|
for (const item of messages) upsertMessage(accountId, item)
|
||||||
|
account.historySync = {
|
||||||
|
status: isLatest || progress === 100 ? 'processing' : 'syncing',
|
||||||
|
syncType: syncType ?? null,
|
||||||
|
progress: progress ?? null,
|
||||||
|
chunkOrder: chunkOrder ?? null,
|
||||||
|
chats: chats.length,
|
||||||
|
contacts: contacts.length,
|
||||||
|
messages: messages.length,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
void saveAccounts()
|
||||||
scheduleStoreSave(accountId)
|
scheduleStoreSave(accountId)
|
||||||
void deliverWebhook(account, 'history.synced', {
|
void deliverWebhook(account, 'history.synced', {
|
||||||
chats: chats.length,
|
chats: chats.length,
|
||||||
contacts: contacts.length,
|
contacts: contacts.length,
|
||||||
messages: messages.length,
|
messages: messages.length,
|
||||||
|
isLatest: Boolean(isLatest),
|
||||||
|
progress: progress ?? null,
|
||||||
|
syncType: syncType ?? null,
|
||||||
|
chunkOrder: chunkOrder ?? null,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
sock.ev.on('messaging-history.status', ({ syncType, status, explicit }) => {
|
||||||
|
account.historySync = {
|
||||||
|
...(account.historySync || {}),
|
||||||
|
status,
|
||||||
|
syncType,
|
||||||
|
explicit: Boolean(explicit),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
...(status === 'complete' ? { completedAt: new Date().toISOString() } : {}),
|
||||||
|
}
|
||||||
|
void saveAccounts()
|
||||||
|
void deliverWebhook(account, 'history.status', account.historySync)
|
||||||
|
})
|
||||||
sock.ev.on('chats.upsert', (items) => {
|
sock.ev.on('chats.upsert', (items) => {
|
||||||
for (const chat of items || []) {
|
for (const chat of items || []) {
|
||||||
const id = String(chat.id || '')
|
const id = String(chat.id || '')
|
||||||
@@ -1307,7 +1440,7 @@ app.post('/accounts/:id/backup/restore', async (req, res) => {
|
|||||||
if (delivery.accountId === account.id) webhookDeliveries.delete(id)
|
if (delivery.accountId === account.id) webhookDeliveries.delete(id)
|
||||||
}
|
}
|
||||||
for (const delivery of snapshot.webhookDeliveries || []) webhookDeliveries.set(delivery.id, delivery)
|
for (const delivery of snapshot.webhookDeliveries || []) webhookDeliveries.set(delivery.id, delivery)
|
||||||
await Promise.all([saveAccounts(), saveOutboundJobs(), saveWebhookDeliveries()])
|
await Promise.all([saveAccounts(), queueOutboundJobsSave(), queueWebhookDeliveriesSave()])
|
||||||
await connectAccount(account.id)
|
await connectAccount(account.id)
|
||||||
res.json({ code: 0, data: { restored: true, account: accountPublic(account) } })
|
res.json({ code: 0, data: { restored: true, account: accountPublic(account) } })
|
||||||
})
|
})
|
||||||
@@ -1463,16 +1596,21 @@ app.get('/accounts/:id/chats/:jid/identity', async (req, res) => {
|
|||||||
phoneJid = jid
|
phoneJid = jid
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
phoneJid = jidNormalizedUser(phoneJid)
|
||||||
const contact = store.contacts.get(jid) || store.contacts.get(phoneJid) || {}
|
const contact = store.contacts.get(jid) || store.contacts.get(phoneJid) || {}
|
||||||
const group = jid.endsWith('@g.us')
|
const group = jid.endsWith('@g.us')
|
||||||
? await sock.groupMetadata(jid).catch(() => store.groups.get(jid))
|
? await sock.groupMetadata(jid).catch(() => store.groups.get(jid))
|
||||||
: null
|
: null
|
||||||
const avatarUrl = await sock.profilePictureUrl(jid, 'image')
|
const includeProfile = req.query.includeProfile !== 'false'
|
||||||
|
const avatarUrl = includeProfile
|
||||||
|
? await sock.profilePictureUrl(jid, 'image')
|
||||||
.catch(() => phoneJid !== jid ? sock.profilePictureUrl(phoneJid, 'image').catch(() => '') : '')
|
.catch(() => phoneJid !== jid ? sock.profilePictureUrl(phoneJid, 'image').catch(() => '') : '')
|
||||||
|
: ''
|
||||||
res.json({
|
res.json({
|
||||||
code: 0,
|
code: 0,
|
||||||
data: {
|
data: {
|
||||||
id: jid,
|
id: jid,
|
||||||
|
canonicalJid: phoneJid.endsWith('@s.whatsapp.net') ? phoneJid : jid,
|
||||||
name: group?.subject || contact.name || contact.notify || contact.verifiedName || jid.split('@')[0],
|
name: group?.subject || contact.name || contact.notify || contact.verifiedName || jid.split('@')[0],
|
||||||
phone: phoneJid.endsWith('@s.whatsapp.net') ? phoneJid.split('@')[0] : '',
|
phone: phoneJid.endsWith('@s.whatsapp.net') ? phoneJid.split('@')[0] : '',
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
@@ -1555,7 +1693,7 @@ app.post('/accounts/:id/messages/send', async (req, res) => {
|
|||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
outboundJobs.set(job.id, job)
|
outboundJobs.set(job.id, job)
|
||||||
await saveOutboundJobs()
|
await queueOutboundJobsSave()
|
||||||
const result = await processOutboundJob(job.id)
|
const result = await processOutboundJob(job.id)
|
||||||
res.json({ code: 0, data: result || queuedOutboundMessage(job) })
|
res.json({ code: 0, data: result || queuedOutboundMessage(job) })
|
||||||
})
|
})
|
||||||
@@ -1668,7 +1806,7 @@ app.post('/accounts/:id/outbound-jobs/:jobId/retry', async (req, res) => {
|
|||||||
delete job.deadAt
|
delete job.deadAt
|
||||||
delete job.lastError
|
delete job.lastError
|
||||||
delete job.nextAttemptAt
|
delete job.nextAttemptAt
|
||||||
await saveOutboundJobs()
|
await queueOutboundJobsSave()
|
||||||
const result = await processOutboundJob(job.id)
|
const result = await processOutboundJob(job.id)
|
||||||
res.json({ code: 0, data: result || queuedOutboundMessage(job) })
|
res.json({ code: 0, data: result || queuedOutboundMessage(job) })
|
||||||
})
|
})
|
||||||
@@ -1727,7 +1865,7 @@ app.post('/accounts/:id/messages/media', upload.single('file'), async (req, res)
|
|||||||
job.filePath = path.join(outboundMediaDir, `${job.id}.bin`)
|
job.filePath = path.join(outboundMediaDir, `${job.id}.bin`)
|
||||||
await fs.writeFile(job.filePath, req.file.buffer, { mode: 0o600 })
|
await fs.writeFile(job.filePath, req.file.buffer, { mode: 0o600 })
|
||||||
outboundJobs.set(job.id, job)
|
outboundJobs.set(job.id, job)
|
||||||
await saveOutboundJobs()
|
await queueOutboundJobsSave()
|
||||||
const result = await processOutboundJob(job.id)
|
const result = await processOutboundJob(job.id)
|
||||||
res.json({ code: 0, data: result || queuedOutboundMessage(job) })
|
res.json({ code: 0, data: result || queuedOutboundMessage(job) })
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user