上一版按"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:
+156
-480
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user