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:
+4
-2
@@ -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` 里留痕,正文不搬;语音如果服务端给了转写就用转写文本。
|
||||
|
||||
@@ -795,14 +795,14 @@
|
||||
|
||||
### 13.5 Web
|
||||
|
||||
新增 `/w/:wsId/integrations`「接入」页(左侧栏最后一项):
|
||||
新增 `/w/:wsId/integrations`「机器人」页(左侧栏最后一项;栏目名从「接入」改过来,因为用户要的是「机器人」这件事,而不是「接入」这个后台动作):
|
||||
|
||||
- 「扫码连接微信」→ 页面上直接画出二维码,每 2 秒问一次状态(待扫 → 已扫待确认 → 成功/过期);
|
||||
- 连上之后显示通道卡片:接收中/登录已失效/已停用、最后收信时间、"微信用户 xxx"、
|
||||
`最近消息`(最近 5 条,标"建了任务/建了知识")、重新扫码、断开;
|
||||
- 只有 owner/admin 能扫码与断开,其他成员只读。
|
||||
|
||||

|
||||

|
||||
|
||||
### 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)
|
||||
"""
|
||||
)
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user