feat: 新增好友自动通过、UI自动化能力与多分辨率适配
- 新增登录状态守卫后台任务 - 新增好友申请自动通过规则引擎 - 新增多分辨率UI配置与模板资源 - 新增消息拉取复合游标支持 - 优化发送队列与UI自动化逻辑 - 新增批量发送日志与错误处理 - 优化Docker镜像构建与ptrace初始化 - 新增联系人名称缓存预热
4
.gitignore
vendored
@ -149,3 +149,7 @@ __pycache__/
|
||||
*.so
|
||||
*.egg-info/
|
||||
*.egg
|
||||
|
||||
.trae/
|
||||
trae/
|
||||
|
||||
|
||||
@ -1,22 +1,65 @@
|
||||
#!/usr/bin/with-contenv bash
|
||||
# 等待微信窗口出现,最多等 5 分钟;超时也启动 bridge(/api/status 会返回 not_running)
|
||||
# 等待微信主界面登录完成(检测到足够大的主窗口),避免未登录时 bridge
|
||||
# 狂刷日志/任务失败。超时 10 分钟仍未登录则放弃启动。
|
||||
WAITED=0
|
||||
MAX_WAIT=300
|
||||
while ! xdotool search --name "微信" >/dev/null 2>&1; do
|
||||
sleep 2
|
||||
WAITED=$((WAITED + 2))
|
||||
if [ "$WAITED" -ge "$MAX_WAIT" ]; then
|
||||
echo "woc-bridge: 等待微信窗口超时(${WAITED}s),仍然启动 bridge" >&2
|
||||
MAX_WAIT=600
|
||||
while true; do
|
||||
# 通过 wechat 进程 PID 查找窗口,过滤出面积足够大的主窗口
|
||||
wid=""
|
||||
area=0
|
||||
pid=$(pgrep -f "/config/wechat/opt/wechat/wechat" | head -1)
|
||||
if [ -n "$pid" ]; then
|
||||
for candidate in $(xdotool search --pid "$pid" 2>/dev/null); do
|
||||
info=$(xdotool getwindowgeometry --shell "$candidate" 2>/dev/null)
|
||||
if [ -n "$info" ]; then
|
||||
eval "$info"
|
||||
a=$((WIDTH * HEIGHT))
|
||||
if [ "$a" -gt "$area" ]; then
|
||||
area=$a
|
||||
wid=$candidate
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# 登录后主窗口面积通常 > 300000;登录窗口/扫码窗口面积较小
|
||||
if [ "$area" -ge 300000 ]; then
|
||||
echo "woc-bridge: 检测到微信已登录主窗口(area=${area}),等待 60s 让微信完全就绪后再启动 bridge" >&2
|
||||
sleep 60
|
||||
echo "woc-bridge: 等待结束,启动 bridge" >&2
|
||||
break
|
||||
fi
|
||||
|
||||
sleep 3
|
||||
WAITED=$((WAITED + 3))
|
||||
if [ "$WAITED" -ge "$MAX_WAIT" ]; then
|
||||
echo "woc-bridge: 等待微信登录超时(${WAITED}s),暂不启动 bridge" >&2
|
||||
# s6 服务退出码 0 会被视为正常结束并自动重启;用 sleep + 退出 0
|
||||
# 让服务进入休眠,用户登录后可通过重启容器或手动 s6-svc -u 唤醒
|
||||
sleep infinity
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
|
||||
# 以 abc 用户身份运行(与微信同 X 会话,才能操作微信窗口)
|
||||
# s6-setuidgid 不接受 VAR=value 参数,须先用 s6-env 设环境变量再切用户
|
||||
|
||||
# 校验 ptrace 权限(key_extractor 读微信进程内存需要)
|
||||
if [ -w /proc/sys/kernel/yama/ptrace_scope ] 2>/dev/null; then
|
||||
echo 0 > /proc/sys/kernel/yama/ptrace_scope 2>/dev/null || true
|
||||
PTRACE_SCOPE="/proc/sys/kernel/yama/ptrace_scope"
|
||||
if [ -e "$PTRACE_SCOPE" ]; then
|
||||
if echo 0 > "$PTRACE_SCOPE" 2>/dev/null; then
|
||||
echo "woc-bridge: ptrace_scope 已设置为 0" >&2
|
||||
else
|
||||
echo "woc-bridge: 无法写入 ptrace_scope(可能需要 --privileged 或 SYS_PTRACE capability)" >&2
|
||||
fi
|
||||
else
|
||||
# 通过 CapEff 检查是否持有 SYS_PTRACE (bit 12)
|
||||
CAP_EFF=$(awk '/^CapEff:/{print $2}' /proc/self/status 2>/dev/null)
|
||||
if [ -n "$CAP_EFF" ] && [ $((0x${CAP_EFF} & 0x1000)) -ne 0 ]; then
|
||||
echo "woc-bridge: 宿主机未启用 YAMA,但容器已持有 SYS_PTRACE,key 提取仍可尝试" >&2
|
||||
else
|
||||
echo "woc-bridge: 宿主机未启用 YAMA 且容器未持有 SYS_PTRACE,自动 key 提取可能不可用" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
exec s6-env DISPLAY=${DISPLAY:-:1} XAUTHORITY=/config/.Xauthority \
|
||||
|
||||
@ -81,6 +81,12 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
await _state.send_queue.start()
|
||||
if _state.message_streamer is not None:
|
||||
await _state.message_streamer.start()
|
||||
# 异步预热 display_name 缓存,降低高并发首批请求的 DB 压力
|
||||
try:
|
||||
from woc_bridge.routes.send import warm_display_name_cache
|
||||
asyncio.create_task(warm_display_name_cache(_state.db_reader))
|
||||
except Exception as e:
|
||||
logger.warning("[lifespan] warm_display_name_cache 启动失败: %s", e)
|
||||
# P3:启动 HA 模块(watchdog + resource_reaper)
|
||||
if _state.watchdog is not None:
|
||||
try:
|
||||
@ -103,6 +109,12 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
logger.warning("[lifespan] 启动清场 3 次失败,可能需要人工 VNC 接入")
|
||||
except Exception as e:
|
||||
logger.warning("[lifespan] 启动清场异常(不阻塞启动): %s", e)
|
||||
# 启动登录守卫(在 friend_watcher 之前,提供登录状态检测基础)
|
||||
if _state.login_guard is not None:
|
||||
try:
|
||||
await _state.login_guard.start()
|
||||
except Exception as e:
|
||||
logger.warning("[lifespan] login_guard.start() 失败: %s", e)
|
||||
# 启动好友申请监听器(仅当 auto_accept 开启时)
|
||||
# 放在 message_streamer 之后,保持"基础设施先于业务"顺序
|
||||
if _state.friend_watcher is not None and _state.config.auto_accept_enabled:
|
||||
@ -116,7 +128,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# 先停 friend_watcher(业务),再停基础设施
|
||||
# 先停 friend_watcher(业务),再停 login_guard,最后停基础设施
|
||||
if _state.friend_watcher is not None:
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
@ -125,6 +137,12 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
except Exception:
|
||||
# Exception 已涵盖 TimeoutError,无需单独列
|
||||
logger.warning("[lifespan] friend_watcher.stop() 超时或异常,强制继续")
|
||||
# 停止登录守卫(friend_watcher 之后,LIFO)
|
||||
if _state.login_guard is not None:
|
||||
try:
|
||||
await asyncio.wait_for(_state.login_guard.stop(), timeout=5.0)
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
logger.warning("[lifespan] login_guard.stop() 超时或异常,强制继续")
|
||||
# 停止消息流推送器(带超时,防止 worker 卡住阻塞关闭)
|
||||
if _state.message_streamer is not None:
|
||||
try:
|
||||
@ -359,6 +377,7 @@ def _init_state(cfg: BridgeConfig) -> None:
|
||||
_state.send_queue = SendQueue(
|
||||
send_delay_ms=cfg.send_delay_ms,
|
||||
max_calls_per_sec=cfg.max_calls_per_sec,
|
||||
max_queue_size=cfg.max_queue_size,
|
||||
)
|
||||
|
||||
# 消息流推送器:内部轮询 DB + SSE 广播(lifespan 中 start/stop)
|
||||
@ -376,7 +395,9 @@ def _init_state(cfg: BridgeConfig) -> None:
|
||||
_state.db_reader.set_default_key(cfg.db_key)
|
||||
logger.info("从 WOC_DB_KEY 环境变量加载默认 DB 密钥(未验证,首次查询时验证)")
|
||||
|
||||
# P3:UI 自动化新架构组件(仅 ui_backend != "legacy" 时构造)
|
||||
# P3:UI 自动化新架构组件(ui_backend != "legacy" 时构造完整能力;
|
||||
# legacy 模式仍构造最小 LocatorRegistry + Actions,使 ContactDriver 能使用
|
||||
# YAML profile 的几何兜底,改善好友申请通过等硬编码坐标准确性)。
|
||||
if cfg.ui_backend != "legacy":
|
||||
try:
|
||||
from woc_bridge.ui.backends import XdotoolBackend, OpenCVBackend
|
||||
@ -411,12 +432,16 @@ def _init_state(cfg: BridgeConfig) -> None:
|
||||
|
||||
_state.actions = Actions(backend=_state.xdotool_backend, locator=_state.locator)
|
||||
|
||||
# P0 修复:将 actions 注入 ContactDriver,启用 OpenCV 优先策略。
|
||||
# 门面构造 ContactDriver 时未传 actions(因 actions 在此之后才构造),
|
||||
# 导致 _click_element_or_fallback 永远走 _click_at 硬编码坐标,
|
||||
# OpenCV 模板匹配 / 熔断器完全失效。此处回填 actions。
|
||||
if _state.xdotool is not None and hasattr(_state.xdotool, '_contact'):
|
||||
_state.xdotool._contact._actions = _state.actions
|
||||
# P0 修复:将 actions 注入 ContactDriver 与 MessageDriver,启用 OpenCV/
|
||||
# LocatorRegistry 优先策略。门面构造子 driver 时 actions 尚未构造,
|
||||
# 导致 _click_element_or_fallback / _open_session_by_name 永远走硬编码坐标。
|
||||
if _state.xdotool is not None and hasattr(_state.xdotool, 'set_actions'):
|
||||
_state.xdotool.set_actions(_state.actions)
|
||||
else:
|
||||
if _state.xdotool is not None and hasattr(_state.xdotool, '_contact'):
|
||||
_state.xdotool._contact._actions = _state.actions
|
||||
if _state.xdotool is not None and hasattr(_state.xdotool, '_message'):
|
||||
_state.xdotool._message._actions = _state.actions
|
||||
|
||||
# 构造 SendFileFlow(注入 actions + xdotool + db_reader)
|
||||
# SendFileFlow 依赖 XdotoolDriver(用于 xdotool type 路径输入文件选择器),
|
||||
@ -478,11 +503,42 @@ def _init_state(cfg: BridgeConfig) -> None:
|
||||
_state.watchdog = None
|
||||
_state.resource_reaper = None
|
||||
_state.batch_worker = None
|
||||
else:
|
||||
# legacy 模式:至少加载 YAML profile 并构造无 OpenCV 的 Actions,
|
||||
# 让 ContactDriver.accept_friend_request 使用 profile 几何兜底而非硬编码坐标。
|
||||
try:
|
||||
from woc_bridge.ui.backends import XdotoolBackend
|
||||
from woc_bridge.ui.locators.registry import LocatorRegistry
|
||||
from woc_bridge.ui.actions import Actions
|
||||
|
||||
_state.xdotool_backend = XdotoolBackend(display=cfg.display)
|
||||
profile_dir = os.path.join(os.path.dirname(__file__), "ui", "profiles")
|
||||
_state.locator = LocatorRegistry(profile_dir=profile_dir, opencv=None)
|
||||
real_resolution = _detect_display_resolution(cfg.display)
|
||||
_state.locator.load("4.0", real_resolution)
|
||||
_state.actions = Actions(backend=_state.xdotool_backend, locator=_state.locator)
|
||||
if _state.xdotool is not None and hasattr(_state.xdotool, 'set_actions'):
|
||||
_state.xdotool.set_actions(_state.actions)
|
||||
else:
|
||||
if _state.xdotool is not None and hasattr(_state.xdotool, '_contact'):
|
||||
_state.xdotool._contact._actions = _state.actions
|
||||
if _state.xdotool is not None and hasattr(_state.xdotool, '_message'):
|
||||
_state.xdotool._message._actions = _state.actions
|
||||
logger.info(
|
||||
"[init] legacy 模式已加载 UI profile 几何兜底: wechat_4.0 %s",
|
||||
real_resolution,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("[init] legacy 模式加载 UI profile 失败: %s", exc)
|
||||
_state.actions = None
|
||||
_state.locator = None
|
||||
_state.xdotool_backend = None
|
||||
|
||||
# 好友自动通过组件(不依赖 P3 UI 架构,独立构造)
|
||||
try:
|
||||
from woc_bridge.models import AcceptRuleConfig, AcceptRuleEngine
|
||||
from woc_bridge.messaging.friend_watcher import FriendRequestWatcher
|
||||
from woc_bridge.messaging.login_guard import LoginGuard
|
||||
from woc_bridge.ui.circuit_breaker import CircuitBreaker
|
||||
from woc_bridge.ui.idem_cache import IdemCache
|
||||
|
||||
@ -507,7 +563,14 @@ def _init_state(cfg: BridgeConfig) -> None:
|
||||
# (好友自动通过不依赖 P3 UI 架构,自持 IdemCache 实例)
|
||||
accept_idem_cache = IdemCache(ttl=300, max_size=1000)
|
||||
|
||||
# 好友申请监听器(注入 6 个依赖)
|
||||
# 登录守卫:后台检测微信登录状态,供 friend_watcher 做登录前提保护
|
||||
# 未登录时 watcher 跳过轮询(不推进游标),登录后自然从上次位置继续
|
||||
_state.login_guard = LoginGuard(
|
||||
xdotool_driver=_state.xdotool,
|
||||
check_interval=5.0,
|
||||
)
|
||||
|
||||
# 好友申请监听器(注入 7 个依赖,含 login_guard)
|
||||
_state.friend_watcher = FriendRequestWatcher(
|
||||
db_reader=_state.db_reader,
|
||||
rule_engine=_state.rule_engine,
|
||||
@ -516,6 +579,7 @@ def _init_state(cfg: BridgeConfig) -> None:
|
||||
xdotool_driver=_state.xdotool,
|
||||
breaker=_state.accept_breaker,
|
||||
poll_interval=cfg.auto_accept_poll_interval,
|
||||
login_guard=_state.login_guard,
|
||||
)
|
||||
|
||||
logger.info("[init] 好友自动通过组件构造完成 (enabled=%s)", cfg.auto_accept_enabled)
|
||||
@ -525,3 +589,4 @@ def _init_state(cfg: BridgeConfig) -> None:
|
||||
_state.rule_engine = None
|
||||
_state.friend_watcher = None
|
||||
_state.accept_breaker = None
|
||||
_state.login_guard = None
|
||||
|
||||
@ -134,6 +134,8 @@ class BridgeConfig:
|
||||
# 环境变量
|
||||
send_delay_ms: int = 3000
|
||||
max_calls_per_sec: int = 10
|
||||
max_queue_size: int = 100
|
||||
send_queue_wait_timeout_ms: int = 15000
|
||||
max_batch_size: int = 50
|
||||
poll_interval_ms: int = 2000
|
||||
db_key: str = ""
|
||||
@ -171,6 +173,8 @@ class BridgeConfig:
|
||||
display=args.display,
|
||||
send_delay_ms=int(os.environ.get("WOC_BRIDGE_SEND_DELAY_MS", "3000")),
|
||||
max_calls_per_sec=int(os.environ.get("WOC_BRIDGE_MAX_CALLS_PER_SEC", "10")),
|
||||
max_queue_size=int(os.environ.get("WOC_BRIDGE_MAX_QUEUE_SIZE", "100")),
|
||||
send_queue_wait_timeout_ms=int(os.environ.get("WOC_BRIDGE_SEND_QUEUE_WAIT_TIMEOUT_MS", "15000")),
|
||||
max_batch_size=int(os.environ.get("WOC_BRIDGE_MAX_BATCH_SIZE", "50")),
|
||||
poll_interval_ms=int(os.environ.get("WOC_BRIDGE_POLL_INTERVAL_MS", "2000")),
|
||||
db_key=os.environ.get("WOC_DB_KEY", "").strip(),
|
||||
@ -260,6 +264,8 @@ class AppState:
|
||||
self.friend_watcher: Optional[Any] = None # FriendRequestWatcher
|
||||
self.rule_engine: Optional[Any] = None # AcceptRuleEngine
|
||||
self.accept_breaker: Optional[Any] = None # CircuitBreaker
|
||||
# 登录状态守卫(后台检测微信登录状态,供后台任务做登录前提保护)
|
||||
self.login_guard: Optional[Any] = None # LoginGuard
|
||||
# 群发消息后台协程(Task 13-16)
|
||||
self.batch_worker: Optional[Any] = None # BatchSendWorker
|
||||
|
||||
|
||||
@ -787,10 +787,14 @@ class DbReader:
|
||||
try:
|
||||
table_to_talker = self._resolve_msg_table_talkers(conn)
|
||||
if not table_to_talker:
|
||||
raise BridgeError(
|
||||
code="DB_NOT_FOUND",
|
||||
message="未找到 Msg_* 消息分片表",
|
||||
)
|
||||
# 微信未登录或尚无消息时,Msg_* 分片表可能不存在;返回空列表
|
||||
# 而不是抛 DB_NOT_FOUND(500),避免前端轮询进入错误重试循环。
|
||||
return {
|
||||
"messages": [],
|
||||
"next_cursor": cursor,
|
||||
"next_cursor_local_id": cursor_local_id,
|
||||
"has_more": False,
|
||||
}
|
||||
id_to_username = self._load_name2id(conn)
|
||||
self_wxid = self._get_current_wxid() or ""
|
||||
|
||||
@ -1227,11 +1231,11 @@ class DbReader:
|
||||
messages = all_rows[:limit]
|
||||
return {"messages": messages, "total": total}
|
||||
|
||||
def get_max_create_time(self) -> Optional[int]:
|
||||
"""返回所有 Msg_* 分片表中最大的 create_time,用于初始化推送游标。
|
||||
def get_max_create_time(self) -> Optional[tuple[int, int]]:
|
||||
"""返回所有 Msg_* 分片表中最大的 (create_time, local_id),用于初始化推送游标。
|
||||
|
||||
DB 不可读或无消息表时返回 None。供 MessageStreamer 在启动时
|
||||
把全局游标对齐到当前最新消息,避免向新连接推送全量历史。
|
||||
把全局游标对齐到当前最新消息,避免同秒消息在启动后被重复推送。
|
||||
"""
|
||||
try:
|
||||
db_path = self._ensure_decrypted("message/message_0.db")
|
||||
@ -1243,18 +1247,26 @@ class DbReader:
|
||||
if not table_to_talker:
|
||||
return None
|
||||
max_ct: Optional[int] = None
|
||||
max_local_id: int = 0
|
||||
for table_name in table_to_talker:
|
||||
try:
|
||||
row = conn.execute(
|
||||
f"SELECT MAX(create_time) FROM [{table_name}]"
|
||||
f"SELECT create_time, local_id FROM [{table_name}] "
|
||||
f"ORDER BY create_time DESC, local_id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
if row and row[0] is not None:
|
||||
ct = int(row[0])
|
||||
if max_ct is None or ct > max_ct:
|
||||
lid = int(row[1]) if row[1] is not None else 0
|
||||
if max_ct is None or ct > max_ct or (
|
||||
ct == max_ct and lid > max_local_id
|
||||
):
|
||||
max_ct = ct
|
||||
max_local_id = lid
|
||||
except sqlite3.Error:
|
||||
continue
|
||||
return max_ct
|
||||
if max_ct is None:
|
||||
return None
|
||||
return (max_ct, max_local_id)
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
@ -1265,7 +1277,8 @@ class DbReader:
|
||||
) -> Optional[dict]:
|
||||
"""查询 fmessage 会话中指定游标之后的好友申请消息。
|
||||
|
||||
直接查 Msg_<MD5("fmessage")> 单表,避免遍历所有分片。
|
||||
优先查 Msg_<MD5("fmessage")> 单表;若 fmessage 表无数据,
|
||||
则 fallback 到 contact.db 的 ticket_info / stranger 表读取待处理申请。
|
||||
复合游标 (create_time, local_id) 作为 tie-breaker,避免同秒消息丢失。
|
||||
|
||||
Args:
|
||||
@ -1338,34 +1351,171 @@ class DbReader:
|
||||
next_ct = row["create_time"]
|
||||
next_lid = row["local_id"]
|
||||
|
||||
return {
|
||||
result = {
|
||||
"requests": requests,
|
||||
"next_create_time": next_ct,
|
||||
"next_local_id": next_lid,
|
||||
}
|
||||
|
||||
# fallback:fmessage 无新申请时,尝试从 contact.db 的 ticket_info 读取
|
||||
if not requests and cursor_create_time == 0:
|
||||
ticket_requests = self.get_pending_requests_from_ticket_info(limit)
|
||||
if ticket_requests:
|
||||
result["requests"] = ticket_requests
|
||||
# ticket_info 无 create_time 时统一用 1 作为占位,确保 watcher 能处理
|
||||
result["next_create_time"] = max(
|
||||
(r["create_time"] for r in ticket_requests), default=1
|
||||
)
|
||||
result["next_local_id"] = max(
|
||||
(r["local_id"] for r in ticket_requests), default=0
|
||||
)
|
||||
|
||||
return result
|
||||
except sqlite3.Error:
|
||||
return empty
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_pending_requests_from_ticket_info(
|
||||
self, limit: int = 50, since_id: int = 0
|
||||
) -> list[dict]:
|
||||
"""从 contact.db 的 ticket_info / stranger 表读取待处理好友申请。
|
||||
|
||||
微信 4.x Linux 中,fmessage 表可能不包含好友申请消息,
|
||||
但 contact.db 的 ticket_info 表中会留存待验证的 ticket(形如 wxid_xxx@stranger)。
|
||||
本方法作为 get_friend_requests_since 的兜底数据源。
|
||||
|
||||
Args:
|
||||
limit: 单次最大返回条数。
|
||||
since_id: 只返回 id > since_id 的记录,用于跳过历史数据。
|
||||
|
||||
Returns:
|
||||
[{"local_id": int, "create_time": int, "content": str}, ...]
|
||||
"""
|
||||
try:
|
||||
db_path = self._ensure_decrypted("contact/contact.db")
|
||||
except BridgeError:
|
||||
return []
|
||||
|
||||
conn = sqlite3.connect(db_path, isolation_level=None)
|
||||
conn.row_factory = sqlite3.Row
|
||||
requests: list[dict] = []
|
||||
try:
|
||||
# 1. 尝试 ticket_info 表
|
||||
tables = {r[0] for r in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
).fetchall()}
|
||||
|
||||
if "ticket_info" in tables:
|
||||
cols = self._table_columns(conn, "ticket_info")
|
||||
ticket_col = self._pick_column(cols, ["ticket"])
|
||||
id_col = self._pick_column(cols, ["id", "rowid"])
|
||||
if ticket_col and id_col:
|
||||
cur = conn.execute(
|
||||
f"SELECT {id_col} AS local_id, {ticket_col} AS ticket "
|
||||
f"FROM ticket_info WHERE {ticket_col} LIKE '%@%' "
|
||||
f"AND {id_col} > ? "
|
||||
f"LIMIT ?",
|
||||
(since_id, limit),
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
ticket = row["ticket"] or ""
|
||||
if "@" not in ticket:
|
||||
continue
|
||||
# 构造最小 sysmsg XML,供 parse_friend_request 解析
|
||||
xml = (
|
||||
f'<sysmsg type="verifyUser">'
|
||||
f'<Link><UserName>{ticket}</UserName></Link>'
|
||||
f'</sysmsg>'
|
||||
)
|
||||
requests.append({
|
||||
"local_id": row["local_id"],
|
||||
"create_time": 1,
|
||||
"content": xml,
|
||||
})
|
||||
|
||||
# 2. 尝试 stranger / stranger_ticket_info 表
|
||||
if not requests and "stranger" in tables:
|
||||
cols = self._table_columns(conn, "stranger")
|
||||
username_col = self._pick_column(cols, ["username", "wxid", "stranger"])
|
||||
id_col = self._pick_column(cols, ["id", "rowid"])
|
||||
if username_col and id_col:
|
||||
cur = conn.execute(
|
||||
f"SELECT {id_col} AS local_id, {username_col} AS username "
|
||||
f"FROM stranger WHERE {username_col} LIKE '%@%' "
|
||||
f"AND {id_col} > ? "
|
||||
f"LIMIT ?",
|
||||
(since_id, limit),
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
username = row["username"] or ""
|
||||
if "@" not in username:
|
||||
username = f"{username}@stranger"
|
||||
xml = (
|
||||
f'<sysmsg type="verifyUser">'
|
||||
f'<Link><UserName>{username}</UserName></Link>'
|
||||
f'</sysmsg>'
|
||||
)
|
||||
requests.append({
|
||||
"local_id": row["local_id"],
|
||||
"create_time": 1,
|
||||
"content": xml,
|
||||
})
|
||||
except sqlite3.Error as e:
|
||||
logger.warning("_get_pending_requests_from_ticket_info 异常: %s", e)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return requests
|
||||
|
||||
def get_max_ticket_info_id(self) -> Optional[int]:
|
||||
"""获取 ticket_info 表当前最大 id(用于初始化增量游标)。
|
||||
|
||||
Returns:
|
||||
最大 id;表不存在或为空时返回 0;DB 不可读时返回 None。
|
||||
"""
|
||||
try:
|
||||
db_path = self._ensure_decrypted("contact/contact.db")
|
||||
except BridgeError:
|
||||
return None
|
||||
|
||||
conn = sqlite3.connect(db_path, isolation_level=None)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
tables = {r[0] for r in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
).fetchall()}
|
||||
if "ticket_info" not in tables:
|
||||
return 0
|
||||
cols = self._table_columns(conn, "ticket_info")
|
||||
id_col = self._pick_column(cols, ["id", "rowid"])
|
||||
if id_col is None:
|
||||
return 0
|
||||
cur = conn.execute(
|
||||
f"SELECT COALESCE(MAX({id_col}), 0) AS max_id FROM ticket_info"
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return int(row["max_id"]) if row else 0
|
||||
except sqlite3.Error as e:
|
||||
logger.warning("get_max_ticket_info_id 异常: %s", e)
|
||||
return None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def verify_friend_accepted(self, stranger_wxid: str) -> bool:
|
||||
"""校验好友申请是否已通过:contact 表中 @stranger 后缀消失。
|
||||
"""校验好友申请是否已通过。
|
||||
|
||||
通过前:username = "wxid_xxx@stranger"(后缀名需环境验证)
|
||||
通过后:username = "wxid_xxx"(后缀被移除,local_type 可能变为 1)
|
||||
|
||||
策略:用 base_wxid 查 contact 表,若存在 base_wxid 记录且不存在
|
||||
base_wxid@stranger 记录,则判定已通过。合并为单条 SQL 避免 2 次查询。
|
||||
判定逻辑:
|
||||
1. contact 表中存在 base_wxid 记录(已变为好友)
|
||||
2. contact 表中不存在 base_wxid@stranger 残留
|
||||
3. ticket_info 表中不存在该 stranger 的待处理 ticket
|
||||
任一条件不满足均视为未通过。
|
||||
|
||||
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")
|
||||
@ -1400,25 +1550,42 @@ class DbReader:
|
||||
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
|
||||
contact_passed = base_cnt > 0 and stranger_cnt == 0
|
||||
|
||||
# 同时检查 ticket_info 表是否还有该 stranger 的待处理 ticket
|
||||
ticket_pending = False
|
||||
tables = {r[0] for r in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
).fetchall()}
|
||||
if "ticket_info" in tables:
|
||||
ticket_cols = self._table_columns(conn, "ticket_info")
|
||||
ticket_col = self._pick_column(ticket_cols, ["ticket"])
|
||||
if ticket_col:
|
||||
cur = conn.execute(
|
||||
f"SELECT COUNT(*) FROM ticket_info WHERE {ticket_col} LIKE ?",
|
||||
(f"{base_wxid}@%",),
|
||||
)
|
||||
ticket_pending = (cur.fetchone()[0] or 0) > 0
|
||||
|
||||
# 通过 = contact 已建立且 ticket 已消失
|
||||
return contact_passed and not ticket_pending
|
||||
except sqlite3.Error:
|
||||
return False
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_max_create_time_for_talker(self, talker: str) -> Optional[int]:
|
||||
"""获取指定会话的消息最大 create_time(游标初始化用)。
|
||||
def get_max_create_time_for_talker(self, talker: str) -> Optional[tuple[int, int]]:
|
||||
"""获取指定会话的消息最大 (create_time, local_id)(游标初始化用)。
|
||||
|
||||
直接查 Msg_<MD5(talker)> 单表,避免遍历所有分片。
|
||||
返回 None 表示 DB 不可读(与 get_max_create_time 语义一致),
|
||||
返回 0 表示表为空或不存在。
|
||||
返回 (0, 0) 表示表为空或不存在。
|
||||
|
||||
Args:
|
||||
talker: 会话 talker(如 "fmessage")
|
||||
|
||||
Returns:
|
||||
最大 create_time,或 None / 0
|
||||
最大 (create_time, local_id),或 None / (0, 0)
|
||||
"""
|
||||
try:
|
||||
db_path = self._ensure_decrypted("message/message_0.db")
|
||||
@ -1433,12 +1600,17 @@ class DbReader:
|
||||
(table_name,),
|
||||
)
|
||||
if cur.fetchone() is None:
|
||||
return 0
|
||||
cur = conn.execute(f"SELECT MAX(create_time) FROM [{table_name}]")
|
||||
return (0, 0)
|
||||
cur = conn.execute(
|
||||
f"SELECT create_time, local_id FROM [{table_name}] "
|
||||
f"ORDER BY create_time DESC, local_id DESC LIMIT 1"
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return int(row[0]) if row and row[0] else 0
|
||||
if row and row[0] is not None:
|
||||
return (int(row[0]), int(row[1]) if row[1] is not None else 0)
|
||||
return (0, 0)
|
||||
except sqlite3.Error:
|
||||
return 0
|
||||
return (0, 0)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"""消息域:发送串行化队列 / SSE 实时消息推送。"""
|
||||
"""消息域:发送串行化队列 / SSE 实时消息推送 / 登录状态守卫。"""
|
||||
|
||||
from woc_bridge.messaging.login_guard import LoginGuard
|
||||
from woc_bridge.messaging.send_queue import SendQueue
|
||||
from woc_bridge.messaging.streamer import MessageStreamer, StreamEvent, format_sse
|
||||
|
||||
__all__ = ["SendQueue", "MessageStreamer", "StreamEvent", "format_sse"]
|
||||
__all__ = ["LoginGuard", "SendQueue", "MessageStreamer", "StreamEvent", "format_sse"]
|
||||
|
||||
@ -18,20 +18,28 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from typing import Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# 仅类型注解用,避免与 friend_watcher.py 形成运行时循环 import
|
||||
from woc_bridge.messaging.friend_watcher import FriendRequestInfo
|
||||
from woc_bridge.models.contact import FriendRequestInfo
|
||||
|
||||
logger = logging.getLogger("woc-bridge")
|
||||
|
||||
|
||||
# 可能的好友申请 XML 类型标记(大小写不敏感)
|
||||
_VERIFYUSER_TYPES = {"verifyuser", "verify_user", "verification"}
|
||||
|
||||
|
||||
def parse_friend_request(
|
||||
content: str, create_time: int, msg_local_id: int
|
||||
) -> Optional[FriendRequestInfo]:
|
||||
"""解析 fmessage 系统消息 XML,提取好友申请信息。
|
||||
|
||||
兼容多种实际格式:
|
||||
- <sysmsg type="verifyUser"><Link><UserName>...</UserName>...</Link></sysmsg>
|
||||
- <sysmsg type="verifyUser"><verifyuser><username>...</username>...</verifyuser></sysmsg>
|
||||
- <verifyuser>...</verifyuser> 根节点
|
||||
- 标签大小写/命名空间不一致
|
||||
|
||||
Args:
|
||||
content: message_content 原始文本(XML)
|
||||
create_time: 消息时间戳
|
||||
@ -49,23 +57,31 @@ def parse_friend_request(
|
||||
# 部分消息可能不是合法 XML(如纯文本通知),跳过
|
||||
return None
|
||||
|
||||
# 检查是否为 verifyUser 类型 sysmsg
|
||||
msg_type = root.get("type", "")
|
||||
if msg_type != "verifyUser":
|
||||
# 1. 判断是否为好友申请类型
|
||||
if not _is_verify_user_root(root):
|
||||
return None
|
||||
|
||||
# 提取 Link 节点中的申请人信息
|
||||
# 2. 提取字段:优先 Link 子树,其次 verifyuser/verification 子树
|
||||
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 link is not None:
|
||||
stranger_wxid = _text(link, "UserName")
|
||||
nickname = _text(link, "NickName")
|
||||
verify_message = _text(link, "Content")
|
||||
scene = _text(link, "Scene")
|
||||
else:
|
||||
# 无 Link 节点时从任意子节点按候选标签名搜索
|
||||
stranger_wxid = _find_text(root, ["UserName", "username", "v1", "ticket"])
|
||||
nickname = _find_text(root, ["NickName", "nickname", "nick"])
|
||||
verify_message = _find_text(root, ["Content", "content", "msg", "verifyContent"])
|
||||
scene = _find_text(root, ["Scene", "scene", "sceneid", "scene_id"])
|
||||
|
||||
# 3. 从 ticket/encrypt username 中反推带 @stranger 的 wxid
|
||||
if not stranger_wxid:
|
||||
return None
|
||||
if "@" not in stranger_wxid:
|
||||
# 微信 ticket 常见为 wxid_xxx@stranger 或加密串,统一补后缀
|
||||
# 若已含 @ 则保留原样(如 @stranger / @openim)
|
||||
stranger_wxid = f"{stranger_wxid}@stranger"
|
||||
|
||||
return FriendRequestInfo(
|
||||
stranger_wxid=stranger_wxid,
|
||||
@ -78,7 +94,42 @@ def parse_friend_request(
|
||||
)
|
||||
|
||||
|
||||
def _is_verify_user_root(root: ET.Element) -> bool:
|
||||
"""判断 XML 根节点是否表示好友申请。"""
|
||||
tag = root.tag.split("}")[-1] if root.tag else ""
|
||||
if tag.lower() in _VERIFYUSER_TYPES:
|
||||
return True
|
||||
if tag.lower() == "sysmsg":
|
||||
msg_type = (root.get("type") or "").lower()
|
||||
if msg_type in _VERIFYUSER_TYPES:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _text(parent: ET.Element, tag: str) -> str:
|
||||
"""安全提取子元素文本(None 安全)。"""
|
||||
el = parent.find(tag)
|
||||
return el.text.strip() if el is not None and el.text else ""
|
||||
"""安全提取直接子元素文本(None 安全,去掉命名空间)。"""
|
||||
tag_lower = tag.lower()
|
||||
for child in parent:
|
||||
child_tag = child.tag.split("}")[-1] if child.tag else ""
|
||||
if child_tag.lower() == tag_lower:
|
||||
return child.text.strip() if child.text else ""
|
||||
return ""
|
||||
|
||||
|
||||
def _find_child_recursive(parent: ET.Element, tag: str) -> Optional[ET.Element]:
|
||||
"""按本地标签名递归查找后代元素(忽略命名空间)。"""
|
||||
tag_lower = tag.lower()
|
||||
for elem in parent.iter():
|
||||
elem_tag = elem.tag.split("}")[-1] if elem.tag else ""
|
||||
if elem_tag.lower() == tag_lower:
|
||||
return elem
|
||||
return None
|
||||
|
||||
|
||||
def _find_text(parent: ET.Element, tags: list[str]) -> str:
|
||||
"""按候选标签名列表顺序递归查找,返回首个非空文本。"""
|
||||
for tag in tags:
|
||||
el = _find_child_recursive(parent, tag)
|
||||
if el is not None and el.text:
|
||||
return el.text.strip()
|
||||
return ""
|
||||
|
||||
@ -11,27 +11,17 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from woc_bridge.models import AcceptDecision, BridgeError
|
||||
from woc_bridge.models.contact import FriendRequestInfo
|
||||
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 系统消息,解析后触发自动通过。"""
|
||||
|
||||
@ -46,6 +36,7 @@ class FriendRequestWatcher:
|
||||
poll_interval: float = 3.0,
|
||||
cursor_create_time: int = 0,
|
||||
cursor_local_id: int = 0,
|
||||
login_guard=None, # LoginGuard(登录守卫,None 时跳过登录检查)
|
||||
) -> None:
|
||||
self._db_reader = db_reader
|
||||
self._rule_engine = rule_engine
|
||||
@ -54,23 +45,36 @@ class FriendRequestWatcher:
|
||||
self._xdotool = xdotool_driver
|
||||
self._breaker = breaker
|
||||
self._poll_interval = poll_interval
|
||||
self._login_guard = login_guard
|
||||
# 复合游标 (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
|
||||
# contact.db 独立 mtime:好友申请可能只写入 contact.db 的 ticket_info,
|
||||
# 不触发 message_0.db 变化,必须单独感知
|
||||
self._last_contact_db_mtime: float = 0.0
|
||||
self._last_contact_wal_mtime: float = 0.0
|
||||
# ticket_info 增量游标:避免每次全量读取历史已通过/残留 ticket
|
||||
self._ticket_cursor_local_id: int = 0
|
||||
self._ticket_cursor_inited: bool = False
|
||||
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
|
||||
self._has_more_ticket_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
|
||||
# 短期去重:同一个 wxid 在 N 秒内不重复入队执行 UI 通过,
|
||||
# 避免已通过但 verify 校验超时/失败时被反复处理导致乱点
|
||||
self._recent_wxids: dict[str, float] = {}
|
||||
self._recent_wxid_ttl: float = 60.0
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
@ -96,6 +100,14 @@ class FriendRequestWatcher:
|
||||
def last_processed_time(self) -> Optional[int]:
|
||||
return self._last_processed_time
|
||||
|
||||
@property
|
||||
def cursor_create_time(self) -> int:
|
||||
return self._cursor_create_time
|
||||
|
||||
@property
|
||||
def cursor_local_id(self) -> int:
|
||||
return self._cursor_local_id
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._watcher is None or self._watcher.done():
|
||||
self._watcher = asyncio.create_task(self._watch_loop())
|
||||
@ -114,56 +126,124 @@ class FriendRequestWatcher:
|
||||
if enabled:
|
||||
# 开启时重置游标,避免批量处理积压申请
|
||||
self._cursor_inited = False
|
||||
self._ticket_cursor_inited = False
|
||||
self._has_more_pending = False
|
||||
self._has_more_ticket_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。
|
||||
"""对齐游标到当前 fmessage 表最大 (create_time, local_id)。
|
||||
|
||||
Returns:
|
||||
True 表示已对齐(或 DB 不可读但标记为已初始化,避免无限重试)
|
||||
False 表示异常,下一轮重试
|
||||
"""
|
||||
try:
|
||||
max_ct = await asyncio.to_thread(
|
||||
max_cursor = await asyncio.to_thread(
|
||||
self._db_reader.get_max_create_time_for_talker, "fmessage"
|
||||
)
|
||||
if max_ct is None:
|
||||
if max_cursor 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
|
||||
if isinstance(max_cursor, tuple) and max_cursor[0] > 0:
|
||||
self._cursor_create_time = max_cursor[0]
|
||||
self._cursor_local_id = max_cursor[1]
|
||||
logger.info(
|
||||
"FriendRequestWatcher 游标对齐到 create_time=%d(跳过历史申请)",
|
||||
self._cursor_create_time,
|
||||
"FriendRequestWatcher 游标对齐到 (create_time=%d, local_id=%d)(跳过历史申请)",
|
||||
self._cursor_create_time, self._cursor_local_id,
|
||||
)
|
||||
# 无论 max_ct 是否为 0(空表),都标记为已初始化,避免空表时无限重试
|
||||
# 无论 max_cursor 是否为 (0, 0)(空表),都标记为已初始化,避免空表时无限重试
|
||||
self._cursor_inited = True
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("FriendRequestWatcher 游标初始化失败: %s", e)
|
||||
return False
|
||||
|
||||
async def _init_ticket_cursor(self) -> bool:
|
||||
"""初始化 ticket_info 增量游标到当前最大 id,跳过历史数据。
|
||||
|
||||
Returns:
|
||||
True 表示已初始化;None 时按 0 处理并标记成功。
|
||||
"""
|
||||
try:
|
||||
max_id = await asyncio.to_thread(
|
||||
self._db_reader.get_max_ticket_info_id
|
||||
)
|
||||
if max_id is None:
|
||||
logger.warning(
|
||||
"FriendRequestWatcher ticket 游标初始化: DB 不可读,将在下轮重试"
|
||||
)
|
||||
return False
|
||||
self._ticket_cursor_local_id = max_id
|
||||
self._ticket_cursor_inited = True
|
||||
logger.info(
|
||||
"FriendRequestWatcher ticket 游标对齐到 max_id=%d(跳过历史 ticket)",
|
||||
self._ticket_cursor_local_id,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("FriendRequestWatcher ticket 游标初始化失败: %s", e)
|
||||
return False
|
||||
|
||||
async def _poll_once(self) -> None:
|
||||
# 1. mtime 感知(一级过滤,避免空 SQL)
|
||||
# 上轮满载时跳过过滤(仍有未读批次),否则会因 DB mtime 未变卡住剩余申请
|
||||
# 必须同时感知 message_0.db 与 contact.db:微信 4.x Linux 好友申请可能
|
||||
# 只写入 contact.db 的 ticket_info,不触发 message_0.db 变化。
|
||||
# 上轮满载时跳过过滤(仍有未读批次),否则会因 DB mtime 未变卡住剩余申请。
|
||||
msg_changed = False
|
||||
contact_changed = False
|
||||
|
||||
mtimes = await asyncio.to_thread(
|
||||
self._db_reader.get_db_mtime, "message/message_0.db"
|
||||
)
|
||||
if mtimes is None:
|
||||
logger.debug("FriendRequestWatcher _poll_once: message_0.db mtime 不可读")
|
||||
else:
|
||||
db_mtime, wal_mtime = mtimes
|
||||
if (
|
||||
self._has_more_pending
|
||||
or db_mtime != self._last_db_mtime
|
||||
or wal_mtime != self._last_wal_mtime
|
||||
):
|
||||
msg_changed = True
|
||||
self._last_db_mtime = db_mtime
|
||||
self._last_wal_mtime = wal_mtime
|
||||
|
||||
mtimes_contact = await asyncio.to_thread(
|
||||
self._db_reader.get_db_mtime, "contact/contact.db"
|
||||
)
|
||||
if mtimes_contact is None:
|
||||
logger.debug("FriendRequestWatcher _poll_once: contact.db mtime 不可读")
|
||||
else:
|
||||
cdb_mtime, cwal_mtime = mtimes_contact
|
||||
if (
|
||||
self._has_more_ticket_pending
|
||||
or cdb_mtime != self._last_contact_db_mtime
|
||||
or cwal_mtime != self._last_contact_wal_mtime
|
||||
):
|
||||
contact_changed = True
|
||||
self._last_contact_db_mtime = cdb_mtime
|
||||
self._last_contact_wal_mtime = cwal_mtime
|
||||
|
||||
logger.debug(
|
||||
"FriendRequestWatcher _poll_once: 开始轮询 "
|
||||
"cursor=(%d,%d) msg_changed=%s contact_changed=%s "
|
||||
"last_msg_mtime=(%.3f,%.3f) last_contact_mtime=(%.3f,%.3f)",
|
||||
self._cursor_create_time, self._cursor_local_id,
|
||||
msg_changed, contact_changed,
|
||||
self._last_db_mtime, self._last_wal_mtime,
|
||||
self._last_contact_db_mtime, self._last_contact_wal_mtime,
|
||||
)
|
||||
if not msg_changed and not contact_changed:
|
||||
logger.debug(
|
||||
"FriendRequestWatcher _poll_once: message_0.db/contact.db mtime 均未变化,跳过本次轮询"
|
||||
)
|
||||
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(
|
||||
@ -174,26 +254,71 @@ class FriendRequestWatcher:
|
||||
)
|
||||
if result is None:
|
||||
# DB 不可读(_ensure_decrypted 抛 BridgeError)
|
||||
logger.warning(
|
||||
"FriendRequestWatcher _poll_once: get_friend_requests_since 返回 None(DB 不可读)"
|
||||
)
|
||||
return
|
||||
|
||||
raw_items: list[dict] = result["requests"]
|
||||
raw_items: list[dict] = list(result["requests"])
|
||||
|
||||
# 2.1 fallback:微信 4.x Linux 好友申请可能仅存于 contact.db 的 ticket_info,
|
||||
# fmessage 表可能为空。额外读取 ticket_info,通过 since_id 游标跳过历史数据。
|
||||
ticket_items = await asyncio.to_thread(
|
||||
self._db_reader.get_pending_requests_from_ticket_info,
|
||||
50,
|
||||
self._ticket_cursor_local_id,
|
||||
)
|
||||
if ticket_items:
|
||||
logger.info(
|
||||
"FriendRequestWatcher _poll_once: 从 ticket_info 读到 %d 条新申请 "
|
||||
"(since_id=%d)",
|
||||
len(ticket_items), self._ticket_cursor_local_id,
|
||||
)
|
||||
raw_items.extend(ticket_items)
|
||||
# 推进 ticket_info 游标到本批最大 id
|
||||
max_ticket_id = max(item["local_id"] for item in ticket_items)
|
||||
if max_ticket_id > self._ticket_cursor_local_id:
|
||||
self._ticket_cursor_local_id = max_ticket_id
|
||||
self._has_more_ticket_pending = len(ticket_items) >= 50
|
||||
|
||||
logger.info(
|
||||
"FriendRequestWatcher _poll_once: 本轮检测到 %d 条原始申请 "
|
||||
"(cursor=%d,%d → next=%d,%d)",
|
||||
len(raw_items),
|
||||
self._cursor_create_time, self._cursor_local_id,
|
||||
result["next_create_time"], result["next_local_id"],
|
||||
)
|
||||
|
||||
# 3. 解析 XML + 逐条处理
|
||||
parsed_count = 0
|
||||
for item in raw_items:
|
||||
info = parse_friend_request(
|
||||
item["content"], item["create_time"], item["local_id"]
|
||||
)
|
||||
if info is None:
|
||||
# 非 verifyUser 类型或解析失败,跳过
|
||||
# 非 verifyUser 类型或解析失败,记录日志便于排查
|
||||
logger.debug(
|
||||
"FriendRequestWatcher: 解析失败或非 verifyUser,content=%s",
|
||||
repr(item["content"][:200]) if item.get("content") else "",
|
||||
)
|
||||
continue
|
||||
parsed_count += 1
|
||||
logger.info(
|
||||
"FriendRequestWatcher: 解析到申请 wxid=%s nickname=%s scene=%s",
|
||||
info.stranger_wxid, info.nickname, info.scene,
|
||||
)
|
||||
await self._handle_request(info)
|
||||
|
||||
# 4. 推进复合游标(与 get_friend_requests_since 返回字段对齐)
|
||||
# 4. 推进复合游标(仅由 fmessage 结果决定;ticket_info 无时间戳,不参与游标)
|
||||
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
|
||||
# 5. 满载标志:仅由 fmessage 本批是否达 limit 决定
|
||||
self._has_more_pending = len(result["requests"]) >= 50
|
||||
logger.debug(
|
||||
"FriendRequestWatcher _poll_once: 解析成功 %d/%d 条,满载=%s",
|
||||
parsed_count, len(raw_items), self._has_more_pending,
|
||||
)
|
||||
|
||||
async def _watch_loop(self) -> None:
|
||||
while True:
|
||||
@ -201,6 +326,20 @@ class FriendRequestWatcher:
|
||||
# auto_accept 关闭时挂起,避免空轮询
|
||||
await self._enabled_event.wait()
|
||||
|
||||
# 登录守卫:微信未登录时跳过轮询,不推进游标
|
||||
# 登录后自然从上次游标位置重新读取,DB 中积累的申请不丢失
|
||||
if self._login_guard is not None and not self._login_guard.is_logged_in:
|
||||
logger.info(
|
||||
"FriendRequestWatcher: 微信未登录 (state=%s),暂停轮询等待登录恢复",
|
||||
self._login_guard.current_state,
|
||||
)
|
||||
# 等待登录恢复,带超时避免紧密循环
|
||||
# 超时后 continue 回到 while 顶部重新检查状态
|
||||
await self._login_guard.wait_for_login(
|
||||
timeout=self._poll_interval
|
||||
)
|
||||
continue
|
||||
|
||||
# 游标未初始化时先对齐(DB 恢复可读后自动补齐)
|
||||
# DB_ENCRYPTED 时 _init_cursor 返回 False,下一轮仍会重试
|
||||
if not self._cursor_inited:
|
||||
@ -210,6 +349,13 @@ class FriendRequestWatcher:
|
||||
await asyncio.sleep(self._poll_interval)
|
||||
continue
|
||||
|
||||
# fmessage 游标初始化成功后,再初始化 ticket_info 游标
|
||||
if not self._ticket_cursor_inited:
|
||||
await self._init_ticket_cursor()
|
||||
if not self._ticket_cursor_inited:
|
||||
await asyncio.sleep(self._poll_interval)
|
||||
continue
|
||||
|
||||
await asyncio.sleep(self._poll_interval)
|
||||
await self._poll_once()
|
||||
except asyncio.CancelledError:
|
||||
@ -218,47 +364,144 @@ class FriendRequestWatcher:
|
||||
logger.exception("FriendRequestWatcher 异常: %s", e)
|
||||
await asyncio.sleep(5.0)
|
||||
|
||||
async def _verify_with_retry(self, stranger_wxid: str) -> bool:
|
||||
"""UI 操作完成后轮询 DB 校验好友是否已通过。
|
||||
|
||||
先等待 1.5s 让微信刷盘,之后每隔 1s 查询一次,最多 10s。
|
||||
期间任一次校验成功即返回 True;DB 异常视为未通过并继续重试。
|
||||
"""
|
||||
await asyncio.sleep(1.5)
|
||||
deadline = time.monotonic() + 10.0
|
||||
attempt = 0
|
||||
while time.monotonic() < deadline:
|
||||
attempt += 1
|
||||
try:
|
||||
verified = await asyncio.to_thread(
|
||||
self._db_reader.verify_friend_accepted, stranger_wxid
|
||||
)
|
||||
logger.info(
|
||||
"FriendRequestWatcher: verify 尝试 #%d (wxid=%s) → verified=%s",
|
||||
attempt, stranger_wxid, verified,
|
||||
)
|
||||
if verified:
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"FriendRequestWatcher: verify_friend_accepted 异常 #%d (wxid=%s): %s",
|
||||
attempt, stranger_wxid, exc,
|
||||
)
|
||||
await asyncio.sleep(1.0)
|
||||
logger.warning(
|
||||
"FriendRequestWatcher: verify 超时 (wxid=%s, 尝试 %d 次)",
|
||||
stranger_wxid, attempt,
|
||||
)
|
||||
return False
|
||||
|
||||
async def _handle_request(self, req: FriendRequestInfo) -> None:
|
||||
self._processed_count += 1
|
||||
self._last_processed_time = req.create_time
|
||||
|
||||
# 1. 规则匹配
|
||||
# 步骤 1:收到申请,打印原始信息
|
||||
logger.info(
|
||||
"[自动通过][步骤1/8] 收到好友申请 wxid=%s nickname=%s scene=%s verify=%s",
|
||||
req.stranger_wxid,
|
||||
req.nickname,
|
||||
req.scene,
|
||||
repr(req.verify_message[:80]) if req.verify_message else "",
|
||||
)
|
||||
|
||||
# 步骤 2:规则引擎决策
|
||||
logger.info("[自动通过][步骤2/8] 进入规则引擎评估 wxid=%s", req.stranger_wxid)
|
||||
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,
|
||||
"[自动通过][步骤2/8] 决策结果=REJECT(拒绝),处理结束 wxid=%s",
|
||||
req.stranger_wxid,
|
||||
)
|
||||
return
|
||||
if decision != AcceptDecision.ACCEPT:
|
||||
logger.info(
|
||||
"friend_request: wxid=%s nickname=%s → decision=%s(跳过)",
|
||||
req.stranger_wxid, req.nickname, decision.value,
|
||||
"[自动通过][步骤2/8] 决策结果=SKIP(跳过),处理结束 wxid=%s,"
|
||||
"原因:未命中任何通过规则(accept_all=false、无白名单/关键词匹配)",
|
||||
req.stranger_wxid,
|
||||
)
|
||||
return
|
||||
logger.info(
|
||||
"[自动通过][步骤2/8] 决策结果=ACCEPT(允许通过) wxid=%s",
|
||||
req.stranger_wxid,
|
||||
)
|
||||
|
||||
# 2. 幂等去重(TTL=300s,5 分钟内不重复处理同一申请人)
|
||||
# content 参数传空串(去重 key 是 stranger_wxid,无内容维度)
|
||||
# 步骤 2.5:前置 DB 检查,避免已经通过的好友被重复执行 UI
|
||||
logger.info("[自动通过][步骤2.5/8] 前置校验是否已是好友 wxid=%s", req.stranger_wxid)
|
||||
try:
|
||||
already_accepted = await asyncio.to_thread(
|
||||
self._db_reader.verify_friend_accepted, req.stranger_wxid
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[自动通过][步骤2.5/8] verify_friend_accepted 异常,按未通过继续 wxid=%s: %s",
|
||||
req.stranger_wxid, exc,
|
||||
)
|
||||
already_accepted = False
|
||||
if already_accepted:
|
||||
self._idem_cache.set(
|
||||
"friend_accept", req.stranger_wxid, "",
|
||||
{"success": True, "verified": True, "pre_check": True}, "",
|
||||
)
|
||||
self._accepted_count += 1
|
||||
logger.info(
|
||||
"[自动通过][步骤2.5/8] 已是好友,跳过 UI 操作并写入幂等缓存 wxid=%s",
|
||||
req.stranger_wxid,
|
||||
)
|
||||
return
|
||||
logger.info("[自动通过][步骤2.5/8] 尚未通过,继续执行 UI wxid=%s", req.stranger_wxid)
|
||||
|
||||
# 步骤 3:幂等去重(TTL=300s,5 分钟内不重复处理同一申请人)
|
||||
logger.info("[自动通过][步骤3/8] 检查幂等缓存 wxid=%s", req.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,
|
||||
"[自动通过][步骤3/8] 幂等命中,跳过 wxid=%s (cached=%s)",
|
||||
req.stranger_wxid, cached,
|
||||
)
|
||||
return
|
||||
|
||||
# 3. 熔断检查
|
||||
if not self._breaker.allow():
|
||||
# 步骤 3.5:短期去重,避免 verify 超时/失败后在短时间内重复入队乱点
|
||||
now = time.monotonic()
|
||||
last_at = self._recent_wxids.get(req.stranger_wxid)
|
||||
if last_at is not None and now - last_at < self._recent_wxid_ttl:
|
||||
logger.info(
|
||||
"[自动通过][步骤3.5/8] %.0fs 内已处理过,跳过 wxid=%s",
|
||||
self._recent_wxid_ttl, req.stranger_wxid,
|
||||
)
|
||||
return
|
||||
logger.info("[自动通过][步骤3/8] 幂等未命中,继续处理 wxid=%s", req.stranger_wxid)
|
||||
|
||||
# 步骤 4:熔断检查
|
||||
logger.info("[自动通过][步骤4/8] 检查熔断器 wxid=%s", req.stranger_wxid)
|
||||
breaker_allowed = self._breaker.allow()
|
||||
breaker_state = self._breaker.state.value
|
||||
logger.info(
|
||||
"[自动通过][步骤4/8] 熔断器状态 allow=%s state=%s wxid=%s",
|
||||
breaker_allowed, breaker_state, req.stranger_wxid,
|
||||
)
|
||||
if not breaker_allowed:
|
||||
logger.warning(
|
||||
"friend_request: wxid=%s → 熔断器 OPEN,跳过",
|
||||
"[自动通过][步骤4/8] 熔断器 OPEN,跳过 wxid=%s",
|
||||
req.stranger_wxid,
|
||||
)
|
||||
return
|
||||
|
||||
# 4. 入队执行(lambda 用默认参数捕获 req,避免延迟执行时变量被覆盖)
|
||||
# 步骤 5/6:入队执行 UI 通过
|
||||
logger.info(
|
||||
"[自动通过][步骤5/8] 准备入队执行 UI 通过 wxid=%s",
|
||||
req.stranger_wxid,
|
||||
)
|
||||
# 记录本次处理时间,用于短期去重(即使后续 verify 失败也不立即重试)
|
||||
self._recent_wxids[req.stranger_wxid] = time.monotonic()
|
||||
try:
|
||||
result = await self._send_queue.enqueue(
|
||||
lambda req=req: self._xdotool.accept_friend_request(
|
||||
@ -268,14 +511,18 @@ class FriendRequestWatcher:
|
||||
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
|
||||
logger.info(
|
||||
"[自动通过][步骤6/8] UI 操作完成 result=%s,开始 DB 校验 wxid=%s",
|
||||
result, req.stranger_wxid,
|
||||
)
|
||||
|
||||
# 步骤 7:等待微信 DB WAL 刷盘后校验
|
||||
verified = await self._verify_with_retry(req.stranger_wxid)
|
||||
|
||||
# 步骤 8:结果处理
|
||||
if verified:
|
||||
# set 签名: (flow_name, to_wxid, content, value, client_request_id)
|
||||
# 只有真正通过并验证成功才写幂等缓存,避免坐标不准等临时失败
|
||||
# 导致该申请人 5 分钟内无法再次处理
|
||||
self._idem_cache.set(
|
||||
"friend_accept", req.stranger_wxid, "",
|
||||
{"success": True, "verified": True}, "",
|
||||
@ -283,30 +530,28 @@ class FriendRequestWatcher:
|
||||
self._breaker.record_success()
|
||||
self._accepted_count += 1
|
||||
logger.info(
|
||||
"friend_request: wxid=%s nickname=%s → 通过并验证成功",
|
||||
"[自动通过][步骤8/8] 通过并验证成功 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}, "",
|
||||
)
|
||||
# UI 操作完成但 DB 校验未通过(可能有延迟),不写入幂等缓存,
|
||||
# 下轮仍可尝试;仅记录熔断器失败。
|
||||
self._breaker.record_failure()
|
||||
logger.warning(
|
||||
"friend_request: wxid=%s → UI 操作完成但 DB 校验未通过",
|
||||
"[自动通过][步骤8/8] UI 操作完成但 DB 校验未通过,"
|
||||
"不写入幂等缓存,允许下轮重试 wxid=%s",
|
||||
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,
|
||||
"[自动通过][步骤6/8] BridgeError code=%s: %s wxid=%s",
|
||||
getattr(e, "code", "UNKNOWN"), e, req.stranger_wxid,
|
||||
)
|
||||
except Exception as e:
|
||||
self._breaker.record_failure()
|
||||
logger.error(
|
||||
"friend_request: wxid=%s → 失败: %s",
|
||||
req.stranger_wxid, e,
|
||||
"[自动通过][步骤6/8] 异常: %s wxid=%s",
|
||||
e, req.stranger_wxid,
|
||||
)
|
||||
|
||||
127
bridge/woc_bridge/messaging/login_guard.py
Normal file
@ -0,0 +1,127 @@
|
||||
"""微信登录状态守卫:后台检测登录状态,提供同步属性与事件通知。
|
||||
|
||||
独立后台协程定期调用 detect_login_state(),维护 asyncio.Event 标记当前
|
||||
登录状态。状态变化时记录日志(避免每轮都输出噪音)。
|
||||
|
||||
设计目标:
|
||||
- 后台任务(如 FriendRequestWatcher)在执行 UI 操作前检查 is_logged_in,
|
||||
未登录时跳过操作,等待登录恢复后自然从上次位置继续处理。
|
||||
- 游标 / 状态不推进,登录后 DB 中积累的数据自然被重新读取,不丢数据。
|
||||
- 不替代路由层的登录守卫(routes/send.py 等已有 detect_login_state 前置检查),
|
||||
仅用于后台长轮询任务的登录前提保护。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger("woc-bridge")
|
||||
|
||||
|
||||
class LoginGuard:
|
||||
"""微信登录状态守卫。
|
||||
|
||||
Attributes:
|
||||
_xdotool: XdotoolDriver 实例(调用 detect_login_state)
|
||||
_check_interval: 检测间隔秒数(默认 5.0s)
|
||||
_logged_in_event: asyncio.Event,set 表示当前已登录
|
||||
_watcher: 后台检测协程
|
||||
_current_state: 最近一次检测到的登录状态字符串
|
||||
_last_logged_in: 上次是否登录(用于检测状态变化)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
xdotool_driver,
|
||||
check_interval: float = 5.0,
|
||||
) -> None:
|
||||
self._xdotool = xdotool_driver
|
||||
self._check_interval = check_interval
|
||||
self._logged_in_event: asyncio.Event = asyncio.Event()
|
||||
self._watcher: Optional[asyncio.Task] = None
|
||||
self._current_state: str = "unknown"
|
||||
self._last_logged_in: bool = False
|
||||
|
||||
@property
|
||||
def is_logged_in(self) -> bool:
|
||||
"""当前是否已登录(同步属性,取最近一次检测结果)。"""
|
||||
return self._logged_in_event.is_set()
|
||||
|
||||
@property
|
||||
def current_state(self) -> str:
|
||||
"""最近一次检测到的登录状态字符串。"""
|
||||
return self._current_state
|
||||
|
||||
async def wait_for_login(self, timeout: Optional[float] = None) -> bool:
|
||||
"""等待登录状态变为已登录。
|
||||
|
||||
Args:
|
||||
timeout: 超时秒数,None 表示无限等待
|
||||
|
||||
Returns:
|
||||
True 表示已登录;False 表示超时仍未登录
|
||||
"""
|
||||
if self._logged_in_event.is_set():
|
||||
return True
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self._logged_in_event.wait(), timeout=timeout
|
||||
)
|
||||
return True
|
||||
except asyncio.TimeoutError:
|
||||
return False
|
||||
|
||||
async def start(self) -> None:
|
||||
"""启动后台检测协程。"""
|
||||
if self._watcher is None or self._watcher.done():
|
||||
self._watcher = asyncio.create_task(self._check_loop())
|
||||
logger.info("LoginGuard 已启动 (check_interval=%.1fs)", self._check_interval)
|
||||
|
||||
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
|
||||
logger.info("LoginGuard 已停止")
|
||||
|
||||
async def _check_loop(self) -> None:
|
||||
"""后台检测循环:定期调用 detect_login_state 并维护事件状态。"""
|
||||
while True:
|
||||
try:
|
||||
state = await self._xdotool.detect_login_state()
|
||||
logged_in = (state == "logged_in")
|
||||
|
||||
# 仅在状态变化时记录日志,避免每轮噪音
|
||||
if logged_in != self._last_logged_in:
|
||||
if logged_in:
|
||||
logger.info(
|
||||
"[LoginGuard] 微信已登录 (state=%s),恢复后台任务",
|
||||
state,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"[LoginGuard] 微信未登录 (state=%s),暂停需要登录的后台任务",
|
||||
state,
|
||||
)
|
||||
self._last_logged_in = logged_in
|
||||
|
||||
if logged_in:
|
||||
self._logged_in_event.set()
|
||||
else:
|
||||
self._logged_in_event.clear()
|
||||
|
||||
self._current_state = state
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning("[LoginGuard] 登录状态检测异常: %s", e)
|
||||
self._logged_in_event.clear()
|
||||
self._current_state = "error"
|
||||
|
||||
await asyncio.sleep(self._check_interval)
|
||||
@ -62,18 +62,25 @@ class SendQueue:
|
||||
pass
|
||||
self._worker = None
|
||||
|
||||
async def enqueue(self, coro_factory: CoroFactory, delay_ms: Optional[int] = None) -> Any:
|
||||
async def enqueue(
|
||||
self,
|
||||
coro_factory: CoroFactory,
|
||||
delay_ms: Optional[int] = None,
|
||||
wait_timeout_ms: Optional[int] = None,
|
||||
) -> Any:
|
||||
"""将一个返回 coroutine 的工厂入队,等待执行结果。
|
||||
|
||||
Args:
|
||||
coro_factory: 调用后返回 coroutine 的工厂函数
|
||||
delay_ms: 自定义延时毫秒,None 用默认 send_delay_ms
|
||||
wait_timeout_ms: 队列等待超时毫秒,None 表示无限等待;
|
||||
超时抛出 BridgeError(TIMEOUT)
|
||||
|
||||
Returns:
|
||||
coroutine 的执行结果
|
||||
|
||||
Raises:
|
||||
BridgeError: 限流命中或队列满时立即抛 RATE_LIMITED;
|
||||
BridgeError: 限流命中、队列满或等待超时时抛错;
|
||||
任务执行抛出的异常会透传给调用方
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
@ -90,7 +97,34 @@ class SendQueue:
|
||||
details={"retry_after": 3},
|
||||
)
|
||||
await self._queue.put((coro_factory, future, delay_ms))
|
||||
logger.info("send_queue: 入队 (pending=%d)", self._queue.qsize())
|
||||
pending = self._queue.qsize()
|
||||
logger.info("send_queue: 入队 (pending=%d)", pending)
|
||||
if wait_timeout_ms is not None and wait_timeout_ms > 0:
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
future, timeout=wait_timeout_ms / 1000.0
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
# 竞争窗口:worker 可能刚好在此时完成并 set_result。
|
||||
# 若 future 已完成且未被取消,直接取结果;否则取消 future 并抛 TIMEOUT。
|
||||
if future.done() and not future.cancelled():
|
||||
logger.info(
|
||||
"send_queue: 等待超时但任务刚好完成,取结果 (pending=%d)",
|
||||
pending,
|
||||
)
|
||||
return future.result()
|
||||
# 取消 future,worker 出队时发现 cancelled 会跳过执行
|
||||
future.cancel()
|
||||
wait_sec = wait_timeout_ms / 1000.0
|
||||
logger.warning(
|
||||
"send_queue: 等待超时 (pending=%d, wait_timeout=%.1fs)",
|
||||
pending, wait_sec,
|
||||
)
|
||||
raise BridgeError(
|
||||
code="TIMEOUT",
|
||||
message=f"发送队列等待超时({pending} 个待处理,已等 {wait_sec:.1f}s)",
|
||||
details={"retry_after": max(1, int(self.send_delay_ms / 1000))},
|
||||
)
|
||||
return await future
|
||||
|
||||
def pending_count(self) -> int:
|
||||
@ -120,6 +154,10 @@ class SendQueue:
|
||||
message=f"发送限流:每秒最多 {self.max_calls_per_sec} 次",
|
||||
details={"retry_after": retry_after},
|
||||
)
|
||||
logger.debug(
|
||||
"send_queue: 限流通过 (recent=%d/%d)",
|
||||
len(self._recent_call_times), self.max_calls_per_sec,
|
||||
)
|
||||
|
||||
async def _run(self) -> None:
|
||||
"""worker 主循环。
|
||||
@ -130,6 +168,15 @@ class SendQueue:
|
||||
"""
|
||||
while True:
|
||||
coro_factory, future, custom_delay_ms = await self._queue.get()
|
||||
# 调用方已超时取消:跳过执行与延时
|
||||
if future.cancelled():
|
||||
self._queue.task_done()
|
||||
logger.info(
|
||||
"send_queue: 出队任务已取消,跳过 (pending=%d)",
|
||||
self._queue.qsize(),
|
||||
)
|
||||
continue
|
||||
|
||||
logger.info("send_queue: 出队,开始处理 (pending=%d)", self._queue.qsize())
|
||||
# 标记任务是否真正开始执行(用于决定 finally 是否延时)
|
||||
executed = False
|
||||
@ -163,7 +210,7 @@ class SendQueue:
|
||||
future.set_exception(e)
|
||||
finally:
|
||||
self._queue.task_done()
|
||||
# 仅在任务真正执行过时延时,限流失败的任务不延时
|
||||
# 仅在任务真正执行过时延时,限流失败/已取消的任务不延时
|
||||
if executed:
|
||||
delay = custom_delay_ms if custom_delay_ms is not None else self.send_delay_ms
|
||||
logger.info(
|
||||
|
||||
@ -114,6 +114,8 @@ class MessageStreamer:
|
||||
# value 为订阅时间戳(monotonic),用于在超限时剔除最早订阅者
|
||||
self._subscribers: dict[asyncio.Queue[StreamEvent], float] = {}
|
||||
self._global_cursor: int = 0
|
||||
# 复合游标 tie-breaker:与 create_time 共同定位分页边界,避免同秒消息重复广播
|
||||
self._global_cursor_local_id: int = 0
|
||||
self._watcher: asyncio.Task | None = None
|
||||
# DB mtime 缓存:变化时才查 SQL
|
||||
self._last_db_mtime: float = 0.0
|
||||
@ -174,11 +176,17 @@ class MessageStreamer:
|
||||
try:
|
||||
q.put_nowait(StreamEvent(
|
||||
event="sync",
|
||||
data={"cursor": self._global_cursor},
|
||||
data={
|
||||
"cursor": self._global_cursor,
|
||||
"cursor_local_id": self._global_cursor_local_id,
|
||||
},
|
||||
))
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
logger.info("SSE 订阅,当前订阅者 %d,cursor=%d", self.subscriber_count(), self._global_cursor)
|
||||
logger.info(
|
||||
"SSE 订阅,当前订阅者 %d,cursor=%d cursor_local_id=%d",
|
||||
self.subscriber_count(), self._global_cursor, self._global_cursor_local_id,
|
||||
)
|
||||
return q
|
||||
|
||||
def unsubscribe(self, q: asyncio.Queue[StreamEvent]) -> None:
|
||||
@ -267,20 +275,25 @@ class MessageStreamer:
|
||||
logger.info("SSE 广播 %s: %s 订阅者=%d", event.event, event.data, self.subscriber_count())
|
||||
|
||||
async def _init_cursor(self) -> bool:
|
||||
"""把全局游标对齐到当前 DB 最大 create_time。
|
||||
"""把全局游标对齐到当前 DB 最大 (create_time, local_id)。
|
||||
|
||||
避免新连接收到全量历史消息。DB 不可读时保持 0,等可读后再对齐。
|
||||
避免新连接收到全量历史消息,也避免启动时同秒消息被重复推送。
|
||||
DB 不可读时保持 0,等可读后再对齐。
|
||||
|
||||
Returns:
|
||||
True 表示成功对齐游标(或游标已对齐);False 表示 DB 不可读,
|
||||
需要在后续轮询中重试
|
||||
"""
|
||||
try:
|
||||
max_ct = await asyncio.to_thread(self._db_reader.get_max_create_time)
|
||||
if max_ct and max_ct > 0:
|
||||
self._global_cursor = max_ct
|
||||
max_cursor = await asyncio.to_thread(self._db_reader.get_max_create_time)
|
||||
if max_cursor and isinstance(max_cursor, tuple) and max_cursor[0] > 0:
|
||||
self._global_cursor = max_cursor[0]
|
||||
self._global_cursor_local_id = max_cursor[1]
|
||||
self._cursor_inited = True
|
||||
logger.info("MessageStreamer 初始游标对齐到 %d", self._global_cursor)
|
||||
logger.info(
|
||||
"MessageStreamer 初始游标对齐到 (%d, %d)",
|
||||
self._global_cursor, self._global_cursor_local_id,
|
||||
)
|
||||
return True
|
||||
# DB 可读但 message 表为空或返回 None:视为已对齐(cursor=0 合理)
|
||||
self._cursor_inited = True
|
||||
@ -368,7 +381,10 @@ class MessageStreamer:
|
||||
while True:
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
self._db_reader.get_messages_since, self._global_cursor, BATCH_LIMIT
|
||||
self._db_reader.get_messages_since,
|
||||
self._global_cursor,
|
||||
BATCH_LIMIT,
|
||||
cursor_local_id=self._global_cursor_local_id,
|
||||
)
|
||||
except Exception as e:
|
||||
# DB_ENCRYPTED 时尝试重新提取 key 并重试一次
|
||||
@ -382,7 +398,10 @@ class MessageStreamer:
|
||||
return
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
self._db_reader.get_messages_since, self._global_cursor, BATCH_LIMIT
|
||||
self._db_reader.get_messages_since,
|
||||
self._global_cursor,
|
||||
BATCH_LIMIT,
|
||||
cursor_local_id=self._global_cursor_local_id,
|
||||
)
|
||||
except Exception as e2:
|
||||
logger.warning("MessageStreamer: 重试后仍失败: %s", e2)
|
||||
@ -396,41 +415,22 @@ class MessageStreamer:
|
||||
return
|
||||
|
||||
next_cursor = result.get("next_cursor", self._global_cursor)
|
||||
next_cursor_local_id = result.get("next_cursor_local_id", self._global_cursor_local_id)
|
||||
has_more = result.get("has_more", False)
|
||||
self._global_cursor = next_cursor
|
||||
self._global_cursor_local_id = next_cursor_local_id
|
||||
|
||||
# 6. 广播
|
||||
# 6. 广播(内部已打印摘要 + 逐条消息日志,此处不再重复)
|
||||
self._broadcast(StreamEvent(
|
||||
event="messages",
|
||||
id=str(next_cursor),
|
||||
id=f"{next_cursor}:{next_cursor_local_id}",
|
||||
data={
|
||||
"messages": messages,
|
||||
"next_cursor": next_cursor,
|
||||
"next_cursor_local_id": next_cursor_local_id,
|
||||
"has_more": has_more,
|
||||
},
|
||||
))
|
||||
# 逐条打印通过 SSE 推送出去的消息详情
|
||||
for msg in messages:
|
||||
content_preview = (msg.get("content") or "").replace("\n", "\\n")
|
||||
if len(content_preview) > 100:
|
||||
content_preview = content_preview[:100] + "..."
|
||||
logger.info(
|
||||
"SSE 推送消息详情: msg_id=%s talker=%s sender=%s is_sender=%s "
|
||||
"type=%s render=%s session=%s create_time=%s content=%s",
|
||||
msg.get("msg_id"),
|
||||
msg.get("talker"),
|
||||
msg.get("sender") or "-",
|
||||
msg.get("is_sender"),
|
||||
msg.get("type"),
|
||||
msg.get("render_type"),
|
||||
msg.get("session_type"),
|
||||
msg.get("create_time"),
|
||||
content_preview,
|
||||
)
|
||||
logger.info(
|
||||
"MessageStreamer 推送 %d 条消息, cursor=%d, 订阅者=%d",
|
||||
len(messages), next_cursor, self.subscriber_count(),
|
||||
)
|
||||
|
||||
if not has_more:
|
||||
return
|
||||
|
||||
@ -4,12 +4,13 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import enum
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from woc_bridge.messaging.friend_watcher import FriendRequestInfo
|
||||
|
||||
logger = logging.getLogger("woc-bridge")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -135,6 +136,36 @@ class FriendRequestItem(BaseModel):
|
||||
create_time: int
|
||||
|
||||
|
||||
# 运行时数据结构:避免 friend_parser.py 与 friend_watcher.py 循环导入
|
||||
class FriendRequestInfo:
|
||||
"""好友申请信息(从 fmessage sysmsg XML 解析)。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
stranger_wxid: str,
|
||||
nickname: str,
|
||||
verify_message: str,
|
||||
scene: str,
|
||||
raw_xml: str,
|
||||
create_time: int,
|
||||
msg_local_id: int,
|
||||
) -> None:
|
||||
self.stranger_wxid = stranger_wxid
|
||||
self.nickname = nickname
|
||||
self.verify_message = verify_message
|
||||
self.scene = scene
|
||||
self.raw_xml = raw_xml
|
||||
self.create_time = create_time
|
||||
self.msg_local_id = msg_local_id
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"FriendRequestInfo(stranger_wxid={self.stranger_wxid!r}, "
|
||||
f"nickname={self.nickname!r}, scene={self.scene!r}, "
|
||||
f"create_time={self.create_time}, msg_local_id={self.msg_local_id})"
|
||||
)
|
||||
|
||||
|
||||
class FriendRequestsResponse(BaseModel):
|
||||
"""好友申请列表响应。"""
|
||||
requests: list[FriendRequestItem] = Field(default_factory=list)
|
||||
@ -166,6 +197,18 @@ class AutoAcceptStatus(BaseModel):
|
||||
accepted_count: int = 0
|
||||
rejected_count: int = 0
|
||||
last_processed_time: Optional[int] = None
|
||||
cursor_create_time: Optional[int] = Field(
|
||||
default=None, description="当前轮询 create_time 游标(调试用)"
|
||||
)
|
||||
cursor_local_id: Optional[int] = Field(
|
||||
default=None, description="当前轮询 local_id 游标(调试用)"
|
||||
)
|
||||
db_readable: Optional[bool] = Field(
|
||||
default=None, description="DB 是否可读(调试用)"
|
||||
)
|
||||
breaker_state: Optional[str] = Field(
|
||||
default=None, description="熔断器当前状态(调试用)"
|
||||
)
|
||||
|
||||
|
||||
class AcceptRuleConfig(BaseModel):
|
||||
@ -207,34 +250,69 @@ class AcceptRuleEngine:
|
||||
async with self._lock:
|
||||
cfg = self._config
|
||||
|
||||
logger.info(
|
||||
"[规则引擎] 开始评估 wxid=%s nickname=%s scene=%s verify=%s",
|
||||
req.stranger_wxid, req.nickname, req.scene,
|
||||
repr(req.verify_message[:80]) if req.verify_message else "",
|
||||
)
|
||||
logger.info(
|
||||
"[规则引擎] 当前配置 enabled=%s accept_all=%s allow_scenes=%s keywords=%s "
|
||||
"whitelist_wxids=%s whitelist_nicknames=%s blacklist_wxids=%s blacklist_nicknames=%s",
|
||||
cfg.enabled, cfg.accept_all, cfg.allow_scenes, cfg.keywords,
|
||||
cfg.whitelist_wxids, cfg.whitelist_nicknames,
|
||||
cfg.blacklist_wxids, cfg.blacklist_nicknames,
|
||||
)
|
||||
|
||||
if not cfg.enabled:
|
||||
logger.info("[规则引擎] 全局开关 enabled=false → SKIP")
|
||||
return AcceptDecision.SKIP
|
||||
|
||||
if cfg.accept_all:
|
||||
logger.info("[规则引擎] accept_all=true → ACCEPT")
|
||||
return AcceptDecision.ACCEPT
|
||||
|
||||
# 黑名单优先
|
||||
if req.stranger_wxid in cfg.blacklist_wxids:
|
||||
logger.info("[规则引擎] wxid 命中黑名单 → REJECT")
|
||||
return AcceptDecision.REJECT
|
||||
if req.nickname in cfg.blacklist_nicknames:
|
||||
if req.nickname and req.nickname in cfg.blacklist_nicknames:
|
||||
logger.info("[规则引擎] nickname 命中黑名单 → REJECT")
|
||||
return AcceptDecision.REJECT
|
||||
logger.info("[规则引擎] 黑名单未命中")
|
||||
|
||||
# 场景过滤
|
||||
if cfg.allow_scenes and req.scene not in cfg.allow_scenes:
|
||||
logger.info(
|
||||
"[规则引擎] scene=%s 不在允许列表 %s → SKIP",
|
||||
req.scene, cfg.allow_scenes,
|
||||
)
|
||||
return AcceptDecision.SKIP
|
||||
logger.info("[规则引擎] 场景检查通过 allow_scenes=%s", cfg.allow_scenes)
|
||||
|
||||
# 白名单
|
||||
if req.stranger_wxid in cfg.whitelist_wxids:
|
||||
logger.info("[规则引擎] wxid 命中白名单 → ACCEPT")
|
||||
return AcceptDecision.ACCEPT
|
||||
if req.nickname in cfg.whitelist_nicknames:
|
||||
if req.nickname and req.nickname in cfg.whitelist_nicknames:
|
||||
logger.info("[规则引擎] nickname 命中白名单 → ACCEPT")
|
||||
return AcceptDecision.ACCEPT
|
||||
logger.info("[规则引擎] 白名单未命中")
|
||||
|
||||
# 关键词
|
||||
if cfg.keywords:
|
||||
for kw in cfg.keywords:
|
||||
if kw in req.verify_message:
|
||||
logger.info("[规则引擎] 验证消息命中关键词 '%s' → ACCEPT", kw)
|
||||
return AcceptDecision.ACCEPT
|
||||
logger.info(
|
||||
"[规则引擎] 关键词未命中 keywords=%s verify=%s",
|
||||
cfg.keywords,
|
||||
repr(req.verify_message[:80]) if req.verify_message else "",
|
||||
)
|
||||
else:
|
||||
logger.info("[规则引擎] 未配置关键词")
|
||||
|
||||
logger.info("[规则引擎] 无匹配规则 → SKIP")
|
||||
return AcceptDecision.SKIP
|
||||
|
||||
async def update_config(self, config: AcceptRuleConfig) -> None:
|
||||
|
||||
@ -27,6 +27,9 @@ class MessagesResponse(BaseModel):
|
||||
|
||||
messages: list[Message] = Field(default_factory=list)
|
||||
next_cursor: int = Field(description="下次拉取应使用的 cursor")
|
||||
next_cursor_local_id: int = Field(
|
||||
default=0, description="下次拉取应使用的 local_id 游标(同秒消息 tie-breaker)"
|
||||
)
|
||||
has_more: bool = Field(default=False, description="是否可能还有更多消息")
|
||||
|
||||
|
||||
@ -38,6 +41,9 @@ class MessagesBySessionResponse(BaseModel):
|
||||
|
||||
messages: list[Message] = Field(default_factory=list)
|
||||
next_cursor: int = Field(description="下次拉取应使用的 cursor")
|
||||
next_cursor_local_id: int = Field(
|
||||
default=0, description="下次拉取应使用的 local_id 游标(同秒消息 tie-breaker)"
|
||||
)
|
||||
has_more: bool = Field(default=False, description="是否可能还有更多消息")
|
||||
talker: str = Field(description="本次查询的会话对方 wxid")
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
@ -34,8 +35,24 @@ async def submit_batch(req: BatchSendRequest, request: Request):
|
||||
- 429 RATE_LIMITED:日频次超限 / 批间隔不足
|
||||
"""
|
||||
worker = _require_batch_worker()
|
||||
batch_id = await worker.submit_batch(req)
|
||||
t_total = time.perf_counter()
|
||||
logger.info(
|
||||
"send/batch: 收到请求 targets=%d content_len=%d client_request_id=%s",
|
||||
len(req.targets), len(req.content), req.client_request_id or "(none)",
|
||||
)
|
||||
try:
|
||||
batch_id = await worker.submit_batch(req)
|
||||
except BridgeError as e:
|
||||
logger.warning(
|
||||
"send/batch: 提交失败 %s: %s",
|
||||
e.code, e.message,
|
||||
)
|
||||
raise
|
||||
status = await worker.get_status(batch_id)
|
||||
logger.info(
|
||||
"send/batch: ✓ batch_id=%s targets=%d (总耗时 %.0fms)",
|
||||
batch_id, len(req.targets), (time.perf_counter() - t_total) * 1000,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"batch_id": batch_id,
|
||||
@ -54,6 +71,7 @@ async def get_batch_status(batch_id: str):
|
||||
异常:
|
||||
- 404 NOT_FOUND:batch_id 不存在
|
||||
"""
|
||||
logger.debug("send/batch/status: 查询 batch_id=%s", batch_id)
|
||||
worker = _require_batch_worker()
|
||||
status = await worker.get_status(batch_id)
|
||||
if status is None:
|
||||
@ -74,14 +92,27 @@ async def cancel_batch(batch_id: str):
|
||||
异常:
|
||||
- 404 NOT_FOUND:batch_id 不存在或已完成
|
||||
"""
|
||||
logger.info("send/batch/cancel: 收到请求 batch_id=%s", batch_id)
|
||||
worker = _require_batch_worker()
|
||||
cancelled = await worker.cancel_batch(batch_id)
|
||||
try:
|
||||
cancelled = await worker.cancel_batch(batch_id)
|
||||
except BridgeError as e:
|
||||
logger.warning(
|
||||
"send/batch/cancel: 失败 %s: %s (batch_id=%s)",
|
||||
e.code, e.message, batch_id,
|
||||
)
|
||||
raise
|
||||
if not cancelled:
|
||||
logger.warning(
|
||||
"send/batch/cancel: 失败 NOT_FOUND: batch_id 不存在或已完成 (batch_id=%s)",
|
||||
batch_id,
|
||||
)
|
||||
raise BridgeError(
|
||||
code="NOT_FOUND",
|
||||
message=f"batch_id={batch_id} 不存在或已完成",
|
||||
http_status=404,
|
||||
)
|
||||
logger.info("send/batch/cancel: ✓ batch_id=%s", batch_id)
|
||||
return {"success": True, "batch_id": batch_id, "status": "cancelled"}
|
||||
|
||||
|
||||
@ -95,6 +126,7 @@ async def list_batches(limit: int = 20):
|
||||
异常:
|
||||
- 400 INVALID_PARAMS:limit 越界
|
||||
"""
|
||||
logger.debug("send/batches: 列表查询 limit=%d", limit)
|
||||
worker = _require_batch_worker()
|
||||
if limit < 1 or limit > 100:
|
||||
raise BridgeError(
|
||||
|
||||
@ -2,10 +2,12 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from woc_bridge.config import (
|
||||
_state,
|
||||
_require_db_reader, _require_xdotool, _require_send_queue,
|
||||
_require_rule_engine, _require_friend_watcher,
|
||||
)
|
||||
@ -415,20 +417,36 @@ async def accept_friend(req: AcceptFriendRequest) -> AcceptFriendResponse:
|
||||
BridgeError(WECHAT_NOT_LOGGED_IN): 未登录(HTTP 401)
|
||||
BridgeError(WINDOW_NOT_FOUND / SEND_FAILED / RATE_LIMITED)
|
||||
"""
|
||||
logger.info(
|
||||
"friends/accept: 收到手动通过好友申请 wxid=%s nickname=%s",
|
||||
req.stranger_wxid, req.nickname,
|
||||
)
|
||||
if not req.stranger_wxid:
|
||||
logger.warning("friends/accept: 参数校验失败 — stranger_wxid 为空")
|
||||
raise BridgeError(code="INVALID_PARAMS", message="stranger_wxid 不能为空")
|
||||
|
||||
xdotool = _require_xdotool()
|
||||
send_queue = _require_send_queue()
|
||||
db_reader = _require_db_reader()
|
||||
|
||||
t0 = time.perf_counter()
|
||||
login_state = await xdotool.detect_login_state()
|
||||
logger.info(
|
||||
"friends/accept: 登录态检测 → %s (%.0fms)",
|
||||
login_state, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
if login_state != "logged_in":
|
||||
logger.warning("friends/accept: 拒绝通过,登录态=%s", login_state)
|
||||
raise BridgeError(
|
||||
code="WECHAT_NOT_LOGGED_IN",
|
||||
message=f"当前登录态为 {login_state},无法通过好友申请",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"friends/accept: 入队 send_queue (pending=%d)",
|
||||
send_queue.pending_count(),
|
||||
)
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
# lambda 用默认参数捕获 req(SendQueue.enqueue 延迟执行,避免变量被覆盖)
|
||||
await send_queue.enqueue(
|
||||
@ -439,16 +457,26 @@ async def accept_friend(req: AcceptFriendRequest) -> AcceptFriendResponse:
|
||||
)
|
||||
except BridgeError:
|
||||
# 透传 BridgeError(RATE_LIMITED / SEND_FAILED / WINDOW_NOT_FOUND 等)
|
||||
logger.warning(
|
||||
"friends/accept: 入队/执行失败 BridgeError (%.0fms)",
|
||||
(time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"friends/accept: 入队/执行失败 %s: %s (%.0fms)",
|
||||
type(e).__name__, e, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
raise BridgeError(code="SEND_FAILED", message=f"通过好友申请失败: {e}")
|
||||
|
||||
logger.info(
|
||||
"friends/accept: wxid=%s nickname=%s → UI 操作完成",
|
||||
"friends/accept: wxid=%s nickname=%s → UI 操作完成 (%.0fms)",
|
||||
req.stranger_wxid, req.nickname,
|
||||
(time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
|
||||
# Task 10: 复用 _verify_accept_with_retry 校验好友是否真正通过
|
||||
t0 = time.perf_counter()
|
||||
verified: bool | None = None
|
||||
try:
|
||||
verified = await _verify_accept_with_retry(
|
||||
@ -456,10 +484,14 @@ async def accept_friend(req: AcceptFriendRequest) -> AcceptFriendResponse:
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"friends/accept: post_verify 异常 (wxid=%s): %s",
|
||||
req.stranger_wxid, exc,
|
||||
"friends/accept: post_verify 异常 (wxid=%s): %s (%.0fms)",
|
||||
req.stranger_wxid, exc, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
verified = False
|
||||
logger.info(
|
||||
"friends/accept: ✓ wxid=%s verified=%s post_verify=%.0fms",
|
||||
req.stranger_wxid, verified, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
return AcceptFriendResponse(success=True, error=None, verified=verified)
|
||||
|
||||
|
||||
@ -473,6 +505,10 @@ async def get_auto_accept_config() -> AcceptRuleConfig:
|
||||
@router.put("/api/friends/auto_accept/config", response_model=AcceptRuleConfig)
|
||||
async def update_auto_accept_config(config: AcceptRuleConfig) -> AcceptRuleConfig:
|
||||
"""更新自动通过规则配置(热生效)。"""
|
||||
logger.info(
|
||||
"friends/auto_accept/config: 更新配置 enabled=%s accept_all=%s",
|
||||
config.enabled, config.accept_all,
|
||||
)
|
||||
rule_engine = _require_rule_engine()
|
||||
await rule_engine.update_config(config)
|
||||
# 同步更新 watcher 的 enabled 状态
|
||||
@ -483,6 +519,9 @@ async def update_auto_accept_config(config: AcceptRuleConfig) -> AcceptRuleConfi
|
||||
# 用户通过 API 从 False 切到 True 时需补启动;start() 幂等,已运行则跳过)
|
||||
if config.enabled and not watcher.is_running:
|
||||
await watcher.start()
|
||||
# 关闭 auto_accept 时停止 watcher 协程,避免继续轮询
|
||||
if not config.enabled and watcher.is_running:
|
||||
await watcher.stop()
|
||||
return config
|
||||
|
||||
|
||||
@ -490,6 +529,21 @@ async def update_auto_accept_config(config: AcceptRuleConfig) -> AcceptRuleConfi
|
||||
async def get_auto_accept_status() -> AutoAcceptStatus:
|
||||
"""查询自动通过运行状态。"""
|
||||
watcher = _require_friend_watcher()
|
||||
db_reader = _require_db_reader()
|
||||
breaker = _state.accept_breaker
|
||||
db_readable = await asyncio.to_thread(db_reader.is_db_accessible)
|
||||
breaker_state = None
|
||||
if breaker is not None:
|
||||
try:
|
||||
breaker_state = breaker.state.value
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(
|
||||
"friends/auto_accept/status: running=%s enabled=%s db_readable=%s breaker_state=%s "
|
||||
"processed=%d accepted=%d rejected=%d",
|
||||
watcher.is_running, watcher.is_enabled, db_readable, breaker_state,
|
||||
watcher.processed_count, watcher.accepted_count, watcher.rejected_count,
|
||||
)
|
||||
return AutoAcceptStatus(
|
||||
running=watcher.is_running,
|
||||
enabled=watcher.is_enabled,
|
||||
@ -497,4 +551,8 @@ async def get_auto_accept_status() -> AutoAcceptStatus:
|
||||
accepted_count=watcher.accepted_count,
|
||||
rejected_count=watcher.rejected_count,
|
||||
last_processed_time=watcher.last_processed_time,
|
||||
cursor_create_time=watcher.cursor_create_time,
|
||||
cursor_local_id=watcher.cursor_local_id,
|
||||
db_readable=db_readable,
|
||||
breaker_state=breaker_state,
|
||||
)
|
||||
|
||||
@ -47,12 +47,26 @@ async def login_qr_start() -> QrLoginStartResult:
|
||||
- 若微信已登录,本接口仍会返回二维码截图(调用方应先调
|
||||
/api/status 判断 login_state,避免重复登录)
|
||||
"""
|
||||
logger.info("login/qr/start: 收到请求")
|
||||
xdotool = _require_xdotool()
|
||||
qr_capture = _require_qr_capture()
|
||||
|
||||
# 激活微信窗口(非阻塞,避免 VNC 无人操作时 --sync 死等)
|
||||
await xdotool._activate_window_fast()
|
||||
qr_data_url = await qr_capture.capture_qr_code()
|
||||
t0 = time.perf_counter()
|
||||
logger.info("login/qr/start: 激活窗口 + 截图开始")
|
||||
try:
|
||||
await xdotool._activate_window_fast()
|
||||
qr_data_url = await qr_capture.capture_qr_code()
|
||||
except BridgeError as e:
|
||||
logger.warning(
|
||||
"login/qr/start: 失败 BridgeError code=%s msg=%s (%.0fms)",
|
||||
e.code, e.message, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
raise
|
||||
logger.info(
|
||||
"login/qr/start: 激活窗口 + 截图完成 (%.0fms)",
|
||||
(time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
|
||||
return QrLoginStartResult(
|
||||
qr_data_url=qr_data_url,
|
||||
@ -167,25 +181,51 @@ async def login_logout() -> LogoutResponse:
|
||||
不会拉起新进程;如需重新登录走 /api/login/qr/start
|
||||
- 不删除任何文件:登录态由微信自身管理,bridge 不破坏数据
|
||||
"""
|
||||
logger.info("login/logout: 收到请求")
|
||||
t_total = time.perf_counter()
|
||||
xdotool = _require_xdotool()
|
||||
|
||||
# 记录调用前的登录态(用于返回 message)
|
||||
t0 = time.perf_counter()
|
||||
state_before = await xdotool.detect_login_state()
|
||||
logger.info(
|
||||
"login/logout: 登录态检测 → %s (%.0fms)",
|
||||
state_before, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
|
||||
# 若已登录,执行 UI 退出操作
|
||||
if state_before == LoginState.LOGGED_IN.value:
|
||||
t1 = time.perf_counter()
|
||||
logger.info("login/logout: logout 调用开始")
|
||||
try:
|
||||
await xdotool.logout()
|
||||
except BridgeError:
|
||||
except BridgeError as e:
|
||||
logger.warning(
|
||||
"login/logout: 失败 BridgeError code=%s msg=%s (%.0fms)",
|
||||
e.code, e.message, (time.perf_counter() - t1) * 1000,
|
||||
)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"login/logout: 失败 %s: %s (%.0fms)",
|
||||
type(e).__name__, e, (time.perf_counter() - t1) * 1000,
|
||||
)
|
||||
raise BridgeError(
|
||||
code="LOGOUT_FAILED",
|
||||
message=f"退出登录失败: {e}",
|
||||
)
|
||||
logger.info(
|
||||
"login/logout: logout 调用完成 (%.0fms)",
|
||||
(time.perf_counter() - t1) * 1000,
|
||||
)
|
||||
logger.info(
|
||||
"login/logout: ✓ (总耗时 %.0fms)",
|
||||
(time.perf_counter() - t_total) * 1000,
|
||||
)
|
||||
return LogoutResponse(success=True, message="已退出登录")
|
||||
|
||||
# 未登录,幂等返回
|
||||
logger.info("login/logout: 当前未登录,无需登出")
|
||||
return LogoutResponse(success=True, message="当前未登录,无需退出")
|
||||
|
||||
|
||||
@ -222,22 +262,48 @@ async def wechat_restart() -> RestartResponse:
|
||||
- 调用方应在收到 success=true 后调 /api/status 确认 login_state
|
||||
恢复到 logged_in(autostart 拉起后微信会自动尝试恢复登录)
|
||||
"""
|
||||
timeout_sec = 30
|
||||
logger.info("wechat/restart: 收到请求 timeout=%ds", timeout_sec)
|
||||
t_total = time.perf_counter()
|
||||
xdotool = _require_xdotool()
|
||||
|
||||
# 记录调用前是否在运行
|
||||
t0 = time.perf_counter()
|
||||
was_running = await xdotool.is_wechat_running()
|
||||
logger.info(
|
||||
"wechat/restart: 进程检测 → running=%s (%.0fms)",
|
||||
was_running, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
|
||||
t1 = time.perf_counter()
|
||||
logger.info("wechat/restart: restart 调用开始")
|
||||
try:
|
||||
new_pid = await xdotool.restart_wechat(timeout_sec=30)
|
||||
except BridgeError:
|
||||
new_pid = await xdotool.restart_wechat(timeout_sec=timeout_sec)
|
||||
except BridgeError as e:
|
||||
logger.warning(
|
||||
"wechat/restart: 失败 BridgeError code=%s msg=%s (%.0fms)",
|
||||
e.code, e.message, (time.perf_counter() - t1) * 1000,
|
||||
)
|
||||
raise
|
||||
except Exception as e:
|
||||
# 非 timeout 的意外错误(如 pgrep 不可用)归为内部错误,
|
||||
# 不误报为 RESTART_TIMEOUT(调用方会按 timeout 语义重试,无意义)
|
||||
logger.warning(
|
||||
"wechat/restart: 失败 %s: %s (%.0fms)",
|
||||
type(e).__name__, e, (time.perf_counter() - t1) * 1000,
|
||||
)
|
||||
raise BridgeError(
|
||||
code="BRIDGE_INTERNAL_ERROR",
|
||||
message=f"重启微信失败: {e}",
|
||||
)
|
||||
logger.info(
|
||||
"wechat/restart: restart 调用完成 (%.0fms)",
|
||||
(time.perf_counter() - t1) * 1000,
|
||||
)
|
||||
|
||||
message = "微信已重启" if was_running else "微信已启动"
|
||||
logger.info(
|
||||
"wechat/restart: ✓ (总耗时 %.0fms)",
|
||||
(time.perf_counter() - t_total) * 1000,
|
||||
)
|
||||
return RestartResponse(success=True, message=message, pid=new_pid)
|
||||
|
||||
@ -25,13 +25,17 @@ router = APIRouter()
|
||||
@with_db_retry
|
||||
async def get_messages_since(
|
||||
cursor: int = 0,
|
||||
cursor_local_id: int = Query(
|
||||
0,
|
||||
description="上次拉取的最大 local_id(同 create_time 时的 tie-breaker,默认 0)",
|
||||
),
|
||||
limit: int = 50,
|
||||
is_sender: Optional[bool] = Query(
|
||||
None,
|
||||
description="按发送方向过滤:True=仅本人发送 / False=仅对方发送 / None=不过滤(默认)",
|
||||
),
|
||||
) -> MessagesResponse:
|
||||
"""增量消息拉取(按 create_time 游标)。
|
||||
"""增量消息拉取(按 create_time + local_id 复合游标)。
|
||||
|
||||
channels PullerAdapter 的核心接口:调用方维护本地 cursor(首次为 0),
|
||||
每次拉取 create_time > cursor 的消息,把响应中 next_cursor 存下来作为
|
||||
@ -39,13 +43,14 @@ async def get_messages_since(
|
||||
|
||||
Args:
|
||||
cursor: 上次拉取的最大 create_time,首次传 0(拉全量)
|
||||
cursor_local_id: 上次拉取的最大 local_id,用于同秒消息去重(默认 0)
|
||||
limit: 最多返回条数,1~200,默认 50。建议用 /api/status 返回的
|
||||
max_batch_size 作为上限参考
|
||||
is_sender: 按发送方向过滤,True=仅本人发送 / False=仅对方发送 /
|
||||
None=不过滤(默认)。SQL 层过滤,无法定位本人时返回空
|
||||
|
||||
Returns:
|
||||
MessagesResponse:含 messages 列表 / next_cursor / has_more
|
||||
MessagesResponse:含 messages 列表 / next_cursor / next_cursor_local_id / has_more
|
||||
|
||||
Raises:
|
||||
BridgeError(INVALID_PARAMS): cursor<0 或 limit 越界(HTTP 400)
|
||||
@ -64,6 +69,11 @@ async def get_messages_since(
|
||||
code="INVALID_PARAMS",
|
||||
message=f"cursor 必须 >= 0,收到 {cursor}",
|
||||
)
|
||||
if cursor_local_id < 0:
|
||||
raise BridgeError(
|
||||
code="INVALID_PARAMS",
|
||||
message=f"cursor_local_id 必须 >= 0,收到 {cursor_local_id}",
|
||||
)
|
||||
if limit < 1 or limit > 200:
|
||||
raise BridgeError(
|
||||
code="INVALID_PARAMS",
|
||||
@ -77,12 +87,15 @@ async def get_messages_since(
|
||||
|
||||
# DB 读取是同步阻塞操作,用 to_thread 包装
|
||||
result = await asyncio.to_thread(
|
||||
db_reader.get_messages_since, cursor, limit, is_sender
|
||||
db_reader.get_messages_since, cursor, limit, is_sender,
|
||||
cursor_local_id=cursor_local_id,
|
||||
)
|
||||
msg_count = len(result.get("messages", []))
|
||||
logger.info(
|
||||
"messages/since: cursor=%d limit=%d is_sender=%s → 返回 %d 条, next_cursor=%s has_more=%s",
|
||||
cursor, limit, is_sender, msg_count, result.get("next_cursor"), result.get("has_more"),
|
||||
"messages/since: cursor=%d cursor_local_id=%d limit=%d is_sender=%s → "
|
||||
"返回 %d 条, next_cursor=%s next_cursor_local_id=%s has_more=%s",
|
||||
cursor, cursor_local_id, limit, is_sender, msg_count,
|
||||
result.get("next_cursor"), result.get("next_cursor_local_id"), result.get("has_more"),
|
||||
)
|
||||
return MessagesResponse(**result)
|
||||
|
||||
@ -171,6 +184,10 @@ async def stream_messages(request: Request) -> StreamingResponse:
|
||||
async def get_messages_by_session(
|
||||
talker: str,
|
||||
cursor: int = 0,
|
||||
cursor_local_id: int = Query(
|
||||
0,
|
||||
description="上次拉取的最大 local_id(同 create_time 时的 tie-breaker,默认 0)",
|
||||
),
|
||||
limit: int = 50,
|
||||
direction: str = "before",
|
||||
is_sender: Optional[bool] = Query(
|
||||
@ -187,13 +204,14 @@ async def get_messages_by_session(
|
||||
talker: 会话对方 wxid(群消息为 chatroom id)
|
||||
cursor: 游标,首次传 0;before 模式取该时间之前的旧消息,
|
||||
after 模式取该时间之后的新消息
|
||||
cursor_local_id: 上次拉取的最大 local_id,用于同秒消息去重(默认 0)
|
||||
limit: 最多返回条数,1~200,默认 50
|
||||
direction: before(默认,往前翻历史)/ after(往后拉新消息)
|
||||
is_sender: 按发送方向过滤,True=仅本人发送 / False=仅对方发送 /
|
||||
None=不过滤(默认)。SQL 层过滤,无法定位本人时返回空
|
||||
|
||||
Returns:
|
||||
MessagesBySessionResponse:含 messages / next_cursor / has_more / talker
|
||||
MessagesBySessionResponse:含 messages / next_cursor / next_cursor_local_id / has_more / talker
|
||||
|
||||
Raises:
|
||||
BridgeError(INVALID_PARAMS): talker 为空、limit 越界、direction 非法(HTTP 400)
|
||||
@ -203,7 +221,7 @@ async def get_messages_by_session(
|
||||
Notes:
|
||||
- before 模式返回升序消息(旧在前、新在后),便于客户端追加到列表头部
|
||||
- cursor=0 + before 取最新 limit 条;cursor=某消息时间 + before 取更旧的消息
|
||||
- has_more=true 时用 next_cursor 继续翻页
|
||||
- has_more=true 时用 next_cursor + next_cursor_local_id 继续翻页
|
||||
- 表不存在时返回空列表而非报错(会话可能从未有过消息)
|
||||
"""
|
||||
if not talker:
|
||||
@ -223,18 +241,25 @@ async def get_messages_by_session(
|
||||
code="INVALID_PARAMS",
|
||||
message=f"cursor 必须 >= 0,收到 {cursor}",
|
||||
)
|
||||
if cursor_local_id < 0:
|
||||
raise BridgeError(
|
||||
code="INVALID_PARAMS",
|
||||
message=f"cursor_local_id 必须 >= 0,收到 {cursor_local_id}",
|
||||
)
|
||||
|
||||
db_reader = _require_db_reader()
|
||||
await _check_db_readable()
|
||||
|
||||
result = await asyncio.to_thread(
|
||||
db_reader.get_messages_by_session, talker, cursor, limit, direction, is_sender
|
||||
db_reader.get_messages_by_session,
|
||||
talker, cursor, limit, direction, cursor_local_id, is_sender,
|
||||
)
|
||||
msg_count = len(result.get("messages", []))
|
||||
logger.info(
|
||||
"messages/by_session: talker=%s cursor=%d direction=%s limit=%d is_sender=%s → 返回 %d 条, next_cursor=%s has_more=%s",
|
||||
talker, cursor, direction, limit, is_sender, msg_count,
|
||||
result.get("next_cursor"), result.get("has_more"),
|
||||
"messages/by_session: talker=%s cursor=%d cursor_local_id=%d direction=%s "
|
||||
"limit=%d is_sender=%s → 返回 %d 条, next_cursor=%s next_cursor_local_id=%s has_more=%s",
|
||||
talker, cursor, cursor_local_id, direction, limit, is_sender, msg_count,
|
||||
result.get("next_cursor"), result.get("next_cursor_local_id"), result.get("has_more"),
|
||||
)
|
||||
return MessagesBySessionResponse(**result)
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter
|
||||
@ -136,24 +137,40 @@ async def publish_moment(req: MomentPublishRequest) -> MomentPublishResponse:
|
||||
"""
|
||||
xdotool = _require_xdotool()
|
||||
send_queue = _require_send_queue()
|
||||
t_total = time.perf_counter()
|
||||
|
||||
# 校验请求体
|
||||
if not req.content:
|
||||
logger.warning("moments/publish: 参数校验失败 — content 为空")
|
||||
raise BridgeError(code="INVALID_PARAMS", message="content 不能为空")
|
||||
|
||||
logger.info("moments/publish: 收到请求 content_len=%d", len(req.content))
|
||||
|
||||
# 检查登录态
|
||||
t0 = time.perf_counter()
|
||||
login_state = await xdotool.detect_login_state()
|
||||
logger.info(
|
||||
"moments/publish: 登录态检测 → %s (%.0fms)",
|
||||
login_state, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
if login_state != "logged_in":
|
||||
logger.warning("moments/publish: 拒绝,登录态=%s", login_state)
|
||||
raise BridgeError(
|
||||
code="WECHAT_NOT_LOGGED_IN",
|
||||
message=f"当前登录态为 {login_state},无法发表朋友圈",
|
||||
)
|
||||
|
||||
# 经发送队列串行执行(避免与发消息 UI 竞态)
|
||||
logger.info("moments/publish: 入队 send_queue (pending=%d)", send_queue.pending_count())
|
||||
try:
|
||||
t0 = time.perf_counter()
|
||||
local_moment_id = await send_queue.enqueue(
|
||||
lambda: xdotool.publish_moment(req.content)
|
||||
)
|
||||
logger.info(
|
||||
"moments/publish: 队列执行完成 → local_moment_id=%s (%.0fms)",
|
||||
local_moment_id, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
except BridgeError:
|
||||
raise
|
||||
except Exception as e:
|
||||
@ -163,8 +180,9 @@ async def publish_moment(req: MomentPublishRequest) -> MomentPublishResponse:
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"moments/publish: content_len=%d → 成功 local_moment_id=%s",
|
||||
"moments/publish: content_len=%d → 成功 local_moment_id=%s (总耗时 %.0fms)",
|
||||
len(req.content), local_moment_id,
|
||||
(time.perf_counter() - t_total) * 1000,
|
||||
)
|
||||
return MomentPublishResponse(
|
||||
success=True,
|
||||
@ -210,26 +228,46 @@ async def share_article_moment(req: MomentShareArticleRequest) -> MomentShareArt
|
||||
"""
|
||||
xdotool = _require_xdotool()
|
||||
send_queue = _require_send_queue()
|
||||
t_total = time.perf_counter()
|
||||
|
||||
# 校验请求体
|
||||
if not req.public_account:
|
||||
logger.warning("moments/share_article: 参数校验失败 — public_account 为空")
|
||||
raise BridgeError(code="INVALID_PARAMS", message="public_account 不能为空")
|
||||
if req.article_index < 1:
|
||||
logger.warning(
|
||||
"moments/share_article: 参数校验失败 — article_index=%d (public_account=%s)",
|
||||
req.article_index, req.public_account,
|
||||
)
|
||||
raise BridgeError(
|
||||
code="INVALID_PARAMS",
|
||||
message=f"article_index 必须 >= 1,收到 {req.article_index}",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"moments/share_article: 收到请求 public_account=%s index=%d comment_len=%d",
|
||||
req.public_account, req.article_index,
|
||||
len(req.comment) if req.comment else 0,
|
||||
)
|
||||
|
||||
# 检查登录态
|
||||
t0 = time.perf_counter()
|
||||
login_state = await xdotool.detect_login_state()
|
||||
logger.info(
|
||||
"moments/share_article: 登录态检测 → %s (%.0fms)",
|
||||
login_state, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
if login_state != "logged_in":
|
||||
logger.warning("moments/share_article: 拒绝,登录态=%s", login_state)
|
||||
raise BridgeError(
|
||||
code="WECHAT_NOT_LOGGED_IN",
|
||||
message=f"当前登录态为 {login_state},无法分享文章",
|
||||
)
|
||||
|
||||
# 经发送队列串行执行(避免与发消息 UI 竞态)
|
||||
logger.info("moments/share_article: 入队 send_queue (pending=%d)", send_queue.pending_count())
|
||||
try:
|
||||
t0 = time.perf_counter()
|
||||
local_moment_id = await send_queue.enqueue(
|
||||
lambda: xdotool.share_article_moment(
|
||||
req.public_account,
|
||||
@ -237,6 +275,10 @@ async def share_article_moment(req: MomentShareArticleRequest) -> MomentShareArt
|
||||
comment=req.comment,
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
"moments/share_article: 队列执行完成 → local_moment_id=%s (%.0fms)",
|
||||
local_moment_id, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
except BridgeError:
|
||||
raise
|
||||
except Exception as e:
|
||||
@ -246,10 +288,11 @@ async def share_article_moment(req: MomentShareArticleRequest) -> MomentShareArt
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"moments/share_article: public_account=%s index=%d comment_len=%d → 成功 local_moment_id=%s",
|
||||
"moments/share_article: public_account=%s index=%d comment_len=%d → 成功 local_moment_id=%s (总耗时 %.0fms)",
|
||||
req.public_account, req.article_index,
|
||||
len(req.comment) if req.comment else 0,
|
||||
local_moment_id,
|
||||
(time.perf_counter() - t_total) * 1000,
|
||||
)
|
||||
return MomentShareArticleResponse(
|
||||
success=True,
|
||||
@ -283,7 +326,12 @@ async def moment_like(req: MomentLikeRequest) -> MomentLikeResponse:
|
||||
- experimental:朋友圈"赞/评论"按钮坐标为估算值
|
||||
- 自动进入朋友圈页面,无需调用方预先进入
|
||||
"""
|
||||
t_total = time.perf_counter()
|
||||
if req.moment_index < 1:
|
||||
logger.warning(
|
||||
"moments/like: 参数校验失败 — moment_index=%d",
|
||||
req.moment_index,
|
||||
)
|
||||
raise BridgeError(
|
||||
code="INVALID_PARAMS",
|
||||
message=f"moment_index 必须 >= 1,收到 {req.moment_index}",
|
||||
@ -292,8 +340,16 @@ async def moment_like(req: MomentLikeRequest) -> MomentLikeResponse:
|
||||
xdotool = _require_xdotool()
|
||||
send_queue = _require_send_queue()
|
||||
|
||||
logger.info("moments/like: 收到请求 index=%d", req.moment_index)
|
||||
|
||||
t0 = time.perf_counter()
|
||||
login_state = await xdotool.detect_login_state()
|
||||
logger.info(
|
||||
"moments/like: 登录态检测 → %s (%.0fms)",
|
||||
login_state, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
if login_state != "logged_in":
|
||||
logger.warning("moments/like: 拒绝,登录态=%s", login_state)
|
||||
raise BridgeError(
|
||||
code="WECHAT_NOT_LOGGED_IN",
|
||||
message=f"当前登录态为 {login_state},无法点赞",
|
||||
@ -317,8 +373,14 @@ async def moment_like(req: MomentLikeRequest) -> MomentLikeResponse:
|
||||
return False
|
||||
|
||||
verified: bool | None = None
|
||||
logger.info("moments/like: 入队 send_queue (pending=%d)", send_queue.pending_count())
|
||||
try:
|
||||
t0 = time.perf_counter()
|
||||
verified = await send_queue.enqueue(_like_and_verify)
|
||||
logger.info(
|
||||
"moments/like: 队列执行完成 → verified=%s (%.0fms)",
|
||||
verified, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
except BridgeError:
|
||||
raise
|
||||
except Exception as e:
|
||||
@ -327,7 +389,10 @@ async def moment_like(req: MomentLikeRequest) -> MomentLikeResponse:
|
||||
message=f"点赞失败: {e}",
|
||||
)
|
||||
|
||||
logger.info("moments/like: index=%d → UI 操作完成", req.moment_index)
|
||||
logger.info(
|
||||
"moments/like: index=%d → UI 操作完成 (总耗时 %.0fms)",
|
||||
req.moment_index, (time.perf_counter() - t_total) * 1000,
|
||||
)
|
||||
return MomentLikeResponse(success=True, error=None, verified=verified)
|
||||
|
||||
|
||||
@ -355,9 +420,15 @@ async def moment_comment(req: MomentCommentRequest) -> MomentCommentResponse:
|
||||
- experimental:坐标为估算值
|
||||
- 自动进入朋友圈页面,无需调用方预先进入
|
||||
"""
|
||||
t_total = time.perf_counter()
|
||||
if not req.comment:
|
||||
logger.warning("moments/comment: 参数校验失败 — comment 为空")
|
||||
raise BridgeError(code="INVALID_PARAMS", message="comment 不能为空")
|
||||
if req.moment_index < 1:
|
||||
logger.warning(
|
||||
"moments/comment: 参数校验失败 — moment_index=%d (comment_len=%d)",
|
||||
req.moment_index, len(req.comment),
|
||||
)
|
||||
raise BridgeError(
|
||||
code="INVALID_PARAMS",
|
||||
message=f"moment_index 必须 >= 1,收到 {req.moment_index}",
|
||||
@ -367,8 +438,19 @@ async def moment_comment(req: MomentCommentRequest) -> MomentCommentResponse:
|
||||
send_queue = _require_send_queue()
|
||||
db_reader = _require_db_reader()
|
||||
|
||||
logger.info(
|
||||
"moments/comment: 收到请求 index=%d comment_len=%d",
|
||||
req.moment_index, len(req.comment),
|
||||
)
|
||||
|
||||
t0 = time.perf_counter()
|
||||
login_state = await xdotool.detect_login_state()
|
||||
logger.info(
|
||||
"moments/comment: 登录态检测 → %s (%.0fms)",
|
||||
login_state, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
if login_state != "logged_in":
|
||||
logger.warning("moments/comment: 拒绝,登录态=%s", login_state)
|
||||
raise BridgeError(
|
||||
code="WECHAT_NOT_LOGGED_IN",
|
||||
message=f"当前登录态为 {login_state},无法评论",
|
||||
@ -392,8 +474,14 @@ async def moment_comment(req: MomentCommentRequest) -> MomentCommentResponse:
|
||||
return False
|
||||
|
||||
verified: bool | None = None
|
||||
logger.info("moments/comment: 入队 send_queue (pending=%d)", send_queue.pending_count())
|
||||
try:
|
||||
t0 = time.perf_counter()
|
||||
verified = await send_queue.enqueue(_comment_and_verify)
|
||||
logger.info(
|
||||
"moments/comment: 队列执行完成 → verified=%s (%.0fms)",
|
||||
verified, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
except BridgeError:
|
||||
raise
|
||||
except Exception as e:
|
||||
@ -403,8 +491,9 @@ async def moment_comment(req: MomentCommentRequest) -> MomentCommentResponse:
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"moments/comment: index=%d comment_len=%d → UI 操作完成",
|
||||
"moments/comment: index=%d comment_len=%d → UI 操作完成 (总耗时 %.0fms)",
|
||||
req.moment_index, len(req.comment),
|
||||
(time.perf_counter() - t_total) * 1000,
|
||||
)
|
||||
return MomentCommentResponse(success=True, error=None, verified=verified)
|
||||
|
||||
@ -435,7 +524,12 @@ async def moment_delete(req: MomentDeleteRequest) -> MomentDeleteResponse:
|
||||
- 自动进入朋友圈页面,避免在聊天界面误删消息
|
||||
- 只能删除自己发的朋友圈;长按别人的朋友圈不会出现"删除"项
|
||||
"""
|
||||
t_total = time.perf_counter()
|
||||
if req.moment_index < 1:
|
||||
logger.warning(
|
||||
"moments/delete: 参数校验失败 — moment_index=%d",
|
||||
req.moment_index,
|
||||
)
|
||||
raise BridgeError(
|
||||
code="INVALID_PARAMS",
|
||||
message=f"moment_index 必须 >= 1,收到 {req.moment_index}",
|
||||
@ -445,8 +539,16 @@ async def moment_delete(req: MomentDeleteRequest) -> MomentDeleteResponse:
|
||||
send_queue = _require_send_queue()
|
||||
db_reader = _require_db_reader()
|
||||
|
||||
logger.info("moments/delete: 收到请求 index=%d", req.moment_index)
|
||||
|
||||
t0 = time.perf_counter()
|
||||
login_state = await xdotool.detect_login_state()
|
||||
logger.info(
|
||||
"moments/delete: 登录态检测 → %s (%.0fms)",
|
||||
login_state, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
if login_state != "logged_in":
|
||||
logger.warning("moments/delete: 拒绝,登录态=%s", login_state)
|
||||
raise BridgeError(
|
||||
code="WECHAT_NOT_LOGGED_IN",
|
||||
message=f"当前登录态为 {login_state},无法删除朋友圈",
|
||||
@ -455,15 +557,34 @@ async def moment_delete(req: MomentDeleteRequest) -> MomentDeleteResponse:
|
||||
# P0 修复:删除前捕获朋友圈数量基线,供 post_verify 比对。
|
||||
# 原实现 post_verify 只查 status=ok 就返回 True,完全未校验删除生效。
|
||||
baseline_count: Optional[int] = None
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
result = await asyncio.to_thread(db_reader.get_moments_timeline, 0, 50)
|
||||
if result.get("status") == "ok":
|
||||
baseline_count = len(result.get("moments", []))
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(
|
||||
"moments/delete: baseline_count 捕获成功 → %d (%.0fms)",
|
||||
baseline_count, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"moments/delete: baseline_count 捕获失败,status=%s (%.0fms)",
|
||||
result.get("status"), (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"moments/delete: baseline_count 捕获异常: %s (%.0fms)",
|
||||
exc, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
|
||||
logger.info("moments/delete: 入队 send_queue (pending=%d)", send_queue.pending_count())
|
||||
try:
|
||||
t0 = time.perf_counter()
|
||||
await send_queue.enqueue(lambda: xdotool.moment_delete(req.moment_index))
|
||||
logger.info(
|
||||
"moments/delete: 队列执行完成 (%.0fms)",
|
||||
(time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
except BridgeError:
|
||||
raise
|
||||
except Exception as e:
|
||||
@ -472,7 +593,10 @@ async def moment_delete(req: MomentDeleteRequest) -> MomentDeleteResponse:
|
||||
message=f"删除朋友圈失败: {e}",
|
||||
)
|
||||
|
||||
logger.info("moments/delete: index=%d → UI 操作完成", req.moment_index)
|
||||
logger.info(
|
||||
"moments/delete: index=%d → UI 操作完成 (总耗时 %.0fms)",
|
||||
req.moment_index, (time.perf_counter() - t_total) * 1000,
|
||||
)
|
||||
|
||||
# Task 11: post_verify 校验 MomentsPost 记录是否已删除
|
||||
verified: bool | None = None
|
||||
@ -516,16 +640,26 @@ async def publish_moment_with_image(
|
||||
- experimental:相机按钮、文件选择器、发表按钮坐标均为估算值
|
||||
- image_path 必须是容器内可访问的绝对路径
|
||||
"""
|
||||
t_total = time.perf_counter()
|
||||
if not req.image_path:
|
||||
logger.warning("moments/publish_image: 参数校验失败 — image_path 为空")
|
||||
raise BridgeError(code="INVALID_PARAMS", message="image_path 不能为空")
|
||||
# 路径白名单:仅允许容器内安全目录的图片(防止 /etc/shadow 等敏感文件被读取)
|
||||
_real = os.path.realpath(req.image_path)
|
||||
if not _real.startswith(_SAFE_IMAGE_DIRS):
|
||||
logger.warning(
|
||||
"moments/publish_image: 路径校验失败 — image_path=%s 不在安全目录 %s",
|
||||
_real, _SAFE_IMAGE_DIRS,
|
||||
)
|
||||
raise BridgeError(
|
||||
code="INVALID_PARAMS",
|
||||
message=f"image_path 必须位于安全目录 {_SAFE_IMAGE_DIRS} 内,收到 {_real}",
|
||||
)
|
||||
if not os.path.isfile(req.image_path) or not os.access(req.image_path, os.R_OK):
|
||||
logger.warning(
|
||||
"moments/publish_image: 路径校验失败 — 图片不存在或不可读 image_path=%s",
|
||||
req.image_path,
|
||||
)
|
||||
raise BridgeError(
|
||||
code="INVALID_PARAMS",
|
||||
message=f"图片不存在或不可读: {req.image_path}",
|
||||
@ -534,17 +668,34 @@ async def publish_moment_with_image(
|
||||
xdotool = _require_xdotool()
|
||||
send_queue = _require_send_queue()
|
||||
|
||||
logger.info(
|
||||
"moments/publish_image: 收到请求 image=%s content_len=%d",
|
||||
req.image_path, len(req.content) if req.content else 0,
|
||||
)
|
||||
|
||||
t0 = time.perf_counter()
|
||||
login_state = await xdotool.detect_login_state()
|
||||
logger.info(
|
||||
"moments/publish_image: 登录态检测 → %s (%.0fms)",
|
||||
login_state, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
if login_state != "logged_in":
|
||||
logger.warning("moments/publish_image: 拒绝,登录态=%s", login_state)
|
||||
raise BridgeError(
|
||||
code="WECHAT_NOT_LOGGED_IN",
|
||||
message=f"当前登录态为 {login_state},无法发表朋友圈",
|
||||
)
|
||||
|
||||
logger.info("moments/publish_image: 入队 send_queue (pending=%d)", send_queue.pending_count())
|
||||
try:
|
||||
t0 = time.perf_counter()
|
||||
local_moment_id = await send_queue.enqueue(
|
||||
lambda: xdotool.publish_moment_with_image(req.image_path, req.content)
|
||||
)
|
||||
logger.info(
|
||||
"moments/publish_image: 队列执行完成 → local_moment_id=%s (%.0fms)",
|
||||
local_moment_id, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
except BridgeError:
|
||||
raise
|
||||
except Exception as e:
|
||||
@ -554,8 +705,9 @@ async def publish_moment_with_image(
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"moments/publish_image: image=%s content_len=%d → 成功 local_moment_id=%s",
|
||||
"moments/publish_image: image=%s content_len=%d → 成功 local_moment_id=%s (总耗时 %.0fms)",
|
||||
req.image_path, len(req.content) if req.content else 0, local_moment_id,
|
||||
(time.perf_counter() - t_total) * 1000,
|
||||
)
|
||||
return MomentPublishImageResponse(
|
||||
success=True,
|
||||
|
||||
@ -6,6 +6,7 @@ from fastapi import APIRouter
|
||||
from fastapi.responses import Response
|
||||
|
||||
from woc_bridge.config import _require_qr_capture
|
||||
from woc_bridge.models import BridgeError
|
||||
|
||||
logger = logging.getLogger("woc-bridge")
|
||||
router = APIRouter()
|
||||
@ -34,8 +35,16 @@ async def screenshot() -> Response:
|
||||
- 与 /api/login/qr/start 区别:本接口截全屏,qr/start 截二维码区域
|
||||
- 大屏幕截图可能 > 1MB,调用方注意带宽
|
||||
"""
|
||||
logger.info("screenshot: 收到请求")
|
||||
qr_capture = _require_qr_capture()
|
||||
|
||||
png_bytes = await qr_capture.capture_full_screenshot()
|
||||
logger.info("screenshot: → 截图成功 %d bytes", len(png_bytes))
|
||||
try:
|
||||
png_bytes = await qr_capture.capture_full_screenshot()
|
||||
except BridgeError as e:
|
||||
logger.warning("screenshot: 失败 %s: %s", e.code, e.message)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning("screenshot: 失败 %s: %s", type(e).__name__, e)
|
||||
raise
|
||||
logger.info("screenshot: ✓ 截图成功 %d bytes", len(png_bytes))
|
||||
return Response(content=png_bytes, media_type="image/png")
|
||||
|
||||
@ -32,10 +32,11 @@ router = APIRouter()
|
||||
# 路由:POST /api/send/text
|
||||
# ---------------------------------------------------------------------------
|
||||
# display_name 解析缓存:wxid -> (display_name, timestamp)
|
||||
# 联系人备注/昵称不常变,5 分钟 TTL 足够;最大 256 条防内存膨胀
|
||||
# 联系人备注/昵称不常变,5 分钟 TTL 足够;最大 1024 条防内存膨胀
|
||||
_display_name_cache: dict[str, tuple[str, float]] = {}
|
||||
_DISPLAY_NAME_CACHE_TTL = 300.0 # 5 分钟
|
||||
_DISPLAY_NAME_CACHE_MAX = 256
|
||||
_DISPLAY_NAME_CACHE_MAX = 1024
|
||||
_DISPLAY_NAME_WARMUP_LIMIT = 1000 # 启动时预热最近联系人数量
|
||||
|
||||
|
||||
async def _resolve_display_name(
|
||||
@ -82,6 +83,48 @@ async def _resolve_display_name(
|
||||
return resolved
|
||||
|
||||
|
||||
async def warm_display_name_cache(db_reader: Optional[DbReader]) -> int:
|
||||
"""启动时异步预热 display_name 缓存。
|
||||
|
||||
从高并发场景看,首批 30-100 个请求可能同时到达,若缓存为空会并发查询
|
||||
contact.db。预热最近 1000 个联系人可显著降低启动初期的 DB 压力。
|
||||
|
||||
Args:
|
||||
db_reader: DbReader 实例
|
||||
|
||||
Returns:
|
||||
预热写入的条目数
|
||||
"""
|
||||
if db_reader is None:
|
||||
return 0
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
db_reader.get_contacts, "", _DISPLAY_NAME_WARMUP_LIMIT
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("warm_display_name_cache: 预热失败 %s", exc)
|
||||
return 0
|
||||
|
||||
contacts = result.get("contacts", [])
|
||||
warmed = 0
|
||||
now = time.monotonic()
|
||||
for contact in contacts:
|
||||
wxid = contact.get("username") or contact.get("wxid")
|
||||
if not wxid:
|
||||
continue
|
||||
resolved = contact.get("remark") or contact.get("nickname") or wxid
|
||||
# 启动时批量写入,暂不触发 LRU 淘汰
|
||||
_display_name_cache[wxid] = (resolved, now)
|
||||
warmed += 1
|
||||
|
||||
logger.info(
|
||||
"warm_display_name_cache: 预热 %d 条联系人 (%.0fms)",
|
||||
warmed, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
return warmed
|
||||
|
||||
|
||||
async def _get_latest_create_time_for_talker(
|
||||
db_reader: Optional[DbReader],
|
||||
to_wxid: str,
|
||||
@ -348,15 +391,30 @@ async def _send_text_via_flow(req: SendTextRequest) -> SendResponse:
|
||||
code="BRIDGE_INTERNAL_ERROR", message="orchestrator not initialized"
|
||||
)
|
||||
|
||||
t_total = time.perf_counter()
|
||||
|
||||
# 校验参数
|
||||
if not req.to_wxid:
|
||||
logger.warning("send/text: 参数校验失败 — to_wxid 为空 (flow 路径)")
|
||||
raise BridgeError(code="INVALID_PARAMS", message="to_wxid 不能为空")
|
||||
if not req.content:
|
||||
logger.warning("send/text: 参数校验失败 — content 为空 (to_wxid=%s, flow 路径)", req.to_wxid)
|
||||
raise BridgeError(code="INVALID_PARAMS", message="content 不能为空")
|
||||
|
||||
logger.info(
|
||||
"send/text: 收到请求 (flow) to_wxid=%s content_len=%d display_name=%s client_request_id=%s",
|
||||
req.to_wxid, len(req.content), req.display_name or "(未提供)",
|
||||
req.client_request_id or "(none)",
|
||||
)
|
||||
|
||||
# 检查登录态(与 legacy 路径一致)
|
||||
t0 = time.perf_counter()
|
||||
xdotool = _require_xdotool()
|
||||
login_state = await xdotool.detect_login_state()
|
||||
logger.info(
|
||||
"send/text: 登录态检测 (flow) → %s (%.0fms)",
|
||||
login_state, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
if login_state != "logged_in":
|
||||
logger.warning("send/text: 拒绝发送(flow 路径),登录态=%s", login_state)
|
||||
raise BridgeError(
|
||||
@ -365,9 +423,14 @@ async def _send_text_via_flow(req: SendTextRequest) -> SendResponse:
|
||||
)
|
||||
|
||||
# 解析 display_name(复用现有 _resolve_display_name)
|
||||
t0 = time.perf_counter()
|
||||
display_name = await _resolve_display_name(
|
||||
_state.db_reader, req.to_wxid, req.display_name
|
||||
)
|
||||
logger.info(
|
||||
"send/text: display_name 解析 (flow) → %s (%.0fms)",
|
||||
display_name, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
|
||||
# 调用 FlowOrchestrator
|
||||
result = await orchestrator.send_text(
|
||||
@ -379,6 +442,11 @@ async def _send_text_via_flow(req: SendTextRequest) -> SendResponse:
|
||||
|
||||
# FlowResult 转 SendResponse
|
||||
if result.success:
|
||||
logger.info(
|
||||
"send/text: ✓ (flow) to=%s display_name=%s local_send_id=%s verified=%s skipped=%s (总耗时 %.0fms)",
|
||||
req.to_wxid, display_name, result.local_id, result.verified, result.skipped,
|
||||
(time.perf_counter() - t_total) * 1000,
|
||||
)
|
||||
return SendResponse(
|
||||
success=True,
|
||||
local_send_id=result.local_id,
|
||||
@ -389,6 +457,11 @@ async def _send_text_via_flow(req: SendTextRequest) -> SendResponse:
|
||||
|
||||
# 失败:抛 BridgeError(保持与 legacy 一致的错误语义)
|
||||
error_code = result.error_code or "SEND_FAILED"
|
||||
logger.warning(
|
||||
"send/text: ✗ (flow) to=%s failed code=%s error=%s (总耗时 %.0fms)",
|
||||
req.to_wxid, error_code, result.error,
|
||||
(time.perf_counter() - t_total) * 1000,
|
||||
)
|
||||
# 已知错误码直接透传(含可重试错误码 RATE_LIMITED/SEND_TIMEOUT 等),
|
||||
# ERROR_CODES 表已补全所有 Flow 错误码的 HTTP 映射
|
||||
if error_code in ERROR_CODES:
|
||||
|
||||
@ -51,6 +51,23 @@ class Actions:
|
||||
"""
|
||||
self.backend = backend
|
||||
self.locator = locator
|
||||
# 截图缓存:同一次 flow 内连续查找可避免重复截图(CPU/IO 优化)
|
||||
self._shot_cache: Optional[tuple[float, bytes]] = None
|
||||
self._shot_cache_ttl_ms = 150
|
||||
|
||||
def _get_cached_screenshot(self) -> Optional[bytes]:
|
||||
"""返回未过期的缓存截图,若无则 None。"""
|
||||
if self._shot_cache is None:
|
||||
return None
|
||||
ts, shot = self._shot_cache
|
||||
if (time.monotonic() - ts) * 1000 > self._shot_cache_ttl_ms:
|
||||
self._shot_cache = None
|
||||
return None
|
||||
return shot
|
||||
|
||||
def _set_cached_screenshot(self, shot: bytes) -> None:
|
||||
"""写入截图缓存。"""
|
||||
self._shot_cache = (time.monotonic(), shot)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 元素查找
|
||||
@ -79,7 +96,10 @@ class Actions:
|
||||
and not self.locator.is_circuited(kind)
|
||||
and self.locator.opencv is not None
|
||||
):
|
||||
shot = await self.backend.screenshot()
|
||||
shot = self._get_cached_screenshot()
|
||||
if shot is None:
|
||||
shot = await self.backend.screenshot()
|
||||
self._set_cached_screenshot(shot)
|
||||
roi = (win_geom.x, win_geom.y, win_geom.width, win_geom.height)
|
||||
try:
|
||||
result = await self.locator.opencv.find_template(
|
||||
@ -100,6 +120,10 @@ class Actions:
|
||||
# result = (x, y, w, h, confidence)(绝对坐标,含 ROI 偏移)
|
||||
x, y, w, h, conf = result
|
||||
self.locator.record_image_success(kind)
|
||||
logger.info(
|
||||
"[action] image match success kind=%s at (%d,%d) conf=%.3f",
|
||||
kind, x + w // 2, y + h // 2, conf,
|
||||
)
|
||||
return ElementHandle(
|
||||
selector=sel,
|
||||
x=x + w // 2,
|
||||
@ -120,6 +144,7 @@ class Actions:
|
||||
f"image_match_failed kind={kind}"
|
||||
)
|
||||
|
||||
logger.debug("[action] geom fallback kind=%s", kind)
|
||||
return self._geom_find(sel, win_geom)
|
||||
|
||||
async def click_element(
|
||||
@ -161,10 +186,19 @@ class Actions:
|
||||
win_geom: 当前窗口几何
|
||||
clear_first: 是否先 BackSpace×30 清空旧内容
|
||||
"""
|
||||
t0 = time.perf_counter()
|
||||
logger.info(
|
||||
"[action] type_into start kind=%s text_len=%d clear_first=%s",
|
||||
kind, len(text), clear_first,
|
||||
)
|
||||
await self.click_element(kind, win_geom)
|
||||
if clear_first:
|
||||
await self.backend.key_press("BackSpace", repeat=30)
|
||||
await self.backend.type_text(text)
|
||||
logger.info(
|
||||
"[action] type_into done kind=%s text_len=%d (%.0fms)",
|
||||
kind, len(text), (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
|
||||
async def wait_for(
|
||||
self,
|
||||
@ -184,20 +218,41 @@ class Actions:
|
||||
Returns:
|
||||
ElementHandle 或 None(超时)
|
||||
"""
|
||||
t0 = time.perf_counter()
|
||||
deadline = time.monotonic() + timeout
|
||||
attempts = 0
|
||||
last_err: Optional[Exception] = None
|
||||
while time.monotonic() < deadline:
|
||||
attempts += 1
|
||||
try:
|
||||
elem = await self.find_element(kind, win_geom)
|
||||
if elem:
|
||||
logger.info(
|
||||
"[action] wait_for OK kind=%s strategy=%s attempts=%d (%.0fms)",
|
||||
kind, elem.strategy, attempts,
|
||||
(time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
return elem
|
||||
except ElementNotFoundError:
|
||||
pass
|
||||
except ElementNotFoundError as e:
|
||||
last_err = e
|
||||
await asyncio.sleep(interval)
|
||||
logger.warning(
|
||||
"[action] wait_for TIMEOUT kind=%s attempts=%d timeout=%.1fs last_err=%s",
|
||||
kind, attempts, timeout, last_err,
|
||||
)
|
||||
return None
|
||||
|
||||
async def screenshot(self) -> bytes:
|
||||
"""截图代理方法。"""
|
||||
return await self.backend.screenshot()
|
||||
"""截图代理方法,带 150ms 缓存。"""
|
||||
logger.debug("[action] screenshot start")
|
||||
data = self._get_cached_screenshot()
|
||||
if data is None:
|
||||
data = await self.backend.screenshot()
|
||||
self._set_cached_screenshot(data)
|
||||
logger.debug(
|
||||
"[action] screenshot done bytes=%d", len(data) if data else 0
|
||||
)
|
||||
return data
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 几何兜底
|
||||
@ -207,11 +262,13 @@ class Actions:
|
||||
) -> ElementHandle:
|
||||
"""按 GeomSpec 计算绝对坐标,返回 ElementHandle。
|
||||
|
||||
简化策略:所有 relative_to 模式统一用
|
||||
支持两种 relative_to 语义:
|
||||
- window: 以窗口左上角为原点
|
||||
base_x = win_geom.x + win_geom.width * g.x_ratio + g.x_offset
|
||||
base_y = win_geom.y + win_geom.height * g.y_ratio + g.y_offset
|
||||
relative_to 仅作语义占位,offset 已含正负号区分方向
|
||||
(如 send_button 用 x_offset=-60 表示距右边缘 60px)。
|
||||
- window_bottom_right: 以窗口右下角为原点(offset 含正负号)
|
||||
base_x = win_geom.x + win_geom.width + g.x_offset
|
||||
base_y = win_geom.y + win_geom.height + g.y_offset
|
||||
|
||||
Args:
|
||||
selector: 元素选择器(必须含 by_geom)
|
||||
@ -228,8 +285,20 @@ class Actions:
|
||||
f"no geom spec kind={selector.kind}"
|
||||
)
|
||||
g = selector.by_geom
|
||||
base_x = win_geom.x + win_geom.width * g.x_ratio + g.x_offset
|
||||
base_y = win_geom.y + win_geom.height * g.y_ratio + g.y_offset
|
||||
if g.relative_to == "window_bottom_right":
|
||||
base_x = win_geom.x + win_geom.width + g.x_offset
|
||||
base_y = win_geom.y + win_geom.height + g.y_offset
|
||||
else:
|
||||
base_x = win_geom.x + win_geom.width * g.x_ratio + g.x_offset
|
||||
base_y = win_geom.y + win_geom.height * g.y_ratio + g.y_offset
|
||||
logger.debug(
|
||||
"[action] geom_find kind=%s relative_to=%s win=(%d,%d,%dx%d) "
|
||||
"ratio=(%.3f,%.3f) offset=(%d,%d) → abs=(%d,%d)",
|
||||
selector.kind, g.relative_to,
|
||||
win_geom.x, win_geom.y, win_geom.width, win_geom.height,
|
||||
g.x_ratio, g.y_ratio, g.x_offset, g.y_offset,
|
||||
int(base_x), int(base_y),
|
||||
)
|
||||
return ElementHandle(
|
||||
selector=selector,
|
||||
x=int(base_x),
|
||||
|
||||
@ -130,6 +130,10 @@ class OpenCVBackend:
|
||||
被 find_template 用 asyncio.to_thread 调用,避免阻塞事件循环。
|
||||
"""
|
||||
# 1. 解码截图
|
||||
logger.info("[opencv] decode screenshot: size=%d", len(screenshot_png))
|
||||
if not screenshot_png:
|
||||
logger.warning("[opencv] screenshot_png is empty")
|
||||
return None
|
||||
arr = np.frombuffer(screenshot_png, dtype=np.uint8)
|
||||
img = cv2.imdecode(arr, cv2.IMREAD_GRAYSCALE)
|
||||
if img is None:
|
||||
@ -211,8 +215,13 @@ class OpenCVBackend:
|
||||
)
|
||||
|
||||
if best is not None:
|
||||
logger.debug(
|
||||
logger.warning(
|
||||
"[opencv] no match for %s (best conf=%.3f < threshold=%.2f)",
|
||||
template_name, best[4], threshold,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"[opencv] no match for %s (no valid scale returned result)",
|
||||
template_name,
|
||||
)
|
||||
return None
|
||||
|
||||
@ -111,7 +111,23 @@ class XdotoolBackend(BackendProtocol):
|
||||
|
||||
async def _run(self, args: list[str]) -> tuple[int, bytes, bytes]:
|
||||
"""执行一条命令并返回 (returncode, stdout, stderr)。"""
|
||||
t0 = time.perf_counter()
|
||||
async with self._run_managed(args) as result:
|
||||
rc, stdout, stderr = result
|
||||
# 隐藏 stdout/stderr 内容避免污染日志,仅记录长度和 rc
|
||||
logger.debug(
|
||||
"[ui-backend] cmd rc=%d %s (%.0fms) out=%dB err=%dB",
|
||||
rc, " ".join(args[:6]),
|
||||
(time.perf_counter() - t0) * 1000,
|
||||
len(stdout) if stdout else 0,
|
||||
len(stderr) if stderr else 0,
|
||||
)
|
||||
if rc != 0:
|
||||
err_str = stderr.decode(errors="ignore").strip()[:200] if stderr else ""
|
||||
logger.warning(
|
||||
"[ui-backend] cmd FAILED rc=%d cmd=%s err=%s",
|
||||
rc, " ".join(args), err_str,
|
||||
)
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@ -122,6 +138,8 @@ class XdotoolBackend(BackendProtocol):
|
||||
|
||||
关键:单条命令,避免分两次 fork 之间窗口焦点变化导致点击错位。
|
||||
"""
|
||||
logger.info("[ui-backend] click start (%d,%d)", x, y)
|
||||
t0 = time.perf_counter()
|
||||
async with self._ui_lock:
|
||||
await self._run(
|
||||
[
|
||||
@ -134,95 +152,197 @@ class XdotoolBackend(BackendProtocol):
|
||||
"1",
|
||||
]
|
||||
)
|
||||
logger.info(
|
||||
"[ui-backend] click done (%d,%d) (%.0fms)",
|
||||
x, y, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
|
||||
async def type_text(self, text: str) -> None:
|
||||
"""xdotool type -- <text>:原样输入文本,不解析修饰键组合。"""
|
||||
text_len = len(text)
|
||||
logger.info(
|
||||
"[ui-backend] type_text start len=%d preview=%r",
|
||||
text_len, text[:30],
|
||||
)
|
||||
t0 = time.perf_counter()
|
||||
async with self._ui_lock:
|
||||
await self._run(["xdotool", "type", "--", text])
|
||||
logger.info(
|
||||
"[ui-backend] type_text done len=%d (%.0fms)",
|
||||
text_len, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
|
||||
async def key_press(self, key: str, repeat: int = 1) -> None:
|
||||
"""xdotool key [--repeat N] <key>。"""
|
||||
logger.info("[ui-backend] key_press start key=%s repeat=%d", key, repeat)
|
||||
t0 = time.perf_counter()
|
||||
async with self._ui_lock:
|
||||
args = ["xdotool", "key"]
|
||||
if repeat > 1:
|
||||
args.extend(["--repeat", str(repeat)])
|
||||
args.append(key)
|
||||
await self._run(args)
|
||||
logger.info(
|
||||
"[ui-backend] key_press done key=%s repeat=%d (%.0fms)",
|
||||
key, repeat, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
|
||||
async def screenshot(self) -> bytes:
|
||||
"""scrot -o - 输出 PNG 到 stdout,不写文件。
|
||||
"""scrot 截图输出到临时文件,再读取为 bytes。
|
||||
|
||||
超时 5s,TimeoutError/CancelledError 时 kill+wait。
|
||||
注意:s6 服务内 /dev/stdout 不是有效设备,scrot -o - 会失败,
|
||||
故改用临时文件路径。超时 5s,TimeoutError/CancelledError 时 kill+wait。
|
||||
"""
|
||||
import tempfile
|
||||
|
||||
async with self._ui_lock:
|
||||
# scrot -o - 输出到 stdout
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"scrot",
|
||||
"-o",
|
||||
"-",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=self._env(),
|
||||
)
|
||||
fd, path = tempfile.mkstemp(suffix=".png", prefix="woc_screenshot_")
|
||||
os.close(fd)
|
||||
try:
|
||||
stdout, _ = await asyncio.wait_for(
|
||||
proc.communicate(), timeout=_CMD_TIMEOUT_SEC
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"scrot",
|
||||
"-o",
|
||||
path,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=self._env(),
|
||||
)
|
||||
except (asyncio.TimeoutError, asyncio.CancelledError):
|
||||
proc.kill()
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error("[ui-backend] scrot proc.wait() timeout after kill")
|
||||
raise
|
||||
except Exception:
|
||||
if proc.returncode is None:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(), timeout=_CMD_TIMEOUT_SEC
|
||||
)
|
||||
except (asyncio.TimeoutError, asyncio.CancelledError):
|
||||
proc.kill()
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(
|
||||
"[ui-backend] scrot proc.wait() timeout after kill"
|
||||
)
|
||||
raise
|
||||
return stdout
|
||||
logger.error("[ui-backend] scrot proc.wait() timeout after kill")
|
||||
raise
|
||||
except Exception:
|
||||
if proc.returncode is None:
|
||||
proc.kill()
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(
|
||||
"[ui-backend] scrot proc.wait() timeout after kill"
|
||||
)
|
||||
raise
|
||||
|
||||
async def get_window_geometry(self, window_title: str) -> WindowGeometry:
|
||||
"""按标题查窗口并返回几何。容忍多个匹配窗口,取第一个。
|
||||
if proc.returncode != 0:
|
||||
err = stderr.decode(errors="ignore").strip()[:200]
|
||||
logger.error("[ui-backend] screenshot failed: rc=%s err=%s", proc.returncode, err)
|
||||
return b""
|
||||
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
logger.info(
|
||||
"[ui-backend] screenshot: scrot rc=0 file=%s bytes=%d",
|
||||
path, len(data),
|
||||
)
|
||||
return data
|
||||
finally:
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
async def _find_main_window_id(
|
||||
self, window_title: str, min_area: int = 10000
|
||||
) -> int:
|
||||
"""查找窗口标题匹配者中面积最大的主窗口,过滤隐藏/加载小窗。
|
||||
|
||||
微信启动过程中会出现多个窗口(隐藏加载窗、托盘提示窗等),
|
||||
仅按标题搜索并取第一个常常会拿到 1×1 的无效窗口。本方法:
|
||||
1. 搜索所有标题匹配窗口;
|
||||
2. 逐个获取几何并计算面积;
|
||||
3. 过滤掉面积 < min_area 的候选;
|
||||
4. 返回面积最大者。
|
||||
|
||||
Args:
|
||||
window_title: 窗口标题,如 "微信"
|
||||
min_area: 最小有效窗口面积(默认 10000 像素)
|
||||
|
||||
Returns:
|
||||
主窗口 ID
|
||||
|
||||
Raises:
|
||||
RuntimeError: 窗口未找到(TODO: P3 时换 FlowError(code=WINDOW_NOT_FOUND))
|
||||
RuntimeError: 未找到有效窗口
|
||||
"""
|
||||
# 1. 查窗口 id(容忍多匹配,取第一个)
|
||||
rc, stdout, _ = await self._run(
|
||||
["xdotool", "search", "--name", window_title]
|
||||
)
|
||||
if rc != 0:
|
||||
# TODO: P3 时换 FlowError(code="WINDOW_NOT_FOUND", retryable=False)
|
||||
raise RuntimeError(
|
||||
f"WINDOW_NOT_FOUND: search failed for title={window_title!r}"
|
||||
)
|
||||
text = stdout.decode(errors="ignore").strip()
|
||||
if not text:
|
||||
# TODO: P3 时换 FlowError(code="WINDOW_NOT_FOUND", retryable=False)
|
||||
raise RuntimeError(
|
||||
f"WINDOW_NOT_FOUND: no window matches title={window_title!r}"
|
||||
)
|
||||
first_line = text.splitlines()[0].strip()
|
||||
try:
|
||||
window_id = int(first_line)
|
||||
except ValueError:
|
||||
# TODO: P3 时换 FlowError(code="WINDOW_NOT_FOUND", retryable=False)
|
||||
raise RuntimeError(
|
||||
f"WINDOW_NOT_FOUND: invalid window id={first_line!r} for title={window_title!r}"
|
||||
)
|
||||
|
||||
# 2. 获取几何
|
||||
candidates: list[tuple[int, int]] = []
|
||||
for line in text.splitlines():
|
||||
wid_str = line.strip()
|
||||
if not wid_str:
|
||||
continue
|
||||
try:
|
||||
wid = int(wid_str)
|
||||
except ValueError:
|
||||
continue
|
||||
rc, gstdout, _ = await self._run(
|
||||
["xdotool", "getwindowgeometry", "--shell", str(wid)]
|
||||
)
|
||||
if rc != 0:
|
||||
continue
|
||||
geom: dict[str, str] = {}
|
||||
for gline in gstdout.decode(errors="ignore").splitlines():
|
||||
if "=" in gline:
|
||||
k, v = gline.split("=", 1)
|
||||
geom[k.strip()] = v.strip()
|
||||
try:
|
||||
w = int(geom.get("WIDTH", 0))
|
||||
h = int(geom.get("HEIGHT", 0))
|
||||
area = w * h
|
||||
if area >= min_area and w >= 200 and h >= 200:
|
||||
candidates.append((area, wid))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if not candidates:
|
||||
raise RuntimeError(
|
||||
f"WINDOW_NOT_FOUND: no valid window for title={window_title!r} "
|
||||
f"(candidates filtered by min_area={min_area})"
|
||||
)
|
||||
candidates.sort(key=lambda x: x[0], reverse=True)
|
||||
return candidates[0][1]
|
||||
|
||||
async def get_window_geometry(self, window_title: str) -> WindowGeometry:
|
||||
"""按标题查窗口并返回几何。容忍多个匹配窗口,取面积最大主窗口。
|
||||
|
||||
Raises:
|
||||
RuntimeError: 窗口未找到(TODO: P3 时换 FlowError(code=WINDOW_NOT_FOUND))
|
||||
"""
|
||||
t0 = time.perf_counter()
|
||||
logger.info("[ui-backend] get_window_geometry start title=%r", window_title)
|
||||
try:
|
||||
window_id = await self._find_main_window_id(window_title)
|
||||
except RuntimeError as exc:
|
||||
logger.warning(
|
||||
"[ui-backend] get_window_geometry: %s (%.0fms)",
|
||||
exc, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
raise
|
||||
|
||||
rc, stdout, _ = await self._run(
|
||||
["xdotool", "getwindowgeometry", "--shell", str(window_id)]
|
||||
)
|
||||
if rc != 0:
|
||||
# TODO: P3 时换 FlowError(code="WINDOW_NOT_FOUND", retryable=False)
|
||||
logger.warning(
|
||||
"[ui-backend] get_window_geometry: getwindowgeometry failed id=%d",
|
||||
window_id,
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"WINDOW_NOT_FOUND: getwindowgeometry failed for id={window_id}"
|
||||
)
|
||||
@ -232,14 +352,23 @@ class XdotoolBackend(BackendProtocol):
|
||||
k, v = line.split("=", 1)
|
||||
geom[k.strip()] = v.strip()
|
||||
try:
|
||||
return WindowGeometry(
|
||||
result = WindowGeometry(
|
||||
x=int(geom.get("X", 0)),
|
||||
y=int(geom.get("Y", 0)),
|
||||
width=int(geom.get("WIDTH", 0)),
|
||||
height=int(geom.get("HEIGHT", 0)),
|
||||
)
|
||||
logger.info(
|
||||
"[ui-backend] get_window_geometry OK id=%d geom=%dx%d@(%d,%d) (%.0fms)",
|
||||
window_id, result.width, result.height, result.x, result.y,
|
||||
(time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
return result
|
||||
except ValueError:
|
||||
# TODO: P3 时换 FlowError(code="WINDOW_NOT_FOUND", retryable=False)
|
||||
logger.warning(
|
||||
"[ui-backend] get_window_geometry: invalid geom dict=%s id=%d",
|
||||
geom, window_id,
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"WINDOW_NOT_FOUND: invalid geometry for id={window_id}"
|
||||
)
|
||||
@ -250,29 +379,55 @@ class XdotoolBackend(BackendProtocol):
|
||||
Raises:
|
||||
RuntimeError: 窗口未找到(TODO: P3 时换 FlowError(code=WINDOW_NOT_FOUND))
|
||||
"""
|
||||
rc, stdout, _ = await self._run(
|
||||
["xdotool", "search", "--name", window_title]
|
||||
t0 = time.perf_counter()
|
||||
logger.info("[ui-backend] activate_window start title=%r", window_title)
|
||||
try:
|
||||
window_id = await self._find_main_window_id(window_title)
|
||||
except RuntimeError as exc:
|
||||
logger.warning(
|
||||
"[ui-backend] activate_window: %s (%.0fms)",
|
||||
exc, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
raise
|
||||
# 使用非阻塞 windowactivate,避免 --sync 在 VNC 无人操作时死等。
|
||||
# 之后轮询 getactivewindow 确认焦点是否真正落到目标窗口。
|
||||
rc, _, stderr = await self._run(
|
||||
["xdotool", "windowactivate", str(window_id)]
|
||||
)
|
||||
if rc != 0:
|
||||
# TODO: P3 时换 FlowError(code="WINDOW_NOT_FOUND", retryable=False)
|
||||
raise RuntimeError(
|
||||
f"WINDOW_NOT_FOUND: search failed for title={window_title!r}"
|
||||
err_str = stderr.decode(errors="ignore").strip()[:200] if stderr else ""
|
||||
logger.warning(
|
||||
"[ui-backend] activate_window: windowactivate failed id=%d rc=%d err=%s",
|
||||
window_id, rc, err_str,
|
||||
)
|
||||
text = stdout.decode(errors="ignore").strip()
|
||||
if not text:
|
||||
# TODO: P3 时换 FlowError(code="WINDOW_NOT_FOUND", retryable=False)
|
||||
raise RuntimeError(
|
||||
f"WINDOW_NOT_FOUND: no window matches title={window_title!r}"
|
||||
f"WINDOW_ACTIVATE_FAILED: windowactivate failed for id={window_id} rc={rc} err={err_str}"
|
||||
)
|
||||
first_line = text.splitlines()[0].strip()
|
||||
try:
|
||||
window_id = int(first_line)
|
||||
except ValueError:
|
||||
# TODO: P3 时换 FlowError(code="WINDOW_NOT_FOUND", retryable=False)
|
||||
raise RuntimeError(
|
||||
f"WINDOW_NOT_FOUND: invalid window id={first_line!r} for title={window_title!r}"
|
||||
|
||||
deadline = time.monotonic() + 2.0
|
||||
activated = False
|
||||
while time.monotonic() < deadline:
|
||||
rc, stdout, _ = await self._run(["xdotool", "getactivewindow"])
|
||||
if rc == 0:
|
||||
active_text = stdout.decode(errors="ignore").strip()
|
||||
try:
|
||||
if int(active_text) == window_id:
|
||||
activated = True
|
||||
break
|
||||
except ValueError:
|
||||
pass
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
if activated:
|
||||
logger.info(
|
||||
"[ui-backend] activate_window OK id=%d title=%r (%.0fms)",
|
||||
window_id, window_title, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"[ui-backend] activate_window: focus verify timeout id=%d title=%r (%.0fms), continue",
|
||||
window_id, window_title, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
await self._run(["xdotool", "windowactivate", "--sync", str(window_id)])
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 调试截图异步队列(生产环境关闭,调试模式后台写盘)
|
||||
|
||||
@ -84,13 +84,18 @@ async def _verify_accept_with_retry(
|
||||
)
|
||||
return False
|
||||
|
||||
# 好友申请通过 UI 几何常量(基于 1920x1080,坐标为估算值需实测校准)
|
||||
# 好友申请通过 UI 几何常量(基于窗口比例,坐标为估算值需实测校准)
|
||||
# 注:Task 6 优先使用 profile 中的几何兜底,以下常量仅在 profile 未注册时作为 fallback。
|
||||
_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 # "确定"按钮距底部偏移(向上为负)
|
||||
_CONTACT_ICON_Y_RATIO = 0.171 # 通讯录图标 Y 比例(1435x876 实测)
|
||||
_NEW_FRIEND_ENTRY_X_RATIO = 0.125 # "新的朋友"入口 X 比例(1435x876 实测)
|
||||
_NEW_FRIEND_ENTRY_Y_RATIO = 0.138 # "新的朋友"入口 Y 比例(1435x876 实测)
|
||||
_FRIEND_REQUEST_ITEM_X_RATIO = 0.125 # 申请列表第一条 X 比例(1435x876 实测)
|
||||
_FRIEND_REQUEST_ITEM_Y_RATIO = 0.178 # 申请列表第一条 Y 比例("新的朋友"展开后实测)
|
||||
_VERIFY_BUTTON_X_RATIO = 0.593 # 详情页"前往验证"按钮 X 比例(OpenCV 实测 851/1435)
|
||||
_VERIFY_BUTTON_Y_RATIO = 0.394 # 详情页"前往验证"按钮 Y 比例(OpenCV 实测 345/876)
|
||||
_CONFIRM_BUTTON_X_RATIO = 0.464 # 弹窗"确定"按钮 X 比例(OpenCV 实测 666/1435)
|
||||
_CONFIRM_BUTTON_Y_RATIO = 0.830 # 弹窗"确定"按钮 Y 比例(OpenCV 实测 727/876)
|
||||
|
||||
# 微信 4.x 自绘 UI 布局常量(_open_session_by_name 复用)
|
||||
_LEFT_PANEL_WIDTH = 300
|
||||
@ -277,10 +282,14 @@ class ContactDriver(BaseDriver):
|
||||
from woc_bridge.ui.backends.base import WindowGeometry
|
||||
win_x, win_y, win_w, win_h = win_geom
|
||||
try:
|
||||
await self._actions.click_element(
|
||||
elem = await self._actions.click_element(
|
||||
kind,
|
||||
WindowGeometry(x=win_x, y=win_y, width=win_w, height=win_h),
|
||||
)
|
||||
logger.info(
|
||||
"[ui] click_element(%s) 成功: (%d,%d) strategy=%s conf=%.3f",
|
||||
kind, elem.x, elem.y, elem.strategy, elem.confidence,
|
||||
)
|
||||
except Exception as exc:
|
||||
# 元素未注册或 click_element 异常时回退硬编码坐标
|
||||
logger.warning(
|
||||
@ -589,6 +598,16 @@ class ContactDriver(BaseDriver):
|
||||
- 当前版本仅点击列表第一条申请项,昵称匹配待后续实现
|
||||
- 所有坐标均为估算值,需在目标分辨率实测调优
|
||||
"""
|
||||
logger.info(
|
||||
"accept_friend_request: start wxid=%s nickname=%s timeout=%.1fs",
|
||||
stranger_wxid, nickname, timeout_sec,
|
||||
)
|
||||
if not stranger_wxid and not nickname:
|
||||
logger.warning("accept_friend_request: stranger_wxid 和 nickname 均为空,拒绝执行")
|
||||
raise BridgeError(
|
||||
code="INVALID_PARAMS",
|
||||
message="stranger_wxid 和 nickname 不能同时为空",
|
||||
)
|
||||
deadline = time.monotonic() + timeout_sec
|
||||
|
||||
# 1. 激活窗口
|
||||
@ -601,12 +620,40 @@ class ContactDriver(BaseDriver):
|
||||
await self._step(self._activate_window_fast(window_id), "激活窗口", deadline)
|
||||
await self._step(self._sleep(0.3), "等待窗口激活", deadline)
|
||||
|
||||
# 关闭可能存在的弹窗
|
||||
await self._step(self._key("Escape"), "关闭弹窗", deadline)
|
||||
# 1.1 强制重置 UI 状态:多次 Esc 关闭弹窗,点击主视图回到主界面,
|
||||
# 避免上次失败后停留在"朋友验证"详情页导致后续点击失效
|
||||
await self._step(self._key("Escape"), "关闭弹窗#1", deadline)
|
||||
await self._step(self._sleep(0.3), "等待 Esc 生效", deadline)
|
||||
await self._step(self._key("Escape"), "关闭弹窗#2", deadline)
|
||||
await self._step(self._sleep(0.3), "等待 Esc 生效", deadline)
|
||||
await self._step(self._key("Escape"), "关闭弹窗#3", deadline)
|
||||
await self._step(self._sleep(0.3), "等待 Esc 生效", deadline)
|
||||
|
||||
# 点击主视图区域,确保焦点在主聊天界面
|
||||
win_x, win_y, win_w, win_h = await self._get_window_geometry()
|
||||
main_view_x = win_x + int(win_w * 0.65)
|
||||
main_view_y = win_y + int(win_h * 0.5)
|
||||
await self._step(
|
||||
self._click_element_or_fallback(
|
||||
"main_view", (win_x, win_y, win_w, win_h), main_view_x, main_view_y
|
||||
),
|
||||
"点击主视图回到主界面", deadline,
|
||||
)
|
||||
await self._step(self._sleep(0.3), "等待主界面稳定", deadline)
|
||||
await self._step(self._key("Escape"), "退出可能存在的子页面", deadline)
|
||||
await self._step(self._sleep(0.2), "等待 Esc 生效", deadline)
|
||||
|
||||
# 2. 获取窗口几何
|
||||
win_x, win_y, win_w, win_h = await self._get_window_geometry()
|
||||
if win_w < 200 or win_h < 200:
|
||||
raise BridgeError(
|
||||
code="WINDOW_NOT_FOUND",
|
||||
message=f"微信窗口几何异常: {win_w}x{win_h},无法执行 UI 操作",
|
||||
)
|
||||
logger.info(
|
||||
"accept_friend_request: 窗口几何 win=(%d,%d %dx%d)",
|
||||
win_x, win_y, win_w, win_h,
|
||||
)
|
||||
win_geom = (win_x, win_y, win_w, win_h)
|
||||
|
||||
# 3. 点击通讯录图标(左侧导航栏第 2 个)
|
||||
@ -617,29 +664,52 @@ class ContactDriver(BaseDriver):
|
||||
self._click_element_or_fallback("contact_icon", win_geom, contact_x, contact_y),
|
||||
"点击通讯录图标", deadline,
|
||||
)
|
||||
await self._step(self._sleep(0.8), "等待通讯录页面加载", deadline)
|
||||
# 实测:通讯录页面加载后左侧列表需要约 2~3s 才能响应点击,
|
||||
# 这里等待 2.5s 避免后续点击失效。
|
||||
await self._step(self._sleep(2.5), "等待通讯录页面加载", deadline)
|
||||
|
||||
# 4. 点击"新的朋友"入口
|
||||
new_friend_x = win_x + int(win_w * _NEW_FRIEND_ENTRY_X_RATIO)
|
||||
new_friend_y = win_y + int(win_h * _NEW_FRIEND_ENTRY_Y_RATIO)
|
||||
await self._step(
|
||||
self._click_element_or_fallback("new_friend_entry", win_geom, contact_x, new_friend_y),
|
||||
self._click_element_or_fallback("new_friend_entry", win_geom, new_friend_x, new_friend_y),
|
||||
"点击新的朋友", deadline,
|
||||
)
|
||||
await self._step(self._sleep(0.8), "等待新的朋友列表加载", deadline)
|
||||
await self._step(self._sleep(1.2), "等待新的朋友列表加载", deadline)
|
||||
|
||||
# 5. 定位并点击目标申请项的"前往验证"按钮
|
||||
# 当前版本取第一条申请项(昵称匹配待后续实现)
|
||||
verify_x = win_x + win_w + _VERIFY_BUTTON_X_OFFSET # 负偏移 = 向左
|
||||
# 5. 点击第一条申请项,展开右侧详情页
|
||||
# 微信 4.x Linux 中点击"新的朋友"后只显示左侧列表,
|
||||
# 右侧为空白,必须点击具体申请项才会出现"前往验证"按钮。
|
||||
# 当前版本取第一条(昵称匹配待后续实现)。
|
||||
item_x = win_x + int(win_w * _FRIEND_REQUEST_ITEM_X_RATIO)
|
||||
item_y = win_y + int(win_h * _FRIEND_REQUEST_ITEM_Y_RATIO)
|
||||
await self._step(
|
||||
self._click_element_or_fallback("friend_request_item", win_geom, item_x, item_y),
|
||||
"点击第一条好友申请项", deadline,
|
||||
)
|
||||
await self._step(self._sleep(1.0), "等待详情页加载", deadline)
|
||||
|
||||
# 6. 点击详情页"前往验证"按钮
|
||||
verify_x = win_x + int(win_w * _VERIFY_BUTTON_X_RATIO)
|
||||
verify_y = win_y + int(win_h * _VERIFY_BUTTON_Y_RATIO)
|
||||
logger.info(
|
||||
"accept_friend_request: 准备点击'前往验证' (win=%dx%d, x=%d, y=%d, actions=%s)",
|
||||
win_w, win_h, verify_x, verify_y,
|
||||
"yes" if self._actions is not None else "no",
|
||||
)
|
||||
await self._step(
|
||||
self._click_element_or_fallback("verify_button", win_geom, verify_x, verify_y),
|
||||
"点击前往验证", deadline,
|
||||
)
|
||||
await self._step(self._sleep(1.0), "等待验证弹窗打开", deadline)
|
||||
|
||||
# 6. 点击弹窗"确定"按钮
|
||||
confirm_x = win_x + win_w // 2
|
||||
confirm_y = win_y + win_h + _CONFIRM_BUTTON_Y_OFFSET # 负偏移 = 向上
|
||||
# 7. 点击弹窗"确定"按钮
|
||||
confirm_x = win_x + int(win_w * _CONFIRM_BUTTON_X_RATIO)
|
||||
confirm_y = win_y + int(win_h * _CONFIRM_BUTTON_Y_RATIO)
|
||||
logger.info(
|
||||
"accept_friend_request: 准备点击'确定' (x=%d, y=%d)",
|
||||
confirm_x, confirm_y,
|
||||
)
|
||||
await self._step(
|
||||
self._click_element_or_fallback("confirm_button", win_geom, confirm_x, confirm_y),
|
||||
"点击确定", deadline,
|
||||
|
||||
@ -21,6 +21,7 @@ import time
|
||||
from typing import Any, Awaitable, Optional, TYPE_CHECKING
|
||||
|
||||
from woc_bridge.models import BridgeError
|
||||
from woc_bridge.ui.backends.base import WindowGeometry
|
||||
from woc_bridge.ui.drivers.base import BaseDriver
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -28,6 +29,9 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = logging.getLogger("woc-bridge")
|
||||
|
||||
# 微信窗口标题常量(与 flow 路径保持一致)
|
||||
_WECHAT_WINDOW_TITLE = "微信"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 分层超时与布局常量(与 xdotool_driver.py 保持一致)
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -51,11 +55,15 @@ class MessageDriver(BaseDriver):
|
||||
display: str,
|
||||
backend=None,
|
||||
window_driver: Optional["WindowDriver"] = None,
|
||||
actions: Optional[Any] = None,
|
||||
) -> None:
|
||||
super().__init__(display, backend)
|
||||
# 注入兄弟 driver:_open_session_by_name / revoke / forward 依赖
|
||||
# WindowDriver.find_wechat_window
|
||||
self._window_driver = window_driver
|
||||
# 注入 L3 Actions(可选):非 None 时 _open_session_by_name / 输入框 /
|
||||
# 发送按钮走 LocatorRegistry + profile 比例坐标;None 时回退原硬编码坐标。
|
||||
self._actions = actions
|
||||
# 调试截图:环境变量控制开关(与原 XdotoolDriver.__init__ 一致)
|
||||
self._debug_shots_enabled = os.environ.get(
|
||||
"WOC_UI_DEBUG_SHOTS", "false"
|
||||
@ -202,10 +210,28 @@ class MessageDriver(BaseDriver):
|
||||
self._debug_queue.task_done()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 输入框 / 发送按钮定位
|
||||
# 输入框 / 发送按钮定位(优先走 Actions + profile 比例坐标)
|
||||
# ------------------------------------------------------------------
|
||||
async def _get_window_geometry_backend(self) -> WindowGeometry:
|
||||
"""获取窗口几何,返回 backend 统一的 WindowGeometry。
|
||||
|
||||
优先委托 XdotoolBackend;无 backend 时自建 WindowGeometry。
|
||||
"""
|
||||
if self._backend is not None and hasattr(
|
||||
self._backend, "get_window_geometry"
|
||||
):
|
||||
return await self._backend.get_window_geometry(_WECHAT_WINDOW_TITLE)
|
||||
win_x, win_y, win_w, win_h = await self._get_window_geometry()
|
||||
return WindowGeometry(x=win_x, y=win_y, width=win_w, height=win_h)
|
||||
|
||||
async def _click_input_box(self) -> None:
|
||||
"""鼠标点击聊天输入框,强制焦点落到输入框。"""
|
||||
if self._actions is not None:
|
||||
geom = await self._get_window_geometry_backend()
|
||||
await self._actions.click_element("input_box", geom)
|
||||
await asyncio.sleep(0.2)
|
||||
return
|
||||
# legacy fallback:固定像素坐标
|
||||
win_x, win_y, win_w, win_h = await self._get_window_geometry()
|
||||
input_x = win_x + _LEFT_PANEL_WIDTH + (win_w - _LEFT_PANEL_WIDTH) // 2
|
||||
input_y = win_y + win_h - _INPUT_BOX_Y_OFFSET
|
||||
@ -214,6 +240,11 @@ class MessageDriver(BaseDriver):
|
||||
|
||||
async def _click_send_button(self) -> None:
|
||||
"""鼠标点击右下角发送按钮,替代可能失效的 Return 键。"""
|
||||
if self._actions is not None:
|
||||
geom = await self._get_window_geometry_backend()
|
||||
await self._actions.click_element("send_button", geom)
|
||||
return
|
||||
# legacy fallback:固定像素坐标
|
||||
win_x, win_y, win_w, win_h = await self._get_window_geometry()
|
||||
send_x = win_x + win_w - _SEND_BUTTON_X_OFFSET
|
||||
send_y = win_y + win_h - _SEND_BUTTON_Y_OFFSET
|
||||
@ -229,15 +260,18 @@ class MessageDriver(BaseDriver):
|
||||
) -> None:
|
||||
"""通过微信左侧搜索框定位并进入指定联系人的会话。
|
||||
|
||||
WeChat 4.x 自绘 UI 对 Ctrl+F 等 X11 修饰键组合不响应,全部改用
|
||||
鼠标点击:
|
||||
1. 激活窗口并校验焦点
|
||||
2. 按 Esc 关闭可能存在的搜索框/弹窗
|
||||
优先走 Actions + LocatorRegistry + YAML profile 比例坐标;
|
||||
未注入 Actions 时回退原硬编码坐标逻辑。
|
||||
|
||||
新流程:
|
||||
1. 激活窗口并获取几何
|
||||
2. 点击左侧"会话管理"图标重置状态(回到会话列表首页)
|
||||
3. 点击左侧顶部搜索框
|
||||
4. 输入 display_name
|
||||
5. 等待搜索结果渲染
|
||||
6. 点击第一个搜索结果进入会话
|
||||
7. 按 Esc 退出搜索覆盖层
|
||||
4. Ctrl+A + BackSpace 清空旧内容
|
||||
5. 输入 display_name
|
||||
6. 等待并点击第一个搜索结果
|
||||
7. 验证 input_box 出现(确保真正进入会话)
|
||||
8. 按 Esc 退出搜索覆盖层
|
||||
|
||||
Args:
|
||||
name: 用于搜索的显示名(备注/昵称/微信号)
|
||||
@ -253,6 +287,77 @@ class MessageDriver(BaseDriver):
|
||||
message="display_name 不能为空,无法定位会话",
|
||||
)
|
||||
|
||||
if self._actions is None:
|
||||
return await self._open_session_by_name_legacy(name, timeout_sec)
|
||||
|
||||
deadline = time.monotonic() + timeout_sec
|
||||
backend = self._actions.backend
|
||||
|
||||
# 1. 激活窗口并获取几何
|
||||
geom = await self._get_window_geometry_backend()
|
||||
await self._step(backend.activate_window(_WECHAT_WINDOW_TITLE), "激活窗口", deadline)
|
||||
|
||||
# 2. 重置状态:点击左侧"会话管理"图标,强制回到会话列表首页
|
||||
await self._step(
|
||||
self._actions.click_element("session_manager_icon", geom),
|
||||
"点击会话管理图标重置状态",
|
||||
deadline,
|
||||
)
|
||||
await self._step(backend.key_press("Escape"), "关闭可能打开的菜单", deadline)
|
||||
await self._step(asyncio.sleep(0.3), "等待重置生效", deadline)
|
||||
|
||||
# 3. 点击搜索框
|
||||
await self._step(self._actions.click_element("search_box", geom), "点击搜索框", deadline)
|
||||
await self._step(asyncio.sleep(0.2), "等待搜索框聚焦", deadline)
|
||||
|
||||
# 4. 清空旧内容:Ctrl+A 全选 + BackSpace 删除
|
||||
await self._step(backend.key_press("ctrl+a"), "全选搜索框旧内容", deadline)
|
||||
await self._step(backend.key_press("BackSpace"), "删除搜索框旧内容", deadline)
|
||||
await self._step(asyncio.sleep(0.2), "等待清空", deadline)
|
||||
|
||||
# 5. 输入搜索关键词
|
||||
await self._step(backend.type_text(name), "输入搜索关键词", deadline)
|
||||
await self._step(asyncio.sleep(0.5), "等待搜索结果渲染", deadline)
|
||||
|
||||
# 6. 点击第一个搜索结果进入会话
|
||||
await self._step(
|
||||
self._actions.click_element("search_result_first", geom),
|
||||
"点击第一个搜索结果",
|
||||
deadline,
|
||||
)
|
||||
await self._step(asyncio.sleep(0.5), "等待会话打开", deadline)
|
||||
await self._debug_screenshot("after_open_session")
|
||||
|
||||
# 7. 验证 input_box 出现(确保真正进入目标会话)
|
||||
input_appeared = False
|
||||
verify_deadline = time.monotonic() + 2.0
|
||||
while time.monotonic() < verify_deadline:
|
||||
try:
|
||||
elem = await self._actions.wait_for(
|
||||
"input_box", geom, timeout=0.5, interval=0.1
|
||||
)
|
||||
if elem is not None:
|
||||
input_appeared = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(0.2)
|
||||
if not input_appeared:
|
||||
raise BridgeError(
|
||||
code="SEND_FAILED",
|
||||
message="点击搜索结果后未进入目标会话(未找到输入框)",
|
||||
)
|
||||
|
||||
# 8. 退出搜索模式,确保焦点在会话区域
|
||||
await self._step(backend.key_press("Escape"), "退出搜索模式", deadline)
|
||||
await self._step(asyncio.sleep(0.2), "等待焦点稳定", deadline)
|
||||
|
||||
async def _open_session_by_name_legacy(
|
||||
self,
|
||||
name: str,
|
||||
timeout_sec: float = 20.0,
|
||||
) -> None:
|
||||
"""原硬编码坐标实现(保留兼容)。"""
|
||||
deadline = time.monotonic() + timeout_sec
|
||||
|
||||
# 1. 查找并激活窗口(委托 WindowDriver 查窗口)
|
||||
@ -393,7 +498,7 @@ class MessageDriver(BaseDriver):
|
||||
await self._paste_via_xclip(content)
|
||||
logger.info("[send_text] 文本已输入")
|
||||
await self._debug_screenshot("after_type")
|
||||
await self._sleep(1.0)
|
||||
await self._sleep(0.3)
|
||||
# 优先用鼠标点击右下角发送按钮,规避 Return 键可能不触发发送的问题
|
||||
await self._click_send_button()
|
||||
try:
|
||||
@ -406,11 +511,11 @@ class MessageDriver(BaseDriver):
|
||||
code="SEND_FAILED",
|
||||
message="文本输入/发送步骤超时",
|
||||
) from exc
|
||||
await self._sleep(1.0)
|
||||
await self._sleep(0.3)
|
||||
await self._debug_screenshot("after_send")
|
||||
# 3. 按 Esc 退出当前聊天界面,回到会话列表,避免影响下一次发送
|
||||
await self._key("Escape")
|
||||
await self._sleep(1.0)
|
||||
await self._sleep(0.3)
|
||||
await self._debug_screenshot("after_escape")
|
||||
# 4. 生成 local_send_id(本地 ID,非微信原生 msg_id)
|
||||
local_send_id = f"local_{int(time.time())}_{random.randint(0, 0xFFFFFF):06x}"
|
||||
|
||||
@ -15,11 +15,13 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from woc_bridge.models import BridgeError
|
||||
from woc_bridge.ui.drivers.base import BaseDriver, WindowGeom
|
||||
|
||||
|
||||
logger = logging.getLogger("woc-bridge")
|
||||
|
||||
|
||||
@ -28,6 +30,8 @@ class WindowDriver(BaseDriver):
|
||||
|
||||
def __init__(self, display: str, backend=None) -> None:
|
||||
super().__init__(display, backend)
|
||||
# 缓存最近一次找到的主窗口信息,用于抑制连续重复的 info 日志
|
||||
self._last_window_key: Optional[tuple[int, int, int, int, int, int]] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 命令执行包装(BaseDriver._run_managed 返回 tuple 且抛 TimeoutError,
|
||||
@ -67,30 +71,140 @@ class WindowDriver(BaseDriver):
|
||||
# ------------------------------------------------------------------
|
||||
# 窗口与进程检测
|
||||
# ------------------------------------------------------------------
|
||||
async def _find_wechat_pids(self) -> list[int]:
|
||||
"""查找微信主进程 PID 列表。
|
||||
|
||||
微信可执行文件常见路径:
|
||||
- /config/wechat/opt/wechat/wechat(安装后的实际路径)
|
||||
- /opt/wechat/wechat
|
||||
通过 pgrep 按命令行匹配,避免依赖固定路径。
|
||||
|
||||
Returns:
|
||||
PID 列表(可能为空)
|
||||
"""
|
||||
pids: list[int] = []
|
||||
for pattern in ["wechat", "/config/wechat/opt/wechat"]:
|
||||
rc, stdout, _ = await self._run(["pgrep", "-f", pattern])
|
||||
if rc != 0:
|
||||
continue
|
||||
for line in stdout.decode(errors="ignore").splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
pids.append(int(line))
|
||||
except ValueError:
|
||||
continue
|
||||
# 去重保持顺序
|
||||
seen = set()
|
||||
unique: list[int] = []
|
||||
for pid in pids:
|
||||
if pid not in seen:
|
||||
seen.add(pid)
|
||||
unique.append(pid)
|
||||
return unique
|
||||
|
||||
async def find_wechat_window(self) -> Optional[int]:
|
||||
"""用 xdotool search --name "微信" 查找微信窗口 ID。
|
||||
"""查找微信主窗口 ID。
|
||||
|
||||
策略:
|
||||
1. 优先通过 wechat 进程 PID 搜索窗口(最可靠,微信主窗口 name/class 可能为空);
|
||||
2. 再用 name/class 搜索兜底;
|
||||
3. 过滤掉面积极小的隐藏/装饰/加载窗口;
|
||||
4. 多个候选时取面积最大者。
|
||||
|
||||
Returns:
|
||||
窗口 ID(int),找不到返回 None
|
||||
"""
|
||||
returncode, stdout, stderr = await self._run(
|
||||
["xdotool", "search", "--name", "微信"]
|
||||
)
|
||||
if returncode != 0:
|
||||
return None
|
||||
text = stdout.decode(errors="ignore").strip()
|
||||
if not text:
|
||||
return None
|
||||
# 取第一个匹配的窗口 ID
|
||||
first_line = text.splitlines()[0].strip()
|
||||
try:
|
||||
return int(first_line)
|
||||
except ValueError:
|
||||
candidates: list[tuple[int, int, WindowGeom]] = []
|
||||
seen_wids: set[int] = set()
|
||||
|
||||
async def add_candidate(wid: int) -> None:
|
||||
"""获取窗口几何并加入候选(去重)。"""
|
||||
if wid in seen_wids:
|
||||
return
|
||||
seen_wids.add(wid)
|
||||
rc, gstdout, _ = await self._run(
|
||||
["xdotool", "getwindowgeometry", "--shell", str(wid)]
|
||||
)
|
||||
if rc != 0:
|
||||
return
|
||||
geom = self._parse_window_geometry(gstdout.decode(errors="ignore"))
|
||||
if geom is None:
|
||||
return
|
||||
area = geom.width * geom.height
|
||||
# 过滤极小/无效的窗口
|
||||
if area < 10000 or geom.width < 100 or geom.height < 100:
|
||||
return
|
||||
candidates.append((area, wid, geom))
|
||||
|
||||
# 1. 通过 wechat 进程 PID 搜索窗口(优先)
|
||||
for pid in await self._find_wechat_pids():
|
||||
rc, stdout, _ = await self._run(
|
||||
["xdotool", "search", "--pid", str(pid)]
|
||||
)
|
||||
if rc != 0:
|
||||
continue
|
||||
for line in stdout.decode(errors="ignore").splitlines():
|
||||
wid_str = line.strip()
|
||||
if not wid_str:
|
||||
continue
|
||||
try:
|
||||
await add_candidate(int(wid_str))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# 2. name/class 兜底
|
||||
searches = [
|
||||
["xdotool", "search", "--onlyvisible", "--name", "微信"],
|
||||
["xdotool", "search", "--onlyvisible", "--class", "wechat"],
|
||||
["xdotool", "search", "--name", "微信"],
|
||||
["xdotool", "search", "--class", "wechat"],
|
||||
]
|
||||
for args in searches:
|
||||
returncode, stdout, _ = await self._run(args)
|
||||
if returncode != 0:
|
||||
continue
|
||||
for line in stdout.decode(errors="ignore").splitlines():
|
||||
wid_str = line.strip()
|
||||
if not wid_str:
|
||||
continue
|
||||
try:
|
||||
await add_candidate(int(wid_str))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if not candidates:
|
||||
if self._last_window_key is not None:
|
||||
logger.warning("[window] 未找到有效微信主窗口")
|
||||
self._last_window_key = None
|
||||
else:
|
||||
logger.debug("[window] 仍未找到有效微信主窗口")
|
||||
return None
|
||||
|
||||
# 取面积最大者(微信主窗口通常远大于通知/托盘窗口)
|
||||
candidates.sort(key=lambda x: x[0], reverse=True)
|
||||
area, wid, geom = candidates[0]
|
||||
key = (wid, geom.x, geom.y, geom.width, geom.height, area)
|
||||
if key == self._last_window_key:
|
||||
logger.debug(
|
||||
"[window] 主窗口未变化 id=%d area=%d geom=(%d,%d %dx%d)",
|
||||
wid, area, geom.x, geom.y, geom.width, geom.height,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"[window] 选中微信主窗口 id=%d area=%d geom=(%d,%d %dx%d)",
|
||||
wid, area, geom.x, geom.y, geom.width, geom.height,
|
||||
)
|
||||
self._last_window_key = key
|
||||
return wid
|
||||
|
||||
async def activate_window(self, window_id: Optional[int] = None) -> None:
|
||||
"""激活微信窗口。
|
||||
|
||||
使用非阻塞 windowactivate 避免 --sync 在 VNC 无人操作时死等,
|
||||
之后轮询 getactivewindow 确认焦点是否真正落到目标窗口。
|
||||
|
||||
Args:
|
||||
window_id: 指定窗口 ID;为 None 时自动查找
|
||||
"""
|
||||
@ -101,7 +215,23 @@ class WindowDriver(BaseDriver):
|
||||
code="WINDOW_NOT_FOUND",
|
||||
message="未找到微信窗口,无法激活",
|
||||
)
|
||||
await self._run(["xdotool", "windowactivate", "--sync", str(window_id)])
|
||||
await self._run(["xdotool", "windowactivate", str(window_id)])
|
||||
|
||||
deadline = time.monotonic() + 2.0
|
||||
while time.monotonic() < deadline:
|
||||
rc, stdout, _ = await self._run(["xdotool", "getactivewindow"])
|
||||
if rc == 0:
|
||||
active_text = stdout.decode(errors="ignore").strip()
|
||||
try:
|
||||
if int(active_text) == window_id:
|
||||
return
|
||||
except ValueError:
|
||||
pass
|
||||
await asyncio.sleep(0.1)
|
||||
logger.warning(
|
||||
"[window] activate_window: focus verify timeout id=%d, continue",
|
||||
window_id,
|
||||
)
|
||||
|
||||
async def get_window_geometry(
|
||||
self, window_id: Optional[int] = None
|
||||
|
||||
@ -102,39 +102,61 @@ class Flow:
|
||||
# 状态恢复
|
||||
# ------------------------------------------------------------------
|
||||
async def _reset_to_idle(self, win_geom: Optional[WindowGeometry] = None) -> bool:
|
||||
"""多策略清场:Esc×3 + 点击空白 + BackSpace×30 + Esc×2 + 校验。
|
||||
"""多策略清场:Esc×3 + 点击安全空白 + BackSpace×30 + Esc×2 + 校验。
|
||||
|
||||
Flow 失败或启动时调用,确保 UI 回到干净状态。
|
||||
不依赖 win_geom(点击空白用固定坐标,避免 win_geom=None 时崩溃)。
|
||||
点击位置采用比例坐标,固定落在右侧聊天区域,避免命中左侧联系人列表、
|
||||
分割线或底部输入框导致的误操作。
|
||||
|
||||
Args:
|
||||
win_geom: 窗口几何(可选,用于 post_verify)
|
||||
win_geom: 窗口几何(可选,用于点击空白区域与 post_verify)
|
||||
|
||||
Returns:
|
||||
True 表示清场成功(post_verify 通过),False 表示可能仍残留脏状态
|
||||
True 表示清场成功(post_verify 通过或无需校验),False 表示可能仍残留脏状态
|
||||
"""
|
||||
try:
|
||||
# 1. Esc×3 关闭可能的弹窗/搜索框
|
||||
# 1. Esc×3 关闭可能的弹窗/搜索框/联系人详情
|
||||
await self.actions.backend.key_press("Escape", repeat=3)
|
||||
await asyncio.sleep(0.3)
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
# 2. 点击主视图空白区域(避免焦点在搜索框/输入框)
|
||||
if win_geom is not None:
|
||||
# 点击主视图中心区域
|
||||
blank_x = win_geom.x + win_geom.width // 2
|
||||
blank_y = win_geom.y + win_geom.height // 2
|
||||
# 2. 点击右侧聊天区域的安全空白处(避开左侧联系人面板与底部输入区)
|
||||
if win_geom is not None and win_geom.width >= 200 and win_geom.height >= 200:
|
||||
# 左侧联系人面板一般占宽度 30% 以内,右侧安全区取 65%-85%
|
||||
# 顶部工具栏约 8%,底部输入区约 15%-20%,安全 y 取 25%-45%
|
||||
safe_x_ratio = 0.75
|
||||
safe_y_ratio = 0.40
|
||||
blank_x = win_geom.x + int(win_geom.width * safe_x_ratio)
|
||||
blank_y = win_geom.y + int(win_geom.height * safe_y_ratio)
|
||||
# 兜底保护:若计算点仍落在左侧 42% 区域内,则强制右移
|
||||
left_panel_max_x = win_geom.x + int(win_geom.width * 0.42)
|
||||
if blank_x <= left_panel_max_x:
|
||||
blank_x = win_geom.x + int(win_geom.width * 0.75)
|
||||
await self.actions.backend.click(blank_x, blank_y)
|
||||
await asyncio.sleep(0.3)
|
||||
logger.debug(
|
||||
"[flow] _reset_to_idle click safe blank (%d,%d) ratio=(%.2f,%.2f)",
|
||||
blank_x, blank_y, safe_x_ratio, safe_y_ratio,
|
||||
)
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
# 3. BackSpace×30 清空可能残留的输入框内容
|
||||
# 3. BackSpace×30 清空可能残留的输入框/搜索框内容
|
||||
await self.actions.backend.key_press("BackSpace", repeat=30)
|
||||
await asyncio.sleep(0.2)
|
||||
await asyncio.sleep(0.15)
|
||||
|
||||
# 4. Esc×2 再次关闭
|
||||
# 4. Esc×2 再次关闭可能因 BackSpace 触发的弹窗
|
||||
await self.actions.backend.key_press("Escape", repeat=2)
|
||||
await asyncio.sleep(0.2)
|
||||
await asyncio.sleep(0.15)
|
||||
|
||||
# 5. post_verify:尝试用 main_view 模板校验
|
||||
# 5. 再次点击安全空白,确保焦点不在输入框
|
||||
if win_geom is not None and win_geom.width >= 200 and win_geom.height >= 200:
|
||||
blank_x = win_geom.x + int(win_geom.width * 0.75)
|
||||
blank_y = win_geom.y + int(win_geom.height * 0.40)
|
||||
left_panel_max_x = win_geom.x + int(win_geom.width * 0.42)
|
||||
if blank_x <= left_panel_max_x:
|
||||
blank_x = win_geom.x + int(win_geom.width * 0.75)
|
||||
await self.actions.backend.click(blank_x, blank_y)
|
||||
await asyncio.sleep(0.15)
|
||||
|
||||
# 6. post_verify:尝试用 main_view 模板校验
|
||||
if win_geom is not None:
|
||||
try:
|
||||
elem = await self.actions.wait_for(
|
||||
|
||||
@ -149,36 +149,76 @@ class SendFileFlow:
|
||||
search_query = display_name.strip() if display_name and display_name.strip() else to_wxid
|
||||
|
||||
logger.info(
|
||||
"[flow:send_file] start to_wxid=%s file=%s size=%d type=%s timeout=%.1fs",
|
||||
"[flow:send_file] START to_wxid=%s file=%s size=%d type=%s timeout=%.1fs display_name=%s",
|
||||
to_wxid, file_name, file_size, file_type, timeout_sec,
|
||||
display_name or "(none)",
|
||||
)
|
||||
|
||||
# 3. 获取基线时间戳(post_verify 游标,仅查发送之后的新消息)
|
||||
# 复合游标 (create_time, local_id) 避免基线消息本身被重复匹配
|
||||
baseline_time, baseline_local_id = await self._get_baseline_time(to_wxid)
|
||||
logger.info(
|
||||
"[flow:send_file] baseline ct=%d local_id=%d",
|
||||
baseline_time, baseline_local_id,
|
||||
)
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(timeout_sec):
|
||||
# 4. 激活窗口 + 获取几何 + Esc 清场
|
||||
t0 = time.perf_counter()
|
||||
await self._actions.backend.activate_window(_WECHAT_WINDOW_TITLE)
|
||||
win_geom = await self._actions.backend.get_window_geometry(
|
||||
_WECHAT_WINDOW_TITLE
|
||||
)
|
||||
await self._actions.backend.key_press("Escape")
|
||||
await asyncio.sleep(0.3)
|
||||
logger.info(
|
||||
"[flow:send_file] step=activate done geom=%dx%d@(%d,%d) (%.0fms)",
|
||||
win_geom.width, win_geom.height, win_geom.x, win_geom.y,
|
||||
(time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
|
||||
# 5. 定位会话(搜索框 → 输入 display_name → 点击搜索结果第一项)
|
||||
t0 = time.perf_counter()
|
||||
logger.info(
|
||||
"[flow:send_file] step=open_session start query=%r",
|
||||
search_query,
|
||||
)
|
||||
await self._open_session(search_query, win_geom)
|
||||
logger.info(
|
||||
"[flow:send_file] step=open_session done (%.0fms)",
|
||||
(time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
|
||||
# 6. 点击 "+" → 选 "发送文件" 菜单项
|
||||
t0 = time.perf_counter()
|
||||
logger.info("[flow:send_file] step=click_plus_and_send_file start")
|
||||
await self._click_plus_and_send_file(win_geom)
|
||||
logger.info(
|
||||
"[flow:send_file] step=click_plus_and_send_file done (%.0fms)",
|
||||
(time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
|
||||
# 7. 系统文件选择器:xdotool type 路径 → Return 提交
|
||||
t0 = time.perf_counter()
|
||||
logger.info(
|
||||
"[flow:send_file] step=input_path_in_chooser start path=%s",
|
||||
file_path,
|
||||
)
|
||||
await self._input_path_in_chooser(file_path)
|
||||
logger.info(
|
||||
"[flow:send_file] step=input_path_in_chooser done (%.0fms)",
|
||||
(time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
|
||||
# 8. 等待文件加载到输入区
|
||||
logger.info("[flow:send_file] step=wait_file_load 1.0s")
|
||||
await asyncio.sleep(1.0)
|
||||
except asyncio.TimeoutError as exc:
|
||||
logger.warning(
|
||||
"[flow:send_file] TIMEOUT file=%s timeout=%.1fs",
|
||||
file_name, timeout_sec,
|
||||
)
|
||||
# 兜底清场:Esc 关闭可能残留的弹窗/选择器
|
||||
try:
|
||||
await self._actions.backend.key_press("Escape", repeat=2)
|
||||
@ -191,12 +231,13 @@ class SendFileFlow:
|
||||
) from exc
|
||||
|
||||
# 9. post_verify:DB 校验文件消息是否落库
|
||||
t0 = time.perf_counter()
|
||||
verified = await self._post_verify(
|
||||
to_wxid, file_name, baseline_time, baseline_local_id
|
||||
)
|
||||
logger.info(
|
||||
"[flow:send_file] done file=%s verified=%s",
|
||||
file_name, verified,
|
||||
"[flow:send_file] DONE file=%s verified=%s (%.0fms)",
|
||||
file_name, verified, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
return {"success": True, "verified": verified, "error": None}
|
||||
|
||||
@ -211,14 +252,17 @@ class SendFileFlow:
|
||||
search_query: 搜索查询词(display_name 优先,回退到 to_wxid)
|
||||
win_geom: 窗口几何信息
|
||||
"""
|
||||
logger.info("[flow:send_file] _open_session: click search_box")
|
||||
await self._actions.click_element("search_box", win_geom)
|
||||
await asyncio.sleep(0.3)
|
||||
# 清空可能残留的查询
|
||||
logger.info("[flow:send_file] _open_session: clear + type query")
|
||||
await self._actions.backend.key_press("BackSpace", repeat=30)
|
||||
await self._actions.backend.type_text(search_query)
|
||||
# 等待搜索结果出现
|
||||
await asyncio.sleep(0.5)
|
||||
# 点击搜索结果第一项打开会话
|
||||
logger.info("[flow:send_file] _open_session: click search_result_first")
|
||||
await self._actions.click_element("search_result_first", win_geom)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
@ -234,10 +278,18 @@ class SendFileFlow:
|
||||
# "+" 按钮:靠近窗口右下角(输入框右侧)
|
||||
plus_x = win_geom.x + win_geom.width - 30
|
||||
plus_y = win_geom.y + win_geom.height - 30
|
||||
logger.info(
|
||||
"[flow:send_file] _click_plus: click (+) at (%d,%d) geom=%dx%d@(%d,%d)",
|
||||
plus_x, plus_y, win_geom.width, win_geom.height, win_geom.x, win_geom.y,
|
||||
)
|
||||
await self._actions.backend.click(plus_x, plus_y)
|
||||
await asyncio.sleep(0.6) # 等待菜单弹出
|
||||
|
||||
# 键盘导航选 "发送文件"(参考 publish_moment_with_image 的 Down + Return 范式)
|
||||
logger.info(
|
||||
"[flow:send_file] _click_plus: navigate menu Down×%d + Return",
|
||||
_SEND_FILE_MENU_DOWN_COUNT,
|
||||
)
|
||||
for _ in range(_SEND_FILE_MENU_DOWN_COUNT):
|
||||
await self._actions.backend.key_press("Down")
|
||||
await asyncio.sleep(0.15)
|
||||
@ -253,17 +305,26 @@ class SendFileFlow:
|
||||
关键点:微信自绘 UI 不接受 Ctrl+V,但系统 GTK/Qt 文件选择器对话框
|
||||
接受 xdotool type 逐字符输入。用 --delay 50 保证每字符被对话框处理。
|
||||
"""
|
||||
logger.info(
|
||||
"[flow:send_file] _input_path: xdotool type path len=%d delay=50ms",
|
||||
len(file_path),
|
||||
)
|
||||
rc, _, stderr = await self._xdotool._run(
|
||||
["xdotool", "type", "--delay", "50", file_path]
|
||||
)
|
||||
if rc != 0:
|
||||
err = stderr.decode(errors="ignore").strip() if stderr else "unknown"
|
||||
logger.warning(
|
||||
"[flow:send_file] _input_path: xdotool type FAILED rc=%d err=%s",
|
||||
rc, err,
|
||||
)
|
||||
raise BridgeError(
|
||||
"SEND_FAILED",
|
||||
f"xdotool type 路径失败 (code={rc}): {err}",
|
||||
)
|
||||
await asyncio.sleep(0.3) # 等待输入完成
|
||||
# Return 提交选择
|
||||
logger.info("[flow:send_file] _input_path: Return to submit")
|
||||
await self._xdotool._key("Return")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@ -15,6 +15,7 @@ import random
|
||||
import time
|
||||
from typing import Awaitable, Callable, Optional
|
||||
|
||||
from woc_bridge.ui.backends.base import WindowGeometry
|
||||
from woc_bridge.ui.errors import FlowError, PermanentError
|
||||
from woc_bridge.ui.flow import (
|
||||
Flow,
|
||||
@ -49,14 +50,17 @@ class SendTextFlow(Flow):
|
||||
# 后台 task:获取 before_msg_id(与 _activate 并行)
|
||||
before_msg_task: Optional[asyncio.Task] = None
|
||||
|
||||
logger.info(
|
||||
"[flow:send_text] START to_wxid=%s display_name=%s content_len=%d client_request_id=%s",
|
||||
ctx.to_wxid, ctx.display_name, len(ctx.content), ctx.client_request_id,
|
||||
)
|
||||
try:
|
||||
async with asyncio.timeout(_FLOW_TIMEOUT_SEC):
|
||||
# 检查会话缓存(用 to_wxid 做 key,display_name 非唯一)
|
||||
cache_hit = False
|
||||
if self.session_cache is not None:
|
||||
cached = self.session_cache.get()
|
||||
if cached is not None and cached == ctx.to_wxid:
|
||||
cache_hit = True
|
||||
cache_hit = self.session_cache.get(ctx.to_wxid)
|
||||
if cache_hit:
|
||||
logger.info(
|
||||
"[flow:send_text] session cache hit for %s",
|
||||
ctx.to_wxid,
|
||||
@ -65,16 +69,24 @@ class SendTextFlow(Flow):
|
||||
if cache_hit:
|
||||
# 跳过 click_search_box ~ open_session,但仍需激活窗口
|
||||
# (30s 内用户可能切到其他应用,微信失去焦点)
|
||||
t0 = time.perf_counter()
|
||||
before_msg_task = asyncio.create_task(
|
||||
self._get_latest_create_time(ctx.to_wxid)
|
||||
)
|
||||
# 仅激活窗口 + 获取几何,跳过 Esc 清场(会话已打开)
|
||||
await self.actions.backend.activate_window(_WECHAT_WINDOW_TITLE)
|
||||
ctx.win_geom = await self.actions.backend.get_window_geometry(
|
||||
_WECHAT_WINDOW_TITLE
|
||||
ctx.win_geom = self._ensure_valid_geometry(
|
||||
await self.actions.backend.get_window_geometry(
|
||||
_WECHAT_WINDOW_TITLE
|
||||
)
|
||||
)
|
||||
await self._verify_session_still_open(ctx)
|
||||
ctx.current_state = FlowState.SESSION_OPENED
|
||||
logger.info(
|
||||
"[flow:send_text] cache_hit branch done geom=%dx%d (%.0fms)",
|
||||
ctx.win_geom.width, ctx.win_geom.height,
|
||||
(time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
else:
|
||||
# 完整流程
|
||||
await self._activate(ctx)
|
||||
@ -93,6 +105,14 @@ class SendTextFlow(Flow):
|
||||
ct, lid = await before_msg_task
|
||||
ctx.before_msg_id = ct
|
||||
self._before_msg_local_id = lid
|
||||
logger.info(
|
||||
"[flow:send_text] before_msg baseline ct=%d local_id=%d",
|
||||
ct, lid,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"[flow:send_text] before_msg_task is None, baseline unavailable"
|
||||
)
|
||||
await self._send(ctx)
|
||||
await self._cleanup(ctx)
|
||||
|
||||
@ -100,6 +120,12 @@ class SendTextFlow(Flow):
|
||||
if self.session_cache is not None and ctx.verified:
|
||||
self.session_cache.set(ctx.to_wxid)
|
||||
|
||||
logger.info(
|
||||
"[flow:send_text] DONE success=%s verified=%s local_id=%s state=%s (%.0fms)",
|
||||
True, ctx.verified, ctx.local_send_id, ctx.current_state,
|
||||
(time.monotonic() - started_at) * 1000,
|
||||
)
|
||||
|
||||
return FlowResult(
|
||||
success=True,
|
||||
local_id=ctx.local_send_id,
|
||||
@ -111,8 +137,8 @@ class SendTextFlow(Flow):
|
||||
|
||||
except (FlowError, asyncio.TimeoutError) as exc:
|
||||
logger.warning(
|
||||
"[flow:send_text] failed at state=%s: %s",
|
||||
ctx.current_state, exc,
|
||||
"[flow:send_text] failed at state=%s: %s: %s",
|
||||
ctx.current_state, type(exc).__name__, exc,
|
||||
)
|
||||
# 尝试恢复
|
||||
try:
|
||||
@ -121,13 +147,13 @@ class SendTextFlow(Flow):
|
||||
logger.warning(
|
||||
"[flow:send_text] reset_to_idle failed: %s", reset_exc
|
||||
)
|
||||
# 失败时失效会话缓存
|
||||
# 失败时仅失效当前联系人会话缓存
|
||||
if self.session_cache is not None:
|
||||
self.session_cache.invalidate()
|
||||
self.session_cache.invalidate(ctx.to_wxid)
|
||||
|
||||
error_code = ""
|
||||
error_code = "SEND_FAILED"
|
||||
error_msg = str(exc)
|
||||
if isinstance(exc, FlowError):
|
||||
if isinstance(exc, FlowError) and exc.code:
|
||||
error_code = exc.code
|
||||
elif isinstance(exc, asyncio.TimeoutError):
|
||||
error_code = "SEND_TIMEOUT"
|
||||
@ -148,7 +174,7 @@ class SendTextFlow(Flow):
|
||||
except Exception:
|
||||
pass
|
||||
if self.session_cache is not None:
|
||||
self.session_cache.invalidate()
|
||||
self.session_cache.invalidate(ctx.to_wxid)
|
||||
return FlowResult(
|
||||
success=False,
|
||||
error=str(exc),
|
||||
@ -170,13 +196,34 @@ class SendTextFlow(Flow):
|
||||
# ------------------------------------------------------------------
|
||||
# Transitions
|
||||
# ------------------------------------------------------------------
|
||||
def _ensure_valid_geometry(self, geom: Optional[WindowGeometry]) -> WindowGeometry:
|
||||
"""校验窗口几何有效,防止拿到 1×1 隐藏窗后继续误点 (0,0)。
|
||||
|
||||
返回校验后的 WindowGeometry,方便调用方获得窄化类型。
|
||||
"""
|
||||
if geom is None:
|
||||
raise PermanentError(
|
||||
"WINDOW_GEOMETRY_INVALID",
|
||||
"无法获取微信窗口几何,无法执行 UI 操作",
|
||||
)
|
||||
if geom.width < 200 or geom.height < 200:
|
||||
raise PermanentError(
|
||||
"WINDOW_GEOMETRY_INVALID",
|
||||
f"微信窗口几何异常 {geom.width}x{geom.height},无法执行 UI 操作",
|
||||
)
|
||||
return geom
|
||||
|
||||
async def _activate(self, ctx: FlowContext) -> None:
|
||||
"""激活窗口 + 获取几何 + Esc。"""
|
||||
"""激活窗口 + 获取几何 + 重置状态(回到会话列表首页)。"""
|
||||
await self.actions.backend.activate_window(_WECHAT_WINDOW_TITLE)
|
||||
ctx.win_geom = await self.actions.backend.get_window_geometry(
|
||||
_WECHAT_WINDOW_TITLE
|
||||
ctx.win_geom = self._ensure_valid_geometry(
|
||||
await self.actions.backend.get_window_geometry(_WECHAT_WINDOW_TITLE)
|
||||
)
|
||||
# 重置状态:点击左侧"会话管理"图标,强制回到会话列表首页,
|
||||
# 清除可能存在的搜索覆盖层、弹窗或非会话页面。
|
||||
await self.actions.click_element("session_manager_icon", ctx.win_geom)
|
||||
await self.actions.backend.key_press("Escape")
|
||||
await asyncio.sleep(0.3)
|
||||
ctx.current_state = FlowState.WINDOW_ACTIVATED
|
||||
logger.info(
|
||||
"[flow:send_text] activated window geom=%dx%d",
|
||||
@ -185,77 +232,149 @@ class SendTextFlow(Flow):
|
||||
|
||||
async def _click_search_box(self, ctx: FlowContext) -> None:
|
||||
"""点击搜索框。"""
|
||||
t0 = time.perf_counter()
|
||||
logger.info("[flow:send_text] step=search_box click start")
|
||||
await self.actions.click_element("search_box", ctx.win_geom)
|
||||
ctx.current_state = FlowState.SEARCH_BOX_CLICKED
|
||||
# 简化:点击后立即可输入,等待 0.3s 让 UI 响应
|
||||
await asyncio.sleep(0.3)
|
||||
# 点击后短暂等待 UI 响应,高并发场景下调短以提升吞吐
|
||||
await asyncio.sleep(0.2)
|
||||
logger.info(
|
||||
"[flow:send_text] step=search_box click done (%.0fms)",
|
||||
(time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
|
||||
async def _type_query(self, ctx: FlowContext) -> None:
|
||||
"""输入搜索查询(display_name)。"""
|
||||
# 清空可能的残留
|
||||
await self.actions.backend.key_press("BackSpace", repeat=30)
|
||||
t0 = time.perf_counter()
|
||||
logger.info(
|
||||
"[flow:send_text] step=type_query start display_name=%r",
|
||||
ctx.display_name,
|
||||
)
|
||||
# 清空可能的残留:Ctrl+A 全选 + BackSpace 删除,比 BackSpace×30 更可靠
|
||||
await self.actions.backend.key_press("ctrl+a")
|
||||
await self.actions.backend.key_press("BackSpace")
|
||||
await asyncio.sleep(0.2)
|
||||
await self.actions.backend.type_text(ctx.display_name)
|
||||
ctx.current_state = FlowState.QUERY_TYPED
|
||||
# 自适应等待搜索结果
|
||||
# 自适应等待搜索结果渲染;P1 阶段无 OpenCV 时 find_element 的 geom 兜底
|
||||
# 不可信,因此 _check_search_result_appeared 只在 image 命中时返回 True。
|
||||
appeared = await self._wait_for_condition(
|
||||
lambda: self._check_search_result_appeared(ctx),
|
||||
timeout=3.0,
|
||||
timeout=1.5,
|
||||
interval=0.3,
|
||||
)
|
||||
if not appeared:
|
||||
logger.warning(
|
||||
"[flow:send_text] search result not appeared in 3s, continue"
|
||||
"[flow:send_text] search result not appeared in 1.5s, continue (%.0fms)",
|
||||
(time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"[flow:send_text] step=type_query done (search result appeared, %.0fms)",
|
||||
(time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
|
||||
async def _open_session(self, ctx: FlowContext) -> None:
|
||||
"""点击搜索结果第一项打开会话。"""
|
||||
t0 = time.perf_counter()
|
||||
logger.info("[flow:send_text] step=open_session click search_result_first start")
|
||||
await self.actions.click_element("search_result_first", ctx.win_geom)
|
||||
ctx.current_state = FlowState.SESSION_OPENED
|
||||
# 自适应等待 input_box 出现
|
||||
# 自适应等待 input_box 出现;P1 阶段无 OpenCV 时 find_element 的 geom 兜底
|
||||
# 不可信,因此 _check_input_box_appeared 只在 image 命中时返回 True。
|
||||
appeared = await self._wait_for_condition(
|
||||
lambda: self._check_input_box_appeared(ctx),
|
||||
timeout=3.0,
|
||||
timeout=1.5,
|
||||
interval=0.3,
|
||||
)
|
||||
if not appeared:
|
||||
logger.warning(
|
||||
"[flow:send_text] input_box not appeared in 3s, continue"
|
||||
"[flow:send_text] input_box not appeared in 1.5s, continue (%.0fms)",
|
||||
(time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"[flow:send_text] step=open_session done (input_box appeared, %.0fms)",
|
||||
(time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
|
||||
async def _focus_input(self, ctx: FlowContext) -> None:
|
||||
"""聚焦输入框。"""
|
||||
t0 = time.perf_counter()
|
||||
logger.info("[flow:send_text] step=focus_input click input_box start")
|
||||
await self.actions.click_element("input_box", ctx.win_geom)
|
||||
ctx.current_state = FlowState.INPUT_FOCUSED
|
||||
await asyncio.sleep(0.1)
|
||||
logger.info(
|
||||
"[flow:send_text] step=focus_input done (%.0fms)",
|
||||
(time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
|
||||
async def _type_content(self, ctx: FlowContext) -> None:
|
||||
"""输入消息内容。"""
|
||||
t0 = time.perf_counter()
|
||||
# 清理开头/结尾的空白和换行,避免输入框视觉为空、发送按钮灰色
|
||||
cleaned_content = ctx.content.strip()
|
||||
if cleaned_content != ctx.content:
|
||||
logger.info(
|
||||
"[flow:send_text] step=type_content stripped leading/trailing whitespace: "
|
||||
"original_len=%d cleaned_len=%d",
|
||||
len(ctx.content), len(cleaned_content),
|
||||
)
|
||||
ctx.content = cleaned_content
|
||||
logger.info(
|
||||
"[flow:send_text] step=type_content start content_len=%d",
|
||||
len(ctx.content),
|
||||
)
|
||||
await self.actions.backend.type_text(ctx.content)
|
||||
ctx.current_state = FlowState.TEXT_TYPED
|
||||
logger.info(
|
||||
"[flow:send_text] step=type_content done (%.0fms)",
|
||||
(time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
|
||||
async def _send(self, ctx: FlowContext) -> None:
|
||||
"""点击发送按钮 + 校验。"""
|
||||
t0 = time.perf_counter()
|
||||
logger.info("[flow:send_text] step=send click send_button start")
|
||||
await self.actions.click_element("send_button", ctx.win_geom)
|
||||
ctx.current_state = FlowState.SENT
|
||||
ctx.local_send_id = f"local_{int(time.time())}_{random.randint(1000,9999)}"
|
||||
ctx.verified = await self._verify_sent(ctx)
|
||||
logger.info(
|
||||
"[flow:send_text] sent local_id=%s verified=%s",
|
||||
"[flow:send_text] step=send done local_id=%s verified=%s (%.0fms)",
|
||||
ctx.local_send_id, ctx.verified,
|
||||
(time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
|
||||
async def _cleanup(self, ctx: FlowContext) -> None:
|
||||
"""清理:Esc。"""
|
||||
t0 = time.perf_counter()
|
||||
logger.info("[flow:send_text] step=cleanup Esc start")
|
||||
await self.actions.backend.key_press("Escape")
|
||||
await asyncio.sleep(0.2)
|
||||
ctx.current_state = FlowState.CLEANED_UP
|
||||
logger.info(
|
||||
"[flow:send_text] step=cleanup done (%.0fms)",
|
||||
(time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 校验
|
||||
# ------------------------------------------------------------------
|
||||
async def _verify_sent(self, ctx: FlowContext) -> bool:
|
||||
"""发送结果校验:DB 校验优先,失败降级截图校验。"""
|
||||
t0 = time.perf_counter()
|
||||
if self.db_reader is None:
|
||||
logger.info("[flow:send_text] no db_reader, fall back to screenshot")
|
||||
return await self._verify_by_screenshot(ctx)
|
||||
logger.warning(
|
||||
"[flow:send_text] no db_reader, fall back to screenshot verify"
|
||||
)
|
||||
result = await self._verify_by_screenshot(ctx)
|
||||
logger.info(
|
||||
"[flow:send_text] _verify_sent done (no db_reader) result=%s (%.0fms)",
|
||||
result, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
return result
|
||||
# before_msg_id=0 表示无法建立基线(DB 不可用或会话无历史消息),
|
||||
# 直接降级截图校验,避免 cursor=0 查询返回所有历史消息导致假阳性
|
||||
if ctx.before_msg_id <= 0:
|
||||
@ -263,7 +382,12 @@ class SendTextFlow(Flow):
|
||||
"[flow:send_text] before_msg_id=%d, skip DB verify, fall back to screenshot",
|
||||
ctx.before_msg_id,
|
||||
)
|
||||
return await self._verify_by_screenshot(ctx)
|
||||
result = await self._verify_by_screenshot(ctx)
|
||||
logger.info(
|
||||
"[flow:send_text] _verify_sent done (no baseline) result=%s (%.0fms)",
|
||||
result, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
return result
|
||||
|
||||
deadline = time.monotonic() + _DB_VERIFY_TIMEOUT_SEC
|
||||
attempts = 0
|
||||
@ -289,14 +413,15 @@ class SendTextFlow(Flow):
|
||||
# 微信消息内容可能包含发送者前缀(群消息),用 in 匹配
|
||||
if ctx.content and ctx.content in msg_content:
|
||||
logger.info(
|
||||
"[flow:send_text] DB verify OK (attempt=%d, content matched, is_sender=%s)",
|
||||
attempts, msg.get("is_sender"),
|
||||
"[flow:send_text] DB verify OK (attempt=%d, content matched, is_sender=%s, %d new msgs)",
|
||||
attempts, msg.get("is_sender"), len(messages),
|
||||
)
|
||||
return True
|
||||
# 有新消息但内容不匹配 → 可能串号或消息未到达
|
||||
logger.warning(
|
||||
"[flow:send_text] DB verify: %d new msgs but content not matched (attempt=%d)",
|
||||
"[flow:send_text] DB verify: %d new msgs but content not matched (attempt=%d, contents=%s)",
|
||||
len(messages), attempts,
|
||||
[m.get("content", "")[:50] for m in messages],
|
||||
)
|
||||
# 继续轮询,可能是 DB 写入延迟
|
||||
except Exception as exc:
|
||||
@ -307,46 +432,104 @@ class SendTextFlow(Flow):
|
||||
await asyncio.sleep(_DB_VERIFY_INTERVAL_SEC)
|
||||
|
||||
logger.warning(
|
||||
"[flow:send_text] DB verify timeout, fall back to screenshot"
|
||||
"[flow:send_text] DB verify timeout after %ds/%d attempts, fall back to screenshot",
|
||||
_DB_VERIFY_TIMEOUT_SEC, attempts,
|
||||
)
|
||||
return await self._verify_by_screenshot(ctx)
|
||||
result = await self._verify_by_screenshot(ctx)
|
||||
logger.info(
|
||||
"[flow:send_text] _verify_sent done (DB timeout) result=%s (%.0fms)",
|
||||
result, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
return result
|
||||
|
||||
async def _verify_by_screenshot(self, ctx: FlowContext) -> bool:
|
||||
"""截图校验:等待 input_box_empty 模板出现。"""
|
||||
"""截图校验:轮询等待 input_box_empty 模板出现。
|
||||
|
||||
策略说明:
|
||||
- OpenCV image 命中 → 可靠校验通过,返回 True
|
||||
- geom 兜底命中 → 不可靠,继续轮询等待 image 命中;超时后返回 False
|
||||
- 超时/异常 → 返回 False
|
||||
|
||||
geom 兜底永远返回坐标,无法证明输入框已清空。以往乐观返回 True 会掩盖
|
||||
真实发送失败,现改为只有 image 命中才认为校验通过。
|
||||
"""
|
||||
if ctx.win_geom is None:
|
||||
logger.warning("[flow:send_text] screenshot verify skipped: win_geom is None")
|
||||
return False
|
||||
try:
|
||||
elem = await self.actions.wait_for(
|
||||
"input_box_empty", ctx.win_geom, timeout=3.0, interval=0.3
|
||||
t0 = time.perf_counter()
|
||||
deadline = time.monotonic() + 3.0
|
||||
attempts = 0
|
||||
last_strategy: Optional[str] = None
|
||||
last_conf: float = 0.0
|
||||
while time.monotonic() < deadline:
|
||||
attempts += 1
|
||||
try:
|
||||
elem = await self.actions.find_element(
|
||||
"input_box_empty", ctx.win_geom
|
||||
)
|
||||
if elem is None:
|
||||
continue
|
||||
if elem.strategy == "image":
|
||||
logger.info(
|
||||
"[flow:send_text] screenshot verify OK (image matched, conf=%.3f, attempts=%d, %.0fms)",
|
||||
elem.confidence, attempts, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
return True
|
||||
# geom 兜底:记录策略,继续轮询看 image 是否能匹配
|
||||
last_strategy = elem.strategy
|
||||
last_conf = elem.confidence
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"[flow:send_text] screenshot verify attempt=%d exception: %s",
|
||||
attempts, exc,
|
||||
)
|
||||
await asyncio.sleep(0.3)
|
||||
# 超时:只有 image 命中才算成功,geom 兜底不再乐观返回
|
||||
if last_strategy == "geom":
|
||||
logger.warning(
|
||||
"[flow:send_text] screenshot verify FAIL: only geom fallback matched, "
|
||||
"image not matched in 3s/%d attempts, %.0fms — cannot prove send success",
|
||||
attempts, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
if elem is not None:
|
||||
logger.info("[flow:send_text] screenshot verify OK")
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"[flow:send_text] screenshot verify exception: %s", exc
|
||||
)
|
||||
logger.warning("[flow:send_text] screenshot verify failed")
|
||||
return False
|
||||
logger.warning(
|
||||
"[flow:send_text] screenshot verify FAIL: input_box_empty not found in 3s "
|
||||
"(attempts=%d, %.0fms)",
|
||||
attempts, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
return False
|
||||
|
||||
async def _verify_session_still_open(self, ctx: FlowContext) -> None:
|
||||
"""会话缓存命中时校验会话仍打开(input_box 存在)。"""
|
||||
# 获取窗口几何(缓存命中时未走 _activate)
|
||||
ctx.win_geom = await self.actions.backend.get_window_geometry(
|
||||
_WECHAT_WINDOW_TITLE
|
||||
t0 = time.perf_counter()
|
||||
# 获取窗口几何(缓存命中时未走 _activate),并校验/窄化类型
|
||||
ctx.win_geom = self._ensure_valid_geometry(
|
||||
await self.actions.backend.get_window_geometry(_WECHAT_WINDOW_TITLE)
|
||||
)
|
||||
try:
|
||||
elem = await self.actions.wait_for(
|
||||
"input_box", ctx.win_geom, timeout=2.0, interval=0.3
|
||||
)
|
||||
if elem is None:
|
||||
logger.warning(
|
||||
"[flow:send_text] session_still_open FAIL: input_box not found (%.0fms)",
|
||||
(time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
raise PermanentError(
|
||||
"ELEMENT_NOT_FOUND",
|
||||
"session cache hit but input_box not found",
|
||||
)
|
||||
logger.info(
|
||||
"[flow:send_text] session_still_open OK (input_box found, strategy=%s, %.0fms)",
|
||||
elem.strategy, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
except PermanentError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[flow:send_text] session_still_open exception: %s (%.0fms)",
|
||||
exc, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
raise PermanentError(
|
||||
"ELEMENT_NOT_FOUND",
|
||||
f"session cache verify failed: {exc}",
|
||||
@ -373,20 +556,29 @@ class SendTextFlow(Flow):
|
||||
return False
|
||||
|
||||
async def _check_search_result_appeared(self, ctx: FlowContext) -> bool:
|
||||
"""检查搜索结果是否出现。"""
|
||||
"""检查搜索结果是否真正出现。
|
||||
|
||||
P1 阶段:find_element 在 image 匹配失败时会回退到 geom 兜底,
|
||||
而 geom 兜底只是返回 YAML profile 中写死的比例坐标,不能证明
|
||||
搜索结果已渲染。因此只有 image 命中(strategy == "image")
|
||||
才视为可靠出现。
|
||||
"""
|
||||
try:
|
||||
elem = await self.actions.find_element(
|
||||
"search_result_first", ctx.win_geom
|
||||
)
|
||||
return elem is not None
|
||||
return elem is not None and elem.strategy == "image"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _check_input_box_appeared(self, ctx: FlowContext) -> bool:
|
||||
"""检查输入框是否出现。"""
|
||||
"""检查输入框是否真正出现。
|
||||
|
||||
同 _check_search_result_appeared,只有 image 命中才视为可靠。
|
||||
"""
|
||||
try:
|
||||
elem = await self.actions.find_element("input_box", ctx.win_geom)
|
||||
return elem is not None
|
||||
return elem is not None and elem.strategy == "image"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@ -396,7 +588,9 @@ class SendTextFlow(Flow):
|
||||
async def _get_latest_create_time(self, to_wxid: str) -> tuple[int, int]:
|
||||
"""获取目标会话当前最新消息的 (create_time, local_id)。"""
|
||||
if self.db_reader is None:
|
||||
logger.debug("[flow:send_text] get_latest_create_time: db_reader is None")
|
||||
return (0, 0)
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
self.db_reader.get_messages_by_session,
|
||||
@ -410,9 +604,18 @@ class SendTextFlow(Flow):
|
||||
if messages:
|
||||
ct = int(messages[0].get("create_time", 0))
|
||||
lid = int(messages[0].get("local_id", 0))
|
||||
logger.info(
|
||||
"[flow:send_text] get_latest_create_time OK to_wxid=%s ct=%d local_id=%d (%.0fms)",
|
||||
to_wxid, ct, lid, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
return (ct, lid)
|
||||
logger.info(
|
||||
"[flow:send_text] get_latest_create_time: no history msgs for to_wxid=%s (%.0fms)",
|
||||
to_wxid, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"[flow:send_text] get_latest_create_time exception: %s", exc
|
||||
logger.warning(
|
||||
"[flow:send_text] get_latest_create_time exception: %s (%.0fms)",
|
||||
exc, (time.perf_counter() - t0) * 1000,
|
||||
)
|
||||
return (0, 0)
|
||||
|
||||
@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
@ -51,18 +52,45 @@ class LocatorRegistry:
|
||||
# Profile 加载
|
||||
# ------------------------------------------------------------------
|
||||
def load(self, app_version: str, resolution: tuple[int, int]) -> None:
|
||||
"""按精确 > 版本 > default 优先级加载 YAML profile。
|
||||
"""按精确 > 最近分辨率 > 版本 > default 优先级加载 YAML profile。
|
||||
|
||||
Args:
|
||||
app_version: 微信版本号,如 "4.0"
|
||||
resolution: (width, height) 元组,如 (1920, 1080)
|
||||
"""
|
||||
candidates = [
|
||||
f"wechat_{app_version}_{resolution[0]}x{resolution[1]}.yaml",
|
||||
f"wechat_{app_version}.yaml",
|
||||
"default.yaml",
|
||||
]
|
||||
for name in candidates:
|
||||
exact_name = f"wechat_{app_version}_{resolution[0]}x{resolution[1]}.yaml"
|
||||
exact_path = os.path.join(self.profile_dir, exact_name)
|
||||
if os.path.exists(exact_path):
|
||||
logger.info(
|
||||
"[locator] load exact profile: %s (app_version=%s resolution=%s)",
|
||||
exact_name,
|
||||
app_version,
|
||||
resolution,
|
||||
)
|
||||
self._load_yaml(exact_path)
|
||||
return
|
||||
|
||||
# 无精确匹配时,找同版本最近分辨率并按比例缩放绝对偏移
|
||||
nearest = self._find_nearest_profile(app_version, resolution)
|
||||
if nearest:
|
||||
name, profile_w, profile_h = nearest
|
||||
logger.info(
|
||||
"[locator] load nearest profile: %s for resolution=%s "
|
||||
"(scale=%.3fx%.3f)",
|
||||
name,
|
||||
resolution,
|
||||
resolution[0] / profile_w,
|
||||
resolution[1] / profile_h,
|
||||
)
|
||||
self._load_yaml(
|
||||
os.path.join(self.profile_dir, name),
|
||||
target_resolution=resolution,
|
||||
profile_resolution=(profile_w, profile_h),
|
||||
)
|
||||
return
|
||||
|
||||
# 版本通用/默认兜底
|
||||
for name in [f"wechat_{app_version}.yaml", "default.yaml"]:
|
||||
path = os.path.join(self.profile_dir, name)
|
||||
if os.path.exists(path):
|
||||
logger.info(
|
||||
@ -73,6 +101,7 @@ class LocatorRegistry:
|
||||
)
|
||||
self._load_yaml(path)
|
||||
return
|
||||
|
||||
logger.warning(
|
||||
"[locator] no profile found in %s, fall back to built-in defaults "
|
||||
"(app_version=%s resolution=%s)",
|
||||
@ -82,6 +111,30 @@ class LocatorRegistry:
|
||||
)
|
||||
self._load_defaults()
|
||||
|
||||
def _find_nearest_profile(
|
||||
self, app_version: str, resolution: tuple[int, int]
|
||||
) -> Optional[tuple[str, int, int]]:
|
||||
"""找与当前分辨率最接近的 wechat_<version>_<w>x<h>.yaml。"""
|
||||
pattern = re.compile(rf"wechat_{re.escape(app_version)}_(\d+)x(\d+)\.yaml$")
|
||||
target_w, target_h = resolution
|
||||
target_area = target_w * target_h
|
||||
best: Optional[tuple[str, int, int]] = None
|
||||
best_score: Optional[float] = None
|
||||
for name in os.listdir(self.profile_dir):
|
||||
m = pattern.match(name)
|
||||
if not m:
|
||||
continue
|
||||
pw, ph = int(m.group(1)), int(m.group(2))
|
||||
area = pw * ph
|
||||
area_ratio = min(target_area, area) / max(target_area, area)
|
||||
aspect_diff = abs(target_w / target_h - pw / ph)
|
||||
# 面积越接近、宽高比越接近,分数越低
|
||||
score = (1 - area_ratio) + aspect_diff
|
||||
if best_score is None or score < best_score:
|
||||
best_score = score
|
||||
best = (name, pw, ph)
|
||||
return best
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
"""切换主题(light / dark)。"""
|
||||
if theme != self.theme:
|
||||
@ -135,9 +188,17 @@ class LocatorRegistry:
|
||||
# ------------------------------------------------------------------
|
||||
# YAML 解析与默认值
|
||||
# ------------------------------------------------------------------
|
||||
def _load_yaml(self, path: str) -> None:
|
||||
def _load_yaml(
|
||||
self,
|
||||
path: str,
|
||||
target_resolution: Optional[tuple[int, int]] = None,
|
||||
profile_resolution: Optional[tuple[int, int]] = None,
|
||||
) -> None:
|
||||
"""用 yaml.safe_load 加载 YAML 并填充 self.selectors。
|
||||
|
||||
若提供 target_resolution 与 profile_resolution,则对 by_geom 中的
|
||||
绝对偏移 x_offset / y_offset 按分辨率比例缩放(宽高比不变时坐标更接近目标)。
|
||||
|
||||
YAML 结构(每个 key 对应一个 Selector):
|
||||
search_box:
|
||||
by_image: search_box.png # str,可省略
|
||||
@ -159,6 +220,11 @@ class LocatorRegistry:
|
||||
self._load_defaults()
|
||||
return
|
||||
|
||||
scale_x = scale_y = 1.0
|
||||
if target_resolution and profile_resolution:
|
||||
scale_x = target_resolution[0] / profile_resolution[0]
|
||||
scale_y = target_resolution[1] / profile_resolution[1]
|
||||
|
||||
self.selectors.clear()
|
||||
for kind, spec in data.items():
|
||||
if not isinstance(spec, dict):
|
||||
@ -171,8 +237,8 @@ class LocatorRegistry:
|
||||
relative_to=by_geom_raw.get("relative_to", "window"),
|
||||
x_ratio=float(by_geom_raw.get("x_ratio", 0.0)),
|
||||
y_ratio=float(by_geom_raw.get("y_ratio", 0.0)),
|
||||
x_offset=int(by_geom_raw.get("x_offset", 0)),
|
||||
y_offset=int(by_geom_raw.get("y_offset", 0)),
|
||||
x_offset=int(int(by_geom_raw.get("x_offset", 0)) * scale_x),
|
||||
y_offset=int(int(by_geom_raw.get("y_offset", 0)) * scale_y),
|
||||
)
|
||||
self.selectors[kind] = Selector(
|
||||
kind=kind,
|
||||
@ -183,7 +249,8 @@ class LocatorRegistry:
|
||||
description=str(spec.get("description", "")),
|
||||
)
|
||||
logger.info(
|
||||
"[locator] loaded %d selectors from %s", len(self.selectors), path
|
||||
"[locator] loaded %d selectors from %s (scale=%.3fx%.3f)",
|
||||
len(self.selectors), path, scale_x, scale_y,
|
||||
)
|
||||
|
||||
def _default_selector(self, kind: str) -> Selector:
|
||||
@ -201,12 +268,23 @@ class LocatorRegistry:
|
||||
(offset 已含正负号区分方向,详见 Actions._geom_find)。
|
||||
"""
|
||||
defaults = {
|
||||
"session_manager_icon": Selector(
|
||||
kind="session_manager_icon",
|
||||
by_geom=GeomSpec(
|
||||
relative_to="window",
|
||||
x_ratio=0.018,
|
||||
y_ratio=0.109,
|
||||
x_offset=0,
|
||||
y_offset=0,
|
||||
),
|
||||
description="左侧导航栏顶部微信消息/会话管理图标",
|
||||
),
|
||||
"search_box": Selector(
|
||||
kind="search_box",
|
||||
by_geom=GeomSpec(
|
||||
relative_to="window",
|
||||
x_ratio=0.08,
|
||||
y_ratio=0.05,
|
||||
x_ratio=0.092,
|
||||
y_ratio=0.095,
|
||||
x_offset=0,
|
||||
y_offset=0,
|
||||
),
|
||||
@ -215,11 +293,11 @@ class LocatorRegistry:
|
||||
"send_button": Selector(
|
||||
kind="send_button",
|
||||
by_geom=GeomSpec(
|
||||
relative_to="window_bottom_right",
|
||||
x_ratio=1.0,
|
||||
y_ratio=1.0,
|
||||
x_offset=-60,
|
||||
y_offset=-30,
|
||||
relative_to="window",
|
||||
x_ratio=0.972,
|
||||
y_ratio=0.929,
|
||||
x_offset=0,
|
||||
y_offset=0,
|
||||
),
|
||||
description="右下角发送按钮",
|
||||
),
|
||||
@ -227,8 +305,8 @@ class LocatorRegistry:
|
||||
kind="input_box",
|
||||
by_geom=GeomSpec(
|
||||
relative_to="window",
|
||||
x_ratio=0.65,
|
||||
y_ratio=0.93,
|
||||
x_ratio=0.743,
|
||||
y_ratio=0.914,
|
||||
x_offset=0,
|
||||
y_offset=0,
|
||||
),
|
||||
@ -238,8 +316,8 @@ class LocatorRegistry:
|
||||
kind="input_box_empty",
|
||||
by_geom=GeomSpec(
|
||||
relative_to="window",
|
||||
x_ratio=0.65,
|
||||
y_ratio=0.93,
|
||||
x_ratio=0.743,
|
||||
y_ratio=0.914,
|
||||
x_offset=0,
|
||||
y_offset=0,
|
||||
),
|
||||
@ -249,8 +327,8 @@ class LocatorRegistry:
|
||||
kind="search_result_first",
|
||||
by_geom=GeomSpec(
|
||||
relative_to="window",
|
||||
x_ratio=0.08,
|
||||
y_ratio=0.11,
|
||||
x_ratio=0.078,
|
||||
y_ratio=0.174,
|
||||
x_offset=0,
|
||||
y_offset=0,
|
||||
),
|
||||
|
||||
@ -6,7 +6,7 @@ send_text 流程:
|
||||
3. 入队(自适应 delay_ms:同联系人 1000 / 不同 config.send_delay_ms)
|
||||
4. 成功且 verified 才写幂等缓存
|
||||
5. DB 校验失败计数 + 熔断
|
||||
6. 失败时 session_cache.invalidate()
|
||||
6. 失败时 session_cache.invalidate(to_wxid)(仅失效当前联系人)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -14,9 +14,14 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
from collections import OrderedDict
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
from woc_bridge.models import BridgeError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from woc_bridge.ui.flows.send_file import SendFileFlow
|
||||
from woc_bridge.ui.flows.send_text import SendTextFlow
|
||||
from woc_bridge.ui.circuit_breaker import CircuitBreaker, CircuitState
|
||||
from woc_bridge.ui.errors import FlowError, is_retryable
|
||||
from woc_bridge.ui.flow import FlowContext, FlowResult, FlowState
|
||||
@ -29,33 +34,49 @@ _SAME_CONTACT_DELAY_MS = 1000
|
||||
|
||||
|
||||
class SessionCache:
|
||||
"""会话缓存:记录最近打开的会话名,30s TTL。
|
||||
"""会话缓存:LRU 多会话,30s TTL。
|
||||
|
||||
命中时 SendTextFlow 跳过 activate/click_search_box/type_query/open_session 四步,
|
||||
直接校验会话仍打开后进入 focus_input。
|
||||
多会话缓存提升 30-100 并发人群中重复联系人的命中率。
|
||||
"""
|
||||
|
||||
def __init__(self, ttl: float = 30.0) -> None:
|
||||
def __init__(self, ttl: float = 30.0, max_size: int = 16) -> None:
|
||||
self.ttl = ttl
|
||||
self._current: Optional[tuple[str, float]] = None # (to_wxid, expire_at)
|
||||
self.max_size = max_size
|
||||
# wxid -> expire_at,按最近使用排序
|
||||
self._cache: OrderedDict[str, float] = OrderedDict()
|
||||
|
||||
def get(self) -> Optional[str]:
|
||||
"""返回当前缓存的 to_wxid,过期或无缓存返回 None。"""
|
||||
if self._current is None:
|
||||
return None
|
||||
to_wxid, expire_at = self._current
|
||||
def get(self, to_wxid: str) -> bool:
|
||||
"""查询指定 wxid 是否在缓存且未过期。命中时移到队尾(最近使用)。"""
|
||||
expire_at = self._cache.get(to_wxid)
|
||||
if expire_at is None:
|
||||
return False
|
||||
if time.monotonic() >= expire_at:
|
||||
self._current = None
|
||||
return None
|
||||
return to_wxid
|
||||
self._cache.pop(to_wxid, None)
|
||||
return False
|
||||
self._cache.move_to_end(to_wxid)
|
||||
return True
|
||||
|
||||
def set(self, to_wxid: str) -> None:
|
||||
"""更新缓存。"""
|
||||
self._current = (to_wxid, time.monotonic() + self.ttl)
|
||||
self._cache[to_wxid] = time.monotonic() + self.ttl
|
||||
self._cache.move_to_end(to_wxid)
|
||||
# 超限淘汰最久未使用
|
||||
while len(self._cache) > self.max_size:
|
||||
self._cache.popitem(last=False)
|
||||
|
||||
def invalidate(self) -> None:
|
||||
"""失效缓存。"""
|
||||
self._current = None
|
||||
def invalidate(self, to_wxid: Optional[str] = None) -> None:
|
||||
"""失效缓存。
|
||||
|
||||
Args:
|
||||
to_wxid: 为 None 时清除全部缓存;为具体 wxid 时仅清除该条目
|
||||
(传空字符串不会误清全部)。
|
||||
"""
|
||||
if to_wxid is None:
|
||||
self._cache.clear()
|
||||
else:
|
||||
self._cache.pop(to_wxid, None)
|
||||
|
||||
|
||||
class FlowOrchestrator:
|
||||
@ -91,10 +112,11 @@ class FlowOrchestrator:
|
||||
self.actions = actions
|
||||
self.db_reader = db_reader
|
||||
self.config = config
|
||||
self.session_cache = SessionCache(ttl=30.0)
|
||||
# DB 校验熔断器:5 次失败 / 30s 恢复
|
||||
# LRU 多会话缓存:最多 16 个会话,30s TTL
|
||||
self.session_cache = SessionCache(ttl=30.0, max_size=16)
|
||||
# DB 校验熔断器:10 次失败 / 30s 恢复(高并发下更宽容)
|
||||
self.db_verify_breaker = CircuitBreaker(
|
||||
"db_verify", failure_threshold=5, recovery_timeout=30.0
|
||||
"db_verify", failure_threshold=10, recovery_timeout=30.0
|
||||
)
|
||||
self._send_text_flow = send_text_flow
|
||||
self._send_file_flow = send_file_flow
|
||||
@ -153,6 +175,11 @@ class FlowOrchestrator:
|
||||
"""
|
||||
started_at = time.monotonic()
|
||||
|
||||
logger.info(
|
||||
"[orchestrator] send_text START to_wxid=%s content_len=%d display_name=%s client_request_id=%s",
|
||||
to_wxid, len(content), display_name or "(empty)", client_request_id or "(none)",
|
||||
)
|
||||
|
||||
# 1. 幂等检查
|
||||
cached = self.idem_cache.get(
|
||||
"send_text", to_wxid, content, client_request_id
|
||||
@ -182,7 +209,8 @@ class FlowOrchestrator:
|
||||
# 2. DB 校验熔断检查
|
||||
if not self.db_verify_breaker.allow():
|
||||
logger.warning(
|
||||
"[orchestrator] db_verify breaker OPEN, reject send"
|
||||
"[orchestrator] db_verify breaker OPEN, reject send (to_wxid=%s)",
|
||||
to_wxid,
|
||||
)
|
||||
return FlowResult(
|
||||
success=False,
|
||||
@ -193,12 +221,13 @@ class FlowOrchestrator:
|
||||
)
|
||||
|
||||
# 3. 自适应延时:同联系人用短延时
|
||||
cached_session = self.session_cache.get()
|
||||
is_same_contact = (
|
||||
cached_session is not None and cached_session == to_wxid
|
||||
)
|
||||
is_same_contact = self.session_cache.get(to_wxid)
|
||||
delay_ms = _SAME_CONTACT_DELAY_MS if is_same_contact else None
|
||||
# None 表示用 send_queue 的默认 send_delay_ms
|
||||
logger.info(
|
||||
"[orchestrator] send_text enqueue: same_contact=%s delay_ms=%s to_wxid=%s",
|
||||
is_same_contact, delay_ms, to_wxid,
|
||||
)
|
||||
|
||||
# 4. 构造 FlowContext
|
||||
ctx = FlowContext(
|
||||
@ -208,19 +237,21 @@ class FlowOrchestrator:
|
||||
client_request_id=client_request_id,
|
||||
)
|
||||
|
||||
# 5. 入队执行
|
||||
# 5. 入队执行(带队列等待超时,避免无界排队)
|
||||
flow = self._get_send_text_flow()
|
||||
try:
|
||||
result: FlowResult = await self.send_queue.enqueue(
|
||||
lambda: flow.run(ctx),
|
||||
delay_ms=delay_ms,
|
||||
wait_timeout_ms=self.config.send_queue_wait_timeout_ms,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"[orchestrator] send_queue exception: %s", exc
|
||||
"[orchestrator] send_queue exception: %s: %s (to_wxid=%s)",
|
||||
type(exc).__name__, exc, to_wxid,
|
||||
)
|
||||
# 失败时失效会话缓存
|
||||
self.session_cache.invalidate()
|
||||
# 失败时仅失效当前联系人会话缓存,避免误伤其它缓存命中
|
||||
self.session_cache.invalidate(to_wxid)
|
||||
# 保留 BridgeError 的原始错误码(如 RATE_LIMITED),
|
||||
# 避免限流/超时等可重试错误被吞成 SEND_FAILED
|
||||
error_code = "SEND_FAILED"
|
||||
@ -246,18 +277,24 @@ class FlowOrchestrator:
|
||||
"send_text", to_wxid, content, result, client_request_id
|
||||
)
|
||||
self.db_verify_breaker.record_success()
|
||||
logger.info(
|
||||
"[orchestrator] send_text DONE ✓ to_wxid=%s local_id=%s verified=%s (%.0fms)",
|
||||
to_wxid, result.local_id, result.verified,
|
||||
(time.monotonic() - started_at) * 1000,
|
||||
)
|
||||
elif result.success and not result.verified:
|
||||
# 发送成功但校验失败:DB 校验熔断计数
|
||||
self.db_verify_breaker.record_failure()
|
||||
logger.warning(
|
||||
"[orchestrator] send success but verify failed, breaker fail_count+1"
|
||||
"[orchestrator] send success but verify failed, breaker fail_count+1 (to_wxid=%s)",
|
||||
to_wxid,
|
||||
)
|
||||
else:
|
||||
# 发送失败:失效会话缓存
|
||||
self.session_cache.invalidate()
|
||||
# 发送失败:仅失效当前联系人会话缓存,避免误伤其它缓存命中
|
||||
self.session_cache.invalidate(to_wxid)
|
||||
logger.warning(
|
||||
"[orchestrator] send failed: %s (code=%s)",
|
||||
result.error, result.error_code,
|
||||
"[orchestrator] send failed: %s (code=%s, to_wxid=%s)",
|
||||
result.error, result.error_code, to_wxid,
|
||||
)
|
||||
|
||||
return result
|
||||
@ -302,6 +339,11 @@ class FlowOrchestrator:
|
||||
# client_request_id 归一化:None -> ""
|
||||
idem_id = client_request_id or ""
|
||||
|
||||
logger.info(
|
||||
"[orchestrator] send_file START to_wxid=%s file=%s type=%s display_name=%s client_request_id=%s",
|
||||
to_wxid, file_path, file_type, display_name or "(none)", idem_id or "(none)",
|
||||
)
|
||||
|
||||
# 1. 幂等检查
|
||||
cached = self.idem_cache.get("send_file", to_wxid, file_path, idem_id)
|
||||
if cached is not None:
|
||||
@ -327,7 +369,8 @@ class FlowOrchestrator:
|
||||
# 2. DB 校验熔断检查
|
||||
if not self.db_verify_breaker.allow():
|
||||
logger.warning(
|
||||
"[orchestrator] send_file: db_verify breaker OPEN, reject"
|
||||
"[orchestrator] send_file: db_verify breaker OPEN, reject (to_wxid=%s)",
|
||||
to_wxid,
|
||||
)
|
||||
return {
|
||||
"success": False, "verified": False, "placeholder": False,
|
||||
@ -340,19 +383,24 @@ class FlowOrchestrator:
|
||||
flow = self._get_send_file_flow()
|
||||
|
||||
# 4. 入队执行(SendFileFlow.execute 内部完成路径校验 + 文件选择器输入 + post_verify)
|
||||
logger.info(
|
||||
"[orchestrator] send_file enqueue (pending=%d)",
|
||||
self.send_queue.pending_count(),
|
||||
)
|
||||
try:
|
||||
result = await self.send_queue.enqueue(
|
||||
lambda: flow.execute(
|
||||
to_wxid, file_path, file_type, display_name=display_name
|
||||
),
|
||||
wait_timeout_ms=self.config.send_queue_wait_timeout_ms,
|
||||
)
|
||||
except BridgeError as exc:
|
||||
logger.error(
|
||||
"[orchestrator] send_file BridgeError code=%s msg=%s",
|
||||
exc.code, exc.message,
|
||||
"[orchestrator] send_file BridgeError code=%s msg=%s (to_wxid=%s)",
|
||||
exc.code, exc.message, to_wxid,
|
||||
)
|
||||
# 失败时失效会话缓存
|
||||
self.session_cache.invalidate()
|
||||
# 失败时失效当前联系人会话缓存
|
||||
self.session_cache.invalidate(to_wxid)
|
||||
return {
|
||||
"success": False, "verified": False, "placeholder": False,
|
||||
"skipped": False,
|
||||
@ -361,10 +409,10 @@ class FlowOrchestrator:
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"[orchestrator] send_file exception: %s: %s",
|
||||
type(exc).__name__, exc,
|
||||
"[orchestrator] send_file exception: %s: %s (to_wxid=%s)",
|
||||
type(exc).__name__, exc, to_wxid,
|
||||
)
|
||||
self.session_cache.invalidate()
|
||||
self.session_cache.invalidate(to_wxid)
|
||||
return {
|
||||
"success": False, "verified": False, "placeholder": False,
|
||||
"skipped": False,
|
||||
@ -386,12 +434,18 @@ class FlowOrchestrator:
|
||||
self.db_verify_breaker.record_success()
|
||||
# 更新会话缓存(send_file 打开了 to_wxid 会话)
|
||||
self.session_cache.set(to_wxid)
|
||||
logger.info(
|
||||
"[orchestrator] send_file DONE ✓ to=%s file=%s verified=%s (%.0fms)",
|
||||
to_wxid, file_path, verified,
|
||||
(time.monotonic() - started_at) * 1000,
|
||||
)
|
||||
elif success and verified is False:
|
||||
# 发送成功但校验失败(真正的超时/未匹配):DB 校验熔断计数
|
||||
self.db_verify_breaker.record_failure()
|
||||
self.session_cache.set(to_wxid)
|
||||
logger.warning(
|
||||
"[orchestrator] send_file success but verify failed, breaker fail_count+1"
|
||||
"[orchestrator] send_file success but verify failed, breaker fail_count+1 (to_wxid=%s)",
|
||||
to_wxid,
|
||||
)
|
||||
elif success and verified is None:
|
||||
# P0 修复:发送成功但无法校验(DB 不可用或无基线):
|
||||
@ -399,21 +453,17 @@ class FlowOrchestrator:
|
||||
# 也不写幂等缓存(未校验的结果不应被复用)。
|
||||
self.session_cache.set(to_wxid)
|
||||
logger.info(
|
||||
"[orchestrator] send_file success but verify skipped (DB unavailable), breaker untouched"
|
||||
"[orchestrator] send_file success but verify skipped (DB unavailable), breaker untouched (to_wxid=%s)",
|
||||
to_wxid,
|
||||
)
|
||||
else:
|
||||
# 发送失败:失效会话缓存
|
||||
self.session_cache.invalidate()
|
||||
# 发送失败:仅失效当前联系人会话缓存
|
||||
self.session_cache.invalidate(to_wxid)
|
||||
logger.warning(
|
||||
"[orchestrator] send_file failed: %s",
|
||||
result.get("error"),
|
||||
"[orchestrator] send_file failed: %s (to_wxid=%s)",
|
||||
result.get("error"), to_wxid,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[orchestrator] send_file done to=%s file=%s success=%s verified=%s (%.0fms)",
|
||||
to_wxid, file_path, success, verified,
|
||||
(time.monotonic() - started_at) * 1000,
|
||||
)
|
||||
return {
|
||||
"success": success,
|
||||
"verified": verified,
|
||||
|
||||
@ -10,8 +10,8 @@
|
||||
search_box:
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.08
|
||||
y_ratio: 0.05
|
||||
x_ratio: 0.092
|
||||
y_ratio: 0.095
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.80
|
||||
@ -20,11 +20,11 @@ search_box:
|
||||
|
||||
send_button:
|
||||
by_geom:
|
||||
relative_to: window_bottom_right
|
||||
x_ratio: 1.0
|
||||
y_ratio: 1.0
|
||||
x_offset: -60
|
||||
y_offset: -30
|
||||
relative_to: window
|
||||
x_ratio: 0.972
|
||||
y_ratio: 0.929
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 右下角发送按钮
|
||||
@ -32,8 +32,8 @@ send_button:
|
||||
input_box:
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.65
|
||||
y_ratio: 0.93
|
||||
x_ratio: 0.743
|
||||
y_ratio: 0.914
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.80
|
||||
@ -43,8 +43,8 @@ input_box:
|
||||
input_box_empty:
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.65
|
||||
y_ratio: 0.93
|
||||
x_ratio: 0.743
|
||||
y_ratio: 0.914
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.80
|
||||
@ -54,8 +54,8 @@ input_box_empty:
|
||||
search_result_first:
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.08
|
||||
y_ratio: 0.11
|
||||
x_ratio: 0.078
|
||||
y_ratio: 0.174
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.80
|
||||
@ -78,7 +78,7 @@ contact_icon:
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.04
|
||||
y_ratio: 0.15
|
||||
y_ratio: 0.171
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.80
|
||||
@ -88,32 +88,43 @@ contact_icon:
|
||||
new_friend_entry:
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.04
|
||||
y_ratio: 0.22
|
||||
x_ratio: 0.125
|
||||
y_ratio: 0.138
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 通讯录页"新的朋友"入口
|
||||
|
||||
verify_button:
|
||||
friend_request_item:
|
||||
by_geom:
|
||||
relative_to: window_bottom_right
|
||||
x_ratio: 1.0
|
||||
y_ratio: 0.30
|
||||
x_offset: -80
|
||||
relative_to: window
|
||||
x_ratio: 0.125
|
||||
y_ratio: 0.178
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.75
|
||||
require_image: false
|
||||
description: 好友申请项"前往验证"按钮
|
||||
description: '"新的朋友"列表第一条申请项'
|
||||
|
||||
verify_button:
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.593
|
||||
y_ratio: 0.394
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.75
|
||||
require_image: false
|
||||
description: 好友详情页"前往验证"按钮(OpenCV 实测标定)
|
||||
|
||||
confirm_button:
|
||||
by_geom:
|
||||
relative_to: window_bottom_right
|
||||
x_ratio: 0.5
|
||||
y_ratio: 1.0
|
||||
relative_to: window
|
||||
x_ratio: 0.464
|
||||
y_ratio: 0.830
|
||||
x_offset: 0
|
||||
y_offset: -60
|
||||
y_offset: 0
|
||||
threshold: 0.75
|
||||
require_image: false
|
||||
description: "通过朋友验证"弹窗确定按钮
|
||||
description: '"通过朋友验证"弹窗绿色确定按钮(OpenCV 弹窗出现时实测 666/1435, 727/876)'
|
||||
|
||||
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 737 B |
|
After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 229 B After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 164 B After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 251 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 139 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 2.4 KiB |
@ -11,8 +11,8 @@ search_box:
|
||||
by_image: search_box.png
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.08
|
||||
y_ratio: 0.05
|
||||
x_ratio: 0.092
|
||||
y_ratio: 0.095
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.80
|
||||
@ -22,12 +22,11 @@ search_box:
|
||||
send_button:
|
||||
by_image: send_button.png
|
||||
by_geom:
|
||||
relative_to: window_bottom_right
|
||||
x_ratio: 1.0
|
||||
y_ratio: 1.0
|
||||
# 1920x1080 的 -60/-30 按 2/3 缩放为 -40/-20
|
||||
x_offset: -40
|
||||
y_offset: -20
|
||||
relative_to: window
|
||||
x_ratio: 0.972
|
||||
y_ratio: 0.929
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 右下角发送按钮
|
||||
@ -36,8 +35,8 @@ input_box:
|
||||
by_image: input_box.png
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.65
|
||||
y_ratio: 0.93
|
||||
x_ratio: 0.743
|
||||
y_ratio: 0.914
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.80
|
||||
@ -48,8 +47,8 @@ input_box_empty:
|
||||
by_image: input_box_empty.png
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.65
|
||||
y_ratio: 0.93
|
||||
x_ratio: 0.743
|
||||
y_ratio: 0.914
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.80
|
||||
@ -60,8 +59,8 @@ search_result_first:
|
||||
by_image: search_result_first.png
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.08
|
||||
y_ratio: 0.11
|
||||
x_ratio: 0.078
|
||||
y_ratio: 0.174
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.80
|
||||
@ -69,7 +68,6 @@ search_result_first:
|
||||
description: 搜索结果第一项
|
||||
|
||||
main_view:
|
||||
by_image: main_view.png
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.65
|
||||
@ -78,7 +76,7 @@ main_view:
|
||||
y_offset: 0
|
||||
threshold: 0.75
|
||||
require_image: false
|
||||
description: 主聊天视图(post_verify 校验用,阈值略低因区域大)
|
||||
description: 主聊天视图(post_verify 校验用,模板匹配不稳定,使用几何兜底)
|
||||
|
||||
# 好友申请通过 UI 元素定位(experimental,坐标为估算值需实测校准)
|
||||
contact_icon:
|
||||
@ -86,7 +84,7 @@ contact_icon:
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.04
|
||||
y_ratio: 0.15
|
||||
y_ratio: 0.171
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.80
|
||||
@ -97,34 +95,44 @@ new_friend_entry:
|
||||
by_image: new_friend_entry.png
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.04
|
||||
y_ratio: 0.22
|
||||
x_ratio: 0.125
|
||||
y_ratio: 0.138
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 通讯录页"新的朋友"入口
|
||||
|
||||
verify_button:
|
||||
by_image: verify_button.png
|
||||
friend_request_item:
|
||||
by_geom:
|
||||
relative_to: window_bottom_right
|
||||
x_ratio: 1.0
|
||||
y_ratio: 0.30
|
||||
x_offset: -53
|
||||
relative_to: window
|
||||
x_ratio: 0.125
|
||||
y_ratio: 0.178
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.75
|
||||
require_image: false
|
||||
description: 好友申请项"前往验证"按钮
|
||||
description: '"新的朋友"列表第一条申请项(模板匹配不稳定,使用几何兜底)'
|
||||
|
||||
confirm_button:
|
||||
by_image: confirm_button.png
|
||||
verify_button:
|
||||
by_image: verify_button.png
|
||||
by_geom:
|
||||
relative_to: window_bottom_right
|
||||
x_ratio: 0.5
|
||||
y_ratio: 1.0
|
||||
relative_to: window
|
||||
x_ratio: 0.593
|
||||
y_ratio: 0.394
|
||||
x_offset: 0
|
||||
y_offset: -40
|
||||
y_offset: 0
|
||||
threshold: 0.75
|
||||
require_image: false
|
||||
description: '"通过朋友验证"弹窗确定按钮'
|
||||
description: 好友详情页"前往验证"按钮(OpenCV 实测标定)
|
||||
|
||||
confirm_button:
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.464
|
||||
y_ratio: 0.830
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.75
|
||||
require_image: false
|
||||
description: '"通过朋友验证"弹窗绿色确定按钮(OpenCV 弹窗出现时实测 666/1435, 727/876)'
|
||||
|
||||
161
bridge/woc_bridge/ui/profiles/wechat_4.0_1435x876.yaml
Normal file
@ -0,0 +1,161 @@
|
||||
# 微信 4.0 UI 自动化 Profile - 1435x876 分辨率
|
||||
#
|
||||
# 适用场景:VNC/实际桌面分辨率为 1435x876 时运行微信 4.x。
|
||||
# 该分辨率为 bridge 运行环境实测常见值(1920x1080 经标题栏/缩放后约为 1435x876)。
|
||||
# 坐标基于截图标定,relative_to 统一使用 window 语义(与 Actions._geom_find 实现一致)。
|
||||
#
|
||||
# 字段说明:
|
||||
# by_image: 模板文件名(相对 templates/wechat_4.0/light/)
|
||||
# by_geom: 几何兜底规格(图像匹配失败或熔断时使用)
|
||||
# relative_to: window / window_bottom_right(当前统一按 window 处理)
|
||||
# x_ratio / y_ratio: 相对窗口宽高的比例位置
|
||||
# x_offset / y_offset: 绝对像素偏移(含正负号)
|
||||
# threshold: 图像匹配置信度阈值
|
||||
# require_image: true 时图像失败拒绝执行(高风险元素)
|
||||
# description: 元素描述
|
||||
|
||||
# 会话管理图标:左侧导航栏最上方(头像下方第一个)微信消息图标。
|
||||
# 每次发送前点击它,强制回到会话列表首页,清除搜索覆盖层/弹窗/非会话页面。
|
||||
session_manager_icon:
|
||||
by_image: session_manager_icon.png
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.018
|
||||
y_ratio: 0.109
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 左侧导航栏顶部微信消息/会话管理图标
|
||||
|
||||
search_box:
|
||||
by_image: search_box.png
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.092
|
||||
y_ratio: 0.095
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 左侧顶部搜索框
|
||||
|
||||
send_button:
|
||||
by_image: send_button.png
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.972
|
||||
y_ratio: 0.929
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 右下角发送按钮
|
||||
|
||||
input_box:
|
||||
by_image: input_box.png
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.743
|
||||
y_ratio: 0.914
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 聊天输入框
|
||||
|
||||
input_box_empty:
|
||||
by_image: input_box_empty.png
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.743
|
||||
y_ratio: 0.914
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 空输入框灰色提示态(发送成功后校验用)
|
||||
|
||||
search_result_first:
|
||||
by_image: search_result_first.png
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.078
|
||||
y_ratio: 0.174
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 搜索结果第一项
|
||||
|
||||
main_view:
|
||||
by_image: main_view.png
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.65
|
||||
y_ratio: 0.50
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.75
|
||||
require_image: false
|
||||
description: 主聊天视图(post_verify 校验用,阈值略低因区域大)
|
||||
|
||||
# 好友申请通过 UI 元素定位(基于 1435x876 实测标定)
|
||||
contact_icon:
|
||||
by_image: contact_icon.png
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.04
|
||||
y_ratio: 0.171
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 左侧导航栏通讯录图标
|
||||
|
||||
new_friend_entry:
|
||||
by_image: new_friend_entry.png
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.125
|
||||
y_ratio: 0.138
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 通讯录页"新的朋友"入口
|
||||
|
||||
friend_request_item:
|
||||
by_image: friend_request_item.png
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.125
|
||||
y_ratio: 0.178
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.75
|
||||
require_image: false
|
||||
description: '"新的朋友"列表第一条申请项'
|
||||
|
||||
verify_button:
|
||||
by_image: verify_button.png
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.593
|
||||
y_ratio: 0.394
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.75
|
||||
require_image: false
|
||||
description: 好友详情页"前往验证"按钮(OpenCV 实测标定)
|
||||
|
||||
confirm_button:
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.464
|
||||
y_ratio: 0.830
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.75
|
||||
require_image: false
|
||||
description: '"通过朋友验证"弹窗绿色确定按钮(OpenCV 弹窗出现时实测 666/1435, 727/876)'
|
||||
@ -17,8 +17,8 @@ search_box:
|
||||
by_image: search_box.png
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.08
|
||||
y_ratio: 0.05
|
||||
x_ratio: 0.092
|
||||
y_ratio: 0.095
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.80
|
||||
@ -28,11 +28,11 @@ search_box:
|
||||
send_button:
|
||||
by_image: send_button.png
|
||||
by_geom:
|
||||
relative_to: window_bottom_right
|
||||
x_ratio: 1.0
|
||||
y_ratio: 1.0
|
||||
x_offset: -60
|
||||
y_offset: -30
|
||||
relative_to: window
|
||||
x_ratio: 0.972
|
||||
y_ratio: 0.929
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 右下角发送按钮
|
||||
@ -41,8 +41,8 @@ input_box:
|
||||
by_image: input_box.png
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.65
|
||||
y_ratio: 0.93
|
||||
x_ratio: 0.743
|
||||
y_ratio: 0.914
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.80
|
||||
@ -53,8 +53,8 @@ input_box_empty:
|
||||
by_image: input_box_empty.png
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.65
|
||||
y_ratio: 0.93
|
||||
x_ratio: 0.743
|
||||
y_ratio: 0.914
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.80
|
||||
@ -65,8 +65,8 @@ search_result_first:
|
||||
by_image: search_result_first.png
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.08
|
||||
y_ratio: 0.11
|
||||
x_ratio: 0.078
|
||||
y_ratio: 0.174
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.80
|
||||
@ -74,7 +74,6 @@ search_result_first:
|
||||
description: 搜索结果第一项
|
||||
|
||||
main_view:
|
||||
by_image: main_view.png
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.65
|
||||
@ -83,7 +82,7 @@ main_view:
|
||||
y_offset: 0
|
||||
threshold: 0.75
|
||||
require_image: false
|
||||
description: 主聊天视图(post_verify 校验用,阈值略低因区域大)
|
||||
description: 主聊天视图(post_verify 校验用,模板匹配不稳定,使用几何兜底)
|
||||
|
||||
# 好友申请通过 UI 元素定位(experimental,坐标为估算值需实测校准)
|
||||
contact_icon:
|
||||
@ -91,7 +90,7 @@ contact_icon:
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.04
|
||||
y_ratio: 0.15
|
||||
y_ratio: 0.171
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.80
|
||||
@ -102,34 +101,44 @@ new_friend_entry:
|
||||
by_image: new_friend_entry.png
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.04
|
||||
y_ratio: 0.22
|
||||
x_ratio: 0.125
|
||||
y_ratio: 0.138
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.80
|
||||
require_image: false
|
||||
description: 通讯录页"新的朋友"入口
|
||||
|
||||
verify_button:
|
||||
by_image: verify_button.png
|
||||
friend_request_item:
|
||||
by_geom:
|
||||
relative_to: window_bottom_right
|
||||
x_ratio: 1.0
|
||||
y_ratio: 0.30
|
||||
x_offset: -80
|
||||
relative_to: window
|
||||
x_ratio: 0.125
|
||||
y_ratio: 0.178
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.75
|
||||
require_image: false
|
||||
description: 好友申请项"前往验证"按钮
|
||||
description: '"新的朋友"列表第一条申请项(模板匹配不稳定,使用几何兜底)'
|
||||
|
||||
confirm_button:
|
||||
by_image: confirm_button.png
|
||||
verify_button:
|
||||
by_image: verify_button.png
|
||||
by_geom:
|
||||
relative_to: window_bottom_right
|
||||
x_ratio: 0.5
|
||||
y_ratio: 1.0
|
||||
relative_to: window
|
||||
x_ratio: 0.593
|
||||
y_ratio: 0.394
|
||||
x_offset: 0
|
||||
y_offset: -60
|
||||
y_offset: 0
|
||||
threshold: 0.75
|
||||
require_image: false
|
||||
description: '"通过朋友验证"弹窗确定按钮'
|
||||
description: 好友详情页"前往验证"按钮(OpenCV 实测标定)
|
||||
|
||||
confirm_button:
|
||||
by_geom:
|
||||
relative_to: window
|
||||
x_ratio: 0.464
|
||||
y_ratio: 0.830
|
||||
x_offset: 0
|
||||
y_offset: 0
|
||||
threshold: 0.75
|
||||
require_image: false
|
||||
description: '"通过朋友验证"弹窗绿色确定按钮(OpenCV 弹窗出现时实测 666/1435, 727/876)'
|
||||
|
||||
@ -79,6 +79,14 @@ class XdotoolDriver:
|
||||
self._moment._ui_lock = shared_ui_lock
|
||||
self._contact._ui_lock = shared_ui_lock
|
||||
|
||||
# Actions 由 app.py 在初始化阶段回填,供 MessageDriver 复用 profile 比例坐标。
|
||||
self._actions: Optional[Any] = None
|
||||
|
||||
def set_actions(self, actions: Any) -> None:
|
||||
"""注入 L3 Actions,使 MessageDriver 可走 LocatorRegistry + profile。"""
|
||||
self._actions = actions
|
||||
self._message._actions = actions
|
||||
|
||||
# ==================================================================
|
||||
# 委托辅助方法(供内联的 5 个未拆分方法调用子 driver 底层能力)
|
||||
# ==================================================================
|
||||
|
||||
@ -13,7 +13,8 @@ services:
|
||||
environment:
|
||||
- PORT=8080
|
||||
# 新建微信实例时使用的镜像(多架构,amd64/arm64 自动匹配);前缀同样跟随 WOC_IMAGE_PREFIX。
|
||||
- WOC_WECHAT_IMAGE=${WOC_IMAGE_PREFIX:-docker.io/gloridust}/wechat-on-cloud:${WOC_VERSION:-latest}
|
||||
# 可通过 .env 中的 WOC_WECHAT_IMAGE 完全覆盖(本地自构建时使用)。
|
||||
- WOC_WECHAT_IMAGE=${WOC_WECHAT_IMAGE:-${WOC_IMAGE_PREFIX:-docker.io/gloridust}/wechat-on-cloud:${WOC_VERSION:-latest}}
|
||||
# 透传给每个微信实例容器(KasmVNC 基础镜像用它们降权运行)
|
||||
- PUID=${WOC_PUID:-1000}
|
||||
- PGID=${WOC_PGID:-1000}
|
||||
@ -45,6 +46,22 @@ services:
|
||||
# bridge 业务 API 的 M2M 鉴权 token(供外部系统如 ForcePilot 调用 /api/bridge/:id/*)
|
||||
# 留空=仅允许管理员会话鉴权(兼容旧部署);配置后支持 Authorization: Bearer <token> 直连。
|
||||
- WOC_BRIDGE_API_TOKEN=${WOC_BRIDGE_API_TOKEN:-}
|
||||
# bridge 行为与好友自动通过相关配置:面板启动实例时透传给容器内的 bridge 进程。
|
||||
- WOC_BRIDGE_SEND_DELAY_MS=${WOC_BRIDGE_SEND_DELAY_MS:-800}
|
||||
- WOC_BRIDGE_MAX_CALLS_PER_SEC=${WOC_BRIDGE_MAX_CALLS_PER_SEC:-10}
|
||||
- WOC_BRIDGE_MAX_BATCH_SIZE=${WOC_BRIDGE_MAX_BATCH_SIZE:-50}
|
||||
- WOC_BRIDGE_POLL_INTERVAL_MS=${WOC_BRIDGE_POLL_INTERVAL_MS:-2000}
|
||||
- WOC_AUTO_ACCEPT_ENABLED=${WOC_AUTO_ACCEPT_ENABLED:-false}
|
||||
- WOC_AUTO_ACCEPT_ALL=${WOC_AUTO_ACCEPT_ALL:-false}
|
||||
- WOC_AUTO_ACCEPT_WHITELIST_WXIDS=${WOC_AUTO_ACCEPT_WHITELIST_WXIDS:-}
|
||||
- WOC_AUTO_ACCEPT_WHITELIST_NICKNAMES=${WOC_AUTO_ACCEPT_WHITELIST_NICKNAMES:-}
|
||||
- WOC_AUTO_ACCEPT_KEYWORDS=${WOC_AUTO_ACCEPT_KEYWORDS:-}
|
||||
- WOC_AUTO_ACCEPT_BLACKLIST_WXIDS=${WOC_AUTO_ACCEPT_BLACKLIST_WXIDS:-}
|
||||
- WOC_AUTO_ACCEPT_BLACKLIST_NICKNAMES=${WOC_AUTO_ACCEPT_BLACKLIST_NICKNAMES:-}
|
||||
- WOC_AUTO_ACCEPT_ALLOW_SCENES=${WOC_AUTO_ACCEPT_ALLOW_SCENES:-}
|
||||
- WOC_AUTO_ACCEPT_POLL_INTERVAL=${WOC_AUTO_ACCEPT_POLL_INTERVAL:-3}
|
||||
- WOC_UI_BACKEND=${WOC_UI_BACKEND:-flow}
|
||||
- WOC_UI_DEBUG_SHOTS=${WOC_UI_DEBUG_SHOTS:-false}
|
||||
|
||||
volumes:
|
||||
# 面板账号数据(用户、实例元信息、密码哈希)
|
||||
|
||||
@ -10,6 +10,12 @@ ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN set -eux; \
|
||||
# 中文字体(否则界面/消息显示方块)+ 语言环境 + 下载/解压工具
|
||||
# 构建环境可能无法直连 deb.debian.org,优先使用国内镜像源
|
||||
if [ -f /etc/apt/sources.list ]; then \
|
||||
cp /etc/apt/sources.list /etc/apt/sources.list.bak; \
|
||||
sed -i 's|http://deb.debian.org/debian|https://mirrors.tuna.tsinghua.edu.cn/debian|g' /etc/apt/sources.list; \
|
||||
sed -i 's|http://security.debian.org/debian-security|https://mirrors.tuna.tsinghua.edu.cn/debian-security|g' /etc/apt/sources.list; \
|
||||
fi; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
curl ca-certificates locales dpkg \
|
||||
@ -27,6 +33,10 @@ RUN set -eux; \
|
||||
# 微信运行时需要、但官方 deb 未声明的额外库(单独成层,避免动到上面缓存的安装层)。
|
||||
# 微信原生版是 Qt 程序,依赖一组 xcb 平台库;libxcb-cursor0 由 Qt 动态 dlopen,ldd 查不到,需主动装。
|
||||
RUN set -eux; \
|
||||
if [ -f /etc/apt/sources.list ]; then \
|
||||
sed -i 's|http://deb.debian.org/debian|https://mirrors.tuna.tsinghua.edu.cn/debian|g' /etc/apt/sources.list; \
|
||||
sed -i 's|http://security.debian.org/debian-security|https://mirrors.tuna.tsinghua.edu.cn/debian-security|g' /etc/apt/sources.list; \
|
||||
fi; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
libatomic1 \
|
||||
@ -101,6 +111,10 @@ RUN chmod 755 /custom-cont-init.d/02-woc-app
|
||||
COPY docker/woc-ptrace-init.sh /custom-cont-init.d/03-woc-ptrace
|
||||
RUN chmod 755 /custom-cont-init.d/03-woc-ptrace
|
||||
|
||||
# 预创建 X11 Unix socket 目录并设置 sticky 全局可写权限,
|
||||
# 避免 Xvnc 以非 root 用户启动时因 euid != 0 报 _XSERVTransmkdir ERROR。
|
||||
RUN mkdir -p /tmp/.X11-unix && chmod 1777 /tmp/.X11-unix
|
||||
|
||||
# 3000 = HTTP web 客户端, 3001 = HTTPS
|
||||
# ===== 新增:woc-bridge 业务 API 服务 =====
|
||||
# 安装 Python3 + 依赖 + sqlite3(DB 读取用)+ scrot(截图用,供 /api/screenshot 与 /api/login/qr/*)
|
||||
@ -115,7 +129,9 @@ RUN set -eux; \
|
||||
"uvicorn[standard]==0.30.0" \
|
||||
python-multipart==0.0.9 \
|
||||
pillow==10.4.0 \
|
||||
cryptography; \
|
||||
cryptography \
|
||||
opencv-python-headless \
|
||||
numpy; \
|
||||
apt-get clean; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
@ -1,5 +1,35 @@
|
||||
#!/usr/bin/with-contenv bash
|
||||
# 设置 ptrace_scope=0,允许 bridge 进程读取微信进程内存
|
||||
# 用于 SQLCipher 密钥自动提取(key_extractor.py 读 /proc/<pid>/mem)
|
||||
echo 0 > /proc/sys/kernel/yama/ptrace_scope 2>/dev/null || \
|
||||
echo "woc-ptrace: 无法设置 ptrace_scope(可能需要 --privileged 或 SYS_PTRACE capability)" >&2
|
||||
|
||||
PTRACE_SCOPE="/proc/sys/kernel/yama/ptrace_scope"
|
||||
|
||||
# 检查容器是否拥有 SYS_PTRACE capability
|
||||
# 通过 /proc/self/status 中的 CapEff 判断 bit 12 (CAP_SYS_PTRACE)
|
||||
has_sys_ptrace() {
|
||||
local cap_eff
|
||||
cap_eff=$(awk '/^CapEff:/{print $2}' /proc/self/status 2>/dev/null)
|
||||
if [ -z "$cap_eff" ]; then
|
||||
return 1
|
||||
fi
|
||||
# CAP_SYS_PTRACE = 12,掩码 0x1000
|
||||
local mask
|
||||
mask=$((0x${cap_eff} & 0x1000))
|
||||
[ "$mask" -ne 0 ]
|
||||
}
|
||||
|
||||
if [ -e "$PTRACE_SCOPE" ]; then
|
||||
# 文件存在:尝试写入 0
|
||||
if echo 0 > "$PTRACE_SCOPE" 2>/dev/null; then
|
||||
echo "woc-ptrace: ptrace_scope 已设置为 0" >&2
|
||||
else
|
||||
echo "woc-ptrace: 无法写入 ptrace_scope(可能需要 --privileged 或 SYS_PTRACE capability)" >&2
|
||||
fi
|
||||
else
|
||||
# 文件不存在:YAMA 模块未加载(常见于 Docker Desktop WSL2 精简内核)
|
||||
if has_sys_ptrace; then
|
||||
echo "woc-ptrace: 宿主机未启用 YAMA($PTRACE_SCOPE 不存在),但容器已持有 SYS_PTRACE,key 提取仍可尝试" >&2
|
||||
else
|
||||
echo "woc-ptrace: 宿主机未启用 YAMA($PTRACE_SCOPE 不存在),且容器未持有 SYS_PTRACE;自动 key 提取可能不可用" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
@ -188,6 +188,23 @@ function envList(inst: Instance): string[] {
|
||||
const v = process.env[k];
|
||||
if (v) env.push(`${k}=${v}`);
|
||||
}
|
||||
// 好友自动通过与 UI 自动化相关配置透传:.env 中修改后必须重建面板/实例容器生效。
|
||||
for (const k of [
|
||||
'WOC_AUTO_ACCEPT_ENABLED',
|
||||
'WOC_AUTO_ACCEPT_ALL',
|
||||
'WOC_AUTO_ACCEPT_WHITELIST_WXIDS',
|
||||
'WOC_AUTO_ACCEPT_WHITELIST_NICKNAMES',
|
||||
'WOC_AUTO_ACCEPT_KEYWORDS',
|
||||
'WOC_AUTO_ACCEPT_BLACKLIST_WXIDS',
|
||||
'WOC_AUTO_ACCEPT_BLACKLIST_NICKNAMES',
|
||||
'WOC_AUTO_ACCEPT_ALLOW_SCENES',
|
||||
'WOC_AUTO_ACCEPT_POLL_INTERVAL',
|
||||
'WOC_UI_BACKEND',
|
||||
'WOC_UI_DEBUG_SHOTS',
|
||||
]) {
|
||||
const v = process.env[k];
|
||||
if (v) env.push(`${k}=${v}`);
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
|
||||