Files
DadaoTask/backend/app/services/wechat_intake.py
T
Steven 1e8cd01b3d
CI / backend (push) Canceled after 0s
fix(integrations): 扫码确认后不再 500,回话不带链接,栏目改叫「机器人」
1. 扫码确认那一刻页面报「服务器内部错误」:`bot_channel` 的轮询口子直接
   `current_setting('app.bot_channel', true)::uuid`,而事务级 set_config 提交后
   会把连接上的变量还原成空串而不是 NULL,空串 cast 就 InvalidTextRepresentation。
   迁移 0015 改成 nullif(...,'')::uuid,与 app_ws_id() 同一写法。
2. 微信回话只回「已保存任务:…」/「已保存知识草稿:…」,不再带站点链接
   (PUBLIC_BASE_URL 是内网地址,手机上打不开)。
3. 栏目名「接入」改名「机器人」;页面标题与提示文案同步。

pytest 151 passed;ruff check/format 干净;tsc 0;vite build 通过;
web/e2e/integrations.mjs 7 项全绿。
2026-11-05 23:22:49 +08:00

223 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""把一句话变成任务或知识草稿。
微信只负责把文本送进来,怎么落成事项是这里的事,接口和后台轮询共用同一份逻辑,
免得"网页里发"和"机器人里发"落成两种东西。
"""
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..errors import AppError, ErrorCode
from ..models import Member, Workspace
from ..models_integrations import BotChannel
from ..models_knowledge import KnowledgeDoc
from ..models_task import Item
MAX_TITLE = 180
MAX_BODY = 20000
KNOWLEDGE_PREFIXES = ("知识", "笔记", "note", "knowledge", "kb")
TASK_PREFIXES = ("任务", "待办", "todo", "task")
HELP_PREFIXES = ("?", "?", "帮助", "help", "用法")
HELP_TEXT = (
"发什么都能用,写法:\n"
"· 直接说 → 记成任务\n"
"· 知识:内容 → 记成知识草稿(也可以写「笔记:」)\n"
"· 任务:内容 → 明确记成任务\n"
"首行当标题,后面几行当正文;标题里带 #标签 会自动加上。"
)
SEPARATORS = (":", ":", " ", "\u3000")
def split_command(text: str) -> tuple[str, str]:
"""返回 (kind, 去掉前缀后的正文)。"""
head, _, rest = text.partition("\n")
probe = head.strip().lower()
for prefix in HELP_PREFIXES:
if probe == prefix or probe.startswith(f"{prefix}:") or probe.startswith(f"{prefix}:"):
return "help", rest
for prefix in KNOWLEDGE_PREFIXES:
for sep in SEPARATORS:
if probe.startswith(f"{prefix}{sep}"):
offset = len(prefix) + len(sep)
return "knowledge", (head.strip()[offset:] + ("\n" + rest if rest else "")).strip()
for prefix in TASK_PREFIXES:
for sep in SEPARATORS:
if probe.startswith(f"{prefix}{sep}"):
offset = len(prefix) + len(sep)
return "task", (head.strip()[offset:] + ("\n" + rest if rest else "")).strip()
return "task", text
def split_tags(body: str) -> tuple[str, list[str]]:
"""把 `#标签` 摘出来。只认行首和空白后的 #,免得把 "C#" 吃掉。"""
tags: list[str] = []
kept: list[str] = []
for line in body.splitlines():
rest: list[str] = []
for word in line.split(" "):
if word.startswith("#") and len(word) > 1 and not word[1:].isdigit():
name = word[1:].strip(",,。.;;")
if name and name.lower() not in {t.lower() for t in tags}:
tags.append(name)
continue
rest.append(word)
kept.append(" ".join(rest).rstrip())
return "\n".join(kept).strip(), tags
def split_title(body: str) -> tuple[str, str]:
lines = body.splitlines()
if not lines:
return "", ""
return lines[0].strip()[:MAX_TITLE], "\n".join(lines[1:]).strip()[:MAX_BODY]
async def creator_id(session: AsyncSession, channel: BotChannel) -> uuid.UUID:
"""消息记在谁名下:通道创建者;人没了就退回工作区 owner。"""
if channel.created_by is not None:
row = await session.execute(
select(Member.id).where(
Member.workspace_id == channel.workspace_id,
Member.user_id == channel.created_by,
Member.status == "active",
)
)
if row.first() is not None:
return channel.created_by
workspace = await session.get(Workspace, channel.workspace_id)
if workspace is None: # 理论上不可能:FK 是 CASCADE
raise AppError(ErrorCode.BOT_TOKEN_INVALID, "接入通道不存在")
return workspace.owner_id
async def create_task(
session: AsyncSession,
*,
ws_id: uuid.UUID,
creator: uuid.UUID,
title: str,
body: str,
tags: list[str],
) -> Item:
item = Item(
workspace_id=ws_id,
kind="task",
title=title,
body_md=body,
status="inbox",
priority=0,
creator_id=creator,
)
session.add(item)
await session.flush()
from ..api.items import _log, _set_tags # 延迟导入:services 不该在模块级依赖 api
if tags:
await _set_tags(session, ws_id, item, tags)
await _log(session, item, creator, "created", {"via": "wechat_clawbot"})
return item
async def create_knowledge(
session: AsyncSession,
*,
ws_id: uuid.UUID,
creator: uuid.UUID,
title: str,
body: str,
tags: list[str],
) -> Item:
item = Item(
workspace_id=ws_id,
kind="knowledge",
title=title,
body_md=body,
status="draft",
creator_id=creator,
)
session.add(item)
await session.flush()
session.add(
KnowledgeDoc(
item_id=item.id,
workspace_id=ws_id,
author_id=creator,
source_item_id=None,
source_kind="manual",
doc_type="note",
status_hint="draft",
generated_by="human",
)
)
await session.flush()
from ..api.items import _log, _set_tags # 延迟导入,理由同上
if tags:
await _set_tags(session, ws_id, item, tags)
await _log(session, item, creator, "knowledge_created", {"via": "wechat_clawbot"})
return item
def reply_text(kind: str, item: Item) -> str:
"""回话只说"收下了",不带链接。
链接是站点地址,可"站点地址"对手机不一定打得开(内网 IP、端口、还没上 HTTPS 的域名),
发过去反而像坏消息。要看详情回网页里看就行。
"""
if kind == "knowledge":
return f"已保存知识草稿:{item.title}"
return f"已保存任务:{item.title}"
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 = 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),
}