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
+1
View File
@@ -129,6 +129,7 @@ class DouyinAuth:
auth.device_id = resolve_proto_device_id(
session.device_id, session.web_id, session.my_uid
)
auth.source_ip = str(getattr(session, "egress_source_ip", "") or "")
# web_protect 缺 client_cert 时,才用 frontier 抓包证书兜底(不覆盖 ts_sign)
if not auth.client_cert and getattr(session, "sdk_cert", ""):
auth.client_cert = normalize_client_cert(session.sdk_cert)
+23
View File
@@ -45,3 +45,26 @@ def normalize_conversation_id(conversation_id: str, my_uid: int) -> str:
if peer_uid and my_uid:
return build_conversation_id(my_uid, peer_uid)
return (conversation_id or "").strip()
def conversation_belongs_to(conversation_id: str, my_uid: int) -> bool:
"""判断单聊会话是否属于 my_uid 本人。
托管多个账号时,一条属于别的账号的会话(例如 frontier 长连接按设备号寻址
造成的跨账号推送)一旦流进本账号的处理链路,resolve_peer_uid 会把末段当成
「对方」、normalize_conversation_id 再拼成 0:1:{本账号}:{别人的好友},于是
本账号就把消息发给了另一个账号的好友。这里给出唯一的归属判据。
无法判定时一律返回 True(保守放行):缺 my_uid、群聊、裸 UID 等形态本来就
不带参与方信息。只有两个参与方都已知、且都不是本账号时才判定为不属于本账号。
"""
try:
uid = int(my_uid or 0)
except (TypeError, ValueError):
return True
if not uid:
return True
parts = parse_conversation_parts(conversation_id)
if not parts:
return True
return uid in parts
+6 -1
View File
@@ -102,11 +102,16 @@ def resolve_frontier_device_id(session: DouyinImSession) -> str:
return ""
def _ws_device_id(url: str) -> str:
def ws_device_id(url: str) -> str:
"""frontier 推送的寻址键:设备号(不是账号 UID)。"""
m = re.search(r"[?&]device_id=([^&\s]+)", url or "")
return unquote(m.group(1)) if m else ""
# 兼容内部旧引用
_ws_device_id = ws_device_id
def _ws_device_matches_session(session: DouyinImSession, url: str) -> bool:
ws_dev = _ws_device_id(url)
if not ws_dev or not ws_dev.isdigit():
+50 -5
View File
@@ -14,7 +14,12 @@ from rpa_engine.egress_channels import (
resolve_fixed_channel,
resolve_send_channels,
)
from .conv_util import build_conversation_id, normalize_conversation_id, resolve_peer_uid
from .conv_util import (
build_conversation_id,
conversation_belongs_to,
normalize_conversation_id,
resolve_peer_uid,
)
from .message_content import format_im_message, serialize_message_content
from .peer_profile import enrich_conversation_item, fetch_peer_profile, is_generic_peer_name
from .protocol import normalize_im_payload_from_bytes, _pick_avatar_url
@@ -1144,8 +1149,17 @@ class DouyinImHttpClient:
self.session.my_uid,
uid,
)
previous = int(self.session.my_uid or 0)
self.session.my_uid = uid
self.session.uid_verified = True
# 托管注册表按 UID 记录「本系统正在托管谁」。纠正后必须迁移,否则回环
# 防护会认错人:旧 UID 永远留在表里,真实 UID 从未登记。只迁移确实已登记
# 的托管身份,避免 API 侧的临时客户端把自己也登记进去。
from . import hosted_registry
if hosted_registry.is_hosted(previous):
hosted_registry.unregister(previous)
hosted_registry.register(uid)
async def get_conversations(
self,
@@ -1383,6 +1397,26 @@ class DouyinImHttpClient:
self._set_error("无法获取当前账号 UID")
self._log_send_failure(conversation_id, "无法获取当前账号 UID(Cookie 可能已失效)")
return False
# 跨账号写入闸门:normalize_conversation_id 会把任何会话 ID 改写成
# 0:1:{本账号}:{末段 UID},所以一条属于别的账号的会话流到这里会被
# 静默改写并发给对方的好友。发送前先确认本账号确实是该会话的参与方。
if not conversation_belongs_to(conversation_id, my_uid):
detail = (
f"会话 {conversation_id} 的参与方都不是本账号(uid={my_uid}),"
"拒绝发送:这条会话属于另一个账号,继续发送会把消息发给别人的好友。"
)
self._set_error(detail)
self.last_send_channel_retryable = False
self._log_send_failure(conversation_id, detail)
logger.error(
"Account %s refused cross-account send to %s (my_uid=%s)",
self.account_id,
conversation_id,
my_uid,
)
return False
if not auth.is_sign_ready():
self._set_error("缺少 IM 签名密钥,请用浏览器登录补全 localStorage")
self._log_send_failure(
@@ -1510,10 +1544,14 @@ class DouyinImHttpClient:
decision = str(result.get("decision") or "").strip().upper()
if decision == "KICK":
self.last_send_channel_retryable = True
# KICK is a terminal, account-session decision. Retrying the
# same authenticated write from another source address cannot
# repair the session and only adds another high-risk request.
self.last_send_needs_refresh = False
self.last_send_channel_retryable = False
detail = (
"抖音安全网关返回 decision=KICK,当前登录/安全会话已被服务端踢下线;"
"系统正在自动重登录,请留意账号卡片上的二维码并扫码"
"已停止本次发送及公网通道重试,系统正在自动重登录,请留意账号卡片上的二维码并扫码"
)
elif decision:
detail = f"抖音安全网关拒绝发送 decision={decision}"
@@ -1527,7 +1565,11 @@ class DouyinImHttpClient:
hint = _BUSINESS_REJECT_FALLBACK
# 7911 属于“签名凭证失效/安全校验未过”,标记为可刷新后重试
self.last_send_needs_refresh = status_code in _CREDENTIAL_EXPIRED_CODES
self.last_send_channel_retryable = self.last_send_needs_refresh
# 7911 is a credential/signature problem. It may be retried
# once only after refreshing the credentials on the same
# session; switching egress mid-session makes the fingerprint
# less consistent and must not be used as the recovery path.
self.last_send_channel_retryable = False
detail = f"抖音拒绝投递 status_code={status_code}"
if status_reason:
detail += f";抖音提示:{status_reason}"
@@ -1550,7 +1592,10 @@ class DouyinImHttpClient:
detail = "".join(reason_bits) or "接口返回但未确认投递(无 server_message_id"
if "INVALID_REQUEST" in detail.upper():
self.last_send_channel_retryable = True
# INVALID_REQUEST is a protocol/session rejection, not a
# transport failure. A second public IP sends the same invalid
# request and can invalidate an otherwise recoverable login.
self.last_send_channel_retryable = False
full_detail = f"{detail}{target}resp[{result.get('summary')}]"
if self.last_request_debug:
full_detail += f"\n--- 请求详情 ---\n{self.last_request_debug}"
+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:
+89 -2
View File
@@ -103,6 +103,12 @@ _LOOP_STATES: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, _LoopWsState
)
# frontier 按 device_id 寻址推送:两个托管账号共用同一个设备号时,两条长连接会
# 订阅到同一个地址并互相收到对方的私信。真正的拦截在 service 的会话归属校验里,
# 这里只负责把「为什么会串号」明确告诉用户。持弱引用,账号停管后自动失效。
_FRONTIER_DEVICE_OWNERS: "dict[str, weakref.ref[DouyinImWsClient]]" = {}
def _get_loop_state() -> _LoopWsState:
loop = asyncio.get_running_loop()
state = _LOOP_STATES.get(loop)
@@ -155,6 +161,8 @@ class DouyinImWsClient:
self._dispatcher_task: Optional[asyncio.Task] = None
self._received_frame_count = 0
self._heartbeat_ack_logged = False
self._frontier_device_id = ""
self._blocked_device_owner_id: Optional[int] = None
async def start(self):
if self._task and not self._task.done():
@@ -210,6 +218,7 @@ class DouyinImWsClient:
if self._task is task:
self._task = None
self._connection = None
self._release_frontier_device()
await self._stop_dispatcher()
def _record_connection_system_event(
@@ -247,6 +256,7 @@ class DouyinImWsClient:
account_key = int(self.account_id or 0)
state.system_log_last_at.pop((account_key, "connected"), None)
state.system_log_last_at.pop((account_key, "retry"), None)
state.system_log_last_at.pop((account_key, "device_taken"), None)
def _ensure_dispatcher(self) -> None:
if self._dispatcher_task and not self._dispatcher_task.done():
@@ -311,8 +321,15 @@ class DouyinImWsClient:
first_attempt = False
if not connect_url:
raise RuntimeError("frontier WebSocket URL is unavailable")
logger.info("Connecting IM WebSocket: %s...", connect_url[:100])
await self._run_connection(connect_url)
if self._claim_frontier_device(connect_url):
logger.info("Connecting IM WebSocket: %s...", connect_url[:100])
await self._run_connection(connect_url)
else:
# 设备号已被另一个在跑的账号占用:绝不并连同一个推送地址,
# 本账号本轮退回 HTTP 轮询兜底(connected 保持 False
# service 会自动切到更快的会话对账节奏),并在退避后重试,
# 等占用方停管时自动接管。
self._report_frontier_device_taken(connect_url)
except asyncio.CancelledError:
break
except Exception as exc:
@@ -348,6 +365,76 @@ class DouyinImWsClient:
except asyncio.CancelledError:
break
self._release_frontier_device()
def _frontier_device_owner(self, device_id: str) -> "Optional[DouyinImWsClient]":
"""当前仍活着的设备号占用方(run 循环任务还在跑才算数)。"""
reference = _FRONTIER_DEVICE_OWNERS.get(device_id)
owner = reference() if reference is not None else None
if owner is None or owner is self:
return None
task = owner._task
if not owner._running or task is None or task.done():
return None
return owner
def _claim_frontier_device(self, url: str) -> bool:
"""独占本账号的 frontier 设备地址;已被别的账号占用时返回 False。
frontier 按 device_id 寻址推送。两个账号共用同一个设备号时,同时建连
会让两条连接互相收到对方的私信(串号的根因),且抖音也可能只保留最后
一条连接、把先连上的那个账号踢成「连着但收不到」。所以同一个设备地址
永远只允许一个账号建连,另一个账号走 HTTP 轮询兜底。
"""
from .frontier import ws_device_id
device_id = ws_device_id(url)
if not device_id:
# 判不出设备号(自建地址/异常格式)时不阻断连接,交给会话归属校验兜底。
return True
owner = self._frontier_device_owner(device_id)
if owner is not None and int(owner.account_id or 0) != int(self.account_id or 0):
self._blocked_device_owner_id = owner.account_id
return False
_FRONTIER_DEVICE_OWNERS[device_id] = weakref.ref(self)
self._frontier_device_id = device_id
self._blocked_device_owner_id = None
return True
def _report_frontier_device_taken(self, url: str) -> None:
from .frontier import ws_device_id
device_id = ws_device_id(url)
owner_id = self._blocked_device_owner_id
logger.error(
"Account %s cannot open frontier device_id %s: already held by "
"account %s; falling back to HTTP polling this round",
self.account_id,
device_id,
owner_id,
)
self._record_connection_system_event(
"device_taken",
"实时接收已让出:与另一个账号共用长连接设备号",
detail=(
f"本账号与账号 {owner_id} 的 frontier 设备号相同(device_id={device_id})。"
"同一个设备地址只允许一个账号建立长连接,否则两个账号会互相收到对方的"
"私信。本账号本轮不建连,改由 HTTP 会话轮询接收(有几十秒级延迟),"
"并在对方停止托管后自动接管。"
"根治办法:为每个账号在独立的浏览器配置/设备上重新采集凭证。"
),
level="error",
)
def _release_frontier_device(self) -> None:
device_id = self._frontier_device_id
self._frontier_device_id = ""
if not device_id:
return
reference = _FRONTIER_DEVICE_OWNERS.get(device_id)
if reference is not None and reference() is self:
_FRONTIER_DEVICE_OWNERS.pop(device_id, None)
def _connection_headers(self) -> list[tuple[str, str]]:
headers = [
("Pragma", "no-cache"),