396 lines
17 KiB
Python
396 lines
17 KiB
Python
"""
|
|
Reverse interface: upload asset.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import urllib.error
|
|
import urllib.request
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
from curl_cffi.requests import AsyncSession
|
|
|
|
from app.core.logger import logger
|
|
from app.core.config import get_config
|
|
from app.core.exceptions import UpstreamException
|
|
from app.services.token.service import TokenService
|
|
from app.services.reverse.utils.headers import build_headers
|
|
from app.services.reverse.utils.retry import retry_on_status
|
|
|
|
UPLOAD_API = "https://grok.com/rest/app-chat/upload-file"
|
|
|
|
|
|
class AssetsUploadReverse:
|
|
"""/rest/app-chat/upload-file reverse interface."""
|
|
|
|
@dataclass
|
|
class _SimpleResponse:
|
|
status_code: int
|
|
headers: dict[str, str]
|
|
text: str
|
|
|
|
def json(self):
|
|
return json.loads(self.text or "{}")
|
|
|
|
@staticmethod
|
|
def _compact_body(text: str, limit: int = 320) -> str:
|
|
body = (text or "").strip().replace("\n", " ")
|
|
if len(body) > limit:
|
|
return f"{body[:limit]}...(len={len(body)})"
|
|
return body
|
|
|
|
@staticmethod
|
|
def _extract_error_hint(text: str) -> str:
|
|
if not text:
|
|
return ""
|
|
lowered = text.lower()
|
|
if "content is moderated" in lowered or "content-moderated" in lowered:
|
|
return "Content is moderated [WKE=file:content-moderated]"
|
|
if "<title>just a moment...</title>" in lowered or "cf-challenge" in lowered:
|
|
return "Cloudflare challenge page"
|
|
|
|
def _collect_strings(node: Any, out: list[str]) -> None:
|
|
if len(out) >= 8:
|
|
return
|
|
if isinstance(node, str):
|
|
value = node.strip()
|
|
if value:
|
|
out.append(value)
|
|
return
|
|
if isinstance(node, dict):
|
|
preferred = ("message", "error", "detail", "reason", "msg", "code")
|
|
for key in preferred:
|
|
if key in node:
|
|
_collect_strings(node.get(key), out)
|
|
for value in node.values():
|
|
_collect_strings(value, out)
|
|
return
|
|
if isinstance(node, list):
|
|
for value in node:
|
|
_collect_strings(value, out)
|
|
|
|
try:
|
|
parsed = json.loads(text)
|
|
except Exception:
|
|
plain = text.strip()
|
|
return plain[:300] if plain else ""
|
|
|
|
messages: list[str] = []
|
|
_collect_strings(parsed, messages)
|
|
deduped: list[str] = []
|
|
seen: set[str] = set()
|
|
for item in messages:
|
|
if item in seen:
|
|
continue
|
|
seen.add(item)
|
|
deduped.append(item)
|
|
joined = " | ".join(deduped[:4])
|
|
return joined[:300]
|
|
|
|
@staticmethod
|
|
async def _urllib_post(
|
|
url: str, headers: dict[str, str], payload: dict[str, Any], timeout: int, proxy_url: str
|
|
) -> "AssetsUploadReverse._SimpleResponse":
|
|
"""使用标准库 urllib 兜底上传,绕过 curl_cffi 异常。"""
|
|
body = json.dumps(payload).encode("utf-8")
|
|
opener = None
|
|
if proxy_url:
|
|
opener = urllib.request.build_opener(
|
|
urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url})
|
|
)
|
|
req = urllib.request.Request(url=url, data=body, headers=headers, method="POST")
|
|
|
|
def _do_post():
|
|
def _read_http_error(err: urllib.error.HTTPError):
|
|
status = int(getattr(err, "code", 500) or 500)
|
|
hdrs = {}
|
|
try:
|
|
hdrs = {
|
|
str(k).lower(): str(v)
|
|
for k, v in dict((err.headers or {}).items()).items()
|
|
}
|
|
except Exception:
|
|
hdrs = {}
|
|
try:
|
|
body = err.read().decode("utf-8", errors="replace")
|
|
except Exception:
|
|
body = ""
|
|
return status, hdrs, body
|
|
|
|
if opener is not None:
|
|
try:
|
|
with opener.open(req, timeout=timeout) as resp:
|
|
status = int(getattr(resp, "status", 200) or 200)
|
|
raw_headers = {
|
|
str(k).lower(): str(v)
|
|
for k, v in dict(resp.headers.items()).items()
|
|
}
|
|
text = resp.read().decode("utf-8", errors="replace")
|
|
return status, raw_headers, text
|
|
except urllib.error.HTTPError as e:
|
|
return _read_http_error(e)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
status = int(getattr(resp, "status", 200) or 200)
|
|
raw_headers = {
|
|
str(k).lower(): str(v) for k, v in dict(resp.headers.items()).items()
|
|
}
|
|
text = resp.read().decode("utf-8", errors="replace")
|
|
return status, raw_headers, text
|
|
except urllib.error.HTTPError as e:
|
|
return _read_http_error(e)
|
|
|
|
status, raw_headers, text = await asyncio.to_thread(_do_post)
|
|
return AssetsUploadReverse._SimpleResponse(
|
|
status_code=status,
|
|
headers=raw_headers,
|
|
text=text,
|
|
)
|
|
|
|
@staticmethod
|
|
async def request(session: AsyncSession, token: str, fileName: str, fileMimeType: str, content: str) -> Any:
|
|
"""Upload asset to Grok.
|
|
|
|
Args:
|
|
session: AsyncSession, the session to use for the request.
|
|
token: str, the SSO token.
|
|
fileName: str, the name of the file.
|
|
fileMimeType: str, the MIME type of the file.
|
|
content: str, the content of the file.
|
|
|
|
Returns:
|
|
Any: The response from the request.
|
|
"""
|
|
try:
|
|
# Get proxies
|
|
base_proxy = get_config("proxy.base_proxy_url")
|
|
assert_proxy = get_config("proxy.asset_proxy_url")
|
|
if assert_proxy:
|
|
proxies = {"http": assert_proxy, "https": assert_proxy}
|
|
proxy_url = assert_proxy
|
|
else:
|
|
proxies = {"http": base_proxy, "https": base_proxy}
|
|
proxy_url = base_proxy
|
|
|
|
# Build headers
|
|
headers = build_headers(
|
|
cookie_token=token,
|
|
content_type="application/json",
|
|
origin="https://grok.com",
|
|
referer="https://grok.com/",
|
|
)
|
|
|
|
# Build payload
|
|
payload = {
|
|
"fileName": fileName,
|
|
"fileMimeType": fileMimeType,
|
|
"content": content,
|
|
}
|
|
logger.info(
|
|
"AssetsUpload request prepared: "
|
|
f"fileName={fileName}, fileMimeType={fileMimeType}, content_len={len(content or '')}"
|
|
)
|
|
|
|
# Curl Config
|
|
timeout = get_config("asset.upload_timeout")
|
|
browser = get_config("proxy.browser")
|
|
|
|
async def _do_request():
|
|
try:
|
|
try:
|
|
response = await session.post(
|
|
UPLOAD_API,
|
|
headers=headers,
|
|
json=payload,
|
|
proxies=proxies,
|
|
timeout=timeout,
|
|
impersonate=browser,
|
|
)
|
|
except Exception as first_err:
|
|
logger.warning(
|
|
"AssetsUploadReverse primary request failed, fallback direct: "
|
|
"error={}",
|
|
str(first_err),
|
|
)
|
|
try:
|
|
response = await session.post(
|
|
UPLOAD_API,
|
|
headers=headers,
|
|
json=payload,
|
|
timeout=timeout,
|
|
)
|
|
except Exception as second_err:
|
|
logger.warning(
|
|
"AssetsUploadReverse direct curl request failed, "
|
|
"fallback urllib: error={}",
|
|
str(second_err),
|
|
)
|
|
response = await AssetsUploadReverse._urllib_post(
|
|
url=UPLOAD_API,
|
|
headers=headers,
|
|
payload=payload,
|
|
timeout=timeout,
|
|
proxy_url=proxy_url,
|
|
)
|
|
if response.status_code != 200:
|
|
body_raw = ""
|
|
try:
|
|
body_raw = response.text or ""
|
|
except Exception:
|
|
body_raw = ""
|
|
body_preview = AssetsUploadReverse._compact_body(body_raw)
|
|
error_hint = AssetsUploadReverse._extract_error_hint(body_raw)
|
|
logger.error(
|
|
"AssetsUploadReverse: Upload failed, "
|
|
"status={}, hint={}, body={}",
|
|
response.status_code,
|
|
error_hint or "-",
|
|
body_preview or "-",
|
|
extra={"error_type": "UpstreamException"},
|
|
)
|
|
raise UpstreamException(
|
|
message=f"AssetsUploadReverse: Upload failed, {response.status_code}",
|
|
details={
|
|
"status": response.status_code,
|
|
"body": body_preview,
|
|
"hint": error_hint,
|
|
},
|
|
)
|
|
return response
|
|
except UpstreamException:
|
|
raise
|
|
except Exception as inner_err:
|
|
err_msg = str(inner_err)
|
|
# 这类异常在实际环境里多为瞬时网络/上游抖动,按可重试处理。
|
|
if "curl: (35)" in err_msg or '"code"' in err_msg:
|
|
logger.warning(
|
|
"AssetsUpload transient exception, mark as retryable: "
|
|
"{}",
|
|
err_msg,
|
|
)
|
|
last_fallback_error: UpstreamException | None = None
|
|
# 部分 '"code"' 异常不会走到上面的降级分支,这里再强制兜底一次。
|
|
try:
|
|
response = await session.post(
|
|
UPLOAD_API,
|
|
headers=headers,
|
|
json=payload,
|
|
timeout=timeout,
|
|
)
|
|
if response.status_code == 200:
|
|
logger.info(
|
|
"AssetsUpload recovered by forced direct fallback after transient error"
|
|
)
|
|
return response
|
|
body_raw = ""
|
|
try:
|
|
body_raw = response.text or ""
|
|
except Exception:
|
|
body_raw = ""
|
|
body_preview = AssetsUploadReverse._compact_body(body_raw)
|
|
error_hint = AssetsUploadReverse._extract_error_hint(body_raw)
|
|
raise UpstreamException(
|
|
message=f"AssetsUpload forced direct failed: {response.status_code}",
|
|
details={
|
|
"status": response.status_code,
|
|
"body": body_preview,
|
|
"hint": error_hint,
|
|
},
|
|
)
|
|
except Exception as forced_direct_err:
|
|
if isinstance(forced_direct_err, UpstreamException):
|
|
last_fallback_error = forced_direct_err
|
|
logger.warning(
|
|
"AssetsUpload forced direct fallback failed, "
|
|
"status={}, hint={}, body={}",
|
|
forced_direct_err.details.get("status"),
|
|
forced_direct_err.details.get("hint") or "-",
|
|
forced_direct_err.details.get("body") or "-",
|
|
)
|
|
else:
|
|
logger.warning(
|
|
"AssetsUpload forced direct fallback failed, "
|
|
"error={}",
|
|
str(forced_direct_err),
|
|
)
|
|
|
|
try:
|
|
response = await AssetsUploadReverse._urllib_post(
|
|
url=UPLOAD_API,
|
|
headers=headers,
|
|
payload=payload,
|
|
timeout=timeout,
|
|
proxy_url=proxy_url,
|
|
)
|
|
if response.status_code == 200:
|
|
logger.info(
|
|
"AssetsUpload recovered by forced urllib fallback after transient error"
|
|
)
|
|
return response
|
|
body_raw = response.text or ""
|
|
body_preview = AssetsUploadReverse._compact_body(body_raw)
|
|
error_hint = AssetsUploadReverse._extract_error_hint(body_raw)
|
|
raise UpstreamException(
|
|
message=f"AssetsUpload forced urllib failed: {response.status_code}",
|
|
details={
|
|
"status": response.status_code,
|
|
"body": body_preview,
|
|
"hint": error_hint,
|
|
},
|
|
)
|
|
except Exception as forced_urllib_err:
|
|
if isinstance(forced_urllib_err, UpstreamException):
|
|
last_fallback_error = forced_urllib_err
|
|
logger.warning(
|
|
"AssetsUpload forced urllib fallback failed, "
|
|
"status={}, hint={}, body={}",
|
|
forced_urllib_err.details.get("status"),
|
|
forced_urllib_err.details.get("hint") or "-",
|
|
forced_urllib_err.details.get("body") or "-",
|
|
)
|
|
else:
|
|
logger.warning(
|
|
"AssetsUpload forced urllib fallback failed, "
|
|
"error={}",
|
|
str(forced_urllib_err),
|
|
)
|
|
|
|
if last_fallback_error is not None:
|
|
raise last_fallback_error
|
|
raise UpstreamException(
|
|
message=f"AssetsUpload transient failure: {err_msg}",
|
|
details={"status": 403, "error": err_msg},
|
|
)
|
|
raise
|
|
|
|
return await retry_on_status(_do_request)
|
|
|
|
except Exception as e:
|
|
# Handle upstream exception
|
|
if isinstance(e, UpstreamException):
|
|
status = None
|
|
if e.details and "status" in e.details:
|
|
status = e.details["status"]
|
|
else:
|
|
status = getattr(e, "status_code", None)
|
|
if status == 401:
|
|
try:
|
|
await TokenService.record_fail(token, status, "assets_upload_auth_failed")
|
|
except Exception:
|
|
pass
|
|
raise
|
|
|
|
# Handle other non-upstream exceptions
|
|
logger.error(
|
|
"AssetsUploadReverse: Upload failed, {}",
|
|
str(e),
|
|
extra={"error_type": type(e).__name__},
|
|
)
|
|
raise UpstreamException(
|
|
message=f"AssetsUploadReverse: Upload failed, {str(e)}",
|
|
details={"status": 502, "error": str(e)},
|
|
)
|
|
|
|
|
|
__all__ = ["AssetsUploadReverse"]
|