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/plugin/context.js
CHANGED
|
@@ -1,976 +1,990 @@
|
|
|
1
|
-
import fs from 'node:fs/promises';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { createLogger } from '../logger.js';
|
|
4
|
-
import { scopeOf, LEVEL, NO_PERM, levelName } from '../roles.js';
|
|
5
|
-
import { md, renderMenuMd } from '../markdown.js';
|
|
6
|
-
import { normalizeDashboard } from './dashboard.js';
|
|
7
|
-
import { createToolCaller } from './tools.js';
|
|
8
|
-
import { createMailApi, sharedMail } from '../mail/service.js';
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
this.
|
|
122
|
-
|
|
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
|
-
if (meta.
|
|
164
|
-
if (meta.
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const
|
|
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
|
-
meta.
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
this.listeners.
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
* @param {
|
|
230
|
-
* @param {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
if (
|
|
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
|
-
this.
|
|
297
|
-
this.
|
|
298
|
-
this.
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
this.
|
|
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
|
-
* @param {
|
|
341
|
-
* @param {any}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
const
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
d
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
this.timer
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
this.dirty
|
|
389
|
-
|
|
390
|
-
await fs.
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
this.timer
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
this.dirty
|
|
401
|
-
|
|
402
|
-
await fs.
|
|
403
|
-
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
*
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
541
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
* @param {
|
|
725
|
-
* @param {
|
|
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
|
-
|
|
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
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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
|
-
|
|
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
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createLogger } from '../logger.js';
|
|
4
|
+
import { scopeOf, LEVEL, NO_PERM, levelName } from '../roles.js';
|
|
5
|
+
import { md, renderMenuMd } from '../markdown.js';
|
|
6
|
+
import { normalizeDashboard } from './dashboard.js';
|
|
7
|
+
import { createToolCaller } from './tools.js';
|
|
8
|
+
import { createMailApi, sharedMail } from '../mail/service.js';
|
|
9
|
+
import { wrapApiForLedger } from './api-record.js';
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 给插件的账本视图:**只有读方法**。
|
|
14
|
+
*
|
|
15
|
+
* 为什么包一层而不是直接给 `ledger` 实例:`append()` 一暴露,插件就能往账本里
|
|
16
|
+
* 塞一条「用户没说过的话」,而账本的价值恰恰在于「它是原始事实」。
|
|
17
|
+
* 包一层之后插件想存自己的东西只能走 `ctx.storage`,边界清楚。
|
|
18
|
+
*
|
|
19
|
+
* 用 `Object.freeze` 而不是私有字段:这层是防误用,不是防恶意代码
|
|
20
|
+
* (插件本来就在同进程里跑,真想绕过谁也拦不住)。所以选最轻的方式。
|
|
21
|
+
*/
|
|
22
|
+
function readOnlyLedger(ledger) {
|
|
23
|
+
return Object.freeze({
|
|
24
|
+
/** 某会话最近 N 条(正序)。`beforeSeq` 用于翻页 */
|
|
25
|
+
recent: (opts) => ledger.recent(opts),
|
|
26
|
+
/** 以某条为锚点取前后邻文,`unknown` 时间的锚点返回空 */
|
|
27
|
+
timeline: (opts) => ledger.timeline(opts),
|
|
28
|
+
/** 按事件 id 取一条完整记录(含原始 payload 之外的字段) */
|
|
29
|
+
get: (id) => ledger.get(id),
|
|
30
|
+
/** 总量、时间范围、未解析引用数、保留天数 */
|
|
31
|
+
stats: () => ledger.stats(),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 给插件的闸视图:只能动**会话级**,动不了全局和这只鸟。
|
|
37
|
+
*
|
|
38
|
+
* 为什么这样切:`setGlobal` 是「把整个进程停掉」,`setBot` 是「把这只鸟停掉」,
|
|
39
|
+
* 两个都是运维动作,出了事要从面板确认(那儿有原因、有「谁改的」)。
|
|
40
|
+
* 插件能停的只有自己正在处理的这个会话——那本来就是它职责范围内的事。
|
|
41
|
+
*
|
|
42
|
+
* `by` 记成 `plugin:<id>`,面板上一眼能看出是哪个插件停的。
|
|
43
|
+
*/
|
|
44
|
+
/**
|
|
45
|
+
* 给插件的投递账本视图:**只能标记失败,不能标记成功**。
|
|
46
|
+
*
|
|
47
|
+
* 为什么只给 `fail`:`settle(requestId, 'sent')` 一暴露,插件就能把一条
|
|
48
|
+
* 根本没发出去的消息标成「已发送」——而账本的全部价值就是「记录的事实」。
|
|
49
|
+
* 插件唯一正当的需求是「这段我不发了」,所以只给这一个动作。
|
|
50
|
+
*/
|
|
51
|
+
function readOnlyDelivery(ledger) {
|
|
52
|
+
return Object.freeze({
|
|
53
|
+
/** 标记这条没发出去(截断、被跳过),允许以后重试 */
|
|
54
|
+
fail: (requestId, code = 'plugin') =>
|
|
55
|
+
ledger.settle(String(requestId ?? ''), 'failed', { code }),
|
|
56
|
+
/** 查一条(只读) */
|
|
57
|
+
get: (requestId) => ledger.get(requestId),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 给插件的身份对照视图:**只读,而且默认就是「这只鸟」。**
|
|
63
|
+
*
|
|
64
|
+
* 为什么不给 `bind` / `unbind`:绑定是「管理员指认谁是谁」的动作,
|
|
65
|
+
* 写错一次就是别人的记忆被读进这个会话。这种事只能从面板做,
|
|
66
|
+
* 那里有记录、有「谁改的」。插件需要的只是**查**。
|
|
67
|
+
*
|
|
68
|
+
* `botId` 默认取 `paths.id`,插件不用自己传——它只会为这一只鸟服务。
|
|
69
|
+
* 想查别的机器人的记录(做迁移类工具时)可以显式传第二个参数。
|
|
70
|
+
*
|
|
71
|
+
* 返回值里 `uin` 为空串 = 没绑定。要「没绑就提示管理员」用 `requireOpenid()`,
|
|
72
|
+
* 它会抛 `IDENTITY_UNBOUND`,`err.message` 可以直接回给用户。
|
|
73
|
+
*/
|
|
74
|
+
function readOnlyIdentity(store, botId) {
|
|
75
|
+
if (!store) return null;
|
|
76
|
+
const bot = String(botId ?? '');
|
|
77
|
+
return Object.freeze({
|
|
78
|
+
/** 这个号码有没有有效绑定 */
|
|
79
|
+
has: (kind, uin) => store.has(kind, uin),
|
|
80
|
+
/** QQ号 → 这只鸟下的 openid(没绑返回空串) */
|
|
81
|
+
openidOf: (uin, forBot = bot) => store.openidOf(forBot, uin),
|
|
82
|
+
/** 群号 → 这只鸟下的 group_openid */
|
|
83
|
+
groupOpenidOf: (groupUin, forBot = bot) => store.groupOpenidOf(forBot, groupUin),
|
|
84
|
+
/** 同 `openidOf`,但没绑定就抛 `IDENTITY_UNBOUND` */
|
|
85
|
+
requireOpenid: (opts = {}) => store.requireOpenid({ botId: bot, ...opts }),
|
|
86
|
+
/** openid → QQ号(不限机器人,反查用) */
|
|
87
|
+
uinOf: (openid) => store.uinOf(openid),
|
|
88
|
+
/** group_openid → 群号 */
|
|
89
|
+
groupUinOf: (groupOpenid) => store.groupUinOf(groupOpenid),
|
|
90
|
+
/** openid → `{ uin, identityId }`,没绑返回 null */
|
|
91
|
+
resolveUser: (openid, forBot = bot) => store.resolveUser(forBot, openid),
|
|
92
|
+
resolveGroup: (groupOpenid, forBot = bot) => store.resolveGroup(forBot, groupOpenid),
|
|
93
|
+
/**
|
|
94
|
+
* 读旧记忆的备用键:号码 + 这个号码名下所有 openid。
|
|
95
|
+
* `current` 传当前已经在读的键,会被排除掉。
|
|
96
|
+
*/
|
|
97
|
+
aliasesOf: (kind, uin, current = '') => store.aliasesOf(kind, uin, current),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function readOnlyGate(gate, pluginId) {
|
|
102
|
+
const by = `plugin:${pluginId}`;
|
|
103
|
+
return Object.freeze({
|
|
104
|
+
/** 能不能对某个会话说话(含原因) */
|
|
105
|
+
check: (chatId) => gate.check(chatId),
|
|
106
|
+
/** 这只鸟的闸状态(同步,不读盘) */
|
|
107
|
+
peek: () => gate.peek(),
|
|
108
|
+
/** 静默一段时间(毫秒);`<= 0` 表示解除 */
|
|
109
|
+
silence: (chatId, ms) => gate.setSilence(chatId, ms, { by }),
|
|
110
|
+
/** 暂停 / 恢复一个会话 */
|
|
111
|
+
pauseChat: (chatId, paused) => gate.setChat(chatId, paused, { by }),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 全局注册表:所有插件注册的命令、事件监听、AI 工具都放这里。
|
|
117
|
+
* 重载插件时按 pluginId 精确清除,互不影响。
|
|
118
|
+
*/
|
|
119
|
+
export class Registry {
|
|
120
|
+
constructor() {
|
|
121
|
+
this.commands = new Map(); // name -> { pluginId, desc, usage, admin, handler }
|
|
122
|
+
this.listeners = new Map(); // eventName -> [{ pluginId, handler }]
|
|
123
|
+
// pluginId -> { id, title, desc, sections: [{ title, items: [{ text, desc, isCommand }] }] }
|
|
124
|
+
this.menus = new Map();
|
|
125
|
+
/**
|
|
126
|
+
* AI 可调用工具:name -> entry。
|
|
127
|
+
*
|
|
128
|
+
* 为什么按工具名而不是按插件索引:模型只认工具名,调用时也只给名字,
|
|
129
|
+
* 按名直查是 O(1);重名必须当场报错,否则模型会调到一个「不知道谁的」
|
|
130
|
+
* 同名工具,出问题极难排查。
|
|
131
|
+
*/
|
|
132
|
+
this.tools = new Map();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
clearPlugin(id) {
|
|
136
|
+
for (const [name, entry] of this.commands) {
|
|
137
|
+
if (entry.pluginId === id) this.commands.delete(name);
|
|
138
|
+
}
|
|
139
|
+
for (const [event, list] of this.listeners) {
|
|
140
|
+
const kept = list.filter((l) => l.pluginId !== id);
|
|
141
|
+
if (kept.length) this.listeners.set(event, kept);
|
|
142
|
+
else this.listeners.delete(event);
|
|
143
|
+
}
|
|
144
|
+
this.menus.delete(id);
|
|
145
|
+
for (const [name, entry] of this.tools) {
|
|
146
|
+
if (entry.pluginId === id) this.tools.delete(name);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 取(或建)某插件的菜单 */
|
|
151
|
+
ensureMenu(pluginId, meta = {}) {
|
|
152
|
+
if (!this.menus.has(pluginId)) {
|
|
153
|
+
this.menus.set(pluginId, {
|
|
154
|
+
id: pluginId,
|
|
155
|
+
title: meta.name ?? pluginId,
|
|
156
|
+
desc: meta.description ?? '',
|
|
157
|
+
version: meta.version ?? '',
|
|
158
|
+
sections: [],
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
const m = this.menus.get(pluginId);
|
|
162
|
+
// 元信息可能后到,补一下
|
|
163
|
+
if (meta.name) m.title = meta.name;
|
|
164
|
+
if (meta.description) m.desc = meta.description;
|
|
165
|
+
if (meta.version) m.version = meta.version;
|
|
166
|
+
return m;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** 往某插件的菜单里加一组条目 */
|
|
170
|
+
addMenuSection(pluginId, meta, title, items) {
|
|
171
|
+
const menu = this.ensureMenu(pluginId, meta);
|
|
172
|
+
const name = title || '其它';
|
|
173
|
+
let sec = menu.sections.find((x) => x.title === name);
|
|
174
|
+
if (!sec) {
|
|
175
|
+
sec = { title: name, items: [] };
|
|
176
|
+
menu.sections.push(sec);
|
|
177
|
+
}
|
|
178
|
+
for (const it of items) {
|
|
179
|
+
const text = typeof it === 'string' ? it : it.text ?? it.cmd ?? '';
|
|
180
|
+
if (!text) continue;
|
|
181
|
+
sec.items.push({
|
|
182
|
+
text,
|
|
183
|
+
desc: (typeof it === 'object' && it.desc) || '',
|
|
184
|
+
isCommand: Boolean(typeof it === 'object' && it.cmd) || false,
|
|
185
|
+
admin: Boolean(typeof it === 'object' && it.admin),
|
|
186
|
+
globalAdmin: Boolean(typeof it === 'object' && it.globalAdmin),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
return sec;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** 该插件是否提供了菜单(有命令也算) */
|
|
193
|
+
hasMenu(pluginId) {
|
|
194
|
+
const m = this.menus.get(pluginId);
|
|
195
|
+
return Boolean(m && m.sections.some((s) => s.items.length));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
addCommand(pluginId, name, meta, handler) {
|
|
199
|
+
const key = name.toLowerCase();
|
|
200
|
+
if (this.commands.has(key)) {
|
|
201
|
+
const owner = this.commands.get(key).pluginId;
|
|
202
|
+
throw new Error(`命令 "${name}" 已被插件 ${owner} 注册`);
|
|
203
|
+
}
|
|
204
|
+
this.commands.set(key, { pluginId, name: key, ...meta, handler });
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 命令自动进菜单。分组默认是「指令」,可以用 `meta.section` 指定。
|
|
208
|
+
*
|
|
209
|
+
* 内核的十几个命令全靠这个分到「基础 / 管理 / 其它」三组——
|
|
210
|
+
* 不然 `/help` 主菜单会把它们全部平铺出来,二十多行,
|
|
211
|
+
* 在手机上要滑两屏才看得完。
|
|
212
|
+
*/
|
|
213
|
+
this.addMenuSection(
|
|
214
|
+
pluginId,
|
|
215
|
+
{ name: meta.plugin },
|
|
216
|
+
meta.section ?? '指令',
|
|
217
|
+
[{ cmd: meta.pattern ?? key, desc: meta.desc ?? '', admin: meta.admin, globalAdmin: meta.globalAdmin }],
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
addListener(pluginId, event, handler) {
|
|
222
|
+
if (!this.listeners.has(event)) this.listeners.set(event, []);
|
|
223
|
+
this.listeners.get(event).push({ pluginId, handler });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* 注册一个 AI 可调用的工具。
|
|
228
|
+
*
|
|
229
|
+
* @param {string} pluginId
|
|
230
|
+
* @param {object} meta { name, description, parameters, ownerOnly, admin, timeoutMs, hidden }
|
|
231
|
+
* @param {(args:object, callCtx:object)=>any} handler
|
|
232
|
+
*/
|
|
233
|
+
addTool(pluginId, meta, handler) {
|
|
234
|
+
const name = String(meta?.name ?? '').trim();
|
|
235
|
+
if (!name) throw new Error('工具缺少 name');
|
|
236
|
+
if (this.tools.has(name)) {
|
|
237
|
+
const owner = this.tools.get(name).pluginId;
|
|
238
|
+
throw new Error(
|
|
239
|
+
owner === pluginId
|
|
240
|
+
? `工具 "${name}" 在本插件里重复注册`
|
|
241
|
+
: `工具 "${name}" 已被插件 ${owner} 注册`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
this.tools.set(name, {
|
|
245
|
+
pluginId,
|
|
246
|
+
pluginName: meta?.pluginName ?? pluginId,
|
|
247
|
+
name,
|
|
248
|
+
description: String(meta?.description ?? '').trim(),
|
|
249
|
+
parameters: meta?.parameters ?? { type: 'object', properties: {} },
|
|
250
|
+
handler,
|
|
251
|
+
ownerOnly: Boolean(meta?.ownerOnly),
|
|
252
|
+
admin: Boolean(meta?.admin),
|
|
253
|
+
timeoutMs: Number(meta?.timeoutMs) > 0 ? Number(meta.timeoutMs) : 0,
|
|
254
|
+
hidden: Boolean(meta?.hidden),
|
|
255
|
+
});
|
|
256
|
+
return this.tools.get(name);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
removeTool(pluginId, name) {
|
|
260
|
+
const e = this.tools.get(name);
|
|
261
|
+
if (e && e.pluginId === pluginId) this.tools.delete(name);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
getTool(name) {
|
|
265
|
+
return this.tools.get(String(name ?? '').trim()) ?? null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* 可对外暴露的工具描述(**不含 handler**)。
|
|
270
|
+
*
|
|
271
|
+
* 为什么不返回 entry 本体:这些描述会原样交给模型服务商,
|
|
272
|
+
* 带上函数引用既没法序列化,也等于把内部实现递出去。
|
|
273
|
+
*/
|
|
274
|
+
listTools({ includeHidden = false } = {}) {
|
|
275
|
+
const out = [];
|
|
276
|
+
for (const e of this.tools.values()) {
|
|
277
|
+
if (e.hidden && !includeHidden) continue;
|
|
278
|
+
out.push({
|
|
279
|
+
name: e.name,
|
|
280
|
+
description: e.description,
|
|
281
|
+
parameters: e.parameters,
|
|
282
|
+
pluginId: e.pluginId,
|
|
283
|
+
pluginName: e.pluginName,
|
|
284
|
+
ownerOnly: e.ownerOnly,
|
|
285
|
+
admin: e.admin,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** 每个插件一个简单的 JSON 键值存储,游戏存档之类可以直接用 */
|
|
293
|
+
export class PluginStorage {
|
|
294
|
+
/** @param {string} file 该插件在该机器人下的存档文件 */
|
|
295
|
+
constructor(file) {
|
|
296
|
+
this.file = file;
|
|
297
|
+
this.data = null;
|
|
298
|
+
this.dirty = false;
|
|
299
|
+
this.timer = null;
|
|
300
|
+
/** key -> 该 key 的更新队列尾,保证 update() 串行 */
|
|
301
|
+
this.locks = new Map();
|
|
302
|
+
this.loading = null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async #load() {
|
|
306
|
+
if (this.data) return this.data;
|
|
307
|
+
// 并发保护:多个命令同时进来时,只读一次文件。
|
|
308
|
+
// 不加这个的话,每个调用都会 await readFile 然后各自赋值 this.data,
|
|
309
|
+
// 后赋值的覆盖前面的,写到不同对象上 → 丢数据。
|
|
310
|
+
if (!this.loading) {
|
|
311
|
+
this.loading = (async () => {
|
|
312
|
+
try {
|
|
313
|
+
this.data = JSON.parse(await fs.readFile(this.file, 'utf8'));
|
|
314
|
+
} catch {
|
|
315
|
+
this.data = {};
|
|
316
|
+
}
|
|
317
|
+
})();
|
|
318
|
+
}
|
|
319
|
+
await this.loading;
|
|
320
|
+
return this.data;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async get(key, fallback = undefined) {
|
|
324
|
+
const d = await this.#load();
|
|
325
|
+
return key in d ? d[key] : fallback;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* 原子更新:对同一个 key 的「读-改-写」串行执行,避免并发覆盖。
|
|
330
|
+
*
|
|
331
|
+
* 为什么需要:直接 get → 改 → set 在并发下会丢更新。
|
|
332
|
+
* 比如玩家连发 /签到 和 /喂食,两条命令都读到同一份旧数据,
|
|
333
|
+
* 后写入的会把前一条的结果盖掉。用 update 就不会。
|
|
334
|
+
*
|
|
335
|
+
* await ctx.storage.update('user:123', (p) => {
|
|
336
|
+
* p.points += 10; // 直接改传入的对象,或返回新对象
|
|
337
|
+
* return p; // 返回 undefined 表示不写入
|
|
338
|
+
* });
|
|
339
|
+
*
|
|
340
|
+
* @param {string} key
|
|
341
|
+
* @param {(cur:any)=>any} fn 拿到当前值,返回新值;返回 undefined 则不写入
|
|
342
|
+
* @param {any} [fallback] key 不存在时传给 fn 的初始值
|
|
343
|
+
*/
|
|
344
|
+
async update(key, fn, fallback = undefined) {
|
|
345
|
+
const prev = this.locks.get(key) ?? Promise.resolve();
|
|
346
|
+
const task = prev.then(async () => {
|
|
347
|
+
const d = await this.#load();
|
|
348
|
+
const cur = key in d ? d[key] : fallback;
|
|
349
|
+
const val = await fn(cur);
|
|
350
|
+
// undefined / null 都表示「这次不写入」,保持原值
|
|
351
|
+
if (val !== undefined && val !== null) {
|
|
352
|
+
d[key] = val;
|
|
353
|
+
this.#scheduleSave();
|
|
354
|
+
}
|
|
355
|
+
return d[key];
|
|
356
|
+
});
|
|
357
|
+
// 排队用的一条链:前一个失败也不能卡住后面的,所以吞掉错误
|
|
358
|
+
const tail = task.catch(() => {});
|
|
359
|
+
this.locks.set(key, tail);
|
|
360
|
+
// 队列跑完就清掉,避免 key 多了内存堆积
|
|
361
|
+
tail.then(() => {
|
|
362
|
+
if (this.locks.get(key) === tail) this.locks.delete(key);
|
|
363
|
+
});
|
|
364
|
+
return task;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async set(key, value) {
|
|
368
|
+
const d = await this.#load();
|
|
369
|
+
d[key] = value;
|
|
370
|
+
this.#scheduleSave();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async delete(key) {
|
|
374
|
+
const d = await this.#load();
|
|
375
|
+
delete d[key];
|
|
376
|
+
this.#scheduleSave();
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async all() {
|
|
380
|
+
return { ...(await this.#load()) };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
#scheduleSave() {
|
|
384
|
+
this.dirty = true;
|
|
385
|
+
if (this.timer) return;
|
|
386
|
+
this.timer = setTimeout(async () => {
|
|
387
|
+
this.timer = null;
|
|
388
|
+
if (!this.dirty) return;
|
|
389
|
+
this.dirty = false;
|
|
390
|
+
await fs.mkdir(path.dirname(this.file), { recursive: true });
|
|
391
|
+
await fs.writeFile(this.file, JSON.stringify(this.data, null, 2), 'utf8');
|
|
392
|
+
}, 300);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async flush() {
|
|
396
|
+
if (this.timer) {
|
|
397
|
+
clearTimeout(this.timer);
|
|
398
|
+
this.timer = null;
|
|
399
|
+
}
|
|
400
|
+
if (!this.dirty) return;
|
|
401
|
+
this.dirty = false;
|
|
402
|
+
await fs.mkdir(path.dirname(this.file), { recursive: true });
|
|
403
|
+
await fs.writeFile(this.file, JSON.stringify(this.data, null, 2), 'utf8');
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* 插件上下文:插件能用的全部能力都在这里,不暴露内部实现。
|
|
409
|
+
*/
|
|
410
|
+
export function createContext({ meta, paths, registry, api, cfg, config, configStore, groups, roles, levels, outbound, session, pipeline, reach, ledger, gate, delivery, identity, isOwner, pages = null, routes = null, state = null }) {
|
|
411
|
+
const id = meta.id;
|
|
412
|
+
// 日志 tag 带机器人 id,多机器人下同名插件的日志不会串
|
|
413
|
+
const logger = createLogger(`plugin:${paths?.id ?? '-'}:${id}`);
|
|
414
|
+
const disposers = [];
|
|
415
|
+
|
|
416
|
+
// 先占位,保证即使插件没注册任何命令,菜单里也有它的条目
|
|
417
|
+
registry.ensureMenu(id, meta);
|
|
418
|
+
|
|
419
|
+
// 面板「运行数据」:插件用 ctx.setDashboard() 推的最后一份
|
|
420
|
+
let dashboardCache = null;
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* 工具调用器。`can` 依赖 ctx 自己(要按会话算权限等级),所以这里传的是
|
|
424
|
+
* 延迟求值的闭包——箭头函数体在真正调用时才碰 ctx,那时它早就建好了。
|
|
425
|
+
*/
|
|
426
|
+
const callTool = createToolCaller({
|
|
427
|
+
registry,
|
|
428
|
+
isOwner,
|
|
429
|
+
can: (m, need) => ctx.can(m, need),
|
|
430
|
+
scopeOf,
|
|
431
|
+
log: (m) => logger.debug(m),
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
const ctx = {
|
|
435
|
+
id,
|
|
436
|
+
name: meta.name ?? id,
|
|
437
|
+
version: meta.version ?? '0.0.0',
|
|
438
|
+
// 已与默认值合并的用户配置,来自 config/bots/<机器人id>/plugins/<id>.json
|
|
439
|
+
config: config ?? meta.config ?? {},
|
|
440
|
+
/**
|
|
441
|
+
* 插件自己的内存状态,**跨热重载保留**。
|
|
442
|
+
*
|
|
443
|
+
* 模块顶层变量在重载时会重建(ESM 的行为,不是 bug),
|
|
444
|
+
* 想留的东西挂到 ctx.state 上就行;要真正持久化还是用 ctx.storage。
|
|
445
|
+
*
|
|
446
|
+
* ctx.state.cache ??= {};
|
|
447
|
+
*/
|
|
448
|
+
state: state ?? {},
|
|
449
|
+
/**
|
|
450
|
+
* 原始接口。**发出去会补记一笔账**(见 `plugin/api-record.js`)——
|
|
451
|
+
* 以前插件直连这里,出站账本里看不到这些消息,「谁」列也是空的。
|
|
452
|
+
*
|
|
453
|
+
* 只是包了一层,不改原对象:`seqMap` / 私有方法 / `instanceof` 都照旧。
|
|
454
|
+
*/
|
|
455
|
+
api: wrapApiForLedger(api, { outbound, plugin: id, log: logger }),
|
|
456
|
+
/**
|
|
457
|
+
* 邮件通知(框架接口):`await ctx.mail.send(收件人, 内容)`。
|
|
458
|
+
* 收件人可以是完整邮箱地址(任意服务商),也可以是 QQ 号(自动补 @qq.com);
|
|
459
|
+
* 没配 SMTP 时 `ctx.mail.configured` 为 false,send() 会抛中文错误。
|
|
460
|
+
* 详见 docs/插件开发规范.md。
|
|
461
|
+
*/
|
|
462
|
+
mail: createMailApi(sharedMail),
|
|
463
|
+
logger,
|
|
464
|
+
/**
|
|
465
|
+
* 可达性:`ctx.reach.canC2C(openid)` / `ctx.reach.canGroupPush(groupOpenid)`。
|
|
466
|
+
*
|
|
467
|
+
* 返回 `{ verdict: 'yes'|'no'|'unknown', reason? }`。
|
|
468
|
+
* **`unknown` 不能当 `no` 用**——未知是「不知道」,不是「不行」。
|
|
469
|
+
*/
|
|
470
|
+
reach,
|
|
471
|
+
/**
|
|
472
|
+
* 事件账本(**只读**):`ctx.ledger.recent({chatId})` / `.timeline({chatId, anchorSeq})` / `.get(id)`。
|
|
473
|
+
*
|
|
474
|
+
* 官方没有历史消息接口,这是唯一能翻到「刚才群里说了什么」的地方。
|
|
475
|
+
* 只给读,不给写:账本记的是「发生过的事实」,插件往里写就等于伪造历史。
|
|
476
|
+
* 插件要存自己的东西用 `ctx.storage`。
|
|
477
|
+
*
|
|
478
|
+
* 注意保留期(默认 7 天),超出范围查不到是正常的,不是 bug。
|
|
479
|
+
*/
|
|
480
|
+
ledger: ledger ? readOnlyLedger(ledger) : null,
|
|
481
|
+
/**
|
|
482
|
+
* 身份对照(**只读**):QQ号 / 群号 ↔ openid。
|
|
483
|
+
*
|
|
484
|
+
* 存在的理由:官方只给 openid,而 openid **换机器人就全变**;
|
|
485
|
+
* 号码不会变。管理员在面板绑一次之后,插件就能用号码代替 openid:
|
|
486
|
+
*
|
|
487
|
+
* const openid = ctx.identity.openidOf('10001'); // 没绑是空串
|
|
488
|
+
* ctx.identity.requireOpenid({ uin: '10001' }); // 没绑抛 IDENTITY_UNBOUND
|
|
489
|
+
* ctx.identity.aliasesOf('group', '763366431', chatId); // 读旧记忆的备用键
|
|
490
|
+
*
|
|
491
|
+
* 没有 `bind` / `unbind`:绑定只能从面板做,插件写错一次就是串场。
|
|
492
|
+
* `identity` 为 null 表示宿主没启用(老版本),调用前判断一下。
|
|
493
|
+
*/
|
|
494
|
+
identity: readOnlyIdentity(identity, paths?.id),
|
|
495
|
+
/**
|
|
496
|
+
* 暂停闸:`ctx.gate.silence(chatId, ms)` / `.pauseChat(chatId, true)` / `.check(chatId)`。
|
|
497
|
+
*
|
|
498
|
+
* **不给 `setGlobal` / `setBot`**:那是「把整个进程停掉」和「把这只鸟停掉」,
|
|
499
|
+
* 属于运维动作,只能从面板做。插件能停的只有自己正在处理的这个会话。
|
|
500
|
+
*
|
|
501
|
+
* 静默是给「她自己决定安静一会儿」用的(`<<silence:2h>>`),
|
|
502
|
+
* 所以一定要能带时长,`ms <= 0` 表示解除。
|
|
503
|
+
*/
|
|
504
|
+
gate: gate ? readOnlyGate(gate, id) : null,
|
|
505
|
+
/** 投递账本(阶段 5):只能 `fail`,见 readOnlyDelivery */
|
|
506
|
+
delivery: delivery ? readOnlyDelivery(delivery) : null,
|
|
507
|
+
storage: new PluginStorage(paths.pluginData(id)),
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* 插件自己的文件目录:data/bots/<机器人>/plugins/<插件id>/
|
|
511
|
+
*
|
|
512
|
+
* 存档走 storage(一个 JSON),但图片缓存、生成的图片这类**文件**
|
|
513
|
+
* 需要一个真实目录。以前插件只能自己猜 cwd,装到别的机器人下就串了。
|
|
514
|
+
*/
|
|
515
|
+
dataDir: paths.pluginDataDirFor(id),
|
|
516
|
+
/**
|
|
517
|
+
* Markdown 与消息内交互标签的构造工具。
|
|
518
|
+
* ctx.md.cmdInput('/签到', '签到') → 点了把 /签到 插进输入框(群聊可用)
|
|
519
|
+
* ctx.md.cmdEnter('/签到') → 点了直接发(群聊不支持)
|
|
520
|
+
* ctx.md.esc(用户输入) → 转义,防止破坏排版
|
|
521
|
+
* 配合 msg.replyMarkdown({content}) 或 msg.replyMenu(title, groups) 使用。
|
|
522
|
+
*/
|
|
523
|
+
md,
|
|
524
|
+
prefix: cfg.commandPrefix || '/',
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* 往面板的「运行数据」推一份指标,面板每几秒拉一次就会更新。
|
|
528
|
+
* ctx.setDashboard({
|
|
529
|
+
* metrics: [
|
|
530
|
+
* { label: '已回复', value: counters.reply, unit: '条' },
|
|
531
|
+
* { label: '平均耗时', value: 320, unit: 'ms' },
|
|
532
|
+
* ],
|
|
533
|
+
* note: '模型:xxx',
|
|
534
|
+
* });
|
|
535
|
+
* 想每次现算,也可以在插件导出里写 dashboard() 函数,二选一。
|
|
536
|
+
*/
|
|
537
|
+
setDashboard(data) {
|
|
538
|
+
dashboardCache = normalizeDashboard(data);
|
|
539
|
+
return dashboardCache;
|
|
540
|
+
},
|
|
541
|
+
|
|
542
|
+
/** 框架内部用:取插件推的最后一份运行数据 */
|
|
543
|
+
getDashboard() {
|
|
544
|
+
return dashboardCache;
|
|
545
|
+
},
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* 往面板注册一个自己的页面。
|
|
549
|
+
*
|
|
550
|
+
* ctx.registerPage({
|
|
551
|
+
* key: 'board', // 只在本插件内唯一
|
|
552
|
+
* label: '战绩榜', // 面板标签上显示的名字
|
|
553
|
+
* html: '<h1>...</h1>', // 一段完整或片段 HTML 都行
|
|
554
|
+
* });
|
|
555
|
+
*
|
|
556
|
+
* 页面在 iframe 里渲染:CSS 不会漏到面板上,也不会反过来影响面板。
|
|
557
|
+
* 插件卸载或重载时页面自动消失,不用自己清理。
|
|
558
|
+
*
|
|
559
|
+
* @returns {string|null} 完整 key(`插件id:key`),环境不支持时返回 null
|
|
560
|
+
*/
|
|
561
|
+
registerPage(page) {
|
|
562
|
+
if (!pages) {
|
|
563
|
+
logger.warn('当前环境不支持注册面板页面');
|
|
564
|
+
return null;
|
|
565
|
+
}
|
|
566
|
+
return pages.register(id, page);
|
|
567
|
+
},
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* 注册一个面板接口(REST),给自己注册的页面取数据用。
|
|
571
|
+
*
|
|
572
|
+
* ctx.registerRoute('GET', '/players', async ({ query, json }) => {
|
|
573
|
+
* return json(200, await repo.list({ q: query.q }));
|
|
574
|
+
* });
|
|
575
|
+
* ctx.registerRoute('PATCH', '/players/:openid', async ({ params, body }) => {
|
|
576
|
+
* await repo.update(params.openid, body);
|
|
577
|
+
* return { ok: true }; // 直接返回对象 = 200 JSON
|
|
578
|
+
* });
|
|
579
|
+
*
|
|
580
|
+
* 实际地址是 `/api/plugins/<插件id>/r<你写的路径>`,
|
|
581
|
+
* 鉴权、CSRF、同源校验全部复用面板那一套,插件拿不到裸 req/res。
|
|
582
|
+
*
|
|
583
|
+
* 处理函数拿到的是纯数据,返回三种东西:
|
|
584
|
+
* - 普通对象 → 200 + JSON
|
|
585
|
+
* - `json(code, data)` 的返回值 → 用你指定的状态码
|
|
586
|
+
* - undefined / null → 204 空响应
|
|
587
|
+
*
|
|
588
|
+
* 支持的方法:GET / POST / PUT / PATCH / DELETE,路径可含 `:param`。
|
|
589
|
+
*
|
|
590
|
+
* @returns {string|null} `METHOD /path`,环境不支持时返回 null
|
|
591
|
+
*/
|
|
592
|
+
registerRoute(method, pattern, handler) {
|
|
593
|
+
if (!routes) {
|
|
594
|
+
logger.warn(`当前环境不支持插件路由,已忽略 ${method} ${pattern}`);
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
return routes.add(id, method, pattern, handler);
|
|
598
|
+
},
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* 改自己的配置并立刻落盘(只改传入的键,其余保留)。
|
|
602
|
+
* await ctx.setConfig({ welcome: '欢迎' });
|
|
603
|
+
*/
|
|
604
|
+
async setConfig(patch) {
|
|
605
|
+
if (!configStore) throw new Error('当前环境不支持写插件配置');
|
|
606
|
+
const next = await configStore.set(patch);
|
|
607
|
+
// 原地更新,插件里已经拿到的 ctx.config 引用也跟着变
|
|
608
|
+
if (this.config && typeof this.config === 'object') {
|
|
609
|
+
for (const k of Object.keys(this.config)) delete this.config[k];
|
|
610
|
+
Object.assign(this.config, next);
|
|
611
|
+
return this.config;
|
|
612
|
+
}
|
|
613
|
+
this.config = next;
|
|
614
|
+
return next;
|
|
615
|
+
},
|
|
616
|
+
|
|
617
|
+
/* ---------------- 权限等级 ---------------- */
|
|
618
|
+
|
|
619
|
+
/** 内核保留等级(只读,插件不能改语义) */
|
|
620
|
+
CORE_LEVELS: LEVEL,
|
|
621
|
+
|
|
622
|
+
/** 本插件分到的号段起点,例如 10 / 20 / 30 */
|
|
623
|
+
levelBase: levels?.base ?? 10,
|
|
624
|
+
|
|
625
|
+
/** 本插件可用号段 { base, end } */
|
|
626
|
+
levelRange: levels ? { base: levels.base, end: levels.end } : { base: 10, end: 19 },
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* 取本插件号段内的等级值。
|
|
630
|
+
* ctx.level(0) → 号段最高权限(数字最小)
|
|
631
|
+
* ctx.level(3) → 号段内第 4 档
|
|
632
|
+
*/
|
|
633
|
+
level(offset = 0) {
|
|
634
|
+
const base = levels?.base ?? 10;
|
|
635
|
+
const end = levels?.end ?? 19;
|
|
636
|
+
return Math.min(base + Math.max(0, offset), end);
|
|
637
|
+
},
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* 一次性定义本插件的等级,并自动写进菜单,方便用户知道怎么授权。
|
|
641
|
+
* const L = ctx.defineLevels({ 管理员: 0, 成员: 2 });
|
|
642
|
+
* → { 管理员: 10, 成员: 12 }
|
|
643
|
+
*/
|
|
644
|
+
defineLevels(map) {
|
|
645
|
+
const out = {};
|
|
646
|
+
for (const [name, offset] of Object.entries(map ?? {})) {
|
|
647
|
+
out[name] = this.level(offset);
|
|
648
|
+
}
|
|
649
|
+
registry.addMenuSection(
|
|
650
|
+
id,
|
|
651
|
+
meta,
|
|
652
|
+
'权限等级',
|
|
653
|
+
Object.entries(out).map(([name, lv]) => ({ text: `${lv}`, desc: name })),
|
|
654
|
+
);
|
|
655
|
+
return out;
|
|
656
|
+
},
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* 注册命令。命令名取第一个词,后面的是用法提示,仅用于帮助展示与必填校验。
|
|
660
|
+
* ctx.command('echo <内容...>', { desc: '回声' }, async (msg, { args }) => { ... })
|
|
661
|
+
*/
|
|
662
|
+
command(name, meta2, handler) {
|
|
663
|
+
if (typeof meta2 === 'function') {
|
|
664
|
+
handler = meta2;
|
|
665
|
+
meta2 = {};
|
|
666
|
+
}
|
|
667
|
+
registry.addCommand(id, String(name).trim().split(/\s+/)[0], {
|
|
668
|
+
desc: meta2?.desc ?? '',
|
|
669
|
+
usage: meta2?.usage ?? `${cfg.commandPrefix}${name}`,
|
|
670
|
+
pattern: String(name).trim(),
|
|
671
|
+
admin: Boolean(meta2?.admin),
|
|
672
|
+
plugin: meta.name ?? id,
|
|
673
|
+
// 合并多插件回复时要靠 id 分组,name 只用来显示
|
|
674
|
+
pluginId: id,
|
|
675
|
+
}, handler);
|
|
676
|
+
},
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* 追加菜单分组。命令会自动进「指令」组,这里用来补充非命令玩法,
|
|
680
|
+
* 比如游戏里的「攻击」「背包」,或者关键词触发。
|
|
681
|
+
*
|
|
682
|
+
* ctx.menu('玩法', [
|
|
683
|
+
* { text: '攻击', desc: '对当前目标发起攻击' },
|
|
684
|
+
* { text: '直接说「掷骰子」', desc: '不用加前缀也能触发' },
|
|
685
|
+
* ])
|
|
686
|
+
*/
|
|
687
|
+
menu(title, items) {
|
|
688
|
+
registry.addMenuSection(id, meta, title, items ?? []);
|
|
689
|
+
},
|
|
690
|
+
|
|
691
|
+
/** 监听原始事件(GROUP_AT_MESSAGE_CREATE 等),或 'message' 统一入口 */
|
|
692
|
+
on(event, handler) {
|
|
693
|
+
registry.addListener(id, event, handler);
|
|
694
|
+
},
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* 注册一个 **AI 可调用**的工具(跨插件)。
|
|
698
|
+
*
|
|
699
|
+
* 命令是给人打的,工具是给模型调的:模型只看到名字、说明和参数 schema,
|
|
700
|
+
* 自己决定什么时候调。别的插件(尤其 AI 插件)用 `ctx.tools()` 拿到清单、
|
|
701
|
+
* `ctx.callTool()` 执行,于是「AI 能查斗罗玩家数据」这种事不用 AI 插件
|
|
702
|
+
* 认识斗罗插件——两边只认这张表。
|
|
703
|
+
*
|
|
704
|
+
* ctx.registerTool({
|
|
705
|
+
* name: 'douluo_status',
|
|
706
|
+
* description: '查一个斗罗玩家的等级、魂环、金币',
|
|
707
|
+
* parameters: {
|
|
708
|
+
* type: 'object',
|
|
709
|
+
* properties: { uin: { type: 'string', description: '玩家 QQ 号' } },
|
|
710
|
+
* required: ['uin'],
|
|
711
|
+
* },
|
|
712
|
+
* async execute({ uin }, call) {
|
|
713
|
+
* const p = await load(uin);
|
|
714
|
+
* return `${uin}:${p.level} 级,${p.gold} 金币`;
|
|
715
|
+
* },
|
|
716
|
+
* });
|
|
717
|
+
*
|
|
718
|
+
* `call` 里有 `{ msg, chatId, userId, userName, kind, scope, callTool }`,
|
|
719
|
+
* 需要按人区分的工具从 `call.userId` 取,别自己去翻全局。
|
|
720
|
+
*
|
|
721
|
+
* 名字**全局唯一**(跨插件也不能重):模型只认名字,重名会调到一个
|
|
722
|
+
* 「不知道谁的」同名工具,出了问题极难查。
|
|
723
|
+
*
|
|
724
|
+
* @param {object} tool
|
|
725
|
+
* @param {string} tool.name 全局唯一,建议带插件前缀
|
|
726
|
+
* @param {string} [tool.description] 给模型看的说明,写「做什么」别写「怎么实现」
|
|
727
|
+
* @param {object} [tool.parameters] JSON Schema(OpenAI function calling 同款)
|
|
728
|
+
* @param {boolean} [tool.ownerOnly] 只有机器人主人能调
|
|
729
|
+
* @param {boolean} [tool.admin] 需要当前会话管理员
|
|
730
|
+
* @param {number} [tool.timeoutMs] 超时,默认 15s
|
|
731
|
+
* @param {boolean} [tool.hidden] 不出现在 ctx.tools() 里,只能显式调用
|
|
732
|
+
* @param {(args:object, call:object)=>any} tool.execute
|
|
733
|
+
* @returns {() => void} 注销函数(插件卸载时会自动调用,不用手动收)
|
|
734
|
+
*/
|
|
735
|
+
registerTool(tool) {
|
|
736
|
+
if (!tool || typeof tool.execute !== 'function') {
|
|
737
|
+
throw new Error('registerTool 需要 { name, execute }');
|
|
738
|
+
}
|
|
739
|
+
registry.addTool(
|
|
740
|
+
id,
|
|
741
|
+
{ ...tool, pluginName: meta.name ?? id },
|
|
742
|
+
// 包一层:把 execute 抛出的同步异常也变成 rejection,
|
|
743
|
+
// 否则 callTool 里的 try/finally 接不住同步 throw
|
|
744
|
+
(args, call) => Promise.resolve().then(() => tool.execute(args, call)),
|
|
745
|
+
);
|
|
746
|
+
const off = () => registry.removeTool(id, tool.name);
|
|
747
|
+
disposers.push(off);
|
|
748
|
+
return off;
|
|
749
|
+
},
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* 列出所有插件暴露的 AI 工具(**不含执行函数**,可以直接序列化给模型)。
|
|
753
|
+
*
|
|
754
|
+
* 为什么不带 handler:这份清单会原样进请求体发给服务商,
|
|
755
|
+
* 带上函数引用既没法 JSON 化,也等于把内部实现递出去。
|
|
756
|
+
*
|
|
757
|
+
* @param {{includeHidden?:boolean}} [opts]
|
|
758
|
+
*/
|
|
759
|
+
tools(opts) {
|
|
760
|
+
return registry.listTools(opts);
|
|
761
|
+
},
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* 执行一个工具。权限、超时、重入在这里统一兜住,调用方只管处理结果。
|
|
765
|
+
*
|
|
766
|
+
* const list = ctx.tools();
|
|
767
|
+
* const out = await ctx.callTool('douluo_status', { uin }, { msg });
|
|
768
|
+
*
|
|
769
|
+
* 失败一律 **throw**(不是返回空):调用方多半是 AI 插件,它要把
|
|
770
|
+
* 「工具报错了」这句话回填给模型,让模型自己圆回来。
|
|
771
|
+
*/
|
|
772
|
+
callTool(name, args, opts) {
|
|
773
|
+
return callTool(name, args, opts);
|
|
774
|
+
},
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* 定时任务。返回停止函数;插件卸载时会自动清理,不会残留。
|
|
778
|
+
* ctx.schedule('daily-reset', 60_000, () => { ... })
|
|
779
|
+
*/
|
|
780
|
+
schedule(name, everyMs, fn) {
|
|
781
|
+
if (typeof name === 'function') {
|
|
782
|
+
fn = name;
|
|
783
|
+
everyMs = 60_000;
|
|
784
|
+
name = 'task';
|
|
785
|
+
}
|
|
786
|
+
const timer = setInterval(() => {
|
|
787
|
+
Promise.resolve()
|
|
788
|
+
.then(fn)
|
|
789
|
+
.catch((e) => logger.error(`定时任务 ${name} 出错:`, e.message));
|
|
790
|
+
}, Math.max(1000, everyMs));
|
|
791
|
+
timer.unref?.();
|
|
792
|
+
disposers.push(() => clearInterval(timer));
|
|
793
|
+
return () => clearInterval(timer);
|
|
794
|
+
},
|
|
795
|
+
|
|
796
|
+
/** 注册卸载时要执行的回调(关连接、清定时器等) */
|
|
797
|
+
onTeardown(fn) {
|
|
798
|
+
if (typeof fn === 'function') disposers.push(fn);
|
|
799
|
+
},
|
|
800
|
+
|
|
801
|
+
/* ---------------- 多轮对话与主动发送 ---------------- */
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* 问一句、等回答。文字游戏的多轮交互全靠它。
|
|
805
|
+
*
|
|
806
|
+
* const ans = await ctx.prompt(msg, '用哪个技能?\n1 蓝银缠绕 2 昊天锤');
|
|
807
|
+
* if (!ans) return; // 超时,prompt 已经提示过了
|
|
808
|
+
* if (ans.content === '1') { ... }
|
|
809
|
+
*
|
|
810
|
+
* @param {object} msg 触发本次交互的消息
|
|
811
|
+
* @param {string} [text] 要发给用户的提示,省略则只等待
|
|
812
|
+
* @param {object} [opts]
|
|
813
|
+
* @param {'user'|'chat'} [opts.from] 只认这个人,还是这个会话里谁都算
|
|
814
|
+
* @param {number} [opts.timeoutMs]
|
|
815
|
+
* @param {string|false} [opts.onTimeout] 超时提示语,false 表示不提示
|
|
816
|
+
* @returns {Promise<object|null>} 用户的下一条消息,超时为 null
|
|
817
|
+
*/
|
|
818
|
+
async prompt(msg, text, opts = {}) {
|
|
819
|
+
if (!session) throw new Error('当前环境不支持多轮对话');
|
|
820
|
+
if (text) await msg.reply(text);
|
|
821
|
+
// 提示语是攒着的,必须立刻发出去,否则用户看不到问题、只能等到超时
|
|
822
|
+
await msg.flushReplies?.();
|
|
823
|
+
|
|
824
|
+
const timeoutMs =
|
|
825
|
+
opts.timeoutMs ?? Math.max(1000, (cfg.session?.promptTimeoutSec ?? 120) * 1000);
|
|
826
|
+
const answer = await session.wait({
|
|
827
|
+
chatId: msg.chatId,
|
|
828
|
+
userId: msg.userId,
|
|
829
|
+
from: opts.from ?? 'user',
|
|
830
|
+
timeoutMs,
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
if (!answer && opts.onTimeout !== false) {
|
|
834
|
+
const hint =
|
|
835
|
+
typeof opts.onTimeout === 'string' ? opts.onTimeout : '等太久没收到回复,这次先取消了。';
|
|
836
|
+
// 用户可能已经离开,发不出去也无所谓
|
|
837
|
+
await msg.reply(hint).catch(() => {});
|
|
838
|
+
}
|
|
839
|
+
return answer;
|
|
840
|
+
},
|
|
841
|
+
|
|
842
|
+
/** 只等待,不主动发提示(用于「上一条已经问过了」的场景) */
|
|
843
|
+
wait(msg, opts = {}) {
|
|
844
|
+
if (!session) throw new Error('当前环境不支持多轮对话');
|
|
845
|
+
return session.wait({
|
|
846
|
+
chatId: msg.chatId,
|
|
847
|
+
userId: msg.userId,
|
|
848
|
+
from: opts.from ?? 'user',
|
|
849
|
+
timeoutMs: opts.timeoutMs ?? Math.max(1000, (cfg.session?.promptTimeoutSec ?? 120) * 1000),
|
|
850
|
+
});
|
|
851
|
+
},
|
|
852
|
+
|
|
853
|
+
/** 取消某人当前的等待 */
|
|
854
|
+
cancelWait(msg) {
|
|
855
|
+
session?.cancel(msg.chatId, msg.userId);
|
|
856
|
+
},
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* 主动推送一条消息(不依赖用户触发)。
|
|
860
|
+
* 注意:受每日额度限制,额度用完会抛错,定时任务请自行 catch。
|
|
861
|
+
*
|
|
862
|
+
* await ctx.push(groupOpenid, '今天的魂兽森林刷新了')
|
|
863
|
+
*/
|
|
864
|
+
push(chatId, text, { kind = 'group', requestId = '' } = {}) {
|
|
865
|
+
if (!outbound) throw new Error('当前环境不支持主动推送');
|
|
866
|
+
return outbound.send({
|
|
867
|
+
kind,
|
|
868
|
+
target: chatId,
|
|
869
|
+
chatKey: `${kind}:${chatId}`,
|
|
870
|
+
payload: { content: String(text ?? '') },
|
|
871
|
+
/**
|
|
872
|
+
* 幂等键(阶段 5)。同一个 id 再推一次只发一条——
|
|
873
|
+
* 调用方(`qq_send`)知道「一段话」的语义边界,只有它给得出这个 id。
|
|
874
|
+
*/
|
|
875
|
+
...(requestId ? { requestId: String(requestId) } : {}),
|
|
876
|
+
/**
|
|
877
|
+
* 账本归属(v3):这条是哪个插件发的。
|
|
878
|
+
*
|
|
879
|
+
* `ctx.push` 是插件主动推送的入口,`msg.reply` 那条路走 `flushReplies`,
|
|
880
|
+
* 两处都要带——否则账本里「谁说的」会一半有一半没有。
|
|
881
|
+
*/
|
|
882
|
+
...(id ? { plugin: id } : {}),
|
|
883
|
+
});
|
|
884
|
+
},
|
|
885
|
+
|
|
886
|
+
/** 这个会话还剩多少条主动消息额度 */
|
|
887
|
+
async quotaLeft(chatId, kind = 'group') {
|
|
888
|
+
if (!outbound) return 0;
|
|
889
|
+
const used = (await outbound.quotaSnapshot())[`${kind}:${chatId}`] ?? 0;
|
|
890
|
+
return Math.max(0, outbound.dailyActive - used);
|
|
891
|
+
},
|
|
892
|
+
|
|
893
|
+
/** 该会话是否处在免@窗口内(用户刚 @ 过,接下来不用每句都 @) */
|
|
894
|
+
isSticky(chatId) {
|
|
895
|
+
return Boolean(session?.isSticky(chatId));
|
|
896
|
+
},
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* 注册一个中间件。在命令/消息到达本插件之前执行,
|
|
900
|
+
* 不调用 next() 就能把消息拦下来。
|
|
901
|
+
*
|
|
902
|
+
* ctx.use('反垃圾', async (ctx, next) => {
|
|
903
|
+
* if (ctx.msg.content.includes('广告')) return;
|
|
904
|
+
* await next();
|
|
905
|
+
* });
|
|
906
|
+
*/
|
|
907
|
+
use(name, fn) {
|
|
908
|
+
if (!pipeline) throw new Error('当前环境不支持中间件');
|
|
909
|
+
pipeline.use(`${id}:${name}`, fn, { pluginId: id });
|
|
910
|
+
},
|
|
911
|
+
|
|
912
|
+
/** 当前消息的发送者是否为机器人主人(全局) */
|
|
913
|
+
isOwner(userId) {
|
|
914
|
+
return isOwner(userId);
|
|
915
|
+
},
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* 当前消息发送者的权限等级(数字,越小越高)。
|
|
919
|
+
*
|
|
920
|
+
* 取三者的最优(数字最小):
|
|
921
|
+
* 全局 global、当前会话(group:/channel:)、本插件 feature:<id>
|
|
922
|
+
* 这样「群主」和「只被授予本插件权限的人」都能通过本插件的检查,
|
|
923
|
+
* 而无关的人拿到的还是 NO_PERM。
|
|
924
|
+
*/
|
|
925
|
+
async levelOf(msg, scope) {
|
|
926
|
+
if (!roles) return isOwner(msg?.userId) ? LEVEL.owner : NO_PERM;
|
|
927
|
+
if (scope) return roles.effective(scope, msg?.userId, { owners: cfg.owners });
|
|
928
|
+
|
|
929
|
+
// 同一条消息里插件可能反复问权限,算一次就缓存到消息对象上
|
|
930
|
+
const cacheKey = `__lvl_${id}`;
|
|
931
|
+
if (msg && msg[cacheKey] !== undefined) return msg[cacheKey];
|
|
932
|
+
|
|
933
|
+
const [chat, feature] = await Promise.all([
|
|
934
|
+
roles.effective(scopeOf(msg), msg?.userId, { owners: cfg.owners }),
|
|
935
|
+
roles.effective(`feature:${id}`, msg?.userId, { owners: cfg.owners }),
|
|
936
|
+
]);
|
|
937
|
+
const v = Math.min(chat, feature);
|
|
938
|
+
if (msg) msg[cacheKey] = v;
|
|
939
|
+
return v;
|
|
940
|
+
},
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* 当前消息发送者是否达到某等级,默认要求管理员(数字越小越高)。
|
|
944
|
+
* ctx.can(msg) → 需要 1 级管理员
|
|
945
|
+
* ctx.can(msg, 'manager') → 需要 2 级群管
|
|
946
|
+
* ctx.can(msg, L.管理员) → 需要本插件的某个等级
|
|
947
|
+
*/
|
|
948
|
+
async can(msg, need = 'admin') {
|
|
949
|
+
const want = typeof need === 'number' ? need : LEVEL[need] ?? LEVEL.admin;
|
|
950
|
+
return (await this.levelOf(msg)) <= want;
|
|
951
|
+
},
|
|
952
|
+
|
|
953
|
+
/** 当前作用域(group:xxx / channel:xxx / user:xxx / global) */
|
|
954
|
+
scopeOf(msg) {
|
|
955
|
+
return scopeOf(msg);
|
|
956
|
+
},
|
|
957
|
+
|
|
958
|
+
/** 等级数字转名字 */
|
|
959
|
+
levelName,
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* 某人是否是指定群的管理员及以上。
|
|
963
|
+
* 统一看 roles 的 group 作用域(群档案里不再存权限)。
|
|
964
|
+
*/
|
|
965
|
+
async isGroupAdmin(groupOpenid, userId) {
|
|
966
|
+
if (!roles) return false;
|
|
967
|
+
const lv = await roles.effective(`group:${groupOpenid}`, userId, { owners: cfg.owners });
|
|
968
|
+
return lv <= LEVEL.admin;
|
|
969
|
+
},
|
|
970
|
+
|
|
971
|
+
/** 读取群档案(邀请者、群主、是否启用等,不含权限) */
|
|
972
|
+
async getGroup(groupOpenid) {
|
|
973
|
+
return groups ? groups.get(groupOpenid) : null;
|
|
974
|
+
},
|
|
975
|
+
|
|
976
|
+
/** 内部:卸载时统一清理 */
|
|
977
|
+
dispose() {
|
|
978
|
+
for (const fn of disposers.splice(0)) {
|
|
979
|
+
try {
|
|
980
|
+
fn();
|
|
981
|
+
} catch (e) {
|
|
982
|
+
logger.warn('清理资源出错:', e.message);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
this.storage.flush().catch(() => {});
|
|
986
|
+
},
|
|
987
|
+
};
|
|
988
|
+
|
|
989
|
+
return ctx;
|
|
990
|
+
}
|