997 lines
44 KiB
Python
997 lines
44 KiB
Python
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,
|
||
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]):
|
||
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
|