qq-bot-roost 0.8.13 → 0.9.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/src/main.js CHANGED
@@ -1,1364 +1,1389 @@
1
- import fs from 'node:fs/promises';
2
- import path from 'node:path';
3
- import { randomBytes } from 'node:crypto';
4
- import process from 'node:process';
5
- import {
6
- loadGlobalConfig,
7
- saveGlobalConfig,
8
- loadBotConfig,
9
- loadAllBotConfigs,
10
- saveBotConfig,
11
- createBotConfig,
12
- ensureBotCredentials,
13
- migrateToMultiBot,
14
- } from './config.js';
15
- import { DIRS, PATHS, botPaths, listBotIds, ensureDirs, rel } from './paths.js';
16
- import { createLogger, setLevel, getLevel, attachLogFile, flushLogs } from './logger.js';
17
- import { configureMirror } from './mirror.js';
18
- import { BotRuntime, pluginAllowedIn } from './bot.js';
19
- import { toggleGroupPlugin, resetGroupPlugins, describeGroupScope } from './plugin/groupscope.js';
20
- import { runtime, sanitizePayload } from './runtime.js';
21
- import { LEVEL, NO_PERM, levelName, parseLevel, parseScope, scopeOf } from './roles.js';
22
- import { openBrowser } from './browser.js';
23
- import { WebhookServer } from './webhook.js';
24
- import { startWebServer } from './web/server.js';
25
- import { Services } from './services/index.js';
26
- import { recoverInterruptedUpdate } from './updater.js';
27
- import { emitMessage } from './message-bus.js';
28
-
29
- /* ------------------------------------------------------------------ */
30
- /* 开箱即用:先把运行环境检查清楚,别让人对着莫名其妙的报错猜 */
31
- /* ------------------------------------------------------------------ */
32
-
33
- const MIN_NODE_MAJOR = 22;
34
- const nodeMajor = Number(process.versions.node.split('.')[0]);
35
- if (!Number.isFinite(nodeMajor) || nodeMajor < MIN_NODE_MAJOR) {
36
- console.error(
37
- `\n需要 Node.js ${MIN_NODE_MAJOR} 或更高版本,当前是 ${process.versions.node}。\n` +
38
- `本项目用到了内置的全局 WebSocket 和 fetch,低版本跑不起来。\n` +
39
- `去 https://nodejs.org/ 装一个 LTS 版本即可,不需要 npm install。\n`,
40
- );
41
- process.exit(1);
42
- }
43
-
44
- const log = createLogger('core');
45
-
46
- /**
47
- * 已知的系统事件不再单独维护一份白名单——全部事件登记在 src/events.js。
48
- * 这里只保留一个提示:收到登记表里没有的事件时,说明官方加了新事件,
49
- * 需要补登记(但不影响转发给插件)。
50
- */
51
- const args = new Set(process.argv.slice(2));
52
- const MOCK = args.has('--mock');
53
- const CHECK_ONLY = args.has('--check');
54
- const SERVE = args.has('--serve');
55
-
56
- if (args.has('--help') || args.has('-h')) {
57
- console.log(`
58
- qq-bot-roost(栖 Roost)· QQ 机器人框架
59
-
60
- 用法:
61
- node src/main.js 连接 QQ,正常跑(会自动打开面板)
62
- node src/main.js --no-open 同上,但不自动开浏览器
63
- node src/main.js --check 只验证 AppID/AppSecret 和网关,不常驻
64
- node src/main.js --mock 不连 QQ,用本地假消息开发插件
65
- node src/main.js --mock --serve 只启动可视化面板,不读标准输入(调界面用)
66
- node src/main.js --force 已经有实例在跑时,强行再开一个(不推荐)
67
- node src/main.js --help 看这个帮助
68
-
69
- 同一个 AppID 默认只允许一个实例,重复启动会拒绝并给出 PID。
70
- 面板端口被占用时会自动顺延(默认最多试 20 个),实际地址看启动日志。
71
-
72
- 配置:
73
- config/bot.json 本体配置(AppID / AppSecret / intents / owners / webui)
74
- config/bots/<机器人id>/plugins/<插件id>.json 插件配置,首次加载自动生成
75
- logs/bot.log 本体日志(含所有插件输出)
76
- logs/plugins/<插件id>.log 单个插件的日志
77
- data/ 运行时数据(存档、权限、额度),不要手改
78
- 环境变量 QQ_BOT_APPID / QQ_BOT_SECRET / QQ_BOT_OWNERS
79
- 可视化面板 默认 http://127.0.0.1:15207
80
- `);
81
- process.exit(0);
82
- }
83
-
84
- /* ------------------------------------------------------------------ */
85
- /* 消息前置检查已迁到 src/pipeline.js(可插拔中间件),这里不再硬编码 */
86
- /* ------------------------------------------------------------------ */
87
-
88
- /* ------------------------------------------------------------------ */
89
- /* 核心命令:不依赖任何插件,保证机器人一启动就能用 */
90
- /* ------------------------------------------------------------------ */
91
-
92
- function registerCoreCommands(rt) {
93
- const { cfg, registry, plugins, tokens, roles, claim, outbound, session, id } = rt;
94
- const save = () => saveBotConfig(id, cfg);
95
- const P = cfg.commandPrefix || '/';
96
- const isOwner = (uid) => cfg.owners.includes(uid);
97
-
98
- /**
99
- * 内核命令分到哪一组。
100
- *
101
- * 以前所有命令都塞进「指令」一组,`/help` 主菜单二十多行。
102
- * 现在分三组,主菜单只放「常用 + 分类入口」,具体命令进下级菜单。
103
- */
104
- const CORE_SECTION = {
105
- ping: '基础',
106
- whoami: '基础',
107
- status: '基础',
108
- stats: '基础',
109
- admin: '管理',
110
- plugins: '管理',
111
- enable: '管理',
112
- disable: '管理',
113
- reload: '管理',
114
- 群插件: '管理',
115
- help: '其它',
116
- menu: '其它',
117
- claim: '其它',
118
- };
119
-
120
- const core = (name, meta, handler) =>
121
- registry.addCommand(
122
- '__core',
123
- name,
124
- { plugin: '框架', section: CORE_SECTION[name] ?? '其它', ...meta },
125
- handler,
126
- );
127
-
128
- /** 把某插件的菜单渲染成文本 */
129
- /**
130
- * 把注册表里的菜单转成 renderMenuMd 需要的分组结构。
131
- * 命令项做成可点击标签,非命令项(被动触发的关键词)只显示文字。
132
- */
133
- function menuGroupsOf(m, { withCommands = true } = {}) {
134
- const groups = [];
135
- for (const sec of m.sections) {
136
- if (!sec.items.length) continue;
137
- groups.push({ title: sec.title, items: sec.items.map((it) => menuItemOf(it, withCommands)) });
138
- }
139
- return groups;
140
- }
141
-
142
- /**
143
- * 单个菜单项 → 可点击项。
144
- * 带参数的命令只把「命令名」做成可点击(点一下插入 /admin,参数自己补),
145
- * 完整用法放说明里——否则点出来是一串 <openid> 占位符,没法直接用。
146
- */
147
- function menuItemOf(it, withCommands = true) {
148
- if (!it.isCommand) {
149
- return { text: it.text, desc: it.desc || '', cmd: null };
150
- }
151
- const pattern = String(it.text).trim();
152
- const name = pattern.split(/\s+/)[0];
153
- const hasArgs = pattern !== name;
154
- return {
155
- text: `${P}${name}`,
156
- // 用法包在反引号里,里面有什么符号都不会被 markdown 吃掉
157
- desc: hasArgs ? `用法 \`${P}${pattern}\`${it.desc ? ' · ' + it.desc : ''}` : it.desc || '',
158
- cmd: withCommands ? `${P}${name}` : null,
159
- };
160
- }
161
-
162
- /** 全局菜单 */
163
- core('help', { desc: '全局菜单;加插件 id 或分类名看下级菜单' }, async (msg) => {
164
- const raw = (msg.args[0] || '').trim();
165
- const arg = raw.toLowerCase();
166
- const myLv = await roles.effective(scopeOf(msg), msg.userId, { owners: cfg.owners });
167
-
168
- /** 内核命令按权限过滤后的分组(分组名 → 菜单项) */
169
- const coreSections = () => {
170
- const out = new Map();
171
- for (const sec of registry.menus.get('__core')?.sections ?? []) {
172
- const items = [];
173
- for (const it of sec.items) {
174
- if (it.admin && myLv > LEVEL.admin) continue;
175
- if (it.globalAdmin && myLv > LEVEL.admin) continue;
176
- items.push(menuItemOf(it));
177
- }
178
- if (items.length) out.set(sec.title, items);
179
- }
180
- return out;
181
- };
182
-
183
- const pluginMenus = [...registry.menus.values()].filter((m) => m.id !== '__core');
184
-
185
- /* ---------- 有参数:进下级 ---------- */
186
- if (arg) {
187
- // 1) 插件 id
188
- const m = registry.menus.get(arg);
189
- if (m) {
190
- return msg.replyMenu(`${m.title}${m.version ? ' v' + m.version : ''}`, menuGroupsOf(m));
191
- }
192
-
193
- // 2) 「插件」→ 插件列表
194
- if (raw === '插件' || arg === 'plugins') {
195
- if (!pluginMenus.length) {
196
- return msg.reply('还没有安装插件。把文件夹放进 plugins/ 就会出现在这里。');
197
- }
198
- return msg.replyMenu('插件', [
199
- {
200
- title: `共 ${pluginMenus.length} 个`,
201
- items: pluginMenus.map((x) => {
202
- const cnt = x.sections.reduce((n, s) => n + s.items.length, 0);
203
- return {
204
- text: x.id,
205
- desc: `${x.title}${cnt ? ` · ${cnt} 条` : ' · 未提供菜单'}`,
206
- cmd: `${P}help ${x.id}`,
207
- };
208
- }),
209
- },
210
- ]);
211
- }
212
-
213
- // 3) 内核分组名(基础 / 管理 / 其它)
214
- const secs = coreSections();
215
- const hit = secs.get(raw);
216
- if (hit) return msg.replyMenu(`框架 · ${raw}`, [{ title: raw, items: hit }]);
217
-
218
- const names = [...secs.keys(), '插件', ...pluginMenus.map((x) => x.id)];
219
- return msg.reply(`没有「${raw}」这个分类或插件。\n可用:${names.join('、')}`);
220
- }
221
-
222
- /* ---------- 没参数:简短主菜单 ---------- */
223
- /**
224
- * 主菜单只放两样东西:最常用的三条命令,和分类入口。
225
- *
226
- * 以前是把内核十几个命令全平铺出来,加上插件列表二十多行,
227
- * 手机上要滑两屏,而且大部分命令一个普通群成员根本用不到。
228
- */
229
- const secs = coreSections();
230
- const groups = [];
231
-
232
- const common = [];
233
- for (const name of ['ping', 'whoami', 'help']) {
234
- for (const items of secs.values()) {
235
- const it = items.find((x) => x.text === `${P}${name}`);
236
- if (it) {
237
- common.push(it);
238
- break;
239
- }
240
- }
241
- }
242
- if (common.length) groups.push({ title: '常用', items: common });
243
-
244
- const entries = [];
245
- for (const [title, items] of secs) {
246
- if (title === '其它') continue;
247
- // /admin 的 7 个子命令都显示成 `/admin`,去重后再列,否则一行全是重复
248
- const uniq = [...new Set(items.map((x) => x.text))];
249
- entries.push({
250
- text: title,
251
- desc: `${items.length} 条 · ${uniq.join(' ')}`,
252
- cmd: `${P}help ${title}`,
253
- });
254
- }
255
- if (pluginMenus.length) {
256
- entries.push({
257
- text: '插件',
258
- desc: `${pluginMenus.length} 个:${pluginMenus.map((x) => x.id).join('、')}`,
259
- cmd: `${P}help 插件`,
260
- });
261
- }
262
- if (entries.length) groups.push({ title: '分类', items: entries });
263
-
264
- const bot = rt.metrics.botName || '菜单';
265
- await msg.replyMenu(bot, groups);
266
- });
267
-
268
- /**
269
- * /menu 是 /help 的别名。
270
- *
271
- * 直接复用 help 的 handler,而不是再写一份——两份菜单各自演化,
272
- * 迟早会出现「/help 里有、/menu 里没有」这种对不上的情况。
273
- */
274
- core('menu', { desc: '等同 /help,查看菜单' }, async (msg) => {
275
- const h = registry.commands.get('help')?.handler;
276
- if (h) await h(msg, { args: msg.args, raw: msg.content, bot: rt });
277
- });
278
-
279
- core('ping', { desc: '测试机器人是否在线' }, async (msg) => {
280
- await msg.reply(`pong(已运行 ${Math.floor(runtime.uptimeMs / 1000)} 秒)`);
281
- });
282
-
283
- core('whoami', { desc: '查看自己的 openid(配置主人用)' }, async (msg) => {
284
- const lv = await roles.effective(scopeOf(msg), msg.userId, { owners: cfg.owners });
285
- const lines = [
286
- `来源: ${msg.kind}`,
287
- `你的 openid: ${msg.userId}`,
288
- msg.groupId ? `群 openid: ${msg.groupId}` : '',
289
- `是否主人: ${isOwner(msg.userId) ? '是' : '否'}`,
290
- `当前权限: ${levelName(lv)}`,
291
- ].filter(Boolean);
292
- await msg.reply(lines.join('\n'));
293
- });
294
-
295
- core('claim', { desc: '认领主人身份,需要启动日志里的认领码', usage: `${P}claim <认领码>` }, async (msg) => {
296
- if (cfg.owners.length > 0) {
297
- return msg.reply('已经设置过主人了。如需变更,请改 config/bot.json 的 owners,或在面板里配置。');
298
- }
299
- if (!claim.code) {
300
- return msg.reply('当前没有可用的认领码,请重启机器人后再试。');
301
- }
302
- const input = String(msg.args[0] ?? '').trim().toUpperCase();
303
- if (!input) {
304
- return msg.reply(
305
- `需要认领码。\n启动日志里有一行「认领码: XXXXXX」,面板「日志」页也能看到。\n用法: ${P}claim <认领码>`,
306
- );
307
- }
308
- if (input !== claim.code) {
309
- return msg.reply('认领码不正确。');
310
- }
311
- cfg.owners.push(msg.userId);
312
- claim.code = '';
313
- await save();
314
- await msg.reply(`已把你设为主人:${msg.userId}\n已写入该机器人的配置文件,重启后依然有效。`);
315
- });
316
-
317
- /** 运行状态总览 */
318
- core('status', { desc: '机器人运行状态' }, async (msg) => {
319
- const list = plugins.list();
320
- const running = list.filter((p) => p.loaded).length;
321
- const up = Math.floor(runtime.uptimeMs / 1000);
322
- const d = Math.floor(up / 86400);
323
- const h = Math.floor((up % 86400) / 3600);
324
- const m = Math.floor((up % 3600) / 60);
325
- const uptime = `${d ? d + '天' : ''}${h ? h + '小时' : ''}${m}分${up < 60 ? up + '秒' : ''}`;
326
-
327
- const lines = [
328
- `连接: ${rt.metrics.connected ? '在线' : '离线'}`,
329
- `机器人: ${rt.metrics.botName || '-'}`,
330
- `运行: ${uptime}`,
331
- ];
332
-
333
- if (tokens?.expiresAt) {
334
- const left = Math.max(0, Math.round((tokens.expiresAt - Date.now()) / 1000));
335
- lines.push(`凭证: 剩余 ${Math.floor(left / 60)} 分(自动续期)`);
336
- }
337
-
338
- lines.push(`消息: 收 ${rt.metrics.counters.recv} / 发 ${rt.metrics.counters.sent} / 命令 ${rt.metrics.counters.commands}`);
339
- lines.push(`插件: ${running}/${list.length} 运行中`);
340
-
341
- // 官方接口的额度与窗口状态,出问题时能一眼看出是哪里卡住
342
- if (outbound) {
343
- const q = await outbound.quotaSnapshot();
344
- const used = q[`${msg.kind}:${msg.chatId}`] ?? 0;
345
- lines.push(
346
- `本会话主动消息: ${used}/${outbound.dailyActive} 条(今日)` +
347
- `${used >= outbound.dailyActive ? ' · 已用完,需等用户 @ 才能继续' : ''}`,
348
- );
349
- if (outbound.stats.degraded) lines.push(`被动超窗降级: ${outbound.stats.degraded} 次`);
350
- if (outbound.stats.expired) lines.push(`回复过期被拒: ${outbound.stats.expired} 次`);
351
- }
352
- if (session) {
353
- const s = session.snapshot();
354
- const remain = session.stickyRemain(msg.chatId);
355
- if (remain > 0) lines.push(`免@窗口: 还剩 ${remain} 秒`);
356
- if (s.pending) lines.push(`等待中的多轮对话: ${s.pending} 个`);
357
- }
358
-
359
- lines.push(`内存: ${Math.round(process.memoryUsage().rss / 1048576)} MB`);
360
- await msg.reply(lines.join('\n'));
361
- });
362
-
363
- /** 详细统计 */
364
- core('stats', { desc: '消息与事件统计' }, async (msg) => {
365
- const c = rt.metrics.counters;
366
- const lines = [
367
- '消息统计',
368
- ` 收到 ${c.recv} 发出 ${c.sent} 命令 ${c.commands} 错误 ${c.errors}`,
369
- ` 去重 ${c.dedup} 限流 ${c.limited} 白名单 ${c.rejected} 无人回应 ${c.ignored}`,
370
- ];
371
-
372
- const chats = [...rt.metrics.perChat.values()].sort((a, b) => b.count - a.count).slice(0, 5);
373
- if (chats.length) {
374
- lines.push('\n活跃会话');
375
- for (const x of chats) {
376
- const id = x.id.length > 12 ? x.id.slice(0, 6) + '…' + x.id.slice(-4) : x.id;
377
- lines.push(` ${x.kind} ${id} ${x.count} 条`);
378
- }
379
- }
380
-
381
- const cs = roles.cacheStats();
382
- lines.push(`\n权限缓存\n 命中 ${cs.hits} / 未命中 ${cs.misses} · 命中率 ${cs.hitRate}% · 条目 ${cs.size}`);
383
-
384
- const events = [...rt.metrics.eventCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8);
385
- if (events.length) {
386
- lines.push('\n事件');
387
- for (const [name, n] of events) lines.push(` ${name} ${n}`);
388
- } else {
389
- lines.push('\n事件\n 还没收到任何事件(检查 intents 和沙箱群配置)');
390
- }
391
-
392
- await msg.reply(lines.join('\n'));
393
- });
394
-
395
- // 把 /admin 的子命令也写进菜单,保证「所有指令都能在菜单里查到」
396
- // 归到「管理」组:单独开一组会让 /help 主菜单多出一个「权限子命令」入口,
397
- // 而它本来就是管理的一部分
398
- registry.addMenuSection('__core', { name: '框架' }, '管理', [
399
- { cmd: 'admin me', desc: '查看自己在各作用域的权限', admin: true },
400
- { cmd: 'admin list [作用域]', desc: '列出某作用域的管理员', admin: true },
401
- { cmd: 'admin set <openid> <等级> [作用域]', desc: '设置权限,只能授予数字更大的等级', admin: true },
402
- { cmd: 'admin del <openid> [作用域]', desc: '移除权限', admin: true },
403
- { cmd: 'admin whois <openid>', desc: '查某人在各作用域的权限(含各插件)', admin: true },
404
- { cmd: 'admin scopes', desc: '列出所有已配置的作用域', admin: true },
405
- { cmd: 'admin levels', desc: '查看等级空间与插件号段分配', admin: true },
406
- ]);
407
-
408
- /* ---------------- 权限管理 ---------------- */
409
-
410
- const levelOf = (uid, scope = 'global') =>
411
- roles.effective(scope, uid, { owners: cfg.owners });
412
- const brief = (id) => (id.length > 14 ? id.slice(0, 8) + '…' + id.slice(-4) : id);
413
-
414
- core('admin', { desc: '权限管理:list / set / del / me / scopes' }, async (msg) => {
415
- const [sub, ...rest] = msg.args;
416
- const scope = scopeOf(msg);
417
- const myLevel = await levelOf(msg.userId, scope);
418
-
419
- // 不带参数:看当前作用域的权限情况
420
- if (!sub) {
421
- const members = await roles.scopeMembers(scope);
422
- const lines = [`作用域: ${scope}`, `你的等级: ${levelName(myLevel)}`];
423
- const list = Object.entries(members).sort((a, b) => a[1] - b[1]);
424
- if (list.length) {
425
- lines.push('\n本作用域管理员:');
426
- for (const [oid, lv] of list) lines.push(` ${brief(oid)} ${levelName(lv)}`);
427
- } else {
428
- lines.push('\n本作用域还没有配置管理员');
429
- }
430
- lines.push(
431
- `\n等级(数字越小权限越高): 0 主人 / 1 管理员 / 2 群管 / 3 助手 / 4+ 自定义`,
432
- `作用域: here(默认) / global / group:<id> / channel:<id> / feature:<插件>`,
433
- `设置: ${P}admin set <openid> <等级> [作用域]`,
434
- );
435
- return msg.reply(lines.join('\n'));
436
- }
437
-
438
- // 我是谁
439
- if (sub === 'me') {
440
- const owner = cfg.owners.includes(msg.userId);
441
- const rows = await roles.scopesOf(msg.userId);
442
- const lines = [`openid: ${msg.userId}`];
443
- if (owner) lines.push('身份: 主人(最高权限)');
444
- else {
445
- const g = await roles.levelIn('global', msg.userId);
446
- lines.push(`全局: ${g ? levelName(g) : '无'}`);
447
- if (rows.length) {
448
- lines.push('\n各作用域:');
449
- for (const r of rows) lines.push(` ${r.scope} ${levelName(r.level)}`);
450
- } else {
451
- lines.push('未在任何作用域获得权限');
452
- }
453
- }
454
- return msg.reply(lines.join('\n'));
455
- }
456
-
457
- // 等级空间与插件号段
458
- if (sub === 'levels') {
459
- if (myLevel > LEVEL.manager) return msg.reply('需要群管及以上权限');
460
- const rows = [...(plugins.allocations ?? new Map()).entries()].sort(
461
- (a, b) => a[1].base - b[1].base,
462
- );
463
- const lines = [
464
- '等级空间(数字越小权限越高)',
465
- ' 0 主人 / 1 管理员 / 2 群管 / 3 助手',
466
- ' 4-9 框架预留',
467
- ' 10+ 插件区,每个插件 10 个号位',
468
- ];
469
- if (rows.length) {
470
- lines.push('\n插件号段:');
471
- for (const [id, a] of rows) {
472
- const loaded = plugins.loaded.has(id) ? '运行中' : '未加载';
473
- lines.push(` ${id.padEnd(12)} ${a.base}-${a.end} ${loaded}`);
474
- }
475
- } else {
476
- lines.push('\n还没有插件');
477
- }
478
- return msg.reply(lines.join('\n'));
479
- }
480
-
481
- // 查某人在所有作用域的权限
482
- if (sub === 'whois') {
483
- const target = rest[0];
484
- if (!target) return msg.reply(`用法: ${P}admin whois <openid>`);
485
- if (myLevel > LEVEL.admin) return msg.reply('需要管理员权限');
486
-
487
- const rows = await roles.scopesOf(target);
488
- const lines = [`openid: ${target}`];
489
- if (cfg.owners.includes(target)) lines.push('身份: 主人');
490
- const g = await roles.levelIn('global', target);
491
- lines.push(`全局: ${g >= NO_PERM ? '无' : levelName(g)}`);
492
- if (rows.length) {
493
- lines.push('\n各作用域:');
494
- for (const r of rows) lines.push(` ${r.scope} ${levelName(r.level)}(${r.level})`);
495
- } else {
496
- lines.push('\n未在任何作用域获得权限');
497
- }
498
- return msg.reply(lines.join('\n'));
499
- }
500
-
501
- // 列出所有作用域
502
- if (sub === 'scopes') {
503
- const all = await roles.listScopes();
504
- if (!all.length) return msg.reply('还没有配置任何权限');
505
- return msg.reply(
506
- ['已配置的作用域:', ...all.map((x) => ` ${x.scope} ${x.count} 人`)].join('\n'),
507
- );
508
- }
509
-
510
- // 列某作用域成员
511
- if (sub === 'list') {
512
- const target = rest[0] ? parseScope(rest[0], msg) : scope;
513
- if (!target) return msg.reply(`作用域写法不对: ${rest[0]}`);
514
- if ((await levelOf(msg.userId, target)) > LEVEL.manager) {
515
- return msg.reply('需要该作用域的群管及以上权限');
516
- }
517
- const members = await roles.scopeMembers(target);
518
- const list = Object.entries(members).sort((a, b) => a[1] - b[1]);
519
- if (!list.length) return msg.reply(`${target} 下没有配置管理员`);
520
- return msg.reply(
521
- [`${target}:`, ...list.map(([oid, lv]) => ` ${brief(oid)} ${levelName(lv)}`)].join('\n'),
522
- );
523
- }
524
-
525
- // 设置
526
- if (sub === 'set') {
527
- const [openid, lvText, scopeText] = rest;
528
- const lv = parseLevel(lvText);
529
- if (!openid || lv === null) {
530
- return msg.reply(
531
- `用法: ${P}admin set <openid> <等级> [作用域]\n等级: 1 管理员 / 2 群管 / 3 助手 / 4 及以上自定义`,
532
- );
533
- }
534
- const target = scopeText ? parseScope(scopeText, msg) : scope;
535
- if (!target) return msg.reply(`作用域写法不对: ${scopeText}`);
536
- if (lv === LEVEL.owner) return msg.reply('主人只能在 config/bot.json 的 owners 里设置');
537
-
538
- const actor = await levelOf(msg.userId, target);
539
- if (actor > LEVEL.manager) {
540
- return msg.reply(`需要群管及以上权限才能设置(当前 ${levelName(actor)})`);
541
- }
542
- // 核心规则:数字越小权限越高,所以只能授予「数字更大」的等级
543
- if (lv <= actor) {
544
- return msg.reply(`不能授予同级或更高的权限(你当前是 ${levelName(actor)})`);
545
- }
546
- const cur = await roles.levelIn(target, openid);
547
- if (cur <= actor) {
548
- return msg.reply(`对方当前是 ${levelName(cur)},权限不低于你,无法修改`);
549
- }
550
-
551
- await roles.set(target, openid, lv);
552
- return msg.reply(`已设置 ${brief(openid)} 在 ${target} 为 ${levelName(lv)}`);
553
- }
554
-
555
- // 移除
556
- if (sub === 'del' || sub === 'remove') {
557
- const [openid, scopeText] = rest;
558
- if (!openid) return msg.reply(`用法: ${P}admin del <openid> [作用域]`);
559
- const target = scopeText ? parseScope(scopeText, msg) : scope;
560
- if (!target) return msg.reply(`作用域写法不对: ${scopeText}`);
561
-
562
- const cur = await roles.levelIn(target, openid);
563
- if (cur >= NO_PERM) return msg.reply('该用户在这个作用域没有权限');
564
- const actor = await levelOf(msg.userId, target);
565
- if (actor >= cur) return msg.reply('不能移除同级或更高权限的管理员');
566
-
567
- await roles.remove(target, openid);
568
- return msg.reply(`已移除 ${brief(openid)} 在 ${target} 的权限`);
569
- }
570
-
571
- await msg.reply(`未知子命令: ${sub}\n可用: list / set / del / me / scopes`);
572
- });
573
-
574
- core('plugins', { desc: '列出插件与状态' }, async (msg) => {
575
- const list = plugins.list();
576
- if (!list.length) return msg.reply('还没有安装任何插件。把插件文件夹放进 plugins/ 即可。');
577
-
578
- const lines = ['插件列表:'];
579
- for (const p of list) {
580
- const flag = p.loaded ? '运行中' : p.enabled ? '未加载' : '已禁用';
581
- const ver = p.version ? ` v${p.version}` : '';
582
- const menu = registry.menus.get(p.id);
583
- const cnt = menu ? menu.sections.reduce((n, s) => n + s.items.length, 0) : 0;
584
- const menuText = cnt ? `${cnt} 条菜单` : '未提供菜单';
585
- const alloc = plugins.allocations?.get(p.id);
586
- const lvText = alloc ? ` · 等级 ${alloc.base}-${alloc.end}` : '';
587
- lines.push(`${p.loaded ? '●' : '○'} ${p.id}${ver} [${flag}] · ${menuText}${lvText}`);
588
- if (p.description) lines.push(` ${p.description}`);
589
- if (p.error) lines.push(` 错误: ${p.error}`);
590
- }
591
- if (isOwner(msg.userId)) {
592
- lines.push(`\n管理: ${P}enable <id> / ${P}disable <id> / ${P}reload <id>`);
593
- }
594
- lines.push(`查看某插件菜单: ${P}help <插件id>`);
595
- await msg.reply(lines.join('\n'));
596
- });
597
-
598
- core('enable', { desc: '启用插件(需全局管理员)', admin: true, globalAdmin: true }, async (msg) => {
599
- const id = msg.args[0];
600
- if (!id) return msg.reply(`用法: ${P}enable <插件id>`);
601
- const ok = await plugins.setEnabled(id, true);
602
- await msg.reply(ok ? `已启用 ${id}` : `启用失败,查看日志或 ${P}plugins 看错误`);
603
- });
604
-
605
- core('disable', { desc: '禁用插件(需全局管理员)', admin: true, globalAdmin: true }, async (msg) => {
606
- const id = msg.args[0];
607
- if (!id) return msg.reply(`用法: ${P}disable <插件id>`);
608
- await plugins.setEnabled(id, false);
609
- await msg.reply(`已禁用 ${id}`);
610
- });
611
-
612
- /**
613
- * 分群插件开关。
614
- *
615
- * 和面板「群组 → 插件」「插件 → 分群开关」改的是同一份 cfg.plugins.groups,
616
- * 三个入口共用 plugin/groupscope.js 的翻译逻辑。
617
- *
618
- * 只影响分发时的判断,不动加载——插件在进程里是全局加载的,
619
- * 按群卸载等于每个群一套运行时,没必要。
620
- */
621
- core('群插件', { desc: '看/改这个群能用哪些插件(群管理)', admin: true }, async (msg) => {
622
- if (msg.kind !== 'group') return msg.reply('这个指令只能在群里用。');
623
-
624
- const chatId = msg.groupId;
625
- const spec = cfg.plugins ?? { mode: 'all', list: [] };
626
- const list = [...plugins.loaded.values()]
627
- .filter((p) => p.meta?.id && p.meta.id !== '__core')
628
- .sort((a, b) => a.meta.id.localeCompare(b.meta.id));
629
-
630
- const [act, ...rest] = msg.args;
631
- const pid = (rest[0] ?? '').trim();
632
-
633
- if (!act) {
634
- const lines = [`本群插件开关(${describeGroupScope(spec, chatId)}):`];
635
- for (const p of list) {
636
- const on = pluginAllowedIn(spec, chatId, p.meta.id);
637
- lines.push(`${on ? '●' : '○'} ${p.meta.id} ${p.meta.name ?? ''}`);
638
- }
639
- lines.push('', `关掉:${P}群插件 关 <id>`, `打开:${P}群插件 开 <id>`, `恢复跟随全局:${P}群插件 重置`);
640
- return msg.reply(lines.join('\n'));
641
- }
642
-
643
- if (act === '重置') {
644
- cfg.plugins = { ...spec, groups: resetGroupPlugins(spec, chatId) };
645
- save();
646
- return msg.reply('已恢复为跟随全局。');
647
- }
648
-
649
- const want =
650
- act === '关' || act === '关闭' || act === 'off' || act === '0'
651
- ? false
652
- : act === '开' || act === '打开' || act === 'on' || act === '1'
653
- ? true
654
- : null;
655
- if (want === null) {
656
- return msg.reply(`用法:${P}群插件 关 <id> / ${P}群插件 开 <id> / ${P}群插件 重置`);
657
- }
658
- if (!pid) return msg.reply(`要给插件 id,先发 ${P}群插件 看列表。`);
659
- if (!list.some((p) => p.meta.id === pid)) return msg.reply(`没有这个插件:${pid}`);
660
-
661
- cfg.plugins = { ...spec, groups: toggleGroupPlugin(spec, chatId, pid, want) };
662
- save();
663
- const scope = describeGroupScope(cfg.plugins, chatId);
664
- return msg.reply(`${want ? '已在本群启用' : '已在本群关闭'} ${pid}(${scope})`);
665
- });
666
-
667
- core('reload', { desc: '重载插件(需全局管理员)', admin: true, globalAdmin: true }, async (msg) => {
668
- const id = msg.args[0];
669
- if (id) {
670
- const ok = await plugins.reload(id);
671
- return msg.reply(ok ? `已重载 ${id}` : `重载 ${id} 失败,看日志`);
672
- }
673
- let n = 0;
674
- for (const p of plugins.list()) {
675
- if (p.enabled && (await plugins.reload(p.id))) n++;
676
- }
677
- await msg.reply(`已重载 ${n} 个插件`);
678
- });
679
- }
680
-
681
- /* ------------------------------------------------------------------ */
682
- /* Mock 模式:没有凭证也能开发插件 */
683
- /* ------------------------------------------------------------------ */
684
-
685
- function createMockApi(botName, metrics = null) {
686
- let mockGroupInvited = false;
687
- const echo = (scene) => async (target, body) => {
688
- metrics?.bump('sent');
689
- // markdown 消息单独打印正文,否则一堆 JSON 没法看
690
- const text = body?.markdown
691
- ? `[markdown]\n${body.markdown.content}`
692
- : body?.content ?? JSON.stringify(body);
693
- console.log(`\n→ [${botName}] [${scene} ${target}] ${text}`);
694
- return { id: 'mock', timestamp: Date.now() };
695
- };
696
- return {
697
- sendGroup: echo('群'),
698
- sendC2C: echo('私聊'),
699
- uploadMedia: async () => ({ file_info: 'mock', ttl: 0 }),
700
- uploadLocalFile: async () => ({ file_info: 'mock', ttl: 0 }),
701
- getGateway: async () => ({ url: 'wss://mock' }),
702
- getMe: async () => ({ username: 'mock-bot', id: '0' }),
703
- retractGroupMessage: async () => ({}),
704
- retractC2CMessage: async () => ({}),
705
- putInteraction: async () => ({}),
706
- // 群管:mock 下也走同一套闸门,方便本地验证插件的群管逻辑
707
- setGroupInvited(v) {
708
- mockGroupInvited = Boolean(v);
709
- return mockGroupInvited;
710
- },
711
- muteMembers: async (g, members) => {
712
- console.log(`\n→ [${botName}] [禁言 ${g}] ${JSON.stringify(members)}`);
713
- return {};
714
- },
715
- muteMember: async (g, uid, expireAt) => {
716
- console.log(`\n→ [${botName}] [禁言 ${g} ${uid}] ${expireAt || '立即解除'}`);
717
- return {};
718
- },
719
- getMuteStatus: async (g) => {
720
- console.log(`\n→ [${botName}] [查询禁言 ${g}]`);
721
- return { mute_list: [] };
722
- },
723
- getGroupInfo: async (g) => ({ group_openid: g, name: 'mock群', owner_openid: 'mock' }),
724
- // bot_state 是内邀接口,mock 同样走闸门;返回字段按官方文档来
725
- getBotGroupState: async (g) => {
726
- if (!mockGroupInvited) throw new Error('群管内邀功能未开启');
727
- console.log(`\n→ [${botName}] [机器人群内状态 ${g}]`);
728
- return {
729
- member_openid: 'mock-bot',
730
- joined_at: new Date().toISOString(),
731
- allow_proactive_msg: true,
732
- recv_msg_setting: 'all',
733
- member_role: 'admin', // mock 下默认当成群管理员,方便验证群管流程
734
- };
735
- },
736
- canManageGroup: async (g) => {
737
- if (!mockGroupInvited) {
738
- // 没开内邀就查不到角色:不拦截,让服务端去判
739
- return { ok: false, role: null, reason: '群管内邀功能未开启,无法预检' };
740
- }
741
- return { ok: true, role: 'admin', reason: '机器人是群管理员' };
742
- },
743
- clearRoleCache: () => {},
744
- getGroupMembers: async (g) => {
745
- if (!mockGroupInvited) throw new Error('群管内邀功能未开启');
746
- return { members: [], next_index: 0 };
747
- },
748
- getGroupMember: async (g, uid) => {
749
- if (!mockGroupInvited) throw new Error('群管内邀功能未开启');
750
- return { member_openid: uid, join_timestamp: 0 };
751
- },
752
- removeMembers: async (g, ids) => {
753
- if (!mockGroupInvited) throw new Error('群管内邀功能未开启');
754
- console.log(`\n→ [${botName}] [踢人 ${g}] ${JSON.stringify(ids)}`);
755
- return { fail_openids: [] };
756
- },
757
- getGroupBlacklist: async (g) => {
758
- if (!mockGroupInvited) throw new Error('群管内邀功能未开启');
759
- return { blacklist: [] };
760
- },
761
- operateGroupBlacklist: async (g, { op, member_openids }) => {
762
- if (!mockGroupInvited) throw new Error('群管内邀功能未开启');
763
- console.log(`\n→ [${botName}] [黑名单 ${op} ${g}] ${JSON.stringify(member_openids)}`);
764
- return { fail_openids: [] };
765
- },
766
- // 入群申请与自动审批:公开接口,mock 直接返回空列表
767
- getJoinRequests: async (g) => {
768
- console.log(`\n→ [${botName}] [入群申请列表 ${g}]`);
769
- return { request_list: [], cursor: '' };
770
- },
771
- handleJoinRequest: async (g, uid, body) => {
772
- console.log(`\n→ [${botName}] [审批入群 ${g} ${uid}] ${body.op}`);
773
- return {};
774
- },
775
- approveJoinRequest: async (g, uid, rid) => {
776
- console.log(`\n→ [${botName}] [通过入群 ${g} ${uid}] ${rid || ''}`);
777
- return {};
778
- },
779
- declineJoinRequest: async (g, uid, body = {}) => {
780
- console.log(`\n→ [${botName}] [拒绝入群 ${g} ${uid}] ${body.rejectReason || ''}`);
781
- return {};
782
- },
783
- listJoinStrategies: async () => {
784
- console.log(`\n→ [${botName}] [审批策略列表]`);
785
- return { strategy_list: [], cursor: '' };
786
- },
787
- createJoinStrategy: async (p) => {
788
- console.log(`\n→ [${botName}] [创建审批策略] ${JSON.stringify(p)}`);
789
- return { strategy_id: 'st_mock' };
790
- },
791
- updateJoinStrategy: async (id, p) => {
792
- console.log(`\n→ [${botName}] [修改审批策略 ${id}] ${JSON.stringify(p)}`);
793
- return {};
794
- },
795
- deleteJoinStrategy: async (id) => {
796
- console.log(`\n→ [${botName}] [删除审批策略 ${id}]`);
797
- return {};
798
- },
799
- executeJoinStrategy: async (id) => {
800
- console.log(`\n→ [${botName}] [执行审批策略 ${id}](官方异步,约 10 分钟完成)`);
801
- return {};
802
- },
803
- updateStrategyWhitelist: async (id, { op, whitelist_users }) => {
804
- console.log(`\n→ [${botName}] [白名单 ${op} ${id}] ${(whitelist_users || []).length} 个号码`);
805
- return { fail_users: [] };
806
- },
807
- // 流式消息与全局菜单:公开接口
808
- sendStreamMessage: async (openid, p) => {
809
- console.log(
810
- `\n→ [${botName}] [流式消息 ${openid}] 第 ${p.index ?? 0} 片 state=${p.state ?? 1} ` +
811
- `mode=${p.mode ?? 'append'}: ${String(p.contentRaw ?? '').slice(0, 40)}`,
812
- );
813
- return { id: `mock-stream-${p.index ?? 0}` };
814
- },
815
- getGlobalMenu: async () => {
816
- console.log(`\n→ [${botName}] [查询全局菜单]`);
817
- return { menu: { items: [] } };
818
- },
819
- setGlobalMenu: async (items) => {
820
- console.log(`\n→ [${botName}] [设置全局菜单] ${(items || []).length} 项`);
821
- return {};
822
- },
823
- };
824
- }
825
-
826
- /**
827
- * mock 模式:从标准输入读假消息,喂给第一个机器人。
828
- * 多机器人时想测哪个,用 !bot <id> 切换。
829
- */
830
- async function runMock(runtimes) {
831
- console.log('\n=== Mock 模式 ===');
832
- console.log('不连接 QQ,用本地假消息验证插件逻辑。输入内容回车发送,Ctrl+C 退出。');
833
- if (runtimes.length > 1) {
834
- console.log(`可用机器人: ${runtimes.map((r) => r.id).join(' / ')},用 !bot <id> 切换\n`);
835
- } else {
836
- console.log('');
837
- }
838
-
839
- let cur = runtimes[0];
840
- let asUser = null;
841
- const currentUser = () => asUser ?? cur.cfg.owners[0] ?? 'mock-user-openid';
842
- const fakeGroup = 'mock-group-openid';
843
- let seq = 0;
844
-
845
- const feed = async (line) => {
846
- const t = line.trim();
847
-
848
- if (t.startsWith('!bot')) {
849
- const [, id] = t.split(/\s+/);
850
- const next = runtimes.find((r) => r.id === id);
851
- if (!next) return console.log(`[模拟] 没有机器人 ${id},可用: ${runtimes.map((r) => r.id).join('/')}`);
852
- cur = next;
853
- return console.log(`[模拟] 当前机器人切换为 ${cur.id}`);
854
- }
855
- if (t.startsWith('!as')) {
856
- const [, id] = t.split(/\s+/);
857
- asUser = id || null;
858
- return console.log(`[模拟] 发送者切换为 ${currentUser()}`);
859
- }
860
- if (t.startsWith('!addbot')) {
861
- const [, gid = fakeGroup, inviter = 'mock-inviter'] = t.split(/\s+/);
862
- console.log(`[模拟] ${cur.id} 被 ${inviter} 拉进群 ${gid}`);
863
- return cur.dispatch('GROUP_ADD_ROBOT', {
864
- group_openid: gid,
865
- op_member_openid: inviter,
866
- timestamp: String(Date.now()),
867
- });
868
- }
869
- if (t.startsWith('!delbot')) {
870
- const [, gid = fakeGroup] = t.split(/\s+/);
871
- console.log(`[模拟] ${cur.id} 被移出群 ${gid}`);
872
- return cur.dispatch('GROUP_DEL_ROBOT', { group_openid: gid, timestamp: String(Date.now()) });
873
- }
874
-
875
- return cur.dispatch('GROUP_AT_MESSAGE_CREATE', {
876
- id: `mock-${++seq}`,
877
- content: line,
878
- group_openid: fakeGroup,
879
- author: { member_openid: currentUser() },
880
- timestamp: String(Date.now()),
881
- });
882
- };
883
-
884
- const send = (line) => {
885
- Promise.resolve(feed(line)).catch((e) => log.error('mock 处理出错:', e.message));
886
- };
887
-
888
- if (!process.stdin.isTTY) {
889
- const chunks = [];
890
- for await (const chunk of process.stdin) chunks.push(chunk);
891
- const lines = Buffer.concat(chunks).toString('utf8').split(/\r?\n/);
892
- const jobs = [];
893
- for (const line of lines) {
894
- if (!line.trim()) continue;
895
- console.log(`你> ${line}`);
896
- if (line.trim().startsWith('!')) {
897
- // 控制命令(!bot / !as / !addbot)必须按顺序执行,
898
- // 否则「切机器人」会晚于后面那条消息生效
899
- await feed(line).catch((e) => log.error('mock 出错:', e.message));
900
- } else {
901
- // 普通消息并发投递:否则命令里的 ctx.prompt() 会卡住输入循环
902
- jobs.push(Promise.resolve(feed(line)).catch((e) => log.error('mock 出错:', e.message)));
903
- }
904
- }
905
- await Promise.allSettled(jobs);
906
- return;
907
- }
908
-
909
- const readline = await import('node:readline/promises');
910
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
911
- while (true) {
912
- let line;
913
- try {
914
- line = await rl.question('你> ');
915
- } catch {
916
- break;
917
- }
918
- if (!line.trim()) continue;
919
- send(line);
920
- }
921
- rl.close();
922
- }
923
-
924
- /* ------------------------------------------------------------------ */
925
- /* 启动 */
926
- /* ------------------------------------------------------------------ */
927
-
928
- async function main() {
929
- const globalCfg = await loadGlobalConfig();
930
- // 下载源是全局的,模块里缓存一份,免得每个调用点都传配置
931
- configureMirror(globalCfg);
932
- setLevel(globalCfg.logLevel);
933
- runtime.startedAt = Date.now();
934
-
935
- /**
936
- * 兜底:任何漏网的异步异常都不要让进程直接死。
937
- *
938
- * Node 22+ 对未捕获的 rejection 默认终止进程——一个畸形请求、一条发不出去的
939
- * 消息,就会把面板和**所有**机器人一起带走。这里记日志后继续跑;
940
- * 真正的清理交给 SIGINT/SIGTERM 那条路。
941
- */
942
- process.on('unhandledRejection', (e) => {
943
- log.error(`未捕获的异步异常(已忽略,进程继续):${e?.stack || e?.message || e}`);
944
- });
945
- /**
946
- * 未捕获异常:不再一律吞掉。
947
- *
948
- * uncaughtException 之后进程处于未定义状态,继续跑可能带着被毒化的
949
- * 事件循环/句柄静默拖垮所有机器人。这里记日志 + 计数:
950
- * 60 秒内超过 5 次就判定「带病空转」,退出交给重启/守护自愈;
951
- * 偶发单次(如一个畸形回调)仍继续,避免一有风吹草动就重启。
952
- */
953
- let crashCount = 0;
954
- let crashWindowStart = Date.now();
955
- process.on('uncaughtException', (e) => {
956
- log.error(`未捕获的异常:${e?.stack || e?.message || e}`);
957
- if (Date.now() - crashWindowStart > 60000) {
958
- crashCount = 0;
959
- crashWindowStart = Date.now();
960
- }
961
- if (++crashCount > 5) {
962
- log.error('60 秒内未捕获异常超过 5 次,判定状态不可信,退出交由重启自愈');
963
- process.exit(1);
964
- }
965
- });
966
-
967
- if (globalCfg.logs?.enabled !== false) {
968
- await attachLogFile('core', PATHS.coreLog, { maxBytes: globalCfg.logs?.maxBytes });
969
- }
970
-
971
- /**
972
- * 上次更新如果被强杀(断电 / 任务管理器结束),src/ 可能是半新半旧。
973
- * 必须在加载任何代码之前回滚——晚一步就是在混着两个版本的进程里跑。
974
- */
975
- const recovered = await recoverInterruptedUpdate().catch((e) => {
976
- log.error(`检查未完成的更新时出错:${e.message}`);
977
- return { recovered: false };
978
- });
979
- if (recovered.recovered) {
980
- /**
981
- * 回滚后**必须退出**,不能接着跑。
982
- *
983
- * 模块是在这行之前 import 的,此刻内存里装的还是「被打断的那一版」,
984
- * 而磁盘已经回到旧版了。继续跑就是「用新代码读旧文件」,
985
- * 报错会指向一堆莫名其妙的地方。让用户重启一次,两边就一致了。
986
- */
987
- log.warn(
988
- `已回滚上次未完成的更新(${recovered.from} → ${recovered.to}),请重新启动一次`,
989
- );
990
- await flushLogs().catch(() => {});
991
- process.exit(0);
992
- }
993
-
994
- // 单机器人 → 多机器人结构迁移(只跑一次)
995
- const mig = await migrateToMultiBot();
996
- if (mig.migrated) {
997
- log.info(`已迁移到多机器人结构:机器人 ${mig.botId},搬移 ${mig.moved.length} 个文件`);
998
- }
999
-
1000
- let botCfgs = await loadAllBotConfigs();
1001
-
1002
- // 全新安装:建一个默认机器人
1003
- if (!botCfgs.length) {
1004
- const id = 'main';
1005
- await ensureDirs(id);
1006
- await createBotConfig(id, { name: '默认机器人' });
1007
- log.info(`已创建默认机器人配置: ${rel(botPaths(id).config)}`);
1008
- botCfgs = [await loadBotConfig(id)];
1009
- }
1010
-
1011
- /**
1012
- * 启动时给所有机器人配置拍一份快照。
1013
- *
1014
- * `config/bots/` 在 .gitignore 里(含 AppSecret,绝不能进仓库),
1015
- * 所以一旦被误删就**无处可恢复**——面板删除默认还是 purge,
1016
- * 连数据日志一起清。每天启动一次就多一份快照,成本几乎为零。
1017
- * 只保留最近 20 份,不会无限涨。
1018
- */
1019
- try {
1020
- const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
1021
- const destDir = path.join(DIRS.config, 'backups', stamp);
1022
- await fs.mkdir(destDir, { recursive: true });
1023
- for (const c of botCfgs) {
1024
- const src = botPaths(c.id).config;
1025
- await fs.copyFile(src, path.join(destDir, `${c.id}.json`)).catch(() => {});
1026
- }
1027
- // 只留最近 20 个快照
1028
- const all = (await fs.readdir(path.join(DIRS.config, 'backups'), { withFileTypes: true }))
1029
- .filter((e) => e.isDirectory())
1030
- .map((e) => e.name)
1031
- .sort();
1032
- for (const old of all.slice(0, Math.max(0, all.length - 20))) {
1033
- await fs.rm(path.join(DIRS.config, 'backups', old), { recursive: true, force: true }).catch(() => {});
1034
- }
1035
- } catch (e) {
1036
- log.warn(`配置快照失败(不影响启动):${e.message}`);
1037
- }
1038
-
1039
- // Webhook 接收服务:全局一个端口,按 AppID 分流给各个机器人。
1040
- // 只有机器人配了 connection.mode=webhook 才需要它,但开着也不占多少资源。
1041
- let webhookServer = null;
1042
- if (globalCfg.webhook?.enabled) {
1043
- webhookServer = new WebhookServer({
1044
- host: globalCfg.webhook.host,
1045
- port: globalCfg.webhook.port,
1046
- path: globalCfg.webhook.path,
1047
- // 默认验签。配里写 verifySignature=false 还不够,必须同时开 allowUnsigned
1048
- // ——关掉之后任何知道地址和 appId 的人都能伪造事件,见 webhook.js 的注释
1049
- verifySignature: globalCfg.webhook.verifySignature !== false,
1050
- allowUnsigned: globalCfg.webhook.allowUnsigned === true,
1051
- allowedIps: globalCfg.webhook.allowedIps,
1052
- perIpPerMin: globalCfg.webhook.perIpPerMin,
1053
- });
1054
- try {
1055
- const port = await webhookServer.start();
1056
- if (globalCfg.webhook.publicUrl) {
1057
- log.info(`Webhook 对外地址(请到开放平台填写): ${globalCfg.webhook.publicUrl}${webhookServer.path}`);
1058
- } else {
1059
- log.info(`Webhook 已监听 :${port}${webhookServer.path},公网部署请配反向代理和 HTTPS`);
1060
- }
1061
- } catch (e) {
1062
- log.error(`Webhook 启动失败: ${e.message}(webhook 模式的机器人将无法收事件)`);
1063
- webhookServer = null;
1064
- }
1065
- } else {
1066
- // 有人配了 webhook 模式但没开服务,提前说清楚
1067
- const wantWebhook = botCfgs.filter((c) => c.enabled !== false && c.connection?.mode === 'webhook');
1068
- if (wantWebhook.length) {
1069
- log.warn(
1070
- `有 ${wantWebhook.length} 个机器人配了 webhook 模式,但全局配置 webhook.enabled=false,` +
1071
- `它们收不到事件。去 config/bot.json 打开`,
1072
- );
1073
- }
1074
- }
1075
-
1076
- // 建运行时
1077
- const runtimes = [];
1078
- for (const cfg of botCfgs) {
1079
- if (cfg.enabled === false) {
1080
- log.info(`机器人 ${cfg.id}(${cfg.name})已禁用,跳过`);
1081
- continue;
1082
- }
1083
- if (!MOCK && (!cfg.appId || !cfg.appSecret)) {
1084
- // 注意要**把返回值写回 cfg**:ensureBotCredentials 返回的是重新读出来的配置,
1085
- // 不写回的话下面 new BotRuntime 拿到的还是空凭证,
1086
- // 用户明明输了 AppID 却报「缺少 AppID / AppSecret」。
1087
- Object.assign(cfg, await ensureBotCredentials(cfg.id));
1088
- }
1089
- const rt = new BotRuntime({
1090
- cfg,
1091
- globalCfg,
1092
- mock: MOCK,
1093
- webhook: webhookServer,
1094
- registerCore: (r) => registerCoreCommands(r),
1095
- makeMockApi: (r) => createMockApi(r.name, r.metrics),
1096
- // 广播给面板 SSE / 外部订阅者(AI 宿主靠这个收消息)
1097
- onMessage: (msg) => emitMessage(cfg.id, msg),
1098
- });
1099
- await rt.init();
1100
- runtimes.push(rt);
1101
- }
1102
-
1103
- if (!runtimes.length) {
1104
- log.error('没有可启动的机器人,检查 config/bots/*.json 里的 enabled');
1105
- process.exit(1);
1106
- }
1107
- log.info(`准备启动 ${runtimes.length} 个机器人: ${runtimes.map((r) => r.id).join('、')}`);
1108
-
1109
- /* ---------------- 面板 ---------------- */
1110
-
1111
- let web = null;
1112
-
1113
- /* ---------------- 后台服务 ---------------- */
1114
-
1115
- /**
1116
- * 健康巡检、文件清理、配置热加载、定时重启、检查更新。
1117
- * 都是增强,单个失败不影响主程序。
1118
- */
1119
- const services = new Services({
1120
- globalCfg,
1121
- getRuntimes: () => runtimes,
1122
- // 手改配置后同步到运行时(能热的字段)
1123
- onConfigReload: (nextGlobal, nextBot) => {
1124
- if (nextBot) {
1125
- const rt = runtimes.find((r) => r.id === nextBot.id);
1126
- if (rt) {
1127
- // 只同步能热改的字段。appId/appSecret 变了要重连,
1128
- // intents 变了也要重连,这些交给面板的保存逻辑处理。
1129
- const hot = ['commandPrefix', 'owners', 'whitelist', 'rateLimit', 'dedupWindowSec', 'groupManage', 'messageLog'];
1130
- for (const k of hot) {
1131
- if (nextBot[k] !== undefined) rt.cfg[k] = nextBot[k];
1132
- }
1133
- /**
1134
- * plugins 单独处理:它是对象,而且分群开关(groups)会随面板/指令频繁改。
1135
- * 之前漏了它,导致「改了配置、提示已热加载、行为却没变」——
1136
- * 面板上关掉的插件在群里照样能用,反过来也一样。
1137
- * 用 Object.assign 保留外层对象引用,别处拿着 cfg.plugins 的不会看到旧值。
1138
- */
1139
- if (nextBot.plugins) {
1140
- if (!rt.cfg.plugins) rt.cfg.plugins = nextBot.plugins;
1141
- else Object.assign(rt.cfg.plugins, nextBot.plugins);
1142
- }
1143
- rt.log.info('配置已热加载');
1144
- }
1145
- }
1146
- },
1147
- // 定时重启:先优雅关闭,再拉起新进程
1148
- onRestart: async () => {
1149
- log.warn('定时重启:正在关闭当前进程…');
1150
- await shutdown({ restarting: true });
1151
- },
1152
- });
1153
-
1154
- const shutdown = async ({ restarting = false } = {}) => {
1155
- if (shutdown.called) {
1156
- // 二次信号 = 关停卡住了,直接强退,别让人只能 kill -9
1157
- log.error('关停进行中又收到退出信号,强制退出');
1158
- process.exit(1);
1159
- }
1160
- shutdown.called = true;
1161
- log.info('正在退出...');
1162
- // 兜底超时:任一步 hang 都不会让进程永久卡在退出中
1163
- const hard = setTimeout(() => {
1164
- log.error('关停超时(10 秒),强制退出');
1165
- process.exit(1);
1166
- }, 10000);
1167
- hard.unref?.();
1168
- try {
1169
- await services.stop().catch(() => {});
1170
- web?.close();
1171
- await webhookServer?.stop().catch(() => {});
1172
- for (const rt of runtimes) await rt.stop().catch(() => {});
1173
- await flushLogs().catch(() => {});
1174
- if (restarting) {
1175
- // 拉起新进程后退出,裸机部署也能自愈
1176
- try {
1177
- const { spawn } = await import('node:child_process');
1178
- const child = spawn(process.execPath, process.argv.slice(1), {
1179
- cwd: process.cwd(),
1180
- detached: true,
1181
- stdio: 'inherit',
1182
- env: { ...process.env, ROOST_RESTARTED: '1' },
1183
- });
1184
- child.unref();
1185
- log.info(`已拉起新进程 pid=${child.pid},当前进程退出`);
1186
- } catch (e) {
1187
- log.error(`拉起新进程失败:${e.message},请手动重启`);
1188
- }
1189
- }
1190
- } finally {
1191
- clearTimeout(hard);
1192
- }
1193
- process.exit(0);
1194
- };
1195
- shutdown.called = false;
1196
-
1197
- // 面板现在支持切换机器人,不再固定展示第一个
1198
- if (runtimes.length > 1) {
1199
- log.info(`面板可切换的机器人: ${runtimes.map((r) => r.id).join('、')}`);
1200
- }
1201
-
1202
- web = await startWebServer({
1203
- globalCfg,
1204
- runtimes,
1205
- mock: MOCK,
1206
- saveBotConfig,
1207
- saveGlobalConfig,
1208
- onShutdown: shutdown,
1209
- services,
1210
-
1211
- /** 面板「新建机器人」 */
1212
- onBotAdd: async (id, patch) => {
1213
- if (!MOCK && (!patch.appId || !patch.appSecret)) {
1214
- throw new Error('AppID 和 AppSecret 都要填');
1215
- }
1216
-
1217
- // 上次如果用 purge=false 删过,data 目录还在。
1218
- // 直接新建会**静默复用**旧存档(权限、群档案、插件进度全都回来),
1219
- // 这种"以为删干净了其实没删"最容易出事故,所以这里直接拦住。
1220
- let leftover = [];
1221
- try {
1222
- leftover = await fs.readdir(botPaths(id).data);
1223
- } catch {
1224
- leftover = [];
1225
- }
1226
- if (leftover.length) {
1227
- throw new Error(
1228
- `data/bots/${id} 还留着上次删除时保留的数据(${leftover.length} 项)。` +
1229
- `新建会复用这些存档。要干净重来就先删掉该目录;确实想接着用旧数据,` +
1230
- `请手动把它移到目标位置后再建。`,
1231
- );
1232
- }
1233
-
1234
- const cfg = await createBotConfig(id, {
1235
- name: patch.name,
1236
- appId: patch.appId,
1237
- appSecret: patch.appSecret,
1238
- });
1239
- const rt = new BotRuntime({
1240
- cfg,
1241
- globalCfg,
1242
- mock: MOCK,
1243
- /**
1244
- * webhook 服务必须传进来。
1245
- * 以前这里漏了:全局开着 webhook 时,面板新建的 webhook 机器人
1246
- * 会直接报「配置选了 webhook 模式,但 Webhook 服务没启动」——
1247
- * 而服务其实好端端跑着,必须重启进程才能恢复。
1248
- */
1249
- webhook: webhookServer,
1250
- registerCore: (r) => registerCoreCommands(r),
1251
- makeMockApi: (r) => createMockApi(r.name, r.metrics),
1252
- onMessage: (msg) => emitMessage(id, msg),
1253
- });
1254
- await rt.init();
1255
- runtimes.push(rt);
1256
- await rt.start();
1257
- log.info(`新建机器人 ${id}(${rt.name}):${rt.state}`);
1258
- return rt;
1259
- },
1260
-
1261
- /** 面板「删除机器人」 */
1262
- onBotRemove: async (id, { purge }) => {
1263
- /**
1264
- * 删之前先把配置备份一份。
1265
- *
1266
- * 血泪教训:面板删除默认 `purge: true`,会连配置、数据、日志一起删。
1267
- * 误点一次,AppSecret 就永久没了——而 `config/bots/` 是 gitignore 的,
1268
- * 从任何地方都恢复不回来,只能去 QQ 开放平台重新拿。
1269
- * 所以不管 purge 与否,**配置先落一份到 config/backups/**。
1270
- */
1271
- try {
1272
- const src = botPaths(id).config;
1273
- await fs.access(src);
1274
- const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
1275
- const destDir = path.join(DIRS.config, 'backups');
1276
- await fs.mkdir(destDir, { recursive: true });
1277
- const dest = path.join(destDir, `${id}.${stamp}.json`);
1278
- await fs.copyFile(src, dest);
1279
- log.info(`已备份机器人配置:${rel(dest)}`);
1280
- } catch (e) {
1281
- if (e.code !== 'ENOENT') log.warn(`备份机器人配置失败(继续删除):${e.message}`);
1282
- }
1283
- const i = runtimes.findIndex((r) => r.id === id);
1284
- if (i >= 0) {
1285
- await runtimes[i].stop().catch(() => {});
1286
- runtimes.splice(i, 1);
1287
- }
1288
- const p = botPaths(id);
1289
- await fs.unlink(p.config).catch(() => {});
1290
- // 插件配置目录(config/bots/<id>/)
1291
- await fs.rm(path.dirname(p.pluginConfigDir), { recursive: true, force: true }).catch(() => {});
1292
- if (purge) {
1293
- await fs.rm(p.data, { recursive: true, force: true }).catch(() => {});
1294
- await fs.rm(p.logDir, { recursive: true, force: true }).catch(() => {});
1295
- }
1296
- log.info(`已删除机器人 ${id}${purge ? '(含数据与日志)' : '(保留数据)'}`);
1297
- if (!purge) {
1298
- log.warn(
1299
- `data/bots/${id} 和 logs/bots/${id} 保留了。注意:再用同名 id 新建会被拒绝,` +
1300
- `需要先手动清理该目录——否则旧存档会被静默复用。`,
1301
- );
1302
- }
1303
- },
1304
- });
1305
-
1306
- if (web && !args.has('--no-open') && globalCfg.webui?.openBrowser !== false && (!MOCK || SERVE)) {
1307
- if (openBrowser(web.url)) log.info('已在浏览器中打开面板');
1308
- }
1309
-
1310
- /* ---------------- mock 模式 ---------------- */
1311
-
1312
- if (MOCK) {
1313
- for (const rt of runtimes) await rt.start();
1314
-
1315
- if (SERVE) {
1316
- log.info('mock + serve:只跑面板,Ctrl+C 退出');
1317
- // mock 下也把服务拉起来,方便测服务本身(清理、健康巡检等)
1318
- await services.start();
1319
- process.on('SIGINT', shutdown);
1320
- process.on('SIGTERM', shutdown);
1321
- return new Promise(() => {});
1322
- }
1323
- await runMock(runtimes);
1324
- web?.close();
1325
- for (const rt of runtimes) await rt.stop().catch(() => {});
1326
- await flushLogs().catch(() => {});
1327
- return;
1328
- }
1329
-
1330
- /* ---------------- 真实启动 ---------------- */
1331
-
1332
- log.info(`全局配置: ${rel(PATHS.globalConfig)}`);
1333
- if (globalCfg.logs?.enabled !== false) {
1334
- log.info(`日志: ${rel(PATHS.coreLog)} · 机器人日志在 ${rel(DIRS.botsLogs)}/<id>/`);
1335
- }
1336
-
1337
- // 认领码:每个机器人各自生成,避免一个码能认领所有机器人
1338
- for (const rt of runtimes) {
1339
- if (!rt.cfg.owners.length) {
1340
- rt.claim.code = randomBytes(3).toString('hex').toUpperCase();
1341
- log.warn(`[${rt.id}] 尚未设置主人。认领码: ${rt.claim.code}`);
1342
- log.warn(`[${rt.id}] 在群里 @机器人 发 /claim ${rt.claim.code} 认领,或在面板里配置 owners。`);
1343
- }
1344
- }
1345
-
1346
- await Promise.all(runtimes.map((rt) => rt.start()));
1347
-
1348
- const online = runtimes.filter((r) => r.online);
1349
- log.info(`已启动 ${online.length}/${runtimes.length} 个机器人`);
1350
- for (const rt of runtimes) {
1351
- if (!rt.online) log.error(`[${rt.id}] 未连上:${rt.lastError || '未知原因'}`);
1352
- }
1353
-
1354
- // 后台服务放在最后启动,不干扰启动流程的日志
1355
- await services.start();
1356
-
1357
- process.on('SIGINT', shutdown);
1358
- process.on('SIGTERM', shutdown);
1359
- }
1360
-
1361
- main().catch((e) => {
1362
- log.error(e.stack || e.message);
1363
- process.exit(1);
1364
- });
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { randomBytes } from 'node:crypto';
4
+ import process from 'node:process';
5
+ import {
6
+ loadGlobalConfig,
7
+ saveGlobalConfig,
8
+ loadBotConfig,
9
+ loadAllBotConfigs,
10
+ saveBotConfig,
11
+ createBotConfig,
12
+ ensureBotCredentials,
13
+ migrateToMultiBot,
14
+ } from './config.js';
15
+ import { DIRS, PATHS, botPaths, listBotIds, ensureDirs, rel } from './paths.js';
16
+ import { createLogger, setLevel, getLevel, attachLogFile, flushLogs } from './logger.js';
17
+ import { configureMirror } from './mirror.js';
18
+ import { BotRuntime, pluginAllowedIn } from './bot.js';
19
+ import { toggleGroupPlugin, resetGroupPlugins, describeGroupScope } from './plugin/groupscope.js';
20
+ import { runtime, sanitizePayload } from './runtime.js';
21
+ import { LEVEL, NO_PERM, levelName, parseLevel, parseScope, scopeOf } from './roles.js';
22
+ import { openBrowser } from './browser.js';
23
+ import { WebhookServer } from './webhook.js';
24
+ import { startWebServer } from './web/server.js';
25
+ import { Services } from './services/index.js';
26
+ import { recoverInterruptedUpdate } from './updater.js';
27
+ import { emitMessage } from './message-bus.js';
28
+
29
+ /* ------------------------------------------------------------------ */
30
+ /* 开箱即用:先把运行环境检查清楚,别让人对着莫名其妙的报错猜 */
31
+ /* ------------------------------------------------------------------ */
32
+
33
+ const MIN_NODE_MAJOR = 22;
34
+ const nodeMajor = Number(process.versions.node.split('.')[0]);
35
+ if (!Number.isFinite(nodeMajor) || nodeMajor < MIN_NODE_MAJOR) {
36
+ console.error(
37
+ `\n需要 Node.js ${MIN_NODE_MAJOR} 或更高版本,当前是 ${process.versions.node}。\n` +
38
+ `本项目用到了内置的全局 WebSocket 和 fetch,低版本跑不起来。\n` +
39
+ `去 https://nodejs.org/ 装一个 LTS 版本即可,不需要 npm install。\n`,
40
+ );
41
+ process.exit(1);
42
+ }
43
+
44
+ const log = createLogger('core');
45
+
46
+ /**
47
+ * 已知的系统事件不再单独维护一份白名单——全部事件登记在 src/events.js。
48
+ * 这里只保留一个提示:收到登记表里没有的事件时,说明官方加了新事件,
49
+ * 需要补登记(但不影响转发给插件)。
50
+ */
51
+ const args = new Set(process.argv.slice(2));
52
+ const MOCK = args.has('--mock');
53
+ const CHECK_ONLY = args.has('--check');
54
+ const SERVE = args.has('--serve');
55
+
56
+ if (args.has('--help') || args.has('-h')) {
57
+ console.log(`
58
+ qq-bot-roost(栖 Roost)· QQ 机器人框架
59
+
60
+ 用法:
61
+ node src/main.js 连接 QQ,正常跑(会自动打开面板)
62
+ node src/main.js --no-open 同上,但不自动开浏览器
63
+ node src/main.js --check 只验证 AppID/AppSecret 和网关,不常驻
64
+ node src/main.js --mock 不连 QQ,用本地假消息开发插件
65
+ node src/main.js --mock --serve 只启动可视化面板,不读标准输入(调界面用)
66
+ node src/main.js --paused 启动时先全局暂停,确认配置无误后在面板放开
67
+ node src/main.js --force 已经有实例在跑时,强行再开一个(不推荐)
68
+ node src/main.js --help 看这个帮助
69
+
70
+ 同一个 AppID 默认只允许一个实例,重复启动会拒绝并给出 PID。
71
+ 面板端口被占用时会自动顺延(默认最多试 20 个),实际地址看启动日志。
72
+
73
+ 配置:
74
+ config/bot.json 本体配置(AppID / AppSecret / intents / owners / webui)
75
+ config/bots/<机器人id>/plugins/<插件id>.json 插件配置,首次加载自动生成
76
+ logs/bot.log 本体日志(含所有插件输出)
77
+ logs/plugins/<插件id>.log 单个插件的日志
78
+ data/ 运行时数据(存档、权限、额度),不要手改
79
+ 环境变量 QQ_BOT_APPID / QQ_BOT_SECRET / QQ_BOT_OWNERS
80
+ 可视化面板 默认 http://127.0.0.1:15207
81
+ `);
82
+ process.exit(0);
83
+ }
84
+
85
+ /* ------------------------------------------------------------------ */
86
+ /* 消息前置检查已迁到 src/pipeline.js(可插拔中间件),这里不再硬编码 */
87
+ /* ------------------------------------------------------------------ */
88
+
89
+ /* ------------------------------------------------------------------ */
90
+ /* 核心命令:不依赖任何插件,保证机器人一启动就能用 */
91
+ /* ------------------------------------------------------------------ */
92
+
93
+ function registerCoreCommands(rt) {
94
+ const { cfg, registry, plugins, tokens, roles, claim, outbound, session, id } = rt;
95
+ const save = () => saveBotConfig(id, cfg);
96
+ const P = cfg.commandPrefix || '/';
97
+ const isOwner = (uid) => cfg.owners.includes(uid);
98
+
99
+ /**
100
+ * 内核命令分到哪一组。
101
+ *
102
+ * 以前所有命令都塞进「指令」一组,`/help` 主菜单二十多行。
103
+ * 现在分三组,主菜单只放「常用 + 分类入口」,具体命令进下级菜单。
104
+ */
105
+ const CORE_SECTION = {
106
+ ping: '基础',
107
+ whoami: '基础',
108
+ status: '基础',
109
+ stats: '基础',
110
+ admin: '管理',
111
+ plugins: '管理',
112
+ enable: '管理',
113
+ disable: '管理',
114
+ reload: '管理',
115
+ 群插件: '管理',
116
+ help: '其它',
117
+ menu: '其它',
118
+ claim: '其它',
119
+ };
120
+
121
+ const core = (name, meta, handler) =>
122
+ registry.addCommand(
123
+ '__core',
124
+ name,
125
+ { plugin: '框架', section: CORE_SECTION[name] ?? '其它', ...meta },
126
+ handler,
127
+ );
128
+
129
+ /** 把某插件的菜单渲染成文本 */
130
+ /**
131
+ * 把注册表里的菜单转成 renderMenuMd 需要的分组结构。
132
+ * 命令项做成可点击标签,非命令项(被动触发的关键词)只显示文字。
133
+ */
134
+ function menuGroupsOf(m, { withCommands = true } = {}) {
135
+ const groups = [];
136
+ for (const sec of m.sections) {
137
+ if (!sec.items.length) continue;
138
+ groups.push({ title: sec.title, items: sec.items.map((it) => menuItemOf(it, withCommands)) });
139
+ }
140
+ return groups;
141
+ }
142
+
143
+ /**
144
+ * 单个菜单项 → 可点击项。
145
+ * 带参数的命令只把「命令名」做成可点击(点一下插入 /admin,参数自己补),
146
+ * 完整用法放说明里——否则点出来是一串 <openid> 占位符,没法直接用。
147
+ */
148
+ function menuItemOf(it, withCommands = true) {
149
+ if (!it.isCommand) {
150
+ return { text: it.text, desc: it.desc || '', cmd: null };
151
+ }
152
+ const pattern = String(it.text).trim();
153
+ const name = pattern.split(/\s+/)[0];
154
+ const hasArgs = pattern !== name;
155
+ return {
156
+ text: `${P}${name}`,
157
+ // 用法包在反引号里,里面有什么符号都不会被 markdown 吃掉
158
+ desc: hasArgs ? `用法 \`${P}${pattern}\`${it.desc ? ' · ' + it.desc : ''}` : it.desc || '',
159
+ cmd: withCommands ? `${P}${name}` : null,
160
+ };
161
+ }
162
+
163
+ /** 全局菜单 */
164
+ core('help', { desc: '全局菜单;加插件 id 或分类名看下级菜单' }, async (msg) => {
165
+ const raw = (msg.args[0] || '').trim();
166
+ const arg = raw.toLowerCase();
167
+ const myLv = await roles.effective(scopeOf(msg), msg.userId, { owners: cfg.owners });
168
+
169
+ /** 内核命令按权限过滤后的分组(分组名 → 菜单项) */
170
+ const coreSections = () => {
171
+ const out = new Map();
172
+ for (const sec of registry.menus.get('__core')?.sections ?? []) {
173
+ const items = [];
174
+ for (const it of sec.items) {
175
+ if (it.admin && myLv > LEVEL.admin) continue;
176
+ if (it.globalAdmin && myLv > LEVEL.admin) continue;
177
+ items.push(menuItemOf(it));
178
+ }
179
+ if (items.length) out.set(sec.title, items);
180
+ }
181
+ return out;
182
+ };
183
+
184
+ const pluginMenus = [...registry.menus.values()].filter((m) => m.id !== '__core');
185
+
186
+ /* ---------- 有参数:进下级 ---------- */
187
+ if (arg) {
188
+ // 1) 插件 id
189
+ const m = registry.menus.get(arg);
190
+ if (m) {
191
+ return msg.replyMenu(`${m.title}${m.version ? ' v' + m.version : ''}`, menuGroupsOf(m));
192
+ }
193
+
194
+ // 2) 「插件」→ 插件列表
195
+ if (raw === '插件' || arg === 'plugins') {
196
+ if (!pluginMenus.length) {
197
+ return msg.reply('还没有安装插件。把文件夹放进 plugins/ 就会出现在这里。');
198
+ }
199
+ return msg.replyMenu('插件', [
200
+ {
201
+ title: `共 ${pluginMenus.length} 个`,
202
+ items: pluginMenus.map((x) => {
203
+ const cnt = x.sections.reduce((n, s) => n + s.items.length, 0);
204
+ return {
205
+ text: x.id,
206
+ desc: `${x.title}${cnt ? ` · ${cnt} 条` : ' · 未提供菜单'}`,
207
+ cmd: `${P}help ${x.id}`,
208
+ };
209
+ }),
210
+ },
211
+ ]);
212
+ }
213
+
214
+ // 3) 内核分组名(基础 / 管理 / 其它)
215
+ const secs = coreSections();
216
+ const hit = secs.get(raw);
217
+ if (hit) return msg.replyMenu(`框架 · ${raw}`, [{ title: raw, items: hit }]);
218
+
219
+ const names = [...secs.keys(), '插件', ...pluginMenus.map((x) => x.id)];
220
+ return msg.reply(`没有「${raw}」这个分类或插件。\n可用:${names.join('、')}`);
221
+ }
222
+
223
+ /* ---------- 没参数:简短主菜单 ---------- */
224
+ /**
225
+ * 主菜单只放两样东西:最常用的三条命令,和分类入口。
226
+ *
227
+ * 以前是把内核十几个命令全平铺出来,加上插件列表二十多行,
228
+ * 手机上要滑两屏,而且大部分命令一个普通群成员根本用不到。
229
+ */
230
+ const secs = coreSections();
231
+ const groups = [];
232
+
233
+ const common = [];
234
+ for (const name of ['ping', 'whoami', 'help']) {
235
+ for (const items of secs.values()) {
236
+ const it = items.find((x) => x.text === `${P}${name}`);
237
+ if (it) {
238
+ common.push(it);
239
+ break;
240
+ }
241
+ }
242
+ }
243
+ if (common.length) groups.push({ title: '常用', items: common });
244
+
245
+ const entries = [];
246
+ for (const [title, items] of secs) {
247
+ if (title === '其它') continue;
248
+ // /admin 的 7 个子命令都显示成 `/admin`,去重后再列,否则一行全是重复
249
+ const uniq = [...new Set(items.map((x) => x.text))];
250
+ entries.push({
251
+ text: title,
252
+ desc: `${items.length} 条 · ${uniq.join(' ')}`,
253
+ cmd: `${P}help ${title}`,
254
+ });
255
+ }
256
+ if (pluginMenus.length) {
257
+ entries.push({
258
+ text: '插件',
259
+ desc: `${pluginMenus.length} 个:${pluginMenus.map((x) => x.id).join('、')}`,
260
+ cmd: `${P}help 插件`,
261
+ });
262
+ }
263
+ if (entries.length) groups.push({ title: '分类', items: entries });
264
+
265
+ const bot = rt.metrics.botName || '菜单';
266
+ await msg.replyMenu(bot, groups);
267
+ });
268
+
269
+ /**
270
+ * /menu 是 /help 的别名。
271
+ *
272
+ * 直接复用 help 的 handler,而不是再写一份——两份菜单各自演化,
273
+ * 迟早会出现「/help 里有、/menu 里没有」这种对不上的情况。
274
+ */
275
+ core('menu', { desc: '等同 /help,查看菜单' }, async (msg) => {
276
+ const h = registry.commands.get('help')?.handler;
277
+ if (h) await h(msg, { args: msg.args, raw: msg.content, bot: rt });
278
+ });
279
+
280
+ core('ping', { desc: '测试机器人是否在线' }, async (msg) => {
281
+ await msg.reply(`pong(已运行 ${Math.floor(runtime.uptimeMs / 1000)} 秒)`);
282
+ });
283
+
284
+ core('whoami', { desc: '查看自己的 openid(配置主人用)' }, async (msg) => {
285
+ const lv = await roles.effective(scopeOf(msg), msg.userId, { owners: cfg.owners });
286
+ const lines = [
287
+ `来源: ${msg.kind}`,
288
+ `你的 openid: ${msg.userId}`,
289
+ msg.groupId ? `群 openid: ${msg.groupId}` : '',
290
+ `是否主人: ${isOwner(msg.userId) ? '是' : '否'}`,
291
+ `当前权限: ${levelName(lv)}`,
292
+ ].filter(Boolean);
293
+ await msg.reply(lines.join('\n'));
294
+ });
295
+
296
+ core('claim', { desc: '认领主人身份,需要启动日志里的认领码', usage: `${P}claim <认领码>` }, async (msg) => {
297
+ if (cfg.owners.length > 0) {
298
+ return msg.reply('已经设置过主人了。如需变更,请改 config/bot.json 的 owners,或在面板里配置。');
299
+ }
300
+ if (!claim.code) {
301
+ return msg.reply('当前没有可用的认领码,请重启机器人后再试。');
302
+ }
303
+ const input = String(msg.args[0] ?? '').trim().toUpperCase();
304
+ if (!input) {
305
+ return msg.reply(
306
+ `需要认领码。\n启动日志里有一行「认领码: XXXXXX」,面板「日志」页也能看到。\n用法: ${P}claim <认领码>`,
307
+ );
308
+ }
309
+ if (input !== claim.code) {
310
+ return msg.reply('认领码不正确。');
311
+ }
312
+ cfg.owners.push(msg.userId);
313
+ claim.code = '';
314
+ await save();
315
+ await msg.reply(`已把你设为主人:${msg.userId}\n已写入该机器人的配置文件,重启后依然有效。`);
316
+ });
317
+
318
+ /** 运行状态总览 */
319
+ core('status', { desc: '机器人运行状态' }, async (msg) => {
320
+ const list = plugins.list();
321
+ const running = list.filter((p) => p.loaded).length;
322
+ const up = Math.floor(runtime.uptimeMs / 1000);
323
+ const d = Math.floor(up / 86400);
324
+ const h = Math.floor((up % 86400) / 3600);
325
+ const m = Math.floor((up % 3600) / 60);
326
+ const uptime = `${d ? d + '天' : ''}${h ? h + '小时' : ''}${m}分${up < 60 ? up + '秒' : ''}`;
327
+
328
+ const lines = [
329
+ `连接: ${rt.metrics.connected ? '在线' : '离线'}`,
330
+ `机器人: ${rt.metrics.botName || '-'}`,
331
+ `运行: ${uptime}`,
332
+ ];
333
+
334
+ if (tokens?.expiresAt) {
335
+ const left = Math.max(0, Math.round((tokens.expiresAt - Date.now()) / 1000));
336
+ lines.push(`凭证: 剩余 ${Math.floor(left / 60)} 分(自动续期)`);
337
+ }
338
+
339
+ lines.push(`消息: 收 ${rt.metrics.counters.recv} / 发 ${rt.metrics.counters.sent} / 命令 ${rt.metrics.counters.commands}`);
340
+ lines.push(`插件: ${running}/${list.length} 运行中`);
341
+
342
+ // 官方接口的额度与窗口状态,出问题时能一眼看出是哪里卡住
343
+ if (outbound) {
344
+ const q = await outbound.quotaSnapshot();
345
+ const used = q[`${msg.kind}:${msg.chatId}`] ?? 0;
346
+ lines.push(
347
+ `本会话主动消息: ${used}/${outbound.dailyActive} 条(今日)` +
348
+ `${used >= outbound.dailyActive ? ' · 已用完,需等用户 @ 才能继续' : ''}`,
349
+ );
350
+ if (outbound.stats.degraded) lines.push(`被动超窗降级: ${outbound.stats.degraded} 次`);
351
+ if (outbound.stats.expired) lines.push(`回复过期被拒: ${outbound.stats.expired} 次`);
352
+ }
353
+ if (session) {
354
+ const s = session.snapshot();
355
+ const remain = session.stickyRemain(msg.chatId);
356
+ if (remain > 0) lines.push(`免@窗口: 还剩 ${remain} 秒`);
357
+ if (s.pending) lines.push(`等待中的多轮对话: ${s.pending} 个`);
358
+ }
359
+
360
+ lines.push(`内存: ${Math.round(process.memoryUsage().rss / 1048576)} MB`);
361
+ await msg.reply(lines.join('\n'));
362
+ });
363
+
364
+ /** 详细统计 */
365
+ core('stats', { desc: '消息与事件统计' }, async (msg) => {
366
+ const c = rt.metrics.counters;
367
+ const lines = [
368
+ '消息统计',
369
+ ` 收到 ${c.recv} 发出 ${c.sent} 命令 ${c.commands} 错误 ${c.errors}`,
370
+ ` 去重 ${c.dedup} 限流 ${c.limited} 白名单 ${c.rejected} 无人回应 ${c.ignored}`,
371
+ ];
372
+
373
+ const chats = [...rt.metrics.perChat.values()].sort((a, b) => b.count - a.count).slice(0, 5);
374
+ if (chats.length) {
375
+ lines.push('\n活跃会话');
376
+ for (const x of chats) {
377
+ const id = x.id.length > 12 ? x.id.slice(0, 6) + '…' + x.id.slice(-4) : x.id;
378
+ lines.push(` ${x.kind} ${id} ${x.count} 条`);
379
+ }
380
+ }
381
+
382
+ const cs = roles.cacheStats();
383
+ lines.push(`\n权限缓存\n 命中 ${cs.hits} / 未命中 ${cs.misses} · 命中率 ${cs.hitRate}% · 条目 ${cs.size}`);
384
+
385
+ const events = [...rt.metrics.eventCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8);
386
+ if (events.length) {
387
+ lines.push('\n事件');
388
+ for (const [name, n] of events) lines.push(` ${name} ${n}`);
389
+ } else {
390
+ lines.push('\n事件\n 还没收到任何事件(检查 intents 和沙箱群配置)');
391
+ }
392
+
393
+ await msg.reply(lines.join('\n'));
394
+ });
395
+
396
+ // 把 /admin 的子命令也写进菜单,保证「所有指令都能在菜单里查到」
397
+ // 归到「管理」组:单独开一组会让 /help 主菜单多出一个「权限子命令」入口,
398
+ // 而它本来就是管理的一部分
399
+ registry.addMenuSection('__core', { name: '框架' }, '管理', [
400
+ { cmd: 'admin me', desc: '查看自己在各作用域的权限', admin: true },
401
+ { cmd: 'admin list [作用域]', desc: '列出某作用域的管理员', admin: true },
402
+ { cmd: 'admin set <openid> <等级> [作用域]', desc: '设置权限,只能授予数字更大的等级', admin: true },
403
+ { cmd: 'admin del <openid> [作用域]', desc: '移除权限', admin: true },
404
+ { cmd: 'admin whois <openid>', desc: '查某人在各作用域的权限(含各插件)', admin: true },
405
+ { cmd: 'admin scopes', desc: '列出所有已配置的作用域', admin: true },
406
+ { cmd: 'admin levels', desc: '查看等级空间与插件号段分配', admin: true },
407
+ ]);
408
+
409
+ /* ---------------- 权限管理 ---------------- */
410
+
411
+ const levelOf = (uid, scope = 'global') =>
412
+ roles.effective(scope, uid, { owners: cfg.owners });
413
+ const brief = (id) => (id.length > 14 ? id.slice(0, 8) + '…' + id.slice(-4) : id);
414
+
415
+ core('admin', { desc: '权限管理:list / set / del / me / scopes' }, async (msg) => {
416
+ const [sub, ...rest] = msg.args;
417
+ const scope = scopeOf(msg);
418
+ const myLevel = await levelOf(msg.userId, scope);
419
+
420
+ // 不带参数:看当前作用域的权限情况
421
+ if (!sub) {
422
+ const members = await roles.scopeMembers(scope);
423
+ const lines = [`作用域: ${scope}`, `你的等级: ${levelName(myLevel)}`];
424
+ const list = Object.entries(members).sort((a, b) => a[1] - b[1]);
425
+ if (list.length) {
426
+ lines.push('\n本作用域管理员:');
427
+ for (const [oid, lv] of list) lines.push(` ${brief(oid)} ${levelName(lv)}`);
428
+ } else {
429
+ lines.push('\n本作用域还没有配置管理员');
430
+ }
431
+ lines.push(
432
+ `\n等级(数字越小权限越高): 0 主人 / 1 管理员 / 2 群管 / 3 助手 / 4+ 自定义`,
433
+ `作用域: here(默认) / global / group:<id> / channel:<id> / feature:<插件>`,
434
+ `设置: ${P}admin set <openid> <等级> [作用域]`,
435
+ );
436
+ return msg.reply(lines.join('\n'));
437
+ }
438
+
439
+ // 我是谁
440
+ if (sub === 'me') {
441
+ const owner = cfg.owners.includes(msg.userId);
442
+ const rows = await roles.scopesOf(msg.userId);
443
+ const lines = [`openid: ${msg.userId}`];
444
+ if (owner) lines.push('身份: 主人(最高权限)');
445
+ else {
446
+ const g = await roles.levelIn('global', msg.userId);
447
+ lines.push(`全局: ${g ? levelName(g) : '无'}`);
448
+ if (rows.length) {
449
+ lines.push('\n各作用域:');
450
+ for (const r of rows) lines.push(` ${r.scope} ${levelName(r.level)}`);
451
+ } else {
452
+ lines.push('未在任何作用域获得权限');
453
+ }
454
+ }
455
+ return msg.reply(lines.join('\n'));
456
+ }
457
+
458
+ // 等级空间与插件号段
459
+ if (sub === 'levels') {
460
+ if (myLevel > LEVEL.manager) return msg.reply('需要群管及以上权限');
461
+ const rows = [...(plugins.allocations ?? new Map()).entries()].sort(
462
+ (a, b) => a[1].base - b[1].base,
463
+ );
464
+ const lines = [
465
+ '等级空间(数字越小权限越高)',
466
+ ' 0 主人 / 1 管理员 / 2 群管 / 3 助手',
467
+ ' 4-9 框架预留',
468
+ ' 10+ 插件区,每个插件 10 个号位',
469
+ ];
470
+ if (rows.length) {
471
+ lines.push('\n插件号段:');
472
+ for (const [id, a] of rows) {
473
+ const loaded = plugins.loaded.has(id) ? '运行中' : '未加载';
474
+ lines.push(` ${id.padEnd(12)} ${a.base}-${a.end} ${loaded}`);
475
+ }
476
+ } else {
477
+ lines.push('\n还没有插件');
478
+ }
479
+ return msg.reply(lines.join('\n'));
480
+ }
481
+
482
+ // 查某人在所有作用域的权限
483
+ if (sub === 'whois') {
484
+ const target = rest[0];
485
+ if (!target) return msg.reply(`用法: ${P}admin whois <openid>`);
486
+ if (myLevel > LEVEL.admin) return msg.reply('需要管理员权限');
487
+
488
+ const rows = await roles.scopesOf(target);
489
+ const lines = [`openid: ${target}`];
490
+ if (cfg.owners.includes(target)) lines.push('身份: 主人');
491
+ const g = await roles.levelIn('global', target);
492
+ lines.push(`全局: ${g >= NO_PERM ? '无' : levelName(g)}`);
493
+ if (rows.length) {
494
+ lines.push('\n各作用域:');
495
+ for (const r of rows) lines.push(` ${r.scope} ${levelName(r.level)}(${r.level})`);
496
+ } else {
497
+ lines.push('\n未在任何作用域获得权限');
498
+ }
499
+ return msg.reply(lines.join('\n'));
500
+ }
501
+
502
+ // 列出所有作用域
503
+ if (sub === 'scopes') {
504
+ const all = await roles.listScopes();
505
+ if (!all.length) return msg.reply('还没有配置任何权限');
506
+ return msg.reply(
507
+ ['已配置的作用域:', ...all.map((x) => ` ${x.scope} ${x.count} 人`)].join('\n'),
508
+ );
509
+ }
510
+
511
+ // 列某作用域成员
512
+ if (sub === 'list') {
513
+ const target = rest[0] ? parseScope(rest[0], msg) : scope;
514
+ if (!target) return msg.reply(`作用域写法不对: ${rest[0]}`);
515
+ if ((await levelOf(msg.userId, target)) > LEVEL.manager) {
516
+ return msg.reply('需要该作用域的群管及以上权限');
517
+ }
518
+ const members = await roles.scopeMembers(target);
519
+ const list = Object.entries(members).sort((a, b) => a[1] - b[1]);
520
+ if (!list.length) return msg.reply(`${target} 下没有配置管理员`);
521
+ return msg.reply(
522
+ [`${target}:`, ...list.map(([oid, lv]) => ` ${brief(oid)} ${levelName(lv)}`)].join('\n'),
523
+ );
524
+ }
525
+
526
+ // 设置
527
+ if (sub === 'set') {
528
+ const [openid, lvText, scopeText] = rest;
529
+ const lv = parseLevel(lvText);
530
+ if (!openid || lv === null) {
531
+ return msg.reply(
532
+ `用法: ${P}admin set <openid> <等级> [作用域]\n等级: 1 管理员 / 2 群管 / 3 助手 / 4 及以上自定义`,
533
+ );
534
+ }
535
+ const target = scopeText ? parseScope(scopeText, msg) : scope;
536
+ if (!target) return msg.reply(`作用域写法不对: ${scopeText}`);
537
+ if (lv === LEVEL.owner) return msg.reply('主人只能在 config/bot.json 的 owners 里设置');
538
+
539
+ const actor = await levelOf(msg.userId, target);
540
+ if (actor > LEVEL.manager) {
541
+ return msg.reply(`需要群管及以上权限才能设置(当前 ${levelName(actor)})`);
542
+ }
543
+ // 核心规则:数字越小权限越高,所以只能授予「数字更大」的等级
544
+ if (lv <= actor) {
545
+ return msg.reply(`不能授予同级或更高的权限(你当前是 ${levelName(actor)})`);
546
+ }
547
+ const cur = await roles.levelIn(target, openid);
548
+ if (cur <= actor) {
549
+ return msg.reply(`对方当前是 ${levelName(cur)},权限不低于你,无法修改`);
550
+ }
551
+
552
+ await roles.set(target, openid, lv);
553
+ return msg.reply(`已设置 ${brief(openid)} 在 ${target} 为 ${levelName(lv)}`);
554
+ }
555
+
556
+ // 移除
557
+ if (sub === 'del' || sub === 'remove') {
558
+ const [openid, scopeText] = rest;
559
+ if (!openid) return msg.reply(`用法: ${P}admin del <openid> [作用域]`);
560
+ const target = scopeText ? parseScope(scopeText, msg) : scope;
561
+ if (!target) return msg.reply(`作用域写法不对: ${scopeText}`);
562
+
563
+ const cur = await roles.levelIn(target, openid);
564
+ if (cur >= NO_PERM) return msg.reply('该用户在这个作用域没有权限');
565
+ const actor = await levelOf(msg.userId, target);
566
+ if (actor >= cur) return msg.reply('不能移除同级或更高权限的管理员');
567
+
568
+ await roles.remove(target, openid);
569
+ return msg.reply(`已移除 ${brief(openid)} 在 ${target} 的权限`);
570
+ }
571
+
572
+ await msg.reply(`未知子命令: ${sub}\n可用: list / set / del / me / scopes`);
573
+ });
574
+
575
+ core('plugins', { desc: '列出插件与状态' }, async (msg) => {
576
+ const list = plugins.list();
577
+ if (!list.length) return msg.reply('还没有安装任何插件。把插件文件夹放进 plugins/ 即可。');
578
+
579
+ const lines = ['插件列表:'];
580
+ for (const p of list) {
581
+ const flag = p.loaded ? '运行中' : p.enabled ? '未加载' : '已禁用';
582
+ const ver = p.version ? ` v${p.version}` : '';
583
+ const menu = registry.menus.get(p.id);
584
+ const cnt = menu ? menu.sections.reduce((n, s) => n + s.items.length, 0) : 0;
585
+ const menuText = cnt ? `${cnt} 条菜单` : '未提供菜单';
586
+ const alloc = plugins.allocations?.get(p.id);
587
+ const lvText = alloc ? ` · 等级 ${alloc.base}-${alloc.end}` : '';
588
+ lines.push(`${p.loaded ? '●' : '○'} ${p.id}${ver} [${flag}] · ${menuText}${lvText}`);
589
+ if (p.description) lines.push(` ${p.description}`);
590
+ if (p.error) lines.push(` 错误: ${p.error}`);
591
+ }
592
+ if (isOwner(msg.userId)) {
593
+ lines.push(`\n管理: ${P}enable <id> / ${P}disable <id> / ${P}reload <id>`);
594
+ }
595
+ lines.push(`查看某插件菜单: ${P}help <插件id>`);
596
+ await msg.reply(lines.join('\n'));
597
+ });
598
+
599
+ core('enable', { desc: '启用插件(需全局管理员)', admin: true, globalAdmin: true }, async (msg) => {
600
+ const id = msg.args[0];
601
+ if (!id) return msg.reply(`用法: ${P}enable <插件id>`);
602
+ const ok = await plugins.setEnabled(id, true);
603
+ await msg.reply(ok ? `已启用 ${id}` : `启用失败,查看日志或 ${P}plugins 看错误`);
604
+ });
605
+
606
+ core('disable', { desc: '禁用插件(需全局管理员)', admin: true, globalAdmin: true }, async (msg) => {
607
+ const id = msg.args[0];
608
+ if (!id) return msg.reply(`用法: ${P}disable <插件id>`);
609
+ await plugins.setEnabled(id, false);
610
+ await msg.reply(`已禁用 ${id}`);
611
+ });
612
+
613
+ /**
614
+ * 分群插件开关。
615
+ *
616
+ * 和面板「群组 → 插件」「插件 → 分群开关」改的是同一份 cfg.plugins.groups,
617
+ * 三个入口共用 plugin/groupscope.js 的翻译逻辑。
618
+ *
619
+ * 只影响分发时的判断,不动加载——插件在进程里是全局加载的,
620
+ * 按群卸载等于每个群一套运行时,没必要。
621
+ */
622
+ core('群插件', { desc: '看/改这个群能用哪些插件(群管理)', admin: true }, async (msg) => {
623
+ if (msg.kind !== 'group') return msg.reply('这个指令只能在群里用。');
624
+
625
+ const chatId = msg.groupId;
626
+ const spec = cfg.plugins ?? { mode: 'all', list: [] };
627
+ const list = [...plugins.loaded.values()]
628
+ .filter((p) => p.meta?.id && p.meta.id !== '__core')
629
+ .sort((a, b) => a.meta.id.localeCompare(b.meta.id));
630
+
631
+ const [act, ...rest] = msg.args;
632
+ const pid = (rest[0] ?? '').trim();
633
+
634
+ if (!act) {
635
+ const lines = [`本群插件开关(${describeGroupScope(spec, chatId)}):`];
636
+ for (const p of list) {
637
+ const on = pluginAllowedIn(spec, chatId, p.meta.id);
638
+ lines.push(`${on ? '●' : '○'} ${p.meta.id} ${p.meta.name ?? ''}`);
639
+ }
640
+ lines.push('', `关掉:${P}群插件 关 <id>`, `打开:${P}群插件 开 <id>`, `恢复跟随全局:${P}群插件 重置`);
641
+ return msg.reply(lines.join('\n'));
642
+ }
643
+
644
+ if (act === '重置') {
645
+ cfg.plugins = { ...spec, groups: resetGroupPlugins(spec, chatId) };
646
+ save();
647
+ return msg.reply('已恢复为跟随全局。');
648
+ }
649
+
650
+ const want =
651
+ act === '关' || act === '关闭' || act === 'off' || act === '0'
652
+ ? false
653
+ : act === '开' || act === '打开' || act === 'on' || act === '1'
654
+ ? true
655
+ : null;
656
+ if (want === null) {
657
+ return msg.reply(`用法:${P}群插件 关 <id> / ${P}群插件 开 <id> / ${P}群插件 重置`);
658
+ }
659
+ if (!pid) return msg.reply(`要给插件 id,先发 ${P}群插件 看列表。`);
660
+ if (!list.some((p) => p.meta.id === pid)) return msg.reply(`没有这个插件:${pid}`);
661
+
662
+ cfg.plugins = { ...spec, groups: toggleGroupPlugin(spec, chatId, pid, want) };
663
+ save();
664
+ const scope = describeGroupScope(cfg.plugins, chatId);
665
+ return msg.reply(`${want ? '已在本群启用' : '已在本群关闭'} ${pid}(${scope})`);
666
+ });
667
+
668
+ core('reload', { desc: '重载插件(需全局管理员)', admin: true, globalAdmin: true }, async (msg) => {
669
+ const id = msg.args[0];
670
+ if (id) {
671
+ const ok = await plugins.reload(id);
672
+ return msg.reply(ok ? `已重载 ${id}` : `重载 ${id} 失败,看日志`);
673
+ }
674
+ let n = 0;
675
+ for (const p of plugins.list()) {
676
+ if (p.enabled && (await plugins.reload(p.id))) n++;
677
+ }
678
+ await msg.reply(`已重载 ${n} 个插件`);
679
+ });
680
+ }
681
+
682
+ /* ------------------------------------------------------------------ */
683
+ /* Mock 模式:没有凭证也能开发插件 */
684
+ /* ------------------------------------------------------------------ */
685
+
686
+ function createMockApi(botName, metrics = null) {
687
+ let mockGroupInvited = false;
688
+ const echo = (scene) => async (target, body) => {
689
+ metrics?.bump('sent');
690
+ // markdown 消息单独打印正文,否则一堆 JSON 没法看
691
+ const text = body?.markdown
692
+ ? `[markdown]\n${body.markdown.content}`
693
+ : body?.content ?? JSON.stringify(body);
694
+ console.log(`\n→ [${botName}] [${scene} ${target}] ${text}`);
695
+ return { id: 'mock', timestamp: Date.now() };
696
+ };
697
+ return {
698
+ sendGroup: echo('群'),
699
+ sendC2C: echo('私聊'),
700
+ uploadMedia: async () => ({ file_info: 'mock', ttl: 0 }),
701
+ uploadLocalFile: async () => ({ file_info: 'mock', ttl: 0 }),
702
+ getGateway: async () => ({ url: 'wss://mock' }),
703
+ getMe: async () => ({ username: 'mock-bot', id: '0' }),
704
+ retractGroupMessage: async () => ({}),
705
+ retractC2CMessage: async () => ({}),
706
+ putInteraction: async () => ({}),
707
+ // 群管:mock 下也走同一套闸门,方便本地验证插件的群管逻辑
708
+ setGroupInvited(v) {
709
+ mockGroupInvited = Boolean(v);
710
+ return mockGroupInvited;
711
+ },
712
+ muteMembers: async (g, members) => {
713
+ console.log(`\n→ [${botName}] [禁言 ${g}] ${JSON.stringify(members)}`);
714
+ return {};
715
+ },
716
+ muteMember: async (g, uid, expireAt) => {
717
+ console.log(`\n→ [${botName}] [禁言 ${g} ${uid}] ${expireAt || '立即解除'}`);
718
+ return {};
719
+ },
720
+ getMuteStatus: async (g) => {
721
+ console.log(`\n→ [${botName}] [查询禁言 ${g}]`);
722
+ return { mute_list: [] };
723
+ },
724
+ getGroupInfo: async (g) => ({ group_openid: g, name: 'mock群', owner_openid: 'mock' }),
725
+ // bot_state 是内邀接口,mock 同样走闸门;返回字段按官方文档来
726
+ getBotGroupState: async (g) => {
727
+ if (!mockGroupInvited) throw new Error('群管内邀功能未开启');
728
+ console.log(`\n→ [${botName}] [机器人群内状态 ${g}]`);
729
+ return {
730
+ member_openid: 'mock-bot',
731
+ joined_at: new Date().toISOString(),
732
+ allow_proactive_msg: true,
733
+ recv_msg_setting: 'all',
734
+ member_role: 'admin', // mock 下默认当成群管理员,方便验证群管流程
735
+ };
736
+ },
737
+ canManageGroup: async (g) => {
738
+ if (!mockGroupInvited) {
739
+ // 没开内邀就查不到角色:不拦截,让服务端去判
740
+ return { ok: false, role: null, reason: '群管内邀功能未开启,无法预检' };
741
+ }
742
+ return { ok: true, role: 'admin', reason: '机器人是群管理员' };
743
+ },
744
+ clearRoleCache: () => {},
745
+ getGroupMembers: async (g) => {
746
+ if (!mockGroupInvited) throw new Error('群管内邀功能未开启');
747
+ return { members: [], next_index: 0 };
748
+ },
749
+ getGroupMember: async (g, uid) => {
750
+ if (!mockGroupInvited) throw new Error('群管内邀功能未开启');
751
+ return { member_openid: uid, join_timestamp: 0 };
752
+ },
753
+ removeMembers: async (g, ids) => {
754
+ if (!mockGroupInvited) throw new Error('群管内邀功能未开启');
755
+ console.log(`\n→ [${botName}] [踢人 ${g}] ${JSON.stringify(ids)}`);
756
+ return { fail_openids: [] };
757
+ },
758
+ getGroupBlacklist: async (g) => {
759
+ if (!mockGroupInvited) throw new Error('群管内邀功能未开启');
760
+ return { blacklist: [] };
761
+ },
762
+ operateGroupBlacklist: async (g, { op, member_openids }) => {
763
+ if (!mockGroupInvited) throw new Error('群管内邀功能未开启');
764
+ console.log(`\n→ [${botName}] [黑名单 ${op} ${g}] ${JSON.stringify(member_openids)}`);
765
+ return { fail_openids: [] };
766
+ },
767
+ // 入群申请与自动审批:公开接口,mock 直接返回空列表
768
+ getJoinRequests: async (g) => {
769
+ console.log(`\n→ [${botName}] [入群申请列表 ${g}]`);
770
+ return { request_list: [], cursor: '' };
771
+ },
772
+ handleJoinRequest: async (g, uid, body) => {
773
+ console.log(`\n→ [${botName}] [审批入群 ${g} ${uid}] ${body.op}`);
774
+ return {};
775
+ },
776
+ approveJoinRequest: async (g, uid, rid) => {
777
+ console.log(`\n→ [${botName}] [通过入群 ${g} ${uid}] ${rid || ''}`);
778
+ return {};
779
+ },
780
+ declineJoinRequest: async (g, uid, body = {}) => {
781
+ console.log(`\n→ [${botName}] [拒绝入群 ${g} ${uid}] ${body.rejectReason || ''}`);
782
+ return {};
783
+ },
784
+ listJoinStrategies: async () => {
785
+ console.log(`\n→ [${botName}] [审批策略列表]`);
786
+ return { strategy_list: [], cursor: '' };
787
+ },
788
+ createJoinStrategy: async (p) => {
789
+ console.log(`\n→ [${botName}] [创建审批策略] ${JSON.stringify(p)}`);
790
+ return { strategy_id: 'st_mock' };
791
+ },
792
+ updateJoinStrategy: async (id, p) => {
793
+ console.log(`\n→ [${botName}] [修改审批策略 ${id}] ${JSON.stringify(p)}`);
794
+ return {};
795
+ },
796
+ deleteJoinStrategy: async (id) => {
797
+ console.log(`\n→ [${botName}] [删除审批策略 ${id}]`);
798
+ return {};
799
+ },
800
+ executeJoinStrategy: async (id) => {
801
+ console.log(`\n→ [${botName}] [执行审批策略 ${id}](官方异步,约 10 分钟完成)`);
802
+ return {};
803
+ },
804
+ updateStrategyWhitelist: async (id, { op, whitelist_users }) => {
805
+ console.log(`\n→ [${botName}] [白名单 ${op} ${id}] ${(whitelist_users || []).length} 个号码`);
806
+ return { fail_users: [] };
807
+ },
808
+ // 流式消息与全局菜单:公开接口
809
+ sendStreamMessage: async (openid, p) => {
810
+ console.log(
811
+ `\n→ [${botName}] [流式消息 ${openid}] 第 ${p.index ?? 0} 片 state=${p.state ?? 1} ` +
812
+ `mode=${p.mode ?? 'append'}: ${String(p.contentRaw ?? '').slice(0, 40)}`,
813
+ );
814
+ return { id: `mock-stream-${p.index ?? 0}` };
815
+ },
816
+ getGlobalMenu: async () => {
817
+ console.log(`\n→ [${botName}] [查询全局菜单]`);
818
+ return { menu: { items: [] } };
819
+ },
820
+ setGlobalMenu: async (items) => {
821
+ console.log(`\n→ [${botName}] [设置全局菜单] ${(items || []).length} 项`);
822
+ return {};
823
+ },
824
+ };
825
+ }
826
+
827
+ /**
828
+ * mock 模式:从标准输入读假消息,喂给第一个机器人。
829
+ * 多机器人时想测哪个,用 !bot <id> 切换。
830
+ */
831
+ async function runMock(runtimes) {
832
+ console.log('\n=== Mock 模式 ===');
833
+ console.log('不连接 QQ,用本地假消息验证插件逻辑。输入内容回车发送,Ctrl+C 退出。');
834
+ if (runtimes.length > 1) {
835
+ console.log(`可用机器人: ${runtimes.map((r) => r.id).join(' / ')},用 !bot <id> 切换\n`);
836
+ } else {
837
+ console.log('');
838
+ }
839
+
840
+ let cur = runtimes[0];
841
+ let asUser = null;
842
+ const currentUser = () => asUser ?? cur.cfg.owners[0] ?? 'mock-user-openid';
843
+ const fakeGroup = 'mock-group-openid';
844
+ let seq = 0;
845
+
846
+ const feed = async (line) => {
847
+ const t = line.trim();
848
+
849
+ if (t.startsWith('!bot')) {
850
+ const [, id] = t.split(/\s+/);
851
+ const next = runtimes.find((r) => r.id === id);
852
+ if (!next) return console.log(`[模拟] 没有机器人 ${id},可用: ${runtimes.map((r) => r.id).join('/')}`);
853
+ cur = next;
854
+ return console.log(`[模拟] 当前机器人切换为 ${cur.id}`);
855
+ }
856
+ if (t.startsWith('!as')) {
857
+ const [, id] = t.split(/\s+/);
858
+ asUser = id || null;
859
+ return console.log(`[模拟] 发送者切换为 ${currentUser()}`);
860
+ }
861
+ if (t.startsWith('!addbot')) {
862
+ const [, gid = fakeGroup, inviter = 'mock-inviter'] = t.split(/\s+/);
863
+ console.log(`[模拟] ${cur.id} 被 ${inviter} 拉进群 ${gid}`);
864
+ return cur.dispatch('GROUP_ADD_ROBOT', {
865
+ group_openid: gid,
866
+ op_member_openid: inviter,
867
+ timestamp: String(Date.now()),
868
+ });
869
+ }
870
+ if (t.startsWith('!delbot')) {
871
+ const [, gid = fakeGroup] = t.split(/\s+/);
872
+ console.log(`[模拟] ${cur.id} 被移出群 ${gid}`);
873
+ return cur.dispatch('GROUP_DEL_ROBOT', { group_openid: gid, timestamp: String(Date.now()) });
874
+ }
875
+
876
+ return cur.dispatch('GROUP_AT_MESSAGE_CREATE', {
877
+ id: `mock-${++seq}`,
878
+ content: line,
879
+ group_openid: fakeGroup,
880
+ author: { member_openid: currentUser() },
881
+ timestamp: String(Date.now()),
882
+ });
883
+ };
884
+
885
+ const send = (line) => {
886
+ Promise.resolve(feed(line)).catch((e) => log.error('mock 处理出错:', e.message));
887
+ };
888
+
889
+ if (!process.stdin.isTTY) {
890
+ const chunks = [];
891
+ for await (const chunk of process.stdin) chunks.push(chunk);
892
+ const lines = Buffer.concat(chunks).toString('utf8').split(/\r?\n/);
893
+ const jobs = [];
894
+ for (const line of lines) {
895
+ if (!line.trim()) continue;
896
+ console.log(`你> ${line}`);
897
+ if (line.trim().startsWith('!')) {
898
+ // 控制命令(!bot / !as / !addbot)必须按顺序执行,
899
+ // 否则「切机器人」会晚于后面那条消息生效
900
+ await feed(line).catch((e) => log.error('mock 出错:', e.message));
901
+ } else {
902
+ // 普通消息并发投递:否则命令里的 ctx.prompt() 会卡住输入循环
903
+ jobs.push(Promise.resolve(feed(line)).catch((e) => log.error('mock 出错:', e.message)));
904
+ }
905
+ }
906
+ await Promise.allSettled(jobs);
907
+ return;
908
+ }
909
+
910
+ const readline = await import('node:readline/promises');
911
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
912
+ while (true) {
913
+ let line;
914
+ try {
915
+ line = await rl.question('你> ');
916
+ } catch {
917
+ break;
918
+ }
919
+ if (!line.trim()) continue;
920
+ send(line);
921
+ }
922
+ rl.close();
923
+ }
924
+
925
+ /* ------------------------------------------------------------------ */
926
+ /* 启动 */
927
+ /* ------------------------------------------------------------------ */
928
+
929
+ async function main() {
930
+ const globalCfg = await loadGlobalConfig();
931
+ // 下载源是全局的,模块里缓存一份,免得每个调用点都传配置
932
+ configureMirror(globalCfg);
933
+ setLevel(globalCfg.logLevel);
934
+ runtime.startedAt = Date.now();
935
+
936
+ /**
937
+ * 兜底:任何漏网的异步异常都不要让进程直接死。
938
+ *
939
+ * Node 22+ 对未捕获的 rejection 默认终止进程——一个畸形请求、一条发不出去的
940
+ * 消息,就会把面板和**所有**机器人一起带走。这里记日志后继续跑;
941
+ * 真正的清理交给 SIGINT/SIGTERM 那条路。
942
+ */
943
+ process.on('unhandledRejection', (e) => {
944
+ log.error(`未捕获的异步异常(已忽略,进程继续):${e?.stack || e?.message || e}`);
945
+ });
946
+ /**
947
+ * 未捕获异常:不再一律吞掉。
948
+ *
949
+ * uncaughtException 之后进程处于未定义状态,继续跑可能带着被毒化的
950
+ * 事件循环/句柄静默拖垮所有机器人。这里记日志 + 计数:
951
+ * 60 秒内超过 5 次就判定「带病空转」,退出交给重启/守护自愈;
952
+ * 偶发单次(如一个畸形回调)仍继续,避免一有风吹草动就重启。
953
+ */
954
+ let crashCount = 0;
955
+ let crashWindowStart = Date.now();
956
+ process.on('uncaughtException', (e) => {
957
+ log.error(`未捕获的异常:${e?.stack || e?.message || e}`);
958
+ if (Date.now() - crashWindowStart > 60000) {
959
+ crashCount = 0;
960
+ crashWindowStart = Date.now();
961
+ }
962
+ if (++crashCount > 5) {
963
+ log.error('60 秒内未捕获异常超过 5 次,判定状态不可信,退出交由重启自愈');
964
+ process.exit(1);
965
+ }
966
+ });
967
+
968
+ if (globalCfg.logs?.enabled !== false) {
969
+ await attachLogFile('core', PATHS.coreLog, { maxBytes: globalCfg.logs?.maxBytes });
970
+ }
971
+
972
+ /**
973
+ * 上次更新如果被强杀(断电 / 任务管理器结束),src/ 可能是半新半旧。
974
+ * 必须在加载任何代码之前回滚——晚一步就是在混着两个版本的进程里跑。
975
+ */
976
+ const recovered = await recoverInterruptedUpdate().catch((e) => {
977
+ log.error(`检查未完成的更新时出错:${e.message}`);
978
+ return { recovered: false };
979
+ });
980
+ if (recovered.recovered) {
981
+ /**
982
+ * 回滚后**必须退出**,不能接着跑。
983
+ *
984
+ * 模块是在这行之前 import 的,此刻内存里装的还是「被打断的那一版」,
985
+ * 而磁盘已经回到旧版了。继续跑就是「用新代码读旧文件」,
986
+ * 报错会指向一堆莫名其妙的地方。让用户重启一次,两边就一致了。
987
+ */
988
+ log.warn(
989
+ `已回滚上次未完成的更新(${recovered.from} → ${recovered.to}),请重新启动一次`,
990
+ );
991
+ await flushLogs().catch(() => {});
992
+ process.exit(0);
993
+ }
994
+
995
+ // 单机器人 → 多机器人结构迁移(只跑一次)
996
+ const mig = await migrateToMultiBot();
997
+ if (mig.migrated) {
998
+ log.info(`已迁移到多机器人结构:机器人 ${mig.botId},搬移 ${mig.moved.length} 个文件`);
999
+ }
1000
+
1001
+ let botCfgs = await loadAllBotConfigs();
1002
+
1003
+ // 全新安装:建一个默认机器人
1004
+ if (!botCfgs.length) {
1005
+ const id = 'main';
1006
+ await ensureDirs(id);
1007
+ await createBotConfig(id, { name: '默认机器人' });
1008
+ log.info(`已创建默认机器人配置: ${rel(botPaths(id).config)}`);
1009
+ botCfgs = [await loadBotConfig(id)];
1010
+ }
1011
+
1012
+ /**
1013
+ * 启动时给所有机器人配置拍一份快照。
1014
+ *
1015
+ * `config/bots/` 在 .gitignore 里(含 AppSecret,绝不能进仓库),
1016
+ * 所以一旦被误删就**无处可恢复**——面板删除默认还是 purge,
1017
+ * 连数据日志一起清。每天启动一次就多一份快照,成本几乎为零。
1018
+ * 只保留最近 20 份,不会无限涨。
1019
+ */
1020
+ try {
1021
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
1022
+ const destDir = path.join(DIRS.config, 'backups', stamp);
1023
+ await fs.mkdir(destDir, { recursive: true });
1024
+ for (const c of botCfgs) {
1025
+ const src = botPaths(c.id).config;
1026
+ await fs.copyFile(src, path.join(destDir, `${c.id}.json`)).catch(() => {});
1027
+ }
1028
+ // 只留最近 20 个快照
1029
+ const all = (await fs.readdir(path.join(DIRS.config, 'backups'), { withFileTypes: true }))
1030
+ .filter((e) => e.isDirectory())
1031
+ .map((e) => e.name)
1032
+ .sort();
1033
+ for (const old of all.slice(0, Math.max(0, all.length - 20))) {
1034
+ await fs.rm(path.join(DIRS.config, 'backups', old), { recursive: true, force: true }).catch(() => {});
1035
+ }
1036
+ } catch (e) {
1037
+ log.warn(`配置快照失败(不影响启动):${e.message}`);
1038
+ }
1039
+
1040
+ // Webhook 接收服务:全局一个端口,按 AppID 分流给各个机器人。
1041
+ // 只有机器人配了 connection.mode=webhook 才需要它,但开着也不占多少资源。
1042
+ let webhookServer = null;
1043
+ if (globalCfg.webhook?.enabled) {
1044
+ webhookServer = new WebhookServer({
1045
+ host: globalCfg.webhook.host,
1046
+ port: globalCfg.webhook.port,
1047
+ path: globalCfg.webhook.path,
1048
+ // 默认验签。配里写 verifySignature=false 还不够,必须同时开 allowUnsigned
1049
+ // ——关掉之后任何知道地址和 appId 的人都能伪造事件,见 webhook.js 的注释
1050
+ verifySignature: globalCfg.webhook.verifySignature !== false,
1051
+ allowUnsigned: globalCfg.webhook.allowUnsigned === true,
1052
+ allowedIps: globalCfg.webhook.allowedIps,
1053
+ perIpPerMin: globalCfg.webhook.perIpPerMin,
1054
+ });
1055
+ try {
1056
+ const port = await webhookServer.start();
1057
+ if (globalCfg.webhook.publicUrl) {
1058
+ log.info(`Webhook 对外地址(请到开放平台填写): ${globalCfg.webhook.publicUrl}${webhookServer.path}`);
1059
+ } else {
1060
+ log.info(`Webhook 已监听 :${port}${webhookServer.path},公网部署请配反向代理和 HTTPS`);
1061
+ }
1062
+ } catch (e) {
1063
+ log.error(`Webhook 启动失败: ${e.message}(webhook 模式的机器人将无法收事件)`);
1064
+ webhookServer = null;
1065
+ }
1066
+ } else {
1067
+ // 有人配了 webhook 模式但没开服务,提前说清楚
1068
+ const wantWebhook = botCfgs.filter((c) => c.enabled !== false && c.connection?.mode === 'webhook');
1069
+ if (wantWebhook.length) {
1070
+ log.warn(
1071
+ `有 ${wantWebhook.length} 个机器人配了 webhook 模式,但全局配置 webhook.enabled=false,` +
1072
+ `它们收不到事件。去 config/bot.json 打开`,
1073
+ );
1074
+ }
1075
+ }
1076
+
1077
+ // 建运行时
1078
+ const runtimes = [];
1079
+ for (const cfg of botCfgs) {
1080
+ if (cfg.enabled === false) {
1081
+ log.info(`机器人 ${cfg.id}(${cfg.name})已禁用,跳过`);
1082
+ continue;
1083
+ }
1084
+ if (!MOCK && (!cfg.appId || !cfg.appSecret)) {
1085
+ // 注意要**把返回值写回 cfg**:ensureBotCredentials 返回的是重新读出来的配置,
1086
+ // 不写回的话下面 new BotRuntime 拿到的还是空凭证,
1087
+ // 用户明明输了 AppID 却报「缺少 AppID / AppSecret」。
1088
+ Object.assign(cfg, await ensureBotCredentials(cfg.id));
1089
+ }
1090
+ const rt = new BotRuntime({
1091
+ cfg,
1092
+ globalCfg,
1093
+ mock: MOCK,
1094
+ webhook: webhookServer,
1095
+ registerCore: (r) => registerCoreCommands(r),
1096
+ makeMockApi: (r) => createMockApi(r.name, r.metrics),
1097
+ // 广播给面板 SSE / 外部订阅者(AI 宿主靠这个收消息)
1098
+ onMessage: (msg) => emitMessage(cfg.id, msg),
1099
+ });
1100
+ await rt.init();
1101
+ runtimes.push(rt);
1102
+ }
1103
+
1104
+ if (!runtimes.length) {
1105
+ log.error('没有可启动的机器人,检查 config/bots/*.json 里的 enabled');
1106
+ process.exit(1);
1107
+ }
1108
+ log.info(`准备启动 ${runtimes.length} 个机器人: ${runtimes.map((r) => r.id).join('、')}`);
1109
+
1110
+ /* ---------------- 面板 ---------------- */
1111
+
1112
+ let web = null;
1113
+
1114
+ /* ---------------- 后台服务 ---------------- */
1115
+
1116
+ /**
1117
+ * 健康巡检、文件清理、配置热加载、定时重启、检查更新。
1118
+ * 都是增强,单个失败不影响主程序。
1119
+ */
1120
+ const services = new Services({
1121
+ globalCfg,
1122
+ getRuntimes: () => runtimes,
1123
+ // 手改配置后同步到运行时(能热的字段)
1124
+ onConfigReload: (nextGlobal, nextBot) => {
1125
+ if (nextBot) {
1126
+ const rt = runtimes.find((r) => r.id === nextBot.id);
1127
+ if (rt) {
1128
+ // 只同步能热改的字段。appId/appSecret 变了要重连,
1129
+ // intents 变了也要重连,这些交给面板的保存逻辑处理。
1130
+ const hot = ['commandPrefix', 'owners', 'whitelist', 'rateLimit', 'dedupWindowSec', 'groupManage', 'messageLog'];
1131
+ for (const k of hot) {
1132
+ if (nextBot[k] !== undefined) rt.cfg[k] = nextBot[k];
1133
+ }
1134
+ /**
1135
+ * plugins 单独处理:它是对象,而且分群开关(groups)会随面板/指令频繁改。
1136
+ * 之前漏了它,导致「改了配置、提示已热加载、行为却没变」——
1137
+ * 面板上关掉的插件在群里照样能用,反过来也一样。
1138
+ * 用 Object.assign 保留外层对象引用,别处拿着 cfg.plugins 的不会看到旧值。
1139
+ */
1140
+ if (nextBot.plugins) {
1141
+ if (!rt.cfg.plugins) rt.cfg.plugins = nextBot.plugins;
1142
+ else Object.assign(rt.cfg.plugins, nextBot.plugins);
1143
+ }
1144
+ rt.log.info('配置已热加载');
1145
+ }
1146
+ }
1147
+ },
1148
+ // 定时重启:先优雅关闭,再拉起新进程
1149
+ onRestart: async () => {
1150
+ log.warn('定时重启:正在关闭当前进程…');
1151
+ await shutdown({ restarting: true });
1152
+ },
1153
+ });
1154
+
1155
+ const shutdown = async ({ restarting = false } = {}) => {
1156
+ if (shutdown.called) {
1157
+ // 二次信号 = 关停卡住了,直接强退,别让人只能 kill -9
1158
+ log.error('关停进行中又收到退出信号,强制退出');
1159
+ process.exit(1);
1160
+ }
1161
+ shutdown.called = true;
1162
+ log.info('正在退出...');
1163
+ // 兜底超时:任一步 hang 都不会让进程永久卡在退出中
1164
+ const hard = setTimeout(() => {
1165
+ log.error('关停超时(10 秒),强制退出');
1166
+ process.exit(1);
1167
+ }, 10000);
1168
+ hard.unref?.();
1169
+ try {
1170
+ await services.stop().catch(() => {});
1171
+ web?.close();
1172
+ await webhookServer?.stop().catch(() => {});
1173
+ for (const rt of runtimes) await rt.stop().catch(() => {});
1174
+ await flushLogs().catch(() => {});
1175
+ if (restarting) {
1176
+ // 拉起新进程后退出,裸机部署也能自愈
1177
+ try {
1178
+ const { spawn } = await import('node:child_process');
1179
+ const child = spawn(process.execPath, process.argv.slice(1), {
1180
+ cwd: process.cwd(),
1181
+ detached: true,
1182
+ stdio: 'inherit',
1183
+ env: { ...process.env, ROOST_RESTARTED: '1' },
1184
+ });
1185
+ child.unref();
1186
+ log.info(`已拉起新进程 pid=${child.pid},当前进程退出`);
1187
+ } catch (e) {
1188
+ log.error(`拉起新进程失败:${e.message},请手动重启`);
1189
+ }
1190
+ }
1191
+ } finally {
1192
+ clearTimeout(hard);
1193
+ }
1194
+ process.exit(0);
1195
+ };
1196
+ shutdown.called = false;
1197
+
1198
+ // 面板现在支持切换机器人,不再固定展示第一个
1199
+ if (runtimes.length > 1) {
1200
+ log.info(`面板可切换的机器人: ${runtimes.map((r) => r.id).join('、')}`);
1201
+ }
1202
+
1203
+ web = await startWebServer({
1204
+ globalCfg,
1205
+ runtimes,
1206
+ mock: MOCK,
1207
+ saveBotConfig,
1208
+ saveGlobalConfig,
1209
+ onShutdown: shutdown,
1210
+ services,
1211
+
1212
+ /** 面板「新建机器人」 */
1213
+ onBotAdd: async (id, patch) => {
1214
+ if (!MOCK && (!patch.appId || !patch.appSecret)) {
1215
+ throw new Error('AppID 和 AppSecret 都要填');
1216
+ }
1217
+
1218
+ // 上次如果用 purge=false 删过,data 目录还在。
1219
+ // 直接新建会**静默复用**旧存档(权限、群档案、插件进度全都回来),
1220
+ // 这种"以为删干净了其实没删"最容易出事故,所以这里直接拦住。
1221
+ let leftover = [];
1222
+ try {
1223
+ leftover = await fs.readdir(botPaths(id).data);
1224
+ } catch {
1225
+ leftover = [];
1226
+ }
1227
+ if (leftover.length) {
1228
+ throw new Error(
1229
+ `data/bots/${id} 还留着上次删除时保留的数据(${leftover.length} 项)。` +
1230
+ `新建会复用这些存档。要干净重来就先删掉该目录;确实想接着用旧数据,` +
1231
+ `请手动把它移到目标位置后再建。`,
1232
+ );
1233
+ }
1234
+
1235
+ const cfg = await createBotConfig(id, {
1236
+ name: patch.name,
1237
+ appId: patch.appId,
1238
+ appSecret: patch.appSecret,
1239
+ });
1240
+ const rt = new BotRuntime({
1241
+ cfg,
1242
+ globalCfg,
1243
+ mock: MOCK,
1244
+ /**
1245
+ * webhook 服务必须传进来。
1246
+ * 以前这里漏了:全局开着 webhook 时,面板新建的 webhook 机器人
1247
+ * 会直接报「配置选了 webhook 模式,但 Webhook 服务没启动」——
1248
+ * 而服务其实好端端跑着,必须重启进程才能恢复。
1249
+ */
1250
+ webhook: webhookServer,
1251
+ registerCore: (r) => registerCoreCommands(r),
1252
+ makeMockApi: (r) => createMockApi(r.name, r.metrics),
1253
+ onMessage: (msg) => emitMessage(id, msg),
1254
+ });
1255
+ await rt.init();
1256
+ runtimes.push(rt);
1257
+ await rt.start();
1258
+ log.info(`新建机器人 ${id}(${rt.name}):${rt.state}`);
1259
+ return rt;
1260
+ },
1261
+
1262
+ /** 面板「删除机器人」 */
1263
+ onBotRemove: async (id, { purge }) => {
1264
+ /**
1265
+ * 删之前先把配置备份一份。
1266
+ *
1267
+ * 血泪教训:面板删除默认 `purge: true`,会连配置、数据、日志一起删。
1268
+ * 误点一次,AppSecret 就永久没了——而 `config/bots/` 是 gitignore 的,
1269
+ * 从任何地方都恢复不回来,只能去 QQ 开放平台重新拿。
1270
+ * 所以不管 purge 与否,**配置先落一份到 config/backups/**。
1271
+ */
1272
+ try {
1273
+ const src = botPaths(id).config;
1274
+ await fs.access(src);
1275
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
1276
+ const destDir = path.join(DIRS.config, 'backups');
1277
+ await fs.mkdir(destDir, { recursive: true });
1278
+ const dest = path.join(destDir, `${id}.${stamp}.json`);
1279
+ await fs.copyFile(src, dest);
1280
+ log.info(`已备份机器人配置:${rel(dest)}`);
1281
+ } catch (e) {
1282
+ if (e.code !== 'ENOENT') log.warn(`备份机器人配置失败(继续删除):${e.message}`);
1283
+ }
1284
+ const i = runtimes.findIndex((r) => r.id === id);
1285
+ if (i >= 0) {
1286
+ await runtimes[i].stop().catch(() => {});
1287
+ runtimes.splice(i, 1);
1288
+ }
1289
+ const p = botPaths(id);
1290
+ await fs.unlink(p.config).catch(() => {});
1291
+ // 插件配置目录(config/bots/<id>/)
1292
+ await fs.rm(path.dirname(p.pluginConfigDir), { recursive: true, force: true }).catch(() => {});
1293
+ if (purge) {
1294
+ await fs.rm(p.data, { recursive: true, force: true }).catch(() => {});
1295
+ await fs.rm(p.logDir, { recursive: true, force: true }).catch(() => {});
1296
+ }
1297
+ log.info(`已删除机器人 ${id}${purge ? '(含数据与日志)' : '(保留数据)'}`);
1298
+ if (!purge) {
1299
+ log.warn(
1300
+ `data/bots/${id} 和 logs/bots/${id} 保留了。注意:再用同名 id 新建会被拒绝,` +
1301
+ `需要先手动清理该目录——否则旧存档会被静默复用。`,
1302
+ );
1303
+ }
1304
+ },
1305
+ });
1306
+
1307
+ /**
1308
+ * `--paused`:这次启动先别说话。
1309
+ *
1310
+ * 放在「启动机器人」之前、面板起来之后:面板已经在跑了,用户能立刻
1311
+ * 看到「已暂停」并在里面放开,不用去改配置文件。
1312
+ *
1313
+ * **只改内存不写盘**(`persist: false`)。这个参数表达的是「这一次先别开口」,
1314
+ * 不是「以后都别开口」——写进 `global.json` 的话,下次不带参数启动却发现
1315
+ * 还在暂停,人会以为程序坏了。
1316
+ *
1317
+ * 每只鸟各有一个 gate 实例、各持一份全局闸,所以必须逐个设。
1318
+ */
1319
+ if (args.has('--paused')) {
1320
+ for (const rt of runtimes) {
1321
+ await rt.gate
1322
+ ?.setGlobal(true, { by: 'cli', reason: '--paused 启动参数' }, { persist: false })
1323
+ .catch((e) => log.warn(`[${rt.id}] 设置启动暂停失败:${e.message}`));
1324
+ }
1325
+ log.warn(
1326
+ `已按 --paused 全局暂停:这次启动不会对任何人说话。` +
1327
+ `确认无误后在面板「概览 → 暂停闸」里点「放开全局」,或去掉 --paused 重启。`,
1328
+ );
1329
+ }
1330
+
1331
+ if (web && !args.has('--no-open') && globalCfg.webui?.openBrowser !== false && (!MOCK || SERVE)) {
1332
+ if (openBrowser(web.url)) log.info('已在浏览器中打开面板');
1333
+ }
1334
+
1335
+ /* ---------------- mock 模式 ---------------- */
1336
+
1337
+ if (MOCK) {
1338
+ for (const rt of runtimes) await rt.start();
1339
+
1340
+ if (SERVE) {
1341
+ log.info('mock + serve:只跑面板,Ctrl+C 退出');
1342
+ // mock 下也把服务拉起来,方便测服务本身(清理、健康巡检等)
1343
+ await services.start();
1344
+ process.on('SIGINT', shutdown);
1345
+ process.on('SIGTERM', shutdown);
1346
+ return new Promise(() => {});
1347
+ }
1348
+ await runMock(runtimes);
1349
+ web?.close();
1350
+ for (const rt of runtimes) await rt.stop().catch(() => {});
1351
+ await flushLogs().catch(() => {});
1352
+ return;
1353
+ }
1354
+
1355
+ /* ---------------- 真实启动 ---------------- */
1356
+
1357
+ log.info(`全局配置: ${rel(PATHS.globalConfig)}`);
1358
+ if (globalCfg.logs?.enabled !== false) {
1359
+ log.info(`日志: ${rel(PATHS.coreLog)} · 机器人日志在 ${rel(DIRS.botsLogs)}/<id>/`);
1360
+ }
1361
+
1362
+ // 认领码:每个机器人各自生成,避免一个码能认领所有机器人
1363
+ for (const rt of runtimes) {
1364
+ if (!rt.cfg.owners.length) {
1365
+ rt.claim.code = randomBytes(3).toString('hex').toUpperCase();
1366
+ log.warn(`[${rt.id}] 尚未设置主人。认领码: ${rt.claim.code}`);
1367
+ log.warn(`[${rt.id}] 在群里 @机器人 发 /claim ${rt.claim.code} 认领,或在面板里配置 owners。`);
1368
+ }
1369
+ }
1370
+
1371
+ await Promise.all(runtimes.map((rt) => rt.start()));
1372
+
1373
+ const online = runtimes.filter((r) => r.online);
1374
+ log.info(`已启动 ${online.length}/${runtimes.length} 个机器人`);
1375
+ for (const rt of runtimes) {
1376
+ if (!rt.online) log.error(`[${rt.id}] 未连上:${rt.lastError || '未知原因'}`);
1377
+ }
1378
+
1379
+ // 后台服务放在最后启动,不干扰启动流程的日志
1380
+ await services.start();
1381
+
1382
+ process.on('SIGINT', shutdown);
1383
+ process.on('SIGTERM', shutdown);
1384
+ }
1385
+
1386
+ main().catch((e) => {
1387
+ log.error(e.stack || e.message);
1388
+ process.exit(1);
1389
+ });