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
+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)),
}