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/README.md +7 -5
- package/package.json +1 -1
- package/src/api.js +18 -5
- package/src/bot.js +231 -1
- package/src/config.js +35 -0
- package/src/gate.js +325 -0
- package/src/ledger.js +513 -0
- package/src/main.js +1389 -1364
- package/src/message-bus.js +1 -1
- package/src/message.js +3 -3
- package/src/outbound.js +65 -15
- package/src/paths.js +20 -0
- package/src/pipeline.js +10 -1
- package/src/plugin/context.js +890 -817
- package/src/plugin/loader.js +938 -929
- package/src/reach.js +364 -0
- package/src/runtime.js +8 -1
- package/src/storage/events-schema.js +71 -0
- package/src/web/qq-call.js +1 -1
- package/src/web/server.js +263 -1
- package/web/app.js +244 -0
- package/web/index.html +107 -0
package/src/plugin/loader.js
CHANGED
|
@@ -1,929 +1,938 @@
|
|
|
1
|
-
import fs from 'node:fs/promises';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { pathToFileURL } from 'node:url';
|
|
4
|
-
import { createLogger, attachLogFile, detachLogFile } from '../logger.js';
|
|
5
|
-
import { createContext } from './context.js';
|
|
6
|
-
import { SubprocessPlugin } from './subprocess.js';
|
|
7
|
-
import { rel } from '../paths.js';
|
|
8
|
-
import { allocateLevels } from '../levels.js';
|
|
9
|
-
import { PluginConfig, schemaOf } from './config.js';
|
|
10
|
-
import { normalizeDashboard } from './dashboard.js';
|
|
11
|
-
import { readPluginVersion } from './market.js';
|
|
12
|
-
import { ensurePluginDeps } from './deps.js';
|
|
13
|
-
import { satisfies, frameworkVersion } from '../semver.js';
|
|
14
|
-
import { PageRegistry } from './pages.js';
|
|
15
|
-
import { RouteRegistry } from './routes.js';
|
|
16
|
-
|
|
17
|
-
const log = createLogger('plugins');
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* 插件管理器
|
|
21
|
-
*
|
|
22
|
-
* 两种插件并存:
|
|
23
|
-
* 1. 内建插件 —— 目录下有 index.js,用 ESM 直接 import 到主进程(最快)
|
|
24
|
-
* 2. 外部插件 —— 目录下有 plugin.json,启动为独立子进程,可用任意语言
|
|
25
|
-
*
|
|
26
|
-
* 外部插件通过 stdin/stdout 的 NDJSON 通信,协议见 src/plugin/protocol.js。
|
|
27
|
-
* 启用状态存在 data/bots/<机器人id>/plugins.json,删掉文件夹即等于卸载。
|
|
28
|
-
*/
|
|
29
|
-
export class PluginManager {
|
|
30
|
-
constructor({ pluginsDir, paths, registry, api, cfg, goBin, groups, roles, outbound, session, pipeline, isOwner, ignoreCompat }) {
|
|
31
|
-
this.dir = pluginsDir;
|
|
32
|
-
// 该机器人的全套路径(配置/数据/日志),多机器人下互不干扰
|
|
33
|
-
this.paths = paths;
|
|
34
|
-
// 全局配置里的 go 可执行文件路径(机器人配置里没有这一项)
|
|
35
|
-
this.goBin = goBin;
|
|
36
|
-
this.registry = registry;
|
|
37
|
-
this.api = api;
|
|
38
|
-
this.cfg = cfg;
|
|
39
|
-
this.groups = groups;
|
|
40
|
-
this.roles = roles;
|
|
41
|
-
this.outbound = outbound;
|
|
42
|
-
this.session = session;
|
|
43
|
-
this.pipeline = pipeline;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
this.
|
|
48
|
-
/**
|
|
49
|
-
this.
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
this.
|
|
54
|
-
|
|
55
|
-
this.
|
|
56
|
-
/**
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
this.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
);
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
this.registry.clearPlugin(id);
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
const
|
|
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
|
-
this
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
if (host.
|
|
541
|
-
this.registry.
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
this.
|
|
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
|
-
if (
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
this.
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
for (const
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
const
|
|
859
|
-
|
|
860
|
-
if (
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
const
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { createLogger, attachLogFile, detachLogFile } from '../logger.js';
|
|
5
|
+
import { createContext } from './context.js';
|
|
6
|
+
import { SubprocessPlugin } from './subprocess.js';
|
|
7
|
+
import { rel } from '../paths.js';
|
|
8
|
+
import { allocateLevels } from '../levels.js';
|
|
9
|
+
import { PluginConfig, schemaOf } from './config.js';
|
|
10
|
+
import { normalizeDashboard } from './dashboard.js';
|
|
11
|
+
import { readPluginVersion } from './market.js';
|
|
12
|
+
import { ensurePluginDeps } from './deps.js';
|
|
13
|
+
import { satisfies, frameworkVersion } from '../semver.js';
|
|
14
|
+
import { PageRegistry } from './pages.js';
|
|
15
|
+
import { RouteRegistry } from './routes.js';
|
|
16
|
+
|
|
17
|
+
const log = createLogger('plugins');
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 插件管理器
|
|
21
|
+
*
|
|
22
|
+
* 两种插件并存:
|
|
23
|
+
* 1. 内建插件 —— 目录下有 index.js,用 ESM 直接 import 到主进程(最快)
|
|
24
|
+
* 2. 外部插件 —— 目录下有 plugin.json,启动为独立子进程,可用任意语言
|
|
25
|
+
*
|
|
26
|
+
* 外部插件通过 stdin/stdout 的 NDJSON 通信,协议见 src/plugin/protocol.js。
|
|
27
|
+
* 启用状态存在 data/bots/<机器人id>/plugins.json,删掉文件夹即等于卸载。
|
|
28
|
+
*/
|
|
29
|
+
export class PluginManager {
|
|
30
|
+
constructor({ pluginsDir, paths, registry, api, cfg, goBin, groups, roles, outbound, session, pipeline, isOwner, reach, ledger, gate, ignoreCompat }) {
|
|
31
|
+
this.dir = pluginsDir;
|
|
32
|
+
// 该机器人的全套路径(配置/数据/日志),多机器人下互不干扰
|
|
33
|
+
this.paths = paths;
|
|
34
|
+
// 全局配置里的 go 可执行文件路径(机器人配置里没有这一项)
|
|
35
|
+
this.goBin = goBin;
|
|
36
|
+
this.registry = registry;
|
|
37
|
+
this.api = api;
|
|
38
|
+
this.cfg = cfg;
|
|
39
|
+
this.groups = groups;
|
|
40
|
+
this.roles = roles;
|
|
41
|
+
this.outbound = outbound;
|
|
42
|
+
this.session = session;
|
|
43
|
+
this.pipeline = pipeline;
|
|
44
|
+
/** 可达性(能不能私聊/能不能往这个群主动推),见 src/reach.js */
|
|
45
|
+
this.reach = reach;
|
|
46
|
+
/** 事件账本(只读给插件),见 src/ledger.js */
|
|
47
|
+
this.ledger = ledger;
|
|
48
|
+
/** 暂停闸(只给会话级,见 context.js 的 readOnlyGate),见 src/gate.js */
|
|
49
|
+
this.gate = gate;
|
|
50
|
+
this.isOwner = isOwner;
|
|
51
|
+
/** 跳过插件声明的 frameworkVersion 校验(作者本地调试用) */
|
|
52
|
+
this.ignoreCompat = Boolean(ignoreCompat);
|
|
53
|
+
this.loaded = new Map(); // id -> { meta, ctx?, host?, dir, kind, menuMissing }
|
|
54
|
+
/** 插件注册的面板页面(每机器人一份,互不干扰) */
|
|
55
|
+
this.pages = new PageRegistry();
|
|
56
|
+
/** 插件注册的面板接口(REST),给上面那些页面取数据用 */
|
|
57
|
+
this.routes = new RouteRegistry();
|
|
58
|
+
/** 磁盘上实际存在的插件目录,含未启用的 */
|
|
59
|
+
this.diskIds = new Set();
|
|
60
|
+
this.state = {}; // id -> enabled
|
|
61
|
+
this.errors = new Map(); // id -> 一句话错误(面板卡片上用)
|
|
62
|
+
/**
|
|
63
|
+
* id -> 完整堆栈。
|
|
64
|
+
*
|
|
65
|
+
* 只给 message 的话,「Cannot read properties of undefined」这种错误
|
|
66
|
+
* 完全看不出是哪一行,插件作者只能靠猜。面板上展开就能看到调用栈。
|
|
67
|
+
*/
|
|
68
|
+
this.errorStacks = new Map();
|
|
69
|
+
/**
|
|
70
|
+
* id -> 插件自己的内存状态对象。
|
|
71
|
+
*
|
|
72
|
+
* 热重载会重建模块,模块顶层变量全丢——插件作者第一反应是「框架有 bug」。
|
|
73
|
+
* ctx.state 由框架持有,重载时原样递回去,写小缓存、计数器就不用落盘了。
|
|
74
|
+
* 卸载时清掉(那是真的不要了)。
|
|
75
|
+
*/
|
|
76
|
+
this.stateStore = new Map();
|
|
77
|
+
this.allocations = new Map(); // id -> { base, end, index }
|
|
78
|
+
/**
|
|
79
|
+
* 插件元信息缓存:id -> { name, version, description, kind, runtime }
|
|
80
|
+
* 插件被禁用 / 卸载后 loaded 里就没有了,但面板还要显示它的名字和版本,
|
|
81
|
+
* 所以这里单独留一份,不随 unload 清除。
|
|
82
|
+
*/
|
|
83
|
+
this.meta = new Map();
|
|
84
|
+
// 面板「运行数据」的短缓存:id -> { at, data }
|
|
85
|
+
// 面板会轮询,插件函数不能每次都真跑一遍
|
|
86
|
+
this.dashCache = new Map();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async #readState() {
|
|
90
|
+
try {
|
|
91
|
+
this.state = JSON.parse(await fs.readFile(this.paths.pluginState, 'utf8'));
|
|
92
|
+
} catch {
|
|
93
|
+
this.state = {};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async #writeState() {
|
|
98
|
+
await fs.mkdir(path.dirname(this.paths.pluginState), { recursive: true });
|
|
99
|
+
await fs.writeFile(this.paths.pluginState, JSON.stringify(this.state, null, 2), 'utf8');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* ---------------- 扫描与加载 ---------------- */
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 扫描并加载插件。
|
|
106
|
+
* @param {object} [opts]
|
|
107
|
+
* @param {(id:string)=>boolean} [opts.filter] 插件过滤:返回 false 的不加载。
|
|
108
|
+
* 每个机器人可以用不同的 filter,实现「不同机器人启用不同插件」。
|
|
109
|
+
* 只影响启动扫描,手动 /reload 单个插件不受它限制。
|
|
110
|
+
*/
|
|
111
|
+
async scan({ filter = () => true } = {}) {
|
|
112
|
+
await this.#readState();
|
|
113
|
+
this.filter = filter;
|
|
114
|
+
let entries = [];
|
|
115
|
+
try {
|
|
116
|
+
entries = await fs.readdir(this.dir, { withFileTypes: true });
|
|
117
|
+
} catch {
|
|
118
|
+
log.warn(`插件目录不存在: ${this.dir}`);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const allIds = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
123
|
+
// 记下磁盘上到底有哪些,list() 要据此显示「装了但未启用」的插件
|
|
124
|
+
this.diskIds = new Set(allIds);
|
|
125
|
+
// 号段分配必须基于**全部**插件,否则不同机器人看到的等级号段会不一致
|
|
126
|
+
this.allocations = allocateLevels(allIds);
|
|
127
|
+
|
|
128
|
+
const ids = allIds.filter((id) => filter(id));
|
|
129
|
+
const skipped = allIds.filter((id) => !filter(id));
|
|
130
|
+
if (skipped.length) log.info(`本机器人未启用: ${skipped.join('、')}`);
|
|
131
|
+
|
|
132
|
+
if (allIds.length) {
|
|
133
|
+
log.info(
|
|
134
|
+
'等级号段分配 ' +
|
|
135
|
+
[...this.allocations.entries()].map(([id, a]) => `${id}:${a.base}-${a.end}`).join(' '),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const id of ids) {
|
|
140
|
+
if (this.state[id] === false) {
|
|
141
|
+
// 禁用中:不加载,但要读一下元信息,否则面板会显示成「版本 0 / 内建」
|
|
142
|
+
await this.#peekMeta(id);
|
|
143
|
+
log.info(`跳过已禁用插件: ${id}`);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
await this.load(id);
|
|
147
|
+
}
|
|
148
|
+
log.info(`插件加载完成,共 ${this.loaded.size} 个`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 重新扫描插件目录:加载新放进来的,卸载已经被删掉的。
|
|
153
|
+
* 面板的「刷新」按钮用它,不用重启进程。
|
|
154
|
+
* @returns {Promise<{added:string[], removed:string[]}>}
|
|
155
|
+
*/
|
|
156
|
+
async rescan({ filter = null } = {}) {
|
|
157
|
+
await this.#readState();
|
|
158
|
+
if (filter) this.filter = filter;
|
|
159
|
+
const use = this.filter ?? (() => true);
|
|
160
|
+
|
|
161
|
+
let entries = [];
|
|
162
|
+
try {
|
|
163
|
+
entries = await fs.readdir(this.dir, { withFileTypes: true });
|
|
164
|
+
} catch {
|
|
165
|
+
return { added: [], removed: [] };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const allIds = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
169
|
+
this.diskIds = new Set(allIds);
|
|
170
|
+
// 号段要按全量重算,否则新插件拿不到号段
|
|
171
|
+
this.allocations = allocateLevels(allIds);
|
|
172
|
+
|
|
173
|
+
const added = [];
|
|
174
|
+
const removed = [];
|
|
175
|
+
|
|
176
|
+
for (const id of allIds) {
|
|
177
|
+
if (this.loaded.has(id)) continue;
|
|
178
|
+
if (!use(id)) continue;
|
|
179
|
+
if (this.state[id] === false) continue;
|
|
180
|
+
try {
|
|
181
|
+
await this.load(id);
|
|
182
|
+
added.push(id);
|
|
183
|
+
} catch (e) {
|
|
184
|
+
this.errors.set(id, e.message);
|
|
185
|
+
this.errorStacks.set(id, e.stack || e.message);
|
|
186
|
+
log.error(`加载插件 ${id} 失败: ${e.stack || e.message}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
for (const id of [...this.loaded.keys()]) {
|
|
191
|
+
if (allIds.includes(id)) continue;
|
|
192
|
+
try {
|
|
193
|
+
await this.unload(id);
|
|
194
|
+
// 目录没了就把它留下的元信息、错误记录、开关状态一起清掉。
|
|
195
|
+
// 不清的话 list() 会继续把这个不存在的插件列出来(meta 里还有它),
|
|
196
|
+
// 面板上就出现一个「已禁用、v0.0.0」的幽灵条目。
|
|
197
|
+
await this.forget(id);
|
|
198
|
+
removed.push(id);
|
|
199
|
+
} catch {}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (added.length) log.info(`新加载插件: ${added.join('、')}`);
|
|
203
|
+
if (removed.length) log.info(`插件目录已消失,已卸载: ${removed.join('、')}`);
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* 把加载失败的也带出去。
|
|
207
|
+
*
|
|
208
|
+
* 原来只返回 added/removed,前端看到空 added 只能提示「没有发现新插件」——
|
|
209
|
+
* 但真实情况可能是「发现了,加载失败」。两种完全不同的事,
|
|
210
|
+
* 却给用户同一句话,只能靠翻日志才知道怎么回事。
|
|
211
|
+
*/
|
|
212
|
+
const errors = [];
|
|
213
|
+
for (const [id, msg] of this.errors) {
|
|
214
|
+
if (allIds.includes(id)) errors.push({ id, error: msg });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return { added, removed, errors };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* 校验插件声明的框架版本要求。
|
|
222
|
+
*
|
|
223
|
+
* 为什么要在**加载前**拦:插件用到了新版本才有的 ctx 方法,
|
|
224
|
+
* 装到旧框架上会在运行到那一行时才炸,报错还指向插件内部,
|
|
225
|
+
* 作者和用户都看不出真正原因是「框架太旧」。
|
|
226
|
+
*
|
|
227
|
+
* 两种写法都认:plugin.json 里的 frameworkVersion,
|
|
228
|
+
* 或内建插件默认导出里的 frameworkVersion。
|
|
229
|
+
*/
|
|
230
|
+
#assertCompatible(id, meta) {
|
|
231
|
+
const need = meta?.frameworkVersion ?? meta?.requires?.framework;
|
|
232
|
+
if (!need || this.ignoreCompat) return;
|
|
233
|
+
const cur = frameworkVersion();
|
|
234
|
+
if (satisfies(cur, need)) return;
|
|
235
|
+
throw new Error(
|
|
236
|
+
`要求框架 ${need},当前是 ${cur}。升级框架后再启用,` +
|
|
237
|
+
'或者把插件里的 frameworkVersion 改成兼容的范围',
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async #detectKind(dir) {
|
|
242
|
+
try {
|
|
243
|
+
await fs.access(path.join(dir, 'plugin.json'));
|
|
244
|
+
return 'external';
|
|
245
|
+
} catch {}
|
|
246
|
+
for (const f of ['index.js', 'index.mjs', 'main.js']) {
|
|
247
|
+
try {
|
|
248
|
+
await fs.access(path.join(dir, f));
|
|
249
|
+
return 'internal';
|
|
250
|
+
} catch {}
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async load(id) {
|
|
256
|
+
const dir = path.join(this.dir, id);
|
|
257
|
+
|
|
258
|
+
if (!this.allocations.has(id)) {
|
|
259
|
+
try {
|
|
260
|
+
const dirs = await fs.readdir(this.dir, { withFileTypes: true });
|
|
261
|
+
const next = allocateLevels(dirs.filter((e) => e.isDirectory()).map((e) => e.name));
|
|
262
|
+
for (const [pid, alloc] of next) {
|
|
263
|
+
const prev = this.allocations.get(pid);
|
|
264
|
+
if (prev && prev.base !== alloc.base) {
|
|
265
|
+
log.warn(
|
|
266
|
+
`插件 ${pid} 的等级号段从 ${prev.base}-${prev.end} 变为 ${alloc.base}-${alloc.end},` +
|
|
267
|
+
`已配置的该插件权限可能需要重新设置。建议重启一次让号段稳定。`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
this.allocations = next;
|
|
272
|
+
} catch {}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const kind = await this.#detectKind(dir);
|
|
276
|
+
if (!kind) {
|
|
277
|
+
log.warn(`插件 ${id} 既没有 plugin.json 也没有 index.js,跳过`);
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
return kind === 'external'
|
|
283
|
+
? await this.#loadExternal(id, dir)
|
|
284
|
+
: await this.#loadInternal(id, dir);
|
|
285
|
+
} catch (e) {
|
|
286
|
+
/**
|
|
287
|
+
* 「热重载只对入口生效」是个容易踩的坑,报错里点名说清楚。
|
|
288
|
+
*
|
|
289
|
+
* 原因:上面只给 `index.js` 加了 `?t=` 绕缓存,它 import 的子模块
|
|
290
|
+
* (`outbound.js` 之类)还在 Node 的模块缓存里。改了子模块再触发重载,
|
|
291
|
+
* 拿到的就是「新入口 + 旧子模块」,典型表现是
|
|
292
|
+
* `does not provide an export named 'xxx'`。
|
|
293
|
+
*
|
|
294
|
+
* ESM 没有清缓存的 API,所以只能提示重启——但至少要让人知道为什么。
|
|
295
|
+
*/
|
|
296
|
+
const msg = String(e?.message ?? '');
|
|
297
|
+
if (/does not provide an export named/.test(msg)) {
|
|
298
|
+
e.message += '\n(热重载只重新加载 index.js,子模块仍在缓存里。改了 outbound.js 这类文件请重启 Roost)';
|
|
299
|
+
}
|
|
300
|
+
this.errors.set(id, e.message);
|
|
301
|
+
this.errorStacks.set(id, e.stack || e.message);
|
|
302
|
+
log.error(`插件 ${id} 加载失败: ${e.stack || e.message}`);
|
|
303
|
+
/**
|
|
304
|
+
* 失败时把已经建起来的东西收干净。
|
|
305
|
+
*
|
|
306
|
+
* 外部插件的子进程如果已经起来了但注册阶段失败,它不在 loaded 里,
|
|
307
|
+
* shutdown() 遍历不到它,它却还活着;内建插件可能已经注册了半套命令,
|
|
308
|
+
* 指向一个马上要被丢掉的 ctx。不清理就是「幽灵进程 + 半生效命令」。
|
|
309
|
+
*/
|
|
310
|
+
try {
|
|
311
|
+
const partial = this.loaded.get(id);
|
|
312
|
+
if (partial?.host) await partial.host.stop().catch(() => {});
|
|
313
|
+
if (partial) {
|
|
314
|
+
this.loaded.delete(id);
|
|
315
|
+
this.registry.clearPlugin(id);
|
|
316
|
+
this.pipeline?.removeByPlugin?.(id);
|
|
317
|
+
this.pages?.clearPlugin(id);
|
|
318
|
+
this.routes?.clearPlugin(id);
|
|
319
|
+
} else {
|
|
320
|
+
// 还没进 loaded:至少把注册表里可能残留的入口摘掉
|
|
321
|
+
this.registry.clearPlugin(id);
|
|
322
|
+
}
|
|
323
|
+
} catch (cleanupErr) {
|
|
324
|
+
log.warn(`清理未加载成功的插件 ${id} 时出错:${cleanupErr.message}`);
|
|
325
|
+
}
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** 取(或新建)插件的内存状态对象。热重载时同一个对象会被递回去 */
|
|
331
|
+
#stateOf(id) {
|
|
332
|
+
let s = this.stateStore.get(id);
|
|
333
|
+
if (!s) {
|
|
334
|
+
s = {};
|
|
335
|
+
this.stateStore.set(id, s);
|
|
336
|
+
}
|
|
337
|
+
return s;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/* ---------------- 内建插件(ESM) ---------------- */
|
|
341
|
+
|
|
342
|
+
async #loadInternal(id, dir) {
|
|
343
|
+
let entryFile = null;
|
|
344
|
+
for (const c of ['index.js', 'index.mjs', 'main.js']) {
|
|
345
|
+
try {
|
|
346
|
+
await fs.access(path.join(dir, c));
|
|
347
|
+
entryFile = path.join(dir, c);
|
|
348
|
+
break;
|
|
349
|
+
} catch {}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* 内建插件也可能有自己的 npm 依赖(比如渲染插件要 @napi-rs/canvas)。
|
|
354
|
+
* 以前只有外部插件会装依赖,内建插件只能让用户手动 `npm i --prefix`,
|
|
355
|
+
* 踩坑的人不少。这里复用同一套安装逻辑:有 package.json 就装到插件目录,
|
|
356
|
+
* 没有就什么都不做(绝大多数内建插件是零依赖的)。
|
|
357
|
+
*/
|
|
358
|
+
if (await fs.access(path.join(dir, 'package.json')).then(() => true).catch(() => false)) {
|
|
359
|
+
await ensurePluginDeps({
|
|
360
|
+
dir,
|
|
361
|
+
manifest: {},
|
|
362
|
+
log,
|
|
363
|
+
enabled: this.cfg?.plugins?.autoInstallDeps !== false,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// 加时间戳绕过 ESM 缓存,实现热重载
|
|
368
|
+
const url = `${pathToFileURL(entryFile).href}?t=${Date.now()}`;
|
|
369
|
+
const mod = await import(url);
|
|
370
|
+
const meta = mod.default ?? mod.plugin;
|
|
371
|
+
if (!meta || typeof meta.setup !== 'function') {
|
|
372
|
+
throw new Error('默认导出必须是 { id, setup(ctx) } 形式的对象');
|
|
373
|
+
}
|
|
374
|
+
// 统一以目录名为插件 id:loaded / errors / allocations / registry 四处都按它索引,
|
|
375
|
+
// 允许自报 id 会导致「加载成功但错误记录残留」这类问题。
|
|
376
|
+
if (meta.id && meta.id !== id) {
|
|
377
|
+
log.warn(`插件目录 ${id} 里声明的 id 是 ${meta.id},与目录名不一致,以目录名为准`);
|
|
378
|
+
}
|
|
379
|
+
meta.id = id;
|
|
380
|
+
// 版本兼容在 setup 之前拦——跑起来才发现「ctx.xxx 不是函数」就太晚了
|
|
381
|
+
this.#assertCompatible(id, meta);
|
|
382
|
+
|
|
383
|
+
// 每个插件一份自己的日志文件:logs/bots/<机器人>/plugins/<id>.log
|
|
384
|
+
if (this.cfg.logs?.enabled !== false) {
|
|
385
|
+
await attachLogFile(`plugin:${this.paths.id}/${meta.id}`, this.paths.pluginLog(meta.id), {
|
|
386
|
+
maxBytes: this.cfg.logs?.maxBytes,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// 每个插件一份自己的配置文件:config/bots/<机器人>/plugins/<id>.json
|
|
391
|
+
const configStore = new PluginConfig({
|
|
392
|
+
id: meta.id,
|
|
393
|
+
defaults: meta.config ?? {},
|
|
394
|
+
file: this.paths.pluginConfig(meta.id),
|
|
395
|
+
log,
|
|
396
|
+
});
|
|
397
|
+
const config = await configStore.load();
|
|
398
|
+
|
|
399
|
+
this.registry.clearPlugin(meta.id);
|
|
400
|
+
const ctx = createContext({
|
|
401
|
+
meta,
|
|
402
|
+
paths: this.paths,
|
|
403
|
+
registry: this.registry,
|
|
404
|
+
api: this.api,
|
|
405
|
+
cfg: this.cfg,
|
|
406
|
+
config,
|
|
407
|
+
configStore,
|
|
408
|
+
groups: this.groups,
|
|
409
|
+
roles: this.roles,
|
|
410
|
+
levels: this.allocations.get(id) ?? null,
|
|
411
|
+
// 同一个对象跨重载复用,见 stateStore 的注释
|
|
412
|
+
state: this.#stateOf(id),
|
|
413
|
+
outbound: this.outbound,
|
|
414
|
+
session: this.session,
|
|
415
|
+
pipeline: this.pipeline,
|
|
416
|
+
reach: this.reach,
|
|
417
|
+
ledger: this.ledger,
|
|
418
|
+
gate: this.gate,
|
|
419
|
+
isOwner: this.isOwner,
|
|
420
|
+
pages: this.pages,
|
|
421
|
+
routes: this.routes,
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
await meta.setup(ctx);
|
|
425
|
+
|
|
426
|
+
const menuMissing = !this.registry.hasMenu(meta.id);
|
|
427
|
+
if (menuMissing) {
|
|
428
|
+
log.warn(
|
|
429
|
+
`插件 ${meta.id} 没有提供任何指令菜单:既没注册命令,也没调用 ctx.menu()。` +
|
|
430
|
+
`请在 setup 里补上,否则用户看不到它能做什么。`,
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
this.loaded.set(meta.id, { meta, ctx, dir, kind: 'internal', menuMissing, configStore });
|
|
435
|
+
this.#cacheMeta(meta.id, meta, 'internal', 'node');
|
|
436
|
+
// 加载成功就把上次的报错和堆栈一起清掉,否则修好之后面板还挂着旧红字
|
|
437
|
+
this.errors.delete(meta.id);
|
|
438
|
+
this.errorStacks.delete(meta.id);
|
|
439
|
+
log.info(`已加载内建插件 ${meta.id} (${meta.name ?? ''})`);
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/* ---------------- 外部插件(子进程,任意语言) ---------------- */
|
|
444
|
+
|
|
445
|
+
async #loadExternal(id, dir) {
|
|
446
|
+
const raw = JSON.parse(await fs.readFile(path.join(dir, 'plugin.json'), 'utf8'));
|
|
447
|
+
if (raw.id && raw.id !== id) {
|
|
448
|
+
log.warn(`插件目录 ${id} 的 plugin.json 声明 id 为 ${raw.id},与目录名不一致,以目录名为准`);
|
|
449
|
+
}
|
|
450
|
+
const meta = {
|
|
451
|
+
id, // 以目录名为准,保证各处索引一致
|
|
452
|
+
name: raw.name ?? id,
|
|
453
|
+
version: raw.version ?? '0.0.0',
|
|
454
|
+
description: raw.description ?? '',
|
|
455
|
+
// 面板和市场卡片用的补充信息,全部可选
|
|
456
|
+
shortDesc: raw.shortDesc ?? '',
|
|
457
|
+
icon: raw.icon ?? '',
|
|
458
|
+
repo: raw.repo ?? '',
|
|
459
|
+
supportPlatforms: Array.isArray(raw.supportPlatforms) ? raw.supportPlatforms : [],
|
|
460
|
+
frameworkVersion: raw.frameworkVersion ?? raw.requires?.framework ?? '',
|
|
461
|
+
/**
|
|
462
|
+
* 监听器优先级:高的先跑。见 bot.js 里的排序说明。
|
|
463
|
+
* 外部插件在 plugin.json 里写 `"priority": -100` 就能排到最后。
|
|
464
|
+
*/
|
|
465
|
+
priority: Number.isFinite(Number(raw.priority)) ? Number(raw.priority) : 0,
|
|
466
|
+
/** 别人认领过的消息还要不要看(默认不看) */
|
|
467
|
+
seeClaimed: raw.seeClaimed === true,
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
this.#assertCompatible(id, meta);
|
|
471
|
+
|
|
472
|
+
// 每个插件一份自己的日志文件:logs/bots/<机器人>/plugins/<id>.log
|
|
473
|
+
if (this.cfg.logs?.enabled !== false) {
|
|
474
|
+
await attachLogFile(`plugin:${this.paths.id}/${id}`, this.paths.pluginLog(id), {
|
|
475
|
+
maxBytes: this.cfg.logs?.maxBytes,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// 开箱即用:缺依赖就装进插件目录里的隔离环境,装不上直接报清楚原因
|
|
480
|
+
const deps = await ensurePluginDeps({
|
|
481
|
+
dir,
|
|
482
|
+
manifest: raw,
|
|
483
|
+
log,
|
|
484
|
+
enabled: this.cfg.plugins?.autoInstallDeps !== false,
|
|
485
|
+
});
|
|
486
|
+
for (const w of deps.warnings) log.warn(`插件 ${id}: ${w}`);
|
|
487
|
+
if (deps.installed.length) log.info(`插件 ${id} 已自动安装:${deps.installed.join('、')}`);
|
|
488
|
+
|
|
489
|
+
// 每个插件一份自己的配置文件:config/bots/<机器人>/plugins/<id>.json
|
|
490
|
+
const configStore = new PluginConfig({
|
|
491
|
+
id,
|
|
492
|
+
defaults: raw.config ?? {},
|
|
493
|
+
file: this.paths.pluginConfig(id),
|
|
494
|
+
log,
|
|
495
|
+
});
|
|
496
|
+
const config = await configStore.load();
|
|
497
|
+
|
|
498
|
+
this.registry.clearPlugin(meta.id);
|
|
499
|
+
|
|
500
|
+
const host = new SubprocessPlugin({
|
|
501
|
+
dir,
|
|
502
|
+
manifest: { ...raw, id: meta.id, config },
|
|
503
|
+
botId: this.paths.id,
|
|
504
|
+
// 外部插件没有 storage 协议,给它一个专属目录自己存
|
|
505
|
+
dataDir: this.paths.pluginDataDirFor(meta.id),
|
|
506
|
+
// 有 venv 就用 venv 里的解释器,没有就用探测到的系统解释器
|
|
507
|
+
pythonPath: deps.python ?? null,
|
|
508
|
+
goPath: this.goBin,
|
|
509
|
+
allocation: this.allocations.get(id) ?? null,
|
|
510
|
+
cfg: this.cfg,
|
|
511
|
+
roles: this.roles,
|
|
512
|
+
isOwner: this.isOwner,
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
await host.start(); // 失败会抛错,由 load() 捕获
|
|
516
|
+
|
|
517
|
+
const prefix = this.cfg.commandPrefix || '/';
|
|
518
|
+
for (const c of host.commands) {
|
|
519
|
+
const pattern = String(c.pattern ?? c.name ?? '').trim();
|
|
520
|
+
const name = pattern.split(/\s+/)[0];
|
|
521
|
+
if (!name) continue;
|
|
522
|
+
this.registry.addCommand(
|
|
523
|
+
meta.id,
|
|
524
|
+
name,
|
|
525
|
+
{
|
|
526
|
+
desc: c.desc ?? '',
|
|
527
|
+
usage: c.usage ?? `${prefix}${pattern}`,
|
|
528
|
+
pattern,
|
|
529
|
+
admin: Boolean(c.admin),
|
|
530
|
+
globalAdmin: Boolean(c.globalAdmin),
|
|
531
|
+
plugin: meta.name,
|
|
532
|
+
},
|
|
533
|
+
async (msg) => {
|
|
534
|
+
await host.handle(msg);
|
|
535
|
+
},
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// 非命令消息:插件声明了 listen 才转发,避免无谓的进程通信
|
|
540
|
+
if (host.listen.includes('message')) {
|
|
541
|
+
this.registry.addListener(meta.id, 'message', async (msg) => {
|
|
542
|
+
await host.handle(msg);
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
for (const sec of host.menus) {
|
|
547
|
+
this.registry.addMenuSection(meta.id, meta, sec.title, sec.items ?? []);
|
|
548
|
+
}
|
|
549
|
+
if (host.levels.length) {
|
|
550
|
+
this.registry.addMenuSection(
|
|
551
|
+
meta.id,
|
|
552
|
+
meta,
|
|
553
|
+
'权限等级',
|
|
554
|
+
host.levels.map((l) => ({ text: String(l.level), desc: l.name ?? '' })),
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const menuMissing = !this.registry.hasMenu(meta.id);
|
|
559
|
+
if (menuMissing) {
|
|
560
|
+
log.warn(`外部插件 ${meta.id} 的 ready 里没有 commands / menus,用户看不到它能做什么`);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
this.loaded.set(meta.id, { meta, host, dir, kind: 'external', menuMissing, configStore });
|
|
564
|
+
this.#cacheMeta(meta.id, meta, 'external', raw.runtime || 'python');
|
|
565
|
+
this.errors.delete(meta.id);
|
|
566
|
+
this.errorStacks.delete(meta.id);
|
|
567
|
+
log.info(
|
|
568
|
+
`已加载外部插件 ${meta.id} (${meta.name}) · ${host.status.runtime} · pid ${host.status.pid} · ${host.commands.length} 条命令`,
|
|
569
|
+
);
|
|
570
|
+
return true;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/* ---------------- 卸载与重载 ---------------- */
|
|
574
|
+
|
|
575
|
+
async unload(id) {
|
|
576
|
+
const item = this.loaded.get(id);
|
|
577
|
+
if (!item) return false;
|
|
578
|
+
|
|
579
|
+
if (item.kind === 'external') {
|
|
580
|
+
try {
|
|
581
|
+
await item.host.stop();
|
|
582
|
+
} catch (e) {
|
|
583
|
+
log.warn(`插件 ${id} 停止进程出错: ${e.message}`);
|
|
584
|
+
}
|
|
585
|
+
} else {
|
|
586
|
+
try {
|
|
587
|
+
await item.meta.teardown?.(item.ctx);
|
|
588
|
+
} catch (e) {
|
|
589
|
+
log.warn(`插件 ${id} teardown 出错: ${e.message}`);
|
|
590
|
+
}
|
|
591
|
+
try {
|
|
592
|
+
item.ctx.dispose?.();
|
|
593
|
+
} catch (e) {
|
|
594
|
+
log.warn(`插件 ${id} 资源清理出错: ${e.message}`);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
this.registry.clearPlugin(id);
|
|
599
|
+
// 插件注册的中间件也要一起摘掉,否则卸载后它还挡着别人的消息
|
|
600
|
+
this.pipeline?.removeByPlugin?.(id);
|
|
601
|
+
// 面板页面同样要清,否则标签还在但点开是空白
|
|
602
|
+
this.pages?.clearPlugin(id);
|
|
603
|
+
// 接口也要清,否则页面还留着但每个请求都 404
|
|
604
|
+
this.routes?.clearPlugin(id);
|
|
605
|
+
// 关掉它的日志文件句柄,免得句柄泄漏
|
|
606
|
+
detachLogFile(`plugin:${this.paths.id}/${id}`);
|
|
607
|
+
this.loaded.delete(id);
|
|
608
|
+
this.dashCache.delete(id);
|
|
609
|
+
log.info(`已卸载插件 ${id}`);
|
|
610
|
+
return true;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async reload(id) {
|
|
614
|
+
await this.unload(id);
|
|
615
|
+
return this.load(id);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* 忘掉一个插件留下的所有痕迹。
|
|
620
|
+
*
|
|
621
|
+
* 卸载时用。只删目录不够——state 里还留着它的开关记录(可能是 false),
|
|
622
|
+
* 重装之后会被这条旧记录挡住不加载,用户会以为装坏了。
|
|
623
|
+
*/
|
|
624
|
+
async forget(id) {
|
|
625
|
+
let dirty = false;
|
|
626
|
+
if (this.state && id in this.state) {
|
|
627
|
+
delete this.state[id];
|
|
628
|
+
dirty = true;
|
|
629
|
+
}
|
|
630
|
+
this.errors.delete(id);
|
|
631
|
+
this.errorStacks.delete(id);
|
|
632
|
+
this.stateStore.delete(id);
|
|
633
|
+
this.meta.delete(id);
|
|
634
|
+
this.diskIds.delete(id);
|
|
635
|
+
if (dirty) await this.#writeState().catch(() => {});
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
async setEnabled(id, enabled) {
|
|
639
|
+
this.state[id] = enabled;
|
|
640
|
+
await this.#writeState();
|
|
641
|
+
if (enabled) return this.load(id);
|
|
642
|
+
return this.unload(id);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* 「启用」是**两层**共同决定的:
|
|
647
|
+
* 1. 机器人配置的过滤器(include / exclude / all)—— 决定这个机器人要不要这个插件
|
|
648
|
+
* 2. 面板上的开关(state)—— 决定装了的插件要不要跑
|
|
649
|
+
*
|
|
650
|
+
* 只看第 2 层会骗人:配置里被 include 名单排除的插件,
|
|
651
|
+
* 面板会显示「已启用」,实际根本没加载,用户点来点去都莫名其妙。
|
|
652
|
+
*/
|
|
653
|
+
isEnabled(id) {
|
|
654
|
+
if (this.filter && !this.filter(id)) return false;
|
|
655
|
+
return this.state[id] !== false;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** 退出前调用:停掉所有外部插件进程,否则子进程会挂住主进程 */
|
|
659
|
+
async shutdown() {
|
|
660
|
+
const entries = [...this.loaded.entries()];
|
|
661
|
+
for (const [id, item] of entries) {
|
|
662
|
+
if (item.kind !== 'external') continue;
|
|
663
|
+
try {
|
|
664
|
+
await item.host.stop();
|
|
665
|
+
log.debug(`已停止外部插件 ${id}`);
|
|
666
|
+
} catch (e) {
|
|
667
|
+
log.warn(`停止外部插件 ${id} 失败: ${e.message}`);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** 记下元信息,供禁用后仍能在面板正确显示 */
|
|
673
|
+
#cacheMeta(id, meta, kind, runtime) {
|
|
674
|
+
this.meta.set(id, {
|
|
675
|
+
name: meta?.name || id,
|
|
676
|
+
version: meta?.version || '',
|
|
677
|
+
description: meta?.description || '',
|
|
678
|
+
// 下面这几项都是可选的,插件不写就是空
|
|
679
|
+
shortDesc: meta?.shortDesc || '',
|
|
680
|
+
icon: meta?.icon || '',
|
|
681
|
+
repo: meta?.repo || '',
|
|
682
|
+
supportPlatforms: Array.isArray(meta?.supportPlatforms) ? meta.supportPlatforms : [],
|
|
683
|
+
frameworkVersion: meta?.frameworkVersion || meta?.requires?.framework || '',
|
|
684
|
+
kind,
|
|
685
|
+
runtime,
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* 只在需要显示时读一下元信息,不真正加载插件。
|
|
691
|
+
* 用于「禁用中」的插件——它们没进 loaded,但面板要显示名字、版本和类型。
|
|
692
|
+
*/
|
|
693
|
+
async #peekMeta(id) {
|
|
694
|
+
if (this.meta.has(id)) return;
|
|
695
|
+
const dir = path.join(this.dir, id);
|
|
696
|
+
|
|
697
|
+
// 外部插件:plugin.json 就是元信息
|
|
698
|
+
try {
|
|
699
|
+
const raw = JSON.parse(await fs.readFile(path.join(dir, 'plugin.json'), 'utf8'));
|
|
700
|
+
this.#cacheMeta(id, raw, 'external', raw.runtime || 'python');
|
|
701
|
+
return;
|
|
702
|
+
} catch {
|
|
703
|
+
/* 不是外部插件,继续看内建 */
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* 内建插件:**不 import**。
|
|
708
|
+
*
|
|
709
|
+
* 以前为了读 name/version 会 import 一次模块——但 ESM 的顶层代码
|
|
710
|
+
* 是立刻执行的:定时器、网络请求、写文件全都会跑起来。
|
|
711
|
+
* 用户点「禁用」本来是想止损,结果插件照样在后台活动。
|
|
712
|
+
* 拿不到版本号就显示空,比偷偷执行代码强。
|
|
713
|
+
*/
|
|
714
|
+
for (const f of ['index.js', 'index.mjs', 'main.js']) {
|
|
715
|
+
const p = path.join(dir, f);
|
|
716
|
+
const exists = await fs.access(p).then(() => true, () => false);
|
|
717
|
+
if (!exists) continue;
|
|
718
|
+
this.#cacheMeta(id, { id, name: id, version: '' }, 'internal', 'node');
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/* ---------------- 面板:配置与数据 ---------------- */
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* 面板「插件设置」要的全部东西:配置 schema、当前值、插件存档。
|
|
727
|
+
* @param {string} id
|
|
728
|
+
*/
|
|
729
|
+
async panelOf(id) {
|
|
730
|
+
const item = this.loaded.get(id);
|
|
731
|
+
if (!item) return null;
|
|
732
|
+
|
|
733
|
+
const store = item.configStore;
|
|
734
|
+
const config = store ? await store.load() : {};
|
|
735
|
+
const schema = store ? schemaOf(store.rawDefaults ?? {}) : {};
|
|
736
|
+
const storage = item.ctx?.storage ?? null;
|
|
737
|
+
const data = storage ? await storage.all() : {};
|
|
738
|
+
|
|
739
|
+
return {
|
|
740
|
+
id,
|
|
741
|
+
name: item.meta?.name ?? id,
|
|
742
|
+
kind: item.kind,
|
|
743
|
+
config,
|
|
744
|
+
schema,
|
|
745
|
+
/**
|
|
746
|
+
* 「快速填入」的预设,插件在 meta 上声明:
|
|
747
|
+
* configPresets: [{ name:'DeepSeek', values:{ baseUrl:'…', model:'…' } }]
|
|
748
|
+
*
|
|
749
|
+
* 放在 meta 而不是 config 里,是因为它跨字段(一次填好几个),
|
|
750
|
+
* 也不该被存进用户配置——用户选了哪个由填进去的值本身体现。
|
|
751
|
+
*/
|
|
752
|
+
presets: Array.isArray(item.meta?.configPresets) ? item.meta.configPresets : [],
|
|
753
|
+
data,
|
|
754
|
+
// 存档键里哪些像玩家数据,给面板分组用
|
|
755
|
+
dataKeys: Object.keys(data),
|
|
756
|
+
// 插件自定义的运行指标(可能为 null)
|
|
757
|
+
dashboard: await this.dashboardOf(id),
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* 面板「运行数据」:插件想在面板上显示什么就显示什么。
|
|
763
|
+
*
|
|
764
|
+
* 三种来源,按优先级:
|
|
765
|
+
* 1. 插件导出里声明 dashboard(对象或函数,函数每次现算)
|
|
766
|
+
* 2. 插件运行中用 ctx.setDashboard() 推的最后一份
|
|
767
|
+
* 3. 外部插件发 {"t":"dashboard"} 协议消息推的
|
|
768
|
+
*
|
|
769
|
+
* 1 秒内重复拉取直接返回上次结果——面板在轮询,不能让插件函数被打爆。
|
|
770
|
+
*/
|
|
771
|
+
async dashboardOf(id) {
|
|
772
|
+
const item = this.loaded.get(id);
|
|
773
|
+
if (!item) return null;
|
|
774
|
+
|
|
775
|
+
const hit = this.dashCache.get(id);
|
|
776
|
+
if (hit && Date.now() - hit.at < 1000) return hit.data;
|
|
777
|
+
|
|
778
|
+
let raw = null;
|
|
779
|
+
try {
|
|
780
|
+
const d = item.meta?.dashboard;
|
|
781
|
+
raw = typeof d === 'function' ? await d(item.ctx) : d ?? null;
|
|
782
|
+
} catch (e) {
|
|
783
|
+
log.warn(`插件 ${id} 的 dashboard() 出错: ${e.message}`);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// 回退要看「洗完之后有没有内容」,不能看 raw 是否 truthy:
|
|
787
|
+
// 插件写了 dashboard: {} 或 dashboard() 返回 null 时,raw 仍是真值,
|
|
788
|
+
// 但 normalize 出来是 null,此时该继续用 ctx.setDashboard 推的那份。
|
|
789
|
+
let data = normalizeDashboard(raw);
|
|
790
|
+
if (!data) {
|
|
791
|
+
data = normalizeDashboard(item.ctx?.getDashboard?.() ?? item.host?.dashboard ?? null);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
this.dashCache.set(id, { at: Date.now(), data });
|
|
795
|
+
return data;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/** 一次拿全部已加载插件的运行数据,面板一次请求就够 */
|
|
799
|
+
async dashboards() {
|
|
800
|
+
const out = {};
|
|
801
|
+
for (const id of this.loaded.keys()) {
|
|
802
|
+
const d = await this.dashboardOf(id);
|
|
803
|
+
if (d) out[id] = d;
|
|
804
|
+
}
|
|
805
|
+
return out;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* 本地已装插件的版本号 { id: version },给插件市场比对新版用。
|
|
810
|
+
*
|
|
811
|
+
* **以磁盘为准**,不是内存里的 meta:用户可能手动换过 plugins/ 下的文件
|
|
812
|
+
* (覆盖安装、从别处拷贝),这时内存里的 version 是旧的,按它判断会漏报。
|
|
813
|
+
* 只有磁盘读不到(文件损坏、语法错误)才回退到内存 meta。
|
|
814
|
+
*/
|
|
815
|
+
async localVersions() {
|
|
816
|
+
const out = {};
|
|
817
|
+
let dirs = [];
|
|
818
|
+
try {
|
|
819
|
+
dirs = await fs.readdir(this.dir);
|
|
820
|
+
} catch {
|
|
821
|
+
dirs = [];
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
for (const name of dirs) {
|
|
825
|
+
const dir = path.join(this.dir, name);
|
|
826
|
+
const stat = await fs.stat(dir).catch(() => null);
|
|
827
|
+
if (!stat?.isDirectory()) continue;
|
|
828
|
+
const v = await readPluginVersion(dir);
|
|
829
|
+
if (v) out[name] = v;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// 磁盘读不到的,用内存里的 meta 兜底
|
|
833
|
+
for (const [id, item] of this.loaded) {
|
|
834
|
+
if (!out[id] && item.meta?.version) out[id] = String(item.meta.version);
|
|
835
|
+
}
|
|
836
|
+
return out;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/** 改插件配置(面板用)。改完插件立刻能读到新值 */
|
|
840
|
+
async setConfig(id, patch) {
|
|
841
|
+
const item = this.loaded.get(id);
|
|
842
|
+
if (!item?.configStore) throw new Error('插件没加载,改不了配置');
|
|
843
|
+
const next = await item.configStore.set(patch);
|
|
844
|
+
// ctx.config 和 configStore.data 是同一个对象时不用动,保险起见重新指一次
|
|
845
|
+
if (item.ctx && next) item.ctx.config = next;
|
|
846
|
+
return next ?? (await item.configStore.load());
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* 恢复插件的出厂配置。
|
|
851
|
+
*
|
|
852
|
+
* 删文件重新生成,连插件升级后遗留的旧键一起清掉。
|
|
853
|
+
* 面板调用前必须让用户确认——API Key 这类值会被一起清空。
|
|
854
|
+
*/
|
|
855
|
+
async resetConfig(id) {
|
|
856
|
+
const item = this.loaded.get(id);
|
|
857
|
+
if (!item?.configStore) throw new Error('插件没加载,改不了配置');
|
|
858
|
+
const next = await item.configStore.reset();
|
|
859
|
+
// 插件读的是 ctx.config,不重新指的话它还在用旧值,得重启才生效
|
|
860
|
+
if (item.ctx) item.ctx.config = next;
|
|
861
|
+
log.info(`插件 ${id} 的配置已恢复默认`);
|
|
862
|
+
return next;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/** 改一条插件存档(面板用) */
|
|
866
|
+
async setData(id, key, value) {
|
|
867
|
+
const st = this.loaded.get(id)?.ctx?.storage;
|
|
868
|
+
if (!st) throw new Error('插件没加载,改不了数据');
|
|
869
|
+
if (!key) throw new Error('缺少数据键');
|
|
870
|
+
await st.set(key, value);
|
|
871
|
+
await st.flush();
|
|
872
|
+
return { key, value };
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* 清空某个插件的全部存档(面板用)。
|
|
877
|
+
*
|
|
878
|
+
* 只删存档,不动配置、不动插件文件——相当于「这个插件从没用过」。
|
|
879
|
+
* 面板上要二次确认,删了没法恢复。
|
|
880
|
+
*/
|
|
881
|
+
async clearData(id) {
|
|
882
|
+
const st = this.loaded.get(id)?.ctx?.storage;
|
|
883
|
+
if (!st) throw new Error('插件没加载,改不了数据');
|
|
884
|
+
const all = await st.all();
|
|
885
|
+
const keys = Object.keys(all);
|
|
886
|
+
for (const k of keys) await st.delete(k);
|
|
887
|
+
await st.flush();
|
|
888
|
+
log.warn(`插件 ${id} 的存档已清空(${keys.length} 条)`);
|
|
889
|
+
return { removed: keys.length };
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/** 删一条插件存档(面板用) */
|
|
893
|
+
async deleteData(id, key) {
|
|
894
|
+
const st = this.loaded.get(id)?.ctx?.storage;
|
|
895
|
+
if (!st) throw new Error('插件没加载,改不了数据');
|
|
896
|
+
if (!key) throw new Error('缺少数据键');
|
|
897
|
+
await st.delete(key);
|
|
898
|
+
await st.flush();
|
|
899
|
+
return { key };
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
list() {
|
|
903
|
+
// 只列「已加载」和「磁盘上确实存在」的。
|
|
904
|
+
// 不能把 state 里的历史键也算进来——插件删了之后状态文件里还留着它的开关记录,
|
|
905
|
+
// 否则面板会一直显示一个装不存在的插件,还标成「内建 · v0.0.0」。
|
|
906
|
+
//
|
|
907
|
+
// diskIds 是「装了但没启用」的那批(include 模式下常见),
|
|
908
|
+
// 也要列出来——否则用户在面板里根本看不到它,也就没法启用。
|
|
909
|
+
const ids = new Set([...this.loaded.keys(), ...this.meta.keys(), ...this.diskIds]);
|
|
910
|
+
return [...ids].map((id) => {
|
|
911
|
+
const item = this.loaded.get(id);
|
|
912
|
+
const cached = this.meta.get(id) ?? null;
|
|
913
|
+
const ext = item?.kind === 'external' ? item.host.status : null;
|
|
914
|
+
return {
|
|
915
|
+
id,
|
|
916
|
+
name: item?.meta?.name ?? cached?.name ?? id,
|
|
917
|
+
version: item?.meta?.version ?? cached?.version ?? '',
|
|
918
|
+
description: item?.meta?.description ?? cached?.description ?? '',
|
|
919
|
+
// 卡片上的一行简介优先用 shortDesc,没写就退回 description
|
|
920
|
+
shortDesc: item?.meta?.shortDesc ?? cached?.shortDesc ?? '',
|
|
921
|
+
icon: item?.meta?.icon ?? cached?.icon ?? '',
|
|
922
|
+
repo: item?.meta?.repo ?? cached?.repo ?? '',
|
|
923
|
+
supportPlatforms: item?.meta?.supportPlatforms ?? cached?.supportPlatforms ?? [],
|
|
924
|
+
frameworkVersion: item?.meta?.frameworkVersion ?? cached?.frameworkVersion ?? '',
|
|
925
|
+
enabled: this.isEnabled(id),
|
|
926
|
+
loaded: Boolean(item),
|
|
927
|
+
// 未加载时用缓存里的类型,别一律当成 internal
|
|
928
|
+
kind: item?.kind ?? cached?.kind ?? 'internal',
|
|
929
|
+
runtime: ext?.runtime ?? cached?.runtime ?? 'node',
|
|
930
|
+
processState: ext?.state ?? null,
|
|
931
|
+
pid: ext?.pid ?? null,
|
|
932
|
+
menuMissing: Boolean(item?.menuMissing),
|
|
933
|
+
error: this.errors.get(id) ?? ext?.error ?? '',
|
|
934
|
+
errorStack: this.errorStacks.get(id) ?? '',
|
|
935
|
+
};
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
}
|