Files
dy/backend/rpa_engine/douyin_im/service.py
T
2026-09-01 15:58:34 +08:00

1730 lines
74 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.
import asyncio
import inspect
import logging
import os
import time
import weakref
from collections import deque
from dataclasses import dataclass, field
from typing import Awaitable, Callable, Optional
from utils import system_logger
from .frontier import ensure_frontier_ws
from .http_client import DouyinImHttpClient, format_session_credential_summary
from .session import DouyinImSession
from .ws_client import DouyinImWsClient
from .reply_queue import AccountReplyQueue
from .traffic_control import get_traffic_controller
from .reply_payload import format_reply_display, serialize_reply_log
from . import hosted_registry
from .conv_util import conversation_belongs_to, resolve_peer_uid
from .peer_profile import (
enrich_conversation_item,
fetch_peer_profile,
is_generic_peer_name,
)
logger = logging.getLogger("douyin_im.service")
MatchReplyFn = Callable[[str], Awaitable[Optional[list[str]]]]
LogFn = Callable[..., Awaitable[None]]
ReceivedLogFn = Callable[..., Awaitable[None]]
ReadyFn = Callable[[], Optional[Awaitable[None]]]
# These sets live on the FastAPI event-loop thread. They let every account
# derive a polling interval from the current population instead of assuming it
# is the only hosted account on the server.
_active_service_ids: set[int] = set()
_ws_service_ids: set[int] = set()
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,
population: Optional[int] = None,
) -> 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.
"""
base_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,
)
if population is None:
if has_ws:
population = len(_ws_service_ids)
else:
population = len(_active_service_ids - _ws_service_ids)
population = max(1, int(population or 0))
# Reserve a bounded request budget for each class. With 500 connected
# accounts and the default 1 req/s budget, reconciliation automatically
# stretches to 500 seconds instead of permanently saturating the two
# shared background-network slots. Disconnected accounts use a separate
# budget so they cannot create a 15-second retry storm after an outage.
budget_name = "KEFU_WS_POLL_BUDGET_RPS" if has_ws else "KEFU_HTTP_POLL_BUDGET_RPS"
try:
budget_rps = max(0.05, float(os.getenv(budget_name, "1.0")))
except (TypeError, ValueError):
budget_rps = 1.0
interval = max(base_interval, population / budget_rps)
spread_ms = max(1, int(interval * 1000))
stagger = ((int(account_id or 0) * 2654435761) % spread_ms) / 1000.0
return interval, stagger
def _initial_unread_concurrency() -> int:
try:
return max(
1,
min(8, int(os.getenv("KEFU_INITIAL_UNREAD_CONCURRENCY", "2"))),
)
except (TypeError, ValueError):
return 2
@dataclass(eq=False)
class _InitialUnreadJob:
service: "DouyinImService"
messages: deque[dict] = field(default_factory=deque)
cancelled: bool = False
active_task: Optional[asyncio.Task] = None
class _InitialUnreadDispatcher:
"""Process startup unread snapshots with a fixed process-wide worker set.
One queued object represents one account and is requeued after each
message. This keeps account-local FIFO while preventing a single account
from monopolizing both consumers. Only active consumers create handler
tasks, so a 500-account start does not create 500 waiting tasks.
"""
def __init__(self, concurrency: Optional[int] = None) -> None:
self.concurrency = max(
1,
int(concurrency or _initial_unread_concurrency()),
)
self._queue: asyncio.Queue[_InitialUnreadJob] = asyncio.Queue()
self._jobs: dict[int, _InitialUnreadJob] = {}
self._workers: list[asyncio.Task] = []
self._lock = asyncio.Lock()
self._stopping = False
async def _ensure_workers(self) -> None:
async with self._lock:
self._workers = [task for task in self._workers if not task.done()]
if self._workers or self._stopping:
return
for index in range(self.concurrency):
self._workers.append(
asyncio.create_task(
self._worker(index + 1),
name=f"initial-unread-worker-{index + 1}",
)
)
async def submit(
self,
service: "DouyinImService",
messages: list[dict],
) -> None:
pending = [message for message in messages if isinstance(message, dict)]
if not pending or not service._running:
return
await self._ensure_workers()
async with self._lock:
if self._stopping or not service._running:
return
service_key = id(service)
existing = self._jobs.get(service_key)
if existing and not existing.cancelled:
existing.messages.extend(pending)
return
job = _InitialUnreadJob(
service=service,
messages=deque(pending),
)
self._jobs[service_key] = job
self._queue.put_nowait(job)
logger.debug(
"Initial unread queued account=%s count=%s",
service.account_id,
len(pending),
)
async def cancel(self, service: "DouyinImService") -> None:
operation: Optional[asyncio.Task] = None
current_task = asyncio.current_task()
async with self._lock:
job = self._jobs.pop(id(service), None)
if job is None:
return
job.cancelled = True
job.messages.clear()
operation = job.active_task
if (
operation
and operation is not current_task
and not operation.done()
):
operation.cancel()
if (
operation
and operation is not current_task
and not operation.done()
):
await asyncio.gather(operation, return_exceptions=True)
async def _worker(self, worker_number: int) -> None:
while True:
job = await self._queue.get()
operation: Optional[asyncio.Task] = None
try:
async with self._lock:
if (
job.cancelled
or not job.service._running
or not job.messages
):
self._jobs.pop(id(job.service), None)
else:
message = job.messages.popleft()
operation = asyncio.create_task(
job.service._handle_incoming(message),
name=(
"initial-unread-account-"
f"{job.service.account_id}"
),
)
job.active_task = operation
if operation is not None:
try:
await operation
except asyncio.CancelledError:
# Cancelling one account cancels only its bounded child
# operation; cancelling this worker still shuts it down.
if asyncio.current_task().cancelling():
raise
except Exception as exc:
logger.error(
"Initial unread handling failed account=%s "
"worker=%s: %s",
job.service.account_id,
worker_number,
exc,
)
async with self._lock:
if job.active_task is operation:
job.active_task = None
if (
job.cancelled
or not job.service._running
or not job.messages
):
self._jobs.pop(id(job.service), None)
else:
# Round-robin across accounts while retaining FIFO
# within this account's initial unread snapshot.
self._queue.put_nowait(job)
except asyncio.CancelledError:
if operation and not operation.done():
operation.cancel()
await asyncio.gather(operation, return_exceptions=True)
raise
finally:
self._queue.task_done()
async def join(self) -> None:
await self._queue.join()
async def stop(self) -> None:
async with self._lock:
if self._stopping:
workers = list(self._workers)
operations = []
else:
self._stopping = True
workers = list(self._workers)
operations = [
job.active_task
for job in self._jobs.values()
if job.active_task and not job.active_task.done()
]
for job in self._jobs.values():
job.cancelled = True
job.messages.clear()
self._jobs.clear()
for operation in operations:
operation.cancel()
for worker in workers:
worker.cancel()
if operations:
await asyncio.gather(*operations, return_exceptions=True)
if workers:
await asyncio.gather(*workers, return_exceptions=True)
while True:
try:
self._queue.get_nowait()
except asyncio.QueueEmpty:
break
else:
self._queue.task_done()
self._workers.clear()
_INITIAL_UNREAD_DISPATCHERS = weakref.WeakKeyDictionary()
def _get_initial_unread_dispatcher() -> _InitialUnreadDispatcher:
loop = asyncio.get_running_loop()
dispatcher = _INITIAL_UNREAD_DISPATCHERS.get(loop)
if dispatcher is None:
dispatcher = _InitialUnreadDispatcher()
_INITIAL_UNREAD_DISPATCHERS[loop] = dispatcher
return dispatcher
async def _shutdown_initial_unread_dispatcher() -> None:
loop = asyncio.get_running_loop()
dispatcher = _INITIAL_UNREAD_DISPATCHERS.pop(loop, None)
if dispatcher is not None:
await dispatcher.stop()
class DouyinImService:
"""抖音 IM 直连服务:WebSocket 实时监听 + HTTP 轮询 + 自动回复"""
def __init__(
self,
session: DouyinImSession,
match_reply: MatchReplyFn,
log_fn: LogFn,
account_id: int,
received_log_fn: Optional[ReceivedLogFn] = None,
reply_delay_seconds: int = 0,
reply_delay_resolver: Optional[Callable[[], Awaitable[int]]] = None,
reply_cooldown_seconds: Optional[int] = None,
cooldown_resolver: Optional[Callable[[], Awaitable[int]]] = None,
refresh_credentials: Optional[Callable[[], Awaitable[bool]]] = None,
send_fallback: Optional[Callable[[str, str], Awaitable[tuple[bool, str]]]] = None,
follow_tick: Optional[Callable[[], Awaitable[None]]] = None,
on_session_invalid: Optional[Callable[[str], Awaitable[None]]] = None,
on_ready: Optional[ReadyFn] = None,
):
self.session = session
self.match_reply = match_reply
self.log_fn = log_fn
self.received_log_fn = received_log_fn
self.account_id = account_id
# 由 worker 注入:周期性检测新粉丝并发送关注欢迎语(约每 60s 触发一次)
self.follow_tick = follow_tick
# 由 worker 注入:检测到 IM 登录失效(INVALID_REQUEST/KICK)时回调,用于自动下线
self.on_session_invalid = on_session_invalid
self._on_ready = on_ready
self._ready_notified = False
self._session_invalid_strikes = 0
self._session_invalid_fired = False
# A keepalive browser may refresh cookies/security material while an
# outbound reply is being prepared. Serialize the short credential
# hand-off with sends so one request never mixes old and new state.
self._session_lock = asyncio.Lock()
self.reply_delay_seconds = max(0, int(reply_delay_seconds or 0))
# 实时解析账号排队间隔:账号专属优先,否则使用系统默认值。
self._reply_delay_resolver = reply_delay_resolver
self._reply_queue = AccountReplyQueue(
account_id=self.account_id,
on_error=self._on_reply_queue_error,
)
# WS 帧与 HTTP 轮询会并发进入;按到达顺序串行完成预处理/入队,确保 FIFO。
self._incoming_lock = asyncio.Lock()
# 该账号专属冷却秒数;None 表示继承全局系统设置(仅作为无 resolver 时的兜底)
self._cooldown_override = (
max(0, int(reply_cooldown_seconds)) if reply_cooldown_seconds is not None else None
)
# 实时解析冷却秒数的回调(账号专属优先,否则全局);优先于 _cooldown_override
self._cooldown_resolver = cooldown_resolver
# 由 worker 注入:触发后台重新采集 web_protect/keys(刷新 ts_sign),返回是否刷新成功
self.refresh_credentials = refresh_credentials
# 由 worker 注入的第二套发送方案:仅当 HTTP 返回非终态的 7911
# 签名错误时,可在同一账号/同一出口的浏览器页面上下文重试一次。
# KICK 与 INVALID_REQUEST 不得重放,避免在已失效会话上继续写请求。
# 签名: async (conversation_id, content) -> (ok, detail)
self.send_fallback = send_fallback
self._running = False
self._replied_keys: set[str] = set()
self._logged_keys: set[str] = set()
self._received_logged_keys: set[str] = set()
# 已告警过的「不属于本账号」的会话,避免同一条串号会话刷屏
self._foreign_conv_logged: set[str] = set()
# 已告警过的「对方也是本系统托管账号」的 peer,避免同一对账号刷屏
self._hosted_peer_logged: set[str] = set()
# 每个对话/用户最近一次自动回复的时间戳(monotonic 秒),用于冷却窗口去重
self._last_reply_at: dict[str, float] = {}
self._conv_previews: dict[str, str] = {}
self._conv_names: dict[str, str] = {} # uid/conv_id -> nickname
self._conv_meta: dict[str, dict] = {} # conversation_id -> meta
# 抖音判定会话列表请求本身不合法时置位:这轮托管不再重复轮询该接口,
# 实时长连接成为唯一接收通道(已在系统日志里说明)。
self._conversation_list_unsupported = False
self._ws_client: Optional[DouyinImWsClient] = None
self.last_error: str = ""
async def _notify_ready(self) -> None:
if self._ready_notified:
return
self._ready_notified = True
if not self._on_ready:
return
result = self._on_ready()
if inspect.isawaitable(result):
await result
def _reply_key(self, conversation_key: str, content: str) -> str:
return f"{conversation_key}::{content}"
@staticmethod
def _reply_queue_merge_keys(
conversation_id: str,
peer_uid: str,
) -> tuple[str, ...]:
"""Return every stable identifier currently known for one conversation."""
conversation_id = str(conversation_id or "").strip()
peer_uid = str(peer_uid or "").strip()
aliases: list[str] = []
if conversation_id:
aliases.append(f"conv:{conversation_id}")
if peer_uid:
aliases.append(f"peer:{peer_uid}")
return tuple(aliases)
@staticmethod
def _merge_reply_queue_details(
existing: dict,
*,
incoming_content: str,
sender_name: str,
sender_id: str,
sender_avatar: Optional[str],
conversation_id: str,
) -> dict:
"""Append one received message while preserving the task's one reply."""
merged = dict(existing or {})
contents = merged.get("incoming_contents")
if isinstance(contents, list):
contents = list(contents)
else:
contents = []
if not contents and "incoming_content" in merged:
contents.append(str(merged.get("incoming_content") or ""))
latest_content = str(incoming_content or "")
contents.append(latest_content)
merged["incoming_content"] = latest_content
merged["incoming_contents"] = contents
merged["message_count"] = len(contents)
if sender_name:
merged["sender_name"] = sender_name
if sender_id:
merged["sender_id"] = sender_id
if sender_avatar:
merged["sender_avatar"] = sender_avatar
if conversation_id:
merged["conversation_id"] = conversation_id
return merged
def _cooldown_seconds_sync(self) -> int:
"""无 resolver 时的兜底:账号专属优先,否则取全局设置;0 表示关闭。"""
if self._cooldown_override is not None:
return self._cooldown_override
try:
from auth.system_settings import get_cached_settings
return max(0, int(get_cached_settings().auto_reply_cooldown_seconds or 0))
except Exception:
return 0
async def _resolve_cooldown_seconds(self) -> int:
"""实时解析冷却秒数:优先用 worker 注入的 resolver(账号优先、否则全局),否则兜底。"""
if self._cooldown_resolver is not None:
try:
return max(0, int(await self._cooldown_resolver() or 0))
except Exception as e:
logger.debug(f"cooldown resolver failed: {e}")
return self._cooldown_seconds_sync()
def _reply_delay_seconds_sync(self) -> int:
"""无 resolver 时解析排队间隔;0 表示不启用排队规则。"""
if self.reply_delay_seconds > 0:
return self.reply_delay_seconds
try:
from auth.system_settings import get_cached_settings
return max(0, int(get_cached_settings().auto_reply_delay_seconds or 0))
except Exception:
return 0
async def _resolve_reply_delay_seconds(self) -> int:
"""实时解析账号生效的回复排队间隔。"""
if self._reply_delay_resolver is not None:
try:
return max(0, int(await self._reply_delay_resolver() or 0))
except Exception as exc:
logger.debug(f"reply delay resolver failed: {exc}")
return self._reply_delay_seconds_sync()
def _on_reply_queue_error(self, description: str, exc: BaseException) -> None:
system_logger.record(
"账号回复队列执行失败",
detail=f"{description or '自动回复任务'}{exc}",
level="error",
category="send",
account_id=self.account_id,
)
def _peer_in_cooldown(self, peer_key: str, cooldown: int) -> bool:
if cooldown <= 0 or not peer_key:
return False
last = self._last_reply_at.get(peer_key)
if last is None:
return False
return (time.monotonic() - last) < cooldown
def _resolve_sender_name(self, msg: dict) -> str:
sender_uid = str(msg.get("sender_uid") or msg.get("sender_name") or "").strip()
conv_id = str(msg.get("conversation_id") or "")
name = (msg.get("sender_name") or "").strip()
if name and not name.isdigit():
return name
if sender_uid and self._conv_names.get(sender_uid):
return self._conv_names[sender_uid]
if conv_id and self._conv_names.get(conv_id):
return self._conv_names[conv_id]
if sender_uid:
return f"用户{sender_uid[-6:]}" if len(sender_uid) > 6 else f"用户{sender_uid}"
return "未知用户"
def _conversation_is_mine(self, conv_id: str) -> bool:
"""本账号是否为该单聊会话的参与方;不是就丢弃,绝不改写后发送。"""
my_uid = int(self.session.my_uid or 0)
if conversation_belongs_to(conv_id, my_uid):
return True
conv_key = str(conv_id or "")
logger.warning(
"Account %s dropped a message from foreign conversation %s "
"(my_uid=%s); two accounts most likely share one set of credentials",
self.account_id,
conv_key,
my_uid,
)
if conv_key not in self._foreign_conv_logged:
if len(self._foreign_conv_logged) > 200:
self._foreign_conv_logged.clear()
self._foreign_conv_logged.add(conv_key)
system_logger.record(
"已丢弃不属于本账号的私信",
detail=(
f"会话 {conv_key} 的参与方都不是本账号(uid={my_uid}),"
"该消息属于另一个账号,已丢弃且不会自动回复。"
"常见原因:多个账号的凭证来自同一台机器/同一个浏览器,"
"frontier 长连接按设备号寻址导致两个账号互相收到对方的私信。"
"请为每个账号单独采集凭证(独立浏览器配置/设备)。"
),
level="warning",
category="recv",
account_id=self.account_id,
)
return False
def _is_self_message(self, msg: dict) -> bool:
sender_uid = str(msg.get("sender_uid") or "").strip()
if not sender_uid or not self.session.my_uid:
return False
try:
return int(sender_uid) == int(self.session.my_uid)
except (TypeError, ValueError):
return False
async def _resolve_peer_profile(
self,
conv_id: str,
sender_uid: str,
sender: str,
sender_avatar: str,
) -> tuple[str, str, str]:
my_uid = int(self.session.my_uid or 0)
peer_uid = str(sender_uid or "").strip()
if (not peer_uid or not peer_uid.isdigit()) and conv_id and my_uid:
resolved = resolve_peer_uid(conv_id, my_uid)
if resolved:
peer_uid = str(resolved)
meta = self._conv_meta.get(conv_id, {}) if conv_id else {}
name = (sender or meta.get("sender_name") or "").strip()
avatar = (sender_avatar or meta.get("sender_avatar") or "").strip()
if peer_uid and self._conv_names.get(peer_uid):
cached_name = self._conv_names[peer_uid]
if is_generic_peer_name(name, peer_uid):
name = cached_name
if conv_id and self._conv_names.get(conv_id) and is_generic_peer_name(name, peer_uid):
name = self._conv_names[conv_id]
if peer_uid and (is_generic_peer_name(name, peer_uid) or not avatar):
profile = await fetch_peer_profile(self.session, peer_uid, self.account_id)
if profile.get("nickname"):
name = profile["nickname"]
self._conv_names[peer_uid] = name
if profile.get("avatar_url"):
avatar = profile["avatar_url"]
if profile.get("uid"):
peer_uid = str(profile["uid"])
if not name:
name = self._resolve_sender_name(
{"sender_uid": peer_uid, "conversation_id": conv_id, "sender_name": sender}
)
return name, avatar, peer_uid
async def _fetch_message_by_id(self, conv_id: str, server_message_id: str) -> dict | None:
"""按 server_message_id 调 get_by_conversation 拉取该条消息的完整数据
(含真实 content / message_type / URL)。命中返回原始消息 dict,否则 None。"""
if not conv_id or not server_message_id:
return None
try:
from .auth import DouyinAuth
controller = get_traffic_controller()
async with controller.background_slot(self.account_id, "message detail fetch"):
meta = self._conv_meta.get(conv_id, {})
short_id = str(meta.get("conversation_short_id") or "")
auth = DouyinAuth.from_im_session(self.session)
my_uid = int(self.session.my_uid or 0)
async with DouyinImHttpClient(self.session, account_id=self.account_id) as http:
if not short_id:
peer_uid = resolve_peer_uid(conv_id, my_uid)
if peer_uid:
_, short_id, _ = await http.get_conversation_info(
auth, int(peer_uid), my_uid, conv_id, 0
)
if short_id:
self._conv_meta[conv_id] = {
**self._conv_meta.get(conv_id, {}),
"conversation_short_id": short_id,
}
messages = await http.get_conversation_messages(
auth, conv_id, int(short_id or 0), limit=20
)
for m in messages:
if str(m.get("server_message_id") or "") == server_message_id:
return m
except Exception as e:
logger.debug(f"_fetch_message_by_id failed: {e}")
return None
async def _enrich_media_content(self, conv_id: str, server_message_id: str, content: str) -> str:
"""媒体消息(相册图片/语音/视频)WS 推送 content 为空时,按 server_message_id
调 get_by_conversation 拉取真实内容并补全 URL。命中失败则原样返回。"""
if not conv_id or not server_message_id or not content:
return content
try:
from .message_content import parse_stored_content, format_im_message, serialize_message_content
parsed = parse_stored_content(content)
mtype = parsed.get("type")
if mtype not in ("image", "voice", "video"):
return content
if parsed.get("url"):
return content # 已有 URL(如商店表情/带 url 的图)
m = await self._fetch_message_by_id(conv_id, server_message_id)
if m:
real = format_im_message(m.get("content") or "", int(m.get("message_type") or 0))
if real.get("url"):
enriched = serialize_message_content(real)
logger.info(
"Enriched media via get_by_conversation: smid=%s type=%s",
server_message_id, real.get("type"),
)
return enriched
except Exception as e:
logger.debug(f"_enrich_media_content failed: {e}")
return content
async def _handle_incoming(self, msg: dict):
# asyncio.Lock 按等待顺序唤醒。锁只覆盖解析、去重、规则匹配与入队;
# 未启用排队时,真正的网络发送仍在锁外执行,保持原有并发行为。
async with self._incoming_lock:
immediate_reply = await self._prepare_incoming(msg)
if immediate_reply is not None and self._running:
await immediate_reply()
async def _prepare_incoming(
self,
msg: dict,
) -> Optional[Callable[[], Awaitable[None]]]:
conv_id = msg.get("conversation_id") or ""
# 跨账号隔离:只处理本账号自己的会话。frontier 按设备号寻址推送,
# 同一台机器/同一浏览器采集出来的多个账号 device_id 可能相同,两条长连接
# 会订阅到同一个地址并互相收到对方的私信。若不在这里拦住,
# normalize_conversation_id 会把别人的会话改写成
# 0:1:{本账号}:{别人的好友},本账号就把自动回复发给了另一个账号的好友。
if not self._conversation_is_mine(conv_id):
return
if self._is_self_message(msg):
return
sender_uid = str(msg.get("sender_uid") or "")
sender = self._resolve_sender_name(msg)
sender_avatar = str(msg.get("sender_avatar") or "").strip()
sender, sender_avatar, peer_uid = await self._resolve_peer_profile(
conv_id, sender_uid, sender, sender_avatar
)
content = (msg.get("content") or "").strip()
has_raw_ws = "raw_content" in msg
raw_incoming = msg.get("raw_content") if has_raw_ws else None
ws_message_type = msg.get("message_type")
# 每条 WS 消息带唯一 server_message_id:用它去重,避免“同一用户重复发送
# 相同文字(如多次‘你好’)被按内容去重而整条丢弃”,这是“有时收不到”的根因。
# HTTP 轮询的会话预览没有该 ID,则退回按 内容 去重(避免对同一未读重复回复)。
server_message_id = str(msg.get("server_message_id") or "")
# WS 仅推送瘦消息(如 type=26content 为空:按 server_message_id 回 HTTP 拉取
# 完整消息,补全 content / message_type,确保「接收到的全部信息」都被记录。
if not content and server_message_id and not (raw_incoming or "").strip():
real = await self._fetch_message_by_id(conv_id, server_message_id)
if real:
real_content = (real.get("content") or "").strip()
if real_content:
raw_incoming = real.get("content")
has_raw_ws = True
real_type = real.get("message_type")
if real_type is not None:
ws_message_type = real_type
try:
from .message_content import format_im_message, serialize_message_content
parsed = format_im_message(real.get("content") or "", int(real_type or 0))
content = serialize_message_content(parsed) if parsed else real_content
except Exception:
content = real_content
logger.info(
"Enriched empty WS push via get_by_conversation: smid=%s type=%s",
server_message_id, real_type,
)
# 相册图片/语音等 WS 推送 content 为空,按 server_message_id 拉取真实内容补 URL
content = await self._enrich_media_content(conv_id, server_message_id, content)
unread = int(msg.get("unread_count") or 0)
if conv_id:
self._conv_meta[conv_id] = {
**self._conv_meta.get(conv_id, {}),
"conversation_id": conv_id,
"sender_name": sender,
"sender_avatar": sender_avatar or self._conv_meta.get(conv_id, {}).get("sender_avatar"),
"content": content or self._conv_meta.get(conv_id, {}).get("content", ""),
"unread_count": unread,
"peer_uid": peer_uid,
}
if sender and peer_uid:
self._conv_names[peer_uid] = sender
if not content and unread <= 0 and raw_incoming is None and not server_message_id:
return
if content == "[未读消息]" and sender in self._conv_previews:
content = self._conv_previews.get(sender, content)
if server_message_id:
log_key = f"mid:{server_message_id}"
key = f"mid:{server_message_id}"
else:
# HTTP 会话预览通常没有 message_id;必须带 conversation_id/peer_uid
# 否则两个同名用户发送相同内容会被误判成同一条消息。
conversation_key = str(conv_id or peer_uid or sender or "unknown")
log_key = self._reply_key(conversation_key, content or "[未读]")
key = self._reply_key(conversation_key, content)
log_kwargs = {
"sender_name": sender,
"sender_id": peer_uid or conv_id or None,
"sender_avatar": sender_avatar or self._conv_meta.get(conv_id, {}).get("sender_avatar"),
"message": content or (raw_incoming if raw_incoming is not None else ""),
}
# 接收消息原始日志:WS content 原样落库(瘦推送已回 HTTP 补全为真实 content
if self.received_log_fn and has_raw_ws:
recv_key = f"recv:mid:{server_message_id}" if server_message_id else f"recv:{log_key}"
if recv_key not in self._received_logged_keys:
self._received_logged_keys.add(recv_key)
message_type = ws_message_type
try:
message_type = int(message_type) if message_type is not None else None
except (TypeError, ValueError):
message_type = None
await self.received_log_fn(
sender_name=sender,
sender_id=peer_uid or conv_id or None,
sender_avatar=log_kwargs.get("sender_avatar"),
raw_content="" if raw_incoming is None else raw_incoming,
conversation_id=conv_id or None,
message_type=message_type,
server_message_id=server_message_id or None,
)
if log_key not in self._logged_keys and content:
self._logged_keys.add(log_key)
await self.log_fn(
**log_kwargs,
reply=None,
status="received",
)
try:
from .message_content import format_system_log_message, parse_stored_content
parsed = parse_stored_content(content)
msg_type = parsed.get("type") or "text"
detail = format_system_log_message(content)
if server_message_id:
detail = f"{detail} | mid={server_message_id}"
system_logger.record(
f"收到{'' if msg_type == 'text' else '['+msg_type+']'}消息:{sender}",
detail=detail,
level="info",
category="recv",
account_id=self.account_id,
)
except Exception as exc:
logger.debug(f"record recv system log failed: {exc}")
if key in self._replied_keys:
return
# WS 与 HTTP 轮询可能同时发现同一条消息。检查后立即占位(中间不 await),
# 防止延迟排队期间被重复加入发送队列。
self._replied_keys.add(key)
# 对方也是本系统托管的账号:双方都会自动回复,一来一回就是无限回环。
# 这种高频互发是触发抖音风控(7911)/业务拒绝(8004)的常见根因,因此消息
# 照常记录,但不再自动回复。需要回复请用消息页手动发送。
if peer_uid and hosted_registry.is_hosted(peer_uid):
await self.log_fn(
**log_kwargs,
reply=None,
status="ignored",
error=(
f"对方(UID {peer_uid})也是本系统托管中的账号,"
"自动回复会在两个账号之间形成无限回环并触发抖音风控,已跳过;"
"如需回复请在消息页手动发送"
),
)
if content:
self._conv_previews[sender] = content
if peer_uid not in self._hosted_peer_logged:
if len(self._hosted_peer_logged) > 200:
self._hosted_peer_logged.clear()
self._hosted_peer_logged.add(peer_uid)
logger.info(
"Account %s skipped auto-reply to hosted account %s",
self.account_id,
peer_uid,
)
system_logger.record(
"自动回复已跳过(对方也是托管账号)",
detail=(
f"{sender}UID {peer_uid})是本系统托管中的另一个账号。"
"两个托管账号互相自动回复会形成无限回环,"
"属于抖音风控(7911/8004)的高发场景,因此只记录消息、不自动回复。"
),
level="warning",
category="send",
account_id=self.account_id,
)
return
# 同账号、同会话只保留一个尚未发送的回复任务。后续来信只追加到
# 原任务详情,不改变它的发送时间、位置或已经匹配好的回复。
queue_merge_keys = self._reply_queue_merge_keys(conv_id, peer_uid)
if queue_merge_keys and self._running:
merge_result = await self._reply_queue.merge_pending(
queue_merge_keys,
lambda existing: self._merge_reply_queue_details(
existing,
incoming_content=content or "",
sender_name=sender,
sender_id=peer_uid or conv_id or "",
sender_avatar=log_kwargs.get("sender_avatar"),
conversation_id=conv_id,
),
)
if merge_result.get("status") == "merged":
if content:
self._conv_previews[sender] = content
message_count = int(merge_result.get("message_count") or 1)
logger.info(
"Merged message into queued reply for %s on account %s: "
"job=%s messages=%s position=%s",
sender,
self.account_id,
merge_result.get("job_id"),
message_count,
merge_result.get("position"),
)
system_logger.record(
"同一会话消息已合并到回复队列",
detail=(
f"{sender} 的新消息已并入原任务;当前共 {message_count} 条消息,"
"发送时间和队列位置保持不变。"
),
level="info",
category="send",
account_id=self.account_id,
)
return
# 收到新消息即尝试自动回复,不按消息类型/托管关系/内容形态过滤
replies = await self.match_reply(content if content != "[未读消息]" else "")
if not replies:
replies = await self.match_reply("")
if not replies:
await self.log_fn(
**log_kwargs,
reply=None,
status="ignored",
error="未配置任何自动回复规则,请在「自动回复规则」中添加至少一条启用规则",
)
if content:
self._conv_previews[sender] = content
return
# 冷却窗口:同一用户在设定时间内,无论发多少条消息,只自动回复一次(账号设置优先,否则全局)
peer_key = (peer_uid or conv_id or sender or "").strip()
cooldown = await self._resolve_cooldown_seconds()
if self._peer_in_cooldown(peer_key, cooldown):
logger.info(
f"Auto-reply to {sender} skipped: within {cooldown}s cooldown window"
)
if content:
self._conv_previews[sender] = content
system_logger.record(
"自动回复已跳过(冷却中)",
detail=f"{sender}{cooldown} 秒冷却窗口内重复发送,未重复回复",
level="info",
category="send",
account_id=self.account_id,
)
return
# 提前标记回复时间,确保冷却窗口内(含延迟期间)的后续消息都被抑制
if peer_key and cooldown > 0:
self._last_reply_at[peer_key] = time.monotonic()
if content:
self._conv_previews[sender] = content
delay_seconds = await self._resolve_reply_delay_seconds()
if not self._running:
return
async def send_reply() -> None:
await self._send_auto_reply(
sender=sender,
content=content,
conv_id=conv_id,
replies=replies,
peer_key=peer_key,
cooldown=cooldown,
log_kwargs=log_kwargs,
)
if delay_seconds > 0:
position = await self._reply_queue.enqueue(
delay_seconds,
send_reply,
description=f"回复 {sender}",
details={
"sender_name": sender,
"sender_id": peer_uid or conv_id or None,
"sender_avatar": log_kwargs.get("sender_avatar"),
"conversation_id": conv_id or None,
"incoming_content": content or "",
"incoming_contents": [content or ""],
"message_count": 1,
"replies": list(replies),
},
merge_keys=queue_merge_keys,
immediate_if_idle=True,
)
scheduled_wait = 0 if position == 1 else delay_seconds
logger.info(
"Queued reply to %s for account %s: position=%s wait=%ss interval=%ss",
sender,
self.account_id,
position,
scheduled_wait,
delay_seconds,
)
if position == 1:
queue_detail = (
f"{sender} 是当前账号队列的首条任务,等待时间为 0 秒;"
f"后续任务仍按 {delay_seconds} 秒间隔排队。"
)
else:
queue_detail = (
f"{sender} 当前排在第 {position} 位;账号生效间隔为 {delay_seconds} 秒,"
"后续任务继续依次排队。"
)
system_logger.record(
"自动回复已进入账号队列",
detail=(
f"{queue_detail} 账号内计时与排位独立;"
"发送时仍进入全局带宽队列逐条投递。"
),
level="info",
category="send",
account_id=self.account_id,
)
return
# 账号与系统均未配置排队间隔:跳过排队规则,保持原来的立即回复。
return send_reply
async def _send_auto_reply(
self,
*,
sender: str,
content: str,
conv_id: str,
replies: list[str],
peer_key: str,
cooldown: int,
log_kwargs: dict,
) -> None:
"""发送一项已匹配的自动回复任务,并记录原有消息/系统日志。"""
if not self._running:
return
reply_displays: list[str] = []
sent_any = False
send_error = ""
meta = self._conv_meta.get(conv_id, {})
for index, reply in enumerate(replies):
if not self._running:
send_error = self.last_error or "托管已停止,后续回复已取消"
break
if index > 0:
await asyncio.sleep(0.6)
if not self._running:
send_error = self.last_error or "托管已停止,后续回复已取消"
break
reply_display = format_reply_display(reply)
reply_displays.append(reply_display)
sent = False
if conv_id:
sent, resolved = await self._send_text(
conv_id,
reply,
conversation_short_id=str(meta.get("conversation_short_id") or ""),
)
if sent:
if resolved:
meta = {**meta, **resolved, "conversation_id": conv_id}
self._conv_meta[conv_id] = meta
else:
send_error = self.last_error or "IM API 发送失败"
else:
send_error = "缺少会话 ID,无法发送自动回复"
if sent:
sent_any = True
combined_display = " | ".join(reply_displays)
# 日志里存结构化内容(单条直接存 payload,多条用 {"messages":[...]} 包裹),
# 这样图片/表情等媒体回复会被前端渲染为真实媒体,而不是被压成 "图片" 占位文字。
reply_log_content = serialize_reply_log(replies)
if not sent_any:
logger.warning(
f"IM API send failed for [{sender}]: {send_error}; reply saved to log only"
)
# 发送彻底失败:清除冷却时间戳,避免把没收到回复的用户锁在冷却窗口内
if peer_key and cooldown > 0:
self._last_reply_at.pop(peer_key, None)
await self.log_fn(
**log_kwargs,
reply=reply_log_content,
status="replied" if sent_any else "failed",
error=None if sent_any else (send_error or "IM API 发送失败"),
)
if sent_any:
system_logger.record(
"自动回复成功",
detail=f"已回复 {sender}{combined_display}",
level="success",
category="send",
account_id=self.account_id,
)
else:
system_logger.record(
"自动回复失败",
detail=f"回复 {sender} 失败:{send_error}(收到:{content}",
level="error",
category="send",
account_id=self.account_id,
)
logger.info(
f"Auto-reply to {sender}: {content!r} -> {combined_display!r} "
f"(sent={sent_any}, count={len(replies)})"
)
async def _index_conversations(
self,
conversations: list[dict],
*,
enrich_profiles: bool = True,
):
my_uid = int(self.session.my_uid or 0)
for raw in conversations:
conv = enrich_conversation_item(raw, my_uid)
conv_id = str(conv.get("conversation_id") or "")
name = (conv.get("sender_name") or "").strip()
avatar = str(conv.get("sender_avatar") or "").strip()
peer_uid = str(conv.get("peer_uid") or "")
if (
enrich_profiles
and peer_uid
and (is_generic_peer_name(name, peer_uid) or not avatar)
):
profile = await fetch_peer_profile(self.session, peer_uid, self.account_id)
if profile.get("nickname"):
name = profile["nickname"]
conv["sender_name"] = name
if profile.get("avatar_url"):
avatar = profile["avatar_url"]
conv["sender_avatar"] = avatar
if conv_id:
self._conv_meta[conv_id] = {
**conv,
"sender_name": name,
"sender_avatar": avatar or None,
"peer_uid": peer_uid,
}
if name:
self._conv_names[conv_id] = name
if peer_uid and name:
self._conv_names[peer_uid] = name
async def _poll_conversations(
self,
*,
initial: bool = False,
defer_handlers: bool = False,
) -> list[dict]:
if self._conversation_list_unsupported:
# 抖音已明确拒绝过这个请求本身;重复调用只会每轮浪费一次请求,
# 并把同一条错误反复写进日志。原因已在首次拒绝时记录。
return []
controller = get_traffic_controller()
async with controller.background_slot(
self.account_id,
"conversation poll",
startup=initial,
):
# Do not keep one idle connection pool per hosted account. At 500
# accounts that would retain hundreds of sockets between sparse
# reconciliations. Capacity-aware scheduling makes construction
# infrequent, while the context manager releases the socket as
# soon as this account's poll finishes.
async with DouyinImHttpClient(
self.session,
account_id=self.account_id,
) as http:
conversations = await http.get_conversations(enrich_profiles=False)
if http.conversation_list_unsupported:
self._conversation_list_unsupported = True
logger.warning(
"Account %s disabled conversation reconciliation; "
"the realtime WebSocket is now the only receive path",
self.account_id,
)
return []
# Capture the previous preview before _index_conversations overwrites
# _conv_meta. A conversation-list preview is not inherently a new
# message: after startup we only act when it is unread or has actually
# changed since the last cache snapshot. This prevents the first
# scheduled reconciliation from replying to every historical preview.
previous_previews: list[tuple[bool, str]] = []
previous_by_peer = {
str(meta.get("peer_uid") or ""): str(meta.get("content") or "")
for meta in self._conv_meta.values()
if str(meta.get("peer_uid") or "")
}
for conv in conversations:
conv_id = str(conv.get("conversation_id") or "").strip()
peer_uid = str(
conv.get("peer_uid") or conv.get("sender_id") or ""
).strip()
sender_name = str(conv.get("sender_name") or "").strip()
prior: Optional[str] = None
known = False
if conv_id and conv_id in self._conv_meta:
known = True
prior = str(self._conv_meta[conv_id].get("content") or "")
elif sender_name and sender_name in self._conv_previews:
known = True
prior = str(self._conv_previews.get(sender_name) or "")
elif peer_uid and peer_uid in previous_by_peer:
known = True
prior = previous_by_peer[peer_uid]
previous_previews.append((known, prior or ""))
# Never enrich every row in a reconciliation snapshot. At 500 accounts
# that could turn one list request into tens of thousands of profile
# calls. _handle_incoming resolves the peer lazily only for an unread
# or genuinely changed conversation after this lightweight cache pass.
await self._index_conversations(
conversations,
enrich_profiles=False,
)
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.
deferred: list[dict] = []
for conv, (preview_known, previous_preview) in zip(
conversations,
previous_previews,
):
unread = int(conv.get("unread_count") or 0)
current_preview = str(conv.get("content") or "")
preview_changed = bool(
preview_known
and current_preview
and current_preview != previous_preview
)
# Existing previews are useful cache data, but on the authoritative
# startup snapshot they are not proof of a newly received message.
# The same is true on later polls unless the cached preview changed.
if unread > 0 or (not initial and preview_changed):
if defer_handlers:
deferred.append(conv)
else:
await self._handle_incoming(conv)
return deferred
async def _verify_account_uid(self):
"""启动时用 query/user 接口核验账号真实 UID,修正采集端可能取错的 my_uid/device_id。
采集端从 tea_cache 推断的 my_uid 可能是访客/对方 id,会导致会话列表为 0、
创建会话 INVALID_REQUEST。这里在建连前先校正,保证后续所有请求身份正确。
"""
if getattr(self.session, "uid_verified", False) and self.session.my_uid:
return
try:
from .auth import DouyinAuth
auth = DouyinAuth.from_im_session(self.session)
controller = get_traffic_controller()
async with controller.background_slot(
self.account_id,
"account UID verify",
startup=True,
):
async with DouyinImHttpClient(self.session, account_id=self.account_id) as http:
old = int(self.session.my_uid or 0)
resolved = await asyncio.to_thread(http._resolve_authoritative_uid, auth)
if resolved and old and int(resolved) != old:
system_logger.record(
"已自动校正账号 UID",
detail=f"采集端识别 UID={old},接口核验真实 UID={resolved},已修正后再建立私信连接。",
level="info",
category="system",
account_id=self.account_id,
)
except Exception as e:
logger.warning(f"启动核验账号 UID 失败(沿用采集值):{e}")
async def run(self):
"""主循环:WebSocket + HTTP 轮询"""
self._running = True
_active_service_ids.add(self.account_id)
await self._reply_queue.start()
await self._verify_account_uid()
# ensure_frontier_ws 可能触发签名/HTTP(阻塞),放线程池避免多账号启动时卡死事件循环
controller = get_traffic_controller()
async with controller.background_slot(
self.account_id,
"frontier discovery",
startup=True,
):
await asyncio.to_thread(ensure_frontier_ws, self.session)
has_ws = bool(self.session.frontier_ws_url())
cred_summary = format_session_credential_summary(self.session)
logger.info(cred_summary)
logger.info(
f"Starting IM direct service for account {self.account_id} "
f"(ws={'yes' if has_ws else 'no'})"
)
system_logger.record(
"私信托管已启动",
detail=f"实时接收通道:{'已就绪' if has_ws else '不可用(仅 HTTP 轮询)'}\n{cred_summary}",
level="success" if has_ws else "warning",
category="system",
account_id=self.account_id,
)
try:
from .emoji_pack import ensure_emoji_map, is_fresh
if not is_fresh():
async with controller.background_slot(
self.account_id,
"emoji preload",
startup=True,
):
await asyncio.to_thread(ensure_emoji_map, self.session)
except Exception as e:
logger.debug(f"emoji map preload failed: {e}")
self._ws_client = DouyinImWsClient(
self.session, self._handle_incoming, account_id=self.account_id
)
await self._ws_client.start()
initial_poll_succeeded = False
initial_unread: list[dict] = []
try:
initial_unread = await self._poll_conversations(
initial=True,
defer_handlers=True,
)
initial_poll_succeeded = True
except Exception as e:
logger.warning(f"Initial conversation poll failed: {e}")
system_logger.record(
"首次会话轮询失败",
detail=f"{e}",
level="warning",
category="poll",
account_id=self.account_id,
)
# Batch readiness covers transport discovery plus the authoritative
# list/cache snapshot, not potentially slow reply generation, logging,
# or outbound sends for pre-existing unread conversations.
await self._notify_ready()
if initial_unread:
await _get_initial_unread_dispatcher().submit(
self,
initial_unread,
)
# A discovered WS URL is enough to start on the low-frequency
# reconciliation schedule. If the socket does not actually open, the
# first health tick switches the account to fallback mode. This avoids
# a second fast HTTP poll just because the handshake needed a moment.
ws_connected = bool(has_ws)
if ws_connected:
_ws_service_ids.add(self.account_id)
else:
_ws_service_ids.discard(self.account_id)
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",
)
poll_failures = 0
follow_interval = _env_poll_seconds(
"KEFU_FOLLOW_POLL_INTERVAL_SECONDS",
60.0,
minimum=30.0,
)
follow_stagger = (
(int(self.account_id or 0) * 2654435761)
% max(1, int(follow_interval * 1000))
) / 1000.0
next_follow_tick_at = loop.time() + follow_interval + follow_stagger
while self._running:
# The initial poll above is authoritative. Sleep before the next
# recurring tick so startup cannot issue two back-to-back polls.
await asyncio.sleep(5)
if not self._running:
break
try:
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
if ws_connected:
_ws_service_ids.add(self.account_id)
else:
_ws_service_ids.discard(self.account_id)
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",
)
else:
# Large batches change the active population while older
# services are already running. Future polls adopt the
# widened capacity interval without moving a poll that is
# already scheduled.
capacity_interval, _ = _conversation_poll_timing(
self.account_id,
ws_connected,
)
if abs(capacity_interval - poll_interval) >= 1.0:
poll_interval = capacity_interval
if loop.time() >= next_conversation_poll_at:
try:
max_poll_seconds = _env_poll_seconds(
"KEFU_CONVERSATION_POLL_DEADLINE_SECONDS",
30.0,
)
await asyncio.wait_for(
self._poll_conversations(),
timeout=max_poll_seconds,
)
poll_failures = 0
except asyncio.TimeoutError:
poll_failures = min(4, poll_failures + 1)
logger.debug(
"Dropping stale conversation poll account=%s after %.1fs",
self.account_id,
max_poll_seconds,
)
except Exception:
poll_failures = min(4, poll_failures + 1)
raise
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 * (2 ** poll_failures)
)
# 关注欢迎语:约每 60s 检测一次新粉丝(独立于私信轮询,失败不影响主循环)
if self.follow_tick and loop.time() >= next_follow_tick_at:
try:
await self.follow_tick()
except Exception as e:
logger.error(f"follow welcome tick error: {e}")
finally:
next_follow_tick_at = loop.time() + follow_interval
except Exception as e:
logger.error(f"IM poll error: {e}")
system_logger.record(
"会话轮询出错",
detail=f"拉取会话/未读时出错:{e}",
level="error",
category="poll",
account_id=self.account_id,
)
async def stop(self):
self._running = False
_active_service_ids.discard(self.account_id)
_ws_service_ids.discard(self.account_id)
dispatcher = _INITIAL_UNREAD_DISPATCHERS.get(asyncio.get_running_loop())
if dispatcher is not None:
await dispatcher.cancel(self)
await get_traffic_controller().send_queue.cancel_account(self.account_id)
await self._reply_queue.stop()
if self._ws_client:
await self._ws_client.stop()
def get_cached_conversations(self) -> list[dict]:
"""返回运行中缓存的会话(来自 WS / 轮询)。"""
results = []
seen = set()
for conv_id, meta in self._conv_meta.items():
name = (meta.get("sender_name") or "").strip()
key = conv_id or name
if not key or key in seen:
continue
seen.add(key)
results.append({
"conversation_id": conv_id,
"sender_name": name or f"会话{conv_id[-8:]}" if conv_id else "未知用户",
"sender_avatar": meta.get("sender_avatar") or None,
"sender_id": str(meta.get("peer_uid") or meta.get("sender_id") or conv_id or ""),
"peer_uid": str(meta.get("peer_uid") or ""),
"content": str(meta.get("content") or ""),
"unread_count": int(meta.get("unread_count") or 0),
})
return results
async def get_reply_queue_snapshot(self) -> list[dict]:
"""返回当前账号自动回复队列的可管理快照。"""
return await self._reply_queue.snapshot()
async def send_queued_reply_now(self, job_id: str) -> dict:
"""把指定自动回复任务移入账号紧急队列;实际发送仍由单消费者串行执行。"""
return await self._reply_queue.send_now(job_id)
async def replace_session(self, fresh: DouyinImSession) -> None:
"""Atomically install a freshly harvested login/security session.
The running WebSocket can keep its current connection, but future
reconnects and every HTTP send must see the same refreshed object.
Account egress selection lives outside persisted IM credentials, so it
is deliberately carried over from the current runtime session.
"""
async with self._session_lock:
current = self.session
current_uid = int(getattr(current, "my_uid", 0) or 0)
fresh_uid = int(getattr(fresh, "my_uid", 0) or 0)
if current_uid and fresh_uid and current_uid != fresh_uid:
raise ValueError(
f"refusing cross-account session refresh: {current_uid} != {fresh_uid}"
)
fresh.conv_meta = {
**dict(getattr(current, "conv_meta", {}) or {}),
**dict(getattr(fresh, "conv_meta", {}) or {}),
}
if not fresh.ws_urls:
fresh.ws_urls = list(getattr(current, "ws_urls", []) or [])
fresh.egress_public_ip = str(
getattr(current, "egress_public_ip", "") or ""
)
fresh.egress_source_ip = str(
getattr(current, "egress_source_ip", "") or ""
)
fresh.egress_auto_attempts = int(
getattr(current, "egress_auto_attempts", 1) or 1
)
self.session = fresh
if self._ws_client is not None:
self._ws_client.session = fresh
async def _send_text(
self,
conversation_id: str,
content: str,
conversation_short_id: str = "",
expected_peer_uid: str = "",
) -> tuple[bool, Optional[dict]]:
async with self._session_lock:
return await self._send_text_unlocked(
conversation_id,
content,
conversation_short_id=conversation_short_id,
expected_peer_uid=expected_peer_uid,
)
async def _send_text_unlocked(
self,
conversation_id: str,
content: str,
conversation_short_id: str = "",
expected_peer_uid: str = "",
) -> tuple[bool, Optional[dict]]:
"""发送一条私信;若因签名凭证失效(7911)失败,刷新 web_protect 后自动重试一次。
返回 (是否成功, 解析到的会话 meta)。失败原因写入 self.last_error。
"""
for attempt in range(2):
async with DouyinImHttpClient(self.session, account_id=self.account_id) as http:
sent = await http.send_text_message(
conversation_id,
content,
conversation_short_id=conversation_short_id,
expected_peer_uid=expected_peer_uid,
)
self.last_error = http.last_error
needs_refresh = http.last_send_needs_refresh
if sent:
resolved = http.last_send_meta.get(conversation_id)
self.session.conv_meta.update(http.session.conv_meta)
self._session_invalid_strikes = 0 # 发送成功 → 登录有效
return True, resolved
# 仅在“签名凭证失效”时刷新并重试一次
if attempt == 0 and needs_refresh and self.refresh_credentials:
logger.warning(
f"Send hit credential-expiry(7911) for {conversation_id}; "
"refreshing web_protect and retrying once..."
)
try:
refreshed = await self.refresh_credentials()
except Exception as e:
logger.warning(f"refresh_credentials raised: {e}")
refreshed = False
if refreshed:
continue
break
# 第二套发送方案(浏览器页面内发送):仅处理非终态 7911。
# KICK/INVALID_REQUEST 会停止发送并进入下线处理,不在失效会话上重放。
upper_err = (self.last_error or "").upper()
# KICK already invalidated the login and INVALID_REQUEST is a
# protocol/session rejection. Replaying either through a browser
# fetch cannot heal it and creates another risky write. 7911 is the
# only non-terminal signing failure eligible for the browser fallback.
if self.send_fallback and "STATUS_CODE=7911" in upper_err:
try:
fb_ok, fb_detail = await self.send_fallback(conversation_id, content)
except Exception as exc:
logger.warning(f"send_fallback raised for {conversation_id}: {exc}")
fb_ok, fb_detail = False, f"浏览器兜底发送异常:{exc}"
if fb_ok:
self._session_invalid_strikes = 0
self._session_invalid_fired = False # 兜底成功说明登录仍有效,撤销自动下线
system_logger.record(
"浏览器兜底发送成功",
detail=f"会话 {conversation_id}{fb_detail}",
level="success",
category="send",
account_id=self.account_id,
)
return True, None
system_logger.record(
"浏览器兜底发送失败",
detail=f"会话 {conversation_id}{fb_detail}",
level="error",
category="send",
account_id=self.account_id,
)
await self._note_session_invalid(self.last_error)
return False, None
async def _note_session_invalid(self, error: str) -> None:
"""根据发送失败原因判断 IM 是否已退出登录,并触发自动下线。
INVALID_REQUEST 来自 create_conversation/发送:会话/签名被抖音判为无效,强相关于「登录失效」。
decision=KICK 是安全网关明确要求终止当前登录态,一次即可确认,无需等待第二次发送。
而 8xxx/7xxx 等业务错误(关系/频控/内容)说明请求已到达抖音、登录仍有效,重置计数。
"""
err = error or ""
upper_err = err.upper()
is_kicked = "DECISION=KICK" in upper_err
is_invalid_request = "INVALID_REQUEST" in upper_err
if not is_invalid_request and not is_kicked:
self._session_invalid_strikes = 0
return
self._session_invalid_strikes += 1
threshold = 1 if is_kicked else 2
if self._session_invalid_strikes < threshold or self._session_invalid_fired:
return
self._session_invalid_fired = True
if is_kicked:
reason = "抖音安全网关已踢下线(decision=KICK)"
failure_detail = "发送接口返回 decision=KICK"
else:
reason = "IM 会话失效(INVALID_REQUEST),登录可能已退出"
failure_detail = f"连续 {self._session_invalid_strikes} 次发送返回 INVALID_REQUEST"
logger.warning(
f"Account {self.account_id} {reason} -> 自动下线"
)
system_logger.record(
"IM 登录失效,自动下线",
detail=f"{reason}{failure_detail})。"
"系统正在自动重登录,请留意账号卡片上的登录二维码并扫码。",
level="error",
category="auth",
account_id=self.account_id,
)
self._running = False # 让主循环尽快退出
if self.on_session_invalid:
try:
await self.on_session_invalid(reason)
except Exception as e:
logger.error(f"on_session_invalid handler error: {e}")
async def send_message(
self,
conversation_id: str,
content: str,
expected_peer_uid: str = "",
) -> bool:
"""手动发送私信。
``expected_peer_uid`` 由调用方(消息页)指定收件人,写入点会在发出去
之前核对,避免界面按昵称匹配到同名的另一个人。
"""
from .conv_util import normalize_conversation_id
from .auth import DouyinAuth
from .dy_util import DEFAULT_USER_AGENT
auth = DouyinAuth()
auth.perepare_auth(
self.session.cookie_header(),
self.session.web_protect_str,
self.session.keys_str,
user_agent=self.session.user_agent or DEFAULT_USER_AGENT,
)
my_uid = self.session.my_uid
if not my_uid:
my_uid = await asyncio.to_thread(lambda: auth.get_uid()) or 0
if my_uid:
conversation_id = normalize_conversation_id(conversation_id, my_uid)
meta = self._conv_meta.get(conversation_id, {})
sent, resolved = await self._send_text(
conversation_id,
content,
conversation_short_id=str(meta.get("conversation_short_id") or ""),
expected_peer_uid=expected_peer_uid,
)
if sent and resolved:
self._conv_meta[conversation_id] = {
**meta,
**resolved,
"conversation_id": conversation_id,
}
return sent