一条通道一张令牌,机器人把消息 POST 到 /integrations/clawbot/{token},
正文按前缀落到任务或知识草稿:没有前缀就是任务,「知识:」「笔记:」记成知识,
「帮助」只回用法。首行当标题、其余当正文,`#标签` 摘出来挂到事项上。
同一个 msg_id 只处理一次——微信会重推,重推不能变成两条任务。
为什么不塞进 system_setting 的 JSON:一个工作区可能挂多个通道,还得能单独停用。
令牌库里只存 sha256,只在创建/重新生成时显示一次;收消息时还不知道是哪个工作区,
所以 bot_channel 的 RLS 额外放行 token_hash = current_setting('app.bot_token_hash'),
读到通道再 bind_tenant 进上下文,按通道创建者记账。
- 后端:models_integrations.py(bot_channel / bot_message)、迁移 0012(RLS)、
api/integrations.py(收消息 + 管理接口)、错误码 9002/9003
- Web:/w/:wsId/integrations「接入」页——建通道、复制回调地址与令牌、最近消息、
停用/换令牌/删除,只有 owner/admin 能改
- 测试:test_clawbot.py 10 例;web/e2e/integrations.mjs 7 项(冒充 clawbot 真推一条)
验证:pytest 150 passed,ruff check/format 全绿,tsc 0,vite build 通过,e2e 7 项全绿。
This commit is contained in:
@@ -697,6 +697,81 @@ Query:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 6.5 外部接入(微信 clawbot)
|
||||||
|
|
||||||
|
微信里那个机器人把消息 POST 到本服务,消息就变成任务或知识草稿。
|
||||||
|
**这一组接口不带登录态**:机器人不会登我们的账号,它只照着配置好的地址发。
|
||||||
|
|
||||||
|
### 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` | 最近的收信记录 |
|
||||||
|
|
||||||
|
创建响应:
|
||||||
|
|
||||||
|
```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…"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 令牌只在这里出现一次。库里存的是 `sha256(令牌)`,页面刷新之后连管理员也看不到;
|
||||||
|
> 丢了就 `rotate` 重新生成。
|
||||||
|
|
||||||
|
### 6.5.2 收消息(clawbot → 本服务)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/integrations/clawbot/{token}?echostr=xxx` | 地址校验:有 `echostr` 就原样回纯文本 |
|
||||||
|
| POST | `/integrations/clawbot/{token}` | 收一条消息,回 `{"reply": "...", "action": "...", "duplicate": false}` |
|
||||||
|
|
||||||
|
请求体**宽松解析**(不同机器人的壳子不一样),按顺序找第一个非空字符串:
|
||||||
|
|
||||||
|
- 正文:`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` / `用法` | 只回一段用法说明,不建事项 |
|
||||||
|
|
||||||
|
首行是标题(截到 180 字),其余行是正文;标题与正文里的 `#标签` 会被摘出来挂到事项上
|
||||||
|
(纯数字的 `#123` 不算标签,避免把编号当成标签)。
|
||||||
|
|
||||||
|
**幂等**:带 `msg_id` 的请求重复到达只处理一次,第二次直接回上次的 `reply` 且 `duplicate: true`
|
||||||
|
——微信会重推,重推不能变成两条任务。
|
||||||
|
|
||||||
|
### 6.5.3 错误码
|
||||||
|
|
||||||
|
| code | HTTP | 场景 |
|
||||||
|
|---|---|---|
|
||||||
|
| 9002 | 404 | 令牌不存在或已被换掉;对外一律"通道不存在",不区分工作区 |
|
||||||
|
| 9003 | 403 | 通道被停用(`status=disabled`) |
|
||||||
|
|
||||||
|
### 6.5.4 租户隔离
|
||||||
|
|
||||||
|
`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`
|
## 7. 安卓发布与升级 `/app`
|
||||||
|
|
||||||
安装包不进版本库:`backend/static/apk/` 由 `publish_apk.py` 写入,里面有 APK 与一份
|
安装包不进版本库:`backend/static/apk/` 由 `publish_apk.py` 写入,里面有 APK 与一份
|
||||||
|
|||||||
@@ -739,3 +739,60 @@
|
|||||||
- 新增 `web/e2e/session-refresh.mjs`(6 项):access 换成假串但 refresh 是真的 → 不被踢、首页正常、
|
- 新增 `web/e2e/session-refresh.mjs`(6 项):access 换成假串但 refresh 是真的 → 不被踢、首页正常、
|
||||||
拿到新令牌、新令牌能调接口;access 与 refresh 都假 → 才回登录页;控制台零异常。
|
拿到新令牌、新令牌能调接口;access 与 refresh 都假 → 才回登录页;控制台零异常。
|
||||||
- 安卓 `assembleDebug` 通过(JDK 17 + Gradle 8.9)。
|
- 安卓 `assembleDebug` 通过(JDK 17 + Gradle 8.9)。
|
||||||
|
|
||||||
|
## 13. 微信接入(clawbot,2026-11-05)
|
||||||
|
|
||||||
|
### 13.1 要解决的问题
|
||||||
|
|
||||||
|
记事的动作发生在手机上,而任务和知识都在浏览器里。人正在外面、手上只有微信的时候,
|
||||||
|
"回去再记"通常等于"再也想不起来"。所以要有一条从微信直接落到工作区里的路。
|
||||||
|
|
||||||
|
### 13.2 决定
|
||||||
|
|
||||||
|
1. **一条通道一张令牌,令牌在 URL 里**。机器人不会登我们的账号;它只会照着配置好的地址
|
||||||
|
POST。令牌是 32 字节随机串,库里只存 `sha256`,页面上"创建/重新生成"时显示一次。
|
||||||
|
2. **一个工作区可以有多个通道**,各自能单独停用。塞进 `system_setting` 的 JSON 里就做不到
|
||||||
|
"停这个、留那个"。
|
||||||
|
3. **收消息用宽松解析**。clawbot 的消息壳子没有公开的固定 schema,与其猜错就 422,
|
||||||
|
不如按常见字段名依次摸一遍,摸不到正文就记一条 `ignored` 留痕。
|
||||||
|
4. **同一 `msg_id` 只处理一次**。微信会重推,重推变成第二条任务是最难解释的脏数据。
|
||||||
|
5. **每条消息都留痕**(`bot_message`,含原始 JSON、处理结果、 reply)。
|
||||||
|
出问题时能直接回答"那条任务到底进没进"。
|
||||||
|
|
||||||
|
### 13.3 后端
|
||||||
|
|
||||||
|
| 位置 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| `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` 通道停用 |
|
||||||
|
|
||||||
|
正文解析:首行是标题(180 字封顶),其余是正文;`#标签` 摘出来挂事项上(纯数字不算);
|
||||||
|
前缀 `知识:`/`笔记:` → 知识草稿,`任务:`/`todo:` → 任务,没有前缀就是任务。
|
||||||
|
|
||||||
|
### 13.4 Web
|
||||||
|
|
||||||
|
新增 `/w/:wsId/integrations`「接入」页(左侧栏最后一项):
|
||||||
|
|
||||||
|
- 「怎么用」三步说明;
|
||||||
|
- 「新建通道」→ 一次性展示**回调地址 + 令牌**,各带复制按钮(clawbot 后台要粘的就是这里);
|
||||||
|
- 每条通道:状态、最后收信时间、`最近消息`(最近 5 条,带"建了任务/建了知识"标签)、
|
||||||
|
停用/启用、重新生成令牌、删除;
|
||||||
|
- 只有 owner/admin 看得到写操作。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 13.5 验证
|
||||||
|
|
||||||
|
- `backend/tests/test_clawbot.py` 10 例:纯文本成任务、`知识:`前缀成草稿并带标签、
|
||||||
|
`帮助`只回复不建事项、错令牌 404/9002、停用通道 403/9003、同 `msg_id` 幂等、
|
||||||
|
GET `echostr` 原样回、令牌只属于一个工作区、换令牌后旧地址失效、普通成员不能建通道。
|
||||||
|
- `web/e2e/integrations.mjs` 7 项(真跑):页面建通道 → 取回调地址 → 冒充 clawbot POST →
|
||||||
|
任务出现在任务列表 → 重复推送不建第二条 → 「最近消息」看得到 → 控制台零报错。
|
||||||
|
|
||||||
|
### 13.6 还没做
|
||||||
|
|
||||||
|
- 只做了"进"(收消息建事项),没做"出"(改完任务回一条微信通知)。
|
||||||
|
- 消息不认人:机器人只知道"这是这个工作区的消息",按通道创建者记账;要认人得先做微信身份绑定。
|
||||||
|
- 语音、图片、文件只留痕不处理。
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
@@ -0,0 +1,108 @@
|
|||||||
|
"""微信(clawbot)接入通道
|
||||||
|
|
||||||
|
Revision ID: 0012
|
||||||
|
Revises: 0011
|
||||||
|
Create Date: 2026-11-05
|
||||||
|
|
||||||
|
两张表:
|
||||||
|
|
||||||
|
- `bot_channel`:一个工作区可以挂多个机器人通道,令牌只存 sha256。
|
||||||
|
- `bot_message`:进来的每条消息留痕,附带它变成了哪条任务/知识。
|
||||||
|
|
||||||
|
`bot_channel` 的 RLS 多放了一条口子:`token_hash = app_bot_token_hash()`。
|
||||||
|
原因很实在——**收到 webhook 时我们还不知道它是哪个工作区的**,而候选令牌
|
||||||
|
必须读过表才知道。没有这条口子,就只能要么关掉 RLS(不行),要么把令牌
|
||||||
|
放一张不挂租户的表里(等于把钥匙串放在门外)。现在的做法是:
|
||||||
|
请求先把 `app.bot_token_hash` 设成本次令牌的哈希,策略只允许命中那一行,
|
||||||
|
其余行照旧看不见。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
from app.roles import APP_ROLE
|
||||||
|
|
||||||
|
revision: str = "0012"
|
||||||
|
down_revision: str = "0011"
|
||||||
|
branch_labels: Sequence[str] | None = None
|
||||||
|
depends_on: Sequence[str] | None = None
|
||||||
|
|
||||||
|
CREATE_STATEMENTS = (
|
||||||
|
"""
|
||||||
|
CREATE TABLE bot_channel (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workspace_id uuid NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||||
|
kind varchar(32) NOT NULL DEFAULT 'wechat_clawbot',
|
||||||
|
name varchar(64) NOT NULL DEFAULT '',
|
||||||
|
token_hash varchar(64) NOT NULL,
|
||||||
|
status varchar(16) NOT NULL DEFAULT 'active',
|
||||||
|
created_by uuid REFERENCES app_user(id) ON DELETE SET NULL,
|
||||||
|
last_seen_at timestamptz,
|
||||||
|
last_error text,
|
||||||
|
version integer NOT NULL DEFAULT 1,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE bot_message (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workspace_id uuid NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||||
|
channel_id uuid NOT NULL REFERENCES bot_channel(id) ON DELETE CASCADE,
|
||||||
|
external_id varchar(128),
|
||||||
|
sender varchar(128),
|
||||||
|
text text NOT NULL DEFAULT '',
|
||||||
|
action varchar(24) NOT NULL DEFAULT 'ignored',
|
||||||
|
item_id uuid REFERENCES item(id) ON DELETE SET NULL,
|
||||||
|
reply text NOT NULL DEFAULT '',
|
||||||
|
raw jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
version integer NOT NULL DEFAULT 1,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
INDEX_STATEMENTS = (
|
||||||
|
"CREATE INDEX ix_bot_channel_ws ON bot_channel (workspace_id)",
|
||||||
|
"CREATE UNIQUE INDEX uq_bot_channel_token ON bot_channel (token_hash)",
|
||||||
|
"CREATE INDEX ix_bot_message_channel ON bot_message (channel_id, created_at)",
|
||||||
|
"CREATE UNIQUE INDEX uq_bot_message_external ON bot_message (channel_id, external_id) "
|
||||||
|
"WHERE external_id IS NOT NULL",
|
||||||
|
)
|
||||||
|
|
||||||
|
POLICY_SQL = (
|
||||||
|
"ALTER TABLE bot_channel ENABLE ROW LEVEL SECURITY",
|
||||||
|
"CREATE POLICY bot_channel_isolation ON bot_channel "
|
||||||
|
"USING (workspace_id = app_ws_id() OR token_hash = current_setting('app.bot_token_hash', true)) "
|
||||||
|
"WITH CHECK (workspace_id = app_ws_id())",
|
||||||
|
"ALTER TABLE bot_message ENABLE ROW LEVEL SECURITY",
|
||||||
|
"CREATE POLICY bot_message_isolation ON bot_message "
|
||||||
|
"USING (workspace_id = app_ws_id()) WITH CHECK (workspace_id = app_ws_id())",
|
||||||
|
f"GRANT SELECT, INSERT, UPDATE, DELETE ON bot_channel TO {APP_ROLE}",
|
||||||
|
f"GRANT SELECT, INSERT, UPDATE, DELETE ON bot_message TO {APP_ROLE}",
|
||||||
|
)
|
||||||
|
|
||||||
|
COMMENT_SQL = (
|
||||||
|
"COMMENT ON TABLE bot_channel IS '外部接入通道(微信 clawbot 等),令牌只存 sha256'",
|
||||||
|
"COMMENT ON TABLE bot_message IS '外部接入收到并处理过的消息,用于支线排查与去重'",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
for statement in CREATE_STATEMENTS:
|
||||||
|
op.execute(statement)
|
||||||
|
for statement in INDEX_STATEMENTS:
|
||||||
|
op.execute(statement)
|
||||||
|
for statement in POLICY_SQL:
|
||||||
|
op.execute(statement)
|
||||||
|
for statement in COMMENT_SQL:
|
||||||
|
op.execute(statement)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP TABLE IF EXISTS bot_message")
|
||||||
|
op.execute("DROP TABLE IF EXISTS bot_channel")
|
||||||
@@ -0,0 +1,562 @@
|
|||||||
|
"""外部接入:微信(clawbot)发来的消息变成任务 / 知识。
|
||||||
|
|
||||||
|
## 一条消息是怎么变成事项的
|
||||||
|
|
||||||
|
微信里发一句话给机器人,clawbot 把消息 POST 到
|
||||||
|
|
||||||
|
POST /api/v1/integrations/clawbot/{token}
|
||||||
|
|
||||||
|
令牌在 URL 里,**不带登录态**:机器人不会登我们的账号,它只会照着配置好的
|
||||||
|
地址发。令牌是 32 字节随机串,库里只存 sha256。
|
||||||
|
|
||||||
|
正文按前缀决定去哪个池子:
|
||||||
|
|
||||||
|
知识:xxx / 笔记:xxx / note: xxx → 知识草稿
|
||||||
|
任务:xxx / todo: xxx → 任务(默认也是任务)
|
||||||
|
?、帮助、help → 回一段用法说明
|
||||||
|
|
||||||
|
首行是标题,其余行是正文。首行里的 `#标签` 会被摘出来挂到事项上
|
||||||
|
(标签是知识的主要检索入口,手机上敲#比打开网页加标签省事)。
|
||||||
|
|
||||||
|
## 为什么每条消息都留痕
|
||||||
|
|
||||||
|
微信会重推(网络抖动、机器人重启)。没有留痕就只能靠猜"那条任务到底进没进"。
|
||||||
|
`bot_message.external_id` 上有个部分唯一索引:同一个外部消息 id 第二次到达时
|
||||||
|
直接回上次的答案,不会变成两条任务。
|
||||||
|
|
||||||
|
## 安全边界
|
||||||
|
|
||||||
|
- 令牌错 → 9002(对外一律"通道不存在",不区分"错令牌"和"这个工作区没接")。
|
||||||
|
- 通道被停用 → 9003。
|
||||||
|
- 消息里不认人:机器人只知道"这是这个工作区的一条消息",落库按通道创建者记账,
|
||||||
|
不会因为消息内容去猜是谁发的。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Query, Request
|
||||||
|
from fastapi.responses import PlainTextResponse
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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"
|
||||||
|
"首行当标题,后面几行当正文;标题里带 #标签 会自动加上。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_token(token: str) -> str:
|
||||||
|
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def new_token() -> str:
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
|
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 _channel_out(channel: BotChannel) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": str(channel.id),
|
||||||
|
"kind": channel.kind,
|
||||||
|
"name": channel.name,
|
||||||
|
"status": channel.status,
|
||||||
|
"created_by": str(channel.created_by) if channel.created_by else None,
|
||||||
|
"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:
|
||||||
|
base = str(request.base_url).rstrip("/")
|
||||||
|
return f"{base}/integrations/clawbot/{token}"
|
||||||
|
|
||||||
|
|
||||||
|
@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())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.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)
|
||||||
|
await session.flush()
|
||||||
|
return ok(
|
||||||
|
{
|
||||||
|
"channel": _channel_out(channel),
|
||||||
|
# 令牌只在这里出现一次,之后只能"重新生成"。
|
||||||
|
"token": token,
|
||||||
|
"webhook_url": _webhook_url(request, token),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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,
|
||||||
|
):
|
||||||
|
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"]
|
||||||
|
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(
|
||||||
|
ws_id: uuid.UUID, channel_id: uuid.UUID, ctx: AdminDep, session: SessionDep
|
||||||
|
):
|
||||||
|
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})
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.get("/{ws_id}/integrations/{channel_id}/messages")
|
||||||
|
async def list_messages(
|
||||||
|
ws_id: uuid.UUID,
|
||||||
|
channel_id: uuid.UUID,
|
||||||
|
ctx: AdminDep,
|
||||||
|
session: SessionDep,
|
||||||
|
limit: int = Query(default=20, 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()
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["webhook_router", "admin_router"]
|
||||||
@@ -79,6 +79,10 @@ class ErrorCode(IntEnum):
|
|||||||
# 9xxx 客户端发布(OTA)
|
# 9xxx 客户端发布(OTA)
|
||||||
APP_RELEASE_NOT_FOUND = 9001
|
APP_RELEASE_NOT_FOUND = 9001
|
||||||
|
|
||||||
|
# 9xxx 外部接入(微信 clawbot 等)
|
||||||
|
BOT_TOKEN_INVALID = 9002
|
||||||
|
BOT_DISABLED = 9003
|
||||||
|
|
||||||
|
|
||||||
# 默认 HTTP 状态:业务码自己带语义,HTTP 只是传输层
|
# 默认 HTTP 状态:业务码自己带语义,HTTP 只是传输层
|
||||||
_HTTP_STATUS: dict[ErrorCode, int] = {
|
_HTTP_STATUS: dict[ErrorCode, int] = {
|
||||||
@@ -135,6 +139,8 @@ _HTTP_STATUS: dict[ErrorCode, int] = {
|
|||||||
ErrorCode.ATTACHMENT_FORBIDDEN: 403,
|
ErrorCode.ATTACHMENT_FORBIDDEN: 403,
|
||||||
ErrorCode.CONFIRM_REQUIRED: 409,
|
ErrorCode.CONFIRM_REQUIRED: 409,
|
||||||
ErrorCode.APP_RELEASE_NOT_FOUND: 404,
|
ErrorCode.APP_RELEASE_NOT_FOUND: 404,
|
||||||
|
ErrorCode.BOT_TOKEN_INVALID: 404,
|
||||||
|
ErrorCode.BOT_DISABLED: 403,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+12
-1
@@ -16,7 +16,16 @@ from starlette.responses import Response
|
|||||||
from starlette.staticfiles import StaticFiles
|
from starlette.staticfiles import StaticFiles
|
||||||
from starlette.types import Scope
|
from starlette.types import Scope
|
||||||
|
|
||||||
from .api import app_release, attachments, auth, items, knowledge, tickets, workspaces
|
from .api import (
|
||||||
|
app_release,
|
||||||
|
attachments,
|
||||||
|
auth,
|
||||||
|
integrations,
|
||||||
|
items,
|
||||||
|
knowledge,
|
||||||
|
tickets,
|
||||||
|
workspaces,
|
||||||
|
)
|
||||||
from .api import settings as settings_api
|
from .api import settings as settings_api
|
||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
from .db import SessionFactory, current_app_role, engine
|
from .db import SessionFactory, current_app_role, engine
|
||||||
@@ -118,6 +127,8 @@ app.include_router(knowledge.router, prefix=settings.api_prefix)
|
|||||||
app.include_router(attachments.router, prefix=settings.api_prefix)
|
app.include_router(attachments.router, prefix=settings.api_prefix)
|
||||||
app.include_router(app_release.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(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="存活探针")
|
@app.get("/health", tags=["运维"], summary="存活探针")
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""外部接入:微信(clawbot)这一类的机器人通道。
|
||||||
|
|
||||||
|
为什么单开一张表而不是塞进 `system_setting`:
|
||||||
|
|
||||||
|
1. 一个工作区可以挂多个通道(自己的号、公司的号),也随时可能换;
|
||||||
|
塞进 setting 的 JSON 里就没法"停用这一个、保留那一个"。
|
||||||
|
2. 通道是**租户数据**:谁能往里发消息,取决于它属于哪个工作区。
|
||||||
|
租户表就得有 `workspace_id NOT NULL` + RLS(AGENTS.md 第 2.5 条)。
|
||||||
|
|
||||||
|
令牌只存 sha256:数据库被看见也拿不到能用的令牌;令牌只在创建时回显一次。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Index, String, Text, text
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PgUUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .models import Base, Timestamps, new_uuid
|
||||||
|
|
||||||
|
BOT_KINDS = ("wechat_clawbot",)
|
||||||
|
BOT_STATUSES = ("active", "disabled")
|
||||||
|
|
||||||
|
INTEGRATION_TENANT_TABLES = ("bot_channel", "bot_message")
|
||||||
|
|
||||||
|
|
||||||
|
class BotChannel(Base, Timestamps):
|
||||||
|
"""一条接入通道:微信里那个机器人 ⇄ 本工作区。"""
|
||||||
|
|
||||||
|
__tablename__ = "bot_channel"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_bot_channel_ws", "workspace_id"),
|
||||||
|
Index("uq_bot_channel_token", "token_hash", unique=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
|
||||||
|
workspace_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
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")
|
||||||
|
# 消息落在谁名下:用创建这条通道的人;人没了就退回工作区 owner。
|
||||||
|
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="SET NULL")
|
||||||
|
)
|
||||||
|
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
last_error: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
||||||
|
|
||||||
|
class BotMessage(Base, Timestamps):
|
||||||
|
"""收到的每条消息都留痕:出问题时能回答"那条任务到底有没有进去"。"""
|
||||||
|
|
||||||
|
__tablename__ = "bot_message"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_bot_message_channel", "channel_id", "created_at"),
|
||||||
|
# 同一个外部消息 id 只处理一次:微信会重推,重推不能变成两条任务。
|
||||||
|
Index(
|
||||||
|
"uq_bot_message_external",
|
||||||
|
"channel_id",
|
||||||
|
"external_id",
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=text("external_id IS NOT NULL"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
|
||||||
|
workspace_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
channel_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PgUUID(as_uuid=True), ForeignKey("bot_channel.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
external_id: Mapped[str | None] = mapped_column(String(128))
|
||||||
|
sender: Mapped[str | None] = mapped_column(String(128))
|
||||||
|
text: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||||
|
# task / knowledge / help / ignored / error
|
||||||
|
action: Mapped[str] = mapped_column(String(24), nullable=False, default="ignored")
|
||||||
|
item_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="SET NULL")
|
||||||
|
)
|
||||||
|
reply: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||||
|
raw: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||||
@@ -39,7 +39,9 @@ from app.stores import reset_store_for_tests
|
|||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
DEV_CODE = settings.sms_dev_code
|
DEV_CODE = settings.sms_dev_code
|
||||||
|
|
||||||
_TABLES = "system_setting, audit_log, invitation, member, workspace, app_user"
|
_TABLES = (
|
||||||
|
"bot_message, bot_channel, system_setting, audit_log, invitation, member, workspace, app_user"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _run_migrations() -> None:
|
def _run_migrations() -> None:
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""微信(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)
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* 微信接入的端到端验证:页面上建通道 → 把"微信"发来的那条 POST 打到回调地址
|
||||||
|
* → 任务真出现在任务列表里。
|
||||||
|
*
|
||||||
|
* 这里不发真正的微信消息(那得有人拿手机发),而是照着 clawbot 的形状
|
||||||
|
* 直接 POST 一次——它就是这个接口唯一的调用方。
|
||||||
|
*/
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { chromium } from "playwright-core";
|
||||||
|
|
||||||
|
// 上一轮 e2e 建的密码就在仓库根的 .local 里(web/e2e 往上两级)
|
||||||
|
function devPassword() {
|
||||||
|
if (process.env.PASSWORD) return process.env.PASSWORD;
|
||||||
|
try {
|
||||||
|
return readFileSync(new URL("../../.local/e2e-account-password.txt", import.meta.url), "utf8").trim();
|
||||||
|
} catch {
|
||||||
|
return "dadao-e2e-2026";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEB = process.env.WEB || "http://localhost:5180";
|
||||||
|
const API = process.env.API || "http://127.0.0.1:8100/api/v1";
|
||||||
|
const PHONE = process.env.PHONE || "13900000001";
|
||||||
|
const PASSWORD = devPassword();
|
||||||
|
const CHROME =
|
||||||
|
process.env.CHROME ||
|
||||||
|
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`;
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
function check(name, ok, detail = "") {
|
||||||
|
results.push({ name, ok, detail });
|
||||||
|
console.log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? ` — ${detail}` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
const resp = await fetch(`${API}/auth/login/password`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ phone: PHONE, password: PASSWORD }),
|
||||||
|
});
|
||||||
|
const body = await resp.json();
|
||||||
|
if (body.code !== 0) throw new Error(`登录失败 ${JSON.stringify(body)}`);
|
||||||
|
return body.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const session = await login();
|
||||||
|
const token = session.tokens.access_token;
|
||||||
|
const headers = { authorization: `Bearer ${token}`, "content-type": "application/json" };
|
||||||
|
|
||||||
|
const wsList = await (await fetch(`${API}/workspaces`, { headers })).json();
|
||||||
|
const ws = wsList.data.items[0];
|
||||||
|
if (!ws) throw new Error("这个账号没有工作区");
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ executablePath: CHROME, headless: true });
|
||||||
|
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||||
|
const page = await context.newPage();
|
||||||
|
const consoleErrors = [];
|
||||||
|
page.on("console", (m) => m.type() === "error" && consoleErrors.push(m.text()));
|
||||||
|
page.on("pageerror", (e) => consoleErrors.push(String(e)));
|
||||||
|
|
||||||
|
await page.addInitScript(
|
||||||
|
([access, refresh]) => {
|
||||||
|
localStorage.setItem("dadao.access", access);
|
||||||
|
localStorage.setItem("dadao.refresh", refresh);
|
||||||
|
},
|
||||||
|
[token, session.tokens.refresh_token],
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto(`${WEB}/w/${ws.id}/integrations`, { waitUntil: "networkidle" });
|
||||||
|
check("接入页能打开", await page.getByRole("heading", { name: "接入" }).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) + "…");
|
||||||
|
|
||||||
|
// 冒充 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 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);
|
||||||
|
|
||||||
|
// 页面上的「最近消息」要能看到刚才那几条
|
||||||
|
await page.getByRole("button", { name: "最近消息" }).first().click();
|
||||||
|
await page.getByText("建了任务").first().waitFor({ state: "visible" });
|
||||||
|
check("页面能看到收到的消息", await page.getByText("建了任务").first().isVisible());
|
||||||
|
|
||||||
|
// 任务真的进了任务列表
|
||||||
|
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}`);
|
||||||
|
if (failed.length > 0) process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("FAIL 脚本异常 —", err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -16,6 +16,7 @@ import { KnowledgeDocPage } from "./pages/KnowledgeDoc";
|
|||||||
import { NotificationsPage } from "./pages/Notifications";
|
import { NotificationsPage } from "./pages/Notifications";
|
||||||
import { JoinPage } from "./pages/Join";
|
import { JoinPage } from "./pages/Join";
|
||||||
import { AccountPage } from "./pages/Account";
|
import { AccountPage } from "./pages/Account";
|
||||||
|
import { IntegrationsPage } from "./pages/Integrations";
|
||||||
import { SettingsPage } from "./pages/Settings";
|
import { SettingsPage } from "./pages/Settings";
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
@@ -103,6 +104,8 @@ export function App() {
|
|||||||
<Route path="/w/:wsId/knowledge/:itemId" element={<KnowledgeDocPage />} />
|
<Route path="/w/:wsId/knowledge/:itemId" element={<KnowledgeDocPage />} />
|
||||||
<Route path="/w/:wsId/notifications" element={<NotificationsPage />} />
|
<Route path="/w/:wsId/notifications" element={<NotificationsPage />} />
|
||||||
<Route path="/w/:wsId/members" element={<WorkspacePage />} />
|
<Route path="/w/:wsId/members" element={<WorkspacePage />} />
|
||||||
|
{/* 接入:微信一句话变成任务/知识,配置在这一页 */}
|
||||||
|
<Route path="/w/:wsId/integrations" element={<IntegrationsPage />} />
|
||||||
<Route path="/join/:wsId/:code" element={<JoinPage />} />
|
<Route path="/join/:wsId/:code" element={<JoinPage />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { api } from "../lib/client";
|
|||||||
import type { Workspace } from "../lib/types";
|
import type { Workspace } from "../lib/types";
|
||||||
import {
|
import {
|
||||||
Book,
|
Book,
|
||||||
|
Chat,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Bell,
|
Bell,
|
||||||
Columns,
|
Columns,
|
||||||
@@ -23,7 +24,8 @@ export type Section =
|
|||||||
| "tickets"
|
| "tickets"
|
||||||
| "knowledge"
|
| "knowledge"
|
||||||
| "notifications"
|
| "notifications"
|
||||||
| "members";
|
| "members"
|
||||||
|
| "integrations";
|
||||||
|
|
||||||
const NAV: { key: Section; label: string; to: (id: string) => string; icon: ReactNode }[] = [
|
const NAV: { key: Section; label: string; to: (id: string) => string; icon: ReactNode }[] = [
|
||||||
{ key: "tasks", label: "任务", to: (id) => `/w/${id}`, icon: <Inbox size={16} /> },
|
{ key: "tasks", label: "任务", to: (id) => `/w/${id}`, icon: <Inbox size={16} /> },
|
||||||
@@ -37,6 +39,7 @@ const NAV: { key: Section; label: string; to: (id: string) => string; icon: Reac
|
|||||||
icon: <Bell size={16} />,
|
icon: <Bell size={16} />,
|
||||||
},
|
},
|
||||||
{ key: "members", label: "成员", to: (id) => `/w/${id}/members`, icon: <Users size={16} /> },
|
{ key: "members", label: "成员", to: (id) => `/w/${id}/members`, icon: <Users size={16} /> },
|
||||||
|
{ key: "integrations", label: "接入", to: (id) => `/w/${id}/integrations`, icon: <Chat size={16} /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
function initial(name: string) {
|
function initial(name: string) {
|
||||||
|
|||||||
@@ -227,6 +227,14 @@ export const Settings = (p: IconProps) => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** 对话气泡:外部接入(微信等)用这个图标。 */
|
||||||
|
export const Chat = (p: IconProps) => (
|
||||||
|
<svg {...base(p)} fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round">
|
||||||
|
<path d="M4 5.5h16v10H9l-4 3v-3H4z" />
|
||||||
|
<path d="M8 9.5h8M8 12.5h5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export const Sliders = (p: IconProps) => (
|
export const Sliders = (p: IconProps) => (
|
||||||
<svg {...base(p)}>
|
<svg {...base(p)}>
|
||||||
<path d="M4 7.5h9M17 7.5h3M4 16.5h3M11 16.5h9" />
|
<path d="M4 7.5h9M17 7.5h3M4 16.5h3M11 16.5h9" />
|
||||||
|
|||||||
@@ -224,3 +224,25 @@ export interface AppNotification {
|
|||||||
read_at: string | null;
|
read_at: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
/** 外部接入通道:微信机器人靠这条令牌往工作区里发消息。 */
|
||||||
|
export interface BotChannel {
|
||||||
|
id: string;
|
||||||
|
kind: string;
|
||||||
|
name: string;
|
||||||
|
status: "active" | "disabled";
|
||||||
|
created_by: string | null;
|
||||||
|
last_seen_at: string | null;
|
||||||
|
last_error: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 收到的消息留痕:`action` 说明它变成了什么。 */
|
||||||
|
export interface BotMessage {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
action: "task" | "knowledge" | "help" | "ignored" | "error" | string;
|
||||||
|
item_id: string | null;
|
||||||
|
reply: string;
|
||||||
|
sender: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import { AppShell } from "../components/AppShell";
|
||||||
|
import { Chat, Link2, Plus, Trash } 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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ago(iso: string | null): string {
|
||||||
|
if (!iso) return "还没收到过消息";
|
||||||
|
const diff = Math.max(0, Date.now() - new Date(iso).getTime());
|
||||||
|
const min = Math.floor(diff / 60_000);
|
||||||
|
if (min < 1) return "刚刚收到消息";
|
||||||
|
if (min < 60) return `${min} 分钟前收到消息`;
|
||||||
|
const hour = Math.floor(min / 60);
|
||||||
|
if (hour < 24) return `${hour} 小时前收到消息`;
|
||||||
|
return `${Math.floor(hour / 24)} 天前收到消息`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_TEXT: Record<string, string> = {
|
||||||
|
task: "建了任务",
|
||||||
|
knowledge: "建了知识",
|
||||||
|
help: "回了用法",
|
||||||
|
ignored: "没处理",
|
||||||
|
error: "出错",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 接入页:把微信(clawbot)接进这个工作区。
|
||||||
|
*
|
||||||
|
* 令牌是通道的钥匙,**只在创建/重生成时出现一次**——旁边配一个复制按钮,
|
||||||
|
* 因为用户要做的就是把它粘进 clawbot 的后台。
|
||||||
|
*/
|
||||||
|
export function IntegrationsPage() {
|
||||||
|
const { wsId = "" } = useParams();
|
||||||
|
const { user, workspaces } = useAuth();
|
||||||
|
const ws = workspaces.find((w) => w.id === wsId);
|
||||||
|
const canManage = ws?.role === "owner" || ws?.role === "admin";
|
||||||
|
|
||||||
|
const [channels, setChannels] = useState<BotChannel[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
/** 刚建好或刚换过的令牌:只活在这一屏里,刷新就没了(库里只有 sha256)。 */
|
||||||
|
const [fresh, setFresh] = useState<{ id: string; token: string; url: string } | null>(null);
|
||||||
|
const [copied, setCopied] = useState<"url" | "token" | null>(null);
|
||||||
|
const [messages, setMessages] = useState<Record<string, BotMessage[]>>({});
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await api.get<{ items: BotChannel[] }>(`/workspaces/${wsId}/integrations`);
|
||||||
|
setChannels(data.items);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "读取接入配置失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [wsId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
setBusy("create");
|
||||||
|
setError(null);
|
||||||
|
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();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "创建通道失败");
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggle(channel: BotChannel) {
|
||||||
|
setBusy(channel.id);
|
||||||
|
try {
|
||||||
|
await api.patch<{ channel: BotChannel }>(
|
||||||
|
`/workspaces/${wsId}/integrations/${channel.id}`,
|
||||||
|
{ status: channel.status === "active" ? "disabled" : "active" },
|
||||||
|
);
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "改状态失败");
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rotate(channel: BotChannel) {
|
||||||
|
setBusy(channel.id);
|
||||||
|
try {
|
||||||
|
const data = await api.post<{ token: string }>(
|
||||||
|
`/workspaces/${wsId}/integrations/${channel.id}/rotate`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
setFresh({
|
||||||
|
id: channel.id,
|
||||||
|
token: data.token,
|
||||||
|
url: webhookUrl(window.location.origin, data.token),
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "换令牌失败");
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(channel: BotChannel) {
|
||||||
|
if (!window.confirm(`删掉「${channel.name}」这条通道?微信那边再发消息就进不来了。`)) return;
|
||||||
|
setBusy(channel.id);
|
||||||
|
try {
|
||||||
|
await api.del(`/workspaces/${wsId}/integrations/${channel.id}`);
|
||||||
|
if (fresh?.id === channel.id) setFresh(null);
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "删除失败");
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function peek(channel: BotChannel) {
|
||||||
|
setBusy(channel.id);
|
||||||
|
try {
|
||||||
|
const data = await api.get<{ items: BotMessage[] }>(
|
||||||
|
`/workspaces/${wsId}/integrations/${channel.id}/messages?limit=5`,
|
||||||
|
);
|
||||||
|
setMessages((prev) => ({ ...prev, [channel.id]: data.items }));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "读取消息失败");
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copy(kind: "url" | "token", text: string) {
|
||||||
|
const ok = await copyText(text);
|
||||||
|
if (!ok) {
|
||||||
|
setError("浏览器不让直接写剪贴板,请手动选中复制");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCopied(kind);
|
||||||
|
window.setTimeout(() => setCopied(null), 1600);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell wsId={wsId} active="integrations">
|
||||||
|
<PageHeader
|
||||||
|
title="接入"
|
||||||
|
desc="微信里发一句话,这里就多一条任务或知识。不用装 App,也不用切浏览器。"
|
||||||
|
actions={
|
||||||
|
canManage ? (
|
||||||
|
<Button variant="primary" onClick={() => void create()} disabled={busy === "create"}>
|
||||||
|
<Plus size={14} /> 新建通道
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="mb-4">
|
||||||
|
<InlineAlert tone="error">{error}</InlineAlert>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mb-5 rounded-xl border border-line-200 bg-paper-50 px-4 py-3 text-sm text-ink-600">
|
||||||
|
<p className="font-medium text-ink-800">怎么用</p>
|
||||||
|
<ol className="mt-1.5 list-decimal space-y-1 pl-5 text-sm leading-6">
|
||||||
|
<li>在下面建一条通道,把「回调地址」粘进 clawbot 的后台。</li>
|
||||||
|
<li>在微信里给机器人发消息:直接说就是任务,「知识:」开头记成知识草稿。</li>
|
||||||
|
<li>首行当标题,其余当正文;标题里带 <code className="text-ink-800">#标签</code> 会一起加上。</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fresh ? (
|
||||||
|
<div className="mb-5 rounded-xl border border-primary-200 bg-primary-50/60 px-4 py-4">
|
||||||
|
<p className="text-sm font-medium text-primary-800">通道已就绪,这两样只在现在显示一次</p>
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-2xs uppercase tracking-wide text-ink-500">回调地址</p>
|
||||||
|
<div className="mt-1 flex items-start gap-2">
|
||||||
|
<code
|
||||||
|
data-testid="webhook-url"
|
||||||
|
className="min-w-0 flex-1 break-all rounded-lg bg-white px-3 py-2 text-xs text-ink-800"
|
||||||
|
>
|
||||||
|
{fresh.url}
|
||||||
|
</code>
|
||||||
|
<Button size="sm" onClick={() => void copy("url", fresh.url)}>
|
||||||
|
<Link2 size={13} /> {copied === "url" ? "已复制" : "复制"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xs uppercase tracking-wide text-ink-500">令牌</p>
|
||||||
|
<div className="mt-1 flex items-start gap-2">
|
||||||
|
<code
|
||||||
|
data-testid="webhook-token"
|
||||||
|
className="min-w-0 flex-1 break-all rounded-lg bg-white px-3 py-2 text-xs text-ink-800"
|
||||||
|
>
|
||||||
|
{fresh.token}
|
||||||
|
</code>
|
||||||
|
<Button size="sm" onClick={() => void copy("token", fresh.token)}>
|
||||||
|
{copied === "token" ? "已复制" : "复制"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-xs text-ink-500">
|
||||||
|
令牌等同于钥匙:库里只存它的哈希,页面刷新就再也看不到。丢了就「重新生成」,旧地址立刻失效。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<SkeletonRows rows={3} />
|
||||||
|
) : channels.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Chat size={18} />}
|
||||||
|
title="还没有接进来"
|
||||||
|
hint={
|
||||||
|
canManage
|
||||||
|
? "先建一条通道,再把地址填到 clawbot 里;之后微信里发的话就会出现在任务和知识里。"
|
||||||
|
: "这个工作区还没有配接入通道。需要所有者或管理员来建。"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{channels.map((channel) => (
|
||||||
|
<li key={channel.id} className="rounded-xl border border-line-200 bg-white px-4 py-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="grid h-8 w-8 place-items-center rounded-lg bg-primary-50 text-primary-700">
|
||||||
|
<Chat size={16} />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-semibold text-ink-900">{channel.name}</p>
|
||||||
|
<p className="text-xs text-ink-500">{ago(channel.last_seen_at)}</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`ml-1 rounded-full px-2 py-px text-2xs ${
|
||||||
|
channel.status === "active"
|
||||||
|
? "bg-primary-50 text-primary-700"
|
||||||
|
: "bg-paper-100 text-ink-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{channel.status === "active" ? "接收中" : "已停用"}
|
||||||
|
</span>
|
||||||
|
{canManage ? (
|
||||||
|
<div className="ml-auto flex flex-wrap items-center gap-1.5">
|
||||||
|
<Button size="sm" onClick={() => void peek(channel)} disabled={busy === channel.id}>
|
||||||
|
最近消息
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => void toggle(channel)} disabled={busy === channel.id}>
|
||||||
|
{channel.status === "active" ? "停用" : "启用"}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => void rotate(channel)} disabled={busy === channel.id}>
|
||||||
|
重新生成令牌
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => void remove(channel)} disabled={busy === channel.id}>
|
||||||
|
<Trash size={13} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{channel.last_error ? (
|
||||||
|
<p className="mt-2 text-xs text-clay-600">上次出错:{channel.last_error}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{messages[channel.id] ? (
|
||||||
|
messages[channel.id].length === 0 ? (
|
||||||
|
<p className="mt-3 text-xs text-ink-500">还没有收到过消息。</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-3 space-y-1.5 border-t border-line-100 pt-2.5">
|
||||||
|
{messages[channel.id].map((m) => (
|
||||||
|
<li key={m.id} className="flex gap-2 text-xs text-ink-600">
|
||||||
|
<span className="shrink-0 rounded bg-paper-100 px-1.5 py-px text-2xs text-ink-500">
|
||||||
|
{ACTION_TEXT[m.action] ?? m.action}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{m.text || "(空)"}</span>
|
||||||
|
<span className="shrink-0 text-2xs text-ink-400">
|
||||||
|
{new Date(m.created_at).toLocaleTimeString("zh-CN", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!canManage && user ? (
|
||||||
|
<p className="mt-4 text-xs text-ink-500">接入通道只有所有者和管理员能改。</p>
|
||||||
|
) : null}
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user