@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,2406 @@
1
+ """NetEase DD provider using the official native client as a sidecar."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ from decimal import Decimal, InvalidOperation
7
+ import hashlib
8
+ import json
9
+ import os
10
+ import queue
11
+ import subprocess
12
+ import threading
13
+ import time
14
+ from pathlib import Path
15
+ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
16
+ import urllib.parse
17
+
18
+ from .errors import FuploadError, ValidationError
19
+ from .trust import trusted_local_dir, trusted_roaming_dir, verify_dd_executable
20
+
21
+
22
+ EXPECTED_DD_VERSION = os.environ.get("FUPLOAD_DD_EXPECTED_VERSION", "any")
23
+ LIFE_TYPES = [
24
+ {"name": "7 days", "value": "seven_day"},
25
+ {"name": "14 days", "value": "fourteen_day"},
26
+ {"name": "30 days", "value": "thirty_day"},
27
+ {"name": "60 days", "value": "sixty_day"},
28
+ {"name": "90 days", "value": "ninety_day"},
29
+ {"name": "forever", "value": "forever"},
30
+ ]
31
+
32
+ _OMIT_FILE_NAME = object()
33
+ _IMAGE_MIME = {
34
+ ".png": "image/png",
35
+ ".jpg": "image/jpeg",
36
+ ".jpeg": "image/jpeg",
37
+ ".gif": "image/gif",
38
+ }
39
+ def _running_dd_dirs() -> List[Path]:
40
+ if os.name != "nt":
41
+ return []
42
+ import ctypes
43
+ from ctypes import wintypes
44
+
45
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
46
+ kernel32.CreateToolhelp32Snapshot.argtypes = [wintypes.DWORD, wintypes.DWORD]
47
+ kernel32.CreateToolhelp32Snapshot.restype = wintypes.HANDLE
48
+ kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
49
+ kernel32.CloseHandle.restype = wintypes.BOOL
50
+ snapshot = kernel32.CreateToolhelp32Snapshot(0x00000002, 0)
51
+ invalid_handle = wintypes.HANDLE(-1).value
52
+ if snapshot == invalid_handle:
53
+ return []
54
+
55
+ class ProcessEntry(ctypes.Structure):
56
+ _fields_ = [
57
+ ("dwSize", wintypes.DWORD),
58
+ ("cntUsage", wintypes.DWORD),
59
+ ("th32ProcessID", wintypes.DWORD),
60
+ ("th32DefaultHeapID", ctypes.c_size_t),
61
+ ("th32ModuleID", wintypes.DWORD),
62
+ ("cntThreads", wintypes.DWORD),
63
+ ("th32ParentProcessID", wintypes.DWORD),
64
+ ("pcPriClassBase", ctypes.c_long),
65
+ ("dwFlags", wintypes.DWORD),
66
+ ("szExeFile", ctypes.c_wchar * 260),
67
+ ]
68
+
69
+ kernel32.Process32FirstW.argtypes = [wintypes.HANDLE, ctypes.POINTER(ProcessEntry)]
70
+ kernel32.Process32FirstW.restype = wintypes.BOOL
71
+ kernel32.Process32NextW.argtypes = [wintypes.HANDLE, ctypes.POINTER(ProcessEntry)]
72
+ kernel32.Process32NextW.restype = wintypes.BOOL
73
+ kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
74
+ kernel32.OpenProcess.restype = wintypes.HANDLE
75
+ kernel32.QueryFullProcessImageNameW.argtypes = [
76
+ wintypes.HANDLE, wintypes.DWORD,
77
+ wintypes.LPWSTR, ctypes.POINTER(wintypes.DWORD),
78
+ ]
79
+ kernel32.QueryFullProcessImageNameW.restype = wintypes.BOOL
80
+ paths: List[Path] = []
81
+ entry = ProcessEntry()
82
+ entry.dwSize = ctypes.sizeof(entry)
83
+ try:
84
+ more = bool(kernel32.Process32FirstW(snapshot, ctypes.byref(entry)))
85
+ while more:
86
+ if entry.szExeFile.casefold() == "netease_dd.exe":
87
+ process = kernel32.OpenProcess(0x1000, False, entry.th32ProcessID)
88
+ if process:
89
+ try:
90
+ size = wintypes.DWORD(32768)
91
+ buffer = ctypes.create_unicode_buffer(size.value)
92
+ if kernel32.QueryFullProcessImageNameW(process, 0, buffer, ctypes.byref(size)):
93
+ paths.append(Path(buffer.value).parent)
94
+ finally:
95
+ kernel32.CloseHandle(process)
96
+ more = bool(kernel32.Process32NextW(snapshot, ctypes.byref(entry)))
97
+ finally:
98
+ kernel32.CloseHandle(snapshot)
99
+ return paths
100
+
101
+
102
+ def _registry_dd_dirs() -> List[Path]:
103
+ if os.name != "nt":
104
+ return []
105
+ import winreg
106
+
107
+ paths: List[Path] = []
108
+ locations = (
109
+ (winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Uninstall"),
110
+ (winreg.HKEY_LOCAL_MACHINE, r"Software\Microsoft\Windows\CurrentVersion\Uninstall"),
111
+ (winreg.HKEY_LOCAL_MACHINE, r"Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"),
112
+ )
113
+ for hive, key_name in locations:
114
+ try:
115
+ root = winreg.OpenKey(hive, key_name)
116
+ except OSError:
117
+ continue
118
+ with root:
119
+ for index in range(4096):
120
+ try:
121
+ child_name = winreg.EnumKey(root, index)
122
+ except OSError:
123
+ break
124
+ try:
125
+ child = winreg.OpenKey(root, child_name)
126
+ except OSError:
127
+ continue
128
+ with child:
129
+ try:
130
+ display_name = str(winreg.QueryValueEx(child, "DisplayName")[0])
131
+ except OSError:
132
+ display_name = ""
133
+ normalized = display_name.casefold().replace(" ", "")
134
+ if not any(name in normalized for name in ("neteasedd", "网易dd", "ccvoicehub")):
135
+ continue
136
+ for value_name in ("InstallLocation", "DisplayIcon"):
137
+ try:
138
+ raw = str(winreg.QueryValueEx(child, value_name)[0]).strip().strip('"')
139
+ except OSError:
140
+ continue
141
+ raw = raw.rsplit(",", 1)[0].strip().strip('"')
142
+ path = Path(raw)
143
+ paths.append(path.parent if path.suffix.lower() == ".exe" else path)
144
+ return paths
145
+
146
+
147
+ def _user_config_dd_dirs() -> List[Path]:
148
+ paths: List[Path] = []
149
+ roots: List[Path] = []
150
+ for resolver in (trusted_roaming_dir, trusted_local_dir):
151
+ try:
152
+ roots.append(resolver("CCVoiceHub"))
153
+ except FuploadError:
154
+ continue
155
+ for root in roots:
156
+ for name in ("appconfig.json", "localGameConfig.json"):
157
+ source = root / name
158
+ try:
159
+ if source.stat().st_size > 1024 * 1024:
160
+ continue
161
+ value = json.loads(source.read_text(encoding="utf-8-sig"))
162
+ except (OSError, ValueError):
163
+ continue
164
+ pending = [value]
165
+ visited = 0
166
+ while pending and visited < 10000:
167
+ item = pending.pop()
168
+ visited += 1
169
+ if isinstance(item, dict):
170
+ pending.extend(item.values())
171
+ elif isinstance(item, list):
172
+ pending.extend(item)
173
+ elif isinstance(item, str) and ("netease_dd" in item.casefold() or "neteasedd" in item.casefold()):
174
+ path = Path(item.strip().strip('"'))
175
+ paths.append(path.parent if path.suffix.lower() == ".exe" else path)
176
+ return paths
177
+
178
+
179
+ def _discovery_roots() -> List[Path]:
180
+ roots: List[Path] = []
181
+ roots.extend(_running_dd_dirs())
182
+ roots.extend(_registry_dd_dirs())
183
+ roots.extend(_user_config_dd_dirs())
184
+ try:
185
+ roots.append(trusted_local_dir("NetEaseDD"))
186
+ except FuploadError:
187
+ pass
188
+ roots.extend((
189
+ Path(os.environ.get("PROGRAMFILES", "")) / "NetEaseDD",
190
+ Path(os.environ.get("PROGRAMFILES(X86)", "")) / "NetEaseDD",
191
+ Path("C:/NetEase/NetEaseDD"), Path("D:/Software/NetEaseDD"), Path("D:/NetEaseDD"),
192
+ ))
193
+ return roots
194
+
195
+
196
+ def discover_dd_info() -> Tuple[Path, Dict[str, str]]:
197
+ candidates: List[Path] = []
198
+ for root in _discovery_roots():
199
+ if str(root) in (".", "") or not root.exists():
200
+ continue
201
+ candidates.append(root)
202
+ try:
203
+ candidates.extend(path.parent for path in root.glob("*/netease_dd.exe"))
204
+ except OSError:
205
+ pass
206
+ seen = set()
207
+ valid: List[Tuple[Path, Dict[str, str]]] = []
208
+ for candidate in candidates:
209
+ try:
210
+ resolved = candidate.resolve()
211
+ except OSError:
212
+ continue
213
+ if resolved in seen:
214
+ continue
215
+ seen.add(resolved)
216
+ version = resolved.name
217
+ if (resolved / "netease_dd.exe").is_file() and (resolved / "ccvoicehub.res").exists() and (resolved / "ccsub64").is_dir():
218
+ if EXPECTED_DD_VERSION and EXPECTED_DD_VERSION != "any" and version != EXPECTED_DD_VERSION:
219
+ continue
220
+ try:
221
+ signature = verify_dd_executable(resolved / "netease_dd.exe")
222
+ except FuploadError:
223
+ continue
224
+ valid.append((resolved, signature))
225
+ if not valid:
226
+ raise FuploadError(
227
+ "cannot locate a valid, officially signed NetEase DD installation",
228
+ kind="installation_not_found",
229
+ )
230
+ return sorted(valid, key=lambda item: item[0].name)[-1]
231
+
232
+
233
+ def discover_dd() -> Path:
234
+ return discover_dd_info()[0]
235
+
236
+
237
+ def state_dir() -> Path:
238
+ result = trusted_roaming_dir("CCVoiceHub", "Fupload")
239
+ result.mkdir(parents=True, exist_ok=True)
240
+ return result
241
+
242
+
243
+ class Sidecar:
244
+ def __init__(self) -> None:
245
+ self.dd_dir, self.signature = discover_dd_info()
246
+ self.process: Optional[subprocess.Popen[str]] = None
247
+ self.counter = 0
248
+ self.lock_handle = None
249
+ self.responses: queue.Queue[Any] = queue.Queue()
250
+ self.reader_thread: Optional[threading.Thread] = None
251
+
252
+ def __enter__(self) -> "Sidecar":
253
+ self._lock()
254
+ executable = self.dd_dir / "netease_dd.exe"
255
+ script = Path(__file__).with_name("dd_sidecar.py")
256
+ environment = os.environ.copy()
257
+ environment["NETEASE_DD_DIR"] = str(self.dd_dir)
258
+ environment["FUPLOAD_DD_DEVICE_STATE"] = str(state_dir() / "sidecar-device.json")
259
+ self.process = subprocess.Popen(
260
+ [str(executable), str(script)], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
261
+ stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="strict",
262
+ env=environment, cwd=str(self.dd_dir),
263
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
264
+ )
265
+ self.reader_thread = threading.Thread(target=self._read_results, daemon=True)
266
+ self.reader_thread.start()
267
+ try:
268
+ ready = self._next_result(timeout=60)
269
+ except Exception:
270
+ self.close()
271
+ raise
272
+ if not ready.get("ready"):
273
+ self.close()
274
+ raise FuploadError(
275
+ str((ready.get("error") or {}).get("message") or "DD sidecar failed to start"),
276
+ kind="authentication_error",
277
+ stage="session",
278
+ )
279
+ return self
280
+
281
+ def _lock(self) -> None:
282
+ import msvcrt
283
+ path = state_dir() / "sidecar.lock"
284
+ self.lock_handle = open(path, "a+b")
285
+ try:
286
+ self.lock_handle.seek(0)
287
+ msvcrt.locking(self.lock_handle.fileno(), msvcrt.LK_NBLCK, 1)
288
+ except OSError as exc:
289
+ self.lock_handle.close()
290
+ self.lock_handle = None
291
+ raise FuploadError("another Fupload DD sidecar is already running", kind="concurrent_session") from exc
292
+
293
+ def _read_results(self) -> None:
294
+ assert self.process and self.process.stdout
295
+ try:
296
+ for line in self.process.stdout:
297
+ if line.startswith("FUPLOAD_RESULT "):
298
+ try:
299
+ self.responses.put(json.loads(line[len("FUPLOAD_RESULT "):]))
300
+ except ValueError:
301
+ self.responses.put(FuploadError("DD sidecar returned invalid JSON", kind="sidecar_error"))
302
+ except UnicodeDecodeError:
303
+ self.responses.put(FuploadError("DD sidecar returned non-UTF-8 output", kind="sidecar_error"))
304
+ else:
305
+ self.responses.put(FuploadError("DD sidecar exited without a result", kind="sidecar_error"))
306
+
307
+ def _next_result(
308
+ self, *, timeout: float = 180, endpoint: Optional[str] = None,
309
+ stage: Optional[str] = None,
310
+ verification_required: bool = False,
311
+ ) -> Dict[str, Any]:
312
+ try:
313
+ value = self.responses.get(timeout=timeout)
314
+ except queue.Empty as exc:
315
+ raise FuploadError(
316
+ "DD sidecar response timed out",
317
+ kind="timeout",
318
+ stage=stage,
319
+ endpoint=endpoint,
320
+ verification_required=verification_required,
321
+ ) from exc
322
+ if isinstance(value, FuploadError):
323
+ raise FuploadError(
324
+ str(value),
325
+ kind=value.kind,
326
+ stage=stage or value.stage,
327
+ endpoint=endpoint or value.endpoint,
328
+ http_status=value.http_status,
329
+ business_code=value.business_code,
330
+ verification_required=verification_required or value.verification_required,
331
+ details=dict(value.details),
332
+ )
333
+ if not isinstance(value, dict):
334
+ raise FuploadError("DD sidecar returned an invalid result", kind="sidecar_error")
335
+ return value
336
+
337
+ def call(self, action: str, **values: Any) -> Any:
338
+ assert self.process and self.process.stdin
339
+ self.counter += 1
340
+ request = {"id": self.counter, "action": action, **values}
341
+ self.process.stdin.write(json.dumps(request, ensure_ascii=True, separators=(",", ":")) + "\n")
342
+ self.process.stdin.flush()
343
+ method = str(values.get("method") or "").upper()
344
+ endpoint = str(values.get("path") or "") or None
345
+ if action == "cc_get":
346
+ endpoint = urllib.parse.urlsplit(str(values.get("url") or "")).path or None
347
+ request_stage = str(values.get("request_stage") or "")
348
+ if action == "request" and request_stage == "dependency_get":
349
+ expected_stage, uncertain = "dependency_get", False
350
+ elif action == "request" and method == "POST":
351
+ expected_stage, uncertain = "mutation", True
352
+ elif action == "upload":
353
+ expected_stage, uncertain = "object_put", True
354
+ elif action == "parse_wa":
355
+ expected_stage, uncertain = "native_parser", False
356
+ else:
357
+ expected_stage, uncertain = "dependency_get", False
358
+ response = self._next_result(
359
+ endpoint=endpoint,
360
+ stage=expected_stage,
361
+ verification_required=uncertain,
362
+ )
363
+ if response.get("id") != self.counter:
364
+ raise FuploadError("DD sidecar response order was invalid", kind="sidecar_error")
365
+ if not response.get("ok"):
366
+ error = response.get("error") if isinstance(response.get("error"), dict) else {}
367
+ message = str(error.get("message") or "DD operation failed")
368
+ timed_out = "timed out" in message.casefold() or "timeout" in message.casefold()
369
+ stage = str(error.get("stage") or expected_stage)
370
+ if stage == "object_put":
371
+ endpoint = "object-store-put"
372
+ elif stage == "upload_authorize":
373
+ endpoint = "/file/upload"
374
+ raise FuploadError(
375
+ message,
376
+ kind=str(error.get("kind") or ("timeout" if timed_out else "platform_error")),
377
+ stage=stage,
378
+ endpoint=endpoint,
379
+ http_status=error.get("http_status"),
380
+ business_code=error.get("business_code"),
381
+ verification_required=bool(error.get("verification_required")) or (uncertain and timed_out),
382
+ details=error.get("details") if isinstance(error.get("details"), dict) else None,
383
+ )
384
+ return response.get("data")
385
+
386
+ def get(self, path: str, params: Optional[Mapping[str, Any]] = None) -> Any:
387
+ return self._business_response(
388
+ path, self.call("request", method="GET", path=path, payload=dict(params or {})), "dependency_get"
389
+ )
390
+
391
+ def post(self, path: str, body: Mapping[str, Any]) -> Any:
392
+ return self._business_response(
393
+ path, self.call("request", method="POST", path=path, payload=dict(body)), "mutation"
394
+ )
395
+
396
+ def post_read(self, path: str, body: Mapping[str, Any]) -> Any:
397
+ return self._business_response(
398
+ path,
399
+ self.call(
400
+ "request", method="POST", path=path, payload=dict(body),
401
+ request_stage="dependency_get",
402
+ ),
403
+ "dependency_get",
404
+ )
405
+
406
+ @staticmethod
407
+ def _business_response(path: str, payload: Any, stage: str = "mutation") -> Any:
408
+ if isinstance(payload, dict) and "code" in payload and payload.get("code") != 0:
409
+ raise FuploadError(
410
+ str(payload.get("msg") or payload.get("message") or "DD operation failed"),
411
+ kind="platform_error",
412
+ stage=stage,
413
+ endpoint=path,
414
+ business_code=payload.get("code"),
415
+ )
416
+ return payload
417
+
418
+ def upload(
419
+ self,
420
+ file: str,
421
+ business: str,
422
+ *,
423
+ file_name: Any = _OMIT_FILE_NAME,
424
+ media: bool = False,
425
+ max_bytes: Optional[int] = None,
426
+ ) -> str:
427
+ suffix = Path(file).suffix.casefold()
428
+ size = Path(file).stat().st_size
429
+ if max_bytes is not None and size > max_bytes:
430
+ raise ValidationError("file exceeds the platform limit", path="$.file")
431
+ if media:
432
+ mime = _IMAGE_MIME.get(suffix)
433
+ if not mime:
434
+ raise ValidationError("media file extension must be .png, .jpg, .jpeg, or .gif", path="$.file")
435
+ else:
436
+ if suffix != ".zip":
437
+ raise ValidationError("resource file extension must be .zip", path="$.file")
438
+ mime = "application/x-zip-compressed"
439
+ file_type = "a19-ui-media" if media else "a19-ui-res"
440
+ upload_business = "img" if media else business
441
+ meta = {"file_type": file_type, "business_id": upload_business, "mime_type": mime}
442
+ if file_name is not _OMIT_FILE_NAME:
443
+ meta["file_name"] = str(file_name)
444
+ result = self.call("upload", file=file, meta=meta)
445
+ return str(result["d_url"])
446
+
447
+ def cc_get(self, url: str) -> Any:
448
+ return self.call("cc_get", url=url)
449
+
450
+ def close(self) -> None:
451
+ if self.process:
452
+ try:
453
+ if self.process.stdin:
454
+ self.process.stdin.close()
455
+ self.process.wait(timeout=15)
456
+ except Exception:
457
+ self.process.kill()
458
+ self.process = None
459
+ if self.lock_handle:
460
+ try:
461
+ import msvcrt
462
+ self.lock_handle.seek(0)
463
+ msvcrt.locking(self.lock_handle.fileno(), msvcrt.LK_UNLCK, 1)
464
+ except OSError:
465
+ pass
466
+ self.lock_handle.close()
467
+ self.lock_handle = None
468
+
469
+ def __exit__(self, *_args: Any) -> None:
470
+ self.close()
471
+
472
+
473
+ def result(payload: Any) -> Any:
474
+ return payload.get("result") if isinstance(payload, dict) else None
475
+
476
+
477
+ def items(payload: Any) -> List[Dict[str, Any]]:
478
+ value = result(payload)
479
+ if isinstance(value, list):
480
+ return [x for x in value if isinstance(x, dict)]
481
+ if isinstance(value, dict):
482
+ for key in ("data_list", "list", "rows", "items", "wa_list", "shares"):
483
+ if isinstance(value.get(key), list):
484
+ return [x for x in value[key] if isinstance(x, dict)]
485
+ return []
486
+
487
+
488
+ def _option_items(payload: Any) -> List[Any]:
489
+ """Read only documented result-level option containers."""
490
+ if isinstance(payload, list):
491
+ return list(payload)
492
+ raw = result(payload)
493
+ if isinstance(raw, list):
494
+ return list(raw)
495
+ if isinstance(raw, dict):
496
+ for key in ("data_list", "list", "rows", "items", "categories", "versions"):
497
+ if isinstance(raw.get(key), list):
498
+ return list(raw[key])
499
+ return []
500
+
501
+
502
+ def _option_values(payload: Any, keys: Sequence[str]) -> set[str]:
503
+ values: set[str] = set()
504
+ for item in _option_items(payload):
505
+ if not isinstance(item, dict):
506
+ if item not in (None, ""):
507
+ values.add(str(item))
508
+ continue
509
+ for key in keys:
510
+ if item.get(key) not in (None, ""):
511
+ values.add(str(item[key]))
512
+ return values
513
+
514
+
515
+ def _category_id(item: Mapping[str, Any]) -> Optional[str]:
516
+ for key in ("id", "c_id", "category_id", "value"):
517
+ if item.get(key) not in (None, ""):
518
+ return str(item[key])
519
+ return None
520
+
521
+
522
+ def _addon_category_tree(payload: Any) -> Dict[str, set[str]]:
523
+ tree: Dict[str, set[str]] = {}
524
+ for item in _option_items(payload):
525
+ if not isinstance(item, dict):
526
+ continue
527
+ parent = _category_id(item)
528
+ if parent is None:
529
+ continue
530
+ children = set()
531
+ for child in item.get("children") or []:
532
+ if isinstance(child, dict):
533
+ child_id = _category_id(child)
534
+ if child_id is not None:
535
+ children.add(child_id)
536
+ tree[parent] = children
537
+ return tree
538
+
539
+
540
+ def _wa_category_values(payload: Any) -> set[str]:
541
+ values: set[str] = set()
542
+ def visit(rows: Sequence[Any]) -> None:
543
+ for item in rows:
544
+ if not isinstance(item, dict):
545
+ continue
546
+ ident = _category_id(item)
547
+ if ident is not None:
548
+ values.add(ident)
549
+ for key in ("children", "items", "options"):
550
+ child_rows = item.get(key)
551
+ if isinstance(child_rows, list):
552
+ visit(child_rows)
553
+ visit(_option_items(payload))
554
+ return values
555
+
556
+
557
+ def _normalized(value: Any) -> Any:
558
+ if isinstance(value, bool) or value is None:
559
+ return value
560
+ if isinstance(value, (int, float, str)):
561
+ return str(value)
562
+ if isinstance(value, list):
563
+ return sorted((_normalized(item) for item in value), key=repr)
564
+ if isinstance(value, dict):
565
+ return {key: _normalized(item) for key, item in sorted(value.items())}
566
+ return str(value)
567
+
568
+
569
+ def _same_readback(expected: Any, actual: Any) -> bool:
570
+ if isinstance(expected, dict):
571
+ return isinstance(actual, dict) and all(
572
+ key in actual and _same_readback(value, actual[key])
573
+ for key, value in expected.items()
574
+ )
575
+ if isinstance(expected, list):
576
+ if not isinstance(actual, list):
577
+ return False
578
+ unmatched = list(actual)
579
+ for wanted in expected:
580
+ match = next(
581
+ (index for index, candidate in enumerate(unmatched) if _same_readback(wanted, candidate)),
582
+ None,
583
+ )
584
+ if match is None:
585
+ return False
586
+ unmatched.pop(match)
587
+ return not unmatched
588
+ return _normalized(expected) == _normalized(actual)
589
+
590
+
591
+ def _verify_fields(expected: Mapping[str, Any], actual: Mapping[str, Any], fields: Sequence[str], endpoint: str) -> None:
592
+ mismatches = [
593
+ name for name in fields
594
+ if name in expected and (name not in actual or not _same_readback(expected[name], actual[name]))
595
+ ]
596
+ if mismatches:
597
+ raise FuploadError(
598
+ "write readback did not match field(s): %s" % ", ".join(sorted(mismatches)),
599
+ kind="verification_required", stage="readback", endpoint=endpoint, verification_required=True,
600
+ details={"fields": sorted(mismatches)},
601
+ )
602
+
603
+
604
+ def _readback(getter: Any, endpoint: str) -> Any:
605
+ try:
606
+ return getter()
607
+ except FuploadError as exc:
608
+ if exc.stage == "readback" and exc.verification_required:
609
+ raise
610
+ raise FuploadError(
611
+ str(exc),
612
+ kind=exc.kind,
613
+ stage="readback",
614
+ endpoint=exc.endpoint or endpoint,
615
+ http_status=exc.http_status,
616
+ business_code=exc.business_code,
617
+ verification_required=True,
618
+ details=exc.details,
619
+ ) from exc
620
+
621
+
622
+ def _readback_until_fields(
623
+ getter: Any,
624
+ projector: Any,
625
+ expected: Mapping[str, Any],
626
+ fields: Sequence[str],
627
+ endpoint: str,
628
+ *,
629
+ attempts: int = 6,
630
+ delay: float = 1.0,
631
+ ) -> Tuple[Any, Mapping[str, Any]]:
632
+ raw: Any = {}
633
+ actual: Mapping[str, Any] = {}
634
+ for attempt in range(attempts):
635
+ raw = _readback(getter, endpoint)
636
+ projected = projector(raw)
637
+ actual = projected if isinstance(projected, Mapping) else {}
638
+ mismatches = [
639
+ name for name in fields
640
+ if name in expected and (name not in actual or not _same_readback(expected[name], actual[name]))
641
+ ]
642
+ if not mismatches:
643
+ return raw, actual
644
+ if attempt + 1 < attempts:
645
+ time.sleep(delay)
646
+ _verify_fields(expected, actual, fields, endpoint)
647
+ return raw, actual
648
+
649
+
650
+ def _dependency_post(session: Any, path: str, body: Mapping[str, Any]) -> Any:
651
+ method = getattr(session, "post_read", None)
652
+ if callable(method):
653
+ return method(path, body)
654
+ return session.post(path, body)
655
+
656
+
657
+ def _author_page(
658
+ session: Sidecar, resource: str, keyword: str, game_type: Any, page: int, size: int,
659
+ ) -> Any:
660
+ common = {"game_type": game_type, "origin": "created", "page": page, "size": size}
661
+ if resource == "plugin":
662
+ return _dependency_post(session, "/addon/addon_list", {
663
+ **common, "category": 0,
664
+ "name_or_author_name_or_share_code": keyword,
665
+ "sort_type": 2,
666
+ })
667
+ if resource == "config":
668
+ return session.get("/share/list", {
669
+ **common, "search_text": keyword, "sort_type": "mtime",
670
+ })
671
+ return session.get("/wa/list", {
672
+ **common, "search_text": keyword, "category_id": "", "sort_type": "mtime",
673
+ })
674
+
675
+
676
+ def _author_total(payload: Any) -> Optional[int]:
677
+ for value in (payload, result(payload)):
678
+ if not isinstance(value, dict):
679
+ continue
680
+ for key in ("total", "total_count", "count"):
681
+ try:
682
+ if value.get(key) is not None:
683
+ return max(0, int(value[key]))
684
+ except (TypeError, ValueError):
685
+ continue
686
+ return None
687
+
688
+
689
+ def _author_items(
690
+ session: Sidecar, resource: str, keyword: str, game_type: Any, *, size: int = 100,
691
+ ) -> List[Dict[str, Any]]:
692
+ collected: List[Dict[str, Any]] = []
693
+ seen_pages: set[Tuple[str, ...]] = set()
694
+ for page in range(1, 1001):
695
+ payload = _author_page(session, resource, keyword, game_type, page, size)
696
+ page_items = items(payload)
697
+ if not page_items:
698
+ return collected
699
+ signature = tuple(
700
+ str(item.get("sn") or item.get("share_sn") or item.get("id") or "")
701
+ for item in page_items
702
+ )
703
+ if signature in seen_pages:
704
+ raise FuploadError(
705
+ "DD author list pagination repeated a page",
706
+ kind="platform_data_error", stage="dependency_get",
707
+ )
708
+ seen_pages.add(signature)
709
+ collected.extend(page_items)
710
+ total = _author_total(payload)
711
+ if total is not None and len(collected) >= total:
712
+ return collected
713
+ if total is None and len(page_items) < size:
714
+ return collected
715
+ raise FuploadError(
716
+ "DD author list pagination exceeded the bounded page limit",
717
+ kind="platform_data_error", stage="dependency_get",
718
+ )
719
+
720
+
721
+ def author_listing(session: Sidecar, resource: str, keyword: str, game_type: Any) -> Any:
722
+ author_items = _author_items(session, resource, keyword, game_type)
723
+ return {"code": 0, "result": {"items": author_items, "total": len(author_items)}}
724
+
725
+
726
+ def readable_author_list(
727
+ session: Sidecar, resource: str, keyword: str, game_type: Any,
728
+ page: int, size: int,
729
+ ) -> Dict[str, Any]:
730
+ def load(search: str) -> Any:
731
+ return _author_page(session, resource, search, game_type, page, size)
732
+
733
+ fallback = False
734
+ try:
735
+ payload = load(keyword)
736
+ except FuploadError:
737
+ if not keyword:
738
+ raise
739
+ payload = load("")
740
+ fallback = True
741
+ safe = safe_author_list(resource, payload)
742
+ if fallback:
743
+ needle = keyword.casefold()
744
+ safe["items"] = [
745
+ item for item in safe["items"]
746
+ if needle in str(item.get("name") or "").casefold()
747
+ or needle in str(item.get("reference") or "").casefold()
748
+ ]
749
+ safe["total"] = len(safe["items"])
750
+ return safe
751
+
752
+
753
+ def created_reference(session: Sidecar, resource: str, name: str, game_type: Any) -> str:
754
+
755
+ for keyword in (name, ""):
756
+ try:
757
+ payload = author_listing(session, resource, keyword, game_type)
758
+ except FuploadError:
759
+ continue
760
+ references = {
761
+ str(item.get("sn") or item.get("share_sn") or "")
762
+ for item in items(payload)
763
+ if str(item.get("name") or item.get("title") or "") == name
764
+ } - {""}
765
+ if len(references) == 1:
766
+ return next(iter(references))
767
+ return ""
768
+
769
+
770
+ def author_item(session: Sidecar, resource: str, reference: str, name: str, game_type: Any) -> Dict[str, Any]:
771
+ for keyword in (name, ""):
772
+ try:
773
+ payload = author_listing(session, resource, keyword, game_type)
774
+ except FuploadError:
775
+ continue
776
+ matches = [
777
+ item for item in items(payload)
778
+ if str(item.get("sn") or item.get("share_sn") or "") == reference
779
+ ]
780
+ if len(matches) == 1:
781
+ return matches[0]
782
+ return {}
783
+
784
+
785
+ def safe_game_types(payload: Any) -> Dict[str, Any]:
786
+ result_items = []
787
+ for item in items(payload):
788
+ result_items.append({
789
+ "game_type": item.get("game_type"), "name": item.get("name"),
790
+ "type": item.get("type"), "def_game_version": item.get("def_game_version"),
791
+ })
792
+ return {
793
+ "total": len(result_items),
794
+ "items": result_items,
795
+ "dependencies": [
796
+ {"parent": "game_type", "children": ["game_versions", "associated_acts", "category_ids"]},
797
+ ],
798
+ }
799
+
800
+
801
+ def safe_game_versions(payload: Any, game_type: Any) -> Dict[str, Any]:
802
+ result_items = []
803
+ seen = set()
804
+ for item in _option_items(payload):
805
+ if isinstance(item, dict):
806
+ value = item.get("game_version") or item.get("version") or item.get("value") or item.get("id")
807
+ name = item.get("name") or item.get("label") or value
808
+ else:
809
+ value = item
810
+ name = item
811
+ if value in (None, "") or str(value) in seen:
812
+ continue
813
+ seen.add(str(value))
814
+ result_items.append({"value": value, "name": name, "game_type": game_type})
815
+ return {
816
+ "parent": {"game_type": game_type},
817
+ "total": len(result_items),
818
+ "items": result_items,
819
+ }
820
+
821
+
822
+ def safe_plugin_categories(payload: Any) -> Dict[str, Any]:
823
+ result_items = []
824
+ for item in _option_items(payload):
825
+ if not isinstance(item, dict):
826
+ continue
827
+ primary = _category_id(item)
828
+ if primary is None:
829
+ continue
830
+ children = []
831
+ for child in item.get("children") or []:
832
+ if not isinstance(child, dict):
833
+ continue
834
+ child_id = _category_id(child)
835
+ if child_id is not None:
836
+ children.append({
837
+ "id": child_id,
838
+ "name": child.get("name") or child.get("label"),
839
+ "primary_category_id": primary,
840
+ })
841
+ result_items.append({
842
+ "id": primary,
843
+ "name": item.get("name") or item.get("label"),
844
+ "children": children,
845
+ })
846
+ return {
847
+ "total": len(result_items),
848
+ "items": result_items,
849
+ "dependencies": [{"parent": "primary_category_id", "child": "second_category_ids"}],
850
+ }
851
+
852
+
853
+ def safe_wa_categories(payload: Any, game_type: Any) -> Dict[str, Any]:
854
+ result_items = []
855
+
856
+ def visit(rows: Sequence[Any], parent: Optional[str] = None) -> None:
857
+ for item in rows:
858
+ if not isinstance(item, dict):
859
+ continue
860
+ value = _category_id(item)
861
+ if value is not None:
862
+ result_items.append({
863
+ "id": value,
864
+ "name": item.get("name") or item.get("label"),
865
+ "parent_id": parent,
866
+ "game_type": game_type,
867
+ })
868
+ for key in ("children", "items", "options"):
869
+ children = item.get(key)
870
+ if isinstance(children, list):
871
+ visit(children, value or parent)
872
+
873
+ visit(_option_items(payload))
874
+ return {"parent": {"game_type": game_type}, "total": len(result_items), "items": result_items}
875
+
876
+
877
+ def safe_channels(payload: Any) -> Dict[str, Any]:
878
+ raw = payload.get("data") if isinstance(payload, dict) else payload
879
+ if isinstance(raw, dict):
880
+ raw_items = raw.get("list") or raw.get("items") or raw.get("channels") or raw.get("data") or []
881
+ else:
882
+ raw_items = raw if isinstance(raw, list) else []
883
+ result_items = []
884
+ def walk(rows: Sequence[Any], inherited_room_id: str = "", inherited_room_name: Any = None) -> None:
885
+ for value in rows:
886
+ if not isinstance(value, dict):
887
+ continue
888
+ room_id = str(value.get("teamId") or value.get("team_id") or value.get("room_id") or inherited_room_id or "")
889
+ room_name = value.get("teamName") or value.get("team_name") or value.get("room_name") or inherited_room_name
890
+ channel_id = str(value.get("channelId") or value.get("channel_id") or "")
891
+ channel_type = str(value.get("channelType") or value.get("channel_type") or "")
892
+ if room_id and (value.get("teamId") or value.get("team_id") or value.get("room_id") or channel_id):
893
+ result_items.append({
894
+ "room_id": room_id,
895
+ "room_name": room_name,
896
+ "channel_id": channel_id,
897
+ "channel_name": value.get("channelName") or value.get("channel_name") or (value.get("name") if channel_id else None),
898
+ "channel_type": channel_type,
899
+ })
900
+ for key in ("channelList", "channels", "children", "items"):
901
+ children = value.get(key)
902
+ if isinstance(children, list):
903
+ walk(children, room_id, room_name)
904
+ walk(raw_items)
905
+ unique = []
906
+ seen = set()
907
+ for item in result_items:
908
+ key = (item["room_id"], item["channel_id"], item["channel_type"])
909
+ if key not in seen:
910
+ seen.add(key)
911
+ unique.append(item)
912
+ return {
913
+ "total": len(unique),
914
+ "items": unique,
915
+ "dependencies": [{"parent": "room_id", "children": ["channel_id", "channel_type"]}],
916
+ }
917
+
918
+
919
+ def version_greater(candidate: Any, current: Any) -> bool:
920
+ left = str(candidate or "").strip()
921
+ right = str(current or "").strip()
922
+ if not left.isdigit():
923
+ return False
924
+ try:
925
+ return Decimal(left) > Decimal(right)
926
+ except InvalidOperation:
927
+ return False
928
+
929
+
930
+ def response_reference(payload: Any, *names: str) -> str:
931
+ value = result(payload)
932
+ if isinstance(value, Mapping):
933
+ for name in names:
934
+ candidate = value.get(name)
935
+ if candidate not in (None, ""):
936
+ return str(candidate)
937
+ return ""
938
+ return "" if value in (None, "") else str(value)
939
+
940
+
941
+ def safe_author_list(kind: str, payload: Any) -> Dict[str, Any]:
942
+ result_items = []
943
+ for item in items(payload):
944
+ reference = item.get("share_sn") or item.get("sn") or ""
945
+ latest = item.get("latest_version")
946
+ if isinstance(latest, dict):
947
+ latest_version = latest.get("version") or ""
948
+ else:
949
+ latest_version = latest or ""
950
+ result_items.append({
951
+ "kind": kind, "reference": str(reference),
952
+ "name": str(item.get("title") or item.get("name") or ""),
953
+ "version": str(item.get("version") or item.get("current_version") or latest_version),
954
+ "scope": item.get("scope"), "status": item.get("status") or item.get("audit_status") or item.get("state"),
955
+ "game_type": item.get("game_type"), "updated_at": item.get("mtime") or item.get("update_time"),
956
+ })
957
+ return {"total": len(result_items), "items": result_items}
958
+
959
+
960
+ def _backup_group_counts(value: Mapping[str, Any]) -> Dict[str, int]:
961
+ result_value = result(value)
962
+ if isinstance(result_value, list):
963
+ result_value = result_value[0] if result_value and isinstance(result_value[0], dict) else {}
964
+ if not isinstance(result_value, dict):
965
+ result_value = {}
966
+ counts = {}
967
+ for name in ("known_addon", "unknown_addon", "material", "font", "known_wa", "unknown_wa"):
968
+ group = result_value.get(name) or {}
969
+ counts[name] = len(group.get("items") or []) if isinstance(group, dict) else 0
970
+ wtf = result_value.get("wtf") or {}
971
+ accounts = wtf.get("accounts") if isinstance(wtf, dict) else []
972
+ counts["wtf_accounts"] = len(accounts) if isinstance(accounts, list) else 0
973
+ return counts
974
+
975
+
976
+ def safe_backup_list(payload: Any) -> Dict[str, Any]:
977
+ result_items = []
978
+ for item in items(payload):
979
+ result_items.append({
980
+ "reference": str(item.get("sn") or item.get("backup_sn") or ""),
981
+ "name": item.get("name"), "game_type": item.get("game_type"),
982
+ "counts": _backup_group_counts({"result": item}),
983
+ })
984
+ return {"total": len(result_items), "items": result_items}
985
+
986
+
987
+ def safe_backup_detail(value: Any) -> Dict[str, Any]:
988
+ if isinstance(value, dict) and "result" in value:
989
+ value = result(value)
990
+ if isinstance(value, list):
991
+ value = value[0] if value and isinstance(value[0], dict) else {}
992
+ if not isinstance(value, dict):
993
+ return {}
994
+ result_value: Dict[str, Any] = {
995
+ "reference": str(value.get("sn") or value.get("backup_sn") or ""),
996
+ "name": value.get("name"), "game_type": value.get("game_type"),
997
+ "counts": _backup_group_counts({"result": value}),
998
+ }
999
+ selection_keys = {
1000
+ "known_addon": "addon_id", "unknown_addon": "name",
1001
+ "material": "name", "font": "name", "known_wa": "uid", "unknown_wa": "uid",
1002
+ }
1003
+ wa_accounts: Dict[str, List[str]] = {}
1004
+ account_info = ((value.get("extra") or {}).get("wa_account_info") or {}) if isinstance(value.get("extra"), dict) else {}
1005
+ if isinstance(account_info, dict):
1006
+ for account, entries in account_info.items():
1007
+ if not isinstance(entries, list):
1008
+ continue
1009
+ for entry in entries:
1010
+ if isinstance(entry, dict) and entry.get("uid") is not None:
1011
+ wa_accounts.setdefault(str(entry["uid"]), []).append(str(account))
1012
+ for name in ("known_addon", "unknown_addon", "material", "font", "known_wa", "unknown_wa"):
1013
+ group = value.get(name) or {}
1014
+ raw_items = group.get("items") if isinstance(group, dict) else []
1015
+ if not isinstance(raw_items, list):
1016
+ raw_items = []
1017
+ summaries = []
1018
+ for item in raw_items:
1019
+ if isinstance(item, dict):
1020
+ selection = item.get(selection_keys[name])
1021
+ summaries.append({
1022
+ "reference": selection,
1023
+ "name": item.get("name"),
1024
+ "content_reference": item.get("detail_sn") if name == "known_addon" else None,
1025
+ "accounts": sorted(wa_accounts.get(str(selection), [])) if name in ("known_wa", "unknown_wa") else None,
1026
+ "release_type": item.get("release_type"),
1027
+ "entry_count": len(item.get("dirs") or item.get("items") or []),
1028
+ })
1029
+ else:
1030
+ summaries.append({"reference": item, "name": str(item)})
1031
+ result_value[name] = summaries
1032
+ wtf = value.get("wtf") or {}
1033
+ role_summary = []
1034
+ backup_reference = str(value.get("sn") or value.get("backup_sn") or "")
1035
+ for account in (wtf.get("accounts") if isinstance(wtf, dict) else []) or []:
1036
+ if not isinstance(account, dict):
1037
+ continue
1038
+ for server in account.get("servers") or []:
1039
+ if isinstance(server, dict):
1040
+ for index, role in enumerate(server.get("items") or []):
1041
+ role_id = role.get("role_id") or role.get("id") or role.get("name") if isinstance(role, dict) else role
1042
+ selector_source = "%s\0%s\0%s\0%d\0%s" % (
1043
+ backup_reference, account.get("name"), server.get("name"), index, role_id,
1044
+ )
1045
+ role_summary.append({
1046
+ "selector": "wtf_" + hashlib.sha256(selector_source.encode("utf-8")).hexdigest()[:20],
1047
+ "account": account.get("name"), "server": server.get("name"),
1048
+ "name": role.get("name") if isinstance(role, dict) else str(role),
1049
+ "role_id": role_id if isinstance(role, dict) and (role.get("role_id") is not None or role.get("id") is not None) else None,
1050
+ })
1051
+ result_value["wtf_roles"] = role_summary
1052
+ retail = value.get("retail_ui_config")
1053
+ if isinstance(retail, dict):
1054
+ result_value["retail_ui_config"] = safe_retail_catalog(value)
1055
+ result_value["dependencies"] = [
1056
+ {"parent": "backup_sn", "children": ["wtf_role_ids", "content_groups", "retail_ui_config"]},
1057
+ {"parent": "wtf_role_ids.account", "children": ["known_wa_ids", "unknown_wa_ids"]},
1058
+ ]
1059
+ return result_value
1060
+
1061
+
1062
+ def _retail_selector(reference: str, section: str, account: str, index: int) -> str:
1063
+ digest = hashlib.sha256(
1064
+ ("%s\0%s\0%s\0%d" % (reference, section, account, index)).encode("utf-8")
1065
+ ).hexdigest()[:20]
1066
+ return ("em_" if section == "editMode" else "cd_") + digest
1067
+
1068
+
1069
+ def retail_catalog(backup: Mapping[str, Any]) -> Dict[str, List[Dict[str, Any]]]:
1070
+ reference = str(backup.get("sn") or backup.get("backup_sn") or "")
1071
+ retail = backup.get("retail_ui_config") or {}
1072
+ if not isinstance(retail, dict):
1073
+ retail = {}
1074
+ catalog: Dict[str, List[Dict[str, Any]]] = {"edit_mode": [], "cool_down": []}
1075
+ for section, public_name in (("editMode", "edit_mode"), ("coolDown", "cool_down")):
1076
+ accounts = retail.get(section) or {}
1077
+ if not isinstance(accounts, dict):
1078
+ continue
1079
+ for account, entries in accounts.items():
1080
+ if not isinstance(entries, list):
1081
+ continue
1082
+ for index, item in enumerate(entries):
1083
+ if not isinstance(item, dict):
1084
+ continue
1085
+ catalog[public_name].append({
1086
+ "selector": _retail_selector(reference, section, str(account), index),
1087
+ "account": str(account),
1088
+ "item": item,
1089
+ })
1090
+ return catalog
1091
+
1092
+
1093
+ def safe_retail_catalog(backup: Mapping[str, Any]) -> Dict[str, Any]:
1094
+ catalog = retail_catalog(backup)
1095
+ edit_modes = [
1096
+ {"selector": entry["selector"], "account": entry["account"], "name": entry["item"].get("name")}
1097
+ for entry in catalog["edit_mode"]
1098
+ ]
1099
+ cool_down = [
1100
+ {
1101
+ "selector": entry["selector"], "account": entry["account"],
1102
+ "name": entry["item"].get("name"), "character": entry["item"].get("char"),
1103
+ "realm": entry["item"].get("realm"), "class_name": entry["item"].get("class_name"),
1104
+ "spec_name": entry["item"].get("spec_name"), "spec_tag": entry["item"].get("spec_tag"),
1105
+ }
1106
+ for entry in catalog["cool_down"]
1107
+ ]
1108
+ return {
1109
+ "edit_modes": edit_modes,
1110
+ "cool_down": cool_down,
1111
+ "constraints": {
1112
+ "edit_mode_max": 5,
1113
+ "edit_mode_default_required_when_selected": True,
1114
+ "cool_down_max_per_spec_tag": 1,
1115
+ },
1116
+ }
1117
+
1118
+
1119
+ def safe_current_retail(value: Any) -> Dict[str, Any]:
1120
+ retail = value if isinstance(value, dict) else {}
1121
+ edit_modes = []
1122
+ for account, entries in (retail.get("edit_mode") or {}).items():
1123
+ if isinstance(entries, list):
1124
+ edit_modes.extend({
1125
+ "account": str(account), "name": item.get("name"),
1126
+ "is_default": bool(item.get("is_default")),
1127
+ } for item in entries if isinstance(item, dict))
1128
+ cool_down = []
1129
+ for account, entries in (retail.get("cool_down") or {}).items():
1130
+ if isinstance(entries, list):
1131
+ cool_down.extend({
1132
+ "account": str(account), "name": item.get("name"),
1133
+ "character": item.get("char"), "realm": item.get("realm"),
1134
+ "class_name": item.get("class_name"), "spec_name": item.get("spec_name"),
1135
+ "spec_tag": item.get("spec_tag"),
1136
+ } for item in entries if isinstance(item, dict))
1137
+ return {
1138
+ "edit_modes": edit_modes,
1139
+ "cool_down": cool_down,
1140
+ "enable_dd_setup_wizard": retail.get("enable_dd_setup_wizard"),
1141
+ }
1142
+
1143
+
1144
+ def resolve_retail_ui_config(
1145
+ backup: Mapping[str, Any], current: Mapping[str, Any], selection: Any,
1146
+ ) -> Any:
1147
+ if selection is None:
1148
+ return None
1149
+ if int(backup.get("game_type") or 0) != 10001:
1150
+ raise ValidationError("retail_ui_config is only available for retail backups", path="$.retail_ui_config")
1151
+ catalog = retail_catalog(backup)
1152
+ maps = {
1153
+ name: {entry["selector"]: entry for entry in entries}
1154
+ for name, entries in catalog.items()
1155
+ }
1156
+ existing = current.get("retail_ui_config") if isinstance(current, dict) else None
1157
+ wire = copy.deepcopy(existing) if isinstance(existing, dict) else {}
1158
+
1159
+ if "edit_mode_selectors" in selection:
1160
+ selectors = selection["edit_mode_selectors"]
1161
+ if len(selectors) != len(set(selectors)):
1162
+ raise ValidationError("duplicate selector", path="$.retail_ui_config.edit_mode_selectors")
1163
+ if len(selectors) > 5:
1164
+ raise ValidationError("at most five edit modes may be selected", path="$.retail_ui_config.edit_mode_selectors")
1165
+ default = selection.get("default_edit_mode_selector")
1166
+ if selectors and not default:
1167
+ raise ValidationError("a default edit mode is required", path="$.retail_ui_config.default_edit_mode_selector")
1168
+ if default and default not in selectors:
1169
+ raise ValidationError("default edit mode must be selected", path="$.retail_ui_config.default_edit_mode_selector")
1170
+ grouped: Dict[str, List[Dict[str, Any]]] = {}
1171
+ for index, selector in enumerate(selectors):
1172
+ entry = maps["edit_mode"].get(selector)
1173
+ if not entry:
1174
+ raise ValidationError("selector is unavailable for this backup", path="$.retail_ui_config.edit_mode_selectors[%d]" % index)
1175
+ item = copy.deepcopy(entry["item"])
1176
+ item["is_default"] = selector == default
1177
+ grouped.setdefault(entry["account"], []).append(item)
1178
+ wire["edit_mode"] = grouped
1179
+ elif "default_edit_mode_selector" in selection:
1180
+ raise ValidationError("default selector requires edit_mode_selectors", path="$.retail_ui_config.default_edit_mode_selector")
1181
+
1182
+ if "cool_down_selectors" in selection:
1183
+ selectors = selection["cool_down_selectors"]
1184
+ if len(selectors) != len(set(selectors)):
1185
+ raise ValidationError("duplicate selector", path="$.retail_ui_config.cool_down_selectors")
1186
+ grouped = {}
1187
+ selected_specs = set()
1188
+ for index, selector in enumerate(selectors):
1189
+ entry = maps["cool_down"].get(selector)
1190
+ if not entry:
1191
+ raise ValidationError("selector is unavailable for this backup", path="$.retail_ui_config.cool_down_selectors[%d]" % index)
1192
+ spec_tag = entry["item"].get("spec_tag")
1193
+ if spec_tag in selected_specs:
1194
+ raise ValidationError("only one cooldown configuration may be selected per spec_tag", path="$.retail_ui_config.cool_down_selectors[%d]" % index)
1195
+ selected_specs.add(spec_tag)
1196
+ grouped.setdefault(entry["account"], []).append(copy.deepcopy(entry["item"]))
1197
+ wire["cool_down"] = grouped
1198
+ if "enable_dd_setup_wizard" in selection:
1199
+ wire["enable_dd_setup_wizard"] = selection["enable_dd_setup_wizard"]
1200
+ return wire
1201
+
1202
+
1203
+ def safe_associated_acts(session: Sidecar, game_type: Any) -> Dict[str, Any]:
1204
+ sources = (
1205
+ ("addon", _author_items(session, "plugin", "", game_type)),
1206
+ ("share", _author_items(session, "config", "", game_type)),
1207
+ ("wa", _author_items(session, "wa", "", game_type)),
1208
+ )
1209
+ result_items = []
1210
+ for kind, source_items in sources:
1211
+ for item in source_items:
1212
+ reference = item.get("sn") or item.get("share_sn")
1213
+ if reference:
1214
+ result_items.append({
1215
+ "sn": str(reference), "act_type": kind,
1216
+ "name": item.get("name") or item.get("title"),
1217
+ "version": item.get("version") or item.get("current_version"),
1218
+ })
1219
+ return {"total": len(result_items), "items": result_items}
1220
+
1221
+
1222
+ def safe_detail(kind: str, value: Dict[str, Any]) -> Dict[str, Any]:
1223
+ result_value = copy.deepcopy(value)
1224
+ if kind == "config" and "retail_ui_config" in result_value:
1225
+ result_value["retail_ui_config"] = safe_current_retail(result_value["retail_ui_config"])
1226
+ sensitive_keys = {"content", "raw_content", "wa_str", "roleobj", "wtflist", "file_url", "upload_url", "url", "u_url", "md5", "hash", "dir_md5", "import_string"}
1227
+ def scrub(node: Any) -> Any:
1228
+ if isinstance(node, dict):
1229
+ cleaned: Dict[str, Any] = {}
1230
+ for key, item in node.items():
1231
+ if key.lower() in sensitive_keys:
1232
+ if isinstance(item, str):
1233
+ cleaned[key + "_summary"] = {"length": len(item)}
1234
+ elif isinstance(item, list):
1235
+ cleaned[key + "_summary"] = {"items": len(item)}
1236
+ else:
1237
+ cleaned[key + "_summary"] = {"present": item is not None}
1238
+ elif key in {"file_path", "d_url"} and isinstance(item, str):
1239
+ cleaned[key + "_summary"] = {"host": urllib.parse.urlsplit(item).netloc}
1240
+ else:
1241
+ cleaned[key] = scrub(item)
1242
+ return cleaned
1243
+ if isinstance(node, list):
1244
+ return [scrub(item) for item in node]
1245
+ return node
1246
+ result_value = scrub(result_value)
1247
+ return result_value
1248
+
1249
+
1250
+ def detail(session: Sidecar, kind: str, reference: str) -> Dict[str, Any]:
1251
+ if kind == "plugin":
1252
+ payload = session.get("/addon/detail_v2", {"sn": reference})
1253
+ if not isinstance(result(payload), dict):
1254
+ payload = session.get("/addon/detail", {"sn": reference})
1255
+ elif kind == "config":
1256
+ payload = session.get("/share/detail", {"sn": reference})
1257
+ else:
1258
+ payload = session.get("/wa/detail", {"sn": reference})
1259
+ value = result(payload)
1260
+ if not isinstance(value, dict):
1261
+ raise FuploadError("DD %s detail was not found" % kind, kind="not_found")
1262
+ return value
1263
+
1264
+
1265
+ def apply_present(form: Dict[str, Any], doc: Mapping[str, Any], names: Iterable[str]) -> None:
1266
+ for name in names:
1267
+ if name in doc:
1268
+ form[name] = copy.deepcopy(doc[name])
1269
+
1270
+
1271
+ def _remote_rows(value: Any, path: str) -> List[Any]:
1272
+ if value is None:
1273
+ return []
1274
+ if not isinstance(value, list):
1275
+ raise FuploadError("DD remote %s field was not an array" % path, kind="platform_data_error")
1276
+ return value
1277
+
1278
+
1279
+ def _remote_candidate(item: Any, aliases: Sequence[str]) -> Any:
1280
+ if not isinstance(item, Mapping):
1281
+ return item
1282
+ for name in aliases:
1283
+ if item.get(name) is not None:
1284
+ return item[name]
1285
+ return None
1286
+
1287
+
1288
+ def _remote_strings(value: Any, aliases: Sequence[str], path: str, *, allow_int: bool = False) -> List[str]:
1289
+ projected: List[str] = []
1290
+ for item in _remote_rows(value, path):
1291
+ candidate = _remote_candidate(item, aliases)
1292
+ if isinstance(candidate, str) and candidate:
1293
+ projected.append(candidate)
1294
+ continue
1295
+ if allow_int and not isinstance(candidate, bool) and isinstance(candidate, int):
1296
+ projected.append(str(candidate))
1297
+ continue
1298
+ raise FuploadError("DD remote %s item was not a scalar string" % path, kind="platform_data_error")
1299
+ return projected
1300
+
1301
+
1302
+ def _remote_ints(value: Any, aliases: Sequence[str], path: str) -> List[int]:
1303
+ projected: List[int] = []
1304
+ for item in _remote_rows(value, path):
1305
+ candidate = _remote_candidate(item, aliases)
1306
+ if isinstance(candidate, bool):
1307
+ raise FuploadError("DD remote %s item was not an integer ID" % path, kind="platform_data_error")
1308
+ try:
1309
+ projected.append(int(candidate))
1310
+ except (TypeError, ValueError) as exc:
1311
+ raise FuploadError("DD remote %s item was not an integer ID" % path, kind="platform_data_error") from exc
1312
+ return projected
1313
+
1314
+
1315
+ def _remote_urls(value: Any, path: str) -> List[str]:
1316
+ return _remote_strings(value, ("d_url", "url", "media_url", "value"), path)
1317
+
1318
+
1319
+ def _remote_int(value: Any, aliases: Sequence[str], path: str) -> Any:
1320
+ candidate = _remote_candidate(value, aliases)
1321
+ if candidate is None:
1322
+ return None
1323
+ if isinstance(candidate, bool):
1324
+ raise FuploadError("DD remote %s field was not an integer ID" % path, kind="platform_data_error")
1325
+ try:
1326
+ return int(candidate)
1327
+ except (TypeError, ValueError) as exc:
1328
+ raise FuploadError("DD remote %s field was not an integer ID" % path, kind="platform_data_error") from exc
1329
+
1330
+
1331
+ def validate_no_display_objects(form: Mapping[str, Any], resource: str) -> None:
1332
+ allowed_objects = {"associated_acts"}
1333
+ if resource == "config":
1334
+ allowed_objects.update({
1335
+ "known_addon", "unknown_addon", "wtf", "material", "font",
1336
+ "known_wa", "unknown_wa", "retail_ui_config",
1337
+ })
1338
+ for name, value in form.items():
1339
+ if isinstance(value, Mapping) and name not in allowed_objects:
1340
+ raise FuploadError(
1341
+ "DD %s mutation field %s contained an unexpected object" % (resource, name),
1342
+ kind="platform_data_error", stage="mutation_projection",
1343
+ )
1344
+ if isinstance(value, list) and name not in allowed_objects and any(isinstance(item, Mapping) for item in value):
1345
+ raise FuploadError(
1346
+ "DD %s mutation field %s contained an unexpected object item" % (resource, name),
1347
+ kind="platform_data_error", stage="mutation_projection",
1348
+ )
1349
+
1350
+
1351
+ COMMERCIAL = (
1352
+ "scope", "share_code_life_type", "need_buy", "price_fen", "buy_life_type",
1353
+ "jump_room", "room_id", "channel_id", "channel_type", "sync_room",
1354
+ "creation_statement", "with_associate", "associated_acts", "need_anchor_vip", "vip_levels",
1355
+ )
1356
+
1357
+
1358
+ def normalize_commercial(
1359
+ form: Dict[str, Any], resource: Optional[str] = None, *, create: bool = False,
1360
+ ) -> None:
1361
+ """Apply the resource's official submit-time conditionals.
1362
+
1363
+ The three DD editors share controls but do not share one wire builder.
1364
+ In particular, only the configuration builder always defaults
1365
+ buy_life_type, while plugin/WA create defaults must not leak into legacy
1366
+ modify payloads.
1367
+ """
1368
+ if resource == "config":
1369
+ form["need_buy"] = 1 if form.get("need_buy") else 0
1370
+ form["buy_life_type"] = form.get("buy_life_type") or "seven_day"
1371
+ elif create and not form.get("buy_life_type"):
1372
+ form["buy_life_type"] = "seven_day"
1373
+ if "price_fen" not in form or form.get("price_fen") is None:
1374
+ form["price_fen"] = 0
1375
+ if create and not form.get("need_buy"):
1376
+ form["price_fen"] = 0
1377
+ if form.get("scope") == "private":
1378
+ form["sync_room"] = False
1379
+ form["need_anchor_vip"] = False
1380
+ form["vip_levels"] = []
1381
+ elif form.get("scope") == "public":
1382
+ if resource in ("plugin", "wa"):
1383
+ form["share_code_life_type"] = "forever"
1384
+ elif resource == "config":
1385
+ form.pop("share_code_life_type", None)
1386
+ if not form.get("jump_room"):
1387
+ form.update({"room_id": "", "channel_id": "", "channel_type": "", "sync_room": False})
1388
+ if form.get("with_associate"):
1389
+ associated_acts = []
1390
+ for index, item in enumerate(form.get("associated_acts") or []):
1391
+ if not isinstance(item, Mapping) or not item.get("sn") or not item.get("act_type"):
1392
+ raise ValidationError(
1393
+ "associated item must contain sn and act_type",
1394
+ path="$.associated_acts[%d]" % index,
1395
+ )
1396
+ associated_acts.append({
1397
+ "sn": copy.deepcopy(item["sn"]),
1398
+ "act_type": copy.deepcopy(item["act_type"]),
1399
+ })
1400
+ form["associated_acts"] = associated_acts
1401
+ else:
1402
+ form["associated_acts"] = []
1403
+
1404
+
1405
+ def validate_locked_usage_mode(current: Mapping[str, Any], form: Mapping[str, Any], doc: Mapping[str, Any]) -> None:
1406
+ if not current or not any(name in doc for name in ("need_buy", "need_anchor_vip")):
1407
+ return
1408
+ current_paid = bool(current.get("need_buy") or current.get("need_anchor_vip"))
1409
+ requested_paid = bool(form.get("need_buy") or form.get("need_anchor_vip"))
1410
+ if current_paid != requested_paid:
1411
+ path = "$.need_buy" if "need_buy" in doc else "$.need_anchor_vip"
1412
+ raise ValidationError("the outer free/paid usage mode is locked after creation", path=path)
1413
+
1414
+
1415
+ PLUGIN_FIELDS = (
1416
+ "game_type", "game_versions", "scope", "addon_type", "name", "description", "logo",
1417
+ "detail_imgs", "primary_category_id", "second_category_ids", "detail_url", "release_type",
1418
+ "version", "html_desc", "update_desc", *COMMERCIAL,
1419
+ )
1420
+
1421
+ PLUGIN_OPEN_FIELDS = (
1422
+ "game_type", "game_versions", "description", "addon_type", "name", "logo",
1423
+ "detail_imgs", "primary_category_id", "second_category_ids", "detail_url",
1424
+ "release_type", "version", "html_desc", "update_desc", "share_code_life_type",
1425
+ "need_buy", "buy_life_type", "jump_room", "room_id", "channel_id",
1426
+ "channel_type", "sync_room", "creation_statement", "with_associate",
1427
+ "associated_acts", "need_anchor_vip",
1428
+ )
1429
+
1430
+ PLUGIN_CREATE_DEFAULTS = {
1431
+ "share_code_life_type": "seven_day",
1432
+ "addon_type": 0,
1433
+ "buy_life_type": "seven_day",
1434
+ "need_buy": False,
1435
+ "with_associate": False,
1436
+ }
1437
+
1438
+ # DD's modify form is not a second create form. The official edit page
1439
+ # rebuilds the payload from the existing commercial/association controls;
1440
+ # first-publication metadata and version fields belong to create/update.
1441
+ PLUGIN_EDIT_FIELDS = (
1442
+ "scope", "share_code_life_type", "need_buy", "price_fen", "buy_life_type",
1443
+ "jump_room", "room_id", "channel_id", "channel_type", "sync_room",
1444
+ "creation_statement", "with_associate", "associated_acts", "need_anchor_vip",
1445
+ "vip_levels",
1446
+ )
1447
+
1448
+
1449
+ def plugin_form(
1450
+ value: Mapping[str, Any], author_value: Optional[Mapping[str, Any]] = None,
1451
+ ) -> Dict[str, Any]:
1452
+ latest = value.get("latest_version") if isinstance(value.get("latest_version"), dict) else {}
1453
+ author_latest = (
1454
+ author_value.get("latest_version")
1455
+ if isinstance(author_value, Mapping) and isinstance(author_value.get("latest_version"), dict)
1456
+ else {}
1457
+ )
1458
+ # Official detail dialog projection followed by the editor's pick list.
1459
+ source = {name: copy.deepcopy(value[name]) for name in PLUGIN_FIELDS if name in value}
1460
+ for name in ("detail_url", "release_type", "version"):
1461
+ latest_name = {"detail_url": "file_path"}.get(name, name)
1462
+ projected = latest.get(latest_name)
1463
+ if projected is None:
1464
+ projected = author_latest.get(latest_name)
1465
+ if projected is None:
1466
+ source.pop(name, None)
1467
+ else:
1468
+ source[name] = copy.deepcopy(projected)
1469
+ source["game_type"] = _remote_int(
1470
+ source.get("game_type") or (value.get("game_types") or [None])[0],
1471
+ ("game_type", "id", "value"), "plugin.game_type",
1472
+ )
1473
+ form = {name: copy.deepcopy(source[name]) for name in PLUGIN_OPEN_FIELDS if name in source}
1474
+ form["scope"] = copy.deepcopy(source.get("scope") or "public")
1475
+ form["price_fen"] = copy.deepcopy(source.get("price_fen") or 0)
1476
+ form["vip_levels"] = _remote_ints(source.get("vip_levels") or [], ("level", "id", "value"), "plugin.vip_levels")
1477
+ if "game_versions" in form:
1478
+ form["game_versions"] = _remote_strings(form["game_versions"], ("version", "build", "value"), "plugin.game_versions")
1479
+ if "detail_imgs" in form:
1480
+ form["detail_imgs"] = _remote_urls(form["detail_imgs"], "plugin.detail_imgs")
1481
+ if "primary_category_id" in form:
1482
+ form["primary_category_id"] = _remote_int(form["primary_category_id"], ("c_id", "category_id", "id", "value"), "plugin.primary_category_id")
1483
+ categories = _remote_ints(form.get("second_category_ids") or [], ("c_id", "category_id", "id", "value"), "plugin.second_category_ids")
1484
+ form["second_category_ids"] = categories[:-1] if categories else []
1485
+ normalize_commercial(form, "plugin")
1486
+ return form
1487
+
1488
+
1489
+ def plugin_version_projection(value: Mapping[str, Any]) -> Dict[str, Any]:
1490
+ """Project version fields for readback without feeding them into modify."""
1491
+ latest = value.get("latest_version") if isinstance(value.get("latest_version"), dict) else {}
1492
+ projection: Dict[str, Any] = {}
1493
+ sources = {
1494
+ "game_versions": (latest, "game_versions", value, "game_versions"),
1495
+ "detail_url": (latest, "file_path", value, "detail_url"),
1496
+ "release_type": (latest, "release_type", value, "release_type"),
1497
+ "version": (latest, "version", value, "version"),
1498
+ "update_desc": (latest, "update_desc", value, "update_desc"),
1499
+ }
1500
+ for name, (preferred, preferred_name, fallback, fallback_name) in sources.items():
1501
+ if preferred.get(preferred_name) is not None:
1502
+ projection[name] = copy.deepcopy(preferred[preferred_name])
1503
+ elif fallback.get(fallback_name) is not None:
1504
+ projection[name] = copy.deepcopy(fallback[fallback_name])
1505
+ return projection
1506
+
1507
+
1508
+ def plugin_history_versions(payload: Any) -> set[str]:
1509
+ versions: set[str] = set()
1510
+
1511
+ def visit(node: Any) -> None:
1512
+ if isinstance(node, dict):
1513
+ if node.get("version") not in (None, ""):
1514
+ versions.add(str(node["version"]).strip().casefold())
1515
+ for child in node.values():
1516
+ visit(child)
1517
+ elif isinstance(node, list):
1518
+ for child in node:
1519
+ visit(child)
1520
+
1521
+ visit(result(payload))
1522
+ return versions
1523
+
1524
+
1525
+ def load_plugin_history_versions(
1526
+ session: Sidecar, reference: str, game_type: Any, *, page_limit: int = 1000,
1527
+ ) -> set[str]:
1528
+ versions: set[str] = set()
1529
+ seen_pages: set[Tuple[str, ...]] = set()
1530
+ for page in range(1, page_limit + 1):
1531
+ payload = session.get("/addon/addon_versions", {
1532
+ "sn": reference, "game_type": game_type, "page": page,
1533
+ })
1534
+ page_items = items(payload)
1535
+ if not page_items:
1536
+ return versions
1537
+ signature = tuple(
1538
+ str(item.get("sn") or item.get("version") or item.get("id") or "")
1539
+ for item in page_items
1540
+ )
1541
+ if signature in seen_pages:
1542
+ return versions
1543
+ seen_pages.add(signature)
1544
+ versions.update(plugin_history_versions(payload))
1545
+ total = _author_total(payload)
1546
+ if total is not None and len(versions) >= total:
1547
+ return versions
1548
+ raise FuploadError(
1549
+ "DD plugin version pagination exceeded the bounded page limit",
1550
+ kind="platform_data_error", stage="dependency_get",
1551
+ )
1552
+
1553
+
1554
+ def _key(item: Any, key: Optional[str]) -> Any:
1555
+ if key is None:
1556
+ return item if not isinstance(item, dict) else item.get("name", item.get("id"))
1557
+ return item.get(key) if isinstance(item, dict) else item
1558
+
1559
+
1560
+ def selected_group(
1561
+ backup: Mapping[str, Any], current: Mapping[str, Any], name: str, key: Optional[str],
1562
+ selected: Sequence[Any], updates: Sequence[Any], update_path: Optional[str] = None,
1563
+ ) -> Dict[str, Any]:
1564
+ available = list(((backup.get(name) or {}).get("items") or []))
1565
+ by_key = {_key(item, key): item for item in available}
1566
+ missing = [value for value in selected if value not in by_key]
1567
+ if missing:
1568
+ raise ValidationError("selection is absent from the chosen backup: %s" % missing, path="$.%s" % name)
1569
+ old_versions = dict(((current.get(name) or {}).get("inner_version") or {}))
1570
+ versions = {}
1571
+ update_keys = {str(value) for value in updates}
1572
+ selected_keys = {str(value) for value in selected}
1573
+ if update_keys - selected_keys:
1574
+ raise ValidationError(
1575
+ "update markers must refer to selected content",
1576
+ path="$.%s" % (update_path or name),
1577
+ )
1578
+ for item in available:
1579
+ value = _key(item, key)
1580
+ lookup = str(value)
1581
+ old = int(old_versions.get(lookup, old_versions.get(value, 0)) or 0)
1582
+ versions[lookup] = old + 1 if lookup in update_keys and old else (old or 1)
1583
+ return {"items": [copy.deepcopy(by_key[value]) for value in selected], "inner_version": versions}
1584
+
1585
+
1586
+ def wtf_tree(backup: Mapping[str, Any], selected_roles: Sequence[str]) -> Dict[str, Any]:
1587
+ wanted = list(map(str, selected_roles))
1588
+ accounts = []
1589
+ raw = (backup.get("wtf") or {}).get("accounts") or backup.get("wtf_accounts") or []
1590
+ candidates: List[Tuple[str, str, str, int, Any, str]] = []
1591
+ backup_reference = str(backup.get("sn") or backup.get("backup_sn") or "")
1592
+ for account in raw:
1593
+ account_name = str(account.get("name") or "")
1594
+ for server in account.get("servers", []):
1595
+ server_name = str(server.get("name") or "")
1596
+ for index, role in enumerate(server.get("items", [])):
1597
+ role_id = role.get("role_id") or role.get("id") or role.get("name") if isinstance(role, dict) else role
1598
+ selector_source = "%s\0%s\0%s\0%d\0%s" % (backup_reference, account_name, server_name, index, role_id)
1599
+ selector = "wtf_" + hashlib.sha256(selector_source.encode("utf-8")).hexdigest()[:20]
1600
+ candidates.append((account_name, server_name, str(role_id), index, role, selector))
1601
+ selected_positions: set[Tuple[str, str, int]] = set()
1602
+ for selected in wanted:
1603
+ exact = [(account, server, index) for account, server, _role_id, index, _role, selector in candidates if selector == selected]
1604
+ legacy = [(account, server, index) for account, server, role_id, index, _role, _selector in candidates if role_id == selected]
1605
+ matches = exact or legacy
1606
+ if len(matches) != 1:
1607
+ message = "WTF role selector is absent" if not matches else "legacy WTF role value is ambiguous; use the backup selector"
1608
+ raise ValidationError(message, path="$.wtf_role_ids")
1609
+ selected_positions.add(matches[0])
1610
+ for account in raw:
1611
+ account_copy = {k: copy.deepcopy(v) for k, v in account.items() if k != "servers"}
1612
+ servers = []
1613
+ for server in account.get("servers", []):
1614
+ chosen = []
1615
+ for index, role in enumerate(server.get("items", [])):
1616
+ if (str(account.get("name") or ""), str(server.get("name") or ""), index) in selected_positions:
1617
+ chosen.append(copy.deepcopy(role))
1618
+ if chosen:
1619
+ server_copy = {k: copy.deepcopy(v) for k, v in server.items() if k != "items"}
1620
+ server_copy["items"] = chosen
1621
+ servers.append(server_copy)
1622
+ if servers:
1623
+ account_copy["servers"] = servers
1624
+ accounts.append(account_copy)
1625
+ return {"accounts": accounts}
1626
+
1627
+
1628
+ CONFIG_GROUPS = (
1629
+ ("known_addon", "addon_id", "known_addon_ids", "known_addon_update_ids"),
1630
+ ("unknown_addon", None, "unknown_addon_ids", "unknown_addon_update_ids"),
1631
+ ("material", "name", "material_names", "material_update_names"),
1632
+ ("font", None, "font_names", "font_update_names"),
1633
+ )
1634
+
1635
+
1636
+ def selected_wtf_account(value: Mapping[str, Any]) -> str:
1637
+ accounts = ((value.get("wtf") or {}).get("accounts") or []) if isinstance(value.get("wtf"), dict) else []
1638
+ selected = [str(account.get("name") or "") for account in accounts if isinstance(account, dict)]
1639
+ selected = [account for account in selected if account]
1640
+ return selected[0] if len(selected) == 1 else ""
1641
+
1642
+
1643
+ def current_wtf_selectors(backup: Mapping[str, Any], current: Mapping[str, Any]) -> List[str]:
1644
+ available = safe_backup_detail(backup).get("wtf_roles") or []
1645
+ selected = []
1646
+ accounts = ((current.get("wtf") or {}).get("accounts") or []) if isinstance(current.get("wtf"), dict) else []
1647
+ for account in accounts:
1648
+ if not isinstance(account, dict):
1649
+ continue
1650
+ for server in account.get("servers") or []:
1651
+ if not isinstance(server, dict):
1652
+ continue
1653
+ for role in server.get("items") or []:
1654
+ role_id = role.get("role_id") or role.get("id") or role.get("name") if isinstance(role, dict) else role
1655
+ matches = [item for item in available if (
1656
+ str(item.get("account") or "") == str(account.get("name") or "")
1657
+ and str(item.get("server") or "") == str(server.get("name") or "")
1658
+ and str(item.get("role_id") or item.get("name") or "") == str(role_id or "")
1659
+ )]
1660
+ if len(matches) != 1:
1661
+ raise ValidationError("current WTF role is absent from the live backup", path="$.wtf_role_ids")
1662
+ selected.append(str(matches[0]["selector"]))
1663
+ return selected
1664
+
1665
+
1666
+ def selected_wa_group(
1667
+ backup: Mapping[str, Any], current: Mapping[str, Any], name: str,
1668
+ selected: Sequence[str], updates: Sequence[str], account: str,
1669
+ update_path: Optional[str] = None,
1670
+ ) -> Dict[str, Any]:
1671
+ available = list(((backup.get(name) or {}).get("items") or []))
1672
+ by_uid = {
1673
+ str(item.get("uid")): item for item in available
1674
+ if isinstance(item, dict) and item.get("uid") is not None
1675
+ }
1676
+ missing = [uid for uid in selected if uid not in by_uid]
1677
+ if missing:
1678
+ raise ValidationError("selection is absent from the chosen backup: %s" % missing, path="$.%s_ids" % name)
1679
+ account_info = ((backup.get("extra") or {}).get("wa_account_info") or {}) if isinstance(backup.get("extra"), dict) else {}
1680
+ mapping = {
1681
+ str(item.get("uid")): item.get("id")
1682
+ for item in (account_info.get(account) or [])
1683
+ if isinstance(item, dict) and item.get("uid") is not None
1684
+ } if isinstance(account_info, dict) else {}
1685
+ unavailable = [uid for uid in selected if uid not in mapping]
1686
+ if unavailable:
1687
+ raise ValidationError("WA selection is unavailable for the selected WTF account", path="$.%s_ids" % name)
1688
+ old_versions = dict(((current.get(name) or {}).get("inner_version") or {}))
1689
+ versions: Dict[str, int] = {}
1690
+ update_keys = {str(value) for value in updates}
1691
+ selected_keys = {str(value) for value in selected}
1692
+ if update_keys - selected_keys:
1693
+ raise ValidationError(
1694
+ "update markers must refer to selected content",
1695
+ path="$.%s" % (update_path or (name + "_ids")),
1696
+ )
1697
+ for item in available:
1698
+ uid = str(item.get("uid"))
1699
+ old = int(old_versions.get(uid, 0) or 0)
1700
+ versions[uid] = old + 1 if uid in update_keys and old else (old or 1)
1701
+ chosen = []
1702
+ for uid in selected:
1703
+ item = copy.deepcopy(by_uid[uid])
1704
+ if name == "unknown_wa":
1705
+ item["id"] = mapping[uid]
1706
+ chosen.append(item)
1707
+ return {"items": chosen, "inner_version": versions}
1708
+
1709
+
1710
+ def config_form(current: Mapping[str, Any], backup: Mapping[str, Any], doc: Mapping[str, Any]) -> Dict[str, Any]:
1711
+ defaults = {
1712
+ "scope": "public", "backup_sn": "", "desc": "", "update_desc": "", "title": "",
1713
+ "display_imgs": [], "share_code_life_type": "seven_day", "brief_desc": "",
1714
+ "price_fen": 0, "need_buy": 0, "buy_life_type": "seven_day",
1715
+ "jump_room": False, "room_id": "", "channel_id": "", "channel_type": "",
1716
+ "sync_room": False, "creation_statement": "", "with_associate": False,
1717
+ "associated_acts": [], "need_anchor_vip": False, "vip_levels": [],
1718
+ }
1719
+ form = {
1720
+ name: copy.deepcopy(current[name] if name in current and current[name] is not None else default)
1721
+ for name, default in defaults.items()
1722
+ }
1723
+ apply_present(form, doc, form.keys())
1724
+ for group, key, selected_name, update_name in CONFIG_GROUPS:
1725
+ if selected_name in doc:
1726
+ form[group] = selected_group(
1727
+ backup, current, group, key, doc[selected_name], doc.get(update_name, []), update_name,
1728
+ )
1729
+ else:
1730
+ current_selected = [
1731
+ _key(item, key) for item in ((current.get(group) or {}).get("items") or [])
1732
+ ]
1733
+ form[group] = selected_group(
1734
+ backup, current, group, key, current_selected, doc.get(update_name, []), update_name,
1735
+ )
1736
+ if "wtf_role_ids" in doc:
1737
+ form["wtf"] = wtf_tree(backup, doc["wtf_role_ids"])
1738
+ else:
1739
+ form["wtf"] = wtf_tree(backup, current_wtf_selectors(backup, current))
1740
+ account = selected_wtf_account(form)
1741
+ current_account = selected_wtf_account(current)
1742
+ account_changed = "wtf_role_ids" in doc and account != current_account
1743
+ for group, selected_name, update_name in (
1744
+ ("known_wa", "known_wa_ids", "known_wa_update_ids"),
1745
+ ("unknown_wa", "unknown_wa_ids", "unknown_wa_update_ids"),
1746
+ ):
1747
+ if selected_name in doc:
1748
+ selected = doc[selected_name]
1749
+ if selected and not account:
1750
+ raise ValidationError("select one WTF role before selecting WA content", path="$.wtf_role_ids")
1751
+ form[group] = selected_wa_group(
1752
+ backup, current, group, selected, doc.get(update_name, []), account, update_name,
1753
+ )
1754
+ elif account_changed:
1755
+ form[group] = selected_wa_group(backup, current, group, [], [], account)
1756
+ else:
1757
+ current_selected = [
1758
+ str(item.get("uid")) for item in ((current.get(group) or {}).get("items") or [])
1759
+ if isinstance(item, dict) and item.get("uid") is not None
1760
+ ]
1761
+ form[group] = selected_wa_group(
1762
+ backup, current, group, current_selected, doc.get(update_name, []), account, update_name,
1763
+ )
1764
+ if current.get("retail_ui_config") is not None:
1765
+ form["retail_ui_config"] = copy.deepcopy(current["retail_ui_config"])
1766
+ form["display_imgs"] = _remote_urls(form.get("display_imgs") or [], "config.display_imgs")
1767
+ form["vip_levels"] = _remote_ints(form.get("vip_levels") or [], ("level", "id", "value"), "config.vip_levels")
1768
+ if (form.get("known_addon", {}).get("items") or form.get("unknown_addon", {}).get("items")) and not form.get("wtf", {}).get("accounts"):
1769
+ raise ValidationError("select at least one WTF role when selecting addon content", path="$.wtf_role_ids")
1770
+ content_groups = ("known_addon", "unknown_addon", "wtf", "material", "font")
1771
+ if not any(form.get(name, {}).get("items") or form.get(name, {}).get("accounts") for name in content_groups):
1772
+ raise ValidationError("DD configuration content cannot contain only WA selections", path="$.known_addon_ids")
1773
+ validate_locked_usage_mode(current, form, doc)
1774
+ normalize_commercial(form, "config", create=not bool(current))
1775
+ return form
1776
+
1777
+
1778
+ def config_readback_projection(value: Any) -> Mapping[str, Any]:
1779
+ if not isinstance(value, Mapping):
1780
+ return {}
1781
+ projected = dict(value)
1782
+ if "need_buy" in projected:
1783
+ projected["need_buy"] = 1 if projected["need_buy"] else 0
1784
+ return projected
1785
+
1786
+
1787
+ WA_FIELDS = (
1788
+ "game_type", "scope", "name", "game_version", "brief_desc", "display_imgs", "category_ids",
1789
+ "content", "desc", "update_desc", "version", "with_file", "file_path", "file_install_path",
1790
+ "parse_wa_uid", "parse_wa_id", *COMMERCIAL,
1791
+ )
1792
+
1793
+ WA_CREATE_DEFAULTS = {
1794
+ "share_code_life_type": "seven_day",
1795
+ "need_buy": False,
1796
+ "buy_life_type": "seven_day",
1797
+ "category_ids": ["ui_original"],
1798
+ "file_install_path": "Interface/Addons",
1799
+ "vip_levels": [],
1800
+ "version": "0",
1801
+ }
1802
+
1803
+
1804
+ def wa_form(value: Mapping[str, Any]) -> Dict[str, Any]:
1805
+ form = {name: copy.deepcopy(value[name]) for name in WA_FIELDS if name in value}
1806
+ form["scope"] = copy.deepcopy(value.get("scope") or "public")
1807
+ form["price_fen"] = copy.deepcopy(value.get("price_fen") or 0)
1808
+ form["vip_levels"] = _remote_ints(value.get("vip_levels") or [], ("level", "id", "value"), "wa.vip_levels")
1809
+ form["version"] = copy.deepcopy(value.get("version") or "0")
1810
+ if "game_type" in form:
1811
+ form["game_type"] = _remote_int(form["game_type"], ("game_type", "id", "value"), "wa.game_type")
1812
+ if "display_imgs" in form:
1813
+ form["display_imgs"] = _remote_urls(form["display_imgs"], "wa.display_imgs")
1814
+ if "category_ids" in form:
1815
+ form["category_ids"] = _remote_strings(
1816
+ form["category_ids"], ("c_id", "category_id", "id", "value"),
1817
+ "wa.category_ids", allow_int=True,
1818
+ )
1819
+ normalize_commercial(form, "wa")
1820
+ return form
1821
+
1822
+
1823
+ def validate_commercial_submission(form: Mapping[str, Any], resource: str) -> None:
1824
+ if form.get("scope") == "private" and not form.get("share_code_life_type"):
1825
+ if resource != "config" or not form.get("need_buy"):
1826
+ raise ValidationError("private publication requires share_code_life_type", path="$.share_code_life_type")
1827
+ if form.get("need_buy"):
1828
+ if form.get("price_fen") is None:
1829
+ raise ValidationError("paid publication requires price_fen", path="$.price_fen")
1830
+ price = int(form.get("price_fen") or 0)
1831
+ if price != 0 and not 10 <= price <= 20000:
1832
+ raise ValidationError("price_fen must be zero or between 10 and 20000", path="$.price_fen")
1833
+ if not form.get("buy_life_type"):
1834
+ raise ValidationError("paid publication requires buy_life_type", path="$.buy_life_type")
1835
+ if form.get("jump_room") and not form.get("room_id"):
1836
+ raise ValidationError("room association requires room_id", path="$.room_id")
1837
+ if not form.get("creation_statement"):
1838
+ raise ValidationError("creation_statement is required by the DD editor", path="$.creation_statement")
1839
+ if form.get("with_associate") and not form.get("associated_acts"):
1840
+ raise ValidationError("content association requires associated_acts", path="$.associated_acts")
1841
+
1842
+
1843
+ def validate_plugin_submission(form: Mapping[str, Any], doc: Mapping[str, Any]) -> None:
1844
+ required = (
1845
+ "game_versions", "name", "description", "primary_category_id", "release_type",
1846
+ "version", "html_desc", "update_desc",
1847
+ )
1848
+ for name in required:
1849
+ if not form.get(name):
1850
+ raise ValidationError("field is required by the DD plugin editor", path="$.%s" % name)
1851
+ if not (form.get("logo") or doc.get("logo_file")):
1852
+ raise ValidationError("plugin logo is required", path="$.logo")
1853
+ if not ((form.get("detail_imgs") or []) or doc.get("detail_img_files")):
1854
+ raise ValidationError("at least one plugin detail image is required", path="$.detail_imgs")
1855
+ if not (form.get("detail_url") or doc.get("file")):
1856
+ raise ValidationError("plugin archive is required", path="$.file")
1857
+ validate_commercial_submission(form, "plugin")
1858
+
1859
+
1860
+ def validate_config_submission(form: Mapping[str, Any], doc: Mapping[str, Any]) -> None:
1861
+ for name in ("backup_sn", "title", "brief_desc", "desc"):
1862
+ if not form.get(name):
1863
+ raise ValidationError("field is required by the DD configuration editor", path="$.%s" % name)
1864
+ if not ((form.get("display_imgs") or []) or doc.get("display_img_files")):
1865
+ raise ValidationError("at least one configuration display image is required", path="$.display_imgs")
1866
+ validate_commercial_submission(form, "config")
1867
+
1868
+
1869
+ def validate_wa_submission(form: Mapping[str, Any], doc: Mapping[str, Any]) -> None:
1870
+ for name in ("name", "game_version", "brief_desc", "category_ids", "content", "desc", "update_desc"):
1871
+ if not form.get(name):
1872
+ raise ValidationError("field is required by the DD WA editor", path="$.%s" % name)
1873
+ if not ((form.get("display_imgs") or []) or doc.get("display_img_files")):
1874
+ raise ValidationError("at least one WA display image is required", path="$.display_imgs")
1875
+ validate_commercial_submission(form, "wa")
1876
+
1877
+
1878
+ class DD:
1879
+ platform = "dd"
1880
+
1881
+ @staticmethod
1882
+ def _fresh_detail(session: Sidecar, resource: str, reference: str) -> Dict[str, Any]:
1883
+ endpoint = {"plugin": "/addon/detail_v2", "config": "/share/detail", "wa": "/wa/detail"}[resource]
1884
+ for attempt in range(2):
1885
+ current = detail(session, resource, reference)
1886
+ if current.get("is_owner") is False:
1887
+ raise FuploadError(
1888
+ "DD target is not owned by the current author",
1889
+ kind="ownership_error", stage="dependency_get", endpoint=endpoint,
1890
+ )
1891
+ game_type = current.get("game_type") or (current.get("game_types") or [None])[0]
1892
+ name = str(current.get("name") or current.get("title") or "")
1893
+ listing = author_item(session, resource, reference, name, game_type)
1894
+ # DD detail and author-list timestamps come from independent read
1895
+ # models. Detail is authoritative for the form; list only
1896
+ # cross-checks ownership when detail omits is_owner.
1897
+ if current.get("is_owner") is True or listing:
1898
+ return current
1899
+ if attempt == 0:
1900
+ time.sleep(1)
1901
+ raise FuploadError(
1902
+ "DD target ownership could not be verified from detail or author list",
1903
+ kind="ownership_error",
1904
+ stage="dependency_get",
1905
+ endpoint=endpoint,
1906
+ )
1907
+
1908
+ @staticmethod
1909
+ def _archive(path: str, suffixes: Sequence[str], limit: Optional[int] = None) -> None:
1910
+ source = Path(path)
1911
+ if source.suffix.lower() not in suffixes:
1912
+ raise ValidationError("file extension is not supported", path="$.file")
1913
+ if limit is not None and source.stat().st_size > limit:
1914
+ raise ValidationError("file exceeds the platform limit", path="$.file")
1915
+
1916
+ @staticmethod
1917
+ def _validate_choices(payload: Any, selected: Sequence[Any], keys: Sequence[str], path: str) -> None:
1918
+ available = _option_values(payload, keys)
1919
+ values = [value for value in selected if value not in (None, "")]
1920
+ if values and not available:
1921
+ raise FuploadError("live option response contained no selectable values", kind="platform_data_error")
1922
+ if available and any(str(value) not in available for value in values):
1923
+ raise ValidationError("selection contains an unavailable live option", path=path)
1924
+
1925
+ def _validate_options(self, session: Sidecar, resource: str, form: Mapping[str, Any]) -> None:
1926
+ game_type = form.get("game_type")
1927
+ if resource == "plugin":
1928
+ versions = session.get("/game_versions/list", {"game_type": game_type})
1929
+ self._validate_choices(versions, form.get("game_versions") or [], ("game_version", "version", "value"), "$.game_versions")
1930
+ categories = session.get("/addon/category", {})
1931
+ tree = _addon_category_tree(categories)
1932
+ if not tree:
1933
+ raise FuploadError("live plugin category response contained no selectable values", kind="platform_data_error")
1934
+ primary = str(form.get("primary_category_id") or "")
1935
+ if primary not in tree:
1936
+ raise ValidationError("primary_category_id must be a live top-level category", path="$.primary_category_id")
1937
+ selected_children = {str(value) for value in form.get("second_category_ids") or []}
1938
+ if selected_children - tree[primary]:
1939
+ raise ValidationError("second_category_ids must belong to primary_category_id", path="$.second_category_ids")
1940
+ if tree[primary] and not selected_children:
1941
+ raise ValidationError("select at least one child category", path="$.second_category_ids")
1942
+ elif resource == "wa":
1943
+ versions = session.get("/game_versions/list", {"game_type": game_type})
1944
+ self._validate_choices(versions, [form.get("game_version")], ("game_version", "version", "value"), "$.game_version")
1945
+ categories = session.get("/wa/categories", {"game_type": game_type})
1946
+ available_categories = _wa_category_values(categories)
1947
+ selected_categories = {str(value) for value in (form.get("category_ids") or []) if str(value) != "ui_original"}
1948
+ if selected_categories and not available_categories:
1949
+ raise FuploadError("live WA category response contained no selectable values", kind="platform_data_error")
1950
+ if selected_categories - available_categories:
1951
+ raise ValidationError("category_ids contains an unavailable live option", path="$.category_ids")
1952
+
1953
+ if form.get("buy_life_type"):
1954
+ self._validate_choices(LIFE_TYPES, [form.get("buy_life_type")], ("value",), "$.buy_life_type")
1955
+ if form.get("share_code_life_type"):
1956
+ self._validate_choices(LIFE_TYPES, [form.get("share_code_life_type")], ("value",), "$.share_code_life_type")
1957
+
1958
+ if form.get("need_anchor_vip"):
1959
+ vip_levels = session.get("/anchor_vip/level/list", {"enrich_acts": "false"})
1960
+ self._validate_choices(vip_levels, form.get("vip_levels") or [], ("id", "level", "value"), "$.vip_levels")
1961
+
1962
+ if form.get("jump_room"):
1963
+ channels = safe_channels(session.cc_get("https://api.cc.163.com/v1/mixteammsgproxy/channelList?source=pluginPublish"))
1964
+ if not channels["items"]:
1965
+ raise FuploadError("live channel response contained no selectable values", kind="platform_data_error")
1966
+ wanted = (str(form.get("room_id")), str(form.get("channel_id")), str(form.get("channel_type")))
1967
+ available = {(str(item["room_id"]), str(item["channel_id"]), str(item["channel_type"])) for item in channels["items"]}
1968
+ if wanted not in available:
1969
+ raise ValidationError("room/channel selection is unavailable", path="$.channel_id")
1970
+
1971
+ if form.get("with_associate"):
1972
+ references = self._associated_refs(session, game_type)
1973
+ if not references:
1974
+ raise FuploadError("live association response contained no selectable values", kind="platform_data_error")
1975
+ for item in form.get("associated_acts") or []:
1976
+ reference = (str(item.get("act_type")), str(item.get("sn"))) if isinstance(item, dict) else ("", str(item))
1977
+ if reference not in references:
1978
+ raise ValidationError("associated_acts contains an unavailable author item", path="$.associated_acts")
1979
+
1980
+ game_types = session.get("/game_type/list", {})
1981
+ self._validate_choices(game_types, [game_type], ("game_type",), "$.game_type")
1982
+
1983
+ @staticmethod
1984
+ def _associated_refs(session: Sidecar, game_type: Any) -> set[Tuple[str, str]]:
1985
+ payloads = (
1986
+ ("addon", _author_items(session, "plugin", "", game_type)),
1987
+ ("share", _author_items(session, "config", "", game_type)),
1988
+ ("wa", _author_items(session, "wa", "", game_type)),
1989
+ )
1990
+ references: set[Tuple[str, str]] = set()
1991
+ for kind, source_items in payloads:
1992
+ for item in source_items:
1993
+ reference = item.get("sn") or item.get("share_sn")
1994
+ if reference:
1995
+ references.add((kind, str(reference)))
1996
+ return references
1997
+
1998
+ def execute_write(self, resource: str, action: str, doc: Dict[str, Any], session_id: Optional[str] = None) -> Any:
1999
+ if not session_id:
2000
+ raise FuploadError("DD live writes require --session from `dd session start`", kind="session_required", stage="session")
2001
+ from .dd_broker import execute
2002
+
2003
+ return execute(session_id, "write", resource, action, doc)
2004
+
2005
+ def execute_write_on(self, session: Sidecar, resource: str, action: str, doc: Dict[str, Any]) -> Any:
2006
+ if action == "delete" and resource in ("plugin", "config", "wa"):
2007
+ return self._delete(session, resource, doc)
2008
+ if resource == "plugin":
2009
+ return self._write_plugin(session, action, doc)
2010
+ if resource == "config":
2011
+ return self._write_config(session, action, doc)
2012
+ if resource == "wa":
2013
+ return self._write_wa(session, action, doc)
2014
+ raise FuploadError("unsupported DD write operation", kind="unsupported_operation")
2015
+
2016
+ def _delete(self, session: Sidecar, resource: str, doc: Mapping[str, Any]) -> Dict[str, Any]:
2017
+ reference = str(doc["sn"])
2018
+ before = self._fresh_detail(session, resource, reference)
2019
+ endpoints = {"plugin": "/addon/delete", "config": "/share/delete", "wa": "/wa/delete"}
2020
+ game_type = before.get("game_type") or (before.get("game_types") or [None])[0]
2021
+ if not game_type and resource == "config" and before.get("backup_sn"):
2022
+ backup = result(session.get("/backup/detail", {"sn": before["backup_sn"]}))
2023
+ if isinstance(backup, dict):
2024
+ game_type = backup.get("game_type")
2025
+ if not game_type:
2026
+ raise FuploadError(
2027
+ "DD target game type could not be resolved before delete",
2028
+ kind="dependency_error",
2029
+ stage="dependency_get",
2030
+ )
2031
+ response = session.post(endpoints[resource], {"sn": reference})
2032
+ name = str(before.get("name") or before.get("title") or "")
2033
+ remaining = _readback(
2034
+ lambda: author_item(session, resource, reference, name, game_type), endpoints[resource]
2035
+ )
2036
+ if remaining:
2037
+ raise FuploadError(
2038
+ "delete response succeeded but the target remains in the author list",
2039
+ kind="verification_required", stage="readback", endpoint=endpoints[resource], verification_required=True,
2040
+ )
2041
+ return {"result": response, "deleted": True, "sn": reference, "before": safe_detail(resource, before), "readback": {"present": False}}
2042
+
2043
+ def _write_plugin(self, session: Sidecar, action: str, doc: Dict[str, Any]) -> Any:
2044
+ if action == "create":
2045
+ current: Dict[str, Any] = {}
2046
+ form = copy.deepcopy(PLUGIN_CREATE_DEFAULTS)
2047
+ apply_present(form, doc, PLUGIN_FIELDS)
2048
+ else:
2049
+ current = self._fresh_detail(session, "plugin", doc["sn"])
2050
+ game_type = current.get("game_type") or (current.get("game_types") or [None])[0]
2051
+ listing = author_item(
2052
+ session, "plugin", str(doc["sn"]), str(current.get("name") or ""), game_type,
2053
+ )
2054
+ form = plugin_form(current, listing)
2055
+ allowed = PLUGIN_EDIT_FIELDS if action == "edit" else ("game_versions", "detail_url", "release_type", "version", "update_desc")
2056
+ current_version = str(form.get("version") or "").strip()
2057
+ apply_present(form, doc, allowed)
2058
+ if current.get("assign_user_sn") and form.get("scope", "public") != "public":
2059
+ raise ValidationError("assigned plugins can only use public scope", path="$.scope")
2060
+ if "game_type" in doc and str(doc["game_type"]) != str(current.get("game_type") or (current.get("game_types") or [None])[0]):
2061
+ raise ValidationError("game_type is locked after plugin creation", path="$.game_type")
2062
+ form["sn"] = doc["sn"]
2063
+ if action == "update" and current_version and str(form.get("version") or "").strip().casefold() == current_version.casefold():
2064
+ raise ValidationError("version already exists; overwrite is not allowed", path="$.version")
2065
+ if action == "update":
2066
+ try:
2067
+ history_versions = load_plugin_history_versions(
2068
+ session, str(doc["sn"]), form.get("game_type"),
2069
+ )
2070
+ except FuploadError:
2071
+ history_versions = set()
2072
+ candidate = str(form.get("version") or "").strip().casefold()
2073
+ if candidate and candidate in history_versions:
2074
+ raise ValidationError("version already exists; overwrite is not allowed", path="$.version")
2075
+ if doc.get("file"):
2076
+ self._archive(doc["file"], (".zip",))
2077
+ validate_locked_usage_mode(current, form, doc)
2078
+ normalize_commercial(form, "plugin", create=action == "create")
2079
+ validate_plugin_submission(form, doc)
2080
+ self._validate_options(session, "plugin", form)
2081
+ validate_no_display_objects(form, "plugin")
2082
+ if doc.get("logo_file"):
2083
+ form["logo"] = session.upload(doc["logo_file"], "addon", file_name="", media=True, max_bytes=10 * 1024 * 1024)
2084
+ if doc.get("detail_img_files"):
2085
+ existing_images = list(form.get("detail_imgs") or [])
2086
+ if len(existing_images) + len(doc["detail_img_files"]) > 8:
2087
+ raise ValidationError("plugin detail images cannot exceed eight", path="$.detail_img_files")
2088
+ form["detail_imgs"] = existing_images + [session.upload(path, "addon", file_name="", media=True, max_bytes=10 * 1024 * 1024) for path in doc["detail_img_files"]]
2089
+ if doc.get("file"):
2090
+ form["detail_url"] = session.upload(doc["file"], "addon", file_name="addon.zip")
2091
+ endpoint = "/addon/create" if action == "create" else "/addon/modify"
2092
+ response = session.post(endpoint, form)
2093
+ reference = response_reference(response, "sn") or str(form.get("sn") or "")
2094
+ if action == "create" and not reference:
2095
+ reference = created_reference(session, "plugin", str(form.get("name") or ""), form.get("game_type"))
2096
+ if action == "create" and not reference:
2097
+ raise FuploadError(
2098
+ "plugin was submitted but its reference could not be resolved; read the author list before retrying",
2099
+ kind="verification_required", stage="readback", verification_required=True,
2100
+ )
2101
+ stable_fields = (
2102
+ "game_type", "scope", "addon_type", "name", "description", "logo", "detail_imgs",
2103
+ "primary_category_id", "second_category_ids", "html_desc", "update_desc", "share_code_life_type",
2104
+ "creation_statement", "need_buy",
2105
+ "price_fen", "buy_life_type", "jump_room", "room_id", "channel_id", "channel_type",
2106
+ "sync_room", "with_associate", "associated_acts", "need_anchor_vip", "vip_levels",
2107
+ )
2108
+ if action != "update":
2109
+ raw_readback: Any = {}
2110
+ actual: Mapping[str, Any] = {}
2111
+ unresolved: set[str] = set()
2112
+ author = {}
2113
+ for attempt in range(6):
2114
+ raw_readback = _readback(
2115
+ lambda: detail(session, "plugin", reference), "/addon/detail_v2"
2116
+ )
2117
+ actual = plugin_form(raw_readback)
2118
+ author = author_item(
2119
+ session, "plugin", reference, str(form.get("name") or ""), form.get("game_type")
2120
+ )
2121
+ author_actual = plugin_form(author) if author else {}
2122
+ detail_mismatches = {
2123
+ name for name in stable_fields
2124
+ if name in form and (name not in actual or not _same_readback(form[name], actual[name]))
2125
+ }
2126
+ author_mismatches = {
2127
+ name for name in stable_fields
2128
+ if name in form and (name not in author_actual or not _same_readback(form[name], author_actual[name]))
2129
+ }
2130
+ unresolved = detail_mismatches & author_mismatches
2131
+ if not unresolved:
2132
+ break
2133
+ if attempt < 5:
2134
+ time.sleep(1)
2135
+ if unresolved:
2136
+ raise FuploadError(
2137
+ "write readback did not match field(s): %s" % ", ".join(sorted(unresolved)),
2138
+ kind="verification_required",
2139
+ stage="readback",
2140
+ endpoint="/addon/detail_v2",
2141
+ verification_required=True,
2142
+ details={
2143
+ "fields": sorted(unresolved),
2144
+ "projections": {
2145
+ "detail_v2": "mismatch",
2146
+ "author_list": "mismatch" if author else "missing",
2147
+ },
2148
+ },
2149
+ )
2150
+ else:
2151
+ raw_readback = _readback(
2152
+ lambda: detail(session, "plugin", reference), "/addon/detail_v2"
2153
+ ) if reference else {}
2154
+ actual = plugin_form(raw_readback) if raw_readback else {}
2155
+ if action in ("create", "update"):
2156
+ # addon_versions can remain empty for private plugins, so version
2157
+ # confirmation uses the two projections the official author UI exposes.
2158
+ # Those projections can lag an accepted mutation briefly, so poll
2159
+ # with GET-only reads and never replay the mutation.
2160
+ update_fields = ("game_versions", "detail_url", "release_type", "version", "update_desc")
2161
+ detail_mismatches: List[str] = []
2162
+ author_mismatches: List[str] = []
2163
+ author: Mapping[str, Any] = {}
2164
+ for attempt in range(6):
2165
+ if attempt:
2166
+ raw_readback = _readback(
2167
+ lambda: detail(session, "plugin", reference), "/addon/detail_v2"
2168
+ )
2169
+ detail_version = plugin_version_projection(raw_readback)
2170
+ detail_mismatches = [
2171
+ name for name in update_fields
2172
+ if name in form and (name not in detail_version or not _same_readback(form[name], detail_version[name]))
2173
+ ]
2174
+ author = author_item(
2175
+ session, "plugin", reference, str(form.get("name") or ""), form.get("game_type")
2176
+ )
2177
+ author_actual = plugin_version_projection(author) if author else {}
2178
+ author_mismatches = [
2179
+ name for name in update_fields
2180
+ if name in form and (name not in author_actual or not _same_readback(form[name], author_actual[name]))
2181
+ ]
2182
+ if not (detail_mismatches and author_mismatches):
2183
+ break
2184
+ if attempt < 5:
2185
+ time.sleep(1)
2186
+ if detail_mismatches and author_mismatches:
2187
+ raise FuploadError(
2188
+ "submitted plugin version is not visible in official readback projections",
2189
+ kind="verification_required",
2190
+ stage="readback",
2191
+ endpoint="/addon/detail_v2",
2192
+ verification_required=True,
2193
+ details={
2194
+ "fields": sorted(set(detail_mismatches) | set(author_mismatches)),
2195
+ "projections": {
2196
+ "detail_v2": "mismatch",
2197
+ "author_list": "mismatch" if author else "missing",
2198
+ },
2199
+ },
2200
+ )
2201
+ return {"result": response, "reference": reference, "readback": safe_detail("plugin", raw_readback) if reference else None}
2202
+
2203
+ def _backup(self, session: Sidecar, backup_sn: str) -> Dict[str, Any]:
2204
+ listing = session.get("/backup/list", {})
2205
+ if backup_sn and not any(str(x.get("sn") or x.get("backup_sn")) == str(backup_sn) for x in items(listing)):
2206
+ raise ValidationError("backup_sn is not present in the current DD backup list", path="$.backup_sn")
2207
+ payload = session.get("/backup/detail", {"sn": backup_sn})
2208
+ value = result(payload)
2209
+ if not isinstance(value, dict):
2210
+ raise FuploadError("DD backup detail was not found", kind="not_found")
2211
+ return value
2212
+
2213
+ def _write_config(self, session: Sidecar, action: str, doc: Dict[str, Any]) -> Any:
2214
+ backup_changed = False
2215
+ if action == "create":
2216
+ current: Dict[str, Any] = {}
2217
+ backup_sn = doc["backup_sn"]
2218
+ backup_changed = True
2219
+ else:
2220
+ current = self._fresh_detail(session, "config", doc["share_sn"])
2221
+ if not current.get("backup_sn"):
2222
+ time.sleep(1)
2223
+ current = detail(session, "config", doc["share_sn"])
2224
+ backup_sn = str(doc.get("backup_sn") or current.get("backup_sn") or "")
2225
+ if backup_sn != str(current.get("backup_sn") or ""):
2226
+ backup_changed = True
2227
+ required = ("known_addon_ids", "unknown_addon_ids", "wtf_role_ids", "material_names", "font_names", "known_wa_ids", "unknown_wa_ids")
2228
+ missing = [name for name in required if name not in doc]
2229
+ if missing:
2230
+ raise ValidationError("changing backup_sn requires complete reselection", path="$.%s" % missing[0])
2231
+ backup = self._backup(session, backup_sn)
2232
+ form = config_form(current, backup, doc)
2233
+ form["backup_sn"] = backup_sn
2234
+ game_type = int(backup.get("game_type") or 0)
2235
+ if game_type == 10001 and backup_changed and "retail_ui_config" not in doc:
2236
+ raise ValidationError("retail_ui_config must be explicitly selected for a retail backup", path="$.retail_ui_config")
2237
+ if "retail_ui_config" in doc:
2238
+ retail_current = {} if backup_changed else current
2239
+ form["retail_ui_config"] = resolve_retail_ui_config(backup, retail_current, doc["retail_ui_config"])
2240
+ elif game_type != 10001:
2241
+ form.pop("retail_ui_config", None)
2242
+ if action != "create":
2243
+ form["share_sn"] = doc["share_sn"]
2244
+ validation_form = dict(form)
2245
+ validation_form["game_type"] = current.get("game_type") or backup.get("game_type")
2246
+ validate_config_submission(form, doc)
2247
+ self._validate_options(session, "config", validation_form)
2248
+ validate_no_display_objects(form, "config")
2249
+ if doc.get("display_img_files"):
2250
+ existing_images = list(form.get("display_imgs") or [])
2251
+ if len(existing_images) + len(doc["display_img_files"]) > 8:
2252
+ raise ValidationError("configuration display images cannot exceed eight", path="$.display_img_files")
2253
+ form["display_imgs"] = existing_images + [session.upload(path, "share", media=True, max_bytes=10 * 1024 * 1024) for path in doc["display_img_files"]]
2254
+ endpoint = "/share/create" if action == "create" else "/share/modify"
2255
+ response = session.post(endpoint, form)
2256
+ reference = response_reference(response, "share_sn", "sn") or str(form.get("share_sn") or "")
2257
+ if action == "create" and not reference:
2258
+ reference = created_reference(session, "config", str(form.get("title") or ""), validation_form.get("game_type"))
2259
+ if action == "create" and not reference:
2260
+ raise FuploadError(
2261
+ "configuration was submitted but its reference could not be resolved; read the author list before retrying",
2262
+ kind="verification_required", stage="readback", verification_required=True,
2263
+ )
2264
+ stable_fields = (
2265
+ "backup_sn", "scope", "title", "brief_desc", "desc", "update_desc", "display_imgs",
2266
+ "share_code_life_type", "creation_statement", "need_buy", "price_fen", "buy_life_type",
2267
+ "jump_room", "room_id", "channel_id", "channel_type", "sync_room", "with_associate",
2268
+ "associated_acts", "need_anchor_vip", "vip_levels", "known_addon", "unknown_addon", "wtf",
2269
+ "material", "font", "known_wa", "unknown_wa", "retail_ui_config",
2270
+ )
2271
+ raw_readback, _actual = _readback_until_fields(
2272
+ lambda: detail(session, "config", reference),
2273
+ config_readback_projection,
2274
+ form,
2275
+ stable_fields,
2276
+ "/share/detail",
2277
+ )
2278
+ return {"result": response, "reference": reference, "readback": safe_detail("config", raw_readback) if reference else None}
2279
+
2280
+ def _write_wa(self, session: Sidecar, action: str, doc: Dict[str, Any]) -> Any:
2281
+ if action == "create":
2282
+ current: Dict[str, Any] = {}
2283
+ form = copy.deepcopy(WA_CREATE_DEFAULTS)
2284
+ apply_present(form, doc, WA_FIELDS)
2285
+ else:
2286
+ current = self._fresh_detail(session, "wa", doc["sn"])
2287
+ form = wa_form(current)
2288
+ allowed = WA_FIELDS if action == "edit" else ("content", "update_desc", "version", "with_file", "file_path", "file_install_path", "parse_wa_uid", "parse_wa_id")
2289
+ apply_present(form, doc, allowed)
2290
+ if current.get("assign_user_sn") and form.get("scope", "public") != "public":
2291
+ raise ValidationError("assigned WA records can only use public scope", path="$.scope")
2292
+ if "game_type" in doc and str(doc["game_type"]) != str(current.get("game_type")):
2293
+ raise ValidationError("game_type is locked after WA creation", path="$.game_type")
2294
+ form["sn"] = doc["sn"]
2295
+ if action == "update" and not version_greater(form.get("version"), current.get("version")):
2296
+ raise ValidationError("version must be greater than the current version", path="$.version")
2297
+ if doc.get("file"):
2298
+ self._archive(doc["file"], (".zip",), 50 * 1024 * 1024)
2299
+ form["with_file"] = True
2300
+ if form.get("with_file") and not (doc.get("file") or form.get("file_path")):
2301
+ raise ValidationError("with_file=true requires an existing or new WA material ZIP", path="$.file")
2302
+ if form.get("with_file") and not form.get("file_install_path"):
2303
+ raise ValidationError("with_file=true requires file_install_path", path="$.file_install_path")
2304
+ content = str(form.get("content") or "")
2305
+ if not content.startswith("!WA:2!"):
2306
+ form["parse_wa_uid"] = ""
2307
+ form["parse_wa_id"] = ""
2308
+ else:
2309
+ parsed = session.call("parse_wa", content=content)
2310
+ if not isinstance(parsed, dict) or not parsed.get("parse_wa_uid") or not parsed.get("parse_wa_id"):
2311
+ raise FuploadError(
2312
+ "DD native WA parser did not return parse identifiers",
2313
+ kind="native_parser_error",
2314
+ stage="native_parser",
2315
+ )
2316
+ form["parse_wa_uid"] = parsed["parse_wa_uid"]
2317
+ form["parse_wa_id"] = parsed["parse_wa_id"]
2318
+ validate_locked_usage_mode(current, form, doc)
2319
+ normalize_commercial(form, "wa", create=action == "create")
2320
+ form["category_ids"] = [str(category_id) for category_id in (form.get("category_ids") or [])]
2321
+ validate_wa_submission(form, doc)
2322
+ self._validate_options(session, "wa", form)
2323
+ validate_no_display_objects(form, "wa")
2324
+ if doc.get("display_img_files"):
2325
+ existing_images = list(form.get("display_imgs") or [])
2326
+ if len(existing_images) + len(doc["display_img_files"]) > 8:
2327
+ raise ValidationError("WA display images cannot exceed eight", path="$.display_img_files")
2328
+ form["display_imgs"] = existing_images + [session.upload(path, "wa", media=True, max_bytes=10 * 1024 * 1024) for path in doc["display_img_files"]]
2329
+ if doc.get("file"):
2330
+ form["file_path"] = session.upload(doc["file"], "wa", file_name="wa_materials.zip", max_bytes=50 * 1024 * 1024)
2331
+ endpoint = "/wa/create" if action == "create" else "/wa/modify"
2332
+ response = session.post(endpoint, form)
2333
+ reference = response_reference(response, "sn") or str(form.get("sn") or "")
2334
+ if action == "create" and not reference:
2335
+ reference = created_reference(session, "wa", str(form.get("name") or ""), form.get("game_type"))
2336
+ if action == "create" and not reference:
2337
+ raise FuploadError(
2338
+ "WA was submitted but its reference could not be resolved; read the author list before retrying",
2339
+ kind="verification_required", stage="readback", verification_required=True,
2340
+ )
2341
+ stable_fields = (
2342
+ "game_type", "scope", "name", "game_version", "brief_desc", "display_imgs", "category_ids",
2343
+ "content", "desc", "update_desc", "version", "with_file", "file_path", "file_install_path",
2344
+ "parse_wa_uid", "parse_wa_id", "share_code_life_type", "creation_statement", "need_buy",
2345
+ "price_fen", "buy_life_type", "jump_room", "room_id", "channel_id", "channel_type",
2346
+ "sync_room", "with_associate", "associated_acts", "need_anchor_vip", "vip_levels",
2347
+ )
2348
+ raw_readback, _actual = _readback_until_fields(
2349
+ lambda: detail(session, "wa", reference),
2350
+ lambda value: value,
2351
+ form,
2352
+ stable_fields,
2353
+ "/wa/detail",
2354
+ )
2355
+ readback = safe_detail("wa", raw_readback) if reference else None
2356
+ return {"result": response, "reference": reference, "readback": readback}
2357
+
2358
+ def execute_read(self, resource: str, action: str, args: Any, session_id: Optional[str] = None) -> Any:
2359
+ if resource == "session":
2360
+ from . import dd_broker
2361
+
2362
+ if action == "doctor":
2363
+ return dd_broker.doctor()
2364
+ if action == "start":
2365
+ return dd_broker.start(bool(getattr(args, "confirm_close_gui", False)))
2366
+ if action == "status":
2367
+ return dd_broker.status(session_id)
2368
+ if action == "stop":
2369
+ if not session_id:
2370
+ raise FuploadError("dd session stop requires --session", kind="session_required", stage="session")
2371
+ return dd_broker.stop(session_id)
2372
+ if not session_id:
2373
+ raise FuploadError("DD live reads require --session from `dd session start`", kind="session_required", stage="session")
2374
+ from .dd_broker import execute
2375
+
2376
+ payload = {
2377
+ key: value for key, value in vars(args).items()
2378
+ if key not in {"handler", "platform", "resource", "action", "input", "dry_run", "session"}
2379
+ }
2380
+ return execute(session_id, "read", resource, action, payload)
2381
+
2382
+ def execute_read_on(self, session: Sidecar, resource: str, action: str, args: Any) -> Any:
2383
+ if resource == "plugin":
2384
+ if action == "list": return readable_author_list(session, "plugin", args.keyword, args.game_type, args.page, args.page_size)
2385
+ if action == "get": return safe_detail("plugin", detail(session, "plugin", args.sn))
2386
+ if action == "categories": return safe_plugin_categories(session.get("/addon/category", {}))
2387
+ if action == "game-versions": return safe_game_versions(session.get("/game_versions/list", {"game_type": args.game_type}), args.game_type)
2388
+ if action == "versions": return session.get("/addon/addon_versions", {"sn": args.sn, "game_type": args.game_type, "page": args.page})
2389
+ if resource == "config":
2390
+ if action == "list": return readable_author_list(session, "config", args.keyword, args.game_type, args.page, args.page_size)
2391
+ if action == "get": return safe_detail("config", detail(session, "config", args.sn))
2392
+ if action == "backups": return safe_backup_list(session.get("/backup/list", {}))
2393
+ if action == "backup-get":
2394
+ payload = session.get("/backup/detail", {"sn": args.sn})
2395
+ return safe_backup_detail(result(payload))
2396
+ if resource == "wa":
2397
+ if action == "list": return readable_author_list(session, "wa", args.keyword, args.game_type, args.page, args.page_size)
2398
+ if action == "get": return safe_detail("wa", detail(session, "wa", args.sn))
2399
+ if action == "categories": return safe_wa_categories(session.get("/wa/categories", {"game_type": args.game_type}), args.game_type)
2400
+ if resource == "options":
2401
+ if action == "game-types": return safe_game_types(session.get("/game_type/list", {}))
2402
+ if action == "channels": return safe_channels(session.cc_get("https://api.cc.163.com/v1/mixteammsgproxy/channelList?" + urllib.parse.urlencode({"source": "pluginPublish"})))
2403
+ if action == "life-types": return {"total": len(LIFE_TYPES), "items": list(LIFE_TYPES), "source": "NetEase DD official client enum"}
2404
+ if action == "vip-levels": return session.get("/anchor_vip/level/list", {"enrich_acts": "false"})
2405
+ if action == "associated-acts": return safe_associated_acts(session, args.game_type)
2406
+ raise FuploadError("unsupported DD read operation", kind="unsupported_operation")