更新
This commit is contained in:
@@ -737,7 +737,7 @@ class DouyinImHttpClient:
|
||||
pass
|
||||
return total
|
||||
|
||||
async def get_conversations(self) -> list[dict]:
|
||||
async def get_conversations(self, *, enrich_profiles: bool = True) -> list[dict]:
|
||||
"""拉取会话列表,返回标准化会话"""
|
||||
payloads = [
|
||||
{"cursor": 0, "count": 50, "inbox_type": 0},
|
||||
@@ -754,7 +754,12 @@ class DouyinImHttpClient:
|
||||
if data is None:
|
||||
data = await self._request("GET", "/v1/conversation/list", body)
|
||||
if data is None:
|
||||
continue
|
||||
# Payload variants only help with schema compatibility. They
|
||||
# cannot repair a network outage, so stop after POST + GET
|
||||
# both fail instead of occupying a scarce global slot for up
|
||||
# to four more full request timeouts.
|
||||
logger.warning("Conversation poll transport failed; skipping payload fallbacks")
|
||||
break
|
||||
|
||||
status_code = data.get("status_code") if isinstance(data, dict) else None
|
||||
error_text = ""
|
||||
@@ -814,6 +819,9 @@ class DouyinImHttpClient:
|
||||
enriched: list[dict] = []
|
||||
for item in conversations:
|
||||
conv = enrich_conversation_item(item, my_uid)
|
||||
if not enrich_profiles:
|
||||
enriched.append(conv)
|
||||
continue
|
||||
peer_uid = str(conv.get("peer_uid") or "")
|
||||
name = (conv.get("sender_name") or "").strip()
|
||||
avatar = str(conv.get("sender_avatar") or "").strip()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Awaitable, Callable, Optional
|
||||
|
||||
@@ -26,6 +27,30 @@ LogFn = Callable[..., Awaitable[None]]
|
||||
ReceivedLogFn = Callable[..., Awaitable[None]]
|
||||
|
||||
|
||||
def _env_poll_seconds(name: str, default: float, minimum: float = 5.0) -> float:
|
||||
try:
|
||||
return max(minimum, float(os.getenv(name, str(default))))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _conversation_poll_timing(account_id: int, has_ws: bool) -> tuple[float, float]:
|
||||
"""Return the reconciliation interval and a stable per-account stagger.
|
||||
|
||||
WebSocket is the real-time receive path. HTTP polling is only a safety
|
||||
reconciliation when that path exists, so running it every 15 seconds for
|
||||
hundreds of accounts wastes bandwidth and eventually starves new starts.
|
||||
Accounts without WebSocket keep the original fast polling cadence.
|
||||
"""
|
||||
interval = _env_poll_seconds(
|
||||
"KEFU_WS_RECONCILE_INTERVAL_SECONDS" if has_ws else "KEFU_HTTP_POLL_INTERVAL_SECONDS",
|
||||
120.0 if has_ws else 15.0,
|
||||
)
|
||||
spread_ms = max(1, int(interval * 1000))
|
||||
stagger = ((int(account_id or 0) * 2654435761) % spread_ms) / 1000.0
|
||||
return interval, stagger
|
||||
|
||||
|
||||
class DouyinImService:
|
||||
"""抖音 IM 直连服务:WebSocket 实时监听 + HTTP 轮询 + 自动回复"""
|
||||
|
||||
@@ -734,11 +759,18 @@ class DouyinImService:
|
||||
controller = get_traffic_controller()
|
||||
async with controller.background_slot(self.account_id, "conversation poll"):
|
||||
async with DouyinImHttpClient(self.session, account_id=self.account_id) as http:
|
||||
unread_total = await http.get_unread_count()
|
||||
if unread_total:
|
||||
logger.info(f"IM unread total: {unread_total}")
|
||||
conversations = await http.get_conversations()
|
||||
await self._index_conversations(conversations)
|
||||
conversations = await http.get_conversations(enrich_profiles=False)
|
||||
# Profile enrichment may involve several slow third-party requests.
|
||||
# Run it after releasing the conversation-list slot; each individual
|
||||
# lookup re-enters the shared controller and yields fairly to startup
|
||||
# validation and other accounts between profiles.
|
||||
await self._index_conversations(conversations)
|
||||
unread_total = sum(
|
||||
max(0, int(item.get("unread_count") or 0))
|
||||
for item in conversations
|
||||
)
|
||||
if unread_total:
|
||||
logger.info(f"IM unread total: {unread_total}")
|
||||
# Message handling may wait in the global send lane. Do not keep one
|
||||
# of the scarce background HTTP slots occupied while that happens.
|
||||
for conv in conversations:
|
||||
@@ -811,8 +843,10 @@ class DouyinImService:
|
||||
)
|
||||
await self._ws_client.start()
|
||||
|
||||
initial_poll_succeeded = False
|
||||
try:
|
||||
await self._poll_conversations()
|
||||
initial_poll_succeeded = True
|
||||
except Exception as e:
|
||||
logger.warning(f"Initial conversation poll failed: {e}")
|
||||
system_logger.record(
|
||||
@@ -823,6 +857,34 @@ class DouyinImService:
|
||||
account_id=self.account_id,
|
||||
)
|
||||
|
||||
ws_connected = bool(
|
||||
self._ws_client and getattr(self._ws_client, "connected", False)
|
||||
)
|
||||
poll_interval, poll_stagger = _conversation_poll_timing(
|
||||
self.account_id,
|
||||
ws_connected,
|
||||
)
|
||||
loop = asyncio.get_running_loop()
|
||||
if initial_poll_succeeded:
|
||||
initial_retry_interval = poll_interval
|
||||
initial_retry_stagger = poll_stagger
|
||||
else:
|
||||
# If the authoritative first poll failed, retry on the fast HTTP
|
||||
# cadence even when WebSocket connected in the meantime.
|
||||
initial_retry_interval, initial_retry_stagger = (
|
||||
_conversation_poll_timing(self.account_id, False)
|
||||
)
|
||||
next_conversation_poll_at = (
|
||||
loop.time() + initial_retry_interval + initial_retry_stagger
|
||||
)
|
||||
logger.info(
|
||||
"Conversation reconciliation account=%s interval=%.1fs stagger=%.1fs ws=%s",
|
||||
self.account_id,
|
||||
poll_interval,
|
||||
poll_stagger,
|
||||
"yes" if ws_connected else "no",
|
||||
)
|
||||
|
||||
loop_count = 0
|
||||
while self._running:
|
||||
# The initial poll above is authoritative. Sleep before the next
|
||||
@@ -832,8 +894,39 @@ class DouyinImService:
|
||||
break
|
||||
loop_count += 1
|
||||
try:
|
||||
if loop_count % 3 == 0:
|
||||
await self._poll_conversations()
|
||||
current_ws_connected = bool(
|
||||
self._ws_client
|
||||
and getattr(self._ws_client, "connected", False)
|
||||
)
|
||||
if current_ws_connected != ws_connected:
|
||||
ws_connected = current_ws_connected
|
||||
poll_interval, poll_stagger = _conversation_poll_timing(
|
||||
self.account_id,
|
||||
ws_connected,
|
||||
)
|
||||
candidate_poll_at = loop.time() + poll_interval + poll_stagger
|
||||
# Never postpone an already scheduled reconciliation.
|
||||
# In particular, reconnecting must preserve the earlier
|
||||
# fallback poll that covers messages missed while offline.
|
||||
next_conversation_poll_at = min(
|
||||
next_conversation_poll_at,
|
||||
candidate_poll_at,
|
||||
)
|
||||
logger.info(
|
||||
"Conversation reconciliation rescheduled account=%s "
|
||||
"interval=%.1fs ws=%s",
|
||||
self.account_id,
|
||||
poll_interval,
|
||||
"yes" if ws_connected else "no",
|
||||
)
|
||||
if loop.time() >= next_conversation_poll_at:
|
||||
try:
|
||||
await self._poll_conversations()
|
||||
finally:
|
||||
# Advance on both success and failure. Otherwise a
|
||||
# past deadline retries every five-second loop tick
|
||||
# during an outage and amplifies traffic.
|
||||
next_conversation_poll_at = loop.time() + poll_interval
|
||||
if loop_count % 6 == 0:
|
||||
logger.info(f"IM direct tick #{loop_count} account={self.account_id}")
|
||||
# 关注欢迎语:约每 60s 检测一次新粉丝(独立于私信轮询,失败不影响主循环)
|
||||
|
||||
@@ -396,6 +396,15 @@ class TrafficController:
|
||||
self._background = asyncio.Semaphore(
|
||||
_env_int("KEFU_BACKGROUND_NETWORK_CONCURRENCY", 2)
|
||||
)
|
||||
# Recurring polls can create hundreds of waiters when many accounts
|
||||
# are online. Admit at most one normal waiter to the semaphore at a
|
||||
# time so startup validation can join near the front instead of being
|
||||
# buried behind the entire polling backlog. The shared semaphore is
|
||||
# still the single bandwidth cap; startup work does not add extra
|
||||
# network concurrency.
|
||||
self._background_normal_admission = asyncio.Lock()
|
||||
self._background_startup_clear = asyncio.Event()
|
||||
self._background_startup_clear.set()
|
||||
self._browser = asyncio.Semaphore(
|
||||
_env_int("KEFU_BROWSER_START_CONCURRENCY", 1)
|
||||
)
|
||||
@@ -410,12 +419,20 @@ class TrafficController:
|
||||
)
|
||||
self.background_waiting = 0
|
||||
self.background_active = 0
|
||||
self.background_startup_waiting = 0
|
||||
self.background_startup_active = 0
|
||||
self.browser_waiting = 0
|
||||
self.browser_active = 0
|
||||
self.media_proxy_active = 0
|
||||
|
||||
@asynccontextmanager
|
||||
async def background_slot(self, account_id: int = 0, description: str = "request"):
|
||||
async def background_slot(
|
||||
self,
|
||||
account_id: int = 0,
|
||||
description: str = "request",
|
||||
*,
|
||||
startup: bool = False,
|
||||
):
|
||||
current_task = asyncio.current_task()
|
||||
owner_task, depth = self._background_owner.get()
|
||||
if owner_task is current_task and depth > 0:
|
||||
@@ -429,12 +446,29 @@ class TrafficController:
|
||||
started = asyncio.get_running_loop().time()
|
||||
self.background_waiting += 1
|
||||
try:
|
||||
await self._background.acquire()
|
||||
if startup:
|
||||
self.background_startup_waiting += 1
|
||||
self._background_startup_clear.clear()
|
||||
try:
|
||||
await self._background.acquire()
|
||||
finally:
|
||||
self.background_startup_waiting -= 1
|
||||
if self.background_startup_waiting == 0:
|
||||
self._background_startup_clear.set()
|
||||
else:
|
||||
# Only one recurring/background request may wait directly on
|
||||
# the shared semaphore. A later startup request therefore
|
||||
# has at most one normal request ahead of it, not hundreds.
|
||||
async with self._background_normal_admission:
|
||||
await self._background_startup_clear.wait()
|
||||
await self._background.acquire()
|
||||
except BaseException:
|
||||
self.background_waiting -= 1
|
||||
raise
|
||||
self.background_waiting -= 1
|
||||
self.background_active += 1
|
||||
if startup:
|
||||
self.background_startup_active += 1
|
||||
token = self._background_owner.set((current_task, 1))
|
||||
waited = asyncio.get_running_loop().time() - started
|
||||
if waited >= 1.0:
|
||||
@@ -448,6 +482,8 @@ class TrafficController:
|
||||
yield
|
||||
finally:
|
||||
self._background_owner.reset(token)
|
||||
if startup:
|
||||
self.background_startup_active -= 1
|
||||
self.background_active -= 1
|
||||
self._background.release()
|
||||
|
||||
@@ -492,6 +528,8 @@ class TrafficController:
|
||||
"background": {
|
||||
"active": self.background_active,
|
||||
"waiting": self.background_waiting,
|
||||
"startup_active": self.background_startup_active,
|
||||
"startup_waiting": self.background_startup_waiting,
|
||||
},
|
||||
"browser": {
|
||||
"active": self.browser_active,
|
||||
|
||||
@@ -27,6 +27,7 @@ class DouyinImWsClient:
|
||||
self.on_message = on_message
|
||||
self.account_id = account_id
|
||||
self._running = False
|
||||
self.connected = False
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
self._ws_app: Optional[WebSocketApp] = None
|
||||
@@ -50,6 +51,7 @@ class DouyinImWsClient:
|
||||
|
||||
async def stop(self):
|
||||
self._running = False
|
||||
self.connected = False
|
||||
with self._ws_lock:
|
||||
if self._ws_app:
|
||||
try:
|
||||
@@ -150,6 +152,7 @@ class DouyinImWsClient:
|
||||
return
|
||||
|
||||
def on_open(_ws):
|
||||
self.connected = True
|
||||
logger.info("IM WebSocket connected")
|
||||
system_logger.record(
|
||||
"实时接收通道已连接",
|
||||
@@ -174,6 +177,7 @@ class DouyinImWsClient:
|
||||
)
|
||||
|
||||
def on_close(_ws, code, msg):
|
||||
self.connected = False
|
||||
logger.info(f"IM WebSocket closed: code={code}, msg={msg}")
|
||||
if self._running:
|
||||
system_logger.record(
|
||||
@@ -206,6 +210,7 @@ class DouyinImWsClient:
|
||||
try:
|
||||
ws_app.run_forever(origin="https://www.douyin.com", ping_interval=20, ping_timeout=10)
|
||||
finally:
|
||||
self.connected = False
|
||||
with self._ws_lock:
|
||||
if self._ws_app is ws_app:
|
||||
self._ws_app = None
|
||||
|
||||
Reference in New Issue
Block a user