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