Files
HeXi/hexi/plugins/nonebot_plugin_galgame_card/processor.py
T

219 lines
8.6 KiB
Python
Raw Normal View History

"""治理层:纯函数处理(无状态、可单测)
命中即弃的规则返回 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"(?<!\d)\d{4,8}(?!\d)") # 验证码/短数字串
BARE_DIGITS_RE = re.compile(r"(?<!\d)\d{6,}(?!\d)") # 裸长数字串
# 字母+数字混合串(至少各一位、总长 ≥6),如 abc1234
MIXED_RE = re.compile(
r"(?<![A-Za-z0-9])(?=[A-Za-z0-9]*[A-Za-z])(?=[A-Za-z0-9]*\d)[A-Za-z0-9]{6,}(?![A-Za-z0-9])"
)
TOKEN_RE = re.compile(r"[A-Za-z0-9._-]{4,}") # 账号/密码值(定位式用)
CODE_TOKEN_RE = re.compile(r"[A-Za-z0-9-]{6,}") # 兑换码值(定位式用)
# 明确模式(无语境也确定替换;按"最长/最特异优先"排序,
# 否则手机号等短模式会先截胡身份证/银行卡里的数字段)
CLEAR_PATTERNS = (
(re.compile(r"(?<!\d)\d{17}[\dXx](?!\d)"), "[身份证]"),
(re.compile(r"(?<!\d)\d{16,19}(?!\d)"), "[银行卡]"),
(re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"), "[邮箱]"),
(re.compile(r"(?<!\d)1[3-9]\d[-\s]?\d{4}[-\s]?\d{4}(?!\d)"), "[手机号]"), # 含 138-0013-8000 变体
(re.compile(r"(?<!\d)(?:\d{1,3}\.){3}\d{1,3}(?!\d)"), "[IP]"),
(re.compile(r"wxid_[A-Za-z0-9_-]{6,}"), "[微信号]"),
(re.compile(r"(?<!\d)(?:\d{1,3}\.\d+)[,,\s](?:\d{1,3}\.\d+)(?!\d)"), "[坐标]"),
( # 车牌:省份简称+字母+数字
re.compile(
r"[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领]"
r"[A-HJ-NP-Z][A-Z0-9]{5,6}"
),
"[车牌]",
),
)
# 定位式(同条消息:关键词 + 分隔/连接词 + 值 → 精准替换,替换后兜底不再命中)
_SEP = r"[::\s是为]*" # 冒号/空格/是/为 等连接词
KEY_VALUE_PATTERNS = (
(re.compile(rf"(?:兑换码|激活码|卡密|cdk|代充码){_SEP}([A-Za-z0-9-]{{6,}})", re.I), "[兑换码]"),
(re.compile(rf"(?:验证码|校验码){_SEP}(\d{{4,8}})"), "[验证码]"),
(
re.compile(rf"(?:密码|口令|pwd|password|支付密码|二级密码){_SEP}([A-Za-z0-9._-]{{4,}})", re.I),
"[密码]",
),
(re.compile(rf"(?:账号|帐号){_SEP}([A-Za-z0-9._-]{{4,}})"), "[账号]"),
(re.compile(rf"(?:VX|vx|微信){_SEP}([A-Za-z0-9_-]{{6,}})"), "[微信号]"),
(re.compile(rf"(?:月薪|工资|薪资|年薪){_SEP}([\d,.]+(?:万)?)"), "[收入]"),
)
# 跨条语境(关键词在附近消息 → 本条的值按语境替换)
CROSS_HINTS = (
(re.compile(r"兑换码|激活码|卡密|cdk|代充", re.I), "[兑换码]", CODE_TOKEN_RE),
(re.compile(r"验证码|校验码|captcha", re.I), "[验证码]", CODE_RE),
(re.compile(r"密码|口令|pwd|password", re.I), "[密码]", MIXED_RE),
)
MAX_CONTENT_LEN = 200 # 长文截断
MAX_REPLY_CTX_LEN = 200 # 被回复快照截断
URL_FLOOD_LIMIT = 3 # 长链接轰炸判定
REPEAT_WINDOW = 3 # 复读检测窗口(最近 N 条内出现过即弃)
THROTTLE_SECONDS = 5 # 刷屏采样:同人同群 N 秒内只落库一次
def _message_to_text(message: Message) -> 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)