@wemind/cli 1.1.0 → 1.1.1
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/LICENSE +22 -0
- package/README.md +23 -32
- package/dist/cli.js +374 -112
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
4
5
|
import crypto, { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
|
5
6
|
import os from "node:os";
|
|
6
7
|
import { spawn, spawnSync } from "node:child_process";
|
|
7
|
-
import { fileURLToPath } from "node:url";
|
|
8
8
|
//#region ../wechat-ilink-core/dist/config.js
|
|
9
9
|
/**
|
|
10
10
|
* iLink 应用标识。
|
|
@@ -13,6 +13,54 @@ import { fileURLToPath } from "node:url";
|
|
|
13
13
|
*/
|
|
14
14
|
const ILINK_APP_ID = process.env.ILINK_APP_ID?.trim() || "bot";
|
|
15
15
|
//#endregion
|
|
16
|
+
//#region ../wechat-ilink-core/dist/pkg-version.js
|
|
17
|
+
/**
|
|
18
|
+
* 读取「调用方所在包」自己的版本号(从它的 package.json)。
|
|
19
|
+
*
|
|
20
|
+
* ## 为什么放在 core
|
|
21
|
+
* `@wemind/wechat-mcp` 与 `@wemind/cli` 都需要上报自己的版本,两边的「向上查找 package.json」
|
|
22
|
+
* 逻辑完全一样。放 core 里只维护一份;两个消费方各留一个 3 行的薄适配层。
|
|
23
|
+
*
|
|
24
|
+
* ## 为什么必须由调用方传入位置(`callerUrl`)
|
|
25
|
+
* `import.meta.url` 指向**当前文件**,而当前文件在 core 里——用它去向上找,命中的会是 core
|
|
26
|
+
* 自己的 `package.json`(或更上层的仓库根),**永远拿不到调用方的版本**。
|
|
27
|
+
* 实测过:在 daemon 语境里直接复用 mcp 的同类实现,得到的是 mcp 的版本(1.0.4)而不是
|
|
28
|
+
* daemon 的(1.1.0),且不会报错——静默给错值是最坏的一类 bug。
|
|
29
|
+
* 所以这里强制由调用方传 `import.meta.url`,函数从**那个位置**向上找。
|
|
30
|
+
*
|
|
31
|
+
* ## 为什么还要 expectedName
|
|
32
|
+
* 只按「最近的 package.json」查找并不可靠:在 monorepo 里向上找,可能先撞到 core 的、
|
|
33
|
+
* 也可能撞到仓库根的。用 `name` 精确匹配,命中不了就返回 `undefined`(安全失败),
|
|
34
|
+
* 由调用方决定兜底值,绝不静默返回别人的版本。
|
|
35
|
+
*
|
|
36
|
+
* ## 打包后仍然有效
|
|
37
|
+
* 两个消费方都用 tsdown 把 core **内联**进单文件产物。由于查找起点是调用方的
|
|
38
|
+
* `import.meta.url`(打包后是 `dist/cli.js` / `dist/index.js`),向上第一个带
|
|
39
|
+
* `name` 匹配的 `package.json` 正是该消费方自己的——dev(`src/`)与产物(`dist/`)两种布局都成立。
|
|
40
|
+
*
|
|
41
|
+
* @param callerUrl 调用方模块的 `import.meta.url`
|
|
42
|
+
* @param expectedName 期望的包名(即调用方自己 package.json 的 `name`)
|
|
43
|
+
* @returns 匹配到的 `version`;找不到返回 `undefined`
|
|
44
|
+
*/
|
|
45
|
+
function readOwnVersion(callerUrl, expectedName) {
|
|
46
|
+
if (!callerUrl || !expectedName) return void 0;
|
|
47
|
+
let dir;
|
|
48
|
+
try {
|
|
49
|
+
dir = path.dirname(fileURLToPath(callerUrl));
|
|
50
|
+
} catch {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const { root } = path.parse(dir);
|
|
54
|
+
while (dir && dir !== root) {
|
|
55
|
+
const candidate = path.join(dir, "package.json");
|
|
56
|
+
try {
|
|
57
|
+
const p = JSON.parse(fs.readFileSync(candidate, "utf-8"));
|
|
58
|
+
if (p.name === expectedName && p.version) return p.version;
|
|
59
|
+
} catch {}
|
|
60
|
+
dir = path.dirname(dir);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
//#endregion
|
|
16
64
|
//#region ../wechat-ilink-core/dist/api/types.js
|
|
17
65
|
/**
|
|
18
66
|
* iLink Bot API 协议类型(镜像服务端 proto:GetUpdatesReq/Resp、WeixinMessage、SendMessageReq 等)。
|
|
@@ -144,8 +192,11 @@ var ILinkError = class extends Error {
|
|
|
144
192
|
};
|
|
145
193
|
//#endregion
|
|
146
194
|
//#region ../wechat-ilink-core/dist/api/client.js
|
|
147
|
-
|
|
195
|
+
/** 普通 RPC 的超时(send/typing/config/upload/notify)。 */
|
|
148
196
|
const DEFAULT_API_TIMEOUT_MS = 15e3;
|
|
197
|
+
/** 消息长轮询(getupdates)超时。 */
|
|
198
|
+
const DEFAULT_LONG_POLL_TIMEOUT_MS = 35e3;
|
|
199
|
+
/** 扫码状态长轮询(get_qrcode_status)超时。与消息长轮询语义不同,故分开命名。 */
|
|
149
200
|
const QR_LONG_POLL_TIMEOUT_MS = 35e3;
|
|
150
201
|
const ILINK_CLIENT_VERSION = process.env.ILINK_CLIENT_VERSION?.trim() || "2.4.3";
|
|
151
202
|
const CHANNEL_VERSION = ILINK_CLIENT_VERSION;
|
|
@@ -183,6 +234,7 @@ function buildHeaders(opts) {
|
|
|
183
234
|
if (opts.token?.trim()) headers.Authorization = `Bearer ${opts.token.trim()}`;
|
|
184
235
|
return headers;
|
|
185
236
|
}
|
|
237
|
+
/** POST 一个 iLink CGI,返回原始响应体。HTTP 非 2xx 抛错;响应体是否是 JSON 由调用方决定。 */
|
|
186
238
|
async function apiFetch(params) {
|
|
187
239
|
const base = ensureTrailingSlash(params.baseUrl);
|
|
188
240
|
const url = new URL(params.endpoint, base);
|
|
@@ -191,7 +243,7 @@ async function apiFetch(params) {
|
|
|
191
243
|
body: params.body
|
|
192
244
|
});
|
|
193
245
|
const controller = new AbortController();
|
|
194
|
-
const
|
|
246
|
+
const timer = setTimeout(() => controller.abort(), params.timeoutMs);
|
|
195
247
|
try {
|
|
196
248
|
const res = await fetch(url.toString(), {
|
|
197
249
|
method: "POST",
|
|
@@ -200,12 +252,19 @@ async function apiFetch(params) {
|
|
|
200
252
|
signal: controller.signal
|
|
201
253
|
});
|
|
202
254
|
const rawText = await res.text();
|
|
203
|
-
|
|
204
|
-
if (!res.ok) throw new Error(`${params.label} ${res.status}: ${rawText}`);
|
|
255
|
+
if (!res.ok) throw new Error(`${params.label} ${res.status}: ${rawText.slice(0, 500)}`);
|
|
205
256
|
return rawText;
|
|
206
|
-
}
|
|
207
|
-
clearTimeout(
|
|
208
|
-
|
|
257
|
+
} finally {
|
|
258
|
+
clearTimeout(timer);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/** 宽松解析:空响应体或非 JSON 返回 null(部分接口成功时无响应体)。 */
|
|
262
|
+
function tryParseJson(raw) {
|
|
263
|
+
if (!raw) return null;
|
|
264
|
+
try {
|
|
265
|
+
return JSON.parse(raw);
|
|
266
|
+
} catch {
|
|
267
|
+
return null;
|
|
209
268
|
}
|
|
210
269
|
}
|
|
211
270
|
/** 防御性解析 API JSON 响应:失败抛带 label 与响应片段的清晰错误,而非裸 SyntaxError(畸形/代理 HTML/截断响应不致以晦涩错误打断调用方)。 */
|
|
@@ -216,6 +275,15 @@ function parseJsonResp(raw, label) {
|
|
|
216
275
|
throw new Error(`${label}: response not JSON (${raw.slice(0, 120)})`);
|
|
217
276
|
}
|
|
218
277
|
}
|
|
278
|
+
/**
|
|
279
|
+
* iLink 即使 HTTP 200 也可能在响应体返回非 0 ret/errcode 表示失败。语义见 error-codes.ts:
|
|
280
|
+
* ret=-2 参数错误(最常见 context_token 失效/不匹配);errcode=-14 会话过期。
|
|
281
|
+
* 不校验会导致「假成功」——消息其实没送达。仅在明确拿到非 0 错误码时抛 ILinkError(携带码+分类,供上层按类型处理)。
|
|
282
|
+
*/
|
|
283
|
+
function throwIfErrorCode(op, resp) {
|
|
284
|
+
const code = resp?.ret ?? resp?.errcode;
|
|
285
|
+
if (code !== void 0 && code !== 0) throw new ILinkError(op, code, resp?.errmsg);
|
|
286
|
+
}
|
|
219
287
|
function buildBaseInfo() {
|
|
220
288
|
return { channel_version: CHANNEL_VERSION };
|
|
221
289
|
}
|
|
@@ -243,7 +311,7 @@ async function getUpdates(params) {
|
|
|
243
311
|
}
|
|
244
312
|
}
|
|
245
313
|
async function sendMessage(params) {
|
|
246
|
-
|
|
314
|
+
throwIfErrorCode("sendMessage", tryParseJson(await apiFetch({
|
|
247
315
|
baseUrl: params.baseUrl,
|
|
248
316
|
endpoint: "ilink/bot/sendmessage",
|
|
249
317
|
body: JSON.stringify({
|
|
@@ -253,19 +321,11 @@ async function sendMessage(params) {
|
|
|
253
321
|
token: params.token,
|
|
254
322
|
timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
|
|
255
323
|
label: "sendMessage"
|
|
256
|
-
});
|
|
257
|
-
let code;
|
|
258
|
-
let errmsg;
|
|
259
|
-
try {
|
|
260
|
-
const resp = raw ? JSON.parse(raw) : null;
|
|
261
|
-
code = resp?.ret ?? resp?.errcode;
|
|
262
|
-
errmsg = resp?.errmsg;
|
|
263
|
-
} catch {}
|
|
264
|
-
if (code !== void 0 && code !== 0) throw new ILinkError("sendMessage", code, errmsg);
|
|
324
|
+
})));
|
|
265
325
|
}
|
|
266
326
|
/** 取 CDN 上传地址(发图/发文件用)。返回 upload_full_url 或 upload_param。 */
|
|
267
327
|
async function getUploadUrl(params) {
|
|
268
|
-
|
|
328
|
+
const resp = parseJsonResp(await apiFetch({
|
|
269
329
|
baseUrl: params.baseUrl,
|
|
270
330
|
endpoint: "ilink/bot/getuploadurl",
|
|
271
331
|
body: JSON.stringify({
|
|
@@ -283,6 +343,8 @@ async function getUploadUrl(params) {
|
|
|
283
343
|
timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
|
|
284
344
|
label: "getUploadUrl"
|
|
285
345
|
}), "getUploadUrl");
|
|
346
|
+
throwIfErrorCode("getUploadUrl", resp);
|
|
347
|
+
return resp;
|
|
286
348
|
}
|
|
287
349
|
async function fetchQRCode(apiBaseUrl) {
|
|
288
350
|
return parseJsonResp(await apiFetch({
|
|
@@ -303,26 +365,21 @@ async function pollQRStatus(apiBaseUrl, qrcode) {
|
|
|
303
365
|
headers: buildCommonHeaders(),
|
|
304
366
|
signal: controller.signal
|
|
305
367
|
});
|
|
306
|
-
if (!response.ok) {
|
|
307
|
-
const body = await response.text().catch(() => "(unreadable)");
|
|
308
|
-
clearTimeout(timer);
|
|
309
|
-
throw new Error(`Failed to poll QR status: ${response.status} ${response.statusText} ${body}`);
|
|
310
|
-
}
|
|
311
368
|
const text = await response.text();
|
|
312
|
-
|
|
369
|
+
if (!response.ok) throw new Error(`pollQRStatus ${response.status} ${response.statusText} ${text.slice(0, 500)}`);
|
|
313
370
|
return parseJsonResp(text, "pollQRStatus");
|
|
314
371
|
} catch (err) {
|
|
315
|
-
clearTimeout(timer);
|
|
316
372
|
if (err instanceof Error && err.name === "AbortError") return { status: "wait" };
|
|
317
373
|
throw err;
|
|
374
|
+
} finally {
|
|
375
|
+
clearTimeout(timer);
|
|
318
376
|
}
|
|
319
377
|
}
|
|
320
378
|
function generateId(prefix) {
|
|
321
379
|
return `${prefix}:${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
|
|
322
380
|
}
|
|
323
|
-
const DEFAULT_CONFIG_TIMEOUT_MS = 15e3;
|
|
324
381
|
async function getConfig(params) {
|
|
325
|
-
|
|
382
|
+
const resp = parseJsonResp(await apiFetch({
|
|
326
383
|
baseUrl: params.baseUrl,
|
|
327
384
|
endpoint: "ilink/bot/getconfig",
|
|
328
385
|
body: JSON.stringify({
|
|
@@ -331,12 +388,14 @@ async function getConfig(params) {
|
|
|
331
388
|
base_info: buildBaseInfo()
|
|
332
389
|
}),
|
|
333
390
|
token: params.token,
|
|
334
|
-
timeoutMs:
|
|
391
|
+
timeoutMs: DEFAULT_API_TIMEOUT_MS,
|
|
335
392
|
label: "getConfig"
|
|
336
393
|
}), "getConfig");
|
|
394
|
+
throwIfErrorCode("getConfig", resp);
|
|
395
|
+
return resp;
|
|
337
396
|
}
|
|
338
397
|
async function sendTyping(params) {
|
|
339
|
-
await apiFetch({
|
|
398
|
+
throwIfErrorCode("sendTyping", tryParseJson(await apiFetch({
|
|
340
399
|
baseUrl: params.baseUrl,
|
|
341
400
|
endpoint: "ilink/bot/sendtyping",
|
|
342
401
|
body: JSON.stringify({
|
|
@@ -344,39 +403,36 @@ async function sendTyping(params) {
|
|
|
344
403
|
base_info: buildBaseInfo()
|
|
345
404
|
}),
|
|
346
405
|
token: params.token,
|
|
347
|
-
timeoutMs:
|
|
406
|
+
timeoutMs: DEFAULT_API_TIMEOUT_MS,
|
|
348
407
|
label: "sendTyping"
|
|
349
|
-
});
|
|
408
|
+
})));
|
|
350
409
|
}
|
|
351
|
-
/**
|
|
352
|
-
|
|
410
|
+
/**
|
|
411
|
+
* 上线/下线通知的共同实现。
|
|
412
|
+
* best-effort:失败只记日志、绝不抛出——通知失败不应阻断登录/登出主流程。
|
|
413
|
+
* 也正因为要吞错,这里刻意**不校验**响应体的 ret/errcode。
|
|
414
|
+
*/
|
|
415
|
+
async function notifyLifecycle(op, endpoint, params) {
|
|
353
416
|
try {
|
|
354
417
|
await apiFetch({
|
|
355
418
|
baseUrl: params.baseUrl,
|
|
356
|
-
endpoint
|
|
419
|
+
endpoint,
|
|
357
420
|
body: JSON.stringify({ base_info: buildBaseInfo() }),
|
|
358
421
|
token: params.token,
|
|
359
|
-
timeoutMs:
|
|
360
|
-
label:
|
|
422
|
+
timeoutMs: DEFAULT_API_TIMEOUT_MS,
|
|
423
|
+
label: op
|
|
361
424
|
});
|
|
362
425
|
} catch (err) {
|
|
363
|
-
console.error(`[wemind/wechat-ilink-core/ilink]
|
|
426
|
+
console.error(`[wemind/wechat-ilink-core/ilink] ${op} failed (ignored, best-effort): ${String(err)}`);
|
|
364
427
|
}
|
|
365
428
|
}
|
|
429
|
+
/** 通知服务端该 bot 客户端上线(best-effort,失败不阻断)。 */
|
|
430
|
+
async function notifyStart(params) {
|
|
431
|
+
await notifyLifecycle("notifyStart", "ilink/bot/msg/notifystart", params);
|
|
432
|
+
}
|
|
366
433
|
/** 通知服务端该 bot 客户端下线(best-effort,失败不阻断)。 */
|
|
367
434
|
async function notifyStop(params) {
|
|
368
|
-
|
|
369
|
-
await apiFetch({
|
|
370
|
-
baseUrl: params.baseUrl,
|
|
371
|
-
endpoint: "ilink/bot/msg/notifystop",
|
|
372
|
-
body: JSON.stringify({ base_info: buildBaseInfo() }),
|
|
373
|
-
token: params.token,
|
|
374
|
-
timeoutMs: DEFAULT_CONFIG_TIMEOUT_MS,
|
|
375
|
-
label: "notifyStop"
|
|
376
|
-
});
|
|
377
|
-
} catch (err) {
|
|
378
|
-
console.error(`[wemind/wechat-ilink-core/ilink] notifyStop failed (ignored, best-effort): ${String(err)}`);
|
|
379
|
-
}
|
|
435
|
+
await notifyLifecycle("notifyStop", "ilink/bot/msg/notifystop", params);
|
|
380
436
|
}
|
|
381
437
|
//#endregion
|
|
382
438
|
//#region ../wechat-ilink-core/dist/api/transport-errors.js
|
|
@@ -559,6 +615,12 @@ function setLabel(key, label) {
|
|
|
559
615
|
*/
|
|
560
616
|
const ACCOUNT_KEY = process.env.WECHAT_ACCOUNT?.trim() || "default";
|
|
561
617
|
const DATA_DIR = process.env.WECHAT_DATA_DIR?.trim() || path.join(WEMIND_ROOT, ACCOUNT_KEY);
|
|
618
|
+
/** 持久化文件名:集中在此,避免各处拼字符串写错(load/save/clear 必须指向同一份)。 */
|
|
619
|
+
const FILES = {
|
|
620
|
+
account: "account.json",
|
|
621
|
+
state: "state.json",
|
|
622
|
+
qrState: "qr_login.json"
|
|
623
|
+
};
|
|
562
624
|
function ensureDir(dir) {
|
|
563
625
|
fs.mkdirSync(dir, { recursive: true });
|
|
564
626
|
}
|
|
@@ -566,8 +628,9 @@ function readJson$2(filePath) {
|
|
|
566
628
|
let raw;
|
|
567
629
|
try {
|
|
568
630
|
raw = fs.readFileSync(filePath, "utf-8");
|
|
569
|
-
} catch {
|
|
570
|
-
return null;
|
|
631
|
+
} catch (e) {
|
|
632
|
+
if (isNotFound(e)) return null;
|
|
633
|
+
throw e;
|
|
571
634
|
}
|
|
572
635
|
try {
|
|
573
636
|
return JSON.parse(raw);
|
|
@@ -579,28 +642,63 @@ function readJson$2(filePath) {
|
|
|
579
642
|
return null;
|
|
580
643
|
}
|
|
581
644
|
}
|
|
645
|
+
function isNotFound(err) {
|
|
646
|
+
return err?.code === "ENOENT";
|
|
647
|
+
}
|
|
582
648
|
/** 原子写:先写临时文件再 rename 覆盖(同盘 rename 原子),避免写途崩溃/并发产生半截损坏文件。 */
|
|
583
649
|
function writeJson(filePath, data) {
|
|
584
650
|
ensureDir(path.dirname(filePath));
|
|
585
651
|
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
586
|
-
|
|
587
|
-
|
|
652
|
+
try {
|
|
653
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8");
|
|
654
|
+
fs.renameSync(tmp, filePath);
|
|
655
|
+
} catch (e) {
|
|
656
|
+
try {
|
|
657
|
+
fs.rmSync(tmp, { force: true });
|
|
658
|
+
} catch {}
|
|
659
|
+
throw e;
|
|
660
|
+
}
|
|
588
661
|
}
|
|
589
|
-
function loadAccount() {
|
|
590
|
-
return readJson$2(path.join(
|
|
662
|
+
function loadAccount(dir = DATA_DIR) {
|
|
663
|
+
return readJson$2(path.join(dir, FILES.account));
|
|
591
664
|
}
|
|
592
|
-
|
|
593
|
-
|
|
665
|
+
let warnedWinPerms = false;
|
|
666
|
+
/**
|
|
667
|
+
* 把登录凭证写进**指定**目录的 `account.json`(原子写 + POSIX 600 + Windows 一次性明文告警)。
|
|
668
|
+
*
|
|
669
|
+
* 为什么要有 dir 参数:`wemind login` 是先写到暂存目录 `.staging/`、扫码确认后才 rename 成账号目录。
|
|
670
|
+
* 那条路径以前直接 `fs.writeFileSync`,于是「POSIX 600 / Windows 告警」这两条安全承诺在最推荐
|
|
671
|
+
* 的登录入口上不成立(chmod 也漏了)。改成复用本函数,两条路径行为一致。
|
|
672
|
+
* rename 同盘保留权限位,所以先写好再归档不会丢 600。
|
|
673
|
+
*/
|
|
674
|
+
function saveAccountTo(dir, account) {
|
|
675
|
+
const file = path.join(dir, FILES.account);
|
|
676
|
+
writeJson(file, account);
|
|
677
|
+
if (process.platform === "win32") {
|
|
678
|
+
if (!warnedWinPerms) {
|
|
679
|
+
warnedWinPerms = true;
|
|
680
|
+
console.error(`[wemind/wechat-ilink-core] warning: ${file} stores the bot token in plaintext; Windows cannot restrict it via chmod. Protect ${dir} with folder permissions or disk encryption.`);
|
|
681
|
+
}
|
|
682
|
+
} else try {
|
|
683
|
+
fs.chmodSync(file, 384);
|
|
684
|
+
} catch {}
|
|
685
|
+
}
|
|
686
|
+
/** 空状态:唯一来源,避免 loadState/resetState 各写一份、将来加字段时漏改。 */
|
|
687
|
+
function emptyState() {
|
|
688
|
+
return {
|
|
594
689
|
updatesBuf: "",
|
|
595
690
|
contextTokens: {},
|
|
596
691
|
lastMessageId: 0
|
|
597
692
|
};
|
|
598
693
|
}
|
|
599
|
-
function
|
|
600
|
-
|
|
694
|
+
function loadState(dir = DATA_DIR) {
|
|
695
|
+
return readJson$2(path.join(dir, FILES.state)) ?? emptyState();
|
|
601
696
|
}
|
|
602
|
-
function
|
|
603
|
-
writeJson(path.join(
|
|
697
|
+
function saveState(state, dir = DATA_DIR) {
|
|
698
|
+
writeJson(path.join(dir, FILES.state), state);
|
|
699
|
+
}
|
|
700
|
+
function saveQrState(state, dir = DATA_DIR) {
|
|
701
|
+
writeJson(path.join(dir, FILES.qrState), state);
|
|
604
702
|
}
|
|
605
703
|
//#endregion
|
|
606
704
|
//#region ../wechat-ilink-core/dist/store/allow-from.js
|
|
@@ -741,8 +839,6 @@ async function uploadMediaBytes(params) {
|
|
|
741
839
|
no_need_thumb: true,
|
|
742
840
|
aeskey: aeskey.toString("hex")
|
|
743
841
|
});
|
|
744
|
-
const code = up.ret ?? up.errcode;
|
|
745
|
-
if (code !== void 0 && code !== 0) throw new ILinkError("getUploadUrl", code, up.errmsg);
|
|
746
842
|
return {
|
|
747
843
|
downloadParam: await uploadBufferToCdn({
|
|
748
844
|
buf: data,
|
|
@@ -3023,6 +3119,18 @@ function sendOrdered(account, to, ctx, text, state = MessageState.FINISH, fallba
|
|
|
3023
3119
|
});
|
|
3024
3120
|
return replyChain;
|
|
3025
3121
|
}
|
|
3122
|
+
/**
|
|
3123
|
+
* 发一条「必须送达」的答复(控制指令回执、任务回执、任务结束确认、出错提示)。
|
|
3124
|
+
*
|
|
3125
|
+
* 与 sendOrdered 的唯一差别:**先计入连发额度**。服务端按「距用户上一条消息」计连续发送数,
|
|
3126
|
+
* 这类答复确实占额度;只发不计数会让 brain 回合的 budgetOf 低估真实值 → 误判「还能发」→
|
|
3127
|
+
* 撞 ret=-2 限流、结果丢失。计数后仍走同一条 replyChain,保证与进度/答案不乱序、遵守最小间隔。
|
|
3128
|
+
* 与 `setTaskPush` 同一套语义:task 推送也是「必须送达、只计数不被 awaiting 暂停」。
|
|
3129
|
+
*/
|
|
3130
|
+
function sendNotice(account, to, ctx, text, fallbackToken) {
|
|
3131
|
+
budgetOf(to).count += 1;
|
|
3132
|
+
return sendOrdered(account, to, ctx, text, MessageState.FINISH, fallbackToken);
|
|
3133
|
+
}
|
|
3026
3134
|
/** 把长回复作为 .md 文件发送(走同一串行链+节流);发文件失败则兜底回退为文本。 */
|
|
3027
3135
|
function sendMdOrdered(account, to, ctx, finalText, fallbackToken) {
|
|
3028
3136
|
replyChain = replyChain.then(async () => {
|
|
@@ -3322,21 +3430,21 @@ async function workerLoop(account, state) {
|
|
|
3322
3430
|
r = "处理指令出错:" + String(e);
|
|
3323
3431
|
}
|
|
3324
3432
|
log("control:", JSON.stringify(ctrl), "->", r.replace(/\n/g, " | "));
|
|
3325
|
-
await
|
|
3433
|
+
await sendNotice(account, from, token, r, latestToken());
|
|
3326
3434
|
continue;
|
|
3327
3435
|
}
|
|
3328
3436
|
const tcmd = item.text ? parseTaskControl(item.text) : null;
|
|
3329
3437
|
if (tcmd?.kind === "stop") {
|
|
3330
3438
|
const r = stopTask(from);
|
|
3331
3439
|
log("task: stop ->", r);
|
|
3332
|
-
await
|
|
3440
|
+
await sendNotice(account, from, token, r, latestToken());
|
|
3333
3441
|
continue;
|
|
3334
3442
|
}
|
|
3335
3443
|
if (tcmd?.kind === "toWorker") {
|
|
3336
3444
|
if (hasActiveTask(from)) {
|
|
3337
3445
|
log("task: inject(explicit):", tcmd.text.slice(0, 40));
|
|
3338
3446
|
injectTask(from, tcmd.text);
|
|
3339
|
-
} else await
|
|
3447
|
+
} else await sendNotice(account, from, token, dispatchTask(from, tcmd.text), latestToken());
|
|
3340
3448
|
continue;
|
|
3341
3449
|
}
|
|
3342
3450
|
const brainEscape = item.text ? parseBrainEscape(item.text) : null;
|
|
@@ -3350,7 +3458,7 @@ async function workerLoop(account, state) {
|
|
|
3350
3458
|
await handleMessage(account, token, from, brainText, item.imageItems, item.createTimeMs, latestToken);
|
|
3351
3459
|
} catch (e) {
|
|
3352
3460
|
log("handleMessage error:", e instanceof Error ? e.stack || e.message : String(e));
|
|
3353
|
-
await
|
|
3461
|
+
await sendNotice(account, from, token, "处理这条消息时出错了,请再发一次试试。", latestToken());
|
|
3354
3462
|
}
|
|
3355
3463
|
}
|
|
3356
3464
|
}
|
|
@@ -3488,8 +3596,50 @@ function pm2Available() {
|
|
|
3488
3596
|
}
|
|
3489
3597
|
/** 账号的 daemon 日志路径,与 run-daemon.ps1 / ecosystem 方式一致:`~/.wemind/<key>/daemon.log`。 */
|
|
3490
3598
|
function daemonLogPath(key) {
|
|
3491
|
-
|
|
3492
|
-
|
|
3599
|
+
return path.join(accountDir(key), "daemon.log");
|
|
3600
|
+
}
|
|
3601
|
+
/**
|
|
3602
|
+
* 生成 pm2 的 app 声明(`{ apps: [decl] }`,写进账号目录的 `pm2.app.json`)。
|
|
3603
|
+
*
|
|
3604
|
+
* **为什么用 JSON 声明而不是把参数堆在 `pm2 start` 命令行上**:
|
|
3605
|
+
* - `restart_delay` / `stop_exit_codes` / `max_restarts` 在命令行上是 `--restart-delay` /
|
|
3606
|
+
* `--stop-exit-codes`(**变参**)/ `--max-restarts`,还要和 `-- daemon` 这个「脚本参数分隔符」
|
|
3607
|
+
* 挤在同一条命令里——解析顺序、变参吞掉后续 token 的风险都只能靠真机试;
|
|
3608
|
+
* - JSON 声明没有解析层:键值原样进 `pm2_env`(`stop_exit_codes` 在 `God.handleExit` 里既接受
|
|
3609
|
+
* 数字数组也接受数字字符串,这里给数字数组),与 `ecosystem.config.cjs` 同源同义;
|
|
3610
|
+
* - 路径全走 JSON 转义,不再依赖 shell 引号。
|
|
3611
|
+
*
|
|
3612
|
+
* 与 `ecosystem.config.cjs` 保持一致的项:app 名 `wemind-<key>`、崩溃 5s 重拉、最多 50 次、
|
|
3613
|
+
* 退出码 78(大脑配置错误)不重启、日志合并到账号 `daemon.log`。导出供单测直接断言(不需要装 pm2)。
|
|
3614
|
+
*/
|
|
3615
|
+
function buildAppDeclaration(key, cliPath, logPath) {
|
|
3616
|
+
return {
|
|
3617
|
+
name: `wemind-${key}`,
|
|
3618
|
+
script: cliPath,
|
|
3619
|
+
args: "daemon",
|
|
3620
|
+
interpreter: "node",
|
|
3621
|
+
cwd: os.homedir(),
|
|
3622
|
+
autorestart: true,
|
|
3623
|
+
restart_delay: 5e3,
|
|
3624
|
+
max_restarts: 50,
|
|
3625
|
+
stop_exit_codes: [78],
|
|
3626
|
+
out_file: logPath,
|
|
3627
|
+
error_file: logPath,
|
|
3628
|
+
merge_logs: true,
|
|
3629
|
+
watch: false,
|
|
3630
|
+
env: { WECHAT_ACCOUNT: key }
|
|
3631
|
+
};
|
|
3632
|
+
}
|
|
3633
|
+
/**
|
|
3634
|
+
* 把声明写进账号目录,返回文件路径。单独导出便于测试(不触碰 pm2);
|
|
3635
|
+
* `dir` 可覆盖目标目录——单测传临时目录,避免动真实 `~/.wemind`。
|
|
3636
|
+
*/
|
|
3637
|
+
function writeAppDeclaration(key, cliPath, logPath, dir = accountDir(key)) {
|
|
3638
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
3639
|
+
const file = path.join(dir, "pm2.app.json");
|
|
3640
|
+
const apps = [buildAppDeclaration(key, cliPath, logPath)];
|
|
3641
|
+
fs.writeFileSync(file, JSON.stringify({ apps }, null, 2) + "\n", "utf-8");
|
|
3642
|
+
return file;
|
|
3493
3643
|
}
|
|
3494
3644
|
/** 用 pm2 启动某账号的 daemon:app 名 `wemind-<key>`,注入 WECHAT_ACCOUNT,日志归一到账号 daemon.log。 */
|
|
3495
3645
|
function pm2Start(key, cliPath) {
|
|
@@ -3497,21 +3647,23 @@ function pm2Start(key, cliPath) {
|
|
|
3497
3647
|
ok: false,
|
|
3498
3648
|
msg: "未检测到 pm2。请先安装:npm i -g pm2,再重试 `wemind start`。"
|
|
3499
3649
|
};
|
|
3500
|
-
const name = `wemind-${key}`;
|
|
3501
3650
|
const log = daemonLogPath(key);
|
|
3651
|
+
const name = `wemind-${key}`;
|
|
3502
3652
|
pm2Run(`delete ${name}`, { quiet: true });
|
|
3503
|
-
const
|
|
3653
|
+
const decl = writeAppDeclaration(key, cliPath, log);
|
|
3654
|
+
const r = pm2Run(`start "${decl}"`, { env: {
|
|
3504
3655
|
...process.env,
|
|
3505
3656
|
WECHAT_ACCOUNT: key
|
|
3506
3657
|
} });
|
|
3507
3658
|
if (r.status !== 0) return {
|
|
3508
3659
|
ok: false,
|
|
3509
|
-
msg: `pm2 启动失败(exit=${r.status}
|
|
3660
|
+
msg: `pm2 启动失败(exit=${r.status}),声明文件:${decl}`
|
|
3510
3661
|
};
|
|
3511
3662
|
pm2Run("save", { quiet: true });
|
|
3512
3663
|
return {
|
|
3513
3664
|
ok: true,
|
|
3514
|
-
msg: `已用 pm2 启动 ${name}(日志:${log})。\n
|
|
3665
|
+
msg: `已用 pm2 启动 ${name}(日志:${log})。\n 崩溃 5s 重拉、退出码 78(大脑配置错误)不再重启,与自带看门狗一致。
|
|
3666
|
+
开机自启(首次):执行 \`pm2 startup\`——Linux/macOS 按提示操作;Windows 用计划任务跑 \`pm2 resurrect\`。`
|
|
3515
3667
|
};
|
|
3516
3668
|
}
|
|
3517
3669
|
/** 停止某账号的 daemon(pm2 delete)。 */
|
|
@@ -3845,17 +3997,23 @@ async function checkAgent(ag, timeoutMs = 6e4) {
|
|
|
3845
3997
|
//#endregion
|
|
3846
3998
|
//#region src/version.ts
|
|
3847
3999
|
/**
|
|
3848
|
-
* CLI
|
|
3849
|
-
*
|
|
3850
|
-
*
|
|
4000
|
+
* CLI 版本号与包身份(`wemind version` / `wemind --version` / help 头部)。
|
|
4001
|
+
*
|
|
4002
|
+
* **版本号唯一来源是 `package.json`**,这里在运行时读它,不再手写常量。
|
|
4003
|
+
* 为什么:以前版本号散落在 package.json 和本文件两处,`npm version` 只会改前者,
|
|
4004
|
+
* 漏改本文件会导致「发了 1.2.0、`wemind version` 还报 1.1.0」这种极难排查的漂移。
|
|
4005
|
+
* 现在 `npm run release:patch|minor|major` 一步到位,本文件自动跟着变。
|
|
4006
|
+
*
|
|
4007
|
+
* 查找逻辑在 `@wemind/wechat-ilink-core` 的 `readOwnVersion()`(与 MCP 包共用一份):
|
|
4008
|
+
* 必须把**本文件的** `import.meta.url` 传进去,否则会命中的是 core 自己的 package.json。
|
|
3851
4009
|
*/
|
|
3852
|
-
|
|
4010
|
+
/** 期望的 npm 包名。这是**独立常量**,故意不从 package.json 读——它是校验基准。 */
|
|
4011
|
+
const CLI_PKG_NAME = "@wemind/cli";
|
|
3853
4012
|
/**
|
|
3854
|
-
*
|
|
3855
|
-
*
|
|
3856
|
-
* 必须写全 `npx @wemind/cli`(见 README 与 docs/发布-wemind-npm.md)。
|
|
4013
|
+
* 包版本。正常情况下等于 `package.json` 的 `version`;
|
|
4014
|
+
* 若查找失败(不该发生),退化为可识别的占位值,单测会把它当失败信号。
|
|
3857
4015
|
*/
|
|
3858
|
-
const
|
|
4016
|
+
const CLI_VERSION = readOwnVersion(import.meta.url, "@wemind/cli") ?? "0.0.0-unknown";
|
|
3859
4017
|
/** 单行版本串(供 --version 输出):命令名 + 版本,包名放括号里便于反馈问题时对上 npm 页面。 */
|
|
3860
4018
|
function versionLine() {
|
|
3861
4019
|
return `wemind ${CLI_VERSION} (${CLI_PKG_NAME}, node ${process.versions.node}, ${process.platform}-${process.arch})`;
|
|
@@ -3871,13 +4029,6 @@ function versionLine() {
|
|
|
3871
4029
|
const BASE = process.env.WEMIND_ILINK_BASE?.trim() || "https://ilinkai.weixin.qq.com";
|
|
3872
4030
|
const STAGING = path.join(WEMIND_ROOT, ".staging");
|
|
3873
4031
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
3874
|
-
function readAccount(dir) {
|
|
3875
|
-
try {
|
|
3876
|
-
return JSON.parse(fs.readFileSync(path.join(dir, "account.json"), "utf-8"));
|
|
3877
|
-
} catch {
|
|
3878
|
-
return null;
|
|
3879
|
-
}
|
|
3880
|
-
}
|
|
3881
4032
|
function readPid(dir, name) {
|
|
3882
4033
|
try {
|
|
3883
4034
|
const n = parseInt(fs.readFileSync(path.join(dir, name), "utf-8").trim(), 10);
|
|
@@ -3893,19 +4044,99 @@ function readPid(dir, name) {
|
|
|
3893
4044
|
function aliveOurs(pid, markers = CLI_PID_MARKERS) {
|
|
3894
4045
|
return isOurProcess(pid, markers);
|
|
3895
4046
|
}
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
|
|
3899
|
-
|
|
3900
|
-
|
|
3901
|
-
|
|
3902
|
-
|
|
3903
|
-
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
|
|
4047
|
+
/** 阻塞式 sleep(CLI 是同步流程;Atomics.wait 不占 CPU、不比 spawn 一个进程贵)。 */
|
|
4048
|
+
function sleepSync(ms) {
|
|
4049
|
+
try {
|
|
4050
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
4051
|
+
} catch {}
|
|
4052
|
+
}
|
|
4053
|
+
/**
|
|
4054
|
+
* 某 pid 的全部后代(POSIX)。Windows 不需要——`taskkill /T` 自己会走进程树。
|
|
4055
|
+
* 用 `ps -A -o pid=,ppid=` 一次取全表再在内存里建树,比逐层 `pgrep -P` 少起进程。
|
|
4056
|
+
* (导出仅供单测;CLI 内部不对外。)
|
|
4057
|
+
*/
|
|
4058
|
+
function descendantPids(pid) {
|
|
4059
|
+
try {
|
|
4060
|
+
const psArgs = [[
|
|
4061
|
+
"-A",
|
|
4062
|
+
"-o",
|
|
4063
|
+
"pid=,ppid="
|
|
4064
|
+
], [
|
|
4065
|
+
"-e",
|
|
4066
|
+
"-o",
|
|
4067
|
+
"pid=,ppid="
|
|
4068
|
+
]];
|
|
4069
|
+
let stdout = "";
|
|
4070
|
+
for (const args of psArgs) {
|
|
4071
|
+
const r = spawnSync("ps", args, {
|
|
4072
|
+
encoding: "utf8",
|
|
4073
|
+
timeout: 5e3
|
|
4074
|
+
});
|
|
4075
|
+
if (r.status === 0 && r.stdout) {
|
|
4076
|
+
stdout = r.stdout;
|
|
4077
|
+
break;
|
|
4078
|
+
}
|
|
4079
|
+
}
|
|
4080
|
+
const childrenOf = /* @__PURE__ */ new Map();
|
|
4081
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
4082
|
+
const m = line.trim().match(/^(\d+)\s+(\d+)$/);
|
|
4083
|
+
if (!m) continue;
|
|
4084
|
+
const child = Number(m[1]);
|
|
4085
|
+
const parent = Number(m[2]);
|
|
4086
|
+
const bucket = childrenOf.get(parent);
|
|
4087
|
+
if (bucket) bucket.push(child);
|
|
4088
|
+
else childrenOf.set(parent, [child]);
|
|
4089
|
+
}
|
|
4090
|
+
const out = [];
|
|
4091
|
+
const walk = (p) => {
|
|
4092
|
+
for (const c of childrenOf.get(p) ?? []) {
|
|
4093
|
+
out.push(c);
|
|
4094
|
+
walk(c);
|
|
4095
|
+
}
|
|
4096
|
+
};
|
|
4097
|
+
walk(pid);
|
|
4098
|
+
return out;
|
|
4099
|
+
} catch {
|
|
4100
|
+
return [];
|
|
4101
|
+
}
|
|
4102
|
+
}
|
|
4103
|
+
/**
|
|
4104
|
+
* 杀「pid 及其整棵进程树」。
|
|
4105
|
+
*
|
|
4106
|
+
* Windows:`taskkill /T /F` 本来就连带子树。
|
|
4107
|
+
* POSIX:`process.kill(pid)` **只**结束该进程,不动它的子进程——daemon 被停后,它拉起的
|
|
4108
|
+
* ACP 大脑 / worker 会留下来继续占着会话与文件句柄(Windows 的 `taskkill /T` 会连带子树,所以这个缺陷只在 POSIX 暴露)。
|
|
4109
|
+
* 所以这里先收集后代、由叶子往根 SIGTERM(daemon 的 SIGTERM 处理器会自己清 pid 文件),
|
|
4110
|
+
* 等一小会儿再对仍存活的补 SIGKILL。
|
|
4111
|
+
*/
|
|
4112
|
+
function killProcessTrees(pids) {
|
|
4113
|
+
const unique = [...new Set(pids.filter((p) => isAlive(p)))];
|
|
4114
|
+
if (!unique.length) return;
|
|
4115
|
+
if (process.platform === "win32") {
|
|
4116
|
+
for (const pid of unique) spawnSync("taskkill", [
|
|
4117
|
+
"/PID",
|
|
4118
|
+
String(pid),
|
|
4119
|
+
"/T",
|
|
4120
|
+
"/F"
|
|
4121
|
+
], {
|
|
4122
|
+
stdio: "ignore",
|
|
4123
|
+
windowsHide: true
|
|
4124
|
+
});
|
|
4125
|
+
return;
|
|
4126
|
+
}
|
|
4127
|
+
const targets = [];
|
|
4128
|
+
for (const pid of unique) targets.push(...descendantPids(pid).reverse(), pid);
|
|
4129
|
+
const all = [...new Set(targets)].filter((p) => isAlive(p));
|
|
4130
|
+
for (const pid of all) try {
|
|
4131
|
+
process.kill(pid, "SIGTERM");
|
|
3908
4132
|
} catch {}
|
|
4133
|
+
sleepSync(800);
|
|
4134
|
+
for (const pid of all) {
|
|
4135
|
+
if (!isAlive(pid)) continue;
|
|
4136
|
+
try {
|
|
4137
|
+
process.kill(pid, "SIGKILL");
|
|
4138
|
+
} catch {}
|
|
4139
|
+
}
|
|
3909
4140
|
}
|
|
3910
4141
|
function openFile(p) {
|
|
3911
4142
|
try {
|
|
@@ -3942,7 +4173,10 @@ function startDaemon(key, runner) {
|
|
|
3942
4173
|
const useRunner = (runner || process.env.WEMIND_RUNNER || "builtin").toLowerCase();
|
|
3943
4174
|
console.log((useRunner === "pm2" ? pm2Start(key, cliPath) : watchdogStart(key, cliPath)).msg);
|
|
3944
4175
|
}
|
|
3945
|
-
/**
|
|
4176
|
+
/**
|
|
4177
|
+
* 停止某账号的常驻:先 pm2 delete(方案 a),再按 pid 兜底杀 wrapper/daemon/brain/worker
|
|
4178
|
+
* (兼容直接启动的)。停的是**整条链**:除列出的进程外,还连带它们的子进程树。
|
|
4179
|
+
*/
|
|
3946
4180
|
function stopDaemon(key) {
|
|
3947
4181
|
pm2Stop(key);
|
|
3948
4182
|
const dir = accountDir(key);
|
|
@@ -3962,22 +4196,30 @@ function stopDaemon(key) {
|
|
|
3962
4196
|
pid: readPid(dir, "brain.pid"),
|
|
3963
4197
|
name: "brain",
|
|
3964
4198
|
markers: brainPidMarkers(ag.bin, ag.args)
|
|
4199
|
+
},
|
|
4200
|
+
{
|
|
4201
|
+
pid: readPid(dir, "worker.pid"),
|
|
4202
|
+
name: "worker",
|
|
4203
|
+
markers: brainPidMarkers(ag.bin, ag.args)
|
|
3965
4204
|
}
|
|
3966
4205
|
];
|
|
3967
4206
|
const lines = commandLines(cand.map((c) => c.pid));
|
|
3968
4207
|
const stopped = [];
|
|
3969
4208
|
const skipped = [];
|
|
4209
|
+
const toKill = [];
|
|
3970
4210
|
for (const c of cand) {
|
|
3971
4211
|
if (!c.pid || !isAlive(c.pid)) continue;
|
|
3972
4212
|
if (matchesMarkers(lines.get(c.pid), c.markers)) {
|
|
3973
|
-
|
|
4213
|
+
toKill.push(c.pid);
|
|
3974
4214
|
stopped.push(`${c.name} ${c.pid}`);
|
|
3975
4215
|
} else skipped.push(`${c.name} ${c.pid}`);
|
|
3976
4216
|
}
|
|
4217
|
+
killProcessTrees(toKill);
|
|
3977
4218
|
for (const f of [
|
|
3978
4219
|
"wrapper.pid",
|
|
3979
4220
|
"daemon.pid",
|
|
3980
|
-
"brain.pid"
|
|
4221
|
+
"brain.pid",
|
|
4222
|
+
"worker.pid"
|
|
3981
4223
|
]) try {
|
|
3982
4224
|
fs.unlinkSync(path.join(dir, f));
|
|
3983
4225
|
} catch {}
|
|
@@ -3988,7 +4230,7 @@ function stopDaemon(key) {
|
|
|
3988
4230
|
}
|
|
3989
4231
|
/** 把 STAGING 的登录按 botId 归档 + 登记。 */
|
|
3990
4232
|
function finalizeStaging(label) {
|
|
3991
|
-
const acc =
|
|
4233
|
+
const acc = loadAccount(STAGING);
|
|
3992
4234
|
if (!acc?.botId) throw new Error(`staging 无有效登录(${STAGING})`);
|
|
3993
4235
|
const key = deriveAccountKey(acc.botId);
|
|
3994
4236
|
const dest = accountDir(key);
|
|
@@ -4088,13 +4330,13 @@ async function cmdLogin(label, autoStart, runner) {
|
|
|
4088
4330
|
}
|
|
4089
4331
|
if (s.status === "confirmed") {
|
|
4090
4332
|
if (!s.bot_token || !s.ilink_bot_id || !s.ilink_user_id) throw new Error("confirmed 但数据不全");
|
|
4091
|
-
|
|
4333
|
+
saveAccountTo(STAGING, {
|
|
4092
4334
|
botToken: s.bot_token,
|
|
4093
4335
|
botId: s.ilink_bot_id,
|
|
4094
4336
|
userId: s.ilink_user_id,
|
|
4095
4337
|
baseUrl: s.baseurl ?? BASE,
|
|
4096
4338
|
savedAt: Date.now()
|
|
4097
|
-
}
|
|
4339
|
+
});
|
|
4098
4340
|
clearSessionPause(s.ilink_bot_id);
|
|
4099
4341
|
await notifyStart({
|
|
4100
4342
|
baseUrl: s.baseurl ?? BASE,
|
|
@@ -4205,10 +4447,18 @@ async function cmdBrainCheck(key) {
|
|
|
4205
4447
|
/** brain 子命令派发:show(默认)/ set / unset / check。 */
|
|
4206
4448
|
async function cmdBrain(rest) {
|
|
4207
4449
|
const argv = [...rest];
|
|
4450
|
+
const FLAGS = /* @__PURE__ */ new Set([
|
|
4451
|
+
"--bin",
|
|
4452
|
+
"--args",
|
|
4453
|
+
"--shell",
|
|
4454
|
+
"--no-shell",
|
|
4455
|
+
"--restart"
|
|
4456
|
+
]);
|
|
4208
4457
|
const takeFlag = (name) => {
|
|
4209
4458
|
const i = argv.indexOf(name);
|
|
4210
4459
|
if (i < 0) return void 0;
|
|
4211
4460
|
const v = argv[i + 1];
|
|
4461
|
+
if (v === void 0 || FLAGS.has(v)) throw new Error(`${name} 缺少参数值`);
|
|
4212
4462
|
argv.splice(i, 2);
|
|
4213
4463
|
return v;
|
|
4214
4464
|
};
|
|
@@ -4247,7 +4497,7 @@ async function cmdBrain(rest) {
|
|
|
4247
4497
|
}
|
|
4248
4498
|
function cmdAdopt(key, label) {
|
|
4249
4499
|
if (!key) throw new Error("用法: adopt <key> [--label 名]");
|
|
4250
|
-
const acc =
|
|
4500
|
+
const acc = loadAccount(accountDir(key));
|
|
4251
4501
|
if (!acc?.botId) throw new Error(`${accountDir(key)} 无有效 account.json`);
|
|
4252
4502
|
const meta = registerAccount({
|
|
4253
4503
|
key,
|
|
@@ -4262,7 +4512,7 @@ function cmdAdopt(key, label) {
|
|
|
4262
4512
|
async function cmdLogout(sel) {
|
|
4263
4513
|
const key = resolveKey(sel);
|
|
4264
4514
|
const dir = accountDir(key);
|
|
4265
|
-
const acc =
|
|
4515
|
+
const acc = loadAccount(dir);
|
|
4266
4516
|
const stopMsg = stopDaemon(key);
|
|
4267
4517
|
if (acc?.botToken) try {
|
|
4268
4518
|
await notifyStop({
|
|
@@ -4485,7 +4735,20 @@ async function runManage(argv) {
|
|
|
4485
4735
|
console.log(`${k} 授权用户(${list.length}):\n${list.map((u) => " - " + u).join("\n") || " (空)"}`);
|
|
4486
4736
|
break;
|
|
4487
4737
|
}
|
|
4488
|
-
|
|
4738
|
+
case "help":
|
|
4739
|
+
case "--help":
|
|
4740
|
+
case "-h":
|
|
4741
|
+
case void 0:
|
|
4742
|
+
console.log(helpText());
|
|
4743
|
+
break;
|
|
4744
|
+
default:
|
|
4745
|
+
console.error(`未知命令:${cmd}`);
|
|
4746
|
+
console.error(helpText());
|
|
4747
|
+
process.exitCode = 2;
|
|
4748
|
+
}
|
|
4749
|
+
}
|
|
4750
|
+
function helpText() {
|
|
4751
|
+
return `WeMind 多账号管理(wemind v${CLI_VERSION})
|
|
4489
4752
|
用法: wemind <command> 其余:wemind version | help
|
|
4490
4753
|
|
|
4491
4754
|
login [--label 名] [--start] 一条命令完成扫码登录:自刷新二维码 + 自动归档(--start 顺带启动 daemon)
|
|
@@ -4508,8 +4771,7 @@ async function runManage(argv) {
|
|
|
4508
4771
|
allow <key|label> <userId> 授权某用户可驱动该账号 bot
|
|
4509
4772
|
revoke <key|label> <userId> 撤销某用户的授权
|
|
4510
4773
|
|
|
4511
|
-
说明:key = 由微信 botId 派生的唯一标识(目录名);label = 人类可读别名。多数命令可用 key 或 label
|
|
4512
|
-
}
|
|
4774
|
+
说明:key = 由微信 botId 派生的唯一标识(目录名);label = 人类可读别名。多数命令可用 key 或 label。`;
|
|
4513
4775
|
}
|
|
4514
4776
|
//#endregion
|
|
4515
4777
|
//#region src/cli.ts
|