qq-bot-roost 1.0.2 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +57 -57
- package/src/bot.js +10 -1
- package/src/config.js +741 -730
- package/src/ledger.js +905 -554
- package/src/message.js +998 -900
- package/src/outbound.js +791 -690
- package/src/plugin/api-record.js +129 -0
- package/src/plugin/context.js +990 -976
- package/src/storage/events-schema.js +124 -99
- package/src/web/server.js +3494 -3280
- package/web/app.js +4372 -4053
- package/web/index.html +1203 -1116
- package/web/style.css +44 -0
package/src/config.js
CHANGED
|
@@ -1,730 +1,741 @@
|
|
|
1
|
-
import fs from 'node:fs/promises';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import readline from 'node:readline/promises';
|
|
4
|
-
import { stdin, stdout } from 'node:process';
|
|
5
|
-
import { ROOT, DIRS, PATHS, botPaths, ensureDirs, listBotIds, rel } from './paths.js';
|
|
6
|
-
import { createLogger } from './logger.js';
|
|
7
|
-
import { SMTP_DEFAULTS } from './mail/defaults.js';
|
|
8
|
-
|
|
9
|
-
const log = createLogger('config');
|
|
10
|
-
|
|
11
|
-
// 兼容旧的导入方式
|
|
12
|
-
export { ROOT, PATHS, DIRS };
|
|
13
|
-
export const CONFIG_PATH = PATHS.globalConfig;
|
|
14
|
-
|
|
15
|
-
/* ------------------------------------------------------------------ */
|
|
16
|
-
/* 两套默认值 */
|
|
17
|
-
/* ------------------------------------------------------------------ */
|
|
18
|
-
|
|
19
|
-
/** 全局配置:管进程本身(面板、日志、插件依赖策略) */
|
|
20
|
-
const GLOBAL_DEFAULTS = {
|
|
21
|
-
logLevel: 'info',
|
|
22
|
-
cooldownHint: true,
|
|
23
|
-
|
|
24
|
-
webui: {
|
|
25
|
-
enabled: true,
|
|
26
|
-
host: '127.0.0.1',
|
|
27
|
-
port: 15207,
|
|
28
|
-
accessKey: '',
|
|
29
|
-
portTries: 20,
|
|
30
|
-
openBrowser: true,
|
|
31
|
-
/**
|
|
32
|
-
* 面板在 Nginx / Caddy 这类反向代理后面时设为 true。
|
|
33
|
-
*
|
|
34
|
-
* 开了之后:
|
|
35
|
-
* - 客户端 IP 从 `X-Forwarded-For` 取(用于认证失败限速)
|
|
36
|
-
* - 真实 Host 从 `X-Forwarded-Host` 取(用于同源校验)
|
|
37
|
-
*
|
|
38
|
-
* **默认关,而且关着的时候伪造这两个头毫无作用**——
|
|
39
|
-
* 不开就是不信任任何转发头,攻击者自己塞的值会被忽略。
|
|
40
|
-
*/
|
|
41
|
-
trustProxy: false,
|
|
42
|
-
/**
|
|
43
|
-
* 反向代理层数。只有一层 Nginx = 1;CDN(Cloudflare 等)+ Nginx = 2。
|
|
44
|
-
*
|
|
45
|
-
* 取 `X-Forwarded-For` 时从**右往左**数第 hops 个:右边那些是代理自己加的,
|
|
46
|
-
* 左边的是客户端可以随便伪造的。取错了要么拿到假 IP(等于没限速),
|
|
47
|
-
* 要么把所有人当成同一个 IP(攻击者一个人就能把面板锁死)。
|
|
48
|
-
*/
|
|
49
|
-
trustProxyHops: 1,
|
|
50
|
-
},
|
|
51
|
-
|
|
52
|
-
logs: {
|
|
53
|
-
enabled: true,
|
|
54
|
-
maxBytes: 5 * 1024 * 1024,
|
|
55
|
-
},
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Webhook 接收服务(全局一个端口,服务所有选了 webhook 模式的机器人)。
|
|
59
|
-
* 不开就不占端口;开了以后每个机器人靠 AppID 分流。
|
|
60
|
-
* 公网部署时记得把 path 暴露出去,并配好 HTTPS 反代。
|
|
61
|
-
*/
|
|
62
|
-
webhook: {
|
|
63
|
-
enabled: false,
|
|
64
|
-
/**
|
|
65
|
-
* 默认只听本机:对外回调应该由 Nginx/Caddy 反代进来并终结 TLS。
|
|
66
|
-
*
|
|
67
|
-
* 以前默认 0.0.0.0,一旦开启就对着整个网络——而 Webhook 收的是群消息与
|
|
68
|
-
* openid,明文过网、也没有速率限制。放在反代后面还有个好处:反代能直接
|
|
69
|
-
* 按平台出口 IP 做白名单,比在应用里做更可靠。
|
|
70
|
-
*/
|
|
71
|
-
host: '127.0.0.1',
|
|
72
|
-
port: 15208,
|
|
73
|
-
path: '/webhook',
|
|
74
|
-
/** 对外可访问的完整地址,只用于面板显示和提示,不参与逻辑 */
|
|
75
|
-
publicUrl: '',
|
|
76
|
-
/**
|
|
77
|
-
* 只接受这些来源 IP(空数组 = 不限)。
|
|
78
|
-
* 公网部署时填平台出口 IP 最稳;也可以交给反代做。
|
|
79
|
-
*/
|
|
80
|
-
allowedIps: [],
|
|
81
|
-
/** 每个 IP 每分钟最多多少个请求,防日志洪泛和刷接口 */
|
|
82
|
-
perIpPerMin: 120,
|
|
83
|
-
/**
|
|
84
|
-
* 允许「关掉验签」的第二把钥匙。
|
|
85
|
-
*
|
|
86
|
-
* 只写 `verifySignature: false` **不会**生效——必须同时把这里设为 true。
|
|
87
|
-
* 目的是防手滑:关掉验签之后,任何知道地址和 appId 的人都能伪造事件
|
|
88
|
-
* (包括冒充群主触发管理命令),这个代价不该由一个配置项决定。
|
|
89
|
-
*/
|
|
90
|
-
allowUnsigned: false,
|
|
91
|
-
},
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* 下载源。国内直连 GitHub 经常超时,插件市场和框架更新都会失败。
|
|
95
|
-
* 这里让用户自己选,面板上有「测速」按钮,按自己网络实际情况挑。
|
|
96
|
-
*
|
|
97
|
-
* mirror auto(直连优先,失败切 Gitee)/ github / gitee / custom
|
|
98
|
-
* mirrorCustom 选 custom 时的前缀,如 https://my-proxy.example/
|
|
99
|
-
* giteeToken Gitee 私人令牌(选填)。公开仓库匿名也能拉,填了只是把
|
|
100
|
-
* API 配额提上去;只加在 Gitee API 调用上,raw 不需要
|
|
101
|
-
*
|
|
102
|
-
* 只有两个真实来源:GitHub 官方和作者维护的 Gitee 镜像(内容与 GitHub
|
|
103
|
-
* 逐字节相同)。以前那批公共 gh 代理和 MirrorChyan 已移除——实测要么
|
|
104
|
-
* 限速要么挂掉,留着只会让排查更困难。
|
|
105
|
-
*/
|
|
106
|
-
network: {
|
|
107
|
-
mirror: 'auto',
|
|
108
|
-
mirrorCustom: '',
|
|
109
|
-
giteeToken: '',
|
|
110
|
-
},
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* 后台服务开关。都是增强,关掉不影响机器人跑。
|
|
114
|
-
*/
|
|
115
|
-
services: {
|
|
116
|
-
/** 每分钟采样内存/积压/重连,超阈值告警 */
|
|
117
|
-
health: true,
|
|
118
|
-
/** 清临时目录、旧备份、过期日志 */
|
|
119
|
-
cleanup: true,
|
|
120
|
-
/** 手改 config/*.json 不用重启 */
|
|
121
|
-
configWatch: true,
|
|
122
|
-
},
|
|
123
|
-
|
|
124
|
-
cleanup: {
|
|
125
|
-
/** 更新备份保留几份 */
|
|
126
|
-
keepBackups: 3,
|
|
127
|
-
/** 日志保留天数 */
|
|
128
|
-
keepLogDays: 14,
|
|
129
|
-
},
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* 定时重启。长时间跑内存碎片、句柄泄漏是慢慢积累的,
|
|
133
|
-
* 每天重启一次比事后排查便宜。
|
|
134
|
-
* "at": "04:00" 每天这个点
|
|
135
|
-
* "everyHours": 12 每 N 小时
|
|
136
|
-
* 两个都填以 at 为准。
|
|
137
|
-
*/
|
|
138
|
-
restart: {
|
|
139
|
-
enabled: false,
|
|
140
|
-
at: '',
|
|
141
|
-
everyHours: 0,
|
|
142
|
-
},
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* 框架自身更新。
|
|
146
|
-
* autoCheck 只检查不安装(默认),发现新版在面板提示。
|
|
147
|
-
* autoApply 会自动装——**默认关**,开了就要接受装到有 bug 版本的风险。
|
|
148
|
-
*/
|
|
149
|
-
update: {
|
|
150
|
-
autoCheck: true,
|
|
151
|
-
autoApply: false,
|
|
152
|
-
intervalHours: 6,
|
|
153
|
-
},
|
|
154
|
-
|
|
155
|
-
plugins: {
|
|
156
|
-
autoInstallDeps: true,
|
|
157
|
-
/** Go 插件用的 go 可执行文件路径,留空则用 PATH 里的 go 或 GO_BIN 环境变量 */
|
|
158
|
-
goBin: '',
|
|
159
|
-
/** 插件市场索引地址,换成你自己的插件仓库即可 */
|
|
160
|
-
marketUrl: 'https://raw.githubusercontent.com/tdcq9594/qq-bot-roost-plugins/main/index.json',
|
|
161
|
-
/**
|
|
162
|
-
* 插件热重载:改完插件代码存盘两秒后自动生效。
|
|
163
|
-
* 默认开——开发插件时不用反复重启或点「重载」。
|
|
164
|
-
* 生产环境想省这点 CPU 可以关掉,改完手动点重载。
|
|
165
|
-
*/
|
|
166
|
-
hotReload: true,
|
|
167
|
-
/** 轮询间隔(毫秒),默认 2 秒 */
|
|
168
|
-
hotReloadInterval: 2000,
|
|
169
|
-
/**
|
|
170
|
-
* 跳过插件的框架版本校验。插件在 plugin.json 里写了 frameworkVersion,
|
|
171
|
-
* 当前框架不满足时会拒绝加载;作者本地调试想硬跑可以打开这个开关。
|
|
172
|
-
*/
|
|
173
|
-
ignoreFrameworkVersion: false,
|
|
174
|
-
},
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* 邮箱通知 / SMTP(全局一份,所有机器人共用这个发信通道)。
|
|
178
|
-
* 字段对齐 BetterGI「设置 → 通知」的邮件部分;面板上就是「通知」页。
|
|
179
|
-
* 走 saveGlobalConfig(排除式写入),新字段会自动落盘,不用改白名单。
|
|
180
|
-
*/
|
|
181
|
-
notifications: {
|
|
182
|
-
smtp: { ...SMTP_DEFAULTS },
|
|
183
|
-
},
|
|
184
|
-
};
|
|
185
|
-
|
|
186
|
-
/** 机器人配置:管连接与行为,每个机器人一份 */
|
|
187
|
-
const BOT_DEFAULTS = {
|
|
188
|
-
name: '',
|
|
189
|
-
enabled: true,
|
|
190
|
-
|
|
191
|
-
appId: '',
|
|
192
|
-
appSecret: '',
|
|
193
|
-
baseUrl: 'https://api.bot.qq.com',
|
|
194
|
-
// GROUP_AND_C2C_EVENT = 1 << 25,群聊 @机器人 + 私聊消息
|
|
195
|
-
intents: 1 << 25,
|
|
196
|
-
commandPrefix: '/',
|
|
197
|
-
owners: [],
|
|
198
|
-
|
|
199
|
-
whitelist: {
|
|
200
|
-
enabled: false,
|
|
201
|
-
groups: [],
|
|
202
|
-
users: [],
|
|
203
|
-
},
|
|
204
|
-
|
|
205
|
-
rateLimit: {
|
|
206
|
-
enabled: true,
|
|
207
|
-
perChatPerMinute: 30,
|
|
208
|
-
},
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
* 收事件的方式:
|
|
212
|
-
* websocket —— 长连接,本地跑最省心(默认)
|
|
213
|
-
* webhook —— 平台 HTTP 回调,适合公网部署 / Serverless
|
|
214
|
-
* 两种模式下**发消息都走 HTTP API**,行为一致。
|
|
215
|
-
*/
|
|
216
|
-
connection: {
|
|
217
|
-
mode: 'websocket',
|
|
218
|
-
},
|
|
219
|
-
|
|
220
|
-
dedupWindowSec: 120,
|
|
221
|
-
|
|
222
|
-
messageLog: {
|
|
223
|
-
enabled: true,
|
|
224
|
-
muteKinds: [],
|
|
225
|
-
},
|
|
226
|
-
|
|
227
|
-
outbound: {
|
|
228
|
-
perSec: 4,
|
|
229
|
-
/**
|
|
230
|
-
* 主动消息额度,按官方文档(2026-10 版):
|
|
231
|
-
* 单关系维度 20/qpm,每群 1 天最多接收 1000 条。
|
|
232
|
-
* 这里以前写 20(把频道的口径套到群聊),少算 50 倍。
|
|
233
|
-
*/
|
|
234
|
-
dailyActive: 1000,
|
|
235
|
-
activePerMinute: 20,
|
|
236
|
-
downgradeToActive: true,
|
|
237
|
-
},
|
|
238
|
-
|
|
239
|
-
session: {
|
|
240
|
-
stickySec: 180,
|
|
241
|
-
promptTimeoutSec: 120,
|
|
242
|
-
},
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* 统计:消息量、活跃人数、命令排行。
|
|
246
|
-
* 只记数量与时间,**不记消息正文**——正文落库会让人不敢用。
|
|
247
|
-
* 需要 Node 22.5+ 的内置 SQLite,没有就自动降级为不统计。
|
|
248
|
-
*/
|
|
249
|
-
stats: {
|
|
250
|
-
enabled: true,
|
|
251
|
-
/** 明细保留天数,超出的自动清掉;成员档案不清 */
|
|
252
|
-
retentionDays: 90,
|
|
253
|
-
},
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* 事件账本:存原始消息,官方没有历史消息接口,这是唯一的历史来源。
|
|
257
|
-
*
|
|
258
|
-
* 和 `stats` 刻意分开:stats 不存正文(只计数),ledger 存正文。
|
|
259
|
-
* 保留期也短得多——全文落库是隐私成本,只留够做上下文和幂等的窗口即可,
|
|
260
|
-
* 长期记忆的证据由 dsh-roost 的 journal 自己留快照。
|
|
261
|
-
*/
|
|
262
|
-
ledger: {
|
|
263
|
-
enabled: true,
|
|
264
|
-
/**
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
if (
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
return null;
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
if (env.
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
return
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
[path.join(DIRS.
|
|
640
|
-
[path.join(DIRS.
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import readline from 'node:readline/promises';
|
|
4
|
+
import { stdin, stdout } from 'node:process';
|
|
5
|
+
import { ROOT, DIRS, PATHS, botPaths, ensureDirs, listBotIds, rel } from './paths.js';
|
|
6
|
+
import { createLogger } from './logger.js';
|
|
7
|
+
import { SMTP_DEFAULTS } from './mail/defaults.js';
|
|
8
|
+
|
|
9
|
+
const log = createLogger('config');
|
|
10
|
+
|
|
11
|
+
// 兼容旧的导入方式
|
|
12
|
+
export { ROOT, PATHS, DIRS };
|
|
13
|
+
export const CONFIG_PATH = PATHS.globalConfig;
|
|
14
|
+
|
|
15
|
+
/* ------------------------------------------------------------------ */
|
|
16
|
+
/* 两套默认值 */
|
|
17
|
+
/* ------------------------------------------------------------------ */
|
|
18
|
+
|
|
19
|
+
/** 全局配置:管进程本身(面板、日志、插件依赖策略) */
|
|
20
|
+
const GLOBAL_DEFAULTS = {
|
|
21
|
+
logLevel: 'info',
|
|
22
|
+
cooldownHint: true,
|
|
23
|
+
|
|
24
|
+
webui: {
|
|
25
|
+
enabled: true,
|
|
26
|
+
host: '127.0.0.1',
|
|
27
|
+
port: 15207,
|
|
28
|
+
accessKey: '',
|
|
29
|
+
portTries: 20,
|
|
30
|
+
openBrowser: true,
|
|
31
|
+
/**
|
|
32
|
+
* 面板在 Nginx / Caddy 这类反向代理后面时设为 true。
|
|
33
|
+
*
|
|
34
|
+
* 开了之后:
|
|
35
|
+
* - 客户端 IP 从 `X-Forwarded-For` 取(用于认证失败限速)
|
|
36
|
+
* - 真实 Host 从 `X-Forwarded-Host` 取(用于同源校验)
|
|
37
|
+
*
|
|
38
|
+
* **默认关,而且关着的时候伪造这两个头毫无作用**——
|
|
39
|
+
* 不开就是不信任任何转发头,攻击者自己塞的值会被忽略。
|
|
40
|
+
*/
|
|
41
|
+
trustProxy: false,
|
|
42
|
+
/**
|
|
43
|
+
* 反向代理层数。只有一层 Nginx = 1;CDN(Cloudflare 等)+ Nginx = 2。
|
|
44
|
+
*
|
|
45
|
+
* 取 `X-Forwarded-For` 时从**右往左**数第 hops 个:右边那些是代理自己加的,
|
|
46
|
+
* 左边的是客户端可以随便伪造的。取错了要么拿到假 IP(等于没限速),
|
|
47
|
+
* 要么把所有人当成同一个 IP(攻击者一个人就能把面板锁死)。
|
|
48
|
+
*/
|
|
49
|
+
trustProxyHops: 1,
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
logs: {
|
|
53
|
+
enabled: true,
|
|
54
|
+
maxBytes: 5 * 1024 * 1024,
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Webhook 接收服务(全局一个端口,服务所有选了 webhook 模式的机器人)。
|
|
59
|
+
* 不开就不占端口;开了以后每个机器人靠 AppID 分流。
|
|
60
|
+
* 公网部署时记得把 path 暴露出去,并配好 HTTPS 反代。
|
|
61
|
+
*/
|
|
62
|
+
webhook: {
|
|
63
|
+
enabled: false,
|
|
64
|
+
/**
|
|
65
|
+
* 默认只听本机:对外回调应该由 Nginx/Caddy 反代进来并终结 TLS。
|
|
66
|
+
*
|
|
67
|
+
* 以前默认 0.0.0.0,一旦开启就对着整个网络——而 Webhook 收的是群消息与
|
|
68
|
+
* openid,明文过网、也没有速率限制。放在反代后面还有个好处:反代能直接
|
|
69
|
+
* 按平台出口 IP 做白名单,比在应用里做更可靠。
|
|
70
|
+
*/
|
|
71
|
+
host: '127.0.0.1',
|
|
72
|
+
port: 15208,
|
|
73
|
+
path: '/webhook',
|
|
74
|
+
/** 对外可访问的完整地址,只用于面板显示和提示,不参与逻辑 */
|
|
75
|
+
publicUrl: '',
|
|
76
|
+
/**
|
|
77
|
+
* 只接受这些来源 IP(空数组 = 不限)。
|
|
78
|
+
* 公网部署时填平台出口 IP 最稳;也可以交给反代做。
|
|
79
|
+
*/
|
|
80
|
+
allowedIps: [],
|
|
81
|
+
/** 每个 IP 每分钟最多多少个请求,防日志洪泛和刷接口 */
|
|
82
|
+
perIpPerMin: 120,
|
|
83
|
+
/**
|
|
84
|
+
* 允许「关掉验签」的第二把钥匙。
|
|
85
|
+
*
|
|
86
|
+
* 只写 `verifySignature: false` **不会**生效——必须同时把这里设为 true。
|
|
87
|
+
* 目的是防手滑:关掉验签之后,任何知道地址和 appId 的人都能伪造事件
|
|
88
|
+
* (包括冒充群主触发管理命令),这个代价不该由一个配置项决定。
|
|
89
|
+
*/
|
|
90
|
+
allowUnsigned: false,
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 下载源。国内直连 GitHub 经常超时,插件市场和框架更新都会失败。
|
|
95
|
+
* 这里让用户自己选,面板上有「测速」按钮,按自己网络实际情况挑。
|
|
96
|
+
*
|
|
97
|
+
* mirror auto(直连优先,失败切 Gitee)/ github / gitee / custom
|
|
98
|
+
* mirrorCustom 选 custom 时的前缀,如 https://my-proxy.example/
|
|
99
|
+
* giteeToken Gitee 私人令牌(选填)。公开仓库匿名也能拉,填了只是把
|
|
100
|
+
* API 配额提上去;只加在 Gitee API 调用上,raw 不需要
|
|
101
|
+
*
|
|
102
|
+
* 只有两个真实来源:GitHub 官方和作者维护的 Gitee 镜像(内容与 GitHub
|
|
103
|
+
* 逐字节相同)。以前那批公共 gh 代理和 MirrorChyan 已移除——实测要么
|
|
104
|
+
* 限速要么挂掉,留着只会让排查更困难。
|
|
105
|
+
*/
|
|
106
|
+
network: {
|
|
107
|
+
mirror: 'auto',
|
|
108
|
+
mirrorCustom: '',
|
|
109
|
+
giteeToken: '',
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 后台服务开关。都是增强,关掉不影响机器人跑。
|
|
114
|
+
*/
|
|
115
|
+
services: {
|
|
116
|
+
/** 每分钟采样内存/积压/重连,超阈值告警 */
|
|
117
|
+
health: true,
|
|
118
|
+
/** 清临时目录、旧备份、过期日志 */
|
|
119
|
+
cleanup: true,
|
|
120
|
+
/** 手改 config/*.json 不用重启 */
|
|
121
|
+
configWatch: true,
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
cleanup: {
|
|
125
|
+
/** 更新备份保留几份 */
|
|
126
|
+
keepBackups: 3,
|
|
127
|
+
/** 日志保留天数 */
|
|
128
|
+
keepLogDays: 14,
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 定时重启。长时间跑内存碎片、句柄泄漏是慢慢积累的,
|
|
133
|
+
* 每天重启一次比事后排查便宜。
|
|
134
|
+
* "at": "04:00" 每天这个点
|
|
135
|
+
* "everyHours": 12 每 N 小时
|
|
136
|
+
* 两个都填以 at 为准。
|
|
137
|
+
*/
|
|
138
|
+
restart: {
|
|
139
|
+
enabled: false,
|
|
140
|
+
at: '',
|
|
141
|
+
everyHours: 0,
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 框架自身更新。
|
|
146
|
+
* autoCheck 只检查不安装(默认),发现新版在面板提示。
|
|
147
|
+
* autoApply 会自动装——**默认关**,开了就要接受装到有 bug 版本的风险。
|
|
148
|
+
*/
|
|
149
|
+
update: {
|
|
150
|
+
autoCheck: true,
|
|
151
|
+
autoApply: false,
|
|
152
|
+
intervalHours: 6,
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
plugins: {
|
|
156
|
+
autoInstallDeps: true,
|
|
157
|
+
/** Go 插件用的 go 可执行文件路径,留空则用 PATH 里的 go 或 GO_BIN 环境变量 */
|
|
158
|
+
goBin: '',
|
|
159
|
+
/** 插件市场索引地址,换成你自己的插件仓库即可 */
|
|
160
|
+
marketUrl: 'https://raw.githubusercontent.com/tdcq9594/qq-bot-roost-plugins/main/index.json',
|
|
161
|
+
/**
|
|
162
|
+
* 插件热重载:改完插件代码存盘两秒后自动生效。
|
|
163
|
+
* 默认开——开发插件时不用反复重启或点「重载」。
|
|
164
|
+
* 生产环境想省这点 CPU 可以关掉,改完手动点重载。
|
|
165
|
+
*/
|
|
166
|
+
hotReload: true,
|
|
167
|
+
/** 轮询间隔(毫秒),默认 2 秒 */
|
|
168
|
+
hotReloadInterval: 2000,
|
|
169
|
+
/**
|
|
170
|
+
* 跳过插件的框架版本校验。插件在 plugin.json 里写了 frameworkVersion,
|
|
171
|
+
* 当前框架不满足时会拒绝加载;作者本地调试想硬跑可以打开这个开关。
|
|
172
|
+
*/
|
|
173
|
+
ignoreFrameworkVersion: false,
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 邮箱通知 / SMTP(全局一份,所有机器人共用这个发信通道)。
|
|
178
|
+
* 字段对齐 BetterGI「设置 → 通知」的邮件部分;面板上就是「通知」页。
|
|
179
|
+
* 走 saveGlobalConfig(排除式写入),新字段会自动落盘,不用改白名单。
|
|
180
|
+
*/
|
|
181
|
+
notifications: {
|
|
182
|
+
smtp: { ...SMTP_DEFAULTS },
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/** 机器人配置:管连接与行为,每个机器人一份 */
|
|
187
|
+
const BOT_DEFAULTS = {
|
|
188
|
+
name: '',
|
|
189
|
+
enabled: true,
|
|
190
|
+
|
|
191
|
+
appId: '',
|
|
192
|
+
appSecret: '',
|
|
193
|
+
baseUrl: 'https://api.bot.qq.com',
|
|
194
|
+
// GROUP_AND_C2C_EVENT = 1 << 25,群聊 @机器人 + 私聊消息
|
|
195
|
+
intents: 1 << 25,
|
|
196
|
+
commandPrefix: '/',
|
|
197
|
+
owners: [],
|
|
198
|
+
|
|
199
|
+
whitelist: {
|
|
200
|
+
enabled: false,
|
|
201
|
+
groups: [],
|
|
202
|
+
users: [],
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
rateLimit: {
|
|
206
|
+
enabled: true,
|
|
207
|
+
perChatPerMinute: 30,
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* 收事件的方式:
|
|
212
|
+
* websocket —— 长连接,本地跑最省心(默认)
|
|
213
|
+
* webhook —— 平台 HTTP 回调,适合公网部署 / Serverless
|
|
214
|
+
* 两种模式下**发消息都走 HTTP API**,行为一致。
|
|
215
|
+
*/
|
|
216
|
+
connection: {
|
|
217
|
+
mode: 'websocket',
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
dedupWindowSec: 120,
|
|
221
|
+
|
|
222
|
+
messageLog: {
|
|
223
|
+
enabled: true,
|
|
224
|
+
muteKinds: [],
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
outbound: {
|
|
228
|
+
perSec: 4,
|
|
229
|
+
/**
|
|
230
|
+
* 主动消息额度,按官方文档(2026-10 版):
|
|
231
|
+
* 单关系维度 20/qpm,每群 1 天最多接收 1000 条。
|
|
232
|
+
* 这里以前写 20(把频道的口径套到群聊),少算 50 倍。
|
|
233
|
+
*/
|
|
234
|
+
dailyActive: 1000,
|
|
235
|
+
activePerMinute: 20,
|
|
236
|
+
downgradeToActive: true,
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
session: {
|
|
240
|
+
stickySec: 180,
|
|
241
|
+
promptTimeoutSec: 120,
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* 统计:消息量、活跃人数、命令排行。
|
|
246
|
+
* 只记数量与时间,**不记消息正文**——正文落库会让人不敢用。
|
|
247
|
+
* 需要 Node 22.5+ 的内置 SQLite,没有就自动降级为不统计。
|
|
248
|
+
*/
|
|
249
|
+
stats: {
|
|
250
|
+
enabled: true,
|
|
251
|
+
/** 明细保留天数,超出的自动清掉;成员档案不清 */
|
|
252
|
+
retentionDays: 90,
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* 事件账本:存原始消息,官方没有历史消息接口,这是唯一的历史来源。
|
|
257
|
+
*
|
|
258
|
+
* 和 `stats` 刻意分开:stats 不存正文(只计数),ledger 存正文。
|
|
259
|
+
* 保留期也短得多——全文落库是隐私成本,只留够做上下文和幂等的窗口即可,
|
|
260
|
+
* 长期记忆的证据由 dsh-roost 的 journal 自己留快照。
|
|
261
|
+
*/
|
|
262
|
+
ledger: {
|
|
263
|
+
enabled: true,
|
|
264
|
+
/**
|
|
265
|
+
* 原始消息保留天数。**0 = 永久不删**(默认)。
|
|
266
|
+
*
|
|
267
|
+
* 2026-11-05 从「7 天」改成「永久」:用户要求不丢历史,改由面板
|
|
268
|
+
* 「账本」页自己管删除(三种口径:整会话 / 某日期前 / 只保留最近 N 条)。
|
|
269
|
+
*
|
|
270
|
+
* 代价要说清:全文永久落库意味着这台机器会一直留着所有群聊原文
|
|
271
|
+
* (含别人的私聊)。所以「占用多少」和「压缩」是配套的,不能省。
|
|
272
|
+
*
|
|
273
|
+
* 这个默认值必须和 `ledger.js` 的构造默认、`bot.js` 的 `?? 0` 三处一致——
|
|
274
|
+
* 任何一处留 7,表现都是「面板显示永久、实际按 7 天删」。
|
|
275
|
+
*/
|
|
276
|
+
retentionDays: 0,
|
|
277
|
+
},
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* 暂停闸。
|
|
281
|
+
*
|
|
282
|
+
* 三级闸本身的状态不写在配置里(它天天变,写在 `data/gate/`),
|
|
283
|
+
* 这里只有一个**启动策略**。
|
|
284
|
+
*/
|
|
285
|
+
gate: {
|
|
286
|
+
/**
|
|
287
|
+
* 首次启动自动暂停。**默认 false**。
|
|
288
|
+
*
|
|
289
|
+
* 对标的产品每次启动都全局暂停,我们不抄:他们是「先隔离检查再放开」的
|
|
290
|
+
* 探索型工具,我们是长期挂机的生产型框架。重启之后不说话,
|
|
291
|
+
* 用户第一反应是「坏了」,不是「等我确认」。
|
|
292
|
+
*
|
|
293
|
+
* 所以只在**首次运行**(闸文件还不存在)时生效:第一次装机要确认,
|
|
294
|
+
* 之后日常重启不添堵。安装向导生成配置时可以写 true。
|
|
295
|
+
*/
|
|
296
|
+
pauseOnStart: false,
|
|
297
|
+
},
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* 启用哪些插件。三态:
|
|
301
|
+
* all — 全部启用,新装插件自动生效
|
|
302
|
+
* include — 只启用 list 里的
|
|
303
|
+
* exclude — 除了 list 里的都启用
|
|
304
|
+
*
|
|
305
|
+
* 默认 all:**装了就用**。
|
|
306
|
+
*
|
|
307
|
+
* 早先的默认是 `include` + 空 list(想「避免误触发」),结果是灾难:
|
|
308
|
+
* 用户从市场装了插件 → 不在名单里 → 不加载 → 面板只列已加载的 →
|
|
309
|
+
* 用户看不到这个插件、也没法启用 → 永远「装了不生效」。
|
|
310
|
+
* 装插件本来就是为了用,默认启用才符合直觉。
|
|
311
|
+
*
|
|
312
|
+
* 需要精细控制(比如某个机器人不跑某几个插件)的,自己改成 include 或 exclude。
|
|
313
|
+
*/
|
|
314
|
+
plugins: {
|
|
315
|
+
mode: 'all',
|
|
316
|
+
list: [],
|
|
317
|
+
/**
|
|
318
|
+
* 启动时自动更新有新版的内邀/公开插件。默认 false:
|
|
319
|
+
* 自动更新会覆盖 plugins/ 里的目录,用户自己改过的代码会丢,
|
|
320
|
+
* 这种事必须先问过。打开后每次启动检查一次,只更市场里标记过的插件。
|
|
321
|
+
*/
|
|
322
|
+
autoUpdate: false,
|
|
323
|
+
/** 在这个版本之前不再弹更新提示(用户选「知道了」后写入) */
|
|
324
|
+
muteUpdateBelow: '',
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* 群管能力。官方群管理接口分两档:
|
|
329
|
+
* 公开 —— 禁言、撤回、群基本信息、机器人群内状态,随时可用
|
|
330
|
+
* 内邀 —— 群成员列表/信息、批量移除(踢人)、群黑名单,
|
|
331
|
+
* 普通机器人调用返回 11253,需先向平台运营申请白名单
|
|
332
|
+
* invited 默认 false:没申请的机器人不该去调,调了也是报错,
|
|
333
|
+
* 还会在日志里留一堆无意义的 11253。申请到了再回面板打开。
|
|
334
|
+
*/
|
|
335
|
+
groupManage: {
|
|
336
|
+
invited: false,
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
function deepMerge(base, extra) {
|
|
341
|
+
const out = { ...base };
|
|
342
|
+
for (const [k, v] of Object.entries(extra ?? {})) {
|
|
343
|
+
if (
|
|
344
|
+
v &&
|
|
345
|
+
typeof v === 'object' &&
|
|
346
|
+
!Array.isArray(v) &&
|
|
347
|
+
typeof base[k] === 'object' &&
|
|
348
|
+
!Array.isArray(base[k])
|
|
349
|
+
) {
|
|
350
|
+
out[k] = deepMerge(base[k], v);
|
|
351
|
+
} else if (v !== undefined) {
|
|
352
|
+
out[k] = v;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return out;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function readJson(file) {
|
|
359
|
+
try {
|
|
360
|
+
return JSON.parse(await fs.readFile(file, 'utf8'));
|
|
361
|
+
} catch (e) {
|
|
362
|
+
if (e.code === 'ENOENT') return null;
|
|
363
|
+
/**
|
|
364
|
+
* 解析失败不再直接抛出去让进程退出。
|
|
365
|
+
* 以前 bot.json 里漏个逗号 → process.exit(1) → 面板起不来,
|
|
366
|
+
* 用户连「用面板改回来」的机会都没有。现在留一份备份、大声报错,
|
|
367
|
+
* 然后按「文件不存在」处理,面板照常起来。
|
|
368
|
+
*/
|
|
369
|
+
const bak = `${file}.corrupt-${Date.now()}`;
|
|
370
|
+
await fs.rename(file, bak).catch(() => {});
|
|
371
|
+
log.error(`${rel(file)} 解析失败: ${e.message}`);
|
|
372
|
+
log.error(`原文件已备份为 ${rel(bak)},本次按默认值启动,请到面板重新填写`);
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function writeJson(file, data) {
|
|
378
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
379
|
+
const tmp = `${file}.tmp`;
|
|
380
|
+
await fs.writeFile(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
381
|
+
await fs.rename(tmp, file);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const exists = (p) =>
|
|
385
|
+
fs.access(p).then(
|
|
386
|
+
() => true,
|
|
387
|
+
() => false,
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
/* ------------------------------------------------------------------ */
|
|
391
|
+
/* 全局配置 */
|
|
392
|
+
/* ------------------------------------------------------------------ */
|
|
393
|
+
|
|
394
|
+
export async function loadGlobalConfig() {
|
|
395
|
+
await ensureDirs();
|
|
396
|
+
const file = (await readJson(PATHS.globalConfig)) ?? {};
|
|
397
|
+
const cfg = deepMerge(GLOBAL_DEFAULTS, file);
|
|
398
|
+
|
|
399
|
+
// 兼容旧字段:webui.token → webui.accessKey
|
|
400
|
+
if (!cfg.webui.accessKey && cfg.webui.token) {
|
|
401
|
+
cfg.webui.accessKey = cfg.webui.token;
|
|
402
|
+
delete cfg.webui.token;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* 环境变量覆盖(容器部署用)。
|
|
407
|
+
* Docker 里配置文件是挂载进来的,改完要重建容器;用环境变量可以直接调,
|
|
408
|
+
* 而且镜像能保持「无状态」——同一份镜像跑不同实例。
|
|
409
|
+
*
|
|
410
|
+
* 优先级:环境变量 > 配置文件 > 默认值。
|
|
411
|
+
* 只在环境变量**有值**时覆盖,不写就完全不影响。
|
|
412
|
+
*/
|
|
413
|
+
const env = process.env;
|
|
414
|
+
if (env.ROOST_WEBUI_HOST) cfg.webui.host = env.ROOST_WEBUI_HOST;
|
|
415
|
+
if (env.ROOST_WEBUI_PORT) {
|
|
416
|
+
const p = Number(env.ROOST_WEBUI_PORT);
|
|
417
|
+
if (Number.isInteger(p) && p > 0 && p < 65536) cfg.webui.port = p;
|
|
418
|
+
}
|
|
419
|
+
if (env.ROOST_WEBUI_KEY) cfg.webui.accessKey = env.ROOST_WEBUI_KEY;
|
|
420
|
+
// 反代场景常靠环境变量开,省得进容器改配置文件
|
|
421
|
+
if (env.ROOST_WEBUI_TRUST_PROXY) {
|
|
422
|
+
cfg.webui.trustProxy = /^(1|true|yes|on)$/i.test(String(env.ROOST_WEBUI_TRUST_PROXY).trim());
|
|
423
|
+
}
|
|
424
|
+
if (env.ROOST_WEBUI_TRUST_PROXY_HOPS) {
|
|
425
|
+
const h = Number(env.ROOST_WEBUI_TRUST_PROXY_HOPS);
|
|
426
|
+
if (Number.isInteger(h) && h >= 1 && h <= 10) cfg.webui.trustProxyHops = h;
|
|
427
|
+
}
|
|
428
|
+
if (env.ROOST_LOG_LEVEL) cfg.logLevel = env.ROOST_LOG_LEVEL;
|
|
429
|
+
|
|
430
|
+
// Webhook:容器里想开回调,两个变量就够
|
|
431
|
+
if (env.ROOST_WEBHOOK_ENABLED) {
|
|
432
|
+
cfg.webhook.enabled = !/^(0|false|no|off)$/i.test(env.ROOST_WEBHOOK_ENABLED);
|
|
433
|
+
}
|
|
434
|
+
if (env.ROOST_WEBHOOK_PORT) {
|
|
435
|
+
const p = Number(env.ROOST_WEBHOOK_PORT);
|
|
436
|
+
if (Number.isInteger(p) && p > 0 && p < 65536) cfg.webhook.port = p;
|
|
437
|
+
}
|
|
438
|
+
if (env.ROOST_WEBHOOK_PATH) cfg.webhook.path = env.ROOST_WEBHOOK_PATH;
|
|
439
|
+
if (env.ROOST_WEBHOOK_PUBLIC_URL) cfg.webhook.publicUrl = env.ROOST_WEBHOOK_PUBLIC_URL;
|
|
440
|
+
|
|
441
|
+
return cfg;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export async function saveGlobalConfig(cfg) {
|
|
445
|
+
/**
|
|
446
|
+
* 用排除法,不再用白名单。
|
|
447
|
+
*
|
|
448
|
+
* 白名单已经漏过两次(webhook / network),这次又漏了
|
|
449
|
+
* services / cleanup / restart / update——面板保存一次日志级别,
|
|
450
|
+
* 就会把用户配好的「每天 04:00 重启」从文件里抹掉,重启后静默失效。
|
|
451
|
+
* 除下划线开头的内部字段外,一律原样写回。
|
|
452
|
+
*/
|
|
453
|
+
const out = {};
|
|
454
|
+
for (const [k, v] of Object.entries(cfg ?? {})) {
|
|
455
|
+
if (k.startsWith('_')) continue;
|
|
456
|
+
out[k] = v;
|
|
457
|
+
}
|
|
458
|
+
await writeJson(PATHS.globalConfig, out);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/* ------------------------------------------------------------------ */
|
|
462
|
+
/* 机器人配置 */
|
|
463
|
+
/* ------------------------------------------------------------------ */
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* 读一个机器人的配置。返回的对象带 `id`。
|
|
467
|
+
* @param {string} id
|
|
468
|
+
*/
|
|
469
|
+
export async function loadBotConfig(id) {
|
|
470
|
+
const p = botPaths(id);
|
|
471
|
+
const file = await readJson(p.config);
|
|
472
|
+
if (!file) throw new Error(`机器人配置不存在: ${rel(p.config)}`);
|
|
473
|
+
|
|
474
|
+
const cfg = deepMerge(BOT_DEFAULTS, file);
|
|
475
|
+
cfg.id = id;
|
|
476
|
+
if (!cfg.name) cfg.name = id;
|
|
477
|
+
|
|
478
|
+
// 环境变量只在配置文件没写时才兜底,避免多机器人时互相串
|
|
479
|
+
if (!cfg.appId && process.env.QQ_BOT_APPID) cfg.appId = process.env.QQ_BOT_APPID;
|
|
480
|
+
if (!cfg.appSecret && process.env.QQ_BOT_SECRET) cfg.appSecret = process.env.QQ_BOT_SECRET;
|
|
481
|
+
if (!file.baseUrl && process.env.QQ_BOT_BASE_URL) cfg.baseUrl = process.env.QQ_BOT_BASE_URL;
|
|
482
|
+
if (!file.owners?.length && process.env.QQ_BOT_OWNERS) {
|
|
483
|
+
cfg.owners = process.env.QQ_BOT_OWNERS.split(',')
|
|
484
|
+
.map((s) => s.trim())
|
|
485
|
+
.filter(Boolean);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* 纠正旧默认值。
|
|
490
|
+
*
|
|
491
|
+
* 0.5.4 之前 BOT_DEFAULTS 是 `mode:'include' + list:[]`,字面意思「一个都不启用」。
|
|
492
|
+
* 结果是用户从市场装了插件也永远不加载,而面板只列已加载的插件,
|
|
493
|
+
* 用户既看不到它、也没法启用——死循环。
|
|
494
|
+
*
|
|
495
|
+
* 只在内存里纠正,**不改用户文件**。用户真想全禁用就自己把 mode 设成 exclude。
|
|
496
|
+
*/
|
|
497
|
+
if (cfg.plugins?.mode === 'include' && !cfg.plugins.list?.length) {
|
|
498
|
+
cfg.plugins.mode = 'all';
|
|
499
|
+
// 这里不能用 logger:logger 依赖 config,会成环。控制台提示够用了。
|
|
500
|
+
console.warn(
|
|
501
|
+
`[提示] 机器人 ${id} 的插件模式是 include 但名单为空(等于全禁用),` +
|
|
502
|
+
`已按 all 处理。想恢复精细控制请编辑 config/bots/${id}.json`,
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return cfg;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export async function saveBotConfig(id, cfg) {
|
|
510
|
+
const p = botPaths(id);
|
|
511
|
+
await writeJson(p.config, {
|
|
512
|
+
name: cfg.name,
|
|
513
|
+
enabled: cfg.enabled !== false,
|
|
514
|
+
appId: cfg.appId,
|
|
515
|
+
appSecret: cfg.appSecret,
|
|
516
|
+
baseUrl: cfg.baseUrl,
|
|
517
|
+
intents: cfg.intents,
|
|
518
|
+
commandPrefix: cfg.commandPrefix,
|
|
519
|
+
owners: cfg.owners,
|
|
520
|
+
whitelist: cfg.whitelist,
|
|
521
|
+
rateLimit: cfg.rateLimit,
|
|
522
|
+
dedupWindowSec: cfg.dedupWindowSec,
|
|
523
|
+
connection: cfg.connection ?? { mode: 'websocket' },
|
|
524
|
+
messageLog: cfg.messageLog,
|
|
525
|
+
outbound: cfg.outbound,
|
|
526
|
+
session: cfg.session,
|
|
527
|
+
stats: cfg.stats ?? { enabled: true, retentionDays: 90 },
|
|
528
|
+
ledger: cfg.ledger ?? { enabled: true, retentionDays: 0 },
|
|
529
|
+
gate: cfg.gate ?? { pauseOnStart: false },
|
|
530
|
+
plugins: cfg.plugins,
|
|
531
|
+
groupManage: cfg.groupManage ?? { invited: false },
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** 读全部机器人配置(坏配置跳过,不阻塞其他机器人) */
|
|
536
|
+
export async function loadAllBotConfigs() {
|
|
537
|
+
const ids = await listBotIds();
|
|
538
|
+
const out = [];
|
|
539
|
+
for (const id of ids) {
|
|
540
|
+
try {
|
|
541
|
+
out.push(await loadBotConfig(id));
|
|
542
|
+
} catch {
|
|
543
|
+
/* 坏配置跳过 */
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return out;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** 新建一个机器人配置 */
|
|
550
|
+
export async function createBotConfig(id, patch = {}) {
|
|
551
|
+
const p = botPaths(id);
|
|
552
|
+
if (await exists(p.config)) throw new Error(`机器人 ${id} 已存在`);
|
|
553
|
+
const cfg = deepMerge(BOT_DEFAULTS, { ...patch, name: patch.name || id });
|
|
554
|
+
cfg.id = id;
|
|
555
|
+
await ensureDirs(id);
|
|
556
|
+
await saveBotConfig(id, cfg);
|
|
557
|
+
return cfg;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/* ------------------------------------------------------------------ */
|
|
561
|
+
/* 迁移:单机器人 → 多机器人 */
|
|
562
|
+
/* ------------------------------------------------------------------ */
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* 搬一个文件,**校验通过才删原文件**。
|
|
566
|
+
* 任何一步失败都抛错,原文件保持不动。
|
|
567
|
+
*/
|
|
568
|
+
async function moveFile(from, to) {
|
|
569
|
+
if (!(await exists(from))) return false;
|
|
570
|
+
const buf = await fs.readFile(from);
|
|
571
|
+
await fs.mkdir(path.dirname(to), { recursive: true });
|
|
572
|
+
await fs.writeFile(to, buf);
|
|
573
|
+
|
|
574
|
+
// 校验:读回来必须逐字节一致,才敢删原件
|
|
575
|
+
const back = await fs.readFile(to);
|
|
576
|
+
if (!back.equals(buf)) throw new Error(`迁移校验失败: ${rel(from)} → ${rel(to)}`);
|
|
577
|
+
await fs.unlink(from);
|
|
578
|
+
return true;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* 首次启动时把旧结构迁到多机器人结构。
|
|
583
|
+
*
|
|
584
|
+
* 用户要求「迁移后不保留旧文件」,所以顺序是:
|
|
585
|
+
* 读旧 → 写新 → 校验 → 删旧
|
|
586
|
+
* 任何一步失败就抛错中止,旧文件不会被破坏。
|
|
587
|
+
*
|
|
588
|
+
* @returns {Promise<{migrated:boolean, botId?:string, moved:string[]}>}
|
|
589
|
+
*/
|
|
590
|
+
export async function migrateToMultiBot() {
|
|
591
|
+
if (await exists(PATHS.migratedFlag)) return { migrated: false, moved: [] };
|
|
592
|
+
|
|
593
|
+
const existing = await listBotIds();
|
|
594
|
+
if (existing.length) {
|
|
595
|
+
await writeJson(PATHS.migratedFlag, { at: Date.now(), note: '已有机器人配置,跳过迁移' });
|
|
596
|
+
return { migrated: false, moved: [] };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// 找旧配置:优先 config/bot.json,其次根目录 config.json
|
|
600
|
+
let legacy = await readJson(PATHS.globalConfig);
|
|
601
|
+
if (!legacy) legacy = await readJson(PATHS.legacyConfig);
|
|
602
|
+
|
|
603
|
+
// 没有 appId 说明是全新安装
|
|
604
|
+
if (!legacy?.appId) {
|
|
605
|
+
await writeJson(PATHS.migratedFlag, { at: Date.now(), note: '全新安装,无需迁移' });
|
|
606
|
+
return { migrated: false, moved: [] };
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const botId = 'main';
|
|
610
|
+
const p = botPaths(botId);
|
|
611
|
+
const moved = [];
|
|
612
|
+
|
|
613
|
+
// 1) 机器人配置
|
|
614
|
+
const botCfg = {
|
|
615
|
+
name: '默认机器人',
|
|
616
|
+
enabled: true,
|
|
617
|
+
appId: legacy.appId,
|
|
618
|
+
appSecret: legacy.appSecret,
|
|
619
|
+
baseUrl: legacy.baseUrl,
|
|
620
|
+
intents: legacy.intents,
|
|
621
|
+
commandPrefix: legacy.commandPrefix,
|
|
622
|
+
owners: legacy.owners,
|
|
623
|
+
whitelist: legacy.whitelist,
|
|
624
|
+
rateLimit: legacy.rateLimit,
|
|
625
|
+
dedupWindowSec: legacy.dedupWindowSec,
|
|
626
|
+
messageLog: legacy.messageLog,
|
|
627
|
+
outbound: legacy.outbound,
|
|
628
|
+
session: legacy.session,
|
|
629
|
+
// 老用户原本就在用的插件,迁移后保持全部启用——
|
|
630
|
+
// 不能因为默认值改成 include 就把人家在跑的插件悄悄停掉
|
|
631
|
+
plugins: { mode: 'all', list: [] },
|
|
632
|
+
};
|
|
633
|
+
await ensureDirs(botId);
|
|
634
|
+
await writeJson(p.config, deepMerge(BOT_DEFAULTS, botCfg));
|
|
635
|
+
moved.push(rel(p.config));
|
|
636
|
+
|
|
637
|
+
// 2) 数据文件
|
|
638
|
+
for (const [from, to] of [
|
|
639
|
+
[path.join(DIRS.data, 'groups.json'), p.groups],
|
|
640
|
+
[path.join(DIRS.data, 'roles.json'), p.roles],
|
|
641
|
+
[path.join(DIRS.data, 'quota.json'), p.quota],
|
|
642
|
+
[path.join(DIRS.data, 'plugins.json'), p.pluginState],
|
|
643
|
+
]) {
|
|
644
|
+
if (await moveFile(from, to)) moved.push(rel(to));
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// 3) 插件存档
|
|
648
|
+
for (const [dir, target, ext] of [
|
|
649
|
+
[path.join(DIRS.data, 'plugins'), p.pluginDataDir, '.json'],
|
|
650
|
+
[path.join(DIRS.logs, 'plugins'), p.pluginLogDir, '.log'],
|
|
651
|
+
[path.join(DIRS.config, 'plugins'), p.pluginConfigDir, '.json'],
|
|
652
|
+
]) {
|
|
653
|
+
try {
|
|
654
|
+
const files = await fs.readdir(dir);
|
|
655
|
+
for (const f of files.filter((x) => x.endsWith(ext))) {
|
|
656
|
+
if (await moveFile(path.join(dir, f), path.join(target, f))) {
|
|
657
|
+
moved.push(rel(path.join(target, f)));
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
} catch {
|
|
661
|
+
/* 目录不存在,正常 */
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// 4) 重写全局配置,只留全局字段
|
|
666
|
+
await writeJson(PATHS.globalConfig, {
|
|
667
|
+
logLevel: legacy.logLevel ?? GLOBAL_DEFAULTS.logLevel,
|
|
668
|
+
cooldownHint: legacy.cooldownHint ?? GLOBAL_DEFAULTS.cooldownHint,
|
|
669
|
+
webui: legacy.webui ?? GLOBAL_DEFAULTS.webui,
|
|
670
|
+
logs: legacy.logs ?? GLOBAL_DEFAULTS.logs,
|
|
671
|
+
plugins: legacy.plugins ?? GLOBAL_DEFAULTS.plugins,
|
|
672
|
+
});
|
|
673
|
+
moved.push(rel(PATHS.globalConfig));
|
|
674
|
+
|
|
675
|
+
// 5) 根目录旧配置如果还在,删掉
|
|
676
|
+
if (await exists(PATHS.legacyConfig)) await fs.unlink(PATHS.legacyConfig);
|
|
677
|
+
|
|
678
|
+
// 6) 清掉搬空了的旧目录
|
|
679
|
+
for (const d of [
|
|
680
|
+
path.join(DIRS.data, 'plugins'),
|
|
681
|
+
path.join(DIRS.logs, 'plugins'),
|
|
682
|
+
path.join(DIRS.config, 'plugins'),
|
|
683
|
+
]) {
|
|
684
|
+
try {
|
|
685
|
+
const rest = await fs.readdir(d);
|
|
686
|
+
if (!rest.length) await fs.rmdir(d);
|
|
687
|
+
} catch {
|
|
688
|
+
/* 不存在就忽略 */
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
await writeJson(PATHS.migratedFlag, { at: Date.now(), botId, moved: moved.length });
|
|
693
|
+
return { migrated: true, botId, moved };
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/* ------------------------------------------------------------------ */
|
|
697
|
+
/* 凭证 */
|
|
698
|
+
/* ------------------------------------------------------------------ */
|
|
699
|
+
|
|
700
|
+
/** 首启向导:给指定机器人补齐 AppID / AppSecret */
|
|
701
|
+
export async function ensureBotCredentials(botId) {
|
|
702
|
+
const cfg = await loadBotConfig(botId);
|
|
703
|
+
if (cfg.appId && cfg.appSecret) return cfg;
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* 没有交互终端时**不要抛错**。
|
|
707
|
+
*
|
|
708
|
+
* 面板本身就是配置入口——因为没凭证就把进程弄死,用户连配的地方都进不去。
|
|
709
|
+
* 这里只提示,交给 BotRuntime 报「缺少凭证」(state=error),
|
|
710
|
+
* 面板照常启动,用户在面板里填完就能连上。
|
|
711
|
+
*/
|
|
712
|
+
if (!stdin.isTTY) {
|
|
713
|
+
console.warn(
|
|
714
|
+
`\n[提示] 机器人 ${botId} 还没有 AppID / AppSecret。\n` +
|
|
715
|
+
` 打开面板填写,或编辑 ${rel(botPaths(botId).config)}。\n` +
|
|
716
|
+
` 面板地址见下面的日志。\n`,
|
|
717
|
+
);
|
|
718
|
+
return cfg;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
console.log(`\n机器人 ${botId} 还没有凭证,请输入 QQ 开放平台的信息(在 q.qq.com 管理端可查看)\n`);
|
|
722
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
723
|
+
try {
|
|
724
|
+
if (!cfg.appId) cfg.appId = (await rl.question('AppID: ')).trim();
|
|
725
|
+
if (!cfg.appSecret) cfg.appSecret = (await rl.question('AppSecret: ')).trim();
|
|
726
|
+
} finally {
|
|
727
|
+
rl.close();
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (!cfg.appId || !cfg.appSecret) {
|
|
731
|
+
console.warn(
|
|
732
|
+
`\n[提示] AppID 或 AppSecret 为空,这次先不连。\n` +
|
|
733
|
+
` 面板已启动,可以在里面填写后再连接。\n`,
|
|
734
|
+
);
|
|
735
|
+
return cfg;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
await saveBotConfig(botId, cfg);
|
|
739
|
+
console.log(`\n已保存到 ${rel(botPaths(botId).config)}\n`);
|
|
740
|
+
return cfg;
|
|
741
|
+
}
|