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 项全绿。
90 lines
3.9 KiB
Python
90 lines
3.9 KiB
Python
"""外部接入:微信(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)
|