wechatbridge-cli 1.3.0__tar.gz → 1.3.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wechatbridge_cli-1.3.2/PKG-INFO +287 -0
- {wechatbridge_cli-1.3.0 → wechatbridge_cli-1.3.2}/pyproject.toml +1 -0
- {wechatbridge_cli-1.3.0 → wechatbridge_cli-1.3.2}/wechatbridge/__init__.py +1 -1
- {wechatbridge_cli-1.3.0 → wechatbridge_cli-1.3.2}/wechatbridge/agy.py +45 -19
- {wechatbridge_cli-1.3.0 → wechatbridge_cli-1.3.2}/wechatbridge/grok.py +45 -24
- {wechatbridge_cli-1.3.0 → wechatbridge_cli-1.3.2}/wechatbridge/ilink.py +29 -15
- {wechatbridge_cli-1.3.0 → wechatbridge_cli-1.3.2}/wechatbridge/main.py +166 -18
- {wechatbridge_cli-1.3.0 → wechatbridge_cli-1.3.2}/wechatbridge/runner_common.py +14 -5
- wechatbridge_cli-1.3.2/wechatbridge_cli.egg-info/PKG-INFO +287 -0
- wechatbridge_cli-1.3.0/PKG-INFO +0 -29
- wechatbridge_cli-1.3.0/wechatbridge_cli.egg-info/PKG-INFO +0 -29
- {wechatbridge_cli-1.3.0 → wechatbridge_cli-1.3.2}/LICENSE +0 -0
- {wechatbridge_cli-1.3.0 → wechatbridge_cli-1.3.2}/README.md +0 -0
- {wechatbridge_cli-1.3.0 → wechatbridge_cli-1.3.2}/setup.cfg +0 -0
- {wechatbridge_cli-1.3.0 → wechatbridge_cli-1.3.2}/wechatbridge/__main__.py +0 -0
- {wechatbridge_cli-1.3.0 → wechatbridge_cli-1.3.2}/wechatbridge/config.py +0 -0
- {wechatbridge_cli-1.3.0 → wechatbridge_cli-1.3.2}/wechatbridge/update_check.py +0 -0
- {wechatbridge_cli-1.3.0 → wechatbridge_cli-1.3.2}/wechatbridge_cli.egg-info/SOURCES.txt +0 -0
- {wechatbridge_cli-1.3.0 → wechatbridge_cli-1.3.2}/wechatbridge_cli.egg-info/dependency_links.txt +0 -0
- {wechatbridge_cli-1.3.0 → wechatbridge_cli-1.3.2}/wechatbridge_cli.egg-info/entry_points.txt +0 -0
- {wechatbridge_cli-1.3.0 → wechatbridge_cli-1.3.2}/wechatbridge_cli.egg-info/requires.txt +0 -0
- {wechatbridge_cli-1.3.0 → wechatbridge_cli-1.3.2}/wechatbridge_cli.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wechatbridge-cli
|
|
3
|
+
Version: 1.3.2
|
|
4
|
+
Summary: Bridge WeChat messages to agy or Grok Build CLIs — text/image/file/voice in, CLI replies and generated files back.
|
|
5
|
+
Author: WeChatBridge contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/dorokuma/wechatbridge
|
|
8
|
+
Project-URL: Repository, https://github.com/dorokuma/wechatbridge
|
|
9
|
+
Project-URL: Issues, https://github.com/dorokuma/wechatbridge/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/dorokuma/wechatbridge/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: wechat,bot,bridge,agy,grok
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Natural Language :: Chinese (Simplified)
|
|
17
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Topic :: Communications :: Chat
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: httpx
|
|
27
|
+
Requires-Dist: qrcode
|
|
28
|
+
Requires-Dist: Pillow
|
|
29
|
+
Requires-Dist: cryptography
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# WeChatBridge
|
|
33
|
+
|
|
34
|
+
[English](README.md) | [简体中文](README.zh-CN.md)
|
|
35
|
+
|
|
36
|
+

|
|
37
|
+

|
|
38
|
+
|
|
39
|
+
WeChatBridge connects a WeChat bot to agentic coding CLIs (Google's agy / Antigravity, or xAI's Grok Build). From WeChat you can send text, images, files, and voice-as-text to the active CLI, get replies back, and receive certain generated files over the WeChat CDN. Switch backends per user with `/backend` — no restart.
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
WeChat (phone) ⇄ iLink bot API ⇄ WeChatBridge ⇄ agy / grok CLI
|
|
43
|
+
(this project) (runs tools)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The bridge process stays up and long-polls iLink. For prompts that go to a CLI, it spawns one `agy` or `grok` child (`-p` single-turn) and exits that child when done — the child does not stay resident. Many slash commands (`/help`, `/backend`, `/persona`, …) are handled inside the bridge and never start a CLI. Only artifacts the bridge can detect under the user's allowed session paths are pushed back via CDN.
|
|
47
|
+
|
|
48
|
+
## Features
|
|
49
|
+
|
|
50
|
+
- Text, image, file, and voice (WeChat server-side transcription only) go to the **active** backend (`agy` or `grok`)
|
|
51
|
+
- Detected CLI artifacts under the per-user allowed tree can be sent back (size-capped); not every file the CLI touches
|
|
52
|
+
- Each WeChat user gets an isolated workspace; model / effort / mode are remembered **per backend**
|
|
53
|
+
- Runtime backend switch: `/backend agy` or `/backend grok` (clears the “continue session” flag so the next CLI turn starts without `-c` / `--continue`; does not immediately wipe history files on disk)
|
|
54
|
+
- Slash commands for model, session reset, persona, and more (see below)
|
|
55
|
+
- Dangerous-prompt gate: a **keyword list** of concrete destructive patterns asks for confirmation before run
|
|
56
|
+
- Sender whitelist (`WECHATBRIDGE_ALLOWED_SENDERS`; empty = allow all)
|
|
57
|
+
- `/mcp` returns short usage text; `/agent` rewrites into a natural-language subagent prompt for the CLI (not a native MCP bridge)
|
|
58
|
+
- Media over WeChat CDN with AES-128-ECB encrypt/decrypt
|
|
59
|
+
- Multi-instance: one codebase, set `WECHATBRIDGE_INSTANCE` per process (state / session / QR paths derive from it)
|
|
60
|
+
- Deploy templates: systemd (Linux), launchd (macOS), Task Scheduler notes (Windows)
|
|
61
|
+
|
|
62
|
+
## Platform Support
|
|
63
|
+
|
|
64
|
+
- **Linux** — primary (systemd unit included)
|
|
65
|
+
- **macOS** — supported (launchd plist included)
|
|
66
|
+
- **Windows** — supported (Task Scheduler guide included)
|
|
67
|
+
|
|
68
|
+
Default data paths expand from `~` (e.g. `~/.local/share/wechatbridge/<instance>/`).
|
|
69
|
+
|
|
70
|
+
## CLI Backends
|
|
71
|
+
|
|
72
|
+
- **agy** (default) — Google Antigravity CLI
|
|
73
|
+
- **grok** — xAI Grok Build CLI
|
|
74
|
+
|
|
75
|
+
Per-user switch: `/backend agy` or `/backend grok`. Each backend keeps its own model / effort / mode memory and persona file layout. Global default is `WECHATBRIDGE_BACKEND`.
|
|
76
|
+
|
|
77
|
+
## Prerequisites
|
|
78
|
+
|
|
79
|
+
- At least one CLI installed and signed in:
|
|
80
|
+
- **agy** on `PATH`, or set `AGY_BIN_PATH`
|
|
81
|
+
- **and/or grok** on `PATH`, or set `GROK_BIN_PATH`
|
|
82
|
+
- Antigravity is Google's terminal agentic coding CLI (successor to Gemini CLI). Grok Build is xAI's counterpart.
|
|
83
|
+
- A WeChat account with a [ClawBot / iLink](https://ilinkai.weixin.qq.com) bot (QR bind on first run)
|
|
84
|
+
- Python 3.10+
|
|
85
|
+
|
|
86
|
+
## Install
|
|
87
|
+
|
|
88
|
+
The recommended way is with [pipx](https://pypa.github.io/pipx/) (Python >= 3.10 required):
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
pipx install wechatbridge-cli
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
After installation, verify:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
wechatbridge --version
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Install pipx
|
|
101
|
+
|
|
102
|
+
**Debian / Ubuntu:**
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
sudo apt install pipx
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**Other systems (or to get the latest version):**
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
python3 -m pip install --user pipx && python3 -m pipx ensurepath
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Then start a new shell or re-source your shell config so `pipx` is on `PATH`.
|
|
115
|
+
|
|
116
|
+
### Developers
|
|
117
|
+
|
|
118
|
+
If you want to hack on the source:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
git clone https://github.com/dorokuma/wechatbridge.git
|
|
122
|
+
cd wechatbridge
|
|
123
|
+
pip install -e .
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Configure
|
|
127
|
+
|
|
128
|
+
Configuration is loaded from the first location found:
|
|
129
|
+
|
|
130
|
+
1. `$WECHATBRIDGE_ENV_FILE` — explicit path
|
|
131
|
+
2. `$XDG_CONFIG_HOME/wechatbridge/<instance>.env` (defaults to `~/.config/wechatbridge/<instance>.env`)
|
|
132
|
+
3. `$XDG_CONFIG_HOME/wechatbridge/.env` (defaults to `~/.config/wechatbridge/.env`)
|
|
133
|
+
4. `.env` in the repository root — **deprecated** (prints a warning on startup)
|
|
134
|
+
|
|
135
|
+
The instance name defaults to `default`; override with `WECHATBRIDGE_INSTANCE`.
|
|
136
|
+
|
|
137
|
+
Get the example config:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
mkdir -p ~/.config/wechatbridge
|
|
141
|
+
curl -o ~/.config/wechatbridge/.env https://raw.githubusercontent.com/dorokuma/wechatbridge/main/deploy/wechatbridge.env.example
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Then edit `~/.config/wechatbridge/.env` with your settings.
|
|
145
|
+
|
|
146
|
+
Key variables (all have defaults):
|
|
147
|
+
|
|
148
|
+
| Variable | Default | Purpose |
|
|
149
|
+
|---|---|---|
|
|
150
|
+
| `AGY_BIN_PATH` | `agy` | path to the agy binary |
|
|
151
|
+
| `GROK_BIN_PATH` | `grok` | path to the grok binary |
|
|
152
|
+
| `WECHATBRIDGE_BACKEND` | `agy` | global default backend (`agy` / `grok`; overridable per user via `/backend`) |
|
|
153
|
+
| `WECHATBRIDGE_INSTANCE` | `default` | instance name; state / session / QR paths derive from it |
|
|
154
|
+
| `WECHATBRIDGE_ALLOWED_SENDERS` | _empty_ | comma-separated WeChat IDs (empty = allow all) |
|
|
155
|
+
| `AGY_TIMEOUT` | `600` | CLI run timeout in seconds (both backends) |
|
|
156
|
+
| `WECHATBRIDGE_MAX_OUTBOUND_BYTES` | `104857600` | max file size sent back to WeChat (100 MB) |
|
|
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 |
|
|
159
|
+
| `WECHATBRIDGE_CONFIRM_TOKEN` | `y` | reply this token to approve a gated dangerous prompt |
|
|
160
|
+
| `WECHATBRIDGE_ENABLE_MCP` | `true` | enable the `/mcp` help text command |
|
|
161
|
+
| `WECHATBRIDGE_ENABLE_SUBAGENT` | `true` | enable the `/agent` prompt-rewrite command |
|
|
162
|
+
| `WECHATBRIDGE_ADMINS` | _empty_ | comma-separated wxid list; admins receive WeChat notification when a new version is detected |
|
|
163
|
+
| `WECHATBRIDGE_UPDATE_CHECK` | `true` | check PyPI for new versions on startup and every 24h; failures are silent |
|
|
164
|
+
| `WECHATBRIDGE_UPDATE_CHECK_INTERVAL` | `86400` | update check interval in seconds |
|
|
165
|
+
|
|
166
|
+
Full list: [`deploy/wechatbridge.env.example`](deploy/wechatbridge.env.example).
|
|
167
|
+
|
|
168
|
+
> **Why the new config location?** With pipx the package is installed globally, so a `.env` next to the source no longer makes sense. The XDG base directory layout keeps your config separate and instance-aware.
|
|
169
|
+
|
|
170
|
+
## Run
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
wechatbridge
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
On first run the bridge prints a QR code (and saves PNG under the instance data dir). Scan with WeChat to bind, then it long-polls for messages.
|
|
177
|
+
|
|
178
|
+
## Upgrading
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
pipx upgrade wechatbridge-cli
|
|
182
|
+
sudo systemctl restart wechatbridge
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Or run the upgrade script (no clone needed — fetch it with curl):
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
curl -fsSL https://raw.githubusercontent.com/dorokuma/wechatbridge/main/deploy/update.sh | sudo bash
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
The script upgrades the pipx installation and restarts the service. If the service runs as a dedicated system user (e.g. `wechatbridge`), running as root automatically runs pipx as that user (override with `WECHATBRIDGE_USER=<user>`).
|
|
192
|
+
|
|
193
|
+
Data lives under `~/.local/share/wechatbridge/<instance>/` (sessions, SQLite history, QR codes, login state) and is **not** touched during upgrade — your bots stay logged in and conversations are preserved.
|
|
194
|
+
|
|
195
|
+
Before upgrading a **major** or **minor** version (e.g. 1.2 → 1.3), check the corresponding section in [`CHANGELOG.md`](CHANGELOG.md) for breaking changes and migration steps.
|
|
196
|
+
|
|
197
|
+
## Deploy
|
|
198
|
+
|
|
199
|
+
### Linux (systemd)
|
|
200
|
+
|
|
201
|
+
First, install the bridge under the `wechatbridge` system user:
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
sudo -u wechatbridge pipx install wechatbridge-cli
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Then deploy the service unit:
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
sudo cp deploy/wechatbridge.service /etc/systemd/system/
|
|
211
|
+
sudo systemctl enable --now wechatbridge
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
**Multi-instance:** copy the template `deploy/wechatbridge@.service` and enable instances:
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
sudo cp deploy/wechatbridge@.service /etc/systemd/system/
|
|
218
|
+
sudo systemctl enable --now wechatbridge@bot2
|
|
219
|
+
sudo systemctl enable --now wechatbridge@bot3
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Each instance reads its own config file (`~/.config/wechatbridge/bot2.env`) and keeps state under its own data directory (`~/.local/share/wechatbridge/bot2/`).
|
|
223
|
+
|
|
224
|
+
### macOS (launchd)
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
cp deploy/wechatbridge.plist ~/Library/LaunchAgents/com.wechatbridge.plist
|
|
228
|
+
# edit WorkingDirectory and ProgramArguments in the plist
|
|
229
|
+
launchctl load ~/Library/LaunchAgents/com.wechatbridge.plist
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### Windows (Task Scheduler)
|
|
233
|
+
|
|
234
|
+
See [`deploy/wechatbridge-windows.md`](deploy/wechatbridge-windows.md).
|
|
235
|
+
|
|
236
|
+
## Slash commands
|
|
237
|
+
|
|
238
|
+
| Command | Action |
|
|
239
|
+
|---|---|
|
|
240
|
+
| `/help` | list supported commands for the active backend |
|
|
241
|
+
| `/backend <agy\|grok>` | switch CLI backend for this WeChat user (on real change: drop continue flag; history files may remain until retention cleanup) |
|
|
242
|
+
| `/clear` or `/new` | drop continue flag so the next CLI turn is a new conversation (does not instantly delete history files) |
|
|
243
|
+
| `/model <name>` | set model (validated against the backend's model list; see `/models`) |
|
|
244
|
+
| `/models` | list models from the active CLI |
|
|
245
|
+
| `/fast` | set low reasoning effort (**on only** — not a toggle; no “off” command) |
|
|
246
|
+
| `/planning` | set planning mode (**on only** — not a toggle) |
|
|
247
|
+
| `/add-dir <path>` | **agy:** pass `--add-dir` on later runs if path is allowed. **grok:** recorded only; not passed to the CLI yet |
|
|
248
|
+
| `/agents` | list agents via the active CLI |
|
|
249
|
+
| `/persona <text>` | set persona (`show` / `clear` / `reset` subcommands) |
|
|
250
|
+
| `/version` | show current version, instance name, and backend; if a newer version is available, show upgrade hint |
|
|
251
|
+
| `/mcp` | short MCP **usage hint** text (can disable with `WECHATBRIDGE_ENABLE_MCP`) |
|
|
252
|
+
| `/agent <name> <task>` | craft a "invoke subagent …" prompt and run the CLI (can disable with `WECHATBRIDGE_ENABLE_SUBAGENT`) |
|
|
253
|
+
|
|
254
|
+
Other `/…` commands are either rejected (e.g. `/exit`), reported as unsupported on WeChat (TUI-only panels), or passed through to the active CLI.
|
|
255
|
+
|
|
256
|
+
`/add-dir` only accepts paths under the user's session directory or roots listed in `WECHATBRIDGE_ADD_DIR_ROOTS`.
|
|
257
|
+
|
|
258
|
+
## Ops & security (what the bridge actually enforces)
|
|
259
|
+
|
|
260
|
+
- **Whitelist first.** Empty `WECHATBRIDGE_ALLOWED_SENDERS` means anyone who can message the bot can use it.
|
|
261
|
+
- **Auto-approve CLIs.** agy runs with `--dangerously-skip-permissions`; grok with `--always-approve` (unless planning mode). Treat this as trusted-user tooling, not a multi-tenant sandbox.
|
|
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
|
+
- **Inbound media** is size-capped (default 20 MB), streamed, and CDN hosts are allowlisted. Missing `aes_key` returns a clear error.
|
|
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.
|
|
266
|
+
- **Long replies** are split into chunks (`WECHATBRIDGE_MESSAGE_CHUNK`, default 2000 characters).
|
|
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
|
+
- **Retention:** session temps vs dialogue history use separate TTLs (`WECHATBRIDGE_SESSION_RETENTION_DAYS`, `WECHATBRIDGE_HISTORY_RETENTION_DAYS`). Prefs/auth are kept.
|
|
269
|
+
- **Child env** is sanitized (strips common secret-style variable names) and points `HOME` (and `USERPROFILE` on Windows) at the per-user session dir.
|
|
270
|
+
|
|
271
|
+
## Limitations
|
|
272
|
+
|
|
273
|
+
- Not a standalone agent — requires agy and/or grok.
|
|
274
|
+
- Voice is WeChat speech-to-text only; no local ASR; empty transcript → “type instead”.
|
|
275
|
+
- No video send/receive; no native WeChat voice-bubble replies (no silk encode).
|
|
276
|
+
- One WeChat binding per process; multiple accounts need multiple instances (`WECHATBRIDGE_INSTANCE`).
|
|
277
|
+
- Artifact send-back is best-effort detection under allowed paths, not “every file the CLI created anywhere”.
|
|
278
|
+
- `/mcp` / `/agent` do not implement MCP protocol or spawn process supervisors inside the bridge — they only guide or rephrase for the CLI.
|
|
279
|
+
- Deploy only for trusted users behind a whitelist when possible.
|
|
280
|
+
|
|
281
|
+
## Contributing
|
|
282
|
+
|
|
283
|
+
See [`CONTRIBUTING.md`](CONTRIBUTING.md). Semantic Versioning from 1.0.0; record changes in [`CHANGELOG.md`](CHANGELOG.md).
|
|
284
|
+
|
|
285
|
+
## License
|
|
286
|
+
|
|
287
|
+
MIT. See [`LICENSE`](LICENSE).
|
|
@@ -3,6 +3,7 @@ name = "wechatbridge-cli"
|
|
|
3
3
|
dynamic = ["version"]
|
|
4
4
|
description = "Bridge WeChat messages to agy or Grok Build CLIs — text/image/file/voice in, CLI replies and generated files back."
|
|
5
5
|
requires-python = ">=3.10"
|
|
6
|
+
readme = "README.md"
|
|
6
7
|
license = { text = "MIT" }
|
|
7
8
|
urls = { Homepage = "https://github.com/dorokuma/wechatbridge", Repository = "https://github.com/dorokuma/wechatbridge", Issues = "https://github.com/dorokuma/wechatbridge/issues", Changelog = "https://github.com/dorokuma/wechatbridge/blob/main/CHANGELOG.md" }
|
|
8
9
|
authors = [
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""WeChatBridge — bridge WeChat messages to agy or Grok Build CLIs."""
|
|
2
|
-
__version__ = "1.3.
|
|
2
|
+
__version__ = "1.3.2"
|
|
@@ -23,6 +23,9 @@ from .runner_common import (
|
|
|
23
23
|
|
|
24
24
|
logger = logging.getLogger("agy_runner")
|
|
25
25
|
|
|
26
|
+
# execve 单参数上限(Linux MAX_ARG_STRLEN = 128KB),留安全余量
|
|
27
|
+
_MAX_ARG_BYTES = 120 * 1024
|
|
28
|
+
|
|
26
29
|
|
|
27
30
|
def extract_artifacts(text: str) -> list[tuple[str, str]]:
|
|
28
31
|
"""Extract (name, absolute_path) tuples from markdown file:/// links.
|
|
@@ -109,6 +112,14 @@ async def run_agy(prompt: str, user_id: str, timeout: int = None) -> tuple[str,
|
|
|
109
112
|
if timeout is None:
|
|
110
113
|
timeout = config.agy_timeout
|
|
111
114
|
|
|
115
|
+
# argv 单参数上限约 128KB(MAX_ARG_STRLEN),超长 prompt 直接拒绝,避免 E2BIG
|
|
116
|
+
if len(prompt.encode("utf-8", errors="replace")) > _MAX_ARG_BYTES:
|
|
117
|
+
logger.warning("Prompt too large for argv from user %s", user_id)
|
|
118
|
+
return format_error(
|
|
119
|
+
"消息过长",
|
|
120
|
+
f"单条消息超过 {_MAX_ARG_BYTES // 1024}KB 无法传给 CLI,请精简或分段发送。",
|
|
121
|
+
), []
|
|
122
|
+
|
|
112
123
|
t0 = time.time()
|
|
113
124
|
session_dir = ensure_user_gemini(user_id)
|
|
114
125
|
|
|
@@ -228,10 +239,25 @@ async def run_agy(prompt: str, user_id: str, timeout: int = None) -> tuple[str,
|
|
|
228
239
|
env=env,
|
|
229
240
|
preexec_fn=os.setsid if hasattr(os, "setsid") else None,
|
|
230
241
|
)
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
242
|
+
try:
|
|
243
|
+
r_stdout, r_stderr = await asyncio.wait_for(
|
|
244
|
+
retry_process.communicate(),
|
|
245
|
+
timeout=float(timeout),
|
|
246
|
+
)
|
|
247
|
+
except asyncio.TimeoutError:
|
|
248
|
+
# 重试进程也必须回收,否则超时后成为孤儿进程
|
|
249
|
+
logger.warning(
|
|
250
|
+
"agy retry timed out after %ss for user %s, terminating retry process",
|
|
251
|
+
timeout, user_id,
|
|
252
|
+
)
|
|
253
|
+
await terminate_process(retry_process, graceful=True)
|
|
254
|
+
return format_error(
|
|
255
|
+
"级联超时",
|
|
256
|
+
"模型 API 级联推理超时,自动重试仍超时。请稍后重试或简化指令。",
|
|
257
|
+
), []
|
|
258
|
+
except (asyncio.CancelledError, Exception):
|
|
259
|
+
await terminate_process(retry_process, graceful=False)
|
|
260
|
+
raise
|
|
235
261
|
r_stdout_text = r_stdout.decode("utf-8", errors="replace").strip()
|
|
236
262
|
r_stderr_text = r_stderr.decode("utf-8", errors="replace").strip()
|
|
237
263
|
# Any useful stdout after retry counts as recovered (agy may exit non-zero)
|
|
@@ -271,10 +297,15 @@ async def run_agy(prompt: str, user_id: str, timeout: int = None) -> tuple[str,
|
|
|
271
297
|
await terminate_process(process, graceful=True)
|
|
272
298
|
return format_error("处理超时", f"超过 {timeout} 秒未完成,已终止本次任务。"), []
|
|
273
299
|
|
|
300
|
+
except asyncio.CancelledError:
|
|
301
|
+
# 任务被取消(如重登录前排空):必须杀掉子进程再传递取消
|
|
302
|
+
await terminate_process(process, graceful=False)
|
|
303
|
+
raise
|
|
304
|
+
|
|
274
305
|
except Exception as e:
|
|
275
306
|
logger.exception("Unexpected error running agy: %s", e)
|
|
276
307
|
await terminate_process(process, graceful=False)
|
|
277
|
-
return format_error("执行出错",
|
|
308
|
+
return format_error("执行出错", "内部错误,详情见服务端日志。"), []
|
|
278
309
|
|
|
279
310
|
|
|
280
311
|
# ---------------------------------------------------------------------------
|
|
@@ -290,6 +321,7 @@ async def _run_agy_subcommand(subcmd_args: list, user_id: str) -> str:
|
|
|
290
321
|
"""
|
|
291
322
|
session_dir = ensure_user_gemini(user_id)
|
|
292
323
|
cmd = [config.agy_binary_path] + subcmd_args
|
|
324
|
+
process = None
|
|
293
325
|
try:
|
|
294
326
|
env = sanitize_env(session_dir)
|
|
295
327
|
process = await asyncio.create_subprocess_exec(
|
|
@@ -320,10 +352,16 @@ async def _run_agy_subcommand(subcmd_args: list, user_id: str) -> str:
|
|
|
320
352
|
return clean_output(stdout_text) or EMPTY_REPLY
|
|
321
353
|
|
|
322
354
|
except asyncio.TimeoutError:
|
|
355
|
+
# 超时必须回收子进程,否则挂死的子命令成为孤儿进程
|
|
356
|
+
await terminate_process(process, graceful=True)
|
|
323
357
|
return format_error("指令超时", "子命令 30 秒内未完成。")
|
|
358
|
+
except asyncio.CancelledError:
|
|
359
|
+
await terminate_process(process, graceful=False)
|
|
360
|
+
raise
|
|
324
361
|
except Exception as e:
|
|
325
362
|
logger.exception("Subcommand error: %s", e)
|
|
326
|
-
|
|
363
|
+
await terminate_process(process, graceful=False)
|
|
364
|
+
return format_error("执行出错", "内部错误,详情见服务端日志。")
|
|
327
365
|
|
|
328
366
|
|
|
329
367
|
def _cmd_help() -> str:
|
|
@@ -612,19 +650,7 @@ async def handle_slash_command(text: str, user_id: str) -> str | None:
|
|
|
612
650
|
"> 用 codegraph 的 search 工具搜 ctxmode"
|
|
613
651
|
)
|
|
614
652
|
|
|
615
|
-
|
|
616
|
-
if not config.enable_subagent:
|
|
617
|
-
return "ℹ️ **该功能已禁用** ℹ️"
|
|
618
|
-
if not args:
|
|
619
|
-
return "❌ **缺少参数** ❌\n\n`/agent <名称> <任务>`"
|
|
620
|
-
# Construct prompt and run through agy
|
|
621
|
-
agent_parts = args.split(maxsplit=1)
|
|
622
|
-
agent_name = agent_parts[0]
|
|
623
|
-
agent_task = agent_parts[1] if len(agent_parts) > 1 else ""
|
|
624
|
-
crafted = f"请用 invoke_subagent 调用 agent {agent_name} 执行任务:{agent_task}"
|
|
625
|
-
logger.info("Agent subcmd: user=%s agent=%s task=%.100s", user_id, agent_name, agent_task)
|
|
626
|
-
result_text, _ = await run_agy(crafted, user_id)
|
|
627
|
-
return result_text
|
|
653
|
+
# /agent 已上移到 main.py 统一处理(必须经过危险确认门,不能再绕过)
|
|
628
654
|
|
|
629
655
|
# --- D class: passthrough to agy (return None so caller runs run_agy) ---
|
|
630
656
|
return None
|
|
@@ -23,6 +23,9 @@ from .runner_common import (
|
|
|
23
23
|
|
|
24
24
|
logger = logging.getLogger("grok_runner")
|
|
25
25
|
|
|
26
|
+
# execve 单参数上限(Linux MAX_ARG_STRLEN = 128KB),留安全余量
|
|
27
|
+
_MAX_ARG_BYTES = 120 * 1024
|
|
28
|
+
|
|
26
29
|
|
|
27
30
|
# ---------------------------------------------------------------------------
|
|
28
31
|
# Per-user .grok directory setup
|
|
@@ -250,13 +253,16 @@ def _build_grok_command(prompt: str, prefs: dict, first: bool, persona_content:
|
|
|
250
253
|
# Artifact extraction from chat_history.jsonl
|
|
251
254
|
# ---------------------------------------------------------------------------
|
|
252
255
|
|
|
253
|
-
def _extract_grok_artifacts(session_dir: str, session_id: str) -> list:
|
|
256
|
+
def _extract_grok_artifacts(session_dir: str, session_id: str, since: float = 0.0) -> list:
|
|
254
257
|
"""Extract (name, abs_path) tuples from grok session chat_history.jsonl.
|
|
255
258
|
|
|
256
259
|
grok stores sessions under $HOME/.grok/sessions/<url-encoded-cwd>/<session-id>/.
|
|
257
260
|
The chat_history.jsonl contains structured tool_calls with file_path arguments
|
|
258
261
|
from write/edit operations.
|
|
259
262
|
|
|
263
|
+
``since``: 只收录 mtime >= since 的文件——chat_history.jsonl 跨轮累积,
|
|
264
|
+
不过滤的话 --continue 会话每轮都会把历史文件重发一遍。
|
|
265
|
+
|
|
260
266
|
Falls back to empty list on any error (never blocks text reply).
|
|
261
267
|
"""
|
|
262
268
|
grok_sessions = os.path.join(session_dir, ".grok", "sessions")
|
|
@@ -292,6 +298,12 @@ def _extract_grok_artifacts(session_dir: str, session_id: str) -> list:
|
|
|
292
298
|
if name in ("write", "edit", "str_replace") and isinstance(args, dict):
|
|
293
299
|
fp = args.get("file_path", "")
|
|
294
300
|
if fp and os.path.isabs(fp):
|
|
301
|
+
# 只收录本轮运行期间新写/修改的文件
|
|
302
|
+
try:
|
|
303
|
+
if since and os.path.getmtime(fp) < since - 2.0:
|
|
304
|
+
continue
|
|
305
|
+
except OSError:
|
|
306
|
+
continue # 文件已不存在,无需回传
|
|
295
307
|
art_name = os.path.basename(fp)
|
|
296
308
|
key = (art_name, fp)
|
|
297
309
|
if key not in seen:
|
|
@@ -305,7 +317,7 @@ def _extract_grok_artifacts(session_dir: str, session_id: str) -> list:
|
|
|
305
317
|
return artifacts
|
|
306
318
|
|
|
307
319
|
|
|
308
|
-
def _parse_grok_output(stdout_text: str, session_dir: str) -> tuple:
|
|
320
|
+
def _parse_grok_output(stdout_text: str, session_dir: str, since: float = 0.0) -> tuple:
|
|
309
321
|
"""Parse grok JSON output into (display_text, artifacts).
|
|
310
322
|
|
|
311
323
|
Handles both success JSON ({text, sessionId, ...}) and error JSON
|
|
@@ -330,7 +342,7 @@ def _parse_grok_output(stdout_text: str, session_dir: str) -> tuple:
|
|
|
330
342
|
|
|
331
343
|
artifacts = []
|
|
332
344
|
if session_id:
|
|
333
|
-
artifacts = _extract_grok_artifacts(session_dir, session_id)
|
|
345
|
+
artifacts = _extract_grok_artifacts(session_dir, session_id, since=since)
|
|
334
346
|
|
|
335
347
|
# Strip file:/// links from display (in case grok emits them)
|
|
336
348
|
display = re.sub(
|
|
@@ -355,6 +367,14 @@ async def run_grok(prompt: str, user_id: str, timeout: int = None) -> tuple:
|
|
|
355
367
|
if timeout is None:
|
|
356
368
|
timeout = config.agy_timeout
|
|
357
369
|
|
|
370
|
+
# argv 单参数上限约 128KB(MAX_ARG_STRLEN),超长 prompt 直接拒绝,避免 E2BIG
|
|
371
|
+
if len(prompt.encode("utf-8", errors="replace")) > _MAX_ARG_BYTES:
|
|
372
|
+
logger.warning("Prompt too large for argv from user %s", user_id)
|
|
373
|
+
return format_error(
|
|
374
|
+
"消息过长",
|
|
375
|
+
f"单条消息超过 {_MAX_ARG_BYTES // 1024}KB 无法传给 CLI,请精简或分段发送。",
|
|
376
|
+
), []
|
|
377
|
+
|
|
358
378
|
t0 = time.time()
|
|
359
379
|
session_dir = ensure_user_grok(user_id)
|
|
360
380
|
|
|
@@ -398,22 +418,22 @@ async def run_grok(prompt: str, user_id: str, timeout: int = None) -> tuple:
|
|
|
398
418
|
stdout_text = stdout_bytes.decode("utf-8", errors="replace").strip()
|
|
399
419
|
stderr_text = stderr_bytes.decode("utf-8", errors="replace").strip()
|
|
400
420
|
|
|
401
|
-
display, artifacts = _parse_grok_output(stdout_text, session_dir)
|
|
421
|
+
display, artifacts = _parse_grok_output(stdout_text, session_dir, since=t0)
|
|
402
422
|
|
|
403
|
-
# Failure detection:
|
|
423
|
+
# Failure detection: 非零退出一律视为失败(与 agy 行为对齐),
|
|
424
|
+
# 零退出但 stdout 是结构化错误(已格式化为 ❌ 前缀)也算失败
|
|
404
425
|
failed = process.returncode != 0 or (
|
|
405
426
|
isinstance(display, str) and display.startswith("❌")
|
|
406
427
|
)
|
|
407
|
-
if process.returncode != 0
|
|
428
|
+
if process.returncode != 0:
|
|
408
429
|
logger.warning(
|
|
409
430
|
"grok exited with code %s for user %s: %.200s",
|
|
410
431
|
process.returncode, user_id, stderr_text,
|
|
411
432
|
)
|
|
412
|
-
display = format_cli_error(
|
|
413
|
-
stderr_text or "grok 进程异常退出", backend="grok"
|
|
414
|
-
)
|
|
415
433
|
artifacts = []
|
|
416
|
-
|
|
434
|
+
if not (isinstance(display, str) and display.startswith("❌")):
|
|
435
|
+
raw_err = stderr_text or ("" if display == EMPTY_REPLY else display) or "grok 进程异常退出"
|
|
436
|
+
display = format_cli_error(raw_err, backend="grok")
|
|
417
437
|
|
|
418
438
|
# Only mark session initialized on a real successful reply
|
|
419
439
|
if first and not failed:
|
|
@@ -431,10 +451,15 @@ async def run_grok(prompt: str, user_id: str, timeout: int = None) -> tuple:
|
|
|
431
451
|
await terminate_process(process, graceful=True)
|
|
432
452
|
return format_error("处理超时", f"超过 {timeout} 秒未完成,已终止本次任务。"), []
|
|
433
453
|
|
|
454
|
+
except asyncio.CancelledError:
|
|
455
|
+
# 任务被取消(如重登录前排空):必须杀掉子进程再传递取消
|
|
456
|
+
await terminate_process(process, graceful=False)
|
|
457
|
+
raise
|
|
458
|
+
|
|
434
459
|
except Exception as e:
|
|
435
460
|
logger.exception("Unexpected error running grok: %s", e)
|
|
436
461
|
await terminate_process(process, graceful=False)
|
|
437
|
-
return format_error("执行出错",
|
|
462
|
+
return format_error("执行出错", "内部错误,详情见服务端日志。"), []
|
|
438
463
|
|
|
439
464
|
|
|
440
465
|
async def _run_grok_subcommand(subcmd_args: list, user_id: str) -> str:
|
|
@@ -445,6 +470,7 @@ async def _run_grok_subcommand(subcmd_args: list, user_id: str) -> str:
|
|
|
445
470
|
"""
|
|
446
471
|
session_dir = ensure_user_grok(user_id)
|
|
447
472
|
cmd = [config.grok_binary_path] + subcmd_args
|
|
473
|
+
process = None
|
|
448
474
|
try:
|
|
449
475
|
env = sanitize_env(session_dir)
|
|
450
476
|
process = await asyncio.create_subprocess_exec(
|
|
@@ -476,10 +502,16 @@ async def _run_grok_subcommand(subcmd_args: list, user_id: str) -> str:
|
|
|
476
502
|
return clean_output(stdout_text) or EMPTY_REPLY
|
|
477
503
|
|
|
478
504
|
except asyncio.TimeoutError:
|
|
505
|
+
# 超时必须回收子进程,否则挂死的子命令成为孤儿进程
|
|
506
|
+
await terminate_process(process, graceful=True)
|
|
479
507
|
return format_error("指令超时", "子命令 30 秒内未完成。")
|
|
508
|
+
except asyncio.CancelledError:
|
|
509
|
+
await terminate_process(process, graceful=False)
|
|
510
|
+
raise
|
|
480
511
|
except Exception as e:
|
|
481
512
|
logger.exception("Subcommand error: %s", e)
|
|
482
|
-
|
|
513
|
+
await terminate_process(process, graceful=False)
|
|
514
|
+
return format_error("执行出错", "内部错误,详情见服务端日志。")
|
|
483
515
|
|
|
484
516
|
|
|
485
517
|
# ---------------------------------------------------------------------------
|
|
@@ -665,18 +697,7 @@ async def handle_grok_slash_command(text: str, user_id: str) -> str | None:
|
|
|
665
697
|
"> 用 codegraph 的 search 工具搜 ctxmode"
|
|
666
698
|
)
|
|
667
699
|
|
|
668
|
-
|
|
669
|
-
if not config.enable_subagent:
|
|
670
|
-
return "ℹ️ **该功能已禁用** ℹ️"
|
|
671
|
-
if not args:
|
|
672
|
-
return "❌ **缺少参数** ❌\n\n`/agent <名称> <任务>`"
|
|
673
|
-
agent_parts = args.split(maxsplit=1)
|
|
674
|
-
agent_name = agent_parts[0]
|
|
675
|
-
agent_task = agent_parts[1] if len(agent_parts) > 1 else ""
|
|
676
|
-
crafted = f"请用 invoke_subagent 调用 agent {agent_name} 执行任务:{agent_task}"
|
|
677
|
-
logger.info("Agent subcmd: user=%s agent=%s task=%.100s", user_id, agent_name, agent_task)
|
|
678
|
-
result_text, _ = await run_grok(crafted, user_id)
|
|
679
|
-
return result_text
|
|
700
|
+
# /agent 已上移到 main.py 统一处理(必须经过危险确认门,不能再绕过)
|
|
680
701
|
|
|
681
702
|
# --- D class: passthrough to grok (return None so caller runs run_grok) ---
|
|
682
703
|
return None
|