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,6 +1,6 @@
1
1
  {
2
2
  "name": "sophhub",
3
- "version": "0.4.69",
3
+ "version": "0.4.70",
4
4
  "description": "SophHub CLI - Manage and download AI Agent skills and agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,12 +1,21 @@
1
1
  {
2
2
  "name": "share-skill",
3
- "version": "1.0.0",
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-05-05",
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、选择虾友后直接发送 web 格式 Skill 分享卡片。Use when the user asks to share or introduce an installed Skill to a Sophclaw friend / 虾友.
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 分享卡片格式发送给虾友。必须按顺序执行:**选择 Skill → 选择虾友 直接发送**。
8
+ 用于把当前容器里实际安装的某个 Skill 按 web 前端一致的 Skill 分享卡片格式发送给虾友或虾友群。必须按顺序执行:**选择 Skill → 选择目标(虾友 / 群聊)→ 发送(群目标必须先回显确认)**。
9
9
 
10
10
  ## Processing Mode
11
11
 
12
- **STRICT SERIAL PROCESSING ONLY** — 不并行发送;用户选定 Skill 和虾友后直接发送,不再追加确认步骤。
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
- ## Step 3:发送(含敏感信息确认)
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
- 用户选定虾友后,发送自定义 Skill 前,脚本会先扫描 Skill 目录是否包含私钥、token、`.env`、凭据文件等秘钥相关敏感信息。
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 ensure_jwt_valid_for_use(
163
- token: str,
164
- jwt_path: str,
165
- clock_skew_sec: int = 60,
166
- ) -> None:
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 >= exp + clock_skew_sec:
172
- raise AppError(f"JWT 已过期,请重新登录后刷新 {jwt_path}")
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 = read_bearer_token(args.jwt_path)
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("--friend-id", type=int, required=True, help="目标虾友 friendId")
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 <= 0:
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
- "friend": {"friendId": args.friend_id, "displayName": friend_name},
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 = read_bearer_token(args.jwt_path)
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
- rid = get_or_create_dm(base_url, token, args.friend_id, args.timeout)
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,请确认接收方可信"