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

221 lines
9.0 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.

"""发送串行化队列。
所有 xdotool 操作经此队列串行执行,避免并发 UI 操作冲突。
内部维护最近 1 秒调用时间戳用于限流。
"""
from __future__ import annotations
import asyncio
import logging
import time
from typing import Any, Awaitable, Callable, Optional
from woc_bridge.models import BridgeError
logger = logging.getLogger("woc-bridge")
# 工厂类型:返回一个待执行的 coroutine
CoroFactory = Callable[[], Awaitable[Any]]
class SendQueue:
"""串行化发送队列 + 限流。
通过 asyncio.Queue 串行执行所有发送任务;执行间隔可配置
(默认 3000ms单实例每秒调用上限可配置默认 10
"""
def __init__(
self,
send_delay_ms: int = 3000,
max_calls_per_sec: int = 10,
max_queue_size: int = 100,
) -> None:
"""初始化队列配置。
Args:
send_delay_ms: 两次发送之间的最小间隔毫秒
max_calls_per_sec: 每秒最大调用次数
max_queue_size: 队列最大深度,满时入队抛 RATE_LIMITED
"""
self.send_delay_ms = send_delay_ms
self.max_calls_per_sec = max_calls_per_sec
self._queue: asyncio.Queue[tuple[CoroFactory, asyncio.Future, Optional[int]]] = asyncio.Queue(maxsize=max_queue_size)
self._worker: asyncio.Task | None = None
# 最近 1 秒内的调用时间戳
self._recent_call_times: list[float] = []
async def start(self) -> None:
"""启动 worker task。"""
if self._worker is None or self._worker.done():
self._worker = asyncio.create_task(self._run())
async def stop(self) -> None:
"""取消 worker。"""
if self._worker is not None and not self._worker.done():
self._worker.cancel()
try:
await self._worker
except asyncio.CancelledError:
pass
self._worker = None
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: 限流命中、队列满或等待超时时抛错;
任务执行抛出的异常会透传给调用方
"""
loop = asyncio.get_running_loop()
future: asyncio.Future = loop.create_future()
# 队列满时立即拒绝,避免无界增长 OOM
if self._queue.maxsize > 0 and self._queue.qsize() >= self._queue.maxsize:
logger.warning(
"send_queue: 队列已满 (size=%d/%d),拒绝入队",
self._queue.qsize(), self._queue.maxsize,
)
raise BridgeError(
code="RATE_LIMITED",
message=f"发送队列已满({self._queue.qsize()}/{self._queue.maxsize}),请稍后重试",
details={"retry_after": 3},
)
await self._queue.put((coro_factory, future, delay_ms))
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()
# 取消 futureworker 出队时发现 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:
"""返回当前队列中待执行任务数(供 /api/status 暴露给客户端做退避决策)。"""
return self._queue.qsize()
def _check_rate_limit(self) -> None:
"""检查限流。
清理 1 秒前的时间戳,若当前已满 max_calls_per_sec 则抛
BridgeError(RATE_LIMITED),并在 details 中携带 retry_after 秒数
供上层设置 Retry-After 响应头。
"""
now = time.monotonic()
# 清理 1 秒前的时间戳
self._recent_call_times = [t for t in self._recent_call_times if now - t < 1.0]
if len(self._recent_call_times) >= self.max_calls_per_sec:
# 计算建议等待秒数:最早一次调用距窗口边界还差多久
oldest = self._recent_call_times[0]
retry_after = max(1, int(1.0 - (now - oldest)) + 1)
logger.warning(
"send_queue: 限流命中,拒绝执行 (recent=%d/%d, retry_after=%ds)",
len(self._recent_call_times), self.max_calls_per_sec, retry_after,
)
raise BridgeError(
code="RATE_LIMITED",
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 主循环。
循环取出任务执行;执行前检查限流(超限则失败该任务);
执行前记录开始时间戳(避免长任务导致 1 秒窗口内超限);
执行后 sleep delay_ms/1000自定义延时优先于默认 send_delay_ms
"""
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
t_exec = time.perf_counter()
try:
# 执行前检查限流
self._check_rate_limit()
# 记录开始时间戳(限流窗口基于开始时刻,避免长任务后窗口偏移)
self._recent_call_times.append(time.monotonic())
executed = True
# 执行任务
logger.info("send_queue: 开始执行任务")
result = await coro_factory()
logger.info(
"send_queue: 任务执行完成 (%.0fms)",
(time.perf_counter() - t_exec) * 1000,
)
if not future.done():
future.set_result(result)
except asyncio.CancelledError:
# worker 被取消时,把取消传播给等待的调用方
if not future.done():
future.cancel()
raise
except Exception as e:
logger.warning(
"send_queue: 任务执行抛异常 %s: %s (%.0fms)",
type(e).__name__, e, (time.perf_counter() - t_exec) * 1000,
)
if not future.done():
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(
"send_queue: 延时 %dms 后处理下一个",
delay,
)
await asyncio.sleep(delay / 1000.0)