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 回复**。
|
走的是腾讯 iLink Bot 协议:**扫码登录 → 后端长轮询收消息 → 用 context_token 回复**。
|
||||||
@@ -743,7 +743,9 @@ Query:
|
|||||||
| `帮助`/`?`/`help` | 只回一段用法,不建任何东西 |
|
| `帮助`/`?`/`help` | 只回一段用法,不建任何东西 |
|
||||||
|
|
||||||
正文第一行当标题(超过 180 字截断),其余当正文;`#标签` 会被摘出来加到事项上(纯数字的不算标签)。
|
正文第一行当标题(超过 180 字截断),其余当正文;`#标签` 会被摘出来加到事项上(纯数字的不算标签)。
|
||||||
建完之后会用 `context_token` 在微信里回一句,附上链接(`PUBLIC_BASE_URL` 或 CORS 里的第一个来源)。
|
建完之后会用 `context_token` 在微信里回一句「已保存任务:标题」/「已保存知识草稿:标题」。
|
||||||
|
**回话里不带链接**:站点地址对手机不一定打得开(内网 IP、端口、还没上 HTTPS 的域名),
|
||||||
|
发过去只会让人以为崩了。
|
||||||
同一条消息(按 `message_id`)重复推送只会落一次,重推直接跳过。
|
同一条消息(按 `message_id`)重复推送只会落一次,重推直接跳过。
|
||||||
|
|
||||||
图片/文件/视频目前只在 `bot_message.raw` 里留痕,正文不搬;语音如果服务端给了转写就用转写文本。
|
图片/文件/视频目前只在 `bot_message.raw` 里留痕,正文不搬;语音如果服务端给了转写就用转写文本。
|
||||||
|
|||||||
@@ -795,14 +795,14 @@
|
|||||||
|
|
||||||
### 13.5 Web
|
### 13.5 Web
|
||||||
|
|
||||||
新增 `/w/:wsId/integrations`「接入」页(左侧栏最后一项):
|
新增 `/w/:wsId/integrations`「机器人」页(左侧栏最后一项;栏目名从「接入」改过来,因为用户要的是「机器人」这件事,而不是「接入」这个后台动作):
|
||||||
|
|
||||||
- 「扫码连接微信」→ 页面上直接画出二维码,每 2 秒问一次状态(待扫 → 已扫待确认 → 成功/过期);
|
- 「扫码连接微信」→ 页面上直接画出二维码,每 2 秒问一次状态(待扫 → 已扫待确认 → 成功/过期);
|
||||||
- 连上之后显示通道卡片:接收中/登录已失效/已停用、最后收信时间、"微信用户 xxx"、
|
- 连上之后显示通道卡片:接收中/登录已失效/已停用、最后收信时间、"微信用户 xxx"、
|
||||||
`最近消息`(最近 5 条,标"建了任务/建了知识")、重新扫码、断开;
|
`最近消息`(最近 5 条,标"建了任务/建了知识")、重新扫码、断开;
|
||||||
- 只有 owner/admin 能扫码与断开,其他成员只读。
|
- 只有 owner/admin 能扫码与断开,其他成员只读。
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### 13.6 验证
|
### 13.6 验证
|
||||||
|
|
||||||
@@ -812,7 +812,18 @@
|
|||||||
登录态失效后通道转 `expired` 且带原因。
|
登录态失效后通道转 `expired` 且带原因。
|
||||||
- `web/e2e/integrations.mjs` 7 项(真跑,拿的是微信真二维码):接入页打开 → 点扫码 →
|
- `web/e2e/integrations.mjs` 7 项(真跑,拿的是微信真二维码):接入页打开 → 点扫码 →
|
||||||
二维码是页面里的 SVG → 提示用微信扫 → 后端通道 `pending` → 没登录态不轮询 → 控制台零报错。
|
二维码是页面里的 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 教训:第一版是猜的
|
### 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
|
return item
|
||||||
|
|
||||||
|
|
||||||
def item_link(base_url: str, ws_id: uuid.UUID, item: Item) -> str:
|
def reply_text(kind: str, 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}"
|
|
||||||
|
|
||||||
|
链接是站点地址,可"站点地址"对手机不一定打得开(内网 IP、端口、还没上 HTTPS 的域名),
|
||||||
def reply_text(kind: str, item: Item, link: str) -> str:
|
发过去反而像坏消息。要看详情回网页里看就行。
|
||||||
|
"""
|
||||||
if kind == "knowledge":
|
if kind == "knowledge":
|
||||||
return f"已存成知识草稿:{item.title}\n{link}"
|
return f"已保存知识草稿:{item.title}"
|
||||||
return f"已记下任务:{item.title}\n{link}"
|
return f"已保存任务:{item.title}"
|
||||||
|
|
||||||
|
|
||||||
async def handle_text(
|
async def handle_text(
|
||||||
@@ -208,7 +205,7 @@ async def handle_text(
|
|||||||
return {
|
return {
|
||||||
"action": "knowledge",
|
"action": "knowledge",
|
||||||
"item_id": item.id,
|
"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(
|
item = await create_task(
|
||||||
session,
|
session,
|
||||||
@@ -221,5 +218,5 @@ async def handle_text(
|
|||||||
return {
|
return {
|
||||||
"action": "task",
|
"action": "task",
|
||||||
"item_id": item.id,
|
"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()
|
await session.commit()
|
||||||
|
|
||||||
assert result["action"] == "task"
|
assert result["action"] == "task"
|
||||||
assert "已记下任务" in result["reply"]
|
# 回话里只确认"收下了",不带站点链接(站点地址在手机上不一定打得开)
|
||||||
|
assert result["reply"] == "已保存任务:给客户回电话"
|
||||||
|
assert "http" not in result["reply"]
|
||||||
async with SessionFactory() as session:
|
async with SessionFactory() as session:
|
||||||
await bind_tenant(session, ws_id, uuid.UUID(user["user_id"]))
|
await bind_tenant(session, ws_id, uuid.UUID(user["user_id"]))
|
||||||
item = await session.get(Item, result["item_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()
|
await session.commit()
|
||||||
|
|
||||||
assert result["action"] == "knowledge"
|
assert result["action"] == "knowledge"
|
||||||
|
assert result["reply"] == "已保存知识草稿:回滚前先停灰度"
|
||||||
async with SessionFactory() as session:
|
async with SessionFactory() as session:
|
||||||
await bind_tenant(session, ws_id, uuid.UUID(user["user_id"]))
|
await bind_tenant(session, ws_id, uuid.UUID(user["user_id"]))
|
||||||
item = await session.get(Item, result["item_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 len(replies) == 1
|
||||||
assert replies[0][0] == "wx-user-9"
|
assert replies[0][0] == "wx-user-9"
|
||||||
assert "已记下任务" in replies[0][1]
|
assert replies[0][1] == "已保存任务:整理一下本周排期"
|
||||||
|
|
||||||
async with SessionFactory() as session:
|
async with SessionFactory() as session:
|
||||||
await session.execute(
|
await session.execute(
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ async function main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await page.goto(`${WEB}/w/${ws.id}/integrations`, { waitUntil: "networkidle" });
|
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: "扫码连接微信" });
|
const button = page.getByRole("button", { name: "扫码连接微信" });
|
||||||
if (await button.isVisible()) {
|
if (await button.isVisible()) {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const NAV: { key: Section; label: string; to: (id: string) => string; icon: Reac
|
|||||||
icon: <Bell size={16} />,
|
icon: <Bell size={16} />,
|
||||||
},
|
},
|
||||||
{ key: "members", label: "成员", to: (id) => `/w/${id}/members`, icon: <Users 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) {
|
function initial(name: string) {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ function ChannelStatus({ channel }: { channel: BotChannel }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 接入页:把微信(clawbot)接进这个工作区。
|
* 机器人页:把微信(clawbot)接进这个工作区。
|
||||||
*
|
*
|
||||||
* 走的是腾讯 iLink Bot 协议:**扫码登录 → 后端长轮询收消息 → 用 context_token 回复**。
|
* 走的是腾讯 iLink Bot 协议:**扫码登录 → 后端长轮询收消息 → 用 context_token 回复**。
|
||||||
* 页面这一侧只做两件事:把二维码画出来、反复问「扫了没」。
|
* 页面这一侧只做两件事:把二维码画出来、反复问「扫了没」。
|
||||||
@@ -85,7 +85,7 @@ export function IntegrationsPage() {
|
|||||||
const data = await api.get<{ items: BotChannel[] }>(`/workspaces/${wsId}/integrations`);
|
const data = await api.get<{ items: BotChannel[] }>(`/workspaces/${wsId}/integrations`);
|
||||||
setChannels(data.items);
|
setChannels(data.items);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : "读不到接入配置");
|
setError(err instanceof ApiError ? err.message : "读不到机器人配置");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -188,7 +188,7 @@ export function IntegrationsPage() {
|
|||||||
return (
|
return (
|
||||||
<AppShell wsId={wsId} active="integrations">
|
<AppShell wsId={wsId} active="integrations">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="接入"
|
title="机器人"
|
||||||
desc="微信里发一句话,这里就多一条任务或知识。不用装 App,也不用切浏览器。"
|
desc="微信里发一句话,这里就多一条任务或知识。不用装 App,也不用切浏览器。"
|
||||||
actions={
|
actions={
|
||||||
canManage ? (
|
canManage ? (
|
||||||
@@ -335,7 +335,7 @@ export function IntegrationsPage() {
|
|||||||
|
|
||||||
{!canManage ? (
|
{!canManage ? (
|
||||||
<p className="mt-3 text-xs text-ink-500">
|
<p className="mt-3 text-xs text-ink-500">
|
||||||
你不是这个工作区的管理员,能看到接入状态,但不能扫码或断开。
|
你不是这个工作区的管理员,能看到机器人的状态,但不能扫码或断开。
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user