- 新增好友申请XML解析器,支持解析微信4.x Linux的fmessage系统消息 - 新增好友申请自动通过规则引擎,支持黑白名单、关键词、场景过滤 - 新增xdotool驱动的好友申请UI自动化流程 - 新增后台好友申请监听器,支持增量轮询与状态统计 - 新增配套API接口,支持查询申请列表、手动/自动通过、配置管理 - 新增多分辨率UI定位配置,适配1280x720和1920x1080分辨率 - 新增配置项支持自动通过功能的开关与规则配置
313 lines
12 KiB
Python
313 lines
12 KiB
Python
"""好友申请监听器:轮询 fmessage 系统消息,解析后触发自动通过。
|
||
|
||
独立后台协程,不侵入 MessageStreamer 架构。复用其 mtime 感知 +
|
||
复合游标 (create_time, local_id) 增量检测模式,但独立游标与频率控制。
|
||
|
||
auto_accept 关闭时挂起在 asyncio.Event 上,不轮询 DB。
|
||
DB_ENCRYPTED 时 _init_cursor 返回 False,每轮重试初始化。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import logging
|
||
from dataclasses import dataclass
|
||
from typing import Optional
|
||
|
||
from woc_bridge.models import AcceptDecision, BridgeError
|
||
from woc_bridge.messaging.friend_parser import parse_friend_request
|
||
|
||
logger = logging.getLogger("woc-bridge")
|
||
|
||
|
||
@dataclass
|
||
class FriendRequestInfo:
|
||
"""好友申请信息(从 fmessage sysmsg XML 解析)。"""
|
||
stranger_wxid: str # 申请人 wxid(含 @stranger 后缀)
|
||
nickname: str # 申请人昵称
|
||
verify_message: str # 验证消息内容
|
||
scene: str # 来源场景(如群聊/搜索/二维码)
|
||
raw_xml: str # 原始 XML(调试用)
|
||
create_time: int # 消息时间戳
|
||
msg_local_id: int # 消息 local_id(游标用)
|
||
|
||
|
||
class FriendRequestWatcher:
|
||
"""好友申请监听器:轮询 fmessage 系统消息,解析后触发自动通过。"""
|
||
|
||
def __init__(
|
||
self,
|
||
db_reader, # DbReader(避免类型注解循环 import)
|
||
rule_engine, # AcceptRuleEngine
|
||
send_queue, # SendQueue
|
||
idem_cache, # IdemCache
|
||
xdotool_driver, # XdotoolDriver
|
||
breaker, # CircuitBreaker
|
||
poll_interval: float = 3.0,
|
||
cursor_create_time: int = 0,
|
||
cursor_local_id: int = 0,
|
||
) -> None:
|
||
self._db_reader = db_reader
|
||
self._rule_engine = rule_engine
|
||
self._send_queue = send_queue
|
||
self._idem_cache = idem_cache
|
||
self._xdotool = xdotool_driver
|
||
self._breaker = breaker
|
||
self._poll_interval = poll_interval
|
||
# 复合游标 (create_time, local_id),与 get_friend_requests_since 返回值对齐
|
||
self._cursor_create_time: int = cursor_create_time
|
||
self._cursor_local_id: int = cursor_local_id
|
||
self._last_db_mtime: float = 0.0
|
||
self._last_wal_mtime: float = 0.0
|
||
self._watcher: Optional[asyncio.Task] = None
|
||
self._enabled_event: asyncio.Event = asyncio.Event() # auto_accept 开关事件
|
||
self._cursor_inited: bool = False
|
||
# 上轮满载标志:为 True 时跳过 mtime 一级过滤,避免批量积压(>50 条)时
|
||
# 剩余批次因 DB mtime 未变被卡住
|
||
self._has_more_pending: bool = False
|
||
|
||
# 状态统计(供 /api/friends/auto_accept/status 查询)
|
||
self._processed_count: int = 0
|
||
self._accepted_count: int = 0
|
||
self._rejected_count: int = 0
|
||
self._last_processed_time: Optional[int] = None
|
||
|
||
@property
|
||
def is_running(self) -> bool:
|
||
return self._watcher is not None and not self._watcher.done()
|
||
|
||
@property
|
||
def is_enabled(self) -> bool:
|
||
return self._enabled_event.is_set()
|
||
|
||
@property
|
||
def processed_count(self) -> int:
|
||
return self._processed_count
|
||
|
||
@property
|
||
def accepted_count(self) -> int:
|
||
return self._accepted_count
|
||
|
||
@property
|
||
def rejected_count(self) -> int:
|
||
return self._rejected_count
|
||
|
||
@property
|
||
def last_processed_time(self) -> Optional[int]:
|
||
return self._last_processed_time
|
||
|
||
async def start(self) -> None:
|
||
if self._watcher is None or self._watcher.done():
|
||
self._watcher = asyncio.create_task(self._watch_loop())
|
||
logger.info("FriendRequestWatcher 已启动")
|
||
|
||
async def stop(self) -> None:
|
||
if self._watcher is not None and not self._watcher.done():
|
||
self._watcher.cancel()
|
||
try:
|
||
await self._watcher
|
||
except asyncio.CancelledError:
|
||
pass
|
||
self._watcher = None
|
||
|
||
def set_enabled(self, enabled: bool) -> None:
|
||
if enabled:
|
||
# 开启时重置游标,避免批量处理积压申请
|
||
self._cursor_inited = False
|
||
self._has_more_pending = False
|
||
self._enabled_event.set()
|
||
else:
|
||
self._enabled_event.clear()
|
||
logger.info("FriendRequestWatcher enabled=%s", enabled)
|
||
|
||
async def _init_cursor(self) -> bool:
|
||
"""对齐游标到当前 fmessage 表最大 create_time。
|
||
|
||
Returns:
|
||
True 表示已对齐(或 DB 不可读但标记为已初始化,避免无限重试)
|
||
False 表示异常,下一轮重试
|
||
"""
|
||
try:
|
||
max_ct = await asyncio.to_thread(
|
||
self._db_reader.get_max_create_time_for_talker, "fmessage"
|
||
)
|
||
if max_ct is None:
|
||
# DB 不可读(_ensure_decrypted 抛 BridgeError 已被吞掉返回 None)
|
||
# 不标记 _cursor_inited,下一轮重试
|
||
logger.warning("FriendRequestWatcher 游标初始化: DB 不可读,将在下轮重试")
|
||
return False
|
||
if max_ct and max_ct > 0:
|
||
self._cursor_create_time = max_ct
|
||
self._cursor_local_id = 0
|
||
logger.info(
|
||
"FriendRequestWatcher 游标对齐到 create_time=%d(跳过历史申请)",
|
||
self._cursor_create_time,
|
||
)
|
||
# 无论 max_ct 是否为 0(空表),都标记为已初始化,避免空表时无限重试
|
||
self._cursor_inited = True
|
||
return True
|
||
except Exception as e:
|
||
logger.warning("FriendRequestWatcher 游标初始化失败: %s", e)
|
||
return False
|
||
|
||
async def _poll_once(self) -> None:
|
||
# 1. mtime 感知(一级过滤,避免空 SQL)
|
||
# 上轮满载时跳过过滤(仍有未读批次),否则会因 DB mtime 未变卡住剩余申请
|
||
mtimes = await asyncio.to_thread(
|
||
self._db_reader.get_db_mtime, "message/message_0.db"
|
||
)
|
||
if mtimes is None:
|
||
return
|
||
db_mtime, wal_mtime = mtimes
|
||
if not self._has_more_pending:
|
||
if db_mtime == self._last_db_mtime and wal_mtime == self._last_wal_mtime:
|
||
return
|
||
self._last_db_mtime = db_mtime
|
||
self._last_wal_mtime = wal_mtime
|
||
|
||
# 2. 查询 fmessage 分片表增量消息(复合游标)
|
||
result = await asyncio.to_thread(
|
||
self._db_reader.get_friend_requests_since,
|
||
self._cursor_create_time,
|
||
self._cursor_local_id,
|
||
50, # limit
|
||
)
|
||
if result is None:
|
||
# DB 不可读(_ensure_decrypted 抛 BridgeError)
|
||
return
|
||
|
||
raw_items: list[dict] = result["requests"]
|
||
|
||
# 3. 解析 XML + 逐条处理
|
||
for item in raw_items:
|
||
info = parse_friend_request(
|
||
item["content"], item["create_time"], item["local_id"]
|
||
)
|
||
if info is None:
|
||
# 非 verifyUser 类型或解析失败,跳过
|
||
continue
|
||
await self._handle_request(info)
|
||
|
||
# 4. 推进复合游标(与 get_friend_requests_since 返回字段对齐)
|
||
self._cursor_create_time = result["next_create_time"]
|
||
self._cursor_local_id = result["next_local_id"]
|
||
|
||
# 5. 满载标志:本批达 limit 则置 True,下轮跳过 mtime 过滤继续读剩余批次
|
||
self._has_more_pending = len(raw_items) >= 50
|
||
|
||
async def _watch_loop(self) -> None:
|
||
while True:
|
||
try:
|
||
# auto_accept 关闭时挂起,避免空轮询
|
||
await self._enabled_event.wait()
|
||
|
||
# 游标未初始化时先对齐(DB 恢复可读后自动补齐)
|
||
# DB_ENCRYPTED 时 _init_cursor 返回 False,下一轮仍会重试
|
||
if not self._cursor_inited:
|
||
await self._init_cursor()
|
||
if not self._cursor_inited:
|
||
# DB 仍不可读,本轮跳过 _poll_once
|
||
await asyncio.sleep(self._poll_interval)
|
||
continue
|
||
|
||
await asyncio.sleep(self._poll_interval)
|
||
await self._poll_once()
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as e:
|
||
logger.exception("FriendRequestWatcher 异常: %s", e)
|
||
await asyncio.sleep(5.0)
|
||
|
||
async def _handle_request(self, req: FriendRequestInfo) -> None:
|
||
self._processed_count += 1
|
||
self._last_processed_time = req.create_time
|
||
|
||
# 1. 规则匹配
|
||
decision = await self._rule_engine.evaluate(req)
|
||
if decision == AcceptDecision.REJECT:
|
||
self._rejected_count += 1
|
||
logger.info(
|
||
"friend_request: wxid=%s nickname=%s → decision=%s(拒绝)",
|
||
req.stranger_wxid, req.nickname, decision.value,
|
||
)
|
||
return
|
||
if decision != AcceptDecision.ACCEPT:
|
||
logger.info(
|
||
"friend_request: wxid=%s nickname=%s → decision=%s(跳过)",
|
||
req.stranger_wxid, req.nickname, decision.value,
|
||
)
|
||
return
|
||
|
||
# 2. 幂等去重(TTL=300s,5 分钟内不重复处理同一申请人)
|
||
# content 参数传空串(去重 key 是 stranger_wxid,无内容维度)
|
||
cached = self._idem_cache.get(
|
||
"friend_accept", req.stranger_wxid, "", ""
|
||
)
|
||
if cached is not None:
|
||
logger.info(
|
||
"friend_request: wxid=%s → 幂等命中,跳过",
|
||
req.stranger_wxid,
|
||
)
|
||
return
|
||
|
||
# 3. 熔断检查
|
||
if not self._breaker.allow():
|
||
logger.warning(
|
||
"friend_request: wxid=%s → 熔断器 OPEN,跳过",
|
||
req.stranger_wxid,
|
||
)
|
||
return
|
||
|
||
# 4. 入队执行(lambda 用默认参数捕获 req,避免延迟执行时变量被覆盖)
|
||
try:
|
||
result = await self._send_queue.enqueue(
|
||
lambda req=req: self._xdotool.accept_friend_request(
|
||
stranger_wxid=req.stranger_wxid,
|
||
nickname=req.nickname,
|
||
),
|
||
delay_ms=None, # 使用 send_queue 默认间隔
|
||
)
|
||
|
||
# 5. 等待微信 DB WAL 刷盘后校验(微信 DB 写入有 1-3s 延迟)
|
||
await asyncio.sleep(2.0)
|
||
verified = await asyncio.to_thread(
|
||
self._db_reader.verify_friend_accepted, req.stranger_wxid
|
||
)
|
||
|
||
if verified:
|
||
# set 签名: (flow_name, to_wxid, content, value, client_request_id)
|
||
self._idem_cache.set(
|
||
"friend_accept", req.stranger_wxid, "",
|
||
{"success": True, "verified": True}, "",
|
||
)
|
||
self._breaker.record_success()
|
||
self._accepted_count += 1
|
||
logger.info(
|
||
"friend_request: wxid=%s nickname=%s → 通过并验证成功",
|
||
req.stranger_wxid, req.nickname,
|
||
)
|
||
else:
|
||
# UI 操作完成但 DB 校验未通过(可能有延迟)
|
||
self._idem_cache.set(
|
||
"friend_accept", req.stranger_wxid, "",
|
||
{"success": True, "verified": False}, "",
|
||
)
|
||
self._breaker.record_failure()
|
||
logger.warning(
|
||
"friend_request: wxid=%s → UI 操作完成但 DB 校验未通过",
|
||
req.stranger_wxid,
|
||
)
|
||
except BridgeError as e:
|
||
# 透传 BridgeError 错误码(RATE_LIMITED / SEND_FAILED / WINDOW_NOT_FOUND 等)
|
||
self._breaker.record_failure()
|
||
logger.error(
|
||
"friend_request: wxid=%s → BridgeError code=%s: %s",
|
||
req.stranger_wxid, getattr(e, "code", "UNKNOWN"), e,
|
||
)
|
||
except Exception as e:
|
||
self._breaker.record_failure()
|
||
logger.error(
|
||
"friend_request: wxid=%s → 失败: %s",
|
||
req.stranger_wxid, e,
|
||
)
|