616 lines
25 KiB
Python
616 lines
25 KiB
Python
import asyncio
|
||
import gzip
|
||
import logging
|
||
import os
|
||
import weakref
|
||
from typing import Awaitable, Callable, Optional
|
||
|
||
from websockets.legacy.client import WebSocketClientProtocol, connect as websocket_connect
|
||
|
||
from utils import system_logger
|
||
from .protocol import parse_ws_payload
|
||
from .session import DouyinImSession
|
||
|
||
logger = logging.getLogger("douyin_im.ws")
|
||
|
||
MessageHandler = Callable[[dict], Awaitable[None]]
|
||
|
||
|
||
def _safe_frame_metadata(payload: bytes) -> str:
|
||
"""Return non-content protobuf metadata for early connection diagnostics."""
|
||
try:
|
||
from .static import Live_pb2, Response_pb2
|
||
|
||
frame = Live_pb2.PushFrame()
|
||
frame.ParseFromString(payload)
|
||
body = bytes(frame.payload)
|
||
if str(frame.payloadEncoding or "").lower() == "gzip":
|
||
body = gzip.decompress(body)
|
||
response = Response_pb2.Response()
|
||
response.ParseFromString(body)
|
||
fields = [field.name for field, _ in response.body.ListFields()]
|
||
message = str(response.message or response.error_desc or "")[:80]
|
||
return (
|
||
f"service={frame.service} method={frame.method} "
|
||
f"encoding={frame.payloadEncoding or 'none'} "
|
||
f"type={frame.payloadType or 'none'} payload_bytes={len(body)} "
|
||
f"cmd={response.cmd} body={','.join(fields) or 'none'} "
|
||
f"status={message or 'ok'}"
|
||
)
|
||
except Exception as exc:
|
||
return f"metadata_unavailable={type(exc).__name__}"
|
||
|
||
# Both stages are finite. The transport queue gives the receive coroutine a
|
||
# small amount of breathing room, while the application queue decouples Pong /
|
||
# frame reads from potentially slow database and reply work. Once both fill,
|
||
# backpressure intentionally reaches TCP instead of allocating more tasks.
|
||
_TRANSPORT_MAX_QUEUE = 4
|
||
_APPLICATION_QUEUE_SIZE = 8
|
||
_INCOMING_MAX_SIZE = 2**20
|
||
_STABLE_CONNECTION_SECONDS = 60.0
|
||
_MAX_RECONNECT_BASE_SECONDS = 60.0
|
||
_CLOSE_GRACE_SECONDS = 2.0
|
||
_PING_TIMEOUT_SECONDS = 120.0
|
||
_HANDLER_CONCURRENCY_ENV = "KEFU_WS_HANDLER_CONCURRENCY"
|
||
_SYSTEM_LOG_THROTTLE_ENV = "KEFU_WS_SYSTEM_LOG_THROTTLE_SECONDS"
|
||
|
||
|
||
def _env_int_clamped(name: str, default: int, minimum: int, maximum: int) -> int:
|
||
try:
|
||
value = int(os.getenv(name, str(default)) or default)
|
||
except (TypeError, ValueError):
|
||
value = default
|
||
return max(minimum, min(maximum, value))
|
||
|
||
|
||
def _env_float_clamped(
|
||
name: str,
|
||
default: float,
|
||
minimum: float,
|
||
maximum: float,
|
||
) -> float:
|
||
try:
|
||
value = float(os.getenv(name, str(default)) or default)
|
||
except (TypeError, ValueError):
|
||
value = default
|
||
return max(minimum, min(maximum, value))
|
||
|
||
|
||
def _handler_concurrency_limit() -> int:
|
||
# SQLite serializes writes. Eight allows unrelated parsing / reads to
|
||
# progress without letting an accidental value such as 500 recreate the
|
||
# original event-loop and database stampede.
|
||
return _env_int_clamped(_HANDLER_CONCURRENCY_ENV, 8, 1, 32)
|
||
|
||
|
||
def _system_log_throttle_seconds() -> float:
|
||
return _env_float_clamped(_SYSTEM_LOG_THROTTLE_ENV, 300.0, 10.0, 3600.0)
|
||
|
||
|
||
class _LoopWsState:
|
||
"""Shared limits for all WS clients owned by one asyncio event loop."""
|
||
|
||
def __init__(self) -> None:
|
||
self.handler_slots = asyncio.Semaphore(_handler_concurrency_limit())
|
||
self.system_log_last_at: dict[tuple[int, str], float] = {}
|
||
|
||
|
||
# asyncio synchronization primitives belong to their creating event loop.
|
||
# Keeping one weakly-keyed state per loop gives production a process-wide
|
||
# limit while keeping isolated test loops and uncommon threaded loops safe.
|
||
_LOOP_STATES: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, _LoopWsState]" = (
|
||
weakref.WeakKeyDictionary()
|
||
)
|
||
|
||
|
||
# frontier 按 device_id 寻址推送:两个托管账号共用同一个设备号时,两条长连接会
|
||
# 订阅到同一个地址并互相收到对方的私信。真正的拦截在 service 的会话归属校验里,
|
||
# 这里只负责把「为什么会串号」明确告诉用户。持弱引用,账号停管后自动失效。
|
||
_FRONTIER_DEVICE_OWNERS: "dict[str, weakref.ref[DouyinImWsClient]]" = {}
|
||
|
||
|
||
def _get_loop_state() -> _LoopWsState:
|
||
loop = asyncio.get_running_loop()
|
||
state = _LOOP_STATES.get(loop)
|
||
if state is None:
|
||
state = _LoopWsState()
|
||
_LOOP_STATES[loop] = state
|
||
return state
|
||
|
||
|
||
def _reconnect_delay(account_id: int | None, retry: int) -> float:
|
||
"""Return exponential backoff with stable, account-specific full jitter.
|
||
|
||
A connection that is accepted and immediately closed is still a failed
|
||
attempt. The old client reset its retry counter whenever ``run_forever``
|
||
returned normally, which kept those accounts reconnecting every 2-7s.
|
||
This delay reaches a 60-90s range after repeated short-lived connections.
|
||
"""
|
||
|
||
attempt = max(1, int(retry or 1))
|
||
base = min(_MAX_RECONNECT_BASE_SECONDS, float(2 ** min(attempt, 6)))
|
||
# Spread later retries across half of the base interval. Keep at least a
|
||
# five-second spread on early retries so a shared outage doesn't reconnect
|
||
# every account in the same instant.
|
||
spread = max(5.0, base / 2.0)
|
||
# Keep one account's fraction stable across attempts. This preserves the
|
||
# exponential ordering while different accounts remain spread apart.
|
||
seed = (int(account_id or 0) * 2654435761) & 0xFFFFFFFF
|
||
fraction = (seed % 10000) / 10000.0
|
||
return base + (spread * fraction)
|
||
|
||
|
||
class DouyinImWsClient:
|
||
"""Async frontier-im WebSocket client with bounded message backpressure."""
|
||
|
||
def __init__(
|
||
self,
|
||
session: DouyinImSession,
|
||
on_message: MessageHandler,
|
||
account_id: int | None = None,
|
||
):
|
||
self.session = session
|
||
self.on_message = on_message
|
||
self.account_id = account_id
|
||
self._running = False
|
||
self.connected = False
|
||
self._task: Optional[asyncio.Task] = None
|
||
self._connection: Optional[WebSocketClientProtocol] = None
|
||
self._last_connection_lifetime = 0.0
|
||
self._message_queue: Optional[asyncio.Queue[dict]] = None
|
||
self._dispatcher_task: Optional[asyncio.Task] = None
|
||
self._received_frame_count = 0
|
||
self._heartbeat_ack_logged = False
|
||
self._frontier_device_id = ""
|
||
self._blocked_device_owner_id: Optional[int] = None
|
||
|
||
async def start(self):
|
||
if self._task and not self._task.done():
|
||
return
|
||
url = self.session.frontier_ws_url()
|
||
if not url:
|
||
logger.warning("No frontier WebSocket URL captured; WS listener disabled")
|
||
system_logger.record(
|
||
"实时接收未启用:未获取到 frontier WebSocket 地址",
|
||
detail="缺少有效的 device_id 或 sessionid,无法建立实时私信通道,将仅依赖 HTTP 轮询。",
|
||
level="warning",
|
||
category="ws",
|
||
account_id=self.account_id,
|
||
)
|
||
return
|
||
self._running = True
|
||
self._ensure_dispatcher()
|
||
self._task = asyncio.create_task(
|
||
self._run_loop(url),
|
||
name=f"im-ws-{self.account_id or 'na'}",
|
||
)
|
||
|
||
async def stop(self):
|
||
self._running = False
|
||
self.connected = False
|
||
|
||
connection = self._connection
|
||
if connection is not None:
|
||
try:
|
||
await asyncio.wait_for(
|
||
connection.close(code=1000, reason="client stopping"),
|
||
timeout=_CLOSE_GRACE_SECONDS,
|
||
)
|
||
except asyncio.TimeoutError:
|
||
# Shutdown iterates over every hosted account. One broken
|
||
# peer must not consume close_timeout repeatedly and turn a
|
||
# 500-account shutdown into a many-minute operation.
|
||
logger.debug("Timed out closing IM WebSocket; aborting transport")
|
||
try:
|
||
connection.fail_connection()
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
logger.debug("Failed to close IM WebSocket cleanly", exc_info=True)
|
||
|
||
task = self._task
|
||
if task and task is not asyncio.current_task() and not task.done():
|
||
task.cancel()
|
||
try:
|
||
await task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
if self._task is task:
|
||
self._task = None
|
||
self._connection = None
|
||
self._release_frontier_device()
|
||
await self._stop_dispatcher()
|
||
|
||
def _record_connection_system_event(
|
||
self,
|
||
event_key: str,
|
||
message: str,
|
||
*,
|
||
detail: str,
|
||
level: str,
|
||
) -> bool:
|
||
"""Persist at most one repeated lifecycle event per account/window."""
|
||
|
||
loop = asyncio.get_running_loop()
|
||
state = _get_loop_state()
|
||
key = (int(self.account_id or 0), event_key)
|
||
now = loop.time()
|
||
last_at = state.system_log_last_at.get(key)
|
||
if last_at is not None and now - last_at < _system_log_throttle_seconds():
|
||
return False
|
||
state.system_log_last_at[key] = now
|
||
system_logger.record(
|
||
message,
|
||
detail=detail,
|
||
level=level,
|
||
category="ws",
|
||
account_id=self.account_id,
|
||
)
|
||
return True
|
||
|
||
def _reset_connection_system_log_throttle(self) -> None:
|
||
loop = asyncio.get_running_loop()
|
||
state = _LOOP_STATES.get(loop)
|
||
if state is None:
|
||
return
|
||
account_key = int(self.account_id or 0)
|
||
state.system_log_last_at.pop((account_key, "connected"), None)
|
||
state.system_log_last_at.pop((account_key, "retry"), None)
|
||
state.system_log_last_at.pop((account_key, "device_taken"), None)
|
||
|
||
def _ensure_dispatcher(self) -> None:
|
||
if self._dispatcher_task and not self._dispatcher_task.done():
|
||
return
|
||
if self._message_queue is None:
|
||
self._message_queue = asyncio.Queue(maxsize=_APPLICATION_QUEUE_SIZE)
|
||
self._dispatcher_task = asyncio.create_task(
|
||
self._dispatch_loop(),
|
||
name=f"im-ws-dispatch-{self.account_id or 'na'}",
|
||
)
|
||
|
||
async def _stop_dispatcher(self) -> None:
|
||
task = self._dispatcher_task
|
||
self._dispatcher_task = None
|
||
if task and task is not asyncio.current_task() and not task.done():
|
||
task.cancel()
|
||
try:
|
||
await task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
|
||
queue = self._message_queue
|
||
self._message_queue = None
|
||
if queue is not None:
|
||
# Dropped messages must decrement the unfinished counter so tests,
|
||
# diagnostics, and a later restart can never hang on queue.join().
|
||
while True:
|
||
try:
|
||
queue.get_nowait()
|
||
except asyncio.QueueEmpty:
|
||
break
|
||
else:
|
||
queue.task_done()
|
||
|
||
async def _prepare_url(self, fallback_url: str) -> str:
|
||
from .frontier import ensure_frontier_ws
|
||
from .traffic_control import get_traffic_controller
|
||
|
||
# Frontier discovery can perform synchronous signing / HTTP work. It
|
||
# remains in the shared background lane and off the FastAPI event loop.
|
||
controller = get_traffic_controller()
|
||
async with controller.background_slot(
|
||
self.account_id or 0,
|
||
"websocket prepare",
|
||
):
|
||
await asyncio.to_thread(ensure_frontier_ws, self.session)
|
||
return self.session.frontier_ws_url() or fallback_url
|
||
|
||
async def _run_loop(self, initial_url: str):
|
||
retry = 0
|
||
first_attempt = True
|
||
while self._running:
|
||
self._last_connection_lifetime = 0.0
|
||
try:
|
||
# Startup validation already prepared the captured URL. Avoid
|
||
# repeating signing / frontier discovery for all 500 accounts
|
||
# on their first connect; refresh only after a disconnect.
|
||
if first_attempt and initial_url:
|
||
connect_url = initial_url
|
||
else:
|
||
connect_url = await self._prepare_url(initial_url)
|
||
first_attempt = False
|
||
if not connect_url:
|
||
raise RuntimeError("frontier WebSocket URL is unavailable")
|
||
if self._claim_frontier_device(connect_url):
|
||
logger.info("Connecting IM WebSocket: %s...", connect_url[:100])
|
||
await self._run_connection(connect_url)
|
||
else:
|
||
# 设备号已被另一个在跑的账号占用:绝不并连同一个推送地址,
|
||
# 本账号本轮退回 HTTP 轮询兜底(connected 保持 False,
|
||
# service 会自动切到更快的会话对账节奏),并在退避后重试,
|
||
# 等占用方停管时自动接管。
|
||
self._report_frontier_device_taken(connect_url)
|
||
except asyncio.CancelledError:
|
||
break
|
||
except Exception as exc:
|
||
if self._running:
|
||
logger.warning("IM WebSocket error: %s", exc)
|
||
self._record_connection_system_event(
|
||
"retry",
|
||
"实时接收连接异常",
|
||
detail=f"建立 frontier WebSocket 失败:{exc}",
|
||
level="error",
|
||
)
|
||
|
||
# Only a genuinely stable connection earns a retry reset. A
|
||
# successful handshake followed by an immediate normal close must
|
||
# continue exponential backoff rather than reconnect forever at
|
||
# the first delay.
|
||
if self._last_connection_lifetime >= _STABLE_CONNECTION_SECONDS:
|
||
retry = 0
|
||
if not self._running:
|
||
break
|
||
|
||
retry += 1
|
||
wait = _reconnect_delay(self.account_id, retry)
|
||
logger.info("IM WebSocket reconnect in %.1fs...", wait)
|
||
self._record_connection_system_event(
|
||
"retry",
|
||
f"实时接收断开,{wait:.1f}s 后重连",
|
||
detail="frontier WebSocket 连接已断开,正在自动重连。",
|
||
level="warning",
|
||
)
|
||
try:
|
||
await asyncio.sleep(wait)
|
||
except asyncio.CancelledError:
|
||
break
|
||
|
||
self._release_frontier_device()
|
||
|
||
def _frontier_device_owner(self, device_id: str) -> "Optional[DouyinImWsClient]":
|
||
"""当前仍活着的设备号占用方(run 循环任务还在跑才算数)。"""
|
||
reference = _FRONTIER_DEVICE_OWNERS.get(device_id)
|
||
owner = reference() if reference is not None else None
|
||
if owner is None or owner is self:
|
||
return None
|
||
task = owner._task
|
||
if not owner._running or task is None or task.done():
|
||
return None
|
||
return owner
|
||
|
||
def _claim_frontier_device(self, url: str) -> bool:
|
||
"""独占本账号的 frontier 设备地址;已被别的账号占用时返回 False。
|
||
|
||
frontier 按 device_id 寻址推送。两个账号共用同一个设备号时,同时建连
|
||
会让两条连接互相收到对方的私信(串号的根因),且抖音也可能只保留最后
|
||
一条连接、把先连上的那个账号踢成「连着但收不到」。所以同一个设备地址
|
||
永远只允许一个账号建连,另一个账号走 HTTP 轮询兜底。
|
||
"""
|
||
from .frontier import ws_device_id
|
||
|
||
device_id = ws_device_id(url)
|
||
if not device_id:
|
||
# 判不出设备号(自建地址/异常格式)时不阻断连接,交给会话归属校验兜底。
|
||
return True
|
||
owner = self._frontier_device_owner(device_id)
|
||
if owner is not None and int(owner.account_id or 0) != int(self.account_id or 0):
|
||
self._blocked_device_owner_id = owner.account_id
|
||
return False
|
||
_FRONTIER_DEVICE_OWNERS[device_id] = weakref.ref(self)
|
||
self._frontier_device_id = device_id
|
||
self._blocked_device_owner_id = None
|
||
return True
|
||
|
||
def _report_frontier_device_taken(self, url: str) -> None:
|
||
from .frontier import ws_device_id
|
||
|
||
device_id = ws_device_id(url)
|
||
owner_id = self._blocked_device_owner_id
|
||
logger.error(
|
||
"Account %s cannot open frontier device_id %s: already held by "
|
||
"account %s; falling back to HTTP polling this round",
|
||
self.account_id,
|
||
device_id,
|
||
owner_id,
|
||
)
|
||
self._record_connection_system_event(
|
||
"device_taken",
|
||
"实时接收已让出:与另一个账号共用长连接设备号",
|
||
detail=(
|
||
f"本账号与账号 {owner_id} 的 frontier 设备号相同(device_id={device_id})。"
|
||
"同一个设备地址只允许一个账号建立长连接,否则两个账号会互相收到对方的"
|
||
"私信。本账号本轮不建连,改由 HTTP 会话轮询接收(有几十秒级延迟),"
|
||
"并在对方停止托管后自动接管。"
|
||
"根治办法:为每个账号在独立的浏览器配置/设备上重新采集凭证。"
|
||
),
|
||
level="error",
|
||
)
|
||
|
||
def _release_frontier_device(self) -> None:
|
||
device_id = self._frontier_device_id
|
||
self._frontier_device_id = ""
|
||
if not device_id:
|
||
return
|
||
reference = _FRONTIER_DEVICE_OWNERS.get(device_id)
|
||
if reference is not None and reference() is self:
|
||
_FRONTIER_DEVICE_OWNERS.pop(device_id, None)
|
||
|
||
def _connection_headers(self) -> list[tuple[str, str]]:
|
||
headers = [
|
||
("Pragma", "no-cache"),
|
||
("Cache-Control", "no-cache"),
|
||
("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8"),
|
||
]
|
||
cookie = self.session.cookie_header()
|
||
if cookie:
|
||
headers.append(("Cookie", cookie))
|
||
return headers
|
||
|
||
@staticmethod
|
||
def _uses_browser_frontier(url: str) -> bool:
|
||
return "zijieapi.com" in url and "access_key=" in url
|
||
|
||
async def _run_browser_heartbeat(self, websocket) -> None:
|
||
"""Mirror Frontier's browser SDK application-level ``hi`` heartbeat."""
|
||
while self._running:
|
||
await websocket.send("hi")
|
||
await asyncio.sleep(30)
|
||
|
||
async def _run_connection(self, url: str) -> None:
|
||
"""Open one connection and dispatch messages sequentially.
|
||
|
||
``max_queue`` bounds the library's receive buffer and ``_dispatch``
|
||
feeds one lifecycle-owned, bounded application queue. This receive
|
||
loop therefore remains responsive to control frames during ordinary
|
||
database stalls without creating one task per incoming frame.
|
||
"""
|
||
|
||
loop = asyncio.get_running_loop()
|
||
connected_at: float | None = None
|
||
connection: Optional[WebSocketClientProtocol] = None
|
||
heartbeat_task: Optional[asyncio.Task] = None
|
||
browser_frontier = self._uses_browser_frontier(url)
|
||
source_ip = str(getattr(self.session, "egress_source_ip", "") or "").strip()
|
||
connect_kwargs = {"local_addr": (source_ip, 0)} if source_ip else {}
|
||
try:
|
||
async with websocket_connect(
|
||
url,
|
||
origin="https://www.douyin.com",
|
||
subprotocols=["binary", "base64", "pbbp2"],
|
||
extra_headers=self._connection_headers(),
|
||
user_agent_header=self.session.user_agent,
|
||
compression="deflate",
|
||
open_timeout=10,
|
||
# The current Douyin browser Frontier SDK uses a text ``hi``
|
||
# heartbeat instead of RFC WebSocket ping frames.
|
||
ping_interval=None if browser_frontier else 20,
|
||
# A handler may legitimately wait up to SQLite's 30s busy
|
||
# timeout. Leave enough headroom for queued work so a healthy
|
||
# socket isn't mistaken for a dead peer during that stall.
|
||
ping_timeout=_PING_TIMEOUT_SECONDS,
|
||
close_timeout=3,
|
||
# Frontier frames contain metadata and media URLs rather than
|
||
# media bytes. A finite frame limit plus a finite queue makes
|
||
# receive memory genuinely bounded across hundreds of peers.
|
||
max_size=_INCOMING_MAX_SIZE,
|
||
max_queue=_TRANSPORT_MAX_QUEUE,
|
||
**connect_kwargs,
|
||
) as websocket:
|
||
connection = websocket
|
||
self._connection = websocket
|
||
connected_at = loop.time()
|
||
self.connected = True
|
||
logger.info(
|
||
"IM WebSocket connected: subprotocol=%s",
|
||
getattr(websocket, "subprotocol", None) or "none",
|
||
)
|
||
self._record_connection_system_event(
|
||
"connected",
|
||
"实时接收通道已连接",
|
||
detail="frontier WebSocket 已建立,可实时接收私信。",
|
||
level="success",
|
||
)
|
||
|
||
if browser_frontier:
|
||
heartbeat_task = asyncio.create_task(
|
||
self._run_browser_heartbeat(websocket),
|
||
name=f"im-ws-heartbeat-{self.account_id or 'na'}",
|
||
)
|
||
try:
|
||
async for raw in websocket:
|
||
if not self._running:
|
||
break
|
||
await self._dispatch(raw)
|
||
finally:
|
||
if heartbeat_task and not heartbeat_task.done():
|
||
heartbeat_task.cancel()
|
||
try:
|
||
await heartbeat_task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
finally:
|
||
if connected_at is not None:
|
||
self._last_connection_lifetime = max(0.0, loop.time() - connected_at)
|
||
if self._last_connection_lifetime >= _STABLE_CONNECTION_SECONDS:
|
||
# A genuinely healthy session starts a new lifecycle. Its
|
||
# next outage should be visible immediately rather than
|
||
# hidden by an old retry window.
|
||
self._reset_connection_system_log_throttle()
|
||
self.connected = False
|
||
if self._connection is connection:
|
||
self._connection = None
|
||
|
||
if connection is not None:
|
||
code = connection.close_code
|
||
reason = connection.close_reason
|
||
logger.info("IM WebSocket closed: code=%s, msg=%s", code, reason)
|
||
if self._running:
|
||
self._record_connection_system_event(
|
||
"retry",
|
||
"实时接收通道关闭",
|
||
detail=f"code={code}, msg={reason}",
|
||
level="warning",
|
||
)
|
||
|
||
async def _dispatch(self, raw):
|
||
if raw == "hi":
|
||
if not self._heartbeat_ack_logged:
|
||
logger.info("IM WebSocket application heartbeat acknowledged")
|
||
self._heartbeat_ack_logged = True
|
||
return
|
||
self._ensure_dispatcher()
|
||
queue = self._message_queue
|
||
if queue is None:
|
||
return
|
||
if isinstance(raw, str):
|
||
payload = raw.encode("utf-8", errors="ignore")
|
||
else:
|
||
payload = raw
|
||
items = parse_ws_payload(payload)
|
||
self._received_frame_count += 1
|
||
if self._received_frame_count <= 3:
|
||
metadata = _safe_frame_metadata(payload) if not items else "parsed-message"
|
||
logger.info(
|
||
"IM WebSocket frame received: seq=%d kind=%s bytes=%d parsed=%d %s",
|
||
self._received_frame_count,
|
||
"text" if isinstance(raw, str) else "binary",
|
||
len(payload),
|
||
len(items),
|
||
metadata,
|
||
)
|
||
for item in items:
|
||
if not self._running:
|
||
return
|
||
await queue.put(item)
|
||
|
||
async def _dispatch_loop(self) -> None:
|
||
queue = self._message_queue
|
||
if queue is None:
|
||
return
|
||
handler_slots = _get_loop_state().handler_slots
|
||
while True:
|
||
item = await queue.get()
|
||
try:
|
||
if self._running:
|
||
# Every account owns one dispatcher, preserving its FIFO.
|
||
# The shared semaphore prevents 500 dispatchers from
|
||
# entering SQLite / reply work at the same instant. A
|
||
# dispatcher waiting here is directly cancellable by
|
||
# stop(); no detached per-message task is created.
|
||
async with handler_slots:
|
||
if self._running:
|
||
await self.on_message(item)
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as exc:
|
||
logger.debug("WS message handler error: %s", exc)
|
||
system_logger.record(
|
||
"实时消息处理失败",
|
||
detail=f"处理收到的私信时出错:{exc}",
|
||
level="error",
|
||
category="recv",
|
||
account_id=self.account_id,
|
||
)
|
||
finally:
|
||
queue.task_done()
|