diff --git a/bridge/woc_bridge/app.py b/bridge/woc_bridge/app.py index bba3a19..a69c60c 100644 --- a/bridge/woc_bridge/app.py +++ b/bridge/woc_bridge/app.py @@ -101,9 +101,28 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: logger.warning("[lifespan] 启动清场 3 次失败,可能需要人工 VNC 接入") except Exception as e: logger.warning("[lifespan] 启动清场异常(不阻塞启动): %s", e) + # 启动好友申请监听器(仅当 auto_accept 开启时) + # 放在 message_streamer 之后,保持"基础设施先于业务"顺序 + if _state.friend_watcher is not None and _state.config.auto_accept_enabled: + try: + # 必须先 set_enabled(True) 再 start():FriendRequestWatcher 构造时 + # _enabled_event 初始为 cleared,_watch_loop 会阻塞在 wait() 上不轮询 + _state.friend_watcher.set_enabled(True) + await _state.friend_watcher.start() + except Exception as e: + logger.warning("[lifespan] friend_watcher.start() 失败: %s", e) try: yield finally: + # 先停 friend_watcher(业务),再停基础设施 + if _state.friend_watcher is not None: + try: + await asyncio.wait_for( + _state.friend_watcher.stop(), timeout=5.0 + ) + except Exception: + # Exception 已涵盖 TimeoutError,无需单独列 + logger.warning("[lifespan] friend_watcher.stop() 超时或异常,强制继续") # 停止消息流推送器(带超时,防止 worker 卡住阻塞关闭) if _state.message_streamer is not None: try: @@ -380,3 +399,50 @@ def _init_state(cfg: BridgeConfig) -> None: _state.opencv_backend = None _state.watchdog = None _state.resource_reaper = None + + # 好友自动通过组件(不依赖 P3 UI 架构,独立构造) + try: + from woc_bridge.models import AcceptRuleConfig, AcceptRuleEngine + from woc_bridge.messaging.friend_watcher import FriendRequestWatcher + from woc_bridge.ui.circuit_breaker import CircuitBreaker + from woc_bridge.ui.idem_cache import IdemCache + + accept_config = AcceptRuleConfig( + enabled=cfg.auto_accept_enabled, + accept_all=cfg.auto_accept_all, + whitelist_wxids=cfg.auto_accept_whitelist_wxids, + whitelist_nicknames=cfg.auto_accept_whitelist_nicknames, + keywords=cfg.auto_accept_keywords, + blacklist_wxids=cfg.auto_accept_blacklist_wxids, + blacklist_nicknames=cfg.auto_accept_blacklist_nicknames, + allow_scenes=cfg.auto_accept_allow_scenes, + ) + _state.rule_engine = AcceptRuleEngine(accept_config) + + # 熔断器(failure_threshold=5, recovery_timeout=60s,比 send_text 的 30s 更长) + _state.accept_breaker = CircuitBreaker( + "accept_verify", failure_threshold=5, recovery_timeout=60.0, + ) + + # 幂等缓存:独立构造,避免 ui_backend=legacy 时 _state.idem_cache 为 None + # (好友自动通过不依赖 P3 UI 架构,自持 IdemCache 实例) + accept_idem_cache = IdemCache(ttl=300, max_size=1000) + + # 好友申请监听器(注入 6 个依赖) + _state.friend_watcher = FriendRequestWatcher( + db_reader=_state.db_reader, + rule_engine=_state.rule_engine, + send_queue=_state.send_queue, + idem_cache=accept_idem_cache, + xdotool_driver=_state.xdotool, + breaker=_state.accept_breaker, + poll_interval=cfg.auto_accept_poll_interval, + ) + + logger.info("[init] 好友自动通过组件构造完成 (enabled=%s)", cfg.auto_accept_enabled) + except Exception as exc: + logger.error("[init] 好友自动通过组件构造失败: %s", exc) + # 构造失败不阻塞启动,相关 API 会返回 BRIDGE_INTERNAL_ERROR + _state.rule_engine = None + _state.friend_watcher = None + _state.accept_breaker = None diff --git a/bridge/woc_bridge/config.py b/bridge/woc_bridge/config.py index 54f7409..56043e3 100644 --- a/bridge/woc_bridge/config.py +++ b/bridge/woc_bridge/config.py @@ -15,7 +15,7 @@ import asyncio import logging import os import threading -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, Callable, Optional from woc_bridge.db import DbReader, KeyCache @@ -112,6 +112,13 @@ class InitState: # --------------------------------------------------------------------------- # BridgeConfig # --------------------------------------------------------------------------- +def _parse_list(s: str) -> list[str]: + """逗号分隔字符串转列表(供 BridgeConfig 解析环境变量用)。""" + if not s.strip(): + return [] + return [item.strip() for item in s.split(",") if item.strip()] + + @dataclass class BridgeConfig: """bridge 服务的配置项(来自命令行参数 + 环境变量)。 @@ -135,6 +142,16 @@ class BridgeConfig: ui_backend: str = "flow" # 调试截图写盘开关:生产环境必须 false,调试模式 true 时异步队列后台写盘 ui_debug_shots: bool = False + # 好友自动通过配置 + auto_accept_enabled: bool = False + auto_accept_all: bool = False + auto_accept_whitelist_wxids: list[str] = field(default_factory=list) + auto_accept_whitelist_nicknames: list[str] = field(default_factory=list) + auto_accept_keywords: list[str] = field(default_factory=list) + auto_accept_blacklist_wxids: list[str] = field(default_factory=list) + auto_accept_blacklist_nicknames: list[str] = field(default_factory=list) + auto_accept_allow_scenes: list[str] = field(default_factory=list) + auto_accept_poll_interval: float = 3.0 @classmethod def from_args_and_env(cls, args: argparse.Namespace) -> "BridgeConfig": @@ -153,6 +170,33 @@ class BridgeConfig: ).lower() not in ("false", "0", "no", "off"), ui_backend=os.environ.get("WOC_UI_BACKEND", "flow").lower(), ui_debug_shots=os.environ.get("WOC_UI_DEBUG_SHOTS", "false").lower() in ("true", "1", "yes", "on"), + auto_accept_enabled=os.environ.get( + "WOC_AUTO_ACCEPT_ENABLED", "false" + ).lower() in ("true", "1", "yes", "on"), + auto_accept_all=os.environ.get( + "WOC_AUTO_ACCEPT_ALL", "false" + ).lower() in ("true", "1", "yes", "on"), + auto_accept_whitelist_wxids=_parse_list( + os.environ.get("WOC_AUTO_ACCEPT_WHITELIST_WXIDS", "") + ), + auto_accept_whitelist_nicknames=_parse_list( + os.environ.get("WOC_AUTO_ACCEPT_WHITELIST_NICKNAMES", "") + ), + auto_accept_keywords=_parse_list( + os.environ.get("WOC_AUTO_ACCEPT_KEYWORDS", "") + ), + auto_accept_blacklist_wxids=_parse_list( + os.environ.get("WOC_AUTO_ACCEPT_BLACKLIST_WXIDS", "") + ), + auto_accept_blacklist_nicknames=_parse_list( + os.environ.get("WOC_AUTO_ACCEPT_BLACKLIST_NICKNAMES", "") + ), + auto_accept_allow_scenes=_parse_list( + os.environ.get("WOC_AUTO_ACCEPT_ALLOW_SCENES", "") + ), + auto_accept_poll_interval=float( + os.environ.get("WOC_AUTO_ACCEPT_POLL_INTERVAL", "3") + ), ) @@ -188,6 +232,10 @@ class AppState: self.opencv_backend: Optional[Any] = None # OpenCVBackend self.xdotool_backend: Optional[Any] = None # XdotoolBackend self.idem_cache: Optional[Any] = None # IdemCache + # 好友自动通过组件 + self.friend_watcher: Optional[Any] = None # FriendRequestWatcher + self.rule_engine: Optional[Any] = None # AcceptRuleEngine + self.accept_breaker: Optional[Any] = None # CircuitBreaker def extract_lock(self) -> asyncio.Lock: """懒初始化 asyncio.Lock(需在事件循环中创建)。""" @@ -250,3 +298,23 @@ def _require_message_streamer() -> MessageStreamer: message="message_streamer 未初始化", ) return _state.message_streamer + + +def _require_rule_engine(): + """返回好友自动通过规则引擎,未初始化时抛 BridgeError。""" + if _state.rule_engine is None: + raise BridgeError( + code="BRIDGE_INTERNAL_ERROR", + message="rule_engine 未初始化", + ) + return _state.rule_engine + + +def _require_friend_watcher(): + """返回好友申请监听器,未初始化时抛 BridgeError。""" + if _state.friend_watcher is None: + raise BridgeError( + code="BRIDGE_INTERNAL_ERROR", + message="friend_watcher 未初始化", + ) + return _state.friend_watcher diff --git a/bridge/woc_bridge/db/reader.py b/bridge/woc_bridge/db/reader.py index 02a2d29..107b5b1 100644 --- a/bridge/woc_bridge/db/reader.py +++ b/bridge/woc_bridge/db/reader.py @@ -1140,6 +1140,188 @@ class DbReader: finally: conn.close() + def get_friend_requests_since( + self, cursor_create_time: int, cursor_local_id: int = 0, limit: int = 50 + ) -> Optional[dict]: + """查询 fmessage 会话中指定游标之后的好友申请消息。 + + 直接查 Msg_ 单表,避免遍历所有分片。 + 复合游标 (create_time, local_id) 作为 tie-breaker,避免同秒消息丢失。 + + Args: + cursor_create_time: 上次处理到的 create_time(0 = 从最新开始) + cursor_local_id: 同 create_time 下已处理的 local_id(tie-breaker) + limit: 单次读取上限 + + Returns: + {"requests": list[dict], "next_create_time": int, "next_local_id": int} + 或 None(DB 不可读) + + Notes: + - _ensure_decrypted 可能抛 BridgeError(DB_ENCRYPTED / DB_NEED_INIT), + 需 catch BridgeError 而非仅 sqlite3.Error + - WCDB_CT_message_content 列可能不存在(非 WCDB 表),用 PRAGMA 预探测 + """ + empty = { + "requests": [], + "next_create_time": cursor_create_time, + "next_local_id": cursor_local_id, + } + try: + db_path = self._ensure_decrypted("message/message_0.db") + except BridgeError: + # DB 加密/无 key:返回 None 让调用方知道 DB 不可读 + return None + + conn = sqlite3.connect(db_path, isolation_level=None) + conn.row_factory = sqlite3.Row + try: + # 计算 fmessage 分片表名 + table_name = f"Msg_{hashlib.md5(b'fmessage').hexdigest()}" + # 验证表存在 + cur = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?", + (table_name,), + ) + if cur.fetchone() is None: + return empty + + # 动态探测 WCDB_CT_message_content 列是否存在 + cols = self._table_columns(conn, table_name) + has_ct_col = "WCDB_CT_message_content" in cols + ct_col = "WCDB_CT_message_content" if has_ct_col else "0 AS WCDB_CT_message_content" + + # 复合游标查询(与 get_messages_since 一致的 tie-breaker) + sql = ( + f"SELECT local_id, create_time, message_content, {ct_col} " + f"FROM [{table_name}] " + f"WHERE (create_time > ? OR (create_time = ? AND local_id > ?)) " + f"ORDER BY create_time ASC, local_id ASC LIMIT ?" + ) + rows = conn.execute( + sql, (cursor_create_time, cursor_create_time, cursor_local_id, limit) + ).fetchall() + + requests = [] + next_ct = cursor_create_time + next_lid = cursor_local_id + for row in rows: + content = self._decompress_msg_content( + row["message_content"], + row["WCDB_CT_message_content"], + ) + requests.append({ + "local_id": row["local_id"], + "create_time": row["create_time"], + "content": content, + }) + next_ct = row["create_time"] + next_lid = row["local_id"] + + return { + "requests": requests, + "next_create_time": next_ct, + "next_local_id": next_lid, + } + except sqlite3.Error: + return empty + finally: + conn.close() + + def verify_friend_accepted(self, stranger_wxid: str) -> bool: + """校验好友申请是否已通过:contact 表中 @stranger 后缀消失。 + + 通过前:username = "wxid_xxx@stranger"(后缀名需环境验证) + 通过后:username = "wxid_xxx"(后缀被移除,local_type 可能变为 1) + + 策略:用 base_wxid 查 contact 表,若存在 base_wxid 记录且不存在 + base_wxid@stranger 记录,则判定已通过。合并为单条 SQL 避免 2 次查询。 + + Args: + stranger_wxid: 含 @ 后缀的 wxid + + Returns: + True 表示已通过,False 表示仍待验证或 DB 不可读 + + Notes: + - _ensure_decrypted 抛 BridgeError 时返回 False(不影响 UI 操作结果) + - @stranger 后缀是假设,若真实后缀不同需调整 SQL LIKE 模式 + """ + try: + db_path = self._ensure_decrypted("contact/contact.db") + except BridgeError: + return False + + conn = sqlite3.connect(db_path, isolation_level=None) + conn.row_factory = sqlite3.Row + try: + table = self._find_contact_table(conn) + if table is None: + return False + + # 动态探测 username 列名(候选: username / wxid) + cols = self._table_columns(conn, table) + username_col = self._pick_column(cols, ["username", "wxid"]) + if username_col is None: + return False + + base_wxid = stranger_wxid.split("@")[0] + + # 单条 SQL:同时统计 base_wxid 记录数和 base_wxid@% 残留数 + sql = ( + f"SELECT " + f" SUM(CASE WHEN {username_col} = ? THEN 1 ELSE 0 END) AS base_cnt, " + f" SUM(CASE WHEN {username_col} LIKE ? THEN 1 ELSE 0 END) AS stranger_cnt " + f"FROM {table}" + ) + cur = conn.execute(sql, (base_wxid, f"{base_wxid}@%")) + row = cur.fetchone() + if row is None: + return False + base_cnt = row["base_cnt"] or 0 + stranger_cnt = row["stranger_cnt"] or 0 + # base_wxid 存在(已变为好友)且无 @stranger 残留 + return base_cnt > 0 and stranger_cnt == 0 + except sqlite3.Error: + return False + finally: + conn.close() + + def get_max_create_time_for_talker(self, talker: str) -> Optional[int]: + """获取指定会话的消息最大 create_time(游标初始化用)。 + + 直接查 Msg_ 单表,避免遍历所有分片。 + 返回 None 表示 DB 不可读(与 get_max_create_time 语义一致), + 返回 0 表示表为空或不存在。 + + Args: + talker: 会话 talker(如 "fmessage") + + Returns: + 最大 create_time,或 None / 0 + """ + try: + db_path = self._ensure_decrypted("message/message_0.db") + except BridgeError: + return None + + conn = sqlite3.connect(db_path, isolation_level=None) + try: + table_name = f"Msg_{hashlib.md5(talker.encode()).hexdigest()}" + cur = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?", + (table_name,), + ) + if cur.fetchone() is None: + return 0 + cur = conn.execute(f"SELECT MAX(create_time) FROM [{table_name}]") + row = cur.fetchone() + return int(row[0]) if row and row[0] else 0 + except sqlite3.Error: + return 0 + finally: + conn.close() + # ------------------------------------------------------------------ # 朋友圈(Moments)DB 探测与读取 # ------------------------------------------------------------------ diff --git a/bridge/woc_bridge/messaging/friend_parser.py b/bridge/woc_bridge/messaging/friend_parser.py new file mode 100644 index 0000000..0193bf8 --- /dev/null +++ b/bridge/woc_bridge/messaging/friend_parser.py @@ -0,0 +1,84 @@ +"""fmessage 系统消息 XML 解析器。 + +解析微信 4.x Linux 的 fmessage sysmsg XML,提取好友申请信息。 +无状态函数,便于单测。 + +XML 格式(推断,需环境验证): + + + xxx@stranger + 申请人昵称 + 验证消息内容 + ... + + +""" + +from __future__ import annotations + +import logging +import xml.etree.ElementTree as ET +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + # 仅类型注解用,避免与 friend_watcher.py 形成运行时循环 import + from woc_bridge.messaging.friend_watcher import FriendRequestInfo + +logger = logging.getLogger("woc-bridge") + + +def parse_friend_request( + content: str, create_time: int, msg_local_id: int +) -> Optional[FriendRequestInfo]: + """解析 fmessage 系统消息 XML,提取好友申请信息。 + + Args: + content: message_content 原始文本(XML) + create_time: 消息时间戳 + msg_local_id: 消息 local_id + + Returns: + FriendRequestInfo 或 None(解析失败 / 非 verifyUser 类型) + """ + if not content or not content.strip(): + return None + + try: + root = ET.fromstring(content) + except ET.ParseError: + # 部分消息可能不是合法 XML(如纯文本通知),跳过 + return None + + # 检查是否为 verifyUser 类型 sysmsg + msg_type = root.get("type", "") + if msg_type != "verifyUser": + return None + + # 提取 Link 节点中的申请人信息 + link = root.find(".//Link") + if link is None: + return None + + stranger_wxid = _text(link, "UserName") + nickname = _text(link, "NickName") + verify_message = _text(link, "Content") + scene = _text(link, "Scene") + + if not stranger_wxid: + return None + + return FriendRequestInfo( + stranger_wxid=stranger_wxid, + nickname=nickname, + verify_message=verify_message, + scene=scene, + raw_xml=content, + create_time=create_time, + msg_local_id=msg_local_id, + ) + + +def _text(parent: ET.Element, tag: str) -> str: + """安全提取子元素文本(None 安全)。""" + el = parent.find(tag) + return el.text.strip() if el is not None and el.text else "" diff --git a/bridge/woc_bridge/messaging/friend_watcher.py b/bridge/woc_bridge/messaging/friend_watcher.py new file mode 100644 index 0000000..818b8ac --- /dev/null +++ b/bridge/woc_bridge/messaging/friend_watcher.py @@ -0,0 +1,312 @@ +"""好友申请监听器:轮询 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, + ) diff --git a/bridge/woc_bridge/models/__init__.py b/bridge/woc_bridge/models/__init__.py index 94aab4d..7c3118d 100644 --- a/bridge/woc_bridge/models/__init__.py +++ b/bridge/woc_bridge/models/__init__.py @@ -50,6 +50,14 @@ from woc_bridge.models.contact import ( SetRemarkResponse, AddFriendRequest, AddFriendResponse, + AcceptRuleConfig, + AcceptDecision, + AcceptRuleEngine, + FriendRequestItem, + FriendRequestsResponse, + AcceptFriendRequest, + AcceptFriendResponse, + AutoAcceptStatus, ) from woc_bridge.models.db import ( DbDecryptRequest, @@ -82,6 +90,9 @@ __all__ = [ "Contact", "ContactsResponse", "GroupsResponse", "GroupMember", "GroupMembersResponse", "SetRemarkRequest", "SetRemarkResponse", "AddFriendRequest", "AddFriendResponse", + "AcceptRuleConfig", "AcceptDecision", "AcceptRuleEngine", + "FriendRequestItem", "FriendRequestsResponse", + "AcceptFriendRequest", "AcceptFriendResponse", "AutoAcceptStatus", "DbDecryptRequest", "DbDecryptResponse", "DbKeyStatusResponse", "DbInitRequest", "DbInitResponse", "DbInitStatusResponse", "ConnectivityResponse", "DiagnosticItem", "DiagnosticRunResult", ] diff --git a/bridge/woc_bridge/models/contact.py b/bridge/woc_bridge/models/contact.py index fe1f971..59264f4 100644 --- a/bridge/woc_bridge/models/contact.py +++ b/bridge/woc_bridge/models/contact.py @@ -2,10 +2,15 @@ from __future__ import annotations -from typing import Optional +import asyncio +import enum +from typing import TYPE_CHECKING, Optional from pydantic import BaseModel, Field +if TYPE_CHECKING: + from woc_bridge.messaging.friend_watcher import FriendRequestInfo + # --------------------------------------------------------------------------- # 联系人接口 @@ -106,3 +111,117 @@ class AddFriendResponse(BaseModel): success: bool = Field(default=False) error: Optional[str] = Field(default=None, description="失败时的错误描述") + + +# --------------------------------------------------------------------------- +# 好友申请自动通过 +# --------------------------------------------------------------------------- +class FriendRequestItem(BaseModel): + """好友申请条目(API 响应用)。""" + stranger_wxid: str + nickname: str = "" + verify_message: str = "" + scene: str = "" + create_time: int + + +class FriendRequestsResponse(BaseModel): + """好友申请列表响应。""" + requests: list[FriendRequestItem] = Field(default_factory=list) + total: int = 0 + + +class AcceptFriendRequest(BaseModel): + """手动通过好友申请请求。""" + stranger_wxid: str = Field(description="申请人 wxid(含 @stranger 后缀)") + nickname: Optional[str] = Field(default=None, description="申请人昵称(UI 定位用)") + + +class AcceptFriendResponse(BaseModel): + """通过好友申请响应。""" + success: bool = Field(default=False) + error: Optional[str] = Field(default=None) + + +class AutoAcceptStatus(BaseModel): + """自动通过运行状态。""" + running: bool + enabled: bool + processed_count: int = 0 + accepted_count: int = 0 + rejected_count: int = 0 + last_processed_time: Optional[int] = None + + +class AcceptRuleConfig(BaseModel): + """自动通过规则配置。""" + enabled: bool = Field(default=False, description="全局开关") + accept_all: bool = Field(default=False, description="通过所有申请(忽略以下规则)") + whitelist_wxids: list[str] = Field(default_factory=list, description="白名单 wxid 列表") + whitelist_nicknames: list[str] = Field(default_factory=list, description="白名单昵称列表(精确匹配)") + keywords: list[str] = Field(default_factory=list, description="验证消息关键词列表(子串匹配)") + blacklist_wxids: list[str] = Field(default_factory=list, description="黑名单 wxid 列表") + blacklist_nicknames: list[str] = Field(default_factory=list, description="黑名单昵称列表") + allow_scenes: list[str] = Field(default_factory=list, description="允许的场景(空=不限制)") + + +class AcceptDecision(enum.Enum): + """规则匹配决策结果。""" + ACCEPT = "accept" + REJECT = "reject" + SKIP = "skip" + + +class AcceptRuleEngine: + """好友申请自动通过规则引擎。 + + 按"黑名单 → 场景 → 白名单 → 关键词 → SKIP"优先级决策, + 配置热更新受 asyncio.Lock 保护。 + """ + + def __init__(self, config: AcceptRuleConfig) -> None: + self._config = config + self._lock = asyncio.Lock() + + async def get_config(self) -> AcceptRuleConfig: + """获取当前规则配置(供 API 查询)。""" + async with self._lock: + return self._config + + async def evaluate(self, req: "FriendRequestInfo") -> AcceptDecision: + async with self._lock: + cfg = self._config + + if not cfg.enabled: + return AcceptDecision.SKIP + + if cfg.accept_all: + return AcceptDecision.ACCEPT + + # 黑名单优先 + if req.stranger_wxid in cfg.blacklist_wxids: + return AcceptDecision.REJECT + if req.nickname in cfg.blacklist_nicknames: + return AcceptDecision.REJECT + + # 场景过滤 + if cfg.allow_scenes and req.scene not in cfg.allow_scenes: + return AcceptDecision.SKIP + + # 白名单 + if req.stranger_wxid in cfg.whitelist_wxids: + return AcceptDecision.ACCEPT + if req.nickname in cfg.whitelist_nicknames: + return AcceptDecision.ACCEPT + + # 关键词 + if cfg.keywords: + for kw in cfg.keywords: + if kw in req.verify_message: + return AcceptDecision.ACCEPT + + return AcceptDecision.SKIP + + async def update_config(self, config: AcceptRuleConfig) -> None: + async with self._lock: + self._config = config diff --git a/bridge/woc_bridge/routes/contacts.py b/bridge/woc_bridge/routes/contacts.py index bfa8c3d..89df3f8 100644 --- a/bridge/woc_bridge/routes/contacts.py +++ b/bridge/woc_bridge/routes/contacts.py @@ -5,9 +5,13 @@ import logging from fastapi import APIRouter -from woc_bridge.config import _require_db_reader, _require_xdotool, _require_send_queue +from woc_bridge.config import ( + _require_db_reader, _require_xdotool, _require_send_queue, + _require_rule_engine, _require_friend_watcher, +) from woc_bridge.db.coordinator import _check_db_readable, with_db_retry from woc_bridge.routes.send import _resolve_display_name +from woc_bridge.messaging.friend_parser import parse_friend_request from woc_bridge.models import ( BridgeError, Contact, @@ -18,6 +22,12 @@ from woc_bridge.models import ( SetRemarkResponse, AddFriendRequest, AddFriendResponse, + AcceptRuleConfig, + FriendRequestItem, + FriendRequestsResponse, + AcceptFriendRequest, + AcceptFriendResponse, + AutoAcceptStatus, ) logger = logging.getLogger("woc-bridge") @@ -306,3 +316,143 @@ async def add_friend(req: AddFriendRequest) -> AddFriendResponse: req.keyword, len(req.message) if req.message else 0, ) return AddFriendResponse(success=True, error=None) + + +# --------------------------------------------------------------------------- +# 路由:好友申请自动通过 +# --------------------------------------------------------------------------- +@router.get("/api/friends/requests", response_model=FriendRequestsResponse) +@with_db_retry +async def list_friend_requests(limit: int = 50) -> FriendRequestsResponse: + """查询待处理的好友申请列表(从 fmessage 系统消息解析)。 + + Args: + limit: 1~200,默认 50 + + Returns: + FriendRequestsResponse:含 requests 列表 / total + + Raises: + BridgeError(INVALID_PARAMS): limit 越界(HTTP 400) + BridgeError(DB_NOT_FOUND): 未找到微信消息 DB(HTTP 500) + BridgeError(DB_ENCRYPTED): DB 已加密(HTTP 503) + """ + if limit < 1 or limit > 200: + raise BridgeError( + code="INVALID_PARAMS", + message=f"limit 必须在 1~200 之间,收到 {limit}", + ) + db_reader = _require_db_reader() + await _check_db_readable() + + # get_friend_requests_since(cursor_create_time, cursor_local_id, limit) + # 查询全部待处理申请:cursor_create_time=0, cursor_local_id=0 + result = await asyncio.to_thread( + db_reader.get_friend_requests_since, 0, 0, limit + ) + if result is None: + # DB 不可读(_ensure_decrypted 抛 BridgeError 已被 _check_db_readable 拦截, + # 此处兜底防御) + return FriendRequestsResponse(requests=[], total=0) + + # 解析 XML 提取结构化信息 + requests = [] + for item in result.get("requests", []): + info = parse_friend_request( + item["content"], item["create_time"], item["local_id"] + ) + if info is not None: + requests.append(FriendRequestItem( + stranger_wxid=info.stranger_wxid, + nickname=info.nickname, + verify_message=info.verify_message, + scene=info.scene, + create_time=info.create_time, + )) + return FriendRequestsResponse(requests=requests, total=len(requests)) + + +@router.post("/api/friends/accept", response_model=AcceptFriendResponse) +async def accept_friend(req: AcceptFriendRequest) -> AcceptFriendResponse: + """手动通过好友申请(experimental)。 + + Args: + req: AcceptFriendRequest + + Returns: + AcceptFriendResponse + + Raises: + BridgeError(INVALID_PARAMS): stranger_wxid 为空(HTTP 400) + BridgeError(WECHAT_NOT_LOGGED_IN): 未登录(HTTP 401) + BridgeError(WINDOW_NOT_FOUND / SEND_FAILED / RATE_LIMITED) + """ + if not req.stranger_wxid: + raise BridgeError(code="INVALID_PARAMS", message="stranger_wxid 不能为空") + + xdotool = _require_xdotool() + send_queue = _require_send_queue() + + login_state = await xdotool.detect_login_state() + if login_state != "logged_in": + raise BridgeError( + code="WECHAT_NOT_LOGGED_IN", + message=f"当前登录态为 {login_state},无法通过好友申请", + ) + + try: + # lambda 用默认参数捕获 req(SendQueue.enqueue 延迟执行,避免变量被覆盖) + await send_queue.enqueue( + lambda req=req: xdotool.accept_friend_request( + stranger_wxid=req.stranger_wxid, + nickname=req.nickname or "", + ) + ) + except BridgeError: + # 透传 BridgeError(RATE_LIMITED / SEND_FAILED / WINDOW_NOT_FOUND 等) + raise + except Exception as e: + raise BridgeError(code="SEND_FAILED", message=f"通过好友申请失败: {e}") + + logger.info( + "friends/accept: wxid=%s nickname=%s → UI 操作完成", + req.stranger_wxid, req.nickname, + ) + return AcceptFriendResponse(success=True, error=None) + + +@router.get("/api/friends/auto_accept/config", response_model=AcceptRuleConfig) +async def get_auto_accept_config() -> AcceptRuleConfig: + """查询自动通过规则配置。""" + rule_engine = _require_rule_engine() + return await rule_engine.get_config() + + +@router.put("/api/friends/auto_accept/config", response_model=AcceptRuleConfig) +async def update_auto_accept_config(config: AcceptRuleConfig) -> AcceptRuleConfig: + """更新自动通过规则配置(热生效)。""" + rule_engine = _require_rule_engine() + await rule_engine.update_config(config) + # 同步更新 watcher 的 enabled 状态 + watcher = _require_friend_watcher() + watcher.set_enabled(config.enabled) + # 启用 auto_accept 时确保 watcher 协程已启动 + # (lifespan 仅在初始 auto_accept_enabled=True 时启动 watcher, + # 用户通过 API 从 False 切到 True 时需补启动;start() 幂等,已运行则跳过) + if config.enabled and not watcher.is_running: + await watcher.start() + return config + + +@router.get("/api/friends/auto_accept/status", response_model=AutoAcceptStatus) +async def get_auto_accept_status() -> AutoAcceptStatus: + """查询自动通过运行状态。""" + watcher = _require_friend_watcher() + return AutoAcceptStatus( + running=watcher.is_running, + enabled=watcher.is_enabled, + processed_count=watcher.processed_count, + accepted_count=watcher.accepted_count, + rejected_count=watcher.rejected_count, + last_processed_time=watcher.last_processed_time, + ) diff --git a/bridge/woc_bridge/ui/profiles/default.yaml b/bridge/woc_bridge/ui/profiles/default.yaml index 8dd6fff..945bb0c 100644 --- a/bridge/woc_bridge/ui/profiles/default.yaml +++ b/bridge/woc_bridge/ui/profiles/default.yaml @@ -72,3 +72,48 @@ main_view: threshold: 0.80 require_image: false description: 主聊天视图(post_verify 校验用) + +# 好友申请通过 UI 元素定位(experimental,坐标为估算值需实测校准) +contact_icon: + by_geom: + relative_to: window + x_ratio: 0.04 + y_ratio: 0.15 + x_offset: 0 + y_offset: 0 + threshold: 0.80 + require_image: false + description: 左侧导航栏通讯录图标 + +new_friend_entry: + by_geom: + relative_to: window + x_ratio: 0.04 + y_ratio: 0.22 + x_offset: 0 + y_offset: 0 + threshold: 0.80 + require_image: false + description: 通讯录页"新的朋友"入口 + +verify_button: + by_geom: + relative_to: window_bottom_right + x_ratio: 1.0 + y_ratio: 0.30 + x_offset: -80 + y_offset: 0 + threshold: 0.75 + require_image: false + description: 好友申请项"前往验证"按钮 + +confirm_button: + by_geom: + relative_to: window_bottom_right + x_ratio: 0.5 + y_ratio: 1.0 + x_offset: 0 + y_offset: -60 + threshold: 0.75 + require_image: false + description: "通过朋友验证"弹窗确定按钮 diff --git a/bridge/woc_bridge/ui/profiles/wechat_4.0_1280x720.yaml b/bridge/woc_bridge/ui/profiles/wechat_4.0_1280x720.yaml index e826dcd..7bbe705 100644 --- a/bridge/woc_bridge/ui/profiles/wechat_4.0_1280x720.yaml +++ b/bridge/woc_bridge/ui/profiles/wechat_4.0_1280x720.yaml @@ -79,3 +79,48 @@ main_view: threshold: 0.75 require_image: false description: 主聊天视图(post_verify 校验用,阈值略低因区域大) + +# 好友申请通过 UI 元素定位(experimental,坐标为估算值需实测校准) +contact_icon: + by_geom: + relative_to: window + x_ratio: 0.04 + y_ratio: 0.15 + x_offset: 0 + y_offset: 0 + threshold: 0.80 + require_image: false + description: 左侧导航栏通讯录图标 + +new_friend_entry: + by_geom: + relative_to: window + x_ratio: 0.04 + y_ratio: 0.22 + x_offset: 0 + y_offset: 0 + threshold: 0.80 + require_image: false + description: 通讯录页"新的朋友"入口 + +verify_button: + by_geom: + relative_to: window_bottom_right + x_ratio: 1.0 + y_ratio: 0.30 + x_offset: -53 + y_offset: 0 + threshold: 0.75 + require_image: false + description: 好友申请项"前往验证"按钮 + +confirm_button: + by_geom: + relative_to: window_bottom_right + x_ratio: 0.5 + y_ratio: 1.0 + x_offset: 0 + y_offset: -40 + threshold: 0.75 + require_image: false + description: "通过朋友验证"弹窗确定按钮 diff --git a/bridge/woc_bridge/ui/profiles/wechat_4.0_1920x1080.yaml b/bridge/woc_bridge/ui/profiles/wechat_4.0_1920x1080.yaml index ee05823..6fa4319 100644 --- a/bridge/woc_bridge/ui/profiles/wechat_4.0_1920x1080.yaml +++ b/bridge/woc_bridge/ui/profiles/wechat_4.0_1920x1080.yaml @@ -84,3 +84,48 @@ main_view: threshold: 0.75 require_image: false description: 主聊天视图(post_verify 校验用,阈值略低因区域大) + +# 好友申请通过 UI 元素定位(experimental,坐标为估算值需实测校准) +contact_icon: + by_geom: + relative_to: window + x_ratio: 0.04 + y_ratio: 0.15 + x_offset: 0 + y_offset: 0 + threshold: 0.80 + require_image: false + description: 左侧导航栏通讯录图标 + +new_friend_entry: + by_geom: + relative_to: window + x_ratio: 0.04 + y_ratio: 0.22 + x_offset: 0 + y_offset: 0 + threshold: 0.80 + require_image: false + description: 通讯录页"新的朋友"入口 + +verify_button: + by_geom: + relative_to: window_bottom_right + x_ratio: 1.0 + y_ratio: 0.30 + x_offset: -80 + y_offset: 0 + threshold: 0.75 + require_image: false + description: 好友申请项"前往验证"按钮 + +confirm_button: + by_geom: + relative_to: window_bottom_right + x_ratio: 0.5 + y_ratio: 1.0 + x_offset: 0 + y_offset: -60 + threshold: 0.75 + require_image: false + description: "通过朋友验证"弹窗确定按钮 diff --git a/bridge/woc_bridge/ui/xdotool_driver.py b/bridge/woc_bridge/ui/xdotool_driver.py index d60fabc..5c8d61e 100644 --- a/bridge/woc_bridge/ui/xdotool_driver.py +++ b/bridge/woc_bridge/ui/xdotool_driver.py @@ -44,6 +44,14 @@ _HTTP_TIMEOUT_SEC = 60.0 # 配合 _open_session_by_name 的流程超时,send_text 总上限受 L3 兜底。 _SEND_BODY_TIMEOUT_SEC = 15.0 +# 好友申请通过 UI 几何常量(基于 1920x1080,坐标为估算值需实测校准) +_CONTACT_ICON_X_RATIO = 0.04 # 通讯录图标 X 比例(左侧导航栏第 2 个图标) +_CONTACT_ICON_Y_RATIO = 0.15 # 通讯录图标 Y 比例 +_NEW_FRIEND_ENTRY_Y_RATIO = 0.22 # "新的朋友"入口 Y 比例(通讯录页顶部) +_VERIFY_BUTTON_X_OFFSET = -80 # "前往验证"按钮距右侧偏移(向左为负) +_VERIFY_BUTTON_Y_RATIO = 0.30 # 第一条申请项"前往验证"按钮 Y 比例 +_CONFIRM_BUTTON_Y_OFFSET = -60 # "确定"按钮距底部偏移(向上为负) + class XdotoolDriver: """xdotool/xclip 异步驱动。 @@ -1743,6 +1751,114 @@ class XdotoolDriver: ) return True + # ------------------------------------------------------------------ + # 通过好友申请(experimental) + # ------------------------------------------------------------------ + async def accept_friend_request( + self, + stranger_wxid: str = "", + nickname: str = "", + timeout_sec: float = 25.0, + ) -> bool: + """通过好友申请(experimental)。 + + UI 路径(基于截图 04/05): + 1. 激活微信窗口 + 2. 点击左侧导航栏"通讯录"图标 + 3. 点击"新的朋友"入口 + 4. 定位目标申请项(按昵称匹配,空则取第一条) + 5. 点击该项右侧"前往验证"按钮 + 6. 在弹窗中点击"确定" + 7. Esc 返回主界面 + + Args: + stranger_wxid: 申请人 wxid(用于日志,当前版本不参与 UI 定位) + nickname: 申请人昵称(用于在列表中匹配定位,空则取第一条) + timeout_sec: 整体超时 + + Returns: + True 表示 UI 操作流程执行完成 + + Raises: + BridgeError(WINDOW_NOT_FOUND / SEND_FAILED) + + Notes: + - 使用 _click_at(链式命令)而非 _click(两条命令),减少子进程创建 + - 当前版本仅点击列表第一条申请项,昵称匹配待后续实现 + - 所有坐标均为估算值,需在目标分辨率实测调优 + """ + deadline = time.monotonic() + timeout_sec + + async def _step(coro: Awaitable[None], desc: str) -> None: + remaining = deadline - time.monotonic() + if remaining <= 0: + if asyncio.iscoroutine(coro): + coro.close() + raise BridgeError( + code="SEND_FAILED", + message=f"通过好友申请超时: {desc}", + ) + try: + await asyncio.wait_for( + coro, timeout=max(_STEP_MIN_TIMEOUT_SEC, remaining) + ) + except asyncio.TimeoutError as exc: + raise BridgeError( + code="SEND_FAILED", + message=f"通过好友申请步骤超时: {desc}", + ) from exc + + # 1. 激活窗口 + window_id = await self.find_wechat_window() + if window_id is None: + raise BridgeError( + code="WINDOW_NOT_FOUND", + message="未找到微信窗口,无法通过好友申请", + ) + await _step(self._activate_window_fast(window_id), "激活窗口") + await _step(self._sleep(0.3), "等待窗口激活") + + # 关闭可能存在的弹窗 + await _step(self._key("Escape"), "关闭弹窗") + await _step(self._sleep(0.2), "等待 Esc 生效") + + # 2. 获取窗口几何 + win_x, win_y, win_w, win_h = await self._get_window_geometry() + + # 3. 点击通讯录图标(左侧导航栏第 2 个) + contact_x = win_x + int(win_w * _CONTACT_ICON_X_RATIO) + contact_y = win_y + int(win_h * _CONTACT_ICON_Y_RATIO) + await _step(self._click_at(contact_x, contact_y, "通讯录图标"), "点击通讯录图标") + await _step(self._sleep(0.8), "等待通讯录页面加载") + + # 4. 点击"新的朋友"入口 + new_friend_y = win_y + int(win_h * _NEW_FRIEND_ENTRY_Y_RATIO) + await _step(self._click_at(contact_x, new_friend_y, "新的朋友"), "点击新的朋友") + await _step(self._sleep(0.8), "等待新的朋友列表加载") + + # 5. 定位并点击目标申请项的"前往验证"按钮 + # 当前版本取第一条申请项(昵称匹配待后续实现) + verify_x = win_x + win_w + _VERIFY_BUTTON_X_OFFSET # 负偏移 = 向左 + verify_y = win_y + int(win_h * _VERIFY_BUTTON_Y_RATIO) + await _step(self._click_at(verify_x, verify_y, "前往验证"), "点击前往验证") + await _step(self._sleep(1.0), "等待验证弹窗打开") + + # 6. 点击弹窗"确定"按钮 + confirm_x = win_x + win_w // 2 + confirm_y = win_y + win_h + _CONFIRM_BUTTON_Y_OFFSET # 负偏移 = 向上 + await _step(self._click_at(confirm_x, confirm_y, "确定"), "点击确定") + await _step(self._sleep(0.8), "等待通过操作完成") + + # 7. 返回主界面 + await _step(self._key("Escape"), "返回主界面") + await _step(self._sleep(0.3), "等待返回") + + logger.info( + "accept_friend_request: wxid=%s nickname=%s → UI 操作完成", + stranger_wxid, nickname, + ) + return True + # ------------------------------------------------------------------ # 朋友圈互动(experimental) # ------------------------------------------------------------------