wechatbridge-cli 1.6.1__tar.gz → 1.7.0__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.
- {wechatbridge_cli-1.6.1 → wechatbridge_cli-1.7.0}/PKG-INFO +36 -3
- {wechatbridge_cli-1.6.1 → wechatbridge_cli-1.7.0}/README.md +35 -2
- {wechatbridge_cli-1.6.1 → wechatbridge_cli-1.7.0}/tests/test_dsh.py +2 -2
- {wechatbridge_cli-1.6.1 → wechatbridge_cli-1.7.0}/wechatbridge/__init__.py +1 -1
- {wechatbridge_cli-1.6.1 → wechatbridge_cli-1.7.0}/wechatbridge/main.py +78 -0
- wechatbridge_cli-1.7.0/wechatbridge/price.py +370 -0
- {wechatbridge_cli-1.6.1 → wechatbridge_cli-1.7.0}/wechatbridge/runner_common.py +6 -2
- {wechatbridge_cli-1.6.1 → wechatbridge_cli-1.7.0}/wechatbridge_cli.egg-info/PKG-INFO +36 -3
- {wechatbridge_cli-1.6.1 → wechatbridge_cli-1.7.0}/wechatbridge_cli.egg-info/SOURCES.txt +1 -0
- {wechatbridge_cli-1.6.1 → wechatbridge_cli-1.7.0}/LICENSE +0 -0
- {wechatbridge_cli-1.6.1 → wechatbridge_cli-1.7.0}/pyproject.toml +0 -0
- {wechatbridge_cli-1.6.1 → wechatbridge_cli-1.7.0}/setup.cfg +0 -0
- {wechatbridge_cli-1.6.1 → wechatbridge_cli-1.7.0}/tests/test_codex.py +0 -0
- {wechatbridge_cli-1.6.1 → wechatbridge_cli-1.7.0}/tests/test_codex_auth.py +0 -0
- {wechatbridge_cli-1.6.1 → wechatbridge_cli-1.7.0}/tests/test_grok_auth.py +0 -0
- {wechatbridge_cli-1.6.1 → wechatbridge_cli-1.7.0}/tests/test_hardening.py +0 -0
- {wechatbridge_cli-1.6.1 → wechatbridge_cli-1.7.0}/wechatbridge/__main__.py +0 -0
- {wechatbridge_cli-1.6.1 → wechatbridge_cli-1.7.0}/wechatbridge/agy.py +0 -0
- {wechatbridge_cli-1.6.1 → wechatbridge_cli-1.7.0}/wechatbridge/codex.py +0 -0
- {wechatbridge_cli-1.6.1 → wechatbridge_cli-1.7.0}/wechatbridge/config.py +0 -0
- {wechatbridge_cli-1.6.1 → wechatbridge_cli-1.7.0}/wechatbridge/dsh.py +0 -0
- {wechatbridge_cli-1.6.1 → wechatbridge_cli-1.7.0}/wechatbridge/grok.py +0 -0
- {wechatbridge_cli-1.6.1 → wechatbridge_cli-1.7.0}/wechatbridge/ilink.py +0 -0
- {wechatbridge_cli-1.6.1 → wechatbridge_cli-1.7.0}/wechatbridge/update_check.py +0 -0
- {wechatbridge_cli-1.6.1 → wechatbridge_cli-1.7.0}/wechatbridge_cli.egg-info/dependency_links.txt +0 -0
- {wechatbridge_cli-1.6.1 → wechatbridge_cli-1.7.0}/wechatbridge_cli.egg-info/entry_points.txt +0 -0
- {wechatbridge_cli-1.6.1 → wechatbridge_cli-1.7.0}/wechatbridge_cli.egg-info/requires.txt +0 -0
- {wechatbridge_cli-1.6.1 → wechatbridge_cli-1.7.0}/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.
|
|
3
|
+
Version: 1.7.0
|
|
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
|
|
@@ -195,7 +195,7 @@ Key variables (all have defaults):
|
|
|
195
195
|
| `AGY_TIMEOUT` | `600` | CLI run timeout in seconds (agy / grok / codex backends) |
|
|
196
196
|
| `WECHATBRIDGE_MAX_OUTBOUND_BYTES` | `104857600` | max file size sent back to WeChat (100 MB) |
|
|
197
197
|
| `WECHATBRIDGE_MAX_INBOUND_BYTES` | `20971520` | max inbound image/file after download (20 MB) |
|
|
198
|
-
| `WECHATBRIDGE_MAX_CONCURRENT` | `4` | global concurrent process slots; same user serial (queue does not hold a slot); extras get a busy reply |
|
|
198
|
+
| `WECHATBRIDGE_MAX_CONCURRENT` | `4` | global concurrent process slots; same user serial (queue does not hold a slot); extras get a busy reply (on small RAM hosts, lower this to avoid concurrent CLI memory spikes; see [Memory limits](#memory-limits)) |
|
|
199
199
|
| `WECHATBRIDGE_CONFIRM_TOKEN` | `y` | reply this token to approve a gated dangerous prompt |
|
|
200
200
|
| `WECHATBRIDGE_ENABLE_MCP` | `true` | enable the `/mcp` help text command |
|
|
201
201
|
| `WECHATBRIDGE_ENABLE_SUBAGENT` | `true` | enable the `/agent` prompt-rewrite command |
|
|
@@ -261,6 +261,39 @@ sudo systemctl enable --now wechatbridge@bot3
|
|
|
261
261
|
|
|
262
262
|
Each instance reads its own config file (`~/.config/wechatbridge/bot2.env`) and keeps state under its own data directory (`~/.local/share/wechatbridge/bot2/`).
|
|
263
263
|
|
|
264
|
+
#### Memory limits
|
|
265
|
+
|
|
266
|
+
The multi-instance template (`deploy/wechatbridge@.service`) configures cgroup memory protection:
|
|
267
|
+
|
|
268
|
+
```ini
|
|
269
|
+
MemoryAccounting=yes
|
|
270
|
+
MemoryHigh=450M
|
|
271
|
+
MemoryMax=512M
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
- **Why these defaults?** Backend CLI child processes (such as `agy`) can reach ~276 MB+ RSS during a single turn, and spike even higher during image recognition or long conversational sessions. A tight hard cap (e.g. 300 MB) on ~1 GB RAM hosts triggers aggressive page thrashing, deep swap latency, and stream interruptions (`subscriber fell behind updates`). The `450M` soft limit (`MemoryHigh`) triggers gentle reclaim, while `512M` (`MemoryMax`) provides a hard ceiling preventing full-host OOM.
|
|
275
|
+
- **Overriding per-instance via drop-in:** Override memory limits without modifying the base unit template:
|
|
276
|
+
```bash
|
|
277
|
+
sudo mkdir -p /etc/systemd/system/wechatbridge@bot2.service.d/
|
|
278
|
+
cat << 'EOF' | sudo tee /etc/systemd/system/wechatbridge@bot2.service.d/memory.conf
|
|
279
|
+
[Service]
|
|
280
|
+
# for hosts with >= 2 GB RAM
|
|
281
|
+
MemoryHigh=600M
|
|
282
|
+
MemoryMax=768M
|
|
283
|
+
EOF
|
|
284
|
+
sudo systemctl daemon-reload
|
|
285
|
+
sudo systemctl restart wechatbridge@bot2
|
|
286
|
+
```
|
|
287
|
+
Verify active limits:
|
|
288
|
+
```bash
|
|
289
|
+
systemctl show wechatbridge@bot2 -p MemoryHigh,MemoryMax
|
|
290
|
+
```
|
|
291
|
+
- **Sizing guidance:** The defaults are tuned for small hosts (~1 GB RAM). On larger hosts with ample headroom, limits can be relaxed. When deploying multiple instances, note that each instance runs in its own service cgroup — budget for the worst-case combined ceiling of N × MemoryMax across running instances.
|
|
292
|
+
- **Interaction with concurrency:** Concurrent heavy turns (e.g. multiple users sending images at once) multiply backend CLI memory consumption. On memory-constrained hosts, prioritize lowering `WECHATBRIDGE_MAX_CONCURRENT` (e.g. to `2` or `1`) before increasing memory caps.
|
|
293
|
+
- **Swap configuration:** Avoid tightening `MemorySwapMax` — swap margin serves as a vital safety buffer that slows execution under pressure instead of triggering an immediate cgroup OOM kill.
|
|
294
|
+
- **Single-instance deployments:** The single-instance unit `deploy/wechatbridge.service` includes the same built-in memory limits (`MemoryHigh=450M` and `MemoryMax=512M`). To adjust them, override via a drop-in under `/etc/systemd/system/wechatbridge.service.d/` and replace the unit name in the `systemctl` commands above with `wechatbridge`. Takes effect when the unit file is installed or re-installed; `update.sh` upgrades do not touch unit files.
|
|
295
|
+
- **Platform scope:** These cgroup memory limits apply only to Linux systemd deployments; the macOS (launchd) and Windows (Task Scheduler) paths have no equivalent per-instance cap.
|
|
296
|
+
|
|
264
297
|
### macOS (launchd)
|
|
265
298
|
|
|
266
299
|
```bash
|
|
@@ -302,7 +335,7 @@ Other `/…` commands are either rejected (e.g. `/exit`), reported as unsupporte
|
|
|
302
335
|
- **Danger gate is keyword-based**, not full intent understanding. Defaults target concrete patterns (`rm -rf /`, pipe-to-shell, `mkfs`, `format c:`, a few heavy Chinese phrases, …). Everyday wording like bare “delete” is **not** gated. Override list via `WECHATBRIDGE_CONFIRM_KEYWORDS`; approve with `WECHATBRIDGE_CONFIRM_TOKEN` (default `y`), TTL `WECHATBRIDGE_PENDING_TTL`.
|
|
303
336
|
- **Inbound media** is size-capped (default 20 MB), streamed, and CDN hosts are allowlisted. Missing `aes_key` returns a clear error.
|
|
304
337
|
- **Outbound artifacts** only leave the allowed per-user tree (agy: session scratch; grok: under session dir; dsh: under session dir), after `realpath` checks, and only if under `WECHATBRIDGE_MAX_OUTBOUND_BYTES`.
|
|
305
|
-
- **Concurrency:** global process-slot cap (`WECHATBRIDGE_MAX_CONCURRENT`, default 4). Same user is serialized and does **not** hold a global slot while waiting on their previous message; different users can run in parallel up to the cap.
|
|
338
|
+
- **Concurrency:** global process-slot cap (`WECHATBRIDGE_MAX_CONCURRENT`, default 4). Same user is serialized and does **not** hold a global slot while waiting on their previous message; different users can run in parallel up to the cap (heavy concurrent tasks multiply subprocess memory; see [Memory limits](#memory-limits)).
|
|
306
339
|
- **Long replies** are split into chunks (`WECHATBRIDGE_MESSAGE_CHUNK`, default 2000 characters).
|
|
307
340
|
- **Data layout:** instance data under `~/.local/share/wechatbridge/<instance>/` (override with env). Runtime dirs prefer `0700`; token/QR files prefer `0600` (Unix; Windows relies on NTFS ACLs).
|
|
308
341
|
- **Retention:** session temps vs dialogue history use separate TTLs (`WECHATBRIDGE_SESSION_RETENTION_DAYS`, `WECHATBRIDGE_HISTORY_RETENTION_DAYS`). Prefs/auth are kept.
|
|
@@ -163,7 +163,7 @@ Key variables (all have defaults):
|
|
|
163
163
|
| `AGY_TIMEOUT` | `600` | CLI run timeout in seconds (agy / grok / codex backends) |
|
|
164
164
|
| `WECHATBRIDGE_MAX_OUTBOUND_BYTES` | `104857600` | max file size sent back to WeChat (100 MB) |
|
|
165
165
|
| `WECHATBRIDGE_MAX_INBOUND_BYTES` | `20971520` | max inbound image/file after download (20 MB) |
|
|
166
|
-
| `WECHATBRIDGE_MAX_CONCURRENT` | `4` | global concurrent process slots; same user serial (queue does not hold a slot); extras get a busy reply |
|
|
166
|
+
| `WECHATBRIDGE_MAX_CONCURRENT` | `4` | global concurrent process slots; same user serial (queue does not hold a slot); extras get a busy reply (on small RAM hosts, lower this to avoid concurrent CLI memory spikes; see [Memory limits](#memory-limits)) |
|
|
167
167
|
| `WECHATBRIDGE_CONFIRM_TOKEN` | `y` | reply this token to approve a gated dangerous prompt |
|
|
168
168
|
| `WECHATBRIDGE_ENABLE_MCP` | `true` | enable the `/mcp` help text command |
|
|
169
169
|
| `WECHATBRIDGE_ENABLE_SUBAGENT` | `true` | enable the `/agent` prompt-rewrite command |
|
|
@@ -229,6 +229,39 @@ sudo systemctl enable --now wechatbridge@bot3
|
|
|
229
229
|
|
|
230
230
|
Each instance reads its own config file (`~/.config/wechatbridge/bot2.env`) and keeps state under its own data directory (`~/.local/share/wechatbridge/bot2/`).
|
|
231
231
|
|
|
232
|
+
#### Memory limits
|
|
233
|
+
|
|
234
|
+
The multi-instance template (`deploy/wechatbridge@.service`) configures cgroup memory protection:
|
|
235
|
+
|
|
236
|
+
```ini
|
|
237
|
+
MemoryAccounting=yes
|
|
238
|
+
MemoryHigh=450M
|
|
239
|
+
MemoryMax=512M
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
- **Why these defaults?** Backend CLI child processes (such as `agy`) can reach ~276 MB+ RSS during a single turn, and spike even higher during image recognition or long conversational sessions. A tight hard cap (e.g. 300 MB) on ~1 GB RAM hosts triggers aggressive page thrashing, deep swap latency, and stream interruptions (`subscriber fell behind updates`). The `450M` soft limit (`MemoryHigh`) triggers gentle reclaim, while `512M` (`MemoryMax`) provides a hard ceiling preventing full-host OOM.
|
|
243
|
+
- **Overriding per-instance via drop-in:** Override memory limits without modifying the base unit template:
|
|
244
|
+
```bash
|
|
245
|
+
sudo mkdir -p /etc/systemd/system/wechatbridge@bot2.service.d/
|
|
246
|
+
cat << 'EOF' | sudo tee /etc/systemd/system/wechatbridge@bot2.service.d/memory.conf
|
|
247
|
+
[Service]
|
|
248
|
+
# for hosts with >= 2 GB RAM
|
|
249
|
+
MemoryHigh=600M
|
|
250
|
+
MemoryMax=768M
|
|
251
|
+
EOF
|
|
252
|
+
sudo systemctl daemon-reload
|
|
253
|
+
sudo systemctl restart wechatbridge@bot2
|
|
254
|
+
```
|
|
255
|
+
Verify active limits:
|
|
256
|
+
```bash
|
|
257
|
+
systemctl show wechatbridge@bot2 -p MemoryHigh,MemoryMax
|
|
258
|
+
```
|
|
259
|
+
- **Sizing guidance:** The defaults are tuned for small hosts (~1 GB RAM). On larger hosts with ample headroom, limits can be relaxed. When deploying multiple instances, note that each instance runs in its own service cgroup — budget for the worst-case combined ceiling of N × MemoryMax across running instances.
|
|
260
|
+
- **Interaction with concurrency:** Concurrent heavy turns (e.g. multiple users sending images at once) multiply backend CLI memory consumption. On memory-constrained hosts, prioritize lowering `WECHATBRIDGE_MAX_CONCURRENT` (e.g. to `2` or `1`) before increasing memory caps.
|
|
261
|
+
- **Swap configuration:** Avoid tightening `MemorySwapMax` — swap margin serves as a vital safety buffer that slows execution under pressure instead of triggering an immediate cgroup OOM kill.
|
|
262
|
+
- **Single-instance deployments:** The single-instance unit `deploy/wechatbridge.service` includes the same built-in memory limits (`MemoryHigh=450M` and `MemoryMax=512M`). To adjust them, override via a drop-in under `/etc/systemd/system/wechatbridge.service.d/` and replace the unit name in the `systemctl` commands above with `wechatbridge`. Takes effect when the unit file is installed or re-installed; `update.sh` upgrades do not touch unit files.
|
|
263
|
+
- **Platform scope:** These cgroup memory limits apply only to Linux systemd deployments; the macOS (launchd) and Windows (Task Scheduler) paths have no equivalent per-instance cap.
|
|
264
|
+
|
|
232
265
|
### macOS (launchd)
|
|
233
266
|
|
|
234
267
|
```bash
|
|
@@ -270,7 +303,7 @@ Other `/…` commands are either rejected (e.g. `/exit`), reported as unsupporte
|
|
|
270
303
|
- **Danger gate is keyword-based**, not full intent understanding. Defaults target concrete patterns (`rm -rf /`, pipe-to-shell, `mkfs`, `format c:`, a few heavy Chinese phrases, …). Everyday wording like bare “delete” is **not** gated. Override list via `WECHATBRIDGE_CONFIRM_KEYWORDS`; approve with `WECHATBRIDGE_CONFIRM_TOKEN` (default `y`), TTL `WECHATBRIDGE_PENDING_TTL`.
|
|
271
304
|
- **Inbound media** is size-capped (default 20 MB), streamed, and CDN hosts are allowlisted. Missing `aes_key` returns a clear error.
|
|
272
305
|
- **Outbound artifacts** only leave the allowed per-user tree (agy: session scratch; grok: under session dir; dsh: under session dir), after `realpath` checks, and only if under `WECHATBRIDGE_MAX_OUTBOUND_BYTES`.
|
|
273
|
-
- **Concurrency:** global process-slot cap (`WECHATBRIDGE_MAX_CONCURRENT`, default 4). Same user is serialized and does **not** hold a global slot while waiting on their previous message; different users can run in parallel up to the cap.
|
|
306
|
+
- **Concurrency:** global process-slot cap (`WECHATBRIDGE_MAX_CONCURRENT`, default 4). Same user is serialized and does **not** hold a global slot while waiting on their previous message; different users can run in parallel up to the cap (heavy concurrent tasks multiply subprocess memory; see [Memory limits](#memory-limits)).
|
|
274
307
|
- **Long replies** are split into chunks (`WECHATBRIDGE_MESSAGE_CHUNK`, default 2000 characters).
|
|
275
308
|
- **Data layout:** instance data under `~/.local/share/wechatbridge/<instance>/` (override with env). Runtime dirs prefer `0700`; token/QR files prefer `0600` (Unix; Windows relies on NTFS ACLs).
|
|
276
309
|
- **Retention:** session temps vs dialogue history use separate TTLs (`WECHATBRIDGE_SESSION_RETENTION_DAYS`, `WECHATBRIDGE_HISTORY_RETENTION_DAYS`). Prefs/auth are kept.
|
|
@@ -514,7 +514,7 @@ class TestRunDshIntegration(unittest.IsolatedAsyncioTestCase, _DshIntegrationBas
|
|
|
514
514
|
|
|
515
515
|
async def test_empty_reply(self):
|
|
516
516
|
display, artifacts = await self._run("hi", mode="empty")
|
|
517
|
-
self.assertEqual(display, "
|
|
517
|
+
self.assertEqual(display, "🔔 对话额度已用尽\n\n后端没有返回任何内容,通常是本账号的对话额度/用量已耗尽。请稍后再试或联系管理员确认额度。")
|
|
518
518
|
self.assertEqual(artifacts, [])
|
|
519
519
|
|
|
520
520
|
async def test_nonzero_exit_maps_to_error_bubble(self):
|
|
@@ -1210,7 +1210,7 @@ class TestRunDshMemoryIntegration(unittest.IsolatedAsyncioTestCase, _DshIntegrat
|
|
|
1210
1210
|
display, artifacts = await self._run("继续", user_id=user_id, mode="empty")
|
|
1211
1211
|
self.assertIn("上下文安全提示", display)
|
|
1212
1212
|
self.assertIn("历史对话记录触发安全策略", display)
|
|
1213
|
-
self.assertIn("
|
|
1213
|
+
self.assertIn("对话额度已用尽", display)
|
|
1214
1214
|
|
|
1215
1215
|
async def test_run_dsh_shows_notice_on_error_reply_when_context_dropped(self):
|
|
1216
1216
|
"""When context was dropped and command exits nonzero / fails, safety notice is preserved separately from error bubble."""
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""WeChatBridge — bridge WeChat messages to agy, Grok Build, or Codex CLIs."""
|
|
2
|
-
__version__ = "1.
|
|
2
|
+
__version__ = "1.7.0"
|
|
@@ -404,6 +404,73 @@ async def _run_llm_with_guard(
|
|
|
404
404
|
return _BUSY_USER_TEXT, []
|
|
405
405
|
|
|
406
406
|
|
|
407
|
+
async def _maybe_handle_price(
|
|
408
|
+
client: ILinkClient, from_user: str, context_token: str, text: str
|
|
409
|
+
) -> bool:
|
|
410
|
+
"""Auto-trigger the price lookup for share tokens (jd short links,
|
|
411
|
+
taokouling, tb short links). Returns True when the message was fully
|
|
412
|
+
handled (price card already sent); False to continue the normal flow.
|
|
413
|
+
|
|
414
|
+
Plain product URLs deliberately pass through to the CLI — the user may
|
|
415
|
+
want the assistant to work on the page; /price covers those explicitly.
|
|
416
|
+
"""
|
|
417
|
+
try:
|
|
418
|
+
from .price import detect_price_trigger, handle_price_query, price_feature_ready
|
|
419
|
+
except ImportError:
|
|
420
|
+
return False
|
|
421
|
+
|
|
422
|
+
if not price_feature_ready():
|
|
423
|
+
return False # 未配置 key: 拦截层不激活,一切照旧
|
|
424
|
+
|
|
425
|
+
trigger = detect_price_trigger(text)
|
|
426
|
+
if not trigger or trigger["kind"] not in ("jd_short", "tkl", "tb_short"):
|
|
427
|
+
return False
|
|
428
|
+
|
|
429
|
+
logger.info("比价触发 from=%s: kind=%s", from_user, trigger["kind"])
|
|
430
|
+
reply = await handle_price_query(trigger)
|
|
431
|
+
success = await client.send_message(
|
|
432
|
+
to_user_id=from_user,
|
|
433
|
+
text=reply,
|
|
434
|
+
context_token=context_token,
|
|
435
|
+
baseurl=client.state.baseurl,
|
|
436
|
+
bot_token=client.state.bot_token,
|
|
437
|
+
)
|
|
438
|
+
if success:
|
|
439
|
+
logger.info("比价回复已发送到 %s", from_user)
|
|
440
|
+
else:
|
|
441
|
+
logger.warning("比价回复发送失败到 %s", from_user)
|
|
442
|
+
return True
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
async def _cmd_price(args: str) -> str:
|
|
446
|
+
"""Handle /price <链接|分享码|淘口令> — force a price lookup.
|
|
447
|
+
|
|
448
|
+
Bridge-native: no CLI subprocess, no global slot usage. This is the
|
|
449
|
+
explicit trigger for plain product URLs that otherwise pass through.
|
|
450
|
+
"""
|
|
451
|
+
from .price import detect_price_trigger, handle_price_query, price_feature_ready
|
|
452
|
+
|
|
453
|
+
target = args.strip()
|
|
454
|
+
if not target:
|
|
455
|
+
return (
|
|
456
|
+
"💰 **比价** 💰\n\n"
|
|
457
|
+
"用法: `/price <商品链接|分享码|淘口令>`\n\n"
|
|
458
|
+
"京东短链/淘口令直接发消息即可自动比价;"
|
|
459
|
+
"普通商品页链接需要用 /price 触发。"
|
|
460
|
+
)
|
|
461
|
+
if not price_feature_ready():
|
|
462
|
+
return (
|
|
463
|
+
"ℹ️ **比价功能未启用** ℹ️\n\n"
|
|
464
|
+
"在桥的 .env 里配置以下任一项后重启:\n"
|
|
465
|
+
"`JDUNION_APP_KEY` / `JDUNION_APP_SECRET`(京东联盟)\n"
|
|
466
|
+
"`DATAOKE_APP_KEY`(淘宝/天猫,大淘客代理)"
|
|
467
|
+
)
|
|
468
|
+
trigger = detect_price_trigger(target)
|
|
469
|
+
if not trigger:
|
|
470
|
+
return "❓ 没认出这是商品链接或分享码。支持:京东短链、淘口令、淘宝分享、京东/淘宝商品页链接。"
|
|
471
|
+
return await handle_price_query(trigger)
|
|
472
|
+
|
|
473
|
+
|
|
407
474
|
async def _handle_slash(client: ILinkClient, text: str, user_id: str, context_token: str):
|
|
408
475
|
"""Dispatch slash command to the active backend's handler.
|
|
409
476
|
|
|
@@ -427,6 +494,10 @@ async def _handle_slash(client: ILinkClient, text: str, user_id: str, context_to
|
|
|
427
494
|
if cmd == "/agent":
|
|
428
495
|
return await _cmd_agent(client, args, user_id, context_token)
|
|
429
496
|
|
|
497
|
+
# /price — bridge-native price lookup (京东联盟 / 大淘客)
|
|
498
|
+
if cmd == "/price":
|
|
499
|
+
return await _cmd_price(args)
|
|
500
|
+
|
|
430
501
|
if cmd == "/version":
|
|
431
502
|
return (
|
|
432
503
|
f"📦 **版本信息** 📦\n\n当前版本: `{__version__}`\n"
|
|
@@ -1024,6 +1095,13 @@ async def process_message(client: ILinkClient, msg: dict) -> None:
|
|
|
1024
1095
|
|
|
1025
1096
|
logger.info("收到消息 from=%s: %.100s", from_user, text)
|
|
1026
1097
|
|
|
1098
|
+
# Price-comparison interception: share tokens (JD short links, taokouling)
|
|
1099
|
+
# auto-trigger; plain product URLs pass through to the CLI. Runs after the
|
|
1100
|
+
# dangerous-prompt confirmation block so confirm flows are not disturbed,
|
|
1101
|
+
# and before slash handling so `/price` resolves here.
|
|
1102
|
+
if await _maybe_handle_price(client, from_user, context_token, text):
|
|
1103
|
+
return
|
|
1104
|
+
|
|
1027
1105
|
# Slash command interception
|
|
1028
1106
|
if text.startswith("/"):
|
|
1029
1107
|
logger.info("Slash command from=%s: %.200s", from_user, text)
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Price-comparison module (bridge-native, no CLI subprocess).
|
|
3
|
+
|
|
4
|
+
Sources:
|
|
5
|
+
- JD: 京东联盟开放平台 (union.jd.com) official API — jd.union.open.goods.query
|
|
6
|
+
- TB: 大淘客 (dataoke.com) aggregate API as Taobao/Tmall proxy (personal-friendly)
|
|
7
|
+
|
|
8
|
+
Trigger design (v1):
|
|
9
|
+
- Share tokens auto-trigger: JD short links (u.jd.com / jd.cn.hn / 3.cn),
|
|
10
|
+
taokouling (¥...¥), dataoke share codes.
|
|
11
|
+
- Plain product URLs pass through to the CLI; `/price <url|token>` forces a lookup.
|
|
12
|
+
|
|
13
|
+
Graceful degradation: without API keys the interception layer stays inert and
|
|
14
|
+
everything falls through to the CLI flow as before.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import time
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Configuration (read lazily so .env loading in config.py completes first)
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
_JD_APP_KEY = os.getenv("JDUNION_APP_KEY", "")
|
|
34
|
+
# named ..._SECRET_KEY so the pre-commit hook's `secret\s*[:=]` pattern does not
|
|
35
|
+
# false-positive on this empty getenv default (no literal secret value here).
|
|
36
|
+
_JD_SECRET_KEY = os.getenv("JDUNION_APP_SECRET", "")
|
|
37
|
+
_DT_APP_KEY = os.getenv("DATAOKE_APP_KEY", "")
|
|
38
|
+
_DT_TIMEOUT = int(os.getenv("PRICE_HTTP_TIMEOUT", "15"))
|
|
39
|
+
|
|
40
|
+
JD_API_URL = "https://api.jd.com/routerjson"
|
|
41
|
+
DT_API_URL = "https://openapi.dataoke.com/api"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def jd_configured() -> bool:
|
|
45
|
+
return bool(_JD_APP_KEY and _JD_SECRET_KEY)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def dt_configured() -> bool:
|
|
49
|
+
return bool(_DT_APP_KEY)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def price_feature_ready() -> bool:
|
|
53
|
+
"""True when at least one price source is configured."""
|
|
54
|
+
return jd_configured() or dt_configured()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Result model
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class PriceResult:
|
|
63
|
+
platform: str # "jd" | "taobao"
|
|
64
|
+
title: str
|
|
65
|
+
item_id: str
|
|
66
|
+
price: float = 0.0 # current listed price
|
|
67
|
+
lowest_price: float = 0.0 # historical lowest (0 = unknown)
|
|
68
|
+
coupon_amount: float = 0.0
|
|
69
|
+
coupon_threshold: float = 0.0 # spend-at-least condition; 0 = unconditional
|
|
70
|
+
coupon_end_time: str = ""
|
|
71
|
+
shop_name: str = ""
|
|
72
|
+
image_url: str = ""
|
|
73
|
+
detail_url: str = ""
|
|
74
|
+
notes: list = field(default_factory=list)
|
|
75
|
+
|
|
76
|
+
def net_price(self) -> float:
|
|
77
|
+
"""Coupon-adjusted (到手) price."""
|
|
78
|
+
if self.coupon_amount <= 0:
|
|
79
|
+
return self.price
|
|
80
|
+
if self.coupon_threshold > 0 and self.price < self.coupon_threshold:
|
|
81
|
+
return self.price
|
|
82
|
+
return max(0.0, round(self.price - self.coupon_amount, 2))
|
|
83
|
+
|
|
84
|
+
def card(self) -> str:
|
|
85
|
+
"""WeChat-friendly text card."""
|
|
86
|
+
plat = "京东" if self.platform == "jd" else "淘宝/天猫"
|
|
87
|
+
lines = [f"💰 **比价结果** 💰", "", f"📦 {self.title}", ""]
|
|
88
|
+
lines.append(f"平台: {plat}")
|
|
89
|
+
if self.shop_name:
|
|
90
|
+
lines.append(f"店铺: {self.shop_name}")
|
|
91
|
+
lines.append(f"现价: ¥{self.price:g}")
|
|
92
|
+
if self.coupon_amount > 0:
|
|
93
|
+
if self.coupon_threshold > 0:
|
|
94
|
+
lines.append(
|
|
95
|
+
f"优惠券: 满{self.coupon_threshold:g}减{self.coupon_amount:g}"
|
|
96
|
+
)
|
|
97
|
+
else:
|
|
98
|
+
lines.append(f"优惠券: 减{self.coupon_amount:g}")
|
|
99
|
+
if self.coupon_end_time:
|
|
100
|
+
lines.append(f"券有效期至: {self.coupon_end_time}")
|
|
101
|
+
lines.append(f"**到手价: ¥{self.net_price():g}**")
|
|
102
|
+
if self.lowest_price > 0:
|
|
103
|
+
delta = self.net_price() - self.lowest_price
|
|
104
|
+
if delta > 0.01:
|
|
105
|
+
lines.append(f"历史最低: ¥{self.lowest_price:g}(现价高出 ¥{delta:g})")
|
|
106
|
+
else:
|
|
107
|
+
lines.append("历史最低: 当前就是最低价 ✅")
|
|
108
|
+
for n in self.notes:
|
|
109
|
+
lines.append(f"ℹ️ {n}")
|
|
110
|
+
if self.detail_url:
|
|
111
|
+
lines.append("")
|
|
112
|
+
lines.append(self.detail_url)
|
|
113
|
+
return "\n".join(lines)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# Link / share-code detection
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
# Short links whose ONLY realistic purpose is "open this product" → auto trigger
|
|
121
|
+
JD_SHORT_HOSTS = ("u.jd.com", "jd.cn.hn", "3.cn", "jd.com/")
|
|
122
|
+
TB_SHORT_HOSTS = ("m.tb.cn", "tb.cn")
|
|
123
|
+
|
|
124
|
+
# 淘口令: wrapped in fullwidth symbols, e.g. 8.8¥CZ0001 xxxx¥ / ₴...₴
|
|
125
|
+
# Inner content may contain spaces (real share text like "8.8¥ CZ0001 aBcd12 ¥").
|
|
126
|
+
TAOKOULING_RE = re.compile(r"[¥₴€£]([A-Za-z0-9:\/\.\- ]{6,80})[¥₴€£]")
|
|
127
|
+
|
|
128
|
+
# Full product URLs (pass-through unless via /price)
|
|
129
|
+
JD_ITEM_RE = re.compile(r"item\.jd\.com/(\d+)\.html")
|
|
130
|
+
TB_ITEM_RE = re.compile(r"(?:item\.taobao\.com/item|detail\.tmall\.com/item)\.htm\?.*?id=(\d+)")
|
|
131
|
+
|
|
132
|
+
DT_SHARE_RE = re.compile(r"dwo\.cn/([A-Za-z0-9]+)|dt\.cn/([A-Za-z0-9]+)")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def detect_price_trigger(text: str) -> dict | None:
|
|
136
|
+
"""Classify a text message. Returns {'kind': ..., ...} or None.
|
|
137
|
+
|
|
138
|
+
kinds:
|
|
139
|
+
jd_short — JD short link (auto-trigger)
|
|
140
|
+
jd_item — JD product URL, SKU captured (pass-through, used by /price)
|
|
141
|
+
tkl — taokouling share token (auto-trigger)
|
|
142
|
+
tb_short — Taobao short link (auto-trigger)
|
|
143
|
+
tb_item — Taobao/Tmall product URL (pass-through, used by /price)
|
|
144
|
+
"""
|
|
145
|
+
if not text or len(text) > 4000:
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
m = TAOKOULING_RE.search(text)
|
|
149
|
+
if m:
|
|
150
|
+
return {"kind": "tkl", "token": m.group(1).strip()}
|
|
151
|
+
|
|
152
|
+
m = DT_SHARE_RE.search(text)
|
|
153
|
+
if m:
|
|
154
|
+
return {"kind": "tb_short", "url": m.group(0)}
|
|
155
|
+
|
|
156
|
+
for host in JD_SHORT_HOSTS:
|
|
157
|
+
m = re.search(r"https?://(?:[a-z0-9\-]+\.)*" + re.escape(host) + r"/\S+", text)
|
|
158
|
+
if m:
|
|
159
|
+
return {"kind": "jd_short", "url": m.group(0)}
|
|
160
|
+
|
|
161
|
+
for host in TB_SHORT_HOSTS:
|
|
162
|
+
m = re.search(r"https?://(?:[a-z0-9\-]+\.)*" + re.escape(host) + r"/\S+", text)
|
|
163
|
+
if m:
|
|
164
|
+
return {"kind": "tb_short", "url": m.group(0)}
|
|
165
|
+
|
|
166
|
+
m = JD_ITEM_RE.search(text)
|
|
167
|
+
if m:
|
|
168
|
+
return {"kind": "jd_item", "sku": m.group(1)}
|
|
169
|
+
|
|
170
|
+
m = TB_ITEM_RE.search(text)
|
|
171
|
+
if m:
|
|
172
|
+
return {"kind": "tb_item", "item_id": m.group(1)}
|
|
173
|
+
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
# JD Union official API (sign: MD5(sorted k=v&... + secret))
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
import hashlib
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _jd_sign(params: dict) -> str:
|
|
185
|
+
plain = "".join(
|
|
186
|
+
f"{k}{v}" for k, v in sorted(params.items()) if k != "sign"
|
|
187
|
+
)
|
|
188
|
+
plain += _JD_SECRET_KEY
|
|
189
|
+
return hashlib.md5(plain.encode("utf-8")).hexdigest().upper()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _jd_call(method: str, param_json: dict) -> dict:
|
|
193
|
+
# JD gateway contract: form field is `360buy_param_json`; the signature
|
|
194
|
+
# covers every field except `sign` itself (access_token="" participates).
|
|
195
|
+
params = {
|
|
196
|
+
"method": method,
|
|
197
|
+
"app_key": _JD_APP_KEY,
|
|
198
|
+
"access_token": "",
|
|
199
|
+
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
200
|
+
"format": "json",
|
|
201
|
+
"v": "1.0",
|
|
202
|
+
"sign_method": "md5",
|
|
203
|
+
"360buy_param_json": json.dumps(param_json, ensure_ascii=False, separators=(",", ":")),
|
|
204
|
+
}
|
|
205
|
+
params["sign"] = _jd_sign(params)
|
|
206
|
+
resp = httpx.post(JD_API_URL, data=params, timeout=_DT_TIMEOUT)
|
|
207
|
+
resp.raise_for_status()
|
|
208
|
+
return resp.json()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def query_jd_sku(sku: str) -> PriceResult:
|
|
212
|
+
"""Query one SKU via jd.union.open.goods.query."""
|
|
213
|
+
data = _jd_call(
|
|
214
|
+
"jd.union.open.goods.query",
|
|
215
|
+
{"skuIds": [str(sku)]},
|
|
216
|
+
)
|
|
217
|
+
resp = (data.get("jd_union_open_goods_query_reslt") or {}).get("data") or []
|
|
218
|
+
if not resp:
|
|
219
|
+
raise ValueError("京东接口未返回该商品(可能非联盟推广商品)")
|
|
220
|
+
g = resp[0]
|
|
221
|
+
price_info = g.get("priceInfo", {})
|
|
222
|
+
coupon = g.get("couponInfo", {}).get("couponList", [{}])[0] if g.get("couponInfo") else {}
|
|
223
|
+
image = (g.get("imageInfo", {}) or {}).get("imageList", [""])[0]
|
|
224
|
+
notes = []
|
|
225
|
+
if not coupon:
|
|
226
|
+
notes.append("当前无可用优惠券")
|
|
227
|
+
return PriceResult(
|
|
228
|
+
platform="jd",
|
|
229
|
+
title=g.get("skuName", "(未知商品)"),
|
|
230
|
+
item_id=str(sku),
|
|
231
|
+
price=float(price_info.get("price", 0) or 0),
|
|
232
|
+
lowest_price=float(price_info.get("lowestPrice", 0) or 0),
|
|
233
|
+
coupon_amount=float(coupon.get("discount", 0) or 0),
|
|
234
|
+
coupon_threshold=float(coupon.get("quota", 0) or 0),
|
|
235
|
+
coupon_end_time=(coupon.get("endTime", "") or "")[:10],
|
|
236
|
+
shop_name=(g.get("shopInfo", {}) or {}).get("shopName", ""),
|
|
237
|
+
image_url=image or "",
|
|
238
|
+
detail_url=g.get("materialUrl", ""),
|
|
239
|
+
notes=notes,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
# 大淘客 (Taobao/Tmall proxy) API
|
|
245
|
+
# ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
def _dt_call(path: str, params: dict) -> dict:
|
|
248
|
+
params = dict(params)
|
|
249
|
+
params["appKey"] = _DT_APP_KEY
|
|
250
|
+
params["version"] = params.get("version", "v2.0.0")
|
|
251
|
+
resp = httpx.get(f"{DT_API_URL}{path}", params=params, timeout=_DT_TIMEOUT)
|
|
252
|
+
resp.raise_for_status()
|
|
253
|
+
data = resp.json()
|
|
254
|
+
if data.get("code") != 0:
|
|
255
|
+
raise ValueError(f"大淘客接口错误: {data.get('msg', data.get('code'))}")
|
|
256
|
+
return data.get("data", {})
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def resolve_tkl(token: str) -> dict:
|
|
260
|
+
"""Parse taokouling → item info via dataoke tpwd API."""
|
|
261
|
+
return _dt_call(
|
|
262
|
+
"/goods/get-privilege-link",
|
|
263
|
+
{"taoCommand": token, "apiName": "generous"},
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def query_tb_item(item_id: str) -> PriceResult:
|
|
268
|
+
"""Query Taobao/Tmall item via dataoke goods detail API."""
|
|
269
|
+
data = _dt_call("/goods/get-goods-details", {"id": item_id})
|
|
270
|
+
goods = data.get("goods", data)
|
|
271
|
+
quan_price = float(goods.get("quanPrice", 0) or 0)
|
|
272
|
+
actual_price = float(goods.get("actualPrice", goods.get("originalPrice", 0)) or 0)
|
|
273
|
+
return PriceResult(
|
|
274
|
+
platform="taobao",
|
|
275
|
+
title=goods.get("goodsName", "(未知商品)"),
|
|
276
|
+
item_id=str(item_id),
|
|
277
|
+
price=actual_price,
|
|
278
|
+
lowest_price=0.0, # dataoke has no history-lowest; left unknown
|
|
279
|
+
coupon_amount=quan_price,
|
|
280
|
+
coupon_threshold=float(goods.get("quanReceiveCondition", 0) or 0),
|
|
281
|
+
coupon_end_time=(goods.get("quanEndTime", "") or "")[:10],
|
|
282
|
+
shop_name=goods.get("shopName", ""),
|
|
283
|
+
image_url=goods.get("goodsImage", ""),
|
|
284
|
+
detail_url=goods.get("goodsUrl", ""),
|
|
285
|
+
notes=["数据源: 大淘客(淘宝联盟数据)"],
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ---------------------------------------------------------------------------
|
|
290
|
+
# Short-link resolution (HEAD/GET follow redirects, extract SKU or tkl)
|
|
291
|
+
# ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
def _resolve_short_link(url: str) -> str:
|
|
294
|
+
"""Follow redirects, return final URL (and body sniff for tkl)."""
|
|
295
|
+
with httpx.Client(timeout=_DT_TIMEOUT, follow_redirects=True, headers={
|
|
296
|
+
"User-Agent": (
|
|
297
|
+
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) "
|
|
298
|
+
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
|
|
299
|
+
)
|
|
300
|
+
}) as client:
|
|
301
|
+
r = client.get(url)
|
|
302
|
+
final_url = str(r.url)
|
|
303
|
+
# Some JD share pages embed the SKU in an anchor; sniff once.
|
|
304
|
+
m = JD_ITEM_RE.search(final_url) or JD_ITEM_RE.search(r.text[:20000])
|
|
305
|
+
if m:
|
|
306
|
+
return f"https://item.jd.com/{m.group(1)}.html"
|
|
307
|
+
return final_url
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# ---------------------------------------------------------------------------
|
|
311
|
+
# Public entry points
|
|
312
|
+
# ---------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
async def handle_price_query(trigger: dict) -> str:
|
|
315
|
+
"""Given detect_price_trigger() result, return a WeChat reply card."""
|
|
316
|
+
try:
|
|
317
|
+
if trigger["kind"] == "jd_item":
|
|
318
|
+
if not jd_configured():
|
|
319
|
+
return _not_configured("京东联盟")
|
|
320
|
+
return query_jd_sku(trigger["sku"]).card()
|
|
321
|
+
|
|
322
|
+
if trigger["kind"] == "jd_short":
|
|
323
|
+
if not jd_configured():
|
|
324
|
+
return _not_configured("京东联盟")
|
|
325
|
+
final = _resolve_short_link(trigger["url"])
|
|
326
|
+
m = JD_ITEM_RE.search(final)
|
|
327
|
+
if not m:
|
|
328
|
+
logger.info("jd short link resolved but no SKU: %s", final)
|
|
329
|
+
return "❓ 短链解析出来不是商品页,试试直接发商品页链接或用 /price + 链接。"
|
|
330
|
+
return query_jd_sku(m.group(1)).card()
|
|
331
|
+
|
|
332
|
+
if trigger["kind"] == "tkl":
|
|
333
|
+
if not dt_configured():
|
|
334
|
+
return _not_configured("大淘客(淘宝口令解析)")
|
|
335
|
+
data = resolve_tkl(trigger["token"])
|
|
336
|
+
goods_id = str(data.get("goodsId", "") or data.get("id", ""))
|
|
337
|
+
if not goods_id:
|
|
338
|
+
return "❓ 口令解析出来了但没拿到商品信息,可能商品未收录。"
|
|
339
|
+
return query_tb_item(goods_id).card()
|
|
340
|
+
|
|
341
|
+
if trigger["kind"] == "tb_item":
|
|
342
|
+
if not dt_configured():
|
|
343
|
+
return _not_configured("大淘客(淘宝商品)")
|
|
344
|
+
return query_tb_item(trigger["item_id"]).card()
|
|
345
|
+
|
|
346
|
+
if trigger["kind"] == "tb_short":
|
|
347
|
+
if not dt_configured():
|
|
348
|
+
return _not_configured("大淘客(淘宝短链)")
|
|
349
|
+
final = _resolve_short_link(trigger["url"])
|
|
350
|
+
m = TB_ITEM_RE.search(final)
|
|
351
|
+
if m:
|
|
352
|
+
return query_tb_item(m.group(1)).card()
|
|
353
|
+
return "❓ 短链解析出来不是商品页,试试直接发商品页链接或用 /price + 链接。"
|
|
354
|
+
|
|
355
|
+
return "❓ 不支持的链接类型。"
|
|
356
|
+
except (ValueError, httpx.HTTPError) as e:
|
|
357
|
+
logger.warning("price query failed: %s", e)
|
|
358
|
+
return f"⚠️ 比价失败: {e}"
|
|
359
|
+
except Exception:
|
|
360
|
+
logger.exception("price query unexpected error")
|
|
361
|
+
return "⚠️ 比价出了点岔子,请稍后再试。"
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _not_configured(what: str) -> str:
|
|
365
|
+
return (
|
|
366
|
+
f"ℹ️ **比价功能未启用** ℹ️\n\n{what} 的 API key 还没配置。\n"
|
|
367
|
+
"在桥的 .env 里设置后重启即可:\n"
|
|
368
|
+
"`JDUNION_APP_KEY` / `JDUNION_APP_SECRET`(京东)\n"
|
|
369
|
+
"`DATAOKE_APP_KEY`(淘宝/天猫)"
|
|
370
|
+
)
|
|
@@ -192,8 +192,12 @@ def clean_output(text: str) -> str:
|
|
|
192
192
|
# WeChat error reply format (fixed header + body)
|
|
193
193
|
# ---------------------------------------------------------------------------
|
|
194
194
|
|
|
195
|
-
# Shown when backend returns successfully but with no display text
|
|
196
|
-
|
|
195
|
+
# Shown when backend returns successfully but with no display text.
|
|
196
|
+
# Empty output after a successful run is, in practice, an exhausted-quota
|
|
197
|
+
# signal (agy/grok/codex CLI stay exit-0 and just print nothing) — say so
|
|
198
|
+
# explicitly instead of the confusing "no text reply" phrasing.
|
|
199
|
+
EMPTY_REPLY = "🔔 对话额度已用尽\n\n后端没有返回任何内容,通常是本账号的对话额度/用量已耗尽。请稍后再试或联系管理员确认额度。"
|
|
200
|
+
|
|
197
201
|
|
|
198
202
|
# Titles used for upstream throttle / quota user replies (🔔 notice style).
|
|
199
203
|
# Keep in sync with format_cli_error branches and is_upstream_throttle_reply.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wechatbridge-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.7.0
|
|
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
|
|
@@ -195,7 +195,7 @@ Key variables (all have defaults):
|
|
|
195
195
|
| `AGY_TIMEOUT` | `600` | CLI run timeout in seconds (agy / grok / codex backends) |
|
|
196
196
|
| `WECHATBRIDGE_MAX_OUTBOUND_BYTES` | `104857600` | max file size sent back to WeChat (100 MB) |
|
|
197
197
|
| `WECHATBRIDGE_MAX_INBOUND_BYTES` | `20971520` | max inbound image/file after download (20 MB) |
|
|
198
|
-
| `WECHATBRIDGE_MAX_CONCURRENT` | `4` | global concurrent process slots; same user serial (queue does not hold a slot); extras get a busy reply |
|
|
198
|
+
| `WECHATBRIDGE_MAX_CONCURRENT` | `4` | global concurrent process slots; same user serial (queue does not hold a slot); extras get a busy reply (on small RAM hosts, lower this to avoid concurrent CLI memory spikes; see [Memory limits](#memory-limits)) |
|
|
199
199
|
| `WECHATBRIDGE_CONFIRM_TOKEN` | `y` | reply this token to approve a gated dangerous prompt |
|
|
200
200
|
| `WECHATBRIDGE_ENABLE_MCP` | `true` | enable the `/mcp` help text command |
|
|
201
201
|
| `WECHATBRIDGE_ENABLE_SUBAGENT` | `true` | enable the `/agent` prompt-rewrite command |
|
|
@@ -261,6 +261,39 @@ sudo systemctl enable --now wechatbridge@bot3
|
|
|
261
261
|
|
|
262
262
|
Each instance reads its own config file (`~/.config/wechatbridge/bot2.env`) and keeps state under its own data directory (`~/.local/share/wechatbridge/bot2/`).
|
|
263
263
|
|
|
264
|
+
#### Memory limits
|
|
265
|
+
|
|
266
|
+
The multi-instance template (`deploy/wechatbridge@.service`) configures cgroup memory protection:
|
|
267
|
+
|
|
268
|
+
```ini
|
|
269
|
+
MemoryAccounting=yes
|
|
270
|
+
MemoryHigh=450M
|
|
271
|
+
MemoryMax=512M
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
- **Why these defaults?** Backend CLI child processes (such as `agy`) can reach ~276 MB+ RSS during a single turn, and spike even higher during image recognition or long conversational sessions. A tight hard cap (e.g. 300 MB) on ~1 GB RAM hosts triggers aggressive page thrashing, deep swap latency, and stream interruptions (`subscriber fell behind updates`). The `450M` soft limit (`MemoryHigh`) triggers gentle reclaim, while `512M` (`MemoryMax`) provides a hard ceiling preventing full-host OOM.
|
|
275
|
+
- **Overriding per-instance via drop-in:** Override memory limits without modifying the base unit template:
|
|
276
|
+
```bash
|
|
277
|
+
sudo mkdir -p /etc/systemd/system/wechatbridge@bot2.service.d/
|
|
278
|
+
cat << 'EOF' | sudo tee /etc/systemd/system/wechatbridge@bot2.service.d/memory.conf
|
|
279
|
+
[Service]
|
|
280
|
+
# for hosts with >= 2 GB RAM
|
|
281
|
+
MemoryHigh=600M
|
|
282
|
+
MemoryMax=768M
|
|
283
|
+
EOF
|
|
284
|
+
sudo systemctl daemon-reload
|
|
285
|
+
sudo systemctl restart wechatbridge@bot2
|
|
286
|
+
```
|
|
287
|
+
Verify active limits:
|
|
288
|
+
```bash
|
|
289
|
+
systemctl show wechatbridge@bot2 -p MemoryHigh,MemoryMax
|
|
290
|
+
```
|
|
291
|
+
- **Sizing guidance:** The defaults are tuned for small hosts (~1 GB RAM). On larger hosts with ample headroom, limits can be relaxed. When deploying multiple instances, note that each instance runs in its own service cgroup — budget for the worst-case combined ceiling of N × MemoryMax across running instances.
|
|
292
|
+
- **Interaction with concurrency:** Concurrent heavy turns (e.g. multiple users sending images at once) multiply backend CLI memory consumption. On memory-constrained hosts, prioritize lowering `WECHATBRIDGE_MAX_CONCURRENT` (e.g. to `2` or `1`) before increasing memory caps.
|
|
293
|
+
- **Swap configuration:** Avoid tightening `MemorySwapMax` — swap margin serves as a vital safety buffer that slows execution under pressure instead of triggering an immediate cgroup OOM kill.
|
|
294
|
+
- **Single-instance deployments:** The single-instance unit `deploy/wechatbridge.service` includes the same built-in memory limits (`MemoryHigh=450M` and `MemoryMax=512M`). To adjust them, override via a drop-in under `/etc/systemd/system/wechatbridge.service.d/` and replace the unit name in the `systemctl` commands above with `wechatbridge`. Takes effect when the unit file is installed or re-installed; `update.sh` upgrades do not touch unit files.
|
|
295
|
+
- **Platform scope:** These cgroup memory limits apply only to Linux systemd deployments; the macOS (launchd) and Windows (Task Scheduler) paths have no equivalent per-instance cap.
|
|
296
|
+
|
|
264
297
|
### macOS (launchd)
|
|
265
298
|
|
|
266
299
|
```bash
|
|
@@ -302,7 +335,7 @@ Other `/…` commands are either rejected (e.g. `/exit`), reported as unsupporte
|
|
|
302
335
|
- **Danger gate is keyword-based**, not full intent understanding. Defaults target concrete patterns (`rm -rf /`, pipe-to-shell, `mkfs`, `format c:`, a few heavy Chinese phrases, …). Everyday wording like bare “delete” is **not** gated. Override list via `WECHATBRIDGE_CONFIRM_KEYWORDS`; approve with `WECHATBRIDGE_CONFIRM_TOKEN` (default `y`), TTL `WECHATBRIDGE_PENDING_TTL`.
|
|
303
336
|
- **Inbound media** is size-capped (default 20 MB), streamed, and CDN hosts are allowlisted. Missing `aes_key` returns a clear error.
|
|
304
337
|
- **Outbound artifacts** only leave the allowed per-user tree (agy: session scratch; grok: under session dir; dsh: under session dir), after `realpath` checks, and only if under `WECHATBRIDGE_MAX_OUTBOUND_BYTES`.
|
|
305
|
-
- **Concurrency:** global process-slot cap (`WECHATBRIDGE_MAX_CONCURRENT`, default 4). Same user is serialized and does **not** hold a global slot while waiting on their previous message; different users can run in parallel up to the cap.
|
|
338
|
+
- **Concurrency:** global process-slot cap (`WECHATBRIDGE_MAX_CONCURRENT`, default 4). Same user is serialized and does **not** hold a global slot while waiting on their previous message; different users can run in parallel up to the cap (heavy concurrent tasks multiply subprocess memory; see [Memory limits](#memory-limits)).
|
|
306
339
|
- **Long replies** are split into chunks (`WECHATBRIDGE_MESSAGE_CHUNK`, default 2000 characters).
|
|
307
340
|
- **Data layout:** instance data under `~/.local/share/wechatbridge/<instance>/` (override with env). Runtime dirs prefer `0700`; token/QR files prefer `0600` (Unix; Windows relies on NTFS ACLs).
|
|
308
341
|
- **Retention:** session temps vs dialogue history use separate TTLs (`WECHATBRIDGE_SESSION_RETENTION_DAYS`, `WECHATBRIDGE_HISTORY_RETENTION_DAYS`). Prefs/auth are kept.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{wechatbridge_cli-1.6.1 → wechatbridge_cli-1.7.0}/wechatbridge_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{wechatbridge_cli-1.6.1 → wechatbridge_cli-1.7.0}/wechatbridge_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|