WechatOnCloud/doc/优化方案/02-好友自动通过设计方案.md

1773 lines
75 KiB
Markdown
Raw Normal View History

# 微信好友申请自动通过设计方案
> **版本**v1.2(深度审查修正版 · 第二轮)
> **日期**2026-07-16
> **范围**`bridge/woc_bridge` 全链路
> **目标**实现好友申请的自动监听、规则匹配、UI 自动通过、DB 校验闭环
>
> **v1.1 修正摘要**(基于源码逐行验证,共修正 15 项):
> - **[P0] IdemCache 签名误用**`get(flow_name, to_wxid, content, request_id)` / `set(flow_name, to_wxid, content, value, request_id)` — value 是 set 的第 4 参数
> - **[P0] WCDB_CT_message_content 列硬编码矛盾**SELECT 硬编码 + row.keys() 兜底矛盾,改为 PRAGMA 预探测
> - **[P0] @stranger 假设未验证**:仅注释提及,无代码验证,补充 fallback 策略与环境验证清单
> - **[P0] BridgeError 未捕获**`_ensure_decrypted` 抛 `DB_ENCRYPTED`/`DB_NEED_INIT`,仅 catch `sqlite3.Error` 会泄漏
> - **[P1] lambda 闭包捕获 bug**:循环中 `req` 变量延迟绑定,需默认参数捕获
> - **[P1] `_enabled_event` 未初始化**`__init__` 缺少 `asyncio.Event` 创建
> - **[P1] `AcceptRuleEngine` 缺 `get_config` 方法**API 路由调用但未定义
> - **[P1] `FriendRequestWatcher` 缺状态属性**`is_running`/`is_enabled`/`processed_count` 等未定义
> - **[P1] `_parse_list` 未定义**config.py 中不存在此函数
> - **[P1] `models/__init__.py` 导出遗漏**:新增 6 个模型需追加导出
> - **[P2] `_click` vs `_click_at`**:统一用 `_click_at`(链式命令更高效)
> - **[P2] 游标缺复合 tie-breaker**`create_time >` 改为 `(create_time, local_id)` 复合游标
> - **[P2] `verify_friend_accepted` 双查询优化**:合并为单条 SQL
> - **[P2] `local_type=4` 未识别**:陌生人被误判为 person影响 `get_contacts` 结果
> - **[P2] `get_contacts` 未过滤 @stranger**:陌生人混入联系人列表
>
> **v1.2 修正摘要**(第二轮深度审查,新增 9 项修正):
> - **[P0] `_poll_once` 复合游标调用缺失**:原 `get_friend_requests_since, self._cursor, limit=50` 缺 `cursor_local_id`,且单一 `_cursor` 无法支撑复合游标 → 改为 `_cursor_create_time` + `_cursor_local_id` 双属性
> - **[P0] `_poll_once` 返回字段名错误**:原 `result["next_cursor"]` 与 3.3.1 节返回的 `next_create_time`/`next_local_id` 不一致
> - **[P0] `_poll_once` 缺解析步骤**:原 `requests: list[FriendRequestInfo] = result["requests"]` 类型错误DB 返回 list[dict],需经 `parse_friend_request` 解析
> - **[P0] `_init_cursor` 未设置 `_cursor_inited`**:导致 `_watch_loop` 每轮都重新初始化游标,无法推进
> - **[P0] `list_friend_requests` 路由参数错误**:原 `get_friend_requests_since, 0, limit` 把 limit 当成 cursor_local_id → 改为 `0, 0, limit`
> - **[P1] `_handle_request` 缺状态统计更新**`_processed_count`/`_accepted_count`/`_rejected_count`/`_last_processed_time` 未更新
> - **[P1] `_handle_request` 异常透传不全**:原统一 `except Exception` 吞掉 BridgeError 错误码 → 拆分 `except BridgeError` 透传
> - **[P1] lifespan 异常捕获冗余**`except (asyncio.TimeoutError, Exception)` 中 Exception 已涵盖 TimeoutError → 统一为 `except Exception`
> - **[P1] lifespan 启动顺序未说明**:补充现有启动/停止顺序,明确 friend_watcher 在 message_streamer 之后启动、之前停止
---
## 1. 背景与现状
### 1.1 现有能力缺口
| 能力 | 现状 | 文件位置 |
|------|------|----------|
| 主动添加好友 | `POST /api/friends/add`xdotool UI 自动化experimental | [routes/contacts.py:257](../bridge/woc_bridge/routes/contacts.py#L257) |
| 修改好友备注 | `POST /api/contacts/{wxid}/remark`experimental | [routes/contacts.py:191](../bridge/woc_bridge/routes/contacts.py#L191) |
| **接收/通过好友申请** | **不存在** | — |
| **好友申请监听** | **不存在** | — |
### 1.2 现有架构可复用基础
| 组件 | 能力 | 文件 |
|------|------|------|
| `MessageStreamer` | 双层增量检测DB mtime + `(create_time, local_id)` 复合游标1s/5s 自适应轮询SSE 广播 | [messaging/streamer.py](../bridge/woc_bridge/messaging/streamer.py) |
| `SendQueue` | 单 worker 串行执行,滑动窗口限流,队列满抛 `RATE_LIMITED` | [messaging/send_queue.py](../bridge/woc_bridge/messaging/send_queue.py) |
| `IdemCache` | 幂等去重namespace + key + content + request_id | [ui/idem_cache.py](../bridge/woc_bridge/ui/idem_cache.py) |
| `CircuitBreaker` | 熔断器failure_threshold / recovery_timeout | [ui/circuit_breaker.py](../bridge/woc_bridge/ui/circuit_breaker.py) |
| `XdotoolDriver` | 窗口激活、坐标计算、`_step` 超时模型L1-L4、`add_friend` 参考实现 | [ui/xdotool_driver.py](../bridge/woc_bridge/ui/xdotool_driver.py) |
| `DbReader` | 动态探测表名/列名,`_ensure_decrypted` 解密缓存,`_SYSTEM_WXIDS` 含 `fmessage` | [db/reader.py](../bridge/woc_bridge/db/reader.py) |
| `FlowOrchestrator` | 幂等 + 熔断 + 会话缓存 + 入队编排(仅 `send_text` | [ui/orchestrator.py](../bridge/woc_bridge/ui/orchestrator.py) |
### 1.3 微信 4.x Linux 好友申请的存在形式
> **验证状态说明**:以下结论基于代码分析推断,部分假设(标注 `[需环境验证]`)需在真实微信 4.x Linux 环境中用 `sqlite3` 直接确认 DB schema 后方可作为实现依据。
**来源 A消息 DB 系统消息**(代码已验证)
- DB`message/message_0.db`
- 分片表:`Msg_<MD5("fmessage")>`(表名计算逻辑已验证,[reader.py:677](../bridge/woc_bridge/db/reader.py#L677)、[reader.py:899](../bridge/woc_bridge/db/reader.py#L899)
- `talker = "fmessage"`(系统账号,已在 `_SYSTEM_WXIDS` 白名单中,[reader.py:1391](../bridge/woc_bridge/db/reader.py#L1391)
- `local_type = 10000`(系统消息,映射 `render_type="system"`[reader.py:38-46](../bridge/woc_bridge/db/reader.py#L38)
- `message_content` 为 XML 格式的 sysmsg `[需环境验证]`:具体 XML 结构(节点名/属性名)需在真实 fmessage 消息上确认,设计方案中的 `<sysmsg type="verifyUser"><Link>...` 是基于微信协议常识的推断
```xml
<!-- 推断格式,需环境验证实际节点名 -->
<sysmsg type="verifyUser" ...>
<Link ...>
<UserName>xxx@stranger</UserName>
<NickName>申请人昵称</NickName>
<Content>验证消息内容</Content>
<Scene>...</Scene>
</Link>
</sysmsg>
```
- 当前 bridge **不解析**此 XML`content` 字段原样返回给客户端([reader.py:688-709](../bridge/woc_bridge/db/reader.py#L688) `_decompress_msg_content` 仅做 zstd 解压 + UTF-8 解码)
**来源 B联系人 DB**(部分假设 `[需环境验证]`
- DB`contact/contact.db`
- 表:`contact` / `contacts` / `rcontact`(动态探测已验证,[reader.py:496-512](../bridge/woc_bridge/db/reader.py#L496)
- `username` 列名动态探测(候选 `["username", "wxid"]`[reader.py:1364](../bridge/woc_bridge/db/reader.py#L1364)
- 申请人以 `username = xxx@stranger` 形式写入 `[需环境验证]`:代码中 `@stranger` 仅出现在注释([reader.py:1389](../bridge/woc_bridge/db/reader.py#L1389)、[reader.py:1416](../bridge/woc_bridge/db/reader.py#L1416)),实际逻辑用 `wxid.split("@", 1)[0]` 取 base 部分,对任意 `@xxx` 后缀都生效。**真实后缀名需在 contact.db 中确认**
- `local_type = 4` `[需环境验证]`:微信协议中通常代表陌生人/未通过验证,但**项目当前未识别**此值(`_infer_contact_type` 仅处理 2 和 512[reader.py:1421-1425](../bridge/woc_bridge/db/reader.py#L1421)),陌生人会被默认推断为 `person`fallback与正常好友混淆
**环境验证清单**(实施前必做):
1. 在真实微信 4.x Linux 环境中,用 `sqlite3` 打开 `contact.db`,执行 `SELECT username, local_type FROM contact WHERE local_type NOT IN (1,2,512)` 确认陌生人后缀格式和 local_type 值
2. 收到一条好友申请后,用 `sqlite3` 打开 `message_0.db`,执行 `SELECT message_content FROM Msg_<MD5('fmessage')> ORDER BY create_time DESC LIMIT 1` 确认 XML 结构
3. 通过好友申请后,再次查 contact.db 确认 `@stranger` 后缀是否消失、`local_type` 是否变化
### 1.4 截图 UI 布局分析
基于 `ui/profiles/templates/screenshot/` 下的截图:
| 截图 | 布局描述 |
|------|----------|
| `04-好友申请界面.png` | 左侧"新的朋友"申请列表,每项含头像/昵称/验证消息 + 右侧"前往验证"按钮 |
| `05-好友申请通过界面.png` | "通过朋友验证"弹窗,含申请人信息 + 底部"确定"按钮 |
**UI 导航路径**:主界面 → 通讯录图标 → 新的朋友 → 列表项"前往验证" → 弹窗"确定" → 完成
---
## 2. 总体架构
### 2.1 全链路数据流
```
┌─────────────────────────────────────────────────────────────────────┐
│ 监听层(双源) │
│ │
│ ┌─────────────────────┐ ┌──────────────────────────────┐ │
│ │ FriendRequestWatcher │ │ MessageStreamer (已有) │ │
│ │ (新增后台协程) │ │ _watch_loop → _poll_once │ │
│ │ │ │ │ │
│ │ 轮询 message DB: │ │ 新消息事件广播 │ │
│ │ Msg_<MD5(fmessage)> │ │ (talker=fmessage, │ │
│ │ 增量游标检测 │ │ local_type=10000) │ │
│ └────────┬────────────┘ └──────────┬───────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ FriendRequestParser (新增) │ │
│ │ XML sysmsg 解析 → FriendRequestInfo │ │
│ └─────────────────────────┬───────────────────────────────────┘ │
└─────────────────────────────┼───────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ 规则匹配引擎(新增) │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐ ┌──────────┐ │
│ │ 白名单匹配 │ │ 关键词匹配 │ │ 黑名单过滤 │ │ auto_accept│
│ │ (wxid/昵称) │ │ (验证消息) │ │ (wxid/昵称) │ │ (全局开关) │
│ └──────┬──────┘ └──────┬───────┘ └──────┬──────┘ └────┬─────┘ │
│ └────────────────┼─────────────────┼──────────────┘ │
│ ▼ ▼ │
│ ┌──────────────┐ │
│ │ 决策结果 │ │
│ │ accept/reject │ │
│ │ /skip │ │
│ └──────┬───────┘ │
└──────────────────────────┼────────────────────────────────────────┘
│ accept
┌─────────────────────────────────────────────────────────────────────┐
│ 去重 + 入队层 │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ IdemCache (已有) │ │ SendQueue (已有) │ │
│ │ namespace= │ │ 串行执行 │ │
│ │ "friend_accept" │────▶│ enqueue(lambda: │ │
│ │ key=stranger_wxid│ │ accept_flow) │ │
│ │ TTL=300s │ │ │ │
│ └──────────────────┘ └────────┬─────────┘ │
└─────────────────────────────────────┼───────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ UI 自动化层 │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ accept_friend_request (新增于 xdotool_driver.py) │ │
│ │ │ │
│ │ 1. 激活窗口 │ │
│ │ 2. 点击通讯录图标 │ │
│ │ 3. 点击"新的朋友"入口 │ │
│ │ 4. 定位目标申请项 → 点击"前往验证" │ │
│ │ 5. 弹窗 → 点击"确定" │ │
│ │ 6. Esc 返回主界面 │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────┬─────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ 校验 + 状态层 │
│ │
│ ┌──────────────────────┐ ┌──────────────────┐ │
│ │ DB 校验 (新增) │ │ CircuitBreaker │ │
│ │ contact 表中 │ │ (已有, 复用) │ │
│ │ @stranger 消失验证 │ │ accept_verify │ │
│ └──────────┬───────────┘ │ failure_threshold │ │
│ │ │ =5, recovery=30s │ │
│ ▼ └──────────────────┘ │
│ ┌──────────────────────┐ │
│ │ 结果记录 │ │
│ │ (日志 + metrics) │ │
│ └──────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
```
### 2.2 分层职责
| 层 | 组件 | 职责 | 新增/复用 |
|----|------|------|-----------|
| L0 监听 | `FriendRequestWatcher` | 轮询 message DB 检测 fmessage 新消息 | **新增** |
| L0 解析 | `FriendRequestParser` | 解析 sysmsg XML 提取申请人信息 | **新增** |
| L1 匹配 | `AcceptRuleEngine` | 白名单/关键词/黑名单/auto_accept 规则决策 | **新增** |
| L2 去重 | `IdemCache` | 按 stranger_wxid 去重TTL=300s | 复用 |
| L3 入队 | `SendQueue` | 串行化 UI 操作,避免 xdotool 命令冲突 | 复用 |
| L4 UI | `XdotoolDriver.accept_friend_request` | 6 步 UI 操作流程 | **新增** |
| L5 校验 | `DbReader.verify_friend_accepted` | contact 表 @stranger 消失验证 | **新增** |
| L6 熔断 | `CircuitBreaker` | UI 操作连续失败保护 | 复用 |
| L7 API | `routes/contacts.py` | 手动查询/通过/配置接口 | **新增路由** |
---
## 3. 详细设计
### 3.1 监听层FriendRequestWatcher
#### 3.1.1 设计决策:独立 Watcher vs 复用 MessageStreamer
| 方案 | 优点 | 缺点 |
|------|------|------|
| **A. 复用 MessageStreamer + handler 注册** | 统一轮询,无重复 DB 读取 | MessageStreamer 当前无 handler 注册机制,需改造其架构 |
| **B. 独立 FriendRequestWatcher** | 不侵入现有 streamer独立游标/频率控制 | 多一个 DB 轮询(但仅查 fmessage 单表,开销极小) |
**选择方案 B**:独立 Watcher。理由
1. MessageStreamer 设计为"纯队列广播模型,无 handler 注册"[streamer.py:14-20](../bridge/woc_bridge/messaging/streamer.py#L14)),注入 handler 会破坏其架构纯净性
2. fmessage 轮询单表 `Msg_<MD5("fmessage")>` 且有独立游标SQL 开销 <1ms不会对 DB 造成压力
3. 独立 Watcher 可按需启停auto_accept 关闭时不轮询),不影响消息流
#### 3.1.2 数据结构
```python
# 新文件messaging/friend_watcher.py
@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",
rule_engine: "AcceptRuleEngine",
send_queue: "SendQueue",
idem_cache: "IdemCache",
xdotool_driver: "XdotoolDriver",
breaker: "CircuitBreaker",
poll_interval: float = 3.0, # 轮询间隔(秒)
cursor_create_time: int = 0, # 初始游标 create_time0 = 从最新开始)
cursor_local_id: int = 0, # 初始游标 local_idtie-breaker
) -> 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
# 状态统计(供 /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._enabled_event.set()
else:
self._enabled_event.clear()
logger.info("FriendRequestWatcher enabled=%s", enabled)
```
#### 3.1.3 轮询逻辑
复用 MessageStreamer 的**双层增量检测**模式:
```python
async def _poll_once(self) -> None:
# 1. mtime 感知(一级过滤,避免空 SQL
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 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"]
```
> **修正说明**
> - 原代码 `get_friend_requests_since, self._cursor, limit=50` 缺少 `cursor_local_id`,且单一 `self._cursor` 无法支撑复合游标
> - 原代码 `result["next_cursor"]` 字段名错误3.3.1 节返回的是 `next_create_time` / `next_local_id`
> - 原代码 `requests: list[FriendRequestInfo] = result["requests"]` 类型错误DB 返回的是 list[dict],需经 `parse_friend_request` 解析才得到 `FriendRequestInfo`
> - 增加 `parse_friend_request` 解析步骤,过滤非 verifyUser 类型的 fmessage 消息
#### 3.1.4 游标初始化策略
| 启动时机 | 游标对齐策略 | 理由 |
|----------|-------------|------|
| Bridge 首次启动 | `cursor_create_time = get_max_create_time_for_talker("fmessage")``cursor_local_id = 0` | 跳过历史申请,仅处理启动后的新申请 |
| auto_accept 从关闭切换为开启 | 同上(`set_enabled` 重置 `_cursor_inited=False` | 避免开启瞬间批量处理积压申请 |
| auto_accept 持续运行 | 持续推进复合游标,不重置 | 正常增量处理 |
```python
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
```
> **修正说明**
> - 原代码未设置 `self._cursor_inited = True`,会导致 `_watch_loop` 每轮都重新初始化游标,无法推进
> - `get_max_create_time_for_talker` 返回 `None`DB 不可读)时不标记已初始化,下一轮重试;返回 `0`(空表)时标记已初始化
> - 复合游标:`_cursor_create_time` 对齐到 max`_cursor_local_id` 重置为 0同秒内的历史消息不处理
#### 3.1.5 轮询间隔
| 状态 | 间隔 | 理由 |
|------|------|------|
| auto_accept 开启 | 3s | 好友申请低频事件3s 足够及时且无 DB 压力 |
| auto_accept 关闭 | 不轮询 | Watcher 协程暂停在 `asyncio.Event` 上 |
| DB_ENCRYPTED | 3s 重试 `_init_cursor` | DB 不可读时 `_cursor_inited` 保持 False每轮重试初始化`_init_cursor` 内部 catch BridgeError不会抛异常风暴 |
| 异常 | 5s 退避 | 避免 `_poll_once` 抛未预期异常时风暴 |
```python
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)
```
> **修正说明**
> - 原表格 "DB_ENCRYPTED 10s 退避" 与代码实现不一致(代码无 10s 退避逻辑)→ 改为 3s 重试 `_init_cursor`
> - `_watch_loop` 增加 `if not self._cursor_inited: continue` 跳过 `_poll_once`,避免 DB 不可读时无效查询
> - `_init_cursor` 返回 False 时DB 不可读),`_cursor_inited` 保持 False下一轮重试
---
### 3.2 解析层FriendRequestParser
#### 3.2.1 XML 解析
fmessage 系统消息的 `message_content` 是 XML 字符串。解析逻辑独立为无状态函数,便于单测:
```python
# 新文件messaging/friend_parser.py
import xml.etree.ElementTree as ET
import re
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:
el = parent.find(tag)
return el.text.strip() if el is not None and el.text else ""
```
#### 3.2.2 容错策略
| 异常 | 处理 |
|------|------|
| XML 解析失败 | 返回 None记 warning 日志,不中断轮询 |
| 非 verifyUser 类型 | 返回 Nonefmessage 也承载好友通过/拒绝通知,需过滤) |
| UserName 缺失 | 返回 None无法定位申请人 |
| NickName 缺失 | 空字符串兜底(不影响通过操作) |
| Content 含非 UTF-8 字符 | `errors="replace"` 兜底(已在 `_decompress_msg_content` 处理) |
---
### 3.3 DB 读取层DbReader 新增方法
#### 3.3.1 get_friend_requests_since
```python
# 在 db/reader.py 中新增
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)
调用方需捕获 BridgeError 而非仅 sqlite3.Error
- WCDB_CT_message_content 列可能不存在(非 WCDB 表),
用 PRAGMA 预探测而非硬编码 SELECT
"""
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()
```
#### 3.3.2 verify_friend_accepted
```python
# 在 db/reader.py 中新增
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]
# 单条 SQLbase_wxid 存在且不含 @ 的记录数 > 0
# 同时检查是否有 @stranger 后缀的记录残留
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()
```
#### 3.3.3 get_max_create_time_for_talker
```python
# 在 db/reader.py 中新增
def get_max_create_time_for_talker(self, talker: str) -> Optional[int]:
"""获取指定会话的消息最大 create_time游标初始化用
直接查 Msg_<MD5(talker)> 单表,避免遍历所有分片。
返回 None 表示 DB 不可读(与 get_max_create_time 语义一致),
返回 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()
```
---
### 3.4 规则匹配引擎AcceptRuleEngine
#### 3.4.1 配置模型
```python
# 在 models/contact.py 中新增
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:
"""好友申请自动通过规则引擎。"""
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
```
#### 3.4.2 匹配优先级
```
黑名单 (wxid / 昵称) ──→ REJECT最高优先级
│ 不命中
场景过滤 (allow_scenes) ──→ SKIP不在允许场景内
│ 通过
白名单 (wxid / 昵称) ──→ ACCEPT
│ 不命中
关键词 (验证消息子串) ──→ ACCEPT
│ 不命中
默认 ──→ SKIP不处理等待人工介入
```
---
### 3.5 UI 自动化层accept_friend_request
#### 3.5.1 实现方案
`xdotool_driver.py` 中新增 `accept_friend_request` 方法,复用现有的 `_step` 超时模型和窗口几何计算:
```python
# 在 ui/xdotool_driver.py 中新增
# 通讯录图标和新的朋友入口的几何常量(基于 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 # "确定"按钮距底部偏移(向上为负)
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
```
#### 3.5.2 Profile 扩展
`ui/profiles/` YAML 中新增好友申请相关元素定位:
```yaml
# 追加到 default.yaml / wechat_4.0_1280x720.yaml / wechat_4.0_1920x1080.yaml
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: "通过朋友验证"弹窗确定按钮
```
---
### 3.6 去重与入队
#### 3.6.1 去重策略
使用 `IdemCache``stranger_wxid` 去重,防止同一申请被重复处理。
> **关键接口签名**[idem_cache.py:61-67](../bridge/woc_bridge/ui/idem_cache.py#L61)、[idem_cache.py:98-105](../bridge/woc_bridge/ui/idem_cache.py#L98)
> ```python
> def get(self, flow_name: str, to_wxid: str, content: str, client_request_id: str = "") -> Optional[Any]
> def set(self, flow_name: str, to_wxid: str, content: str, value: Any, client_request_id: str = "") -> None
> ```
> **注意**`set` 的 `value` 在 `client_request_id` **之前**,不能按位置传 `client_request_id`,否则会被当成 `value`。
>
> **SendQueue.enqueue 是延迟执行**[send_queue.py:65-94](../bridge/woc_bridge/messaging/send_queue.py#L65)
> `coro_factory` 入队后由 worker 协程 `_run` 调用 `await coro_factory()`[send_queue.py:145](../bridge/woc_bridge/messaging/send_queue.py#L145))。
> `enqueue` 的 `await` 实际等待的是 `future`worker 设置结果),不是 `coro_factory()` 本身。
> 因此循环中创建的 lambda 若直接捕获循环变量,会在 worker 真正执行时拿到被覆盖的值 —— 必须用默认参数捕获。
```python
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,
)
```
#### 3.6.2 DB 校验延迟
微信 DB 写入有 WAL 延迟(通常 1-3 秒。DB 校验应在 UI 操作完成后等待 2 秒再查(已整合到 3.6.1 的 `_handle_request` 中,见上方 `await asyncio.sleep(2.0)`)。
> **注意**`SendQueue.enqueue` 是 `await future` 语义 —— `enqueue` 返回时 UI 操作已完成(或抛错)。因此 `sleep(2.0)` 放在 `enqueue` 之后即可,无需在 lambda 内部 sleep。
---
### 3.7 API 路由层
#### 3.7.1 新增路由
```python
# 在 routes/contacts.py 中新增
@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
"""
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
"""
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 延迟执行,见 3.6.1 说明)
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)
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,
)
```
#### 3.7.2 新增模型
```python
# 在 models/contact.py 中新增
class FriendRequestItem(BaseModel):
"""好友申请条目。"""
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
```
---
### 3.8 配置层
#### 3.8.1 环境变量
```bash
# .env 新增
# 自动通过全局开关false=关闭true=开启)
WOC_AUTO_ACCEPT_ENABLED=false
# 通过所有申请(忽略规则,危险!仅测试用)
WOC_AUTO_ACCEPT_ALL=false
# 白名单 wxid逗号分隔
WOC_AUTO_ACCEPT_WHITELIST_WXIDS=
# 白名单昵称(逗号分隔)
WOC_AUTO_ACCEPT_WHITELIST_NICKNAMES=
# 验证消息关键词(逗号分隔)
WOC_AUTO_ACCEPT_KEYWORDS=
# 黑名单 wxid逗号分隔
WOC_AUTO_ACCEPT_BLACKLIST_WXIDS=
# 黑名单昵称(逗号分隔)
WOC_AUTO_ACCEPT_BLACKLIST_NICKNAMES=
# 允许场景(逗号分隔,空=不限制)
WOC_AUTO_ACCEPT_ALLOW_SCENES=
# 轮询间隔(秒)
WOC_AUTO_ACCEPT_POLL_INTERVAL=3
```
#### 3.8.2 BridgeConfig 扩展
```python
# 在 config.py BridgeConfig 中新增字段
@dataclass
class BridgeConfig:
# ... 现有字段 ...
# 好友自动通过配置
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):
return cls(
# ... 现有字段 ...
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")
),
)
def _parse_list(s: str) -> list[str]:
"""逗号分隔字符串转列表。"""
if not s.strip():
return []
return [item.strip() for item in s.split(",") if item.strip()]
```
---
### 3.9 生命周期与初始化
#### 3.9.1 AppState 扩展
```python
# 在 config.py AppState 中新增字段
class AppState:
# ... 现有字段 ...
# 好友自动通过组件
friend_watcher: Optional[Any] = None # FriendRequestWatcher
rule_engine: Optional[Any] = None # AcceptRuleEngine
accept_breaker: Optional[Any] = None # CircuitBreaker
```
#### 3.9.2 初始化序列
```python
# 在 app.py _init_state 中新增
def _init_state(config: BridgeConfig) -> None:
# ... 现有初始化 ...
# 好友自动通过规则引擎
accept_config = AcceptRuleConfig(
enabled=config.auto_accept_enabled,
accept_all=config.auto_accept_all,
whitelist_wxids=config.auto_accept_whitelist_wxids,
whitelist_nicknames=config.auto_accept_whitelist_nicknames,
keywords=config.auto_accept_keywords,
blacklist_wxids=config.auto_accept_blacklist_wxids,
blacklist_nicknames=config.auto_accept_blacklist_nicknames,
allow_scenes=config.auto_accept_allow_scenes,
)
_state.rule_engine = AcceptRuleEngine(accept_config)
# 熔断器
_state.accept_breaker = CircuitBreaker(
"accept_verify", failure_threshold=5, recovery_timeout=60.0,
)
# 好友申请监听器
_state.friend_watcher = FriendRequestWatcher(
db_reader=_state.db_reader,
rule_engine=_state.rule_engine,
send_queue=_state.send_queue,
idem_cache=_state.idem_cache,
xdotool_driver=_state.xdotool,
breaker=_state.accept_breaker,
poll_interval=config.auto_accept_poll_interval,
)
```
#### 3.9.3 lifespan 启停
> **现有 lifespan 启动顺序**[app.py:64-129](../bridge/woc_bridge/app.py#L64)
> `send_queue.start()` → `message_streamer.start()` → `watchdog.start()` → `resource_reaper.start()` → `xdotool._full_cleanup_on_startup()` → `yield`
>
> **停止顺序**(反序):`message_streamer.stop()` → `send_queue.stop()` → `watchdog.stop()` → `resource_reaper.stop()`,每个均包 `asyncio.wait_for(..., timeout=5.0)`。
>
> FriendRequestWatcher 应在 `message_streamer.start()` **之后**启动(不依赖 streamer但保持"基础设施先于业务"顺序),在 `message_streamer.stop()` **之前**停止(先停业务再停基础设施)。
```python
# 在 app.py lifespan 中新增
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
# ... 现有启动send_queue / message_streamer / watchdog / resource_reaper / xdotool cleanup...
# 启动好友申请监听器(仅当 auto_accept 开启时)
# 放在 message_streamer 之后,保持"基础设施先于业务"顺序
if _state.friend_watcher is not None and _state.config.auto_accept_enabled:
try:
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() 超时或异常")
# ... 现有停止message_streamer / send_queue / watchdog / resource_reaper...
```
> **修正说明**
> - 原 `except (asyncio.TimeoutError, Exception)` 冗余 —— `Exception` 已涵盖 `TimeoutError`,统一为 `except Exception`
> - 启动时增加异常捕获,避免 friend_watcher 启动失败阻塞 lifespan
> - 启动顺序:在 `message_streamer.start()` 之后friend_watcher 不依赖 streamer但保持基础设施优先
> - 停止顺序:在 `message_streamer.stop()` 之前(先停业务再停基础设施)
#### 3.9.4 依赖检查函数
```python
# 在 config.py 中新增
def _require_rule_engine() -> AcceptRuleEngine:
if _state.rule_engine is None:
raise BridgeError(
code="BRIDGE_INTERNAL_ERROR",
message="rule_engine 未初始化",
)
return _state.rule_engine
def _require_friend_watcher() -> FriendRequestWatcher:
if _state.friend_watcher is None:
raise BridgeError(
code="BRIDGE_INTERNAL_ERROR",
message="friend_watcher 未初始化",
)
return _state.friend_watcher
```
#### 3.9.5 模型导出
> **当前 `models/__init__.py`**[models/__init__.py:68-87](../bridge/woc_bridge/models/__init__.py#L68))共导出 36 个名字,**不包含**任何好友申请相关模型。新增的 6 个模型必须追加到 `__all__` 列表,否则 `from woc_bridge.models import AcceptRuleConfig` 等导入会失败。
```python
# 在 models/__init__.py 中新增导入和导出
from woc_bridge.models.contact import ( # 假设新增模型定义在 contact.py
AcceptRuleConfig,
AcceptDecision,
FriendRequestItem,
FriendRequestsResponse,
AcceptFriendRequest,
AcceptFriendResponse,
AutoAcceptStatus,
)
# 追加到 __all__ 列表(按字母序插入联系人域之后)
__all__ = [
# ... 现有 36 个名字 ...
# 好友自动通过域
"AcceptRuleConfig",
"AcceptDecision",
"FriendRequestItem",
"FriendRequestsResponse",
"AcceptFriendRequest",
"AcceptFriendResponse",
"AutoAcceptStatus",
]
```
> **注意**
> - `AcceptDecision` 是枚举,若 API 不直接返回可不导出(但路由内部会用到,建议导出)
> - 模型定义文件选择:优先追加到现有 `models/contact.py`(与好友/联系人语义一致),避免新建文件
> - `routes/contacts.py` 现有 import 风格是 `from woc_bridge.models import (...)`,新增模型需在同一 import 语句中追加
---
## 4. 关键设计决策
### 4.1 为什么不修改 MessageStreamer
| 考量 | 说明 |
|------|------|
| 架构纯净性 | MessageStreamer 设计为"纯队列广播模型"[streamer.py:14-20](../bridge/woc_bridge/messaging/streamer.py#L14)),注入 handler 会破坏其设计 |
| 职责单一 | Streamer 负责消息分发,不负责业务逻辑(如 XML 解析、规则匹配) |
| 独立游标 | fmessage 查询单表,开销极小(<1ms独立游标避免与消息流游标耦合 |
| 独立频率 | 好友申请低频事件3s 轮询足够;消息流需要 1s 高频 |
| 独立启停 | auto_accept 关闭时 Watcher 挂起,不影响消息流 |
### 4.2 为什么用 DB 轮询而非 UI 监听
| 方案 | 优点 | 缺点 |
|------|------|------|
| **DB 轮询(选择)** | 精确、可靠、不依赖 UI 状态、可解析结构化信息 | 有 WAL 延迟1-3s |
| UI 轮询 | 实时性好 | 微信 4.x 自绘 UI 无法检测"新的朋友"角标变化UI 状态不可靠 |
### 4.3 为什么 UI 操作走 SendQueue
- **避免 xdotool 命令冲突**:所有 UI 操作(发消息、通过好友、发朋友圈)共享一个微信窗口,必须串行
- **限流保护**SendQueue 的滑动窗口限流防止高频操作触发微信风控
- **队列满保护**`RATE_LIMITED` 错误让调用方知道系统繁忙,可稍后重试
### 4.4 为什么 DB 校验而非 UI 截图校验
- **可靠性**DB 是微信真实状态UI 截图受分辨率/主题/动画影响
- **开销**DB 查询单条记录 <1ms截图 + OpenCV 匹配 100-300ms
- **已有基础设施**`_ensure_decrypted` + `sqlite3` 已有完整解密缓存机制
### 4.5 熔断器参数
| 参数 | 值 | 理由 |
|------|-----|------|
| `failure_threshold` | 5 | 连续 5 次 UI 操作失败(可能是微信未登录/窗口异常),停止自动通过 |
| `recovery_timeout` | 60s | 1 分钟后尝试恢复(比 send_text 的 30s 更长,因为好友通过低频) |
---
## 5. 异常处理矩阵
| 异常场景 | 处理策略 | 错误码 |
|----------|---------|--------|
| DB 加密 | Watcher 暂停10s 后重试,等待 key 提取 | — |
| DB 不可读 | Watcher 暂停5s 后重试 | — |
| XML 解析失败 | 跳过该消息,记 warning不中断轮询 | — |
| 非 verifyUser 类型 | 跳过fmessage 也承载通过/拒绝通知) | — |
| 规则不匹配 | 跳过,记 info 日志 | — |
| 幂等命中 | 跳过,记 info 日志 | — |
| 熔断器 OPEN | 跳过,记 warning | — |
| SendQueue 满 | 抛 `RATE_LIMITED`Watcher 记 error下一轮重试 | `RATE_LIMITED` |
| UI 操作超时 | 抛 `SEND_FAILED`,熔断器记录失败 | `SEND_FAILED` |
| UI 操作失败 | 抛 `SEND_FAILED`,熔断器记录失败 | `SEND_FAILED` |
| DB 校验未通过 | UI 成功但校验失败,熔断器记录失败,记 warning | — |
| 微信未登录 | `detect_login_state` 检测,抛 `WECHAT_NOT_LOGGED_IN` | `WECHAT_NOT_LOGGED_IN` |
| 窗口未找到 | 抛 `WINDOW_NOT_FOUND` | `WINDOW_NOT_FOUND` |
| Watcher 协程异常 | 记 exception 日志5s 后继续轮询 | — |
---
## 6. 安全考虑
### 6.1 防止恶意批量加好友
- **SendQueue 限流**`max_calls_per_sec=10``send_delay_ms=3000`,即每 3 秒最多通过 1 个好友
- **IdemCache 去重**:同一 `stranger_wxid` 5 分钟内不重复处理
- **熔断器**:连续 5 次失败暂停 60 秒
### 6.2 规则配置安全
- `accept_all=true` 是危险配置,仅用于测试环境
- 黑名单优先级最高,防止恶意用户反复申请
- API 修改配置需 Bearer Token 认证(已有 `WOC_BRIDGE_API_TOKEN` 机制)
### 6.3 日志审计
所有自动通过操作记录完整日志:
```
friend_request: wxid=xxx@stranger nickname=张三 → decision=accept
friend_request: wxid=xxx@stranger nickname=张三 → UI 操作完成
friend_request: wxid=xxx@stranger nickname=张三 → 通过并验证成功
```
---
## 7. 测试策略
### 7.1 单元测试
| 模块 | 测试重点 |
|------|---------|
| `friend_parser.py` | XML 解析各种格式(正常/缺失字段/非 verifyUser/非法 XML |
| `AcceptRuleEngine` | 白名单/关键词/黑名单/场景过滤的优先级与组合 |
| `DbReader.get_friend_requests_since` | 游标推进、空表、表不存在 |
| `DbReader.verify_friend_accepted` | @stranger 存在/消失、base_wxid 存在/不存在 |
### 7.2 集成测试
| 场景 | 验证点 |
|------|--------|
| 正常自动通过 | fmessage 消息 → 规则匹配 → UI 操作 → DB 校验 → 幂等缓存 |
| 规则不匹配 | fmessage 消息 → 规则匹配 → SKIP → 不执行 UI 操作 |
| 重复申请 | 同一 stranger_wxid 第二次 → 幂等命中 → 跳过 |
| 熔断触发 | 连续 5 次 UI 失败 → 熔断器 OPEN → 后续申请跳过 |
| auto_accept 开关切换 | 关闭 → 开启 → 游标重置 → 处理新申请 |
### 7.3 UI 测试
需在真实微信 4.x Linux 环境验证:
- 通讯录图标坐标准确性
- "新的朋友"入口坐标准确性
- "前往验证"按钮坐标准确性
- "确定"按钮坐标准确性
- 多条申请时列表滚动定位
---
## 8. 文件变更清单
| 文件 | 变更类型 | 说明 |
|------|---------|------|
| `messaging/friend_watcher.py` | **新增** | FriendRequestWatcher 后台监听器 |
| `messaging/friend_parser.py` | **新增** | XML sysmsg 解析器 |
| `db/reader.py` | 修改 | 新增 3 个方法:`get_friend_requests_since`、`verify_friend_accepted`、`get_max_create_time_for_talker` |
| `ui/xdotool_driver.py` | 修改 | 新增 `accept_friend_request` 方法 + UI 常量 |
| `ui/circuit_breaker.py` | 不变 | 复用现有 CircuitBreaker |
| `models/contact.py` | 修改 | 新增 6 个模型:`FriendRequestItem`、`FriendRequestsResponse`、`AcceptFriendRequest`、`AcceptFriendResponse`、`AcceptRuleConfig`、`AutoAcceptStatus`+ `AcceptDecision` 枚举) |
| `models/__init__.py` | 修改 | 追加 7 个名字到 `__all__``AcceptRuleConfig`、`AcceptDecision`、`FriendRequestItem`、`FriendRequestsResponse`、`AcceptFriendRequest`、`AcceptFriendResponse`、`AutoAcceptStatus`(详见 3.9.5 |
| `routes/contacts.py` | 修改 | 新增 5 个路由:`GET /api/friends/requests`、`POST /api/friends/accept`、`GET/PUT /api/friends/auto_accept/config`、`GET /api/friends/auto_accept/status`import 追加新模型 + `_require_rule_engine` + `_require_friend_watcher` + `parse_friend_request` |
| `config.py` | 修改 | BridgeConfig 新增 10 个字段AppState 新增 3 个字段,新增 `_parse_list` 函数 + 2 个 `_require_*` 函数 |
| `app.py` | 修改 | `_init_state` 新增组件初始化(`rule_engine` / `accept_breaker` / `friend_watcher`lifespan 新增 Watcher 启停(在 `message_streamer` 之后启动、之前停止) |
| `ui/profiles/default.yaml` | 修改 | 新增 4 个元素定位contact_icon / new_friend_entry / verify_button / confirm_button |
| `ui/profiles/wechat_4.0_1280x720.yaml` | 修改 | 同上 |
| `ui/profiles/wechat_4.0_1920x1080.yaml` | 修改 | 同上 |
---
## 9. API 接口汇总
| 方法 | 路径 | 说明 | 认证 |
|------|------|------|------|
| GET | `/api/friends/requests` | 查询待处理好友申请列表 | Bearer Token |
| POST | `/api/friends/accept` | 手动通过好友申请 | Bearer Token |
| GET | `/api/friends/auto_accept/config` | 查询自动通过规则配置 | Bearer Token |
| PUT | `/api/friends/auto_accept/config` | 更新自动通过规则配置(热生效) | Bearer Token |
| GET | `/api/friends/auto_accept/status` | 查询自动通过运行状态 | Bearer Token |
---
## 10. 后续演进
### 10.1 P3 架构迁移
当前方案中 `accept_friend_request` 直接在 `xdotool_driver.py` 中实现P1 模式)。后续可按 `send_text` 的 P3 迁移路径重构:
1. 新增 `AcceptFriendFlow`(继承 `Flow`,自定义 `AcceptFlowState` 枚举)
2. `FlowOrchestrator` 新增 `accept_friend` 编排方法(幂等 + 熔断 + 入队)
3. `WeChatCapabilities` 新增 `accept_friend` 能力 API
### 10.2 好友申请拒绝
当前方案仅实现自动通过ACCEPT后续可扩展自动拒绝REJECT
- `xdotool_driver.py` 新增 `reject_friend_request` 方法
- 规则引擎 `AcceptDecision.REJECT` 时入队执行拒绝操作
### 10.3 消息通知
自动通过成功后,可通过 MessageStreamer 的 SSE 通道推送 `friend_accepted` 事件给客户端,实现实时通知。
### 10.4 统计与监控
`ui/metrics.py` 中新增 Prometheus 指标:
- `woc_friend_requests_total{decision="accept|reject|skip"}`
- `woc_friend_accept_success_total`
- `woc_friend_accept_failure_total{reason="ui_timeout|db_verify_failed|..."}`