This commit is contained in:
Your Name
2026-09-01 15:31:05 +08:00
parent 1f3addcf79
commit 2fc864cc00
20 changed files with 1381 additions and 45 deletions
+148 -14
View File
@@ -17,7 +17,8 @@ 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 . import hosted_registry
from .conv_util import conversation_belongs_to, resolve_peer_uid
from .peer_profile import (
enrich_conversation_item,
fetch_peer_profile,
@@ -338,6 +339,10 @@ class DouyinImService:
self._ready_notified = False
self._session_invalid_strikes = 0
self._session_invalid_fired = False
# A keepalive browser may refresh cookies/security material while an
# outbound reply is being prepared. Serialize the short credential
# hand-off with sends so one request never mixes old and new state.
self._session_lock = asyncio.Lock()
self.reply_delay_seconds = max(0, int(reply_delay_seconds or 0))
# 实时解析账号排队间隔:账号专属优先,否则使用系统默认值。
self._reply_delay_resolver = reply_delay_resolver
@@ -355,15 +360,19 @@ class DouyinImService:
self._cooldown_resolver = cooldown_resolver
# 由 worker 注入:触发后台重新采集 web_protect/keys(刷新 ts_sign),返回是否刷新成功
self.refresh_credentials = refresh_credentials
# 由 worker 注入的第二套发送方案:当 HTTP 签名发送被安全网关拒绝
# decision=KICK / 7911 / INVALID_REQUEST)时,用浏览器页面上下文
# 重新发送(真实 JS 生成 a_bogus/bd-ticket-guard,可自愈被踢的会话)
# 由 worker 注入的第二套发送方案:当 HTTP 返回非终态的 7911
# 签名错误时,可在同一账号/同一出口的浏览器页面上下文重试一次。
# KICK 与 INVALID_REQUEST 不得重放,避免在已失效会话上继续写请求
# 签名: async (conversation_id, content) -> (ok, detail)
self.send_fallback = send_fallback
self._running = False
self._replied_keys: set[str] = set()
self._logged_keys: set[str] = set()
self._received_logged_keys: set[str] = set()
# 已告警过的「不属于本账号」的会话,避免同一条串号会话刷屏
self._foreign_conv_logged: set[str] = set()
# 已告警过的「对方也是本系统托管账号」的 peer,避免同一对账号刷屏
self._hosted_peer_logged: set[str] = set()
# 每个对话/用户最近一次自动回复的时间戳(monotonic 秒),用于冷却窗口去重
self._last_reply_at: dict[str, float] = {}
self._conv_previews: dict[str, str] = {}
@@ -510,6 +519,38 @@ class DouyinImService:
return f"用户{sender_uid[-6:]}" if len(sender_uid) > 6 else f"用户{sender_uid}"
return "未知用户"
def _conversation_is_mine(self, conv_id: str) -> bool:
"""本账号是否为该单聊会话的参与方;不是就丢弃,绝不改写后发送。"""
my_uid = int(self.session.my_uid or 0)
if conversation_belongs_to(conv_id, my_uid):
return True
conv_key = str(conv_id or "")
logger.warning(
"Account %s dropped a message from foreign conversation %s "
"(my_uid=%s); two accounts most likely share one set of credentials",
self.account_id,
conv_key,
my_uid,
)
if conv_key not in self._foreign_conv_logged:
if len(self._foreign_conv_logged) > 200:
self._foreign_conv_logged.clear()
self._foreign_conv_logged.add(conv_key)
system_logger.record(
"已丢弃不属于本账号的私信",
detail=(
f"会话 {conv_key} 的参与方都不是本账号(uid={my_uid}),"
"该消息属于另一个账号,已丢弃且不会自动回复。"
"常见原因:多个账号的凭证来自同一台机器/同一个浏览器,"
"frontier 长连接按设备号寻址导致两个账号互相收到对方的私信。"
"请为每个账号单独采集凭证(独立浏览器配置/设备)。"
),
level="warning",
category="recv",
account_id=self.account_id,
)
return False
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:
@@ -637,10 +678,18 @@ class DouyinImService:
self,
msg: dict,
) -> Optional[Callable[[], Awaitable[None]]]:
conv_id = msg.get("conversation_id") or ""
# 跨账号隔离:只处理本账号自己的会话。frontier 按设备号寻址推送,
# 同一台机器/同一浏览器采集出来的多个账号 device_id 可能相同,两条长连接
# 会订阅到同一个地址并互相收到对方的私信。若不在这里拦住,
# normalize_conversation_id 会把别人的会话改写成
# 0:1:{本账号}:{别人的好友},本账号就把自动回复发给了另一个账号的好友。
if not self._conversation_is_mine(conv_id):
return
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()
@@ -769,6 +818,44 @@ class DouyinImService:
# 防止延迟排队期间被重复加入发送队列。
self._replied_keys.add(key)
# 对方也是本系统托管的账号:双方都会自动回复,一来一回就是无限回环。
# 这种高频互发是触发抖音风控(7911)/业务拒绝(8004)的常见根因,因此消息
# 照常记录,但不再自动回复。需要回复请用消息页手动发送。
if peer_uid and hosted_registry.is_hosted(peer_uid):
await self.log_fn(
**log_kwargs,
reply=None,
status="ignored",
error=(
f"对方(UID {peer_uid})也是本系统托管中的账号,"
"自动回复会在两个账号之间形成无限回环并触发抖音风控,已跳过;"
"如需回复请在消息页手动发送"
),
)
if content:
self._conv_previews[sender] = content
if peer_uid not in self._hosted_peer_logged:
if len(self._hosted_peer_logged) > 200:
self._hosted_peer_logged.clear()
self._hosted_peer_logged.add(peer_uid)
logger.info(
"Account %s skipped auto-reply to hosted account %s",
self.account_id,
peer_uid,
)
system_logger.record(
"自动回复已跳过(对方也是托管账号)",
detail=(
f"{sender}UID {peer_uid})是本系统托管中的另一个账号。"
"两个托管账号互相自动回复会形成无限回环,"
"属于抖音风控(7911/8004)的高发场景,因此只记录消息、不自动回复。"
),
level="warning",
category="send",
account_id=self.account_id,
)
return
# 同账号、同会话只保留一个尚未发送的回复任务。后续来信只追加到
# 原任务详情,不改变它的发送时间、位置或已经匹配好的回复。
queue_merge_keys = self._reply_queue_merge_keys(conv_id, peer_uid)
@@ -1428,11 +1515,60 @@ class DouyinImService:
"""把指定自动回复任务移入账号紧急队列;实际发送仍由单消费者串行执行。"""
return await self._reply_queue.send_now(job_id)
async def replace_session(self, fresh: DouyinImSession) -> None:
"""Atomically install a freshly harvested login/security session.
The running WebSocket can keep its current connection, but future
reconnects and every HTTP send must see the same refreshed object.
Account egress selection lives outside persisted IM credentials, so it
is deliberately carried over from the current runtime session.
"""
async with self._session_lock:
current = self.session
current_uid = int(getattr(current, "my_uid", 0) or 0)
fresh_uid = int(getattr(fresh, "my_uid", 0) or 0)
if current_uid and fresh_uid and current_uid != fresh_uid:
raise ValueError(
f"refusing cross-account session refresh: {current_uid} != {fresh_uid}"
)
fresh.conv_meta = {
**dict(getattr(current, "conv_meta", {}) or {}),
**dict(getattr(fresh, "conv_meta", {}) or {}),
}
if not fresh.ws_urls:
fresh.ws_urls = list(getattr(current, "ws_urls", []) or [])
fresh.egress_public_ip = str(
getattr(current, "egress_public_ip", "") or ""
)
fresh.egress_source_ip = str(
getattr(current, "egress_source_ip", "") or ""
)
fresh.egress_auto_attempts = int(
getattr(current, "egress_auto_attempts", 1) or 1
)
self.session = fresh
if self._ws_client is not None:
self._ws_client.session = fresh
async def _send_text(
self,
conversation_id: str,
content: str,
conversation_short_id: str = "",
) -> tuple[bool, Optional[dict]]:
async with self._session_lock:
return await self._send_text_unlocked(
conversation_id,
content,
conversation_short_id=conversation_short_id,
)
async def _send_text_unlocked(
self,
conversation_id: str,
content: str,
conversation_short_id: str = "",
) -> tuple[bool, Optional[dict]]:
"""发送一条私信;若因签名凭证失效(7911)失败,刷新 web_protect 后自动重试一次。
@@ -1468,16 +1604,14 @@ class DouyinImService:
continue
break
# 第二套发送方案(浏览器页面内发送):
# HTTP 签名发送被安全网关拒绝(KICK/7911/INVALID_REQUEST)时,交给 worker
# 用浏览器页面上下文重发——由抖音页面自带的 security-sdk 在真实环境生成
# a_bogus/bd-ticket-guard,绕开我们 Node execjs 的签名模拟,可自愈被踢会话。
# 第二套发送方案(浏览器页面内发送):仅处理非终态 7911。
# KICK/INVALID_REQUEST 会停止发送并进入下线处理,不在失效会话上重放。
upper_err = (self.last_error or "").upper()
if self.send_fallback and (
"DECISION=KICK" in upper_err
or "STATUS_CODE=7911" in upper_err
or "INVALID_REQUEST" in upper_err
):
# KICK already invalidated the login and INVALID_REQUEST is a
# protocol/session rejection. Replaying either through a browser
# fetch cannot heal it and creates another risky write. 7911 is the
# only non-terminal signing failure eligible for the browser fallback.
if self.send_fallback and "STATUS_CODE=7911" in upper_err:
try:
fb_ok, fb_detail = await self.send_fallback(conversation_id, content)
except Exception as exc: