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

313 lines
12 KiB
Python
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.

"""好友申请监听器:轮询 fmessage 系统消息,解析后触发自动通过。
独立后台协程,不侵入 MessageStreamer 架构。复用其 mtime 感知 +
复合游标 (create_time, local_id) 增量检测模式,但独立游标与频率控制。
auto_accept 关闭时挂起在 asyncio.Event 上,不轮询 DB。
DB_ENCRYPTED 时 _init_cursor 返回 False每轮重试初始化。
"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
from typing import Optional
from woc_bridge.models import AcceptDecision, BridgeError
from woc_bridge.messaging.friend_parser import parse_friend_request
logger = logging.getLogger("woc-bridge")
@dataclass
class FriendRequestInfo:
"""好友申请信息(从 fmessage sysmsg XML 解析)。"""
stranger_wxid: str # 申请人 wxid含 @stranger 后缀)
nickname: str # 申请人昵称
verify_message: str # 验证消息内容
scene: str # 来源场景(如群聊/搜索/二维码)
raw_xml: str # 原始 XML调试用
create_time: int # 消息时间戳
msg_local_id: int # 消息 local_id游标用
class FriendRequestWatcher:
"""好友申请监听器:轮询 fmessage 系统消息,解析后触发自动通过。"""
def __init__(
self,
db_reader, # DbReader避免类型注解循环 import
rule_engine, # AcceptRuleEngine
send_queue, # SendQueue
idem_cache, # IdemCache
xdotool_driver, # XdotoolDriver
breaker, # CircuitBreaker
poll_interval: float = 3.0,
cursor_create_time: int = 0,
cursor_local_id: int = 0,
) -> None:
self._db_reader = db_reader
self._rule_engine = rule_engine
self._send_queue = send_queue
self._idem_cache = idem_cache
self._xdotool = xdotool_driver
self._breaker = breaker
self._poll_interval = poll_interval
# 复合游标 (create_time, local_id),与 get_friend_requests_since 返回值对齐
self._cursor_create_time: int = cursor_create_time
self._cursor_local_id: int = cursor_local_id
self._last_db_mtime: float = 0.0
self._last_wal_mtime: float = 0.0
self._watcher: Optional[asyncio.Task] = None
self._enabled_event: asyncio.Event = asyncio.Event() # auto_accept 开关事件
self._cursor_inited: bool = False
# 上轮满载标志:为 True 时跳过 mtime 一级过滤,避免批量积压(>50 条)时
# 剩余批次因 DB mtime 未变被卡住
self._has_more_pending: bool = False
# 状态统计(供 /api/friends/auto_accept/status 查询)
self._processed_count: int = 0
self._accepted_count: int = 0
self._rejected_count: int = 0
self._last_processed_time: Optional[int] = None
@property
def is_running(self) -> bool:
return self._watcher is not None and not self._watcher.done()
@property
def is_enabled(self) -> bool:
return self._enabled_event.is_set()
@property
def processed_count(self) -> int:
return self._processed_count
@property
def accepted_count(self) -> int:
return self._accepted_count
@property
def rejected_count(self) -> int:
return self._rejected_count
@property
def last_processed_time(self) -> Optional[int]:
return self._last_processed_time
async def start(self) -> None:
if self._watcher is None or self._watcher.done():
self._watcher = asyncio.create_task(self._watch_loop())
logger.info("FriendRequestWatcher 已启动")
async def stop(self) -> None:
if self._watcher is not None and not self._watcher.done():
self._watcher.cancel()
try:
await self._watcher
except asyncio.CancelledError:
pass
self._watcher = None
def set_enabled(self, enabled: bool) -> None:
if enabled:
# 开启时重置游标,避免批量处理积压申请
self._cursor_inited = False
self._has_more_pending = False
self._enabled_event.set()
else:
self._enabled_event.clear()
logger.info("FriendRequestWatcher enabled=%s", enabled)
async def _init_cursor(self) -> bool:
"""对齐游标到当前 fmessage 表最大 create_time。
Returns:
True 表示已对齐(或 DB 不可读但标记为已初始化,避免无限重试)
False 表示异常,下一轮重试
"""
try:
max_ct = await asyncio.to_thread(
self._db_reader.get_max_create_time_for_talker, "fmessage"
)
if max_ct is None:
# DB 不可读_ensure_decrypted 抛 BridgeError 已被吞掉返回 None
# 不标记 _cursor_inited下一轮重试
logger.warning("FriendRequestWatcher 游标初始化: DB 不可读,将在下轮重试")
return False
if max_ct and max_ct > 0:
self._cursor_create_time = max_ct
self._cursor_local_id = 0
logger.info(
"FriendRequestWatcher 游标对齐到 create_time=%d(跳过历史申请)",
self._cursor_create_time,
)
# 无论 max_ct 是否为 0空表都标记为已初始化避免空表时无限重试
self._cursor_inited = True
return True
except Exception as e:
logger.warning("FriendRequestWatcher 游标初始化失败: %s", e)
return False
async def _poll_once(self) -> None:
# 1. mtime 感知(一级过滤,避免空 SQL
# 上轮满载时跳过过滤(仍有未读批次),否则会因 DB mtime 未变卡住剩余申请
mtimes = await asyncio.to_thread(
self._db_reader.get_db_mtime, "message/message_0.db"
)
if mtimes is None:
return
db_mtime, wal_mtime = mtimes
if not self._has_more_pending:
if db_mtime == self._last_db_mtime and wal_mtime == self._last_wal_mtime:
return
self._last_db_mtime = db_mtime
self._last_wal_mtime = wal_mtime
# 2. 查询 fmessage 分片表增量消息(复合游标)
result = await asyncio.to_thread(
self._db_reader.get_friend_requests_since,
self._cursor_create_time,
self._cursor_local_id,
50, # limit
)
if result is None:
# DB 不可读_ensure_decrypted 抛 BridgeError
return
raw_items: list[dict] = result["requests"]
# 3. 解析 XML + 逐条处理
for item in raw_items:
info = parse_friend_request(
item["content"], item["create_time"], item["local_id"]
)
if info is None:
# 非 verifyUser 类型或解析失败,跳过
continue
await self._handle_request(info)
# 4. 推进复合游标(与 get_friend_requests_since 返回字段对齐)
self._cursor_create_time = result["next_create_time"]
self._cursor_local_id = result["next_local_id"]
# 5. 满载标志:本批达 limit 则置 True下轮跳过 mtime 过滤继续读剩余批次
self._has_more_pending = len(raw_items) >= 50
async def _watch_loop(self) -> None:
while True:
try:
# auto_accept 关闭时挂起,避免空轮询
await self._enabled_event.wait()
# 游标未初始化时先对齐DB 恢复可读后自动补齐)
# DB_ENCRYPTED 时 _init_cursor 返回 False下一轮仍会重试
if not self._cursor_inited:
await self._init_cursor()
if not self._cursor_inited:
# DB 仍不可读,本轮跳过 _poll_once
await asyncio.sleep(self._poll_interval)
continue
await asyncio.sleep(self._poll_interval)
await self._poll_once()
except asyncio.CancelledError:
raise
except Exception as e:
logger.exception("FriendRequestWatcher 异常: %s", e)
await asyncio.sleep(5.0)
async def _handle_request(self, req: FriendRequestInfo) -> None:
self._processed_count += 1
self._last_processed_time = req.create_time
# 1. 规则匹配
decision = await self._rule_engine.evaluate(req)
if decision == AcceptDecision.REJECT:
self._rejected_count += 1
logger.info(
"friend_request: wxid=%s nickname=%s → decision=%s(拒绝)",
req.stranger_wxid, req.nickname, decision.value,
)
return
if decision != AcceptDecision.ACCEPT:
logger.info(
"friend_request: wxid=%s nickname=%s → decision=%s(跳过)",
req.stranger_wxid, req.nickname, decision.value,
)
return
# 2. 幂等去重TTL=300s5 分钟内不重复处理同一申请人)
# content 参数传空串(去重 key 是 stranger_wxid无内容维度
cached = self._idem_cache.get(
"friend_accept", req.stranger_wxid, "", ""
)
if cached is not None:
logger.info(
"friend_request: wxid=%s → 幂等命中,跳过",
req.stranger_wxid,
)
return
# 3. 熔断检查
if not self._breaker.allow():
logger.warning(
"friend_request: wxid=%s → 熔断器 OPEN跳过",
req.stranger_wxid,
)
return
# 4. 入队执行lambda 用默认参数捕获 req避免延迟执行时变量被覆盖
try:
result = await self._send_queue.enqueue(
lambda req=req: self._xdotool.accept_friend_request(
stranger_wxid=req.stranger_wxid,
nickname=req.nickname,
),
delay_ms=None, # 使用 send_queue 默认间隔
)
# 5. 等待微信 DB WAL 刷盘后校验(微信 DB 写入有 1-3s 延迟)
await asyncio.sleep(2.0)
verified = await asyncio.to_thread(
self._db_reader.verify_friend_accepted, req.stranger_wxid
)
if verified:
# set 签名: (flow_name, to_wxid, content, value, client_request_id)
self._idem_cache.set(
"friend_accept", req.stranger_wxid, "",
{"success": True, "verified": True}, "",
)
self._breaker.record_success()
self._accepted_count += 1
logger.info(
"friend_request: wxid=%s nickname=%s → 通过并验证成功",
req.stranger_wxid, req.nickname,
)
else:
# UI 操作完成但 DB 校验未通过(可能有延迟)
self._idem_cache.set(
"friend_accept", req.stranger_wxid, "",
{"success": True, "verified": False}, "",
)
self._breaker.record_failure()
logger.warning(
"friend_request: wxid=%s → UI 操作完成但 DB 校验未通过",
req.stranger_wxid,
)
except BridgeError as e:
# 透传 BridgeError 错误码RATE_LIMITED / SEND_FAILED / WINDOW_NOT_FOUND 等)
self._breaker.record_failure()
logger.error(
"friend_request: wxid=%s → BridgeError code=%s: %s",
req.stranger_wxid, getattr(e, "code", "UNKNOWN"), e,
)
except Exception as e:
self._breaker.record_failure()
logger.error(
"friend_request: wxid=%s → 失败: %s",
req.stranger_wxid, e,
)