Files
DadaoTask/backend/app/services/wechat_intake.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

226 lines
7.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.
"""把一句话变成任务或知识草稿。
微信只负责把文本送进来,怎么落成事项是这里的事,接口和后台轮询共用同一份逻辑,
免得"网页里发"和"机器人里发"落成两种东西。
"""
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)),
}