pi-weixin-connect 0.1.1 → 0.2.0

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.
package/README.md CHANGED
@@ -1,12 +1,15 @@
1
1
  # pi-weixin-connect
2
2
 
3
+ > **平台要求:Linux**。后台服务通过 systemd 用户单元(`systemctl --user`)管理,
4
+ > 仅在 Linux 上验证;macOS / Windows 未支持。
5
+
3
6
  把**微信 ClawBot**(腾讯 iLink Bot 协议)接到**当前 Pi 会话**的桥接插件。
4
7
 
5
8
  微信里发的消息进入当前 Pi 会话,Pi 干完活后把最终回答发回微信;用法和
6
9
  [`pi-qq-connect`](https://github.com/JiangXingChi/pi-qq-connect) 对齐,只是聊天软件换成微信。
7
10
 
8
- > 状态:P0(文本通道)、P2(媒体)与独立后台服务(P1 的常驻部分)已完成,
9
- > **桌面命令映射(`/new`、`/model` 等)尚未接入**。
11
+ > 状态:文本通道、媒体与独立后台服务已完成。聊天窗口里只映射 `/new`(技能与提示模板
12
+ > 仍可正常使用);`/model` 等控制命令请在终端执行。
10
13
  > 文本收发已在真机微信验证;图片/文件目前只有单元与 mock 验证,尚未做真机回归。
11
14
 
12
15
  ## 能力
@@ -72,6 +75,7 @@ node scripts/login.mjs --force # 强制重扫,失败会回滚旧凭证
72
75
  | `/wx stop [--force]` | 停止独立后台服务 |
73
76
  | `/wx logout` | 删除凭证并断开 |
74
77
  | `/wx log [n]` | 最近 n 条日志 |
78
+ | `/wx help` | 命令表 |
75
79
 
76
80
  ## 独立后台服务(推荐)
77
81
 
@@ -90,7 +94,7 @@ scripts/uninstall-service.sh # 卸载(不删凭证)
90
94
  状态看 `/wx status` 的“后台服务”一行。服务日志:journal 或
91
95
  `~/.pi/agent/pi-weixin-connect.service.log`。
92
96
 
93
- 微信里直接发 `/new`、`/model` 等桌面命令属于 P1,尚未接入。
97
+ 微信里可以直接发 `/new` 开新会话(聊天窗口只映射这一条);`/model` 等控制命令请在终端执行。
94
98
 
95
99
  ## 配置
96
100
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-weixin-connect",
3
- "version": "0.1.1",
4
- "description": "微信 ClawBot(iLink)⇄ 当前 Pi 会话:私聊、文本收发、后台常驻",
3
+ "version": "0.2.0",
4
+ "description": "微信 ClawBot(iLink)⇄ 当前 Pi 会话:私聊、文本收发、后台常驻(Linux)",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -12,7 +12,8 @@
12
12
  "weixin",
13
13
  "ilink",
14
14
  "clawbot",
15
- "bridge"
15
+ "bridge",
16
+ "linux"
16
17
  ],
17
18
  "engines": {
18
19
  "node": ">=22.19.0"
package/src/api.js CHANGED
@@ -83,7 +83,7 @@ export class WeixinApi {
83
83
  // 200 + 非 JSON(反代门户页、HTML 错误页)必须当失败,
84
84
  // 否则 getupdates 会把它当空结果,200ms 一次热轮询
85
85
  if (res.ok) {
86
- throw new ApiError(`响应不是 JSON:${method} ${url.pathname}`, {
86
+ throw new ApiError(`response is not JSON: ${method} ${url.pathname}`, {
87
87
  status: res.status,
88
88
  body: text.slice(0, 200),
89
89
  });
@@ -117,7 +117,7 @@ export class WeixinApi {
117
117
  return data ?? {};
118
118
  } catch (err) {
119
119
  if (err?.name === "AbortError") {
120
- throw new ApiError(signal?.aborted ? `请求已取消:${path}` : `请求超时:${path}`, { status: 0 });
120
+ throw new ApiError(signal?.aborted ? `request cancelled: ${path}` : `request timed out: ${path}`, { status: 0 });
121
121
  }
122
122
  throw err;
123
123
  } finally {
@@ -153,7 +153,7 @@ export class WeixinApi {
153
153
  // 空对象意味着响应体是空的(#request 对空体返回 {})。长轮询不能把这种
154
154
  // 响应当成功,否则会 200ms 一次热循环。
155
155
  if (!data || typeof data !== "object" || Object.keys(data).length === 0) {
156
- throw new ApiError("getupdates 返回空响应", { status: 200 });
156
+ throw new ApiError("getupdates returned an empty response", { status: 200 });
157
157
  }
158
158
  return data;
159
159
  }
package/src/auth.js CHANGED
@@ -170,10 +170,10 @@ export async function login({
170
170
 
171
171
  try {
172
172
  for (let refresh = 0; refresh <= maxRefreshes; refresh++) {
173
- if (signal?.aborted) throw new Error("登录已取消");
173
+ if (signal?.aborted) throw new Error("login cancelled");
174
174
  // 取二维码固定打官方基座;redirect 只用于状态轮询
175
175
  const qr = await api.getQrCode(localTokens, api.baseUrl);
176
- if (!qr?.qrcode || !qr?.qrcode_img_content) throw new Error("服务端没有返回二维码");
176
+ if (!qr?.qrcode || !qr?.qrcode_img_content) throw new Error("server did not return a QR code");
177
177
 
178
178
  const rendered = renderQr(qr.qrcode_img_content);
179
179
  if (lastPng && lastPng !== rendered.pngPath) {
@@ -192,14 +192,14 @@ export async function login({
192
192
  let expired = false;
193
193
  let pollErrors = 0;
194
194
  while (!expired) {
195
- if (signal?.aborted) throw new Error("登录已取消");
195
+ if (signal?.aborted) throw new Error("login cancelled");
196
196
  let status;
197
197
  try {
198
198
  status = await api.getQrStatus(qr.qrcode, verifyCode, baseUrl);
199
199
  } catch (err) {
200
200
  pollErrors++;
201
201
  log?.warn(`轮询二维码状态失败 ${pollErrors}/5:${err?.message ?? err}`);
202
- if (pollErrors >= 5) throw new Error("连续 5 次无法查询二维码状态,检查网络后重试");
202
+ if (pollErrors >= 5) throw new Error("could not fetch the QR status 5 times in a row; check the network and retry");
203
203
  await sleep(1500);
204
204
  continue;
205
205
  }
@@ -219,14 +219,14 @@ export async function login({
219
219
  break;
220
220
  case "need_verifycode": {
221
221
  onStatus("需要输入手机上的配对码");
222
- if (!onNeedVerifyCode) throw new Error("需要配对码,但当前环境无法输入");
222
+ if (!onNeedVerifyCode) throw new Error("a pairing code is required but this environment cannot prompt for it");
223
223
  const code = await onNeedVerifyCode();
224
- if (!code) throw new Error("没有输入配对码,登录取消");
224
+ if (!code) throw new Error("no pairing code entered; login cancelled");
225
225
  verifyCode = String(code).trim();
226
226
  break;
227
227
  }
228
228
  case "verify_code_blocked":
229
- throw new Error("配对码错误次数过多,二维码已作废");
229
+ throw new Error("too many wrong pairing codes; the QR code expired");
230
230
  case "expired":
231
231
  expired = true;
232
232
  log?.info("二维码已过期,重新获取");
@@ -236,10 +236,10 @@ export async function login({
236
236
  log?.info("扫码的 Bot 已绑定本机,复用现有凭证");
237
237
  return existing;
238
238
  }
239
- throw new Error("扫码的 Bot 已绑定其他客户端,但本机没有可用凭证");
239
+ throw new Error("this bot is bound to another client and no local credentials are available");
240
240
  }
241
241
  case "confirmed": {
242
- if (!status.bot_token) throw new Error("登录成功但没有返回 bot_token");
242
+ if (!status.bot_token) throw new Error("login succeeded but no bot_token was returned");
243
243
  const credentials = {
244
244
  token: status.bot_token,
245
245
  botId: status.ilink_bot_id ?? "",
@@ -269,5 +269,5 @@ export async function login({
269
269
  }
270
270
  }
271
271
  }
272
- throw new Error("二维码多次过期,登录未完成");
272
+ throw new Error("the QR code expired repeatedly; login did not finish");
273
273
  }
package/src/config.js CHANGED
@@ -36,7 +36,7 @@ export const DEFAULT_CONFIG = Object.freeze({
36
36
  sessionWindowHours: 24, // context_token 有效期
37
37
  typing: true,
38
38
  maxQueue: 20,
39
- emptyReply: "(Pi 这轮没有返回文字)",
39
+ emptyReply: "(pi returned no text this round)",
40
40
  dedupeTtlMs: 10 * 60 * 1000,
41
41
  lock: true,
42
42
  // standalone:微信跑独立后台进程/会话(默认,避免陷进“共享会话”的坑)
@@ -77,11 +77,11 @@ export function loadConfig() {
77
77
  try {
78
78
  raw = JSON.parse(fs.readFileSync(file, "utf8"));
79
79
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
80
- error = "配置不是 JSON 对象";
80
+ error = "config is not a JSON object";
81
81
  raw = {};
82
82
  }
83
83
  } catch (err) {
84
- error = `配置解析失败:${err?.message ?? err}`;
84
+ error = `could not parse config: ${err?.message ?? err}`;
85
85
  raw = {};
86
86
  }
87
87
  }
package/src/i18n.js ADDED
@@ -0,0 +1,29 @@
1
+ /** English notice codes for pi-weixin-connect: every warning/error carries a stable [CODE] prefix. */
2
+ export const CODE = {
3
+ CONFIG: "WX_E_CONFIG",
4
+ NOT_LOGGED_IN: "WX_E_NOT_LOGGED_IN",
5
+ SERVICE_STALE: "WX_E_SERVICE_STALE",
6
+ SERVICE_START_FAILED: "WX_E_SERVICE_START_FAILED",
7
+ LOCK_HELD: "WX_E_LOCK_HELD",
8
+ LOCK_TAKEN: "WX_E_LOCK_TAKEN",
9
+ LOGIN_EXPIRED: "WX_E_LOGIN_EXPIRED",
10
+ START_FAILED: "WX_E_START_FAILED",
11
+ QR_BUSY: "WX_E_QR_BUSY",
12
+ STOP_OLD_FAILED: "WX_E_STOP_OLD_FAILED",
13
+ LOGIN_NO_USERID: "WX_E_LOGIN_NO_USERID",
14
+ GATEWAY_NOT_STARTED: "WX_E_GATEWAY_NOT_STARTED",
15
+ LOGIN_FAILED: "WX_E_LOGIN_FAILED",
16
+ STOP_FAILED: "WX_E_STOP_FAILED",
17
+ LOGOUT_BLOCKED: "WX_E_LOGOUT_BLOCKED",
18
+ REPLY_FAILED: "WX_E_REPLY_FAILED",
19
+ BRIDGE_PAUSED: "WX_E_BRIDGE_PAUSED",
20
+ WINDOW_EXPIRED: "WX_E_WINDOW_EXPIRED",
21
+ NEW_SESSION_FAILED: "WX_E_NEW_SESSION_FAILED",
22
+ TUI_ONLY: "WX_E_TUI_ONLY",
23
+ };
24
+
25
+ /** `[CODE] text: detail` — detail is optional. */
26
+ export function err(code, text, detail) {
27
+ const body = detail === undefined || detail === null || detail === "" ? text : `${text}: ${detail}`;
28
+ return `[${code}] ${body}`;
29
+ }
package/src/index.js CHANGED
@@ -35,6 +35,7 @@ import {
35
35
  stopService,
36
36
  } from "./service.js";
37
37
  import { maskId } from "./text.js";
38
+ import { CODE, err } from "./i18n.js";
38
39
 
39
40
  const HERE = path.dirname(fileURLToPath(import.meta.url));
40
41
  const SERVICE_BIN = path.resolve(HERE, "..", "bin", "pi-weixin-connect.mjs");
@@ -62,16 +63,16 @@ function lockFilePath() {
62
63
  }
63
64
 
64
65
  const STATE_LABEL = {
65
- idle: "未启动",
66
- connecting: "连接中",
67
- connected: "在线",
68
- disconnected: "重连中",
69
- expired: "会话过期",
70
- error: "出错",
71
- stopped: "已停止",
72
- disabled: "已关闭",
73
- unconfigured: "未登录",
74
- locked: "被占用",
66
+ idle: "not started",
67
+ connecting: "connecting",
68
+ connected: "online",
69
+ disconnected: "reconnecting",
70
+ expired: "session expired",
71
+ error: "error",
72
+ stopped: "stopped",
73
+ disabled: "disabled",
74
+ unconfigured: "not logged in",
75
+ locked: "locked",
75
76
  };
76
77
 
77
78
  const RUNTIME_KEY = Symbol.for("pi-weixin-connect.runtime.v1");
@@ -94,6 +95,7 @@ function newRuntime() {
94
95
  lockWatch: undefined,
95
96
  lastInbound: undefined,
96
97
  loginInFlight: false,
98
+ pendingExec: undefined,
97
99
  };
98
100
  }
99
101
 
@@ -116,7 +118,7 @@ export default function createExtension(pi) {
116
118
  seen.add(pi);
117
119
 
118
120
  function setStatus(label, detail) {
119
- const text = detail ? `微信: ${label} · ${detail}` : `微信: ${label}`;
121
+ const text = detail ? `wx: ${label} · ${detail}` : `wx: ${label}`;
120
122
  try {
121
123
  state.ctx?.ui?.setStatus?.("weixin", text);
122
124
  } catch {
@@ -124,8 +126,10 @@ export default function createExtension(pi) {
124
126
  }
125
127
  }
126
128
 
127
- function notify(message, level = "info") {
129
+ function notify(message, level = "info", { silent = false } = {}) {
128
130
  state.logger?.[level === "warning" ? "warn" : level === "error" ? "error" : "info"](message);
131
+ // startup / background status notices stay in the log only
132
+ if (silent) return;
129
133
  try {
130
134
  state.ctx?.ui?.notify?.(message, level);
131
135
  } catch {
@@ -133,7 +137,120 @@ export default function createExtension(pi) {
133
137
  }
134
138
  }
135
139
 
140
+ /** 开新会话:失败只提示,不打断当前会话;返回值给聊天侧回执用。 */
141
+ async function switchSession(ctx, name = "") {
142
+ const label = String(name ?? "").trim();
143
+ try {
144
+ const result = await ctx.newSession({
145
+ setup: async (sessionManager) => {
146
+ if (label) sessionManager.appendSessionInfo(label);
147
+ },
148
+ withSession: (next) => {
149
+ state.ctx = next;
150
+ },
151
+ });
152
+ if (result?.cancelled) return { ok: false, cancelled: true, message: "cancelled" };
153
+ return { ok: true };
154
+ } catch (err) {
155
+ if (isStaleContextError(err)) {
156
+ return { ok: false, message: "this session was replaced by another switch" };
157
+ }
158
+ return { ok: false, message: err?.message ?? String(err) };
159
+ }
160
+ }
161
+
162
+ /** 聊天侧开新会话:借 /wx __new 这条扩展命令走 ctx.newSession(ctx 只在命令里有)。 */
163
+ function requestNewSession(args = "") {
164
+ if (state.pendingExec) {
165
+ return Promise.resolve({ ok: false, message: "a new-session request is already in progress" });
166
+ }
167
+ return new Promise((resolve) => {
168
+ let settled = false;
169
+ const done = (result) => {
170
+ if (settled) return;
171
+ settled = true;
172
+ clearTimeout(timer);
173
+ const owner = state.pendingExec?.done === done;
174
+ const pending = state.pendingExec;
175
+ if (owner) state.pendingExec = undefined;
176
+ // 切换失败且没真的开始换会话:立刻解除暂停,别让队列停摆 30 秒
177
+ if (!result?.ok && owner && !pending?.sawShutdown) state.bridge?.cancelSwitch();
178
+ resolve(result);
179
+ };
180
+ const timer = setTimeout(() => done({ ok: false, message: "pi did not switch sessions within 35s" }), 35_000);
181
+ timer.unref?.();
182
+ state.pendingExec = { done };
183
+ state.bridge?.beginSwitch();
184
+ try {
185
+ state.pi.sendUserMessage(args ? `/wx __new ${args}` : "/wx __new", { expandPromptTemplates: true });
186
+ } catch (err) {
187
+ done({ ok: false, message: err?.message ?? String(err) });
188
+ }
189
+ });
190
+ }
191
+
192
+ const BUILTIN_COMMANDS = new Set([
193
+ "settings",
194
+ "model",
195
+ "tree",
196
+ "thinking",
197
+ "scoped-models",
198
+ "export",
199
+ "import",
200
+ "share",
201
+ "bug",
202
+ "copy",
203
+ "name",
204
+ "session",
205
+ "changelog",
206
+ "hotkeys",
207
+ "fork",
208
+ "clone",
209
+ "trust",
210
+ "login",
211
+ "logout",
212
+ "new",
213
+ "compact",
214
+ "resume",
215
+ "reload",
216
+ "quit",
217
+ "help",
218
+ "tools",
219
+ ]);
220
+
221
+ function isKnownCommand(name) {
222
+ if (BUILTIN_COMMANDS.has(name)) return true;
223
+ try {
224
+ return (state.pi?.getCommands?.() ?? []).some((item) => String(item.name).toLowerCase() === name);
225
+ } catch {
226
+ return false;
227
+ }
228
+ }
229
+
230
+ /** 聊天窗口里的斜杠命令:只映射 /new,其余一律提示回终端。 */
231
+ async function handleChatCommand(command) {
232
+ switch (command.name) {
233
+ case "new": {
234
+ const result = await requestNewSession(command.args);
235
+ if (result?.cancelled) return err(CODE.NEW_SESSION_FAILED, "new session cancelled");
236
+ if (!result?.ok) {
237
+ return err(CODE.NEW_SESSION_FAILED, "could not open a new session", result?.message ?? "unknown reason");
238
+ }
239
+ return command.args ? `new session started (${command.args})` : "new session started";
240
+ }
241
+ default:
242
+ // 只有真正存在的命令才拒绝;`/tmp`、`/var 为什么…` 这类当普通消息
243
+ if (!isKnownCommand(command.name)) return null;
244
+ return err(CODE.TUI_ONLY, `/${command.raw ?? command.name} runs in the terminal; only /new works here`);
245
+ }
246
+ }
247
+
248
+
136
249
  function stop({ release = true } = {}) {
250
+ if (state.pendingExec) {
251
+ state.pendingExec.done({ ok: false, message: "background service stopped" });
252
+ state.pendingExec = undefined;
253
+ }
137
254
  if (state.lockWatch) {
138
255
  clearInterval(state.lockWatch);
139
256
  state.lockWatch = undefined;
@@ -149,7 +266,7 @@ export default function createExtension(pi) {
149
266
  if (release && state.config?.lock !== false) releaseLock(lockFilePath());
150
267
  }
151
268
 
152
- async function start(ctx, { quietService = false } = {}) {
269
+ async function start(ctx) {
153
270
  if (state.starting) return;
154
271
  state.starting = true;
155
272
  state.ctx = ctx ?? state.ctx;
@@ -170,7 +287,7 @@ export default function createExtension(pi) {
170
287
  if (error) {
171
288
  state.error = error;
172
289
  setStatus(STATE_LABEL.error, error);
173
- notify(`pi-weixin-connect: ${error}`, "warning");
290
+ notify(err(CODE.CONFIG, "config error", error), "warning");
174
291
  // 之前可能是 inline 轮询进程:不再连长轮询时必须把锁还回去
175
292
  stop({ release: true });
176
293
  return;
@@ -186,7 +303,7 @@ export default function createExtension(pi) {
186
303
  state.credentials = credentials;
187
304
  if (!credentials) {
188
305
  setStatus(STATE_LABEL.unconfigured);
189
- notify("pi-weixin-connect: 还没有登录。在终端运行 /wx login 扫码。", "info");
306
+ notify(err(CODE.NOT_LOGGED_IN, "not logged in; run /wx login in the terminal"), "warning");
190
307
  stop({ release: true });
191
308
  return;
192
309
  }
@@ -200,17 +317,15 @@ export default function createExtension(pi) {
200
317
  if (!selfService && config.service.mode !== "inline") {
201
318
  if (svcAlive) {
202
319
  stop({ release: true });
203
- setStatus("服务在线", `pid ${svc.pid}`);
204
- if (!quietService) {
205
- notify(`微信后台服务在线(pid ${svc.pid}),本进程不连长轮询;用 /wx status 查看`, "info");
206
- }
320
+ setStatus("service online", `pid ${svc.pid}`);
321
+ notify(`background service online (pid ${svc.pid}); this process does not poll; see /wx status`, "info", { silent: true });
207
322
  return;
208
323
  }
209
324
  if (svcProcessAlive) {
210
325
  // 进程还在但心跳停了:再拉一个只会抢锁/双开,交给用户明确处理
211
- state.error = `后台服务进程(pid ${svc.pid})还在,但心跳已停;用 /wx stop --force 后重试`;
326
+ state.error = `background service pid ${svc.pid} is alive but its heartbeat stopped`;
212
327
  setStatus(STATE_LABEL.error, state.error);
213
- if (!quietService) notify(`pi-weixin-connect: ${state.error}`, "warning");
328
+ notify(err(CODE.SERVICE_STALE, "background service heartbeat stopped", `pid ${svc.pid}; run /wx stop --force then retry`), "warning");
214
329
  stop({ release: true });
215
330
  return;
216
331
  }
@@ -223,13 +338,13 @@ export default function createExtension(pi) {
223
338
  sessionId: config.service.sessionId,
224
339
  });
225
340
  if (!started.pid) {
226
- state.error = started.error ?? "无法启动后台服务";
341
+ state.error = started.error ?? "could not start background service";
227
342
  setStatus(STATE_LABEL.error, state.error);
228
- notify(`微信后台服务启动失败:${state.error}\n日志:${started.logFile}`, "warning");
343
+ notify(err(CODE.SERVICE_START_FAILED, "could not start background service", `${state.error} (log: ${started.logFile})`), "warning");
229
344
  return;
230
345
  }
231
- setStatus("服务启动中", `pid ${started.pid}`);
232
- notify(`已自动启动微信后台服务(pid ${started.pid});日志:${started.logFile}`, "info");
346
+ setStatus("service starting", `pid ${started.pid}`);
347
+ notify(`background service started automatically (pid ${started.pid}); log: ${started.logFile}`, "info", { silent: true });
233
348
  return;
234
349
  }
235
350
 
@@ -249,16 +364,16 @@ export default function createExtension(pi) {
249
364
  lock = acquireLock(lockFilePath());
250
365
  }
251
366
  if (!lock.ok) {
252
- state.error = `长轮询已被另一个 Pi 进程持有(pid ${lock.holder?.pid})`;
367
+ state.error = `long-polling lock held by another Pi process (pid ${lock.holder?.pid})`;
253
368
  setStatus(STATE_LABEL.locked, `pid ${lock.holder?.pid}`);
254
- notify(`pi-weixin-connect: ${state.error}。关掉那个进程后再试。`, "warning");
369
+ notify(err(CODE.LOCK_HELD, "long-polling lock held by another Pi process", `pid ${lock.holder?.pid}; close it and retry`), "warning");
255
370
  return;
256
371
  }
257
- if (lock.takenOver) notify("pi-weixin-connect: 已接管长轮询锁", "info");
372
+ if (lock.takenOver) notify("took over the long-polling lock", "info", { silent: true });
258
373
  state.lockWatch = setInterval(() => {
259
374
  const holder = readLock(lockFilePath());
260
375
  if (holder && holder.pid !== process.pid && isProcessAlive(holder.pid)) {
261
- notify(`微信长轮询已被 pid ${holder.pid} 接管,本进程断开`, "warning");
376
+ notify(err(CODE.LOCK_TAKEN, "long-polling taken over by another process", `pid ${holder.pid}; disconnecting`), "warning");
262
377
  stop({ release: false });
263
378
  setStatus(STATE_LABEL.locked, `pid ${holder.pid}`);
264
379
  }
@@ -272,10 +387,11 @@ export default function createExtension(pi) {
272
387
  config,
273
388
  log: state.logger,
274
389
  notify,
390
+ onCommand: (command) => handleChatCommand(command),
275
391
  credentials,
276
392
  onExpired: () => {
277
393
  setStatus(STATE_LABEL.expired);
278
- notify("微信登录已过期(-14):运行 /wx login 重新扫码。", "warning");
394
+ notify(err(CODE.LOGIN_EXPIRED, "weixin login expired (-14); run /wx login to scan again"), "warning");
279
395
  },
280
396
  });
281
397
  const gateway = new WeixinGateway({
@@ -294,7 +410,7 @@ export default function createExtension(pi) {
294
410
  },
295
411
  onExpired: () => {
296
412
  setStatus(STATE_LABEL.expired);
297
- notify("微信登录已过期(-14):运行 /wx login 重新扫码。", "warning");
413
+ notify(err(CODE.LOGIN_EXPIRED, "weixin login expired (-14); run /wx login to scan again"), "warning");
298
414
  },
299
415
  });
300
416
 
@@ -307,11 +423,11 @@ export default function createExtension(pi) {
307
423
  state.logger.error(`网关启动失败:${state.error}`);
308
424
  setStatus(STATE_LABEL.error, state.error);
309
425
  });
310
- } catch (err) {
311
- state.error = err?.message ?? String(err);
312
- state.logger?.error(`启动失败:${err?.stack ?? err}`);
426
+ } catch (cause) {
427
+ state.error = cause?.message ?? String(cause);
428
+ state.logger?.error(`启动失败:${cause?.stack ?? cause}`);
313
429
  setStatus(STATE_LABEL.error, state.error);
314
- notify(`pi-weixin-connect 启动失败:${state.error}`, "warning");
430
+ notify(err(CODE.START_FAILED, "pi-weixin-connect failed to start", state.error), "warning");
315
431
  // 启动到一半抛异常时不要留着锁
316
432
  stop({ release: true });
317
433
  } finally {
@@ -323,34 +439,44 @@ export default function createExtension(pi) {
323
439
  const cfg = state.config;
324
440
  const cred = loadCredentials() ?? state.credentials;
325
441
  const lines = [];
326
- lines.push(`配置:${configPath()}${cfg ? "" : "(未加载)"}`);
327
- lines.push(`凭证:${credentialsPath()}${cred ? "(已登录)" : "(未登录)"}`);
328
- if (state.error) lines.push(`错误:${state.error}`);
442
+ lines.push(`config: ${configPath()}${cfg ? "" : " (not loaded)"}`);
443
+ lines.push(`credentials: ${credentialsPath()}${cred ? " (logged in)" : " (not logged in)"}`);
444
+ if (state.error) lines.push(`error: ${state.error}`);
329
445
  if (!cfg) return lines.join("\n");
330
- if (cred) lines.push(`Bot:${maskId(cred.botId)} · 用户 ${maskId(cred.userId)}`);
446
+ if (cred) lines.push(`bot: ${maskId(cred.botId)} · user ${maskId(cred.userId)}`);
331
447
  const gw = state.gateway;
332
448
  const svcForPoll = liveService();
333
449
  lines.push(
334
- `长轮询:${gw ? STATE_LABEL[gw.state] ?? gw.state : svcForPoll ? `由后台服务承载(pid ${svcForPoll.pid})` : "未启动"}`,
450
+ `long polling: ${gw ? STATE_LABEL[gw.state] ?? gw.state : svcForPoll ? `served by background service (pid ${svcForPoll.pid})` : "not running"}`,
335
451
  );
336
- lines.push(`权限:用户白名单 ${cfg.allowUsers.length} 个${cfg.allowUsers.length === 0 ? "(仅登录者本人)" : ""}`);
452
+ lines.push(`acl: users ${cfg.allowUsers.length}${cfg.allowUsers.length === 0 ? " (only the logged-in user)" : ""}`);
337
453
  const lock = readLock(lockFilePath());
338
- lines.push(`锁:${lock ? `pid ${lock.pid}(${lock.at})` : "无"}`);
339
- lines.push(`后台服务:${describeService(readServiceState(serviceStatePath(baseDir())))}(模式 ${cfg.service.mode})`);
454
+ lines.push(`lock: ${lock ? `pid ${lock.pid} (${lock.at})` : "none"}`);
455
+ lines.push(`service: ${describeService(readServiceState(serviceStatePath(baseDir())))} (mode ${cfg.service.mode})`);
340
456
  if (state.bridge) {
341
457
  const s = state.bridge.status;
342
- const flags = [s.busy ? "Pi 忙" : "", s.switching ? "切换中" : "", s.stale ? "已暂停" : ""].filter(Boolean).join(" · ");
458
+ const flags = [s.busy ? "pi busy" : "", s.switching ? "switching" : "", s.stale ? "paused" : ""].filter(Boolean).join(" · ");
343
459
  lines.push(
344
- `队列:${s.queue} 条 · 已收 ${s.stats.received} / 已发 ${s.stats.sent} / 失败 ${s.stats.failed} / 忽略 ${s.stats.ignored}${flags ? ` · ${flags}` : ""}`,
460
+ `queue: ${s.queue} · received ${s.stats.received} / sent ${s.stats.sent} / failed ${s.stats.failed} / ignored ${s.stats.ignored}${flags ? ` · ${flags}` : ""}`,
345
461
  );
346
462
  }
347
- if (state.lastInbound) lines.push(`最近入站:${state.lastInbound.at} · 用户 ${state.lastInbound.user} · ${state.lastInbound.len} 字`);
348
- lines.push(`预算:每条消息最多 ${cfg.maxChunks} 段 × ${cfg.chunkChars} 字 · 窗口 ${cfg.sessionWindowHours}h`);
463
+ if (state.lastInbound) lines.push(`last inbound: ${state.lastInbound.at} · user ${state.lastInbound.user} · ${state.lastInbound.len} chars`);
464
+ lines.push(`budget: up to ${cfg.maxChunks} chunks × ${cfg.chunkChars} chars per message · window ${cfg.sessionWindowHours}h`);
349
465
  try {
350
- const model = state.ctx?.model ?? state.pi?.model;
351
- if (model) lines.push(`模型:${model.provider ?? "?"}/${model.id ?? model.modelId ?? "?"}`);
352
466
  const sessionId = state.ctx?.sessionManager?.getSessionId?.();
353
- if (sessionId) lines.push(`Pi 会话:${sessionId}`);
467
+ if (sessionId) lines.push(`pi session: ${sessionId}`);
468
+ const model = state.ctx?.model ?? state.pi?.model;
469
+ if (model) lines.push(`model: ${model.provider ?? "?"}/${model.id ?? model.modelId ?? "?"}`);
470
+ lines.push(`thinking: ${state.pi?.getThinkingLevel?.() ?? "unknown"}`);
471
+ const usage = state.ctx?.getContextUsage?.();
472
+ if (usage) {
473
+ const tokens = usage.tokens ?? usage.usedTokens;
474
+ const window = usage.contextWindow ?? usage.maxTokens;
475
+ const percent = Number.isFinite(usage.percent) ? ` (${Math.round(usage.percent)}%)` : "";
476
+ lines.push(`context: ${tokens ?? "?"} / ${window ?? "?"}${percent}`);
477
+ }
478
+ const tools = state.pi?.getActiveTools?.() ?? [];
479
+ if (tools.length) lines.push(`tools: ${tools.length}`);
354
480
  } catch {
355
481
  // ignore
356
482
  }
@@ -360,7 +486,7 @@ export default function createExtension(pi) {
360
486
  /** 扫码登录:二维码落盘 + 提示路径;配对码优先用 TUI 输入。 */
361
487
  async function doLogin(ctx, { force = false } = {}) {
362
488
  if (state.loginInFlight) {
363
- notify("上一次扫码还没结束,稍等", "warning");
489
+ notify(err(CODE.QR_BUSY, "previous QR login is still running; wait a moment"), "warning");
364
490
  return;
365
491
  }
366
492
  state.loginInFlight = true;
@@ -378,15 +504,15 @@ export default function createExtension(pi) {
378
504
  log: logger,
379
505
  });
380
506
  backup = force ? backupCredentials() : null;
381
- notify("正在获取微信登录二维码…", "info");
507
+ notify("fetching weixin login QR code...", "info");
382
508
  let qrShown = false;
383
509
  const credentials = await qrLogin({
384
510
  api,
385
511
  log: logger,
386
512
  onQr: (qr) => {
387
513
  qrShown = true;
388
- const where = qr.pngPath ? `二维码已保存:${qr.pngPath}` : `二维码内容:${qr.content}`;
389
- notify(`${where}\n用微信扫码并确认。`, "info");
514
+ const where = qr.pngPath ? `QR saved: ${qr.pngPath}` : `QR content: ${qr.content}`;
515
+ notify(`${where}\nscan it with WeChat and confirm.`, "info");
390
516
  if (qr.ansi) logger.debug(`已生成终端二维码(${qr.ansi.split("\n").length} 行),不写入日志`);
391
517
  },
392
518
  onStatus: (text) => logger.info(text),
@@ -400,7 +526,7 @@ export default function createExtension(pi) {
400
526
  timer.unref?.();
401
527
  });
402
528
  try {
403
- return await Promise.race([ctx.ui.input("请输入手机微信上显示的配对码"), timeout]);
529
+ return await Promise.race([ctx.ui.input("Enter the pairing code shown on WeChat"), timeout]);
404
530
  } finally {
405
531
  clearTimeout(timer);
406
532
  }
@@ -413,7 +539,7 @@ export default function createExtension(pi) {
413
539
  });
414
540
  loggedIn = true;
415
541
  state.error = null;
416
- notify(`微信登录成功:用户 ${maskId(credentials.userId)}`, "info");
542
+ notify(`weixin login ok: user ${maskId(credentials.userId)}`, "info");
417
543
  state.credentials = credentials;
418
544
  const svc = readServiceState(serviceStatePath(baseDir()));
419
545
  if (config.service.mode !== "inline") {
@@ -426,7 +552,7 @@ export default function createExtension(pi) {
426
552
  removeServiceState(serviceStatePath(baseDir()), svc.pid);
427
553
  } else {
428
554
  oldServiceStopped = false;
429
- notify(`旧后台服务未能停止(${stopped.reason}),本次登录不会重启服务`, "warning");
555
+ notify(err(CODE.STOP_OLD_FAILED, "previous background service did not stop; login will not restart it", stopped.reason), "warning");
430
556
  }
431
557
  }
432
558
  // 无论旧服务是否停掉,控制面自己都不能再拿旧凭证轮询
@@ -440,8 +566,8 @@ export default function createExtension(pi) {
440
566
  });
441
567
  notify(
442
568
  started.pid
443
- ? `凭证已更新,微信独立服务已启动(pid ${started.pid})`
444
- : `凭证已更新,但服务启动失败:${started.error ?? "未知原因"}`,
569
+ ? `credentials updated; background service started (pid ${started.pid})`
570
+ : err(CODE.SERVICE_START_FAILED, "credentials updated but service did not start", started.error ?? "unknown reason"),
445
571
  started.pid ? "info" : "warning",
446
572
  );
447
573
  }
@@ -451,16 +577,16 @@ export default function createExtension(pi) {
451
577
  }
452
578
  dropCredentialsBackup(backup);
453
579
  if (!credentials.userId) {
454
- notify("登录成功,但服务端没有返回用户 ID:默认白名单会拒绝所有消息。请重试 /wx login。", "warning");
580
+ notify(err(CODE.LOGIN_NO_USERID, "login ok but the server returned no user id; the default allowlist rejects all messages, retry /wx login"), "warning");
455
581
  }
456
582
  if (config.service.mode === "inline" && qrShown && !state.gateway)
457
- notify("登录成功,但网关没有启动,查看 /wx status 或日志", "warning");
458
- } catch (err) {
583
+ notify(err(CODE.GATEWAY_NOT_STARTED, "login ok but the gateway did not start; see /wx status or the log"), "warning");
584
+ } catch (cause) {
459
585
  // 只有扫码本身失败才回滚旧凭证;登录已成功、只是网关没起来时保留新凭证
460
586
  if (!loggedIn) restoreCredentials(backup);
461
587
  else dropCredentialsBackup(backup);
462
- loggerSafeError(err);
463
- notify(`扫码登录失败:${err?.message ?? err}`, "warning");
588
+ loggerSafeError(cause);
589
+ notify(err(CODE.LOGIN_FAILED, "QR login failed", cause?.message ?? cause), "warning");
464
590
  } finally {
465
591
  state.loginInFlight = false;
466
592
  }
@@ -479,12 +605,12 @@ export default function createExtension(pi) {
479
605
  const file = serviceStatePath(baseDir());
480
606
  const svc = readServiceState(file);
481
607
  if (svc && isServiceAlive(svc)) {
482
- notify(`后台服务已在运行:${describeService(svc)}`, "info");
608
+ notify(`background service already running: ${describeService(svc)}`, "info");
483
609
  return;
484
610
  }
485
611
  if (svc && isProcessAlive(svc.pid)) {
486
612
  // 进程活着、心跳过期:不要双开,让用户先显式停掉
487
- notify(`后台服务进程还在(pid ${svc.pid}),但心跳已停;先 /wx stop --force 再启动`, "warning");
613
+ notify(err(CODE.SERVICE_STALE, "background service heartbeat stopped", `pid ${svc.pid}; run /wx stop --force then start again`), "warning");
488
614
  return;
489
615
  }
490
616
  if (svc) removeServiceState(file, svc.pid);
@@ -492,7 +618,7 @@ export default function createExtension(pi) {
492
618
  const credentials = loadCredentials();
493
619
  if (credentials) state.credentials = credentials;
494
620
  if (!state.credentials) {
495
- notify("还没有登录,先 /wx login 扫码", "warning");
621
+ notify(err(CODE.NOT_LOGGED_IN, "not logged in; run /wx login first"), "warning");
496
622
  return;
497
623
  }
498
624
  const cwd = state.ctx?.cwd ?? os.homedir();
@@ -503,7 +629,7 @@ export default function createExtension(pi) {
503
629
  sessionId: state.config?.service?.sessionId ?? "pi-weixin-connect-service",
504
630
  });
505
631
  if (!started.pid) {
506
- notify(`后台服务启动失败:${started.error ?? "没有拿到 pid"}\n日志:${started.logFile}`, "warning");
632
+ notify(err(CODE.SERVICE_START_FAILED, "could not start background service", `${started.error ?? "no pid"} (log: ${started.logFile})`), "warning");
507
633
  return;
508
634
  }
509
635
  let current = readServiceState(file);
@@ -514,10 +640,10 @@ export default function createExtension(pi) {
514
640
  if (current && isServiceAlive(current)) {
515
641
  // 服务接管长轮询:本进程让出
516
642
  stop({ release: true });
517
- setStatus("服务在线", `pid ${current.pid}`);
518
- notify(`微信后台服务已启动:${describeService(current)}\n日志:${started.logFile}`, "info");
643
+ setStatus("service online", `pid ${current.pid}`);
644
+ notify(`weixin background service started: ${describeService(current)}\nlog: ${started.logFile}`, "info");
519
645
  } else {
520
- notify(`后台服务启动中(pid ${started.pid}),稍后 /wx status 查看。日志:${started.logFile}`, "warning");
646
+ notify(`background service starting (pid ${started.pid}); check /wx status later; log: ${started.logFile}`, "info");
521
647
  }
522
648
  }
523
649
 
@@ -528,30 +654,31 @@ export default function createExtension(pi) {
528
654
  if (svc && isProcessAlive(svc.pid)) {
529
655
  const result = await stopService(svc, { force });
530
656
  if (!result.stopped) {
531
- notify(`停止失败:${result.reason}`, "warning");
657
+ notify(err(CODE.STOP_FAILED, "stop failed", result.reason), "warning");
532
658
  return;
533
659
  }
534
660
  removeServiceState(file, svc.pid);
535
661
  setStatus(STATE_LABEL.stopped);
536
- notify("微信后台服务已停止", "info");
662
+ notify("weixin background service stopped", "info");
537
663
  return;
538
664
  }
539
665
  if (svc) removeServiceState(file, svc.pid);
540
666
  stop({ release: true });
541
- notify("已断开本进程的微信连接", "info");
667
+ notify("disconnected this process from weixin", "info");
542
668
  setStatus(STATE_LABEL.stopped);
543
669
  }
544
670
 
545
671
  const HELP = [
546
- "用法:/wx <命令>",
547
- " status 查看登录、长轮询、队列、预算",
548
- " login [--force] 扫码登录(首次或 -14 过期后)",
549
- " start 启动独立后台服务(微信走单独进程)",
550
- " stop [--force] 停止后台服务;没有服务时断开本进程",
551
- " logout 删除本机凭证并断开",
552
- " log [n] 看最近 n 条日志(默认 20)",
672
+ "usage: /wx <command>",
673
+ " status service, connection, queue, budget",
674
+ " start start the background service",
675
+ " stop [--force] stop the service; disconnects this process when none runs",
676
+ " log [n] show the last n log lines (default 20)",
677
+ " help this list",
678
+ " login [--force] QR login (first time or after -14)",
679
+ " logout delete local credentials and disconnect",
553
680
  "",
554
- "微信里可直接用终端命令:/new /model /thinking /compact /session /name /tools /help(P1 接入)。",
681
+ "In chat: only /new works; every other command runs in the terminal.",
555
682
  ].join("\n");
556
683
 
557
684
  let commandsRegistered = false;
@@ -559,7 +686,7 @@ export default function createExtension(pi) {
559
686
  if (commandsRegistered) return;
560
687
  commandsRegistered = true;
561
688
  pi.registerCommand("wx", {
562
- description: "微信 ClawBot:status / login / logout / log",
689
+ description: "WeChat ClawBot: status / login / logout / log",
563
690
  handler: async (args, ctx) => {
564
691
  state.ctx = ctx;
565
692
  const [sub, ...rest] = String(args ?? "").trim().split(/\s+/);
@@ -567,6 +694,16 @@ export default function createExtension(pi) {
567
694
  case "status":
568
695
  notify(statusReport(), "info");
569
696
  return;
697
+ case "__new": {
698
+ const pending = state.pendingExec;
699
+ if (!pending) {
700
+ notify(err(CODE.NEW_SESSION_FAILED, "no pending new-session request"), "warning");
701
+ return;
702
+ }
703
+ const result = await switchSession(ctx, rest.join(" "));
704
+ pending.done(result);
705
+ return;
706
+ }
570
707
  case "login":
571
708
  await doLogin(ctx, { force: rest.includes("--force") });
572
709
  return;
@@ -582,7 +719,7 @@ export default function createExtension(pi) {
582
719
  if (svc && isProcessAlive(svc.pid)) {
583
720
  const stopped = await stopService(svc, { force: true });
584
721
  if (!stopped.stopped) {
585
- notify(`无法停止后台服务(${stopped.reason}),登出已取消;先 /wx stop --force`, "warning");
722
+ notify(err(CODE.LOGOUT_BLOCKED, "cannot stop background service; logout cancelled, run /wx stop --force first", stopped.reason), "warning");
586
723
  return;
587
724
  }
588
725
  }
@@ -591,13 +728,13 @@ export default function createExtension(pi) {
591
728
  stop({ release: true });
592
729
  clearCredentials();
593
730
  state.credentials = undefined;
594
- notify("已删除微信凭证并断开连接", "info");
731
+ notify("weixin credentials deleted; connection closed", "info");
595
732
  setStatus(STATE_LABEL.unconfigured);
596
733
  return;
597
734
  }
598
735
  case "log": {
599
736
  const n = Number(rest[0]) > 0 ? Math.min(Number(rest[0]), 200) : 20;
600
- notify(state.logger?.tail(n)?.join("\n") ?? "(没有日志)", "info");
737
+ notify(state.logger?.tail(n)?.join("\n") ?? "(no log)", "info");
601
738
  return;
602
739
  }
603
740
  default:
@@ -625,11 +762,12 @@ export default function createExtension(pi) {
625
762
  if (svc && (state.config?.service?.mode ?? "standalone") !== "inline") {
626
763
  // 独立服务在跑:控制面每次 /new 都不该再拉起一遍,也不该报“正在重建”;
627
764
  // 但仍然要跑 start() 把配置/凭证读进来,后台服务状态才算完整。
628
- setStatus("服务在线", `pid ${svc.pid}`);
629
- void start(ctx, { quietService: true });
765
+ setStatus("service online", `pid ${svc.pid}`);
766
+ void start(ctx);
630
767
  return;
631
768
  }
632
- if (reason !== "startup") notify(`会话已切换(${reason}),没有活动桥,正在重建微信连接…`, "warning");
769
+ if (reason !== "startup")
770
+ notify(`session switched (${reason}); rebuilding weixin connection...`, "info", { silent: true });
633
771
  void start(ctx);
634
772
  return;
635
773
  }
@@ -649,6 +787,7 @@ export default function createExtension(pi) {
649
787
  return;
650
788
  }
651
789
  state.ownerStale = true;
790
+ if (state.pendingExec) state.pendingExec.sawShutdown = true;
652
791
  state.bridge?.beginSwitch();
653
792
  });
654
793
 
package/src/lock.js CHANGED
@@ -41,7 +41,7 @@ export function acquireLock(file, { force = false, pid = process.pid } = {}) {
41
41
  fs.linkSync(tmp, file);
42
42
  return { ok: true, takenOver: false };
43
43
  } catch (err) {
44
- if (err?.code !== "EEXIST") return { ok: false, error: "无法写入锁文件" };
44
+ if (err?.code !== "EEXIST") return { ok: false, error: "could not write the lock file" };
45
45
  const current = readLock(file);
46
46
  if (current && current.pid !== pid && isProcessAlive(current.pid) && !force) {
47
47
  return { ok: false, holder: current };
@@ -50,7 +50,7 @@ export function acquireLock(file, { force = false, pid = process.pid } = {}) {
50
50
  return { ok: true, takenOver: Boolean(holder && holder.pid !== pid && force) };
51
51
  }
52
52
  } catch (err) {
53
- return { ok: false, error: err?.message ?? "无法写入锁文件" };
53
+ return { ok: false, error: err?.message ?? "could not write the lock file" };
54
54
  } finally {
55
55
  try {
56
56
  fs.unlinkSync(tmp);
package/src/media.js CHANGED
@@ -125,17 +125,17 @@ function isDenied(filePath, allowDirs) {
125
125
  /** 校验出站文件;返回 {ok, size, kind, reason}。 */
126
126
  export function inspectFile(filePath, { maxBytes = CDN_MAX_BYTES, allowDirs = [] } = {}) {
127
127
  const resolved = realPath(String(filePath ?? ""));
128
- if (isDenied(resolved, allowDirs)) return { ok: false, reason: "敏感路径,拒绝发送", path: resolved };
128
+ if (isDenied(resolved, allowDirs)) return { ok: false, reason: "path is not allowed", path: resolved };
129
129
  let stat;
130
130
  try {
131
131
  stat = fs.statSync(resolved);
132
132
  } catch {
133
- return { ok: false, reason: "文件不存在", path: resolved };
133
+ return { ok: false, reason: "file not found", path: resolved };
134
134
  }
135
- if (!stat.isFile()) return { ok: false, reason: "不是普通文件", path: resolved };
135
+ if (!stat.isFile()) return { ok: false, reason: "not a regular file", path: resolved };
136
136
  const { mediaType, kind, maxBytes: limit } = mediaKindForFile(resolved);
137
137
  const cap = Math.min(maxBytes, limit);
138
- if (stat.size > cap) return { ok: false, reason: `超过上限 ${Math.round(cap / 1048576)}MB`, path: resolved };
138
+ if (stat.size > cap) return { ok: false, reason: `exceeds the ${Math.round(cap / 1048576)}MB limit`, path: resolved };
139
139
  return { ok: true, size: stat.size, kind, mediaType, path: resolved, name: path.basename(resolved) };
140
140
  }
141
141
 
@@ -155,7 +155,7 @@ export function parseFileMarkers(text, { maxCount = 3, maxBytes = CDN_MAX_BYTES,
155
155
  return "";
156
156
  }
157
157
  if (files.length + skipped.length >= maxCount + 5) {
158
- skipped.push({ path: filePath, reason: "标记太多" });
158
+ skipped.push({ path: filePath, reason: "too many file markers" });
159
159
  return "";
160
160
  }
161
161
  const check = inspectFile(filePath, { maxBytes, allowDirs });
@@ -164,14 +164,14 @@ export function parseFileMarkers(text, { maxCount = 3, maxBytes = CDN_MAX_BYTES,
164
164
  return "";
165
165
  }
166
166
  if (files.length >= maxCount) {
167
- skipped.push({ path: filePath, reason: `最多 ${maxCount} 个` });
167
+ skipped.push({ path: filePath, reason: `at most ${maxCount} files` });
168
168
  return "";
169
169
  }
170
170
  files.push({ path: check.path, name: check.name, kind: check.kind, mediaType: check.mediaType, size: check.size });
171
171
  return "";
172
172
  });
173
173
  if (disabled && disabledCount > 0) {
174
- skipped.push({ path: "", reason: "文件发送已关闭", count: disabledCount });
174
+ skipped.push({ path: "", reason: "file sending is disabled", count: disabledCount });
175
175
  }
176
176
  return { text: kept.trim(), files, skipped };
177
177
  }
@@ -181,7 +181,7 @@ function uploadUrl(uploadFullUrl, uploadParam, filekey, cdnBaseUrl) {
181
181
  if (uploadParam) {
182
182
  return `${cdnBaseUrl}/upload?encrypted_query_param=${encodeURIComponent(uploadParam)}&filekey=${encodeURIComponent(filekey)}`;
183
183
  }
184
- throw new Error("getuploadurl 没有返回上传地址");
184
+ throw new Error("getuploadurl returned no upload url");
185
185
  }
186
186
 
187
187
  /**
@@ -219,13 +219,13 @@ export async function uploadFile(api, token, { to, filePath, mediaType, cdnBaseU
219
219
  });
220
220
  if (res.status >= 400 && res.status < 500) {
221
221
  const msg = res.headers.get("x-error-message") ?? `HTTP ${res.status}`;
222
- throw new Error(`CDN 客户端错误 ${res.status}: ${msg}`);
222
+ throw new Error(`CDN client error ${res.status}: ${msg}`);
223
223
  }
224
224
  if (res.status !== 200) {
225
- throw new Error(`CDN 服务端错误 HTTP ${res.status}`);
225
+ throw new Error(`CDN server error HTTP ${res.status}`);
226
226
  }
227
227
  const downloadParam = res.headers.get("x-encrypted-param");
228
- if (!downloadParam) throw new Error("CDN 响应缺少 x-encrypted-param");
228
+ if (!downloadParam) throw new Error("CDN response is missing x-encrypted-param");
229
229
  return {
230
230
  media: {
231
231
  encrypt_query_param: downloadParam,
@@ -238,30 +238,30 @@ export async function uploadFile(api, token, { to, filePath, mediaType, cdnBaseU
238
238
  };
239
239
  } catch (err) {
240
240
  lastError = err;
241
- if (/客户端错误/.test(String(err?.message))) break;
241
+ if (/^CDN client error /.test(String(err?.message))) break;
242
242
  log?.warn(`CDN 上传失败 ${attempt}/3:${err?.message ?? err}`);
243
243
  await new Promise((resolve) => setTimeout(resolve, 800 * attempt));
244
244
  }
245
245
  }
246
- throw lastError ?? new Error("CDN 上传失败");
246
+ throw lastError ?? new Error("CDN upload failed");
247
247
  }
248
248
 
249
249
  /** 下载 CDN 媒体并解密;返回 Buffer。 */
250
250
  export async function downloadMedia({ media, itemAeskey, cdnBaseUrl, maxBytes = CDN_MAX_BYTES, requireKey = false }) {
251
- if (!media?.full_url && !media?.encrypt_query_param) throw new Error("媒体缺少下载参数");
251
+ if (!media?.full_url && !media?.encrypt_query_param) throw new Error("media is missing download parameters");
252
252
  const url =
253
253
  media.full_url?.trim() ||
254
254
  `${cdnBaseUrl}/download?encrypted_query_param=${encodeURIComponent(media.encrypt_query_param)}`;
255
255
  const res = await fetch(url, { signal: AbortSignal.timeout(120_000) });
256
- if (!res.ok) throw new Error(`媒体下载 HTTP ${res.status}`);
256
+ if (!res.ok) throw new Error(`media download HTTP ${res.status}`);
257
257
  const declared = Number(res.headers.get("content-length"));
258
- if (Number.isFinite(declared) && declared > maxBytes) throw new Error("媒体超过大小上限");
258
+ if (Number.isFinite(declared) && declared > maxBytes) throw new Error("media exceeds the size limit");
259
259
  const ciphertext = Buffer.from(await res.arrayBuffer());
260
- if (ciphertext.length > maxBytes) throw new Error("媒体超过大小上限");
260
+ if (ciphertext.length > maxBytes) throw new Error("media exceeds the size limit");
261
261
 
262
262
  const key = parseAesKey(itemAeskey) ?? parseAesKey(media.aes_key);
263
263
  if (!key) {
264
- if (requireKey) throw new Error("媒体缺少解密密钥");
264
+ if (requireKey) throw new Error("media is missing its decryption key");
265
265
  return ciphertext; // 明文图片
266
266
  }
267
267
  return decryptAesEcb(ciphertext, key);
package/src/service.js CHANGED
@@ -67,20 +67,20 @@ export function isServiceAlive(state, { now = Date.now(), staleMs = HEARTBEAT_ST
67
67
  }
68
68
 
69
69
  export function describeService(state, { now = Date.now(), staleMs = HEARTBEAT_STALE_MS } = {}) {
70
- if (!state) return "未运行";
70
+ if (!state) return "not running";
71
71
  const alive = isProcessAlive(state.pid);
72
72
  const fresh = isServiceAlive(state, { now, staleMs });
73
73
  const parsed = Date.parse(state.heartbeatAt ?? "");
74
74
  const beat = Number.isFinite(parsed) ? Math.round((now - parsed) / 1000) : null;
75
- const detail = !alive ? "进程已退出" : fresh ? "在线" : beat === null ? "心跳时间未知" : `心跳停了 ${beat}s`;
76
- return `pid ${state.pid} · ${detail} · 会话 ${state.sessionId ?? "?"}`;
75
+ const detail = !alive ? "process exited" : fresh ? "online" : beat === null ? "heartbeat time unknown" : `heartbeat stopped ${beat}s ago`;
76
+ return `pid ${state.pid} · ${detail} · session ${state.sessionId ?? "?"}`;
77
77
  }
78
78
 
79
79
  /** 拉起后台服务(detached,父进程退出后仍在)。 */
80
80
  export function startService({ binPath, baseDir, cwd, sessionId = "pi-weixin-connect-service", nodePath = process.execPath }) {
81
81
  const logFile = serviceLogPath(baseDir);
82
- if (!fs.existsSync(binPath)) return { pid: null, logFile, error: `找不到启动脚本:${binPath}` };
83
- if (!fs.existsSync(cwd)) return { pid: null, logFile, error: `工作目录不存在:${cwd}` };
82
+ if (!fs.existsSync(binPath)) return { pid: null, logFile, error: `start script not found: ${binPath}` };
83
+ if (!fs.existsSync(cwd)) return { pid: null, logFile, error: `working directory does not exist: ${cwd}` };
84
84
  fs.mkdirSync(path.dirname(logFile), { recursive: true });
85
85
  try {
86
86
  if (fs.statSync(logFile).size > 5 * 1024 * 1024) fs.renameSync(logFile, `${logFile}.1`);
@@ -134,10 +134,10 @@ function looksLikeService(pid) {
134
134
  export async function stopService(state, { timeoutMs = 10_000, intervalMs = 200, force = false } = {}) {
135
135
  if (!state || !isProcessAlive(state.pid)) return { stopped: true, reason: "already-exited" };
136
136
  if (!force && !isServiceAlive(state)) {
137
- return { stopped: false, reason: `pid ${state.pid} 还活着但心跳已停,确认后用 /wx stop --force` };
137
+ return { stopped: false, reason: `pid ${state.pid} is alive but the heartbeat stopped; run /wx stop --force after confirming` };
138
138
  }
139
139
  if (force && !looksLikeService(state.pid)) {
140
- return { stopped: false, reason: `pid ${state.pid} 看起来不是 pi-weixin-connect,拒绝强制结束` };
140
+ return { stopped: false, reason: `pid ${state.pid} does not look like pi-weixin-connect; refusing to force-kill` };
141
141
  }
142
142
  try {
143
143
  process.kill(state.pid, "SIGTERM");
@@ -149,7 +149,7 @@ export async function stopService(state, { timeoutMs = 10_000, intervalMs = 200,
149
149
  if (!isProcessAlive(state.pid)) return { stopped: true };
150
150
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
151
151
  }
152
- return { stopped: false, reason: `进程 ${state.pid} 在 ${timeoutMs}ms 内没有退出` };
152
+ return { stopped: false, reason: `process ${state.pid} did not exit within ${timeoutMs}ms` };
153
153
  }
154
154
 
155
155
  /** 当前进程是不是后台服务(launcher 注入 env,pi 的 ppid = launcher)。
@@ -184,7 +184,7 @@ export async function startManagedService({ binPath, baseDir, cwd, sessionId = "
184
184
  const systemdError = res.error
185
185
  ? res.error.message
186
186
  : res.status !== 0
187
- ? (res.stderr || res.stdout || `systemctl 退出码 ${res.status}`).trim()
187
+ ? (res.stderr || res.stdout || `systemctl exited with ${res.status}`).trim()
188
188
  : "";
189
189
  if (!systemdError) {
190
190
  usedSystemd = true;
@@ -192,7 +192,7 @@ export async function startManagedService({ binPath, baseDir, cwd, sessionId = "
192
192
  // 容器 / 没有 user bus / 残留 unit:不要卡死在 systemd 上,退回 detached 启动
193
193
  const started = startService({ binPath, baseDir, cwd, sessionId });
194
194
  if (!started.pid) {
195
- return { pid: null, logFile, systemd: false, error: `${systemdError};回退启动失败:${started.error ?? "未知原因"}` };
195
+ return { pid: null, logFile, systemd: false, error: `${systemdError}; fallback start failed: ${started.error ?? "unknown reason"}` };
196
196
  }
197
197
  }
198
198
  } else {
@@ -206,5 +206,5 @@ export async function startManagedService({ binPath, baseDir, cwd, sessionId = "
206
206
  if (state && isServiceAlive(state)) return { pid: state.pid, logFile, systemd: usedSystemd };
207
207
  await new Promise((resolve) => setTimeout(resolve, 200));
208
208
  }
209
- return { pid: null, logFile, systemd: usedSystemd, error: "服务没有在 8 秒内上线" };
209
+ return { pid: null, logFile, systemd: usedSystemd, error: "service did not come online within 8s" };
210
210
  }
package/src/session.js CHANGED
@@ -17,11 +17,19 @@ import { randomInt } from "node:crypto";
17
17
 
18
18
  import { downloadMedia, parseFileMarkers, sniffImageMime, uploadFile } from "./media.js";
19
19
  import { buildPrompt, chunkText, isAllowed, maskId, stripMarkdown } from "./text.js";
20
+ import { CODE, err } from "./i18n.js";
20
21
 
21
22
  export function isStaleContextError(err) {
22
23
  return /stale after session replacement or reload/i.test(String(err?.message ?? err ?? ""));
23
24
  }
24
25
 
26
+ function parseChatCommand(text) {
27
+ const trimmed = String(text ?? "").trim();
28
+ const match = /^\/([^\s]+)(?:\s+([\s\S]*))?$/.exec(trimmed);
29
+ if (!match) return null;
30
+ return { name: match[1].toLowerCase(), raw: match[1], args: (match[2] ?? "").trim() };
31
+ }
32
+
25
33
  export function retryDelayFor(count) {
26
34
  return Math.min(2000 * 2 ** Math.max(0, count - 1), 30_000);
27
35
  }
@@ -39,7 +47,7 @@ function messageText(message) {
39
47
  }
40
48
 
41
49
  export class WeixinBridge {
42
- constructor({ pi, api, config, log, notify, credentials, onExpired, injectTimeoutMs = 5000 }) {
50
+ constructor({ pi, api, config, log, notify, credentials, onExpired, onCommand, injectTimeoutMs = 5000 }) {
43
51
  this.pi = pi;
44
52
  this.api = api;
45
53
  this.cfg = config;
@@ -47,6 +55,7 @@ export class WeixinBridge {
47
55
  this.notify = notify ?? (() => {});
48
56
  this.credentials = credentials;
49
57
  this.onExpired = onExpired ?? (() => {});
58
+ this.onCommand = onCommand ?? (async () => null);
50
59
 
51
60
  this.queue = [];
52
61
  this.deferred = [];
@@ -128,11 +137,8 @@ export class WeixinBridge {
128
137
  }
129
138
 
130
139
  cancelSwitch() {
131
- this.switching = false;
132
- if (this.switchTimer) {
133
- clearTimeout(this.switchTimer);
134
- this.switchTimer = undefined;
135
- }
140
+ // 失败也要把暂存消息放回队列,否则它们会卡到下一次成功切换
141
+ this.endSwitch();
136
142
  }
137
143
 
138
144
  /** 入队;返回 {accepted, reason}。 */
@@ -265,11 +271,11 @@ export class WeixinBridge {
265
271
  }
266
272
  const text = inflight.texts.join("\n\n").trim() || this.cfg.emptyReply;
267
273
  await this.#deliver(inflight, text);
268
- } catch (err) {
274
+ } catch (cause) {
269
275
  this.stats.failed++;
270
- this.log?.error(`回复失败:${err?.stack ?? err}`);
271
- this.notify(`微信回复失败:${err?.message ?? err}`, "warning");
272
- if (isSessionExpired(err)) this.onExpired(err);
276
+ this.log?.error(`回复失败:${cause?.stack ?? cause}`);
277
+ this.notify(err(CODE.REPLY_FAILED, "failed to send weixin reply", cause?.message ?? cause), "warning");
278
+ if (isSessionExpired(cause)) this.onExpired(cause);
273
279
  } finally {
274
280
  this.finishing = false;
275
281
  void this.pump();
@@ -305,6 +311,29 @@ export class WeixinBridge {
305
311
 
306
312
  async #pumpOnce() {
307
313
  const item = this.queue.shift();
314
+ const command = parseChatCommand(item.text);
315
+ if (command) {
316
+ this.finishing = true;
317
+ let reply = null;
318
+ try {
319
+ reply = await this.onCommand(command, item);
320
+ } catch (err) {
321
+ this.log?.error(`command failed: ${err?.message ?? err}`);
322
+ } finally {
323
+ this.finishing = false;
324
+ }
325
+ if (reply) {
326
+ try {
327
+ await this.replyCommand(item, reply);
328
+ } catch (cause) {
329
+ this.stats.failed++;
330
+ this.notify(err(CODE.REPLY_FAILED, "failed to send weixin reply", cause?.message ?? cause), "warning");
331
+ if (isSessionExpired(cause)) this.onExpired(cause);
332
+ }
333
+ return;
334
+ }
335
+ // null = not one of our commands: fall through and treat it as a normal message
336
+ }
308
337
  let images = [];
309
338
  let files = [];
310
339
  let prompt;
@@ -366,7 +395,7 @@ export class WeixinBridge {
366
395
  const cfg = this.cfg.images ?? {};
367
396
  if (!cfg.enabled || !item.images?.length) return [];
368
397
  const list = item.images.slice(0, Math.max(0, cfg.maxCount ?? 2));
369
- if (item.images.length > list.length) notes.push(`(另有 ${item.images.length - list.length} 张图片未下载)`);
398
+ if (item.images.length > list.length) notes.push(`(${item.images.length - list.length} more images not downloaded)`);
370
399
  const results = await Promise.all(
371
400
  list.map(async (image) => {
372
401
  try {
@@ -376,11 +405,11 @@ export class WeixinBridge {
376
405
  cdnBaseUrl: this.cfg.endpoints.cdn,
377
406
  maxBytes: cfg.maxBytes ?? 8 * 1024 * 1024,
378
407
  });
379
- if (!buf?.length) throw new Error("空文件");
408
+ if (!buf?.length) throw new Error("empty file");
380
409
  return { type: "image", data: buf.toString("base64"), mimeType: sniffImageMime(buf) };
381
410
  } catch (err) {
382
411
  this.log?.warn(`入站图片下载失败:${err?.message ?? err}`);
383
- notes.push("[图片下载失败]");
412
+ notes.push("[image download failed]");
384
413
  return null;
385
414
  }
386
415
  }),
@@ -409,17 +438,17 @@ export class WeixinBridge {
409
438
  const target = path.join(dir, `${Date.now()}-${randomInt(1000, 9999)}-${safe}`);
410
439
  fs.writeFileSync(target, buf, { mode: 0o600 });
411
440
  saved.push(target);
412
- notes.push(`[文件] ${file.name} → ${target}`);
441
+ notes.push(`[file] ${file.name} → ${target}`);
413
442
  } catch (err) {
414
443
  this.log?.warn(`入站文件下载失败:${err?.message ?? err}`);
415
- notes.push(`[文件下载失败] ${file.name}`);
444
+ notes.push(`[file download failed] ${file.name}`);
416
445
  }
417
446
  }
418
447
  return saved;
419
448
  }
420
449
 
421
- #failOnStale(item, err) {
422
- const reason = err?.message ?? String(err);
450
+ #failOnStale(item, cause) {
451
+ const reason = cause?.message ?? String(cause);
423
452
  const first = !this.stale;
424
453
  this.stale = true;
425
454
  this.injecting = false;
@@ -427,7 +456,7 @@ export class WeixinBridge {
427
456
  if (item) this.queue.unshift(item);
428
457
  if (first) {
429
458
  this.log?.error(`会话已被替换,暂停注入直到新会话接棒:${reason}`);
430
- this.notify("微信桥已暂停:Pi 会话被替换,等新会话接手后自动恢复", "warning");
459
+ this.notify(err(CODE.BRIDGE_PAUSED, "weixin bridge paused: pi session was replaced; it resumes when the new session takes over"), "warning");
431
460
  }
432
461
  }
433
462
 
@@ -472,6 +501,12 @@ export class WeixinBridge {
472
501
  }
473
502
  }
474
503
 
504
+ /** 直接回复一条命令回执(不进入模型)。 */
505
+ async replyCommand(item, text) {
506
+ const inflight = { item, texts: [], attemptsLeft: this.cfg.maxChunks };
507
+ await this.#deliver(inflight, text);
508
+ }
509
+
475
510
  async #deliver(inflight, text) {
476
511
  if (this.stopped) return;
477
512
  const { item } = inflight;
@@ -487,7 +522,7 @@ export class WeixinBridge {
487
522
  const last = this.lastInboundAt.get(target) || item.at || Date.now();
488
523
  if (Date.now() - last > windowMs) {
489
524
  this.log?.warn("已超过 24 小时窗口,回复未发送");
490
- this.notify("微信 24 小时窗口已过:请在微信里先发一条消息,再重试", "warning");
525
+ this.notify(err(CODE.WINDOW_EXPIRED, "weixin 24h window closed; send a message in WeChat first, then retry"), "warning");
491
526
  return;
492
527
  }
493
528
 
@@ -509,21 +544,21 @@ export class WeixinBridge {
509
544
  .slice(0, 3)
510
545
  .map((skip) => {
511
546
  const name = path.basename(String(skip.path || ""));
512
- return name ? `${name}:${skip.reason}` : skip.reason;
547
+ return name ? `${name}: ${skip.reason}` : skip.reason;
513
548
  })
514
- .join(";");
515
- body = [body, `(${skippedCount} 个文件未发送:${detail})`].filter(Boolean).join("\n");
549
+ .join("; ");
550
+ body = [body, `(${skippedCount} file(s) not sent: ${detail})`].filter(Boolean).join("\n");
516
551
  }
517
552
  const textBudget = Math.max(1, inflight.attemptsLeft - parsed.files.length);
518
553
  const chunks = chunkText(body, {
519
554
  chunkChars: this.cfg.chunkChars,
520
555
  maxChunks: Math.min(textBudget, this.cfg.maxChunks),
521
- overflowHint: "……(内容过长,已截断)",
556
+ overflowHint: "... (truncated, message too long)",
522
557
  });
523
558
 
524
559
  for (const chunk of chunks) {
525
560
  if (this.stopped) return;
526
- await this.#sendWithRetry("文本", () =>
561
+ await this.#sendWithRetry("text", () =>
527
562
  this.api.sendText(this.credentials.token, { to: target, contextToken, text: chunk }),
528
563
  );
529
564
  this.stats.sent++;
@@ -545,7 +580,7 @@ export class WeixinBridge {
545
580
  cdnBaseUrl: this.cfg.endpoints.cdn,
546
581
  log: this.log,
547
582
  });
548
- await this.#sendWithRetry("媒体", () =>
583
+ await this.#sendWithRetry("media", () =>
549
584
  this.api.sendItems(this.credentials.token, {
550
585
  to: target,
551
586
  contextToken,
@@ -559,11 +594,11 @@ export class WeixinBridge {
559
594
  this.stats.failed++;
560
595
  this.log?.error(`文件发送失败 ${file.path}:${err?.stack ?? err}`);
561
596
  if (inflight.attemptsLeft > 0) {
562
- await this.#sendWithRetry("失败提示", () =>
597
+ await this.#sendWithRetry("notice", () =>
563
598
  this.api.sendText(this.credentials.token, {
564
599
  to: target,
565
600
  contextToken,
566
- text: `(文件发送失败:${file.name} — ${err?.message ?? err})`,
601
+ text: `(file send failed: ${file.name} — ${err?.message ?? err})`,
567
602
  }),
568
603
  );
569
604
  this.stats.sent++;
@@ -578,7 +613,7 @@ export class WeixinBridge {
578
613
  let lastError;
579
614
  for (let i = 0; i < attempts; i++) {
580
615
  try {
581
- if (this.stopped) throw new Error("桥已停止");
616
+ if (this.stopped) throw new Error("bridge is stopped");
582
617
  return await fn();
583
618
  } catch (err) {
584
619
  lastError = err;
@@ -588,11 +623,11 @@ export class WeixinBridge {
588
623
  const retryableStatus = [408, 425, 429].includes(status);
589
624
  const fatal = (status >= 400 && status < 500 && !retryableStatus) || business;
590
625
  if (fatal) throw err;
591
- this.log?.warn(`${label}发送失败 ${i + 1}/${attempts}:${err?.message ?? err}`);
626
+ this.log?.warn(`${label} send failed ${i + 1}/${attempts}: ${err?.message ?? err}`);
592
627
  if (i + 1 < attempts) await new Promise((resolve) => setTimeout(resolve, 500 * (i + 1)));
593
628
  }
594
629
  }
595
- throw lastError ?? new Error(`${label}发送失败`);
630
+ throw lastError ?? new Error(`${label} send failed`);
596
631
  }
597
632
 
598
633
  // ── 输入状态 ──────────────────────────────────────────────────────
package/src/text.js CHANGED
@@ -166,7 +166,7 @@ function splitFenced(text, limit) {
166
166
  /** 分片 + (i/n);超过 maxChunks 只发前几段并带提示。 */
167
167
  export function chunkText(
168
168
  text,
169
- { chunkChars = MAX_CONTENT_CHARS, maxChunks = 9, overflowHint = "……(内容过长,已截断)" } = {},
169
+ { chunkChars = MAX_CONTENT_CHARS, maxChunks = 9, overflowHint = "... (message too long, truncated)" } = {},
170
170
  ) {
171
171
  const normalized = String(text ?? "").trim();
172
172
  if (!normalized) return [];