sophhub 0.4.69 → 0.4.71

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.71",
4
4
  "description": "SophHub CLI - Manage and download AI Agent skills and agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,7 @@ import json
9
9
  import os
10
10
  import sys
11
11
  import time
12
+ from datetime import datetime, timedelta
12
13
 
13
14
  import requests
14
15
 
@@ -355,6 +356,157 @@ def pick_flight_and_cabin(
355
356
 
356
357
  # ---------- 创建订单(常规) ----------
357
358
 
359
+ def merge_verified_prices(
360
+ flight_detail: dict,
361
+ cabin: dict,
362
+ vdata: dict,
363
+ sale_price: float,
364
+ departure_tax: float,
365
+ fuel_tax: float,
366
+ ) -> tuple[dict, dict]:
367
+ """把验价得到的实时价格覆盖到 flight/cabin 的副本上,供创单使用。
368
+
369
+ 创单的 productInfo 由这两个对象构造。若不覆盖,priceInfos/segmentInfos 写的是搜索
370
+ 缓存的旧价格,而同一个请求里的 estimatedTotal 与 shoppingCode 用的是验价后的新值——
371
+ 验价返回 10301(票价变动)或 isUpdatePrice=true 时,这种自相矛盾会让供应商拒单,
372
+ 订单留痕也会与实际成交价不符。
373
+
374
+ 只改价格相关字段,其余字段(cabinCode、flightNo、subCabinCode 等)原样保留;
375
+ 入参对象不被修改。
376
+ """
377
+ merged_flight = dict(flight_detail)
378
+ merged_flight["departureTax"] = departure_tax
379
+ merged_flight["fuelTax"] = fuel_tax
380
+
381
+ merged_cabin = dict(cabin)
382
+ merged_cabin["salePrice"] = sale_price
383
+ face_price = vdata.get("facePrice")
384
+ if face_price is not None:
385
+ merged_cabin["facePrice"] = face_price
386
+ return merged_flight, merged_cabin
387
+
388
+
389
+ # 接口文档 6.4:SegmentInfo / RefundChangeDetail 的字段。缺失的取值一律不传,
390
+ # 不用 null 顶替——那会把"没有这个数据"变成"这个字段是空的"。
391
+ _FULL_PRICE_FIELD_BY_GRADE = {"Y": "yPrice", "C": "cPrice", "F": "fPrice"}
392
+ _REFUND_CHANGE_FIELDS = (
393
+ "baggageNum", "baggageKg", "baggage", "changeAmountList", "endorseRule",
394
+ "refundHeaders", "changeHeaders", "refundAmountList", "remark",
395
+ "changeRule", "refundRule",
396
+ )
397
+
398
+
399
+ def _text_or_none(value):
400
+ """只接受非空字符串;其它类型(含对象)一律视为无值。"""
401
+ if isinstance(value, str):
402
+ value = value.strip()
403
+ return value or None
404
+ return None
405
+
406
+
407
+ def _stop_city_code(flight_detail: dict):
408
+ """经停城市三字码。
409
+
410
+ 供应商把经停信息放在对象里(文档 6.1 `flightStopOver: FlightStopOver`,实测形如
411
+ ``{"cityName": "成都", "stopCityCode": "CTU", "stopDuration": "03:30"}``),非经停航班
412
+ 为 null;这里兼容"直接给字符串"的写法,两者取不到值就返回 None(不传该字段)。
413
+ """
414
+ stop_over = flight_detail.get("flightStopOver")
415
+ if isinstance(stop_over, dict):
416
+ return _text_or_none(stop_over.get("stopCityCode"))
417
+ return _text_or_none(stop_over)
418
+
419
+
420
+ def _full_price_of(flight_detail: dict, cabin: dict):
421
+ """舱位全价:供应商在 fullPriceCabin 里按舱位等级给出(Y→yPrice / C→cPrice / F→fPrice)。"""
422
+ full = flight_detail.get("fullPriceCabin") or {}
423
+ return full.get(_FULL_PRICE_FIELD_BY_GRADE.get((cabin.get("grade") or "").upper(), ""))
424
+
425
+
426
+ def _refund_change_detail(refund_change: dict):
427
+ """退改规则:按文档 6.4 的 RefundChangeDetail 结构取字段。"""
428
+ if not isinstance(refund_change, dict):
429
+ return None
430
+ detail = {k: refund_change.get(k) for k in _REFUND_CHANGE_FIELDS
431
+ if refund_change.get(k) is not None}
432
+ return detail or None
433
+
434
+
435
+ def _arrival_date(from_date: str, cross_day) -> str:
436
+ """到达日期:跨天航班算次日,否则与起飞同一天(文档要求必填)。"""
437
+ if not from_date or not cross_day:
438
+ return from_date
439
+ try:
440
+ return (datetime.strptime(from_date, "%Y-%m-%d") + timedelta(days=1)).strftime("%Y-%m-%d")
441
+ except ValueError:
442
+ return from_date
443
+
444
+
445
+ def build_product_info(
446
+ flight_detail: dict,
447
+ cabin: dict,
448
+ from_city: str,
449
+ to_city: str,
450
+ from_date: str,
451
+ passenger_type: str = "ADU",
452
+ ) -> dict:
453
+ """构造创单 productInfo(接口文档 6.4)。
454
+
455
+ 字段覆盖文档里 SegmentInfo / RefundChangeDetail / PriceInfo 的必填项;供应商未返回的
456
+ 取值直接不传(例如经停城市、到达航站楼在非经停/无数据时为空),不用 null 顶替。
457
+
458
+ 供应商用它记录订单的产品信息;网关侧也据此判定订单的价格类型并写入订单留痕
459
+ (price_type / price_type_reason)。不带这段时留痕只能记 UNKNOWN——服务端
460
+ 没有别的可信来源。
461
+ """
462
+ segment = {
463
+ "aircraftType": (flight_detail.get("aircraft") or {}).get("code"),
464
+ "cabinClass": cabin.get("grade"),
465
+ "cabinCode": cabin.get("code"),
466
+ "cabinFullPrice": _full_price_of(flight_detail, cabin),
467
+ "cabinType": cabin.get("type"),
468
+ "carrierName": (flight_detail.get("carrier") or {}).get("name"),
469
+ "departureTax": flight_detail.get("departureTax"),
470
+ "facePrice": cabin.get("facePrice"),
471
+ "flightDuration": flight_detail.get("flightDuration"),
472
+ "flightNo": flight_detail.get("flightNo"),
473
+ "fromAirportCode": flight_detail.get("fromAirportCode"),
474
+ "fromAirportName": flight_detail.get("fromAirportName"),
475
+ "fromAirportNameEn": flight_detail.get("fromAirportEn"),
476
+ "fromCityCode": from_city,
477
+ "fromCityName": flight_detail.get("fromCityCN"),
478
+ "fromDate": from_date,
479
+ "fromTerminal": flight_detail.get("fromTerminal"),
480
+ "fromTime": flight_detail.get("fromTime"),
481
+ "fuelTax": flight_detail.get("fuelTax"),
482
+ "productType": cabin.get("productType"),
483
+ "refundChangeDetail": _refund_change_detail(cabin.get("refundChange")),
484
+ "stopCityCode": _stop_city_code(flight_detail),
485
+ "toAirportCode": flight_detail.get("toAirportCode"),
486
+ "toAirportName": flight_detail.get("toAirportName"),
487
+ "toAirportNameEn": flight_detail.get("toAirportEn"),
488
+ "toCityCode": to_city,
489
+ "toCityName": flight_detail.get("toCityCN"),
490
+ "toDate": _arrival_date(from_date, flight_detail.get("crossDay")),
491
+ "toTerminal": flight_detail.get("toTerminal"),
492
+ "toTime": flight_detail.get("toTime"),
493
+ "yprice": (flight_detail.get("fullPriceCabin") or {}).get("yPrice"),
494
+ }
495
+ if flight_detail.get("isShareFlight") and flight_detail.get("realFlightNo"):
496
+ segment["realFlightNo"] = flight_detail["realFlightNo"]
497
+ price = {
498
+ "passengerType": passenger_type,
499
+ "salePrice": cabin.get("salePrice"),
500
+ "departureTax": flight_detail.get("departureTax"),
501
+ "fuelTax": flight_detail.get("fuelTax"),
502
+ "rescheduledPrice": 0,
503
+ }
504
+ return {
505
+ "segmentInfos": [{k: v for k, v in segment.items() if v is not None}],
506
+ "priceInfos": [{k: v for k, v in price.items() if v is not None}],
507
+ }
508
+
509
+
358
510
  def create_order(
359
511
  flight_detail: dict,
360
512
  cabin: dict,
@@ -389,6 +541,10 @@ def create_order(
389
541
  "estimatedTotal": estimated_total,
390
542
  "travelBusiness": True,
391
543
  "passengerInfo": encrypted_passenger,
544
+ "productInfo": build_product_info(
545
+ flight_detail, cabin, from_city, to_city, from_date,
546
+ passenger.get("passengerType") or "ADU",
547
+ ),
392
548
  }
393
549
  if price_info:
394
550
  order_data["priceInfo"] = price_info
@@ -843,9 +999,15 @@ def cmd_create_order(args: argparse.Namespace) -> int:
843
999
  "message": verify_res.get("msg", ""),
844
1000
  }
845
1001
 
1002
+ # 把验价后的实时价格覆盖到传入创单的对象上:productInfo 由它构造,
1003
+ # 否则会出现 estimatedTotal/shoppingCode 用新值、productInfo 用缓存旧值的矛盾
1004
+ order_flight, order_cabin = merge_verified_prices(
1005
+ flight, cabin, vdata, sale_price, dep_tax, fuel_tax
1006
+ )
1007
+
846
1008
  order_res = create_order(
847
- flight_detail=flight,
848
- cabin=cabin,
1009
+ flight_detail=order_flight,
1010
+ cabin=order_cabin,
849
1011
  from_city=from_city,
850
1012
  to_city=to_city,
851
1013
  from_date=from_date,
@@ -0,0 +1,277 @@
1
+ #!/usr/bin/env python3
2
+ """flight_booking 创单 productInfo 的单测(不触网、不需要密钥)。
3
+
4
+ 覆盖两类问题:
5
+ 1. 验价返回 10301(票价变动)或 isUpdatePrice=true 时,productInfo 必须用**验价后的
6
+ 实时价格与税费**,与同一请求里的 estimatedTotal / shoppingCode 保持一致;
7
+ 2. productInfo 必须包含接口文档 6.4 规定的必填字段(SegmentInfo / RefundChangeDetail /
8
+ PriceInfo)。
9
+
10
+ 用法::
11
+
12
+ python3 skills/flight-booking/src/scripts/test_flight_booking.py
13
+
14
+ 依赖:脚本自身依赖 ``requests``(业务脚本在导入时即引入),运行前需已安装。
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import sys
20
+ import tempfile
21
+ from pathlib import Path
22
+
23
+ # 先隔离运行环境,再导入被测模块:
24
+ # 模块导入时要求 SOPH_API_KEY(或 ~/.openclaw/openclaw.json)且会创建数据目录,
25
+ # 单测不应依赖密钥、也不该写真实用户目录。
26
+ _TMP_HOME = tempfile.mkdtemp(prefix="flight-booking-test-")
27
+ os.environ["HOME"] = _TMP_HOME
28
+ os.environ["USERPROFILE"] = _TMP_HOME
29
+ os.environ.setdefault("SOPH_API_KEY", "unit-test-key-not-used")
30
+
31
+ HERE = Path(__file__).resolve().parent
32
+ if str(HERE) not in sys.path:
33
+ sys.path.insert(0, str(HERE))
34
+
35
+ import flight_booking as fb # noqa: E402
36
+
37
+ # 接口文档 6.4 SegmentInfo 的必填字段
38
+ REQUIRED_SEGMENT_FIELDS = (
39
+ "aircraftType", "cabinClass", "cabinCode", "cabinFullPrice", "cabinType",
40
+ "carrierName", "departureTax", "facePrice", "flightDuration", "flightNo",
41
+ "fromAirportCode", "fromAirportName", "fromAirportNameEn", "fromCityCode",
42
+ "fromCityName", "fromDate", "fromTerminal", "fromTime", "fuelTax",
43
+ "productType", "refundChangeDetail", "stopCityCode",
44
+ "toAirportCode", "toAirportName", "toAirportNameEn", "toCityCode",
45
+ "toCityName", "toDate", "toTerminal", "toTime", "yprice",
46
+ )
47
+
48
+ # 接口文档 6.4 RefundChangeDetail 的必填字段
49
+ REQUIRED_REFUND_CHANGE_FIELDS = (
50
+ "baggageNum", "baggageKg", "baggage", "changeAmountList", "endorseRule",
51
+ "refundHeaders", "changeHeaders", "refundAmountList", "remark",
52
+ "changeRule", "refundRule",
53
+ )
54
+
55
+
56
+ def _cached_flight() -> dict:
57
+ """搜索缓存里的航班对象(字段取自 Dev2 实测响应)。"""
58
+ return {
59
+ "flightNo": "MU5123",
60
+ "aircraft": {"code": "32N", "name": "空客A320"},
61
+ "carrier": {"code": "MU", "name": "东方航空"},
62
+ "departureTax": 50,
63
+ "fuelTax": 70,
64
+ "flightDuration": "02h15m",
65
+ "fromAirportCode": "SHA",
66
+ "fromAirportName": "虹桥国际机场",
67
+ "fromAirportEn": "Hongqiao International Airport",
68
+ "fromCityCode": "SHA",
69
+ "fromCityCN": "上海",
70
+ "fromTerminal": "T2",
71
+ "fromTime": "2026-10-01 19:00:00",
72
+ "toAirportCode": "PKX",
73
+ "toAirportName": "大兴国际机场",
74
+ "toAirportEn": "Daxing International Airport",
75
+ "toCityCode": "BJS",
76
+ "toCityCN": "北京",
77
+ "toTerminal": "T2",
78
+ "toTime": "2026-10-01 21:15:00",
79
+ "crossDay": False,
80
+ "flightStopOver": None,
81
+ "fullPriceCabin": {"yPrice": 3230, "cPrice": 3870, "fPrice": 8390},
82
+ }
83
+
84
+
85
+ def _cached_cabin() -> dict:
86
+ """搜索缓存里的舱位对象(字段取自 Dev2 实测响应)。"""
87
+ return {
88
+ "code": "V",
89
+ "grade": "Y",
90
+ "type": "-1",
91
+ "salePrice": 700,
92
+ "facePrice": 700,
93
+ "productType": "STANDARD_REFUND",
94
+ "shoppingCode": "SC-CACHED",
95
+ "refundChange": {
96
+ "baggageNum": 1,
97
+ "baggageKg": 20.0,
98
+ "baggage": "免费托运行李额20KG",
99
+ "changeAmountList": [65, 194],
100
+ "endorseRule": "不可签转",
101
+ "refundHeaders": ["起飞前7天前", "起飞前2小时前"],
102
+ "changeHeaders": ["起飞前7天前", "起飞前2小时前"],
103
+ "refundAmountList": [129, 258],
104
+ "remark": "以航司最新规定为准",
105
+ "changeRule": "65-168-194-48",
106
+ "refundRule": "129-168-258-48",
107
+ # 供应商会额外返回这些字段;文档未要求,不应出现在 payload 里
108
+ "cabinCode": "V",
109
+ "cabinName": "经济舱",
110
+ "salePrice": 700,
111
+ },
112
+ }
113
+
114
+
115
+ def _product_info(flight_detail: dict, cabin: dict, vdata: dict,
116
+ sale_price: float, departure_tax: float, fuel_tax: float) -> dict:
117
+ """模拟 cmd_create_order 的取值路径:验价 → 覆盖价格 → 构造 productInfo。"""
118
+ merged_flight, merged_cabin = fb.merge_verified_prices(
119
+ flight_detail, cabin, vdata, sale_price, departure_tax, fuel_tax
120
+ )
121
+ return fb.build_product_info(merged_flight, merged_cabin, "SHA", "BJS", "2026-10-01")
122
+
123
+
124
+ def test_product_info_uses_search_prices_when_unchanged() -> None:
125
+ """场景 1:验价价格不变 → productInfo 与搜索结果一致。"""
126
+ flight, cabin = _cached_flight(), _cached_cabin()
127
+ vdata = {"salePrice": 700, "departureTax": 50, "fuelTax": 70, "shoppingCode": "SC-LIVE"}
128
+
129
+ product_info = _product_info(flight, cabin, vdata, 700.0, 50.0, 70.0)
130
+
131
+ price = product_info["priceInfos"][0]
132
+ segment = product_info["segmentInfos"][0]
133
+ assert price["salePrice"] == 700, f"priceInfos.salePrice 应等于搜索结果 700,实际 {price['salePrice']}"
134
+ assert price["departureTax"] == 50 and price["fuelTax"] == 70, "税费应与搜索结果一致"
135
+ assert segment["facePrice"] == 700, "segmentInfos.facePrice 应等于搜索缓存票面价"
136
+
137
+
138
+ def test_product_info_follows_verified_prices_when_price_changed() -> None:
139
+ """场景 2:验价返回 10301/isUpdatePrice → productInfo 必须用验价后的价格与税费。"""
140
+ flight, cabin = _cached_flight(), _cached_cabin()
141
+ # 搜索缓存:700 / 机建 50 / 燃油 70;验价后:750 / 60 / 80,票面价 800
142
+ vdata = {
143
+ "salePrice": 750, "departureTax": 60, "fuelTax": 80, "facePrice": 800,
144
+ "isUpdatePrice": True, "shoppingCode": "SC-LIVE",
145
+ }
146
+ sale_price, dep_tax, fuel_tax = 750.0, 60.0, 80.0
147
+
148
+ product_info = _product_info(flight, cabin, vdata, sale_price, dep_tax, fuel_tax)
149
+
150
+ price = product_info["priceInfos"][0]
151
+ segment = product_info["segmentInfos"][0]
152
+ assert price["salePrice"] == 750, f"priceInfos.salePrice 应为验价后的 750,实际 {price['salePrice']}"
153
+ assert price["departureTax"] == 60 and price["fuelTax"] == 80, "税费应为验价后的值"
154
+ assert segment["departureTax"] == 60 and segment["fuelTax"] == 80, "segmentInfos 的税费也应为验价后的值"
155
+ assert segment["facePrice"] == 800, "票面价应取验价响应里的 facePrice"
156
+
157
+ # 与同一请求里的 estimatedTotal 保持一致
158
+ estimated_total = sale_price + dep_tax + fuel_tax
159
+ assert price["salePrice"] + price["departureTax"] + price["fuelTax"] == estimated_total, \
160
+ "productInfo 的价格合计必须与 estimatedTotal 一致"
161
+
162
+
163
+ def test_product_info_has_all_required_doc_fields() -> None:
164
+ """productInfo 必须满足文档 6.4 的必填字段(本轮 review 的问题点)。
165
+
166
+ 夹具取"字段齐全"的航班(含经停城市)——经停城市是文档必填项,但只有经停航班才有值;
167
+ 非经停航班上供应商返回 null,此时不传该字段(见 test_missing_fields_are_omitted_not_null)。
168
+ """
169
+ flight, cabin = _cached_flight(), _cached_cabin()
170
+ flight["flightStopOver"] = {"cityName": "成都", "stopCityCode": "CTU", "stopDuration": "03:30"}
171
+ product_info = _product_info(flight, cabin, {}, 700.0, 50.0, 70.0)
172
+
173
+ assert set(product_info) == {"segmentInfos", "priceInfos"}
174
+ assert len(product_info["segmentInfos"]) == 1 and len(product_info["priceInfos"]) == 1
175
+
176
+ segment = product_info["segmentInfos"][0]
177
+ missing = [f for f in REQUIRED_SEGMENT_FIELDS if f not in segment]
178
+ assert not missing, f"segmentInfos 缺少文档必填字段: {missing}"
179
+
180
+ refund = segment["refundChangeDetail"]
181
+ missing_refund = [f for f in REQUIRED_REFUND_CHANGE_FIELDS if f not in refund]
182
+ assert not missing_refund, f"refundChangeDetail 缺少文档必填字段: {missing_refund}"
183
+ assert "cabinName" not in refund, "文档未要求的供应商字段不应带进请求"
184
+
185
+ price = product_info["priceInfos"][0]
186
+ missing_price = [f for f in ("passengerType", "salePrice", "departureTax", "fuelTax",
187
+ "rescheduledPrice") if f not in price]
188
+ assert not missing_price, f"priceInfos 缺少必填字段: {missing_price}"
189
+
190
+
191
+ def test_full_price_comes_from_full_price_cabin() -> None:
192
+ """cabinFullPrice / yprice 取供应商的 fullPriceCabin,而不是票面价。"""
193
+ flight, cabin = _cached_flight(), _cached_cabin()
194
+ segment = _product_info(flight, cabin, {}, 700.0, 50.0, 70.0)["segmentInfos"][0]
195
+
196
+ assert segment["cabinFullPrice"] == 3230, "Y 舱应取 fullPriceCabin.yPrice"
197
+ assert segment["yprice"] == 3230, "yprice 为经济舱全价"
198
+
199
+
200
+ def test_arrival_date_follows_cross_day() -> None:
201
+ """到达日期:跨天航班为次日,否则与起飞同一天(文档必填)。"""
202
+ flight, cabin = _cached_flight(), _cached_cabin()
203
+
204
+ same_day = _product_info(flight, cabin, {}, 700.0, 50.0, 70.0)["segmentInfos"][0]
205
+ assert same_day["toDate"] == "2026-10-01"
206
+
207
+ flight["crossDay"] = True
208
+ next_day = _product_info(flight, cabin, {}, 700.0, 50.0, 70.0)["segmentInfos"][0]
209
+ assert next_day["toDate"] == "2026-10-02", "跨天航班到达日期应为次日"
210
+
211
+
212
+ def test_stop_city_code_read_from_nested_object() -> None:
213
+ """经停城市要从 flightStopOver 对象里取(真实响应形如 {"cityName":…, "stopCityCode":"CTU"})。
214
+
215
+ 回归护栏:曾经用 _text_or_none() 直接读该字段,遇到对象一律返回 None,
216
+ 结果**恰恰在有经停的航班上**把文档必填的 stopCityCode 丢掉。
217
+ """
218
+ flight, cabin = _cached_flight(), _cached_cabin()
219
+
220
+ flight["flightStopOver"] = {"cityName": "成都", "stopCityCode": "CTU", "stopDuration": "03:30"}
221
+ segment = _product_info(flight, cabin, {}, 700.0, 50.0, 70.0)["segmentInfos"][0]
222
+ assert segment.get("stopCityCode") == "CTU", "有经停时必须从对象里取出 stopCityCode"
223
+
224
+ # 非经停航班:字段为 null → 不传该字段
225
+ flight["flightStopOver"] = None
226
+ segment = _product_info(flight, cabin, {}, 700.0, 50.0, 70.0)["segmentInfos"][0]
227
+ assert "stopCityCode" not in segment, "非经停航班不应传空的经停城市"
228
+
229
+ # 兼容供应商直接给字符串的写法
230
+ flight["flightStopOver"] = "CTU"
231
+ segment = _product_info(flight, cabin, {}, 700.0, 50.0, 70.0)["segmentInfos"][0]
232
+ assert segment.get("stopCityCode") == "CTU", "字符串写法也应兼容"
233
+
234
+ # 对象里没有 stopCityCode → 不传
235
+ flight["flightStopOver"] = {"cityName": "成都"}
236
+ segment = _product_info(flight, cabin, {}, 700.0, 50.0, 70.0)["segmentInfos"][0]
237
+ assert "stopCityCode" not in segment
238
+
239
+
240
+ def test_missing_fields_are_omitted_not_null() -> None:
241
+ """源数据缺失的字段直接不传,而不是传 null。"""
242
+ flight, cabin = _cached_flight(), _cached_cabin()
243
+ flight.pop("flightDuration")
244
+ flight.pop("toTerminal")
245
+ cabin.pop("type")
246
+ cabin.pop("refundChange")
247
+
248
+ segment = _product_info(flight, cabin, {}, 700.0, 50.0, 70.0)["segmentInfos"][0]
249
+
250
+ for absent in ("flightDuration", "toTerminal", "cabinType", "refundChangeDetail", "stopCityCode"):
251
+ assert absent not in segment, f"{absent} 无数据时不应出现在请求里"
252
+
253
+
254
+ def test_merge_does_not_mutate_inputs() -> None:
255
+ """覆盖价格时不能改到搜索缓存对象本身(后续逻辑仍要用原值算价差)。"""
256
+ flight, cabin = _cached_flight(), _cached_cabin()
257
+ vdata = {"salePrice": 750, "facePrice": 800, "departureTax": 60, "fuelTax": 80}
258
+
259
+ fb.merge_verified_prices(flight, cabin, vdata, 750.0, 60.0, 80.0)
260
+
261
+ assert cabin["salePrice"] == 700 and cabin["facePrice"] == 700, "原始 cabin 不应被修改"
262
+ assert flight["departureTax"] == 50 and flight["fuelTax"] == 70, "原始 flight 不应被修改"
263
+
264
+
265
+ if __name__ == "__main__":
266
+ tests = [(name, fn) for name, fn in sorted(globals().items())
267
+ if name.startswith("test_") and callable(fn)]
268
+ failed = 0
269
+ for name, fn in tests:
270
+ try:
271
+ fn()
272
+ print(f"PASS {name}")
273
+ except AssertionError as exc:
274
+ failed += 1
275
+ print(f"FAIL {name}: {exc}")
276
+ print(f"\n{len(tests) - failed}/{len(tests)} passed")
277
+ sys.exit(1 if failed else 0)
@@ -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,请确认接收方可信"