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.
- {wechatbridge_cli-1.3.2/wechatbridge_cli.egg-info → wechatbridge_cli-1.3.4}/PKG-INFO +3 -3
- {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.4}/README.md +2 -2
- wechatbridge_cli-1.3.4/tests/test_hardening.py +314 -0
- {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.4}/wechatbridge/__init__.py +1 -1
- {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.4}/wechatbridge/agy.py +34 -28
- {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.4}/wechatbridge/config.py +2 -1
- {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.4}/wechatbridge/grok.py +26 -21
- {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.4}/wechatbridge/ilink.py +24 -6
- {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.4}/wechatbridge/main.py +101 -50
- {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.4}/wechatbridge/runner_common.py +114 -42
- {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.4/wechatbridge_cli.egg-info}/PKG-INFO +3 -3
- {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.4}/wechatbridge_cli.egg-info/SOURCES.txt +1 -0
- {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.4}/LICENSE +0 -0
- {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.4}/pyproject.toml +0 -0
- {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.4}/setup.cfg +0 -0
- {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.4}/wechatbridge/__main__.py +0 -0
- {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.4}/wechatbridge/update_check.py +0 -0
- {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.4}/wechatbridge_cli.egg-info/dependency_links.txt +0 -0
- {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.4}/wechatbridge_cli.egg-info/entry_points.txt +0 -0
- {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.4}/wechatbridge_cli.egg-info/requires.txt +0 -0
- {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.
|
|
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
|
|
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)
|
|
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
|
|
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)
|
|
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
|
+
__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"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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 "
|
|
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("
|
|
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>` —
|
|
381
|
+
"- `/backend <agy|grok>` — 切换助手引擎",
|
|
376
382
|
"",
|
|
377
383
|
"**对话控制**",
|
|
378
384
|
"- `/clear` 或 `/new` — 重置对话(开始新会话)",
|
|
379
|
-
"- `/fast` —
|
|
380
|
-
"- `/planning` —
|
|
385
|
+
"- `/fast` — 开启**快速模式**(回答更快,思考更少)",
|
|
386
|
+
"- `/planning` — 开启**规划模式**(先想清楚再动手)",
|
|
381
387
|
"",
|
|
382
388
|
"**工具**",
|
|
383
389
|
"- `/add-dir <路径>` — 添加工作目录",
|
|
384
|
-
"- `/agents` —
|
|
390
|
+
"- `/agents` — 查看可用助手",
|
|
385
391
|
"",
|
|
386
|
-
"
|
|
387
|
-
"- `/mcp` —
|
|
388
|
-
"- `/agent <名称> <任务>` —
|
|
392
|
+
"**扩展工具 & 子助手**",
|
|
393
|
+
"- `/mcp` — 扩展工具使用说明",
|
|
394
|
+
"- `/agent <名称> <任务>` — 调用子助手执行任务",
|
|
389
395
|
"",
|
|
390
396
|
"**人格**",
|
|
391
|
-
"- `/persona <内容>` —
|
|
397
|
+
"- `/persona <内容>` — 设置你专属的人格文档(另有 show / clear / reset)",
|
|
392
398
|
"",
|
|
393
399
|
"**其他**",
|
|
394
400
|
"- `/help` — 显示本帮助",
|
|
395
401
|
"",
|
|
396
|
-
"提示:其他 `/` 指令(如 `/goal`、`/grill-me`、`/schedule`
|
|
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 "✅
|
|
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 "✅
|
|
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
|
-
|
|
631
|
-
|
|
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
|
-
"ℹ️
|
|
646
|
-
"
|
|
647
|
-
"使用方法:用自然语言描述调用,格式为:\n"
|
|
648
|
-
"> 用 call_mcp_tool 调用 `<工具名>`,参数 `<json>`\n\n"
|
|
652
|
+
"ℹ️ **扩展工具说明** ℹ️\n\n"
|
|
653
|
+
"可以直接用自然语言让助手调用已配置的扩展工具(如代码检索等)。\n\n"
|
|
649
654
|
"示例:\n"
|
|
650
|
-
"> 用 codegraph 的 search
|
|
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
|
-
#
|
|
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"
|
|
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 "
|
|
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("
|
|
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>` —
|
|
591
|
+
"- `/backend <agy|grok>` — 切换助手引擎",
|
|
586
592
|
"",
|
|
587
593
|
"**对话控制**",
|
|
588
594
|
"- `/clear` 或 `/new` — 重置对话(开始新会话)",
|
|
589
|
-
"- `/fast` —
|
|
590
|
-
"- `/planning` —
|
|
595
|
+
"- `/fast` — 开启**快速模式**(回答更快,思考更少)",
|
|
596
|
+
"- `/planning` — 开启**规划模式**(先想清楚再动手)",
|
|
591
597
|
"",
|
|
592
598
|
"**工具**",
|
|
593
|
-
"- `/add-dir <路径>` —
|
|
594
|
-
"- `/agents` —
|
|
599
|
+
"- `/add-dir <路径>` — 添加工作目录(当前 grok 引擎暂不支持,仅记录)",
|
|
600
|
+
"- `/agents` — 查看可用助手",
|
|
595
601
|
"",
|
|
596
602
|
"**人格**",
|
|
597
|
-
"- `/persona <内容>` —
|
|
603
|
+
"- `/persona <内容>` — 设置你专属的人格文档(另有 show / clear / reset)",
|
|
598
604
|
"",
|
|
599
605
|
"**其他**",
|
|
600
606
|
"- `/help` — 显示本帮助",
|
|
601
607
|
"",
|
|
602
|
-
"提示:其他 `/`
|
|
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 "✅
|
|
655
|
+
return "✅ **已开启快速模式** ✅"
|
|
650
656
|
|
|
651
657
|
if cmd == "/planning":
|
|
652
658
|
update_active_prefs(user_id, mode="plan")
|
|
653
|
-
return "✅
|
|
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
|
-
"ℹ️
|
|
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
|
-
"ℹ️
|
|
693
|
-
"
|
|
694
|
-
"使用方法:用自然语言描述调用,格式为:\n"
|
|
695
|
-
"> 用 `<工具名>` 调用,参数 `<json>`\n\n"
|
|
698
|
+
"ℹ️ **扩展工具说明** ℹ️\n\n"
|
|
699
|
+
"可以直接用自然语言让助手调用已配置的扩展工具。\n\n"
|
|
696
700
|
"示例:\n"
|
|
697
|
-
"> 用 codegraph 的 search
|
|
701
|
+
"> 用 codegraph 的 search 搜一下 ctxmode\n"
|
|
702
|
+
"> 帮我查一下这个项目里 xxx 怎么实现的"
|
|
698
703
|
)
|
|
699
704
|
|
|
700
705
|
# /agent 已上移到 main.py 统一处理(必须经过危险确认门,不能再绕过)
|