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 +1 -1
- package/skills/flight-booking/src/scripts/flight_booking.py +164 -2
- package/skills/flight-booking/src/scripts/test_flight_booking.py +277 -0
- package/skills/share-skill/skill.json +12 -3
- package/skills/share-skill/src/SKILL.md +100 -8
- package/skills/share-skill/src/scripts/share_skill_to_friend.py +182 -22
package/package.json
CHANGED
|
@@ -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=
|
|
848
|
-
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.
|
|
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,请确认接收方可信"
|