sophhub 0.4.69 → 0.4.70
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,12 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "share-skill",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"types": [
|
|
5
5
|
"builtin"
|
|
6
6
|
],
|
|
7
7
|
"displayName": "Skill分享",
|
|
8
|
-
"description": "分享自己的skill
|
|
8
|
+
"description": "分享自己的 skill 给虾友或虾友群",
|
|
9
9
|
"changelog": [
|
|
10
|
+
{
|
|
11
|
+
"version": "1.1.0",
|
|
12
|
+
"date": "2026-09-07",
|
|
13
|
+
"changes": [
|
|
14
|
+
"新增 list-groups 子命令与 send/preview 的 --group-id 目标,支持把 Skill 分享到虾友群",
|
|
15
|
+
"web_jwt 过期或临近过期时自动用 web_refresh_token 续期(逻辑适配自 claw-friend-message-capture)",
|
|
16
|
+
"SKILL.md 新增群目标回显确认流程与 Processing Mode 双轨规则"
|
|
17
|
+
]
|
|
18
|
+
},
|
|
10
19
|
{
|
|
11
20
|
"changes": [
|
|
12
21
|
"初次提交"
|
|
@@ -16,7 +25,7 @@
|
|
|
16
25
|
}
|
|
17
26
|
],
|
|
18
27
|
"createdAt": "2026-05-05",
|
|
19
|
-
"updatedAt": "2026-
|
|
28
|
+
"updatedAt": "2026-09-07",
|
|
20
29
|
"emoji": "🔗",
|
|
21
30
|
"tags": [
|
|
22
31
|
"开发"
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: share-skill
|
|
3
|
-
description: 列出当前用户容器中实际安装的 Skill,按 openclaw skills list 返回的 source 字段分组,引导用户选择 Skill
|
|
3
|
+
description: 列出当前用户容器中实际安装的 Skill,按 openclaw skills list 返回的 source 字段分组,引导用户选择 Skill、选择虾友或虾友群后发送 web 格式 Skill 分享卡片(发群必须先回显确认)。Use when the user asks to share or introduce an installed Skill to a Sophclaw friend / 虾友 or a group / 虾友群.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# 向虾友介绍 Skill
|
|
7
7
|
|
|
8
|
-
用于把当前容器里实际安装的某个 Skill 按 web 前端一致的 Skill
|
|
8
|
+
用于把当前容器里实际安装的某个 Skill 按 web 前端一致的 Skill 分享卡片格式发送给虾友或虾友群。必须按顺序执行:**选择 Skill → 选择目标(虾友 / 群聊)→ 发送(群目标必须先回显确认)**。
|
|
9
9
|
|
|
10
10
|
## Processing Mode
|
|
11
11
|
|
|
12
|
-
**STRICT SERIAL PROCESSING ONLY** —
|
|
12
|
+
**STRICT SERIAL PROCESSING ONLY** — 不并行发送;各步骤严格按顺序执行,目标类型不同规则不同:
|
|
13
|
+
|
|
14
|
+
- **虾友目标**:用户选定 Skill 和虾友后直接发送,不再追加确认步骤。
|
|
15
|
+
- **群聊目标**:用户选定 Skill 和群后,必须先回显目标群名、成员数与内容摘要,得到用户明确确认后才能发送;未确认前不得调用 send。
|
|
13
16
|
|
|
14
17
|
## Prerequisites
|
|
15
18
|
|
|
@@ -33,7 +36,9 @@ uv run {baseDir}/scripts/share_skill_to_friend.py <command> [args...]
|
|
|
33
36
|
```bash
|
|
34
37
|
uv run {baseDir}/scripts/share_skill_to_friend.py list-skills
|
|
35
38
|
uv run {baseDir}/scripts/share_skill_to_friend.py list-friends
|
|
39
|
+
uv run {baseDir}/scripts/share_skill_to_friend.py list-groups
|
|
36
40
|
uv run {baseDir}/scripts/share_skill_to_friend.py send --skill "<skill-name>" --friend-id <id> --friend-name "<name>"
|
|
41
|
+
uv run {baseDir}/scripts/share_skill_to_friend.py send --skill "<skill-name>" --group-id <id>
|
|
37
42
|
```
|
|
38
43
|
|
|
39
44
|
脚本会优先解析 JSON;如果 OpenClaw 命令输出包含 banner/config warnings,会自动从噪声中提取 JSON;如果实际拿到的是 `openclaw skills list` 的表格输出,也会尽量解析表格中的 Skill 名称、描述、状态和 Source。展示标签直接使用返回的 `source` 字段。
|
|
@@ -94,7 +99,11 @@ uv run {baseDir}/scripts/share_skill_to_friend.py list-skills
|
|
|
94
99
|
4. 标签(如 `[openclaw-workspace]`)**不显示**,保持界面简洁
|
|
95
100
|
5. 排序:openclaw-workspace → openclaw-managed → 其他内置 skill
|
|
96
101
|
|
|
97
|
-
## Step 2
|
|
102
|
+
## Step 2:选择目标(虾友或群聊)
|
|
103
|
+
|
|
104
|
+
默认目标为虾友;仅当用户明确要求发送到群(如"发到 XX 群"、"分享到群里")时走群分支,不要替用户猜测目标类型。
|
|
105
|
+
|
|
106
|
+
### 分支 A:选择虾友(默认)
|
|
98
107
|
|
|
99
108
|
用户选定 Skill 后执行:
|
|
100
109
|
|
|
@@ -133,19 +142,99 @@ uv run {baseDir}/scripts/share_skill_to_friend.py list-friends
|
|
|
133
142
|
|
|
134
143
|
`displayName` 的生成规则:优先使用虾友备注名 `remark`,没有备注时使用昵称 `nickname`,都没有时显示「未命名虾友」。内部保留 `friendId` 供脚本调用,但**不要向用户展示虾友号**。如果昵称重复,只用列表序号区分,并可提示用户按序号选择。
|
|
135
144
|
|
|
136
|
-
|
|
145
|
+
### 分支 B:选择群聊
|
|
146
|
+
|
|
147
|
+
用户选定 Skill 后执行:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
uv run {baseDir}/scripts/share_skill_to_friend.py list-groups
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
输出 JSON(**内部使用,勿向用户展示 `groupId` / 群 rid**):
|
|
154
|
+
|
|
155
|
+
```json
|
|
156
|
+
{
|
|
157
|
+
"groups": [
|
|
158
|
+
{
|
|
159
|
+
"index": 1,
|
|
160
|
+
"groupId": 123,
|
|
161
|
+
"name": "产品交流群",
|
|
162
|
+
"memberCount": 8,
|
|
163
|
+
"myRole": "member",
|
|
164
|
+
"rid": "room-abc123"
|
|
165
|
+
}
|
|
166
|
+
]
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
使用以下格式向用户展示:
|
|
171
|
+
|
|
172
|
+
```text
|
|
173
|
+
👥 **请选择要发送到哪个群聊**
|
|
174
|
+
|
|
175
|
+
1️⃣ **产品交流群**(8 人)
|
|
176
|
+
|
|
177
|
+
2️⃣ **测试群**(3 人)
|
|
178
|
+
|
|
179
|
+
💬 请回复 **序号** 或 **群名称**。
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
规则与虾友分支一致:不替用户猜测群聊;群名称重复时只用列表序号区分。内部保留 `groupId` / `rid` 供脚本调用,但**不要向用户展示**。
|
|
183
|
+
|
|
184
|
+
## Step 3:发送(群目标先确认;自定义 Skill 含敏感信息确认)
|
|
185
|
+
|
|
186
|
+
### 群目标:先回显确认,再发送
|
|
187
|
+
|
|
188
|
+
群消息会广播给全体群成员且无法撤回。用户选定群后,**必须**先回显以下确认信息,得到用户明确同意(如回复「确认发送」)后才可执行发送;虾友目标无需此步骤。
|
|
189
|
+
|
|
190
|
+
```text
|
|
191
|
+
📤 **即将发送到群聊(请确认)**
|
|
192
|
+
|
|
193
|
+
• 目标群:**产品交流群**(8 人)
|
|
194
|
+
• 内容:`operations-dashboard` 的 Skill 分享卡片
|
|
195
|
+
📝 <中文功能介绍>
|
|
196
|
+
|
|
197
|
+
💬 请回复 **「确认发送」** 执行,或回复 **「取消」** 返回。
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
用户确认后执行:
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
uv run {baseDir}/scripts/share_skill_to_friend.py send \
|
|
204
|
+
--skill "<skill-name>" \
|
|
205
|
+
--group-id <groupId> \
|
|
206
|
+
--description-zh "<中文功能介绍>"
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
发送成功返回(群目标为 `"group"` 字段,虾友目标为 `"friend"` 字段):
|
|
210
|
+
|
|
211
|
+
```json
|
|
212
|
+
{
|
|
213
|
+
"success": true,
|
|
214
|
+
"skill": "operations-dashboard",
|
|
215
|
+
"group": { "groupId": 123, "name": "产品交流群", "memberCount": 8 },
|
|
216
|
+
"messageId": "<message-id>",
|
|
217
|
+
"message": "📝 功能介绍:...\n[claw-skill-share]\n{\"v\":1,...}\n[/claw-skill-share]"
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
成功提示:
|
|
222
|
+
|
|
223
|
+
```text
|
|
224
|
+
✅已发送 `operations-dashboard` skill 到群聊「产品交流群」
|
|
225
|
+
```
|
|
137
226
|
|
|
138
|
-
|
|
227
|
+
群目标解析失败(如账号不在目标群)时脚本会报错提示,此时向用户说明其可能不在该群,请先核对群聊选择,不要重试其他群。
|
|
139
228
|
|
|
140
229
|
### 敏感信息检测
|
|
141
230
|
|
|
142
|
-
|
|
231
|
+
用户选定目标后,发送自定义 Skill 前,脚本会先扫描 Skill 目录是否包含私钥、token、`.env`、凭据文件等秘钥相关敏感信息(对虾友与群目标一视同仁)。脚本会检测以下敏感信息:
|
|
143
232
|
- 敏感文件名:`.env`、`.env.local`、`credentials.json`、`id_rsa` 等
|
|
144
233
|
- 敏感文件后缀:`.pem`、`.key`、`.p12`、`.pfx` 等
|
|
145
234
|
- 敏感文件名关键词:`token`、`secret`、`password`、`credential`、`private_key` 等
|
|
146
235
|
- 文件内容:私钥块、AWS Access Key、GitHub Token、Slack Token、JWT 等
|
|
147
236
|
|
|
148
|
-
###
|
|
237
|
+
### 虾友目标发送流程
|
|
149
238
|
|
|
150
239
|
首次发送(不带 `--allow-sensitive`):
|
|
151
240
|
|
|
@@ -259,3 +348,6 @@ Agent 应向用户提示:
|
|
|
259
348
|
- 敏感信息检测到的文件路径和原因可向用户展示,但**不要展示文件内容或密钥值**。
|
|
260
349
|
- 不要替用户猜测虾友;必须让用户从列表中选择,或在用户主动提供时内部使用 `friendId`。
|
|
261
350
|
- 用户侧输出中不要展示虾友号 / `friendId`。
|
|
351
|
+
- 群目标同样不替用户猜测;必须让用户从 `list-groups` 结果中选择,或在用户明确指定群名时内部使用 `groupId`。
|
|
352
|
+
- 用户侧输出中不要展示 `groupId` / 群 rid。
|
|
353
|
+
- 群消息会广播给全部群成员且无法撤回:群发送前必须完成回显确认(群名 + 成员数 + 内容摘要),未经确认不得调用 send。
|
|
@@ -26,6 +26,8 @@ from typing import Any, Dict, Iterable, List, Optional, Pattern, Set, Tuple
|
|
|
26
26
|
DEFAULT_BASE_URL = "https://yagent.sophnet.com/api"
|
|
27
27
|
DEFAULT_TIMEOUT = 30
|
|
28
28
|
JWT_PATH = "/home/node/.openclaw/jwt.json"
|
|
29
|
+
REFRESH_URL = "https://sophnet.com/api/sys/login/refresh"
|
|
30
|
+
REFRESH_MARGIN_SECONDS = 300
|
|
29
31
|
DEFAULT_SKILLS_COMMAND = "openclaw skills list --json"
|
|
30
32
|
DEFAULT_SKILL_INFO_COMMAND_TEMPLATE = "openclaw skills info {skill} --json"
|
|
31
33
|
STORE_SKILLS_PATH = "/open-apis/skill-store/skills"
|
|
@@ -159,17 +161,93 @@ def jwt_exp_unix(token: str) -> Optional[int]:
|
|
|
159
161
|
return None
|
|
160
162
|
|
|
161
163
|
|
|
162
|
-
def
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
164
|
+
def refresh_web_jwt(jwt_path: str, timeout: int) -> str:
|
|
165
|
+
"""用 web_refresh_token 换新 web_jwt 并写回 jwt.json。
|
|
166
|
+
|
|
167
|
+
逻辑适配自 claw-friend-message-capture 的 sophclaw_auth.py
|
|
168
|
+
(各 skill 安装目录相互独立,不做跨 skill import)。
|
|
169
|
+
"""
|
|
170
|
+
try:
|
|
171
|
+
with open(jwt_path, "r", encoding="utf-8") as fp:
|
|
172
|
+
data = json.load(fp)
|
|
173
|
+
except OSError as exc:
|
|
174
|
+
raise AppError(f"无法读取 JWT 文件 {jwt_path}: {exc}") from exc
|
|
175
|
+
except json.JSONDecodeError as exc:
|
|
176
|
+
raise AppError(f"JWT 文件不是有效 JSON: {jwt_path}: {exc}") from exc
|
|
177
|
+
|
|
178
|
+
refresh_token = (
|
|
179
|
+
str(data.get("web_refresh_token") or "").strip() if isinstance(data, dict) else ""
|
|
180
|
+
)
|
|
181
|
+
if not refresh_token:
|
|
182
|
+
raise AppError(
|
|
183
|
+
f"{jwt_path} 缺少 web_refresh_token,无法自动续期;请重新登录后刷新"
|
|
184
|
+
)
|
|
185
|
+
body = json.dumps({"refreshToken": refresh_token}, ensure_ascii=False).encode("utf-8")
|
|
186
|
+
# refresh 接口只认 sophnet.com 域名,不认 yagent.sophnet.com
|
|
187
|
+
req = urllib.request.Request(
|
|
188
|
+
url=REFRESH_URL,
|
|
189
|
+
data=body,
|
|
190
|
+
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
|
191
|
+
method="POST",
|
|
192
|
+
)
|
|
193
|
+
try:
|
|
194
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
195
|
+
raw = resp.read().decode("utf-8", errors="replace")
|
|
196
|
+
except urllib.error.HTTPError as exc:
|
|
197
|
+
if exc.code == 401:
|
|
198
|
+
raise AppError("refresh token 已失效,请重新登录获取新的 JWT") from exc
|
|
199
|
+
detail = exc.read().decode("utf-8", errors="replace")[:300]
|
|
200
|
+
raise AppError(f"刷新 JWT 失败: HTTP {exc.code}: {detail}") from exc
|
|
201
|
+
except urllib.error.URLError as exc:
|
|
202
|
+
raise AppError(f"刷新 JWT 请求失败: {exc}") from exc
|
|
203
|
+
try:
|
|
204
|
+
res = json.loads(raw)
|
|
205
|
+
except json.JSONDecodeError as exc:
|
|
206
|
+
raise AppError(f"刷新 JWT 返回不是有效 JSON: {exc}") from exc
|
|
207
|
+
if isinstance(res, dict) and res.get("status") not in (None, 0):
|
|
208
|
+
raise AppError(
|
|
209
|
+
f"刷新 JWT 接口错误: status={res.get('status')} message={res.get('message')!r}"
|
|
210
|
+
)
|
|
211
|
+
result = unwrap_result(res) if isinstance(res, dict) else {}
|
|
212
|
+
if not isinstance(result, dict):
|
|
213
|
+
result = {}
|
|
214
|
+
new_token = result.get("token") or result.get("accessToken")
|
|
215
|
+
new_refresh = result.get("refreshToken")
|
|
216
|
+
if not isinstance(new_token, str) or not new_token.strip():
|
|
217
|
+
raise AppError(f"刷新 JWT 接口未返回 token: {raw[:300]}")
|
|
218
|
+
new_token = new_token.strip()
|
|
219
|
+
if new_token.lower().startswith("bearer "):
|
|
220
|
+
new_token = new_token[7:].strip()
|
|
221
|
+
data["web_jwt"] = new_token
|
|
222
|
+
if isinstance(new_refresh, str) and new_refresh.strip():
|
|
223
|
+
data["web_refresh_token"] = new_refresh.strip()
|
|
224
|
+
try:
|
|
225
|
+
with open(jwt_path, "w", encoding="utf-8") as fp:
|
|
226
|
+
json.dump(data, fp, ensure_ascii=False, indent=2)
|
|
227
|
+
except OSError as exc:
|
|
228
|
+
print(f"WARN: 续期成功但写回 {jwt_path} 失败: {exc}", file=sys.stderr)
|
|
229
|
+
return new_token
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def get_web_token(args: argparse.Namespace) -> str:
|
|
233
|
+
"""读取 web_jwt;已过期或临近过期时自动用 web_refresh_token 续期。"""
|
|
234
|
+
token = read_bearer_token(args.jwt_path)
|
|
235
|
+
if getattr(args, "allow_expired_jwt", False):
|
|
236
|
+
return token
|
|
167
237
|
exp = jwt_exp_unix(token)
|
|
168
|
-
if exp is None:
|
|
169
|
-
return
|
|
170
238
|
now = int(time.time())
|
|
171
|
-
if now
|
|
172
|
-
|
|
239
|
+
if exp is None or now < exp - REFRESH_MARGIN_SECONDS:
|
|
240
|
+
return token
|
|
241
|
+
try:
|
|
242
|
+
return refresh_web_jwt(args.jwt_path, args.timeout)
|
|
243
|
+
except AppError as exc:
|
|
244
|
+
if exp is not None and now >= exp:
|
|
245
|
+
raise AppError(
|
|
246
|
+
f"JWT 已过期且自动续期失败({exc})。请重新登录后刷新 {args.jwt_path}"
|
|
247
|
+
) from exc
|
|
248
|
+
# 尚未真正过期:续期失败不阻断,沿用当前 token
|
|
249
|
+
print(f"WARN: JWT 自动续期失败({exc}),使用当前 token 继续", file=sys.stderr)
|
|
250
|
+
return token
|
|
173
251
|
|
|
174
252
|
|
|
175
253
|
def make_headers(token: str, content_type: Optional[str] = None) -> StringDict:
|
|
@@ -555,9 +633,7 @@ def merge_skill_detail(args: argparse.Namespace, skill: JsonDict) -> JsonDict:
|
|
|
555
633
|
|
|
556
634
|
|
|
557
635
|
def load_friends(args: argparse.Namespace) -> List[JsonDict]:
|
|
558
|
-
token =
|
|
559
|
-
if not args.allow_expired_jwt:
|
|
560
|
-
ensure_jwt_valid_for_use(token, args.jwt_path)
|
|
636
|
+
token = get_web_token(args)
|
|
561
637
|
url = f"{normalize_base_url(args.base_url)}/sys/openclaw/friend/list"
|
|
562
638
|
data = request_json("GET", url, token, args.timeout)
|
|
563
639
|
result = unwrap_result(data) or {}
|
|
@@ -589,6 +665,47 @@ def load_friends(args: argparse.Namespace) -> List[JsonDict]:
|
|
|
589
665
|
return out
|
|
590
666
|
|
|
591
667
|
|
|
668
|
+
def load_groups(args: argparse.Namespace) -> List[JsonDict]:
|
|
669
|
+
"""列出当前账号所在的虾友群(含 groupId / 群 rid,仅内部使用,不向用户展示)。"""
|
|
670
|
+
token = get_web_token(args)
|
|
671
|
+
url = f"{normalize_base_url(args.base_url)}/sys/openclaw/group/list"
|
|
672
|
+
data = request_json("GET", url, token, args.timeout)
|
|
673
|
+
result = unwrap_result(data) or {}
|
|
674
|
+
groups = result.get("groups") if isinstance(result, dict) else None
|
|
675
|
+
if not isinstance(groups, list):
|
|
676
|
+
raise AppError("群列表接口缺少 groups 数组")
|
|
677
|
+
|
|
678
|
+
out: List[JsonDict] = []
|
|
679
|
+
for item in groups:
|
|
680
|
+
if not isinstance(item, dict):
|
|
681
|
+
continue
|
|
682
|
+
raw_id = item.get("id") if item.get("id") is not None else item.get("groupId")
|
|
683
|
+
try:
|
|
684
|
+
group_id = int(raw_id)
|
|
685
|
+
except (TypeError, ValueError):
|
|
686
|
+
continue
|
|
687
|
+
out.append({
|
|
688
|
+
"groupId": group_id,
|
|
689
|
+
"name": str(item.get("name") or "未命名群聊").strip(),
|
|
690
|
+
"memberCount": item.get("memberCount"),
|
|
691
|
+
"myRole": item.get("myRole"),
|
|
692
|
+
"rid": str(item.get("rid") or "").strip(),
|
|
693
|
+
})
|
|
694
|
+
out.sort(key=lambda g: str(g["name"]))
|
|
695
|
+
for idx, group in enumerate(out, start=1):
|
|
696
|
+
group["index"] = idx
|
|
697
|
+
return out
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def resolve_group(args: argparse.Namespace, group_id: int) -> JsonDict:
|
|
701
|
+
for group in load_groups(args):
|
|
702
|
+
if group["groupId"] == group_id:
|
|
703
|
+
return group
|
|
704
|
+
raise AppError(
|
|
705
|
+
f"未找到群聊 groupId={group_id},当前账号可能不在该群或该群不存在;请用 list-groups 核对"
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
|
|
592
709
|
def encode_skill_share_message(payload: JsonDict, prefix: str = "") -> str:
|
|
593
710
|
head = f"{prefix.strip()}\n" if prefix and prefix.strip() else ""
|
|
594
711
|
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
|
@@ -931,12 +1048,27 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
931
1048
|
p = sub.add_parser("list-friends", help="列出当前账号虾友")
|
|
932
1049
|
add_common_args(p)
|
|
933
1050
|
|
|
1051
|
+
p = sub.add_parser("list-groups", help="列出当前账号所在的虾友群")
|
|
1052
|
+
add_common_args(p)
|
|
1053
|
+
|
|
934
1054
|
for name in ("preview", "send"):
|
|
935
1055
|
p = sub.add_parser(name, help="预览或发送 Skill 功能介绍")
|
|
936
1056
|
add_common_args(p)
|
|
937
1057
|
p.add_argument("--skill", required=True, help="Skill 名称或 list-skills 返回的序号")
|
|
938
|
-
p.add_argument(
|
|
1058
|
+
p.add_argument(
|
|
1059
|
+
"--friend-id",
|
|
1060
|
+
type=int,
|
|
1061
|
+
default=0,
|
|
1062
|
+
help="目标虾友 friendId(与 --group-id 二选一)",
|
|
1063
|
+
)
|
|
939
1064
|
p.add_argument("--friend-name", default="", help="目标虾友展示名")
|
|
1065
|
+
p.add_argument(
|
|
1066
|
+
"--group-id",
|
|
1067
|
+
type=int,
|
|
1068
|
+
default=0,
|
|
1069
|
+
help="目标群聊 groupId(与 --friend-id 二选一;群 rid 由脚本内部经 group/list 解析)",
|
|
1070
|
+
)
|
|
1071
|
+
p.add_argument("--group-name", default="", help="目标群聊展示名(仅 preview 展示用)")
|
|
940
1072
|
p.add_argument("--description-zh", default="", help="可选:由 Agent 改写的中文功能介绍")
|
|
941
1073
|
p.add_argument(
|
|
942
1074
|
"--allow-sensitive",
|
|
@@ -960,9 +1092,14 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
960
1092
|
if args.command == "list-friends":
|
|
961
1093
|
print_json({"friends": load_friends(args)})
|
|
962
1094
|
return 0
|
|
1095
|
+
if args.command == "list-groups":
|
|
1096
|
+
print_json({"groups": load_groups(args)})
|
|
1097
|
+
return 0
|
|
963
1098
|
|
|
964
|
-
if args.friend_id
|
|
965
|
-
raise AppError("friend-id 必须为正整数")
|
|
1099
|
+
if args.friend_id < 0 or args.group_id < 0:
|
|
1100
|
+
raise AppError("friend-id / group-id 必须为正整数")
|
|
1101
|
+
if (args.friend_id > 0) == (args.group_id > 0):
|
|
1102
|
+
raise AppError("必须且只能指定 --friend-id 或 --group-id 之一")
|
|
966
1103
|
skill = merge_skill_detail(args, find_skill(args, args.skill))
|
|
967
1104
|
friend_name = (args.friend_name or "未命名虾友").strip()
|
|
968
1105
|
sensitive_findings: List[StringDict] = []
|
|
@@ -973,19 +1110,35 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
973
1110
|
)
|
|
974
1111
|
if args.command == "preview":
|
|
975
1112
|
message = build_preview_message(skill, args.description_zh)
|
|
1113
|
+
target: JsonDict
|
|
1114
|
+
if args.group_id > 0:
|
|
1115
|
+
target = {
|
|
1116
|
+
"group": {"groupId": args.group_id, "name": args.group_name.strip()}
|
|
1117
|
+
}
|
|
1118
|
+
else:
|
|
1119
|
+
target = {
|
|
1120
|
+
"friend": {"friendId": args.friend_id, "displayName": friend_name}
|
|
1121
|
+
}
|
|
976
1122
|
print_json({
|
|
977
1123
|
"skill": skill,
|
|
978
|
-
|
|
1124
|
+
**target,
|
|
979
1125
|
"message": message,
|
|
980
1126
|
"sensitiveFindings": sensitive_findings if sensitive_findings else None,
|
|
981
1127
|
})
|
|
982
1128
|
return 0
|
|
983
1129
|
|
|
984
|
-
token =
|
|
985
|
-
if not args.allow_expired_jwt:
|
|
986
|
-
ensure_jwt_valid_for_use(token, args.jwt_path)
|
|
1130
|
+
token = get_web_token(args)
|
|
987
1131
|
base_url = normalize_base_url(args.base_url)
|
|
988
|
-
|
|
1132
|
+
group: Optional[JsonDict] = None
|
|
1133
|
+
if args.group_id > 0:
|
|
1134
|
+
group = resolve_group(args, args.group_id)
|
|
1135
|
+
rid = str(group.get("rid") or "")
|
|
1136
|
+
if not rid:
|
|
1137
|
+
raise AppError(
|
|
1138
|
+
f"群「{group.get('name')}」缺少 rid,无法发送;请联系管理员检查群数据"
|
|
1139
|
+
)
|
|
1140
|
+
else:
|
|
1141
|
+
rid = get_or_create_dm(base_url, token, args.friend_id, args.timeout)
|
|
989
1142
|
prefix = build_intro_prefix(skill, args.description_zh)
|
|
990
1143
|
if skill.get("storeSlug"):
|
|
991
1144
|
payload = build_store_payload(skill, prefix)
|
|
@@ -1009,14 +1162,21 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
1009
1162
|
)
|
|
1010
1163
|
message = encode_skill_share_message(payload, prefix)
|
|
1011
1164
|
sent = send_message(base_url, token, rid, message, args.timeout)
|
|
1012
|
-
result = {
|
|
1165
|
+
result: JsonDict = {
|
|
1013
1166
|
"success": True,
|
|
1014
1167
|
"skill": skill["name"],
|
|
1015
|
-
"friend": {"friendId": args.friend_id, "displayName": friend_name},
|
|
1016
1168
|
"rid": rid,
|
|
1017
1169
|
"messageId": sent.get("_id"),
|
|
1018
1170
|
"message": message,
|
|
1019
1171
|
}
|
|
1172
|
+
if group is not None:
|
|
1173
|
+
result["group"] = {
|
|
1174
|
+
"groupId": group["groupId"],
|
|
1175
|
+
"name": group["name"],
|
|
1176
|
+
"memberCount": group.get("memberCount"),
|
|
1177
|
+
}
|
|
1178
|
+
else:
|
|
1179
|
+
result["friend"] = {"friendId": args.friend_id, "displayName": friend_name}
|
|
1020
1180
|
if sensitive_findings:
|
|
1021
1181
|
result["sensitiveFindings"] = sensitive_findings
|
|
1022
1182
|
result["sensitiveWarning"] = "已发送包含敏感信息的 Skill,请确认接收方可信"
|