@follenfang/fupload 0.0.0-bootstrap.0 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +236 -3
  2. package/fupload/SKILL.md +142 -0
  3. package/fupload/agents/openai.yaml +4 -0
  4. package/fupload/examples/curseforge-plugin-upload.json +21 -0
  5. package/fupload/examples/dd-config-delete.json +5 -0
  6. package/fupload/examples/dd-config-update.json +25 -0
  7. package/fupload/examples/dd-plugin-delete.json +5 -0
  8. package/fupload/examples/dd-plugin-update.json +9 -0
  9. package/fupload/examples/dd-wa-delete.json +5 -0
  10. package/fupload/examples/dd-wa-edit.json +9 -0
  11. package/fupload/examples/newbee-config-delete.json +5 -0
  12. package/fupload/examples/newbee-config-update.json +10 -0
  13. package/fupload/examples/newbee-plugin-create.json +14 -0
  14. package/fupload/examples/newbee-plugin-delete.json +5 -0
  15. package/fupload/examples/newbee-wa-delete.json +5 -0
  16. package/fupload/examples/newbee-wa-update.json +8 -0
  17. package/fupload/references/curseforge.md +233 -0
  18. package/fupload/references/dd.md +105 -0
  19. package/fupload/references/newbee-official-cli.md +288 -0
  20. package/fupload/references/newbee.md +80 -0
  21. package/fupload/references/workflow.md +67 -0
  22. package/fupload/scripts/fupload.py +17 -0
  23. package/fupload/scripts/fupload_cli/__init__.py +3 -0
  24. package/fupload/scripts/fupload_cli/cli.py +281 -0
  25. package/fupload/scripts/fupload_cli/curseforge.py +186 -0
  26. package/fupload/scripts/fupload_cli/dd.py +2406 -0
  27. package/fupload/scripts/fupload_cli/dd_broker.py +634 -0
  28. package/fupload/scripts/fupload_cli/dd_sidecar.py +860 -0
  29. package/fupload/scripts/fupload_cli/errors.py +94 -0
  30. package/fupload/scripts/fupload_cli/io.py +125 -0
  31. package/fupload/scripts/fupload_cli/newbee.py +1412 -0
  32. package/fupload/scripts/fupload_cli/newbee_auth.py +135 -0
  33. package/fupload/scripts/fupload_cli/schema.py +587 -0
  34. package/fupload/scripts/fupload_cli/transport.py +125 -0
  35. package/fupload/scripts/fupload_cli/trust.py +207 -0
  36. package/npm/bin/fupload.mjs +92 -0
  37. package/npm/lib/curseforge-config.mjs +36 -0
  38. package/npm/lib/managed-install.mjs +86 -0
  39. package/npm/lib/options.mjs +38 -0
  40. package/npm/lib/python.mjs +45 -0
  41. package/npm/lib/skill-installer.mjs +228 -0
  42. package/npm/lib/uninstall.mjs +211 -0
  43. package/npm/lib/update.mjs +102 -0
  44. package/npm/lib/versions.mjs +63 -0
  45. package/npm/postinstall.mjs +21 -0
  46. package/npm/skill-manifest.json +179 -0
  47. package/package.json +50 -6
@@ -0,0 +1,860 @@
1
+ """DD-native JSONL sidecar. Run only with the version-matched netease_dd.exe."""
2
+
3
+ from __future__ import print_function
4
+
5
+ import ctypes
6
+ import hashlib
7
+ import json
8
+ import logging
9
+ import os
10
+ import re
11
+ import sys
12
+ import time
13
+ import urllib.error
14
+ import urllib.request
15
+
16
+
17
+ DD_DIR = os.environ["NETEASE_DD_DIR"]
18
+ DEVICE_STATE = os.environ["FUPLOAD_DD_DEVICE_STATE"]
19
+ USER_AGENT = (
20
+ "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 "
21
+ "(KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36 app/df_client dfVersion/%s"
22
+ % os.path.basename(DD_DIR.rstrip("\\/"))
23
+ )
24
+ _SENSITIVE_NAME = (
25
+ r"(?:x-amz-(?:credential|signature|security-token)|access[_-]?token|refresh[_-]?token|"
26
+ r"resource[_-]?token|token|jwt(?:token)?|authorization|authentication|cookie|set-cookie|"
27
+ r"credential|client[_-]?(?:no|id)|clientno|clientid|device[_-]?(?:id|proof)|"
28
+ r"client[_-]?secret|api[_-]?key|auth[_-]?key|password|secret|signed[_-]?url|upload[_-]?url|"
29
+ r"presigned[_-]?(?:uri|url)|signature)"
30
+ )
31
+ _SENSITIVE_ERROR_VALUE = re.compile(
32
+ r'''(?i)((?:["']?''' + _SENSITIVE_NAME + r'''["']?)\s*(?:=|:)\s*["']?)[^"',}&\s]+'''
33
+ )
34
+ _BEARER_VALUE = re.compile(r"(?i)Bearer\s+[A-Za-z0-9._~+/=-]+")
35
+ _JWT_VALUE = re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+\b")
36
+ _WA_VALUE = re.compile(r"!WA:\d+![^\s\"']+")
37
+ _HTTP_STATUS = re.compile(r"\bHTTP\s+(\d{3})\b", re.IGNORECASE)
38
+ _SENSITIVE_KEYS = {
39
+ "token", "access_token", "refresh_token", "resource_token", "jwt", "jwttoken",
40
+ "cookie", "set-cookie", "authorization", "authentication", "credential",
41
+ "clientno", "client_no", "clientid", "client_id", "device_id", "device_proof", "signed_url", "upload_url",
42
+ "presigneduri", "presigned_uri", "signature", "client_secret", "api_key", "auth_key", "password", "secret",
43
+ }
44
+ _MAX_LOG_BODY = 1024 * 1024
45
+ _CONFIG_CONTENT_KEYS = {
46
+ "known_addon", "unknown_addon", "wtf", "material", "font",
47
+ "known_wa", "unknown_wa", "retail_ui_config",
48
+ }
49
+ _PRIVATE_BUSINESS_KEYS = _CONFIG_CONTENT_KEYS | {
50
+ "associated_acts", "brief_desc", "channel_id", "content", "cover",
51
+ "creation_statement", "desc", "description", "detail_imgs", "detail_url",
52
+ "display_imgs", "file_install_path", "file_path", "html_desc", "images",
53
+ "import_string", "logo", "name", "parse_wa_id", "parse_wa_uid", "room_id",
54
+ "share_sn", "sn", "t_wa_str", "title", "update_desc", "wa_str",
55
+ }
56
+ _SAFE_REQUEST_VALUE_KEYS = {
57
+ "act_type", "addon_type", "body_bytes", "body_sha256", "business_id",
58
+ "buy_life_type", "category_ids", "channel_type", "creation_statement",
59
+ "file_type", "game_type", "game_version", "game_versions", "headers",
60
+ "jump_room", "mime_type", "need_anchor_vip", "need_buy", "price_fen",
61
+ "primary_category_id", "release_type", "scope", "second_category_ids",
62
+ "share_code_life_type", "sync_room", "version", "vip_levels",
63
+ "with_associate", "with_file",
64
+ }
65
+
66
+
67
+ def safe_exception_message(exc):
68
+ value = _SENSITIVE_ERROR_VALUE.sub(r"\1[REDACTED]", str(exc).strip())
69
+ value = _BEARER_VALUE.sub("Bearer [REDACTED]", value)
70
+ value = _JWT_VALUE.sub("[REDACTED_JWT]", value)
71
+ return _WA_VALUE.sub("[REDACTED_WA]", value)
72
+
73
+
74
+ def _sanitize_log_value(value, key=None, depth=0):
75
+ if depth > 12:
76
+ return "[MAX_DEPTH]"
77
+ normalized_key = str(key or "").replace("-", "_").lower()
78
+ if normalized_key in _SENSITIVE_KEYS or any(
79
+ marker in normalized_key for marker in ("token", "cookie", "credential", "signature", "password", "secret")
80
+ ):
81
+ return "[REDACTED]"
82
+ if isinstance(value, dict):
83
+ return {
84
+ str(child_key): _sanitize_log_value(child_value, child_key, depth + 1)
85
+ for child_key, child_value in value.items()
86
+ }
87
+ if isinstance(value, (list, tuple)):
88
+ return [_sanitize_log_value(item, None, depth + 1) for item in value]
89
+ if isinstance(value, str):
90
+ return safe_exception_message(value)
91
+ if value is None or isinstance(value, (bool, int, float)):
92
+ return value
93
+ return safe_exception_message(str(value))
94
+
95
+
96
+ def _private_value_summary(value):
97
+ try:
98
+ encoded = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
99
+ except (TypeError, ValueError):
100
+ encoded = str(value).encode("utf-8", "replace")
101
+ return {
102
+ "redacted": True,
103
+ "type": type(value).__name__,
104
+ "bytes": len(encoded),
105
+ "sha256": hashlib.sha256(encoded).hexdigest(),
106
+ }
107
+
108
+
109
+ def _sanitize_business_payload(value, endpoint, direction, depth=0):
110
+ if depth > 12:
111
+ return "[MAX_DEPTH]"
112
+ if isinstance(value, dict):
113
+ result = {}
114
+ for key, item in value.items():
115
+ normalized = str(key).replace("-", "_").lower()
116
+ if direction == "request" and normalized in _SAFE_REQUEST_VALUE_KEYS:
117
+ result[str(key)] = _sanitize_log_value(item, key, depth + 1)
118
+ elif direction == "request" and normalized == "associated_acts":
119
+ result[str(key)] = _sanitize_business_payload(item, endpoint, direction, depth + 1)
120
+ elif normalized in _PRIVATE_BUSINESS_KEYS:
121
+ result[str(key)] = _private_value_summary(item)
122
+ elif endpoint == "/file/upload" and normalized in ("url", "d_url"):
123
+ result[str(key)] = _private_value_summary(item)
124
+ elif direction == "request" and isinstance(item, str):
125
+ result[str(key)] = _private_value_summary(item)
126
+ else:
127
+ result[str(key)] = _sanitize_business_payload(item, endpoint, direction, depth + 1)
128
+ return result
129
+ if isinstance(value, (list, tuple)):
130
+ return [_sanitize_business_payload(item, endpoint, direction, depth + 1) for item in value]
131
+ return value
132
+
133
+
134
+ def _request_shape(value, depth=0):
135
+ if depth > 12:
136
+ return {"type": "max_depth"}
137
+ if isinstance(value, dict):
138
+ return {
139
+ "type": "object",
140
+ "fields": {
141
+ str(key): _request_shape(item, depth + 1)
142
+ for key, item in sorted(value.items(), key=lambda pair: str(pair[0]))
143
+ },
144
+ }
145
+ if isinstance(value, (list, tuple)):
146
+ return {
147
+ "type": "array",
148
+ "length": len(value),
149
+ "item_types": sorted({type(item).__name__ for item in value}),
150
+ }
151
+ if isinstance(value, str):
152
+ return {"type": "string", "bytes": len(value.encode("utf-8"))}
153
+ if value is None:
154
+ return {"type": "null"}
155
+ return {"type": type(value).__name__}
156
+
157
+
158
+ def _request_log_content(value, endpoint=""):
159
+ try:
160
+ encoded = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
161
+ except (TypeError, ValueError):
162
+ encoded = str(value).encode("utf-8", "replace")
163
+ projected = _sanitize_business_payload(value, str(endpoint or ""), "request")
164
+ sanitized = _sanitize_log_value(projected)
165
+ sanitized_encoded = json.dumps(
166
+ sanitized, ensure_ascii=True, sort_keys=True, separators=(",", ":"),
167
+ )
168
+ sanitized_size = len(sanitized_encoded.encode("utf-8"))
169
+ result = {
170
+ "request_bytes": len(encoded),
171
+ "request_sha256": hashlib.sha256(encoded).hexdigest(),
172
+ "request_shape": _request_shape(value),
173
+ "request_log_bytes": sanitized_size,
174
+ "request_truncated": sanitized_size > _MAX_LOG_BODY,
175
+ }
176
+ if sanitized_size <= _MAX_LOG_BODY:
177
+ result["request_json"] = sanitized
178
+ else:
179
+ result["request_body"] = sanitized_encoded[:_MAX_LOG_BODY]
180
+ return result
181
+
182
+
183
+ def _bounded_json_fields(prefix, value, endpoint="", direction=""):
184
+ projected = _sanitize_business_payload(value, str(endpoint or ""), direction)
185
+ sanitized = _sanitize_log_value(projected)
186
+ encoded = json.dumps(sanitized, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
187
+ size = len(encoded.encode("utf-8"))
188
+ fields = {prefix + "_bytes": size, prefix + "_truncated": size > _MAX_LOG_BODY}
189
+ if size <= _MAX_LOG_BODY:
190
+ fields[prefix + "_json"] = sanitized
191
+ else:
192
+ fields[prefix + "_body"] = encoded[:_MAX_LOG_BODY]
193
+ return fields
194
+
195
+
196
+ def _bounded_text(value):
197
+ encoded = str(value).encode("utf-8")
198
+ original_size = len(encoded)
199
+ truncated = len(encoded) > _MAX_LOG_BODY
200
+ if truncated:
201
+ encoded = encoded[:_MAX_LOG_BODY]
202
+ return encoded.decode("utf-8", "ignore"), original_size, truncated
203
+
204
+
205
+ def _response_probe(status, body):
206
+ if isinstance(body, bytes):
207
+ text = body.decode("utf-8", "replace")
208
+ else:
209
+ text = str(body or "")
210
+ bounded, size, truncated = _bounded_text(text)
211
+ return {
212
+ "status": int(status or 0),
213
+ "body": bounded,
214
+ "body_bytes": size,
215
+ "body_truncated": truncated,
216
+ }
217
+
218
+
219
+ def _response_status(payload):
220
+ if not isinstance(payload, dict):
221
+ return None
222
+ for key in ("http_status", "status_code", "status", "httpStatus"):
223
+ value = payload.get(key)
224
+ if value not in (None, ""):
225
+ try:
226
+ return int(value)
227
+ except (TypeError, ValueError):
228
+ pass
229
+ for key in ("error", "data", "result"):
230
+ nested = payload.get(key)
231
+ status = _response_status(nested)
232
+ if status is not None:
233
+ return status
234
+ return None
235
+
236
+
237
+ def _response_log_content(probe, payload=None, endpoint=""):
238
+ if payload is not None:
239
+ return _bounded_json_fields("response", payload, endpoint, "response")
240
+ if not isinstance(probe, dict) or not probe.get("body"):
241
+ return {}
242
+ body = str(probe["body"])
243
+ original_size = int(probe.get("body_bytes") or len(body.encode("utf-8")))
244
+ originally_truncated = bool(probe.get("body_truncated"))
245
+ if originally_truncated:
246
+ sanitized, _stored_size, _ = _bounded_text(safe_exception_message(body))
247
+ return {
248
+ "response_body": sanitized,
249
+ "response_bytes": original_size,
250
+ "response_truncated": True,
251
+ }
252
+ try:
253
+ parsed = json.loads(body)
254
+ except (TypeError, ValueError):
255
+ sanitized, size, truncated = _bounded_text(safe_exception_message(body))
256
+ return {
257
+ "response_body": sanitized,
258
+ "response_bytes": size,
259
+ "response_truncated": truncated,
260
+ }
261
+ return _bounded_json_fields("response", parsed, endpoint, "response")
262
+
263
+
264
+ def write_error_log(failure, command, probe=None, payload=None):
265
+ log_dir = os.path.join(DD_DIR, "Fupload", "logs")
266
+ os.makedirs(log_dir, exist_ok=True)
267
+ path = os.path.join(log_dir, "dd-errors-%s.jsonl" % time.strftime("%Y%m%d"))
268
+ request = command.get("wire_request") if isinstance(command, dict) else None
269
+ if request is None and isinstance(command, dict):
270
+ request = command.get("payload")
271
+ if request is None and isinstance(command, dict) and command.get("action") == "upload":
272
+ request = command.get("meta")
273
+ endpoint = command.get("path") if isinstance(command, dict) else None
274
+ if isinstance(command, dict) and command.get("action") == "upload":
275
+ if failure.stage == "upload_authorize":
276
+ endpoint = "/file/upload"
277
+ elif failure.stage == "object_put":
278
+ endpoint = "object-store-put"
279
+ record = {
280
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
281
+ "action": command.get("action") if isinstance(command, dict) else None,
282
+ "method": command.get("method") if isinstance(command, dict) else None,
283
+ "endpoint": endpoint,
284
+ "stage": failure.stage,
285
+ "message": safe_exception_message(failure),
286
+ "http_status": failure.http_status,
287
+ "business_code": failure.business_code,
288
+ "verification_required": bool(failure.verification_required),
289
+ "request_fields": sorted(str(key) for key in request) if isinstance(request, dict) else [],
290
+ "validation": _sanitize_log_value(failure.details),
291
+ }
292
+ if request is not None:
293
+ record.update(_request_log_content(request, endpoint))
294
+ record.update(_response_log_content(probe, payload, endpoint))
295
+ with open(path, "a", encoding="utf-8") as handle:
296
+ handle.write(json.dumps(record, ensure_ascii=True, sort_keys=True, separators=(",", ":")) + "\n")
297
+ return os.path.abspath(path)
298
+
299
+
300
+ def output(payload):
301
+ print("FUPLOAD_RESULT " + json.dumps(payload, ensure_ascii=True, sort_keys=True))
302
+ sys.stdout.flush()
303
+
304
+
305
+ class SidecarFailure(Exception):
306
+ def __init__(self, message, stage, http_status=None, business_code=None, verification_required=False, details=None):
307
+ Exception.__init__(self, message)
308
+ self.stage = stage
309
+ self.http_status = http_status
310
+ self.business_code = business_code
311
+ self.verification_required = verification_required
312
+ self.details = details or {}
313
+
314
+ def as_dict(self):
315
+ result = {
316
+ "kind": "platform_error",
317
+ "stage": self.stage,
318
+ "message": safe_exception_message(self.args[0]),
319
+ "verification_required": bool(self.verification_required),
320
+ }
321
+ if self.http_status is not None:
322
+ result["http_status"] = self.http_status
323
+ if self.business_code is not None:
324
+ result["business_code"] = self.business_code
325
+ if self.details:
326
+ result["details"] = self.details
327
+ return result
328
+
329
+
330
+ def _validation_details(probe):
331
+ if not isinstance(probe, dict):
332
+ return {}
333
+ body = probe.get("body")
334
+ if not body:
335
+ return {}
336
+ try:
337
+ parsed = json.loads(body)
338
+ except (TypeError, ValueError):
339
+ return {"server_message": safe_exception_message(str(body))[:400]}
340
+ if not isinstance(parsed, dict):
341
+ return {"server_response": safe_exception_message(str(parsed))[:400]}
342
+ result = {}
343
+ for key in ("code", "msg", "message", "error", "field", "fields", "type"):
344
+ if key in parsed and parsed[key] not in (None, ""):
345
+ result["server_" + key] = safe_exception_message(str(parsed[key]))[:400]
346
+ detail = parsed.get("detail")
347
+ if isinstance(detail, list):
348
+ items = []
349
+ for item in detail[:20]:
350
+ if not isinstance(item, dict):
351
+ continue
352
+ entry = {}
353
+ for key in ("loc", "msg", "type"):
354
+ if key in item:
355
+ entry[key] = safe_exception_message(str(item[key]))[:200]
356
+ if entry:
357
+ items.append(entry)
358
+ if items:
359
+ result["server_detail"] = items
360
+ elif detail not in (None, ""):
361
+ result["server_detail"] = safe_exception_message(str(detail))[:400]
362
+ return result
363
+
364
+
365
+ def install_response_probe(client):
366
+ """Capture response objects and HTTPError bodies without changing DD behavior."""
367
+ session = getattr(client, "_session", None)
368
+ restore = getattr(client, "_fupload_restore_response_probe", None)
369
+ if callable(restore):
370
+ restore()
371
+ original_opener_open = urllib.request.OpenerDirector.open
372
+
373
+ def opener_open(opener, *args, **kwargs):
374
+ try:
375
+ return original_opener_open(opener, *args, **kwargs)
376
+ except urllib.error.HTTPError as error:
377
+ original_read = error.read
378
+
379
+ def error_read(*read_args, _error=error, **read_kwargs):
380
+ body = original_read(*read_args, **read_kwargs)
381
+ try:
382
+ text = bytes(body or b"").decode("utf-8", "replace")
383
+ except Exception:
384
+ text = ""
385
+ bounded, size, truncated = _bounded_text(text)
386
+ client._fupload_last_response_error = {
387
+ "status": int(getattr(_error, "code", 0) or 0),
388
+ "body": bounded,
389
+ "body_bytes": size,
390
+ "body_truncated": truncated,
391
+ }
392
+ return body
393
+
394
+ error.read = error_read
395
+ raise
396
+
397
+ urllib.request.OpenerDirector.open = opener_open
398
+
399
+ def restore_probe():
400
+ if getattr(urllib.request.OpenerDirector, "open", None) is opener_open:
401
+ urllib.request.OpenerDirector.open = original_opener_open
402
+ client._fupload_restore_response_probe = None
403
+
404
+ client._fupload_restore_response_probe = restore_probe
405
+ for method_name in ("get", "post"):
406
+ original = getattr(session, method_name, None)
407
+ if not callable(original):
408
+ continue
409
+
410
+ def wrapped_factory(original_method):
411
+ def wrapped(*args, **kwargs):
412
+ client._fupload_last_response_error = None
413
+ response = original_method(*args, **kwargs)
414
+ try:
415
+ status = int(getattr(response, "status_code", 0) or 0)
416
+ except (TypeError, ValueError):
417
+ status = 0
418
+ if status >= 400:
419
+ try:
420
+ body = str(getattr(response, "text", "") or "")
421
+ except Exception:
422
+ body = ""
423
+ bounded, size, truncated = _bounded_text(body)
424
+ client._fupload_last_response_error = {
425
+ "status": status,
426
+ "body": bounded,
427
+ "body_bytes": size,
428
+ "body_truncated": truncated,
429
+ }
430
+ return response
431
+ return wrapped
432
+
433
+ try:
434
+ setattr(session, method_name, wrapped_factory(original))
435
+ except (AttributeError, TypeError):
436
+ continue
437
+
438
+
439
+ def failure_from_exception(exc, stage, response=None):
440
+ if isinstance(exc, SidecarFailure):
441
+ return exc
442
+ if isinstance(exc, urllib.error.HTTPError):
443
+ probe = dict(response) if isinstance(response, dict) else {}
444
+ if not probe.get("body"):
445
+ try:
446
+ body = exc.read()
447
+ except Exception:
448
+ body = b""
449
+ if body:
450
+ text = bytes(body).decode("utf-8", "replace")
451
+ bounded, size, truncated = _bounded_text(text)
452
+ probe.update({
453
+ "status": int(exc.code),
454
+ "body": bounded,
455
+ "body_bytes": size,
456
+ "body_truncated": truncated,
457
+ })
458
+ business_code = None
459
+ if probe.get("body"):
460
+ try:
461
+ body_value = json.loads(probe["body"])
462
+ if isinstance(body_value, dict):
463
+ business_code = body_value.get("code")
464
+ if business_code is None:
465
+ business_code = body_value.get("error_code")
466
+ except (TypeError, ValueError):
467
+ pass
468
+ return SidecarFailure(
469
+ "DD %s request returned HTTP %d" % (stage, exc.code), stage,
470
+ http_status=exc.code,
471
+ business_code=business_code,
472
+ verification_required=stage in ("object_put", "mutation") and exc.code >= 500,
473
+ details=_validation_details(probe),
474
+ )
475
+ uncertain = stage in ("object_put", "mutation")
476
+ message = safe_exception_message(exc)
477
+ probe = response if isinstance(response, dict) else {}
478
+ status = probe.get("status")
479
+ match = _HTTP_STATUS.search(message)
480
+ if status is None and match:
481
+ status = int(match.group(1))
482
+ try:
483
+ status = int(status) if status is not None else None
484
+ except (TypeError, ValueError):
485
+ status = None
486
+ business_code = getattr(exc, "code", None)
487
+ if business_code is None:
488
+ business_code = getattr(exc, "error_code", None)
489
+ if business_code is None and isinstance(probe, dict) and probe.get("body"):
490
+ try:
491
+ body_value = json.loads(probe["body"])
492
+ if isinstance(body_value, dict):
493
+ business_code = body_value.get("code")
494
+ if business_code is None:
495
+ business_code = body_value.get("error_code")
496
+ except (TypeError, ValueError):
497
+ pass
498
+ if not message:
499
+ message = type(exc).__name__
500
+ return SidecarFailure(
501
+ "DD %s request failed (%s): %s" % (stage, type(exc).__name__, message[:400]),
502
+ stage,
503
+ http_status=status,
504
+ business_code=business_code,
505
+ verification_required=uncertain and not (status is not None and 400 <= status < 500),
506
+ details=_validation_details(probe),
507
+ )
508
+
509
+
510
+ def bootstrap():
511
+ resource = os.path.join(DD_DIR, "ccvoicehub.res")
512
+ pyqt_dir = os.path.join(DD_DIR, "ccsub64", "PyQt5")
513
+ support_dir = os.path.join(DD_DIR, "ccsub64")
514
+ os.environ["QT_QPA_PLATFORM"] = "offscreen"
515
+ os.environ["PATH"] = os.pathsep.join([pyqt_dir, DD_DIR, support_dir, os.environ.get("PATH", "")])
516
+ ctypes.windll.kernel32.SetDllDirectoryW(pyqt_dir)
517
+ sys.path.insert(0, resource)
518
+
519
+
520
+ def load_client_no(machine_data):
521
+ if os.path.exists(DEVICE_STATE):
522
+ try:
523
+ with open(DEVICE_STATE, "r", encoding="utf-8") as handle:
524
+ state = json.load(handle)
525
+ value = state.get("client_no", "")
526
+ except Exception as exc:
527
+ raise RuntimeError("sidecar-device.json is unreadable") from exc
528
+ if not re.match(r"^[0-9a-f]{32}$", value):
529
+ raise RuntimeError("sidecar-device.json contains an invalid client_no")
530
+ return value, False
531
+ value = machine_data.generateClientNo()
532
+ if not re.match(r"^[0-9a-f]{32}$", value):
533
+ raise RuntimeError("DD generated an invalid client_no")
534
+ directory = os.path.dirname(DEVICE_STATE)
535
+ if not os.path.isdir(directory):
536
+ os.makedirs(directory)
537
+ temporary = DEVICE_STATE + ".tmp.%d" % os.getpid()
538
+ with open(temporary, "w", encoding="utf-8") as handle:
539
+ json.dump({"version": 1, "client_no": value}, handle, sort_keys=True)
540
+ os.replace(temporary, DEVICE_STATE)
541
+ return value, True
542
+
543
+
544
+ def wait_until(qt, predicate, timeout):
545
+ deadline = time.time() + timeout
546
+ while time.time() < deadline:
547
+ qt.process_events(100)
548
+ if predicate():
549
+ return True
550
+ time.sleep(0.02)
551
+ return bool(predicate())
552
+
553
+
554
+ def open_session(timeout=45):
555
+ from cli_anything.ccvoicehub.core.container import ContainerManager
556
+ from cli_anything.ccvoicehub.core.qt_runtime import QtRuntime
557
+ from components.login.flow import MobileReLoginFlow
558
+ import components.login.login_controller as login_controller_module
559
+ import logger.cclogger as cclogger
560
+
561
+ qt = QtRuntime.get_instance()
562
+ container = ContainerManager.get_container()
563
+ storage = container.get_instance("AccountCredStorage")
564
+ account = storage.getAutoAccount()
565
+ credential = storage.getCred(account)
566
+ if not account or not credential:
567
+ raise RuntimeError("DD has no persisted credential; sign in with the desktop client first")
568
+ cclogger.renameLogAfterLogin = lambda *_args, **_kwargs: None
569
+ login_controller_module.renameLogAfterLogin = lambda *_args, **_kwargs: None
570
+ controller = container.get_instance("LoginController")
571
+ jwt_helper = container.get_instance("JwtHelper")
572
+ state = {"done": False, "ok": False, "jwt": False, "error": ""}
573
+
574
+ def login_result(*args):
575
+ state["done"] = True
576
+ state["ok"] = bool(args and args[0])
577
+ if len(args) > 1 and args[1]:
578
+ state["error"] = str(args[1])[:160]
579
+
580
+ def jwt_result(*_args):
581
+ state["jwt"] = True
582
+
583
+ jwt_helper.sigJwtUpdated.connect(jwt_result)
584
+ flow = MobileReLoginFlow(
585
+ controller, container.get_instance("UrsSDK"), container.get_instance("CgiHelper"),
586
+ container.get_instance("NetConfig"), credential.value,
587
+ credential.modifier.name == "mobile_password", account.name,
588
+ )
589
+ flow.sigResult.connect(login_result)
590
+ flow.start()
591
+ if not wait_until(qt, lambda: state["done"], timeout) or not state["ok"]:
592
+ raise RuntimeError(state["error"] or "DD native login failed or timed out")
593
+ jwt_helper.startRequestJwt()
594
+ if not wait_until(qt, lambda: state["jwt"] and bool(jwt_helper.getJwt()), timeout):
595
+ raise RuntimeError("DD JWT refresh failed or timed out")
596
+ from components.wow_ui.nep_wrapper import NepWrapper
597
+ from cli_anything.ccvoicehub.core.ui_api_client import UiApiClient
598
+ nep = NepWrapper(None)
599
+ if not nep.isDllInited():
600
+ raise RuntimeError("DD NEP module is not initialized")
601
+ client = UiApiClient(nep, login_cookie=getattr(controller, "_cookie", None))
602
+ client._session.headers["User-Agent"] = USER_AGENT
603
+ if not client.login():
604
+ raise RuntimeError("DD author API login failed")
605
+ install_response_probe(client)
606
+ return qt, container, flow, jwt_helper, client
607
+
608
+
609
+ def api_result(payload):
610
+ return payload.get("result") if isinstance(payload, dict) else None
611
+
612
+
613
+ def _native_json(value):
614
+ to_json = getattr(value, "toJson", None)
615
+ if callable(to_json):
616
+ value = to_json()
617
+ if isinstance(value, bytes):
618
+ value = value.decode("utf-8", "replace")
619
+ if isinstance(value, str):
620
+ value = json.loads(value)
621
+ return value
622
+
623
+
624
+ def _native_wa_ids(value):
625
+ parsed = _native_json(value)
626
+ if isinstance(parsed, dict):
627
+ code = parsed.get("code")
628
+ if code not in (None, 0, 200):
629
+ raise RuntimeError(str(parsed.get("msg") or parsed.get("message") or "DD WA parser rejected the content"))
630
+ nested = _native_json(parsed.get("result")) if parsed.get("result") is not None else parsed
631
+ if isinstance(nested, dict):
632
+ uid = nested.get("parse_wa_uid") or nested.get("uid") or nested.get("wa_uid")
633
+ ident = nested.get("parse_wa_id") or nested.get("id") or nested.get("wa_id")
634
+ else:
635
+ uid = ident = None
636
+ elif isinstance(parsed, (list, tuple)) and len(parsed) >= 2:
637
+ uid, ident = parsed[0], parsed[1]
638
+ else:
639
+ uid = ident = None
640
+ if not uid or not ident:
641
+ raise RuntimeError("DD native WA parser did not return uid and id")
642
+ return {"parse_wa_uid": str(uid), "parse_wa_id": str(ident)}
643
+
644
+
645
+ def parse_native_wa(session, content):
646
+ """Follow the installed DD bridge and parser chain; never synthesize IDs."""
647
+ container = session[1]
648
+ try:
649
+ interface = container.get_instance("WowUIInterface")
650
+ except Exception:
651
+ interface = None
652
+ bridge = getattr(interface, "parseWa", None)
653
+ if callable(bridge):
654
+ return _native_wa_ids(bridge({"waStr": content}))
655
+
656
+ try:
657
+ from components.wow_ui.wa.wa_parser import WaParser
658
+ parser = WaParser(None)
659
+ return _native_wa_ids(parser.parseWa(content))
660
+ except ImportError:
661
+ pass
662
+
663
+ raise RuntimeError("installed DD runtime does not expose a native WA parser")
664
+
665
+
666
+ def run_command(session, command):
667
+ client = session[-1]
668
+ client._fupload_last_response_error = None
669
+ action = command.get("action")
670
+ rejected_payload = None
671
+ try:
672
+ if action == "request":
673
+ method = command.get("method", "GET").upper()
674
+ path = command["path"]
675
+ params = command.get("payload") or {}
676
+ response_payload = client.get(path, params) if method == "GET" else client.post(path, params)
677
+ if isinstance(response_payload, dict) and response_payload.get("code") not in (None, 0):
678
+ rejected_payload = response_payload
679
+ response_probe = getattr(client, "_fupload_last_response_error", None)
680
+ raise SidecarFailure(
681
+ str(response_payload.get("msg") or response_payload.get("message") or "DD operation failed"),
682
+ command.get("request_stage") or ("dependency_get" if method == "GET" else "mutation"),
683
+ http_status=(response_probe or {}).get("status") if isinstance(response_probe, dict) else _response_status(response_payload),
684
+ business_code=response_payload.get("code"),
685
+ details=_validation_details({"body": json.dumps(response_payload, ensure_ascii=False)}),
686
+ )
687
+ return response_payload
688
+ if action == "upload":
689
+ path = command["file"]
690
+ meta = command["meta"]
691
+ command["wire_request"] = {"upload_authorize": meta}
692
+ try:
693
+ auth = client.get("/file/upload", meta)
694
+ except Exception as exc:
695
+ raise failure_from_exception(exc, "upload_authorize", getattr(client, "_fupload_last_response_error", None))
696
+ if isinstance(auth, dict) and auth.get("code") not in (None, 0):
697
+ rejected_payload = auth
698
+ response_probe = getattr(client, "_fupload_last_response_error", None)
699
+ raise SidecarFailure(
700
+ "DD upload authorization was rejected",
701
+ "upload_authorize",
702
+ http_status=(response_probe or {}).get("status") if isinstance(response_probe, dict) else _response_status(auth),
703
+ business_code=auth.get("code"),
704
+ details=_validation_details({"body": json.dumps(auth, ensure_ascii=False)}),
705
+ )
706
+ info = api_result(auth)
707
+ if not isinstance(info, dict) or not info.get("url") or not info.get("d_url"):
708
+ rejected_payload = auth
709
+ raise SidecarFailure("DD upload authorization did not return a usable upload target", "upload_authorize")
710
+ size = os.path.getsize(path)
711
+ maximum = int(info.get("maxSize") or 0)
712
+ if maximum and size > maximum:
713
+ raise SidecarFailure("file exceeds DD server size limit", "upload_authorize")
714
+ try:
715
+ with open(path, "rb") as handle:
716
+ data = handle.read()
717
+ headers = {"Content-Type": meta["mime_type"], "X-Amz-Acl": "public-read"}
718
+ command["wire_request"]["object_put"] = {
719
+ "headers": headers,
720
+ "body_bytes": size,
721
+ "body_sha256": hashlib.sha256(data).hexdigest(),
722
+ }
723
+ request = urllib.request.Request(
724
+ info["url"], data=data, method="PUT", headers=headers,
725
+ )
726
+ with urllib.request.urlopen(request, timeout=180) as response:
727
+ if response.status != 200:
728
+ try:
729
+ response_body = response.read()
730
+ except Exception:
731
+ response_body = b""
732
+ response_probe = _response_probe(response.status, response_body)
733
+ raise SidecarFailure(
734
+ "DD object upload returned HTTP %d" % response.status,
735
+ "object_put",
736
+ http_status=response.status,
737
+ details=_validation_details(response_probe),
738
+ )
739
+ except urllib.error.HTTPError as exc:
740
+ response_probe = _response_probe(exc.code, exc.read())
741
+ raise failure_from_exception(exc, "object_put", response_probe)
742
+ except Exception as exc:
743
+ raise failure_from_exception(exc, "object_put")
744
+ return {"d_url": info["d_url"], "size": size}
745
+ if action == "cc_get":
746
+ jwt_value = session[3].getJwt()
747
+ url = command["url"]
748
+ request = urllib.request.Request(
749
+ url,
750
+ headers={
751
+ "Authentication": jwt_value,
752
+ "User-Agent": USER_AGENT,
753
+ },
754
+ )
755
+ with urllib.request.urlopen(request, timeout=30) as response:
756
+ return json.loads(response.read().decode("utf-8"))
757
+ if action == "parse_wa":
758
+ return parse_native_wa(session, command["content"])
759
+ raise SidecarFailure("unsupported sidecar action", "session")
760
+ except Exception as exc:
761
+ if action == "request":
762
+ stage = command.get("request_stage")
763
+ if stage not in ("dependency_get", "mutation"):
764
+ stage = "dependency_get" if command.get("method", "GET").upper() == "GET" else "mutation"
765
+ elif action == "cc_get":
766
+ stage = "dependency_get"
767
+ elif action == "parse_wa":
768
+ stage = "native_parser"
769
+ else:
770
+ stage = "session"
771
+ response_probe = locals().get("response_probe")
772
+ if not isinstance(response_probe, dict):
773
+ response_probe = getattr(client, "_fupload_last_response_error", None)
774
+ failure = failure_from_exception(exc, stage, response_probe)
775
+ try:
776
+ log_path = write_error_log(
777
+ failure, command,
778
+ response_probe,
779
+ rejected_payload,
780
+ )
781
+ failure.details["log_path"] = log_path
782
+ except Exception as log_exc:
783
+ failure.details["log_write_error"] = type(log_exc).__name__
784
+ raise failure
785
+
786
+
787
+ def close_session(session):
788
+ if not session:
789
+ return
790
+ qt, container, _flow, _jwt_helper, client = session
791
+ restore_probe = getattr(client, "_fupload_restore_response_probe", None)
792
+ if callable(restore_probe):
793
+ restore_probe()
794
+ try:
795
+ message_center = container.get_instance("MessageCenter")
796
+ message_center.logout()
797
+ try:
798
+ from components.net_server.messagecenter import State
799
+
800
+ def stopped():
801
+ state = message_center.state
802
+ checker = getattr(state, "isStopped", None)
803
+ if callable(checker):
804
+ return bool(checker())
805
+ checker = getattr(State, "isStopped", None)
806
+ return bool(checker(state)) if callable(checker) else False
807
+
808
+ wait_until(qt, stopped, 5)
809
+ except Exception:
810
+ pass
811
+ except Exception:
812
+ pass
813
+ try:
814
+ client._token = ""
815
+ client.close()
816
+ except Exception:
817
+ pass
818
+ try:
819
+ container.shutdown()
820
+ finally:
821
+ qt.shutdown()
822
+
823
+
824
+ def main():
825
+ logging.disable(logging.CRITICAL)
826
+ bootstrap()
827
+ import datacenter.local_data.machine_data as machine_data
828
+ original = machine_data.clientNo
829
+ value, created = load_client_no(machine_data)
830
+ machine_data.clientNo = value
831
+ session = None
832
+ try:
833
+ session = open_session()
834
+ output({"ready": True, "device_state_created": created})
835
+ for line in sys.stdin:
836
+ if not line.strip():
837
+ continue
838
+ command = None
839
+ try:
840
+ command = json.loads(line)
841
+ result = run_command(session, command)
842
+ output({"id": command.get("id"), "ok": True, "data": result})
843
+ except Exception as exc:
844
+ error = exc.as_dict() if isinstance(exc, SidecarFailure) else failure_from_exception(exc, "session").as_dict()
845
+ output({"id": command.get("id") if isinstance(command, dict) else None, "ok": False,
846
+ "error": error})
847
+ return 0
848
+ except Exception as exc:
849
+ output({"ready": False, "error": {
850
+ "type": type(exc).__name__,
851
+ "message": safe_exception_message(exc)[:400],
852
+ }})
853
+ return 1
854
+ finally:
855
+ machine_data.clientNo = original
856
+ close_session(session)
857
+
858
+
859
+ if __name__ == "__main__":
860
+ sys.exit(main())