"""外部接入:微信(clawbot)这一类的机器人通道。 为什么单开一张表而不是塞进 `system_setting`: 1. 一个工作区可以挂多个通道(自己的号、公司的号),也随时可能换; 塞进 setting 的 JSON 里就没法"停用这一个、保留那一个"。 2. 通道是**租户数据**:谁能往里发消息,取决于它属于哪个工作区。 租户表就得有 `workspace_id NOT NULL` + RLS(AGENTS.md 第 2.5 条)。 令牌只存 sha256:数据库被看见也拿不到能用的令牌;令牌只在创建时回显一次。 """ from __future__ import annotations import uuid from datetime import datetime from sqlalchemy import DateTime, ForeignKey, Index, String, Text, text from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import UUID as PgUUID from sqlalchemy.orm import Mapped, mapped_column from .models import Base, Timestamps, new_uuid BOT_KINDS = ("wechat_clawbot",) BOT_STATUSES = ("active", "disabled") INTEGRATION_TENANT_TABLES = ("bot_channel", "bot_message") class BotChannel(Base, Timestamps): """一条接入通道:微信里那个机器人 ⇄ 本工作区。""" __tablename__ = "bot_channel" __table_args__ = ( Index("ix_bot_channel_ws", "workspace_id"), Index("uq_bot_channel_token", "token_hash", unique=True), ) id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid) workspace_id: Mapped[uuid.UUID] = mapped_column( PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False ) kind: Mapped[str] = mapped_column(String(32), nullable=False, default="wechat_clawbot") name: Mapped[str] = mapped_column(String(64), nullable=False, default="") # hex(sha256(令牌))。令牌本身只在创建响应里出现一次。 token_hash: Mapped[str] = mapped_column(String(64), nullable=False) status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") # 消息落在谁名下:用创建这条通道的人;人没了就退回工作区 owner。 created_by: Mapped[uuid.UUID | None] = mapped_column( PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="SET NULL") ) last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_error: Mapped[str | None] = mapped_column(Text) class BotMessage(Base, Timestamps): """收到的每条消息都留痕:出问题时能回答"那条任务到底有没有进去"。""" __tablename__ = "bot_message" __table_args__ = ( Index("ix_bot_message_channel", "channel_id", "created_at"), # 同一个外部消息 id 只处理一次:微信会重推,重推不能变成两条任务。 Index( "uq_bot_message_external", "channel_id", "external_id", unique=True, postgresql_where=text("external_id IS NOT NULL"), ), ) id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid) workspace_id: Mapped[uuid.UUID] = mapped_column( PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False ) channel_id: Mapped[uuid.UUID] = mapped_column( PgUUID(as_uuid=True), ForeignKey("bot_channel.id", ondelete="CASCADE"), nullable=False ) external_id: Mapped[str | None] = mapped_column(String(128)) sender: Mapped[str | None] = mapped_column(String(128)) text: Mapped[str] = mapped_column(Text, nullable=False, default="") # task / knowledge / help / ignored / error action: Mapped[str] = mapped_column(String(24), nullable=False, default="ignored") item_id: Mapped[uuid.UUID | None] = mapped_column( PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="SET NULL") ) reply: Mapped[str] = mapped_column(Text, nullable=False, default="") raw: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)