From 8436c0edbfc8f989606d3fb15a008ba83f5e22ec Mon Sep 17 00:00:00 2001 From: Steven Date: Thu, 5 Nov 2026 23:03:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(integrations):=20=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E8=A6=81=E5=85=88=E6=89=AB=E7=A0=81=E9=85=8D=E5=AF=B9=EF=BC=8C?= =?UTF-8?q?=E6=94=B9=E6=88=90=20iLink=20=E6=89=AB=E7=A0=81=E7=99=BB?= =?UTF-8?q?=E5=BD=95=20+=20=E9=95=BF=E8=BD=AE=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一版按"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(拿的是微信真二维码) 教训:接入第三方协议先去已有实现里找协议,别从直觉倒推。 --- 2.Design/API.md | 87 ++- 2.Design/ROADMAP-M0-M1.md | 99 +-- 2.Design/screens/web-integrations.png | Bin 77980 -> 65650 bytes .../alembic/versions/0013_wechat_bot_login.py | 63 ++ .../0014_bot_channel_worker_policy.py | 46 ++ backend/app/api/integrations.py | 636 +++++------------- backend/app/config.py | 3 + backend/app/main.py | 5 +- backend/app/models_integrations.py | 31 +- backend/app/services/wechat_bot.py | 282 ++++++++ backend/app/services/wechat_ilink.py | 314 +++++++++ backend/app/services/wechat_intake.py | 225 +++++++ backend/requirements.txt | 3 + backend/tests/test_clawbot.py | 207 ------ backend/tests/test_wechat_bot.py | 326 +++++++++ web/e2e/integrations.mjs | 90 +-- web/src/lib/types.ts | 16 +- web/src/pages/Integrations.tsx | 463 +++++++------ 18 files changed, 1836 insertions(+), 1060 deletions(-) create mode 100644 backend/alembic/versions/0013_wechat_bot_login.py create mode 100644 backend/alembic/versions/0014_bot_channel_worker_policy.py create mode 100644 backend/app/services/wechat_bot.py create mode 100644 backend/app/services/wechat_ilink.py create mode 100644 backend/app/services/wechat_intake.py delete mode 100644 backend/tests/test_clawbot.py create mode 100644 backend/tests/test_wechat_bot.py diff --git a/2.Design/API.md b/2.Design/API.md index c3a07db..7d4f5d6 100644 --- a/2.Design/API.md +++ b/2.Design/API.md @@ -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` 是可以直接塞进 `` 的 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` diff --git a/2.Design/ROADMAP-M0-M1.md b/2.Design/ROADMAP-M0-M1.md index 3a2e7f6..2f28c0a 100644 --- a/2.Design/ROADMAP-M0-M1.md +++ b/2.Design/ROADMAP-M0-M1.md @@ -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=` → `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 ` + `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 能扫码与断开,其他成员只读。 ![接入页](screens/web-integrations.png) -### 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/` -→ `200 code=0 action=task`,任务列表里立刻出现一条。 - -![生产接入页](screens/prod-integrations.png) +- 只做了"进"(收消息建事项),没做"出"(任务有进展回一条微信通知)。 +- 消息不认人:按通道创建者记账;要认人得先做微信身份绑定。 +- 语音只在服务端给了转写时使用,图片/文件/视频只留痕不搬。 diff --git a/2.Design/screens/web-integrations.png b/2.Design/screens/web-integrations.png index 4915267d60f983d0316c65e3d5f1d4a6d84e0119..79eafa696f9df5423c9e93848fe006185d3029b6 100644 GIT binary patch literal 65650 zcmd42WmjBH6DUeT5-dP)w-DUjJ$P{U;O=e%L4v#MpuycexVyW%yEA7J^1N%^d;Y-b zPd$6juCA`GsxA$Zl@^7E#fF7|fPfbl`zj9s@je3r0`fQXTkw-_JK`V+h))pWUj-Ch zQjeBl_!YNb_+A3KwZuD7QEg1eV~a~OkmGHxDdXfD_>Hu#UP=|~4G*s4H>^CRhL`$6 zZoQ&3jMn5c(cNg6NfgWdZAws~P&Mg0@zh>M?Q1y}hf;qC7_U7)Jk%sHji!QsUte+n zcU`#$2L@mu`ckPHCI*Tq-heRykJ)wU*9ioKCN47WzxmolA;JCnn$2d5)%K#;O-^?9bZ3$ zHKNJ7>h9t^m*+V_*5%3Pg< z1XT?4!}WiIJQ+g`$yeqLOQv;r`*@hM4~2gCea+D(?=$p@PR|@9NIuU!t0r___kH)@ z>7{9?sc{sPPViA~zBrxF&~QEE%uVG-X>@q0FXBfK^Ko3A8jSEoj9^`E!?euCDWSRR zToTl&<&7V}5pehOZJ9HeoDg5K@abVvvLEEwlc=b>vXHz2U*j}WY_Blyh3_?DgPv9K zFP(8}3TH@a(tBZC12NLX8Wm&7Cemm0dRA7jZk1`1-`QDVP?=+_4uYb7-ppOgr1=-t zeK$e;w_UDM5twiSJF6)5@AfqyccDJUW>P0PhG)0v7tf733N@;>daYpUo*@6x#7UHw z(IH;x&;@Q<2PvW=EUe4iT+&}G)|F^ZNp^h)<%It>FN5hIRp0UGqQnC>f{(LL0goF1OanLc^BA7r$TCm(4DK&dB7JBYQ^bZ9a zj420nG|7ePYx;|TsYFw$Dan+Ej$p(WYbgQR6s>N-@J^TaP;Dc+;ybVi2r4rb&I3Y6 z=N0oZFPB~$aGlZ0h?~gdWol{D@BaZb3nH_v#?Y}k-=L!T$f{j&b-~63KLlqEv�B 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¿M)H8Ej4@{=Y;g_6CdTF|G=@ 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 P{H~^&&i#UWFaG`y*Y|SK literal 77980 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%0AGPIhjL>irv;vg}LpCV4$KyIz{6SRCJaHzbf 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 z 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 None: + for statement in STATEMENTS: + op.execute(statement) + + +def downgrade() -> None: + for statement in DOWN_STATEMENTS: + op.execute(statement) diff --git a/backend/alembic/versions/0014_bot_channel_worker_policy.py b/backend/alembic/versions/0014_bot_channel_worker_policy.py new file mode 100644 index 0000000..56a8dda --- /dev/null +++ b/backend/alembic/versions/0014_bot_channel_worker_policy.py @@ -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") diff --git a/backend/app/api/integrations.py b/backend/app/api/integrations.py index fdea5eb..728ab6d 100644 --- a/backend/app/api/integrations.py +++ b/backend/app/api/integrations.py @@ -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( - select(BotChannel) - .where(BotChannel.workspace_id == ws_id) - .order_by(BotChannel.created_at.desc()) - ) + rows = await session.execute( + select(BotChannel) + .where(BotChannel.workspace_id == ws_id) + .order_by(BotChannel.created_at.desc()) + ) + return ok({"items": [_channel_out(c) for c in rows.scalars().all()]}) + + +@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="微信", + status="pending", + created_by=ctx.user.id, ) - .scalars() - .all() - ) - return ok({"items": [_channel_out(row) for row in rows]}) - - -@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() - channel = BotChannel( - workspace_id=ws_id, - kind="wechat_clawbot", - name=name, - token_hash=hash_token(token), - status="active", - created_by=ctx.user.id, - ) - session.add(channel) + 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( - select(BotMessage) - .where(BotMessage.workspace_id == ws_id, BotMessage.channel_id == channel_id) - .order_by(BotMessage.created_at.desc()) - .limit(limit) - ) - ) - .scalars() - .all() + rows = await session.execute( + select(BotMessage) + .where(BotMessage.channel_id == channel_id, BotMessage.workspace_id == ws_id) + .order_by(BotMessage.created_at.desc()) + .limit(limit) ) 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"] diff --git a/backend/app/config.py b/backend/app/config.py index 0d67ddb..128f9c0 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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。 diff --git a/backend/app/main.py b/backend/app/main.py index 9614c9b..7a0264b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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="存活探针") diff --git a/backend/app/models_integrations.py b/backend/app/models_integrations.py index 760e9a3..2d7096b 100644 --- a/backend/app/models_integrations.py +++ b/backend/app/models_integrations.py @@ -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") diff --git a/backend/app/services/wechat_bot.py b/backend/app/services/wechat_bot.py new file mode 100644 index 0000000..cfa222a --- /dev/null +++ b/backend/app/services/wechat_bot.py @@ -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() diff --git a/backend/app/services/wechat_ilink.py b/backend/app/services/wechat_ilink.py new file mode 100644 index 0000000..a88ce25 --- /dev/null +++ b/backend/app/services/wechat_ilink.py @@ -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=` 每 2 秒问一次,`confirmed` 时给 `bot_token`; +3. 带 `Authorization: Bearer ` 长轮询 `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 diff --git a/backend/app/services/wechat_intake.py b/backend/app/services/wechat_intake.py new file mode 100644 index 0000000..89ef510 --- /dev/null +++ b/backend/app/services/wechat_intake.py @@ -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)), + } diff --git a/backend/requirements.txt b/backend/requirements.txt index ed18c70..919730e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/backend/tests/test_clawbot.py b/backend/tests/test_clawbot.py deleted file mode 100644 index ad0c94a..0000000 --- a/backend/tests/test_clawbot.py +++ /dev/null @@ -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']}") diff --git a/backend/tests/test_wechat_bot.py b/backend/tests/test_wechat_bot.py new file mode 100644 index 0000000..b2cb93a --- /dev/null +++ b/backend/tests/test_wechat_bot.py @@ -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"] diff --git a/web/e2e/integrations.mjs b/web/e2e/integrations.mjs index 5a66f7a..347d432 100644 --- a/web/e2e/integrations.mjs +++ b/web/e2e/integrations.mjs @@ -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); } diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 50a17f7..bf0b4d3 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -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,前端直接塞进 ``。 */ +export interface WechatQr { + channel_id: string; + qrcode_id: string; + payload: string; + image: string; + expires_in: number; +} + /** 收到的消息留痕:`action` 说明它变成了什么。 */ export interface BotMessage { id: string; diff --git a/web/src/pages/Integrations.tsx b/web/src/pages/Integrations.tsx index d745b32..3aff36b 100644 --- a/web/src/pages/Integrations.tsx +++ b/web/src/pages/Integrations.tsx @@ -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 = { error: "出错", }; +/** 扫码的几种回话,说给用户听的话。 */ +const QR_TEXT: Record = { + waiting: "用微信扫这张码,扫完在手机上点确认。", + scaned: "已经扫到了,在手机上点一下「确认登录」。", + confirmed: "配好了,现在可以直接在微信里发消息。", + expired: "二维码过期了,重新拿一张。", + error: "问登录结果时出错了,稍等再试。", +}; + +function ChannelStatus({ channel }: { channel: BotChannel }) { + if (channel.status === "active") { + return ( + + + 接收中 + + ); + } + if (channel.status === "expired") { + return 登录已失效; + } + if (channel.status === "disabled") { + return 已停用; + } + return 待扫码; +} + /** * 接入页:把微信(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(null); const [busy, setBusy] = useState(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(null); + const [qr, setQr] = useState(null); + const [qrStatus, setQrStatus] = useState("waiting"); const [messages, setMessages] = useState>({}); + /** 关掉轮询:确认成功、过期、或者用户离开这一屏。 */ + 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<{ channel: BotChannel; token: string }>( - `/workspaces/${wsId}/integrations`, - { name: "微信" }, - ); - setFresh({ - id: data.channel.id, - token: data.token, - url: webhookUrl(window.location.origin, data.token), - }); - await load(); + const data = await api.post(`/workspaces/${wsId}/integrations/wechat/qr`, {}); + setQr(data); + setQrStatus("waiting"); } catch (err) { - setError(err instanceof ApiError ? err.message : "创建通道失败"); + setError(err instanceof ApiError ? err.message : "取二维码失败,稍后再试"); + setQr(null); } finally { setBusy(null); } - } + }, [wsId]); - 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); - } - } + // 每 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]); - 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); - } - } + const disconnect = useCallback( + async (id: string) => { + setBusy(id); + setError(null); + try { + 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], + ); - 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); - } + const showMessages = useCallback( + async (id: string) => { + if (messages[id]) { + setMessages((prev) => { + const next = { ...prev }; + delete next[id]; + return next; + }); + return; + } + 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 ( @@ -172,8 +191,8 @@ export function IntegrationsPage() { desc="微信里发一句话,这里就多一条任务或知识。不用装 App,也不用切浏览器。" actions={ canManage ? ( - ) : null } @@ -184,140 +203,142 @@ export function IntegrationsPage() { {error} ) : null} - -
    -

    怎么用

    -
      -
    1. 在下面建一条通道,把「回调地址」粘进 clawbot 的后台。
    2. -
    3. 在微信里给机器人发消息:直接说就是任务,「知识:」开头记成知识草稿。
    4. -
    5. 首行当标题,其余当正文;标题里带 #标签 会一起加上。
    6. -
    -
    - - {fresh ? ( -
    -

    通道已就绪,这两样只在现在显示一次

    -
    -
    -

    回调地址

    -
    - - {fresh.url} - - -
    -
    -
    -

    令牌

    -
    - - {fresh.token} - - -
    -
    -
    -

    - 令牌等同于钥匙:库里只存它的哈希,页面刷新就再也看不到。丢了就「重新生成」,旧地址立刻失效。 -

    + {notice ? ( +
    + {notice}
    ) : null} +
    +

    怎么用

    +
      +
    1. 点右上「扫码连接微信」,用手机微信扫出现的二维码并在手机上确认。
    2. +
    3. 连上之后直接给这个微信机器人发消息:默认记成任务,「知识:」开头记成知识草稿。
    4. +
    5. 第一行当标题,其余当正文;标题或正文里带 #标签 会一起加上。
    6. +
    +
    + {loading ? ( - - ) : channels.length === 0 ? ( - } - title="还没有接进来" - hint={ - canManage - ? "先建一条通道,再把地址填到 clawbot 里;之后微信里发的话就会出现在任务和知识里。" - : "这个工作区还没有配接入通道。需要所有者或管理员来建。" - } - /> +
    + +
    ) : ( -
      - {channels.map((channel) => ( -
    • -
      - - - + <> + {qr ? ( +
      +
      + 微信扫码登录二维码
      -

      {channel.name}

      -

      {ago(channel.last_seen_at)}

      +

      + {QR_TEXT[qrStatus] ?? "等扫码结果…"} +

      +

      + 二维码 5 分钟内有效。扫完手机上会问「是否允许登录」,点确认就配好了。 +

      + {qrStatus === "expired" || qrStatus === "error" ? ( + + ) : null}
      - - {channel.status === "active" ? "接收中" : "已停用"} - - {canManage ? ( -
      - - - - +
      +
      + ) : null} + + {channel ? ( +
      +
      +
      +
      + + + + {channel.name || "微信"} + + {channel.polling ? 正在接收 : null}
      - ) : null} +

      + {channel.status === "active" + ? ago(channel.last_seen_at) + : "还没连上微信,扫码之后才收得到消息"} + {channel.ilink_user_id ? ` · 微信用户 ${channel.ilink_user_id}` : ""} +

      + {channel.last_error ? ( +

      {channel.last_error}

      + ) : null} +
      +
      + + {canManage ? ( + <> + + + + ) : null} +
      - {channel.last_error ? ( -

      上次出错:{channel.last_error}

      - ) : null} - {messages[channel.id] ? ( - messages[channel.id].length === 0 ? ( -

      还没有收到过消息。

      - ) : ( -
        - {messages[channel.id].map((m) => ( -
      • - +
        + {messages[channel.id].length === 0 ? ( +

        还没收到过消息。

        + ) : ( + messages[channel.id].map((m) => ( +
        +
        +

        {m.text || "(没有文字)"}

        +

        {m.sender}

        +
        + {ACTION_TEXT[m.action] ?? m.action} - {m.text || "(空)"} - - {new Date(m.created_at).toLocaleTimeString("zh-CN", { - hour: "2-digit", - minute: "2-digit", - })} - -
      • - ))} -
      - ) +
      + )) + )} +
    ) : null} - - ))} -
- )} + + ) : ( +
+ } + title="还没连微信" + hint={ + canManage + ? "点右上「扫码连接微信」,用手机扫一下就行。" + : "只有工作区管理员能连微信,让他扫一下。" + } + /> +
+ )} - {!canManage && user ? ( -

接入通道只有所有者和管理员能改。

- ) : null} + {!canManage ? ( +

+ 你不是这个工作区的管理员,能看到接入状态,但不能扫码或断开。 +

+ ) : null} + + )} ); }