wechatbridge-cli 1.3.2__tar.gz → 1.3.4__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 (21) hide show
  1. {wechatbridge_cli-1.3.2/wechatbridge_cli.egg-info → wechatbridge_cli-1.3.4}/PKG-INFO +3 -3
  2. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.4}/README.md +2 -2
  3. wechatbridge_cli-1.3.4/tests/test_hardening.py +314 -0
  4. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.4}/wechatbridge/__init__.py +1 -1
  5. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.4}/wechatbridge/agy.py +34 -28
  6. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.4}/wechatbridge/config.py +2 -1
  7. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.4}/wechatbridge/grok.py +26 -21
  8. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.4}/wechatbridge/ilink.py +24 -6
  9. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.4}/wechatbridge/main.py +101 -50
  10. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.4}/wechatbridge/runner_common.py +114 -42
  11. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.4/wechatbridge_cli.egg-info}/PKG-INFO +3 -3
  12. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.4}/wechatbridge_cli.egg-info/SOURCES.txt +1 -0
  13. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.4}/LICENSE +0 -0
  14. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.4}/pyproject.toml +0 -0
  15. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.4}/setup.cfg +0 -0
  16. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.4}/wechatbridge/__main__.py +0 -0
  17. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.4}/wechatbridge/update_check.py +0 -0
  18. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.4}/wechatbridge_cli.egg-info/dependency_links.txt +0 -0
  19. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.4}/wechatbridge_cli.egg-info/entry_points.txt +0 -0
  20. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.4}/wechatbridge_cli.egg-info/requires.txt +0 -0
  21. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.4}/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.2
3
+ Version: 1.3.4
4
4
  Summary: Bridge WeChat messages to agy or Grok Build CLIs — text/image/file/voice in, CLI replies and generated files back.
5
5
  Author: WeChatBridge contributors
6
6
  License: MIT
@@ -155,7 +155,7 @@ Key variables (all have defaults):
155
155
  | `AGY_TIMEOUT` | `600` | CLI run timeout in seconds (both backends) |
156
156
  | `WECHATBRIDGE_MAX_OUTBOUND_BYTES` | `104857600` | max file size sent back to WeChat (100 MB) |
157
157
  | `WECHATBRIDGE_MAX_INBOUND_BYTES` | `20971520` | max inbound image/file after download (20 MB) |
158
- | `WECHATBRIDGE_MAX_CONCURRENT` | `4` | global concurrent handlers; extras get a busy reply |
158
+ | `WECHATBRIDGE_MAX_CONCURRENT` | `4` | global concurrent process slots; same user serial (queue does not hold a slot); extras get a busy reply |
159
159
  | `WECHATBRIDGE_CONFIRM_TOKEN` | `y` | reply this token to approve a gated dangerous prompt |
160
160
  | `WECHATBRIDGE_ENABLE_MCP` | `true` | enable the `/mcp` help text command |
161
161
  | `WECHATBRIDGE_ENABLE_SUBAGENT` | `true` | enable the `/agent` prompt-rewrite command |
@@ -262,7 +262,7 @@ Other `/…` commands are either rejected (e.g. `/exit`), reported as unsupporte
262
262
  - **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`.
263
263
  - **Inbound media** is size-capped (default 20 MB), streamed, and CDN hosts are allowlisted. Missing `aes_key` returns a clear error.
264
264
  - **Outbound artifacts** only leave the allowed per-user tree (agy: session scratch; grok: under session dir), after `realpath` checks, and only if under `WECHATBRIDGE_MAX_OUTBOUND_BYTES`.
265
- - **Concurrency:** global cap (`WECHATBRIDGE_MAX_CONCURRENT`, default 4); same user is serialized, different users can run in parallel.
265
+ - **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.
266
266
  - **Long replies** are split into chunks (`WECHATBRIDGE_MESSAGE_CHUNK`, default 2000 characters).
267
267
  - **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).
268
268
  - **Retention:** session temps vs dialogue history use separate TTLs (`WECHATBRIDGE_SESSION_RETENTION_DAYS`, `WECHATBRIDGE_HISTORY_RETENTION_DAYS`). Prefs/auth are kept.
@@ -124,7 +124,7 @@ Key variables (all have defaults):
124
124
  | `AGY_TIMEOUT` | `600` | CLI run timeout in seconds (both backends) |
125
125
  | `WECHATBRIDGE_MAX_OUTBOUND_BYTES` | `104857600` | max file size sent back to WeChat (100 MB) |
126
126
  | `WECHATBRIDGE_MAX_INBOUND_BYTES` | `20971520` | max inbound image/file after download (20 MB) |
127
- | `WECHATBRIDGE_MAX_CONCURRENT` | `4` | global concurrent handlers; extras get a busy reply |
127
+ | `WECHATBRIDGE_MAX_CONCURRENT` | `4` | global concurrent process slots; same user serial (queue does not hold a slot); extras get a busy reply |
128
128
  | `WECHATBRIDGE_CONFIRM_TOKEN` | `y` | reply this token to approve a gated dangerous prompt |
129
129
  | `WECHATBRIDGE_ENABLE_MCP` | `true` | enable the `/mcp` help text command |
130
130
  | `WECHATBRIDGE_ENABLE_SUBAGENT` | `true` | enable the `/agent` prompt-rewrite command |
@@ -231,7 +231,7 @@ Other `/…` commands are either rejected (e.g. `/exit`), reported as unsupporte
231
231
  - **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`.
232
232
  - **Inbound media** is size-capped (default 20 MB), streamed, and CDN hosts are allowlisted. Missing `aes_key` returns a clear error.
233
233
  - **Outbound artifacts** only leave the allowed per-user tree (agy: session scratch; grok: under session dir), after `realpath` checks, and only if under `WECHATBRIDGE_MAX_OUTBOUND_BYTES`.
234
- - **Concurrency:** global cap (`WECHATBRIDGE_MAX_CONCURRENT`, default 4); same user is serialized, different users can run in parallel.
234
+ - **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.
235
235
  - **Long replies** are split into chunks (`WECHATBRIDGE_MESSAGE_CHUNK`, default 2000 characters).
236
236
  - **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).
237
237
  - **Retention:** session temps vs dialogue history use separate TTLs (`WECHATBRIDGE_SESSION_RETENTION_DAYS`, `WECHATBRIDGE_HISTORY_RETENTION_DAYS`). Prefs/auth are kept.
@@ -0,0 +1,314 @@
1
+ """Hardening / cleanup / message-safety probes (stdlib unittest only).
2
+
3
+ Exercises real production helpers and async methods with mocks — not
4
+ string-copied stand-ins of the user-facing copy.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import os
11
+ import tempfile
12
+ import time
13
+ import unittest
14
+ from unittest import mock
15
+ from unittest.mock import AsyncMock, MagicMock
16
+
17
+
18
+ class TestSplitMessageChunks(unittest.TestCase):
19
+ def test_join_equals_original_fixed(self):
20
+ from wechatbridge.runner_common import split_message_chunks
21
+
22
+ samples = [
23
+ "",
24
+ "short",
25
+ "a" * 50,
26
+ "line1\nline2\nline3",
27
+ "word " * 400,
28
+ "x" * 10 + "\n" + "y" * 10,
29
+ " keep spaces at edges ",
30
+ "\n\n\n",
31
+ "中文" * 300,
32
+ ]
33
+ for text in samples:
34
+ for limit in (5, 20, 80, 2000):
35
+ chunks = split_message_chunks(text, limit)
36
+ self.assertEqual("".join(chunks), text, msg=repr(text[:40]))
37
+ for c in chunks:
38
+ self.assertLessEqual(len(c), limit if limit > 0 else len(c))
39
+
40
+ def test_random_join_equals_original(self):
41
+ import random
42
+ from wechatbridge.runner_common import split_message_chunks
43
+
44
+ rng = random.Random(42)
45
+ alphabet = "abc \n中文,。"
46
+ for _ in range(30):
47
+ text = "".join(rng.choice(alphabet) for _ in range(rng.randint(0, 500)))
48
+ limit = rng.randint(1, 120)
49
+ chunks = split_message_chunks(text, limit)
50
+ self.assertEqual("".join(chunks), text)
51
+
52
+
53
+ class TestPathIsUnder(unittest.TestCase):
54
+ def test_child_and_escape(self):
55
+ from wechatbridge.runner_common import path_is_under
56
+
57
+ with tempfile.TemporaryDirectory() as td:
58
+ child = os.path.join(td, "a", "b")
59
+ os.makedirs(child)
60
+ self.assertTrue(path_is_under(child, td))
61
+ self.assertTrue(path_is_under(td, td))
62
+ outside = os.path.join(
63
+ tempfile.gettempdir(), "not-under-" + os.path.basename(td)
64
+ )
65
+ self.assertFalse(path_is_under(outside, td))
66
+
67
+ def test_symlink_escape_blocked(self):
68
+ from wechatbridge.runner_common import path_is_under
69
+
70
+ with tempfile.TemporaryDirectory() as td:
71
+ allowed = os.path.join(td, "allowed")
72
+ secret = os.path.join(td, "secret")
73
+ os.makedirs(allowed)
74
+ os.makedirs(secret)
75
+ leak = os.path.join(allowed, "leak")
76
+ os.symlink(secret, leak)
77
+ self.assertFalse(path_is_under(leak, allowed))
78
+
79
+
80
+ class TestRemoveOldFilesDangling(unittest.TestCase):
81
+ def test_dangling_file_and_dir_links_removed_immediately(self):
82
+ from wechatbridge.runner_common import _remove_old_files_under
83
+
84
+ with tempfile.TemporaryDirectory() as td:
85
+ bad_file = os.path.join(td, "python")
86
+ os.symlink("/no/such/target/python-xyz", bad_file)
87
+ bad_dir = os.path.join(td, "lib64")
88
+ os.symlink("/no/such/lib", bad_dir)
89
+ keep = os.path.join(td, "keep.txt")
90
+ with open(keep, "w", encoding="utf-8") as f:
91
+ f.write("ok")
92
+ old = os.path.join(td, "old.txt")
93
+ with open(old, "w", encoding="utf-8") as f:
94
+ f.write("bye")
95
+ old_mtime = time.time() - 10 * 86400
96
+ os.utime(old, (old_mtime, old_mtime))
97
+
98
+ cutoff = time.time() - 7 * 86400
99
+ removed = _remove_old_files_under(td, cutoff)
100
+
101
+ self.assertGreaterEqual(removed, 3)
102
+ self.assertFalse(os.path.lexists(bad_file))
103
+ self.assertFalse(os.path.lexists(bad_dir))
104
+ self.assertFalse(os.path.exists(old))
105
+ self.assertTrue(os.path.exists(keep))
106
+
107
+ def test_intact_young_symlink_kept(self):
108
+ from wechatbridge.runner_common import _remove_old_files_under
109
+
110
+ with tempfile.TemporaryDirectory() as td:
111
+ target = os.path.join(td, "real.txt")
112
+ with open(target, "w", encoding="utf-8") as f:
113
+ f.write("data")
114
+ link = os.path.join(td, "alias")
115
+ os.symlink(target, link)
116
+ cutoff = time.time() - 7 * 86400
117
+ _remove_old_files_under(td, cutoff)
118
+ self.assertTrue(os.path.lexists(link))
119
+ self.assertTrue(os.path.exists(target))
120
+
121
+ def test_does_not_follow_symlink_into_outside_tree(self):
122
+ """followlinks=False: must not age-delete files outside root via symlink."""
123
+ from wechatbridge.runner_common import _remove_old_files_under
124
+
125
+ with tempfile.TemporaryDirectory() as outer:
126
+ with tempfile.TemporaryDirectory() as root:
127
+ victim = os.path.join(outer, "outside-old.txt")
128
+ with open(victim, "w", encoding="utf-8") as f:
129
+ f.write("keep-me")
130
+ old_mtime = time.time() - 10 * 86400
131
+ os.utime(victim, (old_mtime, old_mtime))
132
+ os.symlink(outer, os.path.join(root, "escape"))
133
+ cutoff = time.time() - 7 * 86400
134
+ _remove_old_files_under(root, cutoff)
135
+ self.assertTrue(os.path.exists(victim), "must not delete outside tree")
136
+
137
+
138
+ class TestOversizedArtifactNotice(unittest.TestCase):
139
+ def test_helper_never_embeds_absolute_path(self):
140
+ from wechatbridge.runner_common import format_oversized_artifact_notice
141
+
142
+ art_path = "/root/.local/share/wechatbridge/default/sessions/u1/scratch/report.pdf"
143
+ text = format_oversized_artifact_notice("report.pdf", 120.5)
144
+ self.assertNotIn(art_path, text)
145
+ self.assertNotIn("/root/", text)
146
+ self.assertNotIn("sessions/", text)
147
+ self.assertIn("report.pdf", text)
148
+ self.assertIn("无法发到微信", text)
149
+
150
+ def test_send_artifacts_back_uses_safe_notice(self):
151
+ """Call the real async helper; assert user text has no server path."""
152
+ from wechatbridge.main import send_artifacts_back
153
+
154
+ async def _run():
155
+ with tempfile.TemporaryDirectory() as td:
156
+ scratch = os.path.join(td, ".gemini", "antigravity-cli", "scratch")
157
+ os.makedirs(scratch)
158
+ big_path = os.path.join(scratch, "huge.bin")
159
+ with open(big_path, "wb") as f:
160
+ f.write(b"x" * 64)
161
+
162
+ client = MagicMock()
163
+ client.state.baseurl = "https://example.test"
164
+ client.state.bot_token = "tok"
165
+ client.send_message = AsyncMock(return_value=True)
166
+ client.send_media = AsyncMock(return_value=True)
167
+
168
+ with mock.patch(
169
+ "wechatbridge.main.get_session_dir", return_value=td
170
+ ), mock.patch(
171
+ "wechatbridge.main._get_backend", return_value="agy"
172
+ ), mock.patch(
173
+ "wechatbridge.main.config"
174
+ ) as cfg:
175
+ cfg.max_outbound_file_bytes = 8 # force oversized
176
+ await send_artifacts_back(
177
+ client,
178
+ "user-1",
179
+ "ctx-token",
180
+ [("huge.bin", big_path)],
181
+ )
182
+
183
+ client.send_media.assert_not_awaited()
184
+ client.send_message.assert_awaited()
185
+ kwargs = client.send_message.await_args.kwargs
186
+ text = kwargs["text"]
187
+ self.assertNotIn(big_path, text)
188
+ self.assertNotIn(td, text)
189
+ self.assertIn("huge.bin", text)
190
+ self.assertIn("无法发到微信", text)
191
+
192
+ asyncio.run(_run())
193
+
194
+
195
+ class TestILinkDeliveryAccepted(unittest.TestCase):
196
+ def test_predicate(self):
197
+ from wechatbridge.ilink import ilink_delivery_accepted
198
+
199
+ self.assertTrue(ilink_delivery_accepted(0, ""))
200
+ self.assertTrue(ilink_delivery_accepted(0, None))
201
+ self.assertTrue(ilink_delivery_accepted(-1, "7487118974343175304"))
202
+ self.assertTrue(ilink_delivery_accepted(1, "abc"))
203
+ self.assertTrue(ilink_delivery_accepted(-1, 42)) # non-empty non-str id
204
+ self.assertFalse(ilink_delivery_accepted(-1, ""))
205
+ self.assertFalse(ilink_delivery_accepted(-1, " "))
206
+ self.assertFalse(ilink_delivery_accepted(1, None))
207
+ self.assertFalse(ilink_delivery_accepted(1, 0))
208
+
209
+
210
+ class TestILinkPostSendmessageRetry(unittest.IsolatedAsyncioTestCase):
211
+ async def asyncSetUp(self):
212
+ from wechatbridge.ilink import ILinkClient
213
+
214
+ self.client = ILinkClient()
215
+
216
+ async def asyncTearDown(self):
217
+ await self.client.http_client.aclose()
218
+
219
+ async def test_ret_minus_one_with_message_id_is_success(self):
220
+ mock_resp = MagicMock()
221
+ mock_resp.raise_for_status = MagicMock()
222
+ mock_resp.json.return_value = {"ret": -1, "message_id": "mid-ok-1"}
223
+ self.client.http_client.post = AsyncMock(return_value=mock_resp)
224
+
225
+ ok = await self.client._post_sendmessage_with_retry(
226
+ url="https://example.test/send",
227
+ headers={},
228
+ body={},
229
+ to_user_id="u1",
230
+ max_attempts=1,
231
+ )
232
+ self.assertTrue(ok)
233
+ self.client.http_client.post.assert_awaited_once()
234
+
235
+ async def test_ret_nonzero_without_message_id_fails_fast(self):
236
+ mock_resp = MagicMock()
237
+ mock_resp.raise_for_status = MagicMock()
238
+ mock_resp.json.return_value = {"ret": -1, "message_id": ""}
239
+ self.client.http_client.post = AsyncMock(return_value=mock_resp)
240
+
241
+ with mock.patch("wechatbridge.ilink.asyncio.sleep", new_callable=AsyncMock):
242
+ ok = await self.client._post_sendmessage_with_retry(
243
+ url="https://example.test/send",
244
+ headers={},
245
+ body={},
246
+ to_user_id="u1",
247
+ max_attempts=2,
248
+ )
249
+ self.assertFalse(ok)
250
+ self.assertEqual(self.client.http_client.post.await_count, 2)
251
+
252
+ async def test_ret_zero_succeeds(self):
253
+ mock_resp = MagicMock()
254
+ mock_resp.raise_for_status = MagicMock()
255
+ mock_resp.json.return_value = {"ret": 0, "message_id": "m0"}
256
+ self.client.http_client.post = AsyncMock(return_value=mock_resp)
257
+ ok = await self.client._post_sendmessage_with_retry(
258
+ url="https://example.test/send",
259
+ headers={},
260
+ body={},
261
+ to_user_id="u1",
262
+ max_attempts=1,
263
+ )
264
+ self.assertTrue(ok)
265
+
266
+
267
+ class TestInboundStreamCapLogic(unittest.TestCase):
268
+ def test_content_length_and_stream_abort_rules(self):
269
+ max_in = 100
270
+
271
+ def reject_cl(declared: int | None) -> bool:
272
+ return declared is not None and declared > max_in
273
+
274
+ def reject_buf(buf_len: int, piece_len: int) -> bool:
275
+ return buf_len + piece_len > max_in
276
+
277
+ self.assertTrue(reject_cl(101))
278
+ self.assertFalse(reject_cl(100))
279
+ self.assertFalse(reject_cl(None))
280
+ self.assertTrue(reject_buf(90, 20))
281
+ self.assertFalse(reject_buf(90, 10))
282
+
283
+
284
+ class TestSensitiveEnv(unittest.TestCase):
285
+ def test_strips_api_keys_keeps_harmless(self):
286
+ from wechatbridge.runner_common import _is_sensitive_env_name, sanitize_env
287
+
288
+ self.assertTrue(_is_sensitive_env_name("XAI_API_KEY"))
289
+ self.assertTrue(_is_sensitive_env_name("OPENAI_API_KEY"))
290
+ self.assertTrue(_is_sensitive_env_name("BOT_TOKEN"))
291
+ self.assertFalse(_is_sensitive_env_name("HOME"))
292
+ self.assertFalse(_is_sensitive_env_name("PATH"))
293
+ self.assertFalse(_is_sensitive_env_name("LANG"))
294
+
295
+ with tempfile.TemporaryDirectory() as td:
296
+ with mock.patch.dict(
297
+ os.environ,
298
+ {
299
+ "PATH": "/usr/bin",
300
+ "XAI_API_KEY": "secret",
301
+ "LANG": "C",
302
+ "HOME": "/tmp/other",
303
+ },
304
+ clear=False,
305
+ ):
306
+ env = sanitize_env(td)
307
+ self.assertEqual(env["HOME"], td)
308
+ self.assertNotIn("XAI_API_KEY", env)
309
+ self.assertIn("LANG", env)
310
+ self.assertEqual(env["LANG"], "C")
311
+
312
+
313
+ if __name__ == "__main__":
314
+ unittest.main()
@@ -1,2 +1,2 @@
1
1
  """WeChatBridge — bridge WeChat messages to agy or Grok Build CLIs."""
2
- __version__ = "1.3.2"
2
+ __version__ = "1.3.4"
@@ -117,7 +117,7 @@ async def run_agy(prompt: str, user_id: str, timeout: int = None) -> tuple[str,
117
117
  logger.warning("Prompt too large for argv from user %s", user_id)
118
118
  return format_error(
119
119
  "消息过长",
120
- f"单条消息超过 {_MAX_ARG_BYTES // 1024}KB 无法传给 CLI,请精简或分段发送。",
120
+ f"这条消息太长了(超过 {_MAX_ARG_BYTES // 1024}KB),请精简或分段发送。",
121
121
  ), []
122
122
 
123
123
  t0 = time.time()
@@ -252,8 +252,8 @@ async def run_agy(prompt: str, user_id: str, timeout: int = None) -> tuple[str,
252
252
  )
253
253
  await terminate_process(retry_process, graceful=True)
254
254
  return format_error(
255
- "级联超时",
256
- "模型 API 级联推理超时,自动重试仍超时。请稍后重试或简化指令。",
255
+ "模型响应超时",
256
+ "模型响应超时,自动重试仍超时。请稍后重试或简化指令。",
257
257
  ), []
258
258
  except (asyncio.CancelledError, Exception):
259
259
  await terminate_process(retry_process, graceful=False)
@@ -269,12 +269,12 @@ async def run_agy(prompt: str, user_id: str, timeout: int = None) -> tuple[str,
269
269
  mark_initialized(session_dir)
270
270
  return r_display, r_artifacts
271
271
  return format_error(
272
- "级联超时",
273
- "模型 API 级联推理超时,自动重试仍失败。请稍后重试或简化指令。",
272
+ "模型响应超时",
273
+ "模型响应超时,自动重试仍失败。请稍后重试或简化指令。",
274
274
  ), []
275
275
  return format_cli_error(stderr_text, backend="agy"), []
276
276
  # Non-zero exit: never treat raw stdout as a normal success reply
277
- raw = stderr_text or stdout_text or display or "agy 进程异常退出"
277
+ raw = stderr_text or stdout_text or display or "process exited abnormally"
278
278
  return format_cli_error(raw, backend="agy"), []
279
279
 
280
280
  # Success path only
@@ -305,7 +305,10 @@ async def run_agy(prompt: str, user_id: str, timeout: int = None) -> tuple[str,
305
305
  except Exception as e:
306
306
  logger.exception("Unexpected error running agy: %s", e)
307
307
  await terminate_process(process, graceful=False)
308
- return format_error("执行出错", "内部错误,详情见服务端日志。"), []
308
+ return format_error(
309
+ "执行出错",
310
+ "这次没处理好,请稍后再试。若一直失败,请联系管理员。",
311
+ ), []
309
312
 
310
313
 
311
314
  # ---------------------------------------------------------------------------
@@ -352,16 +355,19 @@ async def _run_agy_subcommand(subcmd_args: list, user_id: str) -> str:
352
355
  return clean_output(stdout_text) or EMPTY_REPLY
353
356
 
354
357
  except asyncio.TimeoutError:
355
- # 超时必须回收子进程,否则挂死的子命令成为孤儿进程
358
+ # 超时必须回收子进程,否则挂死的查询进程成为孤儿
356
359
  await terminate_process(process, graceful=True)
357
- return format_error("指令超时", "子命令 30 秒内未完成。")
360
+ return format_error("查询超时", "查询超时,请稍后再试。")
358
361
  except asyncio.CancelledError:
359
362
  await terminate_process(process, graceful=False)
360
363
  raise
361
364
  except Exception as e:
362
365
  logger.exception("Subcommand error: %s", e)
363
366
  await terminate_process(process, graceful=False)
364
- return format_error("执行出错", "内部错误,详情见服务端日志。")
367
+ return format_error(
368
+ "执行出错",
369
+ "这次没处理好,请稍后再试。若一直失败,请联系管理员。",
370
+ )
365
371
 
366
372
 
367
373
  def _cmd_help() -> str:
@@ -372,28 +378,28 @@ def _cmd_help() -> str:
372
378
  "**模型控制**",
373
379
  "- `/model <名称>` — 切换模型(用 `/models` 查看可用列表)",
374
380
  "- `/models` — 查看可用模型列表",
375
- "- `/backend <agy|grok>` — 切换后端 CLI",
381
+ "- `/backend <agy|grok>` — 切换助手引擎",
376
382
  "",
377
383
  "**对话控制**",
378
384
  "- `/clear` 或 `/new` — 重置对话(开始新会话)",
379
- "- `/fast` — 开启**快速模式**(低推理开销)",
380
- "- `/planning` — 开启 **planning 模式**",
385
+ "- `/fast` — 开启**快速模式**(回答更快,思考更少)",
386
+ "- `/planning` — 开启**规划模式**(先想清楚再动手)",
381
387
  "",
382
388
  "**工具**",
383
389
  "- `/add-dir <路径>` — 添加工作目录",
384
- "- `/agents` — 查看可用 agent",
390
+ "- `/agents` — 查看可用助手",
385
391
  "",
386
- "**MCP & 子代理**",
387
- "- `/mcp` — MCP 工具使用引导",
388
- "- `/agent <名称> <任务>` — 调用子代理执行任务",
392
+ "**扩展工具 & 子助手**",
393
+ "- `/mcp` — 扩展工具使用说明",
394
+ "- `/agent <名称> <任务>` — 调用子助手执行任务",
389
395
  "",
390
396
  "**人格**",
391
- "- `/persona <内容>` — 设置你专属的人格文档(支持 show / clear / reset 子命令)",
397
+ "- `/persona <内容>` — 设置你专属的人格文档(另有 show / clear / reset",
392
398
  "",
393
399
  "**其他**",
394
400
  "- `/help` — 显示本帮助",
395
401
  "",
396
- "提示:其他 `/` 指令(如 `/goal`、`/grill-me`、`/schedule` 等)会直接交给 agy 处理。",
402
+ "提示:其他 `/` 指令(如 `/goal`、`/grill-me`、`/schedule` 等)会直接交给助手处理。",
397
403
  ]
398
404
  return "\n".join(lines)
399
405
 
@@ -496,13 +502,13 @@ def _cmd_clear(user_id: str) -> str:
496
502
  def _cmd_fast(user_id: str) -> str:
497
503
  """Handle /fast: set effort=low (scoped to current backend)."""
498
504
  update_active_prefs(user_id, effort="low")
499
- return "✅ **已开启 fast 模式** ✅"
505
+ return "✅ **已开启快速模式** ✅"
500
506
 
501
507
 
502
508
  def _cmd_planning(user_id: str) -> str:
503
509
  """Handle /planning: set mode=plan (scoped to current backend)."""
504
510
  update_active_prefs(user_id, mode="plan")
505
- return "✅ **已开启 planning 模式** ✅"
511
+ return "✅ **已开启规划模式** ✅"
506
512
 
507
513
 
508
514
  def _cmd_add_dir(args: str, user_id: str) -> str:
@@ -627,8 +633,9 @@ async def handle_slash_command(text: str, user_id: str) -> str | None:
627
633
 
628
634
  if cmd == "/agents":
629
635
  output = await _run_agy_subcommand(["agents"], user_id)
630
- if not output or output == "Available agents:":
631
- output = "**Available agents**\n\n(当前没有自定义 agent。)"
636
+ # Match CLI empty output; rewrite user-facing wording only
637
+ if not output or output.strip() in ("Available agents:", "Available agents"):
638
+ output = "**可用助手**\n\n(当前没有自定义助手。)"
632
639
  return output
633
640
 
634
641
  if cmd == "/models":
@@ -642,12 +649,11 @@ async def handle_slash_command(text: str, user_id: str) -> str | None:
642
649
  if not config.enable_mcp:
643
650
  return "ℹ️ **该功能已禁用** ℹ️"
644
651
  return (
645
- "ℹ️ **MCP 工具使用引导** ℹ️\n\n"
646
- "agy 已配置 MCP server(ctxmode / codegraph)。\n\n"
647
- "使用方法:用自然语言描述调用,格式为:\n"
648
- "> 用 call_mcp_tool 调用 `<工具名>`,参数 `<json>`\n\n"
652
+ "ℹ️ **扩展工具说明** ℹ️\n\n"
653
+ "可以直接用自然语言让助手调用已配置的扩展工具(如代码检索等)。\n\n"
649
654
  "示例:\n"
650
- "> 用 codegraph 的 search 工具搜 ctxmode"
655
+ "> 用 codegraph 的 search 搜一下 ctxmode\n"
656
+ "> 帮我查一下这个项目里 xxx 怎么实现的"
651
657
  )
652
658
 
653
659
  # /agent 已上移到 main.py 统一处理(必须经过危险确认门,不能再绕过)
@@ -191,7 +191,8 @@ class AppConfig:
191
191
  # Maximum inbound image/file size after download (bytes) — default 20 MB
192
192
  max_inbound_file_bytes: int = _env_int("WECHATBRIDGE_MAX_INBOUND_BYTES", 20 * 1024 * 1024)
193
193
 
194
- # Max concurrent message handlers (global). Extra messages get a busy reply.
194
+ # Global concurrent process_message slots. Same user serializes first and
195
+ # does not hold a slot while waiting on their previous message; extras get a busy reply.
195
196
  max_concurrent_tasks: int = _env_int("WECHATBRIDGE_MAX_CONCURRENT", 4)
196
197
 
197
198
  # WeChat text chunk size (characters) when splitting long replies
@@ -372,7 +372,7 @@ async def run_grok(prompt: str, user_id: str, timeout: int = None) -> tuple:
372
372
  logger.warning("Prompt too large for argv from user %s", user_id)
373
373
  return format_error(
374
374
  "消息过长",
375
- f"单条消息超过 {_MAX_ARG_BYTES // 1024}KB 无法传给 CLI,请精简或分段发送。",
375
+ f"这条消息太长了(超过 {_MAX_ARG_BYTES // 1024}KB),请精简或分段发送。",
376
376
  ), []
377
377
 
378
378
  t0 = time.time()
@@ -432,7 +432,7 @@ async def run_grok(prompt: str, user_id: str, timeout: int = None) -> tuple:
432
432
  )
433
433
  artifacts = []
434
434
  if not (isinstance(display, str) and display.startswith("❌")):
435
- raw_err = stderr_text or ("" if display == EMPTY_REPLY else display) or "grok 进程异常退出"
435
+ raw_err = stderr_text or ("" if display == EMPTY_REPLY else display) or "process exited abnormally"
436
436
  display = format_cli_error(raw_err, backend="grok")
437
437
 
438
438
  # Only mark session initialized on a real successful reply
@@ -459,7 +459,10 @@ async def run_grok(prompt: str, user_id: str, timeout: int = None) -> tuple:
459
459
  except Exception as e:
460
460
  logger.exception("Unexpected error running grok: %s", e)
461
461
  await terminate_process(process, graceful=False)
462
- return format_error("执行出错", "内部错误,详情见服务端日志。"), []
462
+ return format_error(
463
+ "执行出错",
464
+ "这次没处理好,请稍后再试。若一直失败,请联系管理员。",
465
+ ), []
463
466
 
464
467
 
465
468
  async def _run_grok_subcommand(subcmd_args: list, user_id: str) -> str:
@@ -502,16 +505,19 @@ async def _run_grok_subcommand(subcmd_args: list, user_id: str) -> str:
502
505
  return clean_output(stdout_text) or EMPTY_REPLY
503
506
 
504
507
  except asyncio.TimeoutError:
505
- # 超时必须回收子进程,否则挂死的子命令成为孤儿进程
508
+ # 超时必须回收子进程,否则挂死的查询进程成为孤儿
506
509
  await terminate_process(process, graceful=True)
507
- return format_error("指令超时", "子命令 30 秒内未完成。")
510
+ return format_error("查询超时", "查询超时,请稍后再试。")
508
511
  except asyncio.CancelledError:
509
512
  await terminate_process(process, graceful=False)
510
513
  raise
511
514
  except Exception as e:
512
515
  logger.exception("Subcommand error: %s", e)
513
516
  await terminate_process(process, graceful=False)
514
- return format_error("执行出错", "内部错误,详情见服务端日志。")
517
+ return format_error(
518
+ "执行出错",
519
+ "这次没处理好,请稍后再试。若一直失败,请联系管理员。",
520
+ )
515
521
 
516
522
 
517
523
  # ---------------------------------------------------------------------------
@@ -582,24 +588,24 @@ def _cmd_help() -> str:
582
588
  "**模型控制**",
583
589
  "- `/model <名称>` — 切换模型(用 `/models` 查看可用列表)",
584
590
  "- `/models` — 查看可用模型列表",
585
- "- `/backend <agy|grok>` — 切换后端 CLI",
591
+ "- `/backend <agy|grok>` — 切换助手引擎",
586
592
  "",
587
593
  "**对话控制**",
588
594
  "- `/clear` 或 `/new` — 重置对话(开始新会话)",
589
- "- `/fast` — 开启**快速模式**(低推理开销)",
590
- "- `/planning` — 开启 **planning 模式**",
595
+ "- `/fast` — 开启**快速模式**(回答更快,思考更少)",
596
+ "- `/planning` — 开启**规划模式**(先想清楚再动手)",
591
597
  "",
592
598
  "**工具**",
593
- "- `/add-dir <路径>` — 添加工作目录(grok 后端暂不支持,仅记录)",
594
- "- `/agents` — 查看可用 agent",
599
+ "- `/add-dir <路径>` — 添加工作目录(当前 grok 引擎暂不支持,仅记录)",
600
+ "- `/agents` — 查看可用助手",
595
601
  "",
596
602
  "**人格**",
597
- "- `/persona <内容>` — 设置你专属的人格文档(支持 show / clear / reset 子命令)",
603
+ "- `/persona <内容>` — 设置你专属的人格文档(另有 show / clear / reset",
598
604
  "",
599
605
  "**其他**",
600
606
  "- `/help` — 显示本帮助",
601
607
  "",
602
- "提示:其他 `/` 指令会直接交给 grok 处理。",
608
+ "提示:其他 `/` 指令会直接交给助手处理。",
603
609
  ]
604
610
  return "\n".join(lines)
605
611
 
@@ -646,11 +652,11 @@ async def handle_grok_slash_command(text: str, user_id: str) -> str | None:
646
652
 
647
653
  if cmd == "/fast":
648
654
  update_active_prefs(user_id, effort="low")
649
- return "✅ **已开启 fast 模式** ✅"
655
+ return "✅ **已开启快速模式** ✅"
650
656
 
651
657
  if cmd == "/planning":
652
658
  update_active_prefs(user_id, mode="plan")
653
- return "✅ **已开启 planning 模式** ✅"
659
+ return "✅ **已开启规划模式** ✅"
654
660
 
655
661
  if cmd == "/model":
656
662
  return await _cmd_model(args, user_id)
@@ -671,7 +677,7 @@ async def handle_grok_slash_command(text: str, user_id: str) -> str | None:
671
677
  save_prefs(user_id, prefs)
672
678
  return (
673
679
  f"✅ **已记录工作目录** ✅\n\n```\n{resolved}\n```\n\n"
674
- "ℹ️ grok 后端暂不支持通过命令行传递额外目录。"
680
+ "ℹ️ 当前引擎暂不支持额外工作目录,路径已记下备用。"
675
681
  )
676
682
 
677
683
  if cmd == "/agents":
@@ -689,12 +695,11 @@ async def handle_grok_slash_command(text: str, user_id: str) -> str | None:
689
695
  if not config.enable_mcp:
690
696
  return "ℹ️ **该功能已禁用** ℹ️"
691
697
  return (
692
- "ℹ️ **MCP 工具使用引导** ℹ️\n\n"
693
- "grok 已配置 MCP server。\n\n"
694
- "使用方法:用自然语言描述调用,格式为:\n"
695
- "> 用 `<工具名>` 调用,参数 `<json>`\n\n"
698
+ "ℹ️ **扩展工具说明** ℹ️\n\n"
699
+ "可以直接用自然语言让助手调用已配置的扩展工具。\n\n"
696
700
  "示例:\n"
697
- "> 用 codegraph 的 search 工具搜 ctxmode"
701
+ "> 用 codegraph 的 search 搜一下 ctxmode\n"
702
+ "> 帮我查一下这个项目里 xxx 怎么实现的"
698
703
  )
699
704
 
700
705
  # /agent 已上移到 main.py 统一处理(必须经过危险确认门,不能再绕过)