mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-03-01 16:20:25 +00:00
Compare commits
1341 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01c911e178 | ||
|
|
8b3ea8dcef | ||
|
|
fe8b270ab3 | ||
|
|
f02ae5894f | ||
|
|
a6a0b408af | ||
|
|
e9856ac80f | ||
|
|
f553f9dc8d | ||
|
|
5608638e9a | ||
|
|
a53c20767a | ||
|
|
a92bef5b33 | ||
|
|
a9a3b6ec6e | ||
|
|
20d41fff9e | ||
|
|
0b4d7e1346 | ||
|
|
46b9049a24 | ||
|
|
521f4dc365 | ||
|
|
04b507d749 | ||
|
|
5638127813 | ||
|
|
30a7797ba9 | ||
|
|
d09a82b1b8 | ||
|
|
85b5c881ba | ||
|
|
eebce222cf | ||
|
|
ec5ca5d89a | ||
|
|
31a7767ae4 | ||
|
|
fec024334a | ||
|
|
2ad2af4d7c | ||
|
|
2a160d296f | ||
|
|
e43f229e04 | ||
|
|
9158ebc136 | ||
|
|
d758fe3a2b | ||
|
|
4abd0668a3 | ||
|
|
0181700c3b | ||
|
|
6083e9cfcc | ||
|
|
5fec190c70 | ||
|
|
e1743ae5e4 | ||
|
|
ded921c55e | ||
|
|
57e717e898 | ||
|
|
55f21c6caa | ||
|
|
4360775eff | ||
|
|
e2486606f9 | ||
|
|
f8eb368cdb | ||
|
|
9ce51fb082 | ||
|
|
89e50be1e9 | ||
|
|
1259dcea5b | ||
|
|
b4900066b3 | ||
|
|
28acd94cbd | ||
|
|
7aedacb27f | ||
|
|
c9b45ec1a2 | ||
|
|
54cacc30e4 | ||
|
|
9b26fc99d3 | ||
|
|
204846b404 | ||
|
|
3d654791b9 | ||
|
|
63f42f1592 | ||
|
|
0ab0b939da | ||
|
|
522a123f9a | ||
|
|
ec9f8d6e12 | ||
|
|
3c750c75a9 | ||
|
|
5b2b1f499b | ||
|
|
531ffcd55d | ||
|
|
068e4d8bb5 | ||
|
|
5dc33e78ad | ||
|
|
d76a2170a0 | ||
|
|
ec2af3120c | ||
|
|
8de49a3109 | ||
|
|
dbb5a0022e | ||
|
|
cb8c8d6b57 | ||
|
|
93c140ed4e | ||
|
|
457b072f0e | ||
|
|
1869493473 | ||
|
|
f33c66ce15 | ||
|
|
e8d6f86458 | ||
|
|
a000ffdf0d | ||
|
|
202338a160 | ||
|
|
e3eb129a52 | ||
|
|
7654e9f2bb | ||
|
|
a60c03f42f | ||
|
|
60aae228a1 | ||
|
|
b1417f9b56 | ||
|
|
eeeaddbb60 | ||
|
|
b1109022bb | ||
|
|
1807789511 | ||
|
|
49a5b631c2 | ||
|
|
e7aaec81e2 | ||
|
|
05f8e8f3c3 | ||
|
|
e760876470 | ||
|
|
e0ec4d4ebb | ||
|
|
79fa0ade0d | ||
|
|
652b5d6118 | ||
|
|
f4dedf4803 | ||
|
|
06f6a542f5 | ||
|
|
d5b8f886d6 | ||
|
|
97b6dccc30 | ||
|
|
a755487e22 | ||
|
|
5407392f08 | ||
|
|
3359c5ded9 | ||
|
|
caaf8be3b2 | ||
|
|
28ce5d3cb4 | ||
|
|
3dd56c711e | ||
|
|
511fb82ce0 | ||
|
|
1e5524a009 | ||
|
|
d5e6afc7b9 | ||
|
|
91e633b0fb | ||
|
|
397b9880b9 | ||
|
|
54eb26ba67 | ||
|
|
355f7fb4a0 | ||
|
|
325c455e38 | ||
|
|
a7a9792efe | ||
|
|
4bab93e545 | ||
|
|
3c60997b1c | ||
|
|
bf684d9166 | ||
|
|
5c4ee30f37 | ||
|
|
346374b442 | ||
|
|
cc8a387bde | ||
|
|
962685ade6 | ||
|
|
36fdaac406 | ||
|
|
db16db911b | ||
|
|
bb84dfcc27 | ||
|
|
3e71e541e6 | ||
|
|
bcc856d583 | ||
|
|
1eaf480a7d | ||
|
|
86123af7fc | ||
|
|
c3cd2aaf89 | ||
|
|
599afdc7ba | ||
|
|
ffe54af8d9 | ||
|
|
a7b30ef844 | ||
|
|
50b8cb14dc | ||
|
|
0b18f868bc | ||
|
|
c9acb9ae28 | ||
|
|
2e1506a05d | ||
|
|
b35283f970 | ||
|
|
54ac072bfb | ||
|
|
58cefb9cdc | ||
|
|
be4344634d | ||
|
|
2da5d242f7 | ||
|
|
fbd00b2576 | ||
|
|
72548c9575 | ||
|
|
003f3e946d | ||
|
|
dc51d01351 | ||
|
|
c5db525f4a | ||
|
|
c1377e6de7 | ||
|
|
d2c4f425c7 | ||
|
|
803b1a6c77 | ||
|
|
9a35ee9cd1 | ||
|
|
458d22223c | ||
|
|
4ed61136b2 | ||
|
|
1445a29e15 | ||
|
|
55ef040852 | ||
|
|
0c88319248 | ||
|
|
6778bd69de | ||
|
|
f9c9b3a852 | ||
|
|
9ce9e46c57 | ||
|
|
656bde25c8 | ||
|
|
791a360f28 | ||
|
|
376245b749 | ||
|
|
d3e3527c2b | ||
|
|
5b78dfbd5a | ||
|
|
1e461aae3c | ||
|
|
42abc2b9cb | ||
|
|
4f8c320658 | ||
|
|
cbacc89907 | ||
|
|
8e6da5e2d0 | ||
|
|
02980c4d1a | ||
|
|
0129188739 | ||
|
|
98ef642cd1 | ||
|
|
32e886e53b | ||
|
|
315d847f06 | ||
|
|
381d320967 | ||
|
|
2f5b62decb | ||
|
|
2afdb2a0da | ||
|
|
5bfbf92c21 | ||
|
|
a775a0dde9 | ||
|
|
d7f00c0594 | ||
|
|
77c8f874b6 | ||
|
|
fb0a20919b | ||
|
|
0300ba4648 | ||
|
|
d472eee777 | ||
|
|
41bd06e50a | ||
|
|
97334dfbf5 | ||
|
|
e3d8c8e940 | ||
|
|
f2c62db76e | ||
|
|
b1b051c4ce | ||
|
|
a754b2ecc7 | ||
|
|
e0eb625b75 | ||
|
|
937be7678e | ||
|
|
9b88946209 | ||
|
|
74de3d9100 | ||
|
|
42d50014a1 | ||
|
|
a36ae315b0 | ||
|
|
2161ec5fa7 | ||
|
|
32bba007cd | ||
|
|
84d3dc9f40 | ||
|
|
890d032794 | ||
|
|
66f30e1ebf | ||
|
|
ada614d007 | ||
|
|
ea3ab7f13f | ||
|
|
a5e4c24de3 | ||
|
|
bcc7d25b64 | ||
|
|
aae676fdc7 | ||
|
|
0e9aa43476 | ||
|
|
b2ff556aa6 | ||
|
|
69c5b78678 | ||
|
|
8be7f74e9f | ||
|
|
a05150ebe1 | ||
|
|
5e6b607ded | ||
|
|
df2dabfe76 | ||
|
|
5e032fcc6a | ||
|
|
44200a2208 | ||
|
|
e39bb05f01 | ||
|
|
677731dd70 | ||
|
|
fa8e6f2c59 | ||
|
|
509b23ff04 | ||
|
|
cf1765f5a4 | ||
|
|
c541c7e257 | ||
|
|
298b8b71c8 | ||
|
|
5c120a8231 | ||
|
|
88ee8f89fe | ||
|
|
12b8130372 | ||
|
|
58332dad24 | ||
|
|
e97f3e1283 | ||
|
|
e406dca7ae | ||
|
|
e4c1807f76 | ||
|
|
f4412bb086 | ||
|
|
27af8e52ac | ||
|
|
4c9a220300 | ||
|
|
1fe822cd20 | ||
|
|
0ab8d025bf | ||
|
|
a0f3d66607 | ||
|
|
06e7c3363a | ||
|
|
4d200de6b7 | ||
|
|
6200097f7c | ||
|
|
c7af0384fb | ||
|
|
dc87615bd6 | ||
|
|
ff2cfcee97 | ||
|
|
f3c07ed8fc | ||
|
|
7ab44dcb34 | ||
|
|
aa6699d06e | ||
|
|
3cb51a17a6 | ||
|
|
994e8ced3e | ||
|
|
75d26465f1 | ||
|
|
f5052935bd | ||
|
|
84b89de2a6 | ||
|
|
c4f9c4f630 | ||
|
|
c213cd6c3a | ||
|
|
9d22d6e3a0 | ||
|
|
a38419e3cb | ||
|
|
a64779684e | ||
|
|
ecd7012eee | ||
|
|
74a1011fcc | ||
|
|
d4b0a4acca | ||
|
|
ac6e593315 | ||
|
|
b1e77b1658 | ||
|
|
722c3554e9 | ||
|
|
1d08966571 | ||
|
|
fb50ae7544 | ||
|
|
ea695fc9e9 | ||
|
|
5c6d1e6a14 | ||
|
|
b030c40853 | ||
|
|
d6782c35e2 | ||
|
|
120e6db119 | ||
|
|
fa10f8ce19 | ||
|
|
31494b4687 | ||
|
|
857ed0f343 | ||
|
|
8133ff08a7 | ||
|
|
2d315c4d8e | ||
|
|
505f7b6ac9 | ||
|
|
2735eb14bd | ||
|
|
7afbc95eda | ||
|
|
91bb83d8c1 | ||
|
|
55550790e4 | ||
|
|
40221926a9 | ||
|
|
d069374a95 | ||
|
|
1183fe2057 | ||
|
|
f4605d4f74 | ||
|
|
3ce3fb685b | ||
|
|
07c2f7371f | ||
|
|
114aae98a9 | ||
|
|
0bd6548f45 | ||
|
|
06c5b7807b | ||
|
|
e8ef08cae2 | ||
|
|
0d251a9343 | ||
|
|
69a19b0e32 | ||
|
|
1b0b5f3494 | ||
|
|
5abdc8c538 | ||
|
|
9f0ba6d385 | ||
|
|
7863a0f45f | ||
|
|
ef4c2a935c | ||
|
|
40d2e948e4 | ||
|
|
3de4e905d3 | ||
|
|
655f4e199c | ||
|
|
13c3d3a2fb | ||
|
|
db2dca45f6 | ||
|
|
7330a05c78 | ||
|
|
a39c932868 | ||
|
|
a2c24c9197 | ||
|
|
5c3efc681f | ||
|
|
e70d2bd708 | ||
|
|
cf75a961fb | ||
|
|
159f317071 | ||
|
|
713eef592a | ||
|
|
cf03ad8fd9 | ||
|
|
0c0b27901a | ||
|
|
137fe3c8f2 | ||
|
|
d96174076a | ||
|
|
6d5662d96e | ||
|
|
57abd47d99 | ||
|
|
5092b3d791 | ||
|
|
649409d1be | ||
|
|
8f549d896a | ||
|
|
a1359ddbb5 | ||
|
|
304a0dda3e | ||
|
|
fff9c4a4d8 | ||
|
|
2c76102fc4 | ||
|
|
f576cd9417 | ||
|
|
9cfd224b74 | ||
|
|
c12f8de8b4 | ||
|
|
ed9a7c52e2 | ||
|
|
38fcaaa28b | ||
|
|
5317a1c1a9 | ||
|
|
4bc5933ea2 | ||
|
|
6a6bd33fe5 | ||
|
|
8256942a3d | ||
|
|
697632eee8 | ||
|
|
6bbf5b254d | ||
|
|
5831898c4a | ||
|
|
2cc413bec1 | ||
|
|
0af36e89d9 | ||
|
|
b2c0f5d2e5 | ||
|
|
80b74c7da9 | ||
|
|
f14f13b158 | ||
|
|
9dda00b6fa | ||
|
|
a29debb738 | ||
|
|
b990fc43df | ||
|
|
915e9552ee | ||
|
|
c522e0a386 | ||
|
|
c9cc08a9ba | ||
|
|
66e1b1662f | ||
|
|
9372e83bd8 | ||
|
|
b38a240dbb | ||
|
|
76b9506395 | ||
|
|
f1cf636aa2 | ||
|
|
312dcd0e13 | ||
|
|
42c2419613 | ||
|
|
8f7f748e82 | ||
|
|
7ad3bad1be | ||
|
|
5cd682e69f | ||
|
|
5d57780e84 | ||
|
|
f399955204 | ||
|
|
770652fe6b | ||
|
|
9ed5fa8c67 | ||
|
|
5a4ad29727 | ||
|
|
1eda3f2e33 | ||
|
|
95cb95ef96 | ||
|
|
4e7c96634c | ||
|
|
58587b8aea | ||
|
|
3fbf6239db | ||
|
|
faec53d497 | ||
|
|
482dcc534e | ||
|
|
854f61dda6 | ||
|
|
fca38713a1 | ||
|
|
5dd3bade53 | ||
|
|
665360f48d | ||
|
|
65719cb56a | ||
|
|
bdb76d4639 | ||
|
|
15634412ef | ||
|
|
bbcf9649fa | ||
|
|
e845d7314e | ||
|
|
6927b1c94f | ||
|
|
a09c6acd0d | ||
|
|
0963650ccb | ||
|
|
380688b353 | ||
|
|
ad5466bff8 | ||
|
|
a83652bf3f | ||
|
|
c632de314d | ||
|
|
259c9610d5 | ||
|
|
e9936c5524 | ||
|
|
3f60440e72 | ||
|
|
71a15f92fb | ||
|
|
32bc0dd820 | ||
|
|
20d1ac9d01 | ||
|
|
18baf89e0e | ||
|
|
3a1d1f2e59 | ||
|
|
e9a048721d | ||
|
|
68f0c7ff1a | ||
|
|
2875fe94ea | ||
|
|
1870427c0f | ||
|
|
636568fd30 | ||
|
|
bbc2391bf8 | ||
|
|
401684542a | ||
|
|
870edb2513 | ||
|
|
7ad09169ea | ||
|
|
c1a0f8915b | ||
|
|
dcdab8e5a1 | ||
|
|
eb3278fdab | ||
|
|
34db3af48d | ||
|
|
198da960dd | ||
|
|
cb83918fb3 | ||
|
|
f59a48540b | ||
|
|
ccf9c1a5fb | ||
|
|
ba6a85142a | ||
|
|
440baccd2a | ||
|
|
690c073328 | ||
|
|
3f0730ed4f | ||
|
|
01d5663bc8 | ||
|
|
49806cd00e | ||
|
|
935b0848e5 | ||
|
|
5ca20a89a2 | ||
|
|
e89a2266ec | ||
|
|
6607533311 | ||
|
|
4057054220 | ||
|
|
055e43845e | ||
|
|
d67270f2f8 | ||
|
|
d061b6c190 | ||
|
|
945f87d77f | ||
|
|
6c9be52d39 | ||
|
|
98e347f010 | ||
|
|
607e367bb1 | ||
|
|
7a25dc1ef1 | ||
|
|
e22ec4be09 | ||
|
|
51a06622f9 | ||
|
|
22faf5b831 | ||
|
|
e781c662b2 | ||
|
|
5744698d24 | ||
|
|
2c2ab3cd48 | ||
|
|
cfae4f5acd | ||
|
|
de541e3249 | ||
|
|
f5187c5c01 | ||
|
|
9936279443 | ||
|
|
2818773fd4 | ||
|
|
b9293cbcd0 | ||
|
|
5b9e44ddfc | ||
|
|
1791accab7 | ||
|
|
08081360f3 | ||
|
|
e933a95e97 | ||
|
|
4ef457fe6f | ||
|
|
bd9cae8921 | ||
|
|
303a74f8fd | ||
|
|
0b7f126ce1 | ||
|
|
308b5c027f | ||
|
|
ed3abc4b43 | ||
|
|
87ecb3b380 | ||
|
|
7e31763a25 | ||
|
|
c9df57d16a | ||
|
|
3d0f8ee657 | ||
|
|
6421bb4f5c | ||
|
|
3919743885 | ||
|
|
a5a57b9e20 | ||
|
|
e31d2810ad | ||
|
|
140e62fdcd | ||
|
|
014b4deb87 | ||
|
|
956b6cd172 | ||
|
|
bbaca3f044 | ||
|
|
bb8a44b918 | ||
|
|
b5574d5999 | ||
|
|
06dde072da | ||
|
|
8e92a81bb9 | ||
|
|
2c7345ae88 | ||
|
|
33d4696155 | ||
|
|
7d2dcc10e5 | ||
|
|
e82687454c | ||
|
|
84382caebc | ||
|
|
662530e507 | ||
|
|
edf81d0a2e | ||
|
|
7cbae86941 | ||
|
|
8ff7420a5e | ||
|
|
7ae59b1419 | ||
|
|
41036f8ee8 | ||
|
|
380777ca04 | ||
|
|
c658cd1096 | ||
|
|
c7b9946d2f | ||
|
|
0caca473d6 | ||
|
|
3e5d35957d | ||
|
|
6b8b14aba2 | ||
|
|
5db7a90a24 | ||
|
|
88b86611a3 | ||
|
|
886fe2052e | ||
|
|
e4dd194d4a | ||
|
|
a47af60f58 | ||
|
|
35f24eb806 | ||
|
|
36e3119d34 | ||
|
|
8ff3ad824e | ||
|
|
556000c002 | ||
|
|
fda050d3fe | ||
|
|
b1047309c9 | ||
|
|
d766c4945e | ||
|
|
43c98c45b9 | ||
|
|
f7556b5af3 | ||
|
|
cd781c4cf6 | ||
|
|
cd8698b157 | ||
|
|
d921dcddf1 | ||
|
|
9f318ddaef | ||
|
|
5c35ea11c3 | ||
|
|
3b16effff0 | ||
|
|
d3a27ad701 | ||
|
|
2a4589e268 | ||
|
|
80a34c82b9 | ||
|
|
fca7a65ee0 | ||
|
|
30a75bc581 | ||
|
|
7b365367f7 | ||
|
|
3ed5f543e2 | ||
|
|
ceea50b116 | ||
|
|
a5455e27d1 | ||
|
|
6f83d01321 | ||
|
|
c453b82e9f | ||
|
|
b7da316447 | ||
|
|
fb20b2e16c | ||
|
|
9df7c341a9 | ||
|
|
7c113d6e04 | ||
|
|
a6f22167ff | ||
|
|
d49e69735a | ||
|
|
eca73eae18 | ||
|
|
d3a34dfdf9 | ||
|
|
623188d884 | ||
|
|
f093f52792 | ||
|
|
d53607a118 | ||
|
|
5f637e064a | ||
|
|
e4b21e94f5 | ||
|
|
fc37288827 | ||
|
|
dad7245a3a | ||
|
|
4190831081 | ||
|
|
c509a01d7d | ||
|
|
6d259593fd | ||
|
|
bd3e06520f | ||
|
|
41dccd98a9 | ||
|
|
54e6d5c3f2 | ||
|
|
3f6249f39c | ||
|
|
a888714629 | ||
|
|
17ef3231df | ||
|
|
cc30b51d58 | ||
|
|
9d40eacc15 | ||
|
|
a0415c5f4e | ||
|
|
941978b578 | ||
|
|
06538b9122 | ||
|
|
dd895d7c17 | ||
|
|
9257a6cfde | ||
|
|
f8260067ab | ||
|
|
40b06daf1e | ||
|
|
e30915a06b | ||
|
|
2ab3898d28 | ||
|
|
6e38e748b8 | ||
|
|
7ecdd63bef | ||
|
|
8056962203 | ||
|
|
7a42f8c26f | ||
|
|
6c510e42e8 | ||
|
|
45d6ebf084 | ||
|
|
2147c4ffee | ||
|
|
d4ab191f34 | ||
|
|
a5e53a713b | ||
|
|
e3f965a9d6 | ||
|
|
dd00d4c8a5 | ||
|
|
4d292a75fa | ||
|
|
50b6733f57 | ||
|
|
19479b4b3c | ||
|
|
540f58d8ec | ||
|
|
749f1dfcf9 | ||
|
|
176691bb96 | ||
|
|
b9ec8ac9b1 | ||
|
|
28d973b9cb | ||
|
|
a6be54937c | ||
|
|
0664b9af84 | ||
|
|
407d8d1fd2 | ||
|
|
108897f6ad | ||
|
|
3d2decb0ec | ||
|
|
386b884f1b | ||
|
|
ace4da2297 | ||
|
|
a8fb48fb50 | ||
|
|
61f065c0c6 | ||
|
|
d6cf6d120a | ||
|
|
c20c19d8e0 | ||
|
|
bd8bbf76ab | ||
|
|
faccff1834 | ||
|
|
99d3c5a117 | ||
|
|
31eb09edef | ||
|
|
4180c2d754 | ||
|
|
68f5deedff | ||
|
|
9f72196414 | ||
|
|
b32efa9131 | ||
|
|
3cf502fea3 | ||
|
|
863a953ae1 | ||
|
|
a44104d8f7 | ||
|
|
f602bbb0cf | ||
|
|
2807ff5927 | ||
|
|
4fb8e6a4da | ||
|
|
7ec61f089d | ||
|
|
f7a500a8cf | ||
|
|
2b319bd694 | ||
|
|
24cf7c01f8 | ||
|
|
aa50e73909 | ||
|
|
d9b33b5439 | ||
|
|
da58c6bec0 | ||
|
|
c4cbac4331 | ||
|
|
ac26a99143 | ||
|
|
640252d391 | ||
|
|
258a1dda5e | ||
|
|
53a7ce2e46 | ||
|
|
291e2fd8fd | ||
|
|
f180c7698f | ||
|
|
183d6f3011 | ||
|
|
ba71d7ad03 | ||
|
|
26cfaac3bd | ||
|
|
556c8b24c0 | ||
|
|
31f0f527b7 | ||
|
|
b2e0cab702 | ||
|
|
3e3609e0f2 | ||
|
|
83e73d9842 | ||
|
|
673a175ddf | ||
|
|
12eacd3530 | ||
|
|
030f0551fd | ||
|
|
47f5947410 | ||
|
|
aaefa2e83c | ||
|
|
f8e92f7c8d | ||
|
|
b6430e6eb6 | ||
|
|
e60605c7bb | ||
|
|
58d2bd3c81 | ||
|
|
6534d05b76 | ||
|
|
2d7de174c5 | ||
|
|
79aa1dc67f | ||
|
|
7792ad9ea0 | ||
|
|
be6671923b | ||
|
|
0fa1b3f044 | ||
|
|
4ab751696b | ||
|
|
dce4eedf7d | ||
|
|
129b67b751 | ||
|
|
9ab776d53a | ||
|
|
2759a34d96 | ||
|
|
2f9f42750e | ||
|
|
30abd1f904 | ||
|
|
008075466e | ||
|
|
5b4035c320 | ||
|
|
e3feb6a73c | ||
|
|
40fe73317d | ||
|
|
073745030c | ||
|
|
c523437506 | ||
|
|
9eef570d37 | ||
|
|
be37b8cbbd | ||
|
|
c635496677 | ||
|
|
8753ecfd92 | ||
|
|
5eda1f2870 | ||
|
|
d5a60074f7 | ||
|
|
91df57d932 | ||
|
|
e27d4c4302 | ||
|
|
55847f6e10 | ||
|
|
b39d8bae27 | ||
|
|
b0cf23f775 | ||
|
|
c641246056 | ||
|
|
1e5bc9bbea | ||
|
|
99b504b5f6 | ||
|
|
1146454fec | ||
|
|
805e014a75 | ||
|
|
d3acd1efc1 | ||
|
|
9fcd218a5a | ||
|
|
d6a0830cfe | ||
|
|
40a63b9c66 | ||
|
|
eeb19a04cc | ||
|
|
91e457eb03 | ||
|
|
78d1919d7f | ||
|
|
8393acf173 | ||
|
|
bca152a047 | ||
|
|
6a15908a93 | ||
|
|
c626bbab74 | ||
|
|
c5c7dcc6f2 | ||
|
|
03dafe727e | ||
|
|
744921c45e | ||
|
|
abc4a4dcba | ||
|
|
7e0da2f929 | ||
|
|
a3b70d0f1f | ||
|
|
d291724f06 | ||
|
|
122a9ca2cc | ||
|
|
48aaddd32b | ||
|
|
47401af856 | ||
|
|
709adfd812 | ||
|
|
038d0c5412 | ||
|
|
6bb4362ed4 | ||
|
|
e617f9452d | ||
|
|
6d8bb49a37 | ||
|
|
4f6073ee86 | ||
|
|
2e7176304b | ||
|
|
e36cf11004 | ||
|
|
0e49e17f68 | ||
|
|
524de45f6b | ||
|
|
85741a4b60 | ||
|
|
f9ccb8c978 | ||
|
|
ea3d069e49 | ||
|
|
3e6024f183 | ||
|
|
337871693a | ||
|
|
2d921c4577 | ||
|
|
9accff7323 | ||
|
|
88b1ee8c31 | ||
|
|
3ac618bb4e | ||
|
|
0051df3741 | ||
|
|
7eb4e010b0 | ||
|
|
33cc23ada3 | ||
|
|
e5aee372e3 | ||
|
|
6b6ce4a761 | ||
|
|
8c4ea7f8f2 | ||
|
|
c8b268b806 | ||
|
|
faf390bb18 | ||
|
|
cf5e0e0f14 | ||
|
|
7b79f9cc17 | ||
|
|
708d599966 | ||
|
|
1ecd5b78e6 | ||
|
|
fca2e3c51a | ||
|
|
95ea761b2d | ||
|
|
6b3bfa1ee9 | ||
|
|
df3e302a9d | ||
|
|
c88a68c9a8 | ||
|
|
92d01b9cdd | ||
|
|
fe04fa5986 | ||
|
|
c382f541b4 | ||
|
|
f420527207 | ||
|
|
e0c83ebf79 | ||
|
|
c7fb18fc08 | ||
|
|
2db8ab937d | ||
|
|
819f5dd8e5 | ||
|
|
d4a8ed735e | ||
|
|
f07e3bb4d5 | ||
|
|
fa5ef0c221 | ||
|
|
da7499ec0b | ||
|
|
d2f4327e44 | ||
|
|
2eba640180 | ||
|
|
29ae55f340 | ||
|
|
3d2bca3f9f | ||
|
|
7fd8c0c822 | ||
|
|
a9e9c81505 | ||
|
|
e8cc68bdea | ||
|
|
9e51a661a4 | ||
|
|
a167aaf55f | ||
|
|
a54ecbcaa0 | ||
|
|
788462cdfa | ||
|
|
45c5965b99 | ||
|
|
ce7614de46 | ||
|
|
9f78e1ce1e | ||
|
|
2c7b0625e8 | ||
|
|
c3a5da9be1 | ||
|
|
ca796e1920 | ||
|
|
7ce04cf781 | ||
|
|
024a3eb760 | ||
|
|
1702f429b4 | ||
|
|
96d79cf495 | ||
|
|
a6a11a7026 | ||
|
|
970a49e2a5 | ||
|
|
2e013ed4f5 | ||
|
|
f8c396b1fe | ||
|
|
b54870cb60 | ||
|
|
84318acb18 | ||
|
|
a11a042b93 | ||
|
|
8a8aa8f62c | ||
|
|
93f78f4db5 | ||
|
|
404bfdd5e6 | ||
|
|
e4577dc2f1 | ||
|
|
5c932e5a27 | ||
|
|
4bd63c6267 | ||
|
|
aabe24f903 | ||
|
|
69cebd7fbc | ||
|
|
8da371176a | ||
|
|
dd08adf1d1 | ||
|
|
2f67bef139 | ||
|
|
8968c51cdc | ||
|
|
f2fdcc9289 | ||
|
|
aa3a575cbe | ||
|
|
11816d038d | ||
|
|
6a990edb38 | ||
|
|
fa12865924 | ||
|
|
ecdd717742 | ||
|
|
6851334af9 | ||
|
|
9051b29565 | ||
|
|
95c7d3dfbd | ||
|
|
bc1148c00a | ||
|
|
d4556d9299 | ||
|
|
5d389a2359 | ||
|
|
305116874b | ||
|
|
b08a29897f | ||
|
|
b59c1d9122 | ||
|
|
adb9cea701 | ||
|
|
5e148d2e82 | ||
|
|
a0d780558e | ||
|
|
ad56065a4e | ||
|
|
f5dee80b6e | ||
|
|
9cc75881b8 | ||
|
|
593fb13b61 | ||
|
|
fca90592d6 | ||
|
|
d6848e2855 | ||
|
|
7539a4129f | ||
|
|
5402574266 | ||
|
|
853175aa1a | ||
|
|
feb84809ec | ||
|
|
a812c568e4 | ||
|
|
11db25e355 | ||
|
|
ecd2fba629 | ||
|
|
a6763cf5a1 | ||
|
|
c9e91a9b94 | ||
|
|
43fb62c5bd | ||
|
|
cb8727d487 | ||
|
|
a94e03e2fd | ||
|
|
425c3c6432 | ||
|
|
89b9610016 | ||
|
|
62fe88f868 | ||
|
|
11a7f5fade | ||
|
|
fbde997f7c | ||
|
|
26734a35ef | ||
|
|
715c4ac534 | ||
|
|
bd4b0885a1 | ||
|
|
e3c7af3d91 | ||
|
|
a7ee21bfd8 | ||
|
|
d0f51d92ac | ||
|
|
e6dc148ea2 | ||
|
|
514ab6637f | ||
|
|
377794abe8 | ||
|
|
0f3251f35b | ||
|
|
8002dc5bc5 | ||
|
|
c75a13dcf4 | ||
|
|
91d153bb9d | ||
|
|
b32f9fa397 | ||
|
|
80593730ae | ||
|
|
090d54a78d | ||
|
|
b7d1fb181c | ||
|
|
6e56693ca7 | ||
|
|
7403db9b20 | ||
|
|
9d167cd883 | ||
|
|
197eec40ad | ||
|
|
07819a6618 | ||
|
|
b72156866d | ||
|
|
59a7d12a8c | ||
|
|
179351b23a | ||
|
|
790809e8e5 | ||
|
|
1414a8a8c9 | ||
|
|
9ab41734a5 | ||
|
|
03cace2ea1 | ||
|
|
c7371ab869 | ||
|
|
b32d4b618c | ||
|
|
3a27f37686 | ||
|
|
fe2d21979d | ||
|
|
48b1f3d4f0 | ||
|
|
93ed589ac7 | ||
|
|
96de9e2c16 | ||
|
|
b25f9d3bec | ||
|
|
15854c605b | ||
|
|
ac193cc94a | ||
|
|
d626f872e6 | ||
|
|
3eb66fa34a | ||
|
|
0fdd0175b7 | ||
|
|
dec9b477e0 | ||
|
|
a0a4b0dd1d | ||
|
|
8dc6da56a7 | ||
|
|
b4e07aacfe | ||
|
|
19b47f0f42 | ||
|
|
f9ef3d63c7 | ||
|
|
2b574d33b5 | ||
|
|
6039e9bb46 | ||
|
|
adfd4b043f | ||
|
|
719189be55 | ||
|
|
ef9907f4b6 | ||
|
|
16b7447df1 | ||
|
|
4157746478 | ||
|
|
5120786708 | ||
|
|
0176fa75ef | ||
|
|
e6968f2d80 | ||
|
|
c0dd8a53e8 | ||
|
|
3cb3135235 | ||
|
|
28182cac64 | ||
|
|
73b80d2482 | ||
|
|
f22eb22409 | ||
|
|
4a95b17a47 | ||
|
|
f4a71159fd | ||
|
|
c0431e3dc2 | ||
|
|
7f87cee282 | ||
|
|
c24c704439 | ||
|
|
232e5d55b8 | ||
|
|
da24ae7e1c | ||
|
|
8fc13f8a8f | ||
|
|
7e1fe31085 | ||
|
|
c3cba8ba4e | ||
|
|
ba619986c9 | ||
|
|
dcef3f3c3b | ||
|
|
823faa2790 | ||
|
|
ef4248d2a3 | ||
|
|
3917cb0dc9 | ||
|
|
520cec0eaa | ||
|
|
e7655e0ff6 | ||
|
|
350ced55c0 | ||
|
|
2ca6d0a00e | ||
|
|
844abad0d0 | ||
|
|
d278e9d8bc | ||
|
|
6e261f30c2 | ||
|
|
84f0e43369 | ||
|
|
941b30847b | ||
|
|
3223a06983 | ||
|
|
1b874a0264 | ||
|
|
26525a0ff9 | ||
|
|
49234ea5c7 | ||
|
|
c474158a09 | ||
|
|
813a541e7f | ||
|
|
efd489bfd4 | ||
|
|
9c88fcb610 | ||
|
|
c1cac8de19 | ||
|
|
b03fa54e63 | ||
|
|
9785731f25 | ||
|
|
566afde18b | ||
|
|
bae8dabc5c | ||
|
|
0a9dfea20a | ||
|
|
50498aa52d | ||
|
|
cc99fa8346 | ||
|
|
500c10ea7a | ||
|
|
71a62caf8f | ||
|
|
fe9c565ad4 | ||
|
|
f5f915dc91 | ||
|
|
eba17fd9b4 | ||
|
|
86f6caa714 | ||
|
|
8ec5a4d071 | ||
|
|
eae49667ef | ||
|
|
9a87b5ec1a | ||
|
|
ee1291e42c | ||
|
|
15aa1fd0b8 | ||
|
|
e069e0e8aa | ||
|
|
57e72c197f | ||
|
|
1d0d25eea2 | ||
|
|
1a6194b38c | ||
|
|
22057083ce | ||
|
|
6b041becb0 | ||
|
|
96d4a91ee9 | ||
|
|
3069900202 | ||
|
|
c46fb0f48a | ||
|
|
07cd8f883e | ||
|
|
cfdb9d64ad | ||
|
|
b73e3aa3b7 | ||
|
|
cd315b0e71 | ||
|
|
4d4d79e66f | ||
|
|
395ce97a78 | ||
|
|
e44e8423d0 | ||
|
|
fa13a56697 | ||
|
|
6383164aec | ||
|
|
d9adfad1c0 | ||
|
|
901828f5a6 | ||
|
|
2a4b0cbc09 | ||
|
|
c5434efd56 | ||
|
|
b73f283095 | ||
|
|
24ef54f01c | ||
|
|
bff3b85337 | ||
|
|
811d9a7237 | ||
|
|
a764cb8dc2 | ||
|
|
9204b9b286 | ||
|
|
da94faa9bb | ||
|
|
4b53e9a895 | ||
|
|
f5db96187b | ||
|
|
857b191b03 | ||
|
|
09014d1ab5 | ||
|
|
7557b71869 | ||
|
|
9c4751794f | ||
|
|
d07187bd5d | ||
|
|
2c6a6ba440 | ||
|
|
4592bf7817 | ||
|
|
afd6d450a0 | ||
|
|
b134849dcf | ||
|
|
e7d0f6d6da | ||
|
|
16a29b0127 | ||
|
|
1f5596ef16 | ||
|
|
bef05432d0 | ||
|
|
4c5a26698e | ||
|
|
67533d7743 | ||
|
|
0cc86c6348 | ||
|
|
607dd68620 | ||
|
|
7c8cbc0799 | ||
|
|
ec0c2e8c33 | ||
|
|
7f3dbe0552 | ||
|
|
0e9044e0c8 | ||
|
|
3171640193 | ||
|
|
a56cee3485 | ||
|
|
c8ee371982 | ||
|
|
5778daeb60 | ||
|
|
f51f3b9861 | ||
|
|
44dd1a0b02 | ||
|
|
61a00ffcbf | ||
|
|
4b0a0f0a32 | ||
|
|
a3088fb8bc | ||
|
|
88fd1f9eb1 | ||
|
|
15156bac1e | ||
|
|
a898d2e7be | ||
|
|
95b003802c | ||
|
|
95c9eae4ed | ||
|
|
e3814403e4 | ||
|
|
3d16d52dd8 | ||
|
|
1ae47fffb4 | ||
|
|
4e7096b9e2 | ||
|
|
8cc9b7f6a7 | ||
|
|
fb45c1020e | ||
|
|
e9db4ae8f4 | ||
|
|
c46ec32bd6 | ||
|
|
c58a26ed99 | ||
|
|
a66f5e4971 | ||
|
|
574c8c6089 | ||
|
|
67afd95910 | ||
|
|
f7d0cb0be7 | ||
|
|
be9b68a0b1 | ||
|
|
4637414af2 | ||
|
|
4bd92a72bd | ||
|
|
a3be26f3e4 | ||
|
|
675c906cbf | ||
|
|
6be6023236 | ||
|
|
42cee0d018 | ||
|
|
041f725748 | ||
|
|
0594d61631 | ||
|
|
15cae6b765 | ||
|
|
b984116c35 | ||
|
|
13bda6e3f4 | ||
|
|
c0d18549d1 | ||
|
|
3caff72fce | ||
|
|
1313e9c3f4 | ||
|
|
0848d5a39e | ||
|
|
7660646059 | ||
|
|
bcd90fc744 | ||
|
|
638fc22d62 | ||
|
|
c87d365b88 | ||
|
|
aee9602f25 | ||
|
|
976fbd0220 | ||
|
|
afd955d06f | ||
|
|
4d548da66b | ||
|
|
41b70f53d1 | ||
|
|
a47a618bcd | ||
|
|
62170a30af | ||
|
|
780c5ac23c | ||
|
|
9fba519a5a | ||
|
|
3cd0e7d26b | ||
|
|
a8fd6af994 | ||
|
|
4000b89644 | ||
|
|
9c00bbc0b7 | ||
|
|
a2989d3b38 | ||
|
|
fc731b60d5 | ||
|
|
193980dd4a | ||
|
|
35427b0768 | ||
|
|
73ea130e40 | ||
|
|
5667e6aaee | ||
|
|
fbd626131d | ||
|
|
7b82444338 | ||
|
|
8108b9f565 | ||
|
|
c6ddd00cd9 | ||
|
|
20c0c00fa0 | ||
|
|
1f90364ba6 | ||
|
|
49ea4d31a5 | ||
|
|
dc35f1456a | ||
|
|
0ebeb90804 | ||
|
|
3ef5436c98 | ||
|
|
de7996d789 | ||
|
|
ac52d9bae2 | ||
|
|
cb02df3b76 | ||
|
|
5fc5a6f1a6 | ||
|
|
726a0d0394 | ||
|
|
6edf5345a3 | ||
|
|
242bbfdb14 | ||
|
|
89e7712676 | ||
|
|
9525786929 | ||
|
|
72088e41a8 | ||
|
|
a3ed9ff2ef | ||
|
|
ff16dc73ec | ||
|
|
2da4ef5f0f | ||
|
|
eaf481799d | ||
|
|
1f72863aba | ||
|
|
6b353fd8d8 | ||
|
|
56cde4ad79 | ||
|
|
3b86d3c632 | ||
|
|
4ac7a25afb | ||
|
|
8248011a12 | ||
|
|
5f454456d2 | ||
|
|
e99a619c23 | ||
|
|
1fc791bb68 | ||
|
|
f1d83f7c16 | ||
|
|
527bb72bcf | ||
|
|
d78409fd07 | ||
|
|
d5e7e8944f | ||
|
|
fb405a5c1c | ||
|
|
a9e471deca | ||
|
|
9cd15ae337 | ||
|
|
8ed4cc4b0a | ||
|
|
a62de441cf | ||
|
|
02a8999410 | ||
|
|
59c7979d69 | ||
|
|
bb7b28cd8f | ||
|
|
056497b98a | ||
|
|
ac2fb032c4 | ||
|
|
c933bdd5d9 | ||
|
|
89c71a58fa | ||
|
|
27ba85b4ff | ||
|
|
79a75fed8e | ||
|
|
ee7a76b29f | ||
|
|
c53bdc3ce0 | ||
|
|
f36e328751 | ||
|
|
871b5688c2 | ||
|
|
b96076b297 | ||
|
|
d4488e40cf | ||
|
|
7e61497243 | ||
|
|
e71ccdd12a | ||
|
|
202129d491 | ||
|
|
a1700dd503 | ||
|
|
2954776539 | ||
|
|
fb1f122ef7 | ||
|
|
96c63e4689 | ||
|
|
c94936d3dc | ||
|
|
8c22f11087 | ||
|
|
8a089c84a9 | ||
|
|
b631e6f8a2 | ||
|
|
b3b48b032c | ||
|
|
f3e8230eca | ||
|
|
cc9adf9d40 | ||
|
|
15a640d1dc | ||
|
|
c25b9f86db | ||
|
|
ecfd033afb | ||
|
|
f3ed8c7dff | ||
|
|
6089046721 | ||
|
|
44ff92ad4b | ||
|
|
892262eb85 | ||
|
|
2d9cc4d198 | ||
|
|
a0c479485d | ||
|
|
5650f18e50 | ||
|
|
553885d025 | ||
|
|
35de00c4af | ||
|
|
09583e5de5 | ||
|
|
38b0b7cd00 | ||
|
|
8b9c7b0c27 | ||
|
|
1005619bf3 | ||
|
|
3e09cff9cb | ||
|
|
c24384e454 | ||
|
|
f87a543406 | ||
|
|
f752136283 | ||
|
|
7e71622a44 | ||
|
|
da92afb379 | ||
|
|
d3062de5f9 | ||
|
|
f1440b03a8 | ||
|
|
9a8b266cef | ||
|
|
2a9bc57120 | ||
|
|
2ed83a0e30 | ||
|
|
116e8fd30a | ||
|
|
891f11173b | ||
|
|
dfc7996c17 | ||
|
|
dc0561d34f | ||
|
|
4fb0845d79 | ||
|
|
0e0d4837b8 | ||
|
|
a6adde7966 | ||
|
|
7b693132f9 | ||
|
|
3c3114b6ab | ||
|
|
5cdbf58f59 | ||
|
|
6f0a4131a2 | ||
|
|
aa520e2f5d | ||
|
|
2c3b7e9ee8 | ||
|
|
b86a28092a | ||
|
|
d59e5f2133 | ||
|
|
3fdd187102 | ||
|
|
3f085fd8ae | ||
|
|
a4fc131aec | ||
|
|
d7d446c3fc | ||
|
|
212666e603 | ||
|
|
b545c28340 | ||
|
|
72bc345515 | ||
|
|
cc5082a9e3 | ||
|
|
45782a6c6c | ||
|
|
e86d646cce | ||
|
|
92cfc6b8c8 | ||
|
|
82289d9f1f | ||
|
|
4cdbdaaf4e | ||
|
|
ecde2427da | ||
|
|
fed1ec5d83 | ||
|
|
4fbd764ced | ||
|
|
5361079010 | ||
|
|
002d135ef5 | ||
|
|
a39b0a4a78 | ||
|
|
eb5d68422f | ||
|
|
3dc13e5c2e | ||
|
|
16881f057a | ||
|
|
1cd7d0577f | ||
|
|
3c872df97a | ||
|
|
218b7bd2a0 | ||
|
|
4552d6970d | ||
|
|
4b319d15a7 | ||
|
|
0ae3a4172c | ||
|
|
bf0c12f1c4 | ||
|
|
cb5eeecb86 | ||
|
|
8d857cf2be | ||
|
|
6f232c465f | ||
|
|
032d444246 | ||
|
|
49488dd3fb | ||
|
|
9aec3865ff | ||
|
|
b6b7f2051b | ||
|
|
46254a699a | ||
|
|
7b3c287137 | ||
|
|
1a533742a5 | ||
|
|
2027266852 | ||
|
|
946d8b1a7b | ||
|
|
6d2fb5de6f | ||
|
|
91c4a002dd | ||
|
|
4d8112aae5 | ||
|
|
bb53f245cf | ||
|
|
9f31cdbf5b | ||
|
|
9a33039d73 | ||
|
|
7cf3be8333 | ||
|
|
82afb88e53 | ||
|
|
4aa24b5d67 | ||
|
|
57112c21a2 | ||
|
|
0e8ceeb6c9 | ||
|
|
f52b8d1f04 | ||
|
|
f374cc77ae | ||
|
|
7c694e7fae | ||
|
|
932ffc2673 | ||
|
|
3de5438139 | ||
|
|
c4b5f34271 | ||
|
|
22d3ac33a2 | ||
|
|
2e5dd6535a | ||
|
|
eac58a2a50 | ||
|
|
e939ec0e52 | ||
|
|
5b17a14a2a | ||
|
|
8fb8c888f5 | ||
|
|
4a2884509e | ||
|
|
e295235a89 | ||
|
|
ef515a38d0 | ||
|
|
02cff040e3 | ||
|
|
bb0f65a52d | ||
|
|
d51d6a5cc1 | ||
|
|
eb99379a79 | ||
|
|
388eb57d0d | ||
|
|
0b8131392a | ||
|
|
229efbd006 | ||
|
|
a482fa3a8d | ||
|
|
6cf047af39 | ||
|
|
41748c0b3f | ||
|
|
1ce8be3c7e | ||
|
|
32778acf57 | ||
|
|
a3c71473ae | ||
|
|
aceece7e90 | ||
|
|
52efb4f9ef | ||
|
|
6b0d96fe8d | ||
|
|
ad052821b0 | ||
|
|
da7636e60c | ||
|
|
ef01dd0d77 | ||
|
|
03f7d4673f | ||
|
|
94e9c87978 | ||
|
|
501bbbe4df | ||
|
|
c9122a3fee | ||
|
|
8a289d014e | ||
|
|
ddadd38151 | ||
|
|
0b8d0e3cac | ||
|
|
eeb27d38bc | ||
|
|
491a79ec96 | ||
|
|
f429db61af | ||
|
|
2881099602 | ||
|
|
672ae8decf | ||
|
|
2abc7e541d | ||
|
|
45b1f369ac | ||
|
|
3b5d2c8f6f | ||
|
|
5376e16c9f | ||
|
|
af052242fa | ||
|
|
85e0b71545 | ||
|
|
1206d1fcf6 | ||
|
|
f7534dc438 | ||
|
|
97f317254e | ||
|
|
9eaf51e15f | ||
|
|
7221f4ac02 | ||
|
|
1bb6dce239 | ||
|
|
d13db5e8eb | ||
|
|
040b5535f3 | ||
|
|
b44e1618fb | ||
|
|
1e13483bc3 | ||
|
|
f9519d3923 | ||
|
|
86cdfbb79b | ||
|
|
a70585e854 | ||
|
|
040d0a8635 | ||
|
|
efa512ab21 | ||
|
|
9b04aed8b3 | ||
|
|
7087eafe37 | ||
|
|
c81c4af653 | ||
|
|
c05cc9dd02 | ||
|
|
1a0da00f2d | ||
|
|
31b0c1d3d7 | ||
|
|
53c1d40bcf | ||
|
|
97cacb4383 | ||
|
|
e03905abaf | ||
|
|
06eba28b4c | ||
|
|
bbfeac46dd | ||
|
|
2fe4da094a | ||
|
|
b454d8c0f9 | ||
|
|
1f9b5453cc | ||
|
|
3261791e99 | ||
|
|
3bb12e3f45 | ||
|
|
1dc2f7e5a2 | ||
|
|
2531b08538 | ||
|
|
9fcfb5493c | ||
|
|
4576354c51 | ||
|
|
1dcf2ef0c6 | ||
|
|
3642c65e8c | ||
|
|
40e105994a | ||
|
|
f2ee973882 | ||
|
|
3aa30792bf | ||
|
|
6e336fa78e | ||
|
|
900027a6b7 | ||
|
|
38bdca2409 | ||
|
|
7196e476bf | ||
|
|
e0fd3785d9 | ||
|
|
b53ebb6c2a | ||
|
|
1ea80f4447 | ||
|
|
627d3c0a7a | ||
|
|
182cccfc71 | ||
|
|
6a3713e86c | ||
|
|
788da4e4f1 | ||
|
|
fd26d34e19 | ||
|
|
e9fcdc7d2e | ||
|
|
0fe4911d01 | ||
|
|
d4fb09fa80 | ||
|
|
e6d5a37236 | ||
|
|
79fd10ac10 | ||
|
|
a2e6095e44 | ||
|
|
64530471a0 | ||
|
|
e31e831309 | ||
|
|
cf6871df9b | ||
|
|
482e7f1c75 | ||
|
|
aab501e31e | ||
|
|
ceec9e5e1b | ||
|
|
aadebb3cc5 | ||
|
|
657ddd3341 | ||
|
|
62127b6d48 | ||
|
|
f5f405796f | ||
|
|
39873947a3 | ||
|
|
a1079dd948 | ||
|
|
4eeabcc9e0 | ||
|
|
c3568d07e8 | ||
|
|
1adb4a4ba8 | ||
|
|
6d0020533c | ||
|
|
4e6af0a655 | ||
|
|
00f726b515 | ||
|
|
035aa32305 | ||
|
|
62ea4b98e1 | ||
|
|
4be821137d | ||
|
|
7fba9960bf | ||
|
|
876bfbd3cb | ||
|
|
edde2c210b | ||
|
|
f956d96d94 | ||
|
|
c2296fd900 | ||
|
|
0feed5b640 | ||
|
|
93904dcb1b | ||
|
|
86cbdf793a | ||
|
|
56b1b9b598 | ||
|
|
f7ec3ae131 | ||
|
|
01d11d6213 | ||
|
|
74a316e758 | ||
|
|
3fbed815a5 | ||
|
|
d14a1dd948 | ||
|
|
1c0b434f47 | ||
|
|
573451bade |
@@ -12,10 +12,10 @@ insert_final_newline = true
|
|||||||
# Set default charset
|
# Set default charset
|
||||||
charset = utf-8
|
charset = utf-8
|
||||||
|
|
||||||
# 2 space indentation
|
# 4 space indentation
|
||||||
[*.{cjs,mjs,js,jsx,ts,tsx,css,scss,sass,html,json}]
|
[*.{cjs,mjs,js,jsx,ts,tsx,css,scss,sass,html,json}]
|
||||||
indent_style = space
|
indent_style = space
|
||||||
indent_size = 4
|
indent_size = 2
|
||||||
|
|
||||||
[*.bat]
|
[*.bat]
|
||||||
charset = latin1
|
charset = latin1
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
VITE_BUILD_TYPE = Production
|
|
||||||
VITE_BUILD_PLATFORM = Framework
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
VITE_BUILD_TYPE = Production
|
|
||||||
VITE_BUILD_PLATFORM = Shell
|
|
||||||
11
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
11
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -10,13 +10,12 @@ body:
|
|||||||
在提交新的 Bug 反馈前,请确保您:
|
在提交新的 Bug 反馈前,请确保您:
|
||||||
* 已经搜索了现有的 issues,并且没有找到可以解决您问题的方法
|
* 已经搜索了现有的 issues,并且没有找到可以解决您问题的方法
|
||||||
* 不与现有的某一 issue 重复
|
* 不与现有的某一 issue 重复
|
||||||
* 不涉及[已经停止维护的特性](https://github.com/NapNeko/NapCatQQ?tab=readme-ov-file#挥别昨日),例如 CQ 码
|
|
||||||
- type: input
|
- type: input
|
||||||
id: system-version
|
id: system-version
|
||||||
attributes:
|
attributes:
|
||||||
label: 系统版本
|
label: 系统版本
|
||||||
description: 运行 QQNT 的系统版本
|
description: 运行 QQNT 的系统版本
|
||||||
placeholder: Windows 10 Pro Workstation 22H2
|
placeholder: Windows 11 24H2
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: input
|
- type: input
|
||||||
@@ -24,7 +23,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: QQNT 版本
|
label: QQNT 版本
|
||||||
description: 可在 QQNT 的「关于」的设置页中找到
|
description: 可在 QQNT 的「关于」的设置页中找到
|
||||||
placeholder: 9.9.7-21804
|
placeholder: 9.9.16-29927
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: input
|
- type: input
|
||||||
@@ -40,21 +39,21 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: OneBot 客户端
|
label: OneBot 客户端
|
||||||
description: 连接至 NapCat 的客户端版本信息
|
description: 连接至 NapCat 的客户端版本信息
|
||||||
placeholder: Overflow 2.16.0-2cf7991-SNAPSHOT
|
placeholder: Karin 1.0.0
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: what-happened
|
id: what-happened
|
||||||
attributes:
|
attributes:
|
||||||
label: 发生了什么?
|
label: 发生了什么?
|
||||||
description: 填写你认为的 NapCat 的不正常行为
|
description: 填写你认为的 NapCat 的异常行为
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: how-reproduce
|
id: how-reproduce
|
||||||
attributes:
|
attributes:
|
||||||
label: 如何复现
|
label: 如何复现
|
||||||
description: 填写应当如何操作才能触发这个不正常行为
|
description: 填写应当如何操作才能触发这个异常行为
|
||||||
placeholder: |
|
placeholder: |
|
||||||
1. xxx
|
1. xxx
|
||||||
2. xxx
|
2. xxx
|
||||||
|
|||||||
11
.github/dependabot.yml
vendored
11
.github/dependabot.yml
vendored
@@ -1,11 +1,6 @@
|
|||||||
# To get started with Dependabot version updates, you'll need to specify which
|
|
||||||
# package ecosystems to update and where the package manifests are located.
|
|
||||||
# Please see the documentation for all configuration options:
|
|
||||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
|
||||||
|
|
||||||
version: 2
|
version: 2
|
||||||
updates:
|
updates:
|
||||||
- package-ecosystem: "npm" # See documentation for possible values
|
- package-ecosystem: "npm"
|
||||||
directory: "/" # Location of package manifests
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "daily"
|
interval: "weekly"
|
||||||
|
|||||||
27
.github/prompt/default.md
vendored
Normal file
27
.github/prompt/default.md
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# V?.?.?
|
||||||
|
[使用文档](https://napneko.github.io/)
|
||||||
|
|
||||||
|
## Windows 一键包
|
||||||
|
我们为提供了的轻量化一键部署方案
|
||||||
|
相对于普通需要安装QQ的方案,下面已内置QQ和Napcat 阅读使用文档参考
|
||||||
|
|
||||||
|
你可以下载
|
||||||
|
|
||||||
|
NapCat.Shell.Windows.OneKey.zip (无头)
|
||||||
|
|
||||||
|
启动后可自动化部署一键包,教程参考使用文档安装部分
|
||||||
|
|
||||||
|
## 警告
|
||||||
|
**注意QQ版本推荐使用 40768+ 版本 最低可以使用40768版本**
|
||||||
|
**默认WebUi密钥为随机密码 控制台查看**
|
||||||
|
|
||||||
|
**[9.9.22-40990 X64 Win](https://dldir1v6.qq.com/qqfile/qq/QQNT/2c9d3f6c/QQ9.9.22.40990_x64.exe)**
|
||||||
|
[LinuxX64 DEB 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_amd64.deb)
|
||||||
|
[LinuxX64 RPM 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_x86_64.rpm)
|
||||||
|
[LinuxArm64 DEB 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_arm64.deb)
|
||||||
|
[LinuxArm64 RPM 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_aarch64.rpm)
|
||||||
|
[MAC DMG 40990 ](https://dldir1v6.qq.com/qqfile/qq/QQNT/c6cb0f5d/QQ_v6.9.82.40990.dmg)
|
||||||
|
## 如果WinX64缺少运行库或者xxx.dll?
|
||||||
|
[安装运行库](https://aka.ms/vs/17/release/vc_redist.x64.exe)
|
||||||
|
|
||||||
|
## 更新
|
||||||
60
.github/prompt/release_note_prompt.txt
vendored
Normal file
60
.github/prompt/release_note_prompt.txt
vendored
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
注意:输出必须严格使用 NapCat 的发布说明格式,严格保证示例格式,并用简体中文。
|
||||||
|
|
||||||
|
格式规则:
|
||||||
|
1. 第一行:# V{TAG}
|
||||||
|
2. 第二行:[使用文档](https://napneko.github.io/)
|
||||||
|
3. 空行后,按下面的节顺序输出(存在则输出,不存在则省略该节):
|
||||||
|
|
||||||
|
## Windows 一键包
|
||||||
|
- 简短一句话介绍一键包用途
|
||||||
|
- 列出可下载的文件名(只列文件名,不写下载链接)
|
||||||
|
|
||||||
|
## 警告
|
||||||
|
- 如果有需要特别提醒的兼容/运行库/版本要求,写成加粗警告句
|
||||||
|
|
||||||
|
## 如果WinX64缺少运行库或者xxx.dll?
|
||||||
|
- 常见运行库建议
|
||||||
|
|
||||||
|
## 更新
|
||||||
|
按数字序列列出主要变更项,每条尽量一句话
|
||||||
|
- 前缀短 commit id,例如:1. 修复 get_essence_msg_list 崩溃 (a1b2c3d)
|
||||||
|
- 保持 4-18 条要点
|
||||||
|
|
||||||
|
## 开发者注意
|
||||||
|
- 列出迁移/接口断裂/配置变更;若无则省略
|
||||||
|
|
||||||
|
额外约束:
|
||||||
|
- 语言简体中文,面向最终用户
|
||||||
|
|
||||||
|
下面为真实示例,请完全参考(第一行版本号必须使用用户提供的版本号,例如 v4.9.5)
|
||||||
|
|
||||||
|
# V4.9.0
|
||||||
|
[使用文档](https://napneko.github.io/)
|
||||||
|
|
||||||
|
## Windows 一键包
|
||||||
|
我们为提供了的轻量化一键部署方案
|
||||||
|
相对于普通需要安装QQ的方案,下面已内置QQ和Napcat 阅读使用文档参考
|
||||||
|
|
||||||
|
你可以下载
|
||||||
|
|
||||||
|
NapCat.Shell.Windows.OneKey.zip (无头)
|
||||||
|
|
||||||
|
启动后可自动化部署一键包,教程参考使用文档安装部分
|
||||||
|
|
||||||
|
## 警告
|
||||||
|
**注意QQ版本推荐使用 40768+ 版本 最低可以使用40768版本**
|
||||||
|
**默认WebUi密钥为随机密码 控制台查看**
|
||||||
|
|
||||||
|
**[9.9.22-40990 X64 Win](https://dldir1v6.qq.com/qqfile/qq/QQNT/2c9d3f6c/QQ9.9.22.40990_x64.exe)**
|
||||||
|
[LinuxX64 DEB 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_amd64.deb)
|
||||||
|
[LinuxX64 RPM 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_x86_64.rpm)
|
||||||
|
[LinuxArm64 DEB 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_arm64.deb)
|
||||||
|
[LinuxArm64 RPM 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_aarch64.rpm)
|
||||||
|
[MAC DMG 40990 ](https://dldir1v6.qq.com/qqfile/qq/QQNT/c6cb0f5d/QQ_v6.9.82.40990.dmg)
|
||||||
|
## 如果WinX64缺少运行库或者xxx.dll?
|
||||||
|
[安装运行库](https://aka.ms/vs/17/release/vc_redist.x64.exe)
|
||||||
|
|
||||||
|
## 更新
|
||||||
|
1. 修改了XXXXX
|
||||||
|
2. 新增了XXXX
|
||||||
|
3. 重构了XXXX
|
||||||
264
.github/workflows/auto-release.yml
vendored
Normal file
264
.github/workflows/auto-release.yml
vendored
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
name: AI RELEASE NapCat
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
|
|
||||||
|
permissions: write-all
|
||||||
|
|
||||||
|
env:
|
||||||
|
OPENROUTER_API_URL: https://91vip.futureppo.top/v1/chat/completions
|
||||||
|
OPENROUTER_MODEL: "kimi-k2-0905-turbo"
|
||||||
|
RELEASE_NAME: "NapCat"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
Build-LiteLoader:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Clone Main Repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Use Node.js 20.X
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20.x
|
||||||
|
- name: Build NapCat.Framework
|
||||||
|
run: |
|
||||||
|
npm i -g pnpm
|
||||||
|
pnpm i
|
||||||
|
pnpm --filter napcat-webui-frontend run build || exit 1
|
||||||
|
pnpm run build:framework
|
||||||
|
mv packages/napcat-framework/dist framework-dist
|
||||||
|
cd framework-dist
|
||||||
|
npm install --omit=dev
|
||||||
|
rm ./package-lock.json || exit 0
|
||||||
|
- name: Upload Artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: NapCat.Framework
|
||||||
|
path: framework-dist
|
||||||
|
|
||||||
|
Build-Shell:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Clone Main Repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Use Node.js 20.X
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20.x
|
||||||
|
- name: Build NapCat.Shell
|
||||||
|
run: |
|
||||||
|
npm i -g pnpm
|
||||||
|
pnpm i
|
||||||
|
pnpm --filter napcat-webui-frontend run build || exit 1
|
||||||
|
pnpm run build:shell
|
||||||
|
mv packages/napcat-shell/dist shell-dist
|
||||||
|
cd shell-dist
|
||||||
|
npm install --omit=dev
|
||||||
|
rm ./package-lock.json || exit 0
|
||||||
|
- name: Upload Artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: NapCat.Shell
|
||||||
|
path: shell-dist
|
||||||
|
Download-QNX64:
|
||||||
|
needs: Build-Shell
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download Artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: ./artifacts
|
||||||
|
|
||||||
|
- name: Setup tools
|
||||||
|
run: |
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y aria2 unzip zip p7zip-full curl jq
|
||||||
|
|
||||||
|
- name: Download QQ x64, Node.js and Assemble NapCat.Shell.Windows.Node.zip
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TMPDIR=$(mktemp -d)
|
||||||
|
cd "$TMPDIR"
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# 1) 下载 QQ x64
|
||||||
|
# -----------------------------
|
||||||
|
# JS_URL="https://cdn-go.cn/qq-web/im.qq.com_new/latest/rainbow/windowsConfig.js"
|
||||||
|
# JS_URL="https://slave.docadan488.workers.dev/proxy?url=https://cdn-go.cn/qq-web/im.qq.com_new/latest/rainbow/windowsConfig.js"
|
||||||
|
# NT_URL=$(curl -fsSL "$JS_URL" | grep -oP '"ntDownloadX64Url"\s*:\s*"\K[^"]+')
|
||||||
|
NT_URL="https://dldir1v6.qq.com/qqfile/qq/QQNT/eb263b35/QQ9.9.23.42086_x64.exe"
|
||||||
|
QQ_ZIP="$(basename "$NT_URL")"
|
||||||
|
aria2c -x16 -s16 -k1M -o "$QQ_ZIP" "$NT_URL"
|
||||||
|
|
||||||
|
QQ_EXTRACT="$TMPDIR/qq_extracted"
|
||||||
|
mkdir -p "$QQ_EXTRACT"
|
||||||
|
7z x -y -o"$QQ_EXTRACT" "$QQ_ZIP" >/dev/null
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# 2) 下载 Node.js Windows x64 zip 22.11.0
|
||||||
|
# -----------------------------
|
||||||
|
NODE_VER="22.11.0"
|
||||||
|
NODE_URL="https://nodejs.org/dist/v$NODE_VER/node-v$NODE_VER-win-x64.zip"
|
||||||
|
NODE_ZIP="node-v$NODE_VER-win-x64.zip"
|
||||||
|
aria2c -x1 -s1 -k1M -o "$NODE_ZIP" "$NODE_URL"
|
||||||
|
|
||||||
|
NODE_EXTRACT="$TMPDIR/node_extracted"
|
||||||
|
mkdir -p "$NODE_EXTRACT"
|
||||||
|
unzip -q "$NODE_ZIP" -d "$NODE_EXTRACT"
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# 3) 创建输出目录
|
||||||
|
# -----------------------------
|
||||||
|
OUT_DIR="$GITHUB_WORKSPACE/NapCat.Shell.Windows.Node"
|
||||||
|
mkdir -p "$OUT_DIR/NapCat.Shell.Windows.Node"
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# 4) 解压 NapCat.Shell.zip 到 napcat
|
||||||
|
# -----------------------------
|
||||||
|
cp -a "$GITHUB_WORKSPACE/artifacts/NapCat.Shell/." "$OUT_DIR/napcat/"
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# 5) 拷贝 QQ 文件到 NapCat.Shell.Windows.Node
|
||||||
|
# -----------------------------
|
||||||
|
QQ_TARGETS=("avif_convert.dll" "broadcast_ipc.dll" "config.json" "libglib-2.0-0.dll" "libgobject-2.0-0.dll" "libvips-42.dll" "ncnn.dll" "opencv.dll" "package.json" "QBar.dll" "wrapper.node")
|
||||||
|
for name in "${QQ_TARGETS[@]}"; do
|
||||||
|
find "$QQ_EXTRACT" -iname "$name" -exec cp -a {} "$OUT_DIR" \; || true
|
||||||
|
done
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# 6) 拷贝仓库文件 napcat.bat 和 index.js
|
||||||
|
# -----------------------------
|
||||||
|
cp -a "$GITHUB_WORKSPACE/packages/napcat-develop/napcat.bat" "$OUT_DIR/" || true
|
||||||
|
cp -a "$GITHUB_WORKSPACE/packages/napcat-develop/index.js" "$OUT_DIR/" || true
|
||||||
|
cp -a "$GITHUB_WORKSPACE/packages/napcat-develop/QQNT.dll" "$OUT_DIR/" || true
|
||||||
|
# -----------------------------
|
||||||
|
# 7) 拷贝 Node.exe 到 NapCat.Shell.Windows.Node
|
||||||
|
# -----------------------------
|
||||||
|
cp -a "$NODE_EXTRACT/node-v$NODE_VER-win-x64/node.exe" "$OUT_DIR/" || true
|
||||||
|
|
||||||
|
- name: Upload Artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: NapCat.Shell.Windows.Node
|
||||||
|
path: NapCat.Shell.Windows.Node
|
||||||
|
|
||||||
|
release-napcat:
|
||||||
|
needs: [Build-LiteLoader, Build-Shell, Download-QNX64]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download Artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: ./artifacts
|
||||||
|
|
||||||
|
- name: Zip Artifacts
|
||||||
|
run: |
|
||||||
|
cd artifacts
|
||||||
|
[ -d NapCat.Framework ] && (cd NapCat.Framework && zip -qr ../../NapCat.Framework.zip .)
|
||||||
|
[ -d NapCat.Shell ] && (cd NapCat.Shell && zip -qr ../../NapCat.Shell.zip .)
|
||||||
|
[ -d NapCat.Shell.Windows.Node ] && (cd NapCat.Shell.Windows.Node && zip -qr ../../NapCat.Shell.Windows.Node.zip .)
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
- name: Generate release note via OpenRouter
|
||||||
|
env:
|
||||||
|
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||||
|
OPENROUTER_API_URL: ${{ env.OPENROUTER_API_URL }}
|
||||||
|
OPENROUTER_MODEL: ${{ env.OPENROUTER_MODEL }}
|
||||||
|
GITHUB_OWNER: "NapNeKo" # 替换成你的 repo owner
|
||||||
|
GITHUB_REPO: "NapCatQQ" # 替换成你的 repo 名
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# 当前 tag
|
||||||
|
CURRENT_TAG="${GITHUB_REF#refs/tags/}"
|
||||||
|
echo "Current tag: $CURRENT_TAG"
|
||||||
|
|
||||||
|
# 从 GitHub API 获取 tag 列表
|
||||||
|
TAGS_JSON=$(curl -s "https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/tags?per_page=100")
|
||||||
|
TAGS=( $(echo "$TAGS_JSON" | jq -r '.[].name' | sort -V) )
|
||||||
|
|
||||||
|
# 找到上一个 tag
|
||||||
|
PREV_TAG=""
|
||||||
|
for i in "${!TAGS[@]}"; do
|
||||||
|
if [ "${TAGS[$i]}" = "$CURRENT_TAG" ]; then
|
||||||
|
if [ $i -gt 0 ]; then
|
||||||
|
PREV_TAG="${TAGS[$((i-1))]}"
|
||||||
|
fi
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$PREV_TAG" ]; then
|
||||||
|
echo "❌ Could not find previous tag for $CURRENT_TAG, aborting."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Previous tag: $PREV_TAG"
|
||||||
|
|
||||||
|
# 强制拉取上一个 tag 和当前 tag
|
||||||
|
git fetch origin "refs/tags/$PREV_TAG:refs/tags/$PREV_TAG" --force
|
||||||
|
git fetch origin "refs/tags/$CURRENT_TAG:refs/tags/$CURRENT_TAG" --force
|
||||||
|
|
||||||
|
# 获取 commit title + body + 作者,保留换行
|
||||||
|
COMMITS=$(git log --pretty=format:'%h %B (%an)' "$PREV_TAG".."$CURRENT_TAG" | sed 's/$/\\n/')
|
||||||
|
|
||||||
|
echo "Commit list from $PREV_TAG to $CURRENT_TAG:"
|
||||||
|
echo -e "$COMMITS"
|
||||||
|
|
||||||
|
# 读取 prompt
|
||||||
|
PROMPT_FILE=".github/prompt/release_note_prompt.txt"
|
||||||
|
SYSTEM_PROMPT=$(<"$PROMPT_FILE")
|
||||||
|
|
||||||
|
# 构建用户内容
|
||||||
|
USER_CONTENT="当前真正的版本: $CURRENT_TAG\n提交列表:\n$COMMITS"
|
||||||
|
|
||||||
|
# 构建请求 JSON
|
||||||
|
BODY=$(jq -n \
|
||||||
|
--arg system "$SYSTEM_PROMPT" \
|
||||||
|
--arg user "$USER_CONTENT" \
|
||||||
|
'{model: env.OPENROUTER_MODEL, messages:[{role:"system", content:$system},{role:"user", content:$user}], temperature:0.3, max_tokens:800}')
|
||||||
|
|
||||||
|
echo "=== OpenRouter request body ==="
|
||||||
|
echo "$BODY" | jq .
|
||||||
|
|
||||||
|
# 调用 OpenRouter
|
||||||
|
RESPONSE=$(curl -s -X POST "$OPENROUTER_API_URL" \
|
||||||
|
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$BODY")
|
||||||
|
|
||||||
|
echo "=== OpenRouter raw response ==="
|
||||||
|
echo "$RESPONSE" | jq .
|
||||||
|
|
||||||
|
# 提取生成内容
|
||||||
|
RELEASE_BODY=$(echo "$RESPONSE" | jq -r '.choices[0].message.content // .choices[0].text // ""')
|
||||||
|
|
||||||
|
if [ -z "$RELEASE_BODY" ]; then
|
||||||
|
echo "❌ OpenRouter failed to generate release note, terminating workflow."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 输出到 CHANGELOG.md
|
||||||
|
echo -e "$RELEASE_BODY" > CHANGELOG.md
|
||||||
|
echo "=== generated release note ==="
|
||||||
|
cat CHANGELOG.md
|
||||||
|
|
||||||
|
- name: Create Release Draft and Upload Artifacts
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
name: NapCat ${{ github.ref_name }}
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
body_path: CHANGELOG.md
|
||||||
|
files: |
|
||||||
|
NapCat.Shell.Windows.Node.zip
|
||||||
|
NapCat.Framework.zip
|
||||||
|
NapCat.Shell.zip
|
||||||
|
draft: true
|
||||||
31
.github/workflows/build.yml
vendored
31
.github/workflows/build.yml
vendored
@@ -1,8 +1,9 @@
|
|||||||
name: "Build Action"
|
name: "Build Action"
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
pull_request:
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
permissions: write-all
|
permissions: write-all
|
||||||
|
|
||||||
@@ -18,14 +19,19 @@ jobs:
|
|||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
- name: Build NapCat.Framework
|
- name: Build NapCat.Framework
|
||||||
run: |
|
run: |
|
||||||
npm i && cd napcat.webui && npm i && cd .. || exit 1
|
npm i -g pnpm
|
||||||
npm run build:framework && npm run depend || exit 1
|
pnpm i
|
||||||
rm package-lock.json
|
pnpm --filter napcat-webui-frontend run build || exit 1
|
||||||
|
pnpm run build:framework
|
||||||
|
mv packages/napcat-framework/dist framework-dist
|
||||||
|
cd framework-dist
|
||||||
|
npm install --omit=dev
|
||||||
|
rm ./package-lock.json || exit 0
|
||||||
- name: Upload Artifact
|
- name: Upload Artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: NapCat.Framework
|
name: NapCat.Framework
|
||||||
path: dist
|
path: framework-dist
|
||||||
Build-Shell:
|
Build-Shell:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -37,11 +43,16 @@ jobs:
|
|||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
- name: Build NapCat.Shell
|
- name: Build NapCat.Shell
|
||||||
run: |
|
run: |
|
||||||
npm i && cd napcat.webui && npm i && cd .. || exit 1
|
npm i -g pnpm
|
||||||
npm run build:shell && npm run depend || exit 1
|
pnpm i
|
||||||
rm package-lock.json
|
pnpm --filter napcat-webui-frontend run build || exit 1
|
||||||
|
pnpm run build:shell
|
||||||
|
mv packages/napcat-shell/dist shell-dist
|
||||||
|
cd shell-dist
|
||||||
|
npm install --omit=dev
|
||||||
|
rm ./package-lock.json || exit 0
|
||||||
- name: Upload Artifact
|
- name: Upload Artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: NapCat.Shell
|
name: NapCat.Shell
|
||||||
path: dist
|
path: shell-dist
|
||||||
|
|||||||
114
.github/workflows/release.yml
vendored
114
.github/workflows/release.yml
vendored
@@ -1,113 +1,65 @@
|
|||||||
name: "Build Release"
|
name: "Build Release"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_dispatch:
|
||||||
tags:
|
|
||||||
- "v*"
|
|
||||||
|
|
||||||
permissions: write-all
|
permissions: write-all
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-version:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Clone Repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
ref: main
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Extract version from tag
|
|
||||||
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Use Node.js 20.X
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20.x
|
|
||||||
|
|
||||||
- name: Check Version
|
|
||||||
run: |
|
|
||||||
ls
|
|
||||||
node ./script/checkVersion.cjs
|
|
||||||
sh ./checkVersion.sh
|
|
||||||
Build-LiteLoader:
|
Build-LiteLoader:
|
||||||
needs: [check-version]
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Clone Main Repository
|
- name: Clone Main Repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
repository: 'NapNeko/NapCatQQ'
|
|
||||||
submodules: true
|
|
||||||
ref: main
|
|
||||||
token: ${{ secrets.NAPCAT_BUILD }}
|
|
||||||
- name: Use Node.js 20.X
|
- name: Use Node.js 20.X
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
|
- name: Build NapCat.Framework
|
||||||
- name: Build NuCat Framework
|
|
||||||
run: |
|
run: |
|
||||||
npm i
|
npm i -g pnpm
|
||||||
cd napcat.webui
|
pnpm i
|
||||||
npm i
|
pnpm --filter napcat-webui-frontend run build || exit 1
|
||||||
cd ..
|
pnpm run build:framework
|
||||||
npm run build:framework
|
mv packages/napcat-framework/dist framework-dist
|
||||||
cd dist
|
cd framework-dist
|
||||||
npm i --omit=dev
|
npm install --omit=dev
|
||||||
cd ..
|
rm ./package-lock.json || exit 0
|
||||||
- name: Upload Artifact
|
- name: Upload Artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: NapCat.Framework
|
name: NapCat.Framework
|
||||||
path: dist
|
path: framework-dist
|
||||||
|
|
||||||
Build-Shell:
|
Build-Shell:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [check-version]
|
|
||||||
steps:
|
steps:
|
||||||
- name: Clone Main Repository
|
- name: Clone Main Repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
repository: 'NapNeko/NapCatQQ'
|
|
||||||
submodules: true
|
|
||||||
ref: main
|
|
||||||
token: ${{ secrets.NAPCAT_BUILD }}
|
|
||||||
|
|
||||||
- name: Use Node.js 20.X
|
- name: Use Node.js 20.X
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
|
- name: Build NapCat.Shell
|
||||||
- name: Build NuCat Shell
|
|
||||||
run: |
|
run: |
|
||||||
npm i
|
npm i -g pnpm
|
||||||
cd napcat.webui
|
pnpm i
|
||||||
npm i
|
pnpm --filter napcat-webui-frontend run build || exit 1
|
||||||
cd ..
|
pnpm run build:shell
|
||||||
npm run build:shell
|
mv packages/napcat-shell/dist shell-dist
|
||||||
cd dist
|
cd shell-dist
|
||||||
npm i --omit=dev
|
npm install --omit=dev
|
||||||
cd ..
|
rm ./package-lock.json || exit 0
|
||||||
|
|
||||||
- name: Upload Artifact
|
- name: Upload Artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: NapCat.Shell
|
name: NapCat.Shell
|
||||||
path: dist
|
path: shell-dist
|
||||||
|
|
||||||
release-napcat:
|
release-napcat:
|
||||||
needs: [Build-LiteLoader,Build-Shell]
|
needs: [Build-LiteLoader, Build-Shell]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|
||||||
- name: Clone Main Repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
repository: 'NapNeko/NapCatQQ'
|
|
||||||
submodules: true
|
|
||||||
ref: main
|
|
||||||
token: ${{ secrets.NAPCAT_BUILD }}
|
|
||||||
|
|
||||||
- name: Download All Artifact
|
- name: Download All Artifact
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
|
|
||||||
@@ -124,29 +76,13 @@ jobs:
|
|||||||
mv ./NapCat.Shell/NapCat.Shell.zip ./
|
mv ./NapCat.Shell/NapCat.Shell.zip ./
|
||||||
mv ./NapCat.Framework/NapCat.Framework.zip ./
|
mv ./NapCat.Framework/NapCat.Framework.zip ./
|
||||||
|
|
||||||
mkdir ./NapCat.Framework.Windows.Once
|
|
||||||
unzip -q ./external/LiteLoaderWrapper.zip -d ./NapCat.Framework.Windows.Once
|
|
||||||
cd ./NapCat.Framework.Windows.Once
|
|
||||||
ls
|
|
||||||
mkdir -p ./LL/plugins/NapCatQQ
|
|
||||||
unzip -q ../NapCat.Framework.zip -d ./LL/plugins/NapCatQQ
|
|
||||||
zip -q -r NapCat.Framework.Windows.Once.zip *
|
|
||||||
cd ..
|
|
||||||
mv ./NapCat.Framework.Windows.Once/NapCat.Framework.Windows.Once.zip ./
|
|
||||||
- name: Extract version from tag
|
|
||||||
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Clone Changes Log
|
|
||||||
run: curl -o CHANGELOG.md https://fastly.jsdelivr.net/gh/NapNeko/NapCatQQ@main/docs/changelogs/CHANGELOG.v${{ env.VERSION }}.md
|
|
||||||
|
|
||||||
- name: Create Release Draft and Upload Artifacts
|
- name: Create Release Draft and Upload Artifacts
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
with:
|
with:
|
||||||
name: NapCat V${{ env.VERSION }}
|
name: NapCat
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
body_path: CHANGELOG.md
|
body: Automated release artifact (no version detection)
|
||||||
files: |
|
files: |
|
||||||
NapCat.Framework.zip
|
NapCat.Framework.zip
|
||||||
NapCat.Shell.zip
|
NapCat.Shell.zip
|
||||||
NapCat.Framework.Windows.Once.zip
|
|
||||||
draft: true
|
draft: true
|
||||||
83
.github/workflows/trigger-docker-publish.yml
vendored
Normal file
83
.github/workflows/trigger-docker-publish.yml
vendored
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
name: Trigger Docker Publish on Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
shell-docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Trigger NapCat-Docker docker-publish workflow
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.NAPCAT_BUILD }}
|
||||||
|
run: |
|
||||||
|
curl -X POST \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
-H "Authorization: Bearer $GH_TOKEN" \
|
||||||
|
https://api.github.com/repos/NapNeko/NapCat-Docker/actions/workflows/docker-publish.yml/dispatches \
|
||||||
|
-d '{"ref":"main"}'
|
||||||
|
framework-docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Trigger NapCat-Framework-Docker docker-publish workflow
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.NAPCAT_BUILD }}
|
||||||
|
run: |
|
||||||
|
curl -X POST \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
-H "Authorization: Bearer $GH_TOKEN" \
|
||||||
|
https://api.github.com/repos/NapNeko/NapCat.Docker.Framework/actions/workflows/docker-image.yml/dispatches \
|
||||||
|
-d '{"ref":"main"}'
|
||||||
|
appimage-shell-docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Get Latest NapCat Version
|
||||||
|
id: get_version
|
||||||
|
run: |
|
||||||
|
# 获取当前仓库的最新 tag
|
||||||
|
latest_tag=$(git describe --tags $(git rev-list --tags --max-count=1))
|
||||||
|
# 输出调试信息
|
||||||
|
echo "Debug: Latest NapCat Version is ${latest_tag}"
|
||||||
|
echo "latest_tag=${latest_tag}" >> $GITHUB_ENV
|
||||||
|
- name: Trigger Release NapCat AppImage Workflow
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.NAPCAT_BUILD }}
|
||||||
|
NAPCAT_VERSION: ${{ env.latest_tag }}
|
||||||
|
QQ_VERSION_X86_64: 'https://dldir1v6.qq.com/qqfile/qq/QQNT/8015ff90/linuxqq_3.2.21-42086_x86_64.AppImage' # 写死 QQ 版本
|
||||||
|
QQ_VERSION_ARM64: 'https://dldir1v6.qq.com/qqfile/qq/QQNT/8015ff90/linuxqq_3.2.21-42086_arm64.AppImage' # 写死 QQ 版本
|
||||||
|
run: |
|
||||||
|
echo "Debug: Triggering Release NapCat AppImage with napcat_version=${NAPCAT_VERSION}, qq_version_x86_64=${QQ_VERSION_X86_64}, qq_version_arm64=${QQ_VERSION_ARM64}"
|
||||||
|
curl -X POST \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
-H "Authorization: Bearer $GH_TOKEN" \
|
||||||
|
https://api.github.com/repos/NapNeko/NapCatAppImageBuild/actions/workflows/release.yml/dispatches \
|
||||||
|
-d "{\"ref\":\"main\",\"inputs\":{\"napcat_version\":\"${NAPCAT_VERSION}\",\"qq_version_x86_64\":\"${QQ_VERSION_X86_64}\",\"qq_version_arm64\":\"${QQ_VERSION_ARM64}\"}}"
|
||||||
|
trigger-napcat-release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Get Latest NapCat Version
|
||||||
|
id: get_version
|
||||||
|
run: |
|
||||||
|
# 获取当前仓库的最新 tag
|
||||||
|
latest_tag=$(git describe --tags $(git rev-list --tags --max-count=1))
|
||||||
|
# 输出调试信息
|
||||||
|
echo "Debug: Latest NapCat Version is ${latest_tag}"
|
||||||
|
echo "latest_tag=${latest_tag}" >> $GITHUB_ENV
|
||||||
|
- name: Trigger Release NapCat AppImage Workflow
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.NAPCAT_BUILD }}
|
||||||
|
NAPCAT_VERSION: ${{ env.latest_tag }}
|
||||||
|
QQ_VERSION_X86_64: 'https://dldir1v6.qq.com/qqfile/qq/QQNT/8015ff90/linuxqq_3.2.21-42086_x86_64.AppImage' # 写死 QQ 版本
|
||||||
|
QQ_VERSION_ARM64: 'https://dldir1v6.qq.com/qqfile/qq/QQNT/8015ff90/linuxqq_3.2.21-42086_arm64.AppImage' # 写死 QQ 版本
|
||||||
|
run: |
|
||||||
|
echo "Debug: Triggering Release NapCat AppImage with napcat_version=${NAPCAT_VERSION}, qq_url_amd64=${QQ_VERSION_X86_64}, qq_url_arm64=${QQ_VERSION_ARM64}"
|
||||||
|
curl -X POST \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
-H "Authorization: Bearer $GH_TOKEN" \
|
||||||
|
https://api.github.com/repos/NapNeko/NapCatLinuxNodeLoader/actions/workflows/release.yml/dispatches \
|
||||||
|
-d "{\"ref\":\"main\",\"inputs\":{\"napcat_version\":\"${NAPCAT_VERSION}\",\"qq_url_amd64\":\"${QQ_VERSION_X86_64}\",\"qq_url_arm64\":\"${QQ_VERSION_ARM64}\"}}"
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,14 +1,12 @@
|
|||||||
# Develop
|
# Develop
|
||||||
node_modules/
|
node_modules/
|
||||||
package-lock.json
|
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
out/
|
out/
|
||||||
dist/
|
dist/
|
||||||
/src/core.lib/common/
|
/src/core.lib/common/
|
||||||
/localdebug/
|
devconfig/*
|
||||||
|
|
||||||
# Editor
|
# Editor
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
.idea/*
|
.idea/*
|
||||||
|
|
||||||
@@ -16,3 +14,6 @@ dist/
|
|||||||
*.db
|
*.db
|
||||||
checkVersion.sh
|
checkVersion.sh
|
||||||
bun.lockb
|
bun.lockb
|
||||||
|
tests/run/
|
||||||
|
guild1.db-wal
|
||||||
|
guild1.db-shm
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"trailingComma": "es5",
|
|
||||||
"tabWidth": 4,
|
|
||||||
"semi": true,
|
|
||||||
"singleQuote": true,
|
|
||||||
"bracketSpacing": true,
|
|
||||||
"arrowParens": "always",
|
|
||||||
"printWidth": 120,
|
|
||||||
"endOfLine": "auto"
|
|
||||||
}
|
|
||||||
2
.vscode/settings.json
vendored
Normal file
2
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
{
|
||||||
|
}
|
||||||
128
CODE_OF_CONDUCT.md
Normal file
128
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# Contributor Covenant Code of Conduct
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
We as members, contributors, and leaders pledge to make participation in our
|
||||||
|
community a harassment-free experience for everyone, regardless of age, body
|
||||||
|
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||||
|
identity and expression, level of experience, education, socio-economic status,
|
||||||
|
nationality, personal appearance, race, religion, or sexual identity
|
||||||
|
and orientation.
|
||||||
|
|
||||||
|
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||||
|
diverse, inclusive, and healthy community.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to a positive environment for our
|
||||||
|
community include:
|
||||||
|
|
||||||
|
* Demonstrating empathy and kindness toward other people
|
||||||
|
* Being respectful of differing opinions, viewpoints, and experiences
|
||||||
|
* Giving and gracefully accepting constructive feedback
|
||||||
|
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||||
|
and learning from the experience
|
||||||
|
* Focusing on what is best not just for us as individuals, but for the
|
||||||
|
overall community
|
||||||
|
|
||||||
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
|
* The use of sexualized language or imagery, and sexual attention or
|
||||||
|
advances of any kind
|
||||||
|
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
|
* Public or private harassment
|
||||||
|
* Publishing others' private information, such as a physical or email
|
||||||
|
address, without their explicit permission
|
||||||
|
* Other conduct which could reasonably be considered inappropriate in a
|
||||||
|
professional setting
|
||||||
|
|
||||||
|
## Enforcement Responsibilities
|
||||||
|
|
||||||
|
Community leaders are responsible for clarifying and enforcing our standards of
|
||||||
|
acceptable behavior and will take appropriate and fair corrective action in
|
||||||
|
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||||
|
or harmful.
|
||||||
|
|
||||||
|
Community leaders have the right and responsibility to remove, edit, or reject
|
||||||
|
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||||
|
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||||
|
decisions when appropriate.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies within all community spaces, and also applies when
|
||||||
|
an individual is officially representing the community in public spaces.
|
||||||
|
Examples of representing our community include using an official e-mail address,
|
||||||
|
posting via an official social media account, or acting as an appointed
|
||||||
|
representative at an online or offline event.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||||
|
reported to the community leaders responsible for enforcement at
|
||||||
|
nanaeonn@outlook.com.
|
||||||
|
All complaints will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
|
All community leaders are obligated to respect the privacy and security of the
|
||||||
|
reporter of any incident.
|
||||||
|
|
||||||
|
## Enforcement Guidelines
|
||||||
|
|
||||||
|
Community leaders will follow these Community Impact Guidelines in determining
|
||||||
|
the consequences for any action they deem in violation of this Code of Conduct:
|
||||||
|
|
||||||
|
### 1. Correction
|
||||||
|
|
||||||
|
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||||
|
unprofessional or unwelcome in the community.
|
||||||
|
|
||||||
|
**Consequence**: A private, written warning from community leaders, providing
|
||||||
|
clarity around the nature of the violation and an explanation of why the
|
||||||
|
behavior was inappropriate. A public apology may be requested.
|
||||||
|
|
||||||
|
### 2. Warning
|
||||||
|
|
||||||
|
**Community Impact**: A violation through a single incident or series
|
||||||
|
of actions.
|
||||||
|
|
||||||
|
**Consequence**: A warning with consequences for continued behavior. No
|
||||||
|
interaction with the people involved, including unsolicited interaction with
|
||||||
|
those enforcing the Code of Conduct, for a specified period of time. This
|
||||||
|
includes avoiding interactions in community spaces as well as external channels
|
||||||
|
like social media. Violating these terms may lead to a temporary or
|
||||||
|
permanent ban.
|
||||||
|
|
||||||
|
### 3. Temporary Ban
|
||||||
|
|
||||||
|
**Community Impact**: A serious violation of community standards, including
|
||||||
|
sustained inappropriate behavior.
|
||||||
|
|
||||||
|
**Consequence**: A temporary ban from any sort of interaction or public
|
||||||
|
communication with the community for a specified period of time. No public or
|
||||||
|
private interaction with the people involved, including unsolicited interaction
|
||||||
|
with those enforcing the Code of Conduct, is allowed during this period.
|
||||||
|
Violating these terms may lead to a permanent ban.
|
||||||
|
|
||||||
|
### 4. Permanent Ban
|
||||||
|
|
||||||
|
**Community Impact**: Demonstrating a pattern of violation of community
|
||||||
|
standards, including sustained inappropriate behavior, harassment of an
|
||||||
|
individual, or aggression toward or disparagement of classes of individuals.
|
||||||
|
|
||||||
|
**Consequence**: A permanent ban from any sort of public interaction within
|
||||||
|
the community.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||||
|
version 2.0, available at
|
||||||
|
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||||
|
|
||||||
|
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||||
|
enforcement ladder](https://github.com/mozilla/diversity).
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
|
||||||
|
For answers to common questions about this code of conduct, see the FAQ at
|
||||||
|
https://www.contributor-covenant.org/faq. Translations are available at
|
||||||
|
https://www.contributor-covenant.org/translations.
|
||||||
86
README.md
86
README.md
@@ -1,53 +1,89 @@
|
|||||||
|
<img src="https://napneko.github.io/assets/newnewlogo.png" width = "305" height = "411" alt="NapCat" align=right />
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||

|
# NapCat
|
||||||
|
|
||||||
|
_Modern protocol-side framework implemented based on NTQQ._
|
||||||
|
|
||||||
|
> 云起兮风生,心向远方兮路未曾至.
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
## 欢迎回家
|
|
||||||
NapCatQQ 是现代化的基于 NTQQ 的 Bot 协议端实现
|
|
||||||
|
|
||||||
## 特性介绍
|
## New Feature
|
||||||
- [x] **安装简单**:就算是笨蛋也能使用
|
|
||||||
- [x] **性能友好**:就算是低内存也能使用
|
|
||||||
- [x] **接口丰富**:就算是没有也能使用
|
|
||||||
- [x] **稳定好用**:就算是被捉也能使用
|
|
||||||
|
|
||||||
## 使用框架
|
在 v4.8.115+ 版本开始
|
||||||
|
|
||||||
|
1. NapCatQQ 支持 [Stream Api](https://napneko.github.io/develop/file)
|
||||||
|
2. NapCatQQ 推荐 message_id/user_id/group_id 均使用字符串类型
|
||||||
|
|
||||||
|
- [1] 解决 Docker/跨设备/大文件 的多媒体上下传问题
|
||||||
|
- [2] 采用字符串可以解决扩展到int64的问题,同时也可以解决部分语言(如JavaScript)对大整数支持不佳的问题,增加极少成本。
|
||||||
|
|
||||||
|
## Welcome
|
||||||
|
|
||||||
|
- NapCatQQ is a modern implementation of the Bot protocol based on NTQQ.
|
||||||
|
- NapCatQQ 是现代化的基于 NTQQ 的 Bot 协议端实现
|
||||||
|
|
||||||
|
## Feature
|
||||||
|
|
||||||
|
- **Easy to Use**
|
||||||
|
- 作为初学者能够轻松使用.
|
||||||
|
- **Quick and Efficient**
|
||||||
|
- 在低内存操作系统长时运行.
|
||||||
|
- **Rich API Interface**
|
||||||
|
- 完整实现了大部分标准接口.
|
||||||
|
- **Stable and Reliable**
|
||||||
|
- 持续稳定的开发与维护.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
可前往 [Release](https://github.com/NapNeko/NapCatQQ/releases/) 页面下载最新版本
|
可前往 [Release](https://github.com/NapNeko/NapCatQQ/releases/) 页面下载最新版本
|
||||||
|
|
||||||
**首次使用**请务必查看如下文档看使用教程
|
**首次使用**请务必查看如下文档看使用教程
|
||||||
|
|
||||||
### 文档地址
|
> 项目非盈利,对接问题/基础问题/下层框架问题 请自行搜索解决,本项目社区不提供此类解答。
|
||||||
|
|
||||||
[Cloudflare.Worker](https://doc.napneko.icu/)
|
## Link
|
||||||
|
|
||||||
[Cloudflare.HKServer](https://napcat.napneko.icu/)
|
| Docs | [](https://napneko.github.io/) | [](https://doc.napneko.icu/) | [](https://napcat.napneko.icu/) |
|
||||||
|
|:-:|:-:|:-:|:-:|
|
||||||
|
|
||||||
[Github.IO](https://napneko.github.io/)
|
| Docs | [](https://napneko.pages.dev/) | [](https://napcat.cyou/) | [](https://www.napcat.wiki) |
|
||||||
|
|:-:|:-:|:-:|:-:|
|
||||||
|
|
||||||
[Cloudflare.Pages](https://napneko.pages.dev/)
|
| QQ Group | [](https://qm.qq.com/q/CMmPbGw0jA) | [](https://qm.qq.com/q/8zJMLjqy2Y) | [](https://qm.qq.com/q/CMmPbGw0jA) | [](https://qm.qq.com/q/I6LU87a0Yq) |
|
||||||
|
|:-:|:-:|:-:|:-:|:-:|
|
||||||
|
|
||||||
[Server.Other](https://napcat.cyou/)
|
| Telegram | [](https://t.me/napcatqq) |
|
||||||
|
|:-:|:-:|
|
||||||
|
|
||||||
|
| DeepWiki | [](https://deepwiki.com/NapNeko/NapCatQQ) |
|
||||||
|
|:-:|:-:|
|
||||||
|
|
||||||
## 回家旅途
|
> 请不要在其余社区提及本项目(包括其余协议端/相关应用端项目)引发争论,如有建议到达官方交流群讨论或PR。
|
||||||
[QQ Group](https://qm.qq.com/q/NWP25OeV0c)
|
|
||||||
|
|
||||||
## 感谢他们
|
## Thanks
|
||||||
感谢 [Lagrange](https://github.com/LagrangeDev/Lagrange.Core) 对本项目的大力支持 参考部分代码 已获授权
|
|
||||||
|
|
||||||
感谢 Tencent Tdesign / Vue3 强力驱动 NapCat.WebUi
|
- [Lagrange](https://github.com/LagrangeDev/Lagrange.Core) 对本项目的大力支持 参考部分代码 已获授权
|
||||||
|
|
||||||
不过最最重要的 还是需要感谢屏幕前的你哦~
|
- [AstrBot](https://github.com/AstrBotDevs/AstrBot) 是完美适配本项目的LLM Bot框架 在此推荐一下
|
||||||
|
|
||||||
|
- [MaiBot](https://github.com/MaiM-with-u/MaiBot) 一只赛博群友 麦麦 Bot框架 在此推荐一下
|
||||||
|
|
||||||
|
- [qq-chat-exporter](https://github.com/shuakami/qq-chat-exporter/) 基于NapCat的消息导出工具 在此推荐一下
|
||||||
|
|
||||||
|
- 不过最最重要的 还是需要感谢屏幕前的你哦~
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 特殊感谢
|
## License
|
||||||
[LLOneBot](https://github.com/LLOneBot/LLOneBot) 相关的开发曾参与本项目
|
|
||||||
|
|
||||||
## 开源附加
|
本项目采用 混合协议 开源,因此使用本项目时,你需要注意以下几点:
|
||||||
|
|
||||||
任何使用本仓库代码的地方,都应当严格遵守[本仓库开源许可](./LICENSE)。**此外,禁止任何项目未经仓库主作者授权二次分发或基于 NapCat 代码开发。**
|
1. 第三方库代码或修改部分遵循其原始开源许可.
|
||||||
|
2. 本项目获取部分项目授权而不受部分约束
|
||||||
|
2. 项目其余逻辑代码采用[本仓库开源许可](./LICENSE).
|
||||||
|
|
||||||
|
**本仓库仅用于提高易用性,实现消息推送类功能,此外,禁止任何项目未经仓库主作者授权基于 NapCat 代码开发。使用请遵守当地法律法规,由此造成的问题由使用者和提供违规使用教程者负责。**
|
||||||
|
|||||||
11
SECURITY.md
Normal file
11
SECURITY.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
| ------- | ------------------ |
|
||||||
|
| > 4.0 | :white_check_mark: |
|
||||||
|
| < 4.0 | :x: |
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
you should open an issue
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import typescriptEslint from "@typescript-eslint/eslint-plugin";
|
|
||||||
import _import from "eslint-plugin-import";
|
|
||||||
import { fixupPluginRules } from "@eslint/compat";
|
|
||||||
import globals from "globals";
|
|
||||||
import tsParser from "@typescript-eslint/parser";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import js from "@eslint/js";
|
|
||||||
import { FlatCompat } from "@eslint/eslintrc";
|
|
||||||
|
|
||||||
const filename = fileURLToPath(import.meta.url);
|
|
||||||
const dirname = path.dirname(filename);
|
|
||||||
const compat = new FlatCompat({
|
|
||||||
baseDirectory: dirname,
|
|
||||||
recommendedConfig: js.configs.recommended,
|
|
||||||
allConfig: js.configs.all
|
|
||||||
});
|
|
||||||
|
|
||||||
export default [{
|
|
||||||
ignores: ["src/core/proto/"],
|
|
||||||
}, ...compat.extends("eslint:recommended", "plugin:@typescript-eslint/recommended"), {
|
|
||||||
plugins: {
|
|
||||||
"@typescript-eslint": typescriptEslint,
|
|
||||||
import: fixupPluginRules(_import),
|
|
||||||
},
|
|
||||||
|
|
||||||
languageOptions: {
|
|
||||||
globals: {
|
|
||||||
...globals.browser,
|
|
||||||
...globals.node,
|
|
||||||
},
|
|
||||||
|
|
||||||
parser: tsParser,
|
|
||||||
ecmaVersion: "latest",
|
|
||||||
sourceType: "module",
|
|
||||||
},
|
|
||||||
|
|
||||||
settings: {
|
|
||||||
"import/parsers": {
|
|
||||||
"@typescript-eslint/parser": [".ts"],
|
|
||||||
},
|
|
||||||
|
|
||||||
"import/resolver": {
|
|
||||||
typescript: {
|
|
||||||
alwaysTryTypes: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
rules: {
|
|
||||||
indent: ["error", 4],
|
|
||||||
semi: ["error", "always"],
|
|
||||||
"no-unused-vars": "off",
|
|
||||||
"no-async-promise-executor": "off",
|
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
|
||||||
"@typescript-eslint/no-unused-vars": "off",
|
|
||||||
"@typescript-eslint/no-var-requires": "off",
|
|
||||||
"object-curly-spacing": ["error", "always"],
|
|
||||||
},
|
|
||||||
}, {
|
|
||||||
files: ["**/.eslintrc.{js,cjs}"],
|
|
||||||
|
|
||||||
languageOptions: {
|
|
||||||
globals: {
|
|
||||||
...globals.node,
|
|
||||||
},
|
|
||||||
ecmaVersion: 5,
|
|
||||||
sourceType: "commonjs",
|
|
||||||
},
|
|
||||||
}];
|
|
||||||
BIN
external/LiteLoaderWrapper.zip
vendored
BIN
external/LiteLoaderWrapper.zip
vendored
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
logo.png
BIN
logo.png
Binary file not shown.
|
Before Width: | Height: | Size: 335 KiB After Width: | Height: | Size: 250 KiB |
3
napcat.webui/.vscode/extensions.json
vendored
3
napcat.webui/.vscode/extensions.json
vendored
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"recommendations": ["Vue.volar"]
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Vue 3 + TypeScript + Vite
|
|
||||||
|
|
||||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
|
||||||
|
|
||||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import globals from 'globals';
|
|
||||||
import ts from 'typescript-eslint';
|
|
||||||
import vue from 'eslint-plugin-vue';
|
|
||||||
import prettier from 'eslint-plugin-prettier/recommended';
|
|
||||||
|
|
||||||
export default [
|
|
||||||
{
|
|
||||||
languageOptions: {
|
|
||||||
globals: {
|
|
||||||
...globals.browser,
|
|
||||||
...globals.node,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...ts.configs.recommended,
|
|
||||||
{
|
|
||||||
rules: {
|
|
||||||
'@typescript-eslint/no-explicit-any': 'warn',
|
|
||||||
'@typescript-eslint/no-var-requires': 'warn',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...vue.configs['flat/base'],
|
|
||||||
{
|
|
||||||
files: ['*.vue', '**/*.vue'],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
parser: ts.parser,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rules: {
|
|
||||||
indent: ['error', 4],
|
|
||||||
semi: ['error', 'always'],
|
|
||||||
'no-unused-vars': 'off',
|
|
||||||
'@typescript-eslint/no-explicit-any': 'warn',
|
|
||||||
'@typescript-eslint/no-unused-vars': 'warn',
|
|
||||||
'@typescript-eslint/no-var-requires': 'warn',
|
|
||||||
'object-curly-spacing': ['error', 'always'],
|
|
||||||
'vue/v-for-delimiter-style': ['error', 'in'],
|
|
||||||
'vue/require-name-property': 'warn',
|
|
||||||
'vue/prefer-true-attribute-shorthand': 'warn',
|
|
||||||
'prefer-arrow-callback': 'warn',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
prettier,
|
|
||||||
{
|
|
||||||
rules: {
|
|
||||||
'prettier/prettier': 'warn',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>NapCat WebUI</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
<script type="module" src="./src/main.ts"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "napcat.webui",
|
|
||||||
"private": true,
|
|
||||||
"version": "1.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"webui:lint": "eslint --fix src/**/*.{js,ts,vue}",
|
|
||||||
"webui:dev": "vite",
|
|
||||||
"webui:build": "vite build",
|
|
||||||
"webui:preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"eslint-plugin-prettier": "^5.2.1",
|
|
||||||
"qrcode": "^1.5.4",
|
|
||||||
"tdesign-icons-vue-next": "^0.3.3",
|
|
||||||
"tdesign-vue-next": "^1.10.3",
|
|
||||||
"vue": "^3.5.12",
|
|
||||||
"vue-router": "^4.4.5"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@eslint/eslintrc": "^3.1.0",
|
|
||||||
"@eslint/js": "^9.14.0",
|
|
||||||
"@types/qrcode": "^1.5.5",
|
|
||||||
"@vitejs/plugin-legacy": "^5.4.3",
|
|
||||||
"@vitejs/plugin-vue": "^5.1.4",
|
|
||||||
"eslint-config-prettier": "^9.1.0",
|
|
||||||
"eslint-plugin-vue": "^9.31.0",
|
|
||||||
"globals": "^15.12.0",
|
|
||||||
"terser": "^5.36.0",
|
|
||||||
"typescript": "~5.6.2",
|
|
||||||
"vite": "^5.4.10",
|
|
||||||
"vue-tsc": "^2.1.8"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 335 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,7 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div id="app">
|
|
||||||
<router-view />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts"></script>
|
|
||||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 335 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 496 B |
@@ -1,205 +0,0 @@
|
|||||||
import { OneBotConfig } from '../../../src/onebot/config/config';
|
|
||||||
|
|
||||||
export class QQLoginManager {
|
|
||||||
private retCredential: string;
|
|
||||||
private readonly apiPrefix: string;
|
|
||||||
|
|
||||||
//调试时http://127.0.0.1:6099/api 打包时 ../api
|
|
||||||
constructor(retCredential: string, apiPrefix: string = '../api') {
|
|
||||||
this.retCredential = retCredential;
|
|
||||||
this.apiPrefix = apiPrefix;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO:
|
|
||||||
public async GetOB11Config(): Promise<OneBotConfig> {
|
|
||||||
try {
|
|
||||||
const ConfigResponse = await fetch(`${this.apiPrefix}/OB11Config/GetConfig`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: 'Bearer ' + this.retCredential,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (ConfigResponse.status == 200) {
|
|
||||||
const ConfigResponseJson = await ConfigResponse.json();
|
|
||||||
if (ConfigResponseJson.code == 0) {
|
|
||||||
return ConfigResponseJson?.data as OneBotConfig;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error getting OB11 config:', error);
|
|
||||||
}
|
|
||||||
return {} as OneBotConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async SetOB11Config(config: OneBotConfig): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const ConfigResponse = await fetch(`${this.apiPrefix}/OB11Config/SetConfig`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: 'Bearer ' + this.retCredential,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ config: JSON.stringify(config) }),
|
|
||||||
});
|
|
||||||
if (ConfigResponse.status == 200) {
|
|
||||||
const ConfigResponseJson = await ConfigResponse.json();
|
|
||||||
if (ConfigResponseJson.code == 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error setting OB11 config:', error);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async checkQQLoginStatus(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const QQLoginResponse = await fetch(`${this.apiPrefix}/QQLogin/CheckLoginStatus`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: 'Bearer ' + this.retCredential,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (QQLoginResponse.status == 200) {
|
|
||||||
const QQLoginResponseJson = await QQLoginResponse.json();
|
|
||||||
if (QQLoginResponseJson.code == 0) {
|
|
||||||
return QQLoginResponseJson.data.isLogin;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error checking QQ login status:', error);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
public async checkQQLoginStatusWithQrcode(): Promise<{ qrcodeurl: string, isLogin: string } | undefined> {
|
|
||||||
try {
|
|
||||||
const QQLoginResponse = await fetch(`${this.apiPrefix}/QQLogin/CheckLoginStatus`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: 'Bearer ' + this.retCredential,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (QQLoginResponse.status == 200) {
|
|
||||||
const QQLoginResponseJson = await QQLoginResponse.json();
|
|
||||||
if (QQLoginResponseJson.code == 0) {
|
|
||||||
return QQLoginResponseJson.data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error checking QQ login status:', error);
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async checkWebUiLogined(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const LoginResponse = await fetch(`${this.apiPrefix}/auth/check`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: 'Bearer ' + this.retCredential,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (LoginResponse.status == 200) {
|
|
||||||
const LoginResponseJson = await LoginResponse.json();
|
|
||||||
if (LoginResponseJson.code == 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error checking web UI login status:', error);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async loginWithToken(token: string): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
const loginResponse = await fetch(`${this.apiPrefix}/auth/login`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ token: token }),
|
|
||||||
});
|
|
||||||
const loginResponseJson = await loginResponse.json();
|
|
||||||
const retCode = loginResponseJson.code;
|
|
||||||
if (retCode === 0) {
|
|
||||||
this.retCredential = loginResponseJson.data.Credential;
|
|
||||||
return this.retCredential;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error logging in with token:', error);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getQQLoginQrcode(): Promise<string> {
|
|
||||||
try {
|
|
||||||
const QQLoginResponse = await fetch(`${this.apiPrefix}/QQLogin/GetQQLoginQrcode`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: 'Bearer ' + this.retCredential,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (QQLoginResponse.status == 200) {
|
|
||||||
const QQLoginResponseJson = await QQLoginResponse.json();
|
|
||||||
if (QQLoginResponseJson.code == 0) {
|
|
||||||
return QQLoginResponseJson.data.qrcode || '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error getting QQ login QR code:', error);
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getQQQuickLoginList(): Promise<string[]> {
|
|
||||||
try {
|
|
||||||
const QQLoginResponse = await fetch(`${this.apiPrefix}/QQLogin/GetQuickLoginList`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: 'Bearer ' + this.retCredential,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (QQLoginResponse.status == 200) {
|
|
||||||
const QQLoginResponseJson = await QQLoginResponse.json();
|
|
||||||
if (QQLoginResponseJson.code == 0) {
|
|
||||||
return QQLoginResponseJson.data || [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error getting QQ quick login list:', error);
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public async setQuickLogin(uin: string): Promise<{ result: boolean; errMsg: string }> {
|
|
||||||
try {
|
|
||||||
const QQLoginResponse = await fetch(`${this.apiPrefix}/QQLogin/SetQuickLogin`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: 'Bearer ' + this.retCredential,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ uin: uin }),
|
|
||||||
});
|
|
||||||
if (QQLoginResponse.status == 200) {
|
|
||||||
const QQLoginResponseJson = await QQLoginResponse.json();
|
|
||||||
if (QQLoginResponseJson.code == 0) {
|
|
||||||
return { result: true, errMsg: '' };
|
|
||||||
} else {
|
|
||||||
return { result: false, errMsg: QQLoginResponseJson.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error setting quick login:', error);
|
|
||||||
}
|
|
||||||
return { result: false, errMsg: '接口异常' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="dashboard-container">
|
|
||||||
<SidebarMenu :menu-items="menuItems" class="sidebar-menu" />
|
|
||||||
<div class="content">
|
|
||||||
<router-view />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue';
|
|
||||||
import SidebarMenu from './webui/Nav.vue';
|
|
||||||
|
|
||||||
interface MenuItem {
|
|
||||||
value: string;
|
|
||||||
icon: string;
|
|
||||||
label: string;
|
|
||||||
route: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const menuItems = ref<MenuItem[]>([
|
|
||||||
{ value: 'item1', icon: 'dashboard', label: '基础信息', route: '/dashboard/basic-info' },
|
|
||||||
{ value: 'item3', icon: 'wifi-1', label: '网络配置', route: '/dashboard/network-config' },
|
|
||||||
{ value: 'item4', icon: 'setting', label: '其余配置', route: '/dashboard/other-config' },
|
|
||||||
{ value: 'item5', icon: 'system-log', label: '日志查看', route: '/dashboard/log-view' },
|
|
||||||
{ value: 'item6', icon: 'info-circle', label: '关于我们', route: '/dashboard/about-us' },
|
|
||||||
]);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.dashboard-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-menu {
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
flex: 1;
|
|
||||||
/* padding: 20px; */
|
|
||||||
overflow: auto;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.content {
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="login-container">
|
|
||||||
<h2 class="sotheby-font">QQ Login</h2>
|
|
||||||
<div class="login-methods">
|
|
||||||
<t-button id="quick-login" class="login-method" :class="{ active: loginMethod === 'quick' }"
|
|
||||||
@click="loginMethod = 'quick'">Quick Login</t-button>
|
|
||||||
<t-button id="qrcode-login" class="login-method" :class="{ active: loginMethod === 'qrcode' }"
|
|
||||||
@click="loginMethod = 'qrcode'">QR Code</t-button>
|
|
||||||
</div>
|
|
||||||
<div v-show="loginMethod === 'quick'" id="quick-login-dropdown" class="login-form">
|
|
||||||
<t-select id="quick-login-select" v-model="selectedAccount" placeholder="Select Account"
|
|
||||||
@change="selectAccount">
|
|
||||||
<t-option v-for="account in quickLoginList" :key="account" :value="account">{{ account }}</t-option>
|
|
||||||
</t-select>
|
|
||||||
</div>
|
|
||||||
<div v-show="loginMethod === 'qrcode'" id="qrcode" class="qrcode">
|
|
||||||
<canvas ref="qrcodeCanvas"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted } from 'vue';
|
|
||||||
import * as QRCode from 'qrcode';
|
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import { MessagePlugin } from 'tdesign-vue-next';
|
|
||||||
import { QQLoginManager } from '@/backend/shell';
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const loginMethod = ref<'quick' | 'qrcode'>('quick');
|
|
||||||
const quickLoginList = ref<string[]>([]);
|
|
||||||
const selectedAccount = ref<string>('');
|
|
||||||
const qrcodeCanvas = ref<HTMLCanvasElement | null>(null);
|
|
||||||
const qqLoginManager = new QQLoginManager(localStorage.getItem('auth') || '');
|
|
||||||
let heartBeatTimer: number | null = null;
|
|
||||||
let qrcodeUrl: string = '';
|
|
||||||
const selectAccount = async (accountName: string): Promise<void> => {
|
|
||||||
const { result, errMsg } = await qqLoginManager.setQuickLogin(accountName);
|
|
||||||
if (result) {
|
|
||||||
await MessagePlugin.success('登录成功即将跳转');
|
|
||||||
await router.push({ path: '/dashboard/basic-info' });
|
|
||||||
} else {
|
|
||||||
await MessagePlugin.error('登录失败,' + errMsg);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateQrCode = (data: string, canvas: HTMLCanvasElement | null): void => {
|
|
||||||
if (!canvas) {
|
|
||||||
console.error('Canvas element not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
QRCode.toCanvas(canvas, data, function (error: Error | null | undefined) {
|
|
||||||
if (error) {
|
|
||||||
console.error('Error generating QR Code:', error);
|
|
||||||
} else {
|
|
||||||
console.log('QR Code generated!');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const HeartBeat = async (): Promise<void> => {
|
|
||||||
const isLogined = await qqLoginManager.checkQQLoginStatusWithQrcode();
|
|
||||||
if (isLogined?.isLogin) {
|
|
||||||
if (heartBeatTimer) {
|
|
||||||
clearInterval(heartBeatTimer);
|
|
||||||
}
|
|
||||||
//判断是否已经调转
|
|
||||||
if (router.currentRoute.value.path !== '/dashboard/basic-info') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await router.push({ path: '/dashboard/basic-info' });
|
|
||||||
} else if (isLogined?.qrcodeurl && qrcodeUrl !== isLogined.qrcodeurl) {
|
|
||||||
qrcodeUrl = isLogined.qrcodeurl;
|
|
||||||
generateQrCode(qrcodeUrl, qrcodeCanvas.value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const InitPages = async (): Promise<void> => {
|
|
||||||
quickLoginList.value = await qqLoginManager.getQQQuickLoginList();
|
|
||||||
qrcodeUrl = await qqLoginManager.getQQLoginQrcode();
|
|
||||||
generateQrCode(qrcodeUrl, qrcodeCanvas.value);
|
|
||||||
heartBeatTimer = window.setInterval(HeartBeat, 3000);
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
InitPages();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.login-container {
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 5px;
|
|
||||||
background-color: white;
|
|
||||||
max-width: 400px;
|
|
||||||
min-width: 300px;
|
|
||||||
position: relative;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.login-container {
|
|
||||||
width: 90%;
|
|
||||||
min-width: unset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-methods {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-method {
|
|
||||||
padding: 10px 15px;
|
|
||||||
font-size: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-method.active {
|
|
||||||
background-color: #e6f0ff;
|
|
||||||
color: #007bff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-form,
|
|
||||||
.qrcode {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qrcode {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
gap: 15px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sotheby-font {
|
|
||||||
font-family: Sotheby, Helvetica, monospace;
|
|
||||||
font-size: 3.125rem;
|
|
||||||
line-height: 1.2;
|
|
||||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
text-align: center;
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #888;
|
|
||||||
position: fixed;
|
|
||||||
bottom: 20px;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
width: 100%;
|
|
||||||
background-color: white;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="login-container">
|
|
||||||
<h2 class="sotheby-font">WebUi Login</h2>
|
|
||||||
<t-form ref="form" :data="formData" colon :label-width="0" @submit="onSubmit">
|
|
||||||
<t-form-item name="password">
|
|
||||||
<t-input v-model="formData.token" type="password" clearable placeholder="请输入Token">
|
|
||||||
<template #prefix-icon>
|
|
||||||
<lock-on-icon />
|
|
||||||
</template>
|
|
||||||
</t-input>
|
|
||||||
</t-form-item>
|
|
||||||
<t-form-item>
|
|
||||||
<t-button theme="primary" type="submit" block>登录</t-button>
|
|
||||||
</t-form-item>
|
|
||||||
</t-form>
|
|
||||||
</div>
|
|
||||||
<div class="footer">Power By NapCat.WebUi</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import '../css/style.css';
|
|
||||||
import '../css/font.css';
|
|
||||||
import { reactive, onMounted } from 'vue';
|
|
||||||
import { MessagePlugin } from 'tdesign-vue-next';
|
|
||||||
import { LockOnIcon } from 'tdesign-icons-vue-next';
|
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import { QQLoginManager } from '@/backend/shell';
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
interface FormData {
|
|
||||||
token: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formData: FormData = reactive({
|
|
||||||
token: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleLoginSuccess = async (credential: string) => {
|
|
||||||
localStorage.setItem('auth', credential);
|
|
||||||
await checkLoginStatus();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLoginFailure = (message: string) => {
|
|
||||||
MessagePlugin.error(message);
|
|
||||||
};
|
|
||||||
|
|
||||||
const checkLoginStatus = async () => {
|
|
||||||
const storedCredential = localStorage.getItem('auth');
|
|
||||||
if (!storedCredential) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const loginManager = new QQLoginManager(storedCredential);
|
|
||||||
const isWenUiLoggedIn = await loginManager.checkWebUiLogined();
|
|
||||||
console.log('isWenUiLoggedIn', isWenUiLoggedIn);
|
|
||||||
if (!isWenUiLoggedIn) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isQQLoggedIn = await loginManager.checkQQLoginStatus();
|
|
||||||
if (isQQLoggedIn) {
|
|
||||||
await router.push({ path: '/dashboard/basic-info' });
|
|
||||||
} else {
|
|
||||||
await router.push({ path: '/qqlogin' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loginWithToken = async (token: string) => {
|
|
||||||
const loginManager = new QQLoginManager('');
|
|
||||||
const credential = await loginManager.loginWithToken(token);
|
|
||||||
if (credential) {
|
|
||||||
await handleLoginSuccess(credential);
|
|
||||||
} else {
|
|
||||||
handleLoginFailure('登录失败,请检查Token');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
const token = url.searchParams.get('token');
|
|
||||||
if (token) {
|
|
||||||
loginWithToken(token);
|
|
||||||
}
|
|
||||||
checkLoginStatus();
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = async ({ validateResult }: { validateResult: boolean }) => {
|
|
||||||
if (validateResult) {
|
|
||||||
await loginWithToken(formData.token);
|
|
||||||
} else {
|
|
||||||
handleLoginFailure('请填写Token');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.login-container {
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 5px;
|
|
||||||
background-color: white;
|
|
||||||
max-width: 400px;
|
|
||||||
min-width: 300px;
|
|
||||||
position: relative;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.login-container {
|
|
||||||
width: 90%;
|
|
||||||
min-width: unset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tdesign-demo-block-column {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
row-gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tdesign-demo-block-column-large {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
row-gap: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tdesign-demo-block-row {
|
|
||||||
display: flex;
|
|
||||||
column-gap: 16px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sotheby-font {
|
|
||||||
font-family: Sotheby, Helvetica, monospace;
|
|
||||||
font-size: 3.125rem;
|
|
||||||
line-height: 1.2;
|
|
||||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
text-align: center;
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #888;
|
|
||||||
position: fixed;
|
|
||||||
bottom: 20px;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
width: 100%;
|
|
||||||
background-color: white;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
<template>
|
|
||||||
<t-menu theme="light" default-value="2-1" :collapsed="collapsed" class="sidebar-menu">
|
|
||||||
<template #logo> </template>
|
|
||||||
<router-link v-for="item in menuItems" :key="item.value" :to="item.route">
|
|
||||||
<t-menu-item :value="item.value" :disabled="item.disabled" class="menu-item">
|
|
||||||
<template #icon>
|
|
||||||
<t-icon :name="item.icon" />
|
|
||||||
</template>
|
|
||||||
{{ item.label }}
|
|
||||||
</t-menu-item>
|
|
||||||
</router-link>
|
|
||||||
<template #operations>
|
|
||||||
<t-button class="t-demo-collapse-btn" variant="text" shape="square" @click="changeCollapsed">
|
|
||||||
<template #icon><t-icon :name="iconName" /></template>
|
|
||||||
</t-button>
|
|
||||||
</template>
|
|
||||||
</t-menu>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, defineProps } from 'vue';
|
|
||||||
|
|
||||||
type MenuItem = {
|
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
route: string;
|
|
||||||
icon?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
menuItems: MenuItem[];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const collapsed = ref<boolean>(localStorage.getItem('sidebar-collapsed') === 'true');
|
|
||||||
const iconName = ref<string>(collapsed.value ? 'menu-unfold' : 'menu-fold');
|
|
||||||
|
|
||||||
const changeCollapsed = (): void => {
|
|
||||||
collapsed.value = !collapsed.value;
|
|
||||||
iconName.value = collapsed.value ? 'menu-unfold' : 'menu-fold';
|
|
||||||
localStorage.setItem('sidebar-collapsed', collapsed.value.toString());
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.sidebar-menu {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
height: 100%;
|
|
||||||
width: 200px;
|
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.sidebar-menu {
|
|
||||||
width: 100px; /* 移动端侧边栏宽度 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-text {
|
|
||||||
display: block;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-item {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
@font-face {
|
|
||||||
font-family: 'Sotheby';
|
|
||||||
src: url('../assets/Sotheby.ttf') format('truetype');
|
|
||||||
font-weight: normal;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
:root {
|
|
||||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
|
||||||
line-height: 1.5;
|
|
||||||
font-weight: 400;
|
|
||||||
|
|
||||||
color-scheme: light dark;
|
|
||||||
color: rgba(255, 255, 255, 0.87);
|
|
||||||
background-color: #242424;
|
|
||||||
|
|
||||||
font-synthesis: none;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
font-weight: 500;
|
|
||||||
color: #646cff;
|
|
||||||
text-decoration: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover {
|
|
||||||
color: #535bf2;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
place-items: center;
|
|
||||||
min-width: 320px;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 3.2em;
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
padding: 0.6em 1.2em;
|
|
||||||
font-size: 1em;
|
|
||||||
font-weight: 500;
|
|
||||||
font-family: inherit;
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.25s;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:hover {
|
|
||||||
border-color: #646cff;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:focus,
|
|
||||||
button:focus-visible {
|
|
||||||
outline: 4px auto -webkit-focus-ring-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
padding: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
#app {
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
margin: 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
|
||||||
:root {
|
|
||||||
color: #213547;
|
|
||||||
background-color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover {
|
|
||||||
color: #747bff;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
background-color: #f9f9f9;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { createApp } from 'vue';
|
|
||||||
import App from './App.vue';
|
|
||||||
import {
|
|
||||||
Button as TButton,
|
|
||||||
Input as TInput,
|
|
||||||
Form as TForm,
|
|
||||||
FormItem as TFormItem,
|
|
||||||
Select as TSelect,
|
|
||||||
Option as TOption,
|
|
||||||
Menu as TMenu,
|
|
||||||
MenuItem as TMenuItem,
|
|
||||||
Icon as TIcon,
|
|
||||||
Submenu as TSubmenu,
|
|
||||||
Col as TCol,
|
|
||||||
Row as TRow,
|
|
||||||
Card as TCard,
|
|
||||||
Divider as TDivider,
|
|
||||||
Link as TLink,
|
|
||||||
List as TList,
|
|
||||||
Alert as TAlert,
|
|
||||||
Tag as TTag,
|
|
||||||
ListItem as TListItem,
|
|
||||||
Tabs as TTabs,
|
|
||||||
TabPanel as TTabPanel,
|
|
||||||
Space as TSpace,
|
|
||||||
Checkbox as TCheckbox,
|
|
||||||
Popup as TPopup,
|
|
||||||
Dialog as TDialog,
|
|
||||||
Switch as TSwitch,
|
|
||||||
} from 'tdesign-vue-next';
|
|
||||||
import { router } from './router';
|
|
||||||
import 'tdesign-vue-next/es/style/index.css';
|
|
||||||
|
|
||||||
const app = createApp(App);
|
|
||||||
app.use(router);
|
|
||||||
app.use(TButton);
|
|
||||||
app.use(TInput);
|
|
||||||
app.use(TForm);
|
|
||||||
app.use(TFormItem);
|
|
||||||
app.use(TSelect);
|
|
||||||
app.use(TOption);
|
|
||||||
app.use(TMenu);
|
|
||||||
app.use(TMenuItem);
|
|
||||||
app.use(TIcon);
|
|
||||||
app.use(TSubmenu);
|
|
||||||
app.use(TCol);
|
|
||||||
app.use(TRow);
|
|
||||||
app.use(TCard);
|
|
||||||
app.use(TDivider);
|
|
||||||
app.use(TLink);
|
|
||||||
app.use(TList);
|
|
||||||
app.use(TAlert);
|
|
||||||
app.use(TTag);
|
|
||||||
app.use(TListItem);
|
|
||||||
app.use(TTabs);
|
|
||||||
app.use(TTabPanel);
|
|
||||||
app.use(TSpace);
|
|
||||||
app.use(TCheckbox);
|
|
||||||
app.use(TPopup);
|
|
||||||
app.use(TDialog);
|
|
||||||
app.use(TSwitch);
|
|
||||||
app.mount('#app');
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="about-us">
|
|
||||||
<div>
|
|
||||||
<t-divider content="面板关于信息" align="left" />
|
|
||||||
<t-alert theme="success" message="NapCat.WebUi is running" />
|
|
||||||
<t-list class="list">
|
|
||||||
<t-list-item class="list-item">
|
|
||||||
<span class="item-label">开发人员:</span>
|
|
||||||
<span class="item-content">
|
|
||||||
<t-link href="mailto:nanaeonn@outlook.com">Mlikiowa</t-link>
|
|
||||||
</span>
|
|
||||||
</t-list-item>
|
|
||||||
<t-list-item class="list-item">
|
|
||||||
<span class="item-label">版本信息:</span>
|
|
||||||
<span class="item-content">
|
|
||||||
<t-tag class="tag-item" theme="success"> WebUi: {{ pkg.version }} </t-tag>
|
|
||||||
<t-tag class="tag-item" theme="success"> NapCat: {{ napCatVersion }} </t-tag>
|
|
||||||
<t-tag class="tag-item" theme="success">
|
|
||||||
TDesign: {{ pkg.dependencies['tdesign-vue-next'] }}
|
|
||||||
</t-tag>
|
|
||||||
</span>
|
|
||||||
</t-list-item>
|
|
||||||
</t-list>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import pkg from '../../package.json';
|
|
||||||
import { napCatVersion } from '../../../src/common/version';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.about-us {
|
|
||||||
padding: 20px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-label {
|
|
||||||
flex: 1;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-content {
|
|
||||||
flex: 2;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-item {
|
|
||||||
margin-right: 10px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="basic-info">
|
|
||||||
<h1>面板基础信息</h1>
|
|
||||||
<p>这里显示面板的基础信息。</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="log-view">
|
|
||||||
<h1>面板日志信息</h1>
|
|
||||||
<p>这里显示面板的日志信息。</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
<template>
|
|
||||||
<t-space class="full-space">
|
|
||||||
<template v-if="clientPanelData.length > 0">
|
|
||||||
<t-tabs
|
|
||||||
v-model="activeTab"
|
|
||||||
:addable="true"
|
|
||||||
theme="card"
|
|
||||||
@add="showAddTabDialog"
|
|
||||||
@remove="removeTab"
|
|
||||||
class="full-tabs"
|
|
||||||
>
|
|
||||||
<t-tab-panel
|
|
||||||
v-for="(config, idx) in clientPanelData"
|
|
||||||
:key="idx"
|
|
||||||
:label="config.name"
|
|
||||||
:removable="true"
|
|
||||||
:value="idx"
|
|
||||||
class="full-tab-panel"
|
|
||||||
>
|
|
||||||
<component :is="resolveDynamicComponent(getComponent(config.key))" :config="config.data" />
|
|
||||||
<div class="button-container">
|
|
||||||
<t-button @click="saveConfig" style="width: 100px; height: 40px">保存</t-button>
|
|
||||||
</div>
|
|
||||||
</t-tab-panel>
|
|
||||||
</t-tabs>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<EmptyStateComponent :showAddTabDialog="showAddTabDialog" />
|
|
||||||
</template>
|
|
||||||
<t-dialog
|
|
||||||
v-model:visible="isDialogVisible"
|
|
||||||
header="添加网络配置"
|
|
||||||
@close="isDialogVisible = false"
|
|
||||||
@confirm="addTab"
|
|
||||||
>
|
|
||||||
<t-form ref="form" :model="newTab">
|
|
||||||
<t-form-item :rules="[{ required: true, message: '请输入名称' }]" label="名称" name="name">
|
|
||||||
<t-input v-model="newTab.name" />
|
|
||||||
</t-form-item>
|
|
||||||
<t-form-item :rules="[{ required: true, message: '请选择类型' }]" label="类型" name="type">
|
|
||||||
<t-select v-model="newTab.type">
|
|
||||||
<t-option value="httpServers">HTTP 服务器</t-option>
|
|
||||||
<t-option value="httpClients">HTTP 客户端</t-option>
|
|
||||||
<t-option value="websocketServers">WebSocket 服务器</t-option>
|
|
||||||
<t-option value="websocketClients">WebSocket 客户端</t-option>
|
|
||||||
</t-select>
|
|
||||||
</t-form-item>
|
|
||||||
</t-form>
|
|
||||||
</t-dialog>
|
|
||||||
</t-space>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, resolveDynamicComponent, nextTick, Ref, onMounted } from 'vue';
|
|
||||||
import { MessagePlugin } from 'tdesign-vue-next';
|
|
||||||
import {
|
|
||||||
httpServerDefaultConfigs,
|
|
||||||
httpClientDefaultConfigs,
|
|
||||||
websocketServerDefaultConfigs,
|
|
||||||
websocketClientDefaultConfigs,
|
|
||||||
HttpClientConfig,
|
|
||||||
HttpServerConfig,
|
|
||||||
WebsocketClientConfig,
|
|
||||||
WebsocketServerConfig,
|
|
||||||
NetworkConfig,
|
|
||||||
OneBotConfig,
|
|
||||||
mergeOneBotConfigs,
|
|
||||||
} from '../../../src/onebot/config/config';
|
|
||||||
import { QQLoginManager } from '@/backend/shell';
|
|
||||||
import HttpServerComponent from '@/pages/network/HttpServerComponent.vue';
|
|
||||||
import HttpClientComponent from '@/pages/network/HttpClientComponent.vue';
|
|
||||||
import WebsocketServerComponent from '@/pages/network/WebsocketServerComponent.vue';
|
|
||||||
import WebsocketClientComponent from '@/pages/network/WebsocketClientComponent.vue';
|
|
||||||
import EmptyStateComponent from '@/pages/network/EmptyStateComponent.vue';
|
|
||||||
|
|
||||||
type ConfigKey = 'httpServers' | 'httpClients' | 'websocketServers' | 'websocketClients';
|
|
||||||
type ConfigUnion = HttpClientConfig | HttpServerConfig | WebsocketServerConfig | WebsocketClientConfig;
|
|
||||||
type ComponentUnion =
|
|
||||||
| typeof HttpServerComponent
|
|
||||||
| typeof HttpClientComponent
|
|
||||||
| typeof WebsocketServerComponent
|
|
||||||
| typeof WebsocketClientComponent;
|
|
||||||
|
|
||||||
const componentMap: Record<ConfigKey, ComponentUnion> = {
|
|
||||||
httpServers: HttpServerComponent,
|
|
||||||
httpClients: HttpClientComponent,
|
|
||||||
websocketServers: WebsocketServerComponent,
|
|
||||||
websocketClients: WebsocketClientComponent,
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultConfigMap: Record<ConfigKey, ConfigUnion> = {
|
|
||||||
httpServers: httpServerDefaultConfigs,
|
|
||||||
httpClients: httpClientDefaultConfigs,
|
|
||||||
websocketServers: websocketServerDefaultConfigs,
|
|
||||||
websocketClients: websocketClientDefaultConfigs,
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ConfigMap {
|
|
||||||
httpServers: HttpServerConfig;
|
|
||||||
httpClients: HttpClientConfig;
|
|
||||||
websocketServers: WebsocketServerConfig;
|
|
||||||
websocketClients: WebsocketClientConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ClientPanel<K extends ConfigKey = ConfigKey> {
|
|
||||||
name: string;
|
|
||||||
key: K;
|
|
||||||
data: ConfigMap[K];
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeTab = ref<number>(0);
|
|
||||||
const isDialogVisible = ref(false);
|
|
||||||
const newTab = ref<{ name: string; type: ConfigKey }>({ name: '', type: 'httpServers' });
|
|
||||||
const clientPanelData: Ref<ClientPanel[]> = ref([]);
|
|
||||||
|
|
||||||
const getComponent = (type: ConfigKey) => {
|
|
||||||
return componentMap[type];
|
|
||||||
};
|
|
||||||
|
|
||||||
const getOB11Config = async (): Promise<OneBotConfig | undefined> => {
|
|
||||||
const storedCredential = localStorage.getItem('auth');
|
|
||||||
if (!storedCredential) {
|
|
||||||
console.error('No stored credential found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const loginManager = new QQLoginManager(storedCredential);
|
|
||||||
return await loginManager.GetOB11Config();
|
|
||||||
};
|
|
||||||
|
|
||||||
const setOB11Config = async (config: OneBotConfig): Promise<boolean> => {
|
|
||||||
const storedCredential = localStorage.getItem('auth');
|
|
||||||
if (!storedCredential) {
|
|
||||||
console.error('No stored credential found');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const loginManager = new QQLoginManager(storedCredential);
|
|
||||||
return await loginManager.SetOB11Config(config);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addToPanel = <K extends ConfigKey>(configs: ConfigMap[K][], key: K) => {
|
|
||||||
configs.forEach((config) => clientPanelData.value.push({ name: config.name, data: config, key }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const addConfigDataToPanel = (data: NetworkConfig) => {
|
|
||||||
(Object.keys(data) as ConfigKey[]).forEach((key) => {
|
|
||||||
addToPanel(data[key], key);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const parsePanelData = (): NetworkConfig => {
|
|
||||||
const result: NetworkConfig = {
|
|
||||||
httpServers: [],
|
|
||||||
httpClients: [],
|
|
||||||
websocketServers: [],
|
|
||||||
websocketClients: [],
|
|
||||||
};
|
|
||||||
clientPanelData.value.forEach((panel) => {
|
|
||||||
(result[panel.key] as Array<typeof panel.data>).push(panel.data);
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadConfig = async () => {
|
|
||||||
try {
|
|
||||||
const userConfig = await getOB11Config();
|
|
||||||
if (!userConfig) return;
|
|
||||||
const mergedConfig = mergeOneBotConfigs(userConfig);
|
|
||||||
addConfigDataToPanel(mergedConfig.network);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading config:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveConfig = async () => {
|
|
||||||
const config = parsePanelData();
|
|
||||||
const userConfig = await getOB11Config();
|
|
||||||
if (!userConfig) {
|
|
||||||
await MessagePlugin.error('无法获取配置!');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
userConfig.network = config;
|
|
||||||
const success = await setOB11Config(userConfig);
|
|
||||||
if (success) {
|
|
||||||
await MessagePlugin.success('配置保存成功');
|
|
||||||
} else {
|
|
||||||
await MessagePlugin.error('配置保存失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const showAddTabDialog = () => {
|
|
||||||
newTab.value = { name: '', type: 'httpServers' };
|
|
||||||
isDialogVisible.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const addTab = async () => {
|
|
||||||
const { name, type } = newTab.value;
|
|
||||||
if (clientPanelData.value.some((panel) => panel.name === name)) {
|
|
||||||
await MessagePlugin.error('选项卡名称已存在');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const defaultConfig = structuredClone(defaultConfigMap[type]);
|
|
||||||
defaultConfig.name = name;
|
|
||||||
clientPanelData.value.push({ name, data: defaultConfig, key: type });
|
|
||||||
isDialogVisible.value = false;
|
|
||||||
await nextTick();
|
|
||||||
activeTab.value = clientPanelData.value.length - 1;
|
|
||||||
await MessagePlugin.success('选项卡添加成功');
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeTab = async (payload: { value: string; index: number; e: PointerEvent }) => {
|
|
||||||
clientPanelData.value.splice(payload.index, 1);
|
|
||||||
activeTab.value = Math.max(0, activeTab.value - 1);
|
|
||||||
await saveConfig();
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
loadConfig();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.full-space {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.full-tabs {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.full-tab-panel {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-container {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<t-divider content="其余配置" align="left" />
|
|
||||||
</div>
|
|
||||||
<div class="other-config-container">
|
|
||||||
<div class="other-config">
|
|
||||||
<t-form ref="form" :model="otherConfig" class="form">
|
|
||||||
<t-form-item label="音乐签名地址" name="musicSignUrl" class="form-item">
|
|
||||||
<t-input v-model="otherConfig.musicSignUrl" />
|
|
||||||
</t-form-item>
|
|
||||||
<t-form-item label="启用本地文件到URL" name="enableLocalFile2Url" class="form-item">
|
|
||||||
<t-switch v-model="otherConfig.enableLocalFile2Url" />
|
|
||||||
</t-form-item>
|
|
||||||
<t-form-item label="启用上报解析合并消息" name="parseMultMsg" class="form-item">
|
|
||||||
<t-switch v-model="otherConfig.parseMultMsg" />
|
|
||||||
</t-form-item>
|
|
||||||
</t-form>
|
|
||||||
<div class="button-container">
|
|
||||||
<t-button @click="saveConfig">保存</t-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted } from 'vue';
|
|
||||||
import { MessagePlugin } from 'tdesign-vue-next';
|
|
||||||
import { OneBotConfig } from '../../../src/onebot/config/config';
|
|
||||||
import { QQLoginManager } from '@/backend/shell';
|
|
||||||
|
|
||||||
const otherConfig = ref<Partial<OneBotConfig>>({
|
|
||||||
musicSignUrl: '',
|
|
||||||
enableLocalFile2Url: false,
|
|
||||||
parseMultMsg: true
|
|
||||||
});
|
|
||||||
|
|
||||||
const getOB11Config = async (): Promise<OneBotConfig | undefined> => {
|
|
||||||
const storedCredential = localStorage.getItem('auth');
|
|
||||||
if (!storedCredential) {
|
|
||||||
console.error('No stored credential found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const loginManager = new QQLoginManager(storedCredential);
|
|
||||||
return await loginManager.GetOB11Config();
|
|
||||||
};
|
|
||||||
|
|
||||||
const setOB11Config = async (config: OneBotConfig): Promise<boolean> => {
|
|
||||||
const storedCredential = localStorage.getItem('auth');
|
|
||||||
if (!storedCredential) {
|
|
||||||
console.error('No stored credential found');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const loginManager = new QQLoginManager(storedCredential);
|
|
||||||
return await loginManager.SetOB11Config(config);
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadConfig = async () => {
|
|
||||||
try {
|
|
||||||
const userConfig = await getOB11Config();
|
|
||||||
if (userConfig) {
|
|
||||||
otherConfig.value.musicSignUrl = userConfig.musicSignUrl;
|
|
||||||
otherConfig.value.enableLocalFile2Url = userConfig.enableLocalFile2Url;
|
|
||||||
otherConfig.value.parseMultMsg = userConfig.parseMultMsg;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading config:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveConfig = async () => {
|
|
||||||
try {
|
|
||||||
const userConfig = await getOB11Config();
|
|
||||||
if (userConfig) {
|
|
||||||
userConfig.musicSignUrl = otherConfig.value.musicSignUrl || '';
|
|
||||||
userConfig.enableLocalFile2Url = otherConfig.value.enableLocalFile2Url ?? false;
|
|
||||||
userConfig.parseMultMsg = otherConfig.value.parseMultMsg ?? true;
|
|
||||||
const success = await setOB11Config(userConfig);
|
|
||||||
if (success) {
|
|
||||||
MessagePlugin.success('配置保存成功');
|
|
||||||
} else {
|
|
||||||
MessagePlugin.error('配置保存失败');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving config:', error);
|
|
||||||
MessagePlugin.error('配置保存失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
loadConfig();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.other-config-container {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: flex-start;
|
|
||||||
padding: 20px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.other-config {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 600px;
|
|
||||||
background: #fff;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-container {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.form-item {
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-item t-input,
|
|
||||||
.form-item t-switch {
|
|
||||||
flex: 1;
|
|
||||||
margin-left: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="empty-state">
|
|
||||||
<p>当前没有网络配置</p>
|
|
||||||
<t-button @click="showAddTabDialog">添加网络配置</t-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { defineProps } from 'vue';
|
|
||||||
defineProps<{ showAddTabDialog: () => void }>();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.empty-state {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100%;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="container">
|
|
||||||
<div class="form-container">
|
|
||||||
<h3>HTTP Client 配置</h3>
|
|
||||||
<t-form>
|
|
||||||
<t-form-item label="启用">
|
|
||||||
<t-checkbox v-model="config.enable" />
|
|
||||||
</t-form-item>
|
|
||||||
<t-form-item label="URL">
|
|
||||||
<t-input v-model="config.url" />
|
|
||||||
</t-form-item>
|
|
||||||
<t-form-item label="消息格式">
|
|
||||||
<t-select v-model="config.messagePostFormat" :options="messageFormatOptions" />
|
|
||||||
</t-form-item>
|
|
||||||
<t-form-item label="报告自身消息">
|
|
||||||
<t-checkbox v-model="config.reportSelfMessage" />
|
|
||||||
</t-form-item>
|
|
||||||
<t-form-item label="Token">
|
|
||||||
<t-input v-model="config.token" />
|
|
||||||
</t-form-item>
|
|
||||||
<t-form-item label="调试模式">
|
|
||||||
<t-checkbox v-model="config.debug" />
|
|
||||||
</t-form-item>
|
|
||||||
</t-form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { defineProps, ref, watch } from 'vue';
|
|
||||||
import { HttpClientConfig } from '../../../../src/onebot/config/config';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
config: HttpClientConfig;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const messageFormatOptions = ref([
|
|
||||||
{ label: 'Array', value: 'array' },
|
|
||||||
{ label: 'String', value: 'string' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.config.messagePostFormat,
|
|
||||||
(newValue) => {
|
|
||||||
if (newValue !== 'array' && newValue !== 'string') {
|
|
||||||
props.config.messagePostFormat = 'array';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.container {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: flex-start;
|
|
||||||
padding: 20px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-container {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 600px;
|
|
||||||
background: #fff;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="container">
|
|
||||||
<div class="form-container">
|
|
||||||
<h3>HTTP Server 配置</h3>
|
|
||||||
<t-form>
|
|
||||||
<t-form-item label="启用">
|
|
||||||
<t-checkbox v-model="config.enable" />
|
|
||||||
</t-form-item>
|
|
||||||
<t-form-item label="端口">
|
|
||||||
<t-input v-model.number="config.port" type="number" />
|
|
||||||
</t-form-item>
|
|
||||||
<t-form-item label="主机">
|
|
||||||
<t-input v-model="config.host" type="text" />
|
|
||||||
</t-form-item>
|
|
||||||
<t-form-item label="启用 CORS">
|
|
||||||
<t-checkbox v-model="config.enableCors" />
|
|
||||||
</t-form-item>
|
|
||||||
<t-form-item label="启用 WS">
|
|
||||||
<t-checkbox v-model="config.enableWebsocket" />
|
|
||||||
</t-form-item>
|
|
||||||
<t-form-item label="消息格式">
|
|
||||||
<t-select v-model="config.messagePostFormat" :options="messageFormatOptions" />
|
|
||||||
</t-form-item>
|
|
||||||
<t-form-item label="Token">
|
|
||||||
<t-input v-model="config.token" type="text" />
|
|
||||||
</t-form-item>
|
|
||||||
<t-form-item label="调试模式">
|
|
||||||
<t-checkbox v-model="config.debug" />
|
|
||||||
</t-form-item>
|
|
||||||
</t-form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { defineProps, ref, watch } from 'vue';
|
|
||||||
import { HttpServerConfig } from '../../../../src/onebot/config/config';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
config: HttpServerConfig;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const messageFormatOptions = ref([
|
|
||||||
{ label: 'Array', value: 'array' },
|
|
||||||
{ label: 'String', value: 'string' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.config.messagePostFormat,
|
|
||||||
(newValue) => {
|
|
||||||
if (newValue !== 'array' && newValue !== 'string') {
|
|
||||||
props.config.messagePostFormat = 'array';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.container {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: flex-start;
|
|
||||||
padding: 20px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-container {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 600px;
|
|
||||||
background: #fff;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="container">
|
|
||||||
<div class="form-container">
|
|
||||||
<h3>WebSocket Client 配置</h3>
|
|
||||||
<t-form>
|
|
||||||
<t-form-item label="启用">
|
|
||||||
<t-checkbox v-model="config.enable" />
|
|
||||||
</t-form-item>
|
|
||||||
<t-form-item label="URL">
|
|
||||||
<t-input v-model="config.url" />
|
|
||||||
</t-form-item>
|
|
||||||
<t-form-item label="消息格式">
|
|
||||||
<t-select v-model="config.messagePostFormat" :options="messageFormatOptions" />
|
|
||||||
</t-form-item>
|
|
||||||
<t-form-item label="报告自身消息">
|
|
||||||
<t-checkbox v-model="config.reportSelfMessage" />
|
|
||||||
</t-form-item>
|
|
||||||
<t-form-item label="Token">
|
|
||||||
<t-input v-model="config.token" />
|
|
||||||
</t-form-item>
|
|
||||||
<t-form-item label="调试模式">
|
|
||||||
<t-checkbox v-model="config.debug" />
|
|
||||||
</t-form-item>
|
|
||||||
<t-form-item label="心跳间隔">
|
|
||||||
<t-input v-model.number="config.heartInterval" type="number" />
|
|
||||||
</t-form-item>
|
|
||||||
</t-form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { defineProps, ref, watch } from 'vue';
|
|
||||||
import { WebsocketClientConfig } from '../../../../src/onebot/config/config';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
config: WebsocketClientConfig;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const messageFormatOptions = ref([
|
|
||||||
{ label: 'Array', value: 'array' },
|
|
||||||
{ label: 'String', value: 'string' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.config.messagePostFormat,
|
|
||||||
(newValue) => {
|
|
||||||
if (newValue !== 'array' && newValue !== 'string') {
|
|
||||||
props.config.messagePostFormat = 'array';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.container {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: flex-start;
|
|
||||||
padding: 20px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-container {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 600px;
|
|
||||||
background: #fff;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="container">
|
|
||||||
<div class="form-container">
|
|
||||||
<h3>WebSocket Server 配置</h3>
|
|
||||||
<t-form>
|
|
||||||
<t-form-item label="启用">
|
|
||||||
<t-checkbox v-model="config.enable" />
|
|
||||||
</t-form-item>
|
|
||||||
<t-form-item label="主机">
|
|
||||||
<t-input v-model="config.host" />
|
|
||||||
</t-form-item>
|
|
||||||
<t-form-item label="端口">
|
|
||||||
<t-input v-model.number="config.port" type="number" />
|
|
||||||
</t-form-item>
|
|
||||||
<t-form-item label="消息格式">
|
|
||||||
<t-select v-model="config.messagePostFormat" :options="messageFormatOptions" />
|
|
||||||
</t-form-item>
|
|
||||||
<t-form-item label="上报自身消息">
|
|
||||||
<t-checkbox v-model="config.reportSelfMessage" />
|
|
||||||
</t-form-item>
|
|
||||||
<t-form-item label="Token">
|
|
||||||
<t-input v-model="config.token" />
|
|
||||||
</t-form-item>
|
|
||||||
<t-form-item label="强制推送事件">
|
|
||||||
<t-checkbox v-model="config.enableForcePushEvent" />
|
|
||||||
</t-form-item>
|
|
||||||
<t-form-item label="调试模式">
|
|
||||||
<t-checkbox v-model="config.debug" />
|
|
||||||
</t-form-item>
|
|
||||||
<t-form-item label="心跳间隔">
|
|
||||||
<t-input v-model.number="config.heartInterval" type="number" />
|
|
||||||
</t-form-item>
|
|
||||||
</t-form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { defineProps, ref, watch } from 'vue';
|
|
||||||
import { WebsocketServerConfig } from '../../../../src/onebot/config/config';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
config: WebsocketServerConfig;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const messageFormatOptions = ref([
|
|
||||||
{ label: 'Array', value: 'array' },
|
|
||||||
{ label: 'String', value: 'string' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.config.messagePostFormat,
|
|
||||||
(newValue) => {
|
|
||||||
if (newValue !== 'array' && newValue !== 'string') {
|
|
||||||
props.config.messagePostFormat = 'array';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.container {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: flex-start;
|
|
||||||
padding: 20px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-container {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 600px;
|
|
||||||
background: #fff;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router';
|
|
||||||
import Dashboard from '../components/Dashboard.vue';
|
|
||||||
import BasicInfo from '../pages/BasicInfo.vue';
|
|
||||||
import AboutUs from '../pages/AboutUs.vue';
|
|
||||||
import LogView from '../pages/Log.vue';
|
|
||||||
import NetWork from '../pages/NetWork.vue';
|
|
||||||
import QQLogin from '../components/QQLogin.vue';
|
|
||||||
import WebUiLogin from '../components/WebUiLogin.vue';
|
|
||||||
import OtherConfig from '../pages/OtherConfig.vue';
|
|
||||||
|
|
||||||
const routes: Array<RouteRecordRaw> = [
|
|
||||||
{ path: '/', redirect: '/webui' },
|
|
||||||
{ path: '/webui', component: WebUiLogin, name: 'WebUiLogin' },
|
|
||||||
{ path: '/qqlogin', component: QQLogin, name: 'QQLogin' },
|
|
||||||
{
|
|
||||||
path: '/dashboard',
|
|
||||||
component: Dashboard,
|
|
||||||
children: [
|
|
||||||
{ path: '', redirect: 'basic-info' },
|
|
||||||
{ path: 'basic-info', component: BasicInfo, name: 'BasicInfo' },
|
|
||||||
{ path: 'network-config', component: NetWork, name: 'NetWork' },
|
|
||||||
{ path: 'log-view', component: LogView, name: 'LogView' },
|
|
||||||
{ path: 'other-config', component: OtherConfig, name: 'OtherConfig' },
|
|
||||||
{ path: 'about-us', component: AboutUs, name: 'AboutUs' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const router = createRouter({
|
|
||||||
history: createWebHashHistory(),
|
|
||||||
routes,
|
|
||||||
});
|
|
||||||
1
napcat.webui/src/vite-env.d.ts
vendored
1
napcat.webui/src/vite-env.d.ts
vendored
@@ -1 +0,0 @@
|
|||||||
/// <reference types="vite/client" />
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ESNext",
|
|
||||||
"jsx": "preserve",
|
|
||||||
"jsxImportSource": "vue",
|
|
||||||
"lib": [
|
|
||||||
"DOM",
|
|
||||||
"DOM.Iterable"
|
|
||||||
],
|
|
||||||
"baseUrl": ".",
|
|
||||||
"module": "esnext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"paths": {
|
|
||||||
"@/*": [
|
|
||||||
"src/*"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"types": [
|
|
||||||
"vite/client"
|
|
||||||
],
|
|
||||||
"strict": true,
|
|
||||||
"strictNullChecks": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"experimentalDecorators": true,
|
|
||||||
"useDefineForClassFields": true
|
|
||||||
},
|
|
||||||
"include": ["src"],
|
|
||||||
"exclude": ["node_modules"],
|
|
||||||
"references": [{"path": "./tsconfig.node.json"}]
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"composite": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
"strictNullChecks": true
|
|
||||||
},
|
|
||||||
"include": ["vite.config.ts"]
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { defineConfig } from 'vite';
|
|
||||||
import vue from '@vitejs/plugin-vue';
|
|
||||||
import legacy from '@vitejs/plugin-legacy';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
// https://vite.dev/config/
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [
|
|
||||||
vue(),
|
|
||||||
legacy({
|
|
||||||
targets: ['defaults', 'not IE 11'],
|
|
||||||
modernPolyfills: ['web.structured-clone'],
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
base: './',
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@': path.resolve(__dirname, 'src'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
proxy: {
|
|
||||||
'/api': 'http://localhost:6099',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
build: {
|
|
||||||
chunkSizeWarningLimit: 4000,
|
|
||||||
rollupOptions: {
|
|
||||||
output: {
|
|
||||||
chunkFileNames: 'static/js/[name]-[hash].js',
|
|
||||||
entryFileNames: 'static/js/[name]-[hash].js',
|
|
||||||
assetFileNames: 'static/[ext]/[name]-[hash].[ext]',
|
|
||||||
manualChunks(id: string) {
|
|
||||||
if (id.includes('node_modules')) {
|
|
||||||
return id.toString().split('node_modules/')[1].split('/')[0].toString();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
58
package.json
58
package.json
@@ -2,59 +2,21 @@
|
|||||||
"name": "napcat",
|
"name": "napcat",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "4.1.18",
|
"version": "0.0.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:framework": "npm run build:webui && vite build --mode framework || exit 1",
|
"build:shell": "pnpm --filter napcat-shell run build || exit 1",
|
||||||
"build:shell": "npm run build:webui && vite build --mode shell || exit 1",
|
"build:framework": "pnpm --filter napcat-framework run build || exit 1",
|
||||||
"build:webui": "cd napcat.webui && vite build",
|
"build:webui": "pnpm --filter napcat-webui-frontend run build || exit 1",
|
||||||
"dev:framework": "vite build --mode framework",
|
"dev:shell": "pnpm --filter napcat-develop run dev || exit 1"
|
||||||
"dev:shell": "vite build --mode shell",
|
|
||||||
"dev:webui": "cd napcat.webui && npm run webui:dev",
|
|
||||||
"lint": "eslint --fix src/**/*.{js,ts,vue}",
|
|
||||||
"depend": "cd dist && npm install --omit=dev"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/preset-typescript": "^7.24.7",
|
"@rollup/plugin-node-resolve": "^16.0.3",
|
||||||
"@eslint/compat": "^1.2.2",
|
"vite": "^6.4.1",
|
||||||
"@eslint/eslintrc": "^3.1.0",
|
"vite-plugin-cp": "^6.0.3"
|
||||||
"@eslint/js": "^9.14.0",
|
|
||||||
"@log4js-node/log4js-api": "^1.0.2",
|
|
||||||
"@napneko/nap-proto-core": "^0.0.4",
|
|
||||||
"@rollup/plugin-node-resolve": "^15.2.3",
|
|
||||||
"@rollup/plugin-typescript": "^11.1.6",
|
|
||||||
"@types/cors": "^2.8.17",
|
|
||||||
"@types/express": "^5.0.0",
|
|
||||||
"@types/fluent-ffmpeg": "^2.1.24",
|
|
||||||
"@types/node": "^22.0.1",
|
|
||||||
"@types/qrcode-terminal": "^0.12.2",
|
|
||||||
"@types/ws": "^8.5.12",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^8.3.0",
|
|
||||||
"@typescript-eslint/parser": "^8.3.0",
|
|
||||||
"ajv": "^8.13.0",
|
|
||||||
"async-mutex": "^0.5.0",
|
|
||||||
"commander": "^12.1.0",
|
|
||||||
"cors": "^2.8.5",
|
|
||||||
"eslint": "^9.14.0",
|
|
||||||
"eslint-import-resolver-typescript": "^3.6.1",
|
|
||||||
"eslint-plugin-import": "^2.29.1",
|
|
||||||
"fast-xml-parser": "^4.3.6",
|
|
||||||
"file-type": "^19.0.0",
|
|
||||||
"globals": "^15.12.0",
|
|
||||||
"image-size": "^1.1.1",
|
|
||||||
"json-schema-to-ts": "^3.1.1",
|
|
||||||
"typescript": "^5.3.3",
|
|
||||||
"typescript-eslint": "^8.13.0",
|
|
||||||
"vite": "^5.2.6",
|
|
||||||
"vite-plugin-cp": "^4.0.8",
|
|
||||||
"vite-tsconfig-paths": "^5.1.0",
|
|
||||||
"winston": "^3.17.0"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^5.0.0",
|
|
||||||
"fluent-ffmpeg": "^2.1.2",
|
|
||||||
"qrcode-terminal": "^0.12.0",
|
|
||||||
"silk-wasm": "^3.6.1",
|
"silk-wasm": "^3.6.1",
|
||||||
"ws": "^8.18.0",
|
"express": "^5.0.0",
|
||||||
"piscina": "^4.7.0"
|
"ws": "^8.18.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
31
packages/napcat-common/package.json
Normal file
31
packages/napcat-common/package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "napcat-common",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"./src/*": {
|
||||||
|
"import": "./src/*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"compressing": "^1.10.1",
|
||||||
|
"json5": "^2.2.3",
|
||||||
|
"ajv": "^8.13.0",
|
||||||
|
"file-type": "^21.0.0",
|
||||||
|
"napcat-image-size": "workspace:*",
|
||||||
|
"napcat-core": "workspace:*",
|
||||||
|
"silk-wasm": "^3.6.1",
|
||||||
|
"winston": "^3.17.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
20
packages/napcat-common/src/audio-worker.ts
Normal file
20
packages/napcat-common/src/audio-worker.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { encode } from 'silk-wasm';
|
||||||
|
import { parentPort } from 'worker_threads';
|
||||||
|
|
||||||
|
export interface EncodeArgs {
|
||||||
|
input: ArrayBufferView | ArrayBuffer
|
||||||
|
sampleRate: number
|
||||||
|
}
|
||||||
|
export function recvTask<T> (cb: (taskData: T) => Promise<unknown>) {
|
||||||
|
parentPort?.on('message', async (taskData: T) => {
|
||||||
|
try {
|
||||||
|
const ret = await cb(taskData);
|
||||||
|
parentPort?.postMessage(ret);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
parentPort?.postMessage({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
recvTask<EncodeArgs>(async ({ input, sampleRate }) => {
|
||||||
|
return await encode(input, sampleRate);
|
||||||
|
});
|
||||||
84
packages/napcat-common/src/audio.ts
Normal file
84
packages/napcat-common/src/audio.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import fsPromise from 'fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { EncodeResult, getDuration, getWavFileInfo, isSilk, isWav } from 'silk-wasm';
|
||||||
|
import { LogWrapper } from '@/napcat-common/log';
|
||||||
|
import { EncodeArgs } from '@/napcat-common/audio-worker';
|
||||||
|
import { FFmpegService } from '@/napcat-common/ffmpeg';
|
||||||
|
import { runTask } from './worker';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const ALLOW_SAMPLE_RATE = [8000, 12000, 16000, 24000, 32000, 44100, 48000];
|
||||||
|
|
||||||
|
function getWorkerPath () {
|
||||||
|
// return new URL(/* @vite-ignore */ './audio-worker.mjs', import.meta.url).href;
|
||||||
|
return path.join(path.dirname(fileURLToPath(import.meta.url)), 'audio-worker.mjs');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function guessDuration (pttPath: string, logger: LogWrapper) {
|
||||||
|
const pttFileInfo = await fsPromise.stat(pttPath);
|
||||||
|
const duration = Math.max(1, Math.floor(pttFileInfo.size / 1024 / 3)); // 3kb/s
|
||||||
|
logger.log('通过文件大小估算语音的时长:', duration);
|
||||||
|
return duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleWavFile (
|
||||||
|
file: Buffer,
|
||||||
|
filePath: string,
|
||||||
|
pcmPath: string
|
||||||
|
): Promise<{ input: Buffer; sampleRate: number }> {
|
||||||
|
const { fmt } = getWavFileInfo(file);
|
||||||
|
if (!ALLOW_SAMPLE_RATE.includes(fmt.sampleRate)) {
|
||||||
|
const result = await FFmpegService.convert(filePath, pcmPath);
|
||||||
|
return { input: await fsPromise.readFile(pcmPath), sampleRate: result.sampleRate };
|
||||||
|
}
|
||||||
|
return { input: file, sampleRate: fmt.sampleRate };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encodeSilk (filePath: string, TEMP_DIR: string, logger: LogWrapper) {
|
||||||
|
try {
|
||||||
|
const file = await fsPromise.readFile(filePath);
|
||||||
|
const pttPath = path.join(TEMP_DIR, randomUUID());
|
||||||
|
if (!isSilk(file)) {
|
||||||
|
logger.log(`语音文件${filePath}需要转换成silk`);
|
||||||
|
const pcmPath = `${pttPath}.pcm`;
|
||||||
|
// const { input, sampleRate } = isWav(file) ? await handleWavFile(file, filePath, pcmPath): { input: await FFmpegService.convert(filePath, pcmPath) ? await fsPromise.readFile(pcmPath) : Buffer.alloc(0), sampleRate: 24000 };
|
||||||
|
let input: Buffer;
|
||||||
|
let sampleRate: number;
|
||||||
|
if (isWav(file)) {
|
||||||
|
const result = await handleWavFile(file, filePath, pcmPath);
|
||||||
|
input = result.input;
|
||||||
|
sampleRate = result.sampleRate;
|
||||||
|
} else {
|
||||||
|
const result = await FFmpegService.convert(filePath, pcmPath);
|
||||||
|
input = await fsPromise.readFile(pcmPath);
|
||||||
|
sampleRate = result.sampleRate;
|
||||||
|
}
|
||||||
|
const silk = await runTask<EncodeArgs, EncodeResult>(getWorkerPath(), { input, sampleRate });
|
||||||
|
fsPromise.unlink(pcmPath).catch((e) => logger.logError('删除临时文件失败', pcmPath, e));
|
||||||
|
await fsPromise.writeFile(pttPath, Buffer.from(silk.data));
|
||||||
|
logger.log(`语音文件${filePath}转换成功!`, pttPath, '时长:', silk.duration);
|
||||||
|
return {
|
||||||
|
converted: true,
|
||||||
|
path: pttPath,
|
||||||
|
duration: silk.duration / 1000,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
let duration = 0;
|
||||||
|
try {
|
||||||
|
duration = getDuration(file) / 1000;
|
||||||
|
} catch (e: unknown) {
|
||||||
|
logger.log('获取语音文件时长失败, 使用文件大小推测时长', filePath, (e as Error).stack);
|
||||||
|
duration = await guessDuration(filePath, logger);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
converted: false,
|
||||||
|
path: filePath,
|
||||||
|
duration,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logger.logError('convert silk failed', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
79
packages/napcat-common/src/cancel-task.ts
Normal file
79
packages/napcat-common/src/cancel-task.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
export type TaskExecutor<T> = (resolve: (value: T | PromiseLike<T>) => void, reject: (reason?: any) => void, onCancel: (callback: () => void) => void) => void | Promise<void>;
|
||||||
|
|
||||||
|
export class CancelableTask<T> {
|
||||||
|
private promise: Promise<T>;
|
||||||
|
private cancelCallback: (() => void) | null = null;
|
||||||
|
private isCanceled = false;
|
||||||
|
private cancelListeners: Array<() => void> = [];
|
||||||
|
|
||||||
|
constructor (executor: TaskExecutor<T>) {
|
||||||
|
this.promise = new Promise<T>((resolve, reject) => {
|
||||||
|
const onCancel = (callback: () => void) => {
|
||||||
|
this.cancelCallback = callback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const execute = async () => {
|
||||||
|
try {
|
||||||
|
await executor(
|
||||||
|
(value) => {
|
||||||
|
if (!this.isCanceled) {
|
||||||
|
resolve(value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(reason) => {
|
||||||
|
if (!this.isCanceled) {
|
||||||
|
reject(reason);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCancel
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (!this.isCanceled) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
execute();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public cancel () {
|
||||||
|
if (this.cancelCallback) {
|
||||||
|
this.cancelCallback();
|
||||||
|
}
|
||||||
|
this.isCanceled = true;
|
||||||
|
this.cancelListeners.forEach(listener => listener());
|
||||||
|
}
|
||||||
|
|
||||||
|
public isTaskCanceled (): boolean {
|
||||||
|
return this.isCanceled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public onCancel (listener: () => void) {
|
||||||
|
this.cancelListeners.push(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
public then<TResult1 = T, TResult2 = never>(
|
||||||
|
onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null,
|
||||||
|
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null
|
||||||
|
): Promise<TResult1 | TResult2> {
|
||||||
|
return this.promise.then(onfulfilled, onrejected);
|
||||||
|
}
|
||||||
|
|
||||||
|
public catch<TResult = never>(
|
||||||
|
onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | undefined | null
|
||||||
|
): Promise<T | TResult> {
|
||||||
|
return this.promise.catch(onrejected);
|
||||||
|
}
|
||||||
|
|
||||||
|
public finally (onfinally?: (() => void) | undefined | null): Promise<T> {
|
||||||
|
return this.promise.finally(onfinally);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Symbol.asyncIterator] () {
|
||||||
|
return {
|
||||||
|
next: () => this.promise.then(value => ({ value, done: true })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
229
packages/napcat-common/src/clean-task.ts
Normal file
229
packages/napcat-common/src/clean-task.ts
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
// generate Claude 3.7 Sonet Thinking
|
||||||
|
|
||||||
|
interface FileRecord {
|
||||||
|
filePath: string;
|
||||||
|
addedTime: number;
|
||||||
|
retries: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CleanupTask {
|
||||||
|
fileRecord: FileRecord;
|
||||||
|
timer: NodeJS.Timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CleanupQueue {
|
||||||
|
private tasks: Map<string, CleanupTask> = new Map();
|
||||||
|
private readonly MAX_RETRIES = 3;
|
||||||
|
private isProcessing: boolean = false;
|
||||||
|
private pendingOperations: Array<() => void> = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行队列中的待处理操作,确保异步安全
|
||||||
|
*/
|
||||||
|
private executeNextOperation (): void {
|
||||||
|
if (this.pendingOperations.length === 0) {
|
||||||
|
this.isProcessing = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isProcessing = true;
|
||||||
|
const operation = this.pendingOperations.shift();
|
||||||
|
operation?.();
|
||||||
|
|
||||||
|
// 使用 setImmediate 允许事件循环继续,防止阻塞
|
||||||
|
setImmediate(() => this.executeNextOperation());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全执行操作,防止竞态条件
|
||||||
|
* @param operation 要执行的操作
|
||||||
|
*/
|
||||||
|
private safeExecute (operation: () => void): void {
|
||||||
|
this.pendingOperations.push(operation);
|
||||||
|
if (!this.isProcessing) {
|
||||||
|
this.executeNextOperation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查文件是否存在
|
||||||
|
* @param filePath 文件路径
|
||||||
|
* @returns 文件是否存在
|
||||||
|
*/
|
||||||
|
private fileExists (filePath: string): boolean {
|
||||||
|
try {
|
||||||
|
return fs.existsSync(filePath);
|
||||||
|
} catch (_error) {
|
||||||
|
// console.log(`检查文件存在出错: ${filePath}`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加文件到清理队列
|
||||||
|
* @param filePath 文件路径
|
||||||
|
* @param cleanupDelay 清理延迟时间(毫秒)
|
||||||
|
*/
|
||||||
|
addFile (filePath: string, cleanupDelay: number): void {
|
||||||
|
this.safeExecute(() => {
|
||||||
|
// 如果文件已在队列中,取消原来的计时器
|
||||||
|
if (this.tasks.has(filePath)) {
|
||||||
|
this.cancelCleanup(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新的文件记录
|
||||||
|
const fileRecord: FileRecord = {
|
||||||
|
filePath,
|
||||||
|
addedTime: Date.now(),
|
||||||
|
retries: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 设置计时器
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.cleanupFile(fileRecord, cleanupDelay);
|
||||||
|
}, cleanupDelay);
|
||||||
|
|
||||||
|
// 添加到任务队列
|
||||||
|
this.tasks.set(filePath, { fileRecord, timer });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量添加文件到清理队列
|
||||||
|
* @param filePaths 文件路径数组
|
||||||
|
* @param cleanupDelay 清理延迟时间(毫秒)
|
||||||
|
*/
|
||||||
|
addFiles (filePaths: string[], cleanupDelay: number): void {
|
||||||
|
this.safeExecute(() => {
|
||||||
|
for (const filePath of filePaths) {
|
||||||
|
// 内部直接处理,不通过 safeExecute 以保证批量操作的原子性
|
||||||
|
if (this.tasks.has(filePath)) {
|
||||||
|
// 取消已有的计时器,但不使用 cancelCleanup 方法以避免重复的安全检查
|
||||||
|
const existingTask = this.tasks.get(filePath);
|
||||||
|
if (existingTask) {
|
||||||
|
clearTimeout(existingTask.timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileRecord: FileRecord = {
|
||||||
|
filePath,
|
||||||
|
addedTime: Date.now(),
|
||||||
|
retries: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.cleanupFile(fileRecord, cleanupDelay);
|
||||||
|
}, cleanupDelay);
|
||||||
|
|
||||||
|
this.tasks.set(filePath, { fileRecord, timer });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理文件
|
||||||
|
* @param record 文件记录
|
||||||
|
* @param delay 延迟时间,用于重试
|
||||||
|
*/
|
||||||
|
private cleanupFile (record: FileRecord, delay: number): void {
|
||||||
|
this.safeExecute(() => {
|
||||||
|
// 首先检查文件是否存在,不存在则视为清理成功
|
||||||
|
if (!this.fileExists(record.filePath)) {
|
||||||
|
// console.log(`文件已不存在,跳过清理: ${record.filePath}`);
|
||||||
|
this.tasks.delete(record.filePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 尝试删除文件
|
||||||
|
fs.unlinkSync(record.filePath);
|
||||||
|
// 删除成功,从队列中移除任务
|
||||||
|
this.tasks.delete(record.filePath);
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as NodeJS.ErrnoException;
|
||||||
|
|
||||||
|
// 明确处理文件不存在的情况
|
||||||
|
if (err.code === 'ENOENT') {
|
||||||
|
// console.log(`文件在删除时不存在,视为清理成功: ${record.filePath}`);
|
||||||
|
this.tasks.delete(record.filePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件没有访问权限等情况
|
||||||
|
if (err.code === 'EACCES' || err.code === 'EPERM') {
|
||||||
|
// console.error(`没有权限删除文件: ${record.filePath}`, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他删除失败情况,考虑重试
|
||||||
|
if (record.retries < this.MAX_RETRIES - 1) {
|
||||||
|
// 还有重试机会,增加重试次数
|
||||||
|
record.retries++;
|
||||||
|
// console.log(`清理文件失败,将重试(${record.retries}/${this.MAX_RETRIES}): ${record.filePath}`);
|
||||||
|
|
||||||
|
// 设置相同的延迟时间再次尝试
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.cleanupFile(record, delay);
|
||||||
|
}, delay);
|
||||||
|
|
||||||
|
// 更新任务
|
||||||
|
this.tasks.set(record.filePath, { fileRecord: record, timer });
|
||||||
|
} else {
|
||||||
|
// 已达到最大重试次数,从队列中移除任务
|
||||||
|
this.tasks.delete(record.filePath);
|
||||||
|
// console.error(`清理文件失败,已达最大重试次数(${this.MAX_RETRIES}): ${record.filePath}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消文件的清理任务
|
||||||
|
* @param filePath 文件路径
|
||||||
|
* @returns 是否成功取消
|
||||||
|
*/
|
||||||
|
cancelCleanup (filePath: string): boolean {
|
||||||
|
let cancelled = false;
|
||||||
|
this.safeExecute(() => {
|
||||||
|
const task = this.tasks.get(filePath);
|
||||||
|
if (task) {
|
||||||
|
clearTimeout(task.timer);
|
||||||
|
this.tasks.delete(filePath);
|
||||||
|
cancelled = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return cancelled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取队列中的文件数量
|
||||||
|
* @returns 文件数量
|
||||||
|
*/
|
||||||
|
getQueueSize (): number {
|
||||||
|
return this.tasks.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有待清理的文件
|
||||||
|
* @returns 文件路径数组
|
||||||
|
*/
|
||||||
|
getPendingFiles (): string[] {
|
||||||
|
return Array.from(this.tasks.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空所有清理任务
|
||||||
|
*/
|
||||||
|
clearAll (): void {
|
||||||
|
this.safeExecute(() => {
|
||||||
|
// 取消所有定时器
|
||||||
|
for (const task of this.tasks.values()) {
|
||||||
|
clearTimeout(task.timer);
|
||||||
|
}
|
||||||
|
this.tasks.clear();
|
||||||
|
// console.log('已清空所有清理任务');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cleanTaskQueue = new CleanupQueue();
|
||||||
74
packages/napcat-common/src/config-base.ts
Normal file
74
packages/napcat-common/src/config-base.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import type { NapCatCore } from 'napcat-core';
|
||||||
|
import json5 from 'json5';
|
||||||
|
import Ajv, { AnySchema, ValidateFunction } from 'ajv';
|
||||||
|
|
||||||
|
export abstract class ConfigBase<T> {
|
||||||
|
name: string;
|
||||||
|
core: NapCatCore;
|
||||||
|
configPath: string;
|
||||||
|
configData: T = {} as T;
|
||||||
|
ajv: Ajv;
|
||||||
|
validate: ValidateFunction<T>;
|
||||||
|
|
||||||
|
protected constructor (name: string, core: NapCatCore, configPath: string, ConfigSchema: AnySchema) {
|
||||||
|
this.name = name;
|
||||||
|
this.core = core;
|
||||||
|
this.configPath = configPath;
|
||||||
|
this.ajv = new Ajv({ useDefaults: true, coerceTypes: true });
|
||||||
|
this.validate = this.ajv.compile<T>(ConfigSchema);
|
||||||
|
fs.mkdirSync(this.configPath, { recursive: true });
|
||||||
|
this.read();
|
||||||
|
}
|
||||||
|
|
||||||
|
getConfigPath (pathName?: string): string {
|
||||||
|
const filename = pathName ? `${this.name}_${pathName}.json` : `${this.name}.json`;
|
||||||
|
return path.join(this.configPath, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
read (): T {
|
||||||
|
const configPath = this.getConfigPath(this.core.selfInfo.uin);
|
||||||
|
const defaultConfigPath = this.getConfigPath();
|
||||||
|
if (!fs.existsSync(configPath)) {
|
||||||
|
if (fs.existsSync(defaultConfigPath)) {
|
||||||
|
this.configData = this.loadConfig(defaultConfigPath);
|
||||||
|
}
|
||||||
|
this.save();
|
||||||
|
return this.configData;
|
||||||
|
}
|
||||||
|
return this.loadConfig(configPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadConfig (configPath: string): T {
|
||||||
|
try {
|
||||||
|
const newConfigData = json5.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||||
|
this.validate(newConfigData);
|
||||||
|
this.configData = newConfigData;
|
||||||
|
this.core.context.logger.logDebug(`[Core] [Config] 配置文件${configPath}加载`, this.configData);
|
||||||
|
return this.configData;
|
||||||
|
} catch (e: unknown) {
|
||||||
|
this.handleError(e, '读取配置文件时发生错误');
|
||||||
|
return {} as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
save (newConfigData: T = this.configData): void {
|
||||||
|
const configPath = this.getConfigPath(this.core.selfInfo.uin);
|
||||||
|
this.validate(newConfigData);
|
||||||
|
this.configData = newConfigData;
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify(this.configData, null, 2));
|
||||||
|
} catch (e: unknown) {
|
||||||
|
this.handleError(e, `保存配置文件 ${configPath} 时发生错误:`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleError (e: unknown, message: string): void {
|
||||||
|
if (e instanceof SyntaxError) {
|
||||||
|
this.core.context.logger.logError('[Core] [Config] 操作配置文件格式错误,请检查配置文件:', e.message);
|
||||||
|
} else {
|
||||||
|
this.core.context.logger.logError(`[Core] [Config] ${message}:`, (e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
359
packages/napcat-common/src/download-ffmpeg.ts
Normal file
359
packages/napcat-common/src/download-ffmpeg.ts
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
// 更正导入语句
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as https from 'https';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as compressing from 'compressing'; // 修正导入方式
|
||||||
|
import { pipeline } from 'stream/promises';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { LogWrapper } from './log';
|
||||||
|
|
||||||
|
const downloadOri = 'https://github.com/NapNeko/ffmpeg-build/releases/download/v1.0.0/ffmpeg-7.1.1-win64.zip';
|
||||||
|
const urls = [
|
||||||
|
'https://j.1win.ggff.net/' + downloadOri,
|
||||||
|
'https://git.yylx.win/' + downloadOri,
|
||||||
|
'https://ghfile.geekertao.top/' + downloadOri,
|
||||||
|
'https://gh-proxy.net/' + downloadOri,
|
||||||
|
'https://ghm.078465.xyz/' + downloadOri,
|
||||||
|
'https://gitproxy.127731.xyz/' + downloadOri,
|
||||||
|
'https://jiashu.1win.eu.org/' + downloadOri,
|
||||||
|
'https://github.tbedu.top/' + downloadOri,
|
||||||
|
downloadOri,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试URL是否可用
|
||||||
|
* @param url 待测试的URL
|
||||||
|
* @returns 如果URL可访问返回true,否则返回false
|
||||||
|
*/
|
||||||
|
async function testUrl (url: string): Promise<boolean> {
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
const req = https.get(url, { timeout: 5000 }, (res) => {
|
||||||
|
// 检查状态码是否表示成功
|
||||||
|
const statusCode = res.statusCode || 0;
|
||||||
|
if (statusCode >= 200 && statusCode < 300) {
|
||||||
|
// 终止请求并返回true
|
||||||
|
req.destroy();
|
||||||
|
resolve(true);
|
||||||
|
} else {
|
||||||
|
req.destroy();
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', () => {
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('timeout', () => {
|
||||||
|
req.destroy();
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查找第一个可用的URL
|
||||||
|
* @returns 返回第一个可用的URL,如果都不可用则返回null
|
||||||
|
*/
|
||||||
|
async function findAvailableUrl (): Promise<string | null> {
|
||||||
|
for (const url of urls) {
|
||||||
|
try {
|
||||||
|
const available = await testUrl(url);
|
||||||
|
if (available) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// 忽略错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 下载文件
|
||||||
|
* @param url 下载URL
|
||||||
|
* @param destPath 目标保存路径
|
||||||
|
* @returns 成功返回true,失败返回false
|
||||||
|
*/
|
||||||
|
async function downloadFile (url: string, destPath: string, progressCallback?: (percent: number) => void): Promise<boolean> {
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
const file = fs.createWriteStream(destPath);
|
||||||
|
|
||||||
|
const req = https.get(url, (res) => {
|
||||||
|
const statusCode = res.statusCode || 0;
|
||||||
|
|
||||||
|
if (statusCode >= 200 && statusCode < 300) {
|
||||||
|
// 获取文件总大小
|
||||||
|
const totalSize = parseInt(res.headers['content-length'] || '0', 10);
|
||||||
|
let downloadedSize = 0;
|
||||||
|
let lastReportedPercent = -1; // 上次报告的百分比
|
||||||
|
let lastReportTime = 0; // 上次报告的时间戳
|
||||||
|
|
||||||
|
// 如果有内容长度和进度回调,则添加数据监听
|
||||||
|
if (totalSize > 0 && progressCallback) {
|
||||||
|
// 初始报告 0%
|
||||||
|
progressCallback(0);
|
||||||
|
lastReportTime = Date.now();
|
||||||
|
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
downloadedSize += chunk.length;
|
||||||
|
const currentPercent = Math.floor((downloadedSize / totalSize) * 100);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// 只在以下条件触发回调:
|
||||||
|
// 1. 百分比变化至少为1%
|
||||||
|
// 2. 距离上次报告至少500毫秒
|
||||||
|
// 3. 确保报告100%完成
|
||||||
|
if ((currentPercent !== lastReportedPercent &&
|
||||||
|
(currentPercent - lastReportedPercent >= 1 || currentPercent === 100)) &&
|
||||||
|
(now - lastReportTime >= 1000 || currentPercent === 100)) {
|
||||||
|
progressCallback(currentPercent);
|
||||||
|
lastReportedPercent = currentPercent;
|
||||||
|
lastReportTime = now;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pipeline(res, file)
|
||||||
|
.then(() => {
|
||||||
|
// 确保最后报告100%
|
||||||
|
if (progressCallback && lastReportedPercent !== 100) {
|
||||||
|
progressCallback(100);
|
||||||
|
}
|
||||||
|
resolve(true);
|
||||||
|
})
|
||||||
|
.catch(() => resolve(false));
|
||||||
|
} else {
|
||||||
|
file.close();
|
||||||
|
fs.unlink(destPath, () => { });
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', () => {
|
||||||
|
file.close();
|
||||||
|
fs.unlink(destPath, () => { });
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解压缩zip文件中的特定内容
|
||||||
|
* 只解压bin目录中的文件到目标目录
|
||||||
|
* @param zipPath 压缩文件路径
|
||||||
|
* @param extractDir 解压目标路径
|
||||||
|
*/
|
||||||
|
async function extractBinDirectory (zipPath: string, extractDir: string): Promise<void> {
|
||||||
|
// 确保目标目录存在
|
||||||
|
if (!fs.existsSync(extractDir)) {
|
||||||
|
fs.mkdirSync(extractDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解压文件
|
||||||
|
const zipStream = new compressing.zip.UncompressStream({ source: zipPath });
|
||||||
|
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
// 监听条目事件
|
||||||
|
zipStream.on('entry', (header, stream, next) => {
|
||||||
|
// 获取文件路径
|
||||||
|
const filePath = header.name;
|
||||||
|
|
||||||
|
// 匹配内层bin目录中的文件
|
||||||
|
// 例如:ffmpeg-n7.1.1-6-g48c0f071d4-win64-lgpl-7.1/bin/ffmpeg.exe
|
||||||
|
if (filePath.includes('/bin/') && filePath.endsWith('.exe')) {
|
||||||
|
// 提取文件名
|
||||||
|
const fileName = path.basename(filePath);
|
||||||
|
const targetPath = path.join(extractDir, fileName);
|
||||||
|
|
||||||
|
// 创建写入流
|
||||||
|
const writeStream = fs.createWriteStream(targetPath);
|
||||||
|
|
||||||
|
// 将流管道连接到文件
|
||||||
|
stream.pipe(writeStream);
|
||||||
|
|
||||||
|
// 监听写入完成事件
|
||||||
|
writeStream.on('finish', () => {
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
writeStream.on('error', () => {
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 跳过不需要的文件
|
||||||
|
stream.resume();
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
zipStream.on('error', (err) => {
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
zipStream.on('finish', () => {
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载并设置FFmpeg
|
||||||
|
* @param destDir 目标安装目录,默认为用户临时目录下的ffmpeg文件夹
|
||||||
|
* @param tempDir 临时文件目录,默认为系统临时目录
|
||||||
|
* @returns 返回ffmpeg可执行文件的路径,如果失败则返回null
|
||||||
|
*/
|
||||||
|
export async function downloadFFmpeg (
|
||||||
|
destDir?: string,
|
||||||
|
tempDir?: string,
|
||||||
|
progressCallback?: (percent: number, stage: string) => void
|
||||||
|
): Promise<string | null> {
|
||||||
|
// 仅限Windows
|
||||||
|
if (os.platform() !== 'win32') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const destinationDir = destDir || path.join(os.tmpdir(), 'ffmpeg');
|
||||||
|
const tempDirectory = tempDir || os.tmpdir();
|
||||||
|
const zipFilePath = path.join(tempDirectory, 'ffmpeg.zip'); // 临时下载到指定临时目录
|
||||||
|
const ffmpegExePath = path.join(destinationDir, 'ffmpeg.exe');
|
||||||
|
|
||||||
|
// 确保目录存在
|
||||||
|
if (!fs.existsSync(destinationDir)) {
|
||||||
|
fs.mkdirSync(destinationDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保临时目录存在
|
||||||
|
if (!fs.existsSync(tempDirectory)) {
|
||||||
|
fs.mkdirSync(tempDirectory, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果ffmpeg已经存在,直接返回路径
|
||||||
|
if (fs.existsSync(ffmpegExePath)) {
|
||||||
|
if (progressCallback) progressCallback(100, '已找到FFmpeg');
|
||||||
|
return ffmpegExePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找可用URL
|
||||||
|
if (progressCallback) progressCallback(0, '查找可用下载源');
|
||||||
|
const availableUrl = await findAvailableUrl();
|
||||||
|
if (!availableUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载文件
|
||||||
|
if (progressCallback) progressCallback(5, '开始下载FFmpeg');
|
||||||
|
const downloaded = await downloadFile(
|
||||||
|
availableUrl,
|
||||||
|
zipFilePath,
|
||||||
|
(percent) => {
|
||||||
|
// 下载占总进度的70%
|
||||||
|
if (progressCallback) progressCallback(5 + Math.floor(percent * 0.7), '下载FFmpeg');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!downloaded) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 直接解压bin目录文件到目标目录
|
||||||
|
if (progressCallback) progressCallback(75, '解压FFmpeg');
|
||||||
|
await extractBinDirectory(zipFilePath, destinationDir);
|
||||||
|
|
||||||
|
// 清理下载文件
|
||||||
|
if (progressCallback) progressCallback(95, '清理临时文件');
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(zipFilePath);
|
||||||
|
} catch (_err) {
|
||||||
|
// 忽略清理临时文件失败的错误
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查ffmpeg.exe是否成功解压
|
||||||
|
if (fs.existsSync(ffmpegExePath)) {
|
||||||
|
if (progressCallback) progressCallback(100, 'FFmpeg安装完成');
|
||||||
|
return ffmpegExePath;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (_err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查系统PATH环境变量中是否存在指定可执行文件
|
||||||
|
* @param executable 可执行文件名
|
||||||
|
* @returns 如果找到返回完整路径,否则返回null
|
||||||
|
*/
|
||||||
|
function findExecutableInPath (executable: string): string | null {
|
||||||
|
// 仅适用于Windows系统
|
||||||
|
if (os.platform() !== 'win32') return null;
|
||||||
|
|
||||||
|
// 获取PATH环境变量
|
||||||
|
const pathEnv = process.env['PATH'] || '';
|
||||||
|
const pathDirs = pathEnv.split(';');
|
||||||
|
|
||||||
|
// 检查每个目录
|
||||||
|
for (const dir of pathDirs) {
|
||||||
|
if (!dir) continue;
|
||||||
|
try {
|
||||||
|
const filePath = path.join(dir, executable);
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadFFmpegIfNotExists (log: LogWrapper) {
|
||||||
|
// 仅限Windows
|
||||||
|
if (os.platform() !== 'win32') {
|
||||||
|
return {
|
||||||
|
path: null,
|
||||||
|
reset: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const ffmpegInPath = findExecutableInPath('ffmpeg.exe');
|
||||||
|
const ffprobeInPath = findExecutableInPath('ffprobe.exe');
|
||||||
|
|
||||||
|
if (ffmpegInPath && ffprobeInPath) {
|
||||||
|
const ffmpegDir = path.dirname(ffmpegInPath);
|
||||||
|
return {
|
||||||
|
path: ffmpegDir,
|
||||||
|
reset: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果环境变量中没有,检查项目目录中是否存在
|
||||||
|
const currentPath = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const ffmpeg_exist = fs.existsSync(path.join(currentPath, 'ffmpeg', 'ffmpeg.exe'));
|
||||||
|
const ffprobe_exist = fs.existsSync(path.join(currentPath, 'ffmpeg', 'ffprobe.exe'));
|
||||||
|
|
||||||
|
if (!ffmpeg_exist || !ffprobe_exist) {
|
||||||
|
const url = await downloadFFmpeg(path.join(currentPath, 'ffmpeg'), path.join(currentPath, 'cache'), (percentage: number, message: string) => {
|
||||||
|
log.log(`[FFmpeg] [Download] ${percentage}% - ${message}`);
|
||||||
|
});
|
||||||
|
if (!url) {
|
||||||
|
log.log('[FFmpeg] [Error] 下载FFmpeg失败');
|
||||||
|
return {
|
||||||
|
path: null,
|
||||||
|
reset: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
path: path.join(currentPath, 'ffmpeg'),
|
||||||
|
reset: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: path.join(currentPath, 'ffmpeg'),
|
||||||
|
reset: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
9
packages/napcat-common/src/env.d.ts
vendored
Normal file
9
packages/napcat-common/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_NAPCAT_VERSION: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
272
packages/napcat-common/src/event.ts
Normal file
272
packages/napcat-common/src/event.ts
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import { NodeIQQNTWrapperSession } from '@/napcat-core/wrapper';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { ListenerNamingMapping, ServiceNamingMapping } from '@/napcat-core';
|
||||||
|
|
||||||
|
interface InternalMapKey {
|
||||||
|
timeout: number;
|
||||||
|
createtime: number;
|
||||||
|
func: (...arg: any[]) => any;
|
||||||
|
checker: ((...args: any[]) => boolean) | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EnsureFunc<T> = T extends (...args: any) => any ? T : never;
|
||||||
|
|
||||||
|
type FuncKeys<T> = Extract<
|
||||||
|
{
|
||||||
|
[K in keyof T]: EnsureFunc<T[K]> extends never ? never : K;
|
||||||
|
}[keyof T],
|
||||||
|
string
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type ListenerClassBase = Record<string, string>;
|
||||||
|
|
||||||
|
export class NTEventWrapper {
|
||||||
|
private readonly WrapperSession: NodeIQQNTWrapperSession | undefined; // WrapperSession
|
||||||
|
private readonly listenerManager: Map<string, ListenerClassBase> = new Map<string, ListenerClassBase>(); // ListenerName-Unique -> Listener实例
|
||||||
|
private readonly EventTask = new Map<string, Map<string, Map<string, InternalMapKey>>>(); // tasks ListenerMainName -> ListenerSubName-> uuid -> {timeout,createtime,func}
|
||||||
|
|
||||||
|
constructor (
|
||||||
|
wrapperSession: NodeIQQNTWrapperSession
|
||||||
|
) {
|
||||||
|
this.WrapperSession = wrapperSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
createProxyDispatch (ListenerMainName: string) {
|
||||||
|
const dispatcherListenerFunc = this.dispatcherListener.bind(this);
|
||||||
|
return new Proxy(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
get (target: any, prop: any, receiver: any) {
|
||||||
|
if (typeof target[prop] === 'undefined') {
|
||||||
|
// 如果方法不存在,返回一个函数,这个函数调用existentMethod
|
||||||
|
return (...args: any[]) => {
|
||||||
|
dispatcherListenerFunc(ListenerMainName, prop, ...args).then();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 如果方法存在,正常返回
|
||||||
|
return Reflect.get(target, prop, receiver);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
createEventFunction<
|
||||||
|
Service extends keyof ServiceNamingMapping,
|
||||||
|
ServiceMethod extends FuncKeys<ServiceNamingMapping[Service]>,
|
||||||
|
T extends (...args: any) => any = EnsureFunc<ServiceNamingMapping[Service][ServiceMethod]>
|
||||||
|
> (eventName: `${Service}/${ServiceMethod}`): T | undefined {
|
||||||
|
const eventNameArr = eventName.split('/');
|
||||||
|
type eventType = {
|
||||||
|
[key: string]: () => { [key: string]: (...params: Parameters<T>) => Promise<ReturnType<T>>; };
|
||||||
|
};
|
||||||
|
if (eventNameArr.length > 1) {
|
||||||
|
const serviceName = 'get' + (eventNameArr[0]?.replace('NodeIKernel', '') ?? '');
|
||||||
|
const eventName = eventNameArr[1];
|
||||||
|
const services = (this.WrapperSession as unknown as eventType)[serviceName]?.();
|
||||||
|
if (!services || !eventName) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
let event = services[eventName];
|
||||||
|
|
||||||
|
// 重新绑定this
|
||||||
|
event = event?.bind(services);
|
||||||
|
if (event) {
|
||||||
|
return event as T;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
createListenerFunction<T> (listenerMainName: string, uniqueCode: string = ''): T {
|
||||||
|
const existListener = this.listenerManager.get(listenerMainName + uniqueCode);
|
||||||
|
if (!existListener) {
|
||||||
|
const Listener = this.createProxyDispatch(listenerMainName);
|
||||||
|
const ServiceSubName = /^NodeIKernel(.*?)Listener$/.exec(listenerMainName)![1];
|
||||||
|
const Service = `NodeIKernel${ServiceSubName}Service/addKernel${ServiceSubName}Listener`;
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
this.createEventFunction(Service)(Listener as T);
|
||||||
|
this.listenerManager.set(listenerMainName + uniqueCode, Listener);
|
||||||
|
return Listener as T;
|
||||||
|
}
|
||||||
|
return existListener as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一回调清理事件
|
||||||
|
async dispatcherListener (ListenerMainName: string, ListenerSubName: string, ...args: any[]) {
|
||||||
|
this.EventTask.get(ListenerMainName)
|
||||||
|
?.get(ListenerSubName)
|
||||||
|
?.forEach((task, uuid) => {
|
||||||
|
if (task.createtime + task.timeout < Date.now()) {
|
||||||
|
this.EventTask.get(ListenerMainName)?.get(ListenerSubName)?.delete(uuid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (task?.checker?.(...args)) {
|
||||||
|
task.func(...args);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async callNoListenerEvent<
|
||||||
|
Service extends keyof ServiceNamingMapping,
|
||||||
|
ServiceMethod extends FuncKeys<ServiceNamingMapping[Service]>,
|
||||||
|
EventType extends (...args: any) => any = EnsureFunc<ServiceNamingMapping[Service][ServiceMethod]>
|
||||||
|
> (
|
||||||
|
serviceAndMethod: `${Service}/${ServiceMethod}`,
|
||||||
|
...args: Parameters<EventType>
|
||||||
|
): Promise<Awaited<ReturnType<EventType>>> {
|
||||||
|
return (this.createEventFunction(serviceAndMethod))!(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
async registerListen<
|
||||||
|
Listener extends keyof ListenerNamingMapping,
|
||||||
|
ListenerMethod extends FuncKeys<ListenerNamingMapping[Listener]>,
|
||||||
|
ListenerType extends (...args: any) => any = EnsureFunc<ListenerNamingMapping[Listener][ListenerMethod]>
|
||||||
|
> (
|
||||||
|
listenerAndMethod: `${Listener}/${ListenerMethod}`,
|
||||||
|
checker: (...args: Parameters<ListenerType>) => boolean,
|
||||||
|
waitTimes = 1,
|
||||||
|
timeout = 5000
|
||||||
|
) {
|
||||||
|
return new Promise<Parameters<ListenerType>>((resolve, reject) => {
|
||||||
|
const ListenerNameList = listenerAndMethod.split('/');
|
||||||
|
const ListenerMainName = ListenerNameList[0] ?? '';
|
||||||
|
const ListenerSubName = ListenerNameList[1] ?? '';
|
||||||
|
const id = randomUUID();
|
||||||
|
let complete = 0;
|
||||||
|
let retData: Parameters<ListenerType> | undefined;
|
||||||
|
|
||||||
|
function sendDataCallback () {
|
||||||
|
if (complete === 0) {
|
||||||
|
reject(new Error(' ListenerName:' + listenerAndMethod + ' timeout'));
|
||||||
|
} else {
|
||||||
|
resolve(retData!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutRef = setTimeout(sendDataCallback, timeout);
|
||||||
|
const eventCallback = {
|
||||||
|
timeout,
|
||||||
|
createtime: Date.now(),
|
||||||
|
checker,
|
||||||
|
func: (...args: Parameters<ListenerType>) => {
|
||||||
|
complete++;
|
||||||
|
retData = args;
|
||||||
|
if (complete >= waitTimes) {
|
||||||
|
clearTimeout(timeoutRef);
|
||||||
|
sendDataCallback();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (!this.EventTask.get(ListenerMainName)) {
|
||||||
|
this.EventTask.set(ListenerMainName, new Map());
|
||||||
|
}
|
||||||
|
if (!this.EventTask.get(ListenerMainName)?.get(ListenerSubName)) {
|
||||||
|
this.EventTask.get(ListenerMainName)?.set(ListenerSubName, new Map());
|
||||||
|
}
|
||||||
|
this.EventTask.get(ListenerMainName)?.get(ListenerSubName)?.set(id, eventCallback);
|
||||||
|
this.createListenerFunction(ListenerMainName);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async callNormalEventV2<
|
||||||
|
Service extends keyof ServiceNamingMapping,
|
||||||
|
ServiceMethod extends FuncKeys<ServiceNamingMapping[Service]>,
|
||||||
|
Listener extends keyof ListenerNamingMapping,
|
||||||
|
ListenerMethod extends FuncKeys<ListenerNamingMapping[Listener]>,
|
||||||
|
EventType extends (...args: any) => any = EnsureFunc<ServiceNamingMapping[Service][ServiceMethod]>,
|
||||||
|
ListenerType extends (...args: any) => any = EnsureFunc<ListenerNamingMapping[Listener][ListenerMethod]>
|
||||||
|
> (
|
||||||
|
serviceAndMethod: `${Service}/${ServiceMethod}`,
|
||||||
|
listenerAndMethod: `${Listener}/${ListenerMethod}`,
|
||||||
|
args: Parameters<EventType>,
|
||||||
|
checkerEvent: (ret: Awaited<ReturnType<EventType>>) => boolean = () => true,
|
||||||
|
checkerListener: (...args: Parameters<ListenerType>) => boolean = () => true,
|
||||||
|
callbackTimesToWait = 1,
|
||||||
|
timeout = 5000
|
||||||
|
) {
|
||||||
|
const id = randomUUID();
|
||||||
|
let complete = 0;
|
||||||
|
let retData: Parameters<ListenerType> | undefined;
|
||||||
|
let retEvent: any = {};
|
||||||
|
|
||||||
|
function sendDataCallback (resolve: any, reject: any) {
|
||||||
|
if (complete === 0) {
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
'Timeout: NTEvent serviceAndMethod:' +
|
||||||
|
serviceAndMethod +
|
||||||
|
' ListenerName:' +
|
||||||
|
listenerAndMethod +
|
||||||
|
' EventRet:\n' +
|
||||||
|
JSON.stringify(retEvent, null, 4) +
|
||||||
|
'\n'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
resolve([retEvent as Awaited<ReturnType<EventType>>, ...retData!]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListenerNameList = listenerAndMethod.split('/');
|
||||||
|
const ListenerMainName = ListenerNameList[0] ?? '';
|
||||||
|
const ListenerSubName = ListenerNameList[1] ?? '';
|
||||||
|
|
||||||
|
return new Promise<[EventRet: Awaited<ReturnType<EventType>>, ...Parameters<ListenerType>]>(
|
||||||
|
(resolve, reject) => {
|
||||||
|
const timeoutRef = setTimeout(() => sendDataCallback(resolve, reject), timeout);
|
||||||
|
|
||||||
|
const eventCallback = {
|
||||||
|
timeout,
|
||||||
|
createtime: Date.now(),
|
||||||
|
checker: checkerListener,
|
||||||
|
func: (...args: any[]) => {
|
||||||
|
complete++;
|
||||||
|
retData = args as Parameters<ListenerType>;
|
||||||
|
if (complete >= callbackTimesToWait) {
|
||||||
|
clearTimeout(timeoutRef);
|
||||||
|
sendDataCallback(resolve, reject);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (!this.EventTask.get(ListenerMainName)) {
|
||||||
|
this.EventTask.set(ListenerMainName, new Map());
|
||||||
|
}
|
||||||
|
if (!this.EventTask.get(ListenerMainName)?.get(ListenerSubName)) {
|
||||||
|
this.EventTask.get(ListenerMainName)?.set(ListenerSubName, new Map());
|
||||||
|
}
|
||||||
|
this.EventTask.get(ListenerMainName)?.get(ListenerSubName)?.set(id, eventCallback);
|
||||||
|
this.createListenerFunction(ListenerMainName);
|
||||||
|
|
||||||
|
const eventResult = this.createEventFunction(serviceAndMethod)!(...(args));
|
||||||
|
|
||||||
|
const eventRetHandle = (eventData: any) => {
|
||||||
|
retEvent = eventData;
|
||||||
|
if (!checkerEvent(retEvent) && timeoutRef.hasRef()) {
|
||||||
|
clearTimeout(timeoutRef);
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
'EventChecker Failed: NTEvent serviceAndMethod:' +
|
||||||
|
serviceAndMethod +
|
||||||
|
' ListenerName:' +
|
||||||
|
listenerAndMethod +
|
||||||
|
' EventRet:\n' +
|
||||||
|
JSON.stringify(retEvent, null, 4) +
|
||||||
|
'\n'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (eventResult instanceof Promise) {
|
||||||
|
eventResult.then((eventResult: any) => {
|
||||||
|
eventRetHandle(eventResult);
|
||||||
|
})
|
||||||
|
.catch(reject);
|
||||||
|
} else {
|
||||||
|
eventRetHandle(eventResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
packages/napcat-common/src/fall-back.ts
Normal file
42
packages/napcat-common/src/fall-back.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
type Handler<T> = () => T | Promise<T>;
|
||||||
|
type Checker<T> = (result: T) => T | Promise<T>;
|
||||||
|
|
||||||
|
export class Fallback<T> {
|
||||||
|
private handlers: Handler<T>[] = [];
|
||||||
|
private checker: Checker<T>;
|
||||||
|
|
||||||
|
constructor (checker?: Checker<T>) {
|
||||||
|
this.checker = checker || (async (result: T) => result);
|
||||||
|
}
|
||||||
|
|
||||||
|
add (handler: Handler<T>): this {
|
||||||
|
this.handlers.push(handler);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行处理程序链
|
||||||
|
async run (): Promise<T> {
|
||||||
|
const errors: Error[] = [];
|
||||||
|
for (const handler of this.handlers) {
|
||||||
|
try {
|
||||||
|
const result = await handler();
|
||||||
|
const data = await this.checker(result);
|
||||||
|
if (data) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new AggregateError(errors, 'All handlers failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class FallbackUtil {
|
||||||
|
static boolchecker<T>(value: T, condition: boolean): T {
|
||||||
|
if (condition) {
|
||||||
|
return value;
|
||||||
|
} else {
|
||||||
|
throw new Error('Condition is false, throwing error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
130
packages/napcat-common/src/ffmpeg-adapter-factory.ts
Normal file
130
packages/napcat-common/src/ffmpeg-adapter-factory.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* FFmpeg Adapter Factory
|
||||||
|
* 自动检测并选择最佳的 FFmpeg 适配器
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { LogWrapper } from './log';
|
||||||
|
import { FFmpegAddonAdapter } from './ffmpeg-addon-adapter';
|
||||||
|
import { FFmpegExecAdapter } from './ffmpeg-exec-adapter';
|
||||||
|
import type { IFFmpegAdapter } from './ffmpeg-adapter-interface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FFmpeg 适配器工厂
|
||||||
|
*/
|
||||||
|
export class FFmpegAdapterFactory {
|
||||||
|
private static instance: IFFmpegAdapter | null = null;
|
||||||
|
private static initPromise: Promise<IFFmpegAdapter> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化并获取最佳的 FFmpeg 适配器
|
||||||
|
* @param logger 日志记录器
|
||||||
|
* @param ffmpegPath FFmpeg 可执行文件路径(用于 Exec 适配器)
|
||||||
|
* @param ffprobePath FFprobe 可执行文件路径(用于 Exec 适配器)
|
||||||
|
* @param binaryPath 二进制文件路径(来自 pathWrapper.binaryPath,用于 Addon 适配器)
|
||||||
|
*/
|
||||||
|
static async getAdapter (
|
||||||
|
logger: LogWrapper,
|
||||||
|
ffmpegPath: string = 'ffmpeg',
|
||||||
|
ffprobePath: string = 'ffprobe',
|
||||||
|
binaryPath?: string
|
||||||
|
): Promise<IFFmpegAdapter> {
|
||||||
|
// 如果已经初始化,直接返回
|
||||||
|
if (this.instance) {
|
||||||
|
return this.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果正在初始化,等待初始化完成
|
||||||
|
if (this.initPromise) {
|
||||||
|
return this.initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始初始化
|
||||||
|
this.initPromise = this.initialize(logger, ffmpegPath, ffprobePath, binaryPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.instance = await this.initPromise;
|
||||||
|
return this.instance;
|
||||||
|
} finally {
|
||||||
|
this.initPromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化适配器
|
||||||
|
*/
|
||||||
|
private static async initialize (
|
||||||
|
logger: LogWrapper,
|
||||||
|
ffmpegPath: string,
|
||||||
|
ffprobePath: string,
|
||||||
|
binaryPath?: string
|
||||||
|
): Promise<IFFmpegAdapter> {
|
||||||
|
// 1. 优先尝试使用 Native Addon
|
||||||
|
if (binaryPath) {
|
||||||
|
const addonAdapter = new FFmpegAddonAdapter(binaryPath);
|
||||||
|
|
||||||
|
logger.log('[FFmpeg] 检查 Native Addon 可用性...');
|
||||||
|
if (await addonAdapter.isAvailable()) {
|
||||||
|
logger.log('[FFmpeg] ✓ 使用 Native Addon 适配器');
|
||||||
|
return addonAdapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log('[FFmpeg] Native Addon 不可用,尝试使用命令行工具');
|
||||||
|
} else {
|
||||||
|
logger.log('[FFmpeg] 未提供 binaryPath,跳过 Native Addon 检测');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 降级到 execFile 实现
|
||||||
|
const execAdapter = new FFmpegExecAdapter(ffmpegPath, ffprobePath, binaryPath, logger);
|
||||||
|
|
||||||
|
logger.log(`[FFmpeg] 检查命令行工具可用性: ${ffmpegPath}`);
|
||||||
|
if (await execAdapter.isAvailable()) {
|
||||||
|
logger.log('[FFmpeg] 使用命令行工具适配器 ✓');
|
||||||
|
return execAdapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 都不可用,返回 execAdapter 但会在使用时报错
|
||||||
|
logger.logError('[FFmpeg] 警告: FFmpeg 不可用,将使用命令行适配器但可能失败');
|
||||||
|
return execAdapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置适配器(用于测试或重新初始化)
|
||||||
|
*/
|
||||||
|
static reset (): void {
|
||||||
|
this.instance = null;
|
||||||
|
this.initPromise = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新 FFmpeg 路径并重新初始化
|
||||||
|
* @param logger 日志记录器
|
||||||
|
* @param ffmpegPath FFmpeg 可执行文件路径
|
||||||
|
* @param ffprobePath FFprobe 可执行文件路径
|
||||||
|
*/
|
||||||
|
static async updateFFmpegPath (
|
||||||
|
logger: LogWrapper,
|
||||||
|
ffmpegPath: string,
|
||||||
|
ffprobePath: string
|
||||||
|
): Promise<void> {
|
||||||
|
// 如果当前使用的是 Exec 适配器,更新路径
|
||||||
|
if (this.instance && this.instance instanceof FFmpegExecAdapter) {
|
||||||
|
logger.log(`[FFmpeg] 更新 FFmpeg 路径: ${ffmpegPath}`);
|
||||||
|
this.instance.setFFmpegPath(ffmpegPath);
|
||||||
|
this.instance.setFFprobePath(ffprobePath);
|
||||||
|
|
||||||
|
// 验证新路径是否可用
|
||||||
|
if (await this.instance.isAvailable()) {
|
||||||
|
logger.log('[FFmpeg] 新路径验证成功 ✓');
|
||||||
|
} else {
|
||||||
|
logger.logError('[FFmpeg] 警告: 新 FFmpeg 路径不可用');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前适配器(不初始化)
|
||||||
|
*/
|
||||||
|
static getCurrentAdapter (): IFFmpegAdapter | null {
|
||||||
|
return this.instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
68
packages/napcat-common/src/ffmpeg-adapter-interface.ts
Normal file
68
packages/napcat-common/src/ffmpeg-adapter-interface.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* FFmpeg Adapter Interface
|
||||||
|
* 定义统一的 FFmpeg 操作接口,支持多种实现方式
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频信息结果
|
||||||
|
*/
|
||||||
|
export interface VideoInfoResult {
|
||||||
|
/** 视频宽度(像素) */
|
||||||
|
width: number;
|
||||||
|
/** 视频高度(像素) */
|
||||||
|
height: number;
|
||||||
|
/** 视频时长(秒) */
|
||||||
|
duration: number;
|
||||||
|
/** 容器格式 */
|
||||||
|
format: string;
|
||||||
|
/** 缩略图 Buffer */
|
||||||
|
thumbnail?: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FFmpeg 适配器接口
|
||||||
|
*/
|
||||||
|
export interface IFFmpegAdapter {
|
||||||
|
/** 适配器名称 */
|
||||||
|
readonly name: string;
|
||||||
|
|
||||||
|
/** 是否可用 */
|
||||||
|
isAvailable(): Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取视频信息(包含缩略图)
|
||||||
|
* @param videoPath 视频文件路径
|
||||||
|
* @returns 视频信息
|
||||||
|
*/
|
||||||
|
getVideoInfo(videoPath: string): Promise<VideoInfoResult>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取音视频文件时长
|
||||||
|
* @param filePath 文件路径
|
||||||
|
* @returns 时长(秒)
|
||||||
|
*/
|
||||||
|
getDuration(filePath: string): Promise<number>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换音频为 PCM 格式
|
||||||
|
* @param filePath 输入文件路径
|
||||||
|
* @param pcmPath 输出 PCM 文件路径
|
||||||
|
* @returns PCM 数据 Buffer
|
||||||
|
*/
|
||||||
|
convertToPCM(filePath: string, pcmPath: string): Promise<{ result: boolean, sampleRate: number }>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换音频文件
|
||||||
|
* @param inputFile 输入文件路径
|
||||||
|
* @param outputFile 输出文件路径
|
||||||
|
* @param format 目标格式 ('amr' | 'silk' 等)
|
||||||
|
*/
|
||||||
|
convertFile(inputFile: string, outputFile: string, format: string): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取视频缩略图
|
||||||
|
* @param videoPath 视频文件路径
|
||||||
|
* @param thumbnailPath 缩略图输出路径
|
||||||
|
*/
|
||||||
|
extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void>;
|
||||||
|
}
|
||||||
119
packages/napcat-common/src/ffmpeg-addon-adapter.ts
Normal file
119
packages/napcat-common/src/ffmpeg-addon-adapter.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* FFmpeg Native Addon Adapter
|
||||||
|
* 使用原生 Node.js Addon 实现的 FFmpeg 适配器
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { platform, arch } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { writeFile } from 'node:fs/promises';
|
||||||
|
import type { FFmpeg } from './ffmpeg-addon';
|
||||||
|
import type { IFFmpegAdapter, VideoInfoResult } from './ffmpeg-adapter-interface';
|
||||||
|
import { dlopen } from 'node:process';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Native Addon 路径
|
||||||
|
* @param binaryPath 二进制文件路径(来自 pathWrapper.binaryPath)
|
||||||
|
*/
|
||||||
|
function getAddonPath (binaryPath: string): string {
|
||||||
|
const platformName = platform();
|
||||||
|
const archName = arch();
|
||||||
|
|
||||||
|
const addonFileName: string = process.platform + '.' + process.arch;
|
||||||
|
const addonPath = path.join(binaryPath, './native/ffmpeg/', `ffmpegAddon.${addonFileName}.node`);
|
||||||
|
if (!existsSync(addonPath)) {
|
||||||
|
throw new Error(`Unsupported platform: ${platformName} ${archName} - Addon not found at ${addonPath}`);
|
||||||
|
}
|
||||||
|
return addonPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FFmpeg Native Addon 适配器实现
|
||||||
|
*/
|
||||||
|
export class FFmpegAddonAdapter implements IFFmpegAdapter {
|
||||||
|
public readonly name = 'FFmpegAddon';
|
||||||
|
private addon: FFmpeg | null = null;
|
||||||
|
private binaryPath: string;
|
||||||
|
|
||||||
|
constructor (binaryPath: string) {
|
||||||
|
this.binaryPath = binaryPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查 Addon 是否可用
|
||||||
|
*/
|
||||||
|
async isAvailable (): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const temp_addon = { exports: {} };
|
||||||
|
dlopen(temp_addon, getAddonPath(this.binaryPath));
|
||||||
|
this.addon = temp_addon.exports as FFmpeg;
|
||||||
|
return this.addon !== null;
|
||||||
|
} catch (error) {
|
||||||
|
console.log('[FFmpegAddonAdapter] Failed to load addon:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureAddon (): FFmpeg {
|
||||||
|
if (!this.addon) {
|
||||||
|
throw new Error('FFmpeg Addon is not available');
|
||||||
|
}
|
||||||
|
return this.addon;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取视频信息
|
||||||
|
*/
|
||||||
|
async getVideoInfo (videoPath: string): Promise<VideoInfoResult> {
|
||||||
|
const addon = this.ensureAddon();
|
||||||
|
const info = await addon.getVideoInfo(videoPath);
|
||||||
|
|
||||||
|
let format = info.format.includes(',') ? info.format.split(',')[0] ?? info.format : info.format;
|
||||||
|
console.log('[FFmpegAddonAdapter] Detected format:', format);
|
||||||
|
return {
|
||||||
|
width: info.width,
|
||||||
|
height: info.height,
|
||||||
|
duration: info.duration,
|
||||||
|
format: format,
|
||||||
|
thumbnail: info.image,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取时长
|
||||||
|
*/
|
||||||
|
async getDuration (filePath: string): Promise<number> {
|
||||||
|
const addon = this.ensureAddon();
|
||||||
|
return addon.getDuration(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换为 PCM
|
||||||
|
*/
|
||||||
|
async convertToPCM (filePath: string, pcmPath: string): Promise<{ result: boolean, sampleRate: number; }> {
|
||||||
|
const addon = this.ensureAddon();
|
||||||
|
const result = await addon.decodeAudioToPCM(filePath, pcmPath, 24000);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换文件
|
||||||
|
*/
|
||||||
|
async convertFile (inputFile: string, outputFile: string, format: string): Promise<void> {
|
||||||
|
const addon = this.ensureAddon();
|
||||||
|
console.log('[FFmpegAddonAdapter] Converting file:', inputFile, 'to', outputFile, 'as', format);
|
||||||
|
await addon.decodeAudioToFmt(inputFile, outputFile, format);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取缩略图
|
||||||
|
*/
|
||||||
|
async extractThumbnail (videoPath: string, thumbnailPath: string): Promise<void> {
|
||||||
|
const addon = this.ensureAddon();
|
||||||
|
const info = await addon.getVideoInfo(videoPath);
|
||||||
|
|
||||||
|
// 将缩略图写入文件
|
||||||
|
await writeFile(thumbnailPath, info.image);
|
||||||
|
}
|
||||||
|
}
|
||||||
73
packages/napcat-common/src/ffmpeg-addon.ts
Normal file
73
packages/napcat-common/src/ffmpeg-addon.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* FFmpeg Node.js Native Addon Type Definitions
|
||||||
|
*
|
||||||
|
* This addon provides FFmpeg functionality for Node.js including:
|
||||||
|
* - Video information extraction with thumbnail generation
|
||||||
|
* - Audio/Video duration detection
|
||||||
|
* - Audio format conversion to NTSILK
|
||||||
|
* - Audio decoding to PCM
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Video information result object
|
||||||
|
*/
|
||||||
|
export interface VideoInfo {
|
||||||
|
/** Video width in pixels */
|
||||||
|
width: number;
|
||||||
|
|
||||||
|
/** Video height in pixels */
|
||||||
|
height: number;
|
||||||
|
|
||||||
|
/** Video duration in seconds */
|
||||||
|
duration: number;
|
||||||
|
|
||||||
|
/** Container format name (e.g., "mp4", "mkv", "avi") */
|
||||||
|
format: string;
|
||||||
|
|
||||||
|
/** Video codec name (e.g., "h264", "hevc", "vp9") */
|
||||||
|
videoCodec: string;
|
||||||
|
|
||||||
|
/** First frame thumbnail as BMP image buffer */
|
||||||
|
image: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audio PCM decoding result object
|
||||||
|
*/
|
||||||
|
export interface AudioPCMResult {
|
||||||
|
/** PCM audio data as 16-bit signed integer samples */
|
||||||
|
pcm: Buffer;
|
||||||
|
|
||||||
|
/** Sample rate in Hz (e.g., 44100, 48000, 24000) */
|
||||||
|
sampleRate: number;
|
||||||
|
|
||||||
|
/** Number of audio channels (1 for mono, 2 for stereo) */
|
||||||
|
channels: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FFmpeg interface providing all audio/video processing methods
|
||||||
|
*/
|
||||||
|
export interface FFmpeg {
|
||||||
|
convertFile (inputFile: string, outputFile: string, format: string): Promise<{ success: boolean; }>;
|
||||||
|
/**
|
||||||
|
* Get video information including resolution, duration, format, codec and first frame thumbnail
|
||||||
|
*/
|
||||||
|
getVideoInfo (filePath: string, format?: 'bmp' | 'bmp24'): Promise<VideoInfo>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get duration of audio or video file in seconds
|
||||||
|
*/
|
||||||
|
getDuration (filePath: string): Promise<number>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert audio file to NTSILK format (WeChat voice message format)
|
||||||
|
*/
|
||||||
|
convertToNTSilkTct (inputPath: string, outputPath: string): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode audio file to raw PCM data
|
||||||
|
*/
|
||||||
|
decodeAudioToPCM (filePath: string, pcmPath: string, sampleRate?: number): Promise<{ result: boolean, sampleRate: number; }>;
|
||||||
|
decodeAudioToFmt (filePath: string, pcmPath: string, format: string): Promise<{ channels: number; sampleRate: number; format: string; }>;
|
||||||
|
}
|
||||||
244
packages/napcat-common/src/ffmpeg-exec-adapter.ts
Normal file
244
packages/napcat-common/src/ffmpeg-exec-adapter.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
/**
|
||||||
|
* FFmpeg Exec Adapter
|
||||||
|
* 使用 execFile 调用 FFmpeg 命令行工具的适配器实现
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, existsSync, mkdirSync } from 'fs';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
import { execFile } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import { fileTypeFromFile } from 'file-type';
|
||||||
|
import { imageSizeFallBack } from 'napcat-image-size/src/index';
|
||||||
|
import { downloadFFmpegIfNotExists } from './download-ffmpeg';
|
||||||
|
import { LogWrapper } from './log';
|
||||||
|
import type { IFFmpegAdapter, VideoInfoResult } from './ffmpeg-adapter-interface';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保目录存在
|
||||||
|
*/
|
||||||
|
function ensureDirExists (filePath: string): void {
|
||||||
|
const dir = dirname(filePath);
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FFmpeg 命令行适配器实现
|
||||||
|
*/
|
||||||
|
export class FFmpegExecAdapter implements IFFmpegAdapter {
|
||||||
|
public readonly name = 'FFmpegExec';
|
||||||
|
private downloadAttempted = false;
|
||||||
|
|
||||||
|
constructor (
|
||||||
|
private ffmpegPath: string = 'ffmpeg',
|
||||||
|
private ffprobePath: string = 'ffprobe',
|
||||||
|
private binaryPath?: string,
|
||||||
|
private logger?: LogWrapper
|
||||||
|
) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查 FFmpeg 是否可用,如果不可用则尝试下载
|
||||||
|
*/
|
||||||
|
async isAvailable (): Promise<boolean> {
|
||||||
|
// 首先检查当前路径
|
||||||
|
try {
|
||||||
|
await execFileAsync(this.ffmpegPath, ['-version']);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// 如果失败且未尝试下载,尝试下载
|
||||||
|
if (!this.downloadAttempted && this.binaryPath && this.logger) {
|
||||||
|
this.downloadAttempted = true;
|
||||||
|
|
||||||
|
if (process.env['NAPCAT_DISABLE_FFMPEG_DOWNLOAD']) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log('[FFmpeg] 未找到可用的 FFmpeg,尝试自动下载...');
|
||||||
|
const result = await downloadFFmpegIfNotExists(this.logger);
|
||||||
|
|
||||||
|
if (result.path && result.reset) {
|
||||||
|
// 更新路径
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
this.ffmpegPath = join(result.path, 'ffmpeg.exe');
|
||||||
|
this.ffprobePath = join(result.path, 'ffprobe.exe');
|
||||||
|
this.logger.log('[FFmpeg] 已更新路径:', this.ffmpegPath);
|
||||||
|
|
||||||
|
// 再次检查
|
||||||
|
try {
|
||||||
|
await execFileAsync(this.ffmpegPath, ['-version']);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置 FFmpeg 路径
|
||||||
|
*/
|
||||||
|
setFFmpegPath (ffmpegPath: string): void {
|
||||||
|
this.ffmpegPath = ffmpegPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置 FFprobe 路径
|
||||||
|
*/
|
||||||
|
setFFprobePath (ffprobePath: string): void {
|
||||||
|
this.ffprobePath = ffprobePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取视频信息
|
||||||
|
*/
|
||||||
|
async getVideoInfo (videoPath: string): Promise<VideoInfoResult> {
|
||||||
|
// 获取文件大小和类型
|
||||||
|
const [fileType, duration] = await Promise.all([
|
||||||
|
fileTypeFromFile(videoPath).catch(() => null),
|
||||||
|
this.getDuration(videoPath),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 创建临时缩略图路径
|
||||||
|
const thumbnailPath = `${videoPath}.thumbnail.bmp`;
|
||||||
|
let width = 100;
|
||||||
|
let height = 100;
|
||||||
|
let thumbnail: Buffer | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.extractThumbnail(videoPath, thumbnailPath);
|
||||||
|
|
||||||
|
// 获取图片尺寸
|
||||||
|
const dimensions = await imageSizeFallBack(thumbnailPath);
|
||||||
|
width = dimensions.width ?? 100;
|
||||||
|
height = dimensions.height ?? 100;
|
||||||
|
|
||||||
|
// 读取缩略图
|
||||||
|
if (existsSync(thumbnailPath)) {
|
||||||
|
thumbnail = readFileSync(thumbnailPath);
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// 使用默认值
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
duration,
|
||||||
|
format: fileType?.ext ?? 'mp4',
|
||||||
|
thumbnail,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取时长
|
||||||
|
*/
|
||||||
|
async getDuration (filePath: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync(this.ffprobePath, [
|
||||||
|
'-v', 'error',
|
||||||
|
'-show_entries', 'format=duration',
|
||||||
|
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||||
|
filePath,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const duration = parseFloat(stdout.trim());
|
||||||
|
return isNaN(duration) ? 60 : duration;
|
||||||
|
} catch {
|
||||||
|
return 60; // 默认时长
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换为 PCM
|
||||||
|
*/
|
||||||
|
async convertToPCM (filePath: string, pcmPath: string): Promise<{ result: boolean, sampleRate: number; }> {
|
||||||
|
try {
|
||||||
|
ensureDirExists(pcmPath);
|
||||||
|
|
||||||
|
await execFileAsync(this.ffmpegPath, [
|
||||||
|
'-y',
|
||||||
|
'-i', filePath,
|
||||||
|
'-ar', '24000',
|
||||||
|
'-ac', '1',
|
||||||
|
'-f', 's16le',
|
||||||
|
pcmPath,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!existsSync(pcmPath)) {
|
||||||
|
throw new Error('转换PCM失败,输出文件不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { result: true, sampleRate: 24000 };
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(`FFmpeg处理转换出错: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换文件
|
||||||
|
*/
|
||||||
|
async convertFile (inputFile: string, outputFile: string, format: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
ensureDirExists(outputFile);
|
||||||
|
|
||||||
|
const params = format === 'amr'
|
||||||
|
? [
|
||||||
|
'-f', 's16le',
|
||||||
|
'-ar', '24000',
|
||||||
|
'-ac', '1',
|
||||||
|
'-i', inputFile,
|
||||||
|
'-ar', '8000',
|
||||||
|
'-b:a', '12.2k',
|
||||||
|
'-y',
|
||||||
|
outputFile,
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
'-f', 's16le',
|
||||||
|
'-ar', '24000',
|
||||||
|
'-ac', '1',
|
||||||
|
'-i', inputFile,
|
||||||
|
'-y',
|
||||||
|
outputFile,
|
||||||
|
];
|
||||||
|
|
||||||
|
await execFileAsync(this.ffmpegPath, params);
|
||||||
|
|
||||||
|
if (!existsSync(outputFile)) {
|
||||||
|
throw new Error('转换失败,输出文件不存在');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error converting file:', error);
|
||||||
|
throw new Error(`文件转换失败: ${(error as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取缩略图
|
||||||
|
*/
|
||||||
|
async extractThumbnail (videoPath: string, thumbnailPath: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
ensureDirExists(thumbnailPath);
|
||||||
|
|
||||||
|
const { stderr } = await execFileAsync(this.ffmpegPath, [
|
||||||
|
'-i', videoPath,
|
||||||
|
'-ss', '00:00:01.000',
|
||||||
|
'-vframes', '1',
|
||||||
|
'-y', // 覆盖输出文件
|
||||||
|
thumbnailPath,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!existsSync(thumbnailPath)) {
|
||||||
|
throw new Error(`提取缩略图失败,输出文件不存在: ${stderr}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error extracting thumbnail:', error);
|
||||||
|
throw new Error(`提取缩略图失败: ${(error as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
152
packages/napcat-common/src/ffmpeg.ts
Normal file
152
packages/napcat-common/src/ffmpeg.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { statSync, existsSync, writeFileSync } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import type { VideoInfo } from './video';
|
||||||
|
import { fileTypeFromFile } from 'file-type';
|
||||||
|
import { platform } from 'node:os';
|
||||||
|
import { LogWrapper } from './log';
|
||||||
|
import { FFmpegAdapterFactory } from './ffmpeg-adapter-factory';
|
||||||
|
import type { IFFmpegAdapter } from './ffmpeg-adapter-interface';
|
||||||
|
|
||||||
|
const getFFmpegPath = (tool: string, binaryPath?: string): string => {
|
||||||
|
if (process.platform === 'win32' && binaryPath) {
|
||||||
|
const exeName = `${tool}.exe`;
|
||||||
|
const localPath = path.join(binaryPath, 'ffmpeg', exeName);
|
||||||
|
const isLocalExeExists = existsSync(localPath);
|
||||||
|
return isLocalExeExists ? localPath : exeName;
|
||||||
|
}
|
||||||
|
return tool;
|
||||||
|
};
|
||||||
|
|
||||||
|
export let FFMPEG_CMD = 'ffmpeg';
|
||||||
|
export let FFPROBE_CMD = 'ffprobe';
|
||||||
|
export class FFmpegService {
|
||||||
|
private static adapter: IFFmpegAdapter | null = null;
|
||||||
|
private static initialized = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化 FFmpeg 服务
|
||||||
|
* @param binaryPath 二进制文件路径(来自 pathWrapper.binaryPath)
|
||||||
|
* @param logger 日志记录器
|
||||||
|
*/
|
||||||
|
public static async init (binaryPath: string, logger: LogWrapper): Promise<void> {
|
||||||
|
if (this.initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查本地 ffmpeg 路径
|
||||||
|
FFMPEG_CMD = getFFmpegPath('ffmpeg', binaryPath);
|
||||||
|
FFPROBE_CMD = getFFmpegPath('ffprobe', binaryPath);
|
||||||
|
|
||||||
|
// 立即初始化适配器(会触发自动下载等逻辑)
|
||||||
|
this.adapter = await FFmpegAdapterFactory.getAdapter(
|
||||||
|
logger,
|
||||||
|
FFMPEG_CMD,
|
||||||
|
FFPROBE_CMD,
|
||||||
|
binaryPath
|
||||||
|
);
|
||||||
|
|
||||||
|
this.initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getAdapterName (): string {
|
||||||
|
if (!this.adapter) {
|
||||||
|
throw new Error('FFmpeg service not initialized. Please call FFmpegService.init() first.');
|
||||||
|
}
|
||||||
|
return this.adapter.name;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 FFmpeg 适配器
|
||||||
|
*/
|
||||||
|
private static async getAdapter (): Promise<IFFmpegAdapter> {
|
||||||
|
if (!this.adapter) {
|
||||||
|
throw new Error('FFmpeg service not initialized. Please call FFmpegService.init() first.');
|
||||||
|
}
|
||||||
|
return this.adapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置 FFmpeg 路径并更新适配器
|
||||||
|
* @deprecated 建议使用 init() 方法初始化
|
||||||
|
*/
|
||||||
|
public static async setFfmpegPath (ffmpegPath: string, logger: LogWrapper): Promise<void> {
|
||||||
|
if (platform() === 'win32') {
|
||||||
|
FFMPEG_CMD = path.join(ffmpegPath, 'ffmpeg.exe');
|
||||||
|
FFPROBE_CMD = path.join(ffmpegPath, 'ffprobe.exe');
|
||||||
|
logger.log('[Check] ffmpeg:', FFMPEG_CMD);
|
||||||
|
logger.log('[Check] ffprobe:', FFPROBE_CMD);
|
||||||
|
|
||||||
|
// 更新适配器路径
|
||||||
|
await FFmpegAdapterFactory.updateFFmpegPath(logger, FFMPEG_CMD, FFPROBE_CMD);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取视频缩略图
|
||||||
|
*/
|
||||||
|
public static async extractThumbnail (videoPath: string, thumbnailPath: string): Promise<void> {
|
||||||
|
const adapter = await this.getAdapter();
|
||||||
|
await adapter.extractThumbnail(videoPath, thumbnailPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换音频文件
|
||||||
|
*/
|
||||||
|
public static async convertFile (inputFile: string, outputFile: string, format: string): Promise<void> {
|
||||||
|
const adapter = await this.getAdapter();
|
||||||
|
await adapter.convertFile(inputFile, outputFile, format);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换为 PCM 格式
|
||||||
|
*/
|
||||||
|
public static async convert (filePath: string, pcmPath: string): Promise<{ result: boolean, sampleRate: number; }> {
|
||||||
|
const adapter = await this.getAdapter();
|
||||||
|
return adapter.convertToPCM(filePath, pcmPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取视频信息
|
||||||
|
*/
|
||||||
|
public static async getVideoInfo (videoPath: string, thumbnailPath: string): Promise<VideoInfo> {
|
||||||
|
const adapter = await this.getAdapter();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取文件大小
|
||||||
|
const fileSize = statSync(videoPath).size;
|
||||||
|
|
||||||
|
// 使用适配器获取视频信息
|
||||||
|
const videoInfo = await adapter.getVideoInfo(videoPath);
|
||||||
|
|
||||||
|
// 如果提供了缩略图路径且适配器返回了缩略图,保存到指定路径
|
||||||
|
if (thumbnailPath && videoInfo.thumbnail) {
|
||||||
|
writeFileSync(thumbnailPath, videoInfo.thumbnail);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: VideoInfo = {
|
||||||
|
width: videoInfo.width,
|
||||||
|
height: videoInfo.height,
|
||||||
|
time: videoInfo.duration,
|
||||||
|
format: videoInfo.format,
|
||||||
|
size: fileSize,
|
||||||
|
filePath: videoPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (_error) {
|
||||||
|
// 降级处理:返回默认值
|
||||||
|
const fileType = await fileTypeFromFile(videoPath).catch(() => null);
|
||||||
|
const fileSize = statSync(videoPath).size;
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
time: 60,
|
||||||
|
format: fileType?.ext ?? 'mp4',
|
||||||
|
size: fileSize,
|
||||||
|
filePath: videoPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
121
packages/napcat-common/src/file-uuid.ts
Normal file
121
packages/napcat-common/src/file-uuid.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { Peer } from '@/napcat-core';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
class TimeBasedCache<K, V> {
|
||||||
|
private cache = new Map<K, { value: V, timestamp: number, frequency: number }>();
|
||||||
|
private keyList = new Set<K>();
|
||||||
|
private operationCount = 0;
|
||||||
|
|
||||||
|
constructor (private maxCapacity: number, private ttl: number = 30 * 1000 * 60, private cleanupCount: number = 10) {}
|
||||||
|
|
||||||
|
public put (key: K, value: V): void {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const cacheEntry = { value, timestamp, frequency: 1 };
|
||||||
|
this.cache.set(key, cacheEntry);
|
||||||
|
this.keyList.add(key);
|
||||||
|
this.operationCount++;
|
||||||
|
if (this.keyList.size > this.maxCapacity) this.evict();
|
||||||
|
if (this.operationCount >= this.cleanupCount) this.cleanup(this.cleanupCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get (key: K): V | undefined {
|
||||||
|
const entry = this.cache.get(key);
|
||||||
|
if (entry && Date.now() - entry.timestamp < this.ttl) {
|
||||||
|
entry.timestamp = Date.now();
|
||||||
|
entry.frequency++;
|
||||||
|
this.operationCount++;
|
||||||
|
if (this.operationCount >= this.cleanupCount) this.cleanup(this.cleanupCount);
|
||||||
|
return entry.value;
|
||||||
|
} else {
|
||||||
|
this.deleteKey(key);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanup (count: number): void {
|
||||||
|
const currentTime = Date.now();
|
||||||
|
let cleaned = 0;
|
||||||
|
for (const key of this.keyList) {
|
||||||
|
if (cleaned >= count) break;
|
||||||
|
const entry = this.cache.get(key);
|
||||||
|
if (entry && currentTime - entry.timestamp >= this.ttl) {
|
||||||
|
this.deleteKey(key);
|
||||||
|
cleaned++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.operationCount = 0; // 重置操作计数器
|
||||||
|
}
|
||||||
|
|
||||||
|
private deleteKey (key: K): void {
|
||||||
|
this.cache.delete(key);
|
||||||
|
this.keyList.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private evict (): void {
|
||||||
|
while (this.keyList.size > this.maxCapacity) {
|
||||||
|
let oldestKey: K | undefined;
|
||||||
|
let minFrequency = Infinity;
|
||||||
|
for (const key of this.keyList) {
|
||||||
|
const entry = this.cache.get(key);
|
||||||
|
if (entry && entry.frequency < minFrequency) {
|
||||||
|
minFrequency = entry.frequency;
|
||||||
|
oldestKey = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (oldestKey !== undefined) this.deleteKey(oldestKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileUUIDData {
|
||||||
|
peer: Peer;
|
||||||
|
modelId?: string;
|
||||||
|
fileId?: string;
|
||||||
|
msgId?: string;
|
||||||
|
elementId?: string;
|
||||||
|
fileUUID?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FileUUIDManager {
|
||||||
|
private cache: TimeBasedCache<string, FileUUIDData>;
|
||||||
|
|
||||||
|
constructor (ttl: number) {
|
||||||
|
this.cache = new TimeBasedCache<string, FileUUIDData>(5000, ttl);
|
||||||
|
}
|
||||||
|
|
||||||
|
public encode (data: FileUUIDData, endString: string = '', customUUID?: string): string {
|
||||||
|
const uuid = customUUID || randomUUID().replace(/-/g, '') + endString;
|
||||||
|
this.cache.put(uuid, data);
|
||||||
|
return uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public decode (uuid: string): FileUUIDData | undefined {
|
||||||
|
return this.cache.get(uuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FileNapCatOneBotUUIDWrap {
|
||||||
|
private manager: FileUUIDManager;
|
||||||
|
|
||||||
|
constructor (ttl: number = 86400000) {
|
||||||
|
this.manager = new FileUUIDManager(ttl);
|
||||||
|
}
|
||||||
|
|
||||||
|
public encodeModelId (peer: Peer, modelId: string, fileId: string, fileUUID: string = '', endString: string = '', customUUID?: string): string {
|
||||||
|
return this.manager.encode({ peer, modelId, fileId, fileUUID }, endString, customUUID);
|
||||||
|
}
|
||||||
|
|
||||||
|
public decodeModelId (uuid: string): FileUUIDData | undefined {
|
||||||
|
return this.manager.decode(uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
public encode (peer: Peer, msgId: string, elementId: string, fileUUID: string = '', customUUID?: string): string {
|
||||||
|
return this.manager.encode({ peer, msgId, elementId, fileUUID }, '', customUUID);
|
||||||
|
}
|
||||||
|
|
||||||
|
public decode (uuid: string): FileUUIDData | undefined {
|
||||||
|
return this.manager.decode(uuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileNapCatOneBotUUID = new FileNapCatOneBotUUIDWrap();
|
||||||
209
packages/napcat-common/src/file.ts
Normal file
209
packages/napcat-common/src/file.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import { stat } from 'fs/promises';
|
||||||
|
import crypto, { randomUUID } from 'crypto';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { solveProblem } from '@/napcat-common/helper';
|
||||||
|
|
||||||
|
export interface HttpDownloadOptions {
|
||||||
|
url: string;
|
||||||
|
headers?: Record<string, string> | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Uri2LocalRes = {
|
||||||
|
success: boolean,
|
||||||
|
errMsg: string,
|
||||||
|
fileName: string,
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 定义一个异步函数来检查文件是否存在
|
||||||
|
export function checkFileExist (path: string, timeout: number = 3000): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
function check () {
|
||||||
|
if (fs.existsSync(path)) {
|
||||||
|
resolve();
|
||||||
|
} else if (Date.now() - startTime > timeout) {
|
||||||
|
reject(new Error(`文件不存在: ${path}`));
|
||||||
|
} else {
|
||||||
|
setTimeout(check, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
check();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定义一个异步函数来检查文件是否存在
|
||||||
|
export async function checkFileExistV2 (path: string, timeout: number = 3000): Promise<void> {
|
||||||
|
// 使用 Promise.race 来同时进行文件状态检查和超时计时
|
||||||
|
// Promise.race 会返回第一个解决(resolve)或拒绝(reject)的 Promise
|
||||||
|
await Promise.race([
|
||||||
|
checkFile(path),
|
||||||
|
timeoutPromise(timeout, `文件不存在: ${path}`),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换超时时间至 Promise
|
||||||
|
function timeoutPromise (timeout: number, errorMsg: string): Promise<void> {
|
||||||
|
return new Promise((_resolve, reject) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
reject(new Error(errorMsg));
|
||||||
|
}, timeout);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 异步检查文件是否存在
|
||||||
|
async function checkFile (path: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await stat(path);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if ((error as Error & { code: string; }).code === 'ENOENT') {
|
||||||
|
// 如果文件不存在,则抛出一个错误
|
||||||
|
throw new Error(`文件不存在: ${path}`);
|
||||||
|
} else {
|
||||||
|
// 对于 stat 调用的其他错误,重新抛出
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 如果文件存在,则无需做任何事情,Promise 解决(resolve)自身
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateFileMD5 (filePath: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// 创建一个流式读取器
|
||||||
|
const stream = fs.createReadStream(filePath);
|
||||||
|
const hash = crypto.createHash('md5');
|
||||||
|
|
||||||
|
stream.on('data', (data) => {
|
||||||
|
// 当读取到数据时,更新哈希对象的状态
|
||||||
|
hash.update(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('end', () => {
|
||||||
|
// 文件读取完成,计算哈希
|
||||||
|
const md5 = hash.digest('hex');
|
||||||
|
resolve(md5);
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('error', (err: Error) => {
|
||||||
|
// 处理可能的读取错误
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryDownload (options: string | HttpDownloadOptions, useReferer: boolean = false): Promise<Response> {
|
||||||
|
let url: string;
|
||||||
|
let headers: Record<string, string> = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.71 Safari/537.36',
|
||||||
|
};
|
||||||
|
if (typeof options === 'string') {
|
||||||
|
url = options;
|
||||||
|
headers['Host'] = new URL(url).hostname;
|
||||||
|
} else {
|
||||||
|
url = options.url;
|
||||||
|
if (options.headers) {
|
||||||
|
if (typeof options.headers === 'string') {
|
||||||
|
headers = JSON.parse(options.headers);
|
||||||
|
} else {
|
||||||
|
headers = options.headers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (useReferer && !headers['Referer']) {
|
||||||
|
headers['Referer'] = url;
|
||||||
|
}
|
||||||
|
const fetchRes = await fetch(url, { headers, redirect: 'follow' }).catch((err) => {
|
||||||
|
if (err.cause) {
|
||||||
|
throw err.cause;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
return fetchRes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function httpDownload (options: string | HttpDownloadOptions): Promise<Buffer> {
|
||||||
|
const useReferer = typeof options === 'string';
|
||||||
|
let resp = await tryDownload(options);
|
||||||
|
if (resp.status === 403 && useReferer) {
|
||||||
|
resp = await tryDownload(options, true);
|
||||||
|
}
|
||||||
|
if (!resp.ok) throw new Error(`下载文件失败: ${resp.statusText}`);
|
||||||
|
const blob = await resp.blob();
|
||||||
|
const buffer = await blob.arrayBuffer();
|
||||||
|
return Buffer.from(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum FileUriType {
|
||||||
|
Unknown = 0,
|
||||||
|
Local = 1,
|
||||||
|
Remote = 2,
|
||||||
|
Base64 = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkUriType (Uri: string) {
|
||||||
|
const LocalFileRet = await solveProblem((uri: string) => {
|
||||||
|
if (fs.existsSync(path.normalize(uri))) {
|
||||||
|
return { Uri: path.normalize(uri), Type: FileUriType.Local };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, Uri);
|
||||||
|
if (LocalFileRet) return LocalFileRet;
|
||||||
|
const OtherFileRet = await solveProblem((uri: string) => {
|
||||||
|
// 再判断是否是Http
|
||||||
|
if (uri.startsWith('http:') || uri.startsWith('https:')) {
|
||||||
|
return { Uri: uri, Type: FileUriType.Remote };
|
||||||
|
}
|
||||||
|
// 再判断是否是Base64
|
||||||
|
if (uri.startsWith('base64:')) {
|
||||||
|
return { Uri: uri, Type: FileUriType.Base64 };
|
||||||
|
}
|
||||||
|
// 默认file://
|
||||||
|
if (uri.startsWith('file:')) {
|
||||||
|
const filePath: string = decodeURIComponent(uri.startsWith('file:///') && process.platform === 'win32' ? uri.slice(8) : uri.slice(7));
|
||||||
|
return { Uri: filePath, Type: FileUriType.Local };
|
||||||
|
}
|
||||||
|
if (uri.startsWith('data:')) {
|
||||||
|
const data = uri.split(',')[1];
|
||||||
|
if (data) return { Uri: data, Type: FileUriType.Base64 };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, Uri);
|
||||||
|
if (OtherFileRet) return OtherFileRet;
|
||||||
|
|
||||||
|
return { Uri, Type: FileUriType.Unknown };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uriToLocalFile (dir: string, uri: string, filename: string = randomUUID(), headers?: Record<string, string>): Promise<Uri2LocalRes> {
|
||||||
|
const { Uri: HandledUri, Type: UriType } = await checkUriType(uri);
|
||||||
|
|
||||||
|
const filePath = path.join(dir, filename);
|
||||||
|
|
||||||
|
switch (UriType) {
|
||||||
|
case FileUriType.Local: {
|
||||||
|
const fileExt = path.extname(HandledUri);
|
||||||
|
const localFileName = path.basename(HandledUri, fileExt) + fileExt;
|
||||||
|
const tempFilePath = path.join(dir, filename + fileExt);
|
||||||
|
fs.copyFileSync(HandledUri, tempFilePath);
|
||||||
|
return { success: true, errMsg: '', fileName: localFileName, path: tempFilePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
case FileUriType.Remote: {
|
||||||
|
const buffer = await httpDownload({ url: HandledUri, headers: headers ?? {} });
|
||||||
|
fs.writeFileSync(filePath, buffer);
|
||||||
|
return { success: true, errMsg: '', fileName: filename, path: filePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
case FileUriType.Base64: {
|
||||||
|
const base64 = HandledUri.replace(/^base64:\/\//, '');
|
||||||
|
const base64Buffer = Buffer.from(base64, 'base64');
|
||||||
|
fs.writeFileSync(filePath, base64Buffer);
|
||||||
|
return { success: true, errMsg: '', fileName: filename, path: filePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return { success: false, errMsg: `识别URL失败, uri= ${uri}`, fileName: '', path: '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
116
packages/napcat-common/src/forward-msg-builder.ts
Normal file
116
packages/napcat-common/src/forward-msg-builder.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import * as crypto from 'node:crypto';
|
||||||
|
import { PacketMsg } from '@/napcat-core/packet/message/message';
|
||||||
|
|
||||||
|
interface ForwardMsgJson {
|
||||||
|
app: string
|
||||||
|
config: ForwardMsgJsonConfig,
|
||||||
|
desc: string,
|
||||||
|
extra: ForwardMsgJsonExtra,
|
||||||
|
meta: ForwardMsgJsonMeta,
|
||||||
|
prompt: string,
|
||||||
|
ver: string,
|
||||||
|
view: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ForwardMsgJsonConfig {
|
||||||
|
autosize: number,
|
||||||
|
forward: number,
|
||||||
|
round: number,
|
||||||
|
type: string,
|
||||||
|
width: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ForwardMsgJsonExtra {
|
||||||
|
filename: string,
|
||||||
|
tsum: number,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ForwardMsgJsonMeta {
|
||||||
|
detail: ForwardMsgJsonMetaDetail
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ForwardMsgJsonMetaDetail {
|
||||||
|
news: {
|
||||||
|
text: string
|
||||||
|
}[],
|
||||||
|
resid: string,
|
||||||
|
source: string,
|
||||||
|
summary: string,
|
||||||
|
uniseq: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ForwardAdaptMsg {
|
||||||
|
senderName?: string;
|
||||||
|
isGroupMsg?: boolean;
|
||||||
|
msg?: ForwardAdaptMsgElement[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ForwardAdaptMsgElement {
|
||||||
|
preview?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ForwardMsgBuilder {
|
||||||
|
private static build (resId: string, msg: ForwardAdaptMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string): ForwardMsgJson {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const isGroupMsg = msg.some(m => m.isGroupMsg);
|
||||||
|
if (!source) {
|
||||||
|
source = msg.length === 0 ? '聊天记录' : (isGroupMsg ? '群聊的聊天记录' : msg.map(m => m.senderName).filter((v, i, a) => a.indexOf(v) === i).slice(0, 4).join('和') + '的聊天记录');
|
||||||
|
}
|
||||||
|
if (!news) {
|
||||||
|
news = msg.length === 0
|
||||||
|
? [{
|
||||||
|
text: 'Nya~ This message is send from NapCat.Packet!',
|
||||||
|
}]
|
||||||
|
: msg.map(m => ({
|
||||||
|
text: `${m.senderName}: ${m.msg?.map(msg => msg.preview).join('')}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (!summary) {
|
||||||
|
summary = `查看${msg.length}条转发消息`;
|
||||||
|
}
|
||||||
|
if (!prompt) {
|
||||||
|
prompt = '[聊天记录]';
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
app: 'com.tencent.multimsg',
|
||||||
|
config: {
|
||||||
|
autosize: 1,
|
||||||
|
forward: 1,
|
||||||
|
round: 1,
|
||||||
|
type: 'normal',
|
||||||
|
width: 300,
|
||||||
|
},
|
||||||
|
desc: prompt,
|
||||||
|
extra: {
|
||||||
|
filename: id,
|
||||||
|
tsum: msg.length,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
detail: {
|
||||||
|
news,
|
||||||
|
resid: resId,
|
||||||
|
source,
|
||||||
|
summary,
|
||||||
|
uniseq: id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
prompt,
|
||||||
|
ver: '0.0.0.5',
|
||||||
|
view: 'contact',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromResId (resId: string): ForwardMsgJson {
|
||||||
|
return this.build(resId, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromPacketMsg (resId: string, packetMsg: PacketMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string): ForwardMsgJson {
|
||||||
|
return this.build(resId, packetMsg.map(msg => ({
|
||||||
|
senderName: msg.senderName,
|
||||||
|
isGroupMsg: msg.groupId !== undefined,
|
||||||
|
msg: msg.msg.map(m => ({
|
||||||
|
preview: m.valid ? m.toPreview() : '[该消息类型暂不支持查看]',
|
||||||
|
})),
|
||||||
|
})), source, news, summary, prompt);
|
||||||
|
}
|
||||||
|
}
|
||||||
280
packages/napcat-common/src/health.ts
Normal file
280
packages/napcat-common/src/health.ts
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
export interface ResourceConfig<T extends any[], R> {
|
||||||
|
/** 资源获取函数 */
|
||||||
|
resourceFn: (...args: T) => Promise<R>;
|
||||||
|
/** 失败后禁用时间(毫秒),默认 30 秒 */
|
||||||
|
disableTime?: number;
|
||||||
|
/** 最大重试次数,默认 3 次 */
|
||||||
|
maxRetries?: number;
|
||||||
|
/** 主动测试间隔(毫秒),默认 60 秒 */
|
||||||
|
healthCheckInterval?: number;
|
||||||
|
/** 最大健康检查失败次数,超过后永久禁用,默认 5 次 */
|
||||||
|
maxHealthCheckFailures?: number;
|
||||||
|
/** 健康检查函数,如果提供则优先使用此函数进行健康检查 */
|
||||||
|
healthCheckFn?: (...args: T) => Promise<boolean>;
|
||||||
|
/** 测试参数(用于健康检查) */
|
||||||
|
testArgs?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResourceTypeState {
|
||||||
|
/** 资源配置 */
|
||||||
|
config: {
|
||||||
|
resourceFn: (...args: any[]) => Promise<any>;
|
||||||
|
healthCheckFn?: (...args: any[]) => Promise<boolean>;
|
||||||
|
disableTime: number;
|
||||||
|
maxRetries: number;
|
||||||
|
healthCheckInterval: number;
|
||||||
|
maxHealthCheckFailures: number;
|
||||||
|
testArgs?: any[];
|
||||||
|
};
|
||||||
|
/** 是否启用 */
|
||||||
|
isEnabled: boolean;
|
||||||
|
/** 禁用截止时间 */
|
||||||
|
disableUntil: number;
|
||||||
|
/** 当前重试次数 */
|
||||||
|
currentRetries: number;
|
||||||
|
/** 健康检查失败次数 */
|
||||||
|
healthCheckFailureCount: number;
|
||||||
|
/** 是否永久禁用 */
|
||||||
|
isPermanentlyDisabled: boolean;
|
||||||
|
/** 上次健康检查时间 */
|
||||||
|
lastHealthCheckTime: number;
|
||||||
|
/** 成功次数统计 */
|
||||||
|
successCount: number;
|
||||||
|
/** 失败次数统计 */
|
||||||
|
failureCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ResourceManager {
|
||||||
|
private resourceTypes = new Map<string, ResourceTypeState>();
|
||||||
|
private destroyed = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用资源(自动注册或复用已有配置)
|
||||||
|
*/
|
||||||
|
async callResource<T extends any[], R>(
|
||||||
|
type: string,
|
||||||
|
config: ResourceConfig<T, R>,
|
||||||
|
...args: T
|
||||||
|
): Promise<R> {
|
||||||
|
if (this.destroyed) {
|
||||||
|
throw new Error('ResourceManager has been destroyed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取或创建资源类型状态
|
||||||
|
let state = this.resourceTypes.get(type);
|
||||||
|
|
||||||
|
if (!state) {
|
||||||
|
// 首次注册
|
||||||
|
state = {
|
||||||
|
config: {
|
||||||
|
resourceFn: config.resourceFn as (...args: any[]) => Promise<any>,
|
||||||
|
healthCheckFn: config.healthCheckFn as ((...args: any[]) => Promise<boolean>) | undefined,
|
||||||
|
disableTime: config.disableTime ?? 30000,
|
||||||
|
maxRetries: config.maxRetries ?? 3,
|
||||||
|
healthCheckInterval: config.healthCheckInterval ?? 60000,
|
||||||
|
maxHealthCheckFailures: config.maxHealthCheckFailures ?? 20,
|
||||||
|
testArgs: config.testArgs as any[] | undefined,
|
||||||
|
},
|
||||||
|
isEnabled: true,
|
||||||
|
disableUntil: 0,
|
||||||
|
currentRetries: 0,
|
||||||
|
healthCheckFailureCount: 0,
|
||||||
|
isPermanentlyDisabled: false,
|
||||||
|
lastHealthCheckTime: 0,
|
||||||
|
successCount: 0,
|
||||||
|
failureCount: 0,
|
||||||
|
};
|
||||||
|
this.resourceTypes.set(type, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在调用前检查是否需要进行健康检查
|
||||||
|
await this.checkAndPerformHealthCheck(state);
|
||||||
|
|
||||||
|
// 检查资源状态
|
||||||
|
if (state.isPermanentlyDisabled) {
|
||||||
|
throw new Error(`Resource type '${type}' is permanently disabled (success: ${state.successCount}, failure: ${state.failureCount})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isResourceAvailable(type)) {
|
||||||
|
const disableUntilDate = new Date(state.disableUntil).toISOString();
|
||||||
|
throw new Error(`Resource type '${type}' is currently disabled until ${disableUntilDate} (success: ${state.successCount}, failure: ${state.failureCount})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用资源
|
||||||
|
try {
|
||||||
|
const result = await config.resourceFn(...args);
|
||||||
|
this.onResourceSuccess(state);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
this.onResourceFailure(state);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查资源类型是否可用
|
||||||
|
*/
|
||||||
|
isResourceAvailable (type: string): boolean {
|
||||||
|
const state = this.resourceTypes.get(type);
|
||||||
|
if (!state) {
|
||||||
|
return true; // 未注册的资源类型视为可用
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.isPermanentlyDisabled || !state.isEnabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Date.now() >= state.disableUntil;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取资源类型统计信息
|
||||||
|
*/
|
||||||
|
getResourceStats (type: string): { successCount: number; failureCount: number; isEnabled: boolean; isPermanentlyDisabled: boolean } | null {
|
||||||
|
const state = this.resourceTypes.get(type);
|
||||||
|
if (!state) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
successCount: state.successCount,
|
||||||
|
failureCount: state.failureCount,
|
||||||
|
isEnabled: state.isEnabled,
|
||||||
|
isPermanentlyDisabled: state.isPermanentlyDisabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有资源类型统计
|
||||||
|
*/
|
||||||
|
getAllResourceStats (): Map<string, { successCount: number; failureCount: number; isEnabled: boolean; isPermanentlyDisabled: boolean }> {
|
||||||
|
const stats = new Map();
|
||||||
|
for (const [type, state] of this.resourceTypes) {
|
||||||
|
stats.set(type, {
|
||||||
|
successCount: state.successCount,
|
||||||
|
failureCount: state.failureCount,
|
||||||
|
isEnabled: state.isEnabled,
|
||||||
|
isPermanentlyDisabled: state.isPermanentlyDisabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注销资源类型
|
||||||
|
*/
|
||||||
|
unregister (type: string): boolean {
|
||||||
|
return this.resourceTypes.delete(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 销毁管理器
|
||||||
|
*/
|
||||||
|
destroy (): void {
|
||||||
|
if (this.destroyed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.resourceTypes.clear();
|
||||||
|
this.destroyed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查并执行健康检查(如果需要)
|
||||||
|
*/
|
||||||
|
private async checkAndPerformHealthCheck (state: ResourceTypeState): Promise<void> {
|
||||||
|
// 如果资源可用或已永久禁用,无需健康检查
|
||||||
|
if (state.isEnabled && Date.now() >= state.disableUntil) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.isPermanentlyDisabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// 检查是否还在禁用期内
|
||||||
|
if (now < state.disableUntil) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否需要进行健康检查(根据间隔时间)
|
||||||
|
if (now - state.lastHealthCheckTime < state.config.healthCheckInterval) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行健康检查
|
||||||
|
await this.performHealthCheck(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async performHealthCheck (state: ResourceTypeState): Promise<void> {
|
||||||
|
state.lastHealthCheckTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
let healthCheckResult: boolean;
|
||||||
|
|
||||||
|
if (state.config.healthCheckFn) {
|
||||||
|
const testArgs = state.config.testArgs || [];
|
||||||
|
healthCheckResult = await state.config.healthCheckFn(...testArgs);
|
||||||
|
} else {
|
||||||
|
const testArgs = state.config.testArgs || [];
|
||||||
|
await state.config.resourceFn(...testArgs);
|
||||||
|
healthCheckResult = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (healthCheckResult) {
|
||||||
|
// 健康检查成功,重新启用
|
||||||
|
state.isEnabled = true;
|
||||||
|
state.disableUntil = 0;
|
||||||
|
state.currentRetries = 0;
|
||||||
|
state.healthCheckFailureCount = 0;
|
||||||
|
} else {
|
||||||
|
throw new Error('Health check function returned false');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 健康检查失败,增加失败计数
|
||||||
|
state.healthCheckFailureCount++;
|
||||||
|
|
||||||
|
// 检查是否达到最大健康检查失败次数
|
||||||
|
if (state.healthCheckFailureCount >= state.config.maxHealthCheckFailures) {
|
||||||
|
// 永久禁用资源
|
||||||
|
state.isPermanentlyDisabled = true;
|
||||||
|
state.disableUntil = 0;
|
||||||
|
} else {
|
||||||
|
// 继续禁用一段时间
|
||||||
|
state.disableUntil = Date.now() + state.config.disableTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onResourceSuccess (state: ResourceTypeState): void {
|
||||||
|
state.currentRetries = 0;
|
||||||
|
state.disableUntil = 0;
|
||||||
|
state.healthCheckFailureCount = 0;
|
||||||
|
state.successCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private onResourceFailure (state: ResourceTypeState): void {
|
||||||
|
state.currentRetries++;
|
||||||
|
state.failureCount++;
|
||||||
|
|
||||||
|
// 如果重试次数达到上限,禁用资源
|
||||||
|
if (state.currentRetries >= state.config.maxRetries) {
|
||||||
|
state.disableUntil = Date.now() + state.config.disableTime;
|
||||||
|
state.currentRetries = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建全局实例
|
||||||
|
export const resourceManager = new ResourceManager();
|
||||||
|
|
||||||
|
// 便捷函数
|
||||||
|
export async function registerResource<T extends any[], R> (
|
||||||
|
type: string,
|
||||||
|
config: ResourceConfig<T, R>,
|
||||||
|
...args: T
|
||||||
|
): Promise<R> {
|
||||||
|
return resourceManager.callResource(type, config, ...args);
|
||||||
|
}
|
||||||
214
packages/napcat-common/src/helper.ts
Normal file
214
packages/napcat-common/src/helper.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import fs from 'fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import { QQLevel } from '@/napcat-core';
|
||||||
|
import { QQVersionConfigType } from './types';
|
||||||
|
|
||||||
|
export async function solveProblem<T extends (...arg: any[]) => any> (func: T, ...args: Parameters<T>): Promise<ReturnType<T> | undefined> {
|
||||||
|
return new Promise<ReturnType<T> | undefined>((resolve) => {
|
||||||
|
try {
|
||||||
|
const result = func(...args);
|
||||||
|
resolve(result);
|
||||||
|
} catch {
|
||||||
|
resolve(undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function solveAsyncProblem<T extends (...args: any[]) => Promise<any>> (func: T, ...args: Parameters<T>): Promise<Awaited<ReturnType<T>> | undefined> {
|
||||||
|
return new Promise<Awaited<ReturnType<T>> | undefined>((resolve) => {
|
||||||
|
func(...args).then((result) => {
|
||||||
|
resolve(result);
|
||||||
|
}).catch(() => {
|
||||||
|
resolve(undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sleep (ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PromiseTimer<T> (promise: Promise<T>, ms: number): Promise<T> {
|
||||||
|
const timeoutPromise = new Promise<T>((_resolve, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('PromiseTimer: Operation timed out')), ms)
|
||||||
|
);
|
||||||
|
return Promise.race([promise, timeoutPromise]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runAllWithTimeout<T> (tasks: Promise<T>[], timeout: number): Promise<T[]> {
|
||||||
|
const wrappedTasks = tasks.map((task) =>
|
||||||
|
PromiseTimer(task, timeout).then(
|
||||||
|
(result) => ({ status: 'fulfilled', value: result }),
|
||||||
|
(error) => ({ status: 'rejected', reason: error })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const results = await Promise.all(wrappedTasks);
|
||||||
|
return results
|
||||||
|
.filter((result) => result.status === 'fulfilled')
|
||||||
|
.map((result) => (result as { status: 'fulfilled'; value: T; }).value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNull (value: any) {
|
||||||
|
return value === undefined || value === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNumeric (str: string) {
|
||||||
|
return /^\d+$/.test(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncateString (obj: any, maxLength = 500) {
|
||||||
|
if (obj !== null && typeof obj === 'object') {
|
||||||
|
Object.keys(obj).forEach((key) => {
|
||||||
|
if (typeof obj[key] === 'string') {
|
||||||
|
// 如果是字符串且超过指定长度,则截断
|
||||||
|
if (obj[key].length > maxLength) {
|
||||||
|
obj[key] = obj[key].substring(0, maxLength) + '...';
|
||||||
|
}
|
||||||
|
} else if (typeof obj[key] === 'object') {
|
||||||
|
// 如果是对象或数组,则递归调用
|
||||||
|
truncateString(obj[key], maxLength);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEqual (obj1: any, obj2: any) {
|
||||||
|
if (obj1 === obj2) return true;
|
||||||
|
if (obj1 == null || obj2 == null) return false;
|
||||||
|
if (typeof obj1 !== 'object' || typeof obj2 !== 'object') return obj1 === obj2;
|
||||||
|
|
||||||
|
const keys1 = Object.keys(obj1);
|
||||||
|
const keys2 = Object.keys(obj2);
|
||||||
|
|
||||||
|
if (keys1.length !== keys2.length) return false;
|
||||||
|
|
||||||
|
for (const key of keys1) {
|
||||||
|
if (!isEqual(obj1[key], obj2[key])) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultQQVersionConfigInfo (): QQVersionConfigType {
|
||||||
|
if (os.platform() === 'linux') {
|
||||||
|
return {
|
||||||
|
baseVersion: '3.2.12.28060',
|
||||||
|
curVersion: '3.2.12.28060',
|
||||||
|
prevVersion: '',
|
||||||
|
onErrorVersions: [],
|
||||||
|
buildId: '27254',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (os.platform() === 'darwin') {
|
||||||
|
return {
|
||||||
|
baseVersion: '6.9.53.28060',
|
||||||
|
curVersion: '6.9.53.28060',
|
||||||
|
prevVersion: '',
|
||||||
|
onErrorVersions: [],
|
||||||
|
buildId: '28060',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
baseVersion: '9.9.15-28131',
|
||||||
|
curVersion: '9.9.15-28131',
|
||||||
|
prevVersion: '',
|
||||||
|
onErrorVersions: [],
|
||||||
|
buildId: '28131',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQQPackageInfoPath (exePath: string = '', version?: string): string {
|
||||||
|
if (process.env['NAPCAT_QQ_PACKAGE_INFO_PATH']) {
|
||||||
|
return process.env['NAPCAT_QQ_PACKAGE_INFO_PATH'];
|
||||||
|
}
|
||||||
|
let packagePath;
|
||||||
|
if (os.platform() === 'darwin') {
|
||||||
|
packagePath = path.join(path.dirname(exePath), '..', 'Resources', 'app', 'package.json');
|
||||||
|
} else if (os.platform() === 'linux') {
|
||||||
|
packagePath = path.join(path.dirname(exePath), './resources/app/package.json');
|
||||||
|
} else {
|
||||||
|
packagePath = path.join(path.dirname(exePath), './versions/' + version + '/resources/app/package.json');
|
||||||
|
}
|
||||||
|
// 下面是老版本兼容 未来去掉
|
||||||
|
if (!fs.existsSync(packagePath)) {
|
||||||
|
packagePath = path.join(path.dirname(exePath), './resources/app/versions/' + version + '/package.json');
|
||||||
|
}
|
||||||
|
return packagePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQQVersionConfigPath (exePath: string = ''): string | undefined {
|
||||||
|
if (process.env['NAPCAT_QQ_VERSION_CONFIG_PATH']) {
|
||||||
|
return process.env['NAPCAT_QQ_VERSION_CONFIG_PATH'];
|
||||||
|
}
|
||||||
|
let configVersionInfoPath;
|
||||||
|
if (os.platform() === 'win32') {
|
||||||
|
configVersionInfoPath = path.join(path.dirname(exePath), 'versions', 'config.json');
|
||||||
|
} else if (os.platform() === 'darwin') {
|
||||||
|
const userPath = os.homedir();
|
||||||
|
const appDataPath = path.resolve(userPath, './Library/Application Support/QQ');
|
||||||
|
configVersionInfoPath = path.resolve(appDataPath, './versions/config.json');
|
||||||
|
} else {
|
||||||
|
const userPath = os.homedir();
|
||||||
|
const appDataPath = path.resolve(userPath, './.config/QQ');
|
||||||
|
configVersionInfoPath = path.resolve(appDataPath, './versions/config.json');
|
||||||
|
}
|
||||||
|
if (typeof configVersionInfoPath !== 'string') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
// 老版本兼容 未来去掉
|
||||||
|
if (!fs.existsSync(configVersionInfoPath)) {
|
||||||
|
configVersionInfoPath = path.join(path.dirname(exePath), './resources/app/versions/config.json');
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(configVersionInfoPath)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return configVersionInfoPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcQQLevel (level?: QQLevel) {
|
||||||
|
if (!level) return 0;
|
||||||
|
// const { penguinNum, crownNum, sunNum, moonNum, starNum } = level;
|
||||||
|
const { crownNum, sunNum, moonNum, starNum } = level;
|
||||||
|
// 没补类型
|
||||||
|
return crownNum * 64 + sunNum * 16 + moonNum * 4 + starNum;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stringifyWithBigInt (obj: any) {
|
||||||
|
return JSON.stringify(obj, (_key, value) =>
|
||||||
|
typeof value === 'bigint' ? value.toString() : value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAppidFromMajor (nodeMajor: string): string | undefined {
|
||||||
|
const hexSequence = 'A4 09 00 00 00 35';
|
||||||
|
const sequenceBytes = Buffer.from(hexSequence.replace(/ /g, ''), 'hex');
|
||||||
|
const filePath = path.resolve(nodeMajor);
|
||||||
|
const fileContent = fs.readFileSync(filePath);
|
||||||
|
|
||||||
|
let searchPosition = 0;
|
||||||
|
while (true) {
|
||||||
|
const index = fileContent.indexOf(sequenceBytes, searchPosition);
|
||||||
|
if (index === -1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = index + sequenceBytes.length - 1;
|
||||||
|
const end = fileContent.indexOf(0x00, start);
|
||||||
|
if (end === -1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const content = fileContent.subarray(start, end);
|
||||||
|
if (!content.every(byte => byte === 0x00)) {
|
||||||
|
try {
|
||||||
|
return content.toString('utf-8');
|
||||||
|
} catch {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
searchPosition = end + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
321
packages/napcat-common/src/log.ts
Normal file
321
packages/napcat-common/src/log.ts
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
import winston, { format, transports } from 'winston';
|
||||||
|
import { truncateString } from './helper';
|
||||||
|
import path from 'node:path';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import { NTMsgAtType, ChatType, ElementType, MessageElement, RawMessage, SelfInfo } from 'napcat-core/index';
|
||||||
|
import EventEmitter from 'node:events';
|
||||||
|
export enum LogLevel {
|
||||||
|
DEBUG = 'debug',
|
||||||
|
INFO = 'info',
|
||||||
|
WARN = 'warn',
|
||||||
|
ERROR = 'error',
|
||||||
|
FATAL = 'fatal',
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFormattedTimestamp () {
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = (now.getMonth() + 1).toString().padStart(2, '0');
|
||||||
|
const day = now.getDate().toString().padStart(2, '0');
|
||||||
|
const hours = now.getHours().toString().padStart(2, '0');
|
||||||
|
const minutes = now.getMinutes().toString().padStart(2, '0');
|
||||||
|
const seconds = now.getSeconds().toString().padStart(2, '0');
|
||||||
|
const milliseconds = now.getMilliseconds().toString().padStart(3, '0');
|
||||||
|
return `${year}-${month}-${day}_${hours}-${minutes}-${seconds}.${milliseconds}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const logEmitter = new EventEmitter();
|
||||||
|
export type LogListener = (msg: string) => void;
|
||||||
|
class Subscription {
|
||||||
|
public static MAX_HISTORY = 100;
|
||||||
|
public static history: string[] = [];
|
||||||
|
|
||||||
|
subscribe (listener: LogListener) {
|
||||||
|
for (const history of Subscription.history) {
|
||||||
|
try {
|
||||||
|
listener(history);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logEmitter.on('log', listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribe (listener: LogListener) {
|
||||||
|
logEmitter.off('log', listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
notify (msg: string) {
|
||||||
|
logEmitter.emit('log', msg);
|
||||||
|
if (Subscription.history.length >= Subscription.MAX_HISTORY) {
|
||||||
|
Subscription.history.shift();
|
||||||
|
}
|
||||||
|
Subscription.history.push(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logSubscription = new Subscription();
|
||||||
|
|
||||||
|
export class LogWrapper {
|
||||||
|
fileLogEnabled = true;
|
||||||
|
consoleLogEnabled = true;
|
||||||
|
logger: winston.Logger;
|
||||||
|
|
||||||
|
constructor (logDir: string) {
|
||||||
|
const filename = `${getFormattedTimestamp()}.log`;
|
||||||
|
const logPath = path.join(logDir, filename);
|
||||||
|
|
||||||
|
this.logger = winston.createLogger({
|
||||||
|
level: 'debug',
|
||||||
|
format: format.combine(
|
||||||
|
format.timestamp({ format: 'MM-DD HH:mm:ss' }),
|
||||||
|
format.printf(({ timestamp, level, message, ...meta }) => {
|
||||||
|
const userInfo = meta['userInfo'] ? `${meta['userInfo']} | ` : '';
|
||||||
|
return `${timestamp} [${level}] ${userInfo}${message}`;
|
||||||
|
})
|
||||||
|
),
|
||||||
|
transports: [
|
||||||
|
new transports.File({
|
||||||
|
filename: logPath,
|
||||||
|
level: 'debug',
|
||||||
|
maxsize: 5 * 1024 * 1024, // 5MB
|
||||||
|
maxFiles: 5,
|
||||||
|
}),
|
||||||
|
new transports.Console({
|
||||||
|
format: format.combine(
|
||||||
|
format.colorize(),
|
||||||
|
format.printf(({ timestamp, level, message, ...meta }) => {
|
||||||
|
const userInfo = meta['userInfo'] ? `${meta['userInfo']} | ` : '';
|
||||||
|
return `${timestamp} [${level}] ${userInfo}${message}`;
|
||||||
|
})
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setLogSelfInfo({ nick: '', uid: '' });
|
||||||
|
this.cleanOldLogs(logDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanOldLogs (logDir: string) {
|
||||||
|
const oneWeekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
||||||
|
fs.readdir(logDir).then((files) => {
|
||||||
|
files.forEach((file) => {
|
||||||
|
const filePath = path.join(logDir, file);
|
||||||
|
this.deleteOldLogFile(filePath, oneWeekAgo);
|
||||||
|
});
|
||||||
|
}).catch((err) => {
|
||||||
|
this.logger.error('Failed to read log directory', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private deleteOldLogFile (filePath: string, oneWeekAgo: number) {
|
||||||
|
fs.stat(filePath).then((stats) => {
|
||||||
|
if (stats.mtime.getTime() < oneWeekAgo) {
|
||||||
|
fs.unlink(filePath).catch((err) => {
|
||||||
|
if (err) {
|
||||||
|
if (err.code === 'ENOENT') {
|
||||||
|
this.logger.warn(`File already deleted: ${filePath}`);
|
||||||
|
} else {
|
||||||
|
this.logger.error('Failed to delete old log file', err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.info(`Deleted old log file: ${filePath}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).catch((err) => {
|
||||||
|
this.logger.error('Failed to get file stats', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setFileAndConsoleLogLevel (fileLogLevel: LogLevel, consoleLogLevel: LogLevel) {
|
||||||
|
this.logger.transports.forEach((transport) => {
|
||||||
|
if (transport instanceof transports.File) {
|
||||||
|
transport.level = fileLogLevel;
|
||||||
|
} else if (transport instanceof transports.Console) {
|
||||||
|
transport.level = consoleLogLevel;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setLogSelfInfo (selfInfo: { nick: string; uid: string; }) {
|
||||||
|
const userInfo = `${selfInfo.nick}`;
|
||||||
|
this.logger.defaultMeta = { userInfo };
|
||||||
|
}
|
||||||
|
|
||||||
|
setFileLogEnabled (isEnabled: boolean) {
|
||||||
|
this.fileLogEnabled = isEnabled;
|
||||||
|
this.logger.transports.forEach((transport) => {
|
||||||
|
if (transport instanceof transports.File) {
|
||||||
|
transport.silent = !isEnabled;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setConsoleLogEnabled (isEnabled: boolean) {
|
||||||
|
this.consoleLogEnabled = isEnabled;
|
||||||
|
this.logger.transports.forEach((transport) => {
|
||||||
|
if (transport instanceof transports.Console) {
|
||||||
|
transport.silent = !isEnabled;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
formatMsg (msg: any[]) {
|
||||||
|
return msg
|
||||||
|
.map((msgItem) => {
|
||||||
|
if (msgItem instanceof Error) {
|
||||||
|
return msgItem.stack;
|
||||||
|
} else if (typeof msgItem === 'object') {
|
||||||
|
return JSON.stringify(truncateString(JSON.parse(JSON.stringify(msgItem, null, 2))));
|
||||||
|
}
|
||||||
|
return msgItem;
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
_log (level: LogLevel, ...args: any[]) {
|
||||||
|
const message = this.formatMsg(args);
|
||||||
|
if (this.consoleLogEnabled && this.fileLogEnabled) {
|
||||||
|
this.logger.log(level, message);
|
||||||
|
} else if (this.consoleLogEnabled) {
|
||||||
|
this.logger.log(level, message);
|
||||||
|
} else if (this.fileLogEnabled) {
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
this.logger.log(level, message.replace(/\x1B[@-_][0-?]*[ -/]*[@-~]/g, ''));
|
||||||
|
}
|
||||||
|
logSubscription.notify(JSON.stringify({ level, message }));
|
||||||
|
}
|
||||||
|
|
||||||
|
log (...args: any[]) {
|
||||||
|
this._log(LogLevel.INFO, ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
logDebug (...args: any[]) {
|
||||||
|
this._log(LogLevel.DEBUG, ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
logError (...args: any[]) {
|
||||||
|
this._log(LogLevel.ERROR, ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
logWarn (...args: any[]) {
|
||||||
|
this._log(LogLevel.WARN, ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
logFatal (...args: any[]) {
|
||||||
|
this._log(LogLevel.FATAL, ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
logMessage (msg: RawMessage, selfInfo: SelfInfo) {
|
||||||
|
const isSelfSent = msg.senderUin === selfInfo.uin;
|
||||||
|
|
||||||
|
if (msg.elements[0]?.elementType === ElementType.GreyTip) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log(`${isSelfSent ? '发送 ->' : '接收 <-'} ${rawMessageToText(msg)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rawMessageToText (msg: RawMessage, recursiveLevel = 0): string {
|
||||||
|
if (recursiveLevel > 2) {
|
||||||
|
return '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens: string[] = [];
|
||||||
|
|
||||||
|
if (msg.chatType === ChatType.KCHATTYPEC2C) {
|
||||||
|
tokens.push(`私聊 (${msg.peerUin})`);
|
||||||
|
} else if (msg.chatType === ChatType.KCHATTYPEGROUP) {
|
||||||
|
if (recursiveLevel < 1) {
|
||||||
|
tokens.push(`群聊 [${msg.peerName}(${msg.peerUin})]`);
|
||||||
|
}
|
||||||
|
if (msg.senderUin !== '0') {
|
||||||
|
tokens.push(`[${msg.sendMemberName || msg.sendRemarkName || msg.sendNickName}(${msg.senderUin})]`);
|
||||||
|
}
|
||||||
|
} else if (msg.chatType === ChatType.KCHATTYPEDATALINE) {
|
||||||
|
tokens.push('移动设备');
|
||||||
|
} else {
|
||||||
|
tokens.push(`临时消息 (${msg.peerUin})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const element of msg.elements) {
|
||||||
|
tokens.push(msgElementToText(element, msg, recursiveLevel));
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function msgElementToText (element: MessageElement, msg: RawMessage, recursiveLevel: number): string {
|
||||||
|
if (element.textElement) {
|
||||||
|
return textElementToText(element.textElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element.replyElement) {
|
||||||
|
return replyElementToText(element.replyElement, msg, recursiveLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element.picElement) {
|
||||||
|
return '[图片]';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element.fileElement) {
|
||||||
|
return `[文件 ${element.fileElement.fileName}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element.videoElement) {
|
||||||
|
return '[视频]';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element.pttElement) {
|
||||||
|
return `[语音 ${element.pttElement.duration}s]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element.arkElement) {
|
||||||
|
return '[卡片消息]';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element.faceElement) {
|
||||||
|
return `[表情 ${element.faceElement.faceText ?? ''}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element.marketFaceElement) {
|
||||||
|
return element.marketFaceElement.faceName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element.markdownElement) {
|
||||||
|
return '[Markdown 消息]';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element.multiForwardMsgElement) {
|
||||||
|
return '[转发消息]';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element.elementType === ElementType.GreyTip) {
|
||||||
|
return '[灰条消息]';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `[未实现 (ElementType = ${element.elementType})]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function textElementToText (textElement: any): string {
|
||||||
|
if (textElement.atType === NTMsgAtType.ATTYPEUNKNOWN) {
|
||||||
|
const originalContentLines = textElement.content.split('\n');
|
||||||
|
return `${originalContentLines[0]}${originalContentLines.length > 1 ? ' ...' : ''}`;
|
||||||
|
} else if (textElement.atType === NTMsgAtType.ATTYPEALL) {
|
||||||
|
return '@全体成员';
|
||||||
|
} else if (textElement.atType === NTMsgAtType.ATTYPEONE) {
|
||||||
|
return `${textElement.content} (${textElement.atUid})`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function replyElementToText (replyElement: any, msg: RawMessage, recursiveLevel: number): string {
|
||||||
|
const recordMsgOrNull = msg.records.find((record) => replyElement.sourceMsgIdInRecords === record.msgId);
|
||||||
|
return `[回复消息 ${recordMsgOrNull && recordMsgOrNull.peerUin !== '284840486' && recordMsgOrNull.peerUin !== '1094950020'
|
||||||
|
? rawMessageToText(recordMsgOrNull, recursiveLevel + 1)
|
||||||
|
: `未找到消息记录 (MsgId = ${replyElement.sourceMsgIdInRecords})`
|
||||||
|
}]`;
|
||||||
|
}
|
||||||
43
packages/napcat-common/src/lru-cache.ts
Normal file
43
packages/napcat-common/src/lru-cache.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
export class LRUCache<K, V> {
|
||||||
|
private capacity: number;
|
||||||
|
public cache: Map<K, V>;
|
||||||
|
|
||||||
|
constructor (capacity: number) {
|
||||||
|
this.capacity = capacity;
|
||||||
|
this.cache = new Map<K, V>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public get (key: K): V | undefined {
|
||||||
|
const value = this.cache.get(key);
|
||||||
|
if (value !== undefined) {
|
||||||
|
// Move the accessed key to the end to mark it as most recently used
|
||||||
|
this.cache.delete(key);
|
||||||
|
this.cache.set(key, value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public put (key: K, value: V): void {
|
||||||
|
if (this.cache.has(key)) {
|
||||||
|
// If the key already exists, move it to the end to mark it as most recently used
|
||||||
|
this.cache.delete(key);
|
||||||
|
} else if (this.cache.size >= this.capacity) {
|
||||||
|
// If the cache is full, remove the least recently used key (the first one in the map)
|
||||||
|
const firstKey = this.cache.keys().next().value;
|
||||||
|
if (firstKey !== undefined) {
|
||||||
|
this.cache.delete(firstKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.cache.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public resetCapacity (newCapacity: number): void {
|
||||||
|
this.capacity = newCapacity;
|
||||||
|
while (this.cache.size > this.capacity) {
|
||||||
|
const firstKey = this.cache.keys().next().value;
|
||||||
|
if (firstKey !== undefined) {
|
||||||
|
this.cache.delete(firstKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
142
packages/napcat-common/src/message-unique.ts
Normal file
142
packages/napcat-common/src/message-unique.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { Peer } from '@/napcat-core';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
export class LimitedHashTable<K, V> {
|
||||||
|
private readonly keyToValue: Map<K, V> = new Map();
|
||||||
|
private readonly valueToKey: Map<V, K> = new Map();
|
||||||
|
private maxSize: number;
|
||||||
|
|
||||||
|
constructor (maxSize: number) {
|
||||||
|
this.maxSize = maxSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
resize (count: number) {
|
||||||
|
this.maxSize = count;
|
||||||
|
}
|
||||||
|
|
||||||
|
set (key: K, value: V): void {
|
||||||
|
this.keyToValue.set(key, value);
|
||||||
|
this.valueToKey.set(value, key);
|
||||||
|
while (this.keyToValue.size !== this.valueToKey.size) {
|
||||||
|
this.keyToValue.clear();
|
||||||
|
this.valueToKey.clear();
|
||||||
|
}
|
||||||
|
while (this.keyToValue.size > this.maxSize || this.valueToKey.size > this.maxSize) {
|
||||||
|
const oldestKey = this.keyToValue.keys().next().value;
|
||||||
|
if (oldestKey !== undefined) {
|
||||||
|
this.valueToKey.delete(this.keyToValue.get(oldestKey) as V);
|
||||||
|
this.keyToValue.delete(oldestKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getValue (key: K): V | undefined {
|
||||||
|
return this.keyToValue.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
getKey (value: V): K | undefined {
|
||||||
|
return this.valueToKey.get(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteByValue (value: V): void {
|
||||||
|
const key = this.valueToKey.get(value);
|
||||||
|
if (key !== undefined) {
|
||||||
|
this.keyToValue.delete(key);
|
||||||
|
this.valueToKey.delete(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteByKey (key: K): void {
|
||||||
|
const value = this.keyToValue.get(key);
|
||||||
|
if (value !== undefined) {
|
||||||
|
this.keyToValue.delete(key);
|
||||||
|
this.valueToKey.delete(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getKeyList (): K[] {
|
||||||
|
return Array.from(this.keyToValue.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取最近刚写入的几个值
|
||||||
|
getHeads (size: number): { key: K; value: V }[] | undefined {
|
||||||
|
const keyList = this.getKeyList();
|
||||||
|
if (keyList.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const result: { key: K; value: V }[] = [];
|
||||||
|
const listSize = Math.min(size, keyList.length);
|
||||||
|
for (let i = 0; i < listSize; i++) {
|
||||||
|
const key = keyList[listSize - i];
|
||||||
|
if (key !== undefined) {
|
||||||
|
result.push({ key, value: this.keyToValue.get(key)! });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MessageUniqueWrapper {
|
||||||
|
private readonly msgDataMap: LimitedHashTable<string, number>;
|
||||||
|
private readonly msgIdMap: LimitedHashTable<string, number>;
|
||||||
|
|
||||||
|
constructor (maxMap: number = 5000) {
|
||||||
|
this.msgIdMap = new LimitedHashTable<string, number>(maxMap);
|
||||||
|
this.msgDataMap = new LimitedHashTable<string, number>(maxMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
getRecentMsgIds (Peer: Peer, size: number): string[] {
|
||||||
|
const heads = this.msgIdMap.getHeads(size);
|
||||||
|
if (!heads) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const data = heads.map((t) => MessageUnique.getMsgIdAndPeerByShortId(t.value));
|
||||||
|
const ret = data.filter((t) => t?.Peer.chatType === Peer.chatType && t?.Peer.peerUid === Peer.peerUid);
|
||||||
|
return ret.map((t) => t?.MsgId).filter((t) => t !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
createUniqueMsgId (peer: Peer, msgId: string) {
|
||||||
|
const key = `${msgId}|${peer.chatType}|${peer.peerUid}`;
|
||||||
|
const hash = crypto.createHash('md5').update(key).digest();
|
||||||
|
if (hash[0]) {
|
||||||
|
// 设置第一个bit为0 保证shortId为正数
|
||||||
|
hash[0] &= 0x7f;
|
||||||
|
}
|
||||||
|
const shortId = hash.readInt32BE(0);
|
||||||
|
// 减少性能损耗
|
||||||
|
this.msgIdMap.set(msgId, shortId);
|
||||||
|
this.msgDataMap.set(key, shortId);
|
||||||
|
return shortId;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMsgIdAndPeerByShortId (shortId: number): { MsgId: string; Peer: Peer } | undefined {
|
||||||
|
const data = this.msgDataMap.getKey(shortId);
|
||||||
|
if (data) {
|
||||||
|
const [msgId, chatTypeStr, peerUid] = data.split('|');
|
||||||
|
const peer: Peer = {
|
||||||
|
chatType: parseInt(chatTypeStr ?? '0'),
|
||||||
|
peerUid: peerUid ?? '',
|
||||||
|
guildId: '',
|
||||||
|
};
|
||||||
|
return { MsgId: msgId ?? '0', Peer: peer };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
getShortIdByMsgId (msgId: string): number | undefined {
|
||||||
|
return this.msgIdMap.getValue(msgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPeerByMsgId (msgId: string) {
|
||||||
|
const shortId = this.msgIdMap.getValue(msgId);
|
||||||
|
if (!shortId) return undefined;
|
||||||
|
return this.getMsgIdAndPeerByShortId(shortId);
|
||||||
|
}
|
||||||
|
|
||||||
|
resize (maxSize: number): void {
|
||||||
|
this.msgIdMap.resize(maxSize);
|
||||||
|
this.msgDataMap.resize(maxSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MessageUnique: MessageUniqueWrapper = new MessageUniqueWrapper();
|
||||||
41
packages/napcat-common/src/path.ts
Normal file
41
packages/napcat-common/src/path.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import path, { dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
|
||||||
|
export class NapCatPathWrapper {
|
||||||
|
binaryPath: string;
|
||||||
|
logsPath: string;
|
||||||
|
configPath: string;
|
||||||
|
cachePath: string;
|
||||||
|
staticPath: string;
|
||||||
|
pluginPath: string;
|
||||||
|
|
||||||
|
constructor (mainPath: string = dirname(fileURLToPath(import.meta.url))) {
|
||||||
|
this.binaryPath = mainPath;
|
||||||
|
let writePath: string;
|
||||||
|
|
||||||
|
if (process.env['NAPCAT_WORKDIR']) {
|
||||||
|
writePath = process.env['NAPCAT_WORKDIR'];
|
||||||
|
} else if (os.platform() === 'darwin') {
|
||||||
|
writePath = path.join(os.homedir(), 'Library', 'Application Support', 'QQ', 'NapCat');
|
||||||
|
} else {
|
||||||
|
writePath = this.binaryPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logsPath = path.join(writePath, 'logs');
|
||||||
|
this.configPath = path.join(writePath, 'config');
|
||||||
|
this.pluginPath = path.join(writePath, 'plugins');// dynamic load
|
||||||
|
this.cachePath = path.join(writePath, 'cache');
|
||||||
|
this.staticPath = path.join(this.binaryPath, 'static');
|
||||||
|
if (!fs.existsSync(this.logsPath)) {
|
||||||
|
fs.mkdirSync(this.logsPath, { recursive: true });
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(this.configPath)) {
|
||||||
|
fs.mkdirSync(this.configPath, { recursive: true });
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(this.cachePath)) {
|
||||||
|
fs.mkdirSync(this.cachePath, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
317
packages/napcat-common/src/performance-monitor.ts
Normal file
317
packages/napcat-common/src/performance-monitor.ts
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
/**
|
||||||
|
* 性能监控器 - 用于统计函数调用次数、耗时等信息
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
export interface FunctionStats {
|
||||||
|
name: string;
|
||||||
|
callCount: number;
|
||||||
|
totalTime: number;
|
||||||
|
averageTime: number;
|
||||||
|
minTime: number;
|
||||||
|
maxTime: number;
|
||||||
|
fileName?: string;
|
||||||
|
lineNumber?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PerformanceMonitor {
|
||||||
|
private static instance: PerformanceMonitor;
|
||||||
|
private stats = new Map<string, FunctionStats>();
|
||||||
|
private startTimes = new Map<string, number>();
|
||||||
|
private reportInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
static getInstance (): PerformanceMonitor {
|
||||||
|
if (!PerformanceMonitor.instance) {
|
||||||
|
PerformanceMonitor.instance = new PerformanceMonitor();
|
||||||
|
// 启动定时统计报告
|
||||||
|
PerformanceMonitor.instance.startPeriodicReport();
|
||||||
|
}
|
||||||
|
return PerformanceMonitor.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始定时统计报告 (每60秒)
|
||||||
|
*/
|
||||||
|
private startPeriodicReport (): void {
|
||||||
|
if (this.reportInterval) {
|
||||||
|
clearInterval(this.reportInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reportInterval = setInterval(() => {
|
||||||
|
if (this.stats.size > 0) {
|
||||||
|
this.printPeriodicReport();
|
||||||
|
this.writeDetailedLogToFile();
|
||||||
|
}
|
||||||
|
}, 60000); // 60秒
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止定时统计报告
|
||||||
|
*/
|
||||||
|
stopPeriodicReport (): void {
|
||||||
|
if (this.reportInterval) {
|
||||||
|
clearInterval(this.reportInterval);
|
||||||
|
this.reportInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打印定时统计报告 (简化版本)
|
||||||
|
*/
|
||||||
|
private printPeriodicReport (): void {
|
||||||
|
const now = new Date().toLocaleString();
|
||||||
|
console.log(`\n=== 性能监控定时报告 [${now}] ===`);
|
||||||
|
|
||||||
|
const totalFunctions = this.stats.size;
|
||||||
|
const totalCalls = Array.from(this.stats.values()).reduce((sum, stat) => sum + stat.callCount, 0);
|
||||||
|
const totalTime = Array.from(this.stats.values()).reduce((sum, stat) => sum + stat.totalTime, 0);
|
||||||
|
|
||||||
|
console.log(`📊 总览: ${totalFunctions} 个函数, ${totalCalls} 次调用, 总耗时: ${totalTime.toFixed(2)}ms`);
|
||||||
|
|
||||||
|
// 显示Top 5最活跃的函数
|
||||||
|
console.log('\n🔥 最活跃函数 (Top 5):');
|
||||||
|
this.getTopByCallCount(5).forEach((stat, index) => {
|
||||||
|
console.log(`${index + 1}. ${stat.name} - 调用: ${stat.callCount}次, 总耗时: ${stat.totalTime.toFixed(2)}ms`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 显示Top 5最耗时的函数
|
||||||
|
console.log('\n⏱️ 最耗时函数 (Top 5):');
|
||||||
|
this.getTopByTotalTime(5).forEach((stat, index) => {
|
||||||
|
console.log(`${index + 1}. ${stat.name} - 总耗时: ${stat.totalTime.toFixed(2)}ms, 平均: ${stat.averageTime.toFixed(2)}ms`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('===============================\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将详细统计数据写入日志文件
|
||||||
|
*/
|
||||||
|
private writeDetailedLogToFile (): void {
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const dateStr = now.toISOString().replace(/[:.]/g, '-').split('T')[0];
|
||||||
|
const timeStr = now.toTimeString().split(' ')[0]?.replace(/:/g, '-') || 'unknown-time';
|
||||||
|
const timestamp = `${dateStr}_${timeStr}`;
|
||||||
|
const fileName = `${timestamp}.log.txt`;
|
||||||
|
const logPath = path.join(process.cwd(), 'logs', fileName);
|
||||||
|
|
||||||
|
// 确保logs目录存在
|
||||||
|
const logsDir = path.dirname(logPath);
|
||||||
|
if (!fs.existsSync(logsDir)) {
|
||||||
|
fs.mkdirSync(logsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalFunctions = this.stats.size;
|
||||||
|
const totalCalls = Array.from(this.stats.values()).reduce((sum, stat) => sum + stat.callCount, 0);
|
||||||
|
const totalTime = Array.from(this.stats.values()).reduce((sum, stat) => sum + stat.totalTime, 0);
|
||||||
|
|
||||||
|
let logContent = '';
|
||||||
|
logContent += '=== 性能监控详细报告 ===\n';
|
||||||
|
logContent += `生成时间: ${now.toLocaleString()}\n`;
|
||||||
|
logContent += '统计周期: 60秒\n';
|
||||||
|
logContent += `总览: ${totalFunctions} 个函数, ${totalCalls} 次调用, 总耗时: ${totalTime.toFixed(2)}ms\n\n`;
|
||||||
|
|
||||||
|
// 详细函数统计
|
||||||
|
logContent += '=== 所有函数详细统计 ===\n';
|
||||||
|
const allStats = this.getStats().sort((a, b) => b.totalTime - a.totalTime);
|
||||||
|
|
||||||
|
allStats.forEach((stat, index) => {
|
||||||
|
logContent += `${index + 1}. 函数: ${stat.name}\n`;
|
||||||
|
logContent += ` 文件: ${stat.fileName || 'N/A'}\n`;
|
||||||
|
logContent += ` 行号: ${stat.lineNumber || 'N/A'}\n`;
|
||||||
|
logContent += ` 调用次数: ${stat.callCount}\n`;
|
||||||
|
logContent += ` 总耗时: ${stat.totalTime.toFixed(4)}ms\n`;
|
||||||
|
logContent += ` 平均耗时: ${stat.averageTime.toFixed(4)}ms\n`;
|
||||||
|
logContent += ` 最小耗时: ${stat.minTime === Infinity ? 'N/A' : stat.minTime.toFixed(4)}ms\n`;
|
||||||
|
logContent += ` 最大耗时: ${stat.maxTime.toFixed(4)}ms\n`;
|
||||||
|
logContent += ` 性能占比: ${((stat.totalTime / totalTime) * 100).toFixed(2)}%\n`;
|
||||||
|
logContent += '\n';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 排行榜统计
|
||||||
|
logContent += '=== 总耗时排行榜 (Top 20) ===\n';
|
||||||
|
this.getTopByTotalTime(20).forEach((stat, index) => {
|
||||||
|
logContent += `${index + 1}. ${stat.name} - 总耗时: ${stat.totalTime.toFixed(2)}ms, 调用: ${stat.callCount}次, 平均: ${stat.averageTime.toFixed(2)}ms\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
logContent += '\n=== 调用次数排行榜 (Top 20) ===\n';
|
||||||
|
this.getTopByCallCount(20).forEach((stat, index) => {
|
||||||
|
logContent += `${index + 1}. ${stat.name} - 调用: ${stat.callCount}次, 总耗时: ${stat.totalTime.toFixed(2)}ms, 平均: ${stat.averageTime.toFixed(2)}ms\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
logContent += '\n=== 平均耗时排行榜 (Top 20) ===\n';
|
||||||
|
this.getTopByAverageTime(20).forEach((stat, index) => {
|
||||||
|
logContent += `${index + 1}. ${stat.name} - 平均: ${stat.averageTime.toFixed(2)}ms, 调用: ${stat.callCount}次, 总耗时: ${stat.totalTime.toFixed(2)}ms\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
logContent += '\n=== 性能热点分析 ===\n';
|
||||||
|
// 找出最耗时的前10个函数
|
||||||
|
const hotSpots = this.getTopByTotalTime(10);
|
||||||
|
hotSpots.forEach((stat, index) => {
|
||||||
|
const efficiency = stat.callCount / stat.totalTime; // 每毫秒的调用次数
|
||||||
|
logContent += `${index + 1}. ${stat.name}\n`;
|
||||||
|
logContent += ` 性能影响: ${((stat.totalTime / totalTime) * 100).toFixed(2)}%\n`;
|
||||||
|
logContent += ` 调用效率: ${efficiency.toFixed(4)} 调用/ms\n`;
|
||||||
|
logContent += ` 优化建议: ${stat.averageTime > 10
|
||||||
|
? '考虑优化此函数的执行效率'
|
||||||
|
: stat.callCount > 1000
|
||||||
|
? '考虑减少此函数的调用频率'
|
||||||
|
: '性能表现良好'}\n\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
logContent += '=== 报告结束 ===\n';
|
||||||
|
|
||||||
|
// 写入文件
|
||||||
|
fs.writeFileSync(logPath, logContent, 'utf8');
|
||||||
|
console.log(`📄 详细性能报告已保存到: ${logPath}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('写入性能日志文件时出错:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始记录函数调用
|
||||||
|
*/
|
||||||
|
startFunction (functionName: string, fileName?: string, lineNumber?: number): string {
|
||||||
|
const callId = `${functionName}_${Date.now()}_${Math.random()}`;
|
||||||
|
this.startTimes.set(callId, performance.now());
|
||||||
|
|
||||||
|
// 初始化或更新统计信息
|
||||||
|
if (!this.stats.has(functionName)) {
|
||||||
|
this.stats.set(functionName, {
|
||||||
|
name: functionName,
|
||||||
|
callCount: 0,
|
||||||
|
totalTime: 0,
|
||||||
|
averageTime: 0,
|
||||||
|
minTime: Infinity,
|
||||||
|
maxTime: 0,
|
||||||
|
fileName,
|
||||||
|
lineNumber,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = this.stats.get(functionName)!;
|
||||||
|
stat.callCount++;
|
||||||
|
|
||||||
|
return callId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 结束记录函数调用
|
||||||
|
*/
|
||||||
|
endFunction (callId: string, functionName: string): void {
|
||||||
|
const startTime = this.startTimes.get(callId);
|
||||||
|
if (!startTime) return;
|
||||||
|
|
||||||
|
const endTime = performance.now();
|
||||||
|
const duration = endTime - startTime;
|
||||||
|
|
||||||
|
this.startTimes.delete(callId);
|
||||||
|
|
||||||
|
const stat = this.stats.get(functionName);
|
||||||
|
if (!stat) return;
|
||||||
|
|
||||||
|
stat.totalTime += duration;
|
||||||
|
stat.averageTime = stat.totalTime / stat.callCount;
|
||||||
|
stat.minTime = Math.min(stat.minTime, duration);
|
||||||
|
stat.maxTime = Math.max(stat.maxTime, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有统计信息
|
||||||
|
*/
|
||||||
|
getStats (): FunctionStats[] {
|
||||||
|
return Array.from(this.stats.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取排行榜 - 按总耗时排序
|
||||||
|
*/
|
||||||
|
getTopByTotalTime (limit = 20): FunctionStats[] {
|
||||||
|
return this.getStats()
|
||||||
|
.sort((a, b) => b.totalTime - a.totalTime)
|
||||||
|
.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取排行榜 - 按调用次数排序
|
||||||
|
*/
|
||||||
|
getTopByCallCount (limit = 20): FunctionStats[] {
|
||||||
|
return this.getStats()
|
||||||
|
.sort((a, b) => b.callCount - a.callCount)
|
||||||
|
.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取排行榜 - 按平均耗时排序
|
||||||
|
*/
|
||||||
|
getTopByAverageTime (limit = 20): FunctionStats[] {
|
||||||
|
return this.getStats()
|
||||||
|
.sort((a, b) => b.averageTime - a.averageTime)
|
||||||
|
.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空统计数据
|
||||||
|
*/
|
||||||
|
clear (): void {
|
||||||
|
this.stats.clear();
|
||||||
|
this.startTimes.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打印统计报告
|
||||||
|
*/
|
||||||
|
printReport (): void {
|
||||||
|
console.log('\n=== 函数性能监控报告 ===');
|
||||||
|
|
||||||
|
console.log('\n🔥 总耗时排行榜 (Top 10):');
|
||||||
|
this.getTopByTotalTime(10).forEach((stat, index) => {
|
||||||
|
console.log(`${index + 1}. ${stat.name} - 总耗时: ${stat.totalTime.toFixed(2)}ms, 调用次数: ${stat.callCount}, 平均耗时: ${stat.averageTime.toFixed(2)}ms`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n📈 调用次数排行榜 (Top 10):');
|
||||||
|
this.getTopByCallCount(10).forEach((stat, index) => {
|
||||||
|
console.log(`${index + 1}. ${stat.name} - 调用次数: ${stat.callCount}, 总耗时: ${stat.totalTime.toFixed(2)}ms, 平均耗时: ${stat.averageTime.toFixed(2)}ms`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n⏱️ 平均耗时排行榜 (Top 10):');
|
||||||
|
this.getTopByAverageTime(10).forEach((stat, index) => {
|
||||||
|
console.log(`${index + 1}. ${stat.name} - 平均耗时: ${stat.averageTime.toFixed(2)}ms, 调用次数: ${stat.callCount}, 总耗时: ${stat.totalTime.toFixed(2)}ms`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n========================\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取JSON格式的统计数据
|
||||||
|
*/
|
||||||
|
toJSON (): FunctionStats[] {
|
||||||
|
return this.getStats();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局性能监控器实例
|
||||||
|
export const performanceMonitor = PerformanceMonitor.getInstance();
|
||||||
|
|
||||||
|
// 在进程退出时打印报告并停止定时器
|
||||||
|
if (typeof process !== 'undefined') {
|
||||||
|
process.on('exit', () => {
|
||||||
|
performanceMonitor.stopPeriodicReport();
|
||||||
|
performanceMonitor.printReport();
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
performanceMonitor.stopPeriodicReport();
|
||||||
|
performanceMonitor.printReport();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
performanceMonitor.stopPeriodicReport();
|
||||||
|
performanceMonitor.printReport();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
21
packages/napcat-common/src/proxy-handler.ts
Normal file
21
packages/napcat-common/src/proxy-handler.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { LogWrapper } from '@/napcat-common/log';
|
||||||
|
|
||||||
|
export function proxyHandlerOf (logger: LogWrapper) {
|
||||||
|
return {
|
||||||
|
get (target: any, prop: any, receiver: any) {
|
||||||
|
if (typeof target[prop] === 'undefined') {
|
||||||
|
// 如果方法不存在,返回一个函数,这个函数调用existentMethod
|
||||||
|
|
||||||
|
return (..._args: unknown[]) => {
|
||||||
|
logger.logDebug(`${target.constructor.name} has no method ${prop}`);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 如果方法存在,正常返回
|
||||||
|
return Reflect.get(target, prop, receiver);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function proxiedListenerOf<T extends object> (listener: T, logger: LogWrapper) {
|
||||||
|
return new Proxy<T>(listener, proxyHandlerOf(logger));
|
||||||
|
}
|
||||||
106
packages/napcat-common/src/qq-basic-info.ts
Normal file
106
packages/napcat-common/src/qq-basic-info.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import { systemPlatform } from '@/napcat-common/system';
|
||||||
|
import { getDefaultQQVersionConfigInfo, getQQPackageInfoPath, getQQVersionConfigPath, parseAppidFromMajor } from './helper';
|
||||||
|
import AppidTable from 'napcat-core/external/appid.json';
|
||||||
|
import { LogWrapper } from '@/napcat-common/log';
|
||||||
|
import { getMajorPath } from 'napcat-core';
|
||||||
|
import { QQAppidTableType, QQPackageInfoType, QQVersionConfigType } from './types';
|
||||||
|
|
||||||
|
export class QQBasicInfoWrapper {
|
||||||
|
QQMainPath: string | undefined;
|
||||||
|
QQPackageInfoPath: string | undefined;
|
||||||
|
QQVersionConfigPath: string | undefined;
|
||||||
|
isQuickUpdate: boolean | undefined;
|
||||||
|
QQVersionConfig: QQVersionConfigType | undefined;
|
||||||
|
QQPackageInfo: QQPackageInfoType | undefined;
|
||||||
|
QQVersionAppid: string | undefined;
|
||||||
|
QQVersionQua: string | undefined;
|
||||||
|
context: { logger: LogWrapper; };
|
||||||
|
|
||||||
|
constructor (context: { logger: LogWrapper; }) {
|
||||||
|
// 基础目录获取
|
||||||
|
this.context = context;
|
||||||
|
this.QQMainPath = process.execPath;
|
||||||
|
this.QQVersionConfigPath = getQQVersionConfigPath(this.QQMainPath);
|
||||||
|
|
||||||
|
// 基础信息获取 无快更则启用默认模板填充
|
||||||
|
this.isQuickUpdate = !!this.QQVersionConfigPath;
|
||||||
|
this.QQVersionConfig = this.isQuickUpdate
|
||||||
|
? JSON.parse(fs.readFileSync(this.QQVersionConfigPath!).toString())
|
||||||
|
: getDefaultQQVersionConfigInfo();
|
||||||
|
|
||||||
|
this.QQPackageInfoPath = getQQPackageInfoPath(this.QQMainPath, this.QQVersionConfig?.curVersion);
|
||||||
|
this.QQPackageInfo = JSON.parse(fs.readFileSync(this.QQPackageInfoPath).toString());
|
||||||
|
const { appid: IQQVersionAppid, qua: IQQVersionQua } = this.getAppidV2();
|
||||||
|
this.QQVersionAppid = IQQVersionAppid;
|
||||||
|
this.QQVersionQua = IQQVersionQua;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 基础函数
|
||||||
|
getQQBuildStr () {
|
||||||
|
return this.QQVersionConfig?.curVersion.split('-')[1] ?? this.QQPackageInfo?.buildVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
getFullQQVersion () {
|
||||||
|
const version = this.isQuickUpdate ? this.QQVersionConfig?.curVersion : this.QQPackageInfo?.version;
|
||||||
|
if (!version) throw new Error('QQ版本获取失败');
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
requireMinNTQQBuild (buildStr: string) {
|
||||||
|
const currentBuild = +(this.getQQBuildStr() ?? '0');
|
||||||
|
if (currentBuild === 0) throw new Error('QQBuildStr获取失败');
|
||||||
|
return currentBuild >= parseInt(buildStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 此方法不要直接使用
|
||||||
|
getQUAFallback () {
|
||||||
|
const platformMapping: Partial<Record<NodeJS.Platform, string>> = {
|
||||||
|
win32: `V1_WIN_${this.getFullQQVersion()}_${this.getQQBuildStr()}_GW_B`,
|
||||||
|
darwin: `V1_MAC_${this.getFullQQVersion()}_${this.getQQBuildStr()}_GW_B`,
|
||||||
|
linux: `V1_LNX_${this.getFullQQVersion()}_${this.getQQBuildStr()}_GW_B`,
|
||||||
|
};
|
||||||
|
return platformMapping[systemPlatform] ?? (platformMapping.win32)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAppIdFallback () {
|
||||||
|
const platformMapping: Partial<Record<NodeJS.Platform, string>> = {
|
||||||
|
win32: '537246092',
|
||||||
|
darwin: '537246140',
|
||||||
|
linux: '537246140',
|
||||||
|
};
|
||||||
|
return platformMapping[systemPlatform] ?? '537246092';
|
||||||
|
}
|
||||||
|
|
||||||
|
getAppidV2 (): { appid: string; qua: string; } {
|
||||||
|
// 通过已有表 性能好
|
||||||
|
const appidTbale = AppidTable as unknown as QQAppidTableType;
|
||||||
|
const fullVersion = this.getFullQQVersion();
|
||||||
|
if (fullVersion) {
|
||||||
|
const data = appidTbale[fullVersion];
|
||||||
|
if (data) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 通过Major拉取 性能差
|
||||||
|
try {
|
||||||
|
const majorAppid = this.getAppidV2ByMajor(fullVersion);
|
||||||
|
if (majorAppid) {
|
||||||
|
this.context.logger.log('[QQ版本兼容性检测] 当前版本Appid未内置 通过Major获取 为了更好的性能请尝试更新NapCat');
|
||||||
|
return { appid: majorAppid, qua: this.getQUAFallback() };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.context.logger.log('[QQ版本兼容性检测] 通过Major 获取Appid异常 请检测NapCat/QQNT是否正常');
|
||||||
|
}
|
||||||
|
// 最终兜底为老版本
|
||||||
|
this.context.logger.log('[QQ版本兼容性检测] 获取Appid异常 请检测NapCat/QQNT是否正常');
|
||||||
|
this.context.logger.log(`[QQ版本兼容性检测] ${fullVersion} 版本兼容性不佳,可能会导致一些功能无法正常使用`);
|
||||||
|
return { appid: this.getAppIdFallback(), qua: this.getQUAFallback() };
|
||||||
|
}
|
||||||
|
|
||||||
|
getAppidV2ByMajor (QQVersion: string) {
|
||||||
|
const majorPath = getMajorPath(QQVersion);
|
||||||
|
const appid = parseAppidFromMajor(majorPath);
|
||||||
|
return appid;
|
||||||
|
}
|
||||||
|
}
|
||||||
115
packages/napcat-common/src/request.ts
Normal file
115
packages/napcat-common/src/request.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import https from 'node:https';
|
||||||
|
import http from 'node:http';
|
||||||
|
|
||||||
|
export class RequestUtil {
|
||||||
|
// 适用于获取服务器下发cookies时获取,仅GET
|
||||||
|
static async HttpsGetCookies (url: string): Promise<{ [key: string]: string }> {
|
||||||
|
const client = url.startsWith('https') ? https : http;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = client.get(url, (res) => {
|
||||||
|
const cookies: { [key: string]: string } = {};
|
||||||
|
|
||||||
|
res.on('data', () => { }); // Necessary to consume the stream
|
||||||
|
res.on('end', () => {
|
||||||
|
this.handleRedirect(res, url, cookies)
|
||||||
|
.then(resolve)
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.headers['set-cookie']) {
|
||||||
|
this.extractCookies(res.headers['set-cookie'], cookies);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (error: Error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async handleRedirect (res: http.IncomingMessage, url: string, cookies: { [key: string]: string }): Promise<{ [key: string]: string }> {
|
||||||
|
if (res.statusCode === 301 || res.statusCode === 302) {
|
||||||
|
if (res.headers.location) {
|
||||||
|
const redirectUrl = new URL(res.headers.location, url);
|
||||||
|
const redirectCookies = await this.HttpsGetCookies(redirectUrl.href);
|
||||||
|
// 合并重定向过程中的cookies
|
||||||
|
return { ...cookies, ...redirectCookies };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cookies;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static extractCookies (setCookieHeaders: string[], cookies: { [key: string]: string }) {
|
||||||
|
setCookieHeaders.forEach((cookie) => {
|
||||||
|
const parts = cookie.split(';')[0]?.split('=');
|
||||||
|
if (parts) {
|
||||||
|
const key = parts[0];
|
||||||
|
const value = parts[1];
|
||||||
|
if (key && value && key.length > 0 && value.length > 0) {
|
||||||
|
cookies[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 请求和回复都是JSON data传原始内容 自动编码json
|
||||||
|
static async HttpGetJson<T>(url: string, method: string = 'GET', data?: any, headers: {
|
||||||
|
[key: string]: string
|
||||||
|
} = {}, isJsonRet: boolean = true, isArgJson: boolean = true): Promise<T> {
|
||||||
|
const option = new URL(url);
|
||||||
|
const protocol = url.startsWith('https://') ? https : http;
|
||||||
|
const options = {
|
||||||
|
hostname: option.hostname,
|
||||||
|
port: option.port,
|
||||||
|
path: option.pathname + option.search,
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
};
|
||||||
|
// headers: {
|
||||||
|
// 'Content-Type': 'application/json',
|
||||||
|
// 'Content-Length': Buffer.byteLength(postData),
|
||||||
|
// },
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = protocol.request(options, (res: http.IncomingMessage) => {
|
||||||
|
let responseBody = '';
|
||||||
|
res.on('data', (chunk: string | Buffer) => {
|
||||||
|
responseBody += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
if (isJsonRet) {
|
||||||
|
const responseJson = JSON.parse(responseBody);
|
||||||
|
resolve(responseJson as T);
|
||||||
|
} else {
|
||||||
|
resolve(responseBody as T);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Unexpected status code: ${res.statusCode}`));
|
||||||
|
}
|
||||||
|
} catch (parseError: unknown) {
|
||||||
|
reject(new Error((parseError as Error).message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (error: Error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
||||||
|
if (isArgJson) {
|
||||||
|
req.write(JSON.stringify(data));
|
||||||
|
} else {
|
||||||
|
req.write(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 请求返回都是原始内容
|
||||||
|
static async HttpGetText (url: string, method: string = 'GET', data?: any, headers: { [key: string]: string } = {}) {
|
||||||
|
return this.HttpGetJson<string>(url, method, data, headers, false, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
packages/napcat-common/src/store.ts
Normal file
22
packages/napcat-common/src/store.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
class Store {
|
||||||
|
private store = new Map<string, any>();
|
||||||
|
|
||||||
|
set<T> (key: string, value: T, ttl?: number): void {
|
||||||
|
this.store.set(key, value);
|
||||||
|
if (ttl) {
|
||||||
|
setTimeout(() => this.store.delete(key), ttl * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get<T> (key: string): T | null {
|
||||||
|
return this.store.get(key) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
exists (...keys: string[]): number {
|
||||||
|
return keys.filter(key => this.store.has(key)).length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = new Store();
|
||||||
|
|
||||||
|
export default store;
|
||||||
@@ -6,11 +6,10 @@ let osName: string;
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
osName = os.hostname();
|
osName = os.hostname();
|
||||||
} catch (e) {
|
} catch {
|
||||||
osName = 'NapCat'; // + crypto.randomUUID().substring(0, 4);
|
osName = 'NapCat'; // + crypto.randomUUID().substring(0, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const homeDir = os.homedir();
|
const homeDir = os.homedir();
|
||||||
|
|
||||||
export const systemPlatform = os.platform();
|
export const systemPlatform = os.platform();
|
||||||
17
packages/napcat-common/src/types.ts
Normal file
17
packages/napcat-common/src/types.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// QQVersionType
|
||||||
|
export type QQPackageInfoType = {
|
||||||
|
version: string;
|
||||||
|
buildVersion: string;
|
||||||
|
platform: string;
|
||||||
|
eleArch: string;
|
||||||
|
};
|
||||||
|
export type QQVersionConfigType = {
|
||||||
|
baseVersion: string;
|
||||||
|
curVersion: string;
|
||||||
|
prevVersion: string;
|
||||||
|
onErrorVersions: Array<unknown>;
|
||||||
|
buildId: string;
|
||||||
|
};
|
||||||
|
export type QQAppidTableType = {
|
||||||
|
[key: string]: { appid: string, qua: string };
|
||||||
|
};
|
||||||
1
packages/napcat-common/src/version.ts
Normal file
1
packages/napcat-common/src/version.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const napCatVersion = import.meta.env.VITE_NAPCAT_VERSION || 'alpha';
|
||||||
File diff suppressed because one or more lines are too long
34
packages/napcat-common/src/worker.ts
Normal file
34
packages/napcat-common/src/worker.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Worker } from 'worker_threads';
|
||||||
|
|
||||||
|
export async function runTask<T, R> (workerScript: string, taskData: T): Promise<R> {
|
||||||
|
const worker = new Worker(workerScript);
|
||||||
|
try {
|
||||||
|
return await new Promise<R>((resolve, reject) => {
|
||||||
|
worker.on('message', (result: R) => {
|
||||||
|
if ((result as any)?.log) {
|
||||||
|
console.error('Worker Log--->:', (result as { log: string }).log);
|
||||||
|
}
|
||||||
|
if ((result as any)?.error) {
|
||||||
|
reject(new Error('Worker error: ' + (result as { error: string }).error));
|
||||||
|
}
|
||||||
|
resolve(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
worker.on('error', (error) => {
|
||||||
|
reject(new Error(`Worker error: ${error.message}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
worker.on('exit', (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`Worker stopped with exit code ${code}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
worker.postMessage(taskData);
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
throw new Error(`Failed to run task: ${(error as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
// Ensure the worker is terminated after the promise is settled
|
||||||
|
worker.terminate();
|
||||||
|
}
|
||||||
|
}
|
||||||
53
packages/napcat-common/tsconfig.json
Normal file
53
packages/napcat-common/tsconfig.json
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2021",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"lib": [
|
||||||
|
"ES2021"
|
||||||
|
],
|
||||||
|
"typeRoots": [
|
||||||
|
"./node_modules/@types"
|
||||||
|
],
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"noEmit": false,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictFunctionTypes": true,
|
||||||
|
"strictBindCallApply": true,
|
||||||
|
"alwaysStrict": true,
|
||||||
|
"noImplicitThis": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": false,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"useUnknownInCatchVariables": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/napcat-common/*": [
|
||||||
|
"src/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"skipDefaultLibCheck": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist"
|
||||||
|
]
|
||||||
|
}
|
||||||
27
packages/napcat-core/adapters/NodeIDependsAdapter.ts
Normal file
27
packages/napcat-core/adapters/NodeIDependsAdapter.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { MsfChangeReasonType, MsfStatusType } from '@/napcat-core/types/adapter';
|
||||||
|
|
||||||
|
export class NodeIDependsAdapter {
|
||||||
|
onMSFStatusChange (_statusType: MsfStatusType, _changeReasonType: MsfChangeReasonType) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
onMSFSsoError (_args: unknown) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
getGroupCode (_args: unknown) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// onSendMsfReply (_seq: string, _cmd: string, _uk1: number, _uk2: string, _rsp: {
|
||||||
|
// ssoRetCode: 0,
|
||||||
|
// trpcRetCode: 0,
|
||||||
|
// trpcFuncCode: 0,
|
||||||
|
// errorMsg: '',
|
||||||
|
// pbBuffer: Uint8Array,
|
||||||
|
// transInfoMap: Map<unknown, unknown>;
|
||||||
|
// }) {
|
||||||
|
|
||||||
|
// console.log('[NodeIDependsAdapter] onSendMsfReply', _seq, _cmd, _uk1, _uk2, Buffer.from(_rsp.pbBuffer).toString('hex'));
|
||||||
|
// }
|
||||||
|
}
|
||||||
10
packages/napcat-core/adapters/NodeIDispatcherAdapter.ts
Normal file
10
packages/napcat-core/adapters/NodeIDispatcherAdapter.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export class NodeIDispatcherAdapter {
|
||||||
|
dispatchRequest (_arg: unknown) {
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchCall (_arg: unknown) {
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchCallWithJson (_arg: unknown) {
|
||||||
|
}
|
||||||
|
}
|
||||||
25
packages/napcat-core/adapters/NodeIGlobalAdapter.ts
Normal file
25
packages/napcat-core/adapters/NodeIGlobalAdapter.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export class NodeIGlobalAdapter {
|
||||||
|
onLog (..._args: unknown[]) {
|
||||||
|
}
|
||||||
|
|
||||||
|
onGetSrvCalTime (..._args: unknown[]) {
|
||||||
|
}
|
||||||
|
|
||||||
|
onShowErrUITips (..._args: unknown[]) {
|
||||||
|
}
|
||||||
|
|
||||||
|
fixPicImgType (..._args: unknown[]) {
|
||||||
|
}
|
||||||
|
|
||||||
|
getAppSetting (..._args: unknown[]) {
|
||||||
|
}
|
||||||
|
|
||||||
|
onInstallFinished (..._args: unknown[]) {
|
||||||
|
}
|
||||||
|
|
||||||
|
onUpdateGeneralFlag (..._args: unknown[]) {
|
||||||
|
}
|
||||||
|
|
||||||
|
onGetOfflineMsg (..._args: unknown[]) {
|
||||||
|
}
|
||||||
|
}
|
||||||
60
packages/napcat-core/apis/collection.ts
Normal file
60
packages/napcat-core/apis/collection.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { InstanceContext, NapCatCore } from '@/napcat-core/index';
|
||||||
|
|
||||||
|
export class NTQQCollectionApi {
|
||||||
|
context: InstanceContext;
|
||||||
|
core: NapCatCore;
|
||||||
|
|
||||||
|
constructor (context: InstanceContext, core: NapCatCore) {
|
||||||
|
this.context = context;
|
||||||
|
this.core = core;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCollection (authorUin: string, authorUid: string, authorName: string, brief: string, rawData: string) {
|
||||||
|
return this.context.session.getCollectionService().createNewCollectionItem({
|
||||||
|
commInfo: {
|
||||||
|
bid: 1,
|
||||||
|
category: 2,
|
||||||
|
author: {
|
||||||
|
type: 1,
|
||||||
|
numId: authorUin,
|
||||||
|
strId: authorName,
|
||||||
|
groupId: '0',
|
||||||
|
groupName: '',
|
||||||
|
uid: authorUid,
|
||||||
|
},
|
||||||
|
customGroupId: '0',
|
||||||
|
createTime: Date.now().toString(),
|
||||||
|
sequence: Date.now().toString(),
|
||||||
|
},
|
||||||
|
richMediaSummary: {
|
||||||
|
originalUri: '',
|
||||||
|
publisher: '',
|
||||||
|
richMediaVersion: 0,
|
||||||
|
subTitle: '',
|
||||||
|
title: '',
|
||||||
|
brief,
|
||||||
|
picList: [],
|
||||||
|
contentType: 1,
|
||||||
|
},
|
||||||
|
richMediaContent: {
|
||||||
|
rawData,
|
||||||
|
bizDataList: [],
|
||||||
|
picList: [],
|
||||||
|
fileList: [],
|
||||||
|
},
|
||||||
|
need_share_url: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllCollection (category: number = 0, count: number = 50) {
|
||||||
|
return this.context.session.getCollectionService().getCollectionItemList({
|
||||||
|
category,
|
||||||
|
groupId: -1,
|
||||||
|
forceSync: true,
|
||||||
|
forceFromDb: false,
|
||||||
|
timeStamp: '0',
|
||||||
|
count,
|
||||||
|
searchDown: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
552
packages/napcat-core/apis/file.ts
Normal file
552
packages/napcat-core/apis/file.ts
Normal file
@@ -0,0 +1,552 @@
|
|||||||
|
import {
|
||||||
|
ChatType,
|
||||||
|
ElementType,
|
||||||
|
IMAGE_HTTP_HOST,
|
||||||
|
IMAGE_HTTP_HOST_NT,
|
||||||
|
Peer,
|
||||||
|
PicElement,
|
||||||
|
PicSubType,
|
||||||
|
RawMessage,
|
||||||
|
SendFileElement,
|
||||||
|
SendPicElement,
|
||||||
|
SendPttElement,
|
||||||
|
SendVideoElement,
|
||||||
|
} from '@/napcat-core/types';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
import fsPromises from 'fs/promises';
|
||||||
|
import { InstanceContext, NapCatCore, SearchResultItem } from '@/napcat-core/index';
|
||||||
|
import { fileTypeFromFile } from 'file-type';
|
||||||
|
import { RkeyManager } from '@/napcat-core/helper/rkey';
|
||||||
|
import { calculateFileMD5 } from 'napcat-common/src/file';
|
||||||
|
import pathLib from 'node:path';
|
||||||
|
import { defaultVideoThumbB64 } from 'napcat-common/src/video';
|
||||||
|
import { encodeSilk } from 'napcat-common/src/audio';
|
||||||
|
import { SendMessageContext } from 'napcat-onebot/api/msg';
|
||||||
|
import { getFileTypeForSendType } from '../helper/msg';
|
||||||
|
import { FFmpegService } from 'napcat-common/src/ffmpeg';
|
||||||
|
import { rkeyDataType } from '../types/file';
|
||||||
|
import { NapProtoMsg } from '@napneko/nap-proto-core';
|
||||||
|
import { FileId } from '../packet/transformer/proto/misc/fileid';
|
||||||
|
import { imageSizeFallBack } from 'napcat-image-size';
|
||||||
|
|
||||||
|
export class NTQQFileApi {
|
||||||
|
context: InstanceContext;
|
||||||
|
core: NapCatCore;
|
||||||
|
rkeyManager: RkeyManager;
|
||||||
|
packetRkey: Array<{ rkey: string; time: number; type: number; ttl: bigint; }> | undefined;
|
||||||
|
private fetchRkeyFailures: number = 0;
|
||||||
|
private readonly MAX_RKEY_FAILURES: number = 8;
|
||||||
|
|
||||||
|
constructor (context: InstanceContext, core: NapCatCore) {
|
||||||
|
this.context = context;
|
||||||
|
this.core = core;
|
||||||
|
this.rkeyManager = new RkeyManager([
|
||||||
|
'http://ss.xingzhige.com/music_card/rkey',
|
||||||
|
'https://secret-service.bietiaop.com/rkeys',
|
||||||
|
],
|
||||||
|
this.context.logger
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchRkeyWithRetry () {
|
||||||
|
if (this.fetchRkeyFailures >= this.MAX_RKEY_FAILURES) {
|
||||||
|
throw new Error('Native.FetchRkey 已被禁用');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const ret = await this.core.apis.PacketApi.pkt.operation.FetchRkey();
|
||||||
|
this.fetchRkeyFailures = 0; // Reset failures on success
|
||||||
|
return ret;
|
||||||
|
} catch (error) {
|
||||||
|
this.fetchRkeyFailures++;
|
||||||
|
this.context.logger.logError('FetchRkey 失败', (error as Error).message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFileUrl (chatType: ChatType, peer: string, fileUUID?: string, file10MMd5?: string | undefined, timeout: number = 5000) {
|
||||||
|
if (this.core.apis.PacketApi.packetStatus) {
|
||||||
|
try {
|
||||||
|
if (chatType === ChatType.KCHATTYPEGROUP && fileUUID) {
|
||||||
|
return this.core.apis.PacketApi.pkt.operation.GetGroupFileUrl(+peer, fileUUID, timeout);
|
||||||
|
} else if (file10MMd5 && fileUUID) {
|
||||||
|
return this.core.apis.PacketApi.pkt.operation.GetPrivateFileUrl(peer, fileUUID, file10MMd5, timeout);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.context.logger.logError('获取文件URL失败', (error as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('fileUUID or file10MMd5 is undefined');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPttUrl (peer: string, fileUUID?: string, timeout: number = 5000) {
|
||||||
|
if (this.core.apis.PacketApi.packetStatus && fileUUID) {
|
||||||
|
const appid = new NapProtoMsg(FileId).decode(Buffer.from(fileUUID.replaceAll('-', '+').replaceAll('_', '/'), 'base64')).appid;
|
||||||
|
try {
|
||||||
|
if (appid && appid === 1403) {
|
||||||
|
return this.core.apis.PacketApi.pkt.operation.GetGroupPttUrl(+peer, {
|
||||||
|
fileUuid: fileUUID,
|
||||||
|
storeId: 1,
|
||||||
|
uploadTime: 0,
|
||||||
|
ttl: 0,
|
||||||
|
subType: 0,
|
||||||
|
}, timeout);
|
||||||
|
} else if (fileUUID) {
|
||||||
|
return this.core.apis.PacketApi.pkt.operation.GetPttUrl(peer, {
|
||||||
|
fileUuid: fileUUID,
|
||||||
|
storeId: 1,
|
||||||
|
uploadTime: 0,
|
||||||
|
ttl: 0,
|
||||||
|
subType: 0,
|
||||||
|
}, timeout);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.context.logger.logError('获取文件URL失败', (error as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('packet cant get ptt url');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVideoUrlPacket (peer: string, fileUUID?: string, timeout: number = 5000) {
|
||||||
|
if (this.core.apis.PacketApi.packetStatus && fileUUID) {
|
||||||
|
const appid = new NapProtoMsg(FileId).decode(Buffer.from(fileUUID.replaceAll('-', '+').replaceAll('_', '/'), 'base64')).appid;
|
||||||
|
try {
|
||||||
|
if (appid && appid === 1415) {
|
||||||
|
return this.core.apis.PacketApi.pkt.operation.GetGroupVideoUrl(+peer, {
|
||||||
|
fileUuid: fileUUID,
|
||||||
|
storeId: 1,
|
||||||
|
uploadTime: 0,
|
||||||
|
ttl: 0,
|
||||||
|
subType: 0,
|
||||||
|
}, timeout);
|
||||||
|
} else if (fileUUID) {
|
||||||
|
return this.core.apis.PacketApi.pkt.operation.GetVideoUrl(peer, {
|
||||||
|
fileUuid: fileUUID,
|
||||||
|
storeId: 1,
|
||||||
|
uploadTime: 0,
|
||||||
|
ttl: 0,
|
||||||
|
subType: 0,
|
||||||
|
}, timeout);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.context.logger.logError('获取文件URL失败', (error as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('packet cant get video url');
|
||||||
|
}
|
||||||
|
|
||||||
|
async copyFile (filePath: string, destPath: string) {
|
||||||
|
await this.core.util.copyFile(filePath, destPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFileSize (filePath: string): Promise<number> {
|
||||||
|
return await this.core.util.getFileSize(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVideoUrl (peer: Peer, msgId: string, elementId: string) {
|
||||||
|
return (await this.context.session.getRichMediaService().getVideoPlayUrlV2(peer, msgId, elementId, 0, {
|
||||||
|
downSourceType: 1,
|
||||||
|
triggerType: 1,
|
||||||
|
})).urlResult.domainUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadFile (filePath: string, elementType: ElementType = ElementType.PIC, elementSubType: number = 0) {
|
||||||
|
const fileMd5 = await calculateFileMD5(filePath);
|
||||||
|
const extOrEmpty = await fileTypeFromFile(filePath).then(e => e?.ext ?? '').catch(() => '');
|
||||||
|
const ext = extOrEmpty ? `.${extOrEmpty}` : '';
|
||||||
|
let fileName = `${path.basename(filePath)}`;
|
||||||
|
if (fileName.indexOf('.') === -1) {
|
||||||
|
fileName += ext;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaPath = this.context.session.getMsgService().getRichMediaFilePathForGuild({
|
||||||
|
md5HexStr: fileMd5,
|
||||||
|
fileName,
|
||||||
|
elementType,
|
||||||
|
elementSubType,
|
||||||
|
thumbSize: 0,
|
||||||
|
needCreate: true,
|
||||||
|
downloadType: 1,
|
||||||
|
file_uuid: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.copyFile(filePath, mediaPath);
|
||||||
|
const fileSize = await this.getFileSize(filePath);
|
||||||
|
return {
|
||||||
|
md5: fileMd5,
|
||||||
|
fileName,
|
||||||
|
path: mediaPath,
|
||||||
|
fileSize,
|
||||||
|
ext,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createValidSendFileElement (context: SendMessageContext, filePath: string, fileName: string = '', folderId: string = ''): Promise<SendFileElement> {
|
||||||
|
const {
|
||||||
|
fileName: _fileName,
|
||||||
|
path,
|
||||||
|
fileSize,
|
||||||
|
} = await this.core.apis.FileApi.uploadFile(filePath, ElementType.FILE);
|
||||||
|
if (fileSize === 0) {
|
||||||
|
throw new Error('文件异常,大小为0');
|
||||||
|
}
|
||||||
|
context.deleteAfterSentFiles.push(path);
|
||||||
|
return {
|
||||||
|
elementType: ElementType.FILE,
|
||||||
|
elementId: '',
|
||||||
|
fileElement: {
|
||||||
|
fileName: fileName || _fileName,
|
||||||
|
folderId,
|
||||||
|
filePath: path,
|
||||||
|
fileSize: fileSize.toString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createValidSendPicElement (context: SendMessageContext, picPath: string, summary: string = '', subType: PicSubType = 0): Promise<SendPicElement> {
|
||||||
|
const { md5, fileName, path, fileSize } = await this.core.apis.FileApi.uploadFile(picPath, ElementType.PIC, subType);
|
||||||
|
if (fileSize === 0) {
|
||||||
|
throw new Error('文件异常,大小为0');
|
||||||
|
}
|
||||||
|
const imageSize = await imageSizeFallBack(picPath);
|
||||||
|
context.deleteAfterSentFiles.push(path);
|
||||||
|
return {
|
||||||
|
elementType: ElementType.PIC,
|
||||||
|
elementId: '',
|
||||||
|
picElement: {
|
||||||
|
md5HexStr: md5,
|
||||||
|
fileSize: fileSize.toString(),
|
||||||
|
picWidth: imageSize.width,
|
||||||
|
picHeight: imageSize.height,
|
||||||
|
fileName,
|
||||||
|
sourcePath: path,
|
||||||
|
original: true,
|
||||||
|
picType: await getFileTypeForSendType(picPath),
|
||||||
|
picSubType: subType,
|
||||||
|
fileUuid: '',
|
||||||
|
fileSubId: '',
|
||||||
|
thumbFileSize: 0,
|
||||||
|
summary,
|
||||||
|
} as PicElement,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createValidSendVideoElement (context: SendMessageContext, filePath: string, fileName: string = '', _diyThumbPath: string = ''): Promise<SendVideoElement> {
|
||||||
|
let videoInfo = {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
time: 15,
|
||||||
|
format: 'mp4',
|
||||||
|
size: 0,
|
||||||
|
filePath,
|
||||||
|
};
|
||||||
|
let fileExt = 'mp4';
|
||||||
|
try {
|
||||||
|
const tempExt = (await fileTypeFromFile(filePath))?.ext;
|
||||||
|
if (tempExt) fileExt = tempExt;
|
||||||
|
} catch (e) {
|
||||||
|
this.context.logger.logError('获取文件类型失败', e);
|
||||||
|
}
|
||||||
|
const newFilePath = `${filePath}.${fileExt}`;
|
||||||
|
fs.copyFileSync(filePath, newFilePath);
|
||||||
|
context.deleteAfterSentFiles.push(newFilePath);
|
||||||
|
filePath = newFilePath;
|
||||||
|
|
||||||
|
const { fileName: _fileName, path, fileSize, md5 } = await this.core.apis.FileApi.uploadFile(filePath, ElementType.VIDEO);
|
||||||
|
context.deleteAfterSentFiles.push(path);
|
||||||
|
if (fileSize === 0) {
|
||||||
|
throw new Error('文件异常,大小为0');
|
||||||
|
}
|
||||||
|
const thumbDir = path.replace(`${pathLib.sep}Ori${pathLib.sep}`, `${pathLib.sep}Thumb${pathLib.sep}`);
|
||||||
|
fs.mkdirSync(pathLib.dirname(thumbDir), { recursive: true });
|
||||||
|
const thumbPath = pathLib.join(pathLib.dirname(thumbDir), `${md5}_0.png`);
|
||||||
|
try {
|
||||||
|
videoInfo = await FFmpegService.getVideoInfo(filePath, thumbPath);
|
||||||
|
if (!fs.existsSync(thumbPath)) {
|
||||||
|
this.context.logger.logError('获取视频缩略图失败', new Error('缩略图不存在'));
|
||||||
|
throw new Error('获取视频缩略图失败');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.context.logger.logError('获取视频信息失败', e);
|
||||||
|
fs.writeFileSync(thumbPath, Buffer.from(defaultVideoThumbB64, 'base64'));
|
||||||
|
}
|
||||||
|
if (_diyThumbPath) {
|
||||||
|
try {
|
||||||
|
await this.copyFile(_diyThumbPath, thumbPath);
|
||||||
|
} catch (e) {
|
||||||
|
this.context.logger.logError('复制自定义缩略图失败', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context.deleteAfterSentFiles.push(thumbPath);
|
||||||
|
const thumbSize = (await fsPromises.stat(thumbPath)).size;
|
||||||
|
const thumbMd5 = await calculateFileMD5(thumbPath);
|
||||||
|
context.deleteAfterSentFiles.push(thumbPath);
|
||||||
|
|
||||||
|
const uploadName = (fileName || _fileName).toLocaleLowerCase().endsWith(`.${fileExt.toLocaleLowerCase()}`) ? (fileName || _fileName) : `${fileName || _fileName}.${fileExt}`;
|
||||||
|
return {
|
||||||
|
elementType: ElementType.VIDEO,
|
||||||
|
elementId: '',
|
||||||
|
videoElement: {
|
||||||
|
fileName: uploadName,
|
||||||
|
filePath: path,
|
||||||
|
videoMd5: md5,
|
||||||
|
thumbMd5,
|
||||||
|
fileTime: videoInfo.time,
|
||||||
|
thumbPath: new Map([[0, thumbPath]]),
|
||||||
|
thumbSize,
|
||||||
|
thumbWidth: videoInfo.width,
|
||||||
|
thumbHeight: videoInfo.height,
|
||||||
|
fileSize: fileSize.toString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createValidSendPttElement (_context: SendMessageContext, pttPath: string): Promise<SendPttElement> {
|
||||||
|
const { converted, path: silkPath, duration } = await encodeSilk(pttPath, this.core.NapCatTempPath, this.core.context.logger);
|
||||||
|
if (!silkPath) {
|
||||||
|
throw new Error('语音转换失败, 请检查语音文件是否正常');
|
||||||
|
}
|
||||||
|
const { md5, fileName, path, fileSize } = await this.core.apis.FileApi.uploadFile(silkPath, ElementType.PTT);
|
||||||
|
if (fileSize === 0) {
|
||||||
|
throw new Error('文件异常,大小为0');
|
||||||
|
}
|
||||||
|
if (converted) {
|
||||||
|
fsPromises.unlink(silkPath).then().catch((e) => this.context.logger.logError('删除临时文件失败', e));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
elementType: ElementType.PTT,
|
||||||
|
elementId: '',
|
||||||
|
pttElement: {
|
||||||
|
fileName,
|
||||||
|
filePath: path,
|
||||||
|
md5HexStr: md5,
|
||||||
|
fileSize: fileSize.toString(),
|
||||||
|
duration: duration ?? 1,
|
||||||
|
formatType: 1,
|
||||||
|
voiceType: 1,
|
||||||
|
voiceChangeType: 0,
|
||||||
|
canConvert2Text: true,
|
||||||
|
waveAmplitudes: [
|
||||||
|
0, 18, 9, 23, 16, 17, 16, 15, 44, 17, 24, 20, 14, 15, 17,
|
||||||
|
],
|
||||||
|
fileSubId: '',
|
||||||
|
playState: 1,
|
||||||
|
autoConvertText: 0,
|
||||||
|
storeID: 0,
|
||||||
|
otherBusinessInfo: {
|
||||||
|
aiVoiceType: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadFileForModelId (peer: Peer, modelId: string, unknown: string, timeout = 1000 * 60 * 2) {
|
||||||
|
const [, fileTransNotifyInfo] = await this.core.eventWrapper.callNormalEventV2(
|
||||||
|
'NodeIKernelRichMediaService/downloadFileForModelId',
|
||||||
|
'NodeIKernelMsgListener/onRichMediaDownloadComplete',
|
||||||
|
[peer, [modelId], unknown],
|
||||||
|
() => true,
|
||||||
|
(arg) => arg?.commonFileInfo?.fileModelId === modelId,
|
||||||
|
1,
|
||||||
|
timeout
|
||||||
|
);
|
||||||
|
return fileTransNotifyInfo.filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadRawMsgMedia (msg: RawMessage[]) {
|
||||||
|
const res = await Promise.all(
|
||||||
|
msg.map(m =>
|
||||||
|
Promise.all(
|
||||||
|
m.elements
|
||||||
|
.filter(element =>
|
||||||
|
element.elementType === ElementType.PIC ||
|
||||||
|
element.elementType === ElementType.VIDEO ||
|
||||||
|
element.elementType === ElementType.PTT ||
|
||||||
|
element.elementType === ElementType.FILE
|
||||||
|
)
|
||||||
|
.map(element =>
|
||||||
|
this.downloadMedia(m.msgId, m.chatType, m.peerUid, element.elementId, '', '', 1000 * 60 * 2, true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
msg.forEach((m, msgIndex) => {
|
||||||
|
const elementResults = res[msgIndex];
|
||||||
|
let elementIndex = 0;
|
||||||
|
m.elements.forEach(element => {
|
||||||
|
if (
|
||||||
|
element.elementType === ElementType.PIC ||
|
||||||
|
element.elementType === ElementType.VIDEO ||
|
||||||
|
element.elementType === ElementType.PTT ||
|
||||||
|
element.elementType === ElementType.FILE
|
||||||
|
) {
|
||||||
|
switch (element.elementType) {
|
||||||
|
case ElementType.PIC:
|
||||||
|
element.picElement!.sourcePath = elementResults?.[elementIndex] ?? '';
|
||||||
|
break;
|
||||||
|
case ElementType.VIDEO:
|
||||||
|
element.videoElement!.filePath = elementResults?.[elementIndex] ?? '';
|
||||||
|
break;
|
||||||
|
case ElementType.PTT:
|
||||||
|
element.pttElement!.filePath = elementResults?.[elementIndex] ?? '';
|
||||||
|
break;
|
||||||
|
case ElementType.FILE:
|
||||||
|
element.fileElement!.filePath = elementResults?.[elementIndex] ?? '';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
elementIndex++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return res.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadMedia (msgId: string, chatType: ChatType, peerUid: string, elementId: string, thumbPath: string, sourcePath: string, timeout = 1000 * 60 * 2, force: boolean = false) {
|
||||||
|
// 用于下载文件
|
||||||
|
if (sourcePath && fs.existsSync(sourcePath)) {
|
||||||
|
if (force) {
|
||||||
|
try {
|
||||||
|
await fsPromises.unlink(sourcePath);
|
||||||
|
} catch {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return sourcePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const [, completeRetData] = await this.core.eventWrapper.callNormalEventV2(
|
||||||
|
'NodeIKernelMsgService/downloadRichMedia',
|
||||||
|
'NodeIKernelMsgListener/onRichMediaDownloadComplete',
|
||||||
|
[{
|
||||||
|
fileModelId: '0',
|
||||||
|
downSourceType: 0,
|
||||||
|
downloadSourceType: 0,
|
||||||
|
triggerType: 1,
|
||||||
|
msgId,
|
||||||
|
chatType,
|
||||||
|
peerUid,
|
||||||
|
elementId,
|
||||||
|
thumbSize: 0,
|
||||||
|
downloadType: 1,
|
||||||
|
filePath: thumbPath,
|
||||||
|
}],
|
||||||
|
() => true,
|
||||||
|
(arg) => arg.msgElementId === elementId && arg.msgId === msgId,
|
||||||
|
1,
|
||||||
|
timeout
|
||||||
|
);
|
||||||
|
return completeRetData.filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchForFile (keys: string[]): Promise<SearchResultItem | undefined> {
|
||||||
|
const randomResultId = 100000 + Math.floor(Math.random() * 10000);
|
||||||
|
let searchId = 0;
|
||||||
|
const [, searchResult] = await this.core.eventWrapper.callNormalEventV2(
|
||||||
|
'NodeIKernelFileAssistantService/searchFile',
|
||||||
|
'NodeIKernelFileAssistantListener/onFileSearch',
|
||||||
|
[
|
||||||
|
keys,
|
||||||
|
{ resultType: 2, pageLimit: 1 },
|
||||||
|
randomResultId,
|
||||||
|
],
|
||||||
|
(ret) => {
|
||||||
|
searchId = ret;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
result => result.searchId === searchId && result.resultId === randomResultId
|
||||||
|
);
|
||||||
|
return searchResult.resultItems[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadFileById (
|
||||||
|
fileId: string,
|
||||||
|
fileSize: number = 1024576,
|
||||||
|
estimatedTime: number = (fileSize * 1000 / 1024576) + 5000
|
||||||
|
) {
|
||||||
|
const [, fileData] = await this.core.eventWrapper.callNormalEventV2(
|
||||||
|
'NodeIKernelFileAssistantService/downloadFile',
|
||||||
|
'NodeIKernelFileAssistantListener/onFileStatusChanged',
|
||||||
|
[[fileId]],
|
||||||
|
ret => ret.result === 0,
|
||||||
|
status => status.fileStatus === 2 && status.fileProgress === '0',
|
||||||
|
1,
|
||||||
|
estimatedTime // estimate 1MB/s
|
||||||
|
);
|
||||||
|
return fileData.filePath!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getImageUrl (element: PicElement): Promise<string> {
|
||||||
|
if (!element) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const url: string = element.originImageUrl ?? '';
|
||||||
|
|
||||||
|
const md5HexStr = element.md5HexStr;
|
||||||
|
const fileMd5 = element.md5HexStr;
|
||||||
|
const parsedUrl = new URL(IMAGE_HTTP_HOST + url);
|
||||||
|
const imageAppid = parsedUrl.searchParams.get('appid');
|
||||||
|
const isNTV2 = imageAppid && ['1406', '1407'].includes(imageAppid);
|
||||||
|
const imageFileId = parsedUrl.searchParams.get('fileid');
|
||||||
|
if (url && isNTV2 && imageFileId) {
|
||||||
|
const rkeyData = await this.getRkeyData();
|
||||||
|
return this.getImageUrlFromParsedUrl(imageFileId, imageAppid, rkeyData);
|
||||||
|
}
|
||||||
|
return this.getImageUrlFromMd5(fileMd5, md5HexStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getRkeyData () {
|
||||||
|
const rkeyData: rkeyDataType = {
|
||||||
|
private_rkey: 'CAQSKAB6JWENi5LM_xp9vumLbuThJSaYf-yzMrbZsuq7Uz2qEc3Rbib9LP4',
|
||||||
|
group_rkey: 'CAQSKAB6JWENi5LM_xp9vumLbuThJSaYf-yzMrbZsuq7Uz2qffcqm614gds',
|
||||||
|
online_rkey: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (this.core.apis.PacketApi.packetStatus) {
|
||||||
|
const rkey_expired_private = !this.packetRkey || (this.packetRkey[0] && this.packetRkey[0].time + Number(this.packetRkey[0].ttl) < Date.now() / 1000);
|
||||||
|
const rkey_expired_group = !this.packetRkey || (this.packetRkey[0] && this.packetRkey[0].time + Number(this.packetRkey[0].ttl) < Date.now() / 1000);
|
||||||
|
if (rkey_expired_private || rkey_expired_group) {
|
||||||
|
this.packetRkey = await this.fetchRkeyWithRetry();
|
||||||
|
}
|
||||||
|
if (this.packetRkey && this.packetRkey.length > 0) {
|
||||||
|
rkeyData.group_rkey = this.packetRkey[1]?.rkey.slice(6) ?? '';
|
||||||
|
rkeyData.private_rkey = this.packetRkey[0]?.rkey.slice(6) ?? '';
|
||||||
|
rkeyData.online_rkey = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
this.context.logger.logDebug('获取native.rkey失败', (error as Error).message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rkeyData.online_rkey) {
|
||||||
|
try {
|
||||||
|
const tempRkeyData = await this.rkeyManager.getRkey();
|
||||||
|
rkeyData.group_rkey = tempRkeyData.group_rkey;
|
||||||
|
rkeyData.private_rkey = tempRkeyData.private_rkey;
|
||||||
|
rkeyData.online_rkey = tempRkeyData.expired_time > Date.now() / 1000;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
this.context.logger.logDebug('获取remote.rkey失败', (error as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 进行 fallback.rkey 模式
|
||||||
|
return rkeyData;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getImageUrlFromParsedUrl (imageFileId: string, appid: string, rkeyData: rkeyDataType): string {
|
||||||
|
const rkey = appid === '1406' ? rkeyData.private_rkey : rkeyData.group_rkey;
|
||||||
|
if (rkeyData.online_rkey) {
|
||||||
|
return IMAGE_HTTP_HOST_NT + `/download?appid=${appid}&fileid=${imageFileId}&rkey=${rkey}`;
|
||||||
|
}
|
||||||
|
return IMAGE_HTTP_HOST + `/download?appid=${appid}&fileid=${imageFileId}&rkey=${rkey}&spec=0`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getImageUrlFromMd5 (fileMd5: string | undefined, md5HexStr: string | undefined): string {
|
||||||
|
if (fileMd5 || md5HexStr) {
|
||||||
|
return `${IMAGE_HTTP_HOST}/gchatpic_new/0/0-0-${(fileMd5 ?? md5HexStr ?? '').toUpperCase()}/0`;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.context.logger.logDebug('图片url获取失败', { fileMd5, md5HexStr });
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
133
packages/napcat-core/apis/friend.ts
Normal file
133
packages/napcat-core/apis/friend.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { FriendRequest, FriendV2 } from '@/napcat-core/types';
|
||||||
|
import { BuddyListReqType, InstanceContext, NapCatCore } from '@/napcat-core/index';
|
||||||
|
import { LimitedHashTable } from 'napcat-common/src/message-unique';
|
||||||
|
|
||||||
|
export class NTQQFriendApi {
|
||||||
|
context: InstanceContext;
|
||||||
|
core: NapCatCore;
|
||||||
|
|
||||||
|
constructor (context: InstanceContext, core: NapCatCore) {
|
||||||
|
this.context = context;
|
||||||
|
this.core = core;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setBuddyRemark (uid: string, remark: string) {
|
||||||
|
return this.context.session.getBuddyService().setBuddyRemark({ uid, remark });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBuddyV2SimpleInfoMap () {
|
||||||
|
const buddyService = this.context.session.getBuddyService();
|
||||||
|
let uids: string[] = [];
|
||||||
|
if (this.core.context.basicInfoWrapper.requireMinNTQQBuild('41679')) {
|
||||||
|
const buddyListV2NT = await buddyService.getBuddyListV2('0', true, BuddyListReqType.KNOMAL);
|
||||||
|
uids = buddyListV2NT.data.flatMap(item => item.buddyUids);
|
||||||
|
} else {
|
||||||
|
const buddyListV2 = await buddyService.getBuddyListV2('0', BuddyListReqType.KNOMAL);
|
||||||
|
uids = buddyListV2.data.flatMap(item => item.buddyUids);
|
||||||
|
}
|
||||||
|
return await this.core.eventWrapper.callNoListenerEvent(
|
||||||
|
'NodeIKernelProfileService/getCoreAndBaseInfo',
|
||||||
|
'nodeStore',
|
||||||
|
uids
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBuddy (): Promise<FriendV2[]> {
|
||||||
|
return Array.from((await this.getBuddyV2SimpleInfoMap()).values());
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBuddyIdMap (): Promise<LimitedHashTable<string, string>> {
|
||||||
|
const retMap: LimitedHashTable<string, string> = new LimitedHashTable<string, string>(5000);
|
||||||
|
const data = await this.getBuddyV2SimpleInfoMap();
|
||||||
|
data.forEach((value) => retMap.set(value.uin!, value.uid!));
|
||||||
|
return retMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delBuudy (uid: string, tempBlock = false, tempBothDel = false) {
|
||||||
|
return this.context.session.getBuddyService().delBuddy({
|
||||||
|
friendUid: uid,
|
||||||
|
tempBlock,
|
||||||
|
tempBothDel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBuddyV2ExWithCate () {
|
||||||
|
const buddyService = this.context.session.getBuddyService();
|
||||||
|
let uids: string[] = [];
|
||||||
|
let buddyListV2: Awaited<ReturnType<typeof buddyService.getBuddyListV2>>['data'];
|
||||||
|
if (this.core.context.basicInfoWrapper.requireMinNTQQBuild('41679')) {
|
||||||
|
buddyListV2 = (await buddyService.getBuddyListV2('0', true, BuddyListReqType.KNOMAL)).data;
|
||||||
|
uids = buddyListV2.flatMap(item => item.buddyUids);
|
||||||
|
} else {
|
||||||
|
buddyListV2 = (await buddyService.getBuddyListV2('0', BuddyListReqType.KNOMAL)).data;
|
||||||
|
uids = buddyListV2.flatMap(item => item.buddyUids);
|
||||||
|
}
|
||||||
|
const data = await this.core.eventWrapper.callNoListenerEvent(
|
||||||
|
'NodeIKernelProfileService/getCoreAndBaseInfo',
|
||||||
|
'nodeStore',
|
||||||
|
uids
|
||||||
|
);
|
||||||
|
return buddyListV2.map(category => ({
|
||||||
|
categoryId: category.categoryId,
|
||||||
|
categorySortId: category.categorySortId,
|
||||||
|
categoryName: category.categroyName,
|
||||||
|
categoryMbCount: category.categroyMbCount,
|
||||||
|
onlineCount: category.onlineCount,
|
||||||
|
buddyList: category.buddyUids.map(uid => data.get(uid)).filter(value => !!value),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async isBuddy (uid: string) {
|
||||||
|
return this.context.session.getBuddyService().isBuddy(uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearBuddyReqUnreadCnt () {
|
||||||
|
return this.context.session.getBuddyService().clearBuddyReqUnreadCnt();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBuddyReq () {
|
||||||
|
const [, ret] = await this.core.eventWrapper.callNormalEventV2(
|
||||||
|
'NodeIKernelBuddyService/getBuddyReq',
|
||||||
|
'NodeIKernelBuddyListener/onBuddyReqChange',
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleFriendRequest (notify: FriendRequest, accept: boolean) {
|
||||||
|
this.context.session.getBuddyService()?.approvalFriendRequest({
|
||||||
|
friendUid: notify.friendUid,
|
||||||
|
reqTime: notify.reqTime,
|
||||||
|
accept,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDoubtFriendRequest (friendUid: string, str1: string = '', str2: string = '') {
|
||||||
|
this.context.session.getBuddyService().approvalDoubtBuddyReq(friendUid, str1, str2);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDoubtFriendRequest (count: number) {
|
||||||
|
const date = Date.now().toString();
|
||||||
|
const [, ret] = await this.core.eventWrapper.callNormalEventV2(
|
||||||
|
'NodeIKernelBuddyService/getDoubtBuddyReq',
|
||||||
|
'NodeIKernelBuddyListener/onDoubtBuddyReqChange',
|
||||||
|
[date, count, ''],
|
||||||
|
() => true,
|
||||||
|
(data) => data.reqId === date
|
||||||
|
);
|
||||||
|
const requests = Promise.all(ret.doubtList.map(async (item) => {
|
||||||
|
return {
|
||||||
|
flag: item.uid, // 注意强制String 非isNumeric 不遵守则不符合设计
|
||||||
|
uin: await this.core.apis.UserApi.getUinByUidV2(item.uid) ?? 0, // 信息字段
|
||||||
|
nick: item.nick, // 信息字段 这个不是nickname 可能是来源的群内的昵称
|
||||||
|
source: item.source, // 信息字段
|
||||||
|
reason: item.reason, // 信息字段
|
||||||
|
msg: item.msg, // 信息字段
|
||||||
|
group_code: item.groupCode, // 信息字段
|
||||||
|
time: item.reqTime, // 信息字段
|
||||||
|
type: 'doubt', // 保留字段
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
return requests;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user