diff --git a/2.Design/API.md b/2.Design/API.md index 7d4f5d6..114a709 100644 --- a/2.Design/API.md +++ b/2.Design/API.md @@ -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` 里留痕,正文不搬;语音如果服务端给了转写就用转写文本。 diff --git a/2.Design/ROADMAP-M0-M1.md b/2.Design/ROADMAP-M0-M1.md index 2f28c0a..1148200 100644 --- a/2.Design/ROADMAP-M0-M1.md +++ b/2.Design/ROADMAP-M0-M1.md @@ -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 教训:第一版是猜的 diff --git a/2.Design/screens/web-integrations.png b/2.Design/screens/web-integrations.png index 79eafa6..5bd4a70 100644 Binary files a/2.Design/screens/web-integrations.png and b/2.Design/screens/web-integrations.png differ diff --git a/backend/alembic/versions/0015_bot_channel_policy_nullif.py b/backend/alembic/versions/0015_bot_channel_policy_nullif.py new file mode 100644 index 0000000..b82de7f --- /dev/null +++ b/backend/alembic/versions/0015_bot_channel_policy_nullif.py @@ -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) + """ + ) diff --git a/backend/app/services/wechat_intake.py b/backend/app/services/wechat_intake.py index 89ef510..310b32c 100644 --- a/backend/app/services/wechat_intake.py +++ b/backend/app/services/wechat_intake.py @@ -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), } diff --git a/backend/tests/test_wechat_bot.py b/backend/tests/test_wechat_bot.py index 84abb7d..e70f01a 100644 --- a/backend/tests/test_wechat_bot.py +++ b/backend/tests/test_wechat_bot.py @@ -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( diff --git a/web/e2e/integrations.mjs b/web/e2e/integrations.mjs index 347d432..572c36b 100644 --- a/web/e2e/integrations.mjs +++ b/web/e2e/integrations.mjs @@ -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()) { diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx index 44bf879..2239096 100644 --- a/web/src/components/AppShell.tsx +++ b/web/src/components/AppShell.tsx @@ -39,7 +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: }, + { key: "integrations", label: "机器人", to: (id) => `/w/${id}/integrations`, icon: }, ]; function initial(name: string) { diff --git a/web/src/pages/Integrations.tsx b/web/src/pages/Integrations.tsx index 4f70fc6..bfc7e02 100644 --- a/web/src/pages/Integrations.tsx +++ b/web/src/pages/Integrations.tsx @@ -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 ( - 你不是这个工作区的管理员,能看到接入状态,但不能扫码或断开。 + 你不是这个工作区的管理员,能看到机器人的状态,但不能扫码或断开。

) : null}