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"") _QQ_FACE_RE = re.compile(r"") 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