fix(integrations): 扫码确认后不再 500,回话不带链接,栏目改叫「机器人」
CI / backend (push) Canceled after 0s

1. 扫码确认那一刻页面报「服务器内部错误」:`bot_channel` 的轮询口子直接
   `current_setting('app.bot_channel', true)::uuid`,而事务级 set_config 提交后
   会把连接上的变量还原成空串而不是 NULL,空串 cast 就 InvalidTextRepresentation。
   迁移 0015 改成 nullif(...,'')::uuid,与 app_ws_id() 同一写法。
2. 微信回话只回「已保存任务:…」/「已保存知识草稿:…」,不再带站点链接
   (PUBLIC_BASE_URL 是内网地址,手机上打不开)。
3. 栏目名「接入」改名「机器人」;页面标题与提示文案同步。

pytest 151 passed;ruff check/format 干净;tsc 0;vite build 通过;
web/e2e/integrations.mjs 7 项全绿。
This commit is contained in:
Steven
2026-11-05 23:22:49 +08:00
parent 8e3dc65cc6
commit 1e8cd01b3d
9 changed files with 94 additions and 25 deletions
+4 -2
View File
@@ -697,7 +697,7 @@ Query:
---
## 6.5 外部接入(微信 clawbot)
## 6.5 外部接入(微信 clawbot,页面叫「机器人」)
微信里那个机器人连上本服务之后,发一句话就变成任务或知识草稿。
走的是腾讯 iLink Bot 协议:**扫码登录 → 后端长轮询收消息 → 用 context_token 回复**。
@@ -743,7 +743,9 @@ Query:
| `帮助`/`?`/`help` | 只回一段用法,不建任何东西 |
正文第一行当标题(超过 180 字截断),其余当正文;`#标签` 会被摘出来加到事项上(纯数字的不算标签)。
建完之后会用 `context_token` 在微信里回一句,附上链接(`PUBLIC_BASE_URL` 或 CORS 里的第一个来源)。
建完之后会用 `context_token` 在微信里回一句「已保存任务:标题」/「已保存知识草稿:标题」。
**回话里不带链接**:站点地址对手机不一定打得开(内网 IP、端口、还没上 HTTPS 的域名),
发过去只会让人以为崩了。
同一条消息(按 `message_id`)重复推送只会落一次,重推直接跳过。
图片/文件/视频目前只在 `bot_message.raw` 里留痕,正文不搬;语音如果服务端给了转写就用转写文本。
+14 -3
View File
@@ -795,14 +795,14 @@
### 13.5 Web
新增 `/w/:wsId/integrations`「接入」页(左侧栏最后一项):
新增 `/w/:wsId/integrations`「机器人」页(左侧栏最后一项;栏目名从「接入」改过来,因为用户要的是「机器人」这件事,而不是「接入」这个后台动作):
- 「扫码连接微信」→ 页面上直接画出二维码,每 2 秒问一次状态(待扫 → 已扫待确认 → 成功/过期);
- 连上之后显示通道卡片:接收中/登录已失效/已停用、最后收信时间、"微信用户 xxx"、
`最近消息`(最近 5 条,标"建了任务/建了知识")、重新扫码、断开;
- 只有 owner/admin 能扫码与断开,其他成员只读。
![接入页](screens/web-integrations.png)
![机器人页](screens/web-integrations.png)
### 13.6 验证
@@ -812,7 +812,18 @@
登录态失效后通道转 `expired` 且带原因。
- `web/e2e/integrations.mjs` 7 项(真跑,拿的是微信真二维码):接入页打开 → 点扫码 →
二维码是页面里的 SVG → 提示用微信扫 → 后端通道 `pending` → 没登录态不轮询 → 控制台零报错。
- 全量 `pytest` 149 passed;`ruff check` / `ruff format --check` 干净。
- 全量 `pytest` 151 passed;`ruff check` / `ruff format --check` 干净。
### 13.6.1 上线后修的问题
1. **扫码确认那一刻页面报「服务器内部错误」**:`bot_channel` 的轮询口子(迁移 `0014`)直接
`current_setting('app.bot_channel', true)::uuid`,而事务级 `set_config` 提交后会把连接上的
变量还原成**空串**而不是 NULL,空串一 cast 就
`InvalidTextRepresentationError: invalid input syntax for type uuid: ""`。
迁移 `0015` 改成 `nullif(current_setting(...), '')::uuid`,与 `app_ws_id()` 同一写法。
2. **回话别再带站点链接**:`PUBLIC_BASE_URL` 是内网地址,手机上打不开;
现在只回「已保存任务:…」/「已保存知识草稿:…」。
3. **栏目名从「接入」改成「机器人」**:用户要的是「我和这个机器人说话」,不是「配置接入」。
### 13.7 教训:第一版是猜的
Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 66 KiB

@@ -0,0 +1,56 @@
"""bot_channel 的轮询口子要容忍"空字符串"的 GUC
`set_config(..., true)` 提交后会把变量还原成上一次的值;连接池里这条连接如果本来没有
会话级值,还原出来的是空串 `''` 而不是 NULL。`0014` 的策略直接
`current_setting('app.bot_channel', true)::uuid`,空串一 cast 就
`InvalidTextRepresentationError: invalid input syntax for type uuid: ""`,
于是同一连接上下一个请求(比如扫码确认)500。
这里把策略重建成和 `app_ws_id()` 一样的写法:先 `nullif(..., '')` 再 cast。
Revision ID: 0015
Revises: 0014
"""
from __future__ import annotations
from alembic import op
from app.roles import APP_ROLE
revision = "0015"
down_revision = "0014"
branch_labels = None
depends_on = None
STATEMENTS: tuple[str, ...] = (
"DROP POLICY IF EXISTS bot_channel_worker ON bot_channel",
"""
CREATE POLICY bot_channel_worker ON bot_channel
FOR ALL
USING (
id = nullif(current_setting('app.bot_channel', true), '')::uuid
)
WITH CHECK (
id = nullif(current_setting('app.bot_channel', true), '')::uuid
)
""",
f"GRANT SELECT, INSERT, UPDATE, DELETE ON bot_channel TO {APP_ROLE}",
)
def upgrade() -> None:
for statement in STATEMENTS:
op.execute(statement)
def downgrade() -> None:
op.execute("DROP POLICY IF EXISTS bot_channel_worker ON bot_channel")
op.execute(
"""
CREATE POLICY bot_channel_worker ON bot_channel
FOR ALL
USING (id = current_setting('app.bot_channel', true)::uuid)
WITH CHECK (id = current_setting('app.bot_channel', true)::uuid)
"""
)
+9 -12
View File
@@ -166,18 +166,15 @@ async def create_knowledge(
return item
def item_link(base_url: str, ws_id: uuid.UUID, item: Item) -> str:
"""给人点的链接。`base_url` 传站点根(http://host:8200),不带 /api/v1。"""
base = base_url.rstrip("/")
if item.kind == "knowledge":
return f"{base}/w/{ws_id}/knowledge/{item.id}"
return f"{base}/w/{ws_id}?open={item.id}"
def reply_text(kind: str, item: Item) -> str:
"""回话只说"收下了",不带链接。
def reply_text(kind: str, item: Item, link: str) -> str:
链接是站点地址,可"站点地址"对手机不一定打得开(内网 IP、端口、还没上 HTTPS 的域名),
发过去反而像坏消息。要看详情回网页里看就行。
"""
if kind == "knowledge":
return f"已存成知识草稿:{item.title}\n{link}"
return f"已记下任务:{item.title}\n{link}"
return f"已保存知识草稿:{item.title}"
return f"已保存任务:{item.title}"
async def handle_text(
@@ -208,7 +205,7 @@ async def handle_text(
return {
"action": "knowledge",
"item_id": item.id,
"reply": reply_text("knowledge", item, item_link(base_url, channel.workspace_id, item)),
"reply": reply_text("knowledge", item),
}
item = await create_task(
session,
@@ -221,5 +218,5 @@ async def handle_text(
return {
"action": "task",
"item_id": item.id,
"reply": reply_text("task", item, item_link(base_url, channel.workspace_id, item)),
"reply": reply_text("task", item),
}
+5 -2
View File
@@ -176,7 +176,9 @@ async def test_plain_text_becomes_task(client: AsyncClient) -> None:
await session.commit()
assert result["action"] == "task"
assert "已记下任务" in result["reply"]
# 回话里只确认"收下了",不带站点链接(站点地址在手机上不一定打得开)
assert result["reply"] == "已保存任务:给客户回电话"
assert "http" not in result["reply"]
async with SessionFactory() as session:
await bind_tenant(session, ws_id, uuid.UUID(user["user_id"]))
item = await session.get(Item, result["item_id"])
@@ -207,6 +209,7 @@ async def test_knowledge_prefix_creates_draft_with_tags(client: AsyncClient) ->
await session.commit()
assert result["action"] == "knowledge"
assert result["reply"] == "已保存知识草稿:回滚前先停灰度"
async with SessionFactory() as session:
await bind_tenant(session, ws_id, uuid.UUID(user["user_id"]))
item = await session.get(Item, result["item_id"])
@@ -279,7 +282,7 @@ async def test_poller_handles_message_once_and_replies(
assert len(replies) == 1
assert replies[0][0] == "wx-user-9"
assert "已记下任务" in replies[0][1]
assert replies[0][1] == "已保存任务:整理一下本周排期"
async with SessionFactory() as session:
await session.execute(
+1 -1
View File
@@ -67,7 +67,7 @@ async function main() {
);
await page.goto(`${WEB}/w/${ws.id}/integrations`, { waitUntil: "networkidle" });
check("接入页打开", await page.getByText("怎么用").isVisible());
check("机器人页打开", await page.getByText("怎么用").isVisible());
const button = page.getByRole("button", { name: "扫码连接微信" });
if (await button.isVisible()) {
+1 -1
View File
@@ -39,7 +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} /> },
{ key: "integrations", label: "机器人", to: (id) => `/w/${id}/integrations`, icon: <Chat size={16} /> },
];
function initial(name: string) {
+4 -4
View File
@@ -56,7 +56,7 @@ function ChannelStatus({ channel }: { channel: BotChannel }) {
}
/**
* 接入页:把微信(clawbot)接进这个工作区。
* 机器人页:把微信(clawbot)接进这个工作区。
*
* 走的是腾讯 iLink Bot 协议:**扫码登录 → 后端长轮询收消息 → 用 context_token 回复**。
* 页面这一侧只做两件事:把二维码画出来、反复问「扫了没」。
@@ -85,7 +85,7 @@ export function IntegrationsPage() {
const data = await api.get<{ items: BotChannel[] }>(`/workspaces/${wsId}/integrations`);
setChannels(data.items);
} catch (err) {
setError(err instanceof ApiError ? err.message : "读不到接入配置");
setError(err instanceof ApiError ? err.message : "读不到机器人配置");
} finally {
setLoading(false);
}
@@ -188,7 +188,7 @@ export function IntegrationsPage() {
return (
<AppShell wsId={wsId} active="integrations">
<PageHeader
title="接入"
title="机器人"
desc="微信里发一句话,这里就多一条任务或知识。不用装 App,也不用切浏览器。"
actions={
canManage ? (
@@ -335,7 +335,7 @@ export function IntegrationsPage() {
{!canManage ? (
<p className="mt-3 text-xs text-ink-500">
你不是这个工作区的管理员,能看到接入状态,但不能扫码或断开。
你不是这个工作区的管理员,能看到机器人的状态,但不能扫码或断开。
</p>
) : null}
</>