This commit is contained in:
Your Name
2026-07-28 09:00:19 +08:00
parent 8ba13a8ff9
commit 153db97dc7
14 changed files with 793 additions and 75 deletions
+10 -2
View File
@@ -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()
+100 -7
View File
@@ -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