上一版按"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 |
@@ -0,0 +1,63 @@
|
||||
"""微信 clawbot 改成"扫码登录 + 长轮询"
|
||||
|
||||
Revision ID: 0013
|
||||
Revises: 0012
|
||||
Create Date: 2026-11-05
|
||||
|
||||
上一版(0012)做成了"clawbot 往我们这 POST webhook"。真实协议是腾讯 iLink Bot:
|
||||
我们先扫码登录拿 `bot_token`,再由我们长轮询 `getupdates` 拉消息(见
|
||||
`app/services/wechat_ilink.py` 文件头的出处说明)。所以这张表要补登录态与游标:
|
||||
|
||||
- `bot_token_enc`:加密后的登录态(密钥由 JWT_SECRET 派生),换 secret 就要重新扫码;
|
||||
- `ilink_user_id`:扫码的微信号,界面上显示"谁扫的";
|
||||
- `base_url`:服务端可能下发新基址;
|
||||
- `sync_buf`:长轮询游标,丢了会重复收消息;
|
||||
- `context_token`:回复必须带对方上一条消息的 context_token。
|
||||
|
||||
`bot_channel.status` 增加 `pending`(等扫码)与 `expired`(登录态失效)两个取值;
|
||||
`token_hash` 是上一版遗留列,改成可空、不再使用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
from app.roles import APP_ROLE
|
||||
|
||||
revision: str = "0013"
|
||||
down_revision: str = "0012"
|
||||
branch_labels: Sequence[str] | None = None
|
||||
depends_on: Sequence[str] | None = None
|
||||
|
||||
STATEMENTS = (
|
||||
"ALTER TABLE bot_channel ADD COLUMN IF NOT EXISTS bot_token_enc text",
|
||||
"ALTER TABLE bot_channel ADD COLUMN IF NOT EXISTS ilink_user_id varchar(64)",
|
||||
"ALTER TABLE bot_channel ADD COLUMN IF NOT EXISTS base_url text",
|
||||
"ALTER TABLE bot_channel ADD COLUMN IF NOT EXISTS sync_buf text",
|
||||
"ALTER TABLE bot_channel ADD COLUMN IF NOT EXISTS context_token text",
|
||||
"ALTER TABLE bot_channel ALTER COLUMN token_hash DROP NOT NULL",
|
||||
"ALTER TABLE bot_channel ALTER COLUMN status SET DEFAULT 'pending'",
|
||||
"COMMENT ON COLUMN bot_channel.bot_token_enc IS 'iLink 登录态,Fernet 加密(密钥由 JWT_SECRET 派生)'",
|
||||
"COMMENT ON COLUMN bot_channel.sync_buf IS 'getupdates 游标,丢了会重复收消息'",
|
||||
f"GRANT SELECT, INSERT, UPDATE, DELETE ON bot_channel TO {APP_ROLE}",
|
||||
)
|
||||
|
||||
DOWN_STATEMENTS = (
|
||||
"ALTER TABLE bot_channel DROP COLUMN IF EXISTS context_token",
|
||||
"ALTER TABLE bot_channel DROP COLUMN IF EXISTS sync_buf",
|
||||
"ALTER TABLE bot_channel DROP COLUMN IF EXISTS base_url",
|
||||
"ALTER TABLE bot_channel DROP COLUMN IF EXISTS ilink_user_id",
|
||||
"ALTER TABLE bot_channel DROP COLUMN IF EXISTS bot_token_enc",
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for statement in STATEMENTS:
|
||||
op.execute(statement)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for statement in DOWN_STATEMENTS:
|
||||
op.execute(statement)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""轮询任务读通道需要一条 RLS 口子
|
||||
|
||||
Revision ID: 0014
|
||||
Revises: 0013
|
||||
Create Date: 2026-11-05
|
||||
|
||||
后台轮询是"从 channel_id 开始"的:它还没有工作区上下文,而 `bot_channel` 的策略要求
|
||||
`workspace_id = app_ws_id()`,直接 SELECT 会一行都看不到(先有鸡还是先有蛋)。
|
||||
|
||||
和 0012 那条 `token_hash = app_bot_token_hash()` 同理,这里再放一条**只按主键命中自己**
|
||||
的口子:任务在读任何东西之前先把 `app.bot_channel` 设成本次要处理的通道 id,
|
||||
策略允许它看见那一行,其余行照旧不可见。读到 workspace_id 之后立刻 `bind_tenant`,
|
||||
后面所有查询都回到正常的租户上下文里。
|
||||
|
||||
两条策略是 permissive 关系(PG 会 OR 起来),所以原来的隔离策略不用改。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
from app.roles import APP_ROLE
|
||||
|
||||
revision: str = "0014"
|
||||
down_revision: str = "0013"
|
||||
branch_labels: Sequence[str] | None = None
|
||||
depends_on: Sequence[str] | None = None
|
||||
|
||||
CREATE_POLICY = """
|
||||
CREATE POLICY bot_channel_worker ON bot_channel
|
||||
FOR ALL
|
||||
USING (id = current_setting('app.bot_channel', true)::uuid)
|
||||
WITH CHECK (id = current_setting('app.bot_channel', true)::uuid)
|
||||
"""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("DROP POLICY IF EXISTS bot_channel_worker ON bot_channel")
|
||||
op.execute(CREATE_POLICY)
|
||||
op.execute(f"GRANT SELECT, INSERT, UPDATE, DELETE ON bot_channel TO {APP_ROLE}")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP POLICY IF EXISTS bot_channel_worker ON bot_channel")
|
||||
+142
-466
@@ -1,407 +1,62 @@
|
||||
"""外部接入:微信(clawbot)发来的消息变成任务 / 知识。
|
||||
"""外部接入:微信 clawbot 的扫码登录与状态。
|
||||
|
||||
## 一条消息是怎么变成事项的
|
||||
真实协议是腾讯 iLink Bot(出处见 `app/services/wechat_ilink.py` 文件头):
|
||||
**我们**去取二维码、等人扫、拿到登录态后长轮询拉消息。所以这里的接口是三件事:
|
||||
|
||||
微信里发一句话给机器人,clawbot 把消息 POST 到
|
||||
1. `POST /workspaces/{ws}/integrations/wechat/qr` —— 取一张二维码,页面显示给人扫;
|
||||
2. `GET /workspaces/{ws}/integrations/wechat/qr/{id}` —— 问"扫了吗",扫成功就存登录态并开始轮询;
|
||||
3. `DELETE /workspaces/{ws}/integrations/{channel_id}` —— 断开:登录态作废、轮询停止。
|
||||
|
||||
POST /api/v1/integrations/clawbot/{token}
|
||||
二维码 id 放 Redis 而不是内存:问状态的那一下可能落到另一个进程,
|
||||
存内存就会"扫完了但没人知道"。key 带工作区,两个工作区互不踩。
|
||||
|
||||
令牌在 URL 里,**不带登录态**:机器人不会登我们的账号,它只会照着配置好的
|
||||
地址发。令牌是 32 字节随机串,库里只存 sha256。
|
||||
|
||||
正文按前缀决定去哪个池子:
|
||||
|
||||
知识:xxx / 笔记:xxx / note: xxx → 知识草稿
|
||||
任务:xxx / todo: xxx → 任务(默认也是任务)
|
||||
?、帮助、help → 回一段用法说明
|
||||
|
||||
首行是标题,其余行是正文。首行里的 `#标签` 会被摘出来挂到事项上
|
||||
(标签是知识的主要检索入口,手机上敲#比打开网页加标签省事)。
|
||||
|
||||
## 为什么每条消息都留痕
|
||||
|
||||
微信会重推(网络抖动、机器人重启)。没有留痕就只能靠猜"那条任务到底进没进"。
|
||||
`bot_message.external_id` 上有个部分唯一索引:同一个外部消息 id 第二次到达时
|
||||
直接回上次的答案,不会变成两条任务。
|
||||
|
||||
## 安全边界
|
||||
|
||||
- 令牌错 → 9002(对外一律"通道不存在",不区分"错令牌"和"这个工作区没接")。
|
||||
- 通道被停用 → 9003。
|
||||
- 消息里不认人:机器人只知道"这是这个工作区的一条消息",落库按通道创建者记账,
|
||||
不会因为消息内容去猜是谁发的。
|
||||
上一版这里做的是 webhook 接收端(`POST /integrations/clawbot/{token}`),
|
||||
对 clawbot 的理解是错的,接口与令牌一并删掉了(迁移 0013 的注释记了原因)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
import base64
|
||||
import io
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Any
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Query, Request
|
||||
from fastapi.responses import PlainTextResponse
|
||||
import qrcode
|
||||
import qrcode.image.svg
|
||||
from fastapi import APIRouter, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from ..config import get_settings
|
||||
from ..db import bind_tenant
|
||||
from ..deps import AdminDep, SessionDep
|
||||
from ..errors import AppError, ErrorCode
|
||||
from ..models import Member, Workspace
|
||||
from ..models_integrations import BotChannel, BotMessage
|
||||
from ..models_knowledge import KnowledgeDoc
|
||||
from ..models_task import Item
|
||||
from ..schemas import ok
|
||||
from .items import _log, _set_tags
|
||||
from ..services import wechat_ilink
|
||||
from ..services.wechat_bot import manager
|
||||
from ..stores import get_store
|
||||
|
||||
webhook_router = APIRouter(prefix="/integrations", tags=["外部接入"])
|
||||
admin_router = APIRouter(prefix="/workspaces", tags=["外部接入"])
|
||||
|
||||
# 标题在 item.title 上是 varchar(200),留点余量给"…"之类的后缀。
|
||||
MAX_TITLE = 180
|
||||
MAX_BODY = 20000
|
||||
|
||||
KNOWLEDGE_PREFIXES = ("知识", "笔记", "note", "knowledge", "kb")
|
||||
TASK_PREFIXES = ("任务", "待办", "todo", "task")
|
||||
HELP_PREFIXES = ("?", "?", "帮助", "help", "用法")
|
||||
|
||||
HELP_TEXT = (
|
||||
"发什么都能用,写法:\n"
|
||||
"· 直接说 → 记成任务\n"
|
||||
"· 知识:内容 → 记成知识草稿(也可以写「笔记:」)\n"
|
||||
"· 任务:内容 → 明确记成任务\n"
|
||||
"首行当标题,后面几行当正文;标题里带 #标签 会自动加上。"
|
||||
)
|
||||
# iLink 的二维码大约 3 分钟过期,我们按 5 分钟缓存 id,过期就重新取。
|
||||
QR_TTL_SECONDS = 300
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
class ChannelPatch(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
def new_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
def _qr_key(ws_id: uuid.UUID) -> str:
|
||||
return f"wechat:qr:{ws_id}"
|
||||
|
||||
|
||||
def _pick(data: dict[str, Any], *keys: str) -> str | None:
|
||||
"""从任意形状的 payload 里挖字段(clawbot 的消息壳子没定死,宽松一点)。"""
|
||||
for key in keys:
|
||||
value = data.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
nested = _pick(value, "text", "content", "body", "message")
|
||||
if nested:
|
||||
return nested
|
||||
return None
|
||||
|
||||
|
||||
def extract_text(payload: dict[str, Any]) -> str:
|
||||
text = _pick(
|
||||
payload,
|
||||
"text",
|
||||
"content",
|
||||
"msg",
|
||||
"message",
|
||||
"query",
|
||||
"body",
|
||||
"data",
|
||||
"payload",
|
||||
)
|
||||
return (text or "").strip()
|
||||
|
||||
|
||||
def extract_external_id(payload: dict[str, Any]) -> str | None:
|
||||
value = _pick(payload, "msg_id", "message_id", "msgid", "id", "uuid", "event_id")
|
||||
return value[:128] if value else None
|
||||
|
||||
|
||||
def extract_sender(payload: dict[str, Any]) -> str | None:
|
||||
value = _pick(payload, "from", "sender", "from_user", "user", "uid", "sender_id")
|
||||
return value[:128] if value else None
|
||||
|
||||
|
||||
def split_command(text: str) -> tuple[str, str]:
|
||||
"""返回 (kind, 去掉前缀后的正文)。"""
|
||||
head, _, rest = text.partition("\n")
|
||||
probe = head.strip().lower()
|
||||
for prefix in HELP_PREFIXES:
|
||||
if probe == prefix or probe.startswith(f"{prefix}:") or probe.startswith(f"{prefix}:"):
|
||||
return "help", rest
|
||||
for prefix in KNOWLEDGE_PREFIXES:
|
||||
for sep in (":", ":", " ", "\u3000"):
|
||||
if probe.startswith(f"{prefix}{sep}"):
|
||||
offset = len(prefix) + len(sep)
|
||||
return "knowledge", (head.strip()[offset:] + ("\n" + rest if rest else "")).strip()
|
||||
for prefix in TASK_PREFIXES:
|
||||
for sep in (":", ":", " ", "\u3000"):
|
||||
if probe.startswith(f"{prefix}{sep}"):
|
||||
offset = len(prefix) + len(sep)
|
||||
return "task", (head.strip()[offset:] + ("\n" + rest if rest else "")).strip()
|
||||
return "task", text
|
||||
|
||||
|
||||
def split_tags(body: str) -> tuple[str, list[str]]:
|
||||
"""把 `#标签` 摘出来。只认行首和空白后的 #,免得把 "C#" 吃掉。"""
|
||||
tags: list[str] = []
|
||||
kept: list[str] = []
|
||||
for line in body.splitlines():
|
||||
words = line.split(" ")
|
||||
rest: list[str] = []
|
||||
for word in words:
|
||||
if word.startswith("#") and len(word) > 1 and not word[1:].isdigit():
|
||||
name = word[1:].strip(",,。.;;")
|
||||
if name and name.lower() not in {t.lower() for t in tags}:
|
||||
tags.append(name)
|
||||
continue
|
||||
rest.append(word)
|
||||
kept.append(" ".join(rest).rstrip())
|
||||
return "\n".join(kept).strip(), tags
|
||||
|
||||
|
||||
def split_title(body: str) -> tuple[str, str]:
|
||||
lines = body.splitlines()
|
||||
if not lines:
|
||||
return "", ""
|
||||
title = lines[0].strip()
|
||||
rest = "\n".join(lines[1:]).strip()
|
||||
return title[:MAX_TITLE], rest[:MAX_BODY]
|
||||
|
||||
|
||||
async def _creator_id(session: SessionDep, channel: BotChannel) -> uuid.UUID:
|
||||
"""消息记在谁名下:通道创建者;人没了就退回工作区 owner。"""
|
||||
if channel.created_by is not None:
|
||||
row = await session.execute(
|
||||
select(Member.id).where(
|
||||
Member.workspace_id == channel.workspace_id,
|
||||
Member.user_id == channel.created_by,
|
||||
Member.status == "active",
|
||||
)
|
||||
)
|
||||
if row.first() is not None:
|
||||
return channel.created_by
|
||||
workspace = await session.get(Workspace, channel.workspace_id)
|
||||
if workspace is None: # 理论上不可能:FK 是 CASCADE
|
||||
raise AppError(ErrorCode.BOT_TOKEN_INVALID, "接入通道不存在")
|
||||
return workspace.owner_id
|
||||
|
||||
|
||||
async def _create_task(
|
||||
session: SessionDep,
|
||||
*,
|
||||
ws_id: uuid.UUID,
|
||||
creator_id: uuid.UUID,
|
||||
title: str,
|
||||
body: str,
|
||||
tags: list[str],
|
||||
) -> Item:
|
||||
item = Item(
|
||||
workspace_id=ws_id,
|
||||
kind="task",
|
||||
title=title,
|
||||
body_md=body,
|
||||
status="inbox",
|
||||
priority=0,
|
||||
creator_id=creator_id,
|
||||
)
|
||||
session.add(item)
|
||||
await session.flush()
|
||||
if tags:
|
||||
await _set_tags(session, ws_id, item, tags)
|
||||
await _log(session, item, creator_id, "created", {"via": "wechat_clawbot"})
|
||||
return item
|
||||
|
||||
|
||||
async def _create_knowledge(
|
||||
session: SessionDep,
|
||||
*,
|
||||
ws_id: uuid.UUID,
|
||||
creator_id: uuid.UUID,
|
||||
title: str,
|
||||
body: str,
|
||||
tags: list[str],
|
||||
) -> Item:
|
||||
item = Item(
|
||||
workspace_id=ws_id,
|
||||
kind="knowledge",
|
||||
title=title,
|
||||
body_md=body,
|
||||
status="draft",
|
||||
creator_id=creator_id,
|
||||
)
|
||||
session.add(item)
|
||||
await session.flush()
|
||||
doc = KnowledgeDoc(
|
||||
item_id=item.id,
|
||||
workspace_id=ws_id,
|
||||
author_id=creator_id,
|
||||
source_item_id=None,
|
||||
source_kind="manual",
|
||||
doc_type="note",
|
||||
status_hint="draft",
|
||||
generated_by="human",
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
if tags:
|
||||
await _set_tags(session, ws_id, item, tags)
|
||||
await _log(session, item, creator_id, "knowledge_created", {"via": "wechat_clawbot"})
|
||||
return item
|
||||
|
||||
|
||||
def _link(request: Request, ws_id: uuid.UUID, item: Item) -> str:
|
||||
base = str(request.base_url).rstrip("/")
|
||||
# base_url 形如 http://host:8200/api/v1/,而页面在站点根上
|
||||
base = base.split("/api/")[0]
|
||||
if item.kind == "knowledge":
|
||||
return f"{base}/w/{ws_id}/knowledge/{item.id}"
|
||||
return f"{base}/w/{ws_id}?open={item.id}"
|
||||
|
||||
|
||||
async def _channel_by_token(session: SessionDep, token: str) -> BotChannel:
|
||||
from sqlalchemy import text as sql_text
|
||||
|
||||
# 策略要靠这个值才放行那一行,必须先设。
|
||||
await session.execute(
|
||||
sql_text("SELECT set_config('app.bot_token_hash', :h, true)"), {"h": hash_token(token)}
|
||||
)
|
||||
row = (
|
||||
await session.execute(select(BotChannel).where(BotChannel.token_hash == hash_token(token)))
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
raise AppError(ErrorCode.BOT_TOKEN_INVALID, "接入通道不存在或令牌已失效")
|
||||
return row
|
||||
|
||||
|
||||
@webhook_router.get("/clawbot/{token}")
|
||||
async def clawbot_verify(
|
||||
token: str, session: SessionDep, echostr: str | None = Query(default=None)
|
||||
):
|
||||
"""clawbot 后台通常先 GET 一次确认地址是活的:有 echostr 就原样回。"""
|
||||
channel = await _channel_by_token(session, token)
|
||||
if channel.status != "active":
|
||||
raise AppError(ErrorCode.BOT_DISABLED, "这个通道已停用")
|
||||
if echostr is not None:
|
||||
return PlainTextResponse(echostr)
|
||||
return ok({"channel": channel.name, "status": channel.status})
|
||||
|
||||
|
||||
@webhook_router.post("/clawbot/{token}")
|
||||
async def clawbot_message(
|
||||
token: str,
|
||||
request: Request,
|
||||
session: SessionDep,
|
||||
payload: Annotated[dict[str, Any] | None, Body()] = None,
|
||||
):
|
||||
channel = await _channel_by_token(session, token)
|
||||
if channel.status != "active":
|
||||
raise AppError(ErrorCode.BOT_DISABLED, "这个通道已停用")
|
||||
|
||||
raw: dict[str, Any] = payload if isinstance(payload, dict) else {}
|
||||
text = extract_text(raw)
|
||||
external_id = extract_external_id(raw)
|
||||
sender = extract_sender(raw)
|
||||
|
||||
await bind_tenant(session, channel.workspace_id, channel.created_by)
|
||||
|
||||
# 重推:同一个外部消息 id 再来一次,回上次那句,不再建第二条。
|
||||
if external_id:
|
||||
previous = (
|
||||
await session.execute(
|
||||
select(BotMessage).where(
|
||||
BotMessage.channel_id == channel.id,
|
||||
BotMessage.external_id == external_id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if previous is not None:
|
||||
channel.last_seen_at = datetime.now(timezone.utc)
|
||||
return ok({"reply": previous.reply, "action": previous.action, "duplicate": True})
|
||||
|
||||
message = BotMessage(
|
||||
workspace_id=channel.workspace_id,
|
||||
channel_id=channel.id,
|
||||
external_id=external_id,
|
||||
sender=sender,
|
||||
text=text[:MAX_BODY],
|
||||
action="ignored",
|
||||
reply="",
|
||||
raw=raw if isinstance(raw, dict) else {"value": raw},
|
||||
)
|
||||
|
||||
if not text:
|
||||
message.action = "ignored"
|
||||
message.reply = "没收到正文,只收到一个空壳。"
|
||||
session.add(message)
|
||||
return ok({"reply": message.reply, "action": "ignored"})
|
||||
|
||||
kind, body = split_command(text)
|
||||
creator_id = await _creator_id(session, channel)
|
||||
|
||||
if kind == "help":
|
||||
message.action = "help"
|
||||
message.reply = HELP_TEXT
|
||||
else:
|
||||
body, tags = split_tags(body)
|
||||
title, rest = split_title(body)
|
||||
if not title:
|
||||
message.action = "ignored"
|
||||
message.reply = "只有标签没有内容,这条没记。"
|
||||
elif kind == "knowledge":
|
||||
item = await _create_knowledge(
|
||||
session,
|
||||
ws_id=channel.workspace_id,
|
||||
creator_id=creator_id,
|
||||
title=title,
|
||||
body=rest,
|
||||
tags=tags,
|
||||
)
|
||||
message.action = "knowledge"
|
||||
message.item_id = item.id
|
||||
link = _link(request, channel.workspace_id, item)
|
||||
message.reply = f"已记成知识草稿:《{item.title}》。去核对:{link}"
|
||||
else:
|
||||
item = await _create_task(
|
||||
session,
|
||||
ws_id=channel.workspace_id,
|
||||
creator_id=creator_id,
|
||||
title=title,
|
||||
body=rest,
|
||||
tags=tags,
|
||||
)
|
||||
message.action = "task"
|
||||
message.item_id = item.id
|
||||
message.reply = (
|
||||
f"已记成任务:《{item.title}》。打开:{_link(request, channel.workspace_id, item)}"
|
||||
)
|
||||
|
||||
channel.last_seen_at = datetime.now(timezone.utc)
|
||||
channel.last_error = None
|
||||
try:
|
||||
# 用 SAVEPOINT 兜住唯一索引冲突:另一个并发请求刚插了同一条消息,
|
||||
# 这里只要认输回同一句就行,不能把整笔事务打挂。
|
||||
async with session.begin_nested():
|
||||
session.add(message)
|
||||
await session.flush()
|
||||
except IntegrityError:
|
||||
if external_id:
|
||||
previous = (
|
||||
await session.execute(
|
||||
select(BotMessage).where(
|
||||
BotMessage.channel_id == channel.id,
|
||||
BotMessage.external_id == external_id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if previous is not None:
|
||||
return ok({"reply": previous.reply, "action": previous.action, "duplicate": True})
|
||||
raise AppError(ErrorCode.BOT_TOKEN_INVALID, "同一条消息重复到达,已忽略") from None
|
||||
|
||||
return ok({"reply": message.reply, "action": message.action, "duplicate": False})
|
||||
|
||||
|
||||
# ── 工作区里的管理接口(登录 + admin 以上) ─────────────────────────────
|
||||
def render_qr_svg(payload: str) -> str:
|
||||
"""把二维码内容画成 data URL:服务端画,前端不用再引一个二维码库。"""
|
||||
image = qrcode.make(payload, image_factory=qrcode.image.svg.SvgPathImage, box_size=10)
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer)
|
||||
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
|
||||
return f"data:image/svg+xml;base64,{encoded}"
|
||||
|
||||
|
||||
def _channel_out(channel: BotChannel) -> dict[str, Any]:
|
||||
@@ -410,122 +65,152 @@ def _channel_out(channel: BotChannel) -> dict[str, Any]:
|
||||
"kind": channel.kind,
|
||||
"name": channel.name,
|
||||
"status": channel.status,
|
||||
"created_by": str(channel.created_by) if channel.created_by else None,
|
||||
"connected": bool(channel.bot_token_enc) and channel.status == "active",
|
||||
"polling": manager.running(channel.id),
|
||||
"ilink_user_id": channel.ilink_user_id,
|
||||
"last_seen_at": channel.last_seen_at,
|
||||
"last_error": channel.last_error,
|
||||
"created_at": channel.created_at,
|
||||
}
|
||||
|
||||
|
||||
def _webhook_url(request: Request, token: str) -> str:
|
||||
"""拼出机器人要打的那个地址。
|
||||
|
||||
不能直接用 `request.base_url`:生产上应用挂在域名根、路由带 `/api/v1` 前缀,
|
||||
base_url 里没有前缀,拼出来的地址会 405(本地被 Vite 代理掩盖过这个差异)。
|
||||
所以站点根从 base_url 里截,前缀一律取 `settings.api_prefix`。
|
||||
"""
|
||||
base = str(request.base_url).split("/api/")[0].rstrip("/")
|
||||
return f"{base}{get_settings().api_prefix}/integrations/clawbot/{token}"
|
||||
async def _wechat_channel(session: SessionDep, ws_id: uuid.UUID) -> BotChannel | None:
|
||||
rows = await session.execute(
|
||||
select(BotChannel)
|
||||
.where(BotChannel.workspace_id == ws_id, BotChannel.kind == "wechat_clawbot")
|
||||
.order_by(BotChannel.created_at.desc())
|
||||
)
|
||||
return rows.scalars().first()
|
||||
|
||||
|
||||
@admin_router.get("/{ws_id}/integrations")
|
||||
async def list_integrations(ws_id: uuid.UUID, ctx: AdminDep, session: SessionDep):
|
||||
rows = (
|
||||
(
|
||||
await session.execute(
|
||||
rows = await session.execute(
|
||||
select(BotChannel)
|
||||
.where(BotChannel.workspace_id == ws_id)
|
||||
.order_by(BotChannel.created_at.desc())
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return ok({"items": [_channel_out(row) for row in rows]})
|
||||
return ok({"items": [_channel_out(c) for c in rows.scalars().all()]})
|
||||
|
||||
|
||||
@admin_router.post("/{ws_id}/integrations")
|
||||
async def create_integration(
|
||||
ws_id: uuid.UUID,
|
||||
ctx: AdminDep,
|
||||
session: SessionDep,
|
||||
request: Request,
|
||||
body: Annotated[dict[str, Any] | None, Body()] = None,
|
||||
):
|
||||
name = str((body or {}).get("name") or "").strip()[:64] or "微信"
|
||||
token = new_token()
|
||||
@admin_router.post("/{ws_id}/integrations/wechat/qr")
|
||||
async def begin_wechat_login(ws_id: uuid.UUID, ctx: AdminDep, session: SessionDep):
|
||||
"""取一张二维码。已经登录过的通道会被重置成 pending(换号 = 重新扫)。"""
|
||||
try:
|
||||
code = await wechat_ilink.get_qrcode()
|
||||
except wechat_ilink.WechatError as exc:
|
||||
raise AppError(ErrorCode.BOT_TOKEN_INVALID, f"取二维码失败:{exc}") from exc
|
||||
|
||||
channel = await _wechat_channel(session, ws_id)
|
||||
if channel is None:
|
||||
channel = BotChannel(
|
||||
workspace_id=ws_id,
|
||||
kind="wechat_clawbot",
|
||||
name=name,
|
||||
token_hash=hash_token(token),
|
||||
status="active",
|
||||
name="微信",
|
||||
status="pending",
|
||||
created_by=ctx.user.id,
|
||||
)
|
||||
session.add(channel)
|
||||
if channel.bot_token_enc:
|
||||
await manager.stop(channel.id)
|
||||
channel.bot_token_enc = None
|
||||
channel.sync_buf = None
|
||||
channel.context_token = None
|
||||
channel.status = "pending"
|
||||
channel.last_error = None
|
||||
await session.flush()
|
||||
|
||||
await get_store().set(_qr_key(ws_id), code.qrcode_id, QR_TTL_SECONDS)
|
||||
await session.commit()
|
||||
|
||||
return ok(
|
||||
{
|
||||
"channel": _channel_out(channel),
|
||||
# 令牌只在这里出现一次,之后只能"重新生成"。
|
||||
"token": token,
|
||||
"webhook_url": _webhook_url(request, token),
|
||||
"channel_id": str(channel.id),
|
||||
"qrcode_id": code.qrcode_id,
|
||||
"payload": code.payload,
|
||||
"image": render_qr_svg(code.payload),
|
||||
"expires_in": QR_TTL_SECONDS,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@admin_router.get("/{ws_id}/integrations/wechat/qr/{qrcode_id}")
|
||||
async def poll_wechat_login(ws_id: uuid.UUID, qrcode_id: str, ctx: AdminDep, session: SessionDep):
|
||||
"""问扫码结果。只有 confirmed 才落登录态并开轮询,其余状态原样回给页面。"""
|
||||
cached = await get_store().get(_qr_key(ws_id))
|
||||
if cached != qrcode_id:
|
||||
# 页面拿着旧二维码在问:直接说"过期",让它重新取一张。
|
||||
return ok({"status": "expired"})
|
||||
|
||||
try:
|
||||
status = await wechat_ilink.qrcode_status(qrcode_id)
|
||||
except wechat_ilink.WechatError as exc:
|
||||
return ok({"status": "error", "message": str(exc)})
|
||||
|
||||
if status.status != "confirmed" or not status.bot_token:
|
||||
return ok({"status": status.status})
|
||||
|
||||
channel = await _wechat_channel(session, ws_id)
|
||||
if channel is None:
|
||||
raise AppError(ErrorCode.BOT_TOKEN_INVALID, "通道不见了,请重新取二维码")
|
||||
|
||||
await bind_tenant(session, ws_id, ctx.user.id)
|
||||
channel.bot_token_enc = wechat_ilink.encrypt_token(status.bot_token)
|
||||
channel.ilink_user_id = status.ilink_user_id
|
||||
channel.base_url = status.base_url
|
||||
channel.status = "active"
|
||||
channel.sync_buf = None
|
||||
channel.context_token = None
|
||||
channel.last_error = None
|
||||
channel.created_by = channel.created_by or ctx.user.id
|
||||
await session.commit()
|
||||
await get_store().delete(_qr_key(ws_id))
|
||||
|
||||
await manager.start(channel.id)
|
||||
return ok({"status": "confirmed", "channel": _channel_out(channel)})
|
||||
|
||||
|
||||
@admin_router.patch("/{ws_id}/integrations/{channel_id}")
|
||||
async def update_integration(
|
||||
ws_id: uuid.UUID,
|
||||
channel_id: uuid.UUID,
|
||||
ctx: AdminDep,
|
||||
session: SessionDep,
|
||||
body: Annotated[dict[str, Any] | None, Body()] = None,
|
||||
body: ChannelPatch,
|
||||
):
|
||||
channel = await session.get(BotChannel, channel_id)
|
||||
if channel is None or channel.workspace_id != ws_id:
|
||||
raise AppError(ErrorCode.BOT_TOKEN_INVALID, "接入通道不存在")
|
||||
data = body or {}
|
||||
if isinstance(data.get("name"), str):
|
||||
channel.name = data["name"].strip()[:64]
|
||||
if isinstance(data.get("status"), str):
|
||||
if data["status"] not in ("active", "disabled"):
|
||||
raise AppError(ErrorCode.VALIDATION_ERROR, "状态只能是 active 或 disabled")
|
||||
channel.status = data["status"]
|
||||
wanted = body.status.strip()
|
||||
if wanted not in ("active", "disabled"):
|
||||
raise AppError(ErrorCode.VALIDATION_FAILED, "status 只能是 active 或 disabled")
|
||||
if wanted == "active" and not channel.bot_token_enc:
|
||||
raise AppError(ErrorCode.BOT_TOKEN_INVALID, "这个通道还没扫码登录")
|
||||
channel.status = wanted
|
||||
await session.commit()
|
||||
if wanted == "active":
|
||||
await manager.start(channel.id)
|
||||
else:
|
||||
await manager.stop(channel.id)
|
||||
return ok({"channel": _channel_out(channel)})
|
||||
|
||||
|
||||
@admin_router.post("/{ws_id}/integrations/{channel_id}/rotate")
|
||||
async def rotate_integration(
|
||||
ws_id: uuid.UUID,
|
||||
channel_id: uuid.UUID,
|
||||
ctx: AdminDep,
|
||||
session: SessionDep,
|
||||
request: Request,
|
||||
):
|
||||
channel = await session.get(BotChannel, channel_id)
|
||||
if channel is None or channel.workspace_id != ws_id:
|
||||
raise AppError(ErrorCode.BOT_TOKEN_INVALID, "接入通道不存在")
|
||||
token = new_token()
|
||||
channel.token_hash = hash_token(token)
|
||||
return ok(
|
||||
{
|
||||
"channel": _channel_out(channel),
|
||||
"token": token,
|
||||
"webhook_url": _webhook_url(request, token),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@admin_router.delete("/{ws_id}/integrations/{channel_id}")
|
||||
async def delete_integration(
|
||||
async def disconnect_integration(
|
||||
ws_id: uuid.UUID, channel_id: uuid.UUID, ctx: AdminDep, session: SessionDep
|
||||
):
|
||||
"""断开:停轮询、清登录态。留痕(bot_message)不删,翻旧账用得上。"""
|
||||
channel = await session.get(BotChannel, channel_id)
|
||||
if channel is None or channel.workspace_id != ws_id:
|
||||
raise AppError(ErrorCode.BOT_TOKEN_INVALID, "接入通道不存在")
|
||||
await session.delete(channel)
|
||||
return ok({"deleted": True})
|
||||
await manager.stop(channel.id)
|
||||
channel.bot_token_enc = None
|
||||
channel.sync_buf = None
|
||||
channel.context_token = None
|
||||
channel.status = "pending"
|
||||
channel.last_error = None
|
||||
await session.commit()
|
||||
return ok({"channel": _channel_out(channel)})
|
||||
|
||||
|
||||
@admin_router.get("/{ws_id}/integrations/{channel_id}/messages")
|
||||
@@ -534,36 +219,27 @@ async def list_messages(
|
||||
channel_id: uuid.UUID,
|
||||
ctx: AdminDep,
|
||||
session: SessionDep,
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
limit: int = Query(default=5, ge=1, le=100),
|
||||
):
|
||||
rows = (
|
||||
(
|
||||
await session.execute(
|
||||
rows = await session.execute(
|
||||
select(BotMessage)
|
||||
.where(BotMessage.workspace_id == ws_id, BotMessage.channel_id == channel_id)
|
||||
.where(BotMessage.channel_id == channel_id, BotMessage.workspace_id == ws_id)
|
||||
.order_by(BotMessage.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return ok(
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": str(row.id),
|
||||
"text": row.text,
|
||||
"action": row.action,
|
||||
"item_id": str(row.item_id) if row.item_id else None,
|
||||
"reply": row.reply,
|
||||
"sender": row.sender,
|
||||
"created_at": row.created_at,
|
||||
"id": str(m.id),
|
||||
"action": m.action,
|
||||
"sender": m.sender,
|
||||
"text": m.text,
|
||||
"reply": m.reply,
|
||||
"item_id": str(m.item_id) if m.item_id else None,
|
||||
"created_at": m.created_at,
|
||||
}
|
||||
for row in rows
|
||||
for m in rows.scalars().all()
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["webhook_router", "admin_router"]
|
||||
|
||||
@@ -145,6 +145,9 @@ class Settings(BaseSettings):
|
||||
# 生产没有 nginx 时由后端直接托管前端构建产物(同源,/api 无跨域)。
|
||||
# 指向 web/dist 的绝对路径;留空则只提供 API。
|
||||
web_dist_dir: str | None = None
|
||||
# 回复微信消息时用的站点地址(形如 http://192.168.8.188:8200,不带 /api/v1)。
|
||||
# 留空则退回 cors_origins 的第一个,本机开发够用,生产务必显式配。
|
||||
public_base_url: str | None = None
|
||||
|
||||
# ── 安卓安装包(OTA)──────────────────────────────────
|
||||
# 放 latest.json + 对应 apk 的目录;留空则用 backend/static/apk。
|
||||
|
||||
+4
-1
@@ -32,6 +32,7 @@ from .db import SessionFactory, current_app_role, engine
|
||||
from .errors import AppError, ErrorCode, http_status_for
|
||||
from .logging_setup import get_logger, request_id_ctx, setup_logging
|
||||
from .middleware import IdempotencyMiddleware, RequestContextMiddleware, error_body
|
||||
from .services.wechat_bot import manager as wechat_manager
|
||||
|
||||
settings = get_settings()
|
||||
setup_logging("DEBUG" if settings.env == "dev" else "INFO")
|
||||
@@ -50,7 +51,10 @@ async def lifespan(app: FastAPI):
|
||||
"服务启动",
|
||||
extra={"extra_fields": {"env": settings.env, "commit": settings.build_commit}},
|
||||
)
|
||||
# 已登录的微信通道在进程启动时接上,不然重启之后消息就静默收不到了。
|
||||
await wechat_manager.start_all()
|
||||
yield
|
||||
await wechat_manager.shutdown()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@@ -128,7 +132,6 @@ app.include_router(attachments.router, prefix=settings.api_prefix)
|
||||
app.include_router(app_release.router, prefix=settings.api_prefix)
|
||||
app.include_router(settings_api.router, prefix=settings.api_prefix)
|
||||
app.include_router(integrations.admin_router, prefix=settings.api_prefix)
|
||||
app.include_router(integrations.webhook_router, prefix=settings.api_prefix)
|
||||
|
||||
|
||||
@app.get("/health", tags=["运维"], summary="存活探针")
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
"""外部接入:微信(clawbot)这一类的机器人通道。
|
||||
"""外部接入:微信 clawbot 的机器人账号。
|
||||
|
||||
为什么单开一张表而不是塞进 `system_setting`:
|
||||
|
||||
1. 一个工作区可以挂多个通道(自己的号、公司的号),也随时可能换;
|
||||
1. 一个工作区可能挂过好几个机器人(自己的号、公司的号),也随时可能换;
|
||||
塞进 setting 的 JSON 里就没法"停用这一个、保留那一个"。
|
||||
2. 通道是**租户数据**:谁能往里发消息,取决于它属于哪个工作区。
|
||||
租户表就得有 `workspace_id NOT NULL` + RLS(AGENTS.md 第 2.5 条)。
|
||||
|
||||
令牌只存 sha256:数据库被看见也拿不到能用的令牌;令牌只在创建时回显一次。
|
||||
登录态(`bot_token_enc`)是加密存的:它等于"这个微信账号的登录态",
|
||||
明文落库等于把号交出去。密钥由 `JWT_SECRET` 派生,见 `services/wechat_ilink.py`。
|
||||
|
||||
`token_hash` 是上一版 webhook 方案留下的列:那一版以为 clawbot 会往我们这 POST,
|
||||
后来发现真实协议是"我们扫码登录 + 长轮询"(见 `services/wechat_ilink.py` 文件头),
|
||||
列先留着不为空但不再使用,等下一版清理。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -23,7 +28,9 @@ from sqlalchemy.orm import Mapped, mapped_column
|
||||
from .models import Base, Timestamps, new_uuid
|
||||
|
||||
BOT_KINDS = ("wechat_clawbot",)
|
||||
BOT_STATUSES = ("active", "disabled")
|
||||
# pending:二维码还没扫;active:登录态可用、正在轮询;
|
||||
# disabled:人手动停用;expired:登录态失效,需要重新扫码。
|
||||
BOT_STATUSES = ("pending", "active", "disabled", "expired")
|
||||
|
||||
INTEGRATION_TENANT_TABLES = ("bot_channel", "bot_message")
|
||||
|
||||
@@ -43,9 +50,19 @@ class BotChannel(Base, Timestamps):
|
||||
)
|
||||
kind: Mapped[str] = mapped_column(String(32), nullable=False, default="wechat_clawbot")
|
||||
name: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||
# hex(sha256(令牌))。令牌本身只在创建响应里出现一次。
|
||||
token_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
|
||||
# 上一版 webhook 方案的遗留列,现在不用了(见模块说明)。
|
||||
token_hash: Mapped[str | None] = mapped_column(String(64))
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending")
|
||||
# 加密后的 iLink 登录态;解不出来就说明 JWT_SECRET 换过,要重新扫码。
|
||||
bot_token_enc: Mapped[str | None] = mapped_column(Text)
|
||||
# 扫码的那个微信号(iLink 的 ilink_user_id),用来在界面上显示"谁扫的"。
|
||||
ilink_user_id: Mapped[str | None] = mapped_column(String(64))
|
||||
# 服务端可能下发新的基址,跟着它走。
|
||||
base_url: Mapped[str | None] = mapped_column(Text)
|
||||
# 长轮询游标:丢了就会重复收消息。
|
||||
sync_buf: Mapped[str | None] = mapped_column(Text)
|
||||
# 最近一条消息的 context_token:回复必须带它。
|
||||
context_token: Mapped[str | None] = mapped_column(Text)
|
||||
# 消息落在谁名下:用创建这条通道的人;人没了就退回工作区 owner。
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="SET NULL")
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
"""微信机器人的后台轮询:登录之后由它把消息捞进来。
|
||||
|
||||
为什么是长轮询而不是 webhook:iLink Bot 协议就是"登录后由我们主动去拉",
|
||||
服务端只在下一条消息到达时返回(最长等 75 秒)。所以每个通道一个常驻任务。
|
||||
|
||||
几条必须守住的规矩:
|
||||
|
||||
1. **游标要落库**:`get_updates_buf` 是下次从哪里接着读的凭据,丢了就会重复收、
|
||||
把同一条任务记两遍。
|
||||
2. **一条消息只处理一次**:`bot_message.external_id` 有唯一索引,重复投递时先撞唯一键。
|
||||
3. **token 失效要停**:服务端 errcode `-14` 表示登录态作废,这时停轮询、把通道标记成
|
||||
需要重新扫码,别拿死 token 一直撞墙。
|
||||
4. **回复失败不影响已经落库的事项**:任务记下了就是记下了,回不回得去另说。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import text as sa_text
|
||||
|
||||
from ..config import get_settings
|
||||
from ..db import SessionFactory, bind_tenant
|
||||
from ..logging_setup import get_logger
|
||||
from ..models import Workspace
|
||||
from ..models_integrations import BotChannel, BotMessage
|
||||
from . import wechat_ilink
|
||||
from .wechat_intake import handle_text
|
||||
|
||||
logger = get_logger("wechat_bot")
|
||||
|
||||
# 长轮询一次最多等这么久(服务端 75 秒超时,客户端留点余量)。
|
||||
POLL_TIMEOUT_SECONDS = 80.0
|
||||
RETRY_DELAY_SECONDS = 5.0
|
||||
|
||||
|
||||
def site_base() -> str:
|
||||
"""回复里的链接用哪个域名。生产必须配 PUBLIC_BASE_URL,否则只能退回 CORS 里的第一个。"""
|
||||
settings = get_settings()
|
||||
if settings.public_base_url:
|
||||
return settings.public_base_url.rstrip("/")
|
||||
if settings.cors_origins:
|
||||
return str(settings.cors_origins[0]).rstrip("/")
|
||||
return ""
|
||||
|
||||
|
||||
class BotManager:
|
||||
"""一个进程里的轮询任务表:一个通道一个任务。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._tasks: dict[uuid.UUID, asyncio.Task[None]] = {}
|
||||
|
||||
# ── 生命周期 ──────────────────────────────────────────
|
||||
|
||||
async def start(self, channel_id: uuid.UUID) -> None:
|
||||
task = self._tasks.get(channel_id)
|
||||
if task is not None and not task.done():
|
||||
return
|
||||
self._tasks[channel_id] = asyncio.create_task(
|
||||
self._run(channel_id), name=f"wechat-bot-{channel_id}"
|
||||
)
|
||||
|
||||
async def stop(self, channel_id: uuid.UUID) -> None:
|
||||
task = self._tasks.pop(channel_id, None)
|
||||
if task is None:
|
||||
return
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
async def start_all(self) -> None:
|
||||
"""进程启动时把已登录的通道都拉起来,不然重启就静默不收消息了。"""
|
||||
async with SessionFactory() as session:
|
||||
rows = await session.execute(
|
||||
select(BotChannel.id).where(
|
||||
BotChannel.status == "active",
|
||||
BotChannel.bot_token_enc.is_not(None),
|
||||
)
|
||||
)
|
||||
ids = [row[0] for row in rows]
|
||||
for channel_id in ids:
|
||||
await self.start(channel_id)
|
||||
if ids:
|
||||
logger.info("微信机器人已恢复", extra={"extra_fields": {"channels": len(ids)}})
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
for channel_id in list(self._tasks):
|
||||
await self.stop(channel_id)
|
||||
|
||||
def running(self, channel_id: uuid.UUID) -> bool:
|
||||
task = self._tasks.get(channel_id)
|
||||
return task is not None and not task.done()
|
||||
|
||||
# ── 轮询主体 ──────────────────────────────────────────
|
||||
|
||||
async def _load(self, channel_id: uuid.UUID) -> tuple[BotChannel, str] | None:
|
||||
"""读通道并解出 token。返回 None 表示这个通道不该再轮询了。"""
|
||||
async with SessionFactory() as session:
|
||||
channel = await self._get_channel(session, channel_id)
|
||||
if channel is None or channel.status != "active" or not channel.bot_token_enc:
|
||||
return None
|
||||
try:
|
||||
token = wechat_ilink.decrypt_token(channel.bot_token_enc)
|
||||
except wechat_ilink.WechatError:
|
||||
await self._mark_relogin(channel_id, "登录态解不开(JWT_SECRET 可能换过)")
|
||||
return None
|
||||
session.expunge(channel)
|
||||
return channel, token
|
||||
|
||||
async def _run(self, channel_id: uuid.UUID) -> None:
|
||||
loaded = await self._load(channel_id)
|
||||
if loaded is None:
|
||||
self._tasks.pop(channel_id, None)
|
||||
return
|
||||
channel, token = loaded
|
||||
cursor = channel.sync_buf or ""
|
||||
failures = 0
|
||||
while True:
|
||||
try:
|
||||
update = await wechat_ilink.get_updates(
|
||||
token, cursor, base_url=channel.base_url, timeout=POLL_TIMEOUT_SECONDS
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except wechat_ilink.WechatError as exc:
|
||||
if exc.stale_token:
|
||||
await self._mark_relogin(channel_id, str(exc))
|
||||
self._tasks.pop(channel_id, None)
|
||||
return
|
||||
failures += 1
|
||||
await self._note_error(channel_id, str(exc))
|
||||
await asyncio.sleep(RETRY_DELAY_SECONDS if failures < 10 else 60.0)
|
||||
continue
|
||||
except Exception as exc: # 轮询任务不能死,死了就永远收不到消息
|
||||
failures += 1
|
||||
logger.warning("微信长轮询异常:%s", exc)
|
||||
await asyncio.sleep(RETRY_DELAY_SECONDS)
|
||||
continue
|
||||
|
||||
failures = 0
|
||||
for message in update.messages:
|
||||
try:
|
||||
await self._handle(channel_id, message, token, channel.base_url)
|
||||
except Exception as exc: # 单条失败不拖垮整轮
|
||||
logger.warning("微信消息处理失败:%s", exc)
|
||||
|
||||
if update.cursor and update.cursor != cursor:
|
||||
cursor = update.cursor
|
||||
await self._save_cursor(channel_id, cursor)
|
||||
|
||||
# ── 单条消息 ──────────────────────────────────────────
|
||||
|
||||
async def _handle(
|
||||
self,
|
||||
channel_id: uuid.UUID,
|
||||
message: wechat_ilink.Inbound,
|
||||
token: str,
|
||||
base_url: str | None,
|
||||
) -> None:
|
||||
async with SessionFactory() as session:
|
||||
channel = await self._get_channel(session, channel_id)
|
||||
if channel is None or channel.status != "active":
|
||||
return
|
||||
# 读通道时只设了 app.bot_channel;workspace 表同样有 RLS,
|
||||
# 得先把通道所属工作区告诉数据库,才能读到那一行。
|
||||
await session.execute(
|
||||
sa_text("SELECT set_config('app.ws_id', :ws, true)"),
|
||||
{"ws": str(channel.workspace_id)},
|
||||
)
|
||||
workspace = await session.get(Workspace, channel.workspace_id)
|
||||
if workspace is None:
|
||||
return
|
||||
actor = channel.created_by or workspace.owner_id
|
||||
await bind_tenant(session, channel.workspace_id, actor)
|
||||
|
||||
existing = await session.execute(
|
||||
select(BotMessage).where(
|
||||
BotMessage.channel_id == channel_id,
|
||||
BotMessage.external_id == message.message_id,
|
||||
)
|
||||
)
|
||||
if existing.scalars().first() is not None:
|
||||
logger.info("微信消息重复投递,跳过:%s", message.message_id)
|
||||
return
|
||||
|
||||
result: dict[str, Any]
|
||||
if message.text:
|
||||
result = await handle_text(session, channel, message.text, base_url=site_base())
|
||||
elif message.kinds:
|
||||
result = {
|
||||
"action": "ignored",
|
||||
"item_id": None,
|
||||
"reply": "图片、文件、语音暂时还没搬进来,发文字最快。",
|
||||
}
|
||||
else:
|
||||
result = {"action": "ignored", "item_id": None, "reply": "没看懂这条消息。"}
|
||||
|
||||
session.add(
|
||||
BotMessage(
|
||||
workspace_id=channel.workspace_id,
|
||||
channel_id=channel_id,
|
||||
external_id=message.message_id,
|
||||
sender=message.sender[:128],
|
||||
text=message.text[:4000],
|
||||
action=str(result["action"]),
|
||||
item_id=result.get("item_id"),
|
||||
reply=str(result.get("reply") or "")[:2000],
|
||||
raw={"kinds": message.kinds, "message_id": message.message_id},
|
||||
)
|
||||
)
|
||||
channel.last_seen_at = _now()
|
||||
channel.context_token = message.context_token
|
||||
channel.last_error = None
|
||||
await session.commit()
|
||||
|
||||
reply = str(result.get("reply") or "")
|
||||
if reply:
|
||||
try:
|
||||
await wechat_ilink.send_text(
|
||||
token,
|
||||
message.sender,
|
||||
message.context_token,
|
||||
reply,
|
||||
base_url=base_url,
|
||||
)
|
||||
except wechat_ilink.WechatError as exc:
|
||||
await self._note_error(channel_id, f"回复失败:{exc}")
|
||||
|
||||
async def _get_channel(self, session, channel_id: uuid.UUID) -> BotChannel | None:
|
||||
"""按主键取通道。
|
||||
|
||||
轮询任务一开始没有工作区上下文,RLS 会把行藏起来;迁移 0014 给了一条
|
||||
`app.bot_channel` 的按主键口子,所以先设 GUC 再读。
|
||||
"""
|
||||
await session.execute(
|
||||
sa_text("SELECT set_config('app.bot_channel', :cid, true)"), {"cid": str(channel_id)}
|
||||
)
|
||||
return await session.get(BotChannel, channel_id)
|
||||
|
||||
# ── 落库小工具 ────────────────────────────────────────
|
||||
|
||||
async def _save_cursor(self, channel_id: uuid.UUID, cursor: str) -> None:
|
||||
async with SessionFactory() as session:
|
||||
channel = await self._get_channel(session, channel_id)
|
||||
if channel is None:
|
||||
return
|
||||
channel.sync_buf = cursor[:4000]
|
||||
await session.commit()
|
||||
|
||||
async def _note_error(self, channel_id: uuid.UUID, message: str) -> None:
|
||||
async with SessionFactory() as session:
|
||||
channel = await self._get_channel(session, channel_id)
|
||||
if channel is None:
|
||||
return
|
||||
channel.last_error = message[:500]
|
||||
await session.commit()
|
||||
|
||||
async def _mark_relogin(self, channel_id: uuid.UUID, message: str) -> None:
|
||||
"""登录态作废:停用这条通道并记下原因,界面上提示重新扫码。"""
|
||||
async with SessionFactory() as session:
|
||||
channel = await self._get_channel(session, channel_id)
|
||||
if channel is None:
|
||||
return
|
||||
channel.bot_token_enc = None
|
||||
channel.sync_buf = None
|
||||
channel.status = "expired"
|
||||
channel.context_token = None
|
||||
channel.last_error = message[:500]
|
||||
await session.commit()
|
||||
|
||||
|
||||
def _now():
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
manager = BotManager()
|
||||
@@ -0,0 +1,314 @@
|
||||
"""微信 clawbot 客户端(腾讯官方 iLink Bot 协议)。
|
||||
|
||||
协议不是我们自己猜的:OpenClaw 官方插件 `@tencent-weixin/openclaw-weixin` 用的就是它,
|
||||
本机参考实现见 `~/.dsh/.../@michengai/dsh-im-connect/src/channels/weixin.ts` 的
|
||||
`src/channels/weixin.ts`(文件头写着"扫码登录 → 长轮询收消息 → context_token 回复")。
|
||||
|
||||
流程:
|
||||
|
||||
1. `POST /ilink/bot/get_bot_qrcode?bot_type=3` 拿二维码 id 和内容,本地渲染成图给人扫;
|
||||
2. `POST /ilink/bot/get_qrcode_status?qrcode=<id>` 每 2 秒问一次,`confirmed` 时给 `bot_token`;
|
||||
3. 带 `Authorization: Bearer <bot_token>` 长轮询 `POST /ilink/bot/getupdates`,
|
||||
body 里带游标 `get_updates_buf`,回来的新游标要存下来,不然重启会重复收;
|
||||
4. 回复走 `POST /ilink/bot/sendmessage`,必须带对方上一条消息里的 `context_token`。
|
||||
|
||||
这里只封协议,不碰数据库;落库与建事项在 `services/wechat_bot.py`。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import secrets
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from ..config import get_settings
|
||||
|
||||
BASE_URL = "https://ilinkai.weixin.qq.com"
|
||||
# 官方插件同款版本号;服务端会按它挑行为,别乱改。
|
||||
CHANNEL_VERSION = "1.0.0"
|
||||
# token 失效时服务端给的 errcode(官方插件叫 STALE_TOKEN_ERRCODE)。
|
||||
STALE_TOKEN_ERRCODE = -14
|
||||
|
||||
QR_TIMEOUT_SECONDS = 15
|
||||
STATUS_TIMEOUT_SECONDS = 40
|
||||
SEND_TIMEOUT_SECONDS = 15
|
||||
|
||||
|
||||
class WechatError(RuntimeError):
|
||||
"""协议层错误。`kind` 用来区分"该重新扫码"还是"过一会儿再试"。"""
|
||||
|
||||
def __init__(self, message: str, *, kind: str = "api") -> None:
|
||||
super().__init__(message)
|
||||
self.kind = kind
|
||||
|
||||
@property
|
||||
def stale_token(self) -> bool:
|
||||
return self.kind == "stale_token"
|
||||
|
||||
|
||||
@dataclass
|
||||
class QrCode:
|
||||
qrcode_id: str
|
||||
payload: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class QrStatus:
|
||||
status: str
|
||||
bot_token: str | None = None
|
||||
ilink_user_id: str | None = None
|
||||
base_url: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Inbound:
|
||||
"""一条收到的消息:只取用得上的部分。"""
|
||||
|
||||
message_id: str
|
||||
sender: str
|
||||
text: str
|
||||
context_token: str | None = None
|
||||
kinds: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Update:
|
||||
cursor: str
|
||||
messages: list[Inbound]
|
||||
|
||||
|
||||
# ── 令牌加密 ─────────────────────────────────────────────
|
||||
#
|
||||
# bot_token 等于"这个微信号的登录态",明文落库等于把号交出去。
|
||||
# 密钥由 JWT_SECRET 派生:换 JWT_SECRET 会让已存的令牌失效(重新扫码即可),这是可接受的代价。
|
||||
|
||||
|
||||
def _fernet() -> Fernet:
|
||||
digest = hashlib.sha256(get_settings().jwt_secret.encode("utf-8")).digest()
|
||||
return Fernet(base64.urlsafe_b64encode(digest))
|
||||
|
||||
|
||||
def encrypt_token(token: str) -> str:
|
||||
return _fernet().encrypt(token.encode("utf-8")).decode("ascii")
|
||||
|
||||
|
||||
def decrypt_token(stored: str) -> str:
|
||||
try:
|
||||
return _fernet().decrypt(stored.encode("ascii")).decode("utf-8")
|
||||
except InvalidToken as exc: # JWT_SECRET 换过,或者库里的值被人改过
|
||||
raise WechatError("微信登录态解不开,需要重新扫码", kind="stale_token") from exc
|
||||
|
||||
|
||||
# ── 协议 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _headers(token: str | None) -> dict[str, str]:
|
||||
uin = base64.b64encode(str(secrets.randbelow(0xFFFFFFFF)).encode("utf-8")).decode("ascii")
|
||||
headers = {
|
||||
"content-type": "application/json",
|
||||
"iLink-App-ClientVersion": "1",
|
||||
"X-WECHAT-UIN": uin,
|
||||
}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
headers["AuthorizationType"] = "ilink_bot_token"
|
||||
return headers
|
||||
|
||||
|
||||
async def _post(
|
||||
path: str,
|
||||
body: dict[str, Any],
|
||||
*,
|
||||
token: str | None = None,
|
||||
timeout: float = 15.0,
|
||||
tolerate_ret1: bool = False,
|
||||
base_url: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
url = f"{(base_url or BASE_URL).rstrip('/')}{path}"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.post(url, json=body, headers=_headers(token))
|
||||
except httpx.HTTPError as exc:
|
||||
raise WechatError(f"微信接口连不上:{exc}") from exc
|
||||
if response.status_code >= 400:
|
||||
raise WechatError(f"微信接口 {path} 返回 HTTP {response.status_code}")
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError as exc:
|
||||
raise WechatError(f"微信接口 {path} 返回的不是 JSON") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise WechatError(f"微信接口 {path} 返回的格式不对")
|
||||
|
||||
ret = int(data.get("ret") or 0)
|
||||
errcode = int(data.get("errcode") or 0)
|
||||
if tolerate_ret1 and ret == 1 and errcode == 0:
|
||||
return data
|
||||
if ret != 0 or errcode != 0:
|
||||
if errcode == STALE_TOKEN_ERRCODE:
|
||||
raise WechatError("微信登录态已失效,需要重新扫码", kind="stale_token")
|
||||
raise WechatError(
|
||||
f"微信接口 {path} 失败 ret={ret} errcode={errcode} {data.get('errmsg') or ''}".strip()
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
async def get_qrcode() -> QrCode:
|
||||
data = await _post("/ilink/bot/get_bot_qrcode?bot_type=3", {}, timeout=QR_TIMEOUT_SECONDS)
|
||||
qrcode_id = _text(data, "qrcode", "qrcode_id")
|
||||
payload = _text(data, "qrcode_img_content", "qrcode_url", "url")
|
||||
if not qrcode_id or not payload:
|
||||
raise WechatError("微信二维码字段不全,稍后再试")
|
||||
return QrCode(qrcode_id=qrcode_id, payload=payload)
|
||||
|
||||
|
||||
async def qrcode_status(qrcode_id: str) -> QrStatus:
|
||||
data = await _post(
|
||||
f"/ilink/bot/get_qrcode_status?qrcode={qrcode_id}",
|
||||
{},
|
||||
timeout=STATUS_TIMEOUT_SECONDS,
|
||||
tolerate_ret1=True,
|
||||
)
|
||||
status = str(data.get("status") or "waiting")
|
||||
if status != "confirmed":
|
||||
return QrStatus(status=status)
|
||||
token = _text(data, "bot_token")
|
||||
if not token:
|
||||
raise WechatError("微信确认了扫码,但没给登录态,请重新扫码")
|
||||
return QrStatus(
|
||||
status="confirmed",
|
||||
bot_token=token,
|
||||
ilink_user_id=_text(data, "ilink_user_id"),
|
||||
base_url=_text(data, "baseurl", "base_url"),
|
||||
)
|
||||
|
||||
|
||||
async def get_updates(
|
||||
token: str, cursor: str, *, base_url: str | None = None, timeout: float = 75.0
|
||||
) -> Update:
|
||||
data = await _post(
|
||||
"/ilink/bot/getupdates",
|
||||
{"get_updates_buf": cursor, "base_info": {"channel_version": CHANNEL_VERSION}},
|
||||
token=token,
|
||||
timeout=timeout,
|
||||
base_url=base_url,
|
||||
)
|
||||
next_cursor = _text(data, "get_updates_buf", "cursor", "sync_buf") or cursor
|
||||
raw = data.get("msgs") or data.get("messages") or data.get("updates") or []
|
||||
messages: list[Inbound] = []
|
||||
if isinstance(raw, list):
|
||||
for item in raw:
|
||||
parsed = parse_message(item)
|
||||
if parsed is not None:
|
||||
messages.append(parsed)
|
||||
return Update(cursor=next_cursor, messages=messages)
|
||||
|
||||
|
||||
async def send_text(
|
||||
token: str,
|
||||
to_user_id: str,
|
||||
context_token: str | None,
|
||||
text: str,
|
||||
*,
|
||||
base_url: str | None = None,
|
||||
) -> None:
|
||||
await _post(
|
||||
"/ilink/bot/sendmessage",
|
||||
{
|
||||
"msg": {
|
||||
"from_user_id": "",
|
||||
"to_user_id": to_user_id,
|
||||
"client_id": f"dadaotask:{secrets.token_hex(6)}",
|
||||
"message_type": 2,
|
||||
"message_state": 2,
|
||||
"context_token": context_token or "",
|
||||
"item_list": [{"type": 1, "text_item": {"text": text}}],
|
||||
},
|
||||
"base_info": {"channel_version": CHANNEL_VERSION},
|
||||
},
|
||||
token=token,
|
||||
timeout=SEND_TIMEOUT_SECONDS,
|
||||
base_url=base_url,
|
||||
)
|
||||
|
||||
|
||||
# ── 入站解析 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def parse_message(raw: Any) -> Inbound | None:
|
||||
"""把一条原始消息拆成"谁说的、说了什么"。
|
||||
|
||||
iLink 的形状是 `{from_user_id, message_id, context_token, item_list:[{type, text_item}]}`,
|
||||
但偶尔会裹在 `message` 里,两种都认。只处理文本;语音有服务端转写就顺手用,
|
||||
图片/文件/视频只记下类型,这一版不下载。
|
||||
"""
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
message = dict(raw)
|
||||
nested = message.get("message")
|
||||
if isinstance(nested, dict):
|
||||
message.update(nested)
|
||||
if message.get("message_type") is not None and int(message.get("message_type") or 0) != 1:
|
||||
return None
|
||||
|
||||
sender = _text(message, "from_user_id", "from_user")
|
||||
if not sender:
|
||||
return None
|
||||
|
||||
parts: list[str] = []
|
||||
kinds: list[str] = []
|
||||
if isinstance(message.get("text"), str):
|
||||
parts.append(str(message["text"]))
|
||||
items = message.get("item_list")
|
||||
if isinstance(items, list):
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
kind = int(item.get("type") or 0)
|
||||
if kind == 1:
|
||||
text_item = item.get("text_item")
|
||||
if isinstance(text_item, dict):
|
||||
value = _text(text_item, "text")
|
||||
if value:
|
||||
parts.append(value)
|
||||
elif kind == 2:
|
||||
kinds.append("image")
|
||||
elif kind == 3:
|
||||
voice = item.get("voice_item")
|
||||
transcript = _text(voice, "text") if isinstance(voice, dict) else None
|
||||
if transcript:
|
||||
parts.append(f"[语音] {transcript}")
|
||||
else:
|
||||
kinds.append("voice")
|
||||
elif kind == 4:
|
||||
kinds.append("file")
|
||||
elif kind == 5:
|
||||
kinds.append("video")
|
||||
|
||||
message_id = (
|
||||
_text(message, "message_id", "msg_id")
|
||||
or f"{sender}:{_text(message, 'create_time_ms', 'create_time') or secrets.token_hex(4)}"
|
||||
)
|
||||
return Inbound(
|
||||
message_id=message_id,
|
||||
sender=sender,
|
||||
text="".join(parts).strip(),
|
||||
context_token=_text(message, "context_token"),
|
||||
kinds=kinds,
|
||||
)
|
||||
|
||||
|
||||
def _text(data: Any, *keys: str) -> str | None:
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
for key in keys:
|
||||
value = data.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
if isinstance(value, int | float) and not isinstance(value, bool):
|
||||
return str(value)
|
||||
return None
|
||||
@@ -0,0 +1,225 @@
|
||||
"""把一句话变成任务或知识草稿。
|
||||
|
||||
微信只负责把文本送进来,怎么落成事项是这里的事,接口和后台轮询共用同一份逻辑,
|
||||
免得"网页里发"和"机器人里发"落成两种东西。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..errors import AppError, ErrorCode
|
||||
from ..models import Member, Workspace
|
||||
from ..models_integrations import BotChannel
|
||||
from ..models_knowledge import KnowledgeDoc
|
||||
from ..models_task import Item
|
||||
|
||||
MAX_TITLE = 180
|
||||
MAX_BODY = 20000
|
||||
|
||||
KNOWLEDGE_PREFIXES = ("知识", "笔记", "note", "knowledge", "kb")
|
||||
TASK_PREFIXES = ("任务", "待办", "todo", "task")
|
||||
HELP_PREFIXES = ("?", "?", "帮助", "help", "用法")
|
||||
|
||||
HELP_TEXT = (
|
||||
"发什么都能用,写法:\n"
|
||||
"· 直接说 → 记成任务\n"
|
||||
"· 知识:内容 → 记成知识草稿(也可以写「笔记:」)\n"
|
||||
"· 任务:内容 → 明确记成任务\n"
|
||||
"首行当标题,后面几行当正文;标题里带 #标签 会自动加上。"
|
||||
)
|
||||
|
||||
SEPARATORS = (":", ":", " ", "\u3000")
|
||||
|
||||
|
||||
def split_command(text: str) -> tuple[str, str]:
|
||||
"""返回 (kind, 去掉前缀后的正文)。"""
|
||||
head, _, rest = text.partition("\n")
|
||||
probe = head.strip().lower()
|
||||
for prefix in HELP_PREFIXES:
|
||||
if probe == prefix or probe.startswith(f"{prefix}:") or probe.startswith(f"{prefix}:"):
|
||||
return "help", rest
|
||||
for prefix in KNOWLEDGE_PREFIXES:
|
||||
for sep in SEPARATORS:
|
||||
if probe.startswith(f"{prefix}{sep}"):
|
||||
offset = len(prefix) + len(sep)
|
||||
return "knowledge", (head.strip()[offset:] + ("\n" + rest if rest else "")).strip()
|
||||
for prefix in TASK_PREFIXES:
|
||||
for sep in SEPARATORS:
|
||||
if probe.startswith(f"{prefix}{sep}"):
|
||||
offset = len(prefix) + len(sep)
|
||||
return "task", (head.strip()[offset:] + ("\n" + rest if rest else "")).strip()
|
||||
return "task", text
|
||||
|
||||
|
||||
def split_tags(body: str) -> tuple[str, list[str]]:
|
||||
"""把 `#标签` 摘出来。只认行首和空白后的 #,免得把 "C#" 吃掉。"""
|
||||
tags: list[str] = []
|
||||
kept: list[str] = []
|
||||
for line in body.splitlines():
|
||||
rest: list[str] = []
|
||||
for word in line.split(" "):
|
||||
if word.startswith("#") and len(word) > 1 and not word[1:].isdigit():
|
||||
name = word[1:].strip(",,。.;;")
|
||||
if name and name.lower() not in {t.lower() for t in tags}:
|
||||
tags.append(name)
|
||||
continue
|
||||
rest.append(word)
|
||||
kept.append(" ".join(rest).rstrip())
|
||||
return "\n".join(kept).strip(), tags
|
||||
|
||||
|
||||
def split_title(body: str) -> tuple[str, str]:
|
||||
lines = body.splitlines()
|
||||
if not lines:
|
||||
return "", ""
|
||||
return lines[0].strip()[:MAX_TITLE], "\n".join(lines[1:]).strip()[:MAX_BODY]
|
||||
|
||||
|
||||
async def creator_id(session: AsyncSession, channel: BotChannel) -> uuid.UUID:
|
||||
"""消息记在谁名下:通道创建者;人没了就退回工作区 owner。"""
|
||||
if channel.created_by is not None:
|
||||
row = await session.execute(
|
||||
select(Member.id).where(
|
||||
Member.workspace_id == channel.workspace_id,
|
||||
Member.user_id == channel.created_by,
|
||||
Member.status == "active",
|
||||
)
|
||||
)
|
||||
if row.first() is not None:
|
||||
return channel.created_by
|
||||
workspace = await session.get(Workspace, channel.workspace_id)
|
||||
if workspace is None: # 理论上不可能:FK 是 CASCADE
|
||||
raise AppError(ErrorCode.BOT_TOKEN_INVALID, "接入通道不存在")
|
||||
return workspace.owner_id
|
||||
|
||||
|
||||
async def create_task(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
ws_id: uuid.UUID,
|
||||
creator: uuid.UUID,
|
||||
title: str,
|
||||
body: str,
|
||||
tags: list[str],
|
||||
) -> Item:
|
||||
item = Item(
|
||||
workspace_id=ws_id,
|
||||
kind="task",
|
||||
title=title,
|
||||
body_md=body,
|
||||
status="inbox",
|
||||
priority=0,
|
||||
creator_id=creator,
|
||||
)
|
||||
session.add(item)
|
||||
await session.flush()
|
||||
from ..api.items import _log, _set_tags # 延迟导入:services 不该在模块级依赖 api
|
||||
|
||||
if tags:
|
||||
await _set_tags(session, ws_id, item, tags)
|
||||
await _log(session, item, creator, "created", {"via": "wechat_clawbot"})
|
||||
return item
|
||||
|
||||
|
||||
async def create_knowledge(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
ws_id: uuid.UUID,
|
||||
creator: uuid.UUID,
|
||||
title: str,
|
||||
body: str,
|
||||
tags: list[str],
|
||||
) -> Item:
|
||||
item = Item(
|
||||
workspace_id=ws_id,
|
||||
kind="knowledge",
|
||||
title=title,
|
||||
body_md=body,
|
||||
status="draft",
|
||||
creator_id=creator,
|
||||
)
|
||||
session.add(item)
|
||||
await session.flush()
|
||||
session.add(
|
||||
KnowledgeDoc(
|
||||
item_id=item.id,
|
||||
workspace_id=ws_id,
|
||||
author_id=creator,
|
||||
source_item_id=None,
|
||||
source_kind="manual",
|
||||
doc_type="note",
|
||||
status_hint="draft",
|
||||
generated_by="human",
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
from ..api.items import _log, _set_tags # 延迟导入,理由同上
|
||||
|
||||
if tags:
|
||||
await _set_tags(session, ws_id, item, tags)
|
||||
await _log(session, item, creator, "knowledge_created", {"via": "wechat_clawbot"})
|
||||
return item
|
||||
|
||||
|
||||
def item_link(base_url: str, ws_id: uuid.UUID, item: Item) -> str:
|
||||
"""给人点的链接。`base_url` 传站点根(http://host:8200),不带 /api/v1。"""
|
||||
base = base_url.rstrip("/")
|
||||
if item.kind == "knowledge":
|
||||
return f"{base}/w/{ws_id}/knowledge/{item.id}"
|
||||
return f"{base}/w/{ws_id}?open={item.id}"
|
||||
|
||||
|
||||
def reply_text(kind: str, item: Item, link: str) -> str:
|
||||
if kind == "knowledge":
|
||||
return f"已存成知识草稿:{item.title}\n{link}"
|
||||
return f"已记下任务:{item.title}\n{link}"
|
||||
|
||||
|
||||
async def handle_text(
|
||||
session: AsyncSession,
|
||||
channel: BotChannel,
|
||||
text: str,
|
||||
*,
|
||||
base_url: str,
|
||||
) -> dict[str, Any]:
|
||||
"""一段文本 → (action, item_id, reply)。调用方负责提交事务与留痕。"""
|
||||
kind, body = split_command(text.strip())
|
||||
if kind == "help":
|
||||
return {"action": "help", "item_id": None, "reply": HELP_TEXT}
|
||||
body, tags = split_tags(body)
|
||||
title, rest = split_title(body)
|
||||
if not title:
|
||||
return {"action": "ignored", "item_id": None, "reply": "内容是空的,没记。"}
|
||||
creator = await creator_id(session, channel)
|
||||
if kind == "knowledge":
|
||||
item = await create_knowledge(
|
||||
session,
|
||||
ws_id=channel.workspace_id,
|
||||
creator=creator,
|
||||
title=title,
|
||||
body=rest,
|
||||
tags=tags,
|
||||
)
|
||||
return {
|
||||
"action": "knowledge",
|
||||
"item_id": item.id,
|
||||
"reply": reply_text("knowledge", item, item_link(base_url, channel.workspace_id, item)),
|
||||
}
|
||||
item = await create_task(
|
||||
session,
|
||||
ws_id=channel.workspace_id,
|
||||
creator=creator,
|
||||
title=title,
|
||||
body=rest,
|
||||
tags=tags,
|
||||
)
|
||||
return {
|
||||
"action": "task",
|
||||
"item_id": item.id,
|
||||
"reply": reply_text("task", item, item_link(base_url, channel.workspace_id, item)),
|
||||
}
|
||||
@@ -18,4 +18,7 @@ tzdata==2026.5
|
||||
pytest==8.3.4
|
||||
pytest-asyncio==0.25.0
|
||||
httpx==0.28.1
|
||||
# 微信登录态加密(Fernet)与二维码渲染
|
||||
cryptography==50.0.2
|
||||
qrcode==8.2
|
||||
ruff==0.8.4
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
"""微信(clawbot)接入:一条消息进来,变成任务或知识。
|
||||
|
||||
测的是**整条链路**:令牌 → 租户上下文 → 建事项 → 留痕 → 重复推送幂等。
|
||||
webhook 不带登录态,所以这里故意不用 `auth()` 头,只有管理接口才带。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
from .conftest import create_workspace, login, unwrap
|
||||
|
||||
|
||||
async def _channel(client: AsyncClient, user: dict[str, Any], ws: dict[str, Any]) -> dict:
|
||||
return unwrap(
|
||||
await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/integrations",
|
||||
json={"name": "我的微信"},
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _post(client: AsyncClient, token: str, payload: dict[str, Any]):
|
||||
return await client.post(f"/api/v1/integrations/clawbot/{token}", json=payload)
|
||||
|
||||
|
||||
async def test_plain_message_becomes_task(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "微信接入")
|
||||
channel = await _channel(client, user, ws)
|
||||
|
||||
resp = await _post(
|
||||
client,
|
||||
channel["token"],
|
||||
{"msg_id": "wx-1", "from": "steven", "text": "给客户回电话\n顺便确认下报价"},
|
||||
)
|
||||
data = unwrap(resp)
|
||||
assert data["action"] == "task"
|
||||
assert data["duplicate"] is False
|
||||
assert "给客户回电话" in data["reply"]
|
||||
|
||||
items = unwrap(
|
||||
await client.get(f"/api/v1/workspaces/{ws['id']}/items", headers=user["headers"])
|
||||
)["items"]
|
||||
task = next(i for i in items if i["title"] == "给客户回电话")
|
||||
assert task["kind"] == "task"
|
||||
assert task["status"] == "inbox"
|
||||
assert "顺便确认下报价" in task["body_md"]
|
||||
|
||||
|
||||
async def test_knowledge_prefix_creates_draft_with_tags(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "微信知识")
|
||||
channel = await _channel(client, user, ws)
|
||||
|
||||
data = unwrap(
|
||||
await _post(
|
||||
client,
|
||||
channel["token"],
|
||||
{"text": "知识:回滚前先停灰度 #发布 #回滚\n1. 停流量\n2. 回滚镜像"},
|
||||
)
|
||||
)
|
||||
assert data["action"] == "knowledge"
|
||||
|
||||
docs = unwrap(
|
||||
await client.get(
|
||||
f"/api/v1/workspaces/{ws['id']}/knowledge?status=draft", headers=user["headers"]
|
||||
)
|
||||
)["items"]
|
||||
doc = next(d for d in docs if d["title"] == "回滚前先停灰度")
|
||||
assert doc["knowledge"]["status_hint"] == "draft"
|
||||
assert doc["knowledge"]["doc_type"] == "note"
|
||||
assert {t["name"] for t in doc["tags"]} == {"发布", "回滚"}
|
||||
# 标签从标题摘出去以后,正文里不该再留 # 号
|
||||
assert "#发布" not in doc["body_md"]
|
||||
|
||||
|
||||
async def test_help_reply_creates_nothing(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "微信帮助")
|
||||
channel = await _channel(client, user, ws)
|
||||
|
||||
data = unwrap(await _post(client, channel["token"], {"text": "帮助"}))
|
||||
assert data["action"] == "help"
|
||||
assert "记成任务" in data["reply"]
|
||||
items = unwrap(
|
||||
await client.get(f"/api/v1/workspaces/{ws['id']}/items", headers=user["headers"])
|
||||
)["items"]
|
||||
assert items == []
|
||||
|
||||
|
||||
async def test_unknown_token_is_404(client: AsyncClient) -> None:
|
||||
resp = await client.post("/api/v1/integrations/clawbot/nope", json={"text": "x"})
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["code"] == 9002
|
||||
|
||||
|
||||
async def test_disabled_channel_refuses_messages(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "微信停用")
|
||||
channel = await _channel(client, user, ws)
|
||||
await client.patch(
|
||||
f"/api/v1/workspaces/{ws['id']}/integrations/{channel['channel']['id']}",
|
||||
json={"status": "disabled"},
|
||||
headers=user["headers"],
|
||||
)
|
||||
resp = await _post(client, channel["token"], {"text": "这条不该进去"})
|
||||
assert resp.status_code == 403
|
||||
assert resp.json()["code"] == 9003
|
||||
|
||||
|
||||
async def test_same_message_id_is_idempotent(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "微信重推")
|
||||
channel = await _channel(client, user, ws)
|
||||
payload = {"msg_id": "dup-1", "text": "只该有一条"}
|
||||
|
||||
first = unwrap(await _post(client, channel["token"], payload))
|
||||
second = unwrap(await _post(client, channel["token"], payload))
|
||||
assert second["duplicate"] is True
|
||||
assert second["reply"] == first["reply"]
|
||||
|
||||
items = unwrap(
|
||||
await client.get(f"/api/v1/workspaces/{ws['id']}/items", headers=user["headers"])
|
||||
)["items"]
|
||||
assert [i["title"] for i in items] == ["只该有一条"]
|
||||
|
||||
|
||||
async def test_get_verify_echoes_challenge(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "微信校验")
|
||||
channel = await _channel(client, user, ws)
|
||||
|
||||
resp = await client.get(f"/api/v1/integrations/clawbot/{channel['token']}?echostr=abc123")
|
||||
assert resp.status_code == 200
|
||||
assert resp.text == "abc123"
|
||||
|
||||
|
||||
async def test_token_belongs_to_one_workspace(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
ws_a = await create_workspace(client, user, "A 区")
|
||||
ws_b = await create_workspace(client, user, "B 区")
|
||||
channel = await _channel(client, user, ws_a)
|
||||
|
||||
await _post(client, channel["token"], {"text": "只该进 A 区"})
|
||||
|
||||
items_a = unwrap(
|
||||
await client.get(f"/api/v1/workspaces/{ws_a['id']}/items", headers=user["headers"])
|
||||
)["items"]
|
||||
items_b = unwrap(
|
||||
await client.get(f"/api/v1/workspaces/{ws_b['id']}/items", headers=user["headers"])
|
||||
)["items"]
|
||||
assert [i["title"] for i in items_a] == ["只该进 A 区"]
|
||||
assert items_b == []
|
||||
|
||||
|
||||
async def test_rotate_invalidates_old_token(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "微信换令牌")
|
||||
channel = await _channel(client, user, ws)
|
||||
rotated = unwrap(
|
||||
await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/integrations/{channel['channel']['id']}/rotate",
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
assert rotated["token"] != channel["token"]
|
||||
old = await _post(client, channel["token"], {"text": "旧令牌"})
|
||||
assert old.status_code == 404
|
||||
new = unwrap(await _post(client, rotated["token"], {"text": "新令牌能用"}))
|
||||
assert new["action"] == "task"
|
||||
|
||||
|
||||
async def test_member_cannot_manage_channels(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
ws = await create_workspace(client, owner, "微信权限")
|
||||
member = await login(client)
|
||||
code = unwrap(
|
||||
await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/invitations",
|
||||
json={"role": "member"},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/join",
|
||||
json={"code": code["code"]},
|
||||
headers=member["headers"],
|
||||
)
|
||||
resp = await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/integrations",
|
||||
json={"name": "偷加的通道"},
|
||||
headers=member["headers"],
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
assert resp.json()["code"] in (3004, 3003)
|
||||
|
||||
|
||||
async def test_webhook_url_carries_api_prefix(client: AsyncClient) -> None:
|
||||
"""回调地址必须带 /api/v1:生产上应用挂在域名根,漏了前缀就是 405。"""
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "微信地址")
|
||||
channel = await _channel(client, user, ws)
|
||||
assert channel["webhook_url"].endswith(f"/api/v1/integrations/clawbot/{channel['token']}")
|
||||
@@ -0,0 +1,326 @@
|
||||
"""微信 clawbot:扫码登录 + 长轮询 + 消息落成任务/知识。
|
||||
|
||||
不联网:iLink 的四个接口都用 monkeypatch 顶掉,验的是我们这侧的流程与落库:
|
||||
取二维码 → 等扫码 → 存加密登录态 → 轮询拿到消息 → 建事项 → 留痕 → 回复 → 去重。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from app.db import SessionFactory, bind_tenant
|
||||
from app.models_integrations import BotChannel, BotMessage
|
||||
from app.models_task import Item
|
||||
from app.services import wechat_bot, wechat_ilink
|
||||
from app.services.wechat_intake import handle_text
|
||||
|
||||
from .conftest import create_workspace, login, unwrap
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_background_tasks(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""别在测试里真起轮询任务:这里只验"该不该起",任务体另测。"""
|
||||
|
||||
async def fake_start(self, channel_id: uuid.UUID) -> None: # noqa: ARG001
|
||||
return None
|
||||
|
||||
async def fake_stop(self, channel_id: uuid.UUID) -> None: # noqa: ARG001
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(wechat_bot.BotManager, "start", fake_start)
|
||||
monkeypatch.setattr(wechat_bot.BotManager, "stop", fake_stop)
|
||||
|
||||
|
||||
async def _begin(client: AsyncClient, user: dict[str, Any], ws: dict[str, Any]) -> dict[str, Any]:
|
||||
return unwrap(
|
||||
await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/integrations/wechat/qr",
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _patch_qr(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def fake_qr() -> wechat_ilink.QrCode:
|
||||
return wechat_ilink.QrCode(qrcode_id="qr-1", payload="https://wx.example/qr/1")
|
||||
|
||||
monkeypatch.setattr(wechat_ilink, "get_qrcode", fake_qr)
|
||||
|
||||
|
||||
def _patch_status(monkeypatch: pytest.MonkeyPatch, status: wechat_ilink.QrStatus) -> None:
|
||||
async def fake_status(qrcode_id: str) -> wechat_ilink.QrStatus: # noqa: ARG001
|
||||
return status
|
||||
|
||||
monkeypatch.setattr(wechat_ilink, "qrcode_status", fake_status)
|
||||
|
||||
|
||||
async def test_begin_login_returns_scannable_qr(
|
||||
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_patch_qr(monkeypatch)
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "扫码接入")
|
||||
|
||||
data = await _begin(client, user, ws)
|
||||
|
||||
assert data["qrcode_id"] == "qr-1"
|
||||
assert data["payload"] == "https://wx.example/qr/1"
|
||||
assert data["image"].startswith("data:image/svg+xml;base64,")
|
||||
|
||||
channels = unwrap(
|
||||
await client.get(f"/api/v1/workspaces/{ws['id']}/integrations", headers=user["headers"])
|
||||
)
|
||||
assert channels["items"][0]["status"] == "pending"
|
||||
assert channels["items"][0]["connected"] is False
|
||||
|
||||
|
||||
async def test_scan_confirms_and_stores_encrypted_token(
|
||||
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_patch_qr(monkeypatch)
|
||||
_patch_status(
|
||||
monkeypatch,
|
||||
wechat_ilink.QrStatus(
|
||||
status="confirmed", bot_token="bot-token-abc", ilink_user_id="wx-user-9"
|
||||
),
|
||||
)
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "扫码接入")
|
||||
started = await _begin(client, user, ws)
|
||||
|
||||
result = unwrap(
|
||||
await client.get(
|
||||
f"/api/v1/workspaces/{ws['id']}/integrations/wechat/qr/{started['qrcode_id']}",
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
assert result["status"] == "confirmed"
|
||||
assert result["channel"]["connected"] is True
|
||||
assert result["channel"]["ilink_user_id"] == "wx-user-9"
|
||||
|
||||
channel_id = uuid.UUID(started["channel_id"])
|
||||
async with SessionFactory() as session:
|
||||
await session.execute(
|
||||
text("SELECT set_config('app.bot_channel', :c, true)"), {"c": str(channel_id)}
|
||||
)
|
||||
channel = await session.get(BotChannel, channel_id)
|
||||
assert channel is not None
|
||||
assert channel.bot_token_enc is not None
|
||||
# 落库的是密文,且能解回原值
|
||||
assert "bot-token-abc" not in channel.bot_token_enc
|
||||
assert wechat_ilink.decrypt_token(channel.bot_token_enc) == "bot-token-abc"
|
||||
|
||||
|
||||
async def test_stale_qr_poll_is_reported_as_expired(
|
||||
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_patch_qr(monkeypatch)
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "扫码接入")
|
||||
await _begin(client, user, ws)
|
||||
|
||||
data = unwrap(
|
||||
await client.get(
|
||||
f"/api/v1/workspaces/{ws['id']}/integrations/wechat/qr/another-qr",
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
assert data["status"] == "expired"
|
||||
|
||||
|
||||
async def test_disconnect_clears_login_state(
|
||||
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_patch_qr(monkeypatch)
|
||||
_patch_status(monkeypatch, wechat_ilink.QrStatus(status="confirmed", bot_token="bot-token-abc"))
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "扫码接入")
|
||||
started = await _begin(client, user, ws)
|
||||
unwrap(
|
||||
await client.get(
|
||||
f"/api/v1/workspaces/{ws['id']}/integrations/wechat/qr/{started['qrcode_id']}",
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
|
||||
data = unwrap(
|
||||
await client.delete(
|
||||
f"/api/v1/workspaces/{ws['id']}/integrations/{started['channel_id']}",
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
assert data["channel"]["connected"] is False
|
||||
assert data["channel"]["status"] == "pending"
|
||||
|
||||
|
||||
async def test_plain_text_becomes_task(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "收消息")
|
||||
ws_id = uuid.UUID(ws["id"])
|
||||
|
||||
async with SessionFactory() as session:
|
||||
await bind_tenant(session, ws_id, uuid.UUID(user["user_id"]))
|
||||
channel = BotChannel(
|
||||
workspace_id=ws_id, kind="wechat_clawbot", name="微信", status="active"
|
||||
)
|
||||
session.add(channel)
|
||||
await session.flush()
|
||||
result = await handle_text(
|
||||
session, channel, "给客户回电话\n顺便确认报价", base_url="http://site"
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
assert result["action"] == "task"
|
||||
assert "已记下任务" in result["reply"]
|
||||
async with SessionFactory() as session:
|
||||
await bind_tenant(session, ws_id, uuid.UUID(user["user_id"]))
|
||||
item = await session.get(Item, result["item_id"])
|
||||
assert item is not None
|
||||
assert item.title == "给客户回电话"
|
||||
assert item.kind == "task"
|
||||
assert item.status == "inbox"
|
||||
|
||||
|
||||
async def test_knowledge_prefix_creates_draft_with_tags(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "收消息")
|
||||
ws_id = uuid.UUID(ws["id"])
|
||||
|
||||
async with SessionFactory() as session:
|
||||
await bind_tenant(session, ws_id, uuid.UUID(user["user_id"]))
|
||||
channel = BotChannel(
|
||||
workspace_id=ws_id, kind="wechat_clawbot", name="微信", status="active"
|
||||
)
|
||||
session.add(channel)
|
||||
await session.flush()
|
||||
result = await handle_text(
|
||||
session,
|
||||
channel,
|
||||
"知识:回滚前先停灰度 #发布 #回滚\n先停流量,再退镜像版本",
|
||||
base_url="http://site",
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
assert result["action"] == "knowledge"
|
||||
async with SessionFactory() as session:
|
||||
await bind_tenant(session, ws_id, uuid.UUID(user["user_id"]))
|
||||
item = await session.get(Item, result["item_id"])
|
||||
assert item is not None
|
||||
assert item.kind == "knowledge"
|
||||
assert item.status == "draft"
|
||||
assert item.title == "回滚前先停灰度"
|
||||
assert "#" not in item.title
|
||||
|
||||
|
||||
async def test_help_does_not_create_anything(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "收消息")
|
||||
ws_id = uuid.UUID(ws["id"])
|
||||
|
||||
async with SessionFactory() as session:
|
||||
await bind_tenant(session, ws_id, uuid.UUID(user["user_id"]))
|
||||
channel = BotChannel(
|
||||
workspace_id=ws_id, kind="wechat_clawbot", name="微信", status="active"
|
||||
)
|
||||
session.add(channel)
|
||||
await session.flush()
|
||||
result = await handle_text(session, channel, "帮助", base_url="http://site")
|
||||
await session.commit()
|
||||
|
||||
assert result["action"] == "help"
|
||||
assert result["item_id"] is None
|
||||
async with SessionFactory() as session:
|
||||
await bind_tenant(session, ws_id, uuid.UUID(user["user_id"]))
|
||||
items = (
|
||||
(await session.execute(select(Item).where(Item.workspace_id == ws_id))).scalars().all()
|
||||
)
|
||||
assert items == []
|
||||
|
||||
|
||||
async def test_poller_handles_message_once_and_replies(
|
||||
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""同一个外部消息 id 到两次,只能有一条任务、一条留痕、一次回复。"""
|
||||
_patch_qr(monkeypatch)
|
||||
_patch_status(monkeypatch, wechat_ilink.QrStatus(status="confirmed", bot_token="bot-token-abc"))
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "轮询")
|
||||
started = await _begin(client, user, ws)
|
||||
unwrap(
|
||||
await client.get(
|
||||
f"/api/v1/workspaces/{ws['id']}/integrations/wechat/qr/{started['qrcode_id']}",
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
channel_id = uuid.UUID(started["channel_id"])
|
||||
|
||||
replies: list[tuple[str, str]] = []
|
||||
|
||||
async def fake_send(
|
||||
token: str, to: str, context: str | None, text: str, *, base_url: str | None = None
|
||||
) -> None: # noqa: ARG001
|
||||
replies.append((to, text))
|
||||
|
||||
monkeypatch.setattr(wechat_ilink, "send_text", fake_send)
|
||||
|
||||
message = wechat_ilink.Inbound(
|
||||
message_id="msg-1",
|
||||
sender="wx-user-9",
|
||||
text="整理一下本周排期",
|
||||
context_token="ctx-1",
|
||||
)
|
||||
await wechat_bot.manager._handle(channel_id, message, "bot-token-abc", None)
|
||||
await wechat_bot.manager._handle(channel_id, message, "bot-token-abc", None)
|
||||
|
||||
assert len(replies) == 1
|
||||
assert replies[0][0] == "wx-user-9"
|
||||
assert "已记下任务" in replies[0][1]
|
||||
|
||||
async with SessionFactory() as session:
|
||||
await session.execute(
|
||||
text("SELECT set_config('app.bot_channel', :c, true)"), {"c": str(channel_id)}
|
||||
)
|
||||
channel = await session.get(BotChannel, channel_id)
|
||||
assert channel is not None
|
||||
assert channel.context_token == "ctx-1"
|
||||
assert channel.last_seen_at is not None
|
||||
# bot_message 走标准租户隔离,验它得先绑定工作区
|
||||
await bind_tenant(session, uuid.UUID(ws["id"]), uuid.UUID(user["user_id"]))
|
||||
messages = (
|
||||
(await session.execute(select(BotMessage).where(BotMessage.channel_id == channel_id)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(messages) == 1
|
||||
assert messages[0].action == "task"
|
||||
|
||||
|
||||
async def test_stale_token_marks_channel_expired(
|
||||
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_patch_qr(monkeypatch)
|
||||
_patch_status(monkeypatch, wechat_ilink.QrStatus(status="confirmed", bot_token="bot-token-abc"))
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "轮询")
|
||||
started = await _begin(client, user, ws)
|
||||
unwrap(
|
||||
await client.get(
|
||||
f"/api/v1/workspaces/{ws['id']}/integrations/wechat/qr/{started['qrcode_id']}",
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
channel_id = uuid.UUID(started["channel_id"])
|
||||
|
||||
await wechat_bot.manager._mark_relogin(channel_id, "登录态已失效,需要重新扫码")
|
||||
|
||||
channels = unwrap(
|
||||
await client.get(f"/api/v1/workspaces/{ws['id']}/integrations", headers=user["headers"])
|
||||
)
|
||||
assert channels["items"][0]["status"] == "expired"
|
||||
assert channels["items"][0]["connected"] is False
|
||||
assert "重新扫码" in channels["items"][0]["last_error"]
|
||||
+36
-54
@@ -1,9 +1,8 @@
|
||||
/**
|
||||
* 微信接入的端到端验证:页面上建通道 → 把"微信"发来的那条 POST 打到回调地址
|
||||
* → 任务真出现在任务列表里。
|
||||
* 微信接入的端到端验证:页面上点「扫码连接微信」→ 真拿一张二维码回来 → 画在页面上。
|
||||
*
|
||||
* 这里不发真正的微信消息(那得有人拿手机发),而是照着 clawbot 的形状
|
||||
* 直接 POST 一次——它就是这个接口唯一的调用方。
|
||||
* 不扫这张码(那得有人拿手机扫),所以只验到"二维码出来了、通道在等扫码"为止;
|
||||
* 扫码之后的落库与轮询由 `backend/tests/test_wechat_bot.py` 覆盖。
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { chromium } from "playwright-core";
|
||||
@@ -64,67 +63,50 @@ async function main() {
|
||||
localStorage.setItem("dadao.access", access);
|
||||
localStorage.setItem("dadao.refresh", refresh);
|
||||
},
|
||||
[token, session.tokens.refresh_token],
|
||||
[session.tokens.access_token, session.tokens.refresh_token],
|
||||
);
|
||||
|
||||
await page.goto(`${WEB}/w/${ws.id}/integrations`, { waitUntil: "networkidle" });
|
||||
check("接入页能打开", await page.getByRole("heading", { name: "接入" }).isVisible());
|
||||
check("接入页打开", await page.getByText("怎么用").isVisible());
|
||||
|
||||
// 建一条新通道:令牌只在创建后那一屏出现
|
||||
await page.getByRole("button", { name: "新建通道" }).click();
|
||||
const urlBox = page.getByTestId("webhook-url");
|
||||
await urlBox.waitFor({ state: "visible" });
|
||||
const url = (await urlBox.innerText()).trim();
|
||||
check("创建后拿到回调地址", /\/integrations\/clawbot\/[\w-]{20,}/.test(url), url.slice(0, 48) + "…");
|
||||
const button = page.getByRole("button", { name: "扫码连接微信" });
|
||||
if (await button.isVisible()) {
|
||||
await button.click();
|
||||
} else {
|
||||
// 之前连过:卡片上给的是「重新扫码」
|
||||
await page.getByRole("button", { name: "重新扫码" }).click();
|
||||
}
|
||||
|
||||
// 冒充 clawbot 推一条消息
|
||||
const push = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
msg_id: `e2e-${Date.now()}`,
|
||||
from: "e2e",
|
||||
text: `微信来的任务 ${Date.now()}\n从手机发的,带正文`,
|
||||
}),
|
||||
});
|
||||
const pushed = await push.json();
|
||||
check("回调地址收到消息并建了任务", pushed.code === 0 && pushed.data.action === "task");
|
||||
const title = pushed.data.reply.match(/《(.+?)》/)?.[1] || "";
|
||||
const card = page.getByTestId("wechat-qr");
|
||||
await card.waitFor({ state: "visible", timeout: 20_000 });
|
||||
const img = card.locator("img");
|
||||
const src = await img.getAttribute("src");
|
||||
check(
|
||||
"二维码是页面里画出来的 SVG",
|
||||
Boolean(src?.startsWith("data:image/svg+xml;base64,")),
|
||||
src?.slice(0, 32),
|
||||
);
|
||||
check(
|
||||
"提示用微信扫码",
|
||||
(await card.innerText()).includes("用微信扫这张码"),
|
||||
(await card.innerText()).split("\n")[0],
|
||||
);
|
||||
|
||||
// 再推一次同一条:不该多出第二条
|
||||
const again = await (
|
||||
await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ msg_id: "dup", text: "重复消息" }),
|
||||
})
|
||||
).json();
|
||||
const dup = await (
|
||||
await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ msg_id: "dup", text: "重复消息" }),
|
||||
})
|
||||
).json();
|
||||
check("同一条消息不会建两条", again.code === 0 && dup.data.duplicate === true);
|
||||
const channels = await (await fetch(`${API}/workspaces/${ws.id}/integrations`, { headers })).json();
|
||||
const channel = channels.data.items[0];
|
||||
check("后端记下了这条通道", Boolean(channel), JSON.stringify(channel?.status));
|
||||
check("还没扫码,状态是 pending", channel?.status === "pending", channel?.status);
|
||||
check("没有登录态就不该在轮询", channel?.polling === false, String(channel?.polling));
|
||||
|
||||
// 页面上的「最近消息」要能看到刚才那几条
|
||||
await page.getByRole("button", { name: "最近消息" }).first().click();
|
||||
await page.getByText("建了任务").first().waitFor({ state: "visible" });
|
||||
check("页面能看到收到的消息", await page.getByText("建了任务").first().isVisible());
|
||||
await page.screenshot({ path: "e2e/artifacts/integrations.png", fullPage: false });
|
||||
check("控制台没有报错", consoleErrors.length === 0, consoleErrors.slice(0, 2).join(" | "));
|
||||
|
||||
// 任务真的进了任务列表
|
||||
const items = await (await fetch(`${API}/workspaces/${ws.id}/items`, { headers })).json();
|
||||
const found = items.data.items.find((i) => i.title === title);
|
||||
check("任务出现在工作区里", Boolean(found), title);
|
||||
|
||||
check("控制台零报错", consoleErrors.length === 0, consoleErrors.slice(0, 2).join(" | "));
|
||||
|
||||
await page.screenshot({ path: "e2e/artifacts/integrations.png", fullPage: true });
|
||||
await browser.close();
|
||||
|
||||
const failed = results.filter((r) => !r.ok);
|
||||
console.log(`\n${failed.length === 0 ? "ALL PASS" : "FAILED"} — pass ${results.length - failed.length}, fail ${failed.length}`);
|
||||
console.log(
|
||||
`\n${failed.length === 0 ? "ALL PASS" : "FAILED"} — pass ${results.length - failed.length}, fail ${failed.length}`,
|
||||
);
|
||||
if (failed.length > 0) process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
+14
-2
@@ -225,17 +225,29 @@ export interface AppNotification {
|
||||
created_at: string;
|
||||
}
|
||||
/** 外部接入通道:微信机器人靠这条令牌往工作区里发消息。 */
|
||||
/** 微信通道:`status` 是登录态的四种情形,`connected` 只是它的近视写法。 */
|
||||
export interface BotChannel {
|
||||
id: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
status: "active" | "disabled";
|
||||
created_by: string | null;
|
||||
status: "pending" | "active" | "disabled" | "expired";
|
||||
connected: boolean;
|
||||
polling: boolean;
|
||||
ilink_user_id: string | null;
|
||||
last_seen_at: string | null;
|
||||
last_error: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** 取二维码的返回:`image` 是 data URL,前端直接塞进 `<img>`。 */
|
||||
export interface WechatQr {
|
||||
channel_id: string;
|
||||
qrcode_id: string;
|
||||
payload: string;
|
||||
image: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
/** 收到的消息留痕:`action` 说明它变成了什么。 */
|
||||
export interface BotMessage {
|
||||
id: string;
|
||||
|
||||
+238
-217
@@ -1,18 +1,12 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
import { AppShell } from "../components/AppShell";
|
||||
import { Chat, Link2, Plus, Trash } from "../components/icons";
|
||||
import { Chat } from "../components/icons";
|
||||
import { Button, EmptyState, InlineAlert, PageHeader, SkeletonRows } from "../components/ui";
|
||||
import { useAuth } from "../App";
|
||||
import { api, ApiError } from "../lib/client";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import type { BotChannel, BotMessage } from "../lib/types";
|
||||
|
||||
/** 真发出去的那一步:把令牌拼成地址。 */
|
||||
function webhookUrl(origin: string, token: string) {
|
||||
return `${origin.replace(/\/$/, "")}/api/v1/integrations/clawbot/${token}`;
|
||||
}
|
||||
import type { BotChannel, BotMessage, WechatQr } from "../lib/types";
|
||||
|
||||
function ago(iso: string | null): string {
|
||||
if (!iso) return "还没收到过消息";
|
||||
@@ -33,15 +27,42 @@ const ACTION_TEXT: Record<string, string> = {
|
||||
error: "出错",
|
||||
};
|
||||
|
||||
/** 扫码的几种回话,说给用户听的话。 */
|
||||
const QR_TEXT: Record<string, string> = {
|
||||
waiting: "用微信扫这张码,扫完在手机上点确认。",
|
||||
scaned: "已经扫到了,在手机上点一下「确认登录」。",
|
||||
confirmed: "配好了,现在可以直接在微信里发消息。",
|
||||
expired: "二维码过期了,重新拿一张。",
|
||||
error: "问登录结果时出错了,稍等再试。",
|
||||
};
|
||||
|
||||
function ChannelStatus({ channel }: { channel: BotChannel }) {
|
||||
if (channel.status === "active") {
|
||||
return (
|
||||
<span className="chip bg-primary-50 text-primary-700">
|
||||
<span className="mr-1 inline-block h-1.5 w-1.5 rounded-full bg-primary-600" />
|
||||
接收中
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (channel.status === "expired") {
|
||||
return <span className="chip bg-rust-50 text-rust-500">登录已失效</span>;
|
||||
}
|
||||
if (channel.status === "disabled") {
|
||||
return <span className="chip bg-paper-sunken text-ink-500">已停用</span>;
|
||||
}
|
||||
return <span className="chip bg-paper-sunken text-ink-500">待扫码</span>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 接入页:把微信(clawbot)接进这个工作区。
|
||||
*
|
||||
* 令牌是通道的钥匙,**只在创建/重生成时出现一次**——旁边配一个复制按钮,
|
||||
* 因为用户要做的就是把它粘进 clawbot 的后台。
|
||||
* 走的是腾讯 iLink Bot 协议:**扫码登录 → 后端长轮询收消息 → 用 context_token 回复**。
|
||||
* 页面这一侧只做两件事:把二维码画出来、反复问「扫了没」。
|
||||
*/
|
||||
export function IntegrationsPage() {
|
||||
const { wsId = "" } = useParams();
|
||||
const { user, workspaces } = useAuth();
|
||||
const { workspaces } = useAuth();
|
||||
const ws = workspaces.find((w) => w.id === wsId);
|
||||
const canManage = ws?.role === "owner" || ws?.role === "admin";
|
||||
|
||||
@@ -49,10 +70,12 @@ export function IntegrationsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
/** 刚建好或刚换过的令牌:只活在这一屏里,刷新就没了(库里只有 sha256)。 */
|
||||
const [fresh, setFresh] = useState<{ id: string; token: string; url: string } | null>(null);
|
||||
const [copied, setCopied] = useState<"url" | "token" | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [qr, setQr] = useState<WechatQr | null>(null);
|
||||
const [qrStatus, setQrStatus] = useState<string>("waiting");
|
||||
const [messages, setMessages] = useState<Record<string, BotMessage[]>>({});
|
||||
/** 关掉轮询:确认成功、过期、或者用户离开这一屏。 */
|
||||
const stopPolling = useRef(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -61,7 +84,7 @@ export function IntegrationsPage() {
|
||||
const data = await api.get<{ items: BotChannel[] }>(`/workspaces/${wsId}/integrations`);
|
||||
setChannels(data.items);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "读取接入配置失败");
|
||||
setError(err instanceof ApiError ? err.message : "读不到接入配置");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -71,99 +94,95 @@ export function IntegrationsPage() {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
async function create() {
|
||||
setBusy("create");
|
||||
const channel = channels[0] ?? null;
|
||||
|
||||
const startQr = useCallback(async () => {
|
||||
setBusy("qr");
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
setMessages({});
|
||||
stopPolling.current = false;
|
||||
try {
|
||||
const data = await api.post<WechatQr>(`/workspaces/${wsId}/integrations/wechat/qr`, {});
|
||||
setQr(data);
|
||||
setQrStatus("waiting");
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "取二维码失败,稍后再试");
|
||||
setQr(null);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}, [wsId]);
|
||||
|
||||
// 每 2 秒问一次扫码进度;确认之后换成"已连接"并停手。
|
||||
useEffect(() => {
|
||||
if (!qr || stopPolling.current) return;
|
||||
let alive = true;
|
||||
const timer = window.setTimeout(async () => {
|
||||
try {
|
||||
const data = await api.get<{ status: string; channel?: BotChannel }>(
|
||||
`/workspaces/${wsId}/integrations/wechat/qr/${qr.qrcode_id}`,
|
||||
);
|
||||
if (!alive) return;
|
||||
setQrStatus(data.status);
|
||||
if (data.status === "confirmed") {
|
||||
stopPolling.current = true;
|
||||
setQr(null);
|
||||
setNotice("微信已经连上,可以直接在对话里发「任务:…」或「知识:…」。");
|
||||
void load();
|
||||
} else if (data.status === "expired") {
|
||||
stopPolling.current = true;
|
||||
}
|
||||
} catch {
|
||||
if (alive) setQrStatus("error");
|
||||
}
|
||||
}, 2000);
|
||||
return () => {
|
||||
alive = false;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [qr, qrStatus, wsId, load]);
|
||||
|
||||
const disconnect = useCallback(
|
||||
async (id: string) => {
|
||||
setBusy(id);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.post<{ channel: BotChannel; token: string }>(
|
||||
`/workspaces/${wsId}/integrations`,
|
||||
{ name: "微信" },
|
||||
await api.del(`/workspaces/${wsId}/integrations/${id}?confirm=true`);
|
||||
setQr(null);
|
||||
stopPolling.current = true;
|
||||
setNotice("已断开,微信里再发消息就不会进来了。");
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "断开失败");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[wsId, load],
|
||||
);
|
||||
setFresh({
|
||||
id: data.channel.id,
|
||||
token: data.token,
|
||||
url: webhookUrl(window.location.origin, data.token),
|
||||
|
||||
const showMessages = useCallback(
|
||||
async (id: string) => {
|
||||
if (messages[id]) {
|
||||
setMessages((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "创建通道失败");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle(channel: BotChannel) {
|
||||
setBusy(channel.id);
|
||||
try {
|
||||
await api.patch<{ channel: BotChannel }>(
|
||||
`/workspaces/${wsId}/integrations/${channel.id}`,
|
||||
{ status: channel.status === "active" ? "disabled" : "active" },
|
||||
);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "改状态失败");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function rotate(channel: BotChannel) {
|
||||
setBusy(channel.id);
|
||||
try {
|
||||
const data = await api.post<{ token: string }>(
|
||||
`/workspaces/${wsId}/integrations/${channel.id}/rotate`,
|
||||
{},
|
||||
);
|
||||
setFresh({
|
||||
id: channel.id,
|
||||
token: data.token,
|
||||
url: webhookUrl(window.location.origin, data.token),
|
||||
});
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "换令牌失败");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(channel: BotChannel) {
|
||||
if (!window.confirm(`删掉「${channel.name}」这条通道?微信那边再发消息就进不来了。`)) return;
|
||||
setBusy(channel.id);
|
||||
try {
|
||||
await api.del(`/workspaces/${wsId}/integrations/${channel.id}`);
|
||||
if (fresh?.id === channel.id) setFresh(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "删除失败");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function peek(channel: BotChannel) {
|
||||
setBusy(channel.id);
|
||||
try {
|
||||
const data = await api.get<{ items: BotMessage[] }>(
|
||||
`/workspaces/${wsId}/integrations/${channel.id}/messages?limit=5`,
|
||||
);
|
||||
setMessages((prev) => ({ ...prev, [channel.id]: data.items }));
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "读取消息失败");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function copy(kind: "url" | "token", text: string) {
|
||||
const ok = await copyText(text);
|
||||
if (!ok) {
|
||||
setError("浏览器不让直接写剪贴板,请手动选中复制");
|
||||
return;
|
||||
}
|
||||
setCopied(kind);
|
||||
window.setTimeout(() => setCopied(null), 1600);
|
||||
try {
|
||||
const data = await api.get<{ items: BotMessage[] }>(
|
||||
`/workspaces/${wsId}/integrations/${id}/messages?limit=5`,
|
||||
);
|
||||
setMessages((prev) => ({ ...prev, [id]: data.items }));
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "读不到消息记录");
|
||||
}
|
||||
},
|
||||
[wsId, messages],
|
||||
);
|
||||
|
||||
return (
|
||||
<AppShell wsId={wsId} active="integrations">
|
||||
@@ -172,8 +191,8 @@ export function IntegrationsPage() {
|
||||
desc="微信里发一句话,这里就多一条任务或知识。不用装 App,也不用切浏览器。"
|
||||
actions={
|
||||
canManage ? (
|
||||
<Button variant="primary" onClick={() => void create()} disabled={busy === "create"}>
|
||||
<Plus size={14} /> 新建通道
|
||||
<Button variant="primary" onClick={() => void startQr()} disabled={busy === "qr"}>
|
||||
{busy === "qr" ? "正在取二维码…" : qr ? "换一张二维码" : "扫码连接微信"}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
@@ -184,140 +203,142 @@ export function IntegrationsPage() {
|
||||
<InlineAlert tone="error">{error}</InlineAlert>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mb-5 rounded-xl border border-line-200 bg-paper-50 px-4 py-3 text-sm text-ink-600">
|
||||
<p className="font-medium text-ink-800">怎么用</p>
|
||||
<ol className="mt-1.5 list-decimal space-y-1 pl-5 text-sm leading-6">
|
||||
<li>在下面建一条通道,把「回调地址」粘进 clawbot 的后台。</li>
|
||||
<li>在微信里给机器人发消息:直接说就是任务,「知识:」开头记成知识草稿。</li>
|
||||
<li>首行当标题,其余当正文;标题里带 <code className="text-ink-800">#标签</code> 会一起加上。</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{fresh ? (
|
||||
<div className="mb-5 rounded-xl border border-primary-200 bg-primary-50/60 px-4 py-4">
|
||||
<p className="text-sm font-medium text-primary-800">通道已就绪,这两样只在现在显示一次</p>
|
||||
<div className="mt-3 space-y-2">
|
||||
<div>
|
||||
<p className="text-2xs uppercase tracking-wide text-ink-500">回调地址</p>
|
||||
<div className="mt-1 flex items-start gap-2">
|
||||
<code
|
||||
data-testid="webhook-url"
|
||||
className="min-w-0 flex-1 break-all rounded-lg bg-white px-3 py-2 text-xs text-ink-800"
|
||||
>
|
||||
{fresh.url}
|
||||
</code>
|
||||
<Button size="sm" onClick={() => void copy("url", fresh.url)}>
|
||||
<Link2 size={13} /> {copied === "url" ? "已复制" : "复制"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xs uppercase tracking-wide text-ink-500">令牌</p>
|
||||
<div className="mt-1 flex items-start gap-2">
|
||||
<code
|
||||
data-testid="webhook-token"
|
||||
className="min-w-0 flex-1 break-all rounded-lg bg-white px-3 py-2 text-xs text-ink-800"
|
||||
>
|
||||
{fresh.token}
|
||||
</code>
|
||||
<Button size="sm" onClick={() => void copy("token", fresh.token)}>
|
||||
{copied === "token" ? "已复制" : "复制"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-ink-500">
|
||||
令牌等同于钥匙:库里只存它的哈希,页面刷新就再也看不到。丢了就「重新生成」,旧地址立刻失效。
|
||||
</p>
|
||||
{notice ? (
|
||||
<div className="mb-4">
|
||||
<InlineAlert tone="success">{notice}</InlineAlert>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="card mb-4 p-5">
|
||||
<h2 className="text-sm font-semibold text-ink-900">怎么用</h2>
|
||||
<ol className="mt-2 list-decimal space-y-1 pl-5 text-sm leading-6 text-ink-600">
|
||||
<li>点右上「扫码连接微信」,用手机微信扫出现的二维码并在手机上确认。</li>
|
||||
<li>连上之后直接给这个微信机器人发消息:默认记成任务,「知识:」开头记成知识草稿。</li>
|
||||
<li>第一行当标题,其余当正文;标题或正文里带 #标签 会一起加上。</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{loading ? (
|
||||
<SkeletonRows rows={3} />
|
||||
) : channels.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Chat size={18} />}
|
||||
title="还没有接进来"
|
||||
hint={
|
||||
canManage
|
||||
? "先建一条通道,再把地址填到 clawbot 里;之后微信里发的话就会出现在任务和知识里。"
|
||||
: "这个工作区还没有配接入通道。需要所有者或管理员来建。"
|
||||
}
|
||||
/>
|
||||
<div className="card">
|
||||
<SkeletonRows rows={2} />
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{channels.map((channel) => (
|
||||
<li key={channel.id} className="rounded-xl border border-line-200 bg-white px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="grid h-8 w-8 place-items-center rounded-lg bg-primary-50 text-primary-700">
|
||||
<Chat size={16} />
|
||||
</span>
|
||||
<>
|
||||
{qr ? (
|
||||
<section className="card mb-4 p-5" data-testid="wechat-qr">
|
||||
<div className="flex flex-col items-start gap-4 sm:flex-row sm:items-center">
|
||||
<img
|
||||
src={qr.image}
|
||||
alt="微信扫码登录二维码"
|
||||
className="h-44 w-44 rounded-xl border border-line-200 bg-white p-2"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-ink-900">{channel.name}</p>
|
||||
<p className="text-xs text-ink-500">{ago(channel.last_seen_at)}</p>
|
||||
</div>
|
||||
<span
|
||||
className={`ml-1 rounded-full px-2 py-px text-2xs ${
|
||||
channel.status === "active"
|
||||
? "bg-primary-50 text-primary-700"
|
||||
: "bg-paper-100 text-ink-500"
|
||||
}`}
|
||||
>
|
||||
{channel.status === "active" ? "接收中" : "已停用"}
|
||||
</span>
|
||||
{canManage ? (
|
||||
<div className="ml-auto flex flex-wrap items-center gap-1.5">
|
||||
<Button size="sm" onClick={() => void peek(channel)} disabled={busy === channel.id}>
|
||||
最近消息
|
||||
<h2 className="text-sm font-semibold text-ink-900">
|
||||
{QR_TEXT[qrStatus] ?? "等扫码结果…"}
|
||||
</h2>
|
||||
<p className="mt-1 text-xs leading-5 text-ink-500">
|
||||
二维码 5 分钟内有效。扫完手机上会问「是否允许登录」,点确认就配好了。
|
||||
</p>
|
||||
{qrStatus === "expired" || qrStatus === "error" ? (
|
||||
<Button className="mt-3" variant="subtle" onClick={() => void startQr()}>
|
||||
重新取二维码
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => void toggle(channel)} disabled={busy === channel.id}>
|
||||
{channel.status === "active" ? "停用" : "启用"}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => void rotate(channel)} disabled={busy === channel.id}>
|
||||
重新生成令牌
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => void remove(channel)} disabled={busy === channel.id}>
|
||||
<Trash size={13} />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{channel ? (
|
||||
<section className="card p-5" data-testid="wechat-channel">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-primary-50 text-primary-700">
|
||||
<Chat size={15} />
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-ink-900">{channel.name || "微信"}</span>
|
||||
<ChannelStatus channel={channel} />
|
||||
{channel.polling ? <span className="text-xs text-ink-400">正在接收</span> : null}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-ink-500">
|
||||
{channel.status === "active"
|
||||
? ago(channel.last_seen_at)
|
||||
: "还没连上微信,扫码之后才收得到消息"}
|
||||
{channel.ilink_user_id ? ` · 微信用户 ${channel.ilink_user_id}` : ""}
|
||||
</p>
|
||||
{channel.last_error ? (
|
||||
<p className="mt-2 text-xs text-clay-600">上次出错:{channel.last_error}</p>
|
||||
<p className="mt-1 text-xs text-rust-500">{channel.last_error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="subtle" size="sm" onClick={() => void showMessages(channel.id)}>
|
||||
{messages[channel.id] ? "收起消息" : "最近消息"}
|
||||
</Button>
|
||||
{canManage ? (
|
||||
<>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={() => void startQr()}
|
||||
disabled={busy === "qr"}
|
||||
>
|
||||
重新扫码
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void disconnect(channel.id)}
|
||||
disabled={busy === channel.id}
|
||||
>
|
||||
断开
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{messages[channel.id] ? (
|
||||
messages[channel.id].length === 0 ? (
|
||||
<p className="mt-3 text-xs text-ink-500">还没有收到过消息。</p>
|
||||
<div className="mt-3 divide-y divide-line-100 border-t border-line-100">
|
||||
{messages[channel.id].length === 0 ? (
|
||||
<p className="py-3 text-xs text-ink-500">还没收到过消息。</p>
|
||||
) : (
|
||||
<ul className="mt-3 space-y-1.5 border-t border-line-100 pt-2.5">
|
||||
{messages[channel.id].map((m) => (
|
||||
<li key={m.id} className="flex gap-2 text-xs text-ink-600">
|
||||
<span className="shrink-0 rounded bg-paper-100 px-1.5 py-px text-2xs text-ink-500">
|
||||
messages[channel.id].map((m) => (
|
||||
<div key={m.id} className="flex items-start justify-between gap-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs text-ink-700">{m.text || "(没有文字)"}</p>
|
||||
<p className="mt-0.5 text-[11px] text-ink-400">{m.sender}</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-[11px] text-ink-500">
|
||||
{ACTION_TEXT[m.action] ?? m.action}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{m.text || "(空)"}</span>
|
||||
<span className="shrink-0 text-2xs text-ink-400">
|
||||
{new Date(m.created_at).toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : (
|
||||
<div className="card">
|
||||
<EmptyState
|
||||
icon={<Chat size={18} />}
|
||||
title="还没连微信"
|
||||
hint={
|
||||
canManage
|
||||
? "点右上「扫码连接微信」,用手机扫一下就行。"
|
||||
: "只有工作区管理员能连微信,让他扫一下。"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!canManage && user ? (
|
||||
<p className="mt-4 text-xs text-ink-500">接入通道只有所有者和管理员能改。</p>
|
||||
{!canManage ? (
|
||||
<p className="mt-3 text-xs text-ink-500">
|
||||
你不是这个工作区的管理员,能看到接入状态,但不能扫码或断开。
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user