ForcePilot/backend/package/yuxi/channels/adapters/qqbot/format.py
Kris ef5483dc1a refactor(qqbot): 重构QQ机器人适配器代码,优化多项功能与结构
主要变更:
1. 修复速率限流器使用setdefault替代重复创建令牌桶
2. 重构交互注册表匹配逻辑,优化精确匹配查找
3. 重构去重缓存逻辑,移到适配器实例方法
4. 重构发送URL解析,增加合法性校验并拆分公共方法
5. 优化流式消息处理逻辑,简化flush_controller调用
6. 重构群聊类型判断代码,简化语法
7. 修复重连管理器对None类型关闭分类的处理
8. 新增消息缓存、线程模拟器、发送初始化模块
9. 重构凭证备份与会话存储逻辑,支持环境变量指定状态目录
10. 新增配置提示与向导二维码绑定功能
11. 优化媒体上传逻辑,增加重试机制与缓存
12. 新增审批键盘模板构建函数
13. 重构消息格式处理,修正媒体发送字段与长度限制
14. 修复令牌过期时间计算,使用time.time替代monotonic
15. 新增群组激活缓冲区与用户追踪器增强功能
16. 修复换行符问题,统一文件结尾格式
2026-05-13 16:13:48 +08:00

262 lines
8.4 KiB
Python

from __future__ import annotations
from yuxi.channels.models import ChannelResponse
from .constants import DM_CHAT_PREFIX, GROUP_CHAT_PREFIX
TEXT_CONTENT_LIMIT = 2000
MARKDOWN_CONTENT_LIMIT = 4096
EMBED_DESCRIPTION_LIMIT = 4096
EMBED_PROMPT_LIMIT = 200
EMBED_TITLE_LIMIT = 200
def build_text_payload(response: ChannelResponse) -> dict:
content = response.content[:TEXT_CONTENT_LIMIT]
chat_id = response.identity.channel_chat_id
payload: dict = {"content": content}
payload["msg_type"] = 0
if chat_id.startswith(GROUP_CHAT_PREFIX):
payload["group_openid"] = chat_id.replace(GROUP_CHAT_PREFIX, "")
elif not chat_id.startswith(DM_CHAT_PREFIX):
payload["channel_id"] = chat_id
if response.reply_to_message_id:
payload["msg_id"] = response.reply_to_message_id
return payload
def build_markdown_payload(response: ChannelResponse, template_id: str | None = None) -> dict:
md_template_id = response.metadata.get("markdown_template_id") or template_id
if md_template_id:
return {
"msg_type": 2,
"markdown": {
"template_id": md_template_id,
"params": [
{"key": "title", "values": [response.metadata.get("title", "")]},
{"key": "content", "values": [response.content[:MARKDOWN_CONTENT_LIMIT]]},
],
},
}
return {
"msg_type": 2,
"markdown": {
"content": response.content[:MARKDOWN_CONTENT_LIMIT],
},
}
def build_ark_payload(response: ChannelResponse) -> dict:
ark_template_id = response.metadata.get("ark_template_id")
ark_data = response.metadata.get("ark_data", {})
return {
"msg_type": 3,
"ark": {
"template_id": ark_template_id,
"kv": [{"key": k, "value": v} for k, v in ark_data.items()],
},
}
def build_embed_payload(response: ChannelResponse) -> dict:
embed_data = response.metadata.get("embed", {})
return {
"msg_type": 4,
"embed": {
"title": embed_data.get("title", "")[:EMBED_TITLE_LIMIT],
"description": response.content[:EMBED_DESCRIPTION_LIMIT],
"prompt": embed_data.get("prompt", response.content[:EMBED_PROMPT_LIMIT]),
"fields": embed_data.get("fields", []),
},
}
def build_media_generic_payload(response: ChannelResponse, file_id: str, msg_type: int = 7) -> dict:
chat_id = response.identity.channel_chat_id
payload: dict = {
"msg_type": msg_type,
"media": {"file_info": file_id},
}
if response.content:
payload["content"] = response.content[:TEXT_CONTENT_LIMIT]
if chat_id.startswith(GROUP_CHAT_PREFIX):
payload["group_openid"] = chat_id.replace(GROUP_CHAT_PREFIX, "")
elif not chat_id.startswith(DM_CHAT_PREFIX):
payload["channel_id"] = chat_id
return payload
def build_image_payload(response: ChannelResponse, file_id: str) -> dict:
chat_id = response.identity.channel_chat_id
payload: dict = {
"msg_type": 1,
"file_image": file_id,
}
if response.content:
payload["content"] = response.content[:TEXT_CONTENT_LIMIT]
if chat_id.startswith(GROUP_CHAT_PREFIX):
payload["group_openid"] = chat_id.replace(GROUP_CHAT_PREFIX, "")
elif not chat_id.startswith(DM_CHAT_PREFIX):
payload["channel_id"] = chat_id
return payload
def format_outbound(
response: ChannelResponse,
use_markdown: bool = False,
markdown_template_id: str | None = None,
) -> dict:
msg_type = response.metadata.get("qq_msg_type", "")
chat_id = response.identity.channel_chat_id
if msg_type == "markdown" or (use_markdown and not msg_type):
return build_markdown_payload(response, markdown_template_id)
elif msg_type == "ark" and response.metadata.get("ark_template_id"):
return build_ark_payload(response)
elif msg_type == "embed":
if chat_id.startswith(DM_CHAT_PREFIX) or chat_id.startswith(GROUP_CHAT_PREFIX):
return build_markdown_payload(response, markdown_template_id)
return build_embed_payload(response)
elif msg_type == "image" and response.attachments:
file_id = response.attachments[0].file_id or response.attachments[0].url or ""
return build_image_payload(response, file_id)
elif msg_type in ("voice", "video", "file") and response.attachments:
file_id_list = [
response.metadata.get("media_file_id", ""),
response.attachments[0].file_id or "",
response.attachments[0].url or "",
]
file_id = next((fid for fid in file_id_list if fid), "")
return build_media_generic_payload(response, file_id, msg_type=7)
else:
return build_text_payload(response)
class MarkdownChunker:
MAX_CHARS = 4096
CHUNK_OVERLAP = 200
def __init__(self, max_chars: int = MAX_CHARS, chunk_overlap: int = CHUNK_OVERLAP):
self._max_chars = max_chars
self._chunk_overlap = chunk_overlap
def chunk(self, text: str) -> list[str]:
if len(text) <= self._max_chars:
return [text]
paragraphs = self._split_paragraphs(text)
chunks: list[str] = []
current_chunk: list[str] = []
current_len = 0
for para in paragraphs:
para_len = len(para)
if current_len + para_len <= self._max_chars:
current_chunk.append(para)
current_len += para_len
else:
if current_chunk:
chunks.append("".join(current_chunk))
if para_len > self._max_chars:
sub_chunks = self._force_split(para)
if current_chunk:
for i, sc in enumerate(sub_chunks):
chunks.append(sc)
else:
chunks.extend(sub_chunks)
current_chunk = []
current_len = 0
else:
current_chunk = [para]
current_len = para_len
if current_chunk:
chunks.append("".join(current_chunk))
return chunks
def _split_paragraphs(self, text: str) -> list[str]:
sections: list[str] = []
in_code_block = False
current: list[str] = []
lines = text.splitlines(keepends=True)
for line in lines:
stripped = line.strip()
if stripped.startswith("```"):
if current:
sections.append("".join(current))
current = []
if in_code_block:
sections.append(line)
in_code_block = False
else:
in_code_block = True
current.append(line)
continue
if in_code_block:
current.append(line)
if stripped.endswith("```"):
sections.append("".join(current))
current = []
in_code_block = False
continue
if not stripped:
if current:
sections.append("".join(current))
current = []
sections.append(line)
elif (
stripped.startswith(("#", "-", "*", ">", "|"))
and current
and not current[-1].strip().startswith(("#", "-", "*", ">", "|", "1.", "2.", "3."))
):
if current:
sections.append("".join(current))
current = []
current.append(line)
else:
current.append(line)
if current:
sections.append("".join(current))
result: list[str] = []
buffer: list[str] = []
for s in sections:
stripped = s.strip()
if not stripped and buffer:
result.append("".join(buffer))
buffer = []
buffer.append(s)
if buffer:
content = "".join(buffer)
if content.strip():
result.append(content)
return result or [text]
def _force_split(self, text: str) -> list[str]:
chunks: list[str] = []
for i in range(0, len(text), self._max_chars - self._chunk_overlap):
chunks.append(text[i : i + self._max_chars])
return chunks