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
+156 -480
View File
@@ -1,407 +1,62 @@
"""外部接入:微信(clawbot)发来的消息变成任务 / 知识。
"""外部接入:微信 clawbot 的扫码登录与状态。
## 一条消息是怎么变成事项的
真实协议是腾讯 iLink Bot(出处见 `app/services/wechat_ilink.py` 文件头):
**我们**去取二维码、等人扫、拿到登录态后长轮询拉消息。所以这里的接口是三件事:
微信里发一句话给机器人,clawbot 把消息 POST 到
1. `POST /workspaces/{ws}/integrations/wechat/qr` —— 取一张二维码,页面显示给人扫;
2. `GET /workspaces/{ws}/integrations/wechat/qr/{id}` —— 问"扫了吗",扫成功就存登录态并开始轮询;
3. `DELETE /workspaces/{ws}/integrations/{channel_id}` —— 断开:登录态作废、轮询停止。
POST /api/v1/integrations/clawbot/{token}
二维码 id 放 Redis 而不是内存:问状态的那一下可能落到另一个进程,
存内存就会"扫完了但没人知道"。key 带工作区,两个工作区互不踩。
令牌在 URL 里,**不带登录态**:机器人不会登我们的账号,它只会照着配置好的
地址发。令牌是 32 字节随机串,库里只存 sha256。
正文按前缀决定去哪个池子:
知识:xxx / 笔记:xxx / note: xxx → 知识草稿
任务:xxx / todo: xxx → 任务(默认也是任务)
?、帮助、help → 回一段用法说明
首行是标题,其余行是正文。首行里的 `#标签` 会被摘出来挂到事项上
(标签是知识的主要检索入口,手机上敲#比打开网页加标签省事)。
## 为什么每条消息都留痕
微信会重推(网络抖动、机器人重启)。没有留痕就只能靠猜"那条任务到底进没进"。
`bot_message.external_id` 上有个部分唯一索引:同一个外部消息 id 第二次到达时
直接回上次的答案,不会变成两条任务。
## 安全边界
- 令牌错 → 9002(对外一律"通道不存在",不区分"错令牌"和"这个工作区没接")。
- 通道被停用 → 9003。
- 消息里不认人:机器人只知道"这是这个工作区的一条消息",落库按通道创建者记账,
不会因为消息内容去猜是谁发的。
上一版这里做的是 webhook 接收端(`POST /integrations/clawbot/{token}`),
对 clawbot 的理解是错的,接口与令牌一并删掉了(迁移 0013 的注释记了原因)。
"""
from __future__ import annotations
import hashlib
import secrets
import base64
import io
import uuid
from datetime import datetime, timezone
from typing import Annotated, Any
from typing import Any
from fastapi import APIRouter, Body, Query, Request
from fastapi.responses import PlainTextResponse
import qrcode
import qrcode.image.svg
from fastapi import APIRouter, Query
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from ..config import get_settings
from ..db import bind_tenant
from ..deps import AdminDep, SessionDep
from ..errors import AppError, ErrorCode
from ..models import Member, Workspace
from ..models_integrations import BotChannel, BotMessage
from ..models_knowledge import KnowledgeDoc
from ..models_task import Item
from ..schemas import ok
from .items import _log, _set_tags
from ..services import wechat_ilink
from ..services.wechat_bot import manager
from ..stores import get_store
webhook_router = APIRouter(prefix="/integrations", tags=["外部接入"])
admin_router = APIRouter(prefix="/workspaces", tags=["外部接入"])
# 标题在 item.title 上是 varchar(200),留点余量给"…"之类的后缀。
MAX_TITLE = 180
MAX_BODY = 20000
KNOWLEDGE_PREFIXES = ("知识", "笔记", "note", "knowledge", "kb")
TASK_PREFIXES = ("任务", "待办", "todo", "task")
HELP_PREFIXES = ("?", "?", "帮助", "help", "用法")
HELP_TEXT = (
"发什么都能用,写法:\n"
"· 直接说 → 记成任务\n"
"· 知识:内容 → 记成知识草稿(也可以写「笔记:」)\n"
"· 任务:内容 → 明确记成任务\n"
"首行当标题,后面几行当正文;标题里带 #标签 会自动加上。"
)
# iLink 的二维码大约 3 分钟过期,我们按 5 分钟缓存 id,过期就重新取。
QR_TTL_SECONDS = 300
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
class ChannelPatch(BaseModel):
status: str
def new_token() -> str:
return secrets.token_urlsafe(32)
def _qr_key(ws_id: uuid.UUID) -> str:
return f"wechat:qr:{ws_id}"
def _pick(data: dict[str, Any], *keys: str) -> str | None:
"""从任意形状的 payload 里挖字段(clawbot 的消息壳子没定死,宽松一点)。"""
for key in keys:
value = data.get(key)
if isinstance(value, str) and value.strip():
return value
if isinstance(value, dict):
nested = _pick(value, "text", "content", "body", "message")
if nested:
return nested
return None
def extract_text(payload: dict[str, Any]) -> str:
text = _pick(
payload,
"text",
"content",
"msg",
"message",
"query",
"body",
"data",
"payload",
)
return (text or "").strip()
def extract_external_id(payload: dict[str, Any]) -> str | None:
value = _pick(payload, "msg_id", "message_id", "msgid", "id", "uuid", "event_id")
return value[:128] if value else None
def extract_sender(payload: dict[str, Any]) -> str | None:
value = _pick(payload, "from", "sender", "from_user", "user", "uid", "sender_id")
return value[:128] if value else None
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 (":", ":", " ", "\u3000"):
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 (":", ":", " ", "\u3000"):
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():
words = line.split(" ")
rest: list[str] = []
for word in words:
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 "", ""
title = lines[0].strip()
rest = "\n".join(lines[1:]).strip()
return title[:MAX_TITLE], rest[:MAX_BODY]
async def _creator_id(session: SessionDep, 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: SessionDep,
*,
ws_id: uuid.UUID,
creator_id: 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_id,
)
session.add(item)
await session.flush()
if tags:
await _set_tags(session, ws_id, item, tags)
await _log(session, item, creator_id, "created", {"via": "wechat_clawbot"})
return item
async def _create_knowledge(
session: SessionDep,
*,
ws_id: uuid.UUID,
creator_id: 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_id,
)
session.add(item)
await session.flush()
doc = KnowledgeDoc(
item_id=item.id,
workspace_id=ws_id,
author_id=creator_id,
source_item_id=None,
source_kind="manual",
doc_type="note",
status_hint="draft",
generated_by="human",
)
session.add(doc)
await session.flush()
if tags:
await _set_tags(session, ws_id, item, tags)
await _log(session, item, creator_id, "knowledge_created", {"via": "wechat_clawbot"})
return item
def _link(request: Request, ws_id: uuid.UUID, item: Item) -> str:
base = str(request.base_url).rstrip("/")
# base_url 形如 http://host:8200/api/v1/,而页面在站点根上
base = base.split("/api/")[0]
if item.kind == "knowledge":
return f"{base}/w/{ws_id}/knowledge/{item.id}"
return f"{base}/w/{ws_id}?open={item.id}"
async def _channel_by_token(session: SessionDep, token: str) -> BotChannel:
from sqlalchemy import text as sql_text
# 策略要靠这个值才放行那一行,必须先设。
await session.execute(
sql_text("SELECT set_config('app.bot_token_hash', :h, true)"), {"h": hash_token(token)}
)
row = (
await session.execute(select(BotChannel).where(BotChannel.token_hash == hash_token(token)))
).scalar_one_or_none()
if row is None:
raise AppError(ErrorCode.BOT_TOKEN_INVALID, "接入通道不存在或令牌已失效")
return row
@webhook_router.get("/clawbot/{token}")
async def clawbot_verify(
token: str, session: SessionDep, echostr: str | None = Query(default=None)
):
"""clawbot 后台通常先 GET 一次确认地址是活的:有 echostr 就原样回。"""
channel = await _channel_by_token(session, token)
if channel.status != "active":
raise AppError(ErrorCode.BOT_DISABLED, "这个通道已停用")
if echostr is not None:
return PlainTextResponse(echostr)
return ok({"channel": channel.name, "status": channel.status})
@webhook_router.post("/clawbot/{token}")
async def clawbot_message(
token: str,
request: Request,
session: SessionDep,
payload: Annotated[dict[str, Any] | None, Body()] = None,
):
channel = await _channel_by_token(session, token)
if channel.status != "active":
raise AppError(ErrorCode.BOT_DISABLED, "这个通道已停用")
raw: dict[str, Any] = payload if isinstance(payload, dict) else {}
text = extract_text(raw)
external_id = extract_external_id(raw)
sender = extract_sender(raw)
await bind_tenant(session, channel.workspace_id, channel.created_by)
# 重推:同一个外部消息 id 再来一次,回上次那句,不再建第二条。
if external_id:
previous = (
await session.execute(
select(BotMessage).where(
BotMessage.channel_id == channel.id,
BotMessage.external_id == external_id,
)
)
).scalar_one_or_none()
if previous is not None:
channel.last_seen_at = datetime.now(timezone.utc)
return ok({"reply": previous.reply, "action": previous.action, "duplicate": True})
message = BotMessage(
workspace_id=channel.workspace_id,
channel_id=channel.id,
external_id=external_id,
sender=sender,
text=text[:MAX_BODY],
action="ignored",
reply="",
raw=raw if isinstance(raw, dict) else {"value": raw},
)
if not text:
message.action = "ignored"
message.reply = "没收到正文,只收到一个空壳。"
session.add(message)
return ok({"reply": message.reply, "action": "ignored"})
kind, body = split_command(text)
creator_id = await _creator_id(session, channel)
if kind == "help":
message.action = "help"
message.reply = HELP_TEXT
else:
body, tags = split_tags(body)
title, rest = split_title(body)
if not title:
message.action = "ignored"
message.reply = "只有标签没有内容,这条没记。"
elif kind == "knowledge":
item = await _create_knowledge(
session,
ws_id=channel.workspace_id,
creator_id=creator_id,
title=title,
body=rest,
tags=tags,
)
message.action = "knowledge"
message.item_id = item.id
link = _link(request, channel.workspace_id, item)
message.reply = f"已记成知识草稿:《{item.title}》。去核对:{link}"
else:
item = await _create_task(
session,
ws_id=channel.workspace_id,
creator_id=creator_id,
title=title,
body=rest,
tags=tags,
)
message.action = "task"
message.item_id = item.id
message.reply = (
f"已记成任务:《{item.title}》。打开:{_link(request, channel.workspace_id, item)}"
)
channel.last_seen_at = datetime.now(timezone.utc)
channel.last_error = None
try:
# 用 SAVEPOINT 兜住唯一索引冲突:另一个并发请求刚插了同一条消息,
# 这里只要认输回同一句就行,不能把整笔事务打挂。
async with session.begin_nested():
session.add(message)
await session.flush()
except IntegrityError:
if external_id:
previous = (
await session.execute(
select(BotMessage).where(
BotMessage.channel_id == channel.id,
BotMessage.external_id == external_id,
)
)
).scalar_one_or_none()
if previous is not None:
return ok({"reply": previous.reply, "action": previous.action, "duplicate": True})
raise AppError(ErrorCode.BOT_TOKEN_INVALID, "同一条消息重复到达,已忽略") from None
return ok({"reply": message.reply, "action": message.action, "duplicate": False})
# ── 工作区里的管理接口(登录 + admin 以上) ─────────────────────────────
def render_qr_svg(payload: str) -> str:
"""把二维码内容画成 data URL:服务端画,前端不用再引一个二维码库。"""
image = qrcode.make(payload, image_factory=qrcode.image.svg.SvgPathImage, box_size=10)
buffer = io.BytesIO()
image.save(buffer)
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
return f"data:image/svg+xml;base64,{encoded}"
def _channel_out(channel: BotChannel) -> dict[str, Any]:
@@ -410,122 +65,152 @@ def _channel_out(channel: BotChannel) -> dict[str, Any]:
"kind": channel.kind,
"name": channel.name,
"status": channel.status,
"created_by": str(channel.created_by) if channel.created_by else None,
"connected": bool(channel.bot_token_enc) and channel.status == "active",
"polling": manager.running(channel.id),
"ilink_user_id": channel.ilink_user_id,
"last_seen_at": channel.last_seen_at,
"last_error": channel.last_error,
"created_at": channel.created_at,
}
def _webhook_url(request: Request, token: str) -> str:
"""拼出机器人要打的那个地址。
不能直接用 `request.base_url`:生产上应用挂在域名根、路由带 `/api/v1` 前缀,
base_url 里没有前缀,拼出来的地址会 405(本地被 Vite 代理掩盖过这个差异)。
所以站点根从 base_url 里截,前缀一律取 `settings.api_prefix`。
"""
base = str(request.base_url).split("/api/")[0].rstrip("/")
return f"{base}{get_settings().api_prefix}/integrations/clawbot/{token}"
async def _wechat_channel(session: SessionDep, ws_id: uuid.UUID) -> BotChannel | None:
rows = await session.execute(
select(BotChannel)
.where(BotChannel.workspace_id == ws_id, BotChannel.kind == "wechat_clawbot")
.order_by(BotChannel.created_at.desc())
)
return rows.scalars().first()
@admin_router.get("/{ws_id}/integrations")
async def list_integrations(ws_id: uuid.UUID, ctx: AdminDep, session: SessionDep):
rows = (
(
await session.execute(
select(BotChannel)
.where(BotChannel.workspace_id == ws_id)
.order_by(BotChannel.created_at.desc())
)
rows = await session.execute(
select(BotChannel)
.where(BotChannel.workspace_id == ws_id)
.order_by(BotChannel.created_at.desc())
)
return ok({"items": [_channel_out(c) for c in rows.scalars().all()]})
@admin_router.post("/{ws_id}/integrations/wechat/qr")
async def begin_wechat_login(ws_id: uuid.UUID, ctx: AdminDep, session: SessionDep):
"""取一张二维码。已经登录过的通道会被重置成 pending(换号 = 重新扫)。"""
try:
code = await wechat_ilink.get_qrcode()
except wechat_ilink.WechatError as exc:
raise AppError(ErrorCode.BOT_TOKEN_INVALID, f"取二维码失败:{exc}") from exc
channel = await _wechat_channel(session, ws_id)
if channel is None:
channel = BotChannel(
workspace_id=ws_id,
kind="wechat_clawbot",
name="微信",
status="pending",
created_by=ctx.user.id,
)
.scalars()
.all()
)
return ok({"items": [_channel_out(row) for row in rows]})
@admin_router.post("/{ws_id}/integrations")
async def create_integration(
ws_id: uuid.UUID,
ctx: AdminDep,
session: SessionDep,
request: Request,
body: Annotated[dict[str, Any] | None, Body()] = None,
):
name = str((body or {}).get("name") or "").strip()[:64] or "微信"
token = new_token()
channel = BotChannel(
workspace_id=ws_id,
kind="wechat_clawbot",
name=name,
token_hash=hash_token(token),
status="active",
created_by=ctx.user.id,
)
session.add(channel)
session.add(channel)
if channel.bot_token_enc:
await manager.stop(channel.id)
channel.bot_token_enc = None
channel.sync_buf = None
channel.context_token = None
channel.status = "pending"
channel.last_error = None
await session.flush()
await get_store().set(_qr_key(ws_id), code.qrcode_id, QR_TTL_SECONDS)
await session.commit()
return ok(
{
"channel": _channel_out(channel),
# 令牌只在这里出现一次,之后只能"重新生成"。
"token": token,
"webhook_url": _webhook_url(request, token),
"channel_id": str(channel.id),
"qrcode_id": code.qrcode_id,
"payload": code.payload,
"image": render_qr_svg(code.payload),
"expires_in": QR_TTL_SECONDS,
}
)
@admin_router.get("/{ws_id}/integrations/wechat/qr/{qrcode_id}")
async def poll_wechat_login(ws_id: uuid.UUID, qrcode_id: str, ctx: AdminDep, session: SessionDep):
"""问扫码结果。只有 confirmed 才落登录态并开轮询,其余状态原样回给页面。"""
cached = await get_store().get(_qr_key(ws_id))
if cached != qrcode_id:
# 页面拿着旧二维码在问:直接说"过期",让它重新取一张。
return ok({"status": "expired"})
try:
status = await wechat_ilink.qrcode_status(qrcode_id)
except wechat_ilink.WechatError as exc:
return ok({"status": "error", "message": str(exc)})
if status.status != "confirmed" or not status.bot_token:
return ok({"status": status.status})
channel = await _wechat_channel(session, ws_id)
if channel is None:
raise AppError(ErrorCode.BOT_TOKEN_INVALID, "通道不见了,请重新取二维码")
await bind_tenant(session, ws_id, ctx.user.id)
channel.bot_token_enc = wechat_ilink.encrypt_token(status.bot_token)
channel.ilink_user_id = status.ilink_user_id
channel.base_url = status.base_url
channel.status = "active"
channel.sync_buf = None
channel.context_token = None
channel.last_error = None
channel.created_by = channel.created_by or ctx.user.id
await session.commit()
await get_store().delete(_qr_key(ws_id))
await manager.start(channel.id)
return ok({"status": "confirmed", "channel": _channel_out(channel)})
@admin_router.patch("/{ws_id}/integrations/{channel_id}")
async def update_integration(
ws_id: uuid.UUID,
channel_id: uuid.UUID,
ctx: AdminDep,
session: SessionDep,
body: Annotated[dict[str, Any] | None, Body()] = None,
body: ChannelPatch,
):
channel = await session.get(BotChannel, channel_id)
if channel is None or channel.workspace_id != ws_id:
raise AppError(ErrorCode.BOT_TOKEN_INVALID, "接入通道不存在")
data = body or {}
if isinstance(data.get("name"), str):
channel.name = data["name"].strip()[:64]
if isinstance(data.get("status"), str):
if data["status"] not in ("active", "disabled"):
raise AppError(ErrorCode.VALIDATION_ERROR, "状态只能是 active 或 disabled")
channel.status = data["status"]
wanted = body.status.strip()
if wanted not in ("active", "disabled"):
raise AppError(ErrorCode.VALIDATION_FAILED, "status 只能是 active 或 disabled")
if wanted == "active" and not channel.bot_token_enc:
raise AppError(ErrorCode.BOT_TOKEN_INVALID, "这个通道还没扫码登录")
channel.status = wanted
await session.commit()
if wanted == "active":
await manager.start(channel.id)
else:
await manager.stop(channel.id)
return ok({"channel": _channel_out(channel)})
@admin_router.post("/{ws_id}/integrations/{channel_id}/rotate")
async def rotate_integration(
ws_id: uuid.UUID,
channel_id: uuid.UUID,
ctx: AdminDep,
session: SessionDep,
request: Request,
):
channel = await session.get(BotChannel, channel_id)
if channel is None or channel.workspace_id != ws_id:
raise AppError(ErrorCode.BOT_TOKEN_INVALID, "接入通道不存在")
token = new_token()
channel.token_hash = hash_token(token)
return ok(
{
"channel": _channel_out(channel),
"token": token,
"webhook_url": _webhook_url(request, token),
}
)
@admin_router.delete("/{ws_id}/integrations/{channel_id}")
async def delete_integration(
async def disconnect_integration(
ws_id: uuid.UUID, channel_id: uuid.UUID, ctx: AdminDep, session: SessionDep
):
"""断开:停轮询、清登录态。留痕(bot_message)不删,翻旧账用得上。"""
channel = await session.get(BotChannel, channel_id)
if channel is None or channel.workspace_id != ws_id:
raise AppError(ErrorCode.BOT_TOKEN_INVALID, "接入通道不存在")
await session.delete(channel)
return ok({"deleted": True})
await manager.stop(channel.id)
channel.bot_token_enc = None
channel.sync_buf = None
channel.context_token = None
channel.status = "pending"
channel.last_error = None
await session.commit()
return ok({"channel": _channel_out(channel)})
@admin_router.get("/{ws_id}/integrations/{channel_id}/messages")
@@ -534,36 +219,27 @@ async def list_messages(
channel_id: uuid.UUID,
ctx: AdminDep,
session: SessionDep,
limit: int = Query(default=20, ge=1, le=100),
limit: int = Query(default=5, ge=1, le=100),
):
rows = (
(
await session.execute(
select(BotMessage)
.where(BotMessage.workspace_id == ws_id, BotMessage.channel_id == channel_id)
.order_by(BotMessage.created_at.desc())
.limit(limit)
)
)
.scalars()
.all()
rows = await session.execute(
select(BotMessage)
.where(BotMessage.channel_id == channel_id, BotMessage.workspace_id == ws_id)
.order_by(BotMessage.created_at.desc())
.limit(limit)
)
return ok(
{
"items": [
{
"id": str(row.id),
"text": row.text,
"action": row.action,
"item_id": str(row.item_id) if row.item_id else None,
"reply": row.reply,
"sender": row.sender,
"created_at": row.created_at,
"id": str(m.id),
"action": m.action,
"sender": m.sender,
"text": m.text,
"reply": m.reply,
"item_id": str(m.item_id) if m.item_id else None,
"created_at": m.created_at,
}
for row in rows
for m in rows.scalars().all()
]
}
)
__all__ = ["webhook_router", "admin_router"]
+3
View File
@@ -145,6 +145,9 @@ class Settings(BaseSettings):
# 生产没有 nginx 时由后端直接托管前端构建产物(同源,/api 无跨域)。
# 指向 web/dist 的绝对路径;留空则只提供 API。
web_dist_dir: str | None = None
# 回复微信消息时用的站点地址(形如 http://192.168.8.188:8200,不带 /api/v1)。
# 留空则退回 cors_origins 的第一个,本机开发够用,生产务必显式配。
public_base_url: str | None = None
# ── 安卓安装包(OTA)──────────────────────────────────
# 放 latest.json + 对应 apk 的目录;留空则用 backend/static/apk。
+4 -1
View File
@@ -32,6 +32,7 @@ from .db import SessionFactory, current_app_role, engine
from .errors import AppError, ErrorCode, http_status_for
from .logging_setup import get_logger, request_id_ctx, setup_logging
from .middleware import IdempotencyMiddleware, RequestContextMiddleware, error_body
from .services.wechat_bot import manager as wechat_manager
settings = get_settings()
setup_logging("DEBUG" if settings.env == "dev" else "INFO")
@@ -50,7 +51,10 @@ async def lifespan(app: FastAPI):
"服务启动",
extra={"extra_fields": {"env": settings.env, "commit": settings.build_commit}},
)
# 已登录的微信通道在进程启动时接上,不然重启之后消息就静默收不到了。
await wechat_manager.start_all()
yield
await wechat_manager.shutdown()
await engine.dispose()
@@ -128,7 +132,6 @@ app.include_router(attachments.router, prefix=settings.api_prefix)
app.include_router(app_release.router, prefix=settings.api_prefix)
app.include_router(settings_api.router, prefix=settings.api_prefix)
app.include_router(integrations.admin_router, prefix=settings.api_prefix)
app.include_router(integrations.webhook_router, prefix=settings.api_prefix)
@app.get("/health", tags=["运维"], summary="存活探针")
+24 -7
View File
@@ -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")
+282
View File
@@ -0,0 +1,282 @@
"""微信机器人的后台轮询:登录之后由它把消息捞进来。
为什么是长轮询而不是 webhook:iLink Bot 协议就是"登录后由我们主动去拉",
服务端只在下一条消息到达时返回(最长等 75 秒)。所以每个通道一个常驻任务。
几条必须守住的规矩:
1. **游标要落库**:`get_updates_buf` 是下次从哪里接着读的凭据,丢了就会重复收、
把同一条任务记两遍。
2. **一条消息只处理一次**:`bot_message.external_id` 有唯一索引,重复投递时先撞唯一键。
3. **token 失效要停**:服务端 errcode `-14` 表示登录态作废,这时停轮询、把通道标记成
需要重新扫码,别拿死 token 一直撞墙。
4. **回复失败不影响已经落库的事项**:任务记下了就是记下了,回不回得去另说。
"""
from __future__ import annotations
import asyncio
import contextlib
import uuid
from typing import Any
from sqlalchemy import select
from sqlalchemy import text as sa_text
from ..config import get_settings
from ..db import SessionFactory, bind_tenant
from ..logging_setup import get_logger
from ..models import Workspace
from ..models_integrations import BotChannel, BotMessage
from . import wechat_ilink
from .wechat_intake import handle_text
logger = get_logger("wechat_bot")
# 长轮询一次最多等这么久(服务端 75 秒超时,客户端留点余量)。
POLL_TIMEOUT_SECONDS = 80.0
RETRY_DELAY_SECONDS = 5.0
def site_base() -> str:
"""回复里的链接用哪个域名。生产必须配 PUBLIC_BASE_URL,否则只能退回 CORS 里的第一个。"""
settings = get_settings()
if settings.public_base_url:
return settings.public_base_url.rstrip("/")
if settings.cors_origins:
return str(settings.cors_origins[0]).rstrip("/")
return ""
class BotManager:
"""一个进程里的轮询任务表:一个通道一个任务。"""
def __init__(self) -> None:
self._tasks: dict[uuid.UUID, asyncio.Task[None]] = {}
# ── 生命周期 ──────────────────────────────────────────
async def start(self, channel_id: uuid.UUID) -> None:
task = self._tasks.get(channel_id)
if task is not None and not task.done():
return
self._tasks[channel_id] = asyncio.create_task(
self._run(channel_id), name=f"wechat-bot-{channel_id}"
)
async def stop(self, channel_id: uuid.UUID) -> None:
task = self._tasks.pop(channel_id, None)
if task is None:
return
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
async def start_all(self) -> None:
"""进程启动时把已登录的通道都拉起来,不然重启就静默不收消息了。"""
async with SessionFactory() as session:
rows = await session.execute(
select(BotChannel.id).where(
BotChannel.status == "active",
BotChannel.bot_token_enc.is_not(None),
)
)
ids = [row[0] for row in rows]
for channel_id in ids:
await self.start(channel_id)
if ids:
logger.info("微信机器人已恢复", extra={"extra_fields": {"channels": len(ids)}})
async def shutdown(self) -> None:
for channel_id in list(self._tasks):
await self.stop(channel_id)
def running(self, channel_id: uuid.UUID) -> bool:
task = self._tasks.get(channel_id)
return task is not None and not task.done()
# ── 轮询主体 ──────────────────────────────────────────
async def _load(self, channel_id: uuid.UUID) -> tuple[BotChannel, str] | None:
"""读通道并解出 token。返回 None 表示这个通道不该再轮询了。"""
async with SessionFactory() as session:
channel = await self._get_channel(session, channel_id)
if channel is None or channel.status != "active" or not channel.bot_token_enc:
return None
try:
token = wechat_ilink.decrypt_token(channel.bot_token_enc)
except wechat_ilink.WechatError:
await self._mark_relogin(channel_id, "登录态解不开(JWT_SECRET 可能换过)")
return None
session.expunge(channel)
return channel, token
async def _run(self, channel_id: uuid.UUID) -> None:
loaded = await self._load(channel_id)
if loaded is None:
self._tasks.pop(channel_id, None)
return
channel, token = loaded
cursor = channel.sync_buf or ""
failures = 0
while True:
try:
update = await wechat_ilink.get_updates(
token, cursor, base_url=channel.base_url, timeout=POLL_TIMEOUT_SECONDS
)
except asyncio.CancelledError:
raise
except wechat_ilink.WechatError as exc:
if exc.stale_token:
await self._mark_relogin(channel_id, str(exc))
self._tasks.pop(channel_id, None)
return
failures += 1
await self._note_error(channel_id, str(exc))
await asyncio.sleep(RETRY_DELAY_SECONDS if failures < 10 else 60.0)
continue
except Exception as exc: # 轮询任务不能死,死了就永远收不到消息
failures += 1
logger.warning("微信长轮询异常:%s", exc)
await asyncio.sleep(RETRY_DELAY_SECONDS)
continue
failures = 0
for message in update.messages:
try:
await self._handle(channel_id, message, token, channel.base_url)
except Exception as exc: # 单条失败不拖垮整轮
logger.warning("微信消息处理失败:%s", exc)
if update.cursor and update.cursor != cursor:
cursor = update.cursor
await self._save_cursor(channel_id, cursor)
# ── 单条消息 ──────────────────────────────────────────
async def _handle(
self,
channel_id: uuid.UUID,
message: wechat_ilink.Inbound,
token: str,
base_url: str | None,
) -> None:
async with SessionFactory() as session:
channel = await self._get_channel(session, channel_id)
if channel is None or channel.status != "active":
return
# 读通道时只设了 app.bot_channel;workspace 表同样有 RLS,
# 得先把通道所属工作区告诉数据库,才能读到那一行。
await session.execute(
sa_text("SELECT set_config('app.ws_id', :ws, true)"),
{"ws": str(channel.workspace_id)},
)
workspace = await session.get(Workspace, channel.workspace_id)
if workspace is None:
return
actor = channel.created_by or workspace.owner_id
await bind_tenant(session, channel.workspace_id, actor)
existing = await session.execute(
select(BotMessage).where(
BotMessage.channel_id == channel_id,
BotMessage.external_id == message.message_id,
)
)
if existing.scalars().first() is not None:
logger.info("微信消息重复投递,跳过:%s", message.message_id)
return
result: dict[str, Any]
if message.text:
result = await handle_text(session, channel, message.text, base_url=site_base())
elif message.kinds:
result = {
"action": "ignored",
"item_id": None,
"reply": "图片、文件、语音暂时还没搬进来,发文字最快。",
}
else:
result = {"action": "ignored", "item_id": None, "reply": "没看懂这条消息。"}
session.add(
BotMessage(
workspace_id=channel.workspace_id,
channel_id=channel_id,
external_id=message.message_id,
sender=message.sender[:128],
text=message.text[:4000],
action=str(result["action"]),
item_id=result.get("item_id"),
reply=str(result.get("reply") or "")[:2000],
raw={"kinds": message.kinds, "message_id": message.message_id},
)
)
channel.last_seen_at = _now()
channel.context_token = message.context_token
channel.last_error = None
await session.commit()
reply = str(result.get("reply") or "")
if reply:
try:
await wechat_ilink.send_text(
token,
message.sender,
message.context_token,
reply,
base_url=base_url,
)
except wechat_ilink.WechatError as exc:
await self._note_error(channel_id, f"回复失败:{exc}")
async def _get_channel(self, session, channel_id: uuid.UUID) -> BotChannel | None:
"""按主键取通道。
轮询任务一开始没有工作区上下文,RLS 会把行藏起来;迁移 0014 给了一条
`app.bot_channel` 的按主键口子,所以先设 GUC 再读。
"""
await session.execute(
sa_text("SELECT set_config('app.bot_channel', :cid, true)"), {"cid": str(channel_id)}
)
return await session.get(BotChannel, channel_id)
# ── 落库小工具 ────────────────────────────────────────
async def _save_cursor(self, channel_id: uuid.UUID, cursor: str) -> None:
async with SessionFactory() as session:
channel = await self._get_channel(session, channel_id)
if channel is None:
return
channel.sync_buf = cursor[:4000]
await session.commit()
async def _note_error(self, channel_id: uuid.UUID, message: str) -> None:
async with SessionFactory() as session:
channel = await self._get_channel(session, channel_id)
if channel is None:
return
channel.last_error = message[:500]
await session.commit()
async def _mark_relogin(self, channel_id: uuid.UUID, message: str) -> None:
"""登录态作废:停用这条通道并记下原因,界面上提示重新扫码。"""
async with SessionFactory() as session:
channel = await self._get_channel(session, channel_id)
if channel is None:
return
channel.bot_token_enc = None
channel.sync_buf = None
channel.status = "expired"
channel.context_token = None
channel.last_error = message[:500]
await session.commit()
def _now():
from datetime import datetime, timezone
return datetime.now(timezone.utc)
manager = BotManager()
+314
View File
@@ -0,0 +1,314 @@
"""微信 clawbot 客户端(腾讯官方 iLink Bot 协议)。
协议不是我们自己猜的:OpenClaw 官方插件 `@tencent-weixin/openclaw-weixin` 用的就是它,
本机参考实现见 `~/.dsh/.../@michengai/dsh-im-connect/src/channels/weixin.ts` 的
`src/channels/weixin.ts`(文件头写着"扫码登录 → 长轮询收消息 → context_token 回复")。
流程:
1. `POST /ilink/bot/get_bot_qrcode?bot_type=3` 拿二维码 id 和内容,本地渲染成图给人扫;
2. `POST /ilink/bot/get_qrcode_status?qrcode=<id>` 每 2 秒问一次,`confirmed` 时给 `bot_token`;
3. 带 `Authorization: Bearer <bot_token>` 长轮询 `POST /ilink/bot/getupdates`,
body 里带游标 `get_updates_buf`,回来的新游标要存下来,不然重启会重复收;
4. 回复走 `POST /ilink/bot/sendmessage`,必须带对方上一条消息里的 `context_token`。
这里只封协议,不碰数据库;落库与建事项在 `services/wechat_bot.py`。
"""
from __future__ import annotations
import base64
import hashlib
import secrets
from dataclasses import dataclass, field
from typing import Any
import httpx
from cryptography.fernet import Fernet, InvalidToken
from ..config import get_settings
BASE_URL = "https://ilinkai.weixin.qq.com"
# 官方插件同款版本号;服务端会按它挑行为,别乱改。
CHANNEL_VERSION = "1.0.0"
# token 失效时服务端给的 errcode(官方插件叫 STALE_TOKEN_ERRCODE)。
STALE_TOKEN_ERRCODE = -14
QR_TIMEOUT_SECONDS = 15
STATUS_TIMEOUT_SECONDS = 40
SEND_TIMEOUT_SECONDS = 15
class WechatError(RuntimeError):
"""协议层错误。`kind` 用来区分"该重新扫码"还是"过一会儿再试"。"""
def __init__(self, message: str, *, kind: str = "api") -> None:
super().__init__(message)
self.kind = kind
@property
def stale_token(self) -> bool:
return self.kind == "stale_token"
@dataclass
class QrCode:
qrcode_id: str
payload: str
@dataclass
class QrStatus:
status: str
bot_token: str | None = None
ilink_user_id: str | None = None
base_url: str | None = None
@dataclass
class Inbound:
"""一条收到的消息:只取用得上的部分。"""
message_id: str
sender: str
text: str
context_token: str | None = None
kinds: list[str] = field(default_factory=list)
@dataclass
class Update:
cursor: str
messages: list[Inbound]
# ── 令牌加密 ─────────────────────────────────────────────
#
# bot_token 等于"这个微信号的登录态",明文落库等于把号交出去。
# 密钥由 JWT_SECRET 派生:换 JWT_SECRET 会让已存的令牌失效(重新扫码即可),这是可接受的代价。
def _fernet() -> Fernet:
digest = hashlib.sha256(get_settings().jwt_secret.encode("utf-8")).digest()
return Fernet(base64.urlsafe_b64encode(digest))
def encrypt_token(token: str) -> str:
return _fernet().encrypt(token.encode("utf-8")).decode("ascii")
def decrypt_token(stored: str) -> str:
try:
return _fernet().decrypt(stored.encode("ascii")).decode("utf-8")
except InvalidToken as exc: # JWT_SECRET 换过,或者库里的值被人改过
raise WechatError("微信登录态解不开,需要重新扫码", kind="stale_token") from exc
# ── 协议 ─────────────────────────────────────────────────
def _headers(token: str | None) -> dict[str, str]:
uin = base64.b64encode(str(secrets.randbelow(0xFFFFFFFF)).encode("utf-8")).decode("ascii")
headers = {
"content-type": "application/json",
"iLink-App-ClientVersion": "1",
"X-WECHAT-UIN": uin,
}
if token:
headers["Authorization"] = f"Bearer {token}"
headers["AuthorizationType"] = "ilink_bot_token"
return headers
async def _post(
path: str,
body: dict[str, Any],
*,
token: str | None = None,
timeout: float = 15.0,
tolerate_ret1: bool = False,
base_url: str | None = None,
) -> dict[str, Any]:
url = f"{(base_url or BASE_URL).rstrip('/')}{path}"
try:
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.post(url, json=body, headers=_headers(token))
except httpx.HTTPError as exc:
raise WechatError(f"微信接口连不上:{exc}") from exc
if response.status_code >= 400:
raise WechatError(f"微信接口 {path} 返回 HTTP {response.status_code}")
try:
data = response.json()
except ValueError as exc:
raise WechatError(f"微信接口 {path} 返回的不是 JSON") from exc
if not isinstance(data, dict):
raise WechatError(f"微信接口 {path} 返回的格式不对")
ret = int(data.get("ret") or 0)
errcode = int(data.get("errcode") or 0)
if tolerate_ret1 and ret == 1 and errcode == 0:
return data
if ret != 0 or errcode != 0:
if errcode == STALE_TOKEN_ERRCODE:
raise WechatError("微信登录态已失效,需要重新扫码", kind="stale_token")
raise WechatError(
f"微信接口 {path} 失败 ret={ret} errcode={errcode} {data.get('errmsg') or ''}".strip()
)
return data
async def get_qrcode() -> QrCode:
data = await _post("/ilink/bot/get_bot_qrcode?bot_type=3", {}, timeout=QR_TIMEOUT_SECONDS)
qrcode_id = _text(data, "qrcode", "qrcode_id")
payload = _text(data, "qrcode_img_content", "qrcode_url", "url")
if not qrcode_id or not payload:
raise WechatError("微信二维码字段不全,稍后再试")
return QrCode(qrcode_id=qrcode_id, payload=payload)
async def qrcode_status(qrcode_id: str) -> QrStatus:
data = await _post(
f"/ilink/bot/get_qrcode_status?qrcode={qrcode_id}",
{},
timeout=STATUS_TIMEOUT_SECONDS,
tolerate_ret1=True,
)
status = str(data.get("status") or "waiting")
if status != "confirmed":
return QrStatus(status=status)
token = _text(data, "bot_token")
if not token:
raise WechatError("微信确认了扫码,但没给登录态,请重新扫码")
return QrStatus(
status="confirmed",
bot_token=token,
ilink_user_id=_text(data, "ilink_user_id"),
base_url=_text(data, "baseurl", "base_url"),
)
async def get_updates(
token: str, cursor: str, *, base_url: str | None = None, timeout: float = 75.0
) -> Update:
data = await _post(
"/ilink/bot/getupdates",
{"get_updates_buf": cursor, "base_info": {"channel_version": CHANNEL_VERSION}},
token=token,
timeout=timeout,
base_url=base_url,
)
next_cursor = _text(data, "get_updates_buf", "cursor", "sync_buf") or cursor
raw = data.get("msgs") or data.get("messages") or data.get("updates") or []
messages: list[Inbound] = []
if isinstance(raw, list):
for item in raw:
parsed = parse_message(item)
if parsed is not None:
messages.append(parsed)
return Update(cursor=next_cursor, messages=messages)
async def send_text(
token: str,
to_user_id: str,
context_token: str | None,
text: str,
*,
base_url: str | None = None,
) -> None:
await _post(
"/ilink/bot/sendmessage",
{
"msg": {
"from_user_id": "",
"to_user_id": to_user_id,
"client_id": f"dadaotask:{secrets.token_hex(6)}",
"message_type": 2,
"message_state": 2,
"context_token": context_token or "",
"item_list": [{"type": 1, "text_item": {"text": text}}],
},
"base_info": {"channel_version": CHANNEL_VERSION},
},
token=token,
timeout=SEND_TIMEOUT_SECONDS,
base_url=base_url,
)
# ── 入站解析 ─────────────────────────────────────────────
def parse_message(raw: Any) -> Inbound | None:
"""把一条原始消息拆成"谁说的、说了什么"。
iLink 的形状是 `{from_user_id, message_id, context_token, item_list:[{type, text_item}]}`,
但偶尔会裹在 `message` 里,两种都认。只处理文本;语音有服务端转写就顺手用,
图片/文件/视频只记下类型,这一版不下载。
"""
if not isinstance(raw, dict):
return None
message = dict(raw)
nested = message.get("message")
if isinstance(nested, dict):
message.update(nested)
if message.get("message_type") is not None and int(message.get("message_type") or 0) != 1:
return None
sender = _text(message, "from_user_id", "from_user")
if not sender:
return None
parts: list[str] = []
kinds: list[str] = []
if isinstance(message.get("text"), str):
parts.append(str(message["text"]))
items = message.get("item_list")
if isinstance(items, list):
for item in items:
if not isinstance(item, dict):
continue
kind = int(item.get("type") or 0)
if kind == 1:
text_item = item.get("text_item")
if isinstance(text_item, dict):
value = _text(text_item, "text")
if value:
parts.append(value)
elif kind == 2:
kinds.append("image")
elif kind == 3:
voice = item.get("voice_item")
transcript = _text(voice, "text") if isinstance(voice, dict) else None
if transcript:
parts.append(f"[语音] {transcript}")
else:
kinds.append("voice")
elif kind == 4:
kinds.append("file")
elif kind == 5:
kinds.append("video")
message_id = (
_text(message, "message_id", "msg_id")
or f"{sender}:{_text(message, 'create_time_ms', 'create_time') or secrets.token_hex(4)}"
)
return Inbound(
message_id=message_id,
sender=sender,
text="".join(parts).strip(),
context_token=_text(message, "context_token"),
kinds=kinds,
)
def _text(data: Any, *keys: str) -> str | None:
if not isinstance(data, dict):
return None
for key in keys:
value = data.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
if isinstance(value, int | float) and not isinstance(value, bool):
return str(value)
return None
+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)),
}