@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.
- package/README.md +107 -94
- package/assets/branding/connector-qq.png +0 -0
- package/assets/branding/connector-rokid.png +0 -0
- package/assets/branding/connector-weixin.png +0 -0
- package/assets/branding/projects.png +0 -0
- package/bin/ds.js +168 -9
- package/docs/assets/branding/projects.png +0 -0
- package/docs/en/00_QUICK_START.md +308 -70
- package/docs/en/01_SETTINGS_REFERENCE.md +3 -0
- package/docs/en/02_START_RESEARCH_GUIDE.md +112 -0
- package/docs/en/04_LINGZHU_CONNECTOR_GUIDE.md +62 -179
- package/docs/en/09_DOCTOR.md +41 -5
- package/docs/en/10_WEIXIN_CONNECTOR_GUIDE.md +137 -0
- package/docs/en/11_LICENSE_AND_RISK.md +256 -0
- package/docs/en/12_GUIDED_WORKFLOW_TOUR.md +427 -0
- package/docs/en/13_CORE_ARCHITECTURE_GUIDE.md +297 -0
- package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +506 -0
- package/docs/en/99_ACKNOWLEDGEMENTS.md +4 -1
- package/docs/en/README.md +79 -0
- package/docs/images/lingzhu/rokid-agent-platform-create.png +0 -0
- package/docs/images/weixin/weixin-plugin-entry.png +0 -0
- package/docs/images/weixin/weixin-plugin-entry.svg +33 -0
- package/docs/images/weixin/weixin-qr-confirm.svg +30 -0
- package/docs/images/weixin/weixin-quest-media-flow.svg +44 -0
- package/docs/images/weixin/weixin-settings-bind.svg +57 -0
- package/docs/zh/00_QUICK_START.md +315 -74
- package/docs/zh/01_SETTINGS_REFERENCE.md +3 -0
- package/docs/zh/02_START_RESEARCH_GUIDE.md +112 -0
- package/docs/zh/04_LINGZHU_CONNECTOR_GUIDE.md +62 -193
- package/docs/zh/09_DOCTOR.md +41 -5
- package/docs/zh/10_WEIXIN_CONNECTOR_GUIDE.md +144 -0
- package/docs/zh/11_LICENSE_AND_RISK.md +256 -0
- package/docs/zh/12_GUIDED_WORKFLOW_TOUR.md +423 -0
- package/docs/zh/13_CORE_ARCHITECTURE_GUIDE.md +296 -0
- package/docs/zh/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +506 -0
- package/docs/zh/99_ACKNOWLEDGEMENTS.md +4 -1
- package/docs/zh/README.md +126 -0
- package/install.sh +0 -34
- package/package.json +2 -2
- package/pyproject.toml +1 -1
- package/src/deepscientist/__init__.py +1 -1
- package/src/deepscientist/annotations.py +343 -0
- package/src/deepscientist/artifact/arxiv.py +484 -37
- package/src/deepscientist/artifact/service.py +574 -108
- package/src/deepscientist/arxiv_library.py +275 -0
- package/src/deepscientist/bash_exec/service.py +9 -0
- package/src/deepscientist/bridges/builtins.py +2 -0
- package/src/deepscientist/bridges/connectors.py +447 -0
- package/src/deepscientist/channels/__init__.py +2 -0
- package/src/deepscientist/channels/builtins.py +3 -1
- package/src/deepscientist/channels/qq.py +1 -1
- package/src/deepscientist/channels/qq_gateway.py +1 -1
- package/src/deepscientist/channels/relay.py +7 -1
- package/src/deepscientist/channels/weixin.py +59 -0
- package/src/deepscientist/channels/weixin_ilink.py +317 -0
- package/src/deepscientist/config/models.py +22 -2
- package/src/deepscientist/config/service.py +431 -60
- package/src/deepscientist/connector/__init__.py +4 -0
- package/src/deepscientist/connector/connector_profiles.py +481 -0
- package/src/deepscientist/connector/lingzhu_support.py +668 -0
- package/src/deepscientist/connector/qq_profiles.py +206 -0
- package/src/deepscientist/connector/weixin_support.py +663 -0
- package/src/deepscientist/connector_profiles.py +1 -374
- package/src/deepscientist/connector_runtime.py +2 -0
- package/src/deepscientist/daemon/api/handlers.py +165 -5
- package/src/deepscientist/daemon/api/router.py +13 -1
- package/src/deepscientist/daemon/app.py +1130 -61
- package/src/deepscientist/doctor.py +5 -2
- package/src/deepscientist/gitops/diff.py +120 -29
- package/src/deepscientist/lingzhu_support.py +1 -182
- package/src/deepscientist/mcp/server.py +11 -4
- package/src/deepscientist/prompts/builder.py +15 -0
- package/src/deepscientist/qq_profiles.py +1 -196
- package/src/deepscientist/quest/node_traces.py +23 -0
- package/src/deepscientist/quest/service.py +112 -43
- package/src/deepscientist/quest/stage_views.py +71 -5
- package/src/deepscientist/runners/codex.py +55 -3
- package/src/deepscientist/weixin_support.py +1 -0
- package/src/prompts/connectors/lingzhu.md +3 -1
- package/src/prompts/connectors/weixin.md +230 -0
- package/src/prompts/system.md +2 -0
- package/src/tui/package.json +1 -1
- package/src/ui/dist/assets/{AiManusChatView-BKZ103sn.js → AiManusChatView-D0mTXG4-.js} +156 -48
- package/src/ui/dist/assets/{AnalysisPlugin-mTTzGAlK.js → AnalysisPlugin-Db0cTXxm.js} +1 -1
- package/src/ui/dist/assets/{CliPlugin-BH58n3GY.js → CliPlugin-DrV8je02.js} +164 -9
- package/src/ui/dist/assets/{CodeEditorPlugin-BKGRUH7e.js → CodeEditorPlugin-QXMSCH71.js} +8 -8
- package/src/ui/dist/assets/{CodeViewerPlugin-BMADwFWJ.js → CodeViewerPlugin-7hhtWj_E.js} +5 -5
- package/src/ui/dist/assets/{DocViewerPlugin-ZOnTIHLN.js → DocViewerPlugin-BWMSnRJe.js} +3 -3
- package/src/ui/dist/assets/{GitDiffViewerPlugin-CQ7h1Djm.js → GitDiffViewerPlugin-7J9h9Vy_.js} +20 -21
- package/src/ui/dist/assets/{ImageViewerPlugin-GVS5MsnC.js → ImageViewerPlugin-CHJl_0lr.js} +5 -5
- package/src/ui/dist/assets/{LabCopilotPanel-BZNv1JML.js → LabCopilotPanel-1qSow1es.js} +11 -11
- package/src/ui/dist/assets/{LabPlugin-TWcJsdQA.js → LabPlugin-eQpPPCEp.js} +2 -1
- package/src/ui/dist/assets/{LatexPlugin-DIjHiR2x.js → LatexPlugin-BwRfi89Z.js} +7 -7
- package/src/ui/dist/assets/{MarkdownViewerPlugin-D3ooGAH0.js → MarkdownViewerPlugin-836PVQWV.js} +4 -4
- package/src/ui/dist/assets/{MarketplacePlugin-DfVfE9hN.js → MarketplacePlugin-C2y_556i.js} +3 -3
- package/src/ui/dist/assets/{NotebookEditor-s8JhzuX1.js → NotebookEditor-BRzJbGsn.js} +12 -12
- package/src/ui/dist/assets/{NotebookEditor-DDl0_Mc0.js → NotebookEditor-DIX7Mlzu.js} +1 -1
- package/src/ui/dist/assets/{PdfLoader-C2Sf6SJM.js → PdfLoader-DzRaTAlq.js} +14 -7
- package/src/ui/dist/assets/{PdfMarkdownPlugin-CXFLoIsa.js → PdfMarkdownPlugin-DZUfIUnp.js} +73 -6
- package/src/ui/dist/assets/{PdfViewerPlugin-BYTmz2fK.js → PdfViewerPlugin-BwtICzue.js} +103 -34
- package/src/ui/dist/assets/PdfViewerPlugin-DQ11QcSf.css +3627 -0
- package/src/ui/dist/assets/{SearchPlugin-CjWBI1O9.js → SearchPlugin-DHeIAMsx.js} +1 -1
- package/src/ui/dist/assets/{TextViewerPlugin-DdOBU3-S.js → TextViewerPlugin-C3tCmFox.js} +5 -4
- package/src/ui/dist/assets/{VNCViewer-B8HGgLwQ.js → VNCViewer-CQsKVm3t.js} +10 -10
- package/src/ui/dist/assets/bot-BEA2vWuK.js +21 -0
- package/src/ui/dist/assets/branding/logo-rokid.png +0 -0
- package/src/ui/dist/assets/browser-BAcuE0Xj.js +2895 -0
- package/src/ui/dist/assets/{code-BWAY76JP.js → code-XfbSR8K2.js} +1 -1
- package/src/ui/dist/assets/{file-content-C1NwU5oQ.js → file-content-BjxNaIfy.js} +1 -1
- package/src/ui/dist/assets/{file-diff-panel-CywslwB9.js → file-diff-panel-D_lLVQk0.js} +1 -1
- package/src/ui/dist/assets/{file-socket-B4kzuOBQ.js → file-socket-D9x_5vlY.js} +1 -1
- package/src/ui/dist/assets/{image-D-NZM-6P.js → image-BhWT33W1.js} +1 -1
- package/src/ui/dist/assets/{index-DHZJ_0TI.js → index--c4iXtuy.js} +12 -12
- package/src/ui/dist/assets/{index-BdM1Gqfr.js → index-BDxipwrC.js} +2 -2
- package/src/ui/dist/assets/{index-7Chr1g9c.js → index-DZTZ8mWP.js} +14221 -9523
- package/src/ui/dist/assets/{index-DGIYDuTv.css → index-Dqj-Mjb4.css} +2 -13
- package/src/ui/dist/assets/index-PJbSbPTy.js +25 -0
- package/src/ui/dist/assets/{monaco-Cb2uKKe6.js → monaco-K8izTGgo.js} +1 -1
- package/src/ui/dist/assets/{pdf-effect-queue-DSw_D3RV.js → pdf-effect-queue-DfBors6y.js} +16 -1
- package/src/ui/dist/assets/pdf.worker.min-yatZIOMy.mjs +21 -0
- package/src/ui/dist/assets/{popover-Bg72DGgT.js → popover-yFK1J4fL.js} +1 -1
- package/src/ui/dist/assets/{project-sync-Ce_0BglY.js → project-sync-PENr2zcz.js} +1 -74
- package/src/ui/dist/assets/select-CAbJDfYv.js +1690 -0
- package/src/ui/dist/assets/{sigma-DPaACDrh.js → sigma-DEuYJqTl.js} +1 -1
- package/src/ui/dist/assets/{index-CDxNdQdz.js → square-check-big-omoSUmcd.js} +2 -13
- package/src/ui/dist/assets/{trash-BvTgE5__.js → trash--F119N47.js} +1 -1
- package/src/ui/dist/assets/{useCliAccess-CgPeMOwP.js → useCliAccess-D31UR23I.js} +1 -1
- package/src/ui/dist/assets/{useFileDiffOverlay-xPhz7P5B.js → useFileDiffOverlay-BH6KcMzq.js} +1 -1
- package/src/ui/dist/assets/{wrap-text-C3Un3YQr.js → wrap-text-CZ613PM5.js} +1 -1
- package/src/ui/dist/assets/{zoom-out-BgxLa0Ri.js → zoom-out-BgDLAv3z.js} +1 -1
- package/src/ui/dist/index.html +2 -2
- package/src/ui/dist/assets/AutoFigurePlugin-BGxN8Umr.css +0 -3056
- package/src/ui/dist/assets/AutoFigurePlugin-C_wWw4AP.js +0 -8149
- package/src/ui/dist/assets/PdfViewerPlugin-BJXtIwj_.css +0 -260
- package/src/ui/dist/assets/Stepper-B0Dd8CxK.js +0 -158
- package/src/ui/dist/assets/bibtex-CKaefIN2.js +0 -189
- package/src/ui/dist/assets/file-utils-H2fjA46S.js +0 -109
- package/src/ui/dist/assets/message-square-BzjLiXir.js +0 -16
- package/src/ui/dist/assets/pdfjs-DU1YE8WO.js +0 -3
- 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 "")})
|