sophhub 0.4.37 → 0.4.39
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 +1 -1
- package/skills/claw-friend-message-capture/skill.json +41 -0
- package/skills/claw-friend-message-capture/src/SKILL.md +115 -0
- package/skills/claw-friend-message-capture/src/pyproject.toml +12 -0
- package/skills/claw-friend-message-capture/src/scripts/capture_messages.py +573 -0
- package/skills/claw-friend-message-capture/src/scripts/sophclaw_auth.py +395 -0
- package/skills/sophnet-image-edit/skill.json +9 -2
- package/skills/sophnet-image-edit/src/pyproject.toml +5 -1
- package/skills/sophnet-image-generate/skill.json +9 -2
- package/skills/sophnet-image-generate/src/pyproject.toml +5 -1
- package/skills/sophnet-video-generate/skill.json +11 -3
- package/skills/sophnet-video-generate/src/pyproject.toml +13 -0
package/package.json
CHANGED
|
@@ -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)
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sophnet-image-edit",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"types": [
|
|
5
5
|
"builtin"
|
|
6
6
|
],
|
|
7
7
|
"displayName": "",
|
|
8
8
|
"description": "",
|
|
9
9
|
"changelog": [
|
|
10
|
+
{
|
|
11
|
+
"version": "1.0.1",
|
|
12
|
+
"date": "2026-06-09",
|
|
13
|
+
"changes": [
|
|
14
|
+
"pyproject.toml 添加阿里云 PyPI 镜像源"
|
|
15
|
+
]
|
|
16
|
+
},
|
|
10
17
|
{
|
|
11
18
|
"version": "1.0.0",
|
|
12
19
|
"date": "2026-04-09",
|
|
@@ -16,5 +23,5 @@
|
|
|
16
23
|
}
|
|
17
24
|
],
|
|
18
25
|
"createdAt": "2026-04-09",
|
|
19
|
-
"updatedAt": "2026-
|
|
26
|
+
"updatedAt": "2026-06-09"
|
|
20
27
|
}
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sophnet-image-edit"
|
|
3
|
-
version = "1.0.
|
|
3
|
+
version = "1.0.1"
|
|
4
4
|
description = "Sophnet image editing (image-to-image) with task polling"
|
|
5
5
|
requires-python = ">=3.8"
|
|
6
6
|
dependencies = [
|
|
7
7
|
"requests>=2.28.0",
|
|
8
8
|
"sophnet-tools>=0.0.1",
|
|
9
9
|
]
|
|
10
|
+
|
|
11
|
+
[[tool.uv.index]]
|
|
12
|
+
url = "https://mirrors.aliyun.com/pypi/simple/"
|
|
13
|
+
default = true
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sophnet-image-generate",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"types": [
|
|
5
5
|
"builtin"
|
|
6
6
|
],
|
|
7
7
|
"displayName": "",
|
|
8
8
|
"description": "",
|
|
9
9
|
"changelog": [
|
|
10
|
+
{
|
|
11
|
+
"version": "1.0.1",
|
|
12
|
+
"date": "2026-06-09",
|
|
13
|
+
"changes": [
|
|
14
|
+
"pyproject.toml 添加阿里云 PyPI 镜像源"
|
|
15
|
+
]
|
|
16
|
+
},
|
|
10
17
|
{
|
|
11
18
|
"version": "1.0.0",
|
|
12
19
|
"date": "2026-04-09",
|
|
@@ -16,5 +23,5 @@
|
|
|
16
23
|
}
|
|
17
24
|
],
|
|
18
25
|
"createdAt": "2026-04-09",
|
|
19
|
-
"updatedAt": "2026-
|
|
26
|
+
"updatedAt": "2026-06-09"
|
|
20
27
|
}
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sophnet-image-generate"
|
|
3
|
-
version = "1.0.
|
|
3
|
+
version = "1.0.1"
|
|
4
4
|
description = "Sophnet text-to-image generation with task polling"
|
|
5
5
|
requires-python = ">=3.8"
|
|
6
6
|
dependencies = [
|
|
7
7
|
"requests>=2.28.0",
|
|
8
8
|
"sophnet-tools>=0.0.1",
|
|
9
9
|
]
|
|
10
|
+
|
|
11
|
+
[[tool.uv.index]]
|
|
12
|
+
url = "https://mirrors.aliyun.com/pypi/simple/"
|
|
13
|
+
default = true
|
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sophnet-video-generate",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"types": [
|
|
5
5
|
"builtin",
|
|
6
6
|
"store"
|
|
7
7
|
],
|
|
8
|
-
"displayName": "",
|
|
9
|
-
"description": "",
|
|
8
|
+
"displayName": "视频生成",
|
|
9
|
+
"description": "通过 Sophnet API 生成视频,支持文生视频和图生视频",
|
|
10
10
|
"changelog": [
|
|
11
|
+
{
|
|
12
|
+
"version": "1.0.3",
|
|
13
|
+
"date": "2026-06-09",
|
|
14
|
+
"changes": [
|
|
15
|
+
"新增 pyproject.toml 并配置阿里云 PyPI 镜像源",
|
|
16
|
+
"补充 store 类型中文 displayName 和 description"
|
|
17
|
+
]
|
|
18
|
+
},
|
|
11
19
|
{
|
|
12
20
|
"version": "1.0.2",
|
|
13
21
|
"date": "2026-05-11",
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sophnet-video-generate"
|
|
3
|
+
version = "1.0.3"
|
|
4
|
+
description = "Sophnet video generation with task polling"
|
|
5
|
+
requires-python = ">=3.8"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"requests>=2.28.0",
|
|
8
|
+
"sophnet-tools>=0.0.1",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[[tool.uv.index]]
|
|
12
|
+
url = "https://mirrors.aliyun.com/pypi/simple/"
|
|
13
|
+
default = true
|