1808 lines
80 KiB
Markdown
1808 lines
80 KiB
Markdown
# 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 统一处理。
|