更新
This commit is contained in:
@@ -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 检测一次新粉丝(独立于私信轮询,失败不影响主循环)
|
||||
|
||||
Reference in New Issue
Block a user