Files
DadaoTask/backend/app/models_integrations.py
T
Steven 8436c0edbf
CI / backend (push) Canceled after 0s
feat(integrations): 微信要先扫码配对,改成 iLink 扫码登录 + 长轮询
上一版按"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(拿的是微信真二维码)

教训:接入第三方协议先去已有实现里找协议,别从直觉倒推。
2026-11-05 23:03:18 +08:00

107 lines
5.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""外部接入:微信 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)