mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-08 15:08:44 +00:00
Compare commits
1137 Commits
v1.5.1
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75c754baf0 | ||
|
|
5b6117ec28 | ||
|
|
33188485b7 | ||
|
|
08bd5e5435 | ||
|
|
714827a36d | ||
|
|
7a51d8cf64 | ||
|
|
902d2c9c74 | ||
|
|
dcad30bc39 | ||
|
|
73ee524d1f | ||
|
|
4af8334f50 | ||
|
|
43fed79204 | ||
|
|
b356814ebb | ||
|
|
0acad9927a | ||
|
|
5bc46fadfc | ||
|
|
3090306394 | ||
|
|
ec95a16c7a | ||
|
|
45d3f735a9 | ||
|
|
0734b64cc8 | ||
|
|
70ad21cb46 | ||
|
|
9181490d0f | ||
|
|
01fc5cd1a0 | ||
|
|
b12ffff310 | ||
|
|
835359edf8 | ||
|
|
88817cf95e | ||
|
|
88d41f6857 | ||
|
|
ec9c1bbbba | ||
|
|
f9313392f1 | ||
|
|
2db8af3668 | ||
|
|
c56ba6e0a1 | ||
|
|
f1dcc84991 | ||
|
|
e8aaae5616 | ||
|
|
45deb99e3d | ||
|
|
28f6f966b9 | ||
|
|
7bd569feca | ||
|
|
056f2c1833 | ||
|
|
b821d370f9 | ||
|
|
60248b28f8 | ||
|
|
d128bedffa | ||
|
|
489b545965 | ||
|
|
36533d07f8 | ||
|
|
625e4f8e6a | ||
|
|
c4774e1ce1 | ||
|
|
e1682f99d2 | ||
|
|
a23461bfce | ||
|
|
73fc36e63a | ||
|
|
4beddb7a62 | ||
|
|
b130165831 | ||
|
|
9adffc3cd7 | ||
|
|
a52619c4d5 | ||
|
|
cf40d3ad63 | ||
|
|
f7f6252d0b | ||
|
|
14a2475fb1 | ||
|
|
76a55998c2 | ||
|
|
1ec8d54e96 | ||
|
|
62395b275d | ||
|
|
57fad47f27 | ||
|
|
20c5381211 | ||
|
|
b8cd9a8c38 | ||
|
|
4335abe31b | ||
|
|
e5f7b54a7b | ||
|
|
ea1ef03b98 | ||
|
|
8d374d4f49 | ||
|
|
f910e17e53 | ||
|
|
35a76aa04f | ||
|
|
5fce21d799 | ||
|
|
a32696ee13 | ||
|
|
b573baec80 | ||
|
|
0d4feceffc | ||
|
|
92abe73f0a | ||
|
|
7fa26b0716 | ||
|
|
dc49bf3877 | ||
|
|
d825dada59 | ||
|
|
74a08732fe | ||
|
|
7033a77d71 | ||
|
|
3b26e0c014 | ||
|
|
81ec51be33 | ||
|
|
fbecda9f1e | ||
|
|
b6950d4027 | ||
|
|
f31327b528 | ||
|
|
c4c7df2608 | ||
|
|
b8bf29277a | ||
|
|
867f85e8f2 | ||
|
|
7fb98d764a | ||
|
|
792621d982 | ||
|
|
337fe21d18 | ||
|
|
c92b50b6ec | ||
|
|
f83117df20 | ||
|
|
b7b7260838 | ||
|
|
dd960d30ff | ||
|
|
89f3ec57f5 | ||
|
|
95f1e73a39 | ||
|
|
aa029fe113 | ||
|
|
5971757a28 | ||
|
|
1e16ea887b | ||
|
|
837f15c5e8 | ||
|
|
f71ff7392c | ||
|
|
97ba95e2be | ||
|
|
6aae23180f | ||
|
|
49e82e43e4 | ||
|
|
301c490893 | ||
|
|
93a9df48f4 | ||
|
|
209b91bfef | ||
|
|
1049f55118 | ||
|
|
ba7785a359 | ||
|
|
e6c821d3ee | ||
|
|
17a7741697 | ||
|
|
f00525d21a | ||
|
|
f5c79c1fab | ||
|
|
4fc0a92651 | ||
|
|
585ec39f8e | ||
|
|
a0189fdd0a | ||
|
|
ede31732b3 | ||
|
|
a60381522d | ||
|
|
64010ad86b | ||
|
|
e628154b78 | ||
|
|
e5baf5e994 | ||
|
|
05fdbab496 | ||
|
|
512b1f6455 | ||
|
|
5615d83f04 | ||
|
|
ee38918516 | ||
|
|
d1b8d86a20 | ||
|
|
25ef7c5d8a | ||
|
|
db429abf5b | ||
|
|
19d5ae7e15 | ||
|
|
fcbd613f4a | ||
|
|
5fae370c55 | ||
|
|
f2dbe6ee8f | ||
|
|
0175a6998b | ||
|
|
758de9949b | ||
|
|
81b8960d41 | ||
|
|
5b25619b24 | ||
|
|
62e23aaf23 | ||
|
|
aac8eed898 | ||
|
|
108980befb | ||
|
|
6a4cd00d51 | ||
|
|
a6c899c098 | ||
|
|
28170d31df | ||
|
|
ce8d272d6e | ||
|
|
0047685f54 | ||
|
|
2cc0fc64a4 | ||
|
|
67642cebfd | ||
|
|
327dc85d14 | ||
|
|
8c4f42bab1 | ||
|
|
40c29e494c | ||
|
|
0235ec7edc | ||
|
|
fa2a000624 | ||
|
|
861b24cef1 | ||
|
|
ee1977384e | ||
|
|
5d08505f62 | ||
|
|
ab21124327 | ||
|
|
1df792ec9c | ||
|
|
a8fa6e5987 | ||
|
|
1d69c5a78d | ||
|
|
0ae7ba3e11 | ||
|
|
c421ca7f2f | ||
|
|
ea4fff5b10 | ||
|
|
e0b0e38271 | ||
|
|
510b956649 | ||
|
|
17b8af4bc4 | ||
|
|
617b400884 | ||
|
|
a58518ccb5 | ||
|
|
cdd17d919e | ||
|
|
4580cef7f2 | ||
|
|
6f9c765bab | ||
|
|
5b56b2e0be | ||
|
|
b0cc811807 | ||
|
|
eb540d5c13 | ||
|
|
e308293cf6 | ||
|
|
9ed4659c5c | ||
|
|
f5f2b76914 | ||
|
|
551a065497 | ||
|
|
88d7e38d82 | ||
|
|
65e6cb22dd | ||
|
|
689a396f6e | ||
|
|
512ea84850 | ||
|
|
1542e583f7 | ||
|
|
c488dcc3c6 | ||
|
|
1594e0e24b | ||
|
|
1e1fa77621 | ||
|
|
3270c17514 | ||
|
|
e635942e3d | ||
|
|
64dc2858a7 | ||
|
|
d05496bb3d | ||
|
|
bb94553fff | ||
|
|
113216b7ba | ||
|
|
55181edaa8 | ||
|
|
4f01a7b577 | ||
|
|
ea21111037 | ||
|
|
fba9f1de42 | ||
|
|
7d0b8db7a6 | ||
|
|
12faa31e34 | ||
|
|
74945b1752 | ||
|
|
a7c66517d2 | ||
|
|
adf187ddf5 | ||
|
|
614d897dd2 | ||
|
|
ec9a7d68e6 | ||
|
|
79dd91b270 | ||
|
|
b26bcd7603 | ||
|
|
a65468191b | ||
|
|
4ed5271703 | ||
|
|
f2afe2a977 | ||
|
|
430b0f30c9 | ||
|
|
8aac9a795e | ||
|
|
b9405765f9 | ||
|
|
90856b3812 | ||
|
|
1e78af3c25 | ||
|
|
4be232b951 | ||
|
|
59d5c2762d | ||
|
|
21a55439ec | ||
|
|
c4a35f5c15 | ||
|
|
b42f761011 | ||
|
|
46e2e64e65 | ||
|
|
54ed35ffb3 | ||
|
|
7b8bd747ad | ||
|
|
3e379957e1 | ||
|
|
b64525487e | ||
|
|
e544f5c862 | ||
|
|
669759c52e | ||
|
|
4a13d3209b | ||
|
|
be069e9aed | ||
|
|
0b20ee1aa2 | ||
|
|
e4872a78f5 | ||
|
|
4692216325 | ||
|
|
69128062fe | ||
|
|
f81ba3028d | ||
|
|
73a948c528 | ||
|
|
6d652130e6 | ||
|
|
9e6f8077f7 | ||
|
|
40342ca824 | ||
|
|
71238d4a01 | ||
|
|
4da9f1e6cf | ||
|
|
93b55fe370 | ||
|
|
ee5e7d2586 | ||
|
|
4f4e09c3de | ||
|
|
d537d81f1c | ||
|
|
26c6700152 | ||
|
|
49fb96d7a3 | ||
|
|
d256ee5696 | ||
|
|
bd70a7bfa8 | ||
|
|
3fb09bad0d | ||
|
|
06079659af | ||
|
|
22d8049c2c | ||
|
|
5f6b0e8960 | ||
|
|
9b8da7774d | ||
|
|
eabed55a7a | ||
|
|
32cc74f99c | ||
|
|
ffc4cc3d96 | ||
|
|
007cf57efd | ||
|
|
c6dba71197 | ||
|
|
8aa162e294 | ||
|
|
51d6dec7ff | ||
|
|
f1b2762769 | ||
|
|
d126be2aa5 | ||
|
|
ea034ee76a | ||
|
|
39634a690c | ||
|
|
a7001eb6da | ||
|
|
71e3540f18 | ||
|
|
4ea4020faa | ||
|
|
78cadfd352 | ||
|
|
da15f829d3 | ||
|
|
bb60694013 | ||
|
|
b3758d2baf | ||
|
|
bc794e9a44 | ||
|
|
c80115d0f7 | ||
|
|
6277576249 | ||
|
|
2201d369fa | ||
|
|
9f4e4790f5 | ||
|
|
501e373e38 | ||
|
|
b2cf7c92d5 | ||
|
|
e92e13c045 | ||
|
|
f3dec958b0 | ||
|
|
c88a1c5848 | ||
|
|
0cf8ea8166 | ||
|
|
74b830dd79 | ||
|
|
8668c168a7 | ||
|
|
8b8c5f33ce | ||
|
|
2fcbb026df | ||
|
|
66ee72380d | ||
|
|
4f16345351 | ||
|
|
5110618996 | ||
|
|
bf51368cf4 | ||
|
|
d6054745d6 | ||
|
|
a4731f25f8 | ||
|
|
6c4507e495 | ||
|
|
c8e0160d5c | ||
|
|
ac40a81901 | ||
|
|
0162769d22 | ||
|
|
fa55755921 | ||
|
|
ca38a68a75 | ||
|
|
64be2dd562 | ||
|
|
ea2abb6c72 | ||
|
|
011e2ff37a | ||
|
|
cfa335564a | ||
|
|
3d1493b0a6 | ||
|
|
a46b52e603 | ||
|
|
3c0683b9f8 | ||
|
|
3214c2804e | ||
|
|
83f50cbaee | ||
|
|
61ef10de9b | ||
|
|
73f36d6b29 | ||
|
|
666a1a3296 | ||
|
|
acec2e95a2 | ||
|
|
d26e7e78a1 | ||
|
|
77e5c44673 | ||
|
|
619cc84d15 | ||
|
|
22b85439d3 | ||
|
|
64995c25a8 | ||
|
|
1655b5ae78 | ||
|
|
3d3f6d058e | ||
|
|
104a04c5de | ||
|
|
e12193aa40 | ||
|
|
51101387f7 | ||
|
|
641a3bf2ab | ||
|
|
58f22f4bb2 | ||
|
|
562eac4249 | ||
|
|
2e1c0e6c54 | ||
|
|
7759868664 | ||
|
|
e92df66bef | ||
|
|
354f3fd8e2 | ||
|
|
1201ea33db | ||
|
|
f8e99a34c7 | ||
|
|
1cef17174b | ||
|
|
73cabf2acd | ||
|
|
49770f9e8d | ||
|
|
e32261d274 | ||
|
|
3c7a63e616 | ||
|
|
ab7a487e78 | ||
|
|
f01e2efd3f | ||
|
|
3f4a4f7581 | ||
|
|
7f78925bd7 | ||
|
|
d16423818d | ||
|
|
8cbd3b9625 | ||
|
|
9fac12ce3c | ||
|
|
ee050aa5fa | ||
|
|
a179f13031 | ||
|
|
f3fc5760fc | ||
|
|
d4e04a003c | ||
|
|
2604be38f0 | ||
|
|
06a10f77ae | ||
|
|
73f1355011 | ||
|
|
659b9f9680 | ||
|
|
539f854dbf | ||
|
|
45d4e74c98 | ||
|
|
1d0b101352 | ||
|
|
ed96eeccee | ||
|
|
29d49360f5 | ||
|
|
849cac6a40 | ||
|
|
262b3622dd | ||
|
|
2692ac2408 | ||
|
|
c2502a09a9 | ||
|
|
2ea7c72fc6 | ||
|
|
42aafae29b | ||
|
|
61101382d1 | ||
|
|
ba5a791b2d | ||
|
|
ba189aec6f | ||
|
|
4b17d20325 | ||
|
|
b52bdcf4b3 | ||
|
|
8e8c14a51f | ||
|
|
80786c572a | ||
|
|
a331f45f87 | ||
|
|
4c70ebcaf9 | ||
|
|
7760358c02 | ||
|
|
a163ea377c | ||
|
|
3fabf961e5 | ||
|
|
6f3b60ef2c | ||
|
|
4a27653039 | ||
|
|
d5b1f5fb1c | ||
|
|
816770d407 | ||
|
|
8dfd39810d | ||
|
|
b2ee143e1c | ||
|
|
94b0a9f89b | ||
|
|
a0a50ff7d1 | ||
|
|
7ccdae23fa | ||
|
|
0bf57502e6 | ||
|
|
2888c369d7 | ||
|
|
bedb872034 | ||
|
|
cd42e76659 | ||
|
|
1b49aa2d39 | ||
|
|
4423c895c7 | ||
|
|
f9c574ddd9 | ||
|
|
60dc911228 | ||
|
|
ed25a0e395 | ||
|
|
7590623d26 | ||
|
|
043e518cce | ||
|
|
de7f7bc8de | ||
|
|
b8079f11d0 | ||
|
|
7c5b3f2241 | ||
|
|
48e5ce807d | ||
|
|
35e9ea13de | ||
|
|
958677c5b1 | ||
|
|
21a9904b81 | ||
|
|
bc979767d6 | ||
|
|
e933209ea7 | ||
|
|
ae6cd88d9e | ||
|
|
7ffc0c3484 | ||
|
|
0f450154cf | ||
|
|
e32b4c7406 | ||
|
|
d45179a4b0 | ||
|
|
0816fafc02 | ||
|
|
db4cf015c2 | ||
|
|
48c4197b16 | ||
|
|
a0fb109839 | ||
|
|
4c32bf5934 | ||
|
|
19beb846bf | ||
|
|
661b6e46cc | ||
|
|
19d7330d3a | ||
|
|
75f70c2ae0 | ||
|
|
fb00b12d13 | ||
|
|
0f8f202fbb | ||
|
|
f4ad6bf263 | ||
|
|
be7d173746 | ||
|
|
e0b2f152b0 | ||
|
|
d0457a2782 | ||
|
|
ee684021db | ||
|
|
61eef27740 | ||
|
|
774ac7f2fa | ||
|
|
6dcc597b0c | ||
|
|
5bd332369f | ||
|
|
f2c0799854 | ||
|
|
dea77cc268 | ||
|
|
1f5b1e2bb9 | ||
|
|
da68b0fdae | ||
|
|
1680acb22c | ||
|
|
56a8859eaf | ||
|
|
fee8c3f0ee | ||
|
|
faa22966e4 | ||
|
|
3c72f3b1c5 | ||
|
|
7497b48531 | ||
|
|
70fddac2d5 | ||
|
|
8f65124830 | ||
|
|
bb9b7bcf9f | ||
|
|
4bd2c90554 | ||
|
|
bd6b23f413 | ||
|
|
85b5943b9e | ||
|
|
0f5ed083df | ||
|
|
486ca220a2 | ||
|
|
a19bf5fac2 | ||
|
|
5cf8ce4385 | ||
|
|
8026d19d8f | ||
|
|
d64abe4ee3 | ||
|
|
89acfafbd2 | ||
|
|
072c49a037 | ||
|
|
7fad75fad0 | ||
|
|
79e40f6a53 | ||
|
|
f2b1b07f58 | ||
|
|
999ddaeb9a | ||
|
|
d730ae5bef | ||
|
|
bf48e865ac | ||
|
|
7e05909404 | ||
|
|
7a1c944fe6 | ||
|
|
66a2b3224f | ||
|
|
7bcdecaceb | ||
|
|
6beefb9fc0 | ||
|
|
579b63b036 | ||
|
|
1f676254a9 | ||
|
|
eac81ac82b | ||
|
|
8c1b043769 | ||
|
|
eb870d94c2 | ||
|
|
c18b62ffb9 | ||
|
|
02f724bfc3 | ||
|
|
e12ea371c0 | ||
|
|
9a1726c249 | ||
|
|
50f2eaee3b | ||
|
|
6b1229fcf2 | ||
|
|
ef97202867 | ||
|
|
5494490ff8 | ||
|
|
bd4c4878f1 | ||
|
|
6a7851a1cc | ||
|
|
0eac4e2a44 | ||
|
|
053e2cdc64 | ||
|
|
7024b86d00 | ||
|
|
ae75820b77 | ||
|
|
a800c71cba | ||
|
|
55cce56230 | ||
|
|
128f1ca043 | ||
|
|
2f25fd1239 | ||
|
|
c0ad450960 | ||
|
|
0845ee6775 | ||
|
|
ffcdb10802 | ||
|
|
fe5b63eed8 | ||
|
|
f3ca6c3fa7 | ||
|
|
904bc45652 | ||
|
|
845d6b2e2c | ||
|
|
5deacf45cb | ||
|
|
e9bc303e0e | ||
|
|
caaf1e8d0d | ||
|
|
b96e757379 | ||
|
|
53a52d8561 | ||
|
|
32424e46b8 | ||
|
|
1e3829899a | ||
|
|
b6df41e05b | ||
|
|
f4fd5bb797 | ||
|
|
ecc538a932 | ||
|
|
6741a94c1b | ||
|
|
7be2c69256 | ||
|
|
2b97b6ac9d | ||
|
|
512b47a386 | ||
|
|
d6b95036b5 | ||
|
|
e4c188da75 | ||
|
|
edfe28b9ef | ||
|
|
c111ed4f91 | ||
|
|
318c296ee9 | ||
|
|
998b2ce3d7 | ||
|
|
ba5f8928f7 | ||
|
|
641abc57b9 | ||
|
|
0a23ed6ef4 | ||
|
|
8e69e1ec58 | ||
|
|
d50bffad3e | ||
|
|
db71bc3f19 | ||
|
|
f2a9d7097f | ||
|
|
a4b0a25dab | ||
|
|
3af530a15e | ||
|
|
11c7277878 | ||
|
|
6eae60ba54 | ||
|
|
2d711cca80 | ||
|
|
b274c99b91 | ||
|
|
4e66074603 | ||
|
|
42fbc479c9 | ||
|
|
f47610b98a | ||
|
|
cda45ce64c | ||
|
|
009a0d64b8 | ||
|
|
3afb0da017 | ||
|
|
bdc7f8a8a8 | ||
|
|
69a72f24ed | ||
|
|
ee0e71d50e | ||
|
|
39ba175651 | ||
|
|
731f022669 | ||
|
|
8d5527990b | ||
|
|
1ff536c2f7 | ||
|
|
27a18f1fc6 | ||
|
|
8921b90392 | ||
|
|
6cd925b062 | ||
|
|
28a344c63c | ||
|
|
a9b5fa0fae | ||
|
|
65212201ad | ||
|
|
d8c3ba34a8 | ||
|
|
63be8a35ad | ||
|
|
53ef4e11f9 | ||
|
|
c9a6451407 | ||
|
|
9d07a3a7bd | ||
|
|
bd4296199a | ||
|
|
b9e0535f63 | ||
|
|
6e371d75c8 | ||
|
|
7697f382ef | ||
|
|
4c551a8c91 | ||
|
|
56227c69f7 | ||
|
|
5acd3d86c8 | ||
|
|
d7f7139f36 | ||
|
|
1c5cacf1ce | ||
|
|
0a603116ef | ||
|
|
809b28a994 | ||
|
|
f7610a3570 | ||
|
|
b5a371da87 | ||
|
|
bff9e87096 | ||
|
|
d872a8af20 | ||
|
|
4966cdbfac | ||
|
|
cb3eb83eac | ||
|
|
5daa7bce73 | ||
|
|
4e80f93b30 | ||
|
|
2776a1a5ce | ||
|
|
4f402d6a6a | ||
|
|
d544da6e4d | ||
|
|
0e42c19d3b | ||
|
|
0a2bd3d46a | ||
|
|
86d2dade11 | ||
|
|
19ab4409a3 | ||
|
|
3af90bd6e9 | ||
|
|
cfb0cff1a3 | ||
|
|
c08d6cd668 | ||
|
|
a53bebefd7 | ||
|
|
8e0c3306e8 | ||
|
|
f4364b3bd3 | ||
|
|
5b5757a1d7 | ||
|
|
f165f4911b | ||
|
|
b81b538d9a | ||
|
|
2f32c8e092 | ||
|
|
d101a79bf8 | ||
|
|
caea10a190 | ||
|
|
1445202a0d | ||
|
|
6f62ac4ffb | ||
|
|
e87bbe7223 | ||
|
|
e7e2c40c68 | ||
|
|
78b6d445fa | ||
|
|
c212355860 | ||
|
|
c223c20b38 | ||
|
|
524a9cda35 | ||
|
|
8bee66d404 | ||
|
|
142b00499b | ||
|
|
b0ea6c0ea2 | ||
|
|
67fd53a503 | ||
|
|
29529271fb | ||
|
|
4489a0f702 | ||
|
|
0d9fcc731a | ||
|
|
fe1c8862e6 | ||
|
|
092450e4f8 | ||
|
|
da054de708 | ||
|
|
dfac3c57cc | ||
|
|
0f3ecdc4ee | ||
|
|
24c47c3aa3 | ||
|
|
f53de9fe0b | ||
|
|
ee4d1f5689 | ||
|
|
122ad73c2e | ||
|
|
6ad1e6c3f3 | ||
|
|
c899fa72b8 | ||
|
|
e209bd68d4 | ||
|
|
96ac655d92 | ||
|
|
1d97b19774 | ||
|
|
11c7de3568 | ||
|
|
38d899fa94 | ||
|
|
37796c98c9 | ||
|
|
5b2e48badd | ||
|
|
627aa35f88 | ||
|
|
74e974177c | ||
|
|
6911132c95 | ||
|
|
f1affc7d63 | ||
|
|
bea824aee9 | ||
|
|
cbdd5b3a24 | ||
|
|
c02bc753fd | ||
|
|
d4915e1a62 | ||
|
|
2d4a5fc62f | ||
|
|
94a010c9b2 | ||
|
|
a6a202f6ff | ||
|
|
2127fdd443 | ||
|
|
3b3fd8b35c | ||
|
|
95d0937015 | ||
|
|
b070b4f659 | ||
|
|
a8c05fd26c | ||
|
|
ecd64f62bc | ||
|
|
5affd4e57b | ||
|
|
76d69ab7dd | ||
|
|
1d1b38210a | ||
|
|
836032d93e | ||
|
|
dc3e285917 | ||
|
|
e54eb8fea2 | ||
|
|
177dbaa5ff | ||
|
|
1d08ab945d | ||
|
|
10ce7d772c | ||
|
|
e1a23ac606 | ||
|
|
439259ec57 | ||
|
|
a0dda0b866 | ||
|
|
6913defc12 | ||
|
|
f3e2fdd4fc | ||
|
|
5c44b35045 | ||
|
|
cebb6426f8 | ||
|
|
f05e50e63e | ||
|
|
f8ef3f18ff | ||
|
|
47dbc540ac | ||
|
|
766d5ed2af | ||
|
|
783b408611 | ||
|
|
24c91269a0 | ||
|
|
e786026049 | ||
|
|
566b0cf6e5 | ||
|
|
b17844e837 | ||
|
|
5c93c4db57 | ||
|
|
57e8a96a4a | ||
|
|
438581834e | ||
|
|
58cfd49859 | ||
|
|
4a1933e924 | ||
|
|
6ded8c5ab5 | ||
|
|
edf38aad48 | ||
|
|
f4caa51da5 | ||
|
|
9575ba2a9f | ||
|
|
af2fe91f81 | ||
|
|
c641c86598 | ||
|
|
0599de372a | ||
|
|
1c89ee2797 | ||
|
|
5fd846bfc8 | ||
|
|
02aefcf155 | ||
|
|
e92983dd80 | ||
|
|
03f65317a9 | ||
|
|
21cb09fbde | ||
|
|
6c1e7f6f12 | ||
|
|
344dd3343b | ||
|
|
cacb9e449c | ||
|
|
18313141f4 | ||
|
|
ecd73ae0d6 | ||
|
|
7ad754df03 | ||
|
|
cfc601e19a | ||
|
|
9984f9c206 | ||
|
|
39e59a4077 | ||
|
|
d735ed19cb | ||
|
|
f4037a1ccf | ||
|
|
3e917e2062 | ||
|
|
919357a374 | ||
|
|
5b6be864fd | ||
|
|
98a3b06e56 | ||
|
|
6253def76c | ||
|
|
450e5f7e61 | ||
|
|
d2ec9c680d | ||
|
|
56d7ad6999 | ||
|
|
97024395c1 | ||
|
|
10342be2be | ||
|
|
51a3ee4a9b | ||
|
|
8779bbc532 | ||
|
|
90b33ef444 | ||
|
|
3fa0b36426 | ||
|
|
60a64cd777 | ||
|
|
c543fabdf4 | ||
|
|
64b96f00f7 | ||
|
|
86b372de68 | ||
|
|
c108070696 | ||
|
|
80a193a394 | ||
|
|
b9c16dbee4 | ||
|
|
6e870ef300 | ||
|
|
cf45ae30ac | ||
|
|
38a0453cbb | ||
|
|
92d37abbc5 | ||
|
|
39662038f7 | ||
|
|
75b58d0423 | ||
|
|
1814808df1 | ||
|
|
fe57d80a00 | ||
|
|
8cb855328d | ||
|
|
a62ba8e167 | ||
|
|
4f40b4af49 | ||
|
|
8d9a042489 | ||
|
|
ef05466d6d | ||
|
|
0a5cf005a1 | ||
|
|
f6c365bdf1 | ||
|
|
bc2ab60c59 | ||
|
|
ad217d4a3b | ||
|
|
61cc3e6f58 | ||
|
|
a3ab06509e | ||
|
|
54684ea3c9 | ||
|
|
3de4951c96 | ||
|
|
05c551d7ac | ||
|
|
7cea8b4fb3 | ||
|
|
ba2cdbf8cf | ||
|
|
3e004867be | ||
|
|
edaef53712 | ||
|
|
933842f6af | ||
|
|
2eff82891e | ||
|
|
c625756ab4 | ||
|
|
2140a220e2 | ||
|
|
7ead55d801 | ||
|
|
4e0038c813 | ||
|
|
d07e4c8ecd | ||
|
|
63fd42ff05 | ||
|
|
d5dbcd3f80 | ||
|
|
c301f36912 | ||
|
|
9dd5ee2365 | ||
|
|
3388b7a122 | ||
|
|
38af8de469 | ||
|
|
db0ebc6c33 | ||
|
|
7cc2961538 | ||
|
|
835ec4782c | ||
|
|
e6942bc201 | ||
|
|
ebabe1560f | ||
|
|
4da697f507 | ||
|
|
f18fb83a92 | ||
|
|
e050402787 | ||
|
|
b3dd0e25fa | ||
|
|
a5358b82f6 | ||
|
|
2a9f0f24fd | ||
|
|
5945942acd | ||
|
|
bcdb983b98 | ||
|
|
7836c611b7 | ||
|
|
2797d571e4 | ||
|
|
389fd0b1b0 | ||
|
|
25630da1ce | ||
|
|
ca972d3e28 | ||
|
|
80420302c1 | ||
|
|
9759d5f64f | ||
|
|
17a9b6102e | ||
|
|
7e7503035a | ||
|
|
02a6b24517 | ||
|
|
b3fee5b56d | ||
|
|
26d38acddb | ||
|
|
8a30e9b663 | ||
|
|
46a2d04528 | ||
|
|
6a85b82643 | ||
|
|
b436bb63da | ||
|
|
b5cb4051ab | ||
|
|
01f774db54 | ||
|
|
c5a6d765ee | ||
|
|
459f23bbd6 | ||
|
|
360754737f | ||
|
|
36f1476782 | ||
|
|
ecae83f659 | ||
|
|
fbe5109ed9 | ||
|
|
4adedad0de | ||
|
|
28257ba66f | ||
|
|
3062295069 | ||
|
|
3c231a7fde | ||
|
|
0247b02f6e | ||
|
|
8aaad71784 | ||
|
|
e795474917 | ||
|
|
49f99f57c9 | ||
|
|
53398707aa | ||
|
|
1d8a7d2e63 | ||
|
|
313e2bc080 | ||
|
|
0037935280 | ||
|
|
7858b40ce4 | ||
|
|
ab6db27ea7 | ||
|
|
4568795081 | ||
|
|
43643d1a83 | ||
|
|
28e7de6ceb | ||
|
|
c204855a71 | ||
|
|
dab33c4e60 | ||
|
|
47f9c0a502 | ||
|
|
d9a6fd2a42 | ||
|
|
dcb91905ad | ||
|
|
b6fd842d4e | ||
|
|
4b57e3e350 | ||
|
|
1652ebc4ad | ||
|
|
924ff1b6fc | ||
|
|
926ca72331 | ||
|
|
cf7190aaec | ||
|
|
54d6cded53 | ||
|
|
7a7e54ea5b | ||
|
|
7b4aa23f35 | ||
|
|
ac4482bc8b | ||
|
|
0a7f2b15f1 | ||
|
|
95e0b83537 | ||
|
|
bb602af750 | ||
|
|
580242b9d2 | ||
|
|
2cc1b55cbf | ||
|
|
e1944783d0 | ||
|
|
423d760f36 | ||
|
|
16e237b698 | ||
|
|
28d68d8a8e | ||
|
|
d476fbbdae | ||
|
|
64542f2902 | ||
|
|
56a59a5355 | ||
|
|
285ddeb62e | ||
|
|
84ef51f16b | ||
|
|
fb1125136c | ||
|
|
55f7ff1842 | ||
|
|
ac1d2210da | ||
|
|
ff92f355e2 | ||
|
|
4b8c8155fa | ||
|
|
756a83191d | ||
|
|
b5eb8be15e | ||
|
|
38a023d0b6 | ||
|
|
3a878dd019 | ||
|
|
6314c0f1d6 | ||
|
|
c5eed25f06 | ||
|
|
e1243522b0 | ||
|
|
d9108ac6ed | ||
|
|
302abe3e40 | ||
|
|
b6a2191e38 | ||
|
|
84b54e43aa | ||
|
|
e9971aa6c4 | ||
|
|
91f630209c | ||
|
|
b6878aefd6 | ||
|
|
f0f70def8c | ||
|
|
81bc5aefff | ||
|
|
698d2c96d7 | ||
|
|
ce683a539d | ||
|
|
ac481c6b18 | ||
|
|
750d6ad7eb | ||
|
|
7bd801cd01 | ||
|
|
5cb364f754 | ||
|
|
04d1b0c694 | ||
|
|
35028df817 | ||
|
|
2e8f55d7a8 | ||
|
|
815a440082 | ||
|
|
2afcd528dc | ||
|
|
8d68a59799 | ||
|
|
51bc60776d | ||
|
|
43f4c966f9 | ||
|
|
98a0233c4d | ||
|
|
0545be3244 | ||
|
|
4a67b22d8d | ||
|
|
5840bf710c | ||
|
|
1b8e1c2aab | ||
|
|
60aa949cca | ||
|
|
5b05b8927c | ||
|
|
d65d6d2396 | ||
|
|
086ac8fdc9 | ||
|
|
c6c7f128a9 | ||
|
|
36ec12fd0f | ||
|
|
e9fd751578 | ||
|
|
21a97b8871 | ||
|
|
b8ede4cfd0 | ||
|
|
f47eba5764 | ||
|
|
1347136b54 | ||
|
|
89f0758fbb | ||
|
|
b5507b9f5d | ||
|
|
204baa52ab | ||
|
|
bc739dc4a0 | ||
|
|
64616b9136 | ||
|
|
983783ea95 | ||
|
|
1414a4a9cf | ||
|
|
af7639aa73 | ||
|
|
dabc6a2d0a | ||
|
|
d1ef159e87 | ||
|
|
cc5c323ccb | ||
|
|
d18a871429 | ||
|
|
0a1f55f6a6 | ||
|
|
faeda030e9 | ||
|
|
b3700c3a4c | ||
|
|
01a221831f | ||
|
|
9cb41e01e2 | ||
|
|
abdb4f62de | ||
|
|
da7d354436 | ||
|
|
794a306f89 | ||
|
|
ac61ee1833 | ||
|
|
a87d419868 | ||
|
|
abbb7a0cb1 | ||
|
|
a5ae22d2a5 | ||
|
|
22b6a07749 | ||
|
|
dbdb2e2959 | ||
|
|
5147b3f0e4 | ||
|
|
a8eb0057e3 | ||
|
|
7604ff2ae4 | ||
|
|
bf9b5ba593 | ||
|
|
d12c111684 | ||
|
|
dffd3c9138 | ||
|
|
c34f7af6de | ||
|
|
22c7048ef6 | ||
|
|
96aa9d0813 | ||
|
|
d99c0ff8b2 | ||
|
|
c6e8bde078 | ||
|
|
adff7b9e1e | ||
|
|
b62c18fd84 | ||
|
|
de7cbdf494 | ||
|
|
0444ca143e | ||
|
|
596baad296 | ||
|
|
e686bb6247 | ||
|
|
06d6f15e38 | ||
|
|
d3adae42fe | ||
|
|
39b38119c1 | ||
|
|
eace3e9467 | ||
|
|
366da8d38e | ||
|
|
a965890916 | ||
|
|
b07bbd68d7 | ||
|
|
3d4a79aac6 | ||
|
|
e30c4cc644 | ||
|
|
d317be3ad3 | ||
|
|
1b078bd2fd | ||
|
|
1d84ed1614 | ||
|
|
114476d74c | ||
|
|
fb8663fb24 | ||
|
|
3a9be771b4 | ||
|
|
b2ef8f5cd2 | ||
|
|
83d501ae9b | ||
|
|
c555566c9d | ||
|
|
264f9a380b | ||
|
|
33d5951a14 | ||
|
|
68c4e43e05 | ||
|
|
54510f1c18 | ||
|
|
940234c743 | ||
|
|
b31ab46d11 | ||
|
|
c359821844 | ||
|
|
d49cf08e21 | ||
|
|
0f4cd23989 | ||
|
|
e12451911b | ||
|
|
b26f8cc43c | ||
|
|
d63c37cd78 | ||
|
|
c88aa2c9d8 | ||
|
|
4d5c744583 | ||
|
|
5033c5c7b7 | ||
|
|
5a1f2ffac7 | ||
|
|
8eecb592e6 | ||
|
|
fb188d6aaa | ||
|
|
0d33fe8fe4 | ||
|
|
5b3b8b5bc3 | ||
|
|
17de7f2e56 | ||
|
|
03aec7a34e | ||
|
|
266d68be22 | ||
|
|
bfbdefe773 | ||
|
|
5e96cdb1d6 | ||
|
|
19ee47ceb2 | ||
|
|
2823607146 | ||
|
|
1869abd9df | ||
|
|
f070d184ea | ||
|
|
d59d552aae | ||
|
|
a370531f1d | ||
|
|
9ae1b455f4 | ||
|
|
ec0eb64ffd | ||
|
|
f31886e1ab | ||
|
|
7365831ec1 | ||
|
|
4a09b682b2 | ||
|
|
afbd52a91e | ||
|
|
1c6e14acb4 | ||
|
|
6968936c8f | ||
|
|
a571278145 | ||
|
|
e4e25394e2 | ||
|
|
fe47d7b9e3 | ||
|
|
4bb5bc6e32 | ||
|
|
49d951e96a | ||
|
|
9585a02959 | ||
|
|
a51fa5e4a2 | ||
|
|
bc0671440c | ||
|
|
1a07c3970f | ||
|
|
83c07b27f9 | ||
|
|
fbcf7d2fc3 | ||
|
|
b547ac1aed | ||
|
|
411f8a8d61 | ||
|
|
b3741a5cf4 | ||
|
|
b1cf524612 | ||
|
|
364c920fff | ||
|
|
e89ccee5f4 | ||
|
|
6a86e69cd4 | ||
|
|
ab2c086e93 | ||
|
|
b9c65e634c | ||
|
|
b7852a8c07 | ||
|
|
4b9d94eb62 | ||
|
|
70481fd468 | ||
|
|
52c67f4d23 | ||
|
|
d3618f3065 | ||
|
|
29472beee8 | ||
|
|
acaac507b1 | ||
|
|
f25c23b2b3 | ||
|
|
5ab0466a87 | ||
|
|
d49c44f3be | ||
|
|
4577b4e955 | ||
|
|
dafde2eaba | ||
|
|
db4fab9130 | ||
|
|
9aee578707 | ||
|
|
6d74eb65ae | ||
|
|
6e8ae3a12b | ||
|
|
a4be7f9005 | ||
|
|
587ee630d7 | ||
|
|
6952a5f680 | ||
|
|
b263ecd45c | ||
|
|
74fc0e4e88 | ||
|
|
a873366342 | ||
|
|
c4dc266f93 | ||
|
|
96ff783bbd | ||
|
|
804a65f52b | ||
|
|
e88c859f4f | ||
|
|
c1a393eaf6 | ||
|
|
15e08dc529 | ||
|
|
e55bcaf7eb | ||
|
|
4e64c6ad6e | ||
|
|
5a15e1a1d6 | ||
|
|
ba07d47496 | ||
|
|
25325e80ee | ||
|
|
89783b4d45 | ||
|
|
d5f0094025 | ||
|
|
b4f37451be | ||
|
|
84ea378815 | ||
|
|
72d4db1f27 | ||
|
|
21ea879d97 | ||
|
|
a5baef2240 | ||
|
|
bbecf54aba | ||
|
|
5f868d193c | ||
|
|
62b035ab39 | ||
|
|
ff5ee33e08 | ||
|
|
8e28016e5e | ||
|
|
f17a18cb6d | ||
|
|
999f45e5f5 | ||
|
|
3e303fadd7 | ||
|
|
3b7590d8ce | ||
|
|
fabbada580 | ||
|
|
6e434d37dc | ||
|
|
904da80f81 | ||
|
|
2a4bd52f0a | ||
|
|
b4248d4a12 | ||
|
|
75b056d5ba | ||
|
|
e87e12c939 | ||
|
|
5cb7e3bc73 | ||
|
|
1930b91a5b | ||
|
|
ea0dad132c | ||
|
|
5b7b94f507 | ||
|
|
28e38f73f8 | ||
|
|
d43c0ef209 | ||
|
|
6394384be0 | ||
|
|
4f0af3d0cb | ||
|
|
2a6f833718 | ||
|
|
c8835f4d4c | ||
|
|
fff1a1c177 | ||
|
|
8fee96d0e1 | ||
|
|
fdb3d63006 | ||
|
|
071d239892 | ||
|
|
94eb9abe9d | ||
|
|
1031c4013e | ||
|
|
2b5bb34392 | ||
|
|
e28ef9b783 | ||
|
|
e3c17010c1 | ||
|
|
2389aaf314 | ||
|
|
4f1dd7a5fb | ||
|
|
4b203a93b6 | ||
|
|
f219b1a580 | ||
|
|
004ee5bbf0 | ||
|
|
5640db9cbd | ||
|
|
52b26533a2 | ||
|
|
d334a214a4 | ||
|
|
1aab8dfc4e | ||
|
|
e56ee1ff4a | ||
|
|
0393e7aff7 | ||
|
|
c988e4accf | ||
|
|
63ac715792 | ||
|
|
fe0e2e6592 | ||
|
|
ca1a386146 | ||
|
|
7c9d0a39c3 | ||
|
|
a5777027b1 | ||
|
|
c3e911e6fa | ||
|
|
4d03110df2 | ||
|
|
8cb640f565 | ||
|
|
494bd4f539 | ||
|
|
38169691cd | ||
|
|
bd995bc736 | ||
|
|
6e05e74d5e | ||
|
|
d3a1db4efe | ||
|
|
a19f2a57c3 | ||
|
|
666a53f6ba | ||
|
|
b156a08f0d | ||
|
|
9c76aa2189 | ||
|
|
a54c95b6ac | ||
|
|
9cb0ada1b7 | ||
|
|
54378a132f | ||
|
|
4d1632a9b9 | ||
|
|
1eab835458 | ||
|
|
fcbc7fead8 | ||
|
|
ec783e4ccc | ||
|
|
b6f97b102c | ||
|
|
e4ce9a3bd7 | ||
|
|
64d5e721af | ||
|
|
d7419669d6 | ||
|
|
ff2f6799c8 | ||
|
|
2d573896f9 | ||
|
|
ab15190c44 | ||
|
|
551995df68 | ||
|
|
8483babd10 | ||
|
|
79648cd9d5 | ||
|
|
04d690dcf1 | ||
|
|
0b308803bf | ||
|
|
419d5aace3 | ||
|
|
84005f2d43 | ||
|
|
a166079084 | ||
|
|
a70d8fe6c8 | ||
|
|
34cd337146 | ||
|
|
9283594dd0 | ||
|
|
638246e74d | ||
|
|
f506407f67 | ||
|
|
216f201327 | ||
|
|
a557f2ada3 | ||
|
|
e15e4cc3c8 | ||
|
|
2555c46b6d | ||
|
|
fdfd59fbdf | ||
|
|
0e1c3f9364 | ||
|
|
f9bb18d97f | ||
|
|
b7339b6a35 | ||
|
|
26abc30695 | ||
|
|
1f0f824b01 |
114
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
114
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
name: "报告 Bug"
|
||||||
|
description: "代码出现了非预期的问题、崩溃或报错"
|
||||||
|
title: "[Bug]: "
|
||||||
|
labels: ["type: bug", "status: needs info"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
请提供尽可能详细的信息,帮助我们快速定位和修复问题。
|
||||||
|
- type: checkboxes
|
||||||
|
id: pre-check
|
||||||
|
attributes:
|
||||||
|
label: 提交前确认
|
||||||
|
description: 请务必确认以下事项
|
||||||
|
options:
|
||||||
|
- label: 我已搜索过现有的 Issues,确认这不是重复问题
|
||||||
|
required: true
|
||||||
|
- label: 我使用的是最新版本
|
||||||
|
required: true
|
||||||
|
- label: 我已阅读过相关文档
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: platform
|
||||||
|
attributes:
|
||||||
|
label: 使用平台
|
||||||
|
description: 选择出现问题的平台
|
||||||
|
options:
|
||||||
|
- Windows
|
||||||
|
- macOS
|
||||||
|
- Linux
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: severity
|
||||||
|
attributes:
|
||||||
|
label: 问题严重程度
|
||||||
|
description: 这个问题对你的使用造成了多大影响?
|
||||||
|
options:
|
||||||
|
- 严重崩溃或数据丢失(无法使用)
|
||||||
|
- 核心功能受影响(在下一个常规发布中必须修复)
|
||||||
|
- 边缘场景或轻微问题(等待空闲时修复)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: 问题描述
|
||||||
|
description: 清晰描述你遇到的问题,包括实际发生了什么
|
||||||
|
placeholder: 例如:当我点击发送按钮时,应用程序崩溃并显示白屏
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: reproduction
|
||||||
|
attributes:
|
||||||
|
label: 复现步骤
|
||||||
|
description: 提供详细的操作步骤,让我们能够重现这个问题
|
||||||
|
placeholder: |
|
||||||
|
1. 打开应用并登录账号
|
||||||
|
2. 进入聊天页面
|
||||||
|
3. 点击发送按钮
|
||||||
|
4. 观察到应用崩溃
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: expected-behavior
|
||||||
|
attributes:
|
||||||
|
label: 预期行为
|
||||||
|
description: 描述你期望的正确行为应该是什么样的
|
||||||
|
placeholder: 例如:点击发送按钮后,消息应该正常发送并显示在聊天窗口中
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: actual-behavior
|
||||||
|
attributes:
|
||||||
|
label: 实际行为
|
||||||
|
description: 描述实际发生的错误行为
|
||||||
|
placeholder: 例如:点击后应用直接崩溃,显示白屏
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: 错误日志或截图
|
||||||
|
description: 粘贴控制台错误信息、崩溃日志,或拖入截图
|
||||||
|
placeholder: 请粘贴完整的错误堆栈信息
|
||||||
|
render: shell
|
||||||
|
- type: input
|
||||||
|
id: os
|
||||||
|
attributes:
|
||||||
|
label: 操作系统版本
|
||||||
|
description: 例如:Windows 11 24H2、macOS 15.0、Ubuntu 24.04
|
||||||
|
placeholder: Windows 11 24H2
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: app-version
|
||||||
|
attributes:
|
||||||
|
label: 应用版本
|
||||||
|
description: 在关于页面或设置中查看版本号
|
||||||
|
placeholder: v1.2.3
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: architecture
|
||||||
|
attributes:
|
||||||
|
label: 系统架构
|
||||||
|
description: 例如:x64、arm64
|
||||||
|
placeholder: x64
|
||||||
|
- type: textarea
|
||||||
|
id: additional-context
|
||||||
|
attributes:
|
||||||
|
label: 补充信息
|
||||||
|
description: 其他可能有助于定位问题的信息
|
||||||
|
placeholder: 例如:这个问题是在某次更新后开始出现的,或者只在特定网络环境下出现
|
||||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name:🤔 找不到合适的模板?
|
||||||
|
url: https://t.me/weflow_cc
|
||||||
|
about: 如果你的问题不属于上述任何分类,请前往我们的 Telegram 频道与我们交流。
|
||||||
67
.github/ISSUE_TEMPLATE/docs.yml
vendored
Normal file
67
.github/ISSUE_TEMPLATE/docs.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
name: "文档反馈"
|
||||||
|
description: "文档存在错别字、描述不清晰或缺少必要的示例"
|
||||||
|
title: "[Docs]: "
|
||||||
|
labels: ["type: docs"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
优秀的文档和代码一样重要。感谢你帮助我们完善文档!
|
||||||
|
- type: dropdown
|
||||||
|
id: doc-type
|
||||||
|
attributes:
|
||||||
|
label: 文档类型
|
||||||
|
description: 问题出现在哪类文档中?
|
||||||
|
options:
|
||||||
|
- README 或项目说明
|
||||||
|
- 安装部署文档
|
||||||
|
- 使用教程
|
||||||
|
- API 文档
|
||||||
|
- 开发者文档
|
||||||
|
- 其他
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: doc-link
|
||||||
|
attributes:
|
||||||
|
label: 文档位置
|
||||||
|
description: 提供文档的 URL 或文件路径
|
||||||
|
placeholder: 例如:docs/installation.md 或 https://github.com/xxx/xxx/wiki/xxx
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: issue-type
|
||||||
|
attributes:
|
||||||
|
label: 问题类型
|
||||||
|
description: 文档存在什么问题?
|
||||||
|
options:
|
||||||
|
- 错别字或语法错误
|
||||||
|
- 内容过时或不准确
|
||||||
|
- 描述不清晰或有歧义
|
||||||
|
- 缺少必要的示例代码
|
||||||
|
- 缺少重要的说明或警告
|
||||||
|
- 链接失效或错误
|
||||||
|
- 其他
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: issue-desc
|
||||||
|
attributes:
|
||||||
|
label: 问题描述
|
||||||
|
description: 详细说明文档中存在的问题
|
||||||
|
placeholder: 例如:第 3 步中的命令拼写错误,应该是 "npm install" 而不是 "npm instal"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: suggestion
|
||||||
|
attributes:
|
||||||
|
label: 修改建议
|
||||||
|
description: 你认为应该如何修改?
|
||||||
|
placeholder: 例如:建议将"安装依赖"部分补充完整的命令示例,并说明不同操作系统的差异
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: additional
|
||||||
|
attributes:
|
||||||
|
label: 补充说明
|
||||||
|
description: 其他需要补充的信息
|
||||||
78
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
Normal file
78
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
name: "功能与体验优化"
|
||||||
|
description: "对现有的功能逻辑进行优化,或改进用户体验"
|
||||||
|
title: "[Enhancement]: "
|
||||||
|
labels: ["type: enhancement"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
持续优化是项目进步的动力!请告诉我们哪个现有功能可以做得更好。
|
||||||
|
- type: checkboxes
|
||||||
|
id: pre-check
|
||||||
|
attributes:
|
||||||
|
label: 提交前确认
|
||||||
|
options:
|
||||||
|
- label: 我已搜索过现有的 Issues,确认这个优化建议尚未被提出
|
||||||
|
required: true
|
||||||
|
- label: 这是对现有功能的改进,而不是全新功能
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: category
|
||||||
|
attributes:
|
||||||
|
label: 优化类别
|
||||||
|
description: 这个优化主要属于哪个方面?
|
||||||
|
options:
|
||||||
|
- 性能优化(速度、内存、资源占用)
|
||||||
|
- 交互体验(操作流程、界面布局)
|
||||||
|
- 视觉设计(样式、动画、美观度)
|
||||||
|
- 易用性(降低使用门槛、减少操作步骤)
|
||||||
|
- 稳定性(减少崩溃、提高可靠性)
|
||||||
|
- 其他
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: target
|
||||||
|
attributes:
|
||||||
|
label: 目标功能或模块
|
||||||
|
description: 你希望优化的具体功能或页面是哪个?
|
||||||
|
placeholder: 例如:聊天页面的消息加载、设置页面的布局、文件上传功能
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: current-behavior
|
||||||
|
attributes:
|
||||||
|
label: 当前表现
|
||||||
|
description: 描述当前功能的不足之处或存在的问题
|
||||||
|
placeholder: 例如:消息列表滚动时会出现明显卡顿,加载 100 条消息需要 3 秒
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: improvement
|
||||||
|
attributes:
|
||||||
|
label: 优化建议
|
||||||
|
description: 详细说明你的优化方案和预期效果
|
||||||
|
placeholder: 例如:建议使用虚拟滚动技术,只渲染可见区域的消息,预计可将加载时间缩短到 0.5 秒以内
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: benefits
|
||||||
|
attributes:
|
||||||
|
label: 优化收益
|
||||||
|
description: 这个优化会带来什么具体好处?
|
||||||
|
placeholder: 例如:提升 80% 的加载速度、减少 50% 的内存占用、降低用户操作步骤从 5 步到 2 步
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: impact
|
||||||
|
attributes:
|
||||||
|
label: 影响范围
|
||||||
|
description: 这个优化会影响哪些用户或场景?
|
||||||
|
placeholder: 例如:所有用户在查看历史消息时都会受益,尤其是群聊消息较多的场景
|
||||||
|
- type: checkboxes
|
||||||
|
id: contribution
|
||||||
|
attributes:
|
||||||
|
label: 参与贡献
|
||||||
|
options:
|
||||||
|
- label: 我愿意提交 Pull Request 来实现这个优化
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
71
.github/ISSUE_TEMPLATE/feature.yml
vendored
Normal file
71
.github/ISSUE_TEMPLATE/feature.yml
vendored
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
name: "全新功能请求"
|
||||||
|
description: "提议一个目前项目中完全没有的新特性"
|
||||||
|
title: "[Feature]: "
|
||||||
|
labels: ["type: feature"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
感谢你为项目提供新想法!详细的需求描述能极大提高该功能被采纳的几率。
|
||||||
|
- type: checkboxes
|
||||||
|
id: pre-check
|
||||||
|
attributes:
|
||||||
|
label: 提交前确认
|
||||||
|
options:
|
||||||
|
- label: 我已搜索过现有的 Issues 和 Pull Requests,确认这个功能尚未被提出或实现
|
||||||
|
required: true
|
||||||
|
- label: 这是一个全新的功能,而不是对现有功能的改进
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: priority
|
||||||
|
attributes:
|
||||||
|
label: 功能优先级
|
||||||
|
description: 你认为这个功能有多重要?
|
||||||
|
options:
|
||||||
|
- 高优先级(核心功能缺失,严重影响使用体验)
|
||||||
|
- 中优先级(有助于提升使用体验)
|
||||||
|
- 低优先级(锦上添花的功能)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: 问题或痛点
|
||||||
|
description: 【为什么需要】你现在做某件事遇到了什么困难?缺少什么能力?
|
||||||
|
placeholder: 例如:目前无法批量导出聊天记录,每次只能手动复制单条消息,处理 100 条消息需要半小时
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: solution
|
||||||
|
attributes:
|
||||||
|
label: 期望的解决方案
|
||||||
|
description: 【怎么实现】详细描述功能的操作流程、界面位置、可选参数等
|
||||||
|
placeholder: 例如:在聊天窗口右键菜单添加"导出记录",点击后弹窗可选时间范围、导出格式(TXT/JSON)、筛选用户,最后保存到本地
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: use-case
|
||||||
|
attributes:
|
||||||
|
label: 使用场景
|
||||||
|
description: 【什么时候用】你会在哪些具体情况下使用这个功能?
|
||||||
|
placeholder: 例如:每周五整理工作讨论记录;保存客户沟通记录作为合同依据;备份重要群聊内容
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: 替代方案
|
||||||
|
description: 你目前使用什么临时方案?或者有没有考虑过其他实现方式?
|
||||||
|
placeholder: 例如:目前只能手动截图或逐条复制粘贴
|
||||||
|
- type: textarea
|
||||||
|
id: reference
|
||||||
|
attributes:
|
||||||
|
label: 参考示例
|
||||||
|
description: 其他应用中是否有类似功能可以参考?
|
||||||
|
placeholder: 例如:微信的聊天记录导出功能、Telegram 的导出数据功能
|
||||||
|
- type: checkboxes
|
||||||
|
id: contribution
|
||||||
|
attributes:
|
||||||
|
label: 参与贡献
|
||||||
|
options:
|
||||||
|
- label: 我愿意提交 Pull Request 来实现这个功能
|
||||||
71
.github/ISSUE_TEMPLATE/question.yml
vendored
Normal file
71
.github/ISSUE_TEMPLATE/question.yml
vendored
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
name: "使用答疑"
|
||||||
|
description: "关于如何配置、如何使用项目的求助"
|
||||||
|
title: "[Question]: "
|
||||||
|
labels: ["type: question"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
在提问之前,请确保你已经仔细阅读过我们的官方文档。
|
||||||
|
- type: checkboxes
|
||||||
|
id: pre-check
|
||||||
|
attributes:
|
||||||
|
label: 提交前确认
|
||||||
|
options:
|
||||||
|
- label: 我已阅读过相关文档
|
||||||
|
required: true
|
||||||
|
- label: 我已搜索过现有的 Issues,没有找到类似问题
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: question-type
|
||||||
|
attributes:
|
||||||
|
label: 问题类型
|
||||||
|
description: 你的问题属于哪个方面?
|
||||||
|
options:
|
||||||
|
- 安装部署问题
|
||||||
|
- 配置相关问题
|
||||||
|
- 功能使用问题
|
||||||
|
- API 调用问题
|
||||||
|
- 错误排查问题
|
||||||
|
- 其他
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: question
|
||||||
|
attributes:
|
||||||
|
label: 问题描述
|
||||||
|
description: 清晰描述你遇到的问题或疑问
|
||||||
|
placeholder: 例如:我在 Windows 系统上安装后无法启动应用,双击图标没有任何反应
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: attempts
|
||||||
|
attributes:
|
||||||
|
label: 已尝试的方法
|
||||||
|
description: 你已经尝试过哪些解决方法?
|
||||||
|
placeholder: 例如:我尝试过重新安装、以管理员身份运行、关闭防火墙,但问题依然存在
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: environment
|
||||||
|
attributes:
|
||||||
|
label: 运行环境
|
||||||
|
description: 提供你的系统环境信息
|
||||||
|
placeholder: |
|
||||||
|
操作系统:Windows 11
|
||||||
|
应用版本:v1.2.3
|
||||||
|
系统架构:x64
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: code-snippet
|
||||||
|
attributes:
|
||||||
|
label: 相关配置或代码
|
||||||
|
description: 如果涉及配置或代码问题,请粘贴相关内容
|
||||||
|
placeholder: 粘贴你的配置文件或代码片段
|
||||||
|
render: javascript
|
||||||
|
- type: textarea
|
||||||
|
id: screenshots
|
||||||
|
attributes:
|
||||||
|
label: 截图或日志
|
||||||
|
description: 如有必要,请提供截图或错误日志
|
||||||
7
.github/dependabot.yml
vendored
Normal file
7
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
||||||
|
target-branch: "dev"
|
||||||
134
.github/workflows/anti-spam.yml
vendored
Normal file
134
.github/workflows/anti-spam.yml
vendored
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
name: Anti-Spam
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [opened, edited]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-spam:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check for spam
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const issue = context.payload.issue;
|
||||||
|
const title = (issue.title || '').toLowerCase();
|
||||||
|
const body = (issue.body || '').toLowerCase();
|
||||||
|
const text = title + ' ' + body;
|
||||||
|
|
||||||
|
// 博彩/赌球类
|
||||||
|
const gamblingPatterns = [
|
||||||
|
/世界杯.*买球/, /买球.*世界杯/,
|
||||||
|
/世界杯.*下注/, /世界杯.*竞猜/,
|
||||||
|
/世界杯.*投注/, /世界杯.*押注/,
|
||||||
|
/世界杯.*彩票/, /世界杯.*平台/,
|
||||||
|
/世界杯.*app/, /世界杯.*软件/,
|
||||||
|
/世界杯.*网站/, /世界杯.*网址/,
|
||||||
|
/足球.*买球/, /买球.*足球/,
|
||||||
|
/足球.*投注/, /足球.*押注/,
|
||||||
|
/足球.*竞猜/, /足球.*平台/,
|
||||||
|
/篮球.*买球/, /篮球.*投注/,
|
||||||
|
/体育.*投注/, /体育.*竞猜/,
|
||||||
|
/体育.*买球/, /体育.*押注/,
|
||||||
|
/赌球/, /赌博.*网站/, /赌博.*平台/,
|
||||||
|
/博彩/, /博彩.*网站/, /博彩.*平台/,
|
||||||
|
/正规.*买球/, /官方.*买球/,
|
||||||
|
/买球.*网站/, /买球.*app/,
|
||||||
|
/买球.*软件/, /买球.*网址/,
|
||||||
|
/买球.*平台/, /买球.*技巧/,
|
||||||
|
/投注.*网站/, /投注.*平台/,
|
||||||
|
/押注.*网站/, /押注.*平台/,
|
||||||
|
/竞猜.*网站/, /竞猜.*平台/,
|
||||||
|
/彩票.*网站/, /彩票.*平台/,
|
||||||
|
/欧洲杯.*买球/, /欧冠.*买球/,
|
||||||
|
/nba.*买球/, /nba.*投注/,
|
||||||
|
];
|
||||||
|
|
||||||
|
// 色情/交友类
|
||||||
|
const adultPatterns = [
|
||||||
|
/约炮/, /一夜情/, /外围/,
|
||||||
|
/包养/, /援交/, /陪聊/,
|
||||||
|
/成人.*网站/, /成人.*视频/,
|
||||||
|
/av.*网站/, /黄色.*网站/,
|
||||||
|
];
|
||||||
|
|
||||||
|
// 贷款/金融诈骗类
|
||||||
|
const financePatterns = [
|
||||||
|
/秒到账.*贷款/, /无抵押.*贷款/,
|
||||||
|
/征信.*贷款/, /黑户.*贷款/,
|
||||||
|
/快速.*放款/, /私人.*放贷/,
|
||||||
|
/刷单/, /兼职.*日入/, /兼职.*月入/,
|
||||||
|
/网赚/, /躺赚/, /被动收入.*平台/,
|
||||||
|
/虚拟货币.*投资/, /usdt.*投资/,
|
||||||
|
/炒币.*平台/, /数字货币.*平台/,
|
||||||
|
];
|
||||||
|
|
||||||
|
// 垃圾推广类
|
||||||
|
const spamPromoPatterns = [
|
||||||
|
/代刷/, /粉丝.*购买/, /涨粉/,
|
||||||
|
/seo.*优化/, /快速排名/,
|
||||||
|
/微商/, /代理.*招募/,
|
||||||
|
];
|
||||||
|
|
||||||
|
// 账号特征检测(新账号 + 无 contribution)
|
||||||
|
const allPatterns = [
|
||||||
|
...gamblingPatterns,
|
||||||
|
...adultPatterns,
|
||||||
|
...financePatterns,
|
||||||
|
...spamPromoPatterns,
|
||||||
|
];
|
||||||
|
|
||||||
|
const isSpam = allPatterns.some(pattern => pattern.test(text));
|
||||||
|
|
||||||
|
// 额外检测:标题超短且含可疑关键词(常见于批量刷单)
|
||||||
|
const suspiciousShort = title.length < 10 && /(买球|投注|博彩|赌博|下注|押注)/.test(title);
|
||||||
|
|
||||||
|
if (isSpam || suspiciousShort) {
|
||||||
|
// 确保 spam label 存在
|
||||||
|
try {
|
||||||
|
await github.rest.issues.createLabel({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
name: 'spam',
|
||||||
|
color: 'e4e669',
|
||||||
|
description: 'Spam issue'
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// label 已存在,忽略
|
||||||
|
}
|
||||||
|
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issue.number,
|
||||||
|
body: '此 issue 已被自动识别为垃圾内容并关闭。\n\nThis issue has been automatically identified as spam and closed.'
|
||||||
|
});
|
||||||
|
|
||||||
|
await github.rest.issues.addLabels({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issue.number,
|
||||||
|
labels: ['spam']
|
||||||
|
});
|
||||||
|
|
||||||
|
await github.rest.issues.update({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issue.number,
|
||||||
|
state: 'closed',
|
||||||
|
state_reason: 'not_planned'
|
||||||
|
});
|
||||||
|
|
||||||
|
await github.rest.issues.lock({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issue.number,
|
||||||
|
lock_reason: 'spam'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Closed spam issue #${issue.number}: ${issue.title}`);
|
||||||
|
}
|
||||||
353
.github/workflows/dev-daily-fixed.yml
vendored
Normal file
353
.github/workflows/dev-daily-fixed.yml
vendored
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
name: Dev Daily
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# GitHub Actions schedule uses UTC. 16:00 UTC = 北京时间次日 00:00
|
||||||
|
- cron: "0 16 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: dev-nightly-fixed-release
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
|
FIXED_DEV_TAG: nightly-dev
|
||||||
|
TARGET_BRANCH: dev
|
||||||
|
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
prepare:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
dev_version: ${{ steps.meta.outputs.dev_version }}
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
ref: ${{ env.TARGET_BRANCH }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Generate daily dev version
|
||||||
|
id: meta
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
YEAR_2="$(TZ=Asia/Shanghai date +%y)"
|
||||||
|
MONTH="$(TZ=Asia/Shanghai date +%-m)"
|
||||||
|
DAY="$(TZ=Asia/Shanghai date +%-d)"
|
||||||
|
DEV_VERSION="${YEAR_2}.${MONTH}.${DAY}"
|
||||||
|
echo "dev_version=$DEV_VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Dev version: $DEV_VERSION"
|
||||||
|
|
||||||
|
- name: Recreate fixed prerelease
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if gh release view "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||||
|
gh release delete "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag
|
||||||
|
fi
|
||||||
|
gh release create "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" --title "Daily Dev Build" --notes "开发版发布页" --prerelease --target "$TARGET_BRANCH"
|
||||||
|
RELEASE_REST_ID="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_DEV_TAG" --jq '.id')"
|
||||||
|
gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_REST_ID" -f draft=false -f prerelease=true >/dev/null
|
||||||
|
|
||||||
|
dev-mac-arm64:
|
||||||
|
needs: prepare
|
||||||
|
runs-on: macos-14
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
ref: ${{ env.TARGET_BRANCH }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Set dev version
|
||||||
|
shell: bash
|
||||||
|
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Build Frontend & Type Check
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
npx tsc
|
||||||
|
npx vite build
|
||||||
|
|
||||||
|
- name: Package macOS arm64 dev artifacts
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/"
|
||||||
|
echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR"
|
||||||
|
npx electron-builder --mac dmg --arm64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-arm64.${ext}'
|
||||||
|
|
||||||
|
- name: Upload macOS arm64 assets to fixed release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
assets=()
|
||||||
|
while IFS= read -r file; do
|
||||||
|
assets+=("$file")
|
||||||
|
done < <(find release -maxdepth 1 -type f | sort)
|
||||||
|
if [ "${#assets[@]}" -eq 0 ]; then
|
||||||
|
echo "No release files found in ./release"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
gh release upload "$FIXED_DEV_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||||
|
|
||||||
|
dev-linux:
|
||||||
|
needs: prepare
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
ref: ${{ env.TARGET_BRANCH }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Set dev version
|
||||||
|
shell: bash
|
||||||
|
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Build Frontend & Type Check
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
npx tsc
|
||||||
|
npx vite build
|
||||||
|
|
||||||
|
- name: Package Linux dev artifacts
|
||||||
|
run: |
|
||||||
|
npx electron-builder --linux --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-linux.${ext}'
|
||||||
|
|
||||||
|
- name: Upload Linux assets to fixed release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
assets=()
|
||||||
|
while IFS= read -r file; do
|
||||||
|
assets+=("$file")
|
||||||
|
done < <(find release -maxdepth 1 -type f | sort)
|
||||||
|
if [ "${#assets[@]}" -eq 0 ]; then
|
||||||
|
echo "No release files found in ./release"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
gh release upload "$FIXED_DEV_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||||
|
|
||||||
|
dev-win-x64:
|
||||||
|
needs: prepare
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
ref: ${{ env.TARGET_BRANCH }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Set dev version
|
||||||
|
shell: bash
|
||||||
|
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Build Frontend & Type Check
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
npx tsc
|
||||||
|
npx vite build
|
||||||
|
|
||||||
|
- name: Package Windows x64 dev artifacts
|
||||||
|
run: |
|
||||||
|
npx electron-builder --win nsis --x64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-x64-Setup.${ext}'
|
||||||
|
|
||||||
|
- name: Upload Windows x64 assets to fixed release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
assets=()
|
||||||
|
while IFS= read -r file; do
|
||||||
|
assets+=("$file")
|
||||||
|
done < <(find release -maxdepth 1 -type f | sort)
|
||||||
|
if [ "${#assets[@]}" -eq 0 ]; then
|
||||||
|
echo "No release files found in ./release"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
gh release upload "$FIXED_DEV_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||||
|
|
||||||
|
dev-win-arm64:
|
||||||
|
needs: prepare
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
ref: ${{ env.TARGET_BRANCH }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Set dev version
|
||||||
|
shell: bash
|
||||||
|
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Build Frontend & Type Check
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
npx tsc
|
||||||
|
npx vite build
|
||||||
|
|
||||||
|
- name: Package Windows arm64 dev artifacts
|
||||||
|
run: |
|
||||||
|
npx electron-builder --win nsis --arm64 --publish never '--config.publish.channel=dev-arm64' '--config.artifactName=${productName}-dev-arm64-Setup.${ext}'
|
||||||
|
|
||||||
|
- name: Upload Windows arm64 assets to fixed release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
assets=()
|
||||||
|
while IFS= read -r file; do
|
||||||
|
assets+=("$file")
|
||||||
|
done < <(find release -maxdepth 1 -type f | sort)
|
||||||
|
if [ "${#assets[@]}" -eq 0 ]; then
|
||||||
|
echo "No release files found in ./release"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
gh release upload "$FIXED_DEV_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||||
|
|
||||||
|
update-dev-release-notes:
|
||||||
|
needs:
|
||||||
|
- prepare
|
||||||
|
- dev-mac-arm64
|
||||||
|
- dev-linux
|
||||||
|
- dev-win-x64
|
||||||
|
- dev-win-arm64
|
||||||
|
if: always() && needs.prepare.result == 'success'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Update fixed dev release notes
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
FIXED_DEV_TAG: ${{ env.FIXED_DEV_TAG }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TAG="$FIXED_DEV_TAG"
|
||||||
|
REPO="$GITHUB_REPOSITORY"
|
||||||
|
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
|
||||||
|
|
||||||
|
if ! gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then
|
||||||
|
echo "Release $TAG not found, skip notes update."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
ASSETS_JSON="$(gh release view "$TAG" --repo "$REPO" --json assets)"
|
||||||
|
|
||||||
|
pick_asset() {
|
||||||
|
local pattern="$1"
|
||||||
|
echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""'
|
||||||
|
}
|
||||||
|
|
||||||
|
WINDOWS_ASSET="$(pick_asset "dev-x64-Setup[.]exe$")"
|
||||||
|
WINDOWS_ARM64_ASSET="$(pick_asset "dev-arm64-Setup[.]exe$")"
|
||||||
|
MAC_ASSET="$(pick_asset "dev-arm64[.]dmg$")"
|
||||||
|
LINUX_TAR_ASSET="$(pick_asset "dev-linux[.]tar[.]gz$")"
|
||||||
|
LINUX_APPIMAGE_ASSET="$(pick_asset "dev-linux[.]AppImage$")"
|
||||||
|
|
||||||
|
build_link() {
|
||||||
|
local name="$1"
|
||||||
|
if [ -n "$name" ]; then
|
||||||
|
echo "https://github.com/$REPO/releases/download/$TAG/$name"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
WINDOWS_URL="$(build_link "$WINDOWS_ASSET")"
|
||||||
|
WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")"
|
||||||
|
MAC_URL="$(build_link "$MAC_ASSET")"
|
||||||
|
LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
|
||||||
|
LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
|
||||||
|
|
||||||
|
cat > dev_release_notes.md <<EOF
|
||||||
|
## Daily Dev Build
|
||||||
|
- 该发布页为 **开发版**。
|
||||||
|
- 当前构建版本:\`${{ needs.prepare.outputs.dev_version }}\`
|
||||||
|
- 此版本为每日构建的开发版,包含最新的功能和修复,但可能不够稳定,仅推荐用于测试和验证。
|
||||||
|
|
||||||
|
## 下载
|
||||||
|
- Windows x64: [点击下载](${WINDOWS_URL:-$RELEASE_PAGE})
|
||||||
|
- Windows arm64: [点击下载](${WINDOWS_ARM64_URL:-$RELEASE_PAGE})
|
||||||
|
- macOS(Apple Silicon): [点击下载](${MAC_URL:-$RELEASE_PAGE})
|
||||||
|
- Linux (.tar.gz): [点击下载](${LINUX_TAR_URL:-$RELEASE_PAGE})
|
||||||
|
- Linux (.AppImage): [点击下载](${LINUX_APPIMAGE_URL:-$RELEASE_PAGE})
|
||||||
|
|
||||||
|
## macOS 安装提示
|
||||||
|
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
|
||||||
|
- \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\`
|
||||||
|
- 执行后重新打开 WeFlow。
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
- 该发布页的同名资源会被后续构建覆盖,请勿将其视作长期归档版本。
|
||||||
|
- 如某个平台资源暂未生成,请进入[发布页]($RELEASE_PAGE)查看最新状态
|
||||||
|
EOF
|
||||||
|
|
||||||
|
update_release_notes() {
|
||||||
|
local attempts=5
|
||||||
|
local delay_seconds=2
|
||||||
|
local i
|
||||||
|
for ((i=1; i<=attempts; i++)); do
|
||||||
|
if gh release edit "$TAG" --repo "$REPO" --title "Daily Dev Build" --notes-file dev_release_notes.md --prerelease >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ "$i" -lt "$attempts" ]; then
|
||||||
|
echo "Release update failed (attempt $i/$attempts), retry in ${delay_seconds}s..."
|
||||||
|
sleep "$delay_seconds"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
update_release_notes
|
||||||
|
gh release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease,url
|
||||||
84
.github/workflows/issue-auto-assign.yml
vendored
Normal file
84
.github/workflows/issue-auto-assign.yml
vendored
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
name: Issue Auto Assign
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [opened, edited, reopened]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
assign-by-platform:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Assign issue by selected platform
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
env:
|
||||||
|
ASSIGNEE_WINDOWS: ${{ vars.ISSUE_ASSIGNEE_WINDOWS }}
|
||||||
|
ASSIGNEE_MACOS: ${{ vars.ISSUE_ASSIGNEE_MACOS }}
|
||||||
|
ASSIGNEE_LINUX: ${{ vars.ISSUE_ASSIGNEE_LINUX || 'H3CoF6' }}
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const issue = context.payload.issue;
|
||||||
|
if (!issue) {
|
||||||
|
core.info("No issue payload.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = (issue.labels || []).map((l) => l.name);
|
||||||
|
if (!labels.includes("type: bug")) {
|
||||||
|
core.info("Skip non-bug issue.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = issue.body || "";
|
||||||
|
const match = body.match(/###\s*(?:使用平台|平台|Platform)\s*\r?\n+([^\r\n]+)/i);
|
||||||
|
if (!match) {
|
||||||
|
core.info("No platform field found in issue body.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawPlatform = match[1].trim().toLowerCase();
|
||||||
|
let platformKey = null;
|
||||||
|
if (rawPlatform.includes("windows")) platformKey = "windows";
|
||||||
|
if (rawPlatform.includes("macos")) platformKey = "macos";
|
||||||
|
if (rawPlatform.includes("linux")) platformKey = "linux";
|
||||||
|
|
||||||
|
if (!platformKey) {
|
||||||
|
core.info(`Unrecognized platform value: ${rawPlatform}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseAssignees = (value) =>
|
||||||
|
(value || "")
|
||||||
|
.split(",")
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const assigneeMap = {
|
||||||
|
windows: parseAssignees(process.env.ASSIGNEE_WINDOWS),
|
||||||
|
macos: parseAssignees(process.env.ASSIGNEE_MACOS),
|
||||||
|
linux: parseAssignees(process.env.ASSIGNEE_LINUX),
|
||||||
|
};
|
||||||
|
|
||||||
|
const candidates = assigneeMap[platformKey] || [];
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
core.info(`No assignee configured for platform: ${platformKey}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = new Set((issue.assignees || []).map((a) => a.login));
|
||||||
|
const toAdd = candidates.filter((u) => !existing.has(u));
|
||||||
|
if (toAdd.length === 0) {
|
||||||
|
core.info("All configured assignees already assigned.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await github.rest.issues.addAssignees({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issue.number,
|
||||||
|
assignees: toAdd,
|
||||||
|
});
|
||||||
|
|
||||||
|
core.info(`Assigned issue #${issue.number} to: ${toAdd.join(", ")}`);
|
||||||
395
.github/workflows/preview-nightly-main.yml
vendored
Normal file
395
.github/workflows/preview-nightly-main.yml
vendored
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
name: Preview Nightly
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# GitHub Actions schedule uses UTC. 16:00 UTC = 北京时间次日 00:00
|
||||||
|
- cron: "0 16 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: preview-nightly-fixed-release
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
|
FIXED_PREVIEW_TAG: nightly-preview
|
||||||
|
TARGET_BRANCH: main
|
||||||
|
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
prepare:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
should_build: ${{ steps.meta.outputs.should_build }}
|
||||||
|
preview_version: ${{ steps.meta.outputs.preview_version }}
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
ref: ${{ env.TARGET_BRANCH }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Decide whether to build and generate preview version
|
||||||
|
id: meta
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
git fetch origin main --depth=1
|
||||||
|
COMMITS_24H="$(git rev-list --count --since='24 hours ago' origin/main)"
|
||||||
|
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
SHOULD_BUILD=true
|
||||||
|
elif [ "$COMMITS_24H" -gt 0 ]; then
|
||||||
|
SHOULD_BUILD=true
|
||||||
|
else
|
||||||
|
SHOULD_BUILD=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
YEAR_2="$(TZ=Asia/Shanghai date +%y)"
|
||||||
|
YEARLY_RUN_COUNT=1
|
||||||
|
LAST_VERSION="$(gh release view "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --json body --jq '.body' 2>/dev/null | grep -Eo '0\.[0-9]{2}\.[0-9]+' | head -n 1 || true)"
|
||||||
|
if [[ "$LAST_VERSION" =~ ^0\.([0-9]{2})\.([0-9]+)$ ]]; then
|
||||||
|
LAST_YEAR="${BASH_REMATCH[1]}"
|
||||||
|
LAST_COUNT="${BASH_REMATCH[2]}"
|
||||||
|
if [ "$LAST_YEAR" = "$YEAR_2" ]; then
|
||||||
|
YEARLY_RUN_COUNT=$((LAST_COUNT + 1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
PREVIEW_VERSION="0.${YEAR_2}.${YEARLY_RUN_COUNT}"
|
||||||
|
|
||||||
|
echo "should_build=$SHOULD_BUILD" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "preview_version=$PREVIEW_VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Preview version: $PREVIEW_VERSION (commits in last 24h on main: $COMMITS_24H, yearly count: $YEARLY_RUN_COUNT)"
|
||||||
|
|
||||||
|
- name: Recreate fixed preview prerelease
|
||||||
|
if: steps.meta.outputs.should_build == 'true'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if gh release view "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||||
|
gh release delete "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag
|
||||||
|
fi
|
||||||
|
gh release create "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --title "Preview Nightly Build" --notes "预览版发布页" --prerelease --target "$TARGET_BRANCH"
|
||||||
|
RELEASE_REST_ID="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_PREVIEW_TAG" --jq '.id')"
|
||||||
|
gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_REST_ID" -f draft=false -f prerelease=true >/dev/null
|
||||||
|
|
||||||
|
preview-mac-arm64:
|
||||||
|
needs: prepare
|
||||||
|
if: needs.prepare.outputs.should_build == 'true'
|
||||||
|
runs-on: macos-14
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
ref: ${{ env.TARGET_BRANCH }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Set preview version
|
||||||
|
shell: bash
|
||||||
|
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Build Frontend & Type Check
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
npx tsc
|
||||||
|
npx vite build
|
||||||
|
|
||||||
|
- name: Package macOS arm64 preview artifacts
|
||||||
|
env:
|
||||||
|
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/"
|
||||||
|
echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR"
|
||||||
|
npx electron-builder --mac dmg --arm64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-arm64.${ext}'
|
||||||
|
|
||||||
|
- name: Upload macOS arm64 assets to fixed preview release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
assets=()
|
||||||
|
while IFS= read -r file; do
|
||||||
|
assets+=("$file")
|
||||||
|
done < <(find release -maxdepth 1 -type f | sort)
|
||||||
|
if [ "${#assets[@]}" -eq 0 ]; then
|
||||||
|
echo "No release files found in ./release"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||||
|
|
||||||
|
preview-linux:
|
||||||
|
needs: prepare
|
||||||
|
if: needs.prepare.outputs.should_build == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
ref: ${{ env.TARGET_BRANCH }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Set preview version
|
||||||
|
shell: bash
|
||||||
|
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Build Frontend & Type Check
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
npx tsc
|
||||||
|
npx vite build
|
||||||
|
|
||||||
|
- name: Package Linux preview artifacts
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
npx electron-builder --linux --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-linux.${ext}'
|
||||||
|
|
||||||
|
- name: Upload Linux assets to fixed preview release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
assets=()
|
||||||
|
while IFS= read -r file; do
|
||||||
|
assets+=("$file")
|
||||||
|
done < <(find release -maxdepth 1 -type f | sort)
|
||||||
|
if [ "${#assets[@]}" -eq 0 ]; then
|
||||||
|
echo "No release files found in ./release"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||||
|
|
||||||
|
preview-win-x64:
|
||||||
|
needs: prepare
|
||||||
|
if: needs.prepare.outputs.should_build == 'true'
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
ref: ${{ env.TARGET_BRANCH }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Set preview version
|
||||||
|
shell: bash
|
||||||
|
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Build Frontend & Type Check
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
npx tsc
|
||||||
|
npx vite build
|
||||||
|
|
||||||
|
- name: Package Windows x64 preview artifacts
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
npx electron-builder --win nsis --x64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-x64-Setup.${ext}'
|
||||||
|
|
||||||
|
- name: Upload Windows x64 assets to fixed preview release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
assets=()
|
||||||
|
while IFS= read -r file; do
|
||||||
|
assets+=("$file")
|
||||||
|
done < <(find release -maxdepth 1 -type f | sort)
|
||||||
|
if [ "${#assets[@]}" -eq 0 ]; then
|
||||||
|
echo "No release files found in ./release"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||||
|
|
||||||
|
preview-win-arm64:
|
||||||
|
needs: prepare
|
||||||
|
if: needs.prepare.outputs.should_build == 'true'
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
ref: ${{ env.TARGET_BRANCH }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Set preview version
|
||||||
|
shell: bash
|
||||||
|
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Build Frontend & Type Check
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
npx tsc
|
||||||
|
npx vite build
|
||||||
|
|
||||||
|
- name: Package Windows arm64 preview artifacts
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
npx electron-builder --win nsis --arm64 --publish never '--config.publish.channel=preview-arm64' '--config.artifactName=${productName}-preview-arm64-Setup.${ext}'
|
||||||
|
|
||||||
|
- name: Upload Windows arm64 assets to fixed preview release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
assets=()
|
||||||
|
while IFS= read -r file; do
|
||||||
|
assets+=("$file")
|
||||||
|
done < <(find release -maxdepth 1 -type f | sort)
|
||||||
|
if [ "${#assets[@]}" -eq 0 ]; then
|
||||||
|
echo "No release files found in ./release"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||||
|
|
||||||
|
update-preview-release-notes:
|
||||||
|
needs:
|
||||||
|
- prepare
|
||||||
|
- preview-mac-arm64
|
||||||
|
- preview-linux
|
||||||
|
- preview-win-x64
|
||||||
|
- preview-win-arm64
|
||||||
|
if: needs.prepare.outputs.should_build == 'true' && always()
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Update preview release notes
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TAG="$FIXED_PREVIEW_TAG"
|
||||||
|
CURRENT_PREVIEW_VERSION="${{ needs.prepare.outputs.preview_version }}"
|
||||||
|
REPO="$GITHUB_REPOSITORY"
|
||||||
|
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
|
||||||
|
|
||||||
|
if ! gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then
|
||||||
|
echo "Release $TAG not found (possibly all publish jobs failed), skip notes update."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
ASSETS_JSON="$(gh release view "$TAG" --repo "$REPO" --json assets)"
|
||||||
|
|
||||||
|
pick_asset() {
|
||||||
|
local pattern="$1"
|
||||||
|
echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""'
|
||||||
|
}
|
||||||
|
|
||||||
|
WINDOWS_ASSET="$(pick_asset "x64.*[.]exe$")"
|
||||||
|
if [ -z "$WINDOWS_ASSET" ]; then
|
||||||
|
WINDOWS_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("[.]exe$")) | select(test("arm64") | not)][0] // ""')"
|
||||||
|
fi
|
||||||
|
WINDOWS_ARM64_ASSET="$(pick_asset "arm64.*[.]exe$")"
|
||||||
|
MAC_ASSET="$(pick_asset "[.]dmg$")"
|
||||||
|
LINUX_TAR_ASSET="$(pick_asset "[.]tar[.]gz$")"
|
||||||
|
LINUX_APPIMAGE_ASSET="$(pick_asset "[.]AppImage$")"
|
||||||
|
|
||||||
|
build_link() {
|
||||||
|
local name="$1"
|
||||||
|
if [ -n "$name" ]; then
|
||||||
|
echo "https://github.com/$REPO/releases/download/$TAG/$name"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
WINDOWS_URL="$(build_link "$WINDOWS_ASSET")"
|
||||||
|
WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")"
|
||||||
|
MAC_URL="$(build_link "$MAC_ASSET")"
|
||||||
|
LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
|
||||||
|
LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
|
||||||
|
|
||||||
|
cat > preview_release_notes.md <<EOF
|
||||||
|
## Preview Nightly 说明
|
||||||
|
- 该版本为 **预览版**,用于提前体验即将发布的功能与修复。
|
||||||
|
- 可能包含尚未完全稳定的改动,不建议长期使用
|
||||||
|
- 当前版本号:\`$CURRENT_PREVIEW_VERSION\`
|
||||||
|
|
||||||
|
## 下载
|
||||||
|
- Windows x64: [点击下载](${WINDOWS_URL:-$RELEASE_PAGE})
|
||||||
|
- Windows arm64: [点击下载](${WINDOWS_ARM64_URL:-$RELEASE_PAGE})
|
||||||
|
- macOS(Apple Silicon): [点击下载](${MAC_URL:-$RELEASE_PAGE})
|
||||||
|
- Linux (.tar.gz): [点击下载](${LINUX_TAR_URL:-$RELEASE_PAGE})
|
||||||
|
- Linux (.AppImage): [点击下载](${LINUX_APPIMAGE_URL:-$RELEASE_PAGE})
|
||||||
|
|
||||||
|
## macOS 安装提示
|
||||||
|
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
|
||||||
|
- \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\`
|
||||||
|
- 执行后重新打开 WeFlow。
|
||||||
|
|
||||||
|
> 如某个平台链接暂未生成,请前往[发布页]($RELEASE_PAGE)查看最新资源
|
||||||
|
EOF
|
||||||
|
|
||||||
|
update_release_notes() {
|
||||||
|
local attempts=5
|
||||||
|
local delay_seconds=2
|
||||||
|
local i
|
||||||
|
for ((i=1; i<=attempts; i++)); do
|
||||||
|
if gh release edit "$TAG" --repo "$REPO" --title "Preview Nightly Build" --notes-file preview_release_notes.md --prerelease >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ "$i" -lt "$attempts" ]; then
|
||||||
|
echo "Release update failed (attempt $i/$attempts), retry in ${delay_seconds}s..."
|
||||||
|
sleep "$delay_seconds"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
update_release_notes
|
||||||
|
gh release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease,url
|
||||||
262
.github/workflows/release.yml
vendored
262
.github/workflows/release.yml
vendored
@@ -8,24 +8,28 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
|
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release-mac-arm64:
|
||||||
runs-on: windows-latest
|
runs-on: macos-14
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out git repository
|
- name: Check out git repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: 22.12
|
node-version: 24
|
||||||
cache: 'npm'
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm ci
|
run: npm install
|
||||||
|
|
||||||
- name: Sync version with tag
|
- name: Sync version with tag
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -35,6 +39,115 @@ jobs:
|
|||||||
npm version $VERSION --no-git-tag-version --allow-same-version
|
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
- name: Build Frontend & Type Check
|
- name: Build Frontend & Type Check
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
npx tsc
|
||||||
|
npx vite build
|
||||||
|
|
||||||
|
- name: Package and Publish macOS arm64 (unsigned DMG)
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/"
|
||||||
|
echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR"
|
||||||
|
npx electron-builder --mac dmg --arm64 --publish always
|
||||||
|
|
||||||
|
- name: Inject minimumVersion into latest yml
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
TAG=${GITHUB_REF_NAME}
|
||||||
|
REPO=${{ github.repository }}
|
||||||
|
MINIMUM_VERSION="4.1.7"
|
||||||
|
for YML_FILE in latest-mac.yml latest-arm64-mac.yml; do
|
||||||
|
gh release download "$TAG" --repo "$REPO" --pattern "$YML_FILE" --output "/tmp/$YML_FILE" 2>/dev/null || continue
|
||||||
|
if ! grep -q 'minimumVersion' "/tmp/$YML_FILE"; then
|
||||||
|
echo "minimumVersion: $MINIMUM_VERSION" >> "/tmp/$YML_FILE"
|
||||||
|
fi
|
||||||
|
gh release upload "$TAG" --repo "$REPO" "/tmp/$YML_FILE" --clobber
|
||||||
|
done
|
||||||
|
|
||||||
|
release-linux:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Sync version with tag
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
VERSION=${GITHUB_REF_NAME#v}
|
||||||
|
echo "Syncing package.json version to $VERSION"
|
||||||
|
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Build Frontend & Type Check
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
npx tsc
|
||||||
|
npx vite build
|
||||||
|
|
||||||
|
- name: Package and Publish Linux
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
npx electron-builder --linux --publish always
|
||||||
|
|
||||||
|
- name: Inject minimumVersion into latest yml
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
TAG=${GITHUB_REF_NAME}
|
||||||
|
REPO=${{ github.repository }}
|
||||||
|
MINIMUM_VERSION="4.1.7"
|
||||||
|
gh release download "$TAG" --repo "$REPO" --pattern "latest-linux.yml" --output "/tmp/latest-linux.yml" 2>/dev/null
|
||||||
|
if [ -f /tmp/latest-linux.yml ] && ! grep -q 'minimumVersion' /tmp/latest-linux.yml; then
|
||||||
|
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest-linux.yml
|
||||||
|
gh release upload "$TAG" --repo "$REPO" /tmp/latest-linux.yml --clobber
|
||||||
|
fi
|
||||||
|
|
||||||
|
release:
|
||||||
|
runs-on: windows-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Sync version with tag
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
VERSION=${GITHUB_REF_NAME#v}
|
||||||
|
echo "Syncing package.json version to $VERSION"
|
||||||
|
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Build Frontend & Type Check
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
npx tsc
|
npx tsc
|
||||||
npx vite build
|
npx vite build
|
||||||
@@ -43,19 +156,142 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
npx electron-builder --publish always
|
npx electron-builder --win nsis --x64 --publish always '--config.artifactName=${productName}-${version}-x64-Setup.${ext}'
|
||||||
|
|
||||||
- name: Update Release Notes
|
- name: Inject minimumVersion into latest yml
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
cat <<EOF > release_notes.md
|
TAG=${GITHUB_REF_NAME}
|
||||||
|
REPO=${{ github.repository }}
|
||||||
|
MINIMUM_VERSION="4.1.7"
|
||||||
|
gh release download "$TAG" --repo "$REPO" --pattern "latest.yml" --output "/tmp/latest.yml" 2>/dev/null
|
||||||
|
if [ -f /tmp/latest.yml ] && ! grep -q 'minimumVersion' /tmp/latest.yml; then
|
||||||
|
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest.yml
|
||||||
|
gh release upload "$TAG" --repo "$REPO" /tmp/latest.yml --clobber
|
||||||
|
fi
|
||||||
|
|
||||||
|
release-windows-arm64:
|
||||||
|
runs-on: windows-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Sync version with tag
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
VERSION=${GITHUB_REF_NAME#v}
|
||||||
|
echo "Syncing package.json version to $VERSION"
|
||||||
|
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Build Frontend & Type Check
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
npx tsc
|
||||||
|
npx vite build
|
||||||
|
|
||||||
|
- name: Package and Publish Windows arm64
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
npx electron-builder --win nsis --arm64 --publish always '--config.publish.channel=latest-arm64' '--config.artifactName=${productName}-${version}-arm64-Setup.${ext}'
|
||||||
|
|
||||||
|
- name: Inject minimumVersion into latest yml
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
TAG=${GITHUB_REF_NAME}
|
||||||
|
REPO=${{ github.repository }}
|
||||||
|
MINIMUM_VERSION="4.1.7"
|
||||||
|
gh release download "$TAG" --repo "$REPO" --pattern "latest-arm64.yml" --output "/tmp/latest-arm64.yml" 2>/dev/null
|
||||||
|
if [ -f /tmp/latest-arm64.yml ] && ! grep -q 'minimumVersion' /tmp/latest-arm64.yml; then
|
||||||
|
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest-arm64.yml
|
||||||
|
gh release upload "$TAG" --repo "$REPO" /tmp/latest-arm64.yml --clobber
|
||||||
|
fi
|
||||||
|
|
||||||
|
update-release-notes:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs:
|
||||||
|
- release-mac-arm64
|
||||||
|
- release-linux
|
||||||
|
- release
|
||||||
|
- release-windows-arm64
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Generate release notes with platform download links
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TAG="$GITHUB_REF_NAME"
|
||||||
|
REPO="$GITHUB_REPOSITORY"
|
||||||
|
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
|
||||||
|
|
||||||
|
ASSETS_JSON="$(gh release view "$TAG" --repo "$REPO" --json assets)"
|
||||||
|
|
||||||
|
pick_asset() {
|
||||||
|
local pattern="$1"
|
||||||
|
echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""'
|
||||||
|
}
|
||||||
|
|
||||||
|
WINDOWS_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("x64.*\\.exe$"))][0] // ""')"
|
||||||
|
if [ -z "$WINDOWS_ASSET" ]; then
|
||||||
|
WINDOWS_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("\\.exe$")) | select(test("arm64") | not)][0] // ""')"
|
||||||
|
fi
|
||||||
|
WINDOWS_ARM64_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("arm64.*\\.exe$"))][0] // ""')"
|
||||||
|
MAC_ASSET="$(pick_asset "\\.dmg$")"
|
||||||
|
LINUX_TAR_ASSET="$(pick_asset "\\.tar\\.gz$")"
|
||||||
|
LINUX_APPIMAGE_ASSET="$(pick_asset "\\.AppImage$")"
|
||||||
|
|
||||||
|
build_link() {
|
||||||
|
local name="$1"
|
||||||
|
if [ -n "$name" ]; then
|
||||||
|
echo "https://github.com/$REPO/releases/download/$TAG/$name"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
WINDOWS_URL="$(build_link "$WINDOWS_ASSET")"
|
||||||
|
WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")"
|
||||||
|
MAC_URL="$(build_link "$MAC_ASSET")"
|
||||||
|
LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
|
||||||
|
LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
|
||||||
|
|
||||||
|
cat > release_notes.md <<EOF
|
||||||
## 更新日志
|
## 更新日志
|
||||||
修复了一些已知问题
|
修复了一些已知问题
|
||||||
|
|
||||||
## 加入我们的群
|
## 查看更多日志/获取最新动态
|
||||||
[点击加入 Telegram 群](https://t.me/+hn3QzNc4DbA0MzNl)
|
[点击加入 Telegram 频道](https://t.me/weflow_cc)
|
||||||
|
|
||||||
|
## 下载
|
||||||
|
- Windows x64(Win10+): [点击下载](${WINDOWS_URL:-$RELEASE_PAGE})
|
||||||
|
- Windows arm64: [点击下载](${WINDOWS_ARM64_URL:-$RELEASE_PAGE})
|
||||||
|
- macOS(M系列芯片): [点击下载](${MAC_URL:-$RELEASE_PAGE})
|
||||||
|
- Linux (.tar.gz): [点击下载](${LINUX_TAR_URL:-$RELEASE_PAGE})
|
||||||
|
- Linux (.AppImage): [点击下载](${LINUX_APPIMAGE_URL:-$RELEASE_PAGE})
|
||||||
|
|
||||||
|
## macOS 安装提示
|
||||||
|
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
|
||||||
|
- \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\`
|
||||||
|
- 执行后重新打开 WeFlow。
|
||||||
|
|
||||||
|
> 如果某个平台链接暂时未生成,可进入[完整发布页]($RELEASE_PAGE)查看全部资源
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
gh release edit "$GITHUB_REF_NAME" --notes-file release_notes.md
|
gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md
|
||||||
|
|||||||
96
.github/workflows/security-scan.yml
vendored
Normal file
96
.github/workflows/security-scan.yml
vendored
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
name: Security Scan
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 2 * * *' # 每天 UTC 02:00
|
||||||
|
workflow_dispatch: # 手动触发
|
||||||
|
pull_request: # PR 时触发
|
||||||
|
branches: [ main, dev ]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
actions: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
security-scan:
|
||||||
|
name: Security Scan (${{ matrix.branch }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
branch:
|
||||||
|
- main
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout ${{ matrix.branch }}
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
ref: ${{ matrix.branch }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: '24'
|
||||||
|
cache: 'npm' # 使用 npm 缓存加速
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci --ignore-scripts
|
||||||
|
|
||||||
|
# 1. npm audit - 检查依赖漏洞
|
||||||
|
- name: Dependency vulnerability audit
|
||||||
|
run: npm audit --audit-level=moderate
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
# 2. CodeQL 静态分析
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@v3
|
||||||
|
with:
|
||||||
|
languages: javascript, typescript
|
||||||
|
queries: security-and-quality
|
||||||
|
|
||||||
|
- name: Autobuild
|
||||||
|
uses: github/codeql-action/autobuild@v3
|
||||||
|
|
||||||
|
- name: Perform CodeQL Analysis
|
||||||
|
uses: github/codeql-action/analyze@v3
|
||||||
|
with:
|
||||||
|
category: '/language:javascript-typescript/branch:${{ matrix.branch }}'
|
||||||
|
|
||||||
|
# 3. 密钥/敏感信息扫描
|
||||||
|
- name: Secret scanning with Gitleaks
|
||||||
|
uses: gitleaks/gitleaks-action@v2
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
# 动态获取所有分支并扫描
|
||||||
|
scan-all-branches:
|
||||||
|
name: Scan additional branches
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: '24'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Run npm audit on all branches
|
||||||
|
run: |
|
||||||
|
git branch -r | grep -v HEAD | sed 's|origin/||' | tr -d ' ' | while read branch; do
|
||||||
|
echo "===== Auditing branch: $branch ====="
|
||||||
|
git checkout "$branch" 2>/dev/null || continue
|
||||||
|
# 尝试安装并审计
|
||||||
|
npm ci --ignore-scripts --silent 2>/dev/null || npm install --ignore-scripts --silent 2>/dev/null || true
|
||||||
|
npm audit --audit-level=moderate 2>/dev/null || true
|
||||||
|
done
|
||||||
|
continue-on-error: true
|
||||||
20
.gitignore
vendored
20
.gitignore
vendored
@@ -56,5 +56,23 @@ Thumbs.db
|
|||||||
*.aps
|
*.aps
|
||||||
|
|
||||||
wcdb/
|
wcdb/
|
||||||
|
!resources/wcdb/
|
||||||
|
!resources/wcdb/**
|
||||||
|
xkey/
|
||||||
|
server/
|
||||||
*info
|
*info
|
||||||
*.md
|
chatlab-format.md
|
||||||
|
*.bak
|
||||||
|
AGENTS.md
|
||||||
|
AGENT.md
|
||||||
|
.claude/
|
||||||
|
CLAUDE.md
|
||||||
|
.agents/
|
||||||
|
resources/wx_send
|
||||||
|
概述.md
|
||||||
|
pnpm-lock.yaml
|
||||||
|
/pnpm-workspace.yaml
|
||||||
|
wechat-research-site
|
||||||
|
.codex
|
||||||
|
weflow-web-offical
|
||||||
|
Insight
|
||||||
|
|||||||
23
.gitleaks.toml
Normal file
23
.gitleaks.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
title = "Gitleaks Config"
|
||||||
|
|
||||||
|
[extend]
|
||||||
|
# 继承默认规则
|
||||||
|
useDefault = true
|
||||||
|
|
||||||
|
# 排除误报路径
|
||||||
|
[[rules]]
|
||||||
|
id = "curl-auth-header"
|
||||||
|
[rules.allowlist]
|
||||||
|
paths = [
|
||||||
|
'''docs/HTTP-API\.md'''
|
||||||
|
]
|
||||||
|
regexes = [
|
||||||
|
'''YOUR_TOKEN'''
|
||||||
|
]
|
||||||
|
|
||||||
|
[[rules]]
|
||||||
|
id = "generic-api-key"
|
||||||
|
[rules.allowlist]
|
||||||
|
paths = [
|
||||||
|
'''src/pages/ChatPage\.tsx'''
|
||||||
|
]
|
||||||
4
.npmrc
4
.npmrc
@@ -1,3 +1 @@
|
|||||||
registry=https://registry.npmmirror.com
|
registry=https://registry.npmjs.org
|
||||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
|
||||||
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
|
||||||
|
|||||||
72
README.md
72
README.md
@@ -20,8 +20,11 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
|||||||
<a href="https://github.com/hicccc77/WeFlow/issues">
|
<a href="https://github.com/hicccc77/WeFlow/issues">
|
||||||
<img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat-square" alt="Issues">
|
<img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat-square" alt="Issues">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://t.me/+hn3QzNc4DbA0MzNl">
|
<a href="https://github.com/hicccc77/WeFlow/releases">
|
||||||
<img src="https://img.shields.io/badge/Telegram%20交流群-点击加入-0088cc?style=flat-square&logo=telegram&logoColor=0088cc&labelColor=white" alt="Telegram">
|
<img src="https://img.shields.io/github/downloads/hicccc77/WeFlow/total?style=flat-square" alt="Downloads" />
|
||||||
|
</a>
|
||||||
|
<a href="https://t.me/weflow_cc">
|
||||||
|
<img src="https://img.shields.io/badge/Telegram%20频道-0088cc?style=flat-square&logo=telegram&logoColor=0088cc&labelColor=white" alt="Telegram">
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -32,26 +35,64 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
|||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> 仅支持微信 **4.0 及以上**版本,确保你的微信版本符合要求
|
> 仅支持微信 **4.0 及以上**版本,确保你的微信版本符合要求
|
||||||
|
|
||||||
|
|
||||||
# 加入微信交流群
|
|
||||||
|
|
||||||
> 🎉 扫码加入微信群,与其他 WeFlow 用户一起交流问题和使用心得。
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<img src="mdassets/us.png" alt="WeFlow 微信交流群二维码" width="220" style="margin-right: 16px;"
|
|
||||||
</p>
|
|
||||||
|
|
||||||
## 主要功能
|
## 主要功能
|
||||||
|
|
||||||
- 本地实时查看聊天记录
|
- 本地实时查看聊天记录
|
||||||
|
- 朋友圈图片、视频、**实况**的预览和解密
|
||||||
- 统计分析与群聊画像
|
- 统计分析与群聊画像
|
||||||
- 年度报告与可视化概览
|
- 年度报告与可视化概览
|
||||||
- 导出聊天记录为 HTML 等格式
|
- 导出聊天记录为 HTML 等格式
|
||||||
|
- HTTP API 接口(供开发者集成)
|
||||||
|
- 查看完整能力清单:[详细功能](#详细功能清单)
|
||||||
|
|
||||||
|
## 支持平台与设备
|
||||||
|
|
||||||
|
|
||||||
|
| 平台 | 设备/架构 | 安装包 |
|
||||||
|
|------|----------|--------|
|
||||||
|
| Windows | Windows10+、x64(amd64) | `.exe` |
|
||||||
|
| macOS | Apple Silicon(M 系列,arm64) | `.dmg` |
|
||||||
|
| Linux | x64 设备(amd64) | `.AppImage`、`.tar.gz` |
|
||||||
|
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
若你只想使用成品版本,可前往 Release 下载并安装。
|
若你只想使用成品版本,可前往 [Releases](https://github.com/hicccc77/WeFlow/releases) 下载并安装。
|
||||||
|
|
||||||
|
> ArchLinux 用户可以选择 `yay -S weflow` 快速安装
|
||||||
|
|
||||||
|
## 详细功能清单
|
||||||
|
|
||||||
|
当前版本已支持以下能力:
|
||||||
|
|
||||||
|
| 功能模块 | 说明 |
|
||||||
|
|---------|------|
|
||||||
|
| **聊天** | 解密聊天中的图片、视频、实况(仅支持谷歌协议拍摄的实况);支持**修改**、删除**本地**消息;实时刷新最新消息,无需生成解密中间数据库 |
|
||||||
|
| **消息防撤回** | 防止其他人发送的消息被撤回 |
|
||||||
|
| **实时弹窗通知** | 新消息到达时提供桌面弹窗提醒,便于及时查看重要会话,提供黑白名单功能 |
|
||||||
|
| **私聊分析** | 统计好友间消息数量;分析消息类型与发送比例;查看消息时段分布等 |
|
||||||
|
| **群聊分析** | 查看群成员详细信息;分析群内发言排行、活跃时段和媒体内容 |
|
||||||
|
| **年度报告** | 生成按年统计的年度报告,或跨年度的长期历史报告 |
|
||||||
|
| **双人报告** | 选择指定好友,基于双方聊天记录生成专属分析报告 |
|
||||||
|
| **消息导出** | 将微信聊天记录导出为多种格式:JSON、HTML、TXT、Excel、CSV、PGSQL、ChatLab专属格式等 |
|
||||||
|
| **朋友圈** | 解密朋友圈图片、视频、实况;导出朋友圈内容;拦截朋友圈的删除与隐藏操作;突破时间访问限制 |
|
||||||
|
| **联系人** | 导出微信好友、群聊、公众号信息;尝试找回曾经的好友(功能尚不完善) |
|
||||||
|
| **HTTP API 映射** | 将本地消息能力映射为 HTTP API,便于对接外部系统、自动化脚本与二次开发 |
|
||||||
|
|
||||||
|
## HTTP API
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> 此功能目前处于早期阶段,接口可能会有变动,请等待后续更新完善。
|
||||||
|
|
||||||
|
WeFlow 提供本地 HTTP API 服务,支持通过接口查询消息数据,可用于与其他工具集成或二次开发。
|
||||||
|
|
||||||
|
- **启用方式**:设置 → API 服务 → 启动服务
|
||||||
|
- **默认端口**:5031
|
||||||
|
- **访问地址**:`http://127.0.0.1:5031`
|
||||||
|
- **支持格式**:原始 JSON 或 [ChatLab](https://chatlab.fun/) 标准格式
|
||||||
|
|
||||||
|
完整接口文档:[点击查看](docs/HTTP-API.md)
|
||||||
|
|
||||||
|
|
||||||
## 面向开发者
|
## 面向开发者
|
||||||
|
|
||||||
@@ -68,17 +109,12 @@ npm install
|
|||||||
# 3. 运行应用(开发模式)
|
# 3. 运行应用(开发模式)
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
# 4. 打包可执行文件
|
|
||||||
npm run build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
打包产物在 `release` 目录下。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 致谢
|
## 致谢
|
||||||
|
|
||||||
- [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架
|
- [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架
|
||||||
|
- [WeChat-Channels-Video-File-Decryption](https://github.com/Evil0ctal/WeChat-Channels-Video-File-Decryption) 提供了视频解密相关的技术参考
|
||||||
|
|
||||||
## 支持我们
|
## 支持我们
|
||||||
|
|
||||||
|
|||||||
650
docs/HTTP-API.md
Normal file
650
docs/HTTP-API.md
Normal file
@@ -0,0 +1,650 @@
|
|||||||
|
# WeFlow HTTP API / Push 文档
|
||||||
|
|
||||||
|
WeFlow 提供本地 HTTP API(已支持GET 和 POST请求),便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件;也支持在检测到新消息后通过固定 SSE 地址主动推送消息事件。
|
||||||
|
|
||||||
|
## 启用方式
|
||||||
|
|
||||||
|
在应用设置页启用 `API 服务`。
|
||||||
|
|
||||||
|
- 默认监听地址:`127.0.0.1`
|
||||||
|
- 默认端口:`5031`
|
||||||
|
- 基础地址:`http://127.0.0.1:5031`
|
||||||
|
- 可选开启 `主动推送`,检测到新收到的消息后会通过 `GET /api/v1/push/messages` 推送给 SSE 订阅端
|
||||||
|
|
||||||
|
**状态记忆**:API 服务和主动推送的状态及端口会自动保存,重启 WeFlow 后会自动恢复运行。
|
||||||
|
|
||||||
|
## 鉴权规范
|
||||||
|
|
||||||
|
**鉴权规范 (Access Token)** 除健康检查接口外,所有 `/api/v1/*` 接口均受 Token 保护。支持三种传参方式(任选其一):
|
||||||
|
|
||||||
|
1. **HTTP Header (推荐)**: `Authorization: Bearer <您的Token>`
|
||||||
|
2. **Query 参数**: `?access_token=<您的Token>`(SSE 长连接推荐此方式)
|
||||||
|
3. **JSON Body**: `{"access_token": "<您的Token>"}`(仅限 POST 请求)
|
||||||
|
|
||||||
|
## 接口列表
|
||||||
|
|
||||||
|
- `GET|POST /health`
|
||||||
|
- `GET|POST /api/v1/health`
|
||||||
|
- `GET|POST /api/v1/push/messages`
|
||||||
|
- `GET|POST /api/v1/messages`
|
||||||
|
- `GET|POST /api/v1/messages/new`
|
||||||
|
- `GET|POST /api/v1/sessions`
|
||||||
|
- `GET|POST /api/v1/contacts`
|
||||||
|
- `GET|POST /api/v1/group-members`
|
||||||
|
- `GET|POST /api/v1/media/*`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 健康检查
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /health
|
||||||
|
```
|
||||||
|
|
||||||
|
或
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/health
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 主动推送
|
||||||
|
|
||||||
|
通过 SSE 长连接接收新消息事件,端口与 HTTP API 共用。
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/push/messages
|
||||||
|
```
|
||||||
|
|
||||||
|
### 说明
|
||||||
|
|
||||||
|
- 需要先在设置页开启 `HTTP API 服务`
|
||||||
|
- 同时需要开启 `主动推送`
|
||||||
|
- 响应类型为 `text/event-stream`
|
||||||
|
- 新消息事件名固定为 `message.new`
|
||||||
|
- 建议接收端按 `messageKey` 去重
|
||||||
|
|
||||||
|
### 事件字段
|
||||||
|
|
||||||
|
- `event`
|
||||||
|
- `sessionId`
|
||||||
|
- `messageKey`
|
||||||
|
- `avatarUrl`
|
||||||
|
- `sourceName`
|
||||||
|
- `groupName`(仅群聊)
|
||||||
|
- `content`
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -N "http://127.0.0.1:5031/api/v1/push/messages?access_token=YOUR_TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
示例事件:
|
||||||
|
|
||||||
|
```text
|
||||||
|
event: message.new
|
||||||
|
data: {"event":"message.new","sessionId":"xxx@chatroom","messageKey":"server:123456:1760000123:1760000123000:321:wxid_member:1","avatarUrl":"https://example.com/group.jpg","sourceName":"李四","groupName":"项目群","content":"[图片]"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 获取消息
|
||||||
|
|
||||||
|
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||||
|
|
||||||
|
读取指定会话的消息,支持原始 JSON 和 ChatLab 格式。
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/messages
|
||||||
|
```
|
||||||
|
|
||||||
|
### 参数
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `talker` | string | 是 | 会话 ID。私聊通常是对方 `wxid`,群聊是 `xxx@chatroom` |
|
||||||
|
| `limit` | number | 否 | 返回条数,默认 `100`,范围 `1~10000` |
|
||||||
|
| `offset` | number | 否 | 分页偏移,默认 `0` |
|
||||||
|
| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或时间戳 |
|
||||||
|
| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或时间戳 |
|
||||||
|
| `keyword` | string | 否 | 基于消息显示文本过滤 |
|
||||||
|
| `chatlab` | string | 否 | `1/true` 时输出 ChatLab 格式 |
|
||||||
|
| `format` | string | 否 | `json` 或 `chatlab` |
|
||||||
|
| `media` | string | 否 | `1/true` 时导出媒体并返回媒体地址,兼容别名 `meiti` |
|
||||||
|
| `image` | string | 否 | 在 `media=1` 时控制图片导出,兼容别名 `tupian` |
|
||||||
|
| `voice` | string | 否 | 在 `media=1` 时控制语音导出,兼容别名 `vioce` |
|
||||||
|
| `video` | string | 否 | 在 `media=1` 时控制视频导出 |
|
||||||
|
| `emoji` | string | 否 | 在 `media=1` 时控制表情导出 |
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=20"
|
||||||
|
curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&chatlab=1"
|
||||||
|
curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=20260131"
|
||||||
|
curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&voice=0&video=0&emoji=0"
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON 响应字段
|
||||||
|
|
||||||
|
顶层字段:
|
||||||
|
|
||||||
|
- `success`
|
||||||
|
- `talker`
|
||||||
|
- `count`
|
||||||
|
- `hasMore`
|
||||||
|
- `media.enabled`
|
||||||
|
- `media.exportPath`
|
||||||
|
- `media.count`
|
||||||
|
- `messages`
|
||||||
|
|
||||||
|
单条消息字段:
|
||||||
|
|
||||||
|
- `localId`
|
||||||
|
- `serverId`
|
||||||
|
- `localType`
|
||||||
|
- `createTime`
|
||||||
|
- `isSend`
|
||||||
|
- `senderUsername`
|
||||||
|
- `content`
|
||||||
|
- `rawContent`
|
||||||
|
- `parsedContent`
|
||||||
|
- `mediaType`
|
||||||
|
- `mediaFileName`
|
||||||
|
- `mediaUrl`
|
||||||
|
- `mediaLocalPath`
|
||||||
|
|
||||||
|
**示例响应**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"talker": "xxx@chatroom",
|
||||||
|
"count": 2,
|
||||||
|
"hasMore": true,
|
||||||
|
"media": {
|
||||||
|
"enabled": true,
|
||||||
|
"exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media",
|
||||||
|
"count": 1
|
||||||
|
},
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"localId": 123,
|
||||||
|
"serverId": "456",
|
||||||
|
"localType": 1,
|
||||||
|
"createTime": 1738713600,
|
||||||
|
"isSend": 0,
|
||||||
|
"senderUsername": "wxid_member",
|
||||||
|
"content": "你好",
|
||||||
|
"rawContent": "你好",
|
||||||
|
"parsedContent": "你好"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"localId": 124,
|
||||||
|
"localType": 3,
|
||||||
|
"createTime": 1738713660,
|
||||||
|
"isSend": 0,
|
||||||
|
"senderUsername": "wxid_member",
|
||||||
|
"content": "[图片]",
|
||||||
|
"mediaType": "image",
|
||||||
|
"mediaFileName": "abc123.jpg",
|
||||||
|
"mediaUrl": "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/images/abc123.jpg",
|
||||||
|
"mediaLocalPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\xxx@chatroom\\images\\abc123.jpg"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ChatLab 响应
|
||||||
|
|
||||||
|
当 `chatlab=1` 或 `format=chatlab` 时,返回 ChatLab 结构:
|
||||||
|
|
||||||
|
- `chatlab.version`
|
||||||
|
- `chatlab.exportedAt`
|
||||||
|
- `chatlab.generator`
|
||||||
|
- `meta.name`
|
||||||
|
- `meta.platform`
|
||||||
|
- `meta.type`
|
||||||
|
- `meta.groupId`
|
||||||
|
- `meta.groupAvatar`
|
||||||
|
- `meta.ownerId`
|
||||||
|
- `members[].platformId`
|
||||||
|
- `members[].accountName`
|
||||||
|
- `members[].groupNickname`
|
||||||
|
- `members[].avatar`
|
||||||
|
- `messages[].sender`
|
||||||
|
- `messages[].accountName`
|
||||||
|
- `messages[].groupNickname`
|
||||||
|
- `messages[].timestamp`
|
||||||
|
- `messages[].type`
|
||||||
|
- `messages[].content`
|
||||||
|
- `messages[].platformMessageId`
|
||||||
|
- `messages[].mediaPath`
|
||||||
|
|
||||||
|
群聊里 `groupNickname` 会优先来自群成员群昵称;若源数据缺失,则回退为空或展示名。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 获取会话列表
|
||||||
|
|
||||||
|
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/sessions
|
||||||
|
```
|
||||||
|
|
||||||
|
### 参数
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `keyword` | string | 否 | 匹配 `username` 或 `displayName` |
|
||||||
|
| `limit` | number | 否 | 默认 `100` |
|
||||||
|
|
||||||
|
### 响应字段
|
||||||
|
|
||||||
|
- `success`
|
||||||
|
- `count`
|
||||||
|
- `sessions[].username`
|
||||||
|
- `sessions[].displayName`
|
||||||
|
- `sessions[].type`
|
||||||
|
- `sessions[].lastTimestamp`
|
||||||
|
- `sessions[].unreadCount`
|
||||||
|
|
||||||
|
**示例响应**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"count": 1,
|
||||||
|
"sessions": [
|
||||||
|
{
|
||||||
|
"username": "xxx@chatroom",
|
||||||
|
"displayName": "项目群",
|
||||||
|
"type": 2,
|
||||||
|
"lastTimestamp": 1738713600,
|
||||||
|
"unreadCount": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 获取联系人列表
|
||||||
|
|
||||||
|
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/contacts
|
||||||
|
```
|
||||||
|
|
||||||
|
### 参数
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `keyword` | string | 否 | 匹配 `username`、`nickname`、`remark`、`displayName` |
|
||||||
|
| `limit` | number | 否 | 默认 `100` |
|
||||||
|
|
||||||
|
### 响应字段
|
||||||
|
|
||||||
|
- `success`
|
||||||
|
- `count`
|
||||||
|
- `contacts[].username`
|
||||||
|
- `contacts[].displayName`
|
||||||
|
- `contacts[].remark`
|
||||||
|
- `contacts[].nickname`
|
||||||
|
- `contacts[].alias`
|
||||||
|
- `contacts[].avatarUrl`
|
||||||
|
- `contacts[].type`
|
||||||
|
|
||||||
|
**示例响应**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"count": 1,
|
||||||
|
"contacts": [
|
||||||
|
{
|
||||||
|
"username": "wxid_xxx",
|
||||||
|
"displayName": "张三",
|
||||||
|
"remark": "客户张三",
|
||||||
|
"nickname": "张三",
|
||||||
|
"alias": "zhangsan",
|
||||||
|
"avatarUrl": "https://example.com/avatar.jpg",
|
||||||
|
"type": "friend"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 获取群成员列表
|
||||||
|
|
||||||
|
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||||
|
|
||||||
|
返回群成员的 `wxid`、群昵称、备注、微信号等信息。
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/group-members
|
||||||
|
```
|
||||||
|
|
||||||
|
### 参数
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `chatroomId` | string | 是 | 群 ID,兼容使用 `talker` 传入 |
|
||||||
|
| `includeMessageCounts` | string | 否 | `1/true` 时附带成员发言数 |
|
||||||
|
| `withCounts` | string | 否 | `includeMessageCounts` 的别名 |
|
||||||
|
| `forceRefresh` | string | 否 | `1/true` 时跳过内存缓存强制刷新 |
|
||||||
|
|
||||||
|
### 响应字段
|
||||||
|
|
||||||
|
- `success`
|
||||||
|
- `chatroomId`
|
||||||
|
- `count`
|
||||||
|
- `fromCache`
|
||||||
|
- `updatedAt`
|
||||||
|
- `members[].wxid`
|
||||||
|
- `members[].displayName`
|
||||||
|
- `members[].nickname`
|
||||||
|
- `members[].remark`
|
||||||
|
- `members[].alias`
|
||||||
|
- `members[].groupNickname`
|
||||||
|
- `members[].avatarUrl`
|
||||||
|
- `members[].isOwner`
|
||||||
|
- `members[].isFriend`
|
||||||
|
- `members[].messageCount`
|
||||||
|
|
||||||
|
**示例请求**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom"
|
||||||
|
curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1&forceRefresh=1"
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例响应**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"chatroomId": "xxx@chatroom",
|
||||||
|
"count": 2,
|
||||||
|
"fromCache": false,
|
||||||
|
"updatedAt": 1760000000000,
|
||||||
|
"members": [
|
||||||
|
{
|
||||||
|
"wxid": "wxid_member_a",
|
||||||
|
"displayName": "客户A",
|
||||||
|
"nickname": "阿甲",
|
||||||
|
"remark": "客户A",
|
||||||
|
"alias": "kehua",
|
||||||
|
"groupNickname": "甲方",
|
||||||
|
"avatarUrl": "https://example.com/a.jpg",
|
||||||
|
"isOwner": true,
|
||||||
|
"isFriend": true,
|
||||||
|
"messageCount": 128
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"wxid": "wxid_member_b",
|
||||||
|
"displayName": "李四",
|
||||||
|
"nickname": "李四",
|
||||||
|
"remark": "",
|
||||||
|
"alias": "",
|
||||||
|
"groupNickname": "",
|
||||||
|
"avatarUrl": "",
|
||||||
|
"isOwner": false,
|
||||||
|
"isFriend": false,
|
||||||
|
"messageCount": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- `displayName` 是当前应用内的主展示名。
|
||||||
|
- `groupNickname` 是成员在该群里的群昵称。
|
||||||
|
- `remark` 是你对该联系人的备注。
|
||||||
|
- `alias` 是微信号。
|
||||||
|
- 当微信源数据里没有群昵称时,`groupNickname` 会为空。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 朋友圈接口
|
||||||
|
|
||||||
|
### 7.1 获取朋友圈时间线
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/sns/timeline
|
||||||
|
```
|
||||||
|
|
||||||
|
参数:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `limit` | number | 否 | 返回数量,默认 20,范围 `1~200` |
|
||||||
|
| `offset` | number | 否 | 偏移量,默认 0 |
|
||||||
|
| `usernames` | string | 否 | 发布者过滤,逗号分隔,如 `wxid_a,wxid_b` |
|
||||||
|
| `keyword` | string | 否 | 关键词过滤(正文) |
|
||||||
|
| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或秒/毫秒时间戳 |
|
||||||
|
| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或秒/毫秒时间戳 |
|
||||||
|
| `media` | number | 否 | 是否返回可直接访问的媒体地址,默认 `1` |
|
||||||
|
| `replace` | number | 否 | `media=1` 时,是否用解析地址覆盖 `media.url/thumb`,默认 `1` |
|
||||||
|
| `inline` | number | 否 | `media=1` 时,是否内联返回 `data:` URL,默认 `0` |
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=5"
|
||||||
|
curl "http://127.0.0.1:5031/api/v1/sns/timeline?usernames=wxid_a,wxid_b&keyword=旅行"
|
||||||
|
curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=3&media=1&replace=1"
|
||||||
|
curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=3&media=1&inline=1"
|
||||||
|
```
|
||||||
|
|
||||||
|
媒体字段说明(`media=1`):
|
||||||
|
|
||||||
|
- `media[].url/thumb`:你应该优先直接使用的字段。
|
||||||
|
- `replace=1`(默认)时,`media[].url/thumb` 会直接被替换成可访问地址,等价于 `resolvedUrl/resolvedThumbUrl`。
|
||||||
|
- `replace=0` 时,`media[].url/thumb` 仍保留微信原始地址;这时再结合下面的 `raw/proxy/resolved` 字段自己决定用哪一个。
|
||||||
|
- `media[].rawUrl/rawThumb`:原始朋友圈地址
|
||||||
|
- `media[].proxyUrl/proxyThumbUrl`:可直接访问的代理地址
|
||||||
|
- `media[].resolvedUrl/resolvedThumbUrl`:最终可用地址(`inline=1` 时可能是 `data:` URL)
|
||||||
|
- `media[].token/key/encIdx`:微信源数据里的访问/解密参数。通常不需要你自己处理;如果你手动调用 `/api/v1/sns/media/proxy`,把当前条目的 `url` 和 `key` 原样传回即可。
|
||||||
|
- `media[].livePhoto`:实况图的视频部分。外层 `media[].url/thumb` 仍是封面图,`livePhoto` 内部会再提供一组自己的 `url/thumb/raw*/proxy*/resolved*` 字段。
|
||||||
|
- `media=0` 时,不会补充 `raw*/proxy*/resolved*`,接口只返回原始 `url/thumb` 以及源字段(如 `key/token/encIdx`)。
|
||||||
|
|
||||||
|
### 7.2 获取朋友圈发布者
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/sns/usernames
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 获取朋友圈导出统计
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/sns/export/stats
|
||||||
|
```
|
||||||
|
|
||||||
|
参数:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `fast` | number | 否 | `1` 使用快速统计(优先缓存) |
|
||||||
|
|
||||||
|
### 7.4 朋友圈媒体代理
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/sns/media/proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
参数:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `url` | string | 是 | 媒体原始 URL |
|
||||||
|
| `key` | string/number | 否 | 解密 key(部分资源需要) |
|
||||||
|
|
||||||
|
### 7.5 导出朋友圈
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/sns/export
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
Body 示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"outputDir": "C:\\Users\\Alice\\Desktop\\sns-export",
|
||||||
|
"format": "json",
|
||||||
|
"usernames": "wxid_a,wxid_b",
|
||||||
|
"keyword": "旅行",
|
||||||
|
"exportMedia": true,
|
||||||
|
"exportImages": true,
|
||||||
|
"exportLivePhotos": true,
|
||||||
|
"exportVideos": true,
|
||||||
|
"start": "20250101",
|
||||||
|
"end": "20251231"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`format` 支持:`json`、`html`、`arkmejson`(兼容写法:`arkme-json`)。
|
||||||
|
|
||||||
|
### 7.6 朋友圈防删开关
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/sns/block-delete/status
|
||||||
|
POST /api/v1/sns/block-delete/install
|
||||||
|
POST /api/v1/sns/block-delete/uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.7 删除单条朋友圈
|
||||||
|
|
||||||
|
```http
|
||||||
|
DELETE /api/v1/sns/post/{postId}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 访问导出媒体
|
||||||
|
|
||||||
|
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||||
|
|
||||||
|
通过消息接口启用 `media=1` 后,接口会先把图片、语音、视频、表情导出到本地缓存目录,再返回可访问的 HTTP 地址。
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/media/{relativePath}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/images/abc123.jpg"
|
||||||
|
curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/voices/voice_100.wav"
|
||||||
|
curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/videos/video_200.mp4"
|
||||||
|
curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 支持的 Content-Type
|
||||||
|
|
||||||
|
| 扩展名 | Content-Type |
|
||||||
|
| --- | --- |
|
||||||
|
| `.png` | `image/png` |
|
||||||
|
| `.jpg` / `.jpeg` | `image/jpeg` |
|
||||||
|
| `.gif` | `image/gif` |
|
||||||
|
| `.webp` | `image/webp` |
|
||||||
|
| `.wav` | `audio/wav` |
|
||||||
|
| `.mp3` | `audio/mpeg` |
|
||||||
|
| `.mp4` | `video/mp4` |
|
||||||
|
|
||||||
|
常见错误响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Media not found"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 使用示例
|
||||||
|
|
||||||
|
### PowerShell
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$headers = @{ "Authorization" = "Bearer YOUR_TOKEN" }
|
||||||
|
$body = @{ talker = "wxid_xxx"; limit = 10 } | ConvertTo-Json
|
||||||
|
|
||||||
|
Invoke-RestMethod -Uri "http://127.0.0.1:5031/api/v1/messages" -Method POST -Headers $headers -Body $body -ContentType "application/json"
|
||||||
|
```
|
||||||
|
|
||||||
|
### cURL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# GET 带 Token Header
|
||||||
|
curl -H "Authorization: Bearer YOUR_TOKEN" "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx"
|
||||||
|
|
||||||
|
# POST 带 JSON Body
|
||||||
|
curl -X POST http://127.0.0.1:5031/api/v1/messages \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"talker": "xxx@chatroom", "chatlab": true}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
BASE_URL = "http://127.0.0.1:5031"
|
||||||
|
headers = {"Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
# POST 方式获取消息
|
||||||
|
messages = requests.post(
|
||||||
|
f"{BASE_URL}/api/v1/messages",
|
||||||
|
json={"talker": "xxx@chatroom", "limit": 50},
|
||||||
|
headers=headers
|
||||||
|
).json()
|
||||||
|
|
||||||
|
# GET 方式获取群成员
|
||||||
|
members = requests.get(
|
||||||
|
f"{BASE_URL}/api/v1/group-members",
|
||||||
|
params={"chatroomId": "xxx@chatroom", "includeMessageCounts": 1},
|
||||||
|
headers=headers
|
||||||
|
).json()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 注意事项
|
||||||
|
|
||||||
|
1. API 仅监听本机 `127.0.0.1`,不对外网开放。
|
||||||
|
2. 使用前需要先在 WeFlow 中完成数据库连接。
|
||||||
|
3. `start` 和 `end` 支持 `YYYYMMDD` 与时间戳;纯 `YYYYMMDD` 的 `end` 会扩展到当天 `23:59:59`。
|
||||||
|
4. 群成员的 `groupNickname` 依赖微信源数据;源数据缺失时不会自动补出。
|
||||||
|
5. 媒体访问链接只有在对应消息已经通过 `media=1` 导出后才可访问。
|
||||||
4707
electron/assets/wasm/wasm_video_decode.js
Normal file
4707
electron/assets/wasm/wasm_video_decode.js
Normal file
File diff suppressed because it is too large
Load Diff
BIN
electron/assets/wasm/wasm_video_decode.wasm
Normal file
BIN
electron/assets/wasm/wasm_video_decode.wasm
Normal file
Binary file not shown.
@@ -11,6 +11,7 @@ interface WorkerConfig {
|
|||||||
resourcesPath?: string
|
resourcesPath?: string
|
||||||
userDataPath?: string
|
userDataPath?: string
|
||||||
logEnabled?: boolean
|
logEnabled?: boolean
|
||||||
|
excludeWords?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = workerData as WorkerConfig
|
const config = workerData as WorkerConfig
|
||||||
@@ -29,6 +30,7 @@ async function run() {
|
|||||||
dbPath: config.dbPath,
|
dbPath: config.dbPath,
|
||||||
decryptKey: config.decryptKey,
|
decryptKey: config.decryptKey,
|
||||||
wxid: config.myWxid,
|
wxid: config.myWxid,
|
||||||
|
excludeWords: config.excludeWords,
|
||||||
onProgress: (status: string, progress: number) => {
|
onProgress: (status: string, progress: number) => {
|
||||||
parentPort?.postMessage({
|
parentPort?.postMessage({
|
||||||
type: 'dualReport:progress',
|
type: 'dualReport:progress',
|
||||||
|
|||||||
14
electron/entitlements.mac.plist
Normal file
14
electron/entitlements.mac.plist
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.cs.debugger</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.get-task-allow</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
64
electron/exportWorker.ts
Normal file
64
electron/exportWorker.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { parentPort, workerData } from 'worker_threads'
|
||||||
|
import type { ExportOptions } from './services/exportService'
|
||||||
|
|
||||||
|
interface ExportWorkerConfig {
|
||||||
|
sessionIds: string[]
|
||||||
|
outputDir: string
|
||||||
|
options: ExportOptions
|
||||||
|
dbPath?: string
|
||||||
|
decryptKey?: string
|
||||||
|
myWxid?: string
|
||||||
|
resourcesPath?: string
|
||||||
|
userDataPath?: string
|
||||||
|
logEnabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = workerData as ExportWorkerConfig
|
||||||
|
process.env.WEFLOW_WORKER = '1'
|
||||||
|
if (config.resourcesPath) {
|
||||||
|
process.env.WCDB_RESOURCES_PATH = config.resourcesPath
|
||||||
|
}
|
||||||
|
if (config.userDataPath) {
|
||||||
|
process.env.WEFLOW_USER_DATA_PATH = config.userDataPath
|
||||||
|
process.env.WEFLOW_CONFIG_CWD = config.userDataPath
|
||||||
|
}
|
||||||
|
process.env.WEFLOW_PROJECT_NAME = process.env.WEFLOW_PROJECT_NAME || 'WeFlow'
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const [{ wcdbService }, { exportService }] = await Promise.all([
|
||||||
|
import('./services/wcdbService'),
|
||||||
|
import('./services/exportService')
|
||||||
|
])
|
||||||
|
|
||||||
|
wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '')
|
||||||
|
wcdbService.setLogEnabled(config.logEnabled === true)
|
||||||
|
exportService.setRuntimeConfig({
|
||||||
|
dbPath: config.dbPath,
|
||||||
|
decryptKey: config.decryptKey,
|
||||||
|
myWxid: config.myWxid
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await exportService.exportSessions(
|
||||||
|
Array.isArray(config.sessionIds) ? config.sessionIds : [],
|
||||||
|
String(config.outputDir || ''),
|
||||||
|
config.options || { format: 'json' },
|
||||||
|
(progress) => {
|
||||||
|
parentPort?.postMessage({
|
||||||
|
type: 'export:progress',
|
||||||
|
data: progress
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
parentPort?.postMessage({
|
||||||
|
type: 'export:result',
|
||||||
|
data: result
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((error) => {
|
||||||
|
parentPort?.postMessage({
|
||||||
|
type: 'export:error',
|
||||||
|
error: String(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -10,7 +10,7 @@ type WorkerPayload = {
|
|||||||
thumbOnly: boolean
|
thumbOnly: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type Candidate = { score: number; path: string; isThumb: boolean; hasX: boolean }
|
type Candidate = { score: number; path: string; isThumb: boolean }
|
||||||
|
|
||||||
const payload = workerData as WorkerPayload
|
const payload = workerData as WorkerPayload
|
||||||
|
|
||||||
@@ -18,16 +18,26 @@ function looksLikeMd5(value: string): boolean {
|
|||||||
return /^[a-fA-F0-9]{16,32}$/.test(value)
|
return /^[a-fA-F0-9]{16,32}$/.test(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stripDatVariantSuffix(base: string): string {
|
||||||
|
const lower = base.toLowerCase()
|
||||||
|
const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_b', '.b', '_w', '.w', '_t', '.t', '_c', '.c']
|
||||||
|
for (const suffix of suffixes) {
|
||||||
|
if (lower.endsWith(suffix)) {
|
||||||
|
return lower.slice(0, -suffix.length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (/[._][a-z]$/.test(lower)) {
|
||||||
|
return lower.slice(0, -2)
|
||||||
|
}
|
||||||
|
return lower
|
||||||
|
}
|
||||||
|
|
||||||
function hasXVariant(baseLower: string): boolean {
|
function hasXVariant(baseLower: string): boolean {
|
||||||
return /[._][a-z]$/.test(baseLower)
|
return stripDatVariantSuffix(baseLower) !== baseLower
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasImageVariantSuffix(baseLower: string): boolean {
|
function hasImageVariantSuffix(baseLower: string): boolean {
|
||||||
return /[._][a-z]$/.test(baseLower)
|
return stripDatVariantSuffix(baseLower) !== baseLower
|
||||||
}
|
|
||||||
|
|
||||||
function isLikelyImageDatBase(baseLower: string): boolean {
|
|
||||||
return hasImageVariantSuffix(baseLower) || looksLikeMd5(baseLower)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDatBase(name: string): string {
|
function normalizeDatBase(name: string): string {
|
||||||
@@ -35,10 +45,17 @@ function normalizeDatBase(name: string): string {
|
|||||||
if (base.endsWith('.dat') || base.endsWith('.jpg')) {
|
if (base.endsWith('.dat') || base.endsWith('.jpg')) {
|
||||||
base = base.slice(0, -4)
|
base = base.slice(0, -4)
|
||||||
}
|
}
|
||||||
while (/[._][a-z]$/.test(base)) {
|
while (true) {
|
||||||
base = base.slice(0, -2)
|
const stripped = stripDatVariantSuffix(base)
|
||||||
}
|
if (stripped === base) {
|
||||||
return base
|
return base
|
||||||
|
}
|
||||||
|
base = stripped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLikelyImageDatBase(baseLower: string): boolean {
|
||||||
|
return hasImageVariantSuffix(baseLower) || looksLikeMd5(normalizeDatBase(baseLower))
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchesDatName(fileName: string, datName: string): boolean {
|
function matchesDatName(fileName: string, datName: string): boolean {
|
||||||
@@ -47,25 +64,25 @@ function matchesDatName(fileName: string, datName: string): boolean {
|
|||||||
const normalizedBase = normalizeDatBase(base)
|
const normalizedBase = normalizeDatBase(base)
|
||||||
const normalizedTarget = normalizeDatBase(datName.toLowerCase())
|
const normalizedTarget = normalizeDatBase(datName.toLowerCase())
|
||||||
if (normalizedBase === normalizedTarget) return true
|
if (normalizedBase === normalizedTarget) return true
|
||||||
const pattern = new RegExp(`^${datName}(?:[._][a-z])?\\.dat$`)
|
return lower.endsWith('.dat') && lower.includes(normalizedTarget)
|
||||||
if (pattern.test(lower)) return true
|
|
||||||
return lower.endsWith('.dat') && lower.includes(datName)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function scoreDatName(fileName: string): number {
|
function scoreDatName(fileName: string): number {
|
||||||
if (fileName.includes('.t.dat') || fileName.includes('_t.dat')) return 1
|
const lower = fileName.toLowerCase()
|
||||||
if (fileName.includes('.c.dat') || fileName.includes('_c.dat')) return 1
|
const baseLower = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
|
||||||
return 2
|
if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600
|
||||||
|
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 550
|
||||||
|
if (baseLower.endsWith('_b') || baseLower.endsWith('.b')) return 520
|
||||||
|
if (baseLower.endsWith('_w') || baseLower.endsWith('.w')) return 510
|
||||||
|
if (!hasXVariant(baseLower)) return 500
|
||||||
|
if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400
|
||||||
|
if (isThumbnailDat(lower)) return 100
|
||||||
|
return 350
|
||||||
}
|
}
|
||||||
|
|
||||||
function isThumbnailDat(fileName: string): boolean {
|
function isThumbnailDat(fileName: string): boolean {
|
||||||
return fileName.includes('.t.dat') || fileName.includes('_t.dat')
|
|
||||||
}
|
|
||||||
|
|
||||||
function isHdDat(fileName: string): boolean {
|
|
||||||
const lower = fileName.toLowerCase()
|
const lower = fileName.toLowerCase()
|
||||||
const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
|
return lower.includes('.t.dat') || lower.includes('_t.dat') || lower.includes('_thumb.dat')
|
||||||
return base.endsWith('_hd') || base.endsWith('_h')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function walkForDat(
|
function walkForDat(
|
||||||
@@ -105,20 +122,15 @@ function walkForDat(
|
|||||||
if (!lower.endsWith('.dat')) continue
|
if (!lower.endsWith('.dat')) continue
|
||||||
const baseLower = lower.slice(0, -4)
|
const baseLower = lower.slice(0, -4)
|
||||||
if (!isLikelyImageDatBase(baseLower)) continue
|
if (!isLikelyImageDatBase(baseLower)) continue
|
||||||
if (!hasXVariant(baseLower)) continue
|
|
||||||
if (!matchesDatName(lower, datName)) continue
|
if (!matchesDatName(lower, datName)) continue
|
||||||
// 排除高清图片格式 (_hd, _h)
|
|
||||||
if (isHdDat(lower)) continue
|
|
||||||
matchedBases.add(baseLower)
|
matchedBases.add(baseLower)
|
||||||
const isThumb = isThumbnailDat(lower)
|
const isThumb = isThumbnailDat(lower)
|
||||||
if (!allowThumbnail && isThumb) continue
|
if (!allowThumbnail && isThumb) continue
|
||||||
if (thumbOnly && !isThumb) continue
|
if (thumbOnly && !isThumb) continue
|
||||||
const score = scoreDatName(lower)
|
|
||||||
candidates.push({
|
candidates.push({
|
||||||
score,
|
score: scoreDatName(lower),
|
||||||
path: entryPath,
|
path: entryPath,
|
||||||
isThumb,
|
isThumb
|
||||||
hasX: hasXVariant(baseLower)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,10 +138,8 @@ function walkForDat(
|
|||||||
return { path: null, matchedBases: Array.from(matchedBases).slice(0, 20) }
|
return { path: null, matchedBases: Array.from(matchedBases).slice(0, 20) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const withX = candidates.filter((item) => item.hasX)
|
const nonThumb = candidates.filter((item) => !item.isThumb)
|
||||||
const basePool = withX.length ? withX : candidates
|
const finalPool = thumbOnly ? candidates : (nonThumb.length ? nonThumb : candidates)
|
||||||
const nonThumb = basePool.filter((item) => !item.isThumb)
|
|
||||||
const finalPool = thumbOnly ? basePool : (nonThumb.length ? nonThumb : basePool)
|
|
||||||
|
|
||||||
let best: { score: number; path: string } | null = null
|
let best: { score: number; path: string } | null = null
|
||||||
for (const item of finalPool) {
|
for (const item of finalPool) {
|
||||||
|
|||||||
2495
electron/main.ts
2495
electron/main.ts
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ import { join, dirname } from 'path'
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 强制将本地资源目录添加到 PATH 最前端,确保优先加载本地 DLL
|
* 强制将本地资源目录添加到 PATH 最前端,确保优先加载本地 DLL
|
||||||
* 解决系统中存在冲突版本的 DLL 导致的应用崩溃问题
|
* 解决系统中存在冲突版本的数据服务导致的应用崩溃问题
|
||||||
*/
|
*/
|
||||||
function enforceLocalDllPriority() {
|
function enforceLocalDllPriority() {
|
||||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||||
@@ -35,5 +35,5 @@ function enforceLocalDllPriority() {
|
|||||||
try {
|
try {
|
||||||
enforceLocalDllPriority()
|
enforceLocalDllPriority()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[WeFlow] Failed to enforce local DLL priority:', e)
|
console.error('[WeFlow] Failed to enforce local service priority:', e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,12 +19,25 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
onShow: (callback: (event: any, data: any) => void) => {
|
onShow: (callback: (event: any, data: any) => void) => {
|
||||||
ipcRenderer.on('notification:show', callback)
|
ipcRenderer.on('notification:show', callback)
|
||||||
return () => ipcRenderer.removeAllListeners('notification:show')
|
return () => ipcRenderer.removeAllListeners('notification:show')
|
||||||
|
}, // 监听原本发送出来的navigate-to-session事件,跳转到具体的会话
|
||||||
|
onNavigateToSession: (callback: (sessionId: string) => void) => {
|
||||||
|
const listener = (_: any, sessionId: string) => callback(sessionId)
|
||||||
|
ipcRenderer.on('navigate-to-session', listener)
|
||||||
|
return () => ipcRenderer.removeListener('navigate-to-session', listener)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 认证
|
// 认证
|
||||||
auth: {
|
auth: {
|
||||||
hello: (message?: string) => ipcRenderer.invoke('auth:hello', message)
|
hello: (message?: string) => ipcRenderer.invoke('auth:hello', message),
|
||||||
|
verifyEnabled: () => ipcRenderer.invoke('auth:verifyEnabled'),
|
||||||
|
unlock: (password: string) => ipcRenderer.invoke('auth:unlock', password),
|
||||||
|
enableLock: (password: string) => ipcRenderer.invoke('auth:enableLock', password),
|
||||||
|
disableLock: (password: string) => ipcRenderer.invoke('auth:disableLock', password),
|
||||||
|
changePassword: (oldPassword: string, newPassword: string) => ipcRenderer.invoke('auth:changePassword', oldPassword, newPassword),
|
||||||
|
setHelloSecret: (password: string) => ipcRenderer.invoke('auth:setHelloSecret', password),
|
||||||
|
clearHelloSecret: () => ipcRenderer.invoke('auth:clearHelloSecret'),
|
||||||
|
isLockMode: () => ipcRenderer.invoke('auth:isLockMode')
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
@@ -45,6 +58,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
app: {
|
app: {
|
||||||
getDownloadsPath: () => ipcRenderer.invoke('app:getDownloadsPath'),
|
getDownloadsPath: () => ipcRenderer.invoke('app:getDownloadsPath'),
|
||||||
getVersion: () => ipcRenderer.invoke('app:getVersion'),
|
getVersion: () => ipcRenderer.invoke('app:getVersion'),
|
||||||
|
getLaunchAtStartupStatus: () => ipcRenderer.invoke('app:getLaunchAtStartupStatus'),
|
||||||
|
setLaunchAtStartup: (enabled: boolean) => ipcRenderer.invoke('app:setLaunchAtStartup', enabled),
|
||||||
checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'),
|
checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'),
|
||||||
downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'),
|
downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'),
|
||||||
ignoreUpdate: (version: string) => ipcRenderer.invoke('app:ignoreUpdate', version),
|
ignoreUpdate: (version: string) => ipcRenderer.invoke('app:ignoreUpdate', version),
|
||||||
@@ -55,21 +70,45 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => {
|
onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => {
|
||||||
ipcRenderer.on('app:updateAvailable', (_, info) => callback(info))
|
ipcRenderer.on('app:updateAvailable', (_, info) => callback(info))
|
||||||
return () => ipcRenderer.removeAllListeners('app:updateAvailable')
|
return () => ipcRenderer.removeAllListeners('app:updateAvailable')
|
||||||
}
|
},
|
||||||
|
checkWayland: () => ipcRenderer.invoke('app:checkWayland'),
|
||||||
},
|
},
|
||||||
|
|
||||||
// 日志
|
// 日志
|
||||||
log: {
|
log: {
|
||||||
getPath: () => ipcRenderer.invoke('log:getPath'),
|
getPath: () => ipcRenderer.invoke('log:getPath'),
|
||||||
read: () => ipcRenderer.invoke('log:read'),
|
read: () => ipcRenderer.invoke('log:read'),
|
||||||
|
clear: () => ipcRenderer.invoke('log:clear'),
|
||||||
debug: (data: any) => ipcRenderer.send('log:debug', data)
|
debug: (data: any) => ipcRenderer.send('log:debug', data)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
diagnostics: {
|
||||||
|
getExportCardLogs: (options?: { limit?: number }) =>
|
||||||
|
ipcRenderer.invoke('diagnostics:getExportCardLogs', options),
|
||||||
|
clearExportCardLogs: () =>
|
||||||
|
ipcRenderer.invoke('diagnostics:clearExportCardLogs'),
|
||||||
|
exportExportCardLogs: (payload: { filePath: string; frontendLogs?: unknown[] }) =>
|
||||||
|
ipcRenderer.invoke('diagnostics:exportExportCardLogs', payload)
|
||||||
|
},
|
||||||
|
|
||||||
// 窗口控制
|
// 窗口控制
|
||||||
window: {
|
window: {
|
||||||
minimize: () => ipcRenderer.send('window:minimize'),
|
minimize: () => ipcRenderer.send('window:minimize'),
|
||||||
maximize: () => ipcRenderer.send('window:maximize'),
|
maximize: () => ipcRenderer.send('window:maximize'),
|
||||||
|
isMaximized: () => ipcRenderer.invoke('window:isMaximized'),
|
||||||
|
onMaximizeStateChanged: (callback: (isMaximized: boolean) => void) => {
|
||||||
|
const listener = (_: unknown, isMaximized: boolean) => callback(isMaximized)
|
||||||
|
ipcRenderer.on('window:maximizeStateChanged', listener)
|
||||||
|
return () => ipcRenderer.removeListener('window:maximizeStateChanged', listener)
|
||||||
|
},
|
||||||
close: () => ipcRenderer.send('window:close'),
|
close: () => ipcRenderer.send('window:close'),
|
||||||
|
onCloseConfirmRequested: (callback: (payload: { canMinimizeToTray: boolean }) => void) => {
|
||||||
|
const listener = (_: unknown, payload: { canMinimizeToTray: boolean }) => callback(payload)
|
||||||
|
ipcRenderer.on('window:confirmCloseRequested', listener)
|
||||||
|
return () => ipcRenderer.removeListener('window:confirmCloseRequested', listener)
|
||||||
|
},
|
||||||
|
respondCloseConfirm: (action: 'tray' | 'quit' | 'cancel') =>
|
||||||
|
ipcRenderer.invoke('window:respondCloseConfirm', action),
|
||||||
openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'),
|
openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'),
|
||||||
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
|
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
|
||||||
openOnboardingWindow: () => ipcRenderer.invoke('window:openOnboardingWindow'),
|
openOnboardingWindow: () => ipcRenderer.invoke('window:openOnboardingWindow'),
|
||||||
@@ -78,10 +117,24 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight),
|
ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight),
|
||||||
resizeToFitVideo: (videoWidth: number, videoHeight: number) =>
|
resizeToFitVideo: (videoWidth: number, videoHeight: number) =>
|
||||||
ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight),
|
ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight),
|
||||||
openImageViewerWindow: (imagePath: string) =>
|
openImageViewerWindow: (imagePath: string, liveVideoPath?: string) =>
|
||||||
ipcRenderer.invoke('window:openImageViewerWindow', imagePath),
|
ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath),
|
||||||
openChatHistoryWindow: (sessionId: string, messageId: number) =>
|
openChatHistoryWindow: (sessionId: string, messageId: number) =>
|
||||||
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId)
|
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId),
|
||||||
|
openChatHistoryPayloadWindow: (payload: { sessionId: string; title?: string; recordList: any[] }) =>
|
||||||
|
ipcRenderer.invoke('window:openChatHistoryPayloadWindow', payload),
|
||||||
|
getChatHistoryPayload: (payloadId: string) =>
|
||||||
|
ipcRenderer.invoke('window:getChatHistoryPayload', payloadId),
|
||||||
|
openSessionChatWindow: (
|
||||||
|
sessionId: string,
|
||||||
|
options?: {
|
||||||
|
source?: 'chat' | 'export'
|
||||||
|
initialDisplayName?: string
|
||||||
|
initialAvatarUrl?: string
|
||||||
|
initialContactType?: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
||||||
|
}
|
||||||
|
) =>
|
||||||
|
ipcRenderer.invoke('window:openSessionChatWindow', sessionId, options)
|
||||||
},
|
},
|
||||||
|
|
||||||
// 数据库路径
|
// 数据库路径
|
||||||
@@ -105,7 +158,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
// 密钥获取
|
// 密钥获取
|
||||||
key: {
|
key: {
|
||||||
autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'),
|
autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'),
|
||||||
autoGetImageKey: (manualDir?: string) => ipcRenderer.invoke('key:autoGetImageKey', manualDir),
|
autoGetImageKey: (manualDir?: string, wxid?: string) => ipcRenderer.invoke('key:autoGetImageKey', manualDir, wxid),
|
||||||
|
scanImageKeyFromMemory: (userDir: string) => ipcRenderer.invoke('key:scanImageKeyFromMemory', userDir),
|
||||||
onDbKeyStatus: (callback: (payload: { message: string; level: number }) => void) => {
|
onDbKeyStatus: (callback: (payload: { message: string; level: number }) => void) => {
|
||||||
ipcRenderer.on('key:dbKeyStatus', (_, payload) => callback(payload))
|
ipcRenderer.on('key:dbKeyStatus', (_, payload) => callback(payload))
|
||||||
return () => ipcRenderer.removeAllListeners('key:dbKeyStatus')
|
return () => ipcRenderer.removeAllListeners('key:dbKeyStatus')
|
||||||
@@ -121,8 +175,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
chat: {
|
chat: {
|
||||||
connect: () => ipcRenderer.invoke('chat:connect'),
|
connect: () => ipcRenderer.invoke('chat:connect'),
|
||||||
getSessions: () => ipcRenderer.invoke('chat:getSessions'),
|
getSessions: () => ipcRenderer.invoke('chat:getSessions'),
|
||||||
enrichSessionsContactInfo: (usernames: string[]) =>
|
getSessionStatuses: (usernames: string[]) => ipcRenderer.invoke('chat:getSessionStatuses', usernames),
|
||||||
ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames),
|
getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'),
|
||||||
|
getContactTypeCounts: () => ipcRenderer.invoke('chat:getContactTypeCounts'),
|
||||||
|
getSessionMessageCounts: (sessionIds: string[]) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds),
|
||||||
|
enrichSessionsContactInfo: (
|
||||||
|
usernames: string[],
|
||||||
|
options?: { skipDisplayName?: boolean; onlyMissingAvatar?: boolean }
|
||||||
|
) => ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames, options),
|
||||||
getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) =>
|
getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) =>
|
||||||
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending),
|
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending),
|
||||||
getLatestMessages: (sessionId: string, limit?: number) =>
|
getLatestMessages: (sessionId: string, limit?: number) =>
|
||||||
@@ -131,26 +191,74 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
ipcRenderer.invoke('chat:getNewMessages', sessionId, minTime, limit),
|
ipcRenderer.invoke('chat:getNewMessages', sessionId, minTime, limit),
|
||||||
getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username),
|
getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username),
|
||||||
getContactAvatar: (username: string) => ipcRenderer.invoke('chat:getContactAvatar', username),
|
getContactAvatar: (username: string) => ipcRenderer.invoke('chat:getContactAvatar', username),
|
||||||
|
updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) =>
|
||||||
|
ipcRenderer.invoke('chat:updateMessage', sessionId, localId, createTime, newContent),
|
||||||
|
deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) =>
|
||||||
|
ipcRenderer.invoke('chat:deleteMessage', sessionId, localId, createTime, dbPathHint),
|
||||||
|
checkAntiRevokeTriggers: (sessionIds: string[]) =>
|
||||||
|
ipcRenderer.invoke('chat:checkAntiRevokeTriggers', sessionIds),
|
||||||
|
installAntiRevokeTriggers: (sessionIds: string[]) =>
|
||||||
|
ipcRenderer.invoke('chat:installAntiRevokeTriggers', sessionIds),
|
||||||
|
uninstallAntiRevokeTriggers: (sessionIds: string[]) =>
|
||||||
|
ipcRenderer.invoke('chat:uninstallAntiRevokeTriggers', sessionIds),
|
||||||
|
resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) =>
|
||||||
|
ipcRenderer.invoke('chat:resolveTransferDisplayNames', chatroomId, payerUsername, receiverUsername),
|
||||||
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),
|
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),
|
||||||
downloadEmoji: (cdnUrl: string, md5?: string) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5),
|
downloadEmoji: (cdnUrl: string, md5?: string) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5),
|
||||||
getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId),
|
getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId),
|
||||||
|
clearCurrentAccountData: (options: { clearCache?: boolean; clearExports?: boolean }) =>
|
||||||
|
ipcRenderer.invoke('chat:clearCurrentAccountData', options),
|
||||||
close: () => ipcRenderer.invoke('chat:close'),
|
close: () => ipcRenderer.invoke('chat:close'),
|
||||||
getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId),
|
getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId),
|
||||||
|
getSessionDetailFast: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailFast', sessionId),
|
||||||
|
getSessionDetailExtra: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailExtra', sessionId),
|
||||||
|
getExportSessionStats: (
|
||||||
|
sessionIds: string[],
|
||||||
|
options?: {
|
||||||
|
includeRelations?: boolean
|
||||||
|
forceRefresh?: boolean
|
||||||
|
allowStaleCache?: boolean
|
||||||
|
preferAccurateSpecialTypes?: boolean
|
||||||
|
cacheOnly?: boolean
|
||||||
|
}
|
||||||
|
) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds, options),
|
||||||
|
getGroupMyMessageCountHint: (chatroomId: string) =>
|
||||||
|
ipcRenderer.invoke('chat:getGroupMyMessageCountHint', chatroomId),
|
||||||
getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId),
|
getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId),
|
||||||
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) =>
|
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) =>
|
||||||
ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId),
|
ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId),
|
||||||
|
getAllVoiceMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllVoiceMessages', sessionId),
|
||||||
|
getAllImageMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllImageMessages', sessionId),
|
||||||
|
getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', sessionId),
|
||||||
|
getMessageDateCounts: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDateCounts', sessionId),
|
||||||
|
getResourceMessages: (options?: {
|
||||||
|
sessionId?: string
|
||||||
|
types?: Array<'image' | 'video' | 'voice' | 'file'>
|
||||||
|
beginTimestamp?: number
|
||||||
|
endTimestamp?: number
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}) => ipcRenderer.invoke('chat:getResourceMessages', options),
|
||||||
|
getMediaStream: (options?: {
|
||||||
|
sessionId?: string
|
||||||
|
mediaType?: 'image' | 'video' | 'all'
|
||||||
|
beginTimestamp?: number
|
||||||
|
endTimestamp?: number
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}) => ipcRenderer.invoke('chat:getMediaStream', options),
|
||||||
resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId),
|
resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId),
|
||||||
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime),
|
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime),
|
||||||
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => {
|
onVoiceTranscriptPartial: (callback: (payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => void) => {
|
||||||
const listener = (_: any, payload: { msgId: string; text: string }) => callback(payload)
|
const listener = (_: any, payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => callback(payload)
|
||||||
ipcRenderer.on('chat:voiceTranscriptPartial', listener)
|
ipcRenderer.on('chat:voiceTranscriptPartial', listener)
|
||||||
return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener)
|
return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener)
|
||||||
},
|
},
|
||||||
execQuery: (kind: string, path: string | null, sql: string) =>
|
getContacts: (options?: { lite?: boolean }) => ipcRenderer.invoke('chat:getContacts', options),
|
||||||
ipcRenderer.invoke('chat:execQuery', kind, path, sql),
|
|
||||||
getContacts: () => ipcRenderer.invoke('chat:getContacts'),
|
|
||||||
getMessage: (sessionId: string, localId: number) =>
|
getMessage: (sessionId: string, localId: number) =>
|
||||||
ipcRenderer.invoke('chat:getMessage', sessionId, localId),
|
ipcRenderer.invoke('chat:getMessage', sessionId, localId),
|
||||||
|
searchMessages: (keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) =>
|
||||||
|
ipcRenderer.invoke('chat:searchMessages', keyword, sessionId, limit, offset, beginTimestamp, endTimestamp),
|
||||||
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => {
|
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => {
|
||||||
ipcRenderer.on('wcdb-change', callback)
|
ipcRenderer.on('wcdb-change', callback)
|
||||||
return () => ipcRenderer.removeListener('wcdb-change', callback)
|
return () => ipcRenderer.removeListener('wcdb-change', callback)
|
||||||
@@ -163,30 +271,66 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
image: {
|
image: {
|
||||||
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) =>
|
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) =>
|
||||||
ipcRenderer.invoke('image:decrypt', payload),
|
ipcRenderer.invoke('image:decrypt', payload),
|
||||||
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) =>
|
resolveCache: (payload: {
|
||||||
|
sessionId?: string
|
||||||
|
imageMd5?: string
|
||||||
|
imageDatName?: string
|
||||||
|
disableUpdateCheck?: boolean
|
||||||
|
allowCacheIndex?: boolean
|
||||||
|
}) =>
|
||||||
ipcRenderer.invoke('image:resolveCache', payload),
|
ipcRenderer.invoke('image:resolveCache', payload),
|
||||||
preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) =>
|
resolveCacheBatch: (
|
||||||
ipcRenderer.invoke('image:preload', payloads),
|
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
|
||||||
|
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean }
|
||||||
|
) => ipcRenderer.invoke('image:resolveCacheBatch', payloads, options),
|
||||||
|
preload: (
|
||||||
|
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
|
||||||
|
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
|
||||||
|
) => ipcRenderer.invoke('image:preload', payloads, options),
|
||||||
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => {
|
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => {
|
||||||
ipcRenderer.on('image:updateAvailable', (_, payload) => callback(payload))
|
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload)
|
||||||
return () => ipcRenderer.removeAllListeners('image:updateAvailable')
|
ipcRenderer.on('image:updateAvailable', listener)
|
||||||
|
return () => ipcRenderer.removeListener('image:updateAvailable', listener)
|
||||||
},
|
},
|
||||||
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => {
|
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => {
|
||||||
ipcRenderer.on('image:cacheResolved', (_, payload) => callback(payload))
|
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => callback(payload)
|
||||||
return () => ipcRenderer.removeAllListeners('image:cacheResolved')
|
ipcRenderer.on('image:cacheResolved', listener)
|
||||||
|
return () => ipcRenderer.removeListener('image:cacheResolved', listener)
|
||||||
|
},
|
||||||
|
onDecryptProgress: (callback: (payload: {
|
||||||
|
cacheKey: string
|
||||||
|
imageMd5?: string
|
||||||
|
imageDatName?: string
|
||||||
|
stage: 'queued' | 'locating' | 'decrypting' | 'writing' | 'done' | 'failed'
|
||||||
|
progress: number
|
||||||
|
status: 'running' | 'done' | 'error'
|
||||||
|
message?: string
|
||||||
|
}) => void) => {
|
||||||
|
const listener = (_: unknown, payload: {
|
||||||
|
cacheKey: string
|
||||||
|
imageMd5?: string
|
||||||
|
imageDatName?: string
|
||||||
|
stage: 'queued' | 'locating' | 'decrypting' | 'writing' | 'done' | 'failed'
|
||||||
|
progress: number
|
||||||
|
status: 'running' | 'done' | 'error'
|
||||||
|
message?: string
|
||||||
|
}) => callback(payload)
|
||||||
|
ipcRenderer.on('image:decryptProgress', listener)
|
||||||
|
return () => ipcRenderer.removeListener('image:decryptProgress', listener)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 视频
|
// 视频
|
||||||
video: {
|
video: {
|
||||||
getVideoInfo: (videoMd5: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5),
|
getVideoInfo: (videoMd5: string, options?: { includePoster?: boolean; posterFormat?: 'dataUrl' | 'fileUrl' }) => ipcRenderer.invoke('video:getVideoInfo', videoMd5, options),
|
||||||
parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content)
|
parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content)
|
||||||
},
|
},
|
||||||
|
|
||||||
// 数据分析
|
// 数据分析
|
||||||
analytics: {
|
analytics: {
|
||||||
getOverallStatistics: (force?: boolean) => ipcRenderer.invoke('analytics:getOverallStatistics', force),
|
getOverallStatistics: (force?: boolean) => ipcRenderer.invoke('analytics:getOverallStatistics', force),
|
||||||
getContactRankings: (limit?: number) => ipcRenderer.invoke('analytics:getContactRankings', limit),
|
getContactRankings: (limit?: number, beginTimestamp?: number, endTimestamp?: number) =>
|
||||||
|
ipcRenderer.invoke('analytics:getContactRankings', limit, beginTimestamp, endTimestamp),
|
||||||
getTimeDistribution: () => ipcRenderer.invoke('analytics:getTimeDistribution'),
|
getTimeDistribution: () => ipcRenderer.invoke('analytics:getTimeDistribution'),
|
||||||
getExcludedUsernames: () => ipcRenderer.invoke('analytics:getExcludedUsernames'),
|
getExcludedUsernames: () => ipcRenderer.invoke('analytics:getExcludedUsernames'),
|
||||||
setExcludedUsernames: (usernames: string[]) => ipcRenderer.invoke('analytics:setExcludedUsernames', usernames),
|
setExcludedUsernames: (usernames: string[]) => ipcRenderer.invoke('analytics:setExcludedUsernames', usernames),
|
||||||
@@ -208,18 +352,50 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
groupAnalytics: {
|
groupAnalytics: {
|
||||||
getGroupChats: () => ipcRenderer.invoke('groupAnalytics:getGroupChats'),
|
getGroupChats: () => ipcRenderer.invoke('groupAnalytics:getGroupChats'),
|
||||||
getGroupMembers: (chatroomId: string) => ipcRenderer.invoke('groupAnalytics:getGroupMembers', chatroomId),
|
getGroupMembers: (chatroomId: string) => ipcRenderer.invoke('groupAnalytics:getGroupMembers', chatroomId),
|
||||||
|
getGroupMembersPanelData: (
|
||||||
|
chatroomId: string,
|
||||||
|
options?: { forceRefresh?: boolean; includeMessageCounts?: boolean }
|
||||||
|
) => ipcRenderer.invoke('groupAnalytics:getGroupMembersPanelData', chatroomId, options),
|
||||||
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
|
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
|
||||||
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
|
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
|
||||||
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
|
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
|
||||||
exportGroupMembers: (chatroomId: string, outputPath: string) => ipcRenderer.invoke('groupAnalytics:exportGroupMembers', chatroomId, outputPath)
|
getGroupMemberAnalytics: (chatroomId: string, memberUsername: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMemberAnalytics', chatroomId, memberUsername, startTime, endTime),
|
||||||
|
getGroupMemberMessages: (
|
||||||
|
chatroomId: string,
|
||||||
|
memberUsername: string,
|
||||||
|
options?: { startTime?: number; endTime?: number; limit?: number; cursor?: number }
|
||||||
|
) => ipcRenderer.invoke('groupAnalytics:getGroupMemberMessages', chatroomId, memberUsername, options),
|
||||||
|
exportGroupMembers: (chatroomId: string, outputPath: string) => ipcRenderer.invoke('groupAnalytics:exportGroupMembers', chatroomId, outputPath),
|
||||||
|
exportGroupMemberMessages: (chatroomId: string, memberUsername: string, outputPath: string, startTime?: number, endTime?: number) =>
|
||||||
|
ipcRenderer.invoke('groupAnalytics:exportGroupMemberMessages', chatroomId, memberUsername, outputPath, startTime, endTime)
|
||||||
},
|
},
|
||||||
|
|
||||||
// 年度报告
|
// 年度报告
|
||||||
annualReport: {
|
annualReport: {
|
||||||
getAvailableYears: () => ipcRenderer.invoke('annualReport:getAvailableYears'),
|
getAvailableYears: () => ipcRenderer.invoke('annualReport:getAvailableYears'),
|
||||||
|
startAvailableYearsLoad: () => ipcRenderer.invoke('annualReport:startAvailableYearsLoad'),
|
||||||
|
cancelAvailableYearsLoad: (taskId: string) => ipcRenderer.invoke('annualReport:cancelAvailableYearsLoad', taskId),
|
||||||
generateReport: (year: number) => ipcRenderer.invoke('annualReport:generateReport', year),
|
generateReport: (year: number) => ipcRenderer.invoke('annualReport:generateReport', year),
|
||||||
exportImages: (payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) =>
|
exportImages: (payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) =>
|
||||||
ipcRenderer.invoke('annualReport:exportImages', payload),
|
ipcRenderer.invoke('annualReport:exportImages', payload),
|
||||||
|
onAvailableYearsProgress: (callback: (payload: {
|
||||||
|
taskId: string
|
||||||
|
years?: number[]
|
||||||
|
done: boolean
|
||||||
|
error?: string
|
||||||
|
canceled?: boolean
|
||||||
|
strategy?: 'cache' | 'native' | 'hybrid'
|
||||||
|
phase?: 'cache' | 'native' | 'scan' | 'done'
|
||||||
|
statusText?: string
|
||||||
|
nativeElapsedMs?: number
|
||||||
|
scanElapsedMs?: number
|
||||||
|
totalElapsedMs?: number
|
||||||
|
switched?: boolean
|
||||||
|
nativeTimedOut?: boolean
|
||||||
|
}) => void) => {
|
||||||
|
ipcRenderer.on('annualReport:availableYearsProgress', (_, payload) => callback(payload))
|
||||||
|
return () => ipcRenderer.removeAllListeners('annualReport:availableYearsProgress')
|
||||||
|
},
|
||||||
onProgress: (callback: (payload: { status: string; progress: number }) => void) => {
|
onProgress: (callback: (payload: { status: string; progress: number }) => void) => {
|
||||||
ipcRenderer.on('annualReport:progress', (_, payload) => callback(payload))
|
ipcRenderer.on('annualReport:progress', (_, payload) => callback(payload))
|
||||||
return () => ipcRenderer.removeAllListeners('annualReport:progress')
|
return () => ipcRenderer.removeAllListeners('annualReport:progress')
|
||||||
@@ -236,13 +412,28 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
|
|
||||||
// 导出
|
// 导出
|
||||||
export: {
|
export: {
|
||||||
|
getExportStats: (sessionIds: string[], options: any) =>
|
||||||
|
ipcRenderer.invoke('export:getExportStats', sessionIds, options),
|
||||||
exportSessions: (sessionIds: string[], outputDir: string, options: any) =>
|
exportSessions: (sessionIds: string[], outputDir: string, options: any) =>
|
||||||
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
|
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
|
||||||
exportSession: (sessionId: string, outputPath: string, options: any) =>
|
exportSession: (sessionId: string, outputPath: string, options: any) =>
|
||||||
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
|
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
|
||||||
exportContacts: (outputDir: string, options: any) =>
|
exportContacts: (outputDir: string, options: any) =>
|
||||||
ipcRenderer.invoke('export:exportContacts', outputDir, options),
|
ipcRenderer.invoke('export:exportContacts', outputDir, options),
|
||||||
onProgress: (callback: (payload: { current: number; total: number; currentSession: string; phase: string }) => void) => {
|
onProgress: (callback: (payload: {
|
||||||
|
current: number
|
||||||
|
total: number
|
||||||
|
currentSession: string
|
||||||
|
currentSessionId?: string
|
||||||
|
phase: string
|
||||||
|
phaseProgress?: number
|
||||||
|
phaseTotal?: number
|
||||||
|
phaseLabel?: string
|
||||||
|
collectedMessages?: number
|
||||||
|
exportedMessages?: number
|
||||||
|
estimatedTotalMessages?: number
|
||||||
|
writtenFiles?: number
|
||||||
|
}) => void) => {
|
||||||
ipcRenderer.on('export:progress', (_, payload) => callback(payload))
|
ipcRenderer.on('export:progress', (_, payload) => callback(payload))
|
||||||
return () => ipcRenderer.removeAllListeners('export:progress')
|
return () => ipcRenderer.removeAllListeners('export:progress')
|
||||||
}
|
}
|
||||||
@@ -263,7 +454,61 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
sns: {
|
sns: {
|
||||||
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) =>
|
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) =>
|
||||||
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime),
|
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime),
|
||||||
|
getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'),
|
||||||
|
getUserPostCounts: () => ipcRenderer.invoke('sns:getUserPostCounts'),
|
||||||
|
getExportStatsFast: () => ipcRenderer.invoke('sns:getExportStatsFast'),
|
||||||
|
getExportStats: () => ipcRenderer.invoke('sns:getExportStats'),
|
||||||
|
getUserPostStats: (username: string) => ipcRenderer.invoke('sns:getUserPostStats', username),
|
||||||
debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
|
debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
|
||||||
proxyImage: (url: string) => ipcRenderer.invoke('sns:proxyImage', url)
|
proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload),
|
||||||
|
downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload),
|
||||||
|
exportTimeline: (options: any) => ipcRenderer.invoke('sns:exportTimeline', options),
|
||||||
|
onExportProgress: (callback: (payload: any) => void) => {
|
||||||
|
ipcRenderer.on('sns:exportProgress', (_, payload) => callback(payload))
|
||||||
|
return () => ipcRenderer.removeAllListeners('sns:exportProgress')
|
||||||
|
},
|
||||||
|
selectExportDir: () => ipcRenderer.invoke('sns:selectExportDir'),
|
||||||
|
installBlockDeleteTrigger: () => ipcRenderer.invoke('sns:installBlockDeleteTrigger'),
|
||||||
|
uninstallBlockDeleteTrigger: () => ipcRenderer.invoke('sns:uninstallBlockDeleteTrigger'),
|
||||||
|
checkBlockDeleteTrigger: () => ipcRenderer.invoke('sns:checkBlockDeleteTrigger'),
|
||||||
|
deleteSnsPost: (postId: string) => ipcRenderer.invoke('sns:deleteSnsPost', postId),
|
||||||
|
downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:downloadEmoji', params),
|
||||||
|
getCacheMigrationStatus: () => ipcRenderer.invoke('sns:getCacheMigrationStatus'),
|
||||||
|
startCacheMigration: () => ipcRenderer.invoke('sns:startCacheMigration'),
|
||||||
|
onCacheMigrationProgress: (callback: (payload: any) => void) => {
|
||||||
|
const listener = (_event: unknown, payload: any) => callback(payload)
|
||||||
|
ipcRenderer.on('sns:cacheMigrationProgress', listener)
|
||||||
|
return () => ipcRenderer.removeListener('sns:cacheMigrationProgress', listener)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
biz: {
|
||||||
|
listAccounts: (account?: string) => ipcRenderer.invoke('biz:listAccounts', account),
|
||||||
|
listMessages: (username: string, account?: string, limit?: number, offset?: number) =>
|
||||||
|
ipcRenderer.invoke('biz:listMessages', username, account, limit, offset),
|
||||||
|
listPayRecords: (account?: string, limit?: number, offset?: number) =>
|
||||||
|
ipcRenderer.invoke('biz:listPayRecords', account, limit, offset)
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
// 数据收集
|
||||||
|
cloud: {
|
||||||
|
init: () => ipcRenderer.invoke('cloud:init'),
|
||||||
|
recordPage: (pageName: string) => ipcRenderer.invoke('cloud:recordPage', pageName),
|
||||||
|
getLogs: () => ipcRenderer.invoke('cloud:getLogs')
|
||||||
|
},
|
||||||
|
|
||||||
|
// HTTP API 服务
|
||||||
|
http: {
|
||||||
|
start: (port?: number, host?: string) => ipcRenderer.invoke('http:start', port, host),
|
||||||
|
stop: () => ipcRenderer.invoke('http:stop'),
|
||||||
|
status: () => ipcRenderer.invoke('http:status')
|
||||||
|
},
|
||||||
|
|
||||||
|
// AI 见解
|
||||||
|
insight: {
|
||||||
|
testConnection: () => ipcRenderer.invoke('insight:testConnection'),
|
||||||
|
getTodayStats: () => ipcRenderer.invoke('insight:getTodayStats'),
|
||||||
|
triggerTest: () => ipcRenderer.invoke('insight:triggerTest')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export interface ContactRanking {
|
|||||||
username: string
|
username: string
|
||||||
displayName: string
|
displayName: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
|
wechatId?: string
|
||||||
messageCount: number
|
messageCount: number
|
||||||
sentCount: number
|
sentCount: number
|
||||||
receivedCount: number
|
receivedCount: number
|
||||||
@@ -67,33 +68,14 @@ class AnalyticsService {
|
|||||||
return new Set(this.getExcludedUsernamesList())
|
return new Set(this.getExcludedUsernamesList())
|
||||||
}
|
}
|
||||||
|
|
||||||
private escapeSqlValue(value: string): string {
|
|
||||||
return value.replace(/'/g, "''")
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getAliasMap(usernames: string[]): Promise<Record<string, string>> {
|
private async getAliasMap(usernames: string[]): Promise<Record<string, string>> {
|
||||||
const map: Record<string, string> = {}
|
const map: Record<string, string> = {}
|
||||||
if (usernames.length === 0) return map
|
if (usernames.length === 0) return map
|
||||||
|
|
||||||
const chunkSize = 200
|
const result = await wcdbService.getContactAliasMap(usernames)
|
||||||
for (let i = 0; i < usernames.length; i += chunkSize) {
|
if (!result.success || !result.map) return map
|
||||||
const chunk = usernames.slice(i, i + chunkSize)
|
for (const [username, alias] of Object.entries(result.map)) {
|
||||||
const inList = chunk.map((u) => `'${this.escapeSqlValue(u)}'`).join(',')
|
if (username && alias) map[username] = alias
|
||||||
if (!inList) continue
|
|
||||||
const sql = `
|
|
||||||
SELECT username, alias
|
|
||||||
FROM contact
|
|
||||||
WHERE username IN (${inList})
|
|
||||||
`
|
|
||||||
const result = await wcdbService.execQuery('contact', null, sql)
|
|
||||||
if (!result.success || !result.rows) continue
|
|
||||||
for (const row of result.rows as Record<string, any>[]) {
|
|
||||||
const username = row.username || ''
|
|
||||||
const alias = row.alias || ''
|
|
||||||
if (username && alias) {
|
|
||||||
map[username] = alias
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return map
|
return map
|
||||||
@@ -576,7 +558,11 @@ class AnalyticsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getContactRankings(limit: number = 20): Promise<{ success: boolean; data?: ContactRanking[]; error?: string }> {
|
async getContactRankings(
|
||||||
|
limit: number = 20,
|
||||||
|
beginTimestamp: number = 0,
|
||||||
|
endTimestamp: number = 0
|
||||||
|
): Promise<{ success: boolean; data?: ContactRanking[]; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const conn = await this.ensureConnected()
|
const conn = await this.ensureConnected()
|
||||||
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
||||||
@@ -586,7 +572,7 @@ class AnalyticsService {
|
|||||||
return { success: false, error: '未找到消息会话' }
|
return { success: false, error: '未找到消息会话' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.getAggregateWithFallback(sessionInfo.usernames, 0, 0)
|
const result = await this.getAggregateWithFallback(sessionInfo.usernames, beginTimestamp, endTimestamp)
|
||||||
if (!result.success || !result.data) {
|
if (!result.success || !result.data) {
|
||||||
return { success: false, error: result.error || '聚合统计失败' }
|
return { success: false, error: result.error || '聚合统计失败' }
|
||||||
}
|
}
|
||||||
@@ -594,9 +580,10 @@ class AnalyticsService {
|
|||||||
const d = result.data
|
const d = result.data
|
||||||
const sessions = this.normalizeAggregateSessions(d.sessions, d.idMap)
|
const sessions = this.normalizeAggregateSessions(d.sessions, d.idMap)
|
||||||
const usernames = Object.keys(sessions)
|
const usernames = Object.keys(sessions)
|
||||||
const [displayNames, avatarUrls] = await Promise.all([
|
const [displayNames, avatarUrls, aliasMap] = await Promise.all([
|
||||||
wcdbService.getDisplayNames(usernames),
|
wcdbService.getDisplayNames(usernames),
|
||||||
wcdbService.getAvatarUrls(usernames)
|
wcdbService.getAvatarUrls(usernames),
|
||||||
|
this.getAliasMap(usernames)
|
||||||
])
|
])
|
||||||
|
|
||||||
const rankings: ContactRanking[] = usernames
|
const rankings: ContactRanking[] = usernames
|
||||||
@@ -608,10 +595,13 @@ class AnalyticsService {
|
|||||||
const avatarUrl = avatarUrls.success && avatarUrls.map
|
const avatarUrl = avatarUrls.success && avatarUrls.map
|
||||||
? avatarUrls.map[username]
|
? avatarUrls.map[username]
|
||||||
: undefined
|
: undefined
|
||||||
|
const alias = aliasMap[username] || ''
|
||||||
|
const wechatId = alias || (!username.startsWith('wxid_') ? username : '')
|
||||||
return {
|
return {
|
||||||
username,
|
username,
|
||||||
displayName,
|
displayName,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
|
wechatId,
|
||||||
messageCount: stat.total,
|
messageCount: stat.total,
|
||||||
sentCount: stat.sent,
|
sentCount: stat.sent,
|
||||||
receivedCount: stat.received,
|
receivedCount: stat.received,
|
||||||
|
|||||||
@@ -85,7 +85,34 @@ export interface AnnualReportData {
|
|||||||
} | null
|
} | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AvailableYearsLoadProgress {
|
||||||
|
years: number[]
|
||||||
|
strategy: 'cache' | 'native' | 'hybrid'
|
||||||
|
phase: 'cache' | 'native' | 'scan'
|
||||||
|
statusText: string
|
||||||
|
nativeElapsedMs: number
|
||||||
|
scanElapsedMs: number
|
||||||
|
totalElapsedMs: number
|
||||||
|
switched?: boolean
|
||||||
|
nativeTimedOut?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AvailableYearsLoadMeta {
|
||||||
|
strategy: 'cache' | 'native' | 'hybrid'
|
||||||
|
nativeElapsedMs: number
|
||||||
|
scanElapsedMs: number
|
||||||
|
totalElapsedMs: number
|
||||||
|
switched: boolean
|
||||||
|
nativeTimedOut: boolean
|
||||||
|
statusText: string
|
||||||
|
}
|
||||||
|
|
||||||
class AnnualReportService {
|
class AnnualReportService {
|
||||||
|
private readonly availableYearsCacheTtlMs = 10 * 60 * 1000
|
||||||
|
private readonly availableYearsScanConcurrency = 4
|
||||||
|
private readonly availableYearsColumnCache = new Map<string, string>()
|
||||||
|
private readonly availableYearsCache = new Map<string, { years: number[]; updatedAt: number }>()
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +208,235 @@ class AnnualReportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private quoteSqlIdentifier(identifier: string): string {
|
||||||
|
return `"${String(identifier || '').replace(/"/g, '""')}"`
|
||||||
|
}
|
||||||
|
|
||||||
|
private toUnixTimestamp(value: any): number {
|
||||||
|
const n = Number(value)
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return 0
|
||||||
|
// 兼容毫秒级时间戳
|
||||||
|
const seconds = n > 1e12 ? Math.floor(n / 1000) : Math.floor(n)
|
||||||
|
return seconds > 0 ? seconds : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private addYearsFromRange(years: Set<number>, firstTs: number, lastTs: number): boolean {
|
||||||
|
let changed = false
|
||||||
|
const currentYear = new Date().getFullYear()
|
||||||
|
const minTs = firstTs > 0 ? firstTs : lastTs
|
||||||
|
const maxTs = lastTs > 0 ? lastTs : firstTs
|
||||||
|
if (minTs <= 0 || maxTs <= 0) return changed
|
||||||
|
|
||||||
|
const minYear = new Date(minTs * 1000).getFullYear()
|
||||||
|
const maxYear = new Date(maxTs * 1000).getFullYear()
|
||||||
|
for (let y = minYear; y <= maxYear; y++) {
|
||||||
|
if (y >= 2010 && y <= currentYear && !years.has(y)) {
|
||||||
|
years.add(y)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeAvailableYears(years: Iterable<number>): number[] {
|
||||||
|
return Array.from(new Set(Array.from(years)))
|
||||||
|
.filter((y) => Number.isFinite(y))
|
||||||
|
.map((y) => Math.floor(y))
|
||||||
|
.sort((a, b) => b - a)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async forEachWithConcurrency<T>(
|
||||||
|
items: T[],
|
||||||
|
concurrency: number,
|
||||||
|
handler: (item: T, index: number) => Promise<void>,
|
||||||
|
shouldStop?: () => boolean
|
||||||
|
): Promise<void> {
|
||||||
|
if (!items.length) return
|
||||||
|
const workerCount = Math.max(1, Math.min(concurrency, items.length))
|
||||||
|
let nextIndex = 0
|
||||||
|
const workers: Promise<void>[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < workerCount; i++) {
|
||||||
|
workers.push((async () => {
|
||||||
|
while (true) {
|
||||||
|
if (shouldStop?.()) break
|
||||||
|
const current = nextIndex
|
||||||
|
nextIndex += 1
|
||||||
|
if (current >= items.length) break
|
||||||
|
await handler(items[current], current)
|
||||||
|
}
|
||||||
|
})())
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(workers)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async detectTimeColumn(dbPath: string, tableName: string): Promise<string | null> {
|
||||||
|
const cacheKey = `${dbPath}\u0001${tableName}`
|
||||||
|
if (this.availableYearsColumnCache.has(cacheKey)) {
|
||||||
|
const cached = this.availableYearsColumnCache.get(cacheKey) || ''
|
||||||
|
return cached || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await wcdbService.getMessageTableColumns(dbPath, tableName)
|
||||||
|
if (!result.success || !Array.isArray(result.columns) || result.columns.length === 0) {
|
||||||
|
this.availableYearsColumnCache.set(cacheKey, '')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = ['create_time', 'createtime', 'msg_create_time', 'msg_time', 'msgtime', 'time']
|
||||||
|
const columns = new Set<string>()
|
||||||
|
for (const columnName of result.columns) {
|
||||||
|
const name = String(columnName || '').trim().toLowerCase()
|
||||||
|
if (name) columns.add(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (columns.has(candidate)) {
|
||||||
|
this.availableYearsColumnCache.set(cacheKey, candidate)
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.availableYearsColumnCache.set(cacheKey, '')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getTableTimeRange(dbPath: string, tableName: string): Promise<{ first: number; last: number } | null> {
|
||||||
|
const cacheKey = `${dbPath}\u0001${tableName}`
|
||||||
|
const cachedColumn = this.availableYearsColumnCache.get(cacheKey)
|
||||||
|
const initialColumn = cachedColumn && cachedColumn.length > 0 ? cachedColumn : 'create_time'
|
||||||
|
const tried = new Set<string>()
|
||||||
|
|
||||||
|
const queryByColumn = async (column: string): Promise<{ first: number; last: number } | null> => {
|
||||||
|
const result = await wcdbService.getMessageTableTimeRange(dbPath, tableName)
|
||||||
|
if (!result.success || !result.data) return null
|
||||||
|
const row = result.data as Record<string, any>
|
||||||
|
const actualColumn = String(row.column || '').trim().toLowerCase()
|
||||||
|
if (column && actualColumn && column.toLowerCase() !== actualColumn) return null
|
||||||
|
const first = this.toUnixTimestamp(row.first_ts ?? row.firstTs ?? row.min_ts ?? row.minTs)
|
||||||
|
const last = this.toUnixTimestamp(row.last_ts ?? row.lastTs ?? row.max_ts ?? row.maxTs)
|
||||||
|
return { first, last }
|
||||||
|
}
|
||||||
|
|
||||||
|
tried.add(initialColumn)
|
||||||
|
const quick = await queryByColumn(initialColumn)
|
||||||
|
if (quick) {
|
||||||
|
if (!cachedColumn) this.availableYearsColumnCache.set(cacheKey, initialColumn)
|
||||||
|
return quick
|
||||||
|
}
|
||||||
|
|
||||||
|
const detectedColumn = await this.detectTimeColumn(dbPath, tableName)
|
||||||
|
if (!detectedColumn || tried.has(detectedColumn)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryByColumn(detectedColumn)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAvailableYearsByTableScan(
|
||||||
|
sessionIds: string[],
|
||||||
|
options?: { onProgress?: (years: number[]) => void; shouldCancel?: () => boolean }
|
||||||
|
): Promise<number[]> {
|
||||||
|
const years = new Set<number>()
|
||||||
|
let lastEmittedSize = 0
|
||||||
|
|
||||||
|
const emitIfChanged = (force = false) => {
|
||||||
|
if (!options?.onProgress) return
|
||||||
|
const next = this.normalizeAvailableYears(years)
|
||||||
|
if (!force && next.length === lastEmittedSize) return
|
||||||
|
options.onProgress(next)
|
||||||
|
lastEmittedSize = next.length
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldCancel = () => options?.shouldCancel?.() === true
|
||||||
|
|
||||||
|
await this.forEachWithConcurrency(sessionIds, this.availableYearsScanConcurrency, async (sessionId) => {
|
||||||
|
if (shouldCancel()) return
|
||||||
|
const tableStats = await wcdbService.getMessageTableStats(sessionId)
|
||||||
|
if (!tableStats.success || !Array.isArray(tableStats.tables) || tableStats.tables.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const table of tableStats.tables as Record<string, any>[]) {
|
||||||
|
if (shouldCancel()) return
|
||||||
|
const tableName = String(table.table_name || table.name || '').trim()
|
||||||
|
const dbPath = String(table.db_path || table.dbPath || '').trim()
|
||||||
|
if (!tableName || !dbPath) continue
|
||||||
|
|
||||||
|
const range = await this.getTableTimeRange(dbPath, tableName)
|
||||||
|
if (!range) continue
|
||||||
|
const changed = this.addYearsFromRange(years, range.first, range.last)
|
||||||
|
if (changed) emitIfChanged()
|
||||||
|
}
|
||||||
|
}, shouldCancel)
|
||||||
|
|
||||||
|
emitIfChanged(true)
|
||||||
|
return this.normalizeAvailableYears(years)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAvailableYearsByEdgeScan(
|
||||||
|
sessionIds: string[],
|
||||||
|
options?: { onProgress?: (years: number[]) => void; shouldCancel?: () => boolean }
|
||||||
|
): Promise<number[]> {
|
||||||
|
const years = new Set<number>()
|
||||||
|
let lastEmittedSize = 0
|
||||||
|
const shouldCancel = () => options?.shouldCancel?.() === true
|
||||||
|
|
||||||
|
const emitIfChanged = (force = false) => {
|
||||||
|
if (!options?.onProgress) return
|
||||||
|
const next = this.normalizeAvailableYears(years)
|
||||||
|
if (!force && next.length === lastEmittedSize) return
|
||||||
|
options.onProgress(next)
|
||||||
|
lastEmittedSize = next.length
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sessionId of sessionIds) {
|
||||||
|
if (shouldCancel()) break
|
||||||
|
const first = await this.getEdgeMessageTime(sessionId, true)
|
||||||
|
const last = await this.getEdgeMessageTime(sessionId, false)
|
||||||
|
const changed = this.addYearsFromRange(years, first || 0, last || 0)
|
||||||
|
if (changed) emitIfChanged()
|
||||||
|
}
|
||||||
|
emitIfChanged(true)
|
||||||
|
return this.normalizeAvailableYears(years)
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildAvailableYearsCacheKey(dbPath: string, cleanedWxid: string): string {
|
||||||
|
return `${dbPath}\u0001${cleanedWxid}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCachedAvailableYears(cacheKey: string): number[] | null {
|
||||||
|
const cached = this.availableYearsCache.get(cacheKey)
|
||||||
|
if (!cached) return null
|
||||||
|
if (Date.now() - cached.updatedAt > this.availableYearsCacheTtlMs) {
|
||||||
|
this.availableYearsCache.delete(cacheKey)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return [...cached.years]
|
||||||
|
}
|
||||||
|
|
||||||
|
private setCachedAvailableYears(cacheKey: string, years: number[]): void {
|
||||||
|
const normalized = this.normalizeAvailableYears(years)
|
||||||
|
|
||||||
|
this.availableYearsCache.set(cacheKey, {
|
||||||
|
years: normalized,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (this.availableYearsCache.size > 8) {
|
||||||
|
let oldestKey = ''
|
||||||
|
let oldestTime = Number.POSITIVE_INFINITY
|
||||||
|
for (const [key, val] of this.availableYearsCache) {
|
||||||
|
if (val.updatedAt < oldestTime) {
|
||||||
|
oldestTime = val.updatedAt
|
||||||
|
oldestKey = key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (oldestKey) this.availableYearsCache.delete(oldestKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private decodeMessageContent(messageContent: any, compressContent: any): string {
|
private decodeMessageContent(messageContent: any, compressContent: any): string {
|
||||||
let content = this.decodeMaybeCompressed(compressContent)
|
let content = this.decodeMaybeCompressed(compressContent)
|
||||||
if (!content || content.length === 0) {
|
if (!content || content.length === 0) {
|
||||||
@@ -193,11 +449,15 @@ class AnnualReportService {
|
|||||||
if (!raw) return ''
|
if (!raw) return ''
|
||||||
if (typeof raw === 'string') {
|
if (typeof raw === 'string') {
|
||||||
if (raw.length === 0) return ''
|
if (raw.length === 0) return ''
|
||||||
if (this.looksLikeHex(raw)) {
|
// 只有当字符串足够长(超过16字符)且看起来像 hex 时才尝试解码
|
||||||
|
// 短字符串(如 "123456" 等纯数字)容易被误判为 hex
|
||||||
|
if (raw.length > 16 && this.looksLikeHex(raw)) {
|
||||||
const bytes = Buffer.from(raw, 'hex')
|
const bytes = Buffer.from(raw, 'hex')
|
||||||
if (bytes.length > 0) return this.decodeBinaryContent(bytes)
|
if (bytes.length > 0) return this.decodeBinaryContent(bytes)
|
||||||
}
|
}
|
||||||
if (this.looksLikeBase64(raw)) {
|
// 只有当字符串足够长(超过16字符)且看起来像 base64 时才尝试解码
|
||||||
|
// 短字符串(如 "test", "home" 等)容易被误判为 base64
|
||||||
|
if (raw.length > 16 && this.looksLikeBase64(raw)) {
|
||||||
try {
|
try {
|
||||||
const bytes = Buffer.from(raw, 'base64')
|
const bytes = Buffer.from(raw, 'base64')
|
||||||
return this.decodeBinaryContent(bytes)
|
return this.decodeBinaryContent(bytes)
|
||||||
@@ -355,38 +615,226 @@ class AnnualReportService {
|
|||||||
return { sessionId: bestSessionId, days: bestDays, start: bestStart, end: bestEnd }
|
return { sessionId: bestSessionId, days: bestDays, start: bestStart, end: bestEnd }
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAvailableYears(params: { dbPath: string; decryptKey: string; wxid: string }): Promise<{ success: boolean; data?: number[]; error?: string }> {
|
async getAvailableYears(params: {
|
||||||
|
dbPath: string
|
||||||
|
decryptKey: string
|
||||||
|
wxid: string
|
||||||
|
onProgress?: (payload: AvailableYearsLoadProgress) => void
|
||||||
|
shouldCancel?: () => boolean
|
||||||
|
nativeTimeoutMs?: number
|
||||||
|
}): Promise<{ success: boolean; data?: number[]; error?: string; meta?: AvailableYearsLoadMeta }> {
|
||||||
try {
|
try {
|
||||||
|
const isCancelled = () => params.shouldCancel?.() === true
|
||||||
|
const totalStartedAt = Date.now()
|
||||||
|
let nativeElapsedMs = 0
|
||||||
|
let scanElapsedMs = 0
|
||||||
|
let switched = false
|
||||||
|
let nativeTimedOut = false
|
||||||
|
let latestYears: number[] = []
|
||||||
|
|
||||||
|
const emitProgress = (payload: {
|
||||||
|
years?: number[]
|
||||||
|
strategy: 'cache' | 'native' | 'hybrid'
|
||||||
|
phase: 'cache' | 'native' | 'scan'
|
||||||
|
statusText: string
|
||||||
|
switched?: boolean
|
||||||
|
nativeTimedOut?: boolean
|
||||||
|
}) => {
|
||||||
|
if (!params.onProgress) return
|
||||||
|
if (Array.isArray(payload.years)) latestYears = payload.years
|
||||||
|
params.onProgress({
|
||||||
|
years: latestYears,
|
||||||
|
strategy: payload.strategy,
|
||||||
|
phase: payload.phase,
|
||||||
|
statusText: payload.statusText,
|
||||||
|
nativeElapsedMs,
|
||||||
|
scanElapsedMs,
|
||||||
|
totalElapsedMs: Date.now() - totalStartedAt,
|
||||||
|
switched: payload.switched ?? switched,
|
||||||
|
nativeTimedOut: payload.nativeTimedOut ?? nativeTimedOut
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildMeta = (
|
||||||
|
strategy: 'cache' | 'native' | 'hybrid',
|
||||||
|
statusText: string
|
||||||
|
): AvailableYearsLoadMeta => ({
|
||||||
|
strategy,
|
||||||
|
nativeElapsedMs,
|
||||||
|
scanElapsedMs,
|
||||||
|
totalElapsedMs: Date.now() - totalStartedAt,
|
||||||
|
switched,
|
||||||
|
nativeTimedOut,
|
||||||
|
statusText
|
||||||
|
})
|
||||||
|
|
||||||
const conn = await this.ensureConnectedWithConfig(params.dbPath, params.decryptKey, params.wxid)
|
const conn = await this.ensureConnectedWithConfig(params.dbPath, params.decryptKey, params.wxid)
|
||||||
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error, meta: buildMeta('hybrid', '连接数据库失败') }
|
||||||
|
if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
|
||||||
|
const cacheKey = this.buildAvailableYearsCacheKey(params.dbPath, conn.cleanedWxid)
|
||||||
|
const cached = this.getCachedAvailableYears(cacheKey)
|
||||||
|
if (cached) {
|
||||||
|
latestYears = cached
|
||||||
|
emitProgress({
|
||||||
|
years: cached,
|
||||||
|
strategy: 'cache',
|
||||||
|
phase: 'cache',
|
||||||
|
statusText: '命中缓存,已快速加载年份数据'
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: cached,
|
||||||
|
meta: buildMeta('cache', '命中缓存,已快速加载年份数据')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const sessionIds = await this.getPrivateSessions(conn.cleanedWxid)
|
const sessionIds = await this.getPrivateSessions(conn.cleanedWxid)
|
||||||
if (sessionIds.length === 0) {
|
if (sessionIds.length === 0) {
|
||||||
return { success: false, error: '未找到消息会话' }
|
return { success: false, error: '未找到消息会话', meta: buildMeta('hybrid', '未找到消息会话') }
|
||||||
}
|
}
|
||||||
|
if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
|
||||||
|
|
||||||
const fastYears = await wcdbService.getAvailableYears(sessionIds)
|
const nativeTimeoutMs = Math.max(1000, Math.floor(params.nativeTimeoutMs || 5000))
|
||||||
if (fastYears.success && fastYears.data) {
|
const nativeStartedAt = Date.now()
|
||||||
return { success: true, data: fastYears.data }
|
let nativeTicker: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
emitProgress({
|
||||||
|
strategy: 'native',
|
||||||
|
phase: 'native',
|
||||||
|
statusText: '正在使用原生快速模式加载年份...'
|
||||||
|
})
|
||||||
|
nativeTicker = setInterval(() => {
|
||||||
|
nativeElapsedMs = Date.now() - nativeStartedAt
|
||||||
|
emitProgress({
|
||||||
|
strategy: 'native',
|
||||||
|
phase: 'native',
|
||||||
|
statusText: '正在使用原生快速模式加载年份...'
|
||||||
|
})
|
||||||
|
}, 120)
|
||||||
|
|
||||||
|
const nativeRace = await Promise.race([
|
||||||
|
wcdbService.getAvailableYears(sessionIds)
|
||||||
|
.then((result) => ({ kind: 'result' as const, result }))
|
||||||
|
.catch((error) => ({ kind: 'error' as const, error: String(error) })),
|
||||||
|
new Promise<{ kind: 'timeout' }>((resolve) => setTimeout(() => resolve({ kind: 'timeout' }), nativeTimeoutMs))
|
||||||
|
])
|
||||||
|
|
||||||
|
if (nativeTicker) {
|
||||||
|
clearInterval(nativeTicker)
|
||||||
|
nativeTicker = null
|
||||||
}
|
}
|
||||||
|
nativeElapsedMs = Math.max(nativeElapsedMs, Date.now() - nativeStartedAt)
|
||||||
|
|
||||||
const years = new Set<number>()
|
if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
|
||||||
for (const sessionId of sessionIds) {
|
|
||||||
const first = await this.getEdgeMessageTime(sessionId, true)
|
|
||||||
const last = await this.getEdgeMessageTime(sessionId, false)
|
|
||||||
if (!first && !last) continue
|
|
||||||
|
|
||||||
const minYear = new Date((first || last || 0) * 1000).getFullYear()
|
if (nativeRace.kind === 'result' && nativeRace.result.success && Array.isArray(nativeRace.result.data) && nativeRace.result.data.length > 0) {
|
||||||
const maxYear = new Date((last || first || 0) * 1000).getFullYear()
|
const years = this.normalizeAvailableYears(nativeRace.result.data)
|
||||||
for (let y = minYear; y <= maxYear; y++) {
|
latestYears = years
|
||||||
if (y >= 2010 && y <= new Date().getFullYear()) years.add(y)
|
this.setCachedAvailableYears(cacheKey, years)
|
||||||
|
emitProgress({
|
||||||
|
years,
|
||||||
|
strategy: 'native',
|
||||||
|
phase: 'native',
|
||||||
|
statusText: '原生快速模式加载完成'
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: years,
|
||||||
|
meta: buildMeta('native', '原生快速模式加载完成')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortedYears = Array.from(years).sort((a, b) => b - a)
|
switched = true
|
||||||
return { success: true, data: sortedYears }
|
nativeTimedOut = nativeRace.kind === 'timeout'
|
||||||
|
emitProgress({
|
||||||
|
strategy: 'hybrid',
|
||||||
|
phase: 'native',
|
||||||
|
statusText: nativeTimedOut
|
||||||
|
? '原生快速模式超时,已自动切换到扫表兼容模式...'
|
||||||
|
: '原生快速模式不可用,已自动切换到扫表兼容模式...',
|
||||||
|
switched: true,
|
||||||
|
nativeTimedOut
|
||||||
|
})
|
||||||
|
|
||||||
|
const scanStartedAt = Date.now()
|
||||||
|
let scanTicker: ReturnType<typeof setInterval> | null = null
|
||||||
|
scanTicker = setInterval(() => {
|
||||||
|
scanElapsedMs = Date.now() - scanStartedAt
|
||||||
|
emitProgress({
|
||||||
|
strategy: 'hybrid',
|
||||||
|
phase: 'scan',
|
||||||
|
statusText: nativeTimedOut
|
||||||
|
? '原生已超时,正在使用扫表兼容模式加载年份...'
|
||||||
|
: '正在使用扫表兼容模式加载年份...',
|
||||||
|
switched: true,
|
||||||
|
nativeTimedOut
|
||||||
|
})
|
||||||
|
}, 120)
|
||||||
|
|
||||||
|
let years = await this.getAvailableYearsByTableScan(sessionIds, {
|
||||||
|
onProgress: (items) => {
|
||||||
|
latestYears = items
|
||||||
|
scanElapsedMs = Date.now() - scanStartedAt
|
||||||
|
emitProgress({
|
||||||
|
years: items,
|
||||||
|
strategy: 'hybrid',
|
||||||
|
phase: 'scan',
|
||||||
|
statusText: nativeTimedOut
|
||||||
|
? '原生已超时,正在使用扫表兼容模式加载年份...'
|
||||||
|
: '正在使用扫表兼容模式加载年份...',
|
||||||
|
switched: true,
|
||||||
|
nativeTimedOut
|
||||||
|
})
|
||||||
|
},
|
||||||
|
shouldCancel: params.shouldCancel
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isCancelled()) {
|
||||||
|
if (scanTicker) clearInterval(scanTicker)
|
||||||
|
return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
|
||||||
|
}
|
||||||
|
if (years.length === 0) {
|
||||||
|
years = await this.getAvailableYearsByEdgeScan(sessionIds, {
|
||||||
|
onProgress: (items) => {
|
||||||
|
latestYears = items
|
||||||
|
scanElapsedMs = Date.now() - scanStartedAt
|
||||||
|
emitProgress({
|
||||||
|
years: items,
|
||||||
|
strategy: 'hybrid',
|
||||||
|
phase: 'scan',
|
||||||
|
statusText: '扫表结果为空,正在执行游标兜底扫描...',
|
||||||
|
switched: true,
|
||||||
|
nativeTimedOut
|
||||||
|
})
|
||||||
|
},
|
||||||
|
shouldCancel: params.shouldCancel
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (scanTicker) {
|
||||||
|
clearInterval(scanTicker)
|
||||||
|
scanTicker = null
|
||||||
|
}
|
||||||
|
scanElapsedMs = Math.max(scanElapsedMs, Date.now() - scanStartedAt)
|
||||||
|
|
||||||
|
if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
|
||||||
|
|
||||||
|
this.setCachedAvailableYears(cacheKey, years)
|
||||||
|
latestYears = years
|
||||||
|
emitProgress({
|
||||||
|
years,
|
||||||
|
strategy: 'hybrid',
|
||||||
|
phase: 'scan',
|
||||||
|
statusText: '扫表兼容模式加载完成',
|
||||||
|
switched: true,
|
||||||
|
nativeTimedOut
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: years,
|
||||||
|
meta: buildMeta('hybrid', '扫表兼容模式加载完成')
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e), meta: { strategy: 'hybrid', nativeElapsedMs: 0, scanElapsedMs: 0, totalElapsedMs: 0, switched: false, nativeTimedOut: false, statusText: '加载年度数据失败' } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,7 +943,7 @@ class AnnualReportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.reportProgress('加载扩展统计... (初始化)', 30, onProgress)
|
this.reportProgress('加载扩展统计...', 30, onProgress)
|
||||||
const extras = await wcdbService.getAnnualReportExtras(sessionIds, actualStartTime, actualEndTime, peakDayBegin, peakDayEnd)
|
const extras = await wcdbService.getAnnualReportExtras(sessionIds, actualStartTime, actualEndTime, peakDayBegin, peakDayEnd)
|
||||||
if (extras.success && extras.data) {
|
if (extras.success && extras.data) {
|
||||||
this.reportProgress('加载扩展统计... (解析热力图)', 32, onProgress)
|
this.reportProgress('加载扩展统计... (解析热力图)', 32, onProgress)
|
||||||
@@ -687,7 +1135,7 @@ class AnnualReportService {
|
|||||||
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
if (now - lastProgressAt > 200) {
|
if (now - lastProgressAt > 200) {
|
||||||
let progress = 30
|
let progress: number
|
||||||
if (totalMessagesForProgress > 0) {
|
if (totalMessagesForProgress > 0) {
|
||||||
const ratio = Math.min(1, processedMessages / totalMessagesForProgress)
|
const ratio = Math.min(1, processedMessages / totalMessagesForProgress)
|
||||||
progress = 30 + Math.floor(ratio * 50)
|
progress = 30 + Math.floor(ratio * 50)
|
||||||
|
|||||||
219
electron/services/avatarFileCacheService.ts
Normal file
219
electron/services/avatarFileCacheService.ts
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import https from "https";
|
||||||
|
import http, { IncomingMessage } from "http";
|
||||||
|
import { promises as fs } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
import { ConfigService } from "./config";
|
||||||
|
|
||||||
|
// 头像文件缓存服务 - 复用项目已有的缓存目录结构
|
||||||
|
export class AvatarFileCacheService {
|
||||||
|
private static instance: AvatarFileCacheService | null = null;
|
||||||
|
|
||||||
|
// 头像文件缓存目录
|
||||||
|
private readonly cacheDir: string;
|
||||||
|
// 头像URL -> 本地文件路径的内存缓存(仅追踪正在下载的)
|
||||||
|
private readonly pendingDownloads: Map<string, Promise<string | null>> =
|
||||||
|
new Map();
|
||||||
|
// LRU 追踪:文件路径->最后访问时间
|
||||||
|
private readonly lruOrder: string[] = [];
|
||||||
|
private readonly maxCacheFiles = 100;
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
const basePath = ConfigService.getInstance().getCacheBasePath();
|
||||||
|
this.cacheDir = join(basePath, "avatar-files");
|
||||||
|
this.ensureCacheDir();
|
||||||
|
this.loadLruOrder();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getInstance(): AvatarFileCacheService {
|
||||||
|
if (!AvatarFileCacheService.instance) {
|
||||||
|
AvatarFileCacheService.instance = new AvatarFileCacheService();
|
||||||
|
}
|
||||||
|
return AvatarFileCacheService.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureCacheDir(): void {
|
||||||
|
// 同步确保目录存在(构造函数调用)
|
||||||
|
try {
|
||||||
|
fs.mkdir(this.cacheDir, { recursive: true }).catch(() => {});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureCacheDirAsync(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fs.mkdir(this.cacheDir, { recursive: true });
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFilePath(url: string): string {
|
||||||
|
// 使用URL的hash作为文件名,避免特殊字符问题
|
||||||
|
const hash = this.hashString(url);
|
||||||
|
return join(this.cacheDir, `avatar_${hash}.png`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private hashString(str: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
const char = str.charCodeAt(i);
|
||||||
|
hash = (hash << 5) - hash + char;
|
||||||
|
hash = hash & hash; // 转换为32位整数
|
||||||
|
}
|
||||||
|
return Math.abs(hash).toString(16);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadLruOrder(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const entries = await fs.readdir(this.cacheDir);
|
||||||
|
// 按修改时间排序(旧的在前)
|
||||||
|
const filesWithTime: { file: string; mtime: number }[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.startsWith("avatar_") || !entry.endsWith(".png")) continue;
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(join(this.cacheDir, entry));
|
||||||
|
filesWithTime.push({ file: entry, mtime: stat.mtimeMs });
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
filesWithTime.sort((a, b) => a.mtime - b.mtime);
|
||||||
|
this.lruOrder.length = 0;
|
||||||
|
this.lruOrder.push(...filesWithTime.map((f) => f.file));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateLru(fileName: string): void {
|
||||||
|
const index = this.lruOrder.indexOf(fileName);
|
||||||
|
if (index > -1) {
|
||||||
|
this.lruOrder.splice(index, 1);
|
||||||
|
}
|
||||||
|
this.lruOrder.push(fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async evictIfNeeded(): Promise<void> {
|
||||||
|
while (this.lruOrder.length >= this.maxCacheFiles) {
|
||||||
|
const oldest = this.lruOrder.shift();
|
||||||
|
if (oldest) {
|
||||||
|
try {
|
||||||
|
await fs.rm(join(this.cacheDir, oldest));
|
||||||
|
console.log(`[AvatarFileCache] Evicted: ${oldest}`);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async downloadAvatar(url: string): Promise<string | null> {
|
||||||
|
const localPath = this.getFilePath(url);
|
||||||
|
|
||||||
|
// 检查文件是否已存在
|
||||||
|
try {
|
||||||
|
await fs.access(localPath);
|
||||||
|
const fileName = localPath.split("/").pop()!;
|
||||||
|
this.updateLru(fileName);
|
||||||
|
return localPath;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
await this.ensureCacheDirAsync();
|
||||||
|
await this.evictIfNeeded();
|
||||||
|
|
||||||
|
return new Promise<string | null>((resolve) => {
|
||||||
|
const options = {
|
||||||
|
headers: {
|
||||||
|
"User-Agent":
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351",
|
||||||
|
Referer: "https://servicewechat.com/",
|
||||||
|
Accept:
|
||||||
|
"image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
|
||||||
|
"Accept-Encoding": "gzip, deflate, br",
|
||||||
|
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const callback = (res: IncomingMessage) => {
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
res.on("end", async () => {
|
||||||
|
try {
|
||||||
|
const buffer = Buffer.concat(chunks);
|
||||||
|
await fs.writeFile(localPath, buffer);
|
||||||
|
const fileName = localPath.split("/").pop()!;
|
||||||
|
this.updateLru(fileName);
|
||||||
|
console.log(
|
||||||
|
`[AvatarFileCache] Downloaded: ${url.substring(0, 50)}... -> ${localPath}`,
|
||||||
|
);
|
||||||
|
resolve(localPath);
|
||||||
|
} catch {
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.on("error", () => resolve(null));
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = url.startsWith("https")
|
||||||
|
? https.get(url, options, callback)
|
||||||
|
: http.get(url, options, callback);
|
||||||
|
|
||||||
|
req.on("error", () => resolve(null));
|
||||||
|
req.setTimeout(10000, () => {
|
||||||
|
req.destroy();
|
||||||
|
resolve(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取头像本地文件路径,如果需要会下载
|
||||||
|
* 同一URL并发调用会复用同一个下载任务
|
||||||
|
*/
|
||||||
|
async getAvatarPath(url: string): Promise<string | null> {
|
||||||
|
if (!url) return null;
|
||||||
|
|
||||||
|
// 检查是否有正在进行的下载
|
||||||
|
const pending = this.pendingDownloads.get(url);
|
||||||
|
if (pending) {
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发起新下载
|
||||||
|
const downloadPromise = this.downloadAvatar(url);
|
||||||
|
this.pendingDownloads.set(url, downloadPromise);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await downloadPromise;
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
this.pendingDownloads.delete(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理所有缓存文件(App退出时调用)
|
||||||
|
async clearCache(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const entries = await fs.readdir(this.cacheDir);
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.startsWith("avatar_") && entry.endsWith(".png")) {
|
||||||
|
try {
|
||||||
|
await fs.rm(join(this.cacheDir, entry));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.lruOrder.length = 0;
|
||||||
|
console.log("[AvatarFileCache] Cache cleared");
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前缓存的文件数量
|
||||||
|
async getCacheCount(): Promise<number> {
|
||||||
|
try {
|
||||||
|
const entries = await fs.readdir(this.cacheDir);
|
||||||
|
return entries.filter(
|
||||||
|
(e) => e.startsWith("avatar_") && e.endsWith(".png"),
|
||||||
|
).length;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const avatarFileCache = AvatarFileCacheService.getInstance();
|
||||||
243
electron/services/bizService.ts
Normal file
243
electron/services/bizService.ts
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import { join } from 'path'
|
||||||
|
import { readdirSync, existsSync } from 'fs'
|
||||||
|
import { wcdbService } from './wcdbService'
|
||||||
|
import { ConfigService } from './config'
|
||||||
|
import { chatService, Message } from './chatService'
|
||||||
|
import { ipcMain } from 'electron'
|
||||||
|
import { createHash } from 'crypto'
|
||||||
|
|
||||||
|
export interface BizAccount {
|
||||||
|
username: string
|
||||||
|
name: string
|
||||||
|
avatar: string
|
||||||
|
type: number
|
||||||
|
last_time: number
|
||||||
|
formatted_last_time: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BizMessage {
|
||||||
|
local_id: number
|
||||||
|
create_time: number
|
||||||
|
title: string
|
||||||
|
des: string
|
||||||
|
url: string
|
||||||
|
cover: string
|
||||||
|
content_list: any[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BizPayRecord {
|
||||||
|
local_id: number
|
||||||
|
create_time: number
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
merchant_name: string
|
||||||
|
merchant_icon: string
|
||||||
|
timestamp: number
|
||||||
|
formatted_time: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BizService {
|
||||||
|
private configService: ConfigService
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.configService = new ConfigService()
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractXmlValue(xml: string, tagName: string): string {
|
||||||
|
const regex = new RegExp(`<${tagName}>([\\s\\S]*?)</${tagName}>`, 'i')
|
||||||
|
const match = regex.exec(xml)
|
||||||
|
if (match) {
|
||||||
|
return match[1].replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim()
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseBizContentList(xmlStr: string): any[] {
|
||||||
|
if (!xmlStr) return []
|
||||||
|
const contentList: any[] = []
|
||||||
|
try {
|
||||||
|
const itemRegex = /<item>([\s\S]*?)<\/item>/gi
|
||||||
|
let match: RegExpExecArray | null
|
||||||
|
while ((match = itemRegex.exec(xmlStr)) !== null) {
|
||||||
|
const itemXml = match[1]
|
||||||
|
const itemStruct = {
|
||||||
|
title: this.extractXmlValue(itemXml, 'title'),
|
||||||
|
url: this.extractXmlValue(itemXml, 'url'),
|
||||||
|
cover: this.extractXmlValue(itemXml, 'cover') || this.extractXmlValue(itemXml, 'thumburl'),
|
||||||
|
summary: this.extractXmlValue(itemXml, 'summary') || this.extractXmlValue(itemXml, 'digest')
|
||||||
|
}
|
||||||
|
if (itemStruct.title) contentList.push(itemStruct)
|
||||||
|
}
|
||||||
|
} catch (e) { }
|
||||||
|
return contentList
|
||||||
|
}
|
||||||
|
|
||||||
|
private parsePayXml(xmlStr: string): any {
|
||||||
|
if (!xmlStr) return null
|
||||||
|
try {
|
||||||
|
const title = this.extractXmlValue(xmlStr, 'title')
|
||||||
|
const description = this.extractXmlValue(xmlStr, 'des')
|
||||||
|
const merchantName = this.extractXmlValue(xmlStr, 'display_name') || '微信支付'
|
||||||
|
const merchantIcon = this.extractXmlValue(xmlStr, 'icon_url')
|
||||||
|
const pubTime = parseInt(this.extractXmlValue(xmlStr, 'pub_time') || '0')
|
||||||
|
if (!title && !description) return null
|
||||||
|
return { title, description, merchant_name: merchantName, merchant_icon: merchantIcon, timestamp: pubTime }
|
||||||
|
} catch (e) { return null }
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAccounts(account?: string): Promise<BizAccount[]> {
|
||||||
|
try {
|
||||||
|
// 1. 获取公众号联系人列表
|
||||||
|
const contactsResult = await chatService.getContacts({ lite: true })
|
||||||
|
if (!contactsResult.success || !contactsResult.contacts) return []
|
||||||
|
|
||||||
|
const officialContacts = contactsResult.contacts.filter(c => c.type === 'official')
|
||||||
|
const usernames = officialContacts.map(c => c.username)
|
||||||
|
|
||||||
|
// 获取头像和昵称等补充信息
|
||||||
|
const enrichment = await chatService.enrichSessionsContactInfo(usernames)
|
||||||
|
const contactInfoMap = enrichment.success && enrichment.contacts ? enrichment.contacts : {}
|
||||||
|
|
||||||
|
const root = this.configService.get('dbPath')
|
||||||
|
const myWxid = this.configService.get('myWxid')
|
||||||
|
const accountWxid = account || myWxid
|
||||||
|
if (!root || !accountWxid) return []
|
||||||
|
|
||||||
|
const bizLatestTime: Record<string, number> = {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessionsRes = await wcdbService.getSessions()
|
||||||
|
if (sessionsRes.success && sessionsRes.sessions) {
|
||||||
|
for (const session of sessionsRes.sessions) {
|
||||||
|
const uname = session.username || session.strUsrName || session.userName || session.id
|
||||||
|
// 适配日志中发现的字段,注意转为整型数字
|
||||||
|
const timeStr = session.last_timestamp || session.sort_timestamp || session.nTime || session.timestamp || '0'
|
||||||
|
const time = parseInt(timeStr.toString(), 10)
|
||||||
|
|
||||||
|
if (usernames.includes(uname) && time > 0) {
|
||||||
|
bizLatestTime[uname] = time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('获取 Sessions 失败:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 格式化时间显示
|
||||||
|
const formatBizTime = (ts: number) => {
|
||||||
|
if (!ts) return ''
|
||||||
|
const date = new Date(ts * 1000)
|
||||||
|
const now = new Date()
|
||||||
|
const isToday = date.toDateString() === now.toDateString()
|
||||||
|
if (isToday) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||||
|
|
||||||
|
const yesterday = new Date(now)
|
||||||
|
yesterday.setDate(now.getDate() - 1)
|
||||||
|
if (date.toDateString() === yesterday.toDateString()) return '昨天'
|
||||||
|
|
||||||
|
const isThisYear = date.getFullYear() === now.getFullYear()
|
||||||
|
if (isThisYear) return `${date.getMonth() + 1}/${date.getDate()}`
|
||||||
|
|
||||||
|
return `${date.getFullYear().toString().slice(-2)}/${date.getMonth() + 1}/${date.getDate()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 组装数据
|
||||||
|
const result: BizAccount[] = officialContacts.map(contact => {
|
||||||
|
const uname = contact.username
|
||||||
|
const info = contactInfoMap[uname]
|
||||||
|
const lastTime = bizLatestTime[uname] || 0
|
||||||
|
return {
|
||||||
|
username: uname,
|
||||||
|
name: info?.displayName || contact.displayName || uname,
|
||||||
|
avatar: info?.avatarUrl || '',
|
||||||
|
type: 0,
|
||||||
|
last_time: lastTime,
|
||||||
|
formatted_last_time: formatBizTime(lastTime)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 5. 补充公众号类型 (订阅号/服务号)
|
||||||
|
const contactDbPath = join(root, accountWxid, 'db_storage', 'contact', 'contact.db')
|
||||||
|
if (existsSync(contactDbPath)) {
|
||||||
|
const bizInfoRes = await wcdbService.execQuery('contact', contactDbPath, 'SELECT username, type FROM biz_info')
|
||||||
|
if (bizInfoRes.success && bizInfoRes.rows) {
|
||||||
|
const typeMap: Record<string, number> = {}
|
||||||
|
for (const r of bizInfoRes.rows) typeMap[r.username] = r.type
|
||||||
|
for (const acc of result) if (typeMap[acc.username] !== undefined) acc.type = typeMap[acc.username]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 排序输出
|
||||||
|
return result
|
||||||
|
.filter(acc => !acc.name.includes('广告'))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.username === 'gh_3dfda90e39d6') return -1 // 微信支付置顶
|
||||||
|
if (b.username === 'gh_3dfda90e39d6') return 1
|
||||||
|
return b.last_time - a.last_time // 按最新时间降序排列
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('获取账号列表发生错误:', e)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listMessages(username: string, account?: string, limit: number = 20, offset: number = 0): Promise<BizMessage[]> {
|
||||||
|
try {
|
||||||
|
// 仅保留核心路径:利用 chatService 的自动路由能力
|
||||||
|
const res = await chatService.getMessages(username, offset, limit)
|
||||||
|
if (!res.success || !res.messages) return []
|
||||||
|
|
||||||
|
return res.messages.map(msg => {
|
||||||
|
const bizMsg: BizMessage = {
|
||||||
|
local_id: msg.localId,
|
||||||
|
create_time: msg.createTime,
|
||||||
|
title: msg.linkTitle || msg.parsedContent || '',
|
||||||
|
des: msg.appMsgDesc || '',
|
||||||
|
url: msg.linkUrl || '',
|
||||||
|
cover: msg.linkThumb || msg.appMsgThumbUrl || '',
|
||||||
|
content_list: []
|
||||||
|
}
|
||||||
|
if (msg.rawContent) {
|
||||||
|
bizMsg.content_list = this.parseBizContentList(msg.rawContent)
|
||||||
|
if (bizMsg.content_list.length > 0 && !bizMsg.title) {
|
||||||
|
bizMsg.title = bizMsg.content_list[0].title
|
||||||
|
bizMsg.cover = bizMsg.cover || bizMsg.content_list[0].cover
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bizMsg
|
||||||
|
})
|
||||||
|
} catch (e) { return [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPayRecords(account?: string, limit: number = 20, offset: number = 0): Promise<BizPayRecord[]> {
|
||||||
|
const username = 'gh_3dfda90e39d6'
|
||||||
|
try {
|
||||||
|
const res = await chatService.getMessages(username, offset, limit)
|
||||||
|
if (!res.success || !res.messages) return []
|
||||||
|
|
||||||
|
const records: BizPayRecord[] = []
|
||||||
|
for (const msg of res.messages) {
|
||||||
|
if (!msg.rawContent) continue
|
||||||
|
const parsedData = this.parsePayXml(msg.rawContent)
|
||||||
|
if (parsedData) {
|
||||||
|
records.push({
|
||||||
|
local_id: msg.localId,
|
||||||
|
create_time: msg.createTime,
|
||||||
|
...parsedData,
|
||||||
|
timestamp: parsedData.timestamp || msg.createTime,
|
||||||
|
formatted_time: new Date((parsedData.timestamp || msg.createTime) * 1000).toLocaleString()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
} catch (e) { return [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
registerHandlers() {
|
||||||
|
ipcMain.handle('biz:listAccounts', (_, account) => this.listAccounts(account))
|
||||||
|
ipcMain.handle('biz:listMessages', (_, username, account, limit, offset) => this.listMessages(username, account, limit, offset))
|
||||||
|
ipcMain.handle('biz:listPayRecords', (_, account, limit, offset) => this.listPayRecords(account, limit, offset))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bizService = new BizService()
|
||||||
File diff suppressed because it is too large
Load Diff
241
electron/services/cloudControlService.ts
Normal file
241
electron/services/cloudControlService.ts
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import { wcdbService } from './wcdbService'
|
||||||
|
|
||||||
|
interface UsageStats {
|
||||||
|
appVersion: string
|
||||||
|
platform: string
|
||||||
|
deviceId: string
|
||||||
|
timestamp: number
|
||||||
|
online: boolean
|
||||||
|
pages: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
class CloudControlService {
|
||||||
|
private deviceId: string = ''
|
||||||
|
private timer: NodeJS.Timeout | null = null
|
||||||
|
private pages: Set<string> = new Set()
|
||||||
|
private platformVersionCache: string | null = null
|
||||||
|
private pendingReports: UsageStats[] = []
|
||||||
|
private flushInProgress = false
|
||||||
|
private retryDelayMs = 5_000
|
||||||
|
private consecutiveFailures = 0
|
||||||
|
private circuitOpenedAt = 0
|
||||||
|
private nextDelayOverrideMs: number | null = null
|
||||||
|
private initialized = false
|
||||||
|
|
||||||
|
private static readonly BASE_FLUSH_MS = 300_000
|
||||||
|
private static readonly JITTER_MS = 30_000
|
||||||
|
private static readonly MAX_BUFFER_REPORTS = 200
|
||||||
|
private static readonly MAX_BATCH_REPORTS = 20
|
||||||
|
private static readonly MAX_RETRY_MS = 120_000
|
||||||
|
private static readonly CIRCUIT_FAIL_THRESHOLD = 5
|
||||||
|
private static readonly CIRCUIT_COOLDOWN_MS = 120_000
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
if (this.initialized) return
|
||||||
|
this.initialized = true
|
||||||
|
this.deviceId = this.getDeviceId()
|
||||||
|
await wcdbService.cloudInit(300)
|
||||||
|
this.enqueueCurrentReport()
|
||||||
|
await this.flushQueue(true)
|
||||||
|
this.scheduleNextFlush(this.nextDelayOverrideMs ?? undefined)
|
||||||
|
this.nextDelayOverrideMs = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDeviceId(): string {
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const os = require('os')
|
||||||
|
const machineId = os.hostname() + os.platform() + os.arch()
|
||||||
|
return crypto.createHash('md5').update(machineId).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildCurrentReport(): UsageStats {
|
||||||
|
return {
|
||||||
|
appVersion: app.getVersion(),
|
||||||
|
platform: this.getPlatformVersion(),
|
||||||
|
deviceId: this.deviceId,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
online: true,
|
||||||
|
pages: Array.from(this.pages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enqueueCurrentReport() {
|
||||||
|
const report = this.buildCurrentReport()
|
||||||
|
this.pendingReports.push(report)
|
||||||
|
if (this.pendingReports.length > CloudControlService.MAX_BUFFER_REPORTS) {
|
||||||
|
this.pendingReports.splice(0, this.pendingReports.length - CloudControlService.MAX_BUFFER_REPORTS)
|
||||||
|
}
|
||||||
|
this.pages.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private isCircuitOpen(nowMs: number): boolean {
|
||||||
|
if (this.circuitOpenedAt <= 0) return false
|
||||||
|
return nowMs-this.circuitOpenedAt < CloudControlService.CIRCUIT_COOLDOWN_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleNextFlush(delayMs?: number) {
|
||||||
|
if (this.timer) {
|
||||||
|
clearTimeout(this.timer)
|
||||||
|
this.timer = null
|
||||||
|
}
|
||||||
|
const jitter = Math.floor(Math.random() * CloudControlService.JITTER_MS)
|
||||||
|
const nextDelay = Math.max(1_000, Number(delayMs) > 0 ? Number(delayMs) : CloudControlService.BASE_FLUSH_MS + jitter)
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
this.enqueueCurrentReport()
|
||||||
|
this.flushQueue(false).finally(() => {
|
||||||
|
this.scheduleNextFlush(this.nextDelayOverrideMs ?? undefined)
|
||||||
|
this.nextDelayOverrideMs = null
|
||||||
|
})
|
||||||
|
}, nextDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async flushQueue(force: boolean) {
|
||||||
|
if (this.flushInProgress) return
|
||||||
|
if (this.pendingReports.length === 0) return
|
||||||
|
const now = Date.now()
|
||||||
|
if (!force && this.isCircuitOpen(now)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.flushInProgress = true
|
||||||
|
try {
|
||||||
|
while (this.pendingReports.length > 0) {
|
||||||
|
const batch = this.pendingReports.slice(0, CloudControlService.MAX_BATCH_REPORTS)
|
||||||
|
const result = await wcdbService.cloudReport(JSON.stringify(batch))
|
||||||
|
if (!result || result.success !== true) {
|
||||||
|
this.consecutiveFailures += 1
|
||||||
|
this.retryDelayMs = Math.min(CloudControlService.MAX_RETRY_MS, this.retryDelayMs * 2)
|
||||||
|
if (this.consecutiveFailures >= CloudControlService.CIRCUIT_FAIL_THRESHOLD) {
|
||||||
|
this.circuitOpenedAt = Date.now()
|
||||||
|
}
|
||||||
|
this.nextDelayOverrideMs = this.retryDelayMs
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.pendingReports.splice(0, batch.length)
|
||||||
|
this.consecutiveFailures = 0
|
||||||
|
this.retryDelayMs = 5_000
|
||||||
|
this.circuitOpenedAt = 0
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.flushInProgress = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPlatformVersion(): string {
|
||||||
|
if (this.platformVersionCache) {
|
||||||
|
return this.platformVersionCache
|
||||||
|
}
|
||||||
|
|
||||||
|
const os = require('os')
|
||||||
|
const fs = require('fs')
|
||||||
|
const platform = process.platform
|
||||||
|
|
||||||
|
if (platform === 'win32') {
|
||||||
|
const release = os.release()
|
||||||
|
const parts = release.split('.')
|
||||||
|
const major = parseInt(parts[0])
|
||||||
|
const minor = parseInt(parts[1] || '0')
|
||||||
|
const build = parseInt(parts[2] || '0')
|
||||||
|
|
||||||
|
// Windows 11 是 10.0.22000+,且主版本必须是 10.0
|
||||||
|
if (major === 10 && minor === 0 && build >= 22000) {
|
||||||
|
this.platformVersionCache = 'Windows 11'
|
||||||
|
return this.platformVersionCache
|
||||||
|
} else if (major === 10) {
|
||||||
|
this.platformVersionCache = 'Windows 10'
|
||||||
|
return this.platformVersionCache
|
||||||
|
}
|
||||||
|
this.platformVersionCache = `Windows ${release}`
|
||||||
|
return this.platformVersionCache
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'darwin') {
|
||||||
|
// `os.release()` returns Darwin kernel version (e.g. 25.3.0),
|
||||||
|
// while cloud reporting expects the macOS product version (e.g. 26.3).
|
||||||
|
const macVersion = typeof process.getSystemVersion === 'function' ? process.getSystemVersion() : os.release()
|
||||||
|
this.platformVersionCache = `macOS ${macVersion}`
|
||||||
|
return this.platformVersionCache
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'linux') {
|
||||||
|
try {
|
||||||
|
const osReleasePaths = ['/etc/os-release', '/usr/lib/os-release']
|
||||||
|
for (const filePath of osReleasePaths) {
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = fs.readFileSync(filePath, 'utf8')
|
||||||
|
const values: Record<string, string> = {}
|
||||||
|
|
||||||
|
for (const line of content.split('\n')) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const separatorIndex = trimmed.indexOf('=')
|
||||||
|
if (separatorIndex <= 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = trimmed.slice(0, separatorIndex)
|
||||||
|
let value = trimmed.slice(separatorIndex + 1).trim()
|
||||||
|
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) {
|
||||||
|
value = value.slice(1, -1)
|
||||||
|
}
|
||||||
|
values[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.PRETTY_NAME) {
|
||||||
|
this.platformVersionCache = values.PRETTY_NAME
|
||||||
|
return this.platformVersionCache
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.NAME && values.VERSION_ID) {
|
||||||
|
this.platformVersionCache = `${values.NAME} ${values.VERSION_ID}`
|
||||||
|
return this.platformVersionCache
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.NAME) {
|
||||||
|
this.platformVersionCache = values.NAME
|
||||||
|
return this.platformVersionCache
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[CloudControl] Failed to detect Linux distro version:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.platformVersionCache = `Linux ${os.release()}`
|
||||||
|
return this.platformVersionCache
|
||||||
|
}
|
||||||
|
|
||||||
|
this.platformVersionCache = platform
|
||||||
|
return this.platformVersionCache
|
||||||
|
}
|
||||||
|
|
||||||
|
recordPage(pageName: string) {
|
||||||
|
this.pages.add(pageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (this.timer) {
|
||||||
|
clearTimeout(this.timer)
|
||||||
|
this.timer = null
|
||||||
|
}
|
||||||
|
this.pendingReports = []
|
||||||
|
this.flushInProgress = false
|
||||||
|
this.retryDelayMs = 5_000
|
||||||
|
this.consecutiveFailures = 0
|
||||||
|
this.circuitOpenedAt = 0
|
||||||
|
this.nextDelayOverrideMs = null
|
||||||
|
this.initialized = false
|
||||||
|
wcdbService.cloudStop()
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLogs() {
|
||||||
|
return wcdbService.getLogs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cloudControlService = new CloudControlService()
|
||||||
@@ -1,15 +1,29 @@
|
|||||||
|
import { join } from 'path'
|
||||||
|
import { app, safeStorage } from 'electron'
|
||||||
|
import crypto from 'crypto'
|
||||||
import Store from 'electron-store'
|
import Store from 'electron-store'
|
||||||
|
|
||||||
|
// 加密前缀标记
|
||||||
|
const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式)
|
||||||
|
const isSafeStorageAvailable = (): boolean => {
|
||||||
|
try {
|
||||||
|
return typeof safeStorage?.isEncryptionAvailable === 'function' && safeStorage.isEncryptionAvailable()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const LOCK_PREFIX = 'lock:' // 密码派生密钥加密(锁定模式)
|
||||||
|
|
||||||
interface ConfigSchema {
|
interface ConfigSchema {
|
||||||
// 数据库相关
|
// 数据库相关
|
||||||
dbPath: string // 数据库根目录 (xwechat_files)
|
dbPath: string
|
||||||
decryptKey: string // 解密密钥
|
decryptKey: string
|
||||||
myWxid: string // 当前用户 wxid
|
myWxid: string
|
||||||
onboardingDone: boolean
|
onboardingDone: boolean
|
||||||
imageXorKey: number
|
imageXorKey: number
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
wxidConfigs: Record<string, { decryptKey?: string; imageXorKey?: number; imageAesKey?: string; updatedAt?: number }>
|
wxidConfigs: Record<string, { decryptKey?: string; imageXorKey?: number; imageAesKey?: string; updatedAt?: number }>
|
||||||
|
exportPath?: string;
|
||||||
// 缓存相关
|
// 缓存相关
|
||||||
cachePath: string
|
cachePath: string
|
||||||
lastOpenedDb: string
|
lastOpenedDb: string
|
||||||
@@ -20,6 +34,7 @@ interface ConfigSchema {
|
|||||||
themeId: string
|
themeId: string
|
||||||
language: string
|
language: string
|
||||||
logEnabled: boolean
|
logEnabled: boolean
|
||||||
|
launchAtStartup?: boolean
|
||||||
llmModelPath: string
|
llmModelPath: string
|
||||||
whisperModelName: string
|
whisperModelName: string
|
||||||
whisperModelDir: string
|
whisperModelDir: string
|
||||||
@@ -27,27 +42,76 @@ interface ConfigSchema {
|
|||||||
autoTranscribeVoice: boolean
|
autoTranscribeVoice: boolean
|
||||||
transcribeLanguages: string[]
|
transcribeLanguages: string[]
|
||||||
exportDefaultConcurrency: number
|
exportDefaultConcurrency: number
|
||||||
|
exportDefaultImageDeepSearchOnMiss: boolean
|
||||||
analyticsExcludedUsernames: string[]
|
analyticsExcludedUsernames: string[]
|
||||||
|
|
||||||
// 安全相关
|
// 安全相关
|
||||||
authEnabled: boolean
|
authEnabled: boolean
|
||||||
authPassword: string // SHA-256 hash
|
authPassword: string // SHA-256 hash(safeStorage 加密)
|
||||||
authUseHello: boolean
|
authUseHello: boolean
|
||||||
|
authHelloSecret: string // 原始密码(safeStorage 加密,Hello 解锁时使用)
|
||||||
|
|
||||||
// 更新相关
|
// 更新相关
|
||||||
ignoredUpdateVersion: string
|
ignoredUpdateVersion: string
|
||||||
|
updateChannel: 'auto' | 'stable' | 'preview' | 'dev'
|
||||||
|
|
||||||
// 通知
|
// 通知
|
||||||
notificationEnabled: boolean
|
notificationEnabled: boolean
|
||||||
notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
|
notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
|
||||||
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
|
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
|
||||||
notificationFilterList: string[]
|
notificationFilterList: string[]
|
||||||
|
messagePushEnabled: boolean
|
||||||
|
httpApiEnabled: boolean
|
||||||
|
httpApiPort: number
|
||||||
|
httpApiHost: string
|
||||||
|
httpApiToken: string
|
||||||
|
windowCloseBehavior: 'ask' | 'tray' | 'quit'
|
||||||
|
quoteLayout: 'quote-top' | 'quote-bottom'
|
||||||
|
wordCloudExcludeWords: string[]
|
||||||
|
exportWriteLayout: 'A' | 'B' | 'C'
|
||||||
|
|
||||||
|
// AI 见解
|
||||||
|
aiInsightEnabled: boolean
|
||||||
|
aiInsightApiBaseUrl: string
|
||||||
|
aiInsightApiKey: string
|
||||||
|
aiInsightApiModel: string
|
||||||
|
aiInsightSilenceDays: number
|
||||||
|
aiInsightAllowContext: boolean
|
||||||
|
aiInsightWhitelistEnabled: boolean
|
||||||
|
aiInsightWhitelist: string[]
|
||||||
|
/** 活跃分析冷却时间(分钟),0 表示无冷却 */
|
||||||
|
aiInsightCooldownMinutes: number
|
||||||
|
/** 沉默联系人扫描间隔(小时) */
|
||||||
|
aiInsightScanIntervalHours: number
|
||||||
|
/** 发送上下文时的最大消息条数 */
|
||||||
|
aiInsightContextCount: number
|
||||||
|
/** 自定义 system prompt,空字符串表示使用内置默认值 */
|
||||||
|
aiInsightSystemPrompt: string
|
||||||
|
/** 是否启用 Telegram 推送 */
|
||||||
|
aiInsightTelegramEnabled: boolean
|
||||||
|
/** Telegram Bot Token */
|
||||||
|
aiInsightTelegramToken: string
|
||||||
|
/** Telegram 接收 Chat ID,逗号分隔,支持多个 */
|
||||||
|
aiInsightTelegramChatIds: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 需要 safeStorage 加密的字段(普通模式)
|
||||||
|
const ENCRYPTED_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey', 'authPassword', 'httpApiToken', 'aiInsightApiKey'])
|
||||||
|
const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello'])
|
||||||
|
const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
|
||||||
|
|
||||||
|
// 需要与密码绑定的敏感密钥字段(锁定模式时用 lock: 加密)
|
||||||
|
const LOCKABLE_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey'])
|
||||||
|
const LOCKABLE_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
|
||||||
|
|
||||||
export class ConfigService {
|
export class ConfigService {
|
||||||
private static instance: ConfigService
|
private static instance: ConfigService
|
||||||
private store!: Store<ConfigSchema>
|
private store!: Store<ConfigSchema>
|
||||||
|
|
||||||
|
// 锁定模式运行时状态
|
||||||
|
private unlockedKeys: Map<string, any> = new Map()
|
||||||
|
private unlockPassword: string | null = null
|
||||||
|
|
||||||
static getInstance(): ConfigService {
|
static getInstance(): ConfigService {
|
||||||
if (!ConfigService.instance) {
|
if (!ConfigService.instance) {
|
||||||
ConfigService.instance = new ConfigService()
|
ConfigService.instance = new ConfigService()
|
||||||
@@ -60,9 +124,7 @@ export class ConfigService {
|
|||||||
return ConfigService.instance
|
return ConfigService.instance
|
||||||
}
|
}
|
||||||
ConfigService.instance = this
|
ConfigService.instance = this
|
||||||
this.store = new Store<ConfigSchema>({
|
const defaults: ConfigSchema = {
|
||||||
name: 'WeFlow-config',
|
|
||||||
defaults: {
|
|
||||||
dbPath: '',
|
dbPath: '',
|
||||||
decryptKey: '',
|
decryptKey: '',
|
||||||
myWxid: '',
|
myWxid: '',
|
||||||
@@ -83,35 +145,629 @@ export class ConfigService {
|
|||||||
whisperDownloadSource: 'tsinghua',
|
whisperDownloadSource: 'tsinghua',
|
||||||
autoTranscribeVoice: false,
|
autoTranscribeVoice: false,
|
||||||
transcribeLanguages: ['zh'],
|
transcribeLanguages: ['zh'],
|
||||||
exportDefaultConcurrency: 2,
|
exportDefaultConcurrency: 4,
|
||||||
|
exportDefaultImageDeepSearchOnMiss: true,
|
||||||
analyticsExcludedUsernames: [],
|
analyticsExcludedUsernames: [],
|
||||||
|
|
||||||
authEnabled: false,
|
authEnabled: false,
|
||||||
authPassword: '',
|
authPassword: '',
|
||||||
authUseHello: false,
|
authUseHello: false,
|
||||||
|
authHelloSecret: '',
|
||||||
ignoredUpdateVersion: '',
|
ignoredUpdateVersion: '',
|
||||||
|
updateChannel: 'auto',
|
||||||
notificationEnabled: true,
|
notificationEnabled: true,
|
||||||
notificationPosition: 'top-right',
|
notificationPosition: 'top-right',
|
||||||
notificationFilterMode: 'all',
|
notificationFilterMode: 'all',
|
||||||
notificationFilterList: []
|
notificationFilterList: [],
|
||||||
}
|
httpApiToken: '',
|
||||||
})
|
httpApiEnabled: false,
|
||||||
|
httpApiPort: 5031,
|
||||||
|
httpApiHost: '127.0.0.1',
|
||||||
|
messagePushEnabled: false,
|
||||||
|
windowCloseBehavior: 'ask',
|
||||||
|
quoteLayout: 'quote-top',
|
||||||
|
wordCloudExcludeWords: [],
|
||||||
|
exportWriteLayout: 'A',
|
||||||
|
aiInsightEnabled: false,
|
||||||
|
aiInsightApiBaseUrl: '',
|
||||||
|
aiInsightApiKey: '',
|
||||||
|
aiInsightApiModel: 'gpt-4o-mini',
|
||||||
|
aiInsightSilenceDays: 3,
|
||||||
|
aiInsightAllowContext: false,
|
||||||
|
aiInsightWhitelistEnabled: false,
|
||||||
|
aiInsightWhitelist: [],
|
||||||
|
aiInsightCooldownMinutes: 120,
|
||||||
|
aiInsightScanIntervalHours: 4,
|
||||||
|
aiInsightContextCount: 40,
|
||||||
|
aiInsightSystemPrompt: '',
|
||||||
|
aiInsightTelegramEnabled: false,
|
||||||
|
aiInsightTelegramToken: '',
|
||||||
|
aiInsightTelegramChatIds: ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const storeOptions: any = {
|
||||||
|
name: 'WeFlow-config',
|
||||||
|
defaults,
|
||||||
|
projectName: String(process.env.WEFLOW_PROJECT_NAME || 'WeFlow').trim() || 'WeFlow'
|
||||||
|
}
|
||||||
|
const runningInWorker = process.env.WEFLOW_WORKER === '1'
|
||||||
|
if (runningInWorker) {
|
||||||
|
const cwd = String(process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || '').trim()
|
||||||
|
if (cwd) {
|
||||||
|
storeOptions.cwd = cwd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.store = new Store<ConfigSchema>(storeOptions)
|
||||||
|
} catch (error) {
|
||||||
|
const message = String((error as Error)?.message || error || '')
|
||||||
|
if (message.includes('projectName')) {
|
||||||
|
const fallbackOptions = {
|
||||||
|
...storeOptions,
|
||||||
|
projectName: 'WeFlow',
|
||||||
|
cwd: storeOptions.cwd || process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || process.cwd()
|
||||||
|
}
|
||||||
|
this.store = new Store<ConfigSchema>(fallbackOptions)
|
||||||
|
} else {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.migrateAuthFields()
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 状态查询 ===
|
||||||
|
|
||||||
|
isLockMode(): boolean {
|
||||||
|
const raw: any = this.store.get('decryptKey')
|
||||||
|
return typeof raw === 'string' && raw.startsWith(LOCK_PREFIX)
|
||||||
|
}
|
||||||
|
|
||||||
|
isUnlocked(): boolean {
|
||||||
|
return !this.isLockMode() || this.unlockedKeys.size > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// === get / set ===
|
||||||
|
|
||||||
get<K extends keyof ConfigSchema>(key: K): ConfigSchema[K] {
|
get<K extends keyof ConfigSchema>(key: K): ConfigSchema[K] {
|
||||||
return this.store.get(key)
|
const raw = this.store.get(key)
|
||||||
|
|
||||||
|
if (ENCRYPTED_BOOL_KEYS.has(key)) {
|
||||||
|
const str = typeof raw === 'string' ? raw : ''
|
||||||
|
if (!str || !str.startsWith(SAFE_PREFIX)) return raw
|
||||||
|
return (this.safeDecrypt(str) === 'true') as ConfigSchema[K]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ENCRYPTED_NUMBER_KEYS.has(key)) {
|
||||||
|
const str = typeof raw === 'string' ? raw : ''
|
||||||
|
if (!str) return raw
|
||||||
|
if (str.startsWith(LOCK_PREFIX)) {
|
||||||
|
const cached = this.unlockedKeys.get(key as string)
|
||||||
|
return (cached !== undefined ? cached : 0) as ConfigSchema[K]
|
||||||
|
}
|
||||||
|
if (!str.startsWith(SAFE_PREFIX)) return raw
|
||||||
|
const num = Number(this.safeDecrypt(str))
|
||||||
|
return (Number.isFinite(num) ? num : 0) as ConfigSchema[K]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ENCRYPTED_STRING_KEYS.has(key) && typeof raw === 'string') {
|
||||||
|
if (key === 'authPassword') return this.safeDecrypt(raw) as ConfigSchema[K]
|
||||||
|
if (raw.startsWith(LOCK_PREFIX)) {
|
||||||
|
const cached = this.unlockedKeys.get(key as string)
|
||||||
|
return (cached !== undefined ? cached : '') as ConfigSchema[K]
|
||||||
|
}
|
||||||
|
return this.safeDecrypt(raw) as ConfigSchema[K]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'wxidConfigs' && raw && typeof raw === 'object') {
|
||||||
|
return this.decryptWxidConfigs(raw as any) as ConfigSchema[K]
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw
|
||||||
}
|
}
|
||||||
|
|
||||||
set<K extends keyof ConfigSchema>(key: K, value: ConfigSchema[K]): void {
|
set<K extends keyof ConfigSchema>(key: K, value: ConfigSchema[K]): void {
|
||||||
this.store.set(key, value)
|
let toStore = value
|
||||||
|
const inLockMode = this.isLockMode() && this.unlockPassword
|
||||||
|
|
||||||
|
if (ENCRYPTED_BOOL_KEYS.has(key)) {
|
||||||
|
toStore = this.safeEncrypt(String(value)) as ConfigSchema[K]
|
||||||
|
} else if (ENCRYPTED_NUMBER_KEYS.has(key)) {
|
||||||
|
if (inLockMode && LOCKABLE_NUMBER_KEYS.has(key)) {
|
||||||
|
toStore = this.lockEncrypt(String(value), this.unlockPassword!) as ConfigSchema[K]
|
||||||
|
this.unlockedKeys.set(key as string, value)
|
||||||
|
} else {
|
||||||
|
toStore = this.safeEncrypt(String(value)) as ConfigSchema[K]
|
||||||
|
}
|
||||||
|
} else if (ENCRYPTED_STRING_KEYS.has(key) && typeof value === 'string') {
|
||||||
|
if (key === 'authPassword') {
|
||||||
|
toStore = this.safeEncrypt(value) as ConfigSchema[K]
|
||||||
|
} else if (inLockMode && LOCKABLE_STRING_KEYS.has(key)) {
|
||||||
|
toStore = this.lockEncrypt(value, this.unlockPassword!) as ConfigSchema[K]
|
||||||
|
this.unlockedKeys.set(key as string, value)
|
||||||
|
} else {
|
||||||
|
toStore = this.safeEncrypt(value) as ConfigSchema[K]
|
||||||
|
}
|
||||||
|
} else if (key === 'wxidConfigs' && value && typeof value === 'object') {
|
||||||
|
if (inLockMode) {
|
||||||
|
toStore = this.lockEncryptWxidConfigs(value as any) as ConfigSchema[K]
|
||||||
|
} else {
|
||||||
|
toStore = this.encryptWxidConfigs(value as any) as ConfigSchema[K]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getAll(): ConfigSchema {
|
this.store.set(key, toStore)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 加密/解密工具 ===
|
||||||
|
|
||||||
|
private safeEncrypt(plaintext: string): string {
|
||||||
|
if (!plaintext) return ''
|
||||||
|
if (plaintext.startsWith(SAFE_PREFIX)) return plaintext
|
||||||
|
if (!isSafeStorageAvailable()) return plaintext
|
||||||
|
const encrypted = safeStorage.encryptString(plaintext)
|
||||||
|
return SAFE_PREFIX + encrypted.toString('base64')
|
||||||
|
}
|
||||||
|
|
||||||
|
private safeDecrypt(stored: string): string {
|
||||||
|
if (!stored) return ''
|
||||||
|
if (!stored.startsWith(SAFE_PREFIX)) return stored
|
||||||
|
if (!isSafeStorageAvailable()) return ''
|
||||||
|
try {
|
||||||
|
const buf = Buffer.from(stored.slice(SAFE_PREFIX.length), 'base64')
|
||||||
|
return safeStorage.decryptString(buf)
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private lockEncrypt(plaintext: string, password: string): string {
|
||||||
|
if (!plaintext) return ''
|
||||||
|
const salt = crypto.randomBytes(16)
|
||||||
|
const iv = crypto.randomBytes(12)
|
||||||
|
const derivedKey = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256')
|
||||||
|
const cipher = crypto.createCipheriv('aes-256-gcm', derivedKey, iv)
|
||||||
|
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
|
||||||
|
const authTag = cipher.getAuthTag()
|
||||||
|
const combined = Buffer.concat([salt, iv, authTag, encrypted])
|
||||||
|
return LOCK_PREFIX + combined.toString('base64')
|
||||||
|
}
|
||||||
|
|
||||||
|
private lockDecrypt(stored: string, password: string): string | null {
|
||||||
|
if (!stored || !stored.startsWith(LOCK_PREFIX)) return null
|
||||||
|
try {
|
||||||
|
const combined = Buffer.from(stored.slice(LOCK_PREFIX.length), 'base64')
|
||||||
|
const salt = combined.subarray(0, 16)
|
||||||
|
const iv = combined.subarray(16, 28)
|
||||||
|
const authTag = combined.subarray(28, 44)
|
||||||
|
const ciphertext = combined.subarray(44)
|
||||||
|
const derivedKey = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256')
|
||||||
|
const decipher = crypto.createDecipheriv('aes-256-gcm', derivedKey, iv)
|
||||||
|
decipher.setAuthTag(authTag)
|
||||||
|
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
||||||
|
return decrypted.toString('utf8')
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通过尝试解密 lock: 字段来验证密码是否正确(当 authPassword 被删除时使用)
|
||||||
|
private verifyPasswordByDecrypt(password: string): boolean {
|
||||||
|
// 依次尝试解密任意一个 lock: 字段,GCM authTag 会验证密码正确性
|
||||||
|
const lockFields = ['decryptKey', 'imageAesKey', 'imageXorKey'] as const
|
||||||
|
for (const key of lockFields) {
|
||||||
|
const raw: any = this.store.get(key as any)
|
||||||
|
if (typeof raw === 'string' && raw.startsWith(LOCK_PREFIX)) {
|
||||||
|
const result = this.lockDecrypt(raw, password)
|
||||||
|
// lockDecrypt 返回 null 表示解密失败(密码错误),非 null 表示成功
|
||||||
|
return result !== null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// === wxidConfigs 加密/解密 ===
|
||||||
|
|
||||||
|
private encryptWxidConfigs(configs: ConfigSchema['wxidConfigs']): ConfigSchema['wxidConfigs'] {
|
||||||
|
const result: ConfigSchema['wxidConfigs'] = {}
|
||||||
|
for (const [wxid, cfg] of Object.entries(configs)) {
|
||||||
|
result[wxid] = { ...cfg }
|
||||||
|
if (cfg.decryptKey) result[wxid].decryptKey = this.safeEncrypt(cfg.decryptKey)
|
||||||
|
if (cfg.imageAesKey) result[wxid].imageAesKey = this.safeEncrypt(cfg.imageAesKey)
|
||||||
|
if (cfg.imageXorKey !== undefined) {
|
||||||
|
(result[wxid] as any).imageXorKey = this.safeEncrypt(String(cfg.imageXorKey))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private decryptLockedWxidConfigs(password: string): void {
|
||||||
|
const wxidConfigs = this.store.get('wxidConfigs')
|
||||||
|
if (!wxidConfigs || typeof wxidConfigs !== 'object') return
|
||||||
|
for (const [wxid, cfg] of Object.entries(wxidConfigs) as [string, any][]) {
|
||||||
|
if (cfg.decryptKey && typeof cfg.decryptKey === 'string' && cfg.decryptKey.startsWith(LOCK_PREFIX)) {
|
||||||
|
const d = this.lockDecrypt(cfg.decryptKey, password)
|
||||||
|
if (d !== null) this.unlockedKeys.set(`wxid:${wxid}:decryptKey`, d)
|
||||||
|
}
|
||||||
|
if (cfg.imageAesKey && typeof cfg.imageAesKey === 'string' && cfg.imageAesKey.startsWith(LOCK_PREFIX)) {
|
||||||
|
const d = this.lockDecrypt(cfg.imageAesKey, password)
|
||||||
|
if (d !== null) this.unlockedKeys.set(`wxid:${wxid}:imageAesKey`, d)
|
||||||
|
}
|
||||||
|
if (cfg.imageXorKey && typeof cfg.imageXorKey === 'string' && cfg.imageXorKey.startsWith(LOCK_PREFIX)) {
|
||||||
|
const d = this.lockDecrypt(cfg.imageXorKey, password)
|
||||||
|
if (d !== null) this.unlockedKeys.set(`wxid:${wxid}:imageXorKey`, Number(d))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private decryptWxidConfigs(configs: ConfigSchema['wxidConfigs']): ConfigSchema['wxidConfigs'] {
|
||||||
|
const result: ConfigSchema['wxidConfigs'] = {}
|
||||||
|
for (const [wxid, cfg] of Object.entries(configs) as [string, any][]) {
|
||||||
|
result[wxid] = { ...cfg, updatedAt: cfg.updatedAt }
|
||||||
|
// decryptKey
|
||||||
|
if (typeof cfg.decryptKey === 'string') {
|
||||||
|
if (cfg.decryptKey.startsWith(LOCK_PREFIX)) {
|
||||||
|
result[wxid].decryptKey = this.unlockedKeys.get(`wxid:${wxid}:decryptKey`) ?? ''
|
||||||
|
} else {
|
||||||
|
result[wxid].decryptKey = this.safeDecrypt(cfg.decryptKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// imageAesKey
|
||||||
|
if (typeof cfg.imageAesKey === 'string') {
|
||||||
|
if (cfg.imageAesKey.startsWith(LOCK_PREFIX)) {
|
||||||
|
result[wxid].imageAesKey = this.unlockedKeys.get(`wxid:${wxid}:imageAesKey`) ?? ''
|
||||||
|
} else {
|
||||||
|
result[wxid].imageAesKey = this.safeDecrypt(cfg.imageAesKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// imageXorKey
|
||||||
|
if (typeof cfg.imageXorKey === 'string') {
|
||||||
|
if (cfg.imageXorKey.startsWith(LOCK_PREFIX)) {
|
||||||
|
result[wxid].imageXorKey = this.unlockedKeys.get(`wxid:${wxid}:imageXorKey`) ?? 0
|
||||||
|
} else if (cfg.imageXorKey.startsWith(SAFE_PREFIX)) {
|
||||||
|
const num = Number(this.safeDecrypt(cfg.imageXorKey))
|
||||||
|
result[wxid].imageXorKey = Number.isFinite(num) ? num : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
private lockEncryptWxidConfigs(configs: ConfigSchema['wxidConfigs']): ConfigSchema['wxidConfigs'] {
|
||||||
|
const result: ConfigSchema['wxidConfigs'] = {}
|
||||||
|
for (const [wxid, cfg] of Object.entries(configs)) {
|
||||||
|
result[wxid] = { ...cfg }
|
||||||
|
if (cfg.decryptKey) result[wxid].decryptKey = this.lockEncrypt(cfg.decryptKey, this.unlockPassword!) as any
|
||||||
|
if (cfg.imageAesKey) result[wxid].imageAesKey = this.lockEncrypt(cfg.imageAesKey, this.unlockPassword!) as any
|
||||||
|
if (cfg.imageXorKey !== undefined) {
|
||||||
|
(result[wxid] as any).imageXorKey = this.lockEncrypt(String(cfg.imageXorKey), this.unlockPassword!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 业务方法 ===
|
||||||
|
|
||||||
|
enableLock(password: string): { success: boolean; error?: string } {
|
||||||
|
try {
|
||||||
|
// 先读取当前所有明文密钥
|
||||||
|
const decryptKey = this.get('decryptKey')
|
||||||
|
const imageAesKey = this.get('imageAesKey')
|
||||||
|
const imageXorKey = this.get('imageXorKey')
|
||||||
|
const wxidConfigs = this.get('wxidConfigs')
|
||||||
|
|
||||||
|
// 存储密码 hash(safeStorage 加密)
|
||||||
|
const passwordHash = crypto.createHash('sha256').update(password).digest('hex')
|
||||||
|
this.store.set('authPassword', this.safeEncrypt(passwordHash) as any)
|
||||||
|
this.store.set('authEnabled', this.safeEncrypt('true') as any)
|
||||||
|
|
||||||
|
// 设置运行时状态
|
||||||
|
this.unlockPassword = password
|
||||||
|
this.unlockedKeys.set('decryptKey', decryptKey)
|
||||||
|
this.unlockedKeys.set('imageAesKey', imageAesKey)
|
||||||
|
this.unlockedKeys.set('imageXorKey', imageXorKey)
|
||||||
|
|
||||||
|
// 用密码派生密钥重新加密所有敏感字段
|
||||||
|
if (decryptKey) this.store.set('decryptKey', this.lockEncrypt(String(decryptKey), password) as any)
|
||||||
|
if (imageAesKey) this.store.set('imageAesKey', this.lockEncrypt(String(imageAesKey), password) as any)
|
||||||
|
if (imageXorKey !== undefined) this.store.set('imageXorKey', this.lockEncrypt(String(imageXorKey), password) as any)
|
||||||
|
|
||||||
|
// 处理 wxidConfigs 中的嵌套密钥
|
||||||
|
if (wxidConfigs && Object.keys(wxidConfigs).length > 0) {
|
||||||
|
const lockedConfigs = this.lockEncryptWxidConfigs(wxidConfigs)
|
||||||
|
this.store.set('wxidConfigs', lockedConfigs)
|
||||||
|
for (const [wxid, cfg] of Object.entries(wxidConfigs)) {
|
||||||
|
if (cfg.decryptKey) this.unlockedKeys.set(`wxid:${wxid}:decryptKey`, cfg.decryptKey)
|
||||||
|
if (cfg.imageAesKey) this.unlockedKeys.set(`wxid:${wxid}:imageAesKey`, cfg.imageAesKey)
|
||||||
|
if (cfg.imageXorKey !== undefined) this.unlockedKeys.set(`wxid:${wxid}:imageXorKey`, cfg.imageXorKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
} catch (e: any) {
|
||||||
|
return { success: false, error: e.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unlock(password: string): { success: boolean; error?: string } {
|
||||||
|
try {
|
||||||
|
// 验证密码
|
||||||
|
const storedHash = this.safeDecrypt(this.store.get('authPassword') as any)
|
||||||
|
const inputHash = crypto.createHash('sha256').update(password).digest('hex')
|
||||||
|
|
||||||
|
if (storedHash && storedHash !== inputHash) {
|
||||||
|
// authPassword 存在但密码不匹配
|
||||||
|
return { success: false, error: '密码错误' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!storedHash) {
|
||||||
|
// authPassword 被删除/损坏,尝试用密码直接解密 lock: 字段来验证
|
||||||
|
const verified = this.verifyPasswordByDecrypt(password)
|
||||||
|
if (!verified) {
|
||||||
|
return { success: false, error: '密码错误' }
|
||||||
|
}
|
||||||
|
// 密码正确,自愈 authPassword
|
||||||
|
const newHash = crypto.createHash('sha256').update(password).digest('hex')
|
||||||
|
this.store.set('authPassword', this.safeEncrypt(newHash) as any)
|
||||||
|
this.store.set('authEnabled', this.safeEncrypt('true') as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解密所有 lock: 字段到内存缓存
|
||||||
|
const rawDecryptKey: any = this.store.get('decryptKey')
|
||||||
|
if (typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX)) {
|
||||||
|
const d = this.lockDecrypt(rawDecryptKey, password)
|
||||||
|
if (d !== null) this.unlockedKeys.set('decryptKey', d)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawImageAesKey: any = this.store.get('imageAesKey')
|
||||||
|
if (typeof rawImageAesKey === 'string' && rawImageAesKey.startsWith(LOCK_PREFIX)) {
|
||||||
|
const d = this.lockDecrypt(rawImageAesKey, password)
|
||||||
|
if (d !== null) this.unlockedKeys.set('imageAesKey', d)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawImageXorKey: any = this.store.get('imageXorKey')
|
||||||
|
if (typeof rawImageXorKey === 'string' && rawImageXorKey.startsWith(LOCK_PREFIX)) {
|
||||||
|
const d = this.lockDecrypt(rawImageXorKey, password)
|
||||||
|
if (d !== null) this.unlockedKeys.set('imageXorKey', Number(d))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解密 wxidConfigs 嵌套密钥
|
||||||
|
this.decryptLockedWxidConfigs(password)
|
||||||
|
|
||||||
|
// 保留密码供 set() 使用
|
||||||
|
this.unlockPassword = password
|
||||||
|
return { success: true }
|
||||||
|
} catch (e: any) {
|
||||||
|
return { success: false, error: e.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disableLock(password: string): { success: boolean; error?: string } {
|
||||||
|
try {
|
||||||
|
// 验证密码
|
||||||
|
const storedHash = this.safeDecrypt(this.store.get('authPassword') as any)
|
||||||
|
const inputHash = crypto.createHash('sha256').update(password).digest('hex')
|
||||||
|
if (storedHash !== inputHash) {
|
||||||
|
return { success: false, error: '密码错误' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先解密所有 lock: 字段
|
||||||
|
if (this.unlockedKeys.size === 0) {
|
||||||
|
this.unlock(password)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将所有密钥转回 safe: 格式
|
||||||
|
const decryptKey = this.unlockedKeys.get('decryptKey')
|
||||||
|
const imageAesKey = this.unlockedKeys.get('imageAesKey')
|
||||||
|
const imageXorKey = this.unlockedKeys.get('imageXorKey')
|
||||||
|
|
||||||
|
if (decryptKey) this.store.set('decryptKey', this.safeEncrypt(String(decryptKey)) as any)
|
||||||
|
if (imageAesKey) this.store.set('imageAesKey', this.safeEncrypt(String(imageAesKey)) as any)
|
||||||
|
if (imageXorKey !== undefined) this.store.set('imageXorKey', this.safeEncrypt(String(imageXorKey)) as any)
|
||||||
|
|
||||||
|
// 转换 wxidConfigs
|
||||||
|
const wxidConfigs = this.get('wxidConfigs')
|
||||||
|
if (wxidConfigs && Object.keys(wxidConfigs).length > 0) {
|
||||||
|
const safeConfigs = this.encryptWxidConfigs(wxidConfigs)
|
||||||
|
this.store.set('wxidConfigs', safeConfigs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除 auth 字段
|
||||||
|
this.store.set('authEnabled', false as any)
|
||||||
|
this.store.set('authPassword', '' as any)
|
||||||
|
this.store.set('authUseHello', false as any)
|
||||||
|
this.store.set('authHelloSecret', '' as any)
|
||||||
|
|
||||||
|
// 清除运行时状态
|
||||||
|
this.unlockedKeys.clear()
|
||||||
|
this.unlockPassword = null
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
} catch (e: any) {
|
||||||
|
return { success: false, error: e.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
changePassword(oldPassword: string, newPassword: string): { success: boolean; error?: string } {
|
||||||
|
try {
|
||||||
|
// 验证旧密码
|
||||||
|
const storedHash = this.safeDecrypt(this.store.get('authPassword') as any)
|
||||||
|
const oldHash = crypto.createHash('sha256').update(oldPassword).digest('hex')
|
||||||
|
if (storedHash !== oldHash) {
|
||||||
|
return { success: false, error: '旧密码错误' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保已解锁
|
||||||
|
if (this.unlockedKeys.size === 0) {
|
||||||
|
this.unlock(oldPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用新密码重新加密所有密钥
|
||||||
|
const decryptKey = this.unlockedKeys.get('decryptKey')
|
||||||
|
const imageAesKey = this.unlockedKeys.get('imageAesKey')
|
||||||
|
const imageXorKey = this.unlockedKeys.get('imageXorKey')
|
||||||
|
|
||||||
|
if (decryptKey) this.store.set('decryptKey', this.lockEncrypt(String(decryptKey), newPassword) as any)
|
||||||
|
if (imageAesKey) this.store.set('imageAesKey', this.lockEncrypt(String(imageAesKey), newPassword) as any)
|
||||||
|
if (imageXorKey !== undefined) this.store.set('imageXorKey', this.lockEncrypt(String(imageXorKey), newPassword) as any)
|
||||||
|
|
||||||
|
// 重新加密 wxidConfigs
|
||||||
|
const wxidConfigs = this.get('wxidConfigs')
|
||||||
|
if (wxidConfigs && Object.keys(wxidConfigs).length > 0) {
|
||||||
|
this.unlockPassword = newPassword
|
||||||
|
const lockedConfigs = this.lockEncryptWxidConfigs(wxidConfigs)
|
||||||
|
this.store.set('wxidConfigs', lockedConfigs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新密码 hash
|
||||||
|
const newHash = crypto.createHash('sha256').update(newPassword).digest('hex')
|
||||||
|
this.store.set('authPassword', this.safeEncrypt(newHash) as any)
|
||||||
|
|
||||||
|
// 更新 Hello secret(如果启用了 Hello)
|
||||||
|
const useHello = this.get('authUseHello')
|
||||||
|
if (useHello) {
|
||||||
|
this.store.set('authHelloSecret', this.safeEncrypt(newPassword) as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.unlockPassword = newPassword
|
||||||
|
return { success: true }
|
||||||
|
} catch (e: any) {
|
||||||
|
return { success: false, error: e.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Hello 相关 ===
|
||||||
|
|
||||||
|
setHelloSecret(password: string): void {
|
||||||
|
this.store.set('authHelloSecret', this.safeEncrypt(password) as any)
|
||||||
|
this.store.set('authUseHello', this.safeEncrypt('true') as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
getHelloSecret(): string {
|
||||||
|
const raw: any = this.store.get('authHelloSecret')
|
||||||
|
if (!raw || typeof raw !== 'string') return ''
|
||||||
|
return this.safeDecrypt(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
clearHelloSecret(): void {
|
||||||
|
this.store.set('authHelloSecret', '' as any)
|
||||||
|
this.store.set('authUseHello', this.safeEncrypt('false') as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 迁移 ===
|
||||||
|
|
||||||
|
private migrateAuthFields(): void {
|
||||||
|
// 将旧版明文 auth 字段迁移为 safeStorage 加密格式
|
||||||
|
// 如果已经是 safe: 或 lock: 前缀则跳过
|
||||||
|
const rawEnabled: any = this.store.get('authEnabled')
|
||||||
|
if (typeof rawEnabled === 'boolean') {
|
||||||
|
this.store.set('authEnabled', this.safeEncrypt(String(rawEnabled)) as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawUseHello: any = this.store.get('authUseHello')
|
||||||
|
if (typeof rawUseHello === 'boolean') {
|
||||||
|
this.store.set('authUseHello', this.safeEncrypt(String(rawUseHello)) as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawPassword: any = this.store.get('authPassword')
|
||||||
|
if (typeof rawPassword === 'string' && rawPassword && !rawPassword.startsWith(SAFE_PREFIX)) {
|
||||||
|
this.store.set('authPassword', this.safeEncrypt(rawPassword) as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 迁移敏感密钥字段(明文 → safe:)
|
||||||
|
for (const key of LOCKABLE_STRING_KEYS) {
|
||||||
|
const raw: any = this.store.get(key as any)
|
||||||
|
if (typeof raw === 'string' && raw && !raw.startsWith(SAFE_PREFIX) && !raw.startsWith(LOCK_PREFIX)) {
|
||||||
|
this.store.set(key as any, this.safeEncrypt(raw) as any)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageXorKey: 数字 → safe:
|
||||||
|
const rawXor: any = this.store.get('imageXorKey')
|
||||||
|
if (typeof rawXor === 'number' && rawXor !== 0) {
|
||||||
|
this.store.set('imageXorKey', this.safeEncrypt(String(rawXor)) as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wxidConfigs 中的嵌套密钥
|
||||||
|
const wxidConfigs: any = this.store.get('wxidConfigs')
|
||||||
|
if (wxidConfigs && typeof wxidConfigs === 'object') {
|
||||||
|
let changed = false
|
||||||
|
for (const [_wxid, cfg] of Object.entries(wxidConfigs) as [string, any][]) {
|
||||||
|
if (cfg.decryptKey && typeof cfg.decryptKey === 'string' && !cfg.decryptKey.startsWith(SAFE_PREFIX) && !cfg.decryptKey.startsWith(LOCK_PREFIX)) {
|
||||||
|
cfg.decryptKey = this.safeEncrypt(cfg.decryptKey)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (cfg.imageAesKey && typeof cfg.imageAesKey === 'string' && !cfg.imageAesKey.startsWith(SAFE_PREFIX) && !cfg.imageAesKey.startsWith(LOCK_PREFIX)) {
|
||||||
|
cfg.imageAesKey = this.safeEncrypt(cfg.imageAesKey)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (typeof cfg.imageXorKey === 'number' && cfg.imageXorKey !== 0) {
|
||||||
|
cfg.imageXorKey = this.safeEncrypt(String(cfg.imageXorKey))
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
this.store.set('wxidConfigs', wxidConfigs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 验证 ===
|
||||||
|
|
||||||
|
verifyAuthEnabled(): boolean {
|
||||||
|
// 先检查 authEnabled 字段
|
||||||
|
const rawEnabled: any = this.store.get('authEnabled')
|
||||||
|
if (typeof rawEnabled === 'string' && rawEnabled.startsWith(SAFE_PREFIX)) {
|
||||||
|
if (this.safeDecrypt(rawEnabled) === 'true') return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 即使 authEnabled 被删除/篡改,如果密钥是 lock: 格式,说明曾开启过应用锁
|
||||||
|
const rawDecryptKey: any = this.store.get('decryptKey')
|
||||||
|
return typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX);
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 工具方法 ===
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前 wxid 对应的图片密钥,优先从 wxidConfigs 中取,找不到则回退到全局<E585A8><E5B180>置
|
||||||
|
*/
|
||||||
|
getImageKeysForCurrentWxid(): { xorKey: unknown; aesKey: string } {
|
||||||
|
const wxid = this.get('myWxid')
|
||||||
|
if (wxid) {
|
||||||
|
const wxidConfigs = this.get('wxidConfigs')
|
||||||
|
const cfg = wxidConfigs?.[wxid]
|
||||||
|
if (cfg && (cfg.imageXorKey !== undefined || cfg.imageAesKey)) {
|
||||||
|
return {
|
||||||
|
xorKey: cfg.imageXorKey ?? this.get('imageXorKey'),
|
||||||
|
aesKey: cfg.imageAesKey ?? this.get('imageAesKey')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
xorKey: this.get('imageXorKey'),
|
||||||
|
aesKey: this.get('imageAesKey')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getUserDataPath(): string {
|
||||||
|
const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
|
||||||
|
if (workerUserDataPath) {
|
||||||
|
return workerUserDataPath
|
||||||
|
}
|
||||||
|
return app?.getPath?.('userData') || process.cwd()
|
||||||
|
}
|
||||||
|
|
||||||
|
getCacheBasePath(): string {
|
||||||
|
return join(this.getUserDataPath(), 'cache')
|
||||||
|
}
|
||||||
|
|
||||||
|
getAll(): Partial<ConfigSchema> {
|
||||||
return this.store.store
|
return this.store.store
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this.store.clear()
|
this.store.clear()
|
||||||
|
this.unlockedKeys.clear()
|
||||||
|
this.unlockPassword = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { join, dirname } from 'path'
|
import { join, dirname } from 'path'
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
|
import { ConfigService } from './config'
|
||||||
|
|
||||||
export interface ContactCacheEntry {
|
export interface ContactCacheEntry {
|
||||||
displayName?: string
|
displayName?: string
|
||||||
@@ -15,7 +16,7 @@ export class ContactCacheService {
|
|||||||
constructor(cacheBasePath?: string) {
|
constructor(cacheBasePath?: string) {
|
||||||
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||||
? cacheBasePath
|
? cacheBasePath
|
||||||
: join(app.getPath('documents'), 'WeFlow')
|
: ConfigService.getInstance().getCacheBasePath()
|
||||||
this.cacheFilePath = join(basePath, 'contacts.json')
|
this.cacheFilePath = join(basePath, 'contacts.json')
|
||||||
this.ensureCacheDir()
|
this.ensureCacheDir()
|
||||||
this.loadCache()
|
this.loadCache()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface ContactExportOptions {
|
|||||||
groups: boolean
|
groups: boolean
|
||||||
officials: boolean
|
officials: boolean
|
||||||
}
|
}
|
||||||
|
selectedUsernames?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,6 +41,11 @@ class ContactExportService {
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (Array.isArray(options.selectedUsernames) && options.selectedUsernames.length > 0) {
|
||||||
|
const selectedSet = new Set(options.selectedUsernames)
|
||||||
|
contacts = contacts.filter(c => selectedSet.has(c.username))
|
||||||
|
}
|
||||||
|
|
||||||
if (contacts.length === 0) {
|
if (contacts.length === 0) {
|
||||||
return { success: false, error: '没有符合条件的联系人' }
|
return { success: false, error: '没有符合条件的联系人' }
|
||||||
}
|
}
|
||||||
@@ -87,6 +93,9 @@ class ContactExportService {
|
|||||||
displayName: c.displayName,
|
displayName: c.displayName,
|
||||||
remark: c.remark,
|
remark: c.remark,
|
||||||
nickname: c.nickname,
|
nickname: c.nickname,
|
||||||
|
alias: c.alias,
|
||||||
|
labels: Array.isArray(c.labels) ? c.labels : [],
|
||||||
|
detailDescription: c.detailDescription,
|
||||||
type: c.type
|
type: c.type
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -97,12 +106,15 @@ class ContactExportService {
|
|||||||
* 导出为CSV格式
|
* 导出为CSV格式
|
||||||
*/
|
*/
|
||||||
private async exportToCSV(contacts: any[], outputPath: string): Promise<void> {
|
private async exportToCSV(contacts: any[], outputPath: string): Promise<void> {
|
||||||
const headers = ['用户名', '显示名称', '备注', '昵称', '类型']
|
const headers = ['用户名', '显示名称', '备注', '昵称', '微信号', '标签', '详细描述', '类型']
|
||||||
const rows = contacts.map(c => [
|
const rows = contacts.map(c => [
|
||||||
c.username || '',
|
c.username || '',
|
||||||
c.displayName || '',
|
c.displayName || '',
|
||||||
c.remark || '',
|
c.remark || '',
|
||||||
c.nickname || '',
|
c.nickname || '',
|
||||||
|
c.alias || '',
|
||||||
|
Array.isArray(c.labels) ? c.labels.join(' | ') : '',
|
||||||
|
c.detailDescription || '',
|
||||||
this.getTypeLabel(c.type)
|
this.getTypeLabel(c.type)
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -131,9 +143,13 @@ class ContactExportService {
|
|||||||
lines.push(`NICKNAME:${c.nickname}`)
|
lines.push(`NICKNAME:${c.nickname}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 备注
|
const noteParts = [
|
||||||
if (c.remark) {
|
c.remark ? String(c.remark) : '',
|
||||||
lines.push(`NOTE:${c.remark}`)
|
Array.isArray(c.labels) && c.labels.length > 0 ? `标签: ${c.labels.join(', ')}` : '',
|
||||||
|
c.detailDescription ? `详细描述: ${c.detailDescription}` : ''
|
||||||
|
].filter(Boolean)
|
||||||
|
if (noteParts.length > 0) {
|
||||||
|
lines.push(`NOTE:${noteParts.join('\\n')}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 微信ID
|
// 微信ID
|
||||||
|
|||||||
9440
electron/services/contactRegionLookupData.ts
Normal file
9440
electron/services/contactRegionLookupData.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,90 @@
|
|||||||
import { join, basename } from 'path'
|
import { join, basename } from 'path'
|
||||||
import { existsSync, readdirSync, statSync } from 'fs'
|
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||||
import { homedir } from 'os'
|
import { homedir } from 'os'
|
||||||
|
import { createDecipheriv } from 'crypto'
|
||||||
|
|
||||||
export interface WxidInfo {
|
export interface WxidInfo {
|
||||||
wxid: string
|
wxid: string
|
||||||
modifiedTime: number
|
modifiedTime: number
|
||||||
|
nickname?: string
|
||||||
|
avatarUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DbPathService {
|
export class DbPathService {
|
||||||
|
private readVarint(buf: Buffer, offset: number): { value: number, length: number } {
|
||||||
|
let value = 0;
|
||||||
|
let length = 0;
|
||||||
|
let shift = 0;
|
||||||
|
while (offset < buf.length && shift < 32) {
|
||||||
|
const b = buf[offset++];
|
||||||
|
value |= (b & 0x7f) << shift;
|
||||||
|
length++;
|
||||||
|
if ((b & 0x80) === 0) break;
|
||||||
|
shift += 7;
|
||||||
|
}
|
||||||
|
return { value, length };
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractMmkvString(buf: Buffer, keyName: string): string {
|
||||||
|
const keyBuf = Buffer.from(keyName, 'utf8');
|
||||||
|
const idx = buf.indexOf(keyBuf);
|
||||||
|
if (idx === -1) return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
let offset = idx + keyBuf.length;
|
||||||
|
const v1 = this.readVarint(buf, offset);
|
||||||
|
offset += v1.length;
|
||||||
|
const v2 = this.readVarint(buf, offset);
|
||||||
|
offset += v2.length;
|
||||||
|
|
||||||
|
// 合理性检查
|
||||||
|
if (v2.value > 0 && v2.value <= 10000 && offset + v2.value <= buf.length) {
|
||||||
|
return buf.toString('utf8', offset, offset + v2.value);
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseGlobalConfig(rootPath: string): { wxid: string, nickname: string, avatarUrl: string } | null {
|
||||||
|
try {
|
||||||
|
const configPath = join(rootPath, 'all_users', 'config', 'global_config');
|
||||||
|
if (!existsSync(configPath)) return null;
|
||||||
|
|
||||||
|
const fullData = readFileSync(configPath);
|
||||||
|
if (fullData.length <= 4) return null;
|
||||||
|
const encryptedData = fullData.subarray(4);
|
||||||
|
|
||||||
|
const key = Buffer.alloc(16, 0);
|
||||||
|
Buffer.from('xwechat_crypt_key').copy(key); // 直接硬编码,iv更是不重要
|
||||||
|
const iv = Buffer.alloc(16, 0);
|
||||||
|
|
||||||
|
const decipher = createDecipheriv('aes-128-cfb', key, iv);
|
||||||
|
decipher.setAutoPadding(false);
|
||||||
|
const decrypted = Buffer.concat([decipher.update(encryptedData), decipher.final()]);
|
||||||
|
|
||||||
|
const wxid = this.extractMmkvString(decrypted, 'mmkv_key_user_name');
|
||||||
|
const nickname = this.extractMmkvString(decrypted, 'mmkv_key_nick_name');
|
||||||
|
let avatarUrl = this.extractMmkvString(decrypted, 'mmkv_key_head_img_url');
|
||||||
|
|
||||||
|
if (!avatarUrl && decrypted.includes('http')) {
|
||||||
|
const httpIdx = decrypted.indexOf('http');
|
||||||
|
const nullIdx = decrypted.indexOf(0x00, httpIdx);
|
||||||
|
if (nullIdx !== -1) {
|
||||||
|
avatarUrl = decrypted.toString('utf8', httpIdx, nullIdx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wxid || nickname) {
|
||||||
|
return { wxid, nickname, avatarUrl };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('解析 global_config 失败:', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 自动检测微信数据库根目录
|
* 自动检测微信数据库根目录
|
||||||
*/
|
*/
|
||||||
@@ -16,22 +93,39 @@ export class DbPathService {
|
|||||||
const possiblePaths: string[] = []
|
const possiblePaths: string[] = []
|
||||||
const home = homedir()
|
const home = homedir()
|
||||||
|
|
||||||
// 微信4.x 数据目录
|
if (process.platform === 'darwin') {
|
||||||
|
// macOS 微信 4.0.5+ 新路径(优先检测)
|
||||||
|
const appSupportBase = join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Library', 'Application Support', 'com.tencent.xinWeChat')
|
||||||
|
if (existsSync(appSupportBase)) {
|
||||||
|
try {
|
||||||
|
const entries = readdirSync(appSupportBase)
|
||||||
|
for (const entry of entries) {
|
||||||
|
// 匹配形如 2.0b4.0.9 的版本目录
|
||||||
|
if (/^\d+\.\d+b\d+\.\d+/.test(entry) || /^\d+\.\d+\.\d+/.test(entry)) {
|
||||||
|
possiblePaths.push(join(appSupportBase, entry))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
// macOS 旧路径兜底
|
||||||
|
possiblePaths.push(join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files'))
|
||||||
|
} else {
|
||||||
|
// Windows 微信4.x 数据目录
|
||||||
possiblePaths.push(join(home, 'Documents', 'xwechat_files'))
|
possiblePaths.push(join(home, 'Documents', 'xwechat_files'))
|
||||||
|
|
||||||
|
|
||||||
for (const path of possiblePaths) {
|
|
||||||
if (existsSync(path)) {
|
|
||||||
const rootName = path.split(/[/\\]/).pop()?.toLowerCase()
|
|
||||||
if (rootName !== 'xwechat_files' && rootName !== 'wechat files') {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否有有效的账号目录
|
for (const path of possiblePaths) {
|
||||||
|
if (!existsSync(path)) continue
|
||||||
|
|
||||||
|
// 检查是否有有效的账号目录,或本身就是账号目录
|
||||||
const accounts = this.findAccountDirs(path)
|
const accounts = this.findAccountDirs(path)
|
||||||
if (accounts.length > 0) {
|
if (accounts.length > 0) {
|
||||||
return { success: true, path }
|
return { success: true, path }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果该目录本身就是账号目录(直接包含 db_storage 等)
|
||||||
|
if (this.isAccountDir(path)) {
|
||||||
|
return { success: true, path }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,21 +224,16 @@ export class DbPathService {
|
|||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const entryPath = join(rootPath, entry)
|
const entryPath = join(rootPath, entry)
|
||||||
let stat: ReturnType<typeof statSync>
|
let stat: ReturnType<typeof statSync>
|
||||||
try {
|
try { stat = statSync(entryPath) } catch { continue }
|
||||||
stat = statSync(entryPath)
|
|
||||||
} catch {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!stat.isDirectory()) continue
|
if (!stat.isDirectory()) continue
|
||||||
const lower = entry.toLowerCase()
|
const lower = entry.toLowerCase()
|
||||||
if (lower === 'all_users') continue
|
if (lower === 'all_users') continue
|
||||||
if (!entry.includes('_')) continue
|
if (!entry.includes('_')) continue
|
||||||
|
|
||||||
wxids.push({ wxid: entry, modifiedTime: stat.mtimeMs })
|
wxids.push({ wxid: entry, modifiedTime: stat.mtimeMs })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (wxids.length === 0) {
|
if (wxids.length === 0) {
|
||||||
const rootName = basename(rootPath)
|
const rootName = basename(rootPath)
|
||||||
if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') {
|
if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') {
|
||||||
@@ -154,11 +243,24 @@ export class DbPathService {
|
|||||||
}
|
}
|
||||||
} catch { }
|
} catch { }
|
||||||
|
|
||||||
return wxids.sort((a, b) => {
|
const sorted = wxids.sort((a, b) => {
|
||||||
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
|
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
|
||||||
return a.wxid.localeCompare(b.wxid)
|
return a.wxid.localeCompare(b.wxid)
|
||||||
})
|
});
|
||||||
|
|
||||||
|
const globalInfo = this.parseGlobalConfig(rootPath);
|
||||||
|
if (globalInfo) {
|
||||||
|
for (const w of sorted) {
|
||||||
|
if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) {
|
||||||
|
w.nickname = globalInfo.nickname;
|
||||||
|
w.avatarUrl = globalInfo.avatarUrl;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 扫描 wxid 列表
|
* 扫描 wxid 列表
|
||||||
@@ -182,10 +284,21 @@ export class DbPathService {
|
|||||||
}
|
}
|
||||||
} catch { }
|
} catch { }
|
||||||
|
|
||||||
return wxids.sort((a, b) => {
|
const sorted = wxids.sort((a, b) => {
|
||||||
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
|
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
|
||||||
return a.wxid.localeCompare(b.wxid)
|
return a.wxid.localeCompare(b.wxid)
|
||||||
})
|
});
|
||||||
|
|
||||||
|
const globalInfo = this.parseGlobalConfig(rootPath);
|
||||||
|
if (globalInfo) {
|
||||||
|
for (const w of sorted) {
|
||||||
|
if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) {
|
||||||
|
w.nickname = globalInfo.nickname;
|
||||||
|
w.avatarUrl = globalInfo.avatarUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sorted;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -193,6 +306,23 @@ export class DbPathService {
|
|||||||
*/
|
*/
|
||||||
getDefaultPath(): string {
|
getDefaultPath(): string {
|
||||||
const home = homedir()
|
const home = homedir()
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
// 优先返回 4.0.5+ 新路径
|
||||||
|
const appSupportBase = join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Library', 'Application Support', 'com.tencent.xinWeChat')
|
||||||
|
if (existsSync(appSupportBase)) {
|
||||||
|
try {
|
||||||
|
const entries = readdirSync(appSupportBase)
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (/^\d+\.\d+b\d+\.\d+/.test(entry) || /^\d+\.\d+\.\d+/.test(entry)) {
|
||||||
|
const candidate = join(appSupportBase, entry)
|
||||||
|
if (existsSync(candidate)) return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
// 旧版本路径兜底
|
||||||
|
return join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files')
|
||||||
|
}
|
||||||
return join(home, 'Documents', 'xwechat_files')
|
return join(home, 'Documents', 'xwechat_files')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { parentPort } from 'worker_threads'
|
import { parentPort } from 'worker_threads'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
|
|
||||||
|
|
||||||
export interface DualReportMessage {
|
export interface DualReportMessage {
|
||||||
content: string
|
content: string
|
||||||
isSentByMe: boolean
|
isSentByMe: boolean
|
||||||
createTime: number
|
createTime: number
|
||||||
createTimeStr: string
|
createTimeStr: string
|
||||||
|
localType?: number
|
||||||
|
emojiMd5?: string
|
||||||
|
emojiCdnUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DualReportFirstChat {
|
export interface DualReportFirstChat {
|
||||||
@@ -14,6 +18,9 @@ export interface DualReportFirstChat {
|
|||||||
content: string
|
content: string
|
||||||
isSentByMe: boolean
|
isSentByMe: boolean
|
||||||
senderUsername?: string
|
senderUsername?: string
|
||||||
|
localType?: number
|
||||||
|
emojiMd5?: string
|
||||||
|
emojiCdnUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DualReportStats {
|
export interface DualReportStats {
|
||||||
@@ -26,13 +33,17 @@ export interface DualReportStats {
|
|||||||
friendTopEmojiMd5?: string
|
friendTopEmojiMd5?: string
|
||||||
myTopEmojiUrl?: string
|
myTopEmojiUrl?: string
|
||||||
friendTopEmojiUrl?: string
|
friendTopEmojiUrl?: string
|
||||||
|
myTopEmojiCount?: number
|
||||||
|
friendTopEmojiCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DualReportData {
|
export interface DualReportData {
|
||||||
year: number
|
year: number
|
||||||
selfName: string
|
selfName: string
|
||||||
|
selfAvatarUrl?: string
|
||||||
friendUsername: string
|
friendUsername: string
|
||||||
friendName: string
|
friendName: string
|
||||||
|
friendAvatarUrl?: string
|
||||||
firstChat: DualReportFirstChat | null
|
firstChat: DualReportFirstChat | null
|
||||||
firstChatMessages?: DualReportMessage[]
|
firstChatMessages?: DualReportMessage[]
|
||||||
yearFirstChat?: {
|
yearFirstChat?: {
|
||||||
@@ -42,9 +53,19 @@ export interface DualReportData {
|
|||||||
isSentByMe: boolean
|
isSentByMe: boolean
|
||||||
friendName: string
|
friendName: string
|
||||||
firstThreeMessages: DualReportMessage[]
|
firstThreeMessages: DualReportMessage[]
|
||||||
|
localType?: number
|
||||||
|
emojiMd5?: string
|
||||||
|
emojiCdnUrl?: string
|
||||||
} | null
|
} | null
|
||||||
stats: DualReportStats
|
stats: DualReportStats
|
||||||
topPhrases: Array<{ phrase: string; count: number }>
|
topPhrases: Array<{ phrase: string; count: number }>
|
||||||
|
myExclusivePhrases: Array<{ phrase: string; count: number }>
|
||||||
|
friendExclusivePhrases: Array<{ phrase: string; count: number }>
|
||||||
|
heatmap?: number[][]
|
||||||
|
initiative?: { initiated: number; received: number }
|
||||||
|
response?: { avg: number; fastest: number; count: number }
|
||||||
|
monthly?: Record<string, number>
|
||||||
|
streak?: { days: number; startDate: string; endDate: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
class DualReportService {
|
class DualReportService {
|
||||||
@@ -106,11 +127,15 @@ class DualReportService {
|
|||||||
if (!raw) return ''
|
if (!raw) return ''
|
||||||
if (typeof raw === 'string') {
|
if (typeof raw === 'string') {
|
||||||
if (raw.length === 0) return ''
|
if (raw.length === 0) return ''
|
||||||
if (this.looksLikeHex(raw)) {
|
// 只有当字符串足够长(超过16字符)且看起来像 hex 时才尝试解码
|
||||||
|
// 短字符串(如 "123456" 等纯数字)容易被误判为 hex
|
||||||
|
if (raw.length > 16 && this.looksLikeHex(raw)) {
|
||||||
const bytes = Buffer.from(raw, 'hex')
|
const bytes = Buffer.from(raw, 'hex')
|
||||||
if (bytes.length > 0) return this.decodeBinaryContent(bytes)
|
if (bytes.length > 0) return this.decodeBinaryContent(bytes)
|
||||||
}
|
}
|
||||||
if (this.looksLikeBase64(raw)) {
|
// 只有当字符串足够长(超过16字符)且看起来像 base64 时才尝试解码
|
||||||
|
// 短字符串(如 "test", "home" 等)容易被误判为 base64
|
||||||
|
if (raw.length > 16 && this.looksLikeBase64(raw)) {
|
||||||
try {
|
try {
|
||||||
const bytes = Buffer.from(raw, 'base64')
|
const bytes = Buffer.from(raw, 'base64')
|
||||||
return this.decodeBinaryContent(bytes)
|
return this.decodeBinaryContent(bytes)
|
||||||
@@ -164,26 +189,258 @@ class DualReportService {
|
|||||||
return `${month}/${day} ${hour}:${minute}`
|
return `${month}/${day} ${hour}:${minute}`
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractEmojiUrl(content: string): string | undefined {
|
private getRecordField(record: Record<string, any> | undefined | null, keys: string[]): any {
|
||||||
if (!content) return undefined
|
if (!record) return undefined
|
||||||
const attrMatch = /cdnurl\s*=\s*['"]([^'"]+)['"]/i.exec(content)
|
for (const key of keys) {
|
||||||
if (attrMatch) {
|
if (Object.prototype.hasOwnProperty.call(record, key) && record[key] !== undefined && record[key] !== null) {
|
||||||
let url = attrMatch[1].replace(/&/g, '&')
|
return record[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
private coerceNumber(raw: any): number {
|
||||||
|
if (raw === undefined || raw === null || raw === '') return NaN
|
||||||
|
if (typeof raw === 'number') return raw
|
||||||
|
if (typeof raw === 'bigint') return Number(raw)
|
||||||
|
if (Buffer.isBuffer(raw)) return parseInt(raw.toString('utf-8'), 10)
|
||||||
|
if (raw instanceof Uint8Array) return parseInt(Buffer.from(raw).toString('utf-8'), 10)
|
||||||
|
const parsed = parseInt(String(raw), 10)
|
||||||
|
return Number.isFinite(parsed) ? parsed : NaN
|
||||||
|
}
|
||||||
|
|
||||||
|
private coerceString(raw: any): string {
|
||||||
|
if (raw === undefined || raw === null) return ''
|
||||||
|
if (typeof raw === 'string') return raw
|
||||||
|
if (Buffer.isBuffer(raw)) return this.decodeBinaryContent(raw)
|
||||||
|
if (raw instanceof Uint8Array) return this.decodeBinaryContent(Buffer.from(raw))
|
||||||
|
return String(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
private coerceBoolean(raw: any): boolean | undefined {
|
||||||
|
if (raw === undefined || raw === null || raw === '') return undefined
|
||||||
|
if (typeof raw === 'boolean') return raw
|
||||||
|
if (typeof raw === 'number') return raw !== 0
|
||||||
|
|
||||||
|
const normalized = String(raw).trim().toLowerCase()
|
||||||
|
if (!normalized) return undefined
|
||||||
|
|
||||||
|
if (['1', 'true', 'yes', 'me', 'self', 'mine', 'sent', 'out', 'outgoing'].includes(normalized)) return true
|
||||||
|
if (['0', 'false', 'no', 'friend', 'peer', 'other', 'recv', 'received', 'in', 'incoming'].includes(normalized)) return false
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeEmojiMd5(raw: string): string | undefined {
|
||||||
|
if (!raw) return undefined
|
||||||
|
const trimmed = raw.trim()
|
||||||
|
if (!trimmed) return undefined
|
||||||
|
const match = /([a-fA-F0-9]{16,64})/.exec(trimmed)
|
||||||
|
return match ? match[1].toLowerCase() : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeEmojiUrl(raw: string): string | undefined {
|
||||||
|
if (!raw) return undefined
|
||||||
|
let url = raw.trim().replace(/&/g, '&')
|
||||||
|
if (!url) return undefined
|
||||||
try {
|
try {
|
||||||
if (url.includes('%')) {
|
if (url.includes('%')) {
|
||||||
url = decodeURIComponent(url)
|
url = decodeURIComponent(url)
|
||||||
}
|
}
|
||||||
} catch { }
|
} catch { }
|
||||||
return url
|
return url || undefined
|
||||||
}
|
|
||||||
const tagMatch = /cdnurl[^>]*>([^<]+)/i.exec(content)
|
|
||||||
return tagMatch?.[1]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractEmojiMd5(content: string): string | undefined {
|
private extractEmojiUrl(content: string | undefined): string | undefined {
|
||||||
if (!content) return undefined
|
if (!content) return undefined
|
||||||
const match = /md5="([^"]+)"/i.exec(content) || /<md5>([^<]+)<\/md5>/i.exec(content)
|
const direct = this.normalizeEmojiUrl(content)
|
||||||
return match?.[1]
|
if (direct && /^https?:\/\//i.test(direct)) return direct
|
||||||
|
|
||||||
|
const attrMatch = /(?:cdnurl|thumburl)\s*=\s*['"]([^'"]+)['"]/i.exec(content)
|
||||||
|
|| /(?:cdnurl|thumburl)\s*=\s*([^'"\s>]+)/i.exec(content)
|
||||||
|
if (attrMatch) return this.normalizeEmojiUrl(attrMatch[1])
|
||||||
|
|
||||||
|
const tagMatch = /<(?:cdnurl|thumburl)>([^<]+)<\/(?:cdnurl|thumburl)>/i.exec(content)
|
||||||
|
|| /(?:cdnurl|thumburl)[^>]*>([^<]+)/i.exec(content)
|
||||||
|
return this.normalizeEmojiUrl(tagMatch?.[1] || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractEmojiMd5(content: string | undefined): string | undefined {
|
||||||
|
if (!content) return undefined
|
||||||
|
const direct = this.normalizeEmojiMd5(content)
|
||||||
|
if (direct && direct.length >= 24) return direct
|
||||||
|
|
||||||
|
const match = /md5\s*=\s*['"]([a-fA-F0-9]{16,64})['"]/i.exec(content)
|
||||||
|
|| /md5\s*=\s*([a-fA-F0-9]{16,64})/i.exec(content)
|
||||||
|
|| /<md5>([a-fA-F0-9]{16,64})<\/md5>/i.exec(content)
|
||||||
|
return this.normalizeEmojiMd5(match?.[1] || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveEmojiOwner(item: any, content: string): boolean | undefined {
|
||||||
|
const sentFlag = this.coerceBoolean(this.getRecordField(item, [
|
||||||
|
'isMe',
|
||||||
|
'is_me',
|
||||||
|
'isSent',
|
||||||
|
'is_sent',
|
||||||
|
'isSend',
|
||||||
|
'is_send',
|
||||||
|
'fromMe',
|
||||||
|
'from_me'
|
||||||
|
]))
|
||||||
|
if (sentFlag !== undefined) return sentFlag
|
||||||
|
|
||||||
|
const sideRaw = this.coerceString(this.getRecordField(item, ['side', 'sender', 'from', 'owner', 'role', 'direction'])).trim().toLowerCase()
|
||||||
|
if (sideRaw) {
|
||||||
|
if (['me', 'self', 'mine', 'out', 'outgoing', 'sent'].includes(sideRaw)) return true
|
||||||
|
if (['friend', 'peer', 'other', 'in', 'incoming', 'received', 'recv'].includes(sideRaw)) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefixMatch = /^\s*([01])\s*:\s*/.exec(content)
|
||||||
|
if (prefixMatch) return prefixMatch[1] === '1'
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
private stripEmojiOwnerPrefix(content: string): string {
|
||||||
|
if (!content) return ''
|
||||||
|
return content.replace(/^\s*[01]\s*:\s*/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseEmojiCandidate(item: any): { isMe?: boolean; md5?: string; url?: string; count: number } {
|
||||||
|
const rawContent = this.coerceString(this.getRecordField(item, [
|
||||||
|
'content',
|
||||||
|
'xml',
|
||||||
|
'message_content',
|
||||||
|
'messageContent',
|
||||||
|
'msg',
|
||||||
|
'payload',
|
||||||
|
'raw'
|
||||||
|
]))
|
||||||
|
const content = this.stripEmojiOwnerPrefix(rawContent)
|
||||||
|
|
||||||
|
const countRaw = this.getRecordField(item, ['count', 'cnt', 'times', 'total', 'num'])
|
||||||
|
const parsedCount = this.coerceNumber(countRaw)
|
||||||
|
const count = Number.isFinite(parsedCount) && parsedCount > 0 ? parsedCount : 0
|
||||||
|
|
||||||
|
const directMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(item, [
|
||||||
|
'md5',
|
||||||
|
'emojiMd5',
|
||||||
|
'emoji_md5',
|
||||||
|
'emd5'
|
||||||
|
])))
|
||||||
|
const md5 = directMd5 || this.extractEmojiMd5(content)
|
||||||
|
|
||||||
|
const directUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(item, [
|
||||||
|
'cdnUrl',
|
||||||
|
'cdnurl',
|
||||||
|
'emojiUrl',
|
||||||
|
'emoji_url',
|
||||||
|
'url',
|
||||||
|
'thumbUrl',
|
||||||
|
'thumburl'
|
||||||
|
])))
|
||||||
|
const url = directUrl || this.extractEmojiUrl(content)
|
||||||
|
|
||||||
|
return {
|
||||||
|
isMe: this.resolveEmojiOwner(item, rawContent),
|
||||||
|
md5,
|
||||||
|
url,
|
||||||
|
count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRowInt(row: Record<string, any>, keys: string[], fallback = 0): number {
|
||||||
|
const raw = this.getRecordField(row, keys)
|
||||||
|
const parsed = this.coerceNumber(raw)
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
private decodeRowMessageContent(row: Record<string, any>): string {
|
||||||
|
const messageContent = this.getRecordField(row, [
|
||||||
|
'message_content',
|
||||||
|
'messageContent',
|
||||||
|
'content',
|
||||||
|
'msg_content',
|
||||||
|
'msgContent',
|
||||||
|
'WCDB_CT_message_content',
|
||||||
|
'WCDB_CT_messageContent'
|
||||||
|
])
|
||||||
|
const compressContent = this.getRecordField(row, [
|
||||||
|
'compress_content',
|
||||||
|
'compressContent',
|
||||||
|
'compressed_content',
|
||||||
|
'WCDB_CT_compress_content',
|
||||||
|
'WCDB_CT_compressContent'
|
||||||
|
])
|
||||||
|
return this.decodeMessageContent(messageContent, compressContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async scanEmojiTopFallback(
|
||||||
|
sessionId: string,
|
||||||
|
beginTimestamp: number,
|
||||||
|
endTimestamp: number,
|
||||||
|
rawWxid: string,
|
||||||
|
cleanedWxid: string
|
||||||
|
): Promise<{ my?: { md5: string; url?: string; count: number }; friend?: { md5: string; url?: string; count: number } }> {
|
||||||
|
const cursorResult = await wcdbService.openMessageCursor(sessionId, 500, true, beginTimestamp, endTimestamp)
|
||||||
|
if (!cursorResult.success || !cursorResult.cursor) return {}
|
||||||
|
|
||||||
|
const tallyMap = new Map<string, { isMe: boolean; md5: string; url?: string; count: number }>()
|
||||||
|
try {
|
||||||
|
let hasMore = true
|
||||||
|
while (hasMore) {
|
||||||
|
const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor)
|
||||||
|
if (!batch.success || !Array.isArray(batch.rows)) break
|
||||||
|
|
||||||
|
for (const row of batch.rows) {
|
||||||
|
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0)
|
||||||
|
if (localType !== 47) continue
|
||||||
|
|
||||||
|
const rawContent = this.decodeRowMessageContent(row)
|
||||||
|
const content = this.stripEmojiOwnerPrefix(rawContent)
|
||||||
|
const directMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(row, ['emoji_md5', 'emojiMd5', 'md5'])))
|
||||||
|
const md5 = directMd5 || this.extractEmojiMd5(content)
|
||||||
|
if (!md5) continue
|
||||||
|
|
||||||
|
const directUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(row, [
|
||||||
|
'emoji_cdn_url',
|
||||||
|
'emojiCdnUrl',
|
||||||
|
'cdnurl',
|
||||||
|
'cdn_url',
|
||||||
|
'emoji_url',
|
||||||
|
'emojiUrl',
|
||||||
|
'url',
|
||||||
|
'thumburl',
|
||||||
|
'thumb_url'
|
||||||
|
])))
|
||||||
|
const url = directUrl || this.extractEmojiUrl(content)
|
||||||
|
const isMe = this.resolveIsSent(row, rawWxid, cleanedWxid)
|
||||||
|
const mapKey = `${isMe ? '1' : '0'}:${md5}`
|
||||||
|
const existing = tallyMap.get(mapKey)
|
||||||
|
if (existing) {
|
||||||
|
existing.count += 1
|
||||||
|
if (!existing.url && url) existing.url = url
|
||||||
|
} else {
|
||||||
|
tallyMap.set(mapKey, { isMe, md5, url, count: 1 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hasMore = batch.hasMore === true
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await wcdbService.closeMessageCursor(cursorResult.cursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
let myTop: { md5: string; url?: string; count: number } | undefined
|
||||||
|
let friendTop: { md5: string; url?: string; count: number } | undefined
|
||||||
|
for (const entry of tallyMap.values()) {
|
||||||
|
if (entry.isMe) {
|
||||||
|
if (!myTop || entry.count > myTop.count) {
|
||||||
|
myTop = { md5: entry.md5, url: entry.url, count: entry.count }
|
||||||
|
}
|
||||||
|
} else if (!friendTop || entry.count > friendTop.count) {
|
||||||
|
friendTop = { md5: entry.md5, url: entry.url, count: entry.count }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { my: myTop, friend: friendTop }
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getDisplayName(username: string, fallback: string): Promise<string> {
|
private async getDisplayName(username: string, fallback: string): Promise<string> {
|
||||||
@@ -245,10 +502,11 @@ class DualReportService {
|
|||||||
dbPath: string
|
dbPath: string
|
||||||
decryptKey: string
|
decryptKey: string
|
||||||
wxid: string
|
wxid: string
|
||||||
|
excludeWords?: string[]
|
||||||
onProgress?: (status: string, progress: number) => void
|
onProgress?: (status: string, progress: number) => void
|
||||||
}): Promise<{ success: boolean; data?: DualReportData; error?: string }> {
|
}): Promise<{ success: boolean; data?: DualReportData; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const { year, friendUsername, dbPath, decryptKey, wxid, onProgress } = params
|
const { year, friendUsername, dbPath, decryptKey, wxid, excludeWords, onProgress } = params
|
||||||
this.reportProgress('正在连接数据库...', 5, onProgress)
|
this.reportProgress('正在连接数据库...', 5, onProgress)
|
||||||
const conn = await this.ensureConnectedWithConfig(dbPath, decryptKey, wxid)
|
const conn = await this.ensureConnectedWithConfig(dbPath, decryptKey, wxid)
|
||||||
if (!conn.success || !conn.cleanedWxid || !conn.rawWxid) return { success: false, error: conn.error }
|
if (!conn.success || !conn.cleanedWxid || !conn.rawWxid) return { success: false, error: conn.error }
|
||||||
@@ -267,189 +525,271 @@ class DualReportService {
|
|||||||
if (myName === rawWxid && cleanedWxid && cleanedWxid !== rawWxid) {
|
if (myName === rawWxid && cleanedWxid && cleanedWxid !== rawWxid) {
|
||||||
myName = await this.getDisplayName(cleanedWxid, rawWxid)
|
myName = await this.getDisplayName(cleanedWxid, rawWxid)
|
||||||
}
|
}
|
||||||
|
const avatarCandidates = Array.from(new Set([
|
||||||
|
friendUsername,
|
||||||
|
rawWxid,
|
||||||
|
cleanedWxid
|
||||||
|
].filter(Boolean) as string[]))
|
||||||
|
let selfAvatarUrl: string | undefined
|
||||||
|
let friendAvatarUrl: string | undefined
|
||||||
|
const avatarResult = await wcdbService.getAvatarUrls(avatarCandidates)
|
||||||
|
if (avatarResult.success && avatarResult.map) {
|
||||||
|
selfAvatarUrl = avatarResult.map[rawWxid] || avatarResult.map[cleanedWxid]
|
||||||
|
friendAvatarUrl = avatarResult.map[friendUsername]
|
||||||
|
}
|
||||||
|
|
||||||
this.reportProgress('获取首条聊天记录...', 15, onProgress)
|
this.reportProgress('获取首条聊天记录...', 15, onProgress)
|
||||||
const firstRows = await this.getFirstMessages(friendUsername, 3, 0, 0)
|
const firstRows = await this.getFirstMessages(friendUsername, 10, 0, 0)
|
||||||
let firstChat: DualReportFirstChat | null = null
|
let firstChat: DualReportFirstChat | null = null
|
||||||
if (firstRows.length > 0) {
|
if (firstRows.length > 0) {
|
||||||
const row = firstRows[0]
|
const row = firstRows[0]
|
||||||
const createTime = parseInt(row.create_time || '0', 10) * 1000
|
const createTime = parseInt(row.create_time || '0', 10) * 1000
|
||||||
const content = this.decodeMessageContent(row.message_content, row.compress_content)
|
const rawContent = this.decodeMessageContent(row.message_content, row.compress_content)
|
||||||
|
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType'], 0)
|
||||||
|
let emojiMd5: string | undefined
|
||||||
|
let emojiCdnUrl: string | undefined
|
||||||
|
if (localType === 47) {
|
||||||
|
const stripped = this.stripEmojiOwnerPrefix(rawContent)
|
||||||
|
emojiMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(row, ['emoji_md5', 'emojiMd5', 'md5']))) || this.extractEmojiMd5(stripped)
|
||||||
|
emojiCdnUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(row, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl']))) || this.extractEmojiUrl(stripped)
|
||||||
|
}
|
||||||
|
|
||||||
firstChat = {
|
firstChat = {
|
||||||
createTime,
|
createTime,
|
||||||
createTimeStr: this.formatDateTime(createTime),
|
createTimeStr: this.formatDateTime(createTime),
|
||||||
content: String(content || ''),
|
content: String(rawContent || ''),
|
||||||
isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid),
|
isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid),
|
||||||
senderUsername: row.sender_username || row.sender
|
senderUsername: row.sender_username || row.sender,
|
||||||
|
localType,
|
||||||
|
emojiMd5,
|
||||||
|
emojiCdnUrl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const firstChatMessages: DualReportMessage[] = firstRows.map((row) => {
|
const firstChatMessages: DualReportMessage[] = firstRows.map((row) => {
|
||||||
const msgTime = parseInt(row.create_time || '0', 10) * 1000
|
const msgTime = parseInt(row.create_time || '0', 10) * 1000
|
||||||
const msgContent = this.decodeMessageContent(row.message_content, row.compress_content)
|
const rawContent = this.decodeMessageContent(row.message_content, row.compress_content)
|
||||||
|
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType'], 0)
|
||||||
|
let emojiMd5: string | undefined
|
||||||
|
let emojiCdnUrl: string | undefined
|
||||||
|
if (localType === 47) {
|
||||||
|
const stripped = this.stripEmojiOwnerPrefix(rawContent)
|
||||||
|
emojiMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(row, ['emoji_md5', 'emojiMd5', 'md5']))) || this.extractEmojiMd5(stripped)
|
||||||
|
emojiCdnUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(row, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl']))) || this.extractEmojiUrl(stripped)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: String(msgContent || ''),
|
content: String(rawContent || ''),
|
||||||
isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid),
|
isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid),
|
||||||
createTime: msgTime,
|
createTime: msgTime,
|
||||||
createTimeStr: this.formatDateTime(msgTime)
|
createTimeStr: this.formatDateTime(msgTime),
|
||||||
|
localType,
|
||||||
|
emojiMd5,
|
||||||
|
emojiCdnUrl
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
let yearFirstChat: DualReportData['yearFirstChat'] = null
|
let yearFirstChat: DualReportData['yearFirstChat'] = null
|
||||||
if (!isAllTime) {
|
if (!isAllTime) {
|
||||||
this.reportProgress('获取今年首次聊天...', 20, onProgress)
|
this.reportProgress('获取今年首次聊天...', 20, onProgress)
|
||||||
const firstYearRows = await this.getFirstMessages(friendUsername, 3, startTime, endTime)
|
const firstYearRows = await this.getFirstMessages(friendUsername, 10, startTime, endTime)
|
||||||
if (firstYearRows.length > 0) {
|
if (firstYearRows.length > 0) {
|
||||||
const firstRow = firstYearRows[0]
|
const firstRow = firstYearRows[0]
|
||||||
const createTime = parseInt(firstRow.create_time || '0', 10) * 1000
|
const createTime = parseInt(firstRow.create_time || '0', 10) * 1000
|
||||||
const firstThreeMessages: DualReportMessage[] = firstYearRows.map((row) => {
|
const firstThreeMessages: DualReportMessage[] = firstYearRows.map((row) => {
|
||||||
const msgTime = parseInt(row.create_time || '0', 10) * 1000
|
const msgTime = parseInt(row.create_time || '0', 10) * 1000
|
||||||
const msgContent = this.decodeMessageContent(row.message_content, row.compress_content)
|
const rawContent = this.decodeMessageContent(row.message_content, row.compress_content)
|
||||||
|
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType'], 0)
|
||||||
|
let emojiMd5: string | undefined
|
||||||
|
let emojiCdnUrl: string | undefined
|
||||||
|
if (localType === 47) {
|
||||||
|
const stripped = this.stripEmojiOwnerPrefix(rawContent)
|
||||||
|
emojiMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(row, ['emoji_md5', 'emojiMd5', 'md5']))) || this.extractEmojiMd5(stripped)
|
||||||
|
emojiCdnUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(row, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl']))) || this.extractEmojiUrl(stripped)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: String(msgContent || ''),
|
content: String(rawContent || ''),
|
||||||
isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid),
|
isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid),
|
||||||
createTime: msgTime,
|
createTime: msgTime,
|
||||||
createTimeStr: this.formatDateTime(msgTime)
|
createTimeStr: this.formatDateTime(msgTime),
|
||||||
|
localType,
|
||||||
|
emojiMd5,
|
||||||
|
emojiCdnUrl
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
const firstRowYear = firstYearRows[0]
|
||||||
|
const rawContentYear = this.decodeMessageContent(firstRowYear.message_content, firstRowYear.compress_content)
|
||||||
|
const localTypeYear = this.getRowInt(firstRowYear, ['local_type', 'localType', 'type', 'msg_type', 'msgType'], 0)
|
||||||
|
let emojiMd5Year: string | undefined
|
||||||
|
let emojiCdnUrlYear: string | undefined
|
||||||
|
if (localTypeYear === 47) {
|
||||||
|
const stripped = this.stripEmojiOwnerPrefix(rawContentYear)
|
||||||
|
emojiMd5Year = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(firstRowYear, ['emoji_md5', 'emojiMd5', 'md5']))) || this.extractEmojiMd5(stripped)
|
||||||
|
emojiCdnUrlYear = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(firstRowYear, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl']))) || this.extractEmojiUrl(stripped)
|
||||||
|
}
|
||||||
|
|
||||||
yearFirstChat = {
|
yearFirstChat = {
|
||||||
createTime,
|
createTime,
|
||||||
createTimeStr: this.formatDateTime(createTime),
|
createTimeStr: this.formatDateTime(createTime),
|
||||||
content: String(this.decodeMessageContent(firstRow.message_content, firstRow.compress_content) || ''),
|
content: String(rawContentYear || ''),
|
||||||
isSentByMe: this.resolveIsSent(firstRow, rawWxid, cleanedWxid),
|
isSentByMe: this.resolveIsSent(firstRowYear, rawWxid, cleanedWxid),
|
||||||
friendName,
|
friendName,
|
||||||
firstThreeMessages
|
firstThreeMessages,
|
||||||
|
localType: localTypeYear,
|
||||||
|
emojiMd5: emojiMd5Year,
|
||||||
|
emojiCdnUrl: emojiCdnUrlYear
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.reportProgress('统计聊天数据...', 30, onProgress)
|
this.reportProgress('统计聊天数据...', 30, onProgress)
|
||||||
|
|
||||||
|
const statsResult = await wcdbService.getDualReportStats(friendUsername, startTime, endTime)
|
||||||
|
if (!statsResult.success || !statsResult.data) {
|
||||||
|
return { success: false, error: statsResult.error || '获取双人报告统计失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const cppData = statsResult.data
|
||||||
|
const counts = cppData.counts || {}
|
||||||
|
|
||||||
const stats: DualReportStats = {
|
const stats: DualReportStats = {
|
||||||
totalMessages: 0,
|
totalMessages: counts.total || 0,
|
||||||
totalWords: 0,
|
totalWords: counts.words || 0,
|
||||||
imageCount: 0,
|
imageCount: counts.image || 0,
|
||||||
voiceCount: 0,
|
voiceCount: counts.voice || 0,
|
||||||
emojiCount: 0
|
emojiCount: counts.emoji || 0
|
||||||
}
|
|
||||||
const wordCountMap = new Map<string, number>()
|
|
||||||
const myEmojiCounts = new Map<string, number>()
|
|
||||||
const friendEmojiCounts = new Map<string, number>()
|
|
||||||
const myEmojiUrlMap = new Map<string, string>()
|
|
||||||
const friendEmojiUrlMap = new Map<string, string>()
|
|
||||||
|
|
||||||
const messageCountResult = await wcdbService.getMessageCount(friendUsername)
|
|
||||||
const totalForProgress = messageCountResult.success && messageCountResult.count
|
|
||||||
? messageCountResult.count
|
|
||||||
: 0
|
|
||||||
let processed = 0
|
|
||||||
let lastProgressAt = 0
|
|
||||||
|
|
||||||
const cursorResult = await wcdbService.openMessageCursor(friendUsername, 1000, true, startTime, endTime)
|
|
||||||
if (!cursorResult.success || !cursorResult.cursor) {
|
|
||||||
return { success: false, error: cursorResult.error || '打开消息游标失败' }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Process Emojis to find top for me and friend
|
||||||
let hasMore = true
|
let myTopEmojiMd5: string | undefined
|
||||||
while (hasMore) {
|
let myTopEmojiUrl: string | undefined
|
||||||
const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor)
|
let myTopCount = -1
|
||||||
if (!batch.success || !batch.rows) break
|
|
||||||
for (const row of batch.rows) {
|
|
||||||
const localType = parseInt(row.local_type || row.type || '1', 10)
|
|
||||||
const isSent = this.resolveIsSent(row, rawWxid, cleanedWxid)
|
|
||||||
stats.totalMessages += 1
|
|
||||||
|
|
||||||
if (localType === 3) stats.imageCount += 1
|
let friendTopEmojiMd5: string | undefined
|
||||||
if (localType === 34) stats.voiceCount += 1
|
let friendTopEmojiUrl: string | undefined
|
||||||
if (localType === 47) {
|
let friendTopCount = -1
|
||||||
stats.emojiCount += 1
|
|
||||||
const content = this.decodeMessageContent(row.message_content, row.compress_content)
|
if (cppData.emojis && Array.isArray(cppData.emojis)) {
|
||||||
const md5 = this.extractEmojiMd5(content)
|
for (const item of cppData.emojis) {
|
||||||
const url = this.extractEmojiUrl(content)
|
const candidate = this.parseEmojiCandidate(item)
|
||||||
if (md5) {
|
if (!candidate.md5 || candidate.isMe === undefined || candidate.count <= 0) continue
|
||||||
const targetMap = isSent ? myEmojiCounts : friendEmojiCounts
|
|
||||||
targetMap.set(md5, (targetMap.get(md5) || 0) + 1)
|
if (candidate.isMe) {
|
||||||
if (url) {
|
if (candidate.count > myTopCount) {
|
||||||
const urlMap = isSent ? myEmojiUrlMap : friendEmojiUrlMap
|
myTopCount = candidate.count
|
||||||
if (!urlMap.has(md5)) urlMap.set(md5, url)
|
myTopEmojiMd5 = candidate.md5
|
||||||
|
myTopEmojiUrl = candidate.url
|
||||||
|
}
|
||||||
|
} else if (candidate.count > friendTopCount) {
|
||||||
|
friendTopCount = candidate.count
|
||||||
|
friendTopEmojiMd5 = candidate.md5
|
||||||
|
friendTopEmojiUrl = candidate.url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (localType === 1 || localType === 244813135921) {
|
const needsEmojiFallback = stats.emojiCount > 0 && (!myTopEmojiMd5 || !friendTopEmojiMd5)
|
||||||
const content = this.decodeMessageContent(row.message_content, row.compress_content)
|
if (needsEmojiFallback) {
|
||||||
const text = String(content || '').trim()
|
const fallback = await this.scanEmojiTopFallback(friendUsername, startTime, endTime, rawWxid, cleanedWxid)
|
||||||
if (text.length > 0) {
|
|
||||||
stats.totalWords += text.replace(/\s+/g, '').length
|
if (!myTopEmojiMd5 && fallback.my?.md5) {
|
||||||
const normalized = text.replace(/\s+/g, ' ').trim()
|
myTopEmojiMd5 = fallback.my.md5
|
||||||
if (normalized.length >= 2 &&
|
myTopEmojiUrl = myTopEmojiUrl || fallback.my.url
|
||||||
normalized.length <= 50 &&
|
myTopCount = fallback.my.count
|
||||||
!normalized.includes('http') &&
|
|
||||||
!normalized.includes('<') &&
|
|
||||||
!normalized.startsWith('[') &&
|
|
||||||
!normalized.startsWith('<?xml')) {
|
|
||||||
wordCountMap.set(normalized, (wordCountMap.get(normalized) || 0) + 1)
|
|
||||||
}
|
}
|
||||||
|
if (!friendTopEmojiMd5 && fallback.friend?.md5) {
|
||||||
|
friendTopEmojiMd5 = fallback.friend.md5
|
||||||
|
friendTopEmojiUrl = friendTopEmojiUrl || fallback.friend.url
|
||||||
|
friendTopCount = fallback.friend.count
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (totalForProgress > 0) {
|
const [myEmojiUrlResult, friendEmojiUrlResult] = await Promise.all([
|
||||||
processed++
|
myTopEmojiMd5 && !myTopEmojiUrl ? wcdbService.getEmoticonCdnUrl(dbPath, myTopEmojiMd5) : Promise.resolve(null),
|
||||||
}
|
friendTopEmojiMd5 && !friendTopEmojiUrl ? wcdbService.getEmoticonCdnUrl(dbPath, friendTopEmojiMd5) : Promise.resolve(null)
|
||||||
}
|
])
|
||||||
hasMore = batch.hasMore === true
|
if (myEmojiUrlResult?.success && myEmojiUrlResult.url) myTopEmojiUrl = myEmojiUrlResult.url
|
||||||
|
if (friendEmojiUrlResult?.success && friendEmojiUrlResult.url) friendTopEmojiUrl = friendEmojiUrlResult.url
|
||||||
const now = Date.now()
|
|
||||||
if (now - lastProgressAt > 200) {
|
|
||||||
if (totalForProgress > 0) {
|
|
||||||
const ratio = Math.min(1, processed / totalForProgress)
|
|
||||||
const progress = 30 + Math.floor(ratio * 50)
|
|
||||||
this.reportProgress('统计聊天数据...', progress, onProgress)
|
|
||||||
}
|
|
||||||
lastProgressAt = now
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await wcdbService.closeMessageCursor(cursorResult.cursor)
|
|
||||||
}
|
|
||||||
|
|
||||||
const pickTop = (map: Map<string, number>): string | undefined => {
|
|
||||||
let topKey: string | undefined
|
|
||||||
let topCount = -1
|
|
||||||
for (const [key, count] of map.entries()) {
|
|
||||||
if (count > topCount) {
|
|
||||||
topCount = count
|
|
||||||
topKey = key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return topKey
|
|
||||||
}
|
|
||||||
|
|
||||||
const myTopEmojiMd5 = pickTop(myEmojiCounts)
|
|
||||||
const friendTopEmojiMd5 = pickTop(friendEmojiCounts)
|
|
||||||
|
|
||||||
stats.myTopEmojiMd5 = myTopEmojiMd5
|
stats.myTopEmojiMd5 = myTopEmojiMd5
|
||||||
|
stats.myTopEmojiUrl = myTopEmojiUrl
|
||||||
stats.friendTopEmojiMd5 = friendTopEmojiMd5
|
stats.friendTopEmojiMd5 = friendTopEmojiMd5
|
||||||
stats.myTopEmojiUrl = myTopEmojiMd5 ? myEmojiUrlMap.get(myTopEmojiMd5) : undefined
|
stats.friendTopEmojiUrl = friendTopEmojiUrl
|
||||||
stats.friendTopEmojiUrl = friendTopEmojiMd5 ? friendEmojiUrlMap.get(friendTopEmojiMd5) : undefined
|
if (myTopCount >= 0) stats.myTopEmojiCount = myTopCount
|
||||||
|
if (friendTopCount >= 0) stats.friendTopEmojiCount = friendTopCount
|
||||||
|
|
||||||
this.reportProgress('生成常用语词云...', 85, onProgress)
|
if (friendTopCount >= 0) stats.friendTopEmojiCount = friendTopCount
|
||||||
const topPhrases = Array.from(wordCountMap.entries())
|
|
||||||
.filter(([_, count]) => count >= 2)
|
const excludeSet = new Set(excludeWords || [])
|
||||||
.sort((a, b) => b[1] - a[1])
|
|
||||||
.slice(0, 50)
|
const filterPhrases = (list: any[]) => {
|
||||||
.map(([phrase, count]) => ({ phrase, count }))
|
return (list || []).filter((p: any) => !excludeSet.has(p.phrase))
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanPhrases = filterPhrases(cppData.phrases)
|
||||||
|
const cleanMyPhrases = filterPhrases(cppData.myPhrases)
|
||||||
|
const cleanFriendPhrases = filterPhrases(cppData.friendPhrases)
|
||||||
|
|
||||||
|
const topPhrases = cleanPhrases.map((p: any) => ({
|
||||||
|
phrase: p.phrase,
|
||||||
|
count: p.count
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 计算专属词汇:一方频繁使用而另一方很少使用的词
|
||||||
|
const myPhraseMap = new Map<string, number>()
|
||||||
|
const friendPhraseMap = new Map<string, number>()
|
||||||
|
for (const p of cleanMyPhrases) {
|
||||||
|
myPhraseMap.set(p.phrase, p.count)
|
||||||
|
}
|
||||||
|
for (const p of cleanFriendPhrases) {
|
||||||
|
friendPhraseMap.set(p.phrase, p.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 专属词汇:该方使用占比 >= 75% 且至少出现 2 次
|
||||||
|
const myExclusivePhrases: Array<{ phrase: string; count: number }> = []
|
||||||
|
const friendExclusivePhrases: Array<{ phrase: string; count: number }> = []
|
||||||
|
|
||||||
|
for (const [phrase, myCount] of myPhraseMap) {
|
||||||
|
const friendCount = friendPhraseMap.get(phrase) || 0
|
||||||
|
const total = myCount + friendCount
|
||||||
|
if (myCount >= 2 && total > 0 && myCount / total >= 0.75) {
|
||||||
|
myExclusivePhrases.push({ phrase, count: myCount })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [phrase, friendCount] of friendPhraseMap) {
|
||||||
|
const myCount = myPhraseMap.get(phrase) || 0
|
||||||
|
const total = myCount + friendCount
|
||||||
|
if (friendCount >= 2 && total > 0 && friendCount / total >= 0.75) {
|
||||||
|
friendExclusivePhrases.push({ phrase, count: friendCount })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按频率排序,取前 20
|
||||||
|
myExclusivePhrases.sort((a, b) => b.count - a.count)
|
||||||
|
friendExclusivePhrases.sort((a, b) => b.count - a.count)
|
||||||
|
if (myExclusivePhrases.length > 20) myExclusivePhrases.length = 20
|
||||||
|
if (friendExclusivePhrases.length > 20) friendExclusivePhrases.length = 20
|
||||||
|
|
||||||
const reportData: DualReportData = {
|
const reportData: DualReportData = {
|
||||||
year: reportYear,
|
year: reportYear,
|
||||||
selfName: myName,
|
selfName: myName,
|
||||||
|
selfAvatarUrl,
|
||||||
friendUsername,
|
friendUsername,
|
||||||
friendName,
|
friendName,
|
||||||
|
friendAvatarUrl,
|
||||||
firstChat,
|
firstChat,
|
||||||
firstChatMessages,
|
firstChatMessages,
|
||||||
yearFirstChat,
|
yearFirstChat,
|
||||||
stats,
|
stats,
|
||||||
topPhrases
|
topPhrases,
|
||||||
}
|
myExclusivePhrases,
|
||||||
|
friendExclusivePhrases,
|
||||||
|
heatmap: cppData.heatmap,
|
||||||
|
initiative: cppData.initiative,
|
||||||
|
response: cppData.response,
|
||||||
|
monthly: cppData.monthly,
|
||||||
|
streak: cppData.streak
|
||||||
|
} as any
|
||||||
|
|
||||||
this.reportProgress('双人报告生成完成', 100, onProgress)
|
this.reportProgress('双人报告生成完成', 100, onProgress)
|
||||||
return { success: true, data: reportData }
|
return { success: true, data: reportData }
|
||||||
|
|||||||
354
electron/services/exportCardDiagnosticsService.ts
Normal file
354
electron/services/exportCardDiagnosticsService.ts
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
import { mkdir, writeFile } from 'fs/promises'
|
||||||
|
import { basename, dirname, extname, join } from 'path'
|
||||||
|
|
||||||
|
export type ExportCardDiagSource = 'frontend' | 'main' | 'backend' | 'worker'
|
||||||
|
export type ExportCardDiagLevel = 'debug' | 'info' | 'warn' | 'error'
|
||||||
|
export type ExportCardDiagStatus = 'running' | 'done' | 'failed' | 'timeout'
|
||||||
|
|
||||||
|
export interface ExportCardDiagLogEntry {
|
||||||
|
id: string
|
||||||
|
ts: number
|
||||||
|
source: ExportCardDiagSource
|
||||||
|
level: ExportCardDiagLevel
|
||||||
|
message: string
|
||||||
|
traceId?: string
|
||||||
|
stepId?: string
|
||||||
|
stepName?: string
|
||||||
|
status?: ExportCardDiagStatus
|
||||||
|
durationMs?: number
|
||||||
|
data?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActiveStepState {
|
||||||
|
key: string
|
||||||
|
traceId: string
|
||||||
|
stepId: string
|
||||||
|
stepName: string
|
||||||
|
source: ExportCardDiagSource
|
||||||
|
startedAt: number
|
||||||
|
lastUpdatedAt: number
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StepStartInput {
|
||||||
|
traceId: string
|
||||||
|
stepId: string
|
||||||
|
stepName: string
|
||||||
|
source: ExportCardDiagSource
|
||||||
|
level?: ExportCardDiagLevel
|
||||||
|
message?: string
|
||||||
|
data?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StepEndInput {
|
||||||
|
traceId: string
|
||||||
|
stepId: string
|
||||||
|
stepName: string
|
||||||
|
source: ExportCardDiagSource
|
||||||
|
status?: Extract<ExportCardDiagStatus, 'done' | 'failed' | 'timeout'>
|
||||||
|
level?: ExportCardDiagLevel
|
||||||
|
message?: string
|
||||||
|
data?: Record<string, unknown>
|
||||||
|
durationMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LogInput {
|
||||||
|
ts?: number
|
||||||
|
source: ExportCardDiagSource
|
||||||
|
level?: ExportCardDiagLevel
|
||||||
|
message: string
|
||||||
|
traceId?: string
|
||||||
|
stepId?: string
|
||||||
|
stepName?: string
|
||||||
|
status?: ExportCardDiagStatus
|
||||||
|
durationMs?: number
|
||||||
|
data?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportCardDiagSnapshot {
|
||||||
|
logs: ExportCardDiagLogEntry[]
|
||||||
|
activeSteps: Array<{
|
||||||
|
traceId: string
|
||||||
|
stepId: string
|
||||||
|
stepName: string
|
||||||
|
source: ExportCardDiagSource
|
||||||
|
elapsedMs: number
|
||||||
|
stallMs: number
|
||||||
|
startedAt: number
|
||||||
|
lastUpdatedAt: number
|
||||||
|
message?: string
|
||||||
|
}>
|
||||||
|
summary: {
|
||||||
|
totalLogs: number
|
||||||
|
activeStepCount: number
|
||||||
|
errorCount: number
|
||||||
|
warnCount: number
|
||||||
|
timeoutCount: number
|
||||||
|
lastUpdatedAt: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExportCardDiagnosticsService {
|
||||||
|
private readonly maxLogs = 6000
|
||||||
|
private logs: ExportCardDiagLogEntry[] = []
|
||||||
|
private activeSteps = new Map<string, ActiveStepState>()
|
||||||
|
private seq = 0
|
||||||
|
|
||||||
|
private nextId(ts: number): string {
|
||||||
|
this.seq += 1
|
||||||
|
return `export-card-diag-${ts}-${this.seq}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private trimLogs() {
|
||||||
|
if (this.logs.length <= this.maxLogs) return
|
||||||
|
const drop = this.logs.length - this.maxLogs
|
||||||
|
this.logs.splice(0, drop)
|
||||||
|
}
|
||||||
|
|
||||||
|
log(input: LogInput): ExportCardDiagLogEntry {
|
||||||
|
const ts = Number.isFinite(input.ts) ? Math.max(0, Math.floor(input.ts as number)) : Date.now()
|
||||||
|
const entry: ExportCardDiagLogEntry = {
|
||||||
|
id: this.nextId(ts),
|
||||||
|
ts,
|
||||||
|
source: input.source,
|
||||||
|
level: input.level || 'info',
|
||||||
|
message: input.message,
|
||||||
|
traceId: input.traceId,
|
||||||
|
stepId: input.stepId,
|
||||||
|
stepName: input.stepName,
|
||||||
|
status: input.status,
|
||||||
|
durationMs: Number.isFinite(input.durationMs) ? Math.max(0, Math.floor(input.durationMs as number)) : undefined,
|
||||||
|
data: input.data
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logs.push(entry)
|
||||||
|
this.trimLogs()
|
||||||
|
|
||||||
|
if (entry.traceId && entry.stepId && entry.stepName) {
|
||||||
|
const key = `${entry.traceId}::${entry.stepId}`
|
||||||
|
if (entry.status === 'running') {
|
||||||
|
const previous = this.activeSteps.get(key)
|
||||||
|
this.activeSteps.set(key, {
|
||||||
|
key,
|
||||||
|
traceId: entry.traceId,
|
||||||
|
stepId: entry.stepId,
|
||||||
|
stepName: entry.stepName,
|
||||||
|
source: entry.source,
|
||||||
|
startedAt: previous?.startedAt || entry.ts,
|
||||||
|
lastUpdatedAt: entry.ts,
|
||||||
|
message: entry.message
|
||||||
|
})
|
||||||
|
} else if (entry.status === 'done' || entry.status === 'failed' || entry.status === 'timeout') {
|
||||||
|
this.activeSteps.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
stepStart(input: StepStartInput): ExportCardDiagLogEntry {
|
||||||
|
return this.log({
|
||||||
|
source: input.source,
|
||||||
|
level: input.level || 'info',
|
||||||
|
message: input.message || `${input.stepName} 开始`,
|
||||||
|
traceId: input.traceId,
|
||||||
|
stepId: input.stepId,
|
||||||
|
stepName: input.stepName,
|
||||||
|
status: 'running',
|
||||||
|
data: input.data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
stepEnd(input: StepEndInput): ExportCardDiagLogEntry {
|
||||||
|
return this.log({
|
||||||
|
source: input.source,
|
||||||
|
level: input.level || (input.status === 'done' ? 'info' : 'warn'),
|
||||||
|
message: input.message || `${input.stepName} ${input.status === 'done' ? '完成' : '结束'}`,
|
||||||
|
traceId: input.traceId,
|
||||||
|
stepId: input.stepId,
|
||||||
|
stepName: input.stepName,
|
||||||
|
status: input.status || 'done',
|
||||||
|
durationMs: input.durationMs,
|
||||||
|
data: input.data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.logs = []
|
||||||
|
this.activeSteps.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot(limit = 1200): ExportCardDiagSnapshot {
|
||||||
|
const capped = Number.isFinite(limit) ? Math.max(100, Math.min(5000, Math.floor(limit))) : 1200
|
||||||
|
const logs = this.logs.slice(-capped)
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
const activeSteps = Array.from(this.activeSteps.values())
|
||||||
|
.map(step => ({
|
||||||
|
traceId: step.traceId,
|
||||||
|
stepId: step.stepId,
|
||||||
|
stepName: step.stepName,
|
||||||
|
source: step.source,
|
||||||
|
startedAt: step.startedAt,
|
||||||
|
lastUpdatedAt: step.lastUpdatedAt,
|
||||||
|
elapsedMs: Math.max(0, now - step.startedAt),
|
||||||
|
stallMs: Math.max(0, now - step.lastUpdatedAt),
|
||||||
|
message: step.message
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.lastUpdatedAt - a.lastUpdatedAt)
|
||||||
|
|
||||||
|
let errorCount = 0
|
||||||
|
let warnCount = 0
|
||||||
|
let timeoutCount = 0
|
||||||
|
for (const item of logs) {
|
||||||
|
if (item.level === 'error') errorCount += 1
|
||||||
|
if (item.level === 'warn') warnCount += 1
|
||||||
|
if (item.status === 'timeout') timeoutCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
logs,
|
||||||
|
activeSteps,
|
||||||
|
summary: {
|
||||||
|
totalLogs: this.logs.length,
|
||||||
|
activeStepCount: activeSteps.length,
|
||||||
|
errorCount,
|
||||||
|
warnCount,
|
||||||
|
timeoutCount,
|
||||||
|
lastUpdatedAt: logs.length > 0 ? logs[logs.length - 1].ts : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeExternalLogs(value: unknown[]): ExportCardDiagLogEntry[] {
|
||||||
|
const result: ExportCardDiagLogEntry[] = []
|
||||||
|
for (const item of value) {
|
||||||
|
if (!item || typeof item !== 'object') continue
|
||||||
|
const row = item as Record<string, unknown>
|
||||||
|
const tsRaw = row.ts ?? row.timestamp
|
||||||
|
const tsNum = Number(tsRaw)
|
||||||
|
const ts = Number.isFinite(tsNum) && tsNum > 0 ? Math.floor(tsNum) : Date.now()
|
||||||
|
|
||||||
|
const sourceRaw = String(row.source || 'frontend')
|
||||||
|
const source: ExportCardDiagSource = sourceRaw === 'main' || sourceRaw === 'backend' || sourceRaw === 'worker'
|
||||||
|
? sourceRaw
|
||||||
|
: 'frontend'
|
||||||
|
const levelRaw = String(row.level || 'info')
|
||||||
|
const level: ExportCardDiagLevel = levelRaw === 'debug' || levelRaw === 'warn' || levelRaw === 'error'
|
||||||
|
? levelRaw
|
||||||
|
: 'info'
|
||||||
|
|
||||||
|
const statusRaw = String(row.status || '')
|
||||||
|
const status: ExportCardDiagStatus | undefined = statusRaw === 'running' || statusRaw === 'done' || statusRaw === 'failed' || statusRaw === 'timeout'
|
||||||
|
? statusRaw
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const durationRaw = Number(row.durationMs)
|
||||||
|
result.push({
|
||||||
|
id: String(row.id || this.nextId(ts)),
|
||||||
|
ts,
|
||||||
|
source,
|
||||||
|
level,
|
||||||
|
message: String(row.message || ''),
|
||||||
|
traceId: typeof row.traceId === 'string' ? row.traceId : undefined,
|
||||||
|
stepId: typeof row.stepId === 'string' ? row.stepId : undefined,
|
||||||
|
stepName: typeof row.stepName === 'string' ? row.stepName : undefined,
|
||||||
|
status,
|
||||||
|
durationMs: Number.isFinite(durationRaw) ? Math.max(0, Math.floor(durationRaw)) : undefined,
|
||||||
|
data: row.data && typeof row.data === 'object' ? row.data as Record<string, unknown> : undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private serializeLogEntry(log: ExportCardDiagLogEntry): string {
|
||||||
|
return JSON.stringify(log)
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSummaryText(logs: ExportCardDiagLogEntry[], activeSteps: ExportCardDiagSnapshot['activeSteps']): string {
|
||||||
|
const total = logs.length
|
||||||
|
let errorCount = 0
|
||||||
|
let warnCount = 0
|
||||||
|
let timeoutCount = 0
|
||||||
|
let frontendCount = 0
|
||||||
|
let backendCount = 0
|
||||||
|
let mainCount = 0
|
||||||
|
let workerCount = 0
|
||||||
|
|
||||||
|
for (const item of logs) {
|
||||||
|
if (item.level === 'error') errorCount += 1
|
||||||
|
if (item.level === 'warn') warnCount += 1
|
||||||
|
if (item.status === 'timeout') timeoutCount += 1
|
||||||
|
if (item.source === 'frontend') frontendCount += 1
|
||||||
|
if (item.source === 'backend') backendCount += 1
|
||||||
|
if (item.source === 'main') mainCount += 1
|
||||||
|
if (item.source === 'worker') workerCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = []
|
||||||
|
lines.push('WeFlow 导出卡片诊断摘要')
|
||||||
|
lines.push(`生成时间: ${new Date().toLocaleString('zh-CN')}`)
|
||||||
|
lines.push(`日志总数: ${total}`)
|
||||||
|
lines.push(`来源统计: frontend=${frontendCount}, main=${mainCount}, backend=${backendCount}, worker=${workerCount}`)
|
||||||
|
lines.push(`级别统计: warn=${warnCount}, error=${errorCount}, timeout=${timeoutCount}`)
|
||||||
|
lines.push(`当前活跃步骤: ${activeSteps.length}`)
|
||||||
|
|
||||||
|
if (activeSteps.length > 0) {
|
||||||
|
lines.push('')
|
||||||
|
lines.push('活跃步骤:')
|
||||||
|
for (const step of activeSteps.slice(0, 12)) {
|
||||||
|
lines.push(`- [${step.source}] ${step.stepName} trace=${step.traceId} elapsed=${step.elapsedMs}ms stall=${step.stallMs}ms`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestErrors = logs.filter(item => item.level === 'error' || item.status === 'failed' || item.status === 'timeout').slice(-12)
|
||||||
|
if (latestErrors.length > 0) {
|
||||||
|
lines.push('')
|
||||||
|
lines.push('最近异常:')
|
||||||
|
for (const item of latestErrors) {
|
||||||
|
lines.push(`- ${new Date(item.ts).toLocaleTimeString('zh-CN')} [${item.source}] ${item.stepName || item.stepId || 'unknown'} ${item.status || item.level} ${item.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportCombinedLogs(filePath: string, frontendLogs: unknown[] = []): Promise<{
|
||||||
|
success: boolean
|
||||||
|
filePath?: string
|
||||||
|
summaryPath?: string
|
||||||
|
count?: number
|
||||||
|
error?: string
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const normalizedFrontend = this.normalizeExternalLogs(Array.isArray(frontendLogs) ? frontendLogs : [])
|
||||||
|
const merged = [...this.logs, ...normalizedFrontend]
|
||||||
|
.sort((a, b) => (a.ts - b.ts) || a.id.localeCompare(b.id))
|
||||||
|
|
||||||
|
const lines = merged.map(item => this.serializeLogEntry(item)).join('\n')
|
||||||
|
await mkdir(dirname(filePath), { recursive: true })
|
||||||
|
await writeFile(filePath, lines ? `${lines}\n` : '', 'utf8')
|
||||||
|
|
||||||
|
const ext = extname(filePath)
|
||||||
|
const baseName = ext ? basename(filePath, ext) : basename(filePath)
|
||||||
|
const summaryPath = join(dirname(filePath), `${baseName}.txt`)
|
||||||
|
const snapshot = this.snapshot(1500)
|
||||||
|
const summaryText = this.buildSummaryText(merged, snapshot.activeSteps)
|
||||||
|
await writeFile(summaryPath, summaryText, 'utf8')
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
filePath,
|
||||||
|
summaryPath,
|
||||||
|
count: merged.length
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: String(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const exportCardDiagnosticsService = new ExportCardDiagnosticsService()
|
||||||
229
electron/services/exportContentStatsCacheService.ts
Normal file
229
electron/services/exportContentStatsCacheService.ts
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import { join, dirname } from 'path'
|
||||||
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
||||||
|
import { ConfigService } from './config'
|
||||||
|
|
||||||
|
const CACHE_VERSION = 1
|
||||||
|
const MAX_SCOPE_ENTRIES = 12
|
||||||
|
const MAX_SESSION_ENTRIES_PER_SCOPE = 6000
|
||||||
|
|
||||||
|
export interface ExportContentSessionStatsEntry {
|
||||||
|
updatedAt: number
|
||||||
|
hasAny: boolean
|
||||||
|
hasVoice: boolean
|
||||||
|
hasImage: boolean
|
||||||
|
hasVideo: boolean
|
||||||
|
hasEmoji: boolean
|
||||||
|
mediaReady: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportContentScopeStatsEntry {
|
||||||
|
updatedAt: number
|
||||||
|
sessions: Record<string, ExportContentSessionStatsEntry>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExportContentStatsStore {
|
||||||
|
version: number
|
||||||
|
scopes: Record<string, ExportContentScopeStatsEntry>
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNonNegativeInt(value: unknown): number | undefined {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
|
||||||
|
return Math.max(0, Math.floor(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBoolean(value: unknown, fallback = false): boolean {
|
||||||
|
if (typeof value === 'boolean') return value
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSessionStatsEntry(raw: unknown): ExportContentSessionStatsEntry | null {
|
||||||
|
if (!raw || typeof raw !== 'object') return null
|
||||||
|
const source = raw as Record<string, unknown>
|
||||||
|
const updatedAt = toNonNegativeInt(source.updatedAt)
|
||||||
|
if (updatedAt === undefined) return null
|
||||||
|
return {
|
||||||
|
updatedAt,
|
||||||
|
hasAny: toBoolean(source.hasAny, false),
|
||||||
|
hasVoice: toBoolean(source.hasVoice, false),
|
||||||
|
hasImage: toBoolean(source.hasImage, false),
|
||||||
|
hasVideo: toBoolean(source.hasVideo, false),
|
||||||
|
hasEmoji: toBoolean(source.hasEmoji, false),
|
||||||
|
mediaReady: toBoolean(source.mediaReady, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeScopeStatsEntry(raw: unknown): ExportContentScopeStatsEntry | null {
|
||||||
|
if (!raw || typeof raw !== 'object') return null
|
||||||
|
const source = raw as Record<string, unknown>
|
||||||
|
const updatedAt = toNonNegativeInt(source.updatedAt)
|
||||||
|
if (updatedAt === undefined) return null
|
||||||
|
|
||||||
|
const sessionsRaw = source.sessions
|
||||||
|
if (!sessionsRaw || typeof sessionsRaw !== 'object') {
|
||||||
|
return {
|
||||||
|
updatedAt,
|
||||||
|
sessions: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions: Record<string, ExportContentSessionStatsEntry> = {}
|
||||||
|
for (const [sessionId, entryRaw] of Object.entries(sessionsRaw as Record<string, unknown>)) {
|
||||||
|
const normalized = normalizeSessionStatsEntry(entryRaw)
|
||||||
|
if (!normalized) continue
|
||||||
|
sessions[sessionId] = normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
updatedAt,
|
||||||
|
sessions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneScope(scope: ExportContentScopeStatsEntry): ExportContentScopeStatsEntry {
|
||||||
|
return {
|
||||||
|
updatedAt: scope.updatedAt,
|
||||||
|
sessions: Object.fromEntries(
|
||||||
|
Object.entries(scope.sessions).map(([sessionId, entry]) => [sessionId, { ...entry }])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExportContentStatsCacheService {
|
||||||
|
private readonly cacheFilePath: string
|
||||||
|
private store: ExportContentStatsStore = {
|
||||||
|
version: CACHE_VERSION,
|
||||||
|
scopes: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(cacheBasePath?: string) {
|
||||||
|
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||||
|
? cacheBasePath
|
||||||
|
: ConfigService.getInstance().getCacheBasePath()
|
||||||
|
this.cacheFilePath = join(basePath, 'export-content-stats.json')
|
||||||
|
this.ensureCacheDir()
|
||||||
|
this.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureCacheDir(): void {
|
||||||
|
const dir = dirname(this.cacheFilePath)
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
mkdirSync(dir, { recursive: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private load(): void {
|
||||||
|
if (!existsSync(this.cacheFilePath)) return
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(this.cacheFilePath, 'utf8')
|
||||||
|
const parsed = JSON.parse(raw) as unknown
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = parsed as Record<string, unknown>
|
||||||
|
const scopesRaw = payload.scopes
|
||||||
|
if (!scopesRaw || typeof scopesRaw !== 'object') {
|
||||||
|
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopes: Record<string, ExportContentScopeStatsEntry> = {}
|
||||||
|
for (const [scopeKey, scopeRaw] of Object.entries(scopesRaw as Record<string, unknown>)) {
|
||||||
|
const normalizedScope = normalizeScopeStatsEntry(scopeRaw)
|
||||||
|
if (!normalizedScope) continue
|
||||||
|
scopes[scopeKey] = normalizedScope
|
||||||
|
}
|
||||||
|
|
||||||
|
this.store = {
|
||||||
|
version: CACHE_VERSION,
|
||||||
|
scopes
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('ExportContentStatsCacheService: 载入缓存失败', error)
|
||||||
|
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getScope(scopeKey: string): ExportContentScopeStatsEntry | undefined {
|
||||||
|
if (!scopeKey) return undefined
|
||||||
|
const rawScope = this.store.scopes[scopeKey]
|
||||||
|
if (!rawScope) return undefined
|
||||||
|
const normalizedScope = normalizeScopeStatsEntry(rawScope)
|
||||||
|
if (!normalizedScope) {
|
||||||
|
delete this.store.scopes[scopeKey]
|
||||||
|
this.persist()
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
this.store.scopes[scopeKey] = normalizedScope
|
||||||
|
return cloneScope(normalizedScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
setScope(scopeKey: string, scope: ExportContentScopeStatsEntry): void {
|
||||||
|
if (!scopeKey) return
|
||||||
|
const normalized = normalizeScopeStatsEntry(scope)
|
||||||
|
if (!normalized) return
|
||||||
|
this.store.scopes[scopeKey] = normalized
|
||||||
|
this.trimScope(scopeKey)
|
||||||
|
this.trimScopes()
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSession(scopeKey: string, sessionId: string): void {
|
||||||
|
if (!scopeKey || !sessionId) return
|
||||||
|
const scope = this.store.scopes[scopeKey]
|
||||||
|
if (!scope) return
|
||||||
|
if (!(sessionId in scope.sessions)) return
|
||||||
|
delete scope.sessions[sessionId]
|
||||||
|
if (Object.keys(scope.sessions).length === 0) {
|
||||||
|
delete this.store.scopes[scopeKey]
|
||||||
|
} else {
|
||||||
|
scope.updatedAt = Date.now()
|
||||||
|
}
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
clearScope(scopeKey: string): void {
|
||||||
|
if (!scopeKey) return
|
||||||
|
if (!this.store.scopes[scopeKey]) return
|
||||||
|
delete this.store.scopes[scopeKey]
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAll(): void {
|
||||||
|
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||||
|
try {
|
||||||
|
rmSync(this.cacheFilePath, { force: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('ExportContentStatsCacheService: 清理缓存失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private trimScope(scopeKey: string): void {
|
||||||
|
const scope = this.store.scopes[scopeKey]
|
||||||
|
if (!scope) return
|
||||||
|
|
||||||
|
const entries = Object.entries(scope.sessions)
|
||||||
|
if (entries.length <= MAX_SESSION_ENTRIES_PER_SCOPE) return
|
||||||
|
|
||||||
|
entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
|
||||||
|
scope.sessions = Object.fromEntries(entries.slice(0, MAX_SESSION_ENTRIES_PER_SCOPE))
|
||||||
|
}
|
||||||
|
|
||||||
|
private trimScopes(): void {
|
||||||
|
const scopeEntries = Object.entries(this.store.scopes)
|
||||||
|
if (scopeEntries.length <= MAX_SCOPE_ENTRIES) return
|
||||||
|
|
||||||
|
scopeEntries.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
|
||||||
|
this.store.scopes = Object.fromEntries(scopeEntries.slice(0, MAX_SCOPE_ENTRIES))
|
||||||
|
}
|
||||||
|
|
||||||
|
private persist(): void {
|
||||||
|
try {
|
||||||
|
this.ensureCacheDir()
|
||||||
|
writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('ExportContentStatsCacheService: 持久化缓存失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,83 +25,87 @@ body {
|
|||||||
|
|
||||||
.page {
|
.page {
|
||||||
max-width: 1080px;
|
max-width: 1080px;
|
||||||
margin: 32px auto 60px;
|
margin: 0 auto;
|
||||||
padding: 0 20px;
|
padding: 8px 20px;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
border-radius: var(--radius);
|
border-radius: 12px;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
|
||||||
padding: 24px;
|
padding: 12px 20px;
|
||||||
margin-bottom: 24px;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 24px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0 0 8px;
|
margin: 0;
|
||||||
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta {
|
.meta {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
display: flex;
|
display: inline;
|
||||||
flex-wrap: wrap;
|
margin-left: 12px;
|
||||||
gap: 12px;
|
}
|
||||||
|
|
||||||
|
.meta span {
|
||||||
|
margin-right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control label {
|
.controls input,
|
||||||
font-size: 13px;
|
.controls button {
|
||||||
color: var(--muted);
|
border-radius: 8px;
|
||||||
}
|
|
||||||
|
|
||||||
.control input,
|
|
||||||
.control select,
|
|
||||||
.control button {
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
padding: 10px 12px;
|
padding: 6px 10px;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control button {
|
.controls input[type="search"] {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls input[type="datetime-local"] {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.1s ease;
|
padding: 6px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control button:active {
|
.controls button:active {
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats {
|
.stats {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
display: flex;
|
margin-left: auto;
|
||||||
align-items: flex-end;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-list {
|
.message-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 18px;
|
gap: 12px;
|
||||||
|
padding: 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
@@ -182,6 +186,44 @@ body {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quoted-message {
|
||||||
|
border-left: 3px solid rgba(79, 70, 229, 0.35);
|
||||||
|
background: rgba(79, 70, 229, 0.06);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.sent .quoted-message {
|
||||||
|
background: rgba(37, 99, 235, 0.08);
|
||||||
|
border-left-color: rgba(37, 99, 235, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quoted-sender {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #374151;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quoted-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #4b5563;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-link-card {
|
||||||
|
color: #2563eb;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-link-card:hover {
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
.inline-emoji {
|
.inline-emoji {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
@@ -248,50 +290,11 @@ body {
|
|||||||
cursor: zoom-out;
|
cursor: zoom-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-theme="cloud-dancer"] {
|
|
||||||
--accent: #6b8cff;
|
|
||||||
--sent: #e0e7ff;
|
|
||||||
--received: #ffffff;
|
|
||||||
--border: #d8e0f7;
|
|
||||||
--bg: #f6f7fb;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-theme="corundum-blue"] {
|
|
||||||
--accent: #2563eb;
|
|
||||||
--sent: #dbeafe;
|
|
||||||
--received: #ffffff;
|
|
||||||
--border: #c7d2fe;
|
|
||||||
--bg: #eef2ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-theme="kiwi-green"] {
|
|
||||||
--accent: #16a34a;
|
|
||||||
--sent: #dcfce7;
|
|
||||||
--received: #ffffff;
|
|
||||||
--border: #bbf7d0;
|
|
||||||
--bg: #f0fdf4;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-theme="spicy-red"] {
|
|
||||||
--accent: #e11d48;
|
|
||||||
--sent: #ffe4e6;
|
|
||||||
--received: #ffffff;
|
|
||||||
--border: #fecdd3;
|
|
||||||
--bg: #fff1f2;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-theme="teal-water"] {
|
|
||||||
--accent: #0f766e;
|
|
||||||
--sent: #ccfbf1;
|
|
||||||
--received: #ffffff;
|
|
||||||
--border: #99f6e4;
|
|
||||||
--bg: #f0fdfa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.highlight {
|
.highlight {
|
||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: 4px;
|
outline-offset: 4px;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
|
transition: outline-color 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
@@ -299,3 +302,30 @@ body[data-theme="teal-water"] {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Scroll Container */
|
||||||
|
.scroll-container {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg);
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-container::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-container::-webkit-scrollbar-thumb {
|
||||||
|
background: #c1c1c1;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-sentinel {
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,83 +25,87 @@ body {
|
|||||||
|
|
||||||
.page {
|
.page {
|
||||||
max-width: 1080px;
|
max-width: 1080px;
|
||||||
margin: 32px auto 60px;
|
margin: 0 auto;
|
||||||
padding: 0 20px;
|
padding: 8px 20px;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
border-radius: var(--radius);
|
border-radius: 12px;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
|
||||||
padding: 24px;
|
padding: 12px 20px;
|
||||||
margin-bottom: 24px;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 24px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0 0 8px;
|
margin: 0;
|
||||||
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta {
|
.meta {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
display: flex;
|
display: inline;
|
||||||
flex-wrap: wrap;
|
margin-left: 12px;
|
||||||
gap: 12px;
|
}
|
||||||
|
|
||||||
|
.meta span {
|
||||||
|
margin-right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control label {
|
.controls input,
|
||||||
font-size: 13px;
|
.controls button {
|
||||||
color: var(--muted);
|
border-radius: 8px;
|
||||||
}
|
|
||||||
|
|
||||||
.control input,
|
|
||||||
.control select,
|
|
||||||
.control button {
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
padding: 10px 12px;
|
padding: 6px 10px;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control button {
|
.controls input[type="search"] {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls input[type="datetime-local"] {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.1s ease;
|
padding: 6px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control button:active {
|
.controls button:active {
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats {
|
.stats {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
display: flex;
|
margin-left: auto;
|
||||||
align-items: flex-end;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-list {
|
.message-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 18px;
|
gap: 12px;
|
||||||
|
padding: 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
@@ -182,6 +186,44 @@ body {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quoted-message {
|
||||||
|
border-left: 3px solid rgba(79, 70, 229, 0.35);
|
||||||
|
background: rgba(79, 70, 229, 0.06);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.sent .quoted-message {
|
||||||
|
background: rgba(37, 99, 235, 0.08);
|
||||||
|
border-left-color: rgba(37, 99, 235, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quoted-sender {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #374151;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quoted-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #4b5563;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-link-card {
|
||||||
|
color: #2563eb;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-link-card:hover {
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
.inline-emoji {
|
.inline-emoji {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
@@ -248,50 +290,11 @@ body {
|
|||||||
cursor: zoom-out;
|
cursor: zoom-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-theme="cloud-dancer"] {
|
|
||||||
--accent: #6b8cff;
|
|
||||||
--sent: #e0e7ff;
|
|
||||||
--received: #ffffff;
|
|
||||||
--border: #d8e0f7;
|
|
||||||
--bg: #f6f7fb;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-theme="corundum-blue"] {
|
|
||||||
--accent: #2563eb;
|
|
||||||
--sent: #dbeafe;
|
|
||||||
--received: #ffffff;
|
|
||||||
--border: #c7d2fe;
|
|
||||||
--bg: #eef2ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-theme="kiwi-green"] {
|
|
||||||
--accent: #16a34a;
|
|
||||||
--sent: #dcfce7;
|
|
||||||
--received: #ffffff;
|
|
||||||
--border: #bbf7d0;
|
|
||||||
--bg: #f0fdf4;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-theme="spicy-red"] {
|
|
||||||
--accent: #e11d48;
|
|
||||||
--sent: #ffe4e6;
|
|
||||||
--received: #ffffff;
|
|
||||||
--border: #fecdd3;
|
|
||||||
--bg: #fff1f2;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-theme="teal-water"] {
|
|
||||||
--accent: #0f766e;
|
|
||||||
--sent: #ccfbf1;
|
|
||||||
--received: #ffffff;
|
|
||||||
--border: #99f6e4;
|
|
||||||
--bg: #f0fdfa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.highlight {
|
.highlight {
|
||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: 4px;
|
outline-offset: 4px;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
|
transition: outline-color 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
@@ -299,4 +302,32 @@ body[data-theme="teal-water"] {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Scroll Container */
|
||||||
|
.scroll-container {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg);
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-container::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-container::-webkit-scrollbar-thumb {
|
||||||
|
background: #c1c1c1;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-sentinel {
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
95
electron/services/exportRecordService.ts
Normal file
95
electron/services/exportRecordService.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export interface ExportRecord {
|
||||||
|
exportTime: number
|
||||||
|
format: string
|
||||||
|
messageCount: number
|
||||||
|
sourceLatestMessageTimestamp?: number
|
||||||
|
outputPath?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecordStore = Record<string, ExportRecord[]>
|
||||||
|
|
||||||
|
class ExportRecordService {
|
||||||
|
private filePath: string | null = null
|
||||||
|
private loaded = false
|
||||||
|
private store: RecordStore = {}
|
||||||
|
|
||||||
|
private resolveFilePath(): string {
|
||||||
|
if (this.filePath) return this.filePath
|
||||||
|
const userDataPath = app.getPath('userData')
|
||||||
|
fs.mkdirSync(userDataPath, { recursive: true })
|
||||||
|
this.filePath = path.join(userDataPath, 'weflow-export-records.json')
|
||||||
|
return this.filePath
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureLoaded(): void {
|
||||||
|
if (this.loaded) return
|
||||||
|
this.loaded = true
|
||||||
|
const filePath = this.resolveFilePath()
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(filePath)) return
|
||||||
|
const raw = fs.readFileSync(filePath, 'utf-8')
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
this.store = parsed as RecordStore
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.store = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private persist(): void {
|
||||||
|
try {
|
||||||
|
const filePath = this.resolveFilePath()
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(this.store), 'utf-8')
|
||||||
|
} catch {
|
||||||
|
// ignore persist errors to avoid blocking export flow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getLatestRecord(sessionId: string, format: string): ExportRecord | null {
|
||||||
|
this.ensureLoaded()
|
||||||
|
const records = this.store[sessionId]
|
||||||
|
if (!records || records.length === 0) return null
|
||||||
|
for (let i = records.length - 1; i >= 0; i--) {
|
||||||
|
const record = records[i]
|
||||||
|
if (record && record.format === format) return record
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
saveRecord(
|
||||||
|
sessionId: string,
|
||||||
|
format: string,
|
||||||
|
messageCount: number,
|
||||||
|
extra?: {
|
||||||
|
sourceLatestMessageTimestamp?: number
|
||||||
|
outputPath?: string
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
this.ensureLoaded()
|
||||||
|
const normalizedSessionId = String(sessionId || '').trim()
|
||||||
|
if (!normalizedSessionId) return
|
||||||
|
if (!this.store[normalizedSessionId]) {
|
||||||
|
this.store[normalizedSessionId] = []
|
||||||
|
}
|
||||||
|
const list = this.store[normalizedSessionId]
|
||||||
|
list.push({
|
||||||
|
exportTime: Date.now(),
|
||||||
|
format,
|
||||||
|
messageCount,
|
||||||
|
sourceLatestMessageTimestamp: extra?.sourceLatestMessageTimestamp,
|
||||||
|
outputPath: extra?.outputPath
|
||||||
|
})
|
||||||
|
// keep the latest 30 records per session
|
||||||
|
if (list.length > 30) {
|
||||||
|
this.store[normalizedSessionId] = list.slice(-30)
|
||||||
|
}
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const exportRecordService = new ExportRecordService()
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
204
electron/services/groupMyMessageCountCacheService.ts
Normal file
204
electron/services/groupMyMessageCountCacheService.ts
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import { join, dirname } from 'path'
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
||||||
|
import { ConfigService } from './config'
|
||||||
|
|
||||||
|
const CACHE_VERSION = 1
|
||||||
|
const MAX_GROUP_ENTRIES_PER_SCOPE = 3000
|
||||||
|
const MAX_SCOPE_ENTRIES = 12
|
||||||
|
|
||||||
|
export interface GroupMyMessageCountCacheEntry {
|
||||||
|
updatedAt: number
|
||||||
|
messageCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupMyMessageCountScopeMap {
|
||||||
|
[chatroomId: string]: GroupMyMessageCountCacheEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupMyMessageCountCacheStore {
|
||||||
|
version: number
|
||||||
|
scopes: Record<string, GroupMyMessageCountScopeMap>
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNonNegativeInt(value: unknown): number | undefined {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
|
||||||
|
return Math.max(0, Math.floor(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEntry(raw: unknown): GroupMyMessageCountCacheEntry | null {
|
||||||
|
if (!raw || typeof raw !== 'object') return null
|
||||||
|
const source = raw as Record<string, unknown>
|
||||||
|
const updatedAt = toNonNegativeInt(source.updatedAt)
|
||||||
|
const messageCount = toNonNegativeInt(source.messageCount)
|
||||||
|
if (updatedAt === undefined || messageCount === undefined) return null
|
||||||
|
return {
|
||||||
|
updatedAt,
|
||||||
|
messageCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GroupMyMessageCountCacheService {
|
||||||
|
private readonly cacheFilePath: string
|
||||||
|
private store: GroupMyMessageCountCacheStore = {
|
||||||
|
version: CACHE_VERSION,
|
||||||
|
scopes: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(cacheBasePath?: string) {
|
||||||
|
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||||
|
? cacheBasePath
|
||||||
|
: ConfigService.getInstance().getCacheBasePath()
|
||||||
|
this.cacheFilePath = join(basePath, 'group-my-message-counts.json')
|
||||||
|
this.ensureCacheDir()
|
||||||
|
this.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureCacheDir(): void {
|
||||||
|
const dir = dirname(this.cacheFilePath)
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
mkdirSync(dir, { recursive: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private load(): void {
|
||||||
|
if (!existsSync(this.cacheFilePath)) return
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(this.cacheFilePath, 'utf8')
|
||||||
|
const parsed = JSON.parse(raw) as unknown
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = parsed as Record<string, unknown>
|
||||||
|
const scopesRaw = payload.scopes
|
||||||
|
if (!scopesRaw || typeof scopesRaw !== 'object') {
|
||||||
|
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopes: Record<string, GroupMyMessageCountScopeMap> = {}
|
||||||
|
for (const [scopeKey, scopeValue] of Object.entries(scopesRaw as Record<string, unknown>)) {
|
||||||
|
if (!scopeValue || typeof scopeValue !== 'object') continue
|
||||||
|
const normalizedScope: GroupMyMessageCountScopeMap = {}
|
||||||
|
for (const [chatroomId, entryRaw] of Object.entries(scopeValue as Record<string, unknown>)) {
|
||||||
|
const entry = normalizeEntry(entryRaw)
|
||||||
|
if (!entry) continue
|
||||||
|
normalizedScope[chatroomId] = entry
|
||||||
|
}
|
||||||
|
if (Object.keys(normalizedScope).length > 0) {
|
||||||
|
scopes[scopeKey] = normalizedScope
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.store = {
|
||||||
|
version: CACHE_VERSION,
|
||||||
|
scopes
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GroupMyMessageCountCacheService: 载入缓存失败', error)
|
||||||
|
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get(scopeKey: string, chatroomId: string): GroupMyMessageCountCacheEntry | undefined {
|
||||||
|
if (!scopeKey || !chatroomId) return undefined
|
||||||
|
const scope = this.store.scopes[scopeKey]
|
||||||
|
if (!scope) return undefined
|
||||||
|
const entry = normalizeEntry(scope[chatroomId])
|
||||||
|
if (!entry) {
|
||||||
|
delete scope[chatroomId]
|
||||||
|
if (Object.keys(scope).length === 0) {
|
||||||
|
delete this.store.scopes[scopeKey]
|
||||||
|
}
|
||||||
|
this.persist()
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
set(scopeKey: string, chatroomId: string, entry: GroupMyMessageCountCacheEntry): void {
|
||||||
|
if (!scopeKey || !chatroomId) return
|
||||||
|
const normalized = normalizeEntry(entry)
|
||||||
|
if (!normalized) return
|
||||||
|
|
||||||
|
if (!this.store.scopes[scopeKey]) {
|
||||||
|
this.store.scopes[scopeKey] = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = this.store.scopes[scopeKey][chatroomId]
|
||||||
|
if (existing && existing.updatedAt > normalized.updatedAt) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.store.scopes[scopeKey][chatroomId] = normalized
|
||||||
|
this.trimScope(scopeKey)
|
||||||
|
this.trimScopes()
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(scopeKey: string, chatroomId: string): void {
|
||||||
|
if (!scopeKey || !chatroomId) return
|
||||||
|
const scope = this.store.scopes[scopeKey]
|
||||||
|
if (!scope) return
|
||||||
|
if (!(chatroomId in scope)) return
|
||||||
|
delete scope[chatroomId]
|
||||||
|
if (Object.keys(scope).length === 0) {
|
||||||
|
delete this.store.scopes[scopeKey]
|
||||||
|
}
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
clearScope(scopeKey: string): void {
|
||||||
|
if (!scopeKey) return
|
||||||
|
if (!this.store.scopes[scopeKey]) return
|
||||||
|
delete this.store.scopes[scopeKey]
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAll(): void {
|
||||||
|
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||||
|
try {
|
||||||
|
rmSync(this.cacheFilePath, { force: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GroupMyMessageCountCacheService: 清理缓存失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private trimScope(scopeKey: string): void {
|
||||||
|
const scope = this.store.scopes[scopeKey]
|
||||||
|
if (!scope) return
|
||||||
|
const entries = Object.entries(scope)
|
||||||
|
if (entries.length <= MAX_GROUP_ENTRIES_PER_SCOPE) return
|
||||||
|
entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
|
||||||
|
const trimmed: GroupMyMessageCountScopeMap = {}
|
||||||
|
for (const [chatroomId, entry] of entries.slice(0, MAX_GROUP_ENTRIES_PER_SCOPE)) {
|
||||||
|
trimmed[chatroomId] = entry
|
||||||
|
}
|
||||||
|
this.store.scopes[scopeKey] = trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
private trimScopes(): void {
|
||||||
|
const scopeEntries = Object.entries(this.store.scopes)
|
||||||
|
if (scopeEntries.length <= MAX_SCOPE_ENTRIES) return
|
||||||
|
scopeEntries.sort((a, b) => {
|
||||||
|
const aUpdatedAt = Math.max(...Object.values(a[1]).map((entry) => entry.updatedAt), 0)
|
||||||
|
const bUpdatedAt = Math.max(...Object.values(b[1]).map((entry) => entry.updatedAt), 0)
|
||||||
|
return bUpdatedAt - aUpdatedAt
|
||||||
|
})
|
||||||
|
|
||||||
|
const trimmedScopes: Record<string, GroupMyMessageCountScopeMap> = {}
|
||||||
|
for (const [scopeKey, scopeMap] of scopeEntries.slice(0, MAX_SCOPE_ENTRIES)) {
|
||||||
|
trimmedScopes[scopeKey] = scopeMap
|
||||||
|
}
|
||||||
|
this.store.scopes = trimmedScopes
|
||||||
|
}
|
||||||
|
|
||||||
|
private persist(): void {
|
||||||
|
try {
|
||||||
|
writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GroupMyMessageCountCacheService: 保存缓存失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1844
electron/services/httpService.ts
Normal file
1844
electron/services/httpService.ts
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -6,36 +6,60 @@ type PreloadImagePayload = {
|
|||||||
imageDatName?: string
|
imageDatName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PreloadOptions = {
|
||||||
|
allowDecrypt?: boolean
|
||||||
|
allowCacheIndex?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
type PreloadTask = PreloadImagePayload & {
|
type PreloadTask = PreloadImagePayload & {
|
||||||
key: string
|
key: string
|
||||||
|
allowDecrypt: boolean
|
||||||
|
allowCacheIndex: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ImagePreloadService {
|
export class ImagePreloadService {
|
||||||
private queue: PreloadTask[] = []
|
private queue: PreloadTask[] = []
|
||||||
private pending = new Set<string>()
|
private pending = new Set<string>()
|
||||||
private active = 0
|
private activeCache = 0
|
||||||
private readonly maxConcurrent = 2
|
private activeDecrypt = 0
|
||||||
|
private readonly maxCacheConcurrent = 8
|
||||||
|
private readonly maxDecryptConcurrent = 2
|
||||||
|
private readonly maxQueueSize = 320
|
||||||
|
|
||||||
enqueue(payloads: PreloadImagePayload[]): void {
|
enqueue(payloads: PreloadImagePayload[], options?: PreloadOptions): void {
|
||||||
if (!Array.isArray(payloads) || payloads.length === 0) return
|
if (!Array.isArray(payloads) || payloads.length === 0) return
|
||||||
|
const allowDecrypt = options?.allowDecrypt !== false
|
||||||
|
const allowCacheIndex = options?.allowCacheIndex !== false
|
||||||
for (const payload of payloads) {
|
for (const payload of payloads) {
|
||||||
|
if (!allowDecrypt && this.queue.length >= this.maxQueueSize) break
|
||||||
const cacheKey = payload.imageMd5 || payload.imageDatName
|
const cacheKey = payload.imageMd5 || payload.imageDatName
|
||||||
if (!cacheKey) continue
|
if (!cacheKey) continue
|
||||||
const key = `${payload.sessionId || 'unknown'}|${cacheKey}`
|
const key = `${payload.sessionId || 'unknown'}|${cacheKey}`
|
||||||
if (this.pending.has(key)) continue
|
if (this.pending.has(key)) continue
|
||||||
this.pending.add(key)
|
this.pending.add(key)
|
||||||
this.queue.push({ ...payload, key })
|
this.queue.push({ ...payload, key, allowDecrypt, allowCacheIndex })
|
||||||
}
|
}
|
||||||
this.processQueue()
|
this.processQueue()
|
||||||
}
|
}
|
||||||
|
|
||||||
private processQueue(): void {
|
private processQueue(): void {
|
||||||
while (this.active < this.maxConcurrent && this.queue.length > 0) {
|
while (this.queue.length > 0) {
|
||||||
const task = this.queue.shift()
|
const taskIndex = this.queue.findIndex((task) => (
|
||||||
|
task.allowDecrypt
|
||||||
|
? this.activeDecrypt < this.maxDecryptConcurrent
|
||||||
|
: this.activeCache < this.maxCacheConcurrent
|
||||||
|
))
|
||||||
|
if (taskIndex < 0) return
|
||||||
|
|
||||||
|
const task = this.queue.splice(taskIndex, 1)[0]
|
||||||
if (!task) return
|
if (!task) return
|
||||||
this.active += 1
|
|
||||||
|
if (task.allowDecrypt) this.activeDecrypt += 1
|
||||||
|
else this.activeCache += 1
|
||||||
|
|
||||||
void this.handleTask(task).finally(() => {
|
void this.handleTask(task).finally(() => {
|
||||||
this.active -= 1
|
if (task.allowDecrypt) this.activeDecrypt = Math.max(0, this.activeDecrypt - 1)
|
||||||
|
else this.activeCache = Math.max(0, this.activeCache - 1)
|
||||||
this.pending.delete(task.key)
|
this.pending.delete(task.key)
|
||||||
this.processQueue()
|
this.processQueue()
|
||||||
})
|
})
|
||||||
@@ -49,9 +73,12 @@ export class ImagePreloadService {
|
|||||||
const cached = await imageDecryptService.resolveCachedImage({
|
const cached = await imageDecryptService.resolveCachedImage({
|
||||||
sessionId: task.sessionId,
|
sessionId: task.sessionId,
|
||||||
imageMd5: task.imageMd5,
|
imageMd5: task.imageMd5,
|
||||||
imageDatName: task.imageDatName
|
imageDatName: task.imageDatName,
|
||||||
|
disableUpdateCheck: !task.allowDecrypt,
|
||||||
|
allowCacheIndex: task.allowCacheIndex
|
||||||
})
|
})
|
||||||
if (cached.success) return
|
if (cached.success) return
|
||||||
|
if (!task.allowDecrypt) return
|
||||||
await imageDecryptService.decryptImage({
|
await imageDecryptService.decryptImage({
|
||||||
sessionId: task.sessionId,
|
sessionId: task.sessionId,
|
||||||
imageMd5: task.imageMd5,
|
imageMd5: task.imageMd5,
|
||||||
|
|||||||
882
electron/services/insightService.ts
Normal file
882
electron/services/insightService.ts
Normal file
@@ -0,0 +1,882 @@
|
|||||||
|
/**
|
||||||
|
* insightService.ts
|
||||||
|
*
|
||||||
|
* AI 见解后台服务:
|
||||||
|
* 1. 监听 DB 变更事件(debounce 500ms 防抖,避免开机/重连时爆发大量事件阻塞主线程)
|
||||||
|
* 2. 沉默联系人扫描(独立 setInterval,每 4 小时一次)
|
||||||
|
* 3. 触发后拉取真实聊天上下文(若用户授权),组装 prompt 调用单一 AI 模型
|
||||||
|
* 4. 输出 ≤80 字见解,通过现有 showNotification 弹出右下角通知
|
||||||
|
*
|
||||||
|
* 设计原则:
|
||||||
|
* - 不引入任何额外 npm 依赖,使用 Node 原生 https 模块调用 OpenAI 兼容 API
|
||||||
|
* - 所有失败静默处理,不影响主流程
|
||||||
|
* - 当日触发记录(sessionId + 时间列表)随 prompt 一起发送,让模型自行判断是否克制
|
||||||
|
*/
|
||||||
|
|
||||||
|
import https from 'https'
|
||||||
|
import http from 'http'
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { URL } from 'url'
|
||||||
|
import { app, Notification } from 'electron'
|
||||||
|
import { ConfigService } from './config'
|
||||||
|
import { chatService, ChatSession, Message } from './chatService'
|
||||||
|
|
||||||
|
// ─── 常量 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DB 变更防抖延迟(毫秒)。
|
||||||
|
* 设为 2s:微信写库通常是批量操作,500ms 过短会在开机/重连时产生大量连续触发。
|
||||||
|
*/
|
||||||
|
const DB_CHANGE_DEBOUNCE_MS = 2000
|
||||||
|
|
||||||
|
/** 首次沉默扫描延迟(毫秒),避免启动期间抢占资源 */
|
||||||
|
const SILENCE_SCAN_INITIAL_DELAY_MS = 3 * 60 * 1000
|
||||||
|
|
||||||
|
/** 单次 API 请求超时(毫秒) */
|
||||||
|
const API_TIMEOUT_MS = 45_000
|
||||||
|
|
||||||
|
/** 沉默天数阈值默认值 */
|
||||||
|
const DEFAULT_SILENCE_DAYS = 3
|
||||||
|
const INSIGHT_CONFIG_KEYS = new Set([
|
||||||
|
'aiInsightEnabled',
|
||||||
|
'aiInsightScanIntervalHours',
|
||||||
|
'dbPath',
|
||||||
|
'decryptKey',
|
||||||
|
'myWxid'
|
||||||
|
])
|
||||||
|
|
||||||
|
// ─── 类型 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface TodayTriggerRecord {
|
||||||
|
/** 该会话今日触发的时间戳列表(毫秒) */
|
||||||
|
timestamps: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 桌面日志 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将日志同时输出到 console 和桌面上的 weflow-insight.log 文件。
|
||||||
|
* 文件名带当天日期,每天自动换一个新文件,旧文件保留。
|
||||||
|
*/
|
||||||
|
function insightLog(level: 'INFO' | 'WARN' | 'ERROR', message: string): void {
|
||||||
|
const now = new Date()
|
||||||
|
const dateStr = now.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }).replace(/\//g, '-')
|
||||||
|
const timeStr = now.toLocaleTimeString('zh-CN', { hour12: false })
|
||||||
|
const line = `[${dateStr} ${timeStr}] [${level}] ${message}\n`
|
||||||
|
|
||||||
|
// 同步到 console
|
||||||
|
if (level === 'ERROR' || level === 'WARN') {
|
||||||
|
console.warn(`[InsightService] ${message}`)
|
||||||
|
} else {
|
||||||
|
console.log(`[InsightService] ${message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 异步写入桌面日志文件,避免同步磁盘 I/O 阻塞 Electron 主线程事件循环
|
||||||
|
try {
|
||||||
|
const desktopPath = app.getPath('desktop')
|
||||||
|
const logFile = path.join(desktopPath, `weflow-insight-${dateStr}.log`)
|
||||||
|
fs.appendFile(logFile, line, 'utf-8', () => { /* 失败静默处理 */ })
|
||||||
|
} catch {
|
||||||
|
// getPath 失败时静默处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 工具函数 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绝对拼接 baseUrl 与路径,避免 Node.js URL 相对路径陷阱。
|
||||||
|
*
|
||||||
|
* 例如:
|
||||||
|
* baseUrl = "https://api.ohmygpt.com/v1"
|
||||||
|
* path = "/chat/completions"
|
||||||
|
* 结果为 "https://api.ohmygpt.com/v1/chat/completions"
|
||||||
|
*
|
||||||
|
* 如果 baseUrl 末尾没有斜杠,直接用字符串拼接(而非 new URL(path, base)),
|
||||||
|
* 因为 new URL("chat/completions", "https://api.example.com/v1") 会错误地
|
||||||
|
* 丢弃 v1,变成 https://api.example.com/chat/completions。
|
||||||
|
*/
|
||||||
|
function buildApiUrl(baseUrl: string, path: string): string {
|
||||||
|
const base = baseUrl.replace(/\/+$/, '') // 去掉末尾斜杠
|
||||||
|
const suffix = path.startsWith('/') ? path : `/${path}`
|
||||||
|
return `${base}${suffix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStartOfDay(date: Date = new Date()): number {
|
||||||
|
const d = new Date(date)
|
||||||
|
d.setHours(0, 0, 0, 0)
|
||||||
|
return d.getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(ts: number): string {
|
||||||
|
return new Date(ts).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用 OpenAI 兼容 API(非流式),返回模型第一条消息内容。
|
||||||
|
* 使用 Node 原生 https/http 模块,无需任何第三方 SDK。
|
||||||
|
*/
|
||||||
|
function callApi(
|
||||||
|
apiBaseUrl: string,
|
||||||
|
apiKey: string,
|
||||||
|
model: string,
|
||||||
|
messages: Array<{ role: string; content: string }>,
|
||||||
|
timeoutMs: number = API_TIMEOUT_MS
|
||||||
|
): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions')
|
||||||
|
let urlObj: URL
|
||||||
|
try {
|
||||||
|
urlObj = new URL(endpoint)
|
||||||
|
} catch (e) {
|
||||||
|
reject(new Error(`无效的 API URL: ${endpoint}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = JSON.stringify({
|
||||||
|
model,
|
||||||
|
messages,
|
||||||
|
max_tokens: 200,
|
||||||
|
temperature: 0.7,
|
||||||
|
stream: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
hostname: urlObj.hostname,
|
||||||
|
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
|
||||||
|
path: urlObj.pathname + urlObj.search,
|
||||||
|
method: 'POST' as const,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(body).toString(),
|
||||||
|
Authorization: `Bearer ${apiKey}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isHttps = urlObj.protocol === 'https:'
|
||||||
|
const requestFn = isHttps ? https.request : http.request
|
||||||
|
const req = requestFn(options, (res) => {
|
||||||
|
let data = ''
|
||||||
|
res.on('data', (chunk) => { data += chunk })
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data)
|
||||||
|
const content = parsed?.choices?.[0]?.message?.content
|
||||||
|
if (typeof content === 'string' && content.trim()) {
|
||||||
|
resolve(content.trim())
|
||||||
|
} else {
|
||||||
|
reject(new Error(`API 返回格式异常: ${data.slice(0, 200)}`))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
reject(new Error(`JSON 解析失败: ${data.slice(0, 200)}`))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
req.setTimeout(timeoutMs, () => {
|
||||||
|
req.destroy()
|
||||||
|
reject(new Error('API 请求超时'))
|
||||||
|
})
|
||||||
|
|
||||||
|
req.on('error', (e) => reject(e))
|
||||||
|
req.write(body)
|
||||||
|
req.end()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── InsightService 主类 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class InsightService {
|
||||||
|
private readonly config: ConfigService
|
||||||
|
|
||||||
|
/** DB 变更防抖定时器 */
|
||||||
|
private dbDebounceTimer: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
|
/** 沉默扫描定时器 */
|
||||||
|
private silenceScanTimer: NodeJS.Timeout | null = null
|
||||||
|
private silenceInitialDelayTimer: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
|
/** 是否正在处理中(防重入) */
|
||||||
|
private processing = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当日触发记录:sessionId -> TodayTriggerRecord
|
||||||
|
* 每天 00:00 之后自动重置(通过检查日期实现)
|
||||||
|
*/
|
||||||
|
private todayTriggers: Map<string, TodayTriggerRecord> = new Map()
|
||||||
|
private todayDate = getStartOfDay()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 活跃分析冷却记录:sessionId -> 上次分析时间戳(毫秒)
|
||||||
|
* 同一会话 2 小时内不重复触发活跃分析,防止 DB 频繁变更时爆量调用 API。
|
||||||
|
*/
|
||||||
|
private lastActivityAnalysis: Map<string, number> = new Map()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跟踪每个会话上次见到的最新消息时间戳,用于判断是否有真正的新消息。
|
||||||
|
* sessionId -> lastMessageTimestamp(秒,与微信 DB 保持一致)
|
||||||
|
*/
|
||||||
|
private lastSeenTimestamp: Map<string, number> = new Map()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本地会话快照缓存,避免 analyzeRecentActivity 在每次 DB 变更时都做全量读取。
|
||||||
|
* 首次调用时填充,此后只在沉默扫描里刷新(沉默扫描间隔更长,更合适做全量刷新)。
|
||||||
|
*/
|
||||||
|
private sessionCache: ChatSession[] | null = null
|
||||||
|
/** sessionCache 最后刷新时间戳(ms),超过 15 分钟强制重新拉取 */
|
||||||
|
private sessionCacheAt = 0
|
||||||
|
/** 缓存 TTL 设为 15 分钟,大幅减少 connect() + getSessions() 调用频率 */
|
||||||
|
private static readonly SESSION_CACHE_TTL_MS = 15 * 60 * 1000
|
||||||
|
/** 数据库是否已连接(避免重复调用 chatService.connect()) */
|
||||||
|
private dbConnected = false
|
||||||
|
|
||||||
|
private started = false
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.config = ConfigService.getInstance()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 公开 API ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (this.started) return
|
||||||
|
this.started = true
|
||||||
|
void this.refreshConfiguration('startup')
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.started = false
|
||||||
|
this.clearTimers()
|
||||||
|
this.clearRuntimeCache()
|
||||||
|
this.processing = false
|
||||||
|
insightLog('INFO', '已停止')
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleConfigChanged(key: string): Promise<void> {
|
||||||
|
const normalizedKey = String(key || '').trim()
|
||||||
|
if (!INSIGHT_CONFIG_KEYS.has(normalizedKey)) return
|
||||||
|
|
||||||
|
// 数据库相关配置变更后,丢弃缓存并强制下次重连
|
||||||
|
if (normalizedKey === 'dbPath' || normalizedKey === 'decryptKey' || normalizedKey === 'myWxid') {
|
||||||
|
this.clearRuntimeCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.refreshConfiguration(`config:${normalizedKey}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleConfigCleared(): void {
|
||||||
|
this.clearTimers()
|
||||||
|
this.clearRuntimeCache()
|
||||||
|
this.processing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshConfiguration(_reason: string): Promise<void> {
|
||||||
|
if (!this.started) return
|
||||||
|
if (!this.isEnabled()) {
|
||||||
|
this.clearTimers()
|
||||||
|
this.clearRuntimeCache()
|
||||||
|
this.processing = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.scheduleSilenceScan()
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearRuntimeCache(): void {
|
||||||
|
this.dbConnected = false
|
||||||
|
this.sessionCache = null
|
||||||
|
this.sessionCacheAt = 0
|
||||||
|
this.lastActivityAnalysis.clear()
|
||||||
|
this.lastSeenTimestamp.clear()
|
||||||
|
this.todayTriggers.clear()
|
||||||
|
this.todayDate = getStartOfDay()
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearTimers(): void {
|
||||||
|
if (this.dbDebounceTimer !== null) {
|
||||||
|
clearTimeout(this.dbDebounceTimer)
|
||||||
|
this.dbDebounceTimer = null
|
||||||
|
}
|
||||||
|
if (this.silenceScanTimer !== null) {
|
||||||
|
clearTimeout(this.silenceScanTimer)
|
||||||
|
this.silenceScanTimer = null
|
||||||
|
}
|
||||||
|
if (this.silenceInitialDelayTimer !== null) {
|
||||||
|
clearTimeout(this.silenceInitialDelayTimer)
|
||||||
|
this.silenceInitialDelayTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 由 main.ts 在 addDbMonitorListener 回调中调用。
|
||||||
|
* 加入 2s 防抖,防止开机/重连时大量事件并发阻塞主线程。
|
||||||
|
* 如果当前正在处理中,直接忽略此次事件(不创建新的 timer),避免 timer 堆积。
|
||||||
|
*/
|
||||||
|
handleDbMonitorChange(_type: string, _json: string): void {
|
||||||
|
if (!this.started) return
|
||||||
|
if (!this.isEnabled()) return
|
||||||
|
// 正在处理时忽略新事件,避免 timer 堆积
|
||||||
|
if (this.processing) return
|
||||||
|
|
||||||
|
if (this.dbDebounceTimer !== null) {
|
||||||
|
clearTimeout(this.dbDebounceTimer)
|
||||||
|
}
|
||||||
|
this.dbDebounceTimer = setTimeout(() => {
|
||||||
|
this.dbDebounceTimer = null
|
||||||
|
void this.analyzeRecentActivity()
|
||||||
|
}, DB_CHANGE_DEBOUNCE_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测<><E6B58B><EFBFBD> API 连接,返回 { success, message }。
|
||||||
|
* 供设置页"测试连接"按钮调用。
|
||||||
|
*/
|
||||||
|
async testConnection(): Promise<{ success: boolean; message: string }> {
|
||||||
|
const apiBaseUrl = this.config.get('aiInsightApiBaseUrl') as string
|
||||||
|
const apiKey = this.config.get('aiInsightApiKey') as string
|
||||||
|
const model = (this.config.get('aiInsightApiModel') as string) || 'gpt-4o-mini'
|
||||||
|
|
||||||
|
if (!apiBaseUrl || !apiKey) {
|
||||||
|
return { success: false, message: '请先填写 API 地址和 API Key' }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await callApi(
|
||||||
|
apiBaseUrl,
|
||||||
|
apiKey,
|
||||||
|
model,
|
||||||
|
[{ role: 'user', content: '请回复"连接成功"四个字。' }],
|
||||||
|
15_000
|
||||||
|
)
|
||||||
|
return { success: true, message: `连接成功,模型回复:${result.slice(0, 50)}` }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, message: `连接失败:${(e as Error).message}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强制立即对最近一个私聊会话触发一次见解(忽略冷却,用于测试)。
|
||||||
|
* 返回触发结果描述,供设置页展示。
|
||||||
|
*/
|
||||||
|
async triggerTest(): Promise<{ success: boolean; message: string }> {
|
||||||
|
insightLog('INFO', '手动触发测试见解...')
|
||||||
|
const apiBaseUrl = this.config.get('aiInsightApiBaseUrl') as string
|
||||||
|
const apiKey = this.config.get('aiInsightApiKey') as string
|
||||||
|
if (!apiBaseUrl || !apiKey) {
|
||||||
|
return { success: false, message: '请先填写 API 地址和 Key' }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const connectResult = await chatService.connect()
|
||||||
|
if (!connectResult.success) {
|
||||||
|
return { success: false, message: '数据库连接失败,请先在"数据库连接"页完成配置' }
|
||||||
|
}
|
||||||
|
const sessionsResult = await chatService.getSessions()
|
||||||
|
if (!sessionsResult.success || !sessionsResult.sessions || sessionsResult.sessions.length === 0) {
|
||||||
|
return { success: false, message: '未找到任何会话,请确认数据库已正确连接' }
|
||||||
|
}
|
||||||
|
// 找第一个允许的私聊
|
||||||
|
const session = (sessionsResult.sessions as ChatSession[]).find((s) => {
|
||||||
|
const id = s.username?.trim() || ''
|
||||||
|
return id && !id.endsWith('@chatroom') && !id.toLowerCase().includes('placeholder') && this.isSessionAllowed(id)
|
||||||
|
})
|
||||||
|
if (!session) {
|
||||||
|
return { success: false, message: '未找到任何私聊会话(若已启用白名单,请检查是否有勾选的私聊)' }
|
||||||
|
}
|
||||||
|
const sessionId = session.username?.trim() || ''
|
||||||
|
const displayName = session.displayName || sessionId
|
||||||
|
insightLog('INFO', `测试目标会话:${displayName} (${sessionId})`)
|
||||||
|
await this.generateInsightForSession({
|
||||||
|
sessionId,
|
||||||
|
displayName,
|
||||||
|
triggerReason: 'activity'
|
||||||
|
})
|
||||||
|
return { success: true, message: `已向「${displayName}」发送测试见解,请查看右下角弹窗` }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, message: `测试失败:${(e as Error).message}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取今日触发统计(供设置页展示) */
|
||||||
|
getTodayStats(): { sessionId: string; count: number; times: string[] }[] {
|
||||||
|
this.resetIfNewDay()
|
||||||
|
const result: { sessionId: string; count: number; times: string[] }[] = []
|
||||||
|
for (const [sessionId, record] of this.todayTriggers.entries()) {
|
||||||
|
result.push({
|
||||||
|
sessionId,
|
||||||
|
count: record.timestamps.length,
|
||||||
|
times: record.timestamps.map(formatTimestamp)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 私有方法 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private isEnabled(): boolean {
|
||||||
|
return this.config.get('aiInsightEnabled') === true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断某个会话是否允许触发见解。
|
||||||
|
* 若白名单未启用,则所有私聊会话均允许;
|
||||||
|
* 若白名单已启用,则只有在白名单中的会话才允许。
|
||||||
|
*/
|
||||||
|
private isSessionAllowed(sessionId: string): boolean {
|
||||||
|
const whitelistEnabled = this.config.get('aiInsightWhitelistEnabled') as boolean
|
||||||
|
if (!whitelistEnabled) return true
|
||||||
|
const whitelist = (this.config.get('aiInsightWhitelist') as string[]) || []
|
||||||
|
return whitelist.includes(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取会话列表,优先使用缓存(15 分钟 TTL)。
|
||||||
|
* 缓存命中时完全跳过数据库访问,避免频繁 connect() + getSessions() 消耗 CPU。
|
||||||
|
* forceRefresh=true 时强制重新拉取(仅用于沉默扫描等低频场景)。
|
||||||
|
*/
|
||||||
|
private async getSessionsCached(forceRefresh = false): Promise<ChatSession[]> {
|
||||||
|
const now = Date.now()
|
||||||
|
// 缓存命中:直接返回,零数据库操作
|
||||||
|
if (
|
||||||
|
!forceRefresh &&
|
||||||
|
this.sessionCache !== null &&
|
||||||
|
now - this.sessionCacheAt < InsightService.SESSION_CACHE_TTL_MS
|
||||||
|
) {
|
||||||
|
return this.sessionCache
|
||||||
|
}
|
||||||
|
// 缓存未命中或强制刷新:连接数据库并拉取
|
||||||
|
try {
|
||||||
|
// 只在首次或强制刷新时调用 connect(),避免重复建立连接
|
||||||
|
if (!this.dbConnected || forceRefresh) {
|
||||||
|
const connectResult = await chatService.connect()
|
||||||
|
if (!connectResult.success) {
|
||||||
|
insightLog('WARN', '数据库连接失败,使用旧缓存')
|
||||||
|
return this.sessionCache ?? []
|
||||||
|
}
|
||||||
|
this.dbConnected = true
|
||||||
|
}
|
||||||
|
const result = await chatService.getSessions()
|
||||||
|
if (result.success && result.sessions) {
|
||||||
|
this.sessionCache = result.sessions as ChatSession[]
|
||||||
|
this.sessionCacheAt = now
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
insightLog('WARN', `获取会话缓存失败: ${(e as Error).message}`)
|
||||||
|
// 连接可能已断开,下次强制重连
|
||||||
|
this.dbConnected = false
|
||||||
|
}
|
||||||
|
return this.sessionCache ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetIfNewDay(): void {
|
||||||
|
const todayStart = getStartOfDay()
|
||||||
|
if (todayStart > this.todayDate) {
|
||||||
|
this.todayDate = todayStart
|
||||||
|
this.todayTriggers.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录触发并返回该会话今日所有触发时间(用于组装 prompt)。
|
||||||
|
*/
|
||||||
|
private recordTrigger(sessionId: string): string[] {
|
||||||
|
this.resetIfNewDay()
|
||||||
|
const existing = this.todayTriggers.get(sessionId) ?? { timestamps: [] }
|
||||||
|
existing.timestamps.push(Date.now())
|
||||||
|
this.todayTriggers.set(sessionId, existing)
|
||||||
|
return existing.timestamps.map(formatTimestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取今日全局已触发次数(所有会话合计),用于 prompt 中告知模<E79FA5><E6A8A1><EFBFBD>全局上下文。
|
||||||
|
*/
|
||||||
|
private getTodayTotalTriggerCount(): number {
|
||||||
|
this.resetIfNewDay()
|
||||||
|
let total = 0
|
||||||
|
for (const record of this.todayTriggers.values()) {
|
||||||
|
total += record.timestamps.length
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 沉默联系人扫描 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private scheduleSilenceScan(): void {
|
||||||
|
this.clearTimers()
|
||||||
|
if (!this.started || !this.isEnabled()) return
|
||||||
|
|
||||||
|
// 等待扫描完成后再安排下一次,避免并发堆积
|
||||||
|
const scheduleNext = () => {
|
||||||
|
if (!this.started || !this.isEnabled()) return
|
||||||
|
const intervalHours = (this.config.get('aiInsightScanIntervalHours') as number) || 4
|
||||||
|
const intervalMs = Math.max(0.1, intervalHours) * 60 * 60 * 1000
|
||||||
|
insightLog('INFO', `下次沉默扫描将在 ${intervalHours} 小时后执行`)
|
||||||
|
this.silenceScanTimer = setTimeout(async () => {
|
||||||
|
this.silenceScanTimer = null
|
||||||
|
await this.runSilenceScan()
|
||||||
|
scheduleNext()
|
||||||
|
}, intervalMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.silenceInitialDelayTimer = setTimeout(async () => {
|
||||||
|
this.silenceInitialDelayTimer = null
|
||||||
|
await this.runSilenceScan()
|
||||||
|
scheduleNext()
|
||||||
|
}, SILENCE_SCAN_INITIAL_DELAY_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runSilenceScan(): Promise<void> {
|
||||||
|
if (!this.isEnabled()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.processing) {
|
||||||
|
insightLog('INFO', '沉默扫描:正在处理中,跳过本次')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.processing = true
|
||||||
|
insightLog('INFO', '开始沉默联系人扫描...')
|
||||||
|
try {
|
||||||
|
const silenceDays = (this.config.get('aiInsightSilenceDays') as number) || DEFAULT_SILENCE_DAYS
|
||||||
|
const thresholdMs = silenceDays * 24 * 60 * 60 * 1000
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
insightLog('INFO', `沉默阈值:${silenceDays} 天`)
|
||||||
|
|
||||||
|
// 沉默扫描间隔较长,强制刷新缓存以获取最新数据
|
||||||
|
const sessions = await this.getSessionsCached(true)
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
insightLog('WARN', '获取会话列表失败,跳过沉默扫描')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
insightLog('INFO', `共 ${sessions.length} 个会话,开始过滤...`)
|
||||||
|
|
||||||
|
let silentCount = 0
|
||||||
|
for (const session of sessions) {
|
||||||
|
if (!this.isEnabled()) return
|
||||||
|
const sessionId = session.username?.trim() || ''
|
||||||
|
if (!sessionId || sessionId.endsWith('@chatroom')) continue
|
||||||
|
if (sessionId.toLowerCase().includes('placeholder')) continue
|
||||||
|
if (!this.isSessionAllowed(sessionId)) continue
|
||||||
|
|
||||||
|
const lastTimestamp = (session.lastTimestamp || 0) * 1000
|
||||||
|
if (!lastTimestamp || lastTimestamp <= 0) continue
|
||||||
|
|
||||||
|
const silentMs = now - lastTimestamp
|
||||||
|
if (silentMs < thresholdMs) continue
|
||||||
|
|
||||||
|
silentCount++
|
||||||
|
const silentDays = Math.floor(silentMs / (24 * 60 * 60 * 1000))
|
||||||
|
insightLog('INFO', `发现沉默联系人:${session.displayName || sessionId},已沉默 ${silentDays} 天`)
|
||||||
|
|
||||||
|
await this.generateInsightForSession({
|
||||||
|
sessionId,
|
||||||
|
displayName: session.displayName || session.username,
|
||||||
|
triggerReason: 'silence',
|
||||||
|
silentDays
|
||||||
|
})
|
||||||
|
}
|
||||||
|
insightLog('INFO', `沉默扫描完成,共发现 ${silentCount} 个沉默联系人`)
|
||||||
|
} catch (e) {
|
||||||
|
insightLog('ERROR', `沉默扫描出错: ${(e as Error).message}`)
|
||||||
|
} finally {
|
||||||
|
this.processing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 活跃会话分析 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 DB 变更防抖后执行,分析最近活跃的会话。
|
||||||
|
*
|
||||||
|
* 触发条件(必须同时满足):
|
||||||
|
* 1. 会话有真正的新消息(lastTimestamp 比上次见到的更新)
|
||||||
|
* 2. 该会话距上次活跃分析已超过冷却期
|
||||||
|
*
|
||||||
|
* 白名单启用时:直接使用白名单里的 sessionId,完全跳过 getSessions()。
|
||||||
|
* 白名单未启用时:从缓存拉取全量会话后过滤私聊。
|
||||||
|
*/
|
||||||
|
private async analyzeRecentActivity(): Promise<void> {
|
||||||
|
if (!this.isEnabled()) return
|
||||||
|
if (this.processing) return
|
||||||
|
|
||||||
|
this.processing = true
|
||||||
|
try {
|
||||||
|
const now = Date.now()
|
||||||
|
const cooldownMinutes = (this.config.get('aiInsightCooldownMinutes') as number) ?? 120
|
||||||
|
const cooldownMs = cooldownMinutes * 60 * 1000
|
||||||
|
const whitelistEnabled = this.config.get('aiInsightWhitelistEnabled') as boolean
|
||||||
|
const whitelist = (this.config.get('aiInsightWhitelist') as string[]) || []
|
||||||
|
|
||||||
|
// 白名单启用且有勾选项时,直接用白名单 sessionId,无需查数据库全量会话列表。
|
||||||
|
// 通过拉取该会话最新 1 条消息时间戳判断是否真正有新消息,开销极低。
|
||||||
|
if (whitelistEnabled && whitelist.length > 0) {
|
||||||
|
// 确保数据库已连接(首次时连接,之后复用)
|
||||||
|
if (!this.dbConnected) {
|
||||||
|
const connectResult = await chatService.connect()
|
||||||
|
if (!connectResult.success) return
|
||||||
|
this.dbConnected = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sessionId of whitelist) {
|
||||||
|
if (!sessionId || sessionId.endsWith('@chatroom')) continue
|
||||||
|
|
||||||
|
// 冷却期检查(先过滤,减少不必要的 DB 查询)
|
||||||
|
if (cooldownMs > 0) {
|
||||||
|
const lastAnalysis = this.lastActivityAnalysis.get(sessionId) ?? 0
|
||||||
|
if (cooldownMs - (now - lastAnalysis) > 0) continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拉取最新 1 条消息,用时间戳判断是否有新消息,避免全量 getSessions()
|
||||||
|
try {
|
||||||
|
const msgsResult = await chatService.getLatestMessages(sessionId, 1)
|
||||||
|
if (!msgsResult.success || !msgsResult.messages || msgsResult.messages.length === 0) continue
|
||||||
|
|
||||||
|
const latestMsg = msgsResult.messages[0]
|
||||||
|
const latestTs = Number(latestMsg.createTime) || 0
|
||||||
|
const lastSeen = this.lastSeenTimestamp.get(sessionId) ?? 0
|
||||||
|
|
||||||
|
if (latestTs <= lastSeen) continue // 没有新消息
|
||||||
|
this.lastSeenTimestamp.set(sessionId, latestTs)
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
insightLog('INFO', `白名单会话 ${sessionId} 有新消息,准备生成见解...`)
|
||||||
|
this.lastActivityAnalysis.set(sessionId, now)
|
||||||
|
|
||||||
|
// displayName 使用白名单 sessionId,generateInsightForSession 内部会从上下文里获取真实名称
|
||||||
|
await this.generateInsightForSession({
|
||||||
|
sessionId,
|
||||||
|
displayName: sessionId,
|
||||||
|
triggerReason: 'activity'
|
||||||
|
})
|
||||||
|
break // 每次最多处理 1 个会话
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 白名单未启用:需要拉取全量会话列表,从中过滤私聊
|
||||||
|
const sessions = await this.getSessionsCached()
|
||||||
|
if (sessions.length === 0) return
|
||||||
|
|
||||||
|
const privateSessions = sessions.filter((s) => {
|
||||||
|
const id = s.username?.trim() || ''
|
||||||
|
return id && !id.endsWith('@chatroom') && !id.toLowerCase().includes('placeholder')
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const session of privateSessions.slice(0, 10)) {
|
||||||
|
const sessionId = session.username?.trim() || ''
|
||||||
|
if (!sessionId) continue
|
||||||
|
|
||||||
|
const currentTimestamp = session.lastTimestamp || 0
|
||||||
|
const lastSeen = this.lastSeenTimestamp.get(sessionId) ?? 0
|
||||||
|
if (currentTimestamp <= lastSeen) continue
|
||||||
|
this.lastSeenTimestamp.set(sessionId, currentTimestamp)
|
||||||
|
|
||||||
|
if (cooldownMs > 0) {
|
||||||
|
const lastAnalysis = this.lastActivityAnalysis.get(sessionId) ?? 0
|
||||||
|
if (cooldownMs - (now - lastAnalysis) > 0) continue
|
||||||
|
}
|
||||||
|
|
||||||
|
insightLog('INFO', `${session.displayName || sessionId} 有新消息,准备生成见解...`)
|
||||||
|
this.lastActivityAnalysis.set(sessionId, now)
|
||||||
|
|
||||||
|
await this.generateInsightForSession({
|
||||||
|
sessionId,
|
||||||
|
displayName: session.displayName || session.username,
|
||||||
|
triggerReason: 'activity'
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
insightLog('ERROR', `活跃分析出错: ${(e as Error).message}`)
|
||||||
|
} finally {
|
||||||
|
this.processing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 核心见解生成 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async generateInsightForSession(params: {
|
||||||
|
sessionId: string
|
||||||
|
displayName: string
|
||||||
|
triggerReason: 'activity' | 'silence'
|
||||||
|
silentDays?: number
|
||||||
|
}): Promise<void> {
|
||||||
|
const { sessionId, displayName, triggerReason, silentDays } = params
|
||||||
|
if (!sessionId) return
|
||||||
|
if (!this.isEnabled()) return
|
||||||
|
|
||||||
|
const apiBaseUrl = this.config.get('aiInsightApiBaseUrl') as string
|
||||||
|
const apiKey = this.config.get('aiInsightApiKey') as string
|
||||||
|
const model = (this.config.get('aiInsightApiModel') as string) || 'gpt-4o-mini'
|
||||||
|
const allowContext = this.config.get('aiInsightAllowContext') as boolean
|
||||||
|
const contextCount = (this.config.get('aiInsightContextCount') as number) || 40
|
||||||
|
|
||||||
|
insightLog('INFO', `generateInsightForSession: sessionId=${sessionId}, reason=${triggerReason}, contextCount=${contextCount}, api=${apiBaseUrl ? '已配置' : '未配置'}`)
|
||||||
|
|
||||||
|
if (!apiBaseUrl || !apiKey) {
|
||||||
|
insightLog('WARN', 'API 地址或 Key 未配置,跳过见解生成')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 构建 prompt ─────────────<E29480><E29480><EFBFBD>───────────────────────────────<E29480><E29480><EFBFBD>────────────
|
||||||
|
|
||||||
|
// 今日触发统计(让模型具备时间与克制感)
|
||||||
|
const sessionTriggerTimes = this.recordTrigger(sessionId)
|
||||||
|
const totalTodayTriggers = this.getTodayTotalTriggerCount()
|
||||||
|
|
||||||
|
let contextSection = ''
|
||||||
|
if (allowContext) {
|
||||||
|
try {
|
||||||
|
const msgsResult = await chatService.getLatestMessages(sessionId, contextCount)
|
||||||
|
if (msgsResult.success && msgsResult.messages && msgsResult.messages.length > 0) {
|
||||||
|
const messages: Message[] = msgsResult.messages
|
||||||
|
const msgLines = messages.map((m) => {
|
||||||
|
const sender = m.isSend === 1 ? '我' : (displayName || sessionId)
|
||||||
|
const content = m.rawContent || m.parsedContent || '[非文字消息]'
|
||||||
|
const time = new Date(Number(m.createTime) * 1000).toLocaleString('zh-CN')
|
||||||
|
return `[${time}] ${sender}:${content}`
|
||||||
|
})
|
||||||
|
contextSection = `\n\n近期对话记录(最近 ${msgLines.length} 条):\n${msgLines.join('\n')}`
|
||||||
|
insightLog('INFO', `已加载 ${msgLines.length} 条上下文消息`)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
insightLog('WARN', `拉取上下文失败: ${(e as Error).message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 默认 system prompt(稳定内容,有利于 provider 端 prompt cache 命中)────
|
||||||
|
const DEFAULT_SYSTEM_PROMPT = `你是用户的私人关系观察助手,名叫"见解"。你的任务是主动提供有价值的观察和建议。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
1. 必须给出见解。基于聊天记录分析对方情绪、话题趋势、关系动态,或给出回复建议、聊天话题推荐。
|
||||||
|
2. 控制在 80 字以内,直接、具体、一针见血。不要废话。
|
||||||
|
3. 输出纯文本,不使用 Markdown。
|
||||||
|
4. 只有在完全没有任何可说的内容时(比如对话只有一条"嗯"),才回复"SKIP"。绝大多数情况下你应该输出见解。`
|
||||||
|
|
||||||
|
// 优先使用用户自定义 prompt,为空则使用默认值
|
||||||
|
const customPrompt = (this.config.get('aiInsightSystemPrompt') as string) || ''
|
||||||
|
const systemPrompt = customPrompt.trim() || DEFAULT_SYSTEM_PROMPT
|
||||||
|
|
||||||
|
// 可变的上下文统计信息放在 user message 里,保持 system prompt 稳定不变
|
||||||
|
// 这样 provider 端(Anthropic/OpenAI)能最大化命中 prompt cache,降低费用
|
||||||
|
const triggerDesc =
|
||||||
|
triggerReason === 'silence'
|
||||||
|
? `你已经 ${silentDays} 天没有和「${displayName}」聊天了。`
|
||||||
|
: `你最近和「${displayName}」有新的聊天动态。`
|
||||||
|
|
||||||
|
const todayStatsDesc =
|
||||||
|
sessionTriggerTimes.length > 1
|
||||||
|
? `今天你已经针对「${displayName}」收到过 ${sessionTriggerTimes.length - 1} 条见解(时间:${sessionTriggerTimes.slice(0, -1).join('、')}),请适当克制。`
|
||||||
|
: `今天你还没有针对「${displayName}」发出过见解。`
|
||||||
|
|
||||||
|
const globalStatsDesc = `今天全部联系人合计已触发 ${totalTodayTriggers} 条见解。`
|
||||||
|
|
||||||
|
const userPrompt = `触发原因:${triggerDesc}
|
||||||
|
时间统计:${todayStatsDesc} ${globalStatsDesc}${contextSection}
|
||||||
|
|
||||||
|
请给出你的见解(≤80字):`
|
||||||
|
|
||||||
|
const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions')
|
||||||
|
insightLog('INFO', `准备调用 API: ${endpoint},模型: ${model}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await callApi(
|
||||||
|
apiBaseUrl,
|
||||||
|
apiKey,
|
||||||
|
model,
|
||||||
|
[
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: userPrompt }
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
insightLog('INFO', `API 返回原文: ${result.slice(0, 150)}`)
|
||||||
|
|
||||||
|
// 模型主动选择跳过
|
||||||
|
if (result.trim().toUpperCase() === 'SKIP' || result.trim().startsWith('SKIP')) {
|
||||||
|
insightLog('INFO', `模型选择跳过 ${displayName}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!this.isEnabled()) return
|
||||||
|
|
||||||
|
const insight = result.slice(0, 120)
|
||||||
|
const notifTitle = `见解 · ${displayName}`
|
||||||
|
|
||||||
|
insightLog('INFO', `推送通知 → ${displayName}: ${insight}`)
|
||||||
|
|
||||||
|
// 渠道一:Electron 原生系统通知
|
||||||
|
if (Notification.isSupported()) {
|
||||||
|
const notif = new Notification({ title: notifTitle, body: insight, silent: false })
|
||||||
|
notif.show()
|
||||||
|
} else {
|
||||||
|
insightLog('WARN', '当前系统不支持原生通知')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渠道二:Telegram Bot 推送(可选)
|
||||||
|
const telegramEnabled = this.config.get('aiInsightTelegramEnabled') as boolean
|
||||||
|
if (telegramEnabled) {
|
||||||
|
const telegramToken = (this.config.get('aiInsightTelegramToken') as string) || ''
|
||||||
|
const telegramChatIds = (this.config.get('aiInsightTelegramChatIds') as string) || ''
|
||||||
|
if (telegramToken && telegramChatIds) {
|
||||||
|
const chatIds = telegramChatIds.split(',').map((s) => s.trim()).filter(Boolean)
|
||||||
|
const telegramText = `【WeFlow】 ${notifTitle}\n\n${insight}`
|
||||||
|
for (const chatId of chatIds) {
|
||||||
|
this.sendTelegram(telegramToken, chatId, telegramText).catch((e) => {
|
||||||
|
insightLog('WARN', `Telegram 推送失败 (chatId=${chatId}): ${(e as Error).message}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
insightLog('WARN', 'Telegram 已启用但 Token 或 Chat ID 未填写,跳过')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
insightLog('INFO', `已为 ${displayName} 推送见解`)
|
||||||
|
} catch (e) {
|
||||||
|
insightLog('ERROR', `API 调用失败 (${displayName}): ${(e as Error).message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过 Telegram Bot API 发送消息。
|
||||||
|
* 使用 Node 原生 https 模块,无需第三方依赖。
|
||||||
|
*/
|
||||||
|
private sendTelegram(token: string, chatId: string, text: string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const body = JSON.stringify({ chat_id: chatId, text, parse_mode: 'HTML' })
|
||||||
|
const options = {
|
||||||
|
hostname: 'api.telegram.org',
|
||||||
|
port: 443,
|
||||||
|
path: `/bot${token}/sendMessage`,
|
||||||
|
method: 'POST' as const,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(body).toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const req = https.request(options, (res) => {
|
||||||
|
let data = ''
|
||||||
|
res.on('data', (chunk) => { data += chunk })
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data)
|
||||||
|
if (parsed.ok) {
|
||||||
|
resolve()
|
||||||
|
} else {
|
||||||
|
reject(new Error(parsed.description || '未知错误'))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
reject(new Error(`响应解析失败: ${data.slice(0, 100)}`))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
req.setTimeout(15_000, () => { req.destroy(); reject(new Error('Telegram 请求超时')) })
|
||||||
|
req.on('error', reject)
|
||||||
|
req.write(body)
|
||||||
|
req.end()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const insightService = new InsightService()
|
||||||
127
electron/services/isaac64.ts
Normal file
127
electron/services/isaac64.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* ISAAC-64: A fast cryptographic PRNG
|
||||||
|
* Re-implemented in TypeScript using BigInt for 64-bit support.
|
||||||
|
* Used for WeChat Channels/SNS video decryption.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class Isaac64 {
|
||||||
|
private mm = new BigUint64Array(256);
|
||||||
|
private aa = 0n;
|
||||||
|
private bb = 0n;
|
||||||
|
private cc = 0n;
|
||||||
|
private randrsl = new BigUint64Array(256);
|
||||||
|
private randcnt = 0;
|
||||||
|
private static readonly MASK = 0xFFFFFFFFFFFFFFFFn;
|
||||||
|
|
||||||
|
constructor(seed: number | string | bigint) {
|
||||||
|
const seedBig = BigInt(seed);
|
||||||
|
// 通常单密钥初始化是将密钥放在第一个槽位,其余清零(或者按某种规律填充)
|
||||||
|
// 这里我们尝试仅设置第一个槽位,这在很多 WASM 移植版本中更为常见
|
||||||
|
this.randrsl.fill(0n);
|
||||||
|
this.randrsl[0] = seedBig;
|
||||||
|
this.init(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private init(flag: boolean) {
|
||||||
|
let a: bigint, b: bigint, c: bigint, d: bigint, e: bigint, f: bigint, g: bigint, h: bigint;
|
||||||
|
a = b = c = d = e = f = g = h = 0x9e3779b97f4a7c15n;
|
||||||
|
|
||||||
|
const mix = () => {
|
||||||
|
a = (a - e) & Isaac64.MASK; f ^= (h >> 9n); h = (h + a) & Isaac64.MASK;
|
||||||
|
b = (b - f) & Isaac64.MASK; g ^= (a << 9n) & Isaac64.MASK; a = (a + b) & Isaac64.MASK;
|
||||||
|
c = (c - g) & Isaac64.MASK; h ^= (b >> 23n); b = (b + c) & Isaac64.MASK;
|
||||||
|
d = (d - h) & Isaac64.MASK; a ^= (c << 15n) & Isaac64.MASK; c = (c + d) & Isaac64.MASK;
|
||||||
|
e = (e - a) & Isaac64.MASK; b ^= (d >> 14n); d = (d + e) & Isaac64.MASK;
|
||||||
|
f = (f - b) & Isaac64.MASK; c ^= (e << 20n) & Isaac64.MASK; e = (e + f) & Isaac64.MASK;
|
||||||
|
g = (g - c) & Isaac64.MASK; d ^= (f >> 17n); f = (f + g) & Isaac64.MASK;
|
||||||
|
h = (h - d) & Isaac64.MASK; e ^= (g << 14n) & Isaac64.MASK; g = (g + h) & Isaac64.MASK;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < 4; i++) mix();
|
||||||
|
|
||||||
|
for (let i = 0; i < 256; i += 8) {
|
||||||
|
if (flag) {
|
||||||
|
a = (a + this.randrsl[i]) & Isaac64.MASK;
|
||||||
|
b = (b + this.randrsl[i + 1]) & Isaac64.MASK;
|
||||||
|
c = (c + this.randrsl[i + 2]) & Isaac64.MASK;
|
||||||
|
d = (d + this.randrsl[i + 3]) & Isaac64.MASK;
|
||||||
|
e = (e + this.randrsl[i + 4]) & Isaac64.MASK;
|
||||||
|
f = (f + this.randrsl[i + 5]) & Isaac64.MASK;
|
||||||
|
g = (g + this.randrsl[i + 6]) & Isaac64.MASK;
|
||||||
|
h = (h + this.randrsl[i + 7]) & Isaac64.MASK;
|
||||||
|
}
|
||||||
|
mix();
|
||||||
|
this.mm[i] = a; this.mm[i + 1] = b; this.mm[i + 2] = c; this.mm[i + 3] = d;
|
||||||
|
this.mm[i + 4] = e; this.mm[i + 5] = f; this.mm[i + 6] = g; this.mm[i + 7] = h;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flag) {
|
||||||
|
for (let i = 0; i < 256; i += 8) {
|
||||||
|
a = (a + this.mm[i]) & Isaac64.MASK;
|
||||||
|
b = (b + this.mm[i + 1]) & Isaac64.MASK;
|
||||||
|
c = (c + this.mm[i + 2]) & Isaac64.MASK;
|
||||||
|
d = (d + this.mm[i + 3]) & Isaac64.MASK;
|
||||||
|
e = (e + this.mm[i + 4]) & Isaac64.MASK;
|
||||||
|
f = (f + this.mm[i + 5]) & Isaac64.MASK;
|
||||||
|
g = (g + this.mm[i + 6]) & Isaac64.MASK;
|
||||||
|
h = (h + this.mm[i + 7]) & Isaac64.MASK;
|
||||||
|
mix();
|
||||||
|
this.mm[i] = a; this.mm[i + 1] = b; this.mm[i + 2] = c; this.mm[i + 3] = d;
|
||||||
|
this.mm[i + 4] = e; this.mm[i + 5] = f; this.mm[i + 6] = g; this.mm[i + 7] = h;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isaac64();
|
||||||
|
this.randcnt = 256;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isaac64() {
|
||||||
|
this.cc = (this.cc + 1n) & Isaac64.MASK;
|
||||||
|
this.bb = (this.bb + this.cc) & Isaac64.MASK;
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
let x = this.mm[i];
|
||||||
|
switch (i & 3) {
|
||||||
|
case 0: this.aa = (this.aa ^ (((this.aa << 21n) & Isaac64.MASK) ^ Isaac64.MASK)) & Isaac64.MASK; break;
|
||||||
|
case 1: this.aa = (this.aa ^ (this.aa >> 5n)) & Isaac64.MASK; break;
|
||||||
|
case 2: this.aa = (this.aa ^ ((this.aa << 12n) & Isaac64.MASK)) & Isaac64.MASK; break;
|
||||||
|
case 3: this.aa = (this.aa ^ (this.aa >> 33n)) & Isaac64.MASK; break;
|
||||||
|
}
|
||||||
|
this.aa = (this.mm[(i + 128) & 255] + this.aa) & Isaac64.MASK;
|
||||||
|
const y = (this.mm[Number(x >> 3n) & 255] + this.aa + this.bb) & Isaac64.MASK;
|
||||||
|
this.mm[i] = y;
|
||||||
|
this.bb = (this.mm[Number(y >> 11n) & 255] + x) & Isaac64.MASK;
|
||||||
|
this.randrsl[i] = this.bb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getNext(): bigint {
|
||||||
|
if (this.randcnt === 0) {
|
||||||
|
this.isaac64();
|
||||||
|
this.randcnt = 256;
|
||||||
|
}
|
||||||
|
return this.randrsl[--this.randcnt];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a keystream where each 64-bit block is Big-Endian.
|
||||||
|
* This matches WeChat's behavior (Reverse index order + byte reversal).
|
||||||
|
*/
|
||||||
|
public generateKeystreamBE(size: number): Buffer {
|
||||||
|
const buffer = Buffer.allocUnsafe(size);
|
||||||
|
const fullBlocks = Math.floor(size / 8);
|
||||||
|
|
||||||
|
for (let i = 0; i < fullBlocks; i++) {
|
||||||
|
buffer.writeBigUInt64BE(this.getNext(), i * 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = size % 8;
|
||||||
|
if (remaining > 0) {
|
||||||
|
const lastK = this.getNext();
|
||||||
|
const temp = Buffer.allocUnsafe(8);
|
||||||
|
temp.writeBigUInt64BE(lastK, 0);
|
||||||
|
temp.copy(buffer, fullBlocks * 8, 0, remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
374
electron/services/keyServiceLinux.ts
Normal file
374
electron/services/keyServiceLinux.ts
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||||
|
import { execFile, exec, spawn } from 'child_process'
|
||||||
|
import { promisify } from 'util'
|
||||||
|
import { createRequire } from 'module';
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile)
|
||||||
|
const execAsync = promisify(exec)
|
||||||
|
|
||||||
|
type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] }
|
||||||
|
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string }
|
||||||
|
|
||||||
|
export class KeyServiceLinux {
|
||||||
|
private sudo: any
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
try {
|
||||||
|
this.sudo = require('@vscode/sudo-prompt');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load @vscode/sudo-prompt', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getHelperPath(): string {
|
||||||
|
const isPackaged = app.isPackaged
|
||||||
|
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||||
|
const candidates: string[] = []
|
||||||
|
if (process.env.WX_KEY_HELPER_PATH) candidates.push(process.env.WX_KEY_HELPER_PATH)
|
||||||
|
if (isPackaged) {
|
||||||
|
candidates.push(join(process.resourcesPath, 'resources', 'key', 'linux', archDir, 'xkey_helper_linux'))
|
||||||
|
candidates.push(join(process.resourcesPath, 'resources', 'key', 'linux', 'x64', 'xkey_helper_linux'))
|
||||||
|
candidates.push(join(process.resourcesPath, 'resources', 'key', 'linux', 'xkey_helper_linux'))
|
||||||
|
candidates.push(join(process.resourcesPath, 'resources', 'xkey_helper_linux'))
|
||||||
|
candidates.push(join(process.resourcesPath, 'xkey_helper_linux'))
|
||||||
|
} else {
|
||||||
|
candidates.push(join(app.getAppPath(), 'resources', 'key', 'linux', archDir, 'xkey_helper_linux'))
|
||||||
|
candidates.push(join(app.getAppPath(), 'resources', 'key', 'linux', 'x64', 'xkey_helper_linux'))
|
||||||
|
candidates.push(join(app.getAppPath(), 'resources', 'key', 'linux', 'xkey_helper_linux'))
|
||||||
|
candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper_linux'))
|
||||||
|
candidates.push(join(process.cwd(), 'resources', 'key', 'linux', archDir, 'xkey_helper_linux'))
|
||||||
|
candidates.push(join(process.cwd(), 'resources', 'key', 'linux', 'x64', 'xkey_helper_linux'))
|
||||||
|
candidates.push(join(process.cwd(), 'resources', 'key', 'linux', 'xkey_helper_linux'))
|
||||||
|
candidates.push(join(app.getAppPath(), '..', 'Xkey', 'build', 'xkey_helper_linux'))
|
||||||
|
}
|
||||||
|
for (const p of candidates) {
|
||||||
|
if (existsSync(p)) return p
|
||||||
|
}
|
||||||
|
throw new Error('找不到 xkey_helper_linux,请检查路径')
|
||||||
|
}
|
||||||
|
|
||||||
|
public async autoGetDbKey(
|
||||||
|
timeoutMs = 60_000,
|
||||||
|
onStatus?: (message: string, level: number) => void
|
||||||
|
): Promise<DbKeyResult> {
|
||||||
|
try {
|
||||||
|
// 1. 构造一个包含常用系统命令路径的环境变量,防止打包后找不到命令
|
||||||
|
const envWithPath = {
|
||||||
|
...process.env,
|
||||||
|
PATH: `${process.env.PATH || ''}:/bin:/usr/bin:/sbin:/usr/sbin:/usr/local/bin`
|
||||||
|
};
|
||||||
|
|
||||||
|
onStatus?.('正在尝试结束当前微信进程...', 0)
|
||||||
|
console.log('[Debug] 开始执行进程清理逻辑...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout, stderr } = await execAsync('killall -9 wechat wechat-bin xwechat', { env: envWithPath });
|
||||||
|
console.log(`[Debug] killall 成功退出. stdout: ${stdout}, stderr: ${stderr}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
// 命令如果没找到进程通常会返回 code 1,这也是正常的,但我们需要记录下来
|
||||||
|
console.log(`[Debug] killall 报错或未找到进程: ${err.message}`);
|
||||||
|
|
||||||
|
// Fallback: 尝试使用 pkill 兜底
|
||||||
|
try {
|
||||||
|
console.log('[Debug] 尝试使用备用命令 pkill...');
|
||||||
|
await execAsync('pkill -9 -x "wechat|wechat-bin|xwechat"', { env: envWithPath });
|
||||||
|
console.log('[Debug] pkill 执行完成');
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log(`[Debug] pkill 报错或未找到进程: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 稍微等待进程完全退出
|
||||||
|
await new Promise(r => setTimeout(r, 1000))
|
||||||
|
|
||||||
|
onStatus?.('正在尝试拉起微信...', 0)
|
||||||
|
|
||||||
|
const cleanEnv = { ...process.env };
|
||||||
|
delete cleanEnv.ELECTRON_RUN_AS_NODE;
|
||||||
|
delete cleanEnv.ELECTRON_NO_ATTACH_CONSOLE;
|
||||||
|
delete cleanEnv.APPDIR;
|
||||||
|
delete cleanEnv.APPIMAGE;
|
||||||
|
|
||||||
|
const wechatBins = [
|
||||||
|
'wechat',
|
||||||
|
'wechat-bin',
|
||||||
|
'xwechat',
|
||||||
|
'/opt/wechat/wechat',
|
||||||
|
'/usr/bin/wechat',
|
||||||
|
'/opt/apps/com.tencent.wechat/files/wechat'
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const binName of wechatBins) {
|
||||||
|
try {
|
||||||
|
const child = spawn(binName, [], {
|
||||||
|
detached: true,
|
||||||
|
stdio: 'ignore',
|
||||||
|
env: cleanEnv
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', (err) => {
|
||||||
|
console.log(`[Debug] 拉起 ${binName} 失败:`, err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.unref();
|
||||||
|
console.log(`[Debug] 尝试拉起 ${binName} 完毕`);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log(`[Debug] 尝试拉起 ${binName} 发生异常:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onStatus?.('等待微信进程出现...', 0)
|
||||||
|
let pid = 0
|
||||||
|
for (let i = 0; i < 15; i++) { // 最多等 15 秒
|
||||||
|
await new Promise(r => setTimeout(r, 1000))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync('pidof wechat wechat-bin xwechat', { env: envWithPath });
|
||||||
|
const pids = stdout.trim().split(/\s+/).filter(p => p);
|
||||||
|
if (pids.length > 0) {
|
||||||
|
pid = parseInt(pids[0], 10);
|
||||||
|
console.log(`[Debug] 第 ${i + 1} 秒,通过 pidof 成功获取 PID: ${pid}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.log(`[Debug] 第 ${i + 1} 秒,pidof 失败: ${err.message.split('\n')[0]}`);
|
||||||
|
|
||||||
|
// Fallback: 使用 pgrep 兜底
|
||||||
|
try {
|
||||||
|
const { stdout: pgrepOut } = await execAsync('pgrep -x "wechat|wechat-bin|xwechat"', { env: envWithPath });
|
||||||
|
const pids = pgrepOut.trim().split(/\s+/).filter(p => p);
|
||||||
|
if (pids.length > 0) {
|
||||||
|
pid = parseInt(pids[0], 10);
|
||||||
|
console.log(`[Debug] 第 ${i + 1} 秒,通过 pgrep 成功获取 PID: ${pid}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log(`[Debug] 第 ${i + 1} 秒,pgrep 也失败: ${e.message.split('\n')[0]}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pid) {
|
||||||
|
const err = '未能自动启动微信,或获取PID失败,请查看控制台日志或手动启动并登录。'
|
||||||
|
onStatus?.(err, 2)
|
||||||
|
return { success: false, error: err }
|
||||||
|
}
|
||||||
|
|
||||||
|
onStatus?.(`捕获到微信 PID: ${pid},准备获取密钥...`, 0)
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 2000))
|
||||||
|
|
||||||
|
return await this.getDbKey(pid, onStatus)
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Debug] 自动获取流程彻底崩溃:', err);
|
||||||
|
const errMsg = '自动获取微信 PID 失败: ' + err.message
|
||||||
|
onStatus?.(errMsg, 2)
|
||||||
|
return { success: false, error: errMsg }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getDbKey(pid: number, onStatus?: (message: string, level: number) => void): Promise<DbKeyResult> {
|
||||||
|
try {
|
||||||
|
const helperPath = this.getHelperPath()
|
||||||
|
|
||||||
|
onStatus?.('正在扫描数据库基址...', 0)
|
||||||
|
const { stdout: scanOut } = await execFileAsync(helperPath, ['db_scan', pid.toString()])
|
||||||
|
const scanRes = JSON.parse(scanOut.trim())
|
||||||
|
|
||||||
|
if (!scanRes.success) {
|
||||||
|
const err = scanRes.result || '扫描失败,请确保微信已完全登录'
|
||||||
|
onStatus?.(err, 2)
|
||||||
|
return { success: false, error: err }
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetAddr = scanRes.target_addr
|
||||||
|
onStatus?.('基址扫描成功,正在请求管理员权限进行内存 Hook...', 0)
|
||||||
|
|
||||||
|
return await new Promise((resolve) => {
|
||||||
|
const options = { name: 'WeFlow' }
|
||||||
|
const command = `"${helperPath}" db_hook ${pid} ${targetAddr}`
|
||||||
|
|
||||||
|
this.sudo.exec(command, options, (error, stdout) => {
|
||||||
|
execAsync(`kill -CONT ${pid}`).catch(() => {})
|
||||||
|
if (error) {
|
||||||
|
onStatus?.('授权失败或被取消', 2)
|
||||||
|
resolve({ success: false, error: `授权失败或被取消: ${error.message}` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const hookRes = JSON.parse((stdout as string).trim())
|
||||||
|
if (hookRes.success) {
|
||||||
|
onStatus?.('密钥获取成功', 1)
|
||||||
|
resolve({ success: true, key: hookRes.key })
|
||||||
|
} else {
|
||||||
|
onStatus?.(hookRes.result, 2)
|
||||||
|
resolve({ success: false, error: hookRes.result })
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
onStatus?.('解析 Hook 结果失败', 2)
|
||||||
|
resolve({ success: false, error: '解析 Hook 结果失败' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
onStatus?.(err.message, 2)
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async autoGetImageKey(
|
||||||
|
accountPath?: string,
|
||||||
|
onProgress?: (msg: string) => void,
|
||||||
|
wxid?: string
|
||||||
|
): Promise<ImageKeyResult> {
|
||||||
|
try {
|
||||||
|
onProgress?.('正在初始化缓存扫描...');
|
||||||
|
const helperPath = this.getHelperPath()
|
||||||
|
const { stdout } = await execFileAsync(helperPath, ['image_local'])
|
||||||
|
const res = JSON.parse(stdout.trim())
|
||||||
|
if (!res.success) return { success: false, error: res.result }
|
||||||
|
|
||||||
|
const accounts = res.data.accounts || []
|
||||||
|
let account = accounts.find((a: any) => a.wxid === wxid)
|
||||||
|
if (!account && accounts.length > 0) account = accounts[0]
|
||||||
|
|
||||||
|
if (account && account.keys && account.keys.length > 0) {
|
||||||
|
onProgress?.(`已找到匹配的图片密钥 (wxid: ${account.wxid})`);
|
||||||
|
const keyObj = account.keys[0]
|
||||||
|
return { success: true, xorKey: keyObj.xorKey, aesKey: keyObj.aesKey }
|
||||||
|
}
|
||||||
|
return { success: false, error: '未在缓存中找到匹配的图片密钥' }
|
||||||
|
} catch (err: any) {
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async autoGetImageKeyByMemoryScan(
|
||||||
|
accountPath: string,
|
||||||
|
onProgress?: (msg: string) => void
|
||||||
|
): Promise<ImageKeyResult> {
|
||||||
|
try {
|
||||||
|
onProgress?.('正在查找模板文件...')
|
||||||
|
let result = await this._findTemplateData(accountPath, 32)
|
||||||
|
let { ciphertext, xorKey } = result
|
||||||
|
|
||||||
|
if (ciphertext && xorKey === null) {
|
||||||
|
onProgress?.('未找到有效密钥,尝试扫描更多文件...')
|
||||||
|
result = await this._findTemplateData(accountPath, 100)
|
||||||
|
xorKey = result.xorKey
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ciphertext) return { success: false, error: '未找到 V2 模板文件,请先在微信中查看几张图片' }
|
||||||
|
if (xorKey === null) return { success: false, error: '未能从模板文件中计算出有效的 XOR 密钥' }
|
||||||
|
|
||||||
|
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
|
||||||
|
|
||||||
|
// 2. 找微信 PID
|
||||||
|
const { stdout } = await execAsync('pidof wechat wechat-bin xwechat').catch(() => ({ stdout: '' }))
|
||||||
|
const pids = stdout.trim().split(/\s+/).filter(p => p)
|
||||||
|
if (pids.length === 0) return { success: false, error: '微信未运行,无法扫描内存' }
|
||||||
|
const pid = parseInt(pids[0], 10)
|
||||||
|
|
||||||
|
onProgress?.(`已找到微信进程 PID=${pid},正在提权扫描进程内存...`);
|
||||||
|
|
||||||
|
// 3. 将 Buffer 转换为 hex 传递给 helper
|
||||||
|
const ciphertextHex = ciphertext.toString('hex')
|
||||||
|
const helperPath = this.getHelperPath()
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`[Debug] 准备执行 Helper: ${helperPath} image_mem ${pid} ${ciphertextHex}`);
|
||||||
|
|
||||||
|
const { stdout: memOut, stderr } = await execFileAsync(helperPath, ['image_mem', pid.toString(), ciphertextHex])
|
||||||
|
|
||||||
|
console.log(`[Debug] Helper stdout: ${memOut}`);
|
||||||
|
if (stderr) {
|
||||||
|
console.warn(`[Debug] Helper stderr: ${stderr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!memOut || memOut.trim() === '') {
|
||||||
|
return { success: false, error: 'Helper 返回为空,请检查是否有足够的权限(如需sudo)读取进程内存。' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = JSON.parse(memOut.trim())
|
||||||
|
|
||||||
|
if (res.success) {
|
||||||
|
onProgress?.('内存扫描成功');
|
||||||
|
return { success: true, xorKey, aesKey: res.key }
|
||||||
|
}
|
||||||
|
return { success: false, error: res.result || '未知错误' }
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Debug] 执行或解析 Helper 时发生崩溃:', err);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `内存扫描失败: ${err.message}\nstdout: ${err.stdout || '无'}\nstderr: ${err.stderr || '无'}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
return { success: false, error: `内存扫描失败: ${err.message}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _findTemplateData(userDir: string, limit: number = 32): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> {
|
||||||
|
const V2_MAGIC = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
|
||||||
|
|
||||||
|
// 递归收集 *_t.dat 文件
|
||||||
|
const collect = (dir: string, results: string[], maxFiles: number) => {
|
||||||
|
if (results.length >= maxFiles) return
|
||||||
|
try {
|
||||||
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (results.length >= maxFiles) break
|
||||||
|
const full = join(dir, entry.name)
|
||||||
|
if (entry.isDirectory()) collect(full, results, maxFiles)
|
||||||
|
else if (entry.isFile() && entry.name.endsWith('_t.dat')) results.push(full)
|
||||||
|
}
|
||||||
|
} catch { /* 忽略无权限目录 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const files: string[] = []
|
||||||
|
collect(userDir, files, limit)
|
||||||
|
|
||||||
|
// 按修改时间降序
|
||||||
|
files.sort((a, b) => {
|
||||||
|
try { return statSync(b).mtimeMs - statSync(a).mtimeMs } catch { return 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
let ciphertext: Buffer | null = null
|
||||||
|
const tailCounts: Record<string, number> = {}
|
||||||
|
|
||||||
|
for (const f of files.slice(0, 32)) {
|
||||||
|
try {
|
||||||
|
const data = readFileSync(f)
|
||||||
|
if (data.length < 8) continue
|
||||||
|
|
||||||
|
// 统计末尾两字节用于 XOR 密钥
|
||||||
|
if (data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 2) {
|
||||||
|
const key = `${data[data.length - 2]}_${data[data.length - 1]}`
|
||||||
|
tailCounts[key] = (tailCounts[key] ?? 0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取密文(取第一个有效的)
|
||||||
|
if (!ciphertext && data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 0x1F) {
|
||||||
|
ciphertext = data.subarray(0xF, 0x1F)
|
||||||
|
}
|
||||||
|
} catch { /* 忽略 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算 XOR 密钥
|
||||||
|
let xorKey: number | null = null
|
||||||
|
let maxCount = 0
|
||||||
|
for (const [key, count] of Object.entries(tailCounts)) {
|
||||||
|
if (count > maxCount) {
|
||||||
|
maxCount = count
|
||||||
|
const [x, y] = key.split('_').map(Number)
|
||||||
|
const k = x ^ 0xFF
|
||||||
|
if (k === (y ^ 0xD9)) xorKey = k
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ciphertext, xorKey }
|
||||||
|
}
|
||||||
|
}
|
||||||
1241
electron/services/keyServiceMac.ts
Normal file
1241
electron/services/keyServiceMac.ts
Normal file
File diff suppressed because it is too large
Load Diff
174
electron/services/linuxNotificationService.ts
Normal file
174
electron/services/linuxNotificationService.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { Notification } from "electron";
|
||||||
|
import { avatarFileCache, AvatarFileCacheService } from "./avatarFileCacheService";
|
||||||
|
|
||||||
|
export interface LinuxNotificationData {
|
||||||
|
sessionId?: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
expireTimeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotificationCallback = (sessionId: string) => void;
|
||||||
|
|
||||||
|
let notificationCallbacks: NotificationCallback[] = [];
|
||||||
|
let notificationCounter = 1;
|
||||||
|
const activeNotifications: Map<number, Notification> = new Map();
|
||||||
|
const closeTimers: Map<number, NodeJS.Timeout> = new Map();
|
||||||
|
|
||||||
|
function nextNotificationId(): number {
|
||||||
|
const id = notificationCounter;
|
||||||
|
notificationCounter += 1;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearNotificationState(notificationId: number): void {
|
||||||
|
activeNotifications.delete(notificationId);
|
||||||
|
const timer = closeTimers.get(notificationId);
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
closeTimers.delete(notificationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerNotificationCallback(sessionId: string): void {
|
||||||
|
for (const callback of notificationCallbacks) {
|
||||||
|
try {
|
||||||
|
callback(sessionId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[LinuxNotification] Callback error:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function showLinuxNotification(
|
||||||
|
data: LinuxNotificationData,
|
||||||
|
): Promise<number | null> {
|
||||||
|
if (process.platform !== "linux") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Notification.isSupported()) {
|
||||||
|
console.warn("[LinuxNotification] Notification API is not supported");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let iconPath: string | undefined;
|
||||||
|
if (data.avatarUrl) {
|
||||||
|
iconPath = (await avatarFileCache.getAvatarPath(data.avatarUrl)) || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const notification = new Notification({
|
||||||
|
title: data.title,
|
||||||
|
body: data.content,
|
||||||
|
icon: iconPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
const notificationId = nextNotificationId();
|
||||||
|
activeNotifications.set(notificationId, notification);
|
||||||
|
|
||||||
|
notification.on("click", () => {
|
||||||
|
if (data.sessionId) {
|
||||||
|
triggerNotificationCallback(data.sessionId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
notification.on("close", () => {
|
||||||
|
clearNotificationState(notificationId);
|
||||||
|
});
|
||||||
|
|
||||||
|
notification.on("failed", (_, error) => {
|
||||||
|
console.error("[LinuxNotification] Notification failed:", error);
|
||||||
|
clearNotificationState(notificationId);
|
||||||
|
});
|
||||||
|
|
||||||
|
const expireTimeout = data.expireTimeout ?? 5000;
|
||||||
|
if (expireTimeout > 0) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const currentNotification = activeNotifications.get(notificationId);
|
||||||
|
if (currentNotification) {
|
||||||
|
currentNotification.close();
|
||||||
|
}
|
||||||
|
}, expireTimeout);
|
||||||
|
closeTimers.set(notificationId, timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
notification.show();
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[LinuxNotification] Shown notification ${notificationId}: ${data.title}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return notificationId;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[LinuxNotification] Failed to show notification:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeLinuxNotification(
|
||||||
|
notificationId: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const notification = activeNotifications.get(notificationId);
|
||||||
|
if (!notification) return;
|
||||||
|
notification.close();
|
||||||
|
clearNotificationState(notificationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCapabilities(): Promise<string[]> {
|
||||||
|
if (process.platform !== "linux") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Notification.isSupported()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ["native-notification", "click"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onNotificationAction(callback: NotificationCallback): void {
|
||||||
|
notificationCallbacks.push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeNotificationCallback(
|
||||||
|
callback: NotificationCallback,
|
||||||
|
): void {
|
||||||
|
const index = notificationCallbacks.indexOf(callback);
|
||||||
|
if (index > -1) {
|
||||||
|
notificationCallbacks.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initLinuxNotificationService(): Promise<void> {
|
||||||
|
if (process.platform !== "linux") {
|
||||||
|
console.log("[LinuxNotification] Not on Linux, skipping init");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Notification.isSupported()) {
|
||||||
|
console.warn("[LinuxNotification] Notification API is not supported");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const caps = await getCapabilities();
|
||||||
|
console.log("[LinuxNotification] Service initialized with native API:", caps);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function shutdownLinuxNotificationService(): Promise<void> {
|
||||||
|
// 清理所有活动的通知
|
||||||
|
for (const [id, notification] of activeNotifications) {
|
||||||
|
try {
|
||||||
|
notification.close();
|
||||||
|
} catch {}
|
||||||
|
clearNotificationState(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理头像文件缓存
|
||||||
|
try {
|
||||||
|
await avatarFileCache.clearCache();
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
console.log("[LinuxNotification] Service shutdown complete");
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { join, dirname } from 'path'
|
import { join, dirname } from 'path'
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
|
import { ConfigService } from './config'
|
||||||
|
|
||||||
export interface SessionMessageCacheEntry {
|
export interface SessionMessageCacheEntry {
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
@@ -15,7 +16,7 @@ export class MessageCacheService {
|
|||||||
constructor(cacheBasePath?: string) {
|
constructor(cacheBasePath?: string) {
|
||||||
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||||
? cacheBasePath
|
? cacheBasePath
|
||||||
: join(app.getPath('documents'), 'WeFlow')
|
: ConfigService.getInstance().getCacheBasePath()
|
||||||
this.cacheFilePath = join(basePath, 'session-messages.json')
|
this.cacheFilePath = join(basePath, 'session-messages.json')
|
||||||
this.ensureCacheDir()
|
this.ensureCacheDir()
|
||||||
this.loadCache()
|
this.loadCache()
|
||||||
|
|||||||
379
electron/services/messagePushService.ts
Normal file
379
electron/services/messagePushService.ts
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
import { ConfigService } from './config'
|
||||||
|
import { chatService, type ChatSession, type Message } from './chatService'
|
||||||
|
import { wcdbService } from './wcdbService'
|
||||||
|
import { httpService } from './httpService'
|
||||||
|
|
||||||
|
interface SessionBaseline {
|
||||||
|
lastTimestamp: number
|
||||||
|
unreadCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessagePushPayload {
|
||||||
|
event: 'message.new'
|
||||||
|
sessionId: string
|
||||||
|
messageKey: string
|
||||||
|
avatarUrl?: string
|
||||||
|
sourceName: string
|
||||||
|
groupName?: string
|
||||||
|
content: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const PUSH_CONFIG_KEYS = new Set([
|
||||||
|
'messagePushEnabled',
|
||||||
|
'dbPath',
|
||||||
|
'decryptKey',
|
||||||
|
'myWxid'
|
||||||
|
])
|
||||||
|
|
||||||
|
class MessagePushService {
|
||||||
|
private readonly configService: ConfigService
|
||||||
|
private readonly sessionBaseline = new Map<string, SessionBaseline>()
|
||||||
|
private readonly recentMessageKeys = new Map<string, number>()
|
||||||
|
private readonly groupNicknameCache = new Map<string, { nicknames: Record<string, string>; updatedAt: number }>()
|
||||||
|
private readonly debounceMs = 350
|
||||||
|
private readonly recentMessageTtlMs = 10 * 60 * 1000
|
||||||
|
private readonly groupNicknameCacheTtlMs = 5 * 60 * 1000
|
||||||
|
private debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
private processing = false
|
||||||
|
private rerunRequested = false
|
||||||
|
private started = false
|
||||||
|
private baselineReady = false
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.configService = ConfigService.getInstance()
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (this.started) return
|
||||||
|
this.started = true
|
||||||
|
void this.refreshConfiguration('startup')
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDbMonitorChange(type: string, json: string): void {
|
||||||
|
if (!this.started) return
|
||||||
|
if (!this.isPushEnabled()) return
|
||||||
|
|
||||||
|
let payload: Record<string, unknown> | null = null
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(json)
|
||||||
|
} catch {
|
||||||
|
payload = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableName = String(payload?.table || '').trim().toLowerCase()
|
||||||
|
if (tableName && tableName !== 'session') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scheduleSync()
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleConfigChanged(key: string): Promise<void> {
|
||||||
|
if (!PUSH_CONFIG_KEYS.has(String(key || '').trim())) return
|
||||||
|
if (key === 'dbPath' || key === 'decryptKey' || key === 'myWxid') {
|
||||||
|
this.resetRuntimeState()
|
||||||
|
chatService.close()
|
||||||
|
}
|
||||||
|
await this.refreshConfiguration(`config:${key}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleConfigCleared(): void {
|
||||||
|
this.resetRuntimeState()
|
||||||
|
chatService.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private isPushEnabled(): boolean {
|
||||||
|
return this.configService.get('messagePushEnabled') === true
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetRuntimeState(): void {
|
||||||
|
this.sessionBaseline.clear()
|
||||||
|
this.recentMessageKeys.clear()
|
||||||
|
this.groupNicknameCache.clear()
|
||||||
|
this.baselineReady = false
|
||||||
|
if (this.debounceTimer) {
|
||||||
|
clearTimeout(this.debounceTimer)
|
||||||
|
this.debounceTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshConfiguration(reason: string): Promise<void> {
|
||||||
|
if (!this.isPushEnabled()) {
|
||||||
|
this.resetRuntimeState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectResult = await chatService.connect()
|
||||||
|
if (!connectResult.success) {
|
||||||
|
console.warn(`[MessagePushService] Bootstrap connect failed (${reason}):`, connectResult.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.bootstrapBaseline()
|
||||||
|
}
|
||||||
|
|
||||||
|
private async bootstrapBaseline(): Promise<void> {
|
||||||
|
const sessionsResult = await chatService.getSessions()
|
||||||
|
if (!sessionsResult.success || !sessionsResult.sessions) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setBaseline(sessionsResult.sessions as ChatSession[])
|
||||||
|
this.baselineReady = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleSync(): void {
|
||||||
|
if (this.debounceTimer) {
|
||||||
|
clearTimeout(this.debounceTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.debounceTimer = setTimeout(() => {
|
||||||
|
this.debounceTimer = null
|
||||||
|
void this.flushPendingChanges()
|
||||||
|
}, this.debounceMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async flushPendingChanges(): Promise<void> {
|
||||||
|
if (this.processing) {
|
||||||
|
this.rerunRequested = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.processing = true
|
||||||
|
try {
|
||||||
|
if (!this.isPushEnabled()) return
|
||||||
|
|
||||||
|
const connectResult = await chatService.connect()
|
||||||
|
if (!connectResult.success) {
|
||||||
|
console.warn('[MessagePushService] Sync connect failed:', connectResult.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionsResult = await chatService.getSessions()
|
||||||
|
if (!sessionsResult.success || !sessionsResult.sessions) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = sessionsResult.sessions as ChatSession[]
|
||||||
|
if (!this.baselineReady) {
|
||||||
|
this.setBaseline(sessions)
|
||||||
|
this.baselineReady = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousBaseline = new Map(this.sessionBaseline)
|
||||||
|
this.setBaseline(sessions)
|
||||||
|
|
||||||
|
const candidates = sessions.filter((session) => this.shouldInspectSession(previousBaseline.get(session.username), session))
|
||||||
|
for (const session of candidates) {
|
||||||
|
await this.pushSessionMessages(session, previousBaseline.get(session.username))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.processing = false
|
||||||
|
if (this.rerunRequested) {
|
||||||
|
this.rerunRequested = false
|
||||||
|
this.scheduleSync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setBaseline(sessions: ChatSession[]): void {
|
||||||
|
this.sessionBaseline.clear()
|
||||||
|
for (const session of sessions) {
|
||||||
|
this.sessionBaseline.set(session.username, {
|
||||||
|
lastTimestamp: Number(session.lastTimestamp || 0),
|
||||||
|
unreadCount: Number(session.unreadCount || 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldInspectSession(previous: SessionBaseline | undefined, session: ChatSession): boolean {
|
||||||
|
const sessionId = String(session.username || '').trim()
|
||||||
|
if (!sessionId || sessionId.toLowerCase().includes('placeholder_foldgroup')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = String(session.summary || '').trim()
|
||||||
|
if (Number(session.lastMsgType || 0) === 10002 || summary.includes('撤回了一条消息')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastTimestamp = Number(session.lastTimestamp || 0)
|
||||||
|
const unreadCount = Number(session.unreadCount || 0)
|
||||||
|
|
||||||
|
if (!previous) {
|
||||||
|
return unreadCount > 0 && lastTimestamp > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastTimestamp <= previous.lastTimestamp) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// unread 未增长时,大概率是自己发送、其他设备已读或状态同步,不作为主动推送
|
||||||
|
return unreadCount > previous.unreadCount
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pushSessionMessages(session: ChatSession, previous: SessionBaseline | undefined): Promise<void> {
|
||||||
|
const since = Math.max(0, Number(previous?.lastTimestamp || 0) - 1)
|
||||||
|
const newMessagesResult = await chatService.getNewMessages(session.username, since, 1000)
|
||||||
|
if (!newMessagesResult.success || !newMessagesResult.messages || newMessagesResult.messages.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const message of newMessagesResult.messages) {
|
||||||
|
const messageKey = String(message.messageKey || '').trim()
|
||||||
|
if (!messageKey) continue
|
||||||
|
if (message.isSend === 1) continue
|
||||||
|
|
||||||
|
if (previous && Number(message.createTime || 0) < Number(previous.lastTimestamp || 0)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isRecentMessage(messageKey)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await this.buildPayload(session, message)
|
||||||
|
if (!payload) continue
|
||||||
|
|
||||||
|
httpService.broadcastMessagePush(payload)
|
||||||
|
this.rememberMessageKey(messageKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildPayload(session: ChatSession, message: Message): Promise<MessagePushPayload | null> {
|
||||||
|
const sessionId = String(session.username || '').trim()
|
||||||
|
const messageKey = String(message.messageKey || '').trim()
|
||||||
|
if (!sessionId || !messageKey) return null
|
||||||
|
|
||||||
|
const isGroup = sessionId.endsWith('@chatroom')
|
||||||
|
const content = this.getMessageDisplayContent(message)
|
||||||
|
|
||||||
|
if (isGroup) {
|
||||||
|
const groupInfo = await chatService.getContactAvatar(sessionId)
|
||||||
|
const groupName = session.displayName || groupInfo?.displayName || sessionId
|
||||||
|
const sourceName = await this.resolveGroupSourceName(sessionId, message, session)
|
||||||
|
return {
|
||||||
|
event: 'message.new',
|
||||||
|
sessionId,
|
||||||
|
messageKey,
|
||||||
|
avatarUrl: session.avatarUrl || groupInfo?.avatarUrl,
|
||||||
|
groupName,
|
||||||
|
sourceName,
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const contactInfo = await chatService.getContactAvatar(sessionId)
|
||||||
|
return {
|
||||||
|
event: 'message.new',
|
||||||
|
sessionId,
|
||||||
|
messageKey,
|
||||||
|
avatarUrl: session.avatarUrl || contactInfo?.avatarUrl,
|
||||||
|
sourceName: session.displayName || contactInfo?.displayName || sessionId,
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMessageDisplayContent(message: Message): string | null {
|
||||||
|
switch (Number(message.localType || 0)) {
|
||||||
|
case 1:
|
||||||
|
return message.rawContent || null
|
||||||
|
case 3:
|
||||||
|
return '[图片]'
|
||||||
|
case 34:
|
||||||
|
return '[语音]'
|
||||||
|
case 43:
|
||||||
|
return '[视频]'
|
||||||
|
case 47:
|
||||||
|
return '[表情]'
|
||||||
|
case 42:
|
||||||
|
return message.cardNickname || '[名片]'
|
||||||
|
case 48:
|
||||||
|
return '[位置]'
|
||||||
|
case 49:
|
||||||
|
return message.linkTitle || message.fileName || '[消息]'
|
||||||
|
default:
|
||||||
|
return message.parsedContent || message.rawContent || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveGroupSourceName(chatroomId: string, message: Message, session: ChatSession): Promise<string> {
|
||||||
|
const senderUsername = String(message.senderUsername || '').trim()
|
||||||
|
if (!senderUsername) {
|
||||||
|
return session.lastSenderDisplayName || '未知发送者'
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupNicknames = await this.getGroupNicknames(chatroomId)
|
||||||
|
const senderKey = senderUsername.toLowerCase()
|
||||||
|
const nickname = groupNicknames[senderKey]
|
||||||
|
|
||||||
|
if (nickname) {
|
||||||
|
return nickname
|
||||||
|
}
|
||||||
|
|
||||||
|
const contactInfo = await chatService.getContactAvatar(senderUsername)
|
||||||
|
return contactInfo?.displayName || senderUsername
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getGroupNicknames(chatroomId: string): Promise<Record<string, string>> {
|
||||||
|
const cacheKey = String(chatroomId || '').trim()
|
||||||
|
if (!cacheKey) return {}
|
||||||
|
|
||||||
|
const cached = this.groupNicknameCache.get(cacheKey)
|
||||||
|
if (cached && Date.now() - cached.updatedAt < this.groupNicknameCacheTtlMs) {
|
||||||
|
return cached.nicknames
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await wcdbService.getGroupNicknames(cacheKey)
|
||||||
|
const nicknames = result.success && result.nicknames
|
||||||
|
? this.sanitizeGroupNicknames(result.nicknames)
|
||||||
|
: {}
|
||||||
|
this.groupNicknameCache.set(cacheKey, { nicknames, updatedAt: Date.now() })
|
||||||
|
return nicknames
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizeGroupNicknames(nicknames: Record<string, string>): Record<string, string> {
|
||||||
|
const buckets = new Map<string, Set<string>>()
|
||||||
|
for (const [memberIdRaw, nicknameRaw] of Object.entries(nicknames || {})) {
|
||||||
|
const memberId = String(memberIdRaw || '').trim().toLowerCase()
|
||||||
|
const nickname = String(nicknameRaw || '').trim()
|
||||||
|
if (!memberId || !nickname) continue
|
||||||
|
const slot = buckets.get(memberId)
|
||||||
|
if (slot) {
|
||||||
|
slot.add(nickname)
|
||||||
|
} else {
|
||||||
|
buckets.set(memberId, new Set([nickname]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const trusted: Record<string, string> = {}
|
||||||
|
for (const [memberId, nicknameSet] of buckets.entries()) {
|
||||||
|
if (nicknameSet.size !== 1) continue
|
||||||
|
trusted[memberId] = Array.from(nicknameSet)[0]
|
||||||
|
}
|
||||||
|
return trusted
|
||||||
|
}
|
||||||
|
|
||||||
|
private isRecentMessage(messageKey: string): boolean {
|
||||||
|
this.pruneRecentMessageKeys()
|
||||||
|
const timestamp = this.recentMessageKeys.get(messageKey)
|
||||||
|
return typeof timestamp === 'number' && Date.now() - timestamp < this.recentMessageTtlMs
|
||||||
|
}
|
||||||
|
|
||||||
|
private rememberMessageKey(messageKey: string): void {
|
||||||
|
this.recentMessageKeys.set(messageKey, Date.now())
|
||||||
|
this.pruneRecentMessageKeys()
|
||||||
|
}
|
||||||
|
|
||||||
|
private pruneRecentMessageKeys(): void {
|
||||||
|
const now = Date.now()
|
||||||
|
for (const [key, timestamp] of this.recentMessageKeys.entries()) {
|
||||||
|
if (now - timestamp > this.recentMessageTtlMs) {
|
||||||
|
this.recentMessageKeys.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export const messagePushService = new MessagePushService()
|
||||||
293
electron/services/sessionStatsCacheService.ts
Normal file
293
electron/services/sessionStatsCacheService.ts
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import { join, dirname } from 'path'
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
||||||
|
import { ConfigService } from './config'
|
||||||
|
|
||||||
|
const CACHE_VERSION = 2
|
||||||
|
const MAX_SESSION_ENTRIES_PER_SCOPE = 2000
|
||||||
|
const MAX_SCOPE_ENTRIES = 12
|
||||||
|
|
||||||
|
export interface SessionStatsCacheStats {
|
||||||
|
totalMessages: number
|
||||||
|
voiceMessages: number
|
||||||
|
imageMessages: number
|
||||||
|
videoMessages: number
|
||||||
|
emojiMessages: number
|
||||||
|
transferMessages: number
|
||||||
|
redPacketMessages: number
|
||||||
|
callMessages: number
|
||||||
|
firstTimestamp?: number
|
||||||
|
lastTimestamp?: number
|
||||||
|
privateMutualGroups?: number
|
||||||
|
groupMemberCount?: number
|
||||||
|
groupMyMessages?: number
|
||||||
|
groupActiveSpeakers?: number
|
||||||
|
groupMutualFriends?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionStatsCacheEntry {
|
||||||
|
updatedAt: number
|
||||||
|
includeRelations: boolean
|
||||||
|
stats: SessionStatsCacheStats
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionStatsScopeMap {
|
||||||
|
[sessionId: string]: SessionStatsCacheEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionStatsCacheStore {
|
||||||
|
version: number
|
||||||
|
scopes: Record<string, SessionStatsScopeMap>
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNonNegativeInt(value: unknown): number | undefined {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
|
||||||
|
return Math.max(0, Math.floor(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStats(raw: unknown): SessionStatsCacheStats | null {
|
||||||
|
if (!raw || typeof raw !== 'object') return null
|
||||||
|
const source = raw as Record<string, unknown>
|
||||||
|
|
||||||
|
const totalMessages = toNonNegativeInt(source.totalMessages)
|
||||||
|
const voiceMessages = toNonNegativeInt(source.voiceMessages)
|
||||||
|
const imageMessages = toNonNegativeInt(source.imageMessages)
|
||||||
|
const videoMessages = toNonNegativeInt(source.videoMessages)
|
||||||
|
const emojiMessages = toNonNegativeInt(source.emojiMessages)
|
||||||
|
const transferMessages = toNonNegativeInt(source.transferMessages)
|
||||||
|
const redPacketMessages = toNonNegativeInt(source.redPacketMessages)
|
||||||
|
const callMessages = toNonNegativeInt(source.callMessages)
|
||||||
|
|
||||||
|
if (
|
||||||
|
totalMessages === undefined ||
|
||||||
|
voiceMessages === undefined ||
|
||||||
|
imageMessages === undefined ||
|
||||||
|
videoMessages === undefined ||
|
||||||
|
emojiMessages === undefined ||
|
||||||
|
transferMessages === undefined ||
|
||||||
|
redPacketMessages === undefined ||
|
||||||
|
callMessages === undefined
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized: SessionStatsCacheStats = {
|
||||||
|
totalMessages,
|
||||||
|
voiceMessages,
|
||||||
|
imageMessages,
|
||||||
|
videoMessages,
|
||||||
|
emojiMessages,
|
||||||
|
transferMessages,
|
||||||
|
redPacketMessages,
|
||||||
|
callMessages
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstTimestamp = toNonNegativeInt(source.firstTimestamp)
|
||||||
|
if (firstTimestamp !== undefined) normalized.firstTimestamp = firstTimestamp
|
||||||
|
|
||||||
|
const lastTimestamp = toNonNegativeInt(source.lastTimestamp)
|
||||||
|
if (lastTimestamp !== undefined) normalized.lastTimestamp = lastTimestamp
|
||||||
|
|
||||||
|
const privateMutualGroups = toNonNegativeInt(source.privateMutualGroups)
|
||||||
|
if (privateMutualGroups !== undefined) normalized.privateMutualGroups = privateMutualGroups
|
||||||
|
|
||||||
|
const groupMemberCount = toNonNegativeInt(source.groupMemberCount)
|
||||||
|
if (groupMemberCount !== undefined) normalized.groupMemberCount = groupMemberCount
|
||||||
|
|
||||||
|
const groupMyMessages = toNonNegativeInt(source.groupMyMessages)
|
||||||
|
if (groupMyMessages !== undefined) normalized.groupMyMessages = groupMyMessages
|
||||||
|
|
||||||
|
const groupActiveSpeakers = toNonNegativeInt(source.groupActiveSpeakers)
|
||||||
|
if (groupActiveSpeakers !== undefined) normalized.groupActiveSpeakers = groupActiveSpeakers
|
||||||
|
|
||||||
|
const groupMutualFriends = toNonNegativeInt(source.groupMutualFriends)
|
||||||
|
if (groupMutualFriends !== undefined) normalized.groupMutualFriends = groupMutualFriends
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEntry(raw: unknown): SessionStatsCacheEntry | null {
|
||||||
|
if (!raw || typeof raw !== 'object') return null
|
||||||
|
const source = raw as Record<string, unknown>
|
||||||
|
const updatedAt = toNonNegativeInt(source.updatedAt)
|
||||||
|
const includeRelations = typeof source.includeRelations === 'boolean' ? source.includeRelations : false
|
||||||
|
const stats = normalizeStats(source.stats)
|
||||||
|
|
||||||
|
if (updatedAt === undefined || !stats) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
updatedAt,
|
||||||
|
includeRelations,
|
||||||
|
stats
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SessionStatsCacheService {
|
||||||
|
private readonly cacheFilePath: string
|
||||||
|
private store: SessionStatsCacheStore = {
|
||||||
|
version: CACHE_VERSION,
|
||||||
|
scopes: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(cacheBasePath?: string) {
|
||||||
|
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||||
|
? cacheBasePath
|
||||||
|
: ConfigService.getInstance().getCacheBasePath()
|
||||||
|
this.cacheFilePath = join(basePath, 'session-stats.json')
|
||||||
|
this.ensureCacheDir()
|
||||||
|
this.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureCacheDir(): void {
|
||||||
|
const dir = dirname(this.cacheFilePath)
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
mkdirSync(dir, { recursive: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private load(): void {
|
||||||
|
if (!existsSync(this.cacheFilePath)) return
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(this.cacheFilePath, 'utf8')
|
||||||
|
const parsed = JSON.parse(raw) as unknown
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = parsed as Record<string, unknown>
|
||||||
|
const version = Number(payload.version)
|
||||||
|
if (!Number.isFinite(version) || version !== CACHE_VERSION) {
|
||||||
|
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const scopesRaw = payload.scopes
|
||||||
|
if (!scopesRaw || typeof scopesRaw !== 'object') {
|
||||||
|
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopes: Record<string, SessionStatsScopeMap> = {}
|
||||||
|
for (const [scopeKey, scopeValue] of Object.entries(scopesRaw as Record<string, unknown>)) {
|
||||||
|
if (!scopeValue || typeof scopeValue !== 'object') continue
|
||||||
|
const normalizedScope: SessionStatsScopeMap = {}
|
||||||
|
for (const [sessionId, entryRaw] of Object.entries(scopeValue as Record<string, unknown>)) {
|
||||||
|
const entry = normalizeEntry(entryRaw)
|
||||||
|
if (!entry) continue
|
||||||
|
normalizedScope[sessionId] = entry
|
||||||
|
}
|
||||||
|
if (Object.keys(normalizedScope).length > 0) {
|
||||||
|
scopes[scopeKey] = normalizedScope
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.store = {
|
||||||
|
version: CACHE_VERSION,
|
||||||
|
scopes
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SessionStatsCacheService: 载入缓存失败', error)
|
||||||
|
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get(scopeKey: string, sessionId: string): SessionStatsCacheEntry | undefined {
|
||||||
|
if (!scopeKey || !sessionId) return undefined
|
||||||
|
const scope = this.store.scopes[scopeKey]
|
||||||
|
if (!scope) return undefined
|
||||||
|
const entry = normalizeEntry(scope[sessionId])
|
||||||
|
if (!entry) {
|
||||||
|
delete scope[sessionId]
|
||||||
|
if (Object.keys(scope).length === 0) {
|
||||||
|
delete this.store.scopes[scopeKey]
|
||||||
|
}
|
||||||
|
this.persist()
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
set(scopeKey: string, sessionId: string, entry: SessionStatsCacheEntry): void {
|
||||||
|
if (!scopeKey || !sessionId) return
|
||||||
|
const normalized = normalizeEntry(entry)
|
||||||
|
if (!normalized) return
|
||||||
|
|
||||||
|
if (!this.store.scopes[scopeKey]) {
|
||||||
|
this.store.scopes[scopeKey] = {}
|
||||||
|
}
|
||||||
|
this.store.scopes[scopeKey][sessionId] = normalized
|
||||||
|
|
||||||
|
this.trimScope(scopeKey)
|
||||||
|
this.trimScopes()
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(scopeKey: string, sessionId: string): void {
|
||||||
|
if (!scopeKey || !sessionId) return
|
||||||
|
const scope = this.store.scopes[scopeKey]
|
||||||
|
if (!scope) return
|
||||||
|
if (!(sessionId in scope)) return
|
||||||
|
|
||||||
|
delete scope[sessionId]
|
||||||
|
if (Object.keys(scope).length === 0) {
|
||||||
|
delete this.store.scopes[scopeKey]
|
||||||
|
}
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
clearScope(scopeKey: string): void {
|
||||||
|
if (!scopeKey) return
|
||||||
|
if (!this.store.scopes[scopeKey]) return
|
||||||
|
delete this.store.scopes[scopeKey]
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAll(): void {
|
||||||
|
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||||
|
try {
|
||||||
|
rmSync(this.cacheFilePath, { force: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SessionStatsCacheService: 清理缓存失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private trimScope(scopeKey: string): void {
|
||||||
|
const scope = this.store.scopes[scopeKey]
|
||||||
|
if (!scope) return
|
||||||
|
const entries = Object.entries(scope)
|
||||||
|
if (entries.length <= MAX_SESSION_ENTRIES_PER_SCOPE) return
|
||||||
|
|
||||||
|
entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
|
||||||
|
const trimmed: SessionStatsScopeMap = {}
|
||||||
|
for (const [sessionId, entry] of entries.slice(0, MAX_SESSION_ENTRIES_PER_SCOPE)) {
|
||||||
|
trimmed[sessionId] = entry
|
||||||
|
}
|
||||||
|
this.store.scopes[scopeKey] = trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
private trimScopes(): void {
|
||||||
|
const scopeEntries = Object.entries(this.store.scopes)
|
||||||
|
if (scopeEntries.length <= MAX_SCOPE_ENTRIES) return
|
||||||
|
|
||||||
|
scopeEntries.sort((a, b) => {
|
||||||
|
const aUpdatedAt = Math.max(...Object.values(a[1]).map((entry) => entry.updatedAt), 0)
|
||||||
|
const bUpdatedAt = Math.max(...Object.values(b[1]).map((entry) => entry.updatedAt), 0)
|
||||||
|
return bUpdatedAt - aUpdatedAt
|
||||||
|
})
|
||||||
|
|
||||||
|
const trimmedScopes: Record<string, SessionStatsScopeMap> = {}
|
||||||
|
for (const [scopeKey, scopeMap] of scopeEntries.slice(0, MAX_SCOPE_ENTRIES)) {
|
||||||
|
trimmedScopes[scopeKey] = scopeMap
|
||||||
|
}
|
||||||
|
this.store.scopes = trimmedScopes
|
||||||
|
}
|
||||||
|
|
||||||
|
private persist(): void {
|
||||||
|
try {
|
||||||
|
writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SessionStatsCacheService: 保存缓存失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,10 @@
|
|||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
|
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync, unlinkSync } from 'fs'
|
||||||
|
import { spawn } from 'child_process'
|
||||||
|
import { pathToFileURL } from 'url'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
import { app } from 'electron'
|
||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import Database from 'better-sqlite3'
|
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
|
|
||||||
export interface VideoInfo {
|
export interface VideoInfo {
|
||||||
@@ -11,13 +14,112 @@ export interface VideoInfo {
|
|||||||
exists: boolean
|
exists: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TimedCacheEntry<T> {
|
||||||
|
value: T
|
||||||
|
expiresAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VideoIndexEntry {
|
||||||
|
videoPath?: string
|
||||||
|
coverPath?: string
|
||||||
|
thumbPath?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PosterFormat = 'dataUrl' | 'fileUrl'
|
||||||
|
|
||||||
|
function getStaticFfmpegPath(): string | null {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const ffmpegStatic = require('ffmpeg-static')
|
||||||
|
if (typeof ffmpegStatic === 'string') {
|
||||||
|
let fixedPath = ffmpegStatic
|
||||||
|
if (fixedPath.includes('app.asar') && !fixedPath.includes('app.asar.unpacked')) {
|
||||||
|
fixedPath = fixedPath.replace('app.asar', 'app.asar.unpacked')
|
||||||
|
}
|
||||||
|
if (existsSync(fixedPath)) return fixedPath
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
const ffmpegName = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
|
||||||
|
const devPath = join(process.cwd(), 'node_modules', 'ffmpeg-static', ffmpegName)
|
||||||
|
if (existsSync(devPath)) return devPath
|
||||||
|
|
||||||
|
if (app.isPackaged) {
|
||||||
|
const packedPath = join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', ffmpegName)
|
||||||
|
if (existsSync(packedPath)) return packedPath
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
class VideoService {
|
class VideoService {
|
||||||
private configService: ConfigService
|
private configService: ConfigService
|
||||||
|
private hardlinkResolveCache = new Map<string, TimedCacheEntry<string | null>>()
|
||||||
|
private videoInfoCache = new Map<string, TimedCacheEntry<VideoInfo>>()
|
||||||
|
private videoDirIndexCache = new Map<string, TimedCacheEntry<Map<string, VideoIndexEntry>>>()
|
||||||
|
private pendingVideoInfo = new Map<string, Promise<VideoInfo>>()
|
||||||
|
private pendingPosterExtract = new Map<string, Promise<string | null>>()
|
||||||
|
private extractedPosterCache = new Map<string, TimedCacheEntry<string | null>>()
|
||||||
|
private posterExtractRunning = 0
|
||||||
|
private posterExtractQueue: Array<() => void> = []
|
||||||
|
private readonly hardlinkCacheTtlMs = 10 * 60 * 1000
|
||||||
|
private readonly videoInfoCacheTtlMs = 2 * 60 * 1000
|
||||||
|
private readonly videoIndexCacheTtlMs = 90 * 1000
|
||||||
|
private readonly extractedPosterCacheTtlMs = 15 * 60 * 1000
|
||||||
|
private readonly maxPosterExtractConcurrency = 1
|
||||||
|
private readonly maxCacheEntries = 2000
|
||||||
|
private readonly maxIndexEntries = 6
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.configService = new ConfigService()
|
this.configService = new ConfigService()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private log(message: string, meta?: Record<string, unknown>): void {
|
||||||
|
try {
|
||||||
|
const timestamp = new Date().toISOString()
|
||||||
|
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
|
||||||
|
const logDir = join(app.getPath('userData'), 'logs')
|
||||||
|
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true })
|
||||||
|
appendFileSync(join(logDir, 'wcdb.log'), `[${timestamp}] [VideoService] ${message}${metaStr}\n`, 'utf8')
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private readTimedCache<T>(cache: Map<string, TimedCacheEntry<T>>, key: string): T | undefined {
|
||||||
|
const hit = cache.get(key)
|
||||||
|
if (!hit) return undefined
|
||||||
|
if (hit.expiresAt <= Date.now()) {
|
||||||
|
cache.delete(key)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return hit.value
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeTimedCache<T>(
|
||||||
|
cache: Map<string, TimedCacheEntry<T>>,
|
||||||
|
key: string,
|
||||||
|
value: T,
|
||||||
|
ttlMs: number,
|
||||||
|
maxEntries: number
|
||||||
|
): void {
|
||||||
|
cache.set(key, { value, expiresAt: Date.now() + ttlMs })
|
||||||
|
if (cache.size <= maxEntries) return
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
for (const [cacheKey, entry] of cache) {
|
||||||
|
if (entry.expiresAt <= now) {
|
||||||
|
cache.delete(cacheKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (cache.size > maxEntries) {
|
||||||
|
const oldestKey = cache.keys().next().value as string | undefined
|
||||||
|
if (!oldestKey) break
|
||||||
|
cache.delete(oldestKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取数据库根目录
|
* 获取数据库根目录
|
||||||
*/
|
*/
|
||||||
@@ -32,13 +134,6 @@ class VideoService {
|
|||||||
return this.configService.get('myWxid') || ''
|
return this.configService.get('myWxid') || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取缓存目录(解密后的数据库存放位置)
|
|
||||||
*/
|
|
||||||
private getCachePath(): string {
|
|
||||||
return this.configService.get('cachePath') || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清理 wxid 目录名(去掉后缀)
|
* 清理 wxid 目录名(去掉后缀)
|
||||||
*/
|
*/
|
||||||
@@ -58,102 +153,151 @@ class VideoService {
|
|||||||
return trimmed
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private getScopeKey(dbPath: string, wxid: string): string {
|
||||||
* 从 video_hardlink_info_v4 表查询视频文件名
|
return `${dbPath}::${this.cleanWxid(wxid)}`.toLowerCase()
|
||||||
* 优先使用 cachePath 中解密后的 hardlink.db(使用 better-sqlite3)
|
}
|
||||||
* 如果失败,则尝试使用 wcdbService.execQuery 查询加密的 hardlink.db
|
|
||||||
*/
|
private resolveVideoBaseDir(dbPath: string, wxid: string): string {
|
||||||
private async queryVideoFileName(md5: string): Promise<string | undefined> {
|
|
||||||
const cachePath = this.getCachePath()
|
|
||||||
const dbPath = this.getDbPath()
|
|
||||||
const wxid = this.getMyWxid()
|
|
||||||
const cleanedWxid = this.cleanWxid(wxid)
|
const cleanedWxid = this.cleanWxid(wxid)
|
||||||
|
const dbPathLower = dbPath.toLowerCase()
|
||||||
if (!wxid) return undefined
|
const wxidLower = wxid.toLowerCase()
|
||||||
|
const cleanedWxidLower = cleanedWxid.toLowerCase()
|
||||||
// 方法1:优先在 cachePath 下查找解密后的 hardlink.db
|
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
|
||||||
if (cachePath) {
|
if (dbPathContainsWxid) {
|
||||||
const cacheDbPaths = [
|
return join(dbPath, 'msg', 'video')
|
||||||
join(cachePath, cleanedWxid, 'hardlink.db'),
|
|
||||||
join(cachePath, wxid, 'hardlink.db'),
|
|
||||||
join(cachePath, 'hardlink.db'),
|
|
||||||
join(cachePath, 'databases', cleanedWxid, 'hardlink.db'),
|
|
||||||
join(cachePath, 'databases', wxid, 'hardlink.db')
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const p of cacheDbPaths) {
|
|
||||||
if (existsSync(p)) {
|
|
||||||
try {
|
|
||||||
const db = new Database(p, { readonly: true })
|
|
||||||
const row = db.prepare(`
|
|
||||||
SELECT file_name, md5 FROM video_hardlink_info_v4
|
|
||||||
WHERE md5 = ?
|
|
||||||
LIMIT 1
|
|
||||||
`).get(md5) as { file_name: string; md5: string } | undefined
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
if (row?.file_name) {
|
|
||||||
const realMd5 = row.file_name.replace(/\.[^.]+$/, '')
|
|
||||||
return realMd5
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// 忽略错误
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return join(dbPath, wxid, 'msg', 'video')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 方法2:使用 wcdbService.execQuery 查询加密的 hardlink.db
|
private getHardlinkDbPaths(dbPath: string, wxid: string, cleanedWxid: string): string[] {
|
||||||
if (dbPath) {
|
|
||||||
// 检查 dbPath 是否已经包含 wxid
|
|
||||||
const dbPathLower = dbPath.toLowerCase()
|
const dbPathLower = dbPath.toLowerCase()
|
||||||
const wxidLower = wxid.toLowerCase()
|
const wxidLower = wxid.toLowerCase()
|
||||||
const cleanedWxidLower = cleanedWxid.toLowerCase()
|
const cleanedWxidLower = cleanedWxid.toLowerCase()
|
||||||
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
|
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
|
||||||
|
|
||||||
const encryptedDbPaths: string[] = []
|
|
||||||
if (dbPathContainsWxid) {
|
if (dbPathContainsWxid) {
|
||||||
// dbPath 已包含 wxid,不需要再拼接
|
return [join(dbPath, 'db_storage', 'hardlink', 'hardlink.db')]
|
||||||
encryptedDbPaths.push(join(dbPath, 'db_storage', 'hardlink', 'hardlink.db'))
|
|
||||||
} else {
|
|
||||||
// dbPath 不包含 wxid,需要拼接
|
|
||||||
encryptedDbPaths.push(join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'))
|
|
||||||
encryptedDbPaths.push(join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db'))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'),
|
||||||
|
join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db')
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 video_hardlink_info_v4 表查询视频文件名
|
||||||
|
* 使用 wcdb 专属接口查询加密的 hardlink.db
|
||||||
|
*/
|
||||||
|
private async resolveVideoHardlinks(
|
||||||
|
md5List: string[],
|
||||||
|
dbPath: string,
|
||||||
|
wxid: string,
|
||||||
|
cleanedWxid: string
|
||||||
|
): Promise<Map<string, string>> {
|
||||||
|
const scopeKey = this.getScopeKey(dbPath, wxid)
|
||||||
|
const normalizedList = Array.from(
|
||||||
|
new Set((md5List || []).map((item) => String(item || '').trim().toLowerCase()).filter(Boolean))
|
||||||
|
)
|
||||||
|
const resolvedMap = new Map<string, string>()
|
||||||
|
const unresolvedSet = new Set(normalizedList)
|
||||||
|
|
||||||
|
for (const md5 of normalizedList) {
|
||||||
|
const cacheKey = `${scopeKey}|${md5}`
|
||||||
|
const cached = this.readTimedCache(this.hardlinkResolveCache, cacheKey)
|
||||||
|
if (cached === undefined) continue
|
||||||
|
if (cached) resolvedMap.set(md5, cached)
|
||||||
|
unresolvedSet.delete(md5)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unresolvedSet.size === 0) return resolvedMap
|
||||||
|
|
||||||
|
const encryptedDbPaths = this.getHardlinkDbPaths(dbPath, wxid, cleanedWxid)
|
||||||
for (const p of encryptedDbPaths) {
|
for (const p of encryptedDbPaths) {
|
||||||
if (existsSync(p)) {
|
if (!existsSync(p) || unresolvedSet.size === 0) continue
|
||||||
|
const unresolved = Array.from(unresolvedSet)
|
||||||
|
const requests = unresolved.map((md5) => ({ md5, dbPath: p }))
|
||||||
try {
|
try {
|
||||||
const escapedMd5 = md5.replace(/'/g, "''")
|
const batchResult = await wcdbService.resolveVideoHardlinkMd5Batch(requests)
|
||||||
|
if (batchResult.success && Array.isArray(batchResult.rows)) {
|
||||||
// 用 md5 字段查询,获取 file_name
|
for (const row of batchResult.rows) {
|
||||||
const sql = `SELECT file_name FROM video_hardlink_info_v4 WHERE md5 = '${escapedMd5}' LIMIT 1`
|
const index = Number.isFinite(Number(row?.index)) ? Math.floor(Number(row?.index)) : -1
|
||||||
|
const inputMd5 = index >= 0 && index < requests.length
|
||||||
const result = await wcdbService.execQuery('media', p, sql)
|
? requests[index].md5
|
||||||
|
: String(row?.md5 || '').trim().toLowerCase()
|
||||||
if (result.success && result.rows && result.rows.length > 0) {
|
if (!inputMd5) continue
|
||||||
const row = result.rows[0]
|
const resolvedMd5 = row?.success && row?.data?.resolved_md5
|
||||||
if (row?.file_name) {
|
? String(row.data.resolved_md5).trim().toLowerCase()
|
||||||
// 提取不带扩展名的文件名作为实际视频 MD5
|
: ''
|
||||||
const realMd5 = String(row.file_name).replace(/\.[^.]+$/, '')
|
if (!resolvedMd5) continue
|
||||||
return realMd5
|
const cacheKey = `${scopeKey}|${inputMd5}`
|
||||||
|
this.writeTimedCache(this.hardlinkResolveCache, cacheKey, resolvedMd5, this.hardlinkCacheTtlMs, this.maxCacheEntries)
|
||||||
|
resolvedMap.set(inputMd5, resolvedMd5)
|
||||||
|
unresolvedSet.delete(inputMd5)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 兼容不支持批量接口的版本,回退单条请求。
|
||||||
|
for (const req of requests) {
|
||||||
|
try {
|
||||||
|
const single = await wcdbService.resolveVideoHardlinkMd5(req.md5, req.dbPath)
|
||||||
|
const resolvedMd5 = single.success && single.data?.resolved_md5
|
||||||
|
? String(single.data.resolved_md5).trim().toLowerCase()
|
||||||
|
: ''
|
||||||
|
if (!resolvedMd5) continue
|
||||||
|
const cacheKey = `${scopeKey}|${req.md5}`
|
||||||
|
this.writeTimedCache(this.hardlinkResolveCache, cacheKey, resolvedMd5, this.hardlinkCacheTtlMs, this.maxCacheEntries)
|
||||||
|
resolvedMap.set(req.md5, resolvedMd5)
|
||||||
|
unresolvedSet.delete(req.md5)
|
||||||
|
} catch { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 忽略错误
|
this.log('resolveVideoHardlinks 批量查询失败', { path: p, error: String(e) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const md5 of unresolvedSet) {
|
||||||
|
const cacheKey = `${scopeKey}|${md5}`
|
||||||
|
this.writeTimedCache(this.hardlinkResolveCache, cacheKey, null, this.hardlinkCacheTtlMs, this.maxCacheEntries)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return resolvedMap
|
||||||
|
}
|
||||||
|
|
||||||
|
private async queryVideoFileName(md5: string): Promise<string | undefined> {
|
||||||
|
const normalizedMd5 = String(md5 || '').trim().toLowerCase()
|
||||||
|
const dbPath = this.getDbPath()
|
||||||
|
const wxid = this.getMyWxid()
|
||||||
|
const cleanedWxid = this.cleanWxid(wxid)
|
||||||
|
|
||||||
|
this.log('queryVideoFileName 开始', { md5: normalizedMd5, wxid, cleanedWxid, dbPath })
|
||||||
|
|
||||||
|
if (!normalizedMd5 || !wxid || !dbPath) {
|
||||||
|
this.log('queryVideoFileName: 参数缺失', { hasMd5: !!normalizedMd5, hasWxid: !!wxid, hasDbPath: !!dbPath })
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedMap = await this.resolveVideoHardlinks([normalizedMd5], dbPath, wxid, cleanedWxid)
|
||||||
|
const resolved = resolvedMap.get(normalizedMd5)
|
||||||
|
if (resolved) {
|
||||||
|
this.log('queryVideoFileName 命中', { input: normalizedMd5, resolved })
|
||||||
|
return resolved
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async preloadVideoHardlinkMd5s(md5List: string[]): Promise<void> {
|
||||||
* 将文件转换为 data URL
|
const dbPath = this.getDbPath()
|
||||||
*/
|
const wxid = this.getMyWxid()
|
||||||
private fileToDataUrl(filePath: string, mimeType: string): string | undefined {
|
const cleanedWxid = this.cleanWxid(wxid)
|
||||||
|
if (!dbPath || !wxid) return
|
||||||
|
await this.resolveVideoHardlinks(md5List, dbPath, wxid, cleanedWxid)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fileToPosterUrl(filePath: string | undefined, mimeType: string, posterFormat: PosterFormat): string | undefined {
|
||||||
try {
|
try {
|
||||||
if (!existsSync(filePath)) return undefined
|
if (!filePath || !existsSync(filePath)) return undefined
|
||||||
|
if (posterFormat === 'fileUrl') return pathToFileURL(filePath).toString()
|
||||||
const buffer = readFileSync(filePath)
|
const buffer = readFileSync(filePath)
|
||||||
return `data:${mimeType};base64,${buffer.toString('base64')}`
|
return `data:${mimeType};base64,${buffer.toString('base64')}`
|
||||||
} catch {
|
} catch {
|
||||||
@@ -161,115 +305,448 @@ class VideoService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getOrBuildVideoIndex(videoBaseDir: string): Map<string, VideoIndexEntry> {
|
||||||
|
const cached = this.readTimedCache(this.videoDirIndexCache, videoBaseDir)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const index = new Map<string, VideoIndexEntry>()
|
||||||
|
const ensureEntry = (key: string): VideoIndexEntry => {
|
||||||
|
let entry = index.get(key)
|
||||||
|
if (!entry) {
|
||||||
|
entry = {}
|
||||||
|
index.set(key, entry)
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const yearMonthDirs = readdirSync(videoBaseDir)
|
||||||
|
.filter((dir) => {
|
||||||
|
const dirPath = join(videoBaseDir, dir)
|
||||||
|
try {
|
||||||
|
return statSync(dirPath).isDirectory()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.localeCompare(a))
|
||||||
|
|
||||||
|
for (const yearMonth of yearMonthDirs) {
|
||||||
|
const dirPath = join(videoBaseDir, yearMonth)
|
||||||
|
let files: string[] = []
|
||||||
|
try {
|
||||||
|
files = readdirSync(dirPath)
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const lower = file.toLowerCase()
|
||||||
|
const fullPath = join(dirPath, file)
|
||||||
|
|
||||||
|
if (lower.endsWith('.mp4')) {
|
||||||
|
const md5 = lower.slice(0, -4)
|
||||||
|
const entry = ensureEntry(md5)
|
||||||
|
if (!entry.videoPath) entry.videoPath = fullPath
|
||||||
|
if (md5.endsWith('_raw')) {
|
||||||
|
const baseMd5 = md5.replace(/_raw$/, '')
|
||||||
|
const baseEntry = ensureEntry(baseMd5)
|
||||||
|
if (!baseEntry.videoPath) baseEntry.videoPath = fullPath
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lower.endsWith('.jpg')) continue
|
||||||
|
const jpgBase = lower.slice(0, -4)
|
||||||
|
if (jpgBase.endsWith('_thumb')) {
|
||||||
|
const baseMd5 = jpgBase.slice(0, -6)
|
||||||
|
const entry = ensureEntry(baseMd5)
|
||||||
|
if (!entry.thumbPath) entry.thumbPath = fullPath
|
||||||
|
} else {
|
||||||
|
const entry = ensureEntry(jpgBase)
|
||||||
|
if (!entry.coverPath) entry.coverPath = fullPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, entry] of index) {
|
||||||
|
if (!key.endsWith('_raw')) continue
|
||||||
|
const baseKey = key.replace(/_raw$/, '')
|
||||||
|
const baseEntry = index.get(baseKey)
|
||||||
|
if (!baseEntry) continue
|
||||||
|
if (!entry.coverPath) entry.coverPath = baseEntry.coverPath
|
||||||
|
if (!entry.thumbPath) entry.thumbPath = baseEntry.thumbPath
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.log('构建视频索引失败', { videoBaseDir, error: String(e) })
|
||||||
|
}
|
||||||
|
|
||||||
|
this.writeTimedCache(
|
||||||
|
this.videoDirIndexCache,
|
||||||
|
videoBaseDir,
|
||||||
|
index,
|
||||||
|
this.videoIndexCacheTtlMs,
|
||||||
|
this.maxIndexEntries
|
||||||
|
)
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
private getVideoInfoFromIndex(
|
||||||
|
index: Map<string, VideoIndexEntry>,
|
||||||
|
md5: string,
|
||||||
|
includePoster = true,
|
||||||
|
posterFormat: PosterFormat = 'dataUrl'
|
||||||
|
): VideoInfo | null {
|
||||||
|
const normalizedMd5 = String(md5 || '').trim().toLowerCase()
|
||||||
|
if (!normalizedMd5) return null
|
||||||
|
|
||||||
|
const candidates = [normalizedMd5]
|
||||||
|
const baseMd5 = normalizedMd5.replace(/_raw$/, '')
|
||||||
|
if (baseMd5 !== normalizedMd5) {
|
||||||
|
candidates.push(baseMd5)
|
||||||
|
} else {
|
||||||
|
candidates.push(`${normalizedMd5}_raw`)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of candidates) {
|
||||||
|
const entry = index.get(key)
|
||||||
|
if (!entry?.videoPath) continue
|
||||||
|
if (!existsSync(entry.videoPath)) continue
|
||||||
|
if (!includePoster) {
|
||||||
|
return {
|
||||||
|
videoUrl: entry.videoPath,
|
||||||
|
exists: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
videoUrl: entry.videoPath,
|
||||||
|
coverUrl: this.fileToPosterUrl(entry.coverPath, 'image/jpeg', posterFormat),
|
||||||
|
thumbUrl: this.fileToPosterUrl(entry.thumbPath, 'image/jpeg', posterFormat),
|
||||||
|
exists: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fallbackScanVideo(
|
||||||
|
videoBaseDir: string,
|
||||||
|
realVideoMd5: string,
|
||||||
|
includePoster = true,
|
||||||
|
posterFormat: PosterFormat = 'dataUrl'
|
||||||
|
): VideoInfo | null {
|
||||||
|
try {
|
||||||
|
const yearMonthDirs = readdirSync(videoBaseDir)
|
||||||
|
.filter((dir) => {
|
||||||
|
const dirPath = join(videoBaseDir, dir)
|
||||||
|
try {
|
||||||
|
return statSync(dirPath).isDirectory()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.localeCompare(a))
|
||||||
|
|
||||||
|
for (const yearMonth of yearMonthDirs) {
|
||||||
|
const dirPath = join(videoBaseDir, yearMonth)
|
||||||
|
const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
|
||||||
|
if (!existsSync(videoPath)) continue
|
||||||
|
if (!includePoster) {
|
||||||
|
return {
|
||||||
|
videoUrl: videoPath,
|
||||||
|
exists: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const baseMd5 = realVideoMd5.replace(/_raw$/, '')
|
||||||
|
const coverPath = join(dirPath, `${baseMd5}.jpg`)
|
||||||
|
const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`)
|
||||||
|
return {
|
||||||
|
videoUrl: videoPath,
|
||||||
|
coverUrl: this.fileToPosterUrl(coverPath, 'image/jpeg', posterFormat),
|
||||||
|
thumbUrl: this.fileToPosterUrl(thumbPath, 'image/jpeg', posterFormat),
|
||||||
|
exists: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.log('fallback 扫描视频目录失败', { error: String(e) })
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFfmpegPath(): string {
|
||||||
|
const staticPath = getStaticFfmpegPath()
|
||||||
|
if (staticPath) return staticPath
|
||||||
|
return 'ffmpeg'
|
||||||
|
}
|
||||||
|
|
||||||
|
private async withPosterExtractSlot<T>(run: () => Promise<T>): Promise<T> {
|
||||||
|
if (this.posterExtractRunning >= this.maxPosterExtractConcurrency) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
this.posterExtractQueue.push(resolve)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
this.posterExtractRunning += 1
|
||||||
|
try {
|
||||||
|
return await run()
|
||||||
|
} finally {
|
||||||
|
this.posterExtractRunning = Math.max(0, this.posterExtractRunning - 1)
|
||||||
|
const next = this.posterExtractQueue.shift()
|
||||||
|
if (next) next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async extractFirstFramePoster(videoPath: string, posterFormat: PosterFormat): Promise<string | null> {
|
||||||
|
const normalizedPath = String(videoPath || '').trim()
|
||||||
|
if (!normalizedPath || !existsSync(normalizedPath)) return null
|
||||||
|
|
||||||
|
const cacheKey = `${normalizedPath}|format=${posterFormat}`
|
||||||
|
const cached = this.readTimedCache(this.extractedPosterCache, cacheKey)
|
||||||
|
if (cached !== undefined) return cached
|
||||||
|
|
||||||
|
const pending = this.pendingPosterExtract.get(cacheKey)
|
||||||
|
if (pending) return pending
|
||||||
|
|
||||||
|
const task = this.withPosterExtractSlot(() => new Promise<string | null>((resolve) => {
|
||||||
|
const tmpDir = join(app.getPath('temp'), 'weflow_video_frames')
|
||||||
|
try {
|
||||||
|
if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true })
|
||||||
|
} catch {
|
||||||
|
resolve(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const stableHash = crypto.createHash('sha1').update(normalizedPath).digest('hex').slice(0, 24)
|
||||||
|
const outputPath = join(tmpDir, `frame_${stableHash}.jpg`)
|
||||||
|
if (posterFormat === 'fileUrl' && existsSync(outputPath)) {
|
||||||
|
resolve(pathToFileURL(outputPath).toString())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const ffmpegPath = this.getFfmpegPath()
|
||||||
|
const args = [
|
||||||
|
'-hide_banner', '-loglevel', 'error', '-y',
|
||||||
|
'-ss', '0',
|
||||||
|
'-i', normalizedPath,
|
||||||
|
'-frames:v', '1',
|
||||||
|
'-q:v', '3',
|
||||||
|
outputPath
|
||||||
|
]
|
||||||
|
|
||||||
|
const errChunks: Buffer[] = []
|
||||||
|
let done = false
|
||||||
|
const finish = (value: string | null) => {
|
||||||
|
if (done) return
|
||||||
|
done = true
|
||||||
|
if (posterFormat === 'dataUrl') {
|
||||||
|
try {
|
||||||
|
if (existsSync(outputPath)) unlinkSync(outputPath)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolve(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const proc = spawn(ffmpegPath, args, {
|
||||||
|
stdio: ['ignore', 'ignore', 'pipe'],
|
||||||
|
windowsHide: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
try { proc.kill('SIGKILL') } catch { /* ignore */ }
|
||||||
|
finish(null)
|
||||||
|
}, 12000)
|
||||||
|
|
||||||
|
proc.stderr.on('data', (chunk: Buffer) => errChunks.push(chunk))
|
||||||
|
|
||||||
|
proc.on('error', () => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
finish(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
proc.on('close', (code: number) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
if (code !== 0 || !existsSync(outputPath)) {
|
||||||
|
if (errChunks.length > 0) {
|
||||||
|
this.log('extractFirstFrameDataUrl failed', {
|
||||||
|
videoPath: normalizedPath,
|
||||||
|
error: Buffer.concat(errChunks).toString().slice(0, 240)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
finish(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const jpgBuf = readFileSync(outputPath)
|
||||||
|
if (!jpgBuf.length) {
|
||||||
|
finish(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (posterFormat === 'fileUrl') {
|
||||||
|
finish(pathToFileURL(outputPath).toString())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
finish(`data:image/jpeg;base64,${jpgBuf.toString('base64')}`)
|
||||||
|
} catch {
|
||||||
|
finish(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
this.pendingPosterExtract.set(cacheKey, task)
|
||||||
|
try {
|
||||||
|
const result = await task
|
||||||
|
this.writeTimedCache(
|
||||||
|
this.extractedPosterCache,
|
||||||
|
cacheKey,
|
||||||
|
result,
|
||||||
|
this.extractedPosterCacheTtlMs,
|
||||||
|
this.maxCacheEntries
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
} finally {
|
||||||
|
this.pendingPosterExtract.delete(cacheKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensurePoster(info: VideoInfo, includePoster: boolean, posterFormat: PosterFormat): Promise<VideoInfo> {
|
||||||
|
if (!includePoster) return info
|
||||||
|
if (!info.exists || !info.videoUrl) return info
|
||||||
|
if (info.coverUrl || info.thumbUrl) return info
|
||||||
|
|
||||||
|
const extracted = await this.extractFirstFramePoster(info.videoUrl, posterFormat)
|
||||||
|
if (!extracted) return info
|
||||||
|
return {
|
||||||
|
...info,
|
||||||
|
coverUrl: extracted,
|
||||||
|
thumbUrl: extracted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据视频MD5获取视频文件信息
|
* 根据视频MD5获取视频文件信息
|
||||||
* 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/
|
* 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/
|
||||||
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
|
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
|
||||||
*/
|
*/
|
||||||
async getVideoInfo(videoMd5: string): Promise<VideoInfo> {
|
async getVideoInfo(videoMd5: string, options?: { includePoster?: boolean; posterFormat?: PosterFormat }): Promise<VideoInfo> {
|
||||||
|
const normalizedMd5 = String(videoMd5 || '').trim().toLowerCase()
|
||||||
|
const includePoster = options?.includePoster !== false
|
||||||
|
const posterFormat: PosterFormat = options?.posterFormat === 'fileUrl' ? 'fileUrl' : 'dataUrl'
|
||||||
const dbPath = this.getDbPath()
|
const dbPath = this.getDbPath()
|
||||||
const wxid = this.getMyWxid()
|
const wxid = this.getMyWxid()
|
||||||
|
|
||||||
if (!dbPath || !wxid || !videoMd5) {
|
this.log('getVideoInfo 开始', { videoMd5: normalizedMd5, dbPath, wxid })
|
||||||
|
|
||||||
|
if (!dbPath || !wxid || !normalizedMd5) {
|
||||||
|
this.log('getVideoInfo: 参数缺失', { hasDbPath: !!dbPath, hasWxid: !!wxid, hasVideoMd5: !!normalizedMd5 })
|
||||||
return { exists: false }
|
return { exists: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 先尝试从数据库查询真正的视频文件名
|
const scopeKey = this.getScopeKey(dbPath, wxid)
|
||||||
const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5
|
const cacheKey = `${scopeKey}|${normalizedMd5}|poster=${includePoster ? 1 : 0}|format=${posterFormat}`
|
||||||
|
|
||||||
// 检查 dbPath 是否已经包含 wxid,避免重复拼接
|
const cachedInfo = this.readTimedCache(this.videoInfoCache, cacheKey)
|
||||||
const dbPathLower = dbPath.toLowerCase()
|
if (cachedInfo) return cachedInfo
|
||||||
const wxidLower = wxid.toLowerCase()
|
|
||||||
const cleanedWxid = this.cleanWxid(wxid)
|
|
||||||
|
|
||||||
let videoBaseDir: string
|
const pending = this.pendingVideoInfo.get(cacheKey)
|
||||||
if (dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxid.toLowerCase())) {
|
if (pending) return pending
|
||||||
// dbPath 已经包含 wxid,直接使用
|
|
||||||
videoBaseDir = join(dbPath, 'msg', 'video')
|
const task = (async (): Promise<VideoInfo> => {
|
||||||
} else {
|
const realVideoMd5 = await this.queryVideoFileName(normalizedMd5) || normalizedMd5
|
||||||
// dbPath 不包含 wxid,需要拼接
|
const videoBaseDir = this.resolveVideoBaseDir(dbPath, wxid)
|
||||||
videoBaseDir = join(dbPath, wxid, 'msg', 'video')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!existsSync(videoBaseDir)) {
|
if (!existsSync(videoBaseDir)) {
|
||||||
return { exists: false }
|
const miss = { exists: false }
|
||||||
|
this.writeTimedCache(this.videoInfoCache, cacheKey, miss, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||||
|
return miss
|
||||||
}
|
}
|
||||||
|
|
||||||
// 遍历年月目录查找视频文件
|
const index = this.getOrBuildVideoIndex(videoBaseDir)
|
||||||
|
const indexed = this.getVideoInfoFromIndex(index, realVideoMd5, includePoster, posterFormat)
|
||||||
|
if (indexed) {
|
||||||
|
const withPoster = await this.ensurePoster(indexed, includePoster, posterFormat)
|
||||||
|
this.writeTimedCache(this.videoInfoCache, cacheKey, withPoster, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||||
|
return withPoster
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = this.fallbackScanVideo(videoBaseDir, realVideoMd5, includePoster, posterFormat)
|
||||||
|
if (fallback) {
|
||||||
|
const withPoster = await this.ensurePoster(fallback, includePoster, posterFormat)
|
||||||
|
this.writeTimedCache(this.videoInfoCache, cacheKey, withPoster, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||||
|
return withPoster
|
||||||
|
}
|
||||||
|
|
||||||
|
const miss = { exists: false }
|
||||||
|
this.writeTimedCache(this.videoInfoCache, cacheKey, miss, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||||
|
this.log('getVideoInfo: 未找到视频', { inputMd5: normalizedMd5, resolvedMd5: realVideoMd5 })
|
||||||
|
return miss
|
||||||
|
})()
|
||||||
|
|
||||||
|
this.pendingVideoInfo.set(cacheKey, task)
|
||||||
try {
|
try {
|
||||||
const allDirs = readdirSync(videoBaseDir)
|
return await task
|
||||||
|
} finally {
|
||||||
// 支持多种目录格式: YYYY-MM, YYYYMM, 或其他
|
this.pendingVideoInfo.delete(cacheKey)
|
||||||
const yearMonthDirs = allDirs
|
|
||||||
.filter(dir => {
|
|
||||||
const dirPath = join(videoBaseDir, dir)
|
|
||||||
return statSync(dirPath).isDirectory()
|
|
||||||
})
|
|
||||||
.sort((a, b) => b.localeCompare(a)) // 从最新的目录开始查找
|
|
||||||
|
|
||||||
for (const yearMonth of yearMonthDirs) {
|
|
||||||
const dirPath = join(videoBaseDir, yearMonth)
|
|
||||||
|
|
||||||
const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
|
|
||||||
const coverPath = join(dirPath, `${realVideoMd5}.jpg`)
|
|
||||||
const thumbPath = join(dirPath, `${realVideoMd5}_thumb.jpg`)
|
|
||||||
|
|
||||||
// 检查视频文件是否存在
|
|
||||||
if (existsSync(videoPath)) {
|
|
||||||
return {
|
|
||||||
videoUrl: videoPath, // 返回文件路径,前端通过 readFile 读取
|
|
||||||
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
|
|
||||||
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
|
|
||||||
exists: true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// 忽略错误
|
|
||||||
}
|
|
||||||
|
|
||||||
return { exists: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据消息内容解析视频MD5
|
* 根据消息内容解析视频MD5
|
||||||
*/
|
*/
|
||||||
parseVideoMd5(content: string): string | undefined {
|
parseVideoMd5(content: string): string | undefined {
|
||||||
|
|
||||||
// 打印前500字符看看 XML 结构
|
|
||||||
|
|
||||||
if (!content) return undefined
|
if (!content) return undefined
|
||||||
|
|
||||||
|
// 打印原始 XML 前 800 字符,帮助排查自己发的视频结构
|
||||||
|
this.log('parseVideoMd5 原始内容', { preview: content.slice(0, 800) })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 提取所有可能的 md5 值进行日志
|
// 收集所有 md5 相关属性,方便对比
|
||||||
const allMd5s: string[] = []
|
const allMd5Attrs: string[] = []
|
||||||
const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]+)['"]/gi
|
const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]*)['"]/gi
|
||||||
let match
|
let match
|
||||||
while ((match = md5Regex.exec(content)) !== null) {
|
while ((match = md5Regex.exec(content)) !== null) {
|
||||||
allMd5s.push(`${match[0]}`)
|
allMd5Attrs.push(match[0])
|
||||||
|
}
|
||||||
|
this.log('parseVideoMd5 所有 md5 属性', { attrs: allMd5Attrs })
|
||||||
|
|
||||||
|
// 方法1:从 <videomsg md5="..."> 提取(收到的视频)
|
||||||
|
const videoMsgMd5Match = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||||
|
if (videoMsgMd5Match) {
|
||||||
|
this.log('parseVideoMd5 命中 videomsg md5 属性', { md5: videoMsgMd5Match[1] })
|
||||||
|
return videoMsgMd5Match[1].toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提取 md5(用于查询 hardlink.db)
|
// 方法2:从 <videomsg rawmd5="..."> 提取(自己发的视频,没有 md5 只有 rawmd5)
|
||||||
// 注意:不是 rawmd5,rawmd5 是另一个值
|
const rawMd5Match = /<videomsg[^>]*\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||||
// 格式: md5="xxx" 或 <md5>xxx</md5>
|
if (rawMd5Match) {
|
||||||
|
this.log('parseVideoMd5 命中 videomsg rawmd5 属性(自发视频)', { rawmd5: rawMd5Match[1] })
|
||||||
// 尝试从videomsg标签中提取md5
|
return rawMd5Match[1].toLowerCase()
|
||||||
const videoMsgMatch = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
|
||||||
if (videoMsgMatch) {
|
|
||||||
return videoMsgMatch[1].toLowerCase()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const attrMatch = /\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
// 方法3:任意属性 md5="..."(非 rawmd5/cdnthumbaeskey 等)
|
||||||
|
const attrMatch = /(?<![a-z])md5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||||
if (attrMatch) {
|
if (attrMatch) {
|
||||||
|
this.log('parseVideoMd5 命中通用 md5 属性', { md5: attrMatch[1] })
|
||||||
return attrMatch[1].toLowerCase()
|
return attrMatch[1].toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
const md5Match = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
|
// 方法4:<md5>...</md5> 标签
|
||||||
if (md5Match) {
|
const md5TagMatch = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
|
||||||
return md5Match[1].toLowerCase()
|
if (md5TagMatch) {
|
||||||
|
this.log('parseVideoMd5 命中 md5 标签', { md5: md5TagMatch[1] })
|
||||||
|
return md5TagMatch[1].toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 方法5:兜底取 rawmd5 属性(任意位置)
|
||||||
|
const rawMd5Fallback = /\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||||
|
if (rawMd5Fallback) {
|
||||||
|
this.log('parseVideoMd5 兜底命中 rawmd5', { rawmd5: rawMd5Fallback[1] })
|
||||||
|
return rawMd5Fallback[1].toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log('parseVideoMd5 未提取到任何 md5', { contentLength: content.length })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[VideoService] 解析视频MD5失败:', e)
|
this.log('parseVideoMd5 异常', { error: String(e) })
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined
|
return undefined
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import { existsSync, mkdirSync, statSync, unlinkSync, createWriteStream } from 'fs'
|
import { existsSync, mkdirSync, statSync, unlinkSync, createWriteStream, openSync, writeSync, closeSync } from 'fs'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import * as https from 'https'
|
import * as https from 'https'
|
||||||
import * as http from 'http'
|
import * as http from 'http'
|
||||||
@@ -24,6 +24,7 @@ type DownloadProgress = {
|
|||||||
downloadedBytes: number
|
downloadedBytes: number
|
||||||
totalBytes?: number
|
totalBytes?: number
|
||||||
percent?: number
|
percent?: number
|
||||||
|
speed?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const SENSEVOICE_MODEL: ModelInfo = {
|
const SENSEVOICE_MODEL: ModelInfo = {
|
||||||
@@ -47,6 +48,46 @@ export class VoiceTranscribeService {
|
|||||||
private recognizer: OfflineRecognizer | null = null
|
private recognizer: OfflineRecognizer | null = null
|
||||||
private isInitializing = false
|
private isInitializing = false
|
||||||
|
|
||||||
|
private buildTranscribeWorkerEnv(): NodeJS.ProcessEnv {
|
||||||
|
const env: NodeJS.ProcessEnv = { ...process.env }
|
||||||
|
const platform = process.platform === 'win32' ? 'win' : process.platform
|
||||||
|
const platformPkg = `sherpa-onnx-${platform}-${process.arch}`
|
||||||
|
const candidates = [
|
||||||
|
join(__dirname, '..', 'node_modules', platformPkg),
|
||||||
|
join(__dirname, 'node_modules', platformPkg),
|
||||||
|
join(process.cwd(), 'node_modules', platformPkg),
|
||||||
|
process.resourcesPath ? join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', platformPkg) : ''
|
||||||
|
].filter((item): item is string => Boolean(item) && existsSync(item))
|
||||||
|
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
const key = 'DYLD_LIBRARY_PATH'
|
||||||
|
const existing = env[key] || ''
|
||||||
|
const merged = [...candidates, ...existing.split(':').filter(Boolean)]
|
||||||
|
env[key] = Array.from(new Set(merged)).join(':')
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
|
||||||
|
}
|
||||||
|
} else if (process.platform === 'linux') {
|
||||||
|
const key = 'LD_LIBRARY_PATH'
|
||||||
|
const existing = env[key] || ''
|
||||||
|
const merged = [...candidates, ...existing.split(':').filter(Boolean)]
|
||||||
|
env[key] = Array.from(new Set(merged)).join(':')
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
|
||||||
|
}
|
||||||
|
} else if (process.platform === 'win32') {
|
||||||
|
// Windows: 把 sherpa-onnx 所在目录加到 PATH,否则 native module 找不到依赖
|
||||||
|
const existing = env['PATH'] || ''
|
||||||
|
const merged = [...candidates, ...existing.split(';').filter(Boolean)]
|
||||||
|
env['PATH'] = Array.from(new Set(merged)).join(';')
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
private resolveModelDir(): string {
|
private resolveModelDir(): string {
|
||||||
const configured = this.configService.get('whisperModelDir') as string | undefined
|
const configured = this.configService.get('whisperModelDir') as string | undefined
|
||||||
if (configured) return configured
|
if (configured) return configured
|
||||||
@@ -123,44 +164,44 @@ export class VoiceTranscribeService {
|
|||||||
percent: 0
|
percent: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
// 下载模型文件 (40%)
|
// 下载模型文件 (80% 权重)
|
||||||
console.info('[VoiceTranscribe] 开始下载模型文件...')
|
console.info('[VoiceTranscribe] 开始下载模型文件...')
|
||||||
await this.downloadToFile(
|
await this.downloadToFile(
|
||||||
MODEL_DOWNLOAD_URLS.model,
|
MODEL_DOWNLOAD_URLS.model,
|
||||||
modelPath,
|
modelPath,
|
||||||
'model',
|
'model',
|
||||||
(downloaded, total) => {
|
(downloaded, total, speed) => {
|
||||||
const percent = total ? (downloaded / total) * 40 : undefined
|
const percent = total ? (downloaded / total) * 80 : 0
|
||||||
onProgress?.({
|
onProgress?.({
|
||||||
modelName: SENSEVOICE_MODEL.name,
|
modelName: SENSEVOICE_MODEL.name,
|
||||||
downloadedBytes: downloaded,
|
downloadedBytes: downloaded,
|
||||||
totalBytes: SENSEVOICE_MODEL.sizeBytes,
|
totalBytes: SENSEVOICE_MODEL.sizeBytes,
|
||||||
percent
|
percent,
|
||||||
|
speed
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// 下载 tokens 文件 (30%)
|
// 下载 tokens 文件 (20% 权重)
|
||||||
console.info('[VoiceTranscribe] 开始下载 tokens 文件...')
|
console.info('[VoiceTranscribe] 开始下载 tokens 文件...')
|
||||||
await this.downloadToFile(
|
await this.downloadToFile(
|
||||||
MODEL_DOWNLOAD_URLS.tokens,
|
MODEL_DOWNLOAD_URLS.tokens,
|
||||||
tokensPath,
|
tokensPath,
|
||||||
'tokens',
|
'tokens',
|
||||||
(downloaded, total) => {
|
(downloaded, total, speed) => {
|
||||||
const modelSize = existsSync(modelPath) ? statSync(modelPath).size : 0
|
const modelSize = existsSync(modelPath) ? statSync(modelPath).size : 0
|
||||||
const percent = total ? 40 + (downloaded / total) * 30 : 40
|
const percent = total ? 80 + (downloaded / total) * 20 : 80
|
||||||
onProgress?.({
|
onProgress?.({
|
||||||
modelName: SENSEVOICE_MODEL.name,
|
modelName: SENSEVOICE_MODEL.name,
|
||||||
downloadedBytes: modelSize + downloaded,
|
downloadedBytes: modelSize + downloaded,
|
||||||
totalBytes: SENSEVOICE_MODEL.sizeBytes,
|
totalBytes: SENSEVOICE_MODEL.sizeBytes,
|
||||||
percent
|
percent,
|
||||||
|
speed
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
console.info('[VoiceTranscribe] 模型下载完成')
|
console.info('[VoiceTranscribe] 模型下载完成')
|
||||||
|
|
||||||
console.info('[VoiceTranscribe] 所有文件下载完成')
|
|
||||||
return { success: true, modelPath, tokensPath }
|
return { success: true, modelPath, tokensPath }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
|
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
|
||||||
@@ -180,7 +221,7 @@ export class VoiceTranscribeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 转写 WAV 音频数据 (后台 Worker Threads 版本)
|
* 转写 WAV 音频数据
|
||||||
*/
|
*/
|
||||||
async transcribeWavBuffer(
|
async transcribeWavBuffer(
|
||||||
wavData: Buffer,
|
wavData: Buffer,
|
||||||
@@ -197,56 +238,62 @@ export class VoiceTranscribeService {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取配置的语言列表,如果没有传入则从配置读取
|
|
||||||
let supportedLanguages = languages
|
let supportedLanguages = languages
|
||||||
if (!supportedLanguages || supportedLanguages.length === 0) {
|
if (!supportedLanguages || supportedLanguages.length === 0) {
|
||||||
supportedLanguages = this.configService.get('transcribeLanguages')
|
supportedLanguages = this.configService.get('transcribeLanguages')
|
||||||
// 如果配置中也没有或为空,使用默认值
|
|
||||||
if (!supportedLanguages || supportedLanguages.length === 0) {
|
if (!supportedLanguages || supportedLanguages.length === 0) {
|
||||||
supportedLanguages = ['zh', 'yue']
|
supportedLanguages = ['zh', 'yue']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { Worker } = require('worker_threads')
|
const { fork } = require('child_process')
|
||||||
// main.js 和 transcribeWorker.js 同在 dist-electron 目录下
|
|
||||||
const workerPath = join(__dirname, 'transcribeWorker.js')
|
const workerPath = join(__dirname, 'transcribeWorker.js')
|
||||||
|
|
||||||
const worker = new Worker(workerPath, {
|
const worker = fork(workerPath, [], {
|
||||||
workerData: {
|
env: this.buildTranscribeWorkerEnv(),
|
||||||
|
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
||||||
|
serialization: 'advanced'
|
||||||
|
})
|
||||||
|
worker.send({
|
||||||
modelPath,
|
modelPath,
|
||||||
tokensPath,
|
tokensPath,
|
||||||
wavData,
|
wavData,
|
||||||
sampleRate: 16000,
|
sampleRate: 16000,
|
||||||
languages: supportedLanguages
|
languages: supportedLanguages
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
let finalTranscript = ''
|
let finalTranscript = ''
|
||||||
|
|
||||||
worker.on('message', (msg: any) => {
|
worker.on('message', (msg: any) => {
|
||||||
|
|
||||||
if (msg.type === 'partial') {
|
if (msg.type === 'partial') {
|
||||||
onPartial?.(msg.text)
|
onPartial?.(msg.text)
|
||||||
} else if (msg.type === 'final') {
|
} else if (msg.type === 'final') {
|
||||||
finalTranscript = msg.text
|
finalTranscript = msg.text
|
||||||
|
|
||||||
resolve({ success: true, transcript: finalTranscript })
|
resolve({ success: true, transcript: finalTranscript })
|
||||||
worker.terminate()
|
worker.disconnect()
|
||||||
|
worker.kill()
|
||||||
} else if (msg.type === 'error') {
|
} else if (msg.type === 'error') {
|
||||||
console.error('[VoiceTranscribe] Worker 错误:', msg.error)
|
console.error('[VoiceTranscribe] Worker 错误:', msg.error)
|
||||||
resolve({ success: false, error: msg.error })
|
resolve({ success: false, error: msg.error })
|
||||||
worker.terminate()
|
worker.disconnect()
|
||||||
|
worker.kill()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
worker.on('error', (err: Error) => {
|
worker.on('error', (err: Error) => resolve({ success: false, error: String(err) }))
|
||||||
resolve({ success: false, error: String(err) })
|
worker.on('exit', (code: number | null, signal: string | null) => {
|
||||||
})
|
if (code === null || signal === 'SIGSEGV') {
|
||||||
|
|
||||||
|
console.error(`[VoiceTranscribe] Worker 异常崩溃,信号: ${signal}。可能是由于底层 C++ 运行库在当前系统上发生段错误。`);
|
||||||
|
resolve({
|
||||||
|
success: false,
|
||||||
|
error: 'SEGFAULT_ERROR'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
worker.on('exit', (code: number) => {
|
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
console.error(`[VoiceTranscribe] Worker stopped with exit code ${code}`)
|
resolve({ success: false, error: `Worker exited with code ${code}` });
|
||||||
resolve({ success: false, error: `Worker exited with code ${code}` })
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -257,121 +304,240 @@ export class VoiceTranscribeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 下载文件
|
* 下载文件 (支持多线程)
|
||||||
*/
|
*/
|
||||||
private downloadToFile(
|
private async downloadToFile(
|
||||||
url: string,
|
url: string,
|
||||||
targetPath: string,
|
targetPath: string,
|
||||||
fileName: string,
|
fileName: string,
|
||||||
onProgress?: (downloaded: number, total?: number) => void,
|
onProgress?: (downloaded: number, total?: number, speed?: number) => void
|
||||||
remainingRedirects = 5
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
if (existsSync(targetPath)) {
|
||||||
|
unlinkSync(targetPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(`[VoiceTranscribe] 准备下载 ${fileName}: ${url}`)
|
||||||
|
|
||||||
|
// 1. 探测支持情况
|
||||||
|
let probeResult
|
||||||
|
try {
|
||||||
|
probeResult = await this.probeUrl(url)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[VoiceTranscribe] ${fileName} 探测失败,使用单线程`, err)
|
||||||
|
return this.downloadSingleThread(url, targetPath, fileName, onProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { totalSize, acceptRanges, finalUrl } = probeResult
|
||||||
|
|
||||||
|
// 如果文件太小 (< 2MB) 或者不支持 Range,使用单线程
|
||||||
|
if (totalSize < 2 * 1024 * 1024 || !acceptRanges) {
|
||||||
|
return this.downloadSingleThread(finalUrl, targetPath, fileName, onProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(`[VoiceTranscribe] ${fileName} 开始多线程下载 (4 线程), 大小: ${(totalSize / 1024 / 1024).toFixed(2)} MB`)
|
||||||
|
|
||||||
|
const threadCount = 4
|
||||||
|
const chunkSize = Math.ceil(totalSize / threadCount)
|
||||||
|
const fd = openSync(targetPath, 'w')
|
||||||
|
|
||||||
|
let downloadedTotal = 0
|
||||||
|
let lastDownloaded = 0
|
||||||
|
let lastTime = Date.now()
|
||||||
|
let speed = 0
|
||||||
|
|
||||||
|
const speedInterval = setInterval(() => {
|
||||||
|
const now = Date.now()
|
||||||
|
const duration = (now - lastTime) / 1000
|
||||||
|
if (duration > 0) {
|
||||||
|
speed = (downloadedTotal - lastDownloaded) / duration
|
||||||
|
lastDownloaded = downloadedTotal
|
||||||
|
lastTime = now
|
||||||
|
onProgress?.(downloadedTotal, totalSize, speed)
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const promises = []
|
||||||
|
for (let i = 0; i < threadCount; i++) {
|
||||||
|
const start = i * chunkSize
|
||||||
|
const end = i === threadCount - 1 ? totalSize - 1 : (i + 1) * chunkSize - 1
|
||||||
|
|
||||||
|
promises.push(this.downloadChunk(finalUrl, fd, start, end, (bytes) => {
|
||||||
|
downloadedTotal += bytes
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(promises)
|
||||||
|
// Final progress update
|
||||||
|
onProgress?.(totalSize, totalSize, 0)
|
||||||
|
console.info(`[VoiceTranscribe] ${fileName} 多线程下载完成`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[VoiceTranscribe] ${fileName} 多线程下载失败:`, err)
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
clearInterval(speedInterval)
|
||||||
|
closeSync(fd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async probeUrl(url: string, remainingRedirects = 5): Promise<{ totalSize: number, acceptRanges: boolean, finalUrl: string }> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const protocol = url.startsWith('https') ? https : http
|
const protocol = url.startsWith('https') ? https : http
|
||||||
console.info(`[VoiceTranscribe] 下载 ${fileName}:`, url)
|
const options = {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Referer': 'https://modelscope.cn/',
|
||||||
|
'Range': 'bytes=0-0'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = protocol.get(url, options, (res) => {
|
||||||
|
if ([301, 302, 303, 307, 308].includes(res.statusCode || 0)) {
|
||||||
|
const location = res.headers.location
|
||||||
|
if (location && remainingRedirects > 0) {
|
||||||
|
const nextUrl = new URL(location, url).href
|
||||||
|
this.probeUrl(nextUrl, remainingRedirects - 1).then(resolve).catch(reject)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.statusCode !== 206 && res.statusCode !== 200) {
|
||||||
|
reject(new Error(`Probe failed: HTTP ${res.statusCode}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentRange = res.headers['content-range']
|
||||||
|
let totalSize = 0
|
||||||
|
if (contentRange) {
|
||||||
|
const parts = contentRange.split('/')
|
||||||
|
totalSize = parseInt(parts[parts.length - 1], 10)
|
||||||
|
} else {
|
||||||
|
totalSize = parseInt(res.headers['content-length'] || '0', 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
const acceptRanges = res.headers['accept-ranges'] === 'bytes' || !!contentRange
|
||||||
|
resolve({ totalSize, acceptRanges, finalUrl: url })
|
||||||
|
res.destroy()
|
||||||
|
})
|
||||||
|
req.on('error', reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async downloadChunk(url: string, fd: number, start: number, end: number, onData: (bytes: number) => void): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const protocol = url.startsWith('https') ? https : http
|
||||||
const options = {
|
const options = {
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
},
|
'Referer': 'https://modelscope.cn/',
|
||||||
timeout: 30000 // 30秒连接超时
|
'Range': `bytes=${start}-${end}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = protocol.get(url, options, (res) => {
|
||||||
|
if (res.statusCode !== 206) {
|
||||||
|
reject(new Error(`Chunk download failed: HTTP ${res.statusCode}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentOffset = start
|
||||||
|
res.on('data', (chunk: Buffer) => {
|
||||||
|
try {
|
||||||
|
writeSync(fd, chunk, 0, chunk.length, currentOffset)
|
||||||
|
currentOffset += chunk.length
|
||||||
|
onData(chunk.length)
|
||||||
|
} catch (err) {
|
||||||
|
reject(err)
|
||||||
|
res.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
res.on('end', () => resolve())
|
||||||
|
res.on('error', reject)
|
||||||
|
})
|
||||||
|
req.on('error', reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async downloadSingleThread(url: string, targetPath: string, fileName: string, onProgress?: (downloaded: number, total?: number, speed?: number) => void, remainingRedirects = 5): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const protocol = url.startsWith('https') ? https : http
|
||||||
|
const options = {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Referer': 'https://modelscope.cn/'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = protocol.get(url, options, (response) => {
|
const request = protocol.get(url, options, (response) => {
|
||||||
console.info(`[VoiceTranscribe] ${fileName} 响应状态:`, response.statusCode)
|
if ([301, 302, 303, 307, 308].includes(response.statusCode || 0)) {
|
||||||
|
const location = response.headers.location
|
||||||
// 处理重定向
|
if (location && remainingRedirects > 0) {
|
||||||
if ([301, 302, 303, 307, 308].includes(response.statusCode || 0) && response.headers.location) {
|
const nextUrl = new URL(location, url).href
|
||||||
if (remainingRedirects <= 0) {
|
this.downloadSingleThread(nextUrl, targetPath, fileName, onProgress, remainingRedirects - 1).then(resolve).catch(reject)
|
||||||
reject(new Error('重定向次数过多'))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
console.info(`[VoiceTranscribe] 重定向到:`, response.headers.location)
|
|
||||||
this.downloadToFile(response.headers.location, targetPath, fileName, onProgress, remainingRedirects - 1)
|
|
||||||
.then(resolve)
|
|
||||||
.catch(reject)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.statusCode !== 200) {
|
if (response.statusCode !== 200) {
|
||||||
reject(new Error(`下载失败: HTTP ${response.statusCode}`))
|
reject(new Error(`Fallback download failed: HTTP ${response.statusCode}`))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalBytes = Number(response.headers['content-length'] || 0) || undefined
|
const totalBytes = Number(response.headers['content-length'] || 0) || undefined
|
||||||
let downloadedBytes = 0
|
let downloadedBytes = 0
|
||||||
|
let lastDownloaded = 0
|
||||||
|
let lastTime = Date.now()
|
||||||
|
let speed = 0
|
||||||
|
|
||||||
console.info(`[VoiceTranscribe] ${fileName} 文件大小:`, totalBytes ? `${(totalBytes / 1024 / 1024).toFixed(2)} MB` : '未知')
|
const speedInterval = setInterval(() => {
|
||||||
|
const now = Date.now()
|
||||||
|
const duration = (now - lastTime) / 1000
|
||||||
|
if (duration > 0) {
|
||||||
|
speed = (downloadedBytes - lastDownloaded) / duration
|
||||||
|
lastDownloaded = downloadedBytes
|
||||||
|
lastTime = now
|
||||||
|
onProgress?.(downloadedBytes, totalBytes, speed)
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
const writer = createWriteStream(targetPath)
|
const writer = createWriteStream(targetPath)
|
||||||
|
|
||||||
// 设置数据接收超时(60秒没有数据则超时)
|
|
||||||
let lastDataTime = Date.now()
|
|
||||||
const dataTimeout = setInterval(() => {
|
|
||||||
if (Date.now() - lastDataTime > 60000) {
|
|
||||||
clearInterval(dataTimeout)
|
|
||||||
response.destroy()
|
|
||||||
writer.close()
|
|
||||||
reject(new Error('下载超时:60秒内未收到数据'))
|
|
||||||
}
|
|
||||||
}, 5000)
|
|
||||||
|
|
||||||
response.on('data', (chunk) => {
|
response.on('data', (chunk) => {
|
||||||
lastDataTime = Date.now()
|
|
||||||
downloadedBytes += chunk.length
|
downloadedBytes += chunk.length
|
||||||
onProgress?.(downloadedBytes, totalBytes)
|
|
||||||
})
|
|
||||||
|
|
||||||
response.on('error', (error) => {
|
|
||||||
clearInterval(dataTimeout)
|
|
||||||
try { writer.close() } catch { }
|
|
||||||
console.error(`[VoiceTranscribe] ${fileName} 响应错误:`, error)
|
|
||||||
reject(error)
|
|
||||||
})
|
|
||||||
|
|
||||||
writer.on('error', (error) => {
|
|
||||||
clearInterval(dataTimeout)
|
|
||||||
try { writer.close() } catch { }
|
|
||||||
console.error(`[VoiceTranscribe] ${fileName} 写入错误:`, error)
|
|
||||||
reject(error)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
writer.on('finish', () => {
|
writer.on('finish', () => {
|
||||||
clearInterval(dataTimeout)
|
clearInterval(speedInterval)
|
||||||
writer.close()
|
writer.close()
|
||||||
console.info(`[VoiceTranscribe] ${fileName} 下载完成:`, targetPath)
|
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
writer.on('error', (err) => {
|
||||||
|
clearInterval(speedInterval)
|
||||||
|
// 确保在错误情况下也关闭文件句柄
|
||||||
|
writer.destroy()
|
||||||
|
reject(err)
|
||||||
|
})
|
||||||
|
|
||||||
|
response.on('error', (err) => {
|
||||||
|
clearInterval(speedInterval)
|
||||||
|
// 确保在响应错误时也关闭文件句柄
|
||||||
|
writer.destroy()
|
||||||
|
reject(err)
|
||||||
|
})
|
||||||
|
|
||||||
response.pipe(writer)
|
response.pipe(writer)
|
||||||
})
|
})
|
||||||
|
request.on('error', reject)
|
||||||
request.on('timeout', () => {
|
|
||||||
request.destroy()
|
|
||||||
console.error(`[VoiceTranscribe] ${fileName} 连接超时`)
|
|
||||||
reject(new Error('连接超时'))
|
|
||||||
})
|
|
||||||
|
|
||||||
request.on('error', (error) => {
|
|
||||||
console.error(`[VoiceTranscribe] ${fileName} 请求错误:`, error)
|
|
||||||
reject(error)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 清理资源
|
|
||||||
*/
|
|
||||||
dispose() {
|
dispose() {
|
||||||
if (this.recognizer) {
|
if (this.recognizer) {
|
||||||
try {
|
|
||||||
// sherpa-onnx 的 recognizer 可能需要手动释放
|
|
||||||
this.recognizer = null
|
this.recognizer = null
|
||||||
} catch (error) {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const voiceTranscribeService = new VoiceTranscribeService()
|
export const voiceTranscribeService = new VoiceTranscribeService()
|
||||||
|
|
||||||
|
|||||||
180
electron/services/wasmService.ts
Normal file
180
electron/services/wasmService.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
import vm from 'vm';
|
||||||
|
|
||||||
|
let app: any;
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
app = require('electron').app;
|
||||||
|
} catch (e) {
|
||||||
|
app = { isPackaged: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// This service handles the loading and execution of the WeChat WASM module
|
||||||
|
// to generate the correct Isaac64 keystream for video decryption.
|
||||||
|
export class WasmService {
|
||||||
|
private static instance: WasmService;
|
||||||
|
private module: any = null;
|
||||||
|
private wasmLoaded = false;
|
||||||
|
private initPromise: Promise<void> | null = null;
|
||||||
|
private capturedKeystream: Uint8Array | null = null;
|
||||||
|
|
||||||
|
private constructor() { }
|
||||||
|
|
||||||
|
public static getInstance(): WasmService {
|
||||||
|
if (!WasmService.instance) {
|
||||||
|
WasmService.instance = new WasmService();
|
||||||
|
}
|
||||||
|
return WasmService.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async init(): Promise<void> {
|
||||||
|
if (this.wasmLoaded) return;
|
||||||
|
if (this.initPromise) return this.initPromise;
|
||||||
|
|
||||||
|
this.initPromise = new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
// For dev, files are in electron/assets/wasm
|
||||||
|
// __dirname in dev (from dist-electron) is .../dist-electron
|
||||||
|
// So we need to go up one level and then into electron/assets/wasm
|
||||||
|
const isDev = !app.isPackaged;
|
||||||
|
const basePath = isDev
|
||||||
|
? path.join(__dirname, '../electron/assets/wasm')
|
||||||
|
: path.join(process.resourcesPath, 'assets/wasm'); // Adjust as needed for production build
|
||||||
|
|
||||||
|
const wasmPath = path.join(basePath, 'wasm_video_decode.wasm');
|
||||||
|
const jsPath = path.join(basePath, 'wasm_video_decode.js');
|
||||||
|
|
||||||
|
|
||||||
|
if (!fs.existsSync(wasmPath) || !fs.existsSync(jsPath)) {
|
||||||
|
throw new Error(`WASM files not found at ${basePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wasmBinary = fs.readFileSync(wasmPath);
|
||||||
|
|
||||||
|
// Emulate Emscripten environment
|
||||||
|
// We must use 'any' for global mocking
|
||||||
|
const mockGlobal: any = {
|
||||||
|
console: console,
|
||||||
|
Buffer: Buffer,
|
||||||
|
Uint8Array: Uint8Array,
|
||||||
|
Int8Array: Int8Array,
|
||||||
|
Uint16Array: Uint16Array,
|
||||||
|
Int16Array: Int16Array,
|
||||||
|
Uint32Array: Uint32Array,
|
||||||
|
Int32Array: Int32Array,
|
||||||
|
Float32Array: Float32Array,
|
||||||
|
Float64Array: Float64Array,
|
||||||
|
BigInt64Array: BigInt64Array,
|
||||||
|
BigUint64Array: BigUint64Array,
|
||||||
|
Array: Array,
|
||||||
|
Object: Object,
|
||||||
|
Function: Function,
|
||||||
|
String: String,
|
||||||
|
Number: Number,
|
||||||
|
Boolean: Boolean,
|
||||||
|
Error: Error,
|
||||||
|
Promise: Promise,
|
||||||
|
require: require,
|
||||||
|
process: process,
|
||||||
|
setTimeout: setTimeout,
|
||||||
|
clearTimeout: clearTimeout,
|
||||||
|
setInterval: setInterval,
|
||||||
|
clearInterval: clearInterval,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Define Module
|
||||||
|
mockGlobal.Module = {
|
||||||
|
onRuntimeInitialized: () => {
|
||||||
|
this.wasmLoaded = true;
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
wasmBinary: wasmBinary,
|
||||||
|
print: (text: string) => console.log('[WASM stdout]', text),
|
||||||
|
printErr: (text: string) => console.error('[WASM stderr]', text)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Define necessary globals for Emscripten loader
|
||||||
|
mockGlobal.self = mockGlobal;
|
||||||
|
mockGlobal.self.location = { href: jsPath };
|
||||||
|
mockGlobal.WorkerGlobalScope = function () { };
|
||||||
|
mockGlobal.VTS_WASM_URL = `file://${wasmPath}`; // Needs a URL, file protocol works in Node context for our mock?
|
||||||
|
|
||||||
|
// Define the callback function that WASM calls to return data
|
||||||
|
// The WASM module calls `wasm_isaac_generate(ptr, size)`
|
||||||
|
mockGlobal.wasm_isaac_generate = (ptr: number, size: number) => {
|
||||||
|
// console.log(`[WasmService] wasm_isaac_generate called: ptr=${ptr}, size=${size}`);
|
||||||
|
const buffer = new Uint8Array(mockGlobal.Module.HEAPU8.buffer, ptr, size);
|
||||||
|
// Copy the data because WASM memory might change or be invalidated
|
||||||
|
this.capturedKeystream = new Uint8Array(buffer);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute the loader script in the context
|
||||||
|
const jsContent = fs.readFileSync(jsPath, 'utf8');
|
||||||
|
const script = new vm.Script(jsContent, { filename: jsPath });
|
||||||
|
|
||||||
|
// create context
|
||||||
|
const context = vm.createContext(mockGlobal);
|
||||||
|
script.runInContext(context);
|
||||||
|
|
||||||
|
// Store reference to module
|
||||||
|
this.module = mockGlobal.Module;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[WasmService] Failed to initialize WASM:', error);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getKeystream(key: string, size: number = 131072): Promise<Buffer> {
|
||||||
|
// ISAAC-64 uses 8-byte blocks. If size is not a multiple of 8,
|
||||||
|
// the global reverse() will cause a shift in alignment.
|
||||||
|
const alignSize = Math.ceil(size / 8) * 8;
|
||||||
|
const buffer = await this.getRawKeystream(key, alignSize);
|
||||||
|
|
||||||
|
// Reverse the entire aligned buffer
|
||||||
|
const reversed = new Uint8Array(buffer);
|
||||||
|
reversed.reverse();
|
||||||
|
|
||||||
|
// Return exactly the requested size from the beginning of the reversed stream.
|
||||||
|
// Since we reversed the 'aligned' buffer, index 0 is the last byte of the last block.
|
||||||
|
return Buffer.from(reversed).subarray(0, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getRawKeystream(key: string, size: number = 131072): Promise<Buffer> {
|
||||||
|
await this.init();
|
||||||
|
|
||||||
|
if (!this.module || !this.module.WxIsaac64) {
|
||||||
|
if (this.module.asm && this.module.asm.WxIsaac64) {
|
||||||
|
this.module.WxIsaac64 = this.module.asm.WxIsaac64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.module.WxIsaac64) {
|
||||||
|
throw new Error('[WasmService] WxIsaac64 not found in WASM module');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.capturedKeystream = null;
|
||||||
|
const isaac = new this.module.WxIsaac64(key);
|
||||||
|
isaac.generate(size);
|
||||||
|
|
||||||
|
if (isaac.delete) {
|
||||||
|
isaac.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.capturedKeystream) {
|
||||||
|
return Buffer.from(this.capturedKeystream);
|
||||||
|
} else {
|
||||||
|
throw new Error('[WasmService] Failed to capture keystream (callback not called)');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[WasmService] Error generating raw keystream:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -80,7 +80,7 @@ export class WcdbService {
|
|||||||
// Worker 退出,需要 reject 所有 pending promises
|
// Worker 退出,需要 reject 所有 pending promises
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
console.error('WCDB Worker 异常退出,退出码:', code)
|
console.error('WCDB Worker 异常退出,退出码:', code)
|
||||||
const errorMsg = `Worker 异常退出 (退出码: ${code})。可能是 DLL 加载失败,请检查是否安装了 Visual C++ Redistributable。`
|
const errorMsg = `Worker 异常退出 (退出码: ${code})。可能是数据服务加载失败,请检查是否安装了 Visual C++ Redistributable。`
|
||||||
for (const [id, p] of this.pending) {
|
for (const [id, p] of this.pending) {
|
||||||
p.reject(new Error(errorMsg))
|
p.reject(new Error(errorMsg))
|
||||||
}
|
}
|
||||||
@@ -136,8 +136,7 @@ export class WcdbService {
|
|||||||
*/
|
*/
|
||||||
setMonitor(callback: (type: string, json: string) => void): void {
|
setMonitor(callback: (type: string, json: string) => void): void {
|
||||||
this.monitorListener = callback;
|
this.monitorListener = callback;
|
||||||
// Notify worker to enable monitor
|
this.callWorker<{ success?: boolean }>('setMonitor').catch(() => { });
|
||||||
this.callWorker('setMonitor').catch(() => { });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -165,6 +164,10 @@ export class WcdbService {
|
|||||||
return this.callWorker('open', { dbPath, hexKey, wxid })
|
return this.callWorker('open', { dbPath, hexKey, wxid })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getLastInitError(): Promise<string | null> {
|
||||||
|
return this.callWorker('getLastInitError')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 关闭数据库连接
|
* 关闭数据库连接
|
||||||
*/
|
*/
|
||||||
@@ -175,10 +178,10 @@ export class WcdbService {
|
|||||||
/**
|
/**
|
||||||
* 关闭服务
|
* 关闭服务
|
||||||
*/
|
*/
|
||||||
shutdown(): void {
|
async shutdown(): Promise<void> {
|
||||||
this.close()
|
try { await this.close() } catch {}
|
||||||
if (this.worker) {
|
if (this.worker) {
|
||||||
this.worker.terminate()
|
try { await this.worker.terminate() } catch {}
|
||||||
this.worker = null
|
this.worker = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,6 +222,83 @@ export class WcdbService {
|
|||||||
return this.callWorker('getMessageCount', { sessionId })
|
return this.callWorker('getMessageCount', { sessionId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
|
||||||
|
return this.callWorker('getMessageCounts', { sessionIds })
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSessionMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
|
||||||
|
return this.callWorker('getSessionMessageCounts', { sessionIds })
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSessionMessageTypeStats(
|
||||||
|
sessionId: string,
|
||||||
|
beginTimestamp: number = 0,
|
||||||
|
endTimestamp: number = 0
|
||||||
|
): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||||
|
return this.callWorker('getSessionMessageTypeStats', { sessionId, beginTimestamp, endTimestamp })
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSessionMessageTypeStatsBatch(
|
||||||
|
sessionIds: string[],
|
||||||
|
options?: {
|
||||||
|
beginTimestamp?: number
|
||||||
|
endTimestamp?: number
|
||||||
|
quickMode?: boolean
|
||||||
|
includeGroupSenderCount?: boolean
|
||||||
|
}
|
||||||
|
): Promise<{ success: boolean; data?: Record<string, any>; error?: string }> {
|
||||||
|
return this.callWorker('getSessionMessageTypeStatsBatch', { sessionIds, options })
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSessionMessageDateCounts(sessionId: string): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
|
||||||
|
return this.callWorker('getSessionMessageDateCounts', { sessionId })
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSessionMessageDateCountsBatch(sessionIds: string[]): Promise<{ success: boolean; data?: Record<string, Record<string, number>>; error?: string }> {
|
||||||
|
return this.callWorker('getSessionMessageDateCountsBatch', { sessionIds })
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMessagesByType(
|
||||||
|
sessionId: string,
|
||||||
|
localType: number,
|
||||||
|
ascending = false,
|
||||||
|
limit = 0,
|
||||||
|
offset = 0
|
||||||
|
): Promise<{ success: boolean; rows?: any[]; error?: string }> {
|
||||||
|
return this.callWorker('getMessagesByType', { sessionId, localType, ascending, limit, offset })
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMediaStream(options?: {
|
||||||
|
sessionId?: string
|
||||||
|
mediaType?: 'image' | 'video' | 'all'
|
||||||
|
beginTimestamp?: number
|
||||||
|
endTimestamp?: number
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}): Promise<{
|
||||||
|
success: boolean
|
||||||
|
items?: Array<{
|
||||||
|
sessionId: string
|
||||||
|
sessionDisplayName?: string
|
||||||
|
mediaType: 'image' | 'video'
|
||||||
|
localId: number
|
||||||
|
serverId?: string
|
||||||
|
createTime: number
|
||||||
|
localType: number
|
||||||
|
senderUsername?: string
|
||||||
|
isSend?: number | null
|
||||||
|
imageMd5?: string
|
||||||
|
imageDatName?: string
|
||||||
|
videoMd5?: string
|
||||||
|
content?: string
|
||||||
|
}>
|
||||||
|
hasMore?: boolean
|
||||||
|
nextOffset?: number
|
||||||
|
error?: string
|
||||||
|
}> {
|
||||||
|
return this.callWorker('getMediaStream', { options })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取联系人昵称
|
* 获取联系人昵称
|
||||||
*/
|
*/
|
||||||
@@ -273,6 +353,10 @@ export class WcdbService {
|
|||||||
return this.callWorker('getMessageTableStats', { sessionId })
|
return this.callWorker('getMessageTableStats', { sessionId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMessageDates(sessionId: string): Promise<{ success: boolean; dates?: string[]; error?: string }> {
|
||||||
|
return this.callWorker('getMessageDates', { sessionId })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取消息元数据
|
* 获取消息元数据
|
||||||
*/
|
*/
|
||||||
@@ -280,6 +364,14 @@ export class WcdbService {
|
|||||||
return this.callWorker('getMessageMeta', { dbPath, tableName, limit, offset })
|
return this.callWorker('getMessageMeta', { dbPath, tableName, limit, offset })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMessageTableColumns(dbPath: string, tableName: string): Promise<{ success: boolean; columns?: string[]; error?: string }> {
|
||||||
|
return this.callWorker('getMessageTableColumns', { dbPath, tableName })
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMessageTableTimeRange(dbPath: string, tableName: string): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||||
|
return this.callWorker('getMessageTableTimeRange', { dbPath, tableName })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取联系人详情
|
* 获取联系人详情
|
||||||
*/
|
*/
|
||||||
@@ -287,6 +379,33 @@ export class WcdbService {
|
|||||||
return this.callWorker('getContact', { username })
|
return this.callWorker('getContact', { username })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量获取联系人 extra_buffer 状态(isFolded/isMuted)
|
||||||
|
*/
|
||||||
|
async getContactStatus(usernames: string[]): Promise<{ success: boolean; map?: Record<string, { isFolded: boolean; isMuted: boolean }>; error?: string }> {
|
||||||
|
return this.callWorker('getContactStatus', { usernames })
|
||||||
|
}
|
||||||
|
|
||||||
|
async getContactTypeCounts(): Promise<{ success: boolean; counts?: { private: number; group: number; official: number; former_friend: number }; error?: string }> {
|
||||||
|
return this.callWorker('getContactTypeCounts')
|
||||||
|
}
|
||||||
|
|
||||||
|
async getContactsCompact(usernames: string[] = []): Promise<{ success: boolean; contacts?: any[]; error?: string }> {
|
||||||
|
return this.callWorker('getContactsCompact', { usernames })
|
||||||
|
}
|
||||||
|
|
||||||
|
async getContactAliasMap(usernames: string[]): Promise<{ success: boolean; map?: Record<string, string>; error?: string }> {
|
||||||
|
return this.callWorker('getContactAliasMap', { usernames })
|
||||||
|
}
|
||||||
|
|
||||||
|
async getContactFriendFlags(usernames: string[]): Promise<{ success: boolean; map?: Record<string, boolean>; error?: string }> {
|
||||||
|
return this.callWorker('getContactFriendFlags', { usernames })
|
||||||
|
}
|
||||||
|
|
||||||
|
async getChatRoomExtBuffer(chatroomId: string): Promise<{ success: boolean; extBuffer?: string; error?: string }> {
|
||||||
|
return this.callWorker('getChatRoomExtBuffer', { chatroomId })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取聚合统计数据
|
* 获取聚合统计数据
|
||||||
*/
|
*/
|
||||||
@@ -315,6 +434,13 @@ export class WcdbService {
|
|||||||
return this.callWorker('getAnnualReportExtras', { sessionIds, beginTimestamp, endTimestamp, peakDayBegin, peakDayEnd })
|
return this.callWorker('getAnnualReportExtras', { sessionIds, beginTimestamp, endTimestamp, peakDayBegin, peakDayEnd })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取双人报告统计数据
|
||||||
|
*/
|
||||||
|
async getDualReportStats(sessionId: string, beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||||
|
return this.callWorker('getDualReportStats', { sessionId, beginTimestamp, endTimestamp })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取群聊统计
|
* 获取群聊统计
|
||||||
*/
|
*/
|
||||||
@@ -351,10 +477,10 @@ export class WcdbService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行 SQL 查询
|
* 执行 SQL 查询(仅主进程内部使用:fallback/diagnostic/低频兼容)
|
||||||
*/
|
*/
|
||||||
async execQuery(kind: string, path: string | null, sql: string): Promise<{ success: boolean; rows?: any[]; error?: string }> {
|
async execQuery(kind: string, path: string | null, sql: string, params: any[] = []): Promise<{ success: boolean; rows?: any[]; error?: string }> {
|
||||||
return this.callWorker('execQuery', { kind, path, sql })
|
return this.callWorker('execQuery', { kind, path, sql, params })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -364,6 +490,20 @@ export class WcdbService {
|
|||||||
return this.callWorker('getEmoticonCdnUrl', { dbPath, md5 })
|
return this.callWorker('getEmoticonCdnUrl', { dbPath, md5 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取表情包释义
|
||||||
|
*/
|
||||||
|
async getEmoticonCaption(dbPath: string, md5: string): Promise<{ success: boolean; caption?: string; error?: string }> {
|
||||||
|
return this.callWorker('getEmoticonCaption', { dbPath, md5 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取表情包释义(严格数据服务接口)
|
||||||
|
*/
|
||||||
|
async getEmoticonCaptionStrict(md5: string): Promise<{ success: boolean; caption?: string; error?: string }> {
|
||||||
|
return this.callWorker('getEmoticonCaptionStrict', { md5 })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 列出消息数据库
|
* 列出消息数据库
|
||||||
*/
|
*/
|
||||||
@@ -385,6 +525,10 @@ export class WcdbService {
|
|||||||
return this.callWorker('getMessageById', { sessionId, localId })
|
return this.callWorker('getMessageById', { sessionId, localId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async searchMessages(keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number): Promise<{ success: boolean; messages?: any[]; error?: string }> {
|
||||||
|
return this.callWorker('searchMessages', { keyword, sessionId, limit, offset, beginTimestamp, endTimestamp })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取语音数据
|
* 获取语音数据
|
||||||
*/
|
*/
|
||||||
@@ -392,6 +536,40 @@ export class WcdbService {
|
|||||||
return this.callWorker('getVoiceData', { sessionId, createTime, candidates, localId, svrId })
|
return this.callWorker('getVoiceData', { sessionId, createTime, candidates, localId, svrId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getVoiceDataBatch(
|
||||||
|
requests: Array<{ session_id: string; create_time: number; local_id?: number; svr_id?: string | number; candidates?: string[] }>
|
||||||
|
): Promise<{ success: boolean; rows?: Array<{ index: number; hex?: string }>; error?: string }> {
|
||||||
|
return this.callWorker('getVoiceDataBatch', { requests })
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMediaSchemaSummary(dbPath: string): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||||
|
return this.callWorker('getMediaSchemaSummary', { dbPath })
|
||||||
|
}
|
||||||
|
|
||||||
|
async getHeadImageBuffers(usernames: string[]): Promise<{ success: boolean; map?: Record<string, string>; error?: string }> {
|
||||||
|
return this.callWorker('getHeadImageBuffers', { usernames })
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveImageHardlink(md5: string, accountDir?: string): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||||
|
return this.callWorker('resolveImageHardlink', { md5, accountDir })
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveImageHardlinkBatch(
|
||||||
|
requests: Array<{ md5: string; accountDir?: string }>
|
||||||
|
): Promise<{ success: boolean; rows?: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }>; error?: string }> {
|
||||||
|
return this.callWorker('resolveImageHardlinkBatch', { requests })
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveVideoHardlinkMd5(md5: string, dbPath?: string): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||||
|
return this.callWorker('resolveVideoHardlinkMd5', { md5, dbPath })
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveVideoHardlinkMd5Batch(
|
||||||
|
requests: Array<{ md5: string; dbPath?: string }>
|
||||||
|
): Promise<{ success: boolean; rows?: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }>; error?: string }> {
|
||||||
|
return this.callWorker('resolveVideoHardlinkMd5Batch', { requests })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取朋友圈
|
* 获取朋友圈
|
||||||
*/
|
*/
|
||||||
@@ -406,8 +584,62 @@ export class WcdbService {
|
|||||||
return this.callWorker('getSnsAnnualStats', { beginTimestamp, endTimestamp })
|
return this.callWorker('getSnsAnnualStats', { beginTimestamp, endTimestamp })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> {
|
||||||
|
return this.callWorker('getSnsUsernames')
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSnsExportStats(myWxid?: string): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> {
|
||||||
|
return this.callWorker('getSnsExportStats', { myWxid })
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkMessageAntiRevokeTriggers(
|
||||||
|
sessionIds: string[]
|
||||||
|
): Promise<{ success: boolean; rows?: Array<{ sessionId: string; success: boolean; installed?: boolean; error?: string }>; error?: string }> {
|
||||||
|
return this.callWorker('checkMessageAntiRevokeTriggers', { sessionIds })
|
||||||
|
}
|
||||||
|
|
||||||
|
async installMessageAntiRevokeTriggers(
|
||||||
|
sessionIds: string[]
|
||||||
|
): Promise<{ success: boolean; rows?: Array<{ sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }>; error?: string }> {
|
||||||
|
return this.callWorker('installMessageAntiRevokeTriggers', { sessionIds })
|
||||||
|
}
|
||||||
|
|
||||||
|
async uninstallMessageAntiRevokeTriggers(
|
||||||
|
sessionIds: string[]
|
||||||
|
): Promise<{ success: boolean; rows?: Array<{ sessionId: string; success: boolean; error?: string }>; error?: string }> {
|
||||||
|
return this.callWorker('uninstallMessageAntiRevokeTriggers', { sessionIds })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 DLL 内部日志
|
* 安装朋友圈删除拦截
|
||||||
|
*/
|
||||||
|
async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> {
|
||||||
|
return this.callWorker('installSnsBlockDeleteTrigger')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卸载朋友圈删除拦截
|
||||||
|
*/
|
||||||
|
async uninstallSnsBlockDeleteTrigger(): Promise<{ success: boolean; error?: string }> {
|
||||||
|
return this.callWorker('uninstallSnsBlockDeleteTrigger')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询朋友圈删除拦截是否已安装
|
||||||
|
*/
|
||||||
|
async checkSnsBlockDeleteTrigger(): Promise<{ success: boolean; installed?: boolean; error?: string }> {
|
||||||
|
return this.callWorker('checkSnsBlockDeleteTrigger')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从数据库直接删除朋友圈记录
|
||||||
|
*/
|
||||||
|
async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
return this.callWorker('deleteSnsPost', { postId })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取数据服务内部日志
|
||||||
*/
|
*/
|
||||||
async getLogs(): Promise<{ success: boolean; logs?: string[]; error?: string }> {
|
async getLogs(): Promise<{ success: boolean; logs?: string[]; error?: string }> {
|
||||||
return this.callWorker('getLogs')
|
return this.callWorker('getLogs')
|
||||||
@@ -420,6 +652,43 @@ export class WcdbService {
|
|||||||
return this.callWorker('verifyUser', { message, hwnd })
|
return this.callWorker('verifyUser', { message, hwnd })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改消息内容
|
||||||
|
*/
|
||||||
|
async updateMessage(sessionId: string, localId: number, createTime: number, newContent: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
return this.callWorker('updateMessage', { sessionId, localId, createTime, newContent })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除消息
|
||||||
|
*/
|
||||||
|
async deleteMessage(sessionId: string, localId: number, createTime: number, dbPathHint?: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
return this.callWorker('deleteMessage', { sessionId, localId, createTime, dbPathHint })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据收集:初始化
|
||||||
|
*/
|
||||||
|
async cloudInit(intervalSeconds: number): Promise<{ success: boolean; error?: string }> {
|
||||||
|
return this.callWorker('cloudInit', { intervalSeconds })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据收集:上报数据
|
||||||
|
*/
|
||||||
|
async cloudReport(statsJson: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
return this.callWorker('cloudReport', { statsJson })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据收集:停止
|
||||||
|
*/
|
||||||
|
cloudStop(): Promise<{ success: boolean; error?: string }> {
|
||||||
|
return this.callWorker('cloudStop', {})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const wcdbService = new WcdbService()
|
export const wcdbService = new WcdbService()
|
||||||
|
|||||||
@@ -1,13 +1,56 @@
|
|||||||
import { parentPort, workerData } from 'worker_threads'
|
import { parentPort, workerData } from 'worker_threads'
|
||||||
|
import { existsSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
interface WorkerParams {
|
interface WorkerParams {
|
||||||
modelPath: string
|
modelPath: string
|
||||||
tokensPath: string
|
tokensPath: string
|
||||||
wavData: Buffer
|
wavData: Buffer | Uint8Array | { type: 'Buffer'; data: number[] }
|
||||||
sampleRate: number
|
sampleRate: number
|
||||||
languages?: string[]
|
languages?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appendLibrarySearchPath(libDir: string): void {
|
||||||
|
if (!existsSync(libDir)) return
|
||||||
|
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
const current = process.env.DYLD_LIBRARY_PATH || ''
|
||||||
|
const paths = current.split(':').filter(Boolean)
|
||||||
|
if (!paths.includes(libDir)) {
|
||||||
|
process.env.DYLD_LIBRARY_PATH = [libDir, ...paths].join(':')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
const current = process.env.LD_LIBRARY_PATH || ''
|
||||||
|
const paths = current.split(':').filter(Boolean)
|
||||||
|
if (!paths.includes(libDir)) {
|
||||||
|
process.env.LD_LIBRARY_PATH = [libDir, ...paths].join(':')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareSherpaRuntimeEnv(): void {
|
||||||
|
const platform = process.platform === 'win32' ? 'win' : process.platform
|
||||||
|
const platformPkg = `sherpa-onnx-${platform}-${process.arch}`
|
||||||
|
const resourcesPath = (process as any).resourcesPath as string | undefined
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
// Dev: /project/dist-electron -> /project/node_modules/...
|
||||||
|
join(__dirname, '..', 'node_modules', platformPkg),
|
||||||
|
// Fallback for alternate layouts
|
||||||
|
join(__dirname, 'node_modules', platformPkg),
|
||||||
|
join(process.cwd(), 'node_modules', platformPkg),
|
||||||
|
// Packaged app: Resources/app.asar.unpacked/node_modules/...
|
||||||
|
resourcesPath ? join(resourcesPath, 'app.asar.unpacked', 'node_modules', platformPkg) : ''
|
||||||
|
].filter(Boolean)
|
||||||
|
|
||||||
|
for (const dir of candidates) {
|
||||||
|
appendLibrarySearchPath(dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 语言标记映射
|
// 语言标记映射
|
||||||
const LANGUAGE_TAGS: Record<string, string> = {
|
const LANGUAGE_TAGS: Record<string, string> = {
|
||||||
'zh': '<|zh|>',
|
'zh': '<|zh|>',
|
||||||
@@ -95,22 +138,60 @@ function isLanguageAllowed(result: any, allowedLanguages: string[]): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
if (!parentPort) {
|
const isForkProcess = !parentPort
|
||||||
return;
|
const emit = (msg: any) => {
|
||||||
|
if (parentPort) {
|
||||||
|
parentPort.postMessage(msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeof process.send === 'function') {
|
||||||
|
process.send(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeBuffer = (data: WorkerParams['wavData']): Buffer => {
|
||||||
|
if (Buffer.isBuffer(data)) return data
|
||||||
|
if (data instanceof Uint8Array) return Buffer.from(data)
|
||||||
|
if (data && typeof data === 'object' && (data as any).type === 'Buffer' && Array.isArray((data as any).data)) {
|
||||||
|
return Buffer.from((data as any).data)
|
||||||
|
}
|
||||||
|
return Buffer.alloc(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const readParams = async (): Promise<WorkerParams | null> => {
|
||||||
|
if (parentPort) {
|
||||||
|
return workerData as WorkerParams
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false
|
||||||
|
const finish = (value: WorkerParams | null) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
resolve(value)
|
||||||
|
}
|
||||||
|
process.once('message', (msg) => finish(msg as WorkerParams))
|
||||||
|
process.once('disconnect', () => finish(null))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
prepareSherpaRuntimeEnv()
|
||||||
|
const params = await readParams()
|
||||||
|
if (!params) return
|
||||||
|
|
||||||
// 动态加载以捕获可能的加载错误(如 C++ 运行库缺失等)
|
// 动态加载以捕获可能的加载错误(如 C++ 运行库缺失等)
|
||||||
let sherpa: any;
|
let sherpa: any;
|
||||||
try {
|
try {
|
||||||
sherpa = require('sherpa-onnx-node');
|
sherpa = require('sherpa-onnx-node');
|
||||||
} catch (requireError) {
|
} catch (requireError) {
|
||||||
parentPort.postMessage({ type: 'error', error: 'Failed to load speech engine: ' + String(requireError) });
|
emit({ type: 'error', error: 'Failed to load speech engine: ' + String(requireError) });
|
||||||
|
if (isForkProcess) process.exit(1)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { modelPath, tokensPath, wavData: rawWavData, sampleRate, languages } = workerData as WorkerParams
|
const { modelPath, tokensPath, wavData: rawWavData, sampleRate, languages } = params
|
||||||
const wavData = Buffer.from(rawWavData);
|
const wavData = normalizeBuffer(rawWavData);
|
||||||
// 确保有有效的语言列表,默认只允许中文
|
// 确保有有效的语言列表,默认只允许中文
|
||||||
let allowedLanguages = languages || ['zh']
|
let allowedLanguages = languages || ['zh']
|
||||||
if (allowedLanguages.length === 0) {
|
if (allowedLanguages.length === 0) {
|
||||||
@@ -151,16 +232,18 @@ async function run() {
|
|||||||
if (isLanguageAllowed(result, allowedLanguages)) {
|
if (isLanguageAllowed(result, allowedLanguages)) {
|
||||||
const processedText = richTranscribePostProcess(result.text)
|
const processedText = richTranscribePostProcess(result.text)
|
||||||
|
|
||||||
parentPort.postMessage({ type: 'final', text: processedText })
|
emit({ type: 'final', text: processedText })
|
||||||
|
if (isForkProcess) process.exit(0)
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
parentPort.postMessage({ type: 'final', text: '' })
|
emit({ type: 'final', text: '' })
|
||||||
|
if (isForkProcess) process.exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
parentPort.postMessage({ type: 'error', error: String(error) })
|
emit({ type: 'error', error: String(error) })
|
||||||
|
if (isForkProcess) process.exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
run();
|
run();
|
||||||
|
|
||||||
|
|||||||
114
electron/utils/LRUCache.ts
Normal file
114
electron/utils/LRUCache.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* LRU (Least Recently Used) Cache implementation for memory management
|
||||||
|
*/
|
||||||
|
export class LRUCache<K, V> {
|
||||||
|
private cache: Map<K, V>
|
||||||
|
private maxSize: number
|
||||||
|
|
||||||
|
constructor(maxSize: number = 100) {
|
||||||
|
this.maxSize = maxSize
|
||||||
|
this.cache = new Map()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get value from cache
|
||||||
|
*/
|
||||||
|
get(key: K): V | undefined {
|
||||||
|
const value = this.cache.get(key)
|
||||||
|
if (value !== undefined) {
|
||||||
|
// Move to end (most recently used)
|
||||||
|
this.cache.delete(key)
|
||||||
|
this.cache.set(key, value)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set value in cache
|
||||||
|
*/
|
||||||
|
set(key: K, value: V): void {
|
||||||
|
if (this.cache.has(key)) {
|
||||||
|
// Update existing
|
||||||
|
this.cache.delete(key)
|
||||||
|
} else if (this.cache.size >= this.maxSize) {
|
||||||
|
// Remove least recently used (first item)
|
||||||
|
const firstKey = this.cache.keys().next().value
|
||||||
|
if (firstKey !== undefined) {
|
||||||
|
this.cache.delete(firstKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.cache.set(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if key exists
|
||||||
|
*/
|
||||||
|
has(key: K): boolean {
|
||||||
|
return this.cache.has(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete key from cache
|
||||||
|
*/
|
||||||
|
delete(key: K): boolean {
|
||||||
|
return this.cache.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all cache entries
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this.cache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current cache size
|
||||||
|
*/
|
||||||
|
get size(): number {
|
||||||
|
return this.cache.size
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all keys (for debugging)
|
||||||
|
*/
|
||||||
|
keys(): IterableIterator<K> {
|
||||||
|
return this.cache.keys()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all values (for debugging)
|
||||||
|
*/
|
||||||
|
values(): IterableIterator<V> {
|
||||||
|
return this.cache.values()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all entries (for iteration support)
|
||||||
|
*/
|
||||||
|
entries(): IterableIterator<[K, V]> {
|
||||||
|
return this.cache.entries()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make LRUCache iterable (for...of support)
|
||||||
|
*/
|
||||||
|
[Symbol.iterator](): IterableIterator<[K, V]> {
|
||||||
|
return this.cache.entries()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force cleanup (optional method for explicit memory management)
|
||||||
|
*/
|
||||||
|
cleanup(): void {
|
||||||
|
// In JavaScript/TypeScript, this is mainly for consistency
|
||||||
|
// The garbage collector will handle actual memory cleanup
|
||||||
|
if (this.cache.size > this.maxSize * 1.5) {
|
||||||
|
// Emergency cleanup if cache somehow exceeds limit
|
||||||
|
const entries = Array.from(this.cache.entries())
|
||||||
|
this.cache.clear()
|
||||||
|
// Keep only the most recent half
|
||||||
|
const keepEntries = entries.slice(-Math.floor(this.maxSize / 2))
|
||||||
|
keepEntries.forEach(([key, value]) => this.cache.set(key, value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,21 +20,26 @@ if (parentPort) {
|
|||||||
result = { success: true }
|
result = { success: true }
|
||||||
break
|
break
|
||||||
case 'setMonitor':
|
case 'setMonitor':
|
||||||
core.setMonitor((type, json) => {
|
{
|
||||||
|
const monitorOk = core.setMonitor((type, json) => {
|
||||||
parentPort!.postMessage({
|
parentPort!.postMessage({
|
||||||
id: -1,
|
id: -1,
|
||||||
type: 'monitor',
|
type: 'monitor',
|
||||||
payload: { type, json }
|
payload: { type, json }
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
result = { success: true }
|
result = { success: monitorOk }
|
||||||
break
|
break
|
||||||
|
}
|
||||||
case 'testConnection':
|
case 'testConnection':
|
||||||
result = await core.testConnection(payload.dbPath, payload.hexKey, payload.wxid)
|
result = await core.testConnection(payload.dbPath, payload.hexKey, payload.wxid)
|
||||||
break
|
break
|
||||||
case 'open':
|
case 'open':
|
||||||
result = await core.open(payload.dbPath, payload.hexKey, payload.wxid)
|
result = await core.open(payload.dbPath, payload.hexKey, payload.wxid)
|
||||||
break
|
break
|
||||||
|
case 'getLastInitError':
|
||||||
|
result = core.getLastInitError()
|
||||||
|
break
|
||||||
case 'close':
|
case 'close':
|
||||||
core.close()
|
core.close()
|
||||||
result = { success: true }
|
result = { success: true }
|
||||||
@@ -54,6 +59,30 @@ if (parentPort) {
|
|||||||
case 'getMessageCount':
|
case 'getMessageCount':
|
||||||
result = await core.getMessageCount(payload.sessionId)
|
result = await core.getMessageCount(payload.sessionId)
|
||||||
break
|
break
|
||||||
|
case 'getMessageCounts':
|
||||||
|
result = await core.getMessageCounts(payload.sessionIds)
|
||||||
|
break
|
||||||
|
case 'getSessionMessageCounts':
|
||||||
|
result = await core.getSessionMessageCounts(payload.sessionIds)
|
||||||
|
break
|
||||||
|
case 'getSessionMessageTypeStats':
|
||||||
|
result = await core.getSessionMessageTypeStats(payload.sessionId, payload.beginTimestamp, payload.endTimestamp)
|
||||||
|
break
|
||||||
|
case 'getSessionMessageTypeStatsBatch':
|
||||||
|
result = await core.getSessionMessageTypeStatsBatch(payload.sessionIds, payload.options)
|
||||||
|
break
|
||||||
|
case 'getSessionMessageDateCounts':
|
||||||
|
result = await core.getSessionMessageDateCounts(payload.sessionId)
|
||||||
|
break
|
||||||
|
case 'getSessionMessageDateCountsBatch':
|
||||||
|
result = await core.getSessionMessageDateCountsBatch(payload.sessionIds)
|
||||||
|
break
|
||||||
|
case 'getMessagesByType':
|
||||||
|
result = await core.getMessagesByType(payload.sessionId, payload.localType, payload.ascending, payload.limit, payload.offset)
|
||||||
|
break
|
||||||
|
case 'getMediaStream':
|
||||||
|
result = await core.getMediaStream(payload.options)
|
||||||
|
break
|
||||||
case 'getDisplayNames':
|
case 'getDisplayNames':
|
||||||
result = await core.getDisplayNames(payload.usernames)
|
result = await core.getDisplayNames(payload.usernames)
|
||||||
break
|
break
|
||||||
@@ -78,12 +107,39 @@ if (parentPort) {
|
|||||||
case 'getMessageTableStats':
|
case 'getMessageTableStats':
|
||||||
result = await core.getMessageTableStats(payload.sessionId)
|
result = await core.getMessageTableStats(payload.sessionId)
|
||||||
break
|
break
|
||||||
|
case 'getMessageDates':
|
||||||
|
result = await core.getMessageDates(payload.sessionId)
|
||||||
|
break
|
||||||
case 'getMessageMeta':
|
case 'getMessageMeta':
|
||||||
result = await core.getMessageMeta(payload.dbPath, payload.tableName, payload.limit, payload.offset)
|
result = await core.getMessageMeta(payload.dbPath, payload.tableName, payload.limit, payload.offset)
|
||||||
break
|
break
|
||||||
|
case 'getMessageTableColumns':
|
||||||
|
result = await core.getMessageTableColumns(payload.dbPath, payload.tableName)
|
||||||
|
break
|
||||||
|
case 'getMessageTableTimeRange':
|
||||||
|
result = await core.getMessageTableTimeRange(payload.dbPath, payload.tableName)
|
||||||
|
break
|
||||||
case 'getContact':
|
case 'getContact':
|
||||||
result = await core.getContact(payload.username)
|
result = await core.getContact(payload.username)
|
||||||
break
|
break
|
||||||
|
case 'getContactStatus':
|
||||||
|
result = await core.getContactStatus(payload.usernames)
|
||||||
|
break
|
||||||
|
case 'getContactTypeCounts':
|
||||||
|
result = await core.getContactTypeCounts()
|
||||||
|
break
|
||||||
|
case 'getContactsCompact':
|
||||||
|
result = await core.getContactsCompact(payload.usernames)
|
||||||
|
break
|
||||||
|
case 'getContactAliasMap':
|
||||||
|
result = await core.getContactAliasMap(payload.usernames)
|
||||||
|
break
|
||||||
|
case 'getContactFriendFlags':
|
||||||
|
result = await core.getContactFriendFlags(payload.usernames)
|
||||||
|
break
|
||||||
|
case 'getChatRoomExtBuffer':
|
||||||
|
result = await core.getChatRoomExtBuffer(payload.chatroomId)
|
||||||
|
break
|
||||||
case 'getAggregateStats':
|
case 'getAggregateStats':
|
||||||
result = await core.getAggregateStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp)
|
result = await core.getAggregateStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp)
|
||||||
break
|
break
|
||||||
@@ -96,6 +152,9 @@ if (parentPort) {
|
|||||||
case 'getAnnualReportExtras':
|
case 'getAnnualReportExtras':
|
||||||
result = await core.getAnnualReportExtras(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp, payload.peakDayBegin, payload.peakDayEnd)
|
result = await core.getAnnualReportExtras(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp, payload.peakDayBegin, payload.peakDayEnd)
|
||||||
break
|
break
|
||||||
|
case 'getDualReportStats':
|
||||||
|
result = await core.getDualReportStats(payload.sessionId, payload.beginTimestamp, payload.endTimestamp)
|
||||||
|
break
|
||||||
case 'getGroupStats':
|
case 'getGroupStats':
|
||||||
result = await core.getGroupStats(payload.chatroomId, payload.beginTimestamp, payload.endTimestamp)
|
result = await core.getGroupStats(payload.chatroomId, payload.beginTimestamp, payload.endTimestamp)
|
||||||
break
|
break
|
||||||
@@ -112,11 +171,17 @@ if (parentPort) {
|
|||||||
result = await core.closeMessageCursor(payload.cursor)
|
result = await core.closeMessageCursor(payload.cursor)
|
||||||
break
|
break
|
||||||
case 'execQuery':
|
case 'execQuery':
|
||||||
result = await core.execQuery(payload.kind, payload.path, payload.sql)
|
result = await core.execQuery(payload.kind, payload.path, payload.sql, payload.params)
|
||||||
break
|
break
|
||||||
case 'getEmoticonCdnUrl':
|
case 'getEmoticonCdnUrl':
|
||||||
result = await core.getEmoticonCdnUrl(payload.dbPath, payload.md5)
|
result = await core.getEmoticonCdnUrl(payload.dbPath, payload.md5)
|
||||||
break
|
break
|
||||||
|
case 'getEmoticonCaption':
|
||||||
|
result = await core.getEmoticonCaption(payload.dbPath, payload.md5)
|
||||||
|
break
|
||||||
|
case 'getEmoticonCaptionStrict':
|
||||||
|
result = await core.getEmoticonCaptionStrict(payload.md5)
|
||||||
|
break
|
||||||
case 'listMessageDbs':
|
case 'listMessageDbs':
|
||||||
result = await core.listMessageDbs()
|
result = await core.listMessageDbs()
|
||||||
break
|
break
|
||||||
@@ -126,24 +191,90 @@ if (parentPort) {
|
|||||||
case 'getMessageById':
|
case 'getMessageById':
|
||||||
result = await core.getMessageById(payload.sessionId, payload.localId)
|
result = await core.getMessageById(payload.sessionId, payload.localId)
|
||||||
break
|
break
|
||||||
|
case 'searchMessages':
|
||||||
|
result = await core.searchMessages(payload.keyword, payload.sessionId, payload.limit, payload.offset, payload.beginTimestamp, payload.endTimestamp)
|
||||||
|
break
|
||||||
case 'getVoiceData':
|
case 'getVoiceData':
|
||||||
result = await core.getVoiceData(payload.sessionId, payload.createTime, payload.candidates, payload.localId, payload.svrId)
|
result = await core.getVoiceData(payload.sessionId, payload.createTime, payload.candidates, payload.localId, payload.svrId)
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
console.error('[wcdbWorker] getVoiceData failed:', result.error)
|
console.error('[wcdbWorker] getVoiceData failed:', result.error)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
case 'getVoiceDataBatch':
|
||||||
|
result = await core.getVoiceDataBatch(payload.requests)
|
||||||
|
break
|
||||||
|
case 'getMediaSchemaSummary':
|
||||||
|
result = await core.getMediaSchemaSummary(payload.dbPath)
|
||||||
|
break
|
||||||
|
case 'getHeadImageBuffers':
|
||||||
|
result = await core.getHeadImageBuffers(payload.usernames)
|
||||||
|
break
|
||||||
|
case 'resolveImageHardlink':
|
||||||
|
result = await core.resolveImageHardlink(payload.md5, payload.accountDir)
|
||||||
|
break
|
||||||
|
case 'resolveImageHardlinkBatch':
|
||||||
|
result = await core.resolveImageHardlinkBatch(payload.requests)
|
||||||
|
break
|
||||||
|
case 'resolveVideoHardlinkMd5':
|
||||||
|
result = await core.resolveVideoHardlinkMd5(payload.md5, payload.dbPath)
|
||||||
|
break
|
||||||
|
case 'resolveVideoHardlinkMd5Batch':
|
||||||
|
result = await core.resolveVideoHardlinkMd5Batch(payload.requests)
|
||||||
|
break
|
||||||
case 'getSnsTimeline':
|
case 'getSnsTimeline':
|
||||||
result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime)
|
result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime)
|
||||||
break
|
break
|
||||||
case 'getSnsAnnualStats':
|
case 'getSnsAnnualStats':
|
||||||
result = await core.getSnsAnnualStats(payload.beginTimestamp, payload.endTimestamp)
|
result = await core.getSnsAnnualStats(payload.beginTimestamp, payload.endTimestamp)
|
||||||
break
|
break
|
||||||
|
case 'getSnsUsernames':
|
||||||
|
result = await core.getSnsUsernames()
|
||||||
|
break
|
||||||
|
case 'getSnsExportStats':
|
||||||
|
result = await core.getSnsExportStats(payload.myWxid)
|
||||||
|
break
|
||||||
|
case 'checkMessageAntiRevokeTriggers':
|
||||||
|
result = await core.checkMessageAntiRevokeTriggers(payload.sessionIds)
|
||||||
|
break
|
||||||
|
case 'installMessageAntiRevokeTriggers':
|
||||||
|
result = await core.installMessageAntiRevokeTriggers(payload.sessionIds)
|
||||||
|
break
|
||||||
|
case 'uninstallMessageAntiRevokeTriggers':
|
||||||
|
result = await core.uninstallMessageAntiRevokeTriggers(payload.sessionIds)
|
||||||
|
break
|
||||||
|
case 'installSnsBlockDeleteTrigger':
|
||||||
|
result = await core.installSnsBlockDeleteTrigger()
|
||||||
|
break
|
||||||
|
case 'uninstallSnsBlockDeleteTrigger':
|
||||||
|
result = await core.uninstallSnsBlockDeleteTrigger()
|
||||||
|
break
|
||||||
|
case 'checkSnsBlockDeleteTrigger':
|
||||||
|
result = await core.checkSnsBlockDeleteTrigger()
|
||||||
|
break
|
||||||
|
case 'deleteSnsPost':
|
||||||
|
result = await core.deleteSnsPost(payload.postId)
|
||||||
|
break
|
||||||
case 'getLogs':
|
case 'getLogs':
|
||||||
result = await core.getLogs()
|
result = await core.getLogs()
|
||||||
break
|
break
|
||||||
case 'verifyUser':
|
case 'verifyUser':
|
||||||
result = await core.verifyUser(payload.message, payload.hwnd)
|
result = await core.verifyUser(payload.message, payload.hwnd)
|
||||||
break
|
break
|
||||||
|
case 'updateMessage':
|
||||||
|
result = await core.updateMessage(payload.sessionId, payload.localId, payload.createTime, payload.newContent)
|
||||||
|
break
|
||||||
|
case 'deleteMessage':
|
||||||
|
result = await core.deleteMessage(payload.sessionId, payload.localId, payload.createTime, payload.dbPathHint)
|
||||||
|
break
|
||||||
|
case 'cloudInit':
|
||||||
|
result = await core.cloudInit(payload.intervalSeconds)
|
||||||
|
break
|
||||||
|
case 'cloudReport':
|
||||||
|
result = await core.cloudReport(payload.statsJson)
|
||||||
|
break
|
||||||
|
case 'cloudStop':
|
||||||
|
result = core.cloudStop()
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
result = { success: false, error: `Unknown method: ${type}` }
|
result = { success: false, error: `Unknown method: ${type}` }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,74 @@
|
|||||||
import { BrowserWindow, ipcMain, screen } from 'electron'
|
import { BrowserWindow, ipcMain, screen } from "electron";
|
||||||
import { join } from 'path'
|
import { join } from "path";
|
||||||
import { ConfigService } from '../services/config'
|
import { ConfigService } from "../services/config";
|
||||||
|
|
||||||
let notificationWindow: BrowserWindow | null = null
|
// Linux D-Bus通知服务
|
||||||
let closeTimer: NodeJS.Timeout | null = null
|
const isLinux = process.platform === "linux";
|
||||||
|
let linuxNotificationService:
|
||||||
|
| typeof import("../services/linuxNotificationService")
|
||||||
|
| null = null;
|
||||||
|
|
||||||
|
// 用于处理通知点击的回调函数(在Linux上用于导航到会话)
|
||||||
|
let onNotificationNavigate: ((sessionId: string) => void) | null = null;
|
||||||
|
|
||||||
|
export function setNotificationNavigateHandler(
|
||||||
|
callback: (sessionId: string) => void,
|
||||||
|
) {
|
||||||
|
onNotificationNavigate = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
let notificationWindow: BrowserWindow | null = null;
|
||||||
|
let closeTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
export function destroyNotificationWindow() {
|
||||||
|
if (closeTimer) {
|
||||||
|
clearTimeout(closeTimer);
|
||||||
|
closeTimer = null;
|
||||||
|
}
|
||||||
|
lastNotificationData = null;
|
||||||
|
|
||||||
|
// Linux:关闭通知服务并清理缓存(fire-and-forget,不阻塞退出)
|
||||||
|
if (isLinux && linuxNotificationService) {
|
||||||
|
linuxNotificationService.shutdownLinuxNotificationService().catch((error) => {
|
||||||
|
console.warn("[NotificationWindow] Failed to shutdown Linux notification service:", error);
|
||||||
|
});
|
||||||
|
linuxNotificationService = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!notificationWindow || notificationWindow.isDestroyed()) {
|
||||||
|
notificationWindow = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const win = notificationWindow;
|
||||||
|
notificationWindow = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
win.destroy();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[NotificationWindow] Failed to destroy window:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function createNotificationWindow() {
|
export function createNotificationWindow() {
|
||||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||||
return notificationWindow
|
return notificationWindow;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
const isDev = !!process.env.VITE_DEV_SERVER_URL;
|
||||||
const iconPath = isDev
|
const iconPath = isDev
|
||||||
? join(__dirname, '../../public/icon.ico')
|
? join(__dirname, "../../public/icon.ico")
|
||||||
: join(process.resourcesPath, 'icon.ico')
|
: join(process.resourcesPath, "icon.ico");
|
||||||
|
|
||||||
console.log('[NotificationWindow] Creating window...')
|
console.log("[NotificationWindow] Creating window...");
|
||||||
const width = 344
|
const width = 344;
|
||||||
const height = 114
|
const height = 114;
|
||||||
|
|
||||||
// Update default creation size
|
// Update default creation size
|
||||||
notificationWindow = new BrowserWindow({
|
notificationWindow = new BrowserWindow({
|
||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
type: 'toolbar', // 有助于在某些操作系统上保持置顶
|
type: "toolbar", // 有助于在某些操作系统上保持置顶
|
||||||
frame: false,
|
frame: false,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
resizable: false,
|
resizable: false,
|
||||||
@@ -33,15 +78,15 @@ export function createNotificationWindow() {
|
|||||||
focusable: false, // 不抢占焦点
|
focusable: false, // 不抢占焦点
|
||||||
icon: iconPath,
|
icon: iconPath,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: join(__dirname, 'preload.js'), // FIX: Use correct relative path (same dir in dist)
|
preload: join(__dirname, "preload.js"), // FIX: Use correct relative path (same dir in dist)
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
// devTools: true // Enable DevTools
|
// devTools: true // Enable DevTools
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
// notificationWindow.webContents.openDevTools({ mode: 'detach' }) // DEBUG: Force Open DevTools
|
// notificationWindow.webContents.openDevTools({ mode: 'detach' }) // DEBUG: Force Open DevTools
|
||||||
notificationWindow.setIgnoreMouseEvents(true, { forward: true }) // 初始点击穿透
|
notificationWindow.setIgnoreMouseEvents(true, { forward: true }); // 初始点击穿透
|
||||||
|
|
||||||
// 处理鼠标事件 (如果需要从渲染进程转发,但目前特定区域处理?)
|
// 处理鼠标事件 (如果需要从渲染进程转发,但目前特定区域处理?)
|
||||||
// 实际上,我们希望窗口可点击。
|
// 实际上,我们希望窗口可点击。
|
||||||
@@ -49,132 +94,228 @@ export function createNotificationWindow() {
|
|||||||
|
|
||||||
const loadUrl = isDev
|
const loadUrl = isDev
|
||||||
? `${process.env.VITE_DEV_SERVER_URL}#/notification-window`
|
? `${process.env.VITE_DEV_SERVER_URL}#/notification-window`
|
||||||
: `file://${join(__dirname, '../dist/index.html')}#/notification-window`
|
: `file://${join(__dirname, "../dist/index.html")}#/notification-window`;
|
||||||
|
|
||||||
console.log('[NotificationWindow] Loading URL:', loadUrl)
|
console.log("[NotificationWindow] Loading URL:", loadUrl);
|
||||||
notificationWindow.loadURL(loadUrl)
|
notificationWindow.loadURL(loadUrl);
|
||||||
|
|
||||||
notificationWindow.on('closed', () => {
|
notificationWindow.on("closed", () => {
|
||||||
notificationWindow = null
|
notificationWindow = null;
|
||||||
})
|
});
|
||||||
|
|
||||||
return notificationWindow
|
return notificationWindow;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function showNotification(data: any) {
|
export async function showNotification(data: any) {
|
||||||
// 先检查配置
|
// 先检查配置
|
||||||
const config = ConfigService.getInstance()
|
const config = ConfigService.getInstance();
|
||||||
const enabled = await config.get('notificationEnabled')
|
const enabled = await config.get("notificationEnabled");
|
||||||
if (enabled === false) return // 默认为 true
|
if (enabled === false) return; // 默认为 true
|
||||||
|
|
||||||
// 检查会话过滤
|
// 检查会话过滤
|
||||||
const filterMode = config.get('notificationFilterMode') || 'all'
|
const filterMode = config.get("notificationFilterMode") || "all";
|
||||||
const filterList = config.get('notificationFilterList') || []
|
const filterList = config.get("notificationFilterList") || [];
|
||||||
const sessionId = data.sessionId
|
const sessionId = data.sessionId;
|
||||||
|
|
||||||
if (sessionId && filterMode !== 'all' && filterList.length > 0) {
|
if (sessionId && filterMode !== "all" && filterList.length > 0) {
|
||||||
const isInList = filterList.includes(sessionId)
|
const isInList = filterList.includes(sessionId);
|
||||||
if (filterMode === 'whitelist' && !isInList) {
|
if (filterMode === "whitelist" && !isInList) {
|
||||||
// 白名单模式:不在列表中则不显示
|
// 白名单模式:不在列表中则不显示
|
||||||
console.log('[NotificationWindow] Filtered by whitelist:', sessionId)
|
return;
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if (filterMode === 'blacklist' && isInList) {
|
if (filterMode === "blacklist" && isInList) {
|
||||||
// 黑名单模式:在列表中则不显示
|
// 黑名单模式:在列表中则不显示
|
||||||
console.log('[NotificationWindow] Filtered by blacklist:', sessionId)
|
return;
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let win = notificationWindow
|
// Linux 使用 D-Bus 通知
|
||||||
|
if (isLinux) {
|
||||||
|
await showLinuxNotification(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let win = notificationWindow;
|
||||||
if (!win || win.isDestroyed()) {
|
if (!win || win.isDestroyed()) {
|
||||||
win = createNotificationWindow()
|
win = createNotificationWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!win) return
|
if (!win) return;
|
||||||
|
|
||||||
// 确保加载完成
|
// 确保加载完成
|
||||||
if (win.webContents.isLoading()) {
|
if (win.webContents.isLoading()) {
|
||||||
win.once('ready-to-show', () => {
|
win.once("ready-to-show", () => {
|
||||||
showAndSend(win!, data)
|
showAndSend(win!, data);
|
||||||
})
|
});
|
||||||
} else {
|
} else {
|
||||||
showAndSend(win, data)
|
showAndSend(win, data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let lastNotificationData: any = null
|
// 显示Linux通知
|
||||||
|
async function showLinuxNotification(data: any) {
|
||||||
async function showAndSend(win: BrowserWindow, data: any) {
|
if (!linuxNotificationService) {
|
||||||
lastNotificationData = data
|
try {
|
||||||
const config = ConfigService.getInstance()
|
linuxNotificationService =
|
||||||
const position = (await config.get('notificationPosition')) || 'top-right'
|
await import("../services/linuxNotificationService");
|
||||||
|
} catch (error) {
|
||||||
// 更新位置
|
console.error(
|
||||||
const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize
|
"[NotificationWindow] Failed to load Linux notification service:",
|
||||||
const winWidth = 344
|
error,
|
||||||
const winHeight = 114
|
);
|
||||||
const padding = 20
|
return;
|
||||||
|
}
|
||||||
let x = 0
|
|
||||||
let y = 0
|
|
||||||
|
|
||||||
switch (position) {
|
|
||||||
case 'top-right':
|
|
||||||
x = screenWidth - winWidth - padding
|
|
||||||
y = padding
|
|
||||||
break
|
|
||||||
case 'bottom-right':
|
|
||||||
x = screenWidth - winWidth - padding
|
|
||||||
y = screenHeight - winHeight - padding
|
|
||||||
break
|
|
||||||
case 'top-left':
|
|
||||||
x = padding
|
|
||||||
y = padding
|
|
||||||
break
|
|
||||||
case 'bottom-left':
|
|
||||||
x = padding
|
|
||||||
y = screenHeight - winHeight - padding
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
|
||||||
win.setPosition(Math.floor(x), Math.floor(y))
|
const { showLinuxNotification: showNotification } = linuxNotificationService;
|
||||||
win.setSize(winWidth, winHeight) // 确保尺寸
|
|
||||||
|
const notificationData = {
|
||||||
|
title: data.title,
|
||||||
|
content: data.content,
|
||||||
|
avatarUrl: data.avatarUrl,
|
||||||
|
sessionId: data.sessionId,
|
||||||
|
expireTimeout: 5000,
|
||||||
|
};
|
||||||
|
|
||||||
|
showNotification(notificationData);
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastNotificationData: any = null;
|
||||||
|
|
||||||
|
async function showAndSend(win: BrowserWindow, data: any) {
|
||||||
|
lastNotificationData = data;
|
||||||
|
const config = ConfigService.getInstance();
|
||||||
|
const position = (await config.get("notificationPosition")) || "top-right";
|
||||||
|
|
||||||
|
// 更新位置
|
||||||
|
const { width: screenWidth, height: screenHeight } =
|
||||||
|
screen.getPrimaryDisplay().workAreaSize;
|
||||||
|
const winWidth = position === "top-center" ? 280 : 344;
|
||||||
|
const winHeight = 114;
|
||||||
|
const padding = 20;
|
||||||
|
|
||||||
|
let x = 0;
|
||||||
|
let y = 0;
|
||||||
|
|
||||||
|
switch (position) {
|
||||||
|
case "top-center":
|
||||||
|
x = (screenWidth - winWidth) / 2;
|
||||||
|
y = padding;
|
||||||
|
break;
|
||||||
|
case "top-right":
|
||||||
|
x = screenWidth - winWidth - padding;
|
||||||
|
y = padding;
|
||||||
|
break;
|
||||||
|
case "bottom-right":
|
||||||
|
x = screenWidth - winWidth - padding;
|
||||||
|
y = screenHeight - winHeight - padding;
|
||||||
|
break;
|
||||||
|
case "top-left":
|
||||||
|
x = padding;
|
||||||
|
y = padding;
|
||||||
|
break;
|
||||||
|
case "bottom-left":
|
||||||
|
x = padding;
|
||||||
|
y = screenHeight - winHeight - padding;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
win.setPosition(Math.floor(x), Math.floor(y));
|
||||||
|
win.setSize(winWidth, winHeight); // 确保尺寸
|
||||||
|
|
||||||
// 设为可交互
|
// 设为可交互
|
||||||
win.setIgnoreMouseEvents(false)
|
win.setIgnoreMouseEvents(false);
|
||||||
win.showInactive() // 显示但不聚焦
|
win.showInactive(); // 显示但不聚焦
|
||||||
win.setAlwaysOnTop(true, 'screen-saver') // 最高层级
|
win.setAlwaysOnTop(true, "screen-saver"); // 最高层级
|
||||||
|
|
||||||
win.webContents.send('notification:show', data)
|
win.webContents.send("notification:show", { ...data, position });
|
||||||
|
|
||||||
// 自动关闭计时器通常由渲染进程管理
|
// 自动关闭计时器通常由渲染进程管理
|
||||||
// 渲染进程发送 'notification:close' 来隐藏窗口
|
// 渲染进程发送 'notification:close' 来隐藏窗口
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerNotificationHandlers() {
|
// 注册通知处理
|
||||||
ipcMain.handle('notification:show', (_, data) => {
|
export async function registerNotificationHandlers() {
|
||||||
showNotification(data)
|
// Linux: 初始化D-Bus服务
|
||||||
})
|
if (isLinux) {
|
||||||
|
try {
|
||||||
|
const linuxNotificationModule =
|
||||||
|
await import("../services/linuxNotificationService");
|
||||||
|
linuxNotificationService = linuxNotificationModule;
|
||||||
|
|
||||||
ipcMain.handle('notification:close', () => {
|
// 初始化服务
|
||||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
await linuxNotificationModule.initLinuxNotificationService();
|
||||||
notificationWindow.hide()
|
|
||||||
notificationWindow.setIgnoreMouseEvents(true, { forward: true })
|
// 在Linux上注册通知点击回调
|
||||||
|
linuxNotificationModule.onNotificationAction((sessionId: string) => {
|
||||||
|
console.log(
|
||||||
|
"[NotificationWindow] Linux notification clicked, sessionId:",
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
// 如果设置了导航处理程序,则使用该处理程序;否则,回退到ipcMain方法。
|
||||||
|
if (onNotificationNavigate) {
|
||||||
|
onNotificationNavigate(sessionId);
|
||||||
|
} else {
|
||||||
|
// 如果尚未设置处理程序,则通过ipcMain发出事件
|
||||||
|
// 正常流程中不应该发生这种情况,因为我们在初始化之前设置了处理程序。
|
||||||
|
console.warn(
|
||||||
|
"[NotificationWindow] onNotificationNavigate not set yet",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"[NotificationWindow] Linux notification service initialized",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"[NotificationWindow] Failed to initialize Linux notification service:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcMain.handle("notification:show", (_, data) => {
|
||||||
|
showNotification(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("notification:close", () => {
|
||||||
|
if (isLinux && linuxNotificationService) {
|
||||||
|
// 注册通知点击回调函数。Linux通知通过D-Bus自动关闭,但我们可以根据需要进行跟踪
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||||
|
notificationWindow.hide();
|
||||||
|
notificationWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Handle renderer ready event (fix race condition)
|
// Handle renderer ready event (fix race condition)
|
||||||
ipcMain.on('notification:ready', (event) => {
|
ipcMain.on("notification:ready", (event) => {
|
||||||
console.log('[NotificationWindow] Renderer ready, checking cached data')
|
if (isLinux) {
|
||||||
if (lastNotificationData && notificationWindow && !notificationWindow.isDestroyed()) {
|
// Linux不需要通知窗口,拦截通知窗口渲染
|
||||||
console.log('[NotificationWindow] Re-sending cached data')
|
return;
|
||||||
notificationWindow.webContents.send('notification:show', lastNotificationData)
|
|
||||||
}
|
}
|
||||||
})
|
console.log("[NotificationWindow] Renderer ready, checking cached data");
|
||||||
|
if (
|
||||||
|
lastNotificationData &&
|
||||||
|
notificationWindow &&
|
||||||
|
!notificationWindow.isDestroyed()
|
||||||
|
) {
|
||||||
|
console.log("[NotificationWindow] Re-sending cached data");
|
||||||
|
notificationWindow.webContents.send(
|
||||||
|
"notification:show",
|
||||||
|
lastNotificationData,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Handle resize request from renderer
|
// Handle resize request from renderer
|
||||||
ipcMain.on('notification:resize', (event, { width, height }) => {
|
ipcMain.on("notification:resize", (event, { width, height }) => {
|
||||||
|
if (isLinux) {
|
||||||
|
// Linux 通知通过D-Bus自动调整大小
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||||
// Enforce max-height if needed, or trust renderer
|
// Enforce max-height if needed, or trust renderer
|
||||||
// Ensure it doesn't go off screen bottom?
|
// Ensure it doesn't go off screen bottom?
|
||||||
@@ -189,12 +330,12 @@ export function registerNotificationHandlers() {
|
|||||||
// If bottom-right, y needs to prevent overflow.
|
// If bottom-right, y needs to prevent overflow.
|
||||||
|
|
||||||
// Ideally we get current config position
|
// Ideally we get current config position
|
||||||
const bounds = notificationWindow.getBounds()
|
const bounds = notificationWindow.getBounds();
|
||||||
// Check if we need to adjust Y?
|
// Check if we need to adjust Y?
|
||||||
// For now, let's just set the size as requested.
|
// For now, let's just set the size as requested.
|
||||||
notificationWindow.setSize(Math.round(width), Math.round(height))
|
notificationWindow.setSize(Math.round(width), Math.round(height));
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
// 'notification-clicked' 在 main.ts 中处理 (导航)
|
// 'notification-clicked' 在 main.ts 中处理 (导航)
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
mdassets/us.png
BIN
mdassets/us.png
Binary file not shown.
|
Before Width: | Height: | Size: 185 KiB |
5568
package-lock.json
generated
5568
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
136
package.json
136
package.json
@@ -1,28 +1,32 @@
|
|||||||
{
|
{
|
||||||
"name": "weflow",
|
"name": "weflow",
|
||||||
"version": "1.5.0",
|
"version": "4.3.0",
|
||||||
"description": "WeFlow",
|
"description": "WeFlow",
|
||||||
"main": "dist-electron/main.js",
|
"main": "dist-electron/main.js",
|
||||||
"author": "cc",
|
"author": {
|
||||||
|
"name": "cc",
|
||||||
|
"email": "yccccccy@proton.me"
|
||||||
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/hicccc77/WeFlow"
|
"url": "https://github.com/hicccc77/WeFlow"
|
||||||
},
|
},
|
||||||
"//": "二改不应改变此处的作者与应用信息",
|
"//": "二改不应改变此处的作者与应用信息",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "echo 'No native modules to rebuild'",
|
"postinstall": "electron-builder install-app-deps",
|
||||||
"rebuild": "electron-rebuild",
|
"rebuild": "electron-rebuild",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
"build": "tsc && vite build && electron-builder",
|
"build": "tsc && vite build && electron-builder",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"electron:dev": "vite --mode electron",
|
"electron:dev": "vite --mode electron",
|
||||||
"electron:build": "npm run build"
|
"electron:build": "npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^12.5.0",
|
"@vscode/sudo-prompt": "^9.3.2",
|
||||||
"echarts": "^5.5.1",
|
"echarts": "^6.0.0",
|
||||||
"echarts-for-react": "^3.0.2",
|
"echarts-for-react": "^3.0.2",
|
||||||
"electron-store": "^10.0.0",
|
"electron-store": "^11.0.2",
|
||||||
"electron-updater": "^6.3.9",
|
"electron-updater": "^6.3.9",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"ffmpeg-static": "^5.3.0",
|
"ffmpeg-static": "^5.3.0",
|
||||||
@@ -30,32 +34,47 @@
|
|||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"jieba-wasm": "^2.2.0",
|
"jieba-wasm": "^2.2.0",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"koffi": "^2.9.0",
|
"koffi": "^2.15.6",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^1.7.0",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-router-dom": "^7.14.0",
|
||||||
"react-virtuoso": "^4.18.1",
|
"react-virtuoso": "^4.18.1",
|
||||||
"sherpa-onnx-node": "^1.10.38",
|
"remark-gfm": "^4.0.1",
|
||||||
|
"sherpa-onnx-node": "^1.12.35",
|
||||||
"silk-wasm": "^3.7.1",
|
"silk-wasm": "^3.7.1",
|
||||||
"wechat-emojis": "^1.0.2",
|
"wechat-emojis": "^1.0.2",
|
||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron/rebuild": "^4.0.2",
|
"@electron/rebuild": "^4.0.2",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
|
||||||
"@types/react": "^19.1.0",
|
"@types/react": "^19.1.0",
|
||||||
"@types/react-dom": "^19.1.0",
|
"@types/react-dom": "^19.1.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"electron": "^39.2.7",
|
"electron": "^41.1.1",
|
||||||
"electron-builder": "^25.1.8",
|
"electron-builder": "^26.8.1",
|
||||||
"sass": "^1.83.0",
|
"sass": "^1.99.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^6.0.2",
|
||||||
"vite": "^6.0.5",
|
"vite": "^7.3.2",
|
||||||
"vite-plugin-electron": "^0.28.8",
|
"vite-plugin-electron": "^0.29.1",
|
||||||
"vite-plugin-electron-renderer": "^0.14.6"
|
"vite-plugin-electron-renderer": "^0.14.6"
|
||||||
},
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"tar": ">=6.2.1",
|
||||||
|
"minimatch": ">=3.1.2",
|
||||||
|
"rollup": ">=4.0.0",
|
||||||
|
"immutable": ">=4.0.0",
|
||||||
|
"lodash": ">=4.17.21",
|
||||||
|
"brace-expansion": ">=1.1.11",
|
||||||
|
"picomatch": ">=2.3.1",
|
||||||
|
"ajv": ">=8.18.0",
|
||||||
|
"ajv-keywords@3>ajv": "^6.12.6",
|
||||||
|
"@develar/schema-utils>ajv": "^6.12.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.WeFlow.app",
|
"appId": "com.WeFlow.app",
|
||||||
"publish": {
|
"publish": {
|
||||||
@@ -69,11 +88,57 @@
|
|||||||
"directories": {
|
"directories": {
|
||||||
"output": "release"
|
"output": "release"
|
||||||
},
|
},
|
||||||
|
"mac": {
|
||||||
|
"target": [
|
||||||
|
"dmg",
|
||||||
|
"zip"
|
||||||
|
],
|
||||||
|
"category": "public.app-category.utilities",
|
||||||
|
"hardenedRuntime": false,
|
||||||
|
"gatekeeperAssess": false,
|
||||||
|
"entitlements": "electron/entitlements.mac.plist",
|
||||||
|
"entitlementsInherit": "electron/entitlements.mac.plist",
|
||||||
|
"icon": "resources/icons/macos/icon.icns"
|
||||||
|
},
|
||||||
"win": {
|
"win": {
|
||||||
"target": [
|
"target": [
|
||||||
"nsis"
|
"nsis"
|
||||||
],
|
],
|
||||||
"icon": "public/icon.ico"
|
"icon": "public/icon.ico",
|
||||||
|
"extraFiles": [
|
||||||
|
{
|
||||||
|
"from": "resources/runtime/win32/msvcp140.dll",
|
||||||
|
"to": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "resources/runtime/win32/msvcp140_1.dll",
|
||||||
|
"to": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "resources/runtime/win32/vcruntime140.dll",
|
||||||
|
"to": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "resources/runtime/win32/vcruntime140_1.dll",
|
||||||
|
"to": "."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"icon": "public/icon.png",
|
||||||
|
"target": [
|
||||||
|
"appimage",
|
||||||
|
"tar.gz"
|
||||||
|
],
|
||||||
|
"category": "Utility",
|
||||||
|
"executableName": "weflow",
|
||||||
|
"synopsis": "WeFlow for Linux",
|
||||||
|
"extraFiles": [
|
||||||
|
{
|
||||||
|
"from": "resources/installer/linux/install.sh",
|
||||||
|
"to": "install.sh"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
@@ -104,6 +169,14 @@
|
|||||||
{
|
{
|
||||||
"from": "public/icon.ico",
|
"from": "public/icon.ico",
|
||||||
"to": "icon.ico"
|
"to": "icon.ico"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "public/icon.png",
|
||||||
|
"to": "icon.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "electron/assets/wasm/",
|
||||||
|
"to": "assets/wasm/"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"files": [
|
"files": [
|
||||||
@@ -112,25 +185,16 @@
|
|||||||
],
|
],
|
||||||
"asarUnpack": [
|
"asarUnpack": [
|
||||||
"node_modules/silk-wasm/**/*",
|
"node_modules/silk-wasm/**/*",
|
||||||
"node_modules/sherpa-onnx-node/**/*"
|
"node_modules/sherpa-onnx-node/**/*",
|
||||||
|
"node_modules/sherpa-onnx-*/*",
|
||||||
|
"node_modules/sherpa-onnx-*/**/*",
|
||||||
|
"node_modules/ffmpeg-static/**/*"
|
||||||
],
|
],
|
||||||
"extraFiles": [
|
"icon": "resources/icons/macos/icon.icns"
|
||||||
{
|
|
||||||
"from": "resources/msvcp140.dll",
|
|
||||||
"to": "."
|
|
||||||
},
|
},
|
||||||
{
|
"overrides": {
|
||||||
"from": "resources/msvcp140_1.dll",
|
"picomatch": "^4.0.4",
|
||||||
"to": "."
|
"tar": "^7.5.13",
|
||||||
},
|
"immutable": "^5.1.5"
|
||||||
{
|
|
||||||
"from": "resources/vcruntime140.dll",
|
|
||||||
"to": "."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"from": "resources/vcruntime140_1.dll",
|
|
||||||
"to": "."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/icon.ico
BIN
public/icon.ico
Binary file not shown.
|
Before Width: | Height: | Size: 212 KiB After Width: | Height: | Size: 364 KiB |
BIN
public/icon.png
Normal file
BIN
public/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 570 KiB |
BIN
public/logo.png
BIN
public/logo.png
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 570 KiB |
249
public/splash.html
Normal file
249
public/splash.html
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>WeFlow</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
background: transparent;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splash {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
border-radius: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 品牌区 */
|
||||||
|
.brand {
|
||||||
|
padding: 48px 52px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
animation: fadeIn 0.4s ease both;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
width: 56px; height: 56px;
|
||||||
|
border-radius: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.app-name {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
.app-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 5px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer { flex: 1; }
|
||||||
|
|
||||||
|
/* 底部进度区 */
|
||||||
|
.bottom {
|
||||||
|
padding: 0 48px 40px;
|
||||||
|
animation: fadeIn 0.4s ease 0.1s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 进度条轨道 */
|
||||||
|
.progress-track {
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 进度条填充 */
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
width: 0%;
|
||||||
|
border-radius: 2px;
|
||||||
|
position: relative;
|
||||||
|
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 扫光:只在有进度时显示,不循环 */
|
||||||
|
.progress-fill::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.5) 50%, transparent 100%);
|
||||||
|
animation: sweep 1.2s ease-out forwards;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 等待阶段:进度条末端呼吸光点 */
|
||||||
|
.progress-fill.waiting::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -1px; right: -2px;
|
||||||
|
width: 6px; height: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: inherit;
|
||||||
|
filter: blur(2px);
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.progress-text {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.38;
|
||||||
|
}
|
||||||
|
.version {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes sweep {
|
||||||
|
0% { opacity: 0; transform: translateX(-100%); }
|
||||||
|
20% { opacity: 1; }
|
||||||
|
80% { opacity: 1; }
|
||||||
|
100% { opacity: 0; transform: translateX(100%); }
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 0.4; transform: scaleX(1); }
|
||||||
|
50% { opacity: 1; transform: scaleX(1.8); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="splash" id="splash">
|
||||||
|
<div class="brand">
|
||||||
|
<img class="logo" src="./logo.png" alt="WeFlow" />
|
||||||
|
<div class="brand-text">
|
||||||
|
<div class="app-name" id="appName">WeFlow</div>
|
||||||
|
<div class="app-desc" id="appDesc">微信聊天记录管理工具</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="spacer"></div>
|
||||||
|
|
||||||
|
<div class="bottom">
|
||||||
|
<div class="progress-track" id="progressTrack">
|
||||||
|
<div class="progress-fill" id="progressFill"></div>
|
||||||
|
</div>
|
||||||
|
<div class="bottom-row">
|
||||||
|
<div class="progress-text" id="progressText">正在启动...</div>
|
||||||
|
<div class="version" id="versionText"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var themes = {
|
||||||
|
'cloud-dancer': {
|
||||||
|
light: { primary: '#8B7355', bg: '#F0EEE9', bgEnd: '#E5E1DA', text: '#3d3d3d', desc: '#8B7355' },
|
||||||
|
dark: { primary: '#C9A86C', bg: '#1a1816', bgEnd: '#252220', text: '#F0EEE9', desc: '#C9A86C' }
|
||||||
|
},
|
||||||
|
'corundum-blue': {
|
||||||
|
light: { primary: '#4A6670', bg: '#E8EEF0', bgEnd: '#D8E4E8', text: '#3d3d3d', desc: '#4A6670' },
|
||||||
|
dark: { primary: '#6A9AAA', bg: '#141a1c', bgEnd: '#1e2a2e', text: '#E0EEF2', desc: '#6A9AAA' }
|
||||||
|
},
|
||||||
|
'kiwi-green': {
|
||||||
|
light: { primary: '#7A9A5C', bg: '#E8F0E4', bgEnd: '#D8E8D2', text: '#3d3d3d', desc: '#7A9A5C' },
|
||||||
|
dark: { primary: '#9ABA7C', bg: '#161a14', bgEnd: '#222a1e', text: '#E8F0E4', desc: '#9ABA7C' }
|
||||||
|
},
|
||||||
|
'spicy-red': {
|
||||||
|
light: { primary: '#8B4049', bg: '#F0E8E8', bgEnd: '#E8D8D8', text: '#3d3d3d', desc: '#8B4049' },
|
||||||
|
dark: { primary: '#C06068', bg: '#1a1416', bgEnd: '#261e20', text: '#F2E8EA', desc: '#C06068' }
|
||||||
|
},
|
||||||
|
'teal-water': {
|
||||||
|
light: { primary: '#5A8A8A', bg: '#E4F0F0', bgEnd: '#D2E8E8', text: '#3d3d3d', desc: '#5A8A8A' },
|
||||||
|
dark: { primary: '#7ABAAA', bg: '#121a1a', bgEnd: '#1a2626', text: '#E0F2EE', desc: '#7ABAAA' }
|
||||||
|
},
|
||||||
|
'blossom-dream': {
|
||||||
|
light: { primary: '#D4849A', primaryEnd: '#D4849A', bg: '#FCF9FB', bgMid: '#F8F2F8', bgEnd: '#F2F6FB', text: '#2E2633', desc: '#D4849A' },
|
||||||
|
dark: { primary: '#C670C3', primaryEnd: '#8A60C0', bg: '#120B16', bgMid: '#1A1020', bgEnd: '#0E0B18', text: '#F2EAF4', desc: '#C670C3' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function applyTheme(themeId, mode) {
|
||||||
|
var t = themes[themeId] || themes['cloud-dancer'];
|
||||||
|
var isDark = mode === 'dark';
|
||||||
|
if (mode === 'system') isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
var c = isDark ? t.dark : t.light;
|
||||||
|
|
||||||
|
var el = document.getElementById('splash');
|
||||||
|
var fill = document.getElementById('progressFill');
|
||||||
|
|
||||||
|
if (themeId === 'blossom-dream') {
|
||||||
|
if (isDark) {
|
||||||
|
// 深色
|
||||||
|
el.style.background =
|
||||||
|
'radial-gradient(ellipse 60% 50% at 100% 0%, ' + c.primary + '28 0%, transparent 70%), ' +
|
||||||
|
'linear-gradient(150deg, ' + c.bg + ' 0%, ' + c.bgMid + ' 45%, ' + c.bgEnd + ' 100%)';
|
||||||
|
} else {
|
||||||
|
// 浅色
|
||||||
|
el.style.background = 'linear-gradient(150deg, ' + c.bg + ' 0%, ' + c.bgMid + ' 45%, ' + c.bgEnd + ' 100%)';
|
||||||
|
}
|
||||||
|
// 进度条
|
||||||
|
fill.style.background = 'linear-gradient(90deg, ' + c.primary + ' 0%, ' + c.primaryEnd + ' 100%)';
|
||||||
|
} else {
|
||||||
|
if (isDark) {
|
||||||
|
el.style.background =
|
||||||
|
'radial-gradient(ellipse 60% 50% at 100% 0%, ' + c.primary + '22 0%, transparent 70%), ' +
|
||||||
|
'linear-gradient(145deg, ' + c.bg + ' 0%, ' + c.bgEnd + ' 100%)';
|
||||||
|
} else {
|
||||||
|
el.style.background = 'linear-gradient(150deg, ' + c.bg + ' 0%, ' + c.bgEnd + ' 100%)';
|
||||||
|
}
|
||||||
|
fill.style.background = c.primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('appName').style.color = c.text;
|
||||||
|
document.getElementById('appDesc').style.color = c.desc;
|
||||||
|
document.getElementById('progressText').style.color = c.text;
|
||||||
|
document.getElementById('versionText').style.color = c.text;
|
||||||
|
document.getElementById('progressTrack').style.background = c.primary + (isDark ? '25' : '18');
|
||||||
|
}
|
||||||
|
|
||||||
|
// percent: 实际进度值;waiting: 是否处于等待阶段
|
||||||
|
function updateProgress(percent, text, waiting) {
|
||||||
|
var fill = document.getElementById('progressFill');
|
||||||
|
var label = document.getElementById('progressText');
|
||||||
|
|
||||||
|
if (fill) {
|
||||||
|
fill.style.width = percent + '%';
|
||||||
|
if (waiting) {
|
||||||
|
fill.classList.add('waiting');
|
||||||
|
} else {
|
||||||
|
fill.classList.remove('waiting');
|
||||||
|
// 触发扫光:重置动画
|
||||||
|
fill.style.animation = 'none';
|
||||||
|
fill.offsetHeight;
|
||||||
|
fill.style.animation = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (label && text) label.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVersion(ver) {
|
||||||
|
var el = document.getElementById('versionText');
|
||||||
|
if (el) el.textContent = 'v' + ver;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTheme('cloud-dancer', 'light');
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
resources/icons/macos/icon.icns
Normal file
BIN
resources/icons/macos/icon.icns
Normal file
Binary file not shown.
59
resources/installer/linux/install.sh
Normal file
59
resources/installer/linux/install.sh
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
APP_NAME="weflow"
|
||||||
|
APP_EXEC="weflow"
|
||||||
|
OPT_DIR="/opt/$APP_NAME"
|
||||||
|
BIN_LINK="/usr/bin/$APP_NAME"
|
||||||
|
DESKTOP_DIR="/usr/share/applications"
|
||||||
|
ICON_DIR="/usr/share/pixmaps"
|
||||||
|
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
echo "❌ 请使用 root 权限运行此脚本 (例如: sudo ./install.sh)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🚀 开始安装 $APP_NAME..."
|
||||||
|
|
||||||
|
echo "📦 正在复制文件到 $OPT_DIR..."
|
||||||
|
rm -rf "$OPT_DIR"
|
||||||
|
mkdir -p "$OPT_DIR"
|
||||||
|
cp -r ./* "$OPT_DIR/"
|
||||||
|
chmod -R 755 "$OPT_DIR"
|
||||||
|
chmod +x "$OPT_DIR/$APP_EXEC"
|
||||||
|
|
||||||
|
echo "🔗 正在创建软链接 $BIN_LINK..."
|
||||||
|
ln -sf "$OPT_DIR/$APP_EXEC" "$BIN_LINK"
|
||||||
|
|
||||||
|
echo "📝 正在创建桌面快捷方式..."
|
||||||
|
cat <<EOF >"$DESKTOP_DIR/${APP_NAME}.desktop"
|
||||||
|
[Desktop Entry]
|
||||||
|
Name=WeFlow
|
||||||
|
Exec=$OPT_DIR/$APP_EXEC %U
|
||||||
|
Terminal=false
|
||||||
|
Type=Application
|
||||||
|
Icon=$APP_NAME
|
||||||
|
StartupWMClass=WeFlow
|
||||||
|
Comment=A local WeChat database decryption and analysis tool
|
||||||
|
Categories=Utility;
|
||||||
|
EOF
|
||||||
|
chmod 644 "$DESKTOP_DIR/${APP_NAME}.desktop"
|
||||||
|
|
||||||
|
echo "🖼️ 正在安装图标..."
|
||||||
|
if [ -f "$OPT_DIR/resources/icon.png" ]; then
|
||||||
|
cp "$OPT_DIR/resources/icon.png" "$ICON_DIR/${APP_NAME}.png"
|
||||||
|
chmod 644 "$ICON_DIR/${APP_NAME}.png"
|
||||||
|
elif [ -f "$OPT_DIR/icon.png" ]; then
|
||||||
|
cp "$OPT_DIR/icon.png" "$ICON_DIR/${APP_NAME}.png"
|
||||||
|
chmod 644 "$ICON_DIR/${APP_NAME}.png"
|
||||||
|
else
|
||||||
|
echo "⚠️ 警告: 未找到图标文件,跳过图标安装。"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v update-desktop-database >/dev/null 2>&1; then
|
||||||
|
echo "🔄 更新桌面数据库..."
|
||||||
|
update-desktop-database "$DESKTOP_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ 安装完成!你现在可以在应用菜单中找到 WeFlow,或者在终端输入 'weflow' 启动。"
|
||||||
BIN
resources/key/linux/x64/xkey_helper_linux
Normal file
BIN
resources/key/linux/x64/xkey_helper_linux
Normal file
Binary file not shown.
10
resources/key/macos/source/image_scan_entitlements.plist
Normal file
10
resources/key/macos/source/image_scan_entitlements.plist
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.cs.debugger</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
77
resources/key/macos/source/image_scan_helper.c
Normal file
77
resources/key/macos/source/image_scan_helper.c
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/*
|
||||||
|
* image_scan_helper - 轻量包装程序
|
||||||
|
* 加载 libwx_key.dylib 并调用 ScanMemoryForImageKey
|
||||||
|
* 用法: image_scan_helper <pid> <ciphertext_hex>
|
||||||
|
* 输出: JSON {"success":true,"aesKey":"..."} 或 {"success":false,"error":"..."}
|
||||||
|
*/
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <dlfcn.h>
|
||||||
|
#include <libgen.h>
|
||||||
|
#include <mach-o/dyld.h>
|
||||||
|
|
||||||
|
typedef const char* (*ScanMemoryForImageKeyFn)(int pid, const char* ciphertext);
|
||||||
|
typedef void (*FreeStringFn)(const char* str);
|
||||||
|
|
||||||
|
int main(int argc, char* argv[]) {
|
||||||
|
if (argc != 3) {
|
||||||
|
fprintf(stderr, "Usage: %s <pid> <ciphertext_hex>\n", argv[0]);
|
||||||
|
printf("{\"success\":false,\"error\":\"invalid arguments\"}\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int pid = atoi(argv[1]);
|
||||||
|
const char* ciphertext_hex = argv[2];
|
||||||
|
|
||||||
|
if (pid <= 0) {
|
||||||
|
printf("{\"success\":false,\"error\":\"invalid pid\"}\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 定位 dylib: 与自身同目录下的 libwx_key.dylib */
|
||||||
|
char exe_path[4096];
|
||||||
|
uint32_t size = sizeof(exe_path);
|
||||||
|
if (_NSGetExecutablePath(exe_path, &size) != 0) {
|
||||||
|
printf("{\"success\":false,\"error\":\"cannot get executable path\"}\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
char* dir = dirname(exe_path);
|
||||||
|
char dylib_path[4096];
|
||||||
|
snprintf(dylib_path, sizeof(dylib_path), "%s/libwx_key.dylib", dir);
|
||||||
|
|
||||||
|
void* handle = dlopen(dylib_path, RTLD_LAZY);
|
||||||
|
if (!handle) {
|
||||||
|
printf("{\"success\":false,\"error\":\"dlopen failed: %s\"}\n", dlerror());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
ScanMemoryForImageKeyFn scan_fn = (ScanMemoryForImageKeyFn)dlsym(handle, "ScanMemoryForImageKey");
|
||||||
|
if (!scan_fn) {
|
||||||
|
printf("{\"success\":false,\"error\":\"symbol not found: ScanMemoryForImageKey\"}\n");
|
||||||
|
dlclose(handle);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
FreeStringFn free_fn = (FreeStringFn)dlsym(handle, "FreeString");
|
||||||
|
|
||||||
|
fprintf(stderr, "[image_scan_helper] calling ScanMemoryForImageKey(pid=%d, ciphertext=%s)\n", pid, ciphertext_hex);
|
||||||
|
|
||||||
|
const char* result = scan_fn(pid, ciphertext_hex);
|
||||||
|
|
||||||
|
if (result && strlen(result) > 0) {
|
||||||
|
/* 检查是否是错误 */
|
||||||
|
if (strncmp(result, "ERROR", 5) == 0) {
|
||||||
|
printf("{\"success\":false,\"error\":\"%s\"}\n", result);
|
||||||
|
} else {
|
||||||
|
printf("{\"success\":true,\"aesKey\":\"%s\"}\n", result);
|
||||||
|
}
|
||||||
|
if (free_fn) free_fn(result);
|
||||||
|
} else {
|
||||||
|
printf("{\"success\":false,\"error\":\"no key found\"}\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
dlclose(handle);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
BIN
resources/key/macos/universal/image_scan_helper
Normal file
BIN
resources/key/macos/universal/image_scan_helper
Normal file
Binary file not shown.
BIN
resources/key/macos/universal/libwx_key.dylib
Normal file
BIN
resources/key/macos/universal/libwx_key.dylib
Normal file
Binary file not shown.
BIN
resources/key/macos/universal/xkey_helper
Normal file
BIN
resources/key/macos/universal/xkey_helper
Normal file
Binary file not shown.
BIN
resources/key/macos/universal/xkey_helper_macos
Normal file
BIN
resources/key/macos/universal/xkey_helper_macos
Normal file
Binary file not shown.
BIN
resources/key/win32/x64/wx_key.dll
Normal file
BIN
resources/key/win32/x64/wx_key.dll
Normal file
Binary file not shown.
BIN
resources/wcdb/linux/x64/libwcdb_api.so
Normal file
BIN
resources/wcdb/linux/x64/libwcdb_api.so
Normal file
Binary file not shown.
BIN
resources/wcdb/macos/universal/libWCDB.dylib
Normal file
BIN
resources/wcdb/macos/universal/libWCDB.dylib
Normal file
Binary file not shown.
BIN
resources/wcdb/macos/universal/libwcdb_api.dylib
Normal file
BIN
resources/wcdb/macos/universal/libwcdb_api.dylib
Normal file
Binary file not shown.
BIN
resources/wcdb/win32/arm64/WCDB.dll
Normal file
BIN
resources/wcdb/win32/arm64/WCDB.dll
Normal file
Binary file not shown.
BIN
resources/wcdb/win32/arm64/wcdb_api.dll
Normal file
BIN
resources/wcdb/win32/arm64/wcdb_api.dll
Normal file
Binary file not shown.
BIN
resources/wcdb/win32/x64/wcdb_api.dll
Normal file
BIN
resources/wcdb/win32/x64/wcdb_api.dll
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user