WechatOnCloud/doc/优化方案/02-好友自动通过设计方案.md
Kris 67aab58a00 docs: 新增PRD规范与WechatOnCloud改造、多应用桥接框架文档
新增三份文档:
1. 产品需求文档(PRD)编写规范
2. WechatOnCloud容器化微信改造需求方案
3. 多应用桥接框架整体设计方案
2026-07-18 16:04:25 +08:00

1773 lines
75 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 微信好友申请自动通过设计方案
> **版本**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|..."}`