ForcePilot/backend/package/yuxi/channels/adapters/qqbot/inbound_pipeline.py
Kris 552aef767c feat(qqbot): 实现QQ机器人适配器完整功能模块
新增QQ Bot适配器完整代码栈,包含:
1. 基础适配器入口与工具类封装
2. 会话管理、重试队列与流量控制
3. 命令系统与内置指令(ping/help/status等)
4. 富媒体消息处理与格式转换
5. 引用存储与审批管理
6. 凭证备份与会话持久化
7. 健康检查与交互回调系统
2026-05-12 00:48:04 +08:00

385 lines
13 KiB
Python

from __future__ import annotations
import logging
import re
import time
import uuid
from yuxi.channels.pipeline.base import BaseInboundPipeline, PipelineStage
from yuxi.channels.pipeline.context import PipelineContext
from .media_tags import parse_media_tags, has_media_tags
logger = logging.getLogger(__name__)
_QQ_EMOJI_RE = re.compile(r"<emoji:(\d+)>")
_QQ_FACE_RE = re.compile(r"<face:(\d+)>")
QQ_EMOJI_MAP: dict[int, str] = {
0: "[微笑]",
1: "[撇嘴]",
2: "[色]",
3: "[发呆]",
4: "[得意]",
5: "[流泪]",
6: "[害羞]",
7: "[闭嘴]",
8: "[睡]",
9: "[大哭]",
10: "[尴尬]",
11: "[发怒]",
12: "[调皮]",
13: "[呲牙]",
14: "[惊讶]",
15: "[难过]",
16: "[酷]",
17: "[冷汗]",
18: "[抓狂]",
19: "[吐]",
20: "[偷笑]",
21: "[可爱]",
22: "[白眼]",
23: "[傲慢]",
24: "[饥饿]",
25: "[困]",
26: "[惊恐]",
27: "[流汗]",
28: "[憨笑]",
29: "[悠闲]",
30: "[奋斗]",
31: "[咒骂]",
32: "[疑问]",
33: "[嘘]",
34: "[晕]",
35: "[疯了]",
36: "[衰]",
37: "[骷髅]",
38: "[敲打]",
39: "[再见]",
40: "[擦汗]",
41: "[抠鼻]",
42: "[鼓掌]",
43: "[糗大了]",
44: "[坏笑]",
45: "[左哼哼]",
46: "[右哼哼]",
47: "[哈欠]",
48: "[鄙视]",
49: "[委屈]",
50: "[快哭了]",
51: "[阴险]",
52: "[亲亲]",
53: "[吓]",
54: "[可怜]",
55: "[菜刀]",
56: "[西瓜]",
57: "[啤酒]",
58: "[篮球]",
59: "[乒乓]",
60: "[咖啡]",
61: "[饭]",
62: "[猪头]",
63: "[玫瑰]",
64: "[凋谢]",
65: "[嘴唇]",
66: "[爱心]",
67: "[心碎]",
68: "[蛋糕]",
69: "[闪电]",
70: "[炸弹]",
71: "[刀]",
72: "[足球]",
73: "[瓢虫]",
74: "[便便]",
75: "[月亮]",
76: "[太阳]",
77: "[礼物]",
78: "[拥抱]",
79: "[强]",
80: "[弱]",
81: "[握手]",
82: "[胜利]",
83: "[抱拳]",
84: "[勾引]",
85: "[拳头]",
86: "[差劲]",
87: "[爱你]",
88: "[NO]",
89: "[OK]",
90: "[爱情]",
91: "[飞吻]",
92: "[跳跳]",
93: "[发抖]",
94: "[怄火]",
95: "[转圈]",
96: "[磕头]",
97: "[回头]",
98: "[跳绳]",
99: "[投降]",
}
def parse_qq_emojis(text: str) -> str:
def _emoji_replacer(m: re.Match) -> str:
code = int(m.group(1))
return QQ_EMOJI_MAP.get(code, f"[表情:{code}]")
text = _QQ_EMOJI_RE.sub(_emoji_replacer, text)
text = _QQ_FACE_RE.sub(_emoji_replacer, text)
return text
_MENTION_RE = re.compile(r"<@!\w+>|@\S+\s?", re.UNICODE)
def strip_bot_mentions(content: str, bot_names: list[str] | None = None) -> tuple[str, bool]:
stripped = _MENTION_RE.sub("", content).strip()
was_stripped = stripped != content.strip()
return stripped, was_stripped
class QQBotInboundPipeline(BaseInboundPipeline):
async def _build_stages(self) -> list[PipelineStage]:
return [
self._dedup,
self._normalize,
self._extract_content,
self._access_policy,
self._content_check,
self._context_fill,
self._dispatch,
]
async def _dedup(self, pipeline: BaseInboundPipeline, ctx: PipelineContext) -> PipelineContext | None:
msg_id = ctx.msg_id or ctx.event_data.get("id", "")
if not msg_id:
return ctx
adapter = pipeline.adapter
dedup_cache = getattr(adapter, "_recent_msg_ids", None)
dedup_window = getattr(adapter, "_dedup_window_s", 60)
if dedup_cache is not None:
now = time.monotonic()
self._prune_dedup_cache(dedup_cache, dedup_window, now)
if msg_id in dedup_cache:
logger.debug("Dedup: %s already processed, skipping", msg_id)
ctx.stop("dedup_duplicate")
return None
dedup_cache[msg_id] = now
return ctx
@staticmethod
def _prune_dedup_cache(cache: dict[str, float], window: int, now: float) -> None:
expired = [mid for mid, ts in cache.items() if now - ts >= window]
for mid in expired:
del cache[mid]
@staticmethod
def _buffer_group_message(pipeline: BaseInboundPipeline, event_data: dict, ctx: PipelineContext) -> None:
adapter = pipeline.adapter
group_buffer = getattr(adapter, "_group_buffer", None)
if group_buffer is None:
return
group_id = event_data.get("group_openid", event_data.get("group_id", ""))
if not group_id:
return
from .group_buffer import GroupMessage
msg = GroupMessage(
msg_id=event_data.get("id", ""),
author_id=ctx.sender_id,
author_name=ctx.sender_name or "",
content=ctx.content or "",
timestamp=time.time(),
mentions_bot=ctx.metadata.get("bot_mentioned", True),
)
group_buffer.record(group_id, msg)
async def _normalize(self, pipeline: BaseInboundPipeline, ctx: PipelineContext) -> PipelineContext | None:
event_data = ctx.event_data
event_type = ctx.event_type
if event_type == "C2C_MESSAGE_CREATE":
author = event_data.get("author", {})
ctx.chat_type = "dm"
ctx.chat_id = author.get("id", "")
ctx.sender_id = author.get("id", "")
ctx.sender_name = author.get("username", "")
ctx.msg_id = event_data.get("id", "")
content_obj = event_data.get("content", "")
ctx.content = content_obj if isinstance(content_obj, str) else ""
self._extract_reply_info(event_data, ctx)
elif event_type == "GROUP_AT_MESSAGE_CREATE":
ctx.chat_type = "group"
ctx.chat_id = event_data.get("group_openid", event_data.get("group_id", ""))
author = event_data.get("author", {})
ctx.sender_id = author.get("member_openid", author.get("id", ""))
ctx.sender_name = author.get("username", "")
ctx.msg_id = event_data.get("id", "")
content_obj = event_data.get("content", "")
ctx.content = content_obj if isinstance(content_obj, str) else ""
ctx.metadata["group_openid"] = event_data.get("group_openid", "")
ctx.metadata["bot_mentioned"] = True
self._extract_reply_info(event_data, ctx)
self._buffer_group_message(pipeline, event_data, ctx)
elif event_type == "GROUP_MESSAGE_CREATE":
ctx.chat_type = "group"
ctx.chat_id = event_data.get("group_openid", event_data.get("group_id", ""))
author = event_data.get("author", {})
ctx.sender_id = author.get("member_openid", author.get("id", ""))
ctx.sender_name = author.get("username", "")
ctx.msg_id = event_data.get("id", "")
content_obj = event_data.get("content", "")
ctx.content = content_obj if isinstance(content_obj, str) else ""
ctx.metadata["group_openid"] = event_data.get("group_openid", "")
ctx.metadata["bot_mentioned"] = False
ctx.stop("group_message_no_mention")
self._buffer_group_message(pipeline, event_data, ctx)
return None
elif event_type == "INTERACTION_CREATE":
interaction_data = event_data.get("data", {})
reply = interaction_data.get("resolved", {}).get("message_interaction", {})
button_data = reply.get("button_data", interaction_data.get("button_data", ""))
button_id = reply.get("button_id", interaction_data.get("button_id", ""))
feature_name = reply.get("feature_name", interaction_data.get("feature_name", ""))
ctx.chat_type = "interaction"
ctx.chat_id = event_data.get("chat_id", "")
ctx.sender_id = event_data.get("user_openid", event_data.get("user_id", ""))
ctx.msg_id = event_data.get("id", "")
ctx.content = interaction_data.get("name", "")
ctx.metadata["interaction_id"] = event_data.get("id", "")
ctx.metadata["feature_id"] = reply.get("feature_id", "")
ctx.metadata["button_data"] = button_data
ctx.metadata["button_id"] = button_id
ctx.metadata["feature_name"] = feature_name
elif event_type == "DIRECT_MESSAGE_CREATE":
ctx.chat_type = "dm"
ctx.chat_id = event_data.get("guild_id", "")
author = event_data.get("author", {})
ctx.sender_id = author.get("id", "")
ctx.sender_name = author.get("username", "")
ctx.msg_id = event_data.get("id", "")
content_obj = event_data.get("content", "")
ctx.content = content_obj if isinstance(content_obj, str) else ""
self._extract_reply_info(event_data, ctx)
elif event_type == "AT_MESSAGE_CREATE":
ctx.chat_type = "group"
ctx.chat_id = event_data.get("guild_id", "")
author = event_data.get("author", {})
ctx.sender_id = author.get("id", "")
ctx.sender_name = author.get("username", "")
ctx.msg_id = event_data.get("id", "")
content_obj = event_data.get("content", "")
ctx.content = content_obj if isinstance(content_obj, str) else ""
self._extract_reply_info(event_data, ctx)
else:
adapter = pipeline.adapter
try:
msg = adapter.normalize_inbound({"event_type": event_type, "event": event_data})
ctx.msg_id = msg.identity.channel_message_id
ctx.sender_id = msg.identity.channel_user_id
ctx.chat_id = msg.identity.channel_chat_id
ctx.content = msg.content or ""
ctx.chat_type = msg.chat_type.value
ctx.metadata["qq_chat_type"] = msg.chat_type.value
except Exception:
ctx.stop("unknown_event_type")
return None
ctx.metadata["received_at"] = time.time()
ctx.metadata["event_id"] = ctx.msg_id or str(uuid.uuid4())
return ctx
@staticmethod
def _extract_reply_info(event_data: dict, ctx: PipelineContext) -> None:
msg_elements = event_data.get("msg_elements", [])
if not msg_elements:
return
for element in msg_elements:
if not isinstance(element, dict):
continue
if element.get("type") != "reply":
continue
reply_data = element.get("reply_element") or element.get("reply", {})
if not reply_data:
continue
quoted_author = reply_data.get("author", {})
ctx.metadata["quoted_content"] = reply_data.get("content", "")
ctx.metadata["quoted_author_id"] = quoted_author.get("id") or quoted_author.get("member_openid", "")
ctx.metadata["quoted_author_name"] = quoted_author.get("username", "")
ctx.metadata["quoted_msg_id"] = reply_data.get("id", "")
break
async def _extract_content(self, pipeline: BaseInboundPipeline, ctx: PipelineContext) -> PipelineContext | None:
raw = ctx.content
if raw and isinstance(raw, str):
if has_media_tags(raw):
parsed = parse_media_tags(raw)
ctx.content = parsed.text
ctx.metadata["inline_media"] = [
{"type": m.media_type, "reference": m.reference, "is_url": m.is_url} for m in parsed.media_items
]
else:
ctx.content = raw.strip()
ctx.content = parse_qq_emojis(ctx.content)
if ctx.chat_type == "group" and ctx.content:
ctx.content, stripped = strip_bot_mentions(ctx.content)
if stripped:
ctx.metadata["mention_stripped"] = True
return ctx
async def _access_policy(self, pipeline: BaseInboundPipeline, ctx: PipelineContext) -> PipelineContext | None:
adapter = pipeline.adapter
security = getattr(adapter, "_security", None)
if security is None:
return ctx
if ctx.chat_type == "dm" or ctx.chat_type == "direct":
result = security.check_dm_access(ctx.sender_id)
if not result.allowed:
ctx.stop(f"access_dm_{result.reason}")
return None
elif ctx.chat_type == "group":
group_id = ctx.chat_id.replace("group_", "")
result = security.check_group_access(group_id)
if not result.allowed:
ctx.stop(f"access_group_{result.reason}")
return None
return ctx
async def _content_check(self, pipeline: BaseInboundPipeline, ctx: PipelineContext) -> PipelineContext | None:
netloc = ctx.content.strip()
if netloc and len(netloc) > 8000:
ctx.content = netloc[:8000]
return ctx
async def _context_fill(self, pipeline: BaseInboundPipeline, ctx: PipelineContext) -> PipelineContext | None:
ctx.metadata["pipeline_version"] = "qqbot_v2"
ctx.metadata["processed_at"] = time.time()
return ctx
async def _dispatch(self, pipeline: BaseInboundPipeline, ctx: PipelineContext) -> PipelineContext | None:
adapter = pipeline.adapter
handler = getattr(adapter, "_on_pipeline_dispatch", None)
if handler:
await handler(ctx)
return ctx