新增QQ Bot适配器完整代码栈,包含: 1. 基础适配器入口与工具类封装 2. 会话管理、重试队列与流量控制 3. 命令系统与内置指令(ping/help/status等) 4. 富媒体消息处理与格式转换 5. 引用存储与审批管理 6. 凭证备份与会话持久化 7. 健康检查与交互回调系统
251 lines
7.9 KiB
Python
251 lines
7.9 KiB
Python
from __future__ import annotations
|
|
|
|
from yuxi.channels.models import ChannelResponse
|
|
|
|
from .constants import DM_CHAT_PREFIX, GROUP_CHAT_PREFIX
|
|
|
|
|
|
def build_text_payload(response: ChannelResponse) -> dict:
|
|
content = response.content[:2000]
|
|
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[:4096]]},
|
|
],
|
|
},
|
|
}
|
|
|
|
return {
|
|
"msg_type": 2,
|
|
"markdown": {
|
|
"content": response.content[:4096],
|
|
},
|
|
}
|
|
|
|
|
|
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", ""),
|
|
"description": response.content[:4096],
|
|
"prompt": embed_data.get("prompt", response.content[:200]),
|
|
"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[:2000]
|
|
|
|
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,
|
|
"image": file_id,
|
|
"content": response.content[:2000] if response.content else "",
|
|
}
|
|
|
|
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", "")
|
|
|
|
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":
|
|
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 = 5000
|
|
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
|