This commit is contained in:
Your Name
2026-07-17 09:24:47 +08:00
commit 530e7f839d
4353 changed files with 731879 additions and 0 deletions
+501
View File
@@ -0,0 +1,501 @@
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_payload import format_reply_display, serialize_reply_log
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_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
self.follow_tick = follow_tick
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._cooldown_override = (
max(0, int(reply_cooldown_seconds)) if reply_cooldown_seconds is not None else None
)
self._cooldown_resolver = cooldown_resolver
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()
self._last_reply_at: dict[str, float] = {}
self._conv_previews: dict[str, str] = {}
self._conv_names: dict[str, str] = {}
self._conv_meta: dict[str, dict] = {}
self._ws_client: Optional[DouyinImWsClient] = None
self.last_error: str = ""
self.last_send_risk_notice: str = ""
def _reply_key(self, sender: str, content: str) -> str:
return f"{sender}::{content}"
def _cooldown_seconds_sync(self) -> int:
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:
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 _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 _handle_incoming(self, msg: dict):
if self._is_self_message(msg):
return
sender = self._resolve_sender_name(msg)
content = (msg.get("content") or "").strip()
unread = int(msg.get("unread_count") or 0)
conv_id = msg.get("conversation_id") or ""
sender_uid = str(msg.get("sender_uid") or "")
sender_avatar = str(msg.get("sender_avatar") or "").strip()
# 每条 WS 消息带唯一 server_message_id:用它去重,避免“同一用户重复发送
# 相同文字(如多次‘你好’)被按内容去重而整条丢弃”,这是“有时收不到”的根因。
# HTTP 轮询的会话预览没有该 ID,则退回按 内容 去重(避免对同一未读重复回复)。
server_message_id = str(msg.get("server_message_id") or "")
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": sender_uid,
}
if sender and sender_uid:
self._conv_names[sender_uid] = sender
if not content and unread <= 0:
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:
log_key = self._reply_key(sender, content or "[未读]")
key = self._reply_key(sender, content)
log_kwargs = {
"sender_name": sender,
"sender_id": conv_id or sender_uid or None,
"sender_avatar": sender_avatar or self._conv_meta.get(conv_id, {}).get("sender_avatar"),
"message": content,
}
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",
)
if key in self._replied_keys:
return
prev = self._conv_previews.get(sender)
should_reply = unread > 0 or (prev and content != prev) or (content and content != "[未读消息]")
if not should_reply:
if content:
self._conv_previews[sender] = content
return
from .hosted_registry import is_hosted
if sender_uid and is_hosted(sender_uid):
# 对方也是本系统托管的账号:若双方都自动回复会形成无限回环,
# 高频来回发送极易触发抖音风控(7911)/业务拒绝(8004),故直接跳过。
await self.log_fn(
**log_kwargs,
reply=None,
status="ignored",
error="对方是本系统托管的另一账号,已跳过自动回复以避免互相回复触发风控",
)
self._replied_keys.add(key)
if content:
self._conv_previews[sender] = content
logger.info(
f"Skip auto-reply to hosted account {sender_uid} (avoid reply loop)"
)
return
from .protocol import should_skip_auto_reply
skip, skip_reason = should_skip_auto_reply(content)
if skip:
await self.log_fn(
**log_kwargs,
reply=None,
status="ignored",
error=skip_reason,
)
self._replied_keys.add(key)
if content:
self._conv_previews[sender] = content
logger.info(f"Skip auto-reply to {sender}: {content!r} ({skip_reason})")
return
replies = await self.match_reply(content if content != "[未读消息]" else "")
if not replies:
await self.log_fn(
**log_kwargs,
reply=None,
status="ignored",
error="未配置任何自动回复规则",
)
self._replied_keys.add(key)
if content:
self._conv_previews[sender] = content
return
peer_key = (sender_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")
self._replied_keys.add(key)
if content:
self._conv_previews[sender] = content
return
if peer_key and cooldown > 0:
self._last_reply_at[peer_key] = time.monotonic()
if self.reply_delay_seconds > 0:
logger.info(f"Delaying reply to {sender} for {self.reply_delay_seconds}s")
await asyncio.sleep(self.reply_delay_seconds)
if not self._running:
return
reply_displays = []
sent_any = False
send_error = ""
failed_parts: list[str] = []
meta = self._conv_meta.get(conv_id, {})
for index, reply in enumerate(replies):
if index > 0:
await asyncio.sleep(0.6)
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:
part_err = self.last_error or "IM API 发送失败"
send_error = part_err
failed_parts.append(f"{reply_display}: {part_err}")
else:
send_error = "缺少会话 ID,无法发送自动回复"
failed_parts.append(f"{reply_display}: {send_error}")
if sent:
sent_any = True
combined_display = " | ".join(reply_displays)
reply_log_content = serialize_reply_log(replies)
partial = sent_any and bool(failed_parts)
if partial:
send_error = "".join(failed_parts)
if not sent_any:
logger.warning(
f"IM API send failed for [{sender}]: {send_error}"
)
if peer_key and cooldown > 0:
self._last_reply_at.pop(peer_key, None)
self._replied_keys.add(key)
if content:
self._conv_previews[sender] = content
await self.log_fn(
**log_kwargs,
reply=reply_log_content,
status="replied" if sent_any and not partial else ("partial" if partial else "failed"),
error=None if sent_any and not partial else (send_error or "IM API 发送失败"),
)
if sent_any and not partial:
system_logger.record(
"自动回复成功",
detail=f"已回复 {sender}{combined_display}",
level="success",
category="send",
account_id=self.account_id,
)
elif partial:
system_logger.record(
"自动回复部分失败",
detail=f"回复 {sender} 部分成功:{combined_display};失败:{send_error}",
level="warning",
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}, partial={partial}, msgs={len(replies)})"
)
def _index_conversations(self, conversations: list[dict]):
for conv in conversations:
conv_id = str(conv.get("conversation_id") or "")
name = (conv.get("sender_name") or "").strip()
if conv_id:
self._conv_meta[conv_id] = conv
if name:
self._conv_names[conv_id] = name
peer_uid = str(conv.get("peer_uid") or "")
if peer_uid and name:
self._conv_names[peer_uid] = name
async def _poll_conversations(self):
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()
self._index_conversations(conversations)
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 run(self):
"""主循环:WebSocket + HTTP 轮询"""
self._running = True
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,
)
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:
loop_count += 1
try:
if loop_count % 3 == 1:
await self._poll_conversations()
if loop_count % 6 == 1:
logger.info(f"IM direct tick #{loop_count} account={self.account_id}")
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,
)
await asyncio.sleep(5)
async def stop(self):
self._running = False
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,
"content": str(meta.get("content") or ""),
"unread_count": int(meta.get("unread_count") or 0),
})
return results
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。
"""
self.last_send_risk_notice = ""
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
self.last_send_risk_notice = http.last_send_risk_notice
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)
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
return False, None
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,
)
my_uid = 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