Files
kefu/wechat_rpa/contact_reply_guard.py
T
2026-09-21 10:34:06 +08:00

159 lines
6.5 KiB
Python

"""Recognize contact restrictions from system notices, never ordinary chat topics."""
import re
import unicodedata
import math
CONTACT_REASON = '对方开启联系人验证,当前不是联系人,已跳过自动回复'
SYSTEM_TYPES = {11, 38, 101, 132, 1002, 1011, 1012, 1017, 1022, 1025, 1043, 1988}
def normalized_notice(text):
return re.sub(r'[\W_]+', '', unicodedata.normalize('NFKC', str(text or '')))
def contact_verification_reason(text):
value = normalized_notice(text)
if ('开启了联系人验证' in value or '开启了朋友验证' in value) and (
'请先发送联系人验证请求' in value or '请先发送朋友验证请求' in value
):
return CONTACT_REASON
if re.search(r'你(?:还|尚)?不是.{0,5}的(?:联系人|好友)', value) and (
'验证通过后' in value or '验证请求' in value
):
return CONTACT_REASON
return ''
def rejected_contact_reason(text):
# Used only for explicit transport failures, never user-written message text.
return contact_verification_reason(text) or (CONTACT_REASON if any(
phrase in normalized_notice(text) for phrase in
('对方不是你的联系人', '该用户不是你的联系人', '你不是对方的联系人', '对方拒收了你的消息')
) else '')
def system_contact_status(message):
try:
system = message.get('is_system') is True or int(message.get('content_type') or 0) in SYSTEM_TYPES
except (ValueError, TypeError):
system = False
if not system:
return ''
text = message.get('content', '')
if contact_verification_reason(text):
return 'blocked'
value = normalized_notice(text)
if ('通过了' in value and ('联系人验证' in value or '朋友验证' in value)) or (
'你已添加了' in value and '现在可以开始聊天了' in value
):
return 'restored'
return ''
def conversation_contact_reason(context):
for message in reversed((context or {}).get('messages') or []):
status = system_contact_status(message)
if status:
return CONTACT_REASON if status == 'blocked' else ''
return ''
def contact_event_order(message):
"""A source message anchor, never the time an old notice was rediscovered."""
try:
stamp = float(message.get('send_time') or 0)
row = int(message.get('rowid') or 0)
return (stamp, row) if math.isfinite(stamp) and stamp > 0 and row >= 0 else (0.0, 0)
except (TypeError, ValueError, OverflowError):
return (0.0, 0)
def contact_peer(account, conv_id):
account, conv_id = str(account or ''), str(conv_id or '')
if not re.fullmatch(r'[0-9]{1,24}', account):
return ''
if re.fullmatch(r'M:[0-9]{1,24}', conv_id):
return conv_id[2:] if conv_id[2:] != account else ''
if re.fullmatch(r'S:[0-9]{1,24}_[0-9]{1,24}', conv_id):
peers = conv_id[2:].split('_')
if peers.count(account) == 1:
return next(peer for peer in peers if peer != account)
return ''
def latest_contact_notice(context):
"""Only notices belonging to the requested exact account/conversation."""
if not isinstance(context, dict):
return None
account, conv_id = context.get('account'), context.get('conv_id')
for message in reversed(context.get('messages') or []):
if (message.get('account') == account and message.get('conv_id') == conv_id
and system_contact_status(message)):
return message
return None
def current_customer_relation(context):
"""An active local relation permits rechecking a NEW question, not old sends."""
if not isinstance(context, dict):
return False
account, conv_id = context.get('account'), context.get('conv_id')
peer = contact_peer(account, conv_id)
relation = context.get('contact_relation') or {}
return bool(peer and isinstance(relation, dict) and relation.get('state') == 'active'
and relation.get('account') == account and relation.get('conv_id') == conv_id
and relation.get('peer_id') == peer)
def real_customer_event(message, account, conv_id):
"""Require a positive persisted server identity, peer sender and direct chat."""
peer = contact_peer(account, conv_id)
if not isinstance(message, dict) or not peer:
return False
try:
kind = int(message.get('content_type'))
server = str(message.get('server_id') or '')
stamp, row = contact_event_order(message)
return bool(message.get('account') == account and message.get('conv_id') == conv_id
and message.get('is_self') is False and str(message.get('sender_id') or '') == peer
and message.get('dedup_key') and server.isdecimal() and int(server) > 0
and stamp > 0 and row > 0 and message.get('is_system') is not True
and kind in {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 14, 16, 17, 20, 111, 123})
except (TypeError, ValueError, OverflowError):
return False
def visual_contact_reason(region, items):
"""Require centered grey notice text inside the already-cropped chat pane."""
import numpy as np
if region is None or getattr(region, 'ndim', 0) != 3 or not region.size:
return ''
height, width = region.shape[:2]
candidates = []
for item in items:
if float(item.get('score') or 0) < .70:
continue
x1, y1 = max(0, int(item.get('x1', 0))), max(0, int(item.get('y1', 0)))
x2, y2 = min(width, int(item.get('x2', 0))), min(height, int(item.get('y2', 0)))
if x2 <= x1 or y2 <= y1 or not .25 * width < (x1+x2)/2 < .75 * width:
continue
pixels = region[y1:y2, x1:x2, :3].astype(np.int16)
low, high = pixels.min(axis=2), pixels.max(axis=2)
neutral_ink = (high-low < 40) & (low < 215)
grey_ink = neutral_ink & (low >= 90)
# Black text in white customer bubbles and blue outgoing bubbles is not a system banner.
count = int(neutral_ink.sum())
if count < 15 or float(grey_ink.sum()) / count < .65:
continue
candidates.append((y1, y2, x1, str(item.get('text') or '')))
candidates.sort()
group, bottom, line_height = [], -1000, 0
for y1, y2, x1, text in candidates:
if y1-bottom > max(12, line_height * 1.5):
if contact_verification_reason(''.join(group)):
return CONTACT_REASON
group = []
group.append(text)
bottom, line_height = max(bottom, y2), max(1, y2-y1)
return contact_verification_reason(''.join(group))