"""好友申请监听器:轮询 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, )