This commit is contained in:
Your Name
2026-07-23 17:56:25 +08:00
parent a05dae8412
commit 4970d8f8d3
4262 changed files with 735221 additions and 0 deletions
+983
View File
@@ -0,0 +1,983 @@
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=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)
# 同账号、同会话只保留一个尚未发送的回复任务。后续来信只追加到
# 原任务详情,不改变它的发送时间、位置或已经匹配好的回复。
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