import asyncio import logging import time 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 .conv_util import 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]] 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, follow_tick: Optional[Callable[[], Awaitable[None]]] = None, on_session_invalid: Optional[Callable[[str], Awaitable[None]]] = 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)时回调,用于自动下线 self.on_session_invalid = on_session_invalid self._session_invalid_strikes = 0 self._session_invalid_fired = False 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 self._running = False self._replied_keys: set[str] = set() self._logged_keys: set[str] = set() self._received_logged_keys: 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._ws_client: Optional[DouyinImWsClient] = None self.last_error: str = "" 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 _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]]]: if self._is_self_message(msg): return conv_id = msg.get("conversation_id") or "" 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=26)content 为空:按 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) # 同账号、同会话只保留一个尚未发送的回复任务。后续来信只追加到 # 原任务详情,不改变它的发送时间、位置或已经匹配好的回复。 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, ) logger.info( "Queued reply to %s for account %s: position=%s interval=%ss", sender, self.account_id, position, delay_seconds, ) system_logger.record( "自动回复已进入账号队列", detail=( f"{sender} 当前排在第 {position} 位;账号生效间隔为 {delay_seconds} 秒," "账号内计时与排位独立;到点后再进入全局带宽队列逐条投递。" ), 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]): 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 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): 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) # 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: unread = int(conv.get("unread_count") or 0) if unread > 0 or conv.get("content"): await self._handle_incoming(conv) 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"): 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 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"): 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"): 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() try: await self._poll_conversations() 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, ) loop_count = 0 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 loop_count += 1 try: if loop_count % 3 == 0: await self._poll_conversations() if loop_count % 6 == 0: logger.info(f"IM direct tick #{loop_count} account={self.account_id}") # 关注欢迎语:约每 60s 检测一次新粉丝(独立于私信轮询,失败不影响主循环) if self.follow_tick and loop_count % 12 == 0: try: await self.follow_tick() except Exception as e: logger.error(f"follow welcome tick error: {e}") 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 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 _send_text( self, conversation_id: str, content: str, conversation_short_id: 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, ) 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 await self._note_session_invalid(self.last_error) return False, None async def _note_session_invalid(self, error: str) -> None: """根据发送失败原因判断 IM 是否已退出登录;连续 INVALID_REQUEST 即触发自动下线。 INVALID_REQUEST 来自 create_conversation/发送:会话/签名被抖音判为无效,强相关于「登录失效」。 而 8xxx/7xxx 等业务错误(关系/频控/内容)说明请求已到达抖音、登录仍有效,重置计数。 """ err = error or "" if "INVALID_REQUEST" not in err: self._session_invalid_strikes = 0 return self._session_invalid_strikes += 1 if self._session_invalid_strikes < 2 or self._session_invalid_fired: return self._session_invalid_fired = True reason = "IM 会话失效(INVALID_REQUEST),登录可能已退出" logger.warning( f"Account {self.account_id} {reason};连续 {self._session_invalid_strikes} 次 -> 自动下线" ) system_logger.record( "IM 登录失效,自动下线", detail=f"{reason}(连续 {self._session_invalid_strikes} 次发送返回 INVALID_REQUEST)。" "请停止托管后用浏览器模式重新登录并打开私信页,再重新启动托管。", 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) -> bool: """手动发送私信""" from .conv_util import normalize_conversation_id from .auth import DouyinAuth auth = DouyinAuth() auth.perepare_auth( self.session.cookie_header(), self.session.web_protect_str, self.session.keys_str, ) if getattr(self.session, "uid_verified", False) and self.session.my_uid: my_uid = self.session.my_uid else: my_uid = await asyncio.to_thread(lambda: auth.get_uid()) or self.session.my_uid 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 ""), ) if sent and resolved: self._conv_meta[conversation_id] = { **meta, **resolved, "conversation_id": conversation_id, } return sent