@researai/deepscientist 1.5.9 → 1.5.11

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 (140) hide show
  1. package/README.md +107 -94
  2. package/assets/branding/connector-qq.png +0 -0
  3. package/assets/branding/connector-rokid.png +0 -0
  4. package/assets/branding/connector-weixin.png +0 -0
  5. package/assets/branding/projects.png +0 -0
  6. package/bin/ds.js +168 -9
  7. package/docs/assets/branding/projects.png +0 -0
  8. package/docs/en/00_QUICK_START.md +308 -70
  9. package/docs/en/01_SETTINGS_REFERENCE.md +3 -0
  10. package/docs/en/02_START_RESEARCH_GUIDE.md +112 -0
  11. package/docs/en/04_LINGZHU_CONNECTOR_GUIDE.md +62 -179
  12. package/docs/en/09_DOCTOR.md +41 -5
  13. package/docs/en/10_WEIXIN_CONNECTOR_GUIDE.md +137 -0
  14. package/docs/en/11_LICENSE_AND_RISK.md +256 -0
  15. package/docs/en/12_GUIDED_WORKFLOW_TOUR.md +427 -0
  16. package/docs/en/13_CORE_ARCHITECTURE_GUIDE.md +297 -0
  17. package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +506 -0
  18. package/docs/en/99_ACKNOWLEDGEMENTS.md +4 -1
  19. package/docs/en/README.md +79 -0
  20. package/docs/images/lingzhu/rokid-agent-platform-create.png +0 -0
  21. package/docs/images/weixin/weixin-plugin-entry.png +0 -0
  22. package/docs/images/weixin/weixin-plugin-entry.svg +33 -0
  23. package/docs/images/weixin/weixin-qr-confirm.svg +30 -0
  24. package/docs/images/weixin/weixin-quest-media-flow.svg +44 -0
  25. package/docs/images/weixin/weixin-settings-bind.svg +57 -0
  26. package/docs/zh/00_QUICK_START.md +315 -74
  27. package/docs/zh/01_SETTINGS_REFERENCE.md +3 -0
  28. package/docs/zh/02_START_RESEARCH_GUIDE.md +112 -0
  29. package/docs/zh/04_LINGZHU_CONNECTOR_GUIDE.md +62 -193
  30. package/docs/zh/09_DOCTOR.md +41 -5
  31. package/docs/zh/10_WEIXIN_CONNECTOR_GUIDE.md +144 -0
  32. package/docs/zh/11_LICENSE_AND_RISK.md +256 -0
  33. package/docs/zh/12_GUIDED_WORKFLOW_TOUR.md +423 -0
  34. package/docs/zh/13_CORE_ARCHITECTURE_GUIDE.md +296 -0
  35. package/docs/zh/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +506 -0
  36. package/docs/zh/99_ACKNOWLEDGEMENTS.md +4 -1
  37. package/docs/zh/README.md +126 -0
  38. package/install.sh +0 -34
  39. package/package.json +2 -2
  40. package/pyproject.toml +1 -1
  41. package/src/deepscientist/__init__.py +1 -1
  42. package/src/deepscientist/annotations.py +343 -0
  43. package/src/deepscientist/artifact/arxiv.py +484 -37
  44. package/src/deepscientist/artifact/service.py +574 -108
  45. package/src/deepscientist/arxiv_library.py +275 -0
  46. package/src/deepscientist/bash_exec/service.py +9 -0
  47. package/src/deepscientist/bridges/builtins.py +2 -0
  48. package/src/deepscientist/bridges/connectors.py +447 -0
  49. package/src/deepscientist/channels/__init__.py +2 -0
  50. package/src/deepscientist/channels/builtins.py +3 -1
  51. package/src/deepscientist/channels/qq.py +1 -1
  52. package/src/deepscientist/channels/qq_gateway.py +1 -1
  53. package/src/deepscientist/channels/relay.py +7 -1
  54. package/src/deepscientist/channels/weixin.py +59 -0
  55. package/src/deepscientist/channels/weixin_ilink.py +317 -0
  56. package/src/deepscientist/config/models.py +22 -2
  57. package/src/deepscientist/config/service.py +431 -60
  58. package/src/deepscientist/connector/__init__.py +4 -0
  59. package/src/deepscientist/connector/connector_profiles.py +481 -0
  60. package/src/deepscientist/connector/lingzhu_support.py +668 -0
  61. package/src/deepscientist/connector/qq_profiles.py +206 -0
  62. package/src/deepscientist/connector/weixin_support.py +663 -0
  63. package/src/deepscientist/connector_profiles.py +1 -374
  64. package/src/deepscientist/connector_runtime.py +2 -0
  65. package/src/deepscientist/daemon/api/handlers.py +165 -5
  66. package/src/deepscientist/daemon/api/router.py +13 -1
  67. package/src/deepscientist/daemon/app.py +1130 -61
  68. package/src/deepscientist/doctor.py +5 -2
  69. package/src/deepscientist/gitops/diff.py +120 -29
  70. package/src/deepscientist/lingzhu_support.py +1 -182
  71. package/src/deepscientist/mcp/server.py +11 -4
  72. package/src/deepscientist/prompts/builder.py +15 -0
  73. package/src/deepscientist/qq_profiles.py +1 -196
  74. package/src/deepscientist/quest/node_traces.py +23 -0
  75. package/src/deepscientist/quest/service.py +112 -43
  76. package/src/deepscientist/quest/stage_views.py +71 -5
  77. package/src/deepscientist/runners/codex.py +55 -3
  78. package/src/deepscientist/weixin_support.py +1 -0
  79. package/src/prompts/connectors/lingzhu.md +3 -1
  80. package/src/prompts/connectors/weixin.md +230 -0
  81. package/src/prompts/system.md +2 -0
  82. package/src/tui/package.json +1 -1
  83. package/src/ui/dist/assets/{AiManusChatView-BKZ103sn.js → AiManusChatView-D0mTXG4-.js} +156 -48
  84. package/src/ui/dist/assets/{AnalysisPlugin-mTTzGAlK.js → AnalysisPlugin-Db0cTXxm.js} +1 -1
  85. package/src/ui/dist/assets/{CliPlugin-BH58n3GY.js → CliPlugin-DrV8je02.js} +164 -9
  86. package/src/ui/dist/assets/{CodeEditorPlugin-BKGRUH7e.js → CodeEditorPlugin-QXMSCH71.js} +8 -8
  87. package/src/ui/dist/assets/{CodeViewerPlugin-BMADwFWJ.js → CodeViewerPlugin-7hhtWj_E.js} +5 -5
  88. package/src/ui/dist/assets/{DocViewerPlugin-ZOnTIHLN.js → DocViewerPlugin-BWMSnRJe.js} +3 -3
  89. package/src/ui/dist/assets/{GitDiffViewerPlugin-CQ7h1Djm.js → GitDiffViewerPlugin-7J9h9Vy_.js} +20 -21
  90. package/src/ui/dist/assets/{ImageViewerPlugin-GVS5MsnC.js → ImageViewerPlugin-CHJl_0lr.js} +5 -5
  91. package/src/ui/dist/assets/{LabCopilotPanel-BZNv1JML.js → LabCopilotPanel-1qSow1es.js} +11 -11
  92. package/src/ui/dist/assets/{LabPlugin-TWcJsdQA.js → LabPlugin-eQpPPCEp.js} +2 -1
  93. package/src/ui/dist/assets/{LatexPlugin-DIjHiR2x.js → LatexPlugin-BwRfi89Z.js} +7 -7
  94. package/src/ui/dist/assets/{MarkdownViewerPlugin-D3ooGAH0.js → MarkdownViewerPlugin-836PVQWV.js} +4 -4
  95. package/src/ui/dist/assets/{MarketplacePlugin-DfVfE9hN.js → MarketplacePlugin-C2y_556i.js} +3 -3
  96. package/src/ui/dist/assets/{NotebookEditor-s8JhzuX1.js → NotebookEditor-BRzJbGsn.js} +12 -12
  97. package/src/ui/dist/assets/{NotebookEditor-DDl0_Mc0.js → NotebookEditor-DIX7Mlzu.js} +1 -1
  98. package/src/ui/dist/assets/{PdfLoader-C2Sf6SJM.js → PdfLoader-DzRaTAlq.js} +14 -7
  99. package/src/ui/dist/assets/{PdfMarkdownPlugin-CXFLoIsa.js → PdfMarkdownPlugin-DZUfIUnp.js} +73 -6
  100. package/src/ui/dist/assets/{PdfViewerPlugin-BYTmz2fK.js → PdfViewerPlugin-BwtICzue.js} +103 -34
  101. package/src/ui/dist/assets/PdfViewerPlugin-DQ11QcSf.css +3627 -0
  102. package/src/ui/dist/assets/{SearchPlugin-CjWBI1O9.js → SearchPlugin-DHeIAMsx.js} +1 -1
  103. package/src/ui/dist/assets/{TextViewerPlugin-DdOBU3-S.js → TextViewerPlugin-C3tCmFox.js} +5 -4
  104. package/src/ui/dist/assets/{VNCViewer-B8HGgLwQ.js → VNCViewer-CQsKVm3t.js} +10 -10
  105. package/src/ui/dist/assets/bot-BEA2vWuK.js +21 -0
  106. package/src/ui/dist/assets/branding/logo-rokid.png +0 -0
  107. package/src/ui/dist/assets/browser-BAcuE0Xj.js +2895 -0
  108. package/src/ui/dist/assets/{code-BWAY76JP.js → code-XfbSR8K2.js} +1 -1
  109. package/src/ui/dist/assets/{file-content-C1NwU5oQ.js → file-content-BjxNaIfy.js} +1 -1
  110. package/src/ui/dist/assets/{file-diff-panel-CywslwB9.js → file-diff-panel-D_lLVQk0.js} +1 -1
  111. package/src/ui/dist/assets/{file-socket-B4kzuOBQ.js → file-socket-D9x_5vlY.js} +1 -1
  112. package/src/ui/dist/assets/{image-D-NZM-6P.js → image-BhWT33W1.js} +1 -1
  113. package/src/ui/dist/assets/{index-DHZJ_0TI.js → index--c4iXtuy.js} +12 -12
  114. package/src/ui/dist/assets/{index-BdM1Gqfr.js → index-BDxipwrC.js} +2 -2
  115. package/src/ui/dist/assets/{index-7Chr1g9c.js → index-DZTZ8mWP.js} +14221 -9523
  116. package/src/ui/dist/assets/{index-DGIYDuTv.css → index-Dqj-Mjb4.css} +2 -13
  117. package/src/ui/dist/assets/index-PJbSbPTy.js +25 -0
  118. package/src/ui/dist/assets/{monaco-Cb2uKKe6.js → monaco-K8izTGgo.js} +1 -1
  119. package/src/ui/dist/assets/{pdf-effect-queue-DSw_D3RV.js → pdf-effect-queue-DfBors6y.js} +16 -1
  120. package/src/ui/dist/assets/pdf.worker.min-yatZIOMy.mjs +21 -0
  121. package/src/ui/dist/assets/{popover-Bg72DGgT.js → popover-yFK1J4fL.js} +1 -1
  122. package/src/ui/dist/assets/{project-sync-Ce_0BglY.js → project-sync-PENr2zcz.js} +1 -74
  123. package/src/ui/dist/assets/select-CAbJDfYv.js +1690 -0
  124. package/src/ui/dist/assets/{sigma-DPaACDrh.js → sigma-DEuYJqTl.js} +1 -1
  125. package/src/ui/dist/assets/{index-CDxNdQdz.js → square-check-big-omoSUmcd.js} +2 -13
  126. package/src/ui/dist/assets/{trash-BvTgE5__.js → trash--F119N47.js} +1 -1
  127. package/src/ui/dist/assets/{useCliAccess-CgPeMOwP.js → useCliAccess-D31UR23I.js} +1 -1
  128. package/src/ui/dist/assets/{useFileDiffOverlay-xPhz7P5B.js → useFileDiffOverlay-BH6KcMzq.js} +1 -1
  129. package/src/ui/dist/assets/{wrap-text-C3Un3YQr.js → wrap-text-CZ613PM5.js} +1 -1
  130. package/src/ui/dist/assets/{zoom-out-BgxLa0Ri.js → zoom-out-BgDLAv3z.js} +1 -1
  131. package/src/ui/dist/index.html +2 -2
  132. package/src/ui/dist/assets/AutoFigurePlugin-BGxN8Umr.css +0 -3056
  133. package/src/ui/dist/assets/AutoFigurePlugin-C_wWw4AP.js +0 -8149
  134. package/src/ui/dist/assets/PdfViewerPlugin-BJXtIwj_.css +0 -260
  135. package/src/ui/dist/assets/Stepper-B0Dd8CxK.js +0 -158
  136. package/src/ui/dist/assets/bibtex-CKaefIN2.js +0 -189
  137. package/src/ui/dist/assets/file-utils-H2fjA46S.js +0 -109
  138. package/src/ui/dist/assets/message-square-BzjLiXir.js +0 -16
  139. package/src/ui/dist/assets/pdfjs-DU1YE8WO.js +0 -3
  140. package/src/ui/dist/assets/tooltip-C_mA6R0w.js +0 -108
@@ -0,0 +1,663 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import json
6
+ import mimetypes
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Any
10
+ from urllib.error import HTTPError
11
+ from urllib.parse import quote, urlparse
12
+ from urllib.request import Request
13
+
14
+ from .. import __version__ as DEEPSCIENTIST_VERSION
15
+ from ..network import urlopen_with_proxy as urlopen
16
+ from ..shared import ensure_dir, read_json, write_json
17
+
18
+ DEFAULT_WEIXIN_BASE_URL = "https://ilinkai.weixin.qq.com"
19
+ DEFAULT_WEIXIN_CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c"
20
+ DEFAULT_WEIXIN_BOT_TYPE = "3"
21
+ DEFAULT_WEIXIN_LONG_POLL_TIMEOUT_MS = 35_000
22
+ DEFAULT_WEIXIN_API_TIMEOUT_MS = 15_000
23
+ DEFAULT_WEIXIN_INBOUND_MEDIA_MAX_BYTES = 25 * 1024 * 1024
24
+ DEFAULT_WEIXIN_REMOTE_ATTACHMENT_MAX_BYTES = 100 * 1024 * 1024
25
+ SESSION_EXPIRED_ERRCODE = -14
26
+
27
+ WEIXIN_UPLOAD_MEDIA_IMAGE = 1
28
+ WEIXIN_UPLOAD_MEDIA_VIDEO = 2
29
+ WEIXIN_UPLOAD_MEDIA_FILE = 3
30
+
31
+
32
+ def weixin_base_info() -> dict[str, Any]:
33
+ return {"channel_version": DEEPSCIENTIST_VERSION}
34
+
35
+
36
+ def normalize_weixin_base_url(value: Any, *, default: str = DEFAULT_WEIXIN_BASE_URL) -> str:
37
+ text = str(value or "").strip()
38
+ if not text:
39
+ return default
40
+ return text.rstrip("/")
41
+
42
+
43
+ def normalize_weixin_cdn_base_url(value: Any, *, default: str = DEFAULT_WEIXIN_CDN_BASE_URL) -> str:
44
+ text = str(value or "").strip()
45
+ if not text:
46
+ return default
47
+ return text.rstrip("/")
48
+
49
+
50
+ def _random_wechat_uin() -> str:
51
+ value = int.from_bytes(os.urandom(4), "big", signed=False)
52
+ return base64.b64encode(str(value).encode("utf-8")).decode("ascii")
53
+
54
+
55
+ def _json_request(
56
+ url: str,
57
+ *,
58
+ method: str,
59
+ body: dict[str, Any] | None = None,
60
+ token: str | None = None,
61
+ timeout: float = DEFAULT_WEIXIN_API_TIMEOUT_MS / 1000.0,
62
+ headers: dict[str, str] | None = None,
63
+ ) -> dict[str, Any]:
64
+ raw = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
65
+ request = Request(url, data=raw, method=method)
66
+ request.add_header("iLink-App-ClientVersion", "1")
67
+ if raw is not None:
68
+ request.add_header("Content-Type", "application/json")
69
+ request.add_header("Content-Length", str(len(raw)))
70
+ request.add_header("AuthorizationType", "ilink_bot_token")
71
+ request.add_header("X-WECHAT-UIN", _random_wechat_uin())
72
+ if token:
73
+ request.add_header("Authorization", f"Bearer {token}")
74
+ for key, value in (headers or {}).items():
75
+ if value:
76
+ request.add_header(key, value)
77
+ try:
78
+ with urlopen(request, timeout=timeout) as response: # noqa: S310
79
+ payload = response.read().decode("utf-8", errors="replace")
80
+ except HTTPError as exc:
81
+ body_text = exc.read().decode("utf-8", errors="replace")
82
+ raise RuntimeError(f"Weixin HTTP {exc.code}: {body_text or exc.reason}") from exc
83
+ if not payload.strip():
84
+ return {}
85
+ try:
86
+ parsed = json.loads(payload)
87
+ except json.JSONDecodeError as exc:
88
+ raise RuntimeError(f"Weixin returned invalid JSON: {payload[:200]}") from exc
89
+ if not isinstance(parsed, dict):
90
+ raise RuntimeError("Weixin returned a non-object JSON payload.")
91
+ return parsed
92
+
93
+
94
+ def _is_weixin_timeout_error(exc: Exception) -> bool:
95
+ if isinstance(exc, TimeoutError):
96
+ return True
97
+ message = str(exc or "").strip().lower()
98
+ return "timed out" in message or "timeout" in message
99
+
100
+
101
+ def _raise_for_weixin_api_error(payload: dict[str, Any], *, endpoint: str) -> None:
102
+ ret = int(payload.get("ret") or 0)
103
+ errcode = int(payload.get("errcode") or 0)
104
+ if ret == 0 and errcode == 0:
105
+ return
106
+ errmsg = str(payload.get("errmsg") or payload.get("message") or "").strip()
107
+ detail = f": {errmsg}" if errmsg else ""
108
+ raise RuntimeError(f"Weixin {endpoint} failed with ret={ret} errcode={errcode}{detail}")
109
+
110
+
111
+ def fetch_weixin_qrcode(
112
+ *,
113
+ base_url: str,
114
+ bot_type: str = DEFAULT_WEIXIN_BOT_TYPE,
115
+ route_tag: str | None = None,
116
+ timeout: float = DEFAULT_WEIXIN_API_TIMEOUT_MS / 1000.0,
117
+ ) -> dict[str, Any]:
118
+ normalized_base_url = normalize_weixin_base_url(base_url)
119
+ encoded_bot_type = quote(str(bot_type or DEFAULT_WEIXIN_BOT_TYPE), safe="")
120
+ return _json_request(
121
+ f"{normalized_base_url}/ilink/bot/get_bot_qrcode?bot_type={encoded_bot_type}",
122
+ method="GET",
123
+ timeout=timeout,
124
+ headers={"SKRouteTag": str(route_tag or "").strip()},
125
+ )
126
+
127
+
128
+ def poll_weixin_qrcode_status(
129
+ *,
130
+ base_url: str,
131
+ qrcode: str,
132
+ route_tag: str | None = None,
133
+ timeout: float = DEFAULT_WEIXIN_LONG_POLL_TIMEOUT_MS / 1000.0,
134
+ ) -> dict[str, Any]:
135
+ normalized_base_url = normalize_weixin_base_url(base_url)
136
+ encoded_qrcode = quote(str(qrcode or "").strip(), safe="")
137
+ return _json_request(
138
+ f"{normalized_base_url}/ilink/bot/get_qrcode_status?qrcode={encoded_qrcode}",
139
+ method="GET",
140
+ timeout=timeout,
141
+ headers={"SKRouteTag": str(route_tag or "").strip()},
142
+ )
143
+
144
+
145
+ def get_weixin_updates(
146
+ *,
147
+ base_url: str,
148
+ token: str,
149
+ get_updates_buf: str = "",
150
+ route_tag: str | None = None,
151
+ timeout_ms: int = DEFAULT_WEIXIN_LONG_POLL_TIMEOUT_MS,
152
+ ) -> dict[str, Any]:
153
+ normalized_base_url = normalize_weixin_base_url(base_url)
154
+ payload = {
155
+ "get_updates_buf": str(get_updates_buf or ""),
156
+ "base_info": weixin_base_info(),
157
+ }
158
+ try:
159
+ return _json_request(
160
+ f"{normalized_base_url}/ilink/bot/getupdates",
161
+ method="POST",
162
+ body=payload,
163
+ token=str(token or "").strip(),
164
+ timeout=max(float(timeout_ms) / 1000.0, 1.0),
165
+ headers={"SKRouteTag": str(route_tag or "").strip()},
166
+ )
167
+ except Exception as exc:
168
+ if _is_weixin_timeout_error(exc):
169
+ return {
170
+ "ret": 0,
171
+ "msgs": [],
172
+ "get_updates_buf": str(get_updates_buf or ""),
173
+ }
174
+ raise
175
+
176
+
177
+ def send_weixin_message(
178
+ *,
179
+ base_url: str,
180
+ token: str,
181
+ body: dict[str, Any],
182
+ route_tag: str | None = None,
183
+ timeout_ms: int = DEFAULT_WEIXIN_API_TIMEOUT_MS,
184
+ ) -> dict[str, Any]:
185
+ normalized_base_url = normalize_weixin_base_url(base_url)
186
+ payload = {**body, "base_info": weixin_base_info()}
187
+ response = _json_request(
188
+ f"{normalized_base_url}/ilink/bot/sendmessage",
189
+ method="POST",
190
+ body=payload,
191
+ token=str(token or "").strip(),
192
+ timeout=max(float(timeout_ms) / 1000.0, 1.0),
193
+ headers={"SKRouteTag": str(route_tag or "").strip()},
194
+ )
195
+ _raise_for_weixin_api_error(response, endpoint="sendmessage")
196
+ return response
197
+
198
+
199
+ def get_weixin_upload_url(
200
+ *,
201
+ base_url: str,
202
+ token: str,
203
+ body: dict[str, Any],
204
+ route_tag: str | None = None,
205
+ timeout_ms: int = DEFAULT_WEIXIN_API_TIMEOUT_MS,
206
+ ) -> dict[str, Any]:
207
+ normalized_base_url = normalize_weixin_base_url(base_url)
208
+ payload = {**body, "base_info": weixin_base_info()}
209
+ response = _json_request(
210
+ f"{normalized_base_url}/ilink/bot/getuploadurl",
211
+ method="POST",
212
+ body=payload,
213
+ token=str(token or "").strip(),
214
+ timeout=max(float(timeout_ms) / 1000.0, 1.0),
215
+ headers={"SKRouteTag": str(route_tag or "").strip()},
216
+ )
217
+ _raise_for_weixin_api_error(response, endpoint="getuploadurl")
218
+ return response
219
+
220
+
221
+ def _load_aes_helpers() -> tuple[Any, Any]:
222
+ try:
223
+ from cryptography.hazmat.primitives import padding
224
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
225
+
226
+ def encrypt(plaintext: bytes, key: bytes) -> bytes:
227
+ padder = padding.PKCS7(128).padder()
228
+ padded = padder.update(plaintext) + padder.finalize()
229
+ cipher = Cipher(algorithms.AES(key), modes.ECB())
230
+ encryptor = cipher.encryptor()
231
+ return encryptor.update(padded) + encryptor.finalize()
232
+
233
+ def decrypt(ciphertext: bytes, key: bytes) -> bytes:
234
+ cipher = Cipher(algorithms.AES(key), modes.ECB())
235
+ decryptor = cipher.decryptor()
236
+ padded = decryptor.update(ciphertext) + decryptor.finalize()
237
+ unpadder = padding.PKCS7(128).unpadder()
238
+ return unpadder.update(padded) + unpadder.finalize()
239
+
240
+ return encrypt, decrypt
241
+ except Exception: # pragma: no cover - fallback path
242
+ from Crypto.Cipher import AES
243
+ from Crypto.Util.Padding import pad, unpad
244
+
245
+ def encrypt(plaintext: bytes, key: bytes) -> bytes:
246
+ return AES.new(key, AES.MODE_ECB).encrypt(pad(plaintext, 16))
247
+
248
+ def decrypt(ciphertext: bytes, key: bytes) -> bytes:
249
+ return unpad(AES.new(key, AES.MODE_ECB).decrypt(ciphertext), 16)
250
+
251
+ return encrypt, decrypt
252
+
253
+
254
+ _AES_ENCRYPT, _AES_DECRYPT = _load_aes_helpers()
255
+
256
+
257
+ def encrypt_weixin_aes_ecb(plaintext: bytes, key: bytes) -> bytes:
258
+ return _AES_ENCRYPT(plaintext, key)
259
+
260
+
261
+ def decrypt_weixin_aes_ecb(ciphertext: bytes, key: bytes) -> bytes:
262
+ return _AES_DECRYPT(ciphertext, key)
263
+
264
+
265
+ def weixin_aes_ecb_padded_size(plaintext_size: int) -> int:
266
+ return ((int(plaintext_size) // 16) + 1) * 16
267
+
268
+
269
+ def build_weixin_cdn_upload_url(*, cdn_base_url: str, upload_param: str, filekey: str) -> str:
270
+ normalized_cdn_base_url = normalize_weixin_cdn_base_url(cdn_base_url)
271
+ return (
272
+ f"{normalized_cdn_base_url}/upload"
273
+ f"?encrypted_query_param={quote(str(upload_param or ''), safe='')}"
274
+ f"&filekey={quote(str(filekey or ''), safe='')}"
275
+ )
276
+
277
+
278
+ def build_weixin_cdn_download_url(*, cdn_base_url: str, encrypted_query_param: str) -> str:
279
+ normalized_cdn_base_url = normalize_weixin_cdn_base_url(cdn_base_url)
280
+ return (
281
+ f"{normalized_cdn_base_url}/download"
282
+ f"?encrypted_query_param={quote(str(encrypted_query_param or ''), safe='')}"
283
+ )
284
+
285
+
286
+ def _read_bounded_response_bytes(response: Any, *, max_bytes: int) -> bytes:
287
+ payload = bytearray()
288
+ while True:
289
+ chunk = response.read(65536)
290
+ if not chunk:
291
+ break
292
+ payload.extend(chunk)
293
+ if len(payload) > max_bytes:
294
+ raise RuntimeError(f"Weixin media exceeds max size limit ({max_bytes} bytes).")
295
+ return bytes(payload)
296
+
297
+
298
+ def _parse_weixin_aes_key(value: str) -> bytes:
299
+ normalized = str(value or "").strip()
300
+ if not normalized:
301
+ raise RuntimeError("Weixin media did not include `aes_key`.")
302
+ if len(normalized) == 32 and all(char in "0123456789abcdefABCDEF" for char in normalized):
303
+ return bytes.fromhex(normalized)
304
+ decoded = base64.b64decode(normalized)
305
+ if len(decoded) == 16:
306
+ return decoded
307
+ if len(decoded) == 32:
308
+ decoded_text = decoded.decode("ascii", errors="ignore")
309
+ if len(decoded_text) == 32 and all(char in "0123456789abcdefABCDEF" for char in decoded_text):
310
+ return bytes.fromhex(decoded_text)
311
+ raise RuntimeError("Weixin media `aes_key` uses an unsupported encoding.")
312
+
313
+
314
+ def download_weixin_cdn_buffer(
315
+ *,
316
+ encrypted_query_param: str,
317
+ cdn_base_url: str,
318
+ timeout: float = 20.0,
319
+ max_bytes: int = DEFAULT_WEIXIN_INBOUND_MEDIA_MAX_BYTES,
320
+ ) -> bytes:
321
+ request = Request(
322
+ build_weixin_cdn_download_url(
323
+ cdn_base_url=cdn_base_url,
324
+ encrypted_query_param=encrypted_query_param,
325
+ ),
326
+ method="GET",
327
+ )
328
+ try:
329
+ with urlopen(request, timeout=timeout) as response: # noqa: S310
330
+ return _read_bounded_response_bytes(response, max_bytes=max_bytes)
331
+ except HTTPError as exc:
332
+ body_text = exc.read().decode("utf-8", errors="replace")
333
+ raise RuntimeError(f"Weixin CDN download failed with HTTP {exc.code}: {body_text or exc.reason}") from exc
334
+
335
+
336
+ def download_and_decrypt_weixin_media(
337
+ *,
338
+ encrypted_query_param: str,
339
+ aes_key: str,
340
+ cdn_base_url: str,
341
+ timeout: float = 20.0,
342
+ max_bytes: int = DEFAULT_WEIXIN_INBOUND_MEDIA_MAX_BYTES,
343
+ ) -> bytes:
344
+ encrypted = download_weixin_cdn_buffer(
345
+ encrypted_query_param=encrypted_query_param,
346
+ cdn_base_url=cdn_base_url,
347
+ timeout=timeout,
348
+ max_bytes=max_bytes,
349
+ )
350
+ return decrypt_weixin_aes_ecb(encrypted, _parse_weixin_aes_key(aes_key))
351
+
352
+
353
+ def sniff_weixin_media_content_type(buffer: bytes, *, fallback: str = "application/octet-stream") -> str:
354
+ if buffer.startswith(b"\x89PNG\r\n\x1a\n"):
355
+ return "image/png"
356
+ if buffer.startswith(b"\xff\xd8\xff"):
357
+ return "image/jpeg"
358
+ if buffer.startswith((b"GIF87a", b"GIF89a")):
359
+ return "image/gif"
360
+ if len(buffer) >= 12 and buffer[:4] == b"RIFF" and buffer[8:12] == b"WEBP":
361
+ return "image/webp"
362
+ if buffer.startswith(b"BM"):
363
+ return "image/bmp"
364
+ if buffer.startswith(b"%PDF-"):
365
+ return "application/pdf"
366
+ if len(buffer) >= 12 and buffer[4:8] == b"ftyp":
367
+ brand = buffer[8:12]
368
+ if brand == b"qt ":
369
+ return "video/quicktime"
370
+ return "video/mp4"
371
+ return fallback
372
+
373
+
374
+ def _weixin_attachment_suffix(content_type: str) -> str:
375
+ normalized = str(content_type or "").strip().lower()
376
+ if normalized == "image/jpeg":
377
+ return ".jpg"
378
+ return mimetypes.guess_extension(normalized, strict=False) or ".bin"
379
+
380
+
381
+ def download_weixin_message_attachment(
382
+ *,
383
+ item: dict[str, Any],
384
+ dest_dir: Path,
385
+ cdn_base_url: str,
386
+ prefix: str = "weixin-inbound",
387
+ timeout: float = 20.0,
388
+ max_bytes: int = DEFAULT_WEIXIN_INBOUND_MEDIA_MAX_BYTES,
389
+ ) -> dict[str, Any] | None:
390
+ item_type = int(item.get("type") or 0)
391
+ ensure_dir(dest_dir)
392
+
393
+ if item_type == 2:
394
+ image_item = item.get("image_item") if isinstance(item.get("image_item"), dict) else {}
395
+ media = image_item.get("media") if isinstance(image_item.get("media"), dict) else {}
396
+ encrypted_query_param = str(media.get("encrypt_query_param") or "").strip()
397
+ aes_key = str(image_item.get("aeskey") or media.get("aes_key") or "").strip()
398
+ if not encrypted_query_param:
399
+ return None
400
+ payload = (
401
+ download_and_decrypt_weixin_media(
402
+ encrypted_query_param=encrypted_query_param,
403
+ aes_key=aes_key,
404
+ cdn_base_url=cdn_base_url,
405
+ timeout=timeout,
406
+ max_bytes=max_bytes,
407
+ )
408
+ if aes_key
409
+ else download_weixin_cdn_buffer(
410
+ encrypted_query_param=encrypted_query_param,
411
+ cdn_base_url=cdn_base_url,
412
+ timeout=timeout,
413
+ max_bytes=max_bytes,
414
+ )
415
+ )
416
+ content_type = sniff_weixin_media_content_type(payload, fallback="application/octet-stream")
417
+ suffix = _weixin_attachment_suffix(content_type)
418
+ name = f"{prefix}-image{suffix}"
419
+ target = dest_dir / f"{prefix}-{os.urandom(8).hex()}{suffix}"
420
+ target.write_bytes(payload)
421
+ return {
422
+ "kind": "path",
423
+ "name": name,
424
+ "content_type": content_type,
425
+ "path": str(target),
426
+ }
427
+
428
+ if item_type == 4:
429
+ file_item = item.get("file_item") if isinstance(item.get("file_item"), dict) else {}
430
+ media = file_item.get("media") if isinstance(file_item.get("media"), dict) else {}
431
+ encrypted_query_param = str(media.get("encrypt_query_param") or "").strip()
432
+ aes_key = str(media.get("aes_key") or "").strip()
433
+ if not encrypted_query_param or not aes_key:
434
+ return None
435
+ payload = download_and_decrypt_weixin_media(
436
+ encrypted_query_param=encrypted_query_param,
437
+ aes_key=aes_key,
438
+ cdn_base_url=cdn_base_url,
439
+ timeout=timeout,
440
+ max_bytes=max_bytes,
441
+ )
442
+ raw_name = str(file_item.get("file_name") or "").strip()
443
+ content_type = (
444
+ str(mimetypes.guess_type(raw_name, strict=False)[0] or "").strip().lower()
445
+ or sniff_weixin_media_content_type(payload, fallback="application/octet-stream")
446
+ )
447
+ suffix = Path(raw_name).suffix or _weixin_attachment_suffix(content_type)
448
+ name = raw_name or f"{prefix}-file{suffix}"
449
+ target = dest_dir / f"{prefix}-{os.urandom(8).hex()}{suffix or '.bin'}"
450
+ target.write_bytes(payload)
451
+ return {
452
+ "kind": "path",
453
+ "name": name,
454
+ "content_type": content_type,
455
+ "path": str(target),
456
+ }
457
+
458
+ if item_type == 5:
459
+ video_item = item.get("video_item") if isinstance(item.get("video_item"), dict) else {}
460
+ media = video_item.get("media") if isinstance(video_item.get("media"), dict) else {}
461
+ encrypted_query_param = str(media.get("encrypt_query_param") or "").strip()
462
+ aes_key = str(media.get("aes_key") or "").strip()
463
+ if not encrypted_query_param or not aes_key:
464
+ return None
465
+ payload = download_and_decrypt_weixin_media(
466
+ encrypted_query_param=encrypted_query_param,
467
+ aes_key=aes_key,
468
+ cdn_base_url=cdn_base_url,
469
+ timeout=timeout,
470
+ max_bytes=max_bytes,
471
+ )
472
+ content_type = sniff_weixin_media_content_type(payload, fallback="video/mp4")
473
+ suffix = _weixin_attachment_suffix(content_type)
474
+ name = f"{prefix}-video{suffix}"
475
+ target = dest_dir / f"{prefix}-{os.urandom(8).hex()}{suffix}"
476
+ target.write_bytes(payload)
477
+ return {
478
+ "kind": "path",
479
+ "name": name,
480
+ "content_type": content_type,
481
+ "path": str(target),
482
+ }
483
+
484
+ return None
485
+
486
+
487
+ def upload_buffer_to_weixin_cdn(
488
+ *,
489
+ buffer: bytes,
490
+ upload_param: str,
491
+ filekey: str,
492
+ cdn_base_url: str,
493
+ aes_key: bytes,
494
+ timeout: float = DEFAULT_WEIXIN_API_TIMEOUT_MS / 1000.0,
495
+ ) -> dict[str, Any]:
496
+ ciphertext = encrypt_weixin_aes_ecb(buffer, aes_key)
497
+ request = Request(
498
+ build_weixin_cdn_upload_url(cdn_base_url=cdn_base_url, upload_param=upload_param, filekey=filekey),
499
+ data=ciphertext,
500
+ method="POST",
501
+ )
502
+ request.add_header("Content-Type", "application/octet-stream")
503
+ try:
504
+ with urlopen(request, timeout=timeout) as response: # noqa: S310
505
+ download_param = str(response.headers.get("x-encrypted-param") or "").strip()
506
+ if not download_param:
507
+ raise RuntimeError("Weixin CDN upload returned no `x-encrypted-param` header.")
508
+ return {
509
+ "download_param": download_param,
510
+ "ciphertext_size": len(ciphertext),
511
+ }
512
+ except HTTPError as exc:
513
+ body_text = exc.read().decode("utf-8", errors="replace")
514
+ raise RuntimeError(f"Weixin CDN upload failed with HTTP {exc.code}: {body_text or exc.reason}") from exc
515
+
516
+
517
+ def upload_local_media_to_weixin(
518
+ *,
519
+ file_path: Path,
520
+ to_user_id: str,
521
+ base_url: str,
522
+ cdn_base_url: str,
523
+ token: str,
524
+ media_type: int,
525
+ route_tag: str | None = None,
526
+ timeout_ms: int = DEFAULT_WEIXIN_API_TIMEOUT_MS,
527
+ ) -> dict[str, Any]:
528
+ plaintext = file_path.read_bytes()
529
+ raw_size = len(plaintext)
530
+ raw_md5 = hashlib.md5(plaintext).hexdigest()
531
+ file_size = weixin_aes_ecb_padded_size(raw_size)
532
+ filekey = os.urandom(16).hex()
533
+ aes_key = os.urandom(16)
534
+ upload_url_response = get_weixin_upload_url(
535
+ base_url=base_url,
536
+ token=token,
537
+ route_tag=route_tag,
538
+ timeout_ms=timeout_ms,
539
+ body={
540
+ "filekey": filekey,
541
+ "media_type": int(media_type),
542
+ "to_user_id": str(to_user_id or "").strip(),
543
+ "rawsize": raw_size,
544
+ "rawfilemd5": raw_md5,
545
+ "filesize": file_size,
546
+ "no_need_thumb": True,
547
+ "aeskey": aes_key.hex(),
548
+ },
549
+ )
550
+ upload_param = str(upload_url_response.get("upload_param") or "").strip()
551
+ if not upload_param:
552
+ raise RuntimeError("Weixin upload URL response did not include `upload_param`.")
553
+ cdn_upload = upload_buffer_to_weixin_cdn(
554
+ buffer=plaintext,
555
+ upload_param=upload_param,
556
+ filekey=filekey,
557
+ cdn_base_url=cdn_base_url,
558
+ aes_key=aes_key,
559
+ timeout=max(float(timeout_ms) / 1000.0, 1.0),
560
+ )
561
+ return {
562
+ "filekey": filekey,
563
+ "download_param": str(cdn_upload.get("download_param") or "").strip(),
564
+ "aes_key_hex": aes_key.hex(),
565
+ # Match openclaw-weixin: media.aes_key is base64 of the hex string, not base64 of raw bytes.
566
+ "aes_key_base64": base64.b64encode(aes_key.hex().encode("ascii")).decode("ascii"),
567
+ "file_size": raw_size,
568
+ "ciphertext_size": int(cdn_upload.get("ciphertext_size") or file_size),
569
+ }
570
+
571
+
572
+ def download_weixin_remote_attachment(
573
+ *,
574
+ url: str,
575
+ dest_dir: Path,
576
+ prefix: str = "weixin-remote",
577
+ timeout: float = 20.0,
578
+ max_bytes: int = DEFAULT_WEIXIN_REMOTE_ATTACHMENT_MAX_BYTES,
579
+ ) -> Path:
580
+ ensure_dir(dest_dir)
581
+ raw_url = str(url or "").strip()
582
+ parsed_url = urlparse(raw_url)
583
+ if parsed_url.scheme.lower() not in {"http", "https"}:
584
+ raise RuntimeError("Weixin remote attachments only support `http` or `https` URLs.")
585
+ request = Request(raw_url, method="GET")
586
+ with urlopen(request, timeout=timeout) as response: # noqa: S310
587
+ payload = _read_bounded_response_bytes(response, max_bytes=max_bytes)
588
+ content_type = str(response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower()
589
+ suffix = Path(parsed_url.path).suffix
590
+ if not suffix and content_type:
591
+ guessed = mimetypes.guess_extension(content_type)
592
+ suffix = guessed or ""
593
+ target = dest_dir / f"{prefix}-{os.urandom(8).hex()}{suffix}"
594
+ target.write_bytes(payload)
595
+ return target
596
+
597
+
598
+ def weixin_context_tokens_path(root: Path) -> Path:
599
+ return ensure_dir(root) / "context_tokens.json"
600
+
601
+
602
+ def load_weixin_context_tokens(root: Path) -> dict[str, dict[str, Any]]:
603
+ payload = read_json(weixin_context_tokens_path(root), {})
604
+ if not isinstance(payload, dict):
605
+ return {}
606
+ tokens = payload.get("tokens")
607
+ return {str(key): dict(value) for key, value in (tokens or {}).items() if isinstance(value, dict)} if isinstance(tokens, dict) else {}
608
+
609
+
610
+ def save_weixin_context_tokens(root: Path, items: dict[str, dict[str, Any]]) -> None:
611
+ write_json(weixin_context_tokens_path(root), {"tokens": items})
612
+
613
+
614
+ def remember_weixin_context_token(
615
+ root: Path,
616
+ *,
617
+ user_id: str,
618
+ context_token: str,
619
+ account_id: str | None = None,
620
+ conversation_id: str | None = None,
621
+ message_id: str | None = None,
622
+ updated_at: str | None = None,
623
+ ) -> None:
624
+ normalized_user_id = str(user_id or "").strip()
625
+ normalized_context_token = str(context_token or "").strip()
626
+ if not normalized_user_id or not normalized_context_token:
627
+ return
628
+ items = load_weixin_context_tokens(root)
629
+ current = items.get(normalized_user_id, {})
630
+ items[normalized_user_id] = {
631
+ **current,
632
+ "user_id": normalized_user_id,
633
+ "context_token": normalized_context_token,
634
+ "account_id": str(account_id or current.get("account_id") or "").strip() or None,
635
+ "conversation_id": str(conversation_id or current.get("conversation_id") or "").strip() or None,
636
+ "message_id": str(message_id or current.get("message_id") or "").strip() or None,
637
+ "updated_at": str(updated_at or current.get("updated_at") or "").strip() or None,
638
+ }
639
+ save_weixin_context_tokens(root, items)
640
+
641
+
642
+ def get_weixin_context_token(root: Path, user_id: str) -> str | None:
643
+ normalized_user_id = str(user_id or "").strip()
644
+ if not normalized_user_id:
645
+ return None
646
+ items = load_weixin_context_tokens(root)
647
+ token = str((items.get(normalized_user_id) or {}).get("context_token") or "").strip()
648
+ return token or None
649
+
650
+
651
+ def weixin_sync_state_path(root: Path) -> Path:
652
+ return ensure_dir(root) / "sync_state.json"
653
+
654
+
655
+ def load_weixin_get_updates_buf(root: Path) -> str:
656
+ payload = read_json(weixin_sync_state_path(root), {})
657
+ if not isinstance(payload, dict):
658
+ return ""
659
+ return str(payload.get("get_updates_buf") or "").strip()
660
+
661
+
662
+ def save_weixin_get_updates_buf(root: Path, get_updates_buf: str) -> None:
663
+ write_json(weixin_sync_state_path(root), {"get_updates_buf": str(get_updates_buf or "")})