dsh-fish-buddy 0.8.0 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +50 -21
- package/README.md +181 -173
- package/lib/host.js +1874 -1773
- package/package.json +6 -2
package/lib/host.js
CHANGED
|
@@ -1,1773 +1,1874 @@
|
|
|
1
|
-
// 鱼搭子 · DSH 这半边(基座)
|
|
2
|
-
//
|
|
3
|
-
// 干三件事,没有界面上的花活:
|
|
4
|
-
// ① 在本机开一个口(默认 127.0.0.1:8777),收桌面挂件发来的话 → 送进她绑定的那间会话
|
|
5
|
-
// ② 她那间会话说完一轮 → 把回话递回挂件(POST 到挂件给的回调地址)
|
|
6
|
-
// ③ 给设置页提供状态:绑哪间、收了几条、最近几条日志、最后一句回话
|
|
7
|
-
//
|
|
8
|
-
// ⚠ 为什么自己开一个端口、不用 DSH 的 webServer:
|
|
9
|
-
// 挂件是独立程序,不该知道 DSH 跑在哪个端口上;固定 8777 它才好发。
|
|
10
|
-
// (设置页那几条路由仍然走 DSH 的 webServer,那是浏览器在用。)
|
|
11
|
-
|
|
12
|
-
import { spawn } from 'node:child_process'
|
|
13
|
-
import { createServer } from 'node:http'
|
|
14
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, createWriteStream, renameSync, rmSync, readdirSync, statSync, unlinkSync } from 'node:fs'
|
|
15
|
-
import { homedir } from 'node:os'
|
|
16
|
-
import { join, dirname } from 'node:path'
|
|
17
|
-
import { fileURLToPath } from 'node:url'
|
|
18
|
-
import { randomUUID } from 'node:crypto'
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
// ⭐ 0.
|
|
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
|
-
const
|
|
96
|
-
const
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
|
|
176
|
-
* ⭐ 0.0.
|
|
177
|
-
*
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if (!(间隔秒 > 0))
|
|
183
|
-
if (!(
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
if (
|
|
215
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
} catch (error) {}
|
|
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
|
-
if (
|
|
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
|
-
const
|
|
601
|
-
const 桶 =
|
|
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
|
-
const
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
return
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
const
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
const
|
|
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
|
-
if (
|
|
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
|
-
return
|
|
820
|
-
} catch (error) {
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
const
|
|
838
|
-
if (
|
|
839
|
-
|
|
840
|
-
const
|
|
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
|
-
try {
|
|
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
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
if (
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
if (
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
{
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
const
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
'
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
})
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
const
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
try {
|
|
1397
|
-
const
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
} catch (error) {
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
})
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
const
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1
|
+
// 鱼搭子 · DSH 这半边(基座)
|
|
2
|
+
//
|
|
3
|
+
// 干三件事,没有界面上的花活:
|
|
4
|
+
// ① 在本机开一个口(默认 127.0.0.1:8777),收桌面挂件发来的话 → 送进她绑定的那间会话
|
|
5
|
+
// ② 她那间会话说完一轮 → 把回话递回挂件(POST 到挂件给的回调地址)
|
|
6
|
+
// ③ 给设置页提供状态:绑哪间、收了几条、最近几条日志、最后一句回话
|
|
7
|
+
//
|
|
8
|
+
// ⚠ 为什么自己开一个端口、不用 DSH 的 webServer:
|
|
9
|
+
// 挂件是独立程序,不该知道 DSH 跑在哪个端口上;固定 8777 它才好发。
|
|
10
|
+
// (设置页那几条路由仍然走 DSH 的 webServer,那是浏览器在用。)
|
|
11
|
+
|
|
12
|
+
import { spawn } from 'node:child_process'
|
|
13
|
+
import { createServer } from 'node:http'
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, createWriteStream, renameSync, rmSync, readdirSync, statSync, unlinkSync } from 'node:fs'
|
|
15
|
+
import { homedir } from 'node:os'
|
|
16
|
+
import { join, dirname } from 'node:path'
|
|
17
|
+
import { fileURLToPath } from 'node:url'
|
|
18
|
+
import { randomUUID } from 'node:crypto'
|
|
19
|
+
import * as zlib from 'node:zlib'
|
|
20
|
+
|
|
21
|
+
export const name = 'fish-buddy'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 需要宿主提供什么。
|
|
25
|
+
*
|
|
26
|
+
* ⭐ 0.4.10:**能可有可无的一律别写成必需**。
|
|
27
|
+
* 以前 6 个全写成必需(agents / sessions / sessionTitle / webServer / timer / attachments)。
|
|
28
|
+
* Cordis 里「必需服务缺失」会让这个插件挂不起来,装在一个没有 `attachments`(或没有 `timer`)的
|
|
29
|
+
* 宿主上时,整个 profile 的组装都可能跟着失败 —— 表现就是「装完 DSH 起不来」。
|
|
30
|
+
* 现在只把真正离不开的两个写成必需,其余都是可选(代码里本来就有 `ctx.get(...)` 的兜底)。
|
|
31
|
+
*/
|
|
32
|
+
// 这个宿主(Cordis)只认**数组**形式的 inject:数组里每一项都是「必需服务」。
|
|
33
|
+
// ⚠ 写成 { required, optional } 会被当成两个叫「required」「optional」的服务名,
|
|
34
|
+
// 插件就一直 pending 挂不起来 —— 所以只能写数组,只放真正离不开的两个。
|
|
35
|
+
// 其余(sessions / sessionTitle / timer / attachments)一律不声明,用 ctx.get(...) 现取现用,
|
|
36
|
+
// 拿不到就降级(列不出会话 / 发不了图),而不是让整个插件挂掉。
|
|
37
|
+
export const inject = ['agents', 'webServer']
|
|
38
|
+
|
|
39
|
+
// ---------- 常量 ----------
|
|
40
|
+
|
|
41
|
+
const 目录 = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
42
|
+
const 配置文件 = join(目录, 'fish-buddy-config.json')
|
|
43
|
+
const 路由前缀 = '/dsh-fish-buddy'
|
|
44
|
+
// GitHub 问不到「最新 Release」时用这个兜底(版本号变了要跟着改)
|
|
45
|
+
const 下载兜底地址 = 'https://github.com/Yuuyuko-uu/dsh-fish-buddy/releases/latest/download/fish-buddy-v1.0.9.exe'
|
|
46
|
+
const 日志上限 = 60
|
|
47
|
+
|
|
48
|
+
const 默认配置 = {
|
|
49
|
+
总开关: true, // 关掉 = 挂件发什么都不往里塞(调试用)
|
|
50
|
+
端口: 8777, // 挂件往这个端口发
|
|
51
|
+
会话: '', // 绑哪一间('session-…' 或裸 id 都认)
|
|
52
|
+
回调: '', // 她的回话递到哪:'http://127.0.0.1:8778/hands/reply'
|
|
53
|
+
带上来源: false, // 塞进会话时前面加一句「(你说的)」。默认关:电话里说的话就该像本人说的
|
|
54
|
+
挂件路径: '', // 那个 exe 在哪;留空就自动找几个常见位置
|
|
55
|
+
下载地址: '', // 「去下载挂件」按钮跳到哪(填 Release 页面地址)
|
|
56
|
+
// ⭐ 0.0.7:DSH 一起来就把挂件打开(默认开着,不然每次都得手动点)
|
|
57
|
+
自动开挂件: true,
|
|
58
|
+
// 挂件回话口的端口(回调没填时拿它探活;挂件默认 8778)
|
|
59
|
+
回话端口: 8778,
|
|
60
|
+
// ⭐ 0.0.5:陪看时让她自己开口 —— 0 = 关;0.0.8 起单位是**秒**
|
|
61
|
+
// ⭐ 0.6.1:**游戏模式**里主动说一句的间隔(秒)。0 = 不说。
|
|
62
|
+
// ⚠ 只在「截图怎么送 = 攒着」时生效;「每张都说」那一档截图本来就会叫醒她,不叠这个。
|
|
63
|
+
游戏搭话秒: 180,
|
|
64
|
+
// ⭐ 0.7.9:插件自己攒的截图(陪看抽帧、开口/带图那几张)会一直堆着不清。
|
|
65
|
+
// 这两个数管自动清:留几天 / 最多几张,0 = 那一条不限(两条都 0 = 不清)。
|
|
66
|
+
// 只删插件自己这个目录里的图,别的地方不动。
|
|
67
|
+
截图保留天: 30,
|
|
68
|
+
截图最多张: 800,
|
|
69
|
+
随机搭话秒: 600, // 陪看时每多少秒找机会说一句(0 = 只看不说)
|
|
70
|
+
搭话最多秒: 0, // 随机间隔的上限(0 = 自动 = max(下限×4, 120))
|
|
71
|
+
冷却最短秒: 60, // 两条自发消息(发图 / 搭话)之间至少隔多久
|
|
72
|
+
冷却最长秒: 150, // ……最多隔多久(每次都在这两个数之间重掷)
|
|
73
|
+
安静秒: 8, // 多久没有对话就算「她没在说话」(这时截图直接发)
|
|
74
|
+
随机搭话概率: 0.35, // 掷中的概率(纯骰子,不花 token)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* ⭐ 0.7.9:清掉插件自己攒的旧截图。
|
|
79
|
+
*
|
|
80
|
+
* 陪看抽帧、开口那张、说话带的那张,全落在 `~/.dsh/fish-buddy-frames`;
|
|
81
|
+
* 以前只进不出,用久了能堆到几十上百 MB。这里按两条清:
|
|
82
|
+
* · 比「留几天」更老的删掉(0 = 按天数不限)
|
|
83
|
+
* · 从新到旧数下去,超过「最多几张」的删掉(0 = 按张数不限)
|
|
84
|
+
* 两条都 0 就什么都不做。只认图片后缀,别的一律不碰。
|
|
85
|
+
*
|
|
86
|
+
* 返回 `{ 删了, 剩 }`;目录还不存在也算正常(返回 0/0)。
|
|
87
|
+
*/
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* ⭐ 1.6.0:哪些图该拼 —— **只拼 1 分钟以前的**,老的够 2 张就拼。
|
|
91
|
+
* 刚截的那张(还有她自己截图说话发的那张)一律不拼,单发。
|
|
92
|
+
* 纯函数,方便单测。
|
|
93
|
+
*/
|
|
94
|
+
export function 该拼哪几张(张们, 现在毫秒) {
|
|
95
|
+
const 现在_ = Number.isFinite(Number(现在毫秒)) ? Number(现在毫秒) : Date.now()
|
|
96
|
+
const 老的 = []
|
|
97
|
+
const 新的 = []
|
|
98
|
+
for (const x of (Array.isArray(张们) ? 张们 : [])) {
|
|
99
|
+
if (!x || !x.路) continue
|
|
100
|
+
const 时 = Number((x && x.时) || 0)
|
|
101
|
+
if (时 > 0 && 现在_ - 时 >= 60 * 1000) 老的.push(x)
|
|
102
|
+
else 新的.push(x)
|
|
103
|
+
}
|
|
104
|
+
return { 老的, 新的, 该拼: 老的.length >= 2 }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function 清旧截图(目录, 保留天 = 0, 最多张 = 0, 现在毫秒 = Date.now()) {
|
|
108
|
+
const 结果 = { 删了: 0, 剩: 0 }
|
|
109
|
+
let 名们 = []
|
|
110
|
+
try {
|
|
111
|
+
名们 = readdirSync(目录).filter((x) => /\.(jpg|jpeg|png)$/i.test(String(x)))
|
|
112
|
+
} catch (error) {
|
|
113
|
+
return 结果
|
|
114
|
+
}
|
|
115
|
+
const 项们 = []
|
|
116
|
+
for (const 名 of 名们) {
|
|
117
|
+
try {
|
|
118
|
+
const 资 = statSync(join(目录, 名))
|
|
119
|
+
if (!资.isFile()) continue
|
|
120
|
+
项们.push({ 路: join(目录, 名), 时: 资.mtimeMs })
|
|
121
|
+
} catch (error) {
|
|
122
|
+
// 刚好被删 / 没权限:跳过,不影响别的
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
项们.sort((a, b) => b.时 - a.时) // 新 → 旧
|
|
126
|
+
const 天 = 24 * 60 * 60 * 1000
|
|
127
|
+
const 要删 = []
|
|
128
|
+
项们.forEach((项, i) => {
|
|
129
|
+
const 太老 = 保留天 > 0 && 现在毫秒 - 项.时 > 保留天 * 天
|
|
130
|
+
const 太多 = 最多张 > 0 && i >= 最多张
|
|
131
|
+
if (太老 || 太多) 要删.push(项.路)
|
|
132
|
+
})
|
|
133
|
+
for (const 路 of 要删) {
|
|
134
|
+
try {
|
|
135
|
+
unlinkSync(路)
|
|
136
|
+
结果.删了 += 1
|
|
137
|
+
} catch (error) {
|
|
138
|
+
// 删不掉就算了,下一轮再来
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
结果.剩 = 项们.length - 结果.删了
|
|
142
|
+
return 结果
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 0.4.1 改了插件名,配置文件名跟着从 fishbuddy-config.json 换成 fish-buddy-config.json。
|
|
147
|
+
* 新文件还没有、老文件还在 → 原样搬一份过去(老文件留着不动)。
|
|
148
|
+
* 返回:搬了 true / 没搬 false(新文件已存在、没有老文件、老文件是坏的 —— 都算没搬)。
|
|
149
|
+
*/
|
|
150
|
+
export function 搬老配置(家目录) {
|
|
151
|
+
const 新 = join(家目录, 'fish-buddy-config.json')
|
|
152
|
+
const 老 = join(家目录, 'fishbuddy-config.json')
|
|
153
|
+
try {
|
|
154
|
+
if (existsSync(新) || !existsSync(老)) return false
|
|
155
|
+
const 原文 = readFileSync(老, 'utf8').replace(/^\uFEFF/, '')
|
|
156
|
+
JSON.parse(原文) // 坏的就不搬:宁可走默认值,也别写一份坏配置过去
|
|
157
|
+
mkdirSync(家目录, { recursive: true })
|
|
158
|
+
writeFileSync(新, 原文, 'utf8')
|
|
159
|
+
return true
|
|
160
|
+
} catch (error) {
|
|
161
|
+
return false
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---------- 小工具 ----------
|
|
166
|
+
|
|
167
|
+
const 会话键 = (v) => {
|
|
168
|
+
const s = String(v == null ? '' : v).trim()
|
|
169
|
+
return s.startsWith('session-') ? s.slice('session-'.length) : s
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const 现在 = () => Date.now()
|
|
173
|
+
|
|
174
|
+
/** 取消息正文(正文块优先,兼容几种摆放) */
|
|
175
|
+
/**
|
|
176
|
+
* ⭐ 0.0.5:这一下该不该让它自己开口。纯函数,方便自测。
|
|
177
|
+
* ⭐ 0.0.8:间隔改成**秒**;老的「间隔分钟」还认,别把老配置弄坏。
|
|
178
|
+
* 条件:开着(间隔 > 0)、有字幕在流(不对着黑屏说话)、距上次够久、骰子掷中。
|
|
179
|
+
*/
|
|
180
|
+
export function 要搭话(o) {
|
|
181
|
+
let 间隔秒 = Number((o && o.间隔秒) || 0)
|
|
182
|
+
if (!(间隔秒 > 0)) 间隔秒 = Number((o && o.间隔分钟) || 0) * 60
|
|
183
|
+
if (!(间隔秒 > 0)) return false
|
|
184
|
+
if (!(o && o.有字幕)) return false
|
|
185
|
+
const 现 = Number(o.现在) || 0
|
|
186
|
+
const 上次 = Number(o.上次) || 0
|
|
187
|
+
// ⭐ 1.0.3(「固定 30 秒会显得很规律」):在设定的间隔上再随机浮动 ±35%。
|
|
188
|
+
// 填 30 秒 → 实际大概 20~40 秒之间才轮到;测试不传「浮」时按 0.5 算(等于原来的间隔,不会飘)。
|
|
189
|
+
//
|
|
190
|
+
// ⭐⭐ 0.2.4(2026-10-27):
|
|
191
|
+
// ±35% 还是太规律 —— 现在是**范围内真随机**:
|
|
192
|
+
// 下限 = 设定秒数(最小 20 秒),上限 = max(下限×4, 120 秒)。
|
|
193
|
+
// 下限填 15 → 实际落在 20~120 秒之间,偶尔很快、偶尔很久,没有"到点"的感觉。
|
|
194
|
+
// `浮` 传 0~1 时仍按旧算法(测试靠它得到确定值),不传才是真随机。
|
|
195
|
+
const 浮 = Number.isFinite(o && o.浮) ? Math.max(0, Math.min(1, Number(o.浮))) : null
|
|
196
|
+
let 有效间隔
|
|
197
|
+
if (浮 === null) {
|
|
198
|
+
// 下限就是填的那个数(最少 5 秒)—— 填多少就至少隔多久,别偷偷改成 20
|
|
199
|
+
const 下限 = Math.max(5, 间隔秒)
|
|
200
|
+
const 上限 = Math.max(下限 * 4, 120)
|
|
201
|
+
有效间隔 = 下限 + (上限 - 下限) * Math.random()
|
|
202
|
+
} else {
|
|
203
|
+
有效间隔 = 间隔秒 * (0.65 + 0.7 * 浮)
|
|
204
|
+
}
|
|
205
|
+
if (上次 && 现 - 上次 < 有效间隔 * 1000) return false
|
|
206
|
+
const 概率 = Math.max(0, Math.min(1, Number(o.概率 == null ? 0.35 : o.概率)))
|
|
207
|
+
const 骰 = typeof o.骰子 === 'number' ? o.骰子 : Math.random()
|
|
208
|
+
return 骰 < 概率
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const 取正文 = (message) => {
|
|
212
|
+
const 节点们 = [message, message && message.content]
|
|
213
|
+
for (const 节点 of 节点们) {
|
|
214
|
+
if (!节点) continue
|
|
215
|
+
if (typeof 节点 === 'string') return 节点
|
|
216
|
+
if (Array.isArray(节点)) {
|
|
217
|
+
const 段 = 节点.filter((b) => b && b.type === 'text' && typeof b.text === 'string').map((b) => b.text)
|
|
218
|
+
if (段.length) return 段.join('\n')
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return ''
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function apply(ctx, inlineConfig) {
|
|
225
|
+
// ---------- 配置 ----------
|
|
226
|
+
const 进制规整 = (o) => {
|
|
227
|
+
const 合 = { ...默认配置, ...(o && typeof o === 'object' ? o : {}) }
|
|
228
|
+
合.端口 = Math.max(1024, Math.min(65535, Number(合.端口) || 默认配置.端口))
|
|
229
|
+
合.会话 = String(合.会话 || '').trim()
|
|
230
|
+
合.回调 = String(合.回调 || '').trim()
|
|
231
|
+
合.总开关 = 合.总开关 !== false
|
|
232
|
+
合.带上来源 = 合.带上来源 === true // 默认不开(以前是默认开,见 0.2.8 那条注释)
|
|
233
|
+
// ⭐ 0.0.8:老配置是「分钟」,现在统一用「秒」
|
|
234
|
+
const 原 = (o && typeof o === 'object') ? o : {}
|
|
235
|
+
if (!('随机搭话秒' in 原) && '随机搭话分钟' in 原) {
|
|
236
|
+
合.随机搭话秒 = Math.max(0, Number(原.随机搭话分钟) || 0) * 60
|
|
237
|
+
}
|
|
238
|
+
合.随机搭话秒 = Math.max(0, Math.min(14400, Number(合.随机搭话秒) || 0))
|
|
239
|
+
合.游戏搭话秒 = Math.max(0, Math.min(14400, Number(合.游戏搭话秒) || 0))
|
|
240
|
+
合.搭话最多秒 = Math.max(0, Math.min(14400, Number(合.搭话最多秒) || 0))
|
|
241
|
+
合.冷却最短秒 = Math.max(10, Math.min(3600, Number(合.冷却最短秒) || 60))
|
|
242
|
+
合.冷却最长秒 = Math.max(合.冷却最短秒, Math.min(7200, Number(合.冷却最长秒) || 150))
|
|
243
|
+
合.安静秒 = Math.max(1, Math.min(600, Number(合.安静秒) || 8))
|
|
244
|
+
合.截图保留天 = Math.max(0, Math.min(3650, Number(合.截图保留天) || 0))
|
|
245
|
+
合.截图最多张 = Math.max(0, Math.min(200000, Number(合.截图最多张) || 0))
|
|
246
|
+
delete 合.随机搭话分钟
|
|
247
|
+
return 合
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const 读配置 = () => {
|
|
251
|
+
搬老配置(目录) // 0.4.1 换了文件名:第一次读之前先把老配置搬过来
|
|
252
|
+
try {
|
|
253
|
+
// ⚠ 配置是用户手改过的:可能带 BOM(PowerShell 写的),先削掉再解析
|
|
254
|
+
const 原文 = readFileSync(配置文件, 'utf8').replace(/^\uFEFF/, '')
|
|
255
|
+
return 进制规整(JSON.parse(原文))
|
|
256
|
+
} catch (error) {
|
|
257
|
+
return 进制规整(inlineConfig)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const 写配置 = (o) => {
|
|
262
|
+
const 合 = 进制规整(o)
|
|
263
|
+
try {
|
|
264
|
+
mkdirSync(目录, { recursive: true })
|
|
265
|
+
writeFileSync(配置文件, JSON.stringify(合, null, 2), 'utf8')
|
|
266
|
+
} catch (error) {
|
|
267
|
+
记账('配置没写进去:' + 说(error))
|
|
268
|
+
}
|
|
269
|
+
return 合
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const 说 = (e) => String((e && e.message) || e)
|
|
273
|
+
|
|
274
|
+
// ---------- 内存状态 ----------
|
|
275
|
+
const 状态 = {
|
|
276
|
+
收到条数: 0,
|
|
277
|
+
送出条数: 0,
|
|
278
|
+
递回条数: 0,
|
|
279
|
+
最后收到: 0, // 挂件最后一次发话的时间(判断「连上了没」)
|
|
280
|
+
最后回话: { 会话: '', 文本: '', 时: 0 },
|
|
281
|
+
日志: [],
|
|
282
|
+
口子: { 起没起: false, 端口: 0, 错: '' },
|
|
283
|
+
下载: null, // ⭐ 0.4.7:下载挂件的进度
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const 记账 = (话) => {
|
|
287
|
+
状态.日志.unshift({ 时: 现在(), 说: String(话 || '') })
|
|
288
|
+
if (状态.日志.length > 日志上限) 状态.日志.length = 日志上限
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* ⭐ 0.5.2:取一个**没写进 inject** 的宿主服务。
|
|
293
|
+
*
|
|
294
|
+
* cordis 的 ctx 代理在读 `ctx.xxx` / `ctx.get('xxx')` 时,**只要 xxx 没在 inject 里声明就直接抛**
|
|
295
|
+
* (`cannot get property "timer" without inject`)—— 所以「用 typeof 探测在不在」这条路走不通,
|
|
296
|
+
* 探测本身就是抛错。唯一安全的办法是包 try/catch。
|
|
297
|
+
*/
|
|
298
|
+
const 取服务 = (名) => {
|
|
299
|
+
try {
|
|
300
|
+
return ctx.get(名) || null
|
|
301
|
+
} catch (error) {
|
|
302
|
+
return null
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ---------- 会话清单(设置页的下拉用) ----------
|
|
307
|
+
const 会话清单 = () => {
|
|
308
|
+
// ⚠ 2026-11-03:原来只列「活着的会话」(sessions.list) —— 窗口一关就选不到了,
|
|
309
|
+
// 未分组那批更是永远选不到。改成扫磁盘,跨所有工作区,按同一套规矩过滤。
|
|
310
|
+
const 出 = []
|
|
311
|
+
try {
|
|
312
|
+
const 根 = join(目录, 'sessions')
|
|
313
|
+
const 缓存 = join(目录, 'storages', 'session_projcache', 'sessions')
|
|
314
|
+
const 归档 = new Set()
|
|
315
|
+
try {
|
|
316
|
+
const wr = 取服务('workspaceRegistry')
|
|
317
|
+
const ids = wr && wr.archivedSessionIds
|
|
318
|
+
if (Array.isArray(ids)) for (const x of ids) 归档.add(String(x))
|
|
319
|
+
} catch (error) {}
|
|
320
|
+
try {
|
|
321
|
+
const w = JSON.parse(readFileSync(join(目录, 'storages', 'workspace.json'), 'utf8'))
|
|
322
|
+
for (const x of (w && w.global && w.global.archivedSessionIds) || []) 归档.add(String(x))
|
|
323
|
+
} catch (error) {}
|
|
324
|
+
|
|
325
|
+
let 一层们 = []
|
|
326
|
+
try {
|
|
327
|
+
一层们 = readdirSync(根, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name)
|
|
328
|
+
} catch (error) {
|
|
329
|
+
return 出
|
|
330
|
+
}
|
|
331
|
+
const 位置 = []
|
|
332
|
+
for (const 一层 of 一层们) {
|
|
333
|
+
if (existsSync(join(根, 一层, 'session.v4.jsonl.zstd'))) {
|
|
334
|
+
位置.push({ ws: '', sid: 一层 })
|
|
335
|
+
continue
|
|
336
|
+
}
|
|
337
|
+
let 子们 = []
|
|
338
|
+
try {
|
|
339
|
+
子们 = readdirSync(join(根, 一层), { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name)
|
|
340
|
+
} catch (error) {
|
|
341
|
+
continue
|
|
342
|
+
}
|
|
343
|
+
for (const sid of 子们) 位置.push({ ws: 一层, sid })
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const 行们 = []
|
|
347
|
+
for (const { ws, sid } of 位置) {
|
|
348
|
+
const 日志 = join(根, ws, sid, 'session.v4.jsonl.zstd')
|
|
349
|
+
if (!existsSync(日志)) continue
|
|
350
|
+
if (归档.has(sid)) continue
|
|
351
|
+
let 大小 = 0
|
|
352
|
+
try {
|
|
353
|
+
大小 = statSync(日志).size
|
|
354
|
+
} catch (error) {}
|
|
355
|
+
if (大小 < 1024) continue // 空会话
|
|
356
|
+
let 标题 = ''
|
|
357
|
+
let 空 = false
|
|
358
|
+
let 子代理 = false
|
|
359
|
+
let 时 = 0
|
|
360
|
+
try {
|
|
361
|
+
const r = JSON.parse(readFileSync(join(缓存, sid + '.json'), 'utf8'))?.record?.rows || {}
|
|
362
|
+
标题 = String((r.title && r.title.val) || '')
|
|
363
|
+
const meta = (r.sessionListMetadata && r.sessionListMetadata.val) || {}
|
|
364
|
+
时 = Number(meta.lastPromptAt || 0)
|
|
365
|
+
空 = Boolean(meta.blank)
|
|
366
|
+
子代理 = Boolean(r.subagent && r.subagent.val && r.subagent.val.identity)
|
|
367
|
+
} catch (error) {}
|
|
368
|
+
if (空) continue
|
|
369
|
+
if (!标题 || 子代理) {
|
|
370
|
+
// 没缓存或缓存没说清楚 → 看日志头:子代理/深度>0 直接排除,顺便捞第一句话当标题
|
|
371
|
+
try {
|
|
372
|
+
const 缓 = readFileSync(日志).subarray(0, 524288)
|
|
373
|
+
const 帧们 = []
|
|
374
|
+
for (let i = 0; i + 4 <= 缓.length; i++) {
|
|
375
|
+
if (缓[i] === 0x28 && 缓[i + 1] === 0xb5 && 缓[i + 2] === 0x2f && 缓[i + 3] === 0xfd) 帧们.push(i)
|
|
376
|
+
}
|
|
377
|
+
for (let k = 0; k < Math.min(帧们.length, 64); k++) {
|
|
378
|
+
const 段 = 缓.subarray(帧们[k], k + 1 < 帧们.length ? 帧们[k + 1] : 缓.length)
|
|
379
|
+
let 文 = ''
|
|
380
|
+
try {
|
|
381
|
+
文 = zlib.zstdDecompressSync(段).toString('utf8')
|
|
382
|
+
} catch (error) {
|
|
383
|
+
continue
|
|
384
|
+
}
|
|
385
|
+
for (const 行 of 文.split(String.fromCharCode(10))) {
|
|
386
|
+
if (!行.trim()) continue
|
|
387
|
+
let e
|
|
388
|
+
try {
|
|
389
|
+
e = JSON.parse(行)
|
|
390
|
+
} catch (error) {
|
|
391
|
+
continue
|
|
392
|
+
}
|
|
393
|
+
if (e.type === 'session') {
|
|
394
|
+
if (e.origin === 'subagent' || Number(e.delegationDepth || 0) > 0) 子代理 = true
|
|
395
|
+
if (!时) 时 = Number(e.createdAt || 0)
|
|
396
|
+
continue
|
|
397
|
+
}
|
|
398
|
+
if (标题) continue
|
|
399
|
+
if (e.type !== 'user/message') continue
|
|
400
|
+
const 源 = (e.data && e.data.source && e.data.source.kind) || ''
|
|
401
|
+
if (源 !== 'user') continue
|
|
402
|
+
const c = e.data && e.data.content
|
|
403
|
+
const t = (Array.isArray(c) ? c : []).filter((x) => x && x.type === 'text').map((x) => x.text).join(' ').trim()
|
|
404
|
+
if (t) 标题 = t.slice(0, 40)
|
|
405
|
+
}
|
|
406
|
+
if (子代理 && 标题) break
|
|
407
|
+
}
|
|
408
|
+
} catch (error) {}
|
|
409
|
+
}
|
|
410
|
+
if (子代理) continue
|
|
411
|
+
行们.push({ id: sid, 标题: 标题 || ('未命名会话 · ' + (时 ? new Date(时).toLocaleDateString('zh-CN') : sid.slice(0, 8))), at: 时 })
|
|
412
|
+
}
|
|
413
|
+
行们.sort((a, b) => b.at - a.at)
|
|
414
|
+
for (const r of 行们) 出.push({ id: r.id, 标题: r.标题 })
|
|
415
|
+
} catch (error) {
|
|
416
|
+
记账('列会话出错:' + 说(error))
|
|
417
|
+
}
|
|
418
|
+
return 出
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ---------- ① 把话送进会话 ----------
|
|
422
|
+
// ⭐ 0.4.2:绑了会话就整间都算 —— 回话一律递回挂件(以前只递「挂件叫起来的那一轮」,
|
|
423
|
+
// 结果光挂着悬浮窗、或者它自己搭话,回话都递不出去,气泡/弹幕就永远不冒)。
|
|
424
|
+
// ⭐ 1.1.6:陪看到底开着没 —— 挂件每次开/停都会来说一声。
|
|
425
|
+
// 以前不看这个,光凭「最近有字幕」就自己开口:陪看早关了,旧字幕还在 30 秒窗口里,
|
|
426
|
+
// 于是就冒出一句「你俩正一起看着」(人根本没在看)。
|
|
427
|
+
let 陪看开着 = false
|
|
428
|
+
// ⭐ 1.0.3:「陪看时它自己开口」的上一次时间。放在这么前面,是因为 turn/end 那边也要用它
|
|
429
|
+
// (任何一轮结束都重开计时,免得刚回完话又到点自己说一句)。
|
|
430
|
+
let 上次搭话 = 0
|
|
431
|
+
// ⭐ 1.0.3:绑的会话没开着时只记一次,别每 5 秒刷一行
|
|
432
|
+
let 说过了没活着 = false
|
|
433
|
+
// ⭐ 0.2.1:抽帧队列(声明放前面 —— 搭话和 turn/end 都要用它)
|
|
434
|
+
// [{序, 时, 文件, 大小, 尺寸, 台词, 已发}]
|
|
435
|
+
const 帧账 = []
|
|
436
|
+
|
|
437
|
+
const 送进会话 = async (正文, 指定会话) => {
|
|
438
|
+
const 配置 = 读配置()
|
|
439
|
+
if (!配置.总开关) return { ok: false, 说: '总开关关着,没往里送' }
|
|
440
|
+
const 目标 = String(指定会话 || 配置.会话 || '').trim()
|
|
441
|
+
if (!目标) return { ok: false, 说: '还没绑定会话 —— 去 DSH 设置 → 鱼搭子 里选一间' }
|
|
442
|
+
|
|
443
|
+
let agent
|
|
444
|
+
try {
|
|
445
|
+
agent = ctx.agents.get(目标)
|
|
446
|
+
if (agent === undefined) agent = ctx.agents.get('session-' + 会话键(目标))
|
|
447
|
+
} catch (error) {
|
|
448
|
+
return { ok: false, 说: '找不到那间会话:' + 说(error) }
|
|
449
|
+
}
|
|
450
|
+
if (!agent) return { ok: false, 说: '那间会话没开着(DSH 里先把它打开一次)' }
|
|
451
|
+
|
|
452
|
+
// ⭐ 0.2.8:以前写成「(挂件里说的话)」,电话里说的话也这么标,
|
|
453
|
+
// 看着很怪 —— 那是在说话,不是「挂件在说话」。默认干脆不加;要加也只说「你说的」。
|
|
454
|
+
const 文本 = 配置.带上来源 ? '(你说的)' + 正文 : 正文
|
|
455
|
+
const 内容 = [{ type: 'text', text: 文本 }]
|
|
456
|
+
try {
|
|
457
|
+
// ⚠ 必须 await:followup 返回 Promise,不接住的话 reject 会变成未处理异常,把整个宿主带走
|
|
458
|
+
await agent.followup({ id: randomUUID(), role: 'user', content: 内容, source: { kind: 'user' } })
|
|
459
|
+
} catch (error) {
|
|
460
|
+
return { ok: false, 说: '塞进去失败了:' + 说(error) }
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
状态.送出条数 += 1
|
|
464
|
+
try {
|
|
465
|
+
const 会话服务 = 取服务('sessions')
|
|
466
|
+
if (会话服务 && typeof 会话服务.flush === 'function') await 会话服务.flush(agent.session)
|
|
467
|
+
} catch (error) {}
|
|
468
|
+
记账('挂件说的话送进会话了:' + 正文.slice(0, 40) + (正文.length > 40 ? '…' : ''))
|
|
469
|
+
return { ok: true, 说: '送进去了' }
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ---------- ② 她的回话递回挂件 ----------
|
|
473
|
+
// ⭐ 0.4.2:现在每轮都递,挂件没开着的时候别每轮都去撞一遍 —— 失败后静音 30 秒再试。
|
|
474
|
+
let 递回静音到 = 0
|
|
475
|
+
const 递回 = async (文本) => {
|
|
476
|
+
const 配置 = 读配置()
|
|
477
|
+
if (!配置.回调) return { ok: false, 说: '没配回调地址(设置页里填)' }
|
|
478
|
+
if (现在() < 递回静音到) return { ok: false, 说: '挂件刚才没接住,过一会儿再试' }
|
|
479
|
+
try {
|
|
480
|
+
const r = await fetch(配置.回调, {
|
|
481
|
+
method: 'POST',
|
|
482
|
+
headers: { 'content-type': 'application/json' },
|
|
483
|
+
body: JSON.stringify({ text: 文本, session: 配置.会话 }),
|
|
484
|
+
})
|
|
485
|
+
if (!r.ok) throw new Error('HTTP ' + r.status)
|
|
486
|
+
递回静音到 = 0
|
|
487
|
+
状态.递回条数 += 1
|
|
488
|
+
return { ok: true, 说: '递回去了' }
|
|
489
|
+
} catch (error) {
|
|
490
|
+
递回静音到 = 现在() + 30000
|
|
491
|
+
记账('回话没递到挂件:' + 说(error))
|
|
492
|
+
return { ok: false, 说: 说(error) }
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// 一轮里最后那段话(同一间会话分开记,两间同时说话也不会串)
|
|
497
|
+
const 本轮 = new Map()
|
|
498
|
+
const 已递 = new Set()
|
|
499
|
+
// ⭐ 0.3.2:没动静的时候,图直接发过去;有字幕在流的时候,图才排进字幕里。
|
|
500
|
+
let 最近动静 = 0
|
|
501
|
+
// ⭐ 0.3.4:一张图只能出现一次 —— 路径行看过的、直接发过的,都记在这里
|
|
502
|
+
const 已发帧 = new Set()
|
|
503
|
+
// ⭐ 0.3.5:截图直发 和 自动搭话 会「左脚踩右脚」—— 两条共用一个冷却,
|
|
504
|
+
// 任何一条自发消息之后 90 秒内,另一条不发(图改走字幕批,不丢)。
|
|
505
|
+
let 上次自发 = 0
|
|
506
|
+
// ⭐ 0.3.6:冷却不能是死板的 90 秒 —— 那又变成「固定节奏」了。
|
|
507
|
+
// 每次自发消息(发图 / 搭话)之后,**重新掷一个 60~150 秒**的冷却。
|
|
508
|
+
let 下次自发最早 = 0
|
|
509
|
+
// ⭐ 0.3.9:别再分成四个数 —— 就一个「多久说一句」的区间,
|
|
510
|
+
// 发图 和 自己开口 共用它:谁刚发过,下一次就重掷一个落在这个区间里的时间。
|
|
511
|
+
const 掷冷却 = () => {
|
|
512
|
+
const c = 读配置()
|
|
513
|
+
const 报的 = 挂件搭话秒()
|
|
514
|
+
const 下限 = Math.max(5, Number(报的 === null ? c.随机搭话秒 : 报的) || 5)
|
|
515
|
+
const 上限 = Math.max(下限, 节奏('搭话最多秒', Number(c.搭话最多秒) || 0) || Math.max(下限 * 4, 120))
|
|
516
|
+
return 现在() + (下限 + Math.random() * (上限 - 下限)) * 1000
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const 解绑监听 = ctx.on('session/event', (session, event) => {
|
|
520
|
+
const 键 = 会话键(session && session.id)
|
|
521
|
+
const 配置 = 读配置()
|
|
522
|
+
if (!键 || 键 !== 会话键(配置.会话)) return
|
|
523
|
+
最近动静 = 现在() // 有字幕在流 / 它在回话,都算「有动静」
|
|
524
|
+
const 类型 = event && event.type
|
|
525
|
+
const 数据 = (event && event.data) || {}
|
|
526
|
+
|
|
527
|
+
if (类型 === 'assistant/message') {
|
|
528
|
+
if (数据.interrupted) {
|
|
529
|
+
本轮.delete(键)
|
|
530
|
+
return
|
|
531
|
+
}
|
|
532
|
+
const 文 = String(取正文(数据.message) || '').trim()
|
|
533
|
+
if (文) 本轮.set(键, { 轮号: 数据.turn, 文本: 文 })
|
|
534
|
+
return
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (类型 === 'turn/end') {
|
|
538
|
+
// ⭐ 0.0.9:这一轮真的结束了 → 把字幕游标推到本轮拼到的位置(下一轮只发新的)
|
|
539
|
+
字幕这轮发完了(键)
|
|
540
|
+
// ⭐ 1.0.3:**只要有一轮对话结束,就把「自己开口」的计时重开** ——
|
|
541
|
+
// 说完话、它刚回完又到点自己说一句,会像"连着说"。从这一刻重新计时。
|
|
542
|
+
上次搭话 = 现在()
|
|
543
|
+
const 完成 = !!(数据.reason && 数据.reason.kind === 'completed')
|
|
544
|
+
const 攒的 = 本轮.get(键)
|
|
545
|
+
const 标记 = 键 + ':' + String(数据.turn)
|
|
546
|
+
本轮.delete(键)
|
|
547
|
+
if (!完成 || !攒的 || 攒的.轮号 !== 数据.turn || 已递.has(标记)) return
|
|
548
|
+
已递.add(标记)
|
|
549
|
+
if (已递.size > 500) 已递.clear()
|
|
550
|
+
|
|
551
|
+
状态.最后回话 = { 会话: 配置.会话, 文本: 攒的.文本, 时: 现在() }
|
|
552
|
+
// 这间会话绑着挂件:一轮说完就把最后那句递回去(气泡 / 弹幕都靠它)。
|
|
553
|
+
// 不管这轮是谁起的头 —— 电话、它自己搭话、你在 DSH 里直接打字,都一样。
|
|
554
|
+
void 递回(攒的.文本)
|
|
555
|
+
}
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
ctx.effect(() => () => {
|
|
559
|
+
try {
|
|
560
|
+
解绑监听()
|
|
561
|
+
} catch (error) {}
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
// ---------- 陪看:字幕白板(游标式增量投递)----------
|
|
565
|
+
// ⭐ 0.0.9(2026-10-26):游标式投递 —— 上一轮发到哪儿结束,下一轮就从没发的继续,不漏也不多发。
|
|
566
|
+
// 所以这里不是「按时间窗截一段」而是**排队 + 游标**:
|
|
567
|
+
// · 每条字幕进队列时拿一个递增的「序号」;
|
|
568
|
+
// · 白板只拼「序号 > 已发到」的那些;
|
|
569
|
+
// · 等这一轮真正结束(turn/end)才把「已发到」推到本轮拼到的位置。
|
|
570
|
+
// 好处:中间我隔一小时没说话也不丢句;同一句不会被塞两遍;全程本地数字运算,不花 token。
|
|
571
|
+
const chr10 = String.fromCharCode(10)
|
|
572
|
+
const 队列上限 = 400 // 最多存这么多条(再多从最旧的丢,并记「丢了 N 句」)
|
|
573
|
+
const 单轮最多 = 80 // 一轮最多拼这么多句(太长就只发最近这些,并标明前面还有几句)
|
|
574
|
+
const 字幕们 = new Map() // 会话键 -> { 行: [{序, 时, 文}], 下一条: n, 已发到: 0, 本轮到: 0, 丢: 0, 最后: 0 }
|
|
575
|
+
|
|
576
|
+
const 拿字幕桶 = (键) => {
|
|
577
|
+
let 桶 = 字幕们.get(键)
|
|
578
|
+
if (!桶) {
|
|
579
|
+
桶 = { 行: [], 下一条: 1, 已发到: 0, 本轮到: 0, 丢: 0, 最后: 0 }
|
|
580
|
+
字幕们.set(键, 桶)
|
|
581
|
+
}
|
|
582
|
+
return 桶
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* ⭐ 1.1.1:陪看停了 —— 缸里攒的、还没发出去的字幕全扔掉。
|
|
587
|
+
* 只清内容,不重置序号(游标跟着「下一条」走,免得下一轮又把旧的当成新的)。
|
|
588
|
+
*/
|
|
589
|
+
const 清空字幕桶 = () => {
|
|
590
|
+
const 键 = 会话键(读配置().会话)
|
|
591
|
+
const 桶 = 字幕们.get(键)
|
|
592
|
+
if (!桶) return 0
|
|
593
|
+
const 丢了几条 = 桶.行.filter((x) => x.序 > 桶.已发到).length
|
|
594
|
+
桶.行.length = 0
|
|
595
|
+
桶.已发到 = 桶.下一条
|
|
596
|
+
桶.本轮到 = 0
|
|
597
|
+
return 丢了几条
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const 收一句字幕 = (键, 文, 时) => {
|
|
601
|
+
const 桶 = 拿字幕桶(键)
|
|
602
|
+
const 净 = String(文 || '').trim()
|
|
603
|
+
if (!净) return
|
|
604
|
+
// 同一句重复推上来(挂件那边断句抖动)就别写两遍
|
|
605
|
+
if (桶.行.length && 桶.行[桶.行.length - 1].文 === 净) return
|
|
606
|
+
桶.行.push({ 序: 桶.下一条++, 时: Number(时) || 现在(), 文: 净.slice(0, 300) })
|
|
607
|
+
桶.最后 = 现在()
|
|
608
|
+
while (桶.行.length > 队列上限) {
|
|
609
|
+
const 丢的 = 桶.行.shift()
|
|
610
|
+
if (丢的 && 丢的.序 > 桶.已发到) 桶.丢 += 1 // 没发过就被挤掉的,记一笔
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* ⭐ 0.3.1:**截图不是单独一条消息** ——
|
|
616
|
+
* 它是在那两句字幕之间截的,就该像字幕一样排进同一批、同一条消息里。
|
|
617
|
+
* 图本身塞不进「白板」(白板只能是文字),所以这里给的是**本地文件路径 + 尺寸大小**。
|
|
618
|
+
*/
|
|
619
|
+
const 收一帧行 = (键, 条) => {
|
|
620
|
+
const 桶 = 拿字幕桶(键)
|
|
621
|
+
if (!桶) return
|
|
622
|
+
桶.行.push({
|
|
623
|
+
序: 桶.下一条++,
|
|
624
|
+
时: Number(条 && 条.时) || 现在(),
|
|
625
|
+
文: '',
|
|
626
|
+
帧: { 文件: String((条 && 条.文件) || ''), 尺寸: String((条 && 条.尺寸) || ''), 大小: Number((条 && 条.大小) || 0) },
|
|
627
|
+
})
|
|
628
|
+
桶.最后 = 现在()
|
|
629
|
+
while (桶.行.length > 队列上限) {
|
|
630
|
+
const 丢的 = 桶.行.shift()
|
|
631
|
+
if (丢的 && 丢的.序 > 桶.已发到) 桶.丢 += 1
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** 一句字幕是「多久以前」说的(相对时间比钟点有用,而且每轮现拼所以永远新鲜) */
|
|
636
|
+
const 多久前 = (时) => {
|
|
637
|
+
const 秒 = Math.max(0, Math.round((现在() - (Number(时) || 0)) / 1000))
|
|
638
|
+
if (秒 <= 3) return '刚刚'
|
|
639
|
+
if (秒 < 60) return 秒 + ' 秒前'
|
|
640
|
+
const 分 = Math.round(秒 / 60)
|
|
641
|
+
return 分 + ' 分钟前'
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** 还没有「发到」的那些(游标之后) */
|
|
645
|
+
const 待发字幕 = (键) => {
|
|
646
|
+
const 桶 = 字幕们.get(键)
|
|
647
|
+
if (!桶) return []
|
|
648
|
+
return 桶.行.filter((x) => x.序 > 桶.已发到)
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* 每轮拼的那一段(没有新字幕时返回空串 —— 空串不占上下文)。
|
|
653
|
+
* ⚠ 这里**只标记「本轮到哪儿」,不推进「已发到」** —— 推进放在 turn/end,
|
|
654
|
+
* 否则同一轮里 text() 被调用两次就会把内容吃掉。
|
|
655
|
+
*/
|
|
656
|
+
const 字幕白板 = (键) => {
|
|
657
|
+
const 桶 = 字幕们.get(键)
|
|
658
|
+
if (!桶) {
|
|
659
|
+
记账('白板:这间会话还没有字幕桶(可能是刚重启、或者字幕绑到别处去了)')
|
|
660
|
+
return ''
|
|
661
|
+
}
|
|
662
|
+
const 待 = 待发字幕(键)
|
|
663
|
+
if (!待.length) {
|
|
664
|
+
// ⭐ 0.1.6(排查「番那几段一句没收到」):空的时候也留一行证据,
|
|
665
|
+
// 不然「是没收到」还是「白板根本没给」就永远分不清。
|
|
666
|
+
记账('白板:这一轮没有新字幕(已发到 ' + 桶.已发到 + ' / 共 ' + 桶.行.length + ' 句)')
|
|
667
|
+
return ''
|
|
668
|
+
}
|
|
669
|
+
const 发 = 待.length > 单轮最多 ? 待.slice(待.length - 单轮最多) : 待
|
|
670
|
+
const 跳过 = 待.length - 发.length
|
|
671
|
+
桶.本轮到 = 发[发.length - 1].序
|
|
672
|
+
记账('白板:这一轮贴了 ' + 发.length + ' 句(序 ' + 发[0].序 + '~' + 发[发.length - 1].序
|
|
673
|
+
+ ',共 ' + 桶.行.length + ' 句)第一句:「' + String(发[0].文).slice(0, 24) + '」')
|
|
674
|
+
const 头 = '【你正在听的】(视频里的声音,不是你俩说的话'
|
|
675
|
+
+ (跳过 > 0 ? ';前面还有 ' + 跳过 + ' 句太长没贴' : '')
|
|
676
|
+
+ (桶.丢 > 0 ? ';再早还有 ' + 桶.丢 + ' 句被挤掉了' : '') + ')'
|
|
677
|
+
const 行们 = 发.map((x) => {
|
|
678
|
+
if (x.帧) {
|
|
679
|
+
// ⚠ 0.3.4:这张图要是已经直接发过去(或已经贴过),就别再出现第二次
|
|
680
|
+
if (已发帧.has(String(x.帧.文件 || ''))) return null
|
|
681
|
+
const 大小 = x.帧.大小 >= 1024 ? Math.round(x.帧.大小 / 1024) + 'KB' : (x.帧.大小 || 0) + 'B'
|
|
682
|
+
return '· ' + 多久前(x.时) + '|〔这一句是画面〕截了一张图(' + (x.帧.尺寸 || '?')
|
|
683
|
+
+ ',' + 大小 + ')—— 想看到这张画面,就打开这个文件:' + x.帧.文件
|
|
684
|
+
}
|
|
685
|
+
return '· ' + 多久前(x.时) + '|' + x.文
|
|
686
|
+
}).filter(Boolean)
|
|
687
|
+
// 贴出去的帧行就算用过了,后面不再重贴
|
|
688
|
+
for (const x of 发) if (x.帧) 已发帧.add(String(x.帧.文件 || ''))
|
|
689
|
+
return 头 + chr10 + 行们.join(chr10)
|
|
690
|
+
+ chr10 + '(上面这些是片子里的字幕。)'
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** 一轮真正结束:把游标推到这一轮拼到的位置(没拼过的轮次不动) */
|
|
694
|
+
const 字幕这轮发完了 = (键) => {
|
|
695
|
+
const 桶 = 字幕们.get(键)
|
|
696
|
+
if (!桶 || !桶.本轮到) return
|
|
697
|
+
const 前 = 桶.已发到
|
|
698
|
+
桶.已发到 = Math.max(桶.已发到, 桶.本轮到)
|
|
699
|
+
桶.本轮到 = 0
|
|
700
|
+
if (桶.已发到 !== 前) 记账('白板:这一轮说完,字幕游标 ' + 前 + ' → ' + 桶.已发到)
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// 挂在绑定会话上的「白板」——按会话挂,会话没了就卸掉
|
|
704
|
+
const 白板挂着 = new Map() // 会话键 -> 卸下函数
|
|
705
|
+
const 挂白板 = () => {
|
|
706
|
+
const 配置 = 读配置()
|
|
707
|
+
const 目标键 = 会话键(配置.会话)
|
|
708
|
+
if (!目标键) return
|
|
709
|
+
const 桶 = 字幕们.get(目标键)
|
|
710
|
+
if (!桶 || !桶.行.length) {
|
|
711
|
+
const 卸 = 白板挂着.get(目标键)
|
|
712
|
+
if (卸) {
|
|
713
|
+
try { 卸() } catch (error) {}
|
|
714
|
+
白板挂着.delete(目标键)
|
|
715
|
+
}
|
|
716
|
+
return
|
|
717
|
+
}
|
|
718
|
+
if (白板挂着.has(目标键)) return
|
|
719
|
+
let agent = null
|
|
720
|
+
try {
|
|
721
|
+
agent = ctx.agents.get(配置.会话)
|
|
722
|
+
if (!agent) agent = ctx.agents.get('session-' + 目标键)
|
|
723
|
+
} catch (error) {}
|
|
724
|
+
if (!agent || !agent.ctx || !agent.ctx.systemPrompt || typeof agent.ctx.systemPrompt.context !== 'function') return
|
|
725
|
+
try {
|
|
726
|
+
// 🔴 2026-10-26 深夜(排查「缓存命中只有 3%」时查出来的):
|
|
727
|
+
// 白板**必须用 context(),不能用 section()**。
|
|
728
|
+
// section 拼在系统提示词里,而白板每轮都在变(每句都带「12 秒前」这种相对时间)
|
|
729
|
+
// → 系统提示词一变,后面整段对话历史全成了「未缓存输入」(实测 20k 未缓存 / 640 已缓存)。
|
|
730
|
+
// context() 是 DSH 给「每轮都变」准备的那条路:作为一条消息追加到对话末尾,前缀不受影响。
|
|
731
|
+
const 卸 = agent.ctx.systemPrompt.context({
|
|
732
|
+
name: 'watch-subtitles',
|
|
733
|
+
order: 900,
|
|
734
|
+
// 🔴 2026-10-27:**这段是纯文本,不许宿主当提示词模板解析**。
|
|
735
|
+
// 宿主默认会对 context/section 做变量替换:字幕里只要出现 `{{…}}`(台词、代码片段、
|
|
736
|
+
// 游戏里的模板文字都可能有),就会被当成变量名去查,查不到直接抛
|
|
737
|
+
// `unknown prompt variable` —— 那一轮整轮失败,还会一直失败到字幕换掉为止。
|
|
738
|
+
// `interpolate: false` 是宿主自己给的开关(dsh-tools / working-directory 也在用)。
|
|
739
|
+
interpolate: false,
|
|
740
|
+
text: () => 字幕白板(目标键),
|
|
741
|
+
})
|
|
742
|
+
白板挂着.set(目标键, typeof 卸 === 'function' ? 卸 : (() => {}))
|
|
743
|
+
记账('陪看字幕挂上白板了(' + 目标键.slice(0, 6) + ')')
|
|
744
|
+
} catch (error) {
|
|
745
|
+
记账('白板挂不上:' + 说(error))
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// ⭐ 0.0.8:挂件会把它自己配置里的「随机搭话秒」随每句话带上来 ——
|
|
750
|
+
// 这样在挂件设置页改了就立刻生效(以前两边各存一份,改了等于没改)。
|
|
751
|
+
let 挂件报的搭话秒 = null
|
|
752
|
+
const 记住搭话秒 = (v) => {
|
|
753
|
+
const 数 = Number(v)
|
|
754
|
+
if (Number.isFinite(数) && 数 >= 0) 挂件报的搭话秒 = 数
|
|
755
|
+
}
|
|
756
|
+
const 挂件搭话秒 = () => 挂件报的搭话秒
|
|
757
|
+
|
|
758
|
+
// ⭐ 0.3.7:节奏这几个数**只存在挂件那边**,由挂件随每条消息带上来 ——
|
|
759
|
+
// 插件不自己存一份,省得两边改不到一起。
|
|
760
|
+
const 挂件报的节奏 = {}
|
|
761
|
+
const 记住节奏 = (体) => {
|
|
762
|
+
for (const 键 of ['搭话最多秒', '冷却最短秒', '冷却最长秒', '安静秒']) {
|
|
763
|
+
const 数 = Number(体 && 体[键])
|
|
764
|
+
if (Number.isFinite(数) && 数 >= 0) 挂件报的节奏[键] = 数
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
const 节奏 = (键, 默认) => {
|
|
768
|
+
const v = Number(挂件报的节奏[键])
|
|
769
|
+
return Number.isFinite(v) && v > 0 ? v : 默认
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** 一帧的说明:几点、那会儿在说什么(画面本身在图里) */
|
|
773
|
+
const 帧说明 = (帧们) => 帧们.map((f) => {
|
|
774
|
+
let 时 = ''
|
|
775
|
+
try {
|
|
776
|
+
时 = new Date(Number(f.时) || Date.now()).toLocaleTimeString('zh-CN', { hour12: false })
|
|
777
|
+
} catch (error) { 时 = '' }
|
|
778
|
+
const 词 = (f.台词 || []).map((x) => {
|
|
779
|
+
const d = Number(x.差) || 0
|
|
780
|
+
const 前 = d < 0 ? Math.abs(d) + ' 秒前' : (d === 0 ? '这会儿' : d + ' 秒后')
|
|
781
|
+
return '· ' + 前 + ':' + String(x.文 || '')
|
|
782
|
+
}).join(chr10)
|
|
783
|
+
return '【' + 时 + ' 那一帧】' + (词 ? chr10 + 词 : '')
|
|
784
|
+
}).join(chr10 + chr10)
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* ⭐ 1.1.1:让她自己开口的时候,先跟挂件要一张「当下的画面」(跟按住说话带图一个口径)。
|
|
788
|
+
* 挂件没开 / 拍不了就返回空串,这一轮就只说话。
|
|
789
|
+
*/
|
|
790
|
+
const 问挂件要张图 = async () => {
|
|
791
|
+
const 配置 = 读配置()
|
|
792
|
+
let 基 = ''
|
|
793
|
+
try {
|
|
794
|
+
const 址 = String(配置.回调 || '').trim()
|
|
795
|
+
if (址) 基 = new URL(址).origin
|
|
796
|
+
} catch (error) {}
|
|
797
|
+
if (!基) {
|
|
798
|
+
const 备 = Number(配置.回话端口) || 8778
|
|
799
|
+
基 = 'http://127.0.0.1:' + 备
|
|
800
|
+
}
|
|
801
|
+
const 控 = new AbortController()
|
|
802
|
+
const 停 = setTimeout(() => 控.abort(), 4000)
|
|
803
|
+
try {
|
|
804
|
+
const r = await fetch(基 + '/hands/shot', {
|
|
805
|
+
method: 'POST',
|
|
806
|
+
headers: { 'content-type': 'application/json' },
|
|
807
|
+
body: '{}',
|
|
808
|
+
signal: 控.signal,
|
|
809
|
+
})
|
|
810
|
+
clearTimeout(停)
|
|
811
|
+
if (!r.ok) return ''
|
|
812
|
+
const j = await r.json()
|
|
813
|
+
const 图 = String((j && j.图片) || '')
|
|
814
|
+
if (!图) return ''
|
|
815
|
+
mkdirSync(帧目录, { recursive: true })
|
|
816
|
+
const 路 = join(帧目录, '开口-' + Date.now() + '.jpg')
|
|
817
|
+
writeFileSync(路, Buffer.from(图, 'base64'))
|
|
818
|
+
也许清截图()
|
|
819
|
+
return 路
|
|
820
|
+
} catch (error) {
|
|
821
|
+
clearTimeout(停)
|
|
822
|
+
return ''
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/** ⭐ 0.0.5:陪看时她自己开口 —— 走到 DSH 自己的回合(不是假装一段上下文) */
|
|
827
|
+
/**
|
|
828
|
+
* ⭐ 0.6.1:游戏模式也要能主动搭话 —— 不然「攒着」的截图没人看,每次还得她自己先开口,
|
|
829
|
+
* 那就不是陪玩了。
|
|
830
|
+
*
|
|
831
|
+
* ⚠ 只在 **截图送法 = 攒着** 时生效:每张都说那一档,截图本来就会叫醒她,
|
|
832
|
+
* 再叠加搭话等于一轮变两轮、白花钱。
|
|
833
|
+
*/
|
|
834
|
+
const 游戏搭话 = () => {
|
|
835
|
+
try {
|
|
836
|
+
if (!游戏模式.开) return false
|
|
837
|
+
const 配置 = 读配置()
|
|
838
|
+
if (String(配置.截图送法 || '每张') !== '攒着') return false // ← 只跟「攒着」配合
|
|
839
|
+
// ⭐ 1.7.1:优先用**挂件报上来的**那个数,拿不到才用自己配置里的默认值
|
|
840
|
+
const 间隔 = 节奏('游戏搭话秒', Math.max(0, Number(配置.游戏搭话秒) || 0))
|
|
841
|
+
if (间隔 <= 0) return false
|
|
842
|
+
const 键 = 会话键(配置.会话)
|
|
843
|
+
if (!键) return false
|
|
844
|
+
// 刚进游戏模式先安静一会儿,别一进去就开口
|
|
845
|
+
if (现在() - (游戏模式.时 || 0) < Math.min(间隔, 60) * 1000) return false
|
|
846
|
+
if (!要搭话({
|
|
847
|
+
间隔秒: 间隔,
|
|
848
|
+
最多秒: 0,
|
|
849
|
+
概率: Number(配置.随机搭话概率) || 0.35,
|
|
850
|
+
有字幕: false,
|
|
851
|
+
上次: 上次搭话,
|
|
852
|
+
现在: 现在(),
|
|
853
|
+
浮: Math.random(),
|
|
854
|
+
})) return false
|
|
855
|
+
let agent = null
|
|
856
|
+
try {
|
|
857
|
+
agent = ctx.agents.get(配置.会话) || ctx.agents.get('session-' + 键)
|
|
858
|
+
} catch (error) {}
|
|
859
|
+
if (!agent) return false
|
|
860
|
+
上次搭话 = 现在()
|
|
861
|
+
void (async () => {
|
|
862
|
+
try {
|
|
863
|
+
const 内容 = [{ type: 'text', text: '(她正在打游戏,你顺手看了一眼画面,想说就说一句;不想说也可以不说。)' }]
|
|
864
|
+
// 攒着的图一起带上;一张都没攒就问挂件要张现在的
|
|
865
|
+
let 张们 = 取出攒图(键)
|
|
866
|
+
if (!张们.length) {
|
|
867
|
+
const 图路 = await 问挂件要张图()
|
|
868
|
+
if (图路) 张们 = [{ 路: 图路, 时: 现在() }]
|
|
869
|
+
}
|
|
870
|
+
// ⭐ 1.6.0:**只拼 1 分钟以前的**,够 2 张就拼;刚截的那张单发不拼
|
|
871
|
+
const 分 = 该拼哪几张(张们, 现在())
|
|
872
|
+
let 拼好了 = false
|
|
873
|
+
if (分.该拼) {
|
|
874
|
+
const 拼这几张 = 分.老的.slice(-3)
|
|
875
|
+
const 拼图 = await 请挂件拼图(拼这几张)
|
|
876
|
+
if (拼图) {
|
|
877
|
+
try {
|
|
878
|
+
内容.push({ type: 'text', text: '(这 ' + 拼这几张.length + ' 张画面拼成了一张,从上到下;每格左上角是多久前)' })
|
|
879
|
+
内容.push(await 做图片块从字节(Buffer.from(拼图, 'base64'), '游戏画面'))
|
|
880
|
+
拼好了 = true
|
|
881
|
+
} catch (error) {
|
|
882
|
+
记账('拼好的那张没贴进去(退回单张):' + 说(error))
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
if (拼好了) {
|
|
886
|
+
for (const x of 分.老的.slice(0, -3)) {
|
|
887
|
+
try { 内容.push(await 做图片块(x.路, '游戏画面')) } catch (error) {}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
for (const x of (拼好了 ? 分.新的 : 张们)) {
|
|
892
|
+
if (!x || !x.路) continue
|
|
893
|
+
const 差 = Math.abs(现在() - Number(x.时 || 0))
|
|
894
|
+
if (差 > 45000) 内容.push({ type: 'text', text: '(下面这张是 ' + 多久前(x.时) + '的画面)' })
|
|
895
|
+
try {
|
|
896
|
+
内容.push(await 做图片块(x.路, '游戏画面'))
|
|
897
|
+
} catch (error) {}
|
|
898
|
+
}
|
|
899
|
+
try {
|
|
900
|
+
await agent.followup({
|
|
901
|
+
id: randomUUID(), role: 'user',
|
|
902
|
+
content: 内容,
|
|
903
|
+
source: { kind: 'fishbuddy' },
|
|
904
|
+
})
|
|
905
|
+
} catch (error) {
|
|
906
|
+
还回攒图(键, 张们) // ⭐ 0.7.3:没送出去就把图放回去,下次还能带
|
|
907
|
+
throw error
|
|
908
|
+
}
|
|
909
|
+
记账('游戏模式:主动看了一眼' + (内容.length > 1 ? ('(带 ' + (内容.length - 1) + ' 段图/说明)') : '(没有图)'))
|
|
910
|
+
} catch (error) {
|
|
911
|
+
记账('游戏模式搭话失败(' + 说(error) + ')')
|
|
912
|
+
}
|
|
913
|
+
})()
|
|
914
|
+
return true
|
|
915
|
+
} catch (error) {
|
|
916
|
+
return false
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const 可能搭话 = () => {
|
|
921
|
+
try {
|
|
922
|
+
// ⭐ 0.7.7:在打游戏就别走「一起看片子」那条搭话 —— 两套提示词会打架
|
|
923
|
+
if (游戏模式.开 && 现在() - (游戏模式.时 || 0) < 6 * 3600 * 1000) {
|
|
924
|
+
return
|
|
925
|
+
}
|
|
926
|
+
if (!陪看开着) {
|
|
927
|
+
// ⭐ 0.6.1:不在陪看,但可能在游戏模式 —— 那条路只跟「攒着」配合
|
|
928
|
+
try { 游戏搭话() } catch (error) {}
|
|
929
|
+
return
|
|
930
|
+
}
|
|
931
|
+
const 配置 = 读配置()
|
|
932
|
+
const 键 = 会话键(配置.会话)
|
|
933
|
+
if (!键) return
|
|
934
|
+
const 桶 = 字幕们.get(键)
|
|
935
|
+
const 行 = 桶 ? 桶.行 : []
|
|
936
|
+
const 有字幕 = 行.length > 0 && 现在() - (桶.最后 || 0) < 30000
|
|
937
|
+
if (!要搭话({
|
|
938
|
+
间隔秒: 挂件搭话秒() ?? 配置.随机搭话秒,
|
|
939
|
+
最多秒: 节奏('搭话最多秒', Number(配置.搭话最多秒) || 0),
|
|
940
|
+
概率: 配置.随机搭话概率,
|
|
941
|
+
有字幕,
|
|
942
|
+
上次: 上次搭话,
|
|
943
|
+
现在: 现在(),
|
|
944
|
+
浮: Math.random(), // ⭐ 1.0.3:每次重新掷,别像秒表
|
|
945
|
+
})) return
|
|
946
|
+
let agent = null
|
|
947
|
+
try {
|
|
948
|
+
agent = ctx.agents.get(配置.会话) || ctx.agents.get('session-' + 键)
|
|
949
|
+
} catch (error) {}
|
|
950
|
+
if (!agent) {
|
|
951
|
+
// ⭐ 1.0.3:以前这里静默 return,看起来就像"随机开口坏了" —— 现在留一句证据
|
|
952
|
+
if (!说过了没活着) {
|
|
953
|
+
说过了没活着 = true
|
|
954
|
+
记账('陪看:想自己开口,但绑的会话没开着(' + 配置.会话.slice(0, 20) + '…)—— 打开那间会话就好了')
|
|
955
|
+
}
|
|
956
|
+
return
|
|
957
|
+
}
|
|
958
|
+
说过了没活着 = false
|
|
959
|
+
// ⭐ 0.3.5:刚发过图(90 秒内)就让路 —— 不然「发图 → 立刻自己说一句」会连着说话
|
|
960
|
+
if (现在() < (下次自发最早 || 0)) return
|
|
961
|
+
上次搭话 = 现在()
|
|
962
|
+
上次自发 = 现在()
|
|
963
|
+
下次自发最早 = 掷冷却()
|
|
964
|
+
// ⭐ 0.2.4(2026-10-27):「不许硬凑」不能写成命令,
|
|
965
|
+
// 但得让它知道「安静着也是可以的」。只说这是什么、允许什么都不说。
|
|
966
|
+
// ⭐ 0.2.6:上一版列了四五条「什么时候开口」,那更像命令、条条框框太多。
|
|
967
|
+
// → 只留**这一句**:说明画面里那些字是什么。要不要说话,让它自己看着办。
|
|
968
|
+
const 文案 = '(你俩正一起看着,上面那些是片子里的字幕。)'
|
|
969
|
+
// ⭐ 0.2.1:**截了帧不马上发**,攒着,等她开口这一轮一起给她看;
|
|
970
|
+
// 看过的帧标成已发,下一轮不再重复(跟字幕一个道理)。
|
|
971
|
+
void (async () => {
|
|
972
|
+
try {
|
|
973
|
+
// ⭐ 0.3.1:不再单独附图 —— 帧已经排进字幕队列,白板这一轮会连字幕一起带上
|
|
974
|
+
// ⭐ 1.1.1:她开口的时候要带一张「当下」的画面(跟按住说话带图一样的口径)
|
|
975
|
+
const 图路 = await 问挂件要张图()
|
|
976
|
+
const 内容 = [{ type: 'text', text: 文案 }]
|
|
977
|
+
// ⚠ 0.7.7:陪看搭话**只带它自己那张「当下」的画面**。
|
|
978
|
+
// 以前这里会把「游戏模式攒的截图」也一起捞走 —— 于是游戏里攒的图
|
|
979
|
+
// 被挂到「你俩正一起看着片子」这句提示词下面发出去,口径全乱了。
|
|
980
|
+
if (图路) {
|
|
981
|
+
try {
|
|
982
|
+
内容.push(await 做图片块(图路, '一起看时的画面'))
|
|
983
|
+
} catch (error) {
|
|
984
|
+
记账('陪看:图没拼进去(' + 说(error) + ')')
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
await agent.followup({
|
|
988
|
+
id: randomUUID(), role: 'user',
|
|
989
|
+
content: 内容,
|
|
990
|
+
source: { kind: 'fishbuddy' },
|
|
991
|
+
})
|
|
992
|
+
记账('陪看:掷中了,让她自己开口说一句' + (内容.length > 1 ? '(带了一张图)' : ''))
|
|
993
|
+
} catch (error) {
|
|
994
|
+
记账('陪看:自己开口失败(' + 说(error) + ')')
|
|
995
|
+
}
|
|
996
|
+
})()
|
|
997
|
+
} catch (error) {}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const 白板心跳 = () => {
|
|
1001
|
+
try { 挂白板() } catch (error) {}
|
|
1002
|
+
try { 可能搭话() } catch (error) {}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// ---------- ③ 本机口子(挂件往这里发) ----------
|
|
1006
|
+
const 回 = (res, 码, o) => {
|
|
1007
|
+
try {
|
|
1008
|
+
res.statusCode = 码
|
|
1009
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
1010
|
+
res.end(JSON.stringify(o))
|
|
1011
|
+
} catch (error) {}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
const 读体 = async (req) => {
|
|
1015
|
+
let 串 = ''
|
|
1016
|
+
try {
|
|
1017
|
+
for await (const 块 of req) 串 += 块
|
|
1018
|
+
} catch (error) {}
|
|
1019
|
+
try {
|
|
1020
|
+
return JSON.parse(串 || '{}')
|
|
1021
|
+
} catch (error) {
|
|
1022
|
+
return {}
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// ⚠ 抽帧的处理器在下面才定义(要用帧账/帧目录),这里先留个口子:
|
|
1027
|
+
// 程序 POST 的是 `${dsh_port}/hands/frame`(跟字幕同一个口),
|
|
1028
|
+
// 所以必须挂在**这个 8777 服务**上 —— 0.2.1/0.2.2 只把它挂到了 DSH 的
|
|
1029
|
+
// `/dsh-fish-buddy/frame`,程序打 /hands/frame,结果一律 404(0.2.3 修的)。
|
|
1030
|
+
let 处理帧 = null
|
|
1031
|
+
/**
|
|
1032
|
+
* ⭐ 0.6.0:**攒图** —— 游戏截图「攒着等说话」那一档用。
|
|
1033
|
+
* 键 = 会话键;值 = [{ 路, 时 }],最多 3 张(新的挤掉最旧的)。
|
|
1034
|
+
* 图只存本地、**不叫醒**;等她说话(或她自己搭话)时,一次带过去。
|
|
1035
|
+
*/
|
|
1036
|
+
// ⭐ 0.6.1:挂件会告诉我们「现在在不在游戏模式」—— 游戏模式也要能主动搭话
|
|
1037
|
+
const 游戏模式 = { 开: false, 时: 0 }
|
|
1038
|
+
const 攒图 = new Map()
|
|
1039
|
+
// ⭐ 0.7.2:**不限制张数**(以前的「最多 3 张、新的挤掉最旧的」是错的 ——
|
|
1040
|
+
// 挤掉的图等于白截了)。攒着就一直留着,等她说话 / 她自己看一眼时**全部**带过去。
|
|
1041
|
+
// ⚠ 只有个上限 60 张防跑飞(一小时 30 秒一张也就 120 张,实际轮不到)。
|
|
1042
|
+
const 攒图上限 = 60
|
|
1043
|
+
const 收一张攒图 = (键, 路, 时) => {
|
|
1044
|
+
if (!键) return
|
|
1045
|
+
const 排 = 攒图.get(键) || []
|
|
1046
|
+
排.push({ 路, 时: Number(时) || 现在() })
|
|
1047
|
+
while (排.length > 攒图上限) 排.shift()
|
|
1048
|
+
攒图.set(键, 排)
|
|
1049
|
+
}
|
|
1050
|
+
const 取出攒图 = (键) => {
|
|
1051
|
+
const 排 = (攒图.get(键) || []).slice()
|
|
1052
|
+
攒图.delete(键)
|
|
1053
|
+
return 排
|
|
1054
|
+
}
|
|
1055
|
+
/** ⭐ 0.7.3:发失败了就把图**放回去**(发成功才真的算看过) */
|
|
1056
|
+
const 还回攒图 = (键, 张们) => {
|
|
1057
|
+
if (!键 || !Array.isArray(张们) || !张们.length) return
|
|
1058
|
+
const 排 = (张们 || []).filter((x) => x && x.路)
|
|
1059
|
+
if (!排.length) return
|
|
1060
|
+
const 现 = 攒图.get(键) || []
|
|
1061
|
+
攒图.set(键, 排.concat(现))
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
ctx.effect(() => {
|
|
1065
|
+
const 配置 = 读配置()
|
|
1066
|
+
const 口 = createServer(async (req, res) => {
|
|
1067
|
+
const 路径 = String((req.url || '').split('?')[0])
|
|
1068
|
+
// 陪看:挂件每出来一句字幕就推过来(不当作消息,进白板)
|
|
1069
|
+
if (路径 === '/hands/subtitle') {
|
|
1070
|
+
if (req.method !== 'POST') return 回(res, 405, { ok: false, 说: '要 POST' })
|
|
1071
|
+
const 体 = await 读体(req)
|
|
1072
|
+
const 配置 = 读配置()
|
|
1073
|
+
记住搭话秒(体.随机搭话秒)
|
|
1074
|
+
记住节奏(体)
|
|
1075
|
+
const 键 = 会话键(体.session || 配置.会话)
|
|
1076
|
+
if (!键) return 回(res, 409, { ok: false, 说: '还没绑定会话 —— 去设置里选一间' })
|
|
1077
|
+
const 行们 = Array.isArray(体.lines) ? 体.lines : [体.text]
|
|
1078
|
+
let 收了几条 = 0
|
|
1079
|
+
for (const 一 of 行们) {
|
|
1080
|
+
const 文 = typeof 一 === 'string' ? 一 : (一 && (一.text || 一.文))
|
|
1081
|
+
if (!文) continue
|
|
1082
|
+
收一句字幕(键, 文, 一 && 一.ts)
|
|
1083
|
+
收了几条 += 1
|
|
1084
|
+
}
|
|
1085
|
+
挂白板() // 有字幕了就把白板挂上(没挂的话)
|
|
1086
|
+
return 回(res, 200, { ok: true, 说: '收到 ' + 收了几条 + ' 句', 缓存: (字幕们.get(键) || {}).行 ? 字幕们.get(键).行.length : 0 })
|
|
1087
|
+
}
|
|
1088
|
+
// ⭐ 0.4.4:按住说话 —— 一句话(可能还带一张图)一起发进会话
|
|
1089
|
+
if (路径 === '/hands/talk') {
|
|
1090
|
+
if (req.method !== 'POST') return 回(res, 405, { ok: false, 说: '要 POST' })
|
|
1091
|
+
const 体2 = await 读体(req)
|
|
1092
|
+
const 话 = String(体2.text || 体2.正文 || '').trim()
|
|
1093
|
+
const 图2 = String(体2.图片 || '')
|
|
1094
|
+
if (!话 && !图2) return 回(res, 400, { ok: false, 说: '空的' })
|
|
1095
|
+
状态.收到条数 += 1
|
|
1096
|
+
状态.最后收到 = 现在()
|
|
1097
|
+
// ⭐ 0.6.0:说话时把**攒着的截图**一起带过去(开麦说话时图也会跟着送)
|
|
1098
|
+
let 攒的 = []
|
|
1099
|
+
try {
|
|
1100
|
+
攒的 = 取出攒图(会话键(读配置().会话))
|
|
1101
|
+
} catch (error) {}
|
|
1102
|
+
const 张们 = 攒的.slice()
|
|
1103
|
+
let 路2 = ''
|
|
1104
|
+
if (图2) {
|
|
1105
|
+
try {
|
|
1106
|
+
mkdirSync(帧目录, { recursive: true })
|
|
1107
|
+
路2 = join(帧目录, '说-' + Date.now() + '.jpg')
|
|
1108
|
+
writeFileSync(路2, Buffer.from(图2, 'base64'))
|
|
1109
|
+
也许清截图()
|
|
1110
|
+
张们.push({ 路: 路2, 时: 现在() })
|
|
1111
|
+
} catch (error) {
|
|
1112
|
+
return 回(res, 500, { ok: false, 说: '带图发失败:' + 说(error) })
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
if (!张们.length) {
|
|
1116
|
+
const 结果0 = await 送进会话(话, 体2.session)
|
|
1117
|
+
return 回(res, 结果0.ok ? 200 : 409, 结果0)
|
|
1118
|
+
}
|
|
1119
|
+
try {
|
|
1120
|
+
// 有攒着的自动截图 → 别冒充她;只有热键那张 → 就是她发的
|
|
1121
|
+
const 结果1 = await 发说话那一轮(张们, 话, 攒的.length ? 'fishbuddy' : 'user')
|
|
1122
|
+
// ⚠ 发失败时发说话那一轮 会「返回一句话」不是抛错,这里按字面判断
|
|
1123
|
+
if (/失败|没开着|绑会话|总开关/.test(String(结果1))) {
|
|
1124
|
+
还回攒图(会话键(读配置().会话), 张们)
|
|
1125
|
+
return 回(res, 200, { ok: false, 说: 结果1 })
|
|
1126
|
+
}
|
|
1127
|
+
return 回(res, 200, { ok: true, 说: 结果1, 带图: 张们.length })
|
|
1128
|
+
} catch (error) {
|
|
1129
|
+
还回攒图(会话键(读配置().会话), 张们)
|
|
1130
|
+
return 回(res, 500, { ok: false, 说: '带图发失败:' + 说(error) })
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
// ⭐ 1.1.1:陪看停了 —— 把还没发出去的字幕清掉,别在后面的轮次里继续往会话里灌
|
|
1134
|
+
if (路径 === '/hands/watch') {
|
|
1135
|
+
if (req.method !== 'POST') return 回(res, 405, { ok: false, 说: '要 POST' })
|
|
1136
|
+
const 体0 = await 读体(req)
|
|
1137
|
+
// ⚠ 1.1.1:键名同时认 `watch`(ASCII)和 `开` —— 有些客户端在传送时会把中文键弄坏
|
|
1138
|
+
const 开着 = !(体0 && (体0.watch === false || 体0.开 === false))
|
|
1139
|
+
陪看开着 = 开着
|
|
1140
|
+
if (!开着) {
|
|
1141
|
+
try {
|
|
1142
|
+
清空字幕桶()
|
|
1143
|
+
} catch (error) {}
|
|
1144
|
+
记账('陪看停了:攒着没发的字幕已经清掉,不再往会话里灌')
|
|
1145
|
+
}
|
|
1146
|
+
return 回(res, 200, { ok: true, 说: 开着 ? '知道了' : '字幕清了' })
|
|
1147
|
+
}
|
|
1148
|
+
// 挂件那边对外的宽接口:/hands/say(老)和 /say(短)
|
|
1149
|
+
if (路径 === '/hands/mode') {
|
|
1150
|
+
if (req.method !== 'POST') return 回(res, 405, { ok: false, 说: '要 POST' })
|
|
1151
|
+
const 体3 = await 读体(req)
|
|
1152
|
+
// ⭐ 0.7.5:挂件重开时把攒图清掉 —— 那些图是上一次的,重开就不该还挂着
|
|
1153
|
+
if (体3 && 体3.重置攒图) {
|
|
1154
|
+
try {
|
|
1155
|
+
const 键1 = 会话键(读配置().会话)
|
|
1156
|
+
const 清掉 = (攒图.get(键1) || []).length
|
|
1157
|
+
攒图.delete(键1)
|
|
1158
|
+
return 回(res, 200, { ok: true, 说: '攒图清了(' + 清掉 + ' 张)' })
|
|
1159
|
+
} catch (error) {}
|
|
1160
|
+
}
|
|
1161
|
+
游戏模式.开 = !(体3 && (体3.游戏 === false || 体3.开 === false))
|
|
1162
|
+
游戏模式.时 = 现在()
|
|
1163
|
+
// ⭐ 1.7.1:挂件把「游戏搭话秒」也报上来了 —— 存进「挂件报的节奏」,
|
|
1164
|
+
// 跟其它节奏值走同一条路(插件不自己存一份,省得两边改不到一起)。
|
|
1165
|
+
if (体3 && 体3.搭话秒 !== undefined) {
|
|
1166
|
+
const 数 = Number(体3.搭话秒)
|
|
1167
|
+
if (Number.isFinite(数) && 数 >= 0) 挂件报的节奏['游戏搭话秒'] = 数
|
|
1168
|
+
}
|
|
1169
|
+
return 回(res, 200, { ok: true, 说: 游戏模式.开 ? '好,游戏模式' : '好,不在游戏模式了' })
|
|
1170
|
+
}
|
|
1171
|
+
if (路径 === '/hands/frame') {
|
|
1172
|
+
if (req.method !== 'POST') return 回(res, 405, { ok: false, 说: '要 POST' })
|
|
1173
|
+
if (!处理帧) return 回(res, 503, { ok: false, 说: '还没准备好,等一秒再发' })
|
|
1174
|
+
return 处理帧(req, res)
|
|
1175
|
+
}
|
|
1176
|
+
if (路径 !== '/hands/say' && 路径 !== '/say') {
|
|
1177
|
+
if (路径 === '/hands/ping') return 回(res, 200, { ok: true, 说: '鱼搭子在这儿' })
|
|
1178
|
+
return 回(res, 404, { ok: false, 说: '没这个地址' })
|
|
1179
|
+
}
|
|
1180
|
+
if (req.method !== 'POST') return 回(res, 405, { ok: false, 说: '要 POST' })
|
|
1181
|
+
const 体 = await 读体(req)
|
|
1182
|
+
记住搭话秒(体.随机搭话秒)
|
|
1183
|
+
记住节奏(体)
|
|
1184
|
+
const 正文 = String(体.text || 体.正文 || '').trim()
|
|
1185
|
+
if (!正文) return 回(res, 400, { ok: false, 说: '空的一句话' })
|
|
1186
|
+
// ⭐ 0.7.5:**不带图说话也要把攒着的截图带出去** ——
|
|
1187
|
+
// 以前只有 /hands/talk(带图说话)会取攒图,所以按一次只说话键,攒的图一张都没发。
|
|
1188
|
+
try {
|
|
1189
|
+
const 键0 = 会话键(读配置().会话)
|
|
1190
|
+
const 攒的0 = 取出攒图(键0)
|
|
1191
|
+
if (攒的0.length) {
|
|
1192
|
+
const 结果 = await 发说话那一轮(攒的0, 正文)
|
|
1193
|
+
if (/失败|没开着|绑会话|总开关/.test(String(结果))) {
|
|
1194
|
+
还回攒图(键0, 攒的0)
|
|
1195
|
+
return 回(res, 200, { ok: false, 说: 结果 })
|
|
1196
|
+
}
|
|
1197
|
+
return 回(res, 200, { ok: true, 说: 结果, 带图: 攒的0.length })
|
|
1198
|
+
}
|
|
1199
|
+
} catch (error) {}
|
|
1200
|
+
状态.收到条数 += 1
|
|
1201
|
+
状态.最后收到 = 现在()
|
|
1202
|
+
const 结果 = await 送进会话(正文, 体.session)
|
|
1203
|
+
return 回(res, 结果.ok ? 200 : 409, 结果)
|
|
1204
|
+
})
|
|
1205
|
+
|
|
1206
|
+
口.on('error', (error) => {
|
|
1207
|
+
状态.口子 = { 起没起: false, 端口: 配置.端口, 错: 说(error) }
|
|
1208
|
+
if (String(error && error.code) === 'EADDRINUSE') {
|
|
1209
|
+
记账('端口 ' + 配置.端口 + ' 被别人占了 —— 换个端口,或关掉占它的程序')
|
|
1210
|
+
} else {
|
|
1211
|
+
记账('口子起不来:' + 说(error))
|
|
1212
|
+
}
|
|
1213
|
+
})
|
|
1214
|
+
|
|
1215
|
+
口.listen(配置.端口, '127.0.0.1', () => {
|
|
1216
|
+
状态.口子 = { 起没起: true, 端口: 配置.端口, 错: '' }
|
|
1217
|
+
记账('本机口子开着:127.0.0.1:' + 配置.端口 + '/hands/say')
|
|
1218
|
+
})
|
|
1219
|
+
|
|
1220
|
+
return () => {
|
|
1221
|
+
try {
|
|
1222
|
+
口.close()
|
|
1223
|
+
} catch (error) {}
|
|
1224
|
+
状态.口子 = { 起没起: false, 端口: 配置.端口, 错: '' }
|
|
1225
|
+
}
|
|
1226
|
+
})
|
|
1227
|
+
|
|
1228
|
+
ctx.effect(() => {
|
|
1229
|
+
const 做 = () => { try { 白板心跳() } catch (error) {} }
|
|
1230
|
+
// ⭐ 0.5.1:宿主的 `timer` 服务不再写进 inject(有的宿主没有),这里**现取现用**:
|
|
1231
|
+
// 摸 `ctx.interval` 会直接抛 `cannot get property "timer" without inject`,整插件激活失败。
|
|
1232
|
+
const 计 = 取服务('timer')
|
|
1233
|
+
if (计 && typeof 计.interval === 'function') {
|
|
1234
|
+
const 定 = 计.interval(做, 5000)
|
|
1235
|
+
return () => { try { if (typeof 定 === 'function') 定() } catch (error) {} }
|
|
1236
|
+
}
|
|
1237
|
+
const 定2 = setInterval(做, 5000)
|
|
1238
|
+
return () => clearInterval(定2)
|
|
1239
|
+
}, 'fishbuddy: 字幕白板心跳')
|
|
1240
|
+
|
|
1241
|
+
// ---------- 检查更新(⭐ 0.7.0)----------
|
|
1242
|
+
/**
|
|
1243
|
+
* 查两样:**插件自己**(仓库 main 上的 package.json)和**挂件 exe**(最新 release 的 tag)。
|
|
1244
|
+
*
|
|
1245
|
+
* ⚠ 插件对 `link:` 装的人永远是「本地最新」,所以那种情况直接标「本地开发版」,
|
|
1246
|
+
* 不催他去改版本号。
|
|
1247
|
+
*/
|
|
1248
|
+
const 比版本 = (a, b) => {
|
|
1249
|
+
const 拆 = (x) => String(x || '').replace(/^v/i, '').split(/[.+-]/).map((n) => Number(n) || 0)
|
|
1250
|
+
const A = 拆(a)
|
|
1251
|
+
const B = 拆(b)
|
|
1252
|
+
for (let i = 0; i < Math.max(A.length, B.length); i++) {
|
|
1253
|
+
const d = (A[i] || 0) - (B[i] || 0)
|
|
1254
|
+
if (d) return d > 0 ? 1 : -1
|
|
1255
|
+
}
|
|
1256
|
+
return 0
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
const 检查更新 = async () => {
|
|
1260
|
+
const 出 = { ok: true, 插件: {}, 挂件: {} }
|
|
1261
|
+
const 仓库 = 'Yuuyuko-uu/dsh-fish-buddy'
|
|
1262
|
+
// ① 插件:本地 package.json vs 仓库 main 的 package.json
|
|
1263
|
+
let 本地插件 = ''
|
|
1264
|
+
try {
|
|
1265
|
+
本地插件 = String(JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8')).version || '')
|
|
1266
|
+
} catch (error) {}
|
|
1267
|
+
出.插件.本地 = 本地插件
|
|
1268
|
+
try {
|
|
1269
|
+
const r = await fetch('https://raw.githubusercontent.com/' + 仓库 + '/main/package.json',
|
|
1270
|
+
{ headers: { 'user-agent': 'dsh-fish-buddy' }, signal: AbortSignal.timeout(8000) })
|
|
1271
|
+
if (r.ok) {
|
|
1272
|
+
const j = await r.json()
|
|
1273
|
+
出.插件.最新 = String(j.version || '')
|
|
1274
|
+
}
|
|
1275
|
+
} catch (error) {
|
|
1276
|
+
出.插件.错 = String((error && error.message) || error)
|
|
1277
|
+
}
|
|
1278
|
+
出.插件.有新版 = Boolean(出.插件.最新 && 本地插件 && 比版本(出.插件.最新, 本地插件) > 0)
|
|
1279
|
+
出.插件.本地开发版 = Boolean(出.插件.最新 && 本地插件 && 比版本(本地插件, 出.插件.最新) > 0)
|
|
1280
|
+
|
|
1281
|
+
// ② 挂件:问 8778 它自己是哪个版本;最新版看 release
|
|
1282
|
+
try {
|
|
1283
|
+
const 端口 = Number(读配置().回调 && /:(\d+)/.exec(String(读配置().回调))?.[1]) || 8778
|
|
1284
|
+
const r = await fetch('http://127.0.0.1:' + 端口 + '/hands/devices', { signal: AbortSignal.timeout(3000) })
|
|
1285
|
+
if (r.ok) {
|
|
1286
|
+
const j = await r.json()
|
|
1287
|
+
出.挂件.本地 = String((j && j.版本) || '')
|
|
1288
|
+
}
|
|
1289
|
+
} catch (error) {
|
|
1290
|
+
出.挂件.错 = '问不到挂件(它没开着?)'
|
|
1291
|
+
}
|
|
1292
|
+
try {
|
|
1293
|
+
const r = await fetch('https://api.github.com/repos/' + 仓库 + '/releases/latest',
|
|
1294
|
+
{ headers: { 'user-agent': 'dsh-fish-buddy', accept: 'application/vnd.github+json' }, signal: AbortSignal.timeout(8000) })
|
|
1295
|
+
if (r.ok) {
|
|
1296
|
+
const j = await r.json()
|
|
1297
|
+
出.挂件.最新 = String(j.tag_name || '')
|
|
1298
|
+
const 资产 = (j.assets || []).find((a) => /\.exe$/i.test(String(a.name || '')))
|
|
1299
|
+
if (资产) 出.挂件.下载 = String(资产.browser_download_url || '')
|
|
1300
|
+
}
|
|
1301
|
+
} catch (error) {
|
|
1302
|
+
出.挂件.错2 = String((error && error.message) || error)
|
|
1303
|
+
}
|
|
1304
|
+
出.挂件.有新版 = Boolean(出.挂件.最新 && 出.挂件.本地 && 比版本(出.挂件.最新, 出.挂件.本地) > 0)
|
|
1305
|
+
if (!出.挂件.本地) 出.挂件.说明 = '挂件没开着,量不到它现在是什么版本'
|
|
1306
|
+
return 出
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// ---------- 设置页用的路由 ----------
|
|
1310
|
+
const 路线 = (路径, handler) =>
|
|
1311
|
+
ctx.effect(() =>
|
|
1312
|
+
ctx.webServer.register({
|
|
1313
|
+
kind: 'exact',
|
|
1314
|
+
path: 路径,
|
|
1315
|
+
handler: async (req, res) => {
|
|
1316
|
+
try {
|
|
1317
|
+
await handler(req, res)
|
|
1318
|
+
} catch (error) {
|
|
1319
|
+
回(res, 200, { ok: false, 说: 说(error) })
|
|
1320
|
+
}
|
|
1321
|
+
},
|
|
1322
|
+
}),
|
|
1323
|
+
)
|
|
1324
|
+
|
|
1325
|
+
// ---------- 挂件在哪(设置页显示 + 一键打开) ----------
|
|
1326
|
+
const 找挂件 = () => {
|
|
1327
|
+
const 配置 = 读配置()
|
|
1328
|
+
const 试 = []
|
|
1329
|
+
if (配置.挂件路径) 试.push(String(配置.挂件路径))
|
|
1330
|
+
试.push(join(目录, '鱼搭子', '鱼搭子.exe'))
|
|
1331
|
+
试.push(join(homedir(), 'Desktop', '鱼搭子.exe'))
|
|
1332
|
+
for (const p of 试) {
|
|
1333
|
+
try {
|
|
1334
|
+
if (p && existsSync(p)) return p
|
|
1335
|
+
} catch (error) {}
|
|
1336
|
+
}
|
|
1337
|
+
return ''
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
// ⭐ 0.4.7:设置页那个「下载挂件」按钮
|
|
1341
|
+
// ⭐ 0.7.0:检查更新(插件 + 挂件)
|
|
1342
|
+
路线(`${路由前缀}/update-check`, async (req, res) => {
|
|
1343
|
+
try {
|
|
1344
|
+
return 回(res, 200, await 检查更新())
|
|
1345
|
+
} catch (error) {
|
|
1346
|
+
return 回(res, 200, { ok: false, 说: '查不了:' + 说(error) })
|
|
1347
|
+
}
|
|
1348
|
+
})
|
|
1349
|
+
|
|
1350
|
+
路线(`${路由前缀}/fetch`, async (req, res) => {
|
|
1351
|
+
const r = await 下载挂件()
|
|
1352
|
+
return 回(res, 200, r)
|
|
1353
|
+
})
|
|
1354
|
+
|
|
1355
|
+
路线(`${路由前缀}/launch`, async (req, res) => {
|
|
1356
|
+
const 路径 = 找挂件()
|
|
1357
|
+
if (!路径) return 回(res, 200, { ok: false, 说: '没找到挂件程序 —— 在下面填一下它的位置(那个 exe)' })
|
|
1358
|
+
try {
|
|
1359
|
+
const 子 = spawn(路径, [], { detached: true, stdio: 'ignore', windowsHide: false })
|
|
1360
|
+
// ⚠ 0.7.4:**必须挂 'error'** —— 打开失败(比如那个 exe 要求管理员、DSH 却是普通权限)
|
|
1361
|
+
// 子进程会发 error 事件,没人接就会把整个 Node 进程(DSH)带下去。
|
|
1362
|
+
子.on('error', (错) => { 记账('挂件没打开:' + 说(错) + '(要管理员的话,自己右键用管理员启动它)') })
|
|
1363
|
+
子.unref()
|
|
1364
|
+
记账('把挂件打开了:' + 路径)
|
|
1365
|
+
return 回(res, 200, { ok: true, 说: '挂件开了,看屏幕右边' })
|
|
1366
|
+
} catch (error) {
|
|
1367
|
+
return 回(res, 200, { ok: false, 说: '打不开:' + 说(error) })
|
|
1368
|
+
}
|
|
1369
|
+
})
|
|
1370
|
+
|
|
1371
|
+
路线(`${路由前缀}/state`, async (req, res) => {
|
|
1372
|
+
const 配置 = 读配置()
|
|
1373
|
+
const 清单 = 会话清单()
|
|
1374
|
+
const 挂件 = 找挂件()
|
|
1375
|
+
const 多久没说话 = 状态.最后收到 ? Math.round((现在() - 状态.最后收到) / 1000) : -1
|
|
1376
|
+
return 回(res, 200, {
|
|
1377
|
+
ok: true,
|
|
1378
|
+
配置: {
|
|
1379
|
+
总开关: 配置.总开关, 端口: 配置.端口, 会话: 配置.会话, 回调: 配置.回调,
|
|
1380
|
+
带上来源: 配置.带上来源, 挂件路径: 配置.挂件路径, 下载地址: 配置.下载地址,
|
|
1381
|
+
自动开挂件: 配置.自动开挂件 !== false,
|
|
1382
|
+
},
|
|
1383
|
+
会话清单: 清单.map((x) => ({ 值: x.id, 名: (x.标题 || '(没标题)') + ' · ' + x.id.slice(-6) })),
|
|
1384
|
+
口子: 状态.口子,
|
|
1385
|
+
挂件: { 找到: !!挂件, 路径: 挂件, 最后说话秒前: 多久没说话 },
|
|
1386
|
+
下载: 状态.下载,
|
|
1387
|
+
搭话: {
|
|
1388
|
+
秒: 挂件搭话秒() === null ? 配置.随机搭话秒 : 挂件搭话秒(),
|
|
1389
|
+
概率: 配置.随机搭话概率,
|
|
1390
|
+
},
|
|
1391
|
+
'收到条数': 状态.收到条数,
|
|
1392
|
+
'送出条数': 状态.送出条数,
|
|
1393
|
+
递回条数: 状态.递回条数,
|
|
1394
|
+
最后回话: 状态.最后回话,
|
|
1395
|
+
字幕: (() => {
|
|
1396
|
+
try {
|
|
1397
|
+
const 键 = 会话键(配置.会话)
|
|
1398
|
+
const 桶 = 键 ? 字幕们.get(键) : null
|
|
1399
|
+
const 行 = 桶 ? 桶.行 : []
|
|
1400
|
+
return { 条数: 行.length, 滚掉: 桶 ? 桶.滚掉 : 0, 丢: 桶 ? 桶.丢 : 0, 已发到: 桶 ? 桶.已发到 : 0, 待发: 桶 ? Math.max(0, 桶.行.filter((x) => x.序 > 桶.已发到).length) : 0, 最后: 桶 ? 桶.最后 : 0, 白板挂着: 白板挂着.has(键) }
|
|
1401
|
+
} catch (error) {
|
|
1402
|
+
return { 条数: 0, 滚掉: 0, 丢: 0, 已发到: 0, 待发: 0, 最后: 0, 白板挂着: false }
|
|
1403
|
+
}
|
|
1404
|
+
})(),
|
|
1405
|
+
日志: 状态.日志.slice(0, 40),
|
|
1406
|
+
})
|
|
1407
|
+
})
|
|
1408
|
+
|
|
1409
|
+
路线(`${路由前缀}/config`, async (req, res) => {
|
|
1410
|
+
const 体 = await 读体(req)
|
|
1411
|
+
const 新 = 写配置({ ...读配置(), ...(体 && typeof 体 === 'object' ? 体 : {}) })
|
|
1412
|
+
记账('设置改了:绑会话=' + (新.会话 || '(空)') + '、端口=' + 新.端口 + '、回调=' + (新.回调 || '(空)'))
|
|
1413
|
+
return 回(res, 200, { ok: true, 说: '存好了(改端口要重启 DSH 才生效)', 配置: 新 })
|
|
1414
|
+
})
|
|
1415
|
+
|
|
1416
|
+
路线(`${路由前缀}/sessions`, async (req, res) => {
|
|
1417
|
+
return 回(res, 200, { ok: true, 清单: 会话清单() })
|
|
1418
|
+
})
|
|
1419
|
+
|
|
1420
|
+
// ---------- 麦克风:真正的设备在挂件那头,这里只做转发 ----------
|
|
1421
|
+
// 为什么要绕:只有本机进程能枚举/打开麦克风;DSH 是网页,拿不到。
|
|
1422
|
+
const 挂件基址 = () => {
|
|
1423
|
+
const 址 = String(读配置().回调 || '').trim()
|
|
1424
|
+
if (!址) return ''
|
|
1425
|
+
try {
|
|
1426
|
+
const u = new URL(址)
|
|
1427
|
+
return u.origin
|
|
1428
|
+
} catch (error) {
|
|
1429
|
+
return ''
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
路线(`${路由前缀}/mic`, async (req, res) => {
|
|
1434
|
+
const 基 = 挂件基址()
|
|
1435
|
+
if (!基) return 回(res, 200, { ok: false, 说: '还没填「回话递回」地址 —— 挂件的口子不知道在哪' })
|
|
1436
|
+
try {
|
|
1437
|
+
const r = await fetch(基 + '/hands/devices')
|
|
1438
|
+
const 数据 = await r.json()
|
|
1439
|
+
return 回(res, 200, { ok: true, ...数据 })
|
|
1440
|
+
} catch (error) {
|
|
1441
|
+
return 回(res, 200, { ok: false, 说: '挂件没应声(它开着了没?):' + 说(error), 设备: [] })
|
|
1442
|
+
}
|
|
1443
|
+
})
|
|
1444
|
+
|
|
1445
|
+
路线(`${路由前缀}/mic/set`, async (req, res) => {
|
|
1446
|
+
const 基 = 挂件基址()
|
|
1447
|
+
if (!基) return 回(res, 200, { ok: false, 说: '还没填「回话递回」地址' })
|
|
1448
|
+
const 体 = await 读体(req)
|
|
1449
|
+
const 号 = 体 && (体.index === 0 || 体.index) ? 体.index : null
|
|
1450
|
+
const 名 = String((体 && 体.name) || '')
|
|
1451
|
+
try {
|
|
1452
|
+
const r = await fetch(基 + '/hands/device', {
|
|
1453
|
+
method: 'POST',
|
|
1454
|
+
headers: { 'content-type': 'application/json' },
|
|
1455
|
+
body: JSON.stringify({ index: 号, name: 名 }),
|
|
1456
|
+
})
|
|
1457
|
+
const 数据 = await r.json()
|
|
1458
|
+
记账('麦克风换成:' + (名 || '系统默认'))
|
|
1459
|
+
return 回(res, 200, { ok: !!(数据 && 数据.ok), ...数据 })
|
|
1460
|
+
} catch (error) {
|
|
1461
|
+
return 回(res, 200, { ok: false, 说: '换不了(挂件没应声):' + 说(error) })
|
|
1462
|
+
}
|
|
1463
|
+
})
|
|
1464
|
+
|
|
1465
|
+
路线(`${路由前缀}/last-reply`, async (req, res) => {
|
|
1466
|
+
return 回(res, 200, { ok: true, ...状态.最后回话 })
|
|
1467
|
+
})
|
|
1468
|
+
|
|
1469
|
+
// ---------- ③ 陪看时「时不时抽一帧」给她看(0.3.0)----------
|
|
1470
|
+
// 看番/打游戏时随机抽几帧,带时间点 + 那会儿的台词;2K 原图太大,程序那边先压好再发。
|
|
1471
|
+
const 帧目录 = join(目录, 'fish-buddy-frames')
|
|
1472
|
+
|
|
1473
|
+
// ⭐ 0.7.9:截图自动清理。十分钟最多查一次,别每次写图都把目录扫一遍。
|
|
1474
|
+
let 上次清截图 = 0
|
|
1475
|
+
const 也许清截图 = () => {
|
|
1476
|
+
const 配置 = 读配置()
|
|
1477
|
+
const 保留天 = Number(配置.截图保留天) || 0
|
|
1478
|
+
const 最多张 = Number(配置.截图最多张) || 0
|
|
1479
|
+
if (!保留天 && !最多张) return
|
|
1480
|
+
if (现在() - 上次清截图 < 10 * 60 * 1000) return
|
|
1481
|
+
上次清截图 = 现在()
|
|
1482
|
+
try {
|
|
1483
|
+
const 果 = 清旧截图(帧目录, 保留天, 最多张)
|
|
1484
|
+
if (果.删了 > 0) 记账('截图清理:删了 ' + 果.删了 + ' 张,还剩 ' + 果.剩 + ' 张')
|
|
1485
|
+
} catch (error) {
|
|
1486
|
+
记账('截图清理没成:' + 说(error))
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
也许清截图() // 插件一起来先清一次(上次清截图是 0,这一下一定会真的跑)
|
|
1490
|
+
|
|
1491
|
+
/** ⭐ 0.7.10:报一下现在攒了多少张、多大 —— 设置页要显示,也得能手动清空。 */
|
|
1492
|
+
const 截图家底 = () => {
|
|
1493
|
+
const 果 = { 张数: 0, 兆: 0, 目录: 帧目录 }
|
|
1494
|
+
try {
|
|
1495
|
+
for (const 名 of readdirSync(帧目录)) {
|
|
1496
|
+
if (!/\.(jpg|jpeg|png)$/i.test(String(名))) continue
|
|
1497
|
+
try {
|
|
1498
|
+
const 资 = statSync(join(帧目录, 名))
|
|
1499
|
+
if (!资.isFile()) continue
|
|
1500
|
+
果.张数 += 1
|
|
1501
|
+
果.兆 += 资.size
|
|
1502
|
+
} catch (error) { /* 刚好被删掉,跳过 */ }
|
|
1503
|
+
}
|
|
1504
|
+
} catch (error) { /* 目录还没有 = 0 张 */ }
|
|
1505
|
+
果.兆 = Math.round((果.兆 / 1048576) * 10) / 10
|
|
1506
|
+
return 果
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
路线(`${路由前缀}/shots`, async (req, res) => 回(res, 200, { ok: true, ...截图家底() }))
|
|
1510
|
+
|
|
1511
|
+
路线(`${路由前缀}/shots/clear`, async (req, res) => {
|
|
1512
|
+
let 删了 = 0
|
|
1513
|
+
try {
|
|
1514
|
+
for (const 名 of readdirSync(帧目录)) {
|
|
1515
|
+
if (!/\.(jpg|jpeg|png)$/i.test(String(名))) continue
|
|
1516
|
+
try {
|
|
1517
|
+
unlinkSync(join(帧目录, 名))
|
|
1518
|
+
删了 += 1
|
|
1519
|
+
} catch (error) { /* 删不掉就算了 */ }
|
|
1520
|
+
}
|
|
1521
|
+
} catch (error) { /* 目录不存在 */ }
|
|
1522
|
+
记账('手动清空截图:删了 ' + 删了 + ' 张')
|
|
1523
|
+
return 回(res, 200, { ok: true, 删了, ...截图家底() })
|
|
1524
|
+
})
|
|
1525
|
+
/**
|
|
1526
|
+
* ⭐ 1.0.5:图片块要**先存成附件**,再引用它。
|
|
1527
|
+
* 宿主只认 `{ type:'image', attachment: { attachmentId, … } }`。
|
|
1528
|
+
*/
|
|
1529
|
+
|
|
1530
|
+
/** 请挂件把几张拼成一竖条,返回 base64;不成返回空串 */
|
|
1531
|
+
const 请挂件拼图 = async (张们) => {
|
|
1532
|
+
if (!Array.isArray(张们) || 张们.length < 2) return ''
|
|
1533
|
+
const 控 = new AbortController()
|
|
1534
|
+
const 停 = setTimeout(() => 控.abort(), 15000)
|
|
1535
|
+
try {
|
|
1536
|
+
const r = await fetch(挂件基址() + '/hands/stitch', {
|
|
1537
|
+
method: 'POST',
|
|
1538
|
+
headers: { 'content-type': 'application/json' },
|
|
1539
|
+
body: JSON.stringify({ 帧们: 张们.map((x) => ({ 路径: x.路, 标: '· ' + 多久前(x.时) })) }),
|
|
1540
|
+
signal: 控.signal,
|
|
1541
|
+
})
|
|
1542
|
+
clearTimeout(停)
|
|
1543
|
+
if (!r.ok) return ''
|
|
1544
|
+
const j = await r.json()
|
|
1545
|
+
if (!j || !j.ok || !j.图片) {
|
|
1546
|
+
记账('拼图没成:' + String((j && j.说) || '挂件没回图片'))
|
|
1547
|
+
return ''
|
|
1548
|
+
}
|
|
1549
|
+
return String(j.图片)
|
|
1550
|
+
} catch (error) {
|
|
1551
|
+
记账('拼图请求失败:' + String((error && error.message) || error))
|
|
1552
|
+
return ''
|
|
1553
|
+
} finally {
|
|
1554
|
+
clearTimeout(停)
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
const 做图片块从字节 = async (字节, 名) => {
|
|
1559
|
+
const 附件服务 = 取服务('attachments')
|
|
1560
|
+
if (!附件服务 || typeof 附件服务.saveImage !== 'function') {
|
|
1561
|
+
throw new Error('这个宿主没给 attachments 服务,图片发不进去')
|
|
1562
|
+
}
|
|
1563
|
+
const ref = await 附件服务.saveImage({
|
|
1564
|
+
data: new Uint8Array(字节),
|
|
1565
|
+
mediaType: 'image/jpeg',
|
|
1566
|
+
name: 名 || '画面',
|
|
1567
|
+
})
|
|
1568
|
+
return { type: 'image', attachment: ref }
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
const 做图片块 = async (路, 名) => {
|
|
1572
|
+
const 附件服务 = 取服务('attachments')
|
|
1573
|
+
if (!附件服务 || typeof 附件服务.saveImage !== 'function') {
|
|
1574
|
+
throw new Error('这个宿主没给 attachments 服务,图片发不进去')
|
|
1575
|
+
}
|
|
1576
|
+
const 字节 = readFileSync(路)
|
|
1577
|
+
const ref = await 附件服务.saveImage({
|
|
1578
|
+
data: new Uint8Array(字节),
|
|
1579
|
+
mediaType: 'image/jpeg',
|
|
1580
|
+
name: 名 || '画面',
|
|
1581
|
+
})
|
|
1582
|
+
return { type: 'image', attachment: ref }
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
/**
|
|
1586
|
+
* ⭐ 0.4.3:把一张图**当场**发一轮(带文字 + 图片块),让她真的看得见画面。
|
|
1587
|
+
* 返回一句给人看的结果。
|
|
1588
|
+
*/
|
|
1589
|
+
/**
|
|
1590
|
+
* ⭐ 0.7.8:**谁能装成「她发的」**,按来源分:
|
|
1591
|
+
* · 热键截的(她自己按的)→ `source.kind = 'user'`,界面照常渲染成她发的图;
|
|
1592
|
+
* · 自动截图(游戏间隔截、攒着待发、陪看帧)→ `'fishbuddy'`,
|
|
1593
|
+
* 界面显示成「未知内容块」(渲染不出来),但**文字和时间标记照样在**,也不会冒充她。
|
|
1594
|
+
*/
|
|
1595
|
+
const 发截图那一轮 = async (路, 源) => {
|
|
1596
|
+
const 配置 = 读配置()
|
|
1597
|
+
if (!配置.总开关) return '总开关关着,没发'
|
|
1598
|
+
const 目标 = String(配置.会话 || '').trim()
|
|
1599
|
+
if (!目标) return '还没绑会话,发不出去'
|
|
1600
|
+
let agent
|
|
1601
|
+
try {
|
|
1602
|
+
agent = ctx.agents.get(目标)
|
|
1603
|
+
if (agent === undefined) agent = ctx.agents.get('session-' + 会话键(目标))
|
|
1604
|
+
} catch (error) {
|
|
1605
|
+
return '找不到那间会话:' + 说(error)
|
|
1606
|
+
}
|
|
1607
|
+
if (!agent) return '那间会话没开着'
|
|
1608
|
+
const 话 = 源 === '游戏' ? '(这是我游戏里的画面)' : '(这是我现在的屏幕)'
|
|
1609
|
+
try {
|
|
1610
|
+
await agent.followup({
|
|
1611
|
+
id: randomUUID(),
|
|
1612
|
+
role: 'user',
|
|
1613
|
+
content: [{ type: 'text', text: 话 }, await 做图片块(路, '画面')],
|
|
1614
|
+
source: { kind: 源 === '热键' ? 'user' : 'fishbuddy' },
|
|
1615
|
+
})
|
|
1616
|
+
状态.送出条数 += 1
|
|
1617
|
+
return '发过去了'
|
|
1618
|
+
} catch (error) {
|
|
1619
|
+
return '发失败了:' + 说(error)
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
/**
|
|
1624
|
+
* ⭐ 0.4.4:按住说话带图 —— 用那句话 + 图片块发一轮。
|
|
1625
|
+
*/
|
|
1626
|
+
const 发说话那一轮 = async (路们, 话, 来源类别 = 'user') => {
|
|
1627
|
+
const 配置 = 读配置()
|
|
1628
|
+
if (!配置.总开关) return '总开关关着,没发'
|
|
1629
|
+
const 目标 = String(配置.会话 || '').trim()
|
|
1630
|
+
if (!目标) return '还没绑会话'
|
|
1631
|
+
let agent
|
|
1632
|
+
try {
|
|
1633
|
+
agent = ctx.agents.get(目标)
|
|
1634
|
+
if (agent === undefined) agent = ctx.agents.get('session-' + 会话键(目标))
|
|
1635
|
+
} catch (error) {
|
|
1636
|
+
return '找不到那间会话:' + 说(error)
|
|
1637
|
+
}
|
|
1638
|
+
if (!agent) return '那间会话没开着'
|
|
1639
|
+
try {
|
|
1640
|
+
// ⭐ 0.6.0:可以带**多张**(攒着的几张一起给);不是刚截的,前面标一句「几分钟前」
|
|
1641
|
+
const 内容 = [{ type: 'text', text: 话 || '(看这个)' }]
|
|
1642
|
+
const 原们 = Array.isArray(路们) ? 路们 : [{ 路: 路们, 时: 现在() }]
|
|
1643
|
+
const 张们 = 原们.map((x) => (typeof x === 'string' ? { 路: x, 时: 现在() } : { 路: x && x.路, 时: (x && x.时) || 现在() }))
|
|
1644
|
+
.filter((x) => x.路)
|
|
1645
|
+
// ⭐ 1.6.0:**只拼 1 分钟以前的**,够 2 张就拼;刚截的那张(还有她自己截图发的那张)单发不拼
|
|
1646
|
+
const 分 = 该拼哪几张(张们, 现在())
|
|
1647
|
+
let 拼好了 = false
|
|
1648
|
+
if (分.该拼) {
|
|
1649
|
+
const 拼这几张 = 分.老的.slice(-3) // 最多拼 3 张(取最新的 3 张老图;2 张就全带上)
|
|
1650
|
+
const 拼图 = await 请挂件拼图(拼这几张)
|
|
1651
|
+
if (拼图) {
|
|
1652
|
+
try {
|
|
1653
|
+
内容.push({ type: 'text', text: '(这 ' + 拼这几张.length + ' 张画面拼成了一张,从上到下;每格左上角是多久前)' })
|
|
1654
|
+
内容.push(await 做图片块从字节(Buffer.from(拼图, 'base64'), '说话时的画面'))
|
|
1655
|
+
拼好了 = true
|
|
1656
|
+
} catch (error) {
|
|
1657
|
+
记账('拼好的那张没贴进去(退回单张):' + 说(error))
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
if (拼好了) {
|
|
1661
|
+
for (const x of 分.老的.slice(0, -3)) {
|
|
1662
|
+
try { 内容.push(await 做图片块(x.路, '说话时的画面')) } catch (error) {}
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
for (const x of (拼好了 ? 分.新的 : 张们)) {
|
|
1667
|
+
try {
|
|
1668
|
+
const 差 = Math.abs(现在() - Number(x.时 || 0))
|
|
1669
|
+
if (差 > 45000) 内容.push({ type: 'text', text: '(上面那张是 ' + 多久前(x.时) + '的画面)' })
|
|
1670
|
+
内容.push(await 做图片块(x.路, '说话时的画面'))
|
|
1671
|
+
} catch (error) {
|
|
1672
|
+
记账('这一张没拼进去(' + 说(error) + ')')
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
await agent.followup({
|
|
1676
|
+
id: randomUUID(),
|
|
1677
|
+
role: 'user',
|
|
1678
|
+
content: 内容,
|
|
1679
|
+
source: { kind: 来源类别 },
|
|
1680
|
+
})
|
|
1681
|
+
状态.送出条数 += 1
|
|
1682
|
+
return '发过去了'
|
|
1683
|
+
} catch (error) {
|
|
1684
|
+
return '发失败了:' + 说(error)
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
const 收一帧 = async (req, res) => {
|
|
1689
|
+
const 体 = await 读体(req)
|
|
1690
|
+
const 图 = String((体 && 体.图片) || '')
|
|
1691
|
+
if (!图) return 回(res, 400, { ok: false, 说: '没带图' })
|
|
1692
|
+
try {
|
|
1693
|
+
mkdirSync(帧目录, { recursive: true })
|
|
1694
|
+
const 字节 = Buffer.from(图, 'base64')
|
|
1695
|
+
const 名 = '帧-' + Date.now() + '.jpg'
|
|
1696
|
+
const 路 = join(帧目录, 名)
|
|
1697
|
+
writeFileSync(路, 字节)
|
|
1698
|
+
也许清截图()
|
|
1699
|
+
const 条 = {
|
|
1700
|
+
序: (帧账.length ? 帧账[帧账.length - 1].序 : 0) + 1,
|
|
1701
|
+
时: Number((体 && 体.时间点) || Date.now()) || Date.now(),
|
|
1702
|
+
文件: 路,
|
|
1703
|
+
大小: 字节.length,
|
|
1704
|
+
尺寸: String((体 && 体.尺寸) || ''),
|
|
1705
|
+
台词: Array.isArray(体 && 体.台词) ? 体.台词.slice(0, 10) : [],
|
|
1706
|
+
}
|
|
1707
|
+
帧账.push(条)
|
|
1708
|
+
// 一帧只发一次。队列别无限长 —— 攒太多就把最旧的丢掉。
|
|
1709
|
+
while (帧账.length > 8) 帧账.shift()
|
|
1710
|
+
// ⭐ 0.4.0(2026-10-28):图**不单独发** —— 只跟着字幕一起出去。
|
|
1711
|
+
// 以前安静 8 秒就自己发一张:跟「字幕那一批」撞成两层,同一张图还可能出两次。
|
|
1712
|
+
// 现在:图先存本地、排进字幕队列;字幕在流的时候,跟那批字幕一起到。
|
|
1713
|
+
try {
|
|
1714
|
+
收一帧行(会话键(读配置().会话), 条)
|
|
1715
|
+
} catch (error) {}
|
|
1716
|
+
// ⭐ 0.4.3:游戏模式 / 热键截的图是**立刻**发一轮的 —— 她要的是「她真看见画面」,
|
|
1717
|
+
// 而且没画面她也没法说点什么。陪看那一路还是排队等字幕。
|
|
1718
|
+
// ⭐ 0.6.0:**存档**(游戏截图「攒着」那一档)→ 只排队,不叫醒
|
|
1719
|
+
if (Number((体 && 体.存档) || 0) === 1) {
|
|
1720
|
+
try {
|
|
1721
|
+
收一张攒图(会话键(读配置().会话), 路, (体 && 体.时间点) || 现在())
|
|
1722
|
+
} catch (error) {}
|
|
1723
|
+
return 回(res, 200, { ok: true, 说: '攒着了(说话时一起给你看)', 攒: (攒图.get(会话键(读配置().会话)) || []).length })
|
|
1724
|
+
}
|
|
1725
|
+
const 立刻 = Number((体 && 体.立即) || 0) === 1
|
|
1726
|
+
if (立刻) {
|
|
1727
|
+
const 源 = String((体 && 体.来源) || '截图')
|
|
1728
|
+
const 结果 = await 发截图那一轮(路, 源)
|
|
1729
|
+
记账('收图(' + 源 + '):' + Math.round(条.大小 / 1024) + 'KB → ' + 结果)
|
|
1730
|
+
return 回(res, 200, { ok: true, 说: 结果, 存: 条.文件 })
|
|
1731
|
+
}
|
|
1732
|
+
记账('收到一帧画面(' + Math.round(条.大小 / 1024) + 'KB' + (条.尺寸 ? ',' + 条.尺寸 : '')
|
|
1733
|
+
+ ')—— 排进字幕里,下一轮跟字幕一起发')
|
|
1734
|
+
return 回(res, 200, { ok: true, 说: '收到 1 帧', 存: 条.文件, 帧数: 帧账.length })
|
|
1735
|
+
} catch (error) {
|
|
1736
|
+
return 回(res, 500, { ok: false, 说: '存帧失败:' + 说(error) })
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
// ⭐ 0.4.7:设置页里直接下载挂件(不用自己去 Release 页面翻)
|
|
1740
|
+
const 挂件下载目录 = () => {
|
|
1741
|
+
const 家 = join(目录, '鱼搭子')
|
|
1742
|
+
try { mkdirSync(家, { recursive: true }) } catch (error) {}
|
|
1743
|
+
return 家
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
const 取最新exe地址 = async () => {
|
|
1747
|
+
// 先问 GitHub「最新 Release 里哪个附件是 .exe」,失败就用固定兜底地址
|
|
1748
|
+
try {
|
|
1749
|
+
const r = await fetch('https://api.github.com/repos/Yuuyuko-uu/dsh-fish-buddy/releases/latest', {
|
|
1750
|
+
headers: { accept: 'application/vnd.github+json', 'user-agent': 'dsh-fish-buddy' },
|
|
1751
|
+
})
|
|
1752
|
+
if (r.ok) {
|
|
1753
|
+
const j = await r.json()
|
|
1754
|
+
const 件 = (j.assets || []).find((a) => String(a.name || '').toLowerCase().endsWith('.exe'))
|
|
1755
|
+
if (件 && 件.browser_download_url) return { 址: 件.browser_download_url, 版: String(j.tag_name || '') }
|
|
1756
|
+
}
|
|
1757
|
+
} catch (error) {}
|
|
1758
|
+
return { 址: 下载兜底地址, 版: '' }
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
const 下载挂件 = async () => {
|
|
1762
|
+
if (状态.下载 && 状态.下载.在下载) return { ok: false, 说: '正在下,等它下完' }
|
|
1763
|
+
状态.下载 = { 在下载: true, 百分比: 0, 说: '正在问 GitHub 要地址…', 出错: '', 路径: '', 版: '' }
|
|
1764
|
+
try {
|
|
1765
|
+
const { 址, 版 } = await 取最新exe地址()
|
|
1766
|
+
if (!址) throw new Error('没找到下载地址')
|
|
1767
|
+
状态.下载.版 = 版
|
|
1768
|
+
状态.下载.说 = '正在下载 ' + (版 ? 版 + ' ' : '') + '…'
|
|
1769
|
+
记账('开始下载挂件:' + 址)
|
|
1770
|
+
const r = await fetch(址, { redirect: 'follow' })
|
|
1771
|
+
if (!r.ok) throw new Error('HTTP ' + r.status)
|
|
1772
|
+
const 总 = Number(r.headers.get('content-length') || 0)
|
|
1773
|
+
const 临时 = join(挂件下载目录(), '鱼搭子.exe.下载中')
|
|
1774
|
+
const 目标 = join(挂件下载目录(), '鱼搭子.exe')
|
|
1775
|
+
const 出 = createWriteStream(临时)
|
|
1776
|
+
let 收 = 0
|
|
1777
|
+
for await (const 块 of r.body) {
|
|
1778
|
+
收 += 块.length
|
|
1779
|
+
出.write(块)
|
|
1780
|
+
状态.下载.百分比 = 总 ? Math.min(99, Math.round((收 / 总) * 100)) : 0
|
|
1781
|
+
状态.下载.说 = 总
|
|
1782
|
+
? '正在下载:' + Math.round(收 / 1048576) + ' / ' + Math.round(总 / 1048576) + ' MB'
|
|
1783
|
+
: '正在下载:' + Math.round(收 / 1048576) + ' MB'
|
|
1784
|
+
}
|
|
1785
|
+
await new Promise((好, 坏) => { 出.end(() => 好()); 出.on('error', 坏) })
|
|
1786
|
+
try { if (existsSync(目标)) rmSync(目标) } catch (error) {}
|
|
1787
|
+
renameSync(临时, 目标)
|
|
1788
|
+
// 记进配置:**原来那个还能用就不动它**(别把别人的开发路径悄悄改掉)
|
|
1789
|
+
const 旧配置 = 读配置()
|
|
1790
|
+
const 老路径 = String(旧配置.挂件路径 || '').trim()
|
|
1791
|
+
const 老的还在 = !!老路径 && existsSync(老路径)
|
|
1792
|
+
写配置({ ...旧配置, 挂件路径: 老的还在 ? 老路径 : 目标 })
|
|
1793
|
+
状态.下载 = { 在下载: false, 百分比: 100, 说: '下好了:' + 目标, 出错: '', 路径: 目标, 版 }
|
|
1794
|
+
记账('挂件下载完成:' + 目标 + '(' + Math.round(收 / 1048576) + 'MB)')
|
|
1795
|
+
return { ok: true, 说: '下好了', 路径: 目标 }
|
|
1796
|
+
} catch (error) {
|
|
1797
|
+
状态.下载 = { 在下载: false, 百分比: 0, 说: '没下成', 出错: 说(error), 路径: '', 版: '' }
|
|
1798
|
+
记账('挂件没下成:' + 说(error))
|
|
1799
|
+
return { ok: false, 说: 说(error) }
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
// 两个口都用同一个处理器:程序打 8777 的 /hands/frame,设置页/调试可以打 DSH 的 /dsh-fish-buddy/frame
|
|
1804
|
+
处理帧 = 收一帧
|
|
1805
|
+
路线(`${路由前缀}/frame`, 收一帧)
|
|
1806
|
+
路线(`${路由前缀}/frames`, async (req, res) => {
|
|
1807
|
+
return 回(res, 200, { ok: true, 帧们: 帧账.slice(-10) })
|
|
1808
|
+
})
|
|
1809
|
+
|
|
1810
|
+
// ---------- DSH 起来时把挂件拉起来 ----------
|
|
1811
|
+
// 默认就该开着,不然每次都要自己去点。设置里能关掉。
|
|
1812
|
+
const 挂件端口 = () => {
|
|
1813
|
+
const 配置 = 读配置()
|
|
1814
|
+
const 址 = String(配置.回调 || '').trim()
|
|
1815
|
+
if (址) {
|
|
1816
|
+
try {
|
|
1817
|
+
const 号 = Number(new URL(址).port)
|
|
1818
|
+
if (号 > 0) return 号
|
|
1819
|
+
} catch (error) {}
|
|
1820
|
+
}
|
|
1821
|
+
const 备 = Number(配置.回话端口)
|
|
1822
|
+
return 备 > 0 ? 备 : 8778
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
const 挂件在跑吗 = async () => {
|
|
1826
|
+
const 控 = new AbortController()
|
|
1827
|
+
const 停 = setTimeout(() => 控.abort(), 1200)
|
|
1828
|
+
try {
|
|
1829
|
+
const r = await fetch('http://127.0.0.1:' + 挂件端口() + '/hands/reply', { signal: 控.signal })
|
|
1830
|
+
return r.status < 500
|
|
1831
|
+
} catch (error) {
|
|
1832
|
+
return false
|
|
1833
|
+
} finally {
|
|
1834
|
+
clearTimeout(停)
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
const 自动开挂件 = async () => {
|
|
1839
|
+
if (读配置().自动开挂件 === false) {
|
|
1840
|
+
记账('自动打开挂件是关着的(设置里能开回来)')
|
|
1841
|
+
return false
|
|
1842
|
+
}
|
|
1843
|
+
if (await 挂件在跑吗()) {
|
|
1844
|
+
记账('挂件本来就在跑,不重复开')
|
|
1845
|
+
return false
|
|
1846
|
+
}
|
|
1847
|
+
const 路径 = 找挂件()
|
|
1848
|
+
if (!路径) {
|
|
1849
|
+
记账('想自动打开挂件,但没找到它在哪 —— 设置里填一下它的位置')
|
|
1850
|
+
return false
|
|
1851
|
+
}
|
|
1852
|
+
try {
|
|
1853
|
+
const 子 = spawn(路径, [], { detached: true, stdio: 'ignore', windowsHide: false })
|
|
1854
|
+
// ⚠ 0.7.4:同上 —— 不挂 'error' 的话,打不开会把 DSH 一起带走
|
|
1855
|
+
子.on('error', (错) => { 记账('自动打开挂件没成:' + 说(错) + "(要管理员的话,自己右键用管理员启动它)") })
|
|
1856
|
+
子.unref()
|
|
1857
|
+
记账('顺手把挂件打开了:' + 路径)
|
|
1858
|
+
return true
|
|
1859
|
+
} catch (error) {
|
|
1860
|
+
记账('自动打开挂件失败:' + 说(error))
|
|
1861
|
+
return false
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
// 等 DSH 自己站稳(8 秒)再去开挂件;测试环境用环境变量关掉,别真拉起程序。
|
|
1866
|
+
if (!process.env.DSH_FISHBUDDY_NO_LAUNCH) {
|
|
1867
|
+
ctx.effect(() => {
|
|
1868
|
+
const 定 = setTimeout(() => { void 自动开挂件() }, 8000)
|
|
1869
|
+
return () => clearTimeout(定)
|
|
1870
|
+
})
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
记账('鱼搭子起来了 · 本机口子端口 ' + 读配置().端口)
|
|
1874
|
+
}
|