wechatbridge-cli 1.7.0__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.0 → wechatbridge_cli-1.7.2}/PKG-INFO +1 -1
  2. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/tests/test_hardening.py +256 -0
  3. wechatbridge_cli-1.7.2/tests/test_price.py +186 -0
  4. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/wechatbridge/__init__.py +1 -1
  5. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/wechatbridge/agy.py +115 -27
  6. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/wechatbridge/codex.py +28 -4
  7. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/wechatbridge/dsh.py +13 -70
  8. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/wechatbridge/grok.py +28 -3
  9. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/wechatbridge/ilink.py +40 -6
  10. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/wechatbridge/main.py +58 -18
  11. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/wechatbridge/price.py +137 -31
  12. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/wechatbridge/runner_common.py +258 -0
  13. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/wechatbridge_cli.egg-info/PKG-INFO +1 -1
  14. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/wechatbridge_cli.egg-info/SOURCES.txt +1 -0
  15. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/LICENSE +0 -0
  16. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/README.md +0 -0
  17. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/pyproject.toml +0 -0
  18. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/setup.cfg +0 -0
  19. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/tests/test_codex.py +0 -0
  20. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/tests/test_codex_auth.py +0 -0
  21. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/tests/test_dsh.py +0 -0
  22. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/tests/test_grok_auth.py +0 -0
  23. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/wechatbridge/__main__.py +0 -0
  24. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/wechatbridge/config.py +0 -0
  25. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/wechatbridge/update_check.py +0 -0
  26. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/wechatbridge_cli.egg-info/dependency_links.txt +0 -0
  27. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/wechatbridge_cli.egg-info/entry_points.txt +0 -0
  28. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/wechatbridge_cli.egg-info/requires.txt +0 -0
  29. {wechatbridge_cli-1.7.0 → 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.0
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
@@ -2983,5 +2983,261 @@ class TestAgyStreamRetry(unittest.IsolatedAsyncioTestCase):
2983
2983
  mock_sleep.assert_not_awaited()
2984
2984
 
2985
2985
 
2986
+ class TestAuditHardeningFixes(unittest.IsolatedAsyncioTestCase):
2987
+ """Regression tests for deep-audit security and correctness fixes."""
2988
+
2989
+ def setUp(self):
2990
+ from wechatbridge.config import config as cfg
2991
+ self._cfg = cfg
2992
+ self._tmp = tempfile.TemporaryDirectory()
2993
+ self.session_base = self._tmp.name
2994
+ self._orig_base = cfg.session_base_dir
2995
+ self._orig_backend = cfg.backend
2996
+ cfg.session_base_dir = self.session_base
2997
+
2998
+ def tearDown(self):
2999
+ self._cfg.session_base_dir = self._orig_base
3000
+ self._cfg.backend = self._orig_backend
3001
+ self._tmp.cleanup()
3002
+
3003
+ async def test_agy_send_artifacts_allows_session_dir_and_blocks_sensitive_or_hardlink(self):
3004
+ from wechatbridge import main as main_mod
3005
+ from wechatbridge.runner_common import ensure_session_dir
3006
+
3007
+ self._cfg.backend = "agy"
3008
+ uid = "u-agy-sec"
3009
+ sdir = ensure_session_dir(uid)
3010
+
3011
+ # Normal artifact directly in session_dir (should be allowed)
3012
+ good_file = os.path.join(sdir, "report.pdf")
3013
+ with open(good_file, "wb") as f:
3014
+ f.write(b"%PDF-1.4 ok")
3015
+
3016
+ # Normal artifact in .gemini/antigravity-cli/scratch (should be allowed)
3017
+ scratch_dir = os.path.join(sdir, ".gemini", "antigravity-cli", "scratch")
3018
+ os.makedirs(scratch_dir, exist_ok=True)
3019
+ scratch_file = os.path.join(scratch_dir, "chart.png")
3020
+ with open(scratch_file, "wb") as f:
3021
+ f.write(b"PNGDATA")
3022
+
3023
+ # Sensitive files inside session_dir (must be blocked)
3024
+ prefs_file = os.path.join(sdir, "prefs.json")
3025
+ with open(prefs_file, "w") as f:
3026
+ f.write('{"model": "gemini-4-argon"}')
3027
+ gemini_dir = os.path.join(sdir, ".gemini")
3028
+ os.makedirs(gemini_dir, exist_ok=True)
3029
+ token_file = os.path.join(gemini_dir, "antigravity-oauth-token")
3030
+ with open(token_file, "w") as f:
3031
+ f.write("secret-oauth-token")
3032
+
3033
+ # Hardlink inside session_dir pointing to external file (must be blocked)
3034
+ ext_file = os.path.join(self.session_base, "external_secret.txt")
3035
+ with open(ext_file, "w") as f:
3036
+ f.write("top-secret")
3037
+ hardlink_file = os.path.join(sdir, "linked.txt")
3038
+ os.link(ext_file, hardlink_file)
3039
+
3040
+ client = mock.MagicMock()
3041
+ client.state.baseurl = "https://ilink"
3042
+ client.state.bot_token = "tok"
3043
+ client.send_media = AsyncMock(return_value=True)
3044
+ client.send_message = AsyncMock(return_value=True)
3045
+
3046
+ await main_mod.send_artifacts_back(
3047
+ artifacts=[
3048
+ ("report.pdf", good_file),
3049
+ ("chart.png", scratch_file),
3050
+ ("prefs.json", prefs_file),
3051
+ ("antigravity-oauth-token", token_file),
3052
+ ("linked.txt", hardlink_file),
3053
+ ],
3054
+ client=client,
3055
+ from_user=uid,
3056
+ context_token="ctx",
3057
+ )
3058
+
3059
+ sent_paths = [c.kwargs["path"] for c in client.send_media.await_args_list]
3060
+ self.assertEqual(
3061
+ sent_paths,
3062
+ [os.path.realpath(good_file), os.path.realpath(scratch_file)],
3063
+ )
3064
+
3065
+ def test_agy_extract_artifacts_balanced_parens_and_model_tsv_parsing(self):
3066
+ from wechatbridge import agy as agy_mod
3067
+
3068
+ text = (
3069
+ "已生成 [report (1).pdf](file:///tmp/sess/report%20(1).pdf) "
3070
+ "和 [v2 [final].docx](file:///tmp/sess/v2%20[final].docx)。"
3071
+ )
3072
+ arts = agy_mod.extract_artifacts(text)
3073
+ self.assertEqual(
3074
+ arts,
3075
+ [
3076
+ ("report (1).pdf", "/tmp/sess/report (1).pdf"),
3077
+ ("v2 [final].docx", "/tmp/sess/v2 [final].docx"),
3078
+ ],
3079
+ )
3080
+
3081
+ tsv_models = (
3082
+ "gemini-4-argon\tGemini 3.1 Pro (High)\n"
3083
+ "claude-opus-4-6-thinking\tClaude Opus 4.6 (Thinking)\n"
3084
+ )
3085
+ self.assertEqual(
3086
+ agy_mod._parse_agy_model_ids(tsv_models),
3087
+ ["gemini-4-argon", "claude-opus-4-6-thinking"],
3088
+ )
3089
+
3090
+ async def test_planning_and_fast_toggle_and_clear_resets_mode(self):
3091
+ from wechatbridge import agy as agy_mod
3092
+ from wechatbridge.runner_common import load_prefs
3093
+
3094
+ uid = "u-toggle"
3095
+ r1 = await agy_mod.handle_slash_command("/planning", uid)
3096
+ self.assertIn("已开启规划模式", r1)
3097
+ self.assertEqual(load_prefs(uid).get("mode"), "plan")
3098
+
3099
+ # Calling /planning again toggles it off
3100
+ r2 = await agy_mod.handle_slash_command("/planning", uid)
3101
+ self.assertIn("已关闭规划模式", r2)
3102
+ self.assertEqual(load_prefs(uid).get("mode"), "")
3103
+
3104
+ # Enable again and verify /clear resets sticky mode
3105
+ await agy_mod.handle_slash_command("/planning", uid)
3106
+ self.assertEqual(load_prefs(uid).get("mode"), "plan")
3107
+ await agy_mod.handle_slash_command("/clear", uid)
3108
+ self.assertEqual(load_prefs(uid).get("mode"), "")
3109
+
3110
+ # /fast off
3111
+ await agy_mod.handle_slash_command("/fast", uid)
3112
+ self.assertEqual(load_prefs(uid).get("effort"), "low")
3113
+ r_fast_off = await agy_mod.handle_slash_command("/fast off", uid)
3114
+ self.assertIn("已关闭快速模式", r_fast_off)
3115
+ self.assertEqual(load_prefs(uid).get("effort"), "")
3116
+
3117
+ def test_sanitize_prompt_at_paths_allows_session_and_add_dirs_blocks_external(self):
3118
+ from wechatbridge.runner_common import _sanitize_prompt_at_paths, ensure_session_dir
3119
+
3120
+ sdir = ensure_session_dir("u-at")
3121
+ extra_dir = os.path.join(self.session_base, "allowed_extra")
3122
+ os.makedirs(extra_dir, exist_ok=True)
3123
+ prompt = (
3124
+ f"请看 @{sdir}/img.png 和 @{extra_dir}/doc.md "
3125
+ "不要读 @/etc/passwd 或 @../../etc/shadow"
3126
+ )
3127
+ sanitized = _sanitize_prompt_at_paths(prompt, sdir, extra_roots=[extra_dir])
3128
+ self.assertIn(f"@{sdir}/img.png", sanitized)
3129
+ self.assertIn(f"@{extra_dir}/doc.md", sanitized)
3130
+ self.assertNotIn("/etc/passwd", sanitized)
3131
+ self.assertNotIn("/etc/shadow", sanitized)
3132
+ self.assertEqual(sanitized.count("[blocked-path]"), 2)
3133
+
3134
+ async def test_ilink_get_updates_raises_on_5xx_and_encrypt_roundtrip(self):
3135
+ import httpx
3136
+ from wechatbridge.ilink import ILinkClient, _encrypt_aes_ecb, _decrypt_aes_ecb
3137
+
3138
+ key = os.urandom(16)
3139
+ for length in (0, 1, 15, 16, 17, 32, 100):
3140
+ raw = os.urandom(length)
3141
+ enc = _encrypt_aes_ecb(raw, key)
3142
+ self.assertEqual(_decrypt_aes_ecb(enc, key), raw)
3143
+
3144
+ client = ILinkClient()
3145
+ try:
3146
+ req = httpx.Request("POST", "https://ilink/ilink/bot/getupdates")
3147
+ resp_502 = httpx.Response(502, request=req)
3148
+ client.http_client.post = AsyncMock(
3149
+ side_effect=httpx.HTTPStatusError("502 Bad Gateway", request=req, response=resp_502)
3150
+ )
3151
+ client.state.clear = mock.MagicMock()
3152
+ with self.assertRaises(httpx.HTTPStatusError):
3153
+ await client.get_updates("buf1", "https://ilink", "tok")
3154
+ client.state.clear.assert_not_called()
3155
+ finally:
3156
+ await client.close()
3157
+
3158
+ async def test_price_http_error_does_not_leak_url_or_appkey(self):
3159
+ import httpx
3160
+ from wechatbridge import price as price_mod
3161
+
3162
+ req = httpx.Request(
3163
+ "GET",
3164
+ "https://openapi.dataoke.com/api/goods/get-goods-details?appKey=SECRET_DT_KEY_123&id=1",
3165
+ )
3166
+ err = httpx.ConnectError(
3167
+ "Connection failed for https://openapi.dataoke.com/api?appKey=SECRET_DT_KEY_123",
3168
+ request=req,
3169
+ )
3170
+ with mock.patch.object(price_mod, "dt_configured", return_value=True), \
3171
+ mock.patch.object(price_mod, "query_tb_item", side_effect=err):
3172
+ reply = await price_mod.handle_price_query({"kind": "tb_item", "item_id": "1"})
3173
+ self.assertNotIn("SECRET_DT_KEY_123", reply)
3174
+ self.assertIn("比价接口请求失败", reply)
3175
+
3176
+ with self.assertRaises(ValueError):
3177
+ price_mod._resolve_short_link("http://127.0.0.1:8080/admin")
3178
+ self.assertFalse(price_mod._is_allowed_short_url("https://evil.u.jd.com/abc"))
3179
+ self.assertFalse(price_mod._is_allowed_short_url("https://a.3.cn/xyz"))
3180
+ self.assertTrue(price_mod._is_allowed_short_url("https://u.jd.com/abc"))
3181
+
3182
+ def test_ensure_user_gemini_refreshes_stale_oauth_token_and_blocks_copied_secrets(self):
3183
+ from wechatbridge import agy as agy_mod
3184
+ from wechatbridge.runner_common import is_sensitive_artifact_path, _sanitize_prompt_at_paths
3185
+
3186
+ fake_host_home = os.path.join(self.session_base, "host_home")
3187
+ host_agy = os.path.join(fake_host_home, ".gemini", "antigravity-cli")
3188
+ os.makedirs(host_agy, exist_ok=True)
3189
+ src_token = os.path.join(host_agy, "antigravity-oauth-token")
3190
+ with open(src_token, "w") as f:
3191
+ f.write("old-token-value-111111")
3192
+
3193
+ def _fake_expanduser(p: str) -> str:
3194
+ if p.startswith("~/"):
3195
+ return os.path.join(fake_host_home, p[2:])
3196
+ return p
3197
+
3198
+ with mock.patch("os.path.expanduser", side_effect=_fake_expanduser):
3199
+ sdir = agy_mod.ensure_user_gemini("u-tok-refresh")
3200
+ dst_token = os.path.join(sdir, ".gemini", "antigravity-cli", "antigravity-oauth-token")
3201
+ with open(dst_token) as f:
3202
+ self.assertEqual(f.read(), "old-token-value-111111")
3203
+
3204
+ # Make dst older and update src token
3205
+ os.utime(dst_token, (1000.0, 1000.0))
3206
+ with open(src_token, "w") as f:
3207
+ f.write("refreshed-token-value-222222")
3208
+ os.utime(src_token, (2000.0, 2000.0))
3209
+
3210
+ agy_mod.ensure_user_gemini("u-tok-refresh")
3211
+ with open(dst_token) as f:
3212
+ self.assertEqual(f.read(), "refreshed-token-value-222222")
3213
+
3214
+ # Renamed / copied credentials must still be blocked by is_sensitive_artifact_path
3215
+ copied_secret = os.path.join(sdir, "token.copy")
3216
+ with open(copied_secret, "w") as f:
3217
+ f.write("refreshed-token-value-222222")
3218
+ self.assertTrue(is_sensitive_artifact_path(copied_secret, sdir))
3219
+
3220
+ auth_bak = os.path.join(sdir, "auth.json.bak")
3221
+ with open(auth_bak, "w") as f:
3222
+ f.write("{}")
3223
+ self.assertTrue(is_sensitive_artifact_path(auth_bak, sdir))
3224
+
3225
+ scratch_settings = os.path.join(sdir, ".gemini", "antigravity-cli", "scratch", "settings.json")
3226
+ os.makedirs(os.path.dirname(scratch_settings), exist_ok=True)
3227
+ with open(scratch_settings, "w") as f:
3228
+ f.write("{}")
3229
+ self.assertTrue(is_sensitive_artifact_path(scratch_settings, sdir))
3230
+
3231
+ # @<path> sanitization for bare sensitive names, URL-encoded traversal, and URLs
3232
+ p_out = _sanitize_prompt_at_paths(
3233
+ "read @prefs.json @auth.json.bak @..%2f..%2fetc%2fpasswd @https://evil.com/a",
3234
+ sdir,
3235
+ )
3236
+ self.assertEqual(
3237
+ p_out,
3238
+ "read [blocked-path] [blocked-path] [blocked-path] [blocked-path]",
3239
+ )
3240
+
3241
+
2986
3242
  if __name__ == "__main__":
2987
3243
  unittest.main()
@@ -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.0"
2
+ __version__ = "1.7.2"