feat(integrations): 微信里发一句话,变成一条任务或知识
CI / backend (push) Canceled after 0s

一条通道一张令牌,机器人把消息 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:
Steven
2026-11-05 22:04:34 +08:00
parent 33a3fc03e0
commit 90b99f8253
16 changed files with 1606 additions and 4 deletions
@@ -0,0 +1,108 @@
"""微信(clawbot)接入通道
Revision ID: 0012
Revises: 0011
Create Date: 2026-11-05
两张表:
- `bot_channel`:一个工作区可以挂多个机器人通道,令牌只存 sha256。
- `bot_message`:进来的每条消息留痕,附带它变成了哪条任务/知识。
`bot_channel` 的 RLS 多放了一条口子:`token_hash = app_bot_token_hash()`。
原因很实在——**收到 webhook 时我们还不知道它是哪个工作区的**,而候选令牌
必须读过表才知道。没有这条口子,就只能要么关掉 RLS(不行),要么把令牌
放一张不挂租户的表里(等于把钥匙串放在门外)。现在的做法是:
请求先把 `app.bot_token_hash` 设成本次令牌的哈希,策略只允许命中那一行,
其余行照旧看不见。
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
from app.roles import APP_ROLE
revision: str = "0012"
down_revision: str = "0011"
branch_labels: Sequence[str] | None = None
depends_on: Sequence[str] | None = None
CREATE_STATEMENTS = (
"""
CREATE TABLE bot_channel (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
kind varchar(32) NOT NULL DEFAULT 'wechat_clawbot',
name varchar(64) NOT NULL DEFAULT '',
token_hash varchar(64) NOT NULL,
status varchar(16) NOT NULL DEFAULT 'active',
created_by uuid REFERENCES app_user(id) ON DELETE SET NULL,
last_seen_at timestamptz,
last_error text,
version integer NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
""",
"""
CREATE TABLE bot_message (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
channel_id uuid NOT NULL REFERENCES bot_channel(id) ON DELETE CASCADE,
external_id varchar(128),
sender varchar(128),
text text NOT NULL DEFAULT '',
action varchar(24) NOT NULL DEFAULT 'ignored',
item_id uuid REFERENCES item(id) ON DELETE SET NULL,
reply text NOT NULL DEFAULT '',
raw jsonb NOT NULL DEFAULT '{}'::jsonb,
version integer NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
""",
)
INDEX_STATEMENTS = (
"CREATE INDEX ix_bot_channel_ws ON bot_channel (workspace_id)",
"CREATE UNIQUE INDEX uq_bot_channel_token ON bot_channel (token_hash)",
"CREATE INDEX ix_bot_message_channel ON bot_message (channel_id, created_at)",
"CREATE UNIQUE INDEX uq_bot_message_external ON bot_message (channel_id, external_id) "
"WHERE external_id IS NOT NULL",
)
POLICY_SQL = (
"ALTER TABLE bot_channel ENABLE ROW LEVEL SECURITY",
"CREATE POLICY bot_channel_isolation ON bot_channel "
"USING (workspace_id = app_ws_id() OR token_hash = current_setting('app.bot_token_hash', true)) "
"WITH CHECK (workspace_id = app_ws_id())",
"ALTER TABLE bot_message ENABLE ROW LEVEL SECURITY",
"CREATE POLICY bot_message_isolation ON bot_message "
"USING (workspace_id = app_ws_id()) WITH CHECK (workspace_id = app_ws_id())",
f"GRANT SELECT, INSERT, UPDATE, DELETE ON bot_channel TO {APP_ROLE}",
f"GRANT SELECT, INSERT, UPDATE, DELETE ON bot_message TO {APP_ROLE}",
)
COMMENT_SQL = (
"COMMENT ON TABLE bot_channel IS '外部接入通道(微信 clawbot 等),令牌只存 sha256'",
"COMMENT ON TABLE bot_message IS '外部接入收到并处理过的消息,用于支线排查与去重'",
)
def upgrade() -> None:
for statement in CREATE_STATEMENTS:
op.execute(statement)
for statement in INDEX_STATEMENTS:
op.execute(statement)
for statement in POLICY_SQL:
op.execute(statement)
for statement in COMMENT_SQL:
op.execute(statement)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS bot_message")
op.execute("DROP TABLE IF EXISTS bot_channel")