这是一个批量整理提交,包含以下主要改动: 1. 删除多处冗余的空行和未使用的导入 2. 修复文件末尾缺少换行符的问题 3. 调整部分模块的导入顺序与代码排版 4. 修复部分配置默认值与策略逻辑 5. 新增多个功能模块与辅助工具 6. 完善异常处理与日志记录 7. 修复速率限制、消息缓存、权限校验等逻辑bug 8. 废弃部分旧有API与配置项并添加警告提示
411 lines
14 KiB
Python
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
|