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
+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)