Files
2026-07-17 10:21:18 +08:00

522 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
import logging
import os
import random
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()
# 轮询错峰:多账号同时托管时,若所有账号按同一节奏轮询,请求会在同一
# 时刻叠峰。这里给每个账号随机相位偏移 + 每轮 ±20% 抖动,把请求摊平。
# WS 可用时轮询只是兜底,可以适当放缓(KEFU_IM_POLL_INTERVAL_SECONDS 可调)。
try:
poll_interval = float(os.getenv("KEFU_IM_POLL_INTERVAL_SECONDS", "") or 15)
except ValueError:
poll_interval = 15.0
poll_interval = max(5.0, poll_interval)
if has_ws:
poll_interval = max(poll_interval, 30.0)
# 首轮轮询前的随机延迟(相位偏移),批量启动时错开各账号的首波请求
await asyncio.sleep(random.uniform(0.5, min(10.0, poll_interval)))
if not self._running:
return
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
next_poll = time.monotonic() + poll_interval * random.uniform(0.8, 1.2)
while self._running:
loop_count += 1
try:
if time.monotonic() >= next_poll:
next_poll = time.monotonic() + poll_interval * random.uniform(0.8, 1.2)
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