diff --git a/2.Design/API.md b/2.Design/API.md index 03f8805..c3a07db 100644 --- a/2.Design/API.md +++ b/2.Design/API.md @@ -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` 安装包不进版本库:`backend/static/apk/` 由 `publish_apk.py` 写入,里面有 APK 与一份 diff --git a/2.Design/ROADMAP-M0-M1.md b/2.Design/ROADMAP-M0-M1.md index 66d5f4d..76a327a 100644 --- a/2.Design/ROADMAP-M0-M1.md +++ b/2.Design/ROADMAP-M0-M1.md @@ -739,3 +739,60 @@ - 新增 `web/e2e/session-refresh.mjs`(6 项):access 换成假串但 refresh 是真的 → 不被踢、首页正常、 拿到新令牌、新令牌能调接口;access 与 refresh 都假 → 才回登录页;控制台零异常。 - 安卓 `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 看得到写操作。 + +![接入页](screens/web-integrations.png) + +### 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 还没做 + +- 只做了"进"(收消息建事项),没做"出"(改完任务回一条微信通知)。 +- 消息不认人:机器人只知道"这是这个工作区的消息",按通道创建者记账;要认人得先做微信身份绑定。 +- 语音、图片、文件只留痕不处理。 diff --git a/2.Design/screens/web-integrations.png b/2.Design/screens/web-integrations.png new file mode 100644 index 0000000..4915267 Binary files /dev/null and b/2.Design/screens/web-integrations.png differ diff --git a/backend/alembic/versions/0012_bot_channel.py b/backend/alembic/versions/0012_bot_channel.py new file mode 100644 index 0000000..4e755f8 --- /dev/null +++ b/backend/alembic/versions/0012_bot_channel.py @@ -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") diff --git a/backend/app/api/integrations.py b/backend/app/api/integrations.py new file mode 100644 index 0000000..a6d2097 --- /dev/null +++ b/backend/app/api/integrations.py @@ -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"] diff --git a/backend/app/errors.py b/backend/app/errors.py index 7164b2a..10599e2 100644 --- a/backend/app/errors.py +++ b/backend/app/errors.py @@ -79,6 +79,10 @@ class ErrorCode(IntEnum): # 9xxx 客户端发布(OTA) APP_RELEASE_NOT_FOUND = 9001 + # 9xxx 外部接入(微信 clawbot 等) + BOT_TOKEN_INVALID = 9002 + BOT_DISABLED = 9003 + # 默认 HTTP 状态:业务码自己带语义,HTTP 只是传输层 _HTTP_STATUS: dict[ErrorCode, int] = { @@ -135,6 +139,8 @@ _HTTP_STATUS: dict[ErrorCode, int] = { ErrorCode.ATTACHMENT_FORBIDDEN: 403, ErrorCode.CONFIRM_REQUIRED: 409, ErrorCode.APP_RELEASE_NOT_FOUND: 404, + ErrorCode.BOT_TOKEN_INVALID: 404, + ErrorCode.BOT_DISABLED: 403, } diff --git a/backend/app/main.py b/backend/app/main.py index 9c66de7..9614c9b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -16,7 +16,16 @@ from starlette.responses import Response from starlette.staticfiles import StaticFiles 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 .config import get_settings 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(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 new file mode 100644 index 0000000..760e9a3 --- /dev/null +++ b/backend/app/models_integrations.py @@ -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) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index f7fb496..04f7db2 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -39,7 +39,9 @@ from app.stores import reset_store_for_tests settings = get_settings() 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: diff --git a/backend/tests/test_clawbot.py b/backend/tests/test_clawbot.py new file mode 100644 index 0000000..c410369 --- /dev/null +++ b/backend/tests/test_clawbot.py @@ -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) diff --git a/web/e2e/integrations.mjs b/web/e2e/integrations.mjs new file mode 100644 index 0000000..5a66f7a --- /dev/null +++ b/web/e2e/integrations.mjs @@ -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); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index 7929b43..525cc44 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -16,6 +16,7 @@ import { KnowledgeDocPage } from "./pages/KnowledgeDoc"; import { NotificationsPage } from "./pages/Notifications"; import { JoinPage } from "./pages/Join"; import { AccountPage } from "./pages/Account"; +import { IntegrationsPage } from "./pages/Integrations"; import { SettingsPage } from "./pages/Settings"; interface AuthState { @@ -103,6 +104,8 @@ export function App() { } /> } /> } /> + {/* 接入:微信一句话变成任务/知识,配置在这一页 */} + } /> } /> } /> diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx index e5a6d1a..44bf879 100644 --- a/web/src/components/AppShell.tsx +++ b/web/src/components/AppShell.tsx @@ -5,6 +5,7 @@ import { api } from "../lib/client"; import type { Workspace } from "../lib/types"; import { Book, + Chat, ChevronDown, Bell, Columns, @@ -23,7 +24,8 @@ export type Section = | "tickets" | "knowledge" | "notifications" - | "members"; + | "members" + | "integrations"; const NAV: { key: Section; label: string; to: (id: string) => string; icon: ReactNode }[] = [ { key: "tasks", label: "任务", to: (id) => `/w/${id}`, icon: }, @@ -37,6 +39,7 @@ const NAV: { key: Section; label: string; to: (id: string) => string; icon: Reac icon: , }, { key: "members", label: "成员", to: (id) => `/w/${id}/members`, icon: }, + { key: "integrations", label: "接入", to: (id) => `/w/${id}/integrations`, icon: }, ]; function initial(name: string) { diff --git a/web/src/components/icons.tsx b/web/src/components/icons.tsx index 2173ff4..c233b6c 100644 --- a/web/src/components/icons.tsx +++ b/web/src/components/icons.tsx @@ -227,6 +227,14 @@ export const Settings = (p: IconProps) => ( ); +/** 对话气泡:外部接入(微信等)用这个图标。 */ +export const Chat = (p: IconProps) => ( + + + + +); + export const Sliders = (p: IconProps) => ( diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index bc056ea..50a17f7 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -223,4 +223,26 @@ export interface AppNotification { body: string; read_at: string | null; created_at: string; -} \ No newline at end of file +} +/** 外部接入通道:微信机器人靠这条令牌往工作区里发消息。 */ +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; +} diff --git a/web/src/pages/Integrations.tsx b/web/src/pages/Integrations.tsx new file mode 100644 index 0000000..d745b32 --- /dev/null +++ b/web/src/pages/Integrations.tsx @@ -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 = { + 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([]); + 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 [messages, setMessages] = useState>({}); + + 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 ( + + void create()} disabled={busy === "create"}> + 新建通道 + + ) : null + } + /> + + {error ? ( +
+ {error} +
+ ) : null} + +
+

怎么用

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

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

+
+
+

回调地址

+
+ + {fresh.url} + + +
+
+
+

令牌

+
+ + {fresh.token} + + +
+
+
+

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

+
+ ) : null} + + {loading ? ( + + ) : channels.length === 0 ? ( + } + title="还没有接进来" + hint={ + canManage + ? "先建一条通道,再把地址填到 clawbot 里;之后微信里发的话就会出现在任务和知识里。" + : "这个工作区还没有配接入通道。需要所有者或管理员来建。" + } + /> + ) : ( +
    + {channels.map((channel) => ( +
  • +
    + + + +
    +

    {channel.name}

    +

    {ago(channel.last_seen_at)}

    +
    + + {channel.status === "active" ? "接收中" : "已停用"} + + {canManage ? ( +
    + + + + +
    + ) : null} +
    + + {channel.last_error ? ( +

    上次出错:{channel.last_error}

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

    还没有收到过消息。

    + ) : ( +
      + {messages[channel.id].map((m) => ( +
    • + + {ACTION_TEXT[m.action] ?? m.action} + + {m.text || "(空)"} + + {new Date(m.created_at).toLocaleTimeString("zh-CN", { + hour: "2-digit", + minute: "2-digit", + })} + +
    • + ))} +
    + ) + ) : null} +
  • + ))} +
+ )} + + {!canManage && user ? ( +

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

+ ) : null} + + ); +}