steam_confirm_order.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387
  1. #!/usr/bin/env python3
  2. """
  3. Confirm a Steam trade/market confirmation by calling steamAPI.
  4. Endpoints used:
  5. 1) POST /trade-confirmations/list (optional, when confirmation_id is absent)
  6. 2) POST /trade-confirmations/operate (required)
  7. Example:
  8. python docs/steam_confirm_order.py \
  9. --mafile "/path/to/xxxx.maFile" \
  10. --steam-id 7656119XXXXXXXXXX \
  11. --access-token "your_access_token" \
  12. --refresh-token "your_refresh_token" \
  13. --confirmation-id 1234567890123456789
  14. """
  15. import argparse
  16. import json
  17. import os
  18. import sys
  19. import time
  20. from typing import Any, Dict, Optional, Tuple
  21. from urllib import request, error
  22. from urllib.parse import urlencode
  23. def post_json(url: str, payload: Dict[str, Any], timeout: int = 120) -> Tuple[int, str, Dict[str, Any]]:
  24. data = json.dumps(payload).encode("utf-8")
  25. req = request.Request(
  26. url=url,
  27. data=data,
  28. headers={"Content-Type": "application/json"},
  29. method="POST",
  30. )
  31. try:
  32. with request.urlopen(req, timeout=timeout) as resp:
  33. body = resp.read().decode("utf-8", errors="replace")
  34. code = resp.getcode()
  35. except error.HTTPError as e:
  36. code = e.code
  37. body = e.read().decode("utf-8", errors="replace")
  38. except Exception as e:
  39. return 0, str(e), {}
  40. try:
  41. parsed = json.loads(body) if body else {}
  42. except Exception:
  43. parsed = {}
  44. return code, body, parsed
  45. def build_session(args: argparse.Namespace) -> Dict[str, Any]:
  46. return {
  47. "SteamId": int(args.steam_id),
  48. "AccessToken": args.access_token,
  49. "RefreshToken": args.refresh_token,
  50. "SessionId": args.session_id or "",
  51. }
  52. def load_account_from_accounts_json(accounts_json_path: str, steam_account: str) -> Dict[str, Any]:
  53. if not os.path.exists(accounts_json_path):
  54. raise FileNotFoundError(f"accounts.json not found: {accounts_json_path}")
  55. with open(accounts_json_path, "r", encoding="utf-8") as f:
  56. root = json.load(f)
  57. accounts = root.get("accounts") or {}
  58. if steam_account not in accounts:
  59. raise KeyError(f"steam account not found in accounts.json: {steam_account}")
  60. info = accounts[steam_account]
  61. required_fields = ["steam_guard_path", "steamId", "accessToken", "refreshToken"]
  62. missing = [k for k in required_fields if not info.get(k)]
  63. if missing:
  64. raise ValueError(f"account info missing required fields: {', '.join(missing)}")
  65. return info
  66. def normalize_proxy_url(raw: str) -> Optional[str]:
  67. value = (raw or "").strip().strip('"').strip("'")
  68. if not value:
  69. return None
  70. if value.startswith(("http://", "https://", "socks5://")):
  71. return value
  72. if ":" in value:
  73. return f"http://{value}"
  74. return None
  75. def parse_proxy_api_response(body: str) -> Optional[str]:
  76. trimmed = (body or "").strip()
  77. if not trimmed:
  78. return None
  79. try:
  80. data = json.loads(trimmed)
  81. if isinstance(data, str):
  82. return normalize_proxy_url(data)
  83. if isinstance(data, dict):
  84. for key in ("proxyUrl", "proxy_url", "url", "proxy"):
  85. value = data.get(key)
  86. if isinstance(value, str):
  87. parsed = normalize_proxy_url(value)
  88. if parsed:
  89. return parsed
  90. host = data.get("ip") or data.get("host") or data.get("server")
  91. port = data.get("port") or data.get("serverPort")
  92. if host and port:
  93. return normalize_proxy_url(f"{host}:{port}")
  94. except Exception:
  95. pass
  96. for line in trimmed.splitlines():
  97. candidate = line.strip().split(",")[0].strip()
  98. parsed = normalize_proxy_url(candidate)
  99. if parsed:
  100. return parsed
  101. return None
  102. def load_proxy_config(accounts_json_path: str) -> Dict[str, Any]:
  103. steam_msg_dir = os.path.dirname(accounts_json_path)
  104. proxy_config_path = os.path.join(steam_msg_dir, "proxy_config.json")
  105. if not os.path.exists(proxy_config_path):
  106. return {}
  107. with open(proxy_config_path, "r", encoding="utf-8") as f:
  108. return json.load(f)
  109. def resolve_auto_proxy_from_cfg(cfg: Dict[str, Any], timeout_seconds: int, force_refresh_api: bool = False) -> Tuple[str, Optional[str]]:
  110. mode = (cfg.get("effectiveMode") or "").strip().lower()
  111. if mode in ("", "none"):
  112. return mode or "none", None
  113. effective_brand_id = cfg.get("effectiveBrandId") or ""
  114. brand_cfg = (cfg.get("configs") or {}).get(effective_brand_id)
  115. if not isinstance(brand_cfg, dict):
  116. raise ValueError(f"[proxy] no brand config found for effectiveBrandId={effective_brand_id}")
  117. if mode == "account":
  118. host = (brand_cfg.get("serverHost") or "").strip()
  119. port = int(brand_cfg.get("serverPort") or 0)
  120. user = (brand_cfg.get("serverUsername") or "").strip()
  121. password = brand_cfg.get("serverPassword") or ""
  122. if not host or port <= 0:
  123. raise ValueError("[proxy] account mode requires serverHost and serverPort")
  124. if user:
  125. proxy_url = f"http://{user}:{password}@{host}:{port}"
  126. else:
  127. proxy_url = f"http://{host}:{port}"
  128. return mode, proxy_url
  129. if mode == "api":
  130. api_url = (brand_cfg.get("apiUrl") or "").strip()
  131. if not api_url:
  132. raise ValueError("[proxy] api mode requires apiUrl")
  133. if force_refresh_api:
  134. sep = "&" if "?" in api_url else "?"
  135. api_url = f"{api_url}{sep}{urlencode({'_ts': str(int(time.time() * 1000))})}"
  136. req = request.Request(api_url, method="GET")
  137. try:
  138. with request.urlopen(req, timeout=min(timeout_seconds, 20)) as resp:
  139. body = resp.read().decode("utf-8", errors="replace")
  140. status = resp.getcode()
  141. except Exception as e:
  142. raise RuntimeError(f"[proxy] fetch api proxy failed: {e}") from e
  143. if status < 200 or status >= 300:
  144. raise RuntimeError(f"[proxy] proxy api returned status={status}, body={body[:300]}")
  145. proxy_url = parse_proxy_api_response(body)
  146. if not proxy_url:
  147. raise RuntimeError(f"[proxy] cannot parse proxy from api response: {body[:300]}")
  148. return mode, proxy_url
  149. raise ValueError(f"[proxy] unsupported effectiveMode={mode}")
  150. def proxy_precheck(proxy_url: Optional[str], timeout_seconds: int) -> bool:
  151. if not proxy_url:
  152. return True
  153. target = "https://steamcommunity.com/mobileconf/getlist"
  154. handler = request.ProxyHandler({"http": proxy_url, "https": proxy_url})
  155. opener = request.build_opener(handler)
  156. req = request.Request(target, method="GET")
  157. try:
  158. with opener.open(req, timeout=min(timeout_seconds, 15)) as resp:
  159. code = resp.getcode()
  160. print(f"[proxy] precheck success, status={code}, target=mobileconf/getlist")
  161. return True
  162. except error.HTTPError as e:
  163. # Reaching steam and getting an HTTP status still means proxy path is alive.
  164. print(f"[proxy] precheck reachable, status={e.code}, target=mobileconf/getlist")
  165. return True
  166. except Exception as e:
  167. print(f"[proxy] precheck failed: {e}")
  168. return False
  169. def is_proxy_related_failure(code: int, body: str) -> bool:
  170. if code in (0, 407, 502, 503, 504):
  171. return True
  172. text = (body or "").lower()
  173. tokens = [
  174. "proxy",
  175. "tunnel",
  176. "timed out",
  177. "timeout",
  178. "connection refused",
  179. "connection reset",
  180. "failed to connect",
  181. "unreachable",
  182. "network is unreachable",
  183. "name or service not known",
  184. "an error occurred while sending the request",
  185. ]
  186. return any(token in text for token in tokens)
  187. def resolve_confirmation_id(
  188. api_base: str,
  189. mafile_path: str,
  190. session: Dict[str, Any],
  191. timeout_seconds: int,
  192. proxy_url: Optional[str],
  193. ) -> Tuple[Optional[int], Optional[int]]:
  194. list_url = f"{api_base.rstrip('/')}/trade-confirmations/list"
  195. payload = {"MaFilePath": mafile_path, "Session": session}
  196. if proxy_url:
  197. payload["ProxyUrl"] = proxy_url
  198. code, body, parsed = post_json(list_url, payload, timeout=timeout_seconds)
  199. if code != 200:
  200. print(f"[list] failed, http={code}, body={body}")
  201. return None, None
  202. confirmations = parsed.get("confirmations") or []
  203. if not confirmations:
  204. print("[list] no pending confirmations.")
  205. return None, None
  206. first = confirmations[0]
  207. cid = first.get("id")
  208. ckey = first.get("key")
  209. print(f"[list] picked first pending confirmation id={cid}, key={ckey}")
  210. try:
  211. return int(cid), int(ckey) if ckey is not None else None
  212. except Exception:
  213. return None, None
  214. def main() -> int:
  215. parser = argparse.ArgumentParser(description="Confirm Steam order via steamAPI /trade-confirmations/operate")
  216. parser.add_argument("--api-base", default="http://localhost:5000", help="steamAPI base url, default http://localhost:5000")
  217. parser.add_argument("--accounts-json", default="/Users/wuzilong/Library/Application Support/steam-msg/accounts.json", help="accounts.json path")
  218. parser.add_argument("--steam-account", default="", help="steam account key in accounts.json, e.g. gt23053")
  219. parser.add_argument("--mafile", default="", help="absolute path to .maFile (optional, overrides accounts.json)")
  220. parser.add_argument("--steam-id", type=int, default=0, help="SteamId (optional, overrides accounts.json)")
  221. parser.add_argument("--access-token", default="", help="AccessToken (optional, overrides accounts.json)")
  222. parser.add_argument("--refresh-token", default="", help="RefreshToken (optional, overrides accounts.json)")
  223. parser.add_argument("--session-id", default="", help="optional SessionId from /login response session.sessionId")
  224. parser.add_argument("--confirmation-id", type=int, required=True, help="pending confirmation id to operate")
  225. parser.add_argument("--confirmation-key", type=int, default=0, help="optional confirmation key; omit to let server resolve")
  226. parser.add_argument("--operation", default="accept", choices=["accept", "cancel"], help="accept or cancel, default accept")
  227. parser.add_argument("--timeout-seconds", type=int, default=120, help="HTTP timeout for steamAPI call, default 120")
  228. parser.add_argument("--proxy-retry-times", type=int, default=2, help="retry times when api proxy fails, default 2")
  229. args = parser.parse_args()
  230. if args.steam_account:
  231. try:
  232. account_info = load_account_from_accounts_json(args.accounts_json, args.steam_account)
  233. except Exception as e:
  234. print(f"[account] failed to load account from accounts.json: {e}")
  235. return 2
  236. if not args.mafile:
  237. args.mafile = account_info.get("steam_guard_path", "")
  238. if not args.steam_id:
  239. args.steam_id = int(account_info.get("steamId", 0))
  240. if not args.access_token:
  241. args.access_token = account_info.get("accessToken", "")
  242. if not args.refresh_token:
  243. args.refresh_token = account_info.get("refreshToken", "")
  244. if not args.session_id:
  245. args.session_id = account_info.get("sessionId", "")
  246. if not args.mafile or not args.steam_id or not args.access_token or not args.refresh_token:
  247. print("[args] missing required auth fields. provide --steam-account or manual --mafile/--steam-id/--access-token/--refresh-token")
  248. return 2
  249. proxy_cfg = load_proxy_config(args.accounts_json)
  250. if not proxy_cfg:
  251. print("[proxy] proxy_config.json not found, use direct mode.")
  252. try:
  253. proxy_mode, proxy_url = resolve_auto_proxy_from_cfg(proxy_cfg, args.timeout_seconds, force_refresh_api=False)
  254. except Exception as e:
  255. print(str(e))
  256. return 2
  257. print(f"[proxy] effectiveMode={proxy_mode}, selected proxy={proxy_url or '<none>'}")
  258. if not proxy_precheck(proxy_url, args.timeout_seconds):
  259. if proxy_mode == "api":
  260. print("[proxy] precheck failed, refresh api proxy and retry precheck.")
  261. try:
  262. _, proxy_url = resolve_auto_proxy_from_cfg(proxy_cfg, args.timeout_seconds, force_refresh_api=True)
  263. except Exception as e:
  264. print(str(e))
  265. return 2
  266. print(f"[proxy] refreshed api proxy={proxy_url or '<none>'}")
  267. if not proxy_precheck(proxy_url, args.timeout_seconds):
  268. return 2
  269. else:
  270. return 2
  271. session = build_session(args)
  272. confirmation_id = args.confirmation_id
  273. confirmation_key = args.confirmation_key
  274. if confirmation_id == 0:
  275. confirmation_id, resolved_key = resolve_confirmation_id(
  276. args.api_base,
  277. args.mafile,
  278. session,
  279. args.timeout_seconds,
  280. proxy_url,
  281. )
  282. if not confirmation_id:
  283. print("[operate] no valid confirmation id found.")
  284. return 2
  285. if not confirmation_key and resolved_key:
  286. confirmation_key = resolved_key
  287. operate_url = f"{args.api_base.rstrip('/')}/trade-confirmations/operate"
  288. payload: Dict[str, Any] = {
  289. "MaFilePath": args.mafile,
  290. "Session": session,
  291. "ConfirmationId": int(confirmation_id),
  292. "Operation": args.operation,
  293. }
  294. if proxy_url:
  295. payload["ProxyUrl"] = proxy_url
  296. if confirmation_key:
  297. payload["ConfirmationKey"] = int(confirmation_key)
  298. max_attempts = 1 + max(args.proxy_retry_times, 0) if proxy_mode == "api" else 1
  299. for attempt in range(1, max_attempts + 1):
  300. if proxy_url:
  301. payload["ProxyUrl"] = proxy_url
  302. else:
  303. payload.pop("ProxyUrl", None)
  304. code, body, parsed = post_json(operate_url, payload, timeout=args.timeout_seconds)
  305. print(f"[operate] attempt={attempt}/{max_attempts} http={code}")
  306. if parsed:
  307. print(json.dumps(parsed, ensure_ascii=False, indent=2))
  308. else:
  309. print(body)
  310. if code == 200 and parsed and parsed.get("success") is True:
  311. print("[operate] confirmation succeeded.")
  312. return 0
  313. if attempt >= max_attempts:
  314. break
  315. if not is_proxy_related_failure(code, body):
  316. print("[proxy] non-proxy failure detected, stop retry.")
  317. break
  318. print("[proxy] proxy-related failure detected, refresh api proxy then retry.")
  319. try:
  320. _, proxy_url = resolve_auto_proxy_from_cfg(proxy_cfg, args.timeout_seconds, force_refresh_api=True)
  321. print(f"[proxy] refreshed api proxy={proxy_url or '<none>'}")
  322. except Exception as e:
  323. print(str(e))
  324. break
  325. if not proxy_precheck(proxy_url, args.timeout_seconds):
  326. break
  327. if code != 200:
  328. return 1
  329. print("[operate] request finished but success=false.")
  330. return 3
  331. if __name__ == "__main__":
  332. sys.exit(main())