ForcePilot/backend/package/yuxi/channels/adapters/qqbot/inbound_pipeline.py
Kris 1f78c44b03 refactor: 整理并清理项目中的冗余代码与格式问题
这是一个批量整理提交,包含以下主要改动:
1.  删除多处冗余的空行和未使用的导入
2.  修复文件末尾缺少换行符的问题
3.  调整部分模块的导入顺序与代码排版
4.  修复部分配置默认值与策略逻辑
5.  新增多个功能模块与辅助工具
6.  完善异常处理与日志记录
7.  修复速率限制、消息缓存、权限校验等逻辑bug
8.  废弃部分旧有API与配置项并添加警告提示
2026-05-12 14:51:53 +08:00

411 lines
14 KiB
Python

from __future__ import annotations
import logging
import re
import time
import uuid
from yuxi.channels.exceptions import MessageFormatError
from yuxi.channels.pipeline.base import BaseInboundPipeline, PipelineStage
from yuxi.channels.pipeline.context import PipelineContext
from .media_tags import has_media_tags, parse_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 MessageFormatError:
ctx.stop("unknown_event_type")
return None
except Exception:
logger.exception("[QQBot] Unexpected error normalizing event type=%s", event_type)
ctx.stop("normalize_error")
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
content = ctx.content or ""
is_command = content.startswith("/")
command_name = ""
if is_command:
parts = content[1:].strip().split(maxsplit=1)
command_name = parts[0].lower() if parts else ""
mentions_bot = ctx.metadata.get("bot_mentioned", False)
has_other_mentions = False
gate_ok, gate_reason = security.check_group_message_gate(
chat_id=ctx.chat_id,
content=content,
mentions_bot=mentions_bot,
has_other_mentions=has_other_mentions,
is_command=is_command,
command_name=command_name,
)
if not gate_ok:
ctx.stop(f"group_gate_{gate_reason}")
return None
ctx.metadata["group_gate_reason"] = gate_reason
return ctx
async def _content_check(self, pipeline: BaseInboundPipeline, ctx: PipelineContext) -> PipelineContext | None:
if ctx.content and len(ctx.content) > 8000:
ctx.content = ctx.content[:8000].strip()
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