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"]