一条通道一张令牌,机器人把消息 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:
@@ -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)
|
||||
Reference in New Issue
Block a user