CI / backend (push) Canceled after 0s
上一版按"webhook + URL 里带令牌"自己编了一套,用户当场指出微信必须先扫码配对。 现在照腾讯 iLink Bot 协议自己做客户端:取二维码 → 手机扫码确认 → 存加密登录态 → 后端长轮询收消息 → 用 context_token 回话。独立实现,不依赖 DSH 的运行环境。 后端 - wechat_ilink.py:get_bot_qrcode / get_qrcode_status / getupdates / sendmessage, bot_token 用 Fernet(密钥由 JWT_SECRET 派生)加密入库,日志与接口都不回明文 - wechat_intake.py:一句话 → 任务或知识草稿,前缀识别、#标签、首行标题 - wechat_bot.py:每条通道一个轮询任务,启动时按 status='active' 恢复; message_id 去重;errcode -14 转 expired 让用户重新扫 - api/integrations.py 重写成扫码三接口 + 停用/断开/最近消息 - 迁移 0013(登录态列)、0014(轮询入口按 app.bot_channel 主键的 RLS 口子) Web - 接入页改成扫码流程:页面里画出二维码、每 2 秒问一次状态、连上显示微信用户与最近消息 验证 - pytest 149 passed(新增 test_wechat_bot 9 例) - ruff check / ruff format --check 干净;tsc 0;vite build ✓ - web/e2e/integrations.mjs 7 项 ALL PASS(拿的是微信真二维码) 教训:接入第三方协议先去已有实现里找协议,别从直觉倒推。
107 lines
5.1 KiB
Python
107 lines
5.1 KiB
Python
"""外部接入:微信 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)
|