"""外部接入:微信 clawbot 的机器人账号。 为什么单开一张表而不是塞进 `system_setting`: 1. 一个工作区可能挂过好几个机器人(自己的号、公司的号),也随时可能换; 塞进 setting 的 JSON 里就没法"停用这一个、保留那一个"。 2. 通道是**租户数据**:谁能往里发消息,取决于它属于哪个工作区。 租户表就得有 `workspace_id NOT NULL` + RLS(AGENTS.md 第 2.5 条)。 登录态(`bot_token_enc`)是加密存的:它等于"这个微信账号的登录态", 明文落库等于把号交出去。密钥由 `JWT_SECRET` 派生,见 `services/wechat_ilink.py`。 `token_hash` 是上一版 webhook 方案留下的列:那一版以为 clawbot 会往我们这 POST, 后来发现真实协议是"我们扫码登录 + 长轮询"(见 `services/wechat_ilink.py` 文件头), 列先留着不为空但不再使用,等下一版清理。 """ 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",) # pending:二维码还没扫;active:登录态可用、正在轮询; # disabled:人手动停用;expired:登录态失效,需要重新扫码。 BOT_STATUSES = ("pending", "active", "disabled", "expired") 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="") # 上一版 webhook 方案的遗留列,现在不用了(见模块说明)。 token_hash: Mapped[str | None] = mapped_column(String(64)) status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending") # 加密后的 iLink 登录态;解不出来就说明 JWT_SECRET 换过,要重新扫码。 bot_token_enc: Mapped[str | None] = mapped_column(Text) # 扫码的那个微信号(iLink 的 ilink_user_id),用来在界面上显示"谁扫的"。 ilink_user_id: Mapped[str | None] = mapped_column(String(64)) # 服务端可能下发新的基址,跟着它走。 base_url: Mapped[str | None] = mapped_column(Text) # 长轮询游标:丢了就会重复收消息。 sync_buf: Mapped[str | None] = mapped_column(Text) # 最近一条消息的 context_token:回复必须带它。 context_token: Mapped[str | None] = mapped_column(Text) # 消息落在谁名下:用创建这条通道的人;人没了就退回工作区 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)