742 lines
26 KiB
Python
742 lines
26 KiB
Python
"""抖音 IM 私信图片上传。
|
||
|
||
复刻抖音 PC 网页版私信发图的真实链路(与"创作者中心 ImageX 图文图床"是两套,
|
||
IM 发送只认这条链路产出的 tos-cn-o-* 资源):
|
||
|
||
1. GET www.douyin.com/aweme/v1/web/im/upload/config/v2 (a_bogus 签名)
|
||
→ 返回内含 STS2 凭证(AccessKeyID + SignedSecretAccessKey)与 SpaceName(=zhenzhen)
|
||
2. GET vod.bytedanceapi.com/?Action=ApplyUploadInner&SpaceName=zhenzhen&FileType=image
|
||
(AWS4-HMAC-SHA256,service=vod,带 x-amz-security-token=STS2…)
|
||
→ 返回 UploadHost / StoreUri(tos-cn-o-*) / Auth(SpaceKey JWT) / SessionKey
|
||
3. POST https://{UploadHost}/upload/v1/{StoreUri}
|
||
(Authorization: SpaceKey/zhenzhen/…JWT,Content-CRC32,X-Storage-U=my_uid)
|
||
4. POST vod.bytedanceapi.com/?Action=CommitUploadInner&SpaceName=zhenzhen (AWS4 签名)
|
||
→ 确认上传,最终 uri 即 StoreUri(tos-cn-o-*)
|
||
|
||
之后用该 tos-cn-o-* uri 构造 type=27 消息体即可被 IM 后端校验通过。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import datetime
|
||
import hashlib
|
||
import hmac
|
||
import json
|
||
import logging
|
||
import random
|
||
import re
|
||
import string
|
||
import zlib
|
||
from typing import Any
|
||
from urllib.parse import urlencode
|
||
|
||
from rpa_engine.egress_channels import source_bound_requests_session
|
||
|
||
logger = logging.getLogger("douyin_im.image_upload")
|
||
|
||
_LOCAL_URL_RE = re.compile(
|
||
r"^(/api/media/messages/|https?://(?:localhost|127\.0\.0\.1)(?::\d+)?/api/media/messages/)",
|
||
re.I,
|
||
)
|
||
|
||
# 抖音 web 私信图片上传专用通道(VOD/ImageX inner 接口 + IM 上传配置)
|
||
VOD_HOST = "https://vod.bytedanceapi.com/"
|
||
VOD_REGION = "cn-north-1"
|
||
VOD_SERVICE = "vod"
|
||
IM_UPLOAD_CONFIG_URL = "https://www.douyin.com/aweme/v1/web/im/upload/config/v2"
|
||
DEFAULT_SPACE_NAME = "zhenzhen"
|
||
|
||
|
||
def is_local_media_url(url: str) -> bool:
|
||
raw = (url or "").strip()
|
||
if not raw:
|
||
return False
|
||
if raw.startswith("/api/media/messages/"):
|
||
return True
|
||
if raw.startswith("/api/media/link-cards/"):
|
||
return True
|
||
return bool(_LOCAL_URL_RE.match(raw))
|
||
|
||
|
||
def is_douyin_cdn_url(url: str) -> bool:
|
||
raw = (url or "").strip().lower()
|
||
if not raw.startswith("http"):
|
||
return False
|
||
return any(
|
||
host in raw
|
||
for host in (
|
||
"douyinpic.com",
|
||
"byteimg.com",
|
||
"ibyteimg.com",
|
||
"douyinstatic.com",
|
||
"snssdk.com",
|
||
"vodupload.com",
|
||
)
|
||
)
|
||
|
||
|
||
def parse_local_message_media_path(url: str) -> tuple[int, str] | None:
|
||
"""从 /api/media/messages/{account_id}/{filename} 解析 account_id 与文件名。"""
|
||
raw = (url or "").strip()
|
||
m = re.search(r"/api/media/messages/(\d+)/([^/?#]+)", raw)
|
||
if not m:
|
||
return None
|
||
return int(m.group(1)), m.group(2)
|
||
|
||
|
||
def parse_link_card_media_path(url: str) -> tuple[int, str] | None:
|
||
"""从 /api/media/link-cards/{owner_id}/{filename} 解析 owner_id 与文件名。"""
|
||
raw = (url or "").strip()
|
||
m = re.search(r"/api/media/link-cards/(\d+)/([^/?#]+)", raw)
|
||
if not m:
|
||
return None
|
||
return int(m.group(1)), m.group(2)
|
||
|
||
|
||
|
||
|
||
def _requests_proxies() -> dict | None:
|
||
try:
|
||
from rpa_engine.runtime_config import requests_proxies
|
||
|
||
return requests_proxies()
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _random_s() -> str:
|
||
chars = string.digits + string.ascii_lowercase
|
||
return "".join(random.choice(chars) for _ in range(11))
|
||
|
||
|
||
def _signing_key(secret: str, date_stamp: str, region: str, service: str) -> bytes:
|
||
k_date = hmac.new(("AWS4" + secret).encode(), date_stamp.encode(), hashlib.sha256).digest()
|
||
k_region = hmac.new(k_date, region.encode(), hashlib.sha256).digest()
|
||
k_service = hmac.new(k_region, service.encode(), hashlib.sha256).digest()
|
||
return hmac.new(k_service, b"aws4_request", hashlib.sha256).digest()
|
||
|
||
|
||
def _aws4_authorization(
|
||
*,
|
||
method: str,
|
||
canonical_querystring: str,
|
||
amz_date: str,
|
||
date_stamp: str,
|
||
session_token: str,
|
||
access_key_id: str,
|
||
secret_access_key: str,
|
||
payload_hash: str | None = None,
|
||
signed_headers: list[str] | None = None,
|
||
service: str = VOD_SERVICE,
|
||
region: str = VOD_REGION,
|
||
) -> str:
|
||
if signed_headers is None:
|
||
signed_headers = ["x-amz-date", "x-amz-security-token"]
|
||
if payload_hash is None:
|
||
payload_hash = hashlib.sha256(b"").hexdigest()
|
||
|
||
header_lines = []
|
||
for name in signed_headers:
|
||
if name == "x-amz-date":
|
||
header_lines.append(f"x-amz-date:{amz_date}\n")
|
||
elif name == "x-amz-security-token":
|
||
header_lines.append(f"x-amz-security-token:{session_token}\n")
|
||
elif name == "x-amz-content-sha256":
|
||
header_lines.append(f"x-amz-content-sha256:{payload_hash}\n")
|
||
canonical_headers = "".join(header_lines)
|
||
signed = ";".join(signed_headers)
|
||
canonical_request = (
|
||
f"{method}\n/\n{canonical_querystring}\n{canonical_headers}\n{signed}\n{payload_hash}"
|
||
)
|
||
credential_scope = f"{date_stamp}/{region}/{service}/aws4_request"
|
||
string_to_sign = (
|
||
"AWS4-HMAC-SHA256\n"
|
||
f"{amz_date}\n"
|
||
f"{credential_scope}\n"
|
||
f"{hashlib.sha256(canonical_request.encode()).hexdigest()}"
|
||
)
|
||
signature = hmac.new(
|
||
_signing_key(secret_access_key, date_stamp, region, service),
|
||
string_to_sign.encode(),
|
||
hashlib.sha256,
|
||
).hexdigest()
|
||
return (
|
||
f"AWS4-HMAC-SHA256 Credential={access_key_id}/{credential_scope}, "
|
||
f"SignedHeaders={signed}, Signature={signature}"
|
||
)
|
||
|
||
|
||
def _safe_json(resp) -> dict[str, Any]:
|
||
text = (getattr(resp, "text", None) or "").strip()
|
||
if not text:
|
||
return {"error": f"空响应 (HTTP {getattr(resp, 'status_code', '?')})"}
|
||
try:
|
||
data = resp.json()
|
||
return data if isinstance(data, dict) else {"error": "响应不是 JSON 对象"}
|
||
except Exception:
|
||
snippet = text[:200].replace("\n", " ")
|
||
return {"error": f"非 JSON 响应 (HTTP {getattr(resp, 'status_code', '?')}): {snippet}"}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 第 1 步:拉取 IM 上传配置,提取 STS 凭证 + SpaceName
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _sanitize_for_log(obj: Any, depth: int = 0) -> Any:
|
||
"""结构化脱敏:保留字段名与层级,长字符串只留前 12 位 + 长度,便于排查凭证字段。"""
|
||
if depth > 6:
|
||
return "…"
|
||
if isinstance(obj, dict):
|
||
return {str(k): _sanitize_for_log(v, depth + 1) for k, v in obj.items()}
|
||
if isinstance(obj, list):
|
||
return [_sanitize_for_log(v, depth + 1) for v in obj[:3]]
|
||
if isinstance(obj, str):
|
||
return f"{obj[:12]}…(len={len(obj)})" if len(obj) > 24 else obj
|
||
return obj
|
||
|
||
|
||
def _find_auth_object(obj: Any, depth: int = 0) -> dict | None:
|
||
"""找到「直接含有 STS2 会话凭证字符串」的那个 dict(即临时凭证对象)。
|
||
|
||
该对象里通常同时含有 AccessKeyId / SecretAccessKey / SessionToken(=STS2…)。
|
||
关键:签名要用同级的 SecretAccessKey 字段,而**不是** STS2 令牌内部解出的
|
||
SignedSecretAccessKey(那是服务端校验用的,拿来当签名密钥会 SignatureDoesNotMatch)。
|
||
"""
|
||
if depth > 8 or not isinstance(obj, (dict, list)):
|
||
return None
|
||
if isinstance(obj, dict):
|
||
for value in obj.values():
|
||
if isinstance(value, str) and value.startswith("STS2"):
|
||
return obj
|
||
for value in obj.values():
|
||
found = _find_auth_object(value, depth + 1)
|
||
if found is not None:
|
||
return found
|
||
else:
|
||
for value in obj:
|
||
found = _find_auth_object(value, depth + 1)
|
||
if found is not None:
|
||
return found
|
||
return None
|
||
|
||
|
||
def _find_sts_token(obj: Any, depth: int = 0) -> str:
|
||
"""递归在响应里找到形如 'STS2...' 的会话凭证字符串。"""
|
||
if depth > 8:
|
||
return ""
|
||
if isinstance(obj, str):
|
||
return obj if obj.startswith("STS2") else ""
|
||
if isinstance(obj, dict):
|
||
for value in obj.values():
|
||
found = _find_sts_token(value, depth + 1)
|
||
if found:
|
||
return found
|
||
elif isinstance(obj, list):
|
||
for value in obj:
|
||
found = _find_sts_token(value, depth + 1)
|
||
if found:
|
||
return found
|
||
return ""
|
||
|
||
|
||
def _find_space_name(obj: Any, depth: int = 0) -> str:
|
||
if depth > 8:
|
||
return ""
|
||
if isinstance(obj, dict):
|
||
for key, value in obj.items():
|
||
if str(key).lower() in ("space_name", "spacename") and isinstance(value, str) and value:
|
||
return value
|
||
found = _find_space_name(value, depth + 1)
|
||
if found:
|
||
return found
|
||
elif isinstance(obj, list):
|
||
for value in obj:
|
||
found = _find_space_name(value, depth + 1)
|
||
if found:
|
||
return found
|
||
return ""
|
||
|
||
|
||
def _decode_sts(sts_token: str) -> tuple[str, str]:
|
||
"""STS2<base64(JSON)>,解出 AccessKeyID 与 SignedSecretAccessKey。"""
|
||
try:
|
||
b64 = sts_token[4:] if sts_token.startswith("STS2") else sts_token
|
||
b64 += "=" * (-len(b64) % 4)
|
||
data = json.loads(base64.b64decode(b64).decode("utf-8", "ignore"))
|
||
ak = data.get("AccessKeyID") or data.get("AccessKeyId") or ""
|
||
sk = data.get("SignedSecretAccessKey") or data.get("SecretAccessKey") or ""
|
||
return ak, sk
|
||
except Exception:
|
||
return "", ""
|
||
|
||
|
||
def _fetch_im_upload_sts(session, source_ip: str = "") -> tuple[str, str, str, str]:
|
||
"""返回 (access_key_id, secret_access_key, sts_token, space_name)。"""
|
||
from .auth import DouyinAuth
|
||
from .dy_util import (
|
||
DEFAULT_USER_AGENT,
|
||
generate_a_bogus,
|
||
generate_msToken,
|
||
generate_webid,
|
||
splice_url,
|
||
)
|
||
|
||
auth = DouyinAuth()
|
||
ua = session.user_agent or DEFAULT_USER_AGENT
|
||
auth.perepare_auth(
|
||
session.cookie_header(),
|
||
session.web_protect_str,
|
||
session.keys_str,
|
||
user_agent=ua,
|
||
)
|
||
|
||
params = {
|
||
"device_platform": "webapp",
|
||
"aid": "6383",
|
||
"channel": "channel_pc_web",
|
||
"update_version_code": "170400",
|
||
"pc_client_type": "1",
|
||
"pc_libra_divert": "Windows",
|
||
"support_h265": "1",
|
||
"support_dash": "1",
|
||
"version_code": "170400",
|
||
"version_name": "17.4.0",
|
||
"cookie_enabled": "true",
|
||
"screen_width": "1536",
|
||
"screen_height": "960",
|
||
"browser_language": "zh-CN",
|
||
"browser_platform": "Win32",
|
||
"browser_name": "Chrome",
|
||
"browser_version": "120.0.0.0",
|
||
"browser_online": "true",
|
||
"engine_name": "Blink",
|
||
"engine_version": "120.0.0.0",
|
||
"os_name": "Windows",
|
||
"os_version": "10",
|
||
"cpu_core_num": "8",
|
||
"device_memory": "8",
|
||
"platform": "PC",
|
||
"downlink": "10",
|
||
"effective_type": "4g",
|
||
"round_trip_time": "50",
|
||
"webid": generate_webid(auth, "https://www.douyin.com/"),
|
||
"verifyFp": auth.cookie.get("s_v_web_id", "") if auth.cookie else "",
|
||
"fp": auth.cookie.get("s_v_web_id", "") if auth.cookie else "",
|
||
"msToken": auth.msToken or generate_msToken(),
|
||
}
|
||
query = splice_url(params)
|
||
params["a_bogus"] = generate_a_bogus(query, user_agent=ua)
|
||
|
||
headers = {
|
||
"User-Agent": ua,
|
||
"Referer": "https://www.douyin.com/",
|
||
"Accept": "application/json, text/plain, */*",
|
||
}
|
||
with source_bound_requests_session(source_ip) as client:
|
||
resp = client.get(
|
||
IM_UPLOAD_CONFIG_URL,
|
||
params=params,
|
||
headers=headers,
|
||
cookies=auth.cookie,
|
||
timeout=20,
|
||
verify=False,
|
||
proxies=None if source_ip else _requests_proxies(),
|
||
)
|
||
data = _safe_json(resp)
|
||
if data.get("error"):
|
||
raise RuntimeError(f"获取 IM 上传配置失败:{data['error']}")
|
||
if data.get("status_code") not in (None, 0):
|
||
raise RuntimeError(
|
||
f"获取 IM 上传配置失败:status_code={data.get('status_code')} "
|
||
f"{data.get('status_msg') or ''}"
|
||
)
|
||
|
||
# 诊断:把 config/v2 响应结构(字段名保留、长字符串脱敏)打到日志,便于核对凭证字段。
|
||
try:
|
||
logger.info(
|
||
"im/upload/config/v2 结构: %s",
|
||
json.dumps(_sanitize_for_log(data), ensure_ascii=False),
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
# 凭证块选择(决定 8003 是否发生的关键):
|
||
# - inner_image_config / public_image_config / public_file_config 共用同一套
|
||
# 可用于 VOD 上传的 STS 令牌,仅 space 不同;
|
||
# - public_image_config_v2 的 token 不同,不兼容 VOD 内部上传(会报
|
||
# "session token sequence is broken"),必须避开。
|
||
# 空间含义:maya_review = 审核空间,图片上传后处于待审核态,作为消息发送会被拒(8003);
|
||
# zhenzhen = 公开可发送空间,IM 图片消息应使用它。
|
||
# 因此优先 public_image_config(zhenzhen),其凭证同样能完成 VOD 上传。
|
||
def _block_has_sts(block: Any) -> bool:
|
||
return isinstance(block, dict) and any(
|
||
isinstance(v, str) and v.startswith("STS2") for v in block.values()
|
||
)
|
||
|
||
auth_obj = None
|
||
for _key in ("public_image_config", "inner_image_config"):
|
||
if isinstance(data, dict) and _block_has_sts(data.get(_key)):
|
||
auth_obj = data[_key]
|
||
break
|
||
if auth_obj is None:
|
||
auth_obj = _find_auth_object(data)
|
||
if not auth_obj:
|
||
raise RuntimeError(
|
||
"IM 上传配置响应里未找到 STS 凭证(STS2 token);可能 cookie/签名失效,请用浏览器模式重新登录"
|
||
)
|
||
|
||
sts = next(
|
||
(v for v in auth_obj.values() if isinstance(v, str) and v.startswith("STS2")),
|
||
"",
|
||
)
|
||
# 优先用凭证对象同级的 AccessKeyId / SecretAccessKey(用于 SigV4 签名的真实密钥)。
|
||
ak = (
|
||
auth_obj.get("AccessKeyID")
|
||
or auth_obj.get("AccessKeyId")
|
||
or auth_obj.get("access_key_id")
|
||
or ""
|
||
)
|
||
sk = (
|
||
auth_obj.get("SecretAccessKey")
|
||
or auth_obj.get("SecretAccesskey")
|
||
or auth_obj.get("secret_access_key")
|
||
or ""
|
||
)
|
||
# 兜底:若响应没给独立的 ak/sk,再尝试从 STS2 令牌解码(SignedSecretAccessKey 一般不可用,仅最后兜底)。
|
||
if not ak or not sk:
|
||
dec_ak, dec_sk = _decode_sts(sts)
|
||
ak = ak or dec_ak
|
||
sk = sk or dec_sk
|
||
if not ak or not sk:
|
||
raise RuntimeError("解析 STS 凭证失败(缺少 AccessKeyId / SecretAccessKey)")
|
||
# space 必须取自与凭证同一块,避免凭证用 inner_image_config 而 space 误取到别处。
|
||
space = (
|
||
auth_obj.get("space_name")
|
||
or auth_obj.get("SpaceName")
|
||
or auth_obj.get("spaceName")
|
||
or _find_space_name(data)
|
||
or DEFAULT_SPACE_NAME
|
||
)
|
||
return ak, sk, sts, space
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 第 2 步:ApplyUploadInner(VOD),申请上传地址
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _extract_apply_inner(data: dict[str, Any]) -> tuple[str, str, str, str]:
|
||
"""返回 (upload_host, store_uri, jwt_auth, session_key)。"""
|
||
result = data.get("Result") or {}
|
||
addr = result.get("InnerUploadAddress") or result.get("UploadAddress") or {}
|
||
|
||
nodes = addr.get("UploadNodes") or []
|
||
if nodes:
|
||
node = nodes[0]
|
||
stores = node.get("StoreInfos") or []
|
||
store = stores[0] if stores else {}
|
||
host = node.get("UploadHost") or ""
|
||
if not host:
|
||
hosts = node.get("UploadHosts") or addr.get("UploadHosts") or []
|
||
host = hosts[0] if hosts else ""
|
||
return (
|
||
str(host or ""),
|
||
str(store.get("StoreUri") or ""),
|
||
str(store.get("Auth") or ""),
|
||
str(node.get("SessionKey") or addr.get("SessionKey") or ""),
|
||
)
|
||
|
||
hosts = addr.get("UploadHosts") or []
|
||
host = hosts[0] if hosts else ""
|
||
stores = addr.get("StoreInfos") or []
|
||
store = stores[0] if stores else {}
|
||
return (
|
||
str(host or ""),
|
||
str(store.get("StoreUri") or ""),
|
||
str(store.get("Auth") or ""),
|
||
str(addr.get("SessionKey") or result.get("SessionKey") or ""),
|
||
)
|
||
|
||
|
||
def _vod_apply_upload_inner(
|
||
ak: str, sk: str, token: str, space: str, file_size: int, source_ip: str = ""
|
||
) -> tuple[str, str, str, str]:
|
||
from .dy_util import DEFAULT_USER_AGENT
|
||
|
||
now = datetime.datetime.utcnow()
|
||
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
|
||
date_stamp = now.strftime("%Y%m%d")
|
||
params = {
|
||
"Action": "ApplyUploadInner",
|
||
"Version": "2020-11-19",
|
||
"SpaceName": space,
|
||
"FileType": "image",
|
||
"IsInner": "1",
|
||
"NeedFallback": "true",
|
||
"FileSize": str(file_size),
|
||
"s": _random_s(),
|
||
}
|
||
qs = urlencode(sorted(params.items()))
|
||
authorization = _aws4_authorization(
|
||
method="GET",
|
||
canonical_querystring=qs,
|
||
amz_date=amz_date,
|
||
date_stamp=date_stamp,
|
||
session_token=token,
|
||
access_key_id=ak,
|
||
secret_access_key=sk,
|
||
service=VOD_SERVICE,
|
||
)
|
||
with source_bound_requests_session(source_ip) as client:
|
||
resp = client.get(
|
||
f"{VOD_HOST}?{qs}",
|
||
headers={
|
||
"accept": "*/*",
|
||
"authorization": authorization,
|
||
"user-agent": DEFAULT_USER_AGENT,
|
||
"x-amz-date": amz_date,
|
||
"x-amz-security-token": token,
|
||
"Referer": "https://www.douyin.com/",
|
||
},
|
||
timeout=30,
|
||
verify=False,
|
||
proxies=None if source_ip else _requests_proxies(),
|
||
)
|
||
data = _safe_json(resp)
|
||
if data.get("error"):
|
||
raise RuntimeError(f"申请上传地址失败:{data['error']}")
|
||
meta = data.get("ResponseMetadata") or {}
|
||
err = meta.get("Error")
|
||
if err:
|
||
raise RuntimeError(f"申请上传地址失败:{err.get('Message') or err.get('Code')}")
|
||
return _extract_apply_inner(data)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 第 3 步:上传二进制(SpaceKey JWT 鉴权)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _vod_upload_binary(
|
||
host: str,
|
||
store_uri: str,
|
||
jwt_auth: str,
|
||
user_id: str,
|
||
raw: bytes,
|
||
session=None,
|
||
source_ip: str = "",
|
||
) -> None:
|
||
from .dy_util import DEFAULT_USER_AGENT
|
||
|
||
crc32 = format(zlib.crc32(raw) & 0xFFFFFFFF, "08x")
|
||
url = f"https://{host}/upload/v1/{store_uri}"
|
||
ua = (getattr(session, "user_agent", None) or DEFAULT_USER_AGENT)
|
||
headers = {
|
||
"Authorization": jwt_auth,
|
||
"Content-CRC32": crc32,
|
||
"Content-Type": "application/octet-stream",
|
||
"Content-Disposition": 'attachment; filename="undefined"',
|
||
"User-Agent": ua,
|
||
}
|
||
if user_id:
|
||
headers["X-Storage-U"] = str(user_id)
|
||
with source_bound_requests_session(source_ip) as client:
|
||
resp = client.post(
|
||
url,
|
||
headers=headers,
|
||
data=raw,
|
||
timeout=60,
|
||
verify=False,
|
||
proxies=None if source_ip else _requests_proxies(),
|
||
)
|
||
data = _safe_json(resp)
|
||
if data.get("error"):
|
||
raise RuntimeError(f"上传图片数据失败:{data['error']}")
|
||
if data.get("code") not in (2000, 0, None):
|
||
raise RuntimeError(f"上传图片数据失败:{data.get('message') or data}")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 第 4 步:CommitUploadInner(VOD),确认上传
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _vod_commit_upload_inner(
|
||
ak: str, sk: str, token: str, space: str, session_key: str, source_ip: str = ""
|
||
) -> dict[str, Any]:
|
||
from .dy_util import DEFAULT_USER_AGENT
|
||
|
||
now = datetime.datetime.utcnow()
|
||
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
|
||
date_stamp = now.strftime("%Y%m%d")
|
||
params = {
|
||
"Action": "CommitUploadInner",
|
||
"Version": "2020-11-19",
|
||
"SpaceName": space,
|
||
}
|
||
qs = urlencode(sorted(params.items()))
|
||
body = json.dumps({"SessionKey": session_key, "Functions": []}, separators=(",", ":"))
|
||
payload_hash = hashlib.sha256(body.encode()).hexdigest()
|
||
signed_headers = ["x-amz-content-sha256", "x-amz-date", "x-amz-security-token"]
|
||
authorization = _aws4_authorization(
|
||
method="POST",
|
||
canonical_querystring=qs,
|
||
amz_date=amz_date,
|
||
date_stamp=date_stamp,
|
||
session_token=token,
|
||
access_key_id=ak,
|
||
secret_access_key=sk,
|
||
payload_hash=payload_hash,
|
||
signed_headers=signed_headers,
|
||
service=VOD_SERVICE,
|
||
)
|
||
with source_bound_requests_session(source_ip) as client:
|
||
resp = client.post(
|
||
f"{VOD_HOST}?{qs}",
|
||
data=body,
|
||
headers={
|
||
"accept": "*/*",
|
||
"authorization": authorization,
|
||
"content-type": "application/json",
|
||
"user-agent": DEFAULT_USER_AGENT,
|
||
"x-amz-content-sha256": payload_hash,
|
||
"x-amz-date": amz_date,
|
||
"x-amz-security-token": token,
|
||
"Referer": "https://www.douyin.com/",
|
||
},
|
||
timeout=30,
|
||
verify=False,
|
||
proxies=None if source_ip else _requests_proxies(),
|
||
)
|
||
data = _safe_json(resp)
|
||
if data.get("error"):
|
||
raise RuntimeError(f"确认上传失败:{data['error']}")
|
||
meta = data.get("ResponseMetadata") or {}
|
||
err = meta.get("Error")
|
||
if err:
|
||
raise RuntimeError(f"确认上传失败:{err.get('Message') or err.get('Code')}")
|
||
return data.get("Result") or {}
|
||
|
||
|
||
def upload_im_image(
|
||
session,
|
||
raw: bytes,
|
||
*,
|
||
filename: str = "image.jpg",
|
||
content_type: str = "image/jpeg",
|
||
source_ip: str = "",
|
||
) -> dict[str, Any]:
|
||
"""上传图片到抖音 IM 私信图床(VOD/zhenzhen 空间)。
|
||
|
||
成功返回 {uri(tos-cn-o-*), url, url_list, md5};失败返回 {"error": "..."}。
|
||
"""
|
||
del filename, content_type # VOD 按二进制上传,文件名仅用于本地存储
|
||
if not raw:
|
||
return {"error": "图片为空"}
|
||
try:
|
||
ak, sk, token, space = _fetch_im_upload_sts(session, source_ip)
|
||
host, store_uri, jwt_auth, session_key = _vod_apply_upload_inner(
|
||
ak, sk, token, space, len(raw), source_ip
|
||
)
|
||
if not host or not store_uri or not jwt_auth:
|
||
return {"error": "申请上传地址失败:缺少 UploadHost/StoreUri/Auth"}
|
||
|
||
user_id = str(getattr(session, "my_uid", "") or "")
|
||
_vod_upload_binary(host, store_uri, jwt_auth, user_id, raw, session, source_ip)
|
||
_vod_commit_upload_inner(ak, sk, token, space, session_key, source_ip)
|
||
|
||
uri = store_uri.lstrip("/")
|
||
out: dict[str, Any] = {"uri": uri, "md5": hashlib.md5(raw).hexdigest()}
|
||
|
||
from .message_content import uri_to_cdn_urls
|
||
|
||
urls = uri_to_cdn_urls(uri)
|
||
if urls:
|
||
out["url_list"] = urls
|
||
out["url"] = urls[0]
|
||
|
||
logger.info("Uploaded IM image via VOD uri=%s host=%s space=%s", uri, host, space)
|
||
return out
|
||
except Exception as exc:
|
||
logger.warning("upload_im_image (VOD) failed: %s", exc)
|
||
return {"error": str(exc)}
|
||
|
||
|
||
def prepare_image_reply_spec(
|
||
spec: dict[str, Any],
|
||
session,
|
||
upload_dir: str,
|
||
source_ip: str = "",
|
||
) -> tuple[dict[str, Any], str]:
|
||
"""若图片仍是本地地址,则上传到抖音 CDN 并补全 uri。返回 (spec, error)。"""
|
||
if spec.get("type") != "image":
|
||
return spec, ""
|
||
|
||
uri = str(spec.get("uri") or "").strip()
|
||
url = str(spec.get("url") or "").strip()
|
||
|
||
# 已经持有抖音 CDN 的 uri,说明图片早已上传完成(且经历过风控/转码),直接复用即可。
|
||
# 关键修复:不要因为 url 仍是本机预览地址(/api/media/...)而再次上传——
|
||
# 重复上传会拿到一个“刚提交、尚未完成风控/转码”的新 uri,发送时常被抖音以
|
||
# raw_check_code=1 / status_code=8003 拒绝(与是否互关无关)。
|
||
# 发送链路(build_msg_payload)只用 uri / url_list,从不使用这个本机 url,故本机 url 无害。
|
||
if uri and not is_local_media_url(uri):
|
||
return spec, ""
|
||
|
||
if url and is_douyin_cdn_url(url) and not uri:
|
||
return spec, ""
|
||
|
||
raw: bytes | None = None
|
||
filename = "image.jpg"
|
||
content_type = "image/jpeg"
|
||
|
||
if is_local_media_url(url):
|
||
import os
|
||
|
||
card_parsed = parse_link_card_media_path(url)
|
||
if card_parsed:
|
||
# 卡片封面在 uploads/link-cards/{owner}/ 下,与消息图片目录(uploads/messages)不同。
|
||
owner_id, fname = card_parsed
|
||
link_cards_dir = os.path.join(os.path.dirname(upload_dir), "link-cards")
|
||
path = os.path.join(link_cards_dir, str(owner_id), fname)
|
||
else:
|
||
parsed = parse_local_message_media_path(url)
|
||
if not parsed:
|
||
return spec, "无法解析本地图片路径"
|
||
_, fname = parsed
|
||
path = os.path.join(upload_dir, str(parsed[0]), fname)
|
||
if not os.path.isfile(path):
|
||
return spec, f"本地图片不存在:{fname}"
|
||
with open(path, "rb") as f:
|
||
raw = f.read()
|
||
filename = fname
|
||
if fname.lower().endswith(".png"):
|
||
content_type = "image/png"
|
||
elif fname.lower().endswith(".webp"):
|
||
content_type = "image/webp"
|
||
elif fname.lower().endswith(".gif"):
|
||
content_type = "image/gif"
|
||
|
||
if raw is None:
|
||
if url.startswith("http") and not is_douyin_cdn_url(url):
|
||
return spec, "图片地址必须是抖音 CDN 或本地上传后的地址,外部 URL 无法用于 IM 发送"
|
||
return spec, "缺少可上传的图片数据"
|
||
|
||
uploaded = upload_im_image(
|
||
session,
|
||
raw,
|
||
filename=filename,
|
||
content_type=content_type,
|
||
source_ip=source_ip,
|
||
)
|
||
if uploaded.get("error"):
|
||
return spec, uploaded["error"]
|
||
if not uploaded.get("uri"):
|
||
return spec, "抖音图片上传失败:未返回 uri"
|
||
|
||
merged = {
|
||
**spec,
|
||
**uploaded,
|
||
"type": "image",
|
||
"text": spec.get("text") or "[图片]",
|
||
}
|
||
return merged, ""
|