Commit c37b3aa0 by Archer Committed by GitHub

wechat publish (#6607)

* wechat publish

* update test

* doc
parent c84c4539
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
1. 输出语言:中文 1. 输出语言:中文
2. 输出的设计文档位置:.claude/design,问题分析文档位置: .claude/issue,以 Markdown 文件为主。 2. 输出的设计文档位置:.claude/design,问题分析文档位置: .claude/issue,以 Markdown 文件为主。
3. 输出 Plan 时,均需写入 .claude/plan 目录下,以 Markdown 文件为主。 3. 输出 Plan 时,均需写入 .claude/plan 目录下,以 Markdown 文件为主。
4. 相同需求文档,尽量写在一起,或者创建要给目录一起管理,不要随意平铺一堆不同版本的相同问题的文档。 4. 相同需求文档,尽量写在一起(内容超过 300 行,可以分批写入),或者创建要给目录一起管理,不要随意平铺一堆不同版本的相同问题的文档。
5. 文件输出,使用正确的编码格式,例如UTF-8。 5. 文件输出,使用正确的编码格式,例如UTF-8。
6. 如果用户未指明,不要随意编写总结报告。 6. 如果用户未指明,不要随意编写总结报告。
......
...@@ -9,6 +9,10 @@ description: 当用户需要设计 FastGPT 的代码时,可调用此 Skill。 ...@@ -9,6 +9,10 @@ description: 当用户需要设计 FastGPT 的代码时,可调用此 Skill。
* [AI 虚拟机设计文档](./core/ai/sandbox/prd.md) * [AI 虚拟机设计文档](./core/ai/sandbox/prd.md)
### Agent 构建
* [微信个人号发布渠道设计文档](./support/outlink/wechat-clawbot.md)
### 工作流 ### 工作流
* [工作流设计文档](./core/workflow/index.md) * [工作流设计文档](./core/workflow/index.md)
......
# 微信个人号(ClawBot) - 设计文档
## 1. 架构概览
```
┌──────────────────── BullMQ ────────────────────────────┐
│ │
│ Queue: wechatPoll │
│ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ poll │ │ poll │ │ poll │ ... │
│ │ ch_1 │ │ ch_2 │ │ ch_3 │ │
│ └──┬───┘ └──┬───┘ └──┬───┘ │
│ │ │ │ │
│ └──────────┴──────────┘ │
│ │ │
│ Worker (concurrency: 10) │
│ │ │
│ ┌──────────┴──────────┐ │
│ │ 1. getUpdates() │ │
│ │ 2. 按用户分组合并 │ │
│ │ 3. outlinkInvokeChat│ │
│ │ 4. sendMessage() │ │
│ │ 5. 更新 buf │ │
│ │ 6. 自链: queue.add │ ←── 完成后立刻创建下一个 │
│ └─────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
多节点部署:
Node A ──┐
Node B ──┼── 同一个 Redis ── 同一个 Queue
Node C ──┘ BullMQ 自动保证同一个 Job 只被一个 Worker 消费
```
## 2. 核心流程
### 2.1 Job 生命周期
```
渠道上线(扫码登录成功)
queue.add('poll', { shareId }, { jobId: `wechat-poll-${shareId}-${ts}` })
Worker 消费 Job
├── 1. 从数据库读取 buf 和 token
├── 2. 检查渠道状态(离线 → 不续链,轮询自然停止)
├── 3. 调用 ilink getUpdates(buf)(长轮询,最多 35 秒)
├── 4. 收到消息 → groupMessagesByUser → 合并文本
├── 5. 对每组调用 outlinkInvokeChat → sendMessage 回复
├── 6. 更新 buf 到数据库
└── 7. 自链: queue.add 创建下一个 Job
```
### 2.2 渠道上下线控制
```
上线: 扫码成功 → status='online' → queue.add(首个 Job)
下线: 用户登出/删除 → status='offline' → Worker 检测后不续链
异常: 连续失败 ≥5 次 → status='error' → 不续链
重连: 用户重新扫码 → 清空 syncBuf → 同上线流程
```
## 3. 类型定义
### 3.1 WechatAppType
```typescript
// packages/global/support/outLink/type.ts
export const WechatAppSchema = z.object({
token: z.string().default(''),
baseUrl: z.string().default('https://ilinkai.weixin.qq.com'),
accountId: z.string().default(''),
userId: z.string().optional(),
syncBuf: z.string().default(''),
status: z.enum(['online', 'offline', 'error']).default('offline'),
loginTime: z.string().optional(),
lastError: z.string().optional()
});
export type WechatAppType = z.infer<typeof WechatAppSchema>;
```
### 3.2 BullMQ Job 数据
```typescript
// packages/service/support/outLink/wechat/type.ts
export type WechatPollJobData = { shareId: string };
```
## 4. 关键设计决策
### 4.1 为什么用自链式而不是 Repeatable
| | Repeatable | 自链式 |
|--|-----------|--------|
| 消息延迟 | 固定间隔(如 30s) | 实时(ilink 长轮询) |
| Job 重叠 | 会(定时无脑创建) | 不会(处理完才创建下一个) |
| 停止方式 | 需要删除 repeatable key | 不续链即可,天然停止 |
| 多节点安全 | BullMQ 保证 | BullMQ 保证 |
### 4.2 Worker 参数
| 参数 | 值 | 说明 |
|------|-----|------|
| concurrency | 10 | 单实例同时处理 10 个渠道(I/O 密集,不占 CPU) |
| lockDuration | 120s | getUpdates 35s + 工作流 60s + sendMessage = ~100s,留余量 |
| stalledInterval | 60s | 检测 stalled Job |
| removeOnComplete | count: 0 | 完成即删 |
| removeOnFail | count: 100, age: 7d | 保留最近 100 条失败记录 |
### 4.3 错误处理策略
| 错误类型 | 处理 |
|---------|------|
| 网络超时 | 正常(getUpdates 35s 超时),续链 |
| API 返回错误 | 记录失败计数,延迟 10s 续链 |
| 连续失败 ≥ 5 次 | 标记 status='error',停止续链 |
| 渠道被删除 | outLink 查不到,不续链 |
| 工作流处理失败 | 发送 defaultResponse 给用户,续链继续 |
### 4.4 重连时 buf 清空
重连时清空 `syncBuf` 是正确的。新 token 对应新 session,旧 buf 在 ilink 服务端已失效。清空后首次 getUpdates 会返回新的 buf。
## 5. 数据库索引
```typescript
// packages/service/support/outLink/schema.ts
OutLinkSchema.index({ shareId: -1 });
OutLinkSchema.index({ teamId: 1, tmbId: 1, appId: 1 });
// 条件索引: 仅索引 wechat online 渠道,用于服务重启恢复
OutLinkSchema.index(
{ type: 1, 'app.status': 1 },
{ partialFilterExpression: { type: 'wechat', 'app.status': 'online' } }
);
```
## 6. Redis Key 清单
| Key | 用途 | TTL |
|-----|------|-----|
| `publish:wechat:qrcode:${shareId}` | 二维码临时存储 | 480s |
| `publish:wechat:failures:${shareId}` | 连续失败计数 | 300s |
## 7. 文件清单
### 修改现有文件
| 文件 | 改动 |
|------|------|
| `packages/global/support/outLink/constant.ts` | `PublishChannelEnum` 新增 `wechat` |
| `packages/global/support/outLink/type.ts` | 新增 `WechatAppSchema` / `WechatAppType` |
| `packages/global/core/chat/constants.ts` | `ChatSourceEnum` / `ChatSourceMap` 新增 wechat |
| `packages/global/support/wallet/usage/constants.ts` | `UsageSourceEnum` / `UsageSourceMap` 新增 wechat |
| `packages/global/support/wallet/usage/tools.ts` | `getUsageSourceByPublishChannel` 新增 case |
| `packages/global/core/chat/utils.ts` | `getChatSourceByPublishChannel` 新增 case |
| `packages/web/i18n/zh-CN/publish.json` | wechat 相关 i18n |
| `packages/web/i18n/en/publish.json` | wechat 相关 i18n |
| `packages/web/i18n/zh-Hant/publish.json` | wechat 相关 i18n |
| `packages/service/common/bullmq/index.ts` | `QueueNames` 新增 `wechatPoll` |
| `packages/service/support/outLink/schema.ts` | 新增条件索引 |
| `projects/app/src/pageComponents/app/detail/Publish/index.tsx` | 注册 wechat 渠道入口 |
| `projects/app/src/service/common/bullmq/index.ts` | 注册 `initWechatPollWorker` + `resumeAllWechatPolling` |
### 新建文件
| 文件 | 说明 |
|------|------|
| `projects/app/src/pageComponents/app/detail/Publish/Wechat/index.tsx` | 渠道列表(含状态、扫码登录入口) |
| `projects/app/src/pageComponents/app/detail/Publish/Wechat/WechatEditModal.tsx` | 创建/编辑弹窗(name + maxUsagePoints) |
| `projects/app/src/pageComponents/app/detail/Publish/Wechat/QRLoginModal.tsx` | 扫码登录弹窗(二维码展示 + 状态轮询) |
| `packages/service/support/outLink/wechat/ilinkClient.ts` | ilink API 客户端(QR 登录 + 消息收发) |
| `packages/service/support/outLink/wechat/type.ts` | `WechatPollJobData` 类型 |
| `packages/service/support/outLink/wechat/messageParser.ts` | 消息解析纯函数(extractTextFromItem + groupMessagesByUser) |
| `packages/service/support/outLink/wechat/mq.ts` | BullMQ Worker + 轮询调度 |
| `projects/app/src/pages/api/support/outLink/wechat/qrcode/generate.ts` | 二维码生成 API |
| `projects/app/src/pages/api/support/outLink/wechat/qrcode/status.ts` | 扫码状态查询 API(confirmed 时保存 token + 启动轮询) |
| `projects/app/src/pages/api/support/outLink/wechat/logout.ts` | 登出 API(status → offline,清空 token) |
| `test/cases/service/support/outLink/wechat/messageParser.test.ts` | 消息解析单元测试(16 cases) |
...@@ -38,13 +38,18 @@ jobs: ...@@ -38,13 +38,18 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-sandbox-buildx- ${{ runner.os }}-sandbox-buildx-
# login github # ghcr + any extra names in outputs (Ali is pushed only in release via imagetools)
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_NAME }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Build for ${{ matrix.arch }} - name: Build for ${{ matrix.arch }}
id: build id: build
...@@ -56,7 +61,7 @@ jobs: ...@@ -56,7 +61,7 @@ jobs:
labels: | labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }} org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.description=fastgpt-sandbox image org.opencontainers.image.description=fastgpt-sandbox image
outputs: type=image,"name=ghcr.io/${{ github.repository_owner }}/fastgpt-sandbox,${{ secrets.FASTGPT_ALI_IMAGE_PREFIX }}/fastgpt-sandbox,${{ secrets.DOCKER_IMAGE_NAME }}/fastgpt-sandbox",push-by-digest=true,push=true outputs: type=image,"name=ghcr.io/${{ github.repository_owner }}/fastgpt-sandbox,${{ secrets.DOCKER_IMAGE_NAME }}/fastgpt-sandbox",push-by-digest=true,push=true
cache-from: type=local,src=/tmp/.buildx-cache cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache cache-to: type=local,dest=/tmp/.buildx-cache
......
...@@ -36,10 +36,11 @@ CODE_SANDBOX_TOKEN=代码运行沙盒的凭证(可以为空,4.14.8 新增加 ...@@ -36,10 +36,11 @@ CODE_SANDBOX_TOKEN=代码运行沙盒的凭证(可以为空,4.14.8 新增加
## 🚀 新增内容 ## 🚀 新增内容
1. 新增 AI 虚拟机功能,可以给 AI 挂载一个虚拟机工具进行更丰富的操作。(目前仅云服务开放使用,下个版本会推出轻量部署方案) 1. 新增 AI 虚拟机功能,可以给 AI 挂载一个虚拟机工具进行更丰富的操作。(目前仅云服务开放使用,下个版本会推出轻量部署方案)
2. AgentV2 上下文适配暂停态。 2. 发布渠道支持微信个人号。
3. 封装 logger sdk。增加 Metrics 追踪。 3. AgentV2 上下文适配暂停态。
4. 更新知识库单个数据时,同步更新 collection 更新时间。 4. 封装 logger sdk。增加 Metrics 追踪。
5. 表单输入文件时,支持打开文件进行预览。 5. 更新知识库单个数据时,同步更新 collection 更新时间。
6. 表单输入文件时,支持打开文件进行预览。
## ⚙️ 优化 ## ⚙️ 优化
......
...@@ -205,4 +205,5 @@ description: FastGPT Toc ...@@ -205,4 +205,5 @@ description: FastGPT Toc
- [/en/docs/use-cases/external-integration/feishu](/en/docs/use-cases/external-integration/feishu) - [/en/docs/use-cases/external-integration/feishu](/en/docs/use-cases/external-integration/feishu)
- [/en/docs/use-cases/external-integration/official_account](/en/docs/use-cases/external-integration/official_account) - [/en/docs/use-cases/external-integration/official_account](/en/docs/use-cases/external-integration/official_account)
- [/en/docs/use-cases/external-integration/openapi](/en/docs/use-cases/external-integration/openapi) - [/en/docs/use-cases/external-integration/openapi](/en/docs/use-cases/external-integration/openapi)
- [/en/docs/use-cases/external-integration/wechat](/en/docs/use-cases/external-integration/wechat)
- [/en/docs/use-cases/external-integration/wecom](/en/docs/use-cases/external-integration/wecom) - [/en/docs/use-cases/external-integration/wecom](/en/docs/use-cases/external-integration/wecom)
...@@ -205,4 +205,5 @@ description: FastGPT 文档目录 ...@@ -205,4 +205,5 @@ description: FastGPT 文档目录
- [/docs/use-cases/external-integration/feishu](/docs/use-cases/external-integration/feishu) - [/docs/use-cases/external-integration/feishu](/docs/use-cases/external-integration/feishu)
- [/docs/use-cases/external-integration/official_account](/docs/use-cases/external-integration/official_account) - [/docs/use-cases/external-integration/official_account](/docs/use-cases/external-integration/official_account)
- [/docs/use-cases/external-integration/openapi](/docs/use-cases/external-integration/openapi) - [/docs/use-cases/external-integration/openapi](/docs/use-cases/external-integration/openapi)
- [/docs/use-cases/external-integration/wechat](/docs/use-cases/external-integration/wechat)
- [/docs/use-cases/external-integration/wecom](/docs/use-cases/external-integration/wecom) - [/docs/use-cases/external-integration/wecom](/docs/use-cases/external-integration/wecom)
{ {
"title": "External FastGPT Integration", "title": "External FastGPT Integration",
"description": "Tutorial on external applications calling FastGPT features through various methods", "description": "Tutorial on external applications calling FastGPT features through various methods",
"pages": [ "pages": ["openapi", "wechat", "feishu", "dingtalk", "wecom", "official_account"]
"openapi",
"feishu",
"dingtalk",
"wecom",
"official_account"
]
} }
{ {
"title": "外部调用 FastGPT", "title": "外部调用 FastGPT",
"description": "外部应用通过多种方式调用 FastGPT 功能的教程", "description": "外部应用通过多种方式调用 FastGPT 功能的教程",
"pages": ["openapi", "feishu", "dingtalk", "wecom", "official_account"] "pages": ["openapi", "wechat", "feishu", "dingtalk", "wecom", "official_account"]
} }
---
title: WeChat Personal Account Integration
description: How to integrate FastGPT with a WeChat personal account
---
## 1. Create a Publishing Channel
Open your FastGPT Agent, click the tab at the top to switch to **Publishing Channels**, select **WeChat Personal Account**, and click **Create**.
![alt text](../../../../public/imgs/image-3.png)
You can fill in the form fields as needed.
## 2. Scan QR Code to Log In
After creating the channel, a new entry will appear. For the first time, you need to scan a QR code to log in. Click **Scan QR Code to Log In** to display the login QR code.
![alt text](../../../../public/imgs/image-4.png)
![alt text](../../../../public/imgs/image-17.png)
## 3. Start Chatting
Once connected via QR code, a **WeChat ClawBot** will appear in your contacts list.
![alt text](../../../../public/imgs/image-25.png)
Click to open the conversation and start chatting!
![alt text](../../../../public/imgs/image-26.png)
## FAQ
### How to Reset a Chat
Type `Reset` or `/reset` in the input field to clear the chat history.
---
title: 接入微信个人号教程
description: FastGPT 接入微信个人号教程
---
## 1. 新建发布渠道
进入 FastGPT 搭建好的 Agent,点击顶部的 tab 切换到发布渠道,并选择`微信个人号`,点击新建。
![alt text](../../../../public/imgs/image-3.png)
表单内容随便填写即可。
## 2. 扫码登录
确认创建渠道后,会多出一条记录,首次需要扫码登录,点击扫码登录,即可跳出登录二维码。
![alt text](../../../../public/imgs/image-4.png)
![alt text](../../../../public/imgs/image-17.png)
## 3. 愉快玩耍
扫码连接后,好友列表就会多出一个`微信 ClawBot`的机器人可使用。
![alt text](../../../../public/imgs/image-25.png)
点击进入后,即可开始聊天啦!
![alt text](../../../../public/imgs/image-26.png)
## FAQ
### 微信没找到入口
目前仅支持 IOS 系统,并且需要升级最新版本微信。
### 如何重置聊天
输入框输入:`Reset`或者`/reset` 即可重置聊天记录。
\ No newline at end of file
...@@ -235,10 +235,10 @@ ...@@ -235,10 +235,10 @@
"document/content/docs/self-host/upgrading/4-14/4147.en.mdx": "2026-03-03T17:39:47+08:00", "document/content/docs/self-host/upgrading/4-14/4147.en.mdx": "2026-03-03T17:39:47+08:00",
"document/content/docs/self-host/upgrading/4-14/4147.mdx": "2026-03-03T17:39:47+08:00", "document/content/docs/self-host/upgrading/4-14/4147.mdx": "2026-03-03T17:39:47+08:00",
"document/content/docs/self-host/upgrading/4-14/4148.en.mdx": "2026-03-06T19:32:23+08:00", "document/content/docs/self-host/upgrading/4-14/4148.en.mdx": "2026-03-06T19:32:23+08:00",
"document/content/docs/self-host/upgrading/4-14/4148.mdx": "2026-03-09T17:39:53+08:00", "document/content/docs/self-host/upgrading/4-14/4148.mdx": "2026-03-22T17:58:45+08:00",
"document/content/docs/self-host/upgrading/4-14/41481.en.mdx": "2026-03-09T12:02:02+08:00", "document/content/docs/self-host/upgrading/4-14/41481.en.mdx": "2026-03-09T12:02:02+08:00",
"document/content/docs/self-host/upgrading/4-14/41481.mdx": "2026-03-09T17:39:53+08:00", "document/content/docs/self-host/upgrading/4-14/41481.mdx": "2026-03-09T17:39:53+08:00",
"document/content/docs/self-host/upgrading/4-14/4149.mdx": "2026-03-22T17:40:08+08:00", "document/content/docs/self-host/upgrading/4-14/4149.mdx": "2026-03-23T11:33:27+08:00",
"document/content/docs/self-host/upgrading/outdated/40.en.mdx": "2026-03-03T17:39:47+08:00", "document/content/docs/self-host/upgrading/outdated/40.en.mdx": "2026-03-03T17:39:47+08:00",
"document/content/docs/self-host/upgrading/outdated/40.mdx": "2026-03-03T17:39:47+08:00", "document/content/docs/self-host/upgrading/outdated/40.mdx": "2026-03-03T17:39:47+08:00",
"document/content/docs/self-host/upgrading/outdated/41.en.mdx": "2026-03-03T17:39:47+08:00", "document/content/docs/self-host/upgrading/outdated/41.en.mdx": "2026-03-03T17:39:47+08:00",
...@@ -379,8 +379,8 @@ ...@@ -379,8 +379,8 @@
"document/content/docs/self-host/upgrading/outdated/499.mdx": "2026-03-03T17:39:47+08:00", "document/content/docs/self-host/upgrading/outdated/499.mdx": "2026-03-03T17:39:47+08:00",
"document/content/docs/self-host/upgrading/upgrade-intruction.en.mdx": "2026-03-03T17:39:47+08:00", "document/content/docs/self-host/upgrading/upgrade-intruction.en.mdx": "2026-03-03T17:39:47+08:00",
"document/content/docs/self-host/upgrading/upgrade-intruction.mdx": "2026-03-03T17:39:47+08:00", "document/content/docs/self-host/upgrading/upgrade-intruction.mdx": "2026-03-03T17:39:47+08:00",
"document/content/docs/toc.en.mdx": "2026-03-21T12:19:44+08:00", "document/content/docs/toc.en.mdx": "2026-03-23T11:33:27+08:00",
"document/content/docs/toc.mdx": "2026-03-21T12:19:44+08:00", "document/content/docs/toc.mdx": "2026-03-23T11:33:27+08:00",
"document/content/docs/use-cases/app-cases/dalle3.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/use-cases/app-cases/dalle3.en.mdx": "2026-02-26T22:14:30+08:00",
"document/content/docs/use-cases/app-cases/dalle3.mdx": "2025-07-23T21:35:03+08:00", "document/content/docs/use-cases/app-cases/dalle3.mdx": "2025-07-23T21:35:03+08:00",
"document/content/docs/use-cases/app-cases/english_essay_correction_bot.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/use-cases/app-cases/english_essay_correction_bot.en.mdx": "2026-02-26T22:14:30+08:00",
...@@ -407,6 +407,8 @@ ...@@ -407,6 +407,8 @@
"document/content/docs/use-cases/external-integration/official_account.mdx": "2026-02-26T00:26:52+08:00", "document/content/docs/use-cases/external-integration/official_account.mdx": "2026-02-26T00:26:52+08:00",
"document/content/docs/use-cases/external-integration/openapi.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/use-cases/external-integration/openapi.en.mdx": "2026-02-26T22:14:30+08:00",
"document/content/docs/use-cases/external-integration/openapi.mdx": "2026-02-12T18:45:30+08:00", "document/content/docs/use-cases/external-integration/openapi.mdx": "2026-02-12T18:45:30+08:00",
"document/content/docs/use-cases/external-integration/wechat.en.mdx": "2026-03-23T11:33:27+08:00",
"document/content/docs/use-cases/external-integration/wechat.mdx": "2026-03-23T11:33:27+08:00",
"document/content/docs/use-cases/external-integration/wecom.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/use-cases/external-integration/wecom.en.mdx": "2026-02-26T22:14:30+08:00",
"document/content/docs/use-cases/external-integration/wecom.mdx": "2025-12-10T20:07:05+08:00", "document/content/docs/use-cases/external-integration/wecom.mdx": "2025-12-10T20:07:05+08:00",
"document/content/docs/use-cases/index.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/use-cases/index.en.mdx": "2026-02-26T22:14:30+08:00",
......
...@@ -71,6 +71,7 @@ export type FastGPTFeConfigsType = { ...@@ -71,6 +71,7 @@ export type FastGPTFeConfigsType = {
show_publish_dingtalk?: boolean; show_publish_dingtalk?: boolean;
show_publish_wecom?: boolean; show_publish_wecom?: boolean;
show_publish_offiaccount?: boolean; show_publish_offiaccount?: boolean;
show_publish_wechat?: boolean;
show_agent_sandbox?: boolean; show_agent_sandbox?: boolean;
show_dataset_enhance?: boolean; show_dataset_enhance?: boolean;
......
...@@ -32,6 +32,7 @@ export enum ChatSourceEnum { ...@@ -32,6 +32,7 @@ export enum ChatSourceEnum {
feishu = 'feishu', feishu = 'feishu',
official_account = 'official_account', official_account = 'official_account',
wecom = 'wecom', wecom = 'wecom',
wechat = 'wechat',
mcp = 'mcp' mcp = 'mcp'
} }
...@@ -72,6 +73,10 @@ export const ChatSourceMap = { ...@@ -72,6 +73,10 @@ export const ChatSourceMap = {
name: i18nT('common:core.chat.logs.wecom'), name: i18nT('common:core.chat.logs.wecom'),
color: '#FD853A' color: '#FD853A'
}, },
[ChatSourceEnum.wechat]: {
name: i18nT('common:core.chat.logs.wechat'),
color: '#07C160'
},
[ChatSourceEnum.mcp]: { [ChatSourceEnum.mcp]: {
name: i18nT('common:core.chat.logs.mcp'), name: i18nT('common:core.chat.logs.mcp'),
color: '#F97066' color: '#F97066'
......
...@@ -284,6 +284,8 @@ export const getChatSourceByPublishChannel = (publishChannel: PublishChannelEnum ...@@ -284,6 +284,8 @@ export const getChatSourceByPublishChannel = (publishChannel: PublishChannelEnum
return ChatSourceEnum.feishu; return ChatSourceEnum.feishu;
case PublishChannelEnum.wecom: case PublishChannelEnum.wecom:
return ChatSourceEnum.wecom; return ChatSourceEnum.wecom;
case PublishChannelEnum.wechat:
return ChatSourceEnum.wechat;
case PublishChannelEnum.officialAccount: case PublishChannelEnum.officialAccount:
return ChatSourceEnum.official_account; return ChatSourceEnum.official_account;
default: default:
......
...@@ -6,5 +6,6 @@ export enum PublishChannelEnum { ...@@ -6,5 +6,6 @@ export enum PublishChannelEnum {
dingtalk = 'dingtalk', dingtalk = 'dingtalk',
wecom = 'wecom', wecom = 'wecom',
officialAccount = 'official_account', officialAccount = 'official_account',
wechat = 'wechat',
playground = 'playground' playground = 'playground'
} }
...@@ -27,8 +27,17 @@ export interface WecomAppType { ...@@ -27,8 +27,17 @@ export interface WecomAppType {
// SuiteSecret: string; // SuiteSecret: string;
} }
// TODO: unused export const WechatAppSchema = z.object({
export interface WechatAppType {} token: z.string().default(''),
baseUrl: z.string().default('https://ilinkai.weixin.qq.com'),
accountId: z.string().default(''),
userId: z.string().optional(),
syncBuf: z.string().default(''),
status: z.enum(['online', 'offline', 'error']).default('offline'),
loginTime: z.string().optional(),
lastError: z.string().optional()
});
export type WechatAppType = z.infer<typeof WechatAppSchema>;
export interface OffiAccountAppType { export interface OffiAccountAppType {
appId: string; appId: string;
...@@ -46,6 +55,7 @@ export type OutlinkAppType = ...@@ -46,6 +55,7 @@ export type OutlinkAppType =
| WecomAppType | WecomAppType
| OffiAccountAppType | OffiAccountAppType
| DingtalkAppType | DingtalkAppType
| WechatAppType
| undefined; | undefined;
export type OutLinkSchema<T extends OutlinkAppType = undefined> = { export type OutLinkSchema<T extends OutlinkAppType = undefined> = {
......
...@@ -8,6 +8,7 @@ export enum UsageSourceEnum { ...@@ -8,6 +8,7 @@ export enum UsageSourceEnum {
cronJob = 'cronJob', cronJob = 'cronJob',
share = 'share', share = 'share',
wecom = 'wecom', wecom = 'wecom',
wechat = 'wechat',
feishu = 'feishu', feishu = 'feishu',
dingtalk = 'dingtalk', dingtalk = 'dingtalk',
official_account = 'official_account', official_account = 'official_account',
...@@ -46,6 +47,9 @@ export const UsageSourceMap = { ...@@ -46,6 +47,9 @@ export const UsageSourceMap = {
[UsageSourceEnum.wecom]: { [UsageSourceEnum.wecom]: {
label: i18nT('account_usage:wecom') label: i18nT('account_usage:wecom')
}, },
[UsageSourceEnum.wechat]: {
label: i18nT('account_usage:wechat')
},
[UsageSourceEnum.dingtalk]: { [UsageSourceEnum.dingtalk]: {
label: i18nT('account_usage:dingtalk') label: i18nT('account_usage:dingtalk')
}, },
......
...@@ -35,6 +35,8 @@ export const getUsageSourceByPublishChannel = (publishchannel: PublishChannelEnu ...@@ -35,6 +35,8 @@ export const getUsageSourceByPublishChannel = (publishchannel: PublishChannelEnu
return UsageSourceEnum.feishu; return UsageSourceEnum.feishu;
case PublishChannelEnum.wecom: case PublishChannelEnum.wecom:
return UsageSourceEnum.wecom; return UsageSourceEnum.wecom;
case PublishChannelEnum.wechat:
return UsageSourceEnum.wechat;
case PublishChannelEnum.officialAccount: case PublishChannelEnum.officialAccount:
return UsageSourceEnum.official_account; return UsageSourceEnum.official_account;
case PublishChannelEnum.dingtalk: case PublishChannelEnum.dingtalk:
......
...@@ -33,6 +33,10 @@ export enum QueueNames { ...@@ -33,6 +33,10 @@ export enum QueueNames {
datasetDelete = 'datasetDelete', datasetDelete = 'datasetDelete',
appDelete = 'appDelete', appDelete = 'appDelete',
teamDelete = 'teamDelete', teamDelete = 'teamDelete',
// Publish
wechatPoll = 'wechatPoll',
/** @deprecated */ /** @deprecated */
websiteSync = 'websiteSync' websiteSync = 'websiteSync'
} }
......
...@@ -93,7 +93,8 @@ export const LogCategories = { ...@@ -93,7 +93,8 @@ export const LogCategories = {
OFFIACCOUNT: ['outlink', 'offiaccount'], OFFIACCOUNT: ['outlink', 'offiaccount'],
PLAYGROUND: ['outlink', 'playground'], PLAYGROUND: ['outlink', 'playground'],
TOOLS: ['outlink', 'tools'], TOOLS: ['outlink', 'tools'],
WECOM: ['outlink', 'wecom'] WECOM: ['outlink', 'wecom'],
WECHAT: ['outlink', 'wechat']
}), }),
CHAT: Object.assign(['chat'], { CHAT: Object.assign(['chat'], {
FEEDBACK: ['chat', 'feedback'], FEEDBACK: ['chat', 'feedback'],
...@@ -133,9 +134,6 @@ export const LogCategories = { ...@@ -133,9 +134,6 @@ export const LogCategories = {
// 事件层 // 事件层
EVENT: { EVENT: {
OUTLINK: ['event', 'outlink'],
FEISHU: ['event', 'feishu'],
WECHAT: ['event', 'wechat'],
TRACK: ['event', 'track'] TRACK: ['event', 'track']
} }
}; };
......
...@@ -336,74 +336,71 @@ export const runAgentCall = async ({ ...@@ -336,74 +336,71 @@ export const runAgentCall = async ({
// 4. Call tools // 4. Call tools
let toolCallStep = false; let toolCallStep = false;
await Promise.all( for await (const tool of toolCalls) {
toolCalls.map(async (tool) => { const {
const { response,
assistantMessages: toolAssistantMessages,
usages: toolUsages,
interactive,
stop
} = await handleToolResponse({
call: tool,
messages: cloneRequestMessages
});
childrenUsages.push(...toolUsages);
usagePush?.(toolUsages);
// 5. Add tool response to messages
// 获取当前 messages 的 token 数,用于动态调整 tool response 的压缩阈值(防止下一个工具直接打爆上下文)
const currentMessagesTokens = await countGptMessagesTokens(requestMessages);
const { compressed: compressed_context, usage: compressionUsage } =
await compressToolResponse({
response, response,
assistantMessages: toolAssistantMessages, model: modelData,
usages: toolUsages, currentMessagesTokens,
interactive, toolLength: toolCalls.length,
stop reservedTokens: 8000 // 预留 8k tokens 给输出
} = await handleToolResponse({ });
if (compressionUsage) {
childrenUsages.push(compressionUsage);
usagePush?.([compressionUsage]);
onToolCompress?.({
call: tool, call: tool,
messages: cloneRequestMessages response: compressed_context,
usage: {
inputTokens: compressionUsage.inputTokens || 0,
outputTokens: compressionUsage.outputTokens || 0,
totalPoints: compressionUsage.totalPoints || 0
}
}); });
childrenUsages.push(...toolUsages); }
usagePush?.(toolUsages);
// 5. Add tool response to messages
// 获取当前 messages 的 token 数,用于动态调整 tool response 的压缩阈值(防止下一个工具直接打爆上下文)
const currentMessagesTokens = await countGptMessagesTokens(requestMessages);
const { compressed: compressed_context, usage: compressionUsage } =
await compressToolResponse({
response,
model: modelData,
currentMessagesTokens,
toolLength: toolCalls.length,
reservedTokens: 8000 // 预留 8k tokens 给输出
});
if (compressionUsage) {
childrenUsages.push(compressionUsage);
usagePush?.([compressionUsage]);
onToolCompress?.({
call: tool,
response: compressed_context,
usage: {
inputTokens: compressionUsage.inputTokens || 0,
outputTokens: compressionUsage.outputTokens || 0,
totalPoints: compressionUsage.totalPoints || 0
}
});
}
const toolMessage: ChatCompletionMessageParam = { const toolMessage: ChatCompletionMessageParam = {
tool_call_id: tool.id, tool_call_id: tool.id,
role: ChatCompletionRequestMessageRoleEnum.Tool, role: ChatCompletionRequestMessageRoleEnum.Tool,
content: compressed_context content: compressed_context
}; };
assistantMessages.push(toolMessage); assistantMessages.push(toolMessage);
requestMessages.push(toolMessage); requestMessages.push(toolMessage);
assistantMessages.push(...filterEmptyAssistantMessages(toolAssistantMessages)); // 因为 toolAssistantMessages 也需要记录成 AI 响应,所以这里需要推送。 assistantMessages.push(...filterEmptyAssistantMessages(toolAssistantMessages)); // 因为 toolAssistantMessages 也需要记录成 AI 响应,所以这里需要推送。
if (interactive) { if (interactive) {
interactiveResponse = { interactiveResponse = {
type: 'toolChildrenInteractive', type: 'toolChildrenInteractive',
params: { params: {
childrenResponse: interactive, childrenResponse: interactive,
toolParams: { toolParams: {
memoryRequestMessages: [], memoryRequestMessages: [],
toolCallId: tool.id toolCallId: tool.id
}
} }
}; }
} };
if (stop) { }
toolCallStep = true; if (stop) {
} toolCallStep = true;
}) }
); }
if (toolCalls.length === 0 || !!interactiveResponse || toolCallStep || isAborted?.()) { if (toolCalls.length === 0 || !!interactiveResponse || toolCallStep || isAborted?.()) {
break; break;
} }
......
import { authFrequencyLimit } from '../../../common/system/frequencyLimit/utils';
import type {
AuthOutLinkInitProps,
AuthOutLinkLimitProps,
AuthOutLinkResponse
} from '@fastgpt/global/support/outLink/api';
import { axios } from '../../../common/api/axios';
import { OutLinkErrEnum } from '@fastgpt/global/common/error/code/outLink';
import type { OutLinkSchema } from '@fastgpt/global/support/outLink/type';
import { addMinutes } from 'date-fns';
import { S3_KEY_PATH_INVALID_CHARS } from '../../../common/s3/constants';
import { UserError } from '@fastgpt/global/common/error/utils';
export type TokenAuthResponseType = {
success: boolean;
msg?: string;
message?: string;
data?: AuthOutLinkResponse;
};
export const authOutLinkInit = async ({
tokenUrl,
outLinkUid
}: AuthOutLinkInitProps): Promise<AuthOutLinkResponse> => {
if (!tokenUrl) return { uid: outLinkUid };
const { data } = await axios<TokenAuthResponseType>({
baseURL: tokenUrl,
url: '/shareAuth/init',
method: 'POST',
data: {
token: outLinkUid
}
});
if (data?.success !== true) {
return Promise.reject(data?.message || data?.msg || OutLinkErrEnum.unAuthUser);
}
const uid = data?.data?.uid;
if (
!uid ||
typeof uid !== 'string' ||
Buffer.byteLength(uid) > 255 ||
S3_KEY_PATH_INVALID_CHARS.test(uid)
) {
return Promise.reject(new UserError('Invalid UID'));
}
return { uid };
};
const authIpLimit = async ({ ip, outLink }: { ip: string; outLink: OutLinkSchema }) => {
if (!outLink.limit || !outLink.limit.QPM) {
return;
}
try {
await authFrequencyLimit({
eventId: `${outLink._id}-${ip}`,
maxAmount: outLink.limit.QPM,
expiredTime: addMinutes(new Date(), 1)
});
} catch (error) {
return Promise.reject(new UserError(`每分钟仅能请求 ${outLink.limit.QPM} 次~`));
}
};
export async function authOutLinkLimit({
outLink,
ip,
outLinkUid,
question
}: AuthOutLinkLimitProps): Promise<AuthOutLinkResponse> {
if (!outLink.limit) {
return { uid: outLinkUid };
}
// expiredTime already to string
if (outLink.limit.expiredTime && new Date(outLink.limit.expiredTime).getTime() < Date.now()) {
return Promise.reject(new UserError('分享链接已过期'));
}
if (
outLink.limit.maxUsagePoints &&
outLink.limit.maxUsagePoints > -1 &&
outLink.usagePoints > outLink.limit.maxUsagePoints
) {
return Promise.reject(new UserError('链接超出使用限制'));
}
// ip limit
if (ip) {
await authIpLimit({ ip, outLink });
}
// url auth. send request
if (!outLink.limit.hookUrl) {
return { uid: outLinkUid };
}
try {
const { data } = await axios<TokenAuthResponseType>({
baseURL: outLink.limit.hookUrl,
url: '/shareAuth/start',
method: 'POST',
data: {
token: outLinkUid,
question
}
});
if (data?.success !== true) {
return Promise.reject(new UserError(data?.message || data?.msg || '身份校验失败'));
}
return { uid: data?.data?.uid || outLinkUid };
} catch (error) {
return Promise.reject(new UserError('身份校验失败'));
}
}
...@@ -110,6 +110,11 @@ const logger = getLogger(LogCategories.INFRA.MONGO); ...@@ -110,6 +110,11 @@ const logger = getLogger(LogCategories.INFRA.MONGO);
try { try {
OutLinkSchema.index({ shareId: -1 }); OutLinkSchema.index({ shareId: -1 });
OutLinkSchema.index({ teamId: 1, tmbId: 1, appId: 1 }); OutLinkSchema.index({ teamId: 1, tmbId: 1, appId: 1 });
// Wechat polling recovery: find online channels on startup
OutLinkSchema.index(
{ type: 1, 'app.status': 1 },
{ partialFilterExpression: { type: 'wechat', 'app.status': 'online' } }
);
} catch (error) { } catch (error) {
logger.error('Failed to build outlink indexes', { error }); logger.error('Failed to build outlink indexes', { error });
} }
......
import crypto from 'crypto';
const DEFAULT_BASE_URL = 'https://ilinkai.weixin.qq.com';
const CHANNEL_VERSION = '1.0.0';
const BOT_TYPE = '3';
const LONG_POLL_TIMEOUT_MS = 35_000;
const SEND_TIMEOUT_MS = 15_000;
export type WeixinMessage = {
msgid: string;
from_user_id: string;
to_user_id?: string;
message_type: number;
message_state?: number;
item_list?: MessageItem[];
context_token?: string;
create_time_ms?: number;
};
export type MessageItem = {
type: number;
text_item?: { text: string };
voice_item?: { text: string };
ref_msg?: { title?: string };
};
export type GetUpdatesResponse = {
ret?: number;
errcode?: number;
errmsg?: string;
msgs?: WeixinMessage[];
get_updates_buf?: string;
};
export type QRCodeResponse = {
qrcode: string;
qrcode_img_content: string;
};
export type QRStatusResponse = {
status: 'wait' | 'scaned' | 'confirmed' | 'expired';
bot_token?: string;
ilink_bot_id?: string;
baseurl?: string;
ilink_user_id?: string;
};
export class ILinkClient {
private baseUrl: string;
private token?: string;
constructor(baseUrl?: string, token?: string) {
this.baseUrl = (baseUrl || DEFAULT_BASE_URL).replace(/\/$/, '');
this.token = token;
}
private randomUin(): string {
const uint32 = crypto.randomBytes(4).readUInt32BE(0);
return Buffer.from(String(uint32), 'utf-8').toString('base64');
}
private buildHeaders(body?: string): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
AuthorizationType: 'ilink_bot_token',
'X-WECHAT-UIN': this.randomUin()
};
if (body) {
headers['Content-Length'] = String(Buffer.byteLength(body, 'utf-8'));
}
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
return headers;
}
private async post(endpoint: string, body: string, timeoutMs: number): Promise<string> {
const url = `${this.baseUrl}/${endpoint}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, {
method: 'POST',
headers: this.buildHeaders(body),
body,
signal: controller.signal
});
clearTimeout(timer);
const text = await res.text();
if (!res.ok) throw new Error(`HTTP ${res.status}: ${text}`);
return text;
} catch (err) {
clearTimeout(timer);
throw err;
}
}
async getQRCode(): Promise<QRCodeResponse> {
const url = `${this.baseUrl}/ilink/bot/get_bot_qrcode?bot_type=${BOT_TYPE}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`QR fetch failed: ${res.status}`);
return res.json();
}
async getQRCodeStatus(qrcode: string): Promise<QRStatusResponse> {
const url = `${this.baseUrl}/ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), LONG_POLL_TIMEOUT_MS);
try {
const res = await fetch(url, {
headers: { 'iLink-App-ClientVersion': '1' },
signal: controller.signal
});
clearTimeout(timer);
if (!res.ok) throw new Error(`QR status failed: ${res.status}`);
return res.json();
} catch (err) {
clearTimeout(timer);
if (err instanceof Error && err.name === 'AbortError') return { status: 'wait' };
throw err;
}
}
async getUpdates(buf: string): Promise<GetUpdatesResponse> {
const body = JSON.stringify({
get_updates_buf: buf,
base_info: { channel_version: CHANNEL_VERSION }
});
try {
const raw = await this.post('ilink/bot/getupdates', body, LONG_POLL_TIMEOUT_MS);
return JSON.parse(raw);
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
return { ret: 0, msgs: [], get_updates_buf: buf };
}
throw err;
}
}
async sendMessage(params: {
to_user_id: string;
text: string;
context_token: string;
}): Promise<void> {
const clientId = `fastgpt:${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
const body = JSON.stringify({
msg: {
from_user_id: '',
to_user_id: params.to_user_id,
client_id: clientId,
message_type: 2,
message_state: 2,
item_list: [{ type: 1, text_item: { text: params.text } }],
context_token: params.context_token
},
base_info: { channel_version: CHANNEL_VERSION }
});
await this.post('ilink/bot/sendmessage', body, SEND_TIMEOUT_MS);
}
}
import type { WeixinMessage } from './ilinkClient';
const MSG_TYPE_USER = 1;
const MSG_ITEM_TEXT = 1;
const MSG_ITEM_VOICE = 3;
export type ParsedMessageGroup = {
userId: string;
text: string;
contextToken: string;
msgIds: string[];
};
export function extractTextFromItem(item: NonNullable<WeixinMessage['item_list']>[number]): string {
if (item.type === MSG_ITEM_TEXT && item.text_item?.text) {
const text = item.text_item.text;
if (item.ref_msg?.title) {
return `[引用: ${item.ref_msg.title}]\n${text}`;
}
return text;
}
if (item.type === MSG_ITEM_VOICE && item.voice_item?.text) {
return item.voice_item.text;
}
return '';
}
export function groupMessagesByUser(msgs: WeixinMessage[]): ParsedMessageGroup[] {
const groups = new Map<string, ParsedMessageGroup>();
for (const msg of msgs) {
if (msg.message_type !== MSG_TYPE_USER) continue;
let text = '';
for (const item of msg.item_list ?? []) {
const t = extractTextFromItem(item);
if (t) {
text = t;
break;
}
}
if (!text) continue;
const userId = msg.from_user_id ?? 'unknown';
const existing = groups.get(userId);
if (existing) {
existing.text += '\n' + text;
existing.msgIds.push(msg.msgid);
if (msg.context_token) {
existing.contextToken = msg.context_token;
}
} else {
groups.set(userId, {
userId,
text,
contextToken: msg.context_token ?? '',
msgIds: [msg.msgid]
});
}
}
return Array.from(groups.values());
}
import { getWorker, getQueue, QueueNames, type Job } from '../../../common/bullmq';
import { getLogger, LogCategories } from '../../../common/logger';
import { ILinkClient } from './ilinkClient';
import type { WechatPollJobData } from './type';
import type { OutLinkSchema, WechatAppType } from '@fastgpt/global/support/outLink/type';
import { MongoOutLink } from '../../../support/outLink/schema';
import { outlinkInvokeChat } from '../../../support/outLink/runtime/utils';
import { setRedisCache, getRedisCache } from '../../../common/redis/cache';
import { groupMessagesByUser, type ParsedMessageGroup } from './messageParser';
import { getErrText } from '@fastgpt/global/common/error/utils';
const logger = getLogger(LogCategories.MODULE.OUTLINK.WECHAT);
const queueName = 'wechatPublishPoll';
const MAX_CONSECUTIVE_FAILURES = 5;
const FAILURE_BACKOFF_MS = 10_000;
/* ============ Worker 处理逻辑 ============ */
async function processWechatPollJob(job: Job<WechatPollJobData>): Promise<void> {
const { shareId } = job.data;
// 1. 获取渠道配置
const outLink = (await MongoOutLink.findOne({
shareId
}).lean()) as unknown as OutLinkSchema<WechatAppType>;
if (!outLink || !outLink.app) {
logger.warn('OutLink not found, stop polling', { shareId });
return;
}
const app = outLink.app;
// 2. 检查状态
if (app.status !== 'online') {
logger.info('Channel not online, stop polling', { shareId, status: app.status });
return;
}
if (!app.token) {
logger.warn('No token, stop polling', { shareId });
return;
}
const client = new ILinkClient(app.baseUrl, app.token);
const failKey = `publish:wechat:failures:${shareId}`;
try {
// 3. 长轮询拉取消息
const resp = await client.getUpdates(app.syncBuf || '');
// 检查 API 错误
const isError =
(resp.ret !== undefined && resp.ret !== 0) ||
(resp.errcode !== undefined && resp.errcode !== 0);
if (isError) {
logger.error('getUpdates API error', {
shareId,
ret: resp.ret,
errcode: resp.errcode,
errmsg: resp.errmsg
});
const failures = Number((await getRedisCache(failKey)) ?? '0') + 1;
await setRedisCache(failKey, String(failures), 300);
if (failures >= MAX_CONSECUTIVE_FAILURES) {
await MongoOutLink.updateOne(
{ shareId },
{ $set: { 'app.status': 'error', 'app.lastError': resp.errmsg || 'Too many failures' } }
);
logger.error('Too many failures, stop polling', { shareId, failures });
return;
}
// 延迟续链
await scheduleNextPoll(shareId, FAILURE_BACKOFF_MS);
return;
}
// 清除失败计数
await setRedisCache(failKey, '0', 300);
// 4. 处理消息
if (resp.msgs && resp.msgs.length > 0) {
const groups = groupMessagesByUser(resp.msgs);
logger.debug('Processing messages', {
shareId,
totalMsgs: resp.msgs.length,
userGroups: groups.length
});
// 并发处理各用户分组
await Promise.allSettled(groups.map((group) => processUserGroup(outLink, group)));
}
// 5. 更新 buf
if (resp.get_updates_buf) {
await MongoOutLink.updateOne({ shareId }, { $set: { 'app.syncBuf': resp.get_updates_buf } });
}
} catch (error) {
logger.error('Poll job error', { shareId, error: String(error) });
}
// 6. 续链
await scheduleNextPoll(shareId);
}
/* ============ 处理单个用户分组 ============ */
async function processUserGroup(
outLink: OutLinkSchema<WechatAppType>,
group: ParsedMessageGroup
): Promise<void> {
const app = outLink.app;
const chatId = `wechat_${outLink.shareId}_${group.userId}`;
const client = new ILinkClient(app.baseUrl, app.token);
try {
await outlinkInvokeChat({
outLinkConfig: outLink,
chatId,
query: [{ text: { content: group.text } }],
messageId: group.msgIds[group.msgIds.length - 1],
chatUserId: group.userId,
replyCallback: async (replyContent: string) => {
await client.sendMessage({
to_user_id: group.userId,
text: replyContent,
context_token: group.contextToken
});
return { errcode: 0 };
}
});
} catch (error) {
logger.error('Process user group failed', {
shareId: outLink.shareId,
userId: group.userId,
error: String(error)
});
// 尝试发送错误提示
try {
const errorText = outLink.defaultResponse || `Run agent error: ${getErrText(error)}`;
await client.sendMessage({
to_user_id: group.userId,
text: errorText,
context_token: group.contextToken
});
} catch {
// 忽略发送失败
}
}
}
/* ============ 续链调度 ============ */
async function scheduleNextPoll(shareId: string, delayMs?: number): Promise<void> {
const queue = getQueue<WechatPollJobData>(QueueNames.wechatPoll);
await queue.add(
queueName,
{ shareId },
{
jobId: `wechat-poll-${shareId}-${Date.now()}`,
...(delayMs ? { delay: delayMs } : {})
}
);
}
/* ============ 对外接口 ============ */
/**
* 初始化微信轮询 Worker
*/
export const initWechatPollWorker = async () => {
/**
* 服务启动时恢复所有 online 渠道的轮询
*/
const resumeAllWechatPolling = async (): Promise<void> => {
const onlineChannels = await MongoOutLink.find(
{
type: 'wechat',
'app.status': 'online',
'app.token': { $exists: true, $ne: '' }
},
{ shareId: 1 }
).lean();
logger.info('Resuming wechat polling', { count: onlineChannels.length });
for (const ch of onlineChannels) {
await startWechatPolling(ch.shareId);
}
};
getWorker<WechatPollJobData>(QueueNames.wechatPoll, processWechatPollJob, {
concurrency: 10,
lockDuration: 120_000,
stalledInterval: 60_000,
removeOnComplete: { count: 0 },
removeOnFail: { count: 100, age: 7 * 24 * 60 * 60 }
});
await resumeAllWechatPolling();
logger.info('Wechat poll worker initialized');
};
/**
* 启动某个渠道的轮询(扫码登录成功后调用)
*/
export const startWechatPolling = async (shareId: string): Promise<void> => {
const queue = getQueue<WechatPollJobData>(QueueNames.wechatPoll);
await queue.add(
queueName,
{ shareId },
{
jobId: `wechat-poll-${shareId}-${Date.now()}`
}
);
logger.info('Wechat polling started', { shareId });
};
/**
* 停止某个渠道的轮询
*/
export const stopWechatPolling = async (shareId: string): Promise<void> => {
await MongoOutLink.updateOne({ shareId }, { $set: { 'app.status': 'offline', 'app.token': '' } });
logger.info('Wechat polling stopped', { shareId });
};
export type WechatPollJobData = {
shareId: string;
};
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { Box, type BoxProps, Flex, type FlexProps } from '@chakra-ui/react'; import { Box, type BoxProps, Flex, type FlexProps } from '@chakra-ui/react';
type ColorSchemaType = 'white' | 'blue' | 'green' | 'red' | 'yellow' | 'gray' | 'purple' | 'adora'; export type ColorSchemaType =
| 'white'
| 'blue'
| 'green'
| 'red'
| 'yellow'
| 'gray'
| 'purple'
| 'adora';
export type TagProps = FlexProps & { export type TagProps = FlexProps & {
children: React.ReactNode | React.ReactNode[]; children: React.ReactNode | React.ReactNode[];
......
...@@ -66,5 +66,6 @@ ...@@ -66,5 +66,6 @@
"total_usage": "Total Usage", "total_usage": "Total Usage",
"usage_detail": "Details", "usage_detail": "Details",
"user_type": "type", "user_type": "type",
"wecom": "WeCom" "wecom": "WeCom",
"wechat": "WeChat Personal"
} }
...@@ -408,6 +408,7 @@ ...@@ -408,6 +408,7 @@
"core.chat.logs.team": "Team Space Chat", "core.chat.logs.team": "Team Space Chat",
"core.chat.logs.test": "Online debugging", "core.chat.logs.test": "Online debugging",
"core.chat.logs.wecom": "WeChat Work", "core.chat.logs.wecom": "WeChat Work",
"core.chat.logs.wechat": "WeChat",
"core.chat.markdown.Edit Question": "Edit Question", "core.chat.markdown.Edit Question": "Edit Question",
"core.chat.markdown.Quick Question": "Click to Ask Immediately", "core.chat.markdown.Quick Question": "Click to Ask Immediately",
"core.chat.markdown.Send Question": "Send Question", "core.chat.markdown.Send Question": "Send Question",
......
...@@ -5,12 +5,20 @@ ...@@ -5,12 +5,20 @@
"copy_link_hint": "Copy the link below to the specified location", "copy_link_hint": "Copy the link below to the specified location",
"create_api_key": "Create New Key", "create_api_key": "Create New Key",
"create_link": "Create Link", "create_link": "Create Link",
"custom_domain_management": "Custom Domain Management",
"dingtalk.api": "DingTalk API",
"dingtalk.bot": "DingTalk Bot",
"dingtalk.bot_desc": "Connect to DingTalk Bot directly via API",
"dingtalk.create_modal_title": "Create DingTalk Bot",
"dingtalk.edit_modal_title": "Edit DingTalk Bot",
"dingtalk.title": "Publish to DingTalk Bot",
"edit_api_key": "Edit Key Details", "edit_api_key": "Edit Key Details",
"edit_feishu_bot": "Edit Feishu Bot", "edit_feishu_bot": "Edit Feishu Bot",
"edit_link": "Edit", "edit_link": "Edit",
"feishu_api": "Feishu API", "feishu_api": "Feishu API",
"feishu_bot": "Feishu Bot", "feishu_bot": "Feishu Bot",
"feishu_bot_desc": "Connect to Feishu Bot directly via API", "feishu_bot_desc": "Connect to Feishu Bot directly via API",
"ip_whitelist": "IP Whitelist",
"key_alias": "Key alias, for display only", "key_alias": "Key alias, for display only",
"key_tips": "You can use the API key to access specific interfaces (cannot access the application, use the in-app API key for that)", "key_tips": "You can use the API key to access specific interfaces (cannot access the application, use the in-app API key for that)",
"link_name": "Share Link Name", "link_name": "Share Link Name",
...@@ -30,21 +38,42 @@ ...@@ -30,21 +38,42 @@
"token_auth": "Token Authentication", "token_auth": "Token Authentication",
"token_auth_tips": "Token authentication server URL. If provided, a request will be sent to the specified server for authentication before each conversation.", "token_auth_tips": "Token authentication server URL. If provided, a request will be sent to the specified server for authentication before each conversation.",
"token_auth_use_cases": "View Token Authentication Guide", "token_auth_use_cases": "View Token Authentication Guide",
"use_default_domain": "Use Default Domain",
"wechat.bot": "WeChat Personal",
"wechat.bot_desc": "Connect to WeChat personal chat via ClawBot",
"wechat.confirmed_tip": "Login successful!",
"wechat.create": "Create WeChat Channel",
"wechat.desc": "Connect to WeChat via ClawBot",
"wechat.edit": "Edit WeChat Channel",
"wechat.expired_tip": "QR code expired",
"wechat.generating_qr": "Generating QR code...",
"wechat.last_poll": "Last Poll",
"wechat.login": "Scan to Login",
"wechat.login_success": "Login successful",
"wechat.login_title": "WeChat QR Login",
"wechat.logout": "Logout",
"wechat.name_placeholder": "Enter channel name",
"wechat.qr_generate_failed": "Failed to generate QR code, please retry",
"wechat.relogin": "Log in again",
"wechat.retry": "Regenerate",
"wechat.scan_qr_desc": "Open WeChat and scan the QR code above to login",
"wechat.scan_qr_tip": "Scan with WeChat",
"wechat.scanned_desc": "Please confirm login on your phone",
"wechat.scanned_tip": "Scanned, please confirm in WeChat",
"wechat.status": "Status",
"wechat.status.error": "Error",
"wechat.status.offline": "Offline",
"wechat.status.online": "Online",
"wechat.status.polling": "Polling",
"wechat.status.stale": "Stale",
"wechat.status_check_failed": "Failed to check status, please retry",
"wechat.title": "WeChat Personal",
"wecom.api": "WeCom API", "wecom.api": "WeCom API",
"wecom.bot": "WeCom Bot", "wecom.bot": "WeCom Bot",
"wecom.bot_desc": "Connect to WeCom Bot directly via API", "wecom.bot_desc": "Connect to WeCom Bot directly via API",
"wecom.create_modal_title": "Create WeCom Bot",
"wecom.edit_modal_title": "Edit WeCom Bot",
"wecom.create_modal.step.1": "Configure Parameters", "wecom.create_modal.step.1": "Configure Parameters",
"wecom.create_modal.step.2": "Fill in Callback URL", "wecom.create_modal.step.2": "Fill in Callback URL",
"wecom.title": "Publish to WeCom Bot", "wecom.create_modal_title": "Create WeCom Bot",
"dingtalk.bot": "DingTalk Bot", "wecom.edit_modal_title": "Edit WeCom Bot",
"dingtalk.bot_desc": "Connect to DingTalk Bot directly via API", "wecom.title": "Publish to WeCom Bot"
"dingtalk.create_modal_title": "Create DingTalk Bot",
"dingtalk.edit_modal_title": "Edit DingTalk Bot",
"dingtalk.title": "Publish to DingTalk Bot",
"dingtalk.api": "DingTalk API",
"use_default_domain": "Use Default Domain",
"ip_whitelist": "IP Whitelist",
"custom_domain_management": "Custom Domain Management"
} }
...@@ -66,5 +66,6 @@ ...@@ -66,5 +66,6 @@
"total_usage": "总消耗", "total_usage": "总消耗",
"usage_detail": "使用详情", "usage_detail": "使用详情",
"user_type": "类型", "user_type": "类型",
"wecom": "企业微信" "wecom": "企业微信",
"wechat": "微信个人号"
} }
...@@ -408,6 +408,7 @@ ...@@ -408,6 +408,7 @@
"core.chat.logs.team": "团队空间对话", "core.chat.logs.team": "团队空间对话",
"core.chat.logs.test": "在线调试", "core.chat.logs.test": "在线调试",
"core.chat.logs.wecom": "企业微信", "core.chat.logs.wecom": "企业微信",
"core.chat.logs.wechat": "微信",
"core.chat.markdown.Edit Question": "编辑问题", "core.chat.markdown.Edit Question": "编辑问题",
"core.chat.markdown.Quick Question": "点我立即提问", "core.chat.markdown.Quick Question": "点我立即提问",
"core.chat.markdown.Send Question": "发送问题", "core.chat.markdown.Send Question": "发送问题",
......
...@@ -5,12 +5,20 @@ ...@@ -5,12 +5,20 @@
"copy_link_hint": "将下面链接复制到指定位置", "copy_link_hint": "将下面链接复制到指定位置",
"create_api_key": "创建新 key", "create_api_key": "创建新 key",
"create_link": "创建链接", "create_link": "创建链接",
"custom_domain_management": "自定义域名管理",
"dingtalk.api": "钉钉 API",
"dingtalk.bot": "钉钉机器人",
"dingtalk.bot_desc": "通过 API 直接接入钉钉机器人",
"dingtalk.create_modal_title": "创建钉钉机器人",
"dingtalk.edit_modal_title": "编辑钉钉机器人",
"dingtalk.title": "发布到钉钉机器人",
"edit_api_key": "编辑 key 信息", "edit_api_key": "编辑 key 信息",
"edit_feishu_bot": "编辑飞书机器人", "edit_feishu_bot": "编辑飞书机器人",
"edit_link": "编辑", "edit_link": "编辑",
"feishu_api": "飞书接口", "feishu_api": "飞书接口",
"feishu_bot": "飞书机器人", "feishu_bot": "飞书机器人",
"feishu_bot_desc": "通过 API 直接接入飞书机器人", "feishu_bot_desc": "通过 API 直接接入飞书机器人",
"ip_whitelist": "IP 白名单",
"key_alias": "key 的别名,仅用于展示", "key_alias": "key 的别名,仅用于展示",
"key_tips": "你可以使用 API 密钥访问一些特定的接口(无法访问应用,访问应用需使用应用内的 API key)", "key_tips": "你可以使用 API 密钥访问一些特定的接口(无法访问应用,访问应用需使用应用内的 API key)",
"link_name": "分享链接的名字", "link_name": "分享链接的名字",
...@@ -30,21 +38,42 @@ ...@@ -30,21 +38,42 @@
"token_auth": "身份验证", "token_auth": "身份验证",
"token_auth_tips": "身份校验服务器地址", "token_auth_tips": "身份校验服务器地址",
"token_auth_use_cases": "查看身份验证使用说明", "token_auth_use_cases": "查看身份验证使用说明",
"use_default_domain": "使用默认域名",
"wechat.bot": "微信个人号",
"wechat.bot_desc": "通过 ClawBot 接入微信个人号聊天",
"wechat.confirmed_tip": "登录成功!",
"wechat.create": "创建微信渠道",
"wechat.desc": "通过微信 ClawBot 接入微信个人号",
"wechat.edit": "编辑微信渠道",
"wechat.expired_tip": "二维码已过期",
"wechat.generating_qr": "正在生成二维码...",
"wechat.last_poll": "最后轮询",
"wechat.login": "扫码登录",
"wechat.login_success": "登录成功",
"wechat.login_title": "微信扫码登录",
"wechat.logout": "退出登录",
"wechat.name_placeholder": "请输入渠道名称",
"wechat.qr_generate_failed": "二维码生成失败,请重试",
"wechat.relogin": "重新登录",
"wechat.retry": "重新生成",
"wechat.scan_qr_desc": "打开微信,扫描上方二维码进行登录",
"wechat.scan_qr_tip": "请使用微信扫描二维码",
"wechat.scanned_desc": "请在手机上点击确认登录",
"wechat.scanned_tip": "已扫码,请在微信中确认",
"wechat.status": "状态",
"wechat.status.error": "错误",
"wechat.status.offline": "离线",
"wechat.status.online": "在线",
"wechat.status.polling": "轮询中",
"wechat.status.stale": "异常",
"wechat.status_check_failed": "状态检查失败,请重试",
"wechat.title": "微信个人号",
"wecom.api": "企微 API", "wecom.api": "企微 API",
"wecom.bot": "企业微信机器人", "wecom.bot": "企业微信机器人",
"wecom.bot_desc": "通过 API 直接接入企业微信机器人", "wecom.bot_desc": "通过 API 直接接入企业微信机器人",
"wecom.create_modal_title": "创建企微机器人",
"wecom.edit_modal_title": "编辑企微机器人",
"wecom.create_modal.step.1": "配置参数", "wecom.create_modal.step.1": "配置参数",
"wecom.create_modal.step.2": "填写回调地址", "wecom.create_modal.step.2": "填写回调地址",
"wecom.title": "发布到企业微信机器人", "wecom.create_modal_title": "创建企微机器人",
"dingtalk.bot": "钉钉机器人", "wecom.edit_modal_title": "编辑企微机器人",
"dingtalk.bot_desc": "通过 API 直接接入钉钉机器人", "wecom.title": "发布到企业微信机器人"
"dingtalk.create_modal_title": "创建钉钉机器人",
"dingtalk.edit_modal_title": "编辑钉钉机器人",
"dingtalk.title": "发布到钉钉机器人",
"dingtalk.api": "钉钉 API",
"use_default_domain": "使用默认域名",
"ip_whitelist": "IP 白名单",
"custom_domain_management": "自定义域名管理"
} }
...@@ -63,5 +63,6 @@ ...@@ -63,5 +63,6 @@
"total_usage": "總消耗", "total_usage": "總消耗",
"usage_detail": "使用詳細資訊", "usage_detail": "使用詳細資訊",
"user_type": "類型", "user_type": "類型",
"wecom": "企業微信" "wecom": "企業微信",
"wechat": "微信個人號"
} }
...@@ -404,6 +404,7 @@ ...@@ -404,6 +404,7 @@
"core.chat.logs.team": "團隊空間對話", "core.chat.logs.team": "團隊空間對話",
"core.chat.logs.test": "測試", "core.chat.logs.test": "測試",
"core.chat.logs.wecom": "企業微信", "core.chat.logs.wecom": "企業微信",
"core.chat.logs.wechat": "微信",
"core.chat.markdown.Edit Question": "編輯問題", "core.chat.markdown.Edit Question": "編輯問題",
"core.chat.markdown.Quick Question": "點我立即發問", "core.chat.markdown.Quick Question": "點我立即發問",
"core.chat.markdown.Send Question": "傳送問題", "core.chat.markdown.Send Question": "傳送問題",
......
...@@ -5,12 +5,20 @@ ...@@ -5,12 +5,20 @@
"copy_link_hint": "請將下方連結複製到指定位置", "copy_link_hint": "請將下方連結複製到指定位置",
"create_api_key": "建立新金鑰", "create_api_key": "建立新金鑰",
"create_link": "建立連結", "create_link": "建立連結",
"custom_domain_management": "自訂域名管理",
"dingtalk.api": "釘釘 API",
"dingtalk.bot": "釘釘聊天機器人",
"dingtalk.bot_desc": "透過 API 直接連結釘釘聊天機器人",
"dingtalk.create_modal_title": "建立釘釘聊天機器人",
"dingtalk.edit_modal_title": "編輯釘釘聊天機器人",
"dingtalk.title": "發布至釘釘聊天機器人",
"edit_api_key": "編輯金鑰細節", "edit_api_key": "編輯金鑰細節",
"edit_feishu_bot": "編輯 Lark 聊天機器人", "edit_feishu_bot": "編輯 Lark 聊天機器人",
"edit_link": "編輯", "edit_link": "編輯",
"feishu_api": "Lark API", "feishu_api": "Lark API",
"feishu_bot": "Lark 聊天機器人", "feishu_bot": "Lark 聊天機器人",
"feishu_bot_desc": "透過 API 直接連結 Lark 聊天機器人", "feishu_bot_desc": "透過 API 直接連結 Lark 聊天機器人",
"ip_whitelist": "IP 白名單",
"key_alias": "金鑰別名,僅供顯示", "key_alias": "金鑰別名,僅供顯示",
"key_tips": "您可以使用 API 金鑰存取特定介面(無法存取應用程式,需使用應用程式內建的 API 金鑰)", "key_tips": "您可以使用 API 金鑰存取特定介面(無法存取應用程式,需使用應用程式內建的 API 金鑰)",
"link_name": "分享連結名稱", "link_name": "分享連結名稱",
...@@ -30,21 +38,42 @@ ...@@ -30,21 +38,42 @@
"token_auth": "身分驗證", "token_auth": "身分驗證",
"token_auth_tips": "身分驗證伺服器網址。若有提供,每次對話前將向指定伺服器傳送驗證請求。", "token_auth_tips": "身分驗證伺服器網址。若有提供,每次對話前將向指定伺服器傳送驗證請求。",
"token_auth_use_cases": "檢視身分驗證指南", "token_auth_use_cases": "檢視身分驗證指南",
"use_default_domain": "使用預設域名",
"wechat.bot": "微信個人號",
"wechat.bot_desc": "透過 ClawBot 連結微信個人號聊天",
"wechat.confirmed_tip": "登入成功!",
"wechat.create": "建立微信頻道",
"wechat.desc": "透過微信 ClawBot 連結微信個人號",
"wechat.edit": "編輯微信頻道",
"wechat.expired_tip": "QR 碼已過期",
"wechat.generating_qr": "正在產生 QR 碼...",
"wechat.last_poll": "最後輪詢",
"wechat.login": "掃碼登入",
"wechat.login_success": "登入成功",
"wechat.login_title": "微信掃碼登入",
"wechat.logout": "登出",
"wechat.name_placeholder": "請輸入頻道名稱",
"wechat.qr_generate_failed": "QR 碼產生失敗,請重試",
"wechat.relogin": "重新登入",
"wechat.retry": "重新產生",
"wechat.scan_qr_desc": "開啟微信,掃描上方 QR 碼進行登入",
"wechat.scan_qr_tip": "請使用微信掃描 QR 碼",
"wechat.scanned_desc": "請在手機上點擊確認登入",
"wechat.scanned_tip": "已掃碼,請在微信中確認",
"wechat.status": "狀態",
"wechat.status.error": "錯誤",
"wechat.status.offline": "離線",
"wechat.status.online": "線上",
"wechat.status.polling": "輪詢中",
"wechat.status.stale": "異常",
"wechat.status_check_failed": "狀態檢查失敗,請重試",
"wechat.title": "微信個人號",
"wecom.api": "企業微信 API", "wecom.api": "企業微信 API",
"wecom.bot": "企業微信聊天機器人", "wecom.bot": "企業微信聊天機器人",
"wecom.bot_desc": "透過 API 直接連結企業微信聊天機器人", "wecom.bot_desc": "透過 API 直接連結企業微信聊天機器人",
"wecom.create_modal_title": "建立企業微信聊天機器人",
"wecom.edit_modal_title": "編輯企業微信聊天機器人",
"wecom.create_modal.step.1": "配置參數", "wecom.create_modal.step.1": "配置參數",
"wecom.create_modal.step.2": "填寫回調地址", "wecom.create_modal.step.2": "填寫回調地址",
"wecom.title": "發布至企業微信聊天機器人", "wecom.create_modal_title": "建立企業微信聊天機器人",
"dingtalk.bot": "釘釘聊天機器人", "wecom.edit_modal_title": "編輯企業微信聊天機器人",
"dingtalk.bot_desc": "透過 API 直接連結釘釘聊天機器人", "wecom.title": "發布至企業微信聊天機器人"
"dingtalk.create_modal_title": "建立釘釘聊天機器人",
"dingtalk.edit_modal_title": "編輯釘釘聊天機器人",
"dingtalk.title": "發布至釘釘聊天機器人",
"dingtalk.api": "釘釘 API",
"use_default_domain": "使用預設域名",
"ip_whitelist": "IP 白名單",
"custom_domain_management": "自訂域名管理"
} }
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { Box, Button, Flex, ModalBody, ModalFooter, Spinner, Text } from '@chakra-ui/react';
import MyModal from '@fastgpt/web/components/v2/common/MyModal';
import { useTranslation } from 'next-i18next';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { POST, GET } from '@/web/common/api/request';
import QRCode from 'qrcode';
import MyLoading from '@fastgpt/web/components/common/MyLoading';
import { formatFileSize } from '@fastgpt/global/common/file/tools';
type QRStatus = 'loading' | 'wait' | 'scanned' | 'confirmed' | 'expired' | 'error';
const QRLoginModal = ({
shareId,
onSuccess,
onClose
}: {
shareId: string;
onSuccess: () => void;
onClose: () => void;
}) => {
const { t } = useTranslation();
const { toast } = useToast();
const [status, setStatus] = useState<QRStatus>('loading');
const [qrText, setQrText] = useState('');
const [errMsg, setErrMsg] = useState('');
const canvasRef = useRef<HTMLDivElement>(null);
const mountedRef = useRef(true);
const pollingRef = useRef(false);
const stopPolling = useCallback(() => {
pollingRef.current = false;
}, []);
// 串行轮询:等上一个请求完成后再发起下一个
const startPolling = useCallback(() => {
pollingRef.current = true;
const poll = async () => {
while (pollingRef.current && mountedRef.current) {
try {
const data = await GET<{ status: string }>('/support/outLink/wechat/qrcode/status', {
shareId
});
if (!mountedRef.current || !pollingRef.current) return;
switch (data.status) {
case 'scaned':
setStatus('scanned');
break;
case 'confirmed':
setStatus('confirmed');
pollingRef.current = false;
toast({
title: t('publish:wechat.login_success'),
status: 'success'
});
setTimeout(onSuccess, 1000);
return;
case 'expired':
setStatus('expired');
pollingRef.current = false;
return;
}
} catch {
if (!mountedRef.current) return;
setStatus('error');
setErrMsg(t('publish:wechat.status_check_failed'));
pollingRef.current = false;
return;
}
// 等待 2s 后再发起下一轮
await new Promise((r) => setTimeout(r, 2000));
}
};
poll();
}, [shareId, toast, t, onSuccess]);
// 用 qrcode 库渲染二维码到 canvas
const drawQRCode = useCallback((text: string) => {
if (!text || !canvasRef.current) return;
const canvas = document.createElement('canvas');
QRCode.toCanvas(canvas, text, {
width: 220,
margin: 2,
color: { dark: '#000000', light: '#ffffff' }
})
.then(() => {
if (canvasRef.current) {
canvasRef.current.innerHTML = '';
canvasRef.current.appendChild(canvas);
}
})
.catch(console.error);
}, []);
const generateQR = useCallback(async () => {
try {
setStatus('loading');
setErrMsg('');
stopPolling();
const data = await POST<{
qrcode: string;
qrcode_img_content: string;
}>('/support/outLink/wechat/qrcode/generate', { shareId });
if (!mountedRef.current) return;
setQrText(data.qrcode_img_content);
setStatus('wait');
startPolling();
} catch {
if (!mountedRef.current) return;
setStatus('error');
setErrMsg(t('publish:wechat.qr_generate_failed'));
}
}, [shareId, startPolling, stopPolling, t]);
// qrText 变化时重新渲染二维码
useEffect(() => {
drawQRCode(qrText);
}, [qrText, drawQRCode]);
useEffect(() => {
mountedRef.current = true;
generateQR();
return () => {
mountedRef.current = false;
stopPolling();
};
}, []);
const renderContent = () => {
switch (status) {
case 'loading':
return (
<Flex direction="column" align="center" justify="center" minH="350px">
<MyLoading fixed={false} text={t('publish:wechat.generating_qr')} />
</Flex>
);
case 'wait':
return (
<Flex direction="column" align="center">
<Box
p={4}
bg="white"
borderRadius="lg"
boxShadow="md"
border="1px solid"
borderColor="gray.200"
>
<Box ref={canvasRef} w="220px" h="220px" display="inline-block" />
</Box>
<Text mt={4} fontSize="lg" fontWeight="medium">
{t('publish:wechat.scan_qr_tip')}
</Text>
<Text mt={1} fontSize="sm" color="gray.500">
{t('publish:wechat.scan_qr_desc')}
</Text>
</Flex>
);
case 'scanned':
return (
<Flex direction="column" align="center" justify="center" minH="350px">
<Text fontSize="60px">👀</Text>
<Text mt={4} fontSize="lg" fontWeight="medium" color="blue.600">
{t('publish:wechat.scanned_tip')}
</Text>
<Text mt={1} fontSize="sm" color="gray.500">
{t('publish:wechat.scanned_desc')}
</Text>
</Flex>
);
case 'confirmed':
return (
<Flex direction="column" align="center" justify="center" minH="350px">
<Text fontSize="60px"></Text>
<Text mt={4} fontSize="lg" fontWeight="medium" color="green.600">
{t('publish:wechat.confirmed_tip')}
</Text>
</Flex>
);
case 'expired':
return (
<Flex direction="column" align="center" justify="center" minH="350px">
<Text fontSize="60px"></Text>
<Text mt={4} fontSize="lg" fontWeight="medium" color="orange.600">
{t('publish:wechat.expired_tip')}
</Text>
<Button mt={4} colorScheme="blue" onClick={generateQR}>
{t('publish:wechat.retry')}
</Button>
</Flex>
);
case 'error':
return (
<Flex direction="column" align="center" justify="center" minH="350px">
<Text fontSize="60px"></Text>
<Text mt={4} color="red.500">
{errMsg}
</Text>
<Button mt={4} colorScheme="blue" onClick={generateQR}>
{t('publish:wechat.retry')}
</Button>
</Flex>
);
}
};
return (
<MyModal isOpen onClose={onClose} title={t('publish:wechat.login_title')} size="md">
<ModalBody py={6}>{renderContent()}</ModalBody>
<ModalFooter>
<Button variant="whiteBase" onClick={onClose}>
{t('common:Close')}
</Button>
</ModalFooter>
</MyModal>
);
};
export default QRLoginModal;
import React from 'react';
import { Box, Button, Flex, Grid, Input, ModalBody, ModalFooter } from '@chakra-ui/react';
import MyModal from '@fastgpt/web/components/common/MyModal';
import { PublishChannelEnum } from '@fastgpt/global/support/outLink/constant';
import type { WechatAppType, OutLinkEditType } from '@fastgpt/global/support/outLink/type';
import { useTranslation } from 'next-i18next';
import { useForm } from 'react-hook-form';
import { createShareChat, updateShareChat } from '@/web/support/outLink/api';
import { useRequest } from '@fastgpt/web/hooks/useRequest';
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
const WechatEditModal = ({
appId,
defaultData,
onClose,
onCreate,
onEdit,
isEdit = false
}: {
appId: string;
defaultData: OutLinkEditType<WechatAppType>;
onClose: () => void;
onCreate: (shareId: string) => Promise<string | undefined>;
onEdit: () => void;
isEdit?: boolean;
}) => {
const { t } = useTranslation();
const { register, setValue, handleSubmit } = useForm({
defaultValues: defaultData
});
const { runAsync: onclickCreate, loading: creating } = useRequest(
(e) =>
createShareChat({
...e,
appId,
type: PublishChannelEnum.wechat
}),
{
errorToast: t('common:create_failed'),
successToast: t('common:create_success'),
onSuccess: async (shareId) => {
const _id = await onCreate(shareId);
if (_id) setValue('_id', _id);
onClose();
}
}
);
const { runAsync: onclickUpdate, loading: updating } = useRequest((e) => updateShareChat(e), {
errorToast: t('common:update_failed'),
successToast: t('common:update_success'),
onSuccess: () => {
onEdit();
onClose();
}
});
return (
<MyModal
iconSrc="core/app/publish/wechat"
title={isEdit ? t('publish:wechat.edit') : t('publish:wechat.create')}
minW={['auto', '500px']}
onClose={onClose}
>
<ModalBody fontSize={'14px'} p={8}>
<Grid gridTemplateColumns={'1fr'} gap={4}>
<Flex flexDir={'column'} gap={2}>
<FormLabel required>{t('common:Name')}</FormLabel>
<Input
placeholder={t('publish:wechat.name_placeholder')}
maxLength={100}
{...register('name', { required: t('common:name_is_empty') })}
/>
</Flex>
<Flex flexDir={'column'} gap={2}>
<FormLabel>
{t('common:support.outlink.Max usage points')}
<QuestionTip ml={1} label={t('common:support.outlink.Max usage points tip')} />
</FormLabel>
<Input
{...register('limit.maxUsagePoints', {
min: -1,
max: 10000000,
valueAsNumber: true
})}
/>
</Flex>
</Grid>
</ModalBody>
<ModalFooter>
<Button variant={'whiteBase'} mr={3} onClick={onClose}>
{t('common:Close')}
</Button>
<Button
isLoading={creating || updating}
onClick={handleSubmit((data) => (isEdit ? onclickUpdate(data) : onclickCreate(data)))}
>
{t('common:Confirm')}
</Button>
</ModalFooter>
</MyModal>
);
};
export default WechatEditModal;
import React, { useState } from 'react';
import {
Box,
Button,
Flex,
Link,
Table,
TableContainer,
Tbody,
Td,
Th,
Thead,
Tr
} from '@chakra-ui/react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useLoading } from '@fastgpt/web/hooks/useLoading';
import { getShareChatList, delShareChatById } from '@/web/support/outLink/api';
import { formatTimeToChatTime } from '@fastgpt/global/common/string/time';
import { defaultOutLinkForm } from '@/web/core/app/constants';
import type { WechatAppType, OutLinkEditType } from '@fastgpt/global/support/outLink/type';
import { PublishChannelEnum } from '@fastgpt/global/support/outLink/constant';
import { useTranslation } from 'next-i18next';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import dynamic from 'next/dynamic';
import MyMenu from '@fastgpt/web/components/common/MyMenu';
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
import { useRequest } from '@fastgpt/web/hooks/useRequest';
import { getDocPath } from '@/web/common/system/doc';
import { POST } from '@/web/common/api/request';
import type { ColorSchemaType } from '@fastgpt/web/components/common/Tag/index';
import MyTag from '@fastgpt/web/components/common/Tag/index';
const WechatEditModal = dynamic(() => import('./WechatEditModal'));
const QRLoginModal = dynamic(() => import('./QRLoginModal'));
const Wechat = ({ appId }: { appId: string }) => {
const { t } = useTranslation();
const { Loading, setIsLoading } = useLoading();
const { feConfigs } = useSystemStore();
const [editData, setEditData] = useState<OutLinkEditType<WechatAppType>>();
const [isEdit, setIsEdit] = useState(false);
const [loginShareId, setLoginShareId] = useState<string>();
const {
data: shareChatList = [],
loading: isFetching,
runAsync: refetch
} = useRequest(
() => getShareChatList<WechatAppType>({ appId, type: PublishChannelEnum.wechat }),
{
manual: false,
refreshDeps: [appId]
}
);
const statusBadge = (status?: string) => {
const map: Record<string, { colorSchema: ColorSchemaType; label: string }> = {
online: { colorSchema: 'green', label: t('publish:wechat.status.online') },
offline: { colorSchema: 'gray', label: t('publish:wechat.status.offline') },
error: { colorSchema: 'red', label: t('publish:wechat.status.error') }
};
const cfg = map[status || 'offline'] ?? map['offline'];
return <MyTag colorSchema={cfg.colorSchema}>{cfg.label}</MyTag>;
};
return (
<Box position={'relative'} pt={3} px={5} minH={'50vh'}>
<Flex justifyContent={'space-between'}>
<Flex alignItems={'center'}>
<Box fontWeight={'bold'} fontSize={['md', 'lg']}>
{t('publish:wechat.title')}
</Box>
{feConfigs?.docUrl && (
<Link
href={getDocPath('/docs/use-cases/external-integration/wechat')}
target={'_blank'}
ml={2}
color={'primary.500'}
fontSize={'sm'}
>
<Flex alignItems={'center'}>
<MyIcon name="book" w={'17px'} h={'17px'} mr="1" />
{t('common:read_doc')}
</Flex>
</Link>
)}
</Flex>
<Button
variant={'primary'}
size={['sm', 'md']}
leftIcon={<MyIcon name={'common/addLight'} w="1.25rem" color="white" />}
{...(shareChatList.length >= 10
? { isDisabled: true, title: t('common:core.app.share.Amount limit tip') }
: {})}
onClick={() => {
setEditData(defaultOutLinkForm as any);
setIsEdit(false);
}}
>
{t('common:add_new')}
</Button>
</Flex>
<TableContainer mt={3}>
<Table variant={'simple'} w={'100%'} fontSize={'sm'}>
<Thead>
<Tr>
<Th>{t('common:Name')}</Th>
<Th>{t('publish:wechat.status')}</Th>
<Th>{t('common:support.outlink.Usage points')}</Th>
<Th>{t('common:last_use_time')}</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{shareChatList.map((item) => (
<Tr key={item._id}>
<Td>{item.name}</Td>
<Td>{statusBadge(item.app?.status)}</Td>
<Td>{Math.round(item.usagePoints)}</Td>
<Td>
{item.lastTime
? t(formatTimeToChatTime(item.lastTime) as any).replace('#', ':')
: t('common:un_used')}
</Td>
<Td display={'flex'} alignItems={'center'}>
{!item.app?.token ? (
<Button
size={'sm'}
mr={3}
colorScheme="green"
onClick={() => {
setLoginShareId(item.shareId);
}}
>
{t('publish:wechat.login')}
</Button>
) : item.app.status === 'online' ? (
<Button
size={'sm'}
mr={3}
variant={'whiteBase'}
onClick={async () => {
setIsLoading(true);
try {
await POST('/support/outLink/wechat/logout', {
shareId: item.shareId
});
refetch();
} finally {
setIsLoading(false);
}
}}
>
{t('publish:wechat.logout')}
</Button>
) : (
<Button
size={'sm'}
mr={3}
variant={'whitePrimary'}
onClick={() => {
setLoginShareId(item.shareId);
}}
>
{t('publish:wechat.relogin')}
</Button>
)}
<MyMenu
Button={
<Button size={'smSquare'} variant={'whiteBase'}>
<MyIcon name={'more'} w={'14px'} />
</Button>
}
menuList={[
{
children: [
{
label: t('common:Edit'),
icon: 'edit',
onClick: () => {
setEditData({
_id: item._id,
name: item.name,
limit: item.limit,
app: item.app,
defaultResponse: item.defaultResponse,
immediateResponse: item.immediateResponse
});
setIsEdit(true);
}
},
{
label: t('common:Delete'),
icon: 'delete',
onClick: async () => {
setIsLoading(true);
try {
await delShareChatById(item._id);
refetch();
} finally {
setIsLoading(false);
}
}
}
]
}
]}
/>
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
{shareChatList.length === 0 && !isFetching && (
<EmptyTip text={t('common:core.app.share.Not share link')} />
)}
{editData && (
<WechatEditModal
appId={appId}
defaultData={editData}
isEdit={isEdit}
onCreate={async (shareId) => {
const newList = await refetch();
return newList?.find((i) => i.shareId === shareId)?._id;
}}
onEdit={() => refetch()}
onClose={() => setEditData(undefined)}
/>
)}
{loginShareId && (
<QRLoginModal
shareId={loginShareId}
onSuccess={() => {
refetch();
setLoginShareId(undefined);
}}
onClose={() => setLoginShareId(undefined)}
/>
)}
<Loading loading={isFetching} fixed={false} />
</Box>
);
};
export default React.memo(Wechat);
...@@ -20,6 +20,7 @@ const FeiShu = dynamic(() => import('./FeiShu')); ...@@ -20,6 +20,7 @@ const FeiShu = dynamic(() => import('./FeiShu'));
const DingTalk = dynamic(() => import('./DingTalk')); const DingTalk = dynamic(() => import('./DingTalk'));
const Wecom = dynamic(() => import('./Wecom')); const Wecom = dynamic(() => import('./Wecom'));
const OffiAccount = dynamic(() => import('./OffiAccount')); const OffiAccount = dynamic(() => import('./OffiAccount'));
const Wechat = dynamic(() => import('./Wechat'));
const Playground = dynamic(() => import('./Playground')); const Playground = dynamic(() => import('./Playground'));
const OutLink = () => { const OutLink = () => {
...@@ -45,6 +46,13 @@ const OutLink = () => { ...@@ -45,6 +46,13 @@ const OutLink = () => {
value: PublishChannelEnum.apikey, value: PublishChannelEnum.apikey,
isProFn: false isProFn: false
}, },
{
icon: 'core/app/publish/wechat',
title: t('publish:wechat.bot'),
desc: t('publish:wechat.bot_desc'),
value: PublishChannelEnum.wechat,
isProFn: true
},
...(feConfigs?.show_publish_feishu !== false && ...(feConfigs?.show_publish_feishu !== false &&
!userInfo?.tags?.includes(UserTagsEnum.enum.wecom) !userInfo?.tags?.includes(UserTagsEnum.enum.wecom)
? [ ? [
...@@ -91,6 +99,7 @@ const OutLink = () => { ...@@ -91,6 +99,7 @@ const OutLink = () => {
} }
] ]
: []), : []),
{ {
icon: 'core/chat/sidebar/home', icon: 'core/chat/sidebar/home',
title: t('common:navbar.Chat'), title: t('common:navbar.Chat'),
...@@ -145,6 +154,7 @@ const OutLink = () => { ...@@ -145,6 +154,7 @@ const OutLink = () => {
{linkType === PublishChannelEnum.dingtalk && <DingTalk appId={appId} />} {linkType === PublishChannelEnum.dingtalk && <DingTalk appId={appId} />}
{linkType === PublishChannelEnum.wecom && <Wecom appId={appId} />} {linkType === PublishChannelEnum.wecom && <Wecom appId={appId} />}
{linkType === PublishChannelEnum.officialAccount && <OffiAccount appId={appId} />} {linkType === PublishChannelEnum.officialAccount && <OffiAccount appId={appId} />}
{linkType === PublishChannelEnum.wechat && <Wechat appId={appId} />}
{linkType === PublishChannelEnum.playground && <Playground appId={appId} />} {linkType === PublishChannelEnum.playground && <Playground appId={appId} />}
</Flex> </Flex>
</Box> </Box>
......
...@@ -10,7 +10,7 @@ import { type ChatCompletionMessageParam } from '@fastgpt/global/core/ai/type'; ...@@ -10,7 +10,7 @@ import { type ChatCompletionMessageParam } from '@fastgpt/global/core/ai/type';
import { type AuthModeType } from '@fastgpt/service/support/permission/type'; import { type AuthModeType } from '@fastgpt/service/support/permission/type';
import { AuthUserTypeEnum } from '@fastgpt/global/support/permission/constant'; import { AuthUserTypeEnum } from '@fastgpt/global/support/permission/constant';
import { authOutLinkValid } from '@fastgpt/service/support/permission/publish/authLink'; import { authOutLinkValid } from '@fastgpt/service/support/permission/publish/authLink';
import { authOutLinkInit } from '@/service/support/permission/auth/outLink'; import { authOutLinkInit } from '@fastgpt/service/support/outLink/runtime/auth';
import { authTeamSpaceToken } from '@/service/support/permission/auth/team'; import { authTeamSpaceToken } from '@/service/support/permission/auth/team';
import { MongoTeamMember } from '@fastgpt/service/support/user/team/teamMemberSchema'; import { MongoTeamMember } from '@fastgpt/service/support/user/team/teamMemberSchema';
import { TeamMemberRoleEnum } from '@fastgpt/global/support/user/team/constant'; import { TeamMemberRoleEnum } from '@fastgpt/global/support/user/team/constant';
......
import type { ApiRequestProps } from '@fastgpt/service/type/next';
import { NextAPI } from '@/service/middleware/entry';
import { MongoOutLink } from '@fastgpt/service/support/outLink/schema';
import { authOutLinkValid } from '@fastgpt/service/support/permission/publish/authLink';
import type { WechatAppType } from '@fastgpt/global/support/outLink/type';
async function handler(req: ApiRequestProps<{ shareId: string }>): Promise<void> {
const { shareId } = req.body;
await authOutLinkValid<WechatAppType>({ shareId });
await MongoOutLink.updateOne(
{ shareId },
{
$set: {
'app.status': 'offline',
'app.token': '',
'app.lastError': ''
}
}
);
}
export default NextAPI(handler);
import type { ApiRequestProps } from '@fastgpt/service/type/next';
import { NextAPI } from '@/service/middleware/entry';
import { ILinkClient } from '@fastgpt/service/support/outLink/wechat/ilinkClient';
import { authOutLinkValid } from '@fastgpt/service/support/permission/publish/authLink';
import type { WechatAppType } from '@fastgpt/global/support/outLink/type';
import { setRedisCache } from '@fastgpt/service/common/redis/cache';
async function handler(
req: ApiRequestProps<{ shareId: string }>
): Promise<{ qrcode: string; qrcode_img_content: string; expireTime: number }> {
const { shareId } = req.body;
await authOutLinkValid<WechatAppType>({ shareId });
const client = new ILinkClient();
const qrData = await client.getQRCode();
await setRedisCache(`publish:wechat:qrcode:${shareId}`, JSON.stringify(qrData), 480);
return {
qrcode: qrData.qrcode,
qrcode_img_content: qrData.qrcode_img_content,
expireTime: 480
};
}
export default NextAPI(handler);
import type { ApiRequestProps } from '@fastgpt/service/type/next';
import { NextAPI } from '@/service/middleware/entry';
import { ILinkClient } from '@fastgpt/service/support/outLink/wechat/ilinkClient';
import { getRedisCache, delRedisCache } from '@fastgpt/service/common/redis/cache';
import { MongoOutLink } from '@fastgpt/service/support/outLink/schema';
import { startWechatPolling } from '@fastgpt/service/support/outLink/wechat/mq';
async function handler(req: ApiRequestProps<{}, { shareId: string }>): Promise<{ status: string }> {
const { shareId } = req.query;
const raw = await getRedisCache(`publish:wechat:qrcode:${shareId}`);
if (!raw) {
return { status: 'expired' };
}
const qrData = JSON.parse(raw);
const client = new ILinkClient();
const statusData = await client.getQRCodeStatus(qrData.qrcode);
if (statusData.status === 'confirmed' && statusData.bot_token && statusData.ilink_bot_id) {
await MongoOutLink.updateOne(
{ shareId },
{
$set: {
'app.token': statusData.bot_token,
'app.baseUrl': statusData.baseurl || 'https://ilinkai.weixin.qq.com',
'app.accountId': statusData.ilink_bot_id,
'app.userId': statusData.ilink_user_id || '',
'app.status': 'online',
'app.loginTime': new Date().toISOString(),
'app.syncBuf': '',
'app.lastError': ''
}
}
);
await delRedisCache(`publish:wechat:qrcode:${shareId}`);
await startWechatPolling(shareId);
}
return { status: statusData.status };
}
export default NextAPI(handler);
...@@ -4,6 +4,7 @@ import { initDatasetDeleteWorker } from '@fastgpt/service/core/dataset/delete'; ...@@ -4,6 +4,7 @@ import { initDatasetDeleteWorker } from '@fastgpt/service/core/dataset/delete';
import { initAppDeleteWorker } from '@fastgpt/service/core/app/delete'; import { initAppDeleteWorker } from '@fastgpt/service/core/app/delete';
import { initTeamDeleteWorker } from '@fastgpt/service/support/user/team/delete'; import { initTeamDeleteWorker } from '@fastgpt/service/support/user/team/delete';
import { initCollectionUpdateWorker } from '@fastgpt/service/core/dataset/collection/mq'; import { initCollectionUpdateWorker } from '@fastgpt/service/core/dataset/collection/mq';
import { initWechatPollWorker } from '@fastgpt/service/support/outLink/wechat/mq';
const logger = getLogger(LogCategories.INFRA.QUEUE); const logger = getLogger(LogCategories.INFRA.QUEUE);
...@@ -14,6 +15,7 @@ export const initBullMQWorkers = () => { ...@@ -14,6 +15,7 @@ export const initBullMQWorkers = () => {
initDatasetDeleteWorker(), initDatasetDeleteWorker(),
initAppDeleteWorker(), initAppDeleteWorker(),
initTeamDeleteWorker(), initTeamDeleteWorker(),
initCollectionUpdateWorker() initCollectionUpdateWorker(),
initWechatPollWorker()
]); ]);
}; };
...@@ -2,7 +2,6 @@ import { POST } from '@fastgpt/service/common/api/plusRequest'; ...@@ -2,7 +2,6 @@ import { POST } from '@fastgpt/service/common/api/plusRequest';
import type { import type {
AuthOutLinkChatProps, AuthOutLinkChatProps,
AuthOutLinkLimitProps, AuthOutLinkLimitProps,
AuthOutLinkInitProps,
AuthOutLinkResponse AuthOutLinkResponse
} from '@fastgpt/global/support/outLink/api'; } from '@fastgpt/global/support/outLink/api';
import { type ShareChatAuthProps } from '@fastgpt/global/support/permission/chat'; import { type ShareChatAuthProps } from '@fastgpt/global/support/permission/chat';
...@@ -10,11 +9,8 @@ import { authOutLinkValid } from '@fastgpt/service/support/permission/publish/au ...@@ -10,11 +9,8 @@ import { authOutLinkValid } from '@fastgpt/service/support/permission/publish/au
import { AuthUserTypeEnum } from '@fastgpt/global/support/permission/constant'; import { AuthUserTypeEnum } from '@fastgpt/global/support/permission/constant';
import { OutLinkErrEnum } from '@fastgpt/global/common/error/code/outLink'; import { OutLinkErrEnum } from '@fastgpt/global/common/error/code/outLink';
import { type OutLinkSchema } from '@fastgpt/global/support/outLink/type'; import { type OutLinkSchema } from '@fastgpt/global/support/outLink/type';
import { authOutLinkInit } from '@fastgpt/service/support/outLink/runtime/auth';
export function authOutLinkInit(data: AuthOutLinkInitProps): Promise<AuthOutLinkResponse> {
if (!global.feConfigs?.isPlus) return Promise.resolve({ uid: data.outLinkUid });
return POST<AuthOutLinkResponse>('/support/outLink/authInit', data);
}
export function authOutLinkChatLimit(data: AuthOutLinkLimitProps): Promise<AuthOutLinkResponse> { export function authOutLinkChatLimit(data: AuthOutLinkLimitProps): Promise<AuthOutLinkResponse> {
if (!global.feConfigs?.isPlus) return Promise.resolve({ uid: data.outLinkUid }); if (!global.feConfigs?.isPlus) return Promise.resolve({ uid: data.outLinkUid });
return POST<AuthOutLinkResponse>('/support/outLink/authChatStart', data); return POST<AuthOutLinkResponse>('/support/outLink/authChatStart', data);
......
...@@ -65,11 +65,6 @@ describe('ChatSourceEnum', () => { ...@@ -65,11 +65,6 @@ describe('ChatSourceEnum', () => {
expect(ChatSourceEnum.wecom).toBe('wecom'); expect(ChatSourceEnum.wecom).toBe('wecom');
expect(ChatSourceEnum.mcp).toBe('mcp'); expect(ChatSourceEnum.mcp).toBe('mcp');
}); });
it('should have all expected sources', () => {
const sources = Object.values(ChatSourceEnum);
expect(sources).toHaveLength(10);
});
}); });
describe('ChatSourceMap', () => { describe('ChatSourceMap', () => {
......
import { describe, expect, it } from 'vitest';
import {
extractTextFromItem,
groupMessagesByUser
} from '@fastgpt/service/support/outLink/wechat/messageParser';
import type { WeixinMessage } from '@fastgpt/service/support/outLink/wechat/ilinkClient';
/* ============ extractTextFromItem ============ */
describe('extractTextFromItem', () => {
it('should extract plain text', () => {
expect(extractTextFromItem({ type: 1, text_item: { text: 'hello' } })).toBe('hello');
});
it('should extract text with ref_msg', () => {
expect(
extractTextFromItem({
type: 1,
text_item: { text: 'reply' },
ref_msg: { title: 'original' }
})
).toBe('[引用: original]\nreply');
});
it('should extract voice text', () => {
expect(extractTextFromItem({ type: 3, voice_item: { text: 'voice content' } })).toBe(
'voice content'
);
});
it('should return empty for unsupported type', () => {
expect(extractTextFromItem({ type: 4 })).toBe('');
});
it('should return empty for text type without text_item', () => {
expect(extractTextFromItem({ type: 1 })).toBe('');
});
it('should return empty for voice type without voice_item', () => {
expect(extractTextFromItem({ type: 3 })).toBe('');
});
});
/* ============ groupMessagesByUser ============ */
const makeMsg = (
overrides: Partial<WeixinMessage> & { from_user_id: string; msgid: string }
): WeixinMessage => ({
message_type: 1,
item_list: [{ type: 1, text_item: { text: 'default text' } }],
...overrides
});
describe('groupMessagesByUser', () => {
it('should group single message', () => {
const msgs = [makeMsg({ from_user_id: 'u1', msgid: 'm1', context_token: 'ctx1' })];
const result = groupMessagesByUser(msgs);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
userId: 'u1',
text: 'default text',
contextToken: 'ctx1',
msgIds: ['m1']
});
});
it('should merge multiple messages from the same user', () => {
const msgs = [
makeMsg({
from_user_id: 'u1',
msgid: 'm1',
context_token: 'ctx1',
item_list: [{ type: 1, text_item: { text: 'first' } }]
}),
makeMsg({
from_user_id: 'u1',
msgid: 'm2',
context_token: 'ctx2',
item_list: [{ type: 1, text_item: { text: 'second' } }]
})
];
const result = groupMessagesByUser(msgs);
expect(result).toHaveLength(1);
expect(result[0].text).toBe('first\nsecond');
expect(result[0].msgIds).toEqual(['m1', 'm2']);
expect(result[0].contextToken).toBe('ctx2'); // 取最后一条的
});
it('should separate messages from different users', () => {
const msgs = [
makeMsg({ from_user_id: 'u1', msgid: 'm1' }),
makeMsg({ from_user_id: 'u2', msgid: 'm2' })
];
const result = groupMessagesByUser(msgs);
expect(result).toHaveLength(2);
expect(result.map((g) => g.userId)).toEqual(['u1', 'u2']);
});
it('should skip non-user messages (message_type !== 1)', () => {
const msgs = [
makeMsg({ from_user_id: 'u1', msgid: 'm1', message_type: 2 }), // bot message
makeMsg({ from_user_id: 'u1', msgid: 'm2', message_type: 1 }) // user message
];
const result = groupMessagesByUser(msgs);
expect(result).toHaveLength(1);
expect(result[0].msgIds).toEqual(['m2']);
});
it('should skip messages with no extractable text', () => {
const msgs = [
makeMsg({
from_user_id: 'u1',
msgid: 'm1',
item_list: [{ type: 4 }] // unsupported type
}),
makeMsg({ from_user_id: 'u1', msgid: 'm2' }) // has text
];
const result = groupMessagesByUser(msgs);
expect(result).toHaveLength(1);
expect(result[0].msgIds).toEqual(['m2']);
});
it('should handle empty message list', () => {
expect(groupMessagesByUser([])).toEqual([]);
});
it('should handle messages with no item_list', () => {
const msgs = [makeMsg({ from_user_id: 'u1', msgid: 'm1', item_list: undefined })];
expect(groupMessagesByUser(msgs)).toEqual([]);
});
it('should use "unknown" for missing from_user_id', () => {
const msgs: WeixinMessage[] = [
{
msgid: 'm1',
from_user_id: undefined as any,
message_type: 1,
item_list: [{ type: 1, text_item: { text: 'hello' } }]
}
];
const result = groupMessagesByUser(msgs);
expect(result).toHaveLength(1);
expect(result[0].userId).toBe('unknown');
});
it('should default contextToken to empty when not provided', () => {
const msgs = [makeMsg({ from_user_id: 'u1', msgid: 'm1', context_token: undefined })];
const result = groupMessagesByUser(msgs);
expect(result[0].contextToken).toBe('');
});
it('should handle voice messages in group', () => {
const msgs = [
makeMsg({
from_user_id: 'u1',
msgid: 'm1',
item_list: [{ type: 3, voice_item: { text: 'voice msg' } }]
})
];
const result = groupMessagesByUser(msgs);
expect(result).toHaveLength(1);
expect(result[0].text).toBe('voice msg');
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment