"""微信(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")