上一版按"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(拿的是微信真二维码) 教训:接入第三方协议先去已有实现里找协议,别从直觉倒推。
This commit is contained in:
@@ -1,13 +1,18 @@
|
||||
"""外部接入:微信(clawbot)这一类的机器人通道。
|
||||
"""外部接入:微信 clawbot 的机器人账号。
|
||||
|
||||
为什么单开一张表而不是塞进 `system_setting`:
|
||||
|
||||
1. 一个工作区可以挂多个通道(自己的号、公司的号),也随时可能换;
|
||||
1. 一个工作区可能挂过好几个机器人(自己的号、公司的号),也随时可能换;
|
||||
塞进 setting 的 JSON 里就没法"停用这一个、保留那一个"。
|
||||
2. 通道是**租户数据**:谁能往里发消息,取决于它属于哪个工作区。
|
||||
租户表就得有 `workspace_id NOT NULL` + RLS(AGENTS.md 第 2.5 条)。
|
||||
|
||||
令牌只存 sha256:数据库被看见也拿不到能用的令牌;令牌只在创建时回显一次。
|
||||
登录态(`bot_token_enc`)是加密存的:它等于"这个微信账号的登录态",
|
||||
明文落库等于把号交出去。密钥由 `JWT_SECRET` 派生,见 `services/wechat_ilink.py`。
|
||||
|
||||
`token_hash` 是上一版 webhook 方案留下的列:那一版以为 clawbot 会往我们这 POST,
|
||||
后来发现真实协议是"我们扫码登录 + 长轮询"(见 `services/wechat_ilink.py` 文件头),
|
||||
列先留着不为空但不再使用,等下一版清理。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -23,7 +28,9 @@ from sqlalchemy.orm import Mapped, mapped_column
|
||||
from .models import Base, Timestamps, new_uuid
|
||||
|
||||
BOT_KINDS = ("wechat_clawbot",)
|
||||
BOT_STATUSES = ("active", "disabled")
|
||||
# pending:二维码还没扫;active:登录态可用、正在轮询;
|
||||
# disabled:人手动停用;expired:登录态失效,需要重新扫码。
|
||||
BOT_STATUSES = ("pending", "active", "disabled", "expired")
|
||||
|
||||
INTEGRATION_TENANT_TABLES = ("bot_channel", "bot_message")
|
||||
|
||||
@@ -43,9 +50,19 @@ class BotChannel(Base, Timestamps):
|
||||
)
|
||||
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")
|
||||
# 上一版 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")
|
||||
|
||||
Reference in New Issue
Block a user