feat(integrations): 微信要先扫码配对,改成 iLink 扫码登录 + 长轮询
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(拿的是微信真二维码)

教训:接入第三方协议先去已有实现里找协议,别从直觉倒推。
This commit is contained in:
Steven
2026-11-05 23:03:18 +08:00
parent d21394a5ac
commit 8436c0edbf
18 changed files with 1836 additions and 1060 deletions
+225
View File
@@ -0,0 +1,225 @@
"""把一句话变成任务或知识草稿。
微信只负责把文本送进来,怎么落成事项是这里的事,接口和后台轮询共用同一份逻辑,
免得"网页里发"和"机器人里发"落成两种东西。
"""
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..errors import AppError, ErrorCode
from ..models import Member, Workspace
from ..models_integrations import BotChannel
from ..models_knowledge import KnowledgeDoc
from ..models_task import Item
MAX_TITLE = 180
MAX_BODY = 20000
KNOWLEDGE_PREFIXES = ("知识", "笔记", "note", "knowledge", "kb")
TASK_PREFIXES = ("任务", "待办", "todo", "task")
HELP_PREFIXES = ("?", "?", "帮助", "help", "用法")
HELP_TEXT = (
"发什么都能用,写法:\n"
"· 直接说 → 记成任务\n"
"· 知识:内容 → 记成知识草稿(也可以写「笔记:」)\n"
"· 任务:内容 → 明确记成任务\n"
"首行当标题,后面几行当正文;标题里带 #标签 会自动加上。"
)
SEPARATORS = (":", ":", " ", "\u3000")
def split_command(text: str) -> tuple[str, str]:
"""返回 (kind, 去掉前缀后的正文)。"""
head, _, rest = text.partition("\n")
probe = head.strip().lower()
for prefix in HELP_PREFIXES:
if probe == prefix or probe.startswith(f"{prefix}:") or probe.startswith(f"{prefix}:"):
return "help", rest
for prefix in KNOWLEDGE_PREFIXES:
for sep in SEPARATORS:
if probe.startswith(f"{prefix}{sep}"):
offset = len(prefix) + len(sep)
return "knowledge", (head.strip()[offset:] + ("\n" + rest if rest else "")).strip()
for prefix in TASK_PREFIXES:
for sep in SEPARATORS:
if probe.startswith(f"{prefix}{sep}"):
offset = len(prefix) + len(sep)
return "task", (head.strip()[offset:] + ("\n" + rest if rest else "")).strip()
return "task", text
def split_tags(body: str) -> tuple[str, list[str]]:
"""把 `#标签` 摘出来。只认行首和空白后的 #,免得把 "C#" 吃掉。"""
tags: list[str] = []
kept: list[str] = []
for line in body.splitlines():
rest: list[str] = []
for word in line.split(" "):
if word.startswith("#") and len(word) > 1 and not word[1:].isdigit():
name = word[1:].strip(",,。.;;")
if name and name.lower() not in {t.lower() for t in tags}:
tags.append(name)
continue
rest.append(word)
kept.append(" ".join(rest).rstrip())
return "\n".join(kept).strip(), tags
def split_title(body: str) -> tuple[str, str]:
lines = body.splitlines()
if not lines:
return "", ""
return lines[0].strip()[:MAX_TITLE], "\n".join(lines[1:]).strip()[:MAX_BODY]
async def creator_id(session: AsyncSession, channel: BotChannel) -> uuid.UUID:
"""消息记在谁名下:通道创建者;人没了就退回工作区 owner。"""
if channel.created_by is not None:
row = await session.execute(
select(Member.id).where(
Member.workspace_id == channel.workspace_id,
Member.user_id == channel.created_by,
Member.status == "active",
)
)
if row.first() is not None:
return channel.created_by
workspace = await session.get(Workspace, channel.workspace_id)
if workspace is None: # 理论上不可能:FK 是 CASCADE
raise AppError(ErrorCode.BOT_TOKEN_INVALID, "接入通道不存在")
return workspace.owner_id
async def create_task(
session: AsyncSession,
*,
ws_id: uuid.UUID,
creator: uuid.UUID,
title: str,
body: str,
tags: list[str],
) -> Item:
item = Item(
workspace_id=ws_id,
kind="task",
title=title,
body_md=body,
status="inbox",
priority=0,
creator_id=creator,
)
session.add(item)
await session.flush()
from ..api.items import _log, _set_tags # 延迟导入:services 不该在模块级依赖 api
if tags:
await _set_tags(session, ws_id, item, tags)
await _log(session, item, creator, "created", {"via": "wechat_clawbot"})
return item
async def create_knowledge(
session: AsyncSession,
*,
ws_id: uuid.UUID,
creator: uuid.UUID,
title: str,
body: str,
tags: list[str],
) -> Item:
item = Item(
workspace_id=ws_id,
kind="knowledge",
title=title,
body_md=body,
status="draft",
creator_id=creator,
)
session.add(item)
await session.flush()
session.add(
KnowledgeDoc(
item_id=item.id,
workspace_id=ws_id,
author_id=creator,
source_item_id=None,
source_kind="manual",
doc_type="note",
status_hint="draft",
generated_by="human",
)
)
await session.flush()
from ..api.items import _log, _set_tags # 延迟导入,理由同上
if tags:
await _set_tags(session, ws_id, item, tags)
await _log(session, item, creator, "knowledge_created", {"via": "wechat_clawbot"})
return item
def item_link(base_url: str, ws_id: uuid.UUID, item: Item) -> str:
"""给人点的链接。`base_url` 传站点根(http://host:8200),不带 /api/v1。"""
base = base_url.rstrip("/")
if item.kind == "knowledge":
return f"{base}/w/{ws_id}/knowledge/{item.id}"
return f"{base}/w/{ws_id}?open={item.id}"
def reply_text(kind: str, item: Item, link: str) -> str:
if kind == "knowledge":
return f"已存成知识草稿:{item.title}\n{link}"
return f"已记下任务:{item.title}\n{link}"
async def handle_text(
session: AsyncSession,
channel: BotChannel,
text: str,
*,
base_url: str,
) -> dict[str, Any]:
"""一段文本 → (action, item_id, reply)。调用方负责提交事务与留痕。"""
kind, body = split_command(text.strip())
if kind == "help":
return {"action": "help", "item_id": None, "reply": HELP_TEXT}
body, tags = split_tags(body)
title, rest = split_title(body)
if not title:
return {"action": "ignored", "item_id": None, "reply": "内容是空的,没记。"}
creator = await creator_id(session, channel)
if kind == "knowledge":
item = await create_knowledge(
session,
ws_id=channel.workspace_id,
creator=creator,
title=title,
body=rest,
tags=tags,
)
return {
"action": "knowledge",
"item_id": item.id,
"reply": reply_text("knowledge", item, item_link(base_url, channel.workspace_id, item)),
}
item = await create_task(
session,
ws_id=channel.workspace_id,
creator=creator,
title=title,
body=rest,
tags=tags,
)
return {
"action": "task",
"item_id": item.id,
"reply": reply_text("task", item, item_link(base_url, channel.workspace_id, item)),
}