@follenfang/fupload 0.0.0-bootstrap.0 → 0.0.1
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.
- package/README.md +208 -3
- package/fupload/SKILL.md +129 -0
- package/fupload/agents/openai.yaml +4 -0
- package/fupload/examples/dd-config-delete.json +5 -0
- package/fupload/examples/dd-config-update.json +25 -0
- package/fupload/examples/dd-plugin-delete.json +5 -0
- package/fupload/examples/dd-plugin-update.json +9 -0
- package/fupload/examples/dd-wa-delete.json +5 -0
- package/fupload/examples/dd-wa-edit.json +9 -0
- package/fupload/examples/newbee-config-delete.json +5 -0
- package/fupload/examples/newbee-config-update.json +10 -0
- package/fupload/examples/newbee-plugin-create.json +14 -0
- package/fupload/examples/newbee-plugin-delete.json +5 -0
- package/fupload/examples/newbee-wa-delete.json +5 -0
- package/fupload/examples/newbee-wa-update.json +8 -0
- package/fupload/references/dd.md +105 -0
- package/fupload/references/newbee-official-cli.md +288 -0
- package/fupload/references/newbee.md +80 -0
- package/fupload/references/workflow.md +67 -0
- package/fupload/scripts/fupload.py +17 -0
- package/fupload/scripts/fupload_cli/__init__.py +3 -0
- package/fupload/scripts/fupload_cli/cli.py +266 -0
- package/fupload/scripts/fupload_cli/dd.py +2406 -0
- package/fupload/scripts/fupload_cli/dd_broker.py +634 -0
- package/fupload/scripts/fupload_cli/dd_sidecar.py +860 -0
- package/fupload/scripts/fupload_cli/errors.py +94 -0
- package/fupload/scripts/fupload_cli/io.py +125 -0
- package/fupload/scripts/fupload_cli/newbee.py +1412 -0
- package/fupload/scripts/fupload_cli/newbee_auth.py +135 -0
- package/fupload/scripts/fupload_cli/schema.py +539 -0
- package/fupload/scripts/fupload_cli/transport.py +125 -0
- package/fupload/scripts/fupload_cli/trust.py +207 -0
- package/npm/bin/fupload.mjs +90 -0
- package/npm/lib/managed-install.mjs +86 -0
- package/npm/lib/options.mjs +38 -0
- package/npm/lib/python.mjs +45 -0
- package/npm/lib/skill-installer.mjs +228 -0
- package/npm/lib/uninstall.mjs +211 -0
- package/npm/lib/update.mjs +99 -0
- package/npm/lib/versions.mjs +63 -0
- package/npm/postinstall.mjs +18 -0
- package/npm/skill-manifest.json +164 -0
- 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")
|