feat: 新增好友申请自动通过功能

- 新增好友申请XML解析器,支持解析微信4.x Linux的fmessage系统消息
- 新增好友申请自动通过规则引擎,支持黑白名单、关键词、场景过滤
- 新增xdotool驱动的好友申请UI自动化流程
- 新增后台好友申请监听器,支持增量轮询与状态统计
- 新增配套API接口,支持查询申请列表、手动/自动通过、配置管理
- 新增多分辨率UI定位配置,适配1280x720和1920x1080分辨率
- 新增配置项支持自动通过功能的开关与规则配置
This commit is contained in:
Kris 2026-07-16 13:03:58 +08:00
parent 15fc62d478
commit 31953eedf4
12 changed files with 1246 additions and 3 deletions

View File

@ -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

View File

@ -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

View File

@ -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_time0 = 从最新开始
cursor_local_id: create_time 下已处理的 local_idtie-breaker
limit: 单次读取上限
Returns:
{"requests": list[dict], "next_create_time": int, "next_local_id": int}
NoneDB 不可读
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()
# ------------------------------------------------------------------
# 朋友圈MomentsDB 探测与读取
# ------------------------------------------------------------------

View 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 ""

View 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=300s5 分钟内不重复处理同一申请人)
# 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,
)

View File

@ -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",
]

View File

@ -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

View File

@ -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): 未找到微信消息 DBHTTP 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 用默认参数捕获 reqSendQueue.enqueue 延迟执行,避免变量被覆盖)
await send_queue.enqueue(
lambda req=req: xdotool.accept_friend_request(
stranger_wxid=req.stranger_wxid,
nickname=req.nickname or "",
)
)
except BridgeError:
# 透传 BridgeErrorRATE_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,
)

View File

@ -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: "通过朋友验证"弹窗确定按钮

View File

@ -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: "通过朋友验证"弹窗确定按钮

View File

@ -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: "通过朋友验证"弹窗确定按钮

View File

@ -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
# ------------------------------------------------------------------