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