WechatOnCloud/bridge/woc_bridge/ui/orchestrator.py
Kris c45282f094 feat: 新增好友自动通过、UI自动化能力与多分辨率适配
- 新增登录状态守卫后台任务
- 新增好友申请自动通过规则引擎
- 新增多分辨率UI配置与模板资源
- 新增消息拉取复合游标支持
- 优化发送队列与UI自动化逻辑
- 新增批量发送日志与错误处理
- 优化Docker镜像构建与ptrace初始化
- 新增联系人名称缓存预热
2026-07-17 18:10:31 +08:00

475 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""L5 FlowOrchestrator编排幂等/熔断/会话缓存/队列/Flow。
send_text 流程:
1. 幂等检查(命中返回 skipped=true
2. DB 校验熔断检查OPEN 时返回 BRIDGE_CIRCUITED
3. 入队(自适应 delay_ms同联系人 1000 / 不同 config.send_delay_ms
4. 成功且 verified 才写幂等缓存
5. DB 校验失败计数 + 熔断
6. 失败时 session_cache.invalidate(to_wxid)(仅失效当前联系人)
"""
from __future__ import annotations
import asyncio
import logging
import time
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
from woc_bridge.ui.idem_cache import IdemCache
logger = logging.getLogger("woc-bridge")
# 同联系人发送间隔(毫秒),比默认 send_delay_ms 短
_SAME_CONTACT_DELAY_MS = 1000
class SessionCache:
"""会话缓存LRU 多会话30s TTL。
命中时 SendTextFlow 跳过 activate/click_search_box/type_query/open_session 四步,
直接校验会话仍打开后进入 focus_input。
多会话缓存提升 30-100 并发人群中重复联系人的命中率。
"""
def __init__(self, ttl: float = 30.0, max_size: int = 16) -> None:
self.ttl = ttl
self.max_size = max_size
# wxid -> expire_at按最近使用排序
self._cache: OrderedDict[str, float] = OrderedDict()
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._cache.pop(to_wxid, None)
return False
self._cache.move_to_end(to_wxid)
return True
def set(self, to_wxid: str) -> None:
"""更新缓存。"""
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, 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:
"""L5 Flow 编排器。
整合幂等缓存、熔断器、会话缓存、发送队列、Flow 执行。
"""
def __init__(
self,
send_queue,
idem_cache: IdemCache,
actions,
db_reader,
config,
send_text_flow: Optional[SendTextFlow] = None,
send_file_flow: Optional[SendFileFlow] = None,
) -> None:
"""初始化编排器。
Args:
send_queue: SendQueue 实例
idem_cache: IdemCache 实例
actions: L3 Actions 实例
db_reader: DbReader 实例
config: BridgeConfig 实例(含 send_delay_ms 等)
send_text_flow: 可选的 SendTextFlow 实例(用于测试注入)
send_file_flow: 可选的 SendFileFlow 实例(必须由 app._init_state 注入,
因 SendFileFlow 依赖 XdotoolDriverorchestrator 无法惰性创建)
"""
self.send_queue = send_queue
self.idem_cache = idem_cache
self.actions = actions
self.db_reader = db_reader
self.config = config
# 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=10, recovery_timeout=30.0
)
self._send_text_flow = send_text_flow
self._send_file_flow = send_file_flow
def _get_send_text_flow(self):
"""获取 SendTextFlow 实例(惰性创建或使用注入的)。"""
if self._send_text_flow is not None:
return self._send_text_flow
# 惰性导入避免循环依赖
from woc_bridge.ui.flows.send_text import SendTextFlow
return SendTextFlow(
actions=self.actions,
db_reader=self.db_reader,
session_cache=self.session_cache,
)
def _get_send_file_flow(self):
"""获取 SendFileFlow 实例(必须由 app._init_state 注入)。
SendFileFlow 依赖 XdotoolDriver用于 xdotool type 路径),
orchestrator 未持有 XdotoolDriver 引用,无法惰性创建。
未注入时抛 BridgeError(BRIDGE_INTERNAL_ERROR)。
"""
if self._send_file_flow is not None:
return self._send_file_flow
raise BridgeError(
code="BRIDGE_INTERNAL_ERROR",
message="send_file_flow 未初始化(需在 app._init_state 中注入 SendFileFlow",
)
async def send_text(
self,
to_wxid: str,
content: str,
display_name: str = "",
client_request_id: str = "",
) -> FlowResult:
"""发送文本消息。
流程:
1. 幂等检查
2. DB 校验熔断检查
3. 自适应延时入队
4. 执行 SendTextFlow
5. 成功且 verified 写幂等缓存
6. 失败时失效会话缓存
Args:
to_wxid: 目标 wxid
content: 消息内容
display_name: 显示名(搜索用)
client_request_id: 客户端幂等 ID
Returns:
FlowResult
"""
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
)
if cached is not None:
logger.info(
"[orchestrator] idem cache hit, skip send (to_wxid=%s)",
to_wxid,
)
# 返回缓存的 FlowResult标记 skipped
if isinstance(cached, FlowResult):
return FlowResult(
success=True,
local_id=cached.local_id,
verified=cached.verified,
skipped=True,
state=cached.state,
duration_ms=(time.monotonic() - started_at) * 1000,
)
# 兜底:构造一个 skipped 结果
return FlowResult(
success=True,
skipped=True,
duration_ms=(time.monotonic() - started_at) * 1000,
)
# 2. DB 校验熔断检查
if not self.db_verify_breaker.allow():
logger.warning(
"[orchestrator] db_verify breaker OPEN, reject send (to_wxid=%s)",
to_wxid,
)
return FlowResult(
success=False,
error="db_verify circuit breaker open",
error_code="BRIDGE_CIRCUITED",
state=FlowState.FAILED,
duration_ms=(time.monotonic() - started_at) * 1000,
)
# 3. 自适应延时:同联系人用短延时
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(
to_wxid=to_wxid,
content=content,
display_name=display_name,
client_request_id=client_request_id,
)
# 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: %s (to_wxid=%s)",
type(exc).__name__, exc, to_wxid,
)
# 失败时仅失效当前联系人会话缓存,避免误伤其它缓存命中
self.session_cache.invalidate(to_wxid)
# 保留 BridgeError 的原始错误码(如 RATE_LIMITED
# 避免限流/超时等可重试错误被吞成 SEND_FAILED
error_code = "SEND_FAILED"
if hasattr(exc, "code") and exc.code:
error_code = exc.code
# 传递 details如 retry_after供路由层设置 Retry-After 头
exc_details = None
if hasattr(exc, "details") and exc.details:
exc_details = exc.details
return FlowResult(
success=False,
error=str(exc),
error_code=error_code,
state=FlowState.FAILED,
duration_ms=(time.monotonic() - started_at) * 1000,
details=exc_details,
)
# 6. 结果处理
if result.success and result.verified:
# 成功且 verified写幂等缓存
self.idem_cache.set(
"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 (to_wxid=%s)",
to_wxid,
)
else:
# 发送失败:仅失效当前联系人会话缓存,避免误伤其它缓存命中
self.session_cache.invalidate(to_wxid)
logger.warning(
"[orchestrator] send failed: %s (code=%s, to_wxid=%s)",
result.error, result.error_code, to_wxid,
)
return result
async def send_file(
self,
to_wxid: str,
file_path: str,
file_type: str = "file",
client_request_id: Optional[str] = None,
display_name: Optional[str] = None,
) -> dict:
"""文件发送 Flow 编排:幂等检查 + 熔断 + 入队 + DB 校验。
流程:
1. 幂等检查(命中返回 skipped=True
2. DB 校验熔断检查OPEN 时返回 BRIDGE_CIRCUITED
3. 入队执行 SendFileFlow.execute路径白名单 + 文件选择器输入 + post_verify
4. 成功且 verified 写幂等缓存
5. DB 校验失败计数 + 熔断
6. 失败时失效会话缓存
幂等缓存键:(send_file, to_wxid, file_path, client_request_id)
IdemCache API 仅支持 4 段 keyfile_path 作为 content 槽位传入)
Args:
to_wxid: 目标会话 wxid
file_path: 文件绝对路径(必须在白名单目录内)
file_type: "image" / "file" / "video"(仅用于日志,不影响流程)
client_request_id: 客户端幂等 IDNone 或空字符串表示不参与幂等)
display_name: 用于微信搜索框定位会话的显示名(备注/昵称),
None 时 SendFileFlow 回退到 to_wxid 搜索
Returns:
{"success": bool, "verified": bool, "placeholder": False,
"skipped": bool, "error": Optional[str], "error_code": Optional[str]}
Raises:
BridgeError(BRIDGE_INTERNAL_ERROR): send_file_flow 未注入
"""
started_at = time.monotonic()
# 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:
logger.info(
"[orchestrator] send_file idem cache hit, skip (to_wxid=%s)",
to_wxid,
)
if isinstance(cached, dict):
return {
"success": cached.get("success", True),
"verified": cached.get("verified", False),
"placeholder": False,
"skipped": True,
"error": None,
"error_code": None,
}
# 兜底:构造一个 skipped 结果
return {
"success": True, "verified": False, "placeholder": False,
"skipped": True, "error": None, "error_code": None,
}
# 2. DB 校验熔断检查
if not self.db_verify_breaker.allow():
logger.warning(
"[orchestrator] send_file: db_verify breaker OPEN, reject (to_wxid=%s)",
to_wxid,
)
return {
"success": False, "verified": False, "placeholder": False,
"skipped": False,
"error": "db_verify circuit breaker open",
"error_code": "BRIDGE_CIRCUITED",
}
# 3. 获取 SendFileFlow 实例(未注入时抛 BridgeError
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 (to_wxid=%s)",
exc.code, exc.message, to_wxid,
)
# 失败时失效当前联系人会话缓存
self.session_cache.invalidate(to_wxid)
return {
"success": False, "verified": False, "placeholder": False,
"skipped": False,
"error": exc.message,
"error_code": exc.code,
}
except Exception as exc:
logger.error(
"[orchestrator] send_file exception: %s: %s (to_wxid=%s)",
type(exc).__name__, exc, to_wxid,
)
self.session_cache.invalidate(to_wxid)
return {
"success": False, "verified": False, "placeholder": False,
"skipped": False,
"error": str(exc),
"error_code": "SEND_FAILED",
}
# 5. 结果处理
# SendFileFlow.execute 返回 {"success": bool, "verified": Optional[bool], "error": Optional[str]}
# verified=None 表示无法校验DB 不可用或无基线),不计熔断失败
success = result.get("success", False)
verified = result.get("verified", False)
if success and verified:
# 成功且 verified写幂等缓存 + 熔断成功
self.idem_cache.set(
"send_file", to_wxid, file_path, result, idem_id
)
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 (to_wxid=%s)",
to_wxid,
)
elif success and verified is None:
# P0 修复发送成功但无法校验DB 不可用或无基线):
# 不计熔断失败,避免 DB 不可用时熔断器雪崩阻塞全部发送。
# 也不写幂等缓存(未校验的结果不应被复用)。
self.session_cache.set(to_wxid)
logger.info(
"[orchestrator] send_file success but verify skipped (DB unavailable), breaker untouched (to_wxid=%s)",
to_wxid,
)
else:
# 发送失败:仅失效当前联系人会话缓存
self.session_cache.invalidate(to_wxid)
logger.warning(
"[orchestrator] send_file failed: %s (to_wxid=%s)",
result.get("error"), to_wxid,
)
return {
"success": success,
"verified": verified,
"placeholder": False,
"skipped": False,
"error": result.get("error"),
"error_code": None,
}