pi-weixin-connect 0.1.0 → 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 +14 -4
- package/package.json +4 -4
- package/src/api.js +3 -3
- package/src/auth.js +10 -10
- package/src/config.js +3 -3
- package/src/i18n.js +29 -0
- package/src/index.js +228 -89
- package/src/lock.js +2 -2
- package/src/media.js +18 -18
- package/src/service.js +11 -11
- package/src/session.js +65 -30
- package/src/text.js +1 -1
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
|
-
>
|
|
9
|
-
>
|
|
11
|
+
> 状态:文本通道、媒体与独立后台服务已完成。聊天窗口里只映射 `/new`(技能与提示模板
|
|
12
|
+
> 仍可正常使用);`/model` 等控制命令请在终端执行。
|
|
10
13
|
> 文本收发已在真机微信验证;图片/文件目前只有单元与 mock 验证,尚未做真机回归。
|
|
11
14
|
|
|
12
15
|
## 能力
|
|
@@ -30,7 +33,13 @@
|
|
|
30
33
|
|
|
31
34
|
## 安装
|
|
32
35
|
|
|
33
|
-
|
|
36
|
+
已发布 npm([`pi-weixin-connect`](https://www.npmjs.com/package/pi-weixin-connect)):
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pi install pi-weixin-connect
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
本地开发目录:
|
|
34
43
|
|
|
35
44
|
```bash
|
|
36
45
|
pi install /path/to/pi-weixin-connect
|
|
@@ -66,6 +75,7 @@ node scripts/login.mjs --force # 强制重扫,失败会回滚旧凭证
|
|
|
66
75
|
| `/wx stop [--force]` | 停止独立后台服务 |
|
|
67
76
|
| `/wx logout` | 删除凭证并断开 |
|
|
68
77
|
| `/wx log [n]` | 最近 n 条日志 |
|
|
78
|
+
| `/wx help` | 命令表 |
|
|
69
79
|
|
|
70
80
|
## 独立后台服务(推荐)
|
|
71
81
|
|
|
@@ -84,7 +94,7 @@ scripts/uninstall-service.sh # 卸载(不删凭证)
|
|
|
84
94
|
状态看 `/wx status` 的“后台服务”一行。服务日志:journal 或
|
|
85
95
|
`~/.pi/agent/pi-weixin-connect.service.log`。
|
|
86
96
|
|
|
87
|
-
|
|
97
|
+
微信里可以直接发 `/new` 开新会话(聊天窗口只映射这一条);`/model` 等控制命令请在终端执行。
|
|
88
98
|
|
|
89
99
|
## 配置
|
|
90
100
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-weixin-connect",
|
|
3
|
-
"version": "0.
|
|
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"
|
|
@@ -28,7 +29,6 @@
|
|
|
28
29
|
"bin",
|
|
29
30
|
"scripts",
|
|
30
31
|
"packaging",
|
|
31
|
-
"examples",
|
|
32
32
|
"README.md"
|
|
33
33
|
],
|
|
34
34
|
"scripts": {
|
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(
|
|
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 ?
|
|
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
|
|
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("
|
|
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("
|
|
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("
|
|
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: "
|
|
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 = "
|
|
80
|
+
error = "config is not a JSON object";
|
|
81
81
|
raw = {};
|
|
82
82
|
}
|
|
83
83
|
} catch (err) {
|
|
84
|
-
error =
|
|
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 ?
|
|
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
|
|
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(
|
|
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("
|
|
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("
|
|
204
|
-
|
|
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 =
|
|
326
|
+
state.error = `background service pid ${svc.pid} is alive but its heartbeat stopped`;
|
|
212
327
|
setStatus(STATE_LABEL.error, state.error);
|
|
213
|
-
|
|
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(
|
|
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("
|
|
232
|
-
notify(
|
|
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 =
|
|
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(
|
|
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("
|
|
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(
|
|
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("
|
|
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("
|
|
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 (
|
|
311
|
-
state.error =
|
|
312
|
-
state.logger?.error(`启动失败:${
|
|
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(
|
|
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(
|
|
327
|
-
lines.push(
|
|
328
|
-
if (state.error) lines.push(
|
|
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(`
|
|
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
|
-
|
|
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(
|
|
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(
|
|
339
|
-
lines.push(
|
|
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 ? "
|
|
458
|
+
const flags = [s.busy ? "pi busy" : "", s.switching ? "switching" : "", s.stale ? "paused" : ""].filter(Boolean).join(" · ");
|
|
343
459
|
lines.push(
|
|
344
|
-
|
|
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(
|
|
348
|
-
lines.push(
|
|
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(`
|
|
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("
|
|
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("
|
|
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 ?
|
|
389
|
-
notify(`${where}\
|
|
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("
|
|
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(
|
|
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(
|
|
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
|
-
?
|
|
444
|
-
:
|
|
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("
|
|
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("
|
|
458
|
-
} catch (
|
|
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(
|
|
463
|
-
notify(
|
|
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(
|
|
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(
|
|
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("
|
|
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(
|
|
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("
|
|
518
|
-
notify(
|
|
643
|
+
setStatus("service online", `pid ${current.pid}`);
|
|
644
|
+
notify(`weixin background service started: ${describeService(current)}\nlog: ${started.logFile}`, "info");
|
|
519
645
|
} else {
|
|
520
|
-
notify(
|
|
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(
|
|
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("
|
|
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("
|
|
667
|
+
notify("disconnected this process from weixin", "info");
|
|
542
668
|
setStatus(STATE_LABEL.stopped);
|
|
543
669
|
}
|
|
544
670
|
|
|
545
671
|
const HELP = [
|
|
546
|
-
"
|
|
547
|
-
" status
|
|
548
|
-
"
|
|
549
|
-
"
|
|
550
|
-
"
|
|
551
|
-
"
|
|
552
|
-
"
|
|
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
|
-
"
|
|
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: "
|
|
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(
|
|
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("
|
|
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") ?? "
|
|
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("
|
|
629
|
-
void start(ctx
|
|
765
|
+
setStatus("service online", `pid ${svc.pid}`);
|
|
766
|
+
void start(ctx);
|
|
630
767
|
return;
|
|
631
768
|
}
|
|
632
|
-
if (reason !== "startup")
|
|
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: "
|
|
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: "
|
|
133
|
+
return { ok: false, reason: "file not found", path: resolved };
|
|
134
134
|
}
|
|
135
|
-
if (!stat.isFile()) return { ok: false, reason: "
|
|
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:
|
|
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:
|
|
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: "
|
|
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
|
|
222
|
+
throw new Error(`CDN client error ${res.status}: ${msg}`);
|
|
223
223
|
}
|
|
224
224
|
if (res.status !== 200) {
|
|
225
|
-
throw new Error(`CDN
|
|
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
|
|
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 (
|
|
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(
|
|
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 ? "
|
|
76
|
-
return `pid ${state.pid} · ${detail} ·
|
|
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:
|
|
83
|
-
if (!fs.existsSync(cwd)) return { pid: null, logFile, error:
|
|
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}
|
|
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}
|
|
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:
|
|
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
|
|
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}
|
|
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: "
|
|
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
|
-
|
|
132
|
-
|
|
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 (
|
|
274
|
+
} catch (cause) {
|
|
269
275
|
this.stats.failed++;
|
|
270
|
-
this.log?.error(`回复失败:${
|
|
271
|
-
this.notify(
|
|
272
|
-
if (isSessionExpired(
|
|
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(
|
|
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(`[
|
|
441
|
+
notes.push(`[file] ${file.name} → ${target}`);
|
|
413
442
|
} catch (err) {
|
|
414
443
|
this.log?.warn(`入站文件下载失败:${err?.message ?? err}`);
|
|
415
|
-
notes.push(`[
|
|
444
|
+
notes.push(`[file download failed] ${file.name}`);
|
|
416
445
|
}
|
|
417
446
|
}
|
|
418
447
|
return saved;
|
|
419
448
|
}
|
|
420
449
|
|
|
421
|
-
#failOnStale(item,
|
|
422
|
-
const reason =
|
|
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("
|
|
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("
|
|
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}
|
|
547
|
+
return name ? `${name}: ${skip.reason}` : skip.reason;
|
|
513
548
|
})
|
|
514
|
-
.join("
|
|
515
|
-
body = [body,
|
|
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:
|
|
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}
|
|
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 [];
|