更新
This commit is contained in:
@@ -7,6 +7,11 @@ from urllib.parse import urlparse
|
||||
import httpx
|
||||
|
||||
from utils import system_logger
|
||||
from rpa_engine.egress_channels import (
|
||||
EgressChannelUnavailable,
|
||||
resolve_fixed_channel,
|
||||
resolve_send_channels,
|
||||
)
|
||||
from .conv_util import build_conversation_id, normalize_conversation_id, resolve_peer_uid
|
||||
from .peer_profile import enrich_conversation_item, fetch_peer_profile, is_generic_peer_name
|
||||
from .protocol import normalize_im_payload, normalize_im_payload_from_bytes, _pick_avatar_url
|
||||
@@ -153,6 +158,24 @@ _BUSINESS_REJECT_FALLBACK = (
|
||||
# 这些 status_code 表示“签名凭证失效/安全校验未通过”,可通过重新采集 web_protect 后重试
|
||||
_CREDENTIAL_EXPIRED_CODES = {7911}
|
||||
|
||||
_CHANNEL_RETRYABLE_ERROR_MARKERS = (
|
||||
"INVALID_REQUEST",
|
||||
"DECISION=KICK",
|
||||
"STATUS_CODE=7911",
|
||||
"ALL CONNECTION ATTEMPTS FAILED",
|
||||
"CANNOT ASSIGN REQUESTED ADDRESS",
|
||||
"CONNECTTIMEOUT",
|
||||
"CONNECT TIMEOUT",
|
||||
"CONNECTION REFUSED",
|
||||
"NETWORK IS UNREACHABLE",
|
||||
"NO ROUTE TO HOST",
|
||||
)
|
||||
|
||||
|
||||
def _is_channel_retryable_error_text(detail: str) -> bool:
|
||||
upper = str(detail or "").upper()
|
||||
return any(marker in upper for marker in _CHANNEL_RETRYABLE_ERROR_MARKERS)
|
||||
|
||||
|
||||
def _mask_proxy(url: str) -> str:
|
||||
"""隐藏代理 URL 中的用户名/密码,仅用于日志展示。"""
|
||||
@@ -204,6 +227,8 @@ def _format_im_request_debug(
|
||||
payload_len: int = 0,
|
||||
proto_hint: dict | None = None,
|
||||
proxy: str = "",
|
||||
egress_public_ip: str = "",
|
||||
egress_source_ip: str = "",
|
||||
) -> str:
|
||||
"""格式化 IM 请求诊断信息(脱敏),便于用户贴日志排查 7911。"""
|
||||
lines = [f"[IM请求/{label}] POST {url}"]
|
||||
@@ -266,6 +291,12 @@ def _format_im_request_debug(
|
||||
)
|
||||
if proxy:
|
||||
lines.append(f" proxy: {_mask_proxy(proxy)}")
|
||||
if egress_public_ip or egress_source_ip:
|
||||
lines.append(
|
||||
" egress: "
|
||||
f"public_ip={egress_public_ip or '(detecting/default)'} "
|
||||
f"source_ip={egress_source_ip or '(default route)'}"
|
||||
)
|
||||
if payload_len:
|
||||
lines.append(f" body: protobuf len={payload_len}")
|
||||
if proto_hint:
|
||||
@@ -300,7 +331,14 @@ def format_session_credential_summary(session: DouyinImSession) -> str:
|
||||
class DouyinImHttpClient:
|
||||
"""抖音 IM HTTP API 客户端(基于 Cookie 鉴权)"""
|
||||
|
||||
def __init__(self, session: DouyinImSession, account_id: Optional[int] = None):
|
||||
def __init__(
|
||||
self,
|
||||
session: DouyinImSession,
|
||||
account_id: Optional[int] = None,
|
||||
*,
|
||||
source_ip: Optional[str] = None,
|
||||
egress_public_ip: str = "",
|
||||
):
|
||||
self.session = session
|
||||
self.account_id = account_id
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
@@ -308,12 +346,29 @@ class DouyinImHttpClient:
|
||||
self.last_error: str = ""
|
||||
# True 表示本次发送失败是“签名凭证失效(7911)”,上层应刷新 web_protect 后重试
|
||||
self.last_send_needs_refresh: bool = False
|
||||
# Only explicit pre-delivery/security rejection failures may switch to
|
||||
# another channel. Ambiguous read timeouts stay false to avoid duplicates.
|
||||
self.last_send_channel_retryable: bool = False
|
||||
self.last_request_debug: str = ""
|
||||
self._proxy_url: str = ""
|
||||
self._source_ip_override = str(source_ip or "").strip()
|
||||
self._egress_public_ip_override = str(egress_public_ip or "").strip()
|
||||
self._source_ip: str = ""
|
||||
self._egress_public_ip: str = ""
|
||||
|
||||
async def __aenter__(self):
|
||||
from rpa_engine.runtime_config import httpx_proxy
|
||||
|
||||
self._source_ip = self._source_ip_override or str(
|
||||
getattr(self.session, "egress_source_ip", "") or ""
|
||||
).strip()
|
||||
self._egress_public_ip = self._egress_public_ip_override or str(
|
||||
getattr(self.session, "egress_public_ip", "") or ""
|
||||
).strip()
|
||||
if self._egress_public_ip and not self._source_ip:
|
||||
route = await resolve_fixed_channel(self._egress_public_ip)
|
||||
self._source_ip = str(route.source_ip or "")
|
||||
|
||||
headers = {
|
||||
"User-Agent": self.session.user_agent,
|
||||
"Cookie": self.session.cookie_header(),
|
||||
@@ -328,11 +383,31 @@ class DouyinImHttpClient:
|
||||
"follow_redirects": True,
|
||||
}
|
||||
# 配置 KEFU_DOUYIN_PROXY 时让全部抖音 IM 请求走住宅代理,绕开机房 IP 风控(7911)
|
||||
proxy = httpx_proxy()
|
||||
configured_proxy = httpx_proxy()
|
||||
# An account-selected source address and a global proxy describe two
|
||||
# different exits. The account channel is the more specific setting.
|
||||
proxy = None if (self._egress_public_ip or self._source_ip) else configured_proxy
|
||||
if configured_proxy and proxy is None:
|
||||
logger.info(
|
||||
"Account egress channel overrides KEFU_DOUYIN_PROXY for this IM request"
|
||||
)
|
||||
transport_kwargs: dict[str, Any] = {}
|
||||
if self._source_ip:
|
||||
transport_kwargs["local_address"] = self._source_ip
|
||||
if proxy:
|
||||
client_kwargs["proxy"] = proxy
|
||||
transport_kwargs["proxy"] = proxy
|
||||
self._proxy_url = proxy
|
||||
logger.info(f"IM HTTP client using proxy: {_mask_proxy(proxy)}")
|
||||
if transport_kwargs:
|
||||
client_kwargs["transport"] = httpx.AsyncHTTPTransport(**transport_kwargs)
|
||||
elif proxy:
|
||||
client_kwargs["proxy"] = proxy
|
||||
if self._egress_public_ip or self._source_ip:
|
||||
logger.info(
|
||||
"IM HTTP client egress: public_ip=%s source_ip=%s",
|
||||
self._egress_public_ip or "default",
|
||||
self._source_ip or "default",
|
||||
)
|
||||
self._client = httpx.AsyncClient(**client_kwargs)
|
||||
return self
|
||||
|
||||
@@ -381,6 +456,7 @@ class DouyinImHttpClient:
|
||||
核验成功后写回 session 并打标,避免每次发送都请求接口。
|
||||
"""
|
||||
sess = self.session
|
||||
auth.source_ip = self._source_ip
|
||||
if getattr(sess, "uid_verified", False) and sess.my_uid:
|
||||
return int(sess.my_uid)
|
||||
resolved = None
|
||||
@@ -524,6 +600,8 @@ class DouyinImHttpClient:
|
||||
payload_len=len(payload or b""),
|
||||
proto_hint=proto_hint,
|
||||
proxy=self._proxy_url,
|
||||
egress_public_ip=self._egress_public_ip,
|
||||
egress_source_ip=self._source_ip,
|
||||
)
|
||||
self.last_request_debug = debug_text
|
||||
logger.info(debug_text)
|
||||
@@ -905,25 +983,82 @@ class DouyinImHttpClient:
|
||||
conversation_hint = str(conversation_id or "")[-12:]
|
||||
|
||||
async def _queued_send() -> bool:
|
||||
# Use a client owned by the dispatcher. If an HTTP request is
|
||||
# cancelled while this job is already active, the request-level
|
||||
# context may close, but the dispatcher must finish the active
|
||||
# upload/send before it starts another bandwidth-heavy job.
|
||||
async with DouyinImHttpClient(
|
||||
self.session,
|
||||
account_id=self.account_id,
|
||||
) as queued_http:
|
||||
sent = await queued_http.send_text_message(
|
||||
conversation_id,
|
||||
content,
|
||||
conversation_short_id=conversation_short_id,
|
||||
_bypass_global_queue=True,
|
||||
)
|
||||
preferred = str(getattr(self.session, "egress_public_ip", "") or "").strip()
|
||||
max_attempts = getattr(self.session, "egress_auto_attempts", 1)
|
||||
try:
|
||||
routes = await resolve_send_channels(preferred, max_attempts)
|
||||
except EgressChannelUnavailable as exc:
|
||||
self._set_error(str(exc))
|
||||
self.last_send_channel_retryable = False
|
||||
self._log_send_failure(conversation_id, str(exc))
|
||||
return False
|
||||
|
||||
kicked_error = ""
|
||||
for index, route in enumerate(routes):
|
||||
# Use a client owned by the dispatcher. If an HTTP request
|
||||
# is cancelled while active, the dispatcher still owns the
|
||||
# complete serial ticket/upload/send operation.
|
||||
route_kwargs: dict[str, Any] = {}
|
||||
if route.source_ip or route.public_ip:
|
||||
route_kwargs = {
|
||||
"source_ip": route.source_ip,
|
||||
"egress_public_ip": route.public_ip,
|
||||
}
|
||||
async with DouyinImHttpClient(
|
||||
self.session,
|
||||
account_id=self.account_id,
|
||||
**route_kwargs,
|
||||
) as queued_http:
|
||||
sent = await queued_http.send_text_message(
|
||||
conversation_id,
|
||||
content,
|
||||
conversation_short_id=conversation_short_id,
|
||||
_bypass_global_queue=True,
|
||||
)
|
||||
self.last_send_meta = dict(queued_http.last_send_meta)
|
||||
self.last_error = queued_http.last_error
|
||||
self.last_send_needs_refresh = queued_http.last_send_needs_refresh
|
||||
self.last_send_channel_retryable = queued_http.last_send_channel_retryable
|
||||
self.last_request_debug = queued_http.last_request_debug
|
||||
return sent
|
||||
if "DECISION=KICK" in (queued_http.last_error or "").upper():
|
||||
kicked_error = queued_http.last_error
|
||||
if sent:
|
||||
if index:
|
||||
system_logger.record(
|
||||
"公网通道切换后发送成功",
|
||||
detail=(
|
||||
f"已通过公网 IP {route.public_ip or '默认出口'} 发送;"
|
||||
f"本次共尝试 {index + 1} 个通道"
|
||||
),
|
||||
level="success",
|
||||
category="send",
|
||||
account_id=self.account_id,
|
||||
)
|
||||
return True
|
||||
if not queued_http.last_send_channel_retryable or index + 1 >= len(routes):
|
||||
if kicked_error and "DECISION=KICK" not in (self.last_error or "").upper():
|
||||
self.last_error = f"{self.last_error}\n此前通道已返回:{kicked_error}"
|
||||
return False
|
||||
next_route = routes[index + 1]
|
||||
logger.warning(
|
||||
"Account %s send rejected on egress %s; trying %s (%s/%s)",
|
||||
self.account_id,
|
||||
route.public_ip or "default",
|
||||
next_route.public_ip or "default",
|
||||
index + 2,
|
||||
len(routes),
|
||||
)
|
||||
system_logger.record(
|
||||
"发送失败,切换公网通道重试",
|
||||
detail=(
|
||||
f"通道 {route.public_ip or '默认出口'} 明确返回通道/安全校验失败;"
|
||||
f"将串行尝试 {next_route.public_ip or '默认出口'}({index + 2}/{len(routes)})"
|
||||
),
|
||||
level="warning",
|
||||
category="send",
|
||||
account_id=self.account_id,
|
||||
)
|
||||
return False
|
||||
|
||||
return await submit_outbound(
|
||||
int(self.account_id or 0),
|
||||
@@ -941,6 +1076,7 @@ class DouyinImHttpClient:
|
||||
|
||||
self._set_error("")
|
||||
self.last_send_needs_refresh = False
|
||||
self.last_send_channel_retryable = False
|
||||
auth = DouyinAuth.from_im_session(self.session)
|
||||
my_uid = await asyncio.to_thread(self._resolve_authoritative_uid, auth)
|
||||
if not my_uid:
|
||||
@@ -978,6 +1114,7 @@ class DouyinImHttpClient:
|
||||
if not conv_short_id or not ticket:
|
||||
detail = self.last_error or "无法获取会话 ticket/short_id"
|
||||
self._set_error(detail)
|
||||
self.last_send_channel_retryable = _is_channel_retryable_error_text(detail)
|
||||
logger.warning(f"Send aborted for {conversation_id}: {detail}")
|
||||
self._log_send_failure(conversation_id, f"无法获取会话票据(ticket/short_id):{detail}")
|
||||
return False
|
||||
@@ -997,7 +1134,11 @@ class DouyinImHttpClient:
|
||||
"messages",
|
||||
)
|
||||
reply_spec, upload_err = await asyncio.to_thread(
|
||||
prepare_image_reply_spec, reply_spec, self.session, upload_dir
|
||||
prepare_image_reply_spec,
|
||||
reply_spec,
|
||||
self.session,
|
||||
upload_dir,
|
||||
self._source_ip,
|
||||
)
|
||||
if upload_err:
|
||||
detail = f"图片上传失败:{upload_err}"
|
||||
@@ -1066,8 +1207,19 @@ class DouyinImHttpClient:
|
||||
|
||||
status_code = result.get("status_code")
|
||||
status_reason = result.get("status_reason") or ""
|
||||
decision = str(result.get("decision") or "").strip().upper()
|
||||
|
||||
if status_code is not None and status_code != 0:
|
||||
if decision == "KICK":
|
||||
self.last_send_channel_retryable = True
|
||||
detail = (
|
||||
"抖音安全网关返回 decision=KICK,当前登录/安全会话已被服务端踢下线;"
|
||||
"请停止托管后用浏览器模式重新登录,并打开一次私信页重新采集凭证"
|
||||
)
|
||||
elif decision:
|
||||
detail = f"抖音安全网关拒绝发送 decision={decision}"
|
||||
if status_reason:
|
||||
detail += f";抖音提示:{status_reason}"
|
||||
elif status_code is not None and status_code != 0:
|
||||
# body 内嵌 JSON 给出了明确的 status_code,这是权威失败原因
|
||||
hint = _STATUS_CODE_HINTS.get(status_code, "")
|
||||
# 8xxx 段未单独建模的,统一归为“业务层拒绝(签名已通过)”
|
||||
@@ -1075,6 +1227,7 @@ 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
|
||||
detail = f"抖音拒绝投递 status_code={status_code}"
|
||||
if status_reason:
|
||||
detail += f";抖音提示:{status_reason}"
|
||||
@@ -1096,6 +1249,8 @@ class DouyinImHttpClient:
|
||||
reason_bits.append(f"cmd={result.get('cmd')}")
|
||||
detail = ";".join(reason_bits) or "接口返回但未确认投递(无 server_message_id)"
|
||||
|
||||
if "INVALID_REQUEST" in detail.upper():
|
||||
self.last_send_channel_retryable = True
|
||||
full_detail = f"{detail};{target};resp[{result.get('summary')}]"
|
||||
if self.last_request_debug:
|
||||
full_detail += f"\n--- 请求详情 ---\n{self.last_request_debug}"
|
||||
@@ -1104,6 +1259,10 @@ class DouyinImHttpClient:
|
||||
self._log_send_failure(conversation_id, full_detail)
|
||||
return False
|
||||
except Exception as e:
|
||||
self.last_send_channel_retryable = isinstance(
|
||||
e,
|
||||
(httpx.ConnectError, httpx.ConnectTimeout, httpx.ProxyError, httpx.PoolTimeout),
|
||||
)
|
||||
err_detail = f"发送请求异常:{e};{target}"
|
||||
if self.last_request_debug:
|
||||
err_detail += f"\n--- 请求详情 ---\n{self.last_request_debug}"
|
||||
|
||||
Reference in New Issue
Block a user