@follenfang/fupload 0.0.0-bootstrap.0 → 0.0.2

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.
Files changed (47) hide show
  1. package/README.md +236 -3
  2. package/fupload/SKILL.md +142 -0
  3. package/fupload/agents/openai.yaml +4 -0
  4. package/fupload/examples/curseforge-plugin-upload.json +21 -0
  5. package/fupload/examples/dd-config-delete.json +5 -0
  6. package/fupload/examples/dd-config-update.json +25 -0
  7. package/fupload/examples/dd-plugin-delete.json +5 -0
  8. package/fupload/examples/dd-plugin-update.json +9 -0
  9. package/fupload/examples/dd-wa-delete.json +5 -0
  10. package/fupload/examples/dd-wa-edit.json +9 -0
  11. package/fupload/examples/newbee-config-delete.json +5 -0
  12. package/fupload/examples/newbee-config-update.json +10 -0
  13. package/fupload/examples/newbee-plugin-create.json +14 -0
  14. package/fupload/examples/newbee-plugin-delete.json +5 -0
  15. package/fupload/examples/newbee-wa-delete.json +5 -0
  16. package/fupload/examples/newbee-wa-update.json +8 -0
  17. package/fupload/references/curseforge.md +233 -0
  18. package/fupload/references/dd.md +105 -0
  19. package/fupload/references/newbee-official-cli.md +288 -0
  20. package/fupload/references/newbee.md +80 -0
  21. package/fupload/references/workflow.md +67 -0
  22. package/fupload/scripts/fupload.py +17 -0
  23. package/fupload/scripts/fupload_cli/__init__.py +3 -0
  24. package/fupload/scripts/fupload_cli/cli.py +281 -0
  25. package/fupload/scripts/fupload_cli/curseforge.py +186 -0
  26. package/fupload/scripts/fupload_cli/dd.py +2406 -0
  27. package/fupload/scripts/fupload_cli/dd_broker.py +634 -0
  28. package/fupload/scripts/fupload_cli/dd_sidecar.py +860 -0
  29. package/fupload/scripts/fupload_cli/errors.py +94 -0
  30. package/fupload/scripts/fupload_cli/io.py +125 -0
  31. package/fupload/scripts/fupload_cli/newbee.py +1412 -0
  32. package/fupload/scripts/fupload_cli/newbee_auth.py +135 -0
  33. package/fupload/scripts/fupload_cli/schema.py +587 -0
  34. package/fupload/scripts/fupload_cli/transport.py +125 -0
  35. package/fupload/scripts/fupload_cli/trust.py +207 -0
  36. package/npm/bin/fupload.mjs +92 -0
  37. package/npm/lib/curseforge-config.mjs +36 -0
  38. package/npm/lib/managed-install.mjs +86 -0
  39. package/npm/lib/options.mjs +38 -0
  40. package/npm/lib/python.mjs +45 -0
  41. package/npm/lib/skill-installer.mjs +228 -0
  42. package/npm/lib/uninstall.mjs +211 -0
  43. package/npm/lib/update.mjs +102 -0
  44. package/npm/lib/versions.mjs +63 -0
  45. package/npm/postinstall.mjs +21 -0
  46. package/npm/skill-manifest.json +179 -0
  47. package/package.json +50 -6
@@ -0,0 +1,1412 @@
1
+ """NewBeeBox Creator provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import http.client
7
+ import json
8
+ import mimetypes
9
+ import os
10
+ import urllib.error
11
+ import urllib.parse
12
+ import urllib.request
13
+ from pathlib import Path
14
+ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
15
+
16
+ from .errors import FuploadError, ValidationError
17
+ from .transport import json_request, multipart_request
18
+ from .newbee_auth import API_BASE, API_ORIGIN, auth_store_dir, creator_headers
19
+ from .trust import NEWBEE_ORIGINS
20
+
21
+
22
+ METADATA_URL = NEWBEE_ORIGINS["metadata"] + "/modconfig.json"
23
+ UPLOAD_SERVER = NEWBEE_ORIGINS["upload"] + "/uploadserver"
24
+ NEXT_API_BASE = NEWBEE_ORIGINS["next"]
25
+ NEXT_ORIGIN = "next"
26
+ METADATA_ORIGIN = "metadata"
27
+ UPLOAD_ORIGIN = "upload"
28
+
29
+
30
+ # Creator Center uses different numeric namespaces for each main record type.
31
+ RELATION_TYPES = {
32
+ "plugin": {"co_authors": 1, "references": 1},
33
+ "config": {"co_authors": 4, "references": 3},
34
+ "wa": {"co_authors": 3, "references": 2},
35
+ }
36
+
37
+
38
+ def _first_object(value: Any) -> Dict[str, Any]:
39
+ if isinstance(value, dict):
40
+ return value
41
+ if isinstance(value, list) and value and isinstance(value[0], dict):
42
+ return value[0]
43
+ return {}
44
+
45
+
46
+ def _decode(value: Any) -> Any:
47
+ if isinstance(value, str):
48
+ try:
49
+ return json.loads(value)
50
+ except ValueError:
51
+ return value
52
+ return value
53
+
54
+
55
+ def _pick(value: Mapping[str, Any], *names: str, default: Any = None) -> Any:
56
+ for name in names:
57
+ if name in value and value[name] is not None:
58
+ return value[name]
59
+ return default
60
+
61
+
62
+ def _list(value: Any) -> List[Any]:
63
+ value = _decode(value)
64
+ return list(value) if isinstance(value, list) else []
65
+
66
+
67
+ def _urls(value: Any) -> List[str]:
68
+ result = []
69
+ for item in _list(value):
70
+ if isinstance(item, str):
71
+ result.append(item)
72
+ elif isinstance(item, dict):
73
+ url = _pick(item, "media_url", "url", "name", default="")
74
+ if url:
75
+ result.append(str(url))
76
+ return result
77
+
78
+
79
+ def _selected_ids(value: Any) -> List[int]:
80
+ """Project selected option rows to their top-level numeric wire IDs."""
81
+ decoded = _decode(value)
82
+ if decoded is None:
83
+ return []
84
+ if not isinstance(decoded, list):
85
+ raise FuploadError("remote selected-ID field was not an array", kind="platform_data_error")
86
+ found: List[int] = []
87
+ for item in decoded:
88
+ candidate = _pick(item, "id", "t_id", "category_id", "t_category_id", "value", default=None) if isinstance(item, dict) else item
89
+ if isinstance(candidate, bool):
90
+ raise FuploadError("remote selected-ID item was not a positive integer", kind="platform_data_error")
91
+ if isinstance(candidate, int) and candidate > 0:
92
+ found.append(candidate)
93
+ elif isinstance(candidate, str) and candidate.isdigit() and int(candidate) > 0:
94
+ found.append(int(candidate))
95
+ else:
96
+ raise FuploadError("remote selected-ID item was not a positive integer", kind="platform_data_error")
97
+ return found
98
+
99
+
100
+ def _selected_names(value: Any) -> List[str]:
101
+ """Project remote option rows to the string values accepted by mutations."""
102
+ decoded = _decode(value)
103
+ if decoded is None:
104
+ return []
105
+ if not isinstance(decoded, list):
106
+ raise FuploadError("remote selected-name field was not an array", kind="platform_data_error")
107
+ found: List[str] = []
108
+ for item in decoded:
109
+ candidate = _pick(item, "name", "display_name", "label", "value", default="") if isinstance(item, dict) else item
110
+ if isinstance(candidate, str) and candidate:
111
+ found.append(candidate)
112
+ else:
113
+ raise FuploadError("remote selected-name item was not a nonempty string", kind="platform_data_error")
114
+ return found
115
+
116
+
117
+ def _version_greater(candidate: Any, current: Any) -> bool:
118
+ left = str(candidate or "").strip()
119
+ right = str(current or "").strip()
120
+ if not left or not right:
121
+ return False
122
+ if left.isdigit() and right.isdigit():
123
+ return int(left) > int(right)
124
+ def parts(value: str) -> List[int]:
125
+ return [int(part) for part in value.split(".") if part.isdigit()]
126
+ left_parts, right_parts = parts(left), parts(right)
127
+ return bool(left_parts and right_parts and left_parts > right_parts)
128
+
129
+
130
+ def _redact_wa(value: Any) -> Any:
131
+ if isinstance(value, dict):
132
+ result = {}
133
+ for key, item in value.items():
134
+ if key.lower() in ("wa_str", "t_wa_str") and isinstance(item, str):
135
+ result[key + "_summary"] = {
136
+ "length": len(item), "sha256": hashlib.sha256(item.encode("utf-8")).hexdigest()
137
+ }
138
+ else:
139
+ result[key] = _redact_wa(item)
140
+ return result
141
+ if isinstance(value, list):
142
+ return [_redact_wa(item) for item in value]
143
+ return value
144
+
145
+
146
+ def _option_rows(value: Any) -> List[Dict[str, Any]]:
147
+ """Read only documented top-level option containers."""
148
+ if isinstance(value, list):
149
+ return [item for item in value if isinstance(item, dict)]
150
+ if not isinstance(value, dict):
151
+ return []
152
+ for key in ("list", "items", "rows", "options"):
153
+ if isinstance(value.get(key), list):
154
+ return [item for item in value[key] if isinstance(item, dict)]
155
+ data = value.get("data")
156
+ if isinstance(data, list):
157
+ return [item for item in data if isinstance(item, dict)]
158
+ return []
159
+
160
+
161
+ def _same_value(expected: Any, actual: Any) -> bool:
162
+ if isinstance(expected, bool):
163
+ return actual is expected
164
+ if isinstance(expected, (int, float)) and not isinstance(expected, bool):
165
+ try:
166
+ return float(actual) == float(expected)
167
+ except (TypeError, ValueError):
168
+ return False
169
+ if isinstance(expected, list):
170
+ if not isinstance(actual, list):
171
+ return False
172
+ unmatched = list(actual)
173
+ for wanted in expected:
174
+ match = next((index for index, candidate in enumerate(unmatched) if _same_value(wanted, candidate)), None)
175
+ if match is None:
176
+ return False
177
+ unmatched.pop(match)
178
+ return len(unmatched) == 0
179
+ if isinstance(expected, dict):
180
+ if not isinstance(actual, dict):
181
+ return False
182
+ for name, wanted in expected.items():
183
+ actual_name = "updateType" if name == "update_type" and "updateType" in actual else name
184
+ if actual_name not in actual or not _same_value(wanted, actual[actual_name]):
185
+ return False
186
+ return True
187
+ if isinstance(expected, str) and isinstance(actual, str):
188
+ if expected.startswith(("http://", "https://")) or actual.startswith(("http://", "https://")):
189
+ expected_path = urllib.parse.urlsplit(expected).path.lstrip("/")
190
+ actual_path = urllib.parse.urlsplit(actual).path.lstrip("/")
191
+ return bool(expected_path and expected_path == actual_path)
192
+ return actual == expected
193
+
194
+
195
+ def _require_readback(expected: Mapping[str, Any], actual: Mapping[str, Any], endpoint: str) -> None:
196
+ mismatches = []
197
+ for name, wanted in expected.items():
198
+ if name not in actual or not _same_value(wanted, actual[name]):
199
+ mismatches.append(name)
200
+ if mismatches:
201
+ raise FuploadError(
202
+ "write readback did not match field(s): %s" % ", ".join(sorted(mismatches)),
203
+ kind="verification_required", endpoint=endpoint, verification_required=True,
204
+ details={"fields": sorted(mismatches)},
205
+ )
206
+
207
+
208
+ def _paged_items(value: Any) -> Tuple[int, List[Dict[str, Any]], Dict[str, Any]]:
209
+ obj = _first_object(value)
210
+ raw_items = _list(_pick(obj, "list", "items", default=[]))
211
+ return int(_pick(obj, "total", "count", default=len(raw_items)) or len(raw_items)), [x for x in raw_items if isinstance(x, dict)], obj
212
+
213
+
214
+ def _plugin_summary(item: Mapping[str, Any]) -> Dict[str, Any]:
215
+ versions = []
216
+ for version in _list(item.get("game_versions")):
217
+ if isinstance(version, dict):
218
+ versions.append({
219
+ "id": version.get("id"), "name": version.get("name"),
220
+ "support_version": version.get("support_version"), "tag": version.get("tag"),
221
+ })
222
+ return {
223
+ "id": int(_pick(item, "t_id", "id", default=0) or 0),
224
+ "name": str(_pick(item, "t_name", "name", default="")),
225
+ "public": int(_pick(item, "t_share", "share_state", default=0) or 0) == 1,
226
+ "review_status": _pick(item, "t_check", "review_status"),
227
+ "content_format": _pick(item, "t_content_format", "content_format"),
228
+ "content_origin": _pick(item, "t_original", "content_origin"),
229
+ "logo": _pick(item, "t_logo", "logo"),
230
+ "screenshots": _urls(item.get("screenshots")),
231
+ "game_versions": versions,
232
+ "subscribe_plan_level": _pick(item, "t_subscribe_plan_level", "subscribe_plan_level"),
233
+ "link_to_channel": bool(_pick(item, "t_link_to_channel", "link_to_channel", default=False)),
234
+ "updated_at": _pick(item, "t_last_update", "updated_at"),
235
+ }
236
+
237
+
238
+ def _wa_summary(item: Mapping[str, Any]) -> Dict[str, Any]:
239
+ categories = []
240
+ for category in _list(item.get("category_list")):
241
+ if isinstance(category, dict):
242
+ categories.append({"id": category.get("t_id"), "name": category.get("t_show_name") or category.get("t_name")})
243
+ return {
244
+ "id": int(_pick(item, "t_id", "id", default=0) or 0),
245
+ "name": str(_pick(item, "t_name", "name", default="")),
246
+ "version": str(_pick(item, "t_version", "version", default="")),
247
+ "public": int(_pick(item, "t_share_state", "share_state", default=2) or 2) == 1,
248
+ "review_status": _pick(item, "t_check_status", "review_status"),
249
+ "game_version_id": _pick(item, "t_game_version_id", "game_version_id"),
250
+ "thumbnail": _pick(item, "t_thumbnail", "thumbnail"),
251
+ "images": _urls(_pick(item, "t_images", "images", default=[])),
252
+ "categories": categories,
253
+ "attachments": _list(_pick(item, "t_attachments", "attachments", default=[])),
254
+ "content_format": _pick(item, "t_content_format", "content_format"),
255
+ "content_origin": _pick(item, "t_content_origin", "content_origin"),
256
+ "subscribe_plan_level": _pick(item, "t_subscribe_plan_level", "subscribe_plan_level"),
257
+ "price": _pick(item, "price", "t_price"),
258
+ "time_range": _pick(item, "t_time_range", "time_range", default=""),
259
+ "link_to_channel": bool(_pick(item, "t_link_to_channel", "link_to_channel", default=False)),
260
+ "updated_at": _pick(item, "t_update_time", "updated_at"),
261
+ }
262
+
263
+
264
+ class NewBee:
265
+ platform = "newbee"
266
+
267
+ def __init__(self) -> None:
268
+ self.base = API_BASE.rstrip("/")
269
+ self._headers: Optional[Dict[str, str]] = None
270
+
271
+ @property
272
+ def headers(self) -> Dict[str, str]:
273
+ if self._headers is None:
274
+ self._headers = creator_headers()
275
+ return self._headers
276
+
277
+ def post(self, endpoint: str, body: Mapping[str, Any]) -> Any:
278
+ envelope = json_request(
279
+ self.base + endpoint, method="POST", headers=self.headers, body=body,
280
+ trusted_service=API_ORIGIN,
281
+ )
282
+ if not isinstance(envelope, dict) or envelope.get("code") != 1:
283
+ raise FuploadError(
284
+ str((envelope or {}).get("message") or "NewBeeBox request failed"),
285
+ endpoint=endpoint,
286
+ business_code=(envelope or {}).get("code"),
287
+ )
288
+ return envelope.get("data")
289
+
290
+ def post_next(self, endpoint: str, body: Mapping[str, Any]) -> Any:
291
+ envelope = json_request(
292
+ NEXT_API_BASE + endpoint, method="POST", headers=self.headers, body=body,
293
+ trusted_service=NEXT_ORIGIN,
294
+ )
295
+ if not isinstance(envelope, dict) or envelope.get("code") != 1:
296
+ raise FuploadError(
297
+ str((envelope or {}).get("message") or "NewBeeBox next request failed"),
298
+ endpoint=endpoint,
299
+ business_code=(envelope or {}).get("code"),
300
+ )
301
+ return envelope.get("data")
302
+
303
+ def upload(self, endpoint: str, path: str, fields: Optional[Mapping[str, str]] = None) -> Any:
304
+ envelope = multipart_request(
305
+ self.base + endpoint, path, headers=self.headers, fields=fields,
306
+ trusted_service=API_ORIGIN,
307
+ )
308
+ if not isinstance(envelope, dict) or envelope.get("code") != 1:
309
+ raise FuploadError(
310
+ str((envelope or {}).get("message") or "NewBeeBox upload failed"),
311
+ endpoint=endpoint,
312
+ business_code=(envelope or {}).get("code"),
313
+ )
314
+ return envelope.get("data")
315
+
316
+ def metadata(self) -> Dict[str, Any]:
317
+ value = json_request(METADATA_URL, trusted_service=METADATA_ORIGIN)
318
+ if not isinstance(value, dict):
319
+ raise FuploadError("NewBeeBox metadata had an unexpected shape", endpoint=METADATA_URL)
320
+ return value
321
+
322
+ def categories(self) -> Dict[str, Any]:
323
+ items = []
324
+ for item in _list(self.metadata().get("mod_category")):
325
+ if isinstance(item, dict):
326
+ items.append({
327
+ "id": int(item.get("t_id") or 0), "name": str(item.get("t_name") or ""),
328
+ "parent_id": int(item.get("t_parent_category_id") or 0),
329
+ "sort_index": int(item.get("t_show_index") or 0),
330
+ })
331
+ return {"total": len(items), "items": items}
332
+
333
+ def game_versions(self) -> Dict[str, Any]:
334
+ items = []
335
+ for item in _list(self.metadata().get("game_version")):
336
+ if isinstance(item, dict):
337
+ items.append({
338
+ "id": int(item.get("id") or 0), "name": str(item.get("name") or ""),
339
+ "search_enabled": bool(item.get("search_enable")), "versions": _list(item.get("version")),
340
+ })
341
+ return {"total": len(items), "items": items}
342
+
343
+ def list_plugins(self, keyword: str, page: int, size: int) -> Any:
344
+ raw = self.post("/creator/wow/mod/publish_list", {
345
+ "keyword": keyword, "game_version_id": 0, "sort_by": "t_last_update",
346
+ "sort_order": "DESC", "pagenum": page, "pagesize": size,
347
+ })
348
+ total, items, obj = _paged_items(raw)
349
+ return {"total": total, "items": [_plugin_summary(item) for item in items], "page": obj.get("pagenum", page), "page_size": obj.get("pagesize", size)}
350
+
351
+ def get_plugin_raw(self, ident: int) -> Dict[str, Any]:
352
+ return _first_object(self.post("/creator/wow/mod/publish_detail", {"id": ident}))
353
+
354
+ def get_plugin(self, ident: int) -> Dict[str, Any]:
355
+ raw = self.get_plugin_raw(ident)
356
+ summary = _plugin_summary(raw)
357
+ summary.update({
358
+ "intro": str(_pick(raw, "t_description", "intro", default="")),
359
+ "description": str(_pick(raw, "t_description_v2", "description", default="")),
360
+ "category_ids": _list(_pick(raw, "category_ids", "mod_categories", default=[])),
361
+ })
362
+ return summary
363
+
364
+ def plugin_versions(self, ident: int, page: int = 1, size: int = 100) -> Any:
365
+ return self.post("/creator/wow/mod_file/mod_file_list", {
366
+ "mod_id": ident, "game_version_id": 0, "pagenum": page, "pagesize": size,
367
+ })
368
+
369
+ def list_backups_raw(self) -> List[Dict[str, Any]]:
370
+ raw = self.post("/creator/wow/share/list", {})
371
+ result: List[Dict[str, Any]] = []
372
+ def walk(value: Any) -> None:
373
+ if isinstance(value, list):
374
+ for item in value:
375
+ walk(item)
376
+ elif isinstance(value, dict):
377
+ result.append(value)
378
+ walk(raw)
379
+ return result
380
+
381
+ def list_backups(self) -> Dict[str, Any]:
382
+ items = []
383
+ for item in self.list_backups_raw():
384
+ if "t_id" not in item:
385
+ continue
386
+ items.append({
387
+ "cloud_id": int(item.get("t_id") or 0), "name": item.get("t_name"),
388
+ "game_version_id": item.get("t_Versionid"), "created_at": item.get("t_create_time"),
389
+ "known_plugins": len(_list(item.get("t_Known_plug"))),
390
+ "unknown_plugins": len(_list(item.get("t_unKnown_list"))),
391
+ "materials": len(_list(item.get("t_material_list"))),
392
+ "fonts": len(_list(item.get("t_font_list"))),
393
+ })
394
+ return {"total": len(items), "items": items}
395
+
396
+ def get_backup(self, cloud_id: int) -> Dict[str, Any]:
397
+ for item in self.list_backups_raw():
398
+ if int(item.get("t_id") or 0) == cloud_id:
399
+ linked = []
400
+ for mod in _list(item.get("t_Known_plug")):
401
+ if isinstance(mod, dict):
402
+ linked.append({
403
+ "mod_id": int(mod.get("id") or 0), "mod_name": str(mod.get("name") or ""),
404
+ "mod_file_id": mod.get("mod_file_id"), "mod_version": mod.get("mod_version"),
405
+ "display_name": mod.get("display_name"),
406
+ "update_type": int(mod.get("updateType") or 1),
407
+ })
408
+ roles = []
409
+ for account in _list(item.get("wtflist")):
410
+ for server in _list((account or {}).get("server")):
411
+ for role in _list((server or {}).get("roleList")):
412
+ roles.append({
413
+ "account": (account or {}).get("account"), "server": (server or {}).get("serverName"),
414
+ "name": (role or {}).get("name"), "role_id": (role or {}).get("role_id"),
415
+ })
416
+ return {
417
+ "cloud_id": cloud_id, "name": item.get("t_name"), "linked_mods": linked,
418
+ "unknown_plugins": [str(x.get("name") or "") for x in _list(item.get("t_unKnown_list")) if isinstance(x, dict)],
419
+ "materials": [str(x.get("name") or "") for x in _list(item.get("t_material_list")) if isinstance(x, dict)],
420
+ "fonts": [str(x.get("name") or "") if isinstance(x, dict) else str(x) for x in _list(item.get("t_font_list"))],
421
+ "roles": roles,
422
+ }
423
+ raise FuploadError("cloud backup %d was not found" % cloud_id, kind="not_found")
424
+
425
+ @staticmethod
426
+ def _validate_backup_selection(backup: Mapping[str, Any], doc: Mapping[str, Any]) -> None:
427
+ linked = {str(item.get("mod_id")) for item in _list(backup.get("linked_mods")) if isinstance(item, dict)}
428
+ selected = {str(item.get("mod_id")) for item in _list(doc.get("linked_mods")) if isinstance(item, dict)}
429
+ if selected - linked:
430
+ raise ValidationError("linked_mods contains an item absent from the selected cloud backup", path="$.linked_mods")
431
+ for field, source in (("ignored_unknown_mods", "unknown_plugins"), ("ignored_materials", "materials"), ("ignored_fronts", "fonts")):
432
+ available = {str(item) for item in _list(backup.get(source))}
433
+ selected = {str(item) for item in _list(doc.get(field))}
434
+ if selected - available:
435
+ raise ValidationError("%s contains an item absent from the selected cloud backup" % field, path="$.%s" % field)
436
+ roles = {str(item.get("role_id")) for item in _list(backup.get("roles")) if isinstance(item, dict)}
437
+ roleid = str(doc.get("roleid") or "")
438
+ if roleid and roleid not in roles:
439
+ raise ValidationError("roleid is absent from the selected cloud backup", path="$.roleid")
440
+
441
+ @staticmethod
442
+ def _created_id(value: Any, title: str, secondary: Optional[Any] = None) -> int:
443
+ direct = _pick(_first_object(value), "id", "t_id", "mod_id", "wa_id", default=0)
444
+ try:
445
+ if int(direct or 0) > 0:
446
+ return int(direct)
447
+ except (TypeError, ValueError):
448
+ pass
449
+ matches: List[int] = []
450
+ def walk(node: Any) -> None:
451
+ if isinstance(node, dict):
452
+ name = _pick(node, "name", "title", "t_name", "t_title", default=None)
453
+ if name == title:
454
+ candidate = _pick(node, "id", "t_id", "mod_id", "wa_id", default=0)
455
+ try:
456
+ candidate_id = int(candidate or 0)
457
+ except (TypeError, ValueError):
458
+ candidate_id = 0
459
+ if candidate_id > 0 and (secondary is None or str(_pick(node, "cloud_id", "t_cloudblackid", default="")) == str(secondary)):
460
+ matches.append(candidate_id)
461
+ for child in node.values():
462
+ if isinstance(child, (dict, list)):
463
+ walk(child)
464
+ elif isinstance(node, list):
465
+ for child in node:
466
+ walk(child)
467
+ walk(value)
468
+ return matches[0] if len(set(matches)) == 1 else 0
469
+
470
+ def list_configs(self, keyword: str, offset: int, size: int) -> Any:
471
+ return self.post("/creator/wow/share_config/publish_list", {
472
+ "keyword": keyword, "game_version_id": 0, "sort": 3,
473
+ "offset": offset, "pagesize": size,
474
+ })
475
+
476
+ def get_config_raw(self, ident: int) -> Dict[str, Any]:
477
+ return _first_object(self.post("/creator/wow/share_config/details_aps", {"id": ident}))
478
+
479
+ def get_config(self, ident: int) -> Dict[str, Any]:
480
+ detail = self.get_config_raw(ident)
481
+ return {
482
+ "id": int(_pick(detail, "t_id", "id", default=ident) or ident),
483
+ "title": _pick(detail, "t_title", "title", default=""),
484
+ "cloud_id": int(_pick(detail, "t_cloudblackid", "cloud_id", default=0) or 0),
485
+ "public": int(_pick(detail, "t_sharing", "sharing", default=0) or 0) != 0,
486
+ "review_status": _pick(detail, "t_check", "review_status"),
487
+ "content": _pick(detail, "t_content", "content", default=""),
488
+ "content_format": int(_pick(detail, "t_content_format", "content_format", default=0) or 0),
489
+ "intro": _pick(detail, "t_intro", "intro", default=""),
490
+ "picture_urls": _urls(_pick(detail, "pic_url", "picture_urls", "piclist", default=[])),
491
+ "content_origin": int(_pick(detail, "t_content_origin", "content_origin", default=0) or 0),
492
+ "link_to_channel": bool(_pick(detail, "t_link_to_channel", "link_to_channel", default=False)),
493
+ "subscribe_plan_level": int(_pick(detail, "t_subscribe_plan_level", "subscribe_plan_level", default=0) or 0),
494
+ "price": int(_pick(detail, "t_price", "price", default=0) or 0),
495
+ "time_range": _pick(detail, "t_time_range", "time_range", default=""),
496
+ "linked_mods": _list(_pick(detail, "t_linked_mods", "linked_mods", default=[])),
497
+ "ignored_unknown_mods": _list(_pick(detail, "t_ignored_unknown_mods", "ignored_unknown_mods", default=[])),
498
+ "ignored_materials": _list(_pick(detail, "t_ignored_materials", "ignored_materials", default=[])),
499
+ "ignored_fronts": _list(_pick(detail, "t_ignored_fronts", "ignored_fronts", default=[])),
500
+ "roleid": str(_pick(detail, "t_roleid", "roleid", "role_id", default="")),
501
+ }
502
+
503
+ def list_was(self, keyword: str, offset: int, size: int) -> Any:
504
+ raw = self.post("/creator/wow/wa/mtg_uc_publish_list", {
505
+ "keyword": keyword, "game_version_id": 0, "sort": 3,
506
+ "offset": offset, "pagesize": size,
507
+ })
508
+ total, items, obj = _paged_items(raw)
509
+ return {"total": total, "items": [_wa_summary(item) for item in items], "next_offset": obj.get("next_offset"), "offset": offset, "page_size": size}
510
+
511
+ def get_wa_raw(self, ident: int) -> Dict[str, Any]:
512
+ return _first_object(self.post("/creator/wow/wa/detail_aps", {"id": ident}))
513
+
514
+ def get_wa(self, ident: int) -> Dict[str, Any]:
515
+ raw = self.get_wa_raw(ident)
516
+ summary = _wa_summary(raw)
517
+ summary.update({
518
+ "intro": str(_pick(raw, "t_intro", "intro", default="")),
519
+ "description": str(_pick(raw, "t_description", "description", default="")),
520
+ "wa_str_titles": _list(_pick(raw, "t_wa_str_titles", "wa_str_titles", default=[])),
521
+ })
522
+ return _redact_wa(summary)
523
+
524
+ def wa_categories(self, game_version_id: int) -> Any:
525
+ return self.post("/creator/wow/wa/category", {"game_version": game_version_id})
526
+
527
+ def attachment_paths(self) -> Any:
528
+ return self.post("/creator/wow/wa/attachment_install_path_list", {})
529
+
530
+ def content_origins(self) -> Dict[str, Any]:
531
+ rows = _option_rows(self.post("/v3/sys/content_origin_list", {}))
532
+ return {"total": len(rows), "items": [{"label": row.get("label"), "value": row.get("value")} for row in rows if row.get("value") is not None]}
533
+
534
+ def subscribe_plans(self) -> Dict[str, Any]:
535
+ rows = _option_rows(self.post("/creator/author_subscribe/plan_level_preset", {}))
536
+ return {"total": len(rows), "items": [{"label": row.get("label") or row.get("name"), "value": row.get("value")} for row in rows if row.get("value") is not None]}
537
+
538
+ def time_ranges(self) -> Dict[str, Any]:
539
+ rows = _option_rows(self.post_next("/cloudsaveserver/GameCloudSavePublish/GetTimeRangeList", {}))
540
+ return {"total": len(rows), "items": [{"label": row.get("label") or row.get("name"), "value": row.get("value")} for row in rows if row.get("value") is not None]}
541
+
542
+ @staticmethod
543
+ def _option_values(payload: Mapping[str, Any], path: str) -> set[str]:
544
+ values = {str(item["value"]) for item in payload.get("items", []) if isinstance(item, dict) and item.get("value") is not None}
545
+ if not values:
546
+ raise FuploadError("live option response contained no selectable values", kind="platform_data_error", details={"path": path})
547
+ return values
548
+
549
+ def _validate_business_options(self, doc: Mapping[str, Any]) -> None:
550
+ if "content_origin" in doc:
551
+ allowed = self._option_values(self.content_origins(), "$.content_origin")
552
+ if str(doc["content_origin"]) not in allowed:
553
+ raise ValidationError("content_origin is unavailable", path="$.content_origin")
554
+ if int(doc.get("subscribe_plan_level") or 0):
555
+ allowed = self._option_values(self.subscribe_plans(), "$.subscribe_plan_level")
556
+ if str(doc["subscribe_plan_level"]) not in allowed:
557
+ raise ValidationError("subscribe_plan_level is unavailable", path="$.subscribe_plan_level")
558
+ if doc.get("time_range"):
559
+ allowed = self._option_values(self.time_ranges(), "$.time_range")
560
+ if str(doc["time_range"]) not in allowed:
561
+ raise ValidationError("time_range is unavailable", path="$.time_range")
562
+
563
+ def _validate_changed_business_options(self, form: Mapping[str, Any], doc: Mapping[str, Any]) -> None:
564
+ fields = ("content_origin", "subscribe_plan_level", "time_range")
565
+ self._validate_business_options({name: form[name] for name in fields if name in doc})
566
+
567
+ @staticmethod
568
+ def _normalize_commercial(form: Dict[str, Any], public: bool) -> None:
569
+ """Apply the Creator Center's submitted, not intermediate, payment state."""
570
+ subscription = int(form.get("subscribe_plan_level") or 0)
571
+ if subscription < 0:
572
+ raise ValidationError("subscribe_plan_level must not be negative", path="$.subscribe_plan_level")
573
+ form["subscribe_plan_level"] = subscription
574
+ if "price" in form:
575
+ price = int(form.get("price") or 0)
576
+ if price < 0:
577
+ raise ValidationError("price must not be negative", path="$.price")
578
+ form["price"] = price
579
+ # A one-time duration has no wire meaning without a one-time price.
580
+ if price == 0:
581
+ form["time_range"] = ""
582
+ if not public:
583
+ form["link_to_channel"] = False
584
+
585
+ @staticmethod
586
+ def _relation_rows(value: Any, name: str) -> Optional[List[Dict[str, Any]]]:
587
+ if isinstance(value, list):
588
+ return [item for item in value if isinstance(item, dict)]
589
+ if not isinstance(value, dict):
590
+ return None
591
+ for key in (name, "list", "items", "data"):
592
+ candidate = value.get(key)
593
+ if isinstance(candidate, list):
594
+ return [item for item in candidate if isinstance(item, dict)]
595
+ return None
596
+
597
+ @staticmethod
598
+ def _require_relation_readback(name: str, expected: Sequence[Mapping[str, Any]], actual: Any, endpoint: str) -> None:
599
+ rows = NewBee._relation_rows(actual, name)
600
+ if rows is None:
601
+ raise FuploadError(
602
+ "relationship write succeeded but its readback had an unexpected shape",
603
+ kind="verification_required", endpoint=endpoint, verification_required=True,
604
+ )
605
+ if name == "co_authors":
606
+ wanted = {(int(item["user_id"]), float(item["share_percent"])) for item in expected}
607
+ observed = {
608
+ (int(_pick(item, "user_id", "t_user_id", "id", default=0) or 0),
609
+ float(_pick(item, "share_percent", "t_share_percent", "ratio", default=-1) or -1))
610
+ for item in rows
611
+ }
612
+ else:
613
+ wanted = {(int(item["type"]), int(item["id"])) for item in expected}
614
+ observed = {
615
+ (int(_pick(item, "type", "content_type", "t_type", default=0) or 0),
616
+ int(_pick(item, "id", "content_id", "t_id", default=0) or 0))
617
+ for item in rows
618
+ }
619
+ if wanted != observed:
620
+ raise FuploadError(
621
+ "relationship write readback did not match the complete replacement",
622
+ kind="verification_required", endpoint=endpoint, verification_required=True,
623
+ )
624
+
625
+ def _replace_relationships(self, resource: str, ident: int, doc: Mapping[str, Any]) -> Dict[str, Any]:
626
+ types = RELATION_TYPES[resource]
627
+ result: Dict[str, Any] = {}
628
+ if "co_authors" in doc:
629
+ body = {"content_type": types["co_authors"], "content_id": ident, "co_authors": doc["co_authors"]}
630
+ mutation = self.post("/creator/co_author/set", body)
631
+ readback = self.post("/creator/co_author/list", {"content_type": types["co_authors"], "content_id": ident})
632
+ self._require_relation_readback("co_authors", doc["co_authors"], readback, "/creator/co_author/list")
633
+ result["co_authors"] = {"result": mutation, "readback": readback}
634
+ if "references" in doc:
635
+ body = {"source_type": types["references"], "source_id": ident, "references": doc["references"]}
636
+ mutation = self.post("/creator/content_reference/set", body)
637
+ readback = self.post("/creator/content_reference/list", {"content_type": types["references"], "content_id": ident})
638
+ self._require_relation_readback("references", doc["references"], readback, "/creator/content_reference/list")
639
+ result["references"] = {"result": mutation, "readback": readback}
640
+ return result
641
+
642
+ @staticmethod
643
+ def _wa_category_values(payload: Any) -> set[int]:
644
+ values: set[int] = set()
645
+ rows = _option_rows(payload)
646
+ def visit(row: Mapping[str, Any]) -> None:
647
+ raw = _pick(row, "id", "t_id", "category_id", "value", default=None)
648
+ if raw is not None and not isinstance(raw, bool):
649
+ try:
650
+ values.add(int(raw))
651
+ except (TypeError, ValueError):
652
+ pass
653
+ for key in ("children", "items", "options"):
654
+ for child in _list(row.get(key)):
655
+ if isinstance(child, dict):
656
+ visit(child)
657
+ for row in rows:
658
+ visit(row)
659
+ return values
660
+
661
+ def _validate_wa_categories(self, game_version_id: int, selected: Sequence[int]) -> None:
662
+ values = self._wa_category_values(self.wa_categories(game_version_id))
663
+ if not values:
664
+ raise FuploadError("live WA category response contained no selectable values", kind="platform_data_error")
665
+ invalid = sorted(set(map(int, selected)) - values)
666
+ if invalid:
667
+ raise ValidationError("category_id_list contains an unavailable live category", path="$.category_id_list")
668
+
669
+ @staticmethod
670
+ def _media_url(value: Any) -> str:
671
+ if isinstance(value, str):
672
+ return value
673
+ obj = _first_object(value)
674
+ result = _pick(obj, "media_url", "url", "name", default="")
675
+ if not result and isinstance(value, list) and value:
676
+ result = value[0] if isinstance(value[0], str) else ""
677
+ if not result:
678
+ raise FuploadError("media upload response did not contain a reusable URL")
679
+ return str(result)
680
+
681
+ def upload_media(self, endpoint: str, path: str) -> str:
682
+ if not os.path.isfile(path):
683
+ raise ValidationError("media file does not exist", path=path)
684
+ return self._media_url(self.upload(endpoint, path))
685
+
686
+ def upload_attachment(self, path: str) -> Dict[str, Any]:
687
+ if not os.path.isfile(path):
688
+ raise ValidationError("attachment file does not exist", path="$.file")
689
+ data = Path(path).read_bytes()
690
+ digest = hashlib.sha256(data).hexdigest()
691
+ upload_code = hashlib.md5(data, usedforsecurity=False).hexdigest()
692
+ name = Path(path).name
693
+ prepare = json_request(
694
+ UPLOAD_SERVER + "/upload/v3/prepare", method="POST",
695
+ trusted_service=UPLOAD_ORIGIN,
696
+ body={
697
+ "code": upload_code, "indexType": 2, "fileName": name,
698
+ "files": [{"fullHash": upload_code, "totalSize": len(data), "chunks": [{"hash": upload_code, "size": len(data)}]}],
699
+ "checksumAlgorithm": 1,
700
+ },
701
+ )
702
+ if not isinstance(prepare, dict) or prepare.get("code") != 1:
703
+ raise FuploadError(str((prepare or {}).get("message") or "attachment upload preparation failed"), endpoint="/upload/v3/prepare")
704
+ prepared = prepare.get("data") or {}
705
+ item = (prepared.get("items") or {}).get(upload_code) or {}
706
+ if not item.get("exists"):
707
+ if not item.get("url") or not item.get("callback"):
708
+ raise FuploadError("attachment upload preparation omitted object credentials", endpoint="/upload/v3/prepare")
709
+ parsed = urllib.parse.urlsplit(item["url"])
710
+ if parsed.scheme.casefold() != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.fragment:
711
+ raise FuploadError("attachment object URL was not HTTPS", kind="trust_boundary")
712
+ connection = http.client.HTTPSConnection(parsed.hostname, parsed.port, timeout=600)
713
+ try:
714
+ target = parsed.path + (("?" + parsed.query) if parsed.query else "")
715
+ connection.request(
716
+ "PUT", target, body=data,
717
+ headers={"x-oss-callback": item["callback"], "Content-Length": str(len(data))},
718
+ )
719
+ response = connection.getresponse()
720
+ response.read()
721
+ if response.status < 200 or response.status >= 300:
722
+ raise FuploadError(
723
+ "attachment object upload returned HTTP %d" % response.status,
724
+ endpoint="object-storage PUT", http_status=response.status,
725
+ )
726
+ except (OSError, http.client.HTTPException) as exc:
727
+ raise FuploadError("attachment upload result is uncertain", verification_required=True) from exc
728
+ finally:
729
+ connection.close()
730
+ index_code = str(prepared.get("id") or "")
731
+ index = json_request(
732
+ UPLOAD_SERVER + "/upload/v3/index/get", method="POST", body={"code": index_code},
733
+ trusted_service=UPLOAD_ORIGIN,
734
+ )
735
+ index_data = (index or {}).get("data") or {}
736
+ if (index or {}).get("code") != 1 or not index_data.get("code"):
737
+ raise FuploadError("attachment upload index could not be read back", endpoint="/upload/v3/index/get")
738
+ return {
739
+ "file_id": 0, "name": str(index_data.get("fileName") or name),
740
+ "value": str(index_data["code"]), "size": int(index_data.get("totalSize") or len(data)),
741
+ "type": mimetypes.guess_type(name)[0] or "application/zip", "timestamp": 0,
742
+ "sha256": digest,
743
+ }
744
+
745
+ def _resolve_media(self, endpoint: str, urls: Sequence[str], files: Sequence[str]) -> List[str]:
746
+ result = list(urls)
747
+ for path in files:
748
+ result.append(self.upload_media(endpoint, path))
749
+ return result
750
+
751
+ def _validate_ids(self, selected: Iterable[int], available: Iterable[int], path: str) -> None:
752
+ allowed = {int(value) for value in available}
753
+ invalid = sorted({int(value) for value in selected} - allowed)
754
+ if invalid:
755
+ raise ValidationError("unknown or unavailable ID(s): %s" % invalid, path=path)
756
+
757
+ def _validate_game_versions(self, selected: Iterable[str], path: str) -> None:
758
+ available = {
759
+ str(version).strip()
760
+ for item in self.game_versions()["items"]
761
+ for version in _list(item.get("versions"))
762
+ if str(version).strip()
763
+ }
764
+ invalid = sorted({str(value).strip() for value in selected} - available)
765
+ if invalid:
766
+ raise ValidationError("unknown or unavailable game version(s): %s" % invalid, path=path)
767
+
768
+ def create_plugin(self, doc: Dict[str, Any]) -> Any:
769
+ self.post("/creator/wow/mod/permission_check", {})
770
+ self._validate_ids(doc["mod_categories"], [x["id"] for x in self.categories()["items"]], "$.mod_categories")
771
+ self._validate_business_options(doc)
772
+ logo = doc.get("logo", "")
773
+ if doc.get("logo_file"):
774
+ logo = self.upload_media("/creator/wow/mod/upload_media", doc["logo_file"])
775
+ screenshots = self._resolve_media("/creator/wow/mod/upload_media", doc.get("screenshots", []), doc.get("screenshot_files", []))
776
+ if not logo:
777
+ raise ValidationError("logo or logo_file is required", path="$.logo")
778
+ payload = {
779
+ "mod_categories": doc["mod_categories"], "content_origin": doc["content_origin"],
780
+ "content_format": doc["content_format"], "name": doc["name"],
781
+ "description": doc["description"], "intro": doc["intro"], "logo": logo,
782
+ "screenshots": screenshots, "share_state": 0,
783
+ "subscribe_plan_level": doc.get("subscribe_plan_level", 0),
784
+ "link_to_channel": False,
785
+ }
786
+ self._normalize_commercial(payload, False)
787
+ result = self.post("/creator/wow/mod/create", payload)
788
+ ident = self._created_id(result, doc["name"])
789
+ if ident <= 0:
790
+ ident = self._created_id(self.list_plugins(doc["name"], 1, 100), doc["name"])
791
+ if ident <= 0:
792
+ ident = self._created_id(self.list_plugins("", 1, 100), doc["name"])
793
+ if ident <= 0:
794
+ raise FuploadError(
795
+ "plugin was submitted but its ID could not be resolved; read the author list before retrying",
796
+ kind="verification_required", verification_required=True,
797
+ )
798
+ readback = self.get_plugin(ident)
799
+ _require_readback({
800
+ "name": doc["name"], "category_ids": doc["mod_categories"],
801
+ "content_origin": doc["content_origin"], "content_format": doc["content_format"],
802
+ "intro": doc["intro"], "description": doc["description"], "logo": logo,
803
+ "screenshots": screenshots, "public": False,
804
+ "subscribe_plan_level": doc.get("subscribe_plan_level", 0), "link_to_channel": False,
805
+ }, readback, "/creator/wow/mod/publish_detail")
806
+ relationships = self._replace_relationships("plugin", ident, doc)
807
+ return {
808
+ "result": result, "id": ident,
809
+ "review_intent": bool(doc.get("public") and doc.get("submit_for_review")),
810
+ "public_after_first_version": bool(doc.get("public")),
811
+ "readback": readback, "relationships": relationships,
812
+ }
813
+
814
+ def _plugin_form(self, ident: int, detail: Dict[str, Any]) -> Dict[str, Any]:
815
+ return {
816
+ "id": ident,
817
+ "mod_categories": _selected_ids(_pick(detail, "category_ids", "mod_categories", default=[])),
818
+ "content_origin": int(_pick(detail, "t_original", "content_origin", default=0) or 0),
819
+ "content_format": int(_pick(detail, "t_content_format", "content_format", default=0) or 0),
820
+ "name": str(_pick(detail, "t_name", "name", default="")),
821
+ "description": str(_pick(detail, "t_description_v2", "description", "t_description", default="")),
822
+ "intro": str(_pick(detail, "t_description", "intro", default="")),
823
+ "logo": str(_pick(detail, "t_logo", "logo", default="")),
824
+ "screenshots": _urls(_pick(detail, "screenshots", default=[])),
825
+ "share_state": int(_pick(detail, "t_share", "share_state", default=0) or 0),
826
+ "subscribe_plan_level": int(_pick(detail, "t_subscribe_plan_level", "subscribe_plan_level", default=0) or 0),
827
+ "link_to_channel": bool(_pick(detail, "t_link_to_channel", "link_to_channel", default=False)),
828
+ }
829
+
830
+ def edit_plugin(self, doc: Dict[str, Any]) -> Any:
831
+ ident = int(doc["id"])
832
+ form = self._plugin_form(ident, self.get_plugin_raw(ident))
833
+ simple = ("name", "mod_categories", "content_origin", "content_format", "intro", "description", "logo", "screenshots", "subscribe_plan_level", "link_to_channel")
834
+ for name in simple:
835
+ if name in doc:
836
+ form[name] = doc[name]
837
+ if doc.get("logo_file"):
838
+ form["logo"] = self.upload_media("/creator/wow/mod/upload_media", doc["logo_file"])
839
+ if doc.get("screenshot_files"):
840
+ form["screenshots"] = self._resolve_media("/creator/wow/mod/upload_media", form["screenshots"], doc["screenshot_files"])
841
+ if "public" in doc:
842
+ if doc["public"] and not _list(_first_object(self.plugin_versions(ident)).get("list")):
843
+ raise ValidationError("a plugin must have a version before it can be submitted for public review", path="$.public")
844
+ form["share_state"] = 1 if doc["public"] else 0
845
+ if "mod_categories" in doc:
846
+ self._validate_ids(form["mod_categories"], [x["id"] for x in self.categories()["items"]], "$.mod_categories")
847
+ self._normalize_commercial(form, form["share_state"] == 1)
848
+ self._validate_changed_business_options(form, doc)
849
+ result = self.post("/creator/wow/mod/edit", form)
850
+ readback = self.get_plugin(ident)
851
+ mapping = {"mod_categories": "category_ids"}
852
+ expected = {mapping.get(name, name): form[name] for name in doc if name != "public" and name in {
853
+ "name", "mod_categories", "content_origin", "content_format", "intro", "description",
854
+ "logo", "screenshots", "subscribe_plan_level", "link_to_channel", "public",
855
+ }}
856
+ if "public" in doc:
857
+ expected["public"] = form["share_state"] == 1
858
+ _require_readback(expected, readback, "/creator/wow/mod/publish_detail")
859
+ relationships = self._replace_relationships("plugin", ident, doc)
860
+ return {"result": result, "review_intent": bool(doc.get("public") and doc.get("submit_for_review")), "readback": readback, "relationships": relationships}
861
+
862
+ def update_plugin(self, doc: Dict[str, Any]) -> Any:
863
+ ident = int(doc["mod_id"])
864
+ current = self.get_plugin_raw(ident)
865
+ versions = _list(_first_object(self.plugin_versions(ident)).get("list"))
866
+ for item in versions:
867
+ remote = str(_pick(item, "t_display_name", "display_name", "version", "t_version", default="")).strip()
868
+ if remote.lower() == str(doc["version"]).strip().lower():
869
+ raise ValidationError("version already exists; overwrite is not allowed", path="$.version")
870
+ self._validate_game_versions(doc["game_version_list"], "$.game_version_list")
871
+ size = Path(doc["file"]).stat().st_size
872
+ if size > 300 * 1024 * 1024 or Path(doc["file"]).suffix.lower() not in (".zip", ".rar", ".7z"):
873
+ raise ValidationError("plugin package must be .zip/.rar/.7z and no larger than 300 MB", path="$.file")
874
+ fields = {
875
+ "mod_id": str(ident), "version": str(doc["version"]),
876
+ "game_version_list": json.dumps(doc["game_version_list"], separators=(",", ":")),
877
+ "link_to_channel": json.dumps(
878
+ bool(doc["link_to_channel"])
879
+ if "link_to_channel" in doc
880
+ else bool(_pick(current, "t_link_to_channel", "link_to_channel", default=False))
881
+ ),
882
+ }
883
+ if "changelog" in doc:
884
+ fields["changelog"] = str(doc["changelog"])
885
+ result = self.upload("/creator/wow/mod_file/upload_mod_file", doc["file"], fields)
886
+ readback = {"plugin": self.get_plugin(ident), "versions": self.plugin_versions(ident)}
887
+ version_items = _list(_first_object(readback["versions"]).get("list"))
888
+ uploaded = next(
889
+ (
890
+ item for item in version_items
891
+ if str(_pick(item, "t_display_name", "display_name", "version", "t_version", default="")).strip().lower()
892
+ == str(doc["version"]).strip().lower()
893
+ ),
894
+ None,
895
+ )
896
+ if uploaded is None:
897
+ raise FuploadError(
898
+ "upload returned success but the new plugin version was not present in readback",
899
+ endpoint="/creator/wow/mod_file/mod_file_list",
900
+ )
901
+ bound_versions = _list(_pick(uploaded, "versions", "game_version_list", "t_game_version_list", default=[]))
902
+ bound_values = {
903
+ str(_pick(item, "version", "build", "support_version", "value", default=""))
904
+ if isinstance(item, dict) else str(item)
905
+ for item in bound_versions
906
+ }
907
+ missing_bindings = sorted(set(map(str, doc["game_version_list"])) - bound_values)
908
+ if not bound_versions or missing_bindings:
909
+ raise FuploadError(
910
+ "upload returned success but requested game-version bindings were not recorded",
911
+ endpoint="/creator/wow/mod_file/mod_file_list",
912
+ kind="verification_required", verification_required=True,
913
+ details={"missing_builds": missing_bindings},
914
+ )
915
+ return {
916
+ "result": result, "sha256": hashlib.sha256(Path(doc["file"]).read_bytes()).hexdigest(),
917
+ "readback": readback,
918
+ }
919
+
920
+ @staticmethod
921
+ def _linked_mods(value: Any, *, strict: bool = True) -> List[Dict[str, Any]]:
922
+ decoded = _decode(value)
923
+ if decoded is None:
924
+ return []
925
+ if not isinstance(decoded, list):
926
+ raise FuploadError("linked_mods response was not an array", kind="platform_data_error")
927
+ result = []
928
+ for item in decoded:
929
+ if not isinstance(item, dict):
930
+ raise ValidationError("each linked_mods item must be an object", path="$.linked_mods")
931
+ allowed = {"mod_id", "mod_name", "mod_file_id", "mod_version", "display_name", "update_type", "updateType"}
932
+ unknown = set(item) - allowed
933
+ if strict and unknown:
934
+ raise ValidationError("unknown linked_mods field: %s" % sorted(unknown)[0], path="$.linked_mods")
935
+ mod_id = item.get("mod_id")
936
+ if isinstance(mod_id, bool) or not isinstance(mod_id, int) or mod_id <= 0:
937
+ raise ValidationError("mod_id must be a positive integer", path="$.linked_mods")
938
+ for name in ("mod_name", "mod_version", "display_name"):
939
+ if item.get(name) is not None and not isinstance(item[name], str):
940
+ raise ValidationError("%s must be a string or null" % name, path="$.linked_mods")
941
+ if item.get("mod_file_id") is not None and (isinstance(item["mod_file_id"], bool) or not isinstance(item["mod_file_id"], int)):
942
+ raise ValidationError("mod_file_id must be an integer or null", path="$.linked_mods")
943
+ update_type = item.get("update_type", item.get("updateType", 1))
944
+ if isinstance(update_type, bool) or not isinstance(update_type, int):
945
+ raise ValidationError("update_type must be an integer", path="$.linked_mods")
946
+ result.append({
947
+ "mod_id": mod_id, "mod_name": item.get("mod_name"),
948
+ "mod_file_id": item.get("mod_file_id"), "mod_version": item.get("mod_version") or None,
949
+ "display_name": item.get("display_name") or None,
950
+ "updateType": update_type,
951
+ })
952
+ return result
953
+
954
+ @classmethod
955
+ def _canonical_linked_mods(cls, backup: Mapping[str, Any], value: Any) -> List[Dict[str, Any]]:
956
+ requested = cls._linked_mods(value)
957
+ live = {
958
+ item["mod_id"]: item
959
+ for item in cls._linked_mods(backup.get("linked_mods", []), strict=False)
960
+ }
961
+ result: List[Dict[str, Any]] = []
962
+ seen: set[int] = set()
963
+ for item in requested:
964
+ mod_id = item["mod_id"]
965
+ if mod_id in seen:
966
+ raise ValidationError("linked_mods contains a duplicate mod_id", path="$.linked_mods")
967
+ seen.add(mod_id)
968
+ if mod_id not in live:
969
+ raise ValidationError("linked_mods contains an item absent from the selected cloud backup", path="$.linked_mods")
970
+ result.append(dict(live[mod_id]))
971
+ return result
972
+
973
+ def _config_form(self, ident: int, detail: Dict[str, Any]) -> Dict[str, Any]:
974
+ return {
975
+ "tid": ident, "cloud_id": int(_pick(detail, "t_cloudblackid", "cloud_id", default=0) or 0),
976
+ "title": str(_pick(detail, "t_title", "title", default="")),
977
+ "content": str(_pick(detail, "t_content", "content", default="")),
978
+ "content_format": int(_pick(detail, "t_content_format", "content_format", default=0) or 0),
979
+ "intro": str(_pick(detail, "t_intro", "intro", default="")),
980
+ "pic_url": _urls(_pick(detail, "pic_url", "picture_urls", "piclist", default=[])),
981
+ "content_origin": int(_pick(detail, "t_content_origin", "content_origin", default=0) or 0),
982
+ "sharing": int(_pick(detail, "t_sharing", "sharing", default=0) or 0),
983
+ "link_to_channel": bool(_pick(detail, "t_link_to_channel", "link_to_channel", default=False)),
984
+ "subscribe_plan_level": int(_pick(detail, "t_subscribe_plan_level", "subscribe_plan_level", default=0) or 0),
985
+ "price": int(_pick(detail, "t_price", "price", default=0) or 0),
986
+ "time_range": str(_pick(detail, "t_time_range", "time_range", default="")),
987
+ "linked_mods": self._linked_mods(_pick(detail, "t_linked_mods", "linked_mods", default=[]), strict=False),
988
+ "ignored_unknown_mods": _selected_names(_pick(detail, "t_ignored_unknown_mods", "ignored_unknown_mods", default=[])),
989
+ "ignored_materials": _selected_names(_pick(detail, "t_ignored_materials", "ignored_materials", default=[])),
990
+ "ignored_fronts": _selected_names(_pick(detail, "t_ignored_fronts", "ignored_fronts", default=[])),
991
+ "roleid": str(_pick(detail, "t_roleid", "roleid", "role_id", default="")),
992
+ }
993
+
994
+ def create_config(self, doc: Dict[str, Any]) -> Any:
995
+ backup = self.get_backup(int(doc["cloud_id"]))
996
+ self._validate_backup_selection(backup, doc)
997
+ linked_mods = self._canonical_linked_mods(backup, doc["linked_mods"])
998
+ pictures = self._resolve_media("/creator/wow/share_config/upload", doc.get("picture_urls", []), doc.get("picture_files", []))
999
+ if not pictures:
1000
+ raise ValidationError("picture_urls or picture_files must contain at least one image", path="$.picture_urls")
1001
+ payload = {
1002
+ "cloud_id": doc["cloud_id"], "title": doc["title"], "content": doc["content"],
1003
+ "content_format": doc["content_format"], "intro": doc.get("intro", ""), "pic_url": pictures,
1004
+ "content_origin": doc["content_origin"], "sharing": 1 if doc["public"] else 0,
1005
+ "link_to_channel": bool(doc.get("link_to_channel", False)) if doc["public"] else False,
1006
+ "subscribe_plan_level": doc.get("subscribe_plan_level", 0), "price": doc.get("price", 0),
1007
+ "time_range": doc.get("time_range", ""), "linked_mods": linked_mods,
1008
+ "ignored_unknown_mods": doc["ignored_unknown_mods"], "ignored_materials": doc["ignored_materials"],
1009
+ "ignored_fronts": doc["ignored_fronts"], "roleid": doc["roleid"],
1010
+ }
1011
+ self._normalize_commercial(payload, bool(doc["public"]))
1012
+ self._validate_business_options(payload)
1013
+ result = self.post("/creator/wow/share_config/release", payload)
1014
+ ident = self._created_id(result, doc["title"], doc["cloud_id"])
1015
+ if ident <= 0:
1016
+ ident = self._created_id(self.list_configs(doc["title"], 0, 100), doc["title"], doc["cloud_id"])
1017
+ if ident <= 0:
1018
+ ident = self._created_id(self.list_configs("", 0, 100), doc["title"], doc["cloud_id"])
1019
+ if ident <= 0:
1020
+ raise FuploadError("configuration was submitted but its ID could not be resolved; read the author list before retrying", kind="verification_required", verification_required=True)
1021
+ readback = self.get_config(ident)
1022
+ expected = {name: payload[name] for name in (
1023
+ "title", "content", "content_format", "intro", "content_origin", "subscribe_plan_level", "price", "time_range",
1024
+ "linked_mods", "ignored_unknown_mods", "ignored_materials", "ignored_fronts", "roleid",
1025
+ )}
1026
+ expected["public"] = bool(payload["sharing"])
1027
+ expected["picture_urls"] = pictures
1028
+ expected["link_to_channel"] = payload["link_to_channel"]
1029
+ _require_readback(expected, readback, "/creator/wow/share_config/details_aps")
1030
+ relationships = self._replace_relationships("config", ident, doc)
1031
+ return {"result": result, "id": ident, "review_intent": bool(doc.get("public") and doc.get("submit_for_review")), "readback": readback, "relationships": relationships}
1032
+
1033
+ def update_config(self, doc: Dict[str, Any], metadata_only: bool) -> Any:
1034
+ ident = int(doc["id"])
1035
+ form = self._config_form(ident, self.get_config_raw(ident))
1036
+ if metadata_only:
1037
+ mapping = {
1038
+ "title": "title", "content": "content", "content_format": "content_format", "intro": "intro",
1039
+ "picture_urls": "pic_url", "content_origin": "content_origin", "link_to_channel": "link_to_channel",
1040
+ "subscribe_plan_level": "subscribe_plan_level", "price": "price", "time_range": "time_range",
1041
+ }
1042
+ for source, target in mapping.items():
1043
+ if source in doc:
1044
+ form[target] = doc[source]
1045
+ if doc.get("picture_files"):
1046
+ form["pic_url"] = self._resolve_media("/creator/wow/share_config/upload", form["pic_url"], doc["picture_files"])
1047
+ if "public" in doc:
1048
+ form["sharing"] = 1 if doc["public"] else 0
1049
+ else:
1050
+ mapping = {
1051
+ "cloud_id": "cloud_id", "linked_mods": "linked_mods", "ignored_unknown_mods": "ignored_unknown_mods",
1052
+ "ignored_materials": "ignored_materials", "ignored_fronts": "ignored_fronts", "roleid": "roleid",
1053
+ }
1054
+ for source, target in mapping.items():
1055
+ if source in doc:
1056
+ form[target] = self._linked_mods(doc[source]) if source == "linked_mods" else doc[source]
1057
+ backup = self.get_backup(int(form["cloud_id"]))
1058
+ form["linked_mods"] = self._canonical_linked_mods(backup, form["linked_mods"])
1059
+ selection = {name: form[name] for name in ("linked_mods", "ignored_unknown_mods", "ignored_materials", "ignored_fronts", "roleid")}
1060
+ self._validate_backup_selection(backup, selection)
1061
+ self._normalize_commercial(form, bool(form["sharing"]))
1062
+ self._validate_changed_business_options(form, doc)
1063
+ result = self.post("/creator/wow/share_config/update", form)
1064
+ readback = self.get_config(ident)
1065
+ expected = {}
1066
+ mapping = {"picture_urls": "pic_url", "public": "sharing"}
1067
+ for name in {
1068
+ "cloud_id", "title", "content", "content_format", "intro", "picture_urls", "content_origin",
1069
+ "link_to_channel", "subscribe_plan_level", "price", "time_range", "linked_mods",
1070
+ "ignored_unknown_mods", "ignored_materials", "ignored_fronts", "roleid",
1071
+ }:
1072
+ if name in doc:
1073
+ expected[name] = form[mapping.get(name, name)]
1074
+ if "public" in doc:
1075
+ expected["public"] = bool(form["sharing"])
1076
+ if doc.get("picture_files"):
1077
+ expected["picture_urls"] = form["pic_url"]
1078
+ _require_readback(expected, readback, "/creator/wow/share_config/details_aps")
1079
+ relationships = self._replace_relationships("config", ident, doc)
1080
+ return {"result": result, "review_intent": bool(doc.get("public") and doc.get("submit_for_review")), "readback": readback, "relationships": relationships}
1081
+
1082
+ def _wa_form(self, ident: int, detail: Dict[str, Any]) -> Dict[str, Any]:
1083
+ return {
1084
+ "id": ident,
1085
+ "game_version_id": int(_pick(detail, "game_version_id", "t_game_version_id", default=0) or 0),
1086
+ "name": str(_pick(detail, "name", "t_name", default="")), "intro": str(_pick(detail, "intro", "t_intro", default="")),
1087
+ "description": str(_pick(detail, "description", "t_description", default="")),
1088
+ "content_format": int(_pick(detail, "content_format", "t_content_format", default=0) or 0),
1089
+ "thumbnail": str(_pick(detail, "thumbnail", "t_thumbnail", default="")),
1090
+ "images": _urls(_pick(detail, "images", "t_images", default=[])),
1091
+ "category_id_list": _selected_ids(
1092
+ _pick(detail, "category_id_list", "category_ids", "category_list", default=[])
1093
+ ),
1094
+ "content_origin": int(_pick(detail, "content_origin", "t_content_origin", default=0) or 0),
1095
+ "subscribe_plan_level": int(_pick(detail, "subscribe_plan_level", "t_subscribe_plan_level", default=0) or 0),
1096
+ "price": int(_pick(detail, "price", "t_price", default=0) or 0),
1097
+ "time_range": str(_pick(detail, "time_range", "t_time_range", default="")),
1098
+ "share_state": int(_pick(detail, "share_state", "t_share_state", default=2) or 2),
1099
+ "link_to_channel": bool(_pick(detail, "link_to_channel", "t_link_to_channel", default=False)),
1100
+ "attachments": self._attachments(_pick(detail, "attachments", default=[]), strict=False), "wa_log": "",
1101
+ }
1102
+
1103
+ @staticmethod
1104
+ def _attachments(value: Any, *, strict: bool) -> List[Dict[str, Any]]:
1105
+ fields = ("name", "install_type", "install_path", "value", "is_compressed", "timestamp")
1106
+ allowed = set(fields)
1107
+ decoded = _decode(value)
1108
+ if decoded is None:
1109
+ return []
1110
+ if not isinstance(decoded, list):
1111
+ raise FuploadError("attachments response was not an array", kind="platform_data_error")
1112
+ result: List[Dict[str, Any]] = []
1113
+ for index, item in enumerate(decoded):
1114
+ path = "$.attachments[%d]" % index
1115
+ if not isinstance(item, dict):
1116
+ raise ValidationError("attachment must be an object", path=path)
1117
+ unknown = set(item) - allowed
1118
+ if strict and unknown:
1119
+ raise ValidationError("unknown attachment field: %s" % sorted(unknown)[0], path=path)
1120
+ if strict:
1121
+ projected = {name: item[name] for name in fields if name in item}
1122
+ else:
1123
+ projected = {
1124
+ "name": _pick(item, "name", "display_name", "filename", default=""),
1125
+ "install_type": item.get("install_type"),
1126
+ "install_path": item.get("install_path"),
1127
+ "value": _pick(item, "value", "url", default=""),
1128
+ "is_compressed": item.get("is_compressed", True),
1129
+ }
1130
+ projected["timestamp"] = item.get("timestamp", 0)
1131
+ result.append(projected)
1132
+ return result
1133
+
1134
+ def create_wa(self, doc: Dict[str, Any]) -> Any:
1135
+ self._validate_ids([doc["game_version_id"]], [x["id"] for x in self.game_versions()["items"]], "$.game_version_id")
1136
+ self._validate_wa_categories(int(doc["game_version_id"]), doc["category_id_list"])
1137
+ attachments = self._attachments(doc.get("attachments", []), strict=True)
1138
+ self._validate_attachments(attachments)
1139
+ thumbnail = doc.get("thumbnail", "")
1140
+ if doc.get("thumbnail_file"):
1141
+ thumbnail = self.upload_media("/creator/wow/wa/upload_media", doc["thumbnail_file"])
1142
+ if not thumbnail:
1143
+ raise ValidationError("thumbnail or thumbnail_file is required", path="$.thumbnail")
1144
+ images = self._resolve_media("/creator/wow/wa/upload_media", doc.get("images", []), doc.get("image_files", []))
1145
+ payload = {
1146
+ "game_version_id": doc["game_version_id"], "name": doc["name"], "intro": doc.get("intro", ""),
1147
+ "description": doc.get("description", ""), "content_format": doc["content_format"],
1148
+ "thumbnail": thumbnail, "images": images, "category_id_list": doc["category_id_list"],
1149
+ "content_origin": doc["content_origin"], "subscribe_plan_level": doc.get("subscribe_plan_level", 0),
1150
+ "price": doc.get("price", 0), "time_range": doc.get("time_range", ""),
1151
+ "share_state": 1 if doc["public"] else 2,
1152
+ "link_to_channel": bool(doc.get("link_to_channel", False)) if doc["public"] else False,
1153
+ "attachments": attachments, "wa_str": doc["wa_str"],
1154
+ "wa_str_titles": doc.get("wa_str_titles", []), "wa_log": doc["wa_log"],
1155
+ "string_mode": doc["string_mode"],
1156
+ }
1157
+ self._normalize_commercial(payload, bool(doc["public"]))
1158
+ self._validate_business_options(payload)
1159
+ result = self.post("/creator/wow/wa/publish", payload)
1160
+ ident = self._created_id(result, doc["name"])
1161
+ if ident <= 0:
1162
+ ident = self._created_id(self.list_was(doc["name"], 0, 100), doc["name"])
1163
+ if ident <= 0:
1164
+ ident = self._created_id(self.list_was("", 0, 100), doc["name"])
1165
+ if ident <= 0:
1166
+ raise FuploadError("WA was submitted but its ID could not be resolved; read the author list before retrying", kind="verification_required", verification_required=True)
1167
+ readback = self.get_wa(ident)
1168
+ expected = {name: payload[name] for name in (
1169
+ "game_version_id", "name", "intro", "description", "content_format", "content_origin",
1170
+ "subscribe_plan_level", "price", "time_range", "link_to_channel", "attachments",
1171
+ ) if name in payload}
1172
+ expected.update({"thumbnail": thumbnail, "images": images, "public": doc["public"], "categories": [{"id": value, "name": None} for value in doc["category_id_list"]]})
1173
+ actual_for_compare = dict(readback)
1174
+ actual_for_compare["categories"] = [{"id": item.get("id"), "name": None} for item in readback.get("categories", [])]
1175
+ _require_readback(expected, actual_for_compare, "/creator/wow/wa/detail_aps")
1176
+ relationships = self._replace_relationships("wa", ident, doc)
1177
+ return {"result": result, "id": ident, "review_intent": bool(doc.get("public") and doc.get("submit_for_review")), "readback": readback, "relationships": relationships}
1178
+
1179
+ def edit_wa(self, doc: Dict[str, Any]) -> Any:
1180
+ ident = int(doc["id"])
1181
+ form = self._wa_form(ident, self.get_wa_raw(ident))
1182
+ mapping = {
1183
+ "game_version_id": "game_version_id", "name": "name", "intro": "intro", "description": "description",
1184
+ "content_format": "content_format", "thumbnail": "thumbnail", "images": "images",
1185
+ "category_id_list": "category_id_list", "content_origin": "content_origin",
1186
+ "subscribe_plan_level": "subscribe_plan_level", "price": "price", "time_range": "time_range",
1187
+ "link_to_channel": "link_to_channel", "attachments": "attachments",
1188
+ }
1189
+ for source, target in mapping.items():
1190
+ if source in doc:
1191
+ form[target] = self._attachments(doc[source], strict=True) if source == "attachments" else doc[source]
1192
+ if doc.get("thumbnail_file"):
1193
+ form["thumbnail"] = self.upload_media("/creator/wow/wa/upload_media", doc["thumbnail_file"])
1194
+ if doc.get("image_files"):
1195
+ form["images"] = self._resolve_media("/creator/wow/wa/upload_media", form["images"], doc["image_files"])
1196
+ if "public" in doc:
1197
+ form["share_state"] = 1 if doc["public"] else 2
1198
+ self._validate_ids([form["game_version_id"]], [x["id"] for x in self.game_versions()["items"]], "$.game_version_id")
1199
+ self._validate_wa_categories(int(form["game_version_id"]), form["category_id_list"])
1200
+ self._normalize_commercial(form, form["share_state"] == 1)
1201
+ self._validate_changed_business_options(form, doc)
1202
+ self._validate_attachments(form.get("attachments", []))
1203
+ result = self.post("/creator/wow/wa/update", form)
1204
+ readback = self.get_wa(ident)
1205
+ mapping = {"category_id_list": "categories"}
1206
+ expected = {mapping.get(name, name): form[name] for name in doc if name in {
1207
+ "game_version_id", "name", "intro", "description", "content_format", "thumbnail", "images",
1208
+ "category_id_list", "content_origin", "subscribe_plan_level", "price", "time_range",
1209
+ "link_to_channel", "attachments",
1210
+ }}
1211
+ if "public" in doc:
1212
+ expected["public"] = form["share_state"] == 1
1213
+ if "categories" in expected:
1214
+ expected["categories"] = [{"id": value, "name": None} for value in expected["categories"]]
1215
+ actual_for_compare = dict(readback)
1216
+ actual_for_compare["categories"] = [{"id": item.get("id"), "name": None} for item in readback.get("categories", [])]
1217
+ _require_readback(expected, actual_for_compare, "/creator/wow/wa/detail_aps")
1218
+ relationships = self._replace_relationships("wa", ident, doc)
1219
+ return {"result": result, "review_intent": bool(doc.get("public") and doc.get("submit_for_review")), "readback": readback, "relationships": relationships}
1220
+
1221
+ def update_wa(self, doc: Dict[str, Any]) -> Any:
1222
+ ident = int(doc["id"])
1223
+ current = self.get_wa_raw(ident)
1224
+ next_value = self.post("/creator/wow/wa/get_next_version", {"id": ident})
1225
+ version = str(doc.get("version") or _pick(_first_object(next_value), "version", "next_version", "t_version", default=next_value if isinstance(next_value, str) else ""))
1226
+ if not version:
1227
+ raise FuploadError("NewBeeBox did not return a next WA version")
1228
+ if not _version_greater(version, _pick(current, "t_version", "version", default="")):
1229
+ raise ValidationError("version must be greater than the current WA version", path="$.version")
1230
+ payload = {
1231
+ "id": ident, "version": version, "wa_str": doc["wa_str"],
1232
+ "wa_str_titles": (
1233
+ doc["wa_str_titles"] if "wa_str_titles" in doc
1234
+ else _selected_names(_pick(current, "t_wa_str_titles", "wa_str_titles", default=[]))
1235
+ ),
1236
+ "wa_log": doc["wa_log"],
1237
+ "link_to_channel": (
1238
+ bool(doc["link_to_channel"])
1239
+ if "link_to_channel" in doc
1240
+ else bool(_pick(current, "t_link_to_channel", "link_to_channel", default=False))
1241
+ ),
1242
+ }
1243
+ result = self.post("/creator/wow/wa/update_wa_str", payload)
1244
+ readback = self.latest_wa(ident)
1245
+ actual_version = str(_pick(_first_object(readback), "version", "t_version", default=""))
1246
+ if actual_version != version:
1247
+ raise FuploadError("WA update version was not present in readback", kind="verification_required", verification_required=True, endpoint="/creator/wow/wa_log/latest_str_info")
1248
+ return {"result": result, "version": version, "readback": readback}
1249
+
1250
+ def latest_wa(self, ident: int) -> Any:
1251
+ return _redact_wa(self.post("/creator/wow/wa_log/latest_str_info", {"wa_id": ident}))
1252
+
1253
+ def _validate_attachments(self, attachments: Any) -> None:
1254
+ allowed = {"name", "install_type", "install_path", "value", "is_compressed", "timestamp"}
1255
+ paths = self.attachment_paths()
1256
+ candidates = []
1257
+ def walk_rows(rows: Sequence[Any]) -> None:
1258
+ for value in rows:
1259
+ if not isinstance(value, dict):
1260
+ continue
1261
+ if "value" in value and ("extract_base_dir" in value or "install_path" in value):
1262
+ candidates.append(value)
1263
+ for key in ("children", "items", "options"):
1264
+ walk_rows(_list(value.get(key)))
1265
+ walk_rows(_option_rows(paths))
1266
+ if attachments and not candidates:
1267
+ raise FuploadError("live attachment path response contained no selectable values", kind="platform_data_error")
1268
+ for index, item in enumerate(attachments):
1269
+ path = "$.attachments[%d]" % index
1270
+ if not isinstance(item, dict):
1271
+ raise ValidationError("attachment must be an object", path=path)
1272
+ unknown = set(item) - allowed
1273
+ if unknown:
1274
+ raise ValidationError("unknown attachment field: %s" % sorted(unknown)[0], path=path)
1275
+ for name in ("name", "install_type", "install_path", "value", "is_compressed"):
1276
+ if name not in item:
1277
+ raise ValidationError("field is required", path=path + "." + name)
1278
+ if not isinstance(item["name"], str) or not item["name"]:
1279
+ raise ValidationError("expected nonempty string", path=path + ".name")
1280
+ if isinstance(item["install_type"], bool) or not isinstance(item["install_type"], int):
1281
+ raise ValidationError("expected integer", path=path + ".install_type")
1282
+ if not isinstance(item["install_path"], str):
1283
+ raise ValidationError("expected string", path=path + ".install_path")
1284
+ if not isinstance(item["value"], str) or not item["value"]:
1285
+ raise ValidationError("expected nonempty string", path=path + ".value")
1286
+ if not isinstance(item["is_compressed"], bool):
1287
+ raise ValidationError("expected boolean", path=path + ".is_compressed")
1288
+ if "timestamp" in item and (isinstance(item["timestamp"], bool) or not isinstance(item["timestamp"], int)):
1289
+ raise ValidationError("expected integer", path=path + ".timestamp")
1290
+ if candidates and not any(
1291
+ str(option.get("value")) == str(item["install_type"])
1292
+ and str(option.get("extract_base_dir") or item["install_path"]) == str(item["install_path"])
1293
+ for option in candidates
1294
+ ):
1295
+ raise ValidationError("install type/path is not in the current platform options", path=path + ".install_path")
1296
+
1297
+ def execute_write(self, resource: str, action: str, doc: Dict[str, Any]) -> Any:
1298
+ if action == "delete" and resource in ("plugin", "config", "wa"):
1299
+ return self.delete(resource, doc)
1300
+ if (resource, action) == ("plugin", "create"): return self.create_plugin(doc)
1301
+ if (resource, action) == ("plugin", "update"): return self.update_plugin(doc)
1302
+ if (resource, action) == ("plugin", "edit"): return self.edit_plugin(doc)
1303
+ if (resource, action) == ("config", "create"): return self.create_config(doc)
1304
+ if (resource, action) == ("config", "update"): return self.update_config(doc, False)
1305
+ if (resource, action) == ("config", "edit"): return self.update_config(doc, True)
1306
+ if (resource, action) == ("wa", "create"): return self.create_wa(doc)
1307
+ if (resource, action) == ("wa", "update"): return self.update_wa(doc)
1308
+ if (resource, action) == ("wa", "edit"): return self.edit_wa(doc)
1309
+ if (resource, action) == ("plugin-changelog", "edit"):
1310
+ result = self.post("/creator/wow/mod_file/edit_changelog", {"file_id": doc["file_id"], "changelog": doc["changelog"] or ""})
1311
+ return {"result": result, "readback": self.post("/creator/wow/mod_file/get_changelog", {"file_id": doc["file_id"]})}
1312
+ if (resource, action) == ("wa-changelog", "edit"):
1313
+ result = self.post("/creator/wow/wa_log/edit", {"wa_log_id": doc["id"], "content": doc["wa_log"] or ""})
1314
+ readback = None
1315
+ if doc.get("wa_id"):
1316
+ readback = self.post("/creator/wow/wa_log/list", {"wa_id": doc["wa_id"], "pagenum": 1, "pagesize": 20})
1317
+ return {"result": result, "readback": readback}
1318
+ if action == "set" and resource.endswith("-co-author"):
1319
+ base = resource[:-len("-co-author")]
1320
+ if base in RELATION_TYPES:
1321
+ relationships = self._replace_relationships(base, int(doc["content_id"]), {"co_authors": doc["co_authors"]})
1322
+ return relationships["co_authors"]
1323
+ if action == "set" and resource.endswith("-reference"):
1324
+ base = resource[:-len("-reference")]
1325
+ if base in RELATION_TYPES:
1326
+ relationships = self._replace_relationships(base, int(doc["source_id"]), {"references": doc["references"]})
1327
+ return relationships["references"]
1328
+ if (resource, action) == ("wa-share-code", "set"):
1329
+ result = self.post_next("/bannerserver/ShareCode/Set", {"gameId": 1, "moduleId": doc["module_id"], "moduleType": 3})
1330
+ return {"result": result, "readback": self.get_wa(int(doc["module_id"]))}
1331
+ if (resource, action) == ("wa-media", "upload"):
1332
+ if doc["kind"] == "attachment":
1333
+ uploaded = self.upload_attachment(doc["file"])
1334
+ attachment = {
1335
+ "name": uploaded["name"], "value": uploaded["value"],
1336
+ "is_compressed": True, "timestamp": uploaded.get("timestamp", 0),
1337
+ }
1338
+ if "install_type" in doc:
1339
+ attachment["install_type"] = doc["install_type"]
1340
+ if "install_path" in doc:
1341
+ attachment["install_path"] = doc["install_path"]
1342
+ return {"upload": uploaded, "attachment": attachment}
1343
+ return {"url": self.upload_media("/creator/wow/wa/upload_media", doc["file"])}
1344
+ raise FuploadError("unsupported NewBeeBox write operation", kind="unsupported_operation")
1345
+
1346
+ def delete(self, resource: str, doc: Mapping[str, Any]) -> Dict[str, Any]:
1347
+ ident = int(doc["id"])
1348
+ getters = {"plugin": self.get_plugin, "config": self.get_config, "wa": self.get_wa}
1349
+ listers = {
1350
+ "plugin": lambda keyword: self.list_plugins(keyword, 1, 100),
1351
+ "config": lambda keyword: self.list_configs(keyword, 0, 100),
1352
+ "wa": lambda keyword: self.list_was(keyword, 0, 100),
1353
+ }
1354
+ endpoints = {
1355
+ "plugin": "/creator/wow/mod/remove",
1356
+ "config": "/creator/wow/share_config/delete",
1357
+ "wa": "/creator/wow/wa/delete",
1358
+ }
1359
+ before = getters[resource](ident)
1360
+ name = str(before.get("name") or before.get("title") or "")
1361
+ response = self.post(endpoints[resource], {"id": ident})
1362
+ listing = listers[resource](name)
1363
+ _total, rows, _obj = _paged_items(listing)
1364
+ if any(int(_pick(row, "id", "t_id", "mod_id", "wa_id", default=0) or 0) == ident for row in rows):
1365
+ raise FuploadError(
1366
+ "delete response succeeded but the target remains in the author list",
1367
+ kind="verification_required", endpoint=endpoints[resource], verification_required=True,
1368
+ )
1369
+ return {"result": response, "deleted": True, "id": ident, "before": before, "readback": {"present": False}}
1370
+
1371
+ def execute_read(self, resource: str, action: str, args: Any) -> Any:
1372
+ if resource == "session" and action == "doctor":
1373
+ self.headers
1374
+ return {
1375
+ "authenticated": True,
1376
+ "source": "NewBeeBox desktop auth-store",
1377
+ "auth_store": str(auth_store_dir()),
1378
+ "auth_store_source": "windows-known-folder",
1379
+ "api_origins": dict(NEWBEE_ORIGINS),
1380
+ "trusted": True,
1381
+ }
1382
+ if resource in RELATION_TYPES:
1383
+ types = RELATION_TYPES[resource]
1384
+ if action == "co-author-search": return self.post("/creator/co_author/search_user", {"keyword": args.keyword})
1385
+ if action == "co-author-list": return self.post("/creator/co_author/list", {"content_type": types["co_authors"], "content_id": args.id})
1386
+ if action == "reference-search": return self.post("/creator/content_reference/search", {"keyword": args.keyword, "limit": 20, "target_types": [types["references"]]})
1387
+ if action == "reference-list": return self.post("/creator/content_reference/list", {"content_type": types["references"], "content_id": args.id})
1388
+ if resource == "plugin":
1389
+ if action == "list": return self.list_plugins(args.keyword, args.page, args.page_size)
1390
+ if action == "get": return self.get_plugin(args.id)
1391
+ if action == "categories": return self.categories()
1392
+ if action == "game-versions": return self.game_versions()
1393
+ if action == "versions": return self.plugin_versions(args.id, args.page, args.page_size)
1394
+ if action == "changelog-list": return self.post("/creator/wow/mod_file/changelog_list", {"mod_id": args.id, "pagenum": args.page, "pagesize": args.page_size})
1395
+ if action == "changelog-get": return self.post("/creator/wow/mod_file/get_changelog", {"file_id": args.id})
1396
+ if resource == "config":
1397
+ if action == "list": return self.list_configs(args.keyword, args.offset, args.page_size)
1398
+ if action == "get": return self.get_config(args.id)
1399
+ if action == "backups": return self.list_backups()
1400
+ if action == "backup-get": return self.get_backup(args.id)
1401
+ if resource == "wa":
1402
+ if action == "list": return self.list_was(args.keyword, args.offset, args.page_size)
1403
+ if action == "get": return self.get_wa(args.id)
1404
+ if action == "categories": return self.wa_categories(args.game_version_id)
1405
+ if action == "attachment-paths": return self.attachment_paths()
1406
+ if action == "changelog-latest": return self.latest_wa(args.id)
1407
+ if action == "changelog-list": return _redact_wa(self.post("/creator/wow/wa_log/list", {"wa_id": args.id, "pagenum": args.page, "pagesize": args.page_size}))
1408
+ if resource == "options":
1409
+ if action == "content-origins": return self.content_origins()
1410
+ if action == "subscribe-plans": return self.subscribe_plans()
1411
+ if action == "time-ranges": return self.time_ranges()
1412
+ raise FuploadError("unsupported NewBeeBox read operation", kind="unsupported_operation")