feat: 新增好友申请自动通过功能
- 新增好友申请XML解析器,支持解析微信4.x Linux的fmessage系统消息 - 新增好友申请自动通过规则引擎,支持黑白名单、关键词、场景过滤 - 新增xdotool驱动的好友申请UI自动化流程 - 新增后台好友申请监听器,支持增量轮询与状态统计 - 新增配套API接口,支持查询申请列表、手动/自动通过、配置管理 - 新增多分辨率UI定位配置,适配1280x720和1920x1080分辨率 - 新增配置项支持自动通过功能的开关与规则配置
This commit is contained in:
parent
15fc62d478
commit
31953eedf4
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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_<MD5("fmessage")> 单表,避免遍历所有分片。
|
||||
复合游标 (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_<MD5(talker)> 单表,避免遍历所有分片。
|
||||
返回 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 探测与读取
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
84
bridge/woc_bridge/messaging/friend_parser.py
Normal file
84
bridge/woc_bridge/messaging/friend_parser.py
Normal file
@ -0,0 +1,84 @@
|
||||
"""fmessage 系统消息 XML 解析器。
|
||||
|
||||
解析微信 4.x Linux 的 fmessage sysmsg XML,提取好友申请信息。
|
||||
无状态函数,便于单测。
|
||||
|
||||
XML 格式(推断,需环境验证):
|
||||
<sysmsg type="verifyUser" ...>
|
||||
<Link ...>
|
||||
<UserName>xxx@stranger</UserName>
|
||||
<NickName>申请人昵称</NickName>
|
||||
<Content>验证消息内容</Content>
|
||||
<Scene>...</Scene>
|
||||
</Link>
|
||||
</sysmsg>
|
||||
"""
|
||||
|
||||
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 ""
|
||||
312
bridge/woc_bridge/messaging/friend_watcher.py
Normal file
312
bridge/woc_bridge/messaging/friend_watcher.py
Normal file
@ -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,
|
||||
)
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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: "通过朋友验证"弹窗确定按钮
|
||||
|
||||
@ -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: "通过朋友验证"弹窗确定按钮
|
||||
|
||||
@ -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: "通过朋友验证"弹窗确定按钮
|
||||
|
||||
@ -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)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Loading…
Reference in New Issue
Block a user