Files
xinmi/docs/IM集成开发文档.md
yawan f7fc992f57
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

1808 lines
80 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# IM 集成开发文档
WeKnora 的 IM 集成模块将企业即时通讯平台(企业微信、飞书、Lark、Slack、Telegram、钉钉、Mattermost)接入 WeKnora 知识问答管道,支持在 IM 中直接向 AI 提问并获得实时流式回答。
IM 渠道绑定到 Agent,一个 Agent 可接入多个 IM 渠道,所有配置通过前端 Agent 编辑器管理,存储在数据库中。
## 目录
- [快速接入指南](#快速接入指南)
- [企业微信接入](#企业微信接入)
- [飞书接入](#飞书接入)
- [Lark 接入](#lark-接入)
- [Slack 接入](#slack-接入)
- [Telegram 接入](#telegram-接入)
- [钉钉接入](#钉钉接入)
- [Mattermost 接入](#mattermost-接入)
- [前端管理](#前端管理)
- [架构总览](#架构总览)
- [数据模型](#数据模型)
- [API 端点](#api-端点)
- [核心概念](#核心概念)
- [消息处理流程](#消息处理流程)
- [接口定义](#接口定义)
- [平台适配器详解](#平台适配器详解)
- [企业微信 (WeCom)](#企业微信-wecom)
- [飞书 (Feishu) 与 Lark](#飞书-feishu-与-lark)
- [Slack](#slack)
- [Telegram](#telegram)
- [钉钉 (DingTalk)](#钉钉-dingtalk)
- [Mattermost](#mattermost)
- [斜杠指令系统](#斜杠指令系统)
- [QA 队列与限流](#qa-队列与限流)
- [流式输出机制](#流式输出机制)
- [文件消息处理](#文件消息处理)
- [关键参数与阈值](#关键参数与阈值)
- [错误处理](#错误处理)
- [扩展新平台](#扩展新平台)
---
## 快速接入指南
### 前置条件
- WeKnora 已部署并运行
- 已创建至少一个 Agent(自定义智能体)
- Agent 已配置好模型和知识库
> **回复图片需公网可达**:IM 显示知识库图片需让 IM 端拿到公网 http 图片 URL,二选一——
> (A) 存储后端本身公网可达(对象存储公网 endpoint / `MINIO_ENDPOINT` 设为公网);
> (B) 配 `APP_EXTERNAL_URL`,图片走 WeKnora 的 `/r/` 短链(nginx 已内置 `/r/` 代理)。
> 默认 MinIO 部署走 (B) 最简单。详见 `.env.example` 中 `APP_EXTERNAL_URL` 说明。
### 企业微信接入
企业微信提供两种接入模式,根据你的应用类型选择:
#### 方式一:WebSocket 模式(智能机器人,推荐)
> 无需公网域名,适合快速验证和内网部署。
**第一步:创建智能机器人**
1. 登录 [企业微信工作台](确认已升级到最新版企业微信) → **智能机器人** → **创建机器人** → **手动创建** → **切换API模式创建** → **选择"使用长连接"**
2. 创建完成后,在机器人详情页获取:
- **BotID** — 机器人唯一标识
- **BotSecret** — 机器人密钥(点击重置可重新生成)
**第二步:在 WeKnora 中添加 IM 渠道**
1. 进入 Agent 编辑器 → 左侧导航选择 **IM 集成** 标签页
2. 点击 **添加渠道**
3. 填写配置:
- **平台**:选择「企业微信」
- **渠道名称**:自定义名称,方便辨识(如「客服机器人」)
- **接入模式**:选择「WebSocket」
- **输出模式**:选择「流式输出」(推荐)
- **Bot ID**:填入从企业微信获取的 BotID
- **Bot Secret**:填入从企业微信获取的 BotSecret
4. 点击保存
**第三步:验证**
保存后 WeKnora 会自动建立到企业微信的 WebSocket 长连接。日志中出现以下内容表示连接成功:
```
[IM] WeCom WebSocket connecting (bot_id=xxx)...
```
此时在企业微信中给机器人发消息即可收到 AI 回复。
---
#### 方式二:Webhook 模式(自建应用)
> 需要公网可达的回调地址,适合已有自建应用的场景。
**第一步:创建自建应用**
1. 登录 [企业微信管理后台](https://work.weixin.qq.com/) → **应用管理** → **自建** → **创建应用**
2. 记录以下信息:
- **CorpID** — 在 **我的企业** → **企业信息** 页面底部
- **AgentID** — 应用详情页中的 AgentId(整数)
- **Secret** — 应用详情页中的 Secret
**第二步:在 WeKnora 中添加 IM 渠道**
1. 进入 Agent 编辑器 → **IM 集成** 标签页 → **添加渠道**
2. 填写配置:
- **平台**:选择「企业微信」
- **接入模式**:选择「Webhook」
- **输出模式**:选择「流式输出」
- **Corp ID**:企业 ID
- **Agent Secret**:应用 Secret
- **Token**:自定义或随机生成(记录下来)
- **EncodingAESKey**:自定义或随机生成(记录下来)
- **Corp Agent ID**:应用 AgentID(整数)
3. 保存后,渠道卡片上会显示**回调地址**,格式为 `https://你的域名/api/v1/im/callback/{channel_id}`
4. 复制该回调地址
**第三步:配置企业微信接收消息**
1. 在应用详情页 → **接收消息** → **设置 API 接收**
2. 填写:
- **URL**:粘贴上一步复制的回调地址
- **Token**:填入在 WeKnora 中设置的 Token
- **EncodingAESKey**:填入在 WeKnora 中设置的 EncodingAESKey
3. 点击保存,企业微信会发送 GET 验证请求,WeKnora 会自动响应
**第四步:配置可信域名(可选)**
如需在群聊中使用,在应用详情页 → **网页授权及 JS-SDK** 中添加可信域名。
---
### 飞书接入
飞书同样提供两种模式,WebSocket 模式配置更简单。
#### 方式一:WebSocket 模式(推荐)
> 无需公网域名,无需配置事件加密。
**第一步:创建飞书应用**
1. 登录 [飞书开放平台](https://open.feishu.cn/) → **开发者后台** → **创建企业自建应用**
2. 在 **凭证与基础信息** 页获取:
- **App ID**
- **App Secret**
**第二步:开通权限与事件**
1. **添加应用能力**:在应用详情页 → **添加应用能力** → 添加 **机器人** 能力
2. **配置权限**:在 **权限管理** 中搜索并开通以下权限:你的应用 → 权限管理 → 批量导入,粘贴下面 JSON(原文内容不变):
```json
{
"scopes": {
"tenant": [
"aily:file:read",
"aily:file:write",
"application:application.app_message_stats.overview:readonly",
"application:application:self_manage",
"application:bot.menu:write",
"cardkit:card:write",
"contact:user.employee_id:readonly",
"corehr:file:download",
"docs:document.content:read",
"event:ip_list",
"im:chat",
"im:chat.access_event.bot_p2p_chat:read",
"im:chat.members:bot_access",
"im:message",
"im:message.group_at_msg:readonly",
"im:message.group_msg",
"im:message.p2p_msg:readonly",
"im:message:readonly",
"im:message:send_as_bot",
"im:resource",
"sheets:spreadsheet",
"wiki:wiki:readonly"
],
"user": [
"aily:file:read",
"aily:file:write",
"im:chat.access_event.bot_p2p_chat:read"
]
}
}
```
3. **配置事件订阅**:
- 在 **事件与回调** → **事件配置** 中,选择请求方式为 **使用长连接接收事件**
- 添加事件 `im.message.receive_v1`(接收消息)
**第三步:发布应用**
在 **版本管理与发布** 中创建版本并提交审核。审核通过后用户才能与机器人交互。
**第四步:在 WeKnora 中添加 IM 渠道**
1. 进入 Agent 编辑器 → **IM 集成** → **添加渠道**
2. 填写配置:
- **平台**:选择「飞书」
- **接入模式**:选择「WebSocket」
- **输出模式**:选择「流式输出」(需开启 cardkit:card 权限)
- **App ID**:填入从飞书获取的 App ID
- **App Secret**:填入从飞书获取的 App Secret
3. 保存
启动后日志出现以下内容表示连接成功:
```
[IM] Feishu WebSocket connecting (app_id=xxx)...
```
---
#### 方式二:Webhook 模式
> 需要公网可达的回调地址。
**前置步骤**同上(创建应用、开通权限),额外需要:
**第一步:在 WeKnora 中添加 IM 渠道**
1. 进入 Agent 编辑器 → **IM 集成** → **添加渠道**
2. 填写配置:
- **平台**:选择「飞书」
- **接入模式**:选择「Webhook」
- **App ID** / **App Secret**
- **Verification Token**:从飞书事件订阅页面获取
- **Encrypt Key**:从飞书事件订阅页面获取
3. 保存后,复制渠道卡片上显示的**回调地址**
**第二步:配置飞书事件订阅**
1. 在 **事件与回调** → **事件配置** 中,选择请求方式为 **将事件发送到开发者服务器**
2. **请求地址**:粘贴从 WeKnora 复制的回调地址
3. 添加事件 `im.message.receive_v1`
4. 点击保存时飞书会发送 URL 验证请求(challenge),WeKnora 会自动响应
---
#### API Base URL(可选,内网代理)
渠道配置里的 **API Base URL** 是可选字段,用于让 WeKnora 通过反向代理访问飞书开放平台:
- **服务器能直连外网** → **留空**即可,默认访问 `https://open.feishu.cn`
- **服务器需要代理才能访问外网**(如内网部署)→ 填写反向代理地址(如 nginx,`http://10.0.0.1:8080/feishu`)
该地址同时覆盖飞书 IM 的所有出站调用,一个字段统一管控:
| 出站 | 说明 |
|---|---|
| WebSocket bootstrap | SDK 拿 wss 地址的请求 |
| wss 长连接 | 收消息的 WebSocket(经改写后的地址) |
| HTTP API | token、发消息、下载文件、CardKit 流式卡片 |
**nginx 反向代理要点**(WebSocket 模式,已生产验证):
飞书 bootstrap 返回的 ws 地址 host 是 **`msg-frontier.feishu.cn`**(长连接专用域名,**不是 `open.feishu.cn`**),path 是 `/ws/v2`。因此 nginx 需要**三个 location**,upstream 不同不能混:
| location | 匹配 | upstream | 作用 |
|---|---|---|---|
| `= /<前缀>/callback/ws/endpoint` | bootstrap POST | `open.feishu.cn` | 拿 ws 地址 + `sub_filter` 改写 |
| `/<前缀>/ws/` | ws dial(GET+Upgrade) | **`msg-frontier.feishu.cn`** | WebSocket 长连接反代 |
| `/<前缀>/` | OpenAPI HTTP | `open.feishu.cn` | token/发消息/下载文件/卡片 |
- `sub_filter` 替换的是 `wss://msg-frontier.feishu.cn`(不是 `open.feishu.cn`),改成 `ws://<nginx>/<前缀>`,WeKnora 才会明文回连 nginx
- ws dial 的 upstream 必须是 `msg-frontier.feishu.cn`,若误指 `open.feishu.cn` 会 404(open.feishu.cn 不处理 `/ws/v2`)
- `rewrite` 模式要与 location 前缀一致(如 `location /weknora/feishu/ws/` 配 `rewrite ^/weknora/feishu/(.*)$ /$1 break`),否则前缀不剥离 -> 飞书 404 -> ws 握手失败
- `map $http_upgrade $connection_upgrade` 区分 POST(Connection=close)与 ws(Connection=upgrade),三者共存
- 不需要 nginx 支持 CONNECT
以下为生产验证通过的完整配置(按实际调整地址与前缀):
```nginx
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name <能访问外网的Nginx 主机IP>;
# ① bootstrap:POST /callback/ws/endpoint -> open.feishu.cn + sub_filter 改写 wss URL
location = /weknora/feishu/callback/ws/endpoint {
resolver 114.114.114.114 8.8.8.8 valid=300s ipv6=off;
set $feishu_host "open.feishu.cn";
rewrite ^/weknora/feishu/(.*)$ /$1 break;
proxy_pass https://$feishu_host;
proxy_ssl_server_name on;
proxy_ssl_name open.feishu.cn;
proxy_ssl_protocols TLSv1.2 TLSv1.3;
proxy_ssl_verify off;
proxy_set_header Host open.feishu.cn;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# 禁 gzip,sub_filter 才能改写 JSON
proxy_set_header Accept-Encoding "";
# 飞书下发的 wss host 是 msg-frontier.feishu.cn(长连接专用域名,非 open.feishu.cn)
sub_filter 'wss://msg-frontier.feishu.cn' 'ws://<能访问外网的Nginx 主机IP>/weknora/feishu';
sub_filter_once off;
sub_filter_types application/json;
}
# ② ws dial:WebSocket 长连接 -> msg-frontier.feishu.cn(upstream 不是 open.feishu.cn!)
location /weknora/feishu/ws/ {
resolver 114.114.114.114 8.8.8.8 valid=300s ipv6=off;
set $feishu_host "msg-frontier.feishu.cn";
rewrite ^/weknora/feishu/(.*)$ /$1 break;
proxy_pass https://$feishu_host;
proxy_ssl_server_name on;
proxy_ssl_name msg-frontier.feishu.cn;
proxy_ssl_protocols TLSv1.2 TLSv1.3;
proxy_ssl_verify off;
proxy_set_header Host msg-frontier.feishu.cn;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_buffering off;
proxy_cache off;
proxy_request_buffering off;
chunked_transfer_encoding on;
proxy_connect_timeout 60s;
proxy_send_timeout 3600s;
proxy_read_timeout 3600s;
send_timeout 3600s;
client_max_body_size 0;
proxy_max_temp_file_size 0;
}
# ③ OpenAPI HTTP:token/发消息/下载文件/CardKit -> open.feishu.cn
location /weknora/feishu/ {
resolver 114.114.114.114 8.8.8.8 valid=300s ipv6=off;
set $feishu_host "open.feishu.cn";
rewrite ^/weknora/feishu/(.*)$ /$1 break;
proxy_pass https://$feishu_host;
proxy_ssl_server_name on;
proxy_ssl_name open.feishu.cn;
proxy_ssl_protocols TLSv1.2 TLSv1.3;
proxy_ssl_verify off;
proxy_set_header Host open.feishu.cn;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
client_max_body_size 0;
}
}
```
> 验证 sub_filter 生效:`curl -s http://127.0.0.1/weknora/feishu/callback/ws/endpoint -X POST -H 'Content-Type: application/json' -d '{"AppID":"xxx","AppSecret":"xxx"}'`,返回的 `data.URL` 应为 `ws://<能访问外网的Nginx 主机IP>/weknora/feishu/ws/v2?...`(不再是 `wss://msg-frontier.feishu.cn/...`)。WeKnora 日志出现 `[IM] Feishu WebSocket connected successfully` 即长连接建立。
**SSRF 白名单**:若 `api_base_url` 是内网地址,需把主机名加入 `SSRF_WHITELIST` 环境变量,否则 SSRF 防护会拦截。
> Webhook 模式下,`api_base_url` 只影响出站 HTTP API;飞书回调 WeKnora 的入站路径不受影响(回调地址在渠道卡片上单独配置)。
> Lark 国际版同理,只是默认域名换成 `https://open.larksuite.com`。
---
### Lark 接入
Lark 是飞书的国际版。两者是同一产品部署在两朵相互隔离的云上,**IM 的 API 接口、事件结构、
凭证字段、接入模式一致**,因此 WeKnora 中 Lark 与飞书共用同一套适配器代码。
但「代码一致」不等于「配置一致」,接入时有三处差别:
| | 飞书 | Lark |
|---|---|---|
| 开放平台 | <https://open.feishu.cn/> | <https://open.larksuite.com/> |
| 添加渠道时的**平台**选项 | 「飞书」 | 「Lark(飞书国际版)」 |
| 权限清单 | [飞书接入](#飞书接入)一节的 JSON | [Lark 权限配置](#lark-权限配置)一节的 JSON(**不可套用飞书的**) |
创建应用、添加机器人能力、订阅 `im.message.receive_v1` 事件、发布版本、填写
App ID / App Secret 等步骤与[飞书接入](#飞书接入)一节相同。**唯独权限配置不同,见下。**
#### Lark 权限配置
在 Lark 开放平台 **权限管理 → 批量导入** 粘贴下面的 JSON,覆盖 IM 机器人与知识库(Wiki)
数据源两块功能:
```json
{
"scopes": {
"tenant": [
"im:message",
"im:message:send_as_bot",
"im:resource",
"im:message.p2p_msg:readonly",
"im:message.group_at_msg:readonly",
"cardkit:card:write",
"wiki:wiki:readonly",
"drive:export:readonly",
"drive:drive:readonly",
"docx:document:readonly"
],
"user": []
}
}
```
> 不要套用[飞书接入](#飞书接入)一节的 JSON:其中 `aily:file:*`、`corehr:file:download`
> 在 Lark 不存在,整份导入会失败。
> **注意**:飞书应用与 Lark 应用互不通用。在 open.feishu.cn 创建的应用无法用于 Lark 渠道,
> 反之亦然——凭证只在创建它的那朵云上有效,跨云调用会认证失败。
WebSocket 模式启动后日志出现以下内容表示连接成功:
```
[IM] Lark WebSocket connecting (app_id=xxx)...
```
同一个 App ID 在两朵云上分别注册时,WeKnora 通过 `平台:app_id` 区分(如 `feishu:cli_xxx`
与 `lark:cli_xxx`),两个渠道可以并存,会话互不干扰。
---
### Slack 接入
Slack 提供两种接入模式,推荐使用 WebSocket (Socket Mode) 模式,无需公网域名。
#### 方式一:WebSocket 模式(Socket Mode,推荐)
> 无需公网域名,适合快速验证和内网部署。
**第一步:创建 Slack App**
1. 登录 [Slack API](https://api.slack.com/apps) → **Create New App** → **From scratch**
2. 填写 App Name 并选择要安装的 Workspace。
**第二步:生成 App-Level Token**
1. 在应用详情页左侧导航栏选择 **Basic Information**。
2. 滚动到 **App-Level Tokens** 区域,点击 **Generate Token and Scopes**。
3. 填写 Token Name,添加 `connections:write` scope。
4. 点击 Generate,复制生成的 Token(以 `xapp-` 开头),这就是 **App Token**。
**第三步:开启 Socket Mode**
1. 在左侧导航栏选择 **Socket Mode**。
2. 开启 **Enable Socket Mode** 开关。
**第四步:配置 Event Subscriptions**
1. 在左侧导航栏选择 **Event Subscriptions**。
2. 开启 **Enable Events** 开关。
3. 展开 **Subscribe to bot events**,添加以下事件:
- `app_mention` (在频道中 @ 机器人)
- `message.channels` (频道消息)
- `message.groups` (私有频道消息)
- `message.im` (私聊消息)
- `message.mpim` (多人私聊消息)
4. 点击 **Save Changes**。
**第五步:配置权限 (OAuth & Permissions)**
1. 在左侧导航栏选择 **OAuth & Permissions**。
2. 滚动到 **Scopes** -> **Bot Token Scopes**,确保包含以下权限(添加事件时通常会自动添加):
- `app_mentions:read`
- `channels:history`
- `chat:write`
- `groups:history`
- `im:history`
- `mpim:history`
- `files:read` (用于接收文件)
3. 滚动到顶部,点击 **Install to Workspace**。
4. 授权后,复制 **Bot User OAuth Token**(以 `xoxb-` 开头),这就是 **Bot Token**。
**第六步:在 WeKnora 中添加 IM 渠道**
1. 进入 Agent 编辑器 → **IM 集成** → **添加渠道**
2. 填写配置:
- **平台**:选择「Slack」
- **接入模式**:选择「WebSocket」
- **输出模式**:选择「流式输出」
- **App Token**:填入以 `xapp-` 开头的 Token
- **Bot Token**:填入以 `xoxb-` 开头的 Token
3. 保存
启动后日志出现以下内容表示连接成功:
```
[IM] Slack WebSocket connecting...
```
---
#### 方式二:Webhook 模式 (Events API)
> 需要公网可达的回调地址。
**第一步:创建 Slack App 并获取凭证**
1. 登录 [Slack API](https://api.slack.com/apps) 创建应用。
2. 在 **Basic Information** 页面,滚动到 **App Credentials** 区域,复制 **Signing Secret**。
3. 在 **OAuth & Permissions** 页面,配置 Bot Token Scopes(同上),安装到 Workspace,复制 **Bot User OAuth Token**(Bot Token)。
**第二步:在 WeKnora 中添加 IM 渠道**
1. 进入 Agent 编辑器 → **IM 集成** → **添加渠道**
2. 填写配置:
- **平台**:选择「Slack」
- **接入模式**:选择「Webhook」
- **Bot Token**:填入以 `xoxb-` 开头的 Token
- **Signing Secret**:填入 Signing Secret
3. 保存后,复制渠道卡片上显示的**回调地址**。
**第三步:配置 Event Subscriptions**
1. 在 Slack App 设置页左侧导航栏选择 **Event Subscriptions**。
2. 开启 **Enable Events** 开关。
3. 在 **Request URL** 中粘贴从 WeKnora 复制的回调地址。Slack 会发送一个 challenge 请求,WeKnora 会自动响应并验证通过。
4. 展开 **Subscribe to bot events**,添加需要的事件(同上)。
5. 点击 **Save Changes**。
---
### Telegram 接入
Telegram 提供两种接入模式,推荐使用 WebSocket(长轮询)模式,无需公网域名。
#### 方式一:WebSocket 模式(长轮询,推荐)
> 无需公网域名,适合快速验证和内网部署。
**第一步:创建 Telegram Bot**
1. 在 Telegram 中搜索 [@BotFather](https://t.me/BotFather) 并开始对话
2. 发送 `/newbot`,按提示填写 Bot 名称和用户名
3. 创建完成后获取 **Bot Token**(格式如 `123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`)
**第二步:在 WeKnora 中添加 IM 渠道**
1. 进入 Agent 编辑器 → **IM 集成** → **添加渠道**
2. 填写配置:
- **平台**:选择「Telegram」
- **接入模式**:选择「WebSocket」
- **输出模式**:选择「流式输出」(推荐)
- **Bot Token**:填入从 BotFather 获取的 Token
3. 保存
启动后日志出现以下内容表示连接成功:
```
[IM] Telegram long polling connecting...
```
此时在 Telegram 中给 Bot 发送消息即可收到 AI 回复。
---
#### 方式二:Webhook 模式
> 需要公网可达的回调地址(HTTPS)。
**第一步:创建 Telegram Bot 并获取凭证**
同上,通过 BotFather 创建 Bot 并获取 Bot Token。
**第二步:在 WeKnora 中添加 IM 渠道**
1. 进入 Agent 编辑器 → **IM 集成** → **添加渠道**
2. 填写配置:
- **平台**:选择「Telegram」
- **接入模式**:选择「Webhook」
- **Bot Token**:填入 Bot Token
- **Secret Token**(可选):自定义密钥,用于验证回调请求的 `X-Telegram-Bot-Api-Secret-Token` 头
3. 保存后,复制渠道卡片上显示的**回调地址**
**第三步:配置 Webhook**
通过 Telegram Bot API 设置 Webhook:
```bash
curl -X POST "https://api.telegram.org/bot<YOUR_BOT_TOKEN>/setWebhook" \
-H "Content-Type: application/json" \
-d '{"url": "<YOUR_CALLBACK_URL>", "secret_token": "<YOUR_SECRET_TOKEN>"}'
```
> 注意:Telegram Webhook 必须使用 HTTPS。
---
### 钉钉接入
钉钉提供两种接入模式,推荐使用 Stream 模式(WebSocket),无需公网域名。
#### 方式一:WebSocket 模式(Stream,推荐)
> 无需公网域名,适合快速验证和内网部署。
**第一步:创建钉钉机器人**
1. 登录 [钉钉开放平台](https://open-dev.dingtalk.com/) → **应用开发** → **创建应用**
2. 在应用详情页 → **添加应用能力** → 添加 **机器人** 能力
3. 在 **凭证与基础信息** 页获取:
- **Client ID**(AppKey)
- **Client Secret**(AppSecret)
**第二步:配置机器人**
1. 在应用详情页 → **机器人** → 配置消息接收模式为 **Stream 模式**
2.(可选)如需流式 AI 卡片效果,在 **互动卡片** 中创建一个 **AI 卡片模板**,记录 **Card Template ID**
**第三步:在 WeKnora 中添加 IM 渠道**
1. 进入 Agent 编辑器 → **IM 集成** → **添加渠道**
2. 填写配置:
- **平台**:选择「钉钉」
- **接入模式**:选择「WebSocket」
- **输出模式**:选择「流式输出」(推荐)
- **Client ID**:填入 AppKey
- **Client Secret**:填入 AppSecret
- **Card Template ID**(可选):填入 AI 卡片模板 ID,启用后流式回复将以 AI 卡片形式展示
3. 保存
启动后日志出现以下内容表示连接成功:
```
[IM] DingTalk Stream connecting...
```
> **关于 AI 卡片**:配置 Card Template ID 后,流式回复将通过钉钉 AI 卡片实时展示打字效果;未配置时流式内容将在结束后一次性发送。
---
#### 方式二:Webhook 模式
> 需要公网可达的回调地址。
**第一步:创建钉钉机器人并获取凭证**
同上,创建应用并获取 Client ID 和 Client Secret。
**第二步:在 WeKnora 中添加 IM 渠道**
1. 进入 Agent 编辑器 → **IM 集成** → **添加渠道**
2. 填写配置:
- **平台**:选择「钉钉」
- **接入模式**:选择「Webhook」
- **Client ID**:填入 AppKey
- **Client Secret**:填入 AppSecret
- **Card Template ID**(可选):同上
3. 保存后,复制渠道卡片上显示的**回调地址**
**第三步:配置钉钉事件订阅**
1. 在应用详情页 → **机器人** → 配置消息接收模式为 **HTTP 模式**
2. **消息接收地址**:粘贴从 WeKnora 复制的回调地址
3. 钉钉会通过 HmacSHA256 签名验证回调请求
---
### Mattermost 接入
Mattermost 为**自建部署**,当前仅支持 **Webhook** 模式:**出站 Webhook(Outgoing Webhook)** 将用户消息 POST 到 WeKnora,Bot 通过 **REST API v4** 发回频道/线程回复。与 Slack Events API 类似,回调需快速返回 `200`,实际回答由 Bot Token 异步调用接口完成。
> 需要公网或内网可达的回调 URL(Mattermost 服务器能访问 WeKnora 的 `/api/v1/im/callback/{channel_id}`)。若 WeKnora 在内网,请在 Mattermost 中配置 [Trusted Internal Connections](https://docs.mattermost.com/configure/environment-configuration-settings.html) 或将回调地址加入允许列表。
#### 第一步:创建 Bot 账户并获取 Token
1. 在 Mattermost 中创建专用 Bot 账户(或使用个人账户的 **Personal Access Token**,不推荐生产环境)。
2. 为 Bot 授予在目标频道/团队发消息、读取文件等所需权限(若需将用户上传的文件写入知识库,需能下载附件)。
3. 在 **Profile → Security → Personal Access Tokens**(或 Bot 设置)生成 Token,复制保存为 **Bot Token**。
#### 第二步:创建 Outgoing Webhook
1. 打开 **产品菜单 → 集成 → 出站 Webhook**(若不可见,需系统管理员在 **系统控制台 → 集成** 中启用出站 Webhook)。
2. 点击 **Add Outgoing Webhook**,填写标题与描述。
3. **Content Type** 建议选择 **application/json**(也支持 `application/x-www-form-urlencoded`,WeKnora 两种均可解析)。
4. 选择触发频道,或配置**触发词**(与频道组合规则以 Mattermost 说明为准)。
5. **Callback URLs** 先留空或填占位;保存后在 WeKnora 创建渠道后会得到正式地址。
6. 保存后复制页面上的 **Token**(出站 Webhook 密钥),即 **Outgoing Webhook Token**。
#### 第三步:在 WeKnora 中添加 IM 渠道
1. 进入 Agent 编辑器 → **IM 集成** → **添加渠道**。
2. 填写配置:
- **平台**:选择「Mattermost」
- **接入模式**:固定为 **Webhook**(选择 Mattermost 后界面会禁用 WebSocket)
- **输出模式**:流式输出 / 完整输出(流式通过编辑帖子实现)
- **Site URL**:Mattermost 站点根地址,如 `https://mattermost.example.com`(无尾部 `/`)
- **Bot Token**:上一步生成的 Token
- **Outgoing Webhook Token**:出站 Webhook 页面复制的 Token(用于校验回调,且参与 `bot_identity` 去重)
- **Bot User ID**(可选):Bot 在 Mattermost 中的用户 ID;填写后可忽略 Bot 自身触发的回调,避免自回复循环
3. 保存后,复制渠道卡片上的 **回调地址**,回到 Mattermost 出站 Webhook 配置,将 **Callback URLs** 设为该地址并保存。
#### 第四步:验证
在绑定频道发送触发出站 Webhook 的消息,应收到 WeKnora 的回复;回复默认出现在**触发帖所在线程**(使用 Mattermost 的 `root_id` 与触发 `post_id` 对齐)。
**参考文档:** [Outgoing webhooks](https://developers.mattermost.com/integrate/webhooks/outgoing/)
---
## 前端管理
IM 渠道在 Agent 编辑器的 **IM 集成** 标签页中管理(仅编辑模式可见,创建 Agent 时不显示)。
### 渠道列表
每个渠道以卡片形式展示,包含:
- **平台标识**:企业微信(绿色)/ 飞书(青色)/ Lark(蓝色)/ Slack(紫色)/ Telegram(蓝色)/ 钉钉(蓝色)/ Mattermost(蓝色)
- **渠道名称**:用户自定义
- **接入模式**:WebSocket / Webhook
- **输出模式**:流式输出 / 完整输出
- **启用开关**:可即时启用/停用渠道
- **回调地址**:Webhook 模式下显示,可一键复制
- **编辑/删除**:管理渠道配置
### 渠道操作
- **添加渠道**:选择平台 → 填写凭证 → 选择模式 → 保存
- **编辑渠道**:可修改名称、模式、输出模式和凭证(平台不可更改)
- **启用/停用**:通过开关即时切换,停用的渠道不会处理消息
- **删除渠道**:删除后不可恢复
---
## 架构总览
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ IM 集成架构 │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 企业微信 │ │ 飞书 │ │ Slack │ │ Telegram │ │ 钉钉 │ IM层│
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ WH/WS │ WH/WS │ WH/WS │ WH/LP │ WH/Stream │
│ ─────┼─────────────┼────────────┼─────────────┼─────────────┼────────── │
│ ▼ ▼ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ WeCom │ │ Feishu │ │ Slack │ │ Telegram │ │ DingTalk │ 适配 │
│ │ Adapter │ │ Adapter │ │ Adapter │ │ Adapter │ │ Adapter │ 器层 │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │ │ │
│ ─────┼─────────────┼─────────────┼──────────────┼─────────────┼──────── │
│ └─────────────┼─────────────┼──────────────┘─────────────┘ │
│ │ │
│ ┌────┴────────────┐ │
│ │ Mattermost │ Webhook-only │
│ └────────┬────────┘ │
│ ▼ │
│ ┌────────────────┐ │
│ │ Mattermost │ │
│ │ Adapter │ │
│ └────────┬────────┘ │
│ │ │
│ ──────────────────┴────────────────────────────────────────────────────│
│ ▼ │
│ ┌──────────────────────────────────┐ │
│ │ im.Service │ 服务编排层 │
│ │ │ · IM 渠道管理 (CRUD) │
│ │ ┌────────────────────────────┐ │ · Adapter Factory (动态创建) │
│ │ │ CommandRegistry │ │ · 斜杠指令分发 │
│ │ │ qaQueue (Worker Pool) │ │ · QA 队列调度 (有界, 异步) │
│ │ │ rateLimiter (滑动窗口) │ │ · 滑动窗口限流 │
│ │ │ processedMsgs (去重) │ │ · 消息去重 (MessageID + TTL) │
│ │ │ inflight (取消跟踪) │ │ · 会话映射 (ChannelSession) │
│ │ └────────────────────────────┘ │ · 流式/全量路由 │
│ └──────────────┬───────────────────┘ │
│ │ │
│ ───────────────┼─────────────────────────────────────────────────────── │
│ ▼ │
│ ┌──────────────────────────────────────┐ │
│ │ WeKnora Core (QA Pipeline) │ 核心层 │
│ │ SessionService · MessageService │ │
│ │ TenantService · AgentService │ │
│ │ KnowledgeService (文件保存) │ │
│ └──────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────┘
```
**设计模式:**
| 模式 | 用途 |
|------|------|
| Adapter Pattern | 统一不同 IM 平台的差异,每个平台实现 `im.Adapter` 接口 |
| Factory Pattern | 通过 `AdapterFactory` 从数据库渠道配置动态创建 Adapter 实例 |
| Strategy Pattern | `StreamSender`、`FileDownloader` 可选接口,按需实现 |
| Command Pattern | `Command` 接口 + `CommandRegistry` 实现可插拔的斜杠指令系统 |
| Producer-Consumer | `qaQueue` 有界队列 + Worker Pool,解耦消息接收与 QA 执行 |
| Event-Driven | 通过 `EventBus` 解耦 QA 管道与 IM 输出,支持实时块推送 |
---
## 数据模型
### im_channels 表
IM 渠道配置存储在 `im_channels` 表中,绑定到 Agent:
```sql
CREATE TABLE im_channels (
id VARCHAR(36) PRIMARY KEY,
tenant_id BIGINT NOT NULL,
agent_id VARCHAR(36) NOT NULL, -- 绑定的 Agent ID
platform VARCHAR(20) NOT NULL, -- 'wecom' | 'feishu' | 'lark' | 'slack' | 'telegram' | 'dingtalk' | 'mattermost'
name VARCHAR(255) NOT NULL DEFAULT '',
enabled BOOLEAN NOT NULL DEFAULT true,
mode VARCHAR(20) NOT NULL DEFAULT 'websocket', -- 'webhook' | 'websocket'
output_mode VARCHAR(20) NOT NULL DEFAULT 'stream', -- 'stream' | 'full'
knowledge_base_id VARCHAR(36), -- 可选,绑定知识库以接收文件消息
bot_identity VARCHAR(255), -- 计算字段,防止重复机器人
credentials JSONB NOT NULL DEFAULT '{}', -- 平台凭证
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMPTZ
);
```
**credentials 字段结构:**
| 平台 | 模式 | 字段 |
|------|------|------|
| 企业微信 | WebSocket | `bot_id`, `bot_secret` |
| 企业微信 | Webhook | `corp_id`, `agent_secret`, `token`, `encoding_aes_key`, `corp_agent_id` |
| 飞书 / Lark | WebSocket | `app_id`, `app_secret` |
| 飞书 / Lark | Webhook | `app_id`, `app_secret`, `verification_token`, `encrypt_key` |
| Slack | WebSocket | `app_token`, `bot_token` |
| Slack | Webhook | `bot_token`, `signing_secret` |
| Telegram | WebSocket | `bot_token` |
| Telegram | Webhook | `bot_token`, `secret_token`(可选) |
| 钉钉 | WebSocket | `client_id`, `client_secret`, `card_template_id`(可选) |
| 钉钉 | Webhook | `client_id`, `client_secret`, `card_template_id`(可选) |
| Mattermost | Webhook(唯一支持) | `site_url`, `bot_token`, `outgoing_token`(必填);`bot_user_id`(可选,过滤机器人自身消息) |
`mattermost` 渠道的 `mode` 在数据库中固定为 `webhook`(创建时若未指定,服务端与模型钩子会默认 `webhook`)。`bot_identity` 形如 `mattermost:wh:{outgoing_token}`,用于防止同一出站 Webhook 重复绑定多个渠道。
### im_channel_sessions 表
将 IM 渠道中的用户会话映射到 WeKnora 会话:
```
(im_channel_id, Platform, UserID, ChatID, TenantID) → SessionID
```
首次交互自动创建,后续消息复用同一会话。`/clear` 指令会软删除会话记录,下次消息重新创建。
---
## API 端点
### IM 渠道管理 API(需认证)
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/v1/agents/:id/im-channels` | 创建 IM 渠道 |
| GET | `/api/v1/agents/:id/im-channels` | 列出 Agent 的所有 IM 渠道 |
| PUT | `/api/v1/im-channels/:id` | 更新 IM 渠道 |
| DELETE | `/api/v1/im-channels/:id` | 删除 IM 渠道 |
| POST | `/api/v1/im-channels/:id/toggle` | 启用/停用 IM 渠道 |
### IM 回调端点(无需认证,平台签名验证)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET/POST | `/api/v1/im/callback/:channel_id` | 通用回调(根据 channel_id 自动路由到对应 Adapter) |
> Webhook 模式下,每个渠道有唯一的回调地址 `/api/v1/im/callback/{channel_id}`,在前端渠道卡片上可一键复制。回调路由注册在认证中间件**之前**,由平台签名验证保护。
---
## 核心概念
### IMChannel — IM 渠道
每个 IM 渠道代表一个 IM 平台机器人与 WeKnora Agent 的绑定关系。一个 Agent 可以绑定多个渠道(如同时接入企业微信、飞书、Slack 与 Mattermost),同一平台也可以创建多个渠道(如不同的企业微信机器人)。
渠道有一个计算字段 `BotIdentity`,由平台类型、模式和核心凭证推导,用于防止同一机器人被重复创建。
渠道启动时,Service 通过 `AdapterFactory` 根据平台类型和凭证动态创建对应的 Adapter 实例。
### IncomingMessage — 统一入站消息
所有平台的消息在解密、解析后被归一化为 `IncomingMessage`,抹平平台差异:
```go
type IncomingMessage struct {
Platform Platform // "wecom" | "feishu" | "lark" | "slack" | "telegram" | "dingtalk" | "mattermost"
MessageType MessageType // "text" | "file" | "image"
UserID string // 平台用户标识
UserName string // 显示名 (可选)
ChatID string // 群聊 ID (私聊为空)
ChatType ChatType // "direct" | "group"
Content string // 纯文本内容
MessageID string // 平台消息 ID (用于去重)
FileKey string // 文件标识 (文件/图片消息)
FileName string // 文件名 (文件/图片消息)
FileSize int64 // 文件大小 (字节)
Extra map[string]string // 平台特有字段 (如 req_id、aes_key)
}
```
### ReplyMessage — 统一出站回复
```go
type ReplyMessage struct {
Content string // Markdown 文本
IsStreaming bool // 是否为流式块
IsFinal bool // 是否为最后一块
Extra map[string]string // 平台特有字段
}
```
### ChannelSession — 会话映射
将 IM 渠道 (渠道 ID + 用户 + 群聊) 映射到 WeKnora 会话,实现对话上下文持续性。首次交互自动创建,后续消息复用同一会话。并发创建通过唯一约束 + fallback 查询处理。存储于 `im_channel_sessions` 表。
---
## 消息处理流程
### 完整消息处理流程
```
用户在 IM 中发送消息
│
▼
┌─ HTTP Handler / WebSocket 回调 ─────────────────┐
│ 1. 根据 channel_id 查找渠道配置 │
│ 2. 获取对应 Adapter │
│ 3. 签名验证 (VerifyCallback) │
│ 4. URL 验证处理 (HandleURLVerification) │
│ 5. 解密 + 解析 → IncomingMessage (ParseCallback) │
│ 6. 立即返回 HTTP 200 (异步处理) │
└──────────────────────────┬──────────────────────-┘
│ goroutine
▼
┌─ im.Service.HandleMessage ──────────────────────┐
│ 1. 去重检查 (MessageID, 5 分钟 TTL) │
│ 2. 内容长度校验 (≤ 4096 rune,超出截断) │
│ 3. 斜杠指令检测 → 命中则分发到 CommandRegistry │
│ 4. 限流检查 (滑动窗口, 10次/60s) │
│ 5. 从渠道配置获取 agent_id、tenant_id │
│ 6. 解析/创建 ChannelSession │
│ 7. 获取 WeKnora Session │
│ 8. 加载 Agent 配置(获取知识库、模型等信息) │
│ 9. 所有消息提交到 qaQueue (有界队列, 异步执行) │
└───────────┬─────────────────────────────────────┘
│
▼
┌─ qaQueue Worker ────────────────────────────────┐
│ 从队列取出请求,记录 inflight,判断流式/全量模式 │
└───────────┬─────────────────────┬───────────────┘
│ │
流式模式 ▼ 全量模式 ▼
┌────────────────────┐ ┌─────────────────────┐
│ handleMessageStream│ │ runQA (阻塞收集完整 │
│ │ │ 回答后一次性发送) │
│ · StartStream │ └─────────────────────┘
│ · EventBus 订阅 │
│ · 300ms 批量刷新 │
│ · 工具事件展示 │
│ · UpdateStreamContent │
│ · FinalizeStream │
│ · EndStream │
└────────────────────┘
│
▼
消息持久化 (user + assistant)
```
### 渠道生命周期
```
渠道创建/更新 (前端 UI)
│
▼
┌─ im.Service ──────────────────────────┐
│ 1. 保存渠道配置到数据库 │
│ 2. 如果渠道已启用: │
│ a. AdapterFactory 创建 Adapter │
│ b. WebSocket 模式:建立长连接 │
│ c. Webhook 模式:注册回调处理 │
│ 3. 维护 channels map (channel_id → │
│ channelState{Channel, Adapter}) │
└────────────────────────────────────────┘
服务启动时:
LoadAndStartChannels() → 从 DB 加载所有 enabled 的渠道 → 逐个 StartChannel()
渠道停用/删除时:
StopChannel() → 取消 Adapter 上下文 → 从 map 移除
```
---
## 接口定义
### im.Adapter — 平台适配器 (必须实现)
```go
type Adapter interface {
Platform() Platform
VerifyCallback(c *gin.Context) error
ParseCallback(c *gin.Context) (*IncomingMessage, error)
SendReply(ctx context.Context, incoming *IncomingMessage, reply *ReplyMessage) error
HandleURLVerification(c *gin.Context) bool
}
```
| 方法 | 职责 |
|------|------|
| `Platform()` | 返回平台标识,用于路由和注册 |
| `VerifyCallback()` | 验证回调请求的签名/Token |
| `ParseCallback()` | 解密并解析回调为 `IncomingMessage`,非消息事件返回 `nil` |
| `SendReply()` | 通过平台 API 发送完整回复 |
| `HandleURLVerification()` | 处理平台初始 URL 验证(首次配置时调用) |
### im.StreamSender — 流式推送 (可选)
```go
type StreamSender interface {
StartStream(ctx context.Context, incoming *IncomingMessage) (streamID string, err error)
UpdateStreamContent(ctx context.Context, incoming *IncomingMessage, streamID string, fullContent string) error
FinalizeStream(ctx context.Context, incoming *IncomingMessage, streamID string, finalContent string) error
EndStream(ctx context.Context, incoming *IncomingMessage, streamID string) error
}
```
- `UpdateStreamContent`:流式过程中用**当前可见全文**替换消息(replace 语义,非增量追加)
- `FinalizeStream`:结束前最后一次替换,通常为折叠思考/工具进度后的最终展示(多为纯答案)
实现此接口后,Service 会自动路由到流式模式。渠道配置 `output_mode: "full"` 可强制关闭。
### im.FileDownloader — 文件下载 (可选)
```go
type FileDownloader interface {
DownloadFile(ctx context.Context, msg *IncomingMessage) (io.ReadCloser, string, error)
}
```
实现此接口后,文件和图片可作为 QA 附件供模型理解。配置 `knowledge_base_id` 时,附件还会在后台保存到指定知识库;未配置时仅跳过保存。
### im.AdapterFactory — 适配器工厂
```go
type AdapterFactory func(ctx context.Context, channel *IMChannel, msgHandler func(*IncomingMessage)) (Adapter, CancelFunc, error)
```
每个平台注册一个工厂函数,Service 在启动渠道时调用工厂创建 Adapter 实例。工厂函数根据渠道的 `mode` 和 `credentials` 决定创建哪种 Adapter。
---
## 平台适配器详解
### 企业微信 (WeCom)
提供两种连接模式,对应两套适配器实现:
#### Webhook 模式 (`WebhookAdapter`)
适用于**自建应用**,需要公网可访问的回调地址。
```
企业微信服务器 ──HTTP POST──▶ /api/v1/im/callback/{channel_id}
│
解密 (AES-256-CBC)
解析 XML → IncomingMessage
│
处理完成后调用 WeCom REST API 回复
```
- **加密方案:** AES-256-CBC,Key 由 `encoding_aes_key` Base64 解码得到(32 字节),IV 为 Key 前 16 字节
- **消息格式:** `random(16) + msg_len(4) + message + corp_id`,PKCS#7 填充
- **签名验证:** SHA-1(`sort([token, timestamp, nonce, encrypt])`),常量时间比较
- **消息类型:** 支持 `text`(文本)和 `image`(图片,PicUrl 直接下载或 MediaId 临时素材 API)
- **群聊回复:** 优先尝试 `appchat/send` 群聊 API,失败时降级到私聊直发
- **回复方式:** 通过 `/cgi-bin/message/send` 接口发送 Markdown 消息
#### WebSocket 模式 (`WSAdapter` + `LongConnClient`)
适用于**智能客服机器人**,无需公网域名,由客户端主动建立 WebSocket 长连接。
```
LongConnClient ══WebSocket══▶ wss://openws.work.weixin.qq.com
│
1. 发送 aibot_subscribe (bot_id + secret)
2. 接收 aibot_msg_callback 消息帧
3. 通过 aibot_respond_msg 回复
4. 每 30s 心跳保活 (ping/pong)
5. 断连自动重连 (指数退避 1s → 30s)
```
- **认证:** Bot ID + Bot Secret
- **消息类型:** `text`(文本)、`image`(图片)、`file`(文件)、`voice`(语音,服务端已转文本)、`mixed`(混合,文本 + 图片)、`event`(服务器事件)
- **文件解密:** 附件使用每消息独立 AES-256-CBC 密钥解密(IV 为密钥前 16 字节)
- **流式回复:** 通过 WebSocket 帧发送累积全文,`finish=true` 标记结束
- **容错:** 指数退避重连(基础 1s,上限 30s),读超时 = 3 × 心跳间隔(90s)
#### 源码文件
| 文件 | 职责 |
|------|------|
| `internal/im/wecom/webhook_adapter.go` | Webhook 模式:回调解密、签名验证、REST API 回复、群聊发送、Token 缓存、文件下载 |
| `internal/im/wecom/ws_adapter.go` | WebSocket 模式适配器壳,代理到 `LongConnClient` |
| `internal/im/wecom/longconn.go` | WebSocket 客户端:连接管理、心跳、帧协议、自动重连、多消息类型解析、文件解密 |
---
### 飞书 (Feishu) 与 Lark
统一适配器同时支持 Webhook 和 WebSocket 模式,且原生实现 `StreamSender` 和 `FileDownloader` 接口。
#### 双云共用一套适配器
飞书与 Lark 是同一产品部署在两朵隔离的云上,API 与事件结构完全一致,因此 `internal/im/feishu`
包同时服务两个平台,由 `Region` 决定具体连哪朵云:
```go
// internal/im/feishu/region.go
type Region struct {
Platform im.Platform // "feishu" | "lark",写入 IncomingMessage
OpenBaseURL string // https://open.feishu.cn | https://open.larksuite.com
Label string // 日志前缀 [Feishu] / [Lark]
ThinkingText string // 流式卡片占位文案(中文 / 英文)
ImageFallbackLabel string // 图片上传失败时降级链接的文案
}
var (
RegionFeishu = Region{Platform: im.PlatformFeishu, OpenBaseURL: "https://open.feishu.cn", ...}
RegionLark = Region{Platform: im.PlatformLark, OpenBaseURL: "https://open.larksuite.com", ...}
)
```
容器中注册为两个平台:
```go
// internal/container/container.go
imService.RegisterAdapterFactory("feishu", feishu.NewFactory(feishu.RegionFeishu))
imService.RegisterAdapterFactory("lark", feishu.NewFactory(feishu.RegionLark))
```
适配器所有 API 调用经 `a.api(path, args...)` 拼接 `region.OpenBaseURL`;WebSocket 模式通过
`larkws.WithDomain(region.OpenBaseURL)` 指向对应云(SDK 默认连 open.feishu.cn,Lark 不指定会认证失败)。
> **跨云资源不通用**:`tenant_access_token`、`image_key`、`file_key`、`card_id` 均由单朵云的
> 单个应用签发,跨云或跨应用使用会被拒绝。图片上传缓存因此以 `app_id` 为前缀分区。
#### IM 适配器实际调用的接口与所需权限
下表说明适配器**实际**依赖哪些权限。表中的权限标识已对照 Lark 官方 API 文档逐条核对,
飞书侧同名。
> **两朵云的权限清单并不等价。** API 与事件结构一致,不代表权限目录一致——飞书开放平台
> 上存在的部分权限,Lark 开放平台并没有。因此各自的配置清单请分别参照
> [飞书接入](#飞书接入)与 [Lark 权限配置](#lark-权限配置),不要跨云复制粘贴。
>
> 飞书一节的 JSON 还同时覆盖了**飞书数据源连接器**(Wiki 同步),所以包含
> `wiki:wiki:readonly`、`docs:document.content:read`、`sheets:spreadsheet` 等 IM 用不到的权限。
| 适配器调用 | 用途 | 所需权限(满足其一即可) |
|---|---|---|
| `POST /auth/v3/tenant_access_token/internal` | 换取 tenant access token | 无需权限(凭 App ID / Secret) |
| `POST /im/v1/messages/{id}/reply` | 回复消息(主路径) | `im:message`、`im:message:send_as_bot` 或 `im:message:send` |
| `POST /im/v1/messages` | 发送消息(回复失败时降级) | 同上 |
| `GET /im/v1/messages/{id}/resources/{key}` | 下载用户发来的文件 / 图片 | `im:message` 或 `im:message:readonly` |
| `POST /im/v1/images` | 上传图片以取得 `image_key` | `im:resource` 或 `im:resource:upload` |
| `POST /cardkit/v1/cards`<br>`PUT /cardkit/v1/cards/{id}/elements/{eid}/content`<br>`PATCH /cardkit/v1/cards/{id}/settings` | 流式卡片(仅流式输出模式需要) | `cardkit:card:write` |
| 订阅事件 `im.message.receive_v1`(单聊) | 接收私聊消息 | `im:message.p2p_msg` 或 `im:message.p2p_msg:readonly` |
| 订阅事件 `im.message.receive_v1`(群聊 @) | 接收群内 @机器人 消息 | `im:message.group_at_msg` 或 `im:message.group_at_msg:readonly` |
因此 IM 功能全开(私聊 + 群聊 @ + 文件 + 图片 + 流式卡片)最少需要 5 类权限:收私聊、收群 @、
发消息、读写资源、写卡片。若关闭流式输出(输出模式选「完整输出」),`cardkit:card:write` 可省略。
#### Webhook 模式
```
飞书服务器 ──HTTP POST──▶ /api/v1/im/callback/{channel_id}
│
解密 (AES-256-CBC,可选)
解析 JSON → IncomingMessage
│
通过飞书 Open API 回复
```
- **加密方案:** AES-256-CBC,Key 为 `SHA-256(encrypt_key)`,IV 为密文前 16 字节
- **事件过滤:** 仅处理 `im.message.receive_v1` 事件,忽略其他事件类型
- **消息类型:** `text`(文本)、`file`(文件)、`image`(图片)、`post`(富文本,提取标题 + 结构化内容)
- **群消息处理:** 自动去除 `@_user_xxx` 提及前缀
#### WebSocket 模式
通过飞书官方 SDK (`github.com/larksuite/oapi-sdk-go`) 建立长连接,事件推送与 Webhook 等价,无需公网域名,内置自动重连。
#### 流式回复 (CardKit v1)
飞书的流式输出基于 **CardKit 卡片流式更新**,是官方推荐的最佳实践:
```
StartStream:
1. POST /cardkit/v1/cards → 创建卡片实体 (streaming_mode: true)
2. POST /im/v1/messages → 发送卡片消息到聊天
UpdateStreamContent:
3. PUT /cardkit/v1/cards/{id}/elements/{eid}/content → 更新元素内容 (累积全文)
FinalizeStream:
4. PUT /cardkit/v1/cards/{id}/elements/{eid}/content → 最终可见内容(通常为纯答案)
EndStream:
5. PATCH /cardkit/v1/cards/{id}/settings → 设置 streaming_mode: false
```
每次 `UpdateStreamContent` / `FinalizeStream` 发送的是**累积全文**而非增量,由 `feishuStreamState` 跟踪完整内容和严格递增的 `sequence` 序号。
**Think 块处理:** 流式输出中的 `<think>...</think>` 块会被转换为飞书 Markdown 引用块格式:
```
> 💭 **思考过程**
> [thinking content line 1]
> [thinking content line 2]
```
> **已知限制**:`Region.ThinkingText` 只本地化了卡片的初始占位文案(Lark 显示 `Thinking...`)。
> 一旦开始流式输出,think 块标题来自共享的 `MarkdownThinkStyle`,目前对所有平台
> (Lark、Slack、Telegram、Mattermost 等)都是中文。本地化它需要把 locale 贯穿到
> `FormatIMDisplayContent`,属于跨平台改动,不在 Lark 接入范围内。
**孤立流清理:** 后台协程每 1 分钟扫描超过 5 分钟未关闭的流式卡片,自动调用 `EndStream` 关闭(防止内存泄漏)。
#### 源码文件
| 文件 | 职责 |
|------|------|
| `internal/im/feishu/region.go` | `Region` 定义(飞书 / Lark 的域名、平台标识、日志前缀、本地化文案) |
| `internal/im/feishu/adapter.go` | 事件解析、CardKit 流式实现、Token 缓存、AES 解密、Think 块转换、文件下载 |
| `internal/im/feishu/longconn.go` | WebSocket 长连接(封装飞书 SDK)、事件分发 |
| `internal/im/feishu/factory.go` | 按 `Region` 构造适配器与长连接客户端 |
---
### Slack
统一适配器同时支持 Webhook 和 WebSocket (Socket Mode) 模式,且原生实现 `StreamSender` 接口。
#### Webhook 模式 (Events API)
```
Slack 服务器 ──HTTP POST──▶ /api/v1/im/callback/{channel_id}
│
签名验证 (HMAC-SHA256)
解析 JSON → IncomingMessage
│
通过 Slack Web API 回复
```
- **签名验证:** 使用 `signing_secret` 对请求体进行 HMAC-SHA256 签名验证,防止伪造请求。
- **事件过滤:** 仅处理 `message` 和 `app_mention` 事件,忽略机器人自己发送的消息。
- **URL 验证:** 自动处理 Slack 的 `url_verification` challenge 请求。
#### WebSocket 模式 (Socket Mode)
通过 `slack-go/slack/socketmode` 建立长连接,事件推送与 Webhook 等价,无需公网域名,内置自动重连。
```
LongConnClient ══WebSocket══▶ wss://wss-primary.slack.com
│
1. 使用 App Token 建立连接
2. 接收 Events API 消息帧
3. 确认消息 (Ack)
4. 通过 Slack Web API 回复
```
#### 流式回复
Slack 的流式输出基于消息更新 (chat.update) 实现:
```
StartStream:
1. POST /chat.postMessage → 发送初始消息,获取 ts (timestamp)
UpdateStreamContent:
2. POST /chat.update → 根据 ts 更新消息内容 (累积全文)
FinalizeStream:
3. POST /chat.update → 最终可见内容替换
EndStream:
4. 无需特殊操作
```
每次 `UpdateStreamContent` / `FinalizeStream` 发送的是**累积全文**而非增量。
#### 源码文件
| 文件 | 职责 |
|------|------|
| `internal/im/slack/adapter.go` | 事件解析、签名验证、流式实现、文件下载 |
| `internal/im/slack/longconn.go` | WebSocket 长连接(封装 slack-go Socket Mode) |
---
### Telegram
统一适配器同时支持 Webhook 和长轮询模式,原生实现 `StreamSender` 和 `FileDownloader` 接口。
#### Webhook 模式
```
Telegram 服务器 ──HTTP POST──▶ /api/v1/im/callback/{channel_id}
│
Secret Token 验证(可选)
解析 JSON → IncomingMessage
│
通过 Telegram Bot API 回复
```
- **签名验证:** 通过 `X-Telegram-Bot-Api-Secret-Token` 请求头与配置的 `secret_token` 进行常量时间比较
- **消息类型:** `text`(文本)、`document`(文件,通过 file_id 下载)、`photo`(图片,自动选择最大尺寸)
- **群消息处理:** 自动去除 `@bot` 提及前缀
#### 长轮询模式 (Long Polling)
Telegram 的 WebSocket 模式实际使用长轮询(Long Polling)而非真正的 WebSocket,通过持续调用 `getUpdates` API 获取新消息。
```
LongConnClient ──HTTP POST──▶ https://api.telegram.org/bot<token>/getUpdates
│
1. 发送 getUpdates (timeout=30s,长轮询)
2. 解析返回的 Update 列表
3. 更新 offset 以确认已处理
4. 循环继续
5. 错误时退避 3 秒重试
```
- **超时设置:** HTTP 客户端 35s,长轮询 timeout 30s
- **消息确认:** 通过 `offset` 参数自动确认已处理的消息
#### 流式回复
Telegram 的流式输出基于消息编辑 (editMessageText) 实现:
```
StartStream:
1. POST sendMessage → 发送初始 "正在思考..." 消息,获取 message_id
UpdateStreamContent:
2. POST editMessageText → 根据 message_id 更新消息内容(累积全文,纯文本)
FinalizeStream:
3. POST editMessageText → 最终可见内容替换(与流式阶段相同,纯文本)
EndStream:
4. 清理流状态
```
每次 `UpdateStreamContent` / `FinalizeStream` 发送的是**累积全文**而非增量,最小编辑间隔 500ms(避免触发 Telegram 速率限制)。中间态与终态均使用纯文本,避免 Markdown 解析失败。
**Think 块处理:** 流式输出中的 `<think>...</think>` 块会被转换为 Telegram 引用块格式:
```
> 💭 *思考过程*
> [thinking content line 1]
> [thinking content line 2]
```
**孤立流清理:** 后台协程每 1 分钟扫描超过 5 分钟未关闭的流式消息,自动清理(防止内存泄漏)。
#### 文件下载
Telegram 支持文件和图片消息的下载,流程:
1. 调用 `getFile` API 获取文件路径
2. 通过 `https://api.telegram.org/file/bot<token>/<file_path>` 下载文件
#### 源码文件
| 文件 | 职责 |
|------|------|
| `internal/im/telegram/adapter.go` | 事件解析、Secret Token 验证、流式实现(editMessage)、文件下载 |
| `internal/im/telegram/longconn.go` | 长轮询客户端:getUpdates 循环、offset 管理、错误退避 |
---
### 钉钉 (DingTalk)
统一适配器同时支持 Webhook 和 Stream 模式,原生实现 `StreamSender` 接口。支持可选的 AI 卡片流式输出。
#### Webhook 模式
```
钉钉服务器 ──HTTP POST──▶ /api/v1/im/callback/{channel_id}
│
HmacSHA256 签名验证
解析 JSON → IncomingMessage
│
通过 sessionWebhook 或 OpenAPI 回复
```
- **签名验证:** 使用 `client_secret` 对 `timestamp + "\n" + secret` 进行 HmacSHA256 签名,Base64 编码后与请求头 `Sign` 比较,时间戳 1 小时内有效
- **回复方式:** 优先使用回调消息体中的 `sessionWebhook` 回复(适用于群聊场景),失败或不可用时降级到 OpenAPI
- 群聊:`/v1.0/robot/groupMessages/send`
- 私聊:`/v1.0/robot/oToMessages/batchSend`
- **消息格式:** Markdown 格式(`sampleMarkdown`)
- **认证:** 通过 `/v1.0/oauth2/accessToken` 获取 Access Token,带缓存(提前 5 分钟刷新)
#### Stream 模式 (WebSocket)
通过钉钉官方 SDK (`github.com/open-dingtalk/dingtalk-stream-sdk-go`) 建立 Stream 连接,事件推送与 Webhook 等价,无需公网域名,内置自动重连。
```
LongConnClient ══Stream══▶ 钉钉 Stream 服务
│
1. 使用 ClientID + ClientSecret 建立连接
2. 注册 ChatBot 回调处理
3. 接收消息事件
4. SDK 内置重连和心跳
```
#### 流式回复 (AI 卡片)
钉钉的流式输出基于 **AI 互动卡片** 实现(需配置 `card_template_id`):
```
StartStream:
1. POST /v1.0/card/instances/createAndDeliver → 创建并投递 AI 卡片
UpdateStreamContent:
2. PUT /v1.0/card/streaming → 流式更新卡片内容(累积全文)
FinalizeStream:
3. PUT /v1.0/card/streaming → 最终可见内容替换
EndStream:
4. PUT /v1.0/card/streaming (isFinalize=true) → 标记流式结束
```
每次 `UpdateStreamContent` / `FinalizeStream` 发送的是**累积全文**(`isFull: true`),最小更新间隔 500ms。
**无卡片模板时的降级策略:** 未配置 `card_template_id` 时,流式内容在内存中累积,`EndStream` 时一次性通过 `sessionWebhook` 或 OpenAPI 发送完整回复。
**Think 块处理:** 使用与飞书相同的 Markdown 引用块格式(`MarkdownThinkStyle`):
```
> 💭 **思考过程**
> [thinking content line 1]
> [thinking content line 2]
```
**孤立流清理:** 后台协程每 1 分钟扫描超过 5 分钟未关闭的流,自动清理防止内存泄漏。
#### 源码文件
| 文件 | 职责 |
|------|------|
| `internal/im/dingtalk/adapter.go` | 事件解析、HmacSHA256 签名验证、AI 卡片流式实现、Access Token 缓存、OpenAPI 调用 |
| `internal/im/dingtalk/longconn.go` | Stream 客户端(封装钉钉 SDK)、ChatBot 消息分发 |
---
### Mattermost
仅支持 **Webhook** 模式:Mattermost **Outgoing Webhook** 将请求体 POST 到 WeKnora 统一回调地址;适配器实现 `Adapter`、`StreamSender`、`FileDownloader`(标准库 HTTP 调用 REST API,无第三方 SDK)。
#### Webhook 模式(Outgoing Webhook)
```
Mattermost 服务器 ──HTTP POST──▶ /api/v1/im/callback/{channel_id}
│
校验 body 中 token = outgoing_token
解析 JSON 或 x-www-form-urlencoded
│
通过 REST API v4 回复(Bearer Bot Token)
```
- **安全校验:** 请求体中的 `token` 必须与渠道凭证中的 `outgoing_token` 一致(创建渠道时工厂会校验 `outgoing_token` 非空)。
- **载荷格式:** 支持 `application/json` 与 `application/x-www-form-urlencoded`(与 [官方文档](https://developers.mattermost.com/integrate/webhooks/outgoing/) 一致)。
- **机器人过滤:** 若配置了 `bot_user_id`,且 `user_id` 与之相同,则返回 `nil` 消息,避免自激。
- **线程回复:** `IncomingMessage.Extra` 中保存 `thread_root_id`(有 `root_id` 时用其值,否则用触发帖 `post_id`);`SendReply` / `CreatePost` 时设置 `root_id`,使回复出现在同一线程。
- **消息去重:** `MessageID` 使用触发帖的 `post_id`。
- **文件消息:** 若载荷含 `file_ids`,取首个文件 ID 作为 `FileKey`;`DownloadFile` 先 `GET /api/v4/files/{id}/info` 再 `GET /api/v4/files/{id}` 下载内容。
#### 流式回复
与 Slack 类似,通过**编辑同一条帖子**展示累积全文:
```
StartStream:
1. POST /api/v4/posts → 创建占位帖(如「正在思考...」),得到 post id
UpdateStreamContent:
2. PUT /api/v4/posts/{post_id}/patch → Patch message 字段(累积全文)
FinalizeStream:
3. PUT /api/v4/posts/{post_id}/patch → 最终可见内容替换
EndStream:
4. 清理流状态
```
流式刷新间隔由 Service 侧 `streamFlushInterval`(300ms)批量合并,以降低编辑频率、减轻 API 压力。
#### URL 验证
Mattermost 出站 Webhook 无 Slack/Feishu 类 challenge 流程,`HandleURLVerification` 恒为 `false`。
#### 源码文件
| 文件 | 职责 |
|------|------|
| `internal/im/mattermost/adapter.go` | 出站 Webhook 解析、Token 校验、发帖/补丁流式、文件下载 |
| `internal/im/mattermost/client.go` | REST v4:`CreatePost`、`PatchPostMessage`、文件 info/下载 |
| `internal/im/mattermost/form_parse.go` | 表单编码 body 与 `file_ids` 辅助解析 |
---
## 斜杠指令系统
IM 渠道支持斜杠指令(Slash Commands),用户在聊天中输入 `/指令名` 即可触发,无需经过 QA 管道,且不受限流约束。
### 内置指令
| 指令 | 参数 | 说明 |
|------|------|------|
| `/help` | `[指令名]` | 显示所有可用指令列表;带参数时显示指定指令的详细用法 |
| `/info` | — | 查看当前绑定智能体的名称、角色设定、知识库列表等信息 |
| `/search` | `<关键词>` | 对绑定的知识库执行混合检索(向量 + 关键词),返回最多 5 条原文片段,不经过 AI 总结 |
| `/stop` | — | 取消当前排队中或正在执行的 QA 请求 |
| `/clear` | — | 清空当前对话记忆(软删除 ChannelSession),下次消息开始全新会话 |
### 指令分发流程
```
用户消息 ──▶ HandleMessage
│
├─ 以 "/" 开头?
│ │
│ ├─ 已注册指令 → CommandRegistry.Parse → Command.Execute → 回复结果
│ │ │
│ │ ActionClear → 软删除 ChannelSession
│ │ ActionStop → 取消排队/执行中的 QA
│ │
│ └─ LooksLikeCommand() = true 但未注册
│ → 回复 "未知指令,发送 /help 查看"
│ LooksLikeCommand() = false (如 "/api/v2/users")
│ → 当作普通消息,进入 QA 管道
│
└─ 普通消息 → 限流检查 → qaQueue → QA 管道
```
> `LooksLikeCommand()` 通过检查首 token 是否含有 `/` 分隔符来区分指令尝试和 URL 路径,避免误拦截。
### 扩展自定义指令
实现 `im.Command` 接口并在 Service 初始化时注册到 `CommandRegistry`:
```go
type Command interface {
Name() string // 指令名 (不含 "/")
Description() string // 一行描述,用于 /help 输出
Execute(ctx context.Context, cmdCtx *CommandContext, args []string) (*CommandResult, error)
}
```
**设计约定:**
- 依赖(DB、Service)通过构造函数注入,不放在 `CommandContext` 中
- 用户输入错误通过 `CommandResult` 返回友好提示,`error` 仅用于基础设施故障(DB 异常、网络错误等)
- 通过 `CommandResult.Action` 声明副作用意图(如清空会话),由 Service 执行
- 重复注册同名指令会在启动时 panic,确保配置错误尽早暴露
### 源码文件
| 文件 | 职责 |
|------|------|
| `internal/im/command.go` | Command 接口、CommandAction、CommandContext 定义 |
| `internal/im/command_registry.go` | CommandRegistry:指令注册、解析、分发、LooksLikeCommand |
| `internal/im/cmd_help.go` | `/help` 指令实现 |
| `internal/im/cmd_info.go` | `/info` 指令实现(展示 Agent 信息、知识库列表) |
| `internal/im/cmd_search.go` | `/search` 指令实现(混合检索,最多 5 条,内容截断 200 rune) |
| `internal/im/cmd_stop.go` | `/stop` 指令实现 |
| `internal/im/cmd_clear.go` | `/clear` 指令实现 |
---
## QA 队列与限流
### QA 队列 (qaQueue)
有界工作池队列管理 QA 请求,防止并发过载:
```
消息 ──▶ Enqueue ──▶ [ 等待队列 (≤50) ] ──▶ Worker Pool (5 workers) ──▶ QA 管道
│ │
├─ 队列已满 → 拒绝并回复提示 │
├─ 用户排队超限 (≤3) → 拒绝 ├─ 等待超时 (>60s) → 丢弃并通知
└─ /stop → Remove(userKey) 取消 └─ 正常执行 QA
```
**设计要点:**
- **有界队列**:最大容量 50,防止内存无限增长
- **Per-User 背压**:单用户最多同时排队 3 个请求,避免单用户刷屏占满队列
- **排队等待提示**:入队成功且队列非空时,回复 "前面还有 N 条消息在处理,请稍候"
- **排队超时**:请求在队列中等待超过 60 秒自动丢弃,回复超时提示
- **可取消**:`/stop` 指令通过 `qaQueue.Remove(userKey)` 取消排队请求,通过 `inflight` map 中的 `context.CancelFunc` 取消执行中请求
- **指标监控**:每 30 秒输出队列深度、活跃 Worker 数、入队/处理/拒绝/超时计数(仅在有活动时输出)
### 滑动窗口限流 (rateLimiter)
在消息进入 QA 队列之前,按 `channelID:userID:chatID` 维度进行滑动窗口限流:
| 参数 | 值 | 说明 |
|------|------|------|
| 窗口大小 | 60s | 滑动时间窗口 |
| 最大请求数 | 10 次/窗口 | 每个用户每分钟最多 10 条消息进入 QA |
| 清理周期 | 1 min | 自动清理过期条目,防止内存泄漏 |
超出限流时回复提示消息,不计入队列。斜杠指令不受限流约束。
### 源码文件
| 文件 | 职责 |
|------|------|
| `internal/im/qaqueue.go` | qaQueue:有界队列、Worker Pool、QueueMetrics、指标上报 |
| `internal/im/ratelimit.go` | slidingWindowLimiter:per-key 滑动窗口限流、并发安全清理 |
---
## 流式输出机制
流式模式通过 `EventBus` 实时收集 QA 管道产生的内容块,以 **300ms 间隔批量推送**,在延迟与 API 限频之间取得平衡:
```
QA 管道 ──chunk──chunk──chunk──▶ EventBus
│
每 300ms 刷新
│
┌───────────▼───────────┐
│ 累积内容 → 完整替换推送 │
│ (非增量,每次发送全文) │
└───────────────────────┘
```
### 内容处理
- **Think 块过滤/转换**:`<think>...</think>` 块在飞书和钉钉中转换为 Markdown 引用块展示,在 Telegram 中转换为引用块(斜体标题),在其他平台中过滤
- **工具事件展示**:Agent 工具调用实时展示调用状态
- 调用中:`⏳ [工具名]`(包裹在 think 块内)
- 调用成功:`✅ [工具名] · [摘要]`
- 调用失败:`⚠️ [工具名] 失败`
- 内部工具(thinking、todo_write 等)不展示给用户
- **空内容回退**:流式过程中无可见内容产生时,回退到完整回复模式 (`fallbackNonStream`)
- **完整持久化**:完整内容(含 thinking)持久化到数据库,确保历史完整
### 平台流式特殊处理
- **"正在思考..." 占位**:飞书和 Telegram 在流式初始化后立即显示占位文本,提升用户感知
- **孤立流清理**:飞书、Telegram、钉钉的后台协程每 `streamReaperInterval`(1 分钟)扫描超过 `streamOrphanTTL`(5 分钟)未关闭的流,自动关闭防止内存泄漏
- **Think 块转换**:飞书和钉钉将 `<think>` 标签转换为 Markdown 引用块(`> 💭 **思考过程**`),Telegram 使用斜体标题(`> 💭 *思考过程*`)
---
## 文件消息处理
文件和图片会作为 QA 附件供模型理解,用户只收到该消息对应的 QA 回复。`knowledge_base_id` 配置后会额外触发后台入库;未配置时不入库、不报错。入库结果和后续解析不再产生额外 IM 通知。解析出的附件文本最多保留前 500 行且不超过 32 KiB;发生任一限制时,模型会收到通用的截断提示。
**各平台文件下载方式:**
| 平台 | 方式 |
|------|------|
| 飞书 | GetMessageResource API(通过 FileKey) |
| 企业微信 Webhook | PicUrl 直接下载 或 MediaId 临时素材 API |
| 企业微信 WebSocket | 加密附件 URL + per-message AES 密钥解密 |
| Telegram | getFile API 获取文件路径 + HTTPS 下载(支持文档和图片) |
| Mattermost | `GET /api/v4/files/{file_id}/info` + `GET /api/v4/files/{file_id}`(Bearer Bot Token) |
---
## 关键参数与阈值
| 参数 | 值 | 说明 |
|------|------|------|
| `qaTimeout` | 120s | QA 管道最大执行时间 |
| `dedupTTL` | 5 min | 消息去重 ID 保留时长 |
| `dedupCleanupInterval` | 1 min | 去重清理周期 |
| `maxContentLength` | 4096 | 消息最大长度 (rune),超出截断 |
| `streamFlushInterval` | 300ms | 流式内容批量刷新间隔 |
| `defaultMaxQueueSize` | 50 | QA 队列最大容量 |
| `defaultMaxPerUser` | 3 | 单用户最大排队请求数 |
| `defaultWorkers` | 5 | QA 并发 Worker 数 |
| `queueTimeout` | 60s | 请求在队列中的最大等待时间 |
| `rateLimitWindow` | 60s | 限流滑动窗口大小 |
| `rateLimitMaxRequests` | 10 | 每用户每窗口最大请求数 |
| `metricsLogInterval` | 30s | 队列指标日志上报周期 |
| `streamOrphanTTL` | 5 min | 飞书/Telegram/钉钉 孤立流超时时间 |
| `streamReaperInterval` | 1 min | 飞书/Telegram/钉钉 孤立流清理扫描周期 |
| Telegram 编辑间隔 | 500ms | editMessageText 最小调用间隔(避免速率限制)|
| Telegram 长轮询超时 | 30s | getUpdates timeout 参数 |
| Telegram 错误退避 | 3s | getUpdates 失败后等待时间 |
| DingTalk 卡片更新间隔 | 500ms | AI 卡片流式更新最小间隔 |
| DingTalk 签名有效期 | 1h | Webhook 回调签名时间戳有效窗口 |
| WeCom WS 心跳 | 30s | WebSocket 保活频率 |
| WeCom WS 读超时 | 90s | 3 × 心跳间隔,允许一次心跳丢失 |
| WeCom WS 重连退避 | 1s → 30s | 指数退避,上限 30 秒 |
| Token 缓存安全余量 | 5 min | Token 过期前提前刷新 |
---
## 错误处理
| 场景 | 处理策略 |
|------|---------|
| 流式初始化失败 | 自动降级到全量模式 (`fallbackNonStream`) |
| QA 管道异常 | 回复 "抱歉,处理您的问题时出现了异常,请稍后再试。" |
| QA 超时 (>120s) | 标记消息完成,回复超时提示 |
| 空回答 | 回复 "抱歉,我暂时无法回答这个问题。" |
| 空流式内容 | 无可见内容时回退到完整回复 |
| WebSocket 断连 | 指数退避自动重连 |
| 平台重试 | MessageID 去重,5 分钟内自动跳过 |
| 渠道启动失败 | 日志记录错误,不影响其他渠道 |
| QA 队列已满 | 拒绝请求并回复 "当前排队人数较多,请稍后再试。" |
| 用户排队超限 | 拒绝请求并回复提示(单用户 ≤3) |
| 排队等待超时 | 超过 60s 自动丢弃,回复 "您的消息等待超时,请重新发送。" |
| 消息限流 | 滑动窗口内超过 10 次,回复限流提示 |
| 飞书孤立流 | 每分钟扫描,超过 5 分钟未关闭的自动结束 |
| Telegram/钉钉孤立流 | 同飞书,每分钟扫描并自动清理 |
| 企业微信群聊回复失败 | appchat API 失败时降级到用户私聊 |
| 钉钉 AI 卡片创建失败 | 降级到 sessionWebhook 或 OpenAPI 回复 |
| 钉钉 sessionWebhook 不可用 | 降级到 OpenAPI(群聊/私聊分别调用不同端点) |
---
## 扩展新平台
接入新的 IM 平台只需 3 步:
### 1. 实现 `im.Adapter` 接口
在 `internal/im/<platform>/` 下创建适配器:
```go
package myplatform
type Adapter struct { /* 平台配置 */ }
func (a *Adapter) Platform() im.Platform { return "myplatform" }
func (a *Adapter) VerifyCallback(c *gin.Context) error { /* 签名验证 */ }
func (a *Adapter) ParseCallback(c *gin.Context) (*im.IncomingMessage, error) { /* 解析消息 */ }
func (a *Adapter) SendReply(ctx context.Context, incoming *im.IncomingMessage, reply *im.ReplyMessage) error { /* 发送回复 */ }
func (a *Adapter) HandleURLVerification(c *gin.Context) bool { /* URL 验证 */ }
```
可选接口:
- 实现 `im.StreamSender` 以支持流式输出
- 实现 `im.FileDownloader` 以支持文件消息自动保存到知识库
> 可参考已有的 Telegram(纯 HTTP API)和 DingTalk(SDK + AI 卡片)适配器作为实现参考。
### 2. 注册适配器工厂
在 `internal/container/container.go` 的 `registerIMAdapterFactories` 中注册工厂函数:
```go
imService.RegisterAdapterFactory("myplatform", func(ctx context.Context, channel *im.IMChannel, msgHandler func(*im.IncomingMessage)) (im.Adapter, im.CancelFunc, error) {
creds := parseCredentials(channel.Credentials)
appKey := getString(creds, "app_key")
appSecret := getString(creds, "app_secret")
adapter := myplatform.NewAdapter(appKey, appSecret)
// WebSocket 模式需要启动长连接
if channel.Mode == "websocket" {
cancelCtx, cancel := context.WithCancel(ctx)
go adapter.StartLongConn(cancelCtx, msgHandler)
return adapter, func() { cancel() }, nil
}
return adapter, func() {}, nil
})
```
### 3. 前端添加平台选项
在 `IMChannelPanel.vue` 中:
- 添加平台 radio 选项
- 添加该平台的凭证表单字段
在 i18n 文件中添加平台名称翻译。
Service 层 (`im.Service`) 不需要任何修改 — 渠道管理、指令分发、消息编排、会话管理、QA 调度、限流、流式控制全部由 Service 统一处理。