sophhub 0.4.37 → 0.4.38

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sophhub",
3
- "version": "0.4.37",
3
+ "version": "0.4.38",
4
4
  "description": "SophHub CLI - Manage and download AI Agent skills and agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "claw-friend-message-capture",
3
+ "version": "1.2.0",
4
+ "types": ["store"],
5
+ "displayName": "抓取虾友消息",
6
+ "description": "自动拉取虾友发送的 IM 消息(含文字、文件、截图),支持按好友或时间筛选,下载附件到本地。触发词:抓取虾友消息、查看虾友消息、获取好友消息、拉取未读消息、下载虾友附件。",
7
+ "changelog": [
8
+ {
9
+ "version": "1.2.0",
10
+ "date": "2026-05-29",
11
+ "changes": [
12
+ "添加 setup 子命令:支持手动配置 refresh token(从浏览器 DevTools 获取)",
13
+ "refresh token 多来源支持:jwt.json、环境变量 SOPHCLAW_REFRESH_TOKEN、文件 ~/.openclaw/refresh_token",
14
+ "添加首次设置说明文档"
15
+ ]
16
+ },
17
+ {
18
+ "version": "1.1.0",
19
+ "date": "2026-05-29",
20
+ "changes": [
21
+ "JWT 自动刷新:集成 sophclaw_auth 共享模块,access token 过期时自动用 refresh token 续期,有效期从 2h 延长到 30 天"
22
+ ]
23
+ },
24
+ {
25
+ "version": "1.0.1",
26
+ "date": "2026-05-29",
27
+ "changes": [
28
+ "修复容器 DNS ndots:5 导致 Python urllib3 解析超时:脚本启动时设 RES_OPTIONS=ndots:1"
29
+ ]
30
+ },
31
+ {
32
+ "version": "1.0.0",
33
+ "date": "2026-05-29",
34
+ "changes": [
35
+ "初次提交:查看频道列表、拉取好友消息历史、下载附件(图片/文件)、按时间增量抓取"
36
+ ]
37
+ }
38
+ ],
39
+ "createdAt": "2026-05-29",
40
+ "updatedAt": "2026-05-29T12:00:00Z"
41
+ }
@@ -0,0 +1,115 @@
1
+ ---
2
+ name: claw-friend-message-capture
3
+ description: 自动抓取虾友发送的 IM 消息,包括文字、截图和文件附件。场景:抓取虾友消息、查看虾友消息、获取好友消息、拉取未读消息、下载虾友附件。
4
+ ---
5
+
6
+ # 抓取虾友消息
7
+
8
+ 通过 Sophnet IM API 拉取虾友(好友)发送的消息,支持文字、图片截图和文件附件。脚本 `{baseDir}/scripts/capture_messages.py` 提供四个子命令。
9
+
10
+ ## 子命令速查
11
+
12
+ | 子命令 | 用途 | 示例场景 |
13
+ |--------|------|---------|
14
+ | `friends` | 查看所有虾友及 DM 频道 | "列出我的虾友" |
15
+ | `list` | 拉取指定好友的消息历史 | "查看小明的最近消息" |
16
+ | `unread` | 拉取所有未读/最近消息 | "抓取虾友消息" / "查看未读消息" |
17
+ | `download` | 下载消息附件(图片/文件) | "下载虾友发的截图" |
18
+
19
+ ## 用法
20
+
21
+ ### 1. 列出虾友 + DM 频道
22
+
23
+ ```bash
24
+ uv run {baseDir}/scripts/capture_messages.py friends --json
25
+ ```
26
+
27
+ 输出每项含 `friendId`、`nickname`、`dmChannelId`(rid)。
28
+
29
+ ### 2. 拉取指定好友消息
30
+
31
+ ```bash
32
+ # 通过 friendId 自动查找 DM 频道
33
+ uv run {baseDir}/scripts/capture_messages.py list --friend-id 123 --count 30
34
+
35
+ # 直接用 rid(DM 频道 ID)
36
+ uv run {baseDir}/scripts/capture_messages.py list --rid "<rid>" --count 30
37
+
38
+ # 指定时间范围(latest 为毫秒时间戳,拉取此时间之前的消息)
39
+ uv run {baseDir}/scripts/capture_messages.py list --rid "<rid>" --latest 1700000000000 --count 20
40
+ ```
41
+
42
+ ### 3. 抓取所有未读/最近消息
43
+
44
+ ```bash
45
+ # 拉取所有 DM 频道最新消息
46
+ uv run {baseDir}/scripts/capture_messages.py unread
47
+
48
+ # 仅拉取有未读的频道,每个频道 20 条
49
+ uv run {baseDir}/scripts/capture_messages.py unread --count 20 --min-unread 1
50
+ ```
51
+
52
+ ### 4. 下载消息附件
53
+
54
+ ```bash
55
+ # 下载指定消息中的图片和文件
56
+ uv run {baseDir}/scripts/capture_messages.py download \
57
+ --rid "<rid>" \
58
+ --msg-id "<消息ID>" \
59
+ --out-dir ./downloads
60
+ ```
61
+
62
+ 附件保存到 `{out-dir}/images/`(截图)和 `{out-dir}/files/`(文件)。
63
+
64
+ ## 执行建议
65
+
66
+ - 用户说「抓取虾友消息」→ 先执行 `friends --json`,然后对每个 DM 频道执行 `list` 或批量执行 `unread`
67
+ - 用户说「下载虾友发的图片」→ 从消息 JSON 中找到 `imageAttachments`,对目标消息执行 `download`
68
+ - 用户提供昵称 → 从 `friends` 输出中匹配 `nickname`,拿到 `dmChannelId` 再拉消息
69
+
70
+ ## 首次设置(获取 refresh token)
71
+
72
+ 首次使用前需配置 refresh token,之后 JWT 过期会自动续期。
73
+
74
+ ### 获取 refresh token
75
+
76
+ 1. 浏览器打开 Sophnet 并登录
77
+ 2. 按 F12 → **应用**(Application)→ **本地存储**(Local Storage)→ `user-info`
78
+ 3. 复制其中 `refreshToken` 字段的值
79
+
80
+ 也可以打开浏览器控制台(Console),执行以下命令直接复制:
81
+
82
+ ```js
83
+ copy(JSON.parse(localStorage['user-info']).refreshToken)
84
+ ```
85
+
86
+ ### 配置 refresh token(三选一)
87
+
88
+ **方式 A — setup 子命令(推荐)**:
89
+
90
+ ```bash
91
+ uv run {baseDir}/scripts/capture_messages.py setup --refresh-token "<你的refreshToken>"
92
+ ```
93
+
94
+ **方式 B — 环境变量**(重启后失效):
95
+
96
+ ```bash
97
+ export SOPHCLAW_REFRESH_TOKEN="<你的refreshToken>"
98
+ ```
99
+
100
+ **方式 C — 文件持久化**:
101
+
102
+ ```bash
103
+ echo "<你的refreshToken>" > ~/.openclaw/refresh_token
104
+ ```
105
+
106
+ 配置完成后即可正常使用,JWT 过期时自动续期。
107
+
108
+ ## 注意事项
109
+
110
+ - 从 `/home/node/.openclaw/jwt.json` 的 `web_jwt` 字段读取 JWT,通过 `sophclaw_auth` 模块自动检测过期并续期(refresh token 有效期最长 30 天)
111
+ - refresh token 读取优先级:jwt.json → 环境变量 `SOPHCLAW_REFRESH_TOKEN` → 文件 `~/.openclaw/refresh_token`
112
+ - 默认 API 地址:`https://yagent.sophnet.com/api`,可通过 `--api-base-url` 或环境变量 `SOPHCLAW_API_BASE_URL` 覆盖
113
+ - 附件 URL 默认按 `https://www.sophnet.com` 拼接 Origin,可通过 `--origin` 或 `SOPHCLAW_ORIGIN` 覆盖
114
+ - 凭证文件和 JWT 切勿提交 Git
115
+ - 本 skill 仅提供单次拉取,如需定时自动抓取,请在 Agent 侧配置 Cron Job
@@ -0,0 +1,12 @@
1
+ [project]
2
+ name = "claw-friend-message-capture"
3
+ version = "1.2.0"
4
+ description = "Capture Sophclaw friend IM messages including files and screenshots"
5
+ requires-python = ">=3.8"
6
+ dependencies = [
7
+ "requests>=2.28.0",
8
+ ]
9
+
10
+ # [[tool.uv.index]]
11
+ # url = "https://mirrors.aliyun.com/pypi/simple/"
12
+ # default = true
@@ -0,0 +1,573 @@
1
+ #!/usr/bin/env python3
2
+ """抓取虾友 IM 消息:好友列表、消息拉取、附件下载。"""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import hashlib
8
+ import json
9
+ import os
10
+ import sys
11
+ import time
12
+ import urllib.parse
13
+ from pathlib import Path
14
+ from typing import Any, Optional
15
+
16
+ import requests
17
+
18
+ from sophclaw_auth import SophclawAuth
19
+
20
+ # 修复容器 DNS ndots:5 问题:避免 Python urllib3 在 search domain 上耗尽重试
21
+ os.environ.setdefault("RES_OPTIONS", "ndots:1")
22
+ os.environ.setdefault("SOCKET_THREAD_POOL_SIZE", "0")
23
+
24
+ DEFAULT_TIMEOUT = 30
25
+ DEFAULT_API_BASE_URL = "https://yagent.sophnet.com/api"
26
+ DEFAULT_JWT_PATH = Path("/home/node/.openclaw/jwt.json")
27
+ MAX_RETRIES = 3
28
+ RETRY_DELAY = 2
29
+ DEFAULT_PAGE_SIZE = 50
30
+
31
+
32
+ class AppError(RuntimeError):
33
+ pass
34
+
35
+
36
+ def configure_stdio() -> None:
37
+ for name in ("stdout", "stderr"):
38
+ stream = getattr(sys, name, None)
39
+ if stream is not None and hasattr(stream, "reconfigure"):
40
+ stream.reconfigure(encoding="utf-8", errors="replace")
41
+
42
+
43
+ def print_json(payload: Any) -> None:
44
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
45
+
46
+
47
+ def request_json(
48
+ method: str,
49
+ url: str,
50
+ token: str,
51
+ timeout: int,
52
+ payload: Optional[dict[str, Any]] = None,
53
+ raw_response: bool = False,
54
+ ) -> Any:
55
+ headers = {
56
+ "Authorization": "Bearer {}".format(token),
57
+ "Accept": "application/json",
58
+ }
59
+ if payload is not None:
60
+ headers["Content-Type"] = "application/json"
61
+
62
+ last_error = None
63
+ for attempt in range(1, MAX_RETRIES + 1):
64
+ try:
65
+ resp = requests.request(
66
+ method=method,
67
+ url=url,
68
+ headers=headers,
69
+ json=payload,
70
+ timeout=timeout,
71
+ )
72
+ if raw_response:
73
+ return resp
74
+ if not resp.ok:
75
+ raise AppError(
76
+ "HTTP {} {}: {}".format(resp.status_code, url, resp.text[:500])
77
+ )
78
+ data = resp.json()
79
+ if isinstance(data, dict) and data.get("status") not in (None, 0):
80
+ raise AppError(
81
+ "接口错误: status={} message={!r}".format(
82
+ data.get("status"), data.get("message")
83
+ )
84
+ )
85
+ return data if isinstance(data, dict) else {"result": data}
86
+ except requests.exceptions.ConnectionError as exc:
87
+ last_error = exc
88
+ if attempt < MAX_RETRIES:
89
+ time.sleep(RETRY_DELAY * attempt)
90
+ except requests.exceptions.Timeout as exc:
91
+ last_error = exc
92
+ if attempt < MAX_RETRIES:
93
+ time.sleep(RETRY_DELAY * attempt)
94
+ except AppError:
95
+ raise
96
+ except Exception as exc:
97
+ last_error = exc
98
+ if attempt < MAX_RETRIES:
99
+ time.sleep(RETRY_DELAY * attempt)
100
+
101
+ raise AppError("请求失败 {} (重试 {} 次后仍失败): {}".format(url, MAX_RETRIES, last_error))
102
+
103
+
104
+ def unwrap_result(data: dict[str, Any]) -> Any:
105
+ return data.get("result") if "result" in data else data
106
+
107
+
108
+ def resolve_api_base(args: argparse.Namespace) -> str:
109
+ if args.api_base_url and args.api_base_url.strip():
110
+ return args.api_base_url.strip().rstrip("/")
111
+ env_base = os.environ.get("SOPHCLAW_API_BASE_URL", "").strip()
112
+ if env_base:
113
+ return env_base.rstrip("/")
114
+ return DEFAULT_API_BASE_URL
115
+
116
+
117
+ def resolve_origin(args: argparse.Namespace) -> str:
118
+ if args.origin and args.origin.strip():
119
+ return args.origin.strip().rstrip("/")
120
+ env_origin = os.environ.get("SOPHCLAW_ORIGIN", "").strip()
121
+ if env_origin:
122
+ return env_origin.rstrip("/")
123
+ return "https://www.sophnet.com"
124
+
125
+
126
+ def expand_path(path_str: str) -> Path:
127
+ return Path(path_str).expanduser().resolve()
128
+
129
+
130
+ def looks_like_image(url: str, name: Optional[str] = None) -> bool:
131
+ lower = (name or "") + (url or "")
132
+ return bool(
133
+ __import__("re").search(r"\.(jpg|jpeg|png|gif|webp|bmp|heic)(\?|$)", lower, __import__("re").IGNORECASE)
134
+ )
135
+
136
+
137
+ def cmd_setup(args: argparse.Namespace) -> int:
138
+ """将 refresh token 写入 jwt.json。"""
139
+ try:
140
+ path = SophclawAuth.setup_refresh_token(
141
+ refresh_token=args.refresh_token,
142
+ jwt_path=expand_path(args.jwt_path),
143
+ )
144
+ print("refresh token 已写入 {}".format(path))
145
+ print("现在可以正常使用技能了,JWT 过期时将会自动续期。")
146
+ return 0
147
+ except Exception as exc:
148
+ print("设置失败: {}".format(exc), file=sys.stderr)
149
+ return 1
150
+
151
+
152
+ def normalize_message(m: dict[str, Any], origin: str) -> dict[str, Any]:
153
+ """标准化消息对象,分离图片和文件附件。"""
154
+ msg = {
155
+ "id": str(m.get("_id", m.get("id", ""))),
156
+ "rid": str(m.get("rid", "")),
157
+ "msg": str(m.get("msg", "")),
158
+ "ts": m.get("ts"),
159
+ "updatedAt": m.get("_updatedAt", m.get("updatedAt")),
160
+ "u": None,
161
+ "imageAttachments": [],
162
+ "fileAttachments": [],
163
+ }
164
+
165
+ if isinstance(m.get("u"), dict):
166
+ u = m["u"]
167
+ msg["u"] = {
168
+ "id": str(u.get("_id", u.get("id", ""))),
169
+ "username": str(u.get("username", "")),
170
+ "name": u.get("name"),
171
+ }
172
+
173
+ all_attachments = []
174
+ if isinstance(m.get("attachments"), list):
175
+ all_attachments.extend(m["attachments"])
176
+ if isinstance(m.get("file"), dict):
177
+ all_attachments.append(m["file"])
178
+ if isinstance(m.get("files"), list):
179
+ all_attachments.extend(m["files"])
180
+
181
+ for att in all_attachments:
182
+ if not isinstance(att, dict):
183
+ continue
184
+ url = att.get("image_url") or att.get("imageUrl") or att.get("url", "")
185
+ name = att.get("name") or att.get("title")
186
+ if url and (str(att.get("type", "")).startswith("image/") or looks_like_image(url, name)):
187
+ full_url = _resolve_url(url, origin)
188
+ msg["imageAttachments"].append({"url": full_url, "title": name})
189
+ elif url:
190
+ full_url = _resolve_url(url, origin)
191
+ msg["fileAttachments"].append({
192
+ "url": full_url,
193
+ "name": name,
194
+ "type": att.get("type"),
195
+ "size": att.get("size"),
196
+ })
197
+
198
+ return msg
199
+
200
+
201
+ def _resolve_url(raw: str, origin: str) -> str:
202
+ if not raw:
203
+ return raw
204
+ if raw.startswith("http://") or raw.startswith("https://"):
205
+ return raw
206
+ if raw.startswith("/"):
207
+ return origin.rstrip("/") + raw
208
+ return raw
209
+
210
+
211
+ def download_file(url: str, dest: Path, token: str, timeout: int) -> Path:
212
+ """下载文件到 dest,dest 为父目录,自动处理文件名。"""
213
+ dest = dest.expanduser().resolve()
214
+ dest.mkdir(parents=True, exist_ok=True)
215
+
216
+ resp = request_json("GET", url, token, timeout, raw_response=True)
217
+ content_type = resp.headers.get("Content-Type", "")
218
+ content_disp = resp.headers.get("Content-Disposition", "")
219
+
220
+ filename = None
221
+ if "filename=" in content_disp:
222
+ import re
223
+ m = re.search(r'filename[^=]*="?([^";\s]+)"?', content_disp)
224
+ if m:
225
+ filename = m.group(1)
226
+
227
+ if not filename:
228
+ parsed = urllib.parse.urlparse(url)
229
+ path_part = parsed.path
230
+ if path_part and "/" in path_part:
231
+ filename = path_part.rsplit("/", 1)[-1]
232
+ if not filename or "." not in filename:
233
+ ext = ".jpg" if "image" in content_type else ".bin"
234
+ h = hashlib.md5(url.encode()).hexdigest()[:8]
235
+ filename = "{}{}".format(h, ext)
236
+
237
+ filepath = dest / filename
238
+ with filepath.open("wb") as fp:
239
+ for chunk in resp.iter_content(chunk_size=8192):
240
+ fp.write(chunk)
241
+
242
+ return filepath
243
+
244
+
245
+ # ---------------------------------------------------------------------------
246
+ # 子命令
247
+ # ---------------------------------------------------------------------------
248
+
249
+
250
+ def cmd_friends(args: argparse.Namespace) -> int:
251
+ """列出所有虾友及其 DM 频道。"""
252
+ api_base = resolve_api_base(args)
253
+ auth = SophclawAuth(jwt_path=expand_path(args.jwt_path), api_base_url=api_base)
254
+ token = auth.get_valid_token()
255
+
256
+ # 获取好友列表
257
+ data = request_json("GET", "{}/sys/openclaw/friend/list".format(api_base), token, args.timeout)
258
+ result = unwrap_result(data)
259
+ friends = result.get("friends", []) if isinstance(result, dict) else []
260
+
261
+ output = []
262
+ for f in friends:
263
+ friend_id = f.get("friendId")
264
+ nickname = f.get("nickname", "未知")
265
+ dm_channel = None
266
+
267
+ # 获取/创建 DM 频道
268
+ if friend_id:
269
+ try:
270
+ dm_data = request_json(
271
+ "POST",
272
+ "{}/sys/openclaw/friend/dm".format(api_base),
273
+ token,
274
+ args.timeout,
275
+ payload={"friendId": friend_id},
276
+ )
277
+ dm_result = unwrap_result(dm_data)
278
+ dm_channel = (
279
+ dm_result.get("channel") if isinstance(dm_result, dict) else dm_result
280
+ )
281
+ except AppError:
282
+ pass
283
+
284
+ output.append({
285
+ "friendId": friend_id,
286
+ "nickname": nickname,
287
+ "avatarUrl": f.get("avatarUrl"),
288
+ "dmChannelId": str(dm_channel.get("_id", "")) if dm_channel else None,
289
+ "dmChannelName": dm_channel.get("fname") or dm_channel.get("name") if dm_channel else None,
290
+ })
291
+
292
+ if args.json:
293
+ print_json({"total": len(output), "friends": output})
294
+ else:
295
+ for item in output:
296
+ ch_id = item["dmChannelId"] or "无"
297
+ print(" {} (friendId={}, dm={})".format(item["nickname"], item["friendId"], ch_id))
298
+
299
+ return 0
300
+
301
+
302
+ def cmd_list(args: argparse.Namespace) -> int:
303
+ """拉取指定好友/频道的历史消息。"""
304
+ api_base = resolve_api_base(args)
305
+ origin = resolve_origin(args)
306
+ auth = SophclawAuth(jwt_path=expand_path(args.jwt_path), api_base_url=api_base)
307
+ token = auth.get_valid_token()
308
+
309
+ rid = args.rid
310
+ if not rid and args.friend_id is not None:
311
+ # 通过 friendId 查找 DM 频道
312
+ dm_data = request_json(
313
+ "POST",
314
+ "{}/sys/openclaw/friend/dm".format(api_base),
315
+ token,
316
+ args.timeout,
317
+ payload={"friendId": args.friend_id},
318
+ )
319
+ dm_result = unwrap_result(dm_data)
320
+ dm_channel = dm_result.get("channel") if isinstance(dm_result, dict) else dm_result
321
+ if dm_channel:
322
+ rid = str(dm_channel.get("_id", ""))
323
+
324
+ if not rid:
325
+ raise AppError("请指定 --rid 或 --friend-id")
326
+
327
+ count = args.count if args.count and args.count > 0 else DEFAULT_PAGE_SIZE
328
+ params = {"rid": rid, "count": min(count, 100)}
329
+ if args.latest is not None:
330
+ params["latest"] = args.latest
331
+ if args.oldest is not None:
332
+ params["oldest"] = args.oldest
333
+ if args.offset is not None:
334
+ params["offset"] = args.offset
335
+
336
+ query = "&".join("{}={}".format(k, v) for k, v in params.items())
337
+ url = "{}/sys/openclaw/im/channels.history?{}".format(api_base, query)
338
+ data = request_json("GET", url, token, args.timeout)
339
+ result = unwrap_result(data)
340
+
341
+ raw_messages = []
342
+ if isinstance(result, dict):
343
+ raw_messages = result.get("messages", [])
344
+ elif isinstance(result, list):
345
+ raw_messages = result
346
+
347
+ messages = [normalize_message(m, origin) for m in raw_messages if isinstance(m, dict)]
348
+
349
+ output = {
350
+ "rid": rid,
351
+ "count": len(messages),
352
+ "messages": messages,
353
+ }
354
+ print_json(output)
355
+ return 0
356
+
357
+
358
+ def cmd_unread(args: argparse.Namespace) -> int:
359
+ """拉取所有 DM 频道的最近消息(模拟未读消息)。"""
360
+ api_base = resolve_api_base(args)
361
+ origin = resolve_origin(args)
362
+ auth = SophclawAuth(jwt_path=expand_path(args.jwt_path), api_base_url=api_base)
363
+ token = auth.get_valid_token()
364
+
365
+ # 拉取频道列表(含未读数)
366
+ data = request_json("GET", "{}/sys/openclaw/im/channels.list".format(api_base), token, args.timeout)
367
+ result = unwrap_result(data)
368
+ channels = result.get("channels", []) if isinstance(result, dict) else []
369
+ if isinstance(channels, list):
370
+ pass
371
+ else:
372
+ channels = []
373
+
374
+ # 过滤出 DM 频道 (t == "d")
375
+ dm_channels = [ch for ch in channels if isinstance(ch, dict) and ch.get("t") == "d"]
376
+
377
+ min_unread = args.min_unread if args.min_unread is not None else 0
378
+ if min_unread > 0:
379
+ dm_channels = [ch for ch in dm_channels if ch.get("unread", 0) >= min_unread]
380
+
381
+ count = args.count if args.count and args.count > 0 else 20
382
+ output_channels = []
383
+
384
+ for ch in dm_channels:
385
+ rid = str(ch.get("_id", ch.get("rid", "")))
386
+ # 拉取最近消息
387
+ try:
388
+ h_url = "{}/sys/openclaw/im/channels.history?rid={}&count={}".format(
389
+ api_base, rid, min(count, 100)
390
+ )
391
+ h_data = request_json("GET", h_url, token, args.timeout)
392
+ h_result = unwrap_result(h_data)
393
+ raw_msgs = (
394
+ h_result.get("messages", [])
395
+ if isinstance(h_result, dict)
396
+ else (h_result if isinstance(h_result, list) else [])
397
+ )
398
+ messages = [
399
+ normalize_message(m, origin) for m in raw_msgs if isinstance(m, dict)
400
+ ]
401
+ except AppError:
402
+ messages = []
403
+
404
+ output_channels.append({
405
+ "rid": rid,
406
+ "name": ch.get("fname") or ch.get("name", ""),
407
+ "unread": ch.get("unread", 0),
408
+ "lm": ch.get("lm"),
409
+ "lastMessage": ch.get("lastMessage"),
410
+ "messages": messages,
411
+ "messageCount": len(messages),
412
+ })
413
+
414
+ output = {
415
+ "channels": output_channels,
416
+ "total": len(output_channels),
417
+ }
418
+ print_json(output)
419
+ return 0
420
+
421
+
422
+ def cmd_download(args: argparse.Namespace) -> int:
423
+ """下载指定消息的附件。"""
424
+ api_base = resolve_api_base(args)
425
+ origin = resolve_origin(args)
426
+ auth = SophclawAuth(jwt_path=expand_path(args.jwt_path), api_base_url=api_base)
427
+ token = auth.get_valid_token()
428
+ out_dir = expand_path(args.out_dir or "./shrimp_attachments")
429
+
430
+ rid = args.rid
431
+ msg_id = args.msg_id
432
+
433
+ if not rid or not msg_id:
434
+ raise AppError("请同时指定 --rid 和 --msg-id")
435
+
436
+ # 查历史消息找到目标消息
437
+ url = "{}/sys/openclaw/im/channels.history?rid={}&count=100".format(api_base, rid)
438
+ data = request_json("GET", url, token, args.timeout)
439
+ result = unwrap_result(data)
440
+ raw_msgs = (
441
+ result.get("messages", []) if isinstance(result, dict) else (result if isinstance(result, list) else [])
442
+ )
443
+
444
+ target_msg = None
445
+ for m in raw_msgs:
446
+ m_id = str(m.get("_id", m.get("id", "")))
447
+ if m_id == msg_id or msg_id in m_id:
448
+ target_msg = m
449
+ break
450
+
451
+ if target_msg is None:
452
+ raise AppError("未找到消息: msgId={}".format(msg_id))
453
+
454
+ normalized = normalize_message(target_msg, origin)
455
+ image_dir = out_dir / "images"
456
+ file_dir = out_dir / "files"
457
+ downloaded = []
458
+
459
+ # 下载图片
460
+ for idx, img in enumerate(normalized.get("imageAttachments", [])):
461
+ img_url = img.get("url")
462
+ if img_url:
463
+ try:
464
+ dest = download_file(img_url, image_dir, token, args.timeout)
465
+ downloaded.append({"type": "image", "url": img_url, "path": str(dest)})
466
+ except AppError as exc:
467
+ downloaded.append({"type": "image", "url": img_url, "error": str(exc)})
468
+
469
+ # 下载文件
470
+ for idx, file_att in enumerate(normalized.get("fileAttachments", [])):
471
+ file_url = file_att.get("url")
472
+ if file_url:
473
+ try:
474
+ dest = download_file(file_url, file_dir, token, args.timeout)
475
+ downloaded.append({"type": "file", "url": file_url, "path": str(dest)})
476
+ except AppError as exc:
477
+ downloaded.append({"type": "file", "url": file_url, "error": str(exc)})
478
+
479
+ output = {
480
+ "msgId": normalized["id"],
481
+ "downloaded": downloaded,
482
+ "total": len(downloaded),
483
+ }
484
+ print_json(output)
485
+ return 0
486
+
487
+
488
+ # ---------------------------------------------------------------------------
489
+ # 命令行入口
490
+ # ---------------------------------------------------------------------------
491
+
492
+
493
+ def add_http_args(parser: argparse.ArgumentParser) -> None:
494
+ parser.add_argument(
495
+ "--api-base-url",
496
+ default="",
497
+ help="虾友 API 根地址(默认 {})".format(DEFAULT_API_BASE_URL),
498
+ )
499
+ parser.add_argument("--jwt-path", default=str(DEFAULT_JWT_PATH), help="JWT 文件路径")
500
+ parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT, help="HTTP 超时秒数")
501
+
502
+
503
+ def build_parser() -> argparse.ArgumentParser:
504
+ parser = argparse.ArgumentParser(description="抓取虾友 IM 消息")
505
+ sub = parser.add_subparsers(dest="command", required=True)
506
+
507
+ # friends
508
+ p_friends = sub.add_parser("friends", help="列出所有虾友及 DM 频道")
509
+ add_http_args(p_friends)
510
+ p_friends.add_argument("--json", action="store_true", help="JSON 输出")
511
+
512
+ # list
513
+ p_list = sub.add_parser("list", help="拉取指定好友/频道的历史消息")
514
+ add_http_args(p_list)
515
+ p_list.add_argument("--origin", default="", help="附件 URL 拼接前缀(默认 https://www.sophnet.com)")
516
+ p_list.add_argument("--rid", default="", help="DM 频道 ID")
517
+ p_list.add_argument("--friend-id", type=int, default=None, help="好友 ID(自动查找 DM 频道)")
518
+ p_list.add_argument("--count", type=int, default=DEFAULT_PAGE_SIZE, help="拉取条数(默认 {},上限 100)".format(DEFAULT_PAGE_SIZE))
519
+ p_list.add_argument("--latest", type=int, default=None, help="拉取此时间戳之前的消息(毫秒)")
520
+ p_list.add_argument("--oldest", type=int, default=None, help="拉取此时间戳之后的消息(毫秒)")
521
+ p_list.add_argument("--offset", type=int, default=None, help="偏移量")
522
+
523
+ # unread
524
+ p_unread = sub.add_parser("unread", help="拉取所有 DM 频道的最近/未读消息")
525
+ add_http_args(p_unread)
526
+ p_unread.add_argument("--origin", default="", help="附件 URL 拼接前缀(默认 https://www.sophnet.com)")
527
+ p_unread.add_argument("--count", type=int, default=20, help="每个频道拉取条数(默认 20)")
528
+ p_unread.add_argument("--min-unread", type=int, default=0, help="最小未读数过滤(默认 0 表示全部)")
529
+
530
+ # download
531
+ p_download = sub.add_parser("download", help="下载指定消息的附件")
532
+ add_http_args(p_download)
533
+ p_download.add_argument("--origin", default="", help="附件 URL 拼接前缀")
534
+ p_download.add_argument("--rid", required=True, help="频道 ID")
535
+ p_download.add_argument("--msg-id", required=True, help="消息 ID")
536
+ p_download.add_argument("--out-dir", default="./shrimp_attachments", help="输出目录")
537
+
538
+ # setup — 设置 refresh token
539
+ p_setup = sub.add_parser("setup", help="设置 refresh token 到 jwt.json")
540
+ p_setup.add_argument(
541
+ "--refresh-token", required=True, help="从浏览器 localStorage user-info 中复制的 refreshToken"
542
+ )
543
+ p_setup.add_argument("--jwt-path", default=str(DEFAULT_JWT_PATH), help="JWT 文件路径")
544
+
545
+ return parser
546
+
547
+
548
+ def main(argv: Optional[list[str]] = None) -> int:
549
+ configure_stdio()
550
+ args = build_parser().parse_args(argv)
551
+ try:
552
+ if args.timeout <= 0:
553
+ raise AppError("timeout 必须为正整数")
554
+
555
+ if args.command == "friends":
556
+ return cmd_friends(args)
557
+ if args.command == "list":
558
+ return cmd_list(args)
559
+ if args.command == "unread":
560
+ return cmd_unread(args)
561
+ if args.command == "download":
562
+ return cmd_download(args)
563
+ if args.command == "setup":
564
+ return cmd_setup(args)
565
+
566
+ raise AppError("未知命令: {}".format(args.command))
567
+ except AppError as exc:
568
+ print("ERROR: {}".format(exc), file=sys.stderr)
569
+ return 1
570
+
571
+
572
+ if __name__ == "__main__":
573
+ raise SystemExit(main())
@@ -0,0 +1,395 @@
1
+ #!/usr/bin/env python3
2
+ """Sophclaw Skill 共享认证模块:JWT 自动刷新。
3
+
4
+ 用法:
5
+ from sophclaw_auth import SophclawAuth
6
+
7
+ auth = SophclawAuth(api_base_url="https://yagent.sophnet.com/api")
8
+ token = auth.get_valid_token() # 自动检测过期并刷新
9
+
10
+ refresh token 读取优先级(从高到低):
11
+ 1. jwt.json 中的 web_refresh_token 字段(Web 端同步,v1.1.0+)
12
+ 2. 环境变量 SOPHCLAW_REFRESH_TOKEN
13
+ 3. 文件 ~/.openclaw/refresh_token
14
+ 4. 若都没有且 access token 过期,抛出 AuthError 提示用户 setup
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import base64
20
+ import json
21
+ from pathlib import Path
22
+ from typing import Any, Optional
23
+ import sys
24
+ import time
25
+
26
+ # DNS fix: container ndots:5 breaks Python thread pool DNS
27
+ import os as _os
28
+ import time
29
+ _os.environ.setdefault('RES_OPTIONS', 'ndots:1')
30
+ _os.environ.setdefault('SOCKET_THREAD_POOL_SIZE', '0')
31
+ del _os
32
+
33
+ import requests
34
+
35
+ DEFAULT_JWT_PATH = Path("/home/node/.openclaw/jwt.json")
36
+ DEFAULT_REFRESH_TOKEN_FILE = Path("/home/node/.openclaw/refresh_token")
37
+ # 提前 5 分钟刷新,避免边界情况
38
+ REFRESH_MARGIN_SECONDS = 300
39
+ MAX_RETRIES = 3
40
+ RETRY_DELAY = 2
41
+ REQUEST_TIMEOUT = 30
42
+
43
+ SETUP_INSTRUCTIONS = """
44
+ ========================================
45
+ 未找到 refresh token,无法自动续期 JWT。
46
+
47
+ 获取 refresh token 的方法:
48
+ 1. 浏览器打开 Sophnet,按 F12 → Application → Local Storage → user-info
49
+ 复制其中 refreshToken 字段的值
50
+ 2. 执行以下命令完成设置(二选一):
51
+
52
+ 方式 A — 写入环境变量(推荐,重启后失效需重设):
53
+ export SOPHCLAW_REFRESH_TOKEN="<你的refreshToken>"
54
+
55
+ 方式 B — 写入文件(持久化):
56
+ echo "<你的refreshToken>" > ~/.openclaw/refresh_token
57
+
58
+ 方式 C — 用脚本 setup 子命令(写入 jwt.json,持久化):
59
+ uv run {baseDir}/scripts/auto_accept.py setup --refresh-token "<你的refreshToken>"
60
+
61
+ 3. 设置完成后重新执行原有命令即可。
62
+ ========================================
63
+ """
64
+
65
+
66
+ class AuthError(RuntimeError):
67
+ """认证相关错误。"""
68
+ pass
69
+
70
+
71
+ def _resolve_api_base(api_base_url: Optional[str] = None) -> str:
72
+ if api_base_url and api_base_url.strip():
73
+ return api_base_url.strip().rstrip("/")
74
+ env_base = os.environ.get("SOPHCLAW_API_BASE_URL", "").strip()
75
+ if env_base:
76
+ return env_base.rstrip("/")
77
+ return "https://yagent.sophnet.com/api"
78
+
79
+
80
+ def _load_json_file(path: Path) -> dict[str, Any]:
81
+ try:
82
+ with path.open("r", encoding="utf-8") as fp:
83
+ data = json.load(fp)
84
+ except OSError as exc:
85
+ raise AuthError("无法读取文件 {}: {}".format(path, exc)) from exc
86
+ except json.JSONDecodeError as exc:
87
+ raise AuthError("文件不是有效 JSON: {}: {}".format(path, exc)) from exc
88
+ if not isinstance(data, dict):
89
+ raise AuthError("JSON 根对象必须是对象: {}".format(path))
90
+ return data
91
+
92
+
93
+ def _save_json_file(path: Path, data: dict[str, Any]) -> None:
94
+ try:
95
+ parent = path.parent
96
+ if not parent.exists():
97
+ parent.mkdir(parents=True, exist_ok=True)
98
+ tmp_path = path.with_suffix(path.suffix + ".tmp")
99
+ with tmp_path.open("w", encoding="utf-8") as fp:
100
+ json.dump(data, fp, ensure_ascii=False, indent=2)
101
+ tmp_path.replace(path)
102
+ except OSError as exc:
103
+ raise AuthError("无法写入文件 {}: {}".format(path, exc)) from exc
104
+
105
+
106
+ def _read_refresh_token_file(path: Path) -> Optional[str]:
107
+ """读取纯文本 refresh token 文件。"""
108
+ try:
109
+ with path.open("r", encoding="utf-8") as fp:
110
+ token = fp.read().strip()
111
+ return token if token else None
112
+ except OSError:
113
+ return None
114
+
115
+
116
+ def _save_refresh_token_file(token: str, path: Optional[Path] = None) -> None:
117
+ """将 refresh token 写入独立纯文本文件(不被 Web 同步覆盖)。"""
118
+ if path is None:
119
+ path = DEFAULT_REFRESH_TOKEN_FILE
120
+ try:
121
+ parent = path.parent
122
+ if not parent.exists():
123
+ parent.mkdir(parents=True, exist_ok=True)
124
+ with path.open("w", encoding="utf-8") as fp:
125
+ fp.write(token.strip() + "\n")
126
+ except OSError as exc:
127
+ raise AuthError("无法写入 refresh token 文件 {}: {}".format(path, exc)) from exc
128
+
129
+
130
+ def _resolve_refresh_token(
131
+ jwt_data: dict[str, Any],
132
+ jwt_path: Path,
133
+ ) -> str:
134
+ """查找 refresh token,优先级:独立文件 → jwt.json → 环境变量。
135
+
136
+ - 独立文件 ~/.openclaw/refresh_token 不会被 Web 前端覆盖
137
+ - jwt.json 的 web_refresh_token 会在界面刷新时丢失
138
+ - 环境变量重启后失效
139
+
140
+ 返回 refresh token 字符串,找不到则抛出 AuthError。
141
+ """
142
+ # 1. 独立文件 ~/.openclaw/refresh_token(最稳定,不被 Web 覆盖)
143
+ rt = _read_refresh_token_file(DEFAULT_REFRESH_TOKEN_FILE)
144
+ if rt:
145
+ _sophlog("从 {} 读取 refresh token".format(DEFAULT_REFRESH_TOKEN_FILE))
146
+ return rt
147
+
148
+ # 2. jwt.json 中的 web_refresh_token(会被 Web 刷新重置)
149
+ rt = jwt_data.get("web_refresh_token")
150
+ if isinstance(rt, str) and rt.strip():
151
+ # 顺便备份到独立文件,防止 Web 刷新后丢失
152
+ try:
153
+ _save_refresh_token_file(rt)
154
+ except AuthError:
155
+ pass
156
+ return rt.strip()
157
+
158
+ # 3. 环境变量
159
+ rt = os.environ.get("SOPHCLAW_REFRESH_TOKEN", "").strip()
160
+ if rt:
161
+ _sophlog("从环境变量 SOPHCLAW_REFRESH_TOKEN 读取 refresh token")
162
+ try:
163
+ _save_refresh_token_file(rt)
164
+ jwt_data["web_refresh_token"] = rt
165
+ _save_json_file(jwt_path, jwt_data)
166
+ except AuthError:
167
+ pass
168
+ return rt
169
+
170
+ raise AuthError(SETUP_INSTRUCTIONS)
171
+
172
+
173
+ def _http_request(
174
+ method: str,
175
+ url: str,
176
+ token: str,
177
+ timeout: int = REQUEST_TIMEOUT,
178
+ payload: Optional[dict[str, Any]] = None,
179
+ expect_json: bool = True,
180
+ ) -> Any:
181
+ headers = {
182
+ "Authorization": "Bearer {}".format(token),
183
+ "Accept": "application/json",
184
+ }
185
+ if payload is not None:
186
+ headers["Content-Type"] = "application/json"
187
+
188
+ last_error = None
189
+ for attempt in range(1, MAX_RETRIES + 1):
190
+ try:
191
+ resp = requests.request(
192
+ method=method,
193
+ url=url,
194
+ headers=headers,
195
+ json=payload,
196
+ timeout=timeout,
197
+ )
198
+ if not expect_json:
199
+ return resp
200
+ if not resp.ok:
201
+ raise AuthError(
202
+ "HTTP {} {}: {}".format(resp.status_code, url, resp.text[:500])
203
+ )
204
+ data = resp.json()
205
+ if isinstance(data, dict) and data.get("status") not in (None, 0):
206
+ raise AuthError(
207
+ "接口错误: status={} message={!r}".format(
208
+ data.get("status"), data.get("message")
209
+ )
210
+ )
211
+ return data if isinstance(data, dict) else {"result": data}
212
+ except requests.exceptions.ConnectionError as exc:
213
+ last_error = exc
214
+ if attempt < MAX_RETRIES:
215
+ time.sleep(RETRY_DELAY * attempt)
216
+ except requests.exceptions.Timeout as exc:
217
+ last_error = exc
218
+ if attempt < MAX_RETRIES:
219
+ time.sleep(RETRY_DELAY * attempt)
220
+ except AuthError:
221
+ raise
222
+ except Exception as exc:
223
+ last_error = exc
224
+ if attempt < MAX_RETRIES:
225
+ time.sleep(RETRY_DELAY * attempt)
226
+
227
+ raise AuthError("请求失败 {} (重试 {} 次后仍失败): {}".format(url, MAX_RETRIES, last_error))
228
+
229
+
230
+ def _sophlog(msg: str) -> None:
231
+ """模块级日志函数,供 _resolve_refresh_token 等模块函数调用。"""
232
+ print("[sophclaw-auth] {}".format(msg), file=sys.stderr)
233
+
234
+
235
+ class SophclawAuth:
236
+ """Sophclaw 认证管理器:自动检测 JWT 过期并刷新。"""
237
+
238
+ def __init__(
239
+ self,
240
+ jwt_path: Optional[Path] = None,
241
+ api_base_url: Optional[str] = None,
242
+ ):
243
+ self._jwt_path = Path(jwt_path or DEFAULT_JWT_PATH).expanduser().resolve()
244
+ self._api_base = _resolve_api_base(api_base_url)
245
+
246
+ # ------------------------------------------------------------------
247
+ # Public API
248
+ # ------------------------------------------------------------------
249
+
250
+ def get_valid_token(self) -> str:
251
+ """获取有效的 JWT access token,过期时自动刷新。"""
252
+ data = _load_json_file(self._jwt_path)
253
+
254
+ # 提取 access token
255
+ raw = data.get("web_jwt")
256
+ if not isinstance(raw, str) or not raw.strip():
257
+ raise AuthError("jwt.json 缺少有效 web_jwt: {}".format(self._jwt_path))
258
+ raw = raw.strip()
259
+ if raw.lower().startswith("bearer "):
260
+ raw = raw[7:].strip()
261
+
262
+ if not self._is_expired(raw):
263
+ return raw
264
+
265
+ # 尝试用 refresh token 续期(多来源查找)
266
+ refresh = _resolve_refresh_token(data, self._jwt_path)
267
+
268
+ self._log("JWT 已过期,尝试用 refreshToken 续期...")
269
+ new_token, new_refresh = self._do_refresh(refresh)
270
+
271
+ # 写回 jwt.json + 独立文件(双重保险)
272
+ data["web_jwt"] = "Bearer {}".format(new_token)
273
+ data["web_refresh_token"] = new_refresh
274
+ data["savedAt"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
275
+ _save_json_file(self._jwt_path, data)
276
+ _save_refresh_token_file(new_refresh)
277
+
278
+ self._log("JWT 续期成功。")
279
+ return new_token
280
+
281
+ def get_api_base(self) -> str:
282
+ return self._api_base
283
+
284
+ @staticmethod
285
+ def setup_refresh_token(
286
+ refresh_token: str,
287
+ jwt_path: Optional[Path] = None,
288
+ ) -> Path:
289
+ """将 refresh token 写入 jwt.json。
290
+
291
+ 如果 jwt.json 不存在则创建,如果已存在则保留原有字段并更新 web_refresh_token。
292
+ 返回 jwt.json 的路径。
293
+ """
294
+ path = Path(jwt_path or DEFAULT_JWT_PATH).expanduser().resolve()
295
+
296
+ try:
297
+ data = _load_json_file(path)
298
+ except AuthError:
299
+ # 文件不存在或无效,创建新的
300
+ data = {}
301
+
302
+ data["web_refresh_token"] = refresh_token.strip()
303
+ if "savedAt" not in data:
304
+ data["savedAt"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
305
+
306
+ _save_json_file(path, data)
307
+ _save_refresh_token_file(refresh_token.strip())
308
+ _log("refresh token 已写入 {} + {}".format(path, DEFAULT_REFRESH_TOKEN_FILE))
309
+ return path
310
+
311
+ # ------------------------------------------------------------------
312
+ # Internal
313
+ # ------------------------------------------------------------------
314
+
315
+ def _is_expired(self, token: str) -> bool:
316
+ """检查 token 是否已过期(含 5 分钟提前量)。"""
317
+ exp = self._jwt_exp_unix(token)
318
+ if exp is None:
319
+ return False # 无法解析则不拦截
320
+ return time.time() >= exp - REFRESH_MARGIN_SECONDS
321
+
322
+ @staticmethod
323
+ def _jwt_exp_unix(token: str) -> Optional[int]:
324
+ try:
325
+ parts = token.split(".")
326
+ if len(parts) != 3:
327
+ return None
328
+ payload = parts[1] + "=" * (-len(parts[1]) % 4)
329
+ raw = base64.urlsafe_b64decode(payload.encode("ascii"))
330
+ data = json.loads(raw)
331
+ exp = data.get("exp")
332
+ return int(exp) if isinstance(exp, (int, float)) else None
333
+ except Exception:
334
+ return None
335
+
336
+ def _do_refresh(self, refresh_token: str) -> tuple[str, str]:
337
+ """调用 refresh 接口换取新 token。
338
+
339
+ 返回: (new_access_token, new_refresh_token)
340
+ """
341
+ # refresh 接口只认 sophnet.com,不认 yagent.sophnet.com
342
+ url = "https://sophnet.com/api/sys/login/refresh"
343
+ try:
344
+ # refresh 接口不需要 Authorization header,
345
+ # 请求体传 refreshToken 即可
346
+ resp = requests.post(
347
+ url,
348
+ json={"refreshToken": refresh_token},
349
+ headers={
350
+ "Content-Type": "application/json",
351
+ "Accept": "application/json",
352
+ },
353
+ timeout=REQUEST_TIMEOUT,
354
+ )
355
+ if resp.status_code == 401:
356
+ raise AuthError("Refresh token 已失效,请重新登录获取新的 refresh token。")
357
+ if not resp.ok:
358
+ raise AuthError(
359
+ "Refresh 接口返回 HTTP {}: {}".format(
360
+ resp.status_code, resp.text[:500]
361
+ )
362
+ )
363
+ data = resp.json()
364
+
365
+ # 兼容两种响应格式:
366
+ # { status: 0, result: { token, refreshToken, ... } }
367
+ # { token, refreshToken, ... }
368
+ if isinstance(data, dict) and data.get("status") not in (None, 0):
369
+ raise AuthError(
370
+ "Refresh 接口错误: status={} message={!r}".format(
371
+ data.get("status"), data.get("message")
372
+ )
373
+ )
374
+ result = data.get("result") if isinstance(data, dict) else data
375
+ if isinstance(result, dict):
376
+ token_data = result
377
+ else:
378
+ token_data = data
379
+
380
+ new_token = token_data.get("token") or token_data.get("accessToken")
381
+ new_refresh = token_data.get("refreshToken")
382
+
383
+ if not new_token:
384
+ raise AuthError("Refresh 接口未返回 token: {}".format(data))
385
+ if not new_refresh:
386
+ # 某些实现可能不返回新 refreshToken,沿用旧的
387
+ new_refresh = refresh_token
388
+
389
+ return str(new_token), str(new_refresh)
390
+ except requests.exceptions.RequestException as exc:
391
+ raise AuthError("Refresh 请求失败: {}".format(exc)) from exc
392
+
393
+ @staticmethod
394
+ def _log(msg: str) -> None:
395
+ _sophlog(msg)