"""治理层:纯函数处理(无状态、可单测) 命中即弃的规则返回 None;`__init__.py` 入口层只负责组装这些函数。 """ import re from typing import Iterable, Optional from nonebot.adapters.onebot.v11 import GroupMessageEvent, Message # 敏感数据替换(本地正则,绝不经过 LLM —— DESIGN.md §6 硬约束) # 值类型正则 CODE_RE = re.compile(r"(? str: """消息段 → 纯文本:Text 拼接,At 转 @qq,其余(图/表情/引用)丢弃""" parts = [] for seg in message: if seg.type == "text": parts.append(seg.data.get("text", "")) elif seg.type == "at": qq = str(seg.data.get("qq", "")) if qq and qq not in {"all", "0"}: parts.append(f"@{qq}") return "".join(parts).strip() def extract_text(event: GroupMessageEvent) -> str: return _message_to_text(event.message) # 图片 CQ 码 file 字段里的 32 位 hash(表情包去重识别用) IMG_HASH_RE = re.compile(r"[0-9a-f]{32}") def image_hash_from_file(file: str, url: str = "") -> str: """从图片 CQ 码提取稳定标识:优先 file 里的 32 位 hash,回退 url 文件名""" f = str(file or "") m = IMG_HASH_RE.search(f.lower()) if m: return m.group(0) u = str(url or "").strip() if u: name = u.rstrip("/").split("/")[-1] m = IMG_HASH_RE.search(name.lower()) if m: return m.group(0) return name[:64] or "image" return f[:64] or "image" def extract_image_hashes(event: GroupMessageEvent) -> list[str]: """本条消息里的图片标识列表(同一张图反复发 = 标志性表情包)""" return [ image_hash_from_file( str(seg.data.get("file", "") or ""), str(seg.data.get("url", "") or "") ) for seg in event.message if seg.type == "image" ] def count_faces(event: GroupMessageEvent) -> int: """QQ 原生表情数量(只记数量,不记 id→名字映射)""" return sum(1 for seg in event.message if seg.type == "face") def has_recordable_content(event: GroupMessageEvent) -> bool: """文本 / 图片 / 表情 任一存在即可记录(纯引用等空消息除外)""" for seg in event.message: if seg.type == "text" and seg.data.get("text", "").strip(): return True if seg.type in {"image", "face"}: return True return False def build_content(text: str, image_count: int = 0, face_count: int = 0) -> Optional[str]: """组装落库内容:文本 + 图片/表情标记;全空返回 None""" parts: list[str] = [] if text: parts.append(text) if image_count and face_count: parts.append(f"[图片×{image_count}][表情×{face_count}]") elif image_count: parts.append(f"[图片×{image_count}]") elif face_count: parts.append(f"[表情×{face_count}]") if not parts: return None return " ".join(parts) def extract_target_user_id(event: GroupMessageEvent) -> Optional[int]: """发给谁:回复的 sender 优先,其次首个 @""" if event.reply and event.reply.sender: return int(event.reply.sender.user_id) for seg in event.message: if seg.type == "at": qq = str(seg.data.get("qq", "")) if qq.isdigit(): return int(qq) return None def extract_reply_to_content(event: GroupMessageEvent) -> Optional[str]: """被回复内容快照(适配器事件直接带原文)""" if not event.reply: return None text = _message_to_text(event.reply.message) return text[:MAX_REPLY_CTX_LEN] or None def desensitize(content: str, context_text: str = "") -> str: """敏感数据替换为占位符(本地正则,不经过 LLM) context_text 为附近消息拼接文本,用于跨条语境强化 (如"收下验证码"在 A 的消息、验证码值在 B 的消息)。 保守替换:宁误杀不放过(占位符不影响人格总结)。 """ out = content # 1) 明确模式:手机号/身份证/银行卡/邮箱/IP/微信号/坐标/车牌 for pattern, placeholder in CLEAR_PATTERNS: out = pattern.sub(placeholder, out) # 2) 定位式:同条"关键词 + 值"精准替换,只替换值、保留关键词 # (占位符不含字母数字,替换后兜底不再命中) for pattern, placeholder in KEY_VALUE_PATTERNS: out = pattern.sub( lambda m: m.group(0)[: m.start(1) - m.start(0)] + placeholder, out ) # 3) 跨条语境:关键词在附近消息 → 本条的值按语境类型替换 hint = f"{content} {context_text}" for hint_re, placeholder, value_re in CROSS_HINTS: if hint_re.search(hint): out = value_re.sub(placeholder, out) # 4) 兜底:无语境的长串(保守替换) out = BARE_DIGITS_RE.sub("[账号]", out) out = MIXED_RE.sub("[密码]", out) return out def is_command(content: str, command_starts: Iterable[str]) -> bool: """命令前缀命中(空前缀不算,避免全量误杀)""" starts = [s for s in command_starts if s] return any(content.startswith(s) for s in starts) def is_url_flood(content: str) -> bool: return content.count("http") >= URL_FLOOD_LIMIT def truncate(content: str, limit: int = MAX_CONTENT_LEN) -> str: return content[:limit] def filter_content(content: str, command_starts: Iterable[str]) -> Optional[str]: """噪声治理:空/命令/链接轰炸 → None;否则截断返回(脱敏由 desensitize 单独做)""" if not content: return None if is_command(content, command_starts): return None if is_url_flood(content): return None return truncate(content)