wechatbridge-cli 1.7.1__tar.gz → 1.7.2__tar.gz

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.
Files changed (29) hide show
  1. {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/PKG-INFO +1 -1
  2. wechatbridge_cli-1.7.2/tests/test_price.py +186 -0
  3. {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge/__init__.py +1 -1
  4. {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge/main.py +1 -1
  5. {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge/price.py +41 -9
  6. {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge_cli.egg-info/PKG-INFO +1 -1
  7. {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge_cli.egg-info/SOURCES.txt +1 -0
  8. {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/LICENSE +0 -0
  9. {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/README.md +0 -0
  10. {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/pyproject.toml +0 -0
  11. {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/setup.cfg +0 -0
  12. {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/tests/test_codex.py +0 -0
  13. {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/tests/test_codex_auth.py +0 -0
  14. {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/tests/test_dsh.py +0 -0
  15. {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/tests/test_grok_auth.py +0 -0
  16. {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/tests/test_hardening.py +0 -0
  17. {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge/__main__.py +0 -0
  18. {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge/agy.py +0 -0
  19. {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge/codex.py +0 -0
  20. {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge/config.py +0 -0
  21. {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge/dsh.py +0 -0
  22. {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge/grok.py +0 -0
  23. {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge/ilink.py +0 -0
  24. {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge/runner_common.py +0 -0
  25. {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge/update_check.py +0 -0
  26. {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge_cli.egg-info/dependency_links.txt +0 -0
  27. {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge_cli.egg-info/entry_points.txt +0 -0
  28. {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge_cli.egg-info/requires.txt +0 -0
  29. {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wechatbridge-cli
3
- Version: 1.7.1
3
+ Version: 1.7.2
4
4
  Summary: Bridge WeChat messages to agy, Grok Build, or Codex CLIs — text/image/file/voice in, CLI replies and generated files back.
5
5
  Author: WeChatBridge contributors
6
6
  License: MIT
@@ -0,0 +1,186 @@
1
+ """Price module contract tests (stdlib unittest only).
2
+
3
+ These pin the *external* contracts that cannot be exercised without real
4
+ API keys: the JD gateway signature rule, the Dataoke signature rule, the
5
+ correct Dataoke endpoints/params for taokouling parsing and goods detail,
6
+ and response parsing against sample payloads shaped like the real APIs.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import os
13
+ import unittest
14
+ from unittest import mock
15
+
16
+ os.environ.setdefault("JDUNION_APP_KEY", "test_jd_key")
17
+ os.environ.setdefault("JDUNION_APP_SECRET", "test_jd_secret")
18
+ os.environ.setdefault("DATAOKE_APP_KEY", "test_dt_key")
19
+ os.environ.setdefault("DATAOKE_APP_SECRET", "test_dt_secret")
20
+
21
+ from wechatbridge import price as price_mod
22
+
23
+
24
+ class TestJdSign(unittest.TestCase):
25
+ def test_secret_wraps_both_ends(self):
26
+ params = {
27
+ "method": "jd.union.open.goods.query",
28
+ "app_key": "test_jd_key",
29
+ "access_token": "",
30
+ "timestamp": "2026-11-01 19:00:00",
31
+ "format": "json",
32
+ "v": "1.0",
33
+ "sign_method": "md5",
34
+ "360buy_param_json": '{"skuIds":["123"]}',
35
+ }
36
+ plain = "".join(f"{k}{v}" for k, v in sorted(params.items()))
37
+ expected = hashlib.md5(
38
+ ("test_jd_secret" + plain + "test_jd_secret").encode("utf-8")
39
+ ).hexdigest().upper()
40
+ self.assertEqual(price_mod._jd_sign(dict(params)), expected)
41
+
42
+ def test_jd_query_parses_sample_payload(self):
43
+ sample = {
44
+ "jd_union_open_goods_query_reslt": {
45
+ "code": "0",
46
+ "data": [
47
+ {
48
+ "skuName": "Sample Widget 2000",
49
+ "priceInfo": {"price": 129.0, "lowestPrice": 99.0},
50
+ "couponInfo": {
51
+ "couponList": [
52
+ {"discount": 20.0, "quota": 100.0, "endTime": "2026-11-11 23:59:59"}
53
+ ]
54
+ },
55
+ "imageInfo": {"imageList": ["https://img.example/x.jpg"]},
56
+ "shopInfo": {"shopName": "Sample Shop"},
57
+ "materialUrl": "https://item.jd.com/123.html",
58
+ }
59
+ ],
60
+ }
61
+ }
62
+ with mock.patch.object(price_mod, "_jd_call", return_value=sample):
63
+ res = price_mod.query_jd_sku("123")
64
+ self.assertEqual(res.title, "Sample Widget 2000")
65
+ self.assertEqual(res.price, 129.0)
66
+ self.assertEqual(res.lowest_price, 99.0)
67
+ self.assertEqual(res.net_price(), 109.0)
68
+ card = res.card()
69
+ self.assertIn("到手价: ¥109", card)
70
+ self.assertIn("历史最低: ¥99", card)
71
+
72
+
73
+ class TestDataokeSignAndCalls(unittest.TestCase):
74
+ def test_dt_sign_matches_sdk_algorithm(self):
75
+ params = {"goodsId": "789", "appKey": "test_dt_key", "version": "v2.0.0"}
76
+ plain = "&".join(f"{k}={v}" for k, v in sorted(params.items()))
77
+ expected = hashlib.md5(
78
+ (plain + "&key=test_dt_secret").encode("utf-8")
79
+ ).hexdigest().upper()
80
+ self.assertEqual(price_mod._dt_sign(dict(params)), expected)
81
+
82
+ def test_dt_call_attaches_appkey_version_and_sign(self):
83
+ captured = {}
84
+
85
+ class FakeResp:
86
+ def raise_for_status(self):
87
+ pass
88
+
89
+ def json(self):
90
+ return {"code": 0, "data": {"ok": True}}
91
+
92
+ def fake_get(url, params=None, timeout=None):
93
+ captured["url"] = url
94
+ captured["params"] = dict(params or {})
95
+ return FakeResp()
96
+
97
+ with mock.patch.object(price_mod.httpx, "get", side_effect=fake_get):
98
+ out = price_mod._dt_call("/goods/get-goods-details", {"goodsId": "789"})
99
+ self.assertEqual(out, {"ok": True})
100
+ self.assertEqual(captured["params"]["appKey"], "test_dt_key")
101
+ self.assertEqual(captured["params"]["version"], "v2.0.0")
102
+ unsigned = {k: v for k, v in captured["params"].items() if k != "sign"}
103
+ self.assertEqual(captured["params"]["sign"], price_mod._dt_sign(unsigned))
104
+
105
+ def test_dt_call_raises_on_nonzero_code(self):
106
+ class FakeResp:
107
+ def raise_for_status(self):
108
+ pass
109
+
110
+ def json(self):
111
+ return {"code": 1, "msg": "sign error"}
112
+
113
+ with mock.patch.object(price_mod.httpx, "get", return_value=FakeResp()):
114
+ with self.assertRaises(ValueError):
115
+ price_mod._dt_call("/goods/get-goods-details", {"goodsId": "1"})
116
+
117
+ def test_resolve_tkl_uses_parse_content_endpoint(self):
118
+ captured = {}
119
+
120
+ def fake_call(path, params):
121
+ captured["path"] = path
122
+ captured["params"] = dict(params)
123
+ return {"goodsId": "789456"}
124
+
125
+ with mock.patch.object(price_mod, "_dt_call", side_effect=fake_call):
126
+ out = price_mod.resolve_tkl("CZ0001 aBcd12")
127
+ self.assertEqual(captured["path"], "/tb-service/parse-content")
128
+ self.assertEqual(captured["params"]["content"], "CZ0001 aBcd12")
129
+ self.assertEqual(out["goodsId"], "789456")
130
+
131
+ def test_query_tb_item_sends_goods_id_and_parses(self):
132
+ captured = {}
133
+ sample_data = {
134
+ "goods": {
135
+ "goodsName": "TB Sample",
136
+ "actualPrice": 59.9,
137
+ "originalPrice": 79.9,
138
+ "quanPrice": 10.0,
139
+ "quanReceiveCondition": 50.0,
140
+ "quanEndTime": "2026-11-11 00:00:00",
141
+ "shopName": "TB Shop",
142
+ "goodsImage": "https://img.example/y.jpg",
143
+ "goodsUrl": "https://detail.tmall.com/item.htm?id=789",
144
+ }
145
+ }
146
+
147
+ def fake_call(path, params):
148
+ captured["path"] = path
149
+ captured["params"] = dict(params)
150
+ return sample_data
151
+
152
+ with mock.patch.object(price_mod, "_dt_call", side_effect=fake_call):
153
+ res = price_mod.query_tb_item("789")
154
+ self.assertEqual(captured["params"], {"goodsId": "789"})
155
+ self.assertEqual(res.title, "TB Sample")
156
+ self.assertEqual(res.price, 59.9)
157
+ self.assertEqual(res.net_price(), 49.9)
158
+
159
+ def test_dt_configured_requires_secret(self):
160
+ with mock.patch.object(price_mod, "_DT_APP_SECRET", ""):
161
+ self.assertFalse(price_mod.dt_configured())
162
+ with mock.patch.object(price_mod, "_DT_APP_SECRET", "x"):
163
+ self.assertTrue(price_mod.dt_configured())
164
+
165
+
166
+ class TestDetectTrigger(unittest.TestCase):
167
+ def test_samples(self):
168
+ cases = [
169
+ ("看看 https://u.jd.com/AbCdEf 不错", "jd_short"),
170
+ ("8.8¥CZ0001 aBcd12¥ 打开淘宝", "tkl"),
171
+ ("https://item.jd.com/100012043978.html", "jd_item"),
172
+ ("https://detail.tmall.com/item.htm?id=789456123", "tb_item"),
173
+ ("https://m.tb.cn/h.abc123", "tb_short"),
174
+ ("今天天气不错", None),
175
+ ]
176
+ for text, kind in cases:
177
+ got = price_mod.detect_price_trigger(text)
178
+ if kind is None:
179
+ self.assertIsNone(got, msg=text)
180
+ else:
181
+ self.assertIsNotNone(got, msg=text)
182
+ self.assertEqual(got["kind"], kind, msg=text)
183
+
184
+
185
+ if __name__ == "__main__":
186
+ unittest.main()
@@ -1,2 +1,2 @@
1
1
  """WeChatBridge — bridge WeChat messages to agy, Grok Build, or Codex CLIs."""
2
- __version__ = "1.7.1"
2
+ __version__ = "1.7.2"
@@ -467,7 +467,7 @@ async def _cmd_price(args: str) -> str:
467
467
  "ℹ️ **比价功能未启用** ℹ️\n\n"
468
468
  "在桥的 .env 里配置以下任一项后重启:\n"
469
469
  "`JDUNION_APP_KEY` / `JDUNION_APP_SECRET`(京东联盟)\n"
470
- "`DATAOKE_APP_KEY`(淘宝/天猫,大淘客代理)"
470
+ "`DATAOKE_APP_KEY` / `DATAOKE_APP_SECRET`(淘宝/天猫,大淘客代理)"
471
471
  )
472
472
  trigger = detect_price_trigger(target)
473
473
  if not trigger:
@@ -37,6 +37,7 @@ _JD_APP_KEY = os.getenv("JDUNION_APP_KEY", "")
37
37
  # false-positive on this empty getenv default (no literal secret value here).
38
38
  _JD_SECRET_KEY = os.getenv("JDUNION_APP_SECRET", "")
39
39
  _DT_APP_KEY = os.getenv("DATAOKE_APP_KEY", "")
40
+ _DT_APP_SECRET = os.getenv("DATAOKE_APP_SECRET", "")
40
41
  _DT_TIMEOUT = int(os.getenv("PRICE_HTTP_TIMEOUT", "15"))
41
42
 
42
43
  JD_API_URL = "https://api.jd.com/routerjson"
@@ -48,7 +49,9 @@ def jd_configured() -> bool:
48
49
 
49
50
 
50
51
  def dt_configured() -> bool:
51
- return bool(_DT_APP_KEY)
52
+ # Dataoke rejects unsigned requests: both the appKey and the appSecret
53
+ # (used only locally to compute `sign`, never sent) are required.
54
+ return bool(_DT_APP_KEY and _DT_APP_SECRET)
52
55
 
53
56
 
54
57
  def price_feature_ready() -> bool:
@@ -182,11 +185,15 @@ def detect_price_trigger(text: str) -> dict | None:
182
185
 
183
186
 
184
187
  def _jd_sign(params: dict) -> str:
188
+ # JD gateway rule: MD5(secret + sorted key+value concat + secret), upper.
189
+ # The secret wraps BOTH ends — appending it only at the end produces a
190
+ # signature the gateway always rejects.
185
191
  plain = "".join(
186
192
  f"{k}{v}" for k, v in sorted(params.items()) if k != "sign"
187
193
  )
188
- plain += _JD_SECRET_KEY
189
- return hashlib.md5(plain.encode("utf-8")).hexdigest().upper()
194
+ return hashlib.md5(
195
+ (_JD_SECRET_KEY + plain + _JD_SECRET_KEY).encode("utf-8")
196
+ ).hexdigest().upper()
190
197
 
191
198
 
192
199
  def _jd_call(method: str, param_json: dict) -> dict:
@@ -244,10 +251,23 @@ def query_jd_sku(sku: str) -> PriceResult:
244
251
  # 大淘客 (Taobao/Tmall proxy) API
245
252
  # ---------------------------------------------------------------------------
246
253
 
254
+ def _dt_sign(params: dict) -> str:
255
+ # Dataoke rule (per the official SDK): sort every request parameter
256
+ # (appKey and version included, sign excluded) by key, join as
257
+ # "k1=v1&k2=v2&...", append "&key=<appSecret>", MD5, uppercase.
258
+ plain = "&".join(
259
+ f"{k}={v}" for k, v in sorted(params.items()) if k != "sign"
260
+ )
261
+ return hashlib.md5(
262
+ (plain + "&key=" + _DT_APP_SECRET).encode("utf-8")
263
+ ).hexdigest().upper()
264
+
265
+
247
266
  def _dt_call(path: str, params: dict) -> dict:
248
267
  params = dict(params)
249
268
  params["appKey"] = _DT_APP_KEY
250
269
  params["version"] = params.get("version", "v2.0.0")
270
+ params["sign"] = _dt_sign(params)
251
271
  resp = httpx.get(f"{DT_API_URL}{path}", params=params, timeout=_DT_TIMEOUT)
252
272
  resp.raise_for_status()
253
273
  data = resp.json()
@@ -257,16 +277,24 @@ def _dt_call(path: str, params: dict) -> dict:
257
277
 
258
278
 
259
279
  def resolve_tkl(token: str) -> dict:
260
- """Parse taokouling → item info via dataoke tpwd API."""
280
+ """Parse taokouling → item info via dataoke's universal parse API.
281
+
282
+ The endpoint for turning a share token / share text into a goods id is
283
+ /tb-service/parse-content (param: content). The privilege-link endpoint
284
+ only converts an already-known goodsId into a promotion link and cannot
285
+ parse tokens.
286
+ """
261
287
  return _dt_call(
262
- "/goods/get-privilege-link",
263
- {"taoCommand": token, "apiName": "generous"},
288
+ "/tb-service/parse-content",
289
+ {"content": token, "version": "v1.0.0"},
264
290
  )
265
291
 
266
292
 
267
293
  def query_tb_item(item_id: str) -> PriceResult:
268
294
  """Query Taobao/Tmall item via dataoke goods detail API."""
269
- data = _dt_call("/goods/get-goods-details", {"id": item_id})
295
+ # get-goods-details takes the Taobao item id as `goodsId`, not `id`
296
+ # (`id` is dataoke's own internal goods id and matches nothing here).
297
+ data = _dt_call("/goods/get-goods-details", {"goodsId": str(item_id)})
270
298
  goods = data.get("goods", data)
271
299
  quan_price = float(goods.get("quanPrice", 0) or 0)
272
300
  actual_price = float(goods.get("actualPrice", goods.get("originalPrice", 0)) or 0)
@@ -400,7 +428,11 @@ async def handle_price_query(trigger: dict) -> str:
400
428
  if not dt_configured():
401
429
  return _not_configured("大淘客(淘宝口令解析)")
402
430
  data = await asyncio.to_thread(resolve_tkl, trigger["token"])
403
- goods_id = str(data.get("goodsId", "") or data.get("id", ""))
431
+ goods_id = str(
432
+ data.get("goodsId", "")
433
+ or data.get("itemId", "")
434
+ or data.get("id", "")
435
+ )
404
436
  if not goods_id:
405
437
  return "❓ 口令解析出来了但没拿到商品信息,可能商品未收录。"
406
438
  res = await asyncio.to_thread(query_tb_item, goods_id)
@@ -440,5 +472,5 @@ def _not_configured(what: str) -> str:
440
472
  f"ℹ️ **比价功能未启用** ℹ️\n\n{what} 的 API key 还没配置。\n"
441
473
  "在桥的 .env 里设置后重启即可:\n"
442
474
  "`JDUNION_APP_KEY` / `JDUNION_APP_SECRET`(京东)\n"
443
- "`DATAOKE_APP_KEY`(淘宝/天猫)"
475
+ "`DATAOKE_APP_KEY` / `DATAOKE_APP_SECRET`(淘宝/天猫)"
444
476
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wechatbridge-cli
3
- Version: 1.7.1
3
+ Version: 1.7.2
4
4
  Summary: Bridge WeChat messages to agy, Grok Build, or Codex CLIs — text/image/file/voice in, CLI replies and generated files back.
5
5
  Author: WeChatBridge contributors
6
6
  License: MIT
@@ -6,6 +6,7 @@ tests/test_codex_auth.py
6
6
  tests/test_dsh.py
7
7
  tests/test_grok_auth.py
8
8
  tests/test_hardening.py
9
+ tests/test_price.py
9
10
  wechatbridge/__init__.py
10
11
  wechatbridge/__main__.py
11
12
  wechatbridge/agy.py