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 项全绿。
109 lines
4.1 KiB
Python
109 lines
4.1 KiB
Python
"""微信(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")
|