更新
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user