@follenfang/fupload 0.0.11 → 0.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,12 +25,23 @@ from typing import Any, Dict, Mapping, Optional
25
25
  from .errors import FuploadError, ValidationError, redact
26
26
  from .state_machine import COMPLETE, ProjectStateMachine
27
27
  from .modus_zip import parse_modus_zip
28
+ from .transport import json_request, multipart_request
28
29
 
29
30
 
30
31
  API_BASE = "https://app.modus.cool/api/"
32
+ STATIC_API_BASE = "https://cdn.modus.cool/modus/client_static_api/"
33
+ RESOURCE_BASE = "https://cdn.modus.cool/"
31
34
  TOKEN_PATH = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) / "ModUs.Creator" / "auth" / "token.dat"
32
35
  TOKEN_ENTROPY = b"ModUs.Creator.TokenStore.v1"
36
+ MODUS_APPDATA = Path(os.environ.get("APPDATA", Path.home() / "AppData/Roaming")) / "modus"
33
37
  MAX_PACKAGE_BYTES = 200 * 1024 * 1024
38
+ MODUS_BUILDS = (
39
+ {"id": 0, "code": "retail", "name": "至暗之夜", "label": "正式服-至暗之夜"},
40
+ {"id": 1, "code": "classic_era", "name": "经典旧世", "label": "怀旧服-经典旧世"},
41
+ {"id": 2, "code": "classic", "name": "熊猫人之谜", "label": "怀旧服-熊猫人之谜"},
42
+ {"id": 3, "code": "classic_titan", "name": "泰坦重铸", "label": "时光服-泰坦重铸"},
43
+ {"id": 4, "code": "anniversary", "name": "燃烧的远征", "label": "周年服-燃烧的远征"},
44
+ )
34
45
  _SECRET_KEYS = {"token", "access_token", "authorization", "cookie", "signedurl", "signed_url", "upload_url"}
35
46
  _WIRE_NAMES = {
36
47
  "project_id": "projectId", "file_id": "fileId", "alt_name": "altName",
@@ -100,6 +111,14 @@ def _category_ids(value: Any) -> Any:
100
111
  return output
101
112
 
102
113
 
114
+ def _tag_filter(value: Any) -> list[str]:
115
+ values = value if isinstance(value, (list, tuple, set)) else str(value).split(",")
116
+ result = [str(item).strip() for item in values if str(item).strip()]
117
+ if not result:
118
+ raise ValidationError("tag filter must contain at least one ID", path="tags")
119
+ return result
120
+
121
+
103
122
  def _sync_type(value: Any) -> Any:
104
123
  if value is None:
105
124
  return 0
@@ -124,13 +143,22 @@ def _image_ops(value: Any) -> list[Dict[str, str]]:
124
143
  path = "image_ops[%d]" % index
125
144
  if not isinstance(item, Mapping):
126
145
  raise ValidationError("image operation must be an object", path=path)
127
- unknown = sorted(set(item) - {"op", "name", "base64"})
146
+ op = item.get("op")
147
+ if op not in ("upload", "delete", "rename"):
148
+ raise ValidationError("image operation must be upload, delete, or rename", path=path + ".op")
149
+ allowed = {"op", "from", "to"} if op == "rename" else {"op", "name", "base64"}
150
+ unknown = sorted(set(item) - allowed)
128
151
  if unknown:
129
152
  raise ValidationError("unknown image operation field(s): %s" % ", ".join(unknown), path=path + "." + unknown[0])
130
- op = item.get("op")
153
+ if op == "rename":
154
+ source, target = item.get("from"), item.get("to")
155
+ if not isinstance(source, str) or not source.strip():
156
+ raise ValidationError("rename image operation requires from", path=path + ".from")
157
+ if not isinstance(target, str) or not target.strip():
158
+ raise ValidationError("rename image operation requires to", path=path + ".to")
159
+ result.append({"op": op, "from": source.strip(), "to": target.strip()})
160
+ continue
131
161
  name = item.get("name")
132
- if op not in ("upload", "delete"):
133
- raise ValidationError("image operation must be upload or delete", path=path + ".op")
134
162
  if not isinstance(name, str) or not name.strip():
135
163
  raise ValidationError("image operation name must be non-empty", path=path + ".name")
136
164
  operation = {"op": op, "name": name.strip()}
@@ -158,6 +186,16 @@ def _positive_id(value: Any, *, field: str) -> int:
158
186
  return result
159
187
 
160
188
 
189
+ def _resource_id(value: Any, *, field: str) -> str:
190
+ """ModUs share/import IDs are opaque positive decimal strings."""
191
+ if isinstance(value, bool) or value is None:
192
+ raise ValidationError("%s must be a non-empty identifier" % field, path=field)
193
+ text = str(value).strip()
194
+ if not text or (text.isdigit() and int(text) <= 0):
195
+ raise ValidationError("%s must be a non-empty identifier" % field, path=field)
196
+ return text
197
+
198
+
161
199
  def _supported_game_versions(value: Any) -> list[Dict[str, str]]:
162
200
  """Map CLI aliases to Creator's anonymous {gameVersion, server} objects.
163
201
 
@@ -274,7 +312,7 @@ def _project_wire(value: Mapping[str, Any], *, create: bool = False) -> Dict[str
274
312
  """Project project create/update fields to Creator's request shape."""
275
313
  result: Dict[str, Any] = {}
276
314
  for key in ("name", "alt_name", "summary", "repo_url"):
277
- if key in value and value[key] is not None:
315
+ if key in value and (value[key] is not None or not create):
278
316
  wire_name = _WIRE_NAMES.get(key, key)
279
317
  if create:
280
318
  if key == "repo_url" and not str(value[key]).strip():
@@ -343,13 +381,56 @@ def _business_code(payload: Mapping[str, Any]) -> Optional[int]:
343
381
  return None
344
382
 
345
383
 
384
+ def _image_upload_record(payload: Any) -> Dict[str, str]:
385
+ """Extract the reusable key and display URL used by the main client."""
386
+ data = _unwrap(payload)
387
+ if not isinstance(data, Mapping):
388
+ raise FuploadError(
389
+ "ModUs image upload returned no media record",
390
+ kind="platform_data_error",
391
+ stage="media_upload",
392
+ )
393
+
394
+ download_url = data.get("downloadUrl") or data.get("url") or data.get("fileUrl")
395
+ reference = data.get("cosStoreKey") or data.get("cosStoreUrl") or data.get("key")
396
+ if not reference and isinstance(download_url, str) and download_url.strip():
397
+ parsed = urllib.parse.urlsplit(download_url.strip())
398
+ reference = urllib.parse.unquote(parsed.path.lstrip("/"))
399
+ if not isinstance(reference, str) or not reference.strip():
400
+ raise FuploadError(
401
+ "ModUs image upload omitted its object key",
402
+ kind="platform_data_error",
403
+ stage="media_upload",
404
+ )
405
+
406
+ reference = reference.strip()
407
+ if isinstance(download_url, str) and download_url.strip():
408
+ display_url = download_url.strip()
409
+ elif reference.startswith(("https://", "http://")):
410
+ display_url = reference
411
+ else:
412
+ display_url = urllib.parse.urljoin(RESOURCE_BASE, reference.lstrip("/"))
413
+ return {"key": reference, "url": display_url, "reference": reference}
414
+
415
+
346
416
  def _safe(value: Any) -> Any:
347
417
  """Recursively redact credentials and presigned URLs in API results."""
348
418
  if isinstance(value, Mapping):
349
419
  result = {}
350
420
  for key, item in value.items():
351
421
  normalized = str(key).replace("-", "_").lower()
352
- result[str(key)] = "[REDACTED]" if normalized in _SECRET_KEYS else _safe(item)
422
+ if normalized in _SECRET_KEYS:
423
+ result[str(key)] = "[REDACTED]"
424
+ elif normalized in {
425
+ "content", "contenttext", "codetext", "changelog",
426
+ "description", "licensecontent",
427
+ }:
428
+ # write_output() hashes these fields before serializing JSON.
429
+ # Preserve the original here so the digest represents the
430
+ # service value instead of a generic redaction placeholder.
431
+ result[str(key)] = item
432
+ else:
433
+ result[str(key)] = _safe(item)
353
434
  return result
354
435
  if isinstance(value, list):
355
436
  return [_safe(item) for item in value]
@@ -407,6 +488,94 @@ def load_token(path: Optional[Path] = None) -> str:
407
488
  return token
408
489
 
409
490
 
491
+ def load_main_session(root: Optional[Path] = None) -> Dict[str, str]:
492
+ """Recover the main ModUs renderer session from Chromium local storage.
493
+
494
+ Chromium LevelDB values are length-prefixed and may be locked by the
495
+ running client. We therefore scan shared-read bytes for JSON fragments,
496
+ accepting only the known persisted token/device keys and never returning
497
+ unrelated storage values.
498
+ """
499
+ base = root or MODUS_APPDATA
500
+ leveldb = base / "Local Storage" / "leveldb"
501
+ if not leveldb.is_dir():
502
+ raise FuploadError("ModUs main-client local storage was not found", kind="authentication_error")
503
+ token = ""
504
+ device = ""
505
+ for path in sorted(leveldb.iterdir()):
506
+ if not path.is_file() or path.name in {"LOCK", "LOG", "LOG.old", "CURRENT", "MANIFEST-000001"}:
507
+ continue
508
+ try:
509
+ with path.open("rb", buffering=0) as handle:
510
+ raw = handle.read()
511
+ except OSError:
512
+ continue
513
+ text = raw.decode("utf-8", errors="ignore")
514
+ # Pinia persistence is JSON, but the LevelDB record can contain a
515
+ # prefix/suffix. Try JSON objects first, then bounded key/value pairs.
516
+ candidates = [text]
517
+ # Persisted Pinia user JSON contains nested wallet objects, so a
518
+ # shallow-brace regex is insufficient. Extract only scalar credential
519
+ # fields from the record instead of attempting to parse the whole log.
520
+ import re
521
+ token_match = re.search(r'"(?:token|accessToken|access_token)"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"', text)
522
+ device_match = re.search(r'"(?:deviceId|device_id)"\s*:\s*"([^"\\]*)"', text)
523
+ if token_match and not token:
524
+ token = bytes(token_match.group(1), "utf-8").decode("unicode_escape")
525
+ if device_match and not device:
526
+ device = device_match.group(1)
527
+ for candidate in candidates:
528
+ try:
529
+ parsed = json.loads(candidate)
530
+ except (TypeError, ValueError):
531
+ continue
532
+ stack = [parsed]
533
+ while stack:
534
+ item = stack.pop()
535
+ if isinstance(item, Mapping):
536
+ for key, value in item.items():
537
+ normalized = str(key).lower()
538
+ if isinstance(value, str) and value.strip():
539
+ if normalized in {"token", "accesstoken", "access_token"} and not token:
540
+ token = value.strip()
541
+ elif normalized in {"deviceid", "device_id"} and not device:
542
+ device = value.strip()
543
+ elif isinstance(value, (Mapping, list)):
544
+ stack.append(value)
545
+ elif isinstance(item, list):
546
+ stack.extend(item)
547
+ if token and device:
548
+ break
549
+ if not token:
550
+ raise FuploadError("ModUs main-client login token was not found", kind="authentication_error")
551
+ return {"token": token, **({"device_id": device} if device else {})}
552
+
553
+
554
+ def load_current_build(root: Optional[Path] = None) -> Optional[int]:
555
+ """Read the persisted main-client currentGameWow id when available."""
556
+ base = root or MODUS_APPDATA
557
+ leveldb = base / "Local Storage" / "leveldb"
558
+ if not leveldb.is_dir():
559
+ return None
560
+ import re
561
+ for path in sorted(leveldb.iterdir()):
562
+ if not path.is_file() or path.name in {"LOCK", "LOG", "LOG.old", "CURRENT", "MANIFEST-000001"}:
563
+ continue
564
+ try:
565
+ with path.open("rb", buffering=0) as handle:
566
+ text = handle.read().decode("utf-8", errors="ignore")
567
+ except OSError:
568
+ continue
569
+ # Pinia persistence may be split across records; accept only the scalar id.
570
+ for pattern in (r'currentGameWow[^{}]{0,120}?"id"\s*:\s*(\d+)', r'"currentGameWowId"\s*:\s*(\d+)'):
571
+ match = re.search(pattern, text)
572
+ if match:
573
+ value = int(match.group(1))
574
+ if 0 <= value <= 4:
575
+ return value
576
+ return None
577
+
578
+
410
579
  def _unwrap(payload: Any) -> Any:
411
580
  if isinstance(payload, Mapping) and "data" in payload:
412
581
  return payload["data"]
@@ -423,12 +592,28 @@ class ModUs:
423
592
  base_url: str = API_BASE,
424
593
  timeout: int = 60,
425
594
  token_path: Optional[Path] = None,
595
+ device_id: Optional[str] = None,
596
+ main_session: bool = False,
426
597
  authenticate: bool = True,
427
598
  ) -> None:
428
599
  self.base_url = base_url.rstrip("/") + "/"
429
600
  self.timeout = timeout
430
601
  self.token_path = token_path or TOKEN_PATH
431
- self.token = token if token is not None else (load_token(self.token_path) if authenticate else "")
602
+ self.device_id = device_id
603
+ self.main_session = main_session
604
+ self.current_build = load_current_build() if main_session else None
605
+ self.authorization_scheme = ""
606
+ if token is not None:
607
+ self.token = token
608
+ elif authenticate:
609
+ if main_session:
610
+ session = load_main_session()
611
+ self.token, self.device_id = session["token"], session.get("device_id")
612
+ self.authorization_scheme = ""
613
+ else:
614
+ self.token = load_token(self.token_path)
615
+ else:
616
+ self.token = ""
432
617
 
433
618
  def _url(self, path: str) -> str:
434
619
  return urllib.parse.urljoin(self.base_url, path.lstrip("/"))
@@ -454,7 +639,10 @@ class ModUs:
454
639
  def _request(self, method: str, path: str, body: Any = None, *, headers: Optional[Mapping[str, str]] = None) -> Any:
455
640
  url = self._url(path)
456
641
  stage = self._request_stage(method, path)
457
- request_headers = {"Accept": "application/json", "Authorization": "Bearer " + self.token}
642
+ authorization = self.token if self.authorization_scheme == "" else self.authorization_scheme + self.token
643
+ request_headers = {"Accept": "application/json", "Authorization": authorization}
644
+ if self.device_id:
645
+ request_headers["X-Device-Id"] = self.device_id
458
646
  if headers:
459
647
  request_headers.update(headers)
460
648
  data = None
@@ -472,7 +660,7 @@ class ModUs:
472
660
  except Exception:
473
661
  detail = "HTTP %d" % exc.code
474
662
  raise FuploadError(redact(str(detail)), endpoint=url, http_status=exc.code, kind="platform_error", stage=stage) from exc
475
- except (OSError, urllib.error.URLError) as exc:
663
+ except (OSError, urllib.error.URLError, http.client.IncompleteRead) as exc:
476
664
  raise FuploadError("ModUs request failed: %s" % exc, endpoint=url, verification_required=method != "GET", stage=stage) from exc
477
665
  if status < 200 or status >= 300:
478
666
  raise FuploadError("ModUs returned HTTP %d" % status, endpoint=url, http_status=status, stage=stage)
@@ -494,6 +682,32 @@ class ModUs:
494
682
  return payload
495
683
 
496
684
  def doctor(self) -> Dict[str, Any]:
685
+ if self.main_session:
686
+ result = {
687
+ "token_present": False,
688
+ "token_decrypted": False,
689
+ "token_nonempty": False,
690
+ "api_ready": False,
691
+ }
692
+ try:
693
+ session = load_main_session()
694
+ token = str(session.get("token") or "").strip()
695
+ result["token_present"] = bool(token)
696
+ result["token_decrypted"] = bool(token)
697
+ result["token_nonempty"] = bool(token)
698
+ if not token:
699
+ return result
700
+ previous_token, previous_device = self.token, self.device_id
701
+ self.token = token
702
+ self.device_id = session.get("device_id")
703
+ try:
704
+ self.user_info()
705
+ result["api_ready"] = True
706
+ finally:
707
+ self.token, self.device_id = previous_token, previous_device
708
+ except (FuploadError, OSError, UnicodeError, ValueError, TypeError):
709
+ pass
710
+ return result
497
711
  selected = self.token_path
498
712
  if not selected.is_file() and selected.with_name("token.json").is_file():
499
713
  selected = selected.with_name("token.json")
@@ -576,31 +790,57 @@ class ModUs:
576
790
  def user_info(self) -> Any:
577
791
  return _safe(_unwrap(self._request("GET", "system/user/getInfo")))
578
792
 
793
+ def builds(self) -> Any:
794
+ """Return the fixed WoW Build choices used by the ModUs main client."""
795
+ current = self.current_build
796
+ return {"current": current, "builds": [dict(item) for item in MODUS_BUILDS]}
797
+
798
+ def _build(self, value: Optional[int]) -> int:
799
+ selected = self.current_build if value is None else value
800
+ if selected is None:
801
+ # The desktop wrapper uses getCurrentWow?.id || 0.
802
+ selected = 0
803
+ selected = int(selected)
804
+ if selected < 0 or selected > 4:
805
+ raise ValidationError("server_type must be a known ModUs Build id", path="server_type")
806
+ return selected
807
+
579
808
  def active_subscription_count(self) -> Any:
580
809
  return _safe(_unwrap(self._request("GET", "user/author/subscription/active/count")))
581
810
 
582
811
  def project_statistics(self) -> Any:
583
812
  return _safe(_unwrap(self._request("GET", "game/data/author/project/statistics")))
584
813
 
585
- def addon_info(self, directories: Any, *, server_type: int = 1) -> Any:
814
+ def addon_info(self, directories: Any, *, server_type: Optional[int] = None) -> Any:
586
815
  values = directories if isinstance(directories, (list, tuple)) else [directories]
587
816
  body = {"pluginList": [str(value) for value in values]}
588
- return _safe(_unwrap(self._request("POST", "plugin/list/info", body, headers={"X-Server-Type": str(int(server_type))})))
817
+ return _safe(_unwrap(self._request("POST", "plugin/list/info", body, headers={"X-Server-Type": str(self._build(server_type))})))
589
818
 
590
- def addon_project_info(self, project_ids: Any, *, server_type: int = 1) -> Any:
819
+ def addon_project_info(self, project_ids: Any, *, server_type: Optional[int] = None) -> Any:
591
820
  values = project_ids if isinstance(project_ids, (list, tuple)) else [project_ids]
592
821
  body = {"projectIds": [int(value) for value in values]}
593
- return _safe(_unwrap(self._request("POST", "plugin/list/detail", body, headers={"X-Server-Type": str(int(server_type))})))
822
+ return _safe(_unwrap(self._request("POST", "plugin/list/detail", body, headers={"X-Server-Type": str(self._build(server_type))})))
594
823
 
595
- def addon_history(self, project_id: int, *, page_num: int = 1, page_size: int = 5, server_type: int = 1) -> Any:
824
+ def addon_history(self, project_id: int, *, page_num: int = 1, page_size: int = 5, server_type: Optional[int] = None) -> Any:
596
825
  body = {"projectIds": [int(project_id)], "pageNum": int(page_num), "pageSize": int(page_size)}
597
- return _safe(_unwrap(self._request("POST", "plugin/project/history", body, headers={"X-Server-Type": str(int(server_type))})))
826
+ return _safe(_unwrap(self._request("POST", "plugin/project/history", body, headers={"X-Server-Type": str(self._build(server_type))})))
598
827
 
599
828
  def project_dependencies(self, query: Any) -> Any:
600
829
  body = _dependency_query_wire(query)
601
830
  return _safe(_unwrap(self._request("POST", "game/data/author/project/dependency/query", body)))
602
831
 
603
832
  def options(self, action: str, *, keys: Optional[Any] = None) -> Any:
833
+ static_files = {
834
+ "config-tags": "share_tags.json",
835
+ "wa-tags": "imports_tags.json",
836
+ "wa-support-addons": "imports_support_addons.json",
837
+ }
838
+ if action in static_files:
839
+ url = urllib.parse.urljoin(STATIC_API_BASE, static_files[action])
840
+ payload = json_request(url)
841
+ if isinstance(payload, Mapping) and "rows" in payload:
842
+ payload = payload["rows"]
843
+ return _safe(_unwrap(payload))
604
844
  routes = {
605
845
  # These paths are the routes used by ModUs.Creator's ApiService.
606
846
  "categories": "plugin/list/Categories",
@@ -618,6 +858,298 @@ class ModUs:
618
858
  raise ValidationError("unsupported ModUs options operation")
619
859
  return _safe(_unwrap(self._request("GET", routes[action])))
620
860
 
861
+ @staticmethod
862
+ def _option_rows(value: Any) -> list[Mapping[str, Any]]:
863
+ if isinstance(value, Mapping):
864
+ value = value.get("rows", value.get("data", []))
865
+ return [item for item in value if isinstance(item, Mapping)] if isinstance(value, list) else []
866
+
867
+ @classmethod
868
+ def _nested_option_rows(cls, value: Any) -> list[Mapping[str, Any]]:
869
+ rows: list[Mapping[str, Any]] = []
870
+ pending = list(cls._option_rows(value))
871
+ while pending:
872
+ item = pending.pop(0)
873
+ rows.append(item)
874
+ pending.extend(cls._option_rows(item.get("children", [])))
875
+ return rows
876
+
877
+ @staticmethod
878
+ def _csv_values(value: Any) -> list[str]:
879
+ return [item.strip() for item in str(value or "").split(",") if item.strip()]
880
+
881
+ @staticmethod
882
+ def _account_rows(backup: Mapping[str, Any]) -> list[Mapping[str, Any]]:
883
+ value = backup.get("wtfAccounts") or []
884
+ if isinstance(value, str):
885
+ try:
886
+ value = json.loads(value)
887
+ except (TypeError, ValueError):
888
+ value = []
889
+ return [item for item in value if isinstance(item, Mapping)] if isinstance(value, list) else []
890
+
891
+ @staticmethod
892
+ def _backup_addons_id(backup: Mapping[str, Any]) -> Optional[str]:
893
+ value = backup.get("knownAddons")
894
+ if not isinstance(value, str) or not value.strip():
895
+ return None
896
+ try:
897
+ parsed = json.loads(value)
898
+ except (TypeError, ValueError):
899
+ return None
900
+ project_ids = parsed.get("projectids") if isinstance(parsed, Mapping) else None
901
+ return None if project_ids is None else str(project_ids)
902
+
903
+ def _validate_project_write(self, action: str, doc: Mapping[str, Any]) -> None:
904
+ if action not in {"create", "release", "update", "edit"}:
905
+ return
906
+ document = _project_document(doc)
907
+ allowed_categories = {
908
+ int(item["id"])
909
+ for item in self._nested_option_rows(self.options("categories"))
910
+ if item.get("id") is not None
911
+ }
912
+ unknown = [item for item in document.get("categories", []) if item not in allowed_categories]
913
+ if unknown:
914
+ raise ValidationError(
915
+ "category is not present in current Creator options",
916
+ path="$.project_state.basic_info.categories",
917
+ )
918
+
919
+ def _validate_main_write(self, resource: str, action: str, doc: Mapping[str, Any]) -> None:
920
+ if resource not in {"config", "wa"} or action not in {"create", "update", "edit"}:
921
+ return
922
+
923
+ if "tags" in doc:
924
+ option_name = "config-tags" if resource == "config" else "wa-tags"
925
+ allowed_tags = {str(item.get("id")) for item in self._option_rows(self.options(option_name)) if item.get("id") is not None}
926
+ unknown = [item for item in self._csv_values(doc.get("tags")) if item not in allowed_tags]
927
+ if unknown:
928
+ raise ValidationError("tag is not present in current %s options" % option_name, path="$.tags")
929
+
930
+ tier_id = doc.get("required_tier_id")
931
+ if tier_id is not None:
932
+ tiers = self._option_rows(self.options("subscription-tiers"))
933
+ allowed_tiers = {
934
+ str(item.get("id"))
935
+ for item in tiers
936
+ if item.get("id") is not None and item.get("isEnabled", item.get("is_enabled", 1)) not in (0, False)
937
+ }
938
+ if str(tier_id) not in allowed_tiers:
939
+ raise ValidationError("required_tier_id is not present in current tier options", path="$.required_tier_id")
940
+
941
+ if resource == "wa" and ("support_addon" in doc or "addons_id" in doc):
942
+ selected_doc = dict(doc)
943
+ if action in {"update", "edit"} and (
944
+ "support_addon" not in selected_doc or "addons_id" not in selected_doc
945
+ ):
946
+ current = self.import_detail(doc["import_id"], server_type=doc.get("server_type"))
947
+ current = current[0] if isinstance(current, list) and current else current
948
+ if isinstance(current, Mapping):
949
+ selected_doc.setdefault("support_addon", current.get("supportAddon"))
950
+ selected_doc.setdefault("addons_id", current.get("addonsId"))
951
+ support_rows = self._option_rows(self.options("wa-support-addons"))
952
+ selected = next((item for item in support_rows if str(item.get("name")) == str(selected_doc.get("support_addon"))), None)
953
+ if selected is None or str(selected.get("id")) != str(selected_doc.get("addons_id")):
954
+ raise ValidationError("support_addon and addons_id must match current options", path="$.support_addon")
955
+ return
956
+
957
+ if resource != "config":
958
+ return
959
+ linkage_fields = {"backup_id", "addons_id", "account_name", "role_name", "exclude_wtf"}
960
+ if not linkage_fields.intersection(doc):
961
+ return
962
+ merged = dict(doc)
963
+ if action in {"update", "edit"} and "backup_id" not in merged:
964
+ current = self.share_detail(doc["share_id"], server_type=doc.get("server_type"))
965
+ current = current[0] if isinstance(current, list) and current else current
966
+ if isinstance(current, Mapping):
967
+ aliases = {
968
+ "backupId": "backup_id", "addonsId": "addons_id", "accountName": "account_name",
969
+ "roleName": "role_name", "excludeWtf": "exclude_wtf",
970
+ }
971
+ for wire, local in aliases.items():
972
+ if local not in merged and wire in current:
973
+ merged[local] = current[wire]
974
+ backup_id = merged.get("backup_id")
975
+ backups = self._option_rows(self.cloud_backups(server_type=merged.get("server_type")))
976
+ backup = next((item for item in backups if str(item.get("id")) == str(backup_id)), None)
977
+ if backup is None:
978
+ raise ValidationError("backup_id is not present in the selected Build", path="$.backup_id")
979
+ expected_addons = self._backup_addons_id(backup)
980
+ if expected_addons is not None and "addons_id" in merged and str(merged.get("addons_id")) != expected_addons:
981
+ raise ValidationError("addons_id does not match selected backup knownAddons.projectids", path="$.addons_id")
982
+ if int(merged.get("exclude_wtf") or 0) == 1:
983
+ return
984
+ account_name = str(merged.get("account_name") or "")
985
+ account = next((item for item in self._account_rows(backup) if account_name in {
986
+ str(item.get("id") or ""), str(item.get("accountId") or ""),
987
+ str(item.get("name") or ""), str(item.get("characterName") or ""),
988
+ }), None)
989
+ if account is None:
990
+ raise ValidationError("account_name is not present in selected backup", path="$.account_name")
991
+ roles = [str(item) for item in account.get("roles") or [] if str(item)]
992
+ role_name = str(merged.get("role_name") or "")
993
+ if roles and not role_name:
994
+ raise ValidationError("role_name is required for selected account", path="$.role_name")
995
+ if role_name and role_name not in roles:
996
+ raise ValidationError("role_name is not present in selected account", path="$.role_name")
997
+
998
+ # Main ModUs client: configuration shares and string articles.
999
+ def cloud_backups(self, *, server_type: Optional[int] = None) -> Any:
1000
+ return _safe(_unwrap(self._request("GET", "system/user/backup/list", headers={"X-Server-Type": str(self._build(server_type))})))
1001
+
1002
+ def cloud_backup_detail(self, backup_id: int, *, server_type: Optional[int] = None) -> Any:
1003
+ return _safe(_unwrap(self._request("GET", "system/user/backup/detail/%s" % _positive_id(backup_id, field="backup_id"), headers={"X-Server-Type": str(self._build(server_type))})))
1004
+
1005
+ def cloud_backup_update(self, doc: Mapping[str, Any], *, server_type: Optional[int] = None) -> Any:
1006
+ body = {"id": _positive_id(doc["backup_id"], field="backup_id"), "backupName": doc["backup_name"]}
1007
+ return _safe(_unwrap(self._request("POST", "system/user/backup/update", body, headers={"X-Server-Type": str(self._build(server_type))})))
1008
+
1009
+ def cloud_backup_delete(self, backup_id: int, *, server_type: Optional[int] = None) -> Any:
1010
+ return _safe(_unwrap(self._request("DELETE", "system/user/backup/delete/%s" % _positive_id(backup_id, field="backup_id"), headers={"X-Server-Type": str(self._build(server_type))})))
1011
+
1012
+ def image_upload(self, file_path: str) -> Dict[str, Any]:
1013
+ """Upload an image through the exact ModUs main-client multipart API."""
1014
+ path = Path(file_path)
1015
+ if not path.is_file():
1016
+ raise ValidationError("image file does not exist", path="$.file")
1017
+ authorization = self.token if self.authorization_scheme == "" else self.authorization_scheme + self.token
1018
+ headers = {"Accept": "application/json", "Authorization": authorization}
1019
+ if self.device_id:
1020
+ headers["X-Device-Id"] = self.device_id
1021
+ payload = multipart_request(
1022
+ self._url("game/data/file/upload/file/image"),
1023
+ str(path),
1024
+ file_field="file",
1025
+ headers=headers,
1026
+ timeout=max(self.timeout, 600),
1027
+ )
1028
+ if isinstance(payload, Mapping):
1029
+ business_code = _business_code(payload)
1030
+ if payload.get("success") is False or (business_code is not None and business_code != 200):
1031
+ raise FuploadError(
1032
+ str(payload.get("msg") or payload.get("message") or "ModUs image upload was rejected"),
1033
+ endpoint=self._url("game/data/file/upload/file/image"),
1034
+ business_code=business_code,
1035
+ kind="platform_error",
1036
+ stage="media_upload",
1037
+ )
1038
+ record = _image_upload_record(payload)
1039
+ content = path.read_bytes()
1040
+ return {
1041
+ **record,
1042
+ "bytes": len(content),
1043
+ "sha256": hashlib.sha256(content).hexdigest(),
1044
+ }
1045
+
1046
+ @staticmethod
1047
+ def _share_wire(doc: Mapping[str, Any]) -> Dict[str, Any]:
1048
+ aliases = {"share_id": "id", "addons_id": "addonsId", "backup_id": "backupId", "account_name": "accountName", "content_text": "contentText", "image_url": "imageUrl", "is_paid": "isPaid", "is_public": "isPublic", "share_type": "shareType", "exclude_wtf": "excludeWtf", "role_name": "roleName", "required_tier_id": "requiredTierId", "sub_type": "subType", "synchronization_type": "synchronizationType"}
1049
+ allowed = {"id", "addonsId", "backupId", "accountName", "content", "contentText", "imageUrl", "isPaid", "isPublic", "price", "shareType", "tags", "title", "excludeWtf", "roleName", "requiredTierId", "subType", "platform", "synchronizationType"}
1050
+ result = {}
1051
+ for key, value in doc.items():
1052
+ wire = aliases.get(key, key)
1053
+ if wire in allowed and value is not None:
1054
+ result[wire] = str(value) if wire == "addonsId" else value
1055
+ result.setdefault("platform", 1)
1056
+ exclude = result.get("excludeWtf", 0)
1057
+ result["excludeWtf"] = 1 if str(exclude).strip().lower() in {"1", "true"} else 0
1058
+ return result
1059
+
1060
+ def share_list(self, body: Optional[Mapping[str, Any]] = None, *, server_type: Optional[int] = None) -> Any:
1061
+ source = body or {}
1062
+ selected_build = self._build(source.get("server", server_type))
1063
+ mine = source.get("mine")
1064
+ share_type = source.get("share_type")
1065
+ wire = {
1066
+ "pageNum": int(source.get("page_num") or 1),
1067
+ "pageSize": int(source.get("page_size") or 20),
1068
+ "server": selected_build,
1069
+ "mine": False if mine is None else bool(mine),
1070
+ "shareType": 0 if share_type is None else share_type,
1071
+ }
1072
+ platform = source.get("platform")
1073
+ wire["platform"] = int(platform) if isinstance(platform, (int, float)) and not isinstance(platform, bool) else 0
1074
+ for key in ("keyword", "status", "tags", "order_by", "is_public", "is_paid"):
1075
+ if key in source and source[key] is not None:
1076
+ wire[{"order_by": "orderBy", "is_public": "isPublic", "is_paid": "isPaid"}.get(key, key)] = (
1077
+ _tag_filter(source[key]) if key == "tags" else source[key]
1078
+ )
1079
+ return _safe(_unwrap(self._request("POST", "system/user/share/list", wire, headers={"X-Server-Type": str(selected_build)})))
1080
+
1081
+ def share_detail(self, share_ids: Any, *, server_type: Optional[int] = None) -> Any:
1082
+ ids = share_ids if isinstance(share_ids, list) else [share_ids]
1083
+ return _safe(_unwrap(self._request("POST", "system/user/share/detail", {"shareIds": [_resource_id(x, field="share_id") for x in ids]}, headers={"X-Server-Type": str(self._build(server_type))})))
1084
+
1085
+ def share_create(self, doc: Mapping[str, Any], *, server_type: Optional[int] = None) -> Any:
1086
+ return _safe(_unwrap(self._request("POST", "system/user/share/create", self._share_wire(doc), headers={"X-Server-Type": str(self._build(server_type))})))
1087
+
1088
+ def share_update(self, doc: Mapping[str, Any], *, server_type: Optional[int] = None) -> Any:
1089
+ return _safe(_unwrap(self._request("PUT", "system/user/share/update", self._share_wire(doc), headers={"X-Server-Type": str(self._build(server_type))})))
1090
+
1091
+ def share_delete(self, share_id: Any, *, server_type: Optional[int] = None) -> Any:
1092
+ return _safe(_unwrap(self._request("DELETE", "system/user/share/delete/%s" % urllib.parse.quote(_resource_id(share_id, field="share_id"), safe=""), headers={"X-Server-Type": str(self._build(server_type))})))
1093
+
1094
+ @staticmethod
1095
+ def _import_wire(doc: Mapping[str, Any]) -> Dict[str, Any]:
1096
+ aliases = {"import_id": "id", "code_text": "codeText", "addons_id": "addonsId", "content_text": "contentText", "file_path": "filePath", "image_url": "imageUrl", "is_paid": "isPaid", "is_public": "isPublic", "share_type": "shareType", "support_addon": "supportAddon", "required_tier_id": "requiredTierId", "sub_type": "subType", "synchronization_type": "synchronizationType"}
1097
+ allowed = {"id", "codeText", "content", "addonsId", "contentText", "filePath", "imageUrl", "isPaid", "isPublic", "price", "shareType", "supportAddon", "tags", "title", "version", "requiredTierId", "subType", "platform", "synchronizationType"}
1098
+ result = {aliases.get(key, key): value for key, value in doc.items() if aliases.get(key, key) in allowed and value is not None}
1099
+ if "addonsId" in result:
1100
+ result["addonsId"] = str(result["addonsId"])
1101
+ result.setdefault("platform", 1)
1102
+ result.setdefault("synchronizationType", 3 if result["platform"] == 3 else 1)
1103
+ return result
1104
+
1105
+ def import_list(self, body: Optional[Mapping[str, Any]] = None, *, server_type: Optional[int] = None) -> Any:
1106
+ source = body or {}
1107
+ selected_build = self._build(source.get("server", server_type))
1108
+ mine = source.get("mine")
1109
+ status = source.get("status")
1110
+ wire = {
1111
+ "pageNum": int(source.get("page_num") or 1),
1112
+ "pageSize": int(source.get("page_size") or 10),
1113
+ "server": selected_build,
1114
+ "mine": False if mine is None else bool(mine),
1115
+ "status": 1 if status is None else status,
1116
+ }
1117
+ platform = source.get("platform")
1118
+ wire["platform"] = int(platform) if isinstance(platform, (int, float)) and not isinstance(platform, bool) else 0
1119
+ for key in ("keyword", "support_addon", "tags", "is_paid", "order_by"):
1120
+ if key in source and source[key] is not None:
1121
+ wire[{"support_addon": "supportAddon", "is_paid": "isPaid", "order_by": "orderBy"}.get(key, key)] = (
1122
+ _tag_filter(source[key]) if key == "tags" else source[key]
1123
+ )
1124
+ return _safe(_unwrap(self._request("POST", "system/user/import/list", wire, headers={"X-Server-Type": str(selected_build)})))
1125
+
1126
+ def import_detail(self, import_ids: Any, *, server_type: Optional[int] = None) -> Any:
1127
+ ids = import_ids if isinstance(import_ids, list) else [import_ids]
1128
+ return _safe(_unwrap(self._request("POST", "system/user/import/detail", {"importIds": [_resource_id(x, field="import_id") for x in ids]}, headers={"X-Server-Type": str(self._build(server_type))})))
1129
+
1130
+ def import_create(self, doc: Mapping[str, Any], *, server_type: Optional[int] = None) -> Any:
1131
+ return _safe(_unwrap(self._request("POST", "system/user/import/create", self._import_wire(doc), headers={"X-Server-Type": str(self._build(server_type))})))
1132
+
1133
+ def import_update(self, doc: Mapping[str, Any], *, server_type: Optional[int] = None) -> Any:
1134
+ return _safe(_unwrap(self._request("POST", "system/user/import/update", self._import_wire(doc), headers={"X-Server-Type": str(self._build(server_type))})))
1135
+
1136
+ def import_delete(self, import_id: Any, *, server_type: Optional[int] = None) -> Any:
1137
+ return _safe(_unwrap(self._request("DELETE", "system/user/import/delete/%s" % urllib.parse.quote(_resource_id(import_id, field="import_id"), safe=""), headers={"X-Server-Type": str(self._build(server_type))})))
1138
+
1139
+ def import_version_publish(self, doc: Mapping[str, Any], *, server_type: Optional[int] = None) -> Any:
1140
+ body = {
1141
+ "importId": _resource_id(doc["import_id"], field="import_id"),
1142
+ "version": str(doc["version"]).strip(),
1143
+ "codeText": str(doc["code_text"]).strip(),
1144
+ }
1145
+ changelog = str(doc.get("changelog") or "").strip()
1146
+ if changelog:
1147
+ body["changelog"] = changelog
1148
+ return _safe(_unwrap(self._request("POST", "system/user/import/version/publish", body, headers={"X-Server-Type": str(self._build(server_type))})))
1149
+
1150
+ def import_version_delete(self, version_id: Any, *, server_type: Optional[int] = None) -> Any:
1151
+ return _safe(_unwrap(self._request("DELETE", "system/user/import/version/delete?versionId=%s" % urllib.parse.quote(str(version_id), safe=""), headers={"X-Server-Type": str(self._build(server_type))})))
1152
+
621
1153
  def release_signature(self, project_id: int, file_id: int) -> str:
622
1154
  result = _unwrap(self._request("GET", "game/data/author/project/file/upload/signature/%s/%s" % (_positive_id(project_id, field="project_id"), _positive_id(file_id, field="file_id"))))
623
1155
  url = result.get("signedUrl") if isinstance(result, Mapping) else result
@@ -819,15 +1351,23 @@ class ModUs:
819
1351
 
820
1352
  def execute_read(self, resource: str, action: str, args: Any = None) -> Any:
821
1353
  if resource == "session" and action == "doctor": return self.doctor()
1354
+ if resource == "options" and action == "builds": return self.builds()
1355
+ if resource == "builds" and action == "list": return self.builds()
822
1356
  doc = vars(args) if args is not None and hasattr(args, "__dict__") else (args or {})
823
1357
  if resource == "account" and action == "info": return self.user_info()
824
1358
  if resource == "account" and action == "subscription-count": return self.active_subscription_count()
825
1359
  if resource == "account" and action == "statistics": return self.project_statistics()
826
- if resource == "addon" and action == "info": return self.addon_info(doc.get("directories", []), server_type=doc.get("server_type", 1))
827
- if resource == "addon" and action == "project-info": return self.addon_project_info(doc.get("project_ids", []), server_type=doc.get("server_type", 1))
828
- if resource == "addon" and action == "history": return self.addon_history(doc["project_id"], page_num=doc.get("page_num", 1), page_size=doc.get("page_size", 5), server_type=doc.get("server_type", 1))
1360
+ if resource == "addon" and action == "info": return self.addon_info(doc.get("directories", []), server_type=doc.get("server_type"))
1361
+ if resource == "addon" and action == "project-info": return self.addon_project_info(doc.get("project_ids", []), server_type=doc.get("server_type"))
1362
+ if resource == "addon" and action == "history": return self.addon_history(doc["project_id"], page_num=doc.get("page_num", 1), page_size=doc.get("page_size", 5), server_type=doc.get("server_type"))
829
1363
  if resource == "project" and action == "dependencies": return self.project_dependencies(doc.get("query") or doc.get("project_ids") or doc.get("project_id"))
830
1364
  if resource == "options": return self.options(action, keys=doc.get("keys"))
1365
+ if resource == "config" and action == "backups": return self.cloud_backups(server_type=doc.get("server_type"))
1366
+ if resource == "config" and action == "backup-get": return self.cloud_backup_detail(doc["backup_id"], server_type=doc.get("server_type"))
1367
+ if resource == "config" and action == "list": return self.share_list(doc, server_type=doc.get("server_type"))
1368
+ if resource == "config" and action == "get": return self.share_detail(doc["share_id"], server_type=doc.get("server_type"))
1369
+ if resource == "wa" and action == "list": return self.import_list(doc, server_type=doc.get("server_type"))
1370
+ if resource == "wa" and action == "get": return self.import_detail(doc["import_id"], server_type=doc.get("server_type"))
831
1371
  if resource == "project" and action == "list": return self.project_list(**doc)
832
1372
  if resource == "project" and action in ("get", "detail"): return self.project_detail(doc["project_id"])
833
1373
  if resource in ("plugin", "release") and action in ("list", "versions"): return self.release_list(doc["project_id"], page_num=doc.get("page_num", 1), page_size=doc.get("page_size", 50))
@@ -835,6 +1375,19 @@ class ModUs:
835
1375
  raise ValidationError("unsupported ModUs read operation")
836
1376
 
837
1377
  def execute_write(self, resource: str, action: str, doc: Mapping[str, Any]) -> Any:
1378
+ if resource == "media" and action == "upload": return self.image_upload(str(doc["file"]))
1379
+ if resource == "project": self._validate_project_write(action, doc)
1380
+ self._validate_main_write(resource, action, doc)
1381
+ if resource == "config" and action == "create": return self.share_create(doc, server_type=doc.get("server_type"))
1382
+ if resource == "config" and action in ("update", "edit"): return self.share_update(doc, server_type=doc.get("server_type"))
1383
+ if resource == "config" and action == "delete": return self.share_delete(doc["share_id"], server_type=doc.get("server_type"))
1384
+ if resource == "config" and action == "backup-edit": return self.cloud_backup_update(doc, server_type=doc.get("server_type"))
1385
+ if resource == "config" and action == "backup-delete": return self.cloud_backup_delete(doc["backup_id"], server_type=doc.get("server_type"))
1386
+ if resource == "wa" and action == "create": return self.import_create(doc, server_type=doc.get("server_type"))
1387
+ if resource == "wa" and action in ("update", "edit"): return self.import_update(doc, server_type=doc.get("server_type"))
1388
+ if resource == "wa" and action == "delete": return self.import_delete(doc["import_id"], server_type=doc.get("server_type"))
1389
+ if resource == "wa" and action == "version-publish": return self.import_version_publish(doc, server_type=doc.get("server_type"))
1390
+ if resource == "wa" and action == "version-delete": return self.import_version_delete(doc["version_id"], server_type=doc.get("server_type"))
838
1391
  if resource == "project" and action in ("create", "release"): return self.project_create(doc)
839
1392
  if resource == "project" and action in ("update", "edit"): return self.project_update(doc)
840
1393
  if resource == "project" and action == "delete": return self.project_delete(int(doc["project_id"]))