feat(integrations): 微信里发一句话,变成一条任务或知识
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 项全绿。
This commit is contained in:
Steven
2026-11-05 22:04:34 +08:00
parent 33a3fc03e0
commit 90b99f8253
16 changed files with 1606 additions and 4 deletions
+89
View File
@@ -0,0 +1,89 @@
"""外部接入:微信(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)