上一版按"webhook + URL 里带令牌"自己编了一套,用户当场指出微信必须先扫码配对。 现在照腾讯 iLink Bot 协议自己做客户端:取二维码 → 手机扫码确认 → 存加密登录态 → 后端长轮询收消息 → 用 context_token 回话。独立实现,不依赖 DSH 的运行环境。 后端 - wechat_ilink.py:get_bot_qrcode / get_qrcode_status / getupdates / sendmessage, bot_token 用 Fernet(密钥由 JWT_SECRET 派生)加密入库,日志与接口都不回明文 - wechat_intake.py:一句话 → 任务或知识草稿,前缀识别、#标签、首行标题 - wechat_bot.py:每条通道一个轮询任务,启动时按 status='active' 恢复; message_id 去重;errcode -14 转 expired 让用户重新扫 - api/integrations.py 重写成扫码三接口 + 停用/断开/最近消息 - 迁移 0013(登录态列)、0014(轮询入口按 app.bot_channel 主键的 RLS 口子) Web - 接入页改成扫码流程:页面里画出二维码、每 2 秒问一次状态、连上显示微信用户与最近消息 验证 - pytest 149 passed(新增 test_wechat_bot 9 例) - ruff check / ruff format --check 干净;tsc 0;vite build ✓ - web/e2e/integrations.mjs 7 项 ALL PASS(拿的是微信真二维码) 教训:接入第三方协议先去已有实现里找协议,别从直觉倒推。
This commit is contained in:
+39
-48
@@ -699,78 +699,69 @@ Query:
|
||||
|
||||
## 6.5 外部接入(微信 clawbot)
|
||||
|
||||
微信里那个机器人把消息 POST 到本服务,消息就变成任务或知识草稿。
|
||||
**这一组接口不带登录态**:机器人不会登我们的账号,它只照着配置好的地址发。
|
||||
微信里那个机器人连上本服务之后,发一句话就变成任务或知识草稿。
|
||||
走的是腾讯 iLink Bot 协议:**扫码登录 → 后端长轮询收消息 → 用 context_token 回复**。
|
||||
所以这一组里只有「扫码」是免登录的(机器人自己不带我们的令牌),其余都要工作区 admin 以上。
|
||||
|
||||
### 6.5.1 建通道(工作区 admin 以上)
|
||||
### 6.5.1 扫码与通道(工作区 admin 以上)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/workspaces/{ws}/integrations` | 列出这个工作区的通道 |
|
||||
| POST | `/workspaces/{ws}/integrations` | `{"name":"微信"}`,**响应里带一次性令牌** |
|
||||
| PATCH | `/workspaces/{ws}/integrations/{id}` | `{"name"?}`、`{"status":"active"|"disabled"}` |
|
||||
| POST | `/workspaces/{ws}/integrations/{id}/rotate` | 换令牌,旧地址立刻失效 |
|
||||
| DELETE | `/workspaces/{ws}/integrations/{id}` | 删通道(历史消息级联删掉) |
|
||||
| GET | `/workspaces/{ws}/integrations/{id}/messages?limit=20` | 最近的收信记录 |
|
||||
| GET | `/workspaces/{ws}/integrations` | 列出通道(含 `status`、`connected`、`polling`、`last_seen_at`、`last_error`) |
|
||||
| POST | `/workspaces/{ws}/integrations/wechat/qr` | 取一张二维码,返回 `{channel_id, qrcode_id, payload, image, expires_in}` |
|
||||
| GET | `/workspaces/{ws}/integrations/wechat/qr/{qrcode_id}` | 问扫码结果:`waiting`/`scaned`/`confirmed`/`expired` |
|
||||
| PATCH | `/workspaces/{ws}/integrations/{id}` | `{"status":"active"|"disabled"}`,停用就停轮询 |
|
||||
| DELETE | `/workspaces/{ws}/integrations/{id}?confirm=true` | 断开:清掉登录态与游标,回到 `pending` |
|
||||
| GET | `/workspaces/{ws}/integrations/{id}/messages?limit=5` | 最近收到的消息与它变成了什么 |
|
||||
|
||||
创建响应:
|
||||
取二维码的响应(`image` 是可以直接塞进 `<img>` 的 data URL,本地渲染,不是图片地址):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0, "message": "ok",
|
||||
"data": {
|
||||
"channel": { "id": "…", "kind": "wechat_clawbot", "name": "微信", "status": "active",
|
||||
"last_seen_at": null, "created_at": "2026-11-05T14:02:11Z" },
|
||||
"token": "lS7…(43 字符)",
|
||||
"webhook_url": "https://example.com/api/v1/integrations/clawbot/lS7…"
|
||||
}
|
||||
"channel_id": "4f0a…",
|
||||
"qrcode_id": "30c97db83291e779217cbe20c5373e91",
|
||||
"payload": "https://liteapp.weixin.qq.com/q/7GiQu1?qrcode=30c97…&bot_type=3",
|
||||
"image": "data:image/svg+xml;base64,PD94bWw…",
|
||||
"expires_in": 300
|
||||
}
|
||||
```
|
||||
|
||||
> 令牌只在这里出现一次。库里存的是 `sha256(令牌)`,页面刷新之后连管理员也看不到;
|
||||
> 丢了就 `rotate` 重新生成。
|
||||
扫码成功那一次(`status == "confirmed"`)除了回 `status`,还带 `channel`:登录态已加密存进
|
||||
`bot_channel.bot_token_enc`(Fernet,密钥由 `JWT_SECRET` 派生),**不会回给任何接口**,轮询任务同时被拉起。
|
||||
|
||||
### 6.5.2 收消息(clawbot → 本服务)
|
||||
`status` 的四个值:`pending`(还没连)、`active`(连上了、正在收)、`disabled`(管理员停用)、
|
||||
`expired`(登录态失效,需要重新扫码)。`polling` 说明后台任务此刻是否在跑。
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/integrations/clawbot/{token}?echostr=xxx` | 地址校验:有 `echostr` 就原样回纯文本 |
|
||||
| POST | `/integrations/clawbot/{token}` | 收一条消息,回 `{"reply": "...", "action": "...", "duplicate": false}` |
|
||||
### 6.5.2 消息怎么变成事项(不分接口,轮询里做)
|
||||
|
||||
请求体**宽松解析**(不同机器人的壳子不一样),按顺序找第一个非空字符串:
|
||||
给机器人发的话这样理解:
|
||||
|
||||
- 正文:`text` / `content` / `msg` / `message` / `query` / `body` / `data`(嵌套对象也会往里找 `text`、`content`、`body`、`message`)
|
||||
- 消息 id:`msg_id` / `message_id` / `msgid` / `id` / `uuid` / `event_id`
|
||||
- 发送者:`from` / `sender` / `from_user` / `user` / `uid` / `sender_id`
|
||||
|
||||
正文的写法:
|
||||
|
||||
| 开头 | 结果 |
|
||||
| 开头 | 落成什么 |
|
||||
|---|---|
|
||||
| 直接说 | 任务(`status=inbox`) |
|
||||
| `任务:` / `待办:` / `todo:` | 任务 |
|
||||
| `知识:` / `笔记:` / `note:` / `kb:` | 知识草稿(`doc_type=note`、`status_hint=draft`) |
|
||||
| `?` / `帮助` / `help` / `用法` | 只回一段用法说明,不建事项 |
|
||||
| `任务:`/`待办:`/`todo:` 或不带前缀 | 任务(`status = "inbox"`) |
|
||||
| `知识:`/`笔记:`/`note:`/`kb:` | 知识草稿(`status = "draft"`,`doc_type = "note"`) |
|
||||
| `帮助`/`?`/`help` | 只回一段用法,不建任何东西 |
|
||||
|
||||
首行是标题(截到 180 字),其余行是正文;标题与正文里的 `#标签` 会被摘出来挂到事项上
|
||||
(纯数字的 `#123` 不算标签,避免把编号当成标签)。
|
||||
正文第一行当标题(超过 180 字截断),其余当正文;`#标签` 会被摘出来加到事项上(纯数字的不算标签)。
|
||||
建完之后会用 `context_token` 在微信里回一句,附上链接(`PUBLIC_BASE_URL` 或 CORS 里的第一个来源)。
|
||||
同一条消息(按 `message_id`)重复推送只会落一次,重推直接跳过。
|
||||
|
||||
**幂等**:带 `msg_id` 的请求重复到达只处理一次,第二次直接回上次的 `reply` 且 `duplicate: true`
|
||||
——微信会重推,重推不能变成两条任务。
|
||||
图片/文件/视频目前只在 `bot_message.raw` 里留痕,正文不搬;语音如果服务端给了转写就用转写文本。
|
||||
|
||||
### 6.5.3 错误码
|
||||
|
||||
| code | HTTP | 场景 |
|
||||
| 场景 | code | HTTP |
|
||||
|---|---|---|
|
||||
| 9002 | 404 | 令牌不存在或已被换掉;对外一律"通道不存在",不区分工作区 |
|
||||
| 9003 | 403 | 通道被停用(`status=disabled`) |
|
||||
| 取二维码 / 问状态时微信返回错误 | `9002` | 404 |
|
||||
| 通道不存在、或不属于这个工作区 | `9002` | 404 |
|
||||
| 登录态失效(微信 errcode `-14`) | —(通道转 `expired`,接口正常返回) | 200 |
|
||||
|
||||
### 6.5.4 租户隔离
|
||||
### 6.5.4 表与隔离
|
||||
|
||||
`bot_channel`(通道、登录态、游标)与 `bot_message`(收到的消息留痕)都挂 `workspace_id`,
|
||||
走标准 RLS。轮询任务从 `channel_id` 起步、此刻还没有工作区上下文,所以迁移 `0014`
|
||||
额外给了一条 `app.bot_channel` 的按主键口子(permissive 策略,与原隔离策略 OR)。
|
||||
|
||||
`bot_channel` / `bot_message` 都带 `workspace_id NOT NULL` + RLS。收消息时还不知道它属于哪个
|
||||
工作区,所以 `bot_channel` 的策略额外允许 `token_hash = current_setting('app.bot_token_hash')`:
|
||||
请求先把 `app.bot_token_hash` 设成本次令牌的哈希,策略只放行命中那一行,其余行照旧看不见。
|
||||
读到通道之后再 `bind_tenant()` 进这个工作区的上下文,任务是按**通道创建者**记账的。
|
||||
|
||||
## 7. 安卓发布与升级 `/app`
|
||||
|
||||
|
||||
+59
-40
@@ -749,62 +749,81 @@
|
||||
|
||||
### 13.2 决定
|
||||
|
||||
1. **一条通道一张令牌,令牌在 URL 里**。机器人不会登我们的账号;它只会照着配置好的地址
|
||||
POST。令牌是 32 字节随机串,库里只存 `sha256`,页面上"创建/重新生成"时显示一次。
|
||||
2. **一个工作区可以有多个通道**,各自能单独停用。塞进 `system_setting` 的 JSON 里就做不到
|
||||
"停这个、留那个"。
|
||||
3. **收消息用宽松解析**。clawbot 的消息壳子没有公开的固定 schema,与其猜错就 422,
|
||||
不如按常见字段名依次摸一遍,摸不到正文就记一条 `ignored` 留痕。
|
||||
4. **同一 `msg_id` 只处理一次**。微信会重推,重推变成第二条任务是最难解释的脏数据。
|
||||
5. **每条消息都留痕**(`bot_message`,含原始 JSON、处理结果、 reply)。
|
||||
1. **走腾讯官方 iLink Bot 协议**:`get_bot_qrcode` 拿二维码 → 手机扫码确认 →
|
||||
拿到 `bot_token` → 后端**长轮询** `getupdates` 收消息 → 用 `context_token` 回话。
|
||||
这是 clawbot / openclaw / dsh-im 走的同一条路,扫码是配对的前提。
|
||||
2. **不依赖 DSH**:协议自己实现(`backend/app/services/wechat_ilink.py`),
|
||||
因为达道Task 是独立项目,不该为了收消息去依赖另一个产品的运行环境。
|
||||
3. **登录态加密存库**:`bot_channel.bot_token_enc` 用 Fernet(密钥由 `JWT_SECRET` 派生),
|
||||
`bot_token` 不落明文、不回调给任何接口、日志里也没有。
|
||||
4. **轮询者是我们自己**:每个已连接的通道一个 asyncio 任务,进程启动时按库里的
|
||||
`status='active'` 恢复;token 失效(微信 errcode `-14`)就把通道转 `expired`,让用户重新扫。
|
||||
5. **一条消息只落一次**:按 `message_id` 去重,微信重推不会变成第二条任务。
|
||||
6. **每条消息都留痕**(`bot_message`,含文本、动作、item_id、回复、raw),
|
||||
出问题时能直接回答"那条任务到底进没进"。
|
||||
7. **一个工作区一条微信通道**:同一时间只允许一条 `kind='wechat_clawbot'` 的记录承载登录态;
|
||||
断开就是清登录态回到 `pending`,再连就重新扫。
|
||||
|
||||
### 13.3 后端
|
||||
### 13.3 协议要点(实现对照)
|
||||
|
||||
| 用途 | 请求 |
|
||||
|---|---|
|
||||
| 取二维码 | `POST https://ilinkai.weixin.qq.com/ilink/bot/get_bot_qrcode?bot_type=3` → `{qrcode, qrcode_img_content}` |
|
||||
| 问扫码结果 | `POST /ilink/bot/get_qrcode_status?qrcode=<id>` → `waiting`/`scaned`/`confirmed`/`expired` |
|
||||
| 收消息 | `POST /ilink/bot/getupdates` body `{get_updates_buf, base_info:{channel_version}}`,长轮询 70s |
|
||||
| 回消息 | `POST /ilink/bot/sendmessage` body `{msg:{to_user_id, context_token, item_list:[{type:1,text_item:{text}}]}}` |
|
||||
|
||||
请求头固定带 `iLink-App-ClientVersion: 1` 与 `X-WECHAT-UIN`(随机数 base64);
|
||||
登录后加 `Authorization: Bearer <bot_token>` + `AuthorizationType: ilink_bot_token`。
|
||||
二维码不是图片地址,`qrcode_img_content` 是待渲染的 payload,服务端用 `qrcode` 库画成 SVG data URL。
|
||||
|
||||
### 13.4 后端
|
||||
|
||||
| 位置 | 内容 |
|
||||
|---|---|
|
||||
| `backend/app/models_integrations.py` | `BotChannel`(令牌哈希、状态、最后收信时间)、`BotMessage`(文本、动作、item_id、reply、原始 JSON) |
|
||||
| `backend/alembic/versions/0012_bot_channel.py` | 建两张表 + RLS;`bot_channel` 策略额外放行 `token_hash = current_setting('app.bot_token_hash')` |
|
||||
| `backend/app/api/integrations.py` | 收消息 `GET/POST /integrations/clawbot/{token}`;管理接口 `/workspaces/{ws}/integrations` |
|
||||
| `backend/app/errors.py` | `9002` 令牌无效、`9003` 通道停用 |
|
||||
| `backend/app/services/wechat_ilink.py` | 只封协议:取码、问状态、长轮询、回话、消息解析、token 加解密 |
|
||||
| `backend/app/services/wechat_intake.py` | 文本 → 事项:前缀识别、`#标签`、标题/正文切分、建任务/知识、回复文案 |
|
||||
| `backend/app/services/wechat_bot.py` | `BotManager`:起停轮询任务、去重、留痕、游标、`-14` 转 `expired` |
|
||||
| `backend/app/api/integrations.py` | `POST /workspaces/{ws}/integrations/wechat/qr`、`GET …/qr/{id}`、`PATCH`、`DELETE`、`GET …/messages` |
|
||||
| `backend/app/models_integrations.py` | `BotChannel`(`bot_token_enc`、`ilink_user_id`、`sync_buf`、`context_token`、`last_seen_at`、`last_error`)、`BotMessage` |
|
||||
| `backend/alembic/versions/0013_wechat_bot_login.py` | 加登录态相关列,`status` 默认 `pending` |
|
||||
| `backend/alembic/versions/0014_bot_channel_worker_policy.py` | 轮询入口没有工作区上下文,按 `app.bot_channel` 主键开一条 permissive 策略 |
|
||||
| `backend/app/main.py` | lifespan 里 `manager.start_all()` / `manager.shutdown()` |
|
||||
|
||||
正文解析:首行是标题(180 字封顶),其余是正文;`#标签` 摘出来挂事项上(纯数字不算);
|
||||
前缀 `知识:`/`笔记:` → 知识草稿,`任务:`/`todo:` → 任务,没有前缀就是任务。
|
||||
前缀 `知识:`/`笔记:` → 知识草稿,`任务:`/`todo:` → 任务,没有前缀就是任务,`帮助`/`?` 只回用法。
|
||||
|
||||
### 13.4 Web
|
||||
### 13.5 Web
|
||||
|
||||
新增 `/w/:wsId/integrations`「接入」页(左侧栏最后一项):
|
||||
|
||||
- 「怎么用」三步说明;
|
||||
- 「新建通道」→ 一次性展示**回调地址 + 令牌**,各带复制按钮(clawbot 后台要粘的就是这里);
|
||||
- 每条通道:状态、最后收信时间、`最近消息`(最近 5 条,带"建了任务/建了知识"标签)、
|
||||
停用/启用、重新生成令牌、删除;
|
||||
- 只有 owner/admin 看得到写操作。
|
||||
- 「扫码连接微信」→ 页面上直接画出二维码,每 2 秒问一次状态(待扫 → 已扫待确认 → 成功/过期);
|
||||
- 连上之后显示通道卡片:接收中/登录已失效/已停用、最后收信时间、"微信用户 xxx"、
|
||||
`最近消息`(最近 5 条,标"建了任务/建了知识")、重新扫码、断开;
|
||||
- 只有 owner/admin 能扫码与断开,其他成员只读。
|
||||
|
||||

|
||||
|
||||
### 13.5 验证
|
||||
### 13.6 验证
|
||||
|
||||
- `backend/tests/test_clawbot.py` 11 例:纯文本成任务、`知识:`前缀成草稿并带标签、
|
||||
`帮助`只回复不建事项、错令牌 404/9002、停用通道 403/9003、同 `msg_id` 幂等、
|
||||
GET `echostr` 原样回、令牌只属于一个工作区、换令牌后旧地址失效、普通成员不能建通道、
|
||||
回调地址必须带 `/api/v1`。
|
||||
- `web/e2e/integrations.mjs` 7 项(真跑):页面建通道 → 取回调地址 → 冒充 clawbot POST →
|
||||
任务出现在任务列表 → 重复推送不建第二条 → 「最近消息」看得到 → 控制台零报错。
|
||||
- `backend/tests/test_wechat_bot.py` 9 例:取二维码(SVG data URL、通道 `pending`)、
|
||||
扫码确认后登录态加密入库且能解回、过期二维码回 `expired`、断开清登录态、
|
||||
纯文本成任务、`知识:`前缀成草稿带标签、`帮助`不建事项、同一 `message_id` 只落一条并只回一次、
|
||||
登录态失效后通道转 `expired` 且带原因。
|
||||
- `web/e2e/integrations.mjs` 7 项(真跑,拿的是微信真二维码):接入页打开 → 点扫码 →
|
||||
二维码是页面里的 SVG → 提示用微信扫 → 后端通道 `pending` → 没登录态不轮询 → 控制台零报错。
|
||||
- 全量 `pytest` 149 passed;`ruff check` / `ruff format --check` 干净。
|
||||
|
||||
### 13.6 还没做
|
||||
### 13.7 教训:第一版是猜的
|
||||
|
||||
- 只做了"进"(收消息建事项),没做"出"(改完任务回一条微信通知)。
|
||||
- 消息不认人:机器人只知道"这是这个工作区的消息",按通道创建者记账;要认人得先做微信身份绑定。
|
||||
- 语音、图片、文件只留痕不处理。
|
||||
第一版没找到资料,按"webhook + 令牌在 URL 里"自己编了一套(用户看到的回调地址那版)。
|
||||
用户当场指出"微信要先扫码配对",一查才发现协议在
|
||||
`~/.dsh/profiles/desktop/node_modules/@michengai/dsh-im-connect/src/channels/weixin.ts` 里写着。
|
||||
**教训:接入第三方协议,先去已有实现里找协议,别从直觉倒推。** 那一版的迁移 `0012` 保留
|
||||
(`bot_channel` / `bot_message` 两张表还在用),`token_hash` 列留着但不再使用。
|
||||
|
||||
### 13.7 上生产时踩的坑
|
||||
### 13.8 还没做
|
||||
|
||||
回调地址一开始用 `request.base_url` 拼,本地没问题(Vite 把 `/api/v1` 代理掉了),
|
||||
生产上应用挂在域名根、前缀只在路由里,拼出来的地址少了 `/api/v1`,clawbot 打过去是 `405`。
|
||||
现在从 `base_url` 里截站点根、前缀一律取 `settings.api_prefix`,并补了一条断言钉住。
|
||||
|
||||
生产实测(工作区「我的备忘」):`POST /api/v1/integrations/clawbot/<token>`
|
||||
→ `200 code=0 action=task`,任务列表里立刻出现一条。
|
||||
|
||||

|
||||
- 只做了"进"(收消息建事项),没做"出"(任务有进展回一条微信通知)。
|
||||
- 消息不认人:按通道创建者记账;要认人得先做微信身份绑定。
|
||||
- 语音只在服务端给了转写时使用,图片/文件/视频只留痕不搬。
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 64 KiB |
Reference in New Issue
Block a user