feat(integrations): 微信里发一句话,变成一条任务或知识
CI / backend (push) Canceled after 0s

一条通道一张令牌,机器人把消息 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:
Steven
2026-11-05 22:04:34 +08:00
parent 33a3fc03e0
commit 90b99f8253
16 changed files with 1606 additions and 4 deletions
+75
View File
@@ -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 与一份
+57
View File
@@ -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 还没做
- 只做了"进"(收消息建事项),没做"出"(改完任务回一条微信通知)。
- 消息不认人:机器人只知道"这是这个工作区的消息",按通道创建者记账;要认人得先做微信身份绑定。
- 语音、图片、文件只留痕不处理。
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")
+562
View File
@@ -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"]
+6
View File
@@ -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,
}
+12 -1
View File
@@ -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="存活探针")
+89
View File
@@ -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)
+3 -1
View File
@@ -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:
+199
View File
@@ -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)
+134
View File
@@ -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);
});
+3
View File
@@ -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() {
<Route path="/w/:wsId/knowledge/:itemId" element={<KnowledgeDocPage />} />
<Route path="/w/:wsId/notifications" element={<NotificationsPage />} />
<Route path="/w/:wsId/members" element={<WorkspacePage />} />
{/* 接入:微信一句话变成任务/知识,配置在这一页 */}
<Route path="/w/:wsId/integrations" element={<IntegrationsPage />} />
<Route path="/join/:wsId/:code" element={<JoinPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
+4 -1
View File
@@ -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: <Inbox size={16} /> },
@@ -37,6 +39,7 @@ const NAV: { key: Section; label: string; to: (id: string) => string; icon: Reac
icon: <Bell 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) {
+8
View File
@@ -227,6 +227,14 @@ export const Settings = (p: IconProps) => (
</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) => (
<svg {...base(p)}>
<path d="M4 7.5h9M17 7.5h3M4 16.5h3M11 16.5h9" />
+22
View File
@@ -224,3 +224,25 @@ export interface AppNotification {
read_at: string | null;
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;
}
+323
View File
@@ -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>
);
}